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» скрывает несколько разных механизмов. D-код может создать временный массив. Сборщик может увидеть доступные объекты. Foreign function может выделить память по собственному контракту. I/O может заблокировать поток. Эти события находятся рядом в трассе запроса, но не имеют одной проверки.
\nУзкий вопрос выглядит так: «При одинаковых входе и ответе создаёт ли этот этап больше временного состояния, чем выбранный бюджет?» Такой вопрос допускает проверку. Вопрос «D тормозит?» не говорит, что измерять и какое действие будет правильным.
\nРазделите запрос на четыре этапа: decode, validate, compute и encode. Для каждого запишите вход и выход. Рядом отметьте границы, которые не принадлежат учебному примеру: вызов C-библиотеки, сеть, файл или база данных. Граница должна остаться в trace даже тогда, когда вызов ещё не выполняется.
В учебной модели 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 с явным статусом | Неисполненный внешний вызов не принимают за измеренный |
Вариант allocation-heavy может получить 12 units, а вариант reuse — 3. Если оба возвращают тот же JSON и выполняют то же число work units, модель выделяет одну гипотезу: промежуточное состояние. Она не доказывает, что второй вариант быстрее на production-трафике.
Ниже показан маленький пример на D. Он ограничен вычислением суммы. В нём нет HTTP, сериализации, GC-профайлера и FFI. Комментарии помечают места, где реальный сервис должен добавить отдельную проверку.
\nstruct 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.
Если в реальном коде encode создаёт строку, это нужно увидеть в trace и подтвердить профилем. Нельзя вывести размер выделения из одного только названия функции. Нельзя считать вызов C безопасным по факту успешной компиляции: проверьте calling convention, layout, ownership, освобождение памяти и возможность блокировки.
| Симптом | Причина-гипотеза | Проверка | Действие |
|---|---|---|---|
| Растёт 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 с реальным профилем | Сохранить модель как гипотезу, а ответ получить инструментом на нужной среде |
Такой разбор не моделирует поведение всей системы. Он не описывает распределение памяти, алгоритм сборщика, scheduler, конкуренцию потоков, backpressure, сетевые повторы, сериализацию настоящего протокола, лимиты контейнера или нагрузку пользователей. Он не подтверждает production-результат и не заменяет нагрузочный тест.
\n@nogc ограничивает D-код, но не отменяет правила внешнего ABI и не запрещает другой части процесса запускать сборку. FFI boundary требует отдельного договора о типах и владении памятью. I/O boundary требует отдельного измерения ожидания и поведения при обрыве. Если эти условия неизвестны, честное действие — оставить их неизвестными и назначить проверку, а не заполнить пробел предположением.
Проверка закрыта, когда для одного входа сохранены базовый trace и trace после изменения; успешный ответ совпадает; error path проверен; выбранная причина подтверждена подходящим измерением; версии и условия запуска записаны; FFI и I/O имеют явный статус; а действие не опирается на учебные allocation units как на production-метрику. Если хотя бы одно условие не выполнено, результат — новая гипотеза, а не доказанное исправление.
\n@nogc.Endpoint иногда отвечает заметно дольше обычного, а в профиле рядом с ним появляется GC. Команда называет причиной «медленный D-runtime» и сразу меняет глобальные настройки сборщика. Такой вывод слишком широк: дополнительная аллокация, сама сборка мусора, вызов C-библиотеки и ожидание I/O — разные события. Они могут оказаться в одной трассе, но требуют разных проверок.
\nРазберём практический способ начать с одного request path. Сначала сохраним вход, успешный ответ и error path. Затем сравним два варианта, которые выполняют одну работу: один создаёт промежуточную копию, второй читает исходный буфер. В конце отдельно проверим, что именно измерено. Учебный пример не выдаём за production-результат: его задача — сделать гипотезу проверяемой.
\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: измерены или не выполнялись | Не приписать ожидание внешнего вызова сборщику |
Укажите также, где начинается и заканчивается каждый этап: decode, validate, compute и encode. Если endpoint вызывает C-библиотеку, сеть, файл или базу данных, вынесите этот вызов в отдельную границу. Иначе суммарное время обработчика не ответит, какой участок создал данные, а какой просто ждал.
В D динамические данные могут находиться в памяти, которой управляет сборщик мусора. Операция копирования, расширение массива или создание объекта способны добавить работу до того, как сборщик запустит цикл. Но «в профиле есть GC» не доказывает, что причина найдена: трасса может показывать соседний этап, а внешний вызов может иметь собственный allocator.
\nНе смешивайте четыре наблюдения:
\nДля короткого учебного сравнения можно использовать условные allocation units. Например, 12 против 3 — это только результат модели. Units не равны байтам, CPU time, latency или числу запусков GC. В рабочем отчёте рядом должны стоять фактический инструмент, его версия, параметры запуска и одинаковый вход.
\nНиже две функции считают одну сумму и возвращают один тип результата. withCopy делает промежуточную копию через dup; withoutCopy читает входной slice и помечен @nogc. Пример намеренно не содержит HTTP, сериализации, FFI и I/O, поэтому его можно использовать только для проверки разницы между двумя вычислительными участками.
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. В статье достаточно самого принципа: число аллокаций нельзя объявить без измерения.
@nogc — полезная граница компилятора: функция не должна выполнять GC-аллокации напрямую или через вызовы, которые не помечены @nogc. Атрибут относится к типу и телу функции, а не ко всему процессу. Другой поток всё ещё может выделить память и запустить сборку. Поэтому успешная компиляция withoutCopy не доказывает отсутствие пауз в endpoint целиком.
Сначала прогоните репродуктор на том же компиляторе и в тех же режимах, которые используются для сервиса. Затем повторите сравнение на одном 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 на целевой среде | Оставить модель локальной гипотезой, пока нет измерения сервиса |
Когда D передаёт данные в C, проверка расширяется. D действительно умеет вызывать C-функции напрямую, но для границы важны соглашение о вызовах, совместимость типов и layout структур. D-строка не обязана быть нулём завершённой, поэтому C-функции, ожидающей C string, нужен явный способ преобразования и отдельная проверка времени жизни.
\nОсобенно опасен указатель на память сборщика. Пока C-функция работает, объект должен оставаться достижимым для GC или быть скопирован в память с согласованным C-контрактом. Освобождение должен выполнять тот allocator, который владеет буфером. Вызов, который успешно прошёл компиляцию, ещё не доказывает правильность ownership и не исключает блокировку.
\ndecode, validate, compute, encode, FFI и I/O.Этот разбор не описывает конкретный алгоритм GC, планировщик потоков, лимиты контейнера, нагрузку пользователей или поведение настоящего HTTP-сериализатора. Он показывает способ сузить вопрос. Даже если вариант без копии проходит @nogc, это не обещает меньшую latency на production-трафике: результат зависит от входного размера, соседних этапов, конкуренции и внешних вызовов.
Проверка готова, когда сохранены базовый и изменённый trace; успешный response совпадает; error path пройден; измерены allocation и GC; FFI и I/O имеют явный статус; а версии и команда запуска записаны. Если осталась только правдоподобная история о «медленном runtime», это ещё гипотеза, а не доказанная причина.
\n@nogc, косвенные вызовы и влияние другого потока.