TV-01 — La boîte ouverte côté industrie : Mistral-7B chargé en 4 bits
Série TransformerVariants, née de l’epic #16058. Le TV-00b avait calculé, sur la config publiée de Mistral 7B, que le cache KV à 32 768 jetons passe de 16,00 Gio (MHA hypothétique) à 4,00 Gio (GQA) — mais sur des formules et des jouets, jamais sur le modèle réel. Ce notebook charge le vrai modèle — 7 milliards de paramètres, 32 couches, GQA 8/32, RoPE, fenêtre glissante 4096 — quantifié en 4 bits, et vérifie chaque constante depuis l’intérieur :
l’anatomie d’abord : la config se lit en ~1 Ko, avant de toucher aux 4 Go de poids ;
le KV-cache promis : la formule du TV-00b rejouée sur la vraie pile, confrontée à un pic mémoire mesuré ;
RoPE reconstruit depuis les poids : inv_freq livre son theta sans qu’aucune config ne le porte ;
SWA à l’échelle réelle : le champ réceptif L*(W-1)+1 du TV-00b dit quand la fenêtre contraint vraiment ;
latence et perplexité : préfill/décodage mesurés, perte sur un vrai corpus (WikiText-2) en perplexité et en bits/caractère — la seule unité comparable entre tokenizers ;
le tableau bloc A contre bloc B : ce que le TV-00a et le TV-00b rendent visible, contre ce que l’industrie met derrière from_pretrained.
Matériel. GPU CUDA requis (H.2 : bloc B de l’epic, documenté ici) ; le premier run télécharge ~4 Go de poids (cache HF), les suivants partent du cache. Exécution complète en quelques minutes sur un GPU 16 Go.
Sommaire
L’anatomie lue sur la config, avant les poids
Charger 7 milliards de paramètres en 4 bits
Le cache KV promis par le TV-00b, sur la vraie pile
RoPE et SWA depuis l’intérieur
Latence : préfill contre décodage
Perplexité sur un vrai corpus
Le tableau bloc A contre bloc B
Conclusion
Exercices (cellules stubbées à compléter) : le cache sous fenêtre glissante, la perplexité par stride, et le coût réel de la quantification. Indices et étapes dans le markdown qui précède chacun.
Conventions. Bloc A (TV-00a/b) : from scratch, CPU, aucune dépendance à transformers. Ce notebook bascule volontairement côté industrie : transformers + bitsandbytes, GPU — c’est le geste du bloc B.
import inspectimport mathimport pathlibimport timeimport torchfrom transformers import AutoConfig, AutoModelForCausalLM, AutoTokenizerimport transformersMODEL_ID ="unsloth/mistral-7b-bnb-4bit"# miroir non gate du Mistral-7B-v0.1 officiel, deja quantifie NF4DEVICE ="cuda:0"if torch.cuda.is_available() else"cpu"SEED =0torch.manual_seed(SEED)import osos.environ["HF_HUB_DISABLE_SYMLINKS_WARNING"] ="1"from transformers.utils import logging as hf_logginghf_logging.disable_progress_bar() # tue les barres de chargement ; les chiffres utiles sont dans nos printsprint("device:", DEVICE, "| torch", torch.__version__,"| transformers", transformers.__version__)GPU_REQUIS ="bloc B : GPU CUDA requis (H.2, documente en tete de notebook)"assert torch.cuda.is_available(), GPU_REQUIS
AutoConfig télécharge ~1 Ko de JSON sans toucher aux 4 Go de poids. C’est toute la spécification de l’architecture — chaque champ ci-dessous renvoie à un composant codé from scratch dans le TV-00b : les têtes KV partagées (GQA), la fenêtre glissante (SWA), et RoPE dont le theta ne figure pas dans la config (valeur par défaut, à reconstruire depuis les poids au §4).
cfg = AutoConfig.from_pretrained(MODEL_ID)N_HEADS, N_KV = cfg.num_attention_heads, cfg.num_key_value_headsDH =getattr(cfg, "head_dim", None) or cfg.hidden_size // N_HEADSprint(f"architecture : {cfg.architectures[0]}")print(f"couches : {cfg.num_hidden_layers} | hidden {cfg.hidden_size} | FFN {cfg.intermediate_size}")print(f"attention : {N_HEADS} tetes Q, {N_KV} tetes KV, dh {DH} -> facteur GQA {N_HEADS // N_KV}x")print(f"fenetre SWA : {cfg.sliding_window} | contexte max {cfg.max_position_embeddings:,}")print(f"vocabulaire : {cfg.vocab_size:,}")def params_depuis_config(c): h, kv = c.num_attention_heads, c.num_key_value_heads d, f = c.hidden_size, c.intermediate_size dh =getattr(c, "head_dim", None) or d // h couche = d * h * dh +2* d * kv * dh + h * dh * d +3* d * f +2* d embarque = c.vocab_size * d * (2ifnot c.tie_word_embeddings else1)return c.num_hidden_layers * couche + embarque + dN_PARAMS_CFG = params_depuis_config(cfg)print(f"parametres (depuis la config) : {N_PARAMS_CFG:,}")
La config dit exactement ce que le TV-00b avait mis en formule sur cette même architecture publiée : 32 têtes Q qui lisent 8 têtes KV — le facteur d’économie du cache vaut 32/8 = 4x, et non les 8x de LLaMA-2 70B : ce qui se conserve d’un modèle à l’autre, c’est le nombre de têtes KV, pas le facteur. La fenêtre 4096 et le contexte 32768 sont les deux bornes à garder en tête pour le §4 : la question n’est pas seulement « quelle fenêtre », mais « à partir de quand elle contraint ». RoPE est partout — mais son theta ne se lit pas ici : la rotation est câblée dans les poids du modèle, et le §4 la reconstruit.
2. Charger 7 milliards de paramètres en 4 bits
Le miroir unsloth/mistral-7b-bnb-4bit embarque sa propre quantization_config NF4 (bitsandbytes) : from_pretrained la lit, et les 7,24 milliards de paramètres descendent en ~4 Go au lieu de ~14,4 en float16. On mesure le temps de chargement, l’empreinte VRAM réelle — et un piège de comptage : la somme des numel des tenseurs chargés ne rend pas le nombre de paramètres, parce que le stockage NF4 range deux paramètres par octet. Trois nombres, un modèle.
t0 = time.time()tok = AutoTokenizer.from_pretrained(MODEL_ID)model = AutoModelForCausalLM.from_pretrained(MODEL_ID, device_map=DEVICE)model.eval()T_LOAD = time.time() - t0N_NUMEL =sum(p.numel() for p in model.parameters())VRAM_ALLOC = torch.cuda.memory_allocated() /2**30print(f"chargement : {T_LOAD:.0f} s (premier run : telechargement ~4 Go inclus, ensuite cache)")print(f"VRAM allouee {VRAM_ALLOC:.2f} Gio | reservee {torch.cuda.memory_reserved() /2**30:.2f} Gio")print(f"parametres comptes depuis la config : {N_PARAMS_CFG:,}")print(f"numel somme sur les tenseurs charges : {N_NUMEL:,}")print(f"octets/parametre (VRAM / config) : {VRAM_ALLOC *2**30/ N_PARAMS_CFG:.2f}")print("-> NF4 stocke deux parametres par octet : le numel sous-compte d'un facteur ~2")
chargement : 5 s (premier run : telechargement ~4 Go inclus, ensuite cache)
VRAM allouee 3.84 Gio | reservee 3.85 Gio
parametres comptes depuis la config : 7,241,732,096
numel somme sur les tenseurs charges : 3,752,071,168
octets/parametre (VRAM / config) : 0.57
-> NF4 stocke deux parametres par octet : le numel sous-compte d'un facteur ~2
2.1 Lecture
Trois nombres décrivent le même modèle. La config compte ~7,24 milliards de paramètres (la formule du §1 : projections, MLP, embeddings, tête non liée). La somme des numel rend moitié moins : bitsandbytes range deux paramètres par octet uint8 — même le comptage est une décision d’implémentation. La VRAM enfin, ~0,57 octet par paramètre, tient la promesse NF4 (0,50) augmentée de la part des tampons restés en float16 (embeddings, tête, normes) et de la double quantification. En float16 pur, les poids seuls dépasseraient 14 Gio avant le premier jeton : le 4-bit est ce qui fait tenir un 7B sur un GPU 16 Go. La leçon du TV-00b se rejoue à une autre échelle — chaque facteur de réduction achète un palier d’usage.
3. Le cache KV promis par le TV-00b, sur la vraie pile
La formule du TV-00b — vérifiée exacte contre une allocation réelle sur un jouet — est rejouée ici sur la vraie config : 32 couches, 8 têtes KV, dh = 128, float16. Puis un second témoin la confronte au modèle lui-même : le pic mémoire marginal d’un forward à contexte 2048 avec cache contre le même sans cache doit rendre l’ordre de grandeur du cache (l’implémentation SDPA ne matérialise pas les matrices d’attention, le logits se retrouve des deux côtés et s’annule dans la différence).
def kv_cache_bytes(n_couches, n_tetes_kv, dh, T, batch=1, octets_par_element=2):"""Octets du cache KV : 2 tenseurs (K, V) x B x G x T x dh (formule verifiee exacte au TV-00b)."""return2* batch * n_couches * n_tetes_kv * T * dh * octets_par_elementfor T in (512, 4096, 32768): gqa = kv_cache_bytes(cfg.num_hidden_layers, N_KV, DH, T) mha = kv_cache_bytes(cfg.num_hidden_layers, N_HEADS, DH, T)print(f"T={T:6d} : GQA {gqa /2**20:9.1f} Mio | MHA hypothetique {mha /2**20:9.1f} Mio | economie {mha / gqa:.0f}x")NOTE_CACHE ="formule du TV-00b, rejouee sur la config reelle du modele charge"print(NOTE_CACHE)
pic marginal avec cache - sans cache (T=2048) : 248.0 Mio
formule KV sur la config reelle (T=2048) : 256.0 Mio
3.1 Lecture
À 32 768 jetons, la formule donne 4,00 Gio pour 8 têtes KV contre 16,00 pour 32 : c’est le tableau du TV-00b, désormais adossé au modèle qui l’exécute réellement. Le témoin par différence de pics n’a pas la précision d’une allocation nue (fragmentation, tampons transitoires), mais il fixe l’ordre de grandeur : le cache croît bien en T, au rythme que la formule prédit. C’est le seul endroit du notebook où l’industrie reste mesurable à la main — après, le cache est fused dans les noyaux et ce que use_cache=True rend visible n’est plus qu’un proxy.
4. RoPE et SWA depuis l’intérieur
Deux constantes ne se lisent pas dans la config : le theta de RoPE (valeur par défaut, câblée dans les poids) et le comportement effectif de la fenêtre. La première se reconstruit : inv_freq[i] = theta^(-2i/d) — le dernier élément du tenseur inv_freq de la première couche livre donc theta = inv[-1]^(-d/(d-2)). La seconde se calcule : le champ réceptif d’une pile de L fenêtres de taille W couvre L*(W-1)+1 positions (la formule mesurée au TV-00b, §champ réceptif).
rot = model.model.rotary_emb # transformers 5.x : la rotation est partagee au niveau du modeleinv = rot.inv_freq.detach().float().cpu()DEMI = inv.shape[0]THETA = (1.0/ inv[-1].item()) ** (2* DEMI / (2* DEMI -2))print(f"inv_freq : {DEMI} paires | premier {inv[0]:.6f} | dernier {inv[-1]:.3e}")print(f"theta reconstruit depuis les poids : {THETA:,.0f} (defaut Mistral : 10 000)")print(f"ecart relatif a 10 000 : {abs(THETA -10_000) /10_000*100:.2f} % (arrondi du stockage float32)")CHAMP = cfg.num_hidden_layers * (cfg.sliding_window -1) +1print(f"champ receptif : {cfg.num_hidden_layers} x ({cfg.sliding_window} - 1) + 1 = {CHAMP:,} jetons")print(f"contexte max {cfg.max_position_embeddings:,} < champ receptif {CHAMP:,}")print("-> sur la plage d'usage du v0.1, la fenetre borne le cout, elle ne restreint pas encore l'attention")
inv_freq : 64 paires | premier 1.000000 | dernier 1.155e-04
theta reconstruit depuis les poids : 10,000 (defaut Mistral : 10 000)
ecart relatif a 10 000 : 0.00 % (arrondi du stockage float32)
champ receptif : 32 x (4096 - 1) + 1 = 131,041 jetons
contexte max 32,768 < champ receptif 131,041
-> sur la plage d'usage du v0.1, la fenetre borne le cout, elle ne restreint pas encore l'attention
4.1 Lecture
Le theta reconstruit — 10 000 — n’était écrit nulle part : il était dans les poids. C’est la différence de nature entre bloc A et bloc B : le TV-00a écrivait la rotation et mesurait l’invariance d(k_m, k_n) ne dépendant que de m-n ; ici la rotation est déjà là et on la relit. Pour SWA, la formule du TV-00b donne le contrepoint d’échelle : avec 32 couches et une fenêtre 4096, le champ réceptif (131 041 jetons) dépasse le contexte max (32 768) — sur la plage d’usage du v0.1, chaque jeton voit en pratique tout son passé, et la fenêtre joue son rôle de plafond de coût (l’attention reste O(T*W), jamais O(T^2) au-delà) plutôt que de restriction. Le jouet du TV-00b montrait la mécanique ; le modèle réel montre quand elle mord.
5. Latence : préfill contre décodage
Deux régimes, deux goulets. Le préfill traite T jetons en parallèle : borné par le calcul, il doit croître à peu près linéairement avec T. Le décodage émet un jeton à la fois : borné par la bande passante mémoire (relire ~4 Gio de poids à chaque pas), il doit rester quasi constant — c’est là que le KV-cache et sa croissance en T paient leur facture, noyée dans la relecture des poids tant que le contexte reste petit devant 4 Gio.
@torch.no_grad()def prefill_ms(T): ids = torch.randint(10_000, 20_000, (1, T), device=DEVICE) model(ids) torch.cuda.synchronize() t0 = time.time() model(ids) torch.cuda.synchronize()return (time.time() - t0) *1000@torch.no_grad()def decode_ms(n_new=32): enc = tok("mesure de latence de decodage", return_tensors="pt").to(DEVICE) model.generate(**enc, max_new_tokens=4, do_sample=False, use_cache=True) torch.cuda.synchronize() t0 = time.time() model.generate(**enc, max_new_tokens=n_new, do_sample=False, use_cache=True) torch.cuda.synchronize()return (time.time() - t0) / n_new *1000PREF = {T: prefill_ms(T) for T in (128, 1024, 4096)}MS_DEC = decode_ms()for T, ms in PREF.items():print(f"prefill T={T:5d} : {ms:8.1f} ms ({ms / T *1000:6.0f} us/jeton)")print(f"decodage (ctx court) : {MS_DEC:5.1f} ms/jeton ({1000/ MS_DEC:5.1f} jetons/s)")
Le rapport entre les deux régimes est le vrai résultat : le préfill traite un jeton pour une fraction de milliseconde (~0,5–0,9 ms selon la longueur, calcul massivement parallèle), le décodage en dépense des dizaines de millisecondes — deux ordres de grandeur d’écart — parce qu’il relit tous les poids pour un seul jeton. Toute l’économie du TV-00b (partager les têtes KV, fenêtrer l’attention) et du 4-bit (§2) attaque ce second régime : moins d’octets à relire par pas. À contexte court, le cache ne pèse presque rien devant les poids ; c’est en contexte long qu’il devient le terme dominant — la formule du §3 donne le point de bascule pour un exercice.
Lecture chiffree — l’amortissement du prefill et l’ecart exact au decodage. Trois lignes suffisent : T=128 -> 835 us/jeton, T=1024 -> 590, T=4096 -> 462 — le cout par jeton du prefill diminue de 45 % quand la fenetre est multipliee par 32 : une part fixe (lancement des noyaux, preparation des tenseurs) se dilue dans le lot, et la courbe s’aplatit vers le cout marginal du calcul lui-meme. Le decodage, lui, paie 52,2 ms par jeton a contexte court — le rapport exact vaut x62 contre le prefill a T=1024 (52,2 ms contre 0,59 ms) et x113 a T=4096 : « deux ordres de grandeur » se verifie chiffre, entre 1,8 et 2,05 decades. Et pour le cache du §3, le temoin par difference rend 248,0 Mio mesures contre 256,0 predits a T=2048 (−3,1 %) : l’ordre de grandeur tient, l’ecart signe les tampons transitoires que la formule ne compte pas.
6. Perplexité sur un vrai corpus
Bloc A = jouets, pas de corpus : la perplexité du TV-01 ne se compare pas en interne, elle fonde la référence de la série. Deux nombres, deux rôles : la ppl (par tokenizer, donc non comparable entre modèles) et les bits/caractère (comparables entre tokenizers, y compris avec le TV-00a si un jour il mesure un corpus). Corpus : WikiText-2 validation, 100 lignes non vides à partir d’un offset fixe — borné, déterministe, ≈ 33 000 caractères / 8 500 jetons.
from datasets import load_datasetds = load_dataset("Salesforce/wikitext", "wikitext-2-raw-v1", split="validation")lignes = [l for l in ds["text"] if l.strip()][240:340]CORPUS ="".join(lignes)ids = tok(CORPUS, return_tensors="pt").input_ids[0]print(f"corpus : {len(CORPUS):,} caracteres | {ids.shape[0]:,} jetons")@torch.no_grad()def nll_fenetre(maxlen=1024, stride=512): tot, n =0.0, 0for beg inrange(0, ids.shape[0] -1, stride): fin =min(beg + maxlen, ids.shape[0] -1) x = ids[beg:fin].unsqueeze(0).to(DEVICE)if x.shape[1] <2:break logits = model(x).logits s = torch.nn.functional.cross_entropy(logits[0, :-1].float(), x[0, 1:], reduction="sum") tot += s.item() n += x.shape[1] -1return tot, nT_NLL, N_TOK = nll_fenetre()PPL = math.exp(T_NLL / N_TOK)BPC = (T_NLL / math.log(2)) /len(CORPUS)print(f"perte {T_NLL / N_TOK:.3f} nat/jeton | perplexite {PPL:.2f}")print(f"{BPC:.3f} bits/caractere (l'unite qui survit au changement de tokenizer)")
corpus : 33,107 caracteres | 8,513 jetons
perte 2.073 nat/jeton | perplexite 7.95
1.490 bits/caractere (l'unite qui survit au changement de tokenizer)
6.1 Lecture
Mesurée : une perplexité autour de 8, là où un 7B en float16 sur WikiText-2 complet se situe plutôt entre 3 et 5. Le prix est conjoint : quantification NF4 (déquantification à la volée à chaque couche), corpus minuscule où chaque ligne de titre ou d’en-tête coûte cher, et fenêtres glissantes qui pénalisent les premiers jetons de chaque fenêtre. On ne revendique pas une mesure de laboratoire : le corpus est borné pour tenir dans un notebook. Ce que le chiffre autorise : un ordre de grandeur vérifiable, et une base de comparaison — 1,49 bits/caractère — pour tout futur livrable de la série qui mesurera le même corpus avec le même protocole, en bits/caractère précisément parce que la ppl ne survit pas au changement de tokenizer.
7. Le tableau bloc A contre bloc B
L’item 7 de l’epic demande la comparaison explicite. Les lignes A citent les nombres committés des TV-00a/TV-00b (non re-mesurés ici) ; la ligne B est mesurée dans ce notebook. Les colonnes disent ce que chaque côté rend visible — c’est la question du tableau, pas « qui gagne ».
Le tableau ne départage pas : le TV-00a et le TV-00b rendent chaque constante lisible (une rotation, un masque, une formule vérifiée exacte) ; le côté industrie met la même architecture derrière modeling_mistral.py — plus les noyaux fused, la quantification et la génération, non comptés — et c’est ce qui fait tenir 7 milliards de paramètres dans un GPU 16 Go avec quelques lignes d’appel. Le from scratch achète l’intuition, le SOTA achète l’échelle : les deux notebooks A existent précisément pour que la colonne de droite ne soit pas une boîte noire.
Exercice 1 — le cache sous fenêtre glissante
Le §3 a calculé le cache complet. Mais un modèle SWA bien implémenté ne garde en mémoire que les W dernières positions : au-delà de la fenêtre, le cache cesse de croître en T.
Objectif : écrire kv_cache_fenetre qui rend les octets réellement conservés.
Étapes : (1) si T <= W, on doit retomber exactement sur kv_cache_bytes ; (2) sinon, seules les W dernières positions restent ; (3) appliquer à T = 32768, W = 4096 et comparer au cache complet du §3 — l’économie porte sur le terme de croissance, pas sur la base.
Indice : passé la fenêtre, la réponse ne dépend plus de T.
def kv_cache_fenetre(n_couches, n_tetes_kv, dh, T, W, batch=1, octets_par_element=2):# Etape 1 : T <= W -> cache complet (retomber sur kv_cache_bytes)# Etape 2 : T > W -> seulement W positions conserveesreturnNone# TODO etudiant
Exercice 2 — la perplexité par stride
La mesure du §6 découpe le corpus en fenêtres de 1024 avec un stride de 512 : les jetons de la zone de chevauchement sont prédits deux fois, une fois par fenêtre. L’estimateur dépend-il de ce choix ?
Objectif : re-mesurer la perte totale pour stride in (256, 512, 768) et rapporter l’écart relatif maximal entre les trois.
Étapes : (1) factoriser la boucle de nll_fenetre en une fonction du stride ; (2) collecter les trois pertes moyennes en nat/jeton ; (3) conclure : variation de l’ordre du dixième de pourcent (estimateur stable) ou de l’ordre du pourcent (le choix de stride se discute).
Indice : seuls les premiers jetons de chaque fenêtre sont « froids » (sans contexte gauche) ; élargir le stride augmente leur proportion.
def ecart_relatif_strides(strides=(256, 512, 768)):# Etape 1 : reutiliser la boucle de nll_fenetre parametree par le stride# Etape 2 : perte moyenne (nat/jeton) pour chaque stride# Etape 3 : retourner (max - min) / min en pourcentagereturnNone# TODO etudiant
Exercice 3 — le coût réel de la quantification
Le §2 a mesuré ~0,57 octet/paramètre, entre la cible NF4 (0,50) et le float16 (2,00). D’où vient la différence ?
Objectif : simuler le budget d’un modèle NF4 idéal : 0,5 octet/paramètre pour les poids quantifiés, plus 1/16 d’octet pour la double quantification, plus 2 octets/paramètre pour les tampons laissés en float16 (têtes, normes, embeddings selon l’implémentation).
Étapes : (1) écrire simulation_nf4(n_params, frac_fp16) rendant les octets totaux ; (2) retrouver par balayage la fraction frac_fp16 qui reproduit la mesure du §2 ; (3) comparer à 0.02 — l’ordre de grandeur attendu pour les tampons résiduels d’un 7B.
Indice : VRAM_ALLOC * 2**30 / N_PARAMS_CFG du §2 est le nombre à reproduire.
def simulation_nf4(n_params, frac_fp16):# Etape 1 : frac_fp16 des parametres restent en 16 bits, le reste en NF4 (0.5 o + 1/16 o)# Etape 2 : retourner les octets totaux# Etape 3 : balayer frac_fp16 pour retrouver le ratio mesure au paragraphe 2returnNone# TODO etudiant
8. Conclusion
Ce qu’on a mesuré, et ce que chaque chiffre autorise à conclure.
Affirmation
Preuve dans ce notebook
Verdict
La config publique annonce GQA 8/32, SWA 4096 — et le modèle chargé les porte
anatomie §1, config lue avant les poids
établi
7,24 Md paramètres tiennent en ~4–5 Gio VRAM en NF4
§2 : chargement, octets/paramètre mesurés
établi
La formule KV du TV-00b prédit le cache du vrai modèle
§3 : formule sur config réelle + pic marginal du même ordre
établi (ordre de grandeur)
Le theta de RoPE se reconstruit depuis les poids
§4 : inv[-1]^(-d/(d-2)) = 10 000
établi
À W=4096, L=32, la fenêtre ne restreint pas l’attention sous 131 041 jetons
§4 : champ réceptif vs contexte max
établi (formule du TV-00b appliquée)
Préfill et décodage vivent dans des régimes séparés
§5 : µs/jeton contre ms/jeton
établi
La ppl du modèle est « celle d’un 7B »
§6 : nombre unique, corpus minuscule
non revendiqué — base de comparaison seulement
La phrase du bloc B. Le TV-00b finissait sur le jouet ; celui-ci finit sur le modèle que le monde déploie. Entre les deux, chaque constante a changé de statut : écrite, puis vérifiée, puis reconstruite depuis l’industrie. Le prochain livrable de la série (TV-02, MoE côté SOTA) refera le voyage pour le routage d’experts.