Drie identieke controleplaten liggen in één testframe, terwijl een gearchiveerde plaat met een ander patroon erbuiten ligt.

Waarom byte-exacte vergelijking een slechte manier is om een MLX-integratie te testen

Denis Ineshin · 2026-07-25

Een casus uit mlx-teacache en de testaanpak die de vergelijking op bit-niveau verving

Byte-exact vergelijken met referentiebestanden leek de veiligste manier om mlx-teacache te testen. Dat pakte anders uit. Eén dag voor de eerste release van de bibliotheek, in mei 2026, week de wrapper bij drempel nul af van de opgeslagen uitvoer van vanilla mflux op 5 van de 5 FLUX.1-dev-prompts. Op FLUX.1-schnell slaagde dezelfde test — ook 5 van de 5. En dat terwijl wrapper en vanilla binnen één Python-proces op alle 25 denoising-stappen gelijk waren.

Dat resultaat bevestigde de wiskunde van de wrapper en legde een slechte test bloot. Het opgeslagen referentiebestand had niet alleen de uitvoer vastgelegd, maar ook het script dat die uitvoer maakte. Hieronder ontleden we de fout en scheiden we vier taken: drift van een artefact opsporen, controleren of de wrapper niets verandert (no-op), numerieke equivalentie nagaan, en bewust benaderende uitvoer beoordelen. Sinds v0.1.0 heeft elke taak een eigen test. Diezelfde valkuil bedreigt elke integratie die het numerieke pad van een ander framework verandert op een lazy runtime die op de GPU dispatcht.

Bronnen en kanttekeningen

1. De check die faalde

TeaCache (arXiv:2411.19108) slaat stappen van de diffusietransformer over wanneer een gekalibreerde polynoom voorspelt dat de uitvoer van het hoofdblok nauwelijks verandert. Bij rel_l1_thresh=0 staat de drempel uit: elke stap rekent volledig door, en de wrapper hoort niets toe te voegen behalve wat administratie. De release-documentatie noemt dit de bewuste referentiemodus, volledig gelijk aan vanilla. De pre-release-test nam die als ijkpunt voor correctheid: hij genereerde de eind-latents met vanilla mflux en legde ze vast als .safetensors- referentiebestanden, waarna CI ze via mx.array_equal vergeleek met de uitvoer van de wrapper bij drempel nul.

Pogingen om die check te laten slagen brachten drie losstaande integratiebugs aan het licht. In mflux 0.17 was het veld veranderd waarmee de variant wordt bepaald. Het callback-register bood methoden waar de wrapper lijsten verwachtte. En een normalisatielaag kreeg een ongeldig keyword-argument. Alle drie zijn vóór de release verholpen, dus tijdens het debuggen was exact vergelijken nuttig.

Als vaste test viel het echter tegen. Nadat de echte bugs waren opgelost, bleef het verschil op de dev-variant bestaan. Over vijf prompts lag de cosinusgelijkenis tussen de wrapper-latent en het referentiebestand op 0.105 tot 0.256, en het grootste absolute verschil op 5.31 tot 5.82 op fp32-latents. Een drempel die ruim genoeg is om een verschil van bijna 5.8 te accepteren, houdt niets zinvols meer tegen.

De schnell-referentiebestanden kwamen intussen nog byte voor byte overeen. Juist die inconsistentie maakte de test gevaarlijker: hij kon lang genoeg slagen om mee te gaan in een release, en dan falen na een wijziging die er niets mee te maken had.

2. Het verschil lokaliseren: het referentiebestand legde het script vast

Bij het onderzoek zijn SHA-256-hashes van de eind-latents vergeleken tussen opstellingen die in precies één variabele verschilden. Dit zijn de doorslaggevende rijen die de auteur meldt, van 14 mei 2026:

Opstelling (zelfde model, prompt, seed, config) Hash van de eind-latent
Oorspronkelijk generatiescript voor de referentiebestanden (vanilla mflux, één callback na de lus) 45471c34… (het referentiebestand)
Hetzelfde script dagen later opnieuw gedraaid 45471c34… (reproduceerbaar)
Wrapper bij rel_l1_thresh=0 0db096b2…
Wrapper: geforceerde skips versus geen skips bij drempel 0 beide 0db096b2…
Vanilla mflux, zonder wrapper, maar de callback-klasse definieert ook een methode binnen de lus 0db096b2…
Vanilla versus wrapper, één proces, stap voor stap, alle 25 stappen bit voor bit gelijk

De vijfde rij telt het zwaarst. Vanilla mflux — helemaal zonder TeaCache-code op het model — leverde de bytes van de wrapper zodra de klassevorm van het geregistreerde callback-object veranderde. Daaruit blijkt dat de hash afhing van de processtructuur op Python-niveau, zelfs zonder de wiskunde van de wrapper. Welk MLX-subsysteem die structurele wijziging in andere bytes omzette, blijft daarmee open. Het vastgelegde referentiebestand had trouw één procesconfiguratie opgenomen; de gewijzigde opstellingen in de tests een andere.

Binnen één proces was het beeld anders. Mflux evalueert de latents één keer per denoising-stap (mx.eval(latents) in de generatielus, mflux 0.18.0), en de diagnose liet op elke grens een exacte overeenkomst tussen wrapper en vanilla zien. Hier hield de vergelijking binnen één proces genoeg van de omgeving onder controle om byte-gelijkheid zinvol te maken.

3. Wat MLX documenteert, en wat niet

Nergens in de MLX-documentatie staat dat MLX niet-deterministisch is, en dit artikel beweert dat ook niet. Het officiële materiaal beschrijft wel een paar relevante bouwstenen:

MLX kent geen tegenhanger van torch.use_deterministic_algorithms(). PyTorch heeft zo'n schakelaar wel, maar ook daar waarschuwt de documentatie dat reproduceerbaarheid niet gegarandeerd is tussen releases, commits en platforms, en dat gelijke seeds geen gelijk resultaat op CPU en GPU garanderen.

Dit zijn plausibele routes naar verschillen op byte-niveau, geen bewijs van de grondoorzaak van dit incident. Optellen met drijvende komma is niet associatief, dus een andere volgorde in kernel of reductie kan de laagste bits veranderen. De vorm van de graaf en de referentietellers kunnen de planning, het hergebruik van buffers en de dispatchvoorwaarden beïnvloeden, zonder de wiskunde zelf aan te raken. De experimenten in mei wezen niet aan welke route (als er al één was) de hashverschuiving veroorzaakte. Ze stelden een beperkter resultaat vast: een gewijzigde callback-klasse hing samen met andere eind-bytes, terwijl gepaarde paden binnen één proces gelijk bleven. En zodra er een numeriek verschil is, kan elke volgende denoising-stap het via een niet-lineaire transformer versterken.

Het werk van Thinking Machines over batch-invariantie documenteerde diezelfde opeenstapeling op server-GPU's: de reductiestrategie in de kernels veranderde met de batchgrootte, en ongecontroleerde batchgroottes leverden 1000 runs bij temperatuur nul 80 verschillende uitkomsten op. De MLX-casus hier is een analogie, geen bewijs van hetzelfde mechanisme: de scriptstructuur hing samen met andere bytes, en het vastgelegde referentiebestand ging ervan uit dat die afhankelijkheid stabiel bleef. Voor een correctheidstest is dat geen veilige aanname.

Deze verklaring kent twee grenzen. Ten eerste is de stabiliteit binnen één proces, waarop de vervangende test steunt, een empirische regelmaat, geen gedocumenteerde garantie. De huidige testset bevestigt haar telkens opnieuw wanneer de handmatige gelijkheidstest draait, maar MLX belooft haar nergens.

Ten tweede is nooit geïsoleerd via welk precies mechanisme de aanwezigheid van de wrapper andere bytes opleverde. De belangrijkste hypothese richtte zich op cache-tensors die de vroege wrapper bij drempel nul nog aanmaakte (cached_residual = body_out − body_in). Die tensors hielden tussenwaarden van hoofdblok en staart in leven, wat het recht op buffer donation op hun grens kon veranderen. De hypothese was concreet genoeg om te testen: er kwam een snel pad bij drempel nul dat zulke tensors helemaal niet aanmaakt. Het resultaat weerlegde donation als hoofdoorzaak — het verschil met de vastgelegde referentiebestanden werd geen greintje kleiner. Het snelle pad bleef toch, want het schrapt overbodig werk.

De huidige _kernel/gate.py sluit nog steeds kort bij rel_l1_thresh <= 0, maar de oorspronkelijke verklaring overleefde haar eigen test niet. Buffer donation blijft een reëel, gedocumenteerd dispatchmechanisme; wat sneuvelde was de specifieke bewering dat juist deze tensors hier de doorslag gaven. Daarom staat die bewering hier genoteerd als een kandidaat die zijn eerste onderscheidende experiment niet doorstond, niet als de verklaring.

4. Eén check, vier verschillende vragen

De herziening in v0.1.0 begon met het uit elkaar halen van vier vragen die eerder samenvielen in één vergelijking met een referentiebestand. Elke vraag vereist een eigen testcriterium (in testtermen: een eigen test-oracle). De wijziging staat in de publieke CHANGELOG.

One integration, four questions, four test oraclesWhat claim shouldthe test support?1. Did the committedartifact change?2. Does the no-op wrapperpreserve the underlying math?3. Is a changed executionpath numerically equivalent?4. Is the intentionallyapproximate output acceptable?Artifact-drift oracleExact SHA-256 + pinned upstream revisionFailure means: something movedPaired same-process oraclevanilla -> wrapper at threshold 0 -> restore -> vanillaExact only where measurement supports it; assert 0 skipsPer-variant numerical oracleUse byte equality where stable; otherwise use a documented gateMark it provisional when the full distribution is missingQuality + engagement oracleSSIM against same-process vanillaplus a calibrated skip-count bandConceptual test-selection map. It summarizes the shipped oracle split; it does not identify the unisolated cause of the cross-process byte difference.
Figuur 1. Conceptuele kaart van de vier testvragen en de bijpassende testcriteria (de labels in het diagram staan in het Engels). De kaart kiest een testgrens. Ze wijst de oorzaak van het byte-verschil tussen processen niet aan (die is niet geïsoleerd) en voegt geen metingen toe bovenop wat dit artikel samenvat. De bewerkbare PlantUML-bron is samen met de SVG gepubliceerd.

Is het vastgelegde artefact veranderd?

Dat is driftdetectie, geen correctheidstest. De referentiebestanden bleven, maar in een lagere rol — als vingerafdrukken: tests/test_fixtures_integrity.py controleert de SHA-256 van de bestanden op schijf, en tests/test_hf_revisions.py zet de Hugging Face-modelrevisies vast waaruit ze zijn gegenereerd. Een fout hier betekent "er is iets verschoven", nooit "de wrapper is fout".

Is de wrapper een wiskundige no-op als je hem zo instelt?

Dat is de vraag die de byte-vergelijking probeerde te stellen, en het geteste FLUX.1-pad heeft binnen één proces een exact byte-antwoord. In de release doet een gepaarde gelijkheidstest binnen één proces dat werk (tests/test_parity_flux1.py): neem vanilla op, pas de wrapper toe bij drempel nul, herstel de toestand en neem vanilla opnieuw op. De test controleert mx.array_equal(vanilla_before, wrapper), mx.array_equal(vanilla_before, vanilla_after) en nul geregistreerde skips. De controle vanilla_after vangt lekkage bij het herstellen op — een achtergebleven callback, proxy of marker.

Een variant met omgekeerde volgorde (eerst de wrapper, dan vanilla) controleert of vanilla eerst draaien het proces "opwarmt" tot een gunstige toestand. Elk gepaard geval kost ongeveer 3× één generatie. Eén prompt is het aangewezen snelle testgeval, en de run over vijf prompts draagt een "slow"-markering. Omdat de echte gewichten toegang op aanvraag en inloggegevens vereisen, draait de repository deze test handmatig als validatie op releasekwaliteit, niet bij elke pull request.

Is het gewijzigde uitvoeringspad numeriek equivalent?

Elke variant verandert het uitvoeringspad op de een of andere manier, en of byte-gelijkheid daarbij stabiel blijft is een empirische vraag — dus het antwoord verschilt per variant.

FLUX.1 zet een proxy-transformer in: die loopt de forward pass opnieuw af en plaatst de skip tussen hoofdblok en staart. Binnen één proces bleek dat opnieuw aflopen bit voor bit gelijk aan vanilla — dus voor FLUX.1 kan het criterium mx.array_equal hierboven blijven.

FLUX.2 en Z-Image vervangen de predict-functie van mflux door een eager closure, zodat de skip tussen hoofdblok en staart past. Op de meeste hardware compileert mflux de vanilla predict-functie, maar op de base- en Pro-chips van M1/M2 slaat het compilatie over. Het door de bron gerapporteerde FLUX.2-resultaat komt van een M1 Max, waar mflux niet compileert — dus het verschil "gecompileerd versus eager" kan die meting niet verklaren. Alleen het gewijzigde aflopen van de forward pass is beide hardwarepaden gemeen.

Qwen-Image gebruikt geen compilatie. Mflux roept zijn transformer twee keer per stap rechtstreeks aan, terwijl de integratie een eager proxy plaatst die QwenTransformer.__call__ in fasen opnieuw afloopt. Zijn test bij drempel nul is niet byte-exact: een aparte zelfcontrole op de eerste stap meet een cosinusgelijkenis ≥ 0.999 tussen het opnieuw aflopen en de transformer zonder wrapper.

De uiteindelijke criteria weerspiegelen dit bewijs. FLUX.2 liet per stap een verschil op ULP-niveau zien en een cosinusgelijkenis van ongeveer 0.99 over de hele generatie, dus zijn drempel is cosinus ≥ 0.97 (tests/test_parity_flux2.py). Qwen-Image en Z-Image gebruiken bij drempel nul cosinus ≥ 0.99. Hun gepubliceerde basis is een port-getrouwheidscontrole op de eerste stap ≥ 0.999, geen verdeling over de hele generatie over meerdere prompts (Qwen-Image, Z-Image).

De herstelcontrole vanilla_before / vanilla_after blijft bit voor bit gelijk over al deze varianten: beide vanilla-runs volgen hetzelfde onaangeroerde mflux-pad binnen één proces. Zo krijgt elke vergelijking het sterkste criterium dat zijn grens toelaat.

Is bewust benaderende uitvoer acceptabel?

Bij een positieve drempel hoort TeaCache de uitvoer te veranderen, dus de test controleert niet langer gelijkheid van latents, maar perceptuele kwaliteit en gedrag. Hij meet SSIM op beelden die via de VAE zijn gedecodeerd, tegen een vanilla-basislijn binnen hetzelfde proces. En hij eist 5 tot 7 skips voor de standaard-drempel-bench van FLUX.1-dev, zodat een inactieve cache de test niet kan halen door niets te doen. Die faalmodus staat beschreven in de begeleidende notitie over de drempel bij korte gedistilleerde schema's.

5. Hoe je toleranties kalibreert

Een verdedigbare tolerantie meet je, in plaats van haar na een fout naar boven bij te stellen. Dit is de werkwijze die het project op elke MLX-integratie wil toepassen:

  1. Draai de gepaarde vergelijking en leg een verdeling vast, geen "geslaagd/gefaald". Noteer max-abs, mean-abs, max-relative (met een epsilon in de noemer) en cosinusgelijkenis over meerdere prompts op de doelhardware. Voor beeldmodellen ook SSIM na het decoderen.
  2. Controleer of er ruimte tussen zit. Een tolerantie is pas een zinvolle test als de gemeten ruis en de kleinste bug die het waard is om te vangen aan weerszijden ervan liggen. Het incident in mei is het negatieve voorbeeld: om de vergelijking met het referentiebestand te halen, was een absolute tolerantie boven 5.8 nodig geweest. Zo'n tolerantie scheidt numerieke drift niet meer van veel zinvolle fouten — dus de juiste zet was de vergelijking veranderen, niet de constante.
  3. Zet de drempel net onder de gemeten ondergrens, met marge alleen voor bekende bronnen van spreiding. De cosinusdrempel van FLUX.2 is 0.97 tegen een gemeten ~0.99 over de hele generatie. En Qwen-Image en Z-Image tonen de grens van de huidige gegevens: hun drempels van 0.99 leunen op een port-getrouwheidscontrole op de eerste stap ≥ 0.999, niet op een gepubliceerde verdeling over de hele generatie over meerdere prompts. Zulke drempels zijn een nuttige regressiebescherming, maar blijven voorlopig en verdienen aanscherping of herziening zodra die verdeling wél gemeten is.
  4. Bewaar de onderbouwing naast de constante. Een commentaar bij de drempel hoort te vermelden wat gemeten is, wanneer en op welke hardware, zodat de volgende lezer "gekalibreerd" van "voorlopig" kan onderscheiden.

Het alternatief is een falende test versoepelen tot hij groen wordt. Zo boek je elk nieuw geaccepteerd verschil stilzwijgend als ruis, meestal zonder te meten of de test de fouten waarvoor hij bedoeld was nog wel vangt.

6. Hoe andere caching-projecten correctheid testen

Het project onderzocht hoe andere caching-lagen voor diffusie correctheid valideren (overzicht van mei 2026, opnieuw getoetst aan de publieke repositories bij het schrijven):

Geen van de onderzochte projecten test correctheid door byte-exact te vergelijken met vastgelegde uitvoer van echte modellen. De reproduceerbaarheidsnotities van PyTorch geven bitgelijkheid tussen releases en platforms expliciet niet als garantie. Werk aan batch-invariante inferentie behandelt bitgelijkheid als een technisch doel waarvoor je speciale kernels schrijft, niet als een eigenschap die er vanzelf is. Op MLX levert mlx-deterministic batch-invariante kernels voor RMSNorm, matmul, attention en softmax; de README meldt gemeten overhead van ongeveer 7% voor RMSNorm en 27–32% voor matmul. Zijn terrein is invariantie voor de batchgrootte bij LLM-inferentie, niet de gevoeligheid voor scriptstructuur die we hier zagen. Maar hij laat het belangrijkste zien: bit-determinisme op Metal kan, als een project ervoor wil betalen. Voor een prestatiebibliotheek als mlx-teacache is die prijs moeilijk te rechtvaardigen, alleen om één handige test te behouden.

7. Beweringen en hun status

Bewering Status Bron
Wrapper bij drempel nul versus vastgelegd referentiebestand: 5/5 fouten op dev, cosinus 0.105–0.256, max_abs 5.31–5.82; 5/5 geslaagd op schnell door de auteur gerapporteerde meting, mei 2026; ruwe logs niet openbaar methode en grenzen samengevat in secties 1 en 2
Wrapper versus vanilla in één proces, stap voor stap, 25 stappen: bit voor bit gelijk door de auteur gerapporteerde diagnose, mei 2026; ruwe logs niet openbaar methode samengevat in sectie 2
Huidige gepaarde FLUX.1-test controleert byte-exacte eind-latents en nul skips getoetst aan de publieke test op v0.9.2 tests/test_parity_flux1.py
Vanilla mflux met een gewijzigde callback-vorm levert de hash van de wrapper, niet van het referentiebestand door de auteur gerapporteerd experiment met één variabele, mei 2026; ruwe logs niet openbaar methode samengevat in sectie 2
Het snelle pad bij drempel nul verkleinde het verschil tussen processen niet door de auteur gerapporteerde meting; weerlegde de donation-hypothese als hoofdoorzaak resultaat samengevat in sectie 3; snel pad publiek in _kernel/gate.py
Gewijzigd forward-pad van FLUX.2: verschil op ULP-niveau en cosinus ongeveer 0.99 over de hele generatie bij drempel 0 op een M1 Max door de bron gerapporteerde meting; omdat mflux op deze chip compilatie overslaat, is het geen bewijs voor een oorzaak "gecompileerd versus eager" tests/test_parity_flux2.py
Drempel van Qwen-Image/Z-Image bij nul is cosinus ≥ 0.99; aparte controle van het opnieuw aflopen op de eerste stap ≥ 0.999 gecontroleerd als de gedocumenteerde publieke drempel en port-getrouwheidscontrole; geen gepubliceerde ondergrens over de volledige generatie én meerdere prompts variantpagina's
MLX documenteert lazy grafen, evaluatie vanaf de uitvoer, donation en dispatch-heuristieken, maar geen non-determinisme; geen API voor een deterministische modus gevonden getoetst aan documentatie en vastgezette broncode bij het schrijven links in sectie 3
Geen van de onderzochte caching-projecten test op vastgelegde bytes van echte modellen getoetst aan publieke repositories bij het schrijven links in sectie 6
Grondoorzaak van het byte-verschil tussen processen niet geïsoleerd; de donation-hypothese doorstond haar onderscheidende experiment niet deze notitie, sectie 3

8. Lessen

Kort samengevat: een golden file bewijst correctheid alleen als de test elke bron van byte-verandering onder controle houdt. Hier hing het bestand af van het script dat het maakte. Schnell kwam toevallig overeen en dev niet; maar zelfs een volledig groen resultaat had kunnen breken na een callback-wijziging of een MLX-update.

Aparte tests voor aparte taken. Hashes vangen drift van bestanden. Gepaarde runs testen de wiskunde van de wrapper. Volgen beide runs hetzelfde numerieke pad, vergelijk dan bytes. Verandert het pad, gebruik dan een gemeten tolerantie. Staat caching aan, kijk dan naar de beeldkwaliteit en bevestig dat er skips plaatsvonden.

Het verhaal van de donation laat bovendien zien hoe je met een onvolledige verklaring omgaat. Ze voorspelde dat het verwijderen van sommige levende referenties het byte-verschil zou verkleinen. Dat gebeurde niet. Het snelle pad bleef, omdat het overbodig werk schrapt, maar de grondoorzaak is nog steeds onbekend — en de conclusie over testen hangt niet af van het vinden ervan.

Bronnen en verwijzingen


Opgesteld op 2026-07-24. Laatst bijgewerkt op 2026-07-25. Denis Ineshin. Vertaling van het Engelse origineel.

Buy me a coffee