perf: cache window bounds and replace deepcopy on window lists (-21% planning) - #4536
Conversation
There was a problem hiding this comment.
Pull request overview
This PR reduces Python-side overhead in planning by (1) replacing copy.deepcopy on window lists with a purpose-built shallow clone and (2) caching derived (start,end) bounds for window lists to avoid recomputation across repeated simulations, while adding targeted tests to protect correctness.
Changes:
- Add
utils.clone_windows()and replacecopy.deepcopy(...)usages on window-list snapshots with shallow per-dict copies. - Add a window-bounds cache in
prediction_kerneland route window start mutations throughset_window_start()(and disable caching in pool workers). - Extend the unit test suite with
clone_windowsand window-cache validation tests; update spell-check dictionary for new wording.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| apps/predbat/utils.py | Adds clone_windows() helper for fast window-list cloning. |
| apps/predbat/unit_test.py | Registers new window clone/cache test runners; adds isolated runner for cache replay test. |
| apps/predbat/tests/test_window.py | Adds clone_windows tests and comprehensive window-cache validation tests. |
| apps/predbat/prediction.py | Uses window_bound_tuple() for prediction cache key; routes export window start mutation via set_window_start(). |
| apps/predbat/prediction_kernel.py | Introduces window bounds caching and mutation helpers; uses cached arrays in run_prediction_kernel. |
| apps/predbat/plan.py | Replaces deepcopies with clone_windows()/shallow copies and disables window cache in pool workers; routes start mutations via set_window_start(). |
| apps/predbat/marginal.py | Replaces deepcopy of flat load dict with .copy() for performance. |
| .cspell/custom-dictionary-workspace.txt | Adds “unpickles” to custom dictionary. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Window dicts only ever hold primitives (start/end/average/target/id/key), so
copy.deepcopy's generic recursive walk is doing no work that a per-dict .copy()
does not - measured 30.0us vs 1.6us per call on a 48-window list, 18.5x.
Adds utils.clone_windows() and uses it for the 13 planning-path deepcopy call
sites, plus a plain .copy() for the flat {minute: float} load forecast in
calculate_marginal_costs.
The copies exist for isolation, not just duplication: thread_run_prediction_export
writes export_window[window_n]["start"] in place, and plan_window_snapshot /
preclip_new are restored later. clone_windows keeps that contract - each dict is
copied, so in-place writes cannot leak either way - and the new tests pin it.
This is performance-neutral on the benchmark: 20 scenarios A/B, 3 reps each, came
out within noise (-0.4% median against ~6% run-to-run spread), because these call
sites were only ~1.5% of plan time. Kept for the explicit isolation contract and
the reduced allocation churn (deepcopy invocations -30%, total calls -3.4%), not
for a speed claim.
Verified byte-identical: all 20 random scenarios unchanged on metric, cost, and
all three PV futures (+0.0000 across the board).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ulation 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>
d271dc7 to
4a0f828
Compare
…as identity Addresses two of the three review comments on this PR. A window list that grows or shrinks in place keeps its id(), so identity alone was not enough to decide a cache hit. run_prediction_kernel passes n_charge/n_export from len(window_list) alongside the cached arrays, so a stale shorter array would be read past its end by the C kernel - a memory-safety failure rather than merely a wrong plan. The count is now part of the hit condition. Nothing on the planning path resizes a window list today, so this guards the class rather than fixing a live defect. The test proves it would have bitten: before the change, appending to a cached list left a one-entry bounds array against two windows, and popping left window_bound_tuple returning the longer tuple. Also corrects the correctness note, which pointed at Plan.set_window_start() and Prediction.set_window_start(). Neither exists - they are module functions here - and after the rebase the prediction path does not use them at all, since _prepare_export applies a trial start copy-on-write to a window dict and list of its own. The third comment, to build the bound tuple from a generator rather than a list comprehension, is not taken: measured over 50k calls at 200 windows, the generator is 19.3% slower (307ms against 366ms), because it pays per-item interpreter overhead the specialised list comprehension avoids. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Rebased onto Review comments1. Correctness note points at functions that don't exist — fixed. It said to route mutations through 2. Cache should guard against in-place length changes — fixed, and it was real. The count is now part of the hit condition. Nothing on the planning path resizes a window list today, so this guards the class rather than fixing a live defect — but the failure mode is worse than a wrong plan, since The test written for it fails without the guard, which is what makes it worth having: 3. Use a generator instead of
The generator pays per-item interpreter overhead that the specialised list comprehension avoids, so the change would work against the performance intent rather than with it. Rebase notesThe branch predated #4540's batching redesign, so this was more than a textual merge. Three judgement calls worth flagging:
Also kept MeasurementInterleaved against
960 fields compared across three rounds of all 20 scenarios — zero mismatches. Full suite of 217 tests and pre-commit green. Cumulatively with #4549, planning is 36.3% faster than it was before that PR (26.13s → 16.64s). 🤖 Generated with Claude Code |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (1)
apps/predbat/prediction_kernel.py:498
- window_bound_tuple() builds an intermediate list before converting to a tuple (both in the cache-disabled path and when initially populating the cached value). This adds avoidable allocations on a hot path; using a generator expression produces the same tuple without the temporary list.
if not WINDOW_CACHE_ENABLED:
return tuple([(window["start"], window["end"]) for window in window_list])
entry = _window_cache_entry(window_list)
if entry[3] is None:
entry[3] = tuple([(window["start"], window["end"]) for window in window_list])
Two related changes to how the planner handles window lists. Both are byte-identical on the 20-scenario random benchmark.
1.
clone_windowsinstead ofcopy.deepcopy(10e4092)Window dicts only hold primitives, so deepcopy's recursive walk does no work a per-dict
.copy()doesn't — 30.0µs vs 1.6µs on a 48-window list.The copies exist for isolation, not just duplication:
thread_run_prediction_exportwritesexport_window[window_n]["start"]in place, andplan_window_snapshot/preclip_newget restored later.clone_windowskeeps that contract andrun_clone_windows_testspins it.This one is performance-neutral (-0.4% median against ~6% run-to-run spread — these sites were only ~1.5% of plan time). Kept for the explicit isolation contract and reduced allocation churn, not for a speed claim.
2. Window bounds cache (d271dc7) — the actual win
A search runs thousands of simulations over the same windows, varying only the limits, but every call re-derived the start/end bounds from the dicts: 4 ctypes arrays for the kernel scenario, 2 tuples for the prediction cache key. That was the largest single block of Python time in a plan. Now cached by window-list identity, built lazily. ~94% hit rate.
threads=0threads=autopk_runitself is unchanged — 19,209 calls at 55.0µs before and after — which is what confirms the simulation work is identical and only Python overhead was removed.Correctness
The cache is only sound while every mutation of a window's start/end invalidates it, so the 16 in-place assignments on the planning path go through
set_window_start()/set_window_end().The guard is
run_window_cache_tests, which replays a fullcalculate_planwithVALIDATE_WINDOW_CACHEon — re-deriving bounds on every cache hit and raising on any stale entry. A future barewindow["start"] = ...on this path fails the suite rather than silently simulating the wrong geometry. The test also asserts the validator catches a planted stale entry, so it can't pass vacuously.Pool workers unpickle fresh lists every call and can never hit the cache, where leaving it on cost ~3% of a pooled plan — so
Pool()runsdisable_window_cacheas its worker initialiser. The cache is bounded and pins the lists it keys on, so a caller that never repeats a list can't grow it without limit or alias a freed list'sid()onto the wrong entry.Verification
+0.0000kernel_paritypasses (no kernel ABI change)🤖 Generated with Claude Code