Проверка параметров умножения на константу в 64 бит

Для моего кода BigInteger вывод оказался медленным для очень больших BigInteger. Итак, теперь я использую рекурсивный алгоритм «разделяй и властвуй», которому по-прежнему требуется 2'30 дюймов, чтобы преобразовать самое большое известное в настоящее время простое число в десятичную строку из более чем 22 миллионов цифр (но всего 135 мс, чтобы превратить ее в шестнадцатеричную строку) .

Я все еще хочу сократить время, поэтому мне нужна процедура, которая может очень быстро делить NativeUInt (т.е. UInt32 на 32-битных платформах, UInt64 на 64-битных платформах). Поэтому я использую умножение на константу. Это отлично работает в 32-битном коде, но я не уверен на 100% для 64-битного.

Итак, мой вопрос: есть ли способ проверить достоверность результатов умножения на константу для беззнаковых 64-битных значений? Я проверил 32-битные значения, просто попробовав все значения UInt32 (0..$FFFFFFFF). Это заняло ок. 3 минуты. Проверка всех UInt64 заняла бы гораздо больше времени, чем моя жизнь. Есть ли способ проверить, надежны ли используемые параметры (постоянные, постсдвиговые)?

Я заметил, что DivMod100() всегда терпел неудачу для значения, подобного $4000004B, если выбранные параметры были неправильными (но близкими). Существуют ли специальные значения или диапазоны для проверки 64-битной версии, чтобы мне не нужно было проверять все значения?

Мой текущий код:

const
{$IF DEFINED(WIN32)}
  // Checked
  Div100Const = UInt32(UInt64($1FFFFFFFFF) div 100 + 1);
  Div100PostShift = 5;
{$ELSEIF DEFINED(WIN64)}
  // Unchecked!!
  Div100Const = $A3D70A3D70A3D71; 
  // UInt64(UInt128($3 FFFF FFFF FFFF FFFF) div 100 + 1); 
  // UInt128 is fictive type.
  Div100PostShift = 2;
{$IFEND}

// Calculates X div 100 using multiplication by a constant, taking the
// high part of the 64 bit (or 128 bit) result and shifting
// right. The remainder is calculated as X - quotient * 100;
// This was tested to work safely and quickly for all values of UInt32.
function DivMod100(var X: NativeUInt): NativeUInt;
{$IFDEF WIN32}
asm
        // EAX = address of X, X is UInt32 here.
        PUSH    EBX
        MOV     EDX,Div100Const
        MOV     ECX,EAX
        MOV     EAX,[ECX]
        MOV     EBX,EAX
        MUL     EDX
        SHR     EDX,Div100PostShift
        MOV     [ECX],EDX       // Quotient

        // Slightly faster than MUL

        LEA     EDX,[EDX + 4*EDX] // EDX := EDX * 5;
        LEA     EDX,[EDX + 4*EDX] // EDX := EDX * 5;
        SHL     EDX,2             // EDX := EDX * 4; 5*5*4 = 100.

        MOV     EAX,EBX
        SUB     EAX,EDX         // Remainder
        POP     EBX
end;
{$ELSE WIN64}
asm
        .NOFRAME

        // RCX is address of X, X is UInt64 here.
        MOV     RAX,[RCX]
        MOV     R8,RAX
        XOR     RDX,RDX
        MOV     R9,Div100Const
        MUL     R9
        SHR     RDX,Div100PostShift
        MOV     [RCX],RDX      // Quotient

        // Faster than LEA and SHL

        MOV     RAX,RDX
        MOV     R9D,100
        MUL     R9
        SUB     R8,RAX
        MOV     RAX,R8         // Remainder
end;
{$ENDIF WIN32}

person Rudy Velthuis    schedule 30.01.2016    source источник
comment
Это похоже на обман stackoverflow.com/questions/20270596, но в любом случае вы найдете там ответ, прочитав libdivide   -  person David Heffernan    schedule 30.01.2016
comment
Я использовал libdivide для генерации константы, но она составляет $1C0000000000000000 div 100 + 1 с пост-сдвигом 6, но результат не n div 100 в старшей части. libdivide дает ожидаемые результаты для 32-битной системы, но, возможно, я не понимаю, как она используется для 64-битной версии. Поэкспериментирую еще немного.   -  person Rudy Velthuis    schedule 30.01.2016
comment
Пожалуйста, дайте ответ.   -  person David Heffernan    schedule 30.01.2016
comment
@DavidHeffernan: Хорошо, я нашел, как это сделать правильно, используя libdivide.h. По-видимому, требуется шаг сдвига/добавления. Теперь работает нормально. Должен ли я опубликовать решение как ответ или просто отредактировать вопрос?   -  person Rudy Velthuis    schedule 30.01.2016
comment
Хорошо, опубликую ответ.   -  person Rudy Velthuis    schedule 30.01.2016


Ответы (3)


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

gcc реализует беззнаковый 64-битный divmod с константой 0x28f5c28f5c28f5c3. Я не рассматривал подробно генерирование констант для деления, но есть алгоритмы их генерирования, которые дают заведомо хорошие результаты (поэтому исчерпывающее тестирование не требуется).

Код на самом деле имеет несколько важных отличий: он использует константу иначе, чем константа OP.

См. комментарии для анализа того, что это на самом деле делает: сначала разделите на 4, чтобы он мог использовать константу, которая работает для деления на 25 только тогда, когда делимое достаточно мало. Это также позволяет избежать необходимости добавления позже.

#include <stdint.h>

// rem, quot ordering takes one extra instruction
struct divmod { uint64_t quotient, remainder; }
 div_by_100(uint64_t x) {
    struct divmod retval = { x%100, x/100 };
    return retval;
}

компилируется в (gcc 5.3 -O3 -mtune=haswell):

    movabs  rdx, 2951479051793528259
    mov     rax, rdi            ; Function arg starts in RDI (SysV ABI)
    shr     rax, 2
    mul     rdx
    shr     rdx, 2
    lea     rax, [rdx+rdx*4]    ; multiply by 5
    lea     rax, [rax+rax*4]    ; multiply by another 5
    sal     rax, 2              ; imul rax, rdx, 100 is better here (Intel SnB).
    sub     rdi, rax
    mov     rax, rdi
    ret
; return values in rdx:rax

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


Часть умножения на 100.

gcc использует указанную выше последовательность lea/lea/shl, как и в вашем вопросе. В вашем ответе используется последовательность mov imm/mul.

Каждый из ваших комментариев говорит, что версия, которую они выбрали, быстрее. Если да, то это из-за тонкого выравнивания инструкций или другого вторичного эффекта: в Intel SnB-семействе это одинаковое количество мопов (3) и такая же задержка критического пути (mov imm находится за пределами критического пути, а mul составляет 3 цикла).

clang использует то, что я считаю лучшим выбором (imul rax, rdx, 100). Я подумал об этом до того, как увидел, что это выбрал лязг, но это не имеет значения. Это 1 uop слитого домена (который может выполняться только на p0), все еще с задержкой 3c. Поэтому, если вы ограничены задержкой, используя эту процедуру для множественной точности, это, вероятно, не поможет, но это лучший выбор. (Если вы привязаны к задержке, встраивание вашего кода в цикл вместо передачи одного из параметров через память может сэкономить много циклов.)

imul работает, потому что вы только с использованием младших 64b результата. Для mul не существует 2-х или 3-х операндной формы, потому что младшая половина результата одинакова независимо от подписанной или беззнаковой интерпретации входных данных.

Кстати, clang с -march=native использует mulx для 64x64->128 вместо mul, но ничего от этого не выигрывает. Согласно таблицам Агнера Фога, задержка на один цикл меньше, чем mul.


AMD имеет задержку хуже, чем 3c для imul r,r,i (особенно версия 64b), возможно, поэтому gcc ее избегает. IDK, сколько труда сопровождающие gcc вложили в настройку затрат, чтобы такие настройки, как -mtune=haswell, работали хорошо, но много кода не скомпилировано ни с одним параметром -mtune (даже с тем, который подразумевается -march), так что я не удивлен, когда gcc делает выбор, который был оптимален для старых процессоров или для AMD.

clang по-прежнему использует imul r64, r64, imm с -mtune=bdver1 (Bulldozer), что экономит m-ops, но требует задержки на 1c больше, чем при использовании lea/lea/shl. (значение со шкалой>1 соответствует задержке 2c на Bulldozer).

person Peter Cordes    schedule 31.01.2016
comment
@ user246408: Если я правильно понимаю комментарии к вашему ответу, clang и gcc, вероятно, делают это таким образом, поэтому им не нужно 65-битное сложение и сдвиг, верно? Это выглядит дешевле, чем код Руди. - person Peter Cordes; 01.02.2016
comment
Извините, я удалил свои комментарии; потому что я недостаточно вникал в код gcc; да, он использует только сдвиг, а не сложение и сдвиг; константа 0x28f5c28f5c28f5c3 - это те же старшие значащие биты, обратные 1/25 (или 1/100, они одинаковы), только сдвинутые вправо на 2 бита. Учитывая, что код gcc правильный, он использует оптимизацию, основанную на том факте, что делимое после предварительного сдвига на 2 бита вправо меньше 0x40000000000000000. Итог: хотя общей 64-битной константы для деления на 25 не существует, но она существует для делимых меньше 0x40000000000000000. - person kludg; 01.02.2016
comment
@ user246408: спасибо за этот анализ. Я не тратил время на то, чтобы увидеть всю картину, в основном я просто хотел посмотреть, что отвечает компилятор, не тратя время на то, чтобы понять, почему именно компилятор смог это сделать. - person Peter Cordes; 01.02.2016
comment
Было бы неплохо, если бы вы добавили код gcc для div_by_25. Если мой анализ верен, он должен отличаться от div_by_100, потому что нужна вещь сложения и сдвига. - person kludg; 01.02.2016
comment
@ user246408: godbolt позволяет любому легко пойти и попробовать изменить код и увидеть тот же вывод asm, который я скопировал / вставил. Вот почему я помещаю ссылки Godbolt во все свои ответы на asm-output. Во всяком случае, вот ссылка с div_by_100 и div_by_25. Вы правы, он сдвигается на единицу и добавляет. Константа, которую он использует, — 0x47ae147ae147ae15, чтобы избавить Godbolt от необходимости переключать godbolt в двоичный режим для получения шестнадцатеричного значения. Интересно, что ICC13 использует эту константу как для div_by_100, так и для div_by_25. Вместо умножения на 25 или 100 оно умножается на -25 или -100. - person Peter Cordes; 01.02.2016

Я нашел решение с помощью libdivide.h. Вот немного более сложная часть для Win64:

{$ELSE WIN64}
asm
        .NOFRAME

        MOV     RAX,[RCX]
        MOV     R8,RAX
        XOR     RDX,RDX
        MOV     R9,Div100Const       // New: $47AE147AE147AE15
        MUL     R9                   // Preliminary result Q in RDX

        // Additional part: add/shift

        ADD     RDX,R8               // Q := Q + X shr 1;
        RCR     RDX,1

        SHR     RDX,Div100PostShift  // Q := Q shr 6;
        MOV     [RCX],RDX            // X := Q;

        // Faster than LEA and SHL

        MOV     RAX,RDX
        MOV     R9D,100
        MUL     R9
        SUB     R8,RAX
        MOV     RAX,R8         // Remainder
end;
{$ENDIF WIN32}
person Rudy Velthuis    schedule 30.01.2016
comment
Почему не (Q + X) shr 1 вместо Q + (X - Q) shr 1? - person kludg; 30.01.2016
comment
Хммм... Я взял это из libdivide.h. Я предполагаю, что промежуточный результат может переполниться для некоторых значений. Хотя я могу попробовать. При необходимости я могу попробовать использовать вместо него RCR. Спасибо за подсказку. - person Rudy Velthuis; 30.01.2016
comment
@ user246408: Вы правы. Я использовал RCR для смещения назад в переносе в случае переполнения, и теперь это немного проще и быстрее. Я отредактировал ответ. - person Rudy Velthuis; 30.01.2016
comment
Интересный трюк с rcr для получения дополнительной промежуточной точности. Это инструкция с 3 операциями на Intel SnB-семействе (заменяющая три 1uop insns), поэтому изменение не сохраняет там никаких операций. Однако на AMD это всего 1 м-операция, поэтому там экономится две макрооперации. Обратите внимание, что rcr на немедленный счет, отличный от 1, значительно медленнее, поэтому он бесполезен, даже если вы можете комбинировать его сдвиг вправо со следующим shr. rcr имеет задержку 2c (Intel), а mov не был на критическом пути (и в любом случае имеет нулевую задержку на IvB и более поздних версиях), так что снова это промывка. (sub и shl r,1 равны 1c) - person Peter Cordes; 31.01.2016
comment
@PeterCordes: Итак, MOV R9,R8; SUB R9,RDX; SHR R9,1; ADD RDX,R9 эквивалентен (в смысле времени, я имею в виду - я знаю, что это дает тот же результат) приведенному выше, но избегает 65-го бита? - person Rudy Velthuis; 31.01.2016
comment
@RudyVelthuis: в семействе Intel SnB да, такое же количество операций и задержка. Возможно, разные требования к порту. (например, IvB и более поздние версии не нуждаются в порте для перемещения). От Pentium-M до Nehalem это 2 мкп (по-прежнему с задержкой 2c). На AMD (и PII/PIII) add/rcr 1 быстрее. В Silvermont rcr 1 составляет 7 микроопераций (в то время как простые инструкции по-прежнему равны 1). Я видел идиому Q + (X - Q)/2 раньше, в C, для вычисления среднего значения, избегая переполнения/переноса. В любом случае, вы наткнулись на еще один из тех, которые быстрее на одном процессоре и медленнее на других. - person Peter Cordes; 01.02.2016
comment
Вам не нужно обнулять rdx перед mul. Это операнд только для записи для mul, в отличие от div. И, как я указал в своем ответе, imul rax, rdx, 100 лучше, чем lea / lea / shl. clang даже использует его. Версия Clang составляет 9 мопов (Intel Haswell) по сравнению с вашими 12 (не считая ваших загрузок и хранилищ или потраченного впустую xor). - person Peter Cordes; 01.02.2016
comment
^ Да. Возможны 2 улучшения: (1) вместо обнуления RDX переместите Div100Const в RDX и MUL RDX; (2) если RCR медленный, то сдвиньте делимое на 1 (или 2) бита вправо и разделите на 50 (или 25) - переполнения не будет, поэтому RCR не понадобится. - person kludg; 01.02.2016
comment
@user и Питер: я все время забываю, что мне не нужно обнулять RDX перед мультом, а только перед тем, как я начну цепочку делений. - person Rudy Velthuis; 01.02.2016

Код в ответе @Rudy является результатом следующих шагов:

  1. Запишите 1/100 в двоичной форме: 0.000000(10100011110101110000);
  2. Подсчет ведущих нулей после запятой: S = 6;
  3. 72 первых значащих бита:

1010 0011 1101 0111 0000 1010 0011 1101 0111 0000 1010 0011 1101 0111 0000 1010 0011 1101

  1. Округлить до 65 бит; есть какая-то магия в том, как выполняется это округление; путем обратного проектирования константы из ответа Руди правильное округление:

1010 0011 1101 0111 0000 1010 0011 1101 0111 0000 1010 0011 1101 0111 0000 1010 1

  1. Удалите начальный бит 1:

0100 0111 1010 1110 0001 0100 0111 1010 1110 0001 0100 0111 1010 1110 0001 0101

  1. Напишите в шестнадцатеричном виде (получив обратно отомщенную константу):

A = 47 AE 14 7A E1 47 AE 15

  1. X div 100 = (((uint128(X) * uint128(A)) shr 64) + X) shr 7 (7 = 1 + S)

person kludg    schedule 30.01.2016
comment
На самом деле я сделал что-то подобное для 32-битной версии, но проще. Я разделил $100000000 на 100 (на самом деле я начал с div 25, а затем shr 2) и попробовал это со всеми кардиналами. Когда это не удалось, я использовал $200000000 и одну дополнительную смену и повторял это, пока не нашел то, что мне было нужно. - person Rudy Velthuis; 31.01.2016
comment
FWIW, используемое округление кажется довольно простым: округлить (от нуля). Но почему удаляется ведущий бит? Я все же думаю, что magic = $3FFFFFFFFFFFFFFFFF div 100 + 1 (у вас тоже есть: $A3D70A3... etc.) и сдвиг 6 тоже должны работать. Я просто не знаю, как это надежно доказать или проверить. - person Rudy Velthuis; 31.01.2016
comment
FWIW, их константа $1C0000000000000000 div 100, моя $400000000000000000 div 100. 1 доллар США = 28. Таким образом, (28/64) * (64/100) = 28/100. Добавьте к этому 100/100 (X), и вы получите 128/100 от X. Сдвиньте это вправо один раз, и вы получите 64/100. Сдвиньте это вправо 6 раз, и вы получите 1/100. ISTM, что моя константа ($A3D70...) должна работать без добавления/сдвига и давать точно такой же результат. Также обратите внимание, что 64-битный x 64-битный никогда не может переполниться в 129-битный или что-то в этом роде, поэтому нет необходимости в обмане, например (Q + (X - Q) shr 1). - person Rudy Velthuis; 31.01.2016
comment
@RudyVelthuis удаляется начальный 1 бит, поскольку он учитывается путем добавления X в окончательную формулу; трюк с добавлением X означает, что мы используем 65-битную константу вместо 64-битной, как вы пытались в первой попытке; 64-битной константы недостаточно для получения всегда правильного результата деления в 64-битном случае; Я также не уверен, что 65-битной константы достаточно в 64-битном случае. - person kludg; 31.01.2016
comment
Хм... 32-битной константы достаточно, чтобы надежно получить правильный результат в 32-битном формате. Возможно, не для всех значений, но, видимо, для div 100. Я так понял, что добавляется X. - person Rudy Velthuis; 31.01.2016
comment
FWIW, вместо 100, я мог бы использовать 25 для деления, а затем сдвинуться вправо на 2 позже. Это позволит избежать 65-го бита и позволит получить еще более точную константу. Я должен буду попробовать это. - person Rudy Velthuis; 31.01.2016
comment
Хммм... libdivide дает такое же постоянное значение и код и только сдвиг на 4 вместо 6 для деления на 25. Ничего не выиграно. - person Rudy Velthuis; 31.01.2016
comment
@RudyVelthuis - похоже, 65 бит всегда достаточно; на самом деле (N+1)-битной константы всегда достаточно для N-битного деления, в то время как N-битная константа возможна только для некоторых делителей. - person kludg; 01.02.2016
comment
Я сейчас читаю это. Кажется, это основа для кода libdivide: gmplib.org/~tege/divcnst-pldi94 .pdf - person Rudy Velthuis; 01.02.2016
comment
@RudyVelthuis - прочитал с этой статьей некоторые мысли: sergworks .wordpress.com/2016/02/01/целое-деление-на-константу - person kludg; 01.02.2016