Нехватка регистров в x86-64: спиллы слабо предсказывают замедление кода
Авторы блога rjp.io взяли девять небольших ядер на C и пошагово отбирали у компилятора регистры x86-64 флагом gcc -ffixed-<рег>: на каждом шаге резервировался ещё один регистр, недоступный при генерации кода. На каждом шаге измеряли две величины: статическое число инструкций-спиллов (обращений к фиксированному слоту стека через (%rsp), кроме индексируемого доступа к массивам, считали дизассемблированием утилитой objdump) и реальное время выполнения (минимум из 15 прогонов на закреплённом ядре, машина Intel Xeon E-2236, gcc 15.2.0, -O2). Проверяли три гипотезы: (H1) чем меньше регистров, тем монотонно больше спиллов и медленнее код; (H2) замедление специфично для того файла регистров, GP (общего назначения) или XMM, который реально использует ядро; (H3) число спиллов предсказывает величину замедления. Шесть целочисленных ядер нагружают GP-регистры (развёртка с 15 до 5 доступных: по очереди отдавали r15 r14 r13 r12 rbp rbx r11 r10 r9 r8, rsp никогда не резервировался), три ядра с плавающей точкой нагружают XMM-регистры (развёртка с 16 до 4: xmm15…xmm4). ABI-регистры (rax rcx rdx rsi rdi, xmm0, 3), нужные для передачи аргументов и инструкций вроде mul и переменных сдвигов, никогда не резервировались.
Результат 1. Восемь из девяти ядер замедляются на 14, 76% при самом жёстком бюджете регистров; SipHash-2-4, единственное исключение, его время не сдвигается. Число спиллов растёт у всех девяти ядер. Контрольные суммы совпадают на всех бюджетах, резервирование регистров меняет только сгенерированный код, но не сам результат вычисления. Ни кривая спиллов, ни кривая времени не строго монотонны: у ChaCha20 и Quicksort число спиллов иногда снижается при отбирании ещё одного регистра, а время выполнения у LZ77 и SipHash в некоторых точках проседает, сигнал шумит на уровне около 1% даже при минимуме из 15 замеров. Целочисленное матричное умножение стартует с 60 спиллов уже при полном наборе регистров: его блок 4×4 держит 16 аккумуляторов int64, больше, чем 15 доступных GP-регистров, и спиллит ещё до того, как у него что-то отобрали.
Результат 2, центральный вывод работы. По всем 105 точкам (ядро × бюджет регистров) корреляция Пирсона между числом добавленных спиллов и замедлением составила лишь r = 0,55. Цена одного добавленного спилла для разных ядер различается почти в 30 раз: от около 0%/спилл у SipHash до 2,2%/спилл у FIR-фильтра. Примеры: целочисленное матричное умножение доходит до +42% всего от 22 добавленных спиллов; LZ77 добавляет 76 спиллов ради +19%; SipHash добавляет 23 спилла без заметного замедления (около −2%, то есть практически без изменения времени). Собственное объяснение авторов (заявлено как гипотеза, не как установленный факт): стек размещается в кэше L1, поэтому спилл вне критического пути зависимостей почти бесплатен, а спилл внутри тесной рекуррентной зависимости (ARX-цепочка, обновление аккумулятора) упирается в задержку перезагрузки значения из памяти и стоит дорого. Статическое число спиллов не различает эти два случая.
Результат 3: замедление специфично для конкретного файла регистров. Резервирование GP-регистров у матричного умножения с двойной точностью почти не влияет на время (131,9 → 131,1 мс при резервировании от 0 до 10 регистров), а резервирование XMM у того же ядра стоит +34%, чистое подтверждение гипотезы H2. SHA-256, более запутанный случай: резервирование XMM стоит всего +5,6%, а GP, уже +33%, но при этом резервирование XMM вовсе не нейтрально: число спиллов в стек у SHA-256 растёт с 8 до 65, потому что GCC при -O2 автоматически векторизует часть вычисления расписания сообщений SHA-256 инструкциями SSE (155 обращений к XMM-регистрам в горячей функции при полном бюджете). Принудительное вытеснение этой работы обратно на GP-регистры и стек видно по числу спиллов, но почти не видно по времени, потому что это вычисление не лежит на критическом пути.
Отдельный раздел авторы прямо помечают как экстраполяцию, а не измерение: сколько это стоило бы на 32-битном x86 девяностых. У 32-битного x86 было восемь GP-регистров, но esp никогда не был доступен для аллокатора, а ebp обычно занят под указатель кадра, реально доступно 6, 7 регистров против 15 здесь, и развёртка эксперимента как раз проходит через эти точки. Переход с 15 до 6, 7 GP-регистров стоит около 15% на типичном ядре и около 30% на самом прожорливом до регистров целочисленном ядре, так что расхожая цифра «около 30%» справедлива только для прожорливого хвоста ядер и примерно вдвое завышена для среднего ядра. Авторы отмечают два фактора, из-за которых их оценка занижает реальную цену для техники 1990-х (64-битное значение на x86-32 занимает пару регистров, целочисленные ядра под такой нагрузкой почувствовали бы это сильнее; и главное, спиллы дёшевы сегодня именно потому, что стек живёт в L1, а исполнение с изменением порядка (out-of-order) перекрывает перезагрузку другой работой, чего тогдашнее железо делать не умело), и один фактор в обратную сторону, почему восьмирегистровый x86 состарился лучше, чем состарился бы восьмирегистровый load/store-процессор архитектуры RISC: двухоперандная система команд x86 с операндами в памяти позволяет однократно читаемому спиллу свернуться прямо в потребляющую инструкцию без отдельной загрузки, и при однотактном попадании в L1 такой спилл почти бесплатен даже на процессоре без изменения порядка исполнения. Опубликованные при выходе AMD64 сравнения x86-64 и x86-32 в целом укладывались в диапазон примерно 5, 15% для обычного целочисленного кода, что согласуется с собственным разбиением авторов на «около 15% в среднем, около 30% для самых прожорливых», хотя те цифры включают и другие изменения AMD64 (фиксированное соглашение о вызовах, адресацию относительно RIP, удвоенный файл XMM-регистров), а не только эффект от числа регистров.
Авторы сами перечисляют ограничения работы: одна машина, один компилятор, один уровень оптимизации (gcc 15.2.0, -O2), Clang/LLVM использует другой аллокатор регистров и, по их ожиданию, дал бы другие кривые, но это не проверялось; число спиллов, лишь статический косвенный показатель, аппаратные счётчики производительности в их окружении были отключены (perf_event_paranoid=4), поэтому реальных динамических данных о спиллах/перезагрузках нет; измерялось астрономическое время, а не такты или количество исполненных микроопераций, и сигнал шумит на уровне около 1% даже при минимуме из 15 повторов; тестовые ядра, микробенчмарки с рабочими наборами, целиком помещающимися в L1, ядро, чьи спиллы промахивались бы мимо кэша, показало бы более резкую зависимость цены от числа спиллов; порядок отбора регистров фиксирован, другой порядок или прицельное вытеснение конкретных «горячих» значений могли бы сдвинуть кривые. Код и сырые данные (results.json) выложены на GitHub в репозитории rjpower/spillbench.
Ключевые факты
- На девяти C-ядрах при пошаговом отборе регистров x86-64 (gcc -ffixed) восемь ядер из девяти замедлились на 14, 76% при самом жёстком бюджете; исключение, SipHash-2-4, время которого практически не сдвинулось.
- Главный вывод: число спиллов слабо предсказывает замедление, корреляция Пирсона r = 0,55 по 105 точкам (кернел × бюджет), а цена одного спилла для разных ядер различается почти в 30 раз (от около 0%/спилл у SipHash до 2,2%/спилл у FIR-фильтра).
- Замедление зависит от конкретного файла регистров: резервирование GP-регистров у матричного умножения с плавающей точкой почти не влияет на время, а резервирование XMM у того же ядра стоит +34%; у SHA-256 картина смешанная, GCC автоматически векторизует часть кода инструкциями SSE, поэтому резервирование XMM поднимает число спиллов с 8 до 65, но замедляет лишь на 5,6%.
- Авторы прямо помечают как экстраполяцию (не измерение) оценку для 32-битного x86 девяностых: переход с 15 до 6, 7 GP-регистров стоит около 15% на типичном ядре и около 30% на самом прожорливом, расхожая цифра «около 30%» справедлива только для прожорливого хвоста ядер.
- Собственные ограничения работы: одна машина, один компилятор и один уровень оптимизации (gcc 15.2.0, -O2); аппаратные счётчики спиллов были недоступны, поэтому метрика, статический косвенный показатель по дизассемблированному коду, а не реальные события перезагрузки из памяти.
Почему это важно
Работа проверяет эмпирикой расхожее допущение системного программирования: «чем больше спиллов регистров, тем медленнее код». Оказывается, это верно только в среднем и очень грубо, корреляция между числом спиллов и реальным замедлением всего 0,55 из 1, а цена одного спилла для разных ядер различается почти в 30 раз. Причина, не все спиллы равны: спилл вне критического пути вычислений почти бесплатен (стек живёт в кэше L1, а современный процессор с изменением порядка исполнения прячет задержку загрузки за другой работой), а спилл внутри тесной рекуррентной зависимости, обновление аккумулятора, шаг ARX-цепочки, упирается в эту задержку напрямую и стоит дорого. Это разрушает удобную, но неточную интуицию, которой обычно объясняют регистровое давление в компиляторах.
Кому это важно
Разработчикам компиляторов и авторам регистровых аллокаторов; инженерам, пишущим низкоуровневый производительный код на C/C++/Rust и вручную рассуждающим о регистровом давлении; авторам и читателям микробенчмарков, которые используют число спиллов как прокси-метрику производительности; разработчикам под встраиваемые и старые архитектуры с малым числом регистров, где вопрос «сколько теряем на нехватке регистров» встаёт буквально.
Как это применить
Не использовать статическое число спиллов как самостоятельную метрику ожидаемого замедления, оно объясняет лишь около трети дисперсии (r² ≈ 0,3) реального эффекта. При анализе конкретного узкого места смотреть, лежит ли спиллящееся значение на критическом пути (рекуррентная зависимость, аккумулятор в цикле) или вне его: это определяет цену спилла на порядок точнее, чем их количество. При работе с ограниченным числом регистров учитывать, какой именно файл регистров (GP или XMM) нагружает код, и иметь в виду, что компилятор может неявно задействовать XMM-регистры даже в целочисленном коде через автовекторизацию. Собственные измерения при необходимости можно воспроизвести, код и сырые данные (results.json) авторы выложили в открытом репозитории GitHub rjpower/spillbench.
Можно ли доверять
Источник, авторский технический блог (rjp.io), а не рецензируемая публикация; имена авторов и институциональная принадлежность в тексте не указаны, текст ведётся от первого лица множественного числа («мы»). Сильная сторона, полная воспроизводимость: код тестов, метод замера и сырые данные (results.json) выложены открыто на GitHub, методология описана детально (конкретная машина, версия компилятора, флаги, число повторов), контрольные суммы результатов вычислений проверены на совпадение на каждом бюджете регистров. Авторы сами чётко разделяют измеренные результаты и явно помеченную экстраполяцию (раздел про x86-32), не выдавая предположения за факты.
Риски и подводные камни
Все выводы получены на одной машине (Intel Xeon E-2236), одном компиляторе и одном уровне оптимизации (gcc 15.2.0, -O2), авторы сами ожидают, что Clang/LLVM с другим аллокатором регистров дал бы другие кривые, но это не проверялось. Метрика спиллов, статический косвенный показатель по дизассемблированному коду, а не реальные динамические события перезагрузки из памяти: аппаратные счётчики производительности в окружении авторов были отключены. Тестовые ядра, микробенчмарки с рабочим набором, целиком помещающимся в кэш L1; для кода, чьи спиллы промахиваются мимо кэша, цена спилла может быть заметно выше измеренной здесь. Порядок отбора конкретных регистров фиксирован, другой порядок или прицельное вытеснение конкретных «горячих» значений могли бы сдвинуть кривые. Раздел про цену нехватки регистров на 32-битном x86 девяностых, прямо заявленная авторами экстраполяция, а не измерение на историческом железе.
«Мы полагаем, объяснение в том, что стек размещается в кэше L1, поэтому спилл вне критического пути зависимостей почти бесплатен, а спилл внутри тесной рекуррентной зависимости, ARX-цепочки, обновления аккумулятора, упирается в задержку перезагрузки значения из памяти.»
— авторы исследования, блог rjp.io