Skip to content

Commit 4501327

Browse files
perf(prediction): build the kernel's ctypes arrays via array.array
The six per-simulation ctypes buffers were built as (ctypes.c_double * n)(*values), which unpacks the list into positional arguments - several times slower than copying from an array.array of the same type. Marshalling was the largest remaining cost in the planner after #4505, #4507 and #4508: 8.1s of a 13.9s profiled plan, more than everything else combined. Measured on the shapes the planner actually passes, building the charge window geometry drops from 35.0us to 12.7us per call. Content-keyed memoisation of the geometry arrays was measured as an alternative (10.8us) and rejected: it is only marginally ahead, because the key still has to be built and hashed, and it would have to stay correct across the passes that mutate window bounds in place. Reusing the soc_out buffer was also measured and is not worth it at 0.15us per allocation. from_buffer returns a view over the array.array rather than a copy, so the backing object has to outlive the kernel call. ctypes keeps it alive through the view's _objects; a new test asserts that rather than assuming it, since the failure mode is reading freed memory silently. The pool workers are separate forked processes so no buffer is shared between them, which was verified by running the same plan single-process and pooled and confirming identical results (not added as a test - it needs two full plan runs). Benchmark: worst scenario 11.03s -> 8.39s, mean optimise time across the 20 scenarios 3.717s -> 1.961s (1.9x), with plan metric and cost identical on all 20. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 457cf0a commit 4501327

3 files changed

Lines changed: 72 additions & 26 deletions

File tree

apps/predbat/prediction_kernel.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
rejected at load time rather than producing divergent results.
2121
"""
2222

23+
import array
2324
import ctypes
2425
import os
2526
import platform
@@ -261,13 +262,23 @@ def load_kernel(log=None):
261262

262263

263264
def double_array(values):
264-
"""Create a ctypes double array from a Python list"""
265-
return (ctypes.c_double * len(values))(*values)
265+
"""Create a ctypes double array from a Python list.
266+
267+
Built via array.array rather than (ctypes.c_double * n)(*values): the latter unpacks the list as
268+
positional arguments and is several times slower, which matters because these are rebuilt on
269+
every simulation. from_buffer returns a view over the array.array, and ctypes keeps the backing
270+
object alive through the view's _objects, so the buffer cannot be collected while the kernel is
271+
using it. Each pool worker is a separate process (multiprocessing with fork), so no buffer is
272+
ever shared between workers.
273+
"""
274+
backing = array.array("d", values)
275+
return (ctypes.c_double * len(backing)).from_buffer(backing)
266276

267277

268278
def int32_array(values):
269-
"""Create a ctypes int32 array from a Python list"""
270-
return (ctypes.c_int32 * len(values))(*values)
279+
"""Create a ctypes int32 array from a Python list - see double_array for why array.array is used"""
280+
backing = array.array("i", values)
281+
return (ctypes.c_int32 * len(backing)).from_buffer(backing)
271282

272283

273284
def kernel_context_free(handle):

apps/predbat/tests/test_kernel_parity.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"""
2222

2323
import copy
24+
import gc
2425
import os
2526
import random
2627
import subprocess
@@ -380,6 +381,39 @@ def dual_run(name, my_predbat, pv_step, pv10_step, load_step, load10_step, charg
380381
return failed
381382

382383

384+
def run_marshalling_tests():
385+
"""Check the ctypes buffer helpers, returns True on failure.
386+
387+
double_array/int32_array build their buffers with from_buffer, which returns a view over an
388+
array.array rather than a copy. If ctypes did not keep the backing object alive the kernel would
389+
read freed memory - silently, and only sometimes - so that guarantee is asserted here rather than
390+
assumed, along with the values surviving the round trip.
391+
"""
392+
print("**** Running kernel marshalling tests ****")
393+
failed = False
394+
395+
for name, builder, values in (("double_array", prediction_kernel.double_array, [0.0, -1.5, 3.25, 1e6]), ("int32_array", prediction_kernel.int32_array, [0, -7, 42, 100000])):
396+
# Build from a temporary so the source list/array is unreferenced by the time it is read
397+
buffer = builder(list(values))
398+
gc.collect()
399+
got = [buffer[i] for i in range(len(values))]
400+
if got != values:
401+
print("ERROR: {} round trip expected {} but got {}".format(name, values, got))
402+
failed = True
403+
if buffer._objects is None:
404+
print("ERROR: {} did not retain its backing buffer - the kernel could read freed memory".format(name))
405+
failed = True
406+
407+
empty = prediction_kernel.int32_array([])
408+
if len(empty) != 0:
409+
print("ERROR: int32_array([]) should be empty, got length {}".format(len(empty)))
410+
failed = True
411+
412+
if not failed:
413+
print("PASS")
414+
return failed
415+
416+
383417
def run_edge_case_tests(my_predbat):
384418
"""Deterministic scenarios pinning each kernel branch, returns True on failure"""
385419
failed = False
@@ -840,7 +874,8 @@ def run_kernel_parity_tests(my_predbat):
840874

841875
state = snapshot_scenario_state(my_predbat)
842876
try:
843-
failed = run_edge_case_tests(my_predbat)
877+
failed = run_marshalling_tests()
878+
failed |= run_edge_case_tests(my_predbat)
844879
if not failed:
845880
failed |= run_random_sweep_tests(my_predbat)
846881
if not failed:

coverage/cases/random_results.json

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"run_info": {
33
"template_yaml": "cases/predbat_debug_agile1.yaml",
44
"scenarios_file": "cases/random_scenarios.yaml",
5-
"timestamp": "2026-08-13T10:34:13.978454+00:00"
5+
"timestamp": "2026-08-13T11:57:01.949843+00:00"
66
},
77
"results": [
88
{
@@ -17,7 +17,7 @@
1717
"soc_final": 6.8616,
1818
"battery_cycles": 21.0198,
1919
"carbon_g": 12959.33,
20-
"runtime_s": 3.993,
20+
"runtime_s": 2.1,
2121
"failed": false,
2222
"error": null,
2323
"end_record": 1440
@@ -34,7 +34,7 @@
3434
"soc_final": 4.8,
3535
"battery_cycles": 10.5525,
3636
"carbon_g": 4613.47,
37-
"runtime_s": 3.174,
37+
"runtime_s": 2.772,
3838
"failed": false,
3939
"error": null,
4040
"end_record": 1440
@@ -51,7 +51,7 @@
5151
"soc_final": 2.2382,
5252
"battery_cycles": 5.8265,
5353
"carbon_g": 9017.53,
54-
"runtime_s": 3.094,
54+
"runtime_s": 1.561,
5555
"failed": false,
5656
"error": null,
5757
"end_record": 1440
@@ -68,7 +68,7 @@
6868
"soc_final": 2.0559,
6969
"battery_cycles": 9.7908,
7070
"carbon_g": 9673.45,
71-
"runtime_s": 0.236,
71+
"runtime_s": 0.211,
7272
"failed": false,
7373
"error": null,
7474
"end_record": 1440
@@ -85,7 +85,7 @@
8585
"soc_final": 4.8,
8686
"battery_cycles": 7.3257,
8787
"carbon_g": 15944.91,
88-
"runtime_s": 0.423,
88+
"runtime_s": 0.364,
8989
"failed": false,
9090
"error": null,
9191
"end_record": 1440
@@ -102,7 +102,7 @@
102102
"soc_final": 2.3066,
103103
"battery_cycles": 34.749,
104104
"carbon_g": 884.71,
105-
"runtime_s": 0.433,
105+
"runtime_s": 0.39,
106106
"failed": false,
107107
"error": null,
108108
"end_record": 1440
@@ -119,7 +119,7 @@
119119
"soc_final": 0.38,
120120
"battery_cycles": 38.4449,
121121
"carbon_g": 8065.01,
122-
"runtime_s": 18.238,
122+
"runtime_s": 8.31,
123123
"failed": false,
124124
"error": null,
125125
"end_record": 1440
@@ -136,7 +136,7 @@
136136
"soc_final": 0.38,
137137
"battery_cycles": 14.2039,
138138
"carbon_g": 18350.09,
139-
"runtime_s": 4.406,
139+
"runtime_s": 3.376,
140140
"failed": false,
141141
"error": null,
142142
"end_record": 1440
@@ -153,7 +153,7 @@
153153
"soc_final": 0.38,
154154
"battery_cycles": 10.6638,
155155
"carbon_g": 12867.91,
156-
"runtime_s": 0.417,
156+
"runtime_s": 0.361,
157157
"failed": false,
158158
"error": null,
159159
"end_record": 1440
@@ -170,7 +170,7 @@
170170
"soc_final": 9.152,
171171
"battery_cycles": 25.215,
172172
"carbon_g": 4663.11,
173-
"runtime_s": 0.566,
173+
"runtime_s": 0.512,
174174
"failed": false,
175175
"error": null,
176176
"end_record": 1440
@@ -187,7 +187,7 @@
187187
"soc_final": 1.0713,
188188
"battery_cycles": 25.7368,
189189
"carbon_g": 7751.35,
190-
"runtime_s": 2.156,
190+
"runtime_s": 1.404,
191191
"failed": false,
192192
"error": null,
193193
"end_record": 1440
@@ -204,7 +204,7 @@
204204
"soc_final": 0.38,
205205
"battery_cycles": 6.174,
206206
"carbon_g": 13156.97,
207-
"runtime_s": 1.181,
207+
"runtime_s": 0.768,
208208
"failed": false,
209209
"error": null,
210210
"end_record": 1440
@@ -221,7 +221,7 @@
221221
"soc_final": 0.38,
222222
"battery_cycles": 28.7593,
223223
"carbon_g": 15144.61,
224-
"runtime_s": 3.545,
224+
"runtime_s": 2.101,
225225
"failed": false,
226226
"error": null,
227227
"end_record": 1440
@@ -238,7 +238,7 @@
238238
"soc_final": 6.2665,
239239
"battery_cycles": 9.68,
240240
"carbon_g": 12838.7,
241-
"runtime_s": 0.499,
241+
"runtime_s": 0.433,
242242
"failed": false,
243243
"error": null,
244244
"end_record": 1440
@@ -255,7 +255,7 @@
255255
"soc_final": 1.5389,
256256
"battery_cycles": 5.6825,
257257
"carbon_g": 16611.75,
258-
"runtime_s": 0.477,
258+
"runtime_s": 0.415,
259259
"failed": false,
260260
"error": null,
261261
"end_record": 1440
@@ -272,7 +272,7 @@
272272
"soc_final": 4.3726,
273273
"battery_cycles": 19.543,
274274
"carbon_g": 16613.71,
275-
"runtime_s": 4.61,
275+
"runtime_s": 2.725,
276276
"failed": false,
277277
"error": null,
278278
"end_record": 1440
@@ -289,7 +289,7 @@
289289
"soc_final": 0.38,
290290
"battery_cycles": 4.9586,
291291
"carbon_g": 15848.43,
292-
"runtime_s": 0.297,
292+
"runtime_s": 0.255,
293293
"failed": false,
294294
"error": null,
295295
"end_record": 1440
@@ -306,7 +306,7 @@
306306
"soc_final": 0.38,
307307
"battery_cycles": 25.8549,
308308
"carbon_g": 153.34,
309-
"runtime_s": 5.097,
309+
"runtime_s": 1.887,
310310
"failed": false,
311311
"error": null,
312312
"end_record": 1440
@@ -323,7 +323,7 @@
323323
"soc_final": 0.5251,
324324
"battery_cycles": 9.8445,
325325
"carbon_g": 11504.26,
326-
"runtime_s": 0.212,
326+
"runtime_s": 0.19,
327327
"failed": false,
328328
"error": null,
329329
"end_record": 1440
@@ -340,7 +340,7 @@
340340
"soc_final": 19.6907,
341341
"battery_cycles": 60.7987,
342342
"carbon_g": 18744.27,
343-
"runtime_s": 21.279,
343+
"runtime_s": 9.089,
344344
"failed": false,
345345
"error": null,
346346
"end_record": 1440

0 commit comments

Comments
 (0)