Оптимизирует ли компилятор работу по константной переменной и номеру литерала const?
Скажем, у меня есть класс с полем:
const double magicalConstant = 43;
Это где-то в коде:
double random = GetRandom();
double unicornAge = random * magicalConstant * 2.0;
Будет ли компилятор оптимизировать мой код, чтобы он не вычислял magicalConstant * 2.0
каждый раз, когда он вычисляет unicornAge
?
Я знаю, что могу определить следующий const, который учитывает это умножение. Но в моем коде это выглядит намного чище. И для компилятора имеет смысл оптимизировать его.
Ответы
Ответ 1
(Этот вопрос был тема моего блога в октябре 2015 г., спасибо за интересный вопрос!)
У вас уже есть хорошие ответы, которые отвечают на ваш фактический вопрос: Нет, компилятор С# не генерирует код для выполнения одного умножения на 86. Он генерирует умножение на 43 и умножение на 2.
Здесь есть несколько тонкостей, в которые никто не вошел.
Умножение является "левым ассоциативным" в С#. То есть
x * y * z
должен быть вычислен как
(x * y) * z
И не
x * (y * z)
Теперь, есть ли у вас когда-нибудь разные ответы для этих двух вычислений? Если ответ "нет", то операция называется "ассоциативной операцией", то есть неважно, где мы помещаем скобки, и, следовательно, можем делать оптимизации, чтобы скобки были в лучшем месте. (Примечание: я сделал ошибку в предыдущем редактировании этого ответа, где я сказал "коммутативный", когда имел в виду "ассоциативный" - коммутативная операция - это то, где x * y равно y * x.)
В С# конкатенация строк является ассоциативной операцией. Если вы скажете
myString + "hello" + "world" + myString
то вы получите тот же результат для
((myString + "hello") + "world") + myString
и
(myString + ("hello" + "world")) + myString
и поэтому компилятор С# может здесь оптимизировать; он может выполнять вычисления во время компиляции и генерировать код так, как если бы вы написали
(myString + "helloworld") + myString
который на самом деле является компилятором С#. (Забавный факт: реализация этой оптимизации была одной из первых вещей, которые я сделал, когда присоединился к команде компилятора.)
Возможно ли аналогичная оптимизация для умножения? Только если умножение является ассоциативным. Но это не так! Есть несколько способов, в которых это не так.
Посмотрим на несколько другой случай. Предположим, что
x * 0.5 * 6.0
Можно ли просто сказать, что
(x * 0.5) * 6.0
совпадает с
x * (0.5 * 6.0)
и сгенерировать умножение на 3.0? Нет. Предположим, что x настолько мало, что x, умноженное на 0,5, округлено до нуля. Тогда нулевое время 6.0 все равно равно нулю. Таким образом, первая форма может дать нуль, а вторая форма может дать ненулевое значение. Поскольку две операции дают разные результаты, операция не является ассоциативной.
Компилятор С# мог бы добавить в него smarts - как и для конкатенации строк - чтобы выяснить, в каких случаях умножение ассоциативно и делает оптимизацию, но, откровенно говоря, это просто не стоит. Сохранение конкатенаций строк - огромная победа. Операции со струнами являются дорогостоящими во времени и в памяти. И очень часто в программах содержится очень много конкатенаций строк, где константы и переменные смешиваются вместе. Операции с плавающей точкой очень дешевы во времени и в памяти, трудно понять, какие из них являются ассоциативными, и редко встречаются длинные цепочки умножений в реалистичных программах. Время и энергия, которые потребуется для проектирования, внедрения и тестирования этой оптимизации, были бы лучше потрачены на использование других функций.
Ответ 2
В вашем конкретном случае это не будет.
Рассмотрим следующий код:
class Program
{
const double test = 5.5;
static void Main(string[] args)
{
double i = Double.Parse(args[0]);
Console.WriteLine(test * i * 1.5);
}
}
в этом случае константы не складываются:
.method private hidebysig static void Main(string[] args) cil managed
{
.entrypoint
// Code size 36 (0x24)
.maxstack 2
.locals init ([0] float64 i)
IL_0000: ldarg.0
IL_0001: ldc.i4.0
IL_0002: ldelem.ref
IL_0003: call float64 [mscorlib]System.Double::Parse(string)
IL_0008: stloc.0
IL_0009: ldc.r8 5.5
IL_0012: ldloc.0
IL_0013: mul
IL_0014: ldc.r8 1.5
IL_001d: mul
IL_001e: call void [mscorlib]System.Console::WriteLine(float64)
IL_0023: ret
} // end of method Program::Main
Но в целом он будет оптимизирован. Эта оптимизация называется постоянной складкой.
Мы можем это доказать. Вот тестовый код в С#:
class Program
{
const double test = 5.5;
static void Main(string[] args)
{
Console.WriteLine(test * 1.5);
}
}
Вот декомпилированный код из ILDasm:
.method private hidebysig static void Main(string[] args) cil managed
{
.entrypoint
// Code size 15 (0xf)
.maxstack 8
IL_0000: ldc.r8 8.25
IL_0009: call void [mscorlib]System.Console::WriteLine(float64)
IL_000e: ret
} // end of method Program::Main
Как вы можете видеть IL_0000: ldc.r8 8.25
, компилятор вычислил выражение.
Некоторые ребята сказали, что это потому, что вы имеете дело с float, но это не так. Оптимизация не выполняется даже для целых чисел:
class Program
{
const int test = 5;
static void Main(string[] args)
{
int i = Int32.Parse(args[0]);
Console.WriteLine(test * i * 2);
}
}
Код (без складывания):
.method private hidebysig static void Main(string[] args) cil managed
{
.entrypoint
// Code size 20 (0x14)
.maxstack 2
.locals init ([0] int32 i)
IL_0000: ldarg.0
IL_0001: ldc.i4.0
IL_0002: ldelem.ref
IL_0003: call int32 [mscorlib]System.Int32::Parse(string)
IL_0008: stloc.0
IL_0009: ldc.i4.5
IL_000a: ldloc.0
IL_000b: mul
IL_000c: ldc.i4.2
IL_000d: mul
IL_000e: call void [mscorlib]System.Console::WriteLine(int32)
IL_0013: ret
} // end of method Program::Main
Ответ 3
Нет, в этом случае это не так.
Посмотрите на этот код:
const double magicalConstant = 43;
static void Main(string[] args)
{
double random = GetRandom();
double unicornAge = random * magicalConstant * 2.0;
Console.WriteLine(unicornAge);
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static double GetRandom()
{
return new Random().NextDouble();
}
наша разборка:
double random = GetRandom();
00007FFDCD203C92 in al,dx
00007FFDCD203C93 sub al,ch
00007FFDCD203C95 mov r14,gs
00007FFDCD203C98 push rdx
double unicornAge = random * magicalConstant * 2.0;
00007FFDCD203C9A movups xmm1,xmmword ptr [7FFDCD203CC0h]
00007FFDCD203CA1 mulsd xmm1,xmm0
00007FFDCD203CA5 mulsd xmm1,mmword ptr [7FFDCD203CC8h]
Console.WriteLine(unicornAge);
00007FFDCD203CAD movapd xmm0,xmm1
00007FFDCD203CB1 call 00007FFE2BEDAFE0
00007FFDCD203CB6 nop
00007FFDCD203CB7 add rsp,28h
00007FFDCD203CBB ret
мы имеем две инструкции mulsd
, поэтому мы имеем два умножения.
Теперь поставьте несколько скобок:
double unicornAge = random * (magicalConstant * 2.0);
00007FFDCD213C9A movups xmm1,xmmword ptr [7FFDCD213CB8h]
00007FFDCD213CA1 mulsd xmm1,xmm0
как вы можете видеть, компилятор оптимизировал его.
В плавающих пунктах (a*b)*c != a*(b*c)
, поэтому он не может оптимизировать его без ручной помощи.
Например, целочисленный код:
int random = GetRandom();
00007FFDCD203860 sub rsp,28h
00007FFDCD203864 call 00007FFDCD0EC8E8
int unicornAge = random * magicalConstant * 2;
00007FFDCD203869 imul eax,eax,2Bh
int unicornAge = random * magicalConstant * 2;
00007FFDCD20386C add eax,eax
с помощью скобок:
int random = GetRandom();
00007FFDCD213BA0 sub rsp,28h
00007FFDCD213BA4 call 00007FFDCD0FC8E8
int unicornAge = random * (magicalConstant * 2);
00007FFDCD213BA9 imul eax,eax,56h
Ответ 4
Если это было просто:
double unicornAge = magicalConstant * 2.0;
Тогда да, даже если компилятор не требуется для какой-либо конкретной оптимизации, мы можем разумно ожидать и предположить, что этот простой выполняется. Как отметил Eric, этот пример немного вводит в заблуждение, потому что в этом случае компилятор должен учитывать magicalConstant * 2.0
как константу.
Однако из-за ошибок с плавающей запятой (random * 6.0 != (random * 3.0) * 2.0
) он заменит вычисленное значение, только если вы добавите скобки:
double unicornAge = random * (magicalConstant * 2.0);
РЕДАКТИРОВАТЬ: какие из этих ошибок с плавающей запятой я говорю? две причины:
- Precision 1: числа с плавающей запятой являются приблизительными, а компилятору не разрешается выполнять какую-либо оптимизацию, которая изменит результат. Например (как лучше показано в Eric answer) в
verySmallValue * 0.1 * 10
, если verySmallValue * 0.1
округляется до 0 (из-за fp), а затем (verySmallValue * 0.1) * 10 != verySmallValue * (0.1 * 10)
, потому что 0 * 10 == 0
.
- Precision 2: у вас нет этой проблемы только для очень маленьких чисел, потому что число целых чисел IEEE 754 выше
2^53 - 1
(9007199254740991
) не может быть безопасно представлено, тогда c * (10 * 0.5)
может давать неверный результат, если c * 10
выше 9007199254740991
(но см. позже, что официальная реализация, процессоры могут использовать расширенную точность).
- Точность 3: обратите внимание, что
x * 0 >= 0
не всегда истинно, тогда выражение a * b * c >= 0
, когда b
равно 0
, может быть истинным или не соответствовать a
и c
значения или ассоциативность.
- Диапазон: числовые типы с плавающей запятой имеют конечный диапазон, если первое умножение вызовет значение Infinity, тогда оптимизация изменит это значение.
Посмотрите пример проблемы с диапазоном, потому что он более тонкий, чем этот.
// x = n * c1 * c2
double x = veryHighNumber * 2 * 0.5;
Предполагая, что veryHighNumber * 2
находится вне диапазона double
, тогда вы ожидаете (без какой-либо оптимизации), что x
+Infinity
(поскольку veryHighNumber * 2
- +Infinity
). Удивительный (?) Результат правильный (или неверный, если вы ожидаете +Infinity
) и x == veryHighNumber
(даже когда компилятор сохраняет все, как вы их написали, и генерирует код для (veryHighNumber * 2) * 0.5
).
Почему это происходит? Компилятор здесь не выполняет никакой оптимизации, тогда ЦП должен быть виновен. Компилятор С# генерирует команды ldc.r8
и mul
, JIT генерирует это (если он компилируется в обычный код FPU, для генерированных инструкций SIMD вы можете увидеть дизассемблированный код в ответе Alex's):
fld qword ptr ds:[00540C48h] ; veryHighNumber
fmul qword ptr ds:[002A2790h] ; 2
fmul qword ptr ds:[002A2798h] ; 0.5
fstp qword ptr [ebp-44h] ; x
fmul
умножьте ST(0)
со значением из памяти и сохраните результат в ST(0)
. Регистры находятся в расширенной точности, тогда цепочка fmul
(сокращения) не вызовет +Infinity
до тех пор, пока она не переполнит расширенный диапазон точности (может быть проверена с использованием очень большого числа также для c1
в предыдущем примере).
Это происходит только тогда, когда промежуточные значения хранятся в регистре FPU, если вы разделяете наше выражение в несколько шагов (где каждое промежуточное значение сохраняется в памяти, а затем преобразуется обратно в двойную точность), вы ожидаете поведения (результат +Infinity
). Это ИМО - более запутанная вещь:
double x = veryHighNumber * 2 * 0.5;
double terriblyHighNumber = veryHighNumber * 2;
double x2 = terriblyHighNumber * 0.5;
Debug.Assert(!Double.IsInfinity(x));
Debug.Assert(Double.IsInfinity(x2));
Debug.Assert(x != x2);