Когда речь заходит о гиперпоточности, то как правило всё начинается с того, что нам показывают красивые картинки с квадратиками типа такой:

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

Я впервые столкнулся с этой технологией лет двадцать назад на двухъядерном Xeon процессоре, и включив её, обрёл кучу неприятностей — драйвер платы захвата изображения спонтанно начал выносить систему в BSOD. Спонтанно — это значит, что всё могло работать два-три дня, а могло и рухнуть два-три раза на дню. Я её выключил, и забыл на многие месяцы. Потом я сделал ещё пару "подходов к снаряду" в попытке "методом научного тыка" написать многопоточную программу, которая была бы явно быстрее с НТ, но нет, явных преимуществ не увидел, все замеры показывали примерно одинаковый результат на грани статистической погрешности (но использовалась LabVIEW, она несколько специфична). Сейчас эта опция у меня всегда включена, ибо восемь ядер в общем лучше чем четыре (я тут мог бы рассказать скабрезный анекдот про плюс два или минус два, но не буду) а отключается лишь по необходимости.
Впрочем одно должно быть очевидно — удвоенное количество ядер не даст в общем случае удвоенной производительности, но мне всегда хотелось набросать код, который бы явно демонстрировал особенности этой фишки, и, пока я писал коммент к упомянутой статье, я попробовал, и всё оказалось заметно проще, чем ожидалось (если знать, где копать).
Проблема продемонстрировать влияние гиперпоточности на синтетические бенчмарки (а особенно на те, что набросаны вайб-кодингом) заключается в том, что машинные инструкции теста оценки производительности могут оказаться как "подходящими", так и "неподходящими" для демонстрации, а нам нужен этакий "дистилированный" тест, полностью контролируемый нами, вплоть до отдельных команд процессора, а значит — расчехляем Ассемблер (в теории можно и на Си, возможно с вставками инлайн ассемблера, но мы не ищем лёгких путей, да и там всё равно придётся листинг асма проверять).
Тестовое окружение
Как и в прошлой статье мы будем упражняться вот на этом железе:

Для затравки я сделаю тупой замер производительности первым попавшимся в руки бенчмарком так и сяк, и разницы нет от слова "вообще":

Но если мы навскидку не видим разницы, то это ещё не значит, что её нет, давайте попробуем нагрузить ядра нашим собственным тестом, в котором мы просто будем умножать пару чисел в регистрах и ничего больше.
Я не буду далеко ходить, вот код - затравка на Евро Ассемблере с комментариями ИИ, я их слегка "причесал":
EUROASM AutoSegment=Yes, CPU=X64, SIMD=AVX2
multest PROGRAM Format=PE, Width=64, Model=Flat, IconFile=, Entry=Start:
INCLUDE winscon.htm, winabi.htm, cpuext64.htm
Buffer DB 32 * B ; это буфер чтобы конвертировать число из регистра в ASCII строку
Start: nop ; Точка входа. nop — пустая инструкция (используется для автосегментации)
mov r8, 1_200_000_000 ; Загружаем лярд с гаком в регистр r8 — столько будем крутить
RDTSC ; Чтение Time Stamp Counter (счётчика тактов процессора) в rdx:rax
shl rdx, 32 ; двинули rdx влево на 32 бита
or rax, rdx ; это операция ИЛИ с rax, где и лежит TSC
mov r9, rax ; Сохраняем начальное значение TSC в r9 для последующего сравнения
.loop:
imul r10, r10 ; Умножаем r10 самоё на себя — основная "нагрузка" цикла
dec r8 ; Уменьшаем счётчик итераций на 1 (там изначально миллиард двести)
jnz .loop ; Если r8 ≠ 0, переходим к началу цикла
RDTSCP ; Читаем Time Stamp Counter ещё раз (с барьером упорядочивания команд)
shl rdx, 32 ; это вы уже знаете
or rax, rdx ; Опять собираем 64-битное значение TSC
sub rax, r9 ; Вычисляем разницу между конечным и начальным временем
StoD Buffer ; Переводим значение rax в десятичную строку и сохраняем в Buffer
StdOutput Buffer, Eol=Yes, Console=Yes ; Выводим буфер в консоль,
jmp Start: ; Бесконечный цикл — программа повторяет измерение снова
ENDPROGRAM multest ; Конец программы
То есть весь наш цикл, это просто умножение регистра самого на себя, квинтэссенция бенчмарка:
mov r8, 1_200_000_000
.loop:
imul r10, r10
dec r8
jnz .loop
Значение, которые там в регистре r10, равно как и то, что онo нигде потом не используется, нас совершенно не волнует, это не Си, который выкинет "ненужный" код, тут попросили проц умножить — он умножит. StoD и StdOutput — это макросы (понятно, что для перевода значения регистра в строку и вывода в консоль надо выполнить довольно много нудных операций). Именно для этих макросов вначале включены winscon, winabi и cpuext64.
Почему я взял именно столько итераций — 1,2 миллиарда? Это потому, что подопытный наш процессор с паспортной частотой 3,5 ГГц будет крутиться на 3,6 ГГц (это турбо буст), а цикл этот требует трёх тактов процессора (и вовсе не оттого, что там три команды в цикле, а оттого что задержка imul три такта), соответственно процессор накрутит как раз 3,6 миллиарда циклов, стало быть это будет занимать ровно одну секунду, а таймер RDTSC возвращает значение временнóй метки процессора на базовой частоте, которая суть 3,5 ГГц, так что я должен буду увидеть три с половиной миллиарда с небольшим инкрементов и мне будет легко прикинуть время — вижу 350.. — это значит секунда и мы бежим в цикле без задержек. По идее при старте бенчмарка желательно вызвать cpuid, но тут больше миллиарда циклов, и если первый и начнёт выполняться перед RDTSC, или там предыдущие команды пролезут под него, то я этого просто не замечу.
Компилируем и запускаем, так и есть, набираем 3,5 миллиарда с небольшим инкрементов:
>EuroAsm.exe multest1.asm
>multest1.exe
3505805101
3504562242
3504544750
3506495981
3504488179
3505398041
3506179951
3504361215
Если посмотреть загрузку процессора, то увидим примерно следующее (извините за немецкий скриншот, но тут всё понятно без слов):

Практически все эти пики — это наше приложение (для этого теста я убедился, что в простое нагрузка от активности Windows не превышает нескольких процентов).
Почему заняты все ядра, а не одно? А потому что так работает Windows. Наша программа (точнее поток, что выполняется), перебрасывается от ядра к ядру (примерно каждые 10-15 миллисекунд). Это легко продемонстрировать, поскольку инструкция RDTSCP в конце бенчмарка помимо барьера упорядочивания команд также пишет номер ядра, на котором она исполнилась (IA32_TSC_AUX) в ECX.
Это значение (индекс ядра) тоже легко вывести в консоль, опять же пригодится для самоконтроля в дальнейшем:
...
Msg0 D ">",0
Buf0 DB 4 * B
Buf1 DB 32 * B
...
RDTSCP
shl rdx, 32
or rax, rdx
sub rax, r9
StoD Buf1
mov rax, rcx
StoD Buf0
StdOutput Buf0, Msg0, Buf1, Eol=Yes, Console=Yes
Да кто бы сомневался, ядро меняется на лету в процессе работы - 5, 1, 3, 3, 2, 6, используются как чётные, так и нечётные ядра, кстати:
>multest1.exe
5>3505983940
1>3504954770
3>3504997054
3>3505153196
2>3505061802
6>3506563185 ...
Для того, чтобы запустить программу на определённом ядре, надо задать affinity, самой простое соорудить командный файл типа такого, либо добавить в меню F2 Far Manager, если вы им пользуетесь. (откуда берутся маски 0x01, 0x04, 0x10, 0x40 объяснять не буду, мы ж всё-таки на хабре):
start "" /affinity 0x01 "multest1.exe"
start "" /affinity 0x04 "multest1.exe"
start "" /affinity 0x10 "multest1.exe"
start "" /affinity 0x40 "multest1.exe"
запускаем четыре потока, и в каждом по три с половиной миллиарда инкрементов:

То есть мы плотно сидим на четырёх физических ядрах, 55 процентов занято, каждый микробенчмарк честно отрабатывает свою секунду. Небольшая просадка есть, ведь ОС тоже должна где-то жить, но в общем и целом всё ровно.
Нетерпеливый читатель, конечно, воскликнет: "ну давай уже, запусти восемь копий на восьми ядрах", и мы это, разумеется, сделаем (принимаются ставки на результат забега), но нет, не всё сразу, давайте сделаем ещё один простой эксперимент: добавим ещё одно умножение другого регистра (другого, потому что зависимость по приёмнику тут внесёт коррективы) в наш цикл:
.loop:
imul r10, r10
imul r11, r11
dec r8
jnz .loop
Это нам нужно, поскольку у команд процессора есть два важных параметра - Latency (задержка) и Throughput (пропускная способность). Сколько же времени будет выполняться цикл теперь?
На самом деле те, кто подсмотрел в справочнике Latency и Throughput этой инструкции, не удивятся, тому, что цикл по-прежнему будет выполняться одну секунду, как и с одним, вот я его без affiniti запущу, пусть Windows жонглирует тредом от ядра к ядру, дадим гипетредингу фору:
>multest2.exe
3>3504262254
1>3504642284
3>3504270689
6>3504938132
5>3504372957
2>3504528373
1>3504337204
А ну-ка, три умножения:
.loop:
imul r10, r10
imul r11, r11
imul r12, r12
dec r8
jnz .loop
И снова та же скорость:
>multest3.exe
3>3505637001
0>3506060212
2>3506114325
3>3504772562
5>3506323017
2>3505235671
7>3505771778
Кто-нибудь из читающих может подумать, что ядро резиновое или что-то тут не так, но нет, просто imul хотя и имеет задержку в три такта, но процессор умеет выполнять их три параллельно (и, кстати, туда же отправляются dec и jnz команды до кучи, вообще это, кажется, называется макро-фьюжн, когда команды комбинируются), а вот четвёртый imul таки да, замедлит программу:
.loop:
imul r10, r10
imul r11, r11
imul r12, r12
imul r13, r13
dec r8
jnz .loop
Теперь будет 4,6 миллиарда, это значит что код исполняется примерно на треть секунды больше 4,6/3,5 = 1,3(142857) секунды (красивое, кстати, число, там 142857 будет бесконечно повторяться), пруф:
>multest4.exe
2>4673869757
3>4673805777
1>4677235204
3>4676222892
7>4676995126
2>4674254098
2>4674209913
6>4672962356
На самом деле, именно это значение тиков получается оттого, что теперь итерации цикла нужно не три такта, а четыре, так что будет израсходовано 1,2 млрд * 4 = 4,8 млрд тактов, но это на частоте 3,6, а тиков времени будет 4,8 * (3,5/3,6) = 4,66(6), что мы и наблюдаем.
Всё это, кстати, честно документировано, можно сходить на сайт uops.info, и посмотреть там, я вас не обманываю, вот одиночное умножение:

А вот четыре подряд:

И, кстати, это для архитектуры Haswell, это очень важно, поскольку на другой архитектуре всё может быть совсем по-другому, например одиночный imul может занимать два такта, а не три, прогресс на месте не стоит.
В сухом остатке у нас есть некоторое количество команд и соответствующее ему число тактов, необходимых для выполнения этих операций, и есть такая важная метрика как "количество команд на такт", и её можно получить через Intel Performance Monitor, что я упоминал в предыдущей статье. Давайте запустим его и посмотрим что происходит для одного умножения в цикле (который мы запустим на первом ядре):

Да, я тут обнаружил, что pcm.exe можно запускать с ключом --color, так что скриншоты будут как новогодняя ёлка. Здесь мы видим в колонке UTIL, что ядро загружено полностью, вышло на 3,6 ГГц, и у нас 1.00 IPC (instructions Per Cycle), так и есть (три команды на три такта), при этом обратите внимание, что на остальных ядрах, которые почти в простое у нас меньше единицы, 0.29 означает, что там процессор лениво проворачивает примерно три цикла на одну команду. У нас же проворачивается три цикла на итерацию, но в итерации три команды - imul, dec и jnz, отсюда единица.
Теперь запустим вместо одиночного умножения код с двумя умножениями:

Стало 1,33. Почему 1,33? А потому что теперь четыре команды, но всё ещё три цикла на итерацию, то есть 4/3.
Теперь перед запуском трёх умножений, вы, вероятно уже сможете ответить, сколько инструкций на цикл там будет. Инструкций - пять (три умножения и не забываем про инкремент и переход), а тактов на итерацию по-прежнему три — стало быть 5/3, ну так оно и есть — 1,66, теория и практика согласуются:

Ну а для четырёх умножений мы получим полтора, так как тактов процессора нужно уже четыре, а команд - инструкций стало шесть. Скриншотом утомлять не буду, так как нам не терпится запустить наши бенчмарки с одним умножением все вместе:
start "" /affinity 0x01 "multest1.exe"
start "" /affinity 0x02 "multest1.exe"
start "" /affinity 0x04 "multest1.exe"
start "" /affinity 0x08 "multest1.exe"
start "" /affinity 0x10 "multest1.exe"
start "" /affinity 0x20 "multest1.exe"
start "" /affinity 0x40 "multest1.exe"
start "" /affinity 0x80 "multest1.exe"
Итак, восемь потоков с циклом, в котором одно умножение, все ядра нагружены на сто процентов, на каждом ядре практически одна инструкция на такт, частота по всем стабильно 3,6 ГГц, и вот, получите:

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

Я думаю, уже стало понятно, в чём там дело. Красивые разноцветные квадратики из Википедии стали совсем "осязаемыми". При одной команде умножения в цикле, конвейеры заняты только на треть, соответственно второй поток подгоняет команды, которые и задействуют простаивающий конвейер. Очевидно, что если бы у нас был "тригипертрединг" с тремя логическими ядрами, то получился бы "двенадцатиядерник", ведь мы ещё на выжали из процессора "все соки". Также очевидно, что мы можем запустить на одном ядре цикл, в котором одно умножение,а на втором — два, и всё по-прежнему будет хорошо:

Здесь на первом ядре у нас одна инструкция на цикл, а на втором 1,33 (потому что там два умножения), и каждому циклу норм, хотя они и крутятся на двух логических ядрах одного физического.
А вот если мы запустим на первом ядре также цикл с двумя умножениями, то всё, приехали, мы исчерпаем ёмкость конвейера, и процессор загрустит:

Кстати, 4,6-4,7 миллиарда инкрементов говорит нам о том, что в каждом цикле теперь требуется четыре такта, а поскольку там два умножения, стало быть команд четыре, отсюда IPC по каждому - единица, всё сходится. То есть случай этот равносилен нашему однопоточному тесту выше с четырьмя командами — производительность практически та же (даже, пожалуй, чуть хуже).
Таким образом, запуская на четырёх физических ядрах код с одним, двумя или тремя умножениями, мы всегда получим 50% в менеджере задач, но это несколько некорректные проценты, поскольку при одном умножении у нас ещё есть резерв, аж в две трети, так что реально используется 33% ресурса процессора; при двух умножениях будет 67%, а при трёх мы выберем всю ёмкость конвейеров четырьмя потоками, и реальная загрузка процессора будет под сотню, попытка запуска ещё четырёх потоков на гипетредированных ядрах вообще не даст ничего, она просто просадит производительность уже работающих циклов — процессор действительно не резиновый. В этом и есть собственно посыл данной статьи. Но как же оценить оставшийся ресурс?
В комментариях резонно задали вопрос, а что если оценивать потребляемую мощность? Давайте проверим, запускаем на каждом физическом ядре цикл с однократным умножением:

И там в интеловском мониторе есть потребляемая мощность, сейчас это 48 джоулей:

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

Представляется, что более правильной метрикой для коррекции загрузки процессора может служить не мощность, а именно количество инструкций на такт — IPC. Вот в данном случае она возросла до полутора в среднем (четыре ядра отрабатывают по 1,66):

И это не то чтобы предел, но близко к нему, поскольку все конвейеры плотненько заняты, и на оставшихся четырёх ядрах запустить хоть что-то серьёзное не получится, хотя официальная загрузка по-прежнему 55%:

Вот только ещё раз — это совсем не те 55%, которые были тогда, когда на процессорах крутился цикл с одним умножением. Тогда нам действительно позволили запустить ещё четыре таких же приложения и получить удвоенную производительность, а в данном случае это, конечно же невозможно без просадки производительности уже запущенных приложений.
В реальной жизни у нас, конечно куча самых разных команд и заранее предсказать то, как оно будет параллелиться, просто невозможно, ведь потоки бегут в общем случае асинхронно.
Да вот, к примеру, я заменю умножение сложением:
.loop:
add r11, r11
dec r8
jnz .loop
Сложение требует одного такта и цикл бежит куда как быстрее (чуть меньше 1,2 млрд тиков как и должно быть):
>addtest1.exe
6>1189476055
3>1180739525
3>1188747639
3>1183781603
2>1185612768
5>1189684228 ...
Но будет ошибочно полагать, что он "растворится" в умножении, хотя там в цикле с одним умножением есть резерв, нет, при запуске на соседнем ядре цикла с умножением, сложение тут же вдвое просядет:

На скриншоте выше слева работает код add r11, r11 , а справа imul r10, r10, момент запуска которого я пометил слева жёлтеньким.
SHA256
Дотошный читатель тут заметит, что, мол, всё это очень познавательно, но в реальности всё сложнее, и реальная программа — это не только умножение двух регистров, а много всяких других команд. Что же, давайте теперь таки заставим посчитать что-нибудь полезное и более-менее реальное. Некоторое время назад тут была статья "Генерация SHA-256 посредством SIMD (SSE-2) инструкций, в MMX и XMM регистрах..." — это реализация SHA256 на чистом ассемблере. И хотя код этот звёзд с неба не хватает (библиотечная реализация OpenSSL обгоняет его, а если взять процессор с нативной поддержкой SHA256, то вообще без шансов), но изюминка именно этого подхода в том, что там всё сделано чисто на регистрах, без использования памяти, а значит всё будет бежать ровно и детерминированно (насколько это может быть под Windows), скажем большое спасибо Илье @KILYAV.
Я перебросил этот код в Евро Ассемблер, а бенчмарк сделал примитивнейшим образом.
Мы будем считать SHA256 дайджест от мегабайтной строки, заполненной символами "a". Вначале сделаем "контрольный выстрел", чтобы убедиться, что дайджест считается технически корректно:
array DB 1M * B
digest DB 65 * B
Start: nop
Clear array, 1M, Filler="a"
; TEST call Expected Digest 9bc1....b360
mov rcx, array
mov rdx, 1M
mov r8, digest
call fnHex
StdOutput digest, Eol=Yes, Console=Yes
Проверяем любой независимой реализацией, да хоть этой:

Наш код, вполне годно:
>sha256bench.exe
9BC1B2A288B26AF7257A36277AE3816A7D4F16E89C1E7E77D0A5C48BAD62B360
А затем мы встаём в цикл, где будем вызывать этот код раз за разом, пока RDTSC не наберёт 3500000000 инкрементов — это и будет ровнёхонько одна секунда. Ну а сколько раз мы успеем прокрутить наш цикл, это и будет количество мегабайт в секунду — такой подход избавит нас от необходимости операций с плавающей точкой.
begin:
mov r14, 3500000000 ; 1 second at 3,5 GHz CPU
xor r13, r13 ; MiB/s Counter
continue:
inc r13
RDTSC
shl rdx, 32
or rax, rdx
mov r15, rax
mov rcx, array
mov rdx, 1M ; 1 MB
mov r8, digest
call fnBin
RDTSCP
shl rdx, 32
or rax, rdx
sub rax, r15 ; rax - ticks diff, rcx - core#
sub r14, rax
cmp r14, 0
jge continue:
Clear Buf1, 32
mov rax, r13 ; MiB counter
StoD Buf1
mov rax, rcx
StoD Buf0
StdOutput Buf0, Msg0, Buf1, Msg1, Eol=Yes, Console=Yes
jmp begin:
И вот собственно полный код, как есть:
sha256bench.asm
EUROASM AutoSegment=Yes, CPU=X64, SIMD=AVX2, MMX=Enabled
EUROASM NoWarn=2101 ; W2101 Some Symbols was defined but never used.
sha256bench PROGRAM Format=PE, Width=64, Model=Flat, IconFile=, Entry=Start:
INCLUDE winscon.htm, winabi.htm, cpuext64.htm, memory64.htm, wins.htm
EXPORT fnBin
EXPORT fnHex
.const
align 16
SHA: dd 0428a2f98h, 071374491h, 0b5c0fbcfh, 0e9b5dba5h
dd 03956c25bh, 059f111f1h, 0923f82a4h, 0ab1c5ed5h
dd 0d807aa98h, 012835b01h, 0243185beh, 0550c7dc3h
dd 072be5d74h, 080deb1feh, 09bdc06a7h, 0c19bf174h
dd 0e49b69c1h, 0efbe4786h, 00fc19dc6h, 0240ca1cch
dd 02de92c6fh, 04a7484aah, 05cb0a9dch, 076f988dah
dd 0983e5152h, 0a831c66dh, 0b00327c8h, 0bf597fc7h
dd 0c6e00bf3h, 0d5a79147h, 006ca6351h, 014292967h
dd 027b70a85h, 02e1b2138h, 04d2c6dfch, 053380d13h
dd 0650a7354h, 0766a0abbh, 081c2c92eh, 092722c85h
dd 0a2bfe8a1h, 0a81a664bh, 0c24b8b70h, 0c76c51a3h
dd 0d192e819h, 0d6990624h, 0f40e3585h, 0106aa070h
dd 019a4c116h, 01e376c08h, 02748774ch, 034b0bcb5h
dd 0391c0cb3h, 04ed8aa4ah, 05b9cca4fh, 0682e6ff3h
dd 0748f82eeh, 078a5636fh, 084c87814h, 08cc70208h
dd 090befffah, 0a4506cebh, 0bef9a3f7h, 0c67178f2h
.code
Msg0 D ">",0
Msg1 D " MiB/s",0
Buf0 DB 4 * B
Buf1 DB 32 * B
array DB 1M * B
digest DB 65 * B
Start: nop
Clear array, 1M, Filler="a"
; TEST call Expected Digest 9bc1....b360
mov rcx, array
mov rdx, 1M
mov r8, digest
call fnHex
StdOutput digest, Eol=Yes, Console=Yes
begin:
mov r14, 3500000000 ; 1 second at 3,5 GHz CPU
xor r13, r13 ; MiB/s Counter
continue:
inc r13
RDTSC
shl rdx, 32
or rax, rdx
mov r15, rax
mov rcx, array
mov rdx, 1M ; 1 MB
mov r8, digest
call fnBin
RDTSCP
shl rdx, 32
or rax, rdx
sub rax, r15 ; rax - ticks diff, rcx - core#
sub r14, rax
cmp r14, 0
jge continue:
Clear Buf1, 32
mov rax, r13 ; MiB counter
StoD Buf1
mov rax, rcx
StoD Buf0
StdOutput Buf0, Msg0, Buf1, Msg1, Eol=Yes, Console=Yes
jmp begin:
; ----------------------------------------------------------------------------
; fnBin - Buffer to Digest
; x64 calling convention - rcx rdx r8 r9
; void fnBin(uint8_t *input, int32_t n, uint8_t *digest);
; rcx - source buffer
; rdx -> source count
; r8 -> digest buffer
; ----------------------------------------------------------------------------
;
align 16
fnBin PROC
push r12
lea r9, [SHA]
mov r11, rcx ; r11 - saved ptr to input
mov r10, r8 ; r10 - saved ptr to digest
lea r12, [rdx * 8] ; Load Effective Address trick
mov rax, 0bb67ae856a09e667h
movq mm0, rax ; must be movq instead of movd!
mov rax, 03c6ef372a54ff53ah ; 0a54ff53a3c6ef372h
movq mm1, rax
mov rax, 09b05688c510e527fh
movq mm2, rax
mov rax, 01f83d9ab5be0cd19h ; 05be0cd191f83d9abh
movq mm3, rax
Block:
movq [r8 + 00h], mm0 ; must be movq instead of movd!
movq [r8 + 08h], mm1
movq [r8 + 10h], mm2
movq [r8 + 18h], mm3
call Load
mov eax, 18h
L0@fnBin:
call HeadTail
dec eax
jnz L0@fnBin
mov eax, 08h
L1@fnBin:
movdq2q mm7, xmm0
paddd mm7, [r9]
paddd mm7, mm3
shufps xmm0, xmm1, 01001110b
shufps xmm1, xmm2, 01001110b
shufps xmm2, xmm3, 01001110b
psrldq xmm3, 8
call Tail
dec eax
jnz L1@fnBin
paddd mm0, [r10 + 00h]
paddd mm1, [r10 + 08h]
paddd mm2, [r10 + 10h]
paddd mm3, [r10 + 18h]
sub r9, 100h ; fix 1
cmp rdx, -8
jge Block
movq2dq xmm1, mm0
movq2dq xmm2, mm1
call Store
movdqa xmm0, xmm1
movq2dq xmm1, mm2
movq2dq xmm2, mm3
call Store
movdqu [r10 + 00h], xmm0
movdqu [r10 + 10h], xmm1
mov rax, r10
pop r12
ret ; usually void, but rax is needed in BinToHex
ENDPROC fnBin
; ----------------------------------------------------------------------------
; fnHex - same as above, but as ASCII String
; ----------------------------------------------------------------------------
fnHex PROC
call fnBin
mov rdx, 303007070909h
movq xmm5, rdx
punpcklbw xmm5, xmm5
call HexDuoLine
movdqu [rax + 30h], xmm2
movdqa xmm2, xmm1
call HexLine
movdqu [rax + 20h], xmm2
movdqa xmm1, xmm0
call HexDuoLine
movdqu [rax + 10h], xmm2
movdqa xmm2,xmm1
call HexLine
movdqu [rax + 00h], xmm2
ret
ENDPROC fnHex
; ----------------------------------------------------------------------------
; Head + Tail at the end
;
align 16
HeadTail PROC
; s0
pshufd xmm4, xmm0, 10100101b
movdqa xmm5, xmm4
psrld xmm5, 3
psrlq xmm4, 7
pxor xmm5, xmm4
psrlq xmm4, 11
pxor xmm5, xmm4
; s0 + w[0]
pshufd xmm5, xmm5, 10001000b
paddd xmm5, xmm0
; s0 + w[0] + w[9]
pshufd xmm4, xmm2, 10011001b
paddd xmm5, xmm4
; w[i] + k[i] + h
movdq2q mm7, xmm0
paddd mm7, [r9]
paddd mm7, mm3
shufps xmm0, xmm1, 01001110b
shufps xmm1, xmm2, 01001110b
shufps xmm2, xmm3, 01001110b
shufps xmm3, xmm5, 01001110b
; s1
pshufd xmm4, xmm3, 01010000b
movdqa xmm5, xmm4
psrld xmm5, 10
psrlq xmm4, 17
pxor xmm5, xmm4
psrlq xmm4, 2
pxor xmm5, xmm4
pshufd xmm5, xmm5, 10001000b
; s1 + s0 + w[0] + w[9]
pslldq xmm5, 8
paddd xmm3, xmm5
jmp @Tail ;continue to Tail from here!
ret
ENDPROC HeadTail
; ----------------------------------------------------------------------------
; Tail (no return above)
;
align 16
Tail PROC
@Tail:
clc
; s1
L0@Tail:
align 16
pshufw mm4, mm2, 01000100b
psrlq mm4, 6
pshufw mm5, mm4, 11100100b
psrlq mm4, 5
pxor mm5, mm4
psrlq mm4, 14
pxor mm4, mm5
; ch
punpckhdq mm3, mm2
pshufw mm5, mm2, 11101110b
pand mm5, mm2
pshufw mm6, mm2, 01000100b
pandn mm6, mm3
pxor mm5, mm6
; t1
paddd mm5, mm7
psrlq mm7, 20h
paddd mm4, mm5
; d + t1
psllq mm4, 20h
punpckldq mm2,mm1
paddd mm2, mm4
pshufw mm2, mm2, 01001110b
; s0
pshufw mm5, mm0, 01000100b
psrlq mm5, 2
pshufw mm6, mm5, 11100100b
psrlq mm5, 11
pxor mm6, mm5
psrlq mm5, 9
pxor mm5, mm6
; t1 + s0
punpckhdq mm1, mm0
punpckldq mm0, mm5
paddd mm0, mm4
; maj
align 16
pshufw mm4, mm0, 01000100b
pand mm4, mm1
pshufw mm5, mm4, 11101110b
pshufw mm6, mm1, 01001110b ; 5-> 6 in next tree comands
pxor mm4, mm5
pand mm6, mm1
pxor mm4, mm6 ; maj
; t1 + t2
psllq mm4, 20h
paddd mm0, mm4
pshufw mm0, mm0,01001110b
cmc
jc L0@Tail
add r9, 08h
ret
ENDPROC Tail
Load PROC
cmp rdx, 0
jle LoadPlugData
mov eax, 40h
cmp rdx, 10h
jge LoadDataLine
ret_LoadDataLine:
movd xmm5, eax ; should be movd, not movq!
mov rax, [r11]
bt edx, 3
cmovc rax, [r11 + 8]
mov r10, 80h
ror rax, cl
shld r10, rax, cl
xor rax, rax
bt edx, 3
cmovc rax, r10
cmovc r10, [r11]
bswap rax
bswap r10
movq xmm3, r10 ; movq instead of movd!
movq xmm4, rax
shufps xmm3, xmm4,00010001b
movd eax, xmm5 ; should be movd, not movq!
sub rdx, 10h
sub eax, 10h
cmp eax, 0
jg LoadZeroLine
ret_LoadZeroLine:
pshufd xmm4, xmm3, 10111011b
movq rax, xmm4 ; movq instead of movd!
cmp rdx, -9
cmovle rax, r12
movq xmm4, rax ; movq instead of movd!
shufps xmm3, xmm4, 00010100b
ret
L0@Load:
pxor xmm3, xmm3
sub rdx, 10h
sub eax, 10h
jle ret_LoadZeroLine
LoadZeroLine:
movdqa xmm0, xmm1
movdqa xmm1, xmm2
movdqa xmm2, xmm3
cmp rdx, 0
jl L0@Load
cmp rdx, 10h
jl ret_LoadDataLine
LoadDataLine:
movdqu xmm3, [r11]
movdqa xmm4, xmm3
psllw xmm3, 8
psrlw xmm4, 8
por xmm3, xmm4
pshufhw xmm3, xmm3, 10110001b
pshuflw xmm3, xmm3, 10110001b
add r11, 10h
sub rdx, 10h
sub eax, 10h
cmp eax, 0
jg LoadZeroLine
ret
LoadPlugData:
setz al
movzx eax, al
shl eax, 31; was 7 - fix 2
movd xmm0, eax ; should be movd, not movq!
pxor xmm1, xmm1
pxor xmm2, xmm2
movq xmm3, r12 ; but here movq instead of movd
pshufd xmm3, xmm3, 00011110b
sub rdx, 40h
ret
ENDPROC Load
; ----------------------------------------------------------------------------
; Store
;
align 16
Store PROC
pshuflw xmm1, xmm1, 10110001b
pshuflw xmm2, xmm2, 00011011b
punpcklqdq xmm1, xmm2
movdqa xmm2, xmm1
psllw xmm1, 8
psrlw xmm2, 8
por xmm1, xmm2
ret
ENDPROC Store
; ----------------------------------------------------------------
; Hex output to the String utilities
;
align 16
HexDuoLine PROC
movdqa xmm2, xmm1
pxor xmm3, xmm3
punpckhbw xmm2, xmm3
punpcklbw xmm1, xmm3
movdqa xmm3, xmm2
psrlw xmm2, 4
psllw xmm3, 12
psrlw xmm3, 4
por xmm2, xmm3
movdqa xmm3, xmm2
pshufd xmm4, xmm5, 0
pcmpgtb xmm3, xmm4
pshufd xmm4, xmm5, 01010101b
pand xmm3, xmm4
paddb xmm2, xmm3
pshufd xmm4, xmm5, 10101010b
paddb xmm2, xmm4
ret
ENDPROC HexDuoLine
align 16
HexLine PROC
movdqa xmm3, xmm2
psrlw xmm2, 4
psllw xmm3, 12
psrlw xmm3, 4
por xmm2, xmm3
movdqa xmm3, xmm2
pshufd xmm4, xmm5, 0
pcmpgtb xmm3, xmm4
pshufd xmm4, xmm5, 01010101b
pand xmm3, xmm4
paddb xmm2, xmm3
pshufd xmm4, xmm5, 10101010b
paddb xmm2, xmm4
ret
ENDPROC HexLine
ENDPROGRAM sha256bench
Давайте запустим его на первом ядре и посмотрим на скорость, а также на количество инструкций на такт при его работе:

2,78 инструкций на такт, код "сколочен" довольно плотно и хешей выдаёт он 155-156 МБ/с. И в принципе тут уже понятно, что особого выигрыша гипертрединг не даст, ну так и есть, запускаем вторую копию на гиперпоточном ядре, и вот, теперь количество операций на такт стало меньше полутора — 1,48 и 1,47, и скорость упала:

То есть если изначально один поток выдавал 156 МБ/с, но один, то теперь два, но по 83, но два.
Хотя профит конечно есть, ведь два по 83 — это всё-таки 166 МБ/с, примерно 10 MB в секунду мы выиграли, и это типичный практический выигрыш от гиперпоточности, несколько процентов.
Эффект кеша
Простаивающий конвейер — не единственное место, где один поток гипертрединга может "встрять" в другой. Процессор также будет находиться в ожидании, если оперативная память не будет успевать подгонять данные, и при массированном доступе в память у нас будут происходить массивные промахи по кешу. Это тоже довольно несложно спровоцировать. Мы аллоцируем в одном случае небольшой массив в 32К, а во втором случае гигабайтный массив, и будем читать либо 32К элементов строго побайтово и последовательно, либо 32K элементов из гигабайтного массива, но с шагом в 32K (stride называется). В первом случае мы в основном будем хорошо выбирать данные из кеша (ведь мы помним, что при доступе к одному байту в кеш грузится вся линия, которая 64 байта), это будет быстро, а во втором мы огребём качественные пенальти. Нормировать на базовую частоту не будем, сколько тиков наберётся, столько и наберётся.
Код нагрузочного цикла будет вот такой:
mov r8, 32K ; Итераций цикла
mov rsi, r10 ; в rsi адрес массива для чтения
.loop:
mov al, [rsi] ; читаем байт за байтом в AL
inc rsi
dec r8
jnz .loop
Результат:

Мы набираем 40 тысяч инкрементов без малого для цикла в 32768 итераций, и это в общем весьма неплохо.
А теперь вот так:
mov r8, 32K ; Те же 32К итераций
mov rsi, r10 ; Тут адрес на гигабайтный массив
.loop:
mov al, [rsi] ; читаем байт
add rsi,32768 ; но с шагом 32К
dec r8
jnz .loop
Стало так:

И там и сям одинаковое количество циклов, но насколько второй медленнее первого. А если мы запустим их вместе, то они ощутимо сильно замедлятся, причём оба и довольно неравномерно:

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

То есть, несмотря на то, что цикл умножения оставляет "пространство для манёвра", цикл чтения памяти ощутимо замедлился. Если я запущу на первом ядре процесс, выполняющий троекратное умножение, то замедление увеличится:

И, кстати, видно, что процесс умножения также медленнее в обоих случаях, изначально там было 3,5 млрд инкрементов, а теперь — 3,6...3,8.
Если повторить эксперимент с циклом, промахивающимся мимо кеша, то будет так в случае однократного умножения:

И в случае "трёхратного" умножения, скомбинированного с тестом с промахами по кешу:

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

Эффект турбо буста
Этот эффект, хотя и не имеет прямого отношения к гиперпоточности, но тем не менее должен быть учтён в мультиядерных бенчмарках. В данном конкретном вышеизложенном случае все ядра процессора Xeon "бустятся" одинаково хорошо, хоть и немного, но все вместе, они дружно отрабатывают 3,6 ГГц, в этом смысле они независимы от нагрузки. А вот, скажем i7, работает чуть иначе — если у нас активен только один поток, то ядро, на котором он выполняется, разгоняется очень хорошо. Однако если мы начинаем задействовать оставшиеся ядра, то первое и последующие будут немного снижать свою скорость, учитывайте эту зависимость при замерах производительности (и это в общем опять же зависит от конкретного типа процессора и архитектуры).
За примером тут далеко ходить не надо, у меня есть подходящий шестиядерник, я возьму SHA256 тест из предыдущего упражнения, и вот, шесть потоков - где-то 130 МБ/с на каждом физическом ядре, частота 3,6 ГГц стабильно, семидесяти семи процентам загрузки мы не особо верим, разумеется:

А вот четыре потока, и частота поднялась:

Два потока, стало веселее:

Ну и один поток, тут частота на добрый гигагерц выше, и мы выжимаем больше 160 МБ/с:

Вот, собственно, и всё, чем я хотел с вами поделиться. Код к статье добавлен в гитхаб — папка HyperThread. Для ассемблирования требуется упомянутый выше Евро Ассемблер и больше ничего, просто положите его в ту же папку, где исходники. Бинарники я собрал в релиз, если кому надо.
Всем добра и быстрых потоков!
Комментарии (10)
MEGA_Nexus
08.09.2025 17:07Мы попытаемся копнуть чуть поглубже и более детально разобраться как работает гиперпоточность (или гипертрединг, как его иногда называют).
Скорее как работал гипертрединг, т.к. сейчас от него отказываются в новых процессорах.
Tzimie
08.09.2025 17:07А я честно говоря думал что программы с промахом Кеша удачно ускоряются гипертредингом, типа пока один ждёт память другой работает
AndreyDmitriev Автор
08.09.2025 17:07Я тоже, но на практике вижу подтормаживание. Может надо попытаться именно из одного процесса два потока создать (хотя влиять не должно бы), либо приоритеты разные назначить, либо стратегию поменять, в общем ещё есть где поковыряться.
denis_iii
08.09.2025 17:07У вас интересные циклы статей. Гипертрейдинг нужен для выполнения бОльшего количества потоков одним ядром, пока другой поток находится в спин блокировке, например на критической секции в коде (_mm_pause) и, тем самым, блокирует ядро. Для расчетных задач и игр, известный факт, что он не дает буста. Попробуйте сделать, например, вставку в конкурентный список через атомики/мьютексы (lock cmpxchg) и спин ожидание в двух конкурентных очередях на 4-е потока каждый. И это будут типичные задачи ядра (файловый ввод-вывод, сокеты) в работе пользовательских ОС.
AndreyDmitriev Автор
08.09.2025 17:07О, спасибо, я так сконцентрировался на числодробилке, что про спинлок забыл, мне и в голову не пришла идея это проверить. Надо будет PAUSE туда вкорячить. Хотя с этой стороны я особых сюрпризов не жду, тут вроде всё прозрачно, но без проверки и демки тут утверждать ничего нельзя.
oldcrock
08.09.2025 17:07Много времени посещаю Хабр в режиме readonly, но тут специально зарегистрировался, чтобы выразить автору респект!
Как человек изучавший суперскалярность ещё в прошлом тысячелетии, ещё без SMT, первую четверть статьи поплёвывал сквозь зубы — ой, это не факт, есть варианты, it's depends — но к середине включились подмагничивание и подмотка проволоки в голове. Спасибо, вы вернули мне пару лет работы центрального головного мозга. Подписка.
alan008
Спасибо за подробный разбор этой щекотливой темы.