Skip to content

perf(data)!: bin once and share: cached dual-layout index, row views, bin-routed margins, binned matrices - #168

Closed
brndnmtthws wants to merge 7 commits into
feat/exact-quantile-leavesfrom
perf/bin-once-share
Closed

brndnmtthws wants to merge 7 commits into
feat/exact-quantile-leavesfrom
perf/bin-once-share

Conversation

@brndnmtthws

@brndnmtthws brndnmtthws commented Oct 20, 2026 •

Copy link
Copy Markdown
Owner

What

Bin each matrix's feature values once and share the result: across trainings of a matrix and its metadata copies, across cross-validation folds and other row selections, and across concurrent trainings. Plus a matrix type that trains from the bins alone (XGBoost's QuantileDMatrix).

Stacked on #164.

  1. Dual-layout index, streamed cuts (perf(data)): one parallel pass over 1,024-row blocks writes CSR global row bins (u16 up to 65,536 total bins) and, for dense indexes and sparse ones at least half full, feature-major local columns (u8 up to 256 bins per feature, else u16/u32) with a missing-entry bitmap. Cuts stream dense rows in 65,536-row transposed blocks. Every builder keeps its exact per-bin addition order, and sum_order still keys on the old is_dense rule, so no sum changes.
  2. Bin cache (perf(data)): DMatrix::hist_index(max_bin) caches the index with the values, keyed by max_bin, weights, feature types and missing value. The first caller builds it and concurrent callers wait (a parallel build runs on its own pool, so waiting rayon workers cannot deadlock). Clones and with_* copies share it, and it is freed with the values.
  3. Row views (feat(data)!): select_rows returns a view (a row map over the shared storage, with only the metadata gathered). ViewCuts::Own (the default) cuts the view's own rows, bit-identical to a copy of them. ViewCuts::Source instead gathers the view's rows of the source's index, so a source and its views share one binning pass. Python: DMatrix.slice(rows, cuts="own" | "source"). CV folds are views.
  4. Bin-routed margins (perf(training)): sampled rounds and exact leaves route every training row through the finished tree by its bins (BinnedTree) instead of reading scattered raw f32 rows. This happens only when the cuts were sketched from every row (SketchedRows::All); otherwise rows are routed by value, as before. Prediction also skips loading row blocks when the tree range is empty, which every training's initial margins did.
  5. Binned matrices (feat(data)): data::BinnedMatrix / hessboost.BinnedMatrix keeps the index and metadata and drops the values. Trainer::new and train take impl Into<TrainingData> (a DMatrix or a BinnedMatrix), and training on the bins is training on the matrix, bit for bit. Everything that would read values is refused through an allow-list over refuse_changes_from, so options added later are refused until vetted: another max_bin, continuation, device = cuda, tree_method approx/exact, non-gbtree boosters, linear leaves, refresh, SGLB, and reuse penalties. Binning refuses rows with zero weight, whose values the cuts never saw.
  6. Borrowed arrays (feat(python)): DMatrix(x, copy=False) reads a read-only, C-contiguous, aligned float32 array in place, so a memory-mapped .npy (np.load(..., mmap_mode="r")) stays file-backed; the matrix and every copy or slice of it keep the array alive (a BinnedMatrix keeps only the bins). Refused: writeable arrays, non-contiguous ones, arrays whose base chain (followed through memory views and the buffer protocol) can still write the buffer, and buffers another Rust extension holds a mutable borrow of. The core API is DMatrix::from_dense_shared(Arc<dyn AsRef<[f32]> + Send + Sync>, ..); the extension's one unsafe block (python/src/borrowed.rs, unsafe_code lowered from forbid to deny with that block allowed) turns the kept-alive numpy buffer into the slice. The contract, documented on copy: the array, and every array sharing its memory, stays unchanged while a matrix borrows it — numpy offers no way to enforce that (any WRITEABLE flag can be set again), so it is the caller's promise, as in every zero-copy numpy bridge.

Numbers

4M x 173 panel (41.6% NaN), Neoverse-V3, cores pinned; before = #164's head (53862434, main merged in), after = this branch. Another workload shared the cores during these runs: memory is unaffected, and the per-round rows are the fastest of 12 runs interleaved with the baseline.

measurement before after
1-round train, 1 thread: first / again / copy+set_info 15.46 / 15.82 / 16.30 s 10.74 / 0.48 / 0.47 s
same, 8 threads 3.22 / 2.53 / 2.29 s 1.80 / 0.15 / 0.13 s
same, 16 threads 1.95 / 1.98 / 1.98 s 0.84 / 0.09 / 0.09 s
q10 quantile round, 8 threads (warm) 84.1 ms 74.9 ms
q10 with exact leaves, 8 threads 98.9 ms 88.2 ms
4 heads x 4 threads sharing one matrix, 10 rounds: peak / time 13.10 GiB / 7.4 s 4.54 GiB / 3.8 s
8 heads x 4 threads 23.54 GiB / 10.6 s 5.13 GiB / 3.9 s
trial (85% of rows + eval set) peak, 8 threads 7.60 GiB (numpy copies) 4.16 GiB (views, own cuts); 5.67 GiB (source cuts)
refit on all rows: peak / time 5.55 GiB / 4.24 s 4.52 GiB / 3.22 s (own); 4.49 GiB / 1.62 s (source: index reused)
BinnedMatrix of the panel, then drop the DMatrix, then 20 rounds — index 1.42 GiB; training peak 1.55 GiB, 1.60 s
4 / 8 heads on one matrix borrowing the memory-mapped array (copy=False) 13.10 / 23.54 GiB 1.97 / 2.56 GiB
trial / refit peaks on views of a borrowing matrix 7.60 / 5.55 GiB 1.58 / 1.94 GiB (own cuts); 3.10 / 3.10 GiB (source cuts)

When copying, the 4- and 8-head peaks are one value copy (2.64 GiB) + one index (1.42 GiB) + about 0.12 GiB per head; borrowing drops the copy, since the memory-mapped pages are file-backed and not counted. A profile of 20 sampled rounds puts the bin-routed margin update at 4.5% of samples, against about 10.5% for the raw walk it replaces (DMatrix::get + predict_row + goes_left). The skipped row loads for an empty tree range were another 7.4%.

Bit identity

Model and margin hashes for 7 configurations (vector quantiles, weighted and dense; 2-target squared error by node; vector lossguide; scalar quantile; scalar squared), each at 1, 4, and 16 threads, are identical to #164 after every commit. tests/binned_matrix.rs trains every option binned training accepts on both the DMatrix and its BinnedMatrix and compares model bytes and eval histories: sampled rows and columns, lossguide, forests, GOSS, quantized gradients with leaf renewal, extra trees, path smoothing, monotone and interaction constraints, exact leaves, vector leaves, multiclass, ranking, and a sparse index without columns, with symmetric trees.

Design notes

  • A columns-only layout (no CSR rows) was tried first: setup fell from 2.43 s to 1.68 s, but rounds went from 82.5 ms to 131 ms at 8 threads. Gathering a node's scattered rows touches one cache line per row per feature. The dual layout keeps row gathers on CSR rows and single-column streaming on 8-bit columns.
  • Routing a row by its bins equals routing it by value only for a row the cuts were sketched from. A zero-weight row's value can lie beyond the last cut (it then clamps into the last bin), and an unseen category clamps to bin 0. DMatrix::sketched_rows (All / Weighted / Partial) replaces the weights-only check. It sees through source-cut views: a view that gives weight to rows its source weighs zero gets Partial, so its training margins route by value. That keeps "train k + j rounds" equal to "train k, then continue for j" (tests/row_views.rs).
  • perf(multi): sweep vector-leaf histograms by feature on sparse indexes #163's sweep policy (each node's thread share, sweep_pays) is carried onto the new column layout in the merge of feat(training): opt-in exact quantile leaves (leaf_estimation_method = exact) #164's head (de492643).

Review

An independent review found five defects, all around source-cut provenance; 0e74a6a8 fixes them and adds a regression test for each:

  • A binned matrix of a source-cut view that weighs rows its unweighted source sketched read values it no longer had.
  • A forked child could hang on a bin cache caught mid-build; it now bypasses inherited caches.
  • Budget mode followed bin partitions for weighted rows the source never sketched; it now refuses such views.
  • with_feature_types on a source-cut view contradicted its cuts; it is now refused.
  • SGLB leaf renewal read those partitions too; it now routes such rows by value.

The re-review of the fixes found nothing further.

Breaking changes

  • DMatrix::select_rows returns a view that keeps the source's missing value; it no longer converts the selection to NaN-missing.
  • Trainer::new and train take impl Into<TrainingData<'_>>. &DMatrix callers compile unchanged; callers passing &&DMatrix need one deref.
  • Python: Objective is generic over the training matrix type (Objective[M]).

The 0.4.0 bump in #165 covers these.

GHistIndex keeps its two layouts, both written by one parallel pass over
1,024-row blocks instead of binning rows and then transposing them:

- rows: each row's present entries as global bins (u16 up to 65,536
  bins, else u32), CSR-shaped, which row-wise histogram gathers read;
- columns (dense indexes and sparse ones at least half full): each
  feature's bins local to the feature, feature-major, in u8 up to 256
  bins per feature (the default max_bin), else u16/u32, plus one bit per
  entry marking missing values, which partitions, vector-leaf sweeps,
  budget/quantized column paths and the GPU uploads read.

The columns were global u16 with a u16::MAX sentinel; local bins with a
separate bitmap fit 256-bin features in a byte. HistCuts streams dense
rows through 65,536-row transposed blocks (every sketch still takes its
feature's values in row order) instead of transposing the whole matrix,
and CSR input goes through its CSC view a feature at a time as before.

Every histogram keeps its per-bin addition order (sum_order is keyed on
the dense flag as before), so models are bit for bit unchanged.
A DMatrix and its metadata copies (with_labels, with_weights, Python's
copy + set_info) share their feature values; they now also share the
histogram indexes binned from them. DMatrix::hist_index(max_bin) keys a
BinCache by max_bin, sample weights, feature types and missing value, and
hist training, EBM pair ranking and budget training read their index
from it, so repeated and concurrent trainings of one matrix bin once. The
index is freed with the values; new values (dense_values_mut, map_values,
select_rows) start a new cache.

The first request for a key builds it and concurrent requests wait. A
parallel build runs on a pool of its own and publishes from there: a
builder waiting on its own rayon joins may steal a waiter (two trainings
of one matrix started from one pool), which would otherwise wait on its
own stack forever. Serial builds (small matrices, one thread) never enter
rayon and run on the caller; a builder that cannot start threads builds
unshared and its waiters build their own.

A cache hit is the index a miss builds, so training is bit for bit
unchanged.
`DMatrix::select_rows` now returns a view: the selected rows' metadata is
gathered, the feature values stay shared with the source and are read
through a composing row map. Raw-value readers go through the row-mapped
`DenseRows`/`CsrRows` accessors, and writing a view's values
(`dense_values_mut`) copies only its rows. Cross-validation folds,
target-statistics folds and every other internal selection are views.

`select_rows_with_cuts(rows, ViewCuts)` picks the view's histogram cuts:
`ViewCuts::Own` (the default) cuts the view's rows, so training on a view
is training on a copy of its rows, bit for bit; `ViewCuts::Source`
gathers the view's rows of the source's cached index
(`GHistIndex::select_rows`), so a source and its views share one binning
pass and split on the same thresholds. Python: `DMatrix.slice(rows,
cuts="own" | "source")`.

The routing guard of the `hist` builder's row partitions is now
`DMatrix::sketched_rows`: a source-cut view of rows its source weighs zero
holds values its cuts never saw, so those rows' training margins route by
value (and linear leaves and device rounds keep off the partitions), which
keeps training equal to continuing it.

BREAKING CHANGE: `select_rows` keeps the source's missing value instead of
converting the selection to NaN-missing.
A sampled round's margin update and exact leaves' row routing walked every
training row through the new tree by raw value, reading scattered row-major
`f32`s. When the index's cuts were sketched from every row (no zero sample
weight, `SketchedRows::All`), a row's bins route it through any tree split
on those cuts as its values do, so both now walk the index's narrow feature
columns instead (`tree::binned::BinnedTree`, `margins::RowRouting`); every
row still receives one addition of the same leaf value, so models are
unchanged bit for bit. Trees whose thresholds fall inside a bin
(`HistCuts::bins_left_of`), linear leaves, refreshed trees, sparse indexes
without columns, and eval sets keep routing by value.

`HistCuts::categories_left` replaces the builder's private
`category_left`, now shared by partitions, device growth, and the walker.

Prediction no longer loads every row block when summing an empty tree
range, which every training's initial margins did.
`data::BinnedMatrix` (Python `hessboost.BinnedMatrix`) keeps a matrix's
`hist` index and metadata and drops the values, as XGBoost's
`QuantileDMatrix` does: `BinnedMatrix::new(&dmatrix, max_bin)` takes the
index the matrix's own training builds (shared with it, so binning after a
training costs nothing), and training on it is training on the matrix, bit
for bit. On the 4M x 173 panel the index takes 1.4 GiB against 2.6 GiB of
values, and 20 rounds of quantile training peak at 1.56 GiB once the
`DMatrix` is dropped.

`Trainer::new` and `train` take `impl Into<TrainingData>`, a `DMatrix` or a
`BinnedMatrix`. Training refuses what would read the values it dropped:
another `max_bin`, continuation, `device = cuda` (whose recovery routes rows
by value), and every option outside an allow-list of what `hist` growth on
bins serves (`validate_binned_request` over `refuse_changes_from`, so a new
option is refused until it is vetted). Binning refuses zero-weight rows,
whose values the cuts never saw. The weights and feature types that shaped
the cuts are fixed; labels, groups, margins, bounds, and feature weights
can be set. The values-free storage (`Storage::Bins`) panics if a value is
ever read, so a missed refusal fails loudly.

Training rows route through finished trees by their bins on every index
layout now: `BinnedTree` also walks a sparse index's row bins.

Python: `train` is generic over the matrix, and a custom objective receives
it (`Objective[M]`); prediction, eval sets, and `cv` refuse a
`BinnedMatrix` with a `TypeError`. `DMatrix` and `BinnedMatrix` share their
metadata readers (`_Metadata`).
Review fixes for the bin sharing:

- A binned matrix's rows are all sketched (`SketchedRows::All`): binning
  proved it, and its weights are fixed. Re-deriving it from the weights
  failed for a source-cut view that weighs rows its unweighted source
  sketched anyway, sending its training to read values it no longer has.
- A forked child bypasses the bin caches it inherits: the fork may have
  caught one mid-build (its builder thread did not fork) or with a lock
  held, either of which would hang the child's first training.
- Budget mode refuses a source-cut view that weighs rows its source never
  sketched: its margins and loss decrements follow the builder's
  partitions, which route those rows by bin, not value.
- A source-cut view keeps its source's feature types, which its cuts were
  computed for (`with_feature_types` refuses others).
- SGLB leaf re-estimation reads the builder's partitions under the same
  guard as the margins, routing rows by value for such views (scalar and
  vector leaves).
@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:50:17.727886Z de49264 PR opened
🔒 Security Review ✅ Completed 2026-10-20T17:49:40.916029Z de49264 PR opened
ℹ️ 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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: de49264310

ℹ️ 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".

Comment thread src/data/quantile.rs
Comment on lines +138 to +140
let mut columns: Vec<ColumnCuts> = (0..n_features)
.map(|f| ColumnCuts::new(is_categorical[f], present[f], max_bin, ingest))
.collect();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid retaining a sketch for every dense feature

For wide dense matrices, constructing all ColumnCuts up front retains every feature's quantile queue, summaries, and sort scratch until all row blocks have been processed. With fewer than CUT_BLOCK_ROWS rows, the transposed block already occupies the same rows × features memory as the former full transpose, so datasets such as thousands of rows by tens of thousands of features can now consume hundreds of megabytes or more in additional per-feature sketch state and fail from memory pressure. Process bounded feature batches, or otherwise avoid keeping one populated sketch per column concurrently.

Useful? React with 👍 / 👎.

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