Почему GCC не оптимизирует aaaaaa в (aaa)(aaa)? Я работаю над числовой оптимизацией в научном приложении и заметил, что GCC оптимизирует pow(a,2) в aa, но не оптимизирует pow(a,6), что вызывает вызов библиотечной функции и значительно влияет на производительность. Когда я заменил pow(a,6) на aaaaaa с использованием 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
Однако при написании (aaa)(aa*a) он производит:
movapd %xmm14, %xmm13
mulsd %xmm14, %xmm13
mulsd %xmm14, %xmm13
mulsd %xmm13, %xmm13
что сокращает количество инструкций умножения до 3. Компилятор Intel C++ (icc) показывает аналогичное поведение. Почему компиляторы не распознают эту возможность оптимизации?
Компиляторы не автоматически оптимизируют выражение a*a*a*a*a*a до (a*a*a)*(a*a*a) из-за нескольких проблем оптимизации и архитектурных решений в компиляторах.
Содержание
- Сложности оптимизации компиляторов
- Почему эта конкретная оптимизация не применяется
- Анализ затрат и выгод в компиляторах
- Обходные пути и решения
- Связанные шаблоны оптимизации
Сложности оптимизации компиляторов
Оптимизация, которую вы описываете, представляет собой комбинацию снижения силы операций и устранения общих подвыражений. Это хорошо известные методы оптимизации, но их автоматическое применение сталкивается с несколькими трудностями:
-
Анализ сложности: Компилятор должен проанализировать дерево выражений для определения оптимальных последовательностей вычислений. Для
a*a*a*a*a*aоптимальная последовательность требует вычисленияa*a, затемa*a*a, а затем возведения результата в квадрат. -
Распределение регистров: Оптимизированная версия требует дополнительных временных переменных, что увеличивает нагрузку на регистры. Компилятор должен определить, оправдывает ли прирост производительности дополнительное использование регистров.
-
Влияние на векторизацию: Оптимизация может помешать возможностям векторизации SIMD, так как перестроенный шаблон вычислений может плохо совпадать с векторными инструкциями.
-
Соображения точности: Разные порядки вычислений могут давать слегка разные результаты из-за округления чисел с плавающей запятой, и компиляторы должны быть осторожны при изменении порядка вычислений.
Почему эта конкретная оптимизация не применяется
Несколько технических факторов объясняют, почему GCC и другие компиляторы не выполняют эту оптимизацию автоматически:
-
Ограниченное сопоставление шаблонов: Компиляторы имеют предопределенные шаблоны оптимизации для малых показателей степени (например,
pow(a,2) → a*a), но не имеют комплексного сопоставления шаблонов для более высоких степеней. Компилятор может распознатьa*aкак цель оптимизации, но не распознать возможность дляa*a*a*a*a*a. -
Ограничения эвристик: Эвристики оптимизации компилятора могут определить, что потенциальное улучшение производительности не оправдывает затраты времени компиляции на анализ деревьев выражений для оптимальных последовательностей вычислений.
-
Выбор инструкций: Алгоритм выбора инструкций компилятора может не придавать приоритета минимизации количества умножений, когда другие факторы, как пропускная способность или задержка инструкций, более релевантны для целевой архитектуры.
-
Порядок проходов оптимизации: Порядок проходов оптимизации в GCC может быть таким, что это преобразование происходит слишком рано или слишком поздно в процессе компиляции, упуская возможность.
Анализ затрат и выгод в компиляторах
Компиляторы выполняют сложный анализ затрат и выгод перед применением оптимизаций. В вашем конкретном случае:
// Исходный код: 5 умножений
a*a*a*a*a*a
= ((a*a)*a)*a*a*a
= temp1 = a*a
= temp2 = temp1*a
= temp3 = temp2*a
= temp4 = temp3*a
= temp5 = temp4*a
// Оптимизированный: 3 умножения
(a*a*a)*(a*a*a)
= temp1 = a*a
= temp2 = temp1*a
= result = temp2*temp2
Компилятор должен учитывать:
- Прирост производительности: 40% сокращение операций умножения (5→3)
- Использование регистров: Увеличение с 1-2 временных переменных до 2-3
- Размер кода: Незначительное увеличение из-за дополнительных инструкций
- Числовая точность: Потенциально разное поведение округания
Для современных процессоров с конвейерными умножающими блоками разница в производительности может быть менее значительной, чем ожидается, что заставляет компилятор считать оптимизацию нецелесообразной.
Обходные пути и решения
Поскольку автоматическая оптимизация ненадежна, можно использовать несколько подходов:
-
Ручная оптимизация: Как вы обнаружили, переписывание
a*a*a*a*a*aкак(a*a*a)*(a*a*a)является наиболее надежным методом. -
Вспомогательные функции: Создайте встроенные функции для общих вычислений степеней:
cstatic inline double cube(double x) { return x*x*x; } // Затем используйте cube(a) * cube(a) -
Атрибуты, специфичные для компилятора: Используйте атрибуты компилятора для подсказок об возможностях оптимизации:
c__attribute__((always_inline, hot)) static inline double pow6(double a) { double cube_a = a*a*a; return cube_a * cube_a; } -
Директивы Pragma: Некоторые компиляторы поддерживают директивы оптимизации:
c#pragma GCC optimize ("unroll-loops") -
Метапрограммирование с шаблонами: В C++ можно создавать шаблоны вычисления степеней времени компиляции, которые генерируют оптимальные последовательности.
Связанные шаблоны оптимизации
Эта оптимизация является частью более широкой категории оптимизации последовательностей умножений. Существуют схожие возможности:
-
Возведение в степень методом возведения в квадрат: Для произвольных степеней компиляторы могли бы использовать этот алгоритм:
pow(a, n) = { если n == 0: вернуть 1 если n == 1: вернуть a если n четное: вернуть pow(a*a, n/2) если n нечетное: вернуть a * pow(a*a, (n-1)/2) } -
Метод Горнера: Для оценки полиномов компиляторы могут оптимизировать последовательности, такие как
a*a*a + b*a*a + c*a + d, в более эффективные формы. -
Снижение силы операций: Замена умножения сложением в циклах, хотя это чаще применяется к индексации массивов.
Тот факт, что компиляторы не применяют эти оптимизации автоматически, подчеркивает сложность баланса между комплексной оптимизацией и производительностью компиляции. По мере развития оборудования компиляторы должны постоянно адаптировать свои стратегии оптимизации под характеристики конкретных процессоров.
Источники
- Опции оптимизации GCC
- Методы оптимизации компиляторов - Снижение силы операций
- Устранение общих подвыражений в компиляторах
- Соображения оптимизации чисел с плавающей запятой
- Руководство по оптимизации компилятора Intel
Заключение
Компиляторы не оптимизируют a*a*a*a*a*a до (a*a*a)*(a*a*a) в основном из-за ограничений эвристик, анализа затрат и выгод и сложности комплексной оптимизации деревьев выражений. Хотя прирост производительности значителен (40% сокращение умножений), компиляторы должны сбалансировать это с увеличенным использованием регистров, потенциальными различиями в числовой точности и затратами времени компиляции.
Для научных вычислительных приложений, где производительность критична, ручная оптимизация остается наиболее надежным подходом. Создание вспомогательных функций или использование метапрограммирования с шаблонами может помочь сохранить читаемость кода при обеспечении оптимальной производительности. По мере развития технологии компиляторов мы можем увидеть более сложную автоматическую оптимизацию последовательностей умножений, но пока явная оптимизация необходима для достижения лучших результатов.