[cuda] implement max_delta_step output cap on the CUDA tree learner#20
[cuda] implement max_delta_step output cap on the CUDA tree learner#20maxwbuckley wants to merge 1 commit into
Conversation
977f51b to
0d440a1
Compare
|
Thank you, Max — and thank you, Claude Code (independently 🙂). Verdict: SOLID, no bug found. Reviewed against the CPU
Your gain-plateau scope note is honest: the structural divergence at Non-blocking: no Blocker is only the required P.S. — you threaded |
|
Thank you, Max — and thank you, Claude Code (independently 🙂). Rebase nudge 👉 This one reviewed out SOLID — it's only CONFLICTING because You have write access now (accept the repo invite if you haven't), so once it's rebased and green you can merge it yourself. 🚀 |
|
Rebased onto current Why it changed. Your Three things worth your eyes:
Validation (RTX 5090, CUDA 13.2, built from source):
Rebased, re-implemented and validated with Claude Code. CI has no GPU, so none of the above is exercised by CI — these are real GPU runs. |
ea67ef3 to
4f38657
Compare
4f38657 to
e875bef
Compare
Implements the max_delta_step leaf-output cap in the CUDA tree learner,
matching the CPU FeatureHistogram USE_MAX_OUTPUT formula:
if (max_delta_step > 0 && fabs(ret) > max_delta_step)
ret = sign(ret) * max_delta_step;
applied between the L1-threshold step and the path-smoothing step, in:
- CUDALeafSplits::CalculateSplittedLeafOutput / GetLeafGain / GetSplitGains
(cuda_leaf_splits.hpp), with the closed-form gain shortcut bypassed
whenever the cap is active (matching CPU's !USE_MAX_OUTPUT && !USE_SMOOTHING
condition)
- the root-leaf init kernels (cuda_leaf_splits.cu)
- every best-split-finder kernel: numerical, categorical, global-memory and
discretized variants (cuda_best_split_finder.cu), threaded as a runtime
double through the kernel signatures and launch macros
- the root SetLeafOutput and the refit kernels
(cuda_single_gpu_tree_learner.cpp/.cu)
This replaces the previous Log::Fatal guard: max_delta_step now works on
CUDA instead of being rejected.
Before this change CUDA silently ignored max_delta_step: with
max_delta_step=0.05, CPU capped the tree-0 leaf spread at 0.10 while CUDA
produced 4.15 (unbounded), with prediction divergence up to 17.7 (binary,
raw score). After this change the cap is enforced identically on both
backends and predictions match at FP epsilon (<= 4.4e-16) in non-degenerate
configurations.
Known limitation: when a very small max_delta_step saturates every leaf,
all candidate split gains collapse to ~0 and CPU/CUDA may pick different
but equally-optimal splits from the gain plateau (FP tie-breaking, the
pre-existing lightgbm-org#6055 family). The cap itself is still enforced and training
loss matches within 0.6%; tree structure can differ. This is covered by a
dedicated loss-equivalence test rather than an exact-match test.
Adds three regression tests to test_dual.py:
- test_cuda_max_delta_step_caps_outputs_like_cpu (cap enforcement, 6 cases)
- test_cuda_max_delta_step_matches_cpu_exactly (bit parity, 3 cases)
- test_cuda_max_delta_step_loss_matches_cpu_when_saturated (plateau regime, 2 cases)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit d9feb82)
e875bef to
4056a65
Compare
Problem: the delta
When training with
device_type="cuda",max_delta_stepwas silently ignored. The leaf-output capexists only in the CPU
FeatureHistogram::CalculateSplittedLeafOutput(theUSE_MAX_OUTPUTpath). The CUDA leaf-output device functions had nomax_delta_stepparameter and no cap.Measured delta before this PR (400×6 synthetic data,
learning_rate=1.0,num_leaves=15, 5 rounds,gpu_use_dp=true; tree-0 leaf-value spread should be ≤2*max_delta_step):CPU enforces the cap; CUDA's leaves are unbounded — predictions diverge by up to 17.7 (raw score).
Fix
Implements the cap on the CUDA tree learner, identical to the CPU formula, threaded as a runtime
double(0 = inactive) through:CUDALeafSplits::CalculateSplittedLeafOutput/GetLeafGain/GetSplitGains(the cap sits between the L1 step and the smoothing step, exactly like CPU; the closed-form gain shortcut is bypassed when the cap is active, matching CPU's!USE_MAX_OUTPUT && !USE_SMOOTHINGcondition)cuda_leaf_splits.cu)cuda_best_split_finder.cu)SetLeafOutputand the refit kernels (cuda_single_gpu_tree_learner.cpp/.cu)The first commit's
Log::Fatalguard is removed:max_delta_stepnow works on CUDA instead of being rejected.Result: the delta after the fix
Same measurement, post-fix:
And in the non-degenerate regime (regression,
learning_rate=0.1, 10 rounds,min_data_in_leaf=5):† Why some configs still differ structurally: with
learning_rate=1.0+min_data_in_leaf=1+ a tiny cap, every leaf saturates at ±max_delta_step, so all candidate split gains collapse onto a plateau of equal values. CPU and CUDA then pick different — but equally optimal — splits from the plateau (ULP-level FP tie-breaking, the known lightgbm-org#6055 family; same root cause PR #13 addresses). The cap itself is enforced identically (spread columns match exactly), and training loss agrees within 0.6% in those configs. This is a property of gain plateaus, not of the max_delta_step implementation.Tests (in
test_dual.py, gated onTASK=cuda)test_cuda_max_delta_step_caps_outputs_like_cputest_cuda_max_delta_step_matches_cpu_exactlytest_cuda_max_delta_step_loss_matches_cpu_when_saturatedAll 11 cases pass with the fix; the cap-enforcement test fails on the unpatched build. Full
test_dual.pysuite (65 tests) passes.🤖 Generated with Claude Code