Что самый быстрый x86-64 ассемблерный алгоритм деления на огромные числа?

Я пишу библиотеку кодов на языке ассемблера x86-64 для предоставления всех обычных побитовых, сдвиговых, логических, сравнительных, арифметических и математических функций для s0128, s0256, s0512, s1024 целочисленные типы и f0128, f0256, f0512, f1024 типы с плавающей точкой. До сих пор я работаю над типами signed-integer, потому что функции с плавающей запятой, скорее всего, вызовут некоторые внутренние подпрограммы, написанные для целых типов.

До сих пор я писал и тестировал функции для выполнения различных унарных операторов, сравнения операторов и операторов add, subtract и multiply.

Теперь я пытаюсь решить, как реализовать функции для операторов деления.

Моя первая мысль была: "Ньютон-Рафсон должен быть лучшим подходом". Зачем? Потому что он сходится очень быстро, учитывая хорошее семя (начальное предположение), и я полагаю, что мне нужно будет выяснить, как выполнить собственную 64-разрядную инструкцию разделения для операндов, чтобы получить отличное начальное значение. Фактически, если начальное значение является точным до 64 бит, чтобы получить правильные ответы, нужно только:

`s0128` : 1~2 iterations : (or 1 iteration  plus 1~2 "test subtracts")
`s0256` : 2~3 iterations : (or 2 iterations plus 1~2 "test subtracts")
`s0512` : 3~4 iterations : (or 3 iterations plus 1~2 "test subtracts")
`s1024` : 4~5 iterations : (or 4 iterations plus 1~2 "test subtracts")

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

s0128 :   4 iterations ==   4 (128-bit = 64-bit * 64-bit) multiplies +  12 adds
s0256 :  16 iterations ==  16 (128-bit = 64-bit * 64-bit) multiplies +  48 adds
s0512 :  64 iterations ==  64 (128-bit = 64-bit * 64-bit) multiplies + 192 adds
s1024 : 256 iterations == 256 (128-bit = 64-bit * 64-bit) multiplies + 768 adds

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

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

Моя первая мысль была следующая:

result = dividend / divisor                  // if I remember my terminology
remainder = dividend - (result * divisor)    // or something along these lines

# 1: Чтобы вычислить каждый бит, как правило, делитель вычитается из дивиденда IF, делитель меньше или равен дивиденду. Ну, обычно мы можем определить, что дивизор определенно меньше или определеннее больше, чем дивиденд, только проверяя их наиболее значимые 64-битные порции. Только тогда, когда эти ms64-битные части равны, должна быть проверена следующая нижняя 64-разрядная часть, и только тогда, когда они равны, мы должны проверить еще ниже и т.д. Поэтому почти на каждой итерации (вычисляя каждый бит результата) мы можем значительно сократить выполнение команд для вычисления этого теста.

# 2: Однако... в среднем примерно в 50% случаев мы найдем, что нам нужно вычесть дивизор из дивиденда, поэтому нам все равно нужно вычесть их ширину. В этом случае мы фактически выполнили больше инструкций, чем в обычном подходе (где мы сначала их вычитаем, а затем проверяем флаги, чтобы определить, является ли дивизор <= дивиденд). Таким образом, половина времени мы осознаем экономию, и половину времени мы осознаем потерю. На больших типах, таких как s1024 (который содержит -16- 64-битные компоненты), сбережения существенны, а потери малы, поэтому этот подход должен обеспечить большую чистую экономию. На крошечных типах, таких как s0128 (который содержит -2-64-битные компоненты), сбережения крошечные, а потери значительны, но не огромны.


Итак, мой вопрос: "Каковы наиболее эффективные алгоритмы разделения", :

#1: modern x86-64 CPUs like FX-8350
#2: executing in 64-bit mode only (no 32-bit)
#3: implementation entirely in assembly-language
#4: 128-bit to 1024-bit integer operands (nominally signed, but...)

ПРИМЕЧАНИЕ. Я предполагаю, что фактическая реализация будет действовать только на целые числа без знака. В случае умножения оказалось, что было проще и эффективнее (возможно) преобразовать отрицательные операнды в положительные, затем выполнить unsigned-multiply, а затем отрицать результат, если ровно один исходный операнд был отрицательным. Тем не менее, я рассмотрю алгоритм с подписанным целым, если он эффективен.

ПРИМЕЧАНИЕ. Если для моих типов с плавающей запятой лучшие варианты отличаются (f0128, f0256, f0512, f1024), объясните, почему.

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

u0256 = u0128 * u0128     // cannot overflow
u0512 = u0256 * u0256     // cannot overflow
u1024 = u0512 * u0512     // cannot overflow
u2048 = u1024 * u1024     // cannot overflow

Моя библиотека кода предлагает две версии размножения для каждого типа данных с целым числом:

s0128 = s0128 * s0128     // can overflow (result not fit in s0128)
s0256 = s0256 * s0256     // can overflow (result not fit in s0256)
s0512 = s0512 * s0512     // can overflow (result not fit in s0512)
s1024 = s1024 * s1024     // can overflow (result not fit in s1024)

s0256 = s0128 * s0128     // cannot overflow
s0512 = s0256 * s0256     // cannot overflow
s1024 = s0512 * s0512     // cannot overflow
s2048 = s1024 * s1024     // cannot overflow

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

Ответы

Ответ 1

Знаете ли вы о существующих пакетах произвольной точности (например, http://gmplib.org/) и о том, как они работают? Они, как правило, предназначены для запуска "как можно быстрее" для произвольных исправлений.

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

Также, если вы его не видели, проверьте D. Knuth "Семинумерные алгоритмы" и "oldie", но действительно goodie, который предоставляет ключевые алгоритмы для многоточечная арифметика. (Большинство пакетов основаны на этих идеях, но у Кнута есть большие объяснения и очень много прав).

Основная идея состоит в том, чтобы обрабатывать числа с несколькими точками, как если бы они были очень большими радисиальными числами (например, radix 2 ^ 64) и применяли стандартную арифметику 3-го класса к "цифрам" (например, 64-битные слова). Divide состоит из цифры оценки (big-radix), умножения на делителя, вычитания из дивиденда, сдвига влево на одну цифру, повторение "пока вы не получите достаточно цифр, чтобы удовлетворить вас. Для деления да, его все без знака (обработка знака в обертках). Основной трюк - это оценка-a-quotient digit well (с использованием инструкций с одной точностью, которые предоставляет процессор), и выполнение быстрой многоточности умножается на отдельные цифры. Подробности см. В книге Knuth. См. Техническую документацию по многоточечной арифметике (вы можете сделать некоторые исследования) для экзотических (" самых быстрых") улучшений.

Ответ 2

Подходы "большого радиуса" более эффективны для типов огромных типов данных, о которых вы говорите, особенно если вы можете выполнять 128-битные деления на 64-разрядные инструкции на языке ассемблера.

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

Ответ 3

Для умножения смотрите здесь:

http://www.math.niu.edu/~rusin/known-math/99/karatsuba

В принципе, он позволяет выполнять умножение 1024 x 1024 с использованием трех (вместо четырех) умножений 512 x 512 бит. Или девять 256 х 256 бит, или двадцать семь 128 х 128 бит. Добавленная сложность может не превзойти грубую силу даже для 1024 x 1024, но, вероятно, для более крупных продуктов. Это самый простой из "быстрых" алгоритмов, используя n ^ (log 3/log 2) = n ^ 1.585 умножений.

Я бы советовал не использовать ассемблер. Реализуйте 64 x 64 → 128-битное умножение с встроенным ассемблером, то же самое с add-with-carry (я думаю, что gcc и clang могут иметь встроенные операции для этого в настоящее время); то вы можете, например, умножить n бит x 256 бит (любое количество слов раз 4 слова) параллельно, избегая всех латентностей умножения, не сходя с ума с ассемблера.

Ответ 4

Для большого количества бит я узнал, что самый быстрый алгоритм выглядит следующим образом: вместо деления x/y вы вычисляете 1/y и умножаетесь на x. Для вычисления 1/y:

1 / y is the solution t of (1 / ty) - 1 = 0.
Newton iteration: t' = t - f (t) / f' (t) 
  = t - (1 / ty - 1) / (-1 / t^2 / y)
  = t + (t - t^2 y)
  = 2t - t^2 y

Итерация Ньютона сходится квадратично. Теперь трюк: если вам нужна точность в 1024 бит, вы начинаете с 32 бит, один шаг итерации дает 64 бит, следующий шаг итерации дает 128 бит, затем 256, затем 512, затем 1024. Таким образом, вы выполняете много итераций, но только последние один использует полную точность. Итак, в целом вы делаете один продукт размером 512 x 512- > 1024 (t ^ 2), один продукт 1024 x 1024 → 1024 (t ^ 2 y = 1/y) и еще один продукт 1024 x 1024 (x * ( 1/y)).

Конечно, вам нужно точно определить, что такое ошибка после каждой итерации; Вам, вероятно, придется начать с 40 бит, потерять немного точности на каждом шаге, чтобы у вас было достаточно в конце.

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

Ответ 5

Альтернативой является грубая сила. Вы можете взять самые высокие 128 бит x, делить на самые высокие 64 бита y и получить наивысший 64 бит r частного, а затем вычесть r x y из x. И повторите по мере необходимости, тщательно проверяя, насколько велики ошибки.

Разделения - это глубина. Итак, вы вычисляете 2 ^ 127/(самые высокие 64 бит y) один раз. Затем, чтобы оценить следующий бит 64, умножьте самый высокий бит 64 бит на это число и переместите все в нужное место. Умножение происходит намного быстрее, чем деление.

Далее вы обнаружите, что все эти операции имеют длительные задержки. Например, 5 циклов, чтобы получить результат, но вы можете делать умножение на каждый цикл. Итак: Оцените 64 бит результата. Начните вычитать r * y в верхнем конце x, так что вы можете оценить следующий 64 бит как можно быстрее. Затем вы вычитаете два или более продуктов из x одновременно, чтобы избежать штрафа от латентности. Реализация этого сложна. Некоторые вещи могут не стоить даже для 1024 бит (это всего лишь шестнадцать 64-битных целых чисел).