Оптимизация кода в уме, или «Ну так же однозначно быстрее»
Намедни работая над одной ошибкой в одном опенсорсном проекте, увидел как коллега (тоже работающий параллельно над той же проблемой) залил такой вот коммит [31a078bec7]:
/*
- * Select the list item based on the index. Negative operand means
- * end-based indexing (-2, ...), and -1 means out of range.
+ * Decode end-offset index values.
*/
- if (opnd < -1) {
- index = opnd+1 + objc;
- } else {
- index = opnd;
- }
+ index = opnd + (opnd <= TCL_INDEX_END)*(objc - 1 - TCL_INDEX_END);
pcAdjustment = 5;
Изменение само по себе правильное (теперь TCL_INDEX_END
есть константное определение (-2)
).
И грубо говоря в уме это разворачивается в следующее (все переменные int):
index = opnd + cmp(opnd, (-2))==>(0 | 1) * (objc - 1 - (-2));
Т. е. он как бы тем самым хотел сэкономить здесь один условный переход.
И всё как бы ничего, однако меня всё же насторожила такая казалось бы пустячная «оптимизация» с уклоном в арифметику.
Во первых, это изменение касается самой «главной» функции в этом проекте (TEBCresume
), ибо она ответственна за исполнение байт-кода (JIT скомпилированных инструкций языка TCL). По этой причине эта функция еще и самая большая (порядка 6 тысяч строк + примитивы и макросы) и одна из самых сложных в кодовой базе проекта, с множественными `goto`, головоломными макросами для работы со «стеком» исполнения, свёртка/развертка NRE (nonrecursive evaluation) и т.д. и т.п.
Т.е. изменения этой функции нередко рассматриваются под лупой, а то и под микроскопом (т.к. бывало что даже незначительные модификации могут перевернуть весь код этой функции с ног на голову)…
Во вторых, по роду деятельности мне часто приходится оптимизировать сишный код, разглядывая его ассемблерное отражение, выжимая доли микро-, а то и нано-секунд, и я часто вижу, что там очень всё совсем не однозначно бывает. Как минимум иногда разворачивая такие вот «экономящие» условный jump конструкции обратно в if
или даже if/else
, я видел улучшение как и в результирующем ассемблерном коде, так и явно при конечном сравнении производительности результатов исполнения.
Собственно к чему я все это писал — хотелось на примере показать как оно бывает, ну и раз уж коснулись этой темы, собрать немного статистики. Посему пара опросов в конце статьи…
Не стоит забывать про оптимизацию в современных компиляторах, которая достигла если не совершенства, то уже очень и очень на уровне.
Другое дело, что то, что там компилятор наоптимизировал, выходит иногда боком при исполнении на современном железе с собственной оптимизацией внутри, типа внеочередное или спекулятивное исполнение, параллелизм на уровне команд (ILP) и прочее.
Но это уже тема совсем другой статьи.
По понятным причинам, не будем разбирать это всё в оригинальной функции…
Поэтому собсвенно в качестве примера две функции test1 (оптимизация в арифметике), и test2 (flow как оно есть с одним `if`), и результирующий ассемблер для обоих функций в разных версиях компилятора, на примере gcc, проверялось с -O2
и -Ofast
(практически без, а в trunk совсем без изменений):
c/c++ | |
---|---|
|
|
x64, gcc 5.1: | |
|
|
x64, gcc 7.3: | |
|
|
x64, gcc (trunk): | |
|
|
x86, gcc 5.1: | |
|
|
x86, gcc 7.3: | |
|
|
x86, gcc (trunk): | |
|
|
Наглядней (с подсветкой и т.д.) это можно увидеть тут или тут.
Что имеем в остатке:
- во всех вариантах (и даже с максимальной оптимизацией)
test1
ничем не лучше, а то и проигрываетtest2
test2
реализуется компилятором совсем без условного перехода, как можно было подумать, например просто используя условнуюcmovl
- даже нативный байт-код
test2
более читабелен - байт-код
test2
короче, местами более удобен для ILP/SEP, меньше размывает CPU-кэш (как минимум instruction cache).
Тут кстати наглядно виден и некоторый прогресс в развитии компилятора.
Ну и в полном виде всей функции компилятор немного изменил весь flow, что на некоторых искусственных тестах производительности показало падение до одного — двух процентов (может отличатся для других компиляторов/систем/платформ и т.д.).
Общий же тест исполнения компилированного TCL-кода (без паразитной нагрузки) показывает незначительное падение (доли процента), что может быть просто связано с флуктуациями энергии единицы объёма вакуума.
Теперь почему как мне кажется не нужно заниматся такой «слепой» оптимизацией в уме:
- смешивается в кучу математика и собственно flow процесса, что например усложняет разбор его для компилятора
- при оптимизации в процессе компиляции у математики ветвление немного слабее чем у того же flow-дерева и inlining-процессов, нужен контроль за overflow, неявным поведением и т.д. (так сложилось исторически, грубо говоря у математики немного смещенный фокус при оптимизации оной).
- исходный код становится неявным, как минимум тормозит review и анализ (иногда просто очень трудночитаем).
Этот список можно продолжать до бесконечности, например при компиляции исходников под конкретный процессор (если поддерживается компилятором), явное требуемое поведение оставит компилятору (или новой версии его) больше шансов подобрать оптимальный сценарий, например используя какие-нибудь новые инструкции процессора, или вырезав «дубликаты» перевернув весь код полностью и т.д.
Я не говорю, что не нужно оптимизировать совсем, но если делать это, то вооружившись лупой, поглядывая в нативный результат компиляции, и выполняя замеры скорости выполнения, профайлинг производительности (как на конкретной функции, так и всего кода целиком) и т.п.
Я часто видел как некоторая оптимизация, однозначно вроде бы улучшающая байт-код (меньше и короче инструкции, оптимальная загрузка регистров, и т.д.) на несколько процентов просаживает исполнение его на реальных тестах многопоточно и/или под параллельной паразитной нагрузкой.
P.S. Всех девчонок с праздником!
За сим к опросам…