Если вы прочитали заголовок и подумали «ну, ты, наверно, сделал сначала что-то глупое», то вы правы! Но что такое программирование, как не упражнения в глупых ошибках? Поиск глупых ошибок — это и есть самое большое удовольствие!

Также стоит заранее сделать оговорку о бенчмаркинге: ускорение на 42% было замерено при выполнении программы с моими данными и на моём компьютере, поэтому относитесь к этому результату с долей скепсиса.

Что делает программа?


codeowners — это программа на Go, выводящая владельцев каждого из файлов в репозитории согласно набору правил, указанному в файле GitHub CODEOWNERS. Правило может гласить, что всеми файлами с расширением .go владеет команда @gophers, или что всеми файлами в папке docs/ владеет команда @docs.

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

type Ruleset []Rule

func (r Ruleset) Match(path string) (*Rule, error) {
  for i := len(r) - 1; i >= 0; i-- {
    rule := r[i]
    match, err := rule.Match(path)
    if match || err != nil {
      return &rule, err
    }
  }
  return nil, nil
}

Поиск медленных фрагментов при помощи pprof и flamegraph


При обработке умеренно крупного репозитория мой инструмент работал немного медленно:

$ hyperfine codeowners
Benchmark 1: codeowners
  Time (mean ± σ):      4.221 s ±  0.089 s    [User: 5.862 s, System: 0.248 s]
  Range (min … max):    4.141 s …  4.358 s    10 runs

Чтобы понять, на какие части программа тратит своё время, я записал профиль CPU при помощи pprof. Сгенерировать профиль CPU можно, добавив в начало функции main следующий фрагмент кода:

pprofFile, pprofErr := os.Create("cpu.pprof")
if pprofErr != nil {
  log.Fatal(pprofErr)
}
pprof.StartCPUProfile(pprofFile)
defer pprof.StopCPUProfile()

Примечание: я пользуюсь pprof довольно активно, поэтому сохранил этот код в виде сниппета vscode. Я просто ввожу pprof, жму tab, и появляется этот сниппет.

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

$ go tool pprof -http=":8000" ./codeowners ./cpu.pprof

Как я и ожидал, основную часть времени программа тратила на функцию Match. Паттерны CODEOWNERS компилируются в регулярные выражения, и бо́льшая часть времени функции Match тратилась на движок regex языка Go. Но также я заметил, что много времени тратилось на распределение и возвращение памяти. Сиреневые блоки в показанном ниже flamegraph соответствуют паттерну gc|malloc, и видно, что суммарно они составляют существенную часть времени выполнения программы.


Охота на распределения кучи при помощи трассировок escape-анализа


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

Чтобы разобраться, когда какая-то память должна продолжать жить в куче, компилятор Go использует методику под названием «escape-анализ». Допустим, функция инициализирует struct, а затем возвращает указатель на неё. Если struct распределена в стеке, возвращённый указатель станет недействительным сразу после возврата функции и инвалидации соответствующего кадра стека. В этом случае компилятор Go определит, что указатель «сбежал» от функции и переместит struct в кучу.

Посматривать, как принимаются эти решения, можно, передавая -gcflags=-m команде go build:

$ go build -gcflags=-m *.go 2>&1 | grep codeowners.go
./codeowners.go:82:18: inlining call to os.IsNotExist
./codeowners.go:71:28: inlining call to filepath.Join
./codeowners.go:52:19: inlining call to os.Open
./codeowners.go:131:6: can inline Rule.Match
./codeowners.go:108:27: inlining call to Rule.Match
./codeowners.go:126:6: can inline Rule.RawPattern
./codeowners.go:155:6: can inline Owner.String
./codeowners.go:92:29: ... argument does not escape
./codeowners.go:96:33: string(output) escapes to heap
./codeowners.go:80:17: leaking param: path
./codeowners.go:70:31: []string{...} does not escape
./codeowners.go:71:28: ... argument does not escape
./codeowners.go:51:15: leaking param: path
./codeowners.go:105:7: leaking param content: r
./codeowners.go:105:24: leaking param: path
./codeowners.go:107:3: moved to heap: rule <<< --------- перемещение в кучу
./codeowners.go:126:7: leaking param: r to result ~r0 level=0
./codeowners.go:131:7: leaking param: r
./codeowners.go:131:21: leaking param: path
./codeowners.go:155:7: leaking param: o to result ~r0 level=0
./codeowners.go:159:13: "@" + o.Value escapes to heap

Вывод выглядит немного зашумлённым, однако бо́льшую его часть можно игнорировать. Так как мы ищем распределения, нас должна волновать фраза moved to heap. Если посмотреть на приведённый выше код Match, можно увидеть, что структуры Rule хранятся в слайсе Ruleset, насчёт которого мы можем уверены, что он уже находится в куче. И поскольку возвращается указатель на rule, не должны требоваться никакие дополнительные распределения.

И тут я понял: присваивая rule := r[i], мы копируем распределённое в куче Rule из слайса в стек, а возвращая &rule, мы создаём указатель («сбегающий») на копию struct. К счастью, исправить это легко. Нам нужно просто переместить амперсанд немного выше, чтобы мы получали ссылку на struct в слайсе, а не копировали её:

 func (r Ruleset) Match(path string) (*Rule, error) {
 	for i := len(r) - 1; i >= 0; i-- {
		rule := &r[i] // вместо rule := r[i]
 		match, err := rule.Match(path)
 		if match || err != nil {
			return rule, err // вместо return &rule, err
 		}
 	}
 	return nil, nil
 }

я рассмотрел и два других подхода:

  1. Превращение Ruleset из []Rule в []*Rule, что означало бы, что нам больше не нужно явным образом получать ссылку на правило.
  2. Возврат Rule вместо *Rule. Это всё равно скопирует Rule, но оно должно остаться в стеке, а не переместиться в кучу.

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

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

$ diff trace-a trace-b
14a15
> ./codeowners.go:105:7: leaking param: r to result ~r0 level=0
16d16
< ./codeowners.go:107:3: moved to heap: rule

Успех! Распределение пропало. Теперь посмотрим, как удаление этого одного распределения кучи повлияло на производительность:

$ hyperfine ./codeowners-a ./codeowners-b
Benchmark 1: ./codeowners-a
  Time (mean ± σ):      4.146 s ±  0.003 s    [User: 5.809 s, System: 0.249 s]
  Range (min … max):    4.139 s …  4.149 s    10 runs

Benchmark 2: ./codeowners-b
  Time (mean ± σ):      2.435 s ±  0.029 s    [User: 2.424 s, System: 0.026 s]
  Range (min … max):    2.413 s …  2.516 s    10 runs

Summary
  ./codeowners-b ran
    1.70 ± 0.02 times faster than ./codeowners-a

Так как это распределение происходило для каждого сопоставляемого пути, его удаление дало в моём случае увеличение скорости в 1,7 раза (то есть программа начала работать на 42% быстрее). Неплохой результат для изменения в один символ.

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


  1. Zara6502
    16.11.2022 10:22
    +1

    Кодировал алгоритмы на C# и заметил что изменение позиции объявления переменной влияло на время исполнения на 20%, условно

    int a=0;

    int b=0;

    менял на

    int a=0, b=0;

    Мне это очень не понравилось, по сути JIT пропускал кучи оптимизаций только из-за особенностей трассировки моего кода. Это эпикфейл MS.


    1. face86
      16.11.2022 11:22
      +2

      Не знаю, как у вас там в С#, но для микроконтроллеров в классическом СИ в любой приличной IDE (IAR, KEIL, и т.д.) компиляторы имеют несколько режимов оптимизации уже давным-давно. И как раз такие вещи они прекрасно обрабатывают. Даже могут несколько строк кода выбрасывать из прошивки, если эти строки не имеют смысла. Во время отладки с непривычки разработчик может быть удивлён тем, что отладчик пропускает некоторые строки и даже блоки кода типа простых циклов со счётчиком, который нигде больше не используется (например, для задержки на 1 млн тактов).

      Уверен, что для С# оптимизация настраивается от и до.


      1. speshuric
        16.11.2022 12:39
        +4

        Уверен, что для С# оптимизация настраивается от и до.

        Нет. Это по многим причинам не так. По большому счёту основная разница идёт между debug и release режимами - между ними разница значительна и, конечно, тестирование производительности в debug не стоит выполнять.
        Компиляция C#->IL почти не настраивается и почти не оптимизирует (ну разве что на уровне вывода типов немного). Это, похоже, принципиальный подход.
        JIT (IL в машинный код) тоже особо не настраивается - это скорее нужно для отладки самого движка .Net, чем для прикладных программистов. Причём JIT ограничен в оптимизациях - во-первых жёсткие требования к времени компиляции, во-вторых некоторые вещи нельзя оптимизировать "как в C" потому что нельзя по контракту.
        В итоге .NET может выявить какие-то константные вычисления, некоторые peephole оптимизации, ограниченно может заинлайнить, может выкинуть неисполнимые ветки кода. Но, например, не делает оптимизацию tail recursion - скорее всего для сохранения возможности размотки стека при исключениях, возможно по этой же причине хвостовой call никогда не преобразует в jump. Расчёт констант и выражений, оптимизация циклов и глубина инлайнинга тоже очень далека от -O3 С/С++. Но не скажу, что всё совсем плохо (пользуясь случаем, скажу спасибо @Nagg за его вклад в дело оптимизации движка).

        Важно отметить, что это не "C# плохой", это скорее "C# другой".


        1. Aleshonne
          16.11.2022 20:50
          +1

          Но, например, не делает оптимизацию tail recursion - скорее всего для сохранения возможности размотки стека при исключениях, возможно по этой же причине хвостовой call никогда не преобразует в jump.

          Вообще говоря, инструкция tail. в IL есть. Другое дело, что компилятор C# очень тяжело заставить её применять. А вот F# и Nemerle активно используют хвостовую рекурсию, если это возможно (например, нет обработки исключений с вызовом рекурсивной функции внутри).


          1. speshuric
            16.11.2022 22:37

            Ну что же, век живи - век учись. Спасибо за наводку. Я не знал о её существовании. В любом случае, C/C++ компилятору проверить допустимость хвостовой рекурсии обычно проще (и поэтому она происходит).


            1. Aleshonne
              16.11.2022 23:03
              +1

              Иногда под дотнетом очень интересные оптимизации встречаются. Например, тот же F# часто вообще рекурсию в цикл преобразует.

              Пример с факториалом

              Было:

              let fact n =
                  let rec fact_r n acc =
                      match n with
                      | 1 -> acc
                      | n -> fact_r (n - 1) (acc * n)
                  if n > 1 then fact_r n 1 else 1

              Стало:

              _.fact_r@2(Int32, Int32)
                  L0000: push ebp
                  L0001: mov  ebp, esp
                  L0003: lea  eax, [ecx-1]
                  L0006: test eax, eax
                  L0008: ja   short L000e
                  L000a: mov  eax, edx
                  L000c: pop  ebp
                  L000d: ret
                  L000e: imul edx, ecx
                  L0011: mov  ecx, eax
                  L0013: jmp  short L0003
              _.fact(Int32)
                  L0000: cmp  ecx, 1
                  L0003: jle  short L0010
                  L0005: mov  edx, 1
                  L000a: call _.fact_r@2(Int32, Int32)
                  L000f: ret
                  L0010: mov  eax, 1
                  L0015: ret

              Эквивалентный код на C#:

              internal static int fact_r@2(int n, int acc)
              {
                  while (true)
                  {
                      if (n == 1) return acc;
                      acc *= n;
                      n = n - 1;
                  }
              }
              public static int fact(int n)
              {
                  if (n > 1) return fact_r@2(n, 1);
                  return 1;
              }


              1. Deosis
                17.11.2022 08:46

                Это и есть пример оптимизации хвостовой рекурсии:

                Перезаписать аргументы и перейти к началу функции.



              1. 0xd34df00d
                17.11.2022 19:14
                +1

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


          1. a-tk
            17.11.2022 10:42
            +3

            Только не инструкция, а префикс инструкции.

            Вот только в своё время изменение обработки этого префикса в JIT-компиляторе вызвало некоторое удивление у некоторых людей.

            Не скажу за детали, но суть была в том, что некоторый человек ориентировался на то, что tailcall игнорируется и некоторый ресурсоёмкий сервис в облаке падает со stack overflow, а после обновления рантайма он внезапно стал использовать хвостовую рекурсию и не падал, а пользователю за работу сервиса был выставлен немалый счёт (это в телеге в pro.net как-то обсуждалось)


            1. Aleshonne
              18.11.2022 18:31

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


    1. ColdPhoenix
      16.11.2022 11:23
      +15

      Хотелось бы увидеть какой-то тест, не один год в C#, никогда не замечал подбного.

      Так как JIT о C# ничего не знает, это надо на iL смотреть.


    1. shushu
      16.11.2022 11:27
      +2

      Я думаю было бы хорошо иметь полный пример


      1. speshuric
        16.11.2022 12:45
        +1

        ... и желательно на https://sharplab.io/


      1. Zara6502
        16.11.2022 12:57

        Нет особой разницы в тем что я написал выше, так было:

        static void strfind3(byte[] s, byte[] su)
        {
          int ju = 0;
          int c = 0;
          int m = 0;
          int sl = s.Length;
          int sul = su.Length;
          int eof = sl - sul;
          int sp = 0;
          // всякое...
        }

        этот код при обработке файла в 900М отрабатывал за 2400 мс, а этот код:

        static void strfind3(byte[] s, byte[] su)
        {
          int ju = 0, c = 0, m = 0, sp = 0, j, sl = s.Length, sul = su.Length, eof = s.Length - sul;
          // всякое
        }

        уже за 1800 мс. Была однозначная повторяемость результатов.

        Позднее в другой программе тоже игрался с объявлением переменных, там код

        int i = 0, compares = 0, matches = 0, sul = substr.Length, sl = str.Length;

        менялся на

        int compares = 0, matches = 0, sul = substr.Length, sl = str.Length;
        int i = 0;

        и выполнение ВСЕЙ программы замедлялось на 40%.

        Я пишу без ООП, короткие программы, я не разработчик, проверяю какие-то алгоритмы и т.п. и для меня такое поведение - нонсенс, так как еще с Turbo C я прекрасно помню как сам компилятор оптимизирует код.

        и желательно на https://sharplab.io/

        я не занимаюсь публикацией своих программ.


        1. speshuric
          16.11.2022 13:17
          +2

          Не обязательно же всю программу.
          Я сделал пример, но в нём разница на 1 mov (и то, зависит просто от кода ниже)

          Надеюсь, что разница именно в release режиме?


          1. Kelbon
            17.11.2022 09:20
            +1

            Наличие этой разницы уже многое говорит


            1. speshuric
              17.11.2022 10:05

              Ни о чём не говорит. Это даже на синтетическом примере не даст и 3% разницы.


              1. 0xd34df00d
                17.11.2022 19:15
                +2

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


              1. Zara6502
                18.11.2022 05:25
                -1

                Сейчас мы НЕ доказываем что код меняется на 40% производительности, мы показываем суть моего треда - при синонимичном изменении кода меняется состав инструкций ЦПУ в конечном коде. Важно именно это, а не сколько там скушает mov.


                1. KvanTTT
                  18.11.2022 16:33
                  +1

                  Сейчас мы НЕ доказываем что код меняется на 40% производительности

                  В начале топика вы четко сказали про 20%:


                  Кодировал алгоритмы на C# и заметил что изменение позиции объявления переменной влияло на время исполнения на 20%, условно


          1. Zara6502
            17.11.2022 09:21
            -2

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


          1. raptor
            18.11.2022 10:32
            +1

            У автора там "опечатка" или хз как ее назвать. Если ее убрать - скомпилированный код будет идентичный. SharpLab


            1. speshuric
              18.11.2022 11:30

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


        1. KvanTTT
          16.11.2022 16:54
          +12

          Я пишу без ООП, короткие программы, я не разработчик, проверяю какие-то алгоритмы и т.п. и для меня такое поведение — нонсенс,

          Возможно, вы просто не умеете замерять производительность.


          1. Zara6502
            17.11.2022 05:24
            -2

            Ох, любимый комментарий XD Если его не пишут, то считайте день прошёл зря.

            Вы подразумеваете, что если программа А в одних и тех же условиях выполняется то 1 минуту, то - 5, то виноват не код программы (язык, компилятор, оптимизации), а процедура замера? (надеюсь вам же не приходит в голову, что два подряд объявления int не являются синонимичными записи с одним int???)

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


            1. speshuric
              17.11.2022 10:24
              +3

              Да, измерять производительность не так просто. Это не про "искривление пространства-времени". Судя по разнице и кейсу, скорее всего у вас разница проявляется на debug режиме. Могу ошибаться, но вы же так и не предоставили конкретные кейсы и замеры. И в этом случае у вас вашим бегунам надо одному после каждых 10 метров отмечаться в бланке о пробегании 10 метров, а второму - раз в 50 метров. В такой процедуре уже неважно, чем вы измеряли - фотофинишем или ручным секундомером, потому что скорость бега на 400 метров вы не измеряете.
              База с которой в принципе можно начинать замерять производительность - поставить максимально в одинаковые "стандартные" условия измеряемые кейсы (в .NET стал стандартным инструмент BenchmarkDotNet, в JVM - JMH). Следующая часть - зафиксировать и описать окружение. Следующая - сформулировать гипотезу (в данном случае - что однострочное объявление лучше многострочного). Потом - проверить гипотезу. Потом, если она подтверждается замерами - максимально изолировать причину и уменьшить влияние других факторов. По результатам уже можно было бы поднять issue в проекте dotnet runtime.
              Да, бывают какие-то отклонения от этой процедуры, например, если бага в инлайнинге jit, то она как раз НЕ проявляется в debug, а проявляется в release (я такой кейс находил). Но это всё равно примерно тот же сценарий (только код был одинаковый, а окружения отличались ровно на опцию debug/release).


              1. Zara6502
                17.11.2022 12:05

                Это не дебаг, что очевидно.

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

                // * Summary *
                
                BenchmarkDotNet=v0.13.1, OS=Windows 10.0.19044.1706 (21H2)
                  [Host]     : .NET 6.0.5 (6.0.522.21309), X64 RyuJIT
                  DefaultJob : .NET 6.0.5 (6.0.522.21309), X64 RyuJIT
                
                |        Method |    Mean |    Error |   StdDev |
                |-------------- |--------:|---------:|---------:|
                |             1 | 1.237 s | 0.0054 s | 0.0048 s |
                |             2 | 2.282 s | 0.0085 s | 0.0079 s |

                Что это изменило? XD


                1. KvanTTT
                  17.11.2022 16:07
                  +4

                  Так полный код предоставьте, на котором такое поведение наблюдается, иначе это все равно голословно.


                  1. Zara6502
                    18.11.2022 05:34
                    -2

                    1) NDA

                    2) Мы не в суде, я сообщил информацию к сведению, вы можете её проигнорировать


                    1. KvanTTT
                      18.11.2022 16:29
                      +1

                      2) Мы не в суде, я сообщил информацию к сведению, вы можете её проигнорировать

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


        1. raptor
          18.11.2022 10:37

          У вас не идентичный код, что выше привели вы. У вас не только изменение в объявлении, но и само присваивание для eof совершенно подругому описано. Ваша проблема может состоять в том, что для вычисления eof переменная S уже ушла из кеша процессора например


    1. gdQuel
      16.11.2022 16:53

      Неужели все это стоит учитывать?


    1. KvanTTT
      16.11.2022 16:53
      +4

      Голословное утверждение, ничем не подкрепленное.


      1. Zara6502
        17.11.2022 10:20

        у меня нет задачи сделать вас своим адептом, но !!!! все, кто пользуется C# не на уровне "Hello World!" знают, что ассемблерный код может очень сильно меняться при несущественных изменениях в программе, просто я лично не ожидал, что эти изменения могут быть практически фатальными для производительности. Я привык, что компилятор умнее меня с точки зрения оптимизации, я это видел и вижу при работе с MOS 6502, с 8088/80386, во всяком случае я не жду от них такой подставы.


  1. tenzink
    16.11.2022 14:42
    +3

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

    Разве сейчас контракт функции не поменялся (пусть и не так заметно)? До этого возвращался reference на копию, а сейчас на оригинал. Гипотетически можно представить клиента, который менял полученное правило и при этом никак не влиял на других клиентов. Теперь это изменится


  1. kuftachev
    18.11.2022 02:54
    +1

    А нельзя просто написать r[i].Match(...)? Зачем это переменная вообще нужна?

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