x86 MESI делает недействительной проблему задержки строки кэша
У меня есть следующие процессы, я пытаюсь сделать ProcessB очень низкой задержкой, поэтому я все время использую тугой цикл и изолирую процессорное ядро 2 .
глобальная переменная в разделяемой памяти:
int bDOIT ;
typedef struct XYZ_ {
int field1 ;
int field2 ;
.....
int field20;
} XYZ;
XYZ glbXYZ ;
static void escape(void* p) {
asm volatile("" : : "g"(p) : "memory");
}
ProcessA (в ядре 1)
while(1){
nonblocking_recv(fd,&iret);
if( errno == EAGAIN)
continue ;
if( iret == 1 )
bDOIT = 1 ;
else
bDOIT = 0 ;
} // while
ProcessB (в ядре 2)
while(1){
escape(&bDOIT) ;
if( bDOIT ){
memcpy(localxyz,glbXYZ) ; // ignore lock issue
doSomething(localxyz) ;
}
} //while
ProcessC (в ядре 3)
while(1){
usleep(1000) ;
glbXYZ.field1 = xx ;
glbXYZ.field2 = xxx ;
....
glbXYZ.field20 = xxxx ;
} //while
в этих простых процессах псевдокода, в то время как ProcessesA изменяет bDOIT на 1, он делает недействительной строку кэша в Core 2, затем после того, как ProcessB получит bDOIT=1, ProcessB выполнит memcpy(localxyz,glbXYZ) .
Так как evry 1000 usec ProcessC сделает недействительным glbXYZ в Core2, я предполагаю, что это повлияет на задержку, пока ProcessB попытается выполнить memcpy(localxyz,glbXYZ), потому что, хотя ProcessB сканирует bDOIT до 1, glbXYZ уже аннулирован ProcessC,
новое значение glbXYZ все еще находится в ядре 3 L1$ или L2$, после того как ProcessB фактически получит bDOIT=1, в это время core 2 узнает, что его glbXYZ недействителен, поэтому он запрашивает новое значение glbXYZ в этот момент, задержка ProcessB определяется ожиданием новое значение glbXYZ .
Мой вопрос:
если у меня есть processD (в ядре 4), которые делают:
while(1){
usleep(10);
memcpy(nouseXYZ,glbXYZ);
} //while
будет ли этот ProcessD сбрасывать glbXYZ в L3$ раньше, чтобы, когда ProcessB в ядре 2 узнал, что его glbXYZ признан недействительным, он запросит новое значение glbXYZ, этот ProcessD поможет PrcoessB получить glbXYZ раньше?! С ProcessD помогите получить glbXYZ до L3$ все время.
1 ответ
Интересная идея, да, это, вероятно, должно привести строку кеша, удерживающую вашу структуру, в состояние в кеше L3, где ядро #2 может получить попадание L3 напрямую, вместо того, чтобы ждать запроса чтения MESI, пока строка все еще находится в состоянии M в L1d ядра № 2.
Или, если ProcessD работает на другом логическом ядре того же физического ядра, что и ProcessB, данные будут выбраны в правильный L1d. Если он проводит большую часть своего времени в спящем режиме (и нечасто просыпается), ProcessB по-прежнему обычно будет иметь весь ЦП, работающий в однопоточном режиме без разделения ROB и буфера хранения.
Вместо того, чтобы вращение фиктивного доступа usleep(10)
Вы могли бы ожидать, что он будет ждать переменную условия или семафор, который ProcessC выдает после записи glbXYZ.
Со счетным семафором (как семафоры POSIX C sem_wait
/ sem_post
) поток, который пишет glbXYZ
может увеличивать семафор, вызывая ОС для пробуждения ProcessD, который заблокирован в sem_down
, Если по какой-то причине ProcessD пропустит свою очередь, чтобы проснуться, он выполнит 2 итерации, прежде чем снова заблокировать, но это нормально. (Хм, так что на самом деле нам не нужен подсчитывающий семафор, но я думаю, что нам нужен сон / пробуждение с помощью ОС, и это простой способ получить его, если только мы не хотим избежать издержек, связанных с системным вызовом в processC после написание структуры.) Или raise()
Системный вызов в ProcessC может отправить сигнал для запуска пробуждения ProcessD.
Благодаря смягчению Specter +Meltdown любой системный вызов, даже эффективный, например, Linux futex
довольно дорого для нити, делающей это. Однако эта стоимость не является частью критического пути, который вы пытаетесь сократить, и все же она намного меньше, чем 10-дневный сон, о котором вы думали между извлечениями.
void ProcessD(void) {
while(1){
sem_wait(something); // allows one iteration to run per sem_post
__builtin_prefetch (&glbXYZ, 0, 1); // PREFETCHT2 into L2 and L3 cache
}
}
(Согласно разделу 7.3.2 руководства по оптимизации Intel, PREFETCHT2 на текущих процессорах идентичен PREFETCHT1 и извлекается в кэш L2 (и L3 по пути. Я не проверял AMD. На какой уровень кеша PREFETCHT2 извлекается?),
Я не проверял, что PREFETCHT2 действительно будет полезен здесь на процессорах Intel или AMD. Возможно, вы захотите использовать манекен volatile
доступ как *(volatile char*)&glbXYZ;
или же *(volatile int*)&glbXYZ.field1
, Особенно если у вас ProcessD работает на том же физическом ядре, что и ProcessB.
Если prefetchT2
работает, вы можете сделать это в теме, которая пишет bDOIT
(ProcessA), поэтому он может инициировать миграцию линии в L3 непосредственно перед тем, как ProcessB понадобится.
Если вы обнаружите, что строка удаляется перед использованием, возможно, вы хотите, чтобы поток извлекался при извлечении этой строки кэша.
На будущих процессорах Intel есть cldemote
инструкция ( _cldemote(const void*)
), который вы могли бы использовать после записи, чтобы инициировать миграцию грязной строки кэша в L3. Он работает как NOP на процессорах, которые его не поддерживают, но пока только для Tremont (Atom). (Вместе с umonitor
/ umwait
просыпаться, когда другое ядро пишет в контролируемом диапазоне из пользовательского пространства, что, вероятно, также было бы очень полезно для межъядерного контента с низкой задержкой.)
Поскольку ProcessA не пишет структуру, вы, вероятно, должны убедиться, bDOIT
находится в другой строке кэша, чем структура. Вы могли бы поставить alignas(64)
на первом члене XYZ
поэтому структура начинается в начале строки кэша. alignas(64) atomic<int> bDOIT;
удостоверился бы, что это также было в начале строки, чтобы они не могли совместно использовать строку кэша. Или сделай это alignas(64) atomic<bool>
или же atomic_flag
,
Также смотрите Понимание std::hardware_destructive_interference_size и std:: hardware_constructive_interference_size 1: обычно 128- это то, что вы хотите избежать ложного совместного использования из-за предварительных сборщиков смежных строк, но на самом деле это не плохо, если ProcessB запускает предварительный выборщик смежных линий L2 на core #2 умозрительно тянуть glbXYZ
в свой кэш L2, когда он вращается на bDOIT
, Поэтому вы можете сгруппировать их в 128-байтовую выровненную структуру, если вы используете процессор Intel.
И / или вы можете даже использовать программную предварительную выборку, если bDOIT
ложно, в процессе. Предварительная выборка не будет блокировать ожидание данных, но если запрос на чтение прибывает в середине записи ProcessC glbXYZ
тогда это займет больше времени. Так что, может быть, только SW prefetch каждый 16-й или 64-й раз bDOIT
это ложь?
И не забудьте использовать _mm_pause()
в вашем цикле вращения, чтобы избежать нюка конвейера неправильной спекуляции порядка памяти, когда ветвь, на которую вы вращаетесь, идет другим путем. (Обычно это ветвь с выходом из цикла в цикле ожидания с вращением, но это не имеет значения. Ваша логика ветвления эквивалентна внешнему бесконечному циклу, содержащему цикл ожидания с вращением, а затем некоторую работу, даже если вы ее не так написали.)
Или возможно использовать lock cmpxchg
вместо чистой загрузки, чтобы прочитать старое значение. Полные барьеры уже блокируют спекулятивные нагрузки после барьера, поэтому не допускайте ошибочных спекуляций. (Вы можете сделать это в C11 с atomic_compare_exchange_weak
с ожидаемым = желаемым. Занимает expected
по ссылке, и обновляет его, если сравнение не удается.) Но удар по строке кэша с lock cmpxchg
Вероятно, бесполезно, чтобы ProcessA могла быстро зафиксировать свое хранилище в L1d.
Проверить machine_clears.memory_ordering
счетчик перфораторов, чтобы увидеть, если это происходит без _mm_pause
, Если это так, попробуйте _mm_pause
сначала, а затем, возможно, попробуйте использовать atomic_compare_exchange_weak
в качестве груза. Или же atomic_fetch_add(&bDOIT, 0)
, так как lock xadd
было бы эквивалентно.
// GNU C11. The typedef in your question looks like C, redundant in C++, so I assumed C.
#include <immintrin.h>
#include <stdatomic.h>
#include <stdalign.h>
alignas(64) atomic_bool bDOIT;
typedef struct { int a,b,c,d; // 16 bytes
int e,f,g,h; // another 16
} XYZ;
alignas(64) XYZ glbXYZ;
extern void doSomething(XYZ);
// just one object (of arbitrary type) that might be modified
// maybe cheaper than a "memory" clobber (compile-time memory barrier)
#define MAYBE_MODIFIED(x) asm volatile("": "+g"(x))
// suggested ProcessB
void ProcessB(void) {
int prefetch_counter = 32; // local that doesn't escape
while(1){
if (atomic_load_explicit(&bDOIT, memory_order_acquire)){
MAYBE_MODIFIED(glbXYZ);
XYZ localxyz = glbXYZ; // or maybe a seqlock_read
// MAYBE_MODIFIED(glbXYZ); // worse code from clang, but still good with gcc, unlike a "memory" clobber which can make gcc store localxyz separately from writing it to the stack as a function arg
// asm("":::"memory"); // make sure it finishes reading glbXYZ instead of optimizing away the copy and doing it during doSomething
// localxyz hasn't escaped the function, so it shouldn't be spilled because of the memory barrier
// but if it's too big to be passed in RDI+RSI, code-gen is in practice worse
doSomething(localxyz);
} else {
if (0 == --prefetch_counter) {
// not too often: don't want to slow down writes
__builtin_prefetch(&glbXYZ, 0, 3); // PREFETCHT0 into L1d cache
prefetch_counter = 32;
}
_mm_pause(); // avoids memory order mis-speculation on bDOIT
// probably worth it for latency and throughput
// even though it pauses for ~100 cycles on Skylake and newer, up from ~5 on earlier Intel.
}
}
}
Это хорошо компилируется на Godbolt в довольно хороший Asm. Если bDOIT
остается верным, это узкая петля без накладных расходов на вызов. clang7.0 даже использует SSE загрузки / сохранения, чтобы скопировать структуру в стек как функцию arg по 16 байт за раз.
Очевидно, что вопрос заключается в беспорядке неопределенного поведения, которое вы должны исправить _Atomic
(С11) или std::atomic
(C++11) с memory_order_relaxed
, Или же mo_release
/ mo_acquire
, У вас нет никакого барьера памяти в функции, которая пишет bDOIT
, так что это может потопить это из цикла. Делая это atomic
с расслабленным порядком памяти имеет буквально нулевой недостаток качества асма.
Предположительно вы используете SeqLock или что-то для защиты glbXYZ
от слез. Да, asm("":::"memory")
должен заставить это работать, заставляя компилятор предполагать, что он был изменен асинхронно. "g"(glbXYZ)
однако ввод оператора asm бесполезен. Это глобально, поэтому "memory"
барьер уже применяется к нему (потому что asm
заявление уже может ссылаться на него). Если вы хотите сообщить компилятору, что это могло измениться, используйте asm volatile("" : "+g"(glbXYZ));
без "memory"
тряпки.
Или в C (не C++), просто сделайте это volatile
и сделайте структурное назначение, позволяя компилятору выбирать, как его скопировать, без использования барьеров. В C++ foo x = y;
терпит неудачу для volatile foo y;
где foo
это агрегатный тип, как структура. volatile struct = struct невозможна, почему?, Это раздражает, когда вы хотите использовать volatile
сообщить компилятору, что данные могут изменяться асинхронно как часть реализации SeqLock в C++, но вы все же хотите, чтобы компилятор копировал их как можно более эффективно в произвольном порядке, а не по одному узкому элементу за раз.
Сноска 1: C++ 17 определяет std::hardware_destructive_interference_size
в качестве альтернативы жесткому программированию 64 или созданию собственной константы CLSIZE, но gcc и clang пока не реализуют ее, поскольку она становится частью ABI, если используется в alignas()
в структуре, и, следовательно, не может измениться в зависимости от фактического размера строки L1d.