Эксперимент, который многое объясняет
Оригинал этого поста также вошёл в число документов по проектированию платформы .NET: lsra-heuristic-tuning.
Контекст
В динамическом компиляторе RyuJIT реализован алгоритм линейной схемы распределения регистров (LSRA), в соответствии с которым регистры процессора присваиваются сгенерированному коду. При выборе регистров алгоритм LSRA применяет разнообразные эвристики (если быть точным, их 17), выбирая из всех доступных регистров тот, который лучше всего подходит в настоящий момент. Все регистры можно разделить на две категории. Либо регистр не содержит значения переменной, поэтому может быть выделен для очередной переменной, либо, он уже содержит значение переменной и, следовательно, он занят. Если в процессе присваивания регистров выбран тот, который занят, то значение, которое в нём сейчас содержится, приходится предварительно сохранить в памяти, а потом уже присвоить туда, куда нужно. Такая операция называется «выгрузкой переменной». В алгоритме LSRA компилятора RyuJIT есть (14 видов) эвристики, в соответствии с которыми сначала выбирается один из свободных регистров. Если свободных регистров не окажется, то есть ещё 4 эвристики, согласно которым выбирается один из занятых регистров. Занятый регистр выбирается в зависимости от того, содержимое какого регистра будет дешевле выгрузить.
Мы заметили, что отдавать приоритет при выборке именно свободным регистрам не всегда полезно. Иногда лучше выбрать занятый регистр, а свободные сберечь на будущее для тех опорных точек, которые попадутся на «горячем» пути выполнения кода.
Рассмотрим сгенерированный код, взятый из dotnet/runtime Issue#8846. В данном примере свободные регистры отводятся тем переменным, которые находятся вне цикла for. В ходе присваивания регистров переменным, находящимся внутри цикла, свободных регистров не достаётся, поэтому алгоритму приходится выгружать регистр, занятый в настоящий момент, чтобы сохранить значение переменной, находящейся внутри цикла. Поразительно, но оказалось, что алгоритм выбирает один и тот же регистр для всех переменных, используемых внутри цикла, и раз за разом выгружает значения переменных, находившихся в регистрах ранее. Складывается впечатление, что это происходит именно из-за того, в каком порядке применяются разные эвристики для выбора регистров. Может быть, лучше придерживаться не фиксированного порядка следования эвристик, а откорректировать этот порядок так, чтобы иногда сначала выбирались занятые регистры, а потом шёл выбор из пула доступных свободных регистров. Именно так зародилась идея настроить эвристику выбора регистров именно так, как было описано в dotnet/runtime Issue# 43318. Требовалось поставить эксперимент и проверить, можно ли улучшить порядок выбора регистров, опираясь на иные критерии. В этой статье подробно рассказано, почему для этой цели было решено использовать генетический алгоритм, и чему удалось научиться в ходе этих экспериментов.
Эвристика выбора регистров
Ниже перечислены эвристики выбора регистров, реализованные в компиляторе RyuJIT:
|
Сокращённое название |
Имя |
Описание |
|
A |
FREE |
В настоящее время не присвоен ни одному активному интервалу. |
|
B |
CONST_AVAILABLE |
Константное значение, которое уже присутствует в регистре. |
|
C |
THIS_ASSIGNED |
Регистр уже присвоен интервалу, актуальному в настоящий момент. |
|
D |
COVERS |
Покрывает актуальное время жизни интервала. |
|
E |
OWN_PREFERENCE |
Набор регистров, предпочтительных для данного интервала. |
|
F |
COVERS_RELATED |
Набор регистров, предпочтительных для интервала, который касается актуального интервала и покрывает его время жизни. |
|
G |
RELATED_PREFERENCE |
Набор регистров, предпочтительных для интервала, который касается актуального интервала. |
|
H |
CALLER_CALLEE |
Набор регистров, сохранённых вызывающей или вызываемой стороной |
|
I |
UNASSIGNED |
В настоящее время не присвоен ни одному активному или неактивному интервалу. |
|
J |
COVERS_FULL |
Покрывает время жизни интервала от данного момента и до конца. |
|
K |
BEST_FIT |
Доступный диапазон ближе всего совпадает с полным диапазоном интервала |
|
L |
IS_PREV_REG |
Регистр ранее был присвоен актуальному интервалу |
|
M |
REG_ORDER |
Прерыватель. Просто выбирает первый доступный «свободный» регистр |
|
N |
SPILL_COST |
Из всех кандидатов стоимость выгрузки для этого будет самой низкой |
|
O |
FAR_NEXT_REF |
Следующая ссылка на него расположена дальше, чем ссылка на наилучшего кандидата, найденного к текущему моменту. |
|
P |
PREV_REG_OPT |
Предыдущая ссылка на присвоенный в данный момент интервал была опциональной. |
|
Q |
REG_NUM |
Прерыватель. Просто выбирает первый доступный «занятый» регистр. |
Эвристики от A до M предназначены для выбора одного из свободных регистров, а эвристики от N до Q — для выбора одного из занятых. Ниже на простом примере показано, как эвристика выбора работала ранее. Начинаем со свободных (доступных) кандидатов и, с применением каждой эвристики, сужаем круг этих кандидатов. Всякий раз, когда видим, что у нас есть выбор (нам подходит более одного регистра), продолжаем пробовать эвристики (в вышеуказанном порядке) до тех пор, пока у нас не останется всего одного регистра. Если ни одного подходящего регистра найти не удаётся, то с применением эвристики N продолжаем поиск, пока не найдём среди занятых регистров такой, содержимое которого можно выгрузить в стек.
registerCandidates = 0; // битовая маска для всех регистровLinearScan::allocateReg(RefPostion refPosition, Inteval* interval){ bool found = false; registerCandidates = allFreeCandidates(); if (!found) { found = applyHeuristics(FREE, FREE_Candidates()); } if (!found) { found = applyHeuristics(CONST_AVAILABLE_Candidates()); } ... if (!found) { found = applyHeuristics(REG_ORDER_Candidates()); } // Свободных регистров в доступе не оказалось, поэтому попытаемся выбрать // один из занятых регистров registerCandidates = allBusyCandidates(); if (!found) { found = applyHeuristics(SPILL_COST_Candidates()); } if (!found) { found = applyHeuristics(FAR_NEXT_REF_Candidates()); } ...}// Фильтрует регистры-кандидаты и возвращает true только после того, как// останется всего один кандидат.bool applyHeuristics(selected_candidates){ filtered_candidates = registerCandidates & selected_candidates; if (filtered_candidates != 0) { registerCandidates = filtered_candidates; return isSingleRegister(registerCandidates); } return false;}
Если бы мы хотели изменить порядок следования эвристик, то должны были бы обновить вышеприведённый код, так, чтобы переупорядочить применяемые эвристики. Если требуется экспериментировать с разными вариантами порядка эвристик, то выполнять такой рефакторинг для проверки каждой комбинации не представляется возможным. Немного исследовав проблему, подбирая, какой паттерн проектирования подходит для решения такой задачи, мы решили действовать дедовским методом и вынесли код каждой отдельно взятой эвристики в её собственный метод (помечаемый как __forceinline, чтобы пропускная способность никак не сказывалась на изменениях, вносимых при рефакторинге). Для вызова любого из этих методов в таком порядке, как мы желаем, можно было бы воспользоваться указателем функции. Наконец, оставалось добавить механизм, который позволял бы пользователю самостоятельно задавать, в каком порядке он хочет попробовать эти методы. Каждой эвристике мы присвоили ровно одну букву(столбец Сокращённое название в таблице выше) и предоставили переменную окружения COMPlus_JitLsraOrdering в качестве средства для указания порядка. По умолчанию используется порядок «ABCDEFGHIJKLMNOPQ» (действует сейчас), но, если задать какой-то другой порядок, допустим, «PEHDCGAIJNLKOBFMQ«, то эвристики будут следовать именно так. Например, P соответствует вариант PREV_REG_OPT, при котором сначала эвристически проверяются занятые регистры, затем OWN_PREFERENCE, CALLER_CALLEE и так далее. Как видите, теперь можно сначала применять эвристику проверки занятых регистров, а лишь затем переходить к эвристикам для свободных регистров.
Собрав всё это вместе, получаем после рефакторинга следующий код:
typedef void (RegisterSelection::*HeuristicFn)();HashTable<char, HeuristicFn> ScoreMappingTable = { {'A', try_FREE}, {'B', try_CONST_AVAILABLE}, ... {'Q', try_REG_NUM}};LinearScan::allocateReg(RefPostion refPosition, Inteval* interval){ char *ordering = Read_COMPlus_LsraOrdering(); HeuristicFn fn; for (char order in ordering) { if (ScoreMappingTable->Lookup(order, &fn)) { bool found = (this->*fn)(); if (found) { break; } } }}bool LinearScan::try_FREE() { ... return applyHeuristics();}...bool LinearScan::try_CONST_AVAILABLE() { ... return applyHeuristics();}...bool LinearScan::try_REG_NUM() { ... return applyHeuristics();}
В dotnet/runtime #52832 содержатся все вышеописанные изменения, вступившие в силу после рефакторинга.
Измерение влияния
Теперь, реализовав возможность переупорядочивания эвристик при помощи COMPlus_JitLsraOrdering, мы решили измерить, как эти перемены отражаются на выполнении инструмента superpmi. Инструмент superpmi динамически компилирует все методы заданного файла сборки (*.dll или *.exe), не выполняя сгенерированного при этом машинного кода. Имея две версии clrjit.dll (двоичный файл RyuJIT), также можно сравнить сгенерированный код и отчитаться о том, сколько методов улучшилось/ухудшилось по показателям CodeSize (размер машинного кода), PerfScore (задержка при выполнении инструкций/измерение пропускной способности), InstructionCount (количество инструкций в наличии) и т.д. Мы выбрали метрики PerfScore, поскольку они точно характеризуют, во сколько обходится выгрузка регистров. Если LSRA не удастся оптимально подобрать регистры, то мы увидим несколько инструкций mov, загружающих значения в память/сохраняющих их, и именно из-за этого как снизится пропускная способность, так и возрастёт задержка. Соответственно, PerfScore станет ниже. Если выгрузка регистров произойдёт внутри цикла, то метрики PerfScore учтут и это. Для оценки этого показателя потребуется проверить произведение весов блочных циклов и PerfScore. Следовательно, наша цель — уменьшить PerfScore, насколько это возможно. Чем ниже будет PerfScore — тем лучше код, который мы сгенерировали. В качестве базового уровня для сравнения мы взяли вариант упорядочивания, действующий по умолчанию, после чего решили сравнить его с вариантом, указанным в COMPlus_JitLsraOrdering. Можно было задать любую комбинацию последовательностей от A до Q и откорректировать алгоритм LSRA так, чтобы порядок следования эвристик в ней изменился. Но, поскольку эвристик 17, будет 355 687 428 096 000 (факториал 17) возможностей, и перепробовать их все практически невозможно. Нам было просто необходимо найти, как решать эту задачу эффективнее!
Генетический алгоритм
Для решения задач такого рода отлично подходят генетические алгоритмы. Если вы не знаете, что это такое, изложим вкратце их суть. Алгоритм начинается с популяции, в которой есть несколько кандидатов, и у каждого кандидата есть заранее определённый показатель приспособленности. Каждый кандидат содержит последовательность генов, причём, количество генов у любого из кандидатов одинаковое. Алгоритм выбирает пару подходящих кандидатов (родители) и получает от них потомство, причём, в ходе такого размножения геномы родителей претерпевают мутации. Алгоритм вычисляет показатель приспособленности полученных потомков и добавляет их в пул популяции (вместе с их показателями приспособленности). По мере развития популяции в ней становится всё больше кандидатов, чей показатель приспособленности равен исходному показателю приспособленности всей популяции или лучше него. Разумеется, популяция бесконечно расти не может, поэтому наименее приспособленные кандидаты вымирают. Как только на определённом этапе в популяции не остаётся кандидатов с иным показателем приспособленности кроме наивысшего, алгоритм останавливается и выдаёт нам набор наилучших кандидатов.
Такой алгоритм отлично описывает задачу, в которой нужно выбрать наилучший порядок следования эвристик выбора. Мы начинаем с «ABCDEFGHIJKLMNOPQ» (порядок следования эвристик, действующий по умолчанию), и каждую букву в этой комбинации можно трактовать как ген. Генетический алгоритм привносит в геном мутации, в результате которых порядок следования меняется, скажем, на «ABMCDEFGHIKLNJOPQ«. Это значение мы запишем в переменную COMPlus_JitLsraOrdering. Затем выполним superpmi.py, чтобы сгенерировать код, и сравним его значение PerfScore с тем, которое получается при применении порядка, действующего по умолчанию. Здесь PerfScore характеризует приспособленность. Чем ниже показатель этой метрики, тем лучше приспособлен кандидат, что в нашем случае означает: «тем лучше порядок следования эвристик».
Ниже приведён псевдокод того генетического алгоритма, с которым мы экспериментировали, подыскивая оптимальный порядок следования эвристик.
// Максимальная численность популяции на поколениеint MaxPopulation = 100;HashMap<string, float> Community = new HashMap<string, float>();HashMap<string, float> NextGen = new HashMap<string, float>();void GeneticAlgorithm() { PopulateCommunity(); do { // новое поколение NextGen = new HashMap<string, float>(); candidateCount = 0; while(candidateCount++ < MaxPopulation) { // Турнирным методом выбираем // 2 кандидатов из множества "Популяция". // https://en.wikipedia.org/wiki/Tournament_selection (parent1, parent2) = DoSelection(); // Гены parent1 и parent2 мутируют, и в результате получаются // 2 потомка (offspring0, offspring1) = MutateGenes(parent1, parent2) // Добавляем потомков в популяцию AddNewOffspring(offspring0) AddNewOffspring(offspring1) } Community = NextGen; // Продолжаем процесс в виде цикла, пока в популяции не останутся // лишь уникальные кандидаты } while (uniqueCandidates);}// Заполняем популяцию случайными кандидатамиvoid PopulateCommunity() { candidateCount = 0; while(candidateCount < MaxPopulation) { newCandidate = GetRandomCombination("ABCDEFGHIJKLMNOPQ") AddNewOffspring(newCandidate) }}// Запускаем инструмент superpmi, и с его помощью считываем показатели PerfScorevoid ComputeFitness(candidate) { perfScore = exec("superpmi.py asmdiffs -base_jit_path default\clrjit.dll -diff_jit_path other\clrjit.dll -diff_jit_option JitLsraOrdering=" + candidate) return perfScore}// Вычисляем показатели приспособленности для обоих потомков и// добавляем их в сообществоvoid AddNewOffspring(candidate) { Community[candidate] = ComputeFitness(candidate) // выкидываем менее приспособленных кандидатов if (Community.Count > MaxPopulation) { weakCandidate = CandidateWithHighestPerfScore(Community); Community.Remove(weakCandidate) }}// Выполняем операции, соответствующие скрещиванию и мутацииvoid MutateGenes(offspring0, offspring1) { assert(offspring0.Length == offspring1.Length) // скрещивание crossOverPoint = random(0, offspring0.Length) i = 0 while (i++ < crossOverPoint) { char c = offspring0[i] offspring0[i] = offspring1[i] offspring1[i] = c } // мутация randomIndex = random(0, offspring0.Length) char c = offspring0[randomIndex] offspring0[randomIndex] = offspring1[randomIndex] offspring1[randomIndex] = c return offspring0, offspring1}
Имея готовый генетический алгоритм, можно в ходе нескольких экспериментов найти оптимальный порядок следования эвристик.
Эксперименты
При помощи superpmi можно динамически компилировать все методы, присутствующие в библиотеках .NET и Microbenchmarks. Нам требовалось провести этот эксперимент для всех поддерживаемых нами операционных систем и архитектур — Windows/x64, Windows/arm64, Linux/x64, Linux/arm и Linux/arm64.
Конфигурация
Для проведения экспериментов мы несколько изменили, как superpmi собирает PerfScore и выдаёт отчётность по этим показателям.
-
superpmi.exeбыла изменена так, чтобы агрегировать относительную разницуPerfScoreв коде, генерируемом по умолчанию и в коде, в котором изменён порядок следования эвристик при LSRA. Когдаsuperpmi.exeвыполняется параллельно (так устроено по умолчанию), это значение отдельно выдаёт в консоль каждый из параллельно работающих процессов. -
superpmi.py была изменена так, чтобы далее агрегировать относительные отличия
PerfScoreу параллельных процессовsuperpmi.exe, после чего выдаёт в качестве отчёта окончательную относительную разницуPerfScore. -
Алгоритм LSRA расставляет по всей базе кода множество утверждений. В соответствии с ними, при выборе регистров сначала пробуем найти свободные регистры, а если таковых нет — то переходим к поиску среди занятых. Поскольку мы также хотели понять, как сказывается на работе программы ситуация, в которой предпочитаются, в первую очередь, занятые регистры, эти утверждения предварительно требовалось отключить.
-
superpmi.exe asmdiffsпринимает две версииclrjit.dll, которые вы хотите сравнить. Они должны происходить из разных мест. В нашем случае хотелось поэкспериментировать лишь с разным порядком эвристик, передавая разные значенияCOMPlus_JitLsraOrdering. Для этого мы скопировалиclrjit.dll -> copy_clrjit.dllи передавали различные варианты порядка скопированномуcopy_clrjit.dll.
Вот пример вызова superpmi.py, при помощи которой генетический алгоритм получает PerfScore (балл приспособленности) для каждого экспериментального варианта порядка:
python superpmi.py asmdiffs -f benchmarks -base_jit_path clrjit.dll -diff_jit_path copy_clrjit.dll -target_os windows -target_arch x64 -error_limit 10 -diff_jit_option JitLsraOrdering=APCDEGHNIOFJKLBMQ -log_file benchmarks_APCDEGHNIOFJKLBMQ.log
Результат
Ниже показаны порядки следования эвристик, которые алгоритм выдал для различных конфигураций (сценарий/операционная система/архитектура). В столбце PerfScore представлено агрегированное значение относительной разницы PerfScore для всех методов. Мы предпочли работать с относительными значениями разницы, а не с абсолютными, так как не хотели, чтобы показатели доминирующего метода маскировали показатели других, более мелких.
|
Конфигурация |
Порядок |
PerfScore |
|
windows-x64 Benchmarks |
EHPDGAJCBNKOLFIMQ |
-36.540712 |
|
windows-x64 Libraries |
PEHDCGAIJNLKOBFMQ |
-271.749901 |
|
windows-x86 Benchmarks |
EHDCFPGJBIALNOKMQ |
-73.004577 |
|
windows-x86 Libraries |
APCDEGHNIOFJKLBMQ |
-168.335079 |
|
Linux-x64 Benchmarks |
HGIDJNLCPOBKAEFMQ |
-96.966704 |
|
Linux-x64 Libraries |
HDGAECNIPLBOFKJMQ |
-391.835935 |
|
Linux-arm64 Libraries |
HECDBFGIANLOKJMPQ |
-249.900161 |
Как понятно из этой таблицы, есть значительно более выигрышные варианты упорядочивания, чем используемый по умолчанию «ABCDEFGHIJKLMNOPQ«. Алгоритм может обеспечить значительно более качественный подбор регистров и, следовательно, повысить производительность. Но также наблюдаем, что предложенные генетическим алгоритмом варианты упорядочивания отличаются от конфигурации к конфигурации. Мы хотели найти общий схожий вариант, который был бы полезен во всех сценариях сразу на многих платформах. На последнем этапе эксперимента мы решили опробовать все наилучшие варианты, полученные для разных конфигураций и сравнить их производительность друг с другом. Например, порядок «EHPDGAJCBNKOLFIMQ» является наилучшим для конфигурации Бенчмарки/windows/x64/, и мы хотели оценить, хорошо ли он подойдёт для конфигурации Библиотеки/Linux/arm64. То же самое — для конфигурации «PEHDCGAIJNLKOBFMQ» (оптимальный вариант для конфигурации Библиотеки/windows/x64) и т.д.
В следующей таблице показаны обобщённые данные по PerfScore, которые мы получили так: брали вариант, признанный наилучшим для одной из конфигураций, и применяли его с другими конфигурациями. В каждой строке указана конфигурация, а также соответствующий оптимальный порядок, предложенный генетическим алгоритмом. В столбцах представлены баллы PerfScore, полученные нами при применении выявленного оптимального порядка к конфигурации, указанной в заголовке столбца.
|
Конфигурация |
Оптимальный порядок |
Linux-x64 Benchmarks |
windows-x64 Benchmarks |
windows-arm64 Benchmarks |
Linux-x64 Libraries |
Linux-arm64 Libraries |
windows-x64 Libraries |
windows-arm64 Libraries.pmi |
windows-x86 Benchmarks |
Linux-arm Libraries |
windows-x86 Libraries |
|
windows-x64 Benchmarks |
EHPDGAJCBNKOLFIMQ |
-83.496405 |
-36.540712 |
-19.09969 |
-340.009195 |
-103.340802 |
-265.397122 |
-113.718544 |
-62.126579 |
11292.33497 |
18.510854 |
|
windows-x64 Libraries |
PEHDCGAIJNLKOBFMQ |
-85.572973 |
-35.853492 |
-19.07247 |
-355.615641 |
-103.028599 |
-271.749901 |
-114.1154 |
-70.087852 |
31974.87698 |
-46.803569 |
|
windows-x86 Benchmarks |
EHDCFPGJBIALNOKMQ |
-101.903471 |
-19.844343 |
-41.041839 |
-419.933377 |
-247.95955 |
-179.127655 |
-265.675453 |
-73.004577 |
10679.36843 |
-136.780091 |
|
windows-x86 Libraries |
APCDEGHNIOFJKLBMQ |
-26.907257 |
-0.284718 |
-30.144657 |
-164.340576 |
-220.351459 |
-73.413256 |
-232.256476 |
-10.25733 |
31979.07983 |
-168.335079 |
|
linux-x64 Benchmarks |
HGIDJNLCPOBKAEFMQ |
-96.966704 |
-9.29483 |
-50.215283 |
-361.159848 |
-221.622609 |
-64.308995 |
-244.127555 |
13.188704 |
8392.714652 |
397.994465 |
|
linux-x64 Libraries |
HDGAECNIPLBOFKJMQ |
-97.682606 |
-13.882952 |
-51.929281 |
-391.835935 |
-240.63813 |
-101.495244 |
-262.746033 |
-22.621316 |
8456.327283 |
165.982045 |
|
linux-arm64 Libraries |
HECDBFGIANLOKJMPQ |
-97.259922 |
-11.159774 |
-54.424627 |
-330.340402 |
-249.900161 |
-52.359275 |
-270.482763 |
-35.304525 |
2404.874376 |
125.707741 |
|
|
Max PerfScore |
EHDCFPGJBIALNOKMQ |
EHPDGAJCBNKOLFIMQ |
HECDBFGIANLOKJMPQ |
HDGAECNIPLBOFKJMQ |
HECDBFGIANLOKJMPQ |
PEHDCGAIJNLKOBFMQ |
HECDBFGIANLOKJMPQ |
EHDCFPGJBIALNOKMQ |
HECDBFGIANLOKJMPQ |
APCDEGHNIOFJKLBMQ |
В последней строке вышеприведённой таблицы указан такой наилучший порядок для конфигурации (из данного столбца), который не входит в число оптимальных вариантов упорядочивания для всех конфигураций. В следующей таблице приведены первый по качеству и второй по качеству варианты порядка для каждой отдельной конфигурации.
|
Конфигурация |
1-й лучший |
2-й лучший |
|
windows-x64 Benchmarks |
EHPDGAJCBNKOLFIMQ |
PEHDCGAIJNLKOBFMQ |
|
windows-x64 Libraries |
PEHDCGAIJNLKOBFMQ |
EHPDGAJCBNKOLFIMQ |
|
windows-x86 Benchmarks |
EHDCFPGJBIALNOKMQ |
PEHDCGAIJNLKOBFMQ |
|
windows-x86 Libraries |
APCDEGHNIOFJKLBMQ |
EHDCFPGJBIALNOKMQ |
|
windows-arm64 Benchmarks |
HECDBFGIANLOKJMPQ |
HDGAECNIPLBOFKJMQ |
|
windows-arm64 Libraries |
HECDBFGIANLOKJMPQ |
EHDCFPGJBIALNOKMQ |
Если проследить закономерность, характерную для столбца «первый лучший», то увидим, что последовательность E и H располагается ближе к началу. Это значит, что, в целом, мы выигрываем, если в качестве одного из первых критериев эвристики обзаводимся OWN_PREFERENCE (регистром, который считается предпочтительным в заданном интервале) или CALLEE_CALLER (регистрами вызывающей и вызываемой стороны). Далее отметим, что в большинстве вариантов упорядочивания также популярны C и D, соответствующие THIS_ASSIGNED (уже присвоен актуальному интервалу) и COVERS (захватывает время жизни интервала). В самом начале также идёт эвристика P, одна из тех, что предназначены для работы с занятыми регистрами. Она соответствует PREV_REG_OPT (предыдущая ссылка на присвоенный в настоящее время интервал была опциональной).
Притом, что такие варианты упорядочивания дают хорошие PerfScore, наблюдаются и случаи просадок, проявляющихся при использовании других методов. Большинство таких просадок можно отнести к одной или нескольким из следующих категорий:
-
В фазе разрешения алгоритма LSRA есть серьёзные нерешённые проблемы, акцентированные в документе dotnet/runtime #47194. Когда для всех блоков будут найдены ходы, снимающие эти проблемы, их нужно пересмотреть и выяснить, есть ли среди них такие, которые можно сократить в порядке оптимизации. У некоторых методов показатель
PerfScoreпадал, так как мы добавляли слишком много таких разрешающих операций на стыках блоков. -
При всей гибкости, доступной при компоновке различных порядков следования регистров, алгоритм LSRA обладает ограниченной информацией о методе и о том участке кода, для которого он выделяет регистр. Например, на этапе выделения алгоритм не знает, нужно ли выделить место для выполнения кода и, соответственно, приберечь лишние регистры для использования в этом коде. То есть, ещё до LSRA нужно предусмотреть фазу, на которой эта информация консолидируется в отдельной структуре данных, которую LSRA затем может использовать в ходе выбора регистров.
-
В ходе экспериментов мы выявили и другие легкодоступные оптимизации, касающиеся LSRA при работе с эвристиками переупорядочивания регистров. Например, если переменная определяется всего один раз, то её можно выгрузить в стек именно в той точке, где она была определена, и впоследствии в пределах метода её выгружать не придётся. Это было достигнуто в dotnet/runtime #54345.
Заключение
Выделение регистров — сложная тема. Минимальное изменение алгоритма может сильнейшим образом сказаться на сгенерированном коде. В этом посте были исследованы различные идеи по поиску оптимальной эвристики для упорядочивания выбора регистров. Оптимальный порядок удалось найти при помощи генетического алгоритма. Также мы обнаружили некоторое сходство в порядке следования эвристик, дававшее выигрыш производительности в большинстве протестированных нами конфигураций. Однако, несмотря на многочисленные улучшения, также во многих методах из всех конфигураций наблюдаются просадки производительности. Мы обнаружили, что есть и другое поле для улучшений, которое нужно проработать до того, как переходить к переупорядочиванию эвристик. Некоторые из проблем затронуты в [RyuJIT][LSRA]. Таким образом, на момент подготовки статьи мы решили не менять никаких эвристик, а сначала устранить другие слабые места имеющейся реализации LSRA. Затем можно будет вернуться к этому эксперименту и воспользоваться найденными при этой работе приёмами, инструментами и знаниями об эвристиках переупорядочивания выбора регистров. Далее можно было бы добавить автонастройку порядка выбора регистров, основываясь на разнообразных факторах — например, сколько параметров у метода, есть ли в нём циклы, обрабатываются ли в нём исключения. Возможности безграничны, а время в дефиците, так что нужно стремиться выбирать лучшее!
ссылка на оригинал статьи https://habr.com/ru/articles/1025788/