Skip to content

perf(predict): complete-tree row walks; typed tree metadata for external walkers - #166

Merged
brndnmtthws merged 9 commits into
mainfrom
perf/complete-tree-rows
Oct 20, 2026
Merged

brndnmtthws merged 9 commits into
mainfrom
perf/complete-tree-rows

Conversation

@brndnmtthws

@brndnmtthws brndnmtthws commented Oct 20, 2026 •

Copy link
Copy Markdown
Owner

Summary

An external walker that lays a model's trees out as complete trees predicted single rows 2–3× faster than hessboost's row methods. This makes the single-row methods (predict_row, predict_row_into, their margin forms) beat it, and gives external walkers the typed metadata they had to dig out of the model JSON.

perf(predict): a RowForest (tree/compact/row.rs) lays shallow numeric trees out as complete trees, 8 trees per chunk, up to depth 8 and features below 2^15: each level is one compare per lane (step_if_greater), padded paths repeat their leaf, and leaf slots are u32 (the f32 bits, or an offset into a shared leaf-vector array). A chunk is laid out complete only while its split and leaf-slot bytes stay within twice its trees' arena node bytes; other chunks (deep, categorical, wide-feature, chain-shaped) walk the model's own CompactForest by tree id with the same row keys, and linear-leaf trees walk their RegTree. Every path adds the same f32 leaves in the same order as predict, so results are bit for bit predict's. The layout is built lazily on the first row prediction, in one scan per tree (chunks left on the arena take their features from the arena's tree summaries); after that the row methods allocate nothing and never enter rayon.

Row keys (key(v), key(-v) per feature, RowKeys) are made on the stack at their first read, and only for a walk of at least one tree per eight features (up to 512 features); shorter walks and wider rows key each value as a step reads it. CompactForest::walk_row shares RowKeys with its policy unchanged. predict_rows keeps the batch blocks.

feat(model): typed tree metadata for external walkers, replacing JSON round trips: RegTree::depth, RegTree::has_categorical_splits, BoostedModel::tree_outputs() (each tree's output range, in tree order like tree_weights()), and Python TreeInfo.depth.

Review changes (since the PR opened)

  • BoostedModel::tree_outputs(t) asserted t < num_trees(), a panic on a caller's argument; it is now tree_outputs(), an iterator of one range per tree like tree_weights(), and internal callers keep tree_output(t) (the backend files are unchanged from main).
  • The row walk keyed every split feature before each call, even for walks that never read a key: a 512-feature model walking one categorical or depth-17 tree, or an empty range, took up to 3.3× main's time. RowKeys now makes keys at their first read, out of line, and only for walks of at least one tree per eight features.
  • The first row call scanned every tree several times (plus a heap-allocated depth walk per tree) and scanned arena trees the arena summarizes anyway: one scan per tree now, a bounded allocation-free depth check, arena features from its tree summaries, trimmed array capacity.
  • Merged main: BoostedModel::average (feat!: model averaging, out-of-fold cross-validation with per-fold stopping; faster quantile scale pass #165) builds its model with TreeLayouts.

Measurements

Two production ensembles, eight single-output models each (p50 µs per row, one pinned core; cold = caches evicted before each call)

class walk warm, main warm, PR cold, main cold, PR
A (4,421 trees) external complete-tree walker 27.4 27.3 33.4 34.7
predict_margin_row_into 48.4 18.7 109.1 33.0
B (11,349 trees) external complete-tree walker 70.5 70.1 84.0 84.2
predict_margin_row_into 132.7 45.6 270.2 72.5

Medians of three paired runs; the external walker is the same code in both columns.

0 bit mismatches against the external walker and against predict_margin of a one-row matrix, on both sets. Every tree takes the complete layout.

Synthetic shapes, predict_margin_row_into (p50 µs per call; first call = the call after decoding, which builds the layouts)

shape warm, main warm, PR first call, main first call, PR
1000 trees, depth 4 7.13 3.62 276 202
300 trees, depth 6, 100 features 2.92 1.63 260 251
depth 10 (arena) 3.68 3.07 1893 1911
lossguide, 9 leaves, depth ≤ 8 (mixed) 1.59 1.00 36 47
20 trees, depth 6, 500 features 0.42 0.30 15 13
30 trees, depth 4, 600 features 0.61 0.30 8 6
categorical (arena) 7.18 7.19 50 55
vector leaves, 3 outputs 1.42 1.07 52 41
softmax, 70 classes, 50 rounds 22.79 18.41 563 193
model shrinkage 0.05 5.74 1.11 50 32
linear leaves 6.86 6.52 54 8

200 trees of 20 features unless named, trained once and read by both revisions; medians of two paired passes on one pinned core. The first call is slower than main's only where both layouts are built (the lossguide model's chunks are mixed) or the scan finds categorical splits; the production models' first calls are 25–30% faster. 0 bit mismatches against one-row predict_margin/predict on every shape, and no allocation in a warm call.

Layout bytes per tree (complete layout vs the arena): 154 B against ≈ 490 B for the production depth-4 trees (10·2^D − 6 B for depth D).

Checks

cargo fmt --all --check, clippy --all-targets --all-features -D warnings (CUDA headers), x86_64 cross clippy, cargo nextest run --cargo-profile ci --features wgpu (838 passed), doctests, RUSTDOCFLAGS=-D warnings cargo doc --all-features, MSRV 1.93 cargo build --all-features, Python clippy, uv run --locked pytest, pyright --verifytypes, ruff, ty.

`RegTree::depth` and `RegTree::has_categorical_splits` describe a tree's
shape, and `BoostedModel::tree_outputs(t)` names the outputs tree `t` adds
to (one for a scalar-leaf tree, every output for a vector-leaf tree),
replacing the crate-private `tree_output`. With `tree_weights` and
`shrinkage` they give an external tree walker everything it reads without a
JSON round trip. The compact encoder and a SHAP test use `depth` instead of
their own walks; Python's `TreeInfo` gains `depth`.
…w methods

The single-row methods (`predict_row`, `predict_row_into`, their margin
forms) now walk a lazily built `RowForest`: every chunk of 8 consecutive
trees that are numeric, constant-leaf, at most 8 levels deep, and split on
features below 2^15 is laid out as complete heap-order trees padded to the
chunk's deepest tree (u32 threshold keys, u16 feature-and-mirror slots,
u32 leaf slots holding the value or a leaf-vector offset), walked 8 trees
in lockstep with the arena's key compare, unless padding would exceed
twice the chunk's arena bytes. Other chunks walk the model's
`CompactForest` by tree id with the same row keys (`walk_row_keyed`), or
the `RegTree`s for linear leaves. Leaves still add in tree order onto the
same intercepts with the same weights, so margins are bit-identical; a
shrunk model's per-iteration shrinks now run between its trees in one
walk.
@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-20T18:13:04.683881Z 3f64411 New commits
🔒 Security Review ✅ Completed 2026-10-20T18:15:06.138420Z 3f64411 New commits
ℹ️ 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.

`BoostedModel::tree_outputs(t)` asserted `t < num_trees()`, a panic on a
caller's argument. It is now `tree_outputs()`, one output range per tree
in tree order, the shape of `tree_weights()`; internal callers keep the
crate-private `tree_output(t)`.
…layout scan

The row walk made both keys of every feature a model splits on before
each call, whether or not a step read them: a 512-feature model walking
one tree (categorical, deeper than 16, or a single-iteration range) or
an empty range took up to 3.3x main's time. `RowKeys` now makes the keys
on the stack at their first read, and the row walk asks for them only
when it walks at least one tree per eight features; otherwise each step
keys the value it reads, as wide rows already did. `walk_row` shares
`RowKeys` (its policy is unchanged: lockstep groups of rows up to 128
features).

`RowForest::from_trees` scans each tree once (features, numeric splits,
leaves) after a bounded, allocation-free depth check, takes the features
of chunks left on the arena from the arena's tree summaries instead of
scanning their nodes, and trims its arrays' capacity. The first row call
of a complete-layout model is now faster than main's arena build.
@brndnmtthws

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: 3d9132d6fd

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

@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: 3d9132d6fd

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.

Inlining the key-making loop into every read site of `RowKeys::get` cost
the arena's lockstep walk its register allocation: depth-10 arena models
walked 5% slower than with keys made up front. `RowKeys::make` is now a
cold, out-of-line call made once per walk; the reads stay a length check
and a flag test.
@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: 146c67357d

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. Hooray!

Reviewed commit: 146c67357d

ℹ️ 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 2b0bbf4 into main Oct 20, 2026
38 checks passed
@brndnmtthws
brndnmtthws deleted the perf/complete-tree-rows branch October 20, 2026 18:32
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