Skip constant tree columns in the separate_trees layout - #120
Open
EmilHvitfeldt wants to merge 2 commits into
Open
Skip constant tree columns in the separate_trees layout#120EmilHvitfeldt wants to merge 2 commits into
EmilHvitfeldt wants to merge 2 commits into
Conversation
Ensembles that fit one tree per class per boosting iteration leave each tree's votes for the other classes at a constant zero. Materialising those as their own columns spends SQL size and parallel slots on values that compute nothing: a 4-estimator 3-class model emitted 36 columns where only 12 did any work, pushing the SQL overhead to +17% against the ~7% the option documents. Aliasing is now skipped for votes the optimizer already folded to a literal, which also covers stump trees holding a single leaf.
Covers both ends of the constant-folding skip: a multiclass GBM, where each tree votes for one class and the other votes fold away, and an ensemble of stumps, where every tree folds and no column survives. The alias classifier goes in orbital_testing_helpers next to execute_sql, since reading back which per-tree columns the export actually emitted is useful to any test about this layout.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this does
export_sql(..., separate_trees=True)materialises each tree's contribution as its own column so columnar engines can evaluate them in parallel. Some of those contributions are constants: the ibis optimizer has already folded them to a literal before the layout runs. A column holding a literal computes nothing, so it spends SQL size and a parallel slot on a value the engine could inline. This skips them and leaves the literal inline.Two cases produce them, and both are ordinary rather than exotic:
GradientBoostingClassifierwithn_estimators=4fits 12 trees, each carrying 3 votes: 36 candidate columns, of which 12 compute anything.How
A new
folded_to_constant()intranslation/steps/trees/tree.pyreports whether an expression reduced to a literal.classifier.pyandregressor.pybuild the list of positions worth preserving, callpreserve()once for those, and write the results back in place.Predictions are unchanged. Only which values get their own SQL column changes, and a folded literal is inlined at the same value it would have had in a column.
What it saves today
Measured on
mainversus this branch, iris,separate_trees=True, duckdb dialect:GradientBoostingClassifier, n=50GradientBoostingClassifier, n=50GradientBoostingRegressor, n=50RandomForestClassifier, n=20DecisionTreeClassifierColumn and character counts are deterministic. The export times are single runs and wander by a few hundredths between repeats, so treat them as a direction rather than a benchmark.
Script that produces the table
Run it once on
mainand once on this branch.Output on
main:Output on this branch:
Where the 450 columns came from
GradientBoostingClassifierfits one tree per class per boosting iteration, so 50 estimators over 3 classes is 150 trees. skl2onnx puts all 150 into a singleTreeEnsembleClassifier, and orbital's translator builds a vote per class for every tree, which is 450 expressions.But each tree only ever votes for the one class it was fitted for.
build_tree_casereads leaf weights withnode["weight"].get(clslabel, 0.0), so for the two classes a tree does not serve, every leaf yields a literal0.0. Both arms of everyCASEare then the same literal,optimizer.fold_casecollapses the whole nestedCASEtoLiteral(0.0), and what is left is a constant that no longer references the input columns at all.Before this change the layout named all 450 of those as columns, so 300 of them came out as
0.0E0 AS "tre_NNN": an alias, a projection slot, and a name to resolve, wrapping a zero. Worse, each was then referenced again downstream, because the per-class sum added the column instead of the literal.That second part is the real cost. Naming a folded literal as a column converts it from something the optimizer can fold into a column reference it cannot, so the zero survives twice over. Skipping the column lets the zero stay a literal in the sum, where
x + 0.0collapses. Checked on an 8-estimator 3-class model:mainemits 24CASEcolumns plus 48 literal ones and contains 48 occurrences of0.0E0, while this branch emits the 24CASEcolumns and contains no0.0E0at all. The zeros do not move inline, they disappear.The same 1-in-3 ratio holds at any size, and it gets worse with more classes: at 5 classes, 4 of every 5 votes are constant.
The smallest case that shows it
One estimator, three classes, default depth,
separate_trees=True. That is 3 trees and 9 votes. Here is the per-tree CTE onmain. The two longCASEbodies are elided and the leaf values shortened (they print in full float32 precision,0.20000000298023224E0and so on), but the column list is verbatim:Six of the nine columns are the literal
0.0E0. Tree 1 votes for class 0 and hands classes 1 and 2 a zero, and so on down the diagonal. The same CTE on this branch:Three columns, one per tree, which is what
separate_treesis asking for. Total SQL for the whole query: 3,333 characters before, 2,989 after.To reproduce:
To print just the per-tree CTE shown above, rather than the whole query:
Why the character count drops much less than the column count
Columns fall by 67% but SQL size only by 16%, which is the expected shape rather than a discrepancy. What goes away is short: a projection item of the form
0.0E0 AS "tre_NNN"plus the reference to it in the sum, around 39 characters per vote across both. The bulk of the SQL is the 150 real trees' nestedCASEexpressions, and those are untouched. The export-time gain comes from ibis and sqlglot having 300 fewer projection items to build and compile, not from smaller trees.Why nothing else moves
One case does get marginally bigger, and it is worth knowing about. With very shallow trees (
max_depth=1) the whole per-tree projection collapses onmain: notre_columns are emitted at all, the votes are inlined into the per-class sums, and the constants show up there as a+ 0.0term. This branch keeps the projection, so a 2-estimator 3-class depth-1 model goes from 2,518 to 2,707 characters (reproduce by passingn_estimators=2, max_depth=1to the classifier in the snippet above). That is the layout doing what was asked of it, materialising 6 per-tree columns wheremainsilently materialised none, rather than a regression.Otherwise: random forest trees vote for every class, so no vote folds. Binary classification takes the ONNX single-weight path, where
classlabelsis trimmed to one entry and there is one vote per tree. Regression has one value per tree. In all three cases there is nothing constant to skip. The stump case is already nearly free onmain, since those trees emit no per-tree columns either way; the test for it is a boundary condition rather than a saving.Note that
separate_treesdefaults toFalsein bothexport_sqlandtranslate, so this only reaches callers who opt in.Relationship to the XGBoost work
This was found while adding XGBoost support