Le pitch
Mon magnifique Minisforum V3 a un Ryzen 7 8840U. Dedans y’a un NPU (XDNA1, « Phoenix », ~16 TOPS INT8), une puce dédiée à l’IA, censée être plus efficace que le CPU et le GPU pour de l’inference. Et sous Linux, ce NPU bah il dort. Rien ne l’utilise
Donc l’idée naïve du départ, genre un dimanche soir : puisqu’il est là, autant m’en servir pour faire tourner un petit LLM en local, non ?
Spoiler : ça marche, c’est correct, et ça sert à rien pour du LLM.
L’état des lieux qui refroidit direct
Premier gros probleme : tout l’écosystème LLM-sur-NPU d’AMD (Lemonade, FastFlowLM, GAIA) cible XDNA2 (les puces Strix, 8 colonnes). Mon XDNA1 en a 4. Les overlays précompilés de ces trucs se chargent même pas sur ma géométrie. En sah quel plaisir.
Le seul chemin réellement ouvert sur npu1, c’est mlir-aie / IRON, le toolkit bas niveau d’AMD. Pas de stack LLM clé en main, juste les briques pour écrire tes propres kernels à la main.
Bref, soit j’abandonne, soit je construis un moteur d’inférence à la main. Devine ce que j’ai choisi.
Le setup : Ryzen 7 8840U, NPU npu1/aie2, 4 colonnes × 4 tuiles = 16 tuiles de calcul, 64 KiB de mémoire par tuile. NixOS, kernel 7.1.3, firmware NPU 1.5.5.391. Toolchain épinglé : mlir-aie v1.3.4, Peano nightly, XRT 2.26.0 compilé depuis les sources. Mémoire LPDDR5X, ~120 GB/s crête, partagée entre CPU, iGPU et NPU.
M0 / M1 — faire tourner QUOI QUE CE SOIT
Objectif minimal : compiler mon propre .xclbin et l’exécuter sur le vrai NPU, avec un GEMV bf16 (y = A·x, le cœur du décodage) correct.
Et là NixOS m’a fait payer son intégrisme FHS, avec amour. Trois murs d’affilée :
- le
clangde Peano est un ELF générique → lestub-ldde NixOS le refuse sec steam-runle fait tourner, mais alors la glibc FHS se bat avec mon Python nix (__nptl_change_stack_perm GLIBC_PRIVATE, un classique)xclbinutil(le packager de XRT) est même pas livré par les wheels
Puis les petites joies d’IRON, notées sur le vif :
from __future__ import annotationscasse@iron.jit(ça transforme les annotations en strings et la détection de rôles part en cacahuète). Une heure de ma vie.kernels.mvest int16-only. Il existe PAS de kernel matrice-vecteur bf16 en v1.3.4. Du coup pour faire mon GEMV bf16 je détournekernels.mm(matriciel) avec un RHS de largeurn=16→ 16× de MACs redondants. Retiens ce détail, il revient hanter tout le projet comme un ex.- memlock : XRT épingle la mémoire à l’ouverture (≥ 64 MB), le podman rootless est capé à 8 MB → podman rootful avec
--ulimit memlock=-1. Voilà pourquoi tu vas voir dusudotraîner.
Et une surprise bonus : « Qwen3.5 0.8B », que je pensais être un décodeur dense classique, est en fait un modèle multimodal et hybride (vision + couches SSM/linear-attention). Ça change rien pour un GEMV (un GEMV reste un GEMV).
Résultat M0/M1 : mon gemv.xclbin tourne sur le NPU, correct (erreur relative 2,5e-7, propre), à ~4,5 GB/s → ~3,5 tok/s. Très TRÈS loin des ~120 GB/s de la RAM. Carré c’est compute-bound à cause du 16×, on va optimiser. Bah gros t’es pas pret.
M2 / M3 — j’optimise, et je me mange le premier twist
M2 : multi-colonnes. Je répartis les lignes de A sur les 4 colonnes. Scaling : 2,1×, plafond ~14 GB/s. Sous-linéaire, ça sent la ressource partagée.
M3 : LE kernel mv natif. J’écris enfin un vrai matrice-vecteur bf16, plus de 16× débile, + de l’overlap DMA. J’attendais un bond vers ~40 GB/s.
Il s’est rien passé. Le mv natif donne EXACTEMENT la même bande passante que le mm, avec 16× moins de MACs.
M3.5 / M6 — c’est un mur, ou c’est juste moi qui suis nul ?
Avant d’accuser le silicium, faut éliminer l’idée que mon design est juste pourri. Je fais un microbench copy-only, zéro MAC : juste déplacer A de la DRAM au NPU. Résultat : le copy frappe le même ~13,5 GB/s. C’est pas mon kernel. Mur DMA agrégé, ~4 flux concurrents, et pouf ça plafonne.
Sauf que… (et c’est là que ça devient marrant) j’ai tiqué sur le chiffre. ~13 GB/s c’est ridiculement bas pour cette puce, et plus lent que le CPU
J’utilisais 1 cœur par colonne = 4 tuiles sur 16. 25 % de la puce. Les 12 autres tuiles se touchaient les MACs pendant tout le projet. Du coup j’ai fait M6 pour tester la pleine occupation… et le plot twist DANS le plot twist : ça change rien, mais pour une raison précise que j’avais pas vue.
Le chemin de lecture DRAM passe par les canaux MM2S des tuiles shim : 2 par colonne × 4 colonnes = 8 flux de lecture max. Point. J’ai essayé d’en câbler un 9e → le placement refuse direct (no ShimNOCTile has sufficient DMA capacity). Et le copy-only sans aucun cœur saturait déjà à ~13 GB/s à 8 canaux. Donc les cœurs de calcul sont en aval du goulot : rajouter des tuiles peut pas aider, elles se battraient pour les mêmes 8 canaux de lecture.
M4-watts — « ok la vitesse est morte, mais les WATTS ? »
L’argument marketing du NPU ç’a toujours été l’efficacité, pas la vitesse. Donc là je m’attendais à sauver le projet. Je monte un banc de conso (RAPL, hwmon iGPU, prise batterie) et je compare le même taf sur NPU vs iGPU 780M vs CPU.
Deux pièges de mesure, et c’est ÇA le vrai retour d’XP :
- Idle contaminé. Mes premières lectures : 22 W au repos. En vrai de la pollution hôte, l’idle réel c’est ~6,5 W. Poubelle.
- Comparaison pas appariée en précision. Je comparais l’iGPU en Q4 (4-bit) au NPU en bf16 (16-bit) — l’iGPU streame 4× moins d’octets. Corrigé en f16 partout.
À précision égale (16-bit) :
| moteur | tok/s | J/token |
|---|---|---|
| iGPU 780M (f16) | 67,3 | 0,157 |
| CPU (f16) | 30,8 | 0,093 |
| NPU (bf16) | 4,3 | 0,565 |
Le NPU perd ~15× en débit et ~3,6× en énergie. Et le pire : même le CPU le défonce. Pourquoi, alors qu’il tire le moins de watts absolus ? Parce que énergie = puissance × temps. Il consomme peu mais il est tellement lent que le temps par token explose. Faible puissance × temps long = beaucoup d’énergie par token. L’efficacité, ça a besoin de débit, et le NPU en a zéro à donner ici.
M5-quant — le dernier espoir, et le twist final (côté decode)
Il me restait UNE carte : la quantification. Le workload est DMA-bound, le mur est en GB/s, donc moins d’octets par poids = plus de tokens/s. Logique imparable.
Bah non. Je build le GEMV en W8A8 (int8) et je mesure :
| kernel | byte-BW | poids/s |
|---|---|---|
| bf16 | 16,76 GB/s | 8,38 G/s |
| int8 | 8,33 GB/s | 8,33 G/s |
Regarde bien : bf16 et int8 déplacent le même nombre de poids par seconde. La BW en octets fait ×2 (la taille d’élément), mais le taux de poids bouge pas d’un poil. Le mur est element-bound, pas byte-bound. Du coup W8, W4, W2… tous le même ~13 tok/s. int8 mesuré : 12,9 tok/s contre 13,0 en bf16. Gain : que dalle. (intuition tuée #2)
M7-prefill — La rédemption
A cause de mes resultat catastrophique wallah je suis effondré je fais enfin le truc que j’aurais du faire depuis de le debut : check comment ca marche sur windows. Et AMD a un article sur leur moteur d’inference et le NPU et tldr, ils ont les meme resultat que moi ducoup au lieu de faire du decode sur le NPU il font juste le promt processing/prefill dessus car askip “le NPU excelle en prefill ?”. Et ils ont raison, et ça remet tout en perspective. Parce que jusqu’ici j’ai mesuré la PIRE phase LLM pour le NPU.
- Decode (ce que j’ai tabassé pendant 6 jalons) = GEMV. Un token, ~1 MAC par poids, tu streames toute la RAM. Memory-bound. Le pire cas.
- Prefill (traiter le prompt) = GEMM. Tu bouffes les P tokens du prompt d’un coup. Chaque poids est lu une fois mais réutilisé sur P tokens. L’intensité arithmétique passe de ~1 à ~P MACs/octet → compute-bound. Et compute-bound, c’est le point de design du NPU (l’array de MAC dense).
Détail poétique : le kernels.mm que je détournais mal en decode (avec le fameux n=16), en prefill P EST la largeur du RHS. Donc pour P ≥ 16, zéro gaspillage. Le kernel « nul pour GEMV » est le bon pour GEMM. La boucle est bouclée.
Et là, enfin, l’array se réveille :
| P (longueur prompt) | util bf16 | util int8 |
|---|---|---|
| 1 (= decode) | ~3 % | ~3 % |
| 512 | 20,7 % | — |
| 2048 | — | 25,1 % |
Utilisation qui passe de 3 % à 25 % — un lift de 7–8×. Et les DEUX négatifs du decode s’inversent, pile comme prévu :
- L’occupation aide enfin : de 4 à 16 tuiles, ×5,8 à ×8,8 de débit. En decode c’était ×1 (M6), là c’est tout le jeu.
- La quant aide enfin : int8 = ~2,3× le bf16 (compute-bound → le pic MAC int8 est 2× celui du bf16).
MAIS (parce qu’il y a toujours un mais) ça plafonne à ~20–25 % du pic, pas les 30–50 % que je visais. Pourquoi ? Le design réutilise A que sur un petit bloc, puis re-streame A… et le mur de lecture shim de M6 revient (et la L1 déborde si j’élargis le bloc en bf16). Le même mur, encore, partout.
Et surtout : ça bat toujours pas l’iGPU. Sur le même modèle f16, l’iGPU fait 2893 tok/s de prefill réel ; ma meilleure projection NPU optimiste c’est 3193 en clean-GEMM, mais l’iGPU sort ses 2893 en faisant aussi l’attention, les norms, tout le reste. Clean-GEMM contre clean-GEMM, l’iGPU gagne large.
Bilan ?
Le NPU est un vrai moteur de calcul, mais sur cet APU avec cette stack ouverte, c’est un deuxième moteur derrière un iGPU plus costaud. On a testé le NPU sur du GEMV memory-bound (son pire cas) ; le prefill GEMM compute-bound (son meilleur cas) le fait briller mais il finit quand même 2e.
Sa vraie valeur, elle est là où je l’ai pas mesurée :
- petits modèles compute-bound et résidents on-chip (CNN vision/audio INT8, faible duty cycle) — qui touchent jamais mon mur de lecture ;
- capteurs always-on basse conso (les effets Windows Studio & co) ;
- la concurrence : décharger un petit prefill (ou de la perception) sur le NPU pendant que l’iGPU fait autre chose. C’est un argument d’ordonnancement, pas de vitesse. Et attention : ça marche PAS pour un utilisateur LLM solo (splitter le prefill pendant que le decode poireaute, tu gagnes rien) — ça gagne au niveau système (plusieurs charges IA en même temps) ou pipeline (prefill de la requête N+1 pendant le decode de N).
Pour du LLM sur un 8840U, la réponse c’est : l’iGPU Radeon 780M via ROCm/Vulkan. Plus rapide, plus efficace, à toute précision. Le NPU, c’est pas fait pour le chat dense, rip.