raddbg
уходит около 2 секунд на выполнение 10000 итераций простого цикла, если внутри него расставлены точки останова. Для сравнения: без точек останова тот же самый цикл выполняется менее чем за 1 мс. Почему же так чертовски медленно? Давайте разберёмся, как условные точки останова обычно реализуются в современных отладчиках, откуда в данном случае берутся проблемы с производительностью и что можно сделать, чтобы ускорить процесс.
Обратите внимание: в этой статье речь идёт об отладчиках, работающих с нативным кодом – например, GDB, LLDB, Visual Studio C++. Отладчики для управляемых и скриптовых языков работают примерно так же, но могут отличаться детали реализации.
Просто проверить условие?
Говоря об «условных точках останова», я имею в виду самую обычную точку такого рода, в которой заложено определённое пользователем условие. Программа останавливается лишь в том случае, если данное условие результирует в
true
. Обычно условие определяется как выражение, написанное (почти) на таком же языке, что и оригинальная программа, причём, условие может обращаться к локальным и глобальным переменным. Следовательно, можно делать подобные вещи: 1. func main() {
2. for i, user := range getUsers() {
? 3. lastName := getLastName(user) // condition: (i % 10 == 0)
? 4. fmt.Printf("Hello, %s\n", lastName) // condition: (lastName == "Hippo")
5. }
6. }
Если бы вас попросили реализовать такую возможность в уже имеющемся отладчике, то, вероятно, быстро нашли бы решение. Нужно просто поставить обычную точку останова, проверять условие всякий раз, когда она срабатывает и, если условие результирует в false, тихо возобновить процесс. Для этого не требуется ничего менять в архитектуре, и такая возможность без всяких сложностей вносится в любой отладчик, уже поддерживающий обычные скучные точки останова.
Проследим код
Давайте проследим код, содержащийся в LLDB и посмотрим, как именно он реализован в данном случае. Чтобы получилось быстрее, давайте начнём с уровня API. Мы быстро заметим, что можно создавать точки останова самым разными способами (например, устанавливать их на строке, на символе или запрограммировать как регулярное выражение), но ни одним из этих способов мы, фактически, не можем описать условие (SBTarget.h#L596):
lldb::SBBreakpoint BreakpointCreateByLocation(
const char *file, uint32_t line);
lldb::SBBreakpoint BreakpointCreateByName(
const char *symbol_name, const char *module_name = nullptr);
lldb::SBBreakpoint BreakpointCreateByRegex(
const char *symbol_name_regex, const char *module_name = nullptr);
lldb::SBBreakpoint BreakpointCreateForException(
lldb::LanguageType language, bool catch_bp, bool throw_bp);
Правда, объект точки останова — это метод с характерным именем (SBBreakpoint.h#L76):
class LLDB_API SBBreakpoint {
public:
void SetCondition(const char *condition);
};
Этот метод не так много делает. Он просто сохраняет условие как строку в опциях точки останова:
SBBreakpoint::SetCondition -> Breakpoint::SetCondition -> BreakpointOptions::SetCondition
Пока всё соответствует нашей первой версии: условная точка останова — это та же обычная точка останова, вслед за которой сохранено условие. Далее происходит именно то, что мы ожидаем: при срабатывании такой точки процесс останавливается и отладчик проверяет, ассоциировано ли с данной точкой останова какое-либо условие. Если да — то он пытается автоматически его интерпретировать и продолжает работу, если условие результирует в false. Код для этой операции достаточно длинный, поэтому приведу здесь лишь наиболее релевантный для нас фрагмент (слегка переформатирован на основе Target/StopInfo.cpp#L447):
if (bp_loc_sp->GetConditionText() == nullptr)
actually_hit_any_locations = true;
else {
Status condition_error;
bool condition_says_stop =
bp_loc_sp->ConditionSaysStop(exe_ctx, condition_error);
...
if (condition_says_stop)
actually_hit_any_locations = true;
...
}
...
if (!actually_hit_any_locations) {
// В конце концов, у нас просто не оказалось таких мест, которые успешно прошли бы свои проверки на
// "попали ли в меня". Поэтому мы и не останавливаемся.
GetThread()->ResetStopInfo();
LLDB_LOGF(log, "Process::%s all locations failed condition checks.", __FUNCTION__);
}
Здесь интересна функция
ConditionSaysStop()
. До сих пор мы не прояснили, что представляет собой условие, мы просто передали его как строку. Что ж, оказывается, что в качестве условия может выступать (почти) любой код на C++! В официальной документации это прописано не очень чётко, но в одном из примеров находим выражение, в составе которого — приведение и вызов функции. Как LLDB с этим справляется? Вслед за реализацией ConditionSaysStop()
видим, что отладчик разбирает выражение, а затем выполняет его. Здесь предусмотрена приятная оптимизация, позволяющая кэшировать разобранное выражение между разными актами выполнения.Для разбора выражения отладчик
пользуется компилятором clang
, при помощи которого поддаются обработке очень сложные выражения. Строковое выражение преобразуется в промежуточное представление LLVM, которое далее может быть отправлено на выполнение. Если полученное таким образом промежуточное представление получается “достаточно простым”, то его может интерпретировать сам отладчик. Но, если в нём содержатся какие-либо нетривиальные инструкции, то выражение динамически компилируется, внедряется в целевой процесс и уже там выполняется.Почему так медленно?
Возможно, вы удивлены, но на самом деле именно так в большинстве современных отладчиков реализуются условные точки останова. Например, отладчик
delve
, применяемый в Golang, принимает в качестве условий Go-подобные выражения, а затем интерпретирует их всякий раз, когда программа попадает в точку останова. Другой пример — отладчик raddbg
для C/C++, код которого недавно был выложен в открытый доступ. Он сохраняет условия как строки выражений, а затем интерпретирует их всякий раз, когда срабатывает конкретная точка останова.При таком подходе выделяется две операции, которые можно заподозрить в замедлении кода. Это 1) остановка процесса в точке и 2) интерпретация условия. Давайте подробнее разберём каждую из них и посмотрим, где именно серьёзно страдает производительность.
Остановите мир
Быстро напомню, как действуют точки останова. Отладчик вносит патч в код процесса – пишет специальную инструкцию перехвата
int3
по тому адресу, где процесс должен остановиться. Когда ЦП выполнит эту инструкцию, возникает прерывание, процесс останавливается, и управление передаётся отладчику. Об этом можно почитать здесь https://eli.thegreenplace.net/2011/01/27/how-debuggers-work-part-2-breakpoints или здесь http://www.nynaeve.net/?p/=80 — в этих статьях тема объяснена гораздо лучше.Если приходится проверять условие при каждом фактическом попадании в точку останова, то возникает такая проблема: отладчик действительно должен всякий раз прекращать выполнение программы. Если впоследствии окажется, что условие результировало в false, мы всё равно несём издержки, связанные с остановкой и возобновлением процесса. Эти издержки не так заметны на тех путях кода, которые выполняются нечасто, но точки останова, расставленные в плотных циклах, определённо влияют на производительность. Даже в одном цикле «остановка-возобновление» отладчику приходится проделать следующие операции:
- (допустим, мы уже где-то установили точку останова)
- При попадании в точку останова – срабатывает прерывание, и процесс ставится на паузу
- Затираем
int3
исходной инструкцией - Возобновляем выполнение процесса в режиме «выполнение одной инструкции»
- Вновь записываем
int3
- Возобновляем процесс
Выполняем код, пока выполняется код
Не менее сложной задачей может оказаться интерпретация условий точки останова. В большинстве отладчиков такая функция уже предусмотрена в той или иной форме, поскольку она часто задействуется для работы со сценариями и визуализации данных. Как правило, поддерживается не весь «исходный» язык, а только его подмножество или даже какой-то похожий скриптовый язык. Увы, реализовать интерпретатор на полнофункциональном C++ просто для интерпретации условий — это далеко не тривиальная задача :D
Производительность при вычислении выражений СИЛЬНО варьируется от отладчика к отладчику. Как я уже упоминал выше, LLDB в самом деле пытается поддерживать (почти) любой валидный код на C++, при этом компилирует и выполняет его при помощи
clang
. Можете себе представить, как медленно это происходит. В delve
содержатся условия, написанные на Go, а этот язык гораздо лучше поддаётся синтаксическому разбору, чем C++. Получающиеся в итоге абстрактные синтаксические деревья из выражений можно использовать многократно, но, если вы попадаете в длинное и «тяжёлое» выражение, то дела идут медленно. Я не знаю в подробностях, каков синтаксис того языка выражений, который поддерживается в raddbg
, но, подозреваю, он не позволит вам никаких чрезмерных вольностей. Следовательно, на этом языке вполне реально всякий раз выполнять синтаксический разбор и вычисление выражения с нуля.Теоретически вычисление выражений можно оптимизировать настолько, что этот процесс станет почти незаметным. Но издержки, связанные с остановкой процесса, никуда не исчезнут. Однако, если мы хотим серьёзно повысить производительность, то должны продумать, как бы нам не останавливать процесс всякий раз, когда требуется проверить условие.
Путешествие к центру процесса
Как мы уже знаем, отладчик может пропатчить код процесса — именно так работают программные точки останова. А что, если вставлять в код не инструкцию перехвата, а какой-нибудь код, проверяющий истинность условия и выполняющий останов лишь в случае, если условие равно true?
Теоретически ситуация выглядит отлично, но на практике, чтобы такого добиться, нужно решить ряд проблем:
- Инструкция перехвата (
int3
) — это опкод размером всего в один байт, поэтому вставляется куда угодно. «Код для проверки условия» может быть большим (даже очень), поэтому его нельзя просто взять и поставить в любое место. - Код процесса — машинный, поэтому первым делом нам потребуется скомпилировать наше условное выражение до машинного кода. Эта задача сильно зависит от платформы, в принципе, нам здесь потребуется реализовать настоящий компилятор.
jmp
в тех точках, где нам потребуется проверить условие. На переход требуется гораздо больше одного байта, поэтому везде такой способ применяться не может, однако, во многих случаях этого может быть достаточно. Если выделять память в программе, то из-за этого могут возникать побочные эффекты, но, послушайте, ведь мы хотели всё сделать по-быстрому, верно? Что касается второй проблемы — здесь есть варианты. В некоторых отладчиках предоставляются «агенты» или «прицепы», в которых содержится некоторая логика и которые можно внедрять в процесс. Хороший пример — GDB и предусмотренный в нём внутрипроцессный агент. Эта возможность проектировалась как раз для тех ситуаций, когда остановка процесса нежелательна, поэтому предпочтительнее будет в том или ином виде реализовать внутрипроцессное выполнение.
Агент – это разделяемая библиотека, которая загружается в целевой процесс. Агент оснащён маленькой виртуальной машиной, которая может вычислять простые выражения на основе байт-кодов. Таким образом, нам не требуется компилировать наше выражение до машинного кода, достаточно превратить его в такой байт-код, который будет понятен агенту. Разумеется, со сложными выражениями этого может быть недостаточно, но мы уже серьёзно упрощаем ситуацию, поскольку не приходится иметь дел с деталями, специфичными для конкретных платформ и архитектур.
Подобные приёмы используются и с программами, которые опираются при работе на динамическое инструментирование. Например, профилировщик производительности Orbit может динамически пропатчивать код процесса, собирая таким образом точную информацию о профилировании, а не полагаясь на выборку.
Если выполнять условия останова непосредственно в коде процесса, то можно радикально (я имею в виду, РАДИКАЛЬНО) повысить производительность. Отладчик
UDB
использует внутрипроцессный агент, уже известный нам из примера с GDB, и с его помощью интерпретирует условия останова. В таком случае измерения показывают, что производительность улучшается примерно в 1000x раз – https://www.youtube.com/watch?v=gcHcGeeJHSA. Если хочется выжать производительность до капли и двигаться ещё быстрее — что ж, придётся компилировать выражения. Не знаю, есть ли какой-нибудь отладчик, в котором такая функция поддерживается нативно, но LLDB к этому очень близок. В нём есть встроенный динамический компилятор, который как раз используется для интерпретации условий в точках останова, но он всё равно работает по стандартной модели «остановить-интерпретировать-продолжить». Уже некоторое время витает в воздухе идея о том, чтобы при помощи динамического компилятора ускорять работу условных точек останова. Но, хотя подготовительная работа для этого уже во многом выполнена, остаётся доделать ещё немало нетривиальных деталей реализации. Может быть, когда-нибудь…
P.S. Обращаем ваше внимание на то, что у нас на сайте проходит распродажа.
Комментарии (10)
DustCn
31.07.2024 09:57+1Он реально каждый раз перекомпилирует сложное условие, или работает как JIT - если попал в точку останова - скомпильнул уже один раз, а потом достает готовый код из кармана?
VBDUnit
31.07.2024 09:57В Visual Studio, судя по всему, да. Иного способа объяснить чудовищные тормоза условных бряков я не вижу
unreal_undead2
31.07.2024 09:57+4Подозреваю, что всё таки не перекомпилирует, а интепретирует. Но да, чаще быстрее вставить в код обычный if, перекомпилировать и поставить обычный breakpoint внутри.
VBDUnit
31.07.2024 09:57Да, скорее всего, Вы правы. Удивительно, что они до сих пор не сделали ленивую компиляцию. Сверхнизкая скорость работы делает эти условные бряки, фактически, неюзабельными
Panzerschrek
31.07.2024 09:57Касательно компиляции условий в нативный код: можно ведь и вручную для целей отладки вставить код проверки и поставить точку остановки внутрь if(condition){}. Плюсы - это работает быстро и даже с самым простым отладчиком, который не умеет в сложные выражения. Минусы - это не работает, когда необходимая проверка зависит от конкретных условий выполнения, например, от адреса какой-то переменной.
unreal_undead2
31.07.2024 09:57Так делали и делают ) Но всё таки с нормальным JIT в дебаггере было бы удобнее, перекомпиляция и линковка дело не такое уж быстрое (если у нас проектик масштаба того же clang или mySQL). Хотя я в любом случае чаще обхожусь отладочной печатью.
Panzerschrek
31.07.2024 09:5710 минут назад в своём проекте на основе кодовой базы clang менял один файлик. Перекомпиляция заняла считанные секунды.
Компиляторы как раз хорошо оптимизируются в сторону сокращения цикла изменил/скомпилировал/запустил - как раз для убыстрения отладки. Ну если это не Rust конечно, где компиляция весьма медленная (хотя и там с этим борются).unreal_undead2
31.07.2024 09:57Зависит от того где этот файлик - в своём коде, из которого генерируется отдельный бинарник, или в самой llvm (т.е. после компиляции надо перелинковать libLLVM.so).
VBDUnit
31.07.2024 09:57В целом и от размера проекта зависит, и от среды, и от языка, и от компилятора. Да, сейчас всё оптимизированное, да, процессоры быстрые. Но и проекты бывают очень большие. И не очень оптимальные.
unreal_undead2
Так всё таки как сейчас работает lldb? Практически в начале сказано
что я читаю как "JITим код условия и инструментируем приложение, смешивая его код с вычислением условия". Но дальше расписываются проблемы со сложностью интепретации и заявляется, что lldb только "очень близок" к нативному вычислению условий без интепретации.