Сводка : попытайтесь выделить память рядом со статическим кодом.Но для вызовов, которые не могут дозвониться с помощью 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
.
- Распределить функции 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.Хорошо, если они находятся в той же странице , что и некоторый код;они доступны только для чтения, поэтому не будут вызывать ядерные конвейеры с самоизменяющимся кодом.