Loading…
gathering yarn balls…
Current Stage
Initializing
—
Stage 0 / 0
Cat Chat · GRU-L3-V5
ArchitectureGRU ×3, char-level
Hidden Dim2048
Token Embed2048-dim learnable
Vocab108 (ASCII + spk0–7)
Parameters≈75.9 M
WindowT=1024 · B=32
BPTTstateful carry
LossSoftmax CE (ε=0.10)
OptimizerNadam · WSD
Precisionmixed FP16+FP32
Checkpoint912 MB
1 / 15
Speed 1.0×
Drag to pan · Click a block to inspect · ← → to step
01
—
—
—
Implementation—
Detail—
Tensor Shape—
Group—
Next Operation —
Scroll for the full essay ↓
Cat Game Research · Language Models

Cat Chat — GRU-L3-V5

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.

Live
September 2026 — Run 2, Epoch 1 in progress: held-out eval 1.159 · top-1 66.9% · top-5 90.3% at step 2250/9123, training on an RTX 3080 (mixed-precision coopmat path). Live telemetry & graphs →
108
Vocab Tokens
2048
Hidden / Embed Dim
75.9M
Parameters
1024
Token Window
65,536
Tokens / Step
299M
Corpus Tokens

Architecture Overview

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.

Cat Chat Data Flow — one training window
Dialogue Record <spk0>…<spk1>…EOS ASCII Tokenizer V=108 · 1 char = 1 id Token Window B32 × T1024 flat stream ×√E Token Embedding 108×2048 · dropout 0.1 GRU ×3 — D = 2048 z, r = σ(·) · h̃ = tanh(·) · h = (1−z)⊙h′ + z⊙h̃ L1 GRU +res → RMSNorm L2 GRU +res → RMSNorm → drop 0.15 L3 GRU +res → RMSNorm → drop 0.15 Carry State h_init ← h_final L3·B32·D2048 ∇ truncated h_prev (t=0) Untied Head 2048 → 108 logits Softmax CE ε=0.10 · all positions ∇ truncated BPTT through T=1024 · clip 2.5 · Nadam (GPU) inference: sample → decode → feed back

The 108-Token Vocabulary

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.

# vocabulary layout (target_vocab_size = 108)
ids 0–3   PAD · BOS · EOS · UNK
ids 4–98  printable ASCII (95 chars)
id  99   '\n' newline
ids 100–107 <spk0> … <spk7> speaker slots

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.

Why character-level?

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 Embedding

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:

xt = ( E[idt] · √2048 ) ⊙ mt,  mt ~ Bernoulli(0.90)/0.90 // inverted dropout, E_dim = 2048

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.

The GRU ×3 Stack

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):

zt = σ( Wz·[xt | ht−1] + bz ) // update gate
rt = σ( Wr·[xt | ht−1] + br ) // reset gate
h̃t = tanh( Wh·[xt | rt⊙ht−1] + bh ) // candidate
ht = (1 − zt) ⊙ ht−1 + zt ⊙ h̃t // state

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:

h̃(l) = GRU(l)( x(l), hprev(l) )
hnorm(l) = RMSNorm( h̃(l) + x(l) ) // l=0: residual from scaled embedding
x(l+1) = Drop0.15( hnorm(l) )

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.

Stateful BPTT — Memory Across Windows

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:

hinit(k+1) = stop-grad( hfinal(k) ) // forward continues, ∇ does not cross the boundary
carry ∈ ℝL=3 × B=32 × D=2048 // bptt_carry.comp — persisted in every checkpoint

Untied Classifier & Objective

The final hidden state h_t of layer 3 feeds an untied linear head over all 108 tokens:

logitst = Wcls·ht(3) + bcls,  Wcls ∈ ℝ108×2048
pt = softmax( logitst ),  L = −Σt log pt(yt) // teacher forcing, yt = idt+1

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).

Training Loop — Run 2, Epoch 1

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.

Held-Out Eval Loss — Run 2
223 held-out seqs · 227K predictions · teacher-forced · real telemetry (steps 50–2250)
3.0 2.4 1.8 1.2 0.6 0 0 500 1000 1500 2000 2250 2.98 1.159
Held-Out Accuracy — Run 2
top-1 and top-5 next-token accuracy · same eval windows
100% 80% 60% 40% 20% 0% 0 500 1000 1500 2000 2250 90.3% 66.9% Top-5 Top-1

Optimizer & Schedule

ĝ = g / max(1, ‖g‖₂ / 2.5) // global-norm clip
m̂, v̂ ← Nadam( ĝ; β₁=0.9, β₂=0.999, wd=0.01, lr=ηt ) // Nesterov lookahead, on GPU
ηt = WSD: linear warmup 2% (182 steps) → stable 1e-3 → cosine decay → 1e-4 over final 10% (912 steps)

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.

Held-Out Evaluation

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).

Vulkan Execution & Precision

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):

Experimental — INT8 research (config-gated, off in run-2)

Three 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.

Corpus v6 — Dialogue-First Pretraining

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).

Checkpoint & Resume

Each 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)
Resume Training Flow

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.

Key Design Decisions

Why GRU instead of a transformer?

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.

Why 108 tokens instead of BPE?

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.

Why speaker slots instead of name prefixes?

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).

Why stateful BPTT instead of longer windows?

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.

Technical Specifications

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

Codebase Map

Explore More

Live numbers, related runtimes, and product context for the language-model program.

Live Telemetry
LLM Bench →
Run-1/Run-2 loss, accuracy, gradient norms, and throughput graphs — updated as Epoch 1 progresses.
Engine Mod
π Agent Mod
The companion runtime: policy-gated local inference over GGUF models with client-side tools — separate from this from-scratch stack.
API Docs
NN / LM Reference
Doxygen coverage of libv_nn — the shared library behind the trainer and inference path.
Products
Cat Chat
Product card and feature list for the GRU training and inference pipeline.