Skip to content

[cuda/io] fp32 gain + fp32-atomic histogram modes; uint8/uint16/fp16 + pandas small-int ingestion#34

Open
BelixRogner wants to merge 21 commits into
masterfrom
hybrid-level-growth
Open

[cuda/io] fp32 gain + fp32-atomic histogram modes; uint8/uint16/fp16 + pandas small-int ingestion#34
BelixRogner wants to merge 21 commits into
masterfrom
hybrid-level-growth

Conversation

@BelixRogner

Copy link
Copy Markdown
Owner

Follow-up to #32, for review.

fp32 numerics modes (161dc90) — both default OFF

  • EXABOOST_FP32_GAIN: fp32/mixed gain math in the find kernels (consumer Blackwell runs fp64 at 1/64 the fp32 rate).
  • EXABOOST_FP32_HIST: fp32-pair (8 B/bin) non-quant CUDA histograms with doubled shared-memory bin capacity per partition; auto-falls back to fp64 for quant/sparse/large-bin/categorical/forced-splits/NCCL/global-memory finder.
  • Measured (3 repeats, 500-round bench params, same-session A/B): epsilon deep −36%/tree, year −18%, covtype −16%, fraud deep −14%, higgs deep −12%, numerai flat — at equal-or-better quality everywhere except a seed-noise-scale covtype dip, which is why defaults stay OFF pending the auto-tuner owning per-shape enablement (per-dataset table in the commit body / ROADMAP).
  • Flags-off bit-identity: covtype quant md5 locks and numerai int8 quant md5 unchanged.

Ingestion completion (ff77c70)

  • uint8/uint16 zero-copy dtypes through the 9c0f5ff LUT seams.
  • pandas plain-small-int frames pass through without the 4x astype(float32) copy (nullable/float/categorical frames keep the float path; nullable Int8+NA md5-matches float+NaN).
  • float16 input binned via a 65536-entry bit-pattern LUT (NaN patterns → missing bin) — exhaustive all-bit-pattern md5 gate.
  • All gated md5-identical to float32-fed models (CPU deterministic + CUDA quant).

Also merges master's CI fixes back into the branch (no-OpenMP build fix, swig download hardening, lint).

🤖 Generated with Claude Code

https://claude.ai/code/session_01EYUXNtCAXDd3zYT71m8YSJ

BelixRogner and others added 14 commits July 14, 2026 22:28
…_HIST) modes

Two opt-in reduced-precision modes for the non-quantized-dominated hot
paths on consumer GPUs (fp64 at 1/64 fp32 rate on sm_120):

- EXABOOST_FP32_GAIN=1: per-bin split-gain arithmetic in the find
  kernels runs in fp32 (leaf/quantized-level sums stay double,
  converted once per task; the once-per-task winner output block stays
  double). GAIN_T is threaded through the per-leaf, discretized and
  batched-level kernels; SplitGainMath/CUDALeafSplits helpers gain a
  type parameter (double-instantiation bit-identical). Categorical and
  large-bin global-memory kernels stay fp64.
- EXABOOST_FP32_HIST=1: non-quantized global histograms are stored as
  float pairs (8B/bin) in the leading half of each unchanged hist_t
  leaf slot, halving construct-merge/fix/subtract/find histogram
  traffic. Shared-memory accumulation was already float (!gpu_use_dp).
  Auto-falls-back to fp64 for quantized training, gpu_use_dp, sparse
  row data, large-bin partitions, categorical features, forced splits
  and NCCL; engagement is logged at debug verbosity.

Both flags DEFAULT OFF: quality-gated per dataset (3 repeats each
config, 500 rounds, bench params) they are within or above the flag-off
noise band everywhere except a ~0.1pp accuracy dip on covtype shallow
(inside the cross-seed flag-off band 0.92116-0.92299 over seeds 42-45,
i.e. seed-noise scale, but outside the same-seed repeat noise), and
numerai (feature_fraction 0.1) is quality-identical but perf-flat.

quality (mean over 3, off -> both):
  fraud shallow AUC  .97178 -> .97167   fraud deep AUC .97044 -> .97035
  higgs shallow AUC  .83075 -> .83084   higgs deep AUC .84843 -> .84859
  epsilon deep AUC   .94352 -> .94380   year RMSE      8.9718 -> 8.9717
  covtype shallow acc .92299 -> .92189  numerai300 RMSE .223662 (equal)
per-tree median (off -> gain / hist / both):
  epsilon deep  92.1 -> 72.7 / 80.9 / 58.9 ms  (-36% both)
  year shallow  1.18 -> 1.10 / 1.02 / 0.96 ms  (-18%)
  covtype shal  5.35 -> 4.62 / 4.57 / 4.50 ms  (-16%)
  fraud deep    0.95 -> 0.86 / 0.87 / 0.82 ms  (-14%)
  higgs deep    19.0 -> 18.8 / 17.2 / 16.7 ms  (-12%)
  fraud shallow 0.62 -> 0.60 / 0.58 / 0.56 ms  (-9%)
  higgs shallow 6.94 -> 6.84 / 6.65 / 6.73 ms  (-3%)
  numerai 300   30.3 -> 30.0 / 30.1 / 30.7 ms  (flat)

Flags-off bit-identity: covtype 1023/10 quant hybrid/classic md5s
5f4e7bdfff1e / fcb9f6c2ab87 and numerai int8 quant slice 763c75c0d9cb
unchanged (non-quantized CUDA training is run-to-run nondeterministic,
so its flag-off equivalence is by construction: the double
instantiations are line-identical transformations).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYUXNtCAXDd3zYT71m8YSJ
Extend the int8/int16 LUT ingestion (9c0f5ff) to unsigned and half:

- C_API_DTYPE_UINT8/UINT16: same seams as int8/int16 (basic.py dtype
  dispatch, CreateFromMats sample+push, RowFunctionFromDenseMatrix for
  predict); unsigned LUTs index at the raw value (no +half-range offset).
- C_API_DTYPE_FLOAT16: np.float16 matrices pass through as their raw
  IEEE 754 half bit patterns and bin via a 65536-entry LUT built by
  Common::HalfBitsToFloat + BinMapper::ValueToBin, so every bit pattern
  (NaNs -> missing, -0 like 0, subnormals) maps exactly as the float
  value would; predict converts per value through the same helper.
- _data_from_pandas: frames whose columns are all plain numpy-backed
  small ints (np.result_type in {int8,int16,uint8,uint16}) skip the
  astype(float) copy and feed the native array; nullable/categorical/
  object/float columns keep the old float path (NA needs NaN).
- refresh stale _c_float_array dtype tests (predate int16 landing).

md5 identity gates (f32-fed vs new-dtype-fed, 20 trees, quant CUDA +
deterministic CPU; models identical in every case):
  uint8  synth 200kx80 C/F: cpu 8b33611219d4, cuda 67f9987da540
  uint16 synth 200kx80 C/F: cpu d8d3f2f929d2, cuda 694bfb3784fd
  uint16 abs(covtype): cpu 5e3f06d6cf15, cuda 16de6c607a42
  pandas passthrough (df vs np-int vs np-f32): int8/int16 cpu
    610b2e8e1975 cuda 7f2b0842a2cc; uint8/uint16 cpu 766601b843c9
    cuda f75aab01a61f; mixed int8+int16->int16 cpu ac82b5caf5f3
    cuda c1f8a38efa1e; float-col old path cpu 509c02cc0b56
    cuda 31c5d378ec3c; nullable Int8+pd.NA vs f32+NaN cpu 3d6655f88b3d
    cuda ea7c2b457183
  fp16 synth (halves, exact ints, NaN col) C/F: cpu 8c6892eb0665,
    cuda 64e0542633a2; exhaustive all-65536 bit patterns single feature
    max_bin=65535 min_data_in_bin=1: 147d8a82f7ee
  linear_tree raw-data path: fp16 266c793708a8, uint16 736cc35d9266
  existing int8/int16 gates unchanged: synth cpu b600e0ab26bb,
    cuda/fortran 9a815e468afb; covtype cpu f9f7e62eed36,
    cuda ed39d42901ac; numerai cuda 763c75c0d9cb
  predict parity: int8/int16/uint8/uint16/fp16/pandas feeds bit-equal
    to float predictions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYUXNtCAXDd3zYT71m8YSJ
…plint)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYUXNtCAXDd3zYT71m8YSJ

# Conflicts:
#	src/io/dataset.cpp
…dmap fp32/ingestion status

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYUXNtCAXDd3zYT71m8YSJ
…uidance); ingestion completion recorded

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dataset::FastFeatureBundling burned ~7.8s on numerai (2748 dense features)
in FindGroups conflict counting (GetConflictCount alone was 38% of
construct in py-spy) and then bundled nothing. Add a precheck over the
already-collected sample: build the exact fixed per-feature non-default
index sets (FixSampleIndices semantics) as bitmaps in parallel, and for
every feature pair passing the group-size gate compute the exact conflict
via word-wise AND+popcount. If no pair could merge in either processing
order, FindGroups provably produces one feature per group; emit that
structure directly (identical deterministic shuffle) and skip both group
searches. Conservative bail-outs: filtered features present, is_sparse
second-round regroup possible (any dense rate < 0.4), or bitmaps > 2GB.

Measured (RTX 5090, 32 threads, numerai 5.43M x 2748):
  construct int8: 15.19s -> 7.48s
  construct f32:  37.51s -> 29.70s

Gates: EXABOOST_EFB_PRECHECK_VERIFY=1 (runs both paths, compares group
structure) fired+identical on numerai i8/f32, higgs, epsilon, fraud,
synth-cuda; correctly did not fire on covtype (bundles) and CPU sparse
synth (multi-val second round). Model md5 unchanged: covtype 1023/10
quant GROWTH=1 5f4e7bdfff1e / GROWTH=0 fcb9f6c2ab87, numerai int8 quant
slice 763c75c0d9cb, fraud/higgs quant A/B on==off, synth CPU f32==int8.
Kill switch: EXABOOST_EFB_PRECHECK=0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYUXNtCAXDd3zYT71m8YSJ
Replace the host per-value binning + packing push loops for dense numpy
inputs when device_type=cuda: stream row chunks through a pinned staging
ring (the raw matrix is never fully device-resident), bin on device
(per-column LUT for int8/16, uint8/16 and fp16 bit patterns - the host
tables upload once; exact BinMapper::ValueToBin double binary search for
float/double incl. NaN/missing handling), pack the per-group column bins
(4-bit where the groups use it) on device, and copy them back into the
normal host DenseBin storage so feature groups, save_binary, the fast
CUDA row-data build and the CPU predictor all work unchanged. H2D+kernel
and bulk D2H run on separate streams; the host gathers chunk k while the
GPU processes k-1 and scatters k-2 with non-temporal-store memcpy.
EFB-bundled multi-feature groups are handled exactly (ascending-column
last-non-skip-wins walk per row); multi-val or sparse bins, linear-tree
raw data and categorical features on the float path fall back to the
host loops. 4-bit bins drop their odd-nibble merge buffer via the new
Bin::SetLoadedFromRawData, skipping the FinishLoad merge.

Also two md5-safe host-side fixes the profile surfaced: FeatureGroup
creation is parallelized (the sequential zero-fill + page-locking of the
full bin storage was ~3s on numerai), and float32/float64 sampling now
uses the same column-parallel sampler as the small-int types with a
row-gather pass (the sequential per-row lambda loop was ~4s).

Measured (RTX 5090, numerai 5.43M x 2748, fresh process):
  construct int8: 7.79s -> 4.88s   (GPU binner itself 1.87s)
  construct f32: 29.88s -> 7.76s   (GPU binner itself 4.35s)
  higgs f32 1.12s -> 0.64s, epsilon f32 5.24s -> 3.12s
  (numbers vs stage-0; pre-stage-0 baselines were 15.8s / 37.4s)

Gates: EXABOOST_GPU_CONSTRUCT_VERIFY=1 builds both paths and memcmps
every group's bin bytes - byte-identical on numerai i8/f32 (full),
higgs, epsilon, fraud, covtype (bundled groups) and synthetic
i8/u8/i16/u16/f16/f32/f64 in C and Fortran order. Model md5 unchanged:
covtype 1023/10 quant GROWTH=1 5f4e7bdfff1e / GROWTH=0 fcb9f6c2ab87;
numerai int8 quant slice 763c75c0d9cb with GPU construct on and off,
also under bagging 0.8/1 (2915950ca714) and feature_fraction 0.3
(09427318aa51); synth f32==int8 on cpu/cuda/fortran. numerai 100-tree
RMSE 0.22367 unchanged; scipy sparse CUDA train unaffected.
Kill switch: EXABOOST_GPU_CONSTRUCT=0 reverts to the host push loops.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYUXNtCAXDd3zYT71m8YSJ
lgb.Dataset(...) now accepts any 2-D C- or Fortran-contiguous object
exposing __cuda_array_interface__ (CuPy arrays; for cuDF pass .values)
for float32/64/16 and int8/16, uint8/16. New C API entry
LGBM_DatasetCreateFromMatDevice takes the device pointer: sampling
gathers only the sampled rows to the host with a device gather kernel
(same deterministic row ids as the host path, fed through the regular
samplers with identity indices, so bin boundaries are identical by
construction), and the stage-1 device binner then reads the device
matrix directly (no staging ring). Datasets the binner cannot handle
(and EXABOOST_GPU_CONSTRUCT=0) fall back to streaming row chunks to the
host and running the normal push loops, so the kill switch and
EXABOOST_GPU_CONSTRUCT_VERIFY=1 byte-comparison work for device inputs
too. A device-wide sync on entry covers producer-stream semantics.

Also md5-safe sampler tuning that the cupy profile surfaced (helps the
numpy paths as well): SampleDenseSmallInt/SampleDenseFloat now count and
reserve the per-column sample vectors before filling them - the
geometric reallocation of the 6-7GB of appends dominated sampling.

Measured (RTX 5090, numerai 5.43M x 2748, fresh process, real cupy wheel
cupy-cuda12x 13.6.0): full-shape cupy int8 construct 3.4-3.8s (raw 15GB
already device-resident; below the numpy 4.5s but above the aspirational
2s - remaining time is host GreedyFindBin ~0.9s, pinned FeatureGroup
allocation ~0.5s, sample gather + samplers ~0.7s, binner ~1.0s).
numpy constructs after the sampler tuning: int8 4.5s, f32 7.2s.

Gates: model md5 identical numpy vs cupy on 1.5M-row numerai slices for
int8 and float32 (both = the standing 763c75c0d9cb lock); synthetic
f32 md5 identical across numpy / cupy C-order / cupy F-order / cupy
with EXABOOST_GPU_CONSTRUCT=0 (chunked host fallback); verify env
byte-identical for cupy i8/f32 numerai slices; full stage-0/1 gate
battery re-run green on the final build (covtype hybrid locks, numerai
int8 quant/bagging/ff A/B, synth cpu, all dtype/layout byte verifies,
numerai 100-tree RMSE 0.22367).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYUXNtCAXDd3zYT71m8YSJ
… construct 37.5->7.2s f32, 3.4s cupy)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tructure

Master loosened the rejection-message regex and split the accept tests
(int8, int16). Mirror that structure verbatim so the merge is clean, and
keep a branch-only accepts_small_dtypes test for what this branch adds
on top: uint8/uint16 zero-copy and fp16 (LUT). Reject list drops uint8
(accepted here) and keeps uint32/uint64 coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYUXNtCAXDd3zYT71m8YSJ
…ion)

When nvforest/treelite (cuml-cu12 >= 26.06) are importable, the model was
trained with device_type=cuda, and the input is a 2-D numpy/CuPy array,
Booster.predict() converts the model in memory (model string -> treelite
-> nvforest, no temp files) and predicts on the GPU. Anything FIL cannot
serve (pred_leaf/pred_contrib, extra predict kwargs, non-array inputs,
objectives outside identity/sigmoid/softmax postprocessing, conversion or
GPU errors) falls back silently to the regular predictor. raw_score is
served by rebuilding the treelite model with an identity postprocessor.
FIL models are cached per (start_iteration, num_iteration, raw_score,
precision) and invalidated on update/__boost/rollback_one_iter/
model_from_string/shuffle_models/set_leaf_output; the cache is excluded
from pickling. Opt-outs: predict(use_fil=False) and EXABOOST_FIL=0.
Residency is preserved (numpy in -> numpy out, CuPy in -> CuPy out) and
the CuPy pool is drained after host-input calls so the staged input does
not starve non-pool CUDA allocations.

Default precision is fp32 (EXABOOST_FIL_PRECISION=single|double|native):
fp64 doubles device memory -- 28.3 GB for the 1.29M x 2748 numerai slice,
OOM when a device copy of the input already exists -- and is ~5x slower
on wide host inputs.

Parity vs CPU predictor (fp32 default; max rel err, denominator |ref|>1e-2):
  fraud binary        prob 1.2e-05  raw 1.6e-05
  covtype multiclass  prob 3.2e-06  raw 5.2e-05
  year regression     3.5e-07
  categorical binary  prob 8.2e-07
  int8-fed regression 2.1e-06
  numerai reg (ff=.1) 5.2e-07
Caveat: ~0.01% of rows (69/500K on higgs) route to a different leaf where
a feature falls inside a split threshold's fp32 rounding gap (higgs AUC
unchanged to 7 decimals); EXABOOST_FIL_PRECISION=double is exact (~1e-13).

Predict wall time (3 reps, this host: RTX 5090 / 32-thread CPU):
  numerai 1.29M x 2748: CPU 0.90s | FIL host-in 1.17s | FIL cupy-in 0.046s
  higgs    500K x 28:   CPU 0.37s | FIL host-in 0.009s | FIL cupy-in 0.004s
Host-input FIL loses only on very wide inputs with small models (PCIe
transfer bound); device-resident inputs are 19-100x faster than CPU.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYUXNtCAXDd3zYT71m8YSJ
…d already device-side

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…prefix

Run the ENTIRE depth-limited exact prefix of a tree as ONE host graph
launch + ONE readback: a CUDA conditional WHILE node (CUDA >= 12.4)
repeats the per-level pipeline (batched tree split + partition apply of
level L, construct/fix/subtract + find/sync of level L+1), and a
device-side level-controller kernel at the start of each iteration
replays the host loop's inter-level decisions -- CollectSplittableLeaves
(leaf-ascending is_valid scan), ArbitrateLevelBudget (incl. the final
partial level's stable gain sort with the exact host tie-break), the
apply/pair/tree-split descriptor building (static per-feature metadata
tables + per-tree column-source table), terminal-region gap detection
(bitonic region sort + parallel gap scan) -- and resizes the body
kernels through device-updatable graph node handles
(cudaGraphKernelNodeSetGridDim / SetEnabled, one thread per node, with
an enabled-state cache), then sets the WHILE condition. No host round
trip separates the levels; the host reconstructs all bookkeeping from a
journal + the cumulative deferred split-info slab + one final best-split
readback, in exactly the host loop's order.

Key mechanics:
- WHILE body built by cudaStreamBeginCaptureToGraph over the existing
  launch functions (placeholder grids, same streams/events wiring);
  nodes collected via cudaStreamGetCaptureInfo and marked
  device-updatable (the driver returns the devNode handle through the
  SetAttribute value struct, which must be zeroed per call).
- Level-varying state (main/out index buffer swap, split-info slab
  base) reaches the four buffer-swapping partition kernels through an
  optional loop-state parameter (nullptr on every host-launched path,
  bit-for-bit the previous behavior); gap descriptors live at a fixed
  buffer offset so the gap kernel's frozen descriptor base stays valid.
- One instantiated graph per per-tree pointer/shape key, LRU-cached
  (64): multiclass cycles one gradient pointer per class, the compact
  column view double-buffers its data and varies its construct BLOCK
  shape per tree (block dims are not device-updatable) -- each shape
  gets its own instance captured with the exact host block dims, so
  results stay bit-identical to the host loop (numerai 100-tree RMSE
  0.22366795 and 2000-tree predictions reproduce exactly, both paths).
- Scope: depth-limited one-sync prefix only (non-quantized; quantized
  training keeps the host loop's classic readback ordering), gated on
  num_leaves <= 2047 / max_depth < 32 / no debug envs; runtime driver
  check (>= 12.4) and graceful fallback to the host loop on any capture
  or instantiation failure. EXABOOST_GRAPH_LEVEL_LOOP=0 kill-switch
  restores the host level loop bit-for-bit.
- Budget-limited configs keep the selective grow-then-prune host flow
  untouched (covtype 64/12 quant md5 01a30b7ff02f unchanged, no graph
  engagement).

Verification (RTX 5090, CUDA 12.9, driver 595.71):
- md5 locks (graph env ON and OFF): covtype 1023/10 quant GROWTH=1
  5f4e7bdfff1e / GROWTH=0 fcb9f6c2ab87; numerai int8 quant slice
  763c75c0d9cb; fraud 63/6 quant 1037798704d1 and year 63/6 quant
  7d07dac08d19 identical graph on/off.
- numerai 32/5 ff0.1 non-quant: 100-tree RMSE 0.22366795 EXACT both
  paths; 2000-tree run bit-identical predictions, RMSE 0.22367504.
- fraud/covtype/year non-quant A/B: quality + mean_leaves identical.
- compute-sanitizer racecheck + memcheck clean with the graph active.
- pre-commit --all-files clean.

Perf (same-session env A/B, median of 3, nothing else running):
- fraud 63/6   1000 trees: 0.63 vs 0.66 s   (-4.5% wall, 630 vs 657 us/tree)
- fraud 1023/10 1000 trees: 0.93 vs 0.98 s  (-5.4%)
- covtype 63/6  500 rounds x7: 2.42 vs 2.51 s (-3.5%)
- year 63/6    1000 trees: 1.22 vs 1.25 s   (-2.6%)
- numerai 32/5 ff0.1 300 trees: 10.25 vs 10.22 s (parity; construct-bound)
- numerai 2000-tree full train_s: 59.39 vs 59.42 s (parity)
- nsys fraud 500 trees: cudaLaunchKernel 35,323 -> 7,022 calls
  (70.6 -> 14.0 per tree, launch API ~132 -> ~27 us/tree); sync
  memcpys 21.2 -> 11.1 per tree. numerai: launches 12,926 -> 3,163
  per 200 trees, 20 instantiates (shape warmup), 1 graph launch/tree.
- device level-controller kernel: 7.2 us/level after warp-shuffle
  scans + per-thread node updates (was 16.5 us serialized).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYUXNtCAXDd3zYT71m8YSJ
… identified)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@BelixRogner

Copy link
Copy Markdown
Owner Author

Overnight additions (all gated, pushed to this branch)

commit what measured
c9b00820 EFB density precheck −7.7s construct on numerai; refuses where bundling exists
ca5bca30 GPU dense-matrix binning (pinned-ring streaming, bins byte-identical) numerai construct f32 37.5→7.2s, int8 15.2→4.5s; higgs 0.65s, epsilon ~3s
633bba9e CuPy / __cuda_array_interface__ ingestion full-shape device-resident int8 construct 3.4s; md5 = numpy-fed
013c77ed Booster.predict() → cuML FIL soft-integration (in-memory handoff) numerai predict 0.90s → 0.046s (CuPy in); fp64 mode bit-exact
99734bae Graphs L1: conditional-WHILE device level loop, device-updated grids launch API 132→27µs/tree; fraud −4.5%, covtype −3.5%; bit-identical models
4c1b436a dtype test reconciliation with master

Validation eval was verified already device-side (0.22ms/iter at numerai scale) — no work needed.

Refreshed official scoreboard at this tip (RTX 5090, medians of 3): 11 wins, 0 ties, 0 losses vs XGBoost — numerai train 59.5s vs 235.9s (3.97×), and construct now also beats XGBoost on the big/wide sets (numerai 7.2 vs 35.5s). All md5 locks unchanged; compute-sanitizer clean on the graph loop.

🤖 Generated with Claude Code

BelixRogner and others added 7 commits July 15, 2026 06:18
…ILE node)

The depth-limited gate bounds the prefix's level count (max_depth <= 11:
2^max_depth <= num_leaves + 1 <= 2048), so the conditional-WHILE body
relaunch was pure overhead. The graph is now max_depth straight-line level
bodies plus one epilogue controller, captured back to back with the same
stream/event wiring the WHILE body used. Each body owns its
device-updatable node slice (stride kHybridGraphMaxNodes); the controller
resizes only ITS level's nodes. Early tree completion sets a done flag in
the loop state: the remaining bodies' controllers exit immediately and
keep their nodes disabled through the per-node enabled cache, so
steady-state same-shape trees issue no redundant device graph updates.

Also fixes a latent race in the L1 controller that racecheck could not
see through the conditional body graph: BlockMax's first s_warp write
could race the preceding BlockExclusiveScan's tail read of s_warp under
independent thread scheduling. BlockMax now syncs on entry
(racecheck-verified: the cuda_hybrid_graph.cu hazards are gone; only the
pre-existing upstream ShuffleReduceSum pair remains, which the graph-OFF
host path reports too).

Gates: covtype 1023/10 quant GROWTH=1/0 md5 5f4e7bdfff1e/fcb9f6c2ab87;
numerai int8 quant 763c75c0d9cb; fraud 63/6 + year 63/6 quant graph A/B
md5-identical; covtype 63/6 quant bagging 0.8/1 A/B aa6fe97eecb8;
numerai 100-tree RMSE 0.22366795 (graph A/B); covtype 64/12 stays on the
selective host flow (01a30b7ff02f).

Perf (same-session env A/B, median of 3, EXABOOST_GRAPH_LEVEL_LOOP=1 vs 0):
fraud 63/6 1000t 0.610 vs 0.670s (-9.0%, was -4.5% at L1); fraud 1023/10
0.900 vs 0.980s (-8.2%); covtype 63/6 500t 2.405 vs 2.510s (-4.2%);
year 63/6 1000t 1.210 vs 1.260s (-4.0%); numerai 32/5 ff0.1 300t parity
(-0.1%). nsys fraud node trace: controller 16.5 -> 6.9 us avg
(115 -> 48.5 us/tree); launch-side API ~30.5 us/tree (graph ON) vs
~162 us/tree host loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYUXNtCAXDd3zYT71m8YSJ
…ates

Cache each device-updatable node's last-set grid in its slot and skip
cudaGraphKernelNodeSetGridDim when unchanged (mirroring the existing
enabled-state cache). This only pays off after stage 1's unroll: each
level body owns its nodes, and 7 of the ~10 roles' grids depend only on
the level's split count, which is stable across same-shape trees -- in
steady state a level issues device updates only for the data-dependent
extents (partition x = max blocks per split on 2 nodes, construct grid y,
gap-copy nodes), i.e. ~3-4 instead of ~10 calls per level.

Per-level grid-policy note (stage 2b): static worst-case x-extents for
the partition kernels were NOT baked -- the x dims are data-dependent
per tree, and padding to the root worst case would add (worst - actual)
* level_splits idle blocks per level, which costs more than the ~0.7us
update call the cache already skips when the value repeats.
The controller stays a separate node (stage 2a not taken): its non-update
cost is ~2us; fusing it into the last sync kernel's tail risks the exact
host-replay semantics for less than the remaining update cost.

Gates: covtype 1023/10 quant GROWTH=1/0 md5 5f4e7bdfff1e/fcb9f6c2ab87;
numerai int8 quant 763c75c0d9cb; fraud 63/6 + year 63/6 quant graph A/B
md5-identical; covtype 63/6 quant bagging 0.8/1 A/B aa6fe97eecb8;
numerai 100-tree RMSE 0.22366795 (graph A/B); covtype 64/12 stays on the
selective host flow (01a30b7ff02f). racecheck fraud 63/6: only the
pre-existing upstream ShuffleReduceSum hazards (graph-OFF shows the same).

Perf (same-session env A/B, median of 3, EXABOOST_GRAPH_LEVEL_LOOP=1 vs 0):
fraud 63/6 1000t 0.610 vs 0.660s (-7.6%); fraud 1023/10 0.880 vs 0.970s
(-9.3%, stage 1 was -8.2%); covtype 63/6 500t 2.375 vs 2.500s (-5.0%,
stage 1 -4.2%); year 63/6 1.200 vs 1.250s (-4.0%); numerai 32/5 ff0.1
parity. nsys fraud node trace: controller kernel 6.85us avg (stage 1
6.93us) -- on fraud the per-tree tree-shape variation defeats the
n-keyed cache at deeper levels, so the win concentrates on the wide
configs (fraud 1023/10, covtype); no config regressed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYUXNtCAXDd3zYT71m8YSJ
…ound

Level body B applies at most min(2^B, 1024) splits (each level at most
doubles the leaf count from 1), but every controller ran with a fixed
1024-thread block. Block dims are per-node constants of the unrolled
graph, so each body's controller now bakes its own bound at capture
(64 threads for bodies 0-4 through 2*2^B, capped at 1024): small-level
bodies drop from 32 warps to 2 through the ~20 block-wide barriers.
The shared-staging guard additionally trips if a body's structural
bound is ever exceeded (impossible in the gated regime, as before).

Also fixes the three pre-existing cpplint findings in
cuda_hybrid_graph.hpp (include order x2, guarded duplicate include).

fraud 63/6 nsys (200 trees): controller 6.85 -> 5.97us avg; working
64-thread bodies 6.63us vs 2.0us for the update-free epilogue, i.e.
the remaining cost is the serialized device graph updates, not block
width. Gates: covtype/numerai/fraud/year/bagging md5 locks unchanged,
graph ON/OFF bit-identical, numerai RMSE 0.22366795, selective flow
untouched, racecheck clean (0 hazards).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYUXNtCAXDd3zYT71m8YSJ
… slab + best splits)

The graph prefix paid three synchronous D2H round trips per tree: the
journal readback, FinishSplitBatch's deferred split-info slab, and the
tail's SyncAllLeafBestSplitsToHost. Nothing mutates those device
buffers between graph completion and the reads (FinishHybridGraphLevels
is host-only bookkeeping; the other two are pure reads), so all three
copies are now issued async on the graph's stream at host-known
upper-bound sizes (num_leaves - 1 splits / num_leaves leaves) right
after cudaGraphLaunch, covered by ONE cudaStreamSynchronize. Values are
bit-for-bit those of the previous synchronous copies. The full
cross-tree overlap (defer the journal walk past the next tree's
gradients) stays out: CUDATree::Shrinkage/GBDT consume the host
mirror's num_leaves immediately after Train returns, and the leaf-wise
tail arbitrates on FinishLevelBookkeeping outputs inside Train, so the
walk is on the critical path by contract.

fraud 63/6 1000t median: graph ON 0.59s vs OFF 0.67s (-11.9%, was
-7.6..-7.7%); fraud 1023/10 -10.9%; covtype 63/6 -8.8%; year 63/6
-5.8%; numerai parity (construct-bound). nsys fraud: sync cudaMemcpy
11 -> 8 per tree, one stream sync per tree carrying the graph wait.

Gates: all md5 locks unchanged (covtype 1023/10 GROWTH, numerai int8,
fraud/year quant A/B, covtype bagging, selective 64/12), numerai RMSE
0.22366795 ON/OFF, early-stopping valid_sets best_iter parity,
racecheck: graph path clean (6 hazards, all the known upstream
ShuffleReduceSum pair). Known pre-existing (verified at 2e047b0,
before this session): rare intermittent cudaErrorIllegalAddress on
multiclass covtype with the graph loop ON (~10-25% of 60-round runs),
rate unchanged by this commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYUXNtCAXDd3zYT71m8YSJ
…t-cause

Pre-existing intermittent cudaErrorIllegalAddress on multiclass with the
graph loop active (~7/30 runs at 2e047b0 baseline, machine-state
dependent; repro in the session scratchpad; sanitizers cannot attach to
device-side graph updates). Keep num_class>1 on the host loop until
root-caused. 10/10 multiclass repro runs clean with the guard; covtype
quant md5 lock unchanged (5f4e7bdfff1e).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYUXNtCAXDd3zYT71m8YSJ
…rget training item

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…aints, forced, linear)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant