Wasmi 2.0 - Engineering van de Snelste Wasm-Interpreters

In mijn vorige bericht over Wasmi 1.0 beloofde ik een fundamentele revisie van de engine voor de toekomstige Wasmi-versie. Die toekomst is nu aangebroken.

Wasmi is een efficiënte en functierijke WebAssembly (Wasm) interpreter. Het is een uitstekende keuze voor IoT-apparaten, plugin-systemen (Typst, Zellij, Josh), cloud-hosts, smart contracts (Soroban, Ripple) en zelfs voor lichtgewicht gameconsoles (Firefly Zero).

Voordat ik inga op de details: een groot dankwoord aan de Stellar Development Foundation (SDF) die het Wasmi-project sinds oktober 2024 sponsort. Zonder hun steun zou Wasmi niet zijn waar het vandaag is. Ook speciale dank aan Felix Kutzner voor het nalezen van dit artikel en het suggereren van vele verbeteringen.

Wasmi 2.0 Release

Na acht maanden gefocust werk is Wasmi 2.0 klaar voor gebruik. Deze release richt zich primair op uitvoeringsprestaties: Wasmi 2.0 is ongeveer 2,2x sneller dan Wasmi 1.0 (geometrisch gemiddelde over de wasmi-benchmarks suite op een Apple M2 Pro).

Wasmi 2.0 introduceert ook nieuwe instellingen, zoals de validate crate-feature, die de omvang van het binaire artifact aanzienlijk vermindert. Daarnaast zijn diverse door gebruikers gevraagde functies toegevoegd, waaronder:

  • Stabiele fuel metering.
  • Ondersteuning voor het deterministische profiel van WebAssembly.
  • Een verbeterde Wasmi CLI-tool.

Prestaties en Positionering

Hoe presteert Wasmi 2.0 ten opzichte van de concurrentie? Wasmi 2.0 is gebenchmarkt tegen enkele van de snelste portable Wasm-interpreters:

  • Wasm3
  • WAMR fast-interpreter
  • Wasmtime Pulley
  • Makepad Stitch
  • Wasmi 1.0

De benchmarks zijn uitgevoerd met het wasmi-benchmarks project op drie verschillende hardware-configuraties om de voorkeuren van de interpreters zichtbaar te maken: Apple M2 Pro, AMD EPYC 7763 en Intel Xeon Platinum 8370C.

Geometrisch Gemiddelde

Bij het analyseren van het geometrisch gemiddelde over alle uitvoerings- en opstart-benchmarks valt op dat de opstartprestaties van Wasmi 2.0 uitstekend blijven en grotendeels op hetzelfde niveau liggen als de vorige versie.

Conclusie Benchmarks: Wasmi 2.0 behoort duidelijk tot de categorie van de snelste portable Wasm-interpreters.

Wat maakt Wasmi 2.0 zo snel?

Wasm3 en Stitch vertonen onder de motorkap veel overeenkomsten met Wasmi 2.0. In deze sectie worden de implementaties besproken die Wasmi 2.0 versnellen.

Nieuwe modi voor instructie-dispatch

Wasmi 2.0 beschikt nu over vier verschillende modi voor het dispatcheren van instructies:

ModeBeschrijvingCrate Features
Direct-Threaded CodeDe snelste configuratie (ook gebruikt door Wasm3 en Stitch). Functiepointers worden direct in de interne IR van de interpreter geplaatst en maakt gebruik van tail calls om van de ene naar de volgende instructie-handler te springen.-
Indirect-Threaded CodeVergelijkbaar met Direct-Threaded, maar plaatst op-codes in de interne IR en gebruikt een jump table om een op-code te mappen naar de functiepointer van de handler. Ongeveer 10-15% trager, maar verbruikt minder geheugen voor de IR.indirect-dispatch
Switch-LoopDe techniek uit Wasmi 1.0. Een naïve methode met een loop en een switch (of match). Dit laat veel prestaties liggen, vooral op Apple Silicon.portable-dispatch + indirect-dispatch
Call-LoopRoept de volgende instructie-handler aan binnen een loop zonder tail calls. Zeer traag en niet geheugenefficiënt; wordt afgeraden.portable-dispatch

Gebruikers wordt geadviseerd:

  • Direct-Threaded Code te gebruiken voor maximale prestaties.
  • Indirect-Threaded Code voor een balans tussen prestaties en geheugengebruik.
  • Switch-Loop voor platforms die geen tail calls ondersteunen.

De auto-dispatch crate-feature selecteert automatisch de beste configuratie op basis van het platform.

Signatuur van de Execution Handler

Voor de uitvoering vertaalt Wasmi de Wasm-bytecode naar Wasmi IR. Elke IR-instructie heeft een eigen handler met de volgende vaste signatuur:

fn(
    store: &mut PrunedStore, // Referentie naar de `Store<T>` geassocieerd met de uitvoering.
    ip: Ip,                  // De instruction pointer.
    sp: Sp,                  // De stack pointer.
    mem0: Mem0Ptr,           // Pointer naar de data van de standaard lineaire memory: `(memory 0)`
    mem0_len: Mem0Len,       // Aantal bytes van de standaard lineaire memory.
    instance: Inst,          // Pointer naar de Wasm-instantie van de huidige functie.
    ireg: Ireg,              // Accumulator register voor integer- en referentiewaarden.
    freg32: Freg32,          // Accumulator register voor `f32` waarden.
    freg64: Freg64,          // Accumulator register voor `f64` waarden.
) -> Done;                   // Status om traps of succesvolle stops te signaleren.
  • store: Wordt gebruikt voor fuel metering, host calls, memory.grow en table.grow.
  • ip: Geeft aan waar de executor zich bevindt in de stream van instructies.
  • sp: De positie van de huidige functie binnen de waardestack.
  • mem0 en mem0_len: Geoptimaliseerde toegang tot de standaard memory (memory 0).
  • instance: Wordt gebruikt om objecten zoals globals, functies, tables en memories te laden.
  • ireg, freg32 en freg64: Accumulator registers voor het efficiënt opslaan van tussenresultaten.

Het probleem met Calling Conventions

Zeven van de negen argumenten in de handlers vereisen general-purpose registers (GPR's). Echter, gangbare calling conventions zoals sysv64 bieden slechts zes GPR's voor integer-argumenten. Een zevende argument zou de prestaties ernstig schaden omdat dit naar de stack zou moeten worden verplaatst (spilled) bij elke dispatch.

Om dit op te lossen, is besloten het instance-argument om te zetten naar een floating-point waarde waar nodig. Benchmarks tonen aan dat deze verplaatsing tussen integer- en float-registers weinig impact heeft op de snelheid.

Accumulator-registers

Hoe Wasmi 1.0 werkte

Wasmi 1.0 maakte overal gebruik van stack-offsets (stack slots) voor operanden en resultaten. Een vereenvoudigde i64.add handler (res = lhs + rhs) zag er als volgt uit:

  1. Decodeer resultaat Slot uit ip.
  2. Decodeer lhs operand Slot uit ip.
  3. Decodeer rhs i64 waarde uit ip.
  4. Laad waarde van lhs (sp[lhs]).
  5. Bereken de som sp[lhs] + rhs.
  6. Sla de som op in sp[result].
  7. Verschuif ip naar de volgende handler.
  8. Voer de volgende handler uit.

Hoe Wasmi 2.0 werkt

Wasmi 2.0 introduceert drie accumulator registers: ireg, freg32 en freg64. Hierdoor kunnen operanden en resultaten direct in hardware-registers worden geladen en opgeslagen.

Dezelfde i64.add operatie ziet er nu zo uit:

  1. Decodeer rhs i64 waarde uit ip.
  2. Bereken de som ireg + rhs.
  3. Sla de som op in ireg.
  4. Verschuif ip naar de volgende handler.
  5. Voer de volgende handler uit.

Dit is aanzienlijk efficiënter omdat accumulator registers impliciet zijn en geen kostbare decoding of stack-operaties vereisen.

In aarch64 assembly (met x1 = ip en x6 = ireg) ziet dit er als volgt uit:

i64_add_rri:
    ldr x7, [x1, #16]!   ; bump ip by 16 bytes and load next handler
    ldur x8, [x1, #-8]   ; fetch rhs immediate operand from ip
    add x6, x8, x6       ; ireg = ireg + rhs
    br x7                ; tail-call next handler

Kopieerinstructies (Copy Instructions)

Het gebruik van impliciete accumulator registers introduceert een nadeel: er zijn nu kopieerinstructies nodig die in het oude ontwerp niet bestonden.

Voorbeeld: local.set & local.tee Voor de sequentie (local 0) + 10 opslaan in (local 1):

  • Wasmi 1.0: Kon dit in één IR-instructie: i32addssi 1 0 10.
  • Wasmi 2.0: Vereist twee instructies: i32addrsi 0 10 (resultaat in ireg) gevolgd door u64copysr 1 (kopieer ireg naar local 1).

Voorbeeld: Register Preservation Wanneer een waarde in ireg moet worden bewaard terwijl een andere berekening wordt uitgevoerd, is een kopie naar een stack slot noodzakelijk (u64copysr A) om te voorkomen dat de waarde wordt overschreven.

Oplossingen voor kopieer-overhead

  1. Efficiëntere kopieën: Introductie van geoptimaliseerde IR-instructies voor veelvoorkomende situaties (bijv. u64copysNr voor locals 0-10).
  2. Op-Code Fusion: Omdat Wasm veel add of load instructies produceert die direct gevolgd worden door local.set of local.tee, zijn er "fused" varianten geïntroduceerd die het resultaat direct in zowel het register als het stack slot opslaan.

Accumulatoren bij Control Flow

Control flow in WebAssembly (blocks, loops, ifs) werkt met een stack-diepte. Wasmi 2.0 probeert accumulator registers over deze grenzen heen mee te nemen.

  • Als een block resultaten heeft van typen (i32 f32), gebruikt Wasmi 2.0 zowel ireg als freg32.
  • Als een block meerdere resultaten van hetzelfde type heeft (bijv. i32, i32, i32), wordt alleen het laatste resultaat in ireg geplaatst; de rest gaat naar stack slots.

Dit is vooral nuttig bij loops, waar inductievariabelen zo in accumulator registers kunnen blijven staan.

Toegang tot Instance-objecten

Wasmi 1.0

Wasmi 1.0 gebruikte een naïeve representatie waarbij elk type object (memories, tables, etc.) een eigen heap-allocatie had. De global.get g instructie vereiste drie stappen: het lezen van de boxed slice, het laden van de handle en het resolven van de handle in de store. Dit was traag, waardoor er een speciale cache voor (global 0) was nodig.

Wasmi 2.0

Wasmi 2.0 maakt gebruik van het feit dat alle instanties van hetzelfde Wasm-module dezelfde object-layout delen. De InstanceEntity is nu een dynamisch geschaald type waarbij alle handles in één aaneengesloten allocatie staan (buffer).

De volgorde in de buffer is: memories, globals, tables, funcs, elems, datas.

  • Memories staan eerst, zodat memory 0 op adres 0 blijft.
  • Datas staan laatst, omdat het aantal data-segmenten pas bij creatie bekend is.

De buffer bevat nu een "entity cache" (pointers naar de eigenlijke objecten in de store), wat is mogelijk gemaakt door de introductie van StableArena voor stabiele adressen. Toegang tot een instance-object is nu slechts één pointer-offset verwijderd van de instance, wat extreem snel is.

Vergelijking met Wasm3 & Stitch

Wasm3 en Stitch gebruiken instance-gerelateerde bytecode (pointers direct in de bytecode per instantie). Wasmi gebruikt module-gerelateerde bytecode (gedeeld tussen instanties), wat veel geheugen bespaart. Dankzij de nieuwe InstanceEntity-layout behaalt Wasmi 2.0 nu vergelijkbare prestaties als Wasm3 en Stitch, zonder de nadelen van per-instantie bytecode.

Lock-Free CodeMap

De CodeMap slaat alle IR-functiebodies op.

  • Wasmi 1.0: Gebruikte een mutex-protected Vec-achtige arena. Elke interne Wasm-aanroep moest de mutex locken, wat zeer inefficiënt was.
  • Wasmi 2.0: Gebruikt "append-only buckets" die nooit worden geheralloceerd of verplaatst. Hierdoor kunnen pointers naar functiebodies direct in de bytecode worden gebakken.

Toegang tot functies is nu lock-free en concurrent, met minimale synchronisatie-overhead. Een call_internal handler voert nul look-ups uit omdat het adres van de FuncEntry direct in de bytecode staat.

Vaste 64-bits cellen

In Wasmi 1.0 waren stack cells 64-bit, of 128-bit als de simd feature was ingeschakeld. De 128-bit modus zorgde voor 5-10% prestatieverlies door meer geheugenverkeer en slechtere cache-utilisatie.

Wasmi 2.0 hanteert altijd een vaste breedte van 64 bits. SIMD-waarden beslaan simpelweg twee aangrenzende cellen. Hierdoor heeft het inschakelen van SIMD geen negatieve impact meer op de prestaties van niet-SIMD code.

Onbedoelde Rust Deoptimalisatie

Tijdens het benchmarken werd ontdekt dat Stitch een prestatiedaling van 30% vertoonde tussen Rust 1.91 en 1.92. De oorzaak was DestinationPropagation, een MIR-optimalisatie die MIR-locals met dezelfde waarde samenvoegt.

Dit zorgde ervoor dat twee dispatch-paden van een conditionele branch-handler inklapten tot één enkel pad (csel), waardoor de branch predictor van de CPU minder effectief werkte.

Wasmi 2.0 leed aan hetzelfde probleem. Na het toepassen van een fix steeg de CoreMark-score van Wasmi 2.0 van ~2800 naar boven de 4200—een verbetering van 50%. Dit bleek de belangrijkste enkele optimalisatie voor Wasmi 2.0.

Wat is de volgende stap?

Voor Wasmi 3.0 is het doel om volledige ondersteuning te bieden voor WebAssembly 3.0. Dit vereist de implementatie van de volgende (nog ontbrekende) voorstellen:

  • Function-references
  • Exception-handling
  • GC (Garbage Collection)

Probeer het uit!

Wasmi kan op verschillende manieren worden gebruikt:

  • Als library via de wasmi crate.
  • Via de CLI-applicatie (cargo install wasmi_cli).
  • In C-interfacing talen via de Wasmi C-API.
  • Door Doom te spelen in de browser, aangedreven door Wasmi 2.0.

Persoonlijke noot

Zonder de sponsoring van de Stellar Development Foundation zou Wasmi 2.0 niet bestaan. Deze financiering stelde mij in staat om twee jaar fulltime aan dit open-source project te werken. De sponsoring eindigt in oktober 2026. Ik ben voornemens Wasmi daarna voort te zetten en zoek naar mogelijkheden om dit mogelijk te maken (nieuwe sponsoring of een parttime rol).

***

Voetnoten

  1. De auteur is geen native English speaker; het artikel is handgeschreven.
  2. Gebruik van cargo-bloat-show hielp bij het elimineren van binary bloat.
  3. "Stable fuel metering" betekent dat het verbruik per executie-eenheid gelijk blijft tussen Wasmi-versies.
  4. Wasmtime Pulley en WAMR fast-interpreter zijn opgenomen vanwege hun respectable prestaties.
  5. Pulley is een optimaliserende interpreter die zeer snel is bij ongeoptimaliseerde Wasm-input.
  6. De configs eager, lazy en lazy-translation worden elders gedocumenteerd.
  7. Bij het uitschakelen van de stable feature gebruikt Wasmi het onstabiele Rust-keyword become.
  8. De next! macro wordt gebruikt om verschillende dispatch-modi te ondersteunen met dezelfde code.
  9. Een global0 pointer werd overwogen, maar niet geïmplementeerd omdat globale toegang al snel genoeg is.
  10. De AnyHandleAndEntity type behoudt de handle voor niet-kritieke functies buiten de executor.
  11. Wasmi 2.0 is sneller in execute/counter-global dan Wasm3/Stitch, waarschijnlijk door het gebruik van accumulator registers.
  12. SIMD is standaard uitgeschakeld om binary bloat en compilatietijd te beperken.
  13. De bron van de collapsed branch sites in Wasmi 2.0 was anders dan in Stitch, maar het effect was hetzelfde.
  14. Wasmi Doom is gecreëerd met behulp van AI.