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