G: Explore Swift: a high-performance, memory-safe programming language with modern syntax and seamless C and Objective-C interoperability. Find learning resources, toolchain guides, and ways to contribute to the community.
swiftlang/swift
1: Swift is a high-performance, memory-safe programming language with modern syntax and seamless interoperability with C and Objective-C. Its open-source project provides compiler and toolchain build guides and welcomes community contributions. Learn mor… swiftlang/swift
5 views
От 798 до 16 нс: как оптимизация debounce привела меня в Swift Runtime
Я прочитал статью Side Effect про debounce на Swift Concurrency и решил проверить, какой прирост производительности дает предложенный там подход. Идея простая: вместо того чтобы создавать новую Task на каждое входящее значение, можно использовать одну и ту же Task для всех событий и продлевать время ожидания.
В моем тесте такая замена сократила среднее время выполнения с 798 до 216 нс. Уже хороший результат, но оставалось непонятно, на что уходят эти 216 нс. В большинстве вызовов Task больше не создается: нужно получить текущее время, сохранить новое значение и пересчитать deadline (момент окончания ожидания).
В итоге я дошел до 16 нс. По пути пришлось разобраться, что происходит при выполнении этих нескольких строк Swift-кода: как runtime работает с типами, что компилятор может оптимизировать и почему расположение блокировки в памяти влияет на результат. В статье я покажу, как проверял свои гипотезы и что обнаружил в экспериментах. Надеюсь, этот опыт пригодится тем, кто хочет разобраться в производительности своего Swift-кода.
Сразу обозначу, что именно здесь ускорилось в 50 раз. Оптимизация относится к среднему времени выполнения одного вызова метода отправки значения. В тесте этот метод вызывался 100 000 раз подряд (без пауз). Метод обновляет состояние debounce и возвращает управление, а обработчик вызывается позже, после заданной задержки с момента последнего значения.
В этой статье
Переиспользуем одну и ту же Task
Результат переиспользования одной Task
Может быть, все дело в блокировке?
Конкретный тип еще не означает известный layout
Заменяем ContinuousClock.Instant на UInt64
Как межмодульная оптимизация влияет на специализацию generic-кода
Как расположение блокировки в памяти влияет на производительность
Что дали оптимизации на практике
Итог
Переиспользуем одну и ту же Task
Представим поле поиска, в котором мы не хотим отправлять запрос после каждого нажатия на клавиатуре. Для этого используем trailing debounce: после каждого изменения текста откладываем запрос на 500 мс. Если за это время пользователь ничего не ввел, отправляем запрос с последним значением, а если продолжил печатать, то откладываем его еще раз.
Самая простая реализация отменяет предыдущую Task и создает следующую:
task?.cancel()
task = Task {
do {
try await Task.sleep(for: dueTime)
await output(value)
} catch {
// Отмененная задача завершается без передачи значения.
}
}
sourcecraft.dev
Это фрагмент реализации с Task на каждое значение. В таком варианте созданные Task будут отменяться, если новые значения поступают раньше, чем истекает задержка debounce. Но создание Task, запрос на ее отмену и обработка этого запроса все равно требуют ресурсов. Отмена в Swift кооперативная: вызов cancel() не означает, что Task сразу завершилась.
Предложенный подход из статьи Side Effect переносит последнее значение и deadline в общее состояние. Первая отправка запускает рабочую задачу. Следующие отправки только меняют состояние. Когда Task просыпается, она проверяет, пора ли вызывать обработчик: если за время сна пришли новые значения, то будет ждать дальше; если после последнего значения прошла заданная задержка, то вызовет обработчик.
Одна последовательность входящих значений и два способа управления Task. Рабочая задача может проснуться несколько раз, прежде чем передаст последнее значение.
Чтобы отложить обработку, достаточно обновить состояние. Прерывать текущий вызов Task.sleep не требуется. Это важная часть экономии: частые входящие события не превращаются в столь же частые действия планировщика.
Реализация emit с переиспользованием TaskВот метод emit исходной реализации с рабочей задачей:
public func emit(value: T) {
let (shouldStartWorker, initialDeadline) = stateMachine.apply { machine in
machine.newValue(value)
}
guard shouldStartWorker else {
return
}
let worker = Task { [stateMachine, output] in
var deadline = initialDeadline
while true {
try? await Task.sleep(until: deadline, clock: .continuous)
let nextStep = stateMachine.apply { machine in
machine.sleepIsOver()
}
switch nextStep {
case .finishDebouncing(let latestValue):
await output(latestValue)
return
case .continueDebouncing(let newDueTime):
deadline = newDueTime
}
}
}
task.set(stored: worker)
}
sourcecraft.dev
Во время уже начатой серии большая часть вызовов заканчивается на guard. Машина состояний (конечный автомат) обновляет ожидающее значение и сообщает, что новая Task не нужна.
Результат переиспользования одной Task
Сначала сравним две стратегии: Task на каждое значение и одна рабочая задача на всю серию.
100 000 целочисленных значений от одного отправителя, задержка debounce 10 мс. Медиана 15 прогонов после прогрева на M1 Max, macOS 26.7.1, Swift 6.4, release-сборка. Сохраненные результаты.
При переходе к одной рабочей задаче среднее время выполнения emit сократилось примерно в 3,7 раза, а CPU-время примерно в 13 раз.
Окружение и условия сравненияРезультаты выше получены 13 октября 2026 года на версии кода из коммита 990cf04.
При сравнении нужно учитывать несколько отличий:
В две исходные реализации я добавил атомарные счетчики созданных Task и завершений Task.sleep. В моей реализации из библиотеки swift-debounce таких счетчиков нет, поэтому дополнительная работа по подсчету есть только у исходных реализаций.
Для модуля References включена межмодульная оптимизация (CMO). Но проверка сгенерированного кода показала, что компилятор не создал версии emit, специализированные под Int. Другие оптимизации при этом могли применяться.
Реализации по-разному выполняют обработчики: исходная допускает их одновременное выполнение, а swift-debounce выполняет их последовательно.
В этом тесте проверяется, что обработчик вызывается один раз и получает последнее отправленное значение.
Переиспользование Task дало заметное ускорение, но оставалось понять, на что уходят 216 нс. Дальнейшие эксперименты проводились на разных нагрузках и версиях прототипа.
Может быть, все дело в блокировке?
В исходной реализации Side Effect общее состояние защищено NSRecursiveLock. При каждом значении мы захватываем блокировку и отпускаем ее, поэтому это было первым местом для проверки.
Я сравнил NSRecursiveLock с os_unfair_lock в двух тестах: при увеличении счетчика под блокировкой и при выполнении всего метода emit.
Операция, один поток
NSRecursiveLock
os_unfair_lock
Захват, увеличение счетчика, освобождение
17,1 нс
8,0 нс
Полный emit
188,0 нс
177,7 нс
Ранний эксперимент с блокировками. Полный метод измерялся на миллионе отправок. Его исходные 188 нс относятся к другому тесту, чем 216 нс выше.
На отдельной блокировке разница выглядит внушительно: почти вдвое. В полном методе выигрыш составил 10,3 нс, около 5,5%. Большая часть стоимости осталась на месте.
Блокировку имело смысл заменить, но оставшиеся затраты одного отправителя она не объясняла. Я перешел к чтению времени и обновлению состояния.
Конкретный тип еще не означает известный layout
При профилировании метода emit я обнаружил вызовы следующих функций Swift Runtime:
type metadata accessor for ContinuousClock.Instant
swift_getGenericMetadata
swift_getTupleTypeMetadatasourcecraft.dev
На первый взгляд странно. deadline хранится в значении конкретного типа ContinuousClock.Instant. В тесте входящее значение тоже конкретное: Int. Откуда работа с метаданными типов?
Компилятор может знать конкретный тип, но не иметь гарантий о его размере и расположении полей в памяти.
Layout типа определяет его размер, выравнивание и расположение полей в памяти. В режиме library evolution библиотека может менять внутреннее устройство типа-значения без @frozen, сохраняя совместимость с уже собранными клиентами. Для этого клиентский код не должен полагаться на текущий размер структуры и смещения ее полей. Атрибут @frozen дает более жесткие гарантии layout, но в определении ContinuousClock.Instant его нет.
В машине состояний deadline хранится внутри enum вместе со значением:
struct DebounceStateMachine<T> {
enum State {
case idle
case debouncing(
value: T,
dueTime: ContinuousClock.Instant,
isValueDuringSleep: Bool
)
}
// ...
}
sourcecraft.dev
При новом значении метод получает текущее время, заменяет содержимое enum и возвращает новый deadline. Для замены значения машинному коду, сгенерированному компилятором, нужно определить расположение полей и записать новые данные.
В Swift метаданные типа связаны с value witness table (подробнее о ее роли в generic-коде). В ней есть информация о layout значения и операции копирования, перемещения и уничтожения. Когда layout типа неизвестен при компиляции, сгенерированный машинный код может использовать операции из value witness table для копирования, перемещения и уничтожения значения. Связь метаданных и value witness table описана в документации Swift ABI.
Схема объясняет механизм работы с неизвестным layout.
В месте вызова компилятору был известен конкретный тип Int. Однако проверка сгенерированного кода показала, что вызывалась generic-версия LongLivedTaskDebounce.emit(value:), рассчитанная на любой допустимый T.
В версии, специализированной под Int, компилятор может использовать известные размер и расположение входного значения. Layout Instant при этом остается неизвестным компилятору клиентского модуля.
При этом обращение к метаданным не означает, что на каждой отправке создается новый объект или для хранения deadline обязательно выделяется отдельный участок памяти в куче. Метаданные могут переиспользоваться, даже если код продолжает к ним обращаться.
Как профилировать самостоятельноВ репозитории есть режим profile:
cd Benchmarks
swift build -c release
.build/release/Locks profile 10 &
sample "$!" 5 1 -file debounce-sample.txtsourcecraft.dev
Тест работает десять секунд; sample собирает стеки пять секунд с интервалом в одну миллисекунду. В историческом профиле исходная версия с рабочей задачей называется OptimizedDebounce. Состав символов в новом запуске может отличаться от сохраненного профиля.
Заменяем ContinuousClock.Instant на UInt64
Следующим шагом я решил проверить работу со временем: как мы получаем текущее значение и храним deadline. Сначала сравнил ContinuousClock.now и mach_continuous_time(). Каждый из них вызывался в цикле. Среднее время одной итерации составило 28,9 нс для ContinuousClock.now и 5,0 нс для mach_continuous_time().
Медиана семи однопоточных прогонов по пять миллионов итераций после прогрева. Тесты по-разному обрабатывают полученное время, поэтому числа включают не только вызов функции часов, но и дополнительную работу внутри цикла.
Вместо ContinuousClock.Instant я стал хранить deadline в UInt64. На Darwin текущее время теперь получаю через mach_continuous_time(). Значение deadline хранится как обычное целое число, layout которого известен компилятору.
Чтение времени и преобразование тиковТак выглядит wrapper над часами:
@usableFromInline
enum _MonotonicClock {
@inlinable
@inline(__always)
static func now() -> UInt64 {
#if canImport(Darwin)
return mach_continuous_time()
#else
var time = timespec()
clock_gettime(CLOCK_BOOTTIME, &time)
let secondsInNanoseconds = UInt64(time.tv_sec) &* 1_000_000_000
return secondsInNanoseconds &+ UInt64(time.tv_nsec)
#endif
}
}
sourcecraft.dev
mach_continuous_time() возвращает время в тиках, которые не обязательно равны наносекундам. При инициализации заданная задержка debounce переводится в тики. Перед вызовом Task.sleep рабочая задача преобразует оставшееся время обратно в наносекунды. Поэтому при каждом вызове send пересчитывать единицы времени не нужно.
Я одновременно изменил способ получения текущего времени и тип deadline, поэтому по этим замерам нельзя определить, какой вклад внесло каждое изменение. При повторном профилировании я снова обнаружил обращения к метаданным. Теперь они были связаны с полем Value?, в котором сохраняется последнее значение для обработки.
Как межмодульная оптимизация влияет на специализацию generic-кода
Теперь нужно было разобраться с обновлением Value?. В тесте используется Int, но метод emit мог по-прежнему выполняться как общая generic-функция. Я проверил, поможет ли здесь межмодульная оптимизация -cross-module-optimization (CMO). Она позволяет оптимизировать код через границы модулей, в том числе создавать специализированные версии generic-функций.
Для сравнения я собрал каждую реализацию с CMO и без него. В каждом запуске тест примерно три секунды подряд вызывал emit, передавая новые значения, и подсчитывал количество вызовов.
Переработанный прототип с CMO успел выполнить около 258 млн вызовов вместо 28 млн. У исходной реализации с одной рабочей задачей результат почти не изменился: около 16 млн вызовов в обоих случаях.
Реализация
Без CMO, вызовов emit за ≈3 с
С CMO, вызовов emit за ≈3 с
Прототип с deadline в UInt64
27 580 000
257 900 000
Исходная рабочая задача
15 770 000
15 680 000
Для каждого варианта сохранен результат одного запуска, поэтому судить о разбросе результатов пока нельзя. Каждый запуск был рассчитан примерно на три секунды; время проверялось после каждых 10 000 вызовов emit. Результаты эксперимента.
Это измерение пропускной способности отдельного прототипа, а не еще один множитель, который можно применить к исходным 798 нс.
CMO заметно помог переработанному прототипу, но почти не повлиял на исходную реализацию. Далее, чтобы разобраться в причине, я решил проверил, какой машинный код создал компилятор.
SIL исходной реализации: generic-вызов emitНиже фрагмент SIL. Для читаемости я перенес длинные строки и убрал служебные комментарии после инструкций:
// function_ref LongLivedTaskDebounce.emit(value:)
%6 = function_ref @$s10References21LongLivedTaskDebounceC4emit5valueyx_tF :
$@convention(method) <τ_0_0 where τ_0_0 : Sendable>
(@in_guaranteed τ_0_0,
@guaranteed LongLivedTaskDebounce<τ_0_0>) -> ()
%7 = apply %6<Int>(%4, %0) :
$@convention(method) <τ_0_0 where τ_0_0 : Sendable>
(@in_guaranteed τ_0_0,
@guaranteed LongLivedTaskDebounce<τ_0_0>) -> ()
sourcecraft.dev
function_ref ссылается на generic-версию emit: в сигнатуре остается параметр типа τ_0_0. Следующая инструкция, apply %6<Int>, вызывает ее с типом Int. Запись <Int> здесь означает передачу конкретного типа в generic-функцию, а не вызов отдельной специализации. Несмотря на включенный CMO, в этом месте вызывается общий метод.
Один из способов оптимизации generic-кода — специализация. Компилятор создает версию функции для конкретного типа. Например, для Debouncer<Int> он может заменить общие операции над Value и Value? операциями над Int и Optional<Int>.
В отдельном эксперименте я сохранил ContinuousClock.Instant, но заменил обертку SafeStorage с NSRecursiveLock на вариант с os_unfair_lock. С включенным CMO компилятор создал специализацию emit под Int. Получается, специализация была возможна и без замены Instant на UInt64.
SIL после замены NSRecursiveLock на os_unfair_lockВо втором варианте той же проверки я заменил блокировку внутри SafeStorage на os_unfair_lock. ContinuousClock.Instant, алгоритм debounce и параметры компиляции оставил прежними. Теперь вызывающий модуль содержит такой фрагмент:
// function_ref specialized LongLivedTaskDebounce.emit(value:)
%4 = function_ref @$s10References21LongLivedTaskDebounceC4emit5valueyx_tFSi_Tg5 :
$@convention(method)
(Int, @guaranteed LongLivedTaskDebounce<Int>) -> ()
%5 = apply %4(%1, %0) :
$@convention(method)
(Int, @guaranteed LongLivedTaskDebounce<Int>) -> ()
sourcecraft.dev
В комментарии к function_ref появилось specialized, а в сигнатуре вместо параметра типа указаны Int и LongLivedTaskDebounce<Int>. Инструкция apply %4 вызывает именно эту версию emit.
Вызов отдельной функции сохранился: здесь видна специализация, а не подстановка ее тела. Оба варианта собраны с включенным CMO.
Еще одна оптимизация — inlining, то есть подстановка тела функции в место вызова. Она может помочь дальнейшим оптимизациям, но специализация возможна и без нее: компилятор может создать отдельную функцию для конкретного типа и оставить ее вызов.
В swift-debounce я пометил функции, которые вызываются из send, атрибутом @inlinable. Благодаря этому их код доступен оптимизатору при компиляции модуля, использующего библиотеку. Это дает возможность выполнить inlining или специализацию, но не гарантирует их. CMO тоже может сделать тела функций доступными для оптимизации без @inlinable. Подробнее эти механизмы описаны в SE-0193.
Функция send получилась такой:
@inlinable
public func send(_ value: Value) {
let now = _MonotonicClock.now()
storage.withLock { state in
let shouldStartWorker = state.machine.send(value, now: now)
if shouldStartWorker {
core.startWorker(&state)
}
}
}
sourcecraft.dev
Недостаточно сделать доступным оптимизатору только тело send. Операция замены trailingValue: Value? находится глубже, в машине состояний, а доступ к ней проходит через withLock. Если тела этих функций недоступны, оптимизатор не сможет проанализировать обновление состояния через эту цепочку вызовов.
Как оптимизатор видит обновление состоянияПоэтому метод withLock тоже помечен @inlinable:
@inlinable
@inline(__always)
func withLock<Result>(_ body: (inout State) -> Result) -> Result {
withUnsafeMutablePointers { statePointer, lockElements in
let lock = lockElements + Self.lockIndex
_lock(lock)
defer {
_unlock(lock)
}
return body(&statePointer.pointee)
}
}
sourcecraft.dev
В машине состояний новое значение обновляет deadline и заменяет сохраненное значение, если предыдущий deadline еще не наступил. Ветку else здесь опускаю:
@inlinable
mutating func send(_ value: Value, now: UInt64) -> Bool {
let newDeadline = _deadline(after: now, adding: interval)
if let currentDeadline = burstDeadline, now < currentDeadline {
burstDeadline = max(currentDeadline, newDeadline)
if edges.contains(.trailing) {
trailingValue = value
}
} else {
// Начало новой серии опущено.
}
if isWorkerActive {
return false
}
isWorkerActive = true
return true
}
sourcecraft.dev
Стрелки показывают доступность кода для компилятора приложения. @inlinable открывает тело функции для анализа с учетом конкретного типа Int. Inlining и специализация не гарантированы; CMO тоже может обеспечить доступ к телам функций.
Здесь важно различать @inlinable и @usableFromInline. Второй атрибут позволяет ссылаться на internal-декларацию из доступного клиентам тела и делает ее частью ABI. Сам по себе он не раскрывает тело функции для оптимизации.
Метод startWorker, который создает рабочую Task, помечен @usableFromInline. Это позволяет вызывать его из @inlinable-метода send, не открывая тело startWorker для оптимизации. Большинство вызовов send только обновляют состояние и до startWorker не доходят.
Как расположение блокировки в памяти влияет на производительность
На влияние расположения блокировки в памяти я наткнулся случайно, когда уже готовил репозиторий к публикации на GitHub. Я сравнивал несколько способов хранения состояния и блокировки, чтобы выбрать реализацию с поддержкой старых версий iOS.
В одном из вариантов состояние и блокировка размещались в одной аллокации с помощью ManagedBuffer. В другом память для блокировки выделялась отдельно от состояния.
При сравнении обнаружился неожиданный результат: отдельная аллокация для блокировки замедляла отправку значений из одного потока, но ускоряла ее при одновременных вызовах из нескольких потоков.
Я предположил, что при нескольких потоках помогает именно расстояние между блокировкой и состоянием. Чтобы это проверить, я оставил их в одной аллокации, но добавил между ними свободное место (padding размером 128 байт). Так можно было отодвинуть блокировку от состояния, не выделяя для нее память отдельно. В реализации хранилища положение блокировки вычисляется так:
@inlinable
@inline(__always)
static var lockIndex: Int {
let paddingBytes = 128
let lockStride = MemoryLayout<_PlatformLock>.stride
return (paddingBytes + lockStride - 1) / lockStride
}
sourcecraft.dev
Без padding блокировка находится в начале области элементов ManagedBuffer. В варианте с padding перед ней зарезервированы 128 байт. Состояние остается на прежнем месте. Размеры блоков показаны условно.
При одновременной работе восьми потоков получились такие результаты:
Тест
Без padding
С padding
Захват блокировки, увеличение счетчика и освобождение блокировки
24,9 нс
37,1 нс
Вызов emit в упрощенной реализации debounce
79,7 нс
54,2 нс
В тесте с восемью потоками padding увеличил время операции со счетчиком под блокировкой, но сократил время выполнения emit. При этом расход памяти вырос со 160 до 288 байт на экземпляр тестового BoxedDebounce. Для времени показаны медианы девяти запусков; в каждом запуске общее время теста делилось на число операций всех потоков. Результаты опыта с хранилищем.
В тесте блокировки вариант без padding оказался быстрее: 24,9 нс против 37,1 нс. Но при вызове emit результат получился обратным: с padding 54,2 нс, без него 79,7 нс. Поэтому выбирать расположение блокировки стоит по результатам той операции, которую она защищает.
Возможная причина — это false sharing. Кэш процессора работает с блоками памяти (кэш-линиями). Если блокировка и защищаемое состояние попадают в одну кэш-линию, потоки могут мешать друг другу: один обновляет состояние, а другие в это время пытаются захватить блокировку. Padding позволяет разнести блокировку и состояние, что может уменьшить эти помехи.
Такой случай описан в документации ядра Linux. Он подходит под наблюдаемый результат, но я не проверял эту гипотезу.
Сравнение пяти вариантов хранения и разброс результатовВ тесте сравнивались пять вариантов хранения состояния и блокировки. Во всех использовалась одна и та же упрощенная реализация debounce.
Ниже показано время на один вызов emit: медиана девяти запусков, а в скобках минимальный и максимальный результаты. Память измерялась на экземпляр тестового BoxedDebounce.
Хранилище
Один поток, нс/вызов
Восемь потоков, нс/вызов
Память на экземпляр, байт
Synchronization.Mutex
13,2 [13,1–13,3]
86,8 [77,5–94,3]
128
ManagedBuffer, без padding
13,1 [13,0–13,2]
79,7 [73,7–97,7]
160
ManagedBuffer, блокировка с padding
13,2 [13,2–13,3]
54,2 [51,9–62,6]
288
OSAllocatedUnfairLock
13,1 [13,1–13,2]
84,6 [71,3–118,8]
160
Отдельная аллокация блокировки
17,9 [17,8–18,0]
65,7 [62,7–67,5]
144
В однопоточном тесте выполнялись два миллиона вызовов emit, в многопоточном по 100 000 из каждого из восьми потоков. Задержка debounce составляла 20 мс. Перед замерами выполнялся прогрев, затем варианты проверялись по очереди в девяти раундах. Время на вызов рассчитано по общей длительности теста и суммарному числу вызовов. Точка на графике медиана, отрезок минимум и максимум.
При работе из одного потока оба варианта ManagedBuffer показали около 13 нс на вызов. При восьми потоках диапазон результатов с padding составил 51,9–62,6 нс, без padding 73,7–97,7 нс. В этих девяти запусках диапазоны не пересекались.
В swift-debounce я оставил вариант с padding. В тестах он ускорил обновление состояния при работе из нескольких потоков, практически не повлияв на результат одного потока.
Что дали оптимизации на практике
Теперь сравним три реализации: новую Task на каждое значение, одну рабочую задачу из статьи Side Effect и swift-debounce.
Реализация
Среднее время одного вызова
Время цикла отправки
CPU-время процесса
Task на каждое значение
798 нс
79,82 мс
280,81 мс
Одна рабочая задача
216 нс
21,61 мс
21,91 мс
swift-debounce
16 нс
1,57 мс
1,72 мс
100 000 целочисленных значений от одного отправителя, задержка debounce 10 мс. Медиана 15 прогонов на M1 Max, macOS 26.7.1, Swift 6.4, release-сборка. Сохраненные результаты. CPU-время включает работу всех потоков за период отправки значений, ожидания результата и завершения отмененных задач.
При 100 000 вызовов подряд среднее время выполнения send в swift-debounce составило 16 нс. При 1000 вызовах уже 41 нс: затраты на создание рабочей задачи распределяются на меньшее число вызовов.
Затем я проверил другой сценарий: 200 значений с интервалом 5 мс. Суммарное CPU-время составило 31,10 мс для реализации с новой Task на каждое значение, 21,65 мс для одной рабочей задачи и 21,22 мс для swift-debounce.
Здесь основную экономию дало переиспользование Task. Дополнительные оптимизации send повлияли на суммарное CPU-время гораздо меньше.
Итог
Все началось с простой идеи: перестать создавать новую Task для каждого значения. Уже это сократило среднее время отправки с 798 до 216 нс. Но мне стало интересно, куда уходят оставшиеся наносекунды. Так оптимизация debounce постепенно превратилась в исследование Swift Runtime и работы компилятора.
Больше всего меня удивило, насколько результаты могут расходиться с первоначальными ожиданиями. Я думал, что основная проблема в блокировке, но ее замена объяснила лишь небольшую часть затрат. Позже оказалось, что даже расположение блокировки в памяти влияет на результат, причем отдельный тест блокировки и обновление состояния debounce показали противоположную картину. А чтобы разобраться с остальными затратами, пришлось смотреть уже не только на Swift-код, но и на то, что из него генерирует компилятор.
В итоге в тесте со 100 000 вызовов подряд среднее время выполнения одного вызова сократилось с 798 до 16 нс, то есть примерно в 50 раз.
Для меня самое интересное в этой истории - это сколько всего обнаружилось за несколькими строками обновления состояния. Даже простой на вид метод может привести к вопросам о layout типов, специализации generic-кода и работе процессорного кэша. Получившуюся реализацию я опубликовал в swift-debounce, а код экспериментов и результаты замеров оставил в репозитории с бенчмарками.
Swiftのおもしろ不具合かも、dispatch thunkを期待するけどnon-resilientな構成でprotocolのdispatch thunkが生成されない。呼び出し元もisResilientがなくwitness table/dispatch thunkを分岐しないから特定の環境でコンパイルエラー
swift#93195
1 like · 68 views
@available(macOS 15.0, *)
@available(MyLib_v1_0, *)
public func api() {}
The above Swift snippet may work for normal build. But will break the swiftinterface for binary integration.
Fix PR: swift#93418
4 likes · 321 views
The idea is to avoid expensive value witness operations in favor of simple 𝚜𝚠𝚒𝚏𝚝_𝚛𝚎𝚝𝚊𝚒𝚗 and 𝚜𝚠𝚒𝚏𝚝_𝚛𝚎𝚕𝚎𝚊𝚜𝚎 calls.
swift#93322
4 likes · 284 views
Has anyone else noticed how much the Preview macro slows down your builds?
3 likes · 1 repost
I used AI to cheat my way to a 156-day reading streak
I asked Codex to recover my Apple Books reading streak. Very important engineering work, obviously.
Books showed two days. I had an 80-day run in the history, then a gap, then a few scattered reading days. It happens to the best of us. The app was reporting exactly what it had recorded. I wanted the old streak back anyway, so I asked an agent to fill the gap.
It ended up showing 156 days and New Record. I have read so much.
Before we go any further: don't try this at home. This is cheating. Do actual reading. The agent added reading time to dates that were below the goal. Those entries were manufactured. Nobody read 71 extra days while a script was running.
But the process was interesting. It involved private Swift APIs, backups, a background process, and, of course, a cache. Apparently even my questionable reading decisions need cache invalidation.
Start with the embarrassing part
My daily goal was one minute. One minute. Apple Books was already being very generous with me. Had it on 15 mins a day, but I had to travel and reduced to 1 minute (talk about commitment issues).
The old streak ran from May 11 through July 29, 2026. Continuing it through October 13 would make 156 calendar days. Of those, 85 already met their goals: the original 80, plus five later dates. That left 71 dates to fill.
Seventy had no daily record. August 1 had six seconds, so it needed another 54. How do you even get six seconds of reading? IDK. A date that already met its goal needed no changes, even if it contained much more reading time.
The calculation was small enough:
# Pseudocode
for day in requested_range:
goal = stored_goal(day, default=60)
recorded = recorded_seconds(day, default=0)
additional = max(0, goal - recorded)
if additional:
increment_reading_time(day, additional, default_goal=60)
The dates also needed the correct timezone. The edits used America/El_Salvador and local noon, keeping each increment inside its intended calendar day. It would be a pretty annoying way to discover a timezone bug, after going through all this to change a number. A big part of this was possible (within the 5 hour window of my $20 codex subscription) thanks to the work previously done to understand the Apple books data model. Give it a read if you want to read about sqlite databases.
{% link dev.to %}
Now, let's start.
Make the backup before getting clever
The history file involved was:
~/Library/Group Containers/group.com.apple.iBooks/CRDTModelLocalFile/ReadingHistoryModel
Before changing it, Codex backed up the history, its context and sync metadata, and the relevant Books preferences. The SQLite context databases were copied through SQLite's backup API. A permanent backup went into Documents, separate from the scratch files.
Then the experiments happened on copies. That was useful because some of the first attempts at calling Apple's private Swift code failed.
An agent having access to your files is a good reason to make the original easy to restore. Keep the backup somewhere separate, give it a name you understand, and be clear about which dates the agent is allowed to change. In this case, the scope was the missing goal time within one specific streak.
The C bridge that called Apple's Swift code
This was the part I wanted to understand properly. Codex found an operation in the installed frameworks:
ReadingHistoryModel.incrementReadingTime(date:amount:defaultGoal:)
But having a method name and calling the method are two separate problems. There was no available SDK module to import for this private interface. The tool used a C bridge to load the framework, resolve the Swift entry points, and call them with the required binary calling convention.
The calls went in both directions. The Swift driver called ordinary C functions declared in native.h. Those C functions called Apple's compiled Swift functions through typed function pointers. The framework was loaded at runtime with dlopen; the C object file was linked into the driver at build time.
Below is the complete C bridge used in this experiment, split into pieces so the explanation can stay next to the code. These are the actual functions and symbols used on this Mac. The Swift snippets are excerpts from the driver.
Start with the C interface
native.h
#include <stdint.h>
typedef struct { uint64_t a, b; } Bits;
void *history_decode(uint64_t a, uint64_t b);
Bits history_describe(void *model);
Bits history_encode(void *model);
void history_increment(void *model, double date, int64_t amount, int64_t goal);
void *history_error(void);
void *history_type(void);
void history_set_streak(void *model, void *dictionary_metadata, void *date, int64_t length);
Bits carries two 64-bit words across the C boundary. On this installation, that matched the representation used for the Data arguments and the Data and String results involved in these calls. Those words can contain storage references or inline information; they are the Swift value's representation, rather than a byte-buffer API.
The C interface exposes opaque pointers for the model and its metadata. The driver uses history_error() to retrieve a captured Swift error and history_type() when reflecting on the model later.
Tell Clang how to call Swift
native.c — attributes, function types, and symbol lookup
#include "native.h"
#include <dlfcn.h>
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#define SWIFT __attribute__((swiftcall))
#define SELF __attribute__((swift_context))
#define ERROR __attribute__((swift_error_result))
#define RESULT __attribute__((swift_indirect_result))
typedef struct { void *value; uintptr_t state; } MetadataResponse;
typedef MetadataResponse (*MetadataFn)(uintptr_t) SWIFT;
typedef void (*DecodeFn)(void * RESULT, uint64_t, uint64_t, void *, void *, void * SELF, void ** ERROR) SWIFT;
typedef Bits (*BitsFn)(void * SELF) SWIFT;
typedef Bits (*EncodeFn)(void *, void *, void *, void *, void * SELF, void ** ERROR) SWIFT;
typedef void (*IncrementFn)(void *, int64_t, int64_t, void * SELF) SWIFT;
static void *metadata, *witness, *last_error;
void *history_error(void) { return last_error; }
void *history_type(void) { return metadata; }
static void *symbol(const char *name) {
void *value = dlsym(RTLD_DEFAULT, name);
if (!value) { fprintf(stderr, "Missing symbol: %s\n", name); exit(1); }
return value;
}
The attributes carry the calling-convention information:
| Macro | Role in the bridge |
| --- | --- |
| SWIFT | Call the function using Swift's calling convention |
| SELF | Give the context parameter its special Swift ABI treatment |
| RESULT | Pass storage for an indirectly returned value |
| ERROR | Receive the error result from a throwing Swift call |
> # What's ABI
>
> No, it's not the name of a girl ;) even tho it may be. ABI stands for Application Binary Interface. In short, it defines how already compiled Swift code interacts at the binary level and how the code talks to the runtime.
> Type layout conventions, representation, metadata, etc.
> You can find way more about this on the web, and probably better explained :)
Clang still needs a function type that describes the lowered arguments correctly. swiftcall handles their low-level placement; the bridge must supply indirect pointers, generic arguments, context, and error parameters in the right shape. The Clang attribute reference documents this distinction.
The symbol() helper resolves an address with dlsym. The $s... strings are Swift's mangled symbol names. They carry information about the module, type, operation, and signature. One of the names used here can be inspected with:
xcrun swift-demangle --compact '$s13BookDataStore19ReadingHistoryModelV09incrementD4Time4date6amount11defaultGoaly10Foundation4DateV_S2itF'
The result identifies ReadingHistoryModel.incrementReadingTime(date:amount:defaultGoal:). Finding that address gives us a place to call. The typedef determines how the call gets there.
Decode a type C doesn't know about
native.c — loading and decoding
void *history_decode(uint64_t a, uint64_t b) {
assert(dlopen("/System/Library/PrivateFrameworks/BookDataStore.framework/BookDataStore", RTLD_NOW | RTLD_GLOBAL));
MetadataFn meta = symbol("$s13BookDataStore19ReadingHistoryModelVMa");
metadata = meta(0).value;
void *(*conforms)(void *, void *) = symbol("swift_conformsToProtocol");
witness = conforms(metadata, symbol("$s9Coherence4CRDTMp"));
assert(metadata && witness);
uintptr_t *vwt = ((uintptr_t **)metadata)[-1];
size_t size = vwt[8];
assert(size > 0 && size < 4096);
void *model = calloc(1, size);
void *error = NULL;
DecodeFn decode = symbol("$s9Coherence4CRDTPAAE14serializedDatax10Foundation0D0V_tKcfC");
decode(model, a, b, metadata, witness, metadata, &error);
if (error) { last_error = error; return NULL; }
fprintf(stderr, "Native model decoded (%zu bytes)\n", size);
return model;
}
dlopen loads BookDataStore. RTLD_GLOBAL makes its loaded exports available to the subsequent RTLD_DEFAULT lookups; the required Coherence symbols were available through the loaded dependencies.
The metadata accessor returns the runtime type information for ReadingHistoryModel. MetadataResponse models its pointer-and-state result; meta(0).value keeps the metadata pointer. swift_conformsToProtocol then retrieves its conformance to Coherence.CRDT. That conformance is represented by a witness table: the information the generic decoder needs to use this particular type's implementation.
C also needs somewhere to put the decoded value. The pointer immediately before the metadata points to its value witness table, which includes layout and value-management information. The bridge reads the size slot used by this ABI, allocates storage, and passes it as the indirect result. The decoded model occupied 24 bytes in this run. Swift's type metadata documentation describes the metadata and value witness table relationship.
The decoder call has more arguments than its Swift source-level spelling suggests. It receives the output storage, the two Data words, generic type metadata, the protocol witness table, the initializer's metatype context, and the error result. The metadata pointer appears twice because those are distinct roles in this entry point.
The driver supplied the Data representation like this:
import Foundation
let arguments = CommandLine.arguments
guard arguments.count >= 3 else { fatalError("input output [increments.json]") }
let data = try Data(contentsOf: URL(fileURLWithPath: arguments[1]))
precondition(MemoryLayout<Data>.size == MemoryLayout<Bits>.size)
let bits = unsafeBitCast(data, to: Bits.self)
guard let model = history_decode(bits.a, bits.b) else {
let error = unsafeBitCast(history_error()!, to: (any Error).self)
print("Native decode failed: \(error)")
exit(1)
}
unsafeBitCast reinterprets the value's bits. The size check catches a size mismatch, but it does not establish that an arbitrary Swift type can safely be passed this way. This bridge depended on the representation and ownership behavior of these specific calls.
Inspect and serialize the model
native.c — description and encoding
Bits history_describe(void *model) {
BitsFn describe = symbol("$s13BookDataStore19ReadingHistoryModelV16debugDescriptionSSvg");
return describe(model);
}
Bits history_encode(void *model) {
EncodeFn encode = symbol("$s9Coherence11CRMergeableP14serializedData_7version10Foundation0D0VAA9CRContextC_AA16CRCodableVersionOtKFTj");
void *(*conforms)(void *, void *) = symbol("swift_conformsToProtocol");
void *mergeable_witness = conforms(metadata, symbol("$s9Coherence11CRMergeableMp"));
assert(mergeable_witness);
MetadataFn context_meta = symbol("$s9Coherence9CRContextCMa");
void *(*create_context)(void * SELF) SWIFT = symbol("$s9Coherence9CRContextC6sharedACvgZ");
void *context = create_context(context_meta(0).value);
uint8_t version = *(uint32_t *)symbol("$s9Coherence16CRCodableVersionO8version3yA2CmFWC");
void *error = NULL;
Bits bits = encode(context, &version, metadata, mergeable_witness, model, &error);
if (error) { last_error = error; return (Bits){0,0}; }
return bits;
}
history_describe calls the model's debug-description getter. The Swift driver interprets the returned Bits as a String and uses that description in its round-trip checks.
Encoding uses a separate CRMergeable conformance, its serialization dispatch thunk, and the shared CRContext. The bridge resolves the installed enum case tag for version3 and passes its address to the encoder. That tag lookup and argument representation were checked for this installation.
Both throwing calls start with a null error pointer. On failure, the bridge captures the returned error for the driver. On success, encoding returns the representation of a Swift Data value, which the driver writes to a scratch file and decodes again before any live replacement.
The increment, and the pointer that mattered
native.c — adding reading time
void history_increment(void *model, double date, int64_t amount, int64_t goal) {
IncrementFn increment = symbol("$s13BookDataStore19ReadingHistoryModelV09incrementD4Time4date6amount11defaultGoaly10Foundation4DateV_S2itF");
increment(&date, amount, goal, model);
}
This is the function that added the missing seconds. The driver already had the intended day and the amount needed to reach its goal:
history_increment(
model,
date.timeIntervalSinceReferenceDate,
Int64(edit["amount"]!),
60
)
The important detail in C is increment(&date, amount, goal, model).
For this entry point on the installed build, Swift expected Date indirectly. Its storage contained the reference-date time interval, so the wrapper put that value in a local double and passed its address. The model pointer received the Swift context treatment specified by SELF. The int64_t arguments also assumed the 64-bit Swift.Int representation used on this Mac.
An earlier attempt declared the date as a directly passed double. The scratch process crashed inside Calendar.component(_:from:), attempting to read address 0x3c: decimal 60, one of the other argument values. The callee was reading the wrong thing as its date pointer. A small Swift probe compiled to LLVM IR confirmed the indirect date argument.
That's a fairly concrete reason to inspect the calling convention before trusting a function-pointer cast. The compiler can accept the cast while the call is still wrong.
Update the record dictionary
native.c — adding streak milestones
void history_set_streak(void *model, void *dictionary_metadata, void *date, int64_t length) {
intptr_t (*offset)(void *, intptr_t) = symbol("swift_reflectionMirror_recursiveChildOffset");
intptr_t field_offset = offset(metadata, 1);
assert(field_offset == 8);
void (*set)(void *, void *, void *, void * SELF) SWIFT = symbol("$s9Coherence15CRDictionary_v1Vyq_Sgxcis");
set(date, &length, dictionary_metadata, (char *)model + field_offset);
}
The Swift driver reflected on the decoded model, checked that the selected field was named streakRecords, and obtained its dictionary type metadata. On this build, it was CRDictionary_v1<Int, Date>.
The C helper asks for the field's offset, asserts the observed eight-byte offset, and calls the installed dictionary setter. The setter gets the optional date value, the integer key, the dictionary metadata, and the address of the dictionary field as its Swift context.
The date argument here is a pointer to the actual Swift Date? representation. The driver prepared it with:
var date: Date? = start.addingTimeInterval(Double(length-1)*86400)
withUnsafeMutablePointer(to: &date) {
history_set_streak(model, dictionaryMetadata, $0, Int64(length))
}
That allowed the native setter to add milestones 81 through 156 while keeping the existing records. The field index, offset, dictionary type, and symbol name are all assumptions that need checking on another build.
Build the bridge and the driver
In the scratch directory, the build commands were:
clang -c native.c -o native.o
swiftc \
-module-cache-path /private/tmp/books-streak-recovery/module-cache \
-import-objc-header native.h \
repair.swift native.o -o repair
-import-objc-header exposes the C declarations to the Swift driver; the header itself contains ordinary C. Linking native.o supplies those helper implementations. The private Swift entry points are resolved later, when the running tool loads the framework.
The full driver also handled the requested dates, error reporting, serialization, and verification. The snippets above focus on the language boundary.
Keep the assumptions visible
This was a short-lived repair process with global state and build-specific layout assumptions. It leaves decoded allocations alive until process exit. A reusable library would need proper Swift value destruction, ownership and alignment handling, allocation checks, and a fuller error lifecycle. _But the goal is to not make this an habit and start reading like a functional human being_
There is also a very practical footgun in the actual code: dlopen sits inside assert. These builds had assertions enabled. Defining NDEBUG would remove the framework load along with the check; a maintained implementation should load it separately and handle failure explicitly.
The code is here because it explains what made the experiment work. It should also explain why I keep saying to leave your reading-history files alone.
And then Books still said two days
After the daily entries were filled, Books recognized another completed day in the current week. The streak still said two days. The record still said 80.
Of course there was more state. It's Apple, there should be something else, somewhere.
Three pieces needed to agree:
- The daily history needed enough credited time for the missing dates.
- streakRecords needed the newly reached streak lengths and their dates.
- ReadingHistory.CurrentStreak needed the new cached count.
The record dictionary ended at 80 days. Codex preserved those entries and added milestones 81 through 156, dated July 30 through October 13. We had to populate this data too.
Swift reflection identified the record field and its dictionary type, and the bridge used the installed dictionary implementation's setter. Another check confirmed that these changes left the daily history unchanged. Finally, with Books closed, the cached count was updated:
BOOKS_PREFS="$HOME/Library/Containers/com.apple.iBooksX/Data/Library/Preferences/com.apple.iBooksX.plist"
defaults write "$BOOKS_PREFS" ReadingHistory.CurrentStreak -int 156
That command only handles the cache. The daily records and streak milestones had already been edited. ReadingHistory.LastStreakDate already contained 20261013, so it stayed unchanged.
Make the app use the edited history
Books and bookdatastored can keep history in memory. Editing the file while they're using it creates an opportunity for a later write to replace your changes. Then we had to close Books app for the cache to be read again.
Before replacement, the live daily history was compared with the version used to prepare the edits. The validated file was staged beside the live one, given the same permissions, flushed, and installed with os.replace. The cache was refreshed while Books remained closed.
Opening Books started the helper again. This time, Reading Goals showed 156 days, a 156-day record, and New Record.
The checks covered both the file and the app. The serialized output was decoded again, its model description was compared with the in-memory result, and every daily date, time, and goal was checked. The app then confirmed that it was displaying the intended result. FINALLY!
The advice I should probably follow
There's an awkward connection to my last article about AI. I wrote about how much easier it is becoming to produce a result, and how we still need to pay attention to the work and understanding behind it:
{% link dev.to %}
Here I was, asking an agent to produce a very impressive number in a book app. It did the software work. It inspected the state, made the edits, and checked the result. My reading habit still needs me to open a book tomorrow, but I'll start from 156 rather than 2 ;)
If you want something practical to do with Books, set a daily goal you can actually meet. On the Mac, that's under Home → Reading Goals → Adjust Goal. Then read. After a missed day, start another streak.
Gotta be honest: my reading goal was 1 minute and even with that low value, I couldn't do it (commitment issues? who?).
The fun lesson, as always, is: you can have the streak you want. It's just a number. The actual value is never in the milestone, is what you learn in the road :)
2 reactions · 1 comment
Swift 6.4 bug report: a `TaskGroup` child returning `(i, result)` from both `do` and `catch` gives `[0,0,0,0]` with `-O`, but `[0,1,2,3]` with `-Onone`. Open issue; narrow trigger.
swift#93118
#SwiftLang
Swift compiler bug report: synthesized enum `hashValue` crashes on x86_64 Linux for a `(UInt128, UInt8)` payload. Reproduced with Swift 6.3.3 and 6.4 at `-Onone`; a misaligned `movaps` load is reported.
swift#93129
#SwiftLang
Mentions
G: Explore Swift: a high-performance, memory-safe programming language with modern syntax and seamless C and Objective-C interoperability. Find learning resources, toolchain guides, and ways to contribute to the community. swiftlang/swift
1: Swift is a high-performance, memory-safe programming language with modern syntax and seamless interoperability with C and Objective-C. Its open-source project provides compiler and toolchain build guides and welcomes community contributions. Learn mor… swiftlang/swift
От 798 до 16 нс: как оптимизация debounce привела меня в Swift Runtime Я прочитал статью Side Effect про debounce на Swift Concurrency и решил проверить, какой прирост производительности дает предложенный там подход. Идея простая: вместо того чтобы создавать новую Task на каждое входящее значение, можно использовать одну и ту же Task для всех событий и продлевать время ожидания. В моем тесте такая замена сократила среднее время выполнения с 798 до 216 нс. Уже хороший результат, но оставалось непонятно, на что уходят эти 216 нс. В большинстве вызовов Task больше не создается: нужно получить текущее время, сохранить новое значение и пересчитать deadline (момент окончания ожидания). В итоге я дошел до 16 нс. По пути пришлось разобраться, что происходит при выполнении этих нескольких строк Swift-кода: как runtime работает с типами, что компилятор может оптимизировать и почему расположение блокировки в памяти влияет на результат. В статье я покажу, как проверял свои гипотезы и что обнаружил в экспериментах. Надеюсь, этот опыт пригодится тем, кто хочет разобраться в производительности своего Swift-кода. Сразу обозначу, что именно здесь ускорилось в 50 раз. Оптимизация относится к среднему времени выполнения одного вызова метода отправки значения. В тесте этот метод вызывался 100 000 раз подряд (без пауз). Метод обновляет состояние debounce и возвращает управление, а обработчик вызывается позже, после заданной задержки с момента последнего значения. В этой статье Переиспользуем одну и ту же Task Результат переиспользования одной Task Может быть, все дело в блокировке? Конкретный тип еще не означает известный layout Заменяем ContinuousClock.Instant на UInt64 Как межмодульная оптимизация влияет на специализацию generic-кода Как расположение блокировки в памяти влияет на производительность Что дали оптимизации на практике Итог Переиспользуем одну и ту же Task Представим поле поиска, в котором мы не хотим отправлять запрос после каждого нажатия на клавиатуре. Для этого используем trailing debounce: после каждого изменения текста откладываем запрос на 500 мс. Если за это время пользователь ничего не ввел, отправляем запрос с последним значением, а если продолжил печатать, то откладываем его еще раз. Самая простая реализация отменяет предыдущую Task и создает следующую: task?.cancel() task = Task { do { try await Task.sleep(for: dueTime) await output(value) } catch { // Отмененная задача завершается без передачи значения. } } sourcecraft.dev Это фрагмент реализации с Task на каждое значение. В таком варианте созданные Task будут отменяться, если новые значения поступают раньше, чем истекает задержка debounce. Но создание Task, запрос на ее отмену и обработка этого запроса все равно требуют ресурсов. Отмена в Swift кооперативная: вызов cancel() не означает, что Task сразу завершилась. Предложенный подход из статьи Side Effect переносит последнее значение и deadline в общее состояние. Первая отправка запускает рабочую задачу. Следующие отправки только меняют состояние. Когда Task просыпается, она проверяет, пора ли вызывать обработчик: если за время сна пришли новые значения, то будет ждать дальше; если после последнего значения прошла заданная задержка, то вызовет обработчик. Одна последовательность входящих значений и два способа управления Task. Рабочая задача может проснуться несколько раз, прежде чем передаст последнее значение. Чтобы отложить обработку, достаточно обновить состояние. Прерывать текущий вызов Task.sleep не требуется. Это важная часть экономии: частые входящие события не превращаются в столь же частые действия планировщика. Реализация emit с переиспользованием TaskВот метод emit исходной реализации с рабочей задачей: public func emit(value: T) { let (shouldStartWorker, initialDeadline) = stateMachine.apply { machine in machine.newValue(value) } guard shouldStartWorker else { return } let worker = Task { [stateMachine, output] in var deadline = initialDeadline while true { try? await Task.sleep(until: deadline, clock: .continuous) let nextStep = stateMachine.apply { machine in machine.sleepIsOver() } switch nextStep { case .finishDebouncing(let latestValue): await output(latestValue) return case .continueDebouncing(let newDueTime): deadline = newDueTime } } } task.set(stored: worker) } sourcecraft.dev Во время уже начатой серии большая часть вызовов заканчивается на guard. Машина состояний (конечный автомат) обновляет ожидающее значение и сообщает, что новая Task не нужна. Результат переиспользования одной Task Сначала сравним две стратегии: Task на каждое значение и одна рабочая задача на всю серию. 100 000 целочисленных значений от одного отправителя, задержка debounce 10 мс. Медиана 15 прогонов после прогрева на M1 Max, macOS 26.7.1, Swift 6.4, release-сборка. Сохраненные результаты. При переходе к одной рабочей задаче среднее время выполнения emit сократилось примерно в 3,7 раза, а CPU-время примерно в 13 раз. Окружение и условия сравненияРезультаты выше получены 13 октября 2026 года на версии кода из коммита 990cf04. При сравнении нужно учитывать несколько отличий: В две исходные реализации я добавил атомарные счетчики созданных Task и завершений Task.sleep. В моей реализации из библиотеки swift-debounce таких счетчиков нет, поэтому дополнительная работа по подсчету есть только у исходных реализаций. Для модуля References включена межмодульная оптимизация (CMO). Но проверка сгенерированного кода показала, что компилятор не создал версии emit, специализированные под Int. Другие оптимизации при этом могли применяться. Реализации по-разному выполняют обработчики: исходная допускает их одновременное выполнение, а swift-debounce выполняет их последовательно. В этом тесте проверяется, что обработчик вызывается один раз и получает последнее отправленное значение. Переиспользование Task дало заметное ускорение, но оставалось понять, на что уходят 216 нс. Дальнейшие эксперименты проводились на разных нагрузках и версиях прототипа. Может быть, все дело в блокировке? В исходной реализации Side Effect общее состояние защищено NSRecursiveLock. При каждом значении мы захватываем блокировку и отпускаем ее, поэтому это было первым местом для проверки. Я сравнил NSRecursiveLock с os_unfair_lock в двух тестах: при увеличении счетчика под блокировкой и при выполнении всего метода emit. Операция, один поток NSRecursiveLock os_unfair_lock Захват, увеличение счетчика, освобождение 17,1 нс 8,0 нс Полный emit 188,0 нс 177,7 нс Ранний эксперимент с блокировками. Полный метод измерялся на миллионе отправок. Его исходные 188 нс относятся к другому тесту, чем 216 нс выше. На отдельной блокировке разница выглядит внушительно: почти вдвое. В полном методе выигрыш составил 10,3 нс, около 5,5%. Большая часть стоимости осталась на месте. Блокировку имело смысл заменить, но оставшиеся затраты одного отправителя она не объясняла. Я перешел к чтению времени и обновлению состояния. Конкретный тип еще не означает известный layout При профилировании метода emit я обнаружил вызовы следующих функций Swift Runtime: type metadata accessor for ContinuousClock.Instant swift_getGenericMetadata swift_getTupleTypeMetadatasourcecraft.dev На первый взгляд странно. deadline хранится в значении конкретного типа ContinuousClock.Instant. В тесте входящее значение тоже конкретное: Int. Откуда работа с метаданными типов? Компилятор может знать конкретный тип, но не иметь гарантий о его размере и расположении полей в памяти. Layout типа определяет его размер, выравнивание и расположение полей в памяти. В режиме library evolution библиотека может менять внутреннее устройство типа-значения без @frozen, сохраняя совместимость с уже собранными клиентами. Для этого клиентский код не должен полагаться на текущий размер структуры и смещения ее полей. Атрибут @frozen дает более жесткие гарантии layout, но в определении ContinuousClock.Instant его нет. В машине состояний deadline хранится внутри enum вместе со значением: struct DebounceStateMachine<T> { enum State { case idle case debouncing( value: T, dueTime: ContinuousClock.Instant, isValueDuringSleep: Bool ) } // ... } sourcecraft.dev При новом значении метод получает текущее время, заменяет содержимое enum и возвращает новый deadline. Для замены значения машинному коду, сгенерированному компилятором, нужно определить расположение полей и записать новые данные. В Swift метаданные типа связаны с value witness table (подробнее о ее роли в generic-коде). В ней есть информация о layout значения и операции копирования, перемещения и уничтожения. Когда layout типа неизвестен при компиляции, сгенерированный машинный код может использовать операции из value witness table для копирования, перемещения и уничтожения значения. Связь метаданных и value witness table описана в документации Swift ABI. Схема объясняет механизм работы с неизвестным layout. В месте вызова компилятору был известен конкретный тип Int. Однако проверка сгенерированного кода показала, что вызывалась generic-версия LongLivedTaskDebounce.emit(value:), рассчитанная на любой допустимый T. В версии, специализированной под Int, компилятор может использовать известные размер и расположение входного значения. Layout Instant при этом остается неизвестным компилятору клиентского модуля. При этом обращение к метаданным не означает, что на каждой отправке создается новый объект или для хранения deadline обязательно выделяется отдельный участок памяти в куче. Метаданные могут переиспользоваться, даже если код продолжает к ним обращаться. Как профилировать самостоятельноВ репозитории есть режим profile: cd Benchmarks swift build -c release .build/release/Locks profile 10 & sample "$!" 5 1 -file debounce-sample.txtsourcecraft.dev Тест работает десять секунд; sample собирает стеки пять секунд с интервалом в одну миллисекунду. В историческом профиле исходная версия с рабочей задачей называется OptimizedDebounce. Состав символов в новом запуске может отличаться от сохраненного профиля. Заменяем ContinuousClock.Instant на UInt64 Следующим шагом я решил проверить работу со временем: как мы получаем текущее значение и храним deadline. Сначала сравнил ContinuousClock.now и mach_continuous_time(). Каждый из них вызывался в цикле. Среднее время одной итерации составило 28,9 нс для ContinuousClock.now и 5,0 нс для mach_continuous_time(). Медиана семи однопоточных прогонов по пять миллионов итераций после прогрева. Тесты по-разному обрабатывают полученное время, поэтому числа включают не только вызов функции часов, но и дополнительную работу внутри цикла. Вместо ContinuousClock.Instant я стал хранить deadline в UInt64. На Darwin текущее время теперь получаю через mach_continuous_time(). Значение deadline хранится как обычное целое число, layout которого известен компилятору. Чтение времени и преобразование тиковТак выглядит wrapper над часами: @usableFromInline enum _MonotonicClock { @inlinable @inline(__always) static func now() -> UInt64 { #if canImport(Darwin) return mach_continuous_time() #else var time = timespec() clock_gettime(CLOCK_BOOTTIME, &time) let secondsInNanoseconds = UInt64(time.tv_sec) &* 1_000_000_000 return secondsInNanoseconds &+ UInt64(time.tv_nsec) #endif } } sourcecraft.dev mach_continuous_time() возвращает время в тиках, которые не обязательно равны наносекундам. При инициализации заданная задержка debounce переводится в тики. Перед вызовом Task.sleep рабочая задача преобразует оставшееся время обратно в наносекунды. Поэтому при каждом вызове send пересчитывать единицы времени не нужно. Я одновременно изменил способ получения текущего времени и тип deadline, поэтому по этим замерам нельзя определить, какой вклад внесло каждое изменение. При повторном профилировании я снова обнаружил обращения к метаданным. Теперь они были связаны с полем Value?, в котором сохраняется последнее значение для обработки. Как межмодульная оптимизация влияет на специализацию generic-кода Теперь нужно было разобраться с обновлением Value?. В тесте используется Int, но метод emit мог по-прежнему выполняться как общая generic-функция. Я проверил, поможет ли здесь межмодульная оптимизация -cross-module-optimization (CMO). Она позволяет оптимизировать код через границы модулей, в том числе создавать специализированные версии generic-функций. Для сравнения я собрал каждую реализацию с CMO и без него. В каждом запуске тест примерно три секунды подряд вызывал emit, передавая новые значения, и подсчитывал количество вызовов. Переработанный прототип с CMO успел выполнить около 258 млн вызовов вместо 28 млн. У исходной реализации с одной рабочей задачей результат почти не изменился: около 16 млн вызовов в обоих случаях. Реализация Без CMO, вызовов emit за ≈3 с С CMO, вызовов emit за ≈3 с Прототип с deadline в UInt64 27 580 000 257 900 000 Исходная рабочая задача 15 770 000 15 680 000 Для каждого варианта сохранен результат одного запуска, поэтому судить о разбросе результатов пока нельзя. Каждый запуск был рассчитан примерно на три секунды; время проверялось после каждых 10 000 вызовов emit. Результаты эксперимента. Это измерение пропускной способности отдельного прототипа, а не еще один множитель, который можно применить к исходным 798 нс. CMO заметно помог переработанному прототипу, но почти не повлиял на исходную реализацию. Далее, чтобы разобраться в причине, я решил проверил, какой машинный код создал компилятор. SIL исходной реализации: generic-вызов emitНиже фрагмент SIL. Для читаемости я перенес длинные строки и убрал служебные комментарии после инструкций: // function_ref LongLivedTaskDebounce.emit(value:) %6 = function_ref @$s10References21LongLivedTaskDebounceC4emit5valueyx_tF : $@convention(method) <τ_0_0 where τ_0_0 : Sendable> (@in_guaranteed τ_0_0, @guaranteed LongLivedTaskDebounce<τ_0_0>) -> () %7 = apply %6<Int>(%4, %0) : $@convention(method) <τ_0_0 where τ_0_0 : Sendable> (@in_guaranteed τ_0_0, @guaranteed LongLivedTaskDebounce<τ_0_0>) -> () sourcecraft.dev function_ref ссылается на generic-версию emit: в сигнатуре остается параметр типа τ_0_0. Следующая инструкция, apply %6<Int>, вызывает ее с типом Int. Запись <Int> здесь означает передачу конкретного типа в generic-функцию, а не вызов отдельной специализации. Несмотря на включенный CMO, в этом месте вызывается общий метод. Один из способов оптимизации generic-кода — специализация. Компилятор создает версию функции для конкретного типа. Например, для Debouncer<Int> он может заменить общие операции над Value и Value? операциями над Int и Optional<Int>. В отдельном эксперименте я сохранил ContinuousClock.Instant, но заменил обертку SafeStorage с NSRecursiveLock на вариант с os_unfair_lock. С включенным CMO компилятор создал специализацию emit под Int. Получается, специализация была возможна и без замены Instant на UInt64. SIL после замены NSRecursiveLock на os_unfair_lockВо втором варианте той же проверки я заменил блокировку внутри SafeStorage на os_unfair_lock. ContinuousClock.Instant, алгоритм debounce и параметры компиляции оставил прежними. Теперь вызывающий модуль содержит такой фрагмент: // function_ref specialized LongLivedTaskDebounce.emit(value:) %4 = function_ref @$s10References21LongLivedTaskDebounceC4emit5valueyx_tFSi_Tg5 : $@convention(method) (Int, @guaranteed LongLivedTaskDebounce<Int>) -> () %5 = apply %4(%1, %0) : $@convention(method) (Int, @guaranteed LongLivedTaskDebounce<Int>) -> () sourcecraft.dev В комментарии к function_ref появилось specialized, а в сигнатуре вместо параметра типа указаны Int и LongLivedTaskDebounce<Int>. Инструкция apply %4 вызывает именно эту версию emit. Вызов отдельной функции сохранился: здесь видна специализация, а не подстановка ее тела. Оба варианта собраны с включенным CMO. Еще одна оптимизация — inlining, то есть подстановка тела функции в место вызова. Она может помочь дальнейшим оптимизациям, но специализация возможна и без нее: компилятор может создать отдельную функцию для конкретного типа и оставить ее вызов. В swift-debounce я пометил функции, которые вызываются из send, атрибутом @inlinable. Благодаря этому их код доступен оптимизатору при компиляции модуля, использующего библиотеку. Это дает возможность выполнить inlining или специализацию, но не гарантирует их. CMO тоже может сделать тела функций доступными для оптимизации без @inlinable. Подробнее эти механизмы описаны в SE-0193. Функция send получилась такой: @inlinable public func send(_ value: Value) { let now = _MonotonicClock.now() storage.withLock { state in let shouldStartWorker = state.machine.send(value, now: now) if shouldStartWorker { core.startWorker(&state) } } } sourcecraft.dev Недостаточно сделать доступным оптимизатору только тело send. Операция замены trailingValue: Value? находится глубже, в машине состояний, а доступ к ней проходит через withLock. Если тела этих функций недоступны, оптимизатор не сможет проанализировать обновление состояния через эту цепочку вызовов. Как оптимизатор видит обновление состоянияПоэтому метод withLock тоже помечен @inlinable: @inlinable @inline(__always) func withLock<Result>(_ body: (inout State) -> Result) -> Result { withUnsafeMutablePointers { statePointer, lockElements in let lock = lockElements + Self.lockIndex _lock(lock) defer { _unlock(lock) } return body(&statePointer.pointee) } } sourcecraft.dev В машине состояний новое значение обновляет deadline и заменяет сохраненное значение, если предыдущий deadline еще не наступил. Ветку else здесь опускаю: @inlinable mutating func send(_ value: Value, now: UInt64) -> Bool { let newDeadline = _deadline(after: now, adding: interval) if let currentDeadline = burstDeadline, now < currentDeadline { burstDeadline = max(currentDeadline, newDeadline) if edges.contains(.trailing) { trailingValue = value } } else { // Начало новой серии опущено. } if isWorkerActive { return false } isWorkerActive = true return true } sourcecraft.dev Стрелки показывают доступность кода для компилятора приложения. @inlinable открывает тело функции для анализа с учетом конкретного типа Int. Inlining и специализация не гарантированы; CMO тоже может обеспечить доступ к телам функций. Здесь важно различать @inlinable и @usableFromInline. Второй атрибут позволяет ссылаться на internal-декларацию из доступного клиентам тела и делает ее частью ABI. Сам по себе он не раскрывает тело функции для оптимизации. Метод startWorker, который создает рабочую Task, помечен @usableFromInline. Это позволяет вызывать его из @inlinable-метода send, не открывая тело startWorker для оптимизации. Большинство вызовов send только обновляют состояние и до startWorker не доходят. Как расположение блокировки в памяти влияет на производительность На влияние расположения блокировки в памяти я наткнулся случайно, когда уже готовил репозиторий к публикации на GitHub. Я сравнивал несколько способов хранения состояния и блокировки, чтобы выбрать реализацию с поддержкой старых версий iOS. В одном из вариантов состояние и блокировка размещались в одной аллокации с помощью ManagedBuffer. В другом память для блокировки выделялась отдельно от состояния. При сравнении обнаружился неожиданный результат: отдельная аллокация для блокировки замедляла отправку значений из одного потока, но ускоряла ее при одновременных вызовах из нескольких потоков. Я предположил, что при нескольких потоках помогает именно расстояние между блокировкой и состоянием. Чтобы это проверить, я оставил их в одной аллокации, но добавил между ними свободное место (padding размером 128 байт). Так можно было отодвинуть блокировку от состояния, не выделяя для нее память отдельно. В реализации хранилища положение блокировки вычисляется так: @inlinable @inline(__always) static var lockIndex: Int { let paddingBytes = 128 let lockStride = MemoryLayout<_PlatformLock>.stride return (paddingBytes + lockStride - 1) / lockStride } sourcecraft.dev Без padding блокировка находится в начале области элементов ManagedBuffer. В варианте с padding перед ней зарезервированы 128 байт. Состояние остается на прежнем месте. Размеры блоков показаны условно. При одновременной работе восьми потоков получились такие результаты: Тест Без padding С padding Захват блокировки, увеличение счетчика и освобождение блокировки 24,9 нс 37,1 нс Вызов emit в упрощенной реализации debounce 79,7 нс 54,2 нс В тесте с восемью потоками padding увеличил время операции со счетчиком под блокировкой, но сократил время выполнения emit. При этом расход памяти вырос со 160 до 288 байт на экземпляр тестового BoxedDebounce. Для времени показаны медианы девяти запусков; в каждом запуске общее время теста делилось на число операций всех потоков. Результаты опыта с хранилищем. В тесте блокировки вариант без padding оказался быстрее: 24,9 нс против 37,1 нс. Но при вызове emit результат получился обратным: с padding 54,2 нс, без него 79,7 нс. Поэтому выбирать расположение блокировки стоит по результатам той операции, которую она защищает. Возможная причина — это false sharing. Кэш процессора работает с блоками памяти (кэш-линиями). Если блокировка и защищаемое состояние попадают в одну кэш-линию, потоки могут мешать друг другу: один обновляет состояние, а другие в это время пытаются захватить блокировку. Padding позволяет разнести блокировку и состояние, что может уменьшить эти помехи. Такой случай описан в документации ядра Linux. Он подходит под наблюдаемый результат, но я не проверял эту гипотезу. Сравнение пяти вариантов хранения и разброс результатовВ тесте сравнивались пять вариантов хранения состояния и блокировки. Во всех использовалась одна и та же упрощенная реализация debounce. Ниже показано время на один вызов emit: медиана девяти запусков, а в скобках минимальный и максимальный результаты. Память измерялась на экземпляр тестового BoxedDebounce. Хранилище Один поток, нс/вызов Восемь потоков, нс/вызов Память на экземпляр, байт Synchronization.Mutex 13,2 [13,1–13,3] 86,8 [77,5–94,3] 128 ManagedBuffer, без padding 13,1 [13,0–13,2] 79,7 [73,7–97,7] 160 ManagedBuffer, блокировка с padding 13,2 [13,2–13,3] 54,2 [51,9–62,6] 288 OSAllocatedUnfairLock 13,1 [13,1–13,2] 84,6 [71,3–118,8] 160 Отдельная аллокация блокировки 17,9 [17,8–18,0] 65,7 [62,7–67,5] 144 В однопоточном тесте выполнялись два миллиона вызовов emit, в многопоточном по 100 000 из каждого из восьми потоков. Задержка debounce составляла 20 мс. Перед замерами выполнялся прогрев, затем варианты проверялись по очереди в девяти раундах. Время на вызов рассчитано по общей длительности теста и суммарному числу вызовов. Точка на графике медиана, отрезок минимум и максимум. При работе из одного потока оба варианта ManagedBuffer показали около 13 нс на вызов. При восьми потоках диапазон результатов с padding составил 51,9–62,6 нс, без padding 73,7–97,7 нс. В этих девяти запусках диапазоны не пересекались. В swift-debounce я оставил вариант с padding. В тестах он ускорил обновление состояния при работе из нескольких потоков, практически не повлияв на результат одного потока. Что дали оптимизации на практике Теперь сравним три реализации: новую Task на каждое значение, одну рабочую задачу из статьи Side Effect и swift-debounce. Реализация Среднее время одного вызова Время цикла отправки CPU-время процесса Task на каждое значение 798 нс 79,82 мс 280,81 мс Одна рабочая задача 216 нс 21,61 мс 21,91 мс swift-debounce 16 нс 1,57 мс 1,72 мс 100 000 целочисленных значений от одного отправителя, задержка debounce 10 мс. Медиана 15 прогонов на M1 Max, macOS 26.7.1, Swift 6.4, release-сборка. Сохраненные результаты. CPU-время включает работу всех потоков за период отправки значений, ожидания результата и завершения отмененных задач. При 100 000 вызовов подряд среднее время выполнения send в swift-debounce составило 16 нс. При 1000 вызовах уже 41 нс: затраты на создание рабочей задачи распределяются на меньшее число вызовов. Затем я проверил другой сценарий: 200 значений с интервалом 5 мс. Суммарное CPU-время составило 31,10 мс для реализации с новой Task на каждое значение, 21,65 мс для одной рабочей задачи и 21,22 мс для swift-debounce. Здесь основную экономию дало переиспользование Task. Дополнительные оптимизации send повлияли на суммарное CPU-время гораздо меньше. Итог Все началось с простой идеи: перестать создавать новую Task для каждого значения. Уже это сократило среднее время отправки с 798 до 216 нс. Но мне стало интересно, куда уходят оставшиеся наносекунды. Так оптимизация debounce постепенно превратилась в исследование Swift Runtime и работы компилятора. Больше всего меня удивило, насколько результаты могут расходиться с первоначальными ожиданиями. Я думал, что основная проблема в блокировке, но ее замена объяснила лишь небольшую часть затрат. Позже оказалось, что даже расположение блокировки в памяти влияет на результат, причем отдельный тест блокировки и обновление состояния debounce показали противоположную картину. А чтобы разобраться с остальными затратами, пришлось смотреть уже не только на Swift-код, но и на то, что из него генерирует компилятор. В итоге в тесте со 100 000 вызовов подряд среднее время выполнения одного вызова сократилось с 798 до 16 нс, то есть примерно в 50 раз. Для меня самое интересное в этой истории - это сколько всего обнаружилось за несколькими строками обновления состояния. Даже простой на вид метод может привести к вопросам о layout типов, специализации generic-кода и работе процессорного кэша. Получившуюся реализацию я опубликовал в swift-debounce, а код экспериментов и результаты замеров оставил в репозитории с бенчмарками.
Swiftのおもしろ不具合かも、dispatch thunkを期待するけどnon-resilientな構成でprotocolのdispatch thunkが生成されない。呼び出し元もisResilientがなくwitness table/dispatch thunkを分岐しないから特定の環境でコンパイルエラー swift#93195
@available(macOS 15.0, *) @available(MyLib_v1_0, *) public func api() {} The above Swift snippet may work for normal build. But will break the swiftinterface for binary integration. Fix PR: swift#93418
The idea is to avoid expensive value witness operations in favor of simple 𝚜𝚠𝚒𝚏𝚝_𝚛𝚎𝚝𝚊𝚒𝚗 and 𝚜𝚠𝚒𝚏𝚝_𝚛𝚎𝚕𝚎𝚊𝚜𝚎 calls. swift#93322
Has anyone else noticed how much the Preview macro slows down your builds?
I used AI to cheat my way to a 156-day reading streak I asked Codex to recover my Apple Books reading streak. Very important engineering work, obviously. Books showed two days. I had an 80-day run in the history, then a gap, then a few scattered reading days. It happens to the best of us. The app was reporting exactly what it had recorded. I wanted the old streak back anyway, so I asked an agent to fill the gap. It ended up showing 156 days and New Record. I have read so much. Before we go any further: don't try this at home. This is cheating. Do actual reading. The agent added reading time to dates that were below the goal. Those entries were manufactured. Nobody read 71 extra days while a script was running. But the process was interesting. It involved private Swift APIs, backups, a background process, and, of course, a cache. Apparently even my questionable reading decisions need cache invalidation. Start with the embarrassing part My daily goal was one minute. One minute. Apple Books was already being very generous with me. Had it on 15 mins a day, but I had to travel and reduced to 1 minute (talk about commitment issues). The old streak ran from May 11 through July 29, 2026. Continuing it through October 13 would make 156 calendar days. Of those, 85 already met their goals: the original 80, plus five later dates. That left 71 dates to fill. Seventy had no daily record. August 1 had six seconds, so it needed another 54. How do you even get six seconds of reading? IDK. A date that already met its goal needed no changes, even if it contained much more reading time. The calculation was small enough: # Pseudocode for day in requested_range: goal = stored_goal(day, default=60) recorded = recorded_seconds(day, default=0) additional = max(0, goal - recorded) if additional: increment_reading_time(day, additional, default_goal=60) The dates also needed the correct timezone. The edits used America/El_Salvador and local noon, keeping each increment inside its intended calendar day. It would be a pretty annoying way to discover a timezone bug, after going through all this to change a number. A big part of this was possible (within the 5 hour window of my $20 codex subscription) thanks to the work previously done to understand the Apple books data model. Give it a read if you want to read about sqlite databases. {% link dev.to %} Now, let's start. Make the backup before getting clever The history file involved was: ~/Library/Group Containers/group.com.apple.iBooks/CRDTModelLocalFile/ReadingHistoryModel Before changing it, Codex backed up the history, its context and sync metadata, and the relevant Books preferences. The SQLite context databases were copied through SQLite's backup API. A permanent backup went into Documents, separate from the scratch files. Then the experiments happened on copies. That was useful because some of the first attempts at calling Apple's private Swift code failed. An agent having access to your files is a good reason to make the original easy to restore. Keep the backup somewhere separate, give it a name you understand, and be clear about which dates the agent is allowed to change. In this case, the scope was the missing goal time within one specific streak. The C bridge that called Apple's Swift code This was the part I wanted to understand properly. Codex found an operation in the installed frameworks: ReadingHistoryModel.incrementReadingTime(date:amount:defaultGoal:) But having a method name and calling the method are two separate problems. There was no available SDK module to import for this private interface. The tool used a C bridge to load the framework, resolve the Swift entry points, and call them with the required binary calling convention. The calls went in both directions. The Swift driver called ordinary C functions declared in native.h. Those C functions called Apple's compiled Swift functions through typed function pointers. The framework was loaded at runtime with dlopen; the C object file was linked into the driver at build time. Below is the complete C bridge used in this experiment, split into pieces so the explanation can stay next to the code. These are the actual functions and symbols used on this Mac. The Swift snippets are excerpts from the driver. Start with the C interface native.h #include <stdint.h> typedef struct { uint64_t a, b; } Bits; void *history_decode(uint64_t a, uint64_t b); Bits history_describe(void *model); Bits history_encode(void *model); void history_increment(void *model, double date, int64_t amount, int64_t goal); void *history_error(void); void *history_type(void); void history_set_streak(void *model, void *dictionary_metadata, void *date, int64_t length); Bits carries two 64-bit words across the C boundary. On this installation, that matched the representation used for the Data arguments and the Data and String results involved in these calls. Those words can contain storage references or inline information; they are the Swift value's representation, rather than a byte-buffer API. The C interface exposes opaque pointers for the model and its metadata. The driver uses history_error() to retrieve a captured Swift error and history_type() when reflecting on the model later. Tell Clang how to call Swift native.c — attributes, function types, and symbol lookup #include "native.h" #include <dlfcn.h> #include <assert.h> #include <stdio.h> #include <stdlib.h> #define SWIFT __attribute__((swiftcall)) #define SELF __attribute__((swift_context)) #define ERROR __attribute__((swift_error_result)) #define RESULT __attribute__((swift_indirect_result)) typedef struct { void *value; uintptr_t state; } MetadataResponse; typedef MetadataResponse (*MetadataFn)(uintptr_t) SWIFT; typedef void (*DecodeFn)(void * RESULT, uint64_t, uint64_t, void *, void *, void * SELF, void ** ERROR) SWIFT; typedef Bits (*BitsFn)(void * SELF) SWIFT; typedef Bits (*EncodeFn)(void *, void *, void *, void *, void * SELF, void ** ERROR) SWIFT; typedef void (*IncrementFn)(void *, int64_t, int64_t, void * SELF) SWIFT; static void *metadata, *witness, *last_error; void *history_error(void) { return last_error; } void *history_type(void) { return metadata; } static void *symbol(const char *name) { void *value = dlsym(RTLD_DEFAULT, name); if (!value) { fprintf(stderr, "Missing symbol: %s\n", name); exit(1); } return value; } The attributes carry the calling-convention information: | Macro | Role in the bridge | | --- | --- | | SWIFT | Call the function using Swift's calling convention | | SELF | Give the context parameter its special Swift ABI treatment | | RESULT | Pass storage for an indirectly returned value | | ERROR | Receive the error result from a throwing Swift call | > # What's ABI > > No, it's not the name of a girl ;) even tho it may be. ABI stands for Application Binary Interface. In short, it defines how already compiled Swift code interacts at the binary level and how the code talks to the runtime. > Type layout conventions, representation, metadata, etc. > You can find way more about this on the web, and probably better explained :) Clang still needs a function type that describes the lowered arguments correctly. swiftcall handles their low-level placement; the bridge must supply indirect pointers, generic arguments, context, and error parameters in the right shape. The Clang attribute reference documents this distinction. The symbol() helper resolves an address with dlsym. The $s... strings are Swift's mangled symbol names. They carry information about the module, type, operation, and signature. One of the names used here can be inspected with: xcrun swift-demangle --compact '$s13BookDataStore19ReadingHistoryModelV09incrementD4Time4date6amount11defaultGoaly10Foundation4DateV_S2itF' The result identifies ReadingHistoryModel.incrementReadingTime(date:amount:defaultGoal:). Finding that address gives us a place to call. The typedef determines how the call gets there. Decode a type C doesn't know about native.c — loading and decoding void *history_decode(uint64_t a, uint64_t b) { assert(dlopen("/System/Library/PrivateFrameworks/BookDataStore.framework/BookDataStore", RTLD_NOW | RTLD_GLOBAL)); MetadataFn meta = symbol("$s13BookDataStore19ReadingHistoryModelVMa"); metadata = meta(0).value; void *(*conforms)(void *, void *) = symbol("swift_conformsToProtocol"); witness = conforms(metadata, symbol("$s9Coherence4CRDTMp")); assert(metadata && witness); uintptr_t *vwt = ((uintptr_t **)metadata)[-1]; size_t size = vwt[8]; assert(size > 0 && size < 4096); void *model = calloc(1, size); void *error = NULL; DecodeFn decode = symbol("$s9Coherence4CRDTPAAE14serializedDatax10Foundation0D0V_tKcfC"); decode(model, a, b, metadata, witness, metadata, &error); if (error) { last_error = error; return NULL; } fprintf(stderr, "Native model decoded (%zu bytes)\n", size); return model; } dlopen loads BookDataStore. RTLD_GLOBAL makes its loaded exports available to the subsequent RTLD_DEFAULT lookups; the required Coherence symbols were available through the loaded dependencies. The metadata accessor returns the runtime type information for ReadingHistoryModel. MetadataResponse models its pointer-and-state result; meta(0).value keeps the metadata pointer. swift_conformsToProtocol then retrieves its conformance to Coherence.CRDT. That conformance is represented by a witness table: the information the generic decoder needs to use this particular type's implementation. C also needs somewhere to put the decoded value. The pointer immediately before the metadata points to its value witness table, which includes layout and value-management information. The bridge reads the size slot used by this ABI, allocates storage, and passes it as the indirect result. The decoded model occupied 24 bytes in this run. Swift's type metadata documentation describes the metadata and value witness table relationship. The decoder call has more arguments than its Swift source-level spelling suggests. It receives the output storage, the two Data words, generic type metadata, the protocol witness table, the initializer's metatype context, and the error result. The metadata pointer appears twice because those are distinct roles in this entry point. The driver supplied the Data representation like this: import Foundation let arguments = CommandLine.arguments guard arguments.count >= 3 else { fatalError("input output [increments.json]") } let data = try Data(contentsOf: URL(fileURLWithPath: arguments[1])) precondition(MemoryLayout<Data>.size == MemoryLayout<Bits>.size) let bits = unsafeBitCast(data, to: Bits.self) guard let model = history_decode(bits.a, bits.b) else { let error = unsafeBitCast(history_error()!, to: (any Error).self) print("Native decode failed: \(error)") exit(1) } unsafeBitCast reinterprets the value's bits. The size check catches a size mismatch, but it does not establish that an arbitrary Swift type can safely be passed this way. This bridge depended on the representation and ownership behavior of these specific calls. Inspect and serialize the model native.c — description and encoding Bits history_describe(void *model) { BitsFn describe = symbol("$s13BookDataStore19ReadingHistoryModelV16debugDescriptionSSvg"); return describe(model); } Bits history_encode(void *model) { EncodeFn encode = symbol("$s9Coherence11CRMergeableP14serializedData_7version10Foundation0D0VAA9CRContextC_AA16CRCodableVersionOtKFTj"); void *(*conforms)(void *, void *) = symbol("swift_conformsToProtocol"); void *mergeable_witness = conforms(metadata, symbol("$s9Coherence11CRMergeableMp")); assert(mergeable_witness); MetadataFn context_meta = symbol("$s9Coherence9CRContextCMa"); void *(*create_context)(void * SELF) SWIFT = symbol("$s9Coherence9CRContextC6sharedACvgZ"); void *context = create_context(context_meta(0).value); uint8_t version = *(uint32_t *)symbol("$s9Coherence16CRCodableVersionO8version3yA2CmFWC"); void *error = NULL; Bits bits = encode(context, &version, metadata, mergeable_witness, model, &error); if (error) { last_error = error; return (Bits){0,0}; } return bits; } history_describe calls the model's debug-description getter. The Swift driver interprets the returned Bits as a String and uses that description in its round-trip checks. Encoding uses a separate CRMergeable conformance, its serialization dispatch thunk, and the shared CRContext. The bridge resolves the installed enum case tag for version3 and passes its address to the encoder. That tag lookup and argument representation were checked for this installation. Both throwing calls start with a null error pointer. On failure, the bridge captures the returned error for the driver. On success, encoding returns the representation of a Swift Data value, which the driver writes to a scratch file and decodes again before any live replacement. The increment, and the pointer that mattered native.c — adding reading time void history_increment(void *model, double date, int64_t amount, int64_t goal) { IncrementFn increment = symbol("$s13BookDataStore19ReadingHistoryModelV09incrementD4Time4date6amount11defaultGoaly10Foundation4DateV_S2itF"); increment(&date, amount, goal, model); } This is the function that added the missing seconds. The driver already had the intended day and the amount needed to reach its goal: history_increment( model, date.timeIntervalSinceReferenceDate, Int64(edit["amount"]!), 60 ) The important detail in C is increment(&date, amount, goal, model). For this entry point on the installed build, Swift expected Date indirectly. Its storage contained the reference-date time interval, so the wrapper put that value in a local double and passed its address. The model pointer received the Swift context treatment specified by SELF. The int64_t arguments also assumed the 64-bit Swift.Int representation used on this Mac. An earlier attempt declared the date as a directly passed double. The scratch process crashed inside Calendar.component(_:from:), attempting to read address 0x3c: decimal 60, one of the other argument values. The callee was reading the wrong thing as its date pointer. A small Swift probe compiled to LLVM IR confirmed the indirect date argument. That's a fairly concrete reason to inspect the calling convention before trusting a function-pointer cast. The compiler can accept the cast while the call is still wrong. Update the record dictionary native.c — adding streak milestones void history_set_streak(void *model, void *dictionary_metadata, void *date, int64_t length) { intptr_t (*offset)(void *, intptr_t) = symbol("swift_reflectionMirror_recursiveChildOffset"); intptr_t field_offset = offset(metadata, 1); assert(field_offset == 8); void (*set)(void *, void *, void *, void * SELF) SWIFT = symbol("$s9Coherence15CRDictionary_v1Vyq_Sgxcis"); set(date, &length, dictionary_metadata, (char *)model + field_offset); } The Swift driver reflected on the decoded model, checked that the selected field was named streakRecords, and obtained its dictionary type metadata. On this build, it was CRDictionary_v1<Int, Date>. The C helper asks for the field's offset, asserts the observed eight-byte offset, and calls the installed dictionary setter. The setter gets the optional date value, the integer key, the dictionary metadata, and the address of the dictionary field as its Swift context. The date argument here is a pointer to the actual Swift Date? representation. The driver prepared it with: var date: Date? = start.addingTimeInterval(Double(length-1)*86400) withUnsafeMutablePointer(to: &date) { history_set_streak(model, dictionaryMetadata, $0, Int64(length)) } That allowed the native setter to add milestones 81 through 156 while keeping the existing records. The field index, offset, dictionary type, and symbol name are all assumptions that need checking on another build. Build the bridge and the driver In the scratch directory, the build commands were: clang -c native.c -o native.o swiftc \ -module-cache-path /private/tmp/books-streak-recovery/module-cache \ -import-objc-header native.h \ repair.swift native.o -o repair -import-objc-header exposes the C declarations to the Swift driver; the header itself contains ordinary C. Linking native.o supplies those helper implementations. The private Swift entry points are resolved later, when the running tool loads the framework. The full driver also handled the requested dates, error reporting, serialization, and verification. The snippets above focus on the language boundary. Keep the assumptions visible This was a short-lived repair process with global state and build-specific layout assumptions. It leaves decoded allocations alive until process exit. A reusable library would need proper Swift value destruction, ownership and alignment handling, allocation checks, and a fuller error lifecycle. _But the goal is to not make this an habit and start reading like a functional human being_ There is also a very practical footgun in the actual code: dlopen sits inside assert. These builds had assertions enabled. Defining NDEBUG would remove the framework load along with the check; a maintained implementation should load it separately and handle failure explicitly. The code is here because it explains what made the experiment work. It should also explain why I keep saying to leave your reading-history files alone. And then Books still said two days After the daily entries were filled, Books recognized another completed day in the current week. The streak still said two days. The record still said 80. Of course there was more state. It's Apple, there should be something else, somewhere. Three pieces needed to agree: - The daily history needed enough credited time for the missing dates. - streakRecords needed the newly reached streak lengths and their dates. - ReadingHistory.CurrentStreak needed the new cached count. The record dictionary ended at 80 days. Codex preserved those entries and added milestones 81 through 156, dated July 30 through October 13. We had to populate this data too. Swift reflection identified the record field and its dictionary type, and the bridge used the installed dictionary implementation's setter. Another check confirmed that these changes left the daily history unchanged. Finally, with Books closed, the cached count was updated: BOOKS_PREFS="$HOME/Library/Containers/com.apple.iBooksX/Data/Library/Preferences/com.apple.iBooksX.plist" defaults write "$BOOKS_PREFS" ReadingHistory.CurrentStreak -int 156 That command only handles the cache. The daily records and streak milestones had already been edited. ReadingHistory.LastStreakDate already contained 20261013, so it stayed unchanged. Make the app use the edited history Books and bookdatastored can keep history in memory. Editing the file while they're using it creates an opportunity for a later write to replace your changes. Then we had to close Books app for the cache to be read again. Before replacement, the live daily history was compared with the version used to prepare the edits. The validated file was staged beside the live one, given the same permissions, flushed, and installed with os.replace. The cache was refreshed while Books remained closed. Opening Books started the helper again. This time, Reading Goals showed 156 days, a 156-day record, and New Record. The checks covered both the file and the app. The serialized output was decoded again, its model description was compared with the in-memory result, and every daily date, time, and goal was checked. The app then confirmed that it was displaying the intended result. FINALLY! The advice I should probably follow There's an awkward connection to my last article about AI. I wrote about how much easier it is becoming to produce a result, and how we still need to pay attention to the work and understanding behind it: {% link dev.to %} Here I was, asking an agent to produce a very impressive number in a book app. It did the software work. It inspected the state, made the edits, and checked the result. My reading habit still needs me to open a book tomorrow, but I'll start from 156 rather than 2 ;) If you want something practical to do with Books, set a daily goal you can actually meet. On the Mac, that's under Home → Reading Goals → Adjust Goal. Then read. After a missed day, start another streak. Gotta be honest: my reading goal was 1 minute and even with that low value, I couldn't do it (commitment issues? who?). The fun lesson, as always, is: you can have the streak you want. It's just a number. The actual value is never in the milestone, is what you learn in the road :)
Swift 6.4 bug report: a `TaskGroup` child returning `(i, result)` from both `do` and `catch` gives `[0,0,0,0]` with `-O`, but `[0,1,2,3]` with `-Onone`. Open issue; narrow trigger. swift#93118 #SwiftLang
Swift compiler bug report: synthesized enum `hashValue` crashes on x86_64 Linux for a `(UInt128, UInt8)` payload. Reproduced with Swift 6.3.3 and 6.4 at `-Onone`; a misaligned `movaps` load is reported. swift#93129 #SwiftLang