Pull to refresh

Comments 40

Вместо бинарного возведения в степень можно использовать таблицу — она получается не такой уж и большой. Аппроксимацию значения экспоненты в диапазоне (0,1) можно получить с любой заданной точностью многочленами Чебышева.
Попробовал ради интереса на шарпе — получилось в 3 раза быстрее библиотечной и даже чуточку точнее.

double[] powtable = {1,2,4,8,16,32, ...,  8.98846567431157954e307};

double pow2(double x)
{
	bool sign = x < 0.0;
	x = sign ? -x : x;
	int xf = (int)x;
	x -= xf;
	double yt = powtable[xf];
	double y = x * (0.6931471805599465398 + x * (0.2402265069590422222 +
		x * (0.05550410866590987068 + x * (0.00961812909717555934 +
		x * (0.0013333558738165095559 + x * (0.00015403509189194102748 +
		x * (0.000015253232908458899497 + x * (1.3207676270599404858e-6 +
		x * (1.0258347084283025531e-7 + (6.537941907237267033e-9 + 
		6.3026908837748924689e-10 * x) * x)))))))));
	return sign ? 1 / (y * yt + yt) : y * yt + yt;
}


UPD: Если ограничиться точностью single, то будет в 6 раз быстрее.
double y = 2.5868889777e-9 + x * (0.693146928693029174 + 
x * (0.240230502044993504 +x * (0.0554804263257944027 + 
x * (0.00968458045229538239 + (0.0012387821479302309 + 
0.000218775047688226791 * x) * x))));
UPD: можно ускорить в 6 раз и без потери точности, если использовать ещё одну таблицу.
Внезапно, с двумя таблицами результат оказался заметно менее точным, поэтому на практике предпочтительнее первоначальный вариант. Единственно, что после
int xf = (int)x;

не помешает добавить строчку
if (xf >= 1024) return sign ? 0.0 : double.PositiveInfinity;
Думаю, что тут мы достигли теоретического предела. Единственная оставшаяся возможность — это считать всё на FPU с 80-битной точностью. Там и функция fscale есть для возведения двойки в целую степень, чтобы таблицу не городить.
Ээ, но ведь возведение двойки в целую степень — это (для double) просто сдвиг показателя степени на 52 бита влево? Для этого не нужно FPU.
FPU нужен для 80-битной точности, а это значение всё равно в него нужно загружать. Возможно, такой фокус и в C# можно провернуть через BitConverter — не пробовал.
Работает фокус:
double yt = BitConverter.Int64BitsToDouble(((long)(1023 + xf) << 52));

C# для неё нормальный код генерит:
add    edx,3FFh  
movsxd rdx,edx  
shl    rdx,34h  
mov    qword ptr [rsp],rdx  
vmovsd xmm1,qword ptr [rsp]
А как по скорости, в сравнении с таблицей?
Чуть-чуть медленнее, 0.9855 от табличного. По сравнению с библиотечной получилось в 3.55 и 3.6 быстрее на Intel Core i5-5200U.

Цитируемый код Q_rsqrt неполон без авторских комментариев, сохраняющих актуальность и при возведении в произвольную степень - "evil floating point bit level hacking" и "what the fuck?"

Совсем забыл добавить их, вернул. С ними сразу как-то понятней стало.

Странно заниматься ускорением функции pow без разбора exp и log.

У бинарного возведения в степень есть погрешность, потому что умножение не ассоциативно в поле чисел с плавающей точкой. x * x * x * x это не то же самое, что y = x * x, y * y.


Кстати, есть более эффективные небинарные возведения, например x ^ 6 — это y = x * x * x, y * y. В бинарном воведении переменных будет на 1 больше: y = x * x, z = y * y, z * y.

Число переменных на эффективность не влияет, а попарных перемножений три что так что так.

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


Более того, число умножений тоже может быть меньше, например для x^27:


Тернарное умножение: 6 умножений, 2 переменных:
y = x * x * x // 3
z = y * y * y // 9
result = z * z * z // 27

Бинарное умножение: 7 умножений, 6 переменных:
x1 = x * x   // 2
x2 = x1 * x1 // 4
x3 = x2 * x2 // 8
x4 = x3 * x3 // 16
x5 = x4 * x3 // 24
x6 = x5 * x1 // 26
result = x6 * x  // 27
А общая формула есть, чтобы вычислить оптимальное «небинарное» возведение в степень для степени N есть? Я как-то смотрел, но ничего кроме полного перебора вариантов не нашел.

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

Мне кажется есть аналогии с поиском оптимального алгоритма умножения через сдвиги и сложения (вычитания). Там приходится применять 3 алгоритма и выбирать лучший результат: схема Горнера, группировка бит (включая инверсную группировку), разложение на множители и рекурсивное применение поиска для множителей. Для степени я пробовал пойти похожим методом, но не знаю доказательств, что найденные последовательности действий минимальны.

Формулы нет, оптимальная схема возведения в степень находится только поиском. Во всяком случае так было у Кнута, и я не слышал новостей на эту тему.

Выглядит как типичная задача динамического программирования. Сложность решения N^2

Спасибо, что написали про ассоциативность, добавлю пометку.
Однако на практике погрешности не возникает. Видимо, встроенные методы тоже подвержены этому эффекту (на скриншотах ниже погрешность отображается в столбце Sum difference).

Тесты в C++
Тесты в C#
Тесты в Java

Про способ с возведением через кубы я тоже думал. Было бы интересно посмотреть на программную реализацию троичного возведения в степень и сравнить его с другими методами.

Однако на практике погрешности не возникает. Видимо, встроенные методы тоже подвержены этому

А если сравнить с тупым перемножением? Его все равно не помешало бы добавить в таблицу для сравнения.


Про способ с возведением через кубы я тоже думал. Было бы интересно посмотреть на программную реализацию троичного возведения в степень и сравнить его с другими методами.

Там не совсем кубы, а в теории вообще обобщенное количество, которое на больших степенях может давать лучший результат. Но вроде такое разложение NP полное — его имеет смысл использовать разве что в генерации кода, но не в исполнении.

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

Погрешность у них одинаковая, как это ни странно. Возведение в квадрат погрешность удваивает. Умножение на точно известное число добавляет к погрешности маленькую константу. Можно показать что погрешность почти полностью определяется показателем степени, а порядок умножения влияет мало.

Не понял про "точно известное число". При возведении в квадрат аналогично, "точно известное число" умножается на себя.

Исходное число x считается заданным без погрешности. При последовательных умножениях одно число накапливает погрешность, второе не несёт с собой ошибку. При последовательных возведения в квадрат (умножениях) оба сомножителя имеют погрешность.

Спасибо, теперь понял.

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

Алгоритм: Бинарное возведение в степень ...
Погрешность: нет

Я бы не стал так категорично...
  $ echo '
  #include <stdio.h>
  #include <assert.h>
  #include <math.h>
  
  double BinaryPower(double b, unsigned long long e) {
    double v = 1.0;
    while (e) {
      if (e & 1) v *= b;
      b *= b;
      e >>= 1;
    }
    return v;
  }
  
  int main()
  {
    unsigned e = 134217728;
    double barr[] = {1.000001, 1.0000001, 1.00000001, 0}, *b = barr;
    while (*b) {
      printf("calc %.10g ** %u:\n  fast pow ==> %.16f\n  native   ==> %.16f\n",
        *b, e, BinaryPower(*b, e), pow(*b,e));
      b++;
    }
    return 0;
  }
  // ' > test.c; gcc -O2 -Wall -Wextra test.c -o test; ./test
  calc 1.000001 ** 134217728:
-    fast pow ==> 19497974326417947731259943008814618865863786077838628618240.0000000000000000
+    native   ==> 19497974384856317387011039256973190612600604089375799640064.0000000000000000
  calc 1.0000001 ** 134217728:
-    fast pow ==> 674530.4760286132805049
+    native   ==> 674530.4755217875353992
  calc 1.00000001 ** 134217728:
-    fast pow ==> 3.8273676342423024
+    native   ==> 3.8273676116718129

Как видим ошибка может быть довольно значительной, я уж не говорю про -Ofast (без -fno-fast-math).

Поправка: пример компилился в -m32toolchain... для -m64 погрешности действительно нет (по крайней мере на этих числах).

По мнению WolframAlpha:
1.94979745417325751802166478351188792876322264823289192517468 × 10^58
674530.47074108455938268917802974681284444414341034203174237732783
3.8273676654778624379533125217738601044713095101002538124954655334

Т.е. разница между двумя реализациями pow не столь значительна, как разница между ними и точным результатом. В последнем случае «быстрая» реализация ещё и ближе к точному результату, чем «родная».

Ну у Wolfram precision и accuracy настраиваемые (если не ошибаюсь оно умеет длинную FP-арифметику, exact quantities и всё такое)...
Пример алгоритма был для double (aka 64 bits IEEE normalized double-precision floating-point number) и C/C++...
Я это о чем, собственно - что-то математически правильное (и доказуемо верное) в конкретной реализации на конкретном языке для какой-либо платформы может вылиться в неслабую такую погрешность (из-за переполнений, недостаточной precision промежуточных результатов и т.п.)...

Несомненно, что бинарное возведение в степень на практике даёт неточный результат.
Я про другое: что «родная» pow даёт столь же неточный результат.

Несомненно, что бинарное возведение в степень на практике даёт неточный результат.

Ну дак а я о чем, "Погрешность: нет" - как бы не совсем верно, если про этот алгоритм...

Я про другое: что «родная» pow даёт столь же неточный результат.

В рамках double - конечно.
Я вам больше скажу, например тот же bigfloat.pow (даже с precision 400) также выдает "неточный" результат (и он менее точен чем нативный pow):

bigfloat.pow, precision 400 ...
>>> pow(1.000001, 134217728, precision(400))
BigFloat.exact('19497974326443151952384768395025354130242292944980784774315.431771682120918607912604365932634709150271941538088343395908095', precision=400)
>>> pow(1.0000001, 134217728, precision(400))
BigFloat.exact('674530.47602706407027793197206421845263783142259940138702038470170721140355151884268337259494331459943481248761388507182459', precision=400)
>>> pow(1.00000001, 134217728, precision(400))
BigFloat.exact('3.8273676342578585041456476752437466436493872281452027009938444315573508186002376274873069800877205918834944719451685524823', precision=400)

Для сравнения возьмем корни от обоих (этот алгоритм у bigfloat насколько знаю точнее чем pow):

bigfloat.root в сравнении с Wolfram ...
>>> setcontext(precision(220)); p = getcontext().precision;
>>> root(pow(1.000001, 134217728), 134217728)
BigFloat.exact('1.0000009999999999177333620536956004798412322998046875000000000000000', precision=220)
>>> root(BigFloat.exact('1.94979745417325751802166478351188792876322264823289192517468e58', precision=p), 134217728)
BigFloat.exact('1.0000010000000000000000000000000000000000000000000000000000000000002', precision=220)
>>>
>>> root(pow(1.0000001, 134217728), 134217728)
BigFloat.exact('1.0000001000000000583867176828789524734020233154296875000000000000000', precision=220)
>>> root(BigFloat.exact('674530.47074108455938268917802974681284444414341034203174237732783', precision=p), 134217728)
BigFloat.exact('1.0000001000000000000000000000000000000000000000000000000000000000003', precision=220)
>>>
>>> root(pow(1.00000001, 134217728), 134217728)
BigFloat.exact('1.0000000099999999392252902907785028219223022460937500000000000000000', precision=220)
>>> root(BigFloat.exact('3.8273676654778624379533125217738601044713095101002538124954655334', precision=p), 134217728)
BigFloat.exact('1.0000000100000000000000000000000000000000000000000000000000000000001', precision=220)

Но если округлить результаты до оригинальной accuracy - то все результаты верны.
Что не отменяет факт, что результат pow может сильно отличатся и погрешность есть, хоть и небольшая - для bigfloat.pow (и BinaryPower) около 11.04e-7%, а для pow - 08.04e-7%, что очень неплохо для степени 134217728.

│ algorithm    │       error % │
├──────────────┼───────────────┤
│ BinaryPower  | 0.0000011043% │
│ bigfloat.pow │ 0.0000011042% │
│ native pow   │ 0.0000008045% │

-m32 означает что все промежуточные результаты приводятся к float. Не мудрено получить погрешность.

Нет, не приводятся - double он и в Африке double, и что там, что тут - 64 бит (размеры floating-point data types не зависит от разрядности и одинаковы и для x86 и для x64). При этом даже для x86, внутренние FPU регистры - 80 бит. Различие может быть лишь в оптимизации, да в используемых инструкциях (mulsd vs fmul).

А можно ли улучшить "грубый" результат каким-либо алгоритмом уточнения корня (как это делается для быстрого обратного квадратного корня)?

Боюсь, что уточнение результата так же, как и в алгоритме быстрого обратного корня, для произвольной степени будет не оптимально. В быстром обратном корне используется метод Ньютона, который использует производную от изначальной функции. А у нас функция вида f(x)=x^n, так что её производная будет f'(x)=nx^{n-1}, что также содержит в себе степень. Получается, что нам придется помимо того, что возводитьxв степеньn, также возводитьxв степеньn-1, для чего снова придется использовать один из быстрых алгоритмов, который будет снова использовать себя, и так далее, пока не дойдем до какой-то степени, которую можно посчитать за один такт.
По поводу других методов уточнения результата ничего сказать не могу, не изучал эту тему.

Sign up to leave a comment.

Articles