Когда я устал от программирования на Си, как и многих, меня заинтересовал язык Go. Он строго типизирован, компилируемый, следовательно достаточно производителен. И тут мне захотелось узнать, насколько заморочились создатели Go над оптимизацией работы с циклами и числами.

Для начала смотрим как обстоят дела у Си.

Пишем такой простой код:

#include <stdint.h>
#include <stdio.h>

int main()
{
        uint64_t i;
        uint64_t j = 0;
        for ( i = 10000000; i>0; i--)
        {
                j ^= i;
        }
        printf("%lu\n", j);
        return 0;
}

Компилируем с O2, дизассемблируем:

564:   31 d2                   xor    %edx,%edx
566:   b8 80 96 98 00          mov    $0x989680,%eax
56b:   0f 1f 44 00 00          nopl   0x0(%rax,%rax,1)
570:   48 31 c2                xor    %rax,%rdx
573:   48 83 e8 01             sub    $0x1,%rax
577:   75 f7                   jne    570 <main+0x10>

Получаем время исполнения:

real 0m0,023s
user 0m0,019s
sys 0m0,004s

Казалось бы уже не куда ускорятся, но у нас же современный процессор, для таких операций у нас есть быстрые sse регистры. Пробуем опции gcc -mfpmath=sse -msse4.2 результат тот же.
Добавляем -O3 и ура:

 57a:   66 0f 1f 44 00 00       nopw   0x0(%rax,%rax,1)
 580:   83 c0 01                add    $0x1,%eax
 583:   66 0f ef c8             pxor   %xmm0,%xmm1
 587:   66 0f d4 c2             paddq  %xmm2,%xmm0
 58b:   3d 40 4b 4c 00          cmp    $0x4c4b40,%eax
 590:   75 ee                   jne    580 <main+0x20>

Видно, что используется SSE2 команды и SSE регистры, и получаем тройной прирост производительности:

real 0m0,006s
user 0m0,006s
sys 0m0,000s

Тоже на Go:

package main
  
import "fmt"

func main() {
        i := 0
        j := 0
        for i = 10000000; i>0; i-- {
                j ^= i
        }
        fmt.Println(j)
}

0x000000000048211a <+42>:    lea    -0x1(%rax),%rdx
0x000000000048211e <+46>:    xor    %rax,%rcx
0x0000000000482121 <+49>:    mov    %rdx,%rax
0x0000000000482124 <+52>:    test   %rax,%rax
0x0000000000482127 <+55>:    ja     0x48211a <main.main+42>


Тайминги Go:
штатный go:
real 0m0,021s
user 0m0,018s
sys 0m0,004s

gccgo:
real 0m0,058s
user 0m0,036s
sys 0m0,014s

Производительность как в случае Си и O2, также ставил gccgo результат такой же, но работает дольше штатного Go (1.10.4) компилятора. Видимо в связи с тем, что штатный компилятор отлично оптимизирует запуск тредов (в моем случае на 4 ядра было создано 5 дополнительных тредов), приложение отрабатывает быстрее.

Заключение



Мне все же удалось заставить стандартный компилятор Go работать c sse инструкциями для цикла, подсунув ему родной для sse float.

package main
  
// +build amd64

import "fmt"

func main() {
        var i float64 = 0
        var j float64 = 0
        for i = 10000000; i>0; i-- {
                j += i
        }
        fmt.Println(j)
}


0x0000000000484bbe <+46>: movsd 0x4252a(%rip),%xmm3 # 0x4c70f0 <$f64.3ff0000000000000>
0x0000000000484bc6 <+54>: movups %xmm0,%xmm4
0x0000000000484bc9 <+57>: subsd %xmm3,%xmm0
0x0000000000484bcd <+61>: addsd %xmm4,%xmm1
0x0000000000484bd1 <+65>: xorps %xmm2,%xmm2
0x0000000000484bd4 <+68>: ucomisd %xmm2,%xmm0
0x0000000000484bd8 <+72>: ja 0x484bbe <main.main+46>

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


  1. youROCK
    12.12.2018 23:16

    Ну Go всё-таки не предназначен для расчетных задач и это не приоритет у разработчиков. Если вам очень хочется оптимизировать конкретный кусок, то лучше использовать встроенный ассемблер (но это непортируемое решение, конечно же)


    1. selenite
      12.12.2018 23:18

      > непортируемое
      можно выехать за счет build tags


  1. negasus
    12.12.2018 23:34
    +5

    Можно, конечно, предсказать комментарий, вида «В три строки делаем HTTP сервер на Go. Си еще есть куда расти»)
    Но ведь действительно, разве Go позиционируется, как замена Си, включая производительность?


    1. andrei_dm Автор
      12.12.2018 23:55
      -5

      Ну мне кажется любой язык компилируемый в оп кода процессора можно считать заменой Си


      1. exegete
        13.12.2018 00:08
        +5

        Уже наличие сборщика мусора в языке автоматически исключает возможность стать заменой языку Си. Также необходим zero runtime, т.е. возможность полного отказа от использования стандартной библиотеки. Иначе просто нереально будет писать ядра операционных систем или прошивки для микроконтроллеров. А это именно те области, где для Си нет альтернатив.


        1. andrei_dm Автор
          13.12.2018 00:11

          Тут я конечно не спорю, но иногда на Си можно написать так, что лучше бы был сборщик мусора


          1. snuk182
            13.12.2018 00:55
            +2

            Можно, но совершенно незачем. Ни богатства в итоге, ни славы.


          1. UnknownUser
            13.12.2018 12:57

            При жёстком недостатке памяти очень важно точно контролировать что когда там создаётся и освобождается.
            Сборщик мусора при этом будет источником нескончаемой боли разработчика.
            А написать так можно на любом языке.


    1. Laney1
      13.12.2018 08:58

      Но ведь действительно, разве Go позиционируется, как замена Си, включая производительность?

      Go позиционируется как замена Си в программах, где бутылочным горлышком оказывается скорость не собственно кода, а системных вызовов, ввода-вывода, и т.п. Например, в таких как docker.


      В софте, где идет борьба за каждую ассемблерную инструкцию, разумеется никакого Go быть не может. Большой привет автору статьи. Там даже на C++ часто ругаются.


  1. FenixFly
    12.12.2018 23:43
    +4

    Некорректно приводить сравнения, в которых действия выполняются меньше секунды. А вдруг это просто система лагнула?


    1. andrei_dm Автор
      12.12.2018 23:54
      -1

      результат стабильный


      1. tangro
        13.12.2018 11:49

        О стабильности результата можно было бы говорить, если бы Вы привели ну там 1000 экспериментов, показали матожидание, дисперсию, расказали о своём подходе к минимизации посторонних факторов и т.д. А так тест выглядит почти как случайные данные.


  1. crea7or
    13.12.2018 00:15
    +3

    Астрологи объявили месяц сравнения языков программирования?


  1. zuko3d
    13.12.2018 00:33

    Предлагаю скомпилировать сишный код с "-Ofast -march=native", ассемблерный код получится намного интереснее. Помимо этого, если у вас проц от Intel, имеет смысл скомпилить через icc.


    1. andrei_dm Автор
      13.12.2018 00:35

      тот же результат
      0x0000555555554590 <+32>: add $0x1,%eax
      0x0000555555554593 <+35>: pxor %xmm0,%xmm1
      0x0000555555554597 <+39>: paddq %xmm2,%xmm0
      0x000055555555459b <+43>: cmp $0x4c4b40,%eax


      1. apro
        13.12.2018 04:03

        А у меня результат (gcc 8.2.1) выглядит так:


        (gdb) disassemble main 
        Dump of assembler code for function main:
           0x0000000000001040 <+0>:     push   %rbp
           0x0000000000001041 <+1>:     vmovdqa 0xfd7(%rip),%ymm0        # 0x2020
           0x0000000000001049 <+9>:     xor    %eax,%eax
           0x000000000000104b <+11>:    vpxor  %xmm3,%xmm3,%xmm3
           0x000000000000104f <+15>:    vmovdqa 0xfe9(%rip),%xmm4        # 0x2040
           0x0000000000001057 <+23>:    mov    %rsp,%rbp
           0x000000000000105a <+26>:    and    $0xffffffffffffffe0,%rsp
           0x000000000000105e <+30>:    xchg   %ax,%ax
           0x0000000000001060 <+32>:    vextractf128 $0x1,%ymm0,%xmm1
           0x0000000000001066 <+38>:    vpaddq %xmm0,%xmm4,%xmm2
           0x000000000000106a <+42>:    vpaddq %xmm1,%xmm4,%xmm1
           0x000000000000106e <+46>:    add    $0x1,%eax
           0x0000000000001071 <+49>:    vxorps %ymm0,%ymm3,%ymm3
           0x0000000000001075 <+53>:    vinsertf128 $0x1,%xmm1,%ymm2,%ymm0
           0x000000000000107b <+59>:    cmp    $0x2625a0,%eax
           0x0000000000001080 <+64>:    jne    0x1060 <main+32>
           0x0000000000001082 <+66>:    lea    0xf7b(%rip),%rdi        # 0x2004
           0x0000000000001089 <+73>:    vmovdqa %xmm3,%xmm0
           0x000000000000108d <+77>:    xor    %eax,%eax
           0x000000000000108f <+79>:    vextractf128 $0x1,%ymm3,%xmm3
           0x0000000000001095 <+85>:    vpxor  %xmm3,%xmm0,%xmm3
           0x0000000000001099 <+89>:    vpsrldq $0x8,%xmm3,%xmm0
           0x000000000000109e <+94>:    vpxor  %xmm0,%xmm3,%xmm3
           0x00000000000010a2 <+98>:    vmovq  %xmm3,%rsi
           0x00000000000010a7 <+103>:   vzeroupper 
           0x00000000000010aa <+106>:   callq  0x1030 <printf@plt>
           0x00000000000010af <+111>:   xor    %eax,%eax
           0x00000000000010b1 <+113>:   leaveq 
           0x00000000000010b2 <+114>:   retq   
        End of assembler dump.


  1. quasilyte
    13.12.2018 00:37

    В компиляторе gc (который знаком большинству людей) нет векторизации циклов. Вообще. By design. Никто пока не предложил как её внедрить, чтобы не нарушить одно из:
    1. Не замедляет время компиляции.
    2. Реализация не слишком сложная (maintainability).
    3. Имеет примеры важного кода, который будет сильно ускоряться, кроме синтетики.

    Я не утверждаю, что это бесполезные вещи, просто напоминаю, что приоритет у этого всего довольно низкий, а порог для включения этих оптимизаций в ядро Go довольно высокий. Как-то так.

    Математический код и HPC может и получит буст от векторизации, но для gc по-моему это не самые частые пользователи. Возможно в будущем что-то изменится, но пока ситуация такая. Где-то ещё были разные связанные с этим proposal'ы, в том числе о введении примитивов для использования FMA инструкций, но под рукой списка нет, можете поискать на github трекере, если интересно.

    Возможно LLVM-based компилятор будет лучше, но по-моему там пока ещё не достаточно всё зрелое.


    1. andrei_dm Автор
      13.12.2018 00:49

      Завтра попробую intel компилятор, но по факту уже чистые sse2 инструкции дают хороший результат, хотя в большинстве прикладных задач, конечно этого не требуется


    1. Nagg
      13.12.2018 03:53

      LLVM успешно развернет и завекторизует.
      Т.е. даже C# с бэкендом LLVM через mono-llvm или Unity Burst будет быстрее
      PS: вообще я удивлен почему компиляторы не посчитали цикл в компайл тайме и не заменили константой — возможно через какой-нибудь llvm-souper/polly можно оптимизнуть


      1. Nagg
        13.12.2018 05:33

        хотя если вместо ^ использовать &,+,* — то посчитает в компайл-тайме


      1. RPG18
        13.12.2018 11:49

        За все надо платить. Стековая природа стандартного компилятора позволяет быстро переключать горутины. Как только мы хотим использовать больше инструкций процессора, то увеличивается контекст(количество регистров которые нужно сохранить).


  1. Nagg
    13.12.2018 03:49

    Скомпилировал ваш Си код clang с -O2 -march=native (или -mcpu=haswell) — он развернул ваш цикл, распихал все инты по всем доступным AVX регистрам.
    https://godbolt.org/z/iLkRS5
    ЗЫ: даже без avx все равно будет быстрее — будет тот же анроллинг и все SSE регистры


    real 0m0.003s
    user 0m0.001s
    sys 0m0.001s


  1. MainNika
    13.12.2018 10:49
    +1

    Код который отвечает за цикл при компиляциях gccgo vs gcc одинаков абсолютно. gccgo с -O3 тоже вкорячивает те же avx инструкции. Посмотрите godbolt.org/z/CvFV-X
    Вы в итоге хотите сравнить циклы C vs Go а на деле сравниваете накладные расходы на запуск приложения, на гошный рантайм, на специфику работы printf.
    Да и на самом деле смысл Go это больше про скорость разработки а не про тесты производительности с C.


    1. andrei_dm Автор
      13.12.2018 12:51

      Да я согласен, время тут сравнивать совсем не корректно, go запускает рабочие треды, шедулер, сборщик мусора, причем родной компилятор Go делает запуск быстрее. И скорее всего исходя из опыта использования google tcmalloc vs стандартный malloc, в много-тредовых сервисах go может показать сравнимые, а может и лучшие результаты относительно сишных сервисов основанных на обычном маллоке и posix тредах.


  1. andrei_dm Автор
    13.12.2018 13:51

    Все же удалось использовать SSE с float типом, видимо с int компилятор считает это преждевременной оптимизацией


  1. a-tk
    13.12.2018 21:12

    Видимо в связи с тем, что штатный компилятор отлично оптимизирует запуск тредов (в моем случае на 4 ядра было создано 5 дополнительных тредов), приложение отрабатывает быстрее.

    У меня одного эта фраза вызвала лёгкое недоумение?