Про 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 |
Флаги сборки |
|
Гарнитура | собственный харнесс с перцентилями, без внешних зависимостей |
Отдельно про доступность: часть 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 |
| 31.7 |
| 320.0 |
| 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, считаем аллокации на каждую функцию):
Что за код | Пошли в кучу? ( | Легло на стек? ( |
|---|---|---|
| нет | нет, свёрнут в константу |
| нет | нет, «размазан» по регистрам |
| нет | да, stack promotion |
| нет | нет |
| да | нет |
| да | нет |
И вот тут ассемблер показывает неожиданное. Локальный 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 |
|---|---|---|---|
| 4.26 | 4.60 | 5.54 |
| 4.08 | 4.51 | 5.36 |
Здесь паритет, оба в пределах шума. Для обычного Int так и должно быть: ARC не платит ни тот, ни другой (число - не ссылка, считать нечего), а проверка выхода за границы массива (bounds-check) есть у обоих. Такой «нулевой» результат я специально не прячу: нет разницы - так и напишем.
Запись оказалась интереснее:
Операция | p50 | p90 | p99 |
|---|---|---|---|
| 49.9 | 53.5 | 57.1 |
| 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):
Тип | Размер |
|---|---|
| 8 байт - только указатель на буфер в куче |
| 16 байт - четыре |
WithArray это всего 8 байт указателя, а сами четыре числа лежат в отдельной аллокации в куче. WithInline несёт данные в себе, без второй аллокации вообще.
Теперь стоимость копирования структуры целиком (копия плюс одна запись):
Операция | p50 | p90 | p99 |
|---|---|---|---|
копия | 40.3 | 43.1 | 53.8 |
копия | 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? |
|---|---|---|---|
| 68 | 1 (холодный | да |
| 32 | 0 | нет |
| 14 | 0 | нет |
| 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 |
|---|---|---|---|
| 75.2 | 82.7 | 94.9 |
| 74.9 | 80.6 | 98.1 |
На нативном массиве медианы сходятся - холодная ветка с мостом в рантайме просто не исполняется. Однако Span стабильнее: в повторных прогонах Array<Node> гулял от 75 до 88 нс с тяжёлыми хвостами, а Span<Node> держал ровно ~75 нс. Итог по этому бенчмарку: для элементов-классов Span даёт не «в разы быстрее», а меньше кода, отсутствие лишней проверки на мост и более предсказуемый хвост. Для простых чисел паритет.
Бенчмарк 2.2 - Span против UnsafeBufferPointer: безопасность правда бесплатна?
Это, пожалуй, главный вопрос про Span. Его продают как «безопасную замену указателям без накладных расходов». Проверим на одинаковой работе - суммируем 1024 числа.
Операция | p50 | p90 | p99 |
|---|---|---|---|
| 3.79 | 4.23 | 4.92 |
| 4.20 | 4.91 | 7.21 |
| 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 |
|---|---|---|---|
| 124.8 | 133.9 | 160.0 |
| 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 и ручным выделением памяти) и собрал его сначала обычно, потом с флагом.
Сборка | Предупреждений |
|---|---|
обычная ( | 0 |
| 9 |
после починки | 0 |
Без флага - тишина: режим строго opt-in, по умолчанию он ничего не меняет и не шумит. С флагом всплыли 9 мест, все одного вида: «выражение использует небезопасные конструкции, но не помечено как unsafe». Ложных срабатываний на этом наборе не было - подсветилось ровно то, что действительно трогает сырые указатели.
Что подсветилось и как я это чинил:
Конструкция | В чём небезопасность | Как починил |
|---|---|---|
| доступ по сырому указателю | заменил на |
| работа с сырыми байтами | осознанно, пометил |
| небезопасный проход |
|
| переинтерпретация типа памяти | пометил |
| ручное выделение памяти | пометил |
тело |
| внутри всё равно нужен |
Отсюда два практических вывода, которые стоит запомнить:
@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 языкам. Флаг даёт то, чего раньше не хватало, - способ доказуемо очертить небезопасный периметр в кодовой базе и не дать ему бесконтрольно расползаться. Это не про скорость. Это про то, чтобы «мы тут точно не трогаем ничего небезопасного» стало утверждением, которое проверяет компилятор, а не обещанием на код-ревью.
Итог: когда фича реально платит
Соберём всё в одну таблицу - её удобно сохранить и вернуться.
Фича | Где реально даёт выигрыш (по замерам) | Где компилятор уже сделал это за вас | Цена в эргономике |
|---|---|---|---|
| поле структуры и частое копирование по значению: ~10× (40.3 → 4.1 нс); нет COW при мутации разделяемого буфера (49.9 → 4.4 нс) | локальный | нет |
| правка на месте без COW: ~10% (124.8 → 116.2 нс); элементы-классы: вдвое меньше кода и нет пути через мост (68 → 32 инстр.) | простые элементы: паритет с | non-escapable: нельзя вернуть/сохранить; свои возвращающие API - пока экспериментально |
strict memory safety | не про скорость: 9 небезопасных мест найдено и исправлено, 0 ложных | - | двойная разметка |
Если собрать всё вместе, главное вот в чём. Это не «кнопки ускорения». По моим замерам все три фичи почти нигде не дают «в разы быстрее» на ровном месте - и не потому, что они плохие, а потому что оптимизатор 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 хотелось бы увидеть «под микроскопом» с реальными замерами.
