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

Пока всё вполне привычно: GCC выполнил предварительные проверки, затем попал в цикл, который суммирует числа при помощи lea (мы уже видели такое раньше). Но приглядевшись к циклу, мы найдём нечто необычное:

.L3:
  lea edx, [rdx+1+rax*2]        ; result = result + 1 + x*2
  add eax, 2                    ; x += 2
  cmp edi, eax                  ; x != value
  jne .L3                       ; продолжаем цикл

Умный компилятор понял, что может обрабатывать по два числа1 за раз благодаря тому что увидел суммирование x и x + 1, что эквивалентно сложению x*2 + 1. Думаю, вы согласитесь, что это очень разумное поведение!

Если повысить уровень оптимизатора до -O3 , то можно увидеть, что компилятор прилагает ещё больше усилий к векторизации цикла при помощи параллельных сложений. Тоже очень умное действие.

С компилятором GCC мы разобрались. Давайте посмотрим, что с нашим кодом делает clang:

И вот на этом моменте я чуть не упал со стула: цикла нет! Clang проверяет, положительно ли value, и если да, то выполняет следующее:

  lea eax, [rdi - 1]        ; eax = value - 1
  lea ecx, [rdi - 2]        ; ecx = value - 2
  imul rcx, rax             ; rcx = (value - 1) * (value - 2)
  shr rcx                   ; rcx >>= 1
  lea eax, [rdi + rcx]      ; eax = value + rcx
  dec eax                   ; --eax
  ret

Для меня было не совсем очевидно, что же, чёрт побери, здесь происходит. Но если немного разобраться с математикой, то становится понятно, что это эквивалентно такой записи:

v + ((v - 1)(v - 2) / 2) - 1;

Раскроем скобки:

v + (v² - 2v - v + 2) / 2 - 1

Немного изменим порядок:

(v² - 3v + 2) / 2 + (v - 1)

Умножаем (v - 1) на 2 / 2:

(v² - 3v + 2) / 2 + (2v - 2)/2

Объединяем их и сокращаем:

(v² - v) / 2

Упростив и вынеся за скобки, получим v(v - 1) / 2 , то есть решение в аналитическом виде «суммы целых чисел»! Поистине великолепно2 — мы выполнили переход написанного в коде от алгоритма O(n) к O(1)!

Я обожаю то, что, несмотря на более чем двадцатилетний опыт работы с компиляторами, они по-прежнему удивляют и радуют меня. Годы опыта и труда, вложенные в совершенствование компиляторов, впечатляют и вдохновляют.


  1. Часть изначального кода выполняет проверку на чётность/нечётность, и учитывает их.

  2. Почему компилятор генерирует именно такую последовательность, а не чуть более простую? Думаю, частично это вызвано необходимостью избегать переполнения в случаях, когда иначе бы возникло переполнение; это просто побочный эффект того, как clang отслеживает и выводит индуктивные переменные. Впрочем, наверняка я этого не знаю.

Комментарии (30)


  1. NeriaLab
    26.12.2025 07:02

    А что со "старенькими" компиляторами - 16-бит/32-бита Вы экспериментировали с ними?


    1. Siemargl
      26.12.2025 07:02

      Они такого не умеют. Это появилось в последних поколениях Gcc (ну может с 9) и clang раньше, причём подходы кардинально разные. Gcc был ближе к эвристикам, а clang (и ICC) к моделированию.

      Про msvc не скажу, не интересовался.


      1. NeriaLab
        26.12.2025 07:02

        Благодарю за ответ


  1. HyperWin
    26.12.2025 07:02

    Разработчики компиляторов гребаные волшебники.


    1. Refridgerator
      26.12.2025 07:02

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


      1. Seraphimt
        26.12.2025 07:02

        существуют только для того, чтобы производить впечатление вот в в такого рода статьях.

        Насколько я знаю, в LLVM есть правило - оптимизация должно быть или очевидно полезной, или подтверждаться примером из практики, что так действительно пишут.
        Плюс, не надо забывать, что оптимизации идут конвейером и реальной код может быть совсем иным, а здесь уже заинлайнены функции, константы посчитаны, какие-то избыточные проверки выкинуты и т.д. и т.п.


      1. mark_ablov
        26.12.2025 07:02

        Не, в данном случае это не peephole оптимизация, а всё круче)


  1. Denis_Chernyshev
    26.12.2025 07:02

    Это же кто-то засунул в компилятор известный анекдот от математиков.

    Говорят, что на одном из уроков, учитель математики решил дать задание для класса, в котором учился Гаусс, с таким расчетом, чтобы подольше занять учащихся. Недолго думая, педагог предложил обучающимся найти сумму чисел от 1 до 100. Юный Карл Фридрих Гаусс, уже с этого возраста отличавшийся незаурядными, решил задачу практически мгновенно.


    1. nin-jin
      26.12.2025 07:02

      Что же такого незаурядного было у юного Карла Фридриха Гаусса так останется.


      1. garwall
        26.12.2025 07:02

        ну насколько я помню эту легенду, там была не формула последовательной суммы, а чуть более изящное в контексте соображение: 1 + 2 + 3 ... + 97 + 98 + 99 + 100 == (1 + 99) + (2 + 98) + (3 + 97) ... + (49 + 51) + 50 + 100


        1. funca
          26.12.2025 07:02

          Там проще: 1 + 100, 2 + 99, ..., 50 + 51 = 101 × 50


  1. nerudo
    26.12.2025 07:02

    Осталось вбить в компилятор формулы для всех прочих известных рядов.


  1. avdx
    26.12.2025 07:02

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

    for (int x = 0; x + 1 < value; x += 2)
    {
      result += x;
      result += x + 1;
    }
    ... // обработка хвоста

    После этого просто оптимизировал тело цикла.

    А вот то, что clang может определять арифметическую прогрессию и знает формулу ее суммы, да, выглядит удивительно.


    1. mark_ablov
      26.12.2025 07:02

      LLVM не знает этого. Но в нём есть блок "Scalar evolutions" (https://www.npopov.com/2023/10/03/LLVM-Scalar-evolution.html), который определяет некоторые характеристика цикла, в том числе и способен представить значение result в виде рекуррентной формулы.


  1. zanzack
    26.12.2025 07:02

    В своё время меня поразил такой трюк.
    Рассмотрим программу на С для деления чисел на константу 450 (файл main.cpp)
    На самом деле подойдёт любая константа, необязательно 450.

    #include <stdio.h>
    #include <stdint.h>
    
    uint64_t div450(uint64_t a)
    {
    	return a/450;
    }
    
    int main(void)
    {
       uint64_t a;
       scanf("%lld", &a);
       printf("result = %lld\n", div450(a));
       return 0;
    }

    Компилируем с опцией -O2

    call "C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\VC\Auxiliary\Build\vcvars64.bat"
    cl.exe -O2 main.cpp

    Смотрим, что получилось -

    .00000001`40001024: 488D542430                  lea          rdx,[rsp][030]
    .00000001`40001029: 488D0D00B30100              lea          rcx,[00000001`4001C330] ;'%lld'
    .00000001`40001030: E89B000000                  call        .00000001`400010D0  ;scanf
    .00000001`40001035: 488B4C2430                  mov          rcx,[rsp][030]
    .00000001`4000103A: 48B813F0CDAB89674523        mov          rax,23456789`ABCDF013
    .00000001`40001044: 48F7E1                      mul          rcx
    .00000001`40001047: 482BCA                      sub          rcx,rdx
    .00000001`4000104A: 48D1E9                      shr          rcx,1
    .00000001`4000104D: 4803D1                      add          rdx,rcx
    .00000001`40001050: 488D0DE1B20100              lea          rcx,[00000001`4001C338] ;'result = %lld'
    .00000001`40001057: 48C1EA08                    shr          rdx,8
    .00000001`4000105B: E810000000                  call        .00000001`40001070 ;printf
    .00000001`40001060: 33C0                        xor          eax,eax
    .00000001`40001062: 4883C428                    add          rsp,028 ;'('
    .00000001`40001066: C3                          retn

    Деление на 450 превращается... превращается...
    В элегантное умножение на 0x23456789ABCDF013 (плюс еще некоторые трюки).
    Но красиво же!
    И быстрее работает - умножение быстрее деления.


    1. R0bur
      26.12.2025 07:02

      И быстрее работает - умножение быстрее деления.

      Хоть что-то в этом мире пока не перевернулось! Как в бейсик-интерпретаторах умножение было быстрее деления, так и сейчас f = g * m1 * m2 * r2, где r2 = 1 / r^2, вычисляется быстрее, чем f = g * m1 * m2 / r^2. Несмотря на FPU, MMX, SSE2 и другие страшные аббревиатуры.


  1. R0bur
    26.12.2025 07:02

    Компиляторы то и дело удивляют меня очень хитрыми трюками.

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


    1. Siemargl
      26.12.2025 07:02

      -О0


      1. VladD-exrabbit
        26.12.2025 07:02

        Забудьте. Вот тут даже с -O0 компилятор заменил x * 2 на x + x. As-if-rule действует всегда.


        1. Siemargl
          26.12.2025 07:02

          Невелика оптимизация, clang и icc используют shl, zig c умножает, но пролог ужас.

          Возможно, стоит брать компилятор потупее, например tcc или вообще freepascal.

          Вопрос, какие цели ставятся при этом.

          Upd. Я ошибся - даже -O0 не надо ставить, без опций и gcc умножает.


          1. VladD-exrabbit
            26.12.2025 07:02

            Но вы хотели, чтобы код компилировался наивно, «как написано». Гарантии нету даже с -O0.


          1. VladD-exrabbit
            26.12.2025 07:02

            Или вот ещё: в коде умножение на 0, но в компиляте нету умножения даже с -O0.


            1. Siemargl
              26.12.2025 07:02

              Здесь нет нарушения  As-if-rule


              1. VladD-exrabbit
                26.12.2025 07:02

                А я и не говорил, что as-if-rule нарушается. Наоборот, as-if-rule говорит, что ожидать наивной, «буквальной» компиляции не имеет смысла. Компилятор имеет право на любое равносильное преобразование.


          1. ALEX_k_s
            26.12.2025 07:02

            Без опций в gcc это эквивалент О0


        1. R0bur
          26.12.2025 07:02

           Вот тут даже с -O0 компилятор заменил x * 2 на x + x.

          Интересно, а чем x + x в данном случае лучше сдвига x << 1?


          1. nin-jin
            26.12.2025 07:02

            Задействованы разные блоки процессора, разные инструкции могут быть исполнены параллельно на разных блоках.


            1. R0bur
              26.12.2025 07:02

              Интересно, в моём представлении сдвиговые и битовые операции "лучше" любых арифметических - с точки зрения скорости выполнения и занимаемой памяти. А вот про многопоточность не подумал.


          1. VladD-exrabbit
            26.12.2025 07:02

            Возможно, это особенность конкретного процессора (я указал -march=haswell). Именно поэтому ручная оптимизация наподобие замены умножения на сдвиг может оказаться и пессимизацией (но продвинутый компилятор всё равно из множества операций с одинаковой семантикой (x + x, x << 1, x * 2) выберет самую оптимальную, даже если программист ошибся в ручной «оптимизации»).


  1. j123123
    26.12.2025 07:02

    Не, тут все не так однозначно. Вот https://godbolt.org/z/MzTd3fx7T для наглядности переписал на uint32_t и результат возвращается в uint64_t. Вручную записанная формула лучше компилируется, и никаких переполнений тут нет

    #include <inttypes.h>
    
    uint64_t sum_up_to2(uint32_t value)
    {
      return ((uint64_t)value*(value-1))/2;
    }
    
    uint64_t sum_up_to(uint32_t value)
    {
      uint64_t result = 0;
      for (uint32_t x = 0; x < value; ++x) {
        result += x;
      }
      return result;
    }