Eén CPU-atomaire instructie, één oneindige packaging-loop: Het verhaal van de 'Lost Update' op LA664
TL;DR
Half een jaar later, in augustus, kwam Wang Miao opnieuw bij mij terug. Deze keer kozen we een andere aanpak: in plaats van dat een mens het probleem probeerde te lokaliseren, lieten we AI een minimale reproductie vinden, terwijl de mens de AI aanstuurde. Na ongeveer twee dagen hadden we een stabiele reproducer en ontdekten we de root cause: de atomaire add-instructie van de CPU faalt incidenteel in zijn atomiciteit. We hadden een nieuwe CPU-erratum gevonden. Nadat Loongson hiervan op de hoogte was gesteld, duurde het slechts twee weken voordat ze een fix vonden met nagenoeg geen prestatieverlies en ons test-firmware leverden. We hebben bevestigd dat de test-firmware het probleem oplost. Loongson gaf aan dat de firmware voor de nationale feestdag (1 oktober) wordt uitgebracht, waarna gebruikers hun firmware kunnen upgraden.
Hieronder volgt het volledige verhaal.
Oorsprong
loong13 is een door de community onderhouden port van Debian 13 stable naar LoongArch, en Wang Miao is een van de maintainers. Tijdens het build- en packagingproces werd ontdekt dat de ingebouwde test van normaliz vastliep in een loop, waardoor de packaging time-outte. In eerste instantie konden we de oorzaak niet vinden en sloegen we het pakket over. Maar omdat verschillende andere pakketten afhankelijk zijn van normaliz, konden we dit niet eeuwig doen. In februari begon het onderzoek. Aangezien we bij andere pakketten vaker verborgen race-condities of problemen met de geheugenvolgorde (memory-ordering) hadden gevonden — wat vaker voorkomt op LoongArch vanwege het zwakke geheugenmodel — vermoedden we in eerste instantie iets soortgelijks.
De eerste ronde van onderzoek
Het onderzoek begon bij de broncode van normaliz. Deze software gebruikt OpenMP om datapunten parallel te verwerken. Het problematische codefragment kan als volgt worden samengevat:
func (std::list<std::vector<int>> LatticePoints) {
size_t nr_to_match = LatticePoints.size(); // input grootte
size_t nr_points_matched = 0; // aantal reeds verwerkte punten
while (true) {
size_t nr_points_done_in_this_round = 0; // aantal punten verwerkt in deze ronde
#pragma omp parallel
{
auto P = LatticePoints.begin(); // thread-private List pointer
size_t ppos = 0; // thread-private List pointer positie
#pragma omp for
for (ppp = 0...nr_to_match){
if (skip_remaining) {
continue;
}
// Verplaats P naar de positie aangegeven door ppp
if ((*P)[0] == 0) { // betekent dat het reeds verwerkt is
continue;
}
#pragma omp atomic
nr_points_matched++;
#pragma omp atomic
nr_points_done_in_this_round++;
// verwerk het object gewezen door P
(*P)[0] = 0;
}
}
// Deze break wordt nooit uitgevoerd
if (nr_points_matched == nr_to_match)
break;
}
}
De essentie is: het programma verwerkt elk punt in de LatticePoints-lijst parallel. Sommige punten worden tijdelijk overgeslagen, waardoor er herhaalde passes nodig zijn tot alles is verwerkt. De loop stopt wanneer nrpointsmatched gelijk is aan nrtomatch. De directe oorzaak van de oneindige loop is dat nrpointsmatched nooit de doelwaarde bereikt.
Via gdb werd vastgesteld dat elk punt in de lijst wel degelijk als verwerkt was gemarkeerd, maar dat de teller nrpointsmatched simpelweg niet was meegekomen. Volgens de code zouden de toenames van nrpointsmatched en nrpointsdoneinthis_round per ronde gelijk moeten zijn, omdat ze altijd samen atomair worden verhoogd. In de praktijk was dit niet het geval: de waarden van de twee tellers verschilden licht, en dit verschil was onstabiel en varieerde per run.
Een probleem met de geheugenvolgorde (memory-ordering) werd uitgesloten, omdat de code niet vertrouwt op atomaire variabelen om andere variabelen te synchroniseren. De volgende verdenking viel op de OpenMP-implementatie: garandeerde #pragma omp atomic wel echt atomiciteit? Uit de disassembly bleek dat de compiler de amadd.d instructie van LoongArch64 gebruikte, zoals verwacht. Om dit te testen, voegden we twee extra std::atomic tellers toe als controle. Ook hier vertoonden de vier tellers willekeurige discrepanties. Dit wees erop dat de atomaire add-instructie onder bepaalde omstandigheden updates "verliest" (lost updates).
Eenvoudige testprogramma's voor atomaire optellingen konden het probleem echter niet reproduceren. Zelfs na het vereenvoudigen van de logica uit normaliz bleef het probleem onvindbaar in een minimale setup. Omdat het programma te complex was, konden we geen betrouwbaar minimaal codefragment vinden en werd het project geparkeerd.
De tweede ronde van onderzoek
Zes maanden later kwam het probleem weer op de agenda. Deze keer gebruikten we AI om te helpen. We wezen de AI op de oneindige loop in de normaliz-code en vroegen om de oorzaak te vinden. In eerste instantie zag de AI de loop, maar concludeerde niet dat de atomaire add-instructie de schuldige was. Na hints dat het probleem specifiek was voor LoongArch, en nadat we direct meldden dat we de fout hadden gelokaliseerd bij de atomaire add, richtte de AI zich op een memcpy-aanroep in de verwerkingsfunctie.
Dit was het deel dat we in de eerste ronde over het hoofd hadden gezien. De implementatie van memcpy in glibc kiest de optimale methode op basis van de beschikbare hardware. Als de hardware een vector-instructieset ondersteunt (LSX/LASX op LoongArch), gebruikt glibc vector-instructies om het kopiëren te versnellen. Precies deze gevectoriseerde geheugenkopieën triggerden de lost update op LoongArch64. Binnen twee dagen produceerde de AI een minimaal programma dat het probleem betrouwbaar reproduceerde.
De scope uitbreiden
Na de ontdekking ontstonden nieuwe vragen: waren andere atomaire instructies ook getroffen en welke geheugenoperaties triggerden het probleem?
Voor de eerste vraag onderzochten we de CAS-instructie (Compare-And-Swap), aangezien deze gebruikt kan worden om atomaire optelling te implementeren. CAS vertoonde inderdaad hetzelfde probleem. Voor andere instructies zoals atomic swap, max, min, AND en OR ontwikkelden we een verificatieschema: we registreerden het resultaat van elke operatie en controleerden dit achteraf. Verificatie toonde aan dat al deze atomaire instructies updates verloren onder dezelfde omstandigheden.
Wat betreft de tweede vraag: testen wees uit dat alleen LASX gevectoriseerde geheugenleesacties (xvld) het probleem triggerden; normale scalaire leesacties en LSX gevectoriseerde leesacties (vld) deden dit niet. Later ontdekte Rong "Mantle" Bao onafhankelijk dat normale scalaire leesacties het probleem ook konden triggeren wanneer het geheugenadres van de atomaire variabele en het leesadres een specifieke positionele relatie hadden, zij het met een lagere waarschijnlijkheid.
Specifieke conclusies
De Loongson 3C6000/S en 3A6000 maken gebruik van de LA664-core. Deze ondersteunt LoongArch64 met SIMD-extensies: de 128-bit LSX en de 256-bit LASX. De oudere LA464-core (zoals in de 3A5000) heeft dit probleem niet. De LASX-extensie bevat de instructie xvld, die 32 bytes tegelijk in een vectorregister kan lezen.
Het reproduceren van een verloren atomaire operatie vereist dat aan de volgende drie voorwaarden tegelijk wordt voldaan:
- De threads draaien op verschillende fysieke cores (niet twee SMT-logische cores op één fysieke core).
- Deze threads voeren atomaire operaties uit op hetzelfde adres zonder een data-barrier (
db). - Ten minste één van de threads intercaleert geheugenleesacties tussen de atomaire operaties.
Hierbij kan de leesactie een LASX-gevectoriseerde leesactie (xvld) zijn, of een gewone scalaire leesactie indien er een specifieke positionele relatie is tussen de adressen.
Reproductie en meting
De minimale reproducer was gebaseerd op de door AI vereenvoudigde normaliz-code. Twee threads doorlopen datapunten van 2208 bytes. Per punt voeren ze eerst een gevectoriseerde geheugenkopie uit (memcpy), gevolgd door een relaxed atomaire add op drie gedeelde tellers (amadd zonder data-barrier).
Op een 3C6000/S (twee verschillende fysieke cores, 200 rondes per trial, 30 trials in totaal) werden de volgende resultaten behaald:
| Atomaire Op | Beide doen LASX copy | Beide doen LASX read | Eén zijde LASX copy |
|---|---|---|---|
amadd.d | 67% | 100% | 53% |
amadd.w | 73% | 100% | 67% |
amcas.d | 77% | 97% | 17% |
amcas_db.d | 0% | 0% | 0% |
ammax.d | 43% | 100% | 50% |
amswap.d | 53% | 100% | 53% |
De percentages geven het aandeel mislukte trials aan van de 30 uitgevoerde tests.
Zijstap: Waarom AOSC het later niet meer kon reproduceren
Tijdens het proces gebeurde er iets interessants. In februari konden zowel AOSC OS als Debian de oneindige loop reproduceren. Maar in augustus kon AOSC het niet meer, terwijl Debian dat nog wel kon.
De reden bleek te liggen bij de memcpy-implementatie. De Core 13 release van AOSC had per ongeluk de --enable-multi-arch optie in glibc uitgeschakeld, waardoor de systeem-memcpy het LASX-acceleratiepad niet meer nam. Debian's glibc had dit wel ingeschakeld. Hierdoor werd de trigger (LASX) bij AOSC verwijderd. Zodra AOSC Core 14 uitbrengt en de optie weer inschakelt, zal het probleem terugkeren.
Impactanalyse
Van de problematische instructies is amadd de meest kritieke, omdat deze vaak wordt gebruikt voor reference counting. Als een verhoging van de referentieteller verloren gaat, wordt de teller lager dan het werkelijke aantal referenties. Dit kan ertoe leiden dat een object voortijdig wordt vrijgegeven, wat resulteert in memory-safety problemen zoals use-after-free of double free.
We ontdekten dat de Rust-standaardbibliotheek de amadd instructie zonder data-barrier gebruikt voor de referentietelling van std::sync::Arc en het klonen van std::sync::mpsc’s Sender. Met een veilig Rust-programma konden we crashes veroorzaken (SIGABRT of heap corruption), wat bevestigde dat er een use-after-free optrad.
Het is echter moeilijk om dit te gebruiken voor een security-aanval. Omdat twee threads gelijktijdig relaxed atomaire operaties op hetzelfde object moeten uitvoeren en er een gevectoriseerde leesactie moet plaatsvinden, moeten de aanvaller en het slachtoffer zich in hetzelfde proces bevinden.
Workarounds
Applicatie-ontwikkelaars hebben geen directe controle over welke atomaire instructies de compiler genereert. Workarounds op softwareniveau moeten via de compiler verlopen, bijvoorbeeld door:
- Geen atomaire instructies zonder data-barrier (
am<OP>.) meer te genereren, maar instructies mét data-barrier (am<OP>_db.). - Atomaire operaties te implementeren via LL/SC-loops.
Voor bestaande binaries is echter een volledige hercompilatie nodig, wat de kosten voor een softwarematige oplossing hoog maakt.
De Fix
Na melding aan Loongson kwam de oplossing snel. Op 26 augustus 2026 werd het probleem gemeld, en op 9 september ontvingen we test-firmware. Zowel de 3A6000 als de 3C6000/S functioneerden daarna weer normaal.
De fix bestaat uit het instellen van bit 13 van MCSR24 op 1. MCSR24 is een intern CSR waarvan de functie niet in de handleiding staat. Na het instellen van deze bit treden de lost updates niet meer op. Het prestatieverlies is minimaal: single-core prestaties blijven gelijk en multi-core prestaties dalen slechts licht.
Gebruikers die niet direct firmware kunnen updaten, kunnen deze bit direct in de Linux-kernel wegschrijven.
Conclusie
Dit verhaal begon als een softwareprobleem: een oneindige loop tijdens packaging. Uiteindelijk bleek het een hardwarefout te zijn in de CPU. Hoewel het half een jaar duurde om het probleem volledig te begrijpen, nam de effectieve voortgang slechts twee dagen in beslag, waarbij zowel AI als mensen een onmisbare rol speelden.
Dergelijke errata zijn gebruikelijk bij CPU's van alle leveranciers. Het is beter om deze specifieke oorzaken vroegtijdig te lokaliseren en op te lossen, in plaats van te wachten tot ze in complexe systemen opduiken als onstabiele foutmeldingen.
Dankwoord
Dit werk werd geïnitieerd en geleid door Wang Miao; ik was verantwoordelijk voor de reproductie en het schrijven van het rapport. Nadat we meldden dat amcas ook het probleem had, ontdekte Rong "Mantle" Bao een soortgelijk CPU-probleem, dat gelijktijdig is opgelost. Dank aan de Chip R&D Department en de Developer Community Operations Department van Loongson voor hun efficiënte en professionele samenwerking.
Groetjes,