Главная » Хабрахабр » [Перевод] Когда вызовы функций через внешний интерфейс быстрее нативных вызовов C

[Перевод] Когда вызовы функций через внешний интерфейс быстрее нативных вызовов C

Дополнено: хорошая дискуссия на Hacker News

Дэвид Ю на GitHub разработал интересный тест производительности для вызовов функций через разные внешние интерфейсы (Foreign Function Interfaces, FFI).

Затем написал код для многократного вызова этой функции через каждый FFI с измерением времени. Он создал файл общего объекта (.so) с одной простой функцией C.

Это различие очень важно, так как действительно сказывается на результатах теста. Для C «FFI» он использовал стандартную динамическую компоновку, а не dlopen(). Можно спорить, насколько честно такое сравнение с фактическим FFI, но всё равно его интересно измерить.

Он примерно на 25% быстрее, чем нативный вызов C для функции общего объекта. Самый удивительный результат бенчмарка — то, что FFI от LuaJIT существенно быстрее, чем C. Точен ли результат?
На самом деле это вполне логично. Как смог слабо и динамически типизированный скриптовый язык обогнать в бенчмарке C? Я подготовил действительно простой эксперимент, чтобы продемонстрировать эффект в простом старом C: Тест запущен на Linux, поэтому задержка идёт от таблицы компоновки процедур (Procedure Linkage Table, PLT).

https://github.com/skeeto/dynamic-function-benchmark

Вот результаты на Intel i7-6700 (Skylake):

759799 ns/call
ind: 1. plt: 1. 008108 ns/call 257125 ns/call
jit: 1.

Здесь три разных типа вызовов функций:

  1. Через PLT.
  2. Косвенный вызов функции (через dlsym(3))
  3. Прямой вызов функции (через JIT-скомпилированную функцию)

Как видим, последний самый быстрый. Обычно он не используется в программах на C, но это естественный вариант в присутствии JIT-компилятора, включая, очевидно, и LuaJIT.

В моём бенчмарке вызывается функция empty():

void empty(void)

Компиляция в общий объект:

$ cc -shared -fPIC -Os -o empty.so empty.c

Как и в предыдущем сравнении ГПСЧ, бенчмарк сколько может многократно вызывает эту функцию, прежде чем сработает сигнал тревоги.
Когда программа или библиотека вызывает функцию в другом общем объекте, компилятор не может знать, где в памяти будет находиться эта функция. Информация выясняется только во время выполнения, когда программа и её зависимости загружены в память. Обычно функция располагается в случайных местах, например, в соответствии с рандомизацией адресного пространства (Address Space Layout Randomization, ASLR).

Ну, есть несколько вариантов. Как решать такую проблему?

Динамический компоновщик рантайма затем вставляет правильный адрес при каждом вызове. Один из них — отмечать каждый вызов в метаданных двоичного файла. Конкретный механизм зависит от кодовой модели, которая использовалась при компиляции.

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

С другой стороны, издержки на вызов динамических функций можно устранить, что даёт JIT-подобную производительность, как показано в бенчмарке.

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

Сама PLT в реальности не исправляется — она отображается как read-only для остального кода. В системах с двоичными файлами ELF эта таблица называется таблицей компоновки процедур (Procedure Linkage Table, PLT). Заглушка PLT извлекает адрес динамической функции из GOT и косвенно переходит на этот адрес. Вместо этого исправляется глобальная таблица смещения (Global Offset Table, GOT). Последующие вызовы используют лениво обнаруженный адрес. Чтобы лениво загрузить адреса функций, эти записи GOT инициализируются с адресом функции, которая находит целевой символ, обновляет GOT этим адресом, а затем переходит к данной функции.

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

Вот бенчмарк:

/* Cleared by an alarm signal. */
volatile sig_atomic_t running; static long
plt_benchmark(void)
{ long count; for (count = 0; running; count++) empty(); return count;
}

Поскольку empty() находится в общем объекте, то вызов идёт через PLT.
Другой способ динамического вызова функций — обход вокруг PLT и получение адреса целевой функции в программе, например, через dlsym(3).

void *h = dlopen("path/to/lib.so", RTLD_NOW);
void (*f)(void) = dlsym("f");
f();

Если адрес функции получен, то издержки меньше, чем на вызовы функции через PLT. Нет промежуточной функции заглушки и доступа к GOT. (Предостережение: если программа имеет запись PLT для данной функции, тогда dlsym(3) в реальности может фактически вернуть адрес заглушки).

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

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

Вот бенчмарк:

volatile sig_atomic_t running; static long
indirect_benchmark(void (*f)(void))
{ long count; for (count = 0; running; count++) f(); return count;
}

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

Если тело цикла достаточно сложное, чтобы вызвать дефицит регистров и тем самым выдавая адрес на стек, то этот бенчмарк тоже нельзя честно сравнивать с бенчмарком PLT.

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

Это означает, что JIT-код следует разместить практически рядом с целевой функцией, empty(). Хитрость в том, что на x86-64 явные переходы ограничены диапазоном 2 ГБ из-за 32-разрядного непосредственного операнда со знаком (signed immediate). Если JIT-код должен вызвать две разные динамические функции, разделённые более чем на 2 ГБ, то невозможно сделать два прямых вызова.

После получения адреса целевой функции он просто вычитает 4 МБ, округляет до ближайшей страницы, выделяет немного памяти и записывает в неё код. Чтобы упростить ситуацию, мой бенчмарк не беспокоится о точном или очень осторожном выборе адреса JIT-кода. В Linux требуется разбор виртуальных файлов под /proc. Если же всё делать как следует, то для поиска места нужно проверить собственные представления программы в памяти, и это невозможно сделать чистым портируемым способом.

Оно предполагает разумное поведение для приведения типов uintptr_t: Вот как у меня выглядит выделение памяти JIT.

static void
jit_compile(struct jit_func *f, void (*empty)(void))
{ uintptr_t addr = (uintptr_t)empty; void *desired = (void *)((addr - SAFETY_MARGIN) & PAGEMASK); /* ... */ unsigned char *p = mmap(desired, len, prot, flags, fd, 0); /* ... */
}

Здесь выделяется две страницы: одна для записи, а другая с незаписываемым кодом. Как и в моей библиотеке для замыканий, здесь нижняя страница доступна для записи и содержит переменную running, которая обнуляется по сигналу тревоги. Эта страница должна находиться рядом с JIT-кодом, чтобы предоставлять эффективный доступ относительно RIP, как функции в двух остальных бенчмарках. Верхняя страница содержит такой ассемблерный код:

jit_benchmark: push rbx xor ebx, ebx
.loop: mov eax, [rel running] test eax, eax je .done call empty inc ebx jmp .loop
.done: mov eax, ebx pop rbx ret

call empty — единственная динамически генерируемая инструкция, она необходима для правильного заполнения относительного адреса (минус 5 указано относительно конца инструкции):

// call empty uintptr_t rel = (uintptr_t)empty - (uintptr_t)p - 5; *p++ = 0xe8; *p++ = rel >> 0; *p++ = rel >> 8; *p++ = rel >> 16; *p++ = rel >> 24;

Если функция empty() находится не в общем объекте, а в том же двоичном файле, то это по сути прямой вызов, который компилятор сгенерирует для plt_benchmark(), предполагая, что почему-то не встроил empty().

Что тут сделать, JIT-скомпилировать другую функцию для прямого вызова? По иронии судьбы, вызов JIT-скомпилированного кода требует косвенного вызова (например, через указатель функции), и это никак не обойти. К счастью, это не имеет значения, потому что в цикле измеряется только прямой вызов.

С учётом этих результатов становится понятно, почему LuaJIT генерирует более эффективные вызовы динамических функций, чем PLT, даже если они остаются косвенными вызовами. В моём бенчмарке непрямые вызовы без PLT были на 28% быстрее, чем с PLT, а прямые вызовы без PLT на 43% быстрее, чем с PLT. Это маленькое преимущество JIT-программ перед простыми старыми нативными программами достигается за счёт абсолютного отказа от обмена кода между процессами.


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

[Перевод] Что входит в обязанности ведущего разработчика

Вот эта большая статья Джона Олспау называется «Быть ведущим инженером». В первый раз я прочитала её примерно четыре года назад, когда только перешла на нынешнюю работу, и она действительно повлияла на представления об этом направлении моей карьеры. Что, конечно, является ...

Первый интроверт в трясущемся мире

Каждый новый фильм о космосе — это шанс заинтересовать людей тем, чем прекрасна космонавтика. И, теоретически, у байопика «Первый на Луне» («First Man», КиноПоиск, IMDB), рассказывающем о жизни Нила Армстронга, были все шансы — единственная авторизованная биография в качестве источника, ...