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 :

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

  1. L’anatomie lue sur la config, avant les poids
  2. Charger 7 milliards de paramètres en 4 bits
  3. Le cache KV promis par le TV-00b, sur la vraie pile
  4. RoPE et SWA depuis l’intérieur
  5. Latence : préfill contre décodage
  6. Perplexité sur un vrai corpus
  7. Le tableau bloc A contre bloc B
  8. 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 inspect
import math
import pathlib
import time

import torch
from transformers import AutoConfig, AutoModelForCausalLM, AutoTokenizer

import transformers

MODEL_ID = "unsloth/mistral-7b-bnb-4bit"   # miroir non gate du Mistral-7B-v0.1 officiel, deja quantifie NF4
DEVICE = "cuda:0" if torch.cuda.is_available() else "cpu"
SEED = 0
torch.manual_seed(SEED)

import os

os.environ["HF_HUB_DISABLE_SYMLINKS_WARNING"] = "1"
from transformers.utils import logging as hf_logging

hf_logging.disable_progress_bar()   # tue les barres de chargement ; les chiffres utiles sont dans nos prints
print("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
device: cuda:0 | torch 2.6.0+cu124 | transformers 5.2.0

1. L’anatomie, avant les poids

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_heads
DH = getattr(cfg, "head_dim", None) or cfg.hidden_size // N_HEADS
print(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 * (2 if not c.tie_word_embeddings else 1)
    return c.num_hidden_layers * couche + embarque + d


N_PARAMS_CFG = params_depuis_config(cfg)
print(f"parametres (depuis la config) : {N_PARAMS_CFG:,}")
architecture   : MistralForCausalLM
couches        : 32 | hidden 4096 | FFN 14336
attention      : 32 tetes Q, 8 tetes KV, dh 128 -> facteur GQA 4x
fenetre SWA    : 4096 | contexte max 32,768
vocabulaire    : 32,000
parametres (depuis la config) : 7,241,732,096

1.1 Lecture

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() - t0
N_NUMEL = sum(p.numel() for p in model.parameters())
VRAM_ALLOC = torch.cuda.memory_allocated() / 2**30
print(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)."""
    return 2 * batch * n_couches * n_tetes_kv * T * dh * octets_par_element


for 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)
T=   512 : GQA      64.0 Mio | MHA hypothetique     256.0 Mio | economie 4x
T=  4096 : GQA     512.0 Mio | MHA hypothetique    2048.0 Mio | economie 4x
T= 32768 : GQA    4096.0 Mio | MHA hypothetique   16384.0 Mio | economie 4x
formule du TV-00b, rejouee sur la config reelle du modele charge
@torch.no_grad()
def pic_mib(T, use_cache):
    ids = torch.randint(10_000, 20_000, (1, T), device=DEVICE)
    torch.cuda.empty_cache()
    torch.cuda.reset_peak_memory_stats()
    model(ids, use_cache=use_cache)
    return torch.cuda.max_memory_allocated() / 2**20


PIC_AVEC = pic_mib(2048, True)
PIC_SANS = pic_mib(2048, False)
FORMULE_2048 = kv_cache_bytes(cfg.num_hidden_layers, N_KV, DH, 2048) / 2**20
print(f"pic marginal avec cache - sans cache (T=2048) : {PIC_AVEC - PIC_SANS:7.1f} Mio")
print(f"formule KV sur la config reelle (T=2048)      : {FORMULE_2048:7.1f} Mio")
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 modele
inv = 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) + 1
print(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 * 1000


PREF = {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)")
prefill T=  128 :    106.8 ms (   835 us/jeton)
prefill T= 1024 :    604.3 ms (   590 us/jeton)
prefill T= 4096 :   1893.9 ms (   462 us/jeton)
decodage (ctx court) :  52.2 ms/jeton ( 19.2 jetons/s)

5.1 Lecture

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_dataset

ds = 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, 0
    for beg in range(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] - 1
    return tot, n


T_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 ».

src_mistral = pathlib.Path(inspect.getsourcefile(type(model))).read_text(encoding="utf-8")
LOC_INDUSTRIE = len(src_mistral.splitlines())
lignes_tab = [
    ("TV-00a RoPE (commite)", "invariance d(k_m,k_n) par m-n, mesuree",
     "jouet d=64", "n.r.", "182"),
    ("TV-00b attention (commite)", "formule KV exacte ; bande O(T*W) ; facteur GQA 4.0x calcule",
     "jouet + configs publiees", "Mistral 7B a T=32768 : 16,00 -> 4,00 Gio (formule)", "383"),
    ("TV-01 SOTA (ce notebook)", "le modele REEL : config, poids, VRAM, latence, corpus",
     f"{N_PARAMS_CFG / 1e9:.2f} Md params",
     f"GQA 8/32 confirme | VRAM {VRAM_ALLOC:.1f} Gio | {MS_DEC:.1f} ms/jeton | "
     f"ppl {PPL:.2f} / {BPC:.3f} bpc", "152"),
]
print(f"{'livrable':26s} | {'echelle':22s} | {'mesure qui compte':60s} | LOC")
print("-" * 130)
for nom, _, ech, mes, loc in lignes_tab:
    print(f"{nom:26s} | {ech:22s} | {mes:60s} | {loc}")
print(f"\ncote industrie : modeling_mistral.py seul = {LOC_INDUSTRIE} lignes "
      "(hors noyaux fused, quantization, generation)")
livrable                   | echelle                | mesure qui compte                                            | LOC
----------------------------------------------------------------------------------------------------------------------------------
TV-00a RoPE (commite)      | jouet d=64             | n.r.                                                         | 182
TV-00b attention (commite) | jouet + configs publiees | Mistral 7B a T=32768 : 16,00 -> 4,00 Gio (formule)           | 383
TV-01 SOTA (ce notebook)   | 7.24 Md params         | GQA 8/32 confirme | VRAM 3.8 Gio | 52.2 ms/jeton | ppl 7.95 / 1.490 bpc | 152

cote industrie : modeling_mistral.py seul = 507 lignes (hors noyaux fused, quantization, generation)

7.1 Lecture

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 conservees
    return None  # 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 pourcentage
    return None  # 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 2
    return None  # 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.

Retour au sommet