Везение с кодогенерацией компилятора - косметическая правка ускорила код в 6 раз
- title
- Везение с кодогенерацией компилятора - косметическая правка ускорила код в 6 раз
- type
- summary
- summary
- Переписывание одной строки на C переключает Clang между ветвлением и csel/cmov, меняя скорость quicksort в 6 раз
- tags
- performance, compilers, microarchitecture, c
- sources
- lucky-code
- created
- 2026-07-18
- updated
- 2026-09-14
- lang
- ru
- translation_of
- compiler-codegen-luck
- source_updated
- 2026-09-14
- translated
- 2026-09-14
- translator
- lllm/antigravity/gemini-3.7-flash-medium
Кристоф Казер (Christof Kaser) оптимизировал branchless-версию quicksort на C и наткнулся на случай, когда два логически идентичных варианта одного и того же горячего цикла по скорости различались больше чем в шесть раз. Сортировка 50 миллионов чисел типа double на M1 компилятором Clang с флагом -O3 в одном случае заняла 4.39 секунды, а в другом - 0.70 секунды. Быстрая версия к тому же почти вдвое обогнала std::sort (1.33 с). Алгоритм, данные и флаги компилятора были одинаковыми. Изменилась лишь запись цикла разбиения (partition).
Изменение
Шаг разбиения проходит по массиву и отправляет каждый элемент в один из двух указателей записи в зависимости от сравнения с опорным элементом (pivot). В явном виде сдвиг указателя вынесен в отдельную инструкцию:
if (BLQS_CMP(x, piv)) { *lwr = x; lwr++; }
else { *rwr = x; rwr--; }
Идиоматичная форма объединяет сохранение и инкремент:
if (BLQS_CMP(x, piv)) *lwr++ = x;
else *rwr-- = x;
Эффект тот же, семантика та же. Но Clang компилирует их по-разному. Компактная форма превращается в цикл без ветвлений на базе csel на AArch64 (cmov на x86): результат сравнения передаётся в условный выбор (conditional select), который определяет целевой указатель и инкремент, а затем выполняется одно безусловное сохранение в память. Явная же форма сохраняет настоящее условное ветвление вокруг записи. GCC игнорирует разницу и в обоих случаях генерирует более медленный вариант с ветвлением.
Почему формулировка важна для компилятора
Механизм разобрали в треде на HN. В варианте с одной инструкцией последней операцией в каждой ветке LLVM IR оказывается сохранение в память. Проход SimplifyCFG в LLVM ищет инструкции сохранения, идентичные за исключением одного операнда, выносит их в общий последующий блок и заменяет выбор на инструкцию select, которая позже транслируется в условное перемещение (conditional move). В варианте с двумя инструкциями последней операцией в каждой ветке становится арифметика указателей, а не сохранение. Сами указатели различаются двумя операндами (разные базовые адреса: один инкрементируется, другой декрементируется), поэтому SimplifyCFG сразу сдаётся и до инструкций сохранения даже не доходит. Анализ пайплайна в Compiler Explorer показывает ту же картину: после преобразования в SSA быстрая версия вычисляет сдвиг указателя до сохранения, медленная - после, и этого порядка достаточно, чтобы сопоставление с шаблоном сломалось. Исходный код никак не подсказывает, по какую сторону этой границы вы окажетесь.
Почему именно здесь выигрывает branchless
Условное перемещение не быстрее ветвления автоматически. Оно быстрее, только если ветвление трудно предсказать. Разбиение в quicksort на случайных данных - почти худший сценарий для предсказателя переходов: примерно половина элементов распределяется в каждую сторону без какой-либо закономерности, предсказатель ошибается примерно в половине случаев, и каждый промах сбрасывает конвейер. У csel/cmov нет ветвления, на котором можно ошибиться: инструкция всегда выполняет один и тот же объём работы. Поэтому она выигрывает на непредсказуемых данных и проигрывает там, где ветвление легко предсказывалось бы. Во время компиляции компилятор не знает распределение данных и строит догадки. На этой нагрузке догадка в пользу branchless случайно оказалась верной - отсюда и слово "везение": быстрый путь возник лишь потому, что косметическая правка подтолкнула компилятор к шаблону, подошедшему данным во время выполнения. Общий баланс компромиссов описан в conditional-move. В Clang есть __builtin_unpredictable(), позволяющий подсказать, что вероятность ветвления 50/50, но это полезно, только если вы заранее знаете, что его нужно применить.
Более широкий вывод
Самое полезное - выводы, которые участники обсуждения сделали в треде. Оптимизаторы компилятора распознают конкретные формы IR, поэтому идиоматичный код повышает шансы на срабатывание прохода, но такое сопоставление хрупко, не документировано, и семантически одинаковый код может повести себя как угодно. Для производительного кода локальность по памяти, векторизация и поведение ветвлений обычно важнее алгоритмической сложности, причём ничего из этого по исходному коду не видно. Постоянный совет: измеряйте, читайте сгенерированный ассемблер (Compiler Explorer, perf) и не верьте предрассудкам о том, что "должно" быть быстрым.
Обратный пример - wasmi-2-interpreter-engineering: в Rust 1.92 проход по MIR объединил две точки диспетчеризации ветвлений в интерпретаторе в csel и одну ветку, а откат этого изменения дал прирост ~50% в CoreMark.
Этот пример стоит в одном ряду с другими случаями в этом vault'е, когда две сборки одного и того же кода расходятся по причинам, которых в исходниках не видно: linux-7-postgres-regression, где отказ от одного из режимов вытеснения в ядре привёл к тому, что держатель spinlock'а в PostgreSQL вытеснялся посреди page fault, уполовинив пропускную способность, и huge-pages, где один лишь размер страницы на порядки меняет количество page fault'ов и нагрузку на TLB. В каждом из них производительность определяется слоями ниже написанного вами кода.
В этот же список входят ещё два примера, оба про работу компилятора с доступом к памяти, а не с выбором инструкций. В false-sharing-alignment-128 показано, что padding, необходимый для предотвращения разделения кэш-линии двумя атомиками на x64, составляет 128 байт, а не 64, как можно было бы ожидать исходя из размера кэш-линии. go-bounds-checks-unsafe заходит с другой стороны, убирая проверки границ, которые компилятор Golang не смог счесть избыточными, и показывает, насколько узкими становятся предположения о платформе, если пойти на этот шаг.
Митчелл Хашимото (Mitchell Hashimoto) приходит к тому же выводу относительно автовекторизации в everyone-should-know-simd: компилятор может иногда векторизовать скалярный цикл, но когда цикл достаточно важен, чтобы требовать пятикратного ускорения, он предпочитает явно прописывать векторизацию, а не полагаться на догадки компилятора, чтобы случайная правка или обновление компилятора не превратили его обратно в скалярный.
Фронтенд во всём этом остаётся пробелом: каждая страница здесь начинается уже после парсинга. intro-compilers-language-design - учебник, закрывающий ту часть, которой больше нет нигде в vault'е.
- Introduction to Compilers and Language Design
- Conditional move (cmov / csel)
- Everyone Should Know SIMD
- Why cache padding uses 128 bytes on a 64-byte cache line
- Eliminating Golang bounds checks with unsafe
- Golang maps after Swiss Tables
- Linux 7.0 cuts PostgreSQL throughput in half
- Wasmi 2.0 interpreter engineering