[cuda] make BitonicArgSort_1024 / _2048 stable on tied values#10
[cuda] make BitonicArgSort_1024 / _2048 stable on tied values#10maxwbuckley wants to merge 27 commits into
Conversation
The two device-side bitonic sorts used the comparator (scores[a] > scores[b]) == ascending which evaluates to true when scores[a] == scores[b] and ascending=false (because false == false), causing ties to be swapped during a descending sort. The output index permutation for tied values then depended on the network structure rather than the input order. CPU code paths use std::stable_sort, which never swaps equal elements. Aligning CUDA's behavior closes the LambdaRank round-1 divergence where all scores are identically zero and the resulting sort permutation determines which document pairs accumulate gradient first. Symptom on a 10-query, 20-items-per-query dataset (round 1, all scores == 0): before: CPU/CUDA max|Δ| raw_score = 0.29 after: CPU/CUDA max|Δ| raw_score = 0.14 (The remaining round-1 divergence is FP-precision in atomicAdd_block order across pairs, which is documented "expected" by upstream maintainers in lightgbm-org#6055 and is unaffected by this change.) Fix: replace `(a > b) == ascending` with the strict-direction form ASCENDING ? (a > b) : (a < b) in both BitonicArgSort_1024 and the equivalent block in BitonicArgSort_2048's outer ascending/descending loops. Verified with the CPU/CUDA parity sweep: no regression on any of the previously-clean cases (categorical kernel still uses BitonicArgSort_1024 in the many-vs-many split path; that case still matches at FP epsilon). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Verifies that CUDA LambdaRank's round-1 predictions stay within ~0.2 of CPU on a small synthetic dataset where all initial scores are zero. Without the BitonicArgSort tie-stability fix in the prior commit, this case diverged by ~0.29; with the fix it drops to ~0.14 (FP-precision residual from pair-gradient atomicAdd ordering, expected per lightgbm-org#6055). The 0.2 threshold catches the bitonic-sort regression while tolerating the FP-precision residual. Gated on LIGHTGBM_TEST_CUDA=1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Thanks Max, and Claude Code. The motivation is right — But I want to flag what I think is a regression in the The two halves of the diff disagree on which variable to consult. In Look at the upstream template <..., bool ASCENDING>
__device__ ... BitonicArgSort_1024(...) {
for (outer_depth ...) {
...
const bool ascending = ASCENDING ? (outer_segment_index % 2 == 0) : (outer_segment_index % 2 > 0);
// ^^^^^^^^^^^^^^^^^^^^^^^ per-pass local, NOT the template parameter
for (inner_depth ...) {
if ((... > ...) == ascending) { swap }
// ^^^^^^^^^ uses the per-pass localThat's textbook bitonic sort: outer phases alternate direction so the inner merge can pull a bitonic sequence into a monotone one. The comparator has to consult per-pass PR #10's const bool need_swap = ASCENDING // ← uppercase, template param
? (scores[...] > scores[...])
: (scores[...] < scores[...]);I believe that breaks the bitonic merge for non-tied inputs. The reason your lambdarank test passes despite the breakage is exactly the same reason the original bug only surfaced there: round-1 scores are all zero, so the strict comparator returns The
Suggested fix for const bool need_swap = ascending // lowercase, the per-pass local
? (scores[indices[threadIdx.x]] > scores[indices[index_to_compare]])
: (scores[indices[threadIdx.x]] < scores[indices[index_to_compare]]);A test with non-trivial input — e.g. a categorical-split sort with all-distinct, well-separated scores, asserting CPU and CUDA agree on the resulting permutation — would also catch this case that lambdarank round-1 can't. Happy to be wrong here if you can show me an input that exercises Same env var convention note as #6/#7/#8/#9 — align to |
CUDA 13.0 removed offline-compilation support for Maxwell (sm_50/52/53),
Pascal (sm_60/61/62), and Volta (sm_70/72). With nvcc 13.x, the
unconditional inclusion of sm_60/61/62/70 in CUDA_ARCHS causes the
build to fail with:
nvcc fatal : Unsupported gpu architecture 'compute_60'
Gate those architectures behind a CUDAToolkit_VERSION VERSION_LESS
"13.0" check. With CUDA >= 13.0 the initial list starts at "75"
(Turing); the existing version-conditional appends below add 80, 86,
87, 89, 90, 100, 120 as appropriate.
Verified locally with CUDA 13.2 + RTX 5090 (sm_120): builds and
installs cleanly without any other changes.
Reference for the dropped capabilities:
https://docs.nvidia.com/cuda/cuda-toolkit-release-notes/
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cpplint's --root=.. workaround derived the expected header-guard prefix from the parent directory name. After renaming the repo to ExaBoost, that prefix changed from LIGHTGBM_INCLUDE_*_H_ to EXABOOST_INCLUDE_*_H_ and every header now fails build/header_guard. We deliberately did not rename the C/C++ symbols (still LightGBM, LGBM_*, import lightgbm) to keep ExaBoost binary-compatible. Disable the header-guard check in the cpplint pre-commit hook to match the existing setup in .ci/lint-cpp.sh. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Builds with -DCMAKE_CUDA_ARCHITECTURES (e.g. "120-real" for a single-GPU local iteration on RTX 5090) currently get overwritten unconditionally by the toolkit-version-driven CUDA_ARCHS list, producing a multi-arch build that takes much longer to compile and isn't what the user asked for. Wrap the existing toolkit-version logic in a check that only applies it when CMAKE_CUDA_ARCHITECTURES is unset or empty. When the user passes it explicitly, use their value verbatim. No behavior change for users who don't pass the flag. Composes with lightgbm-org#5 (the toolkit-version gating for CUDA 13.x dropped archs) — both branches together give a sane default that adapts to the toolkit, plus an escape hatch for fast local iteration. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…v var; cover non-tied input Felix's review on lightgbm-org#10 caught a regression in the BitonicArgSort_1024 half of the original tie-stability fix: I had switched the comparator to read the template parameter `ASCENDING` (the global sort direction) instead of the per-pass local `ascending` (which alternates by outer segment index for the bitonic merge). The all-tied LambdaRank round-1 test passed either way because strict comparison returns false for equal scores -- so the broken comparator never made a wrong swap on that input -- but for non-tied inputs the bitonic merge stops pulling a bitonic sequence into a monotone one and the sort silently produces wrong orderings. The _2048 half of the diff was already correct (uses per-pass `ascending`), so this is symmetric: switch _1024 back to `ascending` to match. Also adds test_cuda_bitonic_argsort_1024_with_distinct_scores_matches_cpu to test_dual.py: trains a regression on a single categorical feature with distinct, well-separated per-category sums, which exercises BitonicArgSort_1024 over non-tied scores via the categorical split-finder. Asserts CPU and CUDA agree on predictions after one boosting round; the wrong-comparator case would diverge. Aligns the env var gate to TASK=cuda to match test_engine.py's existing convention (same change requested on lightgbm-org#6/lightgbm-org#7/lightgbm-org#8/lightgbm-org#9). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Good catch — you're right. Pushed
|
sklearn>=1.9 dev routes check_classification_targets and LabelEncoder
through narwhals, which raises TypeError on a bare pyarrow Array /
ChunkedArray ("Please set `allow_series=True` or `series_only=True`")
because sklearn does not pass that flag. The Python - latest versions
(manylinux_2_28) CI job has been failing for 18 test variants of
test_classification_and_regression_minimally_work_with_all_accepted_data_types
on every PR for this reason.
We advertise pyarrow Array / ChunkedArray as accepted label types
(_LGBM_LabelType), so the user-facing contract should be preserved.
Convert eagerly to numpy at the top of LGBMClassifier.fit, before
calling into sklearn — _LGBMAssertAllFinite, _LGBMCheckClassificationTargets,
and _LGBMLabelEncoder all see a familiar 1-D array.
No behavior change for non-pyarrow y. Regression tests (LGBMRegressor)
don't hit this path because they don't call check_classification_targets;
they were already passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Compares matched configurations on CPU and CUDA at tight tolerance (1e-5 raw_score, exact tree structure). Initial run on 19 tiny configs finds 6 with real prediction divergence (reg_quantile, reg_categorical, reg_l1, reg_bagging, reg_max_depth, multi_dense) and 13 where predictions match at FP epsilon despite tree-dump threshold differences. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The CUDA categorical split-finder kernels accepted min_data_per_group as a function parameter but never referenced it in the function body, so the constraint had zero effect on CUDA training. CPU correctly enforces it via FindBestThresholdCategoricalInner in feature_histogram.cpp. Add the missing left/right count check to the candidate-acceptance condition in both the shared-memory and global-memory variants of the categorical kernel, in both the left-to-right and right-to-left scans. Verified with scratch/probe_categorical3.py: across min_data_per_group values from 1 to 1,000,000, CPU and CUDA now produce identical splits or both correctly decline to split. Also closes the reg_categorical case in the broader CPU/CUDA parity sweep. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Felix asked for a real CI-runnable regression test that locks in the
categorical-kernel fix. Mirrors the scratch/probe_categorical3.py probe:
on a 200-row, 5-category dataset (~40 rows per group), train one round
on CPU and CUDA at min_data_per_group in {10, 41, 100, 1000} and assert
both produce the same split decision.
Before the fix, CUDA accepted the split at mdpg in {100, 1000, 1_000_000}
while CPU correctly refused; the assertion (None, None) != (0, 44.910)
trips loudly.
Gated on TASK=cuda to match the existing CUDA-only test pattern in
test_engine.py.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The reg_categorical case now lives in test_engine.py as a real regression test, so the dev-only parity script no longer needs to ship in the production tree. Removing it also clears the lint errors (T201 print, F841 unused var) that were blocking CI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The CUDA PercentileDevice (used by L1 and quantile leaf-value renewal)
computed the percentile position against `len` instead of `len - 1`,
and indexed it as 0-based instead of CPU's 1-based-with-+1 offset.
For alpha=0.5 (median), this returned the upper-middle element on
even-length arrays and the average of the upper-middle and median on
odd-length arrays - i.e., systematically biased upward in the
descending-sort convention that PercentileDevice uses.
CPU PercentileFun (src/objective/regression_objective.hpp:28-29):
const double float_pos = static_cast<double>(cnt_data - 1) * (1.0 - alpha);
const data_size_t pos = static_cast<data_size_t>(float_pos) + 1;
...
const double bias = float_pos - (pos - 1);
This matches the standard Type-7 interpolated quantile (numpy.median,
R's quantile() default).
Verified against numpy:
reg_l1 leaf-value max delta vs np.median: 0.5 -> 0.0 (after fix)
reg_quantile leaf-value max delta vs np.quantile: 0.6 -> 0.0 (after fix)
After this fix every leaf in the parity benchmark reproducer matches
its numpy counterpart to FP epsilon. There is a residual structural
divergence on reg_l1 (CPU and CUDA disagree on a few splits) which
will be investigated separately - this PR fixes only the leaf-value
calculation.
The weighted-percentile path uses different conventions on CPU and
CUDA (ascending vs descending sort, alpha vs 1-alpha threshold) and
is left untouched here. None of our parity tests exercise it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Regression coverage for the unweighted PercentileDevice formula fix (prior commit). Three parametrized tests, all gated on LIGHTGBM_TEST_CUDA=1 so they only run on a CUDA-enabled build: - test_cuda_l1_leaf_renewal_matches_numpy_median: across 3 random seeds, asserts every leaf value on both CPU and CUDA matches numpy.median over the leaf's data points. - test_cuda_quantile_leaf_renewal_matches_numpy_quantile: same shape but parametrized over alpha = 0.1, 0.25, 0.5, 0.7, 0.9 to cover every even/odd leaf-size combination of the percentile bias. - test_cuda_l1_median_handles_small_even_and_odd_leaves: targets the exact failure mode of the old formula (even-length leaves returned sorted[1] instead of avg(sorted[1], sorted[2])) by sweeping leaves of size 2, 3, 4, 5, 8, 9. Tolerance is 1e-6 - well below the ~0.3 bias the old formula produced, but loose enough to absorb label_t float32 quantization inside the renewal kernel. Verified: with the prior commit reverted, 13 of 14 cases fail with bias > 1e-6; with the fix applied, all 14 pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Aligns with the existing convention used by test_engine.py's CUDA-only tests. Addresses Felix's review note (same change going on lightgbm-org#7/lightgbm-org#8/lightgbm-org#9/lightgbm-org#10). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same bug as PR lightgbm-org#6 fixed for the in-block PercentileDevice, but in the global-memory kernel used for init-score computation. The unweighted branch of PercentileGlobalKernel computed the percentile position against `len` instead of `len - 1`, biasing alpha=0.5 toward the upper-middle element on descending-sort layouts. Reproducer (with the Python wrapper's optimization that drops uniform weights, this is the path actually executed by `objective=regression_l1` or `quantile` when sample weights aren't supplied or are all 1): y = [1, 2, 3, 4, 5] init_score (numpy median): 3.0 CPU init_score: 3.0 (correct) CUDA init_score (before): 3.5 (biased toward upper) CUDA init_score (after): 3.0 (correct) This fix mirrors PR lightgbm-org#6 in PercentileDevice and uses the same Type-7 interpolated-quantile formula: float_pos = (1 - alpha) * (len - 1) pos = floor(float_pos) + 1 bias = float_pos - (pos - 1) Parity-sweep impact: reg_l1 max|Δ|: 0.25 -> 0.000e+00 reg_quantile max|Δ|: 0.54 -> 0.000e+00 The weighted branch of PercentileGlobalKernel uses different conventions and is not touched by this PR. There appears to be an unrelated bug in the CPU `WeightedPercentileFun` macro (off-by-one in which cdf delta is used in the interpolation), but that affects only non-uniform-weight workloads and is out of scope here - the Python wrapper drops uniform weights, so this PR's unweighted-formula fix already covers the common path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Regression coverage for the prior commit. 24 parametrized cases across (objective, alpha, n) verifying the init score logged by 'Start training from score' matches between CPU and CUDA at FP epsilon. Without the fix, regression_l1 (alpha=0.5) and quantile failed for small n where the formula bias landed on a different element. Gated on LIGHTGBM_TEST_CUDA=1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Aligns with the existing convention used by test_engine.py's CUDA-only tests. Addresses Felix's review note (same change going on lightgbm-org#6/lightgbm-org#7/lightgbm-org#8/lightgbm-org#10). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Quick nudge — the chronic
Single Let me know when pushed and I'll merge. |
…-tie-stability # Conflicts: # tests/python_package_test/test_dual.py
|
Quick rebase nudge — #7 and #8 just landed on master and touched files this PR also modifies ( Ready to merge as soon as the conflict's resolved. |
Summary
BitonicArgSort_1024andBitonicArgSort_2048ininclude/LightGBM/cuda/cuda_algorithms.hppused the comparatorFor descending sort (
ascending == false) and equal scoresa == b, this evaluates tofalse == false → true— i.e., ties get swapped. The output index permutation for tied elements then depends on the bitonic network structure rather than input order. CPU code usesstd::stable_sort, which never swaps ties.Symptom
LambdaRank round-1 has all scores = 0. The pair-iteration gradient computation depends on the sorted index order. With non-stable sort, CUDA's pair assignments diverge from CPU's, producing different gradients on the very first round.
The remaining 0.14 is FP-precision in
atomicAdd_blockaccumulation order — documented expected behavior per lightgbm-org#6055 — and is unaffected by this change.Fix
Replace the comparator with a strict-direction form so equal elements never trigger a swap:
Same change applied to the analogous block in
BitonicArgSort_2048.Other call sites
BitonicArgSort_1024is also used by the categorical split-finder kernel (cuda_best_split_finder.cu:640) for many-vs-many splits. The CPU/CUDA parity sweep was re-run after this fix and the categorical case (and every other previously-clean case) still matches at FP epsilon — see verification below.Test plan
test_cuda_lambdarank_round1_matches_cpu_within_fp_driftintests/python_package_test/test_dual.py(gated onLIGHTGBM_TEST_CUDA=1). Asserts max|Δ| < 0.2 — strict enough to catch the regression, loose enough for the FP-precision residual.scratch/cpu_cuda_parity.pysweep on RTX 5090 / CUDA 13.2 — no regression on any case (categorical, regression, binary, multiclass, etc. all still match at FP epsilon where they did before).🤖 Generated with Claude Code