Repository navigation
perf(plan): 21% faster planning, and cars in the random benchmark - #4529
Merged
Merged
Conversation
…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>
Contributor
There was a problem hiding this comment.
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 insideoptimise_charge_limit_price_threads. - Reduce kernel wrapper overhead by allowing
double_array/int32_arrayto 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.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_kwhandimport_kwh_battery.The benchmark was blind to EV users
hit_car_windowreturns on its first line whennum_carsis 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
carsentry 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 oneIt 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.9Mdp2and 16.1Mroundcalls, 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.
dp2is 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) -> hitdict for the length of the pass and hands it in, the same shape as thehit_charge_cachealready beside it. Caller-owned rather than held onself, so the lifetime belongs to whoever knows whencar_charging_slotscan change.dp22. Levels pass — a selection rebuilt for things it doesn't depend on
The price-threshold scan in
optimise_charge_limit_price_threadssits four loops deep, but which charge windows the threshold selects depends only onloop_price,max_charge_slotsandtry_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_nis copied intopred_itemandcharge_modsis only ever read.Also folds the freeze skip into the selection condition, dropping a bare
passthat was executing 19M times. 49.45s → 43.69s (−11.3%), and the function's self-time halved.3. Kernel marshalling
double_arraywas handed[float(limit) for limit in limits]on every simulation;array.arrayalready coerces to double in C, so both thefloat()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:
map(operator.itemgetter("start"), windows)is 0.88µs → 1.05µs at these list lengthspredict_sockeeps 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 protocolline_profilerhad 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_resultswrites a trailing newline.json.dumpleaves the file without one, so the end-of-file hook rewrote the checked-in baseline every time it was regenerated — the cause of the straypre-commit.cifixup commit on the previous branch.random_results.jsonbaseline 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.optimise_windows_kernel(springfall2008), withpredbat_debug_pre_saving1's golden regenerated to match — the debug cases are sensitive to which tests precede them, astest_single_debug.pydocuments.Testing
Full
./run_alland./run_all --quickboth pass; pre-commit clean.kernel_parityandmodel_kernelpass at each step.🤖 Generated with Claude Code