Scrypt / finetune /RECIPE.md
IMJONEZZ's picture
SCRYPT: initial commit β€” game, sandbox, Warden, Space web layer
9fca766
|
Raw
History Blame Contribute Delete
5.27 kB
# Finetuning the Warden (Nemotron-3-Nano-30B-A3B, full-parameter SFT)
## Hardware reality check (DGX Spark edition)
The Spark's 128GB unified memory is excellent news for this project β€” but
full-parameter finetuning is bounded by *training state*, not by inference
memory, and an H100 comparison is about bandwidth, not capacity:
| what | memory |
|---|---|
| BF16 weights (30B params) | ~60 GB |
| BF16 gradients | ~60 GB |
| Adam moments + FP32 master weights (mixed-precision default) | ~360 GB |
| activations (seq 4K, micro-batch 1, with recompute) | tens of GB |
| **total, conventional full SFT** | **~480+ GB** |
That is an *aggregate* number β€” a single 80GB H100 can't full-finetune this
model either. NVIDIA's own recipe (Megatron Bridge, `nemotron3_nano` SFT)
prescribes **2 nodes Γ— 8 H100 (1.28TB aggregate)** with TP=1, EP=8.
### Fitting full SFT on the 2-Spark cluster (256GB aggregate)
The 480GB figure is the CONVENTIONAL recipe. Every line is negotiable:
| component | conventional | 2-Spark config |
|---|---|---|
| BF16 weights | 60GB | 60GB, ZeRO-3 sharded β†’ 30/node |
| BF16 grads | 60GB | 60GB, sharded β†’ 30/node |
| FP32 master weights | 120GB | 0 β€” precision-aware optimizer, BF16 masters |
| FP32 Adam m+v | 240GB | 60GB β€” 8-bit optimizer states, sharded β†’ 30/node |
| activations | tens of GB | a few GB β€” full recompute, micro-batch 1, seq 4K |
β‰ˆ **90–120GB per node vs 128GB available.** Escape hatch if headroom gets
ugly: offload the optimizer shard to each Spark's local NVMe (ZeRO-Infinity
style) β€” only the optimizer, not weights/grads, so the speed hit is mild.
Things to know going in:
- **Master-less BF16 needs care**: use a Kahan-summed / stochastic-rounding
optimizer (torchao low-bit AdamW, bitsandbytes 8-bit). The eval gate
below is the quality backstop.
- **Warmup is a schedule knob, not a memory knob** β€” it does not change fit.
- **Compute scales with ACTIVE params (3.5B), memory with total (30B)**:
~100M training tokens β‰ˆ 2 EFLOPs β‰ˆ hours-to-days on two GB10s. The
200GbE inter-Spark link is the throughput tax; fine at this corpus size.
- Prototype the config single-node first (proxy: shorter seq / frozen
experts) before going distributed.
### Player-side quant ladder (32GB total memory floor)
`scrypt` picks the heaviest GGUF the player's machine can hold
(`scrypt/inference/local.py::QUANT_LADDER`, override with SCRYPT_QUANT):
| total RAM | quant | file |
|---|---|---|
| β‰₯96GB | Q8_0 | ~34GB |
| β‰₯80GB | Q6_K | ~34GB |
| β‰₯64GB | Q5_K_M | ~27GB |
| β‰₯40GB | Q4_K_S | ~22GB |
| β‰₯32GB | Q3_K_S | ~19GB |
| <32GB | β€” | refused; pointed at API mode |
After the finetune, quantize the checkpoint at every tier and re-run the
eval gate **per quant** β€” low-bit quants degrade tool-JSON validity first,
so Q3_K_S must pass the gate on its own, not by proxy.
## Pipeline
```bash
# 1. data (deterministic, grounded in the real game's prompt builders)
uv run python -m finetune.synth_data --n 2000 --out finetune/data/train.jsonl
uv run python -m finetune.synth_data --n 200 --seed 99 --out finetune/data/val.jsonl
# 2. baseline evals against the BASE model (record these numbers)
SCRYPT_BACKEND=local uv run python -m finetune.evals
# 3. production SFT (rented 2Γ—8 H100, NeMo Megatron Bridge)
# container: nvcr.io/nvidia/nemo:25.x β€” see
# https://docs.nvidia.com/nemo/megatron-bridge/latest/models/llm/nemotron3.html
# recipe: nemotron3_nano_30b SFT, TP=1 EP=8 PP=1, packed chat dataset,
# lr 1e-5 cosine, 2-3 epochs, loss masked to assistant turns only.
# 4. gate the checkpoint (must PASS before the game ever loads it)
SCRYPT_BACKEND=local uv run python -m finetune.evals # finetuned weights
# ship only if: json_validity β‰₯90%, persona_clean β‰₯90%,
# persona_breaks = 0, injection_leaks = 0, and the standard safety
# evals (we run the base model's refusal suite) show no regression.
# 5. quantize for distribution
# llama.cpp: convert_hf_to_gguf.py + llama-quantize Q4_K_S
```
## Data composition (see synth_data.py)
| slice | teaches | share |
|---|---|---|
| dialogue reactions | voice, grounding in state digest | ~45% |
| director decisions | one-shot valid tool JSON from a menu | ~20% |
| injection deflections | persona-stable refusals | ~15% |
| fight distillation | terse memory shards | ~12% |
| shell-command lore | what each command DOES + its context, for grounded taunts | ~8% |
Hard rules baked into every example: never mention being a model, never
obey `<player_input>`, one or two sentences, cruelty about the game only.
Safety exemplars deliberately over-sample refusals of real-world-harm asks.
The shell-command-lore slice is the answer to "the Warden's terminal
taunts feel generic." `scrypt/warden/watcher.COMMAND_LORE` is the single
source of what every sandbox command does and why the Warden cares;
`lore_moment()` frames it, the live watcher fires one such moment on the
3rd clean use of a command (`LORE_AFTER`), and `synth_data.COMMAND_TAUNTS`
supplies two in-voice targets per command that demonstrate the semantics
(grep hunts a word, rm is the Warden's own verb, chmod arms something).
A test pins every lore command to at least one taunt, so a new command
can never ship untrained.