Обработка вызовов (потенциально) заблаговременно скомпилированных функций из кода JIT [закрыто]

У меня была эта проблема @ на одном из моих сайтов wordpress после обновления и / или перемещения:)

Проверьте таблицу базы данных «wp_options» на «upload_path» и отредактируйте ее правильно ...

9
задан Bailey Parker 12 April 2019 в 18:55
поделиться

1 ответ

Резюме : попытайтесь выделить память рядом со статическим кодом. Но для вызовов, которые не могут дозвониться с помощью 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 в пределах + -2GiB от ваших целевых функций, очевидно, идеально, позволяя выполнять прямые вызовы 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 не полезно / не доступно), ваша лучшая ставка, вероятно, [ 1115] без 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 (но это не обязательно тот же размер, что и на странице виртуальной памяти).


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

(или 5-байтовый mov r32,imm32, если это возможно. Операционные системы, которые этого не делают) support MAP_32BIT может по-прежнему иметь целевые адреса там внизу. Проверьте это с помощью target == (target as u32) as u64. Третье 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-относительной адресации.


  1. call rel32 для заглушек, использующих mov / jmp reg

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

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

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

Звездочка перед адресом и инструкциями 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.


  1. inline mov r64, imm64 / call reg

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

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

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


  1. 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/ )

[ 11141] 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 могут быть извлечены как block (если он умещается в 16-байтовом выровненном блоке выборки) и декодировать за один цикл, поэтому 3. не является абсолютно необоснованным. Но это лучше.)

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

0
ответ дан Peter Cordes 12 April 2019 в 18:55
поделиться
Другие вопросы по тегам:

Похожие вопросы: