О точности вычислений с плавающей запятой

Недавно я тестировал производительность разных компьютеров с помощью простой фортрановской программки. Идея была проверить 1) насколько быстро процессор справляется с суммированием в цикле и 2) точность суммирования. В моем тесте я вычислял S=\sum j по целым числам в интервале [-N,N] при достаточно больших N. Результат нам известен заранее - мы должны иметь S=0 при любом N. Было интересно проверить насколько быстро и точно компьютер получит 0 прямым суммированием без группировки членов при увеличении N. С помощью этого теста я хотел выбрать оптимальную систему для своих вычислений. Результаты теста мне показались забавными и, рискну предположить, небезЫнтересными, и я решил изложить их в этой небольшой заметке.

Длина вещественной переменной: 64-бит vs. 80-бит

Нас интересует так называемая двойная точность и мы объявим S как 64-битную переменную real*8. Я использовал ``гнутый'' компилятор gfortran и запускал тестовый код на нескольких компьютерах с Linux. На 64-разрядных ОС x86_64 код начинает ошибаться начиная примерно с N = 2**27 ~ 1.3E8. Очевидно, сумма в цикле сначала собирается из членов одного знака и достигает значения S ~ N**2/2 и только после этого к S начинают добавляться члены другого знака. Какая практическая точность у 64-битной переменной типа real*8? Казалось бы число значимых цифр 64-битной переменной можно оценить как lg(2**64) ~ 19. Однако, как нас учит википедия, на практике точность 64-битной вещественной переменной только около 16 цифр. При N=2**27 старшие значения S в цикле уже не помещаются в эту разрядность, код начинает округлять S, ошибки округления накапливаются, и в результате мы не получаем точного сокращения всех членов.

В статье википедии про двойную точность есть ссылка на формат чисел с расширенной двойной точностью (80-бит). Оказывается, что процессоры x86 и x86-64 поддерживают этот формат на уровне железа. Даже более того, 80-битовый формат вещественных чисел является естественным для мат. сопроцессора (387). Сопроцессор проводит внутренние (промежуточные) вычисления в 80-битовом формате, для уменьшения ошибок округления и повышения точности конечного результата. Если ему подсовывают 64-битную переменную, то он ее преобразовывает в 80-бит, причем делает это дважды: на входе и на выходе.

Компиллятор gfortran поддерживает формат переменных расширенной точности как real*10. Практическая точность этого формата (т.е. число значимых цифр), как мы теперь знаем, отличается от наивной оценки lg(2**80) ~ 24 и близка к lg(2**64), т.е. составляет 18 (если повезет, то 19) знаков. Мне показалось интересным проверить как формат real*10 будет работать в моей тестовой программе. Для этого достаточно в заголовках задать параметр Prec=10. Как и ожидалось, точность вычислений при этом существенно повышается, код достойно держится до N=2**32, но начинает ошибаться при N=2**33, в соответствии с прикидками данными выше.

Повышение точности кода только за счет смены декларации типа переменных - уже хорошо. Но самым забавным оказалось то, что код с real*10 не только точнее, но и быстрее кода с real*8! Кажется естественным, что при переходе от 64-битным к 80-битным переменным не должно быть лишних преобразований переменных при общении с мат. сопроцессором. Видимо в этом - причина ускорения кода. Но неожиданным оказалось ускорение работы программы в 2 раза для Intel Xeon на нашем кластере!

Для количественной иллюстрации эффекта я нарисовал пару графиков: 1) относительное изменение производительности компьютера при переходе от 64-битных переменных типа real*8 к 80-битным переменным типа real*10 2) производительность разных систем относительно машины cluster. Под производительностью понимается обратное время вычисления суммы х. Почти все машины были с 64-битной версией Linux, кроме nuke, на котором установлен 32-битный Debian (см. таблицу). Оказалось, что код на cluster работает в 2 раза быстрее с 80-битными переменными! Как видно из графика, существенное ускорение 80-битного кода характерно для 64-битных процессоров Intel. Для AMD (и 32-битной системы на nuke) не наблюдается существенного выигрыша в производительности от перехода от 64- к 80-битной переменной (но точность вычислений повышается).

Тестируемые компьютеры:

_______________________________________________________________________________________
System      CPU                          N.cores    Linux          kernel      gfortran  
_______________________________________________________________________________________
cluster      Intel Xeon 3.0GHz             4         SL x86_64      2.6.32-...  4.5.2
cluster23    AMD Opteron 8214              8         SL x86_64      2.6.18-...  4.1.2
cluster31    Intel Xeon E5530 2.4GHz       8         SL x86_64      2.6.18-...  4.1.2
cluster42    AMD Opteron 6168 1.9GHz       24        SL x86_64      2.6.32-...  4.4.6
nuke         Intel Pentium E5400 2.7GHz    2         Debian i686    3.2.0-4     4.7.2
laptop       Intel i5-2410M 2.30GHz        4         Debian x86_64  3.2.0-4     4.7.2
chromebook   Intel Celeron N2840 2.16GHz   2         Debian x86_64  3.10.18     4.9.2
_______________________________________________________________________________________

80-bit vs 64-bit производительность разных систем

Время вычисления S(N) растет пропорционально N. При этом, как видно из графика, даже скромные, но более новые системы работают быстрее кластерных узлов. Мне показалось забавным и показательным, что мой новый хромбук, который, казалось бы, и претендовать на вычислительную производительность не может, оказался в этом тесте быстрее процессора сервера cluster. Однако, в поддержку х-бука отмечу, что это полностью 64-битная система с хоть и кастрированным (мобильным), но новым 64-битным процессором и со свежим gfortran v.4.9 компилятором. Можно предположить, что этот компилятор оптимизирует код лучше, чем старые компиляторы кластерных машин. Тем не менее, причины того, что Intel Celeron N2840 2.58GHz (в турбо режиме) на 60% быстрее Intel Xeon 3.0GHz все еще кажутся мне загадочными. В этом контексте, было бы интересно протестировать вычислительные возможности процессоров ARM, которые стоят в большинстве планшетов и телефонов. Может быть уже пора попытаться использовать телефоны не только по их прямому назначению :-?)

Очевидно, что производительность кода зависит не только от процессора, но и от компилятора и от параметров компилирования. Графики нарисованы для результатов компиляции с флагом оптимизации O2 и могут выглядеть по-другому с другими флагами gfortran (я не изучал другие компиляторы). Флагов у gfortran много и изучение их возможностей на предмет оптимизации может легко занять все имеющееся время. Я только отмечу флаг -march, который учитывает архитектуру процессора. Желающие могут попробовать оптимизировать код под конкретную машину с помощью параметра -march=native. Показательным в этом смысле оказался пример машины nuke, на которой установлен Debian 32-бит. Машина nuke была самой медленной в тестах со стандартной оптимизацией O2, хотя частоты процессоров у nuke и cluster сравнимы. Первым впечатлением было, что здесь сказывается разрядность ОС. Однако, использование флага -march=native оказалось весьма эффективным и производительность моего тестового кода на 32-битном ядре Linux (процессор у nuke 64-битный) оказалась сравнимой с производительностью "полностью" 64-битного cluster.

80-битные переменные и параллельные вычисления

Описанные выше тесты проводились на одном ядре процессора. Важно также проверить, что мы выиграем (ли) в производительности и точности кода с 80-битными переменными при попытке распараллелить программу. Для этого я использовал OpenMP, который поддерживается даже на самых старых версиях компиляторов на кластере. В моем тестовом коде параллельность управляется параметром prl: prl=0 приводит к последовательному коду для одного процессора, а prl=1 включает параллельные вычисления на всех доступных процессорных ядрах. Код с одним циклом очень эффективно распараллеливается средствами OpenMP. Я его тестировал на нескольких системах, результаты для производительности кода суммированы на графике.

OpenMP performance

Эффект параллельности впечатляет, производительность при достаточно больших N увеличивается пропорционально числу задействованных ядер (исключение составили 4х ядерные системы)! Параллельное выполнение суммирования в цикле также благоприятно сказывается на точности сокращения членов разного знака при больших N. Вот результаты для S(N) для 64-битной и 80-битной версий распараллеленых с помощью OpenMP для разного количества процессоров:

64-bit version:
p        N=2**p   S  No.cores=1   S  No.cores=2   S  No.cores=4   S  No.cores=8   S No.cores=24
26     67108864   0.00000000000   0.00000000000   0.00000000000   0.00000000000   0.00000000000
27    134217728  -11585.0000000  -5792.00000000   0.00000000000   0.00000000000   0.00000000000
28    268435456  -232471923.000  -183344816.000  -88438552.0000  -42486318.0000   1.00000000000
29    536870912  -519822953.000  -461237984.000  -427413984.000  -310103436.000  -137253444.000
30   1073741824  -1065319903.00  -1002421120.00  -985636384.000  -940626304.000  -738108400.000
31   2147483648  -2143284860.00  -2078265344.00  -2069877248.00  -2047507168.00  -1923873512.00
32   4294967296  -4292867744.00  -4226779136.00  -4222556160.00  -4211342720.00  -4150743808.00
33   8589934592  -8588876000.00  -8519720960.00  -8518819840.00  -8513936384.00  -8484304896.00
34  17179869184  -17179339772.0  -17062330368.0  -17075896320.0  -17095215104.0  -17092972544.0
80-bit version:
p        N=2**p   S  No.cores=1   S  No.cores=2   S  No.cores=4   S  No.cores=8   S No.cores=24
26     67108864   0.00000000000   0.00000000000   0.00000000000   0.00000000000   0.00000000000 
27    134217728   0.00000000000   0.00000000000   0.00000000000   0.00000000000   0.00000000000 
28    268435456   0.00000000000   0.00000000000   0.00000000000   0.00000000000   0.00000000000 
29    536870912   0.00000000000   0.00000000000   0.00000000000   0.00000000000   0.00000000000 
30   1073741824   0.00000000000   0.00000000000   0.00000000000   0.00000000000   0.00000000000 
31   2147483648   0.00000000000   0.00000000000   0.00000000000   0.00000000000   0.00000000000 
32   4294967296   0.00000000000   0.00000000000   0.00000000000   0.00000000000   0.00000000000 
33   8589934592  -6074000997.00  -4294967292.00  -1464933360.00   0.00000000000   0.00000000000 
34  17179869184  -16070296103.0  -13588082112.0  -11364843456.0  -7094119694.00  -591823493.000 

Как видно, выполнение параллельного кода на 24 ядрах позволяет добиться "почти" точного результата для 64-битной версии для N=2**28 ~ 2.7E8 (сравниваем с N~0.7E8 для 1 ядра). Это связано, по-видимому, с тем что в парциальных суммах, которые выполняются параллельно, накапливается меньше ошибок округления из-за уменьшения количества членов в сумме и ее абсолютной величины. Для 80-битной версии результаты выглядят еще оптимистичнее. Как уже отмечалось выше, точный результат получается для N=2**32 уже на 1 ядре. На 8 ядрах мы имеем точное сокращение для N=2**33.

Сравнение времен затраченных на вычисление S приводит к картине похожей на результаты на 1 процессоре. Процессоры Intel(64-bit) работают быстрее с 80-битной версией кода (для Intel Xeon сервера cluster имеем выигрыш в 2 раза), тогда как производительность AMD довольно безразлична к разрядности S. Но в любом случае точность вычислений существенно повышается.

Комментарий: исходные коды и скрипты можно найти здесь.

© С. Кулагин, ИЯИ ОТФ