Dit artikel beschrijft de werking van Profile-Guided Optimization (PGO), een build-proces dat in twee fasen verloopt om de prestaties van software te verbeteren.
Fase 1: Instrumentatie De compiler voegt tellers toe aan elk basisblok en elke vertakking. Tijdens het uitvoeren van een trainingswerkload wordt vastgelegd welke paden in de code frequent worden bewandeld ('hot') en welke zelden ('cold').
Fase 2: Hercompileren en Optimaliseren De compiler gebruikt de verzamelde data om de binary fysiek te hervormen via verschillende technieken:
- Inlining: Hot call-sites worden vaker geïnlined, ongeacht statische grootte-heuristieken.
- Branch-indeling: Het 'hot path' wordt zo ingericht dat de CPU recht door kan lezen (fall-through), terwijl 'cold paths' naar aparte secties worden verplaatst om pipeline bubbles te voorkomen.
- Hot/cold functiesplitsing: Functies worden fysiek gesplitst zodat alleen de essentiële core-loop in de instructie-cache blijft.
- Binary function reordering: Functies die vaak samen worden aangeroepen, worden dicht bij elkaar in het geheugen geplaatst voor maximale i-cache en iTLB-lokaliteit.
- Indirect-call promotie: Veelvoorkomende indirecte calls (zoals via vtables) worden omgezet in snellere directe calls.
- Codedichtheid: Hot code wordt geoptimaliseerd voor snelheid, terwijl cold code wordt geoptimaliseerd voor minimale omvang.
De kern van PGO is dat het niet optimaliseert voor specifieke data, maar voor de vorm van de control-flow, waardoor de machinecode optimaal aansluit op de werking van de CPU.
PGO of geen PGO: Dat is de kwestie – Stap 2
Gezien de complexiteit van het onderwerp en de lengte van de tekst, is dit artikel onderverdeeld in drie verschillende blogposts:
- Wat is PGO?
- Hoe PGO werkt.
- Waarom mijn met sysbench getrainde build verliest en hoe je het correct aanpakt.
Hoe PGO werkt
PGO (Profile-Guided Optimization) is een build-proces dat uit twee fasen bestaat.
De eerste fase is het compileren met instrumentatie (-fprofile-generate). Hierbij krijgt elk basisblok en elke vertakking (branch) een teller. Vervolgens wordt een trainingswerkload uitgevoerd, waarna de tellers worden gedumpt in .profraw-bestanden.
De tweede fase is het opnieuw compileren. De compiler gebruikt de verzamelde tellers om beslissingen te nemen over:
- Inlining;
- De indeling van vertakkingen (het 'hot path' loopt recht door, het 'cold path' springt weg);
- Het splitsen van hot/cold functies;
- De volgorde van code voor i-cache/iTLB-lokaliteit;
- Loop unrolling;
- Indirect-call promotie.
Cruciaal is dat PGO niet simpelweg bedoeld is om "de getrainde workload snel te maken". In plaats daarvan vertelt het de compiler welke code 'hot' (veelgebruikt) is en welke 'cold' (zelden gebruikt), zodat de compiler het volledige binary-bestand dienovereenkomstig kan hervormen.
Fase 1: Wat wordt er precies vastgelegd?
De geïnstrumenteerde binary bevat tellers die tijdens het compileren zijn geïnjecteerd. Er is een teller per rand (edge) in de control-flow graph, niet alleen per functie.
Voor elke vertakking, elke loop back-edge en elke call-site is er dus een teller die wordt verhoogd telkens wanneer de executie dat pad volgt. Dit is veel fijnmaziger dan simpelweg vaststellen dat "functie X N keer is aangeroepen"; het registreert bijvoorbeeld: "wanneer we bij deze vertakking kwamen, gingen we 950.000 keer naar links en 50 keer naar rechts."
Deze granulariteit per rand stelt de tweede fase in staat om chirurgische beslissingen te nemen, in plaats van alleen te bepalen of een functie als geheel hot of cold is.
Fase 2: Wat doet de compiler met deze gegevens?
De compiler voert verschillende transformaties uit, die allemaal worden aangestuurd door dezelfde tellergegevens:
Inlining
Normaal gesproken inline't een compiler op basis van statische heuristieken:
- Functiegrootte;
- Aantal call-sites;
- Geschatte kosten/baten.
Met profielgegevens kan de compiler deze heuristieken overrulen. Een call-site die miljoenen keren wordt uitgevoerd, wordt geïnlined, zelfs als dit volgens statische regels "te duur" lijkt, omdat het runtime-voordeel duidelijk opweegt tegen de toename in codegrootte. Omgekeerd blijft een call-site die technisch inlinable is, maar zich in 'dead-cold' code bevindt, een echte call; het inlinen hiervan zou de binary onnodig opblazen zonder enig voordeel.
Branch-indeling (Branch layout)
Elke if in de code wordt gecompileerd tot een branch-instructie met twee mogelijke uitkomsten:
- Recht doorgaan naar de volgende instructie.
- Naar een andere locatie springen.
Recht doorgaan is in feite gratis; de CPU haalt instructies al in volgorde op. Springen is niet gratis: de CPU moet vooraf gokken welke kant een vertakking opgaat om instructies vooruit te kunnen blijven ophalen. Bij een verkeerde gok moet de CPU het werk dat al in de wachtrij stond weggooien en opnieuw beginnen vanaf de juiste plek. Dit is de "pipeline bubble", een kleine stagnatie.
De compiler arrangeert het hot path als de 'fall-through' (recht door) en duwt het cold path buiten de lijn. Dit wordt letterlijk verplaatst naar een aparte locatie in de binary, vaak in een .text.unlikely-sectie.
Een blok zoals if (unlikelyerrorcondition) { …zeldzame afhandeling… } bevindt zich dus niet langer inline waar het het hot path onderbreekt; het wordt volledig verplaatst, waardoor het hot path een rechte reeks instructies wordt zonder afwijkingen.
Let op: Het handmatig omwisselen van de volgorde van if/else in de broncode verandert meestal niet hoe de compiler de machinecode indelt. Optimaliserende compilers bepalen de branch-indeling zelf op basis van profielgegevens (PGO) of statische heuristieken.
Hot/cold functiesplitsing
Dit is hetzelfde concept, maar dan toegepast binnen een enkele functie. Een functie kan een 'hot core loop' hebben en een zelden geraakte 'error-handling tail'. De compiler splitst de functie fysiek in twee delen:
- Het hot-gedeelte blijft in
.text.hot.
- Het cold-gedeelte verhuist naar
.text.unlikely.
De functie werkt nog steeds identiek (een jump verbindt ze indien nodig), maar het hot-gedeelte is nu kleiner en compacter. Hierdoor past er meer in een instructie-cachelijn en verspilt cold code geen i-cache-ruimte door naast hot code te staan.
Herordening van functies in de gehele binary (Whole-binary function reordering)
Hier wordt het "hervormen van de binary" letterlijk. Tijdens het linken (vooral met LTO, wat de PGO-build van MySQL activeert), worden functies fysiek herordend in het uiteindelijke executable-bestand. Functies die elkaar vaak aanroepen, of in sequence worden uitgevoerd tijdens een hot workload, worden dicht bij elkaar in het geheugen geplaatst.
Dit maximaliseert de instructie-cache en iTLB-lokaliteit. De fetch-unit van de CPU haalt een compact cluster van hot functies op in plaats van overal in een binary van 100+MB te springen. Voor een programma van de omvang van mysqld is dit vaak de grootste winst, omdat normale builds functies plaatsen in de volgorde waarin de bronbestanden toevallig zijn gecompileerd.
Indirect-call promotie
Als profielgegevens uitwijzen dat een virtuele call of een call via een functiepointer bijna altijd naar hetzelfde doel leidt (veelvoorkomend in C++ met vtables, bijv. een storage-engine interface waar bijna alleen InnoDB is geregistreerd), voegt de compiler een beveiligde directe call in: "Als doel == dit specifieke adres, roep het direct aan en sla de indirecte jump over; anders val terug op de indirecte call."
Directe calls zijn goedkoper en voorspelbaarder voor de branch-predictor dan een indirecte jump via een tabel.
Registerallocatie en trade-offs in codedichtheid
- Hot code wordt gecompileerd met een focus op snelheid: agressievere unrolling en meer registers toegewezen aan hot-path waarden.
- Cold code (vooral bij
-fprofile-partial-training) wordt gecompileerd met een focus op grootte: minder registers en minder unrolling. De runtime-kosten zijn hier irrelevant, maar de footprint in de binary is dat niet (het neemt ruimte in op disk/page-cache en kan hot lines uit de cache verdringen). Daarom wordt het gesegregeerd naar .text.unlikely.
Switch/jump-table lowering
Een switch-statement met veel cases kan worden gecompileerd als een jump-table (snel, O(1), maar vereist een volledige tabel-load en indirecte jump) of als een cascade van vergelijkingen (trager per case, maar betere branch-predictie als één case domineert). Profielgegevens vertellen de compiler welke case in de praktijk domineert, waarna de juiste methode wordt gekozen.
Conclusie
De term "het hervormen van de gehele binary" is dus niet metaforisch. De compiler tekent de fysieke lay-out van de machinecode in het executable-bestand opnieuw: welke instructies naast elkaar staan, welke secties compact gepakt zijn (hot) versus naar de zijkant geschoven (cold), welke calls direct of indirect zijn, en waar de fetch/predictie-inspanningen van de CPU worden besteed.
Dit proces vereist niet dat de waarden die tijdens de training worden verwerkt exact lijken op het productieverkeer. Het vereist enkel dat de vorm van de control flow (welke branches, functies en paden frequent worden gebruikt) overeenkomt met het productieverkeer.
Dit is de kernreden waarom de brede, maar afwijkende datacoverage van MTR goed overdraagt: het doorloopt bijna elk codepad in mysqld, zelfs als de eigenlijke queries en data niets lijken op een TPCC-workload. De vorm van de control-flow is precies waar PGO op optimaliseert.
PGO of geen PGO: Dat is de kwestie – Stap 2
Gezien de complexiteit van het onderwerp en de lengte van de tekst, is dit artikel onderverdeeld in drie verschillende blogposts:
- Wat is PGO?
- Hoe PGO werkt.
- Waarom mijn met sysbench getrainde build verliest en hoe je het correct aanpakt.
Hoe PGO werkt
PGO (Profile-Guided Optimization) is een build-proces dat uit twee fasen bestaat.
De eerste fase is het compileren met instrumentatie (-fprofile-generate). Hierbij krijgt elk basisblok en elke vertakking (branch) een teller. Vervolgens wordt een trainingswerkload uitgevoerd, waarna de tellers worden gedumpt in .profraw-bestanden.
De tweede fase is het opnieuw compileren. De compiler gebruikt de verzamelde tellers om beslissingen te nemen over:
- Inlining;
- De indeling van vertakkingen (het 'hot path' loopt recht door, het 'cold path' springt weg);
- Het splitsen van hot/cold functies;
- De volgorde van code voor i-cache/iTLB-lokaliteit;
- Loop unrolling;
- Indirect-call promotie.
Cruciaal is dat PGO niet simpelweg bedoeld is om "de getrainde workload snel te maken". In plaats daarvan vertelt het de compiler welke code 'hot' (veelgebruikt) is en welke 'cold' (zelden gebruikt), zodat de compiler het volledige binary-bestand dienovereenkomstig kan hervormen.
Fase 1: Wat wordt er precies vastgelegd?
De geïnstrumenteerde binary bevat tellers die tijdens het compileren zijn geïnjecteerd. Er is een teller per rand (edge) in de control-flow graph, niet alleen per functie.
Voor elke vertakking, elke loop back-edge en elke call-site is er dus een teller die wordt verhoogd telkens wanneer de executie dat pad volgt. Dit is veel fijnmaziger dan simpelweg vaststellen dat "functie X N keer is aangeroepen"; het registreert bijvoorbeeld: "wanneer we bij deze vertakking kwamen, gingen we 950.000 keer naar links en 50 keer naar rechts."
Deze granulariteit per rand stelt de tweede fase in staat om chirurgische beslissingen te nemen, in plaats van alleen te bepalen of een functie als geheel hot of cold is.
Fase 2: Wat doet de compiler met deze gegevens?
De compiler voert verschillende transformaties uit, die allemaal worden aangestuurd door dezelfde tellergegevens:
Inlining
Normaal gesproken inline't een compiler op basis van statische heuristieken:
- Functiegrootte;
- Aantal call-sites;
- Geschatte kosten/baten.
Met profielgegevens kan de compiler deze heuristieken overrulen. Een call-site die miljoenen keren wordt uitgevoerd, wordt geïnlined, zelfs als dit volgens statische regels "te duur" lijkt, omdat het runtime-voordeel duidelijk opweegt tegen de toename in codegrootte. Omgekeerd blijft een call-site die technisch inlinable is, maar zich in 'dead-cold' code bevindt, een echte call; het inlinen hiervan zou de binary onnodig opblazen zonder enig voordeel.
Branch-indeling (Branch layout)
Elke if in de code wordt gecompileerd tot een branch-instructie met twee mogelijke uitkomsten:
- Recht doorgaan naar de volgende instructie.
- Naar een andere locatie springen.
Recht doorgaan is in feite gratis; de CPU haalt instructies al in volgorde op. Springen is niet gratis: de CPU moet vooraf gokken welke kant een vertakking opgaat om instructies vooruit te kunnen blijven ophalen. Bij een verkeerde gok moet de CPU het werk dat al in de wachtrij stond weggooien en opnieuw beginnen vanaf de juiste plek. Dit is de "pipeline bubble", een kleine stagnatie.
De compiler arrangeert het hot path als de 'fall-through' (recht door) en duwt het cold path buiten de lijn. Dit wordt letterlijk verplaatst naar een aparte locatie in de binary, vaak in een .text.unlikely-sectie.
Een blok zoals if (unlikelyerrorcondition) { …zeldzame afhandeling… } bevindt zich dus niet langer inline waar het het hot path onderbreekt; het wordt volledig verplaatst, waardoor het hot path een rechte reeks instructies wordt zonder afwijkingen.
Let op: Het handmatig omwisselen van de volgorde van if/else in de broncode verandert meestal niet hoe de compiler de machinecode indelt. Optimaliserende compilers bepalen de branch-indeling zelf op basis van profielgegevens (PGO) of statische heuristieken.
Hot/cold functiesplitsing
Dit is hetzelfde concept, maar dan toegepast binnen een enkele functie. Een functie kan een 'hot core loop' hebben en een zelden geraakte 'error-handling tail'. De compiler splitst de functie fysiek in twee delen:
- Het hot-gedeelte blijft in
.text.hot.
- Het cold-gedeelte verhuist naar
.text.unlikely.
De functie werkt nog steeds identiek (een jump verbindt ze indien nodig), maar het hot-gedeelte is nu kleiner en compacter. Hierdoor past er meer in een instructie-cachelijn en verspilt cold code geen i-cache-ruimte door naast hot code te staan.
Herordening van functies in de gehele binary (Whole-binary function reordering)
Hier wordt het "hervormen van de binary" letterlijk. Tijdens het linken (vooral met LTO, wat de PGO-build van MySQL activeert), worden functies fysiek herordend in het uiteindelijke executable-bestand. Functies die elkaar vaak aanroepen, of in sequence worden uitgevoerd tijdens een hot workload, worden dicht bij elkaar in het geheugen geplaatst.
Dit maximaliseert de instructie-cache en iTLB-lokaliteit. De fetch-unit van de CPU haalt een compact cluster van hot functies op in plaats van overal in een binary van 100+MB te springen. Voor een programma van de omvang van mysqld is dit vaak de grootste winst, omdat normale builds functies plaatsen in de volgorde waarin de bronbestanden toevallig zijn gecompileerd.
Indirect-call promotie
Als profielgegevens uitwijzen dat een virtuele call of een call via een functiepointer bijna altijd naar hetzelfde doel leidt (veelvoorkomend in C++ met vtables, bijv. een storage-engine interface waar bijna alleen InnoDB is geregistreerd), voegt de compiler een beveiligde directe call in: "Als doel == dit specifieke adres, roep het direct aan en sla de indirecte jump over; anders val terug op de indirecte call."
Directe calls zijn goedkoper en voorspelbaarder voor de branch-predictor dan een indirecte jump via een tabel.
Registerallocatie en trade-offs in codedichtheid
- Hot code wordt gecompileerd met een focus op snelheid: agressievere unrolling en meer registers toegewezen aan hot-path waarden.
- Cold code (vooral bij
-fprofile-partial-training) wordt gecompileerd met een focus op grootte: minder registers en minder unrolling. De runtime-kosten zijn hier irrelevant, maar de footprint in de binary is dat niet (het neemt ruimte in op disk/page-cache en kan hot lines uit de cache verdringen). Daarom wordt het gesegregeerd naar .text.unlikely.
Switch/jump-table lowering
Een switch-statement met veel cases kan worden gecompileerd als een jump-table (snel, O(1), maar vereist een volledige tabel-load en indirecte jump) of als een cascade van vergelijkingen (trager per case, maar betere branch-predictie als één case domineert). Profielgegevens vertellen de compiler welke case in de praktijk domineert, waarna de juiste methode wordt gekozen.
Conclusie
De term "het hervormen van de gehele binary" is dus niet metaforisch. De compiler tekent de fysieke lay-out van de machinecode in het executable-bestand opnieuw: welke instructies naast elkaar staan, welke secties compact gepakt zijn (hot) versus naar de zijkant geschoven (cold), welke calls direct of indirect zijn, en waar de fetch/predictie-inspanningen van de CPU worden besteed.
Dit proces vereist niet dat de waarden die tijdens de training worden verwerkt exact lijken op het productieverkeer. Het vereist enkel dat de vorm van de control flow (welke branches, functies en paden frequent worden gebruikt) overeenkomt met het productieverkeer.
Dit is de kernreden waarom de brede, maar afwijkende datacoverage van MTR goed overdraagt: het doorloopt bijna elk codepad in mysqld, zelfs als de eigenlijke queries en data niets lijken op een TPCC-workload. De vorm van de control-flow is precies waar PGO op optimaliseert.