В последнее время я часто видел обсуждения репозитория сравнения языков. В нём CRuby был третьим снизу, обгоняя по скорости только R и Python.

Автор репозитория @BenjDicken создал забавную визуализацию производительности каждого из языков. Вот одна из визуализаций, в которой по бенчмаркам Ruby оказывается третьим с конца:

Код этой визуализации взят с https://benjdd.com/languages/ с разрешения @BenjDicken

Репозиторий описывается так:

Репозиторий коллективной разработки небольших бенчмарков для сравнения языков.

В нём есть два бенчмарка:

  1. Loops, делающий упор на циклы, условные операторы и простую математику.

  2. Fibonacci, в котором в первую очередь учитываются лишние затраты на вызов функций и рекурсию.

Пример Loops итеративно выполняется 1 миллиард раз при помощи вложенного цикла:

u = ARGV[0].to_i       
r = rand(10_000)                          
a = Array.new(10_000, 0)                 
	
(0...10_000).each do |i|                     
  (0...100_000).each do |j|               
    a[i] += j % u                     
  end
  a[i] += r                      
end
	
puts a[r]

Пример Fibonacci это простая «наивная» реализация вычисления чисел Фибоначчи1:

def fibonacci(n)
  return 0 if n == 0
  return 1 if n == 1
  fibonacci(n - 1) + fibonacci(n - 2)
end

u = ARGV[0].to_i
r = 0

(1...u).each do |i|
  r += fibonacci(i)
end

puts r

Код на Ruby 3.3.6, выполнявшийся на M3 MacBook Pro @BenjDicken, потратил 29 секунд на пример Loops и 12 секунд на пример Fibonacci. Для сравнения: на выполнение обоих примеров у node.js ушло чуть больше секунды — не очень хорошие показатели для Ruby.

Fibonacci

Loops

Ruby

12,17 с

28,80 с

node.js

1,11 с

1,03 с

В дальнейшем я будут опираться на бенчмарки, запускаемые на моём компьютере. Выполнив тот же бенчмарк на моём M2 MacBook Air, я получил 33,43 секунды для loops и 16,33 секунды для fibonacci — показатели ещё хуже. Node выполнял fibonacci чуть больше 1 секунды, а пример loops — 2 секунды.

Fibonacci

Loops

Ruby

16,33 с

33,43 с

node.js

1,36 с

2,07 с

Кого это волнует?

По большей мере подобные бенчмарки бессмысленны. Python был самым медленным языком в бенчмарке, однако в то же время самым используемым языком Github на октябрь 2024 года. На Ruby написано одно из самых крупных веб-приложений в мире. Недавно я запускал бенчмарк производительности веб-сокетов между веб-сервером Ruby Falcon и node.js, и результаты Ruby были близки к результатам node.js. Вы чаще выполняете миллиард итераций цикла или используете веб-сокеты?

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

Тем не менее:

  1. Мир программирования любит бенчмарки

  2. Быстрый бенчмарк не имеет ценности на практике, но привлекает интерес людей к языку. Кто-то может сказать, что он упрощает масштабирование производительности, но об этом можно поспорить2

  3. То, что выбранный вами язык показывает себя не с лучшей стороны, раздражает. Здорово, когда можно сказать: «Я пользуюсь и наслаждаюсь этим языком, и он быстро работает во всех бенчмарках!»

В случае с этим бенчмарком Ruby у меня было чувство, что в коде на Ruby не применили YJIT, поэтому я проверил репозиторий. Команда имела следующий вид:

ruby ./code.rb 40

Мы уже знаем мои результаты (33 секунды и 16 секунд). Что мы получим, если применим YJIT?

ruby --yjit ./code.rb 40

Fibonacci

Loops

Ruby

2,06 с

25,57 с

Отлично! С YJIT тест Fibonacci получает огромный прирост скорости — время выполнения упало с 16,88 секунды до 2,06 секунды. Скорость уже близка к node.js!

Однако в случае примера с циклом достижения YJIT гораздо более скромные — снижение составило с 33,43 секунды до 25,57 секунды. Почему?

Командные усилия

Не я один попробовал запускать эти примеры кода с YJIT. @bsilva96 задал в X те же вопросы:

https://x.com/bsilva96/status/1861136096689606708

@k0kubun рассуждал, почему всё было так медленно и как можно повысить производительность:

https://x.com/k0kubun/status/1861149512640979260

Давайте разберём его ответ. Он состоит из трёх частей:

  1. Range#each в Ruby 3.4 по-прежнему написан на C

  2. Integer#times переписан с C на Ruby в Ruby 3.3

  3. Array#each переписан с C на Ruby в Ruby 3.4

1. Range#each по-прежнему написан на C, который YJIT не может оптимизировать

Вернёмся к коду на Ruby:

(0...10_000).each do |i|                     
  (0...100_000).each do |j|               
    a[i] += j % u                     
  end
  a[i] += r                      
end

Он написан как range, а range имеет собственную реализацию each, которая, очевидно, написана на C. В кодовой базе CRuby относительно легко ориентироваться. Давайте найдём эту реализацию.

У большинства базовых классов в Ruby есть файлы верхнего уровня на C, названные по их именам: в данном случае у нас имеется range.c в корне проекта. У CRuby есть достаточно удобочитаемый интерфейс для раскрытия функций на C в виде классов и методов: существует функция Init, обычно находящаяся внизу файла. Внутри этой Init классы, модули и методы раскрываются из C в Ruby. Вот соответствующие части Init_Range:

void
Init_Range(void)
{
  //...
  rb_cRange = rb_struct_define_without_accessor(
    "Range", rb_cObject, range_alloc,
    "begin", "end", "excl", NULL);

  rb_include_module(rb_cRange, rb_mEnumerable);
  // ...
  rb_define_method(rb_cRange, "each", range_each, 0);

Сначала мы определяем класс Range при помощи rb_struct_define.... Мы назвали его Range с суперклассом Object (rb_cObject), и параметрами инициализации (beginend и параметром, определяющим, нужно ли исключать последнее значение, например, синтаксис интервала .. или ...).

Далее мы включаем Enumerable при помощи rb_include_module. Это даёт нам доступ ко всем замечательным методам перечислений Ruby наподобие mapselectinclude? и кучи других. Достаточно предоставить реализацию each, и она займётся всем остальным.

Затем мы определяем метод each. Он реализован функцией range_each на C и получает ноль явных аргументов (блоки в этом количестве не учитываются).

range_each мощна. Она длиной почти сто строк и имеет несколько версий. Я выделю несколько, объединив их вместе:

static VALUE
range_each(VALUE range)
{
  //...
  range_each_fixnum_endless(beg);
  range_each_fixnum_loop(beg, end, range);
  range_each_bignum_endless(beg);
  rb_str_upto_endless_each(beg, sym_each_i, 0);
  // и другие...

Эти функции C обрабатывают все вариации интервалов, которые вы можете использовать в своём коде:

(0...).each
(0...100).each
("a"..."z").each
# и так далее...

Почему важно, что Range#each написана на C? Это означает, что YJIT не может исследовать её — оптимизации останавливаются на вызове функции и возобновляются после возвратов вызовов функции. Функции C быстры, но YJIT может ещё больше ускорять работу, создавая специализации для «горячих» путей исполнения кода. У Аарона Паттерсона есть отличная статья Ruby Outperforms C, из которой можно узнать о некоторых таких специализированных оптимизациях.

2. Оптимизируем цикл: Integer#times была переписана с C на Ruby в Ruby 3.3

«Горячий» путь (на котором тратится основное время CPU) — это Range#each, то есть функция C. YJIT не может оптимизировать функции C, для него они остаются «чёрными ящиками». Так что же можно сделать?

Мы переписали Integer#times на Ruby в 3.3

Интересно! В Ruby 3.3, Integer#times была переписана из функции C в метод Ruby! Вот версия из 3.3+, она довольно простая:

def times
  #... небольшой код для взаимодействия с C
  i = 0
  while i < self
    yield i
    i = i.succ
  end
  self
end

Очень просто. Это обычный цикл while. Но важнее всего то, что это код на Ruby, то есть YJIT сможет выполнить интроспекцию и оптимизировать его!

Примечание об Integer#succ

Немного странная часть этого кода — i.succ. Я никогда не слышал о Integer#succ, который, очевидно, возвращает «последующий элемент» integer.

В начале 2024 года был PR для повышения производительности Integer#succ, помогший мне понять, зачем его вообще использовать:

Мы используем Integer#succ при переписывании методов циклов на Ruby (например, Integer#times и Array#each), потому что opt_succ (i = i.succ) быстрее диспетчеризировать для интерпретатора, чем putobject 1; opt_plus (i += 1).

https://github.com/ruby/ruby/pull/9519

Integer#succ похож на чит-код виртуальной машины. Он получает стандартную операцию (прибавление 1 к integer) и превращает её из двух операций виртуальной машины в одну. Мы можем вызвать disasm для метода times, чтобы посмотреть на это в действии:

puts RubyVM::InstructionSequence.disasm(1.method(:times))

Метод Integer#times разбивается на большой объём байт-кода Ruby VM, но нас волнуют только несколько строк:

...
0025 getlocal_WC_0   i@0
0027 opt_succ        <calldata!mid:succ, ARGS_SIMPLE>[CcCr]
0029 setlocal_WC_0   i@0
...
  • getlocal_WC_0 получает переменную i из текущей области видимости. Это i в i.succ

  • opt_succ выполняет вызов succ  в i.succ. Он выполняет вызов или самого метода Integer#succ, или оптимизированной функции C для малых чисел

  • В Ruby 3.4 со включенным YJIT малые числа ещё сильнее оптимизируются в машинный код (это просто примечание, этого не показано в машинном коде VM)

  • setlocal_WC_0 присваивает результат opt_succ нашей локальной переменной i

Если мы поменяем i = i.succ на i += 1, то вместо opt_succ будут выполняться две операции VM :

...
0025 getlocal_WC_0        i@0
0027 putobject_INT2FIX_1_
0028 opt_plus             <calldata!mid:+, argc:1, ARGS_SIMPLE>
0029 setlocal_WC_0        i@0
...

По сути, всё осталось таким же, как раньше, но теперь нам нужно выполнять два этапа вместо одного:

  • putobject_INT2FIX_1_ записывает целое число 1 в стек виртуальной машины

  • opt_plus — это + в += 1, он вызывает или метод Ruby + , или оптимизированную функцию C для малых чисел

  • Вероятно, для opt_plus тоже существует YJIT-оптимизация

Главный вывод из этого кода можно сделать такой: на уровне VM и JIT выполняются глубокие оптимизации. При написании обычных программ на Ruby мы не учитываем и не должны учитывать разницу между одной или двумя командами машинного кода. Но на уровне JIT, в масштабе миллионов и миллиардов операций это важно!

Вернёмся к Integer#times

Давайте попробуем снова выполнить код бенчмарка с использованием times! Вместо итераций по интервалам мы просто выполним итерации для 10_000 и 100_000 times:

u = ARGV[0].to_i        
r = rand(10_000)        
a = Array.new(10_000, 0)
	
10_000.times do |i|
  100_000.times do |j|
    a[i] += j % u
  end
  a[i] += r
end
	
puts a[r]

Loops

Range#each

25,57 с

Integer#times

13,66 с

Отлично! При использовании Integer#times влияние YJIT гораздо сильнее. Он существенно увеличивает скорость, снижая время на моей машине до 13,66 секунды. На машине @k0kobun время снизилось до 9 секунд (и до 8 секунд на Ruby 3.4).

Вероятно, чтобы время стало меньше 8 секунд, понадобится уже Ruby 3.5.

Стоит ожидать от Ruby 3.5 ещё более высокой производительности. Поглядим!

3. Array#each была переписана с C на Ruby в Ruby 3.4

В CRuby продолжается переписывание кода C на Ruby, и в Ruby 3.4 одним из таких изменений стал Array#each. Вот пример первой попытки его реализации:

def each
  unless block_given?
    return to_enum(:each) { self.length }
  end
  i = 0
  while i < self.length
    yield self[i]
    i = i.succ
  end
  self
end

Очень просто и читаемо! И код можно оптимизировать YJIT!

К сожалению, из-за чего-то, связанного с внутренним устройством CRuby, он содержал условия гонки. В Ruby 3.4 была внедрена более поздняя реализация.

def each
  Primitive.attr! :inline_block, :c_trace

  unless defined?(yield)
    return Primitive.cexpr! 'SIZED_ENUMERATOR(self, 0, 0, ary_enum_length)'
  end
  _i = 0
  value = nil
  while Primitive.cexpr!(%q{ ary_fetch_next(self, LOCAL_PTR(_i), LOCAL_PTR(value)) })
    yield value
  end
  self
end

В отличие от первой реализации и от Integer#times, здесь всё стало немного загадочнее. Это определённо не тот чистый код на Ruby, который следует писать. Почему-то модуль Primitive как будто позволяет выполнять код на C из Ruby, и благодаря этому он избегает условий гонки, присутствующих в решении на чистом Ruby3.

Я думаю, что благодаря получению индексов и значений при помощи кода на C операция становится более атомарной. Понятия не имею, почему для возврата перечислителя используется Primitive.cexpr!, а также какое значение передаёт Primitive.attr! :inline_block.

На самом деле упомянутый выше исходный код Integer#times тоже содержал синтаксис Primitive. Ядро метода соответствует тому, что мы уже рассмотрели, оно полностью написано на Ruby, но в начале этого метода есть вызовы Primitive для :inline_block и возврат перечислителя:

def times
  Primitive.attr! :inline_block
  unless defined?(yield)
    return Primitive.cexpr! 'SIZED_ENUMERATOR(self, 0, 0, int_dotimes_size)'
  end
  #...

Да, это более загадочно, чем Integer#times, но Array#each по большей части написан на Ruby (в Ruby 3.4+). Давайте попробуем использовать массивы вместо интервалов и times:

u = ARGV[0].to_i
r = rand(10_000)
a = Array.new(10_000, 0)
	
outer = (0...10_000).to_a.freeze
inner = (0...100_000).to_a.freeze
outer.each do |i|
  inner.each do |j|
    a[i] += j % u
  end
  a[i] += r
end
	
puts a[r]

Несмотря на встроенный код на C, YJIT, похоже, по-прежнему способен выполнять приличные оптимизации производительности. Скорость оказывается примерно наравне с Integer#times!

Loops

Range#each

25,57 с

Integer#times

13,66 с

Array#each

13,96 с

Микробенчмаркинг производительности Ruby

Я форкнул репозиторий сравнения языков и создал собственный репозиторий Ruby Microbench. Он использует все описанные выше примеры, а также множество других способов выполнения итераций в Ruby: https://github.com/jpcamara/ruby_microbench

Вот результаты его прогона в Ruby 3.4 с YJIT и без него:

fibonacci

array#each

range#each

times

for

while

loop do

Ruby 3.4 YJIT

2,19 с

14,02 с

26,61 с

13,12 с

14,91 с

37,10 с

13,95 с

Ruby 3.4

16,49 с

34,29 с

33,88 с

33,18 с

36,32 с

37,14 с

50,65 с

Понятия не имею, почему написанный мной пример цикла while такой медленный. Я ожидал, что он будет работать намного быстрее. Возможно, я написал его как-то не так — можете открыть issue или PR, если знаете, в чём ошибка. loop do (взятый из примера @timtilberg) выполняется примерно с той же скоростью, что и Integer#times, однако с отключенным YJIT его производительность ужасна.

Кроме запуска Ruby 3.4 я ради интереса воспользовался rbenv для запуска:

  • Ruby 3.3

  • Ruby 3.3 YJIT

  • Ruby 3.2

  • Ruby 3.2 YJIT

  • TruffleRuby 24.1

  • Ruby Artichoke

  • MRuby

Ниже представлены некоторые из прогонов тестов:

fibonacci

array#each

range#each

times

for

while

loop do

Ruby 3.4 YJIT

2,19 с

14,02 с

26,61 с

13,12 с

14,91 с

37,10 с

13,95 с

Ruby 3.4

16,49 с

34,29 с

33,88 с

33,18 с

36,32 с

37,14 с

50,65 с

TruffleRuby 24.1

0,92 с

0,97 с

0,92 с

2,39 с

2,06 с

3,90 с

0,77 с

MRuby 3.3

28,83 с

144,65 с

126,40 с

128,22 с

133,58 с

91,55 с

144,93 с

Artichoke

19,71 с

236,10 с

214,55 с

214,51 с

215,95 с

174,70 с

264,67 с

На основании этих данных я взял первоначальную визуализацию и создал визуализацию конкретно для Ruby и прогона fibonacci:

Ускоряем range#each

Можем ли мы отдельно от @k0kobun сделать быстрее работу range#each? Если я тупо заменю класс Range реализацией на чистом Ruby, то всё действительно становится намного быстрее! Вот моя реализация:

class Range
  def each
    beginning = self.begin
    ending = self.end
    i = beginning
    loop do
      break if i == ending
      yield i
      i = i.succ
    end
  end
end

А вот изменение в производительности — на 2 секунды медленнее, чем times. Совсем неплохо!

Потраченное время

Range#each на C

25,57 с

Range#each на Ruby

16,64 с

Очевидно, что всё это чрезмерно упрощено. Я не обрабатываю различные случаи Range и могу упускать какие-то нюансы. Кроме того, большинство виденных мной переписанных на Ruby методов вызывает для некоторых операций класс Primitive. Мне бы хотелось больше узнать о том, когда и зачем это нужно.

Но! Это показывает мощь переноса кода с C и использования оптимизаций YJIT. Он может улучшать производительность такими способами, которые было бы сложно или невозможно воссоздать в обычном коде на C.

Стандартная библиотека YJIT

В прошлом году Аарон Паттерсон написал статью Ruby Outperforms C, в которой он переписал C-расширение на Ruby для парсинга GraphQL. Благодаря оптимизациям YJIT код на Ruby превзошёл по производительности код на C.

Это заставило меня задуматься о том, что было бы любопытно увидеть появление некой «стандартной библиотеки YJIT», в которой базовая функциональность Ruby, написанная на C, заменялась бы реализациями на Ruby для тех, кто пользуется YJIT.

Оказалось, именно этим и занимается команда разработчиков ядра YJIT. Во многих случаях они полностью избавились от кода на C, но недавно создали блок with_yjit. Код будет задействоваться только при включенном YJIT, в противном случае будет работать код на C. Например, вот так реализован Array#each:

with_yjit do
  if Primitive.rb_builtin_basic_definition_p(:each)
    undef :each

    def each # :nodoc:
      # ... мы рассматривали этот код ранее...
    end
  end
end

В Ruby 3.3 YJIT можно инициализировать ленивым образом. К счастью, этим занимается код with_yjit — если включен YJIT, то будут выполняться соответствующие версии методов with_yjit:

# Использует встроенный C
[1, 2, 3].each do |i|
  puts i
end

RubyVM::YJIT.enable

# Использует версию на Ruby, которую можно оптимизировать YJIT
[1, 2, 3].each do |i|
  puts i
end

Это связано с тем, что with_yjit — это «хук» YJIT, вызываемый в момент включения YJIT. После вызова он удаляется из среды выполнения при помощи undef :with_yjit.

Исследуем оптимизации YJIT

Мы видели код на Ruby и видели код на C. Мы видели байт-код Ruby VM. Почему бы не сделать ещё один шаг вперёд и не рассмотреть машинный код? А может даже код на Rust? Постойте, не торопитесь сбегать!

Если вы не сбежали и не перескочили к следующему разделу, давайте рассмотрим небольшой кусочек YJIT!

Можно изучить машинный код, генерируемый YJIT. Для этого нужно собрать CRuby из исходников с флагами отладки YJIT. Если вы работаете на Mac, то можете посмотреть мою настройку MacOS для хакинга с CRuby или мою настройку docker для хакинга с CRuby, там есть более подробные инструкции по сборке Ruby. Самое простое — это перейти к ./configure Ruby и передать опцию --enable-yjit=dev:

./configure --enable-yjit=dev
make install

В качестве примера кода на Ruby давайте используем пример Integer#times:

u = ARGV[0].to_i
r = rand(10_000)
a = Array.new(10_000, 0)
	
10_000.times do |i|
  100_000.times do |j|
    a[i] += j % u
  end
  a[i] += r
end
	
puts a[r]

Так как мы собирали Ruby с YJIT в режиме разработки, при запуске программы ruby можно передать флаг --yjit-dump-disasm:

./ruby --yjit --yjit-dump-disasm test.rb 40

Благодаря этому мы сможем просмотреть созданный машинный код. Сосредоточимся только на одной маленькой части — эквиваленте в машинном коде байт-кода Ruby VM, который мы читали ранее. Вот первоначальный байт-код VM для opt_succ, сгенерированный при вызове i.succ метода Integer#succ:

...
0027 opt_succ        <calldata!mid:succ, ARGS_SIMPLE>[CcCr]
...

А вот машинный код, который генерирует в этом сценарии YJIT на моём Mac M2 с архитектурой arm64:

# Block: times@<internal:numeric>:259 
# reg_mapping: [Some(Stack(0)), None, None, None, None]
# Insn: 0027 opt_succ (stack_size: 1)
# call to Integer#succ
# guard object is fixnum
0x1096808c4: tst x1, #1
0x1096808c8: b.eq #0x109683014
0x1096808cc: nop 
0x1096808d0: nop 
0x1096808d4: nop 
0x1096808d8: nop 
0x1096808dc: nop 
# Integer#succ
0x1096808e0: adds x11, x1, #2
0x1096808e4: b.vs #0x109683048
0x1096808e8: mov x1, x11

Честно говоря, я понимаю 25% из этого, а для изучения остальных 75% использовал ИИ и собственную логику. Можете спокойно критиковать меня, если я в чём-то немного ошибся, я люблю узнавать новое. Разберём всё по порядку.

# Block: times@<internal:numeric>:259

Это приблизительно соответствует строке i = i.succ в методе Integer#times в numeric.rb. Я говорю «приблизительно», потому что в своём текущем коде я вижу это в строке 258, но, возможно, он показывает конец блока, который он выполнял, поскольку YJIT компилирует «блоки» кода:

256: while i < self
257:   yield i
258:   i = i.succ
259: end

# reg_mapping: [Some(Stack(0)), None, None, None, None]
# Insn: 0027 opt_succ (stack_size: 1)
# call to Integer#succ

Понятия не имею, что значит reg_mapping — возможно, отображение того, как он использует регистр CPU? Insn: 0027 opt_succ выглядит очень знакомо! Это наш байт-код VM! call to Integer#succ — это просто полезный комментарий. YJIT способен добавлять комментарии в машинный код. Мы всё ещё из них не выбрались.

# guard object is fixnum

А это любопытно. Я смог найти соответствующий фрагмент кода на Rust, отображаемый непосредственно в это. Давайте рассмотрим его:

fn jit_rb_int_succ(
  //...
  asm: &mut Assembler,
  //...
) -> bool {
  // Guard the receiver is fixnum
  let recv_type = asm.ctx.get_opnd_type(StackOpnd(0));
  let recv = asm.stack_pop(1);
  if recv_type != Type::Fixnum {
    asm_comment!(asm, "guard object is fixnum");
    asm.test(recv, Opnd::Imm(RUBY_FIXNUM_FLAG as i64));
         asm.jz(Target::side_exit(Counter::opt_succ_not_fixnum));
  }

  asm_comment!(asm, "Integer#succ");
  let out_val = asm.add(recv, Opnd::Imm(2)); // 2 is untagged Fixnum 1
  asm.jo(Target::side_exit(Counter::opt_succ_overflow));

  // Push the output onto the stack
  let dst = asm.stack_push(Type::Fixnum);
  asm.mov(dst, out_val);

  true
}

Отлично! Это настоящая реализация YJIT Rust вызова opt_succ. Именно эту оптимизацию внёс @k0kubun, чтобы ещё больше повысить производительность opt_succ по сравнению с вызовами функций на C. Мы находимся в части, проверяющей, работаем ли мы с Fixnum — это способ хранения малых integer внутри CRuby:

if recv_type != TypeFixnum 
  asm_comment!(asm, "guard object is fixnum");
  asm.test(recv, Opnd::Imm(RUBY_FIXNUM_FLAG as i64));
  asm.jz(Target::side_exit(Counter::opt_succ_not_fixnum));
}

Это превращается в такой машинный код:

# guard object is fixnum
0x1096808c4: tst x1, #1
0x1096808c8: b.eq #0x109683014

asm.test генерирует tst x1, #1, что по мнению ИИ-бота, которому я задал вопрос, проверяет младший бит, являющийся «меткой» Fixnum, обозначающей, что это Fixnum. Если это Fixnum, то результат равен 1, а b.eq равен false. Если это не Fixnum, результат равен 0, а b.eq равен true и выполняется переход из этого кода.

0x1096808cc: nop 
0x1096808d0: nop 
0x1096808d4: nop 
0x1096808d8: nop 
0x1096808dc: nop 

«NOP для выравнивания/заполнения». Спасибо, ИИ! Я не знаю, зачем это нужно, но, по крайней мере, знаю, чем это может быть.

Наконец мы действительно прибавляем 1 к числу.

asm_comment!(asm, "Integer#succ");
let out_val = asm.add(recv, Opnd::Imm(2)); // 2 is untagged Fixnum 1
asm.jo(Target::side_exit(Counter::opt_succ_overflow));

// Push the output onto the stack
let dst = asm.stack_push(Type::Fixnum);
asm.mov(dst, out_val);

Код на Rust генерирует наш комментарий Integer#succ. Для прибавления 1, поскольку данные «метки Fixnum» встроены в integer, на самом деле нужно прибавить 2 при помощи adds x11, x1, #2. Если произойдёт переполнение доступного пространства, то выполнится выход по другому пути кода: b.vs — это ветвление при переполнении. В противном случае результат сохраняется при помощи mov x1, x11!

# Integer#succ
0x1096808e0: adds x11, x1, #2
0x1096808e4: b.vs #0x109683048
0x1096808e8: mov x1, x11

Да уж, большой объём информации. И, похоже, здесь выполняется много работы, но поскольку это такой низкоуровневый машинный код, он, предположительно, очень быстрый. Мы изучили лишь самую крошечную часть того, что способен генерировать YJIT; JIT очень сложные!

Благодарю @k0kubun за помощь и ссылку на документацию YJIT, в которой тоже содержится множество дополнительных опций.

Будущее оптимизаций CRuby

Ирония реализации языка заключается в том, что часто вы меньше работаете с языком, который реализуете, чем на более низком уровне: в случае Ruby это в основном C и немного Rust.

Благодаря слою наподобие YJIT в будущем потенциально бо́льшая часть языка станет чистым Ruby, и разработчикам на Ruby будет проще вносить свой вклад. У многих языков есть маленькое низкоуровневое ядро, а основная часть языка написана на нём самом (как, например, Java). Возможно, когда-нибудь это станет и будущим CRuby! А пока пусть команда разработчиков YJIT создаёт новые оптимизации YJIT!

  1. Под «наивным» в данном случае подразумевается, что есть более эффективные способы программного вычисления чисел Фибоначчи

  2. Предок YJIT под названием MJIT сделал Ruby гораздо быстрее в некоторых бенчмарках. Но в больших реальных приложениях Rails он на самом деле замедлял работу

  3. При выполнении кода на C он сам выбирает, нужно ли отключать GVL, чтобы потокам было сложнее повреждать или модифицировать данные посередине операции. Первоначальная версия на Ruby могла получать GVL в точках, способных сделать массив невалидным. По крайней мере, я понимаю ситуацию так.

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


  1. IlyaChizhanov
    10.12.2024 21:58

    Бенчмарки - это конечно хорошо, но как на счёт аннотации типов?