Skip to content

perf(multi): sweep vector-leaf histograms by feature on sparse indexes - #163

Merged
brndnmtthws merged 3 commits into
mainfrom
perf/vector-leaf-feature-sweep
Oct 20, 2026
Merged

brndnmtthws merged 3 commits into
mainfrom
perf/vector-leaf-feature-sweep

Conversation

@brndnmtthws

@brndnmtthws brndnmtthws commented Oct 20, 2026 •

Copy link
Copy Markdown
Owner

Problem

GHistIndex::column_bins() is Some only for dense indexes. A matrix with missing values gets Columns::WithMissing, exposed only through missing_columns(), so multi::Grow::build_hist never took its feature-parallel branch: every vector-leaf (multi_strategy = multi_output_tree) node ran the serial CSR loop. Per-round time hardly changed with the thread count (861 / 765 / 672 ms at 1 / 4 / 16 threads).

Change

Histogram construction moves out of Grow into NodeHistograms, which builds a node either row by row over its CSR bins or by sweeping the index's feature-major copy feature by feature: dense columns, or missing_columns() with the width's sentinel skipped (BinIndex::MISSING, a new associated constant that partition_numeric now names too).

  • Only the tree's colsample_bytree pool is swept (ColumnSampler::tree_features). Every node's bylevel/bynode candidates are a subset of that pool, so no bin that is read gets skipped. Bins outside the pool are stale and never read.
  • Each level gives its nodes shares of the threads by their rows (at least one each; the root and a loss-guided node get them all). A node is swept only where that pays (NodeHistograms::sweep_pays): per row, the sweep reads one entry per pool feature, missing ones included, at about 3/2 the cost of a CSR entry, split over the node's share; the CSR loop reads the row's stored entries. So a node with one thread (on a one-thread pool, or on a level whose nodes already occupy every thread) is swept only when its pool is under two thirds of a row's stored entries.
  • A sweep task holds one to four pool features, as few as keep the node's share busy (walk::by_features's rule), and reads each row's split gradients once for all of them.
  • Nodes below 65,536 row × pool-feature entries, and indexes without a feature-major copy, keep the CSR row loop.

Each bin still adds its rows in a single ascending-row f64 chain, whichever way a node is built and on however many threads. There are no row blocks, partial histograms or merges, so vector-leaf models are bit-identical to main at every thread count. Sibling subtraction is unchanged.

Review follow-up (second commit)

The first revision (ddd95a6) swept every node above the entry threshold, on one thread too, in fixed four-feature tasks. On deeper trees that lost to the CSR loop wherever a node had no threads to split over: a sweep of scattered rows reads a column entry per feature where a CSR row is contiguous. Depth-8 trees on 1M rows trained 1.5× (dense) to 1.7× (40% missing) slower than main on one thread, and 1.5× slower on two. The fixed task size also left a narrow pool's root on a few threads (multi_output_k3 below). The second commit adds the per-node thread shares, sweep_pays, and share-sized tasks; takes pool slices by feature index instead of relying on the pool's order; extends the test to 32-bit bins and every task size; and records the one-chain-per-bin rule and the stale non-pool bins in AGENTS.md.

Numbers

Median round time in ms (a throwaway Rust harness over the public API: Trainer::on_round timestamps, median of 10 rounds, 5 for the panel; mean of two interleaved runs). Neoverse-V3, pinned with taskset. All runs use hist, subsample 0.5, lambda 3, min_child_weight 8, eta 0.12, vector leaves.

case threads main ddd95a6 this head
deep sparse: 1M × 64, 40% missing, depth 8, 2 targets 1 375 638 373
2 231 353 232
4 154 188 144
16 107 70 56
64 105 47 41
deep dense: 1M × 64, depth 8, 2 targets 1 560 826 569
2 535 434 298
4 288 220 165
16 123 92 71
64 68 52 46
sparse wide: 400k × 173, 41.6% missing, colsample 0.5, two quantiles 1 126 137 122
2 102 71 80
4 93 40 42
16 88 17 17
64 97 13 11
dense wide: 200k × 100, colsample 0.5, 2 targets 1 59 34 34
2 35 18 17
4 18 10 10
16 8.1 5.0 5.0
64 6.3 5.1 4.3
4M × 173 crypto panel (vecprof.py's), two quantiles, depth 4 1 990 772 1002
4 717 235 292
16 683 111 111
64 618 80 74
the same panel at depth 8 1 1943 2156 1939
4 1001 645 573
16 811 257 256
64 803 167 149

The head never runs slower than main (at most 2%, within run-to-run noise). Against ddd95a6 it gives back the one-thread gain on shallow trees with a half-feature pool (the panel at one and four threads, the sparse wide case at two), in exchange for the deep-tree regressions above.

train_variants_50k_x20_20rounds/multi_output_k3 (scripts/compare_benchmarks.py, dense 50k × 20, 3 targets, colsample 1), against main:

threads ddd95a6 this head
4 −2.2% (paired −6.0% to +2.0%)
16 +5.9% (48.9 → 51.8 ms) −17.9% (54.8 → 45.0 ms, paired 17.8–17.9%)
64 −13.5% (73.9 → 63.9 ms)

The first revision's end-to-end runs, on ddd95a6 (bands.py at 16 threads: 2.77M train / 615k valid / 616k test rows, early stopping with patience 40); the head's 16-thread round on this panel equals ddd95a6's (111 ms, above):

design q10 pinball q90 pinball trees train time before train time after
two scalar models 1.37346 1.44271 932 42.6 s 42.5 s
vector-leaf model 1.37028 1.44162 439 179.9 s 29.7 s
multi-quantile scalar 1.37338 1.44846 1116 44.4 s 45.6 s

Bit identity

  • tree::builder::multi::tests::feature_sweep_matches_the_row_build compares every pool bin's f64 bits from NodeHistograms::build against the CSR row build, on dense and sparse indexes of both bin widths (8 features in 16-bit bins, 300 in 32-bit bins), at 1, 2, 4 and 16 threads (tasks of one to four features). It also checks that all four thread counts grow the same tree. Gradients span 40 binary orders of magnitude, so any reordering changes bits; dropping a feature from three-feature tasks, or a wrong 32-bit sentinel, makes it fail.
  • Cross-build: every case above, plus 400k-row cases over 173 sparse features with colsample_bylevel 0.8 and colsample_bynode 0.7, over 8 dense features, and over 12 features 30% missing, gives one native JSON model hash and one margin hash across main, ddd95a6 and this head at 1, 2, 4, 16 and 64 threads (the panel at 1, 4, 16 and 64). After merging main (feat!: model averaging, out-of-fold cross-validation with per-fold stopping; faster quantile scale pass #165), the head still matches the new main on the sparse wide, deep sparse and dense wide cases at 1 and 16 threads.
  • Existing tests and fixtures are unchanged. Locally: cargo nextest run --all-features (878 passed), doctests, clippy and rustdoc with warnings denied, and the 1.93 MSRV build.

GHistIndex::column_bins() is Some only for dense indexes, so any matrix
with missing values sent every vector-leaf (multi_output_tree) node
through the serial CSR loop, whatever the thread count.

NodeHistograms now sweeps the feature-major copy whenever the index keeps
one: dense columns, or missing_columns() skipping the width's sentinel.
Tasks of four pool features read each row's split gradients once, and only
the tree's colsample_bytree pool is built (every node's candidate features
are a subset of it). Each bin still adds its rows in one ascending chain,
so models are bit-identical at every thread count.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-20T17:15:54.051926Z 96354f1 Manual request
🔒 Security Review ✅ Completed 2026-10-20T17:15:16.766937Z 96354f1 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

The feature sweep ran on every node of 65,536+ row x pool entries, on one
thread or many, in fixed four-feature tasks. A sweep reads an entry per
pool feature (missing ones too) through a column per feature, where a CSR
row is contiguous, so it lost to the row build wherever it had no threads
to split over: depth-8 trees on 1M rows trained 1.5x (dense) to 1.7x (40%
missing) slower than main on one thread, and 1.5x slower on two (40%
missing). Fixed four-feature tasks also left a narrow pool's root on a
few threads.

Each level now gives its nodes shares of the threads by their rows, and a
node is swept only when the sweep's entries, weighted 3/2 and split over
its share, are fewer than its rows' stored entries; one thread sweeps
only pools under two thirds of a row's stored entries. Tasks hold one to
four features, as few as keep the node's share busy
(`walk::by_features`'s rule). Every bin is still one ascending row chain,
so models keep their bits.

Pool slices are taken by feature index rather than relying on the pool's
order, `partition_numeric` names the missing sentinel through
`BinIndex::MISSING`, the sweep test covers 32-bit bins and tasks of every
size, and AGENTS.md records that vector-leaf bins are one row chain each
and that bins outside the tree's pool are stale.
@brndnmtthws

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review · Automatically triggered

Security review completed. No security issues were found in this pull request.

Reviewed commit: 96354f154c

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep it up!

Reviewed commit: 96354f154c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@brndnmtthws
brndnmtthws merged commit b123294 into main Oct 20, 2026
25 checks passed
@brndnmtthws
brndnmtthws deleted the perf/vector-leaf-feature-sweep branch October 20, 2026 17:17
@brndnmtthws
brndnmtthws restored the perf/vector-leaf-feature-sweep branch October 20, 2026 17:18
@brndnmtthws
brndnmtthws deleted the perf/vector-leaf-feature-sweep branch October 20, 2026 17:18
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