Repository navigation
perf(plan): stop rebuilding the kernel context 28 times per cycle - #42
Merged
Merged
Conversation
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>
Owner
Author
|
Additional validation of the The correctness run in the description used a scenario with no cars, iBoost, gas or carbon — which meant Re-ran the same 28-cell uncached-vs-cached comparison with Still bit-identical, and the saving is larger than the plain scenario's 4.9× because there are more invariant arrays to skip rebuilding. |
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.
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, andPrediction.__init__builds the entire C++ kernel context: all 17 arrays, a Python loop over every step, twofind_battery_temperature_capcalls per step. Profiled per cell:create_kernel_contextdeepcopyof the load forecastThirteen 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-ininvariant_cache, andmarginal.pyshares 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 fromminutes_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_tiebreakandplan_preclipall pass.Context
Follows #39 (kernel
round_pyrewrite, ~4× on full plan recomputes) and #41 (CI coverage for it). This one targets what was left: light cycles, which #39 could not touch becausekernel_supported()excludessave=runs.🤖 Generated with Claude Code