/*
- * 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. Всех девчонок с праздником!
За сим к опросам…
Комментарии (48)
sand14
08.03.2018 21:31-2И всё как бы ничего, однако меня всё же насторожила такая казалось бы пустячная «оптимизация» с уклоном в арифметику.
Помимо того, что не следует смешивать разные блоки кода, и операторы ветвления в один блок, используя арифметические псевдооптимизации — понятно, что это общее правило, и долго объяснять, почему это так — конкретно в этом коде серьезный залет, который не приводит ни к какой оптимизации, а только вредит.
В исходном коде были оператор ветвления и сложение, а в новом… еще и умножение.
(На этом можно и закончить, и не говорить о таких пустяках, что в новом коде почему то и операторов сложения больше, и операторы вычитания появились...)
sebres Автор
08.03.2018 21:37-1операторы сложения? где? между константами? учить мат-часть…
sand14
08.03.2018 21:45+1Уважаемый, в приведенном вами коде
index = opnd + (opnd <= TCL_INDEX_END)*(objc - 1 - TCL_INDEX_END);
нет оператора сложения (как и умножение, так и вычитания) между константами, кроме «1 — TCL_INDEX_END»
Надеюсь, не будете выкручиваться, утверждая, что opnd и objc — константы.
Так что учите матчасть сами.
sebres Автор
08.03.2018 22:01там у вас не стояло слово «лишний“ разве? если нет, тогда звиняйте (уже перебрал значит)…
Только умножение он хотел вместо условного перехода (то что вы назвали „оператор ветвления“), так что — ваше „ещё и“ все равно мимо.khim
09.03.2018 17:09+1Проблема в том, что усножение — весьма дорогая операция. А ветвление часто можно на cmov заменить.
На старых процессорах без cmov оптимизация не имеет смысла. На новых процессорах с cmov — тем более.
Так где оно может, хотя бы теоретически, иметь смысл???sebres Автор
09.03.2018 17:42Проблема в том, что умножение — весьма дорогая операция.
Да ну? Тот же imul в интелях всего с максимально 3 cycle latency если мне не изменяет память.
А условный переход, он как привило длиннее (в реальности).
И потом у ув. профессора Портера здесь было умножение на 0/1, т.е. как-раз как бы с надеждой заменить при компиляции на что-нибудь более подходящее.
Просто он забыл, что компиляторы давно и буквально следуют первому правилу при процессорной оптимизации (машинного кода):
Always avoid a branch if you can.
Т.е. что условный переход на таком коротком
if
тоже не получится...
Ну и еще забылось что имеем: branch prediction / ILP, instruction reordering, instruction, TLB and/or data cache (hits/misses/page faults) т.д., и register renaming и прочее впридачу.
Работать компилятором нынче очень сложно. :)
Так где оно может, хотя бы теоретически, иметь смысл???
Я видел вполне себе хорошую реализацию
switch
наimul
для коротких выравненных кусков кода. Даже очень. Побило всё остальное, вплоть до многоуровневых jump-tables, и прочие дериваты.
Другое дело что оно, так-то, не универсально конечно.
И не забывайте, что кэш процессора ограничен. Часто Ofast оптимизация, создающая код чуть длиннее чем O2, много быстрее на искусственных тестах, пока в бою не проявляются проблемы на multithreading, паразитной нагрузке и т.п.
khim
09.03.2018 18:43Да ну? Тот же imul в интелях всего с максимально 3 cycle latency если мне не изменяет память.
Изменяет. Pentium — 9/11, более старые процессоры — ещё медленнее. И даже на Skylake может до 4 циклов занимать.
Но с учётом того, что на процессорах новее Pentium Pro естьcmov
критическое число — 9.
И потом у ув. профессора Портера здесь было умножение на 0/1, т.е. как-раз как бы с надеждой заменить при компиляции на что-нибудь более подходящее.
Напишите тогда уж XOR, тогда есть шансы.
Давно, но не буквально. Это мы уже обсуждали.Просто он забыл, что компиляторы давно и буквально следуют первому правилу при процессорной оптимизации (машинного кода):
Always avoid a branch if you can.
Я видел вполне себе хорошую реализацию
Я, наверное, ничего не понимаю в колбасных обрезках, но в подобных случаях ведь умножать нужно на константу, а не на переменную… то есть этоswitch
наimul
для коротких выравненных кусков кода. Даже очень. Побило всё остальное, вплоть до многоуровневых jump-tables, и прочие дериваты.lea
, а неimul
… сложение, а не умножение…sebres Автор
09.03.2018 20:22> И даже на Skylake может до 4 циклов занимать.
2-operand form, при умножении на 0/1? Вы серьезно?
> Я, наверное, ничего не понимаю в колбасных обрезках
Наверное… :) выше под умножением понималось умножение в сишном коде, как его уже реализует компилятор не очень интересно.
Конкретно в контексте того упомянутого `switch`, ужо не упомнить (а думать некогда), но чисто для примера:
```asm
; в EAX передается значение для switch-а…
; дефоулт всё что больше 50, размер каждой ветки (паддинг) = 24
cmp EAX, 50
ja switch_default
imul EAX, const_padding_size ;# EAX *= 24
jmp EAX
sw0th:
...; instr (24 bytes)
sw1st:
...; instr (24 bytes)
sw2nd:
...; instr (24 bytes)
…
sw50th:
...; instr (24 bytes)
switch_default:
ret
```khim
09.03.2018 21:182-operand form, при умножении на 0/1? Вы серьезно?
При умножении на 0/1 — скорее всего нет. Я не знаю как оно у них зависит от аргументов. У Agnerа таблицы не настолько детализованы.
Наверное… :) выше под умножением понималось умножение в сишном коде, как его уже реализует компилятор не очень интересно.
Не очень понятно как сишный код сочетать с «короткими выравненными кусками кода» — это уже явно ассемблерный уровень.
imul EAX, const_padding_size ;# EAX *= 24
Тут я бы два LEA поставил. Или LEA + SAL, как компилятор делает:
$ cat test.c int foo(int x) { return x*24; } $ gcc test.c -march=native -O3 -S -o- .file "test.c" .text .p2align 4,,15 .globl foo .type foo, @function foo: .LFB0: .cfi_startproc leal (%rdi,%rdi,2), %eax sall $3, %eax ret .cfi_endproc .LFE0: .size foo, .-foo .ident "GCC: (Debian 6.3.0-18+deb9u1) 6.3.0 20170516" .section .note.GNU-stack,"",@progbits
Тут та же самая петрушка, что и с переходами. Не всё то, что выглядит как умнодение в C является умножением на ассемблере.
P.S. А вообще ваш трюк, как и многое подобное, не нов и не уникален. GCC старых версий подобное творит. Сlea
, конечно, не сimul
. Впрочем последние версии не заморачиваются…
Cheater
08.03.2018 22:11-1> int index;
> if ((index = opnd) <= (-2)) {
В чём проблема инициализировать index сразу значением opnd в примере test2? (int index = opnd) Вы приводите аргументы против смешивания математики и flow в одном выражении, но при этом именно это и делаете в test2.sebres Автор
08.03.2018 22:36-1В чём проблема инициализировать index сразу значением opnd
Как раз это компилятору все равно, а в такой форме оно нагляднее, и речь тут не о том… Да и в оригинальной функции оно не так...
но при этом именно это и делаете в test2.
Совершенно нет, и при "ассемблерном мышлении" как раз проще — результат присвоения в eax, потом cmp eax, и условное действие (переход или присвоение-пересылка).
Все дело в том, что условный переход действие не дешевое, и раньше их часто заменяли, умножением в том числе...
potan
08.03.2018 22:38что там компилятор наоптимизировал, выходит иногда боком при исполнении на современном железе с собственной оптимизацией внутри, типа внеочередное или спекулятивное исполнение, параллелизм на уровне команд (ILP) и прочее.
Опция -m arch=686 должна в этом случае помогать.
smer44
08.03.2018 23:03количество комманд ассемблера не показатель, напиши этот код в большом цикле и посмотри что быстрее, мне например не нравится умножение как дорогая операция, я бы сделал
index = opnd + (~((opnd<-1)-1))&(1 + objc)smer44
08.03.2018 23:16короче быстрее всех просто index = (opnd < -1)? opnd: opnd+1+indexEnd;
вариант с магией битов 2 место, с умножением — 3 местоdemimurych
08.03.2018 23:40условный переход всегда медленней умножения. Иногда медленнее на порядки если процессор неправильно предскажет ветвление.
smer44
09.03.2018 00:28да вроде нет, цикл на 100 миллионов операций 10% быстрее работает паскуда, то есть ещё несколько быстрее за вычетом других комманд, а фокус с битами на 3-5% быстрее умножения, или я запускаю как то не так
tyomitch
09.03.2018 14:59Разница будет зависеть от целевой архитектуры, используемого компилятора, опций компиляции, конкретного процессора, и т.д. и т.п. — нельзя сказать, что «ветвление однозначно быстрее битовых фокусов» или наоборот.
khim
09.03.2018 18:51Вообще нет. Правильно предсказанный переход — это вообще 0 тактов. Вот неправильно предсказанный — дело другое.
Но при этом нужно учитывать, что условный оператор != условный переход.
Gumanoid
08.03.2018 23:46А разве результат сравнения True гарантированно будет равен 1? Я думал там может получиться любое ненулевое число.
fshp
09.03.2018 00:55В C нет типа Boolean. Типом результата сравнения является int.
The == (equal to) and != (not equal to) operators are analogous to the relational
operators except for their lower precedence.108) Each of the operators yields 1 if the
specified relation is true and 0 if it is false. The result has type int. For any pair of
operands, exactly one of the relations is true.
http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1570.pdf
Страница 96.fshp
09.03.2018 00:57Конструкция "if(foo)" равносильна "if(foo!=0)". Именно поэтому любое ненулевое выражение является истинным.
bsivko
09.03.2018 00:01Сталкивался с подобными эффектами при оптимизации ещё лет 10 назад и с того времени отношусь с осторожностью. Но такого как вы анализа не делал, спасибо за статью.
sebres Автор
09.03.2018 00:14да разве то анализ… то просто один коммит, по моему неплохо подходящий в качестве примера
u_story
09.03.2018 08:52+2А использование -O3 и -Os как-нибудь повлияет на результат?
А можно другие компиляторы clang и VS так же себя ведут?tyomitch
09.03.2018 13:28+2Под clang код test1 и test2 получается одинаковым.
Под gcc для AArch64 тоже получается одинаковым.
В общем, не настолько компиляторы глупы, как sebres их пытается выставить.
Главным аргументом при выборе между двумя вариантами должна быть читаемость кода, а не то, в какую последовательность команд он оттранслируется конкретной версией компилятора с конкретным набором флагов.sebres Автор
09.03.2018 14:29не настолько компиляторы глупы, как sebres их пытается выставить.
А нельзя ткнуть носом, где я пытаюсь их такими выставить?
Наоборот я их всегда, всячески хвалю, например — "Не стоит забывать про оптимизацию в современных компиляторах, которая достигла если не совершенства, то уже очень и очень на уровне."
И потом я же написал — "во всех вариантах (и даже с максимальной оптимизацией) test1 ничем не лучше, а то и проигрывает test2".
Про собственно clang же много историй можно рассказывать. Он у меня был любимый долгое время… даже кастомная собственная сборка с измененным поведением при оптимизации.
Потом я столько всего под clang словил, что окончательно забросил...tyomitch
09.03.2018 14:48Я имел в виду:
смешивается в кучу математика и собственно flow процесса, что например усложняет разбор его для компилятора
--здесь уместнее было написать «усложнял», потому что более навороченным компиляторам уже и такой смешанный код по зубам.sebres Автор
09.03.2018 14:59Здесь конкретно да, а в других примерах ещё как посмотреть...
Про "более навороченным компиляторам" тоже могу поспорить.
Оно местами настолько "навороченно", что неявное (а то и неопределённое) поведение на нормальном течении словить можно (было как-то, долго боролся, помог откат на пред. версию).
Из последнего вот к примеру, expressive diagnostics у них вообще таким местом реализован, что варнинг на варнинге и третьим погоняет (даже без "-Wextra"). А если например в проекте варнинг к ошибке преравнивается?
sshmakov
09.03.2018 09:02Интересно, а если убрать index, который здесь совсем не нужен, а делать все операции над передаваемым параметром opnd, то результат оптимизации изменится?
Danik-ik
09.03.2018 10:03+11А мне кажется, что самое неправильное в данном коммите — полное уничтожение читаемости. До коммита — ясно, что, почему и как, а после — сим-салябим, ахалай махалай, тыдыщ — ЧУДО!
Даже если бы оно работало быстрее, сокращение комментария вместо добавления обоснования применённого метода — чистой воды вредительство.Danik-ik
09.03.2018 10:06+6То есть в тексте о б этом сказано, но мимоходом, и на самом последнем месте. А по мне — надо на первом.
vscrub
09.03.2018 10:19Я могу ошибаться, но 3 операции с памятью в test2 могут выполняться дольше двух в test1. И, следовательно, test2 будет медленнее, несмотря на то, что короче.
Поправьте меня если я ошибся.arteast
09.03.2018 11:02+1Поправляю — там вообще нет операций с памятью, кроме загрузки значений операндов со стека (которые одинаковы в обоих вариантах). lea — не операция с памятью, несмотря на вид ее второго параметра.
Scf
09.03.2018 11:22+3Не хватает варианта "не занимаюсь пессимизацией кода прямо в процессе разработки".
Я за алгоритмическую оптимизацию — если есть возможность сделать из квадратичной сложности линейную или из линейной логарифмическую — почему нет?
Важно только помнить, что логарифмическая сложность не всегда быстрее линейной :-)
novikovag
09.03.2018 12:18-1А как же мохнатое О люто форсимое смузихлебами оторванными от реальности?
bolk
09.03.2018 18:02-1«Мохнатое О» не имеет отношения к смузи. У нас в университете (КФУ, ВМК) это в 90-х на первом курсе читали. Покойный Самитов сейчас в гробу вертится от вашего «смузихлёбства».
novikovag
10.03.2018 07:35-4Лучше бы вас там программы писать учили, а не бесполезным фантастическим теориям.
bolk
10.03.2018 07:48+4Вы это серьёзно что ли? О-большое — необходимая часть для понимания сложности алгоритмов и написания программ.
novikovag
10.03.2018 08:48-4К реальному миру не имеющее ни какого отношения, топик тому пример.
khim
10.03.2018 18:19+1Топик описывает что нужно делать после того, как с «big O» всё в порядке.
Сколько я видел «сурово оптимизированных» программ с пресловутым алгоритмом маляра Шлемиэля… Не сосчитать. И они все легко «обходятся» совершенно неоптимизированным кодом, в котором пресловутого «Шлемиэля» нету.
А вот если вы всех «Шлемиэлей» извели — тогда уже можно и о тактах думать.
CryptoPirate
09.03.2018 12:18+4Не думаю, что в статье пример из этой области, но есть небольшая тонкость по поводу «убрать условные переходы». В криптографических алгоритмах стараются избегать всех условных переходов которые зависят от используемых данных (просто условный переход в цикле не в счёт). В таких случаях к чёрту оптимизацию в пару циклов, там важнее чтобы не было атак по сторонним каналам (по времени, например). Так что, в некоторых отдельных случаях такой подход полезен.
mike_y_k
09.03.2018 14:32Таки каждому овощу свою грядку.
Тут ещё про мелкие процессоры с их прошивками вспомнить с их проблемами…
Но в примере во первых отсутствие внятного комментария и обоснования применённого решения больше всего напрягает изначально.
За такие включения даже только для себя любимого стоит по рукам и по голове…
caway
09.03.2018 16:38Данные манипуляции лишь запутывают компилятор, не давая ему сделать качественную оптимизацию.
alexeibs
09.03.2018 19:42clang, начиная с версии 3.7, оба варианта компилирует одинаково.
khim
09.03.2018 21:26При этом версия 3.6 лучше компилирует как раз «однозначно более быстрый вариант»… Так что разрабатывай вы это всё под Mac несколько лет назад — был бы повод добавить комментариев и эту версии принять.
Впрочем есть ощущение, что сегодня, сейчас, закладываться на более старую версию clang'а не стоит — тем более, что и icc и MSVC лучше обрабатывают test2.
MechanicZelenyy
Да, совсем недавно коллега сталкивался с подобным случаем, когда убрав из куска кода (написанного под компиляторы ещё плохо умеющие оптимизировать код) различные оптимизации, получил прирост производительности в 20%.