10. GPU training¶
The idea¶
Everything expensive in histogram GBT is a data-parallel reduction or scatter over rows: bin the raw values, sum gradients into cells, scan cells for the best split, route rows to children. A GPU has ~10× the memory bandwidth of a CPU socket, and bandwidth is exactly what these loops are starved for. What a GPU does not have is cheap access to the sequential, branchy part: deciding which nodes to split, bookkeeping the tree, enforcing constraints.
So the design question is not "port the algorithm to CUDA" but "draw the boundary": the device owns every per-row loop, the host owns every per-node decision, and the two exchange the smallest possible messages. In bonsai that boundary has a name: the level transaction (architecture/14), and the CPU and CUDA engines implement the same transactions, so a grower cannot tell which backend it is running on. That is the whole trick; the rest of this chapter is the three problems that make the device side interesting: atomics and precision, keeping data resident, and knowing what to move next.
The math¶
Histogram accumulation is a race by construction. Thousands of threads add (g_i, h_i) into 255 shared cells, so the adds must be atomic, and the order they land in is whatever the scheduler produced. Floating-point addition is not associative:
with \(u\) the unit roundoff. Two consequences you must design for rather than wish away: GPU histograms match CPU histograms to tolerance, not bit-exactly, and two GPU runs of the same fit can differ in the last ulps.
The precision scheme keeps \(\varepsilon\) small without paying for 64-bit atomics (which real hardware serializes through a compare-and-swap loop): accumulate in float within a chunk of at most 32k rows (shared-memory float atomics are native and fast), then merge the per-chunk partials in double. The float stage bounds \(n\) in the error term at 32k; the double merge makes the cross-chunk sum effectively exact. Split scoring then happens in double on cells that are already sums of ≤32k floats.
The subtraction trick survives intact (chapter 2): children of a split partition the parent's rows, so the device builds the smaller child's histogram and derives the larger by a cell-wise subtract kernel, in double, on resident buffers, without the host ever seeing a histogram.
In bonsai¶
The entire backend is one CUDA C++ translation unit, src/cuda/histogram_engine.cu plus its kernel header src/cuda/detail/kernels.cuh, compiled by the project's own clang (-x cuda), not nvcc, same C++23, same libc++ as the rest of the build. Builds without CUDA link a stub that throws; bonsai::cuda_available() is the runtime predicate and the cuda_* growers are registered everywhere, so a GPU-trained model predicts fine on a CPU-only binary.
Follow one fit through the transactions:
- Ingest:
cuda_ingest(decision 54): raw feature values stream to the device in ~64MB chunks and a kernel bins them with alower_boundthat reproduces the hosttransformexactly: same cuts, same comparisons, bit-identical bin ids. The product, anIngestPlane, rides on theDatasetas an opaque receipt; host binned columns are never materialized unless a host consumer asks. At 16M×100 this replaced 4.6s of host binning plus a 1.6GB upload with ~0.5–0.9s of transfer+kernel (host-dependent). begin_tree: the per-tree gradient upload, interleaved into(g,h)pairs on device.open_level(find): level histograms live in a slot-indexed device buffer that ping-pongs between parent and child levels. The find kernel gives each (node, feature) pair one warp: a shuffle-based prefix scan over the cells, per-lane gain scoring, and a shuffle argmax that reduces to the best split with a fixed tie-break. Only the per-node decisions (feature, bin, gain, child sums, a few hundred bytes) cross back to the host.apply_level(partition): route/count/scan/scatter kernels move each split node's row segment into stable left/right children entirely on device; the host receives two integers per split (the child row counts). Stability matters: it is what keeps the device partition semantically identical to the host one.- Histogram build: the shared-memory chunked kernel for large children, a direct-to-global kernel for small ones (a full shared-memory pipeline per 40-row node is all overhead), then the subtract kernel for every larger sibling.
end_tree: the epilogue (decision 53): the host sends the finished tree's node-value table (a few KB); a kernel maps every row's resident leaf assignment to its training value; values and leaf ids come home in two bulk copies. Before this transaction existed, the host looped over 16M rows per tree.
The host control plane between transactions is src/level_step.hpp and the growers, the same plan_level/commit_children logic the CPU path uses, because it is the CPU path's logic. When the device declines (a feature's bins exceed the shared-memory budget), the same grower runs the same tree on the host engine mid-fit.
What this buys, measured (same-pod L40S re-baseline, 16M rows × 100 features × 100 trees, fit() timed end-to-end including binning): bonsai 18.4s oblivious / 20.5s depthwise vs XGBoost-GPU 19.9s, at a third of the host memory (7.0GB vs 22.2GB), rendered at Fit at scale. The path from 3× slower to ahead is chapter 11.
Try it¶
No CUDA device on your machine? The RunPod runbook gets you a validated GPU session for well under a dollar.
# The device suite: SKIPs without a GPU, exercises real kernels with one.
./build-cuda/tests/bonsai_tests "[cuda]"
# Any fit, with the device profile lines:
BONSAI_CUDA_PROFILE=1 BONSAI_GROW_PROFILE=1 \
bonsai fit --config configs/california_housing.toml \
--set dispatch.grower_name=cuda_depthwise
Read the cuda-profile line first: upload vs gpu vs cpu_fallback tells you whether the device path even ran (a large cpu_fallback means max_bin pushed past the shared-memory ceiling). Then cuda-upload-decomp splits every transfer by transaction: this is the line every optimization in chapter 11 was priced against.
# CPU vs GPU on the same data — expect tolerance-level agreement, not bit-equality:
uv run scripts/compare.py --config configs/california_housing.toml \
--growers depthwise,cuda_depthwise
Gotchas & war stories¶
- The allocator can cost more than the kernels. On GeForce drivers, the default CUDA mempool returns freed memory to the OS at every sync, and the resulting alloc/free churn synchronizes the whole process: a measured 11–14 seconds per fit on an RTX 5090 before decision 48 pinned the pool's release threshold. If a GPU fit is inexplicably slow, suspect memory management before kernels.
- Some rented GPUs are just broken. A whole class of hosts showed ~300µs per device synchronization (healthy: ~4µs), invariant to every software knob, an ASPM/IRQ-level defect. Multiply by ~25k syncs per fit and the machine is unusable. The bench harness front-loads a 30-second sync probe and rejects hosts above 50µs (decision 48).
- A demoted split is not the same thing on both planes.
demote_empty_splitsun-splits a node whose child would be empty. On the host that is bookkeeping; on the device the rows were already scattered and stamped, so demoting orphaned their leaf assignments, one mega-leaf per tree at the rightmost spine, quality collapsing only at 16M rows where double-precision sums stopped masking it (PR #29). Control-plane operations must know which plane's invariants they touch. - "First use is single-threaded" is an assumption, not a law. The lazy host materialization of device-binned columns was first guarded by nothing, on the precedent of another lazy member, but its first consumer runs inside a parallel loop, and the race corrupted the heap intermittently (one clean validation pass meant nothing). It is now a
call_once(PR #37). On the same theme: two GPU fits of identical inputs differ in atomic order, so GPU-vs-GPU test tolerances belong at the suite's 1e-4, not tighter. - Byte-identity is still available where it matters. Device binning is bit-identical to host binning (same cuts, same
lower_bound, integer outputs, no atomics), which is what lets a device-binned dataset train the same model as a host-binned one and lets the test suite say so exactly.