Ruby 1.9 a wątki

Mam taką zagwozdkę. Z tego co pamiętam, to YARV a zatem Ruby 1.9.1 miał mieć natywne wątki.

Taka funkcjonalność bardzo by mi się przydała, ponieważ ostatnio dostałem czterordzeniowy serwer na własny użytek i chciałem maksymalnie wykorzystać jego możliwości. Zacząłem zatem od testów i ku mojemu zaskoczeniu okazało się, że albo YARV nie implementuje natywnych wątków albo coś źle skonfigurowałem.

Żeby uniknąć wątpliwości przytaczam procedurę testową. Zainstalowałem Ruby 1.9.0 na Debianie z paczek oraz skompilowałem Ruby 1.9.1-p0 ze strony ruby-lang.org z opcją --enable-pthreads.

Test wyglądał następująco:

[code=ruby]def calc()
10000000.times{|i| i * i}
end

MAX_RUNS = ARGV[0] && ARGV[0].to_i || 5

def parallel(runs)
Benchmark.measure {
threads = []
runs.times{ threads << Thread.new{calc()} }
threads.each{|t| t.join}
}
end

def parallel_proc(runs)
Benchmark.measure {
pids = []
runs.times{
pid = fork
if pid
pids << pid
else
calc()
exit
end
}
pids.each{|pid| Process.wait(pid) }
}
end

def serial(runs)
Benchmark.measure {
runs.times{ calc() }
}
end

def print_speed_up(s_time, p_time, message,runs)
puts “speed-up: " + message + " " +
sprintf(”%.3f",s_time) + “/” + sprintf("%.3f",p_time) + " = " +
sprintf("%.3f",s_time/p_time)
puts “efficiency: " + sprintf(”%.3f",(s_time/p_time)/runs*100) + “%”
end

1.upto(MAX_RUNS) do |runs|
puts “#{runs} runs:”
serial_time = serial(runs).real
parallel_time = parallel(runs).real
parallel_p_time = parallel_proc(runs).real
print_speed_up(serial_time, parallel_time, “threads”,runs)
print_speed_up(serial_time, parallel_p_time, “procs”,runs)
end[/code]
Wyniki zaś były następujące - Ruby 1.9.0 (z paczek Debiana):

1 runs: speed-up: threads 1.226/1.237 = 0.992 efficiency: 99.170% speed-up: procs 1.226/1.241 = 0.988 efficiency: 98.838% 2 runs: speed-up: threads 2.459/2.448 = 1.005 efficiency: 50.230% speed-up: procs 2.459/1.241 = 1.982 efficiency: 99.079% 3 runs: speed-up: threads 3.686/3.595 = 1.025 efficiency: 34.172% speed-up: procs 3.686/1.241 = 2.970 efficiency: 98.994% 4 runs: speed-up: threads 4.913/4.723 = 1.040 efficiency: 26.007% speed-up: procs 4.913/1.283 = 3.828 efficiency: 95.706% 5 runs: speed-up: threads 6.132/5.917 = 1.036 efficiency: 20.725% speed-up: procs 6.132/1.927 = 3.182 efficiency: 63.647%
Ruby 1.9.1-p0 z opcją --enable-pthreads:

1 runs: speed-up: threads 1.154/1.158 = 0.997 efficiency: 99.673% speed-up: procs 1.154/1.156 = 0.998 efficiency: 99.849% 2 runs: speed-up: threads 2.300/2.301 = 0.999 efficiency: 49.973% speed-up: procs 2.300/1.153 = 1.994 efficiency: 99.696% 3 runs: speed-up: threads 3.463/3.522 = 0.983 efficiency: 32.777% speed-up: procs 3.463/1.155 = 2.998 efficiency: 99.934% 4 runs: speed-up: threads 4.615/4.609 = 1.001 efficiency: 25.031% speed-up: procs 4.615/1.182 = 3.905 efficiency: 97.625% 5 runs: speed-up: threads 5.744/5.871 = 0.978 efficiency: 19.570% speed-up: procs 5.744/1.787 = 3.214 efficiency: 64.277%
Ktoś ma pomysł skąd takie wyniki?

http://blog.reverberate.org/2009/01/31/ruby-191-released/
Pierwszy komentarz.

Właśnie to czytałem… :slight_smile:
No dobra - to po co natywne wątki skoro nie można ich uruchamiać równolegle?
Swego czasu słyszałem o GILu i teraz mi się objawił w całej okazałości. Chyba zacznę się uczyć Erlanga… :wink:

Z ciekawości odpaliłem jeszcze na Javie 6.0 (w wersji client, JRuby 1.0 - z paczek Debiana), która ma natywne wątki, które oczywiście mogą działać równolegle (nie odpalałem wersji proc, bo ona nie działa w JRubim):

1 runs: speed-up: threads 1.646/1.820 = 0.904 efficiency: 90.440% 2 runs: speed-up: threads 3.437/2.788 = 1.233 efficiency: 61.639% 3 runs: speed-up: threads 5.135/4.839 = 1.061 efficiency: 35.372% 4 runs: speed-up: threads 6.800/8.078 = 0.842 efficiency: 21.045% 5 runs: speed-up: threads 8.528/11.280 = 0.756 efficiency: 15.121%
Z czego wynika, że tylko przy dwóch wątkach speed-up był istotnie większy od 1. Jak dla mnie jest to wynik gorszy od spodziewanego.

Uruchamianie równoległe natywnych wątków w 1.9/2.0 będzie, tylko nikt nie mówi kiedy :wink:

apohllo, spróbuj z najnowszą wersją jrubiego, bo widzę, że instalowałeś z paczek, a w przypadku tak szybko rozwijającego się projektu jak jruby to jest błąd.

Raczej nie usuną. Czemu? Extensiony w C to jeden z powodów (praktycznie musiałyby wszystkie polecieć do kosza). Ale cały interpreter rubiego jest napisany w sposób non thread-safe. Nie da się tak nagle tego naprawić. Poza tym już byli tacy co chcieli usunąć z Pythona (podobnej klasy interpreter) GIL (dodali tam gdzie trzeba synchronizację na dzielonych danych, zmiennych globalnych itp) i wyszedł im interpreter 2x-4x wolniejszy. Współbieżność nie jest taka prosta jak się czasem wydaje (“odpal wątki systemowe i ciesz się z wielu CPU”). Spójrzcie na javę, jeszcze w wersji 1.1 miała green threads, pomimo, że od początku stała za nią wielka firma.

Za to mogę powiedzieć, że developerzy rubiniusa dają nadzieję, że ich wątki będą odpalane bez GILa (w tej chwili jest, m.in. dlatego, że extensiony C z rubiego mają działać bez zmian z rubiniusem).

Ten fragment Twojej wypowiedzi podoba mi się najbardziej :smiley:

Tomash ma nowy fetysz :wink:

Spoko, ja też kiedyś miałem fioła na punkcie rozszerzeń C do rubiego. Potem przychodzi jednak taka chwila i człowiek rozumie, że takie narzędzia jak FFI to zbawienie. Pisanie nawet gołego glue code jest męczące na dłuższą metę.

Radarek, Ty dobrze wiesz dlaczego mi zależy na zgodności C API :wink:

Pozwolę sobie odgrzebać ten wątek.

Na branchu 2.0.0pre rubiniusa wątki są odpalane bez GIL. Przykład jak wygląda prosty benchmark odpalony na 2xcore:
(ale ten czas leci… 2 lata minęły od moje wypowiedzi a przysiągłbym że to było < 1 rok temu :))

[code=ruby]require “benchmark”

def loop(n, m)
threads = (1…n).map do
Thread.new do
a = 0
m.times { a += 1 }
Thread.current[:result] = a
end
end
threads.each {|thread| thread.join && (thread[:result] == m || (raise “wtf”)) }
end

def warm
puts “warming…”
50.times { loop(1, 1_000_000) }
end

warm

Benchmark.bm(15) do |bm|
bm.report(“1 thread”) do
loop(1, 10_000_000)
end

bm.report(“2 threads”) do
loop(2, 10_000_000)
end

bm.report(“3 threads”) do
loop(3, 10_000_000)
end

bm.report(“4 threads”) do
loop(4, 10_000_000)
end
end[/code]

[code=shell]$ RUBY=rbx; $RUBY --version && $RUBY threads_test.rb
rubinius 2.0.0dev (1.8.7 f7440cf0 yyyy-mm-dd JI) [x86_64-apple-darwin10.7.0]
warming…
user system total real
1 thread 0.000240 0.000048 0.000288 ( 0.778730)
2 threads 0.000155 0.000088 0.000243 ( 0.803637)
3 threads 0.000198 0.000092 0.000290 ( 1.315541)
4 threads 0.000264 0.000148 0.000412 ( 1.650484)

$ RUBY=jruby; $RUBY --version && $RUBY threads_test.rb
jruby 1.6.2 (ruby-1.8.7-p330) (2011-05-23 e2ea975) (Java HotSpot™ 64-Bit Server VM 1.6.0_24) [darwin-x86_64-java]
warming…
user system total real
1 thread 0.943000 0.000000 0.943000 ( 0.892000)
2 threads 1.245000 0.000000 1.245000 ( 1.245000)
3 threads 1.938000 0.000000 1.938000 ( 1.938000)
4 threads 3.089000 0.000000 3.089000 ( 3.089000)

$ RUBY=ruby1.9; $RUBY --version && $RUBY threads_test.rb
ruby 1.9.3dev (2011-02-25 trunk 30956) [x86_64-darwin10.6.0]
warming…
user system total real
1 thread 1.220000 0.000000 1.220000 ( 1.268539)
2 threads 2.390000 0.020000 2.410000 ( 2.450729)
3 threads 3.640000 0.020000 3.660000 ( 3.895239)
4 threads 4.760000 0.040000 4.800000 ( 4.999262)[/code]
Update.

Odpaliłem program apohllo z 1 posta:

$ RUBY=rbx; $RUBY --version && $RUBY -rbenchmark apohllo.rb rubinius 2.0.0dev (1.8.7 f7440cf0 yyyy-mm-dd JI) [x86_64-apple-darwin10.7.0] 1 runs: speed-up: threads 1.490/1.430 = 1.042 efficiency: 104.212% speed-up: procs 1.490/0.980 = 1.520 efficiency: 152.004% 2 runs: speed-up: threads 1.903/0.989 = 1.924 efficiency: 96.217% speed-up: procs 1.903/1.031 = 1.845 efficiency: 92.259% 3 runs: speed-up: threads 2.837/1.531 = 1.853 efficiency: 61.770% speed-up: procs 2.837/1.568 = 1.809 efficiency: 60.315% 4 runs: speed-up: threads 3.797/2.040 = 1.861 efficiency: 46.527% speed-up: procs 3.797/2.085 = 1.821 efficiency: 45.533% 5 runs: [BUG: Tried to stop but threads still running!] [BUG: Tried to stop but threads still running!]
I tu piękny stacktrace. Zgłoszę to do devteam rubiniusa).

Z tego co wiem to odwołania do rozszerzeń w C są serializowane, gdyż większość z nich nie jest ‘thread safe’.

Rozszerzenia w C są złe nie tylko z powodu konieczności pisania w C (syntax) i utrzymywania ich (niezgodność różnych C-API ze sobą - często rozszerzenia korzystają nie z C-API tylko bezpośrednio wywołują metody C z MRI 1.8 czy 1.9 ) ale też z powodu tego, że ciężko optymalizować kod Rubiego na poziomie (cały JIT, wszystkie rzeczy typu SSA, kompilator optymalizujący i cały szereg innych optymalizacji) maszyny wirtualnej kiedy kod Ruby wywołuje jakieś metody w C.

Do tej pory programiści Rubiego często mówili: “metoda jest wolna ? przepisz ją na C”. Rubinius to zmienia: “metoda jest wolna ? zoptymalizuj algorytm pisząc kod w Ruby a resztą zajmie się VM”. Co do istniejących rozszerzeń to ich większość działa na Rubiniusie, część starszych niestety nie (tych co korzystają z hack’ów a nie z API - zawsze można zgłosić patch poprawiający dane rozszerzenie).

Ale nie tyczy się to samego Rubiniusa :slight_smile: Tzn jest tak jak napisałeś. Jeśli jakas metoda API Ruby działa wolno, programiści przepisują prymitywy algorytmu na API VM (napisane w c++). W przypadku rubiniusa trzeba by to chyba rozszerzyć do czegoś w stylu “metoda jest wolna ? przepisz jej najważniejsze części na c++, resztę pozostaw w Ruby”

Nie do końca tak jest, nacisk jest położony jednak na pisanie całego core + stdlib w Ruby a nie w C++ primitives.
Przykład:

Implementacja klasy Array bezpośrednio zawiera tylko 4 wywołania “primitive” na 84 metody. Enumerable - żadnego. Podobnie jest w innych klasach. Ważna różnica: Rubinius nie woła metod"primitives" z C++ w celu optymalizacji kodu (tak jak możnaby przypuszczać) tylko na samym początku podczas ładowania całego systemu (kompilatora).

Cały proces ładowania jest podzielony na etapy (np. alpha, bootstrap, common, delta) i podczas tych wczesnych etapów nie są dostępne niektóre z funkcji Rubiego - wtedy “podpinane” są primitives. Więc używa się “primitives” tylko w celu wystartowania systemu a nie w celu optymalizacji, późniejsze etapy nadpisują definicje metod już kompletną implementacją napisaną w większości wypadków w Ruby. Można więc śmiało powiedzieć że język Ruby w Rubiniusie to obywatel pierwszej kategorii a nie jakiś brzydko mówiąc “hack” jak w przypadku MRI (czy nawet JRuby). Parser jest w połowie (tej ciekawszej) napisany w Ruby, kompilator bajtkodu w Ruby, transformacje AST w Ruby, (całe AST jest dostępne na wyciągnięcie ręki bez uciekania się do zewnętrznych bibliotek typu RubyParser)- słowem ciężko znaleźć w Rubiniusie (poza katalogiem vm) kod który nie jest kodem Ruby.

Więcej o etapie ładowania tutaj.

Dla sceptyków (maniaków przepisywania wszystkiego na C) polecam http://speed.pypy.org Różnica pomiędzy PyPy a Rubiniusem jest taka, że Rubinius ma dużą ilość optymalizacji dopiero przed sobą i z pewnością tam dojdzie z czasem ale już dzisiaj nie ma GIL’a (którego wciąż ma PyPy).

[quote=hosiawak]cja klasy Array bezpośrednio zawiera tylko 4 wywołania “primitive” na 84 metody. Enumerable - żadnego. Podobnie jest w innych klasach. Ważna różnica: Rubinius nie woła metod"primitives" z C++ w celu optymalizacji kodu (tak jak możnaby przypuszczać) tylko na samym początku podczas ładowania całego systemu (kompilatora).

Cały proces ładowania jest podzielony na etapy (np. alpha, bootstrap, common, delta) i podczas tych wczesnych etapów nie są dostępne niektóre z funkcji Rubiego - wtedy “podpinane” są primitives. Więc używa się “primitives” tylko w celu wystartowania systemu a nie w celu optymalizacji, późniejsze etapy nadpisują definicje metod już kompletną implementacją napisaną w większości wypadków w Ruby. Można więc śmiało powiedzieć że język Ruby w Rubiniusie to obywatel pierwszej kategorii a nie jakiś brzydko mówiąc “hack” jak w przypadku MRI (czy nawet JRuby). Parser jest w połowie (tej ciekawszej) napisany w Ruby, kompilator bajtkodu w Ruby, transformacje AST w Ruby, (całe AST jest dostępne na wyciągnięcie ręki bez uciekania się do zewnętrznych bibliotek typu RubyParser)- słowem ciężko znaleźć w Rubiniusie (poza katalogiem vm) kod który nie jest kodem Ruby.[/quote]
No chyba nie dokońca. Tzn te prymitywy nigdzie nie znikają, nie są wymiene na implementacje w Ruby w trakcie działania, są chyba zbyt unikalne, chociaż to też złe słowo.

https://github.com/evanphx/rubinius/commit/19febf3d9b755cdcb3267a0f267a6653c9314f49 Tutaj jest przykład takiego prymitywu i jego użycia już w kodzie.

Rubinius FTW! :slight_smile: Zainteresowałem się troche jak działa JIT w Rubiniusie
Jeśli ktoś jest ciekaw co się dzieje gdy JIT zaczyna działać polecam:

rbx -Xjit.inline.debug=1

W normalnych warunkach metoda jest interpretowana, jeśli jej call hit przekroczy 4000 (lub -Xjit.call_til_compile=wartość) metoda/blok trafia to LLVM JIT i jest podjęta próba kompilacji, prymitywy użyte w metodzie będą inlinowane a wartości trafią do cache.

Przy takiej heurystyce ten warm up nie zrobi wyciągnie ile fabryka dała z Rubiniusa

def warm puts "warming..." 50.times { loop(1, 1_000_000) } end
Tak jest imho lepije :slight_smile:

def warm puts "warming..." 5000.times { loop(1, 1_000) } end
Bez warm’a:

user system total real 1 thread 0.000360 0.000073 0.000433 ( 1.357874) 2 threads 0.000155 0.000099 0.000254 ( 1.391837) 3 threads 0.000203 0.000108 0.000311 ( 1.224389) 4 threads 0.000219 0.000131 0.000350 ( 1.574656)
Warm w którym loop jest interpretowane

user system total real 1 thread 0.000231 0.000058 0.000289 ( 0.737991) 2 threads 0.000152 0.000119 0.000271 ( 0.834065) 3 threads 0.000221 0.000140 0.000361 ( 1.198013) 4 threads 0.000208 0.000128 0.000336 ( 1.586947)
Podjęta próba kompilacji:

user system total real 1 thread 0.000097 0.000063 0.000160 ( 0.214400) 2 threads 0.000119 0.000076 0.000195 ( 0.237756) 3 threads 0.000162 0.000103 0.000265 ( 0.392912) 4 threads 0.000194 0.000143 0.000337 ( 0.512881)
WOW, LLVM daje kopa :wink: To czego tylko nie rozumime to to dlaczego ilość wątków wpływa na szybkość w każdym przypadku.

Dobra na priv już obgadane mam 2 cory :wink: