Как стать автором
Поиск
Написать публикацию
Обновить

Не смотрите на % использования процессора при гиперпоточности

Уровень сложностиСредний
Время на прочтение19 мин
Количество просмотров12K
Всего голосов 130: ↑130 и ↓0+166
Комментарии100

Комментарии 100

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

интересно в чем щекотливость этой темы? Может как в том кино: процессоры (особенно многоядерные, чужие) предмет темный и исследованиям не подлежат?

Допустим пишешь ты программу. И она использует какую-то обработку, которая хорошо параллелится. Логично попытаться создать столько потоков, сколько ядер есть в системе, чтобы ускорить этот расчет. Запросил ты у системы "сколько ядер у нас есть", она рапортует что 8. Ну приложение и создало 8 потоков. А потом оказалось, что всё работает страшно плохо, потому что 8 - это гипертрединговых ядер, а не полноценных. Полноценных - всего 4. Чем не щекотливость.

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

Чуть сложнее с P и Е ядрами, но такого компа я пока в руках не держал.

сколько у неё физических и логических ядер доступно, даже про кеш отчитывается

…, а потом запускаем в виртуалке )))

А в виртуалке можно просто задать выполнение на реальных ядра, не давать системе виртуализации использовать HT ядра. Это везде настраивается и есть подробные маны. Если лень читать и настраивать, то ССЗБ

то есть предлагаете по сути отключить smt?

На этот счет можно следовать очень простой рекомендации, на сколько я знаю:

для приложения которое хорошо параллелится можно вполне надежно использовать количество ядер N/2, потому что ваша программа в системе работает не одна во первых, во вторых система это тоже программа, в третьих есть ресурсы доступ к которым очень плохо параллелится - жесткий диск например (необязательно именно жесткий диск, но скорее всего они есть в нашей программе), в четвертых в худшем случае вы потеряете не больше чем 50% (75, 87,5,) производительности от полученной максималной если будете точно выяснять сколько процессоров использовать оптимально и предыдущие пункты(!) не очень влияют, а в пятых у вас не будет риска о котором вы совершенно справедливо упомянули, риска потерять в производительности, а не выиграть.

В общем да. Чтобы добавить, я могу показать пример - вот SHA256 из статьи, но на 10-ти ядернике (у него 20 логических).

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

Одиночный поток выдаёт на этом процессоре где-то 160-170 МБ/с, и если задействовать только физические, то производительность растёт более-менее линейно до 1700 на десяти ядрах, дальше насыщение. Когда ОС выбирает сама, то до шести потоков у неё всё более-менее, но затем её "трясёт" и на 1700 она выходит лишь на 17 потоках, ну а при последовательном занятии ядер видны чёткие чуть растущие ступеньки - это как раз каждое второе гипертредированное ядро задействуется. А смещение вверх от средней линии (зелёная "сидит" практически на ней) — это как раз насколько система "врёт" когда рапортует о процентах. Как-то так.

Красиво! Только это все теория, реальная задача которую я знаю - компиляция больших проектов - которая достаточно хорошо параллелится с другой стороны упирается в работу с памятью (энергонезависимой или не очень, с диском например), а еще во всякие нюансы что там не все можно делать параллельно, а из-за того что все стараются делать параллельно много чего делается многократно-повторно для параллельности остального, а главное эта задача НЕ реального времени, то есть оно конечно чем быстрее тем лучше, но главное чтобы просто всегда доходила до логического конца-результата.

На самом деле не совсем теория, у меня задачи связаны в основном с промышленным машинным зрением (причём рентгеновским), это значит в памяти картинки 4096х4096 либо 16 бит либо float, и их надо всякими разными способами обрабатывать, причём в почти реальном времени ("почти" означает промышленный конвейер, и я должен должен успевать, иначе начальник смены будет сильно недоволен). Картинки бьются на части, которые обрабатываются несколькими потоками и это практически то, о чём я выше написал (только у меня не sha256 а свёртка или там медианная фильтрация и т.п), ну и ядер не 20 а 64 или больше.

так у вас же есть некоторый супер алгоритм который объединяет данные из разных потоков, ведь так? Пока вы игнорируете этот алгоритм интеграции результатов параллельных вычислений - это абстракция, которая мало чего дает для решения реальной задачи, хотя что-то конечно дает, но мало, по моему. А самое не хорошее - такая абстракция может отвлекать вас на ложные цели оптимизации распараллеливания независимых задач которые на самом деле каким-то образом зависимы. Тут кстати вполне подойдет определение этой темы данное в корневом комментарии - это щекотливая-скользкая тема - теперь я согласен!

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

О, здесь как раз всё очень просто. Смотрите, прямо сейчас мне нужен быстрый алгоритм свёртки (convolution) - 5х5 да 7х7. Картинка 4096х4096 бьётся на полоски 4096х1024 (или 4096х512 если на восемь потоков), дальше алгоритм бежит этими потоками по исходной картинке и пишет результат в неперекрывающиеся области памяти целевой картинки, так что после завершения всех потоков у меня есть отфильтрованное изображение, мне специально собирать ничего не надо, а момент окончания работы всех потоков - это банальный WaitForMultipleObjects, который я в спинлоке использовал в комментах ниже , вот и всё. У меня на данный момент в руках есть три библиотечных реализации свёртки - OpenCV, Intel IPP и NI VDM (и, кстати, NI самый быстрый), но у меня есть стойкое ощущение, что я могу ещё быстрее, применяя знания из статьи (и нет, гипертрединг мне тут не нужен). Короче, если я смогу написать реализацию быстрее любой известной мне, то, безусловно, напишу статью, ну а нет — то нет.

я когда-то сделал функции для кодирования-декодирования определенного формата видео для Intel IPP, с использованием MMX, SSE, SSE2 вот это была реальная оптимизация, там специальная математика была для ММХ! Распараллеливанием тоже занимались, но как то между делом - тогда по этой теме не было такого хайпа, и ядер было не так много тогда.

Ведь даже то что здесь написано про параллельное исполнение фрагмента ассемблерных инструкций в конвейере это не про распараллеливание в обычном смысле - это про оптимизацию на уровне ассемблерных инструкций. MMX, SSE, SSE2 это как раз доп.наборы ассемблерных инструкций, и соответствующая оптимизация на этом уровне.

Да, конечно, есть разные уровни оптимизации от "нано" до "макро", когда либо используется параллелизация на уровне команд (что сложно и зависит от архитектуры), затем идёт SIMD, затем можно одно изображение разбивать на части для потоков и самое простое — один поток-одна картинка, если их много надо обрабатывать; разумно комбинировать эти методы для достижения максимальной производительности в общем не возбраняется.

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

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

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

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

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

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

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

Тут как и с счётным кодом важно, является ли код latency bound (скажем, проход по связному списку, случайно разбросанному по адресам) или throughput bound (когда предсказуемо читаем память подряд, как в примере в статье). В первом случае ускорение будет.

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

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

спин ожидание в двух конкурентных очередях

Я поиграл со спинлоком и, думаю, могу показать промежуточные результаты, они довольно интересны. В сухом остатке — он работает реально быстрее на гипертредированных ядрах, нежели на физических.

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

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

INCLUDE memory64.htm, wins.htm, winscon.htm, winabi.htm, cpuext64.htm

MsgEnd D " Ticks; Check = ",0
Buf_t DB 32 * B		; Buffer for Ticks string
Buf_c DB 32 * B     ; Buffer for Counter string
hThreads DQ 0, 0         ; Space for two thread handles
SpinLock DD 0            ; Shared spin lock (0 = unlocked, 1 = locked)
Counter  DQ 0            ; 64-bit shared counter

ThreadProc PROC
    WinABI GetCurrentThread ; This one will be in RAX
    WinABI SetThreadAffinityMask, RAX, RCX ; RCX is thread param (core#)

	mov r8, 500_000_000
	align 16
SpinWait:  ; Spin lock acquire
    mov eax, 1
    xchg eax, [SpinLock] ; Atomically try to acquire lock
    cmp eax, 0           ; Was lock previously 0 (unlocked)?
    je LockAcquired      ; If yes, we acquired the lock
    ; If not acquired, wait and retry
    PAUSE                ; Hint to CPU that we are in a spin-wait loop
    jmp SpinWait

LockAcquired:
    ; Critical section begins - Increment 64-bit counter
    mov rax, [Counter]
    inc rax
    mov [Counter], rax
    ; Critical section ends - Release lock
    mov [SpinLock], 0
	dec r8 ; total increments counter
	jnz SpinWait ; loop to the start

    xor eax, eax
    ret
ENDPROC ThreadProc

Start: nop
    ; Two Threads, the first one always 1st core 0x1 (0x4 - CREATE_SUSPENDED)
    WinABI CreateThread, 0, 0, ThreadProc, 0x1, 0x4, 0
    mov [hThreads], rax     ; Save handle
    ; Second thread - change 0x2 to 0x4 below for Physical core instead of HT
    WinABI CreateThread, 0, 0, ThreadProc, 0x2, 0x4, 0 ; *
    mov [hThreads+8], rax

	RDTSC
	shl rdx, 32
	or rax, rdx
	mov r9, rax

    WinABI ResumeThread, [hThreads]   
    WinABI ResumeThread, [hThreads+8] 
    WinABI WaitForMultipleObjects, 2, hThreads, 1, 0xFFFFFFFF ; INFINITE

	RDTSCP
	shl rdx, 32
	or rax, rdx
	sub rax, r9
	StoD Buf_t
    mov rax, [Counter]
	StoD Buf_c
	StdOutput Buf_t, MsgEnd, Buf_c, Eol=Yes, Console=Yes

    WinABI CloseHandle, [hThreads]
    WinABI CloseHandle, [hThreads+8]
    TerminateProgram
ENDPROGRAM spin_lock

И вот какое дело — на гипертредированных ядрах это бежит весьма быстро:

> spin_lock_HT.exe
69_126_916_200 Ticks; Check = 1000000000

Я заказал 500 миллионов инкрементов счётчика в двух потоках, состояния гонки нет, всё пучком, но насколько это медленнее на двух физических ядрах, больше чем в четыре раза:

> spin_lock_PH.exe
294_057_181_026 Ticks; Check = 1000000000

Код ровно тот же самый, только второй поток сажается на другое ядро в строке 47

WinABI CreateThread, 0, 0, ThreadProc, 0x4, 0x4, 0 ; *

Если вас интересует, где самая "горячая точка", то вот из VTune, это для гипертредированных:

Для физических всё выглядит примерно также, только время сильно больше — там где 24 секунды в отмеченной строчке они улетают за сотню (и там, где инкремент счётчика тоже). Единственное моё предположение в том, что мы тут налетели на когерентность кеша, ведь спинлок и счётчик расшарены между потоками, только в случае гипертредированных ядер у нас кеш общий на два ядра, а вот для физических он раздельный и при чтении мы само собой должны получать "правильное" значение, и как-то железо должно это согласовывать, чтобы все ядра видели одни и те же данные. Как-то так.

он работает реально быстрее на гипертредированных ядрах, нежели на физических

😱
это как так?!?

или имеется в виду что на паре, относящейся к одному физическому ядру, код работает быстрее, чем на разных физических ядрах?

Имеено так, сам в шоке. И на i7-10850H ровно тоже самое. Проверьте сами — код выше.

Медитативная гифка-пруф на полторы минуты, сначала HT, секунд этак 25, а потом два физических — больше минуты:

Код строго одинаковый, из коммента выше, разница только в аффинити.

Ну это как раз понятно, как там про две действительно сложные проблемы в айти, вот вы наткнулись на одну из них 🤣

А можете дать ссылочку где можно скачать потестить? Хочу посмотреть как на моем 7800x3d будет гонять

Нет проблем, вот исходник., попробуйте

Я там добавил аргументов командной строки по-быстрому, чтоб не плодить экзешники и было удобнее пользоваться. Собранный бинарник в пре-релизе.

Использовать так:

spin_lock.exe Affinity1 Affinity2 Increments

Если на HT ядрах, то spin_lock.exe 1 2 1000000

Если на физических то spin_lock.exe 1 4 1000000

Вирусов там нет, но некоторые эвристики могут нервно реагировать, тут уж извините.

Попробовал, получилось примерно так:

C:\Users\xDololow\Downloads\spin_lock-20250911>spin_lock.exe 1 2 1000000
SpinLockTest for increments: 1000000
101479517 Ticks; Check = 2000000

C:\Users\xDololow\Downloads\spin_lock-20250911>spin_lock.exe 1 4 1000000
SpinLockTest for increments: 1000000
169187257 Ticks; Check = 2000000

C:\Users\xDololow\Downloads\spin_lock-20250911>spin_lock.exe 1 2
SpinLockTest for increments: 50000000
5011705608 Ticks; Check = 100000000

C:\Users\xDololow\Downloads\spin_lock-20250911>spin_lock.exe 1 4
SpinLockTest for increments: 50000000
8196496854 Ticks; Check = 100000000

Спасибо! И, кстати, неплохо, тут "пенальти" где-то в 1,6х раза, а у меня больше чем четырёхкратные, впрочем на современных процессорах я не тестил, на i7-10850H @ 2,7 GHz вот так:

C:\Users\Andrey\Desktop>spin_lock.exe 1 2 1000000
SpinLockTest for increments: 1000000
49251103 Ticks; Check = 2000000

C:\Users\Andrey\Desktop>spin_lock.exe 1 4 1000000
SpinLockTest for increments: 1000000
165250294 Ticks; Check = 2000000

C:\Users\Andrey\Desktop>spin_lock.exe 1 2
SpinLockTest for increments: 50000000
2243564608 Ticks; Check = 100000000

C:\Users\Andrey\Desktop>spin_lock.exe 1 4
SpinLockTest for increments: 50000000
9282440451 Ticks; Check = 100000000

Кстати, значения affinity меня смущают. Система же при нумеровании логических CPU на интеловских процессорах сначала проходит по всем физическим ядрам, выбирая по одному логическому процессору, а потом делает второй проход - т.е. на  i7-10850H с 6 физическими ядрами процессора 0..5 находятся на разных ядрах, 6 живёт на одном ядре с 0, 7 с 1 и т.д. Бенчмарк это учитывает?

PS Хотя то, что написал выше, справедливо на Линуксе (там это легко посмотреть через /sys/devices/system/cpu/cpu<номер>/topology/thread_siblings_list), а вот на Windows 11 на ноуте простенький тест с GetLogicalProcessorInformation говорит, что логические процессоры на одном ядре идут подряд - хотя во времена Pentium 4 точно было не так.

Нет, бенчмарк тупо передаёт два числа, что ему передали в SetThreadAffinityMask() через параметр dwThreadAffinityMask, а дальше WinAPI отрабатывает, это вот здесь происходит:

ThreadProc PROC
    WinABI GetCurrentThread ; This one will be in RAX
    WinABI SetThreadAffinityMask, RAX, RCX ; RCX is thread param (core#)

	mov r8, [Increments]

До сих пор я был уверен, что первые два бита - это два логические ядра одного физического, следующие два - это следующий проц и так далее. Это не номера, это битовая маска, поэтому валидные значения для выполенния на одном ядре 1, 2, 4, 8 и так далее. Если вызвать spin_lock.exe 255 255 то два потока будут на восьми ядрах сидеть (в смысле система их сама будет перебрасывать междя ядрами своим планировщиком). Это маски, не номера. А на Линуксе они по-другому нумеруются, это да, комментом ниже отметили.

Да, я понимаю что передаются маски, вопрос в выставленных битах.

первые два бита - это два логические ядра одного физического, следующие два - это следующий проц и так далее. 

Похоже сейчас на Windows это так (и здесь явно отражена такая нумерация), но на Линуксе и в старых версиях Windows по другому. Скажем, в этом документе от Microsoft пишут

Intel's recommendation is to list the first logical processor on each of the physical HT processors before listing any of the second logical processors.

и про свою нумерацию ничего не вижу. Когда оно поменялось - сходу не подскажу, но в любом случае в серьёзном коде в случаях, когда отображение номеров процессоров на физические ядра существенно, надо явно получать топологию от ОС (как писал выше, в Линуксе топология выставлена через sysfs, в Windows можно получить через GetLogicalProcessorInformation) и не закладывать какую то конкретную нумерацию. И в Линукс, и в Windows порядок может быть другим на процессорах от других производитетелей и/или c другими архитектурами.

Если я ошибся, то это, бомба, мне придётся подводить под это совершенно другую теорию, но не думаю, я слишком много тестов сделал, осбенно проседающий на соседних ядрах SHA256 об этом говорит. Можно, конечно упороться, найти и развернуть системы от W2K до последней и отследить на какой из них точно произошло изменение, но ИИ мне пишет вот что: "Изменение в поведении масок привязки процессора и нумерации логических процессоров — особенно в контексте Hyper-Threading и групп процессоров — произошло с введением групп процессоров в Windows Server 2008 R2 и Windows 7. " поверим ему на слово.

произошло с введением групп процессоров в Windows Server 2008 R2 и Windows 7

Спасибо, будем считать что так (официальную информацию от Microsoft сходу не нахожу) и в ваших тестах привязка потоков к процессорам на одном или разных ядрах корректна. Но, опять же, закладываться на конкретный порядок в промышленном коде точно нельзя.

 закладываться на конкретный порядок в промышленном коде точно нельзя.

С этим я совершенно согласен, более того, есть ещё NUMA, а там можно огрести реальные пенальти, просто обратившись к банку памяти "чужого" сокета.

Да, бывает, что сходить в DRAM через локальный контроллер быстрее, чем в кеш на другом NUMA узле (вот здесь у ребят на седьмом слайде такая ситуация).

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

Выглядит так. Я бы в том же VTune сравнил для разных вариантов каунтеры по зачитыванию модифицированных данных из L3 (скажем, MEM_LOAD_UOPS_L3_HIT_RETIRED.XSNP_HITM ).

каунтеры по зачитыванию модифицированных данных из L3 (скажем, MEM_LOAD_UOPS_L3_HIT_RETIRED.XSNP_HITM

похоже это оно и есть.

У меня VTune 2025.5 чё-то чудит на этой тачке и говорит, что проц не поддерживается (хотя по спекам должен бы)

Но есть ведь ещё Intel PCM, я его так запускаю, данные взял отсюда:

pcm-core.exe -e cpu/umask=0x04,event=0xD2,name=MEM_LOAD_UOPS_L3_HIT_RETIRED.XSNP_HITM/

И вот, при работе по гипертредированным ядрам (первые два), когда всё быстро:

Ну то есть там их кот наплакал, а вот при работе по физическим, то есть через ядро, когда всё тормозит:

Да, там овердофига ивентов.

Откуда вы всё это знаете?!

У меня VTune 2025.5 чё-то чудит на этой тачке и говорит, что проц не поддерживается

Ну так

New in this Release

 2025.5

...

Deprecation:

...

  • Intel CPU Platforms Support:

    • Support for all Intel CPU platforms that is prior to the following are dropped:

      • Intel® Xeon® processor family (based on formerly code named Ice Lake)

      • 3rd generation Intel® Xeon® Scalable processor family (or later)

      • 10th generation Intel® Core™ processor (or later)

https://www.intel.com/content/www/us/en/developer/articles/release-notes/vtune-profiler/current.html

Откуда вы всё это знаете?!

По работе сталкиваюсь )

А, блин, слона-то я и не заметил, всё понятно.

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

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

Пишете больше про это. Иногда позволяет понять где затык и как его немного уменьшить.

В итоге ответ в разы интереснее статьи на которую отвечали, спасибо большое

Большое спасибо

Вот это реально статья на хабре. А не как обычно нынче - буд-то на пикубу зашел

Вообще, довольно интересная история конкретно с imul - видимо, он разбивается на несколько микроинструкций, потому что, как гласит Intel® 64 and IA-32 Architectures
Optimization Reference Manual
Volume 2, в haswell операция умножения может работать только на первом порту (slow int)

Было бы интересно посмотреть на результаты с simd alu, там 2 блока на разных портах.

Вы имеете vmulps/vmulpd и vpmulld/vpmullw? Вроде только для первых двух там два порта и навскидку не всё так шоколадно с НТ, но я сегодня на обеденном перерыве попробую.

Было бы интересно посмотреть на результаты с simd alu

Я попробовал, докладываю:

На этом процессоре вот такой цикл отрабатывает за один такт на итерацию:

	mov r8, 3_500_000_000
.loop:
	vmulps ymm2, ymm1, ymm0 
	dec r8
	jnz .loop

Но есть нюанс — проц немедленно роняет частоту с 3,6 до 3,5 ГГц. То, что это происходит при интенсивном использовании AVX-512 (там где поддерживается) я знал и так, а вот то, что и с AVX происходит — для меня новость.

Если взять две команды подряд, то по-прежнему будет один такт, они параллелятся:

.loop:
	vmulps ymm2, ymm1, ymm0 
	vmulps ymm5, ymm4, ymm3 
	dec r8
	jnz .loop

А три — уже нет, тут будет два такта:

.loop:
	vmulps ymm2, ymm1, ymm0 
	vmulps ymm5, ymm4, ymm3 
	vmulps ymm8, ymm7, ymm6 
	dec r8
	jnz .loop

Однако если запустить одиночное умножение на двух логических ядрах одного физического, то они сразу тормозят друг друга, и каждый цикл на каждом ядре будет требовать два такта. Отсюда мораль — AVX надо раскидывать исключительно по физическим ядрам и не давать им садиться на логические гиперпоточные. По идее для каждого из двух ядер там свой набор регистров, но, похоже к ymm это не относится.

А это сразу требует два такта:

.loop:
	vpmulld ymm2, ymm1, ymm0 
	dec r8
	jnz .loop

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

И они не параллелятся вообще, вот здесь четыре такта сходу:

.loop:
	vpmulld ymm2, ymm1, ymm0 
	vpmulld ymm5, ymm4, ymm3 
	dec r8
	jnz .loop

И кажется теперь я понял, почему некоторые операции по массивам с плавающей точкой работают быстрее, чем по целочисленным.

Всё чудесатее и чудесатее!

Скажите, у вас есть возможность там позапускать vtune amplifier? Он умеет показывать backend-bound задачи, и, кажется, может даже более детал но показать, куда именно уперлись.

у вас есть возможность там позапускать vtune amplifier?

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

В соседней ветке комментов мы более-менее с кешем разобрались, я мог бы и тут через Intel PCM глянуть, но я совершенно ХЗ в какой из ивентов смотреть. Загрузку по портам я глянул, ну да, они заняты, но там возможно что-то ещё.

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

Всё чудесатее и чудесатее!

Всё, я понял, куда упёрлись.

Это требует один такт:

.loop:
	vmulps ymm2, ymm1, ymm0 
	dec r8
	jnz .loop

Два таких цикла в параллель на гипертредированных ядрах никогда не отработают за один такт на Haswell, потому что он не умеет делать два джампа за один такт. Дело в JNZ, а не VMULPS. Если положить два VMULPS - ровно тоже, у JNZ нет свободного такта

А вот в этом случае есть - такой цикл отрабатывается за два такта:

.loop:
	vmulps ymm2, ymm1, ymm0 
	vmulps ymm5, ymm4, ymm3 
	vmulps ymm8, ymm7, ymm6 
	dec r8
	jnz .loop

И если запустить их два в параллель, то будет не по четыре, а только по три, потому что JNZ будет отрабатывать в пустом, свободном от JNZ слоте другого потока. JNZ, кстати, фьюзится вместе с декрементом и они вместе отрабатывают на шестом порту, uiCA рулит.

А три — уже нет, тут будет два такта:

Я на подручном Haswell вижу полтора (и это логично, когда независимые операции раскладываются на два порта).

Я на подручном Haswell вижу полтора 

Да, верно, тут я слегка погорячился

Ну и кстати MCA на этих примерчиках корректно отрабатывает (но моделировать HT не умеет).

Большое спасибо автору за труды, очень доступно и с тестами объяснил, что там Билл между пальцев завернул

На реальных приложениях все может быть ещё хуже. Я впервые столкнулся на тестах бд на станках. Сан очень гордился своими многопоточными процами и даже вместо гипертрединга использовал термин мультитрединг, чтобы подчеркнуть что у них все не так как уинтела. Но оказалось что все примерно так же. И все публикуемые пузомерки своих бд ibm и oracle проводили на системах с отключённой многопоточностью.

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

вообще-то SMT — это общепринятый термин, а «Hyper-Threading Technology» — это официальное название реализации SMT от интел, которое, разумеется, никто кроме intel использовать не может

Ок, неправильно выразился. Хотел сказать, что Sun (как и Ibm) выпустил процессор с smt позднее чем Intel, ht уже успели протестировать и раскритиковать, и Sun заявлял что они рвут Интел как тузик.

ht уже успели протестировать и раскритиковать

так за что особо критиковать? в большинстве сценариев прирост есть.
да, на некоторых есть падение производительности, но большинство таких случаев давно пофикшены в процессорах и/или планировщиках операционных систем.

остаётся критиковать разве что за то, что не случилось серебряной пули… но на самом деле и не особо обещалось.

P.S. ibm в своих powerpc вообще smt4/smt8 предлагает, видимо, выхлоп есть

Какой то есть, определенно, но, повторюсь, все tpc тесты там же Ibm публиковала с отключенным smt.

Не особо понял зачем тут нужно было использовать ассемблер. В компиляторах Си можно отключить всяческие оптимизации и будет тоже самое.

Можно, конечно и на Си, и даже на Расте можно, но придётся повозиться чуть больше. Нам ведь надо три умножения подряд, без вкраплений других инструкций, но если мы сделаем как-то так, "в лоб":

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

int main() {
	for(;;){
		uint64_t ticks_before = __rdtsc();
		for (size_t i = 0; i < 1200000000; i++) {
			volatile uint64_t a1, b1;
			volatile uint64_t c1 = a1 * b1;
			volatile uint64_t a2, b2;
			volatile uint64_t c2 = a2 * b2;
			volatile uint64_t a3, b3;
			volatile uint64_t c3 = a3 * b3;
    	}
		uint64_t ticks_after = __rdtsc(); // GP0
        printf("%llu\n", (unsigned long long)(ticks_after - ticks_before));
	}
    return 0;
}

То получим примерно вот это:

.L2:
	movq	40(%rsp), %rax
	movq	48(%rsp), %rcx
	imulq	%rcx, %rax
	movq	%rax, 56(%rsp)
	movq	64(%rsp), %rax
	movq	72(%rsp), %rcx
	imulq	%rcx, %rax
	movq	%rax, 80(%rsp)
	movq	88(%rsp), %rax
	movq	96(%rsp), %rcx
	imulq	%rcx, %rax
	movq	%rax, 104(%rsp)
	subq	$1, %rdx
	jne	.L2

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

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

int main() {
    for (;;) {
        uint64_t r10 = 0, r11 = 0, r12 = 0;
        uint64_t t_before = __rdtsc();
        asm volatile (
            "mov %[count], %%r8\n\t"
            "1:\n\t"
            "imul %[reg10], %[reg10]\n\t"
            "imul %[reg11], %[reg11]\n\t"
            "imul %[reg12], %[reg12]\n\t"
            "dec %%r8\n\t"
            "jnz 1b\n\t"
            : [reg10] "+r" (r10),
              [reg11] "+r" (r11),
              [reg12] "+r" (r12)
            : [count] "i" (1200000000)
            : "r8"
        );
        uint64_t t_after = __rdtsc();
        printf("Ticks=%llu\n", (unsigned long long)(t_after - t_before));
    }
    return 0;
}

Тогда всё будет как надо:

	mov $1200000000, %r8
	1:
	imul %rax, %rax
	imul %rdx, %rdx
	imul %r9, %r9
	dec %r8
	jnz 1b

Но как по мне, так прямо на ассемблере проще. Опять же Евро Ассемблер - это маленькая портативная штучка (пять мегабайт в архиве и 400 килобайт исполняемый файл), он самодостаточный и портабельный, без зависимостей, то есть это вообще всё, что нужно на абсолютно голой ОС — скопировали и можно экспериментировать, а gcc или Студию ещё ставить нужно, хоть и не сложно, конечно.

В целом разделяю такую позицию, но мне кажется на С\С++ было бы нагляднее для статьи, но это только моё субъективное мнение :)

А так на самом деле можно было бы более элегантно сделать, вот мой пример кода

#include <iostream>
#include <chrono>

using namespace std;

int main() {
        auto tm_start = chrono::high_resolution_clock::now();

        for (uint64_t i = 0; i < 100000000; ++i)
        {
            asm volatile("imul %r10, %r10");
            asm volatile("imul %r11, %r11");
            asm volatile("imul %r11, %r11");
        }

        auto elapsed = chrono::duration_cast<chrono::nanoseconds>(std::chrono::high_resolution_clock::now() - tm_start);

        cout << "elapsed ns " << elapsed.count() << endl;

    return 0;
}

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

А, ну конечно, можно же было отдельные asm использовать, спасибо. С другой стороны мне хотелось в блок асма вставить именно весь цикл, включая инкремент и переход, это важно. И, пожалуй, разрешения chrono::high_resolution_clock недостаточно для детального понимания на уровне команд, __rdtsc() работает уровнем ниже, то есть я б так сделал:

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

int main() {
	for(;;){
        uint64_t t_before = __rdtsc();
        for (uint64_t i = 0; i < 1200000000; ++i) {
            asm volatile("imul %r10, %r10");
            asm volatile("imul %r11, %r11");
            asm volatile("imul %r12, %r12");
        }
        uint64_t t_after = __rdtsc();
        printf("Ticks=%llu\n", (unsigned long long)(t_after - t_before));
	}
    return 0;
}

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

И да, теперь я вижу, что я забыл в ассемблерном коде выравнивание перед циклом, за это тоже спасибо.

Но есть нюанс.

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

        for (uint64_t i = 0; i < 1200000000; ++i) {
            asm volatile("imul %r10, %r10");
        }

Знаете во что скомпилируется этот код? А вот:

То есть слишком умный gcc равернул цикл два раза, а вот как раз здесь это совершенно не нужно, так как это забьёт конвейеры.

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

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

Действительно придется покопаться с настройками компилятора, чтобы вышло то что нужно :) Но мне удалось добиться приемлемого выхлопа компилятора с флагом -O1

Круто, заодно и с флагами оптимизации слегка разобрались, а то я всегда сомневался - чем же О1 от О2 отличается, да как-то руки не доходили. Профит от таких упражнений ещё и в том, что немножко прокачиваются скиллы в смежных областях.

Проблема в том, что код может поменяться при смене версии компилятора или переходе между gcc и clang.

Вкусовщина же, для меня исходный вариант кажется более читаемым.
А в вашем случае компилятор сам поймёт, что регистры r10, r11, r12 модифицируются и не стоит их использовать?

Согласен, что вкусовщина. На счет регистров, мой пример не совсем "безопасный" в реальной программе так делать не стоит, а стоит указать компилятору какие мы регистры заюзали типа так asm("imul %r10, %r10" ::: "r10")

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

Стало всего на два джоуля больше

Ватт же?

Нет, именно джоулей, вот легенда интеловской утилиты:

Впрочем поскольку там по дефолту ровно одну секунду данные набираются, то и ватт тоже, если я физику не забыл

Тогда может лучше написать энергия. Мощность это ватты.

А это хорошее замечание, я потом поправлю, спасибо.

захотел проверить на amd, но у меня под рукой везде linux.

за пару минут сделал патч:

diff --git a/HyperThread/multest1.asm b/HyperThread/multest1.asm
index a609651..682bad3 100644
--- a/HyperThread/multest1.asm
+++ b/HyperThread/multest1.asm
@@ -1,7 +1,8 @@
 EUROASM AutoSegment=Yes, CPU=X64, SIMD=AVX2
-multest1 PROGRAM Format=PE, Width=64, Model=Flat, IconFile=, Entry=Start:
+multest1 PROGRAM Format=ELFX, Width=64, Entry=Start:
 
-INCLUDE winscon.htm, winabi.htm, cpuext64.htm
+INCLUDE linabi.htm
+INCLUDE cpuext64.htm
 
 Msg0 D ">",0
 Buf0 DB 4 * B

а вот как сделать чтобы оно собиралось и под linux, и под windows, сходу не соображу
@AndreyDmitriev, сделаете?

У меня, напротив, линукса сейчас под рукой нет, я попробую на досуге, но скорее на выходных или даже на следующей неделе

так сам код без каких-либо модификаций работает в линуксе.
и да, у меня линуксовая версия esuroasm.x отлично собирает exe, так что, уверен, и виндовая версия соберёт elf.

просто чтобы добавить поддержку linux не ломая собрку под windows надо разбираться как работают макросы в евроассемблере.
сейчас спросил chatgpt, она предложила

%Target %SETE TARGET            ; set env var TARGET=win or linux

%IF "%Target" == "win"
  multest1 PROGRAM Format=PE,   Width=64, Entry=Start:
    INCLUDE winabi.htm
%ELSE
  multest1 PROGRAM Format=ELFX, Width=64, Entry=Start:
    INCLUDE linabi.htm
%ENDIF

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

А, теперь понял. Нет, я настолько глубоко макросы не знаю, чтобы сходу ответить, но я с автором общался пару раз, если что спрошу его напрямую, он на форуме охотно на вопросы отвечает.

полагаю, что среднестатистическое приложение в вакууме вряд ли всегда может выжимать все соки из исполнительных устройств на одном ядре
те, кто выжимают, вообще используют AVX и понятно, что тут с HT особой пользы не будет

Перекидывать однопоток между ядрами --- это, конечно, сильно́.
Может, имело смысл назвать статью "Не смотрите... при гиперпоточности под Windows"?

А, тег "Windows" я забыл, хотя и Линукс так умеет, как мне кажется, хотя планировщики у них отличаются. Я, когда буду под Линуксом, то гляну что там и как. С другой стороны, исключительно все тесты проводились с явной установкой аффинити, так что это всё и для линукса в общем справедливо должно быть.

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

0>3632333059
0>3623845552
0>3627519054
5>3624223258
1>3627158838
5>3624185049
5>3624784022
5>3621166990
5>3620452676
5>3614472377
5>3641361478

а, и нумерация ядер в линуксе другая, например, в примере выше 0-3 «настоящие» ядра, а 4-7 гипертрединговые

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

Скорее просто нюансы планирования - после прерываний система ставит поток туда, куда удобнее. От перегрева это не особо спасает - никто не мешает задать affinity, тогда поток будет всегда на одном ядре; ну и всё таки ожидается, что процессор нормально работает когда все ядра загружены вычислениями. Перегрев фиксится троттлингом частоты.

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

никто не мешает задать affinity

Ну да, против лома нет приёма )
Но по умолчанию планировщик и windows, и linux считает более оптимальным вариант периодической смены ядра.

после прерываний система ставит поток туда, куда удобнее

Линукс меняет активное ядро раз а несколько секунд (иногда в несколько десятков секунд).
Прерывания проходят чаще

Я склонен считать, что всё-таки смена ядер связана с нагревом.

Можете показать реализацию этой логики в исходном коде ядра?

вы неправильно меня поняли.
я имел в виду не то, что процесс перекидывается по мере нагрева ядра (это и не всегда реально, у amd, например, один температурный датчик на ccd), а то, что разработчики рассуждали примерно так: «выгоднее ядра менять, это позволит поддерживать более высокую частоту в некоторых случаях; но не стоит менять слишком часто, иначе l1/l2… кэши будут неэффективны».

Ok, покажите логику, которая просто время от времени намеренно перекидывает поток на другое ядро (когда исходное ядро доступно).

PS Исследования на эту тему были, но не видел, чтобы заливалось в основной бранч.

Всем привет! Если это сервак там может Vt-d iommu вариться, со своими фишками-кэшами контроллера памяти.

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

В целом подобные измерения это всё равно что "пробег автомобиля на одном баке/заряде" - вроде и цифра, и даже из жизни, но your mileage may vary, как говорится

Да, маркетинг это все... На практике для непритязательного пользователя честные 4 ядра 4ГГц будут комфортнее, чем 16 ядер по 3.6ГГц, из которых в турбо могут работать лишь два, а дальше режутся частоты. :-(

В винраре последнем можно в тесте производительности играться с количеством потоков.
Забавно бывает, ставишь 1 поток обрабатывает со скоростью 6000кБ/сек, 2 потока 12000кБ/сек, ставишь максимальные 16 потоков, получаешь жалкие 36000кб/сек.

В целом вроде и быстрее, но явно есть какой-то обман.

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

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

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

Способ тюнинга планировщика под Windows мне неизвестен, но в принципе мы можем оценить примрно как часто происходит переключение между потоками кодом типа этого:

#include <windows.h>
#include <stdio.h>
#include <math.h>
#include <timeapi.h>

int main()
{
	timeBeginPeriod(1); // чтобы была миллисекунда в Sleep, а не 15,6
	double time_min = INFINITY, time_max = 0;

    unsigned int last_core = 0xFF;
    unsigned __int64 last_tsc = 0, current_tsc, start_tsc;
    unsigned int current_core;
    bool first_run = true;
	start_tsc = last_tsc = current_tsc = __rdtscp(&current_core);
	while((current_tsc - start_tsc) < 3600000000) {
    	current_tsc = __rdtscp(&current_core);
		Sleep(1); // не грузим проц понапрасну   
    	if (last_core != 0xFFFFFFFF && current_core != last_core && !first_run) {
        	__int64 diff = current_tsc - last_tsc;
        	double time_ms = (double)diff / 3600000.0;  // Convert ticks to ms
        	printf("core switch %d > %d, time: %.3f  ms\n", last_core, current_core, time_ms);
			if (time_ms > time_max) time_max = time_ms;
			if (time_ms < time_min) time_min = time_ms;
	    	last_tsc = current_tsc;
    	} 
    	last_core = current_core;
		first_run = false;
	}
    printf("Min time = %.3f ms; Max Time =  %.3f ms;\n", time_min, time_max);
    return 0;
}

И вот я вижу, что переключение происходит примрно так (time это сколько мы сидим на одном ядре):

core switch 4 > 1, time: 1.433  ms
core switch 1 > 2, time: 13.927  ms
core switch 2 > 0, time: 1.448  ms
core switch 0 > 2, time: 1.757  ms
core switch 2 > 4, time: 4.021  ms
core switch 4 > 2, time: 1.342  ms
core switch 2 > 4, time: 3.891  ms
core switch 4 > 2, time: 1.270  ms
core switch 2 > 4, time: 6.976  ms
core switch 4 > 2, time: 1.232  ms
core switch 2 > 4, time: 5.234  ms
core switch 4 > 2, time: 2.310  ms
Min time = 1.106 ms; Max Time =  13.927 ms;

Времена меньше одной миллисекундя я там не ловлю, потому что Sleep(1). Ну то есть при десяток миллисекунд я в общем не соврал, больше 14 мс мы на одном ядре не сидели

Если же убрать Sleep(1) из кода и тем самым загнать поток в 100% использование процессора, то будет так:

core switch 2 > 6, time: 0.186  ms
core switch 6 > 7, time: 29.554  ms
core switch 7 > 4, time: 0.224  ms
core switch 4 > 6, time: 120.686  ms
core switch 6 > 7, time: 537.447  ms
core switch 7 > 6, time: 0.214  ms
core switch 6 > 4, time: 0.143  ms
core switch 4 > 2, time: 0.116  ms
Min time = 0.116 ms; Max Time =  537.447 ms;

То есть мы можем и полсекунды провести на одном ядре, а можем и десять раз в миллисекунду переброситься. В общем не особо беспокоит, так как время переброса относительно невелико (где-то я читал про несколько микросекунд, в общем тоже можно попробовать оценить, но лень), Это детально вроде нигде не документированно, мы можем лишь подвергать систему внешнему воздействию нашим кодом и смотреть её реакцию. Я, кстати, не поленился проверить на Windows Server 2019, и там всё ровно также работает, хотя вроде везде пишут, что там планировщик чуть по-другому тюнингован.

Преимущество заключается в повышении многозадачности и производительности в целом за счёт некоторого снижения отзывчивости конкретной задачи.

Рву на себе остатки волос.

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

Бонус для тех, кто промотал комментарии до этого места - в том, месте, где умножение было заменено на сложение цикл замедлялся, но истинная причина была совершенно не в сложении (впрочем я этого и не писал). Умные люди мне подсказали, что просто эта операция выполняется за один такт, но там дальше идёт переход jnz (который параллелится с add). Так вот, когда этот цикл крутится на одном ядре, то всё так и есть - один такт на цикл проца, но когда ему в нагрузку мы придаём второй цикл с imul, то там тоже jnz, и вместе они отрабатывать не могут, Haswell не умеет сделать два условных перехода в пределах одного своего цикла, поэтому ему приходится притормаживать цикл с add, чтобы выполнить переход в тот момент, когда цикл свободен от jnz (а таких два свободных, потому что imul требует три такта, один из которых отрабатывает в параллель с jnz, а два других "свободны"). Вот так-то. А когда там два умножения были, то каждый имел запас и в установившемся режиме они исполнялись со сдвигом на такт. Процессор - офигенно интересная штука, на самом деле. Ну и мораль - чем меньше в коде условных переходов, тем лучше (но это было и так известно), и дело не только в предсказателе переходов, но и в гипертрединге.

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации