Skip to content

perf(plan): stop rebuilding the kernel context 28 times per cycle - #42

Merged
dandwhelan merged 1 commit into
mainfrom
perf/light-run-marginal
Aug 15, 2026
Merged

dandwhelan merged 1 commit into
mainfrom
perf/light-run-marginal

Conversation

@dandwhelan

Copy link
Copy Markdown
Owner

Summary

calculate_marginal_costs() runs 4 kWh levels × 7 time offsets = 28 what-if simulations, on every 5-minute cycle. Measured on a Pi 5 that is ~155ms — about a fifth of a cycle that does no recompute at all.

Two separate problems.

1. The context rebuild cost 13× the prediction it served

Each cell builds a fresh Prediction, and Prediction.__init__ builds the entire C++ kernel context: all 17 arrays, a Python loop over every step, two find_battery_temperature_cap calls per step. Profiled per cell:

cost
create_kernel_context 1.07 ms
deepcopy of the load forecast 0.15 ms
the prediction itself 0.08 ms

Thirteen times the cost of the work it was setting up. Every input except the load forecast is identical across the 28 cells — same plan, same rates, same PV, same battery temperature and curves.

create_kernel_context() now takes an opt-in invariant_cache, and marginal.py shares one across the batch, rebuilding only the three load arrays. The conditions this relies on are documented on the function: a stale cache would silently describe different conditions, and the parity suite could not catch that, because the Python engine would be handed the same wrong data. Nothing else in the tree passes a cache, so every other call site is unchanged.

(I also checked the deepcopy, which looks like the obvious culprit — it is only ~4ms of the 155ms. Not worth touching.)

2. It did not need recomputing every cycle

The matrix is derived from charge_limit_best/export_window_best, so between recomputes it re-derives the same plan and lands on the same numbers. It is display-only — an HA sensor plus the gateway API — and nothing steers off it. It now runs with the plan it describes.

publish_marginal_costs() still runs every cycle, so the live cells on the sensor (grid_import_now/grid_export_now, read from minutes_now) stay current instead of freezing between recomputes.

Measured

28 cells at 288 steps: 35.5ms → 7.3ms (4.9×), with all 28 results bit-identical to the uncached path.

Combined with the gating, a no-recompute cycle loses the whole ~155ms, and a recompute keeps a much cheaper version.

Verified

marginal_costs, kernel_parity (450 randomised configs), model_kernel, optimise_windows_kernel, plan_tiebreak and plan_preclip all pass.

Context

Follows #39 (kernel round_py rewrite, ~4× on full plan recomputes) and #41 (CI coverage for it). This one targets what was left: light cycles, which #39 could not touch because kernel_supported() excludes save= runs.

🤖 Generated with Claude Code

calculate_marginal_costs() runs 4 kWh levels x 7 time offsets = 28 what-if
simulations, and it ran on every 5-minute cycle. Measured on a Pi 5 it was
~155ms, about a fifth of a cycle that does no recompute at all.

Two things were wrong with it.

Each cell built a fresh Prediction, and Prediction.__init__ builds the whole
C++ kernel context - all 17 arrays, a Python loop over every step, two
find_battery_temperature_cap calls per step. That measured 1.07ms against
0.08ms for the prediction it exists to serve: thirteen times the cost of the
work it was setting up. Every input except the load forecast is identical
across the 28 cells, so create_kernel_context() now takes an opt-in
invariant_cache and marginal.py shares one across the batch, rebuilding only
the three load arrays. The conditions this relies on are documented on the
function - a stale cache would silently describe different conditions, and the
parity suite could not catch it because the Python engine would be handed the
same wrong data.

The matrix also does not need recomputing every cycle. It is derived from
charge_limit_best/export_window_best, so between recomputes it re-derives the
same plan and lands on the same numbers; it is display-only (an HA sensor plus
the gateway API), nothing steers off it. It now runs with the plan it
describes. publish_marginal_costs() still runs every cycle so the live cells on
the sensor - grid_import_now/grid_export_now, read from minutes_now - stay
current rather than freezing between recomputes.

Measured, 28 cells at 288 steps: 35.5ms -> 7.3ms, 4.9x, with all 28 results
bit-identical to the uncached path. Combined with the gating, a no-recompute
cycle loses the whole ~155ms and a recompute keeps a much cheaper version.

Verified: marginal_costs, kernel_parity (450 randomised configs), model_kernel,
optimise_windows_kernel, plan_tiebreak and plan_preclip all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dandwhelan

Copy link
Copy Markdown
Owner Author

Additional validation of the invariant_cache, since it is the riskiest part of this change.

The correctness run in the description used a scenario with no cars, iBoost, gas or carbon — which meant car_load_flat, car_rate_flat, gas_rate, iboost_plan_load and carbon were all empty, so the cache was never actually exercised on those fields.

Re-ran the same 28-cell uncached-vs-cached comparison with charge_car=1, car_charging_from_battery, iboost_solar, iboost_gas, rate_gas=5.0 and carbon_enable:

cells=28 mismatches=0  IDENTICAL
28-cell matrix: uncached 46.9 ms   cached 8.0 ms   (5.8x)

Still bit-identical, and the saving is larger than the plain scenario's 4.9× because there are more invariant arrays to skip rebuilding.

@dandwhelan
dandwhelan merged commit dfe2ade into main Aug 15, 2026
2 checks passed
@dandwhelan
dandwhelan deleted the perf/light-run-marginal branch August 15, 2026 13:47
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.

1 participant