Skip to content

Skip constant tree columns in the separate_trees layout - #120

Open
EmilHvitfeldt wants to merge 2 commits into
posit-dev:mainfrom
EmilHvitfeldt:separate-trees-skip-constants
Open

Skip constant tree columns in the separate_trees layout#120
EmilHvitfeldt wants to merge 2 commits into
posit-dev:mainfrom
EmilHvitfeldt:separate-trees-skip-constants

Conversation

@EmilHvitfeldt

@EmilHvitfeldt EmilHvitfeldt commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

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:

  • Ensembles that fit one tree per class per boosting iteration. Each tree votes for a single class, so its votes for every other class are structurally zero. A 3-class GradientBoostingClassifier with n_estimators=4 fits 12 trees, each carrying 3 votes: 36 candidate columns, of which 12 compute anything.
  • Stumps holding a single leaf, where the whole tree is one constant.

How

A new folded_to_constant() in translation/steps/trees/tree.py reports whether an expression reduced to a literal. classifier.py and regressor.py build the list of positions worth preserving, call preserve() 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 main versus this branch, iris, separate_trees=True, duckdb dialect:

pipeline tree columns SQL chars export time
3-class GradientBoostingClassifier, n=50 450 → 150 73,724 → 62,024 0.58s → 0.40s
binary GradientBoostingClassifier, n=50 50 → 50 unchanged unchanged
GradientBoostingRegressor, n=50 50 → 50 unchanged unchanged
3-class RandomForestClassifier, n=20 60 → 60 unchanged unchanged
3-class DecisionTreeClassifier 3 → 3 unchanged unchanged
stump-only regressor, n=20 0 → 0 70 → 60 unchanged

Column 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 main and once on this branch.

"""Measure the separate_trees layout: per-tree columns, SQL size, export time."""

import time

import pandas as pd
from sklearn.datasets import load_iris
from sklearn.ensemble import (
    GradientBoostingClassifier,
    GradientBoostingRegressor,
    RandomForestClassifier,
)
from sklearn.pipeline import Pipeline
from sklearn.tree import DecisionTreeClassifier

import orbital
import orbital.types

iris = load_iris()
NAMES = ["sepal_length", "sepal_width", "petal_length", "petal_width"]
X = pd.DataFrame(iris.data, columns=NAMES)
FEATURES = {name: orbital.types.FloatColumnType() for name in NAMES}


def report(label, estimator, y):
    pipeline = Pipeline([("m", estimator)])
    pipeline.fit(X, y)
    parsed = orbital.parse_pipeline(pipeline, features=FEATURES)

    start = time.perf_counter()
    sql = orbital.export_sql("data", parsed, dialect="duckdb", separate_trees=True)
    elapsed = time.perf_counter() - start

    print(
        f"{label:<44} tree_cols={sql.count('AS \"tre_'):>4}"
        f"  chars={len(sql):>6}  export={elapsed:.2f}s"
    )


report(
    "3-class GradientBoostingClassifier, n=50",
    GradientBoostingClassifier(n_estimators=50, random_state=0),
    iris.target,
)
report(
    "binary GradientBoostingClassifier, n=50",
    GradientBoostingClassifier(n_estimators=50, random_state=0),
    (iris.target == 1).astype(int),
)
report(
    "GradientBoostingRegressor, n=50",
    GradientBoostingRegressor(n_estimators=50, random_state=0),
    iris.data[:, 2],
)
report(
    "3-class RandomForestClassifier, n=20",
    RandomForestClassifier(n_estimators=20, random_state=0),
    iris.target,
)
report(
    "3-class DecisionTreeClassifier",
    DecisionTreeClassifier(random_state=0),
    iris.target,
)
report(
    "stump-only GradientBoostingRegressor, n=20",
    GradientBoostingRegressor(
        n_estimators=20, max_depth=1, min_samples_leaf=len(X), random_state=0
    ),
    iris.data[:, 2],
)

Output on main:

3-class GradientBoostingClassifier, n=50     tree_cols= 450  chars= 73724  export=0.53s
binary GradientBoostingClassifier, n=50      tree_cols=  50  chars= 20348  export=0.14s
GradientBoostingRegressor, n=50              tree_cols=  50  chars= 27388  export=0.12s
3-class RandomForestClassifier, n=20         tree_cols=  60  chars= 26076  export=0.17s
3-class DecisionTreeClassifier               tree_cols=   3  chars=  2314  export=0.02s
stump-only GradientBoostingRegressor, n=20   tree_cols=   0  chars=    70  export=0.01s

Output on this branch:

3-class GradientBoostingClassifier, n=50     tree_cols= 150  chars= 62024  export=0.38s
binary GradientBoostingClassifier, n=50      tree_cols=  50  chars= 20348  export=0.11s
GradientBoostingRegressor, n=50              tree_cols=  50  chars= 27388  export=0.15s
3-class RandomForestClassifier, n=20         tree_cols=  60  chars= 26076  export=0.14s
3-class DecisionTreeClassifier               tree_cols=   3  chars=  2314  export=0.02s
stump-only GradientBoostingRegressor, n=20   tree_cols=   0  chars=    60  export=0.00s

Where the 450 columns came from

GradientBoostingClassifier fits one tree per class per boosting iteration, so 50 estimators over 3 classes is 150 trees. skl2onnx puts all 150 into a single TreeEnsembleClassifier, 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_case reads leaf weights with node["weight"].get(clslabel, 0.0), so for the two classes a tree does not serve, every leaf yields a literal 0.0. Both arms of every CASE are then the same literal, optimizer.fold_case collapses the whole nested CASE to Literal(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.0 collapses. Checked on an 8-estimator 3-class model: main emits 24 CASE columns plus 48 literal ones and contains 48 occurrences of 0.0E0, while this branch emits the 24 CASE columns and contains no 0.0E0 at 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 on main. The two long CASE bodies are elided and the leaf values shortened (they print in full float32 precision, 0.20000000298023224E0 and so on), but the column list is verbatim:

"t2" AS (
  SELECT
    CASE WHEN "t1"."cnc_v5" THEN 0.2 ELSE -0.1 END AS "tre_v12",
    0.0E0 AS "tre_v13",
    0.0E0 AS "tre_v14",
    0.0E0 AS "tre_v15",
    CASE WHEN "t1"."cnc_v5" THEN -0.1 ELSE CASE ... END END AS "tre_v16",
    0.0E0 AS "tre_v17",
    0.0E0 AS "tre_v18",
    0.0E0 AS "tre_v19",
    CASE WHEN "t1"."cnc_v6" THEN CASE ... END ELSE CASE ... END END AS "tre_v20"
  FROM "t1" AS "t1"
)

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:

"t2" AS (
  SELECT
    CASE WHEN "t1"."cnc_v5" THEN 0.2 ELSE -0.1 END AS "tre_v12",
    CASE WHEN "t1"."cnc_v5" THEN -0.1 ELSE CASE ... END END AS "tre_v13",
    CASE WHEN "t1"."cnc_v6" THEN CASE ... END ELSE CASE ... END END AS "tre_v14"
  FROM "t1" AS "t1"
)

Three columns, one per tree, which is what separate_trees is asking for. Total SQL for the whole query: 3,333 characters before, 2,989 after.

To reproduce:

from sklearn.datasets import load_iris
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.pipeline import Pipeline
import pandas as pd, orbital, orbital.types

iris = load_iris()
names = ["sepal_length", "sepal_width", "petal_length", "petal_width"]
X = pd.DataFrame(iris.data, columns=names)

pipeline = Pipeline([("gbm", GradientBoostingClassifier(n_estimators=1, random_state=0))])
pipeline.fit(X, iris.target)

parsed = orbital.parse_pipeline(
    pipeline, features={n: orbital.types.FloatColumnType() for n in names}
)
sql = orbital.export_sql("data", parsed, dialect="duckdb", separate_trees=True)
print(sql)

To print just the per-tree CTE shown above, rather than the whole query:

import sqlglot

for cte in sqlglot.parse_one(sql, read="duckdb").find_all(sqlglot.exp.CTE):
    if "tre_" in cte.sql():
        print(cte.sql(dialect="duckdb", pretty=True))
        break
print("total SQL chars:", len(sql))

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' nested CASE expressions, 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 on main: no tre_ columns are emitted at all, the votes are inlined into the per-class sums, and the constants show up there as a + 0.0 term. This branch keeps the projection, so a 2-estimator 3-class depth-1 model goes from 2,518 to 2,707 characters (reproduce by passing n_estimators=2, max_depth=1 to the classifier in the snippet above). That is the layout doing what was asked of it, materialising 6 per-tree columns where main silently 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 classlabels is 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 on main, 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_trees defaults to False in both export_sql and translate, so this only reaches callers who opt in.

Relationship to the XGBoost work

This was found while adding XGBoost support

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.
@EmilHvitfeldt
EmilHvitfeldt requested a review from amol- July 31, 2026 18:29
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