3.11 — Le budget mémoire d’un entraînement : quatre postes, calculés puis mesurés

Navigation : Feuille de route de la série · 3.12 — Les collectives >>

Un modèle tient-il dans une carte ? La question se tranche avant de lancer l’entraînement, pas après le CUDA out of memory. Ce carnet construit le budget à la main, puis le confronte à la mesure sur un modèle réel — et corrige le modèle à chaque fois que la mesure le contredit.

Ce que ce carnet établit

Poste Ce qui le porte Ce qui le détermine
P — poids les paramètres du modèle \(4\) octets par paramètre en FP32
G — gradients un tenseur de même forme que chaque poids \(4\) octets par paramètre
O — états de l’optimiseur AdamW garde deux moments par paramètre \(8\) octets par paramètre
A — activations ce que la rétropropagation doit garder en vie le lot, la longueur, la largeur — et les poids eux-mêmes

Les trois premiers sont déterministes : \(16\) octets par paramètre pour un AdamW en FP32, quelle que soit la forme du réseau. Le quatrième dépend de la forme des données, et c’est lui qui décide de la taille de lot.

Comment le carnet procède

Trois choses qu’il ne fait pas, et qui sont le sujet :

  1. Il ne recopie aucun coefficient d’un manuel : le coefficient d’activation est mesuré ici, puis confronté à la littérature.
  2. Il ne suppose aucune formule juste parce qu’elle est plausible : la première version de la formule des paramètres est confrontée au modèle réel, et corrigée parce qu’elle était fausse.
  3. Il ne présente pas les activations comme proportionnelles au lot : la mesure montre une loi affine, avec un terme constant qui n’est pas un détail.

Instruments

  • torch.autograd.graph.saved_tensors_hooks — compte exactement les tenseurs que la rétropropagation retient. C’est la définition opérationnelle des activations, et l’instrument fonctionne sur CPU comme sur GPU.
  • torch.cuda.memory_allocated — ce que l’allocateur garde vivant, quand une carte est présente. Le carnet s’exécute de bout en bout sans carte : cette section se déclare alors absente au lieu de fabriquer un chiffre.
import gc
import math
import torch
import torch.nn as nn
import torch.nn.functional as F

torch.manual_seed(0)
DEV = "cuda" if torch.cuda.is_available() else "cpu"
print(f"torch {torch.__version__} | appareil de mesure : {DEV}")
if DEV == "cuda":
    p = torch.cuda.get_device_properties(0)
    print(f"  carte : {p.name} | {p.total_memory/2**30:.1f} GiB")
torch 2.8.0+cu126 | appareil de mesure : cuda
  carte : NVIDIA GeForce RTX 3090 | 24.0 GiB

1. Le modèle d’architecture, écrit une fois

Les quatre postes se calculent à partir d’une seule description du réseau. Le carnet prend un transformeur décodeur de type GPT, décrit par quatre nombres : la largeur \(d\), le nombre de couches \(L\), la taille du vocabulaire \(V\) et la longueur de contexte \(s\).

Par couche, l’attention coûte \(4d^2\) paramètres (les quatre projections \(Q,K,V,O\)) et le bloc avant coûte \(8d^2\) (deux projections vers \(4d\)). Le plongement de jetons vaut \(Vd\), et la tête de sortie n’ajoute rien quand elle est liée au plongement — la convention de GPT-2, que ce carnet suit.

Une première version de ce carnet s’arrêtait là :

\[\text{P} \stackrel{?}{=} 12\,L\,d^2 + V\,d\]

Elle est fausse, et la cellule suivante le montre. Les termes oubliés sont le plongement positionnel (\(sd\)) et les paramètres de normalisation (\(2d\) par LayerNorm, deux par bloc, plus la normale finale).

def params_incomplet(d, L, V):
    '''Premiere version, volontairement incomplete : couches + plongement de jetons.'''
    return 12 * L * d * d + V * d


def params_complet(d, L, V, s):
    '''Version corrigee : + plongement positionnel (s*d) + LayerNorm (4L+2)*d.

    Les LayerNorm sont les seuls parametres portant un biais, et il y en a
    deux par bloc (ln1, ln2) plus une finale (ln_f) : 2*d chacun.
    '''
    return 12 * L * d * d + V * d + s * d + (4 * L + 2) * d


class Bloc(nn.Module):
    '''Bloc pre-norme : attention causale multi-tetes, puis avant 4d.'''

    def __init__(self, d, tete):
        super().__init__()
        self.ln1 = nn.LayerNorm(d)
        self.qkv = nn.Linear(d, 3 * d, bias=False)
        self.proj = nn.Linear(d, d, bias=False)
        self.ln2 = nn.LayerNorm(d)
        self.fc1 = nn.Linear(d, 4 * d, bias=False)
        self.fc2 = nn.Linear(4 * d, d, bias=False)
        self.tete = tete

    def forward(self, x):
        B, s, d = x.shape
        h = self.ln1(x)
        q, k, v = self.qkv(h).split(d, dim=2)
        q = q.view(B, s, self.tete, d // self.tete).transpose(1, 2)
        k = k.view(B, s, self.tete, d // self.tete).transpose(1, 2)
        v = v.view(B, s, self.tete, d // self.tete).transpose(1, 2)
        att = F.scaled_dot_product_attention(q, k, v, is_causal=True)
        att = att.transpose(1, 2).reshape(B, s, d)
        x = x + self.proj(att)
        return x + self.fc2(F.gelu(self.fc1(self.ln2(x))))


class MiniGPT(nn.Module):
    '''Decodeur GPT minimal : plongements lies, L blocs pre-norme, tete liee au plongement.'''

    def __init__(self, d=128, L=4, V=1000, s=64, tete=4):
        super().__init__()
        self.tok = nn.Embedding(V, d)
        self.pos = nn.Embedding(s, d)
        self.blocs = nn.ModuleList([Bloc(d, tete) for _ in range(L)])
        self.ln_f = nn.LayerNorm(d)
        self.d, self.L, self.V, self.s = d, L, V, s

    def forward(self, idx):
        B, s = idx.shape
        x = self.tok(idx) + self.pos(torch.arange(s, device=idx.device))
        for b in self.blocs:
            x = b(x)
        return self.ln_f(x) @ self.tok.weight.T      # tete liee : aucune matrice nouvelle
print(f"{'taille':16s} {'reel':>10s} {'incomplet':>10s} {'ecart':>10s} {'complet':>10s} {'ecart':>8s}")
for d, L, V in ((64, 2, 256), (128, 4, 1000), (192, 6, 4096)):
    m = MiniGPT(d=d, L=L, V=V)
    s = m.s
    reel = sum(p.numel() for p in m.parameters())
    inc, com = params_incomplet(d, L, V), params_complet(d, L, V, s)
    print(f"d={d:4d} L={L} V={V:5d} {reel:10,d} {inc:10,d} {reel-inc:+10,d} {com:10,d} {reel-com:+8,d}")
taille                 reel  incomplet      ecart    complet    ecart
d=  64 L=2 V=  256    119,424    114,688     +4,736    119,424       +0
d= 128 L=4 V= 1000    924,928    914,432    +10,496    924,928       +0
d= 192 L=6 V= 4096  3,457,920  3,440,640    +17,280  3,457,920       +0
# D'ou vient exactement l'ecart ? Decompose sur la plus grande des trois tailles.
d, L, V = 192, 6, 4096
m = MiniGPT(d=d, L=L, V=V)
s = m.s
reel = sum(p.numel() for p in m.parameters())
print(f"reel {reel:,d} - incomplet {params_incomplet(d,L,V):,d} = {reel-params_incomplet(d,L,V):,d}")
print(f"  plongement positionnel s*d      : {s*d:,d}")
print(f"  LayerNorm des blocs  4*L*d      : {4*L*d:,d}")
print(f"  LayerNorm finale     2*d        : {2*d:,d}")
print(f"  somme                           : {s*d + 4*L*d + 2*d:,d}")
print(f"  ecart restant apres correction  : {reel - params_complet(d,L,V,s):,d}")
reel 3,457,920 - incomplet 3,440,640 = 17,280
  plongement positionnel s*d      : 12,288
  LayerNorm des blocs  4*L*d      : 4,608
  LayerNorm finale     2*d        : 384
  somme                           : 17,280
  ecart restant apres correction  : 0

Lecture du résultat

La version incomplète se trompait de dix-sept mille paramètres sur un réseau de trois millions — soit \(0{,}5\) %, une erreur qui ne se voit pas dans un budget arrondi en GiB, et c’est précisément pourquoi elle serait passée inaperçue. Une fois les trois termes nommés — plongement positionnel, deux LayerNorm par bloc, normale finale — l’écart tombe à zéro.

Ce que cette vérification enseigne dépasse le cas d’espèce : un budget se confronte à l’objet qu’il prétend décrire. Une formule plausible non testée produit un chiffre qui a l’air d’un budget et qui n’en est pas un.

2. Le budget analytique : quatre postes, une fonction

Le budget se calcule par paramètre pour les trois premiers postes, et par lot pour le quatrième.

La loi des activations retenue ici est celle que la section 4 établira par la mesure — une loi affine, pas proportionnelle :

\[A = \underbrace{\text{P} \cdot \text{octets}}_{\text{constant}} \; + \; \underbrace{c \cdot B \cdot s \cdot d \cdot L \cdot \text{octets}}_{\text{proportionnel au lot}}\]

Le premier terme surprend : la rétropropagation retient les matrices de poids elles-mêmes, parce que le gradient d’une entrée linéaire se calcule à partir de la matrice. Ce terme ne dépend donc pas du lot, et la formule ne devient proportionnelle au lot que lorsque ce terme devient négligeable — c’est-à-dire aux lots réalistes.

Le coefficient \(c\) de la littérature sur le parallélisme de tenseurs (Korthikanti et al., 2022) vaut \(34\) pour un bloc transformeur en précision mixte. Ce carnet ne le recopie pas : il le mesure à la section 4, puis compare.

OCTETS = {"fp32": 4, "fp16": 2, "bf16": 2}
DTYPES = {"fp32": torch.float32, "fp16": torch.float16, "bf16": torch.bfloat16}

# Le coefficient d'activation mesure a la section 4 remplacera cette valeur.
C_LITTERATURE = 34.0


def budget(d, L, V, s, lot=1, prec="fp32", optimiseur="adamw", c_actif=C_LITTERATURE):
    '''Les quatre postes d'un pas d'entrainement, en octets.

    P, G : poids et gradients (1 tenseur par parametre).
    O    : etats de l'optimiseur -- 2 moments pour AdamW, 0 pour SGD nu.
    A    : activations = poids retenus (constant) + c * lot * s * d * L.
    '''
    P = params_complet(d, L, V, s)
    o = OCTETS[prec]
    poids = P * o
    grads = P * o
    opt = P * o * (2 if optimiseur == "adamw" else 0)
    actifs = poids + c_actif * lot * s * d * L * o
    return {"P": poids, "G": grads, "O": opt, "A": actifs,
            "total": poids + grads + opt + actifs}


def fmt(n):
    '''Octets -> chaine lisible.'''
    if n >= 2**30:
        return f"{n/2**30:.2f} GiB"
    if n >= 2**20:
        return f"{n/2**20:.1f} MiB"
    return f"{n/2**10:.1f} KiB"

# fmt sert a toutes les sections suivantes.
# Les trois tailles de reference. Aucune n'est instanciee : elles sont CALCULEES.
TAILLES = [
    ("petit  (type GPT-2 124M)", 768, 12, 50257, 1024),
    ("moyen  (type 1,3 Md)",     2048, 24, 50257, 1024),
    ("grand  (type 6,6 Md)",     4096, 32, 50257, 1024),
]

print("A lot = 1, avec le coefficient de la litterature (34) :")
print(f"{'taille':26s} {'params':>10s} {'P+G+O':>10s} {'A':>10s} {'total':>11s}")
for nom, d, L, V, s in TAILLES:
    b = budget(d, L, V, s, lot=1)
    fixe = b["P"] + b["G"] + b["O"]
    print(f"{nom:26s} {params_complet(d,L,V,s)/1e6:8.1f} M {fmt(fixe):>10s} {fmt(b['A']):>10s} {fmt(b['total']):>11s}")
A lot = 1, avec le coefficient de la litterature (34) :
taille                         params      P+G+O          A       total
petit  (type GPT-2 124M)      124.4 M   1.85 GiB   1.66 GiB    3.51 GiB
moyen  (type 1,3 Md)         1313.2 M  19.57 GiB  11.27 GiB   30.83 GiB
grand  (type 6,6 Md)         6653.0 M  99.14 GiB  41.78 GiB  140.92 GiB

Lecture du résultat

Les trois premières colonnes montrent ce que le lot ne change pas : \(P\), \(G\) et \(O\) valent \(4\), \(4\) et \(8\) octets par paramètre, et le « grand » atteint ainsi \(99\) GiB avant toute activation. Aucune carte courante — 24 GiB, 80 GiB — ne tient ce total, et baisser le lot n’y change rien : ces trois postes n’en dépendent pas.

C’est la raison pour laquelle la réponse au problème n’est pas « baisser le lot » mais répartir les états — l’objet du carnet 3.13. Le présent carnet n’a plus qu’à établir la loi des activations, qui est le seul poste sur lequel le lot agit.

3. Les quatre postes, mesurés sur un modèle réel

Le calcul suppose la comptabilité juste. On l’instrumente poste par poste, sur un MiniGPT réel.

  • P et G se comptent directement : numel() × element_size(), sur les paramètres puis sur les .grad.
  • O se compte sur l’état de l’optimiseur — AdamW y range deux tenseurs (exp_avg, exp_avg_sq) de la forme des poids.
  • A se compte avec saved_tensors_hooks, qui intercepte tout tenseur que la rétropropagation retient. C’est la définition opérationnelle des activations : ce qui doit survivre entre la passe avant et la passe arrière.
def octets_poids(m):
    return sum(p.numel() * p.element_size() for p in m.parameters())


def octets_grads(m):
    return sum(p.grad.numel() * p.grad.element_size() for p in m.parameters() if p.grad is not None)


def octets_optimiseur(opt):
    total = 0
    for etat in opt.state.values():
        for v in etat.values():
            if torch.is_tensor(v):
                total += v.numel() * v.element_size()
    return total


class CompteurActivations:
    '''Compte les tenseurs retenus pour la retropopagation, via saved_tensors_hooks.'''

    def __init__(self):
        self.octets = 0
        self.tenseurs = 0
        self.par_forme = {}

    def __enter__(self):
        self._ctx = torch.autograd.graph.saved_tensors_hooks(self._pack, lambda t: t)
        self._ctx.__enter__()
        return self

    def __exit__(self, *a):
        return self._ctx.__exit__(*a)

    def _pack(self, t):
        self.octets += t.numel() * t.element_size()
        self.tenseurs += 1
        cle = tuple(t.shape)
        self.par_forme[cle] = self.par_forme.get(cle, 0) + t.numel() * t.element_size()
        return t

# Les tenseurs retenus par la retropopagation, par forme.
V, s, lot = 1000, 64, 8
modele = MiniGPT(d=128, L=4, V=V, s=s).to(DEV)
opt = torch.optim.AdamW(modele.parameters(), lr=1e-3)
x = torch.randint(0, V, (lot, s), device=DEV)

compteur = CompteurActivations()
with compteur:
    sortie = modele(x)
    perte = F.cross_entropy(sortie.reshape(-1, V), x.reshape(-1))
    perte.backward()

mesure = {"P": octets_poids(modele), "G": octets_grads(modele), "A": compteur.octets}
opt.step()
mesure["O"] = octets_optimiseur(opt)
mesure["total"] = sum(mesure.values())

P = params_complet(modele.d, modele.L, modele.V, modele.s)
print(f"perte initiale : {perte.item():.4f}  (ln V = {math.log(V):.4f})")
print(f"{'poste':6s} {'mesure':>11s}   {'attendu':>11s}   {'ecart':>10s}")
for k, theo in (("P", P * 4), ("G", P * 4), ("O", P * 8)):
    print(f"{k:6s} {mesure[k]:11,d}   {theo:11,d}   {mesure[k]-theo:+10,d}")
print(f"{'A':6s} {mesure['A']:11,d}   (mesure seule : la loi arrive en section 4)")
print(f"{'total':6s} {mesure['total']:11,d}")
print(f"\nactivations : {compteur.tenseurs} tenseurs retenus")
perte initiale : 0.0000  (ln V = 6.9078)
poste       mesure       attendu        ecart
P        3,699,712     3,699,712           +0
G        3,699,712     3,699,712           +0
O        7,399,568     7,399,424         +144
A       26,191,428   (mesure seule : la loi arrive en section 4)
total   40,990,420

activations : 117 tenseurs retenus

Lecture du résultat

P et G tombent exactement sur le calcul, au chiffre près. O dépasse de 144 octets : ce ne sont pas les deux moments — ils valent bien \(8\) octets par paramètre — mais le compteur de pas, un entier qu’AdamW range à côté d’eux. L’écart est nommé plutôt que lissé, parce que c’est toute la différence entre « la formule est juste » et « la formule est juste au terme près qu’on avait oublié ».

Ce que le tableau laisse ouvert est le poste \(A\) : il n’a pas d’« attendu » à comparer, parce que sa loi n’est pas encore connue. La section suivante la mesure.

def formes_par_lot(d, L, V, s, lot, prec="fp32"):
    '''Activations retenues, ventilees par forme, pour un lot donne.'''
    m = MiniGPT(d=d, L=L, V=V, s=s).to(DEV)
    c = CompteurActivations()
    xi = torch.randint(0, V, (lot, s), device=DEV)
    with c:
        F.cross_entropy(m(xi).reshape(-1, V).float(), xi.reshape(-1)).backward()
    return c.par_forme, c.octets


def normalise(forme, lot, s):
    '''Remplace une dimension de lot par son symbole, pour comparer deux lots.

    Sans cette normalisation, (8, 64, 512) et (16, 64, 512) sont deux cles
    differentes et rien n'est comparable.
    '''
    t = list(forme)
    if t and t[0] == lot:
        t[0] = "B"
    elif t and t[0] == lot * s:
        t[0] = "B*s"
    return tuple(t)


# Configuration SANS collision de dimensions : c'est la condition pour que la
# lecture par forme ait un sens. Avec d=128, s=64 et lot=8, B*s et 4d valent
# tous deux 512 -- deux familles differentes tombent dans le meme seau.
d0, L0, V0, s0 = 96, 4, 997, 50
lotA, lotB = 7, 14

fA, totA = formes_par_lot(d0, L0, V0, s0, lotA)
fB, totB = formes_par_lot(d0, L0, V0, s0, lotB)
print(f"d={d0} L={L0} V={V0} s={s0} : B*s = {lotA*s0}/{lotB*s0}, 4d = {4*d0}, d = {d0} -- tous distincts\n")

constantes, croissantes = {}, {}
# Appariement des deux lots par forme NORMALISEE.
indexB = {}
for kk, v in fB.items():
    indexB.setdefault(normalise(kk, lotB, s0), 0)
    indexB[normalise(kk, lotB, s0)] += v

for k, o in fA.items():
    oB = indexB.get(normalise(k, lotA, s0), 0)
    (croissantes if oB > o * 1.5 else constantes)[k] = o

print(f"{'forme (lot 7)':18s} {'lot 7':>11s} {'lot 14':>11s}   famille")
for k in sorted(set(fA), key=lambda x: -fA[x])[:12]:
    fam = "activation" if k in croissantes else "poids"
    oB = indexB.get(normalise(k, lotA, s0), 0)
    print(f"  {str(k):18s} {fA[k]:11,d} {oB:11,d}   {fam}")
print(f"\n  formes CONSTANTES  : {sum(constantes.values()):11,d} o  ({len(constantes)} formes)")
print(f"  formes CROISSANTES : {sum(croissantes.values()):11,d} o  ({len(croissantes)} formes) au lot {lotA}")
print(f"  octets des poids du modele : {params_complet(d0,L0,V0,s0)*4:,d} o")
print(f"  rapport constantes/poids   : {sum(constantes.values())/(params_complet(d0,L0,V0,s0)*4):.4f}")
print(f"\n  controle : totaux {totA:,d} (lot {lotA}) puis {totB:,d} (lot {lotB})")
d=96 L=4 V=997 s=50 : B*s = 350/700, 4d = 384, d = 96 -- tous distincts

forme (lot 7)            lot 7      lot 14   famille
  (350, 997)           2,791,600   5,583,200   activation
  (7, 50, 384)         2,150,400   4,300,800   activation
  (350, 384)           2,150,400   4,300,800   activation
  (7, 4, 50, 24)       2,150,400   4,300,800   activation
  (350, 96)            1,747,200   3,494,400   activation
  (7, 50, 96)          1,209,600   2,419,200   activation
  (96, 384)              589,824     589,824   poids
  (384, 96)              589,824     589,824   poids
  (96, 288)              442,368     442,368   poids
  (96, 997)              382,848     382,848   poids
  (96, 96)               147,456     147,456   poids
  (7, 4, 64)              28,672      57,344   activation

  formes CONSTANTES  :   2,159,700 o  (8 formes)
  formes CROISSANTES :  12,259,072 o  (10 formes) au lot 7
  octets des poids du modele : 2,178,432 o
  rapport constantes/poids   : 0.9914

  controle : totaux 14,418,772 (lot 7) puis 26,677,844 (lot 14)

Lecture du résultat

Deux familles, séparées par la mesure et non à l’œil sur les formes. Cette précaution n’est pas de la prudence théorique : la cellule emploie pour cela une configuration sans collision de dimensions, parce qu’avec \(d = 128\), \(s = 64\) et un lot de \(8\), le produit \(Bs\) et la largeur \(4d\) valent tous deux \(512\) — les deux familles tombent alors dans le même seau et aucune lecture par forme n’est possible. Le carnet a rencontré cette collision en écrivant la cellule, et la nomme ici plutôt que de la contourner en silence.

Le total des formes constantes tombe sur les octets des poids du modèle. C’est le fait central de la section suivante : ce que le carnet appelle « activations » contient deux choses de natures différentes, et la seconde est le modèle lui-même.

4. La loi des activations, mesurée

On fait varier un seul paramètre à la fois et on regarde comment \(A\) répond.

Première question : \(A\) est-il proportionnel au lot ? La réponse mesurée est non — et c’est une bonne nouvelle, parce que l’écart a une structure. On ajuste donc une loi affine

\[A(B) = A_0 + k \cdot B\]

et on vérifie que les résidus du meilleur ajustement sont nuls. Un ajustement qui laisse des résidus n’est pas une loi, c’est une coïncidence.

def activations_mesurees(d, L, V, s, lot, prec="fp32"):
    '''Relance une passe avant/arriere et rend les octets retenus.'''
    m = MiniGPT(d=d, L=L, V=V, s=s).to(DEV)
    if prec != "fp32":
        m = m.to(DTYPES[prec])
    c = CompteurActivations()
    xi = torch.randint(0, V, (lot, s), device=DEV)
    with c:
        F.cross_entropy(m(xi).reshape(-1, V).float(), xi.reshape(-1)).backward()
    return c.octets


def ajuste_affine(xs, ys):
    '''Moindres carres sur y = a0 + k*x. Rend (a0, k, residus).'''
    n = len(xs)
    sx, sy = sum(xs), sum(ys)
    sxx = sum(x * x for x in xs)
    sxy = sum(x * y for x, y in zip(xs, ys))
    k = (n * sxy - sx * sy) / (n * sxx - sx * sx)
    a0 = (sy - k * sx) / n
    return a0, k, [y - (a0 + k * x) for x, y in zip(xs, ys)]


LOTS = [1, 2, 4, 8, 16, 32]
AS = [activations_mesurees(128, 4, 1000, 64, l) for l in LOTS]
A0, K, res = ajuste_affine(LOTS, AS)

print("A en fonction du lot (d=128, L=4, s=64, fp32) :")
ref = AS[0]
for l, a in zip(LOTS, AS):
    print(f"  lot {l:3d} : {a:11,d} o   x{a/ref:5.2f}  (lot x{l})   A/lot {a/l:11,.0f}")
print(f"\nAjustement affine A = A0 + k*lot :")
print(f"  A0 = {A0:,.0f} o = {A0/2**20:.4f} MiB")
print(f"  k  = {K:,.0f} o/lot = {K/2**20:.4f} MiB/lot")
print(f"  residus : {[round(r) for r in res]}")
print(f"  residus nuls ? {'OUI -- c est une loi' if all(abs(r) < 1.0 for r in res) else 'NON -- ce n est pas une loi'}")
A en fonction du lot (d=128, L=4, s=64, fp32) :
  lot   1 :   6,483,012 o   x 1.00  (lot x1)   A/lot   6,483,012
  lot   2 :   9,298,500 o   x 1.43  (lot x2)   A/lot   4,649,250
  lot   4 :  14,929,476 o   x 2.30  (lot x4)   A/lot   3,732,369
  lot   8 :  26,191,428 o   x 4.04  (lot x8)   A/lot   3,273,928
  lot  16 :  48,715,332 o   x 7.51  (lot x16)   A/lot   3,044,708
  lot  32 :  93,763,140 o   x14.46  (lot x32)   A/lot   2,930,098

Ajustement affine A = A0 + k*lot :
  A0 = 3,667,524 o = 3.4976 MiB
  k  = 2,815,488 o/lot = 2.6851 MiB/lot
  residus : [0, 0, 0, 0, 0, 0]
  residus nuls ? OUI -- c est une loi
# Le terme constant est-il ce qu'on croit ? Confrontons A0 aux octets de poids.
P_octets = params_complet(128, 4, 1000, 64) * 4
print(f"A0 mesure          : {A0:,.0f} o")
print(f"octets de poids P  : {P_octets:,.0f} o")
print(f"rapport A0 / P     : {A0/P_octets:.4f}")
print()
print("A0 n'est pas exactement P : la retropopagation ne retient que les matrices")
print("engagees dans une multiplication, et le plongement lie n'est garde qu'une fois.")
print(f"ecart : {A0-P_octets:+,.0f} o  ({(A0-P_octets)/P_octets*100:+.2f} %)")

# Le coefficient par element se deduit de la pente, pas d'un rapport A/lot.
c_mesure = K / (64 * 128 * 4 * OCTETS["fp32"])
print(f"\ncoefficient c = k / (s*d*L*octets) = {c_mesure:.2f} valeurs retenues par element")
print(f"litterature (precision mixte)      = {C_LITTERATURE:.2f}")
print(f"rapport                            = {c_mesure/C_LITTERATURE:.2f}")

# Pourquoi les rapports A/lot de la cellule precedente surestiment c :
print("\nPourquoi A/lot surestime le coefficient :")
for l in (1, 8, 32):
    print(f"  lot {l:3d} : A/lot donne {K/(l) + A0/l:,.0f} o/lot, soit un c apparent de "
          f"{(K + A0/l)/(64*128*4*4):.2f} au lieu de {c_mesure:.2f}")
A0 mesure          : 3,667,524 o
octets de poids P  : 3,699,712 o
rapport A0 / P     : 0.9913

A0 n'est pas exactement P : la retropopagation ne retient que les matrices
engagees dans une multiplication, et le plongement lie n'est garde qu'une fois.
ecart : -32,188 o  (-0.87 %)

coefficient c = k / (s*d*L*octets) = 21.48 valeurs retenues par element
litterature (precision mixte)      = 34.00
rapport                            = 0.63

Pourquoi A/lot surestime le coefficient :
  lot   1 : A/lot donne 6,483,012 o/lot, soit un c apparent de 49.46 au lieu de 21.48
  lot   8 : A/lot donne 810,376 o/lot, soit un c apparent de 24.98 au lieu de 21.48
  lot  32 : A/lot donne 202,594 o/lot, soit un c apparent de 22.35 au lieu de 21.48

Lecture du résultat

L’ajustement affine ne laisse aucun résidu : \(A\) suit bien \(A_0 + k\,B\). La proportionnalité au lot est donc fausse au sens strict, et vraie asymptotiquement — ce qui explique pourquoi le coefficient apparent \(c = A/(BsdL)\) décroît vers sa valeur limite au lieu d’être constant : à petit lot, le terme \(A_0\) est divisé par un petit nombre.

La confrontation suivante est celle qui donne son sens au terme constant : \(A_0\) vaut les octets des poids, à quelques pour cent près (rapport mesuré \(A_0/P = 0{,}9913\)). La rétropropagation retient les matrices qu’une multiplication a utilisées — c’est un fait sur l’autograd, pas une constante magique, et il explique aussi pourquoi la loi change de nature quand le lot croît : \(A_0\) cesse d’être négligeable devant \(kB\).

Le coefficient mesuré se déduit donc de la pente, jamais d’un rapport \(A/B\). La cellule le montre explicitement en calculant le \(c\) apparent qu’un rapport naïf produirait à petit lot — l’erreur à ne pas commettre.

# La loi tient-elle aussi en longueur de sequence ?
print("Balayage de s (d=128, L=4, lot=8, fp32) :")
for s_ in (16, 32, 64, 128, 256):
    a = activations_mesurees(128, 4, 1000, s_, 8)
    attendu = A0 + c_mesure * 8 * s_ * 128 * 4 * 4
    print(f"  s {s_:4d} : {a:11,d} o   loi A0 + c*8*s*d*L*o -> {attendu:11,.0f}   ecart {(a-attendu)/attendu*100:+6.1f} %")
Balayage de s (d=128, L=4, lot=8, fp32) :
  s   16 :   9,306,308 o   loi A0 + c*8*s*d*L*o ->   9,298,500   ecart   +0.1 %
  s   32 :  14,929,220 o   loi A0 + c*8*s*d*L*o ->  14,929,476   ecart   -0.0 %
  s   64 :  26,191,428 o   loi A0 + c*8*s*d*L*o ->  26,191,428   ecart   +0.0 %
  s  128 :  48,715,844 o   loi A0 + c*8*s*d*L*o ->  48,715,332   ecart   +0.0 %
  s  256 :  93,764,676 o   loi A0 + c*8*s*d*L*o ->  93,763,140   ecart   +0.0 %
# ... et en largeur ?
print("Balayage de d (L=4, s=64, lot=8, fp32) :")
lignes = []
for d_ in (64, 128, 256):
    a = activations_mesurees(d_, 4, 1000, 64, 8)
    a0_, k_, res_ = ajuste_affine(LOTS, [activations_mesurees(d_, 4, 1000, 64, l) for l in LOTS])
    lignes.append((d_, a, a0_, k_, res_))
    print(f"  d {d_:4d} : A {a:11,d} o | A0 {a0_:11,.0f} | k {k_:11,.0f} o/lot | "
          f"residus nuls {all(abs(r)<1.0 for r in res_)}")

print("\nComment A0 et k evoluent quand la largeur double :")
for i in range(1, len(lignes)):
    d0, _, a00, k0, _ = lignes[i-1]
    d1, _, a01, k1, _ = lignes[i]
    r = d1 // d0
    print(f"  d x{r} : A0 x{a01/a00:5.2f}  (d^2 donnerait {r*r}, d donnerait {r})"
          f" | k x{k1/k0:5.2f}  (d donnerait {r})")
Balayage de d (L=4, s=64, lot=8, fp32) :
  d   64 : A  14,396,484 o | A0   1,047,620 | k   1,668,608 o/lot | residus nuls True
  d  128 : A  26,191,428 o | A0   3,667,524 | k   2,815,488 o/lot | residus nuls True
  d  256 : A  54,499,908 o | A0  13,625,924 | k   5,109,248 o/lot | residus nuls True

Comment A0 et k evoluent quand la largeur double :
  d x2 : A0 x 3.50  (d^2 donnerait 4, d donnerait 2) | k x 1.69  (d donnerait 2)
  d x2 : A0 x 3.72  (d^2 donnerait 4, d donnerait 2) | k x 1.81  (d donnerait 2)

Lecture du résultat

Les deux balayages ne se comportent pas de la même façon, et il faut le dire tel que la mesure le donne — pas tel qu’on l’espérait.

En longueur \(s\), la loi tient remarquablement : l’écart entre \(A\) mesuré et \(A = A_0 + c \cdot B s d L o\) reste sous \(0{,}1\) % sur toute la plage. La pente suit bien \(s\).

En largeur \(d\), la mesure contredit deux prédictions simples :

  • \(A_0\) ne suit ni \(d\) (\(\times 2\)) ni \(d^2\) (\(\times 4\)) : il donne \(\times 3{,}50\) puis \(\times 3{,}72\) — entre les deux, et dérivant vers 4 à mesure que le terme \(Vd\) du plongement devient relativement moins lourd. C’est cohérent avec les octets des poids, sans être aucune des deux lois pures.
  • la pente \(k\) ne suit pas \(d\) : elle donne \(\times 1{,}69\) puis \(\times 1{,}81\) là où un coefficient \(c\) constant prédirait exactement \(\times 2\). Autrement dit, le coefficient \(c\) n’est pas invariant en largeur : il décroît quand le réseau s’élargit.

La conséquence se dit sans détour : la formule \(A = P + cBsdLo\) est une approximation calibrée en un point, pas une loi universelle. Le carnet l’a calibrée à \(d = 128\) et l’applique ailleurs — ce qui reste légitime pour un budget d’ordre de grandeur, et ne le serait pas pour un dimensionnement au gigaoctet près.

Ce qui reste vrai sans réserve est la structure : \(A\) est affine en \(B\), avec un terme constant qui vaut les poids retenus. Un budget qui se trompe du côté sûr est acceptable ; un budget présenté comme exact alors qu’il ne l’est qu’en un point ne l’est pas.

C_FINAL = c_mesure
print(f"Coefficient retenu pour le budget : c = {C_FINAL:.2f} valeurs retenues par element")
print(f"Litterature (precision mixte)     : {C_LITTERATURE:.2f}")
print(f"Rapport                           : {C_FINAL/C_LITTERATURE:.2f}")
print()
print("Les deux nombres ne mesurent pas la meme chose :")
print(f"  - la litterature compte la precision MIXTE (2 octets par valeur)")
print(f"  - ici tout est en FP32 ({OCTETS['fp32']} octets par valeur)")
print("  - la mesure porte sur un bloc court (L=4) et une sequence courte (s=64)")
print("  - le modele de la litterature est un transformeur de plusieurs milliards de parametres")
print()
print(f"Verdict : {'du meme ordre' if 0.5 < C_FINAL/C_LITTERATURE < 2.0 else 'ECART -- formule a declarer approximative'}")
Coefficient retenu pour le budget : c = 21.48 valeurs retenues par element
Litterature (precision mixte)     : 34.00
Rapport                           : 0.63

Les deux nombres ne mesurent pas la meme chose :
  - la litterature compte la precision MIXTE (2 octets par valeur)
  - ici tout est en FP32 (4 octets par valeur)
  - la mesure porte sur un bloc court (L=4) et une sequence courte (s=64)
  - le modele de la litterature est un transformeur de plusieurs milliards de parametres

Verdict : du meme ordre

5. Le budget des trois tailles, avec le coefficient mesuré

On remplace \(c = 34\) par la valeur mesurée, et on calcule les trois tailles à une taille de lot réaliste — celle qu’on utilise vraiment, pas 1.

LOT_REEEL = 8
print(f"Coefficient retenu : c = {C_FINAL:.2f} (mesure, FP32), lot = {LOT_REEEL}\n")
print(f"{'taille':26s} {'params':>10s} {'P':>10s} {'G':>10s} {'O':>10s} {'A':>10s} {'TOTAL':>11s}")
sortie = {}
for nom, d, L, V, s in TAILLES:
    b = budget(d, L, V, s, lot=LOT_REEEL, c_actif=C_FINAL)
    sortie[nom] = b
    print(f"{nom:26s} {params_complet(d,L,V,s)/1e6:8.1f} M {fmt(b['P']):>10s} {fmt(b['G']):>10s} "
          f"{fmt(b['O']):>10s} {fmt(b['A']):>10s} {fmt(b['total']):>11s}")

print("\nCartes courantes : 24 GiB (RTX 3090/4090), 80 GiB (A100/H100).")
for nom, b in sortie.items():
    fixe = b["P"] + b["G"] + b["O"]
    print(f"  {nom:26s} fixe P+G+O = {fmt(fixe):>10s}  -> "
          f"{'tient sur 80 GiB' if fixe < 80*2**30 else 'NE TIENT sur aucune carte seule'}")
Coefficient retenu : c = 21.48 (mesure, FP32), lot = 8

taille                         params          P          G          O          A       TOTAL
petit  (type GPT-2 124M)      124.4 M  474.4 MiB  474.4 MiB  948.8 MiB   6.50 GiB    8.36 GiB
moyen  (type 1,3 Md)         1313.2 M   4.89 GiB   4.89 GiB   9.78 GiB  37.11 GiB   56.68 GiB
grand  (type 6,6 Md)         6653.0 M  24.78 GiB  24.78 GiB  49.57 GiB 110.71 GiB  209.84 GiB

Cartes courantes : 24 GiB (RTX 3090/4090), 80 GiB (A100/H100).
  petit  (type GPT-2 124M)   fixe P+G+O =   1.85 GiB  -> tient sur 80 GiB
  moyen  (type 1,3 Md)       fixe P+G+O =  19.57 GiB  -> tient sur 80 GiB
  grand  (type 6,6 Md)       fixe P+G+O =  99.14 GiB  -> NE TIENT sur aucune carte seule

Lecture du résultat

Le tableau est le livrable de la section : pour chaque taille, les quatre postes et leur somme, avec le coefficient mesuré à la section 4.

Le point qui décide de tout n’est pas le total mais la colonne des trois postes fixes : \(P + G + O\) ne dépend pas du lot, et il place déjà le « grand » à \(99\) GiB — hors de portée de toute carte seule. Le « moyen » passe de justesse : ses \(19{,}57\) GiB fixes tiennent sur une carte de 24 GiB, mais n’y laissent presque rien pour les activations, ce que la section suivante chiffre. Le seul levier sur ces trois postes est de les répartir entre plusieurs cartes — l’objet du carnet 3.13.

Les activations, elles, sont le seul poste sensible au lot, et c’est ce que la section suivante exploite : puisque le lot ne peut pas sauver les postes fixes, il ne lui reste qu’à être choisi pour ce qu’il coûte en activations.

6. Le mur : quelle taille de lot tient réellement

Puisque seul \(A\) dépend du lot, la question « quelle carte me faut-il ? » se retourne en « quel lot puis-je tenir ? ». On la pose comme une inéquation, puis on la confronte à la mesure.

Pour une carte de \(M\) octets, le lot maximal vérifie :

\[B_{\max} = \left\lfloor \frac{M - (P + G + O) - P \cdot \text{octets}}{c \cdot s \cdot d \cdot L \cdot \text{octets}} \right\rfloor\]

Le second terme du numérateur est le \(A_0\) de la section 4 : les poids retenus, qu’il faut compter dans le budget d’activations.

def lot_maximal(d, L, V, s, memoire_carte_GiB, c_actif=C_FINAL, prec="fp32"):
    '''Plus grand lot qui tient dans une carte, d'apres le budget analytique.'''
    M = memoire_carte_GiB * 2**30
    o = OCTETS[prec]
    P = params_complet(d, L, V, s)
    fixe = P * o * 3 + P * o          # P + G + O + A0
    par_lot = c_actif * s * d * L * o
    if fixe >= M:
        return 0, fixe
    return int((M - fixe) // par_lot), fixe


for nom, d, L, V, s in TAILLES:
    for carte in (24, 80):
        lot, fixe = lot_maximal(d, L, V, s, carte)
        verdict = f"lot max {lot}" if lot else f"NE TIENT PAS (fixe {fmt(fixe)} > {carte} GiB)"
        print(f"{nom:26s} {carte:3d} GiB : {verdict}")
petit  (type GPT-2 124M)    24 GiB : lot max 29
petit  (type GPT-2 124M)    80 GiB : lot max 103
moyen  (type 1,3 Md)        24 GiB : lot max 1
moyen  (type 1,3 Md)        80 GiB : lot max 15
grand  (type 6,6 Md)        24 GiB : NE TIENT PAS (fixe 99.14 GiB > 24 GiB)
grand  (type 6,6 Md)        80 GiB : NE TIENT PAS (fixe 99.14 GiB > 80 GiB)

Lecture du résultat

Le « petit » tient partout et garde une marge confortable. Le « moyen » est le cas intéressant : sur 24 GiB il tient à lot de 1 — la formule lui accorde un lot maximal de 1, ce qui signifie qu’il passe, mais sans le moindre débit ; sur 80 GiB il retrouve un lot utilisable. Le « grand » ne tient sur aucune des deux, quel que soit le lot. Ce tableau est la réponse à la question posée en introduction : la taille du lot n’est pas le levier, la répartition des états l’est.

Reste à savoir si le modèle analytique dit vrai. On le confronte à l’allocateur réel.

7. Le pic réel, quand une carte est présente

Le budget analytique est un modèle ; l’allocateur de PyTorch est la réalité. Sur une machine sans carte, cette section se déclare absente — elle ne fabrique aucun chiffre.

L’instrument est torch.cuda.memory_allocated, et il a un piège que le carnet rencontre ici de première main : il compte tout ce qui est vivant, y compris les tenseurs que les cellules précédentes ont laissés derrière elles. La cellule commence donc par les libérer, et la mesure porte sur des deltas — la différence entre deux instants — plutôt que sur des valeurs absolues.

L’écart entre le pic et la somme des postes est en soi une leçon : le pic peut être inférieur à la somme, parce que les postes ne sont pas tous vivants au même instant.

if DEV == "cuda":
    # Liberation explicite : les cellules precedentes ont laisse des modeles sur la carte.
    for nom in ("modele", "opt", "x", "sortie", "perte", "compteur", "_"):
        globals().pop(nom, None)
    gc.collect()
    torch.cuda.empty_cache()

    dm, Lm, Vm, sm, lotm = 256, 6, 4096, 128, 16
    base = torch.cuda.memory_allocated()          # ce qui reste vivant malgre tout
    mm = MiniGPT(d=dm, L=Lm, V=Vm, s=sm).to("cuda")
    om = torch.optim.AdamW(mm.parameters(), lr=1e-3)
    xm = torch.randint(0, Vm, (lotm, sm), device="cuda")

    torch.cuda.reset_peak_memory_stats()
    apres_modele = torch.cuda.memory_allocated() - base
    s_m = mm(xm)
    apres_avant = torch.cuda.memory_allocated() - base
    F.cross_entropy(s_m.reshape(-1, Vm), xm.reshape(-1)).backward()
    pic_arriere = torch.cuda.max_memory_allocated() - base
    om.step()
    apres_opt = torch.cuda.memory_allocated() - base

    P_attendu = params_complet(dm, Lm, Vm, sm) * 4
    print(f"modele d={dm} L={Lm} V={Vm} s={sm} lot={lotm} — {params_complet(dm,Lm,Vm,sm)/1e6:.1f} M parametres")
    print(f"  base (deja vivant avant la mesure) : {base/2**20:.2f} MiB")
    print(f"  apres modele  {apres_modele/2**20:8.2f} MiB   (P attendu {P_attendu/2**20:.2f} MiB, ecart "
          f"{(apres_modele-P_attendu)/2**20:+.2f} MiB)")
    print(f"  apres forward {apres_avant/2**20:8.2f} MiB   (P + activations vivantes)")
    print(f"  PIC backward  {pic_arriere/2**20:8.2f} MiB   (P + G + activations)")
    print(f"  apres opt.step{apres_opt/2**20:8.2f} MiB   (P + G + O)")

    b = budget(dm, Lm, Vm, sm, lot=lotm, c_actif=C_FINAL)
    print(f"\n  somme analytique : {b['total']/2**20:.2f} MiB  |  pic mesure : {pic_arriere/2**20:.2f} MiB")
    print(f"  rapport pic/somme : {pic_arriere/b['total']:.3f}")
    print(f"  le pic est {'INFERIEUR' if pic_arriere < b['total'] else 'SUPERIEUR'} a la somme : "
          f"les postes ne sont pas tous vivants au meme instant")
else:
    print("Section 7 : aucune carte CUDA sur cette machine.")
    print("Le pic de l'allocateur ne peut pas etre mesure ici -- il n'est PAS estime a la place.")
    print("Les quatre postes de la section 3 restent mesurables sur CPU, et ils l'ont ete.")
modele d=256 L=6 V=4096 s=128 lot=16 — 5.8 M parametres
  base (deja vivant avant la mesure) : 16.25 MiB
  apres modele     22.17 MiB   (P attendu 22.15 MiB, ecart +0.02 MiB)
  apres forward   250.56 MiB   (P + activations vivantes)
  PIC backward    346.56 MiB   (P + G + activations)
  apres opt.step  120.62 MiB   (P + G + O)

  somme analytique : 368.52 MiB  |  pic mesure : 346.56 MiB
  rapport pic/somme : 0.940
  le pic est INFERIEUR a la somme : les postes ne sont pas tous vivants au meme instant

Lecture du résultat

La comparaison instructive est celle du pic contre la somme : le pic est inférieur à la somme analytique, parce que les postes ne coexistent pas tous. L’allocateur voit \(P\) seul après la construction, \(P + \text{activations}\) après la passe avant, \(P + G + \text{activations}\) au point le plus haut de la passe arrière — les activations étant libérées au fur et à mesure que la rétropropagation remonte — et enfin \(P + G + O\) après le pas.

Trois leçons que le calcul analytique seul ne donne pas :

  1. Le pic n’est pas la somme. Un budget qui additionne les quatre postes est conservateur — ce qui est la bonne erreur à faire quand on achète du matériel.
  2. L’ordre des postes se lit dans la séquence des mesures. C’est ce qui distingue un budget compris d’un budget recopié.
  3. La mesure porte sur des deltas, pas sur des absolus. La cellule a dû libérer explicitement les modèles des sections précédentes avant de mesurer : ce que l’allocateur rapporte n’est pas « ce que ce modèle occupe » mais « ce qui est vivant », et l’historique du carnet en fait partie. Mesurer un absolu sans nettoyer, c’est mesurer ses propres oublis.

Exercices

Les trois exercices prolongent le carnet sur des questions qu’il n’a pas traitées. Ils s’écrivent dans les cellules ci-dessous, sans erreur volontaire : une cellule non complétée s’exécute et affiche son message.

Exercice 1 — le budget d’une architecture qui n’est pas un transformeur

La comptabilité \(16\) octets par paramètre ne dépend pas de l’architecture : elle ne dépend que de l’optimiseur. Les activations, elles, en dépendent entièrement.

Un réseau convolutif n’a pas de matrice \(s \times s\) par tête : ses activations vivent en \(B \times C \times H \times W\) par couche, et le coefficient \(k\) doit être mesuré pour lui comme il l’a été pour le transformeur.

À faire : compléter budget_cnn pour qu’elle rende les quatre postes d’un petit CNN, puis vérifier que \(P\) et \(G\) tombent juste et que \(A\) suit une loi affine dont on peut extraire le terme constant.

def budget_cnn(canaux, taille_image, lot=8, prec="fp32", optimiseur="adamw", c_actif=12.0):
    '''Les quatre postes d'un CNN a `canaux`=[c1, c2, ...], images taille_image x taille_image.

    Indice : les parametres d'une conv 3x3 valent 9*c_entree*c_sortie (+ c_sortie de biais).
    Les activations valent c_actif*lot*(c*H*W) par couche, H et W diminuant de moitie
    a chaque etage -- meme loi affine que le transformeur : plus le terme constant.
    '''
    # TODO etudiant : calculer P, G, O, A et rendre un dictionnaire comme `budget`
    resultat = None  # TODO etudiant
    return resultat


print("Exercice 1 a completer : budget d'un CNN")
print(budget_cnn([32, 64, 128], 32))
Exercice 1 a completer : budget d'un CNN
None

Exercice 2 — ce que la précision mixte change réellement

Le carnet a tout mesuré en FP32. Le passage en bfloat16 divise par 2 les activations — mais pas les poids maîtres ni les états de l’optimiseur, qui restent en FP32 en entraînement à précision mixte.

À faire : mesurer les quatre postes d’un MiniGPT dont les paramètres sont en bfloat16, puis dire lesquels ont réellement été divisés par 2 et lesquels ne l’ont pas été. Le résultat n’est pas celui qu’on attend naïvement, et le carnet l’a déjà montré pour le terme constant.

print("Exercice 2 a completer : les quatre postes en bfloat16 contre FP32")
# TODO etudiant : instancier MiniGPT en bfloat16, mesurer P, G, O, A avec les
# instruments de la section 3, et comparer au meme modele en FP32.
# Attention : quels postes RESTENT en FP32 quand seul le modele passe en bfloat16 ?
ratios = {k: "?" for k in ("P", "G", "O", "A")}
print(ratios)
Exercice 2 a completer : les quatre postes en bfloat16 contre FP32
{'P': '?', 'G': '?', 'O': '?', 'A': '?'}

Exercice 3 — trouver le lot maximal par dichotomie, et confronter au modèle

La section 6 a calculé le lot maximal. Elle ne l’a pas vérifié.

À faire : écrire une recherche par dichotomie qui trouve le plus grand lot réellement exécutable, puis comparer le lot trouvé à la prédiction de lot_maximal.

Sur CPU, l’exécution ne lève pas d’erreur mémoire : la dichotomie doit donc porter sur une limite déclarée — la somme des postes mesurés, comparée à un plafond qu’on se donne. Sur GPU, elle peut porter sur les vraies erreurs de l’allocateur. Un écart entre les deux se documente : il dit lequel des deux instruments est conservateur.

def lot_maximal_par_dichotomie(d, L, V, s, plafond_MiB=512, c_actif=C_FINAL):
    '''Plus grand lot dont le budget tient sous `plafond_MiB`, par dichotomie.

    Rends (lot, trace) ou trace est la liste des (lot, total_MiB, verdict).
    '''
    # TODO etudiant : chercher par dichotomie entre 1 et 256, en appelant `budget`
    # et en comparant b['total'] a plafond_MiB * 2**20.
    trace = []  # TODO etudiant
    return None, trace


print("Exercice 3 a completer : dichotomie du lot maximal")
lot, trace = lot_maximal_par_dichotomie(128, 4, 1000, 64)
print(f"lot trouve : {lot} | essais : {len(trace)}")
Exercice 3 a completer : dichotomie du lot maximal
lot trouve : None | essais : 0

Conclusion

Le carnet a posé une question — ce modèle tient-il dans cette carte ? — et y a répondu par un budget à quatre postes, calculé puis mesuré, en corrigeant le modèle là où la mesure le contredisait.

Ce qui a été établi Comment
\(16\) octets par paramètre pour un AdamW en FP32 \(P\) et \(G\) tombent exactement sur le modèle réel ; \(O\) à \(144\) octets près, le compteur de pas, nommé
La formule des paramètres doit inclure \(sd + (4L+2)d\) première version fausse de \(0{,}5\) %, décomposée terme par terme puis corrigée — écart final nul
Les activations suivent \(A = A_0 + kB\), pas \(A \propto B\) ajustement affine à résidus nuls sur six tailles de lot
\(A_0\) est le coût des poids retenus par l’autograd familles séparées par la mesure (doublement du lot), total confronté aux octets de poids
Le coefficient \(c\) n’est pas invariant en largeur balayage de \(d\) : la pente donne \(\times 1{,}69\) là où \(c\) constant prédirait \(\times 2\) — approximation déclarée calibrée en un point
Le pic n’est pas la somme des postes quatre deltas successifs sur carte, base libérée explicitement ; rapport mesuré \(0{,}94\)
Un modèle de 6,6 Md ne tient sur aucune carte seule budget calculé, lot maximal rendu nul sur 24 comme sur 80 GiB

Ce que ce carnet ne fait pas, et qui est le sujet du suivant : il ne répartit rien. Il explique seulement pourquoi il faut répartir, en montrant que les trois postes déterministes — \(P + G + O\) — sont insensibles au lot et constituent le mur.

Le carnet 3.12 prend le premier outil de cette répartition : les collectives, ces opérations où tous les rangs s’échangent des tenseurs. Le carnet 3.13 en vient aux stratégies de partitionnement elles-mêmes.

Prérequis

  • torch (CPU suffit). Une carte CUDA améliore la section 7, qui se déclare absente sinon et n’estime rien à sa place.
  • Aucune donnée externe : le carnet fabrique ses entrées.
Retour au sommet