Я поиграл со спинлоком и, думаю, могу показать промежуточные результаты, они довольно интересны. В сухом остатке — он работает реально быстрее на гипертредированных ядрах, нежели на физических.
Вот полный код реализации, тут семьдесят строк кода всего-то, вроде мы с ИИ нигде не ошиблись:
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
И вот какое дело — на гипертредированных ядрах это бежит весьма быстро:
Я заказал 500 миллионов инкрементов счётчика в двух потоках, состояния гонки нет, всё пучком, но насколько это медленнее на двух физических ядрах, больше чем в четыре раза:
Если вас интересует, где самая "горячая точка", то вот из VTune, это для гипертредированных:
Для физических всё выглядит примерно также, только время сильно больше — там где 24 секунды в отмеченной строчке они улетают за сотню (и там, где инкремент счётчика тоже). Единственное моё предположение в том, что мы тут налетели на когерентность кеша, ведь спинлок и счётчик расшарены между потоками, только в случае гипертредированных ядер у нас кеш общий на два ядра, а вот для физических он раздельный и при чтении мы само собой должны получать "правильное" значение, и как-то железо должно это согласовывать, чтобы все ядра видели одни и те же данные. Как-то так.
А, ну конечно, можно же было отдельные asm использовать, спасибо. С другой стороны мне хотелось в блок асма вставить именно весь цикл, включая инкремент и переход, это важно. И, пожалуй, разрешения chrono::high_resolution_clock недостаточно для детального понимания на уровне команд, __rdtsc() работает уровнем ниже, то есть я б так сделал:
Но когда асм скомбинирован с Сишным циклом, то общая картинка немножко теряется, и надо снова заглянуть в ассемблер, но там, кстати всё неплохо, хотя вместо тривиального декремента зачем-то явное вычитание единицы (но в данном случае на скорость не влияет):
И да, теперь я вижу, что я забыл в ассемблерном коде выравнивание перед циклом, за это тоже спасибо.
Но есть нюанс.
Смотрите, я выкину два imul чтобы получить "простаивающий конвейер" и оставлю только один:
for (uint64_t i = 0; i < 1200000000; ++i) {
asm volatile("imul %r10, %r10");
}
Знаете во что скомпилируется этот код? А вот:
То есть слишком умный gcc равернул цикл два раза, а вот как раз здесь это совершенно не нужно, так как это забьёт конвейеры.
Если же я отключу оптимизацию, то imul останется один, как и надо, но зато перед переходом jbe нам вкорячат не только увеличение на единицу, но и явное сравнение, и всё вся ковейеризация может полететь в тартары, смотрите:
Я это не к тому, что это невозможно на Си, а к тому, что тут придётся дотошно контролировать выхлоп компилятора, вот от всего этого чистый ассемблер и избавляет.
А, тег "Windows" я забыл, хотя и Линукс так умеет, как мне кажется, хотя планировщики у них отличаются. Я, когда буду под Линуксом, то гляну что там и как. С другой стороны, исключительно все тесты проводились с явной установкой аффинити, так что это всё и для линукса в общем справедливо должно быть.
Но есть нюанс — проц немедленно роняет частоту с 3,6 до 3,5 ГГц. То, что это происходит при интенсивном использовании AVX-512 (там где поддерживается) я знал и так, а вот то, что и с AVX происходит — для меня новость.
Если взять две команды подряд, то по-прежнему будет один такт, они параллелятся:
Однако если запустить одиночное умножение на двух логических ядрах одного физического, то они сразу тормозят друг друга, и каждый цикл на каждом ядре будет требовать два такта. Отсюда мораль — AVX надо раскидывать исключительно по физическим ядрам и не давать им садиться на логические гиперпоточные. По идее для каждого из двух ядер там свой набор регистров, но, похоже к ymm это не относится.
А это сразу требует два такта:
.loop:
vpmulld ymm2, ymm1, ymm0
dec r8
jnz .loop
И они тоже тормозят друг друга на гиперпоточных ядрах, если их запустить вместе, то каждому циклу надо будет уже четыре такта.
И они не параллелятся вообще, вот здесь четыре такта сходу:
А, теперь понял. Нет, я настолько глубоко макросы не знаю, чтобы сходу ответить, но я с автором общался пару раз, если что спрошу его напрямую, он на форуме охотно на вопросы отвечает.
Можно, конечно и на Си, и даже на Расте можно, но придётся повозиться чуть больше. Нам ведь надо три умножения подряд, без вкраплений других инструкций, но если мы сделаем как-то так, "в лоб":
Но как по мне, так прямо на ассемблере проще. Опять же Евро Ассемблер - это маленькая портативная штучка (пять мегабайт в архиве и 400 килобайт исполняемый файл), он самодостаточный и портабельный, без зависимостей, то есть это вообще всё, что нужно на абсолютно голой ОС — скопировали и можно экспериментировать, а gcc или Студию ещё ставить нужно, хоть и не сложно, конечно.
Вы имеете vmulps/vmulpd и vpmulld/vpmullw? Вроде только для первых двух там два порта и навскидку не всё так шоколадно с НТ, но я сегодня на обеденном перерыве попробую.
Спасибо огромное за то, что не поленились оставить этот комментарий, это безусловно мотивирует писать подобные статьи, значит я не зря потратил своё время, равно как и время тех, кто всё это прочитал.
О, спасибо, я так сконцентрировался на числодробилке, что про спинлок забыл, мне и в голову не пришла идея это проверить. Надо будет PAUSE туда вкорячить. Хотя с этой стороны я особых сюрпризов не жду, тут вроде всё прозрачно, но без проверки и демки тут утверждать ничего нельзя.
Я тоже, но на практике вижу подтормаживание. Может надо попытаться именно из одного процесса два потока создать (хотя влиять не должно бы), либо приоритеты разные назначить, либо стратегию поменять, в общем ещё есть где поковыряться.
Попробовал, действительно, ограничить-то можно, вот только ОС не даёт запустить на 4-м вообще ничего, поскольку для ОС его нет, и попытка установить /affinity хоть через старт, хоть программно, не работает.
Тут скорее наоборот, когда один поток сильно мажет мимо кеша, то второй на логическом ядре гипертрединга начнёт активнее выполняться, а если кеш большой и там активные попадания первым потоком, то это будет тормозить второй. Я попробую и это тоже продемонстрировать простеньким примером, но попозже.
Ха, я только что понял как работает гипертрединг. Он параллелит команды разных потоков, которые мог бы исполнить на разных конвейерах как если бы они исполнялись в одном потоке. Короче, факт в том, что при включённом гипертрединге мы вообще не можем спрогнозировать общую загрузку процессора, поскольку сколько резерва там осталось - это сильно зависит от данных, которыми мы будем кормить оставшиеся ядра, и, пожалуй, я готов это практически продемонстрировать, но вероятно, завтра к вечеру.
Похоже, человек открыл для себя удивительный мир гипертрединга и турбо буста (или как оно на АМД называется - Core Boost?). Очевидным образом логические и физические ядра при включённом гипертрединге не равноценны. Если у меня 12 ядер и 24 логических и я займу двенадцатью потоками все 12 физических ядер (то есть через ядро), то резерва останется, конечно не половина, а сильно меньше, но операционки в основном будут показывать 50%, хотя это ни разу не 50%. Если я буду запускать два потока на двух логических ядрах одного физического, то это тоже будет сильно медленнее чем на двух физических, но общую загрузку СPU будет показывать одинаковую.
Ну то есть занимать ядра процессора вот так и сяк - это две большие разницы, вот тут ни разу не 50%, на самом деле:
А вот так примерно 50% и есть (хотя на i7 будет немножко меньше 50, потому что при загрузке оставшихся четырёх ядер частота первых четырёх дропнется):
Кроме того, при загрузке ядра проц поднимает его частоту (на интеле оно обычно так работает), однако если я займу второе и следующее, то он тоже частоту поднимет, но сбросит при этом немножко частоту первого (хотя это тоже зависит от, к примеру, Xeon, что у меня есть, равномерно поднимает частоту всех ядер в турбо буст, хотя он и небольшой). В этом случае рассчитать общую загрузку и спрогнозировать оставшийся резерв тоже непросто. Хотя теоретически можно придумать "калиброванный" индикатор загрузки процессора, который будет более-менее реалистичен. Там надо не вайб-кодить тесты, а аккуратно писать самому с пониманием что там происходит вплоть до уровня команд процессора и всё встанет на свои места.
Я поиграл со спинлоком и, думаю, могу показать промежуточные результаты, они довольно интересны. В сухом остатке — он работает реально быстрее на гипертредированных ядрах, нежели на физических.
Вот полный код реализации, тут семьдесят строк кода всего-то, вроде мы с ИИ нигде не ошиблись:
И вот какое дело — на гипертредированных ядрах это бежит весьма быстро:
Я заказал 500 миллионов инкрементов счётчика в двух потоках, состояния гонки нет, всё пучком, но насколько это медленнее на двух физических ядрах, больше чем в четыре раза:
Код ровно тот же самый, только второй поток сажается на другое ядро в строке 47
Если вас интересует, где самая "горячая точка", то вот из VTune, это для гипертредированных:
Для физических всё выглядит примерно также, только время сильно больше — там где 24 секунды в отмеченной строчке они улетают за сотню (и там, где инкремент счётчика тоже). Единственное моё предположение в том, что мы тут налетели на когерентность кеша, ведь спинлок и счётчик расшарены между потоками, только в случае гипертредированных ядер у нас кеш общий на два ядра, а вот для физических он раздельный и при чтении мы само собой должны получать "правильное" значение, и как-то железо должно это согласовывать, чтобы все ядра видели одни и те же данные. Как-то так.
А, ну конечно, можно же было отдельные asm использовать, спасибо. С другой стороны мне хотелось в блок асма вставить именно весь цикл, включая инкремент и переход, это важно. И, пожалуй, разрешения
chrono::high_resolution_clock
недостаточно для детального понимания на уровне команд, __rdtsc() работает уровнем ниже, то есть я б так сделал:Но когда асм скомбинирован с Сишным циклом, то общая картинка немножко теряется, и надо снова заглянуть в ассемблер, но там, кстати всё неплохо, хотя вместо тривиального декремента зачем-то явное вычитание единицы (но в данном случае на скорость не влияет):
И да, теперь я вижу, что я забыл в ассемблерном коде выравнивание перед циклом, за это тоже спасибо.
Но есть нюанс.
Смотрите, я выкину два imul чтобы получить "простаивающий конвейер" и оставлю только один:
Знаете во что скомпилируется этот код? А вот:
То есть слишком умный gcc равернул цикл два раза, а вот как раз здесь это совершенно не нужно, так как это забьёт конвейеры.
Если же я отключу оптимизацию, то imul останется один, как и надо, но зато перед переходом jbe нам вкорячат не только увеличение на единицу, но и явное сравнение, и всё вся ковейеризация может полететь в тартары, смотрите:
Я это не к тому, что это невозможно на Си, а к тому, что тут придётся дотошно контролировать выхлоп компилятора, вот от всего этого чистый ассемблер и избавляет.
Да, верно, тут я слегка погорячился
А, тег "Windows" я забыл, хотя и Линукс так умеет, как мне кажется, хотя планировщики у них отличаются. Я, когда буду под Линуксом, то гляну что там и как. С другой стороны, исключительно все тесты проводились с явной установкой аффинити, так что это всё и для линукса в общем справедливо должно быть.
Я попробовал, докладываю:
На этом процессоре вот такой цикл отрабатывает за один такт на итерацию:
Но есть нюанс — проц немедленно роняет частоту с 3,6 до 3,5 ГГц. То, что это происходит при интенсивном использовании AVX-512 (там где поддерживается) я знал и так, а вот то, что и с AVX происходит — для меня новость.
Если взять две команды подряд, то по-прежнему будет один такт, они параллелятся:
А три — уже нет, тут будет два такта:
Однако если запустить одиночное умножение на двух логических ядрах одного физического, то они сразу тормозят друг друга, и каждый цикл на каждом ядре будет требовать два такта. Отсюда мораль — AVX надо раскидывать исключительно по физическим ядрам и не давать им садиться на логические гиперпоточные. По идее для каждого из двух ядер там свой набор регистров, но, похоже к ymm это не относится.
А это сразу требует два такта:
И они тоже тормозят друг друга на гиперпоточных ядрах, если их запустить вместе, то каждому циклу надо будет уже четыре такта.
И они не параллелятся вообще, вот здесь четыре такта сходу:
И кажется теперь я понял, почему некоторые операции по массивам с плавающей точкой работают быстрее, чем по целочисленным.
А, теперь понял. Нет, я настолько глубоко макросы не знаю, чтобы сходу ответить, но я с автором общался пару раз, если что спрошу его напрямую, он на форуме охотно на вопросы отвечает.
У меня, напротив, линукса сейчас под рукой нет, я попробую на досуге, но скорее на выходных или даже на следующей неделе
Можно, конечно и на Си, и даже на Расте можно, но придётся повозиться чуть больше. Нам ведь надо три умножения подряд, без вкраплений других инструкций, но если мы сделаем как-то так, "в лоб":
То получим примерно вот это:
Три умножения тут есть, но из-за mov паззл не сложится, а что бы карты легли правильно, придётся наворотить мракобесие типа такого:
Тогда всё будет как надо:
Но как по мне, так прямо на ассемблере проще. Опять же Евро Ассемблер - это маленькая портативная штучка (пять мегабайт в архиве и 400 килобайт исполняемый файл), он самодостаточный и портабельный, без зависимостей, то есть это вообще всё, что нужно на абсолютно голой ОС — скопировали и можно экспериментировать, а gcc или Студию ещё ставить нужно, хоть и не сложно, конечно.
А это хорошее замечание, я потом поправлю, спасибо.
Нет, именно джоулей, вот легенда интеловской утилиты:
Впрочем поскольку там по дефолту ровно одну секунду данные набираются, то и ватт тоже, если я физику не забыл
Вы имеете vmulps/vmulpd и vpmulld/vpmullw? Вроде только для первых двух там два порта и навскидку не всё так шоколадно с НТ, но я сегодня на обеденном перерыве попробую.
Спасибо огромное за то, что не поленились оставить этот комментарий, это безусловно мотивирует писать подобные статьи, значит я не зря потратил своё время, равно как и время тех, кто всё это прочитал.
О, спасибо, я так сконцентрировался на числодробилке, что про спинлок забыл, мне и в голову не пришла идея это проверить. Надо будет PAUSE туда вкорячить. Хотя с этой стороны я особых сюрпризов не жду, тут вроде всё прозрачно, но без проверки и демки тут утверждать ничего нельзя.
Я тоже, но на практике вижу подтормаживание. Может надо попытаться именно из одного процесса два потока создать (хотя влиять не должно бы), либо приоритеты разные назначить, либо стратегию поменять, в общем ещё есть где поковыряться.
Я таки набросал небольшое продолжение, алаверды, так сказать, как и обещал: Не смотрите на % использования процессора при гиперпоточности
Попробовал, действительно, ограничить-то можно, вот только ОС не даёт запустить на 4-м вообще ничего, поскольку для ОС его нет, и попытка установить /affinity хоть через старт, хоть программно, не работает.
Отличная мысль, кстати, спасибо, там тогда и по тактам всё стабильнее должно стать, я на досуге непременно попробую.
Тут скорее наоборот, когда один поток сильно мажет мимо кеша, то второй на логическом ядре гипертрединга начнёт активнее выполняться, а если кеш большой и там активные попадания первым потоком, то это будет тормозить второй. Я попробую и это тоже продемонстрировать простеньким примером, но попозже.
Ха, я только что понял как работает гипертрединг. Он параллелит команды разных потоков, которые мог бы исполнить на разных конвейерах как если бы они исполнялись в одном потоке. Короче, факт в том, что при включённом гипертрединге мы вообще не можем спрогнозировать общую загрузку процессора, поскольку сколько резерва там осталось - это сильно зависит от данных, которыми мы будем кормить оставшиеся ядра, и, пожалуй, я готов это практически продемонстрировать, но вероятно, завтра к вечеру.
Похоже, человек открыл для себя удивительный мир гипертрединга и турбо буста (или как оно на АМД называется - Core Boost?). Очевидным образом логические и физические ядра при включённом гипертрединге не равноценны. Если у меня 12 ядер и 24 логических и я займу двенадцатью потоками все 12 физических ядер (то есть через ядро), то резерва останется, конечно не половина, а сильно меньше, но операционки в основном будут показывать 50%, хотя это ни разу не 50%. Если я буду запускать два потока на двух логических ядрах одного физического, то это тоже будет сильно медленнее чем на двух физических, но общую загрузку СPU будет показывать одинаковую.
Ну то есть занимать ядра процессора вот так и сяк - это две большие разницы, вот тут ни разу не 50%, на самом деле:
А вот так примерно 50% и есть (хотя на i7 будет немножко меньше 50, потому что при загрузке оставшихся четырёх ядер частота первых четырёх дропнется):
Кроме того, при загрузке ядра проц поднимает его частоту (на интеле оно обычно так работает), однако если я займу второе и следующее, то он тоже частоту поднимет, но сбросит при этом немножко частоту первого (хотя это тоже зависит от, к примеру, Xeon, что у меня есть, равномерно поднимает частоту всех ядер в турбо буст, хотя он и небольшой). В этом случае рассчитать общую загрузку и спрогнозировать оставшийся резерв тоже непросто. Хотя теоретически можно придумать "калиброванный" индикатор загрузки процессора, который будет более-менее реалистичен. Там надо не вайб-кодить тесты, а аккуратно писать самому с пониманием что там происходит вплоть до уровня команд процессора и всё встанет на свои места.