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:

  1. Wat is PGO?
  2. Hoe PGO werkt.
  3. 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:

  1. Recht doorgaan naar de volgende instructie.
  2. 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.