От 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, а код экспериментов и результаты замеров оставил в репозитории с бенчмарками.
Actuali iOS app v1.0.14
App Store / GitHub
Hey all, v1.0.14 is now on the App Store _(v1.0.13 + some late bug fixes)_. Once again it was largely driven by bug reports & feature requests from this sub and Discord. Big update this time, I try to release fortnightly but last time I wasn't happy with the state of the app so we've now got a month worth of features and fixes.
New features:
- Redesigned Add Transaction - a single page with grouped cards, a +/- sign key on the amount, and transfers picked straight from the payee list
- Tags - manage tags _(More → Tags)_, # autocomplete in notes, coloured tag chips on transactions, and per-tag totals
- Account groups, plus drag-and-drop account reordering (groups require Actual Server 26.9.0+)
- Loan & Payoff Estimates, plus savings/deposit growth projections
- Running balance in account registers (toggle from ... menu)
- Upcoming scheduled transactions shown in account views and All Accounts (u/EjayT06)
- Month notes on the Budget tab (u/EjayT06)
- Search in account view
- Change budget type (Envelope ↔ Tracking) from within the app
- Reorder categories and groups from the Budget tab, and rename categories from the long-press menu
- Pinned group headers with group balance and long-press actions, in both Clean and Compact views
- Bulk actions can now categorise, tag and merge selected transactions, and show the selection total
- Category balance impact popup after changing a transaction
- Pin a category on Log Transaction shortcuts (u/AwPravda, u/eleirbagg, u/vixalien)
- Exclude categories from Spent totals, applied to both Budget and Accounts summaries
- Per-category progress bar visibility, plus an inverse progress bar option
- More Budget View options: Show Spent is now in Budget View settings and on by default (u/michikade), hide budgeted amounts, hide the Income group, and show/hide Overview in Clean view
- Budget tab toolbar reorganised: budget actions under their own button, Apply Budget Template from the category long-press menu, and the budget selector in the menu
- Split transactions show each split line on its own row
- Home Screen quick action to add a transaction
- Toggle to turn off auto-picking a category from payee history _(More → Transactions & Automation)_
- Apple Wallet sync now wakes in the background when Wallet data changes
- Bank sync updates a pending charge's amount when it settles _(tips, fuel holds)_, and Wallet sync removes pending holds that get rejected
- Bank sync respects Actual's "import pending transactions" setting
- Pending imports can be saved as transfers, plus a quick clear button for notes and an unmapped card warning
- Diagnostic report under Support & Diagnostic to help troubleshoot connection and sync issues
- Self-signed certificate help in Support
- Added VND currency support, plus a Currency Settings shortcut when a currency mismatch is detected
Bugs and improvements:
- Deleting a transfer now deletes both sides
- Deleted SimpleFIN transactions stay deleted
- Currency preference no longer resets
- Categories you pick are kept when approving pending imports
- Schedule rule actions _(e.g. notes)_ now apply to scheduled transactions
- OpenID sign-in is shown before a password attempt
- Denied Apple Wallet access in Bank Sync now links to Settings instead of greying out
- Staying on All Accounts after adding a transaction from it
- Fixed the split transaction sign toggle on duplicated transactions
- Fixed card mapping edit sheet opening empty, and the Card & Account Mappings count
- Fixed card number extraction from bank SMS when digits came back scrambled
- Fixed a credit card reminder crash, and due vs balance labelled consistently across bills and card screens
- Fixed phantom deleted entries in History; History now tracks payee, category, date and split changes
- Split option hidden on transfers, and category hidden unless one side is off-budget
- Amount keypad stays down after picking a payee/category, and growing text fields stay above the keyboard
- Removed the old Hide Cleared / Hide Reconciled toggles now that status chips cover them (u/asdfjkl826)
- Fixed laggy scrolling in the Compact budget view, plus general performance work across reports, reloads and sync
- Currency symbol now shown on Compact budget amounts
- About screen merged into Information, and Expand/Collapse All Accounts moved into the Accounts menu
- Smaller UI polish across status chips, header spacing, picker text colour, transaction notes and tags
- Privacy policy updated to cover Apple Wallet, SimpleFIN and payee locations
Thanks to the code contributors on this release: abhiy98, azimul-kabir, Vaibhav159, NateMorgan, culpeppers, and welcoming three new contributors, lben, ivan-magda and Sonaryr. I really appreciate everyone who contributed code, reported bugs, emailed, joined Discord or left feedback here.
Heaps more Feature Requests exist so plenty more in the works, thanks again.
Matt
57 points · 17 comments
Mentions
От 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, а код экспериментов и результаты замеров оставил в репозитории с бенчмарками.
Actuali iOS app v1.0.14 App Store / GitHub Hey all, v1.0.14 is now on the App Store _(v1.0.13 + some late bug fixes)_. Once again it was largely driven by bug reports & feature requests from this sub and Discord. Big update this time, I try to release fortnightly but last time I wasn't happy with the state of the app so we've now got a month worth of features and fixes. New features: - Redesigned Add Transaction - a single page with grouped cards, a +/- sign key on the amount, and transfers picked straight from the payee list - Tags - manage tags _(More → Tags)_, # autocomplete in notes, coloured tag chips on transactions, and per-tag totals - Account groups, plus drag-and-drop account reordering (groups require Actual Server 26.9.0+) - Loan & Payoff Estimates, plus savings/deposit growth projections - Running balance in account registers (toggle from ... menu) - Upcoming scheduled transactions shown in account views and All Accounts (u/EjayT06) - Month notes on the Budget tab (u/EjayT06) - Search in account view - Change budget type (Envelope ↔ Tracking) from within the app - Reorder categories and groups from the Budget tab, and rename categories from the long-press menu - Pinned group headers with group balance and long-press actions, in both Clean and Compact views - Bulk actions can now categorise, tag and merge selected transactions, and show the selection total - Category balance impact popup after changing a transaction - Pin a category on Log Transaction shortcuts (u/AwPravda, u/eleirbagg, u/vixalien) - Exclude categories from Spent totals, applied to both Budget and Accounts summaries - Per-category progress bar visibility, plus an inverse progress bar option - More Budget View options: Show Spent is now in Budget View settings and on by default (u/michikade), hide budgeted amounts, hide the Income group, and show/hide Overview in Clean view - Budget tab toolbar reorganised: budget actions under their own button, Apply Budget Template from the category long-press menu, and the budget selector in the menu - Split transactions show each split line on its own row - Home Screen quick action to add a transaction - Toggle to turn off auto-picking a category from payee history _(More → Transactions & Automation)_ - Apple Wallet sync now wakes in the background when Wallet data changes - Bank sync updates a pending charge's amount when it settles _(tips, fuel holds)_, and Wallet sync removes pending holds that get rejected - Bank sync respects Actual's "import pending transactions" setting - Pending imports can be saved as transfers, plus a quick clear button for notes and an unmapped card warning - Diagnostic report under Support & Diagnostic to help troubleshoot connection and sync issues - Self-signed certificate help in Support - Added VND currency support, plus a Currency Settings shortcut when a currency mismatch is detected Bugs and improvements: - Deleting a transfer now deletes both sides - Deleted SimpleFIN transactions stay deleted - Currency preference no longer resets - Categories you pick are kept when approving pending imports - Schedule rule actions _(e.g. notes)_ now apply to scheduled transactions - OpenID sign-in is shown before a password attempt - Denied Apple Wallet access in Bank Sync now links to Settings instead of greying out - Staying on All Accounts after adding a transaction from it - Fixed the split transaction sign toggle on duplicated transactions - Fixed card mapping edit sheet opening empty, and the Card & Account Mappings count - Fixed card number extraction from bank SMS when digits came back scrambled - Fixed a credit card reminder crash, and due vs balance labelled consistently across bills and card screens - Fixed phantom deleted entries in History; History now tracks payee, category, date and split changes - Split option hidden on transfers, and category hidden unless one side is off-budget - Amount keypad stays down after picking a payee/category, and growing text fields stay above the keyboard - Removed the old Hide Cleared / Hide Reconciled toggles now that status chips cover them (u/asdfjkl826) - Fixed laggy scrolling in the Compact budget view, plus general performance work across reports, reloads and sync - Currency symbol now shown on Compact budget amounts - About screen merged into Information, and Expand/Collapse All Accounts moved into the Accounts menu - Smaller UI polish across status chips, header spacing, picker text colour, transaction notes and tags - Privacy policy updated to cover Apple Wallet, SimpleFIN and payee locations Thanks to the code contributors on this release: abhiy98, azimul-kabir, Vaibhav159, NateMorgan, culpeppers, and welcoming three new contributors, lben, ivan-magda and Sonaryr. I really appreciate everyone who contributed code, reported bugs, emailed, joined Discord or left feedback here. Heaps more Feature Requests exist so plenty more in the works, thanks again. Matt