На Skylake (SKL) почему существуют L2-записи в рабочей нагрузке только для чтения, которая превышает размер L3?

Рассмотрим следующий простой код:

#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <time.h>

#include <err.h>

int cpu_ms() {
    return (int)(clock() * 1000 / CLOCKS_PER_SEC);
}

int main(int argc, char** argv) {
    if (argc < 2) errx(EXIT_FAILURE, "provide the array size in KB on the command line");

    size_t size = atol(argv[1]) * 1024;
    unsigned char *p = malloc(size);
    if (!p) errx(EXIT_FAILURE, "malloc of %zu bytes failed", size);

    int fill = argv[2] ? argv[2][0] : 'x'; 
    memset(p, fill, size);

    int startms = cpu_ms();
    printf("allocated %zu bytes at %p and set it to %d in %d ms\n", size, p, fill, startms);

    // wait until 500ms has elapsed from start, so that perf gets the read phase
    while (cpu_ms() - startms < 500) {}
    startms = cpu_ms();

    // we start measuring with perf here
    unsigned char sum = 0;
    for (size_t off = 0; off < 64; off++) {
        for (size_t i = 0; i < size; i += 64) {
            sum += p[i + off];
        }
    }

    int delta = cpu_ms() - startms;
    printf("sum was %u in %d ms \n", sum, delta);

    return EXIT_SUCCESS;
}

Это выделяет массив байтов size (который передается в командной строке, в KiB), устанавливает все байты в одно и то же значение (вызов memset) и, наконец, циклы над массивом в режиме "только для чтения", шаг за шагом кеш-строку (64 байта) и повторяет это 64 раза, так что каждый байт получает доступ один раз.

Если мы перейдем на предварительную выборку с 1 мы ожидаем, что это достигнет 100% на заданном уровне кеша, если size вписывается в кеш, и в основном пропустить на этом уровне в противном случае.

Меня интересуют два события: l2_lines_out.silent и l2_lines_out.non_silent (а также l2_trans.l2_wb - но значения в итоге совпадают с non_silent), который подсчитывает строки, которые молча отбрасываются из l2, а какие нет.

Если мы запустим это с 16 KiB до 1 GiB и l2_lines_in.all эти два события (плюс l2_lines_in.all) только для финального цикла, получим:

L2 lines in/out

Здесь y -a xis - это количество событий, нормированное на количество обращений в цикле. Например, тест 16 KiB выделяет область 16 KiB и делает 16 384 доступа к этому региону, и поэтому значение 0,5 означает, что в среднем на 0,5 доступа было зарегистрировано 0,5 счета данного события.

l2_lines_in.all ведет себя почти так, как мы ожидали. Он начинается около нуля, и когда размер превышает размер L2, он достигает 1,0 и остается там: каждый доступ приводит к строке.

Две другие линии ведут себя странно. В регионе, где тест соответствует L3 (но не в L2), выселение почти все молчат. Однако, как только регион переходит в основную память, выселения не являются безмолвными.

Что объясняет это поведение? Трудно понять, почему выселения из L2 будут зависеть от того, подходит ли базовый регион в основной памяти.

Если вы делаете резервные копии вместо загрузок, почти все - это несимвольная обратная запись, как ожидалось, поскольку значение обновления должно распространяться на внешние кеши:

stores

Мы также можем посмотреть, на каком уровне кеша попадают обращения, используя mem_inst_retired.l1_hit и связанные с ним события:

cache hit ratios

Если вы проигнорируете счетчики попадания L1, которые кажутся невероятно высокими в нескольких точках (более 1 L1 попало за доступ?), Результаты выглядят более или менее, как и ожидалось: в основном L2 попадает, когда регион подходит к L2, в основном L3 для области L3 (до 6 MiB на моем процессоре), а затем промахивается на DRAM после этого.

Вы можете найти код на GitHub. Подробные сведения о создании и работе можно найти в файле README.

Я наблюдал это поведение на моем клиентском процессоре i7-6700HQ для Skylake. Тот же эффект, похоже, не существует на Haswell 2. На Skylake-X поведение совершенно другое, как и ожидалось, поскольку дизайн кэша L3 изменился, чтобы быть чем-то вроде кэша-жертвы для L2.


1 Вы можете сделать это на недавнем Intel с wrmsr -a 0x1a4 "$((2#1111))". Фактически, график почти точно совпадает с предварительной выборкой, поэтому отключить его в основном просто для устранения смешающего фактора.

2 См. Комментарии для получения более подробной информации, но вкратце l2_lines_out.(non_)silent там не существует, но l2_lines_out.demand_(clean|dirty) имеет похожее сходство. Что еще более важно, l2_trans.l2_wb который в большинстве случаев non_silent на Skylake, существует также на Haswell и, кажется, зеркало demand_dirty а также не оказывает влияния на Haswell.

Ответ 1

Архитектура кеша Skylake существенно изменилась по сравнению с Haswell (и ранее): L3 теперь является кешем-жертвой (и в основном эксклюзивным).

Это означает, что когда данные загружаются, они напрямую вытягиваются из основной памяти в кэш L2 и оттуда "вверх" в направлении L1 и регистров (L1 и L2 по-прежнему включаются, поэтому любая строка, загруженная в L1, также присутствует в L2). Когда в L2 должна быть введена новая строка, необходимо заменить какую-то другую строку, и для обработки замененной строки ("жертва") доступно несколько вариантов:

  • Пишите обратно в память
  • Отправить на L3
  • Молча капля

Все три могут быть допустимыми параметрами, в зависимости от состояния и ситуации. Бесшумное отбрасывание является допустимым выбором, только если строка не изменилась/не загрязнена (как в случае с тестом загрузки), но также означает, что размер кэша L3 не используется для кэширования. Отправка на L3 всегда является подходящим выбором, если он грязный или нет, что позволяет использовать дополнительный размер кэша. Обратная запись в память имеет смысл, только если строка грязная, но опять-таки не использует доступный размер кэша для возможного повторного использования в будущем.

К сожалению, основные критерии принятия решения для этого выбора не разглашаются Intel. Они говорят, что это (среди прочего) основано на общем состоянии линии (используется ли оно другим ядром), но все, что конкретно, является лишь предположением.

Учитывая ваши конкретные результаты: l2_lines_out.silent означает, что строки L2 не были переданы в L3 и просто "забыты". Согласно описанию событий Intel это означает, что "эти линии обычно находятся в общем или исключительном состоянии". Поскольку ваши строки являются локальными потоками, они могут считаться исключительными и, таким образом, оставляют место в кэше L3 для других данных. После превышения L3, линии больше не могут считаться исключительными, и, таким образом, выселение проходит через L3.

Это не может быть удовлетворительным ответом, но я боюсь, что вы не получите его без подписания NDA с Intel, а может быть, даже тогда. Если это помогает, другие тоже борются, например, здесь.