x86 MESI делает недействительной проблему задержки строки кэша - PullRequest
0 голосов
/ 16 января 2019

У меня есть следующие процессы, я пытаюсь сделать 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, это приведет к аннулированию строки кэша в Ядро 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, в это время core2 знает его 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 Ответ

0 голосов
/ 16 января 2019

Интересная идея, да, это, вероятно, должно привести к тому, что строка кеша, удерживающая вашу структуру, в состоянии в кеше 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
    }
}

(Согласно руководству по оптимизации Intel, раздел 7.3.2 , 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 на ядре # 2, чтобы умозрительно выдвинуть glbXYZ в свой кэш L2, когда он вращается на bDOIT. Так что вы можете сгруппировать их в 128-байтовую выровненную структуру, если вы используете процессор Intel.

И / или вы можете даже использовать программную предварительную выборку, если bDOIT имеет значение false, в processB. Предварительная выборка не будет блокировать ожидание данных, но если запрос на чтение поступает в середине ProcessC пишет glbXYZ, тогда это займет больше времени. Так, может быть, только предварительная выборка SW каждый 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.
        }

    }
}

Это прекрасно компилируется на Годболте в довольно симпатичный ассм. Если bDOIT остается верным, это узкий цикл без накладных расходов на вызов. clang7.0 даже использует SSE загрузки / сохранения, чтобы скопировать структуру в стек как функцию arg по 16 байт за раз.


Очевидно, что речь идет о беспорядке неопределенного поведения, который вы должны исправить с помощью _Atomic (C11) или std::atomic (C ++ 11) с помощью memory_order_relaxed. Или mo_release / mo_acquire. У вас нет какого-либо барьера памяти в функции, которая записывает bDOIT, так что это может вывести это из цикла. * * * * * * * * * * * * * * * * * С ослабленным порядком памяти, что в буквальном смысле ноль отрицательно сказывается на качестве ассма.

Предположительно, вы используете SeqLock или что-то для защиты glbXYZ от разрывов. Да, asm("":::"memory") должен заставить это работать, заставляя компилятор предполагать, что он был изменен асинхронно. Ввод "g"(glbXYZ) оператора asm бесполезен, хотя . Он глобален, поэтому барьер "memory" уже применяется к нему (поскольку оператор asm уже может ссылаться на него). Если вы хотите сообщить компилятору, что просто он мог бы измениться, используйте asm volatile("" : "+g"(glbXYZ)); без "memory" clobber.

Или в C (не C ++), просто сделайте его volatile и выполните присваивание структуры, позволяя компилятору выбрать способ его копирования без использования барьеров. В C ++ foo x = y; терпит неудачу для volatile foo y;, где foo является агрегатным типом, подобным struct. volatile struct = struct невозможна, почему? . Это раздражает, когда вы хотите использовать volatile, чтобы сообщить компилятору, что данные могут изменяться асинхронно как часть реализации SeqLock в C ++, но вы все же хотите позволить компилятору копировать его настолько эффективно, насколько это возможно в произвольном порядке, а не в узком порядке. член одновременно.


Сноска 1 : C ++ 17 определяет std::hardware_destructive_interference_size в качестве альтернативы жесткому кодированию 64 или созданию собственной константы CLSIZE, но gcc и clang не реализуют ее тем не менее, потому что он становится частью ABI, если используется в alignas() в структуре, и, следовательно, фактически не может меняться в зависимости от фактического размера строки L1d.

...