50 GB/s terugkrijgen uit de ANE
Introductie
Een RTL-prestatiefout (erratum) in de Apple M3 Neural Engine (ANE) knijpt de doorvoersnelheid van de DRAM-gewichten af naar 17–19 GB/s, terwijl de nominale snelheid 45–60 GB/s zou moeten zijn. Dit gebeurt telkens wanneer de totale grootte van de gewichten een geheel veelvoud is van 1 MiB. Momenteel heeft dit invloed op 7 van de 15 modellen van ANEMLL.
Door het problematische pad in de speculatieve prefetch-ring van de kernel DMA-engine te vermijden, steeg de token-doorvoersnelheid van Llama 3.2 1B van 10,0 naar 24,3 tokens/s (DRAM-gebruik steeg van 24,7 naar 60,0 GB/s) en die van Qwen3-8B van 1,36 naar 2,97 tokens/s (DRAM-gebruik steeg van 22,4 naar 48,7 GB/s).
Ontdekking
Tijdens het profileren van de DRAM-doorvoersnelheid (GB/s) van de neural engine voor single token decode stuitte ik op het volgende:
\[ X[1,D] \times W[D,N] = Y[1,N] \]
Bij \(N=4096\) merkte ik op dat \(D=1536\) bijna 3× sneller draaide dan \(D=2048\), wat de standaardwaarde is in Llama 3.2.
STATIC (pure KernelDMA) mediaan µs per replica, N=4096:
| D=576 | D=768 | D=1024 | D=1280 | D=1536 | D=2048 | |
|---|---|---|---|---|---|---|
| rep a | 150.4 | 196.8 | 238.1 | 293.8 | 310.9 | 997.6 |
| rep b | 157.8 | 190.2 | 250.1 | 288.3 | 326.8 | 995.0 |
| rep c | 148.7 | 189.6 | 249.4 | 275.2 | 316.5 | 995.4 |
Bij het testen van waarden rondom \(D = 2048\) bleek dat de doorvoersnelheid bij \(D=2048\) slechts 16,93 GB/s was, terwijl deze bij \(D=2016\) 44,5 GB/s bedroeg. Dit is een daling van 27,57 GB/s (61,96% lager). Deze data is verzameld op een M3 Air over 40 runs onder identieke thermische en belastingscondities.
Ik heb bevestigd dat alleen de DMA-grootte en het adres in het ANE-registerbestand werden gewijzigd:
| Register | D=2044 | D=2048 | D=2052 |
|---|---|---|---|
| TD+0x004 estimated cycles | 0x000001ea | 0x000001eb | 0x000001ec |
| TD+0x078 core 1 base | 0x000ff800 | 0x00100000 | 0x00100800 |
| TD+0x07c core 2 base | 0x001ff000 | 0x00200000 | 0x00201000 |
| ... | ... | ... | ... |
| TD+0x0b0 core 15 base | 0x00ef8800 | 0x00f00000 | 0x00f07800 |
| TD+0x0b4–0x0f0 core sizes ×16 | 0x000ff800 | 0x00100000 | 0x00100800 |
| TD+0x134 Common.Cin | 0x000007fc | 0x00000800 | 0x00000804 |
| TD+0x1f0 L2 source stride | 0x00007fc0 | 0x00008000 | 0x00008040 |
| TD+0x1f4 unknown stride mirror | 0x00007fc0 | 0x00008000 | 0x00008040 |
| TD+0x214 L2 result base | 0x00008fc0 | 0x00009000 | 0x00009050 |
Bij een bredere scan van \(D\) werd een resonantie zichtbaar bij \(D = 2048\). Een FFT van de doorvoersnelheid (GB/s) tegen de tensordimensie (\(D\)) laat zien dat de doorvoersnelheid van de geheugencontroller een dominante harmonische heeft met een golflengte van 2048, wat resulteert in een dip.
Alle veelvouden van \(D = 2048\) zijn op dezelfde manier beperkt tot een vaste bandbreedtevloer van 17–19 GB/s. Bij deze veelvouden daalt de snelheid scherp van de nominale 45–60 GB/s en herstelt deze zich pas ongeveer 256 regels later. Dit is geen bug in de correctheid van de RTL, aangezien de kernel DMA de overdracht correct voltooit, maar de requests rond de 2048 worden gedwongen in een apart, "credit-starved" regime, wat de doorvoersnelheid onredelijk beperkt.
Hypothese 1 - Ruimtelijke DRAM-correlatie
Zouden de 16 cores op dezelfde DRAM-bank aliased worden bij macht-van-twee strides?
DRAM is een parallelle data-interface. De bandbreedte is het aantal DQ-pinnen maal de datasnelheid per pin: \[ \text{DRAM BW} = N \times R = 128\ \text{bit} \times 6.4\ \text{GT/s} = 102.4\ \text{GB/s} \] Dit komt overeen met de geadverteerde 100 GB/s van de LPDDR-6400 in de M3.
Kernconflicten (Core Contention)
De ANE heeft 16 cores die parallel werken. Als er sprake is van core-contention, zou het verminderen van het aantal actieve cores paradoxaal genoeg de doorvoersnelheid voor het beperkte \(D=2048\) geval kunnen verhogen. Bij het testen van het aantal actieve cores voor zowel \(D=2016\) als \(D=2048\) bleek de latentie echter constant te blijven van 1 tot 16 cores. Dit betekent dat de beperking al op het niveau van één enkele core aanwezig is.
Adresconflicten (Address Contention)
Ik vermoedde dat een macht-van-twee stride (zoals 2048) ervoor zorgt dat de lagere bits constant blijven, wat kan leiden tot aliasing in de DRAM-banken. Om dit te testen, heb ik de adressen van waaruit de gewichten worden opgehaald willekeurig gehusseld (scrambled) over het hele IOVA-arena van ~64 MiB.
De mediane doorvoersnelheid van de baseline was 31,37 GB/s en bij de willekeurig gehusselde adressen 32,29 GB/s. Dit kleine verschil van 1 GB/s bewijst niet dat ruimtelijke correlatie het probleem is, en het kan in ieder geval niet de enorme doorvoersnelheidsdrop van ~200% verklaren.
Hypothese 2 - RTL-integer wraparound
Aangezien de dip zich herhaalt bij elk geheel veelvoud van \(D = 2048\), doet dit denken aan integer-overflows in digitale logica met een vaste breedte.
Kerndimensies
\[ X[1,D] \times W[D,N] = Y[1,N] \] Waarbij:
- \(D\) (Cin): lengte van elke kernel: \(D\) FP16-gewichten (2 bytes), oftewel \(2D\) bytes.
- \(N\) (Cout): aantal kernels. Elke core verwerkt \(N/16\) kernels.
De totale bytes per core worden berekend als: \[ \text{bytes/core} = \frac{N}{16} \times D \times 2 \]
Door \(D\) en \(N\) invers te variëren zodat de totale hoeveelheid statische kerneldata per core precies 1 MiB blijft, ontdekte ik dat elke combinatie die resulteert in 1 MiB per core de doorvoersnelheid doet kelderen naar de geobserveerde 17 GB/s. Omdat het residente "L1" KMem 64 KiB per core is, wijst dit op een speculatieve prefetch/credit die opereert op 1 MiB.
Speculatieve Prefetch
Een high-bandwidth memory controller werkt vaak met een minimale transfer-line granule in plaats van losse bytes. In de kernel DMA is dit een granule van 64 bytes.
Bij \(N=4096\) voegt elke \(D += 2048\) precies 1 MiB toe aan de totale gevraagde bytes per core: \[ 256\ \text{kernels/core} \times 4\ \text{KiB/kernel} = 1\ \text{MiB/core} \]
Een transfer van 1 MiB per core vraagt in totaal om \(0x4000\) regels van 64 bytes: \[ 1\ \text{MiB/core} \div 64\ \text{B/line} = 16.384\ \text{regels/core} = \texttt{0x4000}\ \text{regels/core} \]
Dit suggereert dat er een teller is die wraparound vertoont bij \(\texttt{0x4000}\) regels. Een wraparound bij \(2^{14}\) vereist 14 bits opslag. Opvallend is dat de virtuele geheugenpagina's van Apple Silicon ook 16 KiB (\(2^{14}\) bytes) groot zijn.
Als we de afstand tot de "notch" (de dip) definiëren als \(x\), herstelt de bandbreedte zich exact bij \(x = \pm 256\) regels. \[ 64\ \text{B/line} \times 256\ \text{regels} = 16\ \text{KiB} = \text{één pagina} \]
De dip bevindt zich dus precies binnen één VM-pagina aan DMA-regels. Dit wijst op een lookahead-prefetch-window met de grootte van één pagina.
Waarschijnlijke RTL-bug
Het meest waarschijnlijk is dat een speculatieve prefetch-ring in de kernel DMA 14-bit head/tail-adresberekeningen gebruikt, maar een wrap/epoch-bit mist. Hierdoor wordt een transfer die een exact veelvoud is van \(2^{14} = \texttt{0x4000}\) door de logica geïnterpreteerd als "leeg" in plaats van "één volledige ronde resterend". Dit uithongert de prefetch-pipeline.
De fetch vindt nog steeds plaats (het is dus geen correctheidsbug), maar de overdracht wordt omgezet van een bandbreedte-gelimiteerde streaming-transfer naar een "stop-and-go" transfer via een traag, niet-speculatief pad van 17–19 GB/s.
Een mogelijke implementatie van de bug in Verilog/SystemVerilog:
localparam int RING_LINES = 1 << 14; // 0x4000 lines
localparam int PREFETCH_MAX = 256; // 256 lines
logic [31:0] transfer_lines; // full DMA length
logic [13:0] rd_ptr, [13:0] end_ptr, [13:0] distance; // 14-bit prefetch ring
logic [8:0] prefetch_credit; // 0..256 lines
// BUG: Alleen de lage 14 bits gaan de prefetch ring in.
assign rd_ptr = start_line[13:0];
assign end_ptr = (start_line + transfer_lines)[13:0];
// Afstand in de 14-bit ring.
assign distance = end_ptr - rd_ptr;
// Prefetch maximaal 256 regels = 16 KiB vooruit.
assign prefetch_credit = (distance > PREFETCH_MAX) ? PREFETCH_MAX : distance;
Zonder epoch-bit kan de prefetcher geen lookahead uitvoeren voor de volledige \(\texttt{0x4000}\)-regels transfer. De recovery binnen 256 regels (één pagina) komt overeen met de PREFETCH_MAX clamp.
De fix op RTL-niveau zou zijn: distance = transferlines - issuedlines; (berekenen met 32-bit in plaats van 14-bit modulo).
Softwarematige oplossing
De eenvoudigste workaround is om geen kernel-transfers van precies 1 MiB aan te vragen. Een compiler kan elke taak die precies 1 MiB per core compileert, opsplitsen in kleinere chunks, bijvoorbeeld twee transfers van 512 KiB. De extra latentie van het versturen van meerdere taken is verwaarloosbaar vergeleken met de enorme prestatievermindering van de bug.
Het opsplitsen van de 1 MiB/core-transfer herstelt de normale bandbreedte:
- Eén taak van 0x4000 regels: 17,25 GB/s.
- Twee taken van 0x2000 regels: 45,52 GB/s (2,66× sneller).
- Vier taken van 0x1000 regels: 44,83 GB/s (2,60× sneller).
Resultaten
DRAM-doorvoersnelheid
De originele doorvoersnelheid blijft vastzitten op 17–19 GB/s voor alle veelvouden van 1 MiB. De versie met 512k-splitsingen stijgt naar de nominale ~60 GB/s.
| Transfergrootte | Origineel (niet gesplitst) | Opgelost (in chunks) | Versnelling |
|---|---|---|---|
| 2048 (1 MiB) | 17,3 GB/s | 43,5 GB/s | 2,51× |
| 4096 (2 MiB) | 18,4 GB/s | 52,1 GB/s | 2,84× |
| 8192 (4 MiB) | 18,8 GB/s | 57,8 GB/s | 3,07× |
| 12288 (6 MiB) | 19,0 GB/s | 59,8 GB/s | 3,15× |
| 16384 (8 MiB) | 19,1 GB/s | 60,5 GB/s | 3,16× |
LLM-prestaties
Modellen in anemll die beïnvloed werden en de toegepaste splitsing:
| Model | Projectie | Cin × Cout | k (MiB/lane) | Split naar (MiB/lane) |
|---|---|---|---|---|
| Llama 3.2 1B | gate/up/down | $2048 \times 8192$ | 2 | 0.5 |
| Llama 3.1 8B / DeepSeek | q, o | $4096^2$ | 2 | 0.5 |
| Llama 3.1 8B / DeepSeek | gate/up/down | $4096 \times 14336$ | 7 | 3.5 |
| DeepHermes 3B | gate/up/down | $3072 \times 8192$ | 3 | 1.5 |
| Qwen3-8B | q, o | $4096^2$ | 2 | 0.5 |
| Qwen3-8B | gate/up/down | $4096 \times 12288$ | 6 | 1.5 |
| Gemma 3 4B | lm_head shard | $2560 \times 16384$ | 5 | 2.5 |
Concrete resultaten:
- Llama 3.2 1B: 10 tok/s → 24 tok/s.
- Qwen3-8B: 1,36 tok/s → 2,97 tok/s.
Groetjes,