Обработка вызовов (потенциально) заблаговременно скомпилированных функций из кода JITed - PullRequest
0 голосов
/ 01 марта 2019

Этот вопрос был отложен как слишком широкий, по-видимому, из-за исследования, которое я включил, чтобы «показать свою работу» вместо того, чтобы задавать вопрос с минимальными усилиями.Чтобы исправить это, позвольте мне суммировать весь вопрос в одном предложении (спасибо @PeterCordes за эту фразу):

Как эффективно вызвать (x86-64) скомпилированный заранеефункции (которые я контролирую, могут быть на расстоянии более 2 ГБ) от кода JITed (который я генерирую)?

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

Обратите внимание, что ни на один из вопросов, поставленных ниже, я не отвечу;они более риторические. Их цель - продемонстрировать, почему я не могу ответить на вышеуказанный вопрос (несмотря на мои исследования, мне не хватает опыта в этой области, чтобы делать определенные утверждения, такие как предсказание ветвлений @ PeterCordes, скрывающее задержку выборки.и проверка указателя функции из памяти, предполагая, что он хорошо предсказывает. ")Также обратите внимание, что компонент Rust здесь в значительной степени не имеет значения, так как это проблема сборки.Мои соображения по поводу включения этого были в том, что заранее скомпилированные функции написаны на Rust, поэтому я не был уверен, что есть что-то, что Rust сделал (или проинструктировал LLVM), что может быть выгодно в этой ситуации.Для ответа вполне приемлемо вообще не учитывать Rust;на самом деле, я ожидаю, что так оно и будет.

Думайте о следующем, как о задании на обратной стороне экзамена по математике:


Примечание: Iсумбурный термин здесьКак отмечается в комментариях, «заранее скомпилированные функции» - лучшее описание.Ниже я кратко опишу, что AOTC функционирует.

Я пишу JIT в Rust (хотя Rust относится только к части моего вопроса, большая часть этого относится к соглашениям JIT).У меня есть AOTC функции, которые я реализовал в Rust, и мне нужно иметь возможность call из кода, испускаемого моим JIT.Мои JIT mmap(_, _, PROT_EXEC, MAP_ANONYMOUS | MAP_SHARED) s некоторые страницы для кода с дублированием.У меня есть адреса моих функций AOTC , но, к сожалению, они намного дальше, чем 32-битное смещение.Я пытаюсь решить, как отправлять вызовы к этим AOTC функциям.Я рассмотрел следующие варианты (это не вопросы, на которые нужно ответить, просто демонстрирую, почему я сам не могу ответить на основной вопрос этой SO-темы):

  1. (специфично для Rust)Каким-то образом заставьте Rust поместить функции AOTC близко (возможно, к?) К куче так, чтобы call s было в пределах 32-битного смещения.Непонятно, что это возможно с Rust (есть способ указать настраиваемые аргументы линкера , но я не могу сказать, к чему они применяются, и могу ли я указать одну функцию для перемещения. И даже еслиЯ мог бы где это поставить?).Похоже, что это может привести к сбою, если куча достаточно велика.

  2. (зависит от ржавчины) Расположите страницы JIT ближе к функциям AOTC .Это может быть достигнуто с помощью mmap(_, _, PROT_EXEC, MAP_FIXED), но я не уверен, как выбрать адрес, который не смутил бы существующий код Rust (и не выходил за пределы арочных ограничений - есть ли разумный способ получить эти ограничения?).

  3. Создайте заглушки на страницах JIT, которые обрабатывают абсолютный переход (код ниже), затем call заглушки.Это имеет то преимущество, что (исходный) сайт вызова в коде JITted является хорошим небольшим относительным вызовом.Но неправильно прыгать через что-то.Похоже, что это отрицательно сказывается на производительности (возможно, мешает прогнозированию RAS / адреса перехода).Кроме того, похоже, что этот переход будет медленнее, поскольку его адрес является косвенным и зависит от mov для этого адреса.

mov rax, {ABSOLUTE_AOTC_FUNCTION_ADDRESS}
jmp rax

Обратное из (3), просто вставка вышеупомянутого на каждом внутреннем сайте вызова в коде JITed.Это решает проблему косвенного обращения, но увеличивает код JITted (возможно, это приводит к кешу команд и последствиям декодирования).По-прежнему существует проблема, заключающаяся в том, что переход является косвенным и зависит от mov.

Поместить адреса функций AOTC на страницу PROT_READ (только)около страниц JIT.Сделайте все сайты вызовов рядом, абсолютно косвенные вызовы (код ниже).Это удаляет второй уровень косвенности из (2).Но код этой инструкции, к сожалению, большой (6 байт), поэтому он имеет те же проблемы, что и (4).Кроме того, теперь вместо зависимости от регистра переходы без необходимости (поскольку адрес известен во время JIT) зависят от памяти, что, безусловно, влияет на производительность (несмотря на то, что эта страница кэшируется?).

aotc_function_address:
    .quad 0xDEADBEEF

# Then at the call site
call qword ptr [rip+aotc_function_address]

Futz с регистром сегмента, чтобы разместить его ближе к функциям AOTC , чтобы можно было выполнять вызовы относительно этого регистра сегмента.Кодирование такого вызова является длинным (поэтому, возможно, у него есть проблемы с конвейерным декодированием), но кроме этого это в значительной степени позволяет избежать множества хитрых битов всего, что было до него.Но, возможно, вызов относительно не-1074 * сегмента работает плохо.Или, может быть, такое сглаживание нецелесообразно (например, портит среду выполнения Rust). (как указывает @prl, это не работает без дальнего вызова, что ужасно для производительности)

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

Все представленные опции имеют недостатки.Вкратце, 1 и 2 - единственные, которые не оказывают влияния на производительность, но неясно, есть ли нехакерский способ их достижения (или вообще какой-либо способ в этом отношении).3-5 не зависят от Rust, но имеют очевидные недостатки производительности.

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

  1. Для подхода (1), возможно ли заставить Rust связать определенные extern "C" функции по определенному адресу (около кучи)?Как выбрать такой адрес (во время компиляции)?Можно ли предположить, что любой адрес, возвращенный mmap (или выделенный Rust), будет в пределах 32-битного смещения от этого местоположения?

  2. Для подхода (2) как можноЯ нахожу подходящее место для размещения страниц JIT (чтобы он не затирал существующий код Rust)?

И некоторые вопросы JIT (не относящиеся к Rust):

  1. Для подхода (3), заглушки будут препятствовать производительности достаточно, чтобы я заботился?А как насчет косвенного jmp?Я знаю, что это несколько напоминает заглушки компоновщика, за исключением того, что, как я понимаю, заглушки компоновщика разрешаются, по крайней мере, только один раз (поэтому они не должны быть косвенными?).Используют ли какие-либо JIT эту технику?

  2. Для подхода (4), если косвенный вызов в 3 в порядке, стоит ли встраивать вызовы?Если в JIT обычно используется подход (3/4), лучше ли этот вариант?

  3. Для подхода (5) - зависимость перехода от памяти (учитывая, что адрес известен при компиляции?время) плохо?Это сделает это менее производительным, чем (3) или (4)?Используют ли какие-либо JIT эту технику?

  4. Для подхода (6) такое фьюзинг неразумно?(Специфично для Rust) Имеется ли сегментный регистр (не используемый средой выполнения или ABI) для этой цели?Будут ли вызовы относительно не cs сегмента такими же производительными, как и вызовы относительно cs?

  5. И, наконец, (и, что наиболее важно) , есть?лучший подход (возможно, чаще используемый в JIT), который мне здесь не хватает?

Я не могу реализовать (1) или (2) без моих вопросов Rust, имеющих ответы.Я мог бы, конечно, реализовать и протестировать 3-5 (возможно, 6, хотя было бы неплохо знать о смещении регистра сегментов заранее), но, учитывая, что это очень разные подходы, я надеялся, что существует литература по этому вопросу, котораяЯ не мог найти, потому что я не знал правильных терминов для Google (я также в настоящее время работаю над этими тестами).В качестве альтернативы, может быть, кто-то, кто изучает внутреннюю среду JIT, может поделиться своим опытом или тем, что он обычно видел?

Мне известен этот вопрос: Переход на JIT (x86_64) .Он отличается от моего, потому что речь идет о соединении базовых блоков (и принятым решением является слишком много инструкций для часто называемого встроенного).Мне также известно о Вызов абсолютного указателя в машинном коде x86 , который, хотя и обсуждает схожие темы с моим, отличается, поскольку я не предполагаю, что абсолютные переходы необходимы (подходы 1-2 позволят их избежать)например).

1 Ответ

0 голосов
/ 12 апреля 2019

Сводка : попытайтесь выделить память рядом со статическим кодом.Но для вызовов, которые не могут дозвониться с помощью rel32, вернитесь к call qword [rel pointer] или встроенному mov r64,imm64 / call r64.

Ваш механизм 5., вероятно, лучше всего работает, если вы не можете сделать2. работа, но 4. это легко и должно быть хорошо.Direct call rel32 тоже нуждается в некотором предсказании ветвлений, но он определенно все же лучше.


Терминология: "встроенные функции", вероятно, должны быть "вспомогательными" функциями.«Внутренний» обычно означает встроенный язык (например, значение на Фортране) или «не настоящая функция , просто что-то, что указывает на машинную инструкцию» (C / C ++ / Rust означаеткак для SIMD, или как _mm_popcnt_u32(), _pdep_u32() или _mm_mfence()).Ваши функции Rust собираются для компиляции с реальными функциями, которые существуют в машинном коде, который вы будете вызывать с помощью инструкций call.


Да, выделение буферов JIT в пределах + -2 ГБ от вашей целифункции, очевидно, идеальны, позволяя выполнять прямые вызовы rel32.

Наиболее простым будет использование большого статического массива в BSS (который компоновщик разместит в пределах 2 ГБ вашего кода) и выделение ваших выделенийиз этого .(Используйте mprotect (POSIX) или VirtualProtect (Windows), чтобы сделать его исполняемым).

Большинство ОС (включая Linux) выполняют отложенное выделение для BSS (сопоставление COW с нулевой страницей, только выделение физическогофреймы страницы, чтобы поддержать это распределение, когда оно написано, точно так же, как mmap без MAP_POPULATE), поэтому он тратит впустую только виртуальное адресное пространство, чтобы иметь массив 512 МБ в BSS, для которого вы используете только нижние 10 КБ.

Донне делайте его больше или близким к 2 ГБ, потому что это слишком сильно отодвинет другие объекты в BSS.Модель «маленького» кода по умолчанию (как описано в x86-64 System V ABI) помещает все статические адреса в пределах 2 ГБ друг от друга для адресации относительных данных RIP и вызова rel32 / jmp.

Недостаток: вы 'Я должен написать хотя бы простой распределитель памяти самостоятельно, вместо того, чтобы работать с целыми страницами с помощью mmap / munmap.Но это легко, если вам не нужно ничего освобождать.Возможно, просто сгенерируйте код, начинающийся с адреса, и обновите указатель, как только вы дойдете до конца, и выясните, как долго ваш блок кода.(Но это не многопоточность ...) В целях безопасности не забудьте проверить, когда вы доберетесь до конца этого буфера и прервать его или вернуться к mmap.


Есливаши абсолютные целевые адреса находятся в низком 2 ГБ виртуального адресного пространства, используйте mmap(MAP_32BIT) в Linux .(например, если ваш код Rust скомпилирован в исполняемый файл не-PIE для Linux x86-64. Но это не относится к исполняемым файлам PIE ( обычно в наши дни ) или к целям в общих библиотеках.Вы можете обнаружить это во время выполнения, проверив адрес одной из ваших вспомогательных функций.)

В целом (если MAP_32BIT не полезно / недоступно), ваш лучший выбор, вероятно, mmap без MAP_FIXED, но с ненулевым адресом подсказки, который, по вашему мнению, является бесплатным.

В Linux 4.17 введено MAP_FIXED_NOREPLACE, что позволит вам легко искать ближайший неиспользуемый регион (например, шаг на 64 МБ и повторить попытку, если вы получите EEXIST, затем запомните этот адрес, чтобы избежать поиска в следующий раз).В противном случае вы можете проанализировать /proc/self/maps один раз при запуске, чтобы найти не отображенное пространство рядом с отображением, содержащее адрес одной из ваших вспомогательных функций.Они будут близки друг к другу.

Обратите внимание, что более старые ядра, которые не распознают флаг MAP_FIXED_NOREPLACE, обычно (при обнаружении коллизии с существующим отображением) возвращаются к типу "не MAP_FIXED".поведения: они будут возвращать адрес, отличный от запрошенного адреса.

На следующих более высоких или более низких свободных страницах было бы идеально иметь карту с разреженной памятью, поэтому таблице страниц не нужно слишком много различных каталогов страниц верхнего уровня.(Таблицы страниц HW представляют собой основополагающее дерево.) И как только вы найдете место, которое работает, сделайте будущие распределения непрерывными с этим.Если вы в конечном итоге используете там много места, ядро ​​может произвольно использовать огромную страницу размером в 2 МБ, и повторное смешение ваших страниц означает, что они совместно используют один и тот же каталог родительских страниц в таблицах страниц HW, так что пропуск iTLB при запуске просмотра страниц может быть немного дешевле (если эти более высокие уровни остаются горячими в кешах данных или даже кешируются внутри самого оборудования Pagewalk).И для эффективного для ядра, чтобы отслеживать как одно большее отображение.Конечно, использование большего количества уже выделенной страницы еще лучше, если есть место.Лучшая плотность кода на уровне страницы помогает инструкции TLB, и, возможно, также на странице DRAM (но это не обязательно тот же размер, что и на странице виртуальной памяти).


Тогда, как вы делаете code-genдля каждого вызова просто проверьте , находится ли цель в диапазоне для call rel32 с off == (off as i32) as i64
, в противном случае отступите к 10-байтному mov r64,imm64 / call r64,(rustcc скомпилирует это в movsxd / cmp, поэтому проверка каждый раз имеет лишь тривиальную стоимость для времени компиляции JIT.)

(или 5-байтовый mov r32,imm32, если это возможно. ОС, которые этого не делаютподдержка MAP_32BIT может иметь целевые адреса там внизу. Проверьте это с помощью target == (target as u32) as u64. 3-х mov -обратное кодирование, 7-байтовое mov r/m64, sign_extended_imm32, вероятно, неинтересно, если вы не JIT-код ядра для ядраотображается в высоком 2GiB виртуального адресного пространства.)

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


Альтернативы mov-imm / call reg

mov r64,imm64 - это 10-байтовая инструкция, которая немного велика для извлечения / декодирования и для хранения uop-кэша.И может потребоваться дополнительный цикл для чтения из кэша UOP в семействе SnB согласно микроархиву pdf Агнера Фога (https://agner.org/optimize). Но современные процессоры имеют довольно хорошую пропускную способность для выборки кода и надежные внешние интерфейсы.

Если профилирование обнаружит, что узкие места внешнего интерфейса являются большой проблемой в вашем коде, или большой размер кода вызывает вытеснение другого ценного кода из I-кэша L1, я бы выбрал вариант 5.

Кстати, если какая-либо из ваших функций является переменной, x86-64 System V требует, чтобы вы передали AL = число аргументов XMM, вы можете использовать r11 для указателя функции. Он является сглаженным вызовом и не используется для передачи аргументов. НоRAX (или другой «устаревший» регистр) сохранит префикс REX для call.


  1. Распределить функции Rust рядом с тем местом, где mmap будет выделять

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

mmap имеет более 4 ГБ свободноговиртуальное адресное пространство длявыбрать из.Вы не знаете заранее, где он будет выделяться.(Хотя я думаю, что Linux, по крайней мере, сохраняет некоторую локальность для оптимизации таблиц страниц HW.)

Теоретически вы можете скопировать машинный код ваших функций Rust, но они, вероятно,ссылка прочее статический код / ​​данные с режимами RIP-относительной адресации.


call rel32 для заглушек, которые используют mov / jmp reg

Похоже, это пагубно сказывается на производительности (возможно, мешает прогнозированию RAS / адреса перехода).

Недостатком перфекта является только наличие 2 общих инструкций по вызову / прыжку, которые передний интерфейс должен пройти, прежде чем он сможет накормить внутренний конец полезными инструкциями.Это не здорово;5. намного лучше.

Это в основном то, как PLT работает для вызовов функций совместно используемых библиотек в Unix / Linux и будет выполнять то же самое .Вызов через функцию-заглушку PLT (Table Linking Table) почти такой же.Таким образом, влияние на производительность было хорошо изучено и сопоставлено с другими способами работы.Мы знаем, что динамические вызовы библиотек не являются причиной снижения производительности.

Звездочка перед адресом и инструкциями push, куда она направляется? показывает разборку AT & T в один или один шагпрограмма на C, такая как main(){puts("hello"); puts("world");}, если вам интересно.(При первом вызове он выдвигает аргумент arg и переходит к функции отложенного динамического компоновщика; при последующих вызовах целью косвенного перехода является адрес функции в общей библиотеке.)

ПочемуPLT существует в дополнение к GOT, вместо того, чтобы просто использовать GOT? объясняет больше.jmp, адрес которого обновляется с помощью ленивых ссылок, - jmp qword [xxx@GOTPLT].(И да, PLT действительно использует косвенную память jmp здесь, даже на i386, где jmp rel32, который будет переписан, будет работать. IDK, если GNU / Linux когда-либо исторически использовался для перезаписи смещения в jmp rel32.)

jmp - это просто стандартный хвостовой вызов, и не разбалансирует стек предикторов обратного адреса .Возможное значение ret в целевой функции вернется к инструкции после исходного call, то есть по адресу, который call помещен в стек вызовов и на микроархитектурный RAS.Только если вы использовали push / ret (например, «retpoline» для смягчения Призрака), вы бы разбалансировали RAS.

Но код в Jump for JIT (x86_64) , который вы связалиэто, к сожалению, ужасно (см. мой комментарий под ним).Это будет сломать RAS для будущих возвратов.Вы могли бы подумать, что он сломает его только для этого вызова (чтобы получить адрес возврата, который нужно скорректировать), если балансировать push / ret, но на самом деле call +0 - это особый случай, который не идет на RASв большинстве процессоров: http://blog.stuffedcow.net/2018/04/ras-microbenchmarks. (я полагаю, что вызов через nop может измениться, но все это совершенно безумие против call rax, если только он не пытается защитить от эксплойтов Spectre.) Обычно на x86-64, вы используете REA-относительный LEA, чтобы поместить соседний адрес в регистр, а не call/pop.


inline mov r64, imm64 / call reg

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

Но это также, вероятно, достаточно хорошо , особенно если вашМетоды alloc-inside-2GiB в большинстве случаев работают достаточно хорошо для большинства целей, которые вас интересуют.

Хотя могут быть случаи, когда он медленнее, чем 5.Предсказание ветвей скрывает задержку выборки и проверки указателя функции из памяти, предполагая, что он хорошо предсказывает.(И, как правило, так будет, или он работает так редко, что это не имеет отношения к производительности.)


call qword [rel nearby_func_ptr]

Вот как gcc -fno-plt компилирует вызовы функций совместно используемой библиотеки в Linux (call [rip + symbol@GOTPCREL]), и как обычно вызовы функций Windows DLL обычноготово. (Это похоже на одно из предложений в http://www.macieira.org/blog/2012/01/sorry-state-of-dynamic-libraries-on-linux/)

call [RIP-relative] размером 6 байт, только на 1 байт больше, чем call rel32, поэтому оно оказывает незначительное влияние на размер кода по сравнению свызывая заглушку. Интересный факт: иногда вы видите addr32 call rel32 в машинном коде (префикс размера адреса не имеет никакого эффекта, кроме заполнения). Это происходит от компоновщика, ослабляющего call [RIP + symbol@GOTPCREL] до call rel32, если символ сво время связывания была обнаружена невидимая видимость ELF в другом .o, а не в другом общем объекте.

Для вызовов из общей библиотеки это обычно лучше, чем заглушки PLT, с единственным недостатком - более медленный запуск программы, поскольку она требует раннего связывания (без ленивого динамического связывания).Это не проблема для вас;целевой адрес известен раньше времени генерации кода.

Автор патча проверил его производительность по сравнению с традиционным PLT на неизвестном оборудовании x86-64.Clang, возможно, является наихудшим сценарием для вызовов разделяемой библиотеки, потому что он делает много вызовов для небольших функций LLVM, которые не занимают много времени, и он долго выполняется, поэтому затраты на раннее связывание при запуске незначительны.После использования gcc и gcc -fno-plt для компиляции clang время для clang -O2 -g для компиляции tramp3d увеличивается с 41,6 с (PLT) до 36,8 с (-fno-plt).clang --help становится немного медленнее.

(хотя в заглушках x86-64 PLT используется jmp qword [symbol@GOTPLT], а не mov r64,imm64 / jmp. Однако непрямая память jmp - это всего лишь один шаг на современных процессорах IntelТаким образом, при правильном прогнозировании это дешевле, но при неправильном прогнозе может быть медленнее, особенно, если запись GOTPLT отсутствует в кэше. Однако, если она используется часто, она обычно прогнозирует правильно, но в любом случае 10-байтовый movabs и2-байтовый jmp может извлекать как блок (если он умещается в 16-байтовом выровненном блоке выборки) и декодировать за один цикл, так что 3. не является абсолютно необоснованным. Но это лучше.)

При выделении места для ваших указателей помните, что они извлекаются как данные в кэш L1d и с записью dTLB, а не iTLB. Не чередуйте их с кодом, который будет тратить пространство в I-кэше на эти данные, и тратит пространство в D-кэше на строки, содержащие один указатель и в основном код.Сгруппируйте ваши указатели вместе в отдельном 64-байтовом фрагменте из кода, чтобы строка не обязательно была как в L1I, так и в L1D.Хорошо, если они находятся в той же странице , что и некоторый код;они доступны только для чтения, поэтому не будут вызывать ядерные конвейеры с самоизменяющимся кодом.

...