diff --git a/editorial/agent-rewrites/225.json b/editorial/agent-rewrites/225.json index 7677c2f..af8cc8d 100644 --- a/editorial/agent-rewrites/225.json +++ b/editorial/agent-rewrites/225.json @@ -1,7 +1,7 @@ { "index": 225, "slug": "editorial-2021-10-practice-d-runtime-service", - "title": "D-runtime в сервисе: как найти лишнее выделение на одном запросе", - "excerpt": "Если endpoint обвиняют в медленном D-runtime, начните с одного запроса: зафиксируйте результат, отделите GC от FFI и I/O, а затем проверьте гипотезу измерением, а не настройкой вслепую.", - "contentHtml": "

Endpoint иногда отвечает заметно дольше обычного, а в профиле рядом с ним появляется GC. Команда называет причиной «медленный D-runtime» и меняет глобальные настройки сборщика. Симптом может исчезнуть на коротком тесте, но контракт запроса и границы внешних вызовов остаются непроверенными. Цена ошибки — лишний риск в каждом запросе: можно получить больше памяти, длиннее паузы или несовместимость с библиотекой, не доказав, что исправлен нужный участок.

\n

Надёжнее начать с одного request path. Зафиксируйте вход, успешный ответ и ошибочный путь. Затем отделите временные объекты от GC boundary, FFI и I/O. Сначала нужно доказать, что два варианта делают одну работу и возвращают один результат. Только после этого имеет смысл обсуждать allocation, настройки DRuntime или переписывание горячего участка.

\n

Тезис: runtime — это граница вопроса, а не причина

\n

Слово «runtime» скрывает несколько разных механизмов. D-код может создать временный массив. Сборщик может увидеть доступные объекты. Foreign function может выделить память по собственному контракту. I/O может заблокировать поток. Эти события находятся рядом в трассе запроса, но не имеют одной проверки.

\n

Узкий вопрос выглядит так: «При одинаковых входе и ответе создаёт ли этот этап больше временного состояния, чем выбранный бюджет?» Такой вопрос допускает проверку. Вопрос «D тормозит?» не говорит, что измерять и какое действие будет правильным.

\n

Механизм одного request path

\n

Разделите запрос на четыре этапа: decode, validate, compute и encode. Для каждого запишите вход и выход. Рядом отметьте границы, которые не принадлежат учебному примеру: вызов C-библиотеки, сеть, файл или база данных. Граница должна остаться в trace даже тогда, когда вызов ещё не выполняется.

\n

В учебной модели allocation units — условные единицы счёта. Они не равны байтам, времени CPU, latency или числу запусков GC. Бюджет 6 означает только одно: выбранный вариант превышает порог, заданный примером. Реальный порог нужно выбрать для конкретного endpoint и подтвердить инструментом в конкретной версии компилятора, DRuntime и окружения.

\n
Карточка проверки одного запроса
ПолеЧто фиксируемЧто проверяем
Входsum(19, 23), request idОба варианта получают одинаковые данные
Ответ{"requestId":"runtime-d-42","total":42}Оптимизация не меняет контракт
РаботаОдинаковое число work unitsСравнение не прячет другую алгоритмическую работу
AllocationУсловные units и budgetВидно превышение выбранного порога
ГраницыGC, FFI, I/O с явным статусомНеисполненный внешний вызов не принимают за измеренный
\n

Вариант allocation-heavy может получить 12 units, а вариант reuse — 3. Если оба возвращают тот же JSON и выполняют то же число work units, модель выделяет одну гипотезу: промежуточное состояние. Она не доказывает, что второй вариант быстрее на production-трафике.

\n

Пример: сохраняем ответ и меняем только промежуточное состояние

\n

Ниже показан маленький пример на D. Он ограничен вычислением суммы. В нём нет HTTP, сериализации, GC-профайлера и FFI. Комментарии помечают места, где реальный сервис должен добавить отдельную проверку.

\n
struct Response {\n    string requestId;\n    int total;\n}\n\nResponse handle(int left, int right) {\n    // Учебный контракт: результат зависит только от входа.\n    return Response(\"runtime-d-42\", left + right);\n}\n\n@nogc int compute(int left, int right) {\n    // Здесь нет операций, которые требуют GC-аллокации.\n    return left + right;\n}\n\nvoid requestTrace() {\n    // decode -> validate -> compute -> encode\n    // FFI: not performed; I/O: not performed.\n    auto response = handle(19, 23);\n    assert(response.total == 42);\n}
\n

Аннотация @nogc полезна как ограничение на вызываемый D-код: компилятор проверяет конструкции и вызовы, которые могут потребовать GC-аллокации. Но она не делает безопасной неизвестную внешнюю функцию. Она также не доказывает отсутствие пауз сборщика во всём процессе. Другой поток может работать с GC, а FFI-библиотека может иметь собственный allocator. Поэтому атрибут — часть границы, а не итоговый performance report.

\n

Если в реальном коде encode создаёт строку, это нужно увидеть в trace и подтвердить профилем. Нельзя вывести размер выделения из одного только названия функции. Нельзя считать вызов C безопасным по факту успешной компиляции: проверьте calling convention, layout, ownership, освобождение памяти и возможность блокировки.

\n
\"Схема
Маршрут одного запроса. Граница GC на схеме означает точку проверки, а не подтверждённую паузу. FFI и I/O отмечены как внешние границы, пока вызов не измерен.
\n

Симптом → причина → проверка → действие

\n
СимптомПричина-гипотезаПроверкаДействие
Растёт allocation на успешном запросеВременное состояние создаётся на decode или encodeСравнить одинаковый input, output, work units и allocation traceУбрать промежуточную копию или переиспользовать буфер; повторить проверку контракта
В трассе есть GC boundaryВыбранный путь пересёк условный порогПроверить реальный профиль с версией compiler, DRuntime и платформойИзменять локальный участок только после измерения; не менять глобальную настройку вслепую
После FFI меняется задержкаБиблиотека выделяет память или блокирует потокПроверить ABI, ownership, allocator и время вызова отдельноСогласовать контракт с владельцем библиотеки; не приписывать эффект GC
Endpoint медленный только при ошибкеВ error path остаётся дополнительная сериализация или retryПрогнать невалидный input и сравнить trace с успешным путёмИсправить error path и добавить отдельный критерий для него
Учебный тест зелёный, сервис всё ещё медленныйМодель не содержит HTTP, concurrency, backpressure или I/OВоспроизвести один живой endpoint с реальным профилемСохранить модель как гипотезу, а ответ получить инструментом на нужной среде
\n

Порядок проверки

\n
  1. Зафиксируйте симптом. Назовите endpoint, вход, status, body и условие, при котором проявляется проблема. Не начинайте с ярлыка «runtime D».
  2. Сузьте границу. Выберите один участок: временные объекты, GC, FFI или I/O. Остальные границы запишите как неизвестные или неисполненные.
  3. Сохраните контракт. Сравните успешный и ошибочный путь. Для успешного пути зафиксируйте body, для ошибочного — status и форму ошибки.
  4. Соберите базовый trace. Запишите вход, work units, allocation units, выбранный budget и версии compiler, DRuntime, ОС и библиотеки.
  5. Проверьте альтернативу. Измените только промежуточное состояние. Если меняются body или work units, сравнение allocation преждевременно.
  6. Измерьте реальную среду. Запустите подходящий profiler или benchmark на том же endpoint. Учебные units не подменяют байты, CPU time, pauses и latency.
  7. Проверьте отрицательный путь. Повторите тест с невалидным input, ошибкой FFI и отказом I/O, если эти границы входят в endpoint.
  8. Сформулируйте действие. Меняйте локальный код только при подтверждённой гипотезе. После изменения повторите базовый и отрицательный сценарии.
\n

Ограничения

\n

Такой разбор не моделирует поведение всей системы. Он не описывает распределение памяти, алгоритм сборщика, scheduler, конкуренцию потоков, backpressure, сетевые повторы, сериализацию настоящего протокола, лимиты контейнера или нагрузку пользователей. Он не подтверждает production-результат и не заменяет нагрузочный тест.

\n

@nogc ограничивает D-код, но не отменяет правила внешнего ABI и не запрещает другой части процесса запускать сборку. FFI boundary требует отдельного договора о типах и владении памятью. I/O boundary требует отдельного измерения ожидания и поведения при обрыве. Если эти условия неизвестны, честное действие — оставить их неизвестными и назначить проверку, а не заполнить пробел предположением.

\n

Критерий готовности

\n

Проверка закрыта, когда для одного входа сохранены базовый trace и trace после изменения; успешный ответ совпадает; error path проверен; выбранная причина подтверждена подходящим измерением; версии и условия запуска записаны; FFI и I/O имеют явный статус; а действие не опирается на учебные allocation units как на production-метрику. Если хотя бы одно условие не выполнено, результат — новая гипотеза, а не доказанное исправление.

\n

Проверяемые источники

\n" + "title": "D-runtime: как доказать лишнюю аллокацию в одном запросе", + "excerpt": "Если endpoint стал медленнее и рядом с ним виден GC, не меняйте настройки DRuntime вслепую. Сохраните контракт запроса, сравните вариант с копированием и вариант без копии, а затем отдельно проверьте GC, FFI и I/O.", + "contentHtml": "

Endpoint иногда отвечает заметно дольше обычного, а в профиле рядом с ним появляется GC. Команда называет причиной «медленный D-runtime» и сразу меняет глобальные настройки сборщика. Такой вывод слишком широк: дополнительная аллокация, сама сборка мусора, вызов C-библиотеки и ожидание I/O — разные события. Они могут оказаться в одной трассе, но требуют разных проверок.

\n

Разберём практический способ начать с одного request path. Сначала сохраним вход, успешный ответ и error path. Затем сравним два варианта, которые выполняют одну работу: один создаёт промежуточную копию, второй читает исходный буфер. В конце отдельно проверим, что именно измерено. Учебный пример не выдаём за production-результат: его задача — сделать гипотезу проверяемой.

\n

Сначала сохраните контракт запроса

\n

До оптимизации запишите не только latency. Для одного входа сохраните request id, статус, тело успешного ответа, форму ошибки и число элементов во входном буфере. Если меняется тело ответа, статус или error path, сравнение производительности преждевременно: варианты делают уже не одну и ту же работу.

\n
Минимальная карточка воспроизведения
Что фиксируемПримерЗачем
Входsum(19, 23), request id runtime-d-42Повторить тот же сценарий и найти его в trace
Успешный ответ{"requestId":"runtime-d-42","total":42}Убедиться, что оптимизация не меняет контракт
Error pathСтатус и форма ошибки для невалидного входаНе спрятать аллокацию или retry только в ошибочном пути
УсловияВерсии компилятора, DRuntime, ОС и библиотекиНе сравнить разные среды под видом одной
ГраницыGC, FFI и I/O: измерены или не выполнялисьНе приписать ожидание внешнего вызова сборщику
\n

Укажите также, где начинается и заканчивается каждый этап: decode, validate, compute и encode. Если endpoint вызывает C-библиотеку, сеть, файл или базу данных, вынесите этот вызов в отдельную границу. Иначе суммарное время обработчика не ответит, какой участок создал данные, а какой просто ждал.

\n

Что означает «лишняя аллокация»

\n

В D динамические данные могут находиться в памяти, которой управляет сборщик мусора. Операция копирования, расширение массива или создание объекта способны добавить работу до того, как сборщик запустит цикл. Но «в профиле есть GC» не доказывает, что причина найдена: трасса может показывать соседний этап, а внешний вызов может иметь собственный allocator.

\n

Не смешивайте четыре наблюдения:

\n\n

Для короткого учебного сравнения можно использовать условные allocation units. Например, 12 против 3 — это только результат модели. Units не равны байтам, CPU time, latency или числу запусков GC. В рабочем отчёте рядом должны стоять фактический инструмент, его версия, параметры запуска и одинаковый вход.

\n

Минимальный D-репродуктор

\n

Ниже две функции считают одну сумму и возвращают один тип результата. withCopy делает промежуточную копию через dup; withoutCopy читает входной slice и помечен @nogc. Пример намеренно не содержит HTTP, сериализации, FFI и I/O, поэтому его можно использовать только для проверки разницы между двумя вычислительными участками.

\n
import std.stdio : writeln;\n\nstruct Response {\n    int total;\n}\n\nResponse withCopy(const(int)[] input) {\n    auto copy = input.dup;\n    int total;\n    foreach (value; copy) {\n        total += value;\n    }\n    return Response(total);\n}\n\n@nogc Response withoutCopy(const(int)[] input) {\n    int total;\n    foreach (value; input) {\n        total += value;\n    }\n    return Response(total);\n}\n\nvoid main() {\n    int[2] input = [19, 23];\n    auto copied = withCopy(input[]);\n    auto reused = withoutCopy(input[]);\n\n    assert(copied.total == 42);\n    assert(reused.total == copied.total);\n    writeln(reused.total);\n}
\n

Ожидаемый результат — строка 42, а два утверждения не должны завершиться ошибкой. В этом коде мы не объявляем число аллокаций: его нужно измерить тем же способом, которым команда измеряет сервис. Если compiler и profiler показывают отличие, зафиксируйте в инженерном отчёте команду запуска, версии, входные данные и trace. В статье достаточно самого принципа: число аллокаций нельзя объявить без измерения.

\n

@nogc — полезная граница компилятора: функция не должна выполнять GC-аллокации напрямую или через вызовы, которые не помечены @nogc. Атрибут относится к типу и телу функции, а не ко всему процессу. Другой поток всё ещё может выделить память и запустить сборку. Поэтому успешная компиляция withoutCopy не доказывает отсутствие пауз в endpoint целиком.

\n
\"Схема
Схема границ одного request path. Отметка GC показывает место, которое нужно измерить, а не уже доказанную паузу; FFI и I/O требуют собственных наблюдений.
\n

Как перенести результат в сервис

\n

Сначала прогоните репродуктор на том же компиляторе и в тех же режимах, которые используются для сервиса. Затем повторите сравнение на одном endpoint. В обоих случаях сохраните одинаковые входы, тёплый и холодный старт, размер ответа и error path. Не сравнивайте локальный debug-запуск с production-профилем и не называйте учебные units измерением latency.

\n
Матрица проверки гипотезы
НаблюдениеГипотезаПроверкаРешение
Рост allocation только на успешном путиКопия появляется в decode, compute или encodeСравнить trace до и после локальной замены, сохранив response bodyУбрать копию или переиспользовать буфер, затем повторить базовый тест
Видна пауза GCПуть запросил память, и сборщик остановил известные ему потокиСверить время цикла GC с allocation и версиями средыМенять код или настройки только после подтверждения причины
Задержка начинается после FFIБиблиотека блокирует поток или использует собственный allocatorИзмерить вызов отдельно и проверить ABI, layout и ownershipИсправить контракт границы, не приписывать эффект DRuntime
Медленным оказывается только error pathОшибка вызывает сериализацию, логирование или retryПодать невалидный вход и сравнить статус, body и traceДобавить отдельный критерий для ошибочного сценария
Учебный код быстрее, endpoint нетВ модели отсутствуют I/O, конкуренция или backpressureПрофилировать живой request path на целевой средеОставить модель локальной гипотезой, пока нет измерения сервиса
\n

FFI нельзя проверять по одной компиляции

\n

Когда D передаёт данные в C, проверка расширяется. D действительно умеет вызывать C-функции напрямую, но для границы важны соглашение о вызовах, совместимость типов и layout структур. D-строка не обязана быть нулём завершённой, поэтому C-функции, ожидающей C string, нужен явный способ преобразования и отдельная проверка времени жизни.

\n

Особенно опасен указатель на память сборщика. Пока C-функция работает, объект должен оставаться достижимым для GC или быть скопирован в память с согласованным C-контрактом. Освобождение должен выполнять тот allocator, который владеет буфером. Вызов, который успешно прошёл компиляцию, ещё не доказывает правильность ownership и не исключает блокировку.

\n

Порядок проверки

\n
  1. Зафиксируйте симптом. Запишите endpoint, вход, request id, статус, body и условие, при котором растёт latency.
  2. Сохраните базу. Зафиксируйте версии compiler, DRuntime, ОС, библиотеки, режим запуска и параметры профайлера.
  3. Разделите request path. Поставьте наблюдаемые границы вокруг decode, validate, compute, encode, FFI и I/O.
  4. Сверьте контракт. Убедитесь, что успешный response и error path совпадают по смыслу до и после изменения.
  5. Проверьте локальную гипотезу. Сравните вариант с копированием и без копии на одинаковом input; не меняйте одновременно алгоритм и конфигурацию GC.
  6. Измерьте реальную среду. Снимите allocation, время GC, latency и ожидание внешних вызовов подходящими инструментами.
  7. Проверьте FFI отдельно. Сверьте calling convention, типы, layout, ownership, освобождение памяти и поведение при ошибке.
  8. Повторите отрицательные сценарии. Проверьте невалидный input, отказ FFI, таймаут I/O и повтор запроса, если они есть в endpoint.
\n

Ограничения и критерий готовности

\n

Этот разбор не описывает конкретный алгоритм GC, планировщик потоков, лимиты контейнера, нагрузку пользователей или поведение настоящего HTTP-сериализатора. Он показывает способ сузить вопрос. Даже если вариант без копии проходит @nogc, это не обещает меньшую latency на production-трафике: результат зависит от входного размера, соседних этапов, конкуренции и внешних вызовов.

\n

Проверка готова, когда сохранены базовый и изменённый trace; успешный response совпадает; error path пройден; измерены allocation и GC; FFI и I/O имеют явный статус; а версии и команда запуска записаны. Если осталась только правдоподобная история о «медленном runtime», это ещё гипотеза, а не доказанная причина.

\n

Проверяемые источники

\n" }