Подсчет элементов "меньше чем x" в массиве - PullRequest
0 голосов
/ 21 января 2019

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

В x86 вы можете использовать adc (добавить с переносом) для эффективной реализации этого подхода без ветвей 2 (с указателем начала в rdi длиной в rsi и значением искать в edx):

  xor eax, eax
  lea rdi, [rdi + rsi*4]  ; pointer to end of array = base + length
  neg rsi                 ; we loop from -length to zero

loop:
  cmp [rdi + 4 * rsi], edx
  adc rax, 0              ; only a single uop on Sandybridge-family even before BDW
  inc rsi
  jnz loop

Ответ заканчивается на rax. Если вы развернете это (или если у вас фиксированный, известный размер ввода), повторяется только пара инструкций cmp; adc, поэтому накладные расходы приближаются к 2 простым инструкциям для сравнения (и иногда к перегруженной загрузке). Какая микроархитектура Intel представила ADC reg, 0 single-uop special case?

Однако это работает только для беззнаковых сравнений, где флаг переноса содержит результат сравнения. Существует ли эквивалентно эффективная последовательность для подсчета со знаком сравнений ? К сожалению, нет инструкции «добавить 1, если меньше чем»: adc, sbb и флаг переноса являются особенными в этом отношении.

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


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

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

Ответы [ 4 ]

0 голосов
/ 23 января 2019

Если массив гарантированно отсортирован, можно использовать cmovl с «немедленным» значением, представляющим правильное значение для добавления.Для cmovl немедленных действий нет, поэтому вам придется загружать их в регистры заранее.

Этот метод имеет смысл при развертывании, например:

; load constants
  mov r11, 1
  mov r12, 2
  mov r13, 3
  mov r14, 4

loop:
  xor ecx, ecx
  cmp [rdi +  0], edx
  cmovl rcx, r11
  cmp [rdi +  4], edx
  cmovl rcx, r12
  cmp [rdi +  8], edx
  cmovl rcx, r13
  cmp [rdi + 12], edx
  cmovl rcx, r14
  add rax, rcx
  ; update rdi, test loop condition, etc
  jcc loop

У вас есть 2 мопа на сравнение плюс накладные расходы.Между инструкциями cmovl существует цепочка зависимостей в 4 цикла (BDW и более поздние), но она не переносится.

Одним из недостатков является то, что вам необходимо установить константы 1,2,3,4вне петли.Он также не работает, если его не развернуть (вам нужно амортизировать накопление add rax, rcx).

0 голосов
/ 22 января 2019

PCMPGT + PADDD или PSUBD, вероятно, действительно хорошая идея для большинства процессоров, даже для небольших размеров, возможно, с простой скалярной очисткой.Или даже просто скаляр, используя movd нагрузки, см. Ниже.

Для скалярного целого числа, избегая регистров XMM, используйте SETCC для создания целого числа 0/1 из любого состояния флага, которое вы хотите ,xor-zero регистр tmp (возможно, вне цикла) и SETCC в младшем 8 из этого, если вы хотите использовать 32- или 64-битные инструкции ADD вместо только 8-битных.

cmp / adc reg,0 - это, по сути, оптимизация глазка для условия задания набора b elow / c. AFAIK, нет ничего более эффективного для условий сравнения со знаком.В лучшем случае 3 моп для cmp / setcc / add, против 2 для cmp / adc.Так что развертывание, чтобы скрыть издержки цикла, еще более важно.

См. Нижнюю часть Каков наилучший способ установить регистр в ноль в сборке x86: xor, mov или и? дляподробности о том, как эффективно расширить ноль SETCC r/m8, но не вызывая частичных остановок регистрации.И посмотрите Почему GCC не использует частичные регистры? для напоминания о поведении частичных регистров в разных точках.


Да, CF является особенным для многих вещей.Это единственный флаг условия, в котором заданы / очищены / дополнены (stc / clc / cmc) инструкции 1 .Есть причина, по которой bt / bts / etc.инструкции устанавливают CF, и эти инструкции сдвига переходят в него.И да, ADC / SBB может добавлять / подставлять его непосредственно в другой регистр, в отличие от любого другого флага.

OF можно читать аналогично с ADOX (Intel, начиная с Broadwell, AMD со времен Ryzen), но это все равно нам не помогает, потому что это строго OF, а не SF! = OF условие без подписи.

Это типично для большинства ISA, а не только для x86.(AVR и некоторые другие могут устанавливать / сбрасывать любой флаг условия, потому что у них есть инструкция, которая занимает непосредственную битовую позицию в регистре состояния . Но у них все еще есть только ADC / SBB для непосредственного добавления флага переноса вцелочисленный регистр.)

32-разрядный ARM может делать предикат addlt r0, r0, #1, используя любой код условия, включая подписанный меньше чем, вместо добавления с переносом с немедленным 0. У ARM есть ADC-немедленный , который вы могли бы использовать здесь для флага C, но не в режиме Thumb (где было бы полезно избегать IT-инструкции для предиката ADD), поэтому вам понадобится нулевой регистр.

AArch64 может делать несколько предикатных вещей, включая приращение с cinc с произвольными предикатами условия.

Но x86 не может.У нас есть только cmovcc и setcc для преобразования условий, отличных от CF == 1, в целые числа. (или с ADOX для OF==1.)

Сноска 1: Некоторые флаги состояния в EFLAGS, такие как прерывания IF (sti/cli), направление DF (std / cld) и проверка выравнивания (stac / clac), установлены / удаленыинструкции, но не флаги состояния ZF / SF / OF / PF или BCD-carry AF.


cmp [rdi + 4 * rsi], edx не будет ламинировать даже на Haswell / Skylake из-зарежим индексированной адресации, и он не имеет регистра назначения чтения / записи (поэтому он не похож на add reg, [mem].)

Если настройка выполняется только для семейства Sandybridge, вы можете просто увеличить указательи уменьшить счетчик размеров.Несмотря на то, что это сохраняет внутренние (неиспользуемые) мопы для эффектов размера RS.

На практике вы хотите развернуть с шагом указателя.

Вы упомянули размеры от 0 до 32, поэтому нам нужно пропустить цикл, если RSI = 0. Код в вашем вопросе - просто do{}while, который этого не делает.NEG устанавливает флаги в соответствии с результатом, поэтому мы можем использовать JZ для этого.Вы бы надеялись, что он может слиться с макрокомандой, потому что NEG точно такой же, как SUB от 0, но, согласно Agner Fog, он не работает на SnB / IvB.Так что это стоит нам еще один моп при запуске, если вам действительно нужно обрабатывать size = 0.


Использование целочисленных регистров

Стандартный способ реализации integer += (a < b) или любого другого флагового условия - это то, что делают компиляторы ( Godbolt ):

xor    edx,edx            ; can be hoisted out of a short-running loop, but compilers never do that
                          ; but an interrupt-handler will destroy the rdx=dl status
cmp/test/whatever         ; flag-setting code here
setcc  dl                 ; zero-extended to a full register because of earlier xor-zeroing
add    eax, edx

Иногда компиляторы (особенно gcc) будут использовать setcc dl / movzx edx,dl, что ставит MOVZX на критический путь. Это плохо сказывается на задержке, и устранение mov не работает на процессорах Intel, когда они используют (частично) один и тот же регистр для обоих операндов.

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

; slower than cmp/adc: 5 uops per iteration so you'll definitely want to unroll.

; requires size<256 or the count will wrap
; use the add eax,edx version if you need to support larger size

count_signed_lt:          ; (int *arr, size_t size, int key)
  xor    eax, eax

  lea    rdi, [rdi + rsi*4]
  neg    rsi              ; we loop from -length to zero
  jz    .return           ; if(-size == 0) return 0;

       ; xor    edx, edx        ; tmp destination for SETCC
.loop:
  cmp    [rdi + 4 * rsi], edx
  setl   dl               ; false dependency on old RDX on CPUs other than P6-family
  add    al, dl
       ; add    eax, edx        ; boolean condition zero-extended into RDX if it was xor-zeroed

  inc    rsi
  jnz    .loop

.return:
  ret

В качестве альтернативы, используя CMOV, делая переносимую по циклу деп цепочку длиной 2 цикла (или 3 цикла в Intel до Broadwell, где CMOV равен 2 моп):

  ;; 3 uops without any partial-register shenanigans, (or 4 because of unlamination)
  ;;  but creates a 2 cycle loop-carried dep chain
  cmp    [rdi + 4 * rsi], edx
  lea    ecx, [rax + 1]        ; tmp = count+1
  cmovl  eax, ecx              ; count = arr[i]<key ? count+1 : count

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

SETCC - это один моп, так что это 5 мопов с слитым доменом внутри цикла. Это намного хуже на Sandybridge / IvyBridge, и все еще работает со скоростью ниже 1 за час на более позднем семействе SnB. (У некоторых древних процессоров был медленный setcc, как у Pentium 4, но он эффективен во всем, что нас все еще волнует.)

При развертывании, если вы хотите, чтобы это выполнялось быстрее, чем 1 cmp за такт, у вас есть два варианта : использовать отдельные регистры для каждого setcc назначения, создавая несколько цепочек dep для ложных зависимостей или используйте один xor edx,edx внутри цикла, чтобы разбить ложную зависимость, переносимую циклом, на несколько коротких цепочек dep, которые связывают только результаты setcc соседних нагрузок (вероятно, поступающих из той же строки кэша). Вам также понадобится несколько аккумуляторов, потому что add задержка равна 1с.

Очевидно, что вам нужно будет использовать приращение указателя, чтобы cmp [rdi], edx мог слиться воедино с неиндексированным режимом адресации, в противном случае общее значение cmp / setcc / add составляет 4 мопа, и это ширина конвейера на процессорах Intel. .

После записи AL нет никакого частичного регистрового останова вызывающего абонента, читающего EAX, даже на семействе P6, потому что мы сначала обнулили его нулями. Sandybridge не будет переименовывать его отдельно от RAX, потому что add al,dl - это чтение-изменение-запись, а IvB и более поздние никогда не переименовывают AL отдельно от RAX (только AH / BH / CH / DH). Процессоры, отличные от семейства P6 / SnB, вообще не выполняют частичное переименование регистров, только частичные флаги.

То же самое относится к версии, которая читает EDX внутри цикла. Но обработчик прерываний, сохраняющий / восстанавливающий RDX с помощью push / pop, разрушил бы его состояние с нулевым или нулевым значением , что привело бы к частичным остановкам регистров на каждой итерации семейства P6. Это катастрофически плохо, так что это одна из причин, по которой компиляторы никогда не поднимают обнуление xor. Они обычно не знают, будет ли цикл продолжительным или нет, и не пойдут на риск. Вручную, вы, вероятно, захотите развернуть и xor-zero один раз для развернутого тела цикла, а не один раз для cmp / setcc.


Вы можете использовать SSE2 или MMX для скалярных вещей

Оба являются базовыми для x86-64. Поскольку вы ничего не получаете (в семействе SnB) от складывания нагрузки в cmp, вы также можете использовать скалярную загрузку movd в регистр XMM. MMX имеет преимущество меньшего размера кода, но требует EMMS, когда вы закончите. Он также допускает невыровненные операнды памяти, поэтому он потенциально интересен для более простой автоматической векторизации.

До AVX512 у нас есть только сравнение для большего, чем доступно, поэтому потребовалась бы дополнительная movdqa xmm,xmm инструкция для выполнения key > arr[i] без уничтожения ключа вместо arr[i] > key. (Это то, что делают gcc и clang при авто-векторизации).

AVX было бы неплохо, если бы vpcmpgtd xmm0, xmm1, [rdi] делал key > arr[i], как в случае использования gcc и clang с AVX. Но это 128-битная загрузка, и мы хотим, чтобы она была простой и скалярной.

Мы можем уменьшить key и использовать (arr[i]<key) = (arr[i] <= key-1) = !(arr[i] > key-1). Мы можем посчитать элементы, у которых массив больше key-1, и вычесть это из размера. Таким образом, мы можем обойтись только с SSE2 без дополнительных инструкций.

Если бы key было уже самым отрицательным числом (поэтому key-1 обернулось бы), то никакие элементы массива не могут быть меньше его.Это действительно вводит ветвление перед циклом, если этот случай действительно возможен.

 ; signed version of the function in your question
 ; using the low element of XMM vectors
count_signed_lt:          ; (int *arr, size_t size, int key)
                          ; actually only works for size < 2^32
  dec    edx                 ; key-1
  jo    .key_eq_int_min
  movd   xmm2, edx    ; not broadcast, we only use the low element

  movd   xmm1, esi    ; counter = size, decrement toward zero on elements >= key
      ;;  pxor   xmm1, xmm1   ; counter
      ;;  mov    eax, esi     ; save original size for a later SUB

  lea    rdi, [rdi + rsi*4]
  neg    rsi          ; we loop from -length to zero

.loop:
  movd     xmm0, [rdi + 4 * rsi]
  pcmpgtd  xmm0, xmm2    ; xmm0 = arr[i] gt key-1 = arr[i] >= key = not less-than
  paddd    xmm1, xmm0    ; counter += 0 or -1
    ;;  psubd    xmm1, xmm0    ; -0  or  -(-1)  to count upward

  inc      rsi
  jnz      .loop

  movd   eax, xmm1       ; size - count(elements > key-1)
  ret

.key_eq_int_min:
  xor    eax, eax       ; no array elements are less than the most-negative number
  ret

Эта скорость должна быть такой же, как у вашего цикла на процессорах семейства Intel SnB, плюс небольшая дополнительная нагрузка снаружи.Это 4 мопа в домене с предохранителями, поэтому он может выдавать 1 за такт.movd load использует обычный порт загрузки, и есть как минимум 2 векторных порта ALU, которые могут работать с PCMPGTD и PADDD.

Oh, но на IvB / SnB макрос-слитый inc / jnz требует порт 5в то время как PCMPGTD / PADDD оба работают только на p1 / p5, поэтому пропускная способность порта 5 будет узким местом.В HSW и более поздних версиях ветвь работает на порту 6, поэтому у нас все хорошо для фоновой пропускной способности.

Это хуже на процессорах AMD, где cmp с операндом памяти может использовать режим индексированной адресации без штрафа.(И в Intel Silvermont, и в Core 2 / Nehalem, где источник памяти cmp может быть одним мопом с режимом индексированной адресации.)

А в семействе Bulldozer пара целочисленных ядер совместно используют SIMD-модульпоэтому придерживаться целочисленных регистров может быть еще большим преимуществом.Вот почему int <-> XMM movd / movq имеет большую задержку, что опять же сказывается на этой версии.


Другие приемы:

Clang для PowerPC64 (включен в Godboltссылка) показывает нам хитрый трюк: ноль или расширение знака до 64-разрядного, вычитание, а затем захватить MSB результата в виде целого числа 0/1, которое вы добавляете к counter.PowerPC имеет отличные инструкции по битовым полям, включая rldicl.В этом случае он используется для поворота влево на 1, а затем обнуления всех битов выше этого значения, т. Е. Выделения MSB в нижнюю часть другого регистра.(Обратите внимание, что документация PowerPC нумерует биты с MSB = 0, LSB = 63 или 31.)

Если вы не отключите автоматическую векторизацию, он использует Altivec с циклом vcmpgtsw / vsubuwm, которыйЯ предполагаю, что делает то, что вы ожидаете от имен.

# PowerPC64 clang 9-trunk -O3 -fno-tree-vectorize -fno-unroll-loops -mcpu=power9
# signed int version

# I've added "r" to register names, leaving immediates alone, because clang doesn't have `-mregnames`

 ... setup
.LBB0_2:                  # do {
    lwzu   r5, 4(r6)         # zero-extending load and update the address register with the effective-address.  i.e. pre-increment
    extsw  r5, r5            # sign-extend word (to doubleword)
    sub    r5, r5, r4        # 64-bit subtract
    rldicl r5, r5, 1, 63    # rotate-left doubleword immediate then clear left
    add    r3, r3, r5        # retval += MSB of (int64_t)arr[i] - key
    bdnz .LBB0_2          #  } while(--loop_count);

Я думаю, что Clang мог бы избежать extsw внутри цикла, если бы он использовал арифметическую (расширяющую знак) нагрузку.Единственным lwa, который обновляет регистр адресов (сохраняя приращение), представляется индексированная форма lwaux RT, RA, RB, но если clang поместит 4 в другой регистр, он может использовать его.(Кажется, нет инструкции lwau.) Возможно, lwaux медленный или, возможно, пропущенная оптимизация.Я использовал -mcpu=power9, поэтому, несмотря на то, что эта инструкция только для POWER, она должна быть доступна.

Этот трюк может помочь х86 , по крайней мере для свернутого цикла.Таким образом, для сравнения требуется 4 мопа, , а не , подсчитываемые издержки цикла.Несмотря на довольно плохие возможности извлечения битового поля в x86, все, что нам на самом деле нужно, это логическое смещение вправо для выделения MSB.

count_signed_lt:          ; (int *arr, size_t size, int key)
  xor     eax, eax
  movsxd  rdx, edx

  lea     rdi, [rdi + rsi*4]
  neg     rsi          ; we loop from -length to zero

.loop:
  movsxd   rcx, dword [rdi + 4 * rsi]   ; 1 uop, pure load
  sub      rcx, rdx                     ; (int64_t)arr[i] - key
  shr      rcx, 63                      ; extract MSB
  add      eax, ecx                     ; count += MSB of (int64_t)arr[i] - key

  inc      rsi
  jnz      .loop

  ret

Это не имеет ложных зависимостей, но не имеет и 4-uop xor-зеро / cmp / setl / add.Преимущество только в том, что это 4 мопа даже в режиме индексированной адресации.Некоторые процессоры AMD могут запускать MOVSXD как через ALU, так и через порт загрузки, но у Ryzen такая же задержка, как и для обычных нагрузок.

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

.loop
  movsxd   rcx, dword [rdi + 4 * rsi]   ; 1 uop, pure load
  sub      rcx, rdx                     ; (int64_t)arr[i] - key
  shld     rax, rcx, 1    ; 3 cycle latency

  inc rsi  / jnz .loop

  popcnt   rax, rax                     ; turn the bitmap of compare results into an integer

Но 3-тактовая задержка shld делает это showtopper для большинства применений, даже если на SnB- это всего лишь один моп.семьи.Зависимость rax-> rax переносится циклами.

0 голосов
/ 22 января 2019

Есть способ конвертировать сравнение со знаком в сравнение без знака и наоборот, переключая верхний бит

bool signedLessThan(int a, int b)
{
    return ((unsigned)a ^ INT_MIN) < b; // or a + 0x80000000U
}

Это работает, потому что диапазоны вДополнение 2 по-прежнему линейно, только с поменянным местами со знаком и без знака.Так что самым простым способом может быть XORing перед сравнением

  xor eax, eax
  xor edx, 0x80000000     ; adjusting the search value
  lea rdi, [rdi + rsi*4]  ; pointer to end of array = base + length
  neg rsi                 ; we loop from -length to zero

loop:
  mov ecx, [rdi + 4 * rsi]
  xor ecx, 0x80000000
  cmp ecx, edx
  adc rax, 0              ; only a single uop on Sandybridge-family even before BDW
  inc rsi
  jnz loop

Если вы можете изменить массив, просто выполните преобразование перед проверкой


В ADX есть ADOX , который использует перенос из OF.К сожалению, для сравнения со знаком также требуется SF вместо только OF, поэтому вы не можете использовать его следующим образом

  xor ecx, ecx
loop:
  cmp [rdi + 4 * rsi], edx
  adox rax, rcx            ; rcx=0; ADOX is not available with an immediate operand

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

0 голосов
/ 21 января 2019

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

needle> = 0:

  • пройти массив в порядке возрастания
  • начать с подсчета каждого отрицательного элемента массива
  • перейти к положительным числам так же, как в сценарии без знака

needle <0: </p>

  • пройти по массиву в порядке убывания
  • начните с пропуска каждого положительного элемента массива
  • и продолжите работу с отрицательными числами, как в сценарии без знака

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

  • (без знака) подсчитать количество элементов
  • (без знака) считать элементы> = 0x80000000
  • добавить результаты
  • , если needle <0, вычесть длину массива из результата </li>

Возможно, естьмногое будет оптимизировано под мой код ниже.Я довольно ржавый в этом.

                          ; NOTE: no need to initialize eax here!
  lea rdi, [rdi + rsi*4]  ; pointer to end of array = base + length
  neg rsi                 ; we loop from -length to zero

  mov ebx, 80000000h      ; minimum signed integer (need this in the loop too)
  cmp edx, ebx            ; set carry if needle negative
  sbb eax, eax            ; -1 if needle negative, otherwise zero
  and eax, esi            ; -length if needle negative, otherwise zero

loop:
  cmp [rdi + 4 * rsi], edx
  adc rax, 0              ; +1 if element < needle
  cmp [rdi + 4 * rsi], ebx
  cmc
  adc rax, 0              ; +1 if element >= 0x80000000
  inc rsi
  jnz loop
Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...