Про ARC, weak/unowned и retain cycle на русском написано столько, что добавить нечего. Про borrowing/consuming и ~Copyable (это, к слову, Swift 5.9, а не 6 - частая путаница) на английском тоже десятки разборов. И почти все они устроены одинаково: объясняют, что фича делает, на игрушечном примере с файловым дескриптором, и заканчивают выводом «так безопаснее и быстрее».

Мне захотелось зайти иначе. Я взял три «памятных» нововведения Swift 6.2, написал на них бенчмарки, прогнал на релизном тулчейне и полез в сгенерированный ассемблер. Вопрос меня занимал не «что это делает», а куда более практичный: дают ли эти фичи реальный выигрыш в цифрах - и в каких случаях оптимизатор Swift давно делает то же самое за вас, бесплатно и молча. Ответ оказался менее очевидным, чем их маркетинг.

Статью я старался написать так, чтобы её читал не только стафф. Поэтому по ходу я коротко объясняю базовые вещи - стек и кучу, ARC, copy-on-write. Если вы это и так знаете, просто пролистывайте врезки «на полях».

О чём это и почему не «ещё одна статья про ARC»

Swift 6.2 принёс три вещи, которые напрямую касаются памяти:

  • InlineArray - массив фиксированного размера, который хранит элементы прямо внутри себя (обычно на стеке), без отдельной аллокации в куче, без copy-on-write и без подсчёта ссылок. Опирается на value generics - это когда параметр дженерика не тип, а число.

  • Span / MutableSpan - безопасное невладеющее «окно» в непрерывную область памяти, замена unsafe-указателям. Под капотом - non-escapable типы (~Escapable) и lifetime dependencies.

  • Строгая memory safety (opt-in) - режим компилятора, который подсвечивает небезопасные конструкции. Это не про скорость, это про диагностику.

Главный тезис, который я держу до самого конца: самое интересное - там, где фича не даёт выигрыша. Потому что во многих случаях компилятор уже разместил ваш массив на стеке сам и уже убрал лишний подсчёт ссылок. Никаких «X всегда быстрее» здесь не будет.

На полях: стек и куча, если совсем коротко. Стек - это быстрая «стопка тарелок» на время работы функции: положили сверху, по выходу из функции сняли, всё автоматически. Куча (heap) устроена как общий «склад»: туда кладут то, что должно жить дольше одной функции. Но за это платят: аллокация в куче дороже, а за временем жизни объекта кто-то должен следить (в Swift этим занимается ARC, о нём ниже). Если совсем грубо: стек дёшев и прост, куча дороже. Поэтому «убрать поход в кучу» и звучит как выигрыш - вот это мы и будем проверять.


Раздел 0. Как я мерил (не пропускайте - иначе цифрам нельзя верить)

Микробенчмарки врут по умолчанию. Наносекундные замеры чувствительны ко всему: к режиму сборки, к тому, не выкинул ли оптимизатор ваш код целиком, к случайному шуму системы. Поэтому прежде чем показывать числа, зафиксируем условия - иначе это будут красивые, но бессмысленные цифры.

Окружение

Все замеры сделаны на одной машине. Полный код и скрипт, который повторяет весь прогон одной командой, лежат в репозитории (ссылка в конце).

Параметр

Значение

Swift

Apple Swift 6.2.4 (swiftlang-6.2.4.1.4 clang-1700.6.4.2)

Xcode

26.3 (17C529)

ОС

macOS 26 (Darwin 25.3.0), arm64

Чип

Apple M4 Pro, 24 ГБ RAM

Флаги сборки

-O (release)

Гарнитура

собственный харнесс с перцентилями, без внешних зависимостей

Отдельно про доступность: часть API вокруг InlineArray/Span привязана к свежим версиям appleOS. На Linux со свежим тулчейном собирается многое, но не всё и не со всеми conformance’ами. Так что если вы гоняете это на другой платформе - проверяйте отдельно, картина может отличаться.

Три правила, без которых замер недействителен

1. Только -O (release). В debug-сборке (-Onone) картина бессмысленная: там ARC и аллокации раскиданы повсюду, и InlineArray «выигрывает» у Array по совершенно случайным причинам. Мерить нужно то, что уедет в прод, - релизную сборку.

2. Не дать оптимизатору съесть ваш бенчмарк. Представьте: вы в цикле создаёте массив, но нигде его не используете. Оптимизатор в режиме -O не дурак - он видит, что результат никому не нужен, и просто удаляет весь ваш код. Вы замеряете пустой цикл и радуетесь «нулю наносекунд». Чтобы этого не случилось, результат нужно «спрятать» от оптимизатора - для этого есть приём под названием blackHole.

3. Перцентили, а не среднее. На наносекундных масштабах среднее арифметическое обманывает: одна случайная задержка от планировщика ОС - и среднее поехало. Поэтому я беру перцентили: p50 (медиана, «типичный» случай), p90 и p99 (хвосты, «а как в худшем случае»).

Что такое blackHole и зачем он нужен

blackHole - это функция-«заглушка», которая убеждает оптимизатор, что результат вычисления кому-то нужен и выбрасывать его нельзя. Трюк в двух вещах: функцию помечают @inline(never) (оптимизатор не видит её тело и вынужден по-настоящему вычислить и передать ей аргумент), а внутри мы ещё и пишем значение в глобальную переменную, чтобы даже дешёвый Int нельзя было «оптимизировать в никуда»:

@inline(never)
func blackHole<T>(_ value: T) {
    // оптимизатор не заглядывает внутрь @inline(never)-функции
    // и вынужден реально вычислить и передать аргумент
    globalSink = (globalSink &+ 1) & withExtendedLifetime(value) { 1 }
}
nonisolated(unsafe) var globalSink = 0

Сам замер устроен так: берём samples образцов, в каждом гоняем тело бенчмарка batch раз, делим время на число итераций и получаем наносекунды на операцию (ns/op). Перцентили считаем по образцам. Полный код - в Sources/bench/Harness.swift в репозитории.

Санити-чек: а харнессу вообще можно верить? Прежде чем мерить фичи, я проверил сам инструмент на двух заведомо известных случаях - пустое тело (должно быть ~0) и работа, растущая линейно (должна расти линейно):

тело

ns/op

пустое { }

0.72

blackHole ×10

31.7

blackHole ×100

320.0

blackHole ×1000

3073.8

Пустое тело - 0.72 нс, это фактически ноль (накладные расходы самого цикла и часов). А работа масштабируется идеально линейно: ×10 → ×100 → ×1000 даёт 31.7 → 320 → 3074 нс, то есть ~3.1 нс на каждый вызов blackHole. Значит оптимизатор тело не выбрасывает, и харнессу можно доверять. Отсюда важное следствие для чтения таблиц ниже: сам барьер blackHole стоит ~3–4 нс. Если дальше две строки показывают одинаковые ~4 нс, это не значит, что обе операции медленные - просто обе дешевле барьера, и различить их этим замером уже нельзя.

Почему я смотрю в ассемблер, а не только на секундомер

Здесь ключевая мысль всей статьи. Число ns/op отвечает на вопрос «сколько», но не отвечает на вопрос «почему». А «почему» часто важнее: две реализации могут показать одинаковое время, но по совершенно разным причинам - и понять это можно, только посмотрев, какой машинный код на самом деле сгенерировал компилятор.

Поэтому для каждой фичи я делаю не только замер времени, но и заглядываю в оптимизированный ассемблер (swiftc -O -S) и в промежуточное представление SIL. Что я там ищу простыми словами:

  • swift_allocObject - значит был поход в кучу (heap-аллокация).

  • swift_initStackObject - значит объект разместили на стеке (та самая «бесплатная» оптимизация).

  • swift_retain / swift_release - операции ARC (подсчёт ссылок).

# ищем в оптимизированном ассемблере: пошли ли в кучу или обошлись стеком
swiftc -O -S Bench.swift -o - | grep -E 'swift_allocObject|swift_initStackObject'

Часть 1. InlineArray: правда ли стек всегда бьёт кучу

Начнём с обещания. InlineArray<let count: Int, Element> хранит элементы прямо в своём «теле» - на стеке или внутри объемлющей структуры, без отдельной аллокации в куче, без copy-on-write и без подсчёта ссылок. Размер массива становится частью его типа (это и есть value generics - параметр дженерика не тип, а число). Плата за это - массив не умеет расти:

var a: InlineArray<3, Int> = [1, 2, 3]   // или синтаксический сахар: [3 of Int]
a[0] = 10                                 // ок, меняем элемент

// a.append(4)                            // нельзя: размер зафиксирован в типе
// let b: InlineArray<6, Int> = a         // ошибка: другой размер = другой тип

var copy = a                              // копия по значению
copy[0] = 99                              // a[0] при этом не меняется

Обещание звучит убедительно: быстрее, потому что не делает того, что делает обычный Array - не ходит в кучу, не считает ссылки, не проверяет copy-on-write. Проверим.

Бенчмарк 1.1 - сколько стоит создать массив

Рассмотрим сценарий: мы в цикле создаём маленький 4-элементный массив. Я сравнил три варианта: обычный локальный Array, Array, который «убегает» из функции (что это значит, поясню чуть ниже), и InlineArray.

Результат - время (ns/op, p50 / p90 / p99):

Создание 4-элементного массива

p50

p90

p99

Array (локальный)

4.20

4.65

5.46

Array (убегающий в функцию)

3.83

4.06

4.76

InlineArray

4.23

4.48

5.30

По времени - все три неотличимы, все упираются в тот самый «пол» blackHole (~4 нс). Никакого «стек в разы быстрее кучи» на создании нет и в помине. Однако время само по себе тут ничего не объясняет, интересное начинается в ассемблере.

Доказательство - что реально сгенерировал компилятор (swiftc -O -S, считаем аллокации на каждую функцию):

Что за код

Пошли в кучу? (swift_allocObject)

Легло на стек? (swift_initStackObject)

Array, локальный, константный литерал

нет

нет, свёрнут в константу

Array, локальный, значения из рантайма

нет

нет, «размазан» по регистрам

Array, убегает в borrowing-функцию

нет

да, stack promotion

InlineArray

нет

нет

Array, действительно убегает (сохранён в глобал)

да

нет

Array, возвращён наружу

да

нет

И вот тут ассемблер показывает неожиданное. Локальный Array вообще не ходит в кучу. Компилятор либо целиком свернул его в константу, либо «размазал» по процессорным регистрам. Более того, даже когда массив «убегает» в отдельную функцию (я передавал его в @inline(never)-функцию, принимающую [Int]), компилятор доказывает, что этот массив не переживёт текущий кадр стека, и кладёт его на стек сам (swift_initStackObject). Снова без кучи. InlineArray в этих сценариях не даёт ничего, чего оптимизатор уже не сделал бы бесплатно.

На полях: что такое «убегает» и что такое stack promotion. Объект «убегает» (escape), если он должен жить дольше, чем функция, в которой создан: например, вы вернули его наружу или сохранили в глобальную/долгоживущую переменную. Если объект не убегает, компилятор может провернуть stack promotion - разместить его на дешёвом стеке вместо дорогой кучи, потому что точно знает: по выходу из функции объект больше никому не нужен. Это давняя оптимизация, и она работает без всякого InlineArray.

Чтобы Array по-настоящему сходил в кучу, мне пришлось заставить его и правда убежать - сохранить в глобальную переменную или вернуть из функции. Только тогда в ассемблере появляется swift_allocObject, то есть настоящая heap-аллокация. И только в этом случае InlineArray, который убегать не умеет в принципе, действительно её вырезает.

Если свести к одному предложению: InlineArray не «ускоряет создание массива». Он делает явным и гарантированным то, что для локальных массивов компилятор и так делает за вас. Разница появляется только там, где Array вынужден уйти в кучу из-за escape, а InlineArray этого сделать физически не может.

Бенчмарк 1.2 - чтение и запись

Чтение (сумма 64 чисел):

Операция

p50

p90

p99

Array<Int> - чтение

4.26

4.60

5.54

InlineArray<Int> - чтение

4.08

4.51

5.36

Здесь паритет, оба в пределах шума. Для обычного Int так и должно быть: ARC не платит ни тот, ни другой (число - не ссылка, считать нечего), а проверка выхода за границы массива (bounds-check) есть у обоих. Такой «нулевой» результат я специально не прячу: нет разницы - так и напишем.

Запись оказалась интереснее:

Операция

p50

p90

p99

Array<Int> - запись

49.9

53.5

57.1

InlineArray<Int> - запись

4.4

4.7

5.9

Разница в 11 раз! Но тут легко сделать неверный вывод. Она не про скорость самой записи по индексу - она про copy-on-write.

На полях: что такое copy-on-write (COW). Когда вы копируете Array (let b = a), Swift не копирует данные сразу, обе переменные пока указывают на один и тот же буфер в куче. Копирование происходит «лениво», только в момент, когда одну из копий начинают менять. Тогда Swift делает «защитную копию» (defensive copy) буфера, чтобы правки одной переменной не задели другую. Это экономит память при чтении, но за первую запись в разделяемый буфер приходится платить полной копией.

В моём замере Array копировался из буфера, у которого был ещё один владелец, поэтому первая же запись форсила защитную копию 64 чисел в кучу. InlineArray же копируется целиком по значению на стеке. То есть 49.9 нс - это цена COW-копии, а не индексации. Точнее говорить не «InlineArray пишет в 11 раз быстрее», а «InlineArray не платит за копию там, где Array вынужден скопировать разделяемый буфер». Если у Array единственный владелец, запись на месте будет сопоставима.

Бенчмарк 1.3 - InlineArray как поле структуры

А в этом сценарии у InlineArray лучшие шансы на заметный отрыв. Возьмём две структуры:

struct WithArray  { var xs: [Float] }               // массив живёт в куче + COW
struct WithInline { var xs: InlineArray<4, Float> }  // 4 числа лежат прямо внутри структуры

Размеры (MemoryLayout.size):

Тип

Размер

WithArray

8 байт - только указатель на буфер в куче

WithInline

16 байт - четыре Float лежат прямо в структуре

WithArray это всего 8 байт указателя, а сами четыре числа лежат в отдельной аллокации в куче. WithInline несёт данные в себе, без второй аллокации вообще.

Теперь стоимость копирования структуры целиком (копия плюс одна запись):

Операция

p50

p90

p99

копия WithArray (retain + COW при записи)

40.3

43.1

53.8

копия WithInline (чистое значение)

4.1

4.3

5.0

Примерно 10× в пользу InlineArray - и вот это уже настоящий выигрыш по памяти. Когда вы копируете структуру с Array внутри, Swift делает retain её буфера (увеличивает счётчик ссылок), а первая же запись форсит защитную копию в кучу. Структура с InlineArray копируется как обычные 16 байт на стеке - ни retain, ни аллокаций. Это и есть сценарий, где InlineArray реально окупается: небольшой фиксированный буфер как поле структуры, которую часто копируют.

Однако у этой скорости есть цена, о которой нельзя молчать. InlineArray - не полноценная коллекция в привычном смысле: у него урезанный набор conformance’ов и API, нет динамики (append/remove), а размер зашит в тип. Как только вам понадобится растущий массив, или массивы разных размеров в одной коллекции, или богатый Collection-API - InlineArray не подойдёт. Это узкоспециализированный инструмент, а не замена Array.


Часть 2. Span и MutableSpan: безопасный буфер без ARC, и почему его нельзя вернуть

Span<Element> - это невладеющее «окно» в непрерывную область памяти. Он не владеет данными, а только ссылается на чужой буфер, при этом компилятор на этапе компиляции гарантирует, что окно не переживёт этот буфер. По сути это безопасная замена UnsafeBufferPointer - и, по заявлению, без накладных расходов в рантайме.

let numbers = [1, 2, 3, 4, 5]
let s: Span<Int> = numbers.span     // окно поверх буфера массива
let first = s[0]                    // безопасный доступ, без копирования

var data = [1, 2, 3]
var ms: MutableSpan<Int> = data.mutableSpan   // изменяемое окно
ms[0] = 99

Бенчмарк 2.1 - проход по массиву: Array против Span

Рассмотрим сценарий, где элементы - не простые числа, а структуры со ссылкой на класс внутри (я назвал такой тип Node). Это важный случай, потому что как раз тут в дело вступает ARC.

На полях: что такое ARC и почему за него платят. ARC (Automatic Reference Counting) - это способ Swift управлять памятью для ссылочных типов (классов). У каждого объекта есть счётчик ссылок: новая ссылка увеличивает его (retain), исчезнувшая уменьшает (release), на нуле объект удаляется. Каждый retain/release - маленькая атомарная операция, и в горячем цикле их стоимость набегает. Span обещает, что он заимствует элементы, а не владеет ими, поэтому счётчик трогать не должен.

@inline(never) func sumArray(_ a: [Node]) -> Int {   // проход по обычному массиву
    var acc = 0
    for x in a { acc += x.box.v }
    return acc
}
@inline(never) func sumSpan(_ s: Span<Node>) -> Int {  // проход через окно, без владения
    var acc = 0
    for i in 0..<s.count { acc += s[i].box.v }
    return acc
}

Здесь меня подстерегала методическая ловушка, о которой стоит предупредить. Наивная попытка посчитать ARC-операции через grep по SIL обманывает: у всех четырёх вариантов она показывает одинаковые ~5 retain / ~6 release. Оказалось, что это целиком «холодный» код обработки ошибки выхода за границу массива (формирование строки «Index out of range»), одинаковый везде и в настоящем проходе никогда не исполняемый. Считать нужно по ассемблеру, где холодные ветки вынесены в сторону и видно, что процессор выполняет на горячем пути.

Доказательство - ассемблер (swiftc -O -S):

Функция

Инструкций

ARC-операций

Есть путь через мост к NSArray?

sumArray([Node]) класс внутри

68

1 (холодный release)

да

sumSpan(Span<Node>) класс внутри

32

0

нет

sumArrayInt([Int]) простые числа

14

0

нет

sumSpanInt(Span<Int>) простые числа

12

0

нет

Разберём, что это значит:

Во-первых, на горячем пути ARC нет ни у кого. Даже для массива структур со ссылкой внутри компилятор убирает подсчёт ссылок в цикле: цикл только заимствует элементы, а владеет ими всё это время сам массив. То есть популярный тезис «Span убирает ARC, которого не убирает Array» для простого прохода по массиву - миф.

Во-вторых, настоящая разница структурная, а не про ARC. Обычный [Node] тащит за собой проверку «а это нативный Swift-массив или мостовой NSArray из Objective-C?» и целую холодную ветку на случай моста. Span<Node> - это чистое непрерывное окно, никаких мостов: вдвое меньше кода (32 инструкции против 68). Для простых чисел даже этой разницы почти нет (12 против 14).

На полях: что за «мост к NSArray». Массив [T] в Swift под капотом может оказаться не родным Swift-буфером, а «мостом» к NSArray из мира Objective-C. Поэтому компилятор вынужден на каждый доступ подстраховаться: проверить, какой это массив, и держать наготове медленный путь для мостового случая. Span устроен проще, он всегда смотрит на непрерывную «сырую» память, поэтому такой проверки ему не нужно.

А что по времени?

Операция

p50

p90

p99

Array<Node>

75.2

82.7

94.9

Span<Node>

74.9

80.6

98.1

На нативном массиве медианы сходятся - холодная ветка с мостом в рантайме просто не исполняется. Однако Span стабильнее: в повторных прогонах Array<Node> гулял от 75 до 88 нс с тяжёлыми хвостами, а Span<Node> держал ровно ~75 нс. Итог по этому бенчмарку: для элементов-классов Span даёт не «в разы быстрее», а меньше кода, отсутствие лишней проверки на мост и более предсказуемый хвост. Для простых чисел паритет.

Бенчмарк 2.2 - Span против UnsafeBufferPointer: безопасность правда бесплатна?

Это, пожалуй, главный вопрос про Span. Его продают как «безопасную замену указателям без накладных расходов». Проверим на одинаковой работе - суммируем 1024 числа.

Операция

p50

p90

p99

Span<Int>

3.79

4.23

4.92

UnsafeBufferPointer

4.20

4.91

7.21

Array<Int> (для сравнения)

4.01

4.47

5.13

Все три в пределах шума (и всё в том же «полу» ~4 нс - компилятор одинаково развернул суммирование для всех вариантов). Это и есть заголовочный результат: на такой работе безопасность Span действительно бесплатна. Он генерирует ровно тот же машинный код, что и «сырой» небезопасный указатель, но при этом снимает с вас ответственность за границы и за время жизни буфера. И это проверяемое утверждение swiftc -O -S показывает эквивалентные горячие циклы, а не маркетинговое обещание из релиз-ноутов.

Бенчмарк 2.3 - MutableSpan для правки на месте против Array с COW

Рассмотрим сценарий обработки буфера - например, инверсию 4096 байт (x = 255 − x, как при простой обработке изображения). Вариант A мутирует Array, у которого специально есть второй владелец буфера (чтобы спровоцировать COW). Вариант B правит на месте через MutableSpan.

Операция

p50

p90

p99

Array, правка + COW-копия

124.8

133.9

160.0

MutableSpan, правка на месте

116.2

123.0

142.2

Разница скромнее, чем в бенчмарке 1.3, - около 8–10%. Причина понятна: на 4096 байтах доминирует сам проход (4096 чтений-записей), а защитная COW-копия (одна аллокация плюс копирование 4 КБ) добавляется к нему всего один раз, а не на каждой итерации. MutableSpan эту копию убирает целиком и правит буфер на месте - стабильно быстрее и с более лёгким хвостом. Выигрыш практический, но не драматический; и он тем заметнее, чем больше мутирующих проходов вы делаете над разделяемым буфером.

Почему Span нельзя вернуть из функции - и почему это фича, а не баг

Самое важное про Span - то, чего он не умеет. Попробуем вернуть его из функции:

func brokenSpan() -> Span<Int> {
    let local = [1, 2, 3]
    return local.span          // ошибка компиляции
}

Span - это non-escapable тип (~Escapable): его нельзя вернуть из функции, сохранить в свойство или захватить в замыкание. И это сделано намеренно.

На полях: почему это спасает от «висячего указателя». Представьте, что мы всё-таки вернули span наружу. Массив local живёт только внутри функции - по выходу из неё его буфер освобождается. А span, если бы мы его вернули, продолжал бы ссылаться на уже освобождённую память. Это классический «висячий указатель» (dangling pointer) и источник трудноуловимых крашей. Swift просто запрещает создать такую ситуацию: компилятор следит, чтобы окно не жило дольше своего буфера. Это и называется lifetime dependency - «зависимость по времени жизни».

Приведу вывод компилятора Swift 6.2.4 дословно - это интереснее любого пересказа. Попытка вернуть Span:

error: a function cannot return a ~Escapable result
2 | func brokenSpan() -> Span<Int> {
  |                      `- error: a function cannot return a ~Escapable result

Попытка сохранить Span в поле структуры:

error: stored property 's' of 'Escapable'-conforming struct 'Holder'
       has non-Escapable type 'Span<Int>'
8 | struct Holder {
  |        `- note: consider adding '~Escapable' to struct 'Holder'

Обратите внимание на два разных диагноза. Возврат режется формулировкой «функция не может вернуть ~Escapable-результат». А сохранение в свойство ловится иначе: «обычная структура не может содержать non-escapable поле», и компилятор даже подсказывает решение - «добавьте ~Escapable к структуре». Любой путь, которым окно могло бы пережить буфер, отсекается ещё на компиляции.

И здесь важно не пообещать лишнего. Механизм @lifetime, который позволил бы вам возвращать Span из своих API, в 6.2 пока экспериментальный (полноценный proposal ещё в разработке). В стандартной библиотеке он уже работает для span-свойств, но в вашем прикладном коде - нет. Так что сегодня Span - это инструмент для параметров функций и локальных проходов, а не для возвращаемых значений.


Часть 3. Строгая memory safety - белая ворона (это не про скорость)

Сразу расставлю ожидания. Строгая memory safety - это диагностика компилятора, а не оптимизация в рантайме. Флаг -strict-memory-safety включает предупреждения на использование небезопасных конструкций, а атрибуты @safe/@unsafe позволяют явно разметить намерение. Поэтому здесь нет замера ns/op - мерить нечего. Мерить будем воздействие на разработчика.

import Foundation

// Намеренно «грязный» код с unsafe-конструкциями.
func rawPointerWalk(_ p: UnsafePointer<Int>, _ n: Int) -> Int {
    var acc = 0
    for i in 0..<n { acc += p[i] }          // unsafe subscript
    return acc
}

func withBytes(_ a: [UInt8]) -> Int {
    return a.withUnsafeBytes { raw in        // unsafe closure
        var s = 0
        for b in raw { s += Int(b) }
        return s
    }
}

func rebind(_ p: UnsafeRawPointer, _ n: Int) -> Int {
    let ip = p.assumingMemoryBound(to: Int.self)   // unsafe rebind
    return ip[0]
}

func mallocWorld() -> UnsafeMutablePointer<Int> {
    let p = UnsafeMutablePointer<Int>.allocate(capacity: 4)  // unsafe alloc
    p.initialize(repeating: 0, count: 4)
    return p
}

// Пример «чинки»: помечаем осознанный unsafe явно.
@unsafe func documentedUnsafe(_ p: UnsafePointer<Int>) -> Int { p[0] }

Я взял файл с намеренно «грязным» кодом (несколько мест с UnsafePointer, withUnsafeBytes, assumingMemoryBound и ручным выделением памяти) и собрал его сначала обычно, потом с флагом.

Сборка

Предупреждений

обычная (swiftc -O)

0

swiftc -O -strict-memory-safety

9

после починки

0

Без флага - тишина: режим строго opt-in, по умолчанию он ничего не меняет и не шумит. С флагом всплыли 9 мест, все одного вида: «выражение использует небезопасные конструкции, но не помечено как unsafe». Ложных срабатываний на этом наборе не было - подсветилось ровно то, что действительно трогает сырые указатели.

Что подсветилось и как я это чинил:

Конструкция

В чём небезопасность

Как починил

p[i] по UnsafePointer

доступ по сырому указателю

заменил на Span<Int> - предупреждение ушло само

a.withUnsafeBytes { … }

работа с сырыми байтами

осознанно, пометил unsafe a.withUnsafeBytes

for b in raw по сырому буферу

небезопасный проход

for unsafe b in unsafe raw

p.assumingMemoryBound(to:)

переинтерпретация типа памяти

пометил unsafe

UnsafeMutablePointer.allocate + initialize

ручное выделение памяти

пометил unsafe - это осознанное решение

тело @unsafe-функции

@unsafe на функции ≠ разрешение телу

внутри всё равно нужен unsafe p[0]

Отсюда два практических вывода, которые стоит запомнить:

@unsafe на функции - не индульгенция её телу. Даже помеченная @unsafe-функция обязана внутри писать unsafe на каждом небезопасном выражении. Атрибут говорит про вызов функции («будьте осторожны, вызывая меня»), а не про её внутренности.

И вторая шероховатость, самая неочевидная: for-цикл требует двойной пометки. for unsafe b in raw не гасит предупреждение до конца - само raw (небезопасного типа) тоже является unsafe-выражением, поэтому нужно for unsafe b in unsafe raw.

Один абзац контекста, чтобы было ясно, зачем это вообще в языке. Строгая memory safety - часть большого разворота Swift в сторону системного программирования, туда, где раньше безраздельно царил C. И это не только внутренняя инициатива Apple: регуляторы (вплоть до рекомендаций агентств вроде CISA и NSA) всё настойчивее двигают индустрию к memory-safe языкам. Флаг даёт то, чего раньше не хватало, - способ доказуемо очертить небезопасный периметр в кодовой базе и не дать ему бесконтрольно расползаться. Это не про скорость. Это про то, чтобы «мы тут точно не трогаем ничего небезопасного» стало утверждением, которое проверяет компилятор, а не обещанием на код-ревью.


Итог: когда фича реально платит

Соберём всё в одну таблицу - её удобно сохранить и вернуться.

Фича

Где реально даёт выигрыш (по замерам)

Где компилятор уже сделал это за вас

Цена в эргономике

InlineArray

поле структуры и частое копирование по значению: ~10× (40.3 → 4.1 нс); нет COW при мутации разделяемого буфера (49.9 → 4.4 нс)

локальный Array: 0 аллокаций в куче (stack promotion), создание неотличимо (4.2 ≈ 4.2 нс)

нет append/remove, урезанный API, размер зашит в тип, доступность

Span / MutableSpan

правка на месте без COW: ~10% (124.8 → 116.2 нс); элементы-классы: вдвое меньше кода и нет пути через мост (68 → 32 инстр.)

простые элементы: паритет с Array и с UnsafeBufferPointer (~4 нс, ARC и так нет)

non-escapable: нельзя вернуть/сохранить; свои возвращающие API - пока экспериментально

strict memory safety

не про скорость: 9 небезопасных мест найдено и исправлено, 0 ложных

-

двойная разметка for unsafe … in unsafe …, @unsafe не освобождает тело

Если собрать всё вместе, главное вот в чём. Это не «кнопки ускорения». По моим замерам все три фичи почти нигде не дают «в разы быстрее» на ровном месте - и не потому, что они плохие, а потому что оптимизатор Swift уже давно делает за вас львиную долю работы: размещает локальные массивы на стеке, убирает лишний ARC в проходах по массиву, генерирует для Span ровно тот же код, что для сырых указателей. Точечный выигрыш есть, и он реальный: InlineArray как поле структуры, MutableSpan для правки на месте, Span - меньше кода и никаких мостов для элементов-классов.

Но настоящая ценность этих фич не в наносекундах. Она в том, что прежде зависело от милости оптимизатора («скорее всего, ляжет на стек», «наверное, ARC уберётся»), а теперь становится явным и проверяемым на этапе компиляции. Выигрыш в производительности - точечный. Выигрыш в предсказуемости и безопасности - системный. И, на мой взгляд, именно второе важнее первого: ради него эти инструменты и стоит брать в работу.


Воспроизводимость

Все числа в статье получены на:

  • Swift 6.2.4 (swiftlang-6.2.4.1.4 clang-1700.6.4.2), Xcode 26.3 (17C529)

  • ОС macOS 26 (Darwin 25.3.0), arm64

  • Чип Apple M4 Pro, 24 ГБ RAM

  • Сборка -O (release)

Полный код бенчмарков, файлы для доказательств (ассемблер, SIL, диагностики) и скрипт воспроизведения - в репозитории: https://github.com/maxches99/swift6.2_memory_bench. Повторить весь прогон можно одной командой:

git clone https://github.com/maxches99/swift6.2_memory_bench
./reproduce.sh    # окружение + все ns/op + анализ ассемблера + strict + ошибки компилятора

И небольшая оговорка про сами числа. ns/op на уровне 4 нс - это «пол» барьера blackHole, поэтому мелкие расхождения между прогонами (±0.5 нс) нормальны и на выводы не влияют. Ключевые выводы держатся не на дрожащей последней цифре ns/op, а на ассемблере и SIL - на том, что реально сгенерировал компилятор. Это детерминированно и воспроизводимо.

Если захотите перепроверить меня со статистически более строгой гарнитурой (доверительные интервалы, автоэкспорт графиков) - берите package-benchmark от Ordo One; у меня числа сошлись с ней в пределах шума. Свой минимальный харнесс я выбрал только ради того, чтобы всё воспроизводилось одной командой и без зависимостей.

Если статья была полезной - расскажите в комментариях, какие ещё фичи Swift 6.2 хотелось бы увидеть «под микроскопом» с реальными замерами.