Совершать невозможное и раздавать пинки здравому смыслу — в этом и состоит жизнь членов Гуррен-Дана! (C) Камина

Эта статья вступает в техническую полемику со статьей 2015 года за авторством Atakua, подходы из которой я и атакую. Atakua исследует 7 видов интерпретаторов байткода, но делает это без уважения - быстрейшей оказывается двоичная трансляция, которая, по сути, уже не интерпретатор байткода, а форма Ahead-Of-Time компилятора. Эта двоичная трансляция транслирует байткод в машинный код, представляющий собой цепочку вызовов скомпилированных сервисных процедур. Тех самых, что в интерпретаторе байткода отвечают за выполнение каждого опкода.

Но Atakua не выжал из интерпретаторов байткода всю скорость которая возможна. Так что эта статья - туториал: как написать интерпретатор байткода, который может обгонять JIT/AOT-компиляцию по скорости. Интересно? Читайте дальше!

Бенчмарк прилагается. Будет немного хардкора и ни одной сгенерированной нейросетью картинки!

Для затравки посмотрим на гистограмму. Я использую бенчмарк Atakua, добавив в него свои результаты. Самый правый, он же самый маленький синий столбец (native) - это нативный код, данный Atakua в качестве оценки пределов оптимизации. Он не вполне корректен для оценки, потому что в нем нет проверок на количество шагов, границ стека, счетчиков вызываемых процедур и прочих атрибутов виртуальной машины - просто демонстрация минимального предела времени для выбранного алгоритма нахождения простых чисел.

Мои результаты отмечены красным цветом. Да, я смог обогнать двоичную трансляцию. Втрое.

Почему это важно и кому это нужно?

Потому что интерпретатор байткода - это один из лучших способов реализовать среду выполнения "на месте". Это позволяет решать много проблем элегантным способом. Например?

  • чтобы добавить в свой продукт скриптовый язык для автоматизации действий пользователя,

  • для разработки варианта регулярных выражений для анализа не текста, а последовательностей событий, которые приходят из телеметрии мобильного приложения - с миллионов устройств и в реалтайме,

  • для портирования abandonware на современную архитектуру (эмуляторы),

  • создания безопасных "песочниц" для исполнения и эвристического анализа недоверенного кода,

  • разработки системы плагинов для вашего нового браузера,

  • вы делаете кросплатформенное приложение, и ищете способ писать миниум архитектурно-зависимого кода,

  • вы пытаетесь заставить работать смартконтракты Ethereum в не предназначенном для них блокчейне Solana,

  • пишете утилиту инструментирования кода, программный монитор или эмулирующий отладчик для свежеиспеченного System-On-Chip,

  • избавляете человечество от головной боли поиска драйверов,

  • пишете свой Форт для собственного удовольствия.

Почти серебряная пуля, судя по широте применений!

Все это требует понимания, насколько быстрым может быть интерпретатор байткода. И знание методов, как его сделать быстрым. Особенно, если вы не готовы применять тяжелую артиллерию вроде JIT-компиляции и хочется обойтись "малой кровью".

Эта статья поможет вам понять, как написать, возможно, самый быстрый интерпретатор байткода.

Давайте немного восстановим контекст. Вот картинка из статьи Atakua, которая все объясняет (если нет - то стоит освежить в памяти его статью)

Его tailrecursive-вариант хорош: он демонстрирует лучшую скорость среди его интерпретаторов. Почему? Потому что в конце каждой сервисной процедуры находится то, что фортеры называют "NEXT": инлайненный код трех операций: ADVANCE_PC, FETCH_DECODE, и, наконец, DISPATCH. Этот последний DISPATCH и выполняет хвостовой вызов.

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

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

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

Для начала можно сделать все данные состояния интерпретатора глобальными переменными. Это позволит сэкономить на передаче параметров функций, их прологах и эпилогах, сохранении и восстановлении регистров. Хвостовым вызовам не нужны адреса возврата, поэтому нам не нужны call/ret, а все вызовы внутри сервисных процедур и так встраиваются (inline).

Тут мы как раз и можем побить компилятор, оптимизации которого локальны. Глобальный анализ любой программы потребовал бы от компилятора анализировать слишком много путей выполнения. Но, в отличии от компилятора, мы точно знаем, что в tailrecursive все пути выполнения имеют общий паттерн - сервисные процедуры прыгают одна в другую до тех пор, пока не достигнут Halt или Break. Тут мы умнее, или, лучше сказать, более информированы, чем компилятор, который вынужден действовать в ограничениях наиболее общего сценария.

Посмотрим на структуры данных, которые управляют состоянием виртуального процессора и виртуальной машиной в целом:

  typedef uint32_t Instr_t;

  typedef enum {
      Cpu_Running = 0,
      Cpu_Halted,
      Cpu_Break
  } cpu_state_t;

  /* Simulated processor state */
  typedef struct {
      int32_t sp;  /* Stack Pointer */
      uint64_t steps; /* Statistics - total number of instructions */
      uint32_t stack[STACK_CAPACITY]; /* Data Stack */
      uint32_t pc; /* Program Counter */
      const Instr_t *pmem;            /* Program Memory */
      cpu_state_t state;
  } cpu_t;

  /* A struct to store information about a decoded instruction */
  typedef struct {
      Instr_t opcode;
      int length; /* size of instruction (1 or 2 for our bytecode) */
      int32_t immediate; /* argument of opcode if exists */
  } decode_t;

О, Atakua написал очень минималистичную виртуальную машину! Что если мы перенесем все это в регистры? Тогда наша виртуальная машина в процессе своей работы сможет вообще не трогать память (кроме стека):

  #define sp              %rsp
  #define steps           %r8
  #define pc              %r9
  #define prog_mem        %rsi
  #define state           %r15

  #define opcode64        %rdx
  #define opcode32        %edx
  #define immed64         %r14
  #define immed32         %r14d

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

  • использовать 64-разрядные элементы стека вместо 32-разрядных, оставляя верхние биты нулевыми,

  • в качестве смещения использовать указатель стека в регистре %RSP (смещения станут кратными размеру элемента стека)

Таким образом, мы убираем код, который вручную пересчитывает указатель стека, используя вместо него инструкции процессора, которые одновременно помещают/удаляют элемент стека и изменяют %RSP. Так мы упрощаем адресацию и выигрываем в скорости в самом "горячем" коде стековой машины - работе со стеком.

Все эти части интерпретатора работают вместе, улучшение производительности в одном аспекте открывает возможности для улучшения в другом. При этом, оптимизируя интерпретатор, мы ничего не меняем в самой виртуальной машине. Для устранения неоднозначности приведу тут объяснение от ruv:

Следует разделять понятия "машины" (или "виртуальной машины") и "среды исполнения" ("исполняющей среды") для этой машины.

Понятие "стековая машина" (или, "виртуальная стековая машина") подразумевает, что инструкции этой машины берут параметры со стека и кладут результаты на стек. Стек — часть виртуальной машины. Разные стековые машины имеют разный формат "исполняемого" кода.

Код "исполняемый" в кавычках, потому что есть вопрос в том, кто/что его исполняет. Если его исполняет реальный CPU напрямую, то это действительно исполняемый код. Иначе, этот код исполняется другой программой. В Форт-терминологии эта программа называется "адресный интерпретатор", для байткода она будет называться "интерпретатор байткода".

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

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

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

Детали реализации адресного интерпретатора (если он есть) — характеризуют исполняющую среду, а не виртуальную машину.

Если адресный интерпретатор использует стек — то, формально, это другой стек, не стек стековой машины. В некоторых случаях сам адресный интерпретатор (или интерпретатор байткода) может быть конечным автоматом и вообще не использовать стек.

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

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

  /* Удобно запомнить, если воспринимать "b" в имени регистра как "border" */
  #define stack_max       %rbp
  #define stack_min       %rbx

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

  #define steplimit       %rcx
  #define routines        %rdi

Отлично! Мы разместили все переменные в регистрах и у нас даже остались лишние регистры. Два из них можно занять под часто используемые константы:

  # 1 = Cpu_Halted
  #define one             %r11
  # 2 = Cpu_Break
  #define two             %r12

И еще остается два регистра, которые можно использовать чтобы кэшировать два верхних элемента стека. Это используется при реализации форт-машин и помогает улучшить производительность часто выполняемых SWAP и OVER. Ниже я покажу эту технику в деталях.

  #define top             %rax
  #define subtop          %r10

Обратите внимания на выбор %RAX в качестве регистра, который кэширует вершину стека (top). Некоторые машинные команды, такие как DIV, используют регистр %RAX в качестве неявного операнда. И если мы уже имеем операнд на вершине стека, его не придется загружать, что сэкономит нам одну команду ассемблера в реализации сервисной процедуры MOD далее.

Итак, мы заняли все регистры, кроме одного. Назовем его "аккумулятор" и будем использовать в случае необходимости:

  # define acc            %r13


И на третий день Бог создал "Ремингтон" со скользящим затвором, чтобы человек стрелял в динозавров и прикладных программистов... Аминь! (с)

"Но подождите!" - скажет человек с компилятором, - "Разве мы можем вручную распределить все регистры, не оставив ни одного компилятору? Даже Atakua в своей двоичной трансляции прибил только одну переменную к регистру %r15!"

Рекомендация компилятору привязать одну глобальную переменную к регистру - это всего-лишь рекомендация, и компилятор может ее проигнорировать. Но вот прибить все регистры - это уже троллинг. Поэтому, пощадим чувства компилятора и расчехлим ассемблер. Какой ассемблер использовать? Конечно мы будем использовать ассемблер, предназначенный служить бэкендом GCC, а не для того чтобы на нем писал человек. Ассемблер с вывернутым наизнанку порядком операндов, настолько взрывоопасный, что это даже отражено в его названии: GAS.

Итак, каждая сервисная процедура у Atakua заканчивается следующей последовательностью:

  ADVANCE_PC();
  ,*pdecoded = fetch_decode(pcpu);
  DISPATCH();

..и этот код повторяется чуть менее чем везде и представляет собой отличного кандидата для оптимизации. Что же в нем происходит?

  #define DISPATCH() service_routines[pdecoded->opcode](pcpu, pdecoded);

  #define ADVANCE_PC() do {               \
    pcpu->pc += pdecoded->length;         \
    pcpu->steps++;                        \
    if (pcpu->state != Cpu_Running        \
          || pcpu->steps >= steplimit)    \
       return;                            \
    } while(0);

  static inline decode_t fetch_decode(cpu_t *pcpu) {
    return decode(fetch_checked(pcpu), pcpu);
  }

Decode помещает текущую инструкцию в переменную opcode и вычисляет её длину. Если инструкция имеет непосредственный операнд, который следует за ней, то он помещается в переменную immediate. fetch_checked проверят не вышел ли program_counter за пределы байткода программы:

  static inline Instr_t fetch_checked(cpu_t *pcpu) {
      if (!(pcpu->pc < PROGRAM_SIZE)) {
          printf("PC out of bounds\n");
          pcpu->state = Cpu_Break;
          return Instr_Break;
      }
      return fetch(pcpu);
  }

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

Итак, мы будем следовать пути, который проложил Atakua: использование макросов ассемблера заменит нам inline для целей встраивания кода. Для быстрого визуального распознавания я буду именовать их большими буквами.

  .macro FETCH_DECODE
      FETCH_CHECKED
      DECODE
  .endm

Эти двое: FETCH_CHECKED и DECODE - всегда ходят парой.

  .macro FETCH_CHECKED
      .if MAX_PROGRAM_SIZE_CHECK
         ...
      .endif
      FETCH
  .endm

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

Сейчас перейдем к более важному: FETCH и DECODE. Их задача состоит в получении опкода и его непосредственного операнда, если этот опкод его принимает. Но использование целого условного перехода для анализа, нужен ли опкоду непосредственный операнд - расточительно. Лучше мы всегда будем выбирать его, а если опкоду он не нужен - это не наша проблема. Таким образом, можно все свести к двум строчкам:

  .macro FETCH
      mov     (prog_mem, pc, 4), opcode32     # prog_mem[pc]
  .endm

  .macro DECODE
      mov     4(prog_mem, pc, 4), immed32     # prog_mem[pc+1]
  .endm

Вы же помните, что в GAS операнд-источник (source) слева, а операнд-приемник (destination) - справа? Окей, я просто на всякий случай спросил.

Опытный ассемблерный программист может заметить, что мы могли бы избавиться от базового адреса prog_mem, сложив его с pc на старте программы. Я тоже поначалу попал в эту ловушку. В результате программа становится немного медленнее. Это из-за того, что в сервисных процедурах Jump и Je, которые отвечают за прыжки по байткоду, появляется необходимость домножать непосредственный операнд на 4 (размер слова виртуальной машины в байтах). Так как непосредственный операнд прыжков может быть отрицательным числом (для прыжков назад), то оптимальный способ сделать это - использовать арифметический сдвиг SAR. Но даже в этом случае это лишняя команда в часто выполняемой процедуре, которая занимает время. На моей машине это означает, в среднем, разницу между 3.02 и 2.94 секундами выполнения всей программы. Можно пойти на такие жертвы, если надо сэкономить регистр для prog_mem, но в этом нет нужды: регистров пока хватает.

Еще одной отброшенной идей является попытка вместо чтения двух 32-разрядных значений, прочесть одно 64-разрядное и применить сдвиги и перемещения, чтобы получать нужные половины. Но на это уходит больше времени, чем удается выиграть - возможно на машинах с более медленным доступом к памяти это бы сработало лучше.

Наконец, переходим к DISPATCH - последней инструкции каждой сервисной процедуры:

  .macro DISPATCH
      jmp     *(routines, opcode64, 8)
  .endm

Мы совершаем прыжок по адресу, лежащему в массиве указателей. Адрес массива лежит в routunes, номер элемента массива - в opcode64, а размер адреса - 8 байт. По сути, это значит достать значение из routines+(opcode64*8) и прыгнуть по этому адресу. Возможно, эти подробные объяснения будут полезны тем, кто не знаком с ассемблером GAS.

Интересный факт о из жизни opcode64: он инициализируется в FETCH и используется в DISPATCH. И до следующего FETCH любая сервисная процедура может использовать его в качестве временного регистра, убедившись только, что перед следующим FETCH его верхняя половина заполнена нулями. Почти то же самое можно сказать и о immed64 - особенно для тех процедур, которые не используют непосредственное значение. Таким образом у нас уже 3 свободных регистра - с ними мы можем развернуться на полную! Но, не пытайтесь объяснить такую стратегию использования регистров компилятору...

Ах да, мы чуть не забыли про ADVANCE_PC:

  .macro ADVANCE_PC cnt:req
      .if \cnt == 1
        inc     pc
      .else
        lea     \cnt(pc), pc
      .endif

      .if (STEPLIMIT_CHECK || STEPCNT)
        # Аксакалы верят что если разнести инкремент и проверку, то
        # это позволит процессору выполнить все быстрее
        inc     steps
      .endif

      .if STATE_RUNNING_CHECK
        test    state, state        # Cpu_Running(0) != state
        jne     handle_state_not_running
      .endif

      .if STEPLIMIT_CHECK
        cmp     steps, steplimit    # steps >= steplimit
        jl      handle_steplimit_reached
      .endif
  .endm

Так как каждая сервисная процедура сама знает, есть ли у нее непосредственный операнд или нет, я параметризовал этот макрос, чтобы он инкрементировал Program Counter в зависимости от переданного аргумента. В исследуемой виртуальной машине нет опкодов, где Program Counter надо сдвигать больше чем на 2, поэтому использование INC или LEA - оптимально для всех случаев.

Ускорение петли обратной связи может привести к пороговым эффектам. Замедление петли обратной связи может привести к упущенным возможностям.

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

Типичная сервисная процедура у Atakua выглядит так:

  void sr_Swap(cpu_t *pcpu, decode_t *pdecoded) {
      uint32_t tmp1 = pop(pcpu);
      uint32_t tmp2 = pop(pcpu);
      BAIL_ON_ERROR();
      push(pcpu, tmp1);
      push(pcpu, tmp2);
      ADVANCE_PC();
      ,*pdecoded = fetch_decode(pcpu);
      DISPATCH();
  }

Поэтому первое, что нам понадобится - это вспомогательные подпрограммы push() и pop() - они инлайнятся почти во все сервисные процедуры. Их особенность в том, что они проверяют выход за границы стека:

  static inline void push(cpu_t *pcpu, uint32_t v) {
      assert(pcpu);
      if (pcpu->sp >= STACK_CAPACITY-1) {
          printf("Stack overflow\n");
          pcpu->state = Cpu_Break;
          return;
      }
      pcpu->stack[++pcpu->sp] = v;
  }

  static inline uint32_t pop(cpu_t *pcpu) {
      assert(pcpu);
      if (pcpu->sp < 0) {
          printf("Stack underflow\n");
          pcpu->state = Cpu_Break;
          return 0;
      }
      return pcpu->stack[pcpu->sp--];
  }

Поэтому мы должны делать так же:

  .macro PUSH_IMM reg
      .if STACK_CHECK
      cmp     sp, stack_min
      jae     handle_overflow
      .endif

      push    \reg
  .endm

  .macro POP_IMM reg
      .if STACK_CHECK
      cmp     sp, stack_max
      jb      handle_underflow
      .endif

      pop     \reg
  .endm

Опытный системщик сразу заметит здесь, что от части этих проверок можно уклониться: в самом деле, если процедура забирает два слова со стека, а потом кладет два слова на стек, то нужна только одна проверка! К счастью, не потребуется писать сложный макрос, который будет вычислять совокупную проверку, потому что нас ждет классическая оптимизация реализаций языка Форт: кэширование верхушки стека в регистрах!

Чтобы пояснить это, требуется картинка:

Я измерил производительность без кеширования, с кешированием верхнего значения стека и двух верхних значений и решил остановиться на последнем варианте (он показал наилучшие результаты).

Взгляните, процедура SWAP вообще не трогает стек:

  RTN Swap
  xchg   top, subtop
  ADVANCE_PC 1
  FETCH_DECODE
  DISPATCH

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

  .macro RTN name
      .global srv_\name
      .type srv_\name, @function
  srv_\name:
      .if DBGCNT
      incq    cnt_\name(%rip)
      .endif
  .endm

Вам, вероятно, интересно, сколько раз вызывается каждая сервисная процедура при исполнении нашего алгоритма? Вот данные:

  Counters     :
   cnt_Print   :                 9592
   cnt_Je      :            910487889
   cnt_Mod     :            455189149
   cnt_Sub     :            455298740
   cnt_Over    :           1820985370
   cnt_Swap    :            910387890
   cnt_Dup     :                    0
   cnt_Drop    :                99998
   cnt_Push    :               100000
   cnt_Nop     :                    0
   cnt_Halt    :                    1
   cnt_Break   :                    0
   cnt_Inc     :            455198741
   cnt_Jump    :            455198741

Две последних строчки прямо таки намекают, что их можно автоматизировано объединить в одну суперинструкцию - они идут в байткоде друг за другом. И таких мест там полно, например последовательность "OVER, OVER, SWAP" - это прямо таки лабораторная работа по peephole optimization. Надеюсь, я кого-то заинтересовал и скоро можно будет прочесть третью статью об оптимизации виртуальных машин, с еще более впечатляющими результатами.

Конечно, иногда за трюки со стеком приходится платить. Простые процедуры, вроде DROP, заставляют проталкивать через кэш значения по цепочке (поэтому больше двух элементов стека обычно не кэшируют):

  RTN Drop
  movq      subtop, top   # subtop -> top
  POP_IMM   subtop        # from stack -> subtop
  ADVANCE_PC 1
  FETCH_DECODE
  DISPATCH

Но зато мы заставляем более сложные процедуры трогать стек только один, максимум два раза. Взгляните, например на OVER:

  RTN Over
  xchg  top, subtop
  PUSH_IMM  top
  ADVANCE_PC 1
  FETCH_DECODE
  DISPATCH

Вот его грубая альтернатива, без использования кеширования верхних элементов стека (5 обращений к стеку):

  RTN Over
  POP_IMM immed64
  POP_IMM acc
  PUSH_IMM acc
  PUSH_IMM immed64
  PUSH_IMM acc
  ADVANCE_PC 1
  FETCH_DECODE
  DISPATCH

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

  RTN Over
  movq       8(sp), acc
  PUSH_IMM   acc
  ADVANCE_PC 1
  FETCH_DECODE
  DISPATCH

Таким же образом (почти не приходя в сознание) реализуются все остальные процедуры, которые нужны для исполнения оригинального алгоритма Primes. Я не стал реализовать ничего сверх необходимого:

  • Print

  • Je

  • Sub

  • Dup

  • Push

  • Nop

  • Halt

  • Break

  • Inc

  • Jump

Одна процедура заслуживает рассмотрения - MOD:

  RTN Mod
  # Так как мы для top выбрали RAX то не требуется
  # делать "mov top, %rax" для подготовки к делению
  test    subtop, subtop
  je      handle_divide_zero
  xor     %rdx, %rdx        # rdx = opcode64
  div     subtop            # rdx:rax / operand -> rax, rdx
  movq    %rdx, top
  POP_IMM subtop
  ADVANCE_PC 1
  FETCH_DECODE
  DISPATCH

В ней мы видим, что с точки зрения работы со стеком она настолько же проста как DROP.

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

Вот мои результаты профилирования программы в gprof.

  Each sample counts as 0.01 seconds.
    %   cumulative   self              self     total
   time   seconds   seconds    calls  Ts/call  Ts/call  name
   31.23      0.86     0.86                             srv_Swap
   24.37      1.54     0.68                             srv_Over
   19.86      2.09     0.55                             srv_Mod
   16.25      2.54     0.45                             srv_Je
    3.61      2.64     0.10                             srv_Sub
    2.53      2.71     0.07                             srv_Jump
    1.08      2.77     0.03                             srv_Inc

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

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

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

Статья написана, и я отлично повеселился, пора и на работу! Спасибо за внимание!

Весь исходный код можно посмотреть в моем форке репозитория Atakua. Там есть интересные вещи, которые не поместились в статью.

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