Разберитесь с численной нестабильностью суммирования большого массива чисел в плавающей точке: иллюстрируйте на примере почему простой суммирующий цикл даёт ошибки, опишите алгоритм Кахана и другие техники уменьшения ошибок и оцените их стоимость
Кратко по сути и с примером. 1) Почему простой цикл даёт ошибки - Модель округления: при сложении в плавающей точке fl(a+b)=(a+b)(1+δ),∣δ∣≤ϵ,fl(a+b)=(a+b)(1+\delta),\qquad |\delta|\le\epsilon,fl(a+b)=(a+b)(1+δ),∣δ∣≤ϵ,
где ϵ\epsilonϵ — машинный эпсилон (для IEEE‑754 double ϵ≈2−53≈1.11⋅10−16\epsilon\approx2^{-53}\approx1.11\cdot10^{-16}ϵ≈2−53≈1.11⋅10−16). - Накопление ошибок: при наивном суммировании слева направо погрешность растёт примерно как O(nϵ)O(n\epsilon)O(nϵ). Формально для вычисленной суммы s^n\hat s_ns^n и истинной s=∑i=1nxis=\sum_{i=1}^n x_is=∑i=1nxi справедлива оценка ∣s^n−s∣≤γn−1∑i=1n∣xi∣,γm=mϵ1−mϵ.\;|\hat s_n-s|\le\gamma_{n-1}\sum_{i=1}^n|x_i|,\qquad \gamma_m=\frac{m\epsilon}{1-m\epsilon}.∣s^n−s∣≤γn−1i=1∑n∣xi∣,γm=1−mϵmϵ. Простой иллюстративный пример (double): числа [1, 10−16, −1][1,\;10^{-16},\;-1][1,10−16,−1]. Истинная сумма 1+10−16−1=10−161+10^{-16}-1=10^{-16}1+10−16−1=10−16. Наивно слева направо: - 1+10−161+10^{-16}1+10−16 округляется к 111 (потеря малого слагаемого), затем 1−1=01-1=01−1=0. Результат 000 — потеря информации. Kahan‑компенсация (см. ниже) восстановит значение близкое к 10−1610^{-16}10−16. 2) Алгоритм Кахана (Kahan compensated summation) Псевдокод (s — накопитель, c — компенсатор, инициализировать s=0, c=0s=0,\;c=0s=0,c=0): for each xxx do y=x−c\;y = x - cy=x−c t=s+y\;t = s + yt=s+y c=(t−s)−y\;c = (t - s) - yc=(t−s)−y s=t\;s = ts=t
end Математически: y=xi−c,t=s+y,c=(t−s)−y,s=t.\;y=x_i-c,\qquad t=s+y,\qquad c=(t-s)-y,\qquad s=t.y=xi−c,t=s+y,c=(t−s)−y,s=t. Пояснение: ccc аккумулирует потерянные при предыдущих сложениях мелкие биты; при следующем шаге они вычитаются из входа и корректируют сумму. Эффект и стоимость: - Значительно уменьшает накопление погрешности; в большинстве практических случаев даёт почти машинную точность результата. - Стоимость: O(n)O(n)O(n) сложений, но на каждый вход ~5 операций вместо 1 (доступ к памяти тот же). Практический замедляющий множитель ≈ 1.5 − 3×1.5\!-\!3\times1.5−3× по сравнению с наивным суммированием; дополнительная память O(1)O(1)O(1). 3) Другие техники и их оценка - Neumaier (улучшение для случаев, когда |s| < |x|): почти как Kahan, но чуть проще обработка знаков. Стоимость и эффект аналогичны Kahan, лучше в некоторых ситуациях с большими слагаемыми. - Pairwise (divide‑and‑conquer) / Tree summation: разбить массив пополам, суммировать рекурсивно и складывать результаты. - Ошибка ограничена через глубину дерева: вместо γn−1\gamma_{n-1}γn−1 появляется γ⌈log2n⌉\gamma_{\lceil\log_2 n\rceil}γ⌈log2n⌉. То есть погрешность растёт как O(logn⋅ϵ)O(\log n\cdot\epsilon)O(logn⋅ϵ) вместо O(nϵ)O(n\epsilon)O(nϵ). - Стоимость: общее число операций O(n)O(n)O(n) (приблизительно такое же), но накладные расходы на рекурсию/перестановку данных; хорошо распараллеливается (глубина O(logn)O(\log n)O(logn)). Память: при рекурсивной реализации потребуется стек/временные буферы O(logn)O(\log n)O(logn) или O(n)O(n)O(n) для итеративной перестановки. - Сортировка по возрастанию абсолютных значений перед суммированием: - Уменьшает явления отмены; хорошая практика при большом разбросе модулей. - Стоимость: дополнительно O(nlogn)O(n\log n)O(nlogn) сравнений и перестановок; итоговая погрешность близка к минимально возможной для фиксированной точности. - Блочное суммирование + компенсация: разбить на блоки, в каждом блоке применять Kahan или pairwise, затем суммировать блоковые суммы pairwise. Хороший компромисс точности/параллелизма. - Высокая точность (long double, quad, arbitrary precision): - Простое и надёжное решение: вычислять в большей точности. - Стоимость: аппаратный long double ~1.5–4×, программный quad или MPFR — десятки/сотни раз медленнее. Память и энергорасходы растут. - Compensated expansions / double-double / Shewchuk: хранить сумму как расширение (несколько чисел), использовать точные преобразования TwoSum, FastTwoSum. - Позволяет получить контролируемую (или практически точную) сумму, вплоть до произвольной точности, при возрастании числа компонент расширения. - Стоимость линейно зависит от длины расширения; double‑double даёт ≈2× точности при ~3–6× затратах; более длинные расширения — пропорционально дороже. 4) Практические рекомендации - Для большинства задач используйте Kahan (или Neumaier) — почти всегда существенное улучшение при малой цене. - Если данные имеют сильно различающиеся порядки и точность критична — используйте pairwise summation (особенно в параллельной среде) или сортировку по модулю перед суммированием. - Для гарантированной точности используйте более высокую точность или компенсированные расширения (double‑double / MPFR) — при этом будьте готовы к значительному замедлению. - Для больших массивов в HPC: комбинация pairwise (для параллелизма) + локальный Kahan в блоках — хороший компромисс. Краткая сравнительная сводка (точность / стоимость): - Наивное: скорость O(n)O(n)O(n), память O(1)O(1)O(1), ошибка O(nϵ)O(n\epsilon)O(nϵ). - Kahan/Neumaier: скорость O(n)O(n)O(n) (с фактором ≈1.5–3), память O(1)O(1)O(1), ошибка ≈O(ϵ)O(\epsilon)O(ϵ) в большинстве случаев. - Pairwise: скорость O(n)O(n)O(n), лучше параллелизуется, ошибка O(logn⋅ϵ)O(\log n\cdot\epsilon)O(logn⋅ϵ). - Сортировка по модулю: точность очень улучшает, стоимость O(nlogn)O(n\log n)O(nlogn). - Высокая точность / расширения: стоимость ×(2…100+), точность существенно выше (до произвольной). Если нужно, могу привести короткую реализацию Kahan и показать тот же пример численно (пошагово).
1) Почему простой цикл даёт ошибки
- Модель округления: при сложении в плавающей точке
fl(a+b)=(a+b)(1+δ),∣δ∣≤ϵ,fl(a+b)=(a+b)(1+\delta),\qquad |\delta|\le\epsilon,fl(a+b)=(a+b)(1+δ),∣δ∣≤ϵ, где ϵ\epsilonϵ — машинный эпсилон (для IEEE‑754 double ϵ≈2−53≈1.11⋅10−16\epsilon\approx2^{-53}\approx1.11\cdot10^{-16}ϵ≈2−53≈1.11⋅10−16).
- Накопление ошибок: при наивном суммировании слева направо погрешность растёт примерно как O(nϵ)O(n\epsilon)O(nϵ). Формально для вычисленной суммы s^n\hat s_ns^n и истинной s=∑i=1nxis=\sum_{i=1}^n x_is=∑i=1n xi справедлива оценка
∣s^n−s∣≤γn−1∑i=1n∣xi∣,γm=mϵ1−mϵ.\;|\hat s_n-s|\le\gamma_{n-1}\sum_{i=1}^n|x_i|,\qquad
\gamma_m=\frac{m\epsilon}{1-m\epsilon}.∣s^n −s∣≤γn−1 i=1∑n ∣xi ∣,γm =1−mϵmϵ .
Простой иллюстративный пример (double): числа [1, 10−16, −1][1,\;10^{-16},\;-1][1,10−16,−1]. Истинная сумма 1+10−16−1=10−161+10^{-16}-1=10^{-16}1+10−16−1=10−16. Наивно слева направо:
- 1+10−161+10^{-16}1+10−16 округляется к 111 (потеря малого слагаемого), затем 1−1=01-1=01−1=0. Результат 000 — потеря информации.
Kahan‑компенсация (см. ниже) восстановит значение близкое к 10−1610^{-16}10−16.
2) Алгоритм Кахана (Kahan compensated summation)
Псевдокод (s — накопитель, c — компенсатор, инициализировать s=0, c=0s=0,\;c=0s=0,c=0):
for each xxx do
y=x−c\;y = x - cy=x−c
t=s+y\;t = s + yt=s+y
c=(t−s)−y\;c = (t - s) - yc=(t−s)−y
s=t\;s = ts=t end
Математически:
y=xi−c,t=s+y,c=(t−s)−y,s=t.\;y=x_i-c,\qquad t=s+y,\qquad c=(t-s)-y,\qquad s=t.y=xi −c,t=s+y,c=(t−s)−y,s=t.
Пояснение: ccc аккумулирует потерянные при предыдущих сложениях мелкие биты; при следующем шаге они вычитаются из входа и корректируют сумму.
Эффект и стоимость:
- Значительно уменьшает накопление погрешности; в большинстве практических случаев даёт почти машинную точность результата.
- Стоимость: O(n)O(n)O(n) сложений, но на каждый вход ~5 операций вместо 1 (доступ к памяти тот же). Практический замедляющий множитель ≈ 1.5 − 3×1.5\!-\!3\times1.5−3× по сравнению с наивным суммированием; дополнительная память O(1)O(1)O(1).
3) Другие техники и их оценка
- Neumaier (улучшение для случаев, когда |s| < |x|): почти как Kahan, но чуть проще обработка знаков. Стоимость и эффект аналогичны Kahan, лучше в некоторых ситуациях с большими слагаемыми.
- Pairwise (divide‑and‑conquer) / Tree summation: разбить массив пополам, суммировать рекурсивно и складывать результаты.
- Ошибка ограничена через глубину дерева: вместо γn−1\gamma_{n-1}γn−1 появляется γ⌈log2n⌉\gamma_{\lceil\log_2 n\rceil}γ⌈log2 n⌉ . То есть погрешность растёт как O(logn⋅ϵ)O(\log n\cdot\epsilon)O(logn⋅ϵ) вместо O(nϵ)O(n\epsilon)O(nϵ).
- Стоимость: общее число операций O(n)O(n)O(n) (приблизительно такое же), но накладные расходы на рекурсию/перестановку данных; хорошо распараллеливается (глубина O(logn)O(\log n)O(logn)). Память: при рекурсивной реализации потребуется стек/временные буферы O(logn)O(\log n)O(logn) или O(n)O(n)O(n) для итеративной перестановки.
- Сортировка по возрастанию абсолютных значений перед суммированием:
- Уменьшает явления отмены; хорошая практика при большом разбросе модулей.
- Стоимость: дополнительно O(nlogn)O(n\log n)O(nlogn) сравнений и перестановок; итоговая погрешность близка к минимально возможной для фиксированной точности.
- Блочное суммирование + компенсация: разбить на блоки, в каждом блоке применять Kahan или pairwise, затем суммировать блоковые суммы pairwise. Хороший компромисс точности/параллелизма.
- Высокая точность (long double, quad, arbitrary precision):
- Простое и надёжное решение: вычислять в большей точности.
- Стоимость: аппаратный long double ~1.5–4×, программный quad или MPFR — десятки/сотни раз медленнее. Память и энергорасходы растут.
- Compensated expansions / double-double / Shewchuk: хранить сумму как расширение (несколько чисел), использовать точные преобразования TwoSum, FastTwoSum.
- Позволяет получить контролируемую (или практически точную) сумму, вплоть до произвольной точности, при возрастании числа компонент расширения.
- Стоимость линейно зависит от длины расширения; double‑double даёт ≈2× точности при ~3–6× затратах; более длинные расширения — пропорционально дороже.
4) Практические рекомендации
- Для большинства задач используйте Kahan (или Neumaier) — почти всегда существенное улучшение при малой цене.
- Если данные имеют сильно различающиеся порядки и точность критична — используйте pairwise summation (особенно в параллельной среде) или сортировку по модулю перед суммированием.
- Для гарантированной точности используйте более высокую точность или компенсированные расширения (double‑double / MPFR) — при этом будьте готовы к значительному замедлению.
- Для больших массивов в HPC: комбинация pairwise (для параллелизма) + локальный Kahan в блоках — хороший компромисс.
Краткая сравнительная сводка (точность / стоимость):
- Наивное: скорость O(n)O(n)O(n), память O(1)O(1)O(1), ошибка O(nϵ)O(n\epsilon)O(nϵ).
- Kahan/Neumaier: скорость O(n)O(n)O(n) (с фактором ≈1.5–3), память O(1)O(1)O(1), ошибка ≈O(ϵ)O(\epsilon)O(ϵ) в большинстве случаев.
- Pairwise: скорость O(n)O(n)O(n), лучше параллелизуется, ошибка O(logn⋅ϵ)O(\log n\cdot\epsilon)O(logn⋅ϵ).
- Сортировка по модулю: точность очень улучшает, стоимость O(nlogn)O(n\log n)O(nlogn).
- Высокая точность / расширения: стоимость ×(2…100+), точность существенно выше (до произвольной).
Если нужно, могу привести короткую реализацию Kahan и показать тот же пример численно (пошагово).