Foundry/barswap/round-robin-training.md

11 KiB

Round-Robin Training Across 6 K80 Dies

Concept

Train up to 6 models/adapters concurrently by time-slicing across all 6 K80 dies. Each die holds its own model + optimizer state in VRAM persistently. A scheduler daemon cycles through dies, running one training step per visit, then moving on.

Not truly parallel (one die computes at a time) but concurrent — all 6 make progress. The 0.3ms swap cost is negligible compared to training step time.

Why It Works

  • VRAM persists across bar_swap release/acquire (Architecture A, confirmed)
  • CUDA context survives the swap — no weight re-upload, no driver reload
  • Each die has 12GB VRAM (K80) — enough for models up to ~6B INT8 with optimizer state
  • Swap overhead: 0.3ms per die switch vs hundreds of ms per training step

Architecture

┌─────────────────────────────────────────────┐
│              Scheduler Daemon                │
│                                              │
│  for each round:                             │
│    for die_id in 0..5:                       │
│      1. bar_swap acquire(die_id)    ~0.3ms   │
│      2. set CUDA context for die             │
│      3. run N training steps                 │
│      4. bar_swap release(die_id)    ~0.1ms   │
│                                              │
│  repeat until all jobs report done           │
└─────────────────────────────────────────────┘

VRAM per die (persistent):
  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐
  │   Die 0      │  │   Die 1      │  │   Die 2      │
  │  3.6B LoRA   │  │  3.6B LoRA   │  │  0.2B full   │
  │  adapter A   │  │  adapter B   │  │  finetune    │
  │  + optimizer │  │  + optimizer │  │  + optimizer │
  └──────────────┘  └──────────────┘  └──────────────┘
  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐
  │   Die 3      │  │   Die 4      │  │   Die 5      │
  │  3.6B LoRA   │  │  1.5B RFA    │  │  0.2B LoRA   │
  │  adapter C   │  │  hybrid      │  │  adapter D   │
  │  + optimizer │  │  + optimizer │  │  + optimizer │
  └──────────────┘  └──────────────┘  └──────────────┘

VRAM Budget Per Die (12GB)

Model Size Weights (INT8) Optimizer State (LoRA r=32) Activations Fits?
0.2B ~200 MB ~50 MB ~100 MB Easily (11.6 GB free)
1.5B ~1.5 GB ~200 MB ~500 MB Yes (9.8 GB free)
3.6B ~3.6 GB ~400 MB ~1 GB Yes (7 GB free)
7B ~7 GB ~800 MB ~2 GB Tight (~2 GB free)

Training Step Timing Estimates

Per-step time depends on model size, sequence length, and batch size. These are estimates for single-example training steps on K80 (sm_37, ~240 GB/s memory bandwidth, ~2.8 TFLOPS FP32).

Model Seq Len Est. Step Time Steps/min (1 die) Steps/min (6 dies round-robin)
0.2B 256 ~200ms ~300 ~270 (90% efficient)
0.2B 512 ~400ms ~150 ~140
3.6B 256 ~2s ~30 ~29.5 (98% efficient)
3.6B 512 ~4s ~15 ~14.9

Round-robin efficiency = step_time / (step_time + swap_overhead). With 0.3ms swap, even the fastest step (200ms) is 99.85% efficient per die. The main cost is that each die only gets 1/6 of the wall-clock time.

Steps Per Visit (Amortization)

Running more than 1 step per die visit amortizes swap cost further and improves GPU utilization. Trade-off: longer time before other dies get their turn.

Steps/Visit Round Time (6 dies, 3.6B) Swap Overhead
1 ~12s 0.015%
10 ~120s 0.0015%
100 ~20min negligible

For overnight runs, 100+ steps per visit makes sense. For interactive monitoring, 1-10 steps per visit gives faster feedback across all jobs.

Scheduler Design

Job Config

typedef struct {
    int die_id;                     // which die (0-5)
    const char *model_path;         // base model weights
    const char *data_path;          // training data
    const char *output_path;        // where to save adapter
    int lora_rank;                  // LoRA rank
    float lr;                       // learning rate
    int max_epochs;                 // when to stop
    int max_seq_len;                // sequence length
    int steps_per_visit;            // steps per round-robin slot
    bool quantize;                  // INT8 base weights
} rr_job;

Daemon Lifecycle

1. INIT PHASE (one-time, sequential):
   for each job:
     a. acquire die
     b. load model + create CUDA context + upload weights
     c. create LoRA adapter + optimizer
     d. release die

   All 6 dies now hold their model + optimizer in VRAM.

2. TRAINING PHASE (round-robin loop):
   while any job is not done:
     for each active job:
       a. acquire die                         ~0.3ms
       b. restore CUDA context
       c. run steps_per_visit training steps
       d. log loss, step count, throughput
       e. release die                         ~0.1ms
       f. if job.step >= job.max_steps: mark done

3. SAVE PHASE (sequential):
   for each completed job:
     a. acquire die
     b. download adapter weights from VRAM
     c. save to output_path as .safetensors
     d. release die

4. CLEANUP:
   destroy all CUDA contexts, unload nvidia

CLI Interface

# Config file: jobs.json
[
  {
    "die": 0,
    "model": "~/models/base/RWKV-X-3.6B-64k-Base.safetensors",
    "data": "~/models/datasets/code_instruct.jsonl",
    "output": "~/models/loras/code-3.6B/",
    "lora_rank": 32,
    "lr": 2e-5,
    "epochs": 3,
    "seq_len": 512,
    "steps_per_visit": 50,
    "quantize": true
  },
  {
    "die": 1,
    "model": "~/models/base/RWKV-X-3.6B-64k-Base.safetensors",
    "data": "~/models/datasets/chat_instruct.jsonl",
    "output": "~/models/loras/chat-3.6B/",
    "lora_rank": 32,
    "lr": 2e-5,
    "epochs": 3,
    "seq_len": 512,
    "steps_per_visit": 50,
    "quantize": true
  }
]

# Launch
sudo ./rr_train --config jobs.json

# Output (live):
[round 1] die 0: step 50/1500, loss=2.34, 28.5 steps/min
[round 1] die 1: step 50/1500, loss=2.41, 27.8 steps/min
[round 1] die 2: step 50/900,  loss=1.89, 145 steps/min
...
[round 30] die 0: DONE (1500 steps, final loss=0.82)
[round 30] die 1: step 1450/1500, loss=0.79
...
Saving die 0 adapter to ~/models/loras/code-3.6B/adapter.safetensors
All jobs complete.

Current State (What Exists vs What's Needed)

The training pipeline today splits across CPU and GPU:

Step Where it runs now What it does
Forward pass GPU (CUDA) Matmuls, activations, norms — already ported, 38 tok/s on K80
Backward pass CPU only Computes gradients via chain rule — same matmuls/element-wise ops as forward, just not ported yet
Optimizer step CPU only AdamW: updates momentum, variance, applies weight update — element-wise tensor math
Weight update CPU only Applies gradients to LoRA parameters — scaling and addition

The backward pass, optimizer, and weight updates are all standard tensor math — matmuls (fc_sgemm), element-wise ops (fc_add, fc_mul), scaling. These are the same operations we already have CUDA kernels for from the forward pass. Porting them is not new math, it's calling the GPU versions instead of the CPU versions.

Why it's CPU-only today: Training was built on Mac (M4) where Metal handles the forward pass and CPU handles the rest. Apple Silicon's CPU is fast enough that this wasn't a bottleneck. On the K80 server the CPU is much slower, so GPU training actually matters here.

What needs to happen: Port the backward pass in rx_train.c / rfa_train.c to use fc_sgemm, fc_add, fc_mul etc. instead of tm_matmul, tm_add, tm_mul. Then move the AdamW optimizer step to GPU (element-wise update of momentum/variance/weights). Once both are on GPU, the entire training loop stays in VRAM and round-robin scheduling works.

Implementation Order

Phase 1: Single-die GPU training

  • Port backward pass to CUDA (use existing fc_* kernels — same math, different dispatch)
  • Port AdamW optimizer step to CUDA (element-wise: m = beta1*m + (1-beta1)*grad, etc.)
  • Keep optimizer state (m, v tensors) in VRAM via fc_buffer
  • This is the critical prerequisite — everything else depends on it

Phase 2: Multi-die state persistence

  • Prove that optimizer state (Adam momentum/variance) survives hot swap
  • Prove that gradient accumulators survive hot swap
  • Test: load model on die 0, train 10 steps, swap to die 1, swap back, train 10 more, compare loss curve to uninterrupted run

Phase 3: Round-robin scheduler

  • Implement the daemon loop
  • Job config parsing (JSON or simple text format)
  • Per-die CUDA context management
  • Logging and progress reporting

Phase 4: Robustness

  • Handle die acquire failures (skip die, retry next round)
  • Checkpoint adapters periodically (not just at end)
  • Resume from checkpoint after crash/reboot
  • Graceful shutdown (SIGINT saves all adapters)

Open Questions

  1. GPU backward pass: Current training backward pass is CPU-only. Need to port gradient computation to CUDA kernels before any of this works. This is the biggest prerequisite.

  2. Optimizer state in VRAM: AdamW needs momentum (m) and variance (v) tensors per parameter. For LoRA r=32 on 3.6B, this is ~800MB. Fits in 12GB but needs CUDA-side optimizer step.

  3. Multiple CUDA contexts: Can we have 6 CUDA contexts (one per die) alive simultaneously? Current code creates one context in fc_init(). May need to refactor to support context-per-die.

  4. Gradient checkpointing on GPU: Current training uses CPU gradient checkpointing. GPU training will need GPU-side checkpointing to fit in 12GB for larger models.

  5. P40 compatibility: P40 is single-die (24GB), Pascal (sm_61). bar_swap would treat each card as 1 die. Same scheduler works, just fewer dies per card but more VRAM each. CUDA 11.4 supports sm_61. PTX kernels need recompile for sm_61 (or use sm_37 PTX which JIT-compiles up).