Skip to content

perf(kernel): persistent worker pool instead of threads per batch (6% faster) - #4546

Merged
springfall2008 merged 9 commits into
perf/kernel-batch-abifrom
perf/kernel-thread-pool
Aug 16, 2026
Merged

springfall2008 merged 9 commits into
perf/kernel-batch-abifrom
perf/kernel-thread-pool

Conversation

@springfall2008

@springfall2008 springfall2008 commented Aug 16, 2026 •

Copy link
Copy Markdown
Owner

Stacked on #4540 — base is perf/kernel-batch-abi, so this diff shows only the pool work. Retarget to main once #4540 merges.

What

pk_run_batch built and joined a fresh std::vector<std::thread> on every call. It now dispatches to a pool whose workers park on a condition variable for the life of the process.

Why

#4540 made planning 35% faster but enabling kernel threading on top bought only 1.6%. Instrumenting pk_run_batch over one benchmark plan showed why:

serial threaded (16)
calls / jobs 25,091 / 253,899 same
std::threads created 0 201,545
wall time in pk_run_batch 3131 ms 2409 ms
— of which thread creation — 1507 ms

The parallel work compresses well — 3131ms of simulation becomes 902ms across ~8 lanes — but 1507ms of thread creation ate almost all of it. The median batch is 6 jobs, so each call was paying ~8 thread creations at 7.5µs to cover ~180µs of work.

Result

20-scenario benchmark, interleaved, best of 3, all bit-identical:

threads normal with each job 8× dearer
0 (serial) 26.33s 48.92s
4 24.89s 32.03s
6 24.71s 29.98s
8 24.91s 29.85s
16 25.04s 28.94s

6.1% on top of #4540, and more on hardware where the kernel dominates.

End to end, against current main

With #4540 underneath it, interleaved best of 3, each side on its own Python and kernel, all 20 scenarios identical:

20-scenario suite main both PRs
serial (threads: 0) 41.70s 26.26s 37.0% faster
default (threads: auto) 39.53s 24.82s 37.2% faster

This PR is the second 5.5% of that: 26.26s serial to 24.82s at auto on the same tree. For comparison main's process pool bought it 41.70s to 39.53s, 5.2% — so the two parallelism mechanisms are worth about the same, and the pool is the cheaper of them, with no fork, no pickling and no PRED_GLOBAL.

Two things measurement changed

Broadcasting was the wrong wake strategy. Built with a single notify_all, the pool was worth 1.3% — a six-job batch woke all fifteen workers and eleven went straight back to sleep, ~250,000 wasted wakeups per plan. Each worker now has its own flag and condvar so only the needed lanes wake: 1.3% → 3.7% on that change alone. The caller takes lane 0 itself, so a six-job batch wakes five.

A threads: auto cap was measured and rejected. The fast-machine curve peaks below the core count, which argues for capping. But with the kernel made 8× dearer the curve stops turning over entirely, and capping at 4 would cost 10.7% there against 1.3% for no cap at all. auto stays at the core count; resolve_batch_threads carries the numbers.

Also included: one scratch per thread rather than one per call. That is a no-op on its own (26.291s vs 26.344s, inside noise — the allocator never leaves its thread cache) and is kept only because the parked workers need it.

Risk

A live thread pool inside a ctypes-loaded library. Handled explicitly:

  • fork — threads don't survive it, so a child would inherit a pool whose workers don't exist and hang. A pthread_atfork child handler drops the pool; the next batch builds a fresh one.
  • concurrent callers — ctypes releases the GIL, so two Python threads could dispatch at once. A dispatch mutex serialises them.
  • thread creation failure — caught; the batch clamps to the lanes that exist and falls back to inline.
  • shutdown — the pool is deliberately never destroyed, so no static destructor runs while CPython tears down.

No ABI or parity bump — results are unchanged, so an older binary still loads and runs correctly, just without the speedup. All six architecture binaries rebuilt.

Testing

Full suite green (211 tests), 20 scenarios byte-identical, pre-commit clean. New tests cover worker reuse, lane allocation across 7 thread/job combinations, cross-context scratch reuse, concurrent dispatch, and a batch inside a forked child.

Every new test was mutation-checked, and two were not good enough first time:

  • Removing the pthread_atfork handler makes the forked child deadlock; the test kills it on a deadline and reports the timeout rather than hanging the suite.
  • The concurrency test initially passed with the dispatch mutex deleted — at 25 rounds over 4 lanes the GIL keeps the callers from overlapping often enough. At 400 rounds over 8 lanes it deadlocks within seconds. The round count is load-bearing and the test says so.

🤖 Generated with Claude Code

springfall2008 and others added 3 commits August 16, 2026 08:13
Batching made planning 35% faster but threading on top of it bought 1.6%. A census showed why: 25,091
batch calls with a median of 6 jobs, against which a fresh thread pool and a fresh 70KB scratch are
built per call. Staged so the low-risk half (per-thread scratch, which also helps the serial default)
is measured before the concurrency half.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PARKED - measured as a no-op, do not merge on its own.

Replaces the three per-call PkScratch constructions with a thread_local accessor, so a thread grows
its ~70KB of buffers once rather than once per pk_run_batch call (~25,000 of them per plan).

Measured on the serial benchmark, interleaved, best of 3: 26.291s before, 26.344s after - no
difference against ~1.8% run-to-run spread. The hypothesis was wrong: the kernel's comment about the
allocator being the bottleneck was recorded under four threads contending, and one thread repeatedly
allocating and freeing the same-sized block never leaves the allocator's thread cache.

Kept on this branch because the persistent-worker design needs per-worker scratch anyway, and the two
tests are worth having either way: one pins that the buffers stop being rebuilt per call (it fails
before this change), the other that a reused scratch cannot leak between contexts of different
n_steps (verified by mutation - relaxing the assign to skip shrinking makes it fail).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pk_run_batch built and joined a fresh std::vector<std::thread> on every call. Measured over one
benchmark plan that was 201,545 thread creations costing 1.5s against 3.1s of simulation, which is
why threading was worth only 1.6% - the creation was cancelling what the parallelism won.

Workers now park on a condition variable for the life of the process, and each has its own flag and
condvar so a batch wakes exactly the lanes it needs. Broadcasting instead cost most of the win: a
six-job batch woke all fifteen workers, ~250,000 wasted wakeups per plan, and measured 1.3% against
3.7% for targeted wakes. The calling thread takes lane 0 itself, so a six-job batch wakes five.

Also gives each thread one scratch instead of one per call. That is a no-op on its own (26.291s vs
26.344s, inside noise - the allocator never left its thread cache) but the parked workers need it.

20-scenario benchmark, best of 3: 26.33s serial, 24.71s at 6 threads - 6.1%, on top of the batching
work. All 20 scenarios byte-identical at every thread count, in both the normal regime and one with
each job made eight times dearer.

threads: auto stays at the core count. A cap looked right on a fast machine, where the curve peaks
below it, but in the kernel-heavy regime the curve stops turning over - capping at 4 would cost 10.7%
there against 1.3% for no cap at all. resolve_batch_threads carries the numbers.

No ABI or parity bump: results are unchanged, so an older binary still loads and runs correctly.
All six shipped binaries rebuilt.

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

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

Introduces a persistent worker pool inside the C++ prediction kernel so pk_run_batch no longer pays std::thread creation/join overhead per batch call, improving threaded-batch performance while keeping results bit-identical.

Changes:

  • Replace per-call thread creation in pk_run_batch with a process-lifetime PkThreadPool and per-thread scratch reuse.
  • Add fork/concurrent-caller safety handling (atfork handler + dispatch mutex) and expose kernel counters for test verification.
  • Add/extend tests to validate worker reuse, lane allocation, cross-context scratch reuse, concurrent dispatch, and fork behavior; factor thread-count resolution into resolve_batch_threads.

Reviewed changes

Copilot reviewed 6 out of 12 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
docs/superpowers/specs/2026-08-16-kernel-thread-pool-design.md Design/spec documenting pool + scratch reuse rationale, risks, and measurement results.
apps/predbat/prediction_kernel.cpp Implements thread-local scratch, persistent worker pool, atfork handling, dispatch serialization, and test hooks.
apps/predbat/plan.py Adds resolve_batch_threads() and routes threads setting through it.
apps/predbat/tests/test_kernel_parity.py Adds kernel pool/scratch/concurrency/fork regression tests.
apps/predbat/tests/test_prediction_batch.py Adds unit test for threads → kernel lane resolution behavior.
.cspell/custom-dictionary-workspace.txt Adds new low-level terms used in code/tests (e.g., atfork/pthread/waitpid).
Suppressed comments (1)

apps/predbat/prediction_kernel.cpp:1486

  • The pthread_atfork handler functions are internal callbacks, but they’re currently defined with external linkage, which can unnecessarily expand the library’s exported symbol surface. Mark them static (or move them into an anonymous namespace) so they remain private implementation details.
void pk_atfork_prepare()
{
    g_dispatch_mutex.lock();
}


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

Comment on lines +1470 to +1475
std::atomic<PkThreadPool *> g_pool{nullptr};
std::mutex g_pool_create_mutex;
// Held for a whole batch. Python can enter pk_run_batch from two threads at once because ctypes
// releases the GIL, and one shared pool cannot serve two batches concurrently - its published job
// state would race. Nothing in predbat does this today; serialising is the safe answer if it ever does.
std::mutex g_dispatch_mutex;
springfall2008 and others added 6 commits August 16, 2026 09:57
The batch and pool tests call run_prediction_kernel_batch, which returns None when the loaded binary
has no pk_run_batch. Four of them treated that as a failure rather than a skip, so running against an
older binary reported errors instead of degrading the way the product does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	apps/predbat/prediction_kernel.cpp
#	apps/predbat/prediction_kernel_lib_aarch64.so
#	apps/predbat/prediction_kernel_lib_armv7l.so
#	apps/predbat/prediction_kernel_lib_darwin_arm64.so
#	apps/predbat/prediction_kernel_lib_darwin_x86_64.so
#	apps/predbat/prediction_kernel_lib_i686.so
#	apps/predbat/prediction_kernel_lib_x86_64.so
…/kernel-thread-pool

# Conflicts:
#	apps/predbat/prediction_kernel_lib_aarch64.so
#	apps/predbat/prediction_kernel_lib_armv7l.so
#	apps/predbat/prediction_kernel_lib_darwin_arm64.so
#	apps/predbat/prediction_kernel_lib_darwin_x86_64.so
#	apps/predbat/prediction_kernel_lib_i686.so
#	apps/predbat/prediction_kernel_lib_x86_64.so
The pool catches std::system_error when the system refuses a thread. libc++ pulls that declaration in
through <thread>, libstdc++ does not, so the file built on macOS and failed on GCC with a parse error
at the catch clause. Caught by CI, which builds natively with g++; neither the local clang build nor
the zig cross-build sees it, since both use libc++.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@springfall2008
springfall2008 merged commit be9c495 into perf/kernel-batch-abi Aug 16, 2026
2 checks passed
@springfall2008
springfall2008 deleted the perf/kernel-thread-pool branch August 16, 2026 15:31
springfall2008 added a commit that referenced this pull request Aug 16, 2026
…lanning) (#4540)

* perf(kernel): skip the discarded SoC series, and add a batch entry point

Two changes to the kernel, one that pays off immediately and one that lays the
groundwork for running a fan-out of scenarios in parallel.

1. Do not compute the per-minute SoC series when nobody reads it (-13%)

run_prediction_kernel allocated an n_steps buffer for every scenario and told the
kernel to fill it, but a cached run - which is 19,209 of the 25,727 predictions in
a plan - discards it immediately (see the `if not cache` block). Filling it cost a
round_py per step, and round_py is snprintf+strtod at ~96ns, by far the most
expensive thing in the hot loop. soc is never itself rounded, the result only ever
fed soc_out, so skipping it cannot change the simulation.

  52.8us -> 28.5us per scenario (1.85x), and the 20 scenario benchmark
  42.5s -> 36.9s (-13.1%), byte-identical on all 20 and kernel_parity green.

2. pk_run_batch, so a fan-out can be run in one call

Adds PkBatchJob/PkBatchResult and pk_run_batch(handle, jobs, n, results,
n_threads), sharing pk_run_one with pk_run so there is a single copy of the
simulation. Additive: pk_run keeps its signature and PkScenario keeps its layout,
and Python probes for the batch symbol (KERNEL_HAS_BATCH), so a binary built
before this still loads and runs every scenario the old way.

Batched jobs pass soc_out = null. The one thing a caller still wants from the
series is the SoC range across a charge window, so the kernel tracks that inline
over an optional step range instead, mirroring the predict_soc scan in
thread_run_prediction_charge_min_max - materialising a buffer per job would have
cost ~84MB on a large batch.

Verified bit-identical against a loop of pk_run over 120 scenarios, every field.

Threading is present but NOT yet usable, and n_threads should stay 1: the
remaining round_py(soc, 1) at the charge-rate curve lookup is load bearing, and
snprintf/strtod serialise on the C library's global locale. With constant work per
thread it degrades 17x at 8 threads, which is why 4 threads currently measures
0.6x of serial. A per-thread locale (newlocale + uselocale) takes that from 17.3x
to 2.2x in isolation and is the next step before the batch path is wired up in
Python.

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

* perf(kernel): replace the hot-loop round_py with precomputed SoC percent buckets

round_py is snprintf+strtod, ~96ns, and was the most expensive thing in the
simulation loop. It survives in the loop for one reason: prediction.py rounds SoC
to 1dp before the charge/discharge rate curve lookup.

That rounding is not a modelling decision. It exists so utils.get_charge_rate_curve_cached's
lru_cache key quantises and the cache hits - a Python memoisation device. The
kernel has no such cache and gains nothing from it, but rounding before the
percent is taken occasionally moves the resulting integer, so it is observable and
parity forced the kernel to reproduce it.

Everything downstream only wants an integer 0-100: round(soc,1) -> calc_percent_limit
-> curve[percent]. That map is monotone onto 101 buckets, so the boundaries can be
found once and the loop reduced to a binary search over them. The boundaries are
located by bisecting the double's bit pattern (non-negative doubles order the same
as their bits read as integers), so each one is the true boundary to the last bit
rather than an approximation.

This is exact, not approximate: pk_verify_soc_percent_table sweeps 2,000,000 SoC
values plus five probes around all 101 boundaries - where a disagreement would
actually hide - for ten battery sizes from 2 to 100 kWh. Zero disagreements.

Per scenario, on a 1200 scenario batch:

  52.8us  original
  28.5us  after skipping the discarded SoC series
  10.9us  now                                        4.8x on serial

and it unblocks parallelism, because the contention was libc's global locale
inside snprintf/strtod, not the simulation:

   1 thread   13.0 ms
   2 threads   6.7 ms  1.9x
   4 threads   3.5 ms  3.8x
   8 threads   2.4 ms  5.4x
  16 threads   1.9 ms  6.7x     bit-identical at every thread count

The 20 scenario benchmark is 36.9s -> 31.6s even before the Python side batches
anything, byte-identical on all 20, and kernel_parity passes. No ABI change: the
bucket table lives on ContextStore, which never crosses the Python boundary, so
PkContext keeps its layout.

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

* test(kernel): pin the SoC percent table, and rebuild the shipped binaries

Adds run_soc_percent_table_tests to the kernel parity suite. It drives the
kernel's own pk_verify_soc_percent_table over eleven battery sizes from 0.5 to
100 kWh, sweeping the SoC range and - the part that matters - probing either side
of all 101 bucket boundaries, since a boundary is the only place the table could
disagree with the round_py path it replaced.

Checked that the test actually bites: nudging a single boundary by one ULP makes
it fail on five of the eleven capacities, so it detects the smallest corruption
the table can have rather than only catching gross breakage.

Also rebuilds all six shipped binaries, which were built before the batch entry
point and the bucket table existed. Verified the new symbols are present in every
one (dynamic symbols for the four stripped Linux ELFs, nm for the two Mach-O), and
loaded the cross-built darwin_arm64 directly to confirm it reports the batch entry
point and agrees with round_py on the table. Without this a non-macOS install
would silently fall back to the Python engine.

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

* fix(kernel): do not clamp the batch SoC range when no range was asked for

pk_run_one clamped soc_range_min/max whenever the out pointers were non-null, but
the batch path always passes them. A job that asks for no range (all_n, where
Python skips the scan) would therefore have come back with (soc_max, soc_max)
where thread_run_prediction_charge_min_max returns (soc_max, 0) - Python's
clamping lines sit inside the `if not all_n` branch, so they never run for that
case either.

Latent rather than live: nothing calls pk_run_batch from Python yet. Fixed before
wiring it up, since it would have surfaced as a plan difference confined to
all_n charge windows and been thoroughly unpleasant to track down.

pk_run is unaffected - it passes nullptr for both.

Full suite green including the slow tests (debug_cases, annual_integration,
load_ml), 101.22s.

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

* docs: hand-off notes for the Python batching refactor

Working notes so Phase B can be picked up cold: what the kernel work already
landed and what it measured, why Python is now 78% of plan time, the lazy-batch
design, the traps that cost time here (the export window mutation, round_py's
locale contention, test-fixture isolation), the verification protocol, and the
three approaches already measured and rejected so they are not tried again.

Delete before this branch merges.

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

* docs: plan for batching the prediction fan-out from Python

Working plan for Phase B of the kernel batch work: launch_run_prediction_* queues jobs that a single
pk_run_batch call runs per fan-out, which is both where the remaining Python overhead goes and the
only shape that lets the kernel use threads.

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

* fix(prediction): build the export trial window privately instead of mutating the caller's

The trial start was written straight into the caller's window dict, which was only safe because each
pool worker mutated its own unpickled copy. A batched fan-out shares one window list across every job
in the batch, so this has to be local. Ported from perf/threadpool-prototype.

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

* chore: green the quality gate on the batching branch

The hand-off notes landed without passing cspell or markdownlint, so pre-commit has been red on this
branch since. Adds the five technical words the notes use and tags their two code fences.

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

* feat(kernel): marshal a whole fan-out into one pk_run_batch call

Adds BatchJob, run_prediction_kernel_batch and the shared result assembly, and gives pk_run_batch's
ctypes binding the n_threads argument it was missing. Window start/end arrays are marshalled once per
distinct window list rather than once per job, which is where the batching win comes from.

Also adds Prediction.scan_soc_range and routes thread_run_prediction_charge_min_max through it so the
scan logic is not duplicated ahead of the batch runner landing in a later task.

Bit-identical to a loop of pk_run across 60 scenarios at 1, 2, 4 and 8 threads.

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

* test(kernel): pin the pk_run_batch argtypes and the window memo's cache key

Closes two review findings on the batch entry point: the argtypes fix had no regression test (ctypes
silently accepts extra call arguments, and a garbage n_threads only changes work splitting, not
results, so the bug was invisible to the batch parity sweep), and the window-array memo was only ever
exercised with one shared list per side, never with genuinely distinct lists in the same batch.

Adds a direct argtypes assertion to run_marshalling_tests, and a new run_batch_window_variant_tests
that cycles jobs across several distinct charge/export window lists and checks the batch against the
same per-scenario pk_run reference. Extracted build_batch_reference and check_batch_results out of
run_batch_parity_tests so the new test reuses them instead of duplicating the comparison, which also
brings run_batch_parity_tests back under the repo's flake8 complexity limit.

Also rewords a comment on the buffers keepalive list that overstated why it's needed (ctypes' own
container keepalive already covers it; the list is belt-and-braces, not load-bearing) - comment only,
no code change.

Both new assertions verified to actually catch their target bugs via temporary mutation, reverted
before committing.

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

* refactor(prediction): split trial-input building out of thread_run_prediction_*

Each thread_run_prediction_* built the trial inputs, ran the prediction and shaped the result. The
batch path needs the first and third but not the second, so they are split rather than copied - one
definition of what a trial input is, for both paths.

No-op: 20 random scenarios byte-identical.

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

* perf(plan): batch the prediction fan-out into one kernel call

launch_run_prediction_* no longer runs anything: it prepares the trial inputs, queues a job and
returns a handle whose first get() flushes the whole batch through a single pk_run_batch call. Window
start/end arrays are marshalled once per fan-out rather than once per scenario, cache hits and
intra-batch duplicates are resolved in Python first, and anything the kernel refuses falls back to the
Python engine per job. Serial for now.

20 random scenarios byte-identical.

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

* test(batch): pin the debug kernel gate by mechanism, not by value

The debug_enable case of test_batch_fallbacks asserted a result value, which the two engines agree on
to ~1e-6 with every field rounded to 4dp - so it passed whether or not the kernel_supported() gate in
flush_batch existed, leaving the one guard that keeps debug runs on the Python engine unpinned. It now
spies on run_prediction_kernel_batch and asserts the kernel was never reached, keeping the value
assertion alongside.

Also: make "a min/max job is never cached" structural rather than a property of the single caller that
passes cache=False, since a cache hit on a range job would answer (0.0, 0.0) and silently collapse the
optimiser's SoC pruning envelope; cover a range window that starts before minutes_now (the max(...,0)
clamp and the wholly-past negative end step were never executed); cover a single prediction at the
levels optimiser's coarse step; and fix the garbled module docstring.

20 random scenarios still byte-identical.

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

* perf(plan): run each batched fan-out across kernel threads

args[threads] now sizes the kernel's thread pool rather than a pool of worker processes. The GIL is
released for the whole pk_run_batch call, so unlike a Python ThreadPool these are real cores.

Also corrects a stale comment in annual.py that still described calculate_plan() as spinning up a
multiprocessing Pool; it no longer does, and forcing threads=0 there is now about skipping the C++
kernel's per-call std::thread pool creation on hundreds of tiny per-day plans, not fork safety.

Identical results at 1, 2 and 8 threads; 20 random scenarios byte-identical with threading actually
on (16 threads, this machine's CPU count).

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

* refactor(plan): remove the prediction process pool

Nothing has launched work into it since the fan-out started batching, so PRED_GLOBAL, the four
wrapped_run_prediction_* trampolines, DummyThread and the pool lifecycle all go with it. The pool was
measured at 1.11x on 16 cores, with the parent's per-call pickling as the serial bottleneck.

Also cleans up the code and comments left pointing at the removed pool: cleanup_pool() and its call
sites in predbat.py, the stale "pool" entry in userinterface.py's debug-dump exclude list, and
pool-specific reasoning in comments in hass.py and load_ml_component.py. Two tests that used
DummyThread purely as a generic result-handle double (not to exercise pool behaviour) now use a small
local FakeHandle in test_pv90.py; test_infra.py's simple_scenario() calls
Prediction.thread_run_prediction_single directly instead of the deleted wrapper.

20 random scenarios byte-identical.

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

* fix(prediction): drain a pending batch before a saving run, and pin result routing

Three things the batch was relying on nobody getting wrong.

Nothing pinned flush_batch's job->result mapping. Injecting a reversed mapping
(batched[len(kernel_jobs) - 1 - index]) passed the prediction_batch, kernel_parity
and model_kernel suites in full: every trial in a fan-out would silently take
another trial's cost. The hole was that no test compared values across a multi-job
batch - test_batch_is_lazy only checked results were not None, and the threading
test compared the batch against itself at different thread counts, which cannot see
a thread-count-independent Python defect because n_threads goes straight to C.
That test is now test_batch_results_match_their_own_job and uses an external
oracle: ten distinct try_soc values queued in one batch, each handle compared
against thread_run_prediction_charge for the same SoC with the prediction cache
cleared between the two runs. The SoCs are asserted to give ten different costs
first, so a misrouted result cannot match by coincidence - the previous scenario
saturated and gave nine identical costs, hence the trial spread across every charge
window and the different scenario seed. With the reversed mapping applied, all ten
jobs fail at all three thread counts.

A saving run is the only one that populates predict_soc_best and friends. If a
batch were left pending across it, the next handle read would flush it and
reset_kernel_run_state would blank exactly those attributes - the published plan
and the debug HTML would come back empty (verified: 288 entries -> 0 with the guard
removed) - and a job flushed after its inputs were mutated would be cached under a
key hashed from the old ones. All four fan-outs drain their handles first so
neither is reachable, but run_prediction now drains any pending batch when save is
set. The guard is a single test of a local on the ~250k non-save calls per plan.

pending_batch and batch_threads were initialised only inside __init__'s `if base:`
branch, so a Prediction built without a base raised AttributeError on its first
enqueue. Nothing constructs one today - the wrapped_* trampolines that did went
with the process pool - which is what made it worth closing.

Also replaces two kernel_parity assertions that could not fail: the SoC series
check now requires the single-scenario reference to have materialised the series
the batch omits, and car_charging_soc_next (recorded at minute 0, so identical for
every job in a fan-out) is backed by an assertion that the kernel fills it at all.

The four thread_run_prediction_* docstrings no longer claim to run in a thread;
they are the direct synchronous path the batch falls back to. The four
queue_run_prediction_* docstrings now state that inputs are read at flush time and
must not be mutated before get().

20-scenario random-compare: +0.0000 on metric, cost and all three PV futures.

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

* docs: describe the batch model everywhere the pool used to be

The process pool is gone, but several places still document it as how predbat
works - and one of them is read by AI coding assistants as ground truth.

.github/copilot-instructions.md documented self.pool, wrapped_run_prediction_*,
PRED_GLOBAL and DummyThread as current architecture, with code samples an
assistant would happily regenerate. Replaced with the batch model: queue through
launch_run_prediction_*, first get() flushes the fan-out through one pk_run_batch
call, kernel threads rather than worker processes, plus the no-mutation invariant.

double_array()'s docstring justified its from_buffer view with "each pool worker is
a separate process (multiprocessing with fork), so no buffer is ever shared between
workers". That was the stated reason an unsafe-looking construct was safe, so it
needed a real replacement rather than deletion: the views are filled on the calling
thread before the kernel is entered and only ever read afterwards, so even a buffer
deliberately shared between jobs (the window memo) is only read concurrently, and
the one thread that could change it is blocked inside the call. Same staleness
fixed on the module-level loader state and load_kernel's cache note.

plan.py's module and class docstrings still described multi-threaded prediction
runs; the planner is strictly serial Python now and the threading lives inside
pk_run_batch. The four launch_run_prediction_* docstrings now carry the
no-mutation-before-get() invariant, which was documented only in
prediction_batch.py's module docstring - a file a contributor adding a fifth
fan-out would never open.

The window memo comment called window reuse "the bulk of the batching win", true
for the charge fan-outs but not the export one, where _prepare_export builds a
fresh export_window per job and every one misses the memo. BatchJob's docstring
listed step as a field the kernel reads, when run_prediction_kernel_batch
overrides it with PREDICT_STEP and only the Python fallback reads it. BatchHandle
was described as the drop-in for the pool's AsyncResult. The threads doc said '0'
disables threads, when it is clamped up to one kernel thread.

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

* docs(comments): correct references to batched prediction architecture

Fix two comment-only inaccuracies describing the batched prediction system
that replaced the multiprocessing pool:

1. copilot-instructions.md: replace outdated bullets describing deleted
   wrapped_run_prediction_* and PRED_GLOBAL with description of current
   launch_run_prediction_* batching mechanism.

2. prediction_kernel.py: reword window memoisation comment to accurately
   describe export window sharing, which depends on the all_n parameter.

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

* docs: record what the batching refactor measured

Keeps the numbers with the plan now the hand-off notes are gone: the serial win is real (31.6s ->
26.3s), threading is flat (~0.8%, inside noise), and the census that explains why - 25,091 batch calls
with a median of 6 jobs each, so a fresh std::thread pool is built per call to cover ~180us of work.

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

* docs: replace the inherited baseline with a measured one

The 31.6s in the hand-off notes did not survive re-measurement. Against the fork point, kernel built
from each commit's own source and confirmed active on both sides: 41.74s -> 26.22s serial, 40.02s ->
25.81s at each side's default. Neither side's parallelism is worth much; the win is the batching.

Also records that the old process pool died with KeyError outside the __main__ entry point on any
spawn-default platform, which removing it fixes.

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

* chore: update prediction kernel binaries for all platforms

* fix(kernel): bump the ABI to 4 so a pre-batch binary is rejected, not loaded

a325492 made PkScenario::soc_out optional - Python passes no SoC buffer for a cached run, which is
the common case - but left PK_ABI_VERSION at 3. An ABI 3 binary writes to soc_out unconditionally, so
loading one against this Python is a null write on the first prediction: predbat segfaults instead of
falling back to the Python engine.

Reproduced by building the kernel from main and pointing PREDBAT_KERNEL_SO at it: model_kernel exits
139. With the bump the loader reports 'stale binary (abi 3 revision 5, expected abi 4 revision 5)' and
uses the Python engine, which is what the version check exists to do.

Anyone with a locally built .so from an older checkout, or an install whose per-architecture binary
was not replaced, would have hit this. All six shipped binaries rebuilt at ABI 4.

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

* chore: update prediction kernel binaries for all platforms

* perf(kernel): persistent worker pool instead of threads per batch (6% faster) (#4546)

* docs: design for a persistent scratch and thread pool in the kernel

Batching made planning 35% faster but threading on top of it bought 1.6%. A census showed why: 25,091
batch calls with a median of 6 jobs, against which a fresh thread pool and a fresh 70KB scratch are
built per call. Staged so the low-risk half (per-thread scratch, which also helps the serial default)
is measured before the concurrency half.

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

* perf(kernel): give each thread one scratch instead of one per call

PARKED - measured as a no-op, do not merge on its own.

Replaces the three per-call PkScratch constructions with a thread_local accessor, so a thread grows
its ~70KB of buffers once rather than once per pk_run_batch call (~25,000 of them per plan).

Measured on the serial benchmark, interleaved, best of 3: 26.291s before, 26.344s after - no
difference against ~1.8% run-to-run spread. The hypothesis was wrong: the kernel's comment about the
allocator being the bottleneck was recorded under four threads contending, and one thread repeatedly
allocating and freeing the same-sized block never leaves the allocator's thread cache.

Kept on this branch because the persistent-worker design needs per-worker scratch anyway, and the two
tests are worth having either way: one pins that the buffers stop being rebuilt per call (it fails
before this change), the other that a reused scratch cannot leak between contexts of different
n_steps (verified by mutation - relaxing the assign to skip shrinking makes it fail).

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

* perf(kernel): keep a persistent worker pool instead of threads per batch

pk_run_batch built and joined a fresh std::vector<std::thread> on every call. Measured over one
benchmark plan that was 201,545 thread creations costing 1.5s against 3.1s of simulation, which is
why threading was worth only 1.6% - the creation was cancelling what the parallelism won.

Workers now park on a condition variable for the life of the process, and each has its own flag and
condvar so a batch wakes exactly the lanes it needs. Broadcasting instead cost most of the win: a
six-job batch woke all fifteen workers, ~250,000 wasted wakeups per plan, and measured 1.3% against
3.7% for targeted wakes. The calling thread takes lane 0 itself, so a six-job batch wakes five.

Also gives each thread one scratch instead of one per call. That is a no-op on its own (26.291s vs
26.344s, inside noise - the allocator never left its thread cache) but the parked workers need it.

20-scenario benchmark, best of 3: 26.33s serial, 24.71s at 6 threads - 6.1%, on top of the batching
work. All 20 scenarios byte-identical at every thread count, in both the normal regime and one with
each job made eight times dearer.

threads: auto stays at the core count. A cap looked right on a fast machine, where the curve peaks
below it, but in the kernel-heavy regime the curve stops turning over - capping at 4 would cost 10.7%
there against 1.3% for no cap at all. resolve_batch_threads carries the numbers.

No ABI or parity bump: results are unchanged, so an older binary still loads and runs correctly.
All six shipped binaries rebuilt.

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

* test(kernel): skip the pool tests when the binary predates pk_run_batch

The batch and pool tests call run_prediction_kernel_batch, which returns None when the loaded binary
has no pk_run_batch. Four of them treated that as a failure rather than a skip, so running against an
older binary reported errors instead of degrading the way the product does.

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

* chore: update prediction kernel binaries for all platforms

* fix(kernel): include <system_error> explicitly

The pool catches std::system_error when the system refuses a thread. libc++ pulls that declaration in
through <thread>, libstdc++ does not, so the file built on macOS and failed on GCC with a parse error
at the catch clause. Caught by CI, which builds natively with g++; neither the local clang build nor
the zig cross-build sees it, since both use libc++.

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

* chore: update prediction kernel binaries for all platforms

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
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