All streams
Search
Write a publication
Pull to refresh
230
200.4
Андрей Дмитриев @AndreyDmitriev

Пользователь

Send message

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

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

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

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 секунды в отмеченной строчке они улетают за сотню (и там, где инкремент счётчика тоже). Единственное моё предположение в том, что мы тут налетели на когерентность кеша, ведь спинлок и счётчик расшарены между потоками, только в случае гипертредированных ядер у нас кеш общий на два ядра, а вот для физических он раздельный и при чтении мы само собой должны получать "правильное" значение, и как-то железо должно это согласовывать, чтобы все ядра видели одни и те же данные. Как-то так.

А, ну конечно, можно же было отдельные 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 нам вкорячат не только увеличение на единицу, но и явное сравнение, и всё вся ковейеризация может полететь в тартары, смотрите:

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

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

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

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

Было бы интересно посмотреть на результаты с 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

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

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

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

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

#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 или Студию ещё ставить нужно, хоть и не сложно, конечно.

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

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

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

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

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

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

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

Я таки набросал небольшое продолжение, алаверды, так сказать, как и обещал: Не смотрите на % использования процессора при гиперпоточности

Попробовал, действительно, ограничить-то можно, вот только ОС не даёт запустить на 4-м вообще ничего, поскольку для ОС его нет, и попытка установить /affinity хоть через старт, хоть программно, не работает.

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

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

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

Похоже, человек открыл для себя удивительный мир гипертрединга и турбо буста (или как оно на АМД называется - Core Boost?). Очевидным образом логические и физические ядра при включённом гипертрединге не равноценны. Если у меня 12 ядер и 24 логических и я займу двенадцатью потоками все 12 физических ядер (то есть через ядро), то резерва останется, конечно не половина, а сильно меньше, но операционки в основном будут показывать 50%, хотя это ни разу не 50%. Если я буду запускать два потока на двух логических ядрах одного физического, то это тоже будет сильно медленнее чем на двух физических, но общую загрузку СPU будет показывать одинаковую.

Ну то есть занимать ядра процессора вот так и сяк - это две большие разницы, вот тут ни разу не 50%, на самом деле:

А вот так примерно 50% и есть (хотя на i7 будет немножко меньше 50, потому что при загрузке оставшихся четырёх ядер частота первых четырёх дропнется):

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

Information

Rating
28-th
Location
Ahrensburg, Schleswig-Holstein, Германия
Date of birth
Registered
Activity