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

Я впервые столкнулся с этой технологией лет двадцать назад на двухъядерном 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 jnzL0@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 jnzL1@fnBin paddd mm0, [r10 + 00h] paddd mm1, [r10 + 08h] paddd mm2, [r10 + 10h] paddd mm3, [r10 + 18h] subr9, 100h ; fix 1 cmp rdx, -8 jgeBlock 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 jcL0@Tail add r9, 08h ret ENDPROC Tail Load PROC cmp rdx, 0 jleLoadPlugData mov eax, 40h cmp rdx, 10h jgeLoadDataLine 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 jgLoadZeroLine 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 jleret_LoadZeroLine LoadZeroLine: movdqa xmm0, xmm1 movdqa xmm1, xmm2 movdqa xmm2, xmm3 cmp rdx, 0 jlL0@Load cmp rdx, 10h jlret_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 jgLoadZeroLine 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. Для ассемблирования требуется упомянутый выше Евро Ассемблер и больше ничего, просто положите его в ту же папку, где исходники. Бинарники я собрал в релиз, если кому надо.
Всем добра и быстрых потоков!
ссылка на оригинал статьи https://habr.com/ru/articles/945076/
Добавить комментарий