Как я могу разрешить зависимость данных в массивах указателей? - PullRequest
3 голосов
/ 31 октября 2019

Если у нас есть массив целочисленных указателей, которые все указывают на одно и то же целое, и зацикливаем его, выполняя операцию ++, это будет на 100% медленнее, чем указатели, указывающие на два разных целых числа. Вот конкретный пример

int* data[2];
int a, b;
a = b = 0;
for (auto i = 0ul; i < 2; ++i) {
    // Case 3: 2.5 sec
    data[i] = &a;

    // Case 2: 1.25 sec
    // if (i & 1)
    //     data[i] = &a;
    // else
    //     data[i] = &b;
}

for (auto i = 0ul; i < 1000000000; ++i) {
    // Case 1: 0.5sec
    // asm volatile("" : "+g"(i)); // deoptimize
    // ++*data[0];

    ++*data[i & 1];
}

Таким образом, наблюдения: (описывается тело цикла)

случай 1 (быстрый) : ++ * указатель [0]

case 2 (средний) : ++ * указатель [i] с половиной указателя, указывающей на одно целое, и другой половиной, указывающей на другое целое.

case 3 (медленно) : ++ * указатель [i] со всеми указателями, указывающими на один и тот же int

Вот мои текущие мысли. Случай 1 быстрый, потому что современный процессор знает, что мы читаем / записываем одну и ту же ячейку памяти, таким образом, буферизуя операцию, а в случае 2 и 3 нам нужно записывать результат в каждой итерации. Причина, по которой Случай 3 медленнее, чем Случай 2, заключается в том, что когда мы записываем в ячейку памяти указатель a, а затем пытаемся прочитать ее по указателю b, нам приходится ждать окончания записи. Это останавливает суперскалярное выполнение.

Правильно ли я понимаю? Есть ли способ сделать Case 3 быстрее без изменения массива указателей? (возможно, добавив несколько подсказок процессора?)

Вопрос извлечен из реальной проблемы https://github.com/ClickHouse/ClickHouse/pull/7550

1 Ответ

1 голос
/ 01 ноября 2019

Вы обнаружили один из эффектов, вызывающих узкие места в гистограммах. Обходной путь для этой проблемы состоит в том, чтобы сохранить несколько массивов счетчиков и вращаться через них, поэтому повторяющиеся прогоны одного и того же индекса распределяются по 2 или 4 различным счетчикам в памяти.

(Затем выполните цикл по массивам счетчиков длясуммируйте их в один последний набор. Эта часть может извлечь выгоду из SIMD.)


Случай 1 быстрый, потому что современный процессор знает, что мы читаем / записываем одну и ту же область памяти, таким образом, буферизуяоперация

Нет, это не процессор, это время компиляции оптимизация.

++*pointer[0] быстро, потому чтоКомпилятор может вывести хранилище / перезагрузить из цикла и фактически просто увеличить регистр. (Если вы не используете результат, он может оптимизировать даже это.)

Предположение об отсутствии гонки данных позволяет компилятору предположить, что ничто другое не модифицирует pointer[0], так что это определенно тот же объектувеличивается каждый раз. А правило «как если» позволяет ему хранить *pointer[0] в регистре вместо того, чтобы фактически делать приращение к месту назначения памяти.

Таким образом, это означает задержку в 1 цикл для приращения, и, конечно, оно может объединять несколько приращений водин и делать *pointer[0] += n, если он полностью разворачивает и оптимизирует цикл.


когда мы записываем в ячейку памяти указатель a, а затем пытаемся прочитать ее указателем b, мыпридется ждать записи, чтобы закончить. Это останавливает суперскалярное выполнение.

Да, проблема заключается в зависимости от данных в этой области памяти. Не зная во время компиляции, что все указатели указывают на одно и то же место, компилятор создаст asm, который фактически увеличивает указанную ячейку памяти.

«ждать окончания записи» не совсем точен, хотя. ЦП имеет буфер хранилища, чтобы отделить выполнение хранилища от промахов кеша, и спекулятивного исполнения нестандартного исполнения от хранилищ, фактически фиксирующих L1d и видимых для других ядер. Перезагрузка недавно сохраненных данных не должна ждать, пока они будут зафиксированы в кэше; пересылка хранилища из буфера хранилища для перезагрузки - это то, что нужно, когда ЦП обнаружит это.

На современных процессорах Intel задержка пересылки хранилища составляет около 5 циклов, поэтому место назначения памятиadd имеет задержку в 6 циклов. (1 для сложения, 5 для сохранения / перезагрузки, если он находится на критическом пути.)

И да, выполнение не по порядку позволяет двум из этих цепочек зависимостей с 6-тактными задержками работать параллельно. И издержки цикла скрыты под этой задержкой, опять-таки OoO exec.

Related:


Есть ли способ ускорить работу Case 3 без изменения массива указателей?

Да, если этот случай ожидается, возможно, ответвление на нем :

    int *current_pointer = pointer[0];
    int repeats = 1;
    ...

    loop {
        if (pointer[i] == current_pointer) {
            repeats++;
        } else {
            *current_pointer += repeats;
            current_pointer = pointer[i];
            repeats = 1;
        }
    }

Мы оптимизируем путем подсчетадлина повторения одного и того же указателя .

Это полностью побеждено в случае 2 и будет работать плохо, если длинные пробеги не обычны.

Короткие прогоны могут быть скрыты exec-of-order exec;только когда цепочка депонирования становится достаточно длинной, чтобы заполнить ROB (буфер переупорядочения), мы действительно останавливаемся.

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...