Почему замена 0.1f на 0 в цикле замедляет код в 10 раз (C++, VS2010)?
Причина: денормализованные числа (denormals) и аппаратное исключение
Вы столкнулись с классическим эффектом, связанным с обработкой денормализованных чисел (subnormal/denormal numbers) в процессорах x86/x64. Когда вы умножаете int на 0.1f, результат никогда не становится денормализованным — числа остаются в нормальном диапазоне. Но когда вы умножаете на 0, происходит следующее:
i * 0даёт целочисленный ноль.- При приведении к
floatполучается0.0f. - Далее
x = 0.0f, иx * xдаёт0.0f. - Но! В первом же проходе цикла
sumстановится0.0f. После этого все последующие операцииsum += 0.0f— это сложение нуля с нулём.
Проблема в том, что аппаратное обеспечение (FPU/SSE) обрабатывает операции с денормализованными числами (в данном случае — с нулём, который является частным случаем денормализованного) медленнее. На процессорах Intel до Haswell (и на многих AMD) операции с денормализованными числами могут занимать в 10–100 раз больше тактов, чем с нормальными числами. Когда sum и x равны нулю, FPU переходит в режим обработки "gradual underflow" (постепенное исчезновение значимости), что вызывает микроисключения (microcode assists).
Почему с 0.1f такого не происходит?
При умножении на 0.1f переменная x принимает значения типа 0.1, 0.2, 0.3, ... — все они являются нормальными числами (нормализованными). Сумма sum растёт, и никогда не достигает нуля. Следовательно, FPU работает в нормальном режиме.
Как это исправить?
Есть два основных решения:
1. Использовать флаг компилятора для отключения обработки денормализованных чисел (flush-to-zero):
// Для MSVC (VS2010):
_controlfp(_DN_FLUSH, _MCW_DN);
// Или через intrinsic:
_mm_setcsr(_mm_getcsr() | 0x8000); // DAZ + FTZ
2. Изменить логику, чтобы избежать нулевых значений:
float sum = 0.0f;
for (int i = 0; i < 100000000; ++i) {
float x = i * 0;
// Принудительно обнуляем sum, чтобы избежать денормалов:
if (x == 0.0f) continue;
sum += x * x;
}
// Но это не решит проблему, если sum уже стал нулём.
// Лучше:
if (sum == 0.0f) break; // Выход из цикла, так как все последующие итерации дадут 0
3. Использовать целочисленную арифметику, если результат гарантированно ноль:
// Если вы знаете, что результат будет нулевым, просто не пишите цикл:
float sum = 0.0f; // сразу присвойте ноль
Дополнительная информация
- Denormals-are-zero (DAZ) — режим, при котором денормализованные числа приравниваются к нулю. Включается через
_MM_SET_FLUSH_ZERO_MODE(_MM_FLUSH_ZERO_ON). - Flush-to-zero (FTZ) — результат операции, который должен быть денормализованным, принудительно становится нулём.
- В современных процессорах (Skylake и новее) эта проблема менее выражена, но на старых (Core 2, Nehalem, Sandy Bridge) разница может быть драматической.
Таким образом, ваш код не медленнее из-за умножения на ноль — он медленнее из-за того, что FPU тратит время на обработку денормализованных чисел, которые возникают при сложении нуля с нулём. Это известное поведение, и оно не является багом компилятора.
<<Альтернативный взгляд: проблема в микроархитектуре процессора
Первый ответ верно указывает на денормализованные числа, но стоит добавить важный контекст: на процессорах Intel до Haswell (2013 год) операции с денормализованными числами в SSE/AVX обрабатываются микрокодом (microcode assists), а не аппаратно. Это вызывает задержку в несколько десятков тактов на каждую такую операцию.
В вашем примере с 0 цикл выполняет 100 миллионов итераций, и на каждой итерации происходит операция sum += 0.0f, где оба операнда — нули (денормали). Каждая такая операция вызывает микроисключение, что и даёт 10-кратное замедление.
Практический совет: Если вы пишете код для численных расчётов (физика, графика, машинное обучение), всегда включайте режимы FTZ (Flush-To-Zero) и DAZ (Denormals-Are-Zero). Это стандартная практика в игровых движках и высокопроизводительных вычислениях. Включение этих флагов не влияет на точность для большинства задач, так как денормализованные числа — это числа, близкие к нулю (менее 1.17e-38), и их потеря обычно несущественна.
// Включение FTZ и DAZ на MSVC:
#include <float.h>
_controlfp(_DN_FLUSH, _MCW_DN); // FTZ
_controlfp(_DN_UNDERFLOW, _MCW_EM); // DAZ (необязательно)
Также стоит проверить, не оптимизирует ли компилятор ваш цикл с 0.1f сильнее (например, через векторизацию), а с 0 — нет. В VS2010 компилятор мог векторизовать первый вариант, но не второй, из-за особенностей анализа зависимостей.
Ответов пока нет.
Войдите чтобы оставить ответ
Блоги