Compare commits
3 Commits
d422c68704
...
4c3f332f64
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c3f332f64 | |||
| b7104e2cb7 | |||
| 28801fbfe5 |
@@ -13,6 +13,7 @@ unsafe extern "C" {
|
||||
// --- Device ---
|
||||
pub fn cudaGetDeviceCount(count: *mut i32) -> i32;
|
||||
pub fn cudaSetDevice(device: i32) -> i32;
|
||||
pub fn cudaGetDevice(device: *mut i32) -> i32;
|
||||
pub fn cudaDeviceSynchronize() -> i32;
|
||||
|
||||
// --- Memory ---
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod device;
|
||||
pub mod error;
|
||||
pub mod ffi;
|
||||
pub mod memory;
|
||||
mod pool;
|
||||
|
||||
pub use error::{CudaError, Result};
|
||||
pub use memory::GpuBuffer;
|
||||
|
||||
@@ -1,18 +1,37 @@
|
||||
use crate::error::{self, Result};
|
||||
use crate::ffi;
|
||||
use crate::pool;
|
||||
|
||||
/// RAII wrapper around a GPU memory allocation. Dropping frees the memory.
|
||||
/// RAII wrapper around a GPU memory allocation. Dropping returns the buffer to
|
||||
/// the per-device caching pool (see [`crate::pool`]) for reuse instead of
|
||||
/// calling `cudaFree`.
|
||||
///
|
||||
/// `len` is the logical (requested) length used for all copy/memset bounds and
|
||||
/// exposed via [`GpuBuffer::len`]; `cap` is the physical size class the pool
|
||||
/// rounded up to (>= `len`), used only to bucket the buffer for reuse. The
|
||||
/// extra `cap - len` bytes are never exposed to callers, so pooling is
|
||||
/// numerically transparent. `device` records which device pool to return to.
|
||||
pub struct GpuBuffer {
|
||||
ptr: *mut u8,
|
||||
len: usize,
|
||||
cap: usize,
|
||||
device: i32,
|
||||
}
|
||||
|
||||
impl GpuBuffer {
|
||||
/// Allocate at least `len` bytes on the calling thread's current device,
|
||||
/// reusing a pooled buffer when one of the matching size class is free.
|
||||
/// The contents are **uninitialized** (a reused buffer holds stale bytes);
|
||||
/// callers that need zeros must memset (see [`crate::Storage::zeros`]).
|
||||
pub fn alloc(len: usize) -> Result<Self> {
|
||||
assert!(len > 0, "cannot allocate 0 bytes on GPU");
|
||||
let mut ptr = std::ptr::null_mut();
|
||||
error::check(unsafe { ffi::cudaMalloc(&mut ptr, len) })?;
|
||||
Ok(Self { ptr, len })
|
||||
let a = pool::acquire(len)?;
|
||||
Ok(Self {
|
||||
ptr: a.ptr,
|
||||
len,
|
||||
cap: a.cap,
|
||||
device: a.device,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
@@ -56,9 +75,10 @@ impl GpuBuffer {
|
||||
|
||||
impl Drop for GpuBuffer {
|
||||
fn drop(&mut self) {
|
||||
if !self.ptr.is_null() {
|
||||
unsafe { ffi::cudaFree(self.ptr) };
|
||||
}
|
||||
// Return to the device pool for reuse (no cudaFree). The pool retains
|
||||
// the raw pointer for the process lifetime; on process exit the OS
|
||||
// reclaims the device context, so this is not a leak.
|
||||
pool::release(self.ptr, self.device, self.cap);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
124
crates/xtrain-cuda/src/pool.rs
Normal file
124
crates/xtrain-cuda/src/pool.rs
Normal file
@@ -0,0 +1,124 @@
|
||||
//! Device caching / pool allocator (Phase T11, KI-5).
|
||||
//!
|
||||
//! Every tape op allocates its output buffer via [`crate::GpuBuffer::alloc`],
|
||||
//! which used to call `cudaMalloc` + (for `zeros`) `cudaMemset` on *every* op.
|
||||
//! `cudaMalloc`/`cudaFree` are synchronous, process-serialized driver calls; in
|
||||
//! the single-process thread-per-GPU DDP model the rank threads' hundreds of
|
||||
//! per-step allocations queue through the driver and serialize (KI-5). The cost
|
||||
//! hurts single-GPU too.
|
||||
//!
|
||||
//! Fix: cache freed device buffers in a per-device, size-classed free list and
|
||||
//! reuse them. Training has repeating shapes, so after warm-up the steady-state
|
||||
//! `cudaMalloc` count per step is ~0. The pool is **transparent**: a `GpuBuffer`
|
||||
//! handed out from the pool exposes exactly the bytes the caller requested (the
|
||||
//! physical allocation may be rounded up to its size class, but `len()` and all
|
||||
//! copy/memset bounds use the requested length), so numerics are unchanged.
|
||||
//!
|
||||
//! Thread-safety: DDP runs thread-per-GPU in one process. The pool is a global
|
||||
//! registry keyed by device id; each device's free list lives behind its own
|
||||
//! `Mutex`. A buffer remembers which device it was allocated on (the thread's
|
||||
//! current CUDA device at `alloc` time) so `Drop` returns it to the right pool.
|
||||
|
||||
use crate::error::{self, Result};
|
||||
use crate::ffi;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
/// Allocation granularity. Requests are rounded *up* to a size class so that
|
||||
/// op outputs of the same shape (the common case in training) land in the same
|
||||
/// free list and are reused across steps.
|
||||
///
|
||||
/// Small allocations round up to a multiple of `MIN_CLASS`; larger ones round
|
||||
/// up to the next power of two. Powers of two keep the number of distinct
|
||||
/// classes bounded (so the free lists stay shallow) while wasting at most ~2×
|
||||
/// per buffer — fine for fixed-shape training, and freed memory is reused, not
|
||||
/// leaked.
|
||||
const MIN_CLASS: usize = 512;
|
||||
/// Below this threshold, round up to a multiple of `MIN_CLASS` (fine-grained);
|
||||
/// at or above it, round up to the next power of two.
|
||||
const POW2_THRESHOLD: usize = 1 << 20; // 1 MiB
|
||||
|
||||
/// Round a byte length up to its size class (the physical allocation size).
|
||||
fn size_class(len: usize) -> usize {
|
||||
debug_assert!(len > 0);
|
||||
if len <= POW2_THRESHOLD {
|
||||
len.div_ceil(MIN_CLASS) * MIN_CLASS
|
||||
} else {
|
||||
len.next_power_of_two()
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-device free list: size class -> stack of cached raw device pointers.
|
||||
#[derive(Default)]
|
||||
struct DevicePool {
|
||||
free: HashMap<usize, Vec<*mut u8>>,
|
||||
}
|
||||
|
||||
// The raw pointers are device addresses, only ever dereferenced by the GPU.
|
||||
// They are guarded by a `Mutex` and moved between threads as plain handles.
|
||||
unsafe impl Send for DevicePool {}
|
||||
|
||||
type SharedPool = Arc<Mutex<DevicePool>>;
|
||||
|
||||
fn registry() -> &'static Mutex<HashMap<i32, SharedPool>> {
|
||||
static REGISTRY: OnceLock<Mutex<HashMap<i32, SharedPool>>> = OnceLock::new();
|
||||
REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// The CUDA device the calling thread is currently set to. DDP sets this once
|
||||
/// per rank-thread, so it identifies which pool to use.
|
||||
fn current_device() -> Result<i32> {
|
||||
let mut dev = 0i32;
|
||||
error::check(unsafe { ffi::cudaGetDevice(&mut dev) })?;
|
||||
Ok(dev)
|
||||
}
|
||||
|
||||
/// Run `f` with the (locked) pool for `device`, creating it on first use. The
|
||||
/// registry mutex is held only long enough to clone out this device's
|
||||
/// `Arc<Mutex<DevicePool>>`, so different devices' threads don't contend on the
|
||||
/// per-device free list — true per-rank concurrency.
|
||||
fn with_device_pool<R>(device: i32, f: impl FnOnce(&mut DevicePool) -> R) -> R {
|
||||
let pool = {
|
||||
let mut reg = registry().lock().unwrap();
|
||||
reg.entry(device).or_default().clone()
|
||||
};
|
||||
let mut guard = pool.lock().unwrap();
|
||||
f(&mut guard)
|
||||
}
|
||||
|
||||
/// Allocation served by the pool: a raw device pointer plus the device it lives
|
||||
/// on and the size class (capacity) of the physical buffer.
|
||||
pub(crate) struct PoolAlloc {
|
||||
pub ptr: *mut u8,
|
||||
pub device: i32,
|
||||
pub cap: usize,
|
||||
}
|
||||
|
||||
/// Acquire a buffer of at least `len` bytes for the calling thread's current
|
||||
/// device. Reuses a cached buffer of the matching size class if one is free,
|
||||
/// otherwise `cudaMalloc`s a fresh one of the size-class capacity.
|
||||
pub(crate) fn acquire(len: usize) -> Result<PoolAlloc> {
|
||||
let cap = size_class(len);
|
||||
let device = current_device()?;
|
||||
|
||||
let cached = with_device_pool(device, |pool| {
|
||||
pool.free.get_mut(&cap).and_then(|stack| stack.pop())
|
||||
});
|
||||
if let Some(ptr) = cached {
|
||||
return Ok(PoolAlloc { ptr, device, cap });
|
||||
}
|
||||
|
||||
let mut ptr = std::ptr::null_mut();
|
||||
error::check(unsafe { ffi::cudaMalloc(&mut ptr, cap) })?;
|
||||
Ok(PoolAlloc { ptr, device, cap })
|
||||
}
|
||||
|
||||
/// Return a buffer to its device's free list for reuse. Does NOT `cudaFree`.
|
||||
pub(crate) fn release(ptr: *mut u8, device: i32, cap: usize) {
|
||||
if ptr.is_null() {
|
||||
return;
|
||||
}
|
||||
with_device_pool(device, |pool| {
|
||||
pool.free.entry(cap).or_default().push(ptr);
|
||||
});
|
||||
}
|
||||
@@ -168,7 +168,15 @@ fn ddp_matches_single_gpu_and_params_consistent() {
|
||||
}
|
||||
}
|
||||
println!("cross-rank max |param diff| = {max_pdiff:.3e}");
|
||||
assert_eq!(max_pdiff, 0.0, "ranks' params drifted apart");
|
||||
// On this PCIe-only box, NCCL's all-reduce is not bit-reproducible run-to-run
|
||||
// across ranks (algorithm/chunk choice is unstable), so cross-rank params can
|
||||
// differ by a few ULP (observed ≤1.2e-7) even with identical init + averaged
|
||||
// grads. The load-bearing gate is the loss-trajectory match (a, ~5.7e-7); a
|
||||
// tight tolerance here, not bit-identity, is the honest invariant (KI-5).
|
||||
assert!(
|
||||
max_pdiff < 1e-6,
|
||||
"ranks' params drifted apart: {max_pdiff:.3e}"
|
||||
);
|
||||
|
||||
// (c) DDP final params match single-GPU final params within fp tolerance.
|
||||
// Looser than (a)/(b): DDP and single-GPU differ only in the gradient SUMMATION
|
||||
@@ -176,7 +184,7 @@ fn ddp_matches_single_gpu_and_params_consistent() {
|
||||
// then NCCL-sums across ranks). fp addition isn't associative, so that tiny
|
||||
// per-step rounding compounds over the AdamW steps — a few e-3 relative on
|
||||
// individual params is expected and benign. The loss-trajectory match (a, ~1e-7)
|
||||
// and bit-identical cross-rank params (b, ==0) are the load-bearing checks.
|
||||
// and tight cross-rank agreement (b, <1e-6) are the load-bearing checks.
|
||||
let mut max_sdiff = 0.0f32;
|
||||
for (a, b) in ddp_p0.iter().zip(&single_params) {
|
||||
for (x, y) in a.iter().zip(b) {
|
||||
@@ -206,7 +214,10 @@ fn ddp_throughput_scaling() {
|
||||
let steps = 150usize;
|
||||
let seq_len = 64usize;
|
||||
|
||||
let worlds: Vec<usize> = [1, 2, 4].into_iter().filter(|&w| w <= max_gpus).collect();
|
||||
let worlds: Vec<usize> = [1, 2, 4, 8]
|
||||
.into_iter()
|
||||
.filter(|&w| w <= max_gpus)
|
||||
.collect();
|
||||
println!("\n=== DDP throughput scaling (per-GPU batch {per_gpu_batch}, seq {seq_len}) ===");
|
||||
println!(
|
||||
"{:>6} | {:>14} | {:>8}",
|
||||
|
||||
134
docs/10-caching-allocator.md
Normal file
134
docs/10-caching-allocator.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# Phase T11: Device Caching / Pool Allocator — Design Document
|
||||
|
||||
## Goal
|
||||
|
||||
修 **KI-5 的根因**。T10 修掉单卡 launch-bound(1653→40K tok/s)后,DDP 多卡仍只有 ~1.4× 的弱扩展。
|
||||
T11 第一版拟修复(**分桶 all-reduce**)经 dash5 实测**证伪并 revert**:grad all-reduce 每步只占
|
||||
**~6–7%**,融成一发对 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 卡同一时刻
|
||||
只有 1–2 张在忙、轮流跑)。**这笔 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 ← 加 cudaGetDevice(Drop 要知道 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`);
|
||||
- 所有 copy(H2D/D2H)bounds 用 `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 对 cuBLAS;T4 各 op finite-diff grad-check(15 个);
|
||||
- T5 结构 + overfit(27/27) + PyTorch 对拍(B>1,logits/每参数 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 独立 context,torchrun 式)
|
||||
是否是剩余的修复方向(profile 确认,如前两次调查)。
|
||||
|
||||
## 顺手项
|
||||
|
||||
- **放宽 DDP flaky 断言**:`ddp_correctness` 的 cross-rank `max|p0−p1| == 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 → After
|
||||
|
||||
(dash5, 8× RTX 5090, 实测填入;见 known-issues.md KI-5 的 before/after 表与 commit。)
|
||||
Reference in New Issue
Block a user