Skip to content

Commit d271dc7

Browse files
perf(kernel): cache window bounds instead of re-deriving them per simulation
A search runs thousands of simulations over the same charge/export windows, varying only the limits, but every call re-derived the window start/end bounds from the window dicts - four ctypes arrays for the kernel scenario and two tuples for the prediction cache key. That was the largest single block of Python time in a plan. Caches them in prediction_kernel keyed on the identity of the window list, with the derived fields built lazily so a caller that only wants the hash tuple never pays to build the ctypes arrays. Hit rate on the benchmark is ~94%. Correctness rests on every mutation of a window's start/end invalidating the cache, so the 16 in-place assignments on the planning path now go through set_window_start()/set_window_end(). The guard is run_window_cache_tests, which replays a full calculate_plan with VALIDATE_WINDOW_CACHE on - that re-derives the bounds on every cache hit and raises on any stale entry, so a future bare window["start"] = ... on this path fails the suite rather than silently simulating the wrong window geometry. The test also asserts the validator itself catches a planted stale entry, so it cannot pass vacuously. Pool workers unpickle fresh window lists every call and can never hit the cache, where leaving it on cost ~3% of a pooled plan, so Pool() now runs disable_window_cache as its worker initialiser. The cache is bounded and pins the lists it keys on, so a caller that never repeats a list cannot grow it without limit or alias a freed list's id() onto the wrong entry. Measured on random scenario 0 (median of 3): threads=0 2366.3ms -> 1839.0ms -22.3% threads=auto 2134.0ms -> 2146.8ms +0.6% (noise) C++ share of plan time rises from 43.7% to 56.2%; pk_run itself is unchanged at 19,209 calls of 55.0us, which is what confirms the simulation work is identical. The 20 scenario benchmark drops 42.6s -> 33.4s. Verified byte-identical: all 20 random scenarios unchanged on metric, cost and all three PV futures, and kernel_parity passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 10e4092 commit d271dc7

6 files changed

Lines changed: 279 additions & 33 deletions

File tree

.cspell/custom-dictionary-workspace.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,7 @@ unconfigured
520520
undiscounted
521521
unnormalised
522522
unparseable
523+
unpickles
523524
unsmoothed
524525
unstaged
525526
urlsafe

apps/predbat/plan.py

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525
from utils import calc_percent_limit, clone_windows, dp0, dp1, dp2, dp3, dp4, remove_intersecting_windows, in_car_slot
2626
from prediction import Prediction, wrapped_run_prediction_single, wrapped_run_prediction_charge, wrapped_run_prediction_charge_min_max, wrapped_run_prediction_export
27-
from prediction_kernel import kernel_status_summary
27+
from prediction_kernel import kernel_status_summary, set_window_start, disable_window_cache
2828
from predbat_metrics import metrics
2929
import time
3030

@@ -1356,10 +1356,10 @@ def calculate_plan(self, recompute=True, debug_mode=False, publish=True):
13561356
threads = self.get_arg("threads", "auto")
13571357
if threads == "auto":
13581358
self.log("Creating pool of {} processes to match your CPU count".format(cpu_count()))
1359-
self.pool = Pool(processes=cpu_count())
1359+
self.pool = Pool(processes=cpu_count(), initializer=disable_window_cache)
13601360
elif threads:
13611361
self.log("Creating pool of {} processes as per apps.yaml".format(int(threads)))
1362-
self.pool = Pool(processes=int(threads))
1362+
self.pool = Pool(processes=int(threads), initializer=disable_window_cache)
13631363
else:
13641364
self.log("Not using threading as threads is set to 0 in apps.yaml")
13651365

@@ -2946,7 +2946,7 @@ def optimise_plan_pass(self, end_record, budget=0, debug_mode=False):
29462946
continue
29472947

29482948
snapshot = self.plan_window_snapshot(typ, window_n)
2949-
self.export_window_best[window_n]["start"] = self.export_window_best[window_n].get("start_orig", self.export_window_best[window_n]["start"])
2949+
set_window_start(self.export_window_best[window_n], self.export_window_best[window_n].get("start_orig", self.export_window_best[window_n]["start"]))
29502950
best_soc, best_start, best_metric, best_cost, soc_min, soc_min_minute, best_keep, best_cycle, best_carbon, best_import, best_metric_plan = self.optimise_export(
29512951
window_n,
29522952
record_export_windows,
@@ -2958,7 +2958,7 @@ def optimise_plan_pass(self, end_record, budget=0, debug_mode=False):
29582958
)
29592959
self.export_limits_best[window_n] = best_soc
29602960
self.export_window_best[window_n]["start_orig"] = self.export_window_best[window_n].get("start_orig", self.export_window_best[window_n]["start"])
2961-
self.export_window_best[window_n]["start"] = best_start
2961+
set_window_start(self.export_window_best[window_n], best_start)
29622962
candidate = (best_metric_plan, best_cost, best_keep, best_cycle, best_carbon, best_import)
29632963
selected = self.keep_window_change_if_improved(selected, candidate, typ, window_n, snapshot)
29642964
if (count % 16) == 0 and self.debug_enable:
@@ -3103,7 +3103,7 @@ def optimise_solar(self, best_metric, best_cost, best_keep, best_cycle, best_car
31033103
if self.export_limits_best[window_n] == 99.0:
31043104
start_orig = self.export_window_best[window_n].get("start_orig", window_start)
31053105
if start_orig < window_start:
3106-
self.export_window_best[window_n]["start"] = start_orig
3106+
set_window_start(self.export_window_best[window_n], start_orig)
31073107
continue
31083108

31093109
# Only enable currently idle (disabled) export windows
@@ -3159,7 +3159,7 @@ def optimise_solar(self, best_metric, best_cost, best_keep, best_cycle, best_car
31593159
)
31603160
self.export_limits_best[window_n] = new_soc
31613161
self.export_window_best[window_n]["start_orig"] = self.export_window_best[window_n].get("start_orig", self.export_window_best[window_n]["start"])
3162-
self.export_window_best[window_n]["start"] = new_start
3162+
set_window_start(self.export_window_best[window_n], new_start)
31633163
re_optimised += 1
31643164

31653165
# Simulate the final plan for this day on top of any days already kept and decide on the
@@ -3307,18 +3307,18 @@ def optimise_swap_export(self, record_charge_windows, record_export_windows, dro
33073307
if export_limit_target < 99 and (window_length_target + window_length) <= orig_length_target:
33083308
# Full combine
33093309
self.export_limits_best[window_n] = 100
3310-
self.export_window_best[window_n]["start"] = window_start_orig
3310+
set_window_start(self.export_window_best[window_n], window_start_orig)
33113311
self.export_limits_best[window_n_target] = export_limit
3312-
self.export_window_best[window_n_target]["start"] = self.export_window_best[window_n_target]["end"] - (window_length + window_length_target)
3312+
set_window_start(self.export_window_best[window_n_target], self.export_window_best[window_n_target]["end"] - (window_length + window_length_target))
33133313
is_combined = True
33143314
elif export_limit_target < 99 and window_length_target < orig_length_target:
33153315
# Partial combine
33163316
amount_to_move = min(orig_length_target - window_length_target, window_length)
33173317
window_length_target_new = amount_to_move + window_length_target
33183318
window_length_new = amount_to_move + window_length
33193319
self.export_limits_best[window_n] = min(export_limit, export_limit_target)
3320-
self.export_window_best[window_n]["start"] = self.export_window_best[window_n]["end"] - window_length_new
3321-
self.export_window_best[window_n_target]["start"] = self.export_window_best[window_n_target]["end"] - window_length_target_new
3320+
set_window_start(self.export_window_best[window_n], self.export_window_best[window_n]["end"] - window_length_new)
3321+
set_window_start(self.export_window_best[window_n_target], self.export_window_best[window_n_target]["end"] - window_length_target_new)
33223322
self.export_limits_best[window_n_target] = min(export_limit, export_limit_target)
33233323
is_combined = True
33243324
else:
@@ -3329,9 +3329,9 @@ def optimise_swap_export(self, record_charge_windows, record_export_windows, dro
33293329

33303330
# Set the current window to off and optimise the swap window
33313331
self.export_limits_best[window_n] = export_limit_target
3332-
self.export_window_best[window_n]["start"] = max(self.export_window_best[window_n]["end"] - window_length_target, previous_end)
3332+
set_window_start(self.export_window_best[window_n], max(self.export_window_best[window_n]["end"] - window_length_target, previous_end))
33333333
self.export_limits_best[window_n_target] = export_limit
3334-
self.export_window_best[window_n_target]["start"] = max(self.export_window_best[window_n_target]["end"] - window_length, previous_end_target)
3334+
set_window_start(self.export_window_best[window_n_target], max(self.export_window_best[window_n_target]["end"] - window_length, previous_end_target))
33353335

33363336
best_metric, best_battery_value, best_cost, best_keep, best_cycle, best_carbon, best_import, best_export = self.run_prediction_metric(
33373337
self.charge_limit_best, self.charge_window_best, self.export_window_best, self.export_limits_best, end_record=self.end_record
@@ -3402,9 +3402,9 @@ def optimise_swap_export(self, record_charge_windows, record_export_windows, dro
34023402
else:
34033403
# Revert the change
34043404
self.export_limits_best[window_n] = export_limit
3405-
self.export_window_best[window_n]["start"] = window_start
3405+
set_window_start(self.export_window_best[window_n], window_start)
34063406
self.export_limits_best[window_n_target] = export_limit_target
3407-
self.export_window_best[window_n_target]["start"] = window_start_target
3407+
set_window_start(self.export_window_best[window_n_target], window_start_target)
34083408

34093409
self.log(
34103410
"Swap export optimisation finished metric {}{}, cost {}{}, metric_keep {}kWh, cycle {}kWh, carbon {}kg, import {}kWh".format(
@@ -3822,7 +3822,7 @@ def optimise_detailed_pass(
38223822
)
38233823
# Try to optimise the export window
38243824
keep_start = self.export_window_best[window_n]["start"]
3825-
self.export_window_best[window_n]["start"] = self.export_window_best[window_n].get("start_orig", self.export_window_best[window_n]["start"])
3825+
set_window_start(self.export_window_best[window_n], self.export_window_best[window_n].get("start_orig", self.export_window_best[window_n]["start"]))
38263826
n_best_soc, n_best_start, n_best_metric, n_best_cost, n_soc_min, n_soc_min_minute, n_best_keep, n_best_cycle, n_best_carbon, n_best_import, n_best_metric_plan = self.optimise_export(
38273827
window_n,
38283828
record_export_windows,
@@ -3834,7 +3834,7 @@ def optimise_detailed_pass(
38343834
freeze_only=(typ == "df") or pass_type == "freeze",
38353835
allow_freeze=True,
38363836
)
3837-
self.export_window_best[window_n]["start"] = keep_start
3837+
set_window_start(self.export_window_best[window_n], keep_start)
38383838
# The export trim pass may only reduce export, never add it, so the cheapest slots
38393839
# shed any levels over-export before the high-priced peak is touched. A reduction is
38403840
# a shallower discharge (higher SoC limit) and/or a smaller window (later start) -
@@ -3858,7 +3858,7 @@ def optimise_detailed_pass(
38583858
best_soc_min_minute = n_soc_min_minute
38593859
self.export_limits_best[window_n] = best_soc
38603860
self.export_window_best[window_n]["start_orig"] = self.export_window_best[window_n].get("start_orig", self.export_window_best[window_n]["start"])
3861-
self.export_window_best[window_n]["start"] = best_start
3861+
set_window_start(self.export_window_best[window_n], best_start)
38623862

38633863
self.plan_write_debug(debug_mode, "plan_{}_export_{}.html".format(pass_type, window_n), self.pv_forecast_minute_step, self.pv_forecast_minute10_step, self.load_minutes_step, self.load_minutes_step10, self.end_record)
38643864

apps/predbat/prediction.py

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,9 @@
1919

2020
from datetime import timedelta
2121
from const import PREDICT_STEP, PV_SCENARIO_PV10, PV_SCENARIO_PV90, RUN_EVERY, TIME_FORMAT
22-
from operator import itemgetter
2322

2423
from utils import remove_intersecting_windows, get_charge_rate_curve_cached, get_discharge_rate_curve_cached, find_charge_rate, calc_percent_limit, in_iboost_slot, in_car_slot, charge_curve_to_tuple
25-
from prediction_kernel import create_kernel_context, kernel_supported, run_prediction_kernel
24+
from prediction_kernel import create_kernel_context, kernel_supported, run_prediction_kernel, window_bound_tuple, set_window_start
2625

2726

2827
# Only assign globals once to avoid re-creating them with processes are forked
@@ -94,11 +93,6 @@ def get_total_inverted(battery_draw, pv_dc, pv_ac, inverter_loss, inverter_hybri
9493
return total_inverted
9594

9695

97-
# Pulls (start, end) from a window dict; used to build the prediction cache key without a Python
98-
# level loop over every window
99-
window_bounds = itemgetter("start", "end")
100-
101-
10296
class Prediction:
10397
"""
10498
Class to hold prediction input and output data and the run function
@@ -370,7 +364,7 @@ def thread_run_prediction_export(self, this_export_limit, start, window_n, charg
370364
# Adjust start
371365
window = export_window[window_n]
372366
start = min(start, window["end"] - 5)
373-
export_window[window_n]["start"] = start
367+
set_window_start(window, start)
374368

375369
(
376370
metricmid,
@@ -425,9 +419,9 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
425419
sim_hash = hash(
426420
(
427421
tuple(charge_limit),
428-
tuple(map(window_bounds, charge_window)),
422+
window_bound_tuple(charge_window),
429423
tuple(export_limits),
430-
tuple(map(window_bounds, export_window)),
424+
window_bound_tuple(export_window),
431425
pv_scenario,
432426
end_record,
433427
step,

apps/predbat/prediction_kernel.py

Lines changed: 115 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,119 @@ def int32_array(values):
311311
return (ctypes.c_int32 * len(backing)).from_buffer(backing)
312312

313313

314+
# ---------------------------------------------------------------------------
315+
# Window bounds cache
316+
#
317+
# A search re-runs thousands of simulations over the same charge/export windows, changing only the
318+
# limits. The start/end bounds handed to the kernel are therefore identical call after call, but
319+
# were re-derived from the window dicts every time - measured as the largest single block of Python
320+
# time in a plan. They are cached here, keyed on the identity of the window list.
321+
#
322+
# CORRECTNESS: the cache is only sound while no window's start or end changes underneath it. Every
323+
# such mutation must call invalidate_window_cache() - route them through
324+
# Plan.set_window_start()/Prediction.set_window_start() rather than assigning window["start"]
325+
# directly. run_window_cache_tests replays a full plan with VALIDATE_WINDOW_CACHE on, which
326+
# re-derives the bounds on every hit and fails on any stale entry, so a missed invalidation is a
327+
# test failure rather than a silently wrong plan.
328+
#
329+
# Entries pin their window list (so a freed list cannot have its id() reused by a later
330+
# allocation, which would alias to the wrong entry) and the cache is bounded, so a caller that
331+
# never repeats a list - a forked pool worker, which unpickles fresh lists every call - thrashes
332+
# harmlessly within the bound instead of growing without limit.
333+
# ---------------------------------------------------------------------------
334+
WINDOW_CACHE_MAX = 16
335+
VALIDATE_WINDOW_CACHE = False
336+
WINDOW_CACHE_ENABLED = True
337+
_WINDOW_BOUNDS_CACHE = {}
338+
339+
340+
def disable_window_cache():
341+
"""Turn the window bounds cache off in this process - used as the pool worker initialiser.
342+
343+
A worker unpickles fresh window lists on every call, so it can never hit the cache; leaving it
344+
on there only pays the failed lookup. Measured at ~3% of a pooled plan.
345+
"""
346+
global WINDOW_CACHE_ENABLED
347+
WINDOW_CACHE_ENABLED = False
348+
_WINDOW_BOUNDS_CACHE.clear()
349+
350+
351+
def invalidate_window_cache():
352+
"""Drop all cached window bounds - must be called whenever a window's start or end changes"""
353+
_WINDOW_BOUNDS_CACHE.clear()
354+
355+
356+
def set_window_start(window, start):
357+
"""Set a window's start time, invalidating any cached bounds derived from it.
358+
359+
Use this instead of assigning window["start"] directly - see the window bounds cache notes
360+
above for why a bare assignment is unsafe.
361+
"""
362+
if window["start"] != start:
363+
window["start"] = start
364+
_WINDOW_BOUNDS_CACHE.clear()
365+
366+
367+
def set_window_end(window, end):
368+
"""Set a window's end time, invalidating any cached bounds derived from it - see set_window_start"""
369+
if window["end"] != end:
370+
window["end"] = end
371+
_WINDOW_BOUNDS_CACHE.clear()
372+
373+
374+
def _window_cache_entry(window_list):
375+
"""Return the cache entry [window_list, starts, ends, bounds_tuple] for a window list.
376+
377+
The derived fields start as None and are filled in on first use, so a caller that only wants
378+
the hash tuple never pays to build the ctypes arrays, and vice versa.
379+
"""
380+
key = id(window_list)
381+
entry = _WINDOW_BOUNDS_CACHE.get(key)
382+
if entry is not None and entry[0] is window_list:
383+
if VALIDATE_WINDOW_CACHE:
384+
_validate_entry(window_list, entry)
385+
return entry
386+
if len(_WINDOW_BOUNDS_CACHE) >= WINDOW_CACHE_MAX:
387+
_WINDOW_BOUNDS_CACHE.clear()
388+
entry = [window_list, None, None, None]
389+
_WINDOW_BOUNDS_CACHE[key] = entry
390+
return entry
391+
392+
393+
def window_bound_arrays(window_list):
394+
"""Return (start_array, end_array) as ctypes int32 arrays for a window list, cached by identity.
395+
396+
The returned arrays are shared with other callers and must not be modified.
397+
"""
398+
if not WINDOW_CACHE_ENABLED:
399+
return int32_array([window["start"] for window in window_list]), int32_array([window["end"] for window in window_list])
400+
entry = _window_cache_entry(window_list)
401+
if entry[1] is None:
402+
entry[1] = int32_array([window["start"] for window in window_list])
403+
entry[2] = int32_array([window["end"] for window in window_list])
404+
return entry[1], entry[2]
405+
406+
407+
def window_bound_tuple(window_list):
408+
"""Return a tuple of (start, end) pairs for a window list, cached by identity (prediction cache key)"""
409+
if not WINDOW_CACHE_ENABLED:
410+
return tuple([(window["start"], window["end"]) for window in window_list])
411+
entry = _window_cache_entry(window_list)
412+
if entry[3] is None:
413+
entry[3] = tuple([(window["start"], window["end"]) for window in window_list])
414+
return entry[3]
415+
416+
417+
def _validate_entry(window_list, entry):
418+
"""Re-derive the bounds and raise if the cached entry is stale (VALIDATE_WINDOW_CACHE only)"""
419+
starts = [window["start"] for window in window_list]
420+
ends = [window["end"] for window in window_list]
421+
if entry[1] is not None and (starts != list(entry[1]) or ends != list(entry[2])):
422+
raise AssertionError("Stale window bounds cache (arrays): a window changed without invalidate_window_cache().\n starts cached {} actual {}\n ends cached {} actual {}".format(list(entry[1]), starts, list(entry[2]), ends))
423+
if entry[3] is not None and tuple(zip(starts, ends)) != entry[3]:
424+
raise AssertionError("Stale window bounds cache (tuple): a window changed without invalidate_window_cache().\n cached {}\n actual {}".format(entry[3], tuple(zip(starts, ends))))
425+
426+
314427
def kernel_context_free(handle):
315428
"""Free a kernel context by handle (used as a weakref finaliser)"""
316429
if KERNEL_LIB and handle:
@@ -518,11 +631,9 @@ def run_prediction_kernel(pred, charge_limit, charge_window, export_window, expo
518631
# The window fields keep their list comprehensions - map(itemgetter(...)) measured *slower* here at these
519632
# list lengths, the per-item call overhead outweighing what the comprehension costs.
520633
scenario.charge_limit = double_array(charge_limit)
521-
scenario.charge_start = int32_array([window["start"] for window in charge_window])
522-
scenario.charge_end = int32_array([window["end"] for window in charge_window])
634+
scenario.charge_start, scenario.charge_end = window_bound_arrays(charge_window)
523635
scenario.export_limits = double_array(export_limits)
524-
scenario.export_start = int32_array([window["start"] for window in export_window])
525-
scenario.export_end = int32_array([window["end"] for window in export_window])
636+
scenario.export_start, scenario.export_end = window_bound_arrays(export_window)
526637
soc_out = (ctypes.c_double * n_steps)()
527638
scenario.soc_out = soc_out
528639
scenario.n_charge = len(charge_window)

0 commit comments

Comments
 (0)