[Перевод] Как JIT инлайнит наш C# код (эвристики)
Инлайнинг — одна из самых важных оптимизаций в компиляторах. Она не только убирает оверхед от вызова, но и открывает много возможностей для других оптимизаций, например, constant folding, dead code elimination и т.д. Более того, иногда инлайнинг приводит к уменьшению размера вызывающей ф-ции! Я опросил несколько человек на предмет знают ли они по каким правилам инлайнятся ф-ции в C# и большинство ответили, что JIT смотрит на размер IL кода и инлайнит только маленькие ф-ции размером, скажем, до 32 байт. Поэтому я решил написать этот пост, чтобы раскрыть детали реализации при помощи вот такого примера, который покажет сразу несколько эвристик в деле:
Как вы думаете, заинлайнится ли вызов конструктора Volume тут? Очевидно, что нет. Он слишком большой, особенно из-за тяжеловесных throw new операторов, которые приводят к довольно жирному кодгену. Давайте проверим в Disasmo:
Заинлайнился! Более того, все выбросы исключений и их ветки успешно удалились! Вы можете сказать что-то в стиле «А, окей, джит очень умен и проделал полный анализ всех кандидатов к инлайну, посмотрел что будет если передать конкретные аргументы» или «Джит пробует заинлайнить всё что можно, выполняет все оптимизации, а потом решает профитно это или нет» (возьмите в руки комбинаторику и посчитайте сложность этой операции, например, для графа вызовов из десятка-двух методов).
Ну… нет, это нереалистично, особенно в терминах just in time. Поэтому, большинство компиляторов используют так называемые наблюдения и эвристики для решения это классической задачи о рюкзаке и пытаются сами определить себе бюджет и в него максимально эффективно вписаться (и нет, PGO не панацея). RyuJIT имеет положительные и отрицательные наблюдения. Положительные увеличивают коэффициент выгоды (benefit multiplier). Чем больше коэффициент — тем больше кода мы можем заинлайнить. Отрицательные наблюдения наоборот — понижают его или вообще могут запретить инлайнинг. Давайте посмотрим какие наблюдения сделал RyuJIT для нашего примера:
Эти наблюдения можно увидеть в логах из COMPlus_JitDump (например, в Disasmo):
Все эти простые наблюдения повысили коэффициент с 1.0 до 11.5 и помогли успешно побороть бюджет инлайнера, например, тот факт, что мы передаем аргумент-константу и она сравнивается с другой константой говорит нам, что с большой долей вероятности после схлопывания констант удалится одна из веток условия и код станет меньше. Или, например, то, что это конструктор и он вызывается внутри цикла — это тоже намек джиту, что он должен смягчить требования к инлайнингу.
Помимо benefit multiplier, RyuJIT так же использует наблюдения для прогноза размера нативного кода ф-ции и ее performance impact используя магические константы в EstimateCodeSize () и EstimatePerformanceImpact () полученные при помощи ML.
Кстати, а вы заметили этот трюк?:
if ((value - 'A') > ('Z' - 'A'))
Это оптимизировання версия к:
if (value < 'A' || value > 'Z')
Оба выражения являются одним и тем же, но в первом случае у нас один базовый блок, а во втором их целых три. Оказывается, в инлайнере есть строгий лимит на кол-во базовых блоков в ф-ции и если оно превышает 5 то не важно какой большой у нас benefit multiplier — инлайнинг отменяется. Поэтому я применил эту уловку, чтобы вписаться в это строгое требование. Было бы классно если бы Roslyn делал это за меня.
Issue в Roslyn: github.com/dotnet/runtime/issues/13347
PR в RyuJIT (моя неловкая попытка): github.com/dotnet/coreclr/pull/27480
Там же я описал пример почему это имеет смысл сделать не только в Jit, но и в компиляторе C#.
Инлайнинг и виртуальные методы
Тут всё понятно, нельзя заинлайнить то, о чем нет информации на этапе компиляции, хотя если тип или метод sealed то почему бы и нет.
Инлайнинг и выброс исключений
Если метод никогда не возвращает значение (например, просто делает throw new
…) то такие методы автоматически помечаются как throw-helpers и не инлайнятся. Это такой способ замести сложный кодген от throw new
под ковер и ублажить инлайнер.
Инлайнинг и [AggressiveInlining] атрибут
В этом случае вы рекомендуете инлайнеру заинлайнить метод, но тут надо быть предельно осторожным по двум причинам:
- Возможно, вы оптимизируете один случай и ухудшаете все остальные (например, улучшаете случай константных аргументов) по размеру кодгена.
- Инлайнинг частенько генерирует большое количество временные переменных, которые могут перешагнуть определенный лимит — количество переменных, жизненный цикл которых RyuJIT может отследить (512) и после него код начнет обрастать жуткими спиллами в стек и сильно замедляться. Два хороших примера: тыц и тыц.
Инлайнинг и динамические методы
В данный момент такие методы ничего не инлайнят и сами не инлайнятся: github.com/dotnet/runtime/issues/34500
Моя попытка написать свою эвристику
Недавно я попытался написать собственную эвристику чтобы помочь вот такому случаю:
В своем прошлом посте я упоминал что совсем недавно я оптимизировал в RyuJIT вычисление длины от константных строк ("Hello".Length -> 5
), так вот, в примере выше ^ мы видим что если заинлайнить Validate
в Test
, то мы получим if ("hello".Length > 10)
что оптимизируется в if (5 > 10)
что оптимизируется в удаление всего условия/ветки. Однако, инлайнер отказался инлайнить Validate
:
И главная проблема тут в том, что пока нет эвристики, которая подскажет джиту, что мы передаем константную строку в System.String::get_Length
, а значит что callvirt-вызов скорее всего свернется в константу и вся ветка удалится. Собственно, моя эвристика и добавляет это наблюдение (единственный минус — приходится резолвить все callvirt’ы что является не очень быстрым).
Существуют и другие ограничения, со списком которых можно в целом ознакомиться вот тут. А тут можно прочитать мысли одного из главных разработчиков JIT о дизайне инлайнера и его статью на тему использования Machine Learning для этого дела.