Repository navigation
perf(kernel): persistent worker pool instead of threads per batch (6% faster) - #4546
Merged
Merged
Conversation
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>
Contributor
There was a problem hiding this comment.
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_batchwith a process-lifetimePkThreadPooland 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_atforkhandler functions are internal callbacks, but they’re currently defined with external linkage, which can unnecessarily expand the library’s exported symbol surface. Mark themstatic(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; |
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
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>
This was referenced Aug 16, 2026
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.
Stacked on #4540 — base is
perf/kernel-batch-abi, so this diff shows only the pool work. Retarget tomainonce #4540 merges.What
pk_run_batchbuilt and joined a freshstd::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_batchover one benchmark plan showed why:std::threads createdpk_run_batchThe 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:
threads6.1% on top of #4540, and more on hardware where the kernel dominates.
End to end, against current
mainWith #4540 underneath it, interleaved best of 3, each side on its own Python and kernel, all 20 scenarios identical:
mainthreads: 0)threads: auto)This PR is the second 5.5% of that: 26.26s serial to 24.82s at
autoon the same tree. For comparisonmain'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 noPRED_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: autocap 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.autostays at the core count;resolve_batch_threadscarries 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:
pthread_atforkchild handler drops the pool; the next batch builds a fresh one.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:
pthread_atforkhandler makes the forked child deadlock; the test kills it on a deadline and reports the timeout rather than hanging the suite.🤖 Generated with Claude Code