Как наиболее эффективно сохранить часть __m128i / __ m256i, игнорируя при этом некоторое количество элементов с начала / конца

Мой процессор Intel 9700K.

У меня есть __m128i или __m256i, содержащие char, short или int. Мне нужно написать store функцию, которая игнорирует заданное количество элементов с начала, с конца или одновременно с начала и с конца.

Для ints и выше я использую _mm_maskstore_epi32, и хотя я хотел бы улучшить его производительность, это не так уж плохо.

Однако для меньших типов я изначально использовал _mm_maskmoveu_si128, и он очень медленный - заменив его на short первым кодом, который я попробовал: использование _mm_maskstore_epi32 + сохранение 1 короткого замыкания в скаляре с бранчем привело к увеличению производительности в 10 раз.

Итак, мой вопрос:

  1. Я сомневаюсь, что я первый, кому это нужно - может быть, есть известный способ сделать это?
  2. _mm_maskstore_epi32 принимает int*. Требуется ли, чтобы этот int* был выровнен по 4 байтам? Может быть, требуется, чтобы он был выровнен по 16 байтам (32 для 256-битного регистра)? В Интернете это не совсем понятно.

Меня больше интересуют 256-битные регистры, а не 128-битные.

UPD: Я использую только маски на границах своего массива. Дело в том, что это полностью доминировало в моей производительности даже на массивах размером 1 КБ (просмотр 1 КБ данных и вычисление значений было менее важным, чем то, как я обрабатываю хранилища по бокам). Я попробовал еще более простую альтернативу - просто вызвать memcpy для не игнорируемых элементов - и это быстрее, чем мои умные mask_store хаки (вероятно, потому, что мне не нужно готовить маску для mask_store). Мне, вероятно, понадобится что-то вроде специализированного memcpy для менее 32 байтов данных.


person Denis Yaroshevskiy    schedule 03.06.2020    source источник
comment
Можете ли вы перезаписать память, используя уже существующие значения (например, load - ›blend -› store)? Вы знаете во время компиляции, сколько элементов вам нужно сохранить? А вас волнует пропускная способность, задержка, ...?   -  person chtz    schedule 04.06.2020
comment
Хорошей аппаратной поддержки для маскировки узких элементов нет до AVX512BW (Skylake Xeon) с собственным маскированием для каждой инструкции, включая vmovdqu8. А пока вы можете проверить маску на наличие одинаковых пар short элементов, поэтому epi32 будет работать, в противном случае я думаю, вам придется перебирать вектор и делать узкие скалярные хранилища. Или что чтз сказал: вектор смешивается со старым содержимым памяти. Вероятно, это будет лучше, чем что-то проверять с битами маски.   -  person Peter Cordes    schedule 04.06.2020
comment
@PeterCordes, @chtz - Да, я уже использую маски только на границах своего массива. Пробовал базовый memcpy - это лучше моего умного решения. Дело в том, что даже на 1К данных у меня полностью преобладают хранилища масок на стороне. Я пробовал базовый memcpy, он работает лучше, чем мои умные хаки. Хотя, наверное, есть хаки получше.   -  person Denis Yaroshevskiy    schedule 04.06.2020
comment
Если вам нужно загрузить или сохранить какие-либо байты в 32-байтовой области внутри одной строки кэша, наиболее эффективно просто загрузить или сохранить их все в векторном хранилище SIMD. Оборудование такое широкое (в Haswell и более поздних версиях); сделать магазин замаскированным - значит сохранить часть того, что было раньше. Если вам не нужно этого делать, не делайте этого!   -  person Peter Cordes    schedule 04.06.2020
comment
@PeterCordes Я знаю. Я пишу общий алгоритм, который должен работать с произвольным массивом. Я не знаю, что находится слева или справа от моего массива.   -  person Denis Yaroshevskiy    schedule 04.06.2020
comment
О, так вы хотите, чтобы это было в конце небольшой копии массива, достаточно маленького размера, чтобы избежать накладных расходов на вызов memcpy? Не для маскировки произвольных элементов посередине? Обычно лучшая стратегия - выполнить векторную загрузку, которая заканчивается в конце исходного массива, и сохранить ее в соответствующем месте в месте назначения. Это нормально, что он может перекрывать последнее полное векторное хранилище; буфер хранилища / кеш L1d может поглотить это без проблем. ЦП с AVX также имеют эффективные невыровненные нагрузки / хранилища.   -  person Peter Cordes    schedule 04.06.2020
comment
Связанный: Векторизация с невыровненными буферами: использование VMASKMOVPS: создание маски из счетчика несовпадений? Или вообще не использовать этот insn. Если ваши копии на самом деле 1 КБ, просто позвоните memcpy, по крайней мере, если вы работаете в такой системе, как GNU / Linux, где memcpy использует AVX в системах, которые его поддерживают. glibc memcpy очень хорошо оптимизирован для больших копий, включая обработку начала и конца копии. И да, _mm_maskmoveu_si128 имеет подсказку NT (удаляется из кеша), так что она вам определенно не нужна.   -  person Peter Cordes    schedule 04.06.2020
comment
@PeterCordes, а не memcpy, я пишу inclusive_scan, и мне нужно обрабатывать все возможные размеры массива, в частности, можно получить массив с размером меньше моего векторного. Я могу смешаться с предыдущим массивом, который я сохранил, но это означает больше особых случаев и больше раздуваемого кода.   -  person Denis Yaroshevskiy    schedule 04.06.2020
comment
memcpy уже имеет это раздувание, чтобы быстро делать маленькие копии, а также большие копии, если предсказание ветвления предсказывает правильно. Я до сих пор не понимаю, может ли ваша настоящая проблема просто вызвать memcpy, или вам нужно избежать этого по какой-то причине.   -  person Peter Cordes    schedule 04.06.2020
comment
@PeterCordes - memcpy для char / short - лучшее решение, которое у меня есть. Он медленнее, чем maskstore для целых чисел, и все равно медленнее, чем мне бы хотелось. Думаю, у меня получится лучше.   -  person Denis Yaroshevskiy    schedule 04.06.2020
comment
@PeterCordes - знаете ли вы, требует ли _mm_maskstore_epi32 4-байтового выравнивания?   -  person Denis Yaroshevskiy    schedule 05.06.2020
comment
@DenisYaroshevskiy: Не требует выравнивания. Команды SIMD требуют либо полного выравнивания, либо его отсутствия, не по размеру элемента. В разделе исключений на felixcloutier.com/x86/vmaskmov не упоминаются исключения, связанные с выравниванием. . В нем что-то упоминается о поведении с установленным флагом AC, но вы можете предположить, что это не так. В противном случае простой скалярный несогласованный доступ приведет к ошибке, поэтому включение AC неприменимо для обычного кода, сгенерированного компилятором.   -  person Peter Cordes    schedule 05.06.2020
comment
@PeterCordes - к сожалению, не помогло. Я разместил все числа ниже, если вам интересно.   -  person Denis Yaroshevskiy    schedule 06.06.2020
comment
@PeterCordes здесь измерил различные подходы: stackoverflow.com/a/62492369/5021064, если вам интересно   -  person Denis Yaroshevskiy    schedule 21.06.2020


Ответы (3)


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

Откуда возникла проблема.

Я искал, как реализовать инклюзивное сканирование на месте поверх AVX2. Расширения SIMD. Мое решение полностью основано на: @Zboson ответе.

  [a      b           c               d        ]
+ [0      a           b               c        ]
= [a   (a + b)     (b + c)         (c + d)     ]
+ [0      0           a            (a + b)     ]
= [a   (a + b)   (a + b + c)   (a + b + c + d) ]

Каждый алгоритм диапазона, который я реализовал ранее, хорошо работал со следующим шаблоном итерации (код sudo):

auto aligned_f = previous_aligned_address(f);
auto aligned_l = previous_aligned_address(l);
ignore_first_n ignore_first{f - aligned_f};

if (aligned_f != aligned_l) {
   step(aligned_f, ignore_first);  // Do a simd step, ignoring everything 
                                   // between aligned_f and f.
   aligned_f += register_width;
   ignore_first = ignore_first_n{0};

   // Big unrolled loop.
   main_loop(aligned_f, aligned_l);

   if (aligned_f == aligned_l) return;
}

ignore_last_n ignore_last {aligned_l + register_width - l};
ignore_first_last ignore = combine(ignore_first, ignore_last);

// Do a simd step, ignoring everything between aligned_l and l.
// + handle the case when register is bigger than the array size.
step(aligned_l, ignore);

(Если вы не знаете, почему это нормально - см.).

Как упоминалось в @PeterCordes и @PaulR, если вы измените шаблон итерации - смешайте некоторые другие значения и сделайте простое невыровненное хранилище, и это, вероятно, то, что мне придется сделать. Тогда вы можете сделать не более одного истинно замаскированного хранилища - только когда регистр не подходит полностью.

Однако это больше сгенерировано сборкой, и я не был уверен, реализовал ли я store(address, register, ignore) наиболее эффективным способом - отсюда и был мой вопрос.

ОБНОВЛЕНИЕ: попробовал это, даже ничего не смешивая, вы можете просто сначала загрузить 2 перекрывающихся регистра, а затем сохранить их обратно. Все стало немного хуже. Это не кажется хорошей идеей, по крайней мере, для инклюзивного сканирования.

Измерения

Достаточно быстрый я определил как «превзойти скалярную версию на 40 байтах данных» - 40 символов, 20 кратких и 10 целых чисел. Вы могли заметить, что 40 байт больше размера регистра, поэтому мне пришлось бы добавить еще меньшее измерение для более сложного шаблона итераций.

Я показываю измерения для 2 случаев ‹256, 1> - использовать 256-битные регистры, без разворачивания,‹ 256, 2> - дважды развернуть основной цикл.

ПРИМЕЧАНИЕ. В тестах я учитываю возможные проблемы с выравниванием кода, выравнивая код тестирования 64 различными способами и выбирая минимальное значение.

_mm_maskmoveu_si128

Первоначально я выбрал _mm256_maskstore для sizeof(T) >= 4 и 2 _mm_maskmoveu_si128 для остальных.

_ mm_maskmoveu_si128 benchmarks

Это, как вы можете видеть, - выполнено очень плохо - для char мы проигрываем скалярному коду примерно 10 раз, примерно 20 раз для short и 2 раза для int.

Используйте memcpy для char и short

Я пробовал несколько разных вещей: использовать _mm256_maskstore для short, memcpy для int, написать собственный встроенный memcpy для моего случая. Лучшее, что я получил: memcpy за char и short и maskstore за int.

memcpy / maskstore benchmark

Это выигрыш для char, разница в пару наносекунд между использованием без развертывания и двойным развертыванием, около 30% потерь для short и 50% потерь для int.

Итак, по крайней мере, с моей реализацией store(ptr, reg, ignore) мне нужно сделать другой шаблон итераций, если я не хочу очищать циклы.

Объявление для store(addr, reg, ignore)

ПРИМЕЧАНИЕ: я удалил оболочки и адаптеры, возможно, добавил несколько ошибок.

// Only showing one ignore_broadcast, they are very similar and
// are actually generated with templates
template <register_256 Register, std::same<int> T>
inline __m256i ignore_broadcast(ignore_first_n ignore) {
     __m256i idxs = _mm256_set_epi32(7, 6, 5, 4, 3, 2, 1, 0);
     __m256i n_broadcasted = _mm256_set1_epi32(ignore.n - 1);
     return _mm256_cmpgt_epi32(idxs, n_broadcasted);
}

template <template Register, typename T, typename Ignore>
void store(Register reg, T* ptr, Ignore ignore) {
    if constexpr (sizeof(T) >= 4) {
        const auto mask = ignore_broadcast<Register, T>(ignore);
        _store::maskstore(ptr, mask, reg);
        return;
    }

    std::size_t start = 0, n = sizeof(reg) / sizeof(T);
    if constexpr (std::is_same_v<Ignore, ignore_first_n>) {
        start += ignore.n;
        n -= ignore.n;
    } else if constexpr (std::is_same_v<Ignore, ignore_last_n>) {
        n -= ignore.n;
    } else {
        static_assert(std::is_same_v<Ignore, ignore_first_last>);
        start += ignore.first_n;
        n -= ignore.first_n + ignore.last_n;
    }

    // This requires to store the register on the stack.
    std::memcpy(raw_ptr + start, reinterpret_cast<T*>(&reg) + start, n * sizeof(T));
}

Что делает memcpy

Это memcpy вызывается.

Он реализует копирование менее 32 байт следующим образом:

    #if VEC_SIZE > 16
        /* From 16 to 31.  No branch when size == 16.  */
    L(between_16_31):
        vmovdqu        (%rsi), %xmm0
        vmovdqu        -16(%rsi,%rdx), %xmm1
        vmovdqu        %xmm0, (%rdi)
        vmovdqu        %xmm1, -16(%rdi,%rdx)
        ret
    #endif
    L(between_8_15):
        /* From 8 to 15.  No branch when size == 8.  */
        movq        -8(%rsi,%rdx), %rcx
        movq        (%rsi), %rsi
        movq        %rcx, -8(%rdi,%rdx)
        movq        %rsi, (%rdi)
        ret
    L(between_4_7):
        /* From 4 to 7.  No branch when size == 4.  */
        movl        -4(%rsi,%rdx), %ecx
        movl        (%rsi), %esi
        movl        %ecx, -4(%rdi,%rdx)
        movl        %esi, (%rdi)
        ret
    L(between_2_3):
        /* From 2 to 3.  No branch when size == 2.  */
        movzwl        -2(%rsi,%rdx), %ecx
        movzwl        (%rsi), %esi
        movw        %cx, -2(%rdi,%rdx)
        movw        %si, (%rdi)
        ret

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

Сборка и код

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

Итак, я собрал и опубликовал пару листингов сборок:

, a hrefling для отмены сборки = "https://github.com/DenisYaroshevskiy/unsq_eve/blob/7c01a4631c393330b11776b36082a390a011e81f/disassemble/disassemble.s#L54" rel = "nofollow noreferrer"> Короче полная сборка, без разворачивания

Мой код можно найти здесь

PS: Измерение большого размера

Если вам интересно, на достаточно большом массиве этот тип векторизации - хорошая победа. Например, на 10'000 байтах.

измерение большого размера

Примерно 5 раз для символов, 3 раза для коротких и 2 раза для целых.

PS: при развертывании

Я не придумал какой-то умной раскрутки. Самая простая двойная развертка дает около 10% выигрыша для 10000 байт short. Развертывание еще не помогло. Я подозреваю, что причина того, что выигрыш так мала, в том, что алгоритм довольно сложен.

развертка измерений

person Denis Yaroshevskiy    schedule 06.06.2020

Не было места, чтобы добавить это, но это связано.

Этот вопрос расширился для меня до более общего вопроса:
Как изменить массив на месте, если его размер не делится на размер регистра SIMD.

Подобно тому, что сказал @PaulR, я рассмотрел несколько подходов:

  1. скалярная очистка.
  2. использовать store (игнорировать) (как-то замаскировать перед первым байтом и после последнего байта)
  3. если размер массива позволяет это, перекрывайте первые / последние хранилища соседними.
  4. полностью используйте невыровненные загрузки / хранилища и сделайте замаскированное хранилище в качестве последнего шага.

ПРИМЕЧАНИЕ. Отнеситесь к результатам с недоверием, сравнительный анализ - это сложно, и я могу ошибаться.

Выравнивание кода

Краткая версия: размещение вашего кода в двоичном файле существенно влияет на производительность.
Расширенная версия: блог easy perf, Обсуждение конференции llvm

Контрольные точки

Я беру массив заданного размера в байтах и ​​применяю к нему алгоритм.
Я проверяю все выравнивания кода от 0 до 64, добавляя слайд такого размера перед тестом.
(Безоперационный слайд не выполняется при измерении).

Среда

  • процессор: Intel 9700K
  • компилятор: clang-11, собран из ствола
  • ОС: свежий убунту

хранить (ignore_first / ignore_last) реализации

Подробности см. В предыдущем ответе. Я использую maskstore для int и memcpy для char и short.

Алгоритмы / Код

Я в основном сосредоточен здесь на удвоении каждого элемента (x = x + x).
Я называю этот алгоритм transform.

ПРИМЕЧАНИЕ: мой код, вероятно, сложно читать, поэтому я предоставляю сборку для всего. https://disassemble/disassemble.s#L8"> https://disassemble/disassemble.s#L8"> https://disassemble/nofollownoreferrerfree"> https://disassemble/http: //www.http://http://http://www.nofollownoreferrer //gcc.godbolt.org/z/MrKGEG "rel =" nofollow noreferrer "> godbolt std :: transform

  • transform<256, 4> - версия с выровненными чтения / записи первым и последним хранилищами должна иметь дело с частичным выходом за границы с помощью store(ignore). Развертываю 4 раза, компилятор разворачивает еще сверху. 256 - 256-битные регистры. все 4 преобразования, 10 КБ

    Что мне интересно, так это то, что в хорошем сценарии я не вижу каких-либо штрафов за использование невыровненных загрузок / хранилищ (это то, что используют как std::transform, так и мой transform_unaligned).

    Также полезно посмотреть на влияние выравнивания кода  влияние выравнивания кода, 10k

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

    Заключение: предполагая, что вы можете контролировать выравнивание своего кода, стратегия обработки границ имеет значение только при небольшом размере массива.

    Магазины - это то, что дорого

    Сравним 3 алгоритма на 40 короткометражках: reduce, transform, inclusive_scan. reduce делает гораздо больше добавлений, а также кучу свопов, по сравнению с transform, когда он приближается к inclusive_scan.

    reduce / transform / inclusive_scan

    Однако мы видим, что вычисление для сокращения гораздо менее важно, чем сохранение для преобразования. Мы также можем сказать, что множество сдвигов и вычислений для inclusive_scan составляют чуть более 20% его времени (преобразование выполняет все те же действия, за исключением гораздо более простых вычислений).

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

    Сравнение различных стратегий для 40 байт данных

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

    Два игнор против пилинга

    Подход по умолчанию для выполнения store(ignore) превосходит очистку петель для символов и коротких замыканий, но теряет около 25% для целых чисел.

    два игнорирования против отслаивания, 40 байт

    Два игнорирования против невыровненного и одно игнорирование

    Использование невыровненных загрузок / хранилищ для получения одного игнорирования кажется нецелесообразным - разница составляет 0,2 наносекунды, что, по моему мнению, является шумом.

    выровненный против невыровненного, 40 байт

    Перекрытие против двух игнорировать

    Перекрывающиеся магазины - это выигрыш для персонажей и короткометражек, поскольку здесь используется memcpy вместо store(ignore). Однако это не решает мою проблему для int.

    перекрытие против двух игнорировать

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

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

    Два игнорирования против пилинга, инклюзивное сканирование

    Для полноты картины репост обновленных результатов для inclusive_scan - отслаивания петель выглядит очень привлекательно. В этом есть смысл, поскольку на 40 байтах очень мало вычислительной выгоды. (40 байтов означают два регистра, поэтому 64 байта, но 24 из них потрачены впустую).

    два игнорирования против отслаивания, включенное сканирование

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

    P.S. Отслаивание петель при чтении данных.

    std::reduce будет автоматически векторизован, и петля будет снята. Мое сокращение не будет, оно заменит нули элементы, загруженные вне массива. Это хорошая стратегия для 40 байт данных.

    уменьшить против пилинга

    Я также видел похожие результаты для find. Конечно, 40 байт - это произвольно маленький размер, и если вы уменьшите его, вы, вероятно, сможете добраться туда, где это выгодно, но это граница, которую я сокращаю.

    person Denis Yaroshevskiy    schedule 20.06.2020
    comment
    Работает ли текущий clang над проблемой производительности uop-cache введено обновлением микрокода Intel для исправления ошибки JCC? Если нет, то это может во многом объяснить эффект выравнивания кода или разворачивания различий, если мы говорим о выравнивании относительно 32-байтовой границы. - person Peter Cordes; 21.06.2020
    comment
    @PeterCordes - не умеет отвечать. Я знаю 2 вещи: а) Я считаю, что LSB отключен (вы показали мне это в какой-то момент) б) Perf переходит от минимального к максимальному при каждом другом отсутствии операции (0 - плохо, 1 - хорошо, 2 - плохо, 3 - хорошо ... до 64) pasteboard.co/Je2F2RE.png - person Denis Yaroshevskiy; 21.06.2020

  • Существует несколько различных способов обработки данных, размер которых не кратен целым векторам SIMD. Вот три возможности:

    1. Скалярная очистка

      • process whole vectors using SIMD
      • обработать частичный вектор в конце, используя скалярный код
      • за: просто реализовать
      • минус: неэффективно, если нет итераций SIMD >> нет скалярных итераций
    2. Маскированная финальная итерация SIMD

      • process whole vectors using SIMD
      • обрабатывать частичный вектор с помощью SIMD и маски для объединения (смешивания) новых выходных значений с исходными выходными значениями, выходящими за границы
      • за: более эффективно, чем скалярная очистка
      • против: более сложный, некоторое дублирование кода
      • con с загрузкой / смешиванием / хранением: неатомарное чтение-изменение-запись данных вне массива не является потокобезопасным, если другие потоки могут касаться его. Если ваши векторы не выровнены, то также возможно прикосновение к несопоставленной странице. Правильно замаскированные хранилища с подавлением ошибок, такие как AVX512 или _mm_maskstore_epi32, позволяют избежать обеих этих проблем.
    3. Перекрытие конечного вектора

      • process whole vectors using SIMD
      • для окончательного вектора SIMD используйте перекрытие, чтобы вектор начинался с n - vector_size (т.е. будет перекрытие двух последних векторов)
      • pro: прост в реализации, никогда не обращается к элементам за пределами
      • против: работает только для n >= vector_size

    Выбор метода будет зависеть от ряда факторов, но в основном от типичного размера и диапазона n.

    person Paul R    schedule 04.06.2020
    comment
    1 вроде работает полу нормально. Я не обрабатываю все, используя скалярный код, только mask_store, и он лучший из тех, что у меня есть. Мои вопросы по существу - как это сделать лучше, чем просто memcpy. 2 действительно не может сделать 2 - я не знаю, что находится за пределами моего массива. Может быть, это нераспределенная страница, может быть задействован какой-то атом, кто знает. 3 На самом деле не хочу этого делать - так как мне все равно нужно сделать 1 в случае, когда n ‹vector_size. - person Denis Yaroshevskiy; 04.06.2020
    comment
    Хм, если вы используете 1, а основной цикл - это 256-битный SIMD, тогда вы можете выполнить дополнительную одиночную 128-битную итерацию SIMD после основного цикла SIMD, чтобы уменьшить количество скалярных итераций, когда у вас осталось больше половины вектора. . Это значительно снижает среднее количество скалярных итераций. Все еще не оптимально, если n мало. - person Paul R; 04.06.2020
    comment
    Оказывается, это на самом деле то, что делает memcpy - я опубликовал сборку в своем очень длинном ответе, если вам интересно. - person Denis Yaroshevskiy; 06.06.2020
    comment
    провели измерения для всех подходов, см. stackoverflow.com/a/62492369/5021064, если вам интересно. - person Denis Yaroshevskiy; 21.06.2020