Как я могу написать самомодифицирующий код, который эффективно работает на современных процессорах x64?
Я пытаюсь ускорить схему сжатия целочисленного значения переменной ширины, и я заинтересован в генерации и выполнении кода сборки на лету. В настоящее время много времени тратится на неверно прогнозируемые косвенные отрасли, и генерация кода на основе серии битпотоков, найденных, как представляется, является единственным способом избежать этого штрафа.
Общую технику называют "подпрограммной нитью" (или "вызывать потоки", хотя это имеет и другие определения). Цель состоит в том, чтобы использовать преимущества эффективного прогнозирования call/ret процессоров, чтобы избежать ларьков. Этот подход хорошо описан здесь:
http://webdocs.cs.ualberta.ca/~amaral/cascon/CDP05/slides/CDP05-berndl.pdf
Сгенерированный код будет просто серией вызовов, за которыми следует возврат. Если бы было 5 "кусков" ширины [4,8,8,4,16], это выглядело бы так:
call $decode_4
call $decode_8
call $decode_8
call $decode_4
call $decode_16
ret
В реальном использовании это будет более длинная серия вызовов с достаточной длиной, каждая из которых, вероятно, будет уникальной и будет вызываться только один раз. Создание и вызов кода хорошо документировано, как здесь, так и в другом месте. Но я не нашел большого обсуждения эффективности за пределами простого "не делай этого" или хорошо продуманного "дракона". Даже документация Intel говорит главным образом в общих чертах:
8.1.3 Обработка кода самообучения и перекрестной модификации
Действие процессора, записывающего данные в текущий исполняемый код сегмент с целью выполнения этих данных, поскольку код вызывается самомодифицирующийся код. Процессоры IA-32 демонстрируют поведение модели при выполнении саморедактированного кода, в зависимости от того, насколько далеко впереди текущий указатель выполнения, код был изменен.... Самомодифицирующийся код будет выполняться на более низком уровне производительности, чем неавтоматический или нормальный код. Степень производительности ухудшение будет зависеть от частоты модификации и специфические характеристики кода.
11.6 САМО-ИЗМЕНЕНИЕ КОДА
Запись в ячейку памяти в сегменте кода, который в настоящее время кэшированный в процессоре, приводит к тому, что связанная строка (или строки) кэша быть недействительным. Эта проверка основана на физическом адресе инструкция. Кроме того, процессоры семейства P6 и Pentium может ли запись в сегмент кода изменять команду, которая имеет был предварительно выбран для выполнения. Если запись влияет на заранее выбранную команды, очередь предварительной выборки недействительна. Последняя проверка основанный на линейном адресе инструкции. Для Pentium 4 и Процессоры Intel Xeon, запись или snoop инструкции в коде сегмент, где целевая команда уже декодирована и резидентна в кеше трассировки аннулирует весь кеш трассировки. Последний поведение означает, что программы, которые могут самостоятельно модифицировать код, могут ухудшение производительности при работе на Pentium 4 и Intel Xeon процессоры.
Пока есть счетчик производительности, чтобы определить, происходят ли плохие вещи (C3 04 MACHINE_CLEARS.SMC: количество обнаруженных ящиков самомодифицирующего кода). Я хотел бы узнать больше деталей, особенно для Haswell. Мое впечатление состоит в том, что до тех пор, пока я могу написать сгенерированный код достаточно далеко раньше времени, что предварительная выборка команд еще не дошла до этого, и до тех пор, пока я не запускаю детектор SMC, изменяя код на той же странице (четверть- page?) как что-либо в настоящее время выполняется, тогда я должен получить хорошую производительность. Но все детали кажутся крайне неопределенными: насколько близко слишком близко? Насколько далеко?
Попытка сделать их конкретными вопросами:
-
Какое максимальное расстояние перед текущей инструкцией,
прецедент Хасуэлла когда-либо бежит?
-
Каково максимальное расстояние позади текущей инструкции,
Haswell 'cache cache' может содержать?
-
Каков фактический штраф в циклах для события MACHINE_CLEARS.SMC
на Хасуэле?
-
Как я могу запустить цикл generate/execute в прогнозируемом цикле, пока
не позволяя префектуру есть собственный хвост?
-
Как я могу организовать поток так, чтобы каждый фрагмент сгенерированного кода
всегда "видно впервые" и не наступая на инструкции
уже кэширован?
Ответы
Ответ 1
Это меньше в области SMC и больше в Dynamic Binary Optimization, т.е. вы на самом деле не манипулируете кодом, который вы используете (как при написании новых инструкций), вы можете просто генерировать другой фрагмент кода, и перенаправляйте соответствующий вызов в свой код, чтобы вместо этого перейти туда. Единственная модификация находится в точке входа, и она выполняется только один раз, поэтому вам не нужно слишком беспокоиться о накладных расходах (обычно это означает сброс всех трубопроводов, чтобы убедиться, что старая инструкция еще не жива в любом месте машины, я бы предположил, что штраф составляет несколько сотен тактовых циклов, в зависимости от того, насколько загружен процессор. Имеет значение только в том случае, если он повторяется).
В том же смысле вы не должны слишком беспокоиться о том, чтобы делать это достаточно долго. Кстати, в отношении вашего вопроса - процессор только сможет начать выполнение вперед, пока его размер ROB, который в haswell равен 192 мкА (не инструкции, но достаточно близко), в соответствии с этим - http://www.realworldtech.com/haswell-cpu/3/, и будет иметь возможность видеть немного дальше вперед благодаря устройствам предиктора и выборки, поэтому мы говорим о целом, пусть говорят несколько сотен).
Сказав это, позвольте мне повторить то, что было сказано здесь раньше - эксперимент, эксперимент эксперимента:)
Ответ 2
Это не обязательно должен быть самомодифицирующийся код - вместо него может быть создан динамически созданный код, т.е. создаваемые в режиме исполнения "батуты".
Значит, вы храните указатель (глобальную) функции, который перенаправляет на записываемый/исполняемый отображаемый раздел памяти, в котором вы затем активно вставляете вызовы функций, которые вы хотите сделать.
Основная трудность заключается в том, что call
является IP-относительным (как и большинство jmp
), поэтому вам нужно будет рассчитать смещение между местоположением памяти вашего батута и "целевыми funcs". Это как таковое достаточно просто - но объединить это с 64-битным кодом, и вы столкнетесь с относительным смещением, которое call
может иметь дело только с перемещением в диапазоне + -2 ГБ, оно становится более сложным - вам нужно будет позвонить таблица связей.
Итак, вы по существу создали бы такой код, как (/me строго UN * X смещен, следовательно, сборка AT & T и некоторые ссылки на ELF-isms):
.Lstart_of_modifyable_section:
callq 0f
callq 1f
callq 2f
callq 3f
callq 4f
....
ret
.align 32
0: jmpq tgt0
.align 32
1: jmpq tgt1
.align 32
2: jmpq tgt2
.align 32
3: jmpq tgt3
.align 32
4: jmpq tgt4
.align 32
...
Это можно создать во время компиляции (просто создать текстовую секцию для записи) или динамически во время выполнения.
Затем вы во время выполнения исправляете цели перехода. Это похоже на то, как работает .plt
ELF Section (PLT = таблица связывания с процедурой) - просто там есть динамический компоновщик, который исправляет слоты jmp, в то время как в вашем случае вы делаете это сами.
Если вы идете на все время выполнения, то таблица, подобная выше, легко создается с помощью C/С++; начните с таких структур данных, как:
typedef struct call_tbl_entry __attribute__(("packed")) {
uint8_t call_opcode;
int32_t call_displacement;
};
typedef union jmp_tbl_entry_t {
uint8_t cacheline[32];
struct {
uint8_t jmp_opcode[2]; // 64bit absolute jump
uint64_t jmp_tgtaddress;
} tbl __attribute__(("packed"));
}
struct mytbl {
struct call_tbl_entry calltbl[NUM_CALL_SLOTS];
uint8_t ret_opcode;
union jmp_tbl_entry jmptbl[NUM_CALL_SLOTS];
}
Единственная критическая и несколько зависящая от системы вещь - это "упакованная" природа этого, о которой нужно рассказать компилятору (т.е. не вставлять массив call
out), и что нужно придерживаться таблица перехода.
Вам нужно сделать calltbl[i].call_displacement = (int32_t)(&jmptbl[i]-&calltbl[i+1])
, инициализировать пустую/неиспользуемую таблицу перехода с помощью memset(&jmptbl, 0xC3 /* RET */, sizeof(jmptbl))
, а затем просто заполнить поля кодом операции перехода и целевым адресом по мере необходимости.
Ответ 3
Очень хороший вопрос, но ответ не так прост... Вероятно, последнее слово будет для эксперимента - обычным случаем в современном мире разных архитектур.
Во всяком случае, то, что вы хотите сделать, - это не совсем самосовершенствующий код. Процедуры "decode_x" будут существовать и не будут изменены. Таким образом, проблем с кешем не должно быть.
С другой стороны, память, выделенная для сгенерированного кода, вероятно, будет динамически распределена из кучи, поэтому адреса будут достаточно далеко от исполняемого кода программы. Вы можете выделять новый блок каждый раз, когда вам нужно генерировать новую последовательность вызовов.
Как далеко? Я думаю, что это не так далеко. Расстояние должно быть, вероятно, умножено на линию кэша процессора и, таким образом, не так велико. У меня есть что-то вроде 64bytes (для L1). В случае динамически распределенной памяти у вас будет много страниц на расстоянии.
Основная проблема в этом подходе ИМО заключается в том, что код сгенерированных процедур будет выполняться только один раз. Таким образом, программа потеряет основное продвижение модели кэшированной памяти - эффективное выполнение циклического кода.
И в конце - эксперимент не выглядит так тяжело. Просто напишите несколько тестовых программ в обоих вариантах и оцените производительность. И если вы опубликуете эти результаты, я прочитаю их внимательно.:)
Ответ 4
Я нашел лучшую документацию от Intel, и это показалось лучшим местом для дальнейшего использования:
Software should avoid writing to a code page in the same 1-KByte
subpage that is being executed or fetching code in the same 2-KByte
subpage of that is being written.
Архитектуры Intel® 64 и IA-32
Справочное руководство по оптимизации
Это лишь частичный ответ на вопросы (тест, тест, тест), но более прочные числа, чем другие найденные мной источники.
3.6.9. Смешивание кода и данных.
Самомодифицирующий код работает правильно, согласно Intel требования к процессору архитектуры, но несет значительную штраф за исполнение. Избегайте самомодифицирующегося кода, если это возможно. • Размещение записываемые данные в сегменте кода невозможно отличить от самомодифицирующего кода. Записываемые данные в сегменте кода могут имеют такое же ограничение производительности, как и самомодифицирующийся код.
Кодирование сборки/компилятора Правило 57. (М-воздействие, общность L) Если (надеюсь, только для чтения) данные должны встречаться на одной странице с кодом, избегать поместив его сразу же после косвенного прыжка. Например, следуйте косвенный скачок с его наиболее вероятной целью, и поместите данные после безусловная ветвь. Рекомендации по настройке 1. В редких случаях проблема производительности может быть вызвана выполнением данных на кодовой странице в виде инструкции. Это может произойти, когда выполнение после непрямой ветки, которая не находится в кэше трассировки. Если это явно вызывает проблемы с производительностью, попробуйте переместить данные в другом месте или вставить незаконный код операции или инструкцию PAUSE сразу после непрямой ветки. Обратите внимание, что последние два альтернативы могут ухудшить производительность при некоторых обстоятельствах.
Кодирование сборки/компилятора Правило 58. (H impact, L generality) Всегда ставьте код и данные на отдельных страницах. Избегайте самомодифицирующегося кода везде возможное. Если код должен быть изменен, попробуйте сделать все сразу и сделать убедитесь, что код, который выполняет изменения, и код изменены на отдельных страницах с 4 Кбайтами или на отдельном выровненном 1 Кбайт подстраниц.
3.6.9.1 Самомодифицирующий код.
Самомодифицирующий код (SMC), который работает правильно на процессорах Pentium III и предыдущих реализациях правильно на последующих реализациях. SMC и кросс-модификационный код (когда несколько процессоров в многопроцессорной системе записывают в кодовая страница) следует избегать, когда требуется высокая производительность.
Программное обеспечение должно избегать записи на кодовую страницу в том же 1 Кбайт подстраница, которая выполняется или получает код в том же 2-Кбайт подстраница этого записывается. Кроме того, совместное использование страницы содержащий непосредственно или спекулятивно выполненный код с другим процессор как страница данных может инициировать SMC, которое вызывает весь трубопровод машины и отслеживать кеш-память. Это связано с самомодифицируемым кодом состояние. Динамический код не должен вызывать условие SMC, если код написанное заполняет страницу данных до того, как эта страница будет доступна как код.
Динамически модифицированный код (например, от целевых исправлений), скорее всего, страдать от состояния SMC, и его следует избегать, когда это возможно. Избегайте условия путем введения косвенных ветвей и использования данных таблицы на страницах данных (а не на кодовых страницах) с использованием регистро-косвенных вызовов.