Почему GCC не оптимизирует a * a * a * a * a * a to (a * a * a) * (a * a * a)?

Я делаю некоторую численную оптимизацию в научном приложении. Я заметил, что GCC оптимизирует вызов pow(a,2), компилируя его в a*a, но вызов pow(a,6) не оптимизирован и фактически вызовет библиотечную функцию pow, что значительно замедляет производительность. (Напротив, Компилятор Intel С++, исполняемый icc, исключит вызов библиотеки для pow(a,6).)

Мне интересно, что когда я заменил pow(a,6) на a*a*a*a*a*a с помощью GCC 4.5.1 и опций "-O3 -lm -funroll-loops -msse4", он использует инструкции 5 mulsd:

movapd  %xmm14, %xmm13
mulsd   %xmm14, %xmm13
mulsd   %xmm14, %xmm13
mulsd   %xmm14, %xmm13
mulsd   %xmm14, %xmm13
mulsd   %xmm14, %xmm13

while, если я напишу (a*a*a)*(a*a*a), он произведет

movapd  %xmm14, %xmm13
mulsd   %xmm14, %xmm13
mulsd   %xmm14, %xmm13
mulsd   %xmm13, %xmm13

который уменьшает количество команд умножения до 3. icc имеет схожее поведение.

Почему компиляторы не признают этот трюк оптимизации?

Ответ 1

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

В результате большинство компиляторов очень консервативны в отношении переопределения вычислений с плавающей запятой, если они не могут быть уверены, что ответ останется неизменным или если вы не скажете им, что вам не нужна численная точность. Например: параметр -fassociative-math gcc, который позволяет gcc перезаписывать операции с плавающей запятой или даже параметр -ffast-math, который позволяет еще более агрессивно компромисс с точностью против скорости.

Ответ 2

Lambdageek правильно указывает, что, поскольку ассоциативность не выполняется для чисел с плавающей запятой, "оптимизация" от a*a*a*a*a*a до (a*a*a)*(a*a*a) может изменить значение. Вот почему он запрещен C99 (если это специально не разрешено пользователем, через флаг компилятора или прагма). Как правило, предполагается, что программист написал то, что сделал по какой-то причине, и компилятор должен это уважать. Если вы хотите (a*a*a)*(a*a*a), напишите это.

Это может быть болью писать; почему компилятор не может сделать [то, что вы считаете] правильным, когда используете pow(a,6)? Потому что это было бы неправильно. На платформе с хорошей математической библиотекой pow(a,6) значительно более точен, чем a*a*a*a*a*a или (a*a*a)*(a*a*a). Чтобы предоставить некоторые данные, я провел небольшой эксперимент на своем Mac Pro, измеряя худшую ошибку при оценке ^ 6 для всех чисел с плавающей запятой с одной точностью между [1,2]:

worst relative error using    powf(a, 6.f): 5.96e-08
worst relative error using (a*a*a)*(a*a*a): 2.94e-07
worst relative error using     a*a*a*a*a*a: 2.58e-07

Использование pow вместо дерева умножения уменьшает погрешность в 4 раза. Составители не должны (и вообще не делать) делать "оптимизации", которые увеличивают ошибку, если лицензия на это не будет сделана пользователем (например, через -ffast-math).

Обратите внимание, что GCC предоставляет __builtin_powi(x,n) в качестве альтернативы pow( ), который должен генерировать встроенное дерево умножения. Используйте это, если вы хотите скомпрометировать точность для производительности, но не хотите включать ускоренную математику.

Ответ 3

Другой подобный случай: большинство компиляторов не будут оптимизировать a + b + c + d до (a + b) + (c + d) (это оптимизация, поскольку второе выражение может быть конвейеризовано лучше) и оценивать его как заданное (т.е. как (((a + b) + c) + d)). Это также происходит из-за угловых случаев:

float a = 1e35, b = 1e-5, c = -1e35, d = 1e-5;
printf("%e %e\n", a + b + c + d, (a + b) + (c + d));

Это выводит 1.000000e-05 0.000000e+00

Ответ 4

Fortran (предназначен для научных вычислений) имеет встроенный оператор мощности, и, насколько я знаю, компиляторы Fortran обычно оптимизируют повышение до целых полномочий аналогично тому, что вы описываете. У C/С++, к сожалению, нет оператора мощности, только библиотечная функция pow(). Это не мешает умным компиляторам обрабатывать pow специально и вычислять его более быстрым способом для особых случаев, но кажется, что они делают это реже...

Несколько лет назад я пытался сделать его более удобным для вычисления целочисленных мощностей оптимальным образом и придумал следующее. Это С++, а не C, хотя и все еще зависит от того, как компилятор немного соображает, как оптимизировать/встроить вещи. В любом случае, надеюсь, что вы можете найти это полезным на практике:

template<unsigned N> struct power_impl;

template<unsigned N> struct power_impl {
    template<typename T>
    static T calc(const T &x) {
        if (N%2 == 0)
            return power_impl<N/2>::calc(x*x);
        else if (N%3 == 0)
            return power_impl<N/3>::calc(x*x*x);
        return power_impl<N-1>::calc(x)*x;
    }
};

template<> struct power_impl<0> {
    template<typename T>
    static T calc(const T &) { return 1; }
};

template<unsigned N, typename T>
inline T power(const T &x) {
    return power_impl<N>::calc(x);
}

Уточнение для любознательных: это не находит оптимального способа вычисления полномочий, но поскольку поиск оптимального решения является NP-полным проблема, и это все равно стоит делать для малых держав в любом случае (в отличие от использования pow), нет никаких оснований суетиться с деталями.

Затем просто используйте его как power<6>(a).

Это позволяет легко набирать полномочия (не нужно указывать 6 a с помощью parens) и позволяет использовать такую ​​оптимизацию без -ffast-math, если у вас есть что-то точно зависимое, например скомпенсированное суммирование (пример, когда порядок операций имеет важное значение).

Возможно, вы также можете забыть, что это С++ и просто использовать его в программе на C (если он компилируется с помощью компилятора С++).

Надеюсь, это может быть полезно.

EDIT:

Это то, что я получаю от своего компилятора:

Для a*a*a*a*a*a,

    movapd  %xmm1, %xmm0
    mulsd   %xmm1, %xmm0
    mulsd   %xmm1, %xmm0
    mulsd   %xmm1, %xmm0
    mulsd   %xmm1, %xmm0
    mulsd   %xmm1, %xmm0

Для (a*a*a)*(a*a*a),

    movapd  %xmm1, %xmm0
    mulsd   %xmm1, %xmm0
    mulsd   %xmm1, %xmm0
    mulsd   %xmm0, %xmm0

Для power<6>(a),

    mulsd   %xmm0, %xmm0
    movapd  %xmm0, %xmm1
    mulsd   %xmm0, %xmm1
    mulsd   %xmm0, %xmm1

Ответ 5

GCC фактически оптимизирует a * a * a * a * a * a to (a * a * a) * (a * a * a), когда a - целое число. Я попытался с помощью этой команды:

$ echo 'int f(int x) { return x*x*x*x*x*x; }' | gcc -o - -O2 -S -masm=intel -x c -

Есть много флагов gcc, но ничего необычного. Они означают: Читайте от stdin; использовать уровень оптимизации O2; выводить список языков ассемблера вместо двоичного; в листинге должен использоваться синтаксис языка ассемблера Intel; вход на языке C (обычно язык выводится из расширения входного файла, но при чтении из stdin нет расширения файла); и напишите в стандартный вывод.

Здесь важная часть вывода. Я комментировал это с некоторыми комментариями, указывающими, что происходит на ассемблере:

    ; x is in edi to begin with.  eax will be used as a temporary register.
    mov    eax, edi     ; temp1 = x
    imul    eax, edi    ; temp2 = x * temp1
    imul    eax, edi    ; temp3 = x * temp2
    imul    eax, eax    ; temp4 = temp3 * temp3

Я использую систему GCC на Linux Mint 16 Petra, производную Ubuntu. Здесь версия gcc:

$ gcc --version
gcc (Ubuntu/Linaro 4.8.1-10ubuntu9) 4.8.1

Как отмечали другие плакаты, эта опция невозможна в плавающей точке, поскольку арифметика с плавающей запятой на самом деле не ассоциативна.

Ответ 6

Поскольку 32-разрядное число с плавающей запятой - например, 1.024 - не 1.024. В компьютере 1.024 - это интервал: от (1.024-e) до (1.024 + e), где "e" представляет ошибку. Некоторые люди этого не понимают, а также считают, что * в * a означает умножение чисел произвольной точности без каких-либо ошибок, связанных с этими числами. Причина, по которой некоторые люди не понимают этого, - это, пожалуй, математические вычисления, которые они использовали в начальных школах: работая только с идеальными числами без ошибок, и полагая, что это нормально просто игнорировать "е" при выполнении умножения. Они не видят, что "e" подразумевается в "float a = 1.2", "a * a * a" и подобных кодах C.

Если большинство программистов узнают (и смогут выполнить) идею о том, что выражение C a * a * a * a * a * a фактически не работает с идеальными числами, компилятор GCC тогда был бы БЕСПЛАТНО для оптимизации "a * a * a * a * a * a" в say "t = (a * a); t * t * t", для которого требуется меньшее количество умножений. Но, к сожалению, компилятор GCC не знает, думает ли программист, что "a" - это номер с ошибкой или без нее. И поэтому GCC будет делать только то, что выглядит в исходном коде, потому что это то, что GCC видит своим "невооруженным глазом".

... как только вы узнаете, какой вы программист, вы можете использовать переключатель "-ffast-math", чтобы сообщить GCC, что "Эй, GCC, я знаю, что я делаю!". Это позволит GCC преобразовать a * a * a * a * a * a в другой фрагмент текста - он выглядит иначе, чем * a * a * a * a * a, но все же вычисляет число в интервале ошибок а * а * а * а * а * а. Это нормально, так как вы уже знаете, что работаете с интервалами, а не с идеальными числами.

Ответ 7

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

Я был удивлен, узнав из других ответов, что это выражение действительно можно оптимизировать с помощью правильных переключателей компилятора. Либо оптимизация тривиальна, либо является краевым случаем гораздо более общей оптимизации, или авторы компилятора были чрезвычайно тщательными.

Нет ничего плохого в предоставлении подсказок компилятору, как вы это делали. Это нормальная и ожидаемая часть процесса микрооптимизации, чтобы переупорядочить заявления и выражения, чтобы увидеть, какие различия они принесут.

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

Ответ 8

Ни один плакат не упомянул о сокращении плавающих выражений (стандарт ISO C, 6.5p8 и 7.12.2). Если для параметра FP_CONTRACT pragma установлено значение ON, компилятору разрешено рассматривать выражение, такое как a*a*a*a*a*a, как одна операция, как если бы он был точно оценен с помощью одного округления. Например, компилятор может заменить его функцией внутренней мощности, которая является более быстрой и точной. Это особенно интересно, поскольку поведение частично контролируется программистом непосредственно в исходном коде, в то время как параметры компилятора, предоставляемые конечным пользователем, могут иногда использоваться некорректно.

Состояние по умолчанию FP_CONTRACT pragma определяется реализацией, поэтому компилятору разрешено делать такие оптимизации по умолчанию. Таким образом, переносимый код, который должен строго следовать правилам IEEE 754, должен явно установить его на OFF.

Если компилятор не поддерживает эту прагму, он должен быть консервативным, избегая такой оптимизации, если разработчик решил установить его на OFF.

GCC не поддерживает эту прагму, но с параметрами по умолчанию предполагается, что она ON; таким образом, для целей с аппаратным FMA, если нужно предотвратить преобразование a*b+c в fma (a, b, c), необходимо предоставить такой параметр, как -ffp-contract=off (чтобы явно установить прагму в OFF) или -std=c99 (чтобы сообщить GCC, чтобы он соответствовал некоторой стандартной версии C, здесь C99, таким образом, следуйте приведенному выше абзацу). Раньше последний вариант не мешал преобразованию, что означало, что GCC не соответствовал этому вопросу: https://gcc.gnu.org/bugzilla/show_bug.cgi?id=37845

Ответ 9

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

Ответ 10

Функции библиотеки, такие как "pow", обычно тщательно обрабатываются, чтобы получить минимально возможную ошибку (в общем случае). Обычно это достигается приближением функций с сплайнами (согласно комментарию Паскаля, наиболее распространенная реализация, похоже, использует алгоритм Remez)

в основном следующая операция:

pow(x,y);

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

При выполнении следующей операции:

float a=someValue;
float b=a*a*a*a*a*a;

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

Компилятор должен быть очень осторожным с тем видом оптимизации, который он выполняет:

  • если оптимизация pow(a,6) до a*a*a*a*a*a, то может повысить производительность, но значительно снизить точность чисел с плавающей запятой.
  • если оптимизация a*a*a*a*a*a до pow(a,6), это может фактически уменьшить точность, потому что "a" было некоторым специальным значением, которое позволяет умножать без ошибок (мощность 2 или некоторое небольшое целое число)
  • при оптимизации pow(a,6) до (a*a*a)*(a*a*a) или (a*a)*(a*a)*(a*a) по-прежнему может быть потеря точности по сравнению с функцией pow.

В общем, вы знаете, что для любых значений с плавающей запятой "pow" имеет лучшую точность, чем любая функция, которую вы в конечном итоге могли бы написать, но в некоторых особых случаях множественные умножения могут иметь лучшую точность и производительность, разработчик выбирает, что более подходящим, в конце концов комментируя код, чтобы никто другой не "оптимизировал" этот код.

Единственное, что имеет смысл (личное мнение и, по-видимому, выбор в GCC с какой-либо конкретной оптимизацией или флагом компилятора) для оптимизации, должно заменять "pow (a, 2)" на "a * a". Это будет единственная нормальная вещь, которую должен сделать поставщик компилятора.

Ответ 11

Уже есть несколько хороших ответов на этот вопрос, но для полноты я хотел бы указать, что применимый раздел стандарта C - это 5.1.2.2.3/15 (что совпадает с разделом 1.9/9 в стандарте С++ 11). В этом разделе говорится, что операторы могут быть перегруппированы только в том случае, если они действительно ассоциативны или коммутативны.

Ответ 12

gcc действительно может сделать эту оптимизацию, даже для чисел с плавающей запятой. Например,

double foo(double a) {
  return a*a*a*a*a*a;
}

становится

foo(double):
    mulsd   %xmm0, %xmm0
    movapd  %xmm0, %xmm1
    mulsd   %xmm0, %xmm1
    mulsd   %xmm1, %xmm0
    ret

с -O -funsafe-math-optimizations. Однако это переупорядочение нарушает IEEE-754, поэтому для этого требуется флаг.

Подписанные целые числа, как указал Питер Кордес в комментарии, могут сделать эту оптимизацию без -funsafe-math-optimizations, поскольку она выполняется точно, когда нет переполнения, и если есть переполнение, вы получаете поведение undefined. Итак, вы получаете

foo(long):
    movq    %rdi, %rax
    imulq   %rdi, %rax
    imulq   %rdi, %rax
    imulq   %rax, %rax
    ret

всего лишь -O. Для целых чисел без знака это еще проще, так как они работают с модами степени 2 и поэтому могут быть переупорядочены свободно даже перед лицом переполнения.