Загадка потерянного инкремента
- пятница, 20 декабря 2024 г. в 00:00:15
Сцена этого конкретного преступления может показаться неправдоподобной: аномалия производительности, возникающая в простейшем машинном коде. На самом деле, его даже можно назвать чрезмерно упрощённым, ведь он не выполняет никакой полезной работы. Он нужен лишь для того, чтобы продемонстрировать поведение оборудования в образовательных целях.
Но по моему опыту, чрезвычайно простой машинный код — это, на самом деле, один из самых частых источников чего-то странного. Так как мы передаём 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.
Всё вроде сходится. Так в чём же проблема?
Работая в 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]
Это будет не сложнее представить в конвейере, чем любое непосредственное сложение. Вниз по потоку от блока переименования ничего менять не нужно.
Понятия не имею, насколько это правдоподобно, но мне это кажется реалистичным, чего нельзя сказать о всех других моих объяснениях.
Вооружившись большой долей уверенности в том, что 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, чего она, разумеется, не делает.