Подтвердить что ты не робот

Оптимизирует ли компилятор работу по константной переменной и номеру литерала const?

Скажем, у меня есть класс с полем:

const double magicalConstant = 43;

Это где-то в коде:

double random = GetRandom();
double unicornAge = random * magicalConstant * 2.0;

Будет ли компилятор оптимизировать мой код, чтобы он не вычислял magicalConstant * 2.0 каждый раз, когда он вычисляет unicornAge?

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

4b9b3361

Ответ 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);