|
| 1 | +# Benchmark Architecture Fixes TODO |
| 2 | + |
| 3 | +**Created:** 2026-02-19 |
| 4 | +**Context:** 34-architecture GPU benchmark on RTX 4090, 30 Zelda replays, 3 epochs |
| 5 | +**Results:** 30/34 converged. 4 remaining: FNet (complex grad), TTT (NaN), KAN (OOM), H3 (underfitting) |
| 6 | + |
| 7 | +--- |
| 8 | + |
| 9 | +## 1. TTT — NaN (Edifice-level init fix required) [P1] |
| 10 | + |
| 11 | +**Status:** Still NaN at LR=5e-7 / clip=0.1. Needs Edifice source fix, not just hyperparams. |
| 12 | +**File:** `edifice/lib/edifice/recurrent/ttt.ex` |
| 13 | +**Paper:** "Learning to (Learn at Test Time)" (Sun et al., 2024) — https://arxiv.org/abs/2407.04620 |
| 14 | + |
| 15 | +The current implementation is missing 5 critical stability mechanisms from the paper. Each fix is independent and can be applied incrementally. |
| 16 | + |
| 17 | +### Fix 1: W_0 initialization — N(0, 0.02) not glorot_uniform |
| 18 | + |
| 19 | +**Location:** `ttt.ex:223-224` |
| 20 | +```elixir |
| 21 | +# CURRENT (wrong): |
| 22 | +w0_param = Axon.param("#{name}_w0", {inner_size, inner_size}, initializer: :glorot_uniform) |
| 23 | + |
| 24 | +# FIX: Use N(0, 0.02) per Section 4.1 of the paper |
| 25 | +w0_param = Axon.param("#{name}_w0", {inner_size, inner_size}, |
| 26 | + initializer: fn shape, type, _key -> |
| 27 | + Nx.Random.normal(Nx.Random.key(42), shape: shape, type: type) |
| 28 | + |> elem(0) |
| 29 | + |> Nx.multiply(0.02) |
| 30 | + end) |
| 31 | +``` |
| 32 | + |
| 33 | +**Why:** Glorot for a 64x64 matrix gives stddev ~0.18, which is 9x larger than the paper's 0.02. The inner model's self-supervised gradient update `W -= eta * error * k^T` amplifies any large initial weight through the prediction error. Small init keeps early predictions near zero, preventing gradient explosion in the first few steps. |
| 34 | + |
| 35 | +### Fix 2: Eta scaling by 1/head_dim (CRITICAL) |
| 36 | + |
| 37 | +**Location:** `ttt.ex:265` |
| 38 | +```elixir |
| 39 | +# CURRENT (wrong): |
| 40 | +eta = Nx.sigmoid(eta_pre) |
| 41 | + |
| 42 | +# FIX: Scale by 1/inner_size (= 1/head_dim in paper's multi-head formulation) |
| 43 | +eta = Nx.divide(Nx.sigmoid(eta_pre), inner_size) |
| 44 | +``` |
| 45 | + |
| 46 | +**Why:** This is the single most likely cause of NaN. Without the 1/d scaling, eta values after sigmoid are in [0, 1], but the paper uses eta in [0, 1/d] where d=64. This means our learning rate is 64x too large for the inner model update. The gradient step `W -= eta * error * k^T` produces rank-1 updates of magnitude `eta * ||error|| * ||k||`. With eta=0.5 instead of 0.5/64=0.0078, the weight matrix changes dramatically each step, causing prediction errors to explode. |
| 47 | + |
| 48 | +### Fix 3: Reconstruction target — V-K (residual) not raw V |
| 49 | + |
| 50 | +**Location:** `ttt.ex:309-310` (linear variant) and `ttt.ex:289` (MLP variant) |
| 51 | +```elixir |
| 52 | +# CURRENT (wrong): |
| 53 | +error = Nx.subtract(pred, v_t) |
| 54 | + |
| 55 | +# FIX: Use residual reconstruction target |
| 56 | +error = Nx.subtract(pred, Nx.subtract(v_t, k_t)) |
| 57 | +``` |
| 58 | + |
| 59 | +**Why:** The paper trains the inner model to predict the *residual* between V and K, not V directly. This centers the reconstruction target around zero (since K and V are both projections of the same input), making the loss landscape much smoother. Without this, the inner model must learn the full magnitude of V, which means W_0 must already be a good predictor — at odds with near-zero initialization. |
| 60 | + |
| 61 | +### Fix 4: LayerNorm on inner model output before loss |
| 62 | + |
| 63 | +**Location:** `ttt.ex:306-310` — add LayerNorm before computing error |
| 64 | + |
| 65 | +This requires pre-building a learnable LayerNorm (gamma/beta params) and applying it inside the scan. Since Axon.layer_norm can't be used inside a raw Nx computation, implement manually: |
| 66 | + |
| 67 | +```elixir |
| 68 | +# Add as Axon.param in build_ttt_layer (alongside w0_param): |
| 69 | +ln_gamma = Axon.param("#{name}_inner_ln_gamma", {inner_size}, |
| 70 | + initializer: :ones) |
| 71 | +ln_beta = Axon.param("#{name}_inner_ln_beta", {inner_size}, |
| 72 | + initializer: :zeros) |
| 73 | + |
| 74 | +# In the scan, after computing pred: |
| 75 | +pred_normed = manual_layer_norm(pred, ln_gamma, ln_beta) |
| 76 | +error = Nx.subtract(pred_normed, Nx.subtract(v_t, k_t)) |
| 77 | + |
| 78 | +# Helper function: |
| 79 | +defp manual_layer_norm(x, gamma, beta) do |
| 80 | + mean = Nx.mean(x, axes: [-1], keep_axes: true) |
| 81 | + var = Nx.variance(x, axes: [-1], keep_axes: true) |
| 82 | + Nx.add(Nx.multiply(Nx.divide(Nx.subtract(x, mean), Nx.sqrt(Nx.add(var, 1.0e-6))), gamma), beta) |
| 83 | +end |
| 84 | +``` |
| 85 | + |
| 86 | +**Why:** LayerNorm on the inner model's output (before computing loss gradient) prevents the prediction magnitudes from drifting as W changes. Without it, the gradient magnitude is proportional to `||pred||` which grows unboundedly as W accumulates updates. |
| 87 | + |
| 88 | +### Fix 5: Learnable ttt_base_lr (optional, lower priority) |
| 89 | + |
| 90 | +**Location:** `ttt.ex:213` (eta_proj) |
| 91 | + |
| 92 | +Currently eta comes from `sigmoid(W_eta @ x)`. The paper uses `sigmoid(W_eta @ x) * ttt_base_lr / head_dim` where `ttt_base_lr` is a learnable scalar initialized to 1.0. |
| 93 | + |
| 94 | +```elixir |
| 95 | +# Add as Axon.param: |
| 96 | +base_lr = Axon.param("#{name}_base_lr", {1}, |
| 97 | + initializer: :ones) |
| 98 | + |
| 99 | +# In scan, after computing eta: |
| 100 | +eta = Nx.divide(Nx.multiply(Nx.sigmoid(eta_pre), Nx.abs(base_lr)), inner_size) |
| 101 | +``` |
| 102 | + |
| 103 | +**Why:** The learnable base_lr lets the model discover its own optimal inner learning rate during training. Less critical than fixes 1-4 but improves convergence. |
| 104 | + |
| 105 | +### Implementation order |
| 106 | + |
| 107 | +1. **Fix 2 (eta scaling)** — most likely NaN cause, 1-line change |
| 108 | +2. **Fix 1 (W_0 init)** — 3-line change, independent |
| 109 | +3. **Fix 3 (V-K target)** — 1-line change per variant, independent |
| 110 | +4. **Fix 4 (LayerNorm)** — most complex, requires new params in Axon.layer |
| 111 | +5. **Fix 5 (base_lr)** — optional, try after 1-4 |
| 112 | + |
| 113 | +### Testing |
| 114 | + |
| 115 | +After applying fixes in Edifice: |
| 116 | +```bash |
| 117 | +cd edifice && mix test test/edifice/recurrent/ttt_test.exs |
| 118 | +``` |
| 119 | + |
| 120 | +Then in ExPhil: |
| 121 | +```bash |
| 122 | +mix deps.update edifice && mix compile --force |
| 123 | +./scripts/benchmark_isolated.sh --replays /workspace/replays/greg/zelda --only ttt --epochs 3 --cache-embeddings |
| 124 | +``` |
| 125 | + |
| 126 | +Expected: should converge at LR=5e-7 with no NaN. If stable, try relaxing to LR=1e-5. |
| 127 | + |
| 128 | +--- |
| 129 | + |
| 130 | +## 2. FNet — Complex gradient crash (Edifice fix applied, needs verification) [P1] |
| 131 | + |
| 132 | +**Status:** Fix pushed to Edifice (commit fc5c852) but never successfully run on GPU pod. |
| 133 | +**File:** `edifice/lib/edifice/attention/fnet.ex` (already fixed) |
| 134 | + |
| 135 | +### What was done |
| 136 | + |
| 137 | +Replaced `Nx.fft` with real-valued DFT matrix multiply: |
| 138 | +- Precompute cosine DFT matrices at build time: `DFT[k,n] = cos(2*pi*k*n/N)` |
| 139 | +- Use `Nx.dot(x, dft_matrix)` instead of `Nx.fft(x) |> Nx.real()` |
| 140 | +- All operations stay in real f32 space, so gradients never touch complex numbers |
| 141 | +- DFT matrices passed as `Axon.constant` nodes |
| 142 | + |
| 143 | +### To verify on next pod run |
| 144 | + |
| 145 | +```bash |
| 146 | +cd /app |
| 147 | +git pull origin main |
| 148 | +mix deps.update edifice && mix compile --force |
| 149 | +./scripts/benchmark_isolated.sh --replays /workspace/replays/greg/zelda --only fnet --epochs 3 --cache-embeddings |
| 150 | +``` |
| 151 | + |
| 152 | +**Expected:** Should train normally. Quality should be mid-range (3.0-3.2 val loss) since FFT mixing is parameter-free and less expressive than learned attention. |
| 153 | + |
| 154 | +### If still failing |
| 155 | + |
| 156 | +The error would be the same `Nx.less/2 does not support complex inputs` — this would mean the dep didn't update properly. Debug: |
| 157 | +```elixir |
| 158 | +# In iex, verify the fix is loaded: |
| 159 | +Edifice.Attention.FNet.dft_real_matrix(4) |> Nx.shape() |
| 160 | +# Should return {4, 4} |
| 161 | +``` |
| 162 | + |
| 163 | +--- |
| 164 | + |
| 165 | +## 3. KAN — OOM (permanent, skip for benchmark) [P3] |
| 166 | + |
| 167 | +**Status:** OOM at batch_size=4, hidden=64, grid=4 on RTX 4090 (24GB). Fundamental memory issue. |
| 168 | +**File:** `edifice/lib/edifice/feedforward/kan.ex` |
| 169 | + |
| 170 | +### Root cause |
| 171 | + |
| 172 | +KAN's B-spline basis expansion creates `hidden * grid_size` intermediate tensors per layer in the backward pass. For a 2-layer network with hidden=64, grid=4: |
| 173 | +- Forward: each layer expands 64 inputs to 64*4=256 basis functions |
| 174 | +- Backward: EXLA materializes gradient for each basis coefficient |
| 175 | +- The gradient graph for even this tiny config exceeds 24GB |
| 176 | + |
| 177 | +### Options (in order of preference) |
| 178 | + |
| 179 | +**Option A: Skip permanently in benchmark** (recommended) |
| 180 | +KAN is designed for function approximation, not sequence modeling. Its O(hidden * grid * layers) memory scaling in backward pass makes it impractical for GPU training at any reasonable size. |
| 181 | + |
| 182 | +**Option B: CPU-only training (very slow)** |
| 183 | +```elixir |
| 184 | +# In benchmark config: |
| 185 | +{:kan, "KAN (Kolmogorov-Arnold)", [ |
| 186 | + backend: :binary, # Force CPU |
| 187 | + batch_size: 1, |
| 188 | + hidden_sizes: [32, 32], |
| 189 | + grid_size: 3, |
| 190 | + ... |
| 191 | +]} |
| 192 | +``` |
| 193 | +Would take hours per epoch. Not worth it for a architecture that's fundamentally mismatched. |
| 194 | + |
| 195 | +**Option C: Gradient checkpointing (Edifice change)** |
| 196 | +Implement activation checkpointing in KAN's `basis_expansion` to trade compute for memory. This is a significant engineering effort for marginal benefit. |
| 197 | + |
| 198 | +### Recommendation |
| 199 | + |
| 200 | +Mark KAN as "skipped (OOM)" in benchmark results. It's a fascinating architecture for interpretable function approximation but not viable for sequence modeling at useful scales on consumer GPUs. |
| 201 | + |
| 202 | +--- |
| 203 | + |
| 204 | +## 4. H3 — Underfitting at LR=5e-7 (needs LR warmup) [P2] |
| 205 | + |
| 206 | +**Status:** Converges! val=3.6215 after 3 epochs. But severely underfitting (train loss 3.84 > val loss, loss still dropping at epoch 3). LR=5e-7 is too conservative. |
| 207 | +**File:** `exphil/scripts/benchmark_architectures.exs:396-409` |
| 208 | + |
| 209 | +### Root cause |
| 210 | + |
| 211 | +H3 uses learnable `a_log` and `dt_log` parameters with exponential parameterization (`exp(a_log)`). The gradient of `exp(x)` is `exp(x)` — a positive feedback loop. At LR=1e-5, this explodes to NaN. At LR=5e-7, it's stable but converges glacially. |
| 212 | + |
| 213 | +### Fix: LR warmup schedule |
| 214 | + |
| 215 | +The ideal approach is linear warmup from 5e-7 to 1e-5 over epoch 1, then constant: |
| 216 | + |
| 217 | +**Option A: In ExPhil benchmark script** (easiest) |
| 218 | + |
| 219 | +Add a `warmup_steps` parameter to the benchmark config: |
| 220 | +```elixir |
| 221 | +{:h3, "H3 (Hungry Hippos)", [ |
| 222 | + temporal: true, |
| 223 | + backbone: :h3, |
| 224 | + window_size: 30, |
| 225 | + num_layers: 2, |
| 226 | + hidden_sizes: [256, 256], |
| 227 | + batch_size: 64, |
| 228 | + dropout: 0.1, |
| 229 | + learning_rate: 1.0e-5, # Target LR (back to normal) |
| 230 | + max_grad_norm: 0.1, # Keep tight clipping |
| 231 | + warmup_epochs: 1, # Warmup over first epoch |
| 232 | + warmup_start_lr: 5.0e-7 # Start from conservative LR |
| 233 | +]} |
| 234 | +``` |
| 235 | + |
| 236 | +This requires the benchmark training loop to support LR warmup. Check if `ExPhil.Training.Imitation.Optimizer` already supports warmup (it likely does via `warmup_steps` or similar). |
| 237 | + |
| 238 | +**Option B: Just run more epochs** (simplest) |
| 239 | + |
| 240 | +The loss was still dropping at epoch 3. Running 10 epochs at LR=5e-7 might get H3 to a competitive val loss (~3.0-3.1). Less optimal but zero code changes: |
| 241 | +```bash |
| 242 | +./scripts/benchmark_isolated.sh --replays /workspace/replays/greg/zelda --only h3 --epochs 10 --cache-embeddings |
| 243 | +``` |
| 244 | + |
| 245 | +**Option C: Intermediate LR** (compromise) |
| 246 | + |
| 247 | +Try LR=1e-6 (5x current, 10x below NaN threshold): |
| 248 | +```elixir |
| 249 | +learning_rate: 1.0e-6, # Middle ground between 5e-7 (stable, slow) and 1e-5 (NaN) |
| 250 | +``` |
| 251 | + |
| 252 | +### Recommendation |
| 253 | + |
| 254 | +Try Option C first (1-line change, quick to verify), then Option B (10 epochs) if stable. |
| 255 | + |
| 256 | +--- |
| 257 | + |
| 258 | +## 5. Hopfield — Overfitting (needs dropout) [P2] |
| 259 | + |
| 260 | +**Status:** Converges at val=3.1276, but best was epoch 2 (val=2.9532) before overfitting in epoch 3. Gap of 0.18 between epochs 2-3 is significant. |
| 261 | +**File:** `exphil/scripts/benchmark_architectures.exs:528-541` |
| 262 | + |
| 263 | +### Root cause |
| 264 | + |
| 265 | +Hopfield's pattern matrix creates a very expressive model that memorizes training data quickly. At batch_size=4 and hidden=128, there are few regularization constraints. |
| 266 | + |
| 267 | +### Fix: Add dropout |
| 268 | + |
| 269 | +```elixir |
| 270 | +{:hopfield, "Hopfield (Associative Memory)", [ |
| 271 | + temporal: true, |
| 272 | + backbone: :hopfield, |
| 273 | + window_size: 30, |
| 274 | + num_layers: 2, |
| 275 | + num_heads: 2, |
| 276 | + hidden_sizes: [128, 128], |
| 277 | + hidden_size: 128, |
| 278 | + batch_size: 4, |
| 279 | + dropout: 0.2, # ADD: regularization to prevent overfitting |
| 280 | + max_grad_norm: 1.0 |
| 281 | +]} |
| 282 | +``` |
| 283 | + |
| 284 | +Also consider: |
| 285 | +- `learning_rate: 5.0e-4` (slightly lower than default 1e-3) |
| 286 | +- Running 5 epochs with early stopping (best-epoch tracking already exists) |
| 287 | + |
| 288 | +### Expected outcome |
| 289 | + |
| 290 | +With dropout=0.2, the epoch 2→3 overfitting gap should narrow. True val loss likely around 2.95-3.05. |
| 291 | + |
| 292 | +--- |
| 293 | + |
| 294 | +## 6. Longer runs for top-5 [P3] |
| 295 | + |
| 296 | +Once the above fixes are applied, the next benchmark phase is longer training: |
| 297 | + |
| 298 | +```bash |
| 299 | +./scripts/benchmark_isolated.sh \ |
| 300 | + --replays /workspace/replays/greg/zelda \ |
| 301 | + --only s4,xlstm,zamba,s4d,gru \ |
| 302 | + --epochs 10 \ |
| 303 | + --cache-embeddings |
| 304 | +``` |
| 305 | + |
| 306 | +These 5 architectures are within 0.12 of each other (2.82-2.94). 10 epochs will reveal true convergence behavior — some may still be improving, others may plateau. |
| 307 | + |
| 308 | +--- |
| 309 | + |
| 310 | +## Summary Table |
| 311 | + |
| 312 | +| Architecture | Issue | Fix Location | Complexity | Priority | |
| 313 | +|---|---|---|---|---| |
| 314 | +| TTT | NaN | Edifice `ttt.ex` | High (5 changes) | P1 | |
| 315 | +| FNet | Complex grad | Already fixed | Verify only | P1 | |
| 316 | +| KAN | OOM | N/A | Skip | P3 | |
| 317 | +| H3 | Underfitting | ExPhil benchmark config | Low (1 line) | P2 | |
| 318 | +| Hopfield | Overfitting | ExPhil benchmark config | Low (1 line) | P2 | |
| 319 | + |
| 320 | +**Quick wins (next pod session):** |
| 321 | +1. Verify FNet (just `mix deps.update edifice`) |
| 322 | +2. Try H3 at LR=1e-6 |
| 323 | +3. Add dropout=0.2 to Hopfield |
| 324 | +4. Start TTT Edifice fixes (eta scaling first) |
0 commit comments