Skip to content

perf: reduce peak memory on string categoricals - #1187

Open
arthur-priorlabs wants to merge 5 commits into
mainfrom
arthur/optimize-ensemble-preprocessor-string-2
Open

perf: reduce peak memory on string categoricals#1187
arthur-priorlabs wants to merge 5 commits into
mainfrom
arthur/optimize-ensemble-preprocessor-string-2

Conversation

@arthur-priorlabs

@arthur-priorlabs arthur-priorlabs commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Issue

RES-2592: optimize TabPFNEnsemblePreprocessor.fit_transform_ensemble_members_iterator RAM usage — stacked on #1186, which left the half-categorical case as the one mix it could not help.

Motivation and Context

What

  • Two more rebuilds of the feature matrix removed, both on the path a table with categorical columns takes.
  • Takes the half-string mix from 3.33 GB to 2.80 GB of transient RSS, and 4.40 GB -> 2.80 GB cumulatively against main.

Why

  • perf: remove redundant copies in ensemble preprocessing #1186 skips the reshape rebuild only when the column order is untouched, and the encoder rebuild only when nothing is categorical. A half-categorical table meets neither condition, so it kept paying three full-size arrays where the numeric mixes now pay one.
  • That mix is not a corner case: it is what any table with a categorical column looks like to this step.

How

  • Reshape: when the transform is the identity and every input column is used exactly once, take the permutation in one gather instead of a ColumnTransformer's block-plus-hstack.
  • Encode: write the codes and the passthrough columns straight into one preallocated output, the pattern clean_data already uses in its own _encode_into_preallocated.

Both stages had to go: they were tied at the peak, so removing either alone moved nothing. Measured at 66,667 x 400 with the profiler, this step's transient goes 0.422 GB -> 0.213 GB for the reshape and 0.427 GB -> 0.318 GB for the encoder.

numeric half-string numeric-object
input 10.67 GB (666,667 x 2,000) 1.07 GB (333,333 x 400) 1.07 GB (333,333 x 400)
categoricals 0 of 2,000 200 of 400 0 of 400
transient RSS, highest 12.00 -> 12.00 GB (+0.0%) 3.33 -> 2.80 GB (-16.0%) 1.07 -> 1.07 GB (+0.0%)
transient RSS, lowest-direct 12.00 -> 12.00 GB (+0.0%) 2.67 -> 2.67 GB (+0.0%) 1.07 -> 1.07 GB (+0.0%)
median wall, highest 11.015 -> 10.980 s (-0.3%) 6.698 -> 6.741 s (+0.6%) 0.695 -> 0.683 s (-1.7%)
median wall, lowest-direct 10.976 -> 10.859 s (-1.1%) 13.684 -> 13.975 s (+2.1%) 0.696 -> 0.686 s (-1.5%)

Six complete A/B comparisons against this PR's base, run by scripts/bench_matrix.py on dedicated cpuhighmem16spot nodes, with the ensemble members compared cell by cell: bit-identical in all six. Only the half-string RSS cell is outside the ±3% this benchmark carries; every other number here says "unchanged", including all six wall times.

Two results worth reading before the diff

Nothing is won on the dependency floor. On python 3.10 / numpy 1.22 / sklearn 1.2 the base already peaks at 2.67 GB where current libraries peak at 3.33 GB, and this change leaves the floor exactly where it was. Profiling both environments at a shape small enough to profile (66,667 x 400) shows identical stage ladders, so the floor's cheaper baseline only appears at the full shape and I did not chase the cause further. What the table does establish is that the floor is not made worse.

Wall time is a wash, and the gate is stricter than the measurement. The two half-string cells read +0.6% and +2.1%; a nine-repeat run of the same comparison put the medians 2.3% apart while the fastest repeats were 0.3% apart, with the spread inside one run reaching 8.9%. The assembly is doing slightly more work than sklearn's two block copies, but I could not measure it above the noise. The benchmark's zero-tolerance default therefore rejects these cells; --tolerance 0.03 passes them.

What remains on this mix is one gather output, one assembled output and the block of codes, so ~2.5x the table it returns. Going below that means letting the encoder write into its input in place — safe inside the pipeline, which copies once up front precisely so steps have a private array, but a change to the mutation contract of a step that can also be called directly. Deliberately left out of this PR.


Public API Changes

  • No Public API changes
  • Yes, Public API changes (Details below)

How Has This Been Tested?

suite result
tests/test_preprocessing, tests/test_torch_preprocessing 772 passed
A/B vs this PR's base: 3 mixes x 2 dependency sets, plus --no-gpu-preprocessing members bit-identical, no RSS regression
  • New: _encode_into_preallocated is pinned against sklearn as the oracle, over nine input shapes — values, dtype and memory layout, compared with a ColumnTransformer fitted the ordinary way. Two more tests cover the step end to end and the one-row fit the assembly relies on.
  • That layout assertion earned its place immediately: the first draft of this branch pinned the output to Fortran order, and the test caught that hstack returns row-major for two real blocks and Fortran order only when every block it stacks already is. A layout drift here is not cosmetic — the sklearn SVD downstream converges to a different basis on a C- than on a Fortran-contiguous input, which is exactly what perf: remove redundant copies in ensemble preprocessing #1186's _pin_layout exists to stop.

Checklist

  • The changes have been tested locally.
  • Documentation has been updated (if the public API or usage changes).
  • A changelog entry has been added (see changelog/README.md), or "no changelog needed" label requested.
  • The code follows the project's style guidelines.
  • I have considered the impact of these changes on the public API.

@arthur-priorlabs

arthur-priorlabs commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

This change is part of the following stack:

Change managed by git-spice.

arthur-priorlabs added a commit that referenced this pull request Aug 18, 2026
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@arthur-priorlabs arthur-priorlabs changed the title Reduce peak host memory for tables with categorical columns perf: reduce peak memory on string categoricals Aug 18, 2026
@arthur-priorlabs
arthur-priorlabs marked this pull request as ready for review August 18, 2026 16:04
Comment thread src/tabpfn/preprocessing/steps/reshape_feature_distribution_step.py Outdated
@arthur-priorlabs
arthur-priorlabs marked this pull request as draft August 18, 2026 16:22
@arthur-priorlabs
arthur-priorlabs marked this pull request as ready for review August 18, 2026 17:01
arthur-priorlabs added a commit that referenced this pull request Aug 19, 2026
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@arthur-priorlabs
arthur-priorlabs force-pushed the arthur/optimize-ensemble-preprocessor-string-2 branch from 2d03791 to f563ca0 Compare August 19, 2026 09:14
Comment thread src/tabpfn/preprocessing/steps/encode_categorical_features_step.py Outdated
Base automatically changed from arthur/optimize-ensemble-preprocessor to main August 19, 2026 14:22
arthur-priorlabs and others added 5 commits August 19, 2026 14:24
When the transform is scheduled onto the GPU the CPU side is left with the
identity, so the only thing the ColumnTransformer still does to the data is move
columns -- and it pays two full-size arrays to do it, one for the blocks and one
for the hstack. A table that is half categorical takes that path: the identity
skip added earlier does not apply, because the categoricals are passed through
first and the layout is therefore a permutation rather than the identity.

Take the permutation in a single gather instead. The condition is read off the
layout rather than the flags that built it: every input column used exactly once,
which a dropped column, an append_to_original copy or a multi-output transform
all fail, leaving the ColumnTransformer in place.

Halves this step's transient RSS on the half-string mix, 0.422 GB -> 0.213 GB at
66,667 x 400. The overall peak needs the encoder's rebuild gone too, which the
next commit does; on its own this changes no number the benchmark reports.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ColumnTransformer` reaches its result through three full-size arrays' worth of
allocation: the block of codes, the passthrough block, and the hstack of the two.
Writing into one preallocated output costs the output plus the codes. Same pattern
`clean_data` already uses in `_encode_into_preallocated`, for the same reason.

Fitting needs care: `ColumnTransformer.fit` is implemented as `fit_transform`, so
fitting on the whole array would run the pass being replaced. One row settles the
column bookkeeping -- widths and output indices do not depend on the row count for
a one-to-one encoder -- and `fit_transform` on the categorical slice then produces
the codes in a single pass over a single slice.

Two things are matched to sklearn rather than chosen: the output dtype, which is
what its hstack would have promoted to, and the memory layout, which its
concatenate picks as Fortran only when every block it stacks already is. That
second one is not cosmetic -- the sklearn SVD downstream converges to a different
basis on a C- than on a Fortran-contiguous input -- so `_encode_into_preallocated`
is now covered by tests that compare values, dtype and layout against a
ColumnTransformer fitted the ordinary way, over nine input shapes including the
corner cases that flip the layout. The first draft of this commit pinned the order
and those tests caught it.

Measured with `scripts/bench_ensemble_preprocessing.py --reference
arthur/optimize-ensemble-preprocessor --mix half-string` on a cpuhighmem16spot
node, 333,333 x 400, members bit-identical:

  transient RSS     3.33 GB -> 2.80 GB (-16.0%)
  median wall time  7.364 s -> 7.534 s (+2.3%, nine repeats)

The time is a wash rather than a win: the two sides' fastest repeats are 7.267 s
and 7.287 s, and the spread within a run reaches 8.9%, so the median gap sits
inside the noise this benchmark carries. The zero-tolerance gate does reject it --
`--tolerance 0.03` is the documented way to read a delta that small.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`column_order_` was added alongside the gather path, so a step pickled by any
earlier version restores without it -- and the lookup sat between the `getattr`
guard on `data_is_unchanged_` and the fallthrough to `transformer_` that guard
exists to reach. Predicting on such an estimator raised AttributeError instead of
transforming.

Both attributes postdate the class, so both are now read defensively, and the test
that covers the first one has a companion for this one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@arthur-priorlabs
arthur-priorlabs force-pushed the arthur/optimize-ensemble-preprocessor-string-2 branch from 634e592 to a681d94 Compare August 19, 2026 14:25

@cursor cursor 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.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit a681d94. Configure here.

X.shape,
dtype=np.result_type(codes.dtype, X.dtype),
order="F" if stacks_fortran else "C",
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Empty remainder promotes output dtype

Medium Severity

_encode_into_preallocated always sets the output dtype to np.result_type(codes.dtype, X.dtype), including when every column is categorical and there is no passthrough block. A ColumnTransformer with empty remainder returns the encoder output as-is, so its dtype is only codes.dtype. For an object-dtyped all-categorical table that becomes object instead of float, which can break numeric steps that follow.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a681d94. Configure here.

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