Technická poznámka, ta C verze se uvedeným postupem (make -C c) přeloží jako debug build bez optimalizací. Ne že by to v tomhle případě nějak vadilo, protože ten kód je tak jednoduchý, že na něm moc není co optimalizovat, nicméně přeloženo s optimalizacemi (-O3) je to u mě asi o 10% rychlejší.
Já jsem věděl, že se na to přijde! A proto jsem do článku napsal "Raději než úplně vyoptimalizovat C (což dokáže běh jednoho kola srazit na 75 ms)...". Ale uznávám, že to přiznání není na první pohled vidět.
Myslím si totiž, že to porovnání se zcela vyoptimalizovaným Cčkem není úplně správné. V případě GraalVM je totiž možné, aby všechny tři elementy toho algoritmu byly úplně nezávislé (dodané v samostatných souborech) či dokonce napsané v různých jazycích a výsledek bude pořád rychlý. Kdybych toho chtěl dosáhnout v Céčku, tak bych asi měl umístit každou z těch funkcí do vlastní dynamické knihovny. Při startu ty nezávislé .so soubory slinkovat a teprve pak měřit. Jsem přesvědčený, že výsledek by se více podobal tomu debug buildu, jež nedělá žádné inlinování. Asi bych to však měl opravdu zkusit a změřit (pull request by byl vítaný...).
Celé by to mělo ukázat výhodu JIT překladu, kde i modulární aplikace mohou být stejně rychlé, jako nejvyoptimalizovanější monolitické.
Ale pozor, tady se nebavíme o optimalizaci člověkem, ale překladačem. Můj argument je zcela validní, GraalVM optimalizace provádí, proč jsou tedy optimalizace vypnuté pro C překladač? Potom porovnáváte neporovnatelné věci.
Mimochodem zvolená ta úloha není moc vhodná pro porovnání rychlosti, protože více jak 80% času se stráví ve funkci acceptAndAdd, což je jeden jednoduchý cyklus. S tím si snadno poradí každý překladač i virtual machine, a proto jsou také výsledky všeho víceméně srovnatelné.
Mluvíte o inliningu, nicméně překladače používají mnohem širší škálu optimalizací (namátkou loop inrolling, který se mi zdá že v tom příkladu by zrovna mohl dost pomoci). Inlining se skutečně se dá zkazit tím že funkce budou v různých modulech, nicméně inlining výrazně pomůže jen ve specifických případech. Obecně třeba transformacím cyklů nevadí jak jsou funkce organizovány.
Proto si myslím že to porovnání tak jak jste ho napsal není fér a je to spíše marketing. Ten výkonostní rozdíl bude cca 50% v případě tohoto examplu, což je stejně dost solidní, ale tvářit se že je to skoro nastejno imo neodpovídá realitě.
Pro lepší porovnání by byl stejně potřeba lepší benchmark s více různými programy, ale bez mlžení s vypnutými optimalizacemi.
Při srovnávání zjevně nešlo o to porovnávat extrémní příklady, ale pokusit se odhadnout rozdíly u běžného programu. Což samozřejmě přináší problém s definicí běžného programu. Stejně zavádějící by bylo i porovnávat kód, který může C kompilátor perfektně optimalizovat, protože takový kód zase není příliš častý.
Vždycky, když se něco porovnává, je vědět přesně co se porovnávalo – a to je myslím v článku splněné.
Extrémní příklady? Článek je o tom že Graal VM je nejrychlejší VM na světě. Toho dosahuje pomocí agresivní optimalizace. Dále se v článku porovnávají časy běhu programů z nativních kompilátorů a při spuštění v Graal VM. Nicméně aby vyniklo že Graal VM je "skoro stejně výkonný" jako C, tak je při kompilaci C programu "zapomenuto" zapnout optimalizaci (která se běžně používá, není to nic exotického a kterou Graal u toho stejného programu použije). Argument proč nebyla optimalizace u C programu použita mi přijde slabý, proto jsem to napsal.
Chtěl jsem jen upozornit že to srovnání tak jak je postavené mi přijde zavádějící.
Jakési porovnání s LuaJITem existuje. Sám jsem to moc nezkoumal, ale zdá se, že očekávání byla větší než konečný výsledek.
Tak ještě jednou. Předpokládám, že snaha byla porovnat výkon u běžných programů. Aby bylo možné to nějak porovnávat, potřebujete srovnatelný kód ve více jazycích, a málokdo má čas na to psát aplikace pro benchmark, které budou mít desetitisíce řádek a budou dostatečně věrohodně simulovat častou činnost běžných programů, a zároveň to bude měřitelné. Takže ta aplikace pro benchmark bude spíš nějaký jednoduchý kód (jako v tomto případě). Takový kód se dá agresivně optimalizovat, ale pak už to ani vzdáleně nebude připomínat ten častý kód běžných programů, protože v nich bude minimum kódu, který by bylo možné takhle optimalizovat – i když ta optimalizace bude zapnutá.
Extrémním příkladem tedy není zapnutí optimalizací, ale kód, který jde optimalizovat výrazně více, než nejčastější kód běžných aplikací.
Agresivní optimalizace GraalVM podle mne není to samé, jako agresivní optimalizace C kompilátoru – v případě GraalVM jde zejména o optimalizace dynamických jazyků, v případě C jde o optimalizaci instrukcí CPU. Agresivní optimalizace GraalVM podle mne znamená, že prováděný kód je podobný, jako kdyby to byl kód napsaný v C – tedy např. bez několikanásobných referencí, s uložením dat v paměti příznivým pro CPU cache apod.
Je snad jasné, že „nejrychlejší virtuální stroj na světě“ není závěr testu ale hyperbola. Jaroslav Tulach velmi dobře ví, že je nesmysl porovnávat obecně rychlost virtuálních strojů – nikdy nebude jen virtuální stroj absolutní špička, která v čemkoli porazí ostatní. Myslím, že v článku je to jasně napsané.
při určitém počtu elementů začne hrát větši roli vliv cache-miss než jakýchkoliv optimalizací.
A o tom to právě je. Že jakýkoli „hloupý“ interpret dynamického jazyka prohraje kvůli cache-miss hned od začátku, a když se chce VM přiblížit rychlostí kódu psanému v C, musí umět cache-miss vyplývající z dynamické povahy jazyka eliminovat. Na druhou stranu běžné aplikace se s cache-miss běžně potkávají, takže zase nemá cenu dělat benchmark kdy se CPU točí nad jedním řádkem cache, protože to není úplně typický případ běžných aplikací.
Mně se ten argument docela líbí. Ptal jsem se jednou šéfa, co vlastně to naše Truffle API, které používáme na psaní interpretrů, dělá. Dostal jsem odpověď, že: "se stará o to, aby JavaScriptový kód vypadal jako Java". Což mi zní skoro stejně jako "když se chce VM přiblížit rychlostí kódu psanému v C, musí umět cache-miss vyplývající z dynamické povahy jazyka eliminovat". Ano, skutečně je třeba eliminovat dynamickou povahu jazyka a tak nějak se přiblížit typovanému jazyku jako Java/C. Pak to lze přeložit do něčeho, co běží skoro stejně rychle jako C.
Primární důvod "dedynamizace" je změnit asociativní pole v objekt, k jehož členům můžu přistupovat přes index. Tato samotná optimalizace nijak nesouvisí s cache miss. Na cache miss má vliv hlavně to, že takový objekt je obvykle MENŠÍ než asociativní pole, takže pokud jich potřebuji milión ve spojovém seznamu, tak už to pocítím.
V čem se liší nativní cyklus od interpretovaného cyklu? Proč by nemělo fungovat branch prediction? S tou dedynamizací jste na správné stopě. Napovím vám, že lepší termín by byl „deobjektizace“. To asociativní pole totiž typicky neobsahuje hodnoty, ale odkazy na objekty – dynamicky alokované na haldě, takže rozházené po paměti. A to s cache miss souvisí, a to tak že velmi. U dynamických interpretovaných jazyků navíc těch úrovní odkazů bude několik – máte objekty v dynamickém jazyce, a ty jsou zase reprezentovány objekty v interpretu.
Některé VM jako např. V8 nedělají boxing primitivních typů určitého rozsahu, takže pokud mám něco jako { a: 1, b: 2, c: 3 } tak "a" nebude ukazatel na boxovanou 1 ale 1. Vaše argumenty vychází spíš z neznalosti některých technik, které moderní VM využívají. V8 team toho dost publikoval, takže jestli vás to zajímá tak materiálu online je dost.
<blockquote>U dynamických interpretovaných jazyků navíc těch úrovní odkazů bude několik – máte objekty v dynamickém jazyce, a ty jsou zase reprezentovány objekty v interpretu.</blockquote>
To nedává smysl. Interpret nepřidává extra indirekci, jen je neefektivní. Hlavní cyklus interpretu obvykle vypadá jako jeden velký switch kde každý case je nějaká instrukce (opcode/IR). Občas kontroluje vstup, atd, a toto je důvod pro branch misprediction, který jsem napsal, protože predikovat switch o 50 větvích rozumně ani nejde.
Argumenty o neznalost si nechte pro sebe. Netuším, jak jste přišel na to, že většina datových struktur v dynamicky typovaných jazycích jsou ploché mapy s primitivními typy malého rozsahu. Jistě že současné VM dělají optimalizaci některých speciálních případů – vtip nových VM (jako např. GraalVM) je v tom, že dělají optimalizaci většího množství případů nebo lépe. Po GraalVM nepochybně přijde ještě novější VM, která bude optimalizovat ještě více.
Interpret často přidává další odkaz, protože ve své interní struktuře odkazuje na strukturu používanou v interpretovaném jazyce. S branch misprediction jste úplně vedle, způsob zpracování instrukcí je úplně stejný v interpretu i na CPU – zpracovává se instrukce po instrukci, z předchozí instrukce nejde odhadnout, jaká instrukce bude následovat, zároveň ale procesor může zpracovávat víc instrukcí jdoucích za sebou. Navíc moderní VM opravdu nezpracovávají každou jednotlivou instrukci softwarovým interpretem, ale překládají posloupnost instrukcí na strojový kód. Způsob, jakým moderní VM dosahují výkonu srovnatelného s nativním kódem, je takový, že se snaží potlačit dynamičnost jazyka – tj. vygenerují strojový kód pro případ, kdy budou dynamické aspekty zafixované, a před něj dají kontrolu, zda ty zafixované podmínky platí. Díky tomu právě správně funguje branch prediction, protože procesor může spekulativně vykonávat kód pro případ, kdy je daný aspekt fixní, což je ve většině případů splněno. Jedině v případě, kdy se projeví dynamický aspekt jazyka, podmínka splněná není a musí se skočit do pomalé větve, která umí vyřešit tu dynamičnost.
Dáváte mi do úst něco, co jsem neřekl, navíc tu mícháte interprety a JIT. Pokud nerozumíte tomu, co jsem napsal, tak si problematiku nastudujte - opravdu mi nestojí za to argumentovat se zabedněncem co nečte a jen musí mít vždycky pravdu. Podívejte se jak diskuze začala a kde jsme se dostali...
Celá diskuse začala mým konstatováním, že rozdíl výkonu mezi naivním interpretrem dynamického jazyka a VM s kompilací do strojového kódu a optimalizací je z nezanedbatelné části tvořen tím, že taková VM je schopná naservírovat procesoru kód, který redukuje cache miss. Když k tomu nemáte co říct, tak k tomu nic nepište, to je jednoduché.
Protože to máte obráceně. Eliminace cache-miss je příznivý efekt některých optimalizací VM, ale není to jejich primární cíl. Cíl je obvykle snížení cyklů potřebných na vykonání dané části kódu nebo snížení jeho paměťové náročnosti. Např. SMI optimalizace ve V8 zabraňuje boxování integerů a tím snižuje paměťovou náročnost běžícího programu, ovšem jako bonus eliminuje možný cache-miss, který by mohl vzniknout při unboxingu. Existují i jiné optimalizace např. "NaN Tagging", které jsou podobné SMI (jen využívají jinou reprezentaci).
Na cache miss má mnohem větší vliv návrh programu (použité algoritmy a datové struktury) než mikrooptimalizace ve VM. Např. i v tom JS si můžu vybrat mezi TypedArray, kde boxing nikdy nenastane, a Array, kde boxing nastat za určitých okolností může.
A jinak nemáte pravdu ani s tím Interpret vs JIT. První verze V8 měli velmi jednoduchý JIT, který nedělal prakticky žádné optimalizace kromě kompilace do nativního kódu a tzv. "Hidden Classes". Zrychlení 10x, ale ne kvůli cache-miss, ale kvůli tomu, že se odstranil ten interpret, který musí běžně vykonat N strojových instrukcí na to, aby zpracoval 1 instrukci VM. A mezi těmi N instrukcemi je obvykle taky branch, ale o tom už jsem mluvil že jo... Další věc např. instruction scheduling, což je něco o čem se interpretu může jen zdát, ale zase to nemá s cache-miss nic společného.
Nechápu, co je tak těžkého na tom si přiznat, že jste se sekl a dané problematice se nevěnujete. Vždyť vaše argumentace je jen přešlapování na místě a neustálé opakování toho samého. Vy ani neznáte jména těch optimalizací, ale víte, že všechny jsou ultimátně zaměřené pouze na eliminaci cache-miss :)
Když vám na tom tolik záleží, já vám to klidně přiznám. Ano, jistě, máte pravdu, když tvrdíte, že hloupý interpret dynamického jazyka bude mít stejné množství cache-miss jako nativní kód. Jak mne vůbec mohlo napadnout něco jiného. Tabulky virtuálních metod nebo hashtabulky s odkazy na další hashtabulky jsou z hlediska cache-miss stejně efektivní, jako Céčkové struct. Hlavně že už teď budete moci klidně spát.
A co brání interpretu použít "Hidden Classes"? Vy máte zafixované nějaké předpoklady z minulého století, které absolutně nereflektujou současnou situaci v návhrhu VM a používáte je jako nějaký univerzální argument na všechno. Jestli VM použije hash table nebo přístup přes index nemá co dělat s tím, jestli je kód interpretovaný nebo zkompilovaný (viz např. V8 bytecode interpreter).
Takže si žijte dál v tom vašem imaginárním světě kde existujou pouze 2 možnosti, které tu popisujete, reálný svět je ale mnohem rozmanitější a zajímavější.
Interpretru vskutku nic nebrání ve vylepšování, aby se dostal na úroveň V8 nebo třeba až GraalVM. Akorát pak nebude mít smysl dělat porovnání rychlosti jednoduššího interpretru s něčím na pomezí interpretru a VM jako V8 nebo s pokročilou VM jako GraalVM, když budou existovat jen pokročilé VM. Ovšem v mém světě se nepoužívá všude GraalVM nebo něco podobného, v mém světě leckde ještě běží třeba Python 2.
Já jsem nikde nepsal o pouhých dvou možnostech, uváděl jsem to jako dva dostatečně vzdálené body na nějaké škále, takže má smysl porovnávat jejich rozdíly. Pořád ale i ty dva body jsou o jeden bod víc, než váš svět omezený jen na V8.
Jestli VM použije hash table nebo přístup přes index jsem vůbec neřešil. Stále nechápete, že ve světě dynamických jazyků jsou běžné zanořené struktury řešené pomocí odkazů. To je to podstatné, že standardně je to spousta dynamicky alokovaných objektů, a pokud je chce interpret/VM dostat na jedno místo v paměti, musí se o to nějak aktivně snažit.
Jestli je kód interpretovaný nebo zkompilovaný je jenom otázka toho, jak nadefinujeme ten který pojem. Je Java bajtkód zkompilovaný nebo interpretovaný? A je C kód zkompilovaný pro ARM64 spuštěný v emulátoru na x64 zkompilovaný nebo interpretovaný?
Nechápu, proč pořád musíte polemizovat s něčím, co jsem nenapsal.
Stačilo by porovnat nějaké aplikace z reálného světa. Mám aplikaci tu a tu a vím že mi pod GraalVM za daných okolností poběží o tolik % rychleji. To má výpovědní hodnotu. Nebo stačí říct, k jaké změně došlo pokud zákazník XY nasadil ten a ten provoz pod GraalVM. To má taky výpovědní hodnotu, ani není třeba psát komplikované syntetické testy.