Микро Оптимизация 4-сегментной гистограммы большого массива или списка - PullRequest
1 голос
/ 09 апреля 2020

У меня особый вопрос. Я постараюсь описать это как можно точнее.

Я делаю очень важную «микрооптимизацию». L oop, который работает несколько дней подряд. Так что, если я смогу сократить этот цикл, это займет половину времени. 10 дней сократятся до 5 дней и т. Д. c.

У l oop есть функция: "testbenchmark1".

У меня есть 4 индекса, которые мне нужно увеличить в al oop, как это. Но когда я получаю доступ к индексу из списка, это на самом деле требует дополнительного времени, как я уже заметил. Это то, что я пытаюсь увидеть, если есть другое решение.

indexes[n]++; //increase correct index

Полный код для "testbenchmark1", который занимает 122 мс:

void testbenchmark00()
{
    Random random = new Random();
    List<int> indexers = new List<int>();
    for (int i = 0; i < 9256408; i++)
    {
        indexers.Add(random.Next(0, 4));
    }
    int[] valueLIST = indexers.ToArray();


    Stopwatch stopWatch = new Stopwatch();
    stopWatch.Start();

    int[] indexes = { 0, 0, 0, 0 };
    foreach (int n in valueLIST) //Takes 122 ms
    {
        indexes[n]++; //increase correct index
    }

    stopWatch.Stop();
    MessageBox.Show("stopWatch: " + stopWatch.ElapsedMilliseconds.ToString() + " milliseconds");
}

Теперь приведенный ниже код "testbenchmark2" является только экспериментальным, и я знаю, что он не верен, но мне интересно, есть ли какой-либо симуляционный способ использовать такие виды чисел: "1_00_00_00_00" и можно ли увидеть: " 00_00_00_00 "как четыре разных целых числа. Например, если бы я суммировал: 1_00_00_00_00 + 1_00_01_00_00 = 1_00_01_00_00 , а затем в конце можно было бы извлечь каждое число, каждое из четырех, как это: 00, 01, 00, 00

Но я не знаю, возможно ли это каким-либо образом, даже используя двоичные числа. Да, любое решение. Чтобы просто добавить номера, как это. Так же, как тест, что l oop занял всего 59 мс, что вдвое меньше 122 мс. Так что мне интересно посмотреть, есть ли какая-нибудь идея для этого?

double num3 = 1_00_00_00_00;
double num4 = 1_00_01_00_00;
for (int i = 0; i < valueLIST.Count; i++) //Takes 59 ms
{
    num3 += num4;
}

Полный код для "testbenchmark2", который занимает 59 мс:

void testbenchmark2()
{
    List<String> valueLIST = new List<String>(); 
    for (int i = 0; i < 9256408; i++) //56
    {
        valueLIST.Add(i.ToString());
    }

    //https://www.geeksforgeeks.org/binary-literals-and-digit-separators-in-c-sharp/
    double num3 = 1_00_00_00_00;
    double num4 = 1_00_01_00_00;

    Stopwatch stopWatch = new Stopwatch();
    stopWatch.Start();
    for (int i = 0; i < valueLIST.Count; i++) //Takes 59 ms
    {
        num3 += num4;
    }
    stopWatch.Stop();
    MessageBox.Show("stopWatch: " + stopWatch.ElapsedMilliseconds.ToString() + " milliseconds\n\n" + num3);
}

РЕДАКТИРОВАТЬ
Ниже приведен более чистый код того, что я пытаюсь сделать именно!
Но приведенный ниже код, вероятно, будет правильным или решением, но он показывает, что я пытаюсь сделать, я верю.

        void newtest()
        {
            double num1 = 1_00_00_00_00;
            double num2 = 1_00_01_00_00;
            double num3 = 1_00_01_01_00;

            List<double> testnumbers = new List<double>();
            testnumbers.Add(num1);
            testnumbers.Add(num2);
            testnumbers.Add(num3);

            double SUM = 0;
            for (int i = 0; i < testnumbers.Count; i++)
            {
                SUM += testnumbers[i];
            }

            //The result is
            //300020100

            //Would it possible to extract the "four buckets" that I am interesting in somehow?
            //00_02_01_00
        }

Ответы [ 4 ]

4 голосов
/ 09 апреля 2020

Это должно быть возможно примерно при 8 элементах (1 вектор AVX2) за 2,5 такта или около того (на ядро) на современном x86-64, таком как Skylake или Zen 2, с использованием AVX2. Или за 2 часа с раскруткой. Или на вашем процессоре Piledriver, возможно, 1x 16-байтовый вектор индексов на 3 такта с AVX1 _mm_cmpeq_epi32.

Общая стратегия работает с 2-8 сегментами. И для байтовых, 16-битных или 32-битных элементов. (Таким образом, байтовых элементов дает вам 32 элемента, гистограммированных за 2 такта лучший случай, с небольшим количеством внешних-l oop служебных данных для сбора счетчиков байтов до их переполнения.)

Обновление: или отображая int на 1UL << (array[i]*8), чтобы увеличить один из 4 байтов счетчика с добавлением SIMD / SWAR, мы можем go приблизиться к 1 такту на вектор 8 int на SKL или на 2 такта на Zen2. (Это еще более конкретно: от c до 4 или менее сегментов и ввода int, и не сокращается до SSE2. Для этого требуются переменные сдвиги или, по крайней мере, AVX1 переменные тасовки.) Использование байтовых элементов с первой стратегией вероятно, все еще лучше с точки зрения количества элементов в цикле.

Как указывает @ Jona sH, у вас могут быть разные ядра, работающие над разными частями входного массива. Одно ядро ​​может приблизиться к насыщению пропускной способности памяти на типичных настольных ПК, но у многоядерных Xeon пропускная способность памяти на ядро ​​ниже, а совокупная потребность в них выше, и им нужно больше ядер для насыщения пропускной способности L3 или DRAM. Почему Skylake намного лучше, чем Broadwell-E, для пропускной способности однопоточной памяти?


l oop, которая работает по несколько дней подряд.

В одном списке ввода, который очень и очень медлен для итерации, поэтому он по-прежнему не переполняет счетчики int? Или повторяющиеся вызовы с разными большими списками (например, ваш ~ 900k тестовый массив)?

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

Это возможно потому, что вы тестировали с отключенной оптимизацией Не делай этого, это вообще не имеет смысла; Разный код замедляется на разные суммы, отключая оптимизацию. Более явные шаги и переменные tmp часто могут сделать код в режиме отладки медленнее, потому что есть больше вещей, на которые нужно посмотреть с помощью отладчика. Но они могут просто оптимизироваться до обычного приращения указателя l oop, когда вы компилируете с обычной оптимизацией.

Итерирование по массиву может эффективно компилироваться в asm.

Медленная часть - это зависимость Цепочка сквозной памяти для увеличения индекса переменной массива. Например, в процессоре Skylake адресат памяти add с одним и тем же адресом неоднократно сталкивается с узкими местами примерно с шагом в 6 тактов, поскольку следующий add должен ждать загрузки значения, сохраненного предыдущим. (Переадресация из буфера хранилища означает, что ему не нужно ждать, пока он зафиксирует сначала кэширование, но он все же намного медленнее, чем добавление в регистр.) См. Также руководства по оптимизации Agner Fog: https://agner.org/optimize/

С счетами, распределенными только по 4 сегментам, у вас будет много случаев, когда инструкции ждут перезагрузки данных, сохраненных другой недавней инструкцией, поэтому вы даже не можете достичь почти 1 элемента на тактовый цикл вы могли бы, если бы счетчики были хорошо распределены по большему количеству счетчиков, которые все еще были горячими в кеше L1d.

Одним хорошим решением этой проблемы является развертывание l oop с несколькими массивами счетчиков. Методы векторизации гистограммы в SIMD? . Например, вместо int[] indexes = { 0, 0, 0, 0 }; вы можете сделать его двумерным массивом из четырех счетчиков каждый. Вам придется вручную развернуть l oop в исходном коде, чтобы выполнить итерацию по входному массиву, и обработать последние 0..3 оставшиеся элементы после развернутой части.

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


Использование узких целых чисел для экономии пропускной способности кэша / памяти. Еще одна вещь, которую вы можете / должны сделать, - это использовать как можно более узкий тип для ваших массивов значений 0..3 : каждое число может помещаться в байт, поэтому использование 8-битных целых чисел спасет вас в 4 раза больше занимаемой кэш-памяти / пропускной способности памяти. x86 может эффективно загружать / хранить байты в / из полных регистров. С SSE4.1 у вас также есть SIMD pmovzxbd, чтобы сделать его более эффективным для автоматической векторизации, когда у вас есть byte_array[i], используемый с int_array[i] в al oop. (Когда я говорю x86 Я имею в виду включение x86-64, в отличие от ARM или PowerP C. Конечно, вы на самом деле не хотите компилировать 32-битный код, который Microsoft называет «x86») с очень небольшое количество ведер, например 4 Это похоже на работу для SIMD сравнений. В x86 SSE2 число int элементов на 16-байтовый вектор данных равно вашему количеству гистограмм. У вас уже была идея SIMD, когда вы пытались рассматривать число как четыре отдельных байтовых элемента. См. https://en.wikipedia.org/wiki/SIMD#Software Но 00_01_10_11 - это просто синтаксис исходного уровня для читаемых человеком разделителей в числах, а double - это тип с плавающей точкой, внутреннее представление которого не так же, как для целых чисел. И вы определенно не хотите использовать строки; SIMD позволяет выполнять такие вещи, как одновременная работа с 4 элементами целочисленного массива. Лучший способ, который я вижу, - это подсчитывать совпадения для каждого из 4 значений, а не отображать элементы в счетчики. Мы хотим обрабатывать несколько элементов параллельно, но при сопоставлении их со счетчиками могут возникать коллизии, когда в одном векторе элементов есть повторяющиеся значения. Вам нужно увеличить этот счетчик в два раза. Скалярный эквивалент этого: int counts[4] = {0,0,0,0}; for () { counts[0] += (arr[i] == 0); counts[1] += (arr[i] == 1); counts[2] += (arr[i] == 2); // count matches //counts[3] += (arr[i] == 3); // we assume any that aren't 0..2 are this } counts[3] = size - counts[0] - counts[1] - counts[2]; // calculate count 3 from other counts , что (в C ++) G CC -O3 на самом деле будет автоматически векторизовать в точности так, как я делал ниже : https://godbolt.org/z/UJfzuH. Clang даже развертывает его при автоматической векторизации, поэтому он должен быть лучше , чем моя версия с ручной векторизацией для int входов. Тем не менее, она не так хороша, как альтернативная стратегия vpermilps для этого случая. (И вам все равно нужно вручную векторизовать, если вы хотите, чтобы байтовые элементы имели эффективные узкие суммы, расширяясь только во внешнем l oop.) С байтовыми элементами см. Как рассчитывать вхождения символов с использованием SIMD . Размер элемента слишком узок для счетчика; он переполнится после 256 отсчетов. Таким образом, вы должны расширить либо внутреннюю l oop, либо использовать вложенные циклы для некоторого накопления перед расширением. Я не знаю C#, поэтому я мог бы написать код в сборке x86 или в C ++ с внутренностями. Возможно, C ++ intrinsics более полезен для вас. C# имеет какие-то векторные расширения, которые должны сделать возможным его перенос. Это C ++ для x86-64, использующий встроенные функции AVX2 SIMD. См. { ссылка } для получения дополнительной информации. // Manually vectorized for AVX2, for int element size // Going nearly 4x as fast should be possible for byte element size #include <immintrin.h> void count_elements_avx2(const std::vector<int> &input, unsigned output_counts[4]) { __m256i counts[4] = { _mm256_setzero_si256() }; // 4 vectors of zeroed counters // each vector holds counts for one bucket, to be hsummed at the end size_t size = input.size(); for(size_t i = 0 ; i<size ; i+=8) { // 8x 32-bit elements per vector __m256i v = _mm256_loadu_si256((const __m256i*)&input[i]); // unaligned load of 8 ints for (int val = 0 ; val < 3; val++) { // C++ compilers will unroll this with 3 vector constants and no memory access __m256i match = _mm256_cmpeq_epi32(v, _mm256_set1_epi32(val)); // 0 or all-ones aka -1 counts[val] = _mm256_sub_epi32(counts[val], match); // x -= -1 or 0 conditional increment } } // transpose and sum 4 vectors of 8 elements down to 1 vector of 4 elements __m128i summed_counts = hsum_xpose(counts); // helper function defined in Godbolt link _mm_storeu_si128((__m128i*)output_counts, summed_counts); output_counts[3] = size - output_counts[0] - output_counts[1] - output_counts[2]; // TODO: handle the last size%8 input elements; scalar would be easy } Это прекрасно компилируется с помощью clang (в проводнике компилятора Godbolt ). Предположительно, вы можете написать C#, который компилируется в подобный машинный код. Если нет, подумайте о вызове нативного кода из компилятора C ++ (или написанного от руки в asm, если вы не можете получить действительно оптимальный код из компилятора). Если ваш реальный вариант использования выполняет столько итераций, сколько и ваш тест, это может амортизировать дополнительные издержки, если входной массив не нужно копировать. # from an earlier version of the C++, doing all 4 compares in the inner loop # clang -O3 -march=skylake .LBB0_2: # do { vmovdqu ymm7, ymmword ptr [rcx + 4*rdx] # v = load arr[i + 0..7] vpcmpeqd ymm8, ymm7, ymm3 # compare v == 0 vpsubd ymm4, ymm4, ymm8 # total0 -= cmp_result vpcmpeqd ymm8, ymm7, ymm5 vpsubd ymm2, ymm2, ymm8 vpcmpeqd ymm7, ymm7, ymm6 # compare v == 2 vpsubd ymm1, ymm1, ymm7 # total2 -= cmp_result add rdx, 8 # i += 8 cmp rdx, rax jb .LBB0_2 # }while(i < size) Расчетная производительность Skylake в лучшем случае: ~ 2,5 циклов на вектор (8 int или 32 int8_t) или 2 с развертыванием. Без AVX2, используя только SSE2, у вас были бы дополнительные movdqa инструкции и только 4 элемента по вектору. Это все равно будет победа против скалярной гистограммы в памяти. Даже 1 элемент / тактовая частота хороши и должны выполняться с SSE2, который может работать на любом процессоре x86-64. Если, конечно, нет ошибок в кэше, аппаратная предварительная выборка в L1d опережает l oop , Это может произойти только с данными, уже горячими в кеше L2, по крайней мере. Я также предполагаю, что из-за выравнивания памяти нет остановок; в идеале ваши данные должны быть выровнены на 32 байта. Если это обычно не так, возможно, стоит обработать первую невыровненную часть и затем использовать выровненные нагрузки, если массив достаточно большой. Для байтовых элементов самый внутренний l oop будет выглядеть аналогично (с vpcmpeqb и vpsubb, но будет выполняться не более 255 (не 256) итераций перед суммированием до 64-битных счетчиков, чтобы избежать переполнения. Таким образом, пропускная способность на вектор будет одинаковой , но с 4-кратным числом элементов на вектор. См. https://agner.org/optimize/ и https://uops.info/ для подробного анализа производительности. Например, vpcmpeqd для мопов .info Внутренний l oop - это всего лишь 9 мопов с плавкой областью для Haswell / Skylake, поэтому наилучшее узкое место во внешнем интерфейсе - около 1 итерации на 2,25 цикла (конвейер 4 мегапикселя). Эффекты Small-l oop несколько мешают: Снижается ли производительность при выполнении циклов, у которых число мопов не кратно ширине процессора? - у Skylake есть l oop буфер отключен при обновлении микрокода Для ошибки, но даже до этого 9 мегапикселей l oop выдавали чуть хуже, чем один итер в среднем на 2,25 цикла, скажем, 2,5 цикла. Skylake работает vpsubd на портах 0, 1 или 5, и запускается vpcmpeqd на портах 0 или 1. Таким образом, узкое место на портах 0,1,5 составляет 6 векторов ALU для 3 портов или 1 итерация на 2 цикла. Таким образом, узкое место внешнего интерфейса преобладает. (Более широкий внешний интерфейс Ice Lake может позволить узкому месту на заднем конце даже без развертывания; те же самые внутренние пропускные способности там, если вы не используете AVX512 ...) Если бы clang проиндексировал конец массива и посчитал индекс до нуля (так как он все равно решил использовать режим индексированной адресации), он мог бы сохранить моп в общей сложности 8 мопов = один итер на 2 циклы в переднем конце, соответствующие заднему узкому месту. (В любом случае, скалярный add и слитый макрос cmp/jcc, или ветвь add/jcc l oop могут работать на порту 6, и нагрузка не конкурирует с портами ALU.) Повтор выполнения UUUU ALU зависит от загрузка не должна быть проблемой даже в случае пропадания кэша, если узким местом является ALU-мопы, обычно будет много старых мопов, ожидающих готовности исполнительного модуля, а не ожидающих загрузки данных. Развертывание на 2 будет иметь то же самое преимущество: амортизация, что 2 мопа l oop накладных расходов. Таким образом, 16 мопов для 2 входных векторов. Это довольно многократное значение ширины конвейера в SKL и IceLake и ширины конвейера с одним мопом в Zen. Развертывание еще больше может позволить внешнему интерфейсу опередить выполнение, но с ними даже любые задержки внутреннего интерфейса позволят внешнему интерфейсу создать подушку мопов в планировщике. Это позволит ему выполнять загрузку достаточно рано. Zen2 имеет более широкий интерфейс (6 моп или 5 инструкций, IIU C). Ни одна из этих инструкций не является многопользовательской, потому что Zen2 расширил векторные ALU до 256-битных, так что это 5 однопопулярных команд. vpcmpeq* работает на FP 0,1 или 3, так же как и vpsubd, поэтому узкое место на внутреннем сервере такое же, как на Skylake: 1 вектор на 2 цикла. Но более широкий внешний интерфейс устраняет это узкое место, оставляя критический путь, являющийся внутренним, даже без развертывания. Zen1 принимает 2 мопа на 256-битную векторную операцию (или больше для пересечения полосы, но это просто 2 моп). Таким образом, предположительно, 12/3 = 4 цикла на вектор из 8 или 32 элементов, при условии, что он может эффективно провести эти мопы через интерфейс. Я предполагаю, что зависимость циклов задержки с 1 циклом через счетчик векторы хорошо спланированы бэкэндами и не приводят ко многим потерянным циклам. Вероятно, не имеет большого значения, особенно если у вас есть какие-то узкие места в памяти в реальной жизни. (В Piledriver операции SIMD-integer имеют задержку в 2 цикла, но 6 операций ALU для двух векторных портов ALU, которые могут их запускать, составляют 1 вектор (128 бит) на 3 цикла, поэтому даже без развертывания достаточно работы, чтобы скрыть эту задержку.) Я не анализировал горизонтальную часть этого. Он находится за пределами l oop, поэтому он должен запускаться только один раз за звонок. Вы пометили эту микрооптимизацию, но нам, вероятно, не нужно беспокоиться об этой части. Другие числа сегментов

Базовый вариант этой стратегии - 2 сегмента: считать совпадения с одной стороны, count_other = size - count.

Мы знаем, что каждый элемент является одной из этих 4 возможностей, поэтому мы можем предположить, что любой x, который не равен 0, 1 или 2, 3 без проверки. Это означает, что нам не нужно считать совпадения для 3 на всех , и мы можем получить счет для этого сегмента из size - sum(counts[0..2]).

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


AVX512 на Skylake-Xeon

для 64 -байтовых векторов нет vpcmpeqd, чтобы сделать вектор из всех нулевых (0) или всех один (-1) элементов. Вместо этого вы бы сравнили его с регистром маски и использовали бы его для добавления маски с слиянием set1(1). Как и c = _mm512_mask_add_epi32(c, _mm512_set1_epi32(1)).

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


Случайный просмотр кода: в вашем первом тесте:

int[] valueLIST = indexers.ToArray();

Это кажется бессмысленным; Согласно документам MS (https://docs.microsoft.com/en-us/dotnet/standard/collections/), список эффективно индексируется. Я думаю, что это эквивалентно C ++ std::vector<T>. Вы можете просто повторить его, не копируя в массив.


Альтернативная стратегия - сопоставить 0..3 с набором битов в одном байте типа int

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

Но, говоря о том, может быть, стоит использовать 2x _mm256_packs_epi32 (vpackssdw) и _mm256_packs_epi16 (vpacksswb), чтобы сузить до 8-битные целые числа перед счетом с 3x pcmpeqb / psubb. Это стоит 3 мопа на 4 входных вектора, чтобы упаковать до 1 с байтовыми элементами.

Но если ваш вход имеет начальные элементы int, это может быть лучше, чем упаковывать и затем сравнивать 3 способа.

У вас есть 4 сегмента, а int имеет 4 байта. Если мы сможем преобразовать каждый int элемент в 1 в нижней части соответствующего байта, это позволило бы добавить с _mm256_add_epi8 до 255 внутренних l-1332 * итераций перед расширением до 64-битных счетчиков. (Со стандартным _mm256_sad_epu8 против нулевого трюка до байтов без знака hsum без переполнения.)

Есть 2 способа сделать это. Первый: использует тасование в качестве справочной таблицы. AVX2 vpermd работает (_mm256_permutexvar_epi32), используя данные в качестве вектора индекса и константу _mm256_set_epi32(0,0,0,0, 1UL<<24, 1UL<<16, 1UL<<8, 1UL<<0) в качестве перетасовываемых данных. Или наберите вектор, чтобы использовать AVX1 vpermilps в качестве LUT с вектором LUT, имеющим эти байты также в верхней половине.

vpermilps лучше: меньше мопов на AMD Zen 1, и более низкая задержка везде, потому что это на линии (Может вызывать задержку обхода на некоторых процессорах, снижая преимущество задержки, но все же не хуже, чем vpermd).

По некоторым причинам vpermilps с векторным управлением имеет пропускную способность 2 цикла на Zen2, хотя это все еще один моп. Или 4 цикла на Zen1 (для версии 2 UOP YMM). Это 1 цикл на Intel. vpermd еще хуже для AMD: больше мопов и такая же низкая пропускная способность.

vpermilps xmm (16-байтовый вектор) на Piledriver имеет 1 / тактовую пропускную способность в соответствии с тестированием Agner Fog и работает в режиме "ive" c "домен. (Таким образом, на самом деле он имеет дополнительную задержку обхода задержки, когда используется с «предполагаемыми» операндами с плавающей запятой, но не с целым числом).

   // Or for Piledriver, __m128 version of this

    __m256 bytepatterns = _mm256_casts256_ps(_mm256_set_epi32(
         1<<24, 1<<16, 1<<8, 1<<0,
         1<<24, 1<<16, 1<<8, 1<<0) );
    __m256i v = _mm256_loadu_si256((const __m256i*)&input[i]);
    v = _mm256_castps_si256(_mm256_permutevar_ps(bytepatterns, v));  // vpermilps 32-bit variable shuffle
    counts = _mm256_add_epi8(counts, v);

     // after some inner iterations, separate out the 
     // set1_epi32(0x000000ff) counts, 0x0000ff00 counts, etc.

Это создаст чередующиеся счетчики внутри каждого элемента int. Они переполнятся, если вы не накопите их до 256 отсчетов. См. Как подсчитывать вхождения символов с помощью SIMD для простой версии этого с одним счетчиком.

Здесь мы можем развернуть и использовать 2 разных вектора LUT, поэтому, когда мы хотим сгруппировать все значения для 0 вместе мы могли бы смешать 2 вектора вместе и маскировать остальные.


В качестве альтернативы тасованию мы можем сделать это с переменными сдвигами AVX2.

sums += 1UL << (array[i]*8);, где *8 - это число бит в байте, также выполненное со сдвигом , Я написал его как скалярное выражение C ++, потому что теперь у вас есть шанс увидеть, как ваша идея байтов в целом числе может действительно работать. Пока мы не допускаем переполнения отдельных байтов, не имеет значения, добавляет ли байты SIMD перенос блока между байтами или если мы используем 32-битные элементы dword.

Мы сделали бы это с AVX2 как :

__m256i v = loadu...();
v = _mm256_slli_epi32(v, 3);  // v *= 8
v = _mm256_sllv_epi32(_mm256_set1_epi32(1), v);
counts = _mm256_add_epi8(counts, v);

Это 2 сменные инструкции плюс vpaddb. На Skylake сдвиги с переменным числом vpsllvd дешевы: однопроцессные и работают на нескольких портах. Но на Haswell и Zen это медленнее. (Та же пропускная способность, что и vpermilps для AMD)

И 2 мопа для 2 портов по-прежнему не превышают 1 моп для 1 порта для версии с произвольным воспроизведением. (Если только вы не используете обе стратегии поочередно для распределения работы по всем портам ALU в SKL.)

Так или иначе, самый внутренний l oop может go 1 вектор за такт или, может быть, немного лучше с осторожным чередованием методов сдвига и тасования.

Но это потребует некоторого небольшого количества накладных расходов, амортизируемых по 128 или 255 внутренним итерациям l oop.

Эта очистка в конец мог бы смешать 2 вектора вместе, чтобы получить вектор со счетчиками всего для 2 блоков, а затем vpshufb (_mm256_shuffle_epi8) сгруппировать счетчики байтов для одного и того же блока в одни и те же слова. Тогда vpsadbw (_mm256_sad_epu8) против нуля может горизонтально суммировать эти байтовые элементы в каждом qword для _mm256_add_epi64. Таким образом, external-l oop работа должна быть 2 vpblendw, 2x vpshufb, 2x vpsadbw, 2x vpaddq, а затем вернуться к другим 255 итерациям внутреннего l oop. Возможно, также проверяется, что вы находитесь в пределах 255 итераций от конца массива, чтобы установить границу l oop для внутренней итерации.

2 голосов
/ 09 апреля 2020

Как упоминал Питер Кордес, вы можете использовать SIMD для одновременного сложения нескольких значений, см. vector . Но мне не ясно, поможет ли это на самом деле.

Редактировать: Если вы работаете. Net core, есть также SIMD intrinstics , который обеспечивает доступ к оборудованию более низкого уровня.

Как упомянуто NerualHandle, может быть лучше использовать for-l oop, чем foreach. Но когда я проверяю это, то, кажется, нет существенной разницы. Я бы предположил, что компилятор может оптимизировать foreach в этом конкретном случае.

Когда я запускаю ваш код testbenchmark00, он завершается за ~ 6 мс на моем компьютере. Некоторые приблизительные расчеты показывают, что каждая итерация l oop занимает около 0,78 нс, или около 2-4 тактов процессора, что, по-видимому, близко к оптимальному. Кажется странным, что это занимает ~ 20 раз дольше для вас. Вы работаете в режиме релиза?

Вы можете распараллелить проблему. Разделите массив индексаторов на несколько частей, и создайте историческую диаграмму для каждой части в разных потоках, и суммируйте историческую диаграмму для каждого потока в конце. См. Parallel.For , поскольку это может сделать разделение et c за вас, но для этого требуется использование localInit и localFinally, чтобы каждый поток записывал в отдельные гистограммы во избежание проблем параллелизма.

Как всегда при оптимизации производительности, рекомендуемый порядок действий:

  1. Код профиля для определения проблемных областей
  2. Поиск алгоритмов c улучшения
  3. Просмотр о способах выполнения меньшего количества работы, таких как кэширование
  4. Ускорение существующей работы
1 голос
/ 12 апреля 2020

Я попытался переписать код для Vector128<byte> и придумал этот код.

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

Я создал 3 цикла, в которых существует внутреннее значение oop 16x16 = 256, чтобы не создавать переполнения для byte. Тогда «externall oop» имеет точный счет, который рассчитывается из ранее, чтобы поддерживать это.

После этих 3 циклов. Остальные, которые меньше 16 * 16 итераций, суммируются в своих собственных l oop.

Когда я запускал тест между: normalCalculation и CountElements, CountElements SIMD подход примерно в 7,2 раза быстрее.

    void calc()
    { 
        //Create 16 indexes with numbers between: 0-3. The goal is to count how many of those occurences we have for the numbers: 0-3
        int times = 6250;
        int bytes = times * 16;
        byte[] v1 = new byte[bytes];
        for (int i = 0; i < times; i++)
        {
            v1[0 + (i * 16)] = 0;
            v1[1 + (i * 16)] = 1;
            v1[2 + (i * 16)] = 2;
            v1[3 + (i * 16)] = 3;

            v1[4 + (i * 16)] = 1;
            v1[5 + (i * 16)] = 1;
            v1[6 + (i * 16)] = 1;
            v1[7 + (i * 16)] = 1;

            v1[8 + (i * 16)] = 1;
            v1[9 + (i * 16)] = 0;
            v1[10 + (i * 16)] = 0;
            v1[11 + (i * 16)] = 3;

            v1[12 + (i * 16)] = 1;
            v1[13 + (i * 16)] = 1;
            v1[14 + (i * 16)] = 1;
            v1[15 + (i * 16)] = 3;
        }
        /*---------------*/

        ReadOnlySpan<byte> input = v1;

        //Call function
        //normalCalculation(input);
        CountElements(input);
    }

    void normalCalculation(ReadOnlySpan<byte> inputArray)
    {
        int[] countArray0 = new int[4];
        for (int i = 0; i < inputArray.Length; i++)
        {
            countArray0[inputArray[i]]++;
        }

    }
    private unsafe static int[] CountElements(ReadOnlySpan<byte> inputArray)
    {

        //100000 indexes (This SIMD code goes 7.2 times faster than normal C# code)
        double[] countArray = new double[4];
        double arraylength = inputArray.Length; int loops = Convert.ToInt32(arraylength);
        double loopcount = arraylength / 3840; //100000 / 240 * 16 = 26.04
        double indexesToSumFirst = loopcount - Math.Floor(loopcount); //26.04 - 26 = 0.04
        indexesToSumFirst = indexesToSumFirst * 3840; //Num of indexes to be SUMMED first
        loopcount = arraylength - indexesToSumFirst; //100000 - 153.6 = 99846.4
        int outerloop = Convert.ToInt32(loopcount / 3840); //24

        //Sum the first indexes first. So the loops after those are exactly counts of: x16
        int index = Convert.ToInt32(indexesToSumFirst);
        if (index > 0)
        {
            for (int t = 0; t < index; t++)
            {
                countArray[inputArray[t]]++;
            }
        }

        //Below starts the SIMD calculations!
        Span<Vector128<byte>> counts = stackalloc Vector128<byte>[3];
        Span<Vector128<UInt64>> sum64 = stackalloc Vector128<UInt64>[3];
        unsafe
        {
            fixed (byte* fixedInput = inputArray)
            {
                for (int i = 0; i < outerloop; i++)
                {
                    counts.Clear();
                    for (int i2 = 0; i2 < 240; i2++)
                    {
                        var v = Avx.LoadVector128(&fixedInput[index]);
                        for (byte val = 0; val < 3; val++)
                        {
                            var match = Avx.CompareEqual(v, Vector128.Create(val)); //[1,0,0,0,1,0,1,0,0,0,0,0,0,0,0,0] == [1,0,0,0,1,0,1,0,0,0,0,0,0,0,0,0]
                            counts[val] = Avx.Subtract(counts[val], match);
                        }
                        index += 16;
                    }
                    //Here sum
                    for (int i3 = 0; i3 < 3; i3++)
                    {
                        //SumAbsoluteDifferences
                        sum64[i3] = Sse2.Add(sum64[i3], Sse2.SumAbsoluteDifferences(counts[i3], Vector128<byte>.Zero).AsUInt64()); //sum64: <2,0,0,0,3,0,0,0>
                    }
                }

                //UnpackHigh and get the lower element from the Vector128<UInt64>
                if (outerloop > 0)
                {
                    for (int i3 = 0; i3 < 3; i3++)
                    {
                        Vector128<UInt64> upper = Sse2.UnpackHigh(sum64[i3], sum64[i3]).AsUInt64(); //3
                        countArray[i3] += Sse2.Add(sum64[i3], upper).ToScalar();
                    }
                }
                //Calculate the last index
                countArray[3] = loops - countArray[0] - countArray[1] - countArray[2];
            }
        }

        var outputCounts = new int[4];
        return outputCounts;
    }
1 голос
/ 11 апреля 2020

Это непроверенная C# версия ответа @PeterCordes.

private static Vector128<int> HsumTranspose( ReadOnlySpan<Vector256<int>> counts )
{
    var sum01 = Avx2.HorizontalAdd( counts[ 0 ], counts[ 1 ] );
    var sum23 = Avx2.HorizontalAdd( counts[ 2 ], counts[ 3 ] );
    var sum0123 = Avx2.HorizontalAdd( sum01, sum23 );

    var sumHigh = Avx2.ExtractVector128( sum0123, 1 );
    var sumLow = Avx2.ExtractVector128( sum0123, 0 );
    return Sse2.Add( sumHigh, sumLow );
}


private unsafe static int[ ] CountElements( ReadOnlySpan<int> input )
{
    var outputCounts = new int[ 4 ];
    // Four vectors of zeroed counters each vector holds
    // counts for one bucket, to be hsummed at the end.
    Span<Vector256<int>> counts = stackalloc Vector256<int>[ 4 ]
    {
        Vector256<int>.Zero,
        Vector256<int>.Zero,
        Vector256<int>.Zero,
        Vector256<int>.Zero
    };

    unsafe
    {
        fixed ( int* fixedInput = input )
        {
            var size = input.Length;
            for ( var i = 0; i < size; i += 8 )
            {
                var v = Avx.LoadVector256( &fixedInput[ i ] );
                for ( var val = 0; val < 3; val++ )
                {
                    var match = Avx2.CompareEqual( v, Vector256.Create( val ) );
                    counts[ val ] = Avx2.Subtract( counts[ val ], match );
                }
             }

             Vector128<int> summedCounts = HsumTranspose( counts );

             fixed ( int* fixedOutputCounts = outputCounts )
                 Sse2.Store( fixedOutputCounts, summedCounts );

             outputCounts[ 3 ] = size - outputCounts[ 0 ] -
                 outputCounts[ 1 ] - outputCounts[ 2 ];

             // TODO: handle the last size%8 input elements; scalar would be easy
            }                
        }            
    }
    return outputCounts;
}
Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...