Python and Viv · A small GPT
The same GPT in PyTorch, against Viv
What to notice
- The Python column is the model of tiny_gpt in PyTorch:
nn.Modulesubclasses,nn.Linearandnn.Embeddinglayers,F.scaled_dot_product_attention(..., is_causal=True),F.cross_entropy,torch.optim.Adamwith aLambdaLRschedule, and Muon as an 11-linetorch.optim.Optimizersubclass. There is no backward code:loss.backward()fills everyp.grad, which is why its loss and backward regions hold only a comment. It is the reference for what the Viv program would look like with autograd, arrays of modules and batched tensors. It needs torch (run with 2.2.2, in float64 on one thread). - The Viv column imports what the torch program imports:
#! use lib/nnforattention(one causal head, asF.scaled_dot_product_attention(..., is_causal=True)),softmax_rows,rmsnorm,relu2,cross_entropyandembedding(the row lookup ofnn.Embedding);#! use lib/optimforadam_updateandmuon_update, which update one matrix and its memories in place with the settings in anAdamSettingsorMuonSettingsrecord; and#! use lib/randomforrandn_matrixandchoice. It is the programlib/gpt/story_gpt.vivruns withlib/gpt/gpt.vivcopied in, and prints the same bytes as the written-out Viv of the NumPy pair (tiny_gpt): all 30 losses and every trained weight to 17 digits, and the sample, in the interpreter, in C and in wasm. Its model is the same tree as the torch modules, as records:Gpt { tok, pos, blocks: [Block], unembed },Block { attn, mlp },Attention { heads: [Head], Wo },Head { Wq, Wk, Wv }; its gradient isvalue_and_grad(sequence_loss, model, ...), aGpt; andoptimizer_stepisfor_leaves(muon_update, model.blocks, g.blocks, m.blocks, lr_scale, MUON_OPT)and threeadam_updatecalls, where torch callso.step()on each optimizer. - The outputs are the same text. Printed with 17 digits, the 30 losses differ from Viv's by at most 6.7e-16. The weights are copied from the same MINSTD draws (
init_fromfills each layer in the orderlib/gptlays them out, transposed wherenn.Linearstores(out, in)), and the samples use the same draws. Muon's Newton-Schulz step returns the matrix unchanged when its norm is zero, aslib/polardoes: at the first step the output map is zero, so every block matrix has a zero gradient, and dividing by the norm would give NaN. - torch 2.2 has no
F.rms_normand no Muon; both are written out here, in 2 and 11 lines (plus the 9 ofnewton_schulz). Viv imports both from its libraries, so the optimizer region is shorter in Viv (8 lines against 20). The torch program writesMinstd(12 lines) only to draw the weights and the samples Viv draws; Viv's random draws come fromlib/random.
Measured
Lines are code lines: blank lines, comments and docstrings are not counted; characters are those lines' characters. A lookup is a read of a size from the configuration record (cfg.<field>, c.<field>); torch's self.q(x) is not counted, and neither is Viv's h.Wq. study/compare/measure.py counts them.
| region | Python lines | Viv lines | Viv / Python | Python lookups | Viv lookups |
|---|---|---|---|---|---|
| imports | 8 | 3 | 0.4 | 0 | 0 |
| config | 15 | 15 | 1.0 | 0 | 0 |
| random draws | 12 | 0 | 0.0 | 0 | 0 |
| parameters and init | 8 | 25 | 3.1 | 0 | 19 |
| embedding | 0 | 0 | 0 | 0 | |
| attention | 14 | 9 | 0.6 | 6 | 0 |
| block | 11 | 5 | 0.5 | 4 | 0 |
| forward | 13 | 8 | 0.6 | 7 | 0 |
| loss | 0 | 3 | 0 | 0 | |
| backward | 0 | 0 | 0 | 0 | |
| optimizer | 20 | 8 | 0.4 | 0 | 0 |
| training loop | 21 | 18 | 0.9 | 4 | 4 |
| sampling | 7 | 10 | 1.4 | 1 | 0 |
| text as tokens | 4 | 4 | 1.0 | 0 | 0 |
| run | 8 | 12 | 1.5 | 0 | 0 |
| checks | 4 | 12 | 3.0 | 0 | 0 |
| all | 145 | 132 | 0.9 | 22 | 23 |
| characters of code | Python | Viv | Viv / Python |
|---|---|---|---|
| imports | 188 | 189 | 1.0 |
| config | 307 | 362 | 1.2 |
| random draws | 496 | 0 | 0.0 |
| parameters and init | 318 | 937 | 2.9 |
| embedding | 0 | 0 | |
| attention | 552 | 231 | 0.4 |
| block | 385 | 155 | 0.4 |
| forward | 440 | 215 | 0.5 |
| loss | 0 | 86 | |
| backward | 0 | 0 | |
| optimizer | 714 | 400 | 0.6 |
| training loop | 996 | 627 | 0.6 |
| sampling | 227 | 287 | 1.3 |
| text as tokens | 163 | 159 | 1.0 |
| run | 333 | 398 | 1.2 |
| checks | 176 | 469 | 2.7 |
| all | 5295 | 4515 | 0.9 |
| the forward and backward defs | Python | Viv |
|---|---|---|
| defs | 5 | 4 |
| parameters, all of them together | 5 | 9 |
| output parameters (arrays the def itself writes results into) | 0 | 0 |
| most in one def | 1 (forward) | 3 (sequence_loss) |
Where the Viv is still longer
The code follows the house style: a step of one statement is one line (step mlp: X = X + relu2(rmsnorm(X) @ b.mlp.up) @ b.mlp.down), an array of records is built by a comprehension of constructor calls ([Block(c) for l to c.layers]), and no line is wider than 100 columns, so a record that does not fit puts one field per line.
- parameters and init (25 against 8): the torch layers make and size their own weights in their constructors (
nn.Linear(cfg.width, size), in the attention, block and forward regions); the Viv records only declare the matrices, and the constructor defsHead,BlockandGptbuild them (model = Gpt(cfg)). Counting the parameters is a def handed tofor_leaves(count_into, into a one-entry array), where torch writessum(p.numel() for p in model.parameters()): a closure cannot assign the running count it captured, andleaves(model)is not built. - run (12 against 8): the configuration is one line,
GptConfig { vocab: len(vocab) }, with the other sizes the record's field defaults; the seed is set beforeGpt(cfg)draws, the parameter count takes two lines, and the losses are printed by a loop after training, where torch prints insidetrain. - loss (3 against 0):
value_and_graddifferentiates a named def, so the loss issequence_loss, where torch callsF.cross_entropyinside the training loop. - sampling (10 against 7): the context is
take the last len(m.pos) elements of out, where torch writesout[-n:](a Viv slice does not clamp a negative start). - text as tokens (4 against 4):
unique(TEXT)forsorted(set(TEXT)), a map comprehension forstoi, andencodeanddecodebound to lambdas whose bodies are comprehensions, as the Python binds them. - checks (12 against 4): each check is a named
testblock, where the Python writes one bareassert. - training loop (18 against 21):
batchcuts the windows with two comprehensions (the second stacks the windows into a batch-by-context matrix), as torch stacks a list of slices.value_and_gradreturns the loss and the gradient as two names, so its step is braced, to store the loss. - block (5 against 11), attention (9 against 14) and forward (8 against 13) are shorter than torch's: the block is the paper's two lines,
X = X + self_attention(b.attn, rmsnorm(X))and the MLP's, one step each, and lib/nn'sattentionreturns only the head's output.
tiny_gpt_torch.py:8:1: not numeric Python: `import torch`
import torch
^^^^^^^^^^^^
py2viv does not translate PyTorch model code; write it in NumPy.
tiny_gpt_torch.py:9:1: not numeric Python: `import torch.nn`
import torch.nn as nn
^^^^^^^^^^^^^^^^^^^^^
py2viv does not translate PyTorch model code; write it in NumPy.
tiny_gpt_torch.py:10:1: not numeric Python: `import torch.nn.functional`
import torch.nn.functional as F
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
py2viv does not translate PyTorch model code; write it in NumPy.
tiny_gpt_torch.py:12:1: not numeric Python: a use of `torch` (from `torch`, refused at its import)
torch.set_default_dtype(torch.float64)
^^^^^
py2viv does not translate PyTorch model code; write it in NumPy.
tiny_gpt_torch.py:13:1: not numeric Python: a use of `torch` (from `torch`, refused at its import)
torch.set_num_threads(1)
^^^^^
py2viv does not translate PyTorch model code; write it in NumPy.
tiny_gpt_torch.py:46:56: not numeric Python: range of a float
u = [(self.rand(), self.rand()) for _ in range(rows * cols)]
^^^^^^^^^^^
Python raises TypeError: range takes ints.
tiny_gpt_torch.py:47:90: not numeric Python: iterating over a value of unknown type
z = [math.sqrt(-2.0 * math.log(u1)) * math.cos(2.0 * math.pi * u2) for u1, u2 in u]
^
Fix the refusal that produced it.
tiny_gpt_torch.py:48:16: not numeric Python: a use of `torch` (from `torch`, refused at its import)
return torch.tensor(z).reshape(rows, cols)
^^^^^
py2viv does not translate PyTorch model code; write it in NumPy.
tiny_gpt_torch.py:51:24: not numeric Python: `np.searchsorted`
return min(int(np.searchsorted(np.cumsum(p), self.rand(), side="right")), len(p) - 1)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Not in the NumPy mapping yet (tools/py2viv/SUBSET.md lists what is).
tiny_gpt_torch.py:54:2: not numeric Python: a decorator
@torch.no_grad()
^^^^^^^^^^^^^^^
Drop the decorator.
tiny_gpt_torch.py:68:12: not numeric Python: a class that inherits from `nn.Module`
class Head(nn.Module):
^^^^^^^^^
Viv records have no inheritance: put the fields and methods in one class, or write functions.
tiny_gpt_torch.py:78:17: not numeric Python: a class that inherits from `nn.Module`
class Attention(nn.Module):
^^^^^^^^^
Viv records have no inheritance: put the fields and methods in one class, or write functions.
tiny_gpt_torch.py:89:16: not numeric Python: a use of `torch` (from `torch`, refused at its import)
return x / torch.sqrt(x.pow(2).mean(-1, keepdim=True) + 1e-8)
^^^^^
py2viv does not translate PyTorch model code; write it in NumPy.
tiny_gpt_torch.py:92:13: not numeric Python: a class that inherits from `nn.Module`
class Block(nn.Module):
^^^^^^^^^
Viv records have no inheritance: put the fields and methods in one class, or write functions.
tiny_gpt_torch.py:104:11: not numeric Python: a class that inherits from `nn.Module`
class GPT(nn.Module):
^^^^^^^^^
Viv records have no inheritance: put the fields and methods in one class, or write functions.
tiny_gpt_torch.py:128:30: not numeric Python: the attribute `.T` of a value of unknown type (it comes from a construct refused elsewhere)
return newton_schulz(g.T, steps, a, b, c).T
^^^
Fix the refusal that produced it.
tiny_gpt_torch.py:129:12: not numeric Python: the method `.norm` of a value of unknown type (it comes from a construct refused elsewhere)
norm = g.norm()
^^^^^^^^
Fix the refusal that produced it.
tiny_gpt_torch.py:132:17: not numeric Python: the attribute `.T` of a value of unknown type (it comes from a construct refused elsewhere)
s = x @ x.T
^^^
Fix the refusal that produced it.
tiny_gpt_torch.py:137:12: not numeric Python: a class that inherits from `torch.optim.Optimizer`
class Muon(torch.optim.Optimizer):
^^^^^^^^^^^^^^^^^^^^^
Viv records have no inheritance: put the fields and methods in one class, or write functions.
tiny_gpt_torch.py:152:29: not numeric Python: the attribute `.context` of a value of unknown type (it comes from a construct refused elsewhere)
windows = len(tokens) - cfg.context
^^^^^^^^^^^
Fix the refusal that produced it.
tiny_gpt_torch.py:153:63: not numeric Python: the attribute `.batch` of a value of unknown type (it comes from a construct refused elsewhere)
starts = [(step * cfg.batch + b) % windows for b in range(cfg.batch)]
^^^^^^^^^
Fix the refusal that produced it.
tiny_gpt_torch.py:154:12: not numeric Python: a use of `torch` (from `torch`, refused at its import)
return torch.stack([tokens[s + shift : s + shift + cfg.context] for s in starts])
^^^^^
py2viv does not translate PyTorch model code; write it in NumPy.
tiny_gpt_torch.py:158:34: not numeric Python: the method `.named_parameters` of a value of unknown type (it comes from a construct refused elsewhere)
matrices = [p for name, p in model.named_parameters() if name.startswith("blocks.")]
^^^^^^^^^^^^^^^^^^^^^^^^
Fix the refusal that produced it.
tiny_gpt_torch.py:159:32: not numeric Python: the method `.named_parameters` of a value of unknown type (it comes from a construct refused elsewhere)
others = [p for name, p in model.named_parameters() if not name.startswith("blocks.")]
^^^^^^^^^^^^^^^^^^^^^^^^
Fix the refusal that produced it.
tiny_gpt_torch.py:160:53: not numeric Python: a use of `torch` (from `torch`, refused at its import)
opts = [Muon(matrices, MUON_LR, MUON_MOMENTUM), torch.optim.Adam(others, ADAM_LR, betas=ADAM_BETAS)]
^^^^^
py2viv does not translate PyTorch model code; write it in NumPy.
tiny_gpt_torch.py:161:110: not numeric Python: iterating over a value of unknown type
schedules = [torch.optim.lr_scheduler.LambdaLR(o, lambda s: 1.0 - (1.0 - LR_FLOOR) * s / steps) for o in opts]
^^^^
Fix the refusal that produced it.
tiny_gpt_torch.py:164:34: not numeric Python: the attribute `.cfg` of a value of unknown type (it comes from a construct refused elsewhere)
x, y = get_batch(tokens, model.cfg, step, 0), get_batch(tokens, model.cfg, step, 1)
^^^^^^^^^
Fix the refusal that produced it.
tiny_gpt_torch.py:165:18: not numeric Python: a call of `model`
logits = model(x)
^^^^^^^^
Not a def in this file and not in the numeric subset.
tiny_gpt_torch.py:166:16: not numeric Python: a use of `F` (from `torch.nn.functional`, refused at its import)
loss = F.cross_entropy(logits.view(-1, logits.shape[-1]), y.view(-1))
^
py2viv does not translate PyTorch model code; write it in NumPy.
tiny_gpt_torch.py:167:9: not numeric Python: the method `.zero_grad` of a value of unknown type (it comes from a construct refused elsewhere)
model.zero_grad()
^^^^^^^^^^^^^^^^^
Fix the refusal that produced it.
tiny_gpt_torch.py:168:9: not numeric Python: the method `.backward` of a value of unknown type (it comes from a construct refused elsewhere)
loss.backward()
^^^^^^^^^^^^^^^
Fix the refusal that produced it.
tiny_gpt_torch.py:169:18: not numeric Python: iterating over a value of unknown type
for o in opts + schedules:
^^^^^^^^^^^^^^^^
Loop over `range(len(a))` and index.
tiny_gpt_torch.py:171:23: not numeric Python: the method `.item` of a value of unknown type (it comes from a construct refused elsewhere)
losses.append(loss.item())
^^^^^^^^^^^
Fix the refusal that produced it.
tiny_gpt_torch.py:172:40: not numeric Python: the method `.item` of a value of unknown type (it comes from a construct refused elsewhere)
print(f"step {step + 1}: loss {loss.item():.6f}")
^^^^^^^^^^^
Fix the refusal that produced it.
tiny_gpt_torch.py:176:2: not numeric Python: a decorator
@torch.no_grad()
^^^^^^^^^^^^^^^
Drop the decorator.
tiny_gpt_torch.py:188:48: not numeric Python: iterating over a value of unknown type
decode = lambda ids: "".join(vocab[i] for i in ids)
^^^
Fix the refusal that produced it.
tiny_gpt_torch.py:194:32: not numeric Python: the method `.parameters`, which an object of class `GPT` does not have
count = sum(p.numel() for p in model.parameters())
^^^^^^^^^^^^^^^^^^
Python raises AttributeError.
tiny_gpt_torch.py:195:10: not numeric Python: printing a value of unknown type (it comes from a construct refused elsewhere)
print(f"{count} parameters, {len(vocab)} characters")
^^^^^
Fix the refusal that produced it.
tiny_gpt_torch.py:196:23: not numeric Python: a use of `torch` (from `torch`, refused at its import)
losses = train(model, torch.tensor(encode(TEXT)), STEPS)
^^^^^
py2viv does not translate PyTorch model code; write it in NumPy.
tiny_gpt_torch.py:198:7: not numeric Python: printing a value of unknown type (it comes from a construct refused elsewhere)
print(written)
^^^^^^^
Fix the refusal that produced it.
40 constructs outside the numeric subset (tools/py2viv/SUBSET.md) What they print
identical — the two programs print the same text.
Dimmed lines are left out of the comparison. Each Viv test block prints one PASS line; the Python checks the same facts with assert, which prints nothing.
$ python3 tiny_gpt_torch.py
6560 parameters, 9 charactersstep 1: loss 2.197225step 2: loss 2.102097step 3: loss 1.879860step 4: loss 1.575851step 5: loss 1.150941step 6: loss 1.198318step 7: loss 0.840376step 8: loss 0.673002step 9: loss 0.686226step 10: loss 0.541481step 11: loss 0.403199step 12: loss 0.305271step 13: loss 0.563067step 14: loss 0.306272step 15: loss 0.314534step 16: loss 0.195234step 17: loss 0.539175step 18: loss 0.290064step 19: loss 0.191980step 20: loss 0.334400step 21: loss 0.255869step 22: loss 0.229073step 23: loss 0.151354step 24: loss 0.341850step 25: loss 0.196461step 26: loss 0.141672step 27: loss 0.266627step 28: loss 0.179300step 29: loss 0.143552step 30: loss 0.199118hello world! hello world! hel
$ viv run tiny_gpt_torch.viv
6560 parameters, 9 charactersstep 1: loss 2.197225step 2: loss 2.102097step 3: loss 1.879860step 4: loss 1.575851step 5: loss 1.150941step 6: loss 1.198318step 7: loss 0.840376step 8: loss 0.673002step 9: loss 0.686226step 10: loss 0.541481step 11: loss 0.403199step 12: loss 0.305271step 13: loss 0.563067step 14: loss 0.306272step 15: loss 0.314534step 16: loss 0.195234step 17: loss 0.539175step 18: loss 0.290064step 19: loss 0.191980step 20: loss 0.334400step 21: loss 0.255869step 22: loss 0.229073step 23: loss 0.151354step 24: loss 0.341850step 25: loss 0.196461step 26: loss 0.141672step 27: loss 0.266627step 28: loss 0.179300step 29: loss 0.143552step 30: loss 0.199118hello world! hello world! helPASS the model has 6,560 parameters, and 9 characters make its vocabularyPASS the first loss is log 9 exactly, because the output map starts at zeroPASS thirty steps take the loss from 2.197 to 0.1991PASS from the prompt hello, the model writes the line on