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

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

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

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

Моей первой мыслью было: «Ньютон-Рафсон, должно быть, лучший подход». Почему? Потому что он сходится очень быстро при хорошем начальном значении (начальное предположение), и я полагаю, что смогу выяснить, как выполнить встроенную 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: Чтобы вычислить каждый бит, обычно делитель вычитается из делимого ЕСЛИ делитель меньше или равен делимому. Что ж, обычно мы можем определить, что делитель определенно меньше или определенно больше дивиденда, проверяя только их наиболее значимые 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...)

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

ПРИМЕЧАНИЕ. Если лучшие ответы для моих типов с плавающей запятой (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

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


person honestann    schedule 27.12.2013    source источник
comment
Ага. Подразделения - отстой. Вот почему люди стараются избегать как можно большего ...   -  person Mysticial    schedule 27.12.2013
comment
Разве вы не можете использовать алгоритм «разделяй и властвуй» и просто опускать завоевание?   -  person Kerrek SB    schedule 28.12.2013
comment
Я реализовал все вышеперечисленное на C ++: 1. Класс, поддерживающий все арифметические операции для натурального числа любого мыслимого размера. 2. Класс, поддерживающий все арифметические операции с рациональным числом любого мыслимого размера. Последний хранит рациональное число как пару натуральных чисел {перечислитель, знаменатель} и не выполняет никаких операций FP (за исключением конструкторов класса, которые принимают 'double' и преобразуют его в перечислитель и знаменатель). Он довольно медленный и неэффективный, но точный на 100% (без потери точности). Если поможет, могу отправить вам код ...   -  person barak manos    schedule 01.01.2014
comment
Замечательное предложение. Может быть позже. Но более ценными для меня (и других читателей) являются мнения и открытия методов реализации операций. Помимо разделения, sin (), cos (), tan (), cot (), sec (), csc (), asin (), acos (), atan (), sinh (), cosh (), tanh () , asinh (), acosh (), atanh (), ln (), log (), exp (), power (), print () и т. д.? Я почти выбрал операнды переменной длины, но остановился на фиксированных размерах: самый большой поддерживает достаточную точность и диапазон для ПОЧТИ любой мыслимой цели. Мой код не содержит чисел с плавающей запятой (но реализует числа с плавающей запятой). Где взять константы монстров, такие как e, pi и т. Д.?   -  person honestann    schedule 02.01.2014
comment
@Mystical: разделения - отстой? Вот это да. В вычислениях нужно работать с числами, формулами и их алгебраическими свойствами. деление - это операция, обратная умножению, аналог вычитания - операция, обратная сложению. Я не могу представить себе серьезные вычисления без вычитания; это потому, что инверсии действительно полезны. Таким образом, для разделения. Просто потому, что это неудобно для реализации, еще не причина избегать этого; это просто повод хорошо его реализовать.   -  person Ira Baxter    schedule 02.01.2014
comment
@IraBaxter: У меня были очень хорошие результаты с рациональными числами произвольного размера. Для этого число представлено как numerator/divisor * 2**exponent, и вам никогда не нужно делить (вместо этого вы умножаете на обратное). Конечно, вы, вероятно, захотите упростить свои рациональные числа (найти наибольший общий делитель и использовать его для уменьшения числителя и делителя), но это необязательно и не обязательно приближаться к критическому пути.   -  person Brendan    schedule 16.01.2014
comment
@Brendan: Я интерпретирую ваш ответ, поскольку разделение по-прежнему важно; Я просто использую обратные и GCD, как в реализации. Это по-прежнему неудобно, но, как вы заметили, вы делаете это один раз и помещаете в библиотеку, и тогда вам больше не нужно об этом думать. [Мы также создали рациональный пакет, но не увидели никакой ценности в экспоненциальной части (ваша степень двойки). У нас также есть пакет целочисленных произвольной точности, который, помимо прочего, предлагает деление.   -  person Ira Baxter    schedule 16.01.2014


Ответы (5)


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

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

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

Ключевая идея состоит в том, чтобы обрабатывать числа с множественной точностью, как если бы они были числами с очень большой системой счисления (например, основание 2 ^ 64), и применять стандартную арифметику третьего уровня к «цифрам» (например, 64-битным словам). Разделение состоит из «оценить частное (большое основание), умножить оценку на делитель, вычесть из делимого, сдвинуть влево на одну цифру, повторить», пока вы не получите достаточно цифр, чтобы удовлетворить вас. Для деления да, все без знака (обработка знаков в оболочках). Основной трюк - это точная оценка частного числа (с использованием инструкций одинарной точности, которые вам предоставляет процессор) и быстрое умножение с множественной точностью на однозначные числа. См. Подробности у Кнута. См. Технические исследования по арифметике с несколькими точками (вы можете провести небольшое исследование) для экзотических («максимально быстрых») улучшений.

person Ira Baxter    schedule 02.01.2014
comment
Я понимаю основную идею многословного деления, но с многобитными цифрами все становится довольно быстро. Я помню, как меня сбивал с толку даже самый простой случай из двух слов в Hacker's Delight. Подходит ли покрытие TAOCP для чтения для тех из нас, у кого далеко не идеальный опыт в сфере CS, который все еще хотел бы глубоко понять метод? - person doynax; 02.01.2014
comment
Как и все технические документы, независимо от того, имеете ли вы идеальный опыт работы в сфере ИТ или нет, обычно требуется тщательное изучение. TAOCP может читать студент без образования ... по крайней мере, части практикующего специалиста, а это то, что вам нужно. (Да, в нем тоже много глубокой математики, но вы можете пропустить большую часть этого). Я прочитал его в 1969 году, будучи студентом без образования в области CS. Думаю, у тебя все будет хорошо, особенно. если ваша цель - выполнить то, что вы сказали. Учитывая ваш предполагаемый опыт, я предлагаю вам действительно внимательно прочитать о частичной оценке. Это действительно простая и действительно мощная идея для оптимизации кода. - person Ira Baxter; 02.01.2014
comment
Спасибо. Пора сходить в библиотеку местного университета. Для неопытного глаза частичная оценка кажется еще одним видом школ мысли, основанных на программировании как упражнении в кэшировании и предварительном вычислении того, что может быть предварительно вычислено, но более глубокое изучение формализма может выявить неожиданные применения. (О, и я думаю, вы можете спутать меня с OP.) - person doynax; 02.01.2014
comment
@doynax: Частичная оценка - это скорее предварительное вычисление специализированного алгоритма из уже предоставленного более общего. [Да, я перепутал вас с OP. Ну что ж. Надеюсь, этот совет окажется полезным для вас и для него: -}] - person Ira Baxter; 02.01.2014
comment
Как узнать, как работают пакеты gmplib? Предлагают ли они PDF-документ? Комментарии в коде? Какие? Я скачал и прочитал огромную кучу исследовательских статей по этой проблеме (разделению), но они на удивление бесполезны для определения того, какой подход или алгоритм лучше всего подходит для различных размеров операндов (или фиксированного размера или переменного размера). В некоторых статьях даже показаны графики НОРМАЛИЗОВАННОЙ производительности для каждого алгоритма для разной ширины, но нет возможности сравнить производительность двух алгоритмов, даже если они графически изображают их оба! Насколько это глупо? Должен ли человек быть непрактичным / невежественным для проведения исследования? - person honestann; 03.01.2014
comment
Я не понимаю вашу технику разделения, и я никогда не слышал, чтобы кто-то предлагал такой раздел. Это то, как я умножаю свои большие типы, но умножение естественно работает именно так. Моя первая мысль - это разделить что-то вроде 87654321 на 1234. Если мы предположим, что каждая десятичная цифра является нашим собственным типом процессора (64 бита) по аналогии, тогда мы должны сначала разделить 8 (или 87654321) на 1. Очевидный результат - 8. Однако , этот знаменатель может быть 1000 или 1999, что означает, что правильный результат может быть 4, 5, 6, 7 или 8. Пытаться понять, как возиться, чтобы исправить все эти ошибки, кажется непрактичным. Нет? - person honestann; 03.01.2014
comment
@honestann: Прочтите книги Кнута; мой алгоритм действительно принадлежит ему, и он обсуждает, как выбрать правильную цифру для частного, включая эту непрактичную возню, которую вы, кажется, игнорируете. - person Ira Baxter; 03.01.2014
comment
@IraBaxter: Что ж, сбивает с толку! Я быстро получаю перегрузку символов, а код (хоть и короткий) ... уродливый и запутанный. Тем не менее, я сел и разделил числа, которые я выбрал наугад (CD53A673 / A4B2) на бумаге, при этом основание было шестнадцатеричной цифрой, которую я достаточно умен, чтобы обработать и проверить. И действительно, это сработало отлично. Я не совсем уверен, почему decimal такая ужасная ситуация (возможные результаты 4, 5, 6, 7, 8 для проверки и исправления за один шаг), но этот шестнадцатеричный пример был отключен только на 1 и только на одном из несколько шагов. Так что я забуду пытаться читать гиперматематические статьи и просто сделаю это! - person honestann; 05.01.2014
comment
@IraBaxter: Код был неясным, но я заметил, что некоторые проблемы заключались в выполнении шагов на C, которые естественны и эффективны только на языке ассемблера, особенно с современными процессорами CISC, такими как x86_64. Небольшая куча псевдокода, которую я написал, намного проще, но я думаю, что большинство людей обучены, как павловские обезьяны, формулировать все в терминах математики, независимо от того, необходимо ли, уместно или нет. Спасибо, что указали мне на эту технику - похоже, победитель ... особенно, если я смогу найти способ выполнять полезную работу в течение 72 циклов после выполнения деления и до появления результата! - person honestann; 05.01.2014
comment
Я считаю, что Кнут показывает, что если вы вычисляете цифру частного путем деления префикса фиксированного размера (1 или 2 цифры) на первую цифру делителя, ваш ответ будет отклонен не более чем на 1; вам нужна корректирующая проверка и, возможно, дополнительное вычитание IIRC для обработки этого случая. (Прошло 30 лет с тех пор, как я кодировал разделение с несколькими точками, простите мою ошибку). Дело в том, что Кнут объясняет все это как интуитивно, так и снова с помощью математики большого молотка, чтобы показать, что это действительно работает. Даже сейчас я возвращаюсь к его книгам. - person Ira Baxter; 05.01.2014
comment
72 цикла? Да, даже деление с одинарной точностью довольно дорого. Если вы хотите разделить много на известное значение (например, на первую цифру делителя), вы можете вычислить обратное значение один раз (для этой части может подойти Ньютон Рафсон), а затем < i> умножить на обратное. Намного быстрее, чем деление, так как вы амортизируете обратное вычисление по множеству частных цифр. Кнут этого не говорит; он не понял все правильно в 1969 году. - person Ira Baxter; 05.01.2014
comment
@IraBaxter: Практические наблюдения. Я пробовал свой алгоритм с другими числителями и знаменателями (n и d). Проблема, которую я заметил, все еще существует. Если мы разделим (на этот раз в шестнадцатеричном формате) числитель на ms4 = F и знаменатель на ms4 = 1, результат может быть F, E, D, C, B, A, 9 или 8 (в зависимости от других n и d битов. ). Вот почему мы должны СНАЧАЛА нормализовать [n и] d, чтобы их msb == 1 были выровнены. Теперь n и d по своей сути различаются в менее 2 раза, и мы можем выполнять все операции ОСТАВШИЕСЯ в терминах 64-битных элементов. Но этот первый шаг должен быть двоичным, битовым. - person honestann; 06.01.2014
comment
@IraBaxter: Мы всегда можем разделить 1 на любой знаменатель с помощью этого большого алгоритма системы счисления, за исключением замены 1 на 1 ‹---------------- b, где b - общее количество бит в типах данных (в моем случае 128, 256, 512 или 1024 бит). Таким образом, мы вычисляем обратные времена 2 ^ b, с которыми мы можем работать до подходящего времени, когда мы сможем исправить результат (r) следующим образом: r = r ›› b. Итак, принимаем мы обратные или нет (по той причине, которую вы указываете), мы все равно можем принять либо эту технику большого основания, либо метод Ньютона-Рэфсона. Безусловно, большая система счисления кажется намного быстрее для 512 и 1024 бит и, возможно, 256 бит. - person honestann; 06.01.2014
comment
@IraBaxter: Обозначения в D3 слишком абстрактны для меня, чтобы их понять, но, кажется, указывают на то, что прогнозируемый результат (частное) может быть на 2 слишком большим, а не только на 1 слишком высоким. Но мне интуитивно кажется, что эта возможность настолько мала с астрономической точки зрения, что придумать числители и знаменатели, чтобы раскрыть этот случай, может быть чрезвычайно сложно. Итак, я предполагаю, что этот этап проверки и правильности должен быть циклом, в котором результат вычитания предсказанного (результат * знаменатель) из текущего остатка должен проверяться после вычитания на всякий случай и повторяться один раз каждые r 2 ^ 64 циклов или так. - person honestann; 06.01.2014
comment
@IraBaxter: Я вижу, как результат может быть больше знаменателя, но не вижу, как результат может быть больше, чем самый большой числитель (что каждый источник утверждает, что это возможно). Как это может быть так, когда наибольший результат - это случай деления на единицу, который дает результат, точно РАВНЫЙ числителю? - person honestann; 06.01.2014

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

Хотя итерация Ньютона-Рафсона действительно сходится быстро, каждая итерация требует слишком большого количества шагов умножения и накопления для каждой итерации.

person sophia    schedule 16.01.2014

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

http://www.math.niu.edu/~rusin/known-math/99/karatsuba http://web.archive.org/web/20141114071302/http://www.math.niu.edu/%7Erusin/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 бит на 256 бит (любое количество слов на 4 слова) параллельно, избегая всех задержек умножения, не сходя с ума с ассемблером.

person gnasher729    schedule 18.03.2014
comment
Спасибо за ваши 3 новых сообщения. Я уже написал реализации для некоторых из моих подпрограмм, но затем отвлекся (прежде, чем я перешел к версиям с плавающей запятой f0128, f0256, f0512, f1024, f2048, f4096). Но я закончил целочисленное умножение и деление. Циклы умножения управляются таблицей; каждый шаг - 64-битный * 64-битный === ›› 128-битный результат (затем добавьте его). Разделение также выполняется на 64-битные фрагменты, аналогично старому методу школы моды. Если вы хотите сравнить скорость этих подпрограмм с вашей, я могу прислать свой код (все 64-битные сборки att / gas / linux). - person honestann; 19.03.2014

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

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 / г)).

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

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

person gnasher729    schedule 18.03.2014

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

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

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

person gnasher729    schedule 18.03.2014