SIMD in de jaren '90: Programmeren voor Intel's Pentium MMX

Het is een rite de passage voor elke student die onze module software-rendering voltooit om vragen te gaan stellen en het onderwerp SIMD te gaan verkennen.

Vandaag de dag hebben we AVX-512, NEON, SVE en GPU's met duizenden kernen. Deze moderne CPU's en extensies zijn het resultaat van jaren van exploratie, leren en marktdruk, en ze brengen allemaal veel geschiedenis en retro-compatibiliteitsruis met zich mee. Zoals altijd vind ik het leuk om terug te kijken en verschillende technologieën te bespreken door te analyseren wat er gebeurde toen ze net begonnen. Eind jaren '90 leerden veel programmeurs voor het eerst hoe ze een paar extra frames per seconde uit een Pentium MMX konden persen door handmatig assembly te schrijven.

Laten we terugreizen naar 1997 en kijken hoe het programmeren voor MMX eruitzag.

Wat was MMX?

MMX (MultiMedia eXtensions) werd in 1997 geïntroduceerd met de Pentium MMX-processor voor een verbeterde multimedia-ervaring, waaronder graphics, digitale signaalverwerking, audio en modemsoftware.

Het was een set van 57 aanvullende instructies die in de Pentium-chip waren ingebouwd voor verbeterde prestaties. De CPU moest worden omgeschakeld naar de MMX-modus, waardoor de eerste 64 bits van de acht 80-bit floating-point registers van x86 werden omgezet in MMX-registers.

Acht MMX-registers (64 bits lang)

Het idee achter de creatie en het gebruik van MMX was simpel: in plaats van één integer tegelijk te verwerken, verwerkt de CPU meerdere integers die in één enkel 64-bit register zijn verpakt, gelijktijdig.

Voor multimediasoftware (beeldbewerking, audio-mixing, videoweergave en zelfs games) kon dit aanzienlijke versnellingen opleveren. Stel dat we acht bytes individueel zouden optellen:

  • 10 + 20
  • 30 + 40
  • 50 + 60
  • 70 + 80
  • ...

MMX stelt ons in staat om al deze acht optellingen met één enkele instructie uit te voeren. Dat is parallellisme, en dat is het hoofdidee achter SIMD.

Wat is SIMD?

"Single Instruction, Multiple Data" is een type parallelle verwerkingstechniek. Het beschrijft computers met meerdere verwerkingselementen die gelijktijdig dezelfde operatie uitvoeren op meerdere datapunten.

SIMD kan intern zijn (onderdeel van het hardwareontwerp) of direct toegankelijk zijn via een instructiesetarchitectuur (ISA). In het geval van MMX breidde Intel hun x86-instructieset uit om de nieuwe SIMD-instructies op te nemen.

Was MMX de eerste SIMD?

Nee, MMX was zeker niet de eerste SIMD, hoewel het enorm belangrijk was om SIMD mainstream te maken op x86-pc's.

Veel vorderingen en innovaties in de computertechnologie gaan vooraf aan het tijdperk van de pc. SIMD is daar geen uitzondering voor en gaat decennia vooraf aan de MMX van Intel. Het algemene idee om één instructie te gebruiken die op meerdere data-elementen opereert, gaat veel verder terug dan de Pentium.

Een beroemd vroeg voorbeeld is het ILLIAC IV-supercomputerproject uit de jaren '60. De ILLIAC IV was de eerste massaal parallelle computer. Het systeem was oorspronkelijk ontworpen met 256 64-bit floating-point units (FPU's) en vier centrale verwerkingseenheden (CPU's), in staat om 1 miljard operaties per seconde te verwerken.

Kortom, de ILLIAC IV maakte gebruik van een groot aantal verwerkingselementen die onder één instructiestroom opereerden, waardoor het een belangrijke voorouder is van moderne SIMD/vectorarchitecturen. Er waren in de jaren '60 en '80 ook array-processingmachines van bedrijven zoals CDC, Cray en anderen.

SIMD versus Array Processors

Het is belangrijk om op te merken dat vectorverwerking (Vector Processing) en SIMD niet precies hetzelfde zijn. Het onderscheid zit voornamelijk in hoe de meerdere data-elementen aan de processor worden gepresenteerd en hoe de hardware ze uitvoert.

SIMD-operaties zijn fundamenteel gebonden aan de breedte van de registers. De Cray-1 uit 1976 werd gekenmerkt als een vectorprocessor. Deze had acht vectorregisters van 64 elementen, die elk 64-bit waarden bevatten.

Net als bij SIMD vereiste de Cray-1 geen 64 afzonderlijke ADD-instructies. Waarom noemen we dit dan niet simpelweg SIMD? Vanwege een architecturaal onderscheid: SIMD opereert op registers met een vaste breedte, terwijl het vectorregister in de Cray-1 meer was als een container voor een reeks.

Vectorprocessors worden meestal gekenmerkt door de aanwezigheid van een SET VECTOR SIZE-instructie. Deze vector-functionele eenheid kan de elementen over meerdere cycli verwerken. De Cray-1 had een VL (vector length) register en instructies die die lengte konden instellen. De vector-functionele eenheden verwerkten vervolgens dat aantal elementen over meerdere cycli. Alle resterende extra elementen van de array werden simpelweg niet gebruikt.

Het VL-register van de Cray-1 was 7 bits, ook al gebruikte de architectuur alleen waarden tot 64 (2⁶), aangezien de vectorregisters van de Cray-1 tot 64 elementen bevatten. Een klein detail dat vermeldenswaardig is, is dat de Cray-1 VL=0 interpreteerde als 64, waardoor alle 64 elementen van het vectorregister werden verwerkt.

De werkelijke betekenis van MMX

Zoals je kunt zien, is SIMD niet uitgevonden door Intel. De werkelijke betekenis was het introduceren van een relatief toegankelijke SIMD-instructieset in mainstream x86-pc's. Dat was de eerste keer dat de meeste van ons (pc-enthousiastelingen) met dit type technologie aan de slag konden, en dat is waarom MMX zoveel aandacht krijgt in discussies over retro-computing. Het was niet de uitvinding van SIMD, maar wel een van de momenten waarop SIMD overging van gespecialiseerde/high-performance computing naar gewone desktop-softwareontwikkeling.

MMX-registers

Zoals eerder gezien, was een slimme ontwerpbeslissing dat Intel geen gloednieuw registerbestand toevoegde. In plaats daarvan hergebruikte MMX de floating-point stack-registers:

  • MM0
  • MM1
  • MM2
  • MM3
  • MM4
  • MM5
  • MM6
  • MM7

Elk MMX-register is 64 bits breed. Intern waren de MMX-registers aliassen van de x87 floating-point registers. Deze onderliggende floating-point registers waren 80 bits lang, maar MMX greep alleen op de onderste 64 bits.

Deze beslissing bespaarde silicium, maar creëerde ook een belangrijke beperking: SIMD- en floating-point operaties waren wederzijds uitsluitend. Omdat MMX in feite de x87 floating-point registers "overneemt", konden MMX-multimedia-operaties en floating-point-operaties niet naast elkaar bestaan.

Dit leidde tot een belangrijke regel: meng geen MMX- en x87 floating-point code zonder eerst op te ruimen.

Verpakte Integer-data (Packed Integer Data)

Een enkel MMX-register kon verschillende lay-outs bevatten. Hetzelfde register kon elke lay-out vertegenwoordigen, afhankelijk van de instructie die werd gebruikt.

Je eerste MMX-programma

Stel dat we 8 pixels bij elkaar willen optellen (ervan uitgaande dat er geen compiler-optimalisatie plaatsvindt):

for (int i = 0; i < 8; i++) {
    dest[i] = src1[i] + src2[i];
}

Een MMX-versie zou er zo uitzien:

movq  mm0, [src1]  ; laadt acht bytes
movq  mm1, [src2]  ; laadt acht bytes
paddb mm0, mm1     ; voert acht byte-optellingen gelijktijdig uit
movq  [dst], mm0   ; slaat het resultaat op

Eén instructie verving acht afzonderlijke optellingen!

Verzadigende rekenkunde met MMX (Saturating Arithmetic)

Een andere geweldige functie van MMX was het vermogen om "verzadigende" rekenkunde uit te voeren. Bij normale integer-rekenkunde vindt er een 'wrap around' plaats als een waarde te hoog wordt.

250 + 20 // = 270, wat wrapt naar 14

De bovenstaande optelling overschrijdt 255 en zal normaal gesproken terugspringen naar 14. Dit gedrag kan lastig zijn, vooral bij graphics. Als we pixelwaarden optellen, willen we dat maximale helderheid maximale helderheid blijft!

MMX biedt verzadiging voor dergelijke gevallen via de instructie: paddusb mm0, mm1

De us staat voor unsigned saturation en klemmet de maximale resultaatwaarde op 255. 250 + 20 // = 255

Hetzelfde geldt voor aftrekkingen: 10 - 40 // = 0

Dit maakte helderheidsaanpassingen en blending-operaties veel eenvoudiger.

Pixels vergelijken met MMX

Het is logisch om de kracht van MMX toe te passen op graphics. Stel dat we een masker willen maken waarbij elke waarde die groter is dan een andere waarde, wit wordt. MMX biedt vergelijkingsinstructies aan:

pcmpgtb mm0, mm1

Elke byte wordt hierdoor ofwel 0xFF of 0x00. Deze maskers zijn nuttig bij het implementeren van drempelwaardefilters (threshold filters), sprite-transparantie of collisie-logica.

Verpakte waarden verschuiven met MMX (Shifting)

Bitshifting wordt vaak gebruikt om waarden met 2 te vermenigvuldigen of te delen. Gezien hoe duur vermenigvuldigings- en (vooral) divisie-instructies waren in oudere CPU's, was het verschuiven van bits een snellere manier om waarden te berekenen met machten van 2.

MMX biedt een manier om verpakte waarden gelijktijdig te verschuiven:

  • psllw mm0, 1 ; vermenigvuldigt elke 16-bit waarde met twee
  • psrlw mm0, 1 ; deelt door twee

Ook hier worden alle verpakte waarden tegelijkertijd verschoven.

Vermenigvuldigen met MMX

Voor vermenigvuldiging met andere waarden dan 2 ondersteunt MMX verpakte vermenigvuldiging: pmullw mm0, mm1

Elk paar 16-bit integers wordt onafhankelijk vermenigvuldigd. Dit was nuttig voor:

  • Audio-volumeschaling
  • Beeldconvolutie
  • Kleurtransformaties
  • Fixed-point rekenkunde

Omdat MMX de floating-point registers van de x87 overnam, was er geen floating-point SIMD beschikbaar in MMX. MMX-programma's vertrouwden daarom zwaar op fixed-point rekenkunde. Echte floating-point SIMD voor Intel x86-CPU's verscheen pas later met SSE (Streaming SIMD Extensions) in de Pentium III-serie.

Een praktisch voorbeeld

Stel dat je een routine schrijft om een afbeelding lichter te maken. Elke pixel is één byte lang. Zonder MMX zou de code er zo uitzien:

int intensity = 20;
for (int i = 0; i < WIDTH * HEIGHT; i++) {
    pixels[i] += intensity;
    if (pixels[i] > 255) {
        pixels[i] = 255;
    }
}

Met MMX zou het verwerken van blokken pixels er zo uitzien:

section .data
; we plaatsen 8 bytes in het geheugen met de waarde 20 (0x14 in hex)
intensity: times 8 db 20 ; 14 14 14 14 14 14 14 14

section .text
; esi = pointer naar pixels
; ecx = aantal blokken van 8 pixels
loop:
    movq    mm0, [esi]          ; laad 8 pixels
    movq    mm1, [intensity]    ; laad acht 20's
    paddusb mm0, mm1            ; verzadigende optelling
    movq    [esi], mm0          ; sla 8 pixels op
    add     esi, 8              ; volgende 8 pixels
    dec     ecx
    jnz     loop
    emms                        ; verlaat MMX-status

Met MMX worden acht pixels tegelijk verwerkt. Dit was precies het soort optimalisatie waar graphics-bibliotheken van hielden.

De EMMS-instructie

Omdat MMX de floating-point registers van de oorspronkelijke x86-architectuur "leende", moet je de CPU vertellen wanneer je klaar bent met MMX.

emms

Dit wist de MMX-status en zorgt ervoor dat x87 floating-point instructies weer correct werken. Als we vergeten EMMS aan te roepen, kunnen er later in het programma vreemde floating-point bugs optreden.

Gebruikten games MMX?

Verrassend genoeg minder dan je zou denken. Naast de beperkingen van MMX, moet je onthouden dat MMX werd uitgebracht op het moment dat GPU's en 3D-hardware-acceleratiekaarten (zoals S3 ViRGE, ATI 3D Rage en 3dfx Voodoo) de pc-markt betraden. Tegen de tijd dat MMX arriveerde, begon dedicated 3D-hardware al taken over te nemen die anders baat zouden hebben gehad bij CPU-optimalisaties.

Toch, tijdens het late DOS- en vroege Windows 95-tijdperk, waren CPU's vaak de bottleneck voor games zonder hardware-acceleratie. MMX vond zijn weg naar routines zoals:

  • Software texture mapping
  • Sprite composition
  • Alpha blending
  • Image scaling
  • MP3-decodering
  • Modemsoftware
  • MPEG-videoweergave
  • Audio-mixing

Kyle Freeman, de hoofdprogrammeur achter Novalogic's Comanche 3, vermeldt vaak hoe het spel zwaar gebruikmaakte van MMX-instructies voor zowel audio als graphics. Voorbeelden van games die MMX vereisten of er baat bij hadden in hun software-renderer waren Eraser Turnabout, POD en Extreme Assault. Er zijn geruchten over een Tomb Raider-patch die MMX-verbeteringen toevoegde voor belichting en texturen, maar een betrouwbare bron hiervoor is niet bevestigd.

Hoewel Quake en Quake II vaak worden genoemd, is het belangrijk op te merken dat de beroemde software-renderer van Quake ouder is dan MMX. De originele Quake kwam uit in 1996, terwijl Intel de Pentium MMX in 1997 introduceerde. De snelheid van Quake werd dus bereikt door technieken als register-allocatie, loop-optimalisatie, lookup-tabellen en handgeschreven x86, niet door MMX.

Ontwikkelaars hielden meestal een non-MMX versie van hun code aan, omdat miljoenen gebruikers nog oudere Pentium-processoren bezaten.

MMX-ondersteuning detecteren

Software kon er niet simpelweg vanuit gaan dat MMX beschikbaar was. De gebruikelijke aanpak was het gebruik van de CPUID-instructie. CPUID is een x86-instructie die wordt gebruikt om de processor te vragen welke functies hij ondersteunt.

mov eax, 1
cpuid               ; haal informatie over de CPU op
test edx, 1 << 23   ; bit 23 van EDX geeft MMX-ondersteuning aan
jz NoMMX            ; als nul, spring naar routine voor afwezigheid van MMX

Bit 23 van het EDX-register geeft MMX-ondersteuning aan. Andere bits in EDX geven informatie over andere technologieën, zoals Bit 25 voor SSE en Bit 26 voor SSE2.

Hoe zat het met AMD's 3DNow!?

Jaren later voegde Quake II ondersteuning toe voor een technologie genaamd 3DNow!. Dit was ontworpen door AMD en was conceptueel vergelijkbaar met MMX; het voegde SIMD-instructies toe aan de basis x86-instructieset, waardoor floating-point SIMD-operaties mogelijk werden met vectorregisters.

3DNow! gebruikte ook 64-bit MMX-registers, maar voegde instructies toe die gericht waren op floating-point SIMD. De eerste processor met 3DNow!-technologie was de AMD K6-2 uit 1998.

Was 3DNow! een succes?

Nee. Hoewel een paar populaire games 3DNow!-verbeteringen toevoegden, werd het nooit zo populair als MMX. MMX had een enorme voorsprong en Intel had een enorm marktaandeel. Ontwikkelaars moesten kiezen: MMX werd ondersteund door Intel en later door AMD, Cyrix en anderen, terwijl 3DNow! een AMD-specifieke extensie was.

De genadeslag voor 3DNow! was de introductie van SSE (Streaming SIMD Extensions) door Intel in 1999.

SSE: Streaming SIMD Extensions

Intel's SSE was een keerpunt voor zowel 3DNow! als MMX. SSE introduceerde zowel scalaire als verpakte floating-point instructies, waardoor de oude MMX-beperking (waarbij floating-point code en SIMD niet konden co-existeren) verdween.

SSE vormde een grotere bedreiging voor 3DNow! omdat het hetzelfde deed (floating-point SIMD), maar met een krachtigere en uiteindelijk industrie-standaard architectuur. MMX werd niet direct overbodig, omdat het nog steeds uitstekend was in integer SIMD, vooral voor pixels, beeldbewerking en audio.

Waarom stierf MMX uit?

Wat maakte de oude MMX-registers uiteindelijk overbodig? De meeste retro-tech enthousiastelingen wijzen naar de introductie van SSE2 door Intel in 2000. SSE2 bracht precies datgene waar MMX goed in was (integer SIMD) naar de nieuwere XMM-registerarchitectuur, met veel bredere registers.

SSE2 biedt 128-bit XMM-registers en voegt uitgebreide integer SIMD-operaties toe aan de x86-architectuur. Terwijl je met MMX 8 pixels tegelijk kon verwerken, kon je met SSE2 er 16 tegelijk verwerken.

Conclusie

MMX was geen magische "turboknop" die de hele CPU sneller maakte. Het gaf de CPU simpelweg nieuwe instrumenten, maar maakte de oude instrumenten niet automatisch sneller. Dat een "Pentium with MMX" sneller aanvoelde, kwam vaak door andere verbeteringen, zoals een grotere cache (32KB tegenover 16KB bij oudere Pentiums).

Hoewel MMX naar huidige maatstaven klein lijkt (slechts acht 64-bit registers, alleen integer rekenkunde), introduceerde het ideeën die we vandaag nog steeds gebruiken:

  • Verpakte data (Packed data)
  • Gevectoriseerde rekenkunde
  • Verzadigende operaties (Saturating operations)
  • Data-parallel programmeren
  • Expliciete SIMD-optimalisaties

Later breidde SSE de registers uit naar 128 bits, en AVX verdubbelde dit opnieuw. Moderne CPU's kunnen honderden bytes per instructie verwerken via vector-extensies die allemaal hun afstamming herleiden tot die oorspronkelijke MMX-instructies. Voor veel programmeurs in de late jaren '90 was MMX de eerste kennismaking met het denken in vectoren in plaats van individuele waarden.

Was MMX een mislukking?

Ik zou MMX niet als een mislukking bestempelen. Hoewel sommigen beweren dat het minder nuttig was voor games vanwege de impact op de FPU-prestaties, was MMX een succesvolle technologische transitie, ook al was het geen langlevende instructieset. Het blijft een belangrijke mijlpaal in de evolutie van pc-programmeren en een fascinerende momentopname uit een tijdperk waarin elke CPU-cyclus telde.