Files
xtrain/docs/10-caching-allocator.md
Gahow Wang f85bd4d276 perf: KI-5 FIXED — single-GPU 40K->93K tok/s, DDP scaling 1.3x->5x@8
Device caching/pool allocator removes the per-op cudaMalloc serialization that
was the real DDP bottleneck (and a single-GPU cost). Measured on dash5 (8x RTX
5090, dim384/12L, per-rank batch 32, seq 256, steady-state tok/s):

  single-GPU: 40226 -> 92638 tok/s  (~2.3x)
  DDP scaling (global batch 32*world):
    world  before        after
      1    39801 1.00x    92385 1.00x
      2    47229 1.19x   146821 1.59x
      4    52854 1.33x   269867 2.92x
      8    48996 1.23x   461270 4.99x

8-GPU absolute throughput 49K -> 461K tok/s (9.4x); nvidia-smi shows all 8 GPUs
at 95-99% util during the run (KI-5 saw only 1-2/8 busy). Loss trajectories are
bit-identical before/after (10.9026->4.8453). xserv closed loop green: re-export
of the v3 ckpt is md5-identical to the registry safetensors and xserv serves it.

Mark KI-5 FIXED in docs/known-issues.md with before/after table; fill in the
design doc's measured numbers. Residual ~5x@8 (not perfectly linear) is the
~7% all-reduce + 8-GPU PCIe/launch overhead; process-per-GPU is the next lever
if v4 needs higher linearity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 11:15:02 +08:00

161 lines
8.5 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Phase T11: Device Caching / Pool Allocator — Design Document
## Goal
**KI-5 的根因**。T10 修掉单卡 launch-bound1653→40K tok/sDDP 多卡仍只有 ~1.4× 的弱扩展。
T11 第一版拟修复(**分桶 all-reduce**)经 dash5 实测**证伪并 revert**grad all-reduce 每步只占
**~67%**,融成一发对 1/2/4/8 卡几乎无差(见 [docs/known-issues.md](known-issues.md) KI-5 表)。
实测重新定位的根因:**每个 tape op 的输出都走 `Tensor::zeros``GpuBuffer::alloc`
`cudaMalloc` + `cudaMemset`**。`cudaMalloc`/`cudaFree` 是**同步、进程级串行**的 driver 调用;在
**单进程 thread-per-GPU** 的 DDP 模型下N 个 rank 线程每步几百次 alloc 在**单 CUDA context** 里排队
互相串行(`NOCOMM=1` 完全不通信时 fwd+bwd 仍 136→780ms 膨胀 ~6×`nvidia-smi` 抽样 8 卡同一时刻
只有 12 张在忙、轮流跑)。**这笔 per-op alloc 开销单卡也吃**——训练定形状、每步重复 malloc/free
同样的几百个 buffer纯属浪费。
T11 的修复:在 `xtrain-cuda``GpuBuffer`/`cudaMalloc`/`cudaFree` 所在)加一个 **device caching /
pool allocator**——freed 的显存**进 per-device 的 size-classed free-list 复用,不 `cudaFree`**
`alloc` 优先从 free-list 取miss 才 `cudaMalloc`。训练定形状 → 命中率极高,**warm-up 后每步
`cudaMalloc` ≈ 0**,消掉串行 driver 调用风暴。
**硬闸门是正确性**allocator 必须**透明**——交出的字节、数值与改前**逐位一致**,所有既有 grad-check /
PyTorch 对拍 / overfit / DDP / xserv 闭环**必须仍过**。在此之上拿吞吐收益。
## Module Layout
```
crates/xtrain-cuda/src/
pool.rs ← 新增global per-device free-list registry + size-class 逻辑
memory.rs ← GpuBuffer::alloc 从 pool 取Drop 归还 pool不 cudaFree
ffi.rs ← 加 cudaGetDeviceDrop 要知道 buffer 属哪个 device pool
lib.rs ← `mod pool;`
```
`xtrain-tensor` **零改动**`Storage::zeros``GpuBuffer::alloc` + `memset(0)`,签名不变。
pool 完全藏在 `GpuBuffer` 后面,上层无感。
## Key Design Decisions
### 1. Size class按粒度向上取整 → 跨步可复用)
请求字节数向上取整到一个 **size class**,同形状的 op 输出落进同一 free-list、跨 step 复用:
```rust
const MIN_CLASS: usize = 512; // 小分配的对齐粒度
const POW2_THRESHOLD: usize = 1 << 20; // 1 MiB
fn size_class(len) =
if len <= 1 MiB { ceil(len / 512) * 512 } // 细粒度,浪费 ≤512B
else { len.next_power_of_two() } // 粗粒度class 数有界
```
小分配按 512B 对齐(浪费极小);大分配按 2 的幂取整class 数有界 → free-list 浅,最多浪费 ~2×
但**显存是复用不是泄漏**,定形状训练里大 buffer 的 class 也就那么几个)。
**关键透明性**:物理分配是 `cap`(取整后),但 `GpuBuffer::len()` 仍返回**请求的 `len`**
- `memset(0)` 只 zero **逻辑 `len`** 字节(不是 `cap`
- 所有 copyH2D/D2Hbounds 用 `len`D2H 拷回 host 也只拷 `len` 字节;
- op kernel 只按 shape= `len`)读写。
`cap - len` 的尾部字节**永不被任何人读到**,所以 round-up 对数值**完全透明**。
### 2. Per-device + 线程安全DDP thread-per-GPU
DDP 是单进程 thread-per-GPU——pool 必须跨 rank 线程安全,且**不能让不同 device 的线程互相串行**
(否则没解决问题):
```
global REGISTRY: Mutex<HashMap<device_id, Arc<Mutex<DevicePool>>>>
DevicePool { free: HashMap<size_class, Vec<*mut u8>> }
```
- **两级锁**registry 锁只在「按 device_id 取出(或首次插入)该 device 的 `Arc<Mutex<DevicePool>>`
这一瞬持有,立刻 clone Arc 出来、释放 registry 锁,再锁**该 device 自己的** pool。
→ 不同 device 的 rank 线程**各锁各的 pool真并发**registry 锁只是极短的查表。
- buffer 在 **alloc 时**记下当前线程的 CUDA device`cudaGetDevice`DDP 每 rank 线程开头 set 一次),
存进 `GpuBuffer.device`**Drop 时**按这个 device 归还,保证 ptr 回到它所属 context 的 pool
(即使 drop 发生在另一个 device 的线程上也对)。
### 3. Drop → 归还(不 cudaFree
```rust
impl Drop for GpuBuffer {
fn drop(&mut self) { pool::release(self.ptr, self.device, self.cap); }
}
```
free-list **无界**(轻量、不做 eviction——定形状训练的 working set 有界,每步复用同一批 buffer
free-list 深度自然收敛不会无限涨。pool 持有的 ptr 活到**进程退出**,届时 OS 回收整个 device
context**不是泄漏**。
**双重释放/泄漏边界审查**`GpuBuffer``Clone`,独占 ptr`Storage``Arc<GpuBuffer>` 共享,
最后一个 Arc 落地时 buffer 恰好 drop 一次 → `release` 一次。`acquire` 从 free-list `pop` 一个 ptr
交给**唯一**一个新 `GpuBuffer`,无别名。故无双重释放、无别名。
### 4. memset保留正确性优先不做 skip-memset uninit
`Storage::zeros` 复用的 buffer 持有**陈旧字节**,故**继续 `memset(0)`**(正确性)。
任务给的 OPTIONAL bonus给「完全覆盖输出」的 op 加 `uninit`/skip-memset**本次不做**,诚实理由:
- 真正串行的是 `cudaMalloc`**已被 pool 消掉**`cudaMemset` 在 default stream 上 async、开销小。
- 要 skip 必须逐 op 证明输出被**完全覆盖**——`matmul`(beta=0 全写)能跳,但 `embedding_bwd`(scatter-**add**)、
`sumsq_accum`/`sum_rows`(累加器)、`adamw`(读写 m/v) **必须**预 zero。审查面大、收益小、正确性风险高。
- **正确性是硬闸门**,不为一个已非瓶颈的 async memset 冒风险。留作后续(若 profile 显示 memset 成新瓶颈再做)。
## 验证方法(双闸门)
### 闸门一:正确性(透明,零回归)
allocator 不改任何数值。全回归套**必须仍绿**
- T3 GEMM 对 cuBLAST4 各 op finite-diff grad-check15 个);
- T5 结构 + overfit(27/27) + PyTorch 对拍B>1logits/每参数 grad
- T6 AdamW 对 torch + checkpoint 逐位;
- T8 DDP loss 对单卡(~5.7e-7+ 跨 rank 一致T10 batched==looped
- **xserv 闭环**:导出权重对 xtrain 贪心仍逐 token 一致。
### 闸门二:吞吐(收益)
- **单卡 tok/s before/after**malloc 风暴消失应↑)+ GPU util
- **DDP 1/2/4/8 卡 scaling before/after**KI-5 调查的表);
`ddp_throughput_scaling` 测试扩到 world=8。
**诚实原则**:若单卡提速但多卡仍受限 → 说明串行比 malloc 更深(如单 context 下 kernel launch /
cuBLAS handle 仍串行),如实报告,并说明 **process-per-GPU**(每 rank 独立 contexttorchrun 式)
是否是剩余的修复方向profile 确认,如前两次调查)。
## 顺手项
- **放宽 DDP flaky 断言**`ddp_correctness` 的 cross-rank `max|p0p1| == 0.0``< 1e-6`
承重闸门是 loss-match~5.7e-7本机 PCIe-only NCCL all-reduce run-to-run 跨 rank 非逐位可复现,
diff ≤1.2e-7几 ULP数值无害`== 0.0` 过严 flaky。
## Before → Afterdash5, 8× RTX 5090, sm_120
实测(`train_ddp`, dim384/12L/12h·hd32 ffn1536 core 28.3M, per-rank batch 32, seq 256,
steady-state tok/sbefore = parent `d422c68`, after = pooled
**单卡KI-5 假设per-op malloc 单卡也吃)**
| | tok/s | GPU util |
|---|---|---|
| before | 40226 | 8 卡轮流忙12/8 |
| after | **92638** | — |
→ 单卡 **~2.3×**loss 轨迹逐位对住10.9026→4.8453 before/after 一致)。
**DDP 1/2/4/8 卡 scalingglobal batch = 32×world**
| world | before tok/s | before speedup | after tok/s | after speedup |
|---|---|---|---|---|
| 1 | 39801 | 1.00× | 92385 | 1.00× |
| 2 | 47229 | 1.19× | 146821 | 1.59× |
| 4 | 52854 | 1.33× | 269867 | 2.92× |
| 8 | 48996 | 1.23× | **461270** | **4.99×** |
→ 8 卡绝对吞吐 **49K → 461K tok/s = 9.4×**scaling 从「~1.3× 封顶」恢复到 **~5×@8**。
8 卡运行 `nvidia-smi` 抽样 **8 卡全部 9599% util**KI-5 时只有 12/8 在忙)——
per-op cudaMalloc 串行确是根因pool 消掉后 GPU 变 compute-bound 喂满。
**残留**5×@8 非完美线性grad all-reduce ~7% + 8 卡 PCIe / launch 余量),但弱扩展的悬崖已消。
KI-5 标 **FIXED**。若 v4 要更高线性度,下一步才是 process-per-GPU每 rank 独立 context