perf(load_ml): halve the memory of a training pass - #4559
Conversation
Profiling a plan cycle showed ML training dominating peak memory at 1.4GB, with the feature matrix and its copies accounting for most of it. A curriculum pass over three weeks of history builds 11,500 samples by 1,446 float32 features - 66.7MB - and the training path held several copies of it at once. Four changes, measured through the production code path at that scale: Preallocate the feature matrix. _create_dataset built a Python list of 11,500 separate per-sample arrays and then copied it into np.array(), keeping both alive. Rows are now written straight into a preallocated matrix, which also removes the five temporary arrays and the concatenated result that _build_sample created per sample. Normalise in place. _normalize_features gains an opt-in in_place flag, used at the three training sites where the caller is provably finished with the array it hands in - only the normalised arrays are read from there on. The default still copies. Compute normalisation statistics over row blocks. np.std(X, axis=0) materialises the deviations array internally, another full copy of the matrix to produce 1,446 numbers. _feature_mean_std accumulates over blocks in float64 instead, which needs no temporary larger than one block and is also far more accurate: on a large-mean dataset the fitted std error drops from 2.4e-06 to 4.6e-10 and the mean error from 2.2e-04 to the float32 representation floor. Stop promoting the matrix to float64. _get_min_std_array returned a float64 array, so np.maximum made feature_std float64 and (X - mean) / std promoted the entire normalised matrix to double precision - 133.3MB rather than 66.6MB - and ran every forward and backward pass in float64 against float32 weights. The array is now float32, matching the dataset and the weights. Measured on 11,519 samples through _create_dataset plus _normalize_features: dataset construction 181.8MB -> 107.9MB peak growth 298.9MB -> 165.1MB (-45%) normalised matrix float64 133.3MB -> float32 66.6MB The dtype fix changes results slightly, so this is not a pure refactor: across the ML test suite val_mae moves by 0.0054 kWh on average, early epochs are consistently a little better and training runs further before early stopping. All 29 ML tests pass before and after. The feature matrix itself is unchanged - a checksum over it is pinned in the new tests and does not move. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR reduces peak RSS during the ML load-forecaster training path by eliminating avoidable copies of the feature matrix and preventing accidental float64 promotion, while adding targeted regression tests to pin numerics and dtype/memory contracts.
Changes:
- Preallocates and fills the training/validation feature matrices row-by-row in
_create_dataset()to avoid list-of-rows +np.array()double-allocation. - Adds block-wise float64 accumulation for mean/std fitting and an optional in-place normalization path used by training.
- Ensures normalization stays float32 end-to-end (fixing
_get_min_std_array()dtype), and adds newml_memoryunit tests.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| apps/predbat/load_predictor.py | Memory-focused refactor of dataset construction and normalization; adds block-wise stats and float32 min-std to avoid float64 promotion. |
| apps/predbat/tests/test_ml_memory.py | New tests pinning dataset checksum/sample count, normalization numerics, in-place behavior, dtype contract, and stats accuracy. |
| apps/predbat/unit_test.py | Registers the new ml_memory test group in the unit test runner. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Preallocating the feature matrix changed _build_sample to write into a destination row and return just the target, but its three early returns still returned the old two-value form. On a gap in the history that returns the tuple (None, None), and `if target is None` is false for a tuple, so the row was counted as valid and the tuple was appended to the target list. Building the target array then failed: ValueError: setting an array element with a sequence. The requested array has an inhomogeneous shape after 1 dimensions. Every curriculum pass threw, so the model never trained. _do_training catches the exception and logs it, then the component retries a minute later, so Predbat kept planning normally and the forecaster silently never produced a model. The existing fixtures all build unbroken synthetic history, which is why this got past them. The new test punches two sensor-dropout holes in the history and asserts the dataset stays dense, its targets and weights stay aligned with it, and every feature row is fully written. Verified end to end against a real curriculum training run: 9 passes complete with no exceptions, matching the unmodified code, and peak RSS drops from 1440.1MB to 811.4MB. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review feedback on #4559: _feature_mean_std squared the deviations into a fresh array, so each block held two float64 buffers rather than one. At the previous block size of 4096 rows that was 47.4MB each, or 94.8MB of temporaries to compute statistics over a 66.6MB matrix - more than the matrix itself. Square into the deviation buffer instead, which is bitwise identical, and size the block so the buffer is a small fraction of the matrix rather than most of it: 512 rows of 1,446 features is 5.9MB against 47.4MB. The smaller block regroups the float64 summation, which moves the accumulated std by about 5e-16 - eleven orders of magnitude below the float32 resolution of the stored result, so both statistics are bitwise identical once cast. Peak growth of _create_dataset plus _normalize_features at 11,519 samples falls from 165.1MB to 85.9MB, and from 298.9MB before any of this work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks — both comments addressed.
Fixed in 6e7e9be, with a test that punches two sensor-dropout holes in the history and asserts the dataset stays dense and aligned. Verified end to end: 9 curriculum passes complete with no exceptions, matching unmodified behaviour.
Fixed in c58fd52: squaring into the deviation buffer (bitwise identical), and dropping the block to 512 so the buffer is 5.9 MB rather than 47.4 MB. The smaller block regroups the float64 summation, moving the accumulated std by ~5e-16 — eleven orders below the float32 resolution of the stored result — so both statistics are bitwise identical once cast. Verified directly rather than assumed. Peak growth of
Separately, a correction to this PR's own description. I originally cited a |
Problem
Profiling a plan cycle found ML training dominating peak memory at 1.4 GB RSS, with 81% of the live heap in the training thread. A curriculum pass over three weeks of history builds 11,500 samples × 1,446 float32 features — 66.7 MB — and the training path held several copies of it simultaneously.
Four changes
1. Preallocate the feature matrix.
_create_datasetbuilt a Python list of 11,500 separate per-sample arrays, then copied it intonp.array()— both alive at once. Rows now go straight into a preallocated matrix, which also removes the five temporary arrays plus the concatenated result that_build_samplecreated per sample (1.28M allocations each in the profile).2. Normalise in place.
_normalize_featuresgains an opt-inin_placeflag, used at the three training sites where the caller is provably finished with the array — only the_normarrays are read from there on. The default still copies, so the prediction paths are untouched.3. Compute statistics over row blocks.
np.std(X, axis=0)materialises the deviations array internally — another full copy of the matrix to produce 1,446 numbers._feature_mean_stdaccumulates over blocks in float64: no temporary larger than one block, and considerably more accurate:4. Stop promoting the matrix to float64.
_get_min_std_arrayreturned a float64 array, sonp.maximummadefeature_stdfloat64 and(X - mean) / stdpromoted the entire normalised matrix to double precision — 133.3 MB rather than 66.6 MB — running every forward and backward pass in float64 against float32 weights. This looks accidental: the dataset, weights and activations are all deliberately float32.Measured impact
End to end, a real curriculum training run against live Home Assistant history, both arms completing 9 passes with zero exceptions on matched data:
At component level, through
_create_dataset+_normalize_featuresat 11,519 samples:(Peak growth was 165.1 MB before review feedback; squaring the deviations in place and shrinking the statistics block took a further 79 MB.)
Numerics
Change 4 shifts results, so this is not a pure refactor. All 29/29 ML tests pass before and after.
Quantifying the shift is harder than it first appears: the ML suite is not deterministic. Two consecutive runs of identical code produce different
val_maesequences. Against that noise floor:So the change is real at roughly 3x the noise, but a single before/after run cannot pin the magnitude more precisely than that. Changes 1-3 are provably behaviour-preserving; change 4 is the only one that moves results.
The feature matrix itself is unchanged — a checksum over it is pinned in the new tests and does not move, and that check is deterministic.
A bug this introduced, and caught
The second commit fixes a defect from change 1. Making
_build_samplewrite into a destination row and return just the target left its three early returns on the old two-value form, so on a gap in the history it returned the tuple(None, None)— andif target is Noneis false for a tuple. Gap rows were counted as valid and the tuple landed in the target list, so every curriculum pass threwValueError: setting an array element with a sequenceand the model never trained.It was invisible from the test suite because every fixture builds unbroken synthetic history, and invisible at runtime because
_do_trainingcatches the exception, logs it, and retries a minute later — Predbat kept planning normally while the forecaster silently never produced a model. It surfaced only from the end-to-end profile: 9 curriculum passes before the change, 0 after.The new test punches two sensor-dropout holes in the history and asserts the dataset stays dense, its targets and weights stay aligned, and every feature row is fully written.
Testing
tests/test_ml_memory.py, 20 assertions across 6 tests, registered asml_memory: normalisation numerics, in-place behaviour and default non-mutation, dtype contract, a_create_datasetcharacterisation test pinning the feature-matrix checksum and sample count, statistics accuracy on a large-mean dataset, and gap handling./run_all --quick— all tests pass, 20/20 random scenarios match baseline across 320 fields./run_all --test load_ml— 29/29 pass./run_pre_commit— all hooks pass🤖 Generated with Claude Code