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

Это из Википедии как есть
Это из Википедии как есть

И всё бы ничего, но если вы спросите среднестатистического инженера о том, как именно работает эта технология, то скорее всего состоится примерно такой диалог:

— Нуу, при включении у нас будет в два раза больше ядер и что-то там распараллелится...

— И что, же, компьютер начнёт работать в два раза быстрее?

— Нет, не начнёт, конечно, это же не "настоящие ядра", там ведь ресурсы общие...

— Но ведь профит-то какой-то есть? Многопоточная программа будет быстрее?

— Ну в общем, есть, конечно, и программа, возможно, станет быстрее.

— А может и не станет?

— А может и не станет, тут уж как повезёт...

Наверное эта заезженная картинка тут будет вполне уместна:

Я впервые столкнулся с этой технологией лет двадцать назад на двухъядерном 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)


  1. alan008
    08.09.2025 17:07

    Спасибо за подробный разбор этой щекотливой темы.


  1. Puliverizator
    08.09.2025 17:07

    Вот это годная матчасть, спасибо.


  1. MEGA_Nexus
    08.09.2025 17:07

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

    Скорее как работал гипертрединг, т.к. сейчас от него отказываются в новых процессорах.


    1. edo1h
      08.09.2025 17:07

      про отказ amd ничего не слышно. да и про intel пока не совсем понятно


  1. bugy
    08.09.2025 17:07

    Хабр торт, спасибо


  1. Tzimie
    08.09.2025 17:07

    А я честно говоря думал что программы с промахом Кеша удачно ускоряются гипертредингом, типа пока один ждёт память другой работает


    1. AndreyDmitriev Автор
      08.09.2025 17:07

      Я тоже, но на практике вижу подтормаживание. Может надо попытаться именно из одного процесса два потока создать (хотя влиять не должно бы), либо приоритеты разные назначить, либо стратегию поменять, в общем ещё есть где поковыряться.


  1. denis_iii
    08.09.2025 17:07

    У вас интересные циклы статей. Гипертрейдинг нужен для выполнения бОльшего количества потоков одним ядром, пока другой поток находится в спин блокировке, например на критической секции в коде (_mm_pause) и, тем самым, блокирует ядро. Для расчетных задач и игр, известный факт, что он не дает буста. Попробуйте сделать, например, вставку в конкурентный список через атомики/мьютексы (lock cmpxchg) и спин ожидание в двух конкурентных очередях на 4-е потока каждый. И это будут типичные задачи ядра (файловый ввод-вывод, сокеты) в работе пользовательских ОС.


    1. AndreyDmitriev Автор
      08.09.2025 17:07

      О, спасибо, я так сконцентрировался на числодробилке, что про спинлок забыл, мне и в голову не пришла идея это проверить. Надо будет PAUSE туда вкорячить. Хотя с этой стороны я особых сюрпризов не жду, тут вроде всё прозрачно, но без проверки и демки тут утверждать ничего нельзя.


  1. oldcrock
    08.09.2025 17:07

    Много времени посещаю Хабр в режиме readonly, но тут специально зарегистрировался, чтобы выразить автору респект!
    Как человек изучавший суперскалярность ещё в прошлом тысячелетии, ещё без SMT, первую четверть статьи поплёвывал сквозь зубы — ой, это не факт, есть варианты, it's depends — но к середине включились подмагничивание и подмотка проволоки в голове. Спасибо, вы вернули мне пару лет работы центрального головного мозга. Подписка.