Ситуация сложилась так, что временно три компьютера мне не доступны для теста производительности на решении СЛАУ методом Гаусса-Жордана. Поэтому пока я жду их доступности, что бы получить результаты, и тогда проведу детальный анализ.
Во время ожидания у меня возникла необходимость посчитать обратный элемент по модулю. Это можно делать либо с помощью расширенного алгоритма Евклида, либо по теореме Эйлера с помощью быстрого возведения в степень.
Сам-то я не очень силен в модулярной арифметике, поэтому принялся искать готовое описание, годное для реализации, расширенного алгоритма Евклида. Не скажу, что бы прям сильно глубоко искал, но потратил в районе часа.
И выяснил в результате, что в русскоязычном сегменте Инета нет годного внятного описания алгоритма. В англоязычном искал существенно меньше, но и там, похоже, ситуация не лучше. То, что нашел, работает лишь частично верно.
В результате получил верно работающую лишь рекурсивную версию классического расширенного алгоритма Евклида. Догадываюсь, что можно перенести на итерации, но что-то туплю, и не могу понять, как это сделать. И самое главное: не ясно, будет ли с этого какой толк в плане производительности. Возможно, кому-то понадобится рабочий вариант, поэтому выкладываю его на Pascal:
function ExtEuclidRec(X, Y : Int64; var A, B : Int64) : Int64;
var
OldA : Int64;
begin
OldA := X mod Y;
if OldA = 0 then
begin
Result := Y;
A := 0;
B := 1;
end
else
begin
Result := ExtEuclidRec(Y, OldA, A, B);
OldA := A;
A := B;
B := OldA - B*(X div Y);
end;
end;
Уточню обозначения. Расширенный алгоритм Евклида фактически решает линейное диофантово уравнение Ax + By = D.
Встречаются разные трактовки переменных в этом выражении. У меня x и у ‒ заданные значения, а A и B ‒ те константы, которые находит алгоритм Евклида, D же, соответственно ‒ наибольший общий делитель x и у.
Часто встречает обратный подход, когда A и B задано, а x и y необходимо найти.
Не тестил, но, возможно при значения x, y, близких к 2^63 будет работать неверно, из-за переполнения при операциях вычитания. При меньших значения работает всегда верно.
Есть еще бинарный вариант расширенного алгоритма Евклида. Вроде как в обычном варианте, по утверждению Кнута, бинарный вариант быстрее на 60% классического. Но с того момента много воды утекло, и операции деления, хоть и остаются медленными, но стали все же намного быстрее, чем это было 20 лет назад, поэтому это утверждение требует проверки на современных компьютерах. Тем не менее, вполне допускаю это для обычного алгоритма Евклида, так как он вполне себе компактен и красив.
Но вот та версия расширенного бинарного алгоритма, что мне удалось найти, не так как красива, как обычного. Приведу ее рабочий и протестированный вариант:
function ExtEuclidBin(X, Y : Int64; var A, B : Int64) : Int64;
var
g : int64;
u, v : Int64;
A1, B1, C, D : Int64;
begin
g := 1;
while (X and 1 = 0) and (Y and 1 = 0) do
begin
X := X shr 1;
Y := Y shr 1;
g := g shl 1;
end;
u := X;
v := Y;
A1 := 1;
B1 := 0;
C := 0;
D := 1;
repeat
while (u and 1 = 0) do
begin
u := u shr 1;
if (A1 and 1 = 0) and (B1 and 1 = 0) then
begin
A1 := A1 div 2;
B1 := B1 div 2;
end
else
begin
A1 := (A1+Y) div 2;
B1 := (B1-X) div 2;
end;
end;
while (v and 1 = 0) do
begin
v := v shr 1;
if (C and 1 = 0) and (D and 1 = 0) then
begin
C := C div 2;
D := D div 2;
end
else
begin
C := (C+Y) div 2;
D := (D-X) div 2;
end;
end;
if u > v then
begin
u := u-v;
A1 := A1-C;
B1 := B1-D;
end
else
begin
v := v-u;
C := C-A1;
D := D-B1;
end;
until (u = 0) or (v=0);
if u = 0 then
begin
Result := v*g;
A := C;
B := D;
end
else
begin
Result := u*g;
A := A1;
B := B1;
end;
end;
Здесь такое же обозначение переменных, как и в предыдущем примере.
Это просто жесть, конечно, по сравнению с компактной рекурсивной записью расширенного алгоритма Евклида в классическом варианте. Как думаете, будет ли такой алгоритм работать быстрее классического?
Кроме сложности алгоритма, у него есть и другие особенности. В частности, линейное диофантово уравнение допускает множество решений, и классический расширенный алгоритм Евклида дает минимальный вариант коэффициентов А и B, в то время как бинарный дает произвольное правильное решение, то есть найденные А и B могут быть не минимальными по абсолютному значению.
В связи с этим у приведенного бинарного алгоритма есть более серьезное ограничение, чем у классического варианта: он гарантированно корректно работает только при x и у < 2^32. Вызвано это возможным переполнением при вычислении A1, B1, C, D. Может быть, и есть вариант, у которого данное ограничение отсутствует, но мне про него не известно.
Кстати, любопытно, но последние версии Delphi не умеют оптимизировать деление знаковых целых на два, хотя, например, Delphi 7 делала это влет.
Итеративный алгоритм вроде аналогичен итеративному обычного алгоритма Евклида: gcd(x, y) = gcd(y, x%y) = ... = gcd(d, 0), в расширенном только нужно видоизменять уравнения целиком:
ОтветитьУдалитьНа i-й итерации:
a_prev * x + b_prev * y = d_prev
a * x + b * x = d
На i+1 итерации:
a_next * x + b_next * y = d_next
По алгоритму Евклида: d_next = d_prev % d = d_prev - d * (d_prev / d)
Подставляем уравнения из i-й итерации, получаем:
a_next = a_prev - a*(d_prev / d)
b_next = b_prev - b*(d_prev / d)
d_next = d_prev - d*(d_prev / d)
Далее заменяем xx_prev = xx, xx = xx_next и повторяем, пока d_next != 0
В итоге получаем:
a * x + b * y = d где d - НОД
А ну и для 1-й итерации:
1 * x + 0 * y = x
0 * x + 1 * y = y
Дальнейшая оптимизация: считать обе "a" и "b" не обязательно, достаточно только "a" или "b", а в конце можно легко найти вторую зная "d"
Реализация бинарного варианта какая-то запутанная :) я бы для начала переименовал u -> D1, v -> D2, C -> A2, D -> B2, тогда все становится понятнее, по крайней мере мне :), все те же 2 уравнения:
A1 * X + B1 * Y = D1
A2 * X + B2 * Y = D2
Которые трансформируются используя:
- НОД(2*P, 2*Q) = 2*НОД(P, Q) - 1-й цикл в функции
- НОД(2*P, 2*Q+1) = НОД(P, 2*Q+1) - 2 внутренних цикла. Там правда есть хитрость: D может быть чётным, а A и B оба нечетными, в этом случае нужно преобразовывать уравнение: A*X+B*Y=D в (A+Y)*X+(B-X)*Y=D Можно доказать что пары A+Y и B-X станут четными.
- НОД(P, Q) = НОД(abs(P-Q), min(P, Q)) - 2 условия в конце repeat цикла.
Я бы чуток по проще написал основной цикл:
1. Если D1 > D2 (u и v в текущей реализации), меняем уравнения местами
2. A2 -= A1, B2 -= B1, D2 -= D1
3. Если D2 0 выходим
4. Пока D2 четное: сдвигаем, обновляя A2 и B2 аналогично текущей реализации
Во 1-х это будет короче, во 2-х меньше условий, если я конечно не напутал, но это не убирает проблему переполнения.
И также можно обойтись подсчетом только A или B, и в конце, зная D, рассчитать вторую переменную.
Какой метод быстрее? Так сходу сложно сказать... Голосую за бинарный :)
Эх... мое упрощение бинарного алгоритма не оптимально, оно может привести к многократному вычитанию маленького четного из большого нечетного.
УдалитьО, спасибо, Иван! Намного понятнее стало.
УдалитьЯ итеративный вариант так и делал, но переносил без особого понимания, отвлекся, одну команду не дописал, в другой опечатался... Но был уверен, что все верно сделал. )))
С бинарным буду разбираться, есть впечатление, что его можно сделать короче, проще и понятней.
С переполнением, похоже, нужно переходить на длинную арифметику, тем более мне все равно нужны числа, близкие к 2^64, а там и в классическом варианте Евклида тоже придется переходить на нее.
Рад что помог :)
Удалить> С бинарным буду разбираться
У меня существенно упростить не получилось, не потеряв в производительности, обязательно выложите если у Вас получится :)
> там и в классическом варианте Евклида тоже придется переходить на нее.
Вроде нет, по соотношению Безу, всегда существуют a <= x/d и b <= y/d, а так как классический алгоритм находит минимальные коэффициенты, то переполнения не возникает.
Я опять не удержался, реализовал и чуть-чуть потестировал :)))
Но сначала оптимизировал, те что Вы выложили:
- рекурсивный: "X mod Y" можно вычислить через "X div Y" и переиспользовать ниже для B. Ускоряет на 40%
- бинарный: как я описал ранее, можно оставить 1 внутренний цикл, совсем убрать 2-й у меня не получилось, но теперь он выполняется только 1 раз перед repeat, плюс избавился от условных переходов, заменив на тернарные условные операции компилируемые в conditional move. Где-то 17% ускорения.
Так как бинарный алгоритм переполняется, тестирую на 32-bit значениях:
- итеративный, int32: 5.3 млн/сек (100%) - где-то 40 инструкций на итерацию, что сравнимо с задержкой у idiv инструкции.
- итеративный, int64: 3.6 млн/сек (68%) - в отличии от умножения, деление - итеративно в процессоре и время выполнения инструкции ~ пропорционально числу бит.
- рекурсивный до оптимизации, int64: 2.6 млн/сек (48%)
- рекурсивный после оптимизации, int64: 3.6 млн/сек (68%) - время выполнения ~ равно итеративной реализации
- бинарный до оптимизации: 3.3 млн/сек (63%)
- бинарный после оптимизации: 3.8 млн/сек (73%) - чуть-чуть быстрее чем итеративный.
И на 64-бит значениях:
- итеративный: 1.8 млн/сек (100%)
- оптимизированный рекурсивный: 1.7 млн/сек (96%)
- бинарный не тестировал, но так как нужна длинная арифметика, что-то я сомневаюсь что будет быстрее.
Почему бинарный не взлетел? Как я понимаю в основном из-за частых условных переходов которые не возможно предсказать, плюс из-за существенно (в разы) большего числа инструкций. Для не расширенного бинарный быстрее, так как внутренний while цикл, заменяется на TZCNT + SHR что явно быстрее и без условных переходов.
Еще убедился что для нахождения НОД, бинарный алгоритм быстрее итерационного (тестировал на 64-bit):
Удалить- итерационный: 2.1 млн/сек, быстрее расширенного алгоритма всего на 17%, узкое место - деление, пока оно выполняется процессор успевает выполнить практически все умножения и вычитания расширенного алгоритма.
- бинарный: 6.7 млн/сек, в 3 раза быстрее итерационного варианта, что ожидаемо.
> Вроде нет, по соотношению Безу, всегда существуют a <= x/d и b <= y/d, а так как классический
Удалить> алгоритм находит минимальные коэффициенты, то переполнения не возникает.
Вроде так, но только для знаковых целых. Как только мы переходим к беззнаковым, и у нас 2^63 < X или Y < 2^64 , то есть вероятность возникновения переполнения.
Хм... да, этого я не учел, но раз знаковые не переполняются, то вроде достаточно добавить всего один бит и представить A и B парами (число по модулю, знак). Останется только реализовать вычитание для такой пары без полноценной длинной арифметики.
Удалить> Останется только реализовать вычитание для такой пары без полноценной длинной арифметики.
УдалитьХм... Не уверен, что это будет сильно быстрее более полноценной длинной арифметики.
> И на 64-бит значениях:
Удалить> - итеративный: 1.8 млн/сек (100%)
Что-то как-то маловато будет )
У меня получается и тот и другой по 7 млн./с выдают. При этом рекурсивный я уже реализовал на ассемблере с длинной арифметикой (урезанной до минимума, естественно).
Может, мы по разному тестируем или я опять где-то накосячил и не заметил?
Тестирую я так:
QueryPerformanceFrequency(Freq);
QueryPerformanceCounter(T1);
for i := MaxInt to MaxInt + c_ItCount do
d := ExtEuclid(2305843009213693951, i, a, b);
QueryPerformanceCounter(T2);
ShowMessage('Скорость расш. алгоритма Евклида '+FloatToStr(c_ItCount*Freq/(T2-T1)));
Здесь 2305843009213693951 - простое число длиной 61 бит, c_ItCount = 9 999 999, что бы цикл выполнил ровно 10 млн. раз.
Попробовал Ваш тест, получил 3.2 млн/сек.
УдалитьПочему на Вашем тесте у меня быстрее? Я тестирую на 100 пар случайных 64-bit чисел, число итераций от 27 до 46, в среднем 36. В Вашем тесте i достаточно маленькое, в итоге число итераций падает до от 3 до 35, в среднем 20. Отношение миллионов: 3.2/1.8 и среднего число операций: 36/20 совпадают.
Но 3.2 млн. все еще более чем в 2 раза медленнее 7 млн. Почему? 3.2 млн. на моем i5-7267U @ 3.5 GHz это где-то 55 тактов на одну итерацию, судя по таблицам инструкций, idiv на skylake выполняется за 42-95 тактов, вроде как в аптеке, в 2 раза у меня быстрее явно не получится :)
У Вас 7 млн, как я понимаю на FX-4350 с частотой 4.3 GHz и того где-то 31 такт на итерацию, плюс у piledriver idiv существенно быстрее, особенно нижняя граница впечатляет: 13-71 тактов. Вроде это объясняет разную скорость.
Справедливости ради, у piledriver только 1 блок может выполнять деление, тогда как у skylake целых 4-е, но увы в алгоритме Евклида - это не пригождается. Число молотилка от AMD побеждает :)))
Тогда понятно.
Удалить> Числомолотилка от AMD побеждает :)))
Честно говоря, сильно удивлен. Почему-то наивно думал, что Intel всегда чуть-чуть быстрее AMD в одном поколении на всех операциях SISD, если не брать последний Ryzen.
Не знаю почему, но как я понял, Intel забил на реализацию 64-бит деления в железе, у skylake оно в микрокоде. А вот 32-битное уже в железе и не на столько сильно скорость должна отличаться. Плюс посмотрел по лучше, я ошибся, у skylake не 4 блока для 4-х не зависимых делений, а одно деление занимает 4 блока.
УдалитьУ Zen 2 деление вроде еще быстрее: 13-44 тактов.