A from-scratch character-level language model trained in native C++20 on Vulkan compute: a 3-layer gated recurrent network over a 108-token byte vocabulary with folded speaker slots, stateful backpropagation through time, and a 299M-token dialogue corpus. No external ML framework — every kernel is ours.
Cat Chat is a character-level autoregressive language model built on a 3-layer gated recurrent unit (GRU) stack. Text is encoded one token per character over a closed 108-token vocabulary — printable ASCII plus structural control tokens and eight folded speaker slots. Each token is mapped to a 2048-dimensional learned embedding, processed by three 2048-hidden GRU layers with residual connections and RMSNorm, and projected by an untied linear head to a 108-way softmax over the next character or speaker marker.
Training runs on Vulkan compute via the mixed precision path — cooperative-matrix
FP16 GEMMs with FP32 accumulation — inside a stateful truncated-BPTT regime: the batch is
32 contiguous corpus streams of 1024 tokens, and each lane's final hidden state is carried into the next
window as its initial state. Context therefore crosses window boundaries and even record boundaries, while
gradients remain bounded by the window length.
Run-2 uses a closed byte-level vocabulary — no BPE merges, no OOV table, no dynamic growth. One character is exactly one token, which makes the loss surface honest: every position is a real next-token prediction, and bits-per-byte is directly comparable to cross-entropy.
The eight speaker-slot tokens are the only turn-boundary mechanism. The
serializer folds each speaker into a conversation-local slot bound by first appearance, so a record reads
<spk0>hello<spk1>hi there<spk0>…. Every record opens with a speaker marker;
cost per turn is exactly one token, but the model now learns who is speaking — a next-speaker
prediction objective on top of next-token prediction. Conversations with more than eight participants
LRU-rebind the least-recently-active slot, matching the model's bounded context.
Dialogue is punctuation-, casing-, and timing-sensitive; BPE hides that structure inside merges and inflates the effective vocabulary. At V=108 the softmax head is ~221K parameters instead of millions, supervision density is maximal (one decision per byte), and the model can learn speaker identity, turn-taking, and typography end-to-end. The tradeoff — longer sequences for the same text — is exactly what stateful BPTT over a 1024-token window is designed to absorb.
Token ids index a learnable E ∈ ℝ^{108×2048} embedding table. On the GPU
training path the looked-up vector is scaled by √E (√2048 ≈ 45.25) to place the input at
GRU operating magnitude, then embedding dropout (p = 0.10) is applied:
Two regularizers bound the table: a row max-norm of 8.0 keeps rare-token rows from exploding, and the embedding itself is untied from the output classifier — input and output roles of a token learn separate geometry.
Each of the three layers is a standard GRU cell over 2048 hidden units. Per layer
l and timestep t, with input x (embedding output for l=0, previous
layer's normalized hidden state otherwise):
Between layers the output is passed through a residual add followed by RMSNorm, and hidden dropout (p = 0.15) is applied to the feed-forward input of the next layer — the residual taps the clean (undropped) path so gradient signal survives:
On the GPU the three input-side gate projections fuse into one GEMM of width
N = 3·2048 = 6144 (per-layer weight layout [W_z | W_r | W_h] contiguous), the recurrent z+r
projections fuse into a width-4096 hidden GEMM, and gate elementwise ops run as dedicated compute shaders
(gru_ew_*). Parameter cost per layer is 3·D·(Din+D) ≈ 25.2M, so the
recurrent stack is ≈75.5M of the ≈75.9M total.
A vanilla truncated-BPTT run resets hidden state at every window, forcing the model to
re-derive context every 1024 tokens. Cat Chat instead treats the corpus as a flat token
stream sliced across 32 batch lanes — each lane owns a contiguous span (~9,123 sequences of
state) — and carries the final hidden state of window k as the initial state of window
k+1:
reset_state_at_eos=false): record boundaries are data,
not resets — the model learns discourse-level transitions.The final hidden state h_t of layer 3 feeds an untied linear head over all 108
tokens:
Label smoothing ε = 0.10 redistributes 10% of target mass over non-targets —
with only 108 classes this is a mild calibration prior, not a flattening one. Speaker-slot tokens are
trained as ordinary targets: predicting <spk1> after a user turn is the
turn-taking signal. Loss is reported as cross-entropy and bits-per-byte (BPB = CE / ln 2 ≈
CE × 1.443, since one token is one byte).
Run-2 epoch 1 streams 291,930 training sequences (~298.9M tokens) in
preserved corpus order — preserve_record_order=true, no shuffling inside the epoch. Each
optimizer step consumes 32 sequences × 1024 tokens × 2 gradient-accumulation passes = 65,536
tokens, for 9,123 steps per epoch. Live progress: ~22% at step ~2000, train
avg loss ≈1.43, throughput ≈2,400–2,500 tokens/s on an RTX 3080 Laptop GPU.
Additional stabilizers: grad_h_clamp = 1000 bounds the hidden-state gradient,
vocab-embedding rows are constrained to max-norm 8.0, and classifier rows to 3.0. The run-1 spectral-norm
constraint is disabled in run-2 — learned σmax reached 29–48 unconstrained and
the clamp proved counterproductive.
The eval set is drawn by a seeded random-index mask stratified by mining source — fixing run-1's contiguous-tail bug that made eval 100% news transcripts. 223 held-out sequences (~227K token predictions) are scored teacher-forced with speaker prefixes kept, matching the training distribution. Latest: loss 1.159 · top-1 66.88% · top-5 90.26% · BPB 1.560 (step 2250).
Training and inference run entirely on Vulkan compute shaders — no CUDA, no framework. The
active gpu_precision = "mixed" path executes GEMMs on cooperative-matrix FP16 tensor
cores with FP32 accumulation (VK_KHR_cooperative_matrix), a validated +4.6% tokens/s over the
FP32 path with eval parity within 1e-4. Required device features (all present on the RTX 3080):
VK_KHR_cooperative_matrix — tensor-core GEMM tilesVK_KHR_shader_integer_dot_product + shaderInt8 — DP4A INT8 pathVK_EXT_shader_atomic_float — atomic gradient accumulationshaderFloat16, vulkanMemoryModel, timelineSemaphore,
maintenance4Three reduced-precision paths exist behind flags and parity gates: int8_train (HiLo
DP4A INT8×INT8 → INT32 backward GEMMs), int8_optimizer (blockwise INT8 m/√v moments with
FP32 master weights), and int8_act_cache (byte-per-element gate cache, ~4× smaller).
Acceptance requires a full-epoch parity run within ~0.005 loss of FP32. Known gap: INT8 was ~6% slower
because weights requantize every backward call — the fix is quantizing once per optimizer apply.
The v6 corpus was rebuilt from canonical raw sources through a six-stage conversation pipeline. Records are complete conversations — speaker slots folded, turns merged only within a speaker, malformed markup and degenerate repetition stripped — sorted easy→hard by a combined length/turns/TTR score, and streamed in order (a curriculum, not a shuffle).
website; control chars and artifacts normalizedEach checkpoint is a single 912 MB binary containing FP32 weights, Nadam
m/v moments, the BPTT carry buffer, telemetry accumulators, and a step/epoch trailer. Saves rotate every
5,000 steps (max 3) plus a persistent _best on held-out improvement — mid-epoch crashes lose
nothing:
| Component | Size | Purpose |
|---|---|---|
| GRU weights (3 layers) | ~302 MB | 3·D·(D_in+D) + biases per layer, FP32 |
| Token embedding + classifier | ~1.7 MB | 108×2048 + 2048×108 + biases |
| Nadam moments (m + v) | ~607 MB | FP32 optimizer state, all params |
| BPTT carry + RMSNorm + trailer | ~1.5 MB | h[L3·B32·D2048] · norm gains · step/epoch |
| Total | 912 MB | Exact-resume state (verified twice: OOM kills @ steps 1900 & 2250 → exact mid-epoch resumes) |
cat_chat train --config cat_chat_gpu_v6_r2.toml --resume detects the newest checkpoint
(_best wins ties), restores weights + moments + carry + accumulators, recomputes the WSD
schedule position from global_step, and continues mid-epoch from the recorded batch offset.
Proven live twice: OOM kills at steps ~1900 and ~2285 resumed at batches 1900 and 2250 with carry,
accumulators (epoch_loss=101170.5), and best-eval state fully intact.
A GRU is O(n) in sequence length with O(1) inference state — there is no KV cache to grow, no quadratic attention term, and the entire recurrence reduces to three GEMMs per layer per timestep, which maps cleanly onto cooperative-matrix Vulkan shaders. Combined with stateful carry, the effective context is unbounded while per-window compute stays fixed — the right trade for a model that must eventually run inside a game engine.
A closed byte-level vocab makes the model fully deterministic at the text boundary — every byte is representable, OOV is impossible by construction, and speaker-slot tokens give dialogue structure first-class status in the loss. It also keeps the output layer tiny (~0.3% of parameters) so nearly all capacity lives in the recurrent dynamics.
Text prefixes like ALICE: cost several tokens per turn and bake specific names
into the model. Folded <spkN> slots cost exactly one token, carry identity through the
carry state, and make next-speaker prediction an explicit learned objective — the mechanism the REPL and
Bard↔Bing harnesses drive (the harness schedules the marker; the model fills the turn).
Extending T grows activation memory linearly and attention-equivalent cost worse; carrying hidden state across fixed 1024-token windows achieves long-range context at constant cost — 32 lanes cover the full corpus each epoch with zero record fragmentation.
| Parameter | Value | Notes |
|---|---|---|
| Architecture | GRU-L3-V5 | 3-layer GRU, residual + RMSNorm, untied head |
| Hidden dim | 2048 ×3 layers | ~75.5M recurrent params |
| Token embedding | 2048-dim | √E input scaling, dropout 0.1, row max-norm 8.0 |
| Vocabulary | 108 | PAD/BOS/EOS/UNK + ASCII 95 + '\n' + <spk0–7> |
| Total parameters | ≈75.9 M | 912 MB checkpoint (w + Nadam m/v + carry) |
| Window / batch | T=1024 · B=32 | ×2 grad-accum → 65,536 tok/step |
| BPTT | Stateful, truncated | carry [L3·B·D] persisted; ∇ stop at window edge |
| Objective | Softmax CE | label smoothing 0.10 · teacher forcing 1.0 |
| Optimizer | Nadam (GPU) | lr 1e-3 · wd 0.01 · clip 2.5 · grad_h clamp 1000 |
| LR schedule | WSD cosine | warmup 2% → stable → decay 10% → lr_min 1e-4 |
| Hidden dropout | 0.15 | Feed-forward input only; residual taps clean path |
| Precision | mixed (FP16 coopmat + FP32 accum) | +4.6% tk/s vs FP32 · eval parity ≤1e-4 |
| Epoch 1 size | 291,930 seqs · 9,123 steps | ~298.9M tokens, order preserved |
| Eval | 223 held-out seqs | Seeded stratified mask · prefixes kept |
| Throughput | ~2,400–2,500 tok/s | RTX 3080 Laptop · ~33–34 h/epoch |
| Checkpointing | every 5,000 steps + _best | max 3 rotating · exact mid-epoch resume |
| INT8 paths | int8_train / int8_optimizer / int8_act_cache | Experimental — parity-gated, off in run-2 |
Live numbers, related runtimes, and product context for the language-model program.