Ruby, Python i natywne wątki systemu operacyjnego
Posted by Jarosław Zabiełło Sat, 26 Jul 2008 11:22:00 GMT
Wiele się mówi o tym, że Ruby jak i Python nie posiadają obsługi natywnych wątków systemu operacyjnego. Wbudowane, tzw. green threads, nie są w stanie wykorzystać zalet maszyn wyposażonych w procesory wielordzeniowe. Istnieją jednak implementacje obu języków w czystej Javie. Czy ich użycie daje jakieś znaczące przyśpieszenie?
Wszystkie testy były wykonywane na MacBook Pro Core2 Duo 2.16GHZ, 4GB RAM i systemie Mac OS X 10.5.4 Leopard + zainstalowana Java 1.6.0_05.
Python
from time import time
from random import Random
from threading import Thread
rand = Random().randint # alias
class Test(Thread):
def __init__ (self):
Thread.__init__(self)
print "Starting %s" % self.getName()
def run(self):
a = [rand(0,SIZE) for x in xrange(SIZE)]
a.sort()
print "%s finished" % self.getName()
print "Start"
start = time()
THREADS, SIZE = 20, 100000
threads = []
for i in xrange(THREADS):
t = Test()
threads.append(t)
t.start()
while True in [t.isAlive() for t in threads]:
pass
print "Time: %s s" % (time() - start) Dla powyższego kodu Python i Jython uzyskały wyniki:
- Jython 2.2.1 = 12.15 s.
- Python 2.5.2 = 14.24 s.
Rożnica jakoś podejrzanie mała wskazująca na wciąż jeszcze niedojrzałą implementację Jythona. Dla pewności, zmodyfikowałem kod tak, aby korzystał z natywnych bibliotek Javy.
from time import time
from random import Random
rand = Random().randint # alias
from java.lang import Thread, Runnable
class Test(Runnable):
def __init__ (self):
print "Starting %s" % self
def run(self):
a = [rand(0,SIZE) for x in xrange(SIZE)]
a.sort()
print "%s finished" % self
print "Start"
start = time()
THREADS, SIZE = 20, 100000
threads = []
for i in xrange(THREADS):
t = Thread(Test())
threads.append(t)
t.start()
while True in [t.isAlive() for t in threads]:
pass
print "Time: %s s" % (time() - start) Tym razem
- Jython 2.2.1 = 11.96 s.
Różnica jest minimalna. Na dodatek panuje trochę zamieszania co do sposobu odpalania wątków w Jythonie – mętna składnia i za dużo możliwych sposobów uzyskania tego samego efektu. Po SQLAlchemy, Jython jest kolejnym przykładam całkowitej ignorancji założeń języka Python (o istnieniu jednej, oczywistej drogi do tego samego celu). Na domiar złego, mój pierwotny test zakładał testowanie miliona liczb do sortowania. Jython nie był w stanie tego testu wykonać z powodu braku pamięci dla Javy. Ruszył dopiero jak mu zaalokowałem 1GB (słownie: jeden gigabajt pamięci) za pomocą opcji (-Xmx1024M), co jest po prostu chore! Po wymianie pierwotnego range() na generatorowy xrange() zapotrzebowanie na pamięć wyraźnie zmalało i wystarczyła już opcja -Xmx256M. Jednakże kod się wykonywał tak koszmarnie wolno, że nie starczyło mi cierpliwości i zmniejszyłem ilość iteracji do 100 tys.
Ruby
require "time"
require "thread"
THREADS, SIZE = 20, 100_000
start = Time.now
puts "Start"
threads = (1..THREADS).map do |i|
puts "Starting Thread-#{i}\n"
Thread.new(i) do |t|
a = (1..SIZE).map {|e| rand(SIZE)}
a.sort!
puts "Thread-#{t} ended\n"
end
end
while t = threads.find {|lt| lt.alive?}
t.join
end
puts "Time: #{Time.now - start} s."Pierwsze, co mi się od razu rzuca w oczy, to o wiele czytelniejsza, zgrzebniejsza składnia Rubiego, który tu korzysta z bloków kodu. Po drugie, wyniki są dosyć ciekawe:
- Ruby 1.9.0 = 1.11 s
- Ruby Enterprise = 2.77 s
- JRuby 1.1.2 = 3.53 s
- Ruby 1.8.7 = 21.82 s.
Ruby okazał się najwolniejszy w tym zestawieniu. Python był tylko trochę szybszy. JRuby, korzystający z javowych wątków systemowych, zdeklasował Rubiego, Pythona i niedojrzałego Jythona. Jednakże to nie JRuby okazał się tu zwycięzcą. Nie trzeba czekać na Ruby 2.0, wersja 1.9 posiada już natywną obsługę wątków POSIX i jest dodatkowo nieźle zoptymalizowana. Ruby 1.9 pokazał, że jest tu prawie 4x szybszy od JRuby. Nie sądzę, że tą szybkość uzyskał na lepszej implementacji wątków od Javy. Raczej ma mocno zoptymalizowany kod który był wykonywany w każdym z wątków.
Bardzo dobrze poradził sobie też Ruby Enterprise, zoptymalizowana wersja Ruby 1.8.6 przez twórców Passengera! Nie wiem jakim cudem Ruby Enterprise pobił JRubiego… no chyba, że jego twórcy dodali mu obsługę natywnych wątków systemu operacyjnego. Inaczej nie mogę sobie wytłumaczyć tak dobrego wyniku.
Updated
Dla pełniejszego obrazu uruchomiłem testy dla 1 wątku. Poprawiłem też błąd z ilością list do sortowania faworyzującą Rubiego. Ostateczne podsumowanie wygląda tak:
1 thread, 2,000,000 iterations
- Ruby 1.9 = 1.69 s.
- Ruby Enterprise = 3.38 s.
- JRuby 1.1.2 = 7.06 s.
- Jython 2.2.1 = 17.29 s.
- Python 2.5.2 = 18.06 s.
- Ruby 1.8.7 = 22.37 s.
20 threads * 100,000 iterations
- Ruby 1.9 = 1.54 s.
- Ruby Enterprise = 3.01 s.
- JRuby 1.1.2 = 5.82 s.
- Jython 2.2.1 = 11.86 s.
- Python 2.5.2 = 12.32 s.
- Ruby 1.8.7 = 22.68 s.
Czasy, kiedy Python był zawsze szybszy od Rubiego, to już przeszłość. Z wyników wynika, że to nie obsługa wątków zadecydowała o wynikach. Ruby 1.9 pobił wszystkich zarówno w teście 1 jak i wielu wątków.
Updated 2008-07-30
Poprawiłem trochę test, aby nie zużywał tyle pamięci. Wyrzuciłem więc generowanie list i sortownie. Zostało samo generowanie losowych liczb. Dodałem też kod dla Javy dla porównania.
Java
import java.util.Random;
public class Main {
static Integer SIZE, ITERATIONS;
public static void main(String[] args) throws Exception {
Integer[] threads_nr = {1, 10, 50, 100};
SIZE = Integer.parseInt(args[1]);
System.out.println(args[0]);
for(Integer nr: threads_nr) {
ITERATIONS = SIZE / nr;
System.out.printf(" %3d thread(s) x %9s = ", nr, ITERATIONS);
long start = System.currentTimeMillis();
Thread[] threads = new Thread[nr];
for(int i = 0; i < threads.length; i++) {
threads[i] = new ThreadTest();
threads[i].start();
}
while(threadsAlive(threads)) {}
System.out.printf("%s s.\n", (System.currentTimeMillis() - start) / 1000.0);
}
}
public static boolean threadsAlive(Thread[] threads) {
for(Thread t: threads)
if(t.isAlive())
return true;
return false;
}
static class ThreadTest extends Thread {
@Override
public void run() {
Random rnd = new Random();
for(int i = 0; i < ITERATIONS; i++)
rnd.nextInt(SIZE);
}
}
}Test:
$ java Main "`java -version`" 10000000
java version "1.6.0_05"
Java(TM) SE Runtime Environment (build 1.6.0_05-b13-120)
Java HotSpot(TM) 64-Bit Server VM (build 1.6.0_05-b13-52, mixed mode)
1 thread(s) x 10000000 = 0.361 s.
10 thread(s) x 1000000 = 0.18 s.
50 thread(s) x 200000 = 0.186 s.
100 thread(s) x 100000 = 0.189 s. Java, co nie dziwi, jest najszybsza. Nie ma znaczącej różnicy między 10 czy 10 wątkami, bo mój procesor posiada tylko dwa rdzenie.
Ruby/JRuby
require "time"
require "thread"
SIZE = ARGV[1].to_i
puts ARGV[0]
[1, 10, 50, 100].each do |nr|
iterations = SIZE / nr
print " %3d thread(s) x %9s = " % [nr, iterations]
start = Time.now
(1..nr).map do |i|
Thread.new(i) do |t|
iterations.times {rand(SIZE)}
end
end.each {|t| t.join}
puts Time.now - start
endTen sam kod w Ruby/JRuby wygląda znacznie krócej (i ładniej) ale kosztem wydajności. To też nie dziwi. Ruby jest bardzo produktywnym językiem, lecz oczywiście nie tak szybkim jak Java.
Wyniki testów:
Ruby 1.9:
$ ruby1.9 main.rb "`ruby1.9 -v`" 10_000_000
ruby 1.9.0 (2008-06-20 revision 17482) [i686-darwin9.4.0]
1 thread(s) x 10000000 = 2.858588
10 thread(s) x 1000000 = 2.832928
50 thread(s) x 200000 = 2.845377
100 thread(s) x 100000 = 2.852745JRuby:
$ jruby main.rb "`jruby -v`" 10_000_000
jruby 1.1.3 (ruby 1.8.6 patchlevel 114) (2008-07-28 rev 6586) [x86_64-java]
1 thread(s) x 10000000 = 3.58
10 thread(s) x 1000000 = 3.4290000000000003
50 thread(s) x 200000 = 3.442
100 thread(s) x 100000 = 3.516Ruby Enterprise:
$ ruby-enterprise main.rb "`ruby-enterprise -v`" 10_000_000
ruby 1.8.6 (2008-03-03 patchlevel 114) [i686-darwin9.4.0]
1 thread(s) x 10000000 = 3.510694
10 thread(s) x 1000000 = 3.552756
50 thread(s) x 200000 = 3.586885
100 thread(s) x 100000 = 3.574564Ruby 1.8.6 (installed with Leopard):
$ /usr/bin/ruby main.rb "`/usr/bin/ruby -v`" 10_000_000
ruby 1.8.6 (2008-03-03 patchlevel 114) [universal-darwin9.0]
1 thread(s) x 10000000 = 6.586917
10 thread(s) x 1000000 = 6.78801
50 thread(s) x 200000 = 6.951003
100 thread(s) x 100000 = 6.907087Ruby 1.8.7 (installed from MacPorts):
$ ruby main.rb "`ruby -v`" 10_000_000
ruby 1.8.7 (2008-06-20 patchlevel 22) [i686-darwin9.4.0]
1 thread(s) x 10000000 = 36.329174
10 thread(s) x 1000000 = 36.808753
50 thread(s) x 200000 = 36.715717
100 thread(s) x 100000 = 36.499772 To co niepokoi, to koszmarnie wolna praca Ruby 1.8.7 w porównaniu do Ruby 1.8.6. To jest bardzo podejrzana sprawa. Cieszy za to dobra wydajność Ruby Enterprise.
Python/Jython
from random import Random
from sys import argv
from time import time
from threading import Thread
rand = Random().randint # alias
class ThreadTest(Thread):
def __init__ (self, iterations):
Thread.__init__(self)
self.iterations = iterations
def run(self):
[rand(0,SIZE) for x in xrange(self.iterations)]
SIZE = int(argv[2])
print argv[1]
for nr in [10, 50, 100]:
iterations = SIZE / nr
print " %3d thread(s) x %9s = " % (nr, iterations),
start = time()
threads = []
for i in range(nr):
t = ThreadTest(iterations)
threads.append(t)
t.start()
for t in threads:
t.join()
print "%s s." % (time() - start) Test:
$ python main.py "`python -V`" 10000000
Python 2.5.2
1 thread(s) x 10000000 = 34.5188429356 s.
10 thread(s) x 1000000 = 52.3496830463 s.
50 thread(s) x 200000 = 53.9406650066 s.
100 thread(s) x 100000 = 58.3923280239 s.Co ciekawe, Python 2.5.2 okazał się najwolniejszy. Im więcej wątków tym jeszcze wolniej działał. Był wolniejszy nawet od (dziwnie wolnego) Ruby 1.8.6. Ale, być może to jest wina bardzo wolnej biblioteki generowania losowych liczb. Ech, jak tu cokolwiek porównywać między językami jak takie odkrywa się takie babole w bibliotece standardowej Pythona.
Jython był jeszcze wolniejszy. Dla 10 mln. iteracji, mimo że nie były tworzone w pamięci żadne listy, wlókł się tak wolno, że przerwałem test. Po zmniejszeniu iteracji o rząd wielkości (do 1 miliona) uzyskał wyniki takie jak Ruby 1.8.6 dla 10 milionów iteracji. Jython jest najwyraźniej jeszcze bardzo słabo zoptymalizowany. Pomysły aby na nim odpalać frameworki takie jak Django, to na razie raczej przedwczesny pomysł.
Iterations: 1,000,000 instead of 10,000,000:
$ jython main.py "`jython --version`" 1000000
Jython 2.2.1 on java1.6.0_05
10 thread(s) x 100000 = 6.391000032424927 s.
50 thread(s) x 20000 = 4.368000030517578 s.
100 thread(s) x 10000 = 4.352999925613403 s.

Kanały IRC![[Dilber w Onecie]](/images/larry.png)


Też jestem zaskoczony wynikami Ruby Enterprise. Ich implementacja (tak obiło mi się o uszy) różni się w miejscach związanych z obsługą pamięci, a Twój test prawdopodobnie (1GB dla Javy) testuje najbardziej właśnie ten aspekt działania interpretera / maszyny wirtualnej.
Poza Ruby 1.8 wyniki są zbliżone, a to cieszy :)
W wątkach w Rubym najbardziej denerwujące jest to, że operacje blokujące jeden wątek, blokują cały proces. To jedyny duży minus, który napotkałem podczas ich używania. Ten minus neguje największą korzyść z ich używania – np. odpalenie jakiegos wielowątkowego web crawlera jest na wątkach utrudnione – jeden sie przyblokuje i caly proces stoi i czeka na timeout.
Czy ktoś z czytających pokonał w jakiś elegancki sposób ten problem?
Jacek: Też mi się wydaje, że to może być przyczyna.
Jak można przeczytać tutaj ruby uruchamia GC za każdym razem gdy zaalokuje się 8mb pamięci.
Odnioslem wrazanie, ze uwazasz green watki za cos zlego? To ze implementacja jest wolna to nie oznacza wcale ze green watki sa gorsze od systemowych, wrecz sa duzo lepsze. Porownaj implementacje green watkow np. w erlangu.
Erlang nie ma współdzielonej pamięci. To są lekkie procesy, a nie wątki.
w Ruby jest gem fastthread ktory daje ci “szybkie natywne wątki” panie Zabiełło
co do range() to ma pan nawet w tutorialu dla n00bów ze do duzych zbiorów uzywa sie xrange :)
Pozdrawiam :)
Jakub: Czy fassthread rozwiazuje problem blokowania procesu przy zablokowaniu watku?
@Jacek: tak. Korzystałeś kiedyś z Capistrano, a jeśli tak to czytałeś jego komunikaty?
Korzystam, ale nie wnikam bo mi cholera dziala bezblednie :)
A wątki ostro męczyłem za czasów 1.8.4 i jestem po tyłach w tym temacie.
Tym samym statni argument przeciwko Rubiemu w mojej głowie upadł.
Niedawno Jarosław Zabiełło dokonał testu wydajności różnych interpreterów Pythona i Ruby w natywnych wątkach systemu operacyjnego. Zabrakło mi tam testów IronPythona, który natywne wątki jest w stanie obsłużyć w ramach tradycyjnej biblioteki Pythona. http://sprae.jogger.pl/2008/07/28/python-vs-ironpython-vs-watki/
I apologize for commenting in english, but I’ve a few points I believe are important.
1. The random implementation may be the dominant cost here. Creating a random list before creating the child threads, and having them copy it, should help mitigate it. You could also eliminate the threads and the sorting, just benchmarking the random number generation. 2. In the python versions the main loop is busy-waiting, which could be soaking up a lot of CPU time (although I doubt it matters much here). Use “for t in threads: t.join()” instead. 3. You should try disabling python’s tracing GC (gc.disable()). It behaves quite badly under certain conditions.
I’m confused why Ruby 1.8.7 is so slow on this. I get similar numbers to you for the Python version, but my Ruby 1.8.6 (which is little different to 1.8.7) gets 2.85s.
Wrote a blog post, but most of the performance difference in this benchmark is because random is a native python library which is slow.
http://www.skitoy.com/p/python-vs-ruby-performance/172
@Adam Olsen: I have updated the article. I got rid off sorting and list creation. I have only random number generation. But as David Koblas pointed out, it looks like Python has slow random number generator.
@Peter Cooper: I have no idea why Ruby 1.8.7 is so slow. On my MBP Core2Duo, 2.16 GHz & Mac OS X 10.5.4, Ruby 1.8.7 is even 6x slower than Ruby 1.8.6!
Jeszcze wymienie ze Python uzywa juz od dawna watki POSIX:
http://docs.python.org/lib/module-thread.html
Ps. Ten test polega na samym sortowaniu i roznice / zyski sa na poziomie 10%-14% to oznacza ze pana test panie Zabiełło jest ładnie pisząc do bani, bo implementacja sort może być różna proponuje aby wykorzystał pan jakis text albo strone i ją parsował jakas swoja metoda która jest powtarzalna i katująca mocno dla procesora, wtedy będzie można efekty porownywać.
Jak u nas ktos na programowaniu rownoleglym przyniosl program ktory po zrownolegleniu mial przyrost szybkosci < 25% to w ogole prowadzacy mowil mu “nara” :)))
Prosze poprawic test !!!
@Marek Kubica: co z tego, że Python używa wątków skoro GIL nie pozwala im wykorzystać pełni możliwości maszyn wieloprocesorowych/wielordzeniowych?
@JO: Widzę, żeś nie zauważył, że w update do tekstu zmieniłem kod i już nie ma sortowania. Zostało samo generowanie liczb losowych. Niestety, jak nie urok to sraczka. Okazało się że tym razem Python ma (ponoć) wolny generator liczb losowych. Co nie wybrać, to problem. Panie Oboza, sam sobie napisz dokładne i obiektywne testy jak masz na to czas.
Z innej beczki. Zapuściłem ostatnio ten mój kod na Solarisie (Sparc, 32GB, 64 rdzeni) Mam tam tylko Javę i JRuby więc ograniczyłem się do tego. Zauważyłem, że JRuby nie wykorzystuje pełnych zasobów maszyny. Java, bez problemu pochłonęła pełne 100% czasu rdzeni.
“Co nie wybrać, to problem.”
Ano, nie ma letko;) Testowanie to nie jest taka prosta czynność, że się napisze test i odpali. Trzeba jeszcze czasem trochę pomyśleć ;)
“Jython jest najwyraźniej jeszcze bardzo słabo zoptymalizowany. Pomysły aby na nim odpalać frameworki takie jak Django, to na razie raczej przedwczesny pomysł.” – nie sprawdzałem i nie wiem, jak działa Django na Jythonie, ale wysnuwanie takich wniosków na podstawie mikrotestu generatora liczb losowych jest co najmniej komiczne ;)
Tak z ciekawości, jakiego algorytmu używa Ruby/JRuby do generowania liczb losowych?
W tej chwili java.util.Random (MRI używa Mersenne Twister’a który, btw, jest dużo szybszy – shufluje duże słowo co 624 wołań a normalnie robi 4 xory i 4 przesunięcia co powoduje doskonałą utylizację cache’a). W tej chwili JRuby nie używa arity split dla Kernel#rand (a więc jest narzut sprawdzania arności i bardzo duży narzut “boxingu” w tablicę argumentów). W wolnej chwili zakomituję javową wersję MT i dużo szybszy dispatch w Kernel#rand (w zasadzie to on jest wąskim gradłem tutaj a nie generator).
1. Test jest skopany, generowanie liczb losowych to nie taka prosta sprawa, test może tylko rodzić przypuszczenie że w Pythonie zastosowano lepszy algorytm a w Rubym gorszy do generowania liczb losowych. (Może w Rubym jest skopany?) Element losowy należy w tego rodzaju testach wyeliminować. Zrozumiał bym gdybyś np. testował bazy danych a tylko zestaw danych przykładowych był by generowany.
2. Od kiedy to “Jython jest kolejnym przykładam całkowitej ignorancji założeń języka Python (o istnieniu jednej, oczywistej drogi do tego samego celu)” myślałem że “istnienie jednej drogi” to filozofia Rubiego a nie Pythona, ale może coś przeoczyłem.
@rmz: Zdecydowanie ci się pomieszało. Jest dokładnie odwrotnie. Istnienie “jednej drogi” to filozofia Pythona, a nie Rubiego. Zobacz PEP20, pkt.13.
Spoko blog ale za przeprowadzanie testów to raczej się nie bierz bo wygląda na to, że góry zakładasz wynik i nie znasz wystarczająco bebechów.
Generator liczb losowych Ruby jest stosunkowo mało losowy( w porównaniu do Pythona) a przez to szybki. Produkcja takich testów i takich http://blog.zabiello.com/articles/2007/04/02/postgresql-vs-mysql-vs-mssql2k obniża wartość bloga.
@iddqd “Generator liczb losowych Ruby jest stosunkowo mało losowy”. Jak to ? MT to jeden z najlepszych generatorów pseudolosowych jakie istnieją (o ile nie najlepszy w swojej klasie) i przeszedł wszystkie die hard testy i choć nie przydaje się w kryptografi, znakomicie sprawdza się choćby w Monte Carlo.
uzywanie jythona 2.2.1 w czasie gdy nie jest od dobrych kilku miesiecy rozwijany jest conajmniej dziwne… trzeba bylo probowac 2.5.1a, choc teraz mozna spokojnie wersji z trunka sprobowac (dopiero “na dniach” wersja 2.5.1 przeszla do trunka z branch/asm). roznica jest ogromna, nawet przy tescie specjalnie przygotowanym by pokazac wyzszosc ruby nad pythonem ;) tak wiec – czekam na aktualizacje, tudziez nowy wpis do bloga ;)
Lepsza wydajność 2.5.1a to głównie praca nad runtimem. ASM wnosi tylko tyle że po refactoringu i użyciu api ASM kod jest czytelniejszy, wcześniej w źródłach jythona były tragiczne addByte(234), itp.