Skip to content

Commit da085de

Browse files
committed
gpu: the mirror table and its buffer pool live once, in gpu_abi.h
cuda.h and webgpu.h each kept their own copy of the same shape: a struct pairing a host buffer with a device handle and a residency state, a map from the native handle to it, and a size-keyed free list alloc()/release() recycle through. Only the device handle's type differed (CUdeviceptr, wgpu::Buffer). gpu::mirror_table<Handle> (gpu_abi.h) holds that shape once; each backend instantiates it for its own handle type and keeps doing its own copying (before_kernel_, sync_to_host), which is where the two drivers actually diverge. mirror_table::insert moves its Handle parameter into the entry rather than copying it, so a ref-counted handle (wgpu::Buffer) picks up only one AddRef per allocation instead of two. Verified on native Metal (117 cases, 11640 assertions with real device access, plus check_qwen's greedy tokens against the numpy oracle) and gpu_host's no-GPU reference build, under WebGPU via Deno, and by a clean CUDA host-side trace diff against the pre-refactor commit (no kernel call changed). A mutation that let take() hand out an already-live pooled buffer twice was caught hard by the WebGPU suite (32 of 117 cases failed).
1 parent 8d79288 commit da085de

4 files changed

Lines changed: 93 additions & 71 deletions

File tree

‎docs/backends.md‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,15 @@ buffer, so an output into a buffer whose live bytes are the host's has to bring
6060
them up or lose the rest, while an output into a fresh buffer, which is nearly
6161
every output, has nothing to bring.
6262
63+
The table that holds those copies — host pointer, device handle, size,
64+
`residency` — and the size-keyed free list `alloc`/`release` recycle buffers
65+
through are `gpu::mirror_table<Handle>` (`gpu_abi.h`), one instantiation per
66+
mirrored backend (`mirror_table<CUdeviceptr>` in `cuda.h`,
67+
`mirror_table<wgpu::Buffer>` in `webgpu.h`). Only `Handle` — the backend's own
68+
device buffer type — varies; the map, the pool and the copying policy do not.
69+
A backend still does the actual copy (`before_kernel_`, `sync_to_host`) since
70+
that call differs by driver.
71+
6372
## The kernel ABI
6473
6574
An op hands the backend a kernel id, an ordered list of views, a params
@@ -259,9 +268,6 @@ asks; a new backend edits no test.
259268
260269
## What is not shared yet
261270
262-
- The mirror table (handle to host copy, device copy, size, `residency`) and
263-
the buffer pool exist twice, in `cuda.h` and `webgpu.h`. The state machine
264-
itself is shared.
265271
- WebGPU's kernels still predate the canonical ABI and go through `marshal_`
266272
(see above).
267273

‎include/cuda.h‎

Lines changed: 19 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -282,29 +282,19 @@ struct context {
282282
std::vector<timed_launch> timed;
283283
std::vector<CUevent> spare_events;
284284

285-
// Host/device mirror per allocation, keyed by the device pointer (== the
286-
// `native` handle stored in storage). Views sharing a storage share the key,
287-
// so one state serves every view. When to copy is gpu::residency's decision
288-
// (gpu_abi.h); the copies are made here.
289-
struct mirror {
290-
float* host = nullptr; // CPU-side buffer (storage.contents/ptr)
291-
CUdeviceptr dev = 0; // device buffer (storage.native)
292-
size_t bytes = 0;
293-
gpu::residency live;
294-
};
295-
std::unordered_map<CUdeviceptr, mirror> mirrors;
296-
297-
// Size-keyed free list (like Metal's MTLBuffer pool). Released buffers are
298-
// recycled, not cuMemFree'd — repeated large alloc/free otherwise fragments
299-
// the driver allocator (decode benches, training that churns activations).
300-
// Buffers persist until the (leaked) context tears down. Keyed by exact byte
301-
// size; the workloads that churn reuse identical shapes.
302-
std::unordered_map<size_t, std::vector<std::pair<CUdeviceptr, float*>>> pool;
303-
304-
mirror* mirror_(void* native) {
305-
auto it = mirrors.find(reinterpret_cast<CUdeviceptr>(native));
306-
return it == mirrors.end() ? nullptr : &it->second;
307-
}
285+
// Host/device mirror per allocation and its size-keyed free list (shared
286+
// shape with webgpu.h; `gpu::mirror_table`, gpu_abi.h). Keyed by the device
287+
// pointer, reinterpreted as the `native` handle stored in storage. Views
288+
// sharing a storage share the key, so one state serves every view. When to
289+
// copy is gpu::residency's decision; the copies are made here. Released
290+
// buffers are recycled, not cuMemFree'd — repeated large alloc/free
291+
// otherwise fragments the driver allocator (decode benches, training that
292+
// churns activations); they persist until the (leaked) context tears down.
293+
using mirror = gpu::mirror_table<CUdeviceptr>::entry;
294+
gpu::mirror_table<CUdeviceptr> mt;
295+
296+
mirror* mirror_(void* native) { return mt.find(native); }
297+
308298
// A kernel is about to touch this buffer as `a`: bring the host copy up if
309299
// residency says so. Async on the stream like the meta uploads (a blocking
310300
// copy would wait out every kernel already queued and stall the pipeline
@@ -1141,35 +1131,26 @@ inline void* alloc(int64_t bytes, float** contents, bool host_fill = false) {
11411131
size_t nb = bytes > 0 ? (size_t)bytes : 4;
11421132
CUdeviceptr dev = 0;
11431133
float* host = nullptr;
1144-
auto it = c.pool.find(nb); // reuse a recycled buffer of this exact size
1145-
if (it != c.pool.end() && !it->second.empty()) {
1146-
dev = it->second.back().first;
1147-
host = it->second.back().second;
1148-
it->second.pop_back();
1149-
} else {
1134+
if (!c.mt.take(nb, dev, host)) { // no recycled buffer of this exact size
11501135
if (c.d.MemAlloc(&dev, nb) != 0) return nullptr;
11511136
host = static_cast<float*>(std::malloc(nb));
11521137
if (!host) {
11531138
c.d.MemFree(dev);
11541139
return nullptr;
11551140
}
11561141
}
1157-
c.mirrors[dev] = context::mirror{host, dev, nb, gpu::residency(host_fill)};
1142+
void* native = reinterpret_cast<void*>(dev);
1143+
c.mt.insert(native, dev, host, nb, host_fill);
11581144
if (contents) *contents = host;
1159-
return reinterpret_cast<void*>(dev);
1145+
return native;
11601146
}
11611147

11621148
inline void release(void* buf, int64_t, float*) {
11631149
auto& c = context::get();
11641150
if (!c.ready || !buf) return;
1165-
CUdeviceptr dev = reinterpret_cast<CUdeviceptr>(buf);
1166-
auto it = c.mirrors.find(dev);
1167-
if (it == c.mirrors.end()) {
1168-
c.d.MemFree(dev); // untracked (shouldn't happen); free outright
1169-
return;
1151+
if (!c.mt.release(buf)) {
1152+
c.d.MemFree(reinterpret_cast<CUdeviceptr>(buf)); // untracked (shouldn't happen); free outright
11701153
}
1171-
c.pool[it->second.bytes].push_back({dev, it->second.host}); // recycle
1172-
c.mirrors.erase(it);
11731154
}
11741155

11751156
// Reconcile a buffer for a CPU access: when the device holds the live copy,

‎include/gpu_abi.h‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919
#include <cstddef>
2020
#include <cstdint>
2121
#include <string_view>
22+
#include <unordered_map>
23+
#include <utility>
24+
#include <vector>
2225

2326
#include "profile.h"
2427

@@ -160,6 +163,54 @@ struct residency {
160163
void uploaded() { where = both; }
161164
};
162165

166+
// The host/device mirror and size-keyed buffer pool a mirrored backend (CUDA,
167+
// WebGPU) keeps per allocation. `Handle` is the backend's own device buffer
168+
// type (`CUdeviceptr`, `wgpu::Buffer`); the map, the pool and the entry shape
169+
// are otherwise identical between them, so only `Handle` varies. A backend
170+
// still does its own copying (`before_kernel_`, `sync_to_host`) and still
171+
// keys lookups by whatever `void*` it hands out as `native`; this only owns
172+
// where the state lives.
173+
template <class Handle>
174+
struct mirror_table {
175+
struct entry {
176+
float* host = nullptr;
177+
Handle dev{};
178+
size_t bytes = 0;
179+
residency live;
180+
};
181+
std::unordered_map<void*, entry> mirrors;
182+
std::unordered_map<size_t, std::vector<std::pair<Handle, float*>>> pool;
183+
184+
entry* find(void* native) {
185+
auto it = mirrors.find(native);
186+
return it == mirrors.end() ? nullptr : &it->second;
187+
}
188+
189+
// A released buffer of this exact size, if the pool has one.
190+
bool take(size_t bytes, Handle& dev, float*& host) {
191+
auto it = pool.find(bytes);
192+
if (it == pool.end() || it->second.empty()) return false;
193+
dev = it->second.back().first;
194+
host = it->second.back().second;
195+
it->second.pop_back();
196+
return true;
197+
}
198+
199+
void insert(void* key, Handle dev, float* host, size_t bytes, bool host_fill) {
200+
mirrors[key] = entry{host, std::move(dev), bytes, residency(host_fill)};
201+
}
202+
203+
// Moves `native`'s entry into the free list, keyed by its size. False if
204+
// `native` is not tracked (nothing to release).
205+
bool release(void* native) {
206+
auto it = mirrors.find(native);
207+
if (it == mirrors.end()) return false;
208+
pool[it->second.bytes].push_back({it->second.dev, it->second.host});
209+
mirrors.erase(it);
210+
return true;
211+
}
212+
};
213+
163214
// A launch's extent: how many groups, how many threads in each, and the bytes
164215
// of per-group scratch a kernel's reduction needs where the backend sizes it at
165216
// launch (CUDA's shared memory; Metal and WGSL size theirs in the kernel).

‎include/webgpu.h‎

Lines changed: 14 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -196,21 +196,16 @@ struct context {
196196
void* meta_ring_(float** host_out);
197197
uint32_t meta_reserve_slot_();
198198

199-
// Host/device mirror per allocation, keyed by the opaque handle alloc()
200-
// returns as `native`. Views sharing a storage share the key, so one dirty
201-
// state serves every view. `where` tracks which copy is live.
202-
struct mirror {
203-
float* host = nullptr; // CPU-side buffer (storage.ptr)
204-
wgpu::Buffer dev; // device buffer
205-
size_t bytes = 0;
206-
gpu::residency live; // when to copy (gpu_abi.h); the copies are made here
207-
};
208-
std::unordered_map<void*, mirror> mirrors;
209-
210-
// Size-keyed free lists (like Metal's MTLBuffer pool and CUDA's). Repeated
211-
// alloc/free of identical shapes is the common case, and per-dispatch
212-
// allocation would compound the fixed dispatch floor.
213-
std::unordered_map<size_t, std::vector<std::pair<wgpu::Buffer, float*>>> pool;
199+
// Host/device mirror per allocation and its size-keyed free list (shared
200+
// shape with cuda.h; `gpu::mirror_table`, gpu_abi.h), keyed by the opaque
201+
// handle alloc() returns as `native`. Views sharing a storage share the
202+
// key, so one dirty state serves every view.
203+
using mirror = gpu::mirror_table<wgpu::Buffer>::entry;
204+
gpu::mirror_table<wgpu::Buffer> mt;
205+
206+
// Mapped-for-readback staging buffers (sync_to_host's D2H), a distinct pool
207+
// from the mirror table's device buffers: these are never bound as a
208+
// kernel operand, only mapped.
214209
std::unordered_map<size_t, std::vector<wgpu::Buffer>> staging_pool;
215210

216211
// Compute pipelines, keyed by WGSL entry point. Every one is built in the
@@ -347,10 +342,7 @@ struct context {
347342
return instance.WaitAny(f, UINT64_MAX) == wgpu::WaitStatus::Success;
348343
}
349344

350-
mirror* mirror_(void* native) {
351-
auto it = mirrors.find(native);
352-
return it == mirrors.end() ? nullptr : &it->second;
353-
}
345+
mirror* mirror_(void* native) { return mt.find(native); }
354346

355347
// A kernel is about to touch this buffer as `a`: bring the host copy up if
356348
// residency says so.
@@ -512,12 +504,7 @@ inline void* alloc(int64_t bytes, float** contents, bool host_fill = false) {
512504

513505
wgpu::Buffer dev;
514506
float* host = nullptr;
515-
auto it = c.pool.find(nb); // reuse a recycled buffer of this exact size
516-
if (it != c.pool.end() && !it->second.empty()) {
517-
dev = it->second.back().first;
518-
host = it->second.back().second;
519-
it->second.pop_back();
520-
} else {
507+
if (!c.mt.take(nb, dev, host)) { // no recycled buffer of this exact size
521508
wgpu::BufferDescriptor d = {};
522509
d.size = nb;
523510
d.usage = wgpu::BufferUsage::Storage | wgpu::BufferUsage::CopyDst |
@@ -532,18 +519,15 @@ inline void* alloc(int64_t bytes, float** contents, bool host_fill = false) {
532519
// host pointer is both, and malloc will not hand out the same address twice
533520
// while it is live.
534521
void* token = host;
535-
c.mirrors[token] = context::mirror{host, dev, nb, gpu::residency(host_fill)};
522+
c.mt.insert(token, dev, host, nb, host_fill);
536523
if (contents) *contents = host;
537524
return token;
538525
}
539526

540527
inline void release(void* buf, int64_t, float*) {
541528
auto& c = context::get();
542529
if (!c.ready || !buf) return;
543-
auto it = c.mirrors.find(buf);
544-
if (it == c.mirrors.end()) return;
545-
c.pool[it->second.bytes].push_back({it->second.dev, it->second.host});
546-
c.mirrors.erase(it);
530+
c.mt.release(buf);
547531
}
548532

549533
// Reconcile a buffer for a CPU access: flush pending kernels, then D2H if the

0 commit comments

Comments
 (0)