Определить макет NUMA с помощью измерений задержки / производительности
В последнее время я наблюдаю за влиянием производительности в нагрузках с интенсивным использованием памяти, которые я не смог объяснить. Пытаясь разобраться в этом, я начал запускать несколько микробенчмарков, чтобы определить общие параметры производительности, такие как размер строки кэша и размер кэша L1/L2/L3 (я уже знал их, я просто хотел посмотреть, отражают ли мои измерения фактические значения).
Для теста строки кэша мой код выглядит примерно следующим образом (Linux C, но концепция похожа на Windows и т. Д., Конечно):
char *array = malloc (ARRAY_SIZE);
int count = ARRAY_SIZE / STEP;
clock_gettime(CLOCK_REALTIME, &start_time);
for (int i = 0; i < ARRAY_SIZE; i += STEP) {
array[i]++;
}
clock_gettime(CLOCK_REALTIME, &end_time);
// calculate time per element here:
[..]
Варьируя STEP
от 1 до 128 показывает, что из STEP=64
Я увидел, что время на элемент больше не увеличивается, т. е. на каждой итерации потребуется извлекать новую строку кэша, доминирующую во время выполнения. Варьируя ARRAY_SIZE
от 1К до 16384К с сохранением STEP=64
Мне удалось создать хороший график, показывающий шаблон шага, который примерно соответствует задержке L1, L2 и L3. Однако для получения надежных чисел необходимо было повторить цикл for несколько раз, для очень маленьких размеров массива, даже 100000 раз. Затем на моем ноутбуке IvyBridge я ясно вижу L1, заканчивающийся на 64K, L2 на 256K и даже L3 на 6M.
Теперь перейдем к моему настоящему вопросу: в системе NUMA любое ядро получит удаленную основную память и даже разделяемый кеш, который не обязательно настолько близок, как его локальный кеш и память. Я надеялся увидеть разницу в задержке / производительности, таким образом определяя, сколько памяти я мог бы выделить, оставаясь в моих быстрых кэшах / части памяти.
Для этого я усовершенствовал свой тест, чтобы пройтись по памяти в виде фрагментов 1/10 МБ, измеряя задержку отдельно, а затем собрать самые быстрые фрагменты, примерно так:
for (int chunk_start = 0; chunk_start < ARRAY_SIZE; chunk_start += CHUNK_SIZE) {
int chunk_end = MIN (ARRAY_SIZE, chunk_start + CHUNK_SIZE);
int chunk_els = CHUNK_SIZE / STEP;
for (int i = chunk_start; i < chunk_end; i+= STEP) {
array[i]++;
}
// calculate time per element
[..]
Как только я начну расти ARRAY_SIZE
к чему-то большему, чем размер L3, я получаю дикие нереалистичные числа, даже большое количество повторов не может выровнять. Я никак не могу разобрать шаблон, пригодный для оценки производительности, не говоря уже о том, чтобы определить, где именно начинается, заканчивается или располагается полоса NUMA.
Затем я подумал, что аппаратный предварительный выбор достаточно умен, чтобы распознать мой простой шаблон доступа и просто извлечь необходимые строки в кэш, прежде чем я получу к ним доступ. Добавление случайного числа к индексу массива увеличивает время на элемент, но, похоже, не очень помогает, вероятно, потому что у меня был rand ()
вызывать каждую итерацию. Предварительное вычисление некоторых случайных значений и сохранение их в массиве не показалось мне хорошей идеей, так как этот массив также будет храниться в горячем кэше и искажать мои измерения. Увеличение STEP
до 4097 или 8193 тоже мало помогло, сборщик должен быть умнее меня.
Является ли мой подход разумным / жизнеспособным или я пропустил более широкую картину? Можно ли вообще наблюдать такие задержки NUMA? Если да, что я делаю не так? Я отключил рандомизацию адресного пространства, чтобы быть уверенным и исключить странные эффекты наложения кэша. Есть ли что-то еще, что должно быть настроено перед измерением?
1 ответ
Можно ли вообще наблюдать такие задержки NUMA? Если да, что я делаю не так?
Распределители памяти поддерживают NUMA, поэтому по умолчанию вы не будете наблюдать никаких эффектов NUMA, пока явно не попросите выделить память на другом узле. Самый простой способ добиться эффекта - это numactl(8). Просто запустите ваше приложение на одном узле и привязайте распределение памяти к другому, например так:
numactl --cpunodebind 0 --membind 1 ./my-benchmark
Смотрите также numa_alloc_onnode(3).
Есть ли что-то еще, что должно быть настроено перед измерением?
Отключите масштабирование процессора, иначе ваши измерения могут быть шумными:
find '/sys/devices/system/cpu/' -name 'scaling_governor' | while read F; do
echo "==> ${F}"
echo "performance" | sudo tee "${F}" > /dev/null
done
Теперь о самом тесте. Конечно, для измерения задержки шаблон доступа должен быть (псевдо) случайным. В противном случае ваши измерения будут загрязнены быстрыми попаданиями в кэш.
Вот пример, как вы могли бы достичь этого:
Инициализация данных
Заполните массив случайными числами:
static void random_data_init()
{
for (size_t i = 0; i < ARR_SZ; i++) {
arr[i] = rand();
}
}
эталонный тест
Выполняйте операции 1M op за одну итерацию теста, чтобы уменьшить шум измерений. Используйте массив случайных чисел, чтобы перепрыгнуть через несколько строк кэша:
const size_t OPERATIONS = 1 * 1000 * 1000; // 1M operations per iteration
int random_step_sizeK(size_t size)
{
size_t idx = 0;
for (size_t i = 0; i < OPERATIONS; i++) {
arr[idx & (size - 1)]++;
idx += arr[idx & (size - 1)] * 64; // assuming cache line is 64B
}
return 0;
}
Результаты
Вот результаты для процессора i5-4460 @ 3,20 ГГц:
----------------------------------------------------------------
Benchmark Time CPU Iterations
----------------------------------------------------------------
random_step_sizeK/4 4217004 ns 4216880 ns 166
random_step_sizeK/8 4146458 ns 4146227 ns 168
random_step_sizeK/16 4188168 ns 4187700 ns 168
random_step_sizeK/32 4180545 ns 4179946 ns 163
random_step_sizeK/64 5420788 ns 5420140 ns 129
random_step_sizeK/128 6187776 ns 6187337 ns 112
random_step_sizeK/256 7856840 ns 7856549 ns 89
random_step_sizeK/512 11311684 ns 11311258 ns 57
random_step_sizeK/1024 13634351 ns 13633856 ns 51
random_step_sizeK/2048 16922005 ns 16921141 ns 48
random_step_sizeK/4096 15263547 ns 15260469 ns 41
random_step_sizeK/6144 15262491 ns 15260913 ns 46
random_step_sizeK/8192 45484456 ns 45482016 ns 23
random_step_sizeK/16384 54070435 ns 54064053 ns 14
random_step_sizeK/32768 59277722 ns 59273523 ns 11
random_step_sizeK/65536 63676848 ns 63674236 ns 10
random_step_sizeK/131072 66383037 ns 66380687 ns 11
Существуют очевидные шаги между 32 КБ /64 КБ (поэтому мой кэш L1 равен ~32 КБ), 256 КБ /512 КБ (поэтому мой размер кэш-памяти второго уровня ~256 КБ) и 6144 КБ /8192 КБ (поэтому мой кэш L3 составляет ~6 МБ).