Всё вроде должно быть просто
Сцена этого конкретного преступления может показаться неправдоподобной: аномалия производительности, возникающая в простейшем машинном коде. На самом деле, его даже можно назвать чрезмерно упрощённым, ведь он не выполняет никакой полезной работы. Он нужен лишь для того, чтобы продемонстрировать поведение оборудования в образовательных целях.
Но по моему опыту, чрезвычайно простой машинный код — это, на самом деле, один из самых частых источников чего-то странного. Так как мы передаём CPU ограниченное количество крайне специфичных команд без остальной части когда, то упираемся в границы того, что проектировщики оборудования ожидали встретить в реальном мире. В этой ситуации вы с большей вероятностью сможете пощупать границы микроархитектуры, чем в более стандартном сценарии.
Конкретный рассматриваемый нами упрощённый код, вызывающий загадочное поведение, взят из вводного видео о том, как компоновать файлы ASM для микробенчмаркинга. Оно даже не было исследованием производительности!
Но для наших целей это очень подходит, потому что даже если вы не проходили мой курс и не знаете, как читать язык ассемблера, исходный код достаточно прост, чтобы понять его предназначение:
.loop:
inc rax
cmp rax, rcx
jb .loop
Этот цикл состоит всего из трёх команд. Если воспользоваться терминологией языков высокого уровня, первая команда — inc rax
— прибавляет к переменной единицу. Вторая — cmp rax, rcx
— определяет, меньше ли переменная, чем желаемый счётчик итераций1. А третья — jb .loop
— повторяет цикл, если меньше.
На языке высокого уровня тот же цикл можно записать так:
do { // .loop
i++; // inc rax
} while(i < count); // cmp rax, rcx вместе с jb .loop
Этот цикл может выполнять любой чип x64. Он не содержит никаких сложных команд из наборов команд AVX или AVX-512, которые могут существовать только в новых процессорах. На самом деле, он почти настолько прост, чтобы его можно было выполнить на 8086/88! Единственное, что мешает ему ассемблироваться в код для 8086, так это то, что в нём используются 64-битные регистры вместо 16-битных. Если заменить rax
и rcx
на ax
и cx
, то он ассемблируется в валидный код для 8086.
Более того, этот цикл не только прост в понимании, но и, если вы знаете некоторые основы современных микроархитектур, то можете легко понять и его производительность. Или, по крайней мере, это легко было бы сделать, если бы не аномалия, о которой мы будем говорить.
Есть небольшая тонкость в inc rax
и способе обновления флагов, но на наш анализ это никак не повлияет, поэтому чтобы разгадать загадку, вам этого знать не нужно. На самом деле, флаги здесь не важны, поэтому я буду пропускать отслеживание флагов в последующих примерах анализа, чтобы их было проще читать. Но, разумеется, следует помнить о том, что существуют флаги, которые генерируются и отслеживаются в этом цикле, так что если бы это был сценарий, в котором операции с флагами влияют на результат, то их нужно было учитывать.
Если забыть об этой тонкости, при передаче циклу достаточно большого значения rcx
(счётчика) в подавляющем большинстве случаев будет происходить переход к началу цикла. Только при самой последней итерации выполнение «провалится» к следующему блоку кода. Это значит, что фронтенд CPU должен корректно прогнозировать повторение и передавать бэкенду повторяющиеся последовательности микроопераций, которые, по сути, кодируют вот это2 (как я говорил выше, не считая записи флагов):
rax <- rax + 1
cmp rax, rcx / jb
rax <- rax + 1
cmp rax, rcx / jb
rax <- rax + 1
cmp rax, rcx / jb
rax <- rax + 1
cmp rax, rcx / jb
...
Так как имена регистров — это просто имена, на этапе переименования это превратится во что-то концептуально схожее с SSA:
g2 <- g1 + 1
cmp g2, g0 / jb
g3 <- g2 + 1
cmp g3, g0 / jb
g4 <- g3 + 1
cmp g4, g0 / jb
g5 <- g4 + 1
cmp g5, g0 / jb
...
где g
с индексами — это элементы регистрового файла для регистров общего назначения. Так как rcx
(здесь обозначенный как находящийся в g0
) не меняется, структура зависимостей для этого цикла полностью основана на инкременте. Для каждого инкремента существует пара микроопераций, которые должны ожидать его завершения; одна из них — это следующий инкремент:
g2 <- g1 + 1
// Нужно подождать g2
cmp g2, g0 / jb
g3 <- g2 + 1
// Нужно подождать g3
cmp g3, g0 / jb
g4 <- g3 + 1
// Нужно подождать g4
cmp g4, g0 / jb
g5 <- g4 + 1
// Нужно подождать g5
cmp g5, g0 / jb
...
Исходя из этого, мы ожидаем, что производительность этого цикла будет стабильной. Так как перед выполнением каждой пары независимых микроопераций бэкенд должен ждать результата предыдущего инкремента, а самое быстрое получение результата возможно в течение одного такта ядра, стоит ожидать, что этот цикл будет иметь максимальную скорость в один такт ядра на итерацию.
Тогда, например, если у нас есть процессор с частотой 5 ГГц, то следует ожидать, что он выполнит не более пяти миллиардов итераций этого цикла в секунду: по одной итерации на каждый его такт ядра.
Если замерить производительность цикла на разных архитектурах x64, то получим ровно ту производительность, которую ожидаем. Процессоры Skylake, Zen 2 и Zen 4 в моём офисе выполняют этот цикл со скоростью один такт на итерацию.
Более того, если мы выполним симуляции этого цикла при помощи лучшего публичного симулятора микроархитектуры Intel, то по прогнозам симулятора цикл займёт один такт на итерацию на всех поддерживаемых им архитектурах Intel: Sandy Bridge, Ivy Bridge, Haswell, Broadwell, Skylake, Skylake-X, Kaby Lake, Coffee Lake, Cascade Lake, Ice Lake, Tiger Lake и Rocket Lake.
Всё вроде сходится. Так в чём же проблема?
Аномалия Alder Lake
Работая в RAD Game Tools более двадцати лет назад, я понял, что если выпускаешь продукт для широкой аудитории, то сталкиваешься со всевозможными аномалиями оборудования PC. Если у тебя нет постоянного доступа к огромной тестовой лаборатории с тысячами комбинаций оборудования, то ты никак не сможешь предвидеть безумные ситуации, происходящие в реальной жизни. Поэтому я ожидал появления странных вещей, когда люди с курса Performance-Aware Programming начали выполнять собственные микробенчмарки.
Пока мы выявили только пару: ещё не исследованная аномалия пропускной способности памяти Kaby Lake3 и производительность цикла Alder Lake, описанная в предыдущем разделе.
В частности, для воспроизведения аномалии я использовал машину с i7-12700K. Это процессор Alder Lake с максимальной частотой разгона 5 ГГц. Исходя из логики предыдущего раздела, если запустить на этом процессоре простой цикл inc
/cmp
/jb
, то он должен выполняться с максимальной скоростью в пять миллиардов итераций в секунду, то есть по одному такту ядра на итерацию. На основании нашего анализа можно было сказать, что любые показатели выше были бы практически и теоретически невозможны на этом процессоре.
При помощи простых вычислений мы также можем определить теоретический максимум для любого количества итераций. Три миллиарда итераций должны занять три миллиарда тактов. Два миллиарда итераций — два миллиарда тактов. И так далее.
В конкретном случае, тестированном в рамках курса, мы выполняли 1073741824 итераций. Число может показаться странным, но это просто количество байтов в гигабайте памяти (230, или 1024*1024*1024).
При выполнении на моём процессоре Skylake с отключенным разгоном этот тест занял 1074730560 тактов, судя по показаниям счётчика меток времени всего чипа (считываемого при помощи rdtsc). В процессе выполнения неизбежно возникают неточности, поэтому сложно проводить замеры производительности в изоляции. Но даже учитывая это, результаты крайне точно соответствуют нашим ожиданиям: 1074730560 тактов, разделённые на 1073741824 итерации, дают примерно 1,0009 на итерацию, что очень близко к 1.
Однако при выполнении на Alder Lake i7-12700K мы получаем нечто совершенно иное: счётчик меток времени сообщает, что потрачено всего 391416518 тактов. 391416518 тактов, поделённые на 1073741824 итерации — это 0,364535 тактов на итерацию: в 2,7 раза быстрее, чем это возможно теоретически!
На первый взгляд в этом нет ничего особо настораживающего. Если вы уже занимались микробенчмаркингом, то знаете, что в современных процессорах считываемый rdtsc
счётчик меток времени считает такты на базовой частоте и не учитывает таких особенностей, как разгон одного ядра. Поэтому первым делом вы можете подумать «вероятно, разогнанная частота должна быть примерно в три раза больше базовой частоты процессора».
Но так ли это? Разве это не слишком высокая частота разгона?
Если изучить спецификацию, то можно узнать, что базовая частота составляет 3,60 ГГц для P-ядер и 2,70 ГГц для E-ядер, а максимальная частота разгона составляет 5,00 ГГц для P-ядер и 3,80 ГГц для E-ядер. Чтобы множитель между базовой и разогнанной частотой был равен 2,7, даже учитывая максимальный разгон до 5 ГГц, следовало бы ожидать, что базовое значение частоты примерно равно 1,8 ГГц, что далеко от базовой частоты E-ядер, не говоря уже о P-ядрах.
Более того, если замерить количество тактов счётчика меток времени, происходящих за одну секунду по наблюдениям выполняющего тест ядра, то мы получим примерно 3,6 ГГц. То есть мы более-менее знаем, что работаем с P-ядром, его счётчик меток времени работает на 3,6 ГГц, и что максимальный множитель при разгоне будет равен 5 ГГц/3,6 ГГц.
К сожалению, это всего 1,4, то есть примерно вдвое меньше, чем наблюдаемый 2,7.
Огромные муки
То есть даже если взять в расчёт частоту разгона, объяснить эту аномалию невозможно. Можно изложить эти соотношения другим образом: для наших 1073741824 итераций каждую секунду тратится 391416518 меток времени из 3600000000. Это значит, что на тест суммарно требуется 0,108727 секунды. Если бы CPU на самом деле занимал 1 (разогнанный) такт ядра на итерацию при 5 ГГц, т это было бы 1073741824 тактов ядра из 5000000000 каждую секунду, или 0,214748 секунды.
Иными словами, при максимальном разгоне следует ожидать, что тест займёт 0,214748 секунды, но он занимает всего 0,108727 секунды при замерах. Это вдвое выше спрогнозированной производительности. Если мы где-то не ошиблись, Alder Lake как будто выполняет цикл вдвое быстрее теоретического максимума!
Этого просто не может быть, ведь так? Не знаю, как вы, а я бы точно предположил, что не может. Я Windows-разработчик, поэтому это вселяет в меня ощущение ужаса, потому что на этом этапе у меня возникло чувство, что мне предстоит испытать... огромные муки.
Огромные муки — это категория задач программирования, в которых приходится, например, использовать такие вещи, как XML в качестве формата данных. Это тот момент, когда у вас есть задача, тривиально решаемая в случае разумного фундамента, но он спроектирован так, как будто специально должен делать вашу жизнь ужасной.
В этом конкретном случае фундаментом будет Event Tracing for Windows, он же худший API в мире.
Разработчики под Windows часто оценивают производительность при помощи голых показаний rdtsc
не потому, что это лучший способ измерения. На современных процессорах возможности rdtsc
сильно ограничены. Мы должны предпочитать использовать целый набор подробных счётчиков мониторинга производительности (PMC), существующий в современных чипах x64.
Но разработчики под Windows обычно этого не делают, потому что Windows сильно осложняет их чтение. rdtsc
4 удобно вызывать из приложения пользовательского уровня, однако не существует столь же удобного для приложения пользовательского уровня способа считывания целой кучи других ценных PMC процессоров x64.
Если упростить, у разработчиков для Windows есть всего три варианта:
Установить сторонний пакет, имеющий драйвер уровня ядра для сбора PMC (VTune, Intel Performance Counter Monitor и так далее)
Портировать приложение в среду, имеющую более качественную нативную поддержку PMC (Linux, direct boot из BIOS и так далее)
Испытывать огромные муки, то есть пользоваться Event Tracing for Windows, или прямым вызовом API, или при помощи какой-нибудь комбинации фронтендных инструментов (wpr, xperf, tracelog и так далее)
Какой бы вариант вы ни выбрали, вас ждёт что-то намного более раздражающее, чем десять секунд, которые требуются, чтобы напечатать __rdtsc()
в коде5. Так как единственная машина с Alder Lake, к которой у меня был доступ — это чужая удалённая Windows-машина, мне пришлось выбрать третий вариант, то есть подвергнуть себя огромным мукам.
Чтобы не подвергать мукам читателей, я избавлю вас от всех кровавых подробностей и сразу перейду к данным. При помощи бессмысленно ограниченной функциональности ETW мы можем косвенно считать данные с двух дополнительных счётчиков PMC, которые дадут нам более точную картину происходящего при выполнении цикла процессором Alder Lake.
Первый счётчик ETW называется TotalIssues, он считает количество команд, которые, по его мнению сгенерировало ядро. Второй доступный счётчик называется UnhaltedCoreCycles (для наших целей эквивалентно также использовать PMC, в ETW доступный под именем TotalCycles), он подсчитывает количество тактов, выполненных самим ядром (не инвариантных тактов счётчика меток времени, а действительных тактов ядра с учётом разгона).
Если мы будем считывать эти счётчики при выполнении цикла на моём Skylake с отключенным разгоном, то дельты будут соответствовать нашим ожиданиям:
1073970036 TSC elapsed / 1073741824 iterations [0 switches]
3221373030 TotalIssues
1073970333 TotalCycles
1073970331 UnhaltedCoreCycles
Количество наблюдаемых ядром тактов приблизительно равно количеству прошедших меток времени (TSC), как мы и ожидали бы от ядра с отключенным разгоном. Количество отправленных команд примерно в три раза (~2,999500) больше количества наблюдаемых циклов, и этого мы тоже ожидаем, если каждая итерация занимает один такт: в цикле три команды, поэтому если в среднем они исполняются в дном такте, то на такт должно приходиться три отправленные команды.
Но что произойдёт, если мы получим данные тех же счётчиков у машины с Alder Lake?
388096288 TSC elapsed / 1073741824 iterations [0 switches]
3221327798 TotalIssues
536959840 TotalCycles
536959830 UnhaltedCoreCycles
В отличие случая с машиной Skylake, прошедшие TSC отличаются от количества прошедших тактов, но это ожидаемо, ведь мы не отключили разгон на этой машине. Соотношение тактов к TSC примерно равно 1,383573, что вполне соответствует ожидаемым максимальным соотношением разгона до 5,0 ГГц с 3,6 ГГц (~1,388889). То есть наши показания PMC похожи на правильные, и они подтверждают, что сработал разгон, близкий к максимальному, тут никаких сюрпризов.
Но потом мы переходим к показателю TotalIssues. Повторюсь, теоретический максимум отправленных команд на такт для этого цикла должен быть равен трём, и на Skylake он был равен трём.
Однако на Alder Lake мы получаем 3221327798 отправленных команд за 536959840 тактов ядра — частота отправки команд равна почти шести командам на такт (~5,999197). Это не опровергает наши предыдущие результаты, а полностью их подтверждает: согласно PMC, Alder Lake действительно обрабатывает две итерации цикла каждый такт ядра.
Если это не наглая ложь, то ядро на самом деле работает с удвоенной максимальной пропускной способностью.
Может ли это быть правдой?
В P-ядрах Alder Lake используется микроархитектура, названная Intel Golden Cove. Так как мы чётко видим, что цикл выполняется с частотой разгона, близкой к 5 ГГц, то нет сомнений, что измерения делаются на одном из этих P-ядер Golden Cove, а не на менее мощных E-ядрах.
В каком-то смысле наши результаты неудивительны: ядра Golden Cove должны уметь выполнять шесть команд за такт. Это их теоретическая максимальная скорость обработки команд. То есть, как минимум ядро не нарушает своих базовых параметров работы, установленных Intel.
Но в более широком смысле эти результаты довольно неожиданны. Если я не сделал серьёзной ошибки при сборе данных, что всегда возможно при таком тонком тестировании, то теперь у нас есть доказательство того, что ядро Golden Cove способно выполнять два последовательно зависимых инкремента за один такт. Хотя теоретически нет никаких причин для того, чтобы в ядре не было достаточной для обеспечения такого результата логик, пока я не сталкивался с таким ни на одном ядре x64.
В частности, пропускная способность в 6 команд на такт для этого цикла подразумевает, что 2 из этих 6 команд является инкрементами rax
:
inc rax ; [A]
inc rax ; [B]
Если записать это в более явном виде, то получится следующее:
g2 <- g1 + 1 ; [A]
g3 <- g2 + 1 ; [B]
Обычно для вычисления [B]
результат g2
необходимо перенаправить из блока выполнения, вычисляющего [A]
в блок выполнения, который вычислит [B]
, потому что [B]
требует g2
на входе, а его значение неизвестно, пока его не вычислит [A]
. Именно поэтому мы ожидаем, что для каждого инкремента нам нужно ждать как минимум один такт: это минимальное время, которое должно пройти для вычисления и передачи результата из предыдущего инкремента.
Однако мы чётко видим, что и [A]
, и [B]
выполняются в одном такте. Происходит нечто крайне неожиданное.
Все объяснения, связанные с блоками выполнения, которые я могу придумать, были бы чрезвычайно странными и, вероятно, ошибочными. Например, ALU сумматора и планировщик имеют магический режим, в котором они выполняются дважды за один такт; два ALU комбинируются, чтобы выполнить один двойной инкремент, при этом опционально записывая два набора флагов; разгон ядра выполняется до 10 ГГц, но PMC всё равно по какой-то иной причине всё равно ограничены 5 ГГц; и так далее.
Я не проектирую оборудование, поэтому моё воображение сильно ограничено. Возможно, существуют более очевидные способы сделать это, которые мне неизвестны. Но если их нет, я не вижу простых объяснений того, каким образом блоки планирования и выполнения в ядре могут самостоятельно создавать такой результат.
Остаётся фронтенд и блоки переименования/устранения ядра. Здесь я смог придумать объяснение, которое бы не казалось абсолютно смехотворным.
Идея заключается в том, что Intel, возможно, не создавая особого шума, добавила самому этапу переименования возможность регулировать непосредственные операции сложения, чтобы устранять зависимости. Если это было бы так, то ядро могло бы перед планированием переписать:
inc rax ; [A]
inc rax ; [B]
не только так, как мы могли бы ожидать в обычном случае:
g2 <- g1 + 1 ; [A]
g3 <- g2 + 1 ; [B]
но и опционально в менее зависимом виде:
g2 <- g1 + 1 ; [A]
g3 <- (g1+1) + 1 ; [B]
При этом к созданию результата становится готов не один инкремент, а два. Сразу после вычисления g1
можно начать выполнение и [A]
, и [B]
; при этом [B]
не нужно простаивать, ожидая, пока вычислится [A]
.
Более того, кажется правдоподобным (если эта функция не ограничена работой двух или более команд непосредственного сложения), что это можно полностью выполнить на этапе переименования простым изменением констант. Благодаря ассоциативности блок переименования может переписать это:
g3 <- (g1+1) + 1 ; [B]
следующим образом:
g3 <- g1 + (1 + 1) ; [B]
что можно упростить до этого:
g3 <- g1 + 2 ; [B]
Это будет не сложнее представить в конвейере, чем любое непосредственное сложение. Вниз по потоку от блока переименования ничего менять не нужно.
Понятия не имею, насколько это правдоподобно, но мне это кажется реалистичным, чего нельзя сказать о всех других моих объяснениях.
Возможное подтверждение Intel
Вооружившись большой долей уверенности в том, что CPU обеспечивает две итерации на такт ядра, и правдоподобной теорией о том, как это может происходить, я снова попытался найти документацию об этой способности Golden Cove.
Разумеется. я проделал это и в самом начале, но не нашёл ничего. В руководстве Агнера Фога по микроархитектуре не написано ничего подобного, а Golden Cove описан не очень подробно. В руководстве Intel по оптимизации перечислен список улучшений Golden Cove, но не упоминается ничего подобного.
Однако после того, как я заподозрил, что нужно искать нечто, связанное с оптимизацией непосредственного сложения, поискав множество терминов, я нашёл одно— да, лишь одно — возможное подтверждение правильности моей теории. Оно обнаружилось в статье AnandTech о презентации Intel, устроенной в один из её «Дней архитектуры». Оно встречается внизу следующего слайда, где в разделе «Smarter» говорится, что на этапе «переименования/распределения» «выполняется» больше команд:
Хоть это нам особо ни о чём и не говорит, должно быть, AnandTech позже получил от Intel приватную информацию (или увидел какое-то дополнительное публичное объявление, которое мне не встречалось), потому что сам добавил в статью более подробное описание:
К сожалению, я по-прежнему понятия не имею, откуда взялась эта информация. Я самостоятельно пересмотрел видео про «День архитектуры», но проводивший презентацию сотрудник сказал ровно то, что написано на слайде: некоторые команды теперь «выполняются» на этапе переименования/распределения.
Учитывая неопределённость, я бы сказал, что эта загадка по большей мере решена, но не полностью. По сути, мы знаем, что и почему происходит. Но так как я не смог найти полного описания того, как работает эта фича, нам всё ещё недостаёт одного куска пазла.
В частности, AnandTech использовал фразу «обрабатываются как NOP». Если это так, то описанный мной гипотетический механизм может быть неверным. Предложенный мной способ не устраняет непосредственное суммирование, а лишь ребалансирует его, чтобы уменьшить длину цепочек зависимостей. В моей гипотетической модели это:
inc rax
inc rax
inc rax
inc rax
в лучшем случае превращается в это:
g2 <- g1 + 1
g3 <- g1 + 2
g4 <- g1 + 3
g5 <- g1 + 4
Это по-прежнему 4 микрооперации — то же количество, которое мы бы получили без непосредственного сворачивания. Преимущество заключается только в том, что все четыре микрооперации теперь могут выполняться параллельно, в то время как ранее они должны были выполняться последовательно. Ни на одном из этапов мой инкремент не превращается в NOP!
Разумеется, возможно, что верны и моя гипотеза, и заявление AnandTech. Под упоминанием NOP может подразумеваться последующий этап, происходящий после предложенного мной этапа. Если распределитель замечает, что имя регистра было переписано, а, следовательно, на результат пока не запланированного сложения невозможно сослаться, то он может выполнить подобное устранение мёртвого кода и заранее отбросить операцию.
Например, если в приведённой выше последовательности из четырёх микроопераций между инкрементами не используется rax
, то при его переименовании в inc
распределитель может заметить ,что результаты микроопераций g2
, затем g3
, затем g4
никогда нельзя будет использовать. Если он заметит это достаточно рано, то может отбросить часть из них ещё до того, как отправить их в очереди планирования.
Это может быть правдоподобно, но это далеко от моей сферы знаний. Мне приходится лишь гадать. Я занимаюсь ПО, а не оборудованием, так что предпочёл бы найти реальную документацию об этой новой микроархитектурной фиче!
В её отсутствие я считаю, что можно было бы создать серию микробенчмарков в Linux (чтобы доступ к PMC был проще), которые бы позволили предположить, что происходит внутри. Не так сложно представить, как мы передаём Golden Cove слегка отличающиеся последовательности команд, чтобы точно увидеть, что он может и чего не может делать с непосредственными сложениями.
К сожалению, для этого процесса потребуется постоянный доступ к машине с Alder Lake, которого у меня нет. Поэтому, к сожалению, приходится надеяться на кого-то ещё, кто любит экспериментировать с ядрами x64!
Строго говоря, поскольку он задаёт множество флагов, то может определять множество аспектов связей между двумя двумя входными данными, но в последующем условном переходе мы будем использовать только «меньше, чем».
Конкретный способ обработки
jb
обычно не документируется, но в руководствам по микроархитектурам часто говорится, что он «совмещается» (fused) сcmp
по крайней мере в части конвейера, то есть внутреннее представлениеcmp
иjb
движется через очереди как единое целое, а не обрабатывается как полностью отдельные микрооперации. Я обозначал везде совмещённыйjb
как/ jb
, хотя в какой-то момент он может становиться «несовмещённым» для отдельной обработки; в частности, при проверке корректности прогнозирования ветвления. Так как в нашем объяснении производительности это неважно, в используемой статье неформальной записи я не пытался представить, может ли выполняться отдельная обработкаjb
.Я пока не исследовал аномалию Kaby Lake, потому что, несмотря на множество попыток связаться с Intel, чтобы получить доступ к машине с Kaby Lake, мне не удалось договориться с ними. Я смог изучить рассмотренную в статье аномалию Alder Lake только потому, что один из проходящих курс студентов настроил для меня удалённое десктопное соединение со своей машиной.
А в последнее время и
rdpru
на оборудовании AMD, который мне очень нравится — спасибо, AMD!Кстати, именно настолько просто вставить мониторинг PMC в машинный код. Выбрав нужный PMC, можно считывать PMC одной командой
rdpmc
, аналогично тому, как мы считываем TSC командойrdtsc
. Поэтому единственная причина того, что мы не можем удобно считывать PMC, заключается в том, что Windows специально мешает это делать, в основном из соображений безопасности — и это было бы нормально, если бы она предоставляла нормальный API для доступа к PMC, чего она, разумеется, не делает.
Комментарии (21)
stanislavshwartsman
18.12.2024 05:34Автор обнаружил Immediate Folding, известную технологию, описанную в статьях и патентах как минимум с десяток раз. На данный момент она есть уже у всех, включая высокопроизволительные Arm микроархитектуры от Apple и Qualcomm.
dmitryrf
18.12.2024 05:34Как решается вопрос с тем, что проверять результат суммирования надо после каждой суммы? Спекулятивное исполнение? Сложили всё сразу, а потом выполнили проверку для каждого результата?
stanislavshwartsman
18.12.2024 05:34Ни то и ни то. Поищите хотя бы paper "continuous optimization" опубликован на isca 2005 ну и дальше от него по ссылкам. Там immediate folding оптимизация не глубоко расписана, но основное представление получить можно.
Или Reno: a rename based instruction optimizer. Там ещё более подробно.
vanxant
18.12.2024 05:34При разворачивании цикла на две итерации второй inc rax можно заменить на dec rcx (фантомный, разумеется) Тогда можно заNOPать первый cmp / jb. Правда, в этом случае придётся делать отдельную копию rax, куда добавлять 2, а не 1.
Впрочем, вряд ли сделано именно так.
LinkToOS
18.12.2024 05:34На языке высокого уровня тот же цикл можно записать так:
do { i++; } while(i < count); // inc rax; cmp rax, rcx; jb .loopНе можно. Потому что применение такого цикла может иметь разную цель:
1. Задержка на какое-то число тактов
2. Выполнение какого-то действия число раз указанное в rcx
3. Извращенный способ загрузки в регистр rax значения равного значению в регистре rcx.
Соответственно полезный результат выполнения цикла может быть таким:
1. Прошло реальное время равное N тактов процессора
2. Некое действие выполнено X раз (к примеру сохранено X значений из порта ввода/вывода в оперативную память)
3. Значение rax стало равно значению rcx.
Возможная оптимизация в соответствии с целями:
1. Можно установить таймер на прерывание через время равное N.
2. Нельзя оптимизировать.
3. Тупо скопировать rcx в rax.Однако при выполнении на Alder Lake i7-12700K мы получаем нечто совершенно иное: счётчик меток времени сообщает, что потрачено всего 391416518 тактов. 391416518 тактов, поделённые на 1073741824 итерации — это 0,364535 тактов на итерацию: в 2,7 раза быстрее, чем это возможно теоретически!
Это означает прежде всего то, что цикл с последовательными инкрементами регистра, это хреновый способ тестирования производительности современных процессоров.
И еще это какбэ намекает - не надо для организации задержек использовать цикл с инкрементом. Результат непредсказуем.Если я не сделал серьёзной ошибки при сборе данных, что всегда возможно при таком тонком тестировании, то теперь у нас есть доказательство того, что ядро Golden Cove способно выполнять два последовательно зависимых инкремента за один такт.
Круто конечно. Но кому нужно делать два подряд инкремента одной переменой за один такт? Какой в этом сакральный смысл?
Конкретный способ обработки jb обычно не документируется, но в руководствам по микроархитектурам часто говорится, что он «совмещается» (fused) с cmp по крайней мере в части конвейера, то есть внутреннее представление cmp и jb движется через очереди как единое целое, а не обрабатывается как полностью отдельные микрооперации.
Чисто теоретически, можно предположить, что в микроархитектурах новых процессоров элементы ядра динамически конфигурируются по типу ПЛИС, и несколько команд могут объединяться в единый аппаратный логический блок, и выполняться за один такт. Последовательность "инкремент регистра, сравнение, условный переход" - как раз подходящий случай.
В частности, AnandTech использовал фразу «обрабатываются как NOP».
«обрабатываются как NOP» - в том смысле, что процессор на бегу оптимизирует поток команд, выбрасывая "бессмысленные"?
f33lg00d
18.12.2024 05:34Угум-с, непонятно, какой именно "язык высокого уровня" автор имел в виду, но если C++, то по стандарту правильной интерпретацией является 3. Если бы автор взял, скажем, clang, то он бы зело удивился тому, что время выполнения вообще не зависит от разности
i
иcount
: https://godbolt.org/z/4M88EEq3x .
gurux13
18.12.2024 05:34Я полагаю, автор привел цикл для примерной иллюстрации происходящего для людей, не понимающих язык ассемблера.
По поводу осмысленности остальных действий: я (как и автор) предполагал, что очень много read after write команд нельзя выполнить быстрее одного такта на команду. Он нашёл один тип процессора, который это может, и изучал как. Практическая применимость этого цикла вообще неважна, автор не предлагает использовать этот цикл для чего либо кроме анализа микроархитектуры процессора.
LinkToOS
18.12.2024 05:34Я полагаю, автор привел цикл для примерной иллюстрации происходящего для людей, не понимающих язык ассемблера.
Для таких людей он разъяснил назначение каждой команды. - "Этот цикл состоит всего из трёх команд. Если воспользоваться терминологией языков высокого уровня, первая команда — inc rax — прибавляет к переменной единицу. Вторая — cmp rax, rcx — определяет, меньше ли переменная, чем желаемый счётчик итераций1. А третья — jb .loop — повторяет цикл, если меньше."
Пример на условном Си ничего к этому не добавляет, и дальше по тексту он не нужен. К тому же он некорректен, потому что результат компиляции может быть разным. Например цикл с i++ на Си может преобразоваться в "inc rax" в цикле, или в "mov rdx, 1" перед циклом и "add rax, rdx" в цикле. Оба варианта будут корректны относительно записи i++;автор не предлагает использовать этот цикл для чего либо кроме анализа микроархитектуры процессора.
Изначально у него не было цели анализировать микроархитектуру. Автор измерял производительность используя разные бенчмарки, затем столкнулся с аномальной производительностью на Alder Lake при выполнении цикла с инкрементом, и попытался объяснить это особенностями архитектуры.
Загадку автор в итоге не разгадал, и предложил владельцам процессоров Alder Lake подключиться к исследованиям.
По словам автора, он не имеет возможности экспериментировать с процессорами Intel. - "Я пока не исследовал аномалию Kaby Lake, потому что, несмотря на множество попыток связаться с Intel, чтобы получить доступ к машине с Kaby Lake, мне не удалось договориться с ними. Я смог изучить рассмотренную в статье аномалию Alder Lake только потому, что один из проходящих курс студентов настроил для меня удалённое десктопное соединение со своей машиной."
Автор заметил интересный эффект, но достоверного объяснения ему не нашел. Предложил только гипотезы. Того кто сам пишет тесты, статья возможно вдохновит на эксперименты.
alexeishch
18.12.2024 05:34Помню стажировался лет 15 назад в Intel Российском. Непонятно что вас удивляет.
1. То что команды выполняются параллельно? Ну да выполняются начиная с архитектуры Pentium. С тех пор количество "магии" и оптимизаций в микроархитектуре только выросло. Современные чипы CISC имеют внутри RISC-ядра и когда команды транслируются, они еще и оптимизируются. Независимые команды исполняются параллельно, несколько подряд идущих команд могут группироваться. Регистров в процессоре на самом деле сильно больше чем тех что вы видите в ассемблере. rax,rbx регистры - это лишь обозначение ассемблера, на самом деле их внутри сильно больше и процессор способен с каждым из них проводить операции независимо.
2. Каждый процессор имеет свои определенные особенности. Компилятор Intel и их библиотеки имеет оптимизированные версии распространенных функций для каждой микроархитектуры и особенностей кэша(!). Даже простое копирование будет по-разному выполнено на разных архитектурах.
3. Использовать VTune или аналогичный профайлер обязательно начиная с появления технологии Hyper Threading. Внезапно даже включая и выключая HT код может начать вести себя по-разному даже на одном и том же процессоре (потому что HT это неполноценная многоядерность).f33lg00d
18.12.2024 05:34Вот да, по описанию звучит так, что процессор научился в спекулятивное исполнение на уровне кремния. Это круто, конечно, и я похлопаю инженерам Интел, но зачем устраивать из этого загадку дыры?
WASD1
18.12.2024 05:34На уровне понимания автор описал out-of-order && register renaming && score-boarding без употребления умных терминов, но по-сути. А дальше удивляется: они не дают наблюдаемого результата из-за DU-зависимости.
На этом фоне ваш комментарий похож на нагромождение умных слов без понимания сути под ними.
1. Автора удивляет разрыв DU-зависимости (единственной "истинной" зависимости в терминах компиляторов, которую разорвать действительно сложно, особенно в аппаратуре).
Причём разрыв DU-зависимости в аппаратуре.
2. Автор описал разрыв зависимости (и возможные решения) довольно качественно (что прямо супер для человека от железа\компиляторов далёкого).
3. Как указано выше - за такую технику отвечает Immediate Folding. Что весьма интересно.alexeishch
18.12.2024 05:34Автор упустил всего одну вещь - наличие RISC ядра внутри процессоров Intel и наличие конвертации CISC команд в RISC. А следующее уже просто конкретные оптимизации конкретного процессора. После того как уперлись в физическую невозможность увеличивать частоту начали увеличивать размер кремния и фичи которые ответственны за трансляцию CISC->RISC. Уже много лет оптимизации строятся за счёт трасляции x86 команд в микроинструкции выполняемые внутри процессора. У интела каждые 4 года появляется новая микроархитектура с дополнительными улучшениями и удивительно что он это только недавно заметил. Судя по патенту прошло уже 2+ цикла, т.е. на рынке должно быть как минимум 4 поколения процессоров (разных по архитектуре и техпроцессу) где этот функционал реализован
ef_end_y
18.12.2024 05:34Блин, какие длинные статьи сейчас пишутся... Насколько я понял, автор так и не узнал почему так происходит и выдал догадки? Ну так и я, ничего не зная о современных технологиях процов, предположу: допустим jb в связке с cmp не ориентируется на флаги (!), а видит, что rax далеко меньше rcx и точно знает что можно инкрементировать ещё много раз. Да, это слишком частный случай чтоб проц такое мог, но может Интел как-то хитро обобщили такую идею...
cdriper
18.12.2024 05:34запускается одновременно две версии цикла с +1 и +2 с возможностью оставить побочные эффекты только одно из них по итогам выполнения операции cmp
ef_end_y
18.12.2024 05:34Слишком частный случай. А если между ними будут ещё операции?
cdriper
18.12.2024 05:34проблемы с single core производительностью привели нас к тому, что современные камни жрут по 300 ватт и выжать пытаются лишний 0.1% прироста
одна старушка рубль, десять старушек -- червончик (с)
очевидно, что в камне есть супер-пупер продвинутая логика, которая как раз и умеет находить кейсы, корректные для спекулятивного выполнения
и вся эта статья, на самом деле, о том, что в 12-м поколении Intel ее, в очередной раз, сильно прокачала )
WASD1
18.12.2024 05:34уже прокомментирвовали Immediate Folding - можно патент почитать: https://patents.google.com/patent/US20170123799A1/en
И да: это внесение некоторых "компиляторных техник" во фронтэнд CPU-конвейра.
И снова да: scope анализа в проце крайне ограничен (какой - не знаю).
И снова да как вам ответили: причина - выжимание максимума из single thread performance любыми средствами (например Intel гонит напряжение - что означает кубическое увеличение энергопотребление ради линейного увеличения тактовой частоты).
OksikOneC
Старый, добрый, ламповый Хабр! Спасибо за это чувство от прочтения статьи!