Porównanie wydajności systemów tłumaczeń w PHP

Na blogu Zyxa natrafiłem na post o nowym systemie do tłumaczeń w PHP, klasie MessageFormatter. I przyznaję, że nie zrobiło to na mnie wrażenia. Format zapisu tekstu jest tak skomplikowany, że w ostatnim przykładzie do teraz nie mogę się doliczyć nawiasów 😉 A wygląda on tak niewinnie:

$msg = new MessageFormatter('pl_PL', '{0,plural,one{Masz jedną nową wiadomość}few{Masz {0,number} nowe wiadomości}other{Masz {0,number} nowych wiadomości}}.');
echo $msg->format(array(0 => 103));

I tak zacząłem myśleć co takiego ma MessageFormatter, czego nie ma GetText.
To mnie nakłoniło do przeprowadzenia szybkiego testu wydajności. Do zawodów stanęły:

  • MessageFormatter
  • GetText
  • prosty system napisany w PHP mojego autorstwa

Sam test miał być idiotycznie prosty, mierzący jedynie czas potrzebny na dokonanie tłumaczenia danego tekstu. Osobno zmierzyłem czas potrzebny na inicjalizację systemu tłumaczeń.

MessageFormatter

Nie bardzo miałem pomysł jak składować dane dla tego języka. Jednak patrząc po poziomie skomplikowania tekstów wątpię, by ktoś wstawiał bezpośrednio stringi do szablonów. Dlatego wybrałem najprostszą metodę składowania danych – tablica identyfikator tekstu => tłumaczenie

$lang = array(
	'new_msg' => "{0,plural,one{Masz jedną nową wiadomość}few{Masz {0,number} nowe wiadomości}other{Masz {0,number} nowych wiadomości}}."
);

Całość badanego kodu dla MessageFromattera wygląda następująco:
Przygotowanie 100 tysięcy tekstów:

$lang = array();
for($i=0; $i<100000; ++$i) {
	$lang['new_msg_'.$i] = "{0,plural,one{wiad#$i; jedna l.poj}few{wiad#$i; {0,number} nowe mnogie}other{wiad#$i; {0,number} nowych mnogich}}";
}

Przygotowanie losowej wiadomości do wyświetlenia, również 100 tysięcy razy:

for($i=0; $i<100000; ++$i) { 
	$msg = new MessageFormatter('pl_PL', $lang['new_msg_'.rand(0,999)]); 
	$msg->format(array(0 => $i));
}

O wynikach będzie trochę dalej.

GetText

Osobiście, mój faworyt, czyli system stosowany w Linuksach, napisany w C. Jak widać uniwersalny i wydajny, skoro stosowany z powodzeniem do dzisiaj. Tłumaczenia są trzymane w kompilowanych zewnętrznych plikach, jednak ich przygotowanie nie stanowi szczególnego problemu ze względu na mnogość narzędzi.
Po przygotowaniu i skompilowaniu pliku z identycznym zestawem tłumaczeń jak w pierwszym przykładzie następu jedynie załadowanie pliku:

setlocale(LC_ALL, 'pl_PL');
bindtextdomain("messages2", "locale");
textdomain("messages2");
bind_textdomain_codeset('messages2', 'utf-8');
 
$locale = localeconv();
function nfl($number,$decimals=2) {
	global $locale;
	return number_format($number,$decimals, $locale['decimal_point'], $locale['thousands_sep']);
}

Po czym właściwy test:

for($i=0; $i<100000; ++$i) {
	$n = rand(0,999);
	sprintf(ngettext("wiad#$n; one sing", "wiad#$n; %d plur", $i), nfl($i));
}

PHP

… czyli system tłumaczeń z odmianą fleksyjną oparty jedynie na PHP. Jest to bardzo mocno uproszczona klasa, jednak powinna pozwolić na zrozumienie idei działania.
Dane przechowywane są w zserializowanym pliku w postaci:

$lang = array(
	'hash md5' => array("Masz jedną nową wiadomość", "Masz %d nowe wiadomości", "Masz %d nowych wiadomości"),
);

Podstawowa wersja klasy wygląda tak:

class I18n {
 
	private $lang = null;
	static private $locale_r = null;
 
	function __construct() {
		$this->lang = unserialize(file_get_contents('lang_php.ser'));
		self::$locale_r = localeconv();
	}
 
	static function nfl($number,$decimals=2) {
		return number_format($number,$decimals, self::$locale_r['decimal_point'], self::$locale_r['thousands_sep']);
	}
 
	function getForm($n) {
		return ($n==1 ? 0 : $n%10>=2 && $n%10<=4 && ($n%100<10 || $n%100>=20) ? 1 : 2);
	}
 
	function t($sing, $plur, $n, $args=array()) {
		$md5 = md5($sing.$plur);
		if(!isset($this->lang[$md5])) return '';
		return vsprintf($this->lang[$md5][$this->getForm($n)], $args);
	}
}
 
$i18n = new I18n();

A sama pętla testująca tłumaczenia:

for($i=0; $i<100000; ++$i) {
	$n = rand(0, 999);
 	$i18n->t("wiad#$n; one sing", "wiad#$n; %d plur", $n, array(I18n::nfl($n)));
}

Wyniki

Testy były odpalane z konsoli, po parą razy w celu uzyskania powtarzalności wyników.

microtime time
system start test razem user system elapsed
MessageFormatter 0.402 18.225 18.627 18.39 0.11 0:18.68
GetText 0,00014 1,59930 1,59948 1.51 0.09 0:01.61
PHP 0.827 2.687 3.514 3.49 0.16 0:03.70

Czas wczytywania tłumaczeń w MF jest niewiarygodny, gdyż tak w zasadzie, to na miejscu generuję losowe dane zamiast cokolwiek wczytywać. Dzięki prekompilowanym plikom oraz wczytywaniu całości pliku dopiero w momencie wyszukiwania tłumaczenia uruchamianie GetTexta jest szybkie jak błyskawica. Natomiast unserialize(), cóż, wypada akurat najwolniej.
Przy samym tłumaczeniu MF pokazuje swoją ociężałość. 11x wolniej niż GetText i 6,7x wolniej niż czysty PHP. A podobno jest napisane w C… Oczywiście test przewiduje najbardziej pesymistyczny przypadek – tłumaczeń, w których trzeba użyć odmiany fleksyjnej, a takich zwykle w projekcie jest niewiele. Jednak optymalizacje, aby pomijać “kombajnowy” system tłumaczeń gdy się da można zastosować w każdym przypadku.

W czym więc MessageFormatter jest dobry? W skomplikowanych tekstach jak przykład z dokumentacji:

$fmt = new MessageFormatter("en_US", "{0,number,integer} monkeys on {1,number,integer} trees make {2,number} monkeys per tree");

Tylko, że ten przykład nie uwzględnił odmiany fleksyjnej, a o dwóch formach mnogich w języku polskim nie wspomnę.
W końcu całość można zapisać całkiem prosto:

prinf(_('%1$s on %2$s make %3$s per tree'), _('%d monkey', '%d monkeys', 3), _('%d tree', '%d trees', 3), _('%d monkey', '%d monkeys', 1));

Czy rozbudowane możliwości MessageFromattera są warte poświęcenia wydajności i ekstra skomplikowanego zapisu tekstu? Moim zdaniem nie.

3 thoughts on “Porównanie wydajności systemów tłumaczeń w PHP

  1. Zyx

    Za przeproszeniem, ale Twój benchmark można rozbić o kant czterech liter. Dlaczego? Bo porówujesz pralki z samochodami. Gettext to system składowania tłumaczeń, zaś MessageFormatter zajmuje się, jak sama nazwa wskazuje, ich formatowaniem zgodnie z ustawieniami językowymi. Oba systemy mogą być ze sobą łączone, a nawet powinny być, jako że Gettext poza obsługą liczby mnogiej nie posiada żadnych mechanizmów do tworzenia neutralnych językowo tłumaczeń. Najprostszy przykład to ten podany przez Ciebie na końcu artykułu. W tym momencie zakodowałeś na sztywno w programie kolejność argumentów, a tym samym wymusiłeś na tłumaczu określony szyk zdania, który w jego rodzimym języku nie musi być poprawny. Pomyślałeś o czymś takim? Pewnie nie. Zżymamy się, że Anglicy ignorują inne języki, a sami popełniamy dokładnie te same głupie błędy. Inna sprawa – czepiasz się nawiasów klamrowych w komunikatach MessageFormattera, a na dziwaczny przepływ sterowania w printf() i orgię zwykłych nawiasów w tymże nie piśniesz słówka.

    Druga rzecz to sam benchmark, którego metodykę można rozbić o kant czterech liter. Po pierwsze, o czym przed chwilą napisałem, nie porównujemy pralek z samochodami. Po drugie, warto by było najpierw nauczyć się choćby podstaw API i odwzorowywać rzeczywistą sytuację. Wyświetlasz 100000 razy jakiś tekst, ale losujesz go z puli 0-999, czyli pojedynczy komunikat może być wyświetlany więcej niż jeden raz. Tymczasem Ty radośnie olewasz fakt, że już go przeparsowałeś i ponownie tworzysz dla niego nowy obiekt MessageFormattera. Tylko idiota by tak robił, przecież to można scache’ować!

    Zarzut numer trzy: zanim zaczniesz wyciągać wnioski dotyczące wydajności, poczytaj choć trochę, jak dane rozwiązanie działa i co właściwie zmierzyłeś. Nie rób głupków ze swoich czytelników – plik .mo ze 100000-ma tysiącami komunikatów waży u mnie prawie 10 MB. Aby załadować go w 0,00013 sekundy, Twój dysk musiałby przekroczyć prędkość światła i osiągnąć transfer rzędu 71 GB na sekundę. Spróbuj wyświetlić w pętli wszystkie komunikaty, a zobaczysz, że czas ładowania wzrośnie Ci do kilku sekund. Dlaczego? Otóż gettext() na początku wczytuje tylko niewielką mapę, a teksty doładowuje w razie potrzeby w locie. Warto to wiedzieć, zanim się zacznie wyciągać jakieś wnioski.

    Zarzut numer 4 – “system tłumaczeń mojego autorstwa napisany w PHP”. Bez urazy, ale to już jest jawne obrażanie inteligencji czytelników. Do benchmarków bierze się prawdziwe, produkcyjne implementacje (np. Symfony Translation), a nie jakąś skleconą naprędce pipidówę, która ma 0,00001% możliwości MessageFormattera i działa jeno dla języka polskiego. Sorry, ale tak to ja mogę w ogóle na sztywno zakodować wszystkie teksty i stwierdzić, że to jest najszybsze, zatem jak ktoś chce sobie przetłumaczyć aplikację, niech ją sobie sforkuje i przepisze połowę kodu.

    Zarzut numer 4 – czy zadałeś sobie pytanie czy to, co mierzysz, ma jakiekolwiek zastosowanie praktyczne? Ile komunikatów wyświetlasz w pojedynczym żądaniu HTTP? Ile z nich wymaga dynamicznego formatowania? Robię teraz jeden projekt; jeśli w pojedynczym żądaniu wyświetlam 50 komunikatów to jest dobrze. Z tego średnio trzy, cztery, wymagają użycia zaawansowanego formatowania. Czy to wysoka cena w zamian za prawdziwą neutralność językową? Za brak konieczności kodowania na sztywno w kodzie printfów i innych dziwactw? Za brak grochu z kapustą, gdy chcemy sformatować daty, liczby, waluty? O tym już jakoś nie wspomniałeś. Za to, że w końcu jest coś, co wie, że w pełnej dacie po polsku nazwa miesiąca musi być zapisana w dopełniaczu? Moim zdaniem nie.

    PS. A kropką nad “i”, jeśli chodzi o rzetelność, jest nieumiejętność poprawnego napisania nawet mojej ksywy.

  2. cyryl Post author

    Nie powiedziałbym, że porównuję pralkę z samochodem. Raczej samochód (Gettext) z samą karoserią samochodu (MF). Gettext zajmuje się składowaniem tłumaczeń oraz zwracaniem tłumaczenia w odpowiedniej formie. Całą resztę załatwia printf. I specjalnie dla Ciebie poprawiłem ostatni przykład – już nie wymusza kolejności argumentów.
    Różnica między klamrami a nawiasmi jest taka, że jako argumenty funkcji mogę jest sobie dowolnie sformatować by wyglądały czytelnie. Aby sformatować 300-znakowego potworka z MF musiałbym dzielić string na części.

    Myślałem nad tym, czy wprowadzić cache. Zdecydowałem się jednak na worst case scenario. Próbuję tutaj porównać szybkość tworzenia samego systemu tłumaczenia, a nie jakości implementacji cache. Zauważ, że wszystkie trzy implementacje potraktowałem tutaj na równi. Zadaniem jest sprawdzanie szybkości dokonania tłumaczenia, a nie możliwości programisty w dokonywaniu optymalizacji. Aczkolwiek nie zdziwiłbym się, gdyby Gettext miał jakiś wbudowane optymalizacje.
    W dodatku mógłbym zrobić test mniej syntetyczny, a bardziej życiowy. Ba, mógłbym nawet sobie losować 10 komunikatów z tego tysiąca aby odwzorować prawdziwe użycie. Jednak wtedy miałbym różnice rzędu 0,001 sekundy i bym się zastanawiał czy to jeszcze błąd pomiarowy, czy już nie.
    Spójrz na to również od strony serwera produkcyjnego. 50 żądań/s to nie jest dużo. Różne strony, więc różne komunikaty. w tym momencie cache niewiele by się zdało.

    100000/18.225=5487 komunikatów na sekundę
    50 żądań * 10 komunikatów / 5487 komunikatów/s = 0,09s.
    

    Czyli można powiedzieć, że 9% każdej sekundy byłoby brane na wyświetlanie komunikatów. Wiem, że liczone trochę od dupy strony, ale ponownie – liczyłem na większych liczbach by odsunąć się od krawędzi błędu statystycznego. Porównując do czasu generowania skomplikowanych stron w moim frameworku (~0.04s), to przetłumaczenie 10 komunikatów zajmowałoby ~4,5% całego generowania strony. Dużo.

    Zarzut numer trzy. Czasy ładowania się Gettextu wygładały dziwnie, faktycznie. Moja wina, że bardziej nie zanalizowałem tego. Dzięki za info, że początkowo nie jest ładowany cały plik mo.
    Jednak statystycznie każdy komunikat powien zostać wyświetlony 100 razy. Tyle powinno wystarczyć do wczytania całego pliku, a mimo to i tak GetText deklasuje resztę.

    Pierwszy zarzut numer 4. Nie wspomniałem tego w artykule, ale to jest zarys ideologiczny aktualnego systemu tłumaczeń, którego używam. Niestety nie mogę opublikować źródeł. Oczywiście, że getForm() nie jest wbita na sztywno – tworzona jest dynamicznie podczas inicjalizacji klasy. Tak samo zostały wycięte wszelkie cache, optymalizacje i właśnie dodatkowe funkcje. Został sam “szkietet” który powinien być szybszy podczas inicjalizacji i wolniejszy podczas tłumaczenia od “pełnej” wersji klasy. No i przy okazji załatwiłem sprawę “równego startu” – bez cache ani żadnych innych dopalaczy.
    Poza tym wcale mi nie wyszło, że jest to najszybsze. Wręcz przeciwnie, dało mi to sporo do myślenia czy nie zaprzęc do swojej klasy GetTexta, który się okazał znacznie szybszy.

    Drugi zarzut numer 4. Oczywiście, te 0,001 sekundy w jednym żądaniu to nic. Ale jak juz się ma 50 czy 100 żądań na sekundę, to już wychodzi z tego całkiem ładna liczba.

    Zaawansowane formatowanie. Cóż, formatowanie liczb załatwi proste

    function nfl($number,$decimals=2) {
    	$locale = localeconv();
    	return number_format($number,$decimals, $locale['decimal_point'], $locale['thousands_sep']);
    }

    Po dołączeniu tej funkcji czas GetTexta wyniósł zatrważające 1,6 sekundy. I tak najszybciej w stawce. Fakt, nie jest to już 20x szybciej, ale różnica dalej jest znacząca.
    Z funckją formatującą datę nie jest już tak łatwo niestety. Ja ten problem rozwiązałem… nie używac daty w pełnym formacie 🙂

    Podsumowując – MF jest kombajnem i nigdzie temu nie zaprzeczyłem. Potrafi wyświetlić miesiąc w dopełniaczu, chwała mu za to. Jednak ludzie jakoś sobie radzili wcześniej z tym problemem. I te “jakieś” rozwiązania są wydajniejsze od MF. Dla mnie jest to zbyt duża cena.

    Dzięki za uwagi. Poprawiłem test, uwzględniając je.

    PS. Sorry za pomieszanie ksywy. Musiałem się jakoś zamyślić nad domeną, czy coś…

  3. Zyx

    Próbujesz robić test systemu tłumaczenia, więc dlaczego do testu systemu tłumaczenia bierzesz coś, co nie jest systemem tłumaczenia? Sam przyznałeś jedno i drugie, Twój benchmark jest bez sensu. gettext, poza obsługą liczby mnogiej, nie posiada żadnej funkcjonalności MessageFormattera, MessageFormatter, poza obsługą liczby mnogiej nie posiada żadnej funkcjonalności gettext. Obsługa liczby mnogiej to maleńki wycinek funkcjonalności MessageFormattera. Wyszło Ci, że samochód jeździ szybciej od pralki, ale za to pralka lepiej pierze skarpetki. Naprawdę, bardzo użyteczna i bardzo trudna do rozkminienia prawda życiowa.

    Ad. testu “PHP” -> a co mnie obchodzi Twój system, skoro nie możesz udostępnić źródeł, a zatem nie mamy jak zweryfikować tego czy nie kantujesz? A co mnie obchodzi, że wyciąłeś z niego interesujący fragment? Testuje się rzeczywiste i weryfikowalne systemy.

    I nie, ludzie sobie nie radzili. Dowodem jest ta cała masa stron, która wyświetla daty typu “30 listopad 2010” i to, że jak robiłem własną, musiałem robić jakieś masakryczne nakładki, by to poprawić.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.