RSS

Welke toewijzing is sneller?

Ik check bijna dagelijks de site Ada Planet. Dit is een verzamelplek voor allerhande posts over Ada. Ada Planet zuigt ook alle Ada-gerelateerde vragen van Stack Overflow op en een paar weken geleden kwam er een interessante vraag voorbij. Deze betrof de overhead bij het declareren en toewijzen (assignment) van variabelen.

Als je een subprogram hebt - een functie of procedure - dan kun je er enerzijds voor kiezen om een variabele in het declaratiedeel van de subprogram zelf te initialiseren:

procedure Foo (Bar : Integer) is
    Another_Bar : Integer := Bar;
begin
    ...
end Foo;

Anderzijds is het ook mogelijk om dit te doen in de lijst met statements die na de begin komt:

procedure Foo2 (Bar : Integer) is
    Another_Bar : Integer;
begin
    Another_Bar := Bar;
    ...
end Foo;

De vraagsteller wou weten of beide voorbeelden dezelfde assembly-instructies opleveren en dus gelijkwaardig zijn qua performance. Chris, de antwoorder, geeft de juiste respons: op basis van de specificatie van de taal Ada is er geen reden waarom beide voorbeelden zouden verschillen in performance. Dat voelt weliswaar intuïtief correct, maar is het niet veel eenvoudiger om gewoon naar de assembly te kijken die GNAT produceert bij het compileren?

Dit is gelukkig erg eenvoudig. Je kunt enerzijds aan GCC vragen of hij zo vriendelijk wil zijn om ook een bestand met assembly voor je te genereren (met de -S flag), maar nog makkelijker kun je gebruikmaken van de geweldige Compiler Explorer op godbolt.org. Deze site stelt je in staat om broncode in een keur aan talen in te voeren, een compiler en doelplatform te selecteren en dan laat hij je zien welke assemblycode dit oplevert.

Het eerste voorbeeld, met de toewijziging in het declaratieve deel van de procedure levert de volgende code op:

_ada_foo:
        push    rbp                       #
        mov     rbp, rsp                  #,
        mov     DWORD PTR [rbp-20], edi   # bar, bar
        mov     eax, DWORD PTR [rbp-20]   # tmp82, bar
        mov     DWORD PTR [rbp-4], eax    # another_bar, tmp82
        nop     
        nop
        pop     rbp                       #
        ret     

Hierbij heb ik de “…” moeten vervangen door de statement null;, omdat het in Ada niet is toegestaan om de lijst met statements in de body van een subprogram leeg te laten.

Het tweede voorbeeld levert in Godbolt deze assembly op:

_ada_foo:
        push    rbp                       #
        mov     rbp, rsp                  #,
        mov     DWORD PTR [rbp-20], edi   # bar, bar
        mov     eax, DWORD PTR [rbp-20]   # tmp82, bar
        mov     DWORD PTR [rbp-4], eax    # another_bar, tmp82
        nop     
        pop     rbp                       #
        ret  

Deze zijn dus vrijwel hetzelfde. Het enige verschil is dat de eerste een extra nop-instructie heeft. Deze nop staat voor “No Operation” die de processor verteld dat hij even niets moet doen. Deze extra instructie komt voort uit het feit dat we null; moeten gebruiken na de begin. Een nop instructie kost in de meeste processoren 1 klokcyclus, maar dat kan wel verschillen per familie.

De reactie van Chris op Stack Overflow is een aardig voorbeeld van waarom ik niet zo’n fan ben van die site. Er wordt in dit geval weliswaar een goed antwoord gegeven, en het is mooi om te zien hoe programmeurs zo behulpzaam zijn, maar om mijn favoriete programmeur Casey Muratori te citeren is het beter om terug te gaan naar de “first principles”. Wat voor machine-instructies worden er precies door de compiler gebakken? Gaat het hier om een taalconstruct voor het gemak van de programmeur of dient het een onderliggend doel dat zich afspeelt in de krochten van het transistorlabyrint?

In 2020 had ik na het begin van de eerste coronagolf de tijd om mijn kennis van assembly weer wat op te poetsen. De laatste keer dat ik serieus handmatig ASM had geschreven was nog in de 16-bit MS-DOS-dagen. Op de universiteit kwam het af en toe voorbij, maar als informatici hadden we de luxe om over het algemeen hoog boven zulke aardse zaken te zweven. Nu ik weer zelf assembly heb geschreven en kennis heb opgedaan over wat de 64-bits instructieset allemaal voor leuks heeft toegevoegd ben ik deze low level taal opnieuw gaan waarderen.

Het is gemakkelijk om de waanzinnige kracht van een processor uit het oog te verliezen als je werkt in een hogere programmeertaal. Wat dat betreft vind ik Ada ook wel heel aardig, want als je wil is het nog steeds mogelijk om inline assembly te gebruiken. Daarnaast laat dit voorbeeld wel zien dat het vaak vrij goed mogelijk is om van je Ada-code terug te redeneren naar de machine-instructies die dit oplevert. Als je echter serieus bezig gaat met performance en ook debuggers ten volle wil gebruiken dan is het soms nuttig om een duik te nemen in het bad met assembly-instructies en Godbolt is daarbij een fantastische duikbril.