← Projects

GBDT Inference Engine

A from-scratch CUDA inference engine for XGBoost tree ensembles, and a benchmark suite that measures where GPU inference actually beats a well-tuned CPU baseline.

CUDA · C++ · Python

GitHub ↗

A CUDA inference engine for XGBoost gradient-boosted decision tree ensembles, built without using any existing tree-inference library, and a benchmark suite that measures where GPU inference actually beats a well-tuned CPU baseline. The engine parses XGBoost’s serialised JSON model into its own in-memory representation, flattens every tree into a complete binary array laid out for the device, traverses the whole ensemble in one kernel, and reports host-to-device transfer, kernel execution, and device-to-host transfer as three separate numbers instead of one blended wall-clock figure.

The point is the measurement more than the kernel. Published GPU tree-inference benchmarks usually compare against scikit-learn, which is not a serious CPU baseline. Here the comparison is XGBoost’s own native C API predict path, run on the same physical machine as the GPU, plus NVIDIA’s RAPIDS Forest Inference Library (FIL) as a third reference point.

Results

Everything was measured on one Runpod pod — an RTX 4090 (128 SMs, 72 MB L2) alongside a 16-core Ryzen 9 7950X — so the GPU kernel and the CPU baseline share a machine, a memory subsystem, and a thermal envelope. Timing GPU latency on a rented instance and CPU latency on a laptop would have made the crossover claim meaningless.

One booster is trained to 333 rounds and then sliced by boosting round into ten ensembles of 99 to 999 trees, so every ensemble is literally a prefix of the larger ones and tree count is the only thing that varies. All three benchmarks sweep the same fifteen geometric batch sizes (1 to 10,000) and run 50 seeded trials per point against the same 15,000-row sample pool.

There is no crossover in the tested range — the GPU is already ahead at batch size 1, against both baselines, for every one of the ten ensemble sizes. At a batch of a single sample, median end-to-end GPU latency sits at 0.023–0.024 ms and barely moves from 99 trees to 999, while XGBoost’s native predict path on the same machine ranges from 0.090 ms to 0.160 ms — a 3.9x to 6.8x speedup.

The tails are the more interesting half. GPU p99 stays within about 30% of GPU median, while CPU p99 runs two to four times CPU median and gets worse as the ensemble grows; at 699 trees the CPU p99 is 0.545 ms against a 0.144 ms median. Re-running the whole crossover analysis at p99 rather than median moves nothing.

Why the GPU wins at batch 1

Every GPU stage has a fixed floor that a single sample never comes close to touching, and that floor is smaller than what XGBoost spends per call. At 999 trees and batch 1, the 36 bytes of input take 4.6 µs to move — pure fixed cost, since the same transfer at batch 10,000 moves ten thousand times the data in ten times the wall clock. The kernel holds between 6.1 and 7.2 µs from batch 1 all the way to batch 16, almost none of which is arithmetic.

The thread mapping matters here. Each thread handles one (sample, tree) pair, and tree_idx is the fast-varying half of the global id, so 32 consecutive threads in a warp traverse 32 different trees against the same sample. A batch of one against a 999-tree ensemble still launches 999 threads across 4 blocks instead of leaving a single lane doing all the work. The alternative mapping would have put 31 of 32 lanes idle in exactly the regime the benchmark exists to characterise.

Where it stops winning

Running the sweep out to 10,000 samples flips the result for nine of the ten ensembles, and the mechanism is the shape of the output rather than the traversal. The kernel writes one float per (sample, tree) pair and leaves the summation across trees to the host, so the device-to-host payload is batch x num_trees x 4 bytes while host-to-device is only batch x num_features x 4 — a 111-to-1 asymmetry at 999 trees and 9 features. Batch 10,000 sends 360 KB to the device and drags 39.96 MB back, and that copy is 87% of everything the GPU path does.

The same limit explains the FIL comparison. At batch 1 against 999 trees this kernel comes in at 0.024 ms against FIL’s 0.197 ms; by batch 10,000 FIL is at 0.283 ms and this is at 18.66 ms. FIL reduces across trees on the device and returns one score per class per sample, so its output payload is 333 times smaller. Almost the entire large-batch gap is the reduction that isn’t implemented here, not a statement about traversal quality.

Design

Dense tree layout. Every tree is expanded into a complete binary array of 2^(depth+1) - 1 nodes, so the left child of node i is at 2i + 1 and the right at 2i + 2. No child pointers are stored at all, which leaves a 12-byte DenseNode and eliminates pointer chasing. The cost is wasted space for unbalanced trees; at depth 6 that’s 127 nodes per tree and 1.52 MB for a 999-tree ensemble, small enough that the upload is a one-time cost.

Keeping the model resident. The first version of the launch function re-uploaded the whole ensemble every trial, so h2d was almost entirely the cost of re-sending 1.52 MB of unchanged tree data and the actual new samples were lost inside it. DeviceModel uploads once at construction and times that separately, which is what makes the per-stage breakdown mean anything.

CUDA behind a CMake option. Development happened on an M1 Mac with no CUDA toolchain, so enable_language(CUDA) lives inside cuda/CMakeLists.txt and that subdirectory is only added when BUILD_CUDA is on. The parser, the CPU reference traversal, the XGBoost baseline, and two thirds of the correctness gate all build and run locally; a rented GPU is only needed for the parts with nowhere else to run.

Correctness before timing. parse_check gates every timing number: the plain traversal against XGBoost’s own margins to 1e-4, the dense traversal against the plain one per tree, and the GPU kernel’s per-(sample, tree) leaf values against the CPU traversal. NaN is deliberately injected into a held-out test set to exercise XGBoost’s default_left semantics — and deliberately kept out of the benchmark pool, so an uncounted fraction of samples can’t silently traverse differently inside a measurement meant to isolate batch and ensemble size.

C++23 on the host, CUDA C++20 on the device, with nlohmann/json, rapidcsv, and a CUDA error-checking wrapper pulled in through CMake FetchContent.

Limitations

The GPU path doesn’t sum across trees on the device, so it produces the per-tree leaf values a prediction is made of rather than a finished prediction. Every large-batch number carries the cost of that decision. Only the dense layout was built, so there’s no dense-versus-sparse comparison. All models are depth 6 with 9 features on simulated data, and the largest tree buffer is 1.52 MB against 72 MB of L2, so every ensemble in the sweep is fully cache-resident and none of the results say anything about what happens when one stops fitting.

Full write-up, benchmark CSVs, and Nsight Systems traces in the repository.