Самый быстрый способ получить массив значений памяти

В основе структуры индексации я задаюсь вопросом, можно ли сделать оптимизацию для следующей проблемы:

У меня большой (несколько ГБ ОЗУ) массив небольших структур (в ОЗУ), у меня есть меньший массив индексов (порядка 10e4 элементов). Индексы почти распределены случайным образом. У меня есть функция агрегирования, которая является независимой от заказа ( "ассоциативной" для математиков), скажем, например, "сумма".

Я хочу агрегировать значения, полученные из большого массива, по индексам, указанным в малом массиве.

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

Итак, мой вопрос: какой самый быстрый способ получить из известных мест памяти. Есть ли волшебство программирования темного искусства? Есть ли какой-нибудь подход к архитектуре/платформе? Я ищу решения С++ или С#.

Ответ 1

Не зная ничего о вашей проблеме или вашей текущей реализации, один (несколько) простой способ повысить производительность (в некоторой степени) - это вручную предварительно выбрать значения, которые будет действовать ваша функция "sum".

Игнорируя нюансы архитектуры и компилятора, ручная предварительная выборка может выглядеть так:

SmallStruct values [value_count] = {/*whatever*/};
int indices [index_count] = {/*whatever*/};
...

SmallStruct v = values[indices[0]];
for (int i = 1; i < index_count; ++i)
{
    SmallStruct v_next = values[indices[i]];
    DoSomethingWith (v); // Note the *v*
    v = v_next; // You don't want to copy, but this is the simplest form
}
DoSomethingWith (v); // Do the final item

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

Эта оптимизация работает, потому что большинство современных (всех?) современных архитектур могут иметь более одного запроса памяти в полете, а это означает, что эти запросы перекрываются, а среднее время ожидания для этих (предположительно нераскрытых) запросов делится на их concurrency (что хорошо!) Итак, неважно, сколько у вас неиспользуемых строк кеша; важным фактором является количество одновременных считываний памяти, которые система памяти может поддерживать в любой момент времени.

Заметка о влиянии линий кэша

Вышеприведенный (по общему признанию, упрощенный) код игнорирует два очень важных факта: весь SmallStruct не может быть прочитан в одном доступе к памяти (с точки зрения ЦП), что плохо, и эта память всегда читается в единицах строк кеша (64 или 128 байт, в наши дни), что очень хорошо!

Итак, вместо того, чтобы читать весь values[indices[i]] в v_next, мы можем просто прочитать один байт, и если массив values правильно выровнен, значительный объем памяти (одна полная строка кэша) будут загружены и под рукой для возможной обработки.

Два важных момента:

  • Если ваш SmallStruct на самом деле невелик и не будет полностью вписываться в строку кэша, вы должны изменить его элементы, чтобы убедиться, что его части, которые требуются в DoSomethingWith(), смежны и упакованы и подходят в одной строке кэша. Если они все еще не подходят, вам следует рассмотреть возможность разделения вашего алгоритма на два или более проходов, каждый из которых работает с данными, которые вписываются в одну строку кэша.
  • Если вы просто прочитали один байт (или одно слово или что-то еще) из следующего значения, которое вы получите, убедитесь, что компилятор не оптимизирует это чтение!

Альтернативные реализации

Вторая точка выше может быть выражена в коде, например:

touch (&values[indices[0]]);
for (int i = 0; i < index_count; ++i)
{
    if (i + 1 < index_count)
        touch (&values[indices[i + 1]]);

    DoSomethingWith (values[indices[i]]);
}

Функция touch() семантически подобна (хотя реализация, вероятно, будет более сложной).

void touch (void * p)
{
    char c = *(char *)p;
}

Чтобы предварительно выбрать несколько значений, вы должны сделать что-то вроде этого: (Обновление: я изменил свой код на (я считаю) более эффективную реализацию.)

const int PrefetchCount = 3;

// Get the ball rolling...
for (int j = 0; j < PrefetchCount; ++j)
    touch (&values[indices[j]]);

for (int i = 0; i < index_count; ++i)
{
    if (i + PrefetchCount < index_count)
        touch (&values[indices[i + PrefetchCount]]);

    DoSomethingWith (values[indices[i]]);
}

Снова отметим, что все описанные выше реализации очень просты и упрощены. Кроме того, если вы слишком много предварительно выберете, вы можете снести свой кеш L1 и свою производительность.

Выполнение фактической предварительной выборки

У процессора x86-64 есть инструкция, которую вы используете, чтобы попросить ЦП предварительно запрограммировать данные памяти в кеш-строке в свои кеши. Фактически, используя эту инструкцию, вы даете подсказку процессору о том, что ваше конкретное место памяти будет использоваться вашим приложением, а процессор попытается привести его в кеш. Если вы сделаете это достаточно быстро, данные будут готовы к тому времени, когда вам это понадобится, и ваши вычисления не будут остановлены.

Инструкция PREFETCH*, и вы можете использовать встроенные функции для компилятора, а не прибегать к сборке. Эти встроенные функции называются _mm_prefetch для компиляторов Microsoft и Intel С++ и __builtin_prefetch для GCC. (Если вы закончили использовать это, просто помните, что вам нужен самый низкий уровень предварительной выборки, т.е. T0.)

Обратите внимание, что они входят в реализацию функции touch, которую я использовал выше.

Я не знаю никакой библиотеки, которая делает это многоразовым способом. Кроме того, я не знаком с библиотеками С#, чтобы узнать, доступны ли они там или нет.

Ответ 2

Я думаю, что многообещающая оптимизация будет заключаться в том, чтобы изменить способ обработки данных, гарантируя, что в общем случае индексы находятся в интервале определенного максимального размера (в частности, меньше, чем "несколько ГБ":).

Например, если вы можете настроить вызывающего абонента своей "суммой" так, чтобы он обычно запрашивал сумму элементов за определенный интервал, вы могли сначала отсортировать массив индексов, что значительно улучшит шансы на попадание кеша.