Недавно была опубликована статья под заголовком "Глобально оптимальный, восьмой и наиболее быстрый вид интерпретаторов байткода". Несколько тезисов из статьи вызвали у меня сомнения в их справедливости. Об этом я попробовал написать ряд комментариев тире вопросов к указанной статье. Но основной лейтмотив всех ответов сводился к тому - "а ты напиши свою статью". Подход не столько инженерно-научный, сколько детсадовский. Мне бы хватило и содержательных ответов в формате комментариев, но как говорится - уговорили.

Итак, что же утверждается автором статьи про наиболее быстрый интерпретатор:

  • Так что эта статья - туториал: как написать интерпретатор байткода, который может обгонять JIT/AOT-компиляцию по скорости.

  • Итак, способен ли оптимизированный интерпретатор байткода виртуальной машины обогнать двоичную трансляцию? Или, как многие начинающие компиляторщики считают, это невозможно? Является ли JIT (или AOT) - нашей последней надеждой на производительность? Думаю, я смог ответить на этот вопрос.

В начале указанной статьи автор обещает, что его интерпретатор обгонит JIT/AOT-компиляцию по скорости. В конце статьи - утверждается, что ответ на вопрос об обгоне двоичной трансляции дан. О чем пишется в самой статье? О том как был оптимизирован интерпретатор некоторого абстрактного стекового байткода сиречь одной абстрактной стековой машины для x86-процессора.

Делает ли автор статьи о наиболее быстром интерпретаторе какие-либо сравнения с JIT-компиляцией? Нет. Он только исключительно рассказывает про свою реализацию интерпретатора. И использует некоторый бенчмарк Atakua, не уточняя что это за бенчмарк, что и как измеряется в рамках бенчмарка. Кроме того, в самом конце статьи сообщается, что

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

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

Для начала по предложенной ссылке на проект interpreters-comparison, попробуем познакомиться с проектом поближе. Каких-либо специальных указаний не было, поэтому просто копируем проект по указанной автором ссылке. Информации в проекте предоставляется не так много, но если смотреть по истории изменений, то судя по всему собственно код наиболее быстрого интерпретатора написан на ассемблере и размещен в файле asmoptll.S. Исходя из содержимого Makefile'а итоговый исполняемый бинарник, создаваемый make командой - это asmopt. Вооружимся отладчиком gdb и попробуем получить трассу исполнения основного цикла.

Но в начале поговорим о том, какая программа исполняется в примере. Исходя из кода проекта в целом, байткод тестового примера (это видимо и есть бенчмарк?) определен в файле common.c в глобальном массиве Primes. Всю байткод-программу в терминах стековой машины из проекта interpreters-comparison легко посмотреть по ссылке в файле common.c. Здесь же приведем основной и самый горячий внутренний цикл, который состоит из следующих байткод инструкций абстрактной стековой машины из этого проекта:

Instr_Over, // nmax, c, divisor, c
Instr_Over, // nmax, c, divisor, c, divisor
Instr_Swap, // nmax, c, divisor, divisor, c
Instr_Mod, // nmax, c, divisor, c mod divisor
Instr_JE, +5, /* not_prime */ // nmax, c, divisor
Instr_Inc, // nmax, c, divisor+1
Instr_Jump, -15, /* back2 */ // nmax, c, divisor

Запустим теперь исполняемый файл asmopt под отладчиком gdb, немного подождем, прервем исполнение и воспользуемся gdb-командой display:
(gdb) display /i $pc

После чего будем использовать gdb-команду stepi и так достаточно быстро получим трассу исполнения самого горячего цикла, благо он не большой, и трасса получается достаточно быстро даже при таком ручном подходе. Приведу, полученною мной, трассу исполнения одной итерации цикла. В ней все x86-инструкции выполняются последовательно, но для удобства чтения вставлены дополнительные строки с названиями функций интерпретации разных байткод-инструкций:
// src_Over
0x555555555546 : xchg %rax,%r10
0x555555555548 : cmp %rsp,%rbx
0x55555555554b : jae 0x555555555497
0x555555555551 : push %rax
0x555555555552 : inc %r9
0x555555555555 : mov (%rsi,%r9,4),%edx
0x555555555559 : mov 0x4(%rsi,%r9,4),%r14d
0x55555555555e : jmpq *(%rdi,%rdx,8)

// src_Over
0x555555555546 : xchg %rax,%r10
0x555555555548 : cmp %rsp,%rbx
0x55555555554b : jae 0x555555555497
0x555555555551 : push %rax
0x555555555552 : inc %r9
0x555555555555 : mov (%rsi,%r9,4),%edx
0x555555555559 : mov 0x4(%rsi,%r9,4),%r14d
0x55555555555e : jmpq *(%rdi,%rdx,8)
// src_Swap

0x555555555535 : xchg %rax,%r10
0x555555555537 : inc %r9
0x55555555553a : mov (%rsi,%r9,4),%edx
0x55555555553e : mov 0x4(%rsi,%r9,4),%r14d
0x555555555543 : jmpq *(%rdi,%rdx,8)
// src_Mod
0x555555555590 : test %r10,%r10
0x555555555593 : je 0x5555555555b8
0x555555555595 : xor %rdx,%rdx
0x555555555598 : div %r10
0x55555555559b : mov %rdx,%rax
0x55555555559e : cmp %rsp,%rbp
0x5555555555a1 : jb 0x5555555554a6
0x5555555555a7 : pop %r10
0x5555555555a9 : inc %r9
0x5555555555ac : mov (%rsi,%r9,4),%edx
0x5555555555b0 : mov 0x4(%rsi,%r9,4),%r14d
0x5555555555b5 : jmpq *(%rdi,%rdx,8)
// srv_Je
0x5555555555dd : mov %rax,%r13
0x5555555555e0 : mov %r10,%rax
0x5555555555e3 : cmp %rsp,%rbp
0x5555555555e6 : jb 0x5555555554a6
0x5555555555ec : pop %r10
0x5555555555ee : test %r13,%r13
0x5555555555f1 : je 0x555555555603
0x5555555555f3 : lea 0x2(%r9),%r9
0x5555555555f7 : mov (%rsi,%r9,4),%edx
0x5555555555fb : mov 0x4(%rsi,%r9,4),%r14d
0x555555555600 : jmpq *(%rdi,%rdx,8)
// srv_Inc
0x55555555557e : inc %rax
0x555555555581 : inc %r9
0x555555555584 : mov (%rsi,%r9,4),%edx
0x555555555588 : mov 0x4(%rsi,%r9,4),%r14d
0x55555555558d : jmpq *(%rdi,%rdx,8)
// srv_Jump
0x5555555555c7 : movslq %r14d,%r14
0x5555555555ca : add %r14,%r9
0x5555555555cd : lea 0x2(%r9),%r9
0x5555555555d1 : mov (%rsi,%r9,4),%edx
0x5555555555d5 : mov 0x4(%rsi,%r9,4),%r14d
0x5555555555da : jmpq *(%rdi,%rdx,8)

Данная трасса очень хорошо согласуется с содержимым файла asmoptll.S, там есть определение всех srv-функций. Однако получить именно итоговый x86-код исполняемых srv-функций непосредственно из файла несколько сложнее, чем из трассы из-под gdb (дизассемблер объектника хорош когда уже знаешь что искать). Вот что проще, так это из файла asmoptll.S действительно гораздо легче понять логику интерпретации инструкций стековой машины. Несложно теперь установить и исходный алгоритм, использованный для создания байткод-программы, который на языке Си доступен в native.c:
for (int i = 2; i < 100000; i++) {
bool is_prime = true;
for (int divisor = 2; divisor < i; divisor++) {
if (i % divisor == 0) {
is_prime = false;
break;
}
}
if (is_prime) printf("[%d]\n", i);
}

Автор статьи про наиболее быстрый интерпретатор в своем комментарии любезно привел пример на java. Воспользуемся этим примером и проанализируем основной цикл в OpenJDK. Сначала с помощью утилиты javap попробуем получить байткод основного цикла. Похоже, что для случая OpenJDK байткод одной итерации будет устроен следующим образом:

12: iload_3
13: iload_1
14: if_icmpge 34
17: iload_1
18: iload_3
19: irem
20: ifne 28
...
28: iinc 3, 1
31: goto 12

Первое, что бросается в глаза - это то, что основная итерация состоит из 9 байткод инструкций заместо 7 байткод инструкций, как это было ранее для наиболее быстрого интерпретатора. Это говорит о том, что компилятор javac, возможно, сделал не самый оптимальный код или, может быть, стандарт Java на байткод вносит свои ограничения. В противоположность этому в проекте interpreters-comparison сама по себе байткод программа в части основного цикла выглядит более оптимальной и лучше оптимизированной. Но речь-то шла не про сравнение компиляторов, и не про то, кто сделает более оптимальный байткод. Верно? Речь шла про интерпретаторы, и про интерпретаторную наиболее быстрость. А интерпретатор обязан исполнять тот байткод, который ему передали на исполнение с одной стороны, а с другой стороны время работы интерпретатора напрямую зависит от количества инструкций. Сократили во входной байткод-программе на 20% количество инструкций - считай ускорили суммарное время работы интерпретатора на те же ~20%. Составим теперь трассу исполнения для OpenJDK, используя опцию -Xint, чтобы в OpenJDK работал только интерпретатор байткода:
// скорее всего это 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)
// скорее всего это 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)
// скорее всего это iload_1
0x7fffe49215bf: mov -0x8(%r14),%eax
0x7fffe49215c3: movzbl 0x1(%r13),%ebx
0x7fffe49215c8: inc %r13
0x7fffe49215cb: movabs $0x7ffff7d7ebc0,%r10
0x7fffe49215d5: jmpq *(%r10,%rbx,8)
// скорее всего это iload_3
0x7fffe49216be: push %rax
0x7fffe49216bf: mov -0x18(%r14),%eax
0x7fffe49216c3: movzbl 0x1(%r13),%ebx
0x7fffe49216c8: inc %r13
0x7fffe49216cb: movabs $0x7ffff7d7ebc0,%r10
0x7fffe49216d5: jmpq *(%r10,%rbx,8)
// скорее всего это irem
0x7fffe4923e27: mov %eax,%ecx
0x7fffe4923e29: mov (%rsp),%eax
0x7fffe4923e2c: add $0x8,%rsp
0x7fffe4923e30: cmp $0x80000000,%eax
0x7fffe4923e36: jne 0x7fffe4923e47
0x7fffe4923e47: cltd
0x7fffe4923e48: idiv %ecx
0x7fffe4923e4a: mov %edx,%eax
0x7fffe4923e4c: movzbl 0x1(%r13),%ebx
0x7fffe4923e51: inc %r13
0x7fffe4923e54: movabs $0x7ffff7d7ebc0,%r10
0x7fffe4923e5e: jmpq *(%r10,%rbx,8)
// скорее всего это ifne 28
0x7fffe4924ec7: test %eax,%eax
0x7fffe4924ec9: je 0x7fffe4924f0c
0x7fffe4924ecf: mov -0x18(%rbp),%rcx
0x7fffe4924ed3: movswl 0x1(%r13),%edx
0x7fffe4924ed8: bswap %edx
0x7fffe4924eda: sar $0x10,%edx
0x7fffe4924edd: movslq %edx,%rdx
0x7fffe4924ee0: add %rdx,%r13
0x7fffe4924ee3: movzbl 0x0(%r13),%ebx
0x7fffe4924ee8: testb $0x8,0x108(%r15)
0x7fffe4924ef0: je 0x7fffe4924efe
0x7fffe4924efe: movabs $0x7ffff7d813c0,%r10
0x7fffe4924f08: jmpq *(%r10,%rbx,8)
// скорее всего это iinc 3, 1
0x7fffe492463f: movsbl 0x2(%r13),%edx
0x7fffe4924644: movzbl 0x1(%r13),%ebx
0x7fffe4924649: neg %rbx
0x7fffe492464c: add %edx,(%r14,%rbx,8)
0x7fffe4924650: movzbl 0x3(%r13),%ebx
0x7fffe4924655: add $0x3,%r13
0x7fffe4924659: movabs $0x7ffff7d813c0,%r10
0x7fffe4924663: jmpq *(%r10,%rbx,8)
// скорее всего это goto 12
0x7fffe49256df: mov -0x18(%rbp),%rcx
0x7fffe49256e3: movswl 0x1(%r13),%edx
0x7fffe49256e8: bswap %edx
0x7fffe49256ea: sar $0x10,%edx
0x7fffe49256ed: movslq %edx,%rdx
0x7fffe49256f0: add %rdx,%r13
0x7fffe49256f3: movzbl 0x0(%r13),%ebx
0x7fffe49256f8: testb $0x8,0x108(%r15)
0x7fffe4925700: je 0x7fffe492570e
0x7fffe492570e: movabs $0x7ffff7d813c0,%r10
0x7fffe4925718: jmpq *(%r10,%rbx,8)

После того как в отладчике gdb трасса исполнения замкнулась и начала повторяться, то по моим подсчетам получилось так раз 9 участков кода - по одному на каждую байткод инструкцию. В нерелизных сборках OpenJDK очень много отладочных опций, но у меня сейчас под рукой нет таких сборок. В данном случае использую сугубо дистрибутивную java-машину. А значит придется опираться на догадки о соответствии байткод инструкций и соответствующих участках в трассе исполнение. Можно ли на основе приведенных трасс однозначно утверждать о наиболее быстрости одного или другого компилятора? Во-первых, не настолько хорошо разбираюсь в x86-асемблере и устройстве x86-процессоров, чтобы только по asm-тексту предсказать точное время работы. Во-вторых, и что гораздо важнее, сравнение двух интерпретаторов из совершенно разных виртуальных машин, на разных байткод программах - это не особо корректная задача. В разных виртуальных машинах может выполнятся разный набор семантических и технологических действий. По-разному может быть устроено взаимодействие между интерпретатором и jit-компилятором и могут применяться разные стратегии jit-компиляции, определяющие дополнительную нагрузку на процесс интерпретации.

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

$ time ./asmopt > /dev/null
real 0m7,739s

$ time ./native > /dev/null
real 0m1,253s

$ time java -Xint Main > /dev/null
real 0m6,686s

$ time java Main > /dev/null
real 0m1,786s

В доступе у меня была вот такая x86-машина:
model name : Intel(R) Xeon(R) CPU E5-2690 v2 @ 3.00GHz
openjdk 11.0.6 2020-01-14

Программа native - это реализация на языке Си. Программа asmopt - это, судя по всему, наиболее быстрый интерпретатор. Программа Main - это реализация на языке Java. Здесь надо отметить, что автор статьи про наиболее быстрый интерпретатор приводит другие данные:

и получил время 3.945s (сравните с 3.228s ...)

Вывод: Java-код не в состоянии обогнать оптимизированный интерпретатор байткода.

Значит, 3.945 / 3.228 = 1.22, другими словами, утверждается, что наиболее быстрый интерпретатор по абсолютному времени на разных байткод программах в разных виртуальных машинах с разной семантикой быстрее на 22%. У меня при измерениях указанным образом получилось наоборот 7.739 / 6.686 = 1.15, на 15% быстрее OpenJDK. Но в любом случае замах-то был на JIT-компиляторы! Не меньше! В конце концов, но у Вас же изначально есть тест native, так поделите две чиселки и получите 7.739 / 1.253 = 6.2 раза, Карл, или какое там у Вас соотношение получается. Где элементарная логика и откуда только берется это "Является ли JIT (или AOT) - нашей последней надеждой на производительность?".

Итак, мне и всем читателям статьи про наиболее быстрый интерпретатор в самом начале пообещали, что

обгонять JIT/AOT-компиляцию по скорости

А что в итоге? Рассказали, как достичь результатов по скорости интерпретатора, которые по сути мало чем отличаются от результатов, достигнутых для интерпретаторов в OpenJDK/Firefox/Chrome/CoreNET много лет назад? Ну повторили, и что. А слабо сделать полноценную java машину-то? Ее-то повторите? И где это обещанное "обогнать"? Вот JIT-компилятор OpenJDK по сути догоняет реализацию на Си-коде и если увеличить время работы теста так и в принципе догонит. Это да. Это легко проверить и пощупать. Так что к чему все эти попытки по бросанию камней в огород JIT-компиляции? В общем сплошной кликбейт на марше.

Комментарии (65)


  1. monpa
    09.11.2024 22:29

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


    1. e2k_sid Автор
      09.11.2024 22:29

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

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


      1. monpa
        09.11.2024 22:29

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


        1. e2k_sid Автор
          09.11.2024 22:29

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


        1. gev
          09.11.2024 22:29

          На Haskell же =)


      1. Tzimie
        09.11.2024 22:29

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


      1. qweururu
        09.11.2024 22:29

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

        Безотносительно того, чем является C++/Си с классами. Тезис странный. Неясно, откуда появляется ненависть, ведь предпочитаемую вами часть языка/подхода никто не забирает. Даже совместимость довольно строго блюдут. Откуда берётся ненависть? Просто интересно.


        1. e2k_sid Автор
          09.11.2024 22:29

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

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


          1. qweururu
            09.11.2024 22:29

            Несколько дней и бессонных ночей сидеть и втыкать на одной template-строчкой? Ну не все же получают от таких ролевых игр удовольствие

            То есть ненависть от того, что кто-то написал код, который вам сложно понять? А причём здесь C++/комитеты/прочее? Не запрещают так писать? Так это вам скорее к автору конкретного кода, а не к ним - решение о том, как написать принимал он. Крайне странный перенос обвинений с одного на другое.

            И отсутствие смайлика не всегда означает отсутствие иронии.

            Я не знаю, в чём смысл занимать подобную позицию. Делать утверждение, а на вопросы "почему" отвечать "ну это шутка была". Понятно, что это очень удобно - не нужно ничего обосновывать. Но зачем вообще такое писать, какова мотивация? Как шутку/метафору это никто не ценит, насколько я могу судить(как минимум потому, что подобное уже давно стало обыденностью).


      1. checkpoint
        09.11.2024 22:29

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

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

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


    1. checkpoint
      09.11.2024 22:29

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


  1. imitron
    09.11.2024 22:29

    Исходя из анализа ассемблера в трассе он не может быть быстрее чем 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. Т.е. очевидно, что трасса показана неправильно и автор вообще ничего не понимает в ассемблере, иначе бы не допустил такую ошибку.

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


    1. e2k_sid Автор
      09.11.2024 22:29

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

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


      1. Dmitri-D
        09.11.2024 22:29

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


        1. e2k_sid Автор
          09.11.2024 22:29

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

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


          1. Dmitri-D
            09.11.2024 22:29

            При чем здесь резервирование? Вы пишете в скрипте `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? Я думаю что это уделает этот горячий цикл на пару порядков.


            1. checkpoint
              09.11.2024 22:29

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

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

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

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


              1. e2k_sid Автор
                09.11.2024 22:29

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


                1. checkpoint
                  09.11.2024 22:29

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


                  1. e2k_sid Автор
                    09.11.2024 22:29

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

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


                    1. checkpoint
                      09.11.2024 22:29

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


        1. e2k_sid Автор
          09.11.2024 22:29

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

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

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

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


    1. checkpoint
      09.11.2024 22:29

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


  1. Tzimie
    09.11.2024 22:29

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


    1. e2k_sid Автор
      09.11.2024 22:29

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


    1. checkpoint
      09.11.2024 22:29

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


      1. qw1
        09.11.2024 22:29

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

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

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

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

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

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


        1. checkpoint
          09.11.2024 22:29

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

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


          1. qw1
            09.11.2024 22:29

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

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

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


            1. Mingun
              09.11.2024 22:29

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


              1. qw1
                09.11.2024 22:29

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


                1. Rigidus
                  09.11.2024 22:29

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

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

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

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

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


                  1. qw1
                    09.11.2024 22:29

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


                    1. Rigidus
                      09.11.2024 22:29

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


                      1. Mingun
                        09.11.2024 22:29

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


                      1. qw1
                        09.11.2024 22:29

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


                  1. checkpoint
                    09.11.2024 22:29

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


                    1. qw1
                      09.11.2024 22:29

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


                      1. checkpoint
                        09.11.2024 22:29

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

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


          1. qw1
            09.11.2024 22:29

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

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

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

            вы пишете:

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

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


            1. checkpoint
              09.11.2024 22:29

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

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

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

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


              1. qw1
                09.11.2024 22:29

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

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


                1. checkpoint
                  09.11.2024 22:29

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


              1. qw1
                09.11.2024 22:29

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

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

                        jmpq   *(%r10,%rbx,8)
                

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


                1. checkpoint
                  09.11.2024 22:29

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

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

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

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


                  1. qw1
                    09.11.2024 22:29

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


                    1. checkpoint
                      09.11.2024 22:29

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


                      1. qw1
                        09.11.2024 22:29

                        Если в нативе переходы (например, в конце цикла) были по паттерну 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, то в байт-коде длина паттерна сильно возрастает и может быть не схвачена предиктором.


                    1. e2k_sid Автор
                      09.11.2024 22:29

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

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

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

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



                      1. e2k_sid Автор
                        09.11.2024 22:29

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


                    1. e2k_sid Автор
                      09.11.2024 22:29

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

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

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


                      1. e2k_sid Автор
                        09.11.2024 22:29

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


                      1. e2k_sid Автор
                        09.11.2024 22:29

                        _


                      1. Mingun
                        09.11.2024 22:29

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


                      1. e2k_sid Автор
                        09.11.2024 22:29

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


                      1. e2k_sid Автор
                        09.11.2024 22:29

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


                    1. e2k_sid Автор
                      09.11.2024 22:29

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

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


  1. murkin-kot
    09.11.2024 22:29

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

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

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

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

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

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

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

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


    1. e2k_sid Автор
      09.11.2024 22:29

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

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

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


  1. checkpoint
    09.11.2024 22:29

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

    В комментариях к статье пользователя @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.


    1. Rigidus
      09.11.2024 22:29

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


      1. checkpoint
        09.11.2024 22:29

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

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

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


        1. Rigidus
          09.11.2024 22:29

          (9592 / 5462956111) * 100

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


  1. Atakua
    09.11.2024 22:29

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

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


    1. e2k_sid Автор
      09.11.2024 22:29

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

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

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