Кэш-дружественное копирование массива с перестройкой по известному индексу, сбор, разброс

Предположим у нас есть массив данных и другой массив с индексами.

data = [1, 2, 3, 4, 5, 7]
index = [5, 1, 4, 0, 2, 3]

мы хотим создать новый массив из элементов data на должность от index. Результат должен быть

[4, 2, 5, 7, 3, 1]

наивный алгоритм работает для O (N), но он выполняет случайный доступ к памяти.

можете ли вы предложить дружественный алгоритм кэша процессора с той же сложностью.

PS В моем случае все элементы в массиве данных являются целыми числами.

PPS Матрицы может содержать миллионы элементов.

PPPS я в порядке с SSE / AVX или любыми другими оптимизациями x64

7 ответов


объединить индекс и данные в один массив. Затем используйте некоторый удобный для кэша алгоритм сортировки для сортировки этих пар (по индексу). Тогда избавьтесь от индексов. (Вы можете объединить слияние / удаление индексов с первым / последним проходом алгоритма сортировки, чтобы немного оптимизировать это).

для кэш-дружественных O (N) сортировки используйте сортировку radix с достаточно малым radix (не более половины количества строк кэша в кэше CPU).

вот реализация c radix-sort-like алгоритм:

void reorder2(const unsigned size)
{
    const unsigned min_bucket = size / kRadix;
    const unsigned large_buckets = size % kRadix;
    g_counters[0] = 0;

    for (unsigned i = 1; i <= large_buckets; ++i)
        g_counters[i] = g_counters[i - 1] + min_bucket + 1;

    for (unsigned i = large_buckets + 1; i < kRadix; ++i)
        g_counters[i] = g_counters[i - 1] + min_bucket;

    for (unsigned i = 0; i < size; ++i)
    {
        const unsigned dst = g_counters[g_index[i] % kRadix]++;
        g_sort[dst].index = g_index[i] / kRadix;
        g_sort[dst].value = g_input[i];
        __builtin_prefetch(&g_sort[dst + 1].value, 1);
    }

    g_counters[0] = 0;

    for (unsigned i = 1; i < (size + kRadix - 1) / kRadix; ++i)
        g_counters[i] = g_counters[i - 1] + kRadix;

    for (unsigned i = 0; i < size; ++i)
    {
        const unsigned dst = g_counters[g_sort[i].index]++;
        g_output[dst] = g_sort[i].value;
        __builtin_prefetch(&g_output[dst + 1], 1);
    }
}

он отличается от сортировки radix в двух аспектах: (1) он не делает подсчет проходов, потому что все счетчики известны заранее; (2) он избегает использования значений power-of-2 для radix.

этот код C++ использовался для бенчмаркинга (если вы хотите запустить его на 32-битной системе, слегка уменьшить kMaxSize константы).

вот результаты тестов (на процессоре Haswell с 6Mb cache):

benchmark results

легко видеть, что небольшие массивы (ниже ~2 000 000 элементов) кэширование, даже для наивного алгоритма. Также вы можете заметить, что подход сортировки начинает быть кеш-недружественным в последней точке диаграммы (с size/radix около 0.75 строк кэша в кэше L3). Между этими пределами сортировка более эффективна, чем наивный алгоритм.

теоретически (если мы сравним только пропускную способность памяти, необходимую для этих алгоритмы с 64-байтовыми линиями кэша и 4-байтовыми значениями) алгоритм сортировки должен быть в 3 раза быстрее. На практике мы имеем гораздо меньшую разницу, около 20%. Это может быть улучшено, если мы используем меньшие 16-битные значения для data массив (в этом случае алгоритм сортировки примерно в 1,5 раза быстрее).

еще одна проблема с подходом сортировки-его наихудшее поведение, когда size/radix находится слишком близко к какой-питания-в-2. Это может быть либо проигнорировано (потому что не так много "плохих" размеров), либо исправлено делает этот алгоритм немного сложнее.

если мы увеличим количество проходов до 3, Все 3 прохода используют в основном кэш L1, но пропускная способность памяти увеличивается на 60%. Я использовал этот код для получения экспериментальных результатов:TL; DR. После определения (экспериментально) лучшего значения radix я получил несколько лучшие результаты для размеров больше 4 000 000 (где алгоритм 2-pass использует кэш L3 для одного прохода), но несколько худшие результаты для меньших массивов (где алгоритм 2-pass использует Кэша L2 для обоих проходов). Как и следовало ожидать, производительность лучше для 16-битных данных.

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


data = [1, 2, 3, 4, 5, 7]

index = [5, 1, 4, 0, 2, 3]

мы хотим создать новый массив из элементов данных на позициях от индекс. Результат должен быть

result -> [4, 2, 5, 7, 3, 1]

один поток, один проход

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

и data и index доступны (читай) последовательно, что уже оптимально для кэша CPU. Это оставляет случайную запись, но запись в память не так удобна для кэша, как чтение из нее.

для этого понадобится только один последовательный pass через данные и индекс. И, скорее всего, некоторые (иногда многие) из них уже будут кэш-friendly тоже.

использование нескольких блоков для result - несколько потоков

мы могли бы выделить или использовать кэш-дружественные блоки размера для результата (блоки, являющиеся регионами в result array), и петля через index и data несколько раз (пока они остаются в кэше).

в каждом цикле мы затем пишем только элементы в result это вписывается в текущий результат-блок. Это было бы "дружественным к кэшу" для записи тоже, но требует нескольких циклов (количество циклов может даже получить довольно высокое-т. е. size of data / size of result-block).

вышеуказанное может быть вариантом когда используя несколько потоков: data и index, будучи только для чтения, будет совместно использоваться всеми ядрами на некотором уровне в кэше (в зависимости от архитектуры кэша). The result блоки в каждом потоке будут полностью независимыми (одному ядру никогда не придется ждать результата другого ядра или записи в том же регионе). Например: 10 миллионов элементов - каждый поток может работать над независимым результирующим блоком, скажем, 500.000 элементов (число должно быть 2).


объединение значений в пару и сортировка их сначала: это уже займет гораздо больше времени, чем наивный вариант (и также не будет дружественным к кэшу).

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


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

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

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


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

def populate(index,data,newArray,cache)
    blockSize = 1000
    for i = 0; i < size(index); i++
        //We cached this value earlier
        if i in cache
            newArray[i] = cache[i]
            remove(cache,i)
        else
            newIndex = index[i]
            newValue = data[i]
            //Check if this index is in our block
            if i%blockSize != newIndex%blockSize
                //This index is not in our current block, cache it
                cache[newIndex] = newValue
            else
                //This value is in our current block
                newArray[newIndex] = newValue

cache = {}
newArray = []
populate(index,data,newArray,cache)
populate(index,data,newArray,cache)

анализ

наивное решение обращается к индексу и массива данных в порядке, но новый массив осуществляется в произвольном порядке. Поскольку новый массив доступен случайным образом, вы в конечном итоге получаете O (N^2), где N-количество блоков в массиве.

решение на основе блока не переходит из блока в блок. Он считывает индекс, данные и новый массив последовательно для чтения и записи в одни и те же блоки. Если индекс будет находиться в другом блоке, он кэшируется и либо извлекается, когда появляется блок, к которому он принадлежит, либо если блок уже передан, он будет извлечен в второй пас. Второй проход совсем не повредит. Это O (N).

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

давайте представим, что вся информация внутри кэша существует в одном блоке и это вписывается в память. И скажем, что кэш имеет y элементов. Наивный подход имел бы случайный доступ по крайней мере y раз. Блок на основе подхода получит те, во втором проходе.


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

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

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

Если вас интересует этот ответ Я могу разработать псевдокод.


Я обеспокоен тем, что это может быть не выигрышный шаблон.

У нас был фрагмент кода, который работал хорошо, и мы оптимизировали его, удалив копию.

в результате он работал плохо (из-за проблем с кэшем). Я не вижу, как вы можете создать алгоритм с одним проходом, который решает проблему. Использование OpenMP может позволить киоскам, которые это приведет к разделению между несколькими потоками.


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

Я написал следующую программу, чтобы фактически проверить, помогает ли простое разделение цели на N блоков, и мой вывод был:

a) даже в худших случаях было невозможно, чтобы производительность одного потока (с использованием сегментированных записей) не превышала наивную стратегия, и обычно хуже, по крайней мере, в 2 раза

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

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

#include <stdlib.h>
#include <stdio.h>
#include <sys/time.h>


void main(char **ARGS,int ARGC) {
int N=1<<26;

double* source = malloc(N*sizeof(double)); 
double* target = malloc(N*sizeof(double)); 
int* idx = malloc(N*sizeof(double)); 
int i;
for(i=0;i<N;i++) {
source[i]=i;
target[i]=0;
idx[i] = rand() % N ;
};

struct timeval now,then;
gettimeofday(&now,NULL);
for(i=0;i<N;i++) {
target[idx[i]]=source[i];
};
gettimeofday(&then,NULL);
printf("%f\n",(0.0+then.tv_sec*1e6+then.tv_usec-now.tv_sec*1e6-now.tv_usec)/N);


gettimeofday(&now,NULL);
int j;
int targetblocks;
int M = 24;
int targetblocksize = 1<<M;
targetblocks = (N/targetblocksize);
for(i=0;i<N;i++) {
for(j=0;j<targetblocks;j++) {
int k = idx[i];
if ((k>>M) == j) { 
target[k]=source[i];
};
};
};
gettimeofday(&then,NULL);
printf("%d,%f\n",targetblocks,(0.0+then.tv_sec*1e6+then.tv_usec-now.tv_sec*1e6-now.tv_usec)/N);


};