VM rubiego - jak działa

Hej,
od jakiegoś czasu zastanawiam się w jaki sposób działają VM rubiego, szczególnie MRI (YARV) i Rubiniusa. Z tego co się orientuję (czarna dziura w edukacji) taki gcc przekształca kod C na kod maszynowy. W przypadku rubiego (na pewno rubiniusa) kod jest zamieniany na instrukcje do VM. I właśnie - jak taka VM je wykonuje - przekształca na kod rubiniusa i jakoś wykonuje?

W uproszczeniu wszystkie VM (MRI, Rubinius, JRuby i Topaz) mają interpreter który wykonuje kod Ruby, instrukcja za instrukcją. Ten interpreter jest z reguły napisany w czymś niskopoziomowym (MRI - C, Rubinius - C++, JRuby - Java, Topaz - RPython) i działa na zasadzie:

  1. Pobieram instrukcję kodu Ruby
  2. Wykonuje ją w kontekście środowiska maszyny

Punkt 1. w przypadku Ruby 1.8 działa na zasadzie “AST tree walker” czyli działa bezpośrednio na AST kodu Ruby i dla każdego node’a wykonuje odpowiadającą funkcję w C
We wszystkich innych VM jest używany bytecode czyli każdy node AST jest zamieniany na serię niskopoziomowych, abstrakcyjnych instrukcji zrozumiałym dla danej VM, np. to jest “if” w Rubiniusie

Czyli zamiast drzewa AST:

[code]./bin/rbx compile -A -e ‘puts “HELLO”’

Script
@pre_exe: []
@name: :script
@file: “(snippet)”
@body:
SendWithArguments
@privately: true
@line: 1
@block: nil
@check_for_local: false
@vcall_style: false
@name: :puts
@receiver:
Self
@line: 1
@arguments:
ActualArguments
@line: 1
@splat: nil
@array: [
StringLiteral [0]
@line: 1
@string: “HELLO”
][/code]
masz ciąg instrukcji bytecodu:

[code]./bin/rbx compile -B -e ‘puts “HELLO”’

============= :script ==============
Arguments: 0 required, 0 post, 0 total
Arity: 0
Locals: 0
Stack size: 2
Literals: 2: “HELLO”, :puts
Lines to IP: 1: 0…10

0000: push_self
0001: push_literal “HELLO”
0003: string_dup
0004: allow_private
0005: send_stack :puts, 1
0008: pop
0009: push_true
0010: ret
----------------------------------------[/code]
Punkt 2 - to już w dużej mierze zależy od szczegółów danej VM. Ogólnie rzecz biorąc interpreter pobiera każdą instrukcję bytecodu i wykonuje ją (z reguły poprzez wywołanie odpowiadającej jej funkcji) sekwencyjnie w swoim kontekście.

Rubinius, JRuby i Topaz mają oprócz interpretera Just-in-time compiler, który stara się optymalizować często wykonywane fragmenty kodu głównie za sprawą inline’owania funkcji, uproszczenia odczytu/zapisu do pamięci tych samych danych i całej masy innych technik optymalizacyjnych.

Jeśli masz jeszcze jakieś pytania to śmiało. Przy okazcji - kod Rubiniusa jest b. dobrym miejscem żeby podejrzeć i zrozumieć jak to działa i dowiedzieć się jak działa Ruby. Kod MRI - nie bardzo :wink:

Dzięki za odpowiedź!

Rozumiem, że masz na myśli funkcję w C/C++. Czyli czy mogę to zrozumieć że ruby jest w pewnym sensie proxy dla wywoływania metod w C? Czy jak o tym myśleć? Może znacie jakieś przystępne materiały na ten temat?

[quote=slawosz]Hej,
od jakiegoś czasu zastanawiam się w jaki sposób działają VM rubiego, szczególnie MRI (YARV) i Rubiniusa.[/quote]
This is a thread for Janek Stępień! Wyślę mu zaraz linka do tego wątku :slight_smile:

jezeli bede uzywal JRUBY lub MRI wiekszosc gemow bedzie na tym działać czy beda wystepowaly problemy?

MRI = “Matz’s Ruby Interpreter” - to jest ta “pierwotna” inplementacja Ruby’ego, której najprawdopodobniej używasz już teraz.

Znajomy też się kiedyś zastanawiał i popełnił taki post na blogu: http://blog.txus.io/2012/04/learning-with-terror-vm/

Jeżeli używają Travisa, to możesz sprawdzić na travis-ci.org w jakich środowiskach uruchamiają swoje testy.

[quote=slawosz]Dzięki za odpowiedź!

Rozumiem, że masz na myśli funkcję w C/C++. Czyli czy mogę to zrozumieć że ruby jest w pewnym sensie proxy dla wywoływania metod w C? Czy jak o tym myśleć? Może znacie jakieś przystępne materiały na ten temat?[/quote]
Interpreter to bardziej skomplikowana bestia niż tylko proxy, ale tak możesz o tym myśleć na początek. Ogólnie to tak działa ale nie do końca bo w pewnym momencie przestaje się opłacać wywoływanie funkcji C dla każdej instrukcji bytecodu a bardziej opłaca się spojrzenie na całość programu, który działa, jego analizę podczas gdy działa i wygenerowanie zoptymalizowanego kodu maszynowego samemu (zamiast wołania funkcji w C) - czyli posiadanie JIT kompilatora (szczególnie przy językach dynamicznie typowanych takich jak Ruby gdzie faktyczny typ obiektu znany jest dopiero podczas runtime’u).

MRI z tego co wiem (nie śledzę tego co się tam dzieje ostatnio) nie ma JIT kompilatora tylko kilka optymalizacji na poziomie bytecodu i w interpreterze więc bardzo upraszczając możesz założyć, że programy napisane w Ruby będą działać szybciej na maszynach z JIT’em niż na MRI. Oczywiście rzeczywistość jest bardziej skompilkowana i okazuje się, że mnóstwo kodu, którego ludzie na co dzień używają i który akurat jest w hot spocie to nie Ruby tylko C-extensions (np. drivery albo wrappery różnych bibliotek w C np. libxml2 czy openssl), które są pisane pod MRI “C-API” więc MRI wciąż jest szybszy niż którykolwiek z JIT’ów w takich programach niż VM’y probujące emulować obsługę takich extensionów (jak Rubinius czy FFI) i to nie powinno nikogo dziwić.

Tym paragrafem powyżej zbaczam jednak z głównego temat a więc powrót:

Ogólny zarys procesu parsowania kodu, generowania AST i bytecodu w Rubiniusie znajdziesz tutaj

Listę instrukcji bytecodu i opis tutaj

Odnośnie samego interpretera - nie ma dokładnej dokumentacji do tego dla żadnej implementacji Rubiego bo to jest ogromny temat. Najlepiej po prostu zacząć analizować kod (albo zbudować prosty interpreter czy kompilator - na coursera.org jest ciekawy kurs budowy kompilatora coolc Możesz się też pokusić o zbudowanie prostego interpretera (jeśli masz kilka miesięcy wolnego czasu :slight_smile: czy analizę interpretera Lua na przykład) ( Na sieci znajdziesz sporo materiałów objaśniających ogólne zasady budowy interpretera i JIT kompilatora. Znajdziesz sporą ilość na temat JVM’a, kilka ciekawych publikacji na temat PyPy/Topaz’a, i kilka blog postów o Rubinius’ie. Chyba o MRI jest najmniej na ten temat z wiadomych powodów.

Możesz zacząć od:

Definicje dla instrukcji bytecodu w Rubiniusie

Przeczytaj też ten wątek gdzie padają pytania o to jak działa JIT i “czy mogę zobaczyć asm, który jest generowany dla metod” w kontekście Rubinius’a.

Zapraszam też na #rubinius na Freenode jeśli masz jakieś konkretne pytania odnośnie Rubinius’a.

Przy okazji - jeśli ktoś wie gdzie w kodzie MRI znajdują się komponenty o których piszę powyżej to proszę o podzielenie się. Dzięki.

“Kompilatory. Reguły, metody i narzędzia”
Autorzy: Jeffrey D. Ullman, Alfred V. Aho, Ravi Sethi

Dzięki za namiar na wątek, Tomaszu.

Zacznę od uzupełnienia tego co zostało napisane powyżej o kilka słów na temat
MRI, czyli oryginalnej implementacji. Tak jak napisał Karol, MRI do wersji 1.8
włącznie korzystało z naiwnej metody wykonywania kodu typu AST-walker. Kod Ruby
był przy tłumaczony na drzewo reprezentujące strukturę syntaktyczną kodu (tj.
parsowany). Następnie interpreter “chodził” po tej strukturze danych w tę i we w
tę wykonując operacje zależne od elementu, który właśnie odwiedzał.

W 2007. powyższa wolna i starożytna metoda została zastąpiona normalną maszyną
wirtualną, szerzej znaną jako YARV. Po parsowaniu drzewo AST jest tłumaczone na
bytecode reprezentowany przy pomocy instancji InstructionSequence. Każda
instancja reprezentuje pewną prostą operację modyfikującą stan maszyny
wirtualnej.

Podobnie jak w wypadku Rubinius, wszystkie instrukcje YARV zostały
zdefiniowane przy pomocy DSL w pliku insns.def. Oba rozwiązania są
oparte na artykule Ertla i in.. Z zapisu w postaci DSL generowany jest
kod odpowiedzialny za wykonywanie oraz mechanizm skoków pomiędzy fragmentami
maszyny wirtualnej obsługującymi poszczególne instrukcje. Jeśli chcesz
dowiedzieć się więcej na temat sposobu działania maszyny, poczytaj o threaded
code
(nie mylić z wątkami w kontekście programowania współbieżnego).

Maszyna wirtualna porusza się po tablicy zawierającej wygenerowany bytecode i
wykonuje odpowiedni kod przypisany do odpowiednich instrukcji maszyny. Rdzeń tej
części maszyny znajdziesz w vm_exec.c. Linia #include "vm.inc" to
miejsce, w którym include’owany jest cały kod odpowiadający za obsługę
wszystkich instrukcji wygenerowany z wyżej wspomnianego insns.def. Kilka tysięcy
linii C przeplatanego markami. Materiał na mem typu “cannot be unseen”.

Do tego miejsca sposób działania MRI i Rubinius jest dość podobny. MRI pozostaje
na tym stadium aż do czasu zakończenia procesu: skacze po wnętrzu vm_exec
wykonując odpowiednie kawałki kodu napisanego w C, w zależności od aktualnie
przetwarzanej instrukcji.

Rubinius natomiast, tak jak napisał Karol, stara się optymalizować dalej. Zbiera
informacje na temat wykonywanego kodu i w miarę identyfikowania wąskich gardeł
kompiluje co gorętsze fragmenty kodu korzystając z LLVM. Niestety nie znam
szczegółów tego procesu, ale eksperymentując z Rubinius odnoszę wrażenie, że
kompilacja JIT wykonywana w ramach tej maszyny wirtualnej nie stanowi
odpowiedzi na problemy wydajnościowe Ruby
. Zależy to oczywiście od
konkretnego przypadku.

Kompilacja JIT to główna ale nie jedyna cecha odróżniająca Rubinius od MRI.
Kolejna istotna różnica do garbage collector. Rubinius przemieszcza obiekty
pomiędzy kilkoma “kopcami” w zależności od czasu jaki dany obiekt istnieje
(słowa kluczowe: generational garbage collection). W efekcie im obiekt starszy
tym rzadziej Rubinius sprawdza, czy należy go usunąć z pamięci, i traci mniej
czasu na niepotrzebne cykle GC.

W tym miejscu nasuwa się pytanie “hola, hola, przecież można po prostu zastąpić
istniejący, słabiutki GC MRI czymś nowym i problem z główy”. Niestety napotykamy
tu na sporą przeszkodę, jaką poruszył już w swoich wpisach Karol: API C
udostępniane przez MRI.

W telegraficznym skrócie problem kształtuje się następująco. MRI udostępniło
autorom rozszerzeń pisanych w C bardzo bogaty i dający dostęp do wielu
szczegółów implementacji interfejs. Do zbyt wielu. Przykładowo, API C MRI
gwarantuje, że adres obiektu się nie zmienia (tj. obiekt nie jest przenoszony
w pamięci). Założenie to uniemożliwia zaimplementowania w MRI generacyjnego
garbage collectora, do którego działania konieczna jest możliwość przenoszenia
obiektów pomiędzy pulami pamięci.

Kolejny problem z API C MRI to fakt, że strasznie trudno je zaimplementować w
innych implementacjach Ruby. Przyczyn jest sporo, jak choćby założenie o
stałości adresu obiektu. JRuby i Rubinius heroicznym wysiłkiem osiągnęły pewien
poziom zgodności, ale o ile się nie mylę Topaz rezygnuje dla wsparcia rozszerzeń
natywnych napisanych w API C MRI.

Istnieje co prawda inne rozwiązanie tego problemu, czyli FFI. Jest to interfejs
umożliwiający dostęp do rozszerzeń natywnych, ale dużo bardziej abstrakcyjny i
niezależny od danej VM. W efekcie dużo prostszy do wydajnej implementacji.
Niestety większość rozszerzeń natywnych nadal korzysta z API C MRI a o FFI mało
kto słyszał.

No dobrze, a co jeśli ktoś chciałby się pobawić i napisać prościutką maszynę
wirtualną? Nic prostszego. Zespół odpowiedzialny za PyPy przygotował zestaw
narzędzi, który umożliwia przygotowanie maszyny wirtualnej dla dowolnego języka,
z GC i kompilacją JIT za darmo. Podstawy zostały omówione na blogu PyPy na
przykładzie Brainfucka
. Polecam eksperymenty.

Główna przyczyna to fakt, że większość tego typu materiałów jest niestety
dostępna wyłącznie po Japońsku.

Garść informacji znajdziesz wśród moich slajdów na temat MRI, jaką pokazałem jakiś
czas temu na WRUGu.

[quote=y3ti]“Kompilatory. Reguły, metody i narzędzia”
Autorzy: Jeffrey D. Ullman, Alfred V. Aho, Ravi Sethi[/quote]
Trzy lata temu miałem nieudane podejście do tej książki. Nie polecam
jako pozycji wprowadzającej do tematyki - przytłacza ogromem wiedzy.

Dajcie znać, jeśli chcielibyście, żebym coś uzupełnił, lub jeśli gdzieś
nieopacznie minąłem się z prawdą.

@janek: dziękujemy za komentarz, a jeszcze bardziej za linki na końcu.

@janek, świetny post, wielkie dzięki!

Koichi Sasada na ostanim EuRuKo opowiadał o garbage collection w MRI i wspomniał o tym, że tworzy coś, co będzie działało podobnie do generacyjnego collectora. Tutaj są slajdy (temat GC od 46.):
http://www.atdot.net/~ko1/activities/Euruko2013-ko1.pdf

@janek, dzięki za świetny post!

@janek: Dzięki za informacje o MRI i szczegóły implementacji.

Pozwolę sobie tylko odnieść się do:

[quote=janek]eksperymentując z Rubinius odnoszę wrażenie, że
kompilacja JIT wykonywana w ramach tej maszyny wirtualnej [nie stanowi
odpowiedzi na problemy wydajnościowe Ruby][wyniki]. Zależy to oczywiście od
konkretnego przypadku[/quote]
Dokładnie tak jest, i w zależności od tego z czym eksperymentujesz to takie odniesiesz wrażenie.
Obserwuję Rubiniusa już od dłuższego czasu i jeśli chodzi o optymalizację kodu Ruby
to jego JIT radzi sobie ogólnie rzecz biorąc bardzo dobrze, przykład:

Red Black Tree benchmark

Rubinius bez JIT’a (i/s to instructions/second):

$ ./bin/benchmark -T -Xint benchmark/real_world/bench_red_black_tree.rb === bin/rbx === #delete 5.0 (±0.0%) i/s - 25 in 5.037527s (cycle=1) #add 11.5 (±0.0%) i/s - 58 in 5.044493s (cycle=1) #search 18.0 (±0.0%) i/s - 90 in 5.013863s (cycle=1) #inorder_walk 83.6 (±7.2%) i/s - 416 in 5.000242s (cycle=8) #rev_inorder_walk 85.3 (±4.7%) i/s - 432 in 5.073325s (cycle=8) #minimum 26.8 (±0.0%) i/s - 136 in 5.068653s (cycle=2) #maximum 29.7 (±0.0%) i/s - 150 in 5.053588s (cycle=2)
Rubinius z JIT’em:

$ ./bin/benchmark benchmark/real_world/bench_red_black_tree.rb === bin/rbx === #delete 102.5 (±1.0%) i/s - 515 in 5.023655s (cycle=5) #add 110.7 (±0.9%) i/s - 560 in 5.058944s (cycle=10) #search 258.6 (±1.2%) i/s - 1296 in 5.012934s (cycle=24) #inorder_walk 1508.4 (±0.7%) i/s - 7590 in 5.032177s (cycle=138) #rev_inorder_walk 1510.9 (±0.6%) i/s - 7590 in 5.023804s (cycle=138) #minimum 683.7 (±1.0%) i/s - 3431 in 5.018485s (cycle=73) #maximum 717.9 (±1.5%) i/s - 3666 in 5.107446s (cycle=78)
Porównanie z MRI 2.1 i JRuby 1.7.4:

Comparing benchmark/real_world/bench_red_black_tree.rb:#inorder_walk: bin/rbx: 1483 i/s /Users/karol/.rubies/jruby-1.7.4/bin/ruby: 301 i/s - 4.92x slower /Users/karol/.rubies/2.1.0-dev/bin/ruby: 161 i/s - 9.19x slower Comparing benchmark/real_world/bench_red_black_tree.rb:#search: bin/rbx: 262 i/s /Users/karol/.rubies/jruby-1.7.4/bin/ruby: 77 i/s - 3.40x slower /Users/karol/.rubies/2.1.0-dev/bin/ruby: 43 i/s - 6.05x slower Comparing benchmark/real_world/bench_red_black_tree.rb:#add: bin/rbx: 110 i/s /Users/karol/.rubies/jruby-1.7.4/bin/ruby: 40 i/s - 2.70x slower /Users/karol/.rubies/2.1.0-dev/bin/ruby: 21 i/s - 5.18x slower Comparing benchmark/real_world/bench_red_black_tree.rb:#delete: bin/rbx: 103 i/s /Users/karol/.rubies/jruby-1.7.4/bin/ruby: 18 i/s - 5.65x slower /Users/karol/.rubies/2.1.0-dev/bin/ruby: 9 i/s - 10.73x slower Comparing benchmark/real_world/bench_red_black_tree.rb:#rev_inorder_walk: bin/rbx: 1478 i/s /Users/karol/.rubies/jruby-1.7.4/bin/ruby: 293 i/s - 5.04x slower /Users/karol/.rubies/2.1.0-dev/bin/ruby: 159 i/s - 9.26x slower Comparing benchmark/real_world/bench_red_black_tree.rb:#minimum: bin/rbx: 680 i/s /Users/karol/.rubies/jruby-1.7.4/bin/ruby: 133 i/s - 5.10x slower /Users/karol/.rubies/2.1.0-dev/bin/ruby: 73 i/s - 9.28x slower Comparing benchmark/real_world/bench_red_black_tree.rb:#maximum: bin/rbx: 720 i/s /Users/karol/.rubies/jruby-1.7.4/bin/ruby: 137 i/s - 5.23x slower /Users/karol/.rubies/2.1.0-dev/bin/ruby: 77 i/s - 9.32x slower
Inny przykład (Richards benchmark)z dużą ilością kodu Ruby:

[code]$ ./bin/benchmark -t x -t ~/.rubies/2.1.0-dev/bin/ruby -t ~/.rubies/jruby-1.7.4/bin/ruby benchmark/octane/bench_richards.rb
=== bin/rbx ===
#main(10000) 78.6 (±2.5%) i/s - 396 in 5.038839s (cycle=6)
=== /Users/karol/.rubies/2.1.0-dev/bin/ruby ===
#main(10000) 12.2 (±0.0%) i/s - 62 in 5.075841s (cycle=1)
=== /Users/karol/.rubies/jruby-1.7.4/bin/ruby ===
#main(10000) 29.4 (±10.2%) i/s - 145 in 5.004999s (cycle=1)

Comparing benchmark/octane/bench_richards.rb:#main(10000):
bin/rbx: 78 i/s
/Users/karol/.rubies/jruby-1.7.4/bin/ruby: 29 i/s - 2.68x slower
/Users/karol/.rubies/2.1.0-dev/bin/ruby: 12 i/s - 6.44x slower[/code]
Problem z “Real world Ruby apps” jest w tym, że one nie są pisane w 100% w Ruby (tak jak wspomniałem powyżej) tylko ogromna część (prawie każda aplikacja Railsowa) opiera się o jakieś c-extensions pisane pod MRI. Więc jeśli web developer chce sobie sprawdzić Rubiniusa, instaluje go, bundle install i odpala apache benchmark na swojej aplikacji to nie jest już tak różowo (zależy od przypadku, ogólnie rzecz biorąc im więcej rozszerzeń w C tym gorzej na RBX a lepiej na MRI).

Optymalizacja Rubiniusa/Topaza to nie tylko praca nad JIT’em ale też praca “u podstaw” - znajdowanie popularnych rozszerzeń w C do MRI, zgłaszanie PR a także mozolne dyskusje z ludźmi z MRI core teamu odnośnie tego co powinno zostać zmienione, żeby “wszyscy byli zadowoleni”. Chociaż jeśli chodzi o Topaz’a to tam problemu z C-extensions nie ma bo Topaz ich nie obsługuje.

Oprócz tego spora część stdlib (i ogólnie gem’ow) jest pisana pod MRI. Dobry przykład - był kiedyś taki gem “faster csv”. Na MRI 1.8 był on o tyle szybszy w parsowaniu CSV, że core team MRI wprowadził go do stdlib w 1.9. Autor zauważył, że wywoływanie metod w MRI sporo kosztuje i wrzucenie sporej ilości kodu parsującego do jednej, bardzo długiej metody z pętlą sprawia, że MRI szybciej parsuje.

To założenie być może sprawdza się w MRI ale jest kompletnie nieprawdziwe w Rubiniusie gdzie preferowane są krótkie metody, które kompilator jest w stanie “zinlineować”. Długie, skomplikowane metody trudno inlineować w efekcie czego “faster csv” w Rubiniusie działa wolniej niż poprzednia wersja csv.rb z 1.8.

Takich przykładów jest mnóstwo i to po części one sprawiają, że kod pisany pod MRI nie błyszczy od razu na Rubiniusie tylko trzeba nad nim popracować. To się wszystko oczywiście zmienia i takie niuanse mają coraz mniejsze znaczenie ale ogólnie praca nad VM Rubiego to jest wieloletni maraton.

Doszło do tego, że dyskutuję sam ze sobą :slight_smile:

Testuję właśnie starą aplikację pisaną pod Ruby 1.8.7 i Rails 2.3 i Rubinius na produkcji jest średnio 2 razy szybszy niż 1.8.7/1.9/2.0 (800ms/req vs. 1600ms/req) YMMV

Ponieważ 1.8.7 niedawno zakończył żywot Rubinius w trybie 1.8.7 wydaje się być znakomitym następcą ponieważ z tego co się orientuję security patches będą tam backportowane.