Как обычно, для экономии времени читающих — мы просто получим доступ до внутренних счётчиков производительности процессора, используя команду RDPMC из пользовательского приложения при помощи драйвера-помощника, устанавливающего необходимые флаги. Ну а как распоряжаться полученной информацией — тут уже каждый решит сам. Предполагается наличие базовых знаний об устройстве процессора, операционной системы и умения собрать проект используя cmake, будет немножко Си и Ассемблера.

Введение

Многие ругают современное программное обеспечение за неповоротливость и тяжеловесность. Отчасти это справедливо, так как несмотря на всё растущую производительность аппаратной части происходит также лавинообразное усложнение средств разработки, и соответственно, результатов, ими порождённых. Программисты борются с всевозрастающей сложностью при помощи всяческих абстракций, ООП, функционального программирования, проекты обрастают библиотеками и фреймворками, и всё это заметно увеличивает количество операций, выполняемых центральным процессором для достижения того или иного результата (и иначе быть не может, а желающие могут всегда взять чистый ассемблер и начать с нуля). В настоящее время в основном производительность вытягивается за счёт многопоточности, но это не значит, что не нужно оптимизировать код в рамках одного ядра. Обычно мы балансируем между приемлемой производительностью и необходимой навороченностью. Вообще что касается оптимизации, то в общем нет смысла оптимизировать всё и вся, как правило в программе, которая работает медленнее чем ожидается (или требуется по ТЗ), существует всего несколько мест ("горячих точек"), требующих "проработки" и для оценки влияния изменений кода на производительность используются разные средства профилировки, как неинвазивные типа Intel VTune, или встроенных в среду разработки профилировщиков, так и инвазивные, когда код для измерения времени выполнения критичных участков встроен (возможно, опционально) прямо в код программы.

Самый наипростейший способ замера, которым обычно пользуются новички — это тривиальный GetTickCount()/GetTickCount64(). Он просто возвращает время в миллисекундах (от старта системы). Это такой встроенный в систему таймер и в принципе в нём нет ничего плохого, если вы понимаете, как он работает. По умолчанию этот таймер имеет разрешение 15,6 мс, что даёт нам всего 64 герца. Да, его разрешение можно поднять до 1 мс, что даст килогерц (и нет, комп быстрее работать не начнёт), но это предел. Подходит для неточного измерения лошадиных интервалов времени, начиная от десятков и сотен миллисекунд. Есть он, к примеру и в LabVIEW, используется вот так:

Если хочется большей точности, то используют QueryPerformanceCounter(). Разрешение этого счётчика вообще говоря зависит от железа (поэтому его обязательно надо запрашивать вызовом QueryPerformanceFrequency), но на подавляющем большинстве компьютеров оно будет 10 мегагерц. Это значит, что этот счётчик инкрементируется десять раз в микросекунду (независимо от частоты процессора), то есть у него разрешение сто наносекунд. Уже неплохо, можно измерять достаточно короткие интервалы или делать короткие задержки, если надо, и обычно этого счётчика с головой хватает для большинства бытовых применений. Опять же есть в LabVIEW, вот оно (и там действительно QueryPerformanceCounter, я проверил):

Если спуститься на уровень ниже (процессор-то наш работает на гигагерцовых частотах), и да, есть RDTSC. Мне, к счастью, не нужно влезать в детали всех этих методов (и я больше не буду мучить вас LabVIEW кодом), так как прошлогодняя статья Краткое сравнение популярных функций измерения времени затрагивает как раз эту тему, но вот на RDTSC хотелось бы остановиться поподробнее — это будет важно для понимания материала касательно RDPMC.

Короче, поехали, а в качестве подопытного кролика выступит старенький HP Z440, с вот таким вот камушком:

из программной части будет использована Windows 11 LTSC 24H2, Visual Studio 2022 с установленными WinSDK да WinDDK, ещё будет использован ассемблер (довольно любопытный), по мелочи gcc, все версии самые свежие на момент написания поста.

Итак, давайте сделаем несложный эксперимент на Си, для RDTSC есть готовый интрисик, читаем __rdtsc, спим секундочку вызовом банального Sleep из WinAPI и читаем снова, затем считаем разницу:

#include <windows.h>
#include <stdio.h>
#include <intrin.h> // for __rdtsc()

int main() {
    for(;;) {   
        unsigned __int64 start = __rdtsc();  // 1st timestamp
        Sleep(1000); // Sleep
        //for (volatile int i = 0; i < 512000000; ++i); // Buzy
        unsigned __int64 end = __rdtsc();  // 2nd timestamp
        printf("Time-Stamp Counter increments during 1 second: %lld\n", end - start);
    }
    return 0;
}

И мы получим примерно три с половиной миллиарда инкрементов — процессор, который я использую, работает в данном компьютере на базовой частоте в 3,5 гигагерца, как вы видели выше.

>sleep_1000.exe
Time-Stamp Counter increments during 1 second: 3508593469
Time-Stamp Counter increments during 1 second: 3499389189
Time-Stamp Counter increments during 1 second: 3505022599
Time-Stamp Counter increments during 1 second: 3511709394

От цикла к циклу значение будет слегка меняться (Windows не является системой жёсткого реального времени, Sleep не отрабатывает абсолютно точно), но важно понять то, что от частоты это значение не зависит. Это всегда количество инкрементов, привязанное к базовой частоте, так как это счётчик времени. Это значение зачастую путают с количеством тактов процессора. Это так только в том случае, если процессор в процессе измерения и работает на этой самой базовой частоте. Но в реальной жизни частота постоянно меняется — так отрабатывают Turbo Boost и SpeedStep. Если нагрузка возрастёт и частота поднимется турбо бустом, то это не значит, что для кода, исполняемого одну секунду на частоте 3,6 ГГц вы получите 3,6 миллиарда инкрементов. Нет, будет по прежнему 3,5, даже в этом случае:

Чтобы стало ещё понятнее, что там за магия с rdtsc, давайте перепишем этот код на ассемблере, практически один в один.

EUROASM AutoSegment=Yes, CPU=X64, SIMD=AVX2
rdtsc_sleep PROGRAM Format=PE, Width=64, Model=Flat, IconFile=, Entry=Start:

INCLUDE winscon.htm, winabi.htm, cpuext64.htm

Msg1 D "Hello, rdtsc",0
Msg2 D "RDTSC=",0
Buffer DB 80 * B
    
Start: nop ; Machine instruction tells €ASM to switch to [.text] (AutoSegment=Yes).
	StdOutput Msg1, Eol=Yes, Console=Yes

	cpuid               ; Serialize before measurement
	rdtsc
	shl rdx, 32
	or rax, rdx
	mov r15, rax

	WinABI Sleep, 1000
	
	rdtscp
	shl rdx, 32
	or rax, rdx
	sub rax, r15

	StoD Buffer
	StdOutput Msg2, Buffer, Console=Yes

	TerminateProgram

ENDPROGRAM rdtsc_sleep

Здесь используется EuroAssembler, это очень простой ассемблер, который могу смело порекомендовать. Первые две строки конфигурационные. затем включены winscon, winabi и cpuext64 для макросов StdOutput, WinABI и StoD, программа стартует с точки входа Start:, nop, это такое соглашение EuroAssembler, которое позволяет избавиться от секций при включённом AutoSegment=Yes. Команды и регистры можно писать как заглавными так и прописными буквами, разницы нет.

Просто скопируйте код выше, сохраните в файл rdtsc_sleep.asm, затем ассемблируете вот так, получаете обычный исполняемый файл:

>euroasm.exe rdtsc_sleep.asm
>rdtsc_sleep.exe
Hello, rdtsc
RDTSC=3512684668

В принципе тут всё достаточно понятно. Команда rdtsc кладёт значение счётчика в EDX:EAX, это значение собирается в 64 бита и сохраняется в R15 (этот регистр не должен меняться при вызове Sleep(), равно как и RBX, RBP, RDI, RSI, RSP, R12, R13, R14). В интернетах сломано некоторое количество копий относительно того, как правильно использовать rdtsc. Дело в том, что процессор вообще говоря не обязан исполнять команды ровно в том порядке, в каком они ему подаются, он может их переставлять, если это не влияет на результат, (out-of-order выполнение называется), соответственно часть микроинструкций может начать выполняться перед rdtsc, вот чтобы этого не произошло, вставляют cpuid, у которой есть такой побочный эффект в том, что она служит как бы барьером. Это не единственная возможность, иногда используют LFENCE/MFENCE (L и M в смысле Load/Memory), либо есть команда Serialize, но она доступна начиная с Ice Lake, а у меня Haswell. В конце замера используется rdtscp, которая по сути та же rdtsc, но гарантирует, что все предыдущие инструкции выполнятся при её вызове, она как бы комбинирует rdtsc и cpuid, но она рекомендуется обычно именно в конце, а в начале обычно используют пару rdtsc и cpuid. Эта комбинация даёт представление о времени исполнения участка кода, обычно вычленяется "бутылочное горлышко", код обкладывается rdtsc/rdtscp, как я показал выше, затем модифицируется так и сяк, уменьшающееся значение разницы штампов времени говорит о том, что мы идём правильной дорогой, но сегодня речь не об rdtsc, а об rdpmc.

RDPMC

Переходим к сути повествования. Как следует из написанного выше, rdtsc даёт лишь приблизительное представление о количестве реальных тактов, необходимых процессору для выполнения того или иного участка кода. Очевидно, что та же Sleep не занимает процессор полностью, ей нет нужды активно прокручивать 3 с половиной миллиардов циклов, чтобы задержать исполнение на одну секунду. Но в тоже время очевидно, что если мы заменим Sleep циклом с полезной нагрузкой, то на его исполнение будет необходимо затратить совершенно определённое количество тактов, и вопрос в том, как его получить? К счастью такая возможность есть, ведь помимо команды rdtsc также существует команда rdpmc. По идеологии она очень похожа на rdtsc и точно также записывает показания счётчика в EDX:EAX, но требует дополнительного параметра, который передаётся в ECX, потому что может возвращать значения разных счётчиков, их несколько. И ещё важный момент — она будет снимать показания именно с того ядра, на котором и выполняется. На хабре есть одно-единственное упоминание об этой команде в прошлогодней статье Загадка потерянного инкремента, так что лишним это знание точно не будет.

Вот типичное использование:

	mov ecx, 0x40000000 ; это параметр определяет счётчик, который нам интересен
	RDPMC
	shl rdx, 32
	or rax, rdx ; теперь в rax лежит значение счётчика

Но есть нюанс. Дело в том, что вызвать её вот так запросто вы не можете, из под администратора в том числе. Причина тут в том, что при помощи этой команды вы можете, к примеру получить значения промахов и попаданий кэша, и при должной сноровке можете вытащить оттуда какую-нибудь полезную информацию. Попытка вызвать эту команду "просто так" закончится выбрасыванием исключения и в просмотрщике событий это будет выглядеть вот так:

Код исключения 0xC0000096 означает STATUS_PRIVILEGED_INSTRUCTION

Чтобы воспользоваться этой привилегированной командой-инструкцией, нужно установить восьмой бит в четвёртом контрольном регистре процессора (да, там есть не только регистры общего назначения). Он называется CR4.PCE (Performance-Monitoring Counter Enable), а вот сделать это можно только c нулевого ринга, а это значит из режима ядра, ну а это значит, что нам нужен драйвер, который это и сделает.

Вообще в своей жизни я написал драйвер под Windows всего один раз, и было это более четверти века назад и под Windows 95 — для защиты одного дорогого ПО была разработана ISA плата, на которой стоял процессор z80, с которым и надо было общаться. В то время я положил уйму времени на данную задачу, так как изучать всё приходилось методом тыка, сопровождая вдумчивым чтением документации. К счастью времена меняются и теперь сделать это оказалось совсем несложно, а поможет нам проект Intel да ИИ (ну и собственные мозги немножко).

Но сначала нужно приготовить компьютер и рабочее окружение. Поскольку это будет самописный драйвер, то первым делом надо отключить контроль подписи (параноики могут отключить интернет до кучи). Для этого перегружаем комп, идём в Биос и отключаем там Secure Boot, если он включён. Теперь запускаем командный промпт (как администратор) и вводим там команду

bcdedit.exe -set TESTSIGNING ON

После перезагрузки в правом нижнем углу экрана будет отображаться что-то типа:

Вводить команду эту нужно единожды, и этот режим будет сохраняться при перезагрузке, но надо учесть, что эта опция будет активна лишь до момента пока вы не включите Secure Boot обратно. Как только вы его включите и перегрузитесь, она автоматически сбросится.

Теперь нужно установить WinDDK (предполагается, что Visual Studio и SDK у вас уже установлены).

Если у вас установлена Студия "из коробки", то предварительно надо (вдогонку к десктоп приложениям С++) доустановить Driver Kit в индивидуальных компонентах, вот так

И до кучи либы specre (кстати, если Windows отлична от английской, к примеру немецкая, то и в строку поиска надо вводить по-немецки, типа Treiber вместо Driver и так далее)

После этого можно ставить WinDDK, он должен установиться без предупреждений об отсутствующих компонентах, типа таких:

Теперь можно вайб-кодить драйвер. К счастью, нам не надо начинать совсем уж "с нуля". В качестве самого первого упражнения и получения "рабочей заготовки" я рекомендую клонировать либо форкнуть проект Intel Performance Counter Monitor (Intel PCM) и собрать его. Он к сожалению, поставляется в виде набора "собери сам", но особо сложного ничего нет, зато все исходники открыты. Я перед написанием поста сделал билд, используя CMake 4.1.1, как из релиза, так и из мастер-ветки. Собственно вам потребуется сгенерить всего два ингредиента — pcm.exe и драйвер. Для драйвера там в папке WinMSRDriver лежит готовый проект студии MSR.vcxproj, его надо просто открыть, и если WDK установлен корректно, то он соберётся с полпинка.

Теперь надо сложить полученный MSR.sys да pcm.exe в одну папку и запустить из-под админа (я использую Far Manager+ConEmu, так уж исторически сложилось):

Если всё собралось и запустилось без ошибок, то вначале нам выдадут полезную информацию про нашу железку (часть я выкинул, чтоб не раздувать статью):

>pcm.exe

 Intel(r) Performance Counter Monitor (2025-08-11 13:05:45 +0200 ID=3efd9120)

=====  Processor information  =====
Hybrid processor         : no
Max CPUID level          : 15
CPU family               : 6
CPU model number         : 63
Number of physical cores: 4
Number of logical cores: 8
Threads (logical cores) per physical core: 2
Num sockets: 1
Physical cores per socket: 4
Last level cache slices per socket: 4
Core PMU (perfmon) version: 3 ; <<<<<< Вот как раз PMU нам и интересно
Number of core PMU generic (programmable) counters: 4 ; <<<<<< Эти счётчики...
Width of generic (programmable) counters: 48 bits
Number of core PMU fixed counters: 3 ; <<<<<<<<< ...и вот эти
Width of fixed counters: 48 bits
Nominal core frequency: 3500000000 Hz
Package thermal spec power: 140 Watt;
Package minimum power: 37 Watt;
Package maximum power: 280 Watt;

Detected Intel(R) Xeon(R) CPU E5-1620 v3 @ 3.50GHz
"Intel(r) microarchitecture codename Haswell-EP/EN/EX"

А потом раз в секунду на экране будет появляться примерно вот такая табличка

Core (SKT) |UTIL|IPC |CFREQ|L3MISS|L2MISS|L3HIT|L2HIT|L3MPI|L2MPI|  L3OCC|TEMP

   0    0   0.13 0.80  2.47  1051 K 4784 K  0.78  0.5 0.0042 0.019   608   64
   1    0   0.27 1.04  2.27   754 K 5323 K  0.86  0.7 0.0012 0.0085 1232   64
   2    0   0.26 0.95  2.60  1902 K 7937 K  0.76  0.5 0.0030 0.0126 4576   61
   3    0   0.14 0.93  2.73   977 K 3295 K  0.70  0.5 0.0027 0.0091  784   61
   4    0   0.25 0.82  3.04  1507 K 6680 K  0.77  0.5 0.0024 0.0108 1104   63
   5    0   0.08 0.81  2.95   432 K 2111 K  0.79  0.6 0.0024 0.0116  256   63
   6    0   0.19 0.96  2.96  1204 K 5235 K  0.77  0.6 0.0022 0.0095 1328   65
   7    0   0.16 0.62  2.98   415 K 6551 K  0.94  0.7 0.0014 0.0223  224   65
-------------------------------------------------------------------------------
 SKT    0   0.18 0.88  2.72  8247 K   41 M  0.80  0.59  0.0023 0.0119 10112 56
-------------------------------------------------------------------------------
 Instructions retired: 3511 M ; Active cycles: 3989 M ; Time (TSC): 3452 Mticks;
...
MEM (GB)->|  READ |  WRITE | LOCAL | CPU energy | DIMM energy | LLCRDMISSLAT (ns)| UncFREQ (Ghz)|
-----------------------------------------------------------------------------------------
 SKT   0     1.13     0.66  100 %      25.59       0.47         195.98             1.02
-----------------------------------------------------------------------------------------

попросим ИИ перевести легенду:

UTIL : загрузка (то же самое, что активное состояние ядра C0, значение от 0 до 1)
IPC : количество инструкций на один такт процессора
CFREQ : частота ядра в ГГц
L3MISS : промахи чтения из кэша L3
L2MISS : промахи чтения из кэша L2 (включая попадания в кэш L2 других ядер)
L3HIT : коэффициент попадания в кэш L3 при чтении (от 0.00 до 1.00)
L2HIT : коэффициент попадания в кэш L2 (от 0.00 до 1.00)
L3MPI : количество промахов чтения из кэша L3 на одну инструкцию
L2MPI : количество промахов чтения из кэша L2 на одну инструкцию
READ : количество байт, прочитанных из контроллера основной памяти (в гигабайтах)
WRITE : количество байт, записанных в контроллер основной памяти (в гигабайтах)
LOCAL : доля локальных запросов к памяти от контроллера, в процентах
LLCRDMISSLAT : средняя задержка промаха последнего уровня кэша при чтении и предвыборке (в наносекундах)
L3OCC : занятость кэша L3 (в килобайтах)
TEMP : температура, выраженная в градусах Цельсия относительно максимальной температуры TjMax (термический запас): 0 соответствует максимальной температуре
energy : потреблённая энергия в джоулях

В общем всё это довольно познавательно.

Как видите, у меня процессор в простое, но если нагрузить первое ядро, до хоть через тот же CpuStres, то станет так:

 Core (SKT) | UTIL | IPC  | CFREQ | L3MISS | L2MISS | L3HIT | L2HIT | L3OCC | TEMP

   0    0     1.00   0.42    3.60     252 K   1007 K    0.75    0.48    336     47
   1    0     0.04   0.59    3.55     177 K    890 K    0.80    0.64    960     47
   2    0     0.23   0.80    2.44    1141 K   6718 K    0.83    0.60   2048     58
   3    0     0.13   0.95    2.50     606 K   2450 K    0.75    0.64   2256     58
   4    0     0.22   0.93    2.43    1005 K   6153 K    0.84    0.62   2048     57
 ...

Оно в общем понятно и предсказуемо. Частота первого ядра поднялась до 3,6 (это турбо буст отрабатывает, так то у процессора паспортная базовая частота 3,5), у второго тоже, но чуть меньше (это второе ядро гипертрединга), температурка у обоих чуть возросла (инструмент показывает судя по всему запас до отсечки, в максимуме, вероятно, тут будет 0). Для промахов/попаданий в кэши надо соорудить оснастку, и ниже мы это сделаем.

Тут может возникнуть справедливый вопрос — нафига козе баян, если intel pcm и так выкатывает нам кучу полезной информации, включая даже количество инструкций на такт процессора? А дело в том, как он её получает, для этого надо заглянуть в исходники оригинального драйвера, вот сюда:

case IO_CTL_MSR_READ:
                if (inputSize < sizeof(struct MSR_Request)) {
                    status = STATUS_INVALID_PARAMETER;
                    break;
                }
                RtlSecureZeroMemory(&new_affinity, sizeof(GROUP_AFFINITY));
                RtlSecureZeroMemory(&old_affinity, sizeof(GROUP_AFFINITY));
                KeGetProcessorNumberFromIndex(input_msr_req->core_id, &ProcNumber);
                new_affinity.Group = ProcNumber.Group;
                new_affinity.Mask = 1ULL << (ProcNumber.Number);
                KeSetSystemGroupAffinityThread(&new_affinity, &old_affinity);
                __try {
                    *output = __readmsr(input_msr_req->msr_address); // ВОТ ЭТО МЕСТО!
                }
                __except (EXCEPTION_EXECUTE_HANDLER) {
                    status = GetExceptionCode();
                    DbgPrint("Error: 0x%X in IO_CTL_MSR_READ core 0x%X msr 0x%llX\n",
                        status, input_msr_req->core_id, input_msr_req->msr_address);
                }
                KeRevertToUserGroupAffinityThread(&old_affinity);
                Irp->IoStatus.Information = sizeof(ULONG64);    // result size
                break;

Видите, здесь используется __readmsr(). Это интрисик к команде RDMSR для чтения регистра, специфичного для модели процессора. Перед вызовом надо положить в регистр ECX значение регистра, которое мы хотим прочитать. Эта команда доступна исключительно с нулевого ринга, то есть в приложении её использовать нельзя. Поэтому приложение pcm.exe каждый раз отправляет запрос в драйвер для чтения того или иного счётчика, оно вообще не использует команду RDPMC. Отсюда недостаток этого метода — есть довольно большой оверхед в случае, если мы хотим получить это значение в нашей программе. А вот используя команду RDPMC, мы можем получить значения счётчиков прямо в нашем коде, даже привилегии админа не потребуются. Но тут, правда тоже есть ограничения — мы можем лишь получить три базовых счётчика и четыре пользовательских, об этом выше в логе было написано:

Number of core PMU fixed counters: 3
Width of fixed counters: 48 bits
Number of core PMU generic (programmable) counters: 4
Width of generic (programmable) counters: 48 bits

Четыре программируемых счётчика означают, что мы можем "смаппить" четыре регистра из всего доступного набора (ну допустим, промахи и попадания в кэши второго и третьего уровней), это делается через конфигурационные регистры. Счётчики, кстати, 48-битные, они будут периодически переполняться.

Ну вот, теперь поупражнявшись с pmc.exe, можно заставить поработать RDPMC. Давайте для начала получим значения трёх базовых счётчиков, учебник учит, что там надо взвести соответствующий бит в регистре cr4, вот и отправим это запрос ИИ, я буду использовать Microsoft Copilot c GPT-5.

Берём исходник драйвера из набора интел, копируем его себе в папочку (я переименовал проект в RDPMC, чтоб не путаться), и заменяем содержимое msrmain.c на вот такое, что нам нагенерил ИИ с двух попыток:

#include <ntddk.h>

VOID DriverUnload(PDRIVER_OBJECT DriverObject) {
    UNREFERENCED_PARAMETER(DriverObject);
    DbgPrint("Driver unloaded\n");
}

VOID SetCR4PCE()
{
    ULONG_PTR cr4 = __readcr4();
    DbgPrint("PCE CR4 before setting PCE: %p\n", (PVOID)cr4);
    cr4 |= (1 << 8); // Set bit 8 (PCE)
    __writecr4(cr4);
    cr4 = __readcr4();
    DbgPrint("PCE CR4 after setting PCE: %p\n", (PVOID)cr4);
}

VOID SetCR4PCEOnAllCores()
{
    for (ULONG i = 0; i < KeQueryActiveProcessorCount(NULL); ++i) {
        GROUP_AFFINITY affinity = { 0 };
        affinity.Mask = (KAFFINITY)1 << i;
        affinity.Group = 0;

        GROUP_AFFINITY oldAffinity;
        KeSetSystemGroupAffinityThread(&affinity, &oldAffinity);

        SetCR4PCE();
        DbgPrint("PCE CR4 set for cpu %d\n", i);
        KeRevertToUserGroupAffinityThread(&oldAffinity);
    }
}

NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) {
    UNREFERENCED_PARAMETER(RegistryPath);

    DriverObject->DriverUnload = DriverUnload;

    DbgPrint("Driver loaded\n");
    SetCR4PCEOnAllCores();
    DbgPrint("CR4 PCE has been set\n");

    return STATUS_SUCCESS;
}

Вообще говоря я не сторонник метода "скопируй код отсюда", но в данном случае всё достаточно просто. Для записи в CR4 есть готовая функция __writecr4(cr4); нужный нам бит: cr4 |= (1 << 8). Чтоб быть уверенным, мы читаем его до и после установки. Отладочные сообщения можно получить в Debug View (запустить из под админа и включить получение сообщений из ядра). Две попытки понадобились для того, чтобы сказать о том, что это нужно делать для каждого логического ядра. Это важный момент, поскольку счётчики RDPMC привязаны к ядрам и взводить этот бит нужно также для каждого ядра индивидуально. Если бы писать всё вручную, то времени пришлось бы положить куда как больше. Компилируем как обычно, получаем новый msr.sys, RDPMC.sys, но уже с нашей начинкой.

Теперь нам нужен второй ингредиент — это загрузчик этого драйвера. Тут просто создаём пустое консольное приложение, просим ИИ набросать код для загрузки драйвера и вставляем полученное туда

#include <windows.h>
#include <iostream>

bool LoadKernelDriver(const std::wstring& driverName, const std::wstring& driverPath) {
    SC_HANDLE schSCManager = OpenSCManager(nullptr, nullptr, SC_MANAGER_ALL_ACCESS);
    if (!schSCManager) {
        std::wcerr << L"OpenSCManager failed: " << GetLastError() << std::endl;
        return false;
    }

    SC_HANDLE schService = CreateService(
        schSCManager,
        driverName.c_str(), driverName.c_str(),
        SERVICE_ALL_ACCESS, SERVICE_KERNEL_DRIVER,
        SERVICE_DEMAND_START, SERVICE_ERROR_IGNORE,
        driverPath.c_str(),
        nullptr, nullptr, nullptr, nullptr, nullptr);

    if (!schService) {
        DWORD err = GetLastError();
        if (err == ERROR_SERVICE_EXISTS) {
            // Service exists, try to open it
            schService = OpenService(schSCManager, driverName.c_str(), SERVICE_ALL_ACCESS);
            if (!schService) {
                std::wcerr << L"OpenService failed: " << GetLastError() << std::endl;
                CloseServiceHandle(schSCManager);
                return false;
            }
        }
        else {
            std::wcerr << L"CreateService failed: " << err << std::endl;
            CloseServiceHandle(schSCManager);
            return false;
        }
    }

    if (!StartService(schService, 0, nullptr)) {
        DWORD err = GetLastError();
        if (err != ERROR_SERVICE_ALREADY_RUNNING) {
            std::wcerr << L"StartService failed: " << err << std::endl; 
				//4 - ERROR_ACCESS_DENIED
            return false;
        }
    }

    std::wcout << L"Driver started successfully." << std::endl;

    CloseServiceHandle(schService);
    CloseServiceHandle(schSCManager);
    return true;
}

int main() {
    std::wstring driverName = L"MSR"; // Kernel driver service name
    std::wstring driverFile = L"RDPMC.sys"; // The .sys file

    std::wcout << L"Driver will be loaded" << std::endl;
    if (!LoadKernelDriver(driverName, driverFile)) {
        std::wcerr << L"Failed to load driver " << driverFile << std::endl;
        return 1;
    }
    std::wcout << L"Driver loaded" << std::endl; // Driver is now loaded and running.
    return 0;
}

В этом не особо сложном коде я вообще практически ничего не менял кроме пары отладочных сообщений. OutputDebugStringA() выводит отладочные сообщения туда же, куда и драйвер.

Билдим приложение, кладём всё в одну папку со свежеиспечённым драйвером и запускаем с правами администратора, ровно также как и pcm.exe. Всё.

Да, если перед этим вы упражнялись с pcm.exe, то вам пожет потребоваться выгрузить оригиналный интеловский драйвер, и, возможно, перезагрузиться:

pcm.exe --uninstallDriver

Если вы всё сделали правильно, то драйвер загрузится, необходимый бит установится и можно пользоваться RDPMC из любого приложения (и права админа не нужны).

C:\Users\Andrey\Desktop\rdpmc\rdpmc_helper\x64\Release>rdpmc_loader.exe
Driver will be loaded
Driver started successfully.
Driver loaded

Запускать драйвер для каждого использования RDPMC опять же не нужно — бит этот останется установлен до тех пор, пока вы не перезагрузите компьютер, то есть это такой "кик-стартер". Вообще впечатляет, конечно, я был уверен, что несколько раз положу комп в BSOD, но нет, всё реально заработало практически с первого раза (но спасибо Интелу за заготовку драйвера, конечно).

Ну давайте разбираться с новой игрушкой. По умолчанию после установки бита мы можем использовать три встроенных счётчика: 0x40000000, 0x40000001 и 0x40000002. Взведённый бит 30 в параметре говорит о том, что это фиксированные счётчики (у конфигурируемых он сброшен)

Читаем так:

EUROASM AutoSegment=Yes, CPU=X64, SIMD=AVX2
rdpmc0 PROGRAM Format=PE, Width=64, Model=Flat, IconFile=, Entry=Start:

INCLUDE winscon.htm, winabi.htm, cpuext64.htm

Msg0 D "Counter = ",0
Buffer DB 80 * B
    
Start: nop
	mov ecx, 0x40000000
	RDPMC
	shl rdx, 32
	or rax, rdx

	StoD Buffer ; макрос берёт число из rax пишет ASCII в буфер
	StdOutput Msg0, Buffer, Eol=Yes, Console=Yes ; а тут печатаем буфер

	TerminateProgram

ENDPROGRAM rdpmc0

Запустим несколько раз и вот оно:

C:\Users\Andrey\Desktop\driver3\euroasm>rdpmc0.exe
Counter = 171632675924
C:\Users\Andrey\Desktop\driver3\euroasm>rdpmc0.exe
Counter = 411587380742
C:\Users\Andrey\Desktop\driver3\euroasm>rdpmc0.exe
Counter = 140621891101

Значения разные, то больше то меньше, но это не оттого, что счётчик переполняется (чтоб переполнить 48 бит с 3,5 ГГц уйдут почти сутки), а оттого, что читаем мы его с разных ядер, так как Windows запускает код на том ядре, которое хочется ему, а не нам, но мы это поправим.

Три счётчика, доступные нам теперь имеют следующее назначение:

Фиксированный счётчик

Значение ECX для RDPMC

Описание

Счётчик 0

0x40000000

Количество завершённых инструкций (Instructions Retired)

Счётчик 1

0x40000001

Такты ядра в активном состоянии (Unhalted Core Cycles)

Счётчик 2

0x40000002

Эталонные такты в активном состоянии (Unhalted Reference Cycles)

Разберёмся с первым. Это количество инструкций процессора. Первый же тест, который хочется сделать, это просто запустить RDPMC два раза подряд и посмотреть на разницу. Важный момент - сразу после запуска я "прибью" процесс к первому ядру через SetProcessAffinityMask(), для того, чтобы получать счётчик с одного и того же ядра. Там по идее надо получать Process ID, но это псевдохэндл, он всегда -1, так что я просто ленив (не делайте так в продакшене). По старой памяти я вставлю барьер cpuid чтобы притормозить лошадок между забегами:

EUROASM AutoSegment=Yes, CPU=X64, SIMD=AVX2
rdpmc1 PROGRAM Format=PE, Width=64, Model=Flat, IconFile=, Entry=Start:

INCLUDE winscon.htm, winabi.htm, cpuext64.htm

Msg0 D "Instructions = ",0
Buffer DB 80 * B
   
Start: nop
	WinABI SetProcessAffinityMask, -1, 1 ; important!

	mov ecx, 0x40000000
	RDPMC
	shl rdx, 32
	or rax, rdx
	mov r10, rax
	cpuid
	mov ecx, 0x40000000
	RDPMC
	shl rdx, 32
	or rax, rdx
	sub rax, r10 ; subtract previous
	StoD Buffer
	StdOutput Msg0, Buffer, Eol=Yes, Console=Yes

	TerminateProgram

ENDPROGRAM rdpmc1

И результат:

C:\Users\Andrey\Desktop\driver3\euroasm>rdpmc1.exe
Instructions = 6

Шесть завершённых инструкций. И ведь их действительно шесть

	RDPMC ; первая
	shl rdx, 32 ; вторая
	or rax, rdx ; третья
	mov r10, rax ; четвёртая
	cpuid ; пятая
	mov ecx, 0x40000000 ; шестая
	RDPMC ; приехали

Я запустил код снова. Снова 6. И ещё. И опять.

А давайте обложим cpuid парой команд nop:

	RDPMC
	shl rdx, 32
	or rax, rdx
	mov r10, rax
	nop
	cpuid
	nop
	mov ecx, 0x40000000
	RDPMC

Стало восемь. Стабильно восемь. Я не поверил своим глазам, а ну-ка, ну-ка, давайте вычислим факториал 20 из соседней статьи, опубликованной в прошлом месяце "От математики к машине: преобразуем функцию в машинный код", я возьму оригинальный код с двумя переходами:

	mov r11, 20 ; 20
	mov r12, 1  ; Result
    
    mov ecx, 0x40000000
	RDPMC
	shl rdx, 32
	or rax, rdx ; rax содержит значение счётчка
	mov r10, rax	

.loop:
	cmp r11, 1
	jle .done
	imul r12, r11
	dec r11
	jmp .loop
.done:
	mov ecx, 0x40000000
	RDPMC

Получаем 102 команды. Считаем: цикл наш вертится 19 полных раз, в нём пять команд, это 19*5 = 95 команд. Плюс две команды на последней итерации, где мы вываливаемся из цикла - cmp и jle, это 97. Четыре команды перед циклом Это 101. И последний mov после цикла. 102. Как в аптеке. И от запуска к запуску 102. Чем Xeon не ардуинка? На самом деле такая стабильность будет сохраняться где-то до миллиона итераций, дальше Windows начнёт вклиниваться в ядро и счётчик будет честно считать "чужие" такты, чудес, конечно, не бывает. Но впечатляет, теперь мы примерно знаем количество одиночных команд, обработанных ядром между замерами.

Теперь посмотрим на два оставшихся счётчика. Это количество тактов.

Чтоб совсем уж не делать простыню кода копипастингом RDPMC, я заведу пару макросов для старта и останова бенчмарка, ну а поскольку там между делом мы будем печатать результат на экране, то я попрошу покрутить цикл миллиард раз, чтобы команды процессора, необходимые для печати в консоль (их там довольно много) были не так заметны (конечно их можно вынести вне интервала).

EUROASM AutoSegment=Yes, CPU=X64, SIMD=AVX2
fact5 PROGRAM Format=PE, Width=64, Model=Flat, IconFile=, Entry=Start:

INCLUDE winscon.htm, winabi.htm, cpuext64.htm

Msg0 D "Instructions = ",0
Msg1 D "Core Cycles  = ",0
Msg2 D "Reference Cycles  = ",0

Buffer DB 80 * B
N DQ 1_000_000_000 ; Amount of Iterations

StartBench %MACRO Counter, Destination
	mov ecx, %Counter
	RDPMC
	shl rdx, 32
	or rax, rdx
	mov %Destination, rax	
%ENDMACRO StartBench

EndBench %MACRO Counter, Source, Message
	mov ecx, %Counter
	RDPMC
	shl rdx, 32
	or rax, rdx
	sub rax, %Source ; subtract previous
	Clear Buffer, 80
	StoD Buffer
	StdOutput %Message, Buffer, Eol=Yes, Console=Yes
%ENDMACRO EndBench
   
Start: nop
	WinABI SetProcessAffinityMask, -1, 1 ; important!

	mov r11, [N]
	mov r12, 1

	StartBench 0x40000002, r10
	StartBench 0x40000001, r9
	StartBench 0x40000000, r8
.loop:
	cmp r11, 1
	jle .done
	imul r12, r11
	dec r11
	jmp .loop
.done:

	EndBench 0x40000000, r8, Msg0
	EndBench 0x40000001, r9, Msg1
	EndBench 0x40000002, r10, Msg2
     
	TerminateProgram

ENDPROGRAM fact5

Результат:

>fact2.exe
Instructions = 5005544702
Core Cycles  = 3060812355
Reference Cycles  = 2998793865

На миллиард итераций использовано чуть больше пяти миллиардов инструкций (так и есть, у нас ведь пять инструкций в цикле, что мы крутим милллиард раз) и чуть больше трёх миллиардов тактов ядра при почти ровно трёх миллиардах референсных. Почему тактов больше чем референсных? А потому что при выполнении этого кода проц работает на повышенной частоте и вот эта пропорция это примерно насколько частота поднимается выше базовой.

Собственно этот момент и послужил отправной точкой для написания данного поста. Я прогнал миллиард итераций этого цикла, замерив производительность "по старинке", используя RDTSC, и с изумлением обнаружил, что на некоторых прогонах количество тиков меньше трёх миллиардов (каюсь, грешен, я просто забыл, что RDTSC это не про такты, а про время). Но так физически быть не может, поскольку латентность (задержка) imul на архитектуре Haswell ровно три такта, и я это знаю. То есть физически меньше трёх миллиардов тактов затрачено быть ну никак не может, как бы оно там на конвейерах не параллелилось, а разворачивать циклы (делать unroll) процессор вроде пока не умеет. И вот теперь всё стало на свои места — тактов действительно затрачивается три миллиарда с небольшим, но процессор поднимает частоту, а RDTSC привязан к базовой частоте, оттого количество тиков в реальном времени несколько меньше. Таймер бежит на 3,5 ГГц, а проц молотит на 3,6, только и всего.

Давайте сделаем ещё один эксперимент. Там в комментах было замечено, что несмотря на пять команд цикл может быть быстрым, так как предсказателю переходов может больше понравиться.

Смотрите, вот этот оригинальный цикл вычисления факториала с пятью командами

	mov r11, 1_000_000_000
	mov r12, 1
.loop:
	cmp r11, 1
	jle .done
	imul r12, r11
	dec r11
	jmp .loop
.done:

я могу переписать двумя способами, вот так будет четыре команды в цикле, если я перенесу сравнение вниз:

.loop:
	imul r12, r11
	dec r11      
	cmp r11, 1  
	jg .loop

А если использовать тот факт, что декремент устанавливает флаг при достижении нуля, то и сравнение не нужно и можно обойтись вообще тремя командами, вот так:

.loop:
	imul r12, r11 
	dec r11
	jnz .loop

Вот результаты и тут мы видим, что в общем без разницы, количество тактов будет одинаково:

C:\Users\Andrey\Desktop\rdpmc\asm>fact5.exe
Instructions = 5009211823 ; тут пять команд на итерацию
Core Cycles  = 3039928631
Reference Cycles  = 2960370315

C:\Users\Andrey\Desktop\rdpmc\asm>fact4.exe
Instructions = 4008970668 ; четыре команды
Core Cycles  = 3028541628
Reference Cycles  = 2949011800

C:\Users\Andrey\Desktop\rdpmc\asm>fact3.exe
Instructions = 3007590953 ; а тут всего три
Core Cycles  = 3023822204 ; и примерно один такт на команду
Reference Cycles  = 2943796625

Тут мы приходим к довольно важной метрике, которая называется "Количество операций на такт". Это результат деления количества исполненных команд на количество затраченных на это тактов (или количество тактов на операцию, если делить в обратную сторону). Она и в таблице, что выдаёт интеловский pmc.exe тоже есть, это IPC (Instructions Per Cycle).

Даже если nop вставить после умножения, то количество тактов не изменится (до определённого количества этих самых nop, само собой):

.loop:
	cmp r11, 1
	jle .done
	nop
	imul r12, r11
	nop
	dec r11
	nop
	jmp .loop
.done:

Всё то же, но уже восемь команд в цикле:

C:\Users\Andrey\Desktop\rdpmc\asm>fact5nops3.exe
Instructions = 8009301300 ; восемь команд на цикл
Core Cycles  = 3028409256 ; по-прежнему три милларда циклов
Reference Cycles  = 2948700650

Вам придётся реально постараться, чтобы замедлить выполнение nopами:

.loop:
	cmp r11, 1
	jle .done
	nop
	nop
	nop
	imul r12, r11
	nop
	nop
	nop
	dec r11
	nop
	nop
	nop
	jmp .loop
.done:

Вот только с этого места возникает замедление:

C:\Users\Andrey\Desktop\rdpmc\asm>fact5nops9.exe
Instructions = 14011043949 ; уже 14 команд на цикл
Core Cycles  = 3539279257  ; и три с половиной миллиарда тактов
Reference Cycles  = 3445163820

Здесь мы передаём привет статье-переводу Как замедлить программу и почему это может быть полезно?

Что касается отношения затраченных циклов к референсным, то оно, кстати, работает в обе стороны, как в сторону увеличения частоты, так и в сторону уменьшения. Вот что произойдёт, есля я дропну частоту до 1,2 ГГц? Я могу это сделать, используя ThrottleStop, и выставив множитель 12 (меньше этот проц не умеет), попутно отключив Турбо и компьютер сильно загрустит:

и мы получим следующее:

C:\Users\Andrey\Desktop\driver3\euroasm>fact2.exe
Instructions = 5036919002
Core Cycles  = 3089564662
Reference Cycles  = 8853717705

Поскольку процессор сильно замедлился, почти втрое, а референсная частота, это по паспорту 3,5 ГГц, то потребовалось почти девять миллиардов трёхгигагерцовых тактов для трёх миллиардов реальных одногигагерцовых. Всё честно.

Но надо понимать, что количество референсных циклов возвращаемое rdpmc это вовсе не количество тиков rdtsc. Да, оно примерно равно ему если ядро процессора, на которым мы проводим измерения, загружено полностью и не простаивает. А вот если ядро приостанавливается для энергосбережения, то референсные такты на данном ядре также перестают считаться, в отличие от тиков таймера rdtsc, который никогда не спит и всегда молотит на базовой частоте. Это очень просто показать, я вызову Sleep (1000) вкупе с RDPMC:

	StartBench 0x40000002, r10
	StartBench 0x40000001, r9
	StartBench 0x40000000, r8

	WinABI Sleep, 1000

	EndBench 0x40000000, r8, Msg0
	EndBench 0x40000001, r9, Msg1
	EndBench 0x40000002, r10, Msg2

И вот:

C:\Users\Andrey\Desktop\rdpmc\asm>rdpmc_sleep.exe
Instructions = 84263906
Core Cycles  = 157150665
Reference Cycles  = 183542030

Здесь и близко три миллиарда референсных циклов не набежало. Кроме того, на 180 миллионов референсных циклов приходится 157 миллионов реальных, а это значит, что процессор работал на частоте, меньшей, чем базовая.

Сравните с тем, что было в самом начале статьи - rdtsc вернёт три с половиной миллиарда инкрементов. И не забывайте, что rdtsc это один счётчик для всех ядер, а rdpmc — индивидуальный для каждого ядра.

Кроме того, этот нехитрый код открывает широкие возможности для экспериментов, ну вот, к примеру, если в цикле вычисления факториала, заменить умножение imul rbx, rax на сложение add rbx, rax (нам интересен принцип, а не математика), то количество тактов упадёт втрое (поскольку сложение требует одного такта), а вот если заказать две команды сложения подряд, то количество тактов возрастёт, но на сколько, это будет зависеть от регистров — одинаковые ли они или разные. Ну или к примеру при интенсивном использовании AVX-512 вероятно можно опосредованно оценить насколько сильно троттлится процессор по увеличению количества референсных циклов.

Ладно, едем дальше, у нас есть ещё четыре настраиваемых счётчика.

Программируемые счётчики

В принципе доступ к ним осуществляется точно также, только значения, (то, что нужно положить в ecx перед вызовом rdpmc) другие, там старший бит сброшен:

    mov ecx, 0 ; тут может быть 0, 1, 2 или 3.
	RDPMC
	shl rdx, 32
	or rax, rdx ; rax содержит значение счётчка

Но прежде чем их использовать, их нужно не только размаскировать, но и сконфигурировать. Делается это точно также, на нулевом кольце, из того же драйвера, и для этого заводится вот такая структура, в которой заветные цифирки говорят, какие именно счётчики нам нужны, допустим промахи и попадания по кешам второго и крайнего уровней:

static const EVENT_UMASK g_GpEvents[4] = {
    { 0x24, 0x41 },  // GP0: L2 demand data read hit
    { 0x24, 0x21 },  // GP1: L2 demand data read miss
    { 0x2E, 0x4F },  // GP2: L3 cache reference (includes hits)
    { 0x2E, 0x41 },  // GP3: L3 cache miss
};

И в коде драйвера используется вот так через __writemsr (что означает - пиши в регистр, специфический для модели):

    // 2) Program GP selectors (IA32_PERFEVTSEL0..3) and clear GP counters (IA32_PMC0..3)
    for (int i = 0; i < 4; ++i) {
        ULONG msrSel = IA32_PERFEVTSEL0 + (ULONG)i;
        ULONG msrPmc = IA32_PMC0 + (ULONG)i;
        ULONGLONG selVal = BuildPerfEvtSel(g_GpEvents[i].evt, g_GpEvents[i].um,
            countUser, countKernel, FALSE /*anyThread*/);
        __writemsr(msrSel, selVal);    // program event
        __writemsr(msrPmc, 0);         // clear counter (48-bit width typical)
    }
Вот полный код msrmain.c под спойлером

#include <ntddk.h>
#include <intrin.h>


VOID DriverUnload(PDRIVER_OBJECT DriverObject) {
    UNREFERENCED_PARAMETER(DriverObject);
    DbgPrint("Driver unloaded\n");
}

VOID SetPCEFlag() {
    ULONG_PTR cr4;

    cr4 = __readcr4();               // Read CR4
    DbgPrint("CR4 before setting PCE: %p\n", (PVOID)cr4);

    cr4 |= 0x100;                   // Set bit 8 (PCE)
    __writecr4(cr4);                // Write back to CR4
    
    cr4 = __readcr4();
    DbgPrint("CR4 after setting PCE: %p\n", (PVOID)cr4);
}

VOID SetCR4PCE()
{
    ULONG_PTR cr4 = __readcr4();
    DbgPrint("PCE CR4 before setting PCE: %p\n", (PVOID)cr4);
    cr4 |= (1 << 8); // Set bit 8 (PCE)
    __writecr4(cr4);
    cr4 = __readcr4();
    DbgPrint("PCE CR4 after setting PCE: %p\n", (PVOID)cr4);
}

VOID SetCR4PCEOnAllCores()
{
    //KAFFINITY activeProcessors = KeQueryActiveProcessors();
    for (ULONG i = 0; i < KeQueryActiveProcessorCount(NULL); ++i)
    {
        GROUP_AFFINITY affinity = { 0 };
        affinity.Mask = (KAFFINITY)1 << i;
        affinity.Group = 0;

        GROUP_AFFINITY oldAffinity;
        KeSetSystemGroupAffinityThread(&affinity, &oldAffinity);

        SetCR4PCE();
        DbgPrint("PCE CR4 set for cpu %d\n", i);
        KeRevertToUserGroupAffinityThread(&oldAffinity);
    }
}


// second part
// pmu_enable_fixed_gp.c
// Windows kernel (Ring 0, C) code to enable:
//   - 3 fixed counters: INST_RETIRED.ANY, CPU_CLK_UNHALTED.CORE, CPU_CLK_UNHALTED.REF_TSC
//   - 4 general-purpose counters: L1D load hit/miss, L2 demand-data hit/miss
//
// Build with WDK; include in your driver project.
// Call PmuEnableFixedAndGpAllCPUs(...) from DriverEntry or an IOCTL.
// Call PmuDisableAllCPUs() to stop counters.
//
// References for MSRs, fields, and events:
// - Intel PMU programming (PerfEvtSel*, PerfGlobalCtrl, FixedCtrCtrl)  [Intel PMU guide / SDM]
// - RDPMC ECX encoding & CR4.PCE                                      [Intel SDM RDPMC chapter]
// - Events: MEM_LOAD_UOPS_RETIRED.* (0xD1), L2_RQSTS.* (0x24)          [Skylake/Comet Lake event lists]

#include <ntddk.h>
#include <intrin.h>

// ---------- MSR addresses ----------
#define IA32_PERFEVTSEL0        0x186u    // +n for sel1..sel3
#define IA32_PMC0               0x0C1u    // +n for pmc1..pmc3

#define IA32_FIXED_CTR_CTRL     0x38Du
#define IA32_PERF_GLOBAL_CTRL   0x38Fu

#define IA32_FIXED_CTR0         0x309u    // INST_RETIRED.ANY
#define IA32_FIXED_CTR1         0x30Au    // CPU_CLK_UNHALTED.CORE
#define IA32_FIXED_CTR2         0x30Bu    // CPU_CLK_UNHALTED.REF_TSC

// ---------- IA32_PERFEVTSELx bit fields ----------
#define EVSEL_USR               (1ull << 16)  // count in user mode (CPL>0)
#define EVSEL_OS                (1ull << 17)  // count in kernel mode (CPL==0)
#define EVSEL_ANYTHREAD         (1ull << 21)  // count both SMT threads (usually 0)
#define EVSEL_EN                (1ull << 22)  // enable

// ---------- IA32_FIXED_CTR_CTRL (4 bits per fixed counter) ----------
#define FIXED_EN_OS             (1ull << 0)
#define FIXED_EN_USR            (1ull << 1)
#define FIXED_ANY               (1ull << 2)   // usually 0
#define FIXED_PMI               (1ull << 3)   // PMI on overflow (0 unless you wire PMIs)

// ---------- CR4.PCE (bit 8) permits user-mode RDPMC when set ----------
#define CR4_PCE                 (1ull << 8)

// ---------- Events to program into GP counters (Skylake/Comet Lake client) ----------
// GP0: L1D load hit   -> MEM_LOAD_UOPS_RETIRED.L1_HIT   : Event 0xD1, Umask 0x01
// GP1: L1D load miss  -> MEM_LOAD_UOPS_RETIRED.L1_MISS  : Event 0xD1, Umask 0x08
// GP2: L2 DD hit      -> L2_RQSTS.DEMAND_DATA_RD_HIT    : Event 0x24, Umask 0x41
// GP3: L2 DD miss     -> L2_RQSTS.DEMAND_DATA_RD_MISS   : Event 0x24, Umask 0x21
typedef struct _EVENT_UMASK {
    UCHAR evt;
    UCHAR um;
} EVENT_UMASK;

static const EVENT_UMASK g_GpEventsL1[2] = {
    { 0xD1, 0x01 },  // GP0: L1D load hit
    { 0xD1, 0x08 },  // GP1: L1D load miss
};

static const EVENT_UMASK g_GpEvents[4] = {
    { 0x24, 0x41 },  // GP0: L2 demand data read hit
    { 0x24, 0x21 },  // GP1: L2 demand data read miss
    { 0xD1, 0x20 },  // GP2: L3 load hit (MEM_LOAD_RETIRED.L3_HIT)
    { 0xD1, 0x40 },  // GP3: L3 load miss (MEM_LOAD_RETIRED.L3_MISS)
};

static __forceinline ULONGLONG
BuildPerfEvtSel(UCHAR evt, UCHAR umask, BOOLEAN enUsr, BOOLEAN enOs, BOOLEAN anyThread)
{
    ULONGLONG v = 0;
    v |= (ULONGLONG)evt;
    v |= ((ULONGLONG)umask) << 8;
    if (enUsr)     v |= EVSEL_USR;
    if (enOs)      v |= EVSEL_OS;
    if (anyThread) v |= EVSEL_ANYTHREAD;   // keep FALSE unless you *need* both SMT threads
    v |= EVSEL_EN;
    return v;
}

static __forceinline ULONGLONG
BuildFixedCtrl(BOOLEAN enUsr, BOOLEAN enOs, BOOLEAN anyThread, BOOLEAN pmi)
{
    ULONGLONG per = 0;
    if (enOs)      per |= FIXED_EN_OS;
    if (enUsr)     per |= FIXED_EN_USR;
    if (anyThread) per |= FIXED_ANY;
    if (pmi)       per |= FIXED_PMI;
    // ctr0 in bits 3:0, ctr1 in 7:4, ctr2 in 11:8
    return (per << 0) | (per << 4) | (per << 8);
}

static __forceinline VOID
SetUserRdpmcOnThisCPU(BOOLEAN allow)
{
    ULONGLONG cr4 = __readcr4();
    if (allow) cr4 |= CR4_PCE;
    else       cr4 &= ~CR4_PCE;
    __writecr4(cr4);
    // RDPMC ECX: bit30=1 selects fixed counters; =0 selects general-purpose counters.
}

static VOID
ProgramPMU_OnThisCPU(BOOLEAN countUser, BOOLEAN countKernel, BOOLEAN allowUserRdpmc)
{
    // 0) Stop everything on this CPU
    __writemsr(IA32_PERF_GLOBAL_CTRL, 0);

    // 1) Fixed counters: enable OS+USR as requested; no AnyThread/PMI by default
    __writemsr(IA32_FIXED_CTR_CTRL, BuildFixedCtrl(countUser, countKernel, FALSE, FALSE));

    // 2) Program GP selectors (IA32_PERFEVTSEL0..3) and clear GP counters (IA32_PMC0..3)
    for (int i = 0; i < 4; ++i) {
        ULONG msrSel = IA32_PERFEVTSEL0 + (ULONG)i;
        ULONG msrPmc = IA32_PMC0 + (ULONG)i;
        ULONGLONG selVal = BuildPerfEvtSel(g_GpEvents[i].evt, g_GpEvents[i].um,
            countUser, countKernel, FALSE /*anyThread*/);
        __writemsr(msrSel, selVal);    // program event
        __writemsr(msrPmc, 0);         // clear counter (48-bit width typical)
    }

    // 3) Clear fixed counters to start from zero
    __writemsr(IA32_FIXED_CTR0, 0);
    __writemsr(IA32_FIXED_CTR1, 0);
    __writemsr(IA32_FIXED_CTR2, 0);

    // 4) Optionally allow user-mode RDPMC (CR4.PCE)
    SetUserRdpmcOnThisCPU(allowUserRdpmc);

    // 5) Globally enable: GP0..3 (bits 0..3), Fixed0..2 (bits 32..34)
    {
        ULONGLONG enableGp = (1ull << 0) | (1ull << 1) | (1ull << 2) | (1ull << 3);
        ULONGLONG enableFx = (1ull << 32) | (1ull << 33) | (1ull << 34);
        __writemsr(IA32_PERF_GLOBAL_CTRL, enableGp | enableFx);
    }
}

static VOID
DisablePMU_OnThisCPU(VOID)
{
    __writemsr(IA32_PERF_GLOBAL_CTRL, 0);
}

// Execute cb() once on each logical CPU (processor-group aware).
static VOID
ForEachLogicalCPU(VOID(*cb)(VOID))
{
    ULONG total = KeQueryActiveProcessorCountEx(ALL_PROCESSOR_GROUPS);
    for (ULONG idx = 0; idx < total; ++idx)
    {
        PROCESSOR_NUMBER pn;
        RtlZeroMemory(&pn, sizeof(pn));
        KeGetProcessorNumberFromIndex(idx, &pn); // maps idx -> (Group, Number)

        GROUP_AFFINITY gaNew, gaOld;
        RtlZeroMemory(&gaNew, sizeof(gaNew));
        RtlZeroMemory(&gaOld, sizeof(gaOld));
        gaNew.Group = pn.Group;
        gaNew.Mask = (KAFFINITY)(1ull << pn.Number);

        KeSetSystemGroupAffinityThread(&gaNew, &gaOld);
        cb();  // run on that CPU
        KeRevertToUserGroupAffinityThread(&gaOld);
    }
}

// ---------- Public APIs (C) you call from your driver ----------

// Enable fixed + 4 GP counters on all logical CPUs.
// - countUser:   count when CPL>0
// - countKernel: count when CPL==0
// - allowUserRdpmc: set CR4.PCE so user-mode can execute RDPMC
typedef struct _PMU_CFG_CTX {
    BOOLEAN countUser;
    BOOLEAN countKernel;
    BOOLEAN allowUserRdpmc;
} PMU_CFG_CTX;

static PMU_CFG_CTX gPmuCtx;

static VOID PmuPerCpuEnableCallback(VOID)
{
    ProgramPMU_OnThisCPU(gPmuCtx.countUser, gPmuCtx.countKernel, gPmuCtx.allowUserRdpmc);
}

VOID
PmuEnableFixedAndGpAllCPUs(BOOLEAN countUser, BOOLEAN countKernel, BOOLEAN allowUserRdpmc)
{
    PAGED_CODE();
    gPmuCtx.countUser = countUser;
    gPmuCtx.countKernel = countKernel;
    gPmuCtx.allowUserRdpmc = allowUserRdpmc;
    ForEachLogicalCPU(PmuPerCpuEnableCallback);
}

// Stop all fixed + GP counters on all CPUs
VOID
PmuDisableAllCPUs(VOID)
{
    PAGED_CODE();
    ForEachLogicalCPU(DisablePMU_OnThisCPU);
}


NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) {
    UNREFERENCED_PARAMETER(RegistryPath);

    DriverObject->DriverUnload = DriverUnload;

    DbgPrint("Driver loaded\n");

    //SetPCEFlag();
    //SetCR4PCEOnAllCores();
    PmuEnableFixedAndGpAllCPUs(TRUE, TRUE, TRUE);
    DbgPrint("CR4 PCE has been set\n");

    return STATUS_SUCCESS;
}

Счётчиков (или точнее ивентов) там много всяких разных, к примеру если нужно получить данные по кэшу первого уровня, то будет

    { 0xD1, 0x01 },  // GP0: L1D load hit
    { 0xD1, 0x08 },  // GP1: L1D load miss

И так далее. Они все документированы, и Интел предоставляет всю эту документацию. Можно читать температуру ядер, потребляемую мощность, получить инфу для оценки количества успешных предсказаний по переходам, этакий швейцарский нож для пытливого инженера. Троттлинг, о котором я писал выше, можно, вероятно, получить и отсюда, напрямую.

Я не смог отказать себе в удовольствии спровоцировать попадания и промахи по кэшу, это в общем несложно делается, достаточно взять массив приличных размеров и читать его подряд байт за байтом, либо проходить по всему, но со всё большим шагом - через байт, каждый четвёртый и так далее. Кстати, когда мы читаем один байт, всего один, то в реальности происходит выборка всей кэш линии, что составляет 64 байта. Кроме того, когда мы бежим по массиву байт за байтом, то при первом обращении к свежеаллоцированному, но неинициализированному массиву мы получаем Page Fault каждые четыре килобайта, ибо память организована странично. Кто сомневается — могут полистать Танненбаума и Руссиновича.

У меня когда-то давно был набросок кода, демонстрирующий размер кешей анализом задержек (и на хабре была статья, да вот что-то не найду), но в современных реалиях проще просто попросить тот же ИИ:

#include <windows.h>
#include <stdio.h>
#include <stdint.h>
#include <time.h>

#define ARRAY_SIZE (1024 * 1024 * 1024) // 1GB
#define MAX_STRIDE 1024

static inline uint64_t rdpmc(uint32_t counter)
{
	uint32_t lo, hi;
	__asm__ volatile ("rdpmc" : "=a"(lo), "=d"(hi) : "c"(counter));
	return ((uint64_t)hi << 32) | lo;
}

int main() {
	uint8_t *array = (uint8_t *)malloc(ARRAY_SIZE);
	if (!array) {
		fprintf(stderr, "Could not allocate memory\n");
		return 1;
	}
	SetProcessAffinityMask(GetCurrentProcess(), 1);
	// Initialize array to avoid page faults and lazy allocation effects
	for (size_t i = 0; i < ARRAY_SIZE; i += 4096) array[i] = (uint8_t)(i & 0xFF);

	// Iterate over a set of stride values, doubling each time
	printf("Stride\t\t\tCache\n(bytes)\tGB/seconds\tHits\t\tMiss\n");

	for (size_t stride = 1; stride <= MAX_STRIDE; stride *= 2) {
		clock_t start = clock();
		volatile uint8_t tmp;
		size_t accesses = ARRAY_SIZE / stride;
       
		uint64_t gp0_before = rdpmc(0); // GP0
		uint64_t gp1_before = rdpmc(1); // GP1
		for (size_t rep = 0; rep < stride; ++rep)
			for (size_t i = 0; i < accesses; ++i) tmp = array[i * stride];
		uint64_t gp0_after = rdpmc(0); // GP0
		uint64_t gp1_after = rdpmc(1); // GP1

        double seconds = (double)(clock() - start) / CLOCKS_PER_SEC;
        printf("%llu \t%f \t%llu \t%llu\n", (unsigned long long)stride, 1.0/seconds,
        	(unsigned long long)(gp0_after - gp0_before),
        	(unsigned long long)(gp1_after - gp1_before));
    }
    free(array);
    return 0;
}

Поскольку я люблю оставаться в рамках 64-х бит, а в Visual Studio инлайн ассемблер для этого режима не завезли, то я просто скомпилирую это дело через gcc:

gcc -m64 -O2 cache_effect.c -o cache_effect.exe

Самый наипростейший способ обзавестись gcc под Windows, это, вероятно, поставить MSYS2, и не забудьте прописать путь к gcc (по умолчанию C:\msys64\ucrt64\bin) в переменную Path. Если без gcc, то либо надо компилировать в 32-бит, либо соорудить DLL да хоть на том же EuroAssembler, и получать счётчик оттуда (с небольшим оверхедом от вызова, естественно), либо воспользоваться интрисиком __readpmc(). В принципе можно и на Расте наваять, но, по-моему, там пробросить параметр в ассемблер так просто не выйдет, придётся для разных счётчиков раздельные функции с константами задействовать.

Как бы то ни было, запускаем, всё красиво:

C:\Users\Andrey\Desktop\rdpmc\gcc>cache_effect.exe
Stride                  Cache
(bytes) GB/seconds      Hits            Miss
1       2.444988        1071995748      95341
2       2.409639        1069177532      108403
4       2.114165        814586125       7847740
8       1.545595        241870663       68680054
16      0.942507        37952942        190515068
32      0.473934        16501622        526443161
64      0.227687        15709122        1048385757
128     0.156128        24963238        1050090042
256     0.125219        21161211        1051304702
512     0.128172        11769418        1066671686
1024    0.124533        11301575        1064477616

С увеличением шага скорость доступа к памяти замедляется, причём ступенчато, попадания в кэш падают, промахи растут, всё по феншую.

Когда это бывает нужно в реальной жизни? Ну вот, к примеру, мы имеем большую картинку, которая не лезет в кэш, выполняем свёртку с каким-нибудь ядром, и стратегии выборки пикселей вполне могут влиять на эффективность работы с кэшем, а следовательно и на общую скорость. Там есть ещё много интересных вещей, скажем такое понятие как когерентность кэша, но это уже не входит в данную статью, тут всё достаточно базово. Экспериментировать с кэшем и памятью можно бесконечно, главное вовремя остановиться.

Код к статье выложен на гитхаб. Бинарники тоже собраны в релизе, если кому надо.

Надеюсь, кому-нибудь было интересно и полезно. Пишите прям в комментах об ошибках и неточностях, я с удовольствием поправлю. Знайте и любите свои процессоры, всем добра и быстрого кода!

Комментарии (2)


  1. bonsai
    05.09.2025 19:28

    Раз тут сильно контекстно упомянули витюн, то призываю @vvvphoenix и @saul

    Про Интел и считание клоков больше них на хабре, кажется, мало кто расскажет.


    1. AndreyDmitriev Автор
      05.09.2025 19:28

      Да, спасибо, было бы здорово, если бы коллеги ознакомились, у меня есть некоторые небольшие сомнения насчёт терминологии, наверняка будет что обсудить и поправить в смысле технической корректности.