-
Notifications
You must be signed in to change notification settings - Fork 8
Expand file tree
/
Copy pathbenchmark.py
More file actions
1250 lines (1069 loc) · 45.5 KB
/
Copy pathbenchmark.py
File metadata and controls
1250 lines (1069 loc) · 45.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Chain-ring microbenchmark for the BOC runtime.
This benchmark measures *BOC runtime scaling* (scheduler, 2PL, message
queue, sub-interpreter crossings, return-cown allocation) in isolation
from any application-specific serial work. It is **not** a measure of
how well your own application will scale: real applications carry
serial costs (data structure construction, scheduling logic,
result drainage) that this benchmark deliberately eliminates.
A few load-bearing caveats baked into the design:
* Each behavior allocates a fresh return ``Cown`` (the auto-generated
one returned by ``@when``). At thousands of behaviors per second
this is a real, version-dependent constant in every sample.
* ``ChainState`` crosses the interpreter boundary via XIData on every
reschedule; for tiny payloads, marshaling can rival the useful work.
* The ``group-size`` sweep varies acquired-set cardinality and CPU work
together (the inner loop multiplies every window slot into
``window[0]``, ``iters * group_size`` matrix multiplies per
behavior). It is not an isolated 2PL-cost knob.
"""
import argparse
from dataclasses import asdict, dataclass, field
from datetime import datetime
import json
import os
import socket
import statistics
import subprocess
import sys
import time
from typing import Optional
from bocpy import _core
from bocpy import (Cown, Matrix, notice_seed, notice_write, noticeboard,
PinnedCown,
pump, start, wait, when)
SENTINEL_BEGIN = "---BOCPY-BENCH-BEGIN---"
SENTINEL_END = "---BOCPY-BENCH-END---"
SCHEMA_VERSION = 1
def _physical_cpu_count() -> int:
"""Return physical core count, falling back to logical or 1.
Used as the oversubscription threshold so warnings fire when the
requested worker count starts using SMT siblings rather than
waiting until logical cores are exhausted.
:return: A positive integer.
"""
n = _core.physical_cpu_count()
if n > 0:
return n
return os.cpu_count() or 1
class ChainState:
"""Per-chain mutable state carried inside a ``Cown[ChainState]``.
Holds ints only. The chain's ring of ``Cown[Matrix]`` lives in the
noticeboard under ``f"ring_{ring_id}"`` so it is materialized once
per worker (and cached for the lifetime of ``NB_VERSION``) instead
of being marshaled through XIData on every reschedule.
"""
def __init__(self, chain_id: int, ring_id: int, head_idx: int,
iters: int, stride: int, ring_size: int):
"""Initialize a chain state.
:param chain_id: A unique id within the workload.
:param ring_id: Index of the ring this chain runs on. Must
correspond to a ``f"ring_{ring_id}"`` entry already
written to the noticeboard.
:param head_idx: Initial head position on the ring.
:param iters: Inner-loop matrix multiplications per window slot.
:param stride: Step between successive windows.
:param ring_size: Number of cowns on the ring.
"""
self.chain_id = chain_id
self.ring_id = ring_id
self.head_idx = head_idx
self.count = 0
self.iters = iters
self.stride = stride
self.ring_size = ring_size
def next_window(cs: "ChainState", group_size: int) -> list:
"""Compute the next sliding window of cowns for a chain.
Reads the chain's ring from the noticeboard. Must be called from
inside a behavior so that ``noticeboard()`` returns the cached
snapshot for the current ``NB_VERSION``.
:param cs: The chain state.
:param group_size: Number of adjacent cowns in the window.
:return: ``list[Cown[Matrix]]`` for the next acquired set.
"""
ring = noticeboard()[f"ring_{cs.ring_id}"]
return [ring[(cs.head_idx + i * cs.stride) % cs.ring_size]
for i in range(group_size)]
def schedule_step(state_cown: Cown, window_list: list, group_size: int) -> None:
"""Schedule one chain step with the given window.
The ``@when`` decorator inside this helper registers the behavior at
runtime, so this function works correctly when called from a worker
sub-interpreter as well as from the main interpreter.
:param state_cown: The chain's state cown.
:param window_list: Adjacent cowns to acquire for this step.
:param group_size: Window size, captured into the behavior.
"""
@when(state_cown, window_list)
def _step(state, window, group_size=group_size):
cs = state.value
if not noticeboard().get("cr_null", False):
for _ in range(cs.iters):
for c in window:
window[0].value = window[0].value @ c.value
cs.count += 1
cs.head_idx = (cs.head_idx + cs.stride) % cs.ring_size
if not noticeboard().get("cr_stop", False):
schedule_step(state, next_window(cs, group_size), group_size)
# Mirror of the noticeboard's hard entry cap in boc_noticeboard.c
# (NB_MAX_ENTRIES). The workload publishes one "ring_{r}" entry per ring
# plus the NB_RESERVED_KEYS non-ring keys below; validate_config rejects
# configs that would overflow this rather than dropping entries mid-run.
NB_CAPACITY = 64
# Non-ring noticeboard keys: cr_null, cr_stop, final_count,
# final_count_ts_ns, pinned_dispatches.
NB_RESERVED_KEYS = 5
@dataclass
class BenchConfig:
"""Plain-data benchmark configuration.
Holds only ints / floats / strings / lists of the same so that an
instance can stay live in ``main()``'s frame across ``wait()``
without ``stop_workers`` finding any bare Cowns to acquire.
"""
workers: int = 1
duration: float = 5.0
warmup: float = 1.0
iters: int = 2000
group_size: int = 2
stride: int = 1
rings: Optional[int] = None
chains_per_ring: Optional[int] = None
ring_size: int = 128
payload_rows: int = 16
payload_cols: int = 16
repeats: int = 1
null_payload: bool = False
pinned_spinner: bool = False
pinned_spinner_sleep_s: float = 0.001
@dataclass
class RepeatResult:
"""Plain-data result for a single repeat of one sweep point."""
repeat_index: int
completed_behaviors: int
elapsed_s: float
throughput: float
wall_clock_ns_start: int
scheduler_stats: Optional[list] = None
queue_stats: Optional[list] = None
derived: Optional[dict] = None
pinned_dispatches: int = 0
@dataclass
class PointResult:
"""Plain-data result for a single sweep point."""
inputs: dict
repeats: list = field(default_factory=list)
throughput_mean: Optional[float] = None
throughput_stdev: Optional[float] = None
throughput_min: Optional[float] = None
throughput_max: Optional[float] = None
error: Optional[dict] = None
def derive_sizes(cfg: BenchConfig) -> BenchConfig:
"""Auto-size ``rings`` and ``chains_per_ring`` if not overridden.
Biases toward many rings with few chains each so the benchmark
measures scheduler scaling (lots of independent work) rather than
contention/starvation within a single oversubscribed ring.
:param cfg: An input config (mutated and returned).
:return: The same config with ``rings`` / ``chains_per_ring`` set.
"""
if cfg.chains_per_ring is None:
cfg.chains_per_ring = max(
1, cfg.ring_size // (cfg.group_size * cfg.stride * 8))
if cfg.rings is None:
cfg.rings = max(cfg.workers * 16 // cfg.chains_per_ring,
cfg.workers * 4)
return cfg
def validate_config(cfg: BenchConfig) -> Optional[str]:
"""Validate a fully-derived config; return an error string or None.
Hard errors only. Soft warnings (``duration < 1.0``, oversubscribed
workers) are emitted by the caller rather than failing here.
:param cfg: A config with ``rings`` and ``chains_per_ring`` set.
:return: An error message, or ``None`` if the config is valid.
"""
if cfg.group_size * cfg.stride * 2 > cfg.ring_size:
return (f"group_size*stride*2 ({cfg.group_size}*{cfg.stride}*2) "
f"> ring_size ({cfg.ring_size}); chains would collide")
nb_used = cfg.rings + NB_RESERVED_KEYS
if nb_used > NB_CAPACITY:
return (f"rings ({cfg.rings}) + {NB_RESERVED_KEYS} reserved keys "
f"= {nb_used} exceeds noticeboard capacity ({NB_CAPACITY}); "
f"lower --workers/--rings or raise --ring-size")
if cfg.workers < 1:
return f"workers must be >= 1, got {cfg.workers}"
if cfg.iters < 1:
return f"iters must be >= 1, got {cfg.iters}"
if cfg.payload_rows < 1 or cfg.payload_cols < 1:
return "payload dimensions must be >= 1"
if cfg.duration <= 0 or cfg.warmup < 0:
return "duration must be > 0 and warmup must be >= 0"
return None
def emit_soft_warnings(cfg: BenchConfig, cpu_count: int) -> None:
"""Print soft warnings for unusual configs to stderr.
:param cfg: The fully-derived config.
:param cpu_count: Detected CPU count for oversubscription check.
"""
if cfg.duration < 1.0:
print(f"warning: duration={cfg.duration}s is short; results will "
"be noisy", file=sys.stderr)
if cfg.workers > cpu_count:
print(f"warning: workers={cfg.workers} exceeds physical core "
f"count={cpu_count}; oversubscribed (SMT siblings or "
f"hyperthreads will be used)", file=sys.stderr)
def build_workload(cfg: BenchConfig):
"""Build per-ring cowns and per-chain state cowns.
Each ring is published to the noticeboard under ``f"ring_{r}"``.
Workers read it back via ``noticeboard()`` inside ``_step``; the
noticeboard's per-worker version-cache means the ring is
materialized once per worker per ``NB_VERSION`` instead of being
marshaled through XIData on every reschedule.
:param cfg: A fully-derived config.
:return: A ``(rings, state_cowns)`` tuple. ``rings`` is
``list[list[Cown[Matrix]]]``; ``state_cowns`` is
``list[Cown[ChainState]]``. Both containers are invisible to
``stop_workers`` (it does not recurse into containers).
"""
rings = []
state_cowns = []
chain_id = 0
for r in range(cfg.rings):
ring = [Cown(Matrix.uniform(0.0, 1.0,
(cfg.payload_rows, cfg.payload_cols)))
for _ in range(cfg.ring_size)]
rings.append(ring)
notice_seed(f"ring_{r}", ring)
spacing = max(1, cfg.ring_size // cfg.chains_per_ring)
for k in range(cfg.chains_per_ring):
head = (k * spacing) % cfg.ring_size
cs = ChainState(chain_id=chain_id, ring_id=r, head_idx=head,
iters=cfg.iters, stride=cfg.stride,
ring_size=cfg.ring_size)
state_cowns.append(Cown(cs))
chain_id += 1
return rings, state_cowns
def schedule_snap(state_cowns: list) -> None:
"""Schedule the final snapshot behavior.
The snap behavior writes the total count to the noticeboard under
``"final_count"`` rather than sending a message; the parent lifts
the value back via ``wait(noticeboard=True)``. The bare ``snap``
return-cown local falls out of scope at this function's return
boundary, satisfying the no-bare-Cowns-in-main rule before
``wait()`` runs.
:param state_cowns: Every chain's state cown.
"""
@when(state_cowns)
def snap(states):
notice_write("final_count", sum(s.value.count for s in states))
notice_write("final_count_ts_ns", time.perf_counter_ns())
notice_write("cr_stop", True)
_PINNED_COUNT = 0
def schedule_pinned_spinner(spin_cown: "PinnedCown",
sleep_s: float) -> None:
"""Schedule the next pinned-spinner @when on ``spin_cown``.
The body increments ``p[_PINNED_COUNT]`` (exclusive access to the
cown's value while the behavior runs) and either re-schedules
itself or, on the iteration that sees ``cr_stop`` set, writes the
final count to the noticeboard under ``"pinned_dispatches"`` --
exactly one ``NB_VERSION`` bump per run instead of one per
dispatch. The sleep lives inside the body so the spinner
self-paces under a ``pump()`` / ``wait()`` auto-pump loop.
:param spin_cown: The :class:`PinnedCown` the spinner runs on.
Its value must be a one-element list ``[count]``.
:param sleep_s: Per-iteration sleep, in seconds, controlling the
dispatch rate (e.g. ``0.001`` for ~1 kHz).
"""
@when(spin_cown)
def _spinner(p, sleep_s=sleep_s, spin_cown=spin_cown):
p.value[_PINNED_COUNT] += 1
if not noticeboard().get("cr_stop", False):
time.sleep(sleep_s)
schedule_pinned_spinner(spin_cown, sleep_s)
else:
notice_write("pinned_dispatches", p.value[_PINNED_COUNT])
def run_single_point_body(cfg: BenchConfig, repeat_index: int) -> RepeatResult:
"""Run one chain-ring measurement in a fresh BOC runtime.
Snapshots ``_core.scheduler_stats()`` after warmup, then captures
the post-session snapshot via ``wait(stats=True)``. The **delta**
of the two is stored in ``RepeatResult.scheduler_stats`` so warmup
pushes do not pollute the per-window counters consumed by
``compute_derived_metrics``.
:param cfg: The fully-derived config.
:param repeat_index: Index of this repeat for reporting.
:return: A ``RepeatResult`` with no Cown references.
"""
start(worker_count=cfg.workers)
rings, state_cowns = build_workload(cfg)
notice_seed("cr_null", cfg.null_payload)
payload_bytes = cfg.payload_rows * cfg.payload_cols * 8
total_bytes = cfg.rings * cfg.ring_size * payload_bytes
print(f"workload: chain rings={cfg.rings} ring_size={cfg.ring_size} "
f"chains={cfg.rings * cfg.chains_per_ring} "
f"payload={cfg.payload_rows}x{cfg.payload_cols} "
f"(~{total_bytes / 1024:.1f} KiB matrix data)",
file=sys.stderr)
try:
spacing = max(1, cfg.ring_size // cfg.chains_per_ring)
chain_idx = 0
for r in range(cfg.rings):
for k in range(cfg.chains_per_ring):
cs_cown = state_cowns[chain_idx]
head = (k * spacing) % cfg.ring_size
window = [rings[r][(head + i * cfg.stride) % cfg.ring_size]
for i in range(cfg.group_size)]
schedule_step(cs_cown, window, cfg.group_size)
chain_idx += 1
time.sleep(cfg.warmup)
from bocpy import _core
sched_stats_warm = _core.scheduler_stats()
wall_clock_ns_start = time.time_ns()
t_measure_start_ns = time.perf_counter_ns()
if cfg.pinned_spinner:
pinned = PinnedCown([0])
schedule_pinned_spinner(pinned, cfg.pinned_spinner_sleep_s)
deadline_ns = t_measure_start_ns + int(cfg.duration * 1e9)
while time.perf_counter_ns() < deadline_ns:
pump(max_behaviors=1)
else:
time.sleep(cfg.duration)
schedule_snap(state_cowns)
queue_stats_snap = (
_core.queue_stats() if hasattr(_core, "queue_stats") else None
)
finally:
del rings
del state_cowns
wait_result = wait(stats=True, noticeboard=True)
sched_stats_end = wait_result.stats
nb_snap = wait_result.noticeboard
total = int(nb_snap.get("final_count", 0))
pinned_dispatches = int(nb_snap.get("pinned_dispatches", 0))
snap_ts_ns = nb_snap.get("final_count_ts_ns")
if snap_ts_ns is None:
raise RuntimeError("snap behavior did not publish final_count_ts_ns")
elapsed_s = max(0.0, (int(snap_ts_ns) - t_measure_start_ns) / 1e9)
sched_stats_delta = _delta_scheduler_stats(sched_stats_warm,
sched_stats_end)
throughput = total / elapsed_s if elapsed_s > 0 else 0.0
return RepeatResult(repeat_index=repeat_index,
completed_behaviors=total,
elapsed_s=elapsed_s,
throughput=throughput,
wall_clock_ns_start=wall_clock_ns_start,
scheduler_stats=sched_stats_delta,
queue_stats=queue_stats_snap,
derived=compute_derived_metrics(sched_stats_delta,
total),
pinned_dispatches=pinned_dispatches)
_COUNTER_FIELDS = (
"pushed_local",
"dispatched_to_pending",
"pushed_remote",
"popped_local",
"popped_via_steal",
"enqueue_cas_retries",
"dequeue_cas_retries",
"batch_resets",
"steal_attempts",
"steal_failures",
"fairness_arm_fires",
)
def _delta_scheduler_stats(warm: Optional[list],
end: Optional[list]) -> Optional[list]:
"""Return per-worker ``end - warm`` for the monotonic counter fields.
Non-counter fields (``parked``, ``last_steal_attempt_ns``) are
copied from ``end`` unchanged. If either snapshot is missing or
the worker counts disagree (for example because the runtime tore
down between snapshots), returns the end snapshot unchanged.
:param warm: End-of-warmup snapshot (per-worker dicts).
:param end: End-of-measurement-window snapshot.
:return: Per-worker delta dicts.
"""
if not end:
return end
if not warm or len(warm) != len(end):
return end
out = []
for w, e in zip(warm, end):
d = dict(e)
for k in _COUNTER_FIELDS:
if k in e and k in w:
d[k] = int(e[k]) - int(w[k])
out.append(d)
return out
def compute_derived_metrics(stats: Optional[list],
completed_behaviors: int) -> dict:
"""Compute the dispatch-contention metrics from a stats delta.
:param stats: Per-worker delta stats from ``_delta_scheduler_stats``.
:param completed_behaviors: Total completed behaviors over the
measurement window (matches the throughput numerator).
:return: A dict with ``producer_worker_index``,
``enq_retry_ratio``, ``steal_yield``, ``idle_ratio``, and
``producer_pushed_local`` so callers can reconstruct the
ratio's numerator / denominator without re-walking ``stats``.
"""
out = {
"producer_worker_index": None,
"producer_pushed_local": 0,
"producer_enqueue_cas_retries": 0,
"enq_retry_ratio": None,
"steal_yield": None,
"idle_ratio": None,
}
if not stats:
return out
pushed_local = [int(w.get("pushed_local", 0)) for w in stats]
if not pushed_local or max(pushed_local) == 0:
return out
p_idx = max(range(len(pushed_local)), key=lambda i: pushed_local[i])
p_pushed = pushed_local[p_idx]
p_enq_r = int(stats[p_idx].get("enqueue_cas_retries", 0))
out["producer_worker_index"] = p_idx
out["producer_pushed_local"] = p_pushed
out["producer_enqueue_cas_retries"] = p_enq_r
out["enq_retry_ratio"] = (p_enq_r / p_pushed) if p_pushed > 0 else None
total_steal = sum(int(w.get("popped_via_steal", 0)) for w in stats)
if completed_behaviors > 0:
out["steal_yield"] = total_steal / completed_behaviors
total_attempts = sum(int(w.get("steal_attempts", 0)) for w in stats)
total_failures = sum(int(w.get("steal_failures", 0)) for w in stats)
if total_attempts > 0:
out["idle_ratio"] = total_failures / total_attempts
return out
def cfg_to_argv(cfg: BenchConfig) -> list:
"""Render a ``BenchConfig`` as CLI args for a child invocation.
:param cfg: The config to serialize.
:return: A list of CLI arguments suitable for child invocation.
"""
args = [
"--workers", str(cfg.workers),
"--duration", str(cfg.duration),
"--warmup", str(cfg.warmup),
"--iters", str(cfg.iters),
"--group-size", str(cfg.group_size),
"--stride", str(cfg.stride),
"--ring-size", str(cfg.ring_size),
"--payload-rows", str(cfg.payload_rows),
"--payload-cols", str(cfg.payload_cols),
"--repeats", "1",
"--sweep-axis", "none",
]
if cfg.rings is not None:
args += ["--rings", str(cfg.rings)]
if cfg.chains_per_ring is not None:
args += ["--chains-per-ring", str(cfg.chains_per_ring)]
if cfg.null_payload:
args += ["--null-payload"]
if cfg.pinned_spinner:
args += ["--pinned-spinner",
"--pinned-spinner-sleep-s",
str(cfg.pinned_spinner_sleep_s)]
return args
BOCPY_BENCH_EMIT_SCHED_STATS_ENV = "BOCPY_BENCH_EMIT_SCHED_STATS"
def run_in_subprocess(cfg: BenchConfig, repeat_index: int,
git_sha: Optional[str]) -> RepeatResult:
"""Run one repeat in a fresh subprocess and return its result.
On non-zero exit / timeout / missing sentinel, raises
``RuntimeError`` with a stderr-tail diagnostic so the caller can
record an ``error`` entry on the point.
:param cfg: A fully-derived config with ``repeats`` ignored.
:param repeat_index: Index into the parent's ``repeats[]`` list.
:param git_sha: Optional git sha to forward to the child.
"""
env = dict(os.environ)
if git_sha is not None:
env["BOCPY_BENCH_GIT_SHA"] = git_sha
extra = []
if env.get(BOCPY_BENCH_EMIT_SCHED_STATS_ENV) == "1":
extra.append("--emit-scheduler-stats")
cmd = [sys.executable, "-m", "bocpy.examples.benchmark",
"--json-stdout"] + cfg_to_argv(cfg) + extra
timeout = max(cfg.duration * 3 + 30, cfg.duration + cfg.warmup + 60)
try:
proc = subprocess.run(cmd, env=env, capture_output=True,
text=True, timeout=timeout, check=False)
except subprocess.TimeoutExpired as ex:
raise RuntimeError(
f"subprocess timed out after {timeout}s; "
f"stderr tail: {(ex.stderr or '')[-400:]!r}")
if proc.returncode != 0:
raise RuntimeError(
f"subprocess exited {proc.returncode}; "
f"stderr tail: {proc.stderr[-400:]!r}")
payload = _extract_sentinel_payload(proc.stdout)
if payload is None:
raise RuntimeError(
"child produced no sentinel-framed JSON; "
f"stderr tail: {proc.stderr[-400:]!r}")
return RepeatResult(
repeat_index=repeat_index,
completed_behaviors=int(payload["completed_behaviors"]),
elapsed_s=float(payload["elapsed_s"]),
throughput=float(payload["throughput"]),
wall_clock_ns_start=int(payload["wall_clock_ns_start"]),
scheduler_stats=payload.get("scheduler_stats"),
queue_stats=payload.get("queue_stats"),
derived=payload.get("derived"),
pinned_dispatches=int(payload.get("pinned_dispatches", 0)))
def _extract_sentinel_payload(stdout: str) -> Optional[dict]:
"""Find and parse exactly one sentinel-framed JSON object.
:param stdout: The captured child stdout.
:return: The parsed payload, or ``None`` if no valid frame.
"""
begin = stdout.find(SENTINEL_BEGIN)
end = stdout.find(SENTINEL_END)
if begin < 0 or end < 0 or end < begin:
return None
inner = stdout[begin + len(SENTINEL_BEGIN):end].strip()
try:
return json.loads(inner)
except json.JSONDecodeError:
return None
def cfg_for_axis(base: BenchConfig, axis: str, value) -> BenchConfig:
"""Clone ``base`` with one axis varied to ``value``.
:param base: The base config.
:param axis: One of ``workers``, ``iters``, ``group-size``,
``payload``, ``none``.
:param value: The axis value (an ``int`` for most axes; a
``(rows, cols)`` tuple for ``payload``).
:return: A fresh ``BenchConfig`` with that axis applied.
"""
cfg = BenchConfig(**asdict(base))
cfg.rings = base.rings
cfg.chains_per_ring = base.chains_per_ring
if axis == "workers":
cfg.workers = int(value)
cfg.rings = None
cfg.chains_per_ring = None
elif axis == "iters":
cfg.iters = int(value)
elif axis == "group-size":
cfg.group_size = int(value)
cfg.chains_per_ring = None
cfg.rings = None
elif axis == "payload":
cfg.payload_rows, cfg.payload_cols = value
elif axis == "none":
pass
else:
raise ValueError(f"unknown axis: {axis}")
return derive_sizes(cfg)
def summarize_repeats(reps: list) -> dict:
"""Compute mean/stdev/min/max across repeats with the null-stdev rule.
With fewer than 2 repeats, ``stdev`` / ``min`` / ``max`` are
emitted as JSON null rather than zero, to avoid false zero-height
error bars in downstream plots.
:param reps: A list of ``RepeatResult``.
:return: A dict with mean, stdev, min, max.
"""
if not reps:
return {"mean": None, "stdev": None, "min": None, "max": None}
throughputs = [r.throughput for r in reps]
if len(throughputs) < 2:
return {"mean": throughputs[0], "stdev": None,
"min": None, "max": None}
return {
"mean": statistics.fmean(throughputs),
"stdev": statistics.stdev(throughputs),
"min": min(throughputs),
"max": max(throughputs),
}
def run_sweep(axis: str, values: list, base: BenchConfig,
git_sha: Optional[str], output_path: str,
metadata: dict) -> dict:
"""Run a sweep, flushing JSON to disk after every point.
:param axis: Sweep axis name.
:param values: Per-axis values in order.
:param base: Base configuration.
:param git_sha: Optional git sha to forward to children.
:param output_path: Destination JSON file.
:param metadata: Initial metadata dict (will be updated with
``finished_at`` at end).
:return: The final results dict (also written to disk).
"""
points = []
fixed = asdict(base)
fixed.pop("workers", None) if axis == "workers" else None
rendered_values = [list(v) if isinstance(v, tuple) else v for v in values]
sweep_meta = {"axis": axis, "values": rendered_values, "fixed": fixed}
interrupted = False
for value in values:
cfg = cfg_for_axis(base, axis, value)
err = validate_config(cfg)
inputs = asdict(cfg)
if err is not None:
point = PointResult(inputs=inputs,
error={"message": err, "stderr_tail": ""})
points.append(asdict(point))
print(f"point {axis}={value}: validation error: {err}",
file=sys.stderr)
_flush_results(output_path, metadata, sweep_meta, points)
continue
repeats: list = []
try:
for r in range(base.repeats):
print(f"point {axis}={value} repeat {r + 1}/{base.repeats}: "
"spawning child...", file=sys.stderr)
try:
rep = run_in_subprocess(cfg, r, git_sha)
repeats.append(rep)
print(f" -> {rep.throughput:.1f} behaviors/s "
f"({rep.completed_behaviors} in "
f"{rep.elapsed_s:.2f}s)", file=sys.stderr)
except RuntimeError as ex:
point = PointResult(
inputs=inputs,
repeats=[asdict(r) for r in repeats],
error={"message": str(ex), "stderr_tail": ""})
points.append(asdict(point))
_flush_results(output_path, metadata, sweep_meta, points)
repeats = None
break
except KeyboardInterrupt:
interrupted = True
metadata["interrupted"] = True
if repeats:
point = PointResult(
inputs=inputs,
repeats=[asdict(r) for r in repeats],
error={"message": "interrupted", "stderr_tail": ""})
points.append(asdict(point))
_flush_results(output_path, metadata, sweep_meta, points)
break
if repeats is None:
continue
summary = summarize_repeats(repeats)
point = PointResult(
inputs=inputs,
repeats=[asdict(r) for r in repeats],
throughput_mean=summary["mean"],
throughput_stdev=summary["stdev"],
throughput_min=summary["min"],
throughput_max=summary["max"])
points.append(asdict(point))
_flush_results(output_path, metadata, sweep_meta, points)
metadata["finished_at"] = datetime.now().isoformat(timespec="seconds")
metadata["interrupted"] = interrupted or metadata.get("interrupted", False)
final = _flush_results(output_path, metadata, sweep_meta, points)
return final
def _flush_results(path: str, metadata: dict, sweep_meta: dict,
points: list) -> dict:
"""Atomic write of the results JSON; falls back to in-place on Windows.
:param path: Destination file path.
:param metadata: Top-level metadata dict.
:param sweep_meta: Sweep description dict.
:param points: List of point dicts.
:return: The full results document that was written.
"""
document = {
"schema_version": SCHEMA_VERSION,
"metadata": metadata,
"sweep": sweep_meta,
"points": points,
}
serialized = json.dumps(document, indent=2, default=_json_default)
os.makedirs(os.path.dirname(os.path.abspath(path)) or ".", exist_ok=True)
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
f.write(serialized)
delays = (0.05, 0.1, 0.2)
for attempt, delay in enumerate(delays):
try:
os.replace(tmp, path)
return document
except PermissionError:
if attempt == len(delays) - 1:
print(f"warning: atomic rename failed after {len(delays)} "
"attempts; falling back to in-place overwrite",
file=sys.stderr)
with open(path, "w", encoding="utf-8") as f:
f.write(serialized)
try:
os.unlink(tmp)
except OSError:
pass
return document
time.sleep(delay)
return document
def _json_default(obj):
"""Coerce non-JSON-native objects (e.g. tuples) for serialization.
:param obj: An object json.dumps could not serialize natively.
:return: A JSON-serializable representation.
"""
if isinstance(obj, (set, frozenset)):
return list(obj)
raise TypeError(f"object of type {type(obj).__name__} is not "
"JSON-serializable")
def collect_metadata(argv: list, git_sha: Optional[str]) -> dict:
"""Collect metadata for the top of the results JSON.
:param argv: The parent's ``sys.argv``.
:param git_sha: The git sha (or None).
:return: A metadata dict.
"""
try:
bocpy_version = _read_bocpy_version()
except Exception:
bocpy_version = None
free_threaded = bool(getattr(sys, "_is_gil_enabled",
lambda: True)() is False)
return {
"hostname": socket.gethostname(),
"platform": sys.platform,
"cpu_count": os.cpu_count() or 0,
"physical_cpu_count": _physical_cpu_count(),
"python_version": sys.version.split()[0],
"python_implementation": sys.implementation.name,
"free_threaded": free_threaded,
"bocpy_version": bocpy_version,
"git_sha": git_sha,
"started_at": datetime.now().isoformat(timespec="seconds"),
"finished_at": None,
"argv": list(argv),
"interrupted": False,
}
def _read_bocpy_version() -> Optional[str]:
"""Best-effort read of bocpy's version from importlib.metadata.
:return: Version string or None on failure.
"""
try:
from importlib.metadata import version
return version("bocpy")
except Exception:
return None
def _git_sha() -> Optional[str]:
"""Read git sha if available; cheap-and-fail-quietly.
:return: A 12-char abbreviated sha, or None.
"""
cached = os.environ.get("BOCPY_BENCH_GIT_SHA")
if cached:
return cached
try:
out = subprocess.run(
["git", "rev-parse", "--short=12", "HEAD"],
capture_output=True, text=True, timeout=5, check=False)
if out.returncode == 0:
return out.stdout.strip() or None
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
return None
def render_table(document: dict) -> str:
"""Render a compact ASCII summary table from a results document.
:param document: A loaded results JSON.
:return: A multi-line string ready to print.
"""
axis = document["sweep"]["axis"]
points = document["points"]
interrupted = document.get("metadata", {}).get("interrupted", False)
lines = []
show_speedup = axis == "workers"
baseline = None
if show_speedup and points:
first = points[0]
if interrupted or first.get("error") is not None \
or first.get("throughput_mean") is None:
show_speedup = False
lines.append("note: speedup/efficiency suppressed (baseline "
"missing, errored, or interrupted run)")
else:
baseline = first["throughput_mean"]
headers = [axis, "throughput", "stdev"]
if show_speedup:
headers += ["speedup", "efficiency"]
rows = []
for pt in points:
if pt.get("error") is not None:
row = [_axis_label(axis, pt), "ERROR", "-"]
if show_speedup:
row += ["-", "-"]
rows.append(row)
continue
mean = pt.get("throughput_mean")
stdev = pt.get("throughput_stdev")
row = [
_axis_label(axis, pt),
f"{mean:.1f}" if mean is not None else "-",
f"{stdev:.1f}" if stdev is not None else "-",
]
if show_speedup:
speedup = (mean / baseline) if mean and baseline else None
workers = pt["inputs"]["workers"]
efficiency = (speedup / workers) if speedup and workers else None
row += [
f"{speedup:.2f}x" if speedup is not None else "-",
f"{efficiency:.0%}" if efficiency is not None else "-",
]
rows.append(row)
widths = [max(len(h), max((len(r[i]) for r in rows), default=0))
for i, h in enumerate(headers)]
sep = "-+-".join("-" * w for w in widths)
lines.append(" | ".join(h.ljust(widths[i]) for i, h in enumerate(headers)))
lines.append(sep)
for r in rows:
lines.append(" | ".join(r[i].ljust(widths[i]) for i in range(len(r))))
return "\n".join(lines)
def _axis_label(axis: str, pt: dict) -> str:
"""Render the axis cell value for a point row.
:param axis: Sweep axis name.
:param pt: A point dict.
:return: A string for the axis column.
"""
inputs = pt.get("inputs", {})
if axis == "workers":
return str(inputs.get("workers"))
if axis == "iters":
return str(inputs.get("iters"))
if axis == "group-size":
return str(inputs.get("group_size"))
if axis == "payload":
return f"{inputs.get('payload_rows')}x{inputs.get('payload_cols')}"
return "-"
def parse_payload_token(token: str) -> tuple:
"""Parse a payload token of the form ``"<rows>x<cols>"``.
:param token: The CLI token.
:return: A ``(rows, cols)`` tuple.
"""
if "x" not in token:
raise argparse.ArgumentTypeError(
f"payload value {token!r} must look like '<rows>x<cols>'")
rs, cs = token.split("x", 1)
try:
rows, cols = int(rs), int(cs)
except ValueError:
raise argparse.ArgumentTypeError(
f"payload value {token!r}: rows and cols must be integers")
if rows < 1 or cols < 1:
raise argparse.ArgumentTypeError(
f"payload value {token!r}: rows and cols must be >= 1")
return (rows, cols)
def parse_sweep_values(axis: str, raw: Optional[str]) -> list:
"""Parse ``--sweep-values`` per-axis at argparse time.
:param axis: The sweep axis.
:param raw: The raw CSV string, or None.
:return: A list of values appropriate for the axis.
"""
if axis == "none":
if raw:
raise argparse.ArgumentTypeError(
"--sweep-values must be empty when --sweep-axis is 'none'")
return [None]
if raw is None:
return _default_sweep_values(axis)
tokens = [t.strip() for t in raw.split(",") if t.strip()]
if not tokens:
return _default_sweep_values(axis)
if axis in ("workers", "iters", "group-size"):
out = []
for t in tokens:
try:
out.append(int(t))
except ValueError: