В статье Наблюдение за выполнением конкурирующих задач в Go и Rust коллега cpmonster привёл весьма интересные результаты:


Программа на Rust показала намного большую производительность при вычислении членов возвратной последовательности, чем программа на Go: 367 млн. итераций в секунду против 44 млн.

Ну, в 1.5 раза… Ну, в 2 раза… Но семь гвардейцев за два дня — это слишком, тем более что тут "гвардейцев" больше восьми!


Или нет, не слишком? В общем, потенциал любопытства пересилил другие потенциалы и я провёл своё исследование.


Повторение — мать учения и основа научного метода


Для начала попробуем воспроизвести результаты. Нужны исходники, а также Go и Rust (у меня версии 1.18 и 1.61, соответственно).


Идём в папку go/src и запускаем go run concgo.go s:


Cycles per second           70,621,468

Теперь в папке rust выполним cargo run s:


Cycles per second         25,562,372

Надо же, производительность версии на Rust в три раза ниже, чем версии на Go!


А, нет — это же debug, вот так надо: cargo run --release s. Совсем другое дело:


Cycles per second        603,500,301

Да, всё повторилось, те же 8+ раз. У меня и до чтения рассматриваемой статьи сложилось мнение, что Rust "готовит" более быстрые "числодробилки", но полученный результат — это же настоящее "унижение" для Go. Да неужто все именно так?! Будем разбираться.


Куда смотреть?


Смотреть сюда:



Именно эти функции вычисления последовательности триплетов подвергаются испытаниям.


При запуске с ключом s испытание происходит в функции count_cycles_per_sec(). Испытание, надо заметить, происходит "вне конкуренции" — т.е. в одном потоке. Что, конечно, сильно упрощает анализ.


Что пишут?


Сам автор статьи приводит такое соображение:


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

В принципе, да. Значения переменных в Go легко и зачастую незаметно "убегают в кучу", именно с этого я и начал свои эксперименты:


func BenchmarkIterate(b *testing.B) {
    for i := 0; i < b.N; i++ {
        iterate(random_triplet(), 1000000)
    }
}

Нет, тут все чисто:


cpu: Intel(R) Core(TM) i5-3570 CPU @ 3.40GHz
BenchmarkIterate
BenchmarkIterate-4
      79      14,405,887 ns/op         0 B/op          0 allocs/op
PASS
ok      

Версия из комментариев:


А почему версия Go такая старая (1.14.2 выпущена 2020-04-08)?

На Go 1.18 результаты не лучше.


Несколько раз высказывались сомнения в корректности испытаний, по типу такого:


На самом деле сравнение не корректно. И все результаты фактически вытекают из этого.
Более корректно было бы сравнить горутины с async кодом, в идеале — наверное на голом tokio

Всё это интересно, но запуск в режиме go run concgo.go s ведёт к тестированию всего лишь в одном потоке, так что феномен проявляется и может быть изучен без привлечения горутин и tokio.


Попытка номер 2


С ключами оптимизации у компилятора Go негусто, скорее есть ключи "деоптимизации" (-l -N) — так что остаётся работать с исходными текстами.


Сравнение текстов показало, что тип Triplet в Rust объявлен как кортеж (tuple):


type Triplet = (f64, f64, f64);

В то время как для Go используется массив:


type Triplet = [3]float64

В Go более близким к кортежу типом является структура:


type Triplet struct{ f0, f1, f2 float64 }

При помощи чудесного инструмента godbolt посмотрим, есть ли разница в ассемблерном коде для работы с такими определениями:


type Triplet = [3]float64
type Triplet2 struct{ f0, f1, f2 float64 }
...
var t Triplet
t[0] = 10.
t[1] = 11.
t[2] = 12.
printTriplet(t)

var t2 Triplet2
t2.f0 = 20.
t2.f1 = 21.
t2.f2 = 22.
printTriplet2(t2)

Оказывается, разница есть:


MOVSD   $f64.4024000000000000(SB), X0
MOVSD   X0, (SP)
MOVSD   $f64.4026000000000000(SB), X0
MOVSD   X0, 8(SP)
MOVSD   $f64.4028000000000000(SB), X0
MOVSD   X0, 16(SP)
CALL    "".printTriplet(SB)

MOVSD   $f64.4034000000000000(SB), X0
MOVSD   $f64.4035000000000000(SB), X1
MOVSD   $f64.4036000000000000(SB), X2
CALL    "".printTriplet2(SB)

То есть работа со структурой происходит через регистры, а с массивом фиксированной длины — через стек. Это, конечно, может повлиять на производительность!


Я сделал fork оригинального репозитория, в нём папочку go2/src, переопределил Triplet. Результат работает так:


210,349,179

Разница теперь всего в 3 гвардейца, уже не так обидно. Смотрим в ассемблер функций iterate():



Go встраивает вызов get_next_triplet(), но не делает этого для is_convergent():


;*** concgo.go#76   >       if is_convergent(triplet, next_triplet) && !prokukarek {
0x4ab357     0f10d9                 MOVUPS X1, X3               
0x4ab35a     0f10e2                 MOVUPS X2, X4               
0x4ab35d     0f10e8                 MOVUPS X0, X5               
0x4ab360     0f10c6                 MOVUPS X6, X0               
0x4ab363     e8b8feffff             CALL main.is_convergent(SB)     

А вот Rust полностью оснащает iterate.asm встроенной вычислительной техникой. Внешними остались только вызовы типа call std::io::stdio::_print, но они на скорость не влияют, так как последовательность не сходится и условие is_convergent(triplet, next_triplet) никогда не выполняется.


Отсюда и разница.


Для дальнейшего повышения производительности версии Go функцию is_convergent() можно встроить вручную:


//if is_convergent(triplet, next_triplet) && !prokukarek {
if approx_eq(triplet.f0, next_triplet.f0) &&
    approx_eq(triplet.f1, next_triplet.f1) &&
    approx_eq(triplet.f2, next_triplet.f2) && !prokukarek {
    print_convergency(initial_triplet, step, triplet.f2)
    prokukarek = true
}

Получилась папка go3/src, запуск из нее:


Cycles per second          393,081,761

Все равно 1.5 гвардейца, и это при том, что все вычисления встроены, см. go3/src/iterate.asm.


В качестве вишенки на торте попробуем переопределить Tripletв версии для Rust таким образом:


type Triplet = [f64; 3];

Будет ли разница? Нет. Ассемблер раз:


let applicant = triplet.0 + triplet.1 - triplet.2;
movapd  xmm0, xmm7
movapd  xmm7, xmm8
movapd  xmm8, xmm6
movapd  xmm6, xmm0
addsd   xmm6, xmm7
subsd   xmm6, xmm8
movapd  xmm1, xmm6
andpd   xmm1, xmm9 

Ассемблер два:


let applicant = triplet[0] + triplet[1] - triplet[2];
movapd  xmm0, xmm7
movapd  xmm7, xmm8
movapd  xmm8, xmm6
movapd  xmm6, xmm0
addsd   xmm6, xmm7
subsd   xmm6, xmm8
movapd  xmm1, xmm6
andpd   xmm1, xmm9

Некоторые размышления


  • Путём небольшой модификации исходного кода разницу удалось свести от "Rust на голову быстрее Go" к "Rust заметно быстрее Go"
  • Понятно, что речь идёт о конкретном вычислительном случае
  • В данном случае бо́льшая часть проигрыша по производительность упирается в стратегию встраивания в Go: function should be simple enough, the number of AST nodes must less than the budget (80)
  • С ходу возникает предложение завести директиву компилятора //go:inline, которая отменяла бы бюджетные ограничения
  • Такое предложение уже было сделано и висит в статусе FrozenDueToAge, первый комментарий гласит: "This proposal has basically no chance of being accepted" :)
  • Видимо, более подходящим названием директивы было бы //go:tryinline
  • Но даже с учетом встраивания остается разница в полтора раза

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


  1. Kekmefek
    27.05.2022 13:57
    +9

    Срочно переписываем микросервисы на Rust (шутка)!

    Думаю сервисам работающих с потоковой обработкой данных, например такими как звук и видео, стоит обратить внимание на Rust.

    В остальном Go будет удобнее.


    1. m03r
      27.05.2022 15:52
      +7

      Кстати, а чем Go удобнее для Вас?


      1. blind_oracle
        27.05.2022 16:19
        +16

        Неинопланетным синтаксисом, как минимум


        1. m03r
          27.05.2022 16:58
          +17

          Это, кажется, дело вкуса. По моим ощущениям в сравнении с C/Java/PHP оба достаточно инопланетные.

          Кстати, меня удивило, что количество строк практическо одинаковое (700 и 711), при том, что Go продвигается именно под соусом "простоты".

          А в чём ещё проще?


          1. DeniSix
            27.05.2022 17:16
            +5

            Не могу сказать, что знаю один из них сильно больше другого, но вот читать случайный проект на гитхабе проще, если он написан на Go. У Rust'а много плюсов, но читабельность я бы туда не записывал.


            1. AnthonyMikh
              28.05.2022 22:45
              +3

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

              И о чём это говорит? О том, что вы лучше знакомы с Go.


              1. vabka
                29.05.2022 23:47

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

                Например так сразу и не поймёшь, что значат эти ваши лайфтаймы, turbofish, восклицательные и вопросительные знаки, если ни разу до этого Rust не видел.


          1. blind_oracle
            27.05.2022 17:57
            +2

            Ну не знаю, у Go вполне C-подобный синтаксис, только чуть упрощён.

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

            Ещё чем? Ну, простотой работы с горутинами - забываешь про треды и async как страшный сон.


            1. m03r
              27.05.2022 20:31
              +10

              Лично меня очень путала разница = / := (и ещё можно пользоваться var, а можно и нет). А читать мешает то, что тип указывается после переменной/параметра, но без двоеточия. Для меня привычно смотрятся варианты bool a (как в C/Java/PHP) и a: bool (как в Паскале/Python/TypeScript). А вот Go в обоих случаях идёт особым путём.


          1. dream_designer
            27.05.2022 20:42
            +5

            Число строк и простота -- это совершенно несвязанные вещи, скорее даже наоборот, код, условно, на Perl или Haskell будет короче, а на APL еще короче. И длина будет обратно пропорциональна простоте.


            1. 0xd34df00d
              27.05.2022 23:02
              +5

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


        1. Fenex
          27.05.2022 18:21
          +15

          Много кто вначале своего пути изучения раста плюётся от синтаксиса. Однако практика показывает, что уже спустя месяц неторопливого изучения языка, все эти претензии куда-то магически улетучиваются )) Мне кажется это в основном потому что синтаксис на самом деле не слишком отличается, просто визуальная составляющая обматывает.

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


          1. gohrytt
            27.05.2022 20:09
            -3

            всем всегда становится неочевидным*


        1. PsyHaSTe
          27.05.2022 23:51
          +7

          В очередной раз попрошу предложить улучшения чтобы синтаксис был не инопланетным. Что стоит поменять, как считаете? Что конкретно инопланетное и нужно убрать?


          1. Leopotam
            28.05.2022 12:07
            -2

            1. Переделать неймспейсы и вот все это с двоеточий, двойных двоеточий на точки.

            2. Унифицировать скобки (лямбды с вертикальными палками - это кто-то специально постарался).

            3. Убрать/упростить всю пунктуацию, какую можно - это не только упростит восприятие, но и уменьшит сложность топтания по клавиатуре.


            1. DeniSix
              28.05.2022 12:42
              +3

              Убрать/упростить всю пунктуацию, какую можно

              Турборыбу трогать нельзя!


              1. ainu
                28.05.2022 12:53

                Вау. Действительно инопланетянский. Хоть и красиво.


            1. inferrna
              28.05.2022 12:46

              1. По вертикальным палкам лямбду издали видать, если же сделать круглые скобки, будет больше путаницы.

              2. Раст, это не про "тяп-ляп и в продакшн", это про вдумчивую расстановку скобочек, точечек, апострофов, чёрточек и двоеточий, но так, чтобы потом работало веками.


              1. Leopotam
                28.05.2022 12:49
                -1

                если сделать круглые скобки, будет больше путаницы

                Почему другим языкам это не мешает? Насколько было действительно целесообразно вводить настолько разнообразный синтаксис?

                это про вдумчивую расстановку 

                Ну так вопрос был про "что упростить", чтобы быстрее решало вопросы бизнеса путем ускорения разработки и того самого тяп-ляп. Веками не нужно чтобы работало - ТЗ может меняться довольно часто и инструмент должен помогать бежать быстрее уже сейчас.


            1. PsyHaSTe
              28.05.2022 14:35
              +1

              1. С двоеточиями проще видеть и отличать статические члены от членов переменных. В целом наверное можно было бы обойтись без них, учитывая что переменные в PascalCase именовать не принято. Окей, берем.
              2. лямбды как раз весьма удобны, позволяют писать |(x, y)| для деконструкции, например. Тут видно где аргументы а где тупл разбирается. Ну да ладно, принимаем.
              3. Можно привести пример, а то это как тот пункт ?????? пунат из саус парка. за которым следует PROFIT. А то на текущий момент кажется предлагается замена шила на мыло, которое на читаемость не повлияет примерно никак и вопрос больше вкусовщины.

              Почему другим языкам это не мешает? Насколько было действительно целесообразно вводить настолько разнообразный синтаксис?

              Жсу мешает, он потому заставляет расставлять скобочки где по хорошему хотелось бы их избежать. Например foos.map([x,y] => {x, y}) написать не выйдет.


              1. Leopotam
                28.05.2022 16:26
                -1

                Можно привести пример

                Это был ответ на "вдумчивую расстановку точек-запятых" - на это часто нет достаточного количества времени, а код не компилится, потому что нужно сконвертить мутабельный в немутабельный тип + обхитрить оунинг.

                Тут скорее набор вот таких неприятных мелочей, которые by design и не будут меняться, но они сильно портят впечатление при первом-втором-третьем знакомстве и приходится привыкать через страдания, чтобы хоть как-то двигаться вперед. Тот же голанг оказался настолько простым, я бы даже сказал деревянным, что порог входа просто нулевой. Да, горотины, каналы и все это - нужно изучать отдельно (как и вообще мультитред в принципе), но основы просты и приятны - через 3-4 дня человек садится и пишет работающий код, а не борется с компилятором со stackoveflow наперевес на каждый чих.


                1. PsyHaSTe
                  28.05.2022 17:24
                  +1

                  Это был ответ на "вдумчивую расстановку точек-запятых" — на это часто нет достаточного количества времени, а код не компилится, потому что нужно сконвертить мутабельный в немутабельный тип + обхитрить оунинг.

                  Вы какие-то страсти расказываете, я вот теряюсь что вы имеете в виду. Точки с запятой обычно ставятся 1 на строку там где заканчивается стейтмент, как и у всех. Под сконвертить мутабельный тип в немутабельный нельзя, потому что мутабельность не свойство типа. а биндинга. Но если вы про


                  let mut x = ...
                  ...
                  let x = x;

                  То поясните что вам тут не нравится и что стоило бы опять же сделать иначе в таком случае?


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

                  Вот у меня противоположенный опыт какой-то. Скобки/звездочки — как-то странно намешаны без особой логики, ничего не понятно.


                1. 0xd34df00d
                  28.05.2022 19:34
                  +4

                  но основы просты и приятны — через 3-4 дня человек садится и пишет работающий код

                  Это, на мой взгляд, несущественное преимущество, потому что язык вы изучаете один раз, а не каждый день.


            1. freecoder_xx
              28.05.2022 16:37
              +3

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

              foo().map(|x| x + 2)

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


            1. freecoder_xx
              28.05.2022 16:41
              +1

              Предоставляемые Rust возможности требуют явного синтаксического разделения статических обращений xx::yy::zz и динамических xx.yy.zz, где результат будет зависеть от значения объектов xx, yy и zzво время выполнения. Возможны ситуации, когда программист должен использовать канонический путь для обращения к методу, когда ему нужно явно указать тип или типаж, на котором будет вызван данный метод:

              let foo = Foo;
              foo.bar();      // вызов метода `bar` из `Foo`
              
              Bar::bar(&foo); // вызов метода `bar` из реализации типажа `Bar` для `Foo`

              Такое происходит, когда разные типажи, реализованные для типа, используют одинаковые имена методов, и разрешить конфликт имен необходимо статическим указанием на конкретный типаж или тип, к которому относится метод. Подобная запись вызовов оказывается незаменимой при обобщенном программировании. Если использовать для статического и динамического обращения один и тот же разделитель, то становится невозможно различать подобные вызовы:

              Foo::bar(&foo); // вызов метода `bar` из `Foo` у объекта `foo`
              Foo.bazz(&foo); // вызов метода `bazz` у созданного на месте объекта `Foo`

              Программа, написанная на Rust, не имеет информации о типах во время выполнения, так как при компиляции происходит "стирание типов". Поэтому возникает необходимость различать динамические и статические обращения: механизм их работы сильно отличается, как и последствия, к которым они приводят, так что программист должен видеть это в коде явно.

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


              1. PsyHaSTe
                28.05.2022 17:22
                +1

                Ну строго говоря ничего не мешало сделать точку тогда было бы. Пример


                Foo.bazz(&foo); // вызов метода `bazz` у созданного на месте объекта `Foo`

                в расте не прошел бы потому что переменная не может называться Foo, а только foo или FOO. Если мы применяем дефолтные правила форматирования, конечно же.


                А производить же заточку языка под маргиналов которые будут именовать как попало возможно не очень нужно.


                1. mayorovp
                  28.05.2022 18:29

                  Вот никогда-никогда запись Foo не может оказаться выражением, создающим экземпляр типа Foo без полей?


                  1. PsyHaSTe
                    28.05.2022 20:42
                    +1

                    Нет, тогда было бы Foo {}


                    1. AnthonyMikh
                      28.05.2022 22:49
                      +3

                      1. PsyHaSTe
                        28.05.2022 23:33
                        +1

                        Когда проектировались неймспейсы через :: ничто не мешало просто запретить так писать и требовать {} в конце.


                      1. EragonRussia
                        29.05.2022 13:47

                        struct Foo {} и struct Foo — несколько разные конструкции: https://rust-lang.github.io/rfcs/0218-empty-struct-with-braces.html.


                      1. PsyHaSTe
                        29.05.2022 14:52
                        +2

                        Это просто немного сахара закостылили для кодогенерации. И как раз с предложением выше этого бы не надо было делать — всегда была бы только одна запись struct Foo {}


      1. dunmaksim
        28.05.2022 23:04
        -4

        Здесь принято дрочить на всё, что делают Google и Apple. Golang — это Google Oberon. Дальше надо объяснять?


      1. JPEG
        29.05.2022 14:58

        Стыдно признаться, но сборкой мусора.


  1. Amomum
    27.05.2022 14:13

    Лично мне было тяжело воспринимать данные измерений, если они были записаны без разделителей десятичных разрядов (т.е. как 210349179, а не 603,500,301), слишком уж числа длинные. А в разных местах написано то так, то эдак, что еще сильнее затрудняет восприятие.


    1. maxim_ge Автор
      27.05.2022 14:18
      +4

      Добавил разделители для удобства. Хотя это уже "подделка данных".


      1. Amomum
        27.05.2022 14:20
        +5

        Спасибо!

        Имхо это не подделка, а редакторские правки :)


    1. PTM
      27.05.2022 15:13
      +5

      6,03500301e+8 ;)?
      у физиков обычно так


  1. OldFisher
    27.05.2022 14:40
    +17

    А вот бы ещё теперь кто-то взялся за исходник на Rust и добавил сколько-то гвардейцев обратно...


    1. Kelbon
      27.05.2022 14:47

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


  1. aceofspades88
    27.05.2022 14:50

    Хорошая статья, спасибо


  1. sciomenihilscire
    27.05.2022 14:54
    +1

    С чего начать копания в кишках Go, чтобы при возникновении подобных вопросов понимать откуда ноги растут?


    1. ReadOnlySadUser
      27.05.2022 15:19
      +5

      С изучения Си :) И я не шучу)


      1. sciomenihilscire
        27.05.2022 16:16
        +1

        Судя по всему, таки, придется.


      1. LinearLeopard
        27.05.2022 17:00
        +1

        Ну или с Rust, если верить статье


    1. maxim_ge Автор
      27.05.2022 15:43
      +8

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


      Сибираюсь "черкнуть" пару статей про обобщенные типы в Go, там этот вопрос будет рассматриваться.


      1. sciomenihilscire
        27.05.2022 16:17

        Ждем! С удовольствием гляну.


  1. impwx
    27.05.2022 16:00
    +9

    Практически все тесты вида "язык X быстрее языка Y" сводятся к тому, что "одинаковые" программы на самом деле используют разные структуры данных


    1. eyudkin
      27.05.2022 16:17
      +5

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


      1. Starche
        27.05.2022 19:09
        +4

        Максимум, что они проверяют - оптимизацию языком максимально наивной версии кода.

        Так может в этом и суть перфоманса языка? Суметь оптимизировать наивный код среднестатистического девелопера


        1. blind_oracle
          27.05.2022 20:21
          -2

          Это суть компилятора. А язык это просто спецификация обычно, как она реализована под капотом - это уже вторично.


          1. Sulerad
            27.05.2022 22:08
            +1

            Значит ли это, что для условного питона можно написать реализацию компилятора, который будет выдавать программы, сравнимые с условным rustc по производительности? Чтобы обычный наивный код с kwargs и декораторами работал также, как и обычный наивный код с traits и generics. И ещё интересно во сколько раз этот гипотетический компилятор будет сложнее условного gcc или LLVM.


            1. blind_oracle
              27.05.2022 23:03
              -2

              Где я про такое сказал? Я просто указал что спецификация языка и её реализация - это две параллельные вещи.

              Есть условный Си - есть куча разных компиляторов под его различные стандарты (C89 и далее). Есть Borland C, есть GCC, есть Clang, есть ещё бог весть что.

              Если у вас в рамках спецификации получится написать быстрый питон - честь и хвала. В принципе, есть куча реализаций Питона разной степени соответствия спецификации - PyPy, Jython и прочее.

              Тот же Go из сабжа изначально собирался компилятором написанным на Си (или плюсах, не помню), потом эволюционировал до самосборки. И с каждым релизом становится чуть быстрее там и сям, при этом соответствуя спецификации.


              1. slonopotamus
                28.05.2022 01:59
                +4

                Как ни старайся, реализовать Java/Python/Ruby/JS/Go/C#/PHP без сборщика мусора не выйдет, например. Ну тупо потому что операцию создания объектов в языки завезли, а удаление нет. И это тут же немедленно накладывает ограничения на то какой может быть реализация. И в языках полно других конструкций, накладывающих ограничения на эффективность реализации (про kwargs в питоне уже сказали выше).


                1. demoth
                  28.05.2022 04:12

                  Ну в го в большинство аллокаций происходят на стеке/регистрах за счёт escape анализа на этапе компиляции. По этому принципу в го есть много zero-allocation либ оптимизировнных таким образом, чтобы в процессе их работы память на куче вообще не выделялась, а значит и для сборщика мусора меньше работы. Но в целом согласен, что языки накладывают свои ограничения на возможности оптимизации компилятором. В го вообще разработчики компилятора осознанно не добавляют оптимизации, которые могут сильно замедлить скорость компиляции.


                1. Simipa
                  28.05.2022 09:07
                  -4

                  Kotlin и Swift же смогли сделать без сборщика мусора, так в чем проблема то?


                  1. slonopotamus
                    28.05.2022 09:37
                    +7

                    Про котлин не верю, дайте пруфлинк. Про свифт - ну так он течёт на циклических ссылках, потому что вместо полноценного GC там всего лишь рефкаунтинг. И в спецификации языка прописано, что это забота программиста следить за циклами: https://docs.swift.org/swift-book/LanguageGuide/AutomaticReferenceCounting.html


                    1. DeniSix
                      28.05.2022 09:49

                      Он, вероятно, про Kotlin/Native. Там тоже ARC + коллектор циклических ссылок.


                      1. slonopotamus
                        28.05.2022 12:38
                        +2

                        Ну так "коллектор циклических ссылок" это и есть GC, со всеми его минусами.


                      1. DeniSix
                        28.05.2022 12:54

                        Я и не утверждал обратного, лишь вторил по поводу Swift'а.


    1. lrrr11
      28.05.2022 19:13

      сравнивать Go и Rust в числодробильных задачах просто бессмысленно. Go не для этого создан, его компилятор не делает многие оптимизации (собственно в статье об этом написано) и заведомо проиграет LLVM.


  1. cadovvl
    27.05.2022 16:27
    +5

    В мое время это были "окончательные точки над i в сравнении производительности С++ и С#".

    Было же время...


  1. YuryB
    27.05.2022 22:09
    -14

    какой их смысл сравнивать, если go даже java медленнее? :) любая программа которая должна работать больше нескольких секунд и которая хоть как-то создаёт объекты работает медленней...


    1. YuryB
      28.05.2022 13:46
      -5

      о, я смотрю пригорело :) минусы летят, а конструктивно никто поспорить не может :) факт есть факт, в go очень простой сборщик мусора, лет через 10 или 15 может будет нормальный (это если ещё в него как следует вложатся), но сегодня он в роли гири и конкурировать с хорошо оптимизированным рантаймом java не может (зачем тогда вообще сравнивать с rust?). а если уж вы решите писать программу таким образом, чтобы она генерировала минимум мусора, то и на java у вас будет производительность около нативная (наверняка и на .net будет не кардинально хуже). по этому go и попал с спец нишу сетевых программ, а не стал заменой плюсам например. прасцице если кого обидел или расстроил :)


      1. PsyHaSTe
        28.05.2022 14:39
        +2

        То что гц го намного проще джавового и вероятно крутой — допустим факт. А вот то что это автоматически делает все программы на го медленными — это ваши выдумки.


        И это к слову вам говорит человек который го очень… недолюбливает.


        1. YuryB
          28.05.2022 16:38

          а чему ещё тормозить? есть на гите проект CardRaytracerBenchmark (кстати джавовский вариант там плохой по части IO, но не суть) можете посмотреть на разницу между платформами и языками. ну и погуглить про производительность go, тут на самом деле есть заметные проблемы, хотя похоже многие их не ожидают, ведь go компилируемый. если в таком вычислительном бенчмарке go сливает java то зачем он вообще нужен тогда? тем более что go по отзывам совсем не язык мечты...


          1. PsyHaSTe
            28.05.2022 17:25

            Чему угодно. Если вы брутфосите SHA256 хэш для стркои известного размера в 50 символов то ГЦ у вас мешать не будет совершенно, к примеру.


            1. YuryB
              29.05.2022 16:09

              :| какой смысл приводить крайние примеры? что они опровергают?


              1. PsyHaSTe
                29.05.2022 17:18

                У кого крайние, а у кого жизненные. Мы тут на 20% снизили общее время работы программы раставив 3 "лишних" ифа с проверкой размеров массивов в паре мест. Бывает и такое.


                1. gohrytt
                  29.05.2022 19:49

                  Можно просто _ = slice[тут самый большой элемент массива который будет использован] ( конечно если есть увереность что длина именно та). В стандартной библиотеке этот костыль в десятках мест, особенно в crypto.

                  Ну либо просто в начале функции l := len(slice) и по нему играть, компилятору этого достаточно чтобы не подставлять везде проверку.


                  1. PsyHaSTe
                    29.05.2022 21:49

                    там бывает хитрее, например мне каком-то месте пришлось писать условие
                    if i <= len - 1 && i + 1 <= len { ... }. Если любую часть условия убрать то у компилятора там эвристика ломалась и он хреново генерировал код.


                    1. Deosis
                      30.05.2022 08:04

                      А такой вариант не работал?

                      if i < len { ... }


                      1. PsyHaSTe
                        30.05.2022 09:22

                        Мы много чего перепробовали, и мы по сути вставляли проверки которые видели от компилятор в ассемблере после которых он делал джамп на панику. Нет, такой вариант не работал.


      1. aceofspades88
        28.05.2022 15:43
        +3

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


  1. Nbx
    27.05.2022 22:16
    +4

    Тут сам бог велел позвать 0xd34df00d чтобы он порвал всех своим хаскелем.


    1. 0xd34df00d
      27.05.2022 23:18
      +6

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


      1. Inspector-Due
        28.05.2022 10:36
        +5

        (А также @PsyHaSTe) Так, ну, я немного покопался в коде и вот что понял:

        data Triplet = Triplet !Double !Double !Double deriving Show
        
        getNextTriplet :: Triplet -> Triplet
        getNextTriplet (Triplet a b c)
          | abs app <= 1.0 = Triplet b c app
          | otherwise = Triplet b c (1.0 / app)
          where
            app = a + b - c
        
        iterateOver' :: Triplet -> Int -> Triplet
        iterateOver' trip n = foldl' (\t _ -> getNextTriplet t) trip [1..n]

        Наверняка тут можно уйти в unboxed types...

        Примечание

        В оригинальном коде, конечно, чуть сложнее (если текущий и следующий Triplets очень схожи, то об этом будет написано в консоли и всё (то есть можно брать iterateOver' и не париться):

        (~=) :: Double -> Double -> Bool
        a ~= b = abs (a - b) < 1e-14
        
        isConvergent :: Triplet -> Triplet -> Bool
        isConvergent (Triplet a b c) (Triplet x y z)
          = (a ~= x) && (b ~= y) && (c ~= z)
        
        iterateOver :: Triplet -> Int -> (Triplet, Maybe (Int, Double))
        iterateOver triplet cycles
          = foldl' f (triplet, Nothing) [1..cycles]
          where
            f (trip, r) n = (nextTrip, nextR)
              where
                nextTrip = getNextTriplet trip
                Triplet a b c = nextTrip
                nextR = if isConvergent trip nextTrip then r <|> Just (n, b) else r

        Далее поговорим про конкурентность. Вообще там много кода, направленного на создание собственного бенчмарка. Суть в следующем: есть series_size, который характеризует, как много таких вот iterateOver' будет запущенно параллельно. Далее для каждого r \in [1; \text{tasks_max}]делаем следующее: запускаем \lceil \frac{r}{\text{series_size}}\rceilраз в series_size параллельных потоков iterateOver' с рандомно сгенерированным Triplet и фиксированным n. Ну и собираем какие-то метрики, которые агрегируем и выводим на экран.

        Кстати, в коде, судя по всему, есть логическая ошибка. Допустим, tasks_max = 10, а series_size = 3. Тогда для r = 1 лишь один раз запустится iterateOver', хотя мы, вроде как, тестируем конкурентность и iterateOver' должен конкурентно запуститься вseries_size = 3 потоках. Ну, так же и для всех r, не кратных series_size.

        Пояснение к предыдущему абзацу

        Перепишем вот этот код так, чтобы он просто печатал нам, какая task в какой серии запускается.

        Получится

        package main
        
        import "fmt"
        
        func count_series(n_tasks, series_size int) int {
        	n_series := n_tasks / series_size
        
        	if series_size*n_series < n_tasks {
        		n_series++
        	}
        
        	return n_series
        }
        
        func main() {
        	n_tasks := 10
        	series_size := 3
        	n_series := count_series(n_tasks, series_size)
        	task_idx := 0
        
        	for series_idx := 0; series_idx < n_series; series_idx++ {
        		count_tasks_series := 0
        		for task_idx < n_tasks && count_tasks_series < series_size {
        			fmt.Printf("Running task # %d in series # %d\n", task_idx, series_idx)
        			count_tasks_series++
        			task_idx++
        		}
        	}
        }
        

        Ну и что мы получим?

        Running task # 0 in series # 0
        Running task # 1 in series # 0
        Running task # 2 in series # 0
        Running task # 3 in series # 1
        Running task # 4 in series # 1
        Running task # 5 in series # 1
        Running task # 6 in series # 2
        Running task # 7 in series # 2
        Running task # 8 in series # 2
        Running task # 9 in series # 3

        То есть таски 0..2, 3..5, 6..8 реально запускаются по-нормальному так, что параллельно будут выполняться ровно три (series_size) функции. А вот таска # 9 будет выполняться одна. Очевидно, одна параллельная задача выполнится быстрее трёх параллельных, так что это будет влиять на результаты бенчмарка.

        Да и в чём смысл сравнивать горутины с каким-то там crossbeam в Rust, я не знаю. Ведь горутины нужны не для того, чтобы дробить числа, а для IO.


        1. 0xd34df00d
          29.05.2022 00:19
          +6

          Спасибо, что покопались, у меня терпения не хватило. Теперь моя очередь копаться!


          Итак, сначала бейзлайн: раст на моей машине даёт примерно 390 миллионов циклов в секунду. Будем к этому стремиться.


          Итак, сначала однопоток.


          Взял второй код, с iterateOver — напрямую он генерит кучу мусора, и программа почти всё время сидит в GC. Если добавить -XStrict и -O2, то 100 миллионов циклов на моей машине занимает 1.2 секунды (в один поток, да). Что любопытно, -fllvm здесь не особо играет, несмотря на очевидную числодробильность кода — видимо, особо повода для векторизации здесь нет, а это основное отличие между NCG и LLVM в GHC. При этом код всё равно что-то аллоцирует, и 22 миллисекунды в GC проводит. Разбираться с этим в рамках комментария мне, конечно, лень.


          Давайте лучше перейдём на go напишем рекурсию явно и руками:


          iterateOverUp :: Triplet -> Int -> (Triplet, Maybe (Int, Double))
          iterateOverUp triplet cycles = go Nothing triplet 0
            where
              go acc trip n
                | n == cycles = (trip, acc)
                | otherwise = go acc' trip' (n + 1)
                where
                  trip'@(Triplet _ b _) = getNextTriplet trip
                  acc' = if isConvergent trip trip' then acc <|> Just (n, b) else acc

          (весь остальной код плюс-минус как у вас, кроме OPTIONS_GHC -O2 -fllvm и LANGUAGE Strict).
          Результат: 480 мс непосредственно вычислений и 5 мс в GC на 100 миллионов циклов. Что ж ты тут-то аллоцируешь, скотина,


          на три гигабайта и три тыщи gen0-сборок?
             3,200,125,888 bytes allocated in the heap
                    37,808 bytes copied during GC
                    44,408 bytes maximum residency (2 sample(s))
                    29,320 bytes maximum slop
                         2 MiB total memory in use (0 MB lost due to fragmentation)
          
                                               Tot time (elapsed)  Avg pause  Max pause
            Gen  0      3074 colls,     0 par    0.005s   0.005s     0.0000s    0.0000s
            Gen  1         2 colls,     0 par    0.000s   0.000s     0.0001s    0.0001s

          Ладно, с этим потом при случае поиграюсь, это любопытно. В любом случае это всё равно втрое быстрее по скорости и вчетверо — по аллокациям, чем вариант с foldl'.


          А давайте теперь напишем рекурсию не от 0 к cycles, а от cycles к 0? Сравнение с 0 процессоры любят больше, да и cycles тогда в замыкание захватывать не нужно, компилятору будет проще go соптимизировать:


          iterateOverDown :: Triplet -> Int -> (Triplet, Maybe (Int, Double))
          iterateOverDown triple cycles = go Nothing triple cycles
            where
              go acc trip 0 = (trip, acc)
              go acc trip n = go acc' trip' (n - 1)
                where
                  trip'@(Triplet _ b _) = getNextTriplet trip
                  acc' = if isConvergent trip trip' then acc <|> Just (cycles - n, b) else acc

          Хмм, ерунда какая-то, целых 880 мс,


          но зато никаких аллокаций и GC!
                   125,856 bytes allocated in the heap
                     3,312 bytes copied during GC
                    44,408 bytes maximum residency (1 sample(s))
                    25,224 bytes maximum slop
                         2 MiB total memory in use (0 MB lost due to fragmentation)
          
                                               Tot time (elapsed)  Avg pause  Max pause
            Gen  0         0 colls,     0 par    0.000s   0.000s     0.0000s    0.0000s
            Gen  1         1 colls,     0 par    0.000s   0.000s     0.0001s    0.0001s
          
            INIT    time    0.000s  (  0.000s elapsed)
            MUT     time    0.881s  (  0.881s elapsed)
            GC      time    0.000s  (  0.000s elapsed)
            EXIT    time    0.000s  (  0.000s elapsed)
            Total   time    0.881s  (  0.881s elapsed)
          
            %GC     time       0.0%  (0.0% elapsed)
          
            Alloc rate    142,832 bytes per MUT second
          
            Productivity 100.0% of total user, 100.0% of total elapsed

          А, блин, я ж всё равно захватываю cycles в замыкание, оно вон там в Just (cycles - n, b) торчит! Кстати, там ещё наверняка торчит ошибка на плюс-минус единицу, но мне лень об этом думать, давайте лучше поменяем, и будем вычитать после go, а не внутри:


          iterateOverDown :: Triplet -> Int -> (Triplet, Maybe (Int, Double))
          iterateOverDown triplet cycles = second (fmap $ first (cycles -)) $ go Nothing triplet cycles
            where
              go acc trip 0 = (trip, acc)
              go acc trip n = go acc' trip' (n - 1)
                where
                  trip'@(Triplet _ b _) = getNextTriplet trip
                  acc' = if isConvergent trip trip' then acc <|> Just (n, b) else acc

          О, совсем другое дело! 380 мс и по-прежнему никаких аллокаций!


          обязательный +RTS -sstderr
                   125,824 bytes allocated in the heap
                     3,312 bytes copied during GC
                    44,408 bytes maximum residency (1 sample(s))
                    25,224 bytes maximum slop
                         2 MiB total memory in use (0 MB lost due to fragmentation)
          
                                               Tot time (elapsed)  Avg pause  Max pause
            Gen  0         0 colls,     0 par    0.000s   0.000s     0.0000s    0.0000s
            Gen  1         1 colls,     0 par    0.000s   0.000s     0.0001s    0.0001s
          
            INIT    time    0.000s  (  0.000s elapsed)
            MUT     time    0.379s  (  0.379s elapsed)
            GC      time    0.000s  (  0.000s elapsed)
            EXIT    time    0.000s  (  0.000s elapsed)
            Total   time    0.379s  (  0.379s elapsed)
          
            %GC     time       0.0%  (0.0% elapsed)
          
            Alloc rate    331,816 bytes per MUT second

          Давайте поиграемся с флагами оптимизации? Во-первых, теперь -fllvm роляет: без него 450 мс. Во-вторых, теперь -O2 скорее вредит: без него 320-325 мс.


          Итого, 320 мс на 100 миллионов циклов, или чуть больше 310 миллионов циклов в секунду. Ну, не фонтан, но сойдёт.


          Давайте теперь многопоток. Как выглядел main до этой поры? Мне лень парсить аргументы и генерировать случайные тройки в рамках комментария, поэтому


          main = print $ iterateOverDown (Triplet 0.1 0.2 0.3) 312_000_000

          Что для многопотока? Ну, просто добавим зависимость от модуля async и заменим main на


          main = forConcurrently_ [1 .. fromIntegral numCapabilities] $ \cnt ->
              print $ iterateOverDown (Triplet (0.1 / cnt) (0.2 / cnt) (0.3 / cnt)) 312_000_000

          Технически оно раскидывает не по ядрам, а по числу runtime capabilities, которое примерно равно числу ОС-потоков, которое выделяет рантайм хаскеля. У меня 6 физических ядер и 12 логических. Что, если запустить на одном ядре?


            INIT    time    0.000s  (  0.000s elapsed)
            MUT     time    1.023s  (  1.022s elapsed)
            GC      time    0.000s  (  0.000s elapsed)
            EXIT    time    0.000s  (  0.007s elapsed)
            Total   time    1.024s  (  1.030s elapsed)

          На шести?


            MUT     time    6.302s  (  1.087s elapsed)

          Почти нет оверхеда, хорошо — 6.3 секунды суммарного времени по всем ядрам, и 1.09 секунд времени по часам.


          На семи?


            MUT     time    8.284s  (  1.516s elapsed)

          Попёр оверхед. Время по часам выросло на 0.5 секунд. Гипертрединг с числодробилками не очень дружен.


          На 12?


            MUT     time   22.289s  (  1.935s elapsed)

          Почти вдвое больше, чем на шести — как и ожидалось от гипертрединга и CPU-bound-задач.


          На 36, чисто поржать? Должно быть в районе 6 секунд wall time:


            MUT     time   67.104s  (  5.738s elapsed)

          Ну да, как и ожидалось.


  1. PsyHaSTe
    28.05.2022 00:03
    +5

    Посмотрел код, плюсую товарища 0xd34df00d выше, ничего не понял. Какие-то кроссбимы, мутируют при этом шаренные переменные...


    Вопрос, если в расте в томл дописать:


    [profile.release]
    lto = true
    codegen-units = 1

    И запускать как RUSTFLAGS="-C target-cpu=native" cargo run --release s
    то результаты станут лучше? У меня просто мак с М1, и компиляция всегда идет под текущий проц. А на х86 может быть приличная разница.


    1. Fenex
      28.05.2022 11:21
      +1

      Взял похожий процессор i5-3470@3.2 (как я понял, у автора i5-3570@3.4).


      • запуск как у автора: 597,728,631
      • изменён Cargo.toml: 597,728,631
      • изменён Cargo.toml + target-cpu=native: 690,607,734

      Выигрыш виден, но Ivy Bridge — довольно старая архитектура (~10 лет), поэтому полагаю что на более современных процессорах разница между target-cpu=native\generic будет ещё больше.


      Ещё я решил посмотреть, что будет если вместо target-cpu=native попробовать включить оптимизации с упором на компактность бинарника, а не производительность. Получилось следующее: скорость работы снижается существенно, а вот размер — не очень.


      • s: 143,389,733 (2635K)
      • z: 143,410,296 (2665K)
      • 3: 597,728,631 (2692K)

      Результаты размера без strip.


    1. nuald
      29.05.2022 08:25
      +1

      На самом деле, не такая уж приличная разница (что подтверждается другими бенчмарками, которые я курирую). Мои данные:

      Современная архитектура: Intel Xeon E-2324G

      Go (go3, go run concgo.go s): 748,502,994
      Rust (rust2, cargo run --release s, запуск как у автора): 941,619,585
      Rust (rust2, cargo run --release s, изменён Cargo.toml): 939,849,624
      Rust (rust2, RUSTFLAGS="-C target-cpu=native" cargo run --release s, Cargo.toml + target-cpu=native): 965,250,965

      В данном случае, lto дает даже чуть худшие результаты, но это прогрешности, и я никогда не видел, чтобы были реально лучше результаты (использование cpu=native совершенно другая история, и это реально может давать улучшения). Насколько я видел, LTO имеет смысл когда линкуются много файлов, и для этого данного теста это не особо важно.


      1. PsyHaSTe
        29.05.2022 09:54

        Лто там так, до кучи, прост на всякий случай. Меньший бинарник должен давать лушее кеширование инструкций, в теории. НА практике разница и правда невелика.


  1. Inspector-Due
    28.05.2022 11:47

    По поводу кода, написанном на Golang: я попробовал написать пару версий.

    1. Тот же вариант, что и описан в оригинальной статье. type SickTriplet = [3]float64

    2. Вариант, использующий type Triplet struct { a, b, c float64 }, но создающий новую копию при каждой итерации

    3. То же, что и (2), но только данные мутируются на месте.

    Код
    package triplet 
    
    import (
    	"math/rand"
    	"math"
    )
    
    type SickTriplet = [3]float64
    
    func randomSickTriplet() *SickTriplet {
    	return &SickTriplet{rand.Float64(), rand.Float64(), rand.Float64()}
    }
    
    func getNextSickTriplet(trip *SickTriplet) *SickTriplet {
    	app := trip[0] + trip[1] - trip[2]
    	if math.Abs(app) <= 1.0 {
    		return &SickTriplet{trip[1], trip[2], app}
    	} else {
    		return &SickTriplet{trip[1], trip[2], 1.0 / app}
    	}
    }
    
    func calcSickTriplet(trip *SickTriplet, num int) *SickTriplet {
    	for i := 0; i < num; i++ {
    		trip = getNextSickTriplet(trip)
    	}
    	return trip
    }
    
    func fastCalcSickTriplet(trip *SickTriplet, num int) *SickTriplet {
    	for i := 0; i < num; i++ {
    		app := trip[0] + trip[1] - trip[2]
    		trip[0] = trip[1]
    		trip[1] = trip[2]
    		if math.Abs(app) <= 1.0 {
    			trip[2] = app
    		} else {
    			trip[2] = 1.0 / app	
    		}
    	}
    	return trip
    }
    
    type Triplet struct { a, b, c float64 }
    
    func randomTriplet() *Triplet {
    	return &Triplet{rand.Float64(), rand.Float64(), rand.Float64()}
    }
    
    func fastCalcTriplet(trip *Triplet, num int) *Triplet {
    	for i := 0; i < num; i++ {
    		app := trip.a + trip.b - trip.c
    		trip.a = trip.b
    		trip.b = trip.c
    		if math.Abs(app) <= 1.0 {
    			trip.c = app
    		} else {
    			trip.c = 1.0 / app	
    		}
    	}
    	return trip
    }
    
    func getNextTriplet(trip *Triplet) *Triplet {
    	app := trip.a + trip.b - trip.c
    	if math.Abs(app) <= 1.0 {
    		return &Triplet{trip.b, trip.c, app}
    	} else {
    		return &Triplet{trip.b, trip.c, 1.0 / app}
    	}
    }
    
    
    func slowCalcTriplet(trip *Triplet, num int) *Triplet {
    	for i := 0; i < num; i++ {
    		trip = getNextTriplet(trip)
    	}
    	return trip
    }

    Сам бенчмарк:

    package triplet
    
    import "testing"
    
    
    const TIMES = 1000000
    
    
    func BenchmarkFastCalcTriplet(b *testing.B) {
    	for i := 0; i < b.N; i++ {
    		fastCalcTriplet(randomTriplet(), TIMES)
    	}
    }
    
    func BenchmarkSlowCalcTriplet(b *testing.B) {
    	for i := 0; i < b.N; i++ {
    		slowCalcTriplet(randomTriplet(), TIMES)
    	}
    }
    
    func BenchmarkCalcSickTriplet(b *testing.B) {
    	for i := 0; i < b.N; i++ {
    		calcSickTriplet(randomSickTriplet(), TIMES)
    	}
    }
    
    func BenchmarkFastCalcSickTriplet(b *testing.B) {
    	for i := 0; i < b.N; i++ {
    		fastCalcSickTriplet(randomSickTriplet(), TIMES)
    	}
    }
    

    Результаты такие:

    BenchmarkFastCalcTriplet-4       	     548	   2177936 ns/op	      24 B/op	       1 allocs/op
    BenchmarkSlowCalcTriplet-4       	      62	  18613275 ns/op	24000044 B/op	 1000001 allocs/op
    BenchmarkCalcSickTriplet-4       	      62	  18929575 ns/op	24000036 B/op	 1000001 allocs/op
    BenchmarkFastCalcSickTriplet-4   	     534	   2225462 ns/op	      24 B/op	       1 allocs/op

    Получается, выбор структуры данных не особо и влияет?


  1. mishapoiuytrewq
    28.05.2022 17:49
    +1

    В golang gcflags="-l=4" включит middle stack inline


    1. maxim_ge Автор
      28.05.2022 19:40

      Интересный вариант. Попробовал так:


      go run -gcflags="-l=4 -m -m" concgo.go s > o 2>&1


      • На результаты не повлияло
      • В o пишут:
        .\concgo.go:60:6: cannot inline is_convergent: function too complex: cost 87 exceeds budget 80
      • Т.е. таки проблема с is_convergent не во "вложенности", а в "сложности"?
      • В исходниках пишут:
        making 1 the default and -l disable. Additional levels (beyond -l) may be buggy and
        // are not supported.
      • Вроде нет указаний, что -l=4 можно использовать "в бою"?