Почему ваш микробенчмарк врёт
Ловушки, заставляющие микробенчмарки сообщать фантастические числа: устранение мёртвого кода, свёртка констант и вынос инвариантов, неучтённый разогрев JIT, OSR посреди измерения, шум GC, деопты от console.log/try-catch в замеряемой зоне и несовпадение micro и macro
Вы написали цикл, чтобы доказать, что версия A быстрее версии B. Цикл отработал за 0.0 мс. Вы выкатываете версию A. В продакшене она не быстрее — иногда медленнее. Бенчмарк не соврал про время; он замерил программу, которую оптимизатор тихо переписал в ничто, потому что результат ни разу не использовался. Почти каждый уверенный микробенчмарк, что вы читали, неверен одним из горстки конкретных способов.
Почему JIT — противник вашего бенчмарка
Прежде чем написать следующий цикл-бенчмарк, спросите себя: что TurboFan делает с вычислением, результат которого никто не наблюдает? Он его удаляет — полностью. В этом корень каждой ловушки ниже.
Микробенчмарк — это крошечная программа, вывод которой вы игнорируете, прогоняемая в плотном цикле — ровно та программа, которую оптимизирующий компилятор лучше всего умеет сносить. Вся работа TurboFan — убрать работу, не влияющую на наблюдаемое поведение. Если результат вашего бенчмарка никогда не наблюдается, то работа, его породившая, по определению устранима. Ловушки ниже — все следствия того, что оптимизатор делает свою работу над кодом, который вы не намеревались оптимизировать в ничто.
Ловушка 1 — устранение мёртвого кода (DCE)
Если тело цикла вычисляет значение, которое никто не читает, оптимизатор доказывает, что вычисление наблюдаемо бесполезно, и удаляет его. Ваш бенчмарк «1 миллиард операций» становится пустым циклом — иногда удаляется и сам цикл. Фикс — это black-hole sink: накапливайте каждый результат в переменную, которую читаете после замеряемой зоны (и в идеале печатаете или возвращаете), чтобы оптимизатор не смог доказать, что работа мёртвая.
// ОБМАН — результат выброшен, тело устранено
for (let i = 0; i < 1e9; i++) { expensive(i); }
// ЧЕСТНО — sink делает работу наблюдаемой
let sink = 0;
for (let i = 0; i < 1e9; i++) { sink += expensive(i); }
console.log(sink); // read it so DCE cannot remove the loopЛовушка 2 — свёртка констант и вынос инвариантов цикла
Если вход — это литеральная константа, оптимизатор вычисляет ответ один раз на этапе компиляции (свёртка констант), и ваш цикл мерит ничто. Если подвыражение не меняется между итерациями, оно выносится из цикла (вынос инвариантного кода) и выполняется один раз вместо N. Фикс: делайте входы непредсказуемо меняющимися — выводите их из индекса цикла или заранее сгенерированного массива случайных значений, которые оптимизатор не может вычислить заранее («blackbox»-вход).
Ловушка 3 — неучтённый разогрев JIT
Первые итерации идут в Ignition (интерпретируются), затем в Sparkplug, затем в Maglev/TurboFan, как только функция станет горячей. Если замерять с итерации ноль, вы усредняете холодную интерпретацию, паузы компиляции и установившийся машинный код в одно бессмысленное число — и «быстрый» результат может быть просто слишком коротким бенчмарком, который никогда не достигнет TurboFan. Отбросьте фазу разогрева, затем замеряйте; или сообщайте ярусы отдельно, если важны оба — и старт, и установившееся состояние.
Ловушка 4 — OSR посреди измерения
Один гигантский цикл for может вызвать on-stack replacement: V8 компилирует и подставляет оптимизированный код пока цикл ещё выполняется, так что ранняя часть того самого цикла, который вы мерите, идёт интерпретируемо, а остаток — оптимизированно — разрыв, вшитый в одну выборку. Предпочитайте много вызовов меньшей бенчмарк-функции (это разогревает функцию, а не один цикл) одному огромному циклу.
Ловушка 5 — шум GC в замеряемой зоне
Если тело аллоцирует, сборщик мусора рано или поздно встанет на паузу внутри вашего измерения, приписав время GC вашему коду как случайный всплеск. Либо уберите аллокацию из замеряемой зоны, либо прогоните бенчмарк много раз и сообщайте устойчивую статистику (медиана/минимум), а не среднее, чтобы шальная пауза GC не доминировала. Сообщайте распределение, а не одно число.
Ловушка 6 — деопты, которые вы протащили в замеряемую зону
console.log внутри замеряемого цикла, try/catch, через который V8 не будет оптимизировать, или полиморфный вызов, внесённый самим стендом, могут деоптить ту самую функцию, которую вы мерите — так что вы мерите деоптимизированную версию. Держите замеряемую зону чистой: без логирования, без исключений, одна стабильная форма и тип, и проверьте через --trace-deopt, что горячая функция не откатывается во время замера.
Ловушка 7 — несовпадение micro и macro
Даже идеальный микробенчмарк отвечает лишь на вопрос «насколько быстр этот изолированный паттерн в синтетическом цикле». Ваше приложение выполняет его вперемешку с другой работой, с холодными кэшами, реальными распределениями входов и конкуренцией за GC — где победитель micro может проиграть. Микробенчмарки выбирают между двумя реализациями известного горячего места; они не говорят, что это горячее место важно. Для сквозной правды используйте macro-наборы (JetStream, Speedometer) или профилируйте настоящее приложение. Для честных micro-чисел используйте стенд, построенный побеждать эти ловушки: tinybench или mitata берут на себя разогрев, sink и статистику.
- Потребляй результаты в
- black-hole sink
- Входы
- рандомизированы / blackbox
- Разогрев
- отбрось, затем замеряй
- Таймер
- performance.now() (суб-мс)
- Сообщай
- медиана/мин, не среднее
- Проверь горячую fn
- --trace-deopt = без отката
Ваш цикл-бенчмарк над чистой функцией сообщает 0.0мс на миллиард итераций. Что произошло?
Вы замеряете с самой первой итерации горячей функции. Почему число вводит в заблуждение?
Расставьте по порядку шаги превращения наивного цикла в честный микробенчмарк.
- 1 Подавай рандомизированные / blackbox входы, чтобы ничего нельзя было свернуть в константу
- 2 Накапливай каждый результат в black-hole sink, читаемый после замера
- 3 Прогони фазу разогрева и отбрось её, чтобы функция дошла до установившегося яруса
- 4 Замеряй много прогонов и сообщай медиану/минимум, затем проверь отсутствие деопта через --trace-deopt
▸Граничные случаи
Жестокий поворот: микробенчмарк может быть слишком благосклонным. В крошечном цикле в горячую функцию приходят одна форма объекта и один тип, поэтому IC идеально monomorphic, и TurboFan инлайнит всё — быстрое число. В продакшене та же функция видит пять форм, становится megamorphic и никогда не инлайнится. Micro-число было честным про micro-условия и нечестным про ваше приложение. Вот почему проходящий микробенчмарк необходим, но никогда не достаточен: подтвердите macro-прогоном или продакшен-профилем.
- 01Объясните устранение мёртвого кода в бенчмарке и black-hole sink, который его побеждает.
- 02Что такое разогрев JIT и OSR и как они портят измерение с нуля?
- 03Почему проходящий микробенчмарк необходим, но не достаточен, и что добавляют macro-наборы?
Микробенчмарк — это программа, которую оптимизирующий JIT лучше всего умеет сносить, потому что её результат игнорируется, а цикл плотный. Отсюда семь ловушек: устранение мёртвого кода удаляет тело, чей результат не нужен (побеждайте black-hole sink, читаемым после замера); свёртка констант и вынос инвариантов вычисляют заранее литеральные или неменяющиеся входы (побеждайте рандомизированными/blackbox входами); неучтённый разогрев смешивает Ignition, паузы компиляции и установившийся TurboFan в одно число (отбросьте фазу разогрева или сообщайте ярусы отдельно); OSR подставляет оптимизированный код в ещё работающий гигантский цикл (предпочитайте много малых вызовов); шум GC впрыскивает паузы в замеряемую зону (изолируйте аллокацию, сообщайте медиану/минимум); console.log/try-catch/полиморфизм протаскивают деопт в замеряемую функцию (держите зону чистой, проверяйте через —trace-deopt); а несовпадение micro и macro значит, что даже идеальное micro-число не отражает приложение (подтвердите через JetStream/Speedometer или продакшен-профиль). Для честных micro-чисел используйте tinybench или mitata, которые берут на себя sink, разогрев и статистику. Теперь, когда бенчмарк покажет 0 мс, вы не обрадуетесь — вы потянетесь за black-hole sink и запустите снова.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.