Привет, Хабр! Я Кирилл Кузин — старший разработчик в компании Ви.Tech,являющейся IT-дочкой маркетплейса ВсеИнструменты.ру. Интернет-магазин стремительно растет и развивается. И сейчас мы имеем 4 кластера Kubernetes, в каждом из которых живут от 200 до 215 нод, а 1 млн пайплайнов в месяц выполняют свою работу. Ежедневно на наш сайт приходит почти 2 млн уникальных пользователей.
Я и мои коллеги создаем высоконагруженную среду для развития бизнеса маркетплейса. Наши сервисы выдерживают десятки тысяч RPS, а основной язык разработки — это Golang. Мы внимательно следим за тенденциями, изменениями в сфере разработки, изучаем различные возможности и инструменты для ускорения работы микросервисов.
В этой статье хотелось бы рассказать об одном из таких инструментов. Нет, это далеко не новшество в мире программирования, однако в golang он появился лишь полтора года назад. И это PGO.
Я подробно расскажу о том, что конкретно делает данный инструмент и на что влияет. Покажу и расскажу, что происходит в коде компилятора без использования PGO и вместе с ним. Покажу на реальной задаче, как преобразуется код благодаря этому инструменту и возможно ли по итогу получить ускорение работы сервиса. Затем соберу полученные знания воедино, подведу итоги и сделаю объективные выводы. Написанное точно актуально для версии языка до 1.23.
Если не хочется читать, но есть время посмотреть
Данная статья - пересказ моего доклада на GolangConf в рамках Saint Highload ++ 2024. Для тех, кому проще послушать доклад - можно посмотреть его запись :)
Что такое PGO и с чем его «едят»
PGO (Profile-guided Optimization) — это оптимизация с использованием данных, полученных во время профилирования приложения.
Если не вдаваться в подробности, то использование PGO выглядит следующим образом:
мы запускаем сервис в среду, в которой возможно обеспечить реальную нагрузку;
профилируем его какое-то время, собирая необходимые данные;
получив их, перекомпилируем сервис, используя полученный после профилирования файл — именно он вносит необходимые изменения и реализует оптимизации, ускоряющие работу кода;
вводим сервис в эксплуатацию;
повторяем цикл операций при каждом новом деплое.
Как видно, шаги довольно тривиальные и понятные. Возможно, вы уже слышали о PGO из других языков программирования.
На данном скриншоте пример описания работы с PGO в Rust из его мануала — rustbook, и выдержка из поста в блоге golang об инструменте. Принцип, как видно, один и тот же. При этом отличается не только подход к реализации PGO в этих двух языках внутри исходного кода, но и подход в работе самих компиляторов.
В golang компилятор довольно консервативен. Его главной задачей является обеспечение высокой скорости компилирования при оптимальном применении различных внутренних инструментов без ущерба скорости сборки приложений и их работы.
Консервативность означает и то, что по сравнению с другими компиляторами, компилятор Golang выполняет меньше работы. Он не использует часть тех инструментов, что используются при компилировании других языков. Например, компиляторы С++ выполняют большее количество возможных действий и оптимизаций по сравнению с Golang. Возможно, что все дело в возрасте языков.
Возникает резонный вопрос: на чем PGO основывает свою работу в Golang, и что именно он делает в недрах исходного кода?
Половина ответа состоит в том, что PGO стоит на двух столпах, лежащих в его основе: профилировании и оптимизации на этапе компиляции кода. Про профилирование в этой статье говорить не будем. Давайте поговорим про оптимизации компилятора, ведь именно ими оперирует PGO в исходном коде Go.
Оптимизации компилятора в Golang
Оптимизации компилятора — это процессы, или же подходы, которыми оперирует компилятор для преобразования исходного фрагмента кода в новый фрагмент. Оба фрагмента функционально идентичны. Но с точки зрения скорости выполнения и количества машинного кода оптимизированный скомпилированный код будет показывать себя с лучшей стороны. Соответственно, компилятор, который в состоянии применять оптимизации, называется оптимизирующим.
В работе компилятора обычно выделяют два этапа работы:
Frontend: отвечает за парсинг и токенизацию написанного кода, проверку типов и построение синтаксического дерева;
Backend: генерирует машинный код.
Вот только в Golang выделяется еще и третий этап — Middle-end. Он включается в работу сразу после построения синтаксического дерева. Именно этот этап отвечает за применение оптимизаций при компиляции.
Middle-end включает в себя следующие оптимизации:
Поиск и устранение мертвого кода (cmd/compile/internal/deadcode).
Удаляет строки кода, не участвующие в работе программы. В результате сокращаются размеры бинарного файла и синтаксического дерева. А чем меньше синтаксическое дерево, тем быстрее применяются следующие оптимизации;Escape-анализ (cmd/compile/internal/escape).
Данный процесс помогает определить оптимальное место хранения переменной: в куче или в стеке. Это значительно снижает «давление» на Garbage Collector.Инлайнинг или встраивание (cmd/compile/internal/inline).
Помогает встроить тело функции вместо ее вызова — экономия времени на переходе от места вызова к «телу» и обратно.Девиртуализация (cmd/compile/internal/devirtualization).
Преобразование косвенного метода вызова (от переменной типа интерфейса) к прямому вызову (от конкретного типа).
При этом PGO влияет только на две оптимизации: встраивание и девиртуализацию. Но для того, чтобы понять, как именно при этом ускоряется выполнение кода, необходимо разобраться как компилятор применяет указанные оптимизации по умолчанию.
Как работают инлайнинг и девиртуализация по «дефолту»
Начнем с инлайнинга. Это основная оптимизация компилятора, потому что именно она привносит наибольшие изменения в кодовую базу и открывает доступ для остальных оптимизаций. Концепцию инлайнинга и процесс его работы в Golang описывают следующие элементы:
inlineMaxBudget = 80 // «Бюджет» на встраивание
inlineExtraAppendCost = 0 // Стоимость встраивания вызова append()
inlineExtraCallCost = 57 // Стоимость встраивания «невстраиваемых» вызовов функций
inlineExtraPanicCost = 1 // Стоимость встраивания паники
inlineExtraThrowCost = inlineMaxBudget // Стоимость встраивания методов, которые используют runtime.throw для раскручивание стека вызовов при панике
Глядя на список, можем заключить, что, во-первых, процесс инлайнинга имеет довольно простую модель, где каждой рассматриваемой функции в начале работы с инлайнингом присваивается бюджет, равный 80 единицам. Далее происходит рекурсивный обход элементов тела этой функции, количество которых примерно равно количеству узлов в синтаксическом дереве. Каждый из них имеет некий вес — стоимость, которую компилятор отнимает от изначального бюджета. Если на момент окончания обхода бюджет еще остался большим или равным нулю, то функция считается встраиваемой. Бюджет является основополагающим элементом инлайнинга.
Также необходимо иметь ввиду несколько констант, которые описывают стоимость встраивания некоторых элементов рассматриваемой функции. Например, стоимость на встраивание вызова append() равна нулю. Это наводит на мысль о том, что разработчики Golang для утверждения стоимости на встраивание опирались на какие-то свои эмпирические представления и бенчмарки. Это же подтверждает значение константы, описывающей стоимость встраивания «невстраиваемых» функций.
Что значит «невстраиваемые»? Это те вызовы внутри оцениваемой функции, которые либо сами превысили свой бюджет на встраивание, либо заведомо невстраиваемы по некоторым причинам (о них чуть позже). А почему значение именно 57?
На самом деле, это из разряда ответа на вопрос Жизни, Вселенной и всего такого. Причем изначально значение равнялось 60 единицам, но после проведения дополнительного исследования энтузиастом оказалось, что при значении в 57 единиц встраивание достигает в среднем своей максимальной производительности.
Я думаю с концепцией теперь стало более-менее понятно. Но что происходит внутри?
А внутри нас встречают два этапа проведения встраивания.
Первый этап, согласно названию функции, проходится по всему пакету (компилятор оперирует именно пакетами) и проверяет возможность встраивания функций и методов, начиная с верхнеуровневых.
Второй этап покажет нам то, что встраивание тесно связано с девиртуализацией. В функции, которая своим названием и показывает эту взаимосвязь, происходит сам процесс встраивания выбранных на предыдущем этапе вызовов. При этом происходит дополнительная проверка на то, что операция может быть выполнена согласно критерию бюджетирования.
Если провалиться в функцию первого этапа, то произойдет столкновение с первыми условиями, отсеивающими неподходящие для встраивания вызовы.
Во-первых, сталкиваемся с флагом l — это флаг, отвечающий за работу встраивания в принципе. Если его значение равно 0, то оптимизация компилятором не применится. По дефолту флаг равен единице, что означает включенный инлайнинг.
Во-вторых, происходит проверка того, что в функции нет никакой рекурсии, либо она охватывает более, чем одну функцию.
Внутри CanInline() происходит проверка дополнительных условий, чтобы определить является ли функция невстраиваемой. Эти условия делятся на две группы:
Первая зависит от прагм компилятора. Если хотя бы одна из существующих прагм определена перед рассматриваемой функцией, то встраивание вызова не произойдет;
Вторая группа причин невстраивания затрагивает наполнение тела рассматриваемой функции. Инлайнинг не произойдет, если в теле:
объявлена горутина;
имеется recover() или defer;
существует хвостовой вызов этой же функции (tail call);
или флаг InlFuncsWithClosures == 0. Это флаг дебага, который позволяет управлять нашими ожиданиями от встраивания функций с замыканиями. Если флаг 0, то функция с замыканиями в теле не встроится.
Далее хотел бы обратить внимание на одну интересную вещь. По умолчанию все элементы тела функции тратят ее бюджет на встраивание, но в редких случаях происходит наоборот - бюджет увеличивается. Делается это намеренно, так как такие кейсы не особо влияют на какие-либо вычисления. Вот перечень элементов, увеличивающих бюджет функции или метода, к которым они принадлежат:
Паника;
Указатели и их разыменовывание;
Выражение метода;
Множественное присваивание;
Блок {};
Преобразование типов.
Указатели и преобразование типов, как указано в компиляторе, не генерируют никакой код, а значит не влияют на скорость компиляции. И только увеличение бюджета из-за множественного присваивания оправданно тем, что за этим кодом стоят преобразования на стороне компилятора, которые компенсируются увеличением бюджета. Только этот случай позволяет добавить бюджету больше единицы. Остальные случаи увеличения бюджета на встраивание просто нивелируют его дефолтное уменьшение на единицу для большинства элементов тела функции или метода.
Не считая случаи выше, элементы тела функции уменьшают бюджет встраивания хотя бы на единицу — это аксиома. Но есть и те элементы, которые тратят еще больше:
Паника.
Вызовы, которые могут быть встроены.
Append().
Замыкание, если InIFuncsWithClosures > 0.
Вызов функции, которая не встраивается.
Все остальное.
И вот когда подсчет, основанный на этих соображениях, закончен, можно переходить ко второму этапу инлайнинга.
Здесь мы уже пытаемся перестроить код таким образом, чтобы вызов функции оказался заменен на ее тело. Вся работа происходит в TryInlineCall(). Почему try? Внутри метода происходит дополнительная валидация возможности встраивания. Это приходится делать, потому что структура, которая используется компилятором (по сути то самое синтаксическое дерево), подвержена изменениям. Данные изменения могут нарушить прежнюю более тяжеловесную проверку на встраивание.
Стоит заметить, что прямо перед попыткой встроить вызов мы видим попытку его девиртуализировать, если это вызов метода. Почему он находится именно тут? Потому что при такой логике девиртуализация может помочь встроить вызов на втором этапе инлайнинга.
На самом деле, главная задача девиртуализации довольно проста — она состоит в приведении косвенного вызова метода к прямому вызову.
То есть при вызове метода интерфейса, рантайм вместо того, чтобы обращаться к таблице интерфейсов, обращается напрямую к типу, который реализует данный интерфейс. И вызывает в этот момент метод без использования сторонних структур.
Код перестраивается таким образом:
То есть, имея понятие о том, что за тип реализует интерфейс, компилятор может упростить работу кода. Благодаря чему не нужно сверяться с таблицами интерфейса. Как это происходит на деле?
Внутри функции, реализующей оптимизацию, одним из первых условий проверяется то, что рассматриваемый вызов — это вызов метода через переменную типа интерфейса. Следовательно девиртуализируются только такие вызовы. В остальных случаях тип вызываемого метода известен.
Далее компилятор возьмет данные текущего интерфейса и пойдет вверх по телу функции, где выполняется данный вызов. В теле функции выполняется поиск места, в котором происходит преобразование какого-то типа к данному интерфейсу. Тут есть нюанс — не всегда возможно однозначно определить место преобразования, а значит не всегда компилятор может понять, какой тип в данном месте кода реализует интерфейс. И в этом случае оптимизация не применяется.
Однако, если тип определен, то вызов использует обратное приведение типа. Таким образом вместо косвенного вызова начинает использоваться прямой за счет непосредственной работой со структурой кода.
В этом месте и кроется ответ, почему мы увидели, что в коде девиртуализация и встраивание сплетены. Девиртуализация помогает перед вторым прогоном встраивания разрешить нерешаемую главной (не побоюсь этого слова) оптимизацией компилятора задачу: понять, что именно вызывает данный метод, ведь интерфейсы для встраивания — это слепая зона.
Однако у девиртуализации есть явные ограничения:
Во-первых, она не будет работать с вызовами, которые являются частью объявления go-keyword или defer. Объясняется это тем, что при возникновении паники, она перемещается не в целевую функцию, а в блок go/defer, что явно влияет на результаты вычислений в этих конструкциях;
Также, если компилятор находит место преобразования типа, но при этом он видит, что преобразуется интерфейс в интерфейс, то и здесь девиртуализация не будет выполнена. Это происходит потому, что на данном этапе компилятор не обладает полной информацией об интерфейсах. Его ограничивает синтаксическое дерево. Поэтому, чтобы не копаться в нем дальше и не тратить время, компилятор принимает решение действовать быстро.
Данная оптимизация довольно небольшая и умещается в файле объемом в 141 строку. Но для того же встраивания она довольно важная, так как позволяет расширить ее горизонты.
Наглядный пример на тестовой задаче
Думаю, что всё описанное выше, не имело бы смысла, если это не показать на реальной задаче. Я не большой мастер придумывать примеры, но постарался подобрать такой, который бы мог помочь вживую показать как компилятор использует оптимизации.
Представим, что мы работаем в международной компании, владеющей множеством приютов с кошечками и собачками. Соответственно, мы постоянно мониторим кого из своих подопечных и куда отдали. Сразу оговорюсь — пример синтетический и, возможно, забавный, но он поможет наглядно показать, как работает то, что я уже описал.
Компания хочет больше стимулировать людей на то, чтобы забирать кошечек и собачек к себе домой. Для этого решено провести конкурс, в котором победителем объявляется то домохозяйство, где наших подопечных больше всего. Ищем такое в каждой стране. В целом, работа кода состоит из следующих этапов:
Находим дома, в которых больше всего наших подопечных и награждаем жителей таких домов;
Выбираем дом, где больше всего кошек, собак, и животных в целом;
А затем возвращаем аналитику по странам, сортируя дома внутри районов, районы внутри городов и города внутри страны по общему количеству животных.
Код получился громоздкий, поэтому решил показать только его схему и некоторые его части. По факту я создал сервис с одной ручкой, в которой витиевато разбил этапы на множество функций, чтобы наглядно показать возможности оптимизаций. Также я создал небольшой скрипт, генерирующий данные и отправляющий их в ручку сервиса.
Вот так выглядит структура, которой описываются страны, города, их районы и дома.
В хендлере находится функция, использующая в качестве входного параметра интерфейс. Это необходимо для демонстрации работы девиртуализации.
При компиляции получаем вот такие логи:
Видно, что зеленого достаточно много. Некоторые функции компилятор посчитал по бюджету, как встраиваемые. Он даже написал нам их стоимости. Как и стоимости тех функций, что не встроились.
Обратите внимание на третью строку. Компилятор девируализировал вызов интерфейсного метода. В данном случае он смог однозначно определить необходимый тип. Если бы я выше по цепочке вызовов подставил какой-нибудь switch-case, который присваивал переменной этого типа интерфейса то одну, то другую структуру, тогда девиртуализация бы не произошла.
На схеме зеленым обозначены те функции, что встроились при текущем поведении компилятора.
Что же, мы посмотрели, как работают оптимизации компилятора в стандартном сценарии. Но как они преобразуются благодаря PGO? Чтобы ответить на этот вопрос, предлагаю вновь рассмотреть девиртуализацию, а уже потом перейти к инлайнингу.
Оптимизации с PGO
Разберем главную функцию, где реализованы все оптимизации.
Здесь мы оперируем конкретным значением результатов профилирования в переменной profile. Она содержит данные, которые используют и девиртуализация, и инлайнинг. При этом девиртуализация при использовании PGO выполняется непосредственно перед первым этапом встраивания. Это позволяет точнее предсказать, что будет встроено, а что нет. Но какими именно данными оперируют оптимизации с PGO?
Главными здесь являются две мапы: в них записываются списки самых вызываемых (горячих) вызовов функций и методов, а также связки горячих вызывающих-вызываемых функций. Обращаясь к этим мапам, оптимизации могут сопоставить, является рассматриваемый ими вызов горячим или нет, а значит понять, применять к нему улучшение оптимизаций.
Две переменные описывают процент горячих вызовов, который забирается из файла профилирования. Первая переменная отвечает больше за информацию дебага после операции считывания данных, а вторая выставляет порог, по умолчанию 99%, после которого данные из файла профилирования перестают парситься.
Как же девиртуализация работает с этими данными?
Стоит сказать, что чисто технически у компилятора Golang две девиртуализации:
Статическая — она используется без PGO и с ней мы уже познакомились. Ее главным ограничением является то, что она не всегда может решить, какой тип на самом деле можно подставить в конкретном месте вызова метода;
Девиртуализация с PGO. Она расширяет функциональность стандартной девиртуализации, но пока не заменяет ее, а вызывается отдельно.
Почему расширяет? Потому что она работает также с переданными в функцию замыканиями, позволяя в место вызова вставить тело замыкания. Это почти что встраивание, но не полностью. Девиртуализация не вставляет конкретный тип. Оперируя данными из файла профилирования, она заменяет вызов на конструкцию if/else. В первом ответвлении проставляет самый горячий вызов, если он найден в мапе горячих вызовов, в ветке else оставляет вызов, которому суждено пройти через таблицу интерфейсов. Если рантайм понимает, что сейчас можно сделать вызов конкретного типа, он так и делает.
Вот пример из документации:
Как видим, PGO девиртуализация строится на прогнозах, а не на конкретных изменениях вызовов, как это обстояло со стандартной девиртуализацией
А что происходит с инлайнингом после того, как компилятор разбирает файл с результатами профилирования?
Выше я немного слукавил и описал не все параметры, помогающие PGO улучшать оптимизации компилятора. На самом деле, помимо двух мап и двух переменных у нас появляется еще одна переменная, равная 2000 единиц.
Эти 2000 единиц заменяют бюджет в 80 единиц у тех функций, которые по мнению профилировщика являются горячими вызовами. Теперь им дается возможность на встраивание за счет увеличения бюджета.
Вот так это выглядит в коде.
Мы ищем определение данного вызова в профиле, а затем проверяем, существует ли вызов в мапе горячих вызовов. Если есть, то переобозначаем бюджет. И на этом все. Вот так улучшается работа инлайнинга. В отличие от девиртуализации, встраивание перестраивает свое поведение, а не заменяется или дублируется другой логикой.
Еще раз о животных
Теперь покажу как это работает в моей задаче про животных. Еще раз продублирую структуру сервиса, чтобы напомнить о тех функциях, которые компилятор встроил без использования PGO.
Далее я использовал тот алгоритм, о котором нам пишут в статьях, и о котором теперь пишу сам: запуск бинарного кода, его нагрузка, профилирование через pprof, получение файла с данными профилирования, перекомпиляция. Что же мы получим?
Оно работает! И обратите внимание не только на то, что зеленого стало намного больше, но и на цифры бюджета, с которыми встроились функции. Они по большей части сидят в циклах for, поэтому и оказались самыми часто используемыми.
То есть получилось встроить не только листовые вызовы, но и их родителей. А что по перформансу?
При погрешности в 1% я получил по факту прирост скорости на 4,5%. Что согласуется с обещанием разработчиков: они заявляют, что PGO будет ускорять код на 2-14%. И пример показал, что ускорение действительно достигается.
Подведем итоги
Мы рассмотрели то, как работают оптимизации компилятора, и как они преобразуются с применением PGO. Теперь есть четкое понимание, что действительно помогает нам, как разработчикам, достигнуть этот инструмент.
1. PGO помогает принимать решения об оптимизации под конкретное приложение и нагрузку. И вам нет необходимости беспокоится о том, как это работает. В основе процесса имеется определенная логика, с которую я описал в статье. Теперь, понимая ее, мы можем с уверенностью сказать, что если у нас будет валидный собранный профиль, то каждое приложение будет иметь свою собственную стратегию по применению оптимизаций. Если же ваша нагрузка имеет непредсказуемый характер, то можно пойти более сложным путем: снять в разные моменты времени нагрузку нескольких профилей и смерджить их. Так тоже работает.
2. PGO расширяет стандартные возможности оптимизаций. По итогу возможно получить прирост в производительности до 15%. На эту цифру будет влиять качество написания кода и качество собранного профиля.
3. По сути компилятор работает в одиночку. Разработчику остается настроить пайплайн, написать код, а дальше компилятор, профилировщик и девопс все сделают сами. Программисту нет необходимости задумываться о том, как это работает. Машина сама поймет, что где подкрутить. Конечно, мы можем писать такой код, который будет оптимизирован с точки зрения компилятора, но с точки зрения его поддержки это окажется довольно неподходящим решением. Поэтому в данном случае компилятор всецело на нашей стороне.
4. Понимая работу основной оптимизации — инлайнинга — мы можем скорректировать код, который пишем. Встраивание наиболее эффективно для небольших и простых функций, поскольку они выполняют относительно небольшую работу по сравнению с их накладными расходами. Для больших функций встраивание дает меньше преимуществ, поскольку накладные расходы на вызов функции невелики по сравнению со временем, затрачиваемым на выполнение работы. Поэтому оптимизация через профилирование учит нас писать более мелкие функции и методы, что отражается на читаемости и логическом разбиении кода. Также стоит помнить, что будут встраиваться именно наиболее используемые методы и функции
Это все хорошо, но, прямо как в том самом анекдоте, есть нюанс. Вернее даже пять.
Во-первых, PGO увеличивает нагрузку на компилятор. Его работа занимает дополнительные ресурсы. Так как PGO расширяет возможности экспорта данных из пакета за счет записи информации о встроенных функциях, то у нас тратиться больше памяти для запоминания этих данных. Увеличение количества встроенных компонентов увеличивает время компиляции. К тому же, я не упомянул, что бюджет встраивания с помощью PGO можно изменить через флаг дебага PGOInlineBudget (пока что). С одной стороны это позволяет настраивать под себя работу оптимизаций. С другой — агрессивное увеличение значения флага еще больше замедлит работу компилятора, которому придется проходиться по большему количеству строк в функциях. А также встраивать вызовы довольно массивных методов. Как итог, все это приводит к увеличению размера бинарника и странным результатам инлайнинга. Но здесь все просто: не трогайте флаги и помните, что любой инструмент несет издержки. Зато на проде код будет шустрее.
Во-вторых, как мы с вами убедились, логика работы с оптимизациями довольно прямолинейная и простая, что не позволяет оптимизировать весь код. Встраивание могло бы быть более сложным: например, идти не просто рекурсивно по телу функции сверху вниз, встраивая вызовы подряд, а производить встраивание, начиная с самого дешевого вызова и доходя до самого дорогого. Это кажется более оптимальным решением. Но как и сам язык Golang, так и PGO в внутри него продолжают развиваться.
В-третьих, работа с PGO требует обязательного сбора нагрузки с рабочего окружения. Думаю, вы это поняли, когда я описывал как именно PGO преобразует оптимизации. Искусственная нагрузка может дать нам совершенно невалидный граф частых вызовов, что даже замедлит работу приложения. Поэтому тут только стоит следовать рекомендациям разработчиков из официального блога.
В-четвертых, PGO — это увеличение действий при деплое приложений. Нам необходимо сначала собрать сервис, отправить его на некоторое время в ЦОД под рабочую нагрузку, а затем пересобрать его и задеплоить вновь. Но опять же — любой инструмент несет издержки.
В-пятых, PGO — это набор эвристик. Протестированных, обоснованных, но эвристик. И не все они гарантируют наличие одного и того же своего состояния от версии к версии. Это и хорошо, и не очень хорошо. Главное, чтобы PGO ускорял код, а не замедлял его работу.
На этом хочется закончить и без того объемную статью. Как обычно, в тему можно погрузиться еще сильнее, но кажется, что это повод писать и мне, и вам следующие разборы внутренней работы компилятора. Я все же считаю, что понимая то, как и почему происходят определенные вещи в рабочем инструменте, можно улучшать и свои собственные результаты. Что ж, до новых встреч!