Skip to content

Commit 4b2096f

Browse files
Merge pull request #4559 from springfall2008/perf/ml-training-memory
perf(load_ml): halve the memory of a training pass
2 parents 257acce + c58fd52 commit 4b2096f

4 files changed

Lines changed: 423 additions & 36 deletions

File tree

.cspell/custom-dictionary-workspace.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,7 @@ Roboto
420420
rowspan
421421
rstart
422422
rstrip
423+
rtol
423424
rtype
424425
ruamel
425426
Rvrt

apps/predbat/load_predictor.py

Lines changed: 90 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -611,14 +611,23 @@ def _create_dataset(self, load_minutes, now_utc, pv_minutes=None, temp_minutes=N
611611
# Validation window: most recent chunks (chunk_idx 0 … validation_end_chunk-1)
612612
validation_end_chunk = validation_holdout_hours * 60 // CHUNK_MINUTES
613613

614-
X_train_list = []
614+
# Preallocate the feature matrices and write each sample straight into its row. Holding
615+
# a Python list of per-sample arrays and then copying it into np.array() keeps both
616+
# alive at once, which for a three week window is an extra 70MB on top of the 66MB
617+
# matrix. Rows are sized for the maximum possible sample count and the filled prefix is
618+
# returned; samples are only skipped where the history has gaps, so the slack is small.
619+
max_train_rows = max(max_chunk_idx - LOOKBACK_STEPS, 0)
620+
max_val_rows = max(validation_end_chunk, 0)
621+
X_train_all = np.empty((max_train_rows, TOTAL_FEATURES), dtype=np.float32)
622+
X_val_all = np.empty((max_val_rows, TOTAL_FEATURES), dtype=np.float32)
623+
train_rows = 0
624+
val_rows = 0
615625
y_train_list = []
616626
weight_list = []
617-
X_val_list = []
618627
y_val_list = []
619628

620-
def _build_sample(target_chunk_idx):
621-
"""Build one (features, target) sample centred on target_chunk_idx."""
629+
def _build_sample(target_chunk_idx, out_row):
630+
"""Write one sample's features into out_row and return its target, or None on a gap."""
622631
lookback_start = target_chunk_idx + 1
623632
lookback_values = []
624633
pv_lookback_values = []
@@ -635,12 +644,12 @@ def _build_sample(target_chunk_idx):
635644
import_rate_lookback.append(chunked_import.get(lb_idx, 0.0))
636645
export_rate_lookback.append(chunked_export.get(lb_idx, 0.0))
637646
else:
638-
return None, None # Gap in data - skip
647+
return None # Gap in data - skip
639648

640649
if len(lookback_values) != LOOKBACK_STEPS:
641-
return None, None
650+
return None
642651
if target_chunk_idx not in chunked_energy:
643-
return None, None
652+
return None
644653

645654
target_value = chunked_energy[target_chunk_idx]
646655

@@ -651,25 +660,22 @@ def _build_sample(target_chunk_idx):
651660
day_of_year = target_time.timetuple().tm_yday
652661
time_features = self._create_time_features(minute_of_day, day_of_week, day_of_year)
653662

654-
features = np.concatenate(
655-
[
656-
np.array(lookback_values, dtype=np.float32),
657-
np.array(pv_lookback_values, dtype=np.float32),
658-
np.array(temp_lookback_values, dtype=np.float32),
659-
np.array(import_rate_lookback, dtype=np.float32),
660-
np.array(export_rate_lookback, dtype=np.float32),
661-
time_features,
662-
]
663-
)
664-
return features, np.array([target_value], dtype=np.float32)
663+
# Assigning the lists straight into row slices converts them in place, avoiding the
664+
# five temporary arrays and the concatenated result this used to build per sample
665+
offset = 0
666+
for values in (lookback_values, pv_lookback_values, temp_lookback_values, import_rate_lookback, export_rate_lookback):
667+
out_row[offset : offset + LOOKBACK_STEPS] = values
668+
offset += LOOKBACK_STEPS
669+
out_row[offset:] = time_features
670+
return np.array([target_value], dtype=np.float32)
665671

666672
# Training samples: all available chunks
667673
for target_chunk_idx in range(0, max_chunk_idx - LOOKBACK_STEPS):
668-
features, target = _build_sample(target_chunk_idx)
669-
if features is None:
674+
target = _build_sample(target_chunk_idx, X_train_all[train_rows])
675+
if target is None:
670676
continue
671677

672-
X_train_list.append(features)
678+
train_rows += 1
673679
y_train_list.append(target)
674680

675681
# Time-decay weighting (older samples get lower weight)
@@ -678,23 +684,23 @@ def _build_sample(target_chunk_idx):
678684

679685
# Validation samples: most recent validation_end_chunk chunks
680686
for target_chunk_idx in range(0, validation_end_chunk):
681-
features, target = _build_sample(target_chunk_idx)
682-
if features is None:
687+
target = _build_sample(target_chunk_idx, X_val_all[val_rows])
688+
if target is None:
683689
continue
684-
X_val_list.append(features)
690+
val_rows += 1
685691
y_val_list.append(target)
686692

687-
if not X_train_list:
693+
if not train_rows:
688694
return None, None, None, None, None
689695

690-
X_train = np.array(X_train_list, dtype=np.float32)
696+
X_train = X_train_all[:train_rows]
691697
y_train = np.array(y_train_list, dtype=np.float32)
692698
train_weights = np.array(weight_list, dtype=np.float32)
693699

694700
# Normalize weights to sum to number of samples
695701
train_weights = train_weights * len(train_weights) / np.sum(train_weights)
696702

697-
X_val = np.array(X_val_list, dtype=np.float32) if X_val_list else None
703+
X_val = X_val_all[:val_rows] if val_rows else None
698704
y_val = np.array(y_val_list, dtype=np.float32) if y_val_list else None
699705

700706
return X_train, y_train, train_weights, X_val, y_val
@@ -808,6 +814,43 @@ def _ar_rollout_diagnostic(self, load_minutes, now_utc, pv_minutes=None, temp_mi
808814

809815
return float(np.mean(errors)), float(np.mean(biases))
810816

817+
@staticmethod
818+
def _feature_mean_std(X, block=512):
819+
"""
820+
Compute per-feature mean and std over row blocks, accumulating in float64.
821+
822+
np.mean/np.std on a float32 array accumulate in float32, which loses low bits on the
823+
feature groups that sit far from zero, and np.std materialises the deviations array
824+
internally - another full copy of a 66MB training matrix to produce 1,446 numbers.
825+
Two passes over row blocks avoids both: no temporary larger than one block, and
826+
float64 accumulators that match a float64 reference.
827+
828+
Args:
829+
X: Feature array, shape (samples, features)
830+
block: Rows accumulated per pass. The float64 deviation buffer is
831+
block * features * 8 bytes, so this bounds the working set: 512 rows of
832+
1,446 features is 5.9MB against 47.4MB at 4096.
833+
834+
Returns:
835+
Tuple of (mean, std) as float32 arrays
836+
"""
837+
rows = len(X)
838+
total = np.zeros(X.shape[1], dtype=np.float64)
839+
for start in range(0, rows, block):
840+
total += X[start : start + block].sum(axis=0, dtype=np.float64)
841+
mean = total / rows
842+
843+
squares = np.zeros(X.shape[1], dtype=np.float64)
844+
for start in range(0, rows, block):
845+
deviation = X[start : start + block].astype(np.float64)
846+
deviation -= mean
847+
# Square into the deviation buffer rather than allocating a second one of the
848+
# same size; the values are identical either way
849+
squares += np.square(deviation, out=deviation).sum(axis=0)
850+
std = np.sqrt(squares / rows)
851+
852+
return mean.astype(np.float32), std.astype(np.float32)
853+
811854
def _get_min_std_array(self, n_features):
812855
"""
813856
Return the per-feature minimum std array used to prevent extreme normalization.
@@ -818,7 +861,10 @@ def _get_min_std_array(self, n_features):
818861
Returns:
819862
numpy array of minimum std values, shape (n_features,)
820863
"""
821-
min_std = np.ones(n_features) * 1e-8 # Default fallback
864+
# float32 to match the dataset and weights: a float64 minimum promotes the whole
865+
# normalised feature matrix to float64 via np.maximum, doubling its size and running
866+
# training in double precision against float32 weights
867+
min_std = np.ones(n_features, dtype=np.float32) * 1e-8 # Default fallback
822868
if n_features == TOTAL_FEATURES:
823869
min_std[0:LOOKBACK_STEPS] = 0.01 # Load energy (kWh)
824870
min_std[LOOKBACK_STEPS : 2 * LOOKBACK_STEPS] = 0.01 # PV energy (kWh)
@@ -858,7 +904,7 @@ def _log_normalization_stats(self, label=""):
858904

859905
self.log("ML Predictor: Normalization stats [{}] target(mean={:.4f} std={:.4f}) {}".format(label, self.target_mean if self.target_mean is not None else 0, self.target_std if self.target_std is not None else 0, " ".join(parts)))
860906

861-
def _normalize_features(self, X, fit=False, ema_alpha=0.0):
907+
def _normalize_features(self, X, fit=False, ema_alpha=0.0, in_place=False):
862908
"""
863909
Normalize features using z-score normalization with feature-specific minimum stds.
864910
@@ -868,22 +914,25 @@ def _normalize_features(self, X, fit=False, ema_alpha=0.0):
868914
ema_alpha: If > 0 and existing params exist, blend new stats with old via EMA
869915
(new = alpha * new_stats + (1-alpha) * old_stats). Used during
870916
fine-tuning to track feature distribution drift without sudden jumps.
917+
in_place: If True, write the normalised values back into X instead of building a
918+
new array. A training feature matrix is around 66MB, and the caller
919+
keeps no use for the un-normalised values, so copying doubles the
920+
resident cost of a training pass for nothing. Only pass this when the
921+
caller is finished with the array it hands in.
871922
872923
Returns:
873924
Normalized feature array
874925
"""
875926
if fit:
876-
self.feature_mean = np.mean(X, axis=0)
877-
self.feature_std = np.std(X, axis=0)
927+
self.feature_mean, self.feature_std = self._feature_mean_std(X)
878928

879929
# Clamp std to per-feature minimums to prevent extreme normalization
880930
self.feature_std = np.maximum(self.feature_std, self._get_min_std_array(len(self.feature_std)))
881931
self._log_normalization_stats(label="fit")
882932

883933
elif ema_alpha > 0 and self.feature_mean is not None and self.feature_std is not None:
884934
# EMA update: blend new statistics with existing to track distribution drift
885-
new_mean = np.mean(X, axis=0)
886-
new_std = np.std(X, axis=0)
935+
new_mean, new_std = self._feature_mean_std(X)
887936

888937
# Apply same min-std clamping to new stats before blending
889938
new_std = np.maximum(new_std, self._get_min_std_array(len(new_std)))
@@ -896,6 +945,11 @@ def _normalize_features(self, X, fit=False, ema_alpha=0.0):
896945
if self.feature_mean is None or self.feature_std is None:
897946
return X
898947

948+
if in_place:
949+
X -= self.feature_mean
950+
X /= self.feature_std
951+
return X
952+
899953
return (X - self.feature_mean) / self.feature_std
900954

901955
def _normalize_targets(self, y, fit=False):
@@ -1039,13 +1093,13 @@ def train(
10391093
# On initial train: fit normalization from scratch
10401094
# On fine-tune: apply EMA update to track distribution drift gradually
10411095
if is_initial or not self.model_initialized:
1042-
X_train_norm = self._normalize_features(X_train, fit=True)
1096+
X_train_norm = self._normalize_features(X_train, fit=True, in_place=True)
10431097
y_train_norm = self._normalize_targets(y_train, fit=True)
10441098
else:
1045-
X_train_norm = self._normalize_features(X_train, fit=False, ema_alpha=norm_ema_alpha)
1099+
X_train_norm = self._normalize_features(X_train, fit=False, ema_alpha=norm_ema_alpha, in_place=True)
10461100
y_train_norm = self._normalize_targets(y_train, fit=False)
10471101
self.log("ML Predictor: Applied EMA normalization update (alpha={}) to track feature drift".format(norm_ema_alpha))
1048-
X_val_norm = self._normalize_features(X_val, fit=False)
1102+
X_val_norm = self._normalize_features(X_val, fit=False, in_place=True)
10491103
y_val_norm = self._normalize_targets(y_val, fit=False)
10501104

10511105
# Initialise weights if needed

0 commit comments

Comments
 (0)