Определить макет 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 МБ).

Другие вопросы по тегам