perf: reduce peak memory on string categoricals - #1187
Conversation
|
This change is part of the following stack: Change managed by git-spice. |
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2d03791 to
f563ca0
Compare
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>
634e592 to
a681d94
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ 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", | ||
| ) |
There was a problem hiding this comment.
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.
Reviewed by Cursor Bugbot for commit a681d94. Configure here.


Issue
RES-2592: optimize
TabPFNEnsemblePreprocessor.fit_transform_ensemble_members_iteratorRAM usage — stacked on #1186, which left the half-categorical case as the one mix it could not help.Motivation and Context
What
main.Why
How
clean_dataalready 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.
Six complete A/B comparisons against this PR's base, run by
scripts/bench_matrix.pyon dedicatedcpuhighmem16spotnodes, 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.03passes 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
How Has This Been Tested?
tests/test_preprocessing,tests/test_torch_preprocessing--no-gpu-preprocessing_encode_into_preallocatedis pinned against sklearn as the oracle, over nine input shapes — values, dtype and memory layout, compared with aColumnTransformerfitted the ordinary way. Two more tests cover the step end to end and the one-row fit the assembly relies on.hstackreturns 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_layoutexists to stop.Checklist
changelog/README.md), or "no changelog needed" label requested.