Skip to content

perf(plan): 21% faster planning, and cars in the random benchmark - #4529

Merged
springfall2008 merged 9 commits into
mainfrom
perf/levels-selection-memo
Aug 15, 2026
Merged

springfall2008 merged 9 commits into
mainfrom
perf/levels-selection-memo

Conversation

@springfall2008

Copy link
Copy Markdown
Owner

Three parity-exact speedups, plus the benchmark change that exposed the largest of them. Every step below was verified byte-identical across all 20 random scenarios on metric, cost, cost_pv10, cost_pv90, soc_min, soc_final, battery_cycles, export_kwh and import_kwh_battery.

The benchmark was blind to EV users

hit_car_window returns on its first line when num_cars is 0, and every random scenario had no car — so the suite could not see the cost of the code it guards, nor the car load in the prediction.

The generator now gives roughly half the scenarios 1-2 cars with 4-20 charging slots. The car block is drawn from a separate RNG stream seeded off the scenario seed, so re-generating reproduces every pre-existing parameter and data profile bit-identically and older baselines stay comparable — verified. Scenarios written before this carry no cars entry and still run car-less.

Turning cars on immediately cost +21.7% (44.08s → 53.66s), with individual scenarios up to 2.5x slower.

1. hit_car_window — the big one

It computed dp2(window["kwh"]) for every slot before the intersection test that discards nearly all of them. On a car scenario that made it the most expensive function in planning: 5.04s cumulative of an 11s plan, driving 15.9M dp2 and 16.1M round calls, while the C++ kernel took 1.4s.

Testing the intersection first and only rounding a slot that actually overlaps is 6.45µs → 1.08µs per call at 48 slots. dp2 is pure, so moving it last cannot change the answer.

It is still asked the same question about the same few windows ~1.2M times per plan — 99.8% of them from optimise_charge_limit_price_threads — so that caller now keeps a (start, end) -> hit dict for the length of the pass and hands it in, the same shape as the hit_charge_cache already beside it. Caller-owned rather than held on self, so the lifetime belongs to whoever knows when car_charging_slots can change.

suite
with cars, unoptimised 53.66s
intersection before dp2 43.50s (−18.9%)
+ the cache 42.23s (−2.9%)

2. Levels pass — a selection rebuilt for things it doesn't depend on

The price-threshold scan in optimise_charge_limit_price_threads sits four loops deep, but which charge windows the threshold selects depends only on loop_price, max_charge_slots and try_charge_freeze — not on either export loop it is nested inside. It was rebuilt identically once per (max_export_slots, try_export_freeze) pair: ~52M iterations on the heaviest scenario, the hottest loop in planning.

Memoised on (max_charge_slots, try_charge_freeze) rather than hoisted, so the iteration order — and with it every tie-break in the search below — is untouched. Safe to share the cached objects because neither is mutated after it is built: all_n is copied into pred_item and charge_mods is only ever read.

Also folds the freeze skip into the selection condition, dropping a bare pass that was executing 19M times. 49.45s → 43.69s (−11.3%), and the function's self-time halved.

3. Kernel marshalling

double_array was handed [float(limit) for limit in limits] on every simulation; array.array already coerces to double in C, so both the float() and the intermediate list were wasted. 51.01s → 49.45s (−3.0%).

Two things deliberately not changed, having measured them and found them slower — both noted in comments so the next person doesn't repeat the experiment:

  • window fields keep their list comprehensions: map(operator.itemgetter("start"), windows) is 0.88µs → 1.05µs at these list lengths
  • predict_soc keeps its indexed loop: dict(zip(range(...), soc_out)) is 13.95µs → 18.69µs, because iterating a ctypes array boxes each double through the sequence protocol

line_profiler had suggested these were ~29% and ~13% of the wrapper; its per-line overhead badly inflates lines executed 73k times, and a microbenchmark told a different story. The first version of this commit measured +0.5% — no better than noise — which is what caught it.

Also here

  • _save_results writes a trailing newline. json.dump leaves the file without one, so the end-of-file hook rewrote the checked-in baseline every time it was regenerated — the cause of the stray pre-commit.ci fixup commit on the previous branch.
  • The checked-in random_results.json baseline is rebased onto the car scenarios; the old one described a car-less workload and would have shown a large spurious diff on all 11 car scenarios.
  • Test registry reordering and a single-variant optimise_windows_kernel (springfall2008), with predbat_debug_pre_saving1's golden regenerated to match — the debug cases are sensitive to which tests precede them, as test_single_debug.py documents.

Testing

Full ./run_all and ./run_all --quick both pass; pre-commit clean. kernel_parity and model_kernel pass at each step.

🤖 Generated with Claude Code

springfall2008 and others added 8 commits August 15, 2026 13:10
…ning time)

double_array was handed [float(limit) for limit in limits] on every simulation. array.array already
coerces each item to a double in C, so both the float() call and the intermediate list were wasted
work: 0.89us -> 0.53us per call, and it stops allocating a throwaway float object per limit.

Measured on the 20-scenario random benchmark: 51.01s -> 49.45s and 49.47s over two runs, with every
scenario byte-identical on metric, cost, cost_pv10, cost_pv90, soc_min, soc_final, battery_cycles
and export_kwh. kernel_parity and model_kernel pass.

Two things deliberately NOT changed, having measured them and found them slower:

  - the window fields keep their list comprehensions. map(operator.itemgetter("start"), windows) is
    0.88us -> 1.05us at these list lengths, the per-item call overhead outweighing the comprehension.
  - predict_soc keeps its indexed loop. dict(zip(range(...), soc_out)) is 13.95us -> 18.69us,
    because iterating a ctypes array boxes each double through the sequence protocol.

Both are noted in comments so the next person does not repeat the experiment. line_profiler had
suggested these lines were ~29% and ~13% of the wrapper; its per-line overhead badly inflates
lines executed 73k times, and a microbenchmark tells a different story.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nning time)

The price-threshold scan in optimise_charge_limit_price_threads sits four loops deep:

    for max_charge_slots / for max_export_slots / for try_charge_freeze / for try_export_freeze

but which charge windows the threshold selects depends only on loop_price, max_charge_slots and
try_charge_freeze - not on either export loop it is nested inside. It was therefore rebuilt
identically once per (max_export_slots, try_export_freeze) pair. Line profiling put it at ~52M
iterations on the heaviest benchmark scenario, the single hottest loop in planning.

Memoised on (max_charge_slots, try_charge_freeze) rather than hoisted, so the iteration order - and
with it every tie-break in the search below - is untouched. Sharing the cached objects between
iterations is safe because neither is mutated after it is built: all_n is copied into pred_item and
charge_mods is only ever read.

The freeze skip also folds into the selection condition, dropping a bare `pass` branch that was
executing 19M times. The cheap freeze test still comes first so the dict lookup is still
short-circuited for the ~37% of iterations that take it.

Measured on the 20-scenario random benchmark: 49.45s -> 44.08s and 43.69s over two runs, every
scenario byte-identical on metric, cost, cost_pv10, cost_pv90, soc_min, soc_final, battery_cycles,
export_kwh and import_kwh_battery.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
hit_car_window computed dp2(window["kwh"]) for every car charging slot before the intersection test
that discards nearly all of them. For anyone with an EV that made it the most expensive function in
planning: on a car scenario it was 5.04s cumulative of an 11s plan, driving 15.9M dp2 and 16.1M round
calls, while the C++ kernel took 1.4s.

Testing the intersection first and only rounding a slot that actually overlaps is 6.45us -> 1.08us
per call at 48 slots. dp2 is pure, so moving it last cannot change the answer.

It is still asked the same question about the same few windows ~1.2M times per plan, 99.8% of them
from optimise_charge_limit_price_threads, so that caller now keeps a (start, end) -> hit dict for the
length of the pass and hands it in - the same shape as the hit_charge_cache already beside it. The
cache is caller-owned rather than held on self so its lifetime belongs to whoever knows when
car_charging_slots can change.

The random benchmark could not see any of this because it had no cars, so it now generates them:
roughly half the scenarios get 1-2 cars with 4-20 charging slots. The car block is drawn from a
separate RNG stream seeded off the scenario seed, so re-generating reproduces every pre-existing
parameter and data profile bit-identically and old baselines stay comparable - verified. Scenarios
written before this carry no "cars" entry and still run car-less.

apply_scenario_to_predbat now sizes every per-car attribute predbat indexes by car_n, not just the
ones a scenario varies: set_rate_thresholds takes max(car_charging_plan_max_price[:num_cars]) and
raises on an empty slice.

Measured on the 20-scenario benchmark with cars: 53.66s -> 43.50s for the reorder, -> 42.23s with the
cache, every scenario byte-identical on metric, cost, cost_pv10, cost_pv90, soc_min, soc_final,
battery_cycles, export_kwh and import_kwh_battery at each step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…cenarios

random_scenarios.yaml now generates cars for roughly half the scenarios, so the stored baseline -
produced against the car-less set - no longer describes the same workload. Comparing a future run
against it would have shown a large spurious diff on all 11 car scenarios.

Regenerated against the same template the previous baseline used (cases/predbat_debug_agile1.yaml),
with the hit_car_window work in place. 20 scenarios, none failed, all carrying cost_pv10/cost_pv90.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
json.dump leaves the file without one, so the end-of-file hook rewrote the checked-in baseline every
time it was regenerated - which is what produced the stray pre-commit.ci fixup commit on the earlier
branch. Emit it at the writer so the file lands clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 15, 2026 12:33

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves Predbat’s planning performance while keeping prediction parity, and updates the random benchmark so it exercises EV/car-planning code paths (which were previously skipped in all-random scenarios).

Changes:

  • Add EV “cars” data generation + baseline updates for the random benchmark suite (YAML scenarios + JSON results), plus a trailing newline on saved benchmark outputs to avoid EOF hook churn.
  • Speed up planning by optimizing hit_car_window (intersection-first + optional caller cache) and memoising charge-window selection inside optimise_charge_limit_price_threads.
  • Reduce kernel wrapper overhead by allowing double_array/int32_array to accept iterables and removing unnecessary list/float() materialisation in kernel marshalling.

Reviewed changes

Copilot reviewed 5 out of 8 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
coverage/cases/random_scenarios.yaml Adds cars blocks to scenarios so benchmarks cover EV charging interactions.
coverage/cases/random_results.json Updates benchmark baseline results/timestamps/runtimes for the new scenario mix.
coverage/cases/predbat_debug_pre_saving1.yaml.expected.json Regenerates a sensitive golden output to match the new ordering/behaviour.
apps/predbat/unit_test.py Reorders/retags certain tests in the registry (affects --quick skipping).
apps/predbat/tests/test_random_scenarios.py Adds car scenario generation/application and ensures benchmark outputs end with newline.
apps/predbat/tests/test_optimise_all_windows.py Changes kernel test behaviour to run either kernel or Python depending on availability.
apps/predbat/prediction_kernel.py Makes array marshalling accept iterables and removes avoidable allocations/conversions.
apps/predbat/plan.py Planning hot-loop optimisations: hit_car_window caching + selection memoisation + reduced repeated logging.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread apps/predbat/tests/test_optimise_all_windows.py
Comment thread apps/predbat/tests/test_random_scenarios.py
Comment thread apps/predbat/unit_test.py
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@springfall2008
springfall2008 merged commit 9ae81c1 into main Aug 15, 2026
2 checks passed
@springfall2008
springfall2008 deleted the perf/levels-selection-memo branch August 15, 2026 12:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants