De plaag van x86-emulatie: FEX-Emu – Een snelle Linux usermode x86- en x86-64-emulator
De problemen bij het emuleren van dit geheugenmodel op het weak ordering-geheugenmodel dat ARM definieert, zijn veelzijdig. In dit artikel bespreken we alle problemen waar we tegenaan lopen en de manieren waarop we deze oplossen (of in sommige gevallen niet kunnen oplossen). Pak er een snack en een warm drankje bij, want dit wordt een lang verhaal.
Wat is x86-TSO precies?
Voordat we dieper ingaan op hoe we het probleem met het x86-geheugenmodel omzeilen, moeten we eerst bespreken wat het precies is. Een geheugenmodel is een set regels voor hoe geheugentoegangen in een systeem zich ten opzichte van elkaar gedragen. Deze regels bepalen hoe loads (lezen) en stores (schrijven) interageren in een single-threaded of multi-threaded omgeving.
Er zijn een handvol populaire geheugenmodellen geïmplementeerd in diverse hardware, maar de twee die vandaag relevant zijn, zijn het relaxed (of weak) consistentiemodel van ARM en de x86-variant van het Total-Store-Ordering (TSO) consistentiemodel. Deze twee modellen vormen in feite de extremen van het spectrum: ARM is het meest ontspannen, wat aanzienlijke hardware-optimalisaties toelaat; x86 is het strengst en handhaaft een zeer sterk coherentiemodel dat weinig ruimte laat voor optimalisatie.
Let hierbij op het verschil tussen consistentie en atomiciteit. Hoewel deze aan elkaar gerelateerd zijn, zijn ze niet hetzelfde en worden ze niet in alle gevallen gegarandeerd.
Hoe x86-TSO werkt
Bij TSO is de werking zeer strikt. De programmeur kan ervan uitgaan dat wanneer er een memory store plaatsvindt, deze coherent zichtbaar is voor alle andere processoren in het systeem. Dit betekent daarnaast dat wanneer er een memory load plaatsvindt, alle stores die logisch gezien daarvóór kwamen, voltooid of in ieder geval zichtbaar zijn. Dit komt overeen met de intuïtie van een programmeur: je schrijft naar het geheugen en het is direct zichtbaar op het moment van schrijven. De stores bepalen effectief de zichtbaarheid van de loads, vandaar de naam van het model.
Hoe het ARM-geheugenmodel werkt
Het weak memory model van ARM is minder intuïtief. Standaard zijn de reguliere memory loads en stores die ARM gebruikt niet strikt coherent over de processoren in het systeem, wat de CPU in de meeste gevallen efficiënter laat werken. Wanneer een store-instructie wordt uitgevoerd, is dat stukje geheugen (de cacheline) niet onmiddellijk zichtbaar voor andere processoren. Dit bespaart kostbare stroom en verhoogt de efficiëntie, omdat het hardwarematig duur is om cachelines van andere cores te invalideren of om andere caches te laten snoopen.
Bijgevolg is het bij een processor die data laadt uit geheugen waar een andere processor naar heeft geschreven, niet gegarandeerd dat deze load de bijgewerkte data ziet. In multi-threaded applicaties zou dit grote problemen kunnen veroorzaken. Oudere versies van ARM (ARMv7 en ouder) gebruikten een memory barrier-instructie om de volgorde te garanderen, maar dit had aanzienlijke prestatiegevolgen.
Om deze beperking van consistentie op te lossen, introduceerde ARM load-acquire en store-release geheugeninstructies. In C++-termen komt dit overeen met respectievelijk de std::atomic definities memoryorderacquire en memoryorderrelease. In ARM-terminologie worden deze instructies technisch gezien niet als atomaire operaties beschouwd, maar programmeurs halen deze twee vaak door elkaar. FEX heeft de termen atomic-load en atomic-store gebruikt om hetzelfde te betekenen.
Het primaire doel van deze instructies is het afdwingen van de geheugenvolgorde tussen deze klassen van instructies. ARM noemt dit het "Release Consistency sequentially consistent (RCsc)" model. De kern is dat load-acquire-instructies sequentieel moeten worden waargenomen zonder herordening, en dat store-release-instructies dat ook moeten doen, terwijl ze voldoen aan "barrier-ordered-before" semantiek. Hiermee is de kostbare memory barrier-instructie uit oudere ARM-architecturen overbodig.
De bescheiden beginperiode van ARMv8.0-a
Dit is het uitgangspunt bij ARMv8.0-a wanneer we het x86-TSO-geheugenmodel emuleren. We zetten alle x86 memory loads om in ARM load-acquire-instructies, en x86 memory stores in store-release-instructies. Hiermee krijgt FEX effectief dezelfde geheugensemantiek als x86, hoewel we eigenlijk strenger zijn dan strikt noodzakelijk, omdat er geen tussenweg was die exact hetzelfde gedrag vertoonde.
Het emuleren van TSO met deze instructies is echter extreem kostbaar. ARM-CPU's zijn niet ontworpen om deze relatief zeldzame acquire/release-instructies plotseling als de overgrote meerderheid van de uitgevoerde instructies te verwerken.
Prestatieanalyse
Uit microbenchmarks blijkt dat bij vijf geteste CPU's er drie aanzienlijk gehinderd worden door het gebruik van acquire-loads. Met name de AmpereOne-CPU vertoont zeer lage resultaten voor release-store-instructies. Ook de M1-resultaten voor acquire/LRCPC load liggen aanzienlijk lager dan de baseline.
Bij de AmpereOne is te zien hoe problematisch dit legacy-pad is. Door acquire-release semantiek te gebruiken voor elke load bij x86-emulatie, worden strikte beperkingen opgelegd aan ARM-CPU's: load-instructies kunnen niet langer om elkaar heen worden geordend. Wanneer er miljoenen per seconde in omloop zijn, zijn de prestaties logischerwijs slecht. Omdat dit de enige instructies waren in ARMv8.0-a, moesten we deze gebruiken.
De weg vooruit: LRCPC en Apple Silicon
LRCPC-load instructies
Sinds ARMv8.3 is de LRCPC-load-instructie verplicht. Deze extensie voegt nieuwe load-instructies toe en introduceert het "Release Consistency processor consistent (RCpc)" geheugenmodel. Dit model is precies wat we nodig hadden; het is ontworpen met de vereisten van x86-emulatie in het achterhoofd. In onze tests matchen de LRCPC-loads op bijna alle platforms qua prestaties met reguliere loads. Zodra FEX deze extensie detecteert, stoppen we met het gebruik van Acquire-Load en schakelen we over naar LRCPC-Load.
De aanpak van Apple M1
Apple heeft een andere route gekozen door directe hardwareondersteuning voor het x86-TSO-geheugenmodel toe te voegen aan hun Apple Silicon-processoren. Wanneer deze CPU-functie is ingeschakeld, verandert het gedrag van de reguliere ARM load/store-instructies om aan de x86-vereisten te voldoen.
Op Apple-hardware zijn LRCPC-load-instructies daarom eigenlijk aliassen van acquire-load-instructies, omdat hun x86-emulator deze niet eens nodig heeft. Ze gebruiken simpelweg reguliere load/store-instructies, wat resulteert in een verwaarloosbare prestatie-overhead. Wanneer FEX deze CPU-functie via Asahi Linux detecteert, schakelen we dit in voor een "gratis" prestatieverbetering.
Hoewel er een zorg is dat ARM-native code trager wordt omdat alle toegang nu TSO is, is dit in de praktijk verwaarloosbaar omdat de hoeveelheid native ARM-code die onder emulatie draait bijna 0% is.
De FEAT_LRCPC extensies
De officiële FEAT_LRCPC extensie heeft drie versies die stapsgewijs verbeteringen aanbrengen:
- FEAT_LRCPC: Voegt basis GPR TSO load-instructies toe.
- FEAT_LRCPC2: Voegt kleine offset immediate toe aan TSO load-instructies.
- FEAT_LRCPC3: Voegt basis vector- en stack-gebaseerde TSO load & store-instructies toe.
Zelfs met deze drie extensies blijven er edge-cases die minder elegant worden geëmuleerd dan met een hardwarematige TSO-toggle.
Problemen met geheugentoegang en alignment
In eerdere secties gingen we uit van correcte alignment (uitlijning) om een baseline te bepalen. Echter, x86-applicaties negeren alignment volledig; ze benaderen het geheugen zoals ze willen, dwars door cacheline grenzen heen. Dit fenomeen staat bekend als split-locks. Dit is zo problematisch dat zelfs de Linux-kernel deze detecteert en games vertraagt wanneer ze dit doen.
Natural Alignment in ARM
x86 garandeert dat een load-store binnen één cacheline atomair is en voldoet aan het coherentiemodel. Als een load-store echter een cacheline overschrijdt, is de data niet atomair en kan er tearing optreden.
Het probleem is dat ARMv8.0 natural alignment vereist: voor elke grootte van de benaderde data moet de offset in het geheugen overeenkomen met die grootte (bijv. een 8-byte toegang moet op offsets 0, 8, 16, 24, etc. plaatsvinden). Als hier niet aan wordt voldaan, genereert de CPU een alignment fault.
De oplossing van FEX
Binnen het JIT-mechanisme van FEX houden we bij welke instructies x86 load-stores emuleren. Wanneer we weten dat een instructie een alignment fault kan veroorzaken, plaatsen we een patchpoint (een NOP-instructie). Wanneer er een fout optreedt, vangt FEX deze op, patcht de code van een load-acquire/store-release naar een basis equivalent, en omhult de instructie met een data memory barrier (DMB).
Prestaties bij niet-uitgelijnde toegang
Wanneer we terugvallen op DMB-instructies vanwege alignment-problemen, keldert de performance. Hieronder volgt een analyse per platform:
- AmpereOne: Verrassend genoeg zijn aligned en unaligned loads hier ongeveer gelijk. De stores presteren echter zeer slecht, zelfs zonder alignment-problemen. Dit suggereert dat Ampere optimaliseert voor server-workloads in plaats van consumentenhardware.
- Cortex-X4 (Snapdragon 8 Gen 3): Presteert redelijk goed voor een mobiele SoC. De aligned loads en stores halen respectievelijk 11,5 GB/s en 6,7 GB/s. Echter, bij unaligned toegang zorgt de DMB-instructie voor een prestatieverlies van ongeveer 50%.
- Cortex-X925 (DGX Spark): Vergelijkbare trends als de X4, maar met een veel hogere geheugenbandbreedte (273 GB/s tegenover 76,8 GB/s). De penalty voor unaligned toegang is vergelijkbaar met de X4.
- Oryon-3 (Qualcomm Snapdragon X Elite): Zeer sterke resultaten. Aligned LRCPC-loads matchen de prestaties van reguliere loads. Bij unaligned toegang is er een penalty (loads ~70% verlies, stores ~43%), maar het platform is overall nog steeds sneller dan de aligned accesses van de Cortex-cores.
- Apple M1: De absolute winnaar. Door de hardwarematige TSO-ondersteuning is er vrijwel geen verschil tussen aligned en unaligned versies. De hardware handelt dit direct af zonder dat er DMB-instructies in de code gepatcht hoeven te worden.
FEAT_LSE2
ARM introduceerde de FEAT_LSE2 extensie om alignment-vereisten te versoepelen voor acquire/LRCPC/release instructies en read-modify-write (RMW) atomics. Echter, dit versoepelt de eisen alleen voor toegang binnen een 16-byte granule. Omdat x86-applicaties vaak toegang zoeken over de gehele cacheline, biedt deze extensie slechts marginale voordelen.
Atomaire instructies en RMW-operaties
x86 ondersteunt atomaire geheugenoperaties (ALU-operaties op data in het geheugen zonder zichtbare tussenstaat). In x86 is dit altijd zowel atomair als coherent. ARM laat je kiezen tussen alleen atomair of beide.
Mapping x86 naar ARMv8.1-a
Sinds ARMv8.1-a zijn er instructies die bijna 1:1 mappen op x86 atomaire operaties:
| x86 | ARMv8.1-a |
|---|---|
| LOCK DEC | ldaddal |
| LOCK INC | ldaddal |
| LOCK NEG | ??? |
| LOCK NOT | ldeoral |
| LOCK ADC | ldaddal |
| LOCK ADD | ldaddal |
| LOCK AND | ldclral |
| LOCK OR | ldsetal |
| LOCK SBB | ldaddal |
| LOCK SUB | ldaddal |
| LOCK XADD | ldaddal |
| LOCK XOR | ldeoral |
| LOCK BTC | ldclralb |
| LOCK BTR | ldeoralb |
| LOCK BTS | ldsetalb |
| LOCK CMPXCHG | casal |
| CMPXCHG8B | caspal |
| CMPXCHG16B | caspal |
Het probleem met atomaire split-locks
Hoewel de mapping 1:1 lijkt, is de prestatie bij niet-uitgelijnde (unaligned) atomaire operaties een groot probleem. x86 heeft "atomaire cachelines", waardoor een unaligned operatie binnen één cacheline bijna net zoveel kost als een aligned operatie (ca. 1,44 ns). Wanneer een operatie echter een 64-byte granule overschrijdt, treedt er een split-lock op, wat extreem traag is (ca. 660 ns).
Op ARM-platforms is dit nog veel erger. Omdat FEX geen DMB-operaties kan gebruiken voor deze atomaire instructies, veroorzaakt elke split-lock een alignment-fault. Dit dwingt de CPU door een trage cyclus: kernel → userspace signal handler → kernel → original code. Dit gebeurt bij elke enkele split-lock operatie, wat resulteert in een enorme prestatiehit.
Uitzonderingen: Oryon-3 en Valve Steam Frame
- Oryon-3: Qualcomm heeft "coherent cachelines" geïmplementeerd. Hierdoor matchen atomaire operaties binnen een 64-byte cacheline de prestaties van de aligned versie, wat een enorme verbetering is.
- Valve Steam Frame: Valve gebruikt een specifieke kernel-patch (ontwikkeld door een FEX-ontwikkelaar) waardoor de Linux-kernel zelf de unaligned atomic afhandelt zonder de trage omweg via userspace. Dit verbetert de latency van ~1060 ns (X925) naar ~209 ns (Cortex-X4 op Steam Frame).
Is split-lock emulatie correct?
Correcte emulatie van split-locks is hardwarematig zeer lastig. Een naïeve implementatie met een globale mutex is te traag en kan nog steeds leiden tot data tearing als een aligned atomaire operatie niet meedoet aan de mutex.
Een alternatief is Transactional Memory Extension (TME), maar ARM heeft deze extensie gedeprecieerd en vrijwel niemand heeft hem geïmplementeerd.
Een voorgestelde oplossing
FEX stelt voor om een 128-bit CASP-instructie toe te staan die precies over de atomaire granule-boundary kan straddelen (64-bit onder, 64-bit boven). Als de instructie faalt, kan FEX deze simpelweg herhalen (retry) dankzij de ARM LL/SC architectuur. Dit zou de correctheid garanderen en de prestaties benaderen van x86-gedrag.
Ongecacheerd geheugen (Uncached Memory)
In Vulkan-terminologie maken we onderscheid tussen VKMEMORYHOSTCACHEDBIT (gecacheerd) en ongecacheerd geheugen. Ongecacheerd geheugen wordt vaak gebruikt bij PCIe GPU's via Write-Combine (WC) mapping.
De performance-cliff
Bij UMA-systemen (zoals APU's) kan geheugen zowel gecacheerd, coherent als GPU-zichtbaar zijn. Bij PCIe GPU's is dit niet gegarandeerd, waardoor game-engines vaak terugvallen op ongecacheerd geheugen om data naar de GPU te sturen.
Onze benchmarks tonen aan dat store prestaties op ongecacheerd geheugen op ARM-platforms tot 816x trager kunnen zijn dan op x86 (Zen 4). Dit leidde in games als Hollow Knight: Silksong en Subnautica 2 tot framerates van minder dan 1 FPS.
Dit komt doordat de huidige ARM-extensies (FEAT_LRCPC1/2/3) wel load-instructies verbeteren voor x86-TSO, maar niet de stores naar write-combine geheugen.
Workarounds voor UMA
Voor gebruikers van UMA-systemen is er een oplossing: we kunnen de videodriver dwingen om altijd gecacheerde buffers te gebruiken. We hebben een Adreno Turnip patch die dit implementeert voor platforms die dit ondersteunen. Gebruikers van Asahi Linux hebben hier minder last van vanwege hun hardware-TSO-bit.
Blik op de toekomst
De uitdagingen bij het emuleren van het x86-TSO-model zijn groot, maar de vooruitgang van ARMv8.0 naar de huidige hardware is verbazingwekkend. Hoewel niet alle edge-cases op architectuurniveau zijn opgelost, is er een duidelijke inzet van hardwareleveranciers om de compatibiliteit te verbeteren. Hopelijk kunnen we over een decennium terugkijken op deze problemen terwijl we genieten van kwalitatieve x86-games die nooit naar ARM zijn geporteerd, maar toch perfect draaien.
Geschreven op 17 september 2026
Groetjes,