Использование счетчика меток времени и clock_gettime для пропуска кэша
В качестве продолжения этой темы для вычисления задержки памяти я написал следующий код, используя _mm_clflush
, __rdtsc
а также _mm_lfence
(который основан на коде из этого вопроса / ответа).
Как видно из кода, я сначала загружаю массив в кеш. Затем я сбрасываю один элемент, и поэтому строка кэша выселяется из всех уровней кэша. я кладу _mm_lfence
чтобы сохранить порядок во время -O3
,
Затем я использовал счетчик меток времени для расчета задержки или чтения array[0]
, Как видно из двух меток времени, есть три инструкции: две lfence
и один read
, Итак, я должен вычесть lfence
накладные расходы. Последний раздел кода вычисляет эти накладные расходы.
В конце кода печатаются накладные расходы и задержки. Тем не менее, результат не является действительным!
#include <stdio.h>
#include <stdint.h>
#include <x86intrin.h>
int main()
{
int array[ 100 ];
for ( int i = 0; i < 100; i++ )
array[ i ] = i;
uint64_t t1, t2, ov, diff;
_mm_lfence();
_mm_clflush( &array[ 0 ] );
_mm_lfence();
_mm_lfence();
t1 = __rdtsc();
_mm_lfence();
int tmp = array[ 0 ];
_mm_lfence();
t2 = __rdtsc();
_mm_lfence();
diff = t2 - t1;
printf( "diff is %lu\n", diff );
_mm_lfence();
t1 = __rdtsc();
_mm_lfence();
_mm_lfence();
t2 = __rdtsc();
_mm_lfence();
ov = t2 - t1;
printf( "lfence overhead is %lu\n", ov );
printf( "miss cycles is %lu\n", diff-ov );
return 0;
}
Однако вывод недействителен
$ gcc -O3 -o flush1 flush1.c
$ taskset -c 0 ./flush1
diff is 161
lfence overhead is 147
miss cycles is 14
$ taskset -c 0 ./flush1
diff is 161
lfence overhead is 154
miss cycles is 7
$ taskset -c 0 ./flush1
diff is 147
lfence overhead is 154
miss cycles is 18446744073709551609
Есть мысли?
Далее я попробовал clock_gettime
Функция для того, чтобы рассчитать задержку пропуска, как показано ниже
_mm_lfence();
_mm_clflush( &array[ 0 ] );
_mm_lfence();
struct timespec start, end;
clock_gettime(CLOCK_MONOTONIC, &start);
_mm_lfence();
int tmp = array[ 0 ];
_mm_lfence();
clock_gettime(CLOCK_MONOTONIC, &end);
diff = 1000000000 * (end.tv_sec - start.tv_sec) + end.tv_nsec - start.tv_nsec;
printf("miss elapsed time = %lu nanoseconds\n", diff);
Выход miss elapsed time = 578 nanoseconds
, Это надежно?
Update1:
Благодаря Питеру и Хади, чтобы суммировать ответы до сих пор, я узнал
1- Неиспользуемые переменные опускаются на этапе оптимизации, и это было причиной странных значений, которые я видел в выходных данных. Благодаря ответу Питера, есть несколько способов это исправить.
2-
clock_gettime
не подходит для такого разрешения, и эта функция используется для больших задержек.
В качестве обходного пути я попытался перенести массив в кеш, а затем сбросить все элементы, чтобы убедиться, что все элементы удалены со всех уровней кеша. Затем я измерил задержку array[0]
а потом array[20]
, Поскольку каждый элемент имеет 4 байта, расстояние составляет 80 байтов. Я ожидаю получить два промаха кэша. Тем не менее, латентность array[20]
похоже на попадание в кеш. Можно предположить, что длина строки кэша не 80 байт. Так что, может быть array[20]
предварительно загружен аппаратно. Не всегда, но я также вижу некоторые странные результаты снова
for ( int i = 0; i < 100; i++ ) {
_mm_lfence();
_mm_clflush( &array[ i ] );
_mm_lfence();
}
_mm_lfence();
t1 = __rdtsc();
_mm_lfence();
int tmp = array[ 0 ];
_mm_lfence();
t2 = __rdtsc();
_mm_lfence();
diff1 = t2 - t1;
printf( "tmp is %d\ndiff1 is %lu\n", tmp, diff1 );
_mm_lfence();
t1 = __rdtsc();
tmp = array[ 20 ];
_mm_lfence();
t2 = __rdtsc();
_mm_lfence();
diff2 = t2 - t1;
printf( "tmp is %d\ndiff2 is %lu\n", tmp, diff2 );
_mm_lfence();
t1 = __rdtsc();
_mm_lfence();
_mm_lfence();
t2 = __rdtsc();
_mm_lfence();
ov = t2 - t1;
printf( "lfence overhead is %lu\n", ov );
printf( "TSC1 is %lu\n", diff1-ov );
printf( "TSC2 is %lu\n", diff2-ov );
Выход
$ ./flush1
tmp is 0
diff1 is 371
tmp is 20
diff2 is 280
lfence overhead is 147
TSC1 is 224
TSC2 is 133
$ ./flush1
tmp is 0
diff1 is 399
tmp is 20
diff2 is 280
lfence overhead is 154
TSC1 is 245
TSC2 is 126
$ ./flush1
tmp is 0
diff1 is 392
tmp is 20
diff2 is 840
lfence overhead is 147
TSC1 is 245
TSC2 is 693
$ ./flush1
tmp is 0
diff1 is 364
tmp is 20
diff2 is 140
lfence overhead is 154
TSC1 is 210
TSC2 is 18446744073709551602
Утверждение, что "HW prefetcher приносит другие блоки" в этом случае примерно на 80% верно. Что происходит тогда? Есть более точное утверждение?
1 ответ
Вы сломали код Хади, удалив чтение tmp
в конце, так что он будет оптимизирован GCC. В выбранном вами регионе нет нагрузки. C заявления не являются инструкциями asm.
Посмотрите на сгенерированный компилятором asm, например, на проводнике компилятора Godbolt. Вы всегда должны делать это, когда вы пытаетесь микробенчмировать действительно низкоуровневые вещи, подобные этой, особенно если ваши временные результаты неожиданны.
lfence
clflush [rcx]
lfence
lfence
rdtsc # start of first timed region
lfence
# nothing because tmp=array[0] optimized away.
lfence
mov rcx, rax
sal rdx, 32
or rcx, rdx
rdtsc # end of first timed region
mov edi, OFFSET FLAT:.LC2
lfence
sal rdx, 32
or rax, rdx
sub rax, rcx
mov rsi, rax
mov rbx, rax
xor eax, eax
call printf
Вы получаете предупреждение компилятора о неиспользуемой переменной из -Wall
, но вы можете заставить замолчать это способами, которые все еще оптимизируют. например, ваш tmp++
не делает tmp
доступны для чего-либо за пределами функции, так что он все еще оптимизирует прочь. Отключение предупреждения недостаточно: напечатайте значение, верните значение или назначьте его volatile
переменная вне временной области. (Или используйте встроенный asm volatile
требовать от компилятора иметь его в реестре в какой-то момент. CppCon2015 от Chandler Carruth рассказывает об использовании perf
упоминает некоторые хитрости: https://www.youtube.com/watch?v=nXaxk27zwlk)
В GNU C (по крайней мере, с gcc и clang -O3
), вы можете заставить чтение читать (volatile int*)
, как это:
// int tmp = array[0]; // replace this
(void) *(volatile int*)array; // with this
(void)
чтобы избежать предупреждения для оценки выражения в пустом контексте, как, например, запись x;
,
Этот вид выглядит как UB со строгим псевдонимом, но я понимаю, что gcc определяет это поведение. Ядро Linux использует указатель для добавления volatile
классификатор в его ACCESS_ONCE
макрос, поэтому он используется в одной из кодовых баз, которые gcc определенно заботится о поддержке. Вы всегда можете сделать весь массив volatile
; не имеет значения, если инициализация этого не может автоматически векторизовать.
Во всяком случае, это компилируется в
# gcc8.2 -O3
lfence
rdtsc
lfence
mov rcx, rax
sal rdx, 32
mov eax, DWORD PTR [rsp] # the load which wasn't there before.
lfence
or rcx, rdx
rdtsc
mov edi, OFFSET FLAT:.LC2
lfence
Тогда вам не нужно возиться с уверенностью tmp
используется, или с беспокойством об устранении мертвого хранилища, CSE или постоянном распространении. На практике _mm_mfence()
или что-то еще в первоначальном ответе Хади включало в себя достаточный барьер памяти, чтобы заставить gcc на самом деле повторить загрузку для случая с отсутствием кэша и попаданием в кэш, но это легко могло бы оптимизировать одну из перезагрузок.
Обратите внимание, что это может привести к тому, что asm загружается в регистр, но никогда не читает его. Текущие процессоры все еще ждут результата (особенно если есть lfence
), но перезапись результата может позволить гипотетическому процессору сбросить нагрузку и не ждать ее. (Это зависит от компилятора, будет ли он делать что-то еще с регистром до следующего lfence
, лайк mov
часть rdtsc
результат там.)
Это сложно / маловероятно для аппаратного обеспечения, поскольку ЦП должен быть готов к исключениям, см. Обсуждение в комментариях здесь.) Сообщается, что RDRAND работает таким образом ( Какова задержка и пропускная способность инструкции RDRAND на Ivy Bridge?), но это, вероятно, особый случай.
Я сам проверил это на Skylake, добавив xor eax,eax
к выводу asm компилятора, сразу после mov eax, DWORD PTR [rsp]
, чтобы убить результат загрузки кэша. Это не повлияло на сроки.
Тем не менее, это потенциальная ошибка с отбрасыванием результатов volatile
нагрузка; будущие процессоры могут вести себя по-другому. Возможно, было бы лучше суммировать результаты загрузки (вне временной области) и назначить их в конце volatile int sink
в случае, если будущие процессоры начнут отбрасывать мопы, которые дают непрочитанные результаты. Но все еще использовать volatile
для грузов, чтобы убедиться, что они происходят, где вы хотите их.
Также не забудьте выполнить какой-нибудь цикл прогрева, чтобы увеличить скорость работы процессора, если только вы не хотите измерить время выполнения пропуска кэша на тактовой частоте простоя. Похоже, что ваша пустая временная область занимает много циклов ссылок, поэтому ваш процессор, вероятно, работал довольно медленно.
Итак, как именно кеш-атаки, например, распад и призрак, преодолевают такую проблему? По сути, им приходится отключать предварительный выбор hw, поскольку они пытаются измерить соседние адреса, чтобы определить, попали они или нет.
Боковой канал чтения из кэша как часть атаки Meltdown или Spectre, как правило, использует шаг, достаточно большой, чтобы предварительная выборка HW не могла обнаружить шаблон доступа. например, на отдельных страницах вместо смежных строк. Один из первых хитов Google для meltdown cache read prefetch stride
был https://medium.com/@mattklein123/meltdown-spectre-explained-6bc8634cc0c2, который использует шаг 4096. Это может быть сложнее для Spectre, потому что ваш шаг зависит от "гаджетов", которые вы можете найти в целевой процесс.