Подтвердить что ты не робот

Эффективное вычисление (a - K)/(a ​​+ K) с улучшенной точностью

В различных контекстах, например, для сокращения аргументов для математических функций, нужно вычислить (a - K) / (a + K), где a - аргумент положительной переменной, а K - постоянная. Во многих случаях K является степенью двух, что является прецедентом, относящимся к моей работе. Я ищу эффективные способы более точно вычислить этот коэффициент, чем это можно сделать при прямом разделении. Можно предположить аппаратную поддержку плавного многократного добавления (FMA), так как эта операция обеспечивается всеми основными архитектурами CPU и GPU в настоящее время и доступна в C/С++ с помощью функций fma() и fmaf().

Для удобства исследования я экспериментирую с арифметикой float. Поскольку я планирую перенести подход на арифметику double, то никакие операции, использующие более высокую, чем внутренняя точность как аргумента, так и результата, могут быть использованы. Мое лучшее решение до сих пор:

 /* Compute q = (a - K) / (a + K) with improved accuracy. Variant 1 */
 m = a - K;
 p = a + K;
 r = 1.0f / p;
 q = m * r;
 t = fmaf (q, -2.0f*K, m);
 e = fmaf (q, -m, t);
 q = fmaf (r, e, q);

Для аргументов a в интервале [K/2, 4.23*K] приведенный выше код вычисляет фактор, почти правильно округленный для всех входов (максимальная ошибка чрезвычайно близка к 0,5 ulps) при условии, что K является степенью 2, а там не является переполнением или недостаточным промежуточным результатом. Для K, не являющегося степенью двух, этот код еще точнее, чем наивный алгоритм, основанный на делении. Что касается производительности, этот код может быть быстрее, чем наивный подход на платформах, где обратная точка с плавающей запятой может быть вычислена быстрее, чем деление с плавающей запятой.

Я делаю следующее наблюдение, когда K= 2 n: когда верхняя граница рабочего интервала возрастает до 8*K, 16*K,... максимальная ошибка увеличивается постепенно и начинается медленно аппроксимировать максимальную ошибку наивного вычисления снизу. К сожалению, то же самое, как представляется, не верно для нижней границы интервала. Если нижняя граница падает до 0.25*K, максимальная ошибка улучшенного метода выше равна максимальной ошибке наивного метода.

Существует ли метод вычисления q = (a - K)/(a ​​+ K), который может достичь меньшей максимальной ошибки (измеренной в ulp по сравнению с математическим результатом) по сравнению с наивным методом и приведенную выше кодовую последовательность на более широком интервале, в частности, для интервалов, нижняя граница которых меньше 0.5*K? Эффективность важна, но может быть допущено несколько операций, чем в приведенном выше коде.


В одном из ответов ниже было указано, что я мог бы повысить точность, возвращая фактор как неоценимую сумму двух операндов, то есть в виде пары хвоста q:qlo, то есть аналогичной хорошо известной двойной - float и double- double. В моем коде выше это означало бы замену последней строки на qlo = r * e.

Этот подход, безусловно, полезен, и я уже рассматривал его использование для логарифма с расширенной точностью для использования в pow(). Но это принципиально не помогает с желательным расширением интервала, на котором расширенное вычисление обеспечивает более точные коэффициенты. В конкретном случае, на который я смотрю, я хотел бы использовать K=2 (для одиночной точности) или K=4 (для двойной точности), чтобы сохранить интервал первичной аппроксимации узким, а интервал для a примерно равен [0, 28]. Практическая проблема, с которой я столкнулся, заключается в том, что для аргументов < 0,25 * К точность улучшенного деления не намного лучше, чем с наивным методом.

4b9b3361

Ответ 1

Поскольку моя цель - просто расширить интервал, на котором достигаются точные результаты, а не находить решение, которое работает для всех возможных значений a, использование арифметики double-float для всех промежуточных вычислений кажется слишком дорогостоящим.

Подумав еще немного о проблеме, ясно, что вычисление остатка деления e в коде из моего вопроса является важной частью достижения более точного результата. Математически остаток равен (a-K) - q * (a + K). В моем коде я просто использовал m для представления (a-K) и представлял (a + k) как m + 2*K, поскольку это обеспечивает численные превосходные результаты для простого представления.

При относительно небольших дополнительных вычислительных затратах (a + K) можно представить как double- float, то есть пару с хвостом p:plo, что приводит к следующей модифицированной версии моего исходного кода:

/* Compute q = (a - K) / (a + K) with improved accuracy. Variant 2 */
m = a - K;
p = a + K;
r = 1.0f / p;
q = m * r;
mx = fmaxf (a, K);
mn = fminf (a, K);
plo = (mx - p) + mn;
t = fmaf (q, -p, m);
e = fmaf (q, -plo, t);
q = fmaf (r, e, q);

Тестирование показывает, что это дает почти корректные округленные результаты для a в [K/2, 2 24 * K), что позволяет значительно увеличить верхнюю границу интервала, на котором точность результаты достигаются.

Расширение интервала на нижнем конце требует более точного представления (a-K). Мы можем вычислить это как пару с двумя хвостами float m:mlo, что приводит к следующему варианту кода:

/* Compute q = (a - K) / (a + K) with improved accuracy. Variant 3 */
m = a - K;
p = a + K;
r = 1.0f / p;
q = m * r;
plo = (a < K) ? ((K - p) + a) : ((a - p) + K);
mlo = (a < K) ? (a - (K + m)) : ((a - m) - K);
t = fmaf (q, -p, m);
e = fmaf (q, -plo, t);
e = e + mlo;
q = fmaf (r, e, q);

Исчерпывающие испытания показывают, что это дает почти правильно округленные результаты для a в интервале [K/2 24 K * 2 24). К сожалению, это связано с затратами в десять дополнительных операций по сравнению с кодом в моем вопросе, который является крутой ценой, чтобы получить максимальную ошибку примерно с 1.625 ulps с наивным вычислением до почти 0,5 ulp.

Как и в моем исходном коде из вопроса, можно выразить (a + K) через (a-K), тем самым исключая вычисление хвоста p, plo. Этот подход приводит к следующему коду:

/* Compute q = (a - K) / (a + K) with improved accuracy. Variant 4 */
m = a - K;
p = a + K;
r = 1.0f / p;
q = m * r;
mlo = (a < K) ? (a - (K + m)) : ((a - m) - K);
t = fmaf (q, -2.0f*K, m);
t = fmaf (q, -m, t);
e = fmaf (q - 1.0f, -mlo, t);
q = fmaf (r, e, q);

Это оказывается выгодным, если основное внимание уделяется уменьшению нижней границы интервала, что является моим особым фокусом, как объяснялось в вопросе. Исчерпывающее тестирование случая с одной точностью показывает, что при K = 2 n получены почти правильно округленные результаты для значений a в интервале [K/2 24 4,23 * K]. В общей сложности 14 или 15 операций (в зависимости от того, поддерживает ли архитектура полное предикация или просто условные перемещения), для этого требуется от семи до восьми операций, кроме моего исходного кода.

Наконец, можно было бы основывать остаточное вычисление непосредственно на исходной переменной a, чтобы избежать ошибки, присущей вычислению m и p. Это приводит к следующему коду, который при K = 2 n вычисляет почти правильно округленные результаты для a в интервале [K/2 24 K/3)

/* Compute q = (a - K) / (a + K) with improved accuracy. Variant 5 */
m = a - K;
p = a + K;
r = 1.0f / p;       
q = m * r;
t = fmaf (q + 1.0f, -K, a);
e = fmaf (q, -a, t);
q = fmaf (r, e, q);

Ответ 2

Если a велико по сравнению с K, то (a-K)/(a ​​+ K) = 1 - 2K/(a ​​+ K) даст хорошее приближение. Если а мало по сравнению с К, то 2а/(а + К) - 1 даст хорошее приближение. Если K/2 ≤ a ≤ 2K, то a-K является точной операцией, поэтому деление даст достойный результат.

Ответ 3

Одна из возможностей заключается в отслеживании ошибки m и p в m1 и p1 с классическим Dekker/Schewchuk:

m=a-k;
k0=a-m;
a0=k0+m;
k1=k0-k;
a1=a-a0;
m1=a1+k1;

p=a+k;
k0=p-a;
a0=p-k0;
k1=k-k0;
a1=a-a0;
p1=a1+k1;

Затем исправьте наивное деление:

q=m/p;
r0=fmaf(p,-q,m);
r1=fmaf(p1,-q,m1);
r=r0+r1;
q1=r/p;
q=q+q1;

Это будет стоить вам 2 дивизии, но должно быть около половины ульпа, если я не испортил.

Но эти деления могут быть заменены умножениями с обратным к p без каких-либо проблем, так как первое неправильно округленное деление будет компенсироваться остатком r, а второе неправильное округленное деление не имеет большого значения (последние бит поправки q1 выиграл ' t что-либо изменить).

Ответ 4

У меня нет ответа (правильный анализ ошибок с плавающей запятой очень утомительный), но несколько наблюдений:

  • Быстрые взаимные инструкции (такие как RCPSS) не так точны, как деление, поэтому вы можете увидеть снижение точности при использовании этих
  • m вычисляется точно, если a & in; [0,5 & times; K b, 2 1 + n & times; K b), где K b - это мощность 2 ниже K (или сама K, если K - степень 2), а n - число конечных нулей в значении K (т.е. если K - степень 2, то n = 23).
  • Это похоже на упрощенную форму алгоритма div2 из Dekker (1971): расширить диапазон (в частности, нижнюю границу), вам, вероятно, придется включить в него дополнительные корректирующие условия (т.е. сохранить m как сумму 2 float s или использовать double).

Ответ 5

Если вы можете расслабиться API, чтобы вернуть другую переменную, которая моделирует ошибку, тогда решение становится намного проще:

float foo(float a, float k, float *res)
{
    float ret=(a-k)/(a+k);
    *res = fmaf(-ret,a+k,a-k)/(a+k);
    return ret;
}

Это решение обрабатывает только ошибку усечения деления, но не обрабатывает потерю точности a+k и a-k.

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

Тестовый код обновляется, чтобы искусственно генерировать ненулевые младшие значащие биты на входе

тестовый код

https://ideone.com/bHxAg8

Ответ 6

Проблема заключается в добавлении в (a + K). Любая потеря точности в (a + K) увеличивается делением. Проблема заключается не в самом делении.

Если показатели a и K совпадают (почти), точность не теряется, и если абсолютная разница между показателями больше значимого размера, то либо (a + K) == a (если a имеет большая величина) или (a + K) == K (если K имеет большую величину).

Невозможно предотвратить это. Увеличение значимого размера (например, с использованием 80-битного "расширенного двойника" на 80x86) помогает лишь слегка увеличить "точный диапазон результатов". Чтобы понять, почему, рассмотрим smallest + largest (где smallest - наименьшая положительная денормальность, может быть 32-битное число с плавающей запятой). В этом случае (для 32-битных поплавков) для получения результата потребуется примерно 260 бит, чтобы полностью избежать потери точности. Выполнение (например,) temp = 1/(a + K); result = a * temp - K / temp; не поможет, потому что у вас все еще есть точно такая же проблема (a + K) (но это позволит избежать аналогичной проблемы в (a - K)). Также вы не можете сделать result = anything / p + anything_error/p_error, потому что деление не работает так.

Есть только 3 альтернативы, которые я могу придумать, чтобы приблизиться к 0.5 ulps для всех возможных положительных значений a, которые могут поместиться в 32-битную плавающую точку. Никто не может быть приемлемым.

Первая альтернатива предполагает предварительную вычисление таблицы поиска (с использованием математики "большого реального числа" ) для каждого значения a, которое (с некоторыми трюками) заканчивается примерно 2 GiB для 32-битной с плавающей запятой (и совершенно безумный для 64-битной плавающей запятой). Конечно, если диапазон возможных значений a меньше, чем "любое положительное значение, которое может поместиться в 32-битный float", размер таблицы поиска будет уменьшен.

Второй вариант - использовать что-то другое ( "большое реальное число" ) для вычисления во время выполнения (и конвертировать в/из 32-разрядной плавающей запятой).

Третий вариант подразумевает "что-то" (я не знаю, что он назвал, но это дорого). Установите для режима округления значение "округлить до положительной бесконечности" и вычислите temp1 = (a + K); if(a < K) temp2 = (a - K);, затем переключитесь на "круглую на отрицательную бесконечность" и вычислите if(a >= K) temp2 = (a - K); lower_bound = temp2 / temp1;. Затем сделайте a_lower = a и уменьшите a_lower на наименьшую возможную сумму и повторите вычисление "lower_bound", и продолжайте делать это, пока не получите другое значение для lower_bound, а затем вернитесь к предыдущему значению a_lower. После этого вы делаете по существу одинаковые (но противоположные режимы округления и увеличиваете не уменьшающиеся), чтобы определить upper_bound и a_upper (начиная с исходного значения a). Наконец, интерполируем, например a_range = a_upper - a_lower; result = upper_bound * (a_upper - a) / a_range + lower_bound * (a - a_lower) / a_range;. Обратите внимание, что вам нужно будет вычислить начальную верхнюю и нижнюю границу и пропустить все это, если они равны. Также следует предупредить, что все это "теоретически, полностью непроверено", и я, вероятно, его где-то спрятал.

В основном, что я говорю, это то, что (на мой взгляд) вам следует отказаться и принять, что нет ничего, что вы можете сделать, чтобы приблизиться к 0,5 ulp. Извините..:)