Как стать автором
Обновить

Комментарии 73

Очередной фанатик жирожабы... До чего Хабр скатился...

Вы не поверите, но Java/CoreNET я не люблю. А что касается JS/Python/Lua и других языков с динамической типизацией, то я их ненавижу. ЛЮТОЙ НЕНАВИСТЬЮ. Такой, какой может быть только у разработчика оптимизирующего бэкенда компилятора. Но если их используют, то что делать?

А да, забыл добавить, а еще я НЕНАВИЖУ C++, если только это не Си с классами. А комитет по стандартам C++ канделябрами, канделябрами. Что б не повадно было.

А на чём писать-то тогда? Сразу в байткоде LLVM?

Так пишите на чем хотите. Какое Вам дело до моих личных предрассудков :)? Ну плачут разработчики оптимизирующих бэкендов по ночам. Но это же никому не видно и не слышно :)

На Haskell же =)

Есть очень быстрые языки с псевдо динамической типизацией, Julia

НЛО прилетело и опубликовало эту надпись здесь

Термин Си с классами - это слова самого Бьерна нашего Страуструпа. Если он не классик, то кто тогда классик.

Что касается про любовь, то мы покупаем или продаем? С позиции c++ писателя своих проектов и с позиции c++ читателя чужих проектов язык C++ будет выглядеть совершенно по-разному. Несколько дней и бессонных ночей сидеть и втыкать на одной template-строчкой? Ну не все же получают от таких ролевых игр удовольствие :). И отсутствие смайлика не всегда означает отсутствие иронии.

НЛО прилетело и опубликовало эту надпись здесь

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

Еще меня бесит желание создателей языка C++ побороть указатели. Зачем ? Если человек взял в руки острый нож чтобы резать мясо, то он должен понимать последствия неаккуратного обращения с инструментом. Нафига искусственно притуплять инструмент ?

Это так, мысли вслух.

Java здесь сбоку, как некий ориентир. Речь о написании оптимального интепретатора VM на ассемблере.

Исходя из анализа ассемблера в трассе он не может быть быстрее чем asmopt - судите сами:

// скорее всего это iload_3
0x7fffe49216c3:      movzbl 0x1(%r13),%ebx
0x7fffe49216c8:      inc    %r13
0x7fffe49216cb:      movabs $0x7ffff7d7ebc0,%r10
0x7fffe49216d5:      jmpq   *(%r10,%rbx,8)
// скорее всего это iload_1
0x7fffe49215be:      push   %rax 
0x7fffe49215bf:      mov    -0x8(%r14),%eax 
0x7fffe49215c3:      movzbl 0x1(%r13),%ebx 
0x7fffe49215c8:      inc    %r13 
0x7fffe49215cb:      movabs $0x7ffff7d7ebc0,%r10 
0x7fffe49215d5:      jmpq   *(%r10,%rbx,8)

В обоих командах повторяется загрузка константы в %r10 с помощью movabs - в asmopt адрес таблицы всегда лежит в регистре. Та же история повторяется и дальше в каждой операции.

Первая команда вообще ничего не делает кроме продвижения к следующему байткоду (или перед ней есть что-то содержательное и трасса обрезана неправильно)

Идем дальше:

// скорее всего это if_icmpge     34
0x7fffe49253c7:      mov    (%rsp),%edx 
0x7fffe49253ca:      add    $0x8,%rsp 
0x7fffe49253ce:      cmp    %eax,%edx 
0x7fffe49253d0:      jl     0x7fffe4925413 
0x7fffe4925413:      movzbl 0x3(%r13),%ebx 
0x7fffe4925418:      add    $0x3,%r13 
0x7fffe492541c:      movabs $0x7ffff7d813c0,%r10 
0x7fffe4925426:      jmpq   *(%r10,%rbx,8)

Тут происходит чтение из стека 4 байт, сдвиг его указателя как если бы мы прочитали 8 байт а дальше начинается ерунда: jl прыгает на адрес, который идет сразу за ним, но этот адрес на 0x43 отстоит от jl. Т.е. очевидно, что трасса показана неправильно и автор вообще ничего не понимает в ассемблере, иначе бы не допустил такую ошибку.

Дальше смотреть не стал, с такими исходными данными не может быть доверия к выводам на их основе. А без знания ассемблера говорить о производительности интерпретаторов/компиляторов - беспредметно.

Ассемблер приведён для ознакомления. Где в статье есть выводы на основе ассемблера, кроме количества байт-код инструкций? Кстати Вы можете определить реальное количество тактов процессора только по asm-коду? Отлично! А сколько будет инструкций микрокода? Вы же знаете, что в разных x86 процессорах микрокод разный и микро архитектура разная. Знаете? А что такое микрокод x86 знаете, ведь знаете? Ну хотя бы свои данные от запуска с time приведете?

Да и самая суть не в этом. Ну будет ваш интерпретатор на каких-то версиях x86- процессоров чуть быстрее, все равно сравниваются разные контексты. ГДЕ ОБЕЩАННЫЙ ОБГОН JIT/AOT я спрашиваю?!

наверное, что-то можно было бы сделать если получить платформонезависимый промежуточный код (IR) - такой же как если бы его сделал интерпретатор, а потом его оптимизировать с помощью LTO или даже PGO/LTO - LLVM toolchain, получим быстрое исполнение. Но не уверен, что это вообще в контексте вашего спора. Просто сказал потому что занимаюсь этим для выжимания скорости из rust.
Сравнивать ассемблер - дело неплохое. Если бы не поленились сделать в интелловой нотации - я бы прошерстил и прокомментировал, а так - сами грызите этот AT&T ;)
То что нагрыз imitron - звучит правильно. jl перепрыгивает что-то. Если он перепрыгивает редкоиспользуемый код, который вы опустили, то это свидельство неоптимальной программы. Так не должно быть. Jump должен быть редким. Или там должен быть hint для предсказания ветвления что jump частый.

Да, не в контексте. Rust - это язык со статической компиляцией. И на том спасибо разработчикам языка.

Если разработчики OpenJDK допустили явную неоптимальность, значит их интерпретатор можно ускорить. Если вы знаете как, то наверное стоит оформить на них perf багу. Только учтите иногда они вставляют участки, которые могут модифицировать в динамике. Чтобы моделировать программное прерывание например. Из другого потока. Поэтому они могут просто резервировать место для будущих длинных переходов.

При чем здесь резервирование? Вы пишете в скрипте `i < 100000 ` и в ассемблере у вас возникает jl <some address> который значт jmp if less than. То что я написал следует читать так - если i почти всегда меньше 100000 , то должна быть инструкция jge (jmp if greater or equal than) - т.е. она должна срабатывать и приводить к переходу редко. В чем проблема спросите вы. А я отвечу - есть кеш и prefetch. На момент выполнения инструкции вся строка, т..е. 64байта (зависит от платформы) кода вокруг это инструкции уже в кеше и последующие инструкции уже предвыбраны и спекулятивно исполнены. Jmp выбрасывает это в топку. Процессоры имеют предсказатель ветвлений и он часть угадывает, но часто еще не всегда. Поэтому что он реже ошибался есть инструкции подсказывающие это будет частый jump или редкий.

Bottom line -- в коде я вижу jl и не вижу никакого branch hint. Если эта инструкция соответствует i < 100000, то код не оптимален.
Надеюсь вам понятнее что я имел ввиду.

По поводу Rust - вы не поняли что я имел ввиду тоже. Rust, разумеется статический. Но с опцией lto он генерирует промежуточный код, который LLVM может преобразовывать в машинные инструкции и оптимизировать. Что если ваш скрипт преобразовать в промежуточный код и потом доверить оптимизатору в LLVM? Я думаю что это уделает этот горячий цикл на пару порядков.

Всё так. Только немного поправлю Вас:

Jmp выбрасывает это в топку.

Безусловный jmp относительно безопасен. А вот условный jl в случае промаха сливает всю выполеннную наперед работу в канализацию.

Я слегка не в теме, но неужели к Java (JIT) еще не прикрутили бэкэнд от LLVM ?

Тема использования LLVM в качестве jit-бэкенда - это наверное тема отдельной статьи. Есть закрытый проприетарный проект Falcon, и это единственный (известный лично мне) УСПЕШНЫЙ проект использования llvm в качестве jit-бэкенда в промышленной виртуальной машине (причем Java-машине). Но проект закрытый :(. Почему llvm не используется в jit-системах налево и направо (Mesa - не в счет) для меня вопрос любопытный и лично для себя я некоторый ответ на него дал. Но насколько он правдоподобен утверждать не берусь.

С удовольствием прочту Вашу статью на тему LLVM бэкенда для Java JIT.

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

После доклада ко мне подошел сотрудник Huawei (бывший коллега) и сказал, что (на весну 2023) они уже два или три года (точно не помню) уже занимаются схожим проектом в Huawei. Как обычно все украдено до нас :). Но у Huawei есть на "стартапы" ресурсы :(.

Презентацию посмотрю, спасибо. Тема интерсует чисто с академической точки зрения, на стартапы уже нет жизненных сил. :)

У LLVM JIT есть одна беда: компилирует он достаточно долго. Потому успешные применения его замечены только применительно к математике, когда время выполнения кода изначально подразумевается гораздо больше, чем время компиляции: Julia, Numba, … Либо его применяют как «самый крутой уровень jit для супер горячих циклов», как в каком-то из JS движков.

В том же PostgreSQL приходится настройкой (выставленной по умолчанию) подсказывать оптимизатору, что JIT на быстрых запросах делать вредно. Как раз потому, что компиляция LLVM может занять времени больше, чем выполнение запроса без неё.

Потому все адекватные JIT, заточенные под код общего назначения, самописные.

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

Была идея поместить обе трассы в asm-файлы. Подготовить входные данные как-то массивы с байткодом, массивы с начальным состояниям стеков, сделать asm-вставки, чтобы записать в нужные регистры ссылки на массивы. И зациклить работу, чтобы иметь рафинированные измерения. Тогда были бы два компактных и удобных теста для дальнейшего исследования. Задача любопытная, но трудоемкая. Поэтому оставим ее разработчикам наиболее быстрого интерпретатора. Они же не платят мне за работу аналитика :)?

Так что мне хватило макро-измерений с помощью time. И даже если будет их запуск (В ДРУГОМ JIT-КОНТЕКСТЕ!!!) на 30% быстрее, и что? Это не принципиально.

Меня ИНТЕРЕСУЕТ СОВСЕМ ДРУГОЕ - где ОБГОН JIT/AOT? Хотя бы ДОГОН. А для этого нужны не десятки процентов. Для это нужны разы.

Поддерживаю. Мне тоже видится, что трасса выполнения от JVM сильно не оптимальна.

Насколько я понимаю, любой интерпретатор - это ветвления, а значит кошмар для branch prediction и сплошные сбои конвейера, так что дело совсем не только в числе инструкций

Поэтому даже шаблонный транслятор уже ускоряет код. Но ведь это все jit-пропаганда и мы все врем, а если результаты time говорят о другом, то тем хуже для time. Теория выше экспериментов!

Именно так. Поэтому в своей статье пользователь @Rigidus показывает как этого избежать. В его ассемблерном коде минимум обращений к памяти и минимум переходов, а те что есть - хорошо предсказуемы.

Например, исходник был таким:

    for (;;) {
        if (_debug_flag) { // (1)
            printf(...);
        }
        if (++frame >= 60) { // (2)
            Flush();
            frame = 0;
        }

После компиляции в натив, условия (1) и (2) получат свои независимые команды ветвлений, на разных адресах RIP (EIP), и предиктор выучит, что (1) не выполняется никогда, а (2) выполняется очень редко.

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

В его ассемблерном коде минимум обращений к памяти

Минимум по сравнению с чем? С нативом? Так интерпретатор на каждом шаге выполняет служебные команды, к исходной задаче не имеющие отношения, а нужные лишь для организации интерпретации.

Вы же читали статью на которая данная статья является ответом ? Там приведен весь код интерпретатора asmopt и "служебных операций" в нём минимальный минимум.

Разумеется интерпретатор всегда будет уступать нативному заоптимизированному компилятором коду. Но в той статье я не вижу чтобы утверждалось противопложное. В ней обсуждается вопрос создания оптимального интерпретатора, более оптимального чем предложенный в статье от @Atakua. Попутно делается предположение о том, что такой вариант быстрее чем интерпретатор JVM.

"Минимальный минимум" - не надо так безапеляционно )))

Относительно натива оверхед x5-x8.

Относительно любого другого интерпретатора? Там же я давал ссылку, как уменьшить средний размер обработчика опкода на 1-2 команды, кешируя в регистрах несколько ячеек стека vm.

Ну так интерпретатор @Rigidus и кеширует 2 верхние ячейки -- вы что, статью совсем не читали?

А вы ту идею не читали. Можно для каждой глубины кеша иметь свою реализацию опкодов - это будет оптимальнее.

Я работаю над этим сейчас, пока результаты спорные:
- с одной стороны больше кода - бранч-предиктору легче (но возможно это проявляется лишь на коротких программах как представленная @Atakua
- с другой стороны, больше кода - больше кэш-миссов
- с третьей вообще все очень сильно зависит от нюансов ассемблерной реализации и сочетания с другими способами оптимизации. Для такого тюнинга уже хочется иметь какой-то решатель, который выбирает способ оптимизации в зависимости от внешних условий. Но это уже круто отличается от подхода "давайте возьмем интерпретатор, напишем его возможно более оптимально, и нам не потребуется писать сложный jit" - т.к. такой селектор оптимизаций будет приближаться по сложности к jit.

Моя статья своей задачи достигла: интерпретатор с малой сложностью показывает достаточно высокую производительность (даже выше чем ожидалось) путем применения небольшого количества приемов. Можно применить больше приемов и выжать еще немного.

Дальше есть два пути:
1. попробовать включить два приема, предложенных в комментариях:
- отказ о указателя на таблицу сервисных процедур (все процедуры будут одного размера)
- зависимость набора процедур от состояния стека (его глубины и порядка элементов в нем, возможно что-то еще)
2. двигаться в сторону суперинструкций, объединяя в одну инструкцию идущие подряд. Строго говоря, это уже немного jit (я считаю что граница проходит там, где мы начинаем создавать машинный код на который передаем управление)

Оба этих подхода интересные, но увеличивают сложность и становятся ненадежными с точки зрения выигрыша: первый зависит попадания процедур в кэш, второй требует нахождения компромисса между затратами на предобработку и ускорением обработанного байткода. Вывод будет неочевидным - если затюнить параметры иначе можно получить радикально отличающиеся бенчмарки. (Это было бы хорошо если бы мне платили за развязывание споров.) В моей статье этой неочевидности нет - ассемблерный оптимизированный интерпретатор реально быстрый и очевидно по каким причинам.

Я в чем-то неправ?

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

Есть один аспект, когда компилятор хуже: отладка. Отладчик байткода пишется довольно легко (я даже думаю, не написать ли об этом следующую статью). Но если мы компилируем, да еще и применяем оптимизации, то сложность отладчика резко возрастает и это перестает быть "проектом выходного дня".

Конечно напишите

Отлаживать всё же хочется не байт-код, а на уровне исходника.
И тут разница между нативом и байт-кодом не существенна, в сравнении с тем, какую общую инфраструктуру отладчика надо делать.

Что-то мне подсказывает, что бранч-предиктору пофиг на количество кода, он устроен несколько иным способом (упрощенно предиктор это таблица в которой присутствует "адрес откуда", "адрес куда" и число показывающее вероятность). А вот с точки зрения размещения кода в кэше - чем меньше, тем лучше. Также учитывайте, что мы имеем дело с многозадачной ОС которая постоянно свитчится, а при переключениях кэши обновляются. Поэтому чем меньше обьем кода интерпретатора, тем быстрее он будет подкачиваться в кэш и тем более эффективен он будет. Я так думаю.

Я никак не донесу до вас мысль, что в нативном коде точки ветвления - это ветвления из выполняемого алгоритма. Если в алгоритме цикл на 10000, ветвление редкое. В коде интерпретатора косвенный jump после какой-то популярной команды, будь то DUP, LOAD или STORE, прыгает на обработчик следующего байт-кода. Таргетов этого перехода становится в разы больше, и предиктор его не может предсказать.

Я не понимаю зачем Вы пытаетесь донести мне эту мысль, она очевидна и понятна - нативный (компилируемый) код всегда быстрее интерпретируемого по множеству причин. Но мы тут обсуждаем как сделать эффективный интепретатор, а не компилятор.

У предиктора табличка достаточно большая, все джампы сервисных функций в неё спокойно помещаются. Во время исполнения алгоритма у предиктора создается "профиль вероятных переходов" и это даст определенное ускорение, по отношению ко всем другим вариантам интерпретации. С нативным кодом мы не сравниваем, а используем его как референс для понимания того на сколько далеко мы отстоим от идеала. :)

Вы же читали статью на которая данная статья является ответом

А вы читали верхний комментарий этой ветки:

любой интерпретатор - это ветвления, а значит кошмар для branch prediction

вы пишете:

Именно так. Поэтому в своей статье пользователь @Rigidus показывает как этого избежать

И как этого избежать? Конкретно той проблемы с if-ами, в приведённом мной примере.

И как этого избежать? Конкретно той проблемы с if-ами, в приведённом мной примере.

Поясните о каком именно примере идет речь ? Я что-то совсем запутался.

Чтобы избежать проблемы с if-ами нужно избегать их использования. Собсно в вариантах asmopt и asmexp ветвления присутствуют только в проверке границ стека и являются необязательными.

Кстати, проверку границ стека можно доверить блоку MMU - повесить свой обработчик на sigsegv и т.д.

Поясните о каком именно примере идет речь

В этой ветке выше только 1 пример кода.

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

в вариантах asmopt и asmexp ветвления присутствуют только в проверке границ стека

Самое зло для предиктора - это

        jmpq   *(%r10,%rbx,8)

Потому что на обычный условный jmp можно собрать статистику, выполняется он или нет. А если выполняется, то куда. В коде интерпретатора эти переходы через каждые 10-15 инструкций, а таргеты переходов как правило разные.

Если я правильно Вас понял, то Вы апеллируете к инструкции которая формируется макрой DISPATCH, она передает управление сервисной фунции. Собственно моё предложение, имплементированное в optexp, и есть способ избавиться от такого сложного перехода заменив вытаскивание указателя сервисной функции из таблицы на простейший расчет с помощью сдвига. Вот как это реализовал пользователь @Rigidus:

    shl    $7, opcode64      # Умножить номер функции на 0x80
    lea    (routines, opcode64), opcode64  # Добавить базовый адрес первой функции
    jmp    *opcode64         # Перейти по адресу, хранящемуся в %rdx

На моей машине это решение показало +20% прирост в производительности.

Но даже не беря в расчет описанную выше оптимизацию, приведенный Вами jmp *(routines, opcode64, 8) вcё равно не плохо поддается предсказанию, не смотря на свою монструозность. Это связано с тем, что все эти переходы рассредоточены по сервисным функциям (а не находятся в одной точке). А это значит, что у предсказателя на каждый такой джамп сформируется отдельная строка в таблице со своей статистикой. Иными словами, в рамках выполнения заданного алгоритма, все переходы будут с большой вероятностью просчитаны корректно за небольшое число итераций и дальше всё попрёт как по маслу. В этом и состоит весь смысл делать FETCH и DISPATCH в конце каждой сервисной функции, а не в одной точке цикла интерпретатора.

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

Он предсказуем. Потому как эти джампы находятся в сервисных функциях и есть некий профиль их вызова алгоритмом.

Если в нативе переходы (например, в конце цикла) были по паттерну X,X,X,X,X,...
То в байт-коде (например, после инструкции STORE) становятся более замысловатыми, например, X,X,X,Y,Z,X,X,X,Y,Z,X,X,X,Y,Z.

А если в нативе паттерны были X,X,Z,X,X,Z, то в байт-коде длина паттерна сильно возрастает и может быть не схвачена предиктором.

Одним из первых конвейер в процессоре применил академик Лебедев. Идея конечно носилось в воздухе.

В конвейере исполнение операций разбито на стадии. И инструкции исполняются не последовательно а одновременно. Точнее до того, как закончилось исполнение предыдущих операций начинается исполнение следующих.

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

В x86 процессорах (и других out-of-order процессорах) есть предсказатель переходов. Простой предсказатель по ip-адресу бранча может закешировать адрес перехода. Грубо говоря, если последние 7 раз подряд вы перешли на один и тот же адрес из данного ip, то скорее всего и на 8 раз из данного ip туда перейдете. В чем отличие интерпретатора и почему, в частности, помогает даже шаблонный транслятор. После исполнения, например, байткод-инструкции PUSH для некоторой байткод-программы вы перейдете на произвольную байткод-инструкцию. Только в очень маленьком и простом цикле последовательность байткод-команд будет фиксированной. И только в этом случае будет хорошо работать предсказатель. Например, в этом цикле после PUSH всегда идет SUB. И тогда к ip-бранчу в конце PUSH закешируется ip-адрес начала участка интерпретации SUB. А если после PUSH иногда SUB, иногда LOAD, иногда SWAP, то предсказатель не сможет "угадывать" адрес.


Как конкретно работает тот или иной x86/arm/mips/riscv-процессор - это коммерческая тайна. И кто хитрее сделает предсказатель, тот на НЕКОТОРЫХ задачах и будет быстрее. Под любой предсказатель можно сделать антипаттерн.

Верно. Именно по этому вариант asmexp на моём AMD дает прирост в 20%, а у пользователя @Rigidus на Intel-е всего 2%. Хотелось бы прогнать этот тест на большем числе процессоров обоих производителей.

Например, в этом цикле после PUSH всегда идет SUB. И тогда к ip-бранчу в конце PUSH закешируется ip-адрес начала участка интерпретации SUB. А если после PUSH иногда SUB, иногда LOAD, иногда SWAP, то предсказатель не сможет "угадывать" адрес.

Всё верно, только вывод немного не правильный, ну как мне кажется. :-) Предсказатель копит статистику для каждой точки ветвления. Так как джампы разбросаны по сервисным функциям, то через некоторое время у предсказателя сложится некий "профиль" из вероятностей для данного алгоритма. Где-то он будет угадывать чаще, где-то реже, но это всё равно лучше чем ветвление из одной точки где предсказать вообще ничего нельзя.

На счет значения регистров. В современных OoO процессорах для каждой исполняемой команды делается своя копия регистров (Томасуло) и для косвенного джампа адрес перехода уже может быть рассчитан наперед. Но тут мы разумеется встаем на неизведанную нам почву конкретной проприетарной имплементации конвейера и что да как сделано у конкретного производителя мы не имеем возможности точно знать. Однако бенчмарк покажет кто и где по весне... :)

Также надо помнить о том, что x86-ассемблер и x86-регистры - это давно уже виртуальный ассемблер и виртуальные регистры. Аппаратура на лету преобразует их микро-код и на лету работает аппаратный распределитель регистров. Реальные инструкции микро-кода на лету перемешиваются и выполняются в оптимальном порядке. Это еще до всякого конвейера!!! То есть на конвейер идет уже цепочка микро-кода.

Если адрес load'а из микро-кода известен за ранее. И аппаратный планировщик установил отсутствие зависимостей с операциями записи, то аппаратный планировщик может спланировать такой load раньше других операций. Тем самым уменьшается вероятность промаха в кеш.

Как-то видел статью (но потом не смог ее найти), в которой говорилось про один из x86-процессоров, что в нем есть несколько АЛУ-устройств и несколько разноплановых инструкций микро-кода могут выполняться параллельно. То есть в конвейере еще может быть и несколько трактов исполнения. А вы говорите никто в мире не делает VLIW'ов :)))). Просто вам это может быть не видно.

Если load заблокировался аппаратный планировщик может начать исполнять другие инструкции, для которых уже готовы значения входных операндов.

Интересно, а задумывался ли кто-либо компилировать напрямую в эти микрокоды вместо виртуального x86? Это вообще возможно, или микрокод -- вещь в себе? А может вообще не нужно, раз есть ARM?

Интел несколько раз кажется собирался сделать данные о микрокоде открытыми. Но так и не стал этого делать. Иначе потеряется совместимость (во все стороны). Код собранный под один конкретный процессор может элементарно не заработать на другом. А IT-индустрия к этому не привыкла. И по-моему проприетарная интеловская библиотека MKL оптимизирована под каждую разновидность процессора.

Любой out-of-order процессор, для получения производительности скорее всего будет в общем и целом копировать x86. (Но это мои предположения.) Иначе он будет отставать по производительности. Топовые процессоры ARM по-моему тоже уже давно out-of-order. Как впрочем и MIPS.

Во-первых, даже если мы будем знать всё про микрокод конкретного процессора, у нас всё равно нет механизма для того, чтобы поставить сгенерированный нами микрокод на конвейер (исполнить). Во-вторых, такое решение будет жестко прибито гвоздями к конкретной модели микропроцессора и даже к конкретной произведенной партии чипов. Более того, в микрокоде патчатся аппаратные баги и подключаются/отключаются нерабочие блоки аппаратуры, как это всё учесть на стадии компиляции ?

Пользователь @e2k_sid прав, современные высокопроизводительные микропроцессоры это интепретаторы (компиляторы ?) виртуального байт-кода который известен нам как x86-64 ISA.

В этом смысле RISC интересен тем, что в нём команда которая видна программисту это и есть команда микрокода. Я где-то читал, что ARM-ы до сих пор обходятся без микрокода.

Нет, у ARM уже давно есть микрокод. Но может только для отдельных, особо заковыристых инструкций.

Утилита perf по-моему умеет выводить статистику о среднем количестве инструкций микрокода на один такт процессора.

Поэтому замеры, замеры и еще раз замеры!

Методологически неправильно сравнивать результаты на одном единственном алгоритме. Ну и весь последующий холивар, соответственно, скатился в сторону "а я вот здесь лишнюю пару тактов вижу".

Если же произвести сравнение по набору различных алгоритмов, то получим очень сильный разброс результатов, одни из которых будут лучше конкурентов, а другие хуже. При более или менее вменяемом отношении к оптимизации интерпретаторов, бьющих всех по всем алгоритмам не будет. Соответственно, никто и никогда не сможет заявить, что его интерпретатор лучший, потому что по отдельным алгоритмам он всегда будет хуже.

Отсюда лежит прямая дорога в сторону "начать думать".

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

Точность же остаётся многомерной - по каждому алгоритму своё время.

Далее каждый решает сам - у меня в среднем много таких-то задач, поэтому мне нужен вот такой интерпретатор. Хотя обычно решение всё же в сторону JIT, и потому все эти интерпретаторы идут лесом.

Так что получается спор совсем ни о чём. А почему? Да потому что с самого начала (в исходной статье) было предложено рассмотреть очень узкий частный случай от конкретного автора, без каких-либо попыток взглянуть на реальный мир с его очень сложным ландшафтом. И вот за одним узким взглядом потянулась статья с другим, но всё же вынужденно выходящим за узкие рамки, просто что бы получить аргументы против узкого взгляда оппонента за счёт указания на наличие других областей, но опять же весьма узких.

Поэтому призыв ко всем спорящим - а давайте посмотрим ещё шире, а?

Это прописные, азбучные истины. Изложены они правильно и хорошо. И они верны, на то они и прописные. Периодически их действительно надо повторять.

От себя добавлю еще одну. При фиксировании spec-набора любой оптимизатор очень быстро становится спеко-ориентированным. Причем без фиксации spec-набора (бенчмарков) сравнения невозможны в принципе. Так и живем :).

Тем не менее, основной посыл в моей статье не про сравнение производительности двух "абстрактных" интерпретаторов.

Спасибо за ответную статью. Хабр определенно торт!

В комментариях к статье пользователя @Rigidus было указано на несколько "проблем измерения" из-за которых доверять результатам бенчмарка нельзя, а именно:

  1. Запуск и инициализация явы занимает время, существенное время, по сравнению с простым ассемблерным кодом. Сравнивать разультат нельзя.

  2. Вызов printf() как из явы, так и из предложенного пользователем @Rigidusварианта байт-код машины очень сильно сказывается на результате, так как функция printf() очень витиеватая и она, в купе с системным вызовом write() который приводит к переключению контекста, очень сильно портит всё - статистику предсказаталей, кэш и т.д. На всякий случай поясню - переключения контекста является недетерминированным, система может усыпить процесс на любое время которые посчитает нужным, может произойти сброс таблиц TLB и еще много чего нехорошего что полностью изгадит результат измерения .

  3. Разгон и торможение процессора занимает время. То есть нужно либо существенно увеличивать обьем задачи, либо делать предварительный "разогрев". За те 3-5 сек которые занимает выполнение оригинального бенчмарка получить адекватные данные не получится в силу естественных причин экономии электроэнергии. :-)

Поэтому, мной был предложен немного подправленный вариант оригинального бенчмарка. В нём: 1) убран вызов printf(), так чтобы компилятор не заоптимизировал цикл до абсолютного нуля, 2) задача увелична с 100 000 до 300 000 чисел (почти в 10 раз).

Результаты на моей машине оказались весьма интересными: вариант asmexp (это слегка заоптимизированный asmopt в котором адрес сервисной функции расчитывается, а не достается из таблицы) показывает производительность более чем на 20% выше чем JVM, как и предполагает пользователь @Rigidus. Однако побить JIT, не получилось, тут всё в соответствии с утверждением пользователя @e2k_sid.

Вот результаты этого подправленного бенчмарка с моей машины:

rz@butterfly:~/interpreters-comparison % time ./asmexp > /dev/null
24.809u 0.000s 0:24.81 99.9%	848+2896k 0+8io 0pf+0w

rz@butterfly:~/interpreters-comparison % time ./asmopt > /dev/null
33.222u 0.000s 0:33.22 100.0%	843+2895k 0+8io 0pf+0w

rz@butterfly:~/interpreters-comparison % time java -Xint Main
31.386u 0.055s 0:31.43 100.0%	5+167k 0+4io 0pf+0w

rz@butterfly:~/interpreters-comparison % time java Main
5.340u 0.031s 0:05.36 100.1%	17+171k 0+4io 0pf+0w

Это выборка из нескольких прогонов, с минимальными значениями.

Моя машина - ноут Lenovo Ideapad Gaming:

Мне бы хотелось посмотреть на результаты этих тестов на других машинах, в частности на процессорах от Intel с частотой 3+ ГГц. При тестах не плохо бы делать cpu affinity.

Я бы оставил printf() считая его частью задачи. Редко мы используем интерпретаторы общего назначения исключительно для числодробилок без i/o. Для того чтобы нивелировать случайные эффекты, лучше запускать бенчмарк 5 раз, как это сделано у @Atakua. С увеличением задачи согласен, все стало уже слишком быстрым

Я бы оставил printf()

Никак низя! Один системный вызов и весь накопленный гешефт будет израсходован на сотни тысяч тактов вперед просто за счет сброса TLB (почитайте про устройство виртуальной памяти и то как организовываются сисколы). Если строить оптимальный интерпретатор, то надо заморочиться c оптимизацией его внутреннего устройства, а не бороться с операционной системой и артифактами связанными с переключением контекста. Имея такой интерпретатор мы можем гонять его на голом железе нивилируя влияние ОС.

Если требуется печатать результат, то это надо делать в самом конце задачи. Вы же не печатаете статистику вызова сервисных функций после каждого цикла вычислений, а накапливаете её и выводите в самом конце. Здесь всё то же самое.

(9592 / 5462956111) * 100

= 0.00017558259 % составляют вызовы байткода Instr_Print от всех вызовов всех байткодов. Если принт руинит перформанс при таком проценте, то я прям даже не знаю..

Что означают эти цифры ?

Еще раз повторю, системный вызов может (и часто так делает) гадит в статистику предсказателя, в статистику кэшей и смывает TLB. И вообще может остановить исполнение программы на неопределенное время! Это означает, что дальнейшая работа на многие сотни тысяч тактов процессора существенно замедлена. Если убрать вызов pintf(), то весь бенчмарк ускорится разом. Во всяком случае, такова теория и она подтверждается моей практикой. :-)

Давайте немного пофантазируем. В нашем тесте printf() вызывается с частотой 9592/5.2s = 1800 Гц. Предположим, что выход процессора на высокопроизводительный режим (загрузка кэшей и выравнивание статистики предсказателя), после системного вызова, занимает 250 000 тактов и это в случае если наш процесс не был остановлен системой. Тогда за секунду работы процессор будет находиться в низкопроизводительном режиме около 1800*250 000 = 450 000 000 тактов. При тактовой частоте 3ГГц это составляет 15% рабочего времени. Цифры умозрительные, приведены чисто для иллюстрации сути проблемы. По факту все может быть еще хуже, так как работа операционной системы с точки зрения нашего процесса не предсказуема. Сколько тактов занимает загрузка кэшей и TLB я не знаю, но мне попадалась фраза "сотни тысяч тактов" для современных x86. Библиотечная функция vprintf() тоже вещь в себе - посмотрите в её код. :-)

>> смывает TLB
Все так. И в реальных программах именно это и происходит, поэтому я склонен оставлять это в бенчмарке как возмущающий элемент реальной жизни

Сомнительная идея. На мой взгляд нужно сначала добиться какого-то результата на чистом коде интерпретатора, а потом уже "возмущать" его и смотреть как разные виды "возмущений" влияют на его производительность. В общем, не научный у Вас подход получается. :-)

Ну и задачу нужно увеличить в разы, иначе результат сложно отделить от шума.

Я всё ещё медленно читаю эту статью, но решил оставить комментарий уже сейчас. И он будет не о статье, а скорее о моём мета-наблюдении.
Как автор оригинального кода, который здесь и в предыдущей статье разбирают по косточкам, хочу отметить моё давнее наблюдение о судьбе программных проектов. Если какой-то проект имеет ненулевое количество пользователей, то раньше или позже его начнут использовать таким образом, какой его авторы вообще не могли себе когда-либо вообразить. В принципе, наверное это применимо к практически любому тексту.

При условии, что продолжаемая дискуссия ведётся в цивильной манере и имеет цель докопаться до истины и всех её оттенков, я остаюсь доволен :-).

Сугубо в рамках научной дискуссии. У меня есть пример с "абстрактной" виртуальной машиной с регистровым, а не стековым байткодом. Регистры конечно виртуальные. Байткод более жирный (сейчас это не так критично как в 90-е), исполнение одной инструкции более длинное. Но количество инструкций в среднем меньше. Смотрел только на двух модельных тестах. Регистровый байткод получался из пропатченного javac. Тем самым в обоих случаях было одинаковое java-AST.

В режиме только интерпретации время исполнения получилось одинаковое с OpenJDK. То есть в среднем стековый и регистровый интерпретаторы дали на x86 в моем случае одинаковый результат. (Но в разных виртуальных машинах.)

Почему регистровая машина? Лично мне примерно известно как сделать быстрый регистровый интерпретатор для одного in-order процессора, но неизвестно как для этого процессора сделать быстрый стековый интерпретатор. Кроме того, на регистровом представлении (llvm-IR подобном) удобно делать peephole-оптимизации.

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации

Истории