ktstr 0.25.0

Test harness for Linux process schedulers
Documentation
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
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
2862
2863
2864
2865
2866
2867
2868
2869
2870
2871
2872
2873
2874
2875
2876
2877
2878
2879
2880
2881
2882
2883
2884
2885
2886
2887
2888
2889
2890
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
2914
2915
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
2933
2934
2935
2936
2937
2938
2939
2940
2941
2942
2943
2944
2945
2946
2947
2948
2949
2950
2951
2952
2953
2954
2955
2956
2957
2958
2959
2960
2961
2962
2963
2964
2965
2966
2967
2968
2969
2970
2971
2972
2973
2974
2975
2976
2977
2978
2979
2980
2981
2982
2983
2984
2985
2986
2987
2988
2989
2990
2991
2992
2993
2994
2995
2996
2997
2998
2999
3000
3001
3002
3003
3004
3005
3006
3007
3008
3009
3010
3011
3012
3013
3014
3015
3016
3017
3018
3019
3020
3021
3022
3023
3024
3025
3026
3027
3028
3029
3030
3031
3032
3033
3034
3035
3036
3037
3038
3039
3040
3041
3042
3043
3044
3045
3046
3047
3048
3049
3050
3051
3052
3053
3054
3055
3056
3057
3058
3059
3060
3061
3062
3063
3064
3065
3066
3067
3068
3069
3070
3071
3072
3073
3074
3075
3076
3077
3078
3079
3080
3081
3082
3083
3084
3085
3086
3087
3088
3089
3090
3091
3092
3093
3094
3095
3096
3097
3098
3099
3100
3101
3102
3103
3104
3105
3106
3107
3108
3109
3110
3111
3112
3113
3114
3115
3116
3117
3118
3119
3120
3121
3122
3123
3124
3125
3126
3127
3128
3129
3130
3131
3132
3133
3134
3135
3136
3137
3138
3139
3140
3141
3142
3143
3144
3145
3146
3147
3148
3149
3150
3151
3152
3153
3154
3155
3156
3157
3158
3159
3160
3161
3162
3163
3164
3165
3166
3167
3168
3169
3170
3171
3172
3173
3174
3175
3176
3177
3178
3179
3180
3181
3182
3183
3184
3185
3186
3187
3188
3189
3190
3191
3192
3193
3194
3195
3196
3197
3198
3199
3200
3201
3202
3203
3204
3205
3206
3207
3208
3209
3210
3211
3212
3213
3214
3215
3216
3217
3218
3219
3220
3221
3222
3223
3224
3225
3226
3227
3228
3229
3230
3231
3232
3233
3234
3235
3236
3237
3238
3239
//! schbench's run engine, ported faithfully from `schbench.c`: the
//! message-thread / worker-thread topology, the lockless wait-list, the
//! handshake-driven wakeup-latency loop, and the matrix work under the
//! per-CPU lock. The default (non-RPS) mode is the wakeup-latency +
//! request-latency benchmark, with per-phase schedstat (run-delay) capture; the
//! RPS-injector mode (`-R`) and its auto-RPS closed-loop rate control (`-A`)
//! layer on the request queue + once-per-second control thread; pipe mode (`-p`)
//! swaps the matrix work for a memory-transfer simulation over the same handshake.
//!
//! # Topology (`schbench.c` `message_thread` :1540, `worker_thread` :1419)
//!
//! `message_threads` message threads each spawn `worker_threads` worker
//! threads in one process (schbench is single-process pthreads; schbench_rs
//! re-expresses that with [`std::thread`], so the
//! [`handshake`](super::handshake) futex is PRIVATE and the per-CPU locks +
//! matrices live in one address space). A worker loops: block until its
//! message thread wakes it (measuring wakeup latency), then think-sleep +
//! matrix work under the per-CPU lock (measuring request latency). The message
//! thread batch-wakes all waiting workers (`run_msg_thread` :1166 /
//! `xlist_wake_all` :969).
//!
//! # Fidelity
//!
//! - Clock: `CLOCK_MONOTONIC` (ruling), not schbench's `gettimeofday`
//!   wall-clock. Monotonic is freeze-robust (a host-side VM pause cannot make
//!   a delta go negative) and is the correct source for a latency delta. The
//!   measured quantity -- elapsed nanoseconds between two reads -- is identical
//!   to what schbench measures; only the clock id differs.
//! - The lockless wait-list is a Treiber stack ([`TreiberStack`]) over an
//!   intrusive next-pointer, matching schbench's `xlist_add`/`xlist_splice`
//!   cmpxchg list (`schbench.c:866-896`): userspace CAS only, no lock/syscall
//!   on the hot path, so the syscall profile stays futex-dominated like
//!   schbench's. (schbench hand-duplicates this for its thread list and its
//!   request list; schbench_rs shares one generic implementation.)
//! - do_work timing: the matrix multiply compiles to the SAME scalar inner loop
//!   as schbench's `-O2` `do_some_math` -- a `u64` multiply-accumulate
//!   (`imul` with a fused memory operand, then `add`), NOT SIMD. It does not
//!   auto-vectorize: there is no vector `u64` multiply on the build target
//!   (`x86-64-v3`; `vpmullq` is AVX-512) and the serial accumulator reduction
//!   blocks SLP -- exactly as for schbench's `-O2` build (its Makefile
//!   `CFLAGS = -Wall -O2`, no `-march`, likewise scalar; verified by disassembly
//!   of both). A debug build (opt-level 0) emits far more overhead (no register
//!   allocation, unrolling, or memory-operand fusion), so request latency runs
//!   higher than reference schbench's. ABSOLUTE side-by-side fidelity vs the
//!   reference (the `ktstr-schbench-validate` comparison) therefore needs a
//!   release build. The in-ktstr A/B comparison (perf-delta / per-phase claims)
//!   is build-invariant -- both sides use the same build -- so it is unaffected.

use core::cell::UnsafeCell;
use core::ptr;
use core::sync::atomic::{AtomicBool, AtomicPtr, AtomicU32, AtomicU64, AtomicUsize, Ordering};
use std::fs::File;
use std::io::{Read, Seek, SeekFrom};

use super::percpu_lock::PerCpuLocks;
use super::plat::{Percentiles, PlatStats};

/// Read `CLOCK_MONOTONIC` as nanoseconds (ruling: monotonic, not wall-clock).
/// Self-contained so the schbench modules build into the standalone validation
/// binary without reaching into `worker`'s `pub(super)` clock wrapper.
fn monotonic_nanos() -> u64 {
    // SAFETY: `clock_gettime` writes a `timespec` through the out-pointer and
    // reads nothing else; CLOCK_MONOTONIC is always available on Linux.
    let mut ts: libc::timespec = unsafe { core::mem::zeroed() };
    let rc = unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut ts) };
    assert_eq!(rc, 0, "clock_gettime(CLOCK_MONOTONIC) failed");
    (ts.tv_sec as u64) * 1_000_000_000 + ts.tv_nsec as u64
}

/// A node that can be linked into a [`TreiberStack`] via an intrusive
/// next-pointer it owns.
trait Linked: Sized {
    fn next_link(&self) -> &AtomicPtr<Self>;
}

/// Lockless LIFO stack via an intrusive next-pointer, the shared form of
/// schbench's `xlist_add`/`xlist_splice` (`schbench.c:866-896`). Nodes are
/// referenced by raw pointer and must outlive every concurrent operation; the
/// caller owns their storage and lifetimes.
struct TreiberStack<T: Linked> {
    head: AtomicPtr<T>,
}

impl<T: Linked> TreiberStack<T> {
    fn new() -> Self {
        Self {
            head: AtomicPtr::new(ptr::null_mut()),
        }
    }

    /// Push `node` onto the stack. Faithful port of `xlist_add`
    /// (`schbench.c:866`): set the node's link to the current head, then
    /// CAS-publish it, retrying on contention. The caller must not push a node
    /// that is already on the stack (its link would be overwritten while
    /// reachable) -- schbench upholds this (a worker re-queues only after being
    /// spliced off and woken).
    fn add(&self, node: *mut T) {
        // SAFETY: `node` is a valid pointer to a `T` that outlives all list
        // operations (caller contract).
        let link = unsafe { (*node).next_link() };
        loop {
            let old = self.head.load(Ordering::Acquire);
            link.store(old, Ordering::Relaxed);
            if self
                .head
                .compare_exchange(old, node, Ordering::AcqRel, Ordering::Acquire)
                .is_ok()
            {
                return;
            }
        }
    }

    /// Atomically take the whole stack, leaving it empty, returning the head
    /// (null if empty). Faithful port of `xlist_splice` (`schbench.c:884`): an
    /// atomic swap to null (the single-op equivalent of schbench's CAS loop).
    /// The returned chain is walked via [`Linked::next_link`] in LIFO order
    /// (schbench's thread-list splice does not reverse; wake order is
    /// irrelevant).
    fn splice(&self) -> *mut T {
        self.head.swap(ptr::null_mut(), Ordering::AcqRel)
    }

    /// Atomically take the whole stack and return the chain in FIFO (insertion)
    /// order -- the reverse of [`splice`](Self::splice)'s LIFO. Faithful port of
    /// `request_splice` (`schbench.c:920-940`): swap to null, then reverse the
    /// spliced chain so the consumer services the oldest queued request first
    /// (request order matters, unlike the thread wait-list). Returns null if
    /// empty. Single-consumer: only the owning worker drains its own queue.
    fn splice_reversed(&self) -> *mut T {
        let mut cur = self.head.swap(ptr::null_mut(), Ordering::AcqRel);
        let mut rev: *mut T = ptr::null_mut();
        while !cur.is_null() {
            // SAFETY: `cur` is a node that was published on the stack and whose
            // storage the caller owns; the single consumer reverses the chain it
            // just took exclusive ownership of, so no other thread touches these
            // links concurrently.
            let link = unsafe { (*cur).next_link() };
            let next = link.load(Ordering::Acquire);
            link.store(rev, Ordering::Relaxed);
            rev = cur;
            cur = next;
        }
        rev
    }
}

/// A queued work request in RPS-injector mode (`schbench.c` `struct request`,
/// `:757`). The RPS thread heap-allocates one (`Box`) per request and links it
/// onto the target worker's queue; the worker drains its queue and frees each --
/// matching schbench's per-request malloc/free (part of the syscall profile the
/// standalone validation compares). schbench's `start_time` field is set
/// (`:951`) but never read, so it is omitted: the wakeup latency is measured
/// from the worker's `wake_time` (stamped by the RPS thread at enqueue, and by
/// the worker itself before it blocks -- see [`rps_wait`]) and the request
/// latency from a local work-start -- neither reads `start_time`.
/// Omitting it also drops schbench's per-request `gettimeofday` for that dead
/// field (`:951`) -- one fewer clock syscall per request, consistent with the
/// engine's `CLOCK_MONOTONIC`-vs-`gettimeofday` clock divergence.
struct Request {
    /// Intrusive link for the per-worker request [`TreiberStack`].
    next: AtomicPtr<Request>,
}

impl Request {
    fn new() -> Self {
        Self {
            next: AtomicPtr::new(ptr::null_mut()),
        }
    }
}

impl Linked for Request {
    fn next_link(&self) -> &AtomicPtr<Self> {
        &self.next
    }
}

/// Declarative config for the [`Schbench`](crate::workload::WorkType::Schbench)
/// workload. Construct via [`SchbenchConfig::default`] (schbench's own
/// defaults) plus the chainable setters, e.g.
/// `SchbenchConfig::default().message_threads(2).worker_threads(4)`. Derives
/// Clone/Debug/PartialEq/Eq/Hash/serde; the builder shape follows
/// [`WorkloadConfig`](crate::workload::WorkloadConfig), but `Eq`+`Hash` (which
/// `WorkloadConfig` and `WorkSpec` omit because of their transitive `f64`) are
/// available here since every field is integer/bool -- the ktstr f64-free
/// leaf-config convention.
///
/// # schbench(8) CLI parity
///
/// This port re-expresses schbench's default (matrix-work) mode natively, so
/// its tunables are config fields and topology rather than CLI flags. The
/// mapping to schbench's option table (`schbench.c:138-187`):
///
/// | schbench flag | ktstr |
/// |---|---|
/// | `-m` message-threads | `message_threads` |
/// | `-t` threads | `worker_threads` (workers per message thread; `0` = `ceil(cpuset_cpus / message_threads)`, see below) |
/// | `-F` cache_footprint | `cache_footprint_kib` |
/// | `-n` operations | `operations` |
/// | `-s` sleep_usec | `sleep_usec` |
/// | `-L` no-locking | `skip_locking` |
/// | `-R` rps | `requests_per_sec` |
/// | `-A` auto-rps | `auto_rps` |
/// | `--split` (long-only) | `split_percent` (`None` = no split, all-private) |
/// | `-p` pipe (also `--pipe`) | `pipe_transfer_bytes` (`0` = off; memory-transfer mode, no matrix work) |
/// | `-r` runtime | in-VM: the scenario engine's run window (the engine runs until `stop`); host-side: the `run_secs` argument to [`run_standalone`](crate::workload::run_standalone) |
///
/// ## Set by ktstr topology, not a flag
///
/// - `-t` default: with `worker_threads = 0`, ktstr matches schbench's `-t`
///   0-default -- it divides the CPU count across the message threads,
///   `ceil(cpus / message_threads)` per thread (`schbench.c:1849-1852`), so the
///   total worker count stays near the CPU count. ktstr scopes "cpus" to the
///   allocated guest cpuset (the worker's `sched_getaffinity` mask, set by the
///   scenario's topology / `CgroupDef`) rather than schbench's `get_nprocs`, so
///   the total is ≈ the cpuset's CPU count. An explicit non-zero `worker_threads`
///   is workers-per-message-thread in both.
/// - `-M` (message-cpus) / `-W` (worker-cpus) thread pinning: ktstr places
///   threads through its affinity / cpuset layer, so there is deliberately no
///   per-thread-pin knob.
///
/// ## Observability flags -> the metric API
///
/// schbench's `-w` (warmuptime), `-i` (intervaltime), `-z` (zerotime), `-j`
/// (json), and `-J` (jobname) shape its streaming stderr/JSON report. ktstr's
/// numbers flow through the metric API instead -- per-phase attribution and the
/// sidecar -- so these have no flag equivalent. `ktstr-schbench-validate`
/// reproduces schbench's stderr-table shape for a side-by-side comparison.
///
/// ## Split mode (`--split`)
///
/// `Some(p)` partitions `cache_footprint_kib` into a per-thread private matrix
/// (`p`%) and ONE process-global shared matrix (`100-p`%) that every worker
/// multiplies into concurrently, reproducing schbench's cross-core
/// shared-working-set cache contention (`schbench.c:1390-1404`, `:1858-1863`).
/// ktstr models the shared matrix with `AtomicU64` `Relaxed` accesses. Like
/// schbench's emitted code, the shared kernel keeps the running sum in a register
/// and STORES it to each shared C cell on every inner (`k`) iteration -- C is
/// write-only in the loop (A and B are loaded each `k`, C is never reloaded), and
/// that per-k store is what generates the contention. Both gcc and clang keep the
/// per-k store: `do_some_math` reads `m1`/`m2`/`m3` as offsets into one base
/// pointer, so neither can prove the `m3` store doesn't alias the next `k`'s
/// `m1`/`m2` loads. On x86-64 a `Relaxed` load/store lowers to a plain `MOV` (no
/// `LOCK`), so the contention is identical to schbench's plain shared-memory race
/// -- but sound (atomics, no data race), with zero unsafe. `None` (default) is
/// the legacy all-private single matrix.
///
/// ## Pipe mode (`-p`)
///
/// `pipe_transfer_bytes > 0` REPLACES the matrix workload with schbench's
/// memory-transfer simulation (`schbench.c:177`, `pipe_test`). It rides the
/// message-handshake path: the message thread memsets each woken worker's
/// per-thread page to `1` (`schbench.c:980-981`) and the worker memsets its own
/// page to `2` before blocking (`schbench.c:1003-1004`), `pipe_transfer_bytes`
/// bytes each per handshake cycle (clamped to 1 MiB, `PIPE_TRANSFER_BUFFER`).
/// `do_work` and the think-sleep are skipped (`schbench.c:1448`), so the only
/// per-cycle work is the wakeup handshake + the two memsets; the report is the
/// PER-WORKER memory-transfer throughput (`avg worker transfer` = the aggregate
/// rate divided by the worker count, `schbench.c:1697,1942-1943,1979`) alongside
/// the wakeup-latency table, not request latency.
///
/// ktstr does NOT compose `-p` with `-R`: in pipe mode it always runs the
/// message-handshake waker (so BOTH pipe memsets fire — a full transfer) and never
/// starts the RPS injector. schbench instead COMPOSES them, half-broken: it has no
/// precedence (`-R` alone picks the waker, `schbench.c:1594`), so `-p -R` runs the
/// RPS injector while the worker-side memset still fires unconditionally
/// (`schbench.c:1003-1004`) but the waker-side memset — which lives only in
/// `xlist_wake_all` (`schbench.c:980-981`) — does not, yielding a degenerate
/// half-pipe. ktstr's full pipe is the more faithful `-p` behavior; the realistic
/// use is `-p` without `-R`. schbench also zeroes warmuptime in pipe mode
/// (`schbench.c:296`); ktstr has no warmuptime concept, so that is a no-op here.
///
/// ## Modes not ported
///
/// - `-C` (calibrate): a tuning aid that times schbench's own work loop and
///   forces `-L` (`schbench.c:166`, `:389`). Intentionally out of scope -- ktstr
///   measures through the metric path.
#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct SchbenchConfig {
    /// Number of message threads (`schbench.c` `-m`, default 1).
    pub message_threads: usize,
    /// Worker threads per message thread (`schbench.c` `-t`). 0 resolves to
    /// `ceil(cpuset_cpus / message_threads)` -- the CPU count of the allocated
    /// guest cpuset (the worker's `sched_getaffinity` mask, per ruling) divided
    /// across the message threads, matching schbench's 0-default
    /// (`schbench.c:1849-1852`) scoped to the cpuset rather than `get_nprocs`.
    /// See `resolve_worker_count` and the CLI-parity section above.
    pub worker_threads: usize,
    /// Per-worker matrix cache footprint in KiB (`schbench.c` `-F`, default
    /// 256); sets the matrix dimension.
    pub cache_footprint_kib: usize,
    /// Matrix multiplications per work cycle (`schbench.c` `-n`, default 5).
    pub operations: usize,
    /// Think-time sleep before the matrix work, microseconds (`schbench.c`
    /// `-s`, default 100); simulates networking. 0 disables.
    pub sleep_usec: u64,
    /// Skip the per-CPU lock around the matrix work (`schbench.c` `-L`,
    /// default false: locking on).
    pub skip_locking: bool,
    /// Fixed request rate, requests/second (`schbench.c` `-R`, default 0 = off).
    /// 0 selects the default message-handshake mode (each worker is woken by its
    /// message thread); non-zero switches to the RPS-injector mode, where a
    /// dedicated thread enqueues `requests_per_sec` requests/second round-robin
    /// across the workers (`schbench.c` `run_rps_thread`, `:1258`).
    pub requests_per_sec: usize,
    /// Auto-RPS target CPU-busy percentage (`schbench.c` `-A`, default 0 = off).
    /// Non-zero turns on closed-loop rate control: a once-per-second control
    /// thread grows/shrinks the live request rate toward this host-busy% target
    /// (`schbench.c` `auto_scale_rps`, `:1180`). Setting it seeds the rate to 10
    /// when `requests_per_sec` is 0 (`schbench.c:286`), so auto-RPS starts low
    /// and climbs.
    pub auto_rps: usize,
    /// Percent of the cache footprint that is PRIVATE per worker thread
    /// (`schbench.c` `--split`, long-only, 0-100). `None` = no split:
    /// schbench's legacy all-private single matrix (`schbench.c:1405-1408`,
    /// `:1879-1880`). `Some(p)` partitions `cache_footprint_kib` into a
    /// per-thread private matrix (`p`%) and ONE process-global shared matrix
    /// (`100-p`%) that every worker multiplies into concurrently, reproducing
    /// schbench's cross-core shared-working-set cache contention
    /// (`schbench.c:1390-1404`, `:1858-1863`). `Some(0)` = all shared,
    /// `Some(100)` = all private (same matrix sizes as `None`, but routed
    /// through the split branch, matching schbench's `split_specified` path).
    /// Out-of-range `Some(p > 100)` panics when the engine consumes it
    /// (`schbench.c:362-365` exits on the same); the builder also debug-asserts
    /// the bound.
    pub split_percent: Option<usize>,
    /// Pipe-mode transfer size in bytes (`schbench.c` `-p`/`--pipe`, default 0 =
    /// off, clamped to 1 MiB `PIPE_TRANSFER_BUFFER`). Non-zero REPLACES the
    /// matrix workload with schbench's memory-transfer simulation: the message
    /// thread memsets each woken worker's per-thread page to `1` and the worker
    /// memsets its own page to `2` (`schbench.c:980-981`/`:1003-1004`),
    /// `pipe_transfer_bytes` bytes each per cycle, while `do_work` + the
    /// think-sleep are skipped (`schbench.c:1448`). Reports PER-WORKER
    /// memory-transfer throughput (`avg worker transfer`) rather than request
    /// latency.
    pub pipe_transfer_bytes: usize,
}

/// schbench's `PIPE_TRANSFER_BUFFER` (`schbench.c:41`): the per-thread pipe page
/// is 1 MiB; a larger `pipe_transfer_bytes` is clamped to it (schbench clamps and
/// warns, `schbench.c:291-294`).
pub(crate) const PIPE_TRANSFER_BUFFER: usize = 1024 * 1024;

/// Cap on a single RPS-injector pacing sleep (ns). `run_rps_thread` runs
/// synchronously on the message thread that `run` joins on shutdown, so an
/// uncapped paced sleep would delay teardown by up to a full inter-arrival
/// interval — at a degenerate low `requests_per_sec` (interval = 1s / rps) that
/// can exceed the scenario cleanup/watchdog budget. Capping each sleep at this
/// quantum re-checks `stop` at least this often, bounding shutdown latency. At
/// realistic rates the interval is well under this, so a single uncapped wait
/// results — the cap only engages at pathological low rates. (Schbench-local; the
/// sibling taobench engine has its own equivalent, both with self-contained
/// `monotonic_nanos`.)
const STOP_POLL_QUANTUM_NS: u64 = 50_000_000;

/// schbench's pipe-mode (`-p`) throughput summary — the `avg worker transfer`
/// line (`schbench.c:1979-1982`): the per-worker transfer rate as ops/sec plus
/// the pretty-scaled bytes/sec. Built by [`pipe_transfer_report`].
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PipeTransferReport {
    /// Completed transfer cycles per second, PER WORKER (`loop_count / Σ worker
    /// runtimes`, `schbench.c:1942-1943`).
    pub ops_per_sec: f64,
    /// Bytes transferred per second PER WORKER, scaled into [`unit`](Self::unit)
    /// (÷1024 per step, `pretty_size`).
    pub scaled: f64,
    /// The unit `scaled` is expressed in: `B`/`KB`/`MB`/`GB`/`TB`/`PB`/`EB`
    /// (`schbench.c:1606`).
    pub unit: &'static str,
}

/// Derive the pipe-mode `avg worker transfer` line from a run's aggregate
/// `achieved_rps` (completed cycles/sec over the true elapsed window), the
/// requested `pipe_transfer_bytes`, and the resolved `nr_workers`. The figure is
/// PER WORKER: schbench divides by `loop_runtime` = Σ each worker's runtime
/// (`schbench.c:1697` sums `worker->runtime`; `:1942-1943`/`:1979` divide by it),
/// and `Σ worker runtimes ≈ nr_workers * elapsed`, so the per-worker rate is the
/// aggregate `achieved_rps / nr_workers` — the label is literally "avg WORKER
/// transfer". (Dividing the aggregate by wall-clock alone would over-report by
/// `nr_workers`×.) The transfer size is CLAMPED to `PIPE_TRANSFER_BUFFER` first —
/// the engine moves only the clamped size per cycle (`run` applies the same
/// `.min()`), matching schbench's parse-time clamp (`schbench.c:291-294`) — so the
/// throughput reflects the bytes ACTUALLY moved. Scaling is schbench's
/// `pretty_size` (`schbench.c:1606`). `nr_workers` is floored at 1 (no division by
/// zero).
pub fn pipe_transfer_report(
    achieved_rps: f64,
    pipe_transfer_bytes: usize,
    nr_workers: usize,
) -> PipeTransferReport {
    let n = nr_workers.max(1) as f64;
    let bytes = pipe_transfer_bytes.min(PIPE_TRANSFER_BUFFER);
    // PER-WORKER: schbench's loops_per_sec/mb_per_sec divide the aggregate by
    // Σ worker runtimes (≈ nr_workers * elapsed), i.e. `achieved_rps / nr_workers`.
    let ops_per_sec = achieved_rps / n;
    let bytes_per_sec = achieved_rps * bytes as f64 / n;
    let (scaled, unit) = pretty_size(bytes_per_sec);
    PipeTransferReport {
        ops_per_sec,
        scaled,
        unit,
    }
}

/// schbench's `pretty_size` (`schbench.c:1606-1620`): scale a byte count by 1024
/// into B/KB/MB/GB/TB/PB/EB. Stops at the last unit (matches schbench's
/// `units[divs + 1] == NULL` break — never overflows the table).
fn pretty_size(mut number: f64) -> (f64, &'static str) {
    const UNITS: [&str; 7] = ["B", "KB", "MB", "GB", "TB", "PB", "EB"];
    let mut divs = 0;
    while number >= 1024.0 && divs + 1 < UNITS.len() {
        divs += 1;
        number /= 1024.0;
    }
    (number, UNITS[divs])
}

impl Default for SchbenchConfig {
    fn default() -> Self {
        // schbench defaults (schbench.c option table + globals).
        Self {
            message_threads: 1,
            worker_threads: 0,
            cache_footprint_kib: 256,
            operations: 5,
            sleep_usec: 100,
            skip_locking: false,
            requests_per_sec: 0,
            auto_rps: 0,
            split_percent: None,
            pipe_transfer_bytes: 0,
        }
    }
}

impl SchbenchConfig {
    /// Set the number of message threads (schbench `-m`).
    #[must_use = "builder methods consume self; bind the result"]
    pub fn message_threads(mut self, n: usize) -> Self {
        self.message_threads = n;
        self
    }
    /// Set worker threads per message thread (schbench `-t`); 0 = one per
    /// allocated CPU.
    #[must_use = "builder methods consume self; bind the result"]
    pub fn worker_threads(mut self, n: usize) -> Self {
        self.worker_threads = n;
        self
    }
    /// Set the per-worker matrix cache footprint in KiB (schbench `-F`).
    #[must_use = "builder methods consume self; bind the result"]
    pub fn cache_footprint_kib(mut self, kib: usize) -> Self {
        self.cache_footprint_kib = kib;
        self
    }
    /// Set the matrix multiplications per work cycle (schbench `-n`).
    #[must_use = "builder methods consume self; bind the result"]
    pub fn operations(mut self, n: usize) -> Self {
        self.operations = n;
        self
    }
    /// Set the think-time sleep in microseconds (schbench `-s`); 0 disables.
    #[must_use = "builder methods consume self; bind the result"]
    pub fn sleep_usec(mut self, usec: u64) -> Self {
        self.sleep_usec = usec;
        self
    }
    /// Skip the per-CPU lock around the matrix work (schbench `-L`).
    #[must_use = "builder methods consume self; bind the result"]
    pub fn skip_locking(mut self, skip: bool) -> Self {
        self.skip_locking = skip;
        self
    }
    /// Set the fixed request rate in requests/second (schbench `-R`); 0 selects
    /// the default message-handshake mode, non-zero the RPS-injector mode.
    #[must_use = "builder methods consume self; bind the result"]
    pub fn requests_per_sec(mut self, rps: usize) -> Self {
        self.requests_per_sec = rps;
        self
    }
    /// Set the auto-RPS target host-busy percentage (schbench `-A`); 0 disables
    /// auto-scaling. Non-zero seeds the rate to 10 when `requests_per_sec` is 0.
    #[must_use = "builder methods consume self; bind the result"]
    pub fn auto_rps(mut self, target_pct: usize) -> Self {
        self.auto_rps = target_pct;
        self
    }
    /// Set the private/shared cache-footprint split percentage (schbench
    /// `--split`); `None` (default) = no split, all-private single matrix.
    /// `Some(p)` requires `p <= 100`: the builder debug-asserts it for early
    /// feedback, and the engine hard-panics on an out-of-range value at the
    /// consumption boundary in `run` -- the analog of schbench exiting on a bad
    /// `--split` (`schbench.c:362-365`).
    #[must_use = "builder methods consume self; bind the result"]
    pub fn split_percent(mut self, percent: Option<usize>) -> Self {
        debug_assert!(
            percent.is_none_or(|p| p <= 100),
            "split_percent must be 0..=100"
        );
        self.split_percent = percent;
        self
    }
    /// Set the pipe-mode transfer size in bytes (schbench `-p`/`--pipe`); 0
    /// (default) = off (the matrix workload). Non-zero switches to schbench's
    /// memory-transfer simulation; values above 1 MiB (`PIPE_TRANSFER_BUFFER`)
    /// are clamped when the engine consumes it (schbench clamps the same,
    /// `schbench.c:291-294`).
    #[must_use = "builder methods consume self; bind the result"]
    pub fn pipe_transfer_bytes(mut self, bytes: usize) -> Self {
        self.pipe_transfer_bytes = bytes;
        self
    }

    /// Matrix dimension from the cache footprint, identical to schbench
    /// (`schbench.c:1880`: `sqrt(cache_footprint_kb * 1024 / 3 /
    /// sizeof(unsigned long))`) and to ktstr's `FanOutCompute` precompute. Zero
    /// `operations` or `cache_footprint_kib` yields a 0 dimension (no matrix
    /// work).
    pub(crate) fn matrix_size(&self) -> usize {
        if self.operations > 0 && self.cache_footprint_kib > 0 {
            ((self.cache_footprint_kib * 1024 / 3 / core::mem::size_of::<u64>()) as f64).sqrt()
                as usize
        } else {
            0
        }
    }

    /// Shared-matrix dimension for `--split` (`schbench.c:1859,1862`:
    /// `sqrt(cache_footprint_kb*(100-split)/100 * 1024/3/sizeof(ulong))`).
    /// `None` split (or 0 `operations`/`cache_footprint_kib`) => 0 (no shared
    /// matrix); `Some(100)` => 0 (all private, no shared work).
    pub(crate) fn shared_matrix_size(&self) -> usize {
        match self.split_percent {
            Some(p) if self.operations > 0 && self.cache_footprint_kib > 0 => {
                let shared_kb = self.cache_footprint_kib * (100 - p) / 100;
                ((shared_kb * 1024 / 3 / core::mem::size_of::<u64>()) as f64).sqrt() as usize
            }
            _ => 0,
        }
    }

    /// Private (per-worker) matrix dimension. `Some(p)` (`schbench.c:1860,1863`):
    /// `sqrt(cache_footprint_kb*split/100 * 1024/3/sizeof(ulong))`. `None`
    /// (legacy): the full-footprint [`matrix_size`](Self::matrix_size)
    /// (`schbench.c:1880`, the all-private single matrix).
    pub(crate) fn private_matrix_size(&self) -> usize {
        match self.split_percent {
            Some(p) if self.operations > 0 && self.cache_footprint_kib > 0 => {
                let private_kb = self.cache_footprint_kib * p / 100;
                ((private_kb * 1024 / 3 / core::mem::size_of::<u64>()) as f64).sqrt() as usize
            }
            _ => self.matrix_size(),
        }
    }

    /// The effective TOTAL request rate after schbench's startup seed: the user's
    /// `requests_per_sec`, or 10 when auto-RPS is on and no fixed rate was given
    /// (`schbench.c:286`: auto-RPS starts at 10 and climbs toward its target).
    /// Both the mode gate ([`rps_per_message_thread`](Self::rps_per_message_thread))
    /// and the live auto-scaled injection rate seed from this.
    pub(crate) fn normalized_total_rps(&self) -> usize {
        if self.auto_rps != 0 && self.requests_per_sec == 0 {
            10
        } else {
            self.requests_per_sec
        }
    }

    /// Per-message-thread request rate driving the RPS-vs-default MODE GATE: the
    /// normalized total ([`normalized_total_rps`](Self::normalized_total_rps))
    /// divided across the message threads, each of which runs its own RPS thread.
    /// schbench divides once at startup (`requests_per_sec /= message_threads`,
    /// `schbench.c:1899`) BEFORE the per-thread mode gate (`if (requests_per_sec)`,
    /// `schbench.c:1594`), so the gate sees the divided value: a total below the
    /// message-thread count rounds to 0 and the thread runs the DEFAULT
    /// message-handshake mode (not a zero-rate RPS). The gate is decided ONCE from
    /// this seeded value; the actual injection rate is the live auto-scaled total
    /// (which may climb above the seed). Integer division drops the remainder,
    /// matching schbench -- so auto-RPS with more than 10 message threads (seed
    /// 10 / m = 0) degenerates to default mode, exactly as schbench does.
    pub(crate) fn rps_per_message_thread(&self) -> usize {
        self.normalized_total_rps() / self.message_threads.max(1)
    }

    /// Effective per-message-thread worker count for [`run`]. A non-zero
    /// `worker_threads` is workers-per-message-thread as-is; the `0` default
    /// mirrors schbench's `-t` 0-default (`schbench.c:1849-1852`:
    /// `worker_threads = (num_cpus + message_threads - 1) / message_threads`)
    /// but over the allocated guest cpuset (`allowed_count` from
    /// [`resolve_cpu_topology`], per the ktstr cpuset ruling) instead of
    /// `get_nprocs`: it divides the cpuset CPU count across the message threads
    /// (ceil), so the TOTAL worker count (`message_threads * this`) stays ≈ the
    /// cpuset CPU count -- matching schbench's total ≈ `num_cpus`.
    /// `message_threads` floors at 1 to avoid a zero divisor; the divide is a
    /// no-op at the default `message_threads = 1`.
    pub(crate) fn resolve_worker_count(&self, allowed_count: usize) -> usize {
        if self.worker_threads != 0 {
            return self.worker_threads;
        }
        allowed_count.div_ceil(self.message_threads.max(1))
    }
}

/// Per-thread state, the Rust counterpart of schbench's `struct thread_data`
/// (`schbench.c:766`). Shared across threads by raw pointer for the lockless
/// wait-list, so cross-thread access is restricted to the atomic fields
/// (`next`, `futex`, `wake_time`); the histogram fields are owned solely by the
/// worker thread the `ThreadData` belongs to.
pub(crate) struct ThreadData {
    /// Treiber-stack link for the message thread's wait-list
    /// (schbench `thread_data->next`, `:776`). Null when not queued.
    next: AtomicPtr<ThreadData>,
    /// Wake handshake futex (`schbench.c` `thread_data->futex`, `:791`).
    futex: super::handshake::Handshake,
    /// Monotonic-ns timestamp the waker stamps just before posting, so the
    /// woken thread can measure scheduler wakeup latency (schbench `wake_time`,
    /// `:788`, stamped in `xlist_wake_all` `:984`).
    wake_time: AtomicU64,
    /// Wakeup-latency histogram (`schbench.c` `wakeup_stats`, `:794`).
    /// Owner-thread-only; see the `Sync` impl SAFETY note.
    wakeup_stats: UnsafeCell<PlatStats>,
    /// Request (work-cycle) latency histogram (`schbench.c` `request_stats`,
    /// `:795`). Owner-thread-only.
    request_stats: UnsafeCell<PlatStats>,
    /// Mean per-schedule run-queue wait (ns), read from `/proc/<tid>/schedstat`
    /// at thread exit (schbench's `read_sched_delay`, `:1118`). Owner-only.
    /// Feeds the WHOLE-RUN `SchbenchResult` (mean-of-means, matching real
    /// schbench for the side-by-side validation); the per-phase delay comes from
    /// [`Self::phase_snapshots`] instead.
    sched_delay_ns: UnsafeCell<u64>,
    /// Per-phase snapshots this thread accumulated via drain-on-change against
    /// the shared `phase_epoch` (one [`PhaseSnapshot`] per phase the thread did
    /// work in, plus a final in-flight phase at exit). Owner-thread-only,
    /// drained by the main thread after join — same happens-before as the
    /// histogram cells. Empty when run non-phasic (`phase_epoch == None`) until
    /// the single end-of-run drain.
    phase_snapshots: UnsafeCell<Vec<PhaseSnapshot>>,
    /// RPS-injector mode: this worker's pending request queue. The RPS thread
    /// pushes heap-allocated [`Request`]s (round-robin across workers); the
    /// owning worker drains via `splice_reversed` (FIFO) and frees each. Unused
    /// in the default message-handshake mode (`requests_per_sec == 0`).
    requests: TreiberStack<Request>,
    /// RPS-injector backpressure counter (`schbench.c` `thread_data->pending`,
    /// `:779`): incremented by the RPS thread per enqueue, reset to 0 by the
    /// owning worker at each splice. The RPS thread stops enqueuing to a worker
    /// whose `pending` exceeds the batch cap (`:1284`).
    pending: AtomicU64,
    /// Pipe-mode (`-p`) transfer size in bytes (0 = not pipe mode); the length of
    /// [`Self::pipe_page`] and the per-cycle memset. Plain `Copy`, set once at
    /// construction, read-only thereafter.
    pipe_bytes: usize,
    /// Pipe-mode per-thread transfer page (`schbench.c` `thread_data->pipe_page`,
    /// `:801`). The worker memsets it to `2` before blocking and the waker memsets
    /// it to `1` when waking the worker; the bytes are never read back (the point
    /// is the memory-transfer cost). `UnsafeCell` because the WAKER writes the
    /// worker's page (cross-thread) -- see the `Sync` SAFETY note for the
    /// wait-list/futex ordering that keeps it race-free. Empty unless pipe mode.
    pipe_page: UnsafeCell<Box<[u8]>>,
}

/// One thread's latency + run-queue-delay accumulation over a single phase
/// (the window between two `phase_epoch` transitions). Built on the owning
/// thread at each drain-on-change boundary; the histograms are `take`n from the
/// live cells (snapshot-and-reset) and the run-delay is a RAW
/// `/proc/<tid>/schedstat` `(run_delay, pcount)` DELTA over the phase, so the
/// host re-derives the per-phase mean as `Σrun_delay / Σpcount` (the
/// sample-weighted pooled mean — a deliberate divergence from schbench's
/// whole-run mean-of-means, correct for a heterogeneous per-phase thread set).
/// Message threads leave `wakeup`/`request` empty and `loop_count` 0 (they
/// carry only run-delay); workers fill all fields.
struct PhaseSnapshot {
    /// The `phase_epoch` value active while this snapshot's samples were
    /// recorded (NOT the value at drain time). `0` = BASELINE, `u32::MAX` =
    /// inter-step gap; both are emitted as-is and discarded host-side.
    epoch: u32,
    wakeup: PlatStats,
    request: PlatStats,
    /// `/proc/<tid>/schedstat` field 2 (run_delay ns) delta over this phase.
    run_delay_ns: u64,
    /// `/proc/<tid>/schedstat` field 3 (pcount = timeslices) delta over this
    /// phase. The host guards `pcount == 0` → metric absent (never a div-by-zero
    /// 0), matching [`mean_sched_delay`].
    pcount: u64,
    /// Completed work cycles this worker ran in this phase (0 for the message
    /// thread).
    loop_count: u64,
}

/// Per-phase, cross-thread aggregate for one `phase_epoch`: the wakeup + request
/// histograms merged across every worker that ran in the phase, plus the
/// run-delay raw pairs split by thread class (message vs worker). The per-phase
/// wire carrier — rides inside [`crate::workload::PhaseSlice`] guest→host. All
/// fields are integer, so the host re-pools across workers/cgroups by
/// [`PlatStats::combine`] (histogram add) + integer sums; percentiles are
/// re-derived from the merged histogram, NEVER averaged.
///
/// ESTIMATOR NOTE: the per-phase run-delay mean the host derives from
/// `*_run_delay_ns / *_pcount` is SAMPLE-WEIGHTED (`Σrun_delay / Σpcount`), a
/// DIFFERENT estimator from the whole-run [`SchbenchResult`]'s `sched_delay_*`,
/// which keeps schbench's mean-of-per-thread-means (`collect_sched_delay`) for
/// schbench parity. They measure different things by design — never cross-compare a
/// per-phase value against a whole-run threshold (or vice-versa). `pcount == 0`
/// for a class means that class was never scheduled in the phase → the host
/// emits the metric as ABSENT, not `0`.
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub(crate) struct SchbenchPhaseStats {
    /// Wakeup-latency histogram merged across the phase's workers.
    pub(crate) wakeup: PlatStats,
    /// Request-latency histogram merged across the phase's workers.
    pub(crate) request: PlatStats,
    /// Per-phase achieved-RPS distribution: the control thread's per-second
    /// samples attributed to this phase's epoch (the whole-run rps is unchanged;
    /// this is purely additive). A 1s sample straddling an epoch boundary lands
    /// wholly in the sample-time epoch (bounded fuzz, <=1 sample/transition, like
    /// the msg-thread late-drain). Empty for a phase shorter than the ~1s control
    /// cadence -- the host gates on `sample_count() > 0` so a sub-second phase
    /// reads ABSENT, never rps=0. A rate; re-derived from the merged histogram.
    pub(crate) rps: PlatStats,
    /// Σ message-thread `run_delay` (ns) over the phase.
    pub(crate) msg_run_delay_ns: u64,
    /// Σ message-thread `pcount` over the phase (the mean's denominator).
    pub(crate) msg_pcount: u64,
    /// Σ worker `run_delay` (ns) over the phase.
    pub(crate) worker_run_delay_ns: u64,
    /// Σ worker `pcount` over the phase (the mean's denominator).
    pub(crate) worker_pcount: u64,
    /// Σ completed work cycles across the phase's workers.
    pub(crate) loop_count: u64,
}

impl SchbenchPhaseStats {
    /// Merge `other` into `self`: combine the wakeup + request histograms
    /// (bucket-count addition, [`PlatStats::combine`]) and integer-add the
    /// run-delay raw pairs + loop_count. Associative AND commutative (combine +
    /// `saturating_add` both are), so pooling is order-independent — the SAME
    /// operation whether pooling per-engine across message threads (in [`run`])
    /// or per-cgroup across workers host-side
    /// (`crate::assert::PhaseCgroupStats::merge`). Percentiles are NEVER
    /// merged; the merged histogram is re-derived to percentiles by the reader.
    pub(crate) fn merge(&mut self, other: &SchbenchPhaseStats) {
        self.wakeup.combine(&other.wakeup);
        self.request.combine(&other.request);
        self.rps.combine(&other.rps);
        self.msg_run_delay_ns = self.msg_run_delay_ns.saturating_add(other.msg_run_delay_ns);
        self.msg_pcount = self.msg_pcount.saturating_add(other.msg_pcount);
        self.worker_run_delay_ns = self
            .worker_run_delay_ns
            .saturating_add(other.worker_run_delay_ns);
        self.worker_pcount = self.worker_pcount.saturating_add(other.worker_pcount);
        self.loop_count = self.loop_count.saturating_add(other.loop_count);
    }
}

impl Linked for ThreadData {
    fn next_link(&self) -> &AtomicPtr<Self> {
        &self.next
    }
}

// SAFETY: `ThreadData` is shared across threads only via the lockless wait-list
// and (in RPS mode) the per-worker request queue, whose operations touch
// exclusively the atomic fields (`next`, `futex`, `wake_time`, `requests`,
// `pending`) -- all internally synchronized. `requests` is single-producer /
// single-consumer: the RPS thread is the only pusher to a given worker's queue
// (round-robin assignment) and the owning worker is the only drainer, with the
// push's Release CAS / drain's Acquire swap establishing happens-before;
// `pending` is plain atomic counting. The `UnsafeCell` fields (`wakeup_stats`,
// `request_stats`, `sched_delay_ns`, `phase_snapshots`) are written and read
// ONLY by the single worker thread that owns this `ThreadData`, and only the
// main thread reads/drains them after all workers have joined (a happens-before
// via the join). The per-phase drain (`worker_loop` / `run_msg_thread`) `take`s
// and pushes into the owning thread's own cells, never another's. The `pipe_page`
// cell (pipe mode) is the one cross-thread `UnsafeCell`: the owning worker memsets
// it (to 2) and the message thread memsets it (to 1). They never overlap, via two
// happens-before edges:
//   - SAME cycle (worker fill-2 vs waker fill-1): the worker's fill-2 is SEQUENCED
//     BEFORE its `wait_list.add` (Release CAS), and the waker's `splice` (Acquire
//     swap) observes that add, giving fill-2 -> add -> splice -> waker fill-1. The
//     load-bearing fact is "fill-2 precedes add", which holds UNCONDITIONALLY --
//     including the no-block shutdown path (stop set, worker skips the futex wait):
//     the worker still added itself after fill-2, so a waker that then splices and
//     fills it is ordered after the fill-2, and the worker's exit path never
//     touches the page again. Blocking is NOT what makes it safe.
//   - NEXT cycle (worker's fill-2 vs the waker's prior fill-1): the futex
//     post/consume CAS (SeqCst, `Handshake`) orders waker fill-1 -> post -> the
//     worker's consume -> its next fill-2; the worker cannot reach its next fill-2
//     until it consumes the token the waker published after fill-1.
// So the two threads never touch `pipe_page` concurrently. No two threads ever
// touch a cell concurrently, so sharing `&ThreadData` across threads is sound.
unsafe impl Sync for ThreadData {}

impl ThreadData {
    fn new(pipe_bytes: usize) -> Self {
        Self {
            next: AtomicPtr::new(ptr::null_mut()),
            futex: super::handshake::Handshake::new(),
            wake_time: AtomicU64::new(0),
            wakeup_stats: UnsafeCell::new(PlatStats::default()),
            request_stats: UnsafeCell::new(PlatStats::default()),
            sched_delay_ns: UnsafeCell::new(0),
            phase_snapshots: UnsafeCell::new(Vec::new()),
            requests: TreiberStack::new(),
            pending: AtomicU64::new(0),
            pipe_bytes,
            pipe_page: UnsafeCell::new(vec![0u8; pipe_bytes].into_boxed_slice()),
        }
    }

    /// Pipe-mode transfer: fill this thread's `pipe_page` (`pipe_bytes` bytes) with
    /// `val`. The page is never read back, so the fill would be DCE-eligible. The
    /// trailing `black_box(page.as_ptr())` ESCAPES THE ADDRESS (not a loaded value):
    /// the optimizer must then assume the opaque call may read through the pointer,
    /// so every byte of the fill must be in place at that point -- the fill is
    /// retained. This is stronger than `black_box(value)`, which only keeps a value
    /// live and (per the optimization-resistance note in `worker::worker_main`) does
    /// NOT by itself stop a backing STORE from being elided -- which is why the
    /// cache-pressure workloads there use `write_volatile`. Here the faithful
    /// schbench op is a memset (`schbench.c:980-981`/`:1003-1004`) and `slice::fill`
    /// lowers to one; a `write_volatile` byte loop would not. The memory traffic IS
    /// the workload.
    ///
    /// SAFETY: the caller guarantees no concurrent access to `pipe_page`. The only
    /// callers are the owning worker (before it adds itself to the wait-list) and
    /// the message thread (after it splices the worker off the wait-list); the
    /// load-bearing edge is fill-2-before-add (holding even on the no-block
    /// shutdown path), so the add/splice + futex handshake order these with no
    /// overlap (see the `Sync` SAFETY note).
    unsafe fn pipe_fill(&self, val: u8) {
        // SAFETY: exclusive access per the caller contract; the box lives for the
        // `ThreadData`'s lifetime.
        let page = unsafe { &mut *self.pipe_page.get() };
        page.fill(val);
        std::hint::black_box(page.as_ptr());
    }
}

/// schbench's per-request think-time sleep ("simulated networking",
/// `schbench.c:1461`). This is the workload's defined behavior, not a
/// synchronization wait: `usleep` maps to `clock_nanosleep`, matching
/// schbench's syscall profile.
fn think_sleep(usec: u64) {
    std::thread::sleep(std::time::Duration::from_micros(usec));
}

/// schbench's `--split` op partition (`schbench.c:1391-1392`): of `operations`
/// total, `p`% run on the private matrix and the rest on the shared matrix.
/// Returns `(ops_shared, ops_private)`. Extracted so the integer-division split
/// is pinned by a unit test rather than re-implemented inline in `do_work`.
fn ops_split(operations: usize, p: usize) -> (usize, usize) {
    let ops_private = (operations * p) / 100;
    let ops_shared = operations - ops_private;
    (ops_shared, ops_private)
}

/// One work cycle's matrix multiplications under the per-CPU lock. Faithful to
/// schbench's `do_work` (`schbench.c:1379-1413`): take the current CPU's mutex
/// (unless `skip_locking`), run `operations` matrix multiplies, unlock. With
/// `split_percent` set, the ops are partitioned (`schbench.c:1390-1404`):
/// `ops_shared` run on the ONE process-global shared matrix (every worker
/// contends), then `ops_private` on this worker's private buffer; `None` is the
/// legacy all-private single matrix (`:1406-1408`). The guard drops at the end,
/// holding the lock across all operations exactly as schbench does (`:1387`/
/// `:1411`).
#[allow(clippy::too_many_arguments)]
fn do_work(
    private_buf: &mut [u64],
    private_matrix_size: usize,
    shared_buf: &[core::sync::atomic::AtomicU64],
    shared_matrix_size: usize,
    split_percent: Option<usize>,
    operations: usize,
    locks: Option<&PerCpuLocks>,
    work_units: &mut u64,
) {
    let _guard = locks.map(|l| l.lock_this_cpu());
    match split_percent {
        Some(p) => {
            let (ops_shared, ops_private) = ops_split(operations, p);
            // schbench.c:1395-1398: shared ops first, on the global matrix.
            if shared_matrix_size > 0 && ops_shared > 0 {
                for _ in 0..ops_shared {
                    crate::workload::worker::matrix_multiply_shared(
                        shared_buf,
                        shared_matrix_size,
                        work_units,
                    );
                }
            }
            // schbench.c:1401-1404: private ops second, on this worker's buffer.
            if private_matrix_size > 0 && ops_private > 0 {
                for _ in 0..ops_private {
                    crate::workload::worker::matrix_multiply(
                        private_buf,
                        private_matrix_size,
                        work_units,
                    );
                }
            }
        }
        None => {
            // schbench.c:1406-1408: legacy all-private single matrix.
            for _ in 0..operations {
                if private_matrix_size > 0 {
                    crate::workload::worker::matrix_multiply(
                        private_buf,
                        private_matrix_size,
                        work_units,
                    );
                }
            }
        }
    }
}

/// Worker side of one wakeup cycle. Faithful to schbench's `msg_and_wait`
/// (`schbench.c:997`, default branch): stamp our `wake_time`, push ourselves
/// onto the message thread's wait-list, wake the message thread, then block
/// until it wakes us back; record the wakeup (scheduler) latency. The `!stop`
/// guard mirrors schbench's `if (!stopping)` (`schbench.c:1030`) so we do not
/// block during shutdown.
fn msg_and_wait(
    td: &ThreadData,
    msg_td: &ThreadData,
    wait_list: &TreiberStack<ThreadData>,
    stop: &AtomicBool,
) {
    // Our futex is BLOCKED here (consumed by the prior wait, or fresh).
    // Pipe mode: write our transfer page to 2 (schbench memsets td->pipe_page
    // before blocking, schbench.c:1003-1004). Done before we enqueue on the
    // wait-list, so the waker's fill-to-1 (after it splices us off) never races --
    // see the Sync SAFETY note.
    if td.pipe_bytes > 0 {
        // SAFETY: we own this page and have not yet enqueued, so the waker cannot
        // touch it concurrently.
        unsafe { td.pipe_fill(2) };
    }
    td.wake_time.store(monotonic_nanos(), Ordering::Release);
    wait_list.add(td as *const ThreadData as *mut ThreadData);
    msg_td.futex.post();
    if !stop.load(Ordering::Acquire) {
        td.futex.wait_forever();
    }
    let now = monotonic_nanos();
    let wake = td.wake_time.load(Ordering::Acquire);
    // schbench buckets in microseconds (gettimeofday resolution); the monotonic
    // clock gives ns, so divide. `if delta > 0` matches schbench (`:1036`).
    let delta_us = now.saturating_sub(wake) / 1000;
    if delta_us > 0 {
        // SAFETY: only this worker thread accesses its own wakeup_stats cell.
        unsafe { (*td.wakeup_stats.get()).add_lat(delta_us.min(u32::MAX as u64) as u32) };
    }
}

/// Worker side of one RPS-injector cycle. Faithful to schbench's `msg_and_wait`
/// RPS branch (`schbench.c:1007-1037`): stamp our own `wake_time`, reset
/// `pending`, then splice this worker's request queue (FIFO). If non-empty,
/// return the chain immediately (fast path, no wait, no wakeup sample --
/// `:1014-1017`). If empty, block on this worker's own futex until the RPS thread
/// posts it, record the wakeup (scheduler) latency, then splice whatever it
/// queued (null on a spurious wake or the shutdown wake-all). Unlike
/// [`msg_and_wait`], the worker does NOT enqueue on the message thread's
/// wait-list or post a message thread -- the RPS thread is the sole waker.
///
/// `wake_time` is stamped HERE before the splice (schbench `:1008`) AND by the
/// RPS thread at enqueue (`:1294`). On a TRUE block the RPS thread's stamp wins
/// (it overwrites ours before posting), so `now - wake_time` is the enqueue->run
/// scheduler latency. But on a FAST wake -- a leftover RUNNING futex token from a
/// request the worker already drained via splice (the word is consumed by splice,
/// not by `wait`, so it stays RUNNING) -- the RPS thread does NOT re-stamp, so our
/// own stamp makes the delta measure THIS block decision (near-zero), not the
/// stale enqueue time of the already-served request. Omitting it inflates the
/// fast-wake samples to the full inter-request gap (the wakeup-p50 bug: 543ms
/// vs schbench's 7µs).
fn rps_wait(td: &ThreadData, stop: &AtomicBool) -> *mut Request {
    // Stamp our own wake_time before splicing (schbench `:1008`); see the doc for
    // why this is what keeps fast-wake samples honest.
    td.wake_time.store(monotonic_nanos(), Ordering::Release);
    // Reset the backpressure counter before draining (schbench `td->pending = 0`,
    // `:1012`): the RPS thread stops enqueuing while pending exceeds the batch
    // cap, so clearing it re-opens this worker for the next batch.
    td.pending.store(0, Ordering::Release);
    let chain = td.requests.splice_reversed();
    if !chain.is_null() {
        return chain; // work already queued: serve it without blocking
    }
    if stop.load(Ordering::Acquire) {
        return ptr::null_mut();
    }
    // Empty queue: block until the RPS thread posts our futex, then `now -
    // wake_time` is the scheduler wakeup latency (schbench `fwait` NULL then
    // add_lat, `:1032`/`:1034-1037`).
    td.futex.wait_forever();
    // Shutdown wake: if stop is set, the wake came from run_rps_thread's stop-path
    // wake-all (`:1308-1312`), which posts every worker WITHOUT restamping
    // wake_time. A worker blocked between bursts when stop fires would otherwise
    // record `now - (its own stale block-time stamp)` -- the whole inter-burst
    // gap -- as a phantom wakeup, one garbage tail sample per worker. That is not
    // a request-driven wakeup, so skip the sample (schbench's `if (!stopping)
    // fwait` guard, `:1030`, encodes the same intent: shutdown is not a latency
    // event). Return any leftover requests for the caller to free.
    if stop.load(Ordering::Acquire) {
        return td.requests.splice_reversed();
    }
    let now = monotonic_nanos();
    let wake = td.wake_time.load(Ordering::Acquire);
    let delta_us = now.saturating_sub(wake) / 1000;
    if delta_us > 0 {
        // SAFETY: only this worker thread accesses its own wakeup_stats cell.
        unsafe { (*td.wakeup_stats.get()).add_lat(delta_us.min(u32::MAX as u64) as u32) };
    }
    // Splice whatever the RPS thread enqueued before posting us (null if the post
    // was a spurious wake with nothing queued).
    td.requests.splice_reversed()
}

/// Free a chain of [`Request`]s spliced off a worker's queue but not processed
/// (the shutdown path, and the post-join cleanup of any requests the RPS thread
/// enqueued after a worker's last splice). Each node was `Box::into_raw`'d by the
/// RPS thread; the draining/cleaning thread owns them exclusively after the
/// splice, so each is freed exactly once.
fn free_request_chain(mut req: *mut Request) {
    while !req.is_null() {
        // SAFETY: `req` is a node taken via `splice_reversed` (exclusive
        // ownership); freed exactly once.
        let next = unsafe { (*req).next.load(Ordering::Acquire) };
        drop(unsafe { Box::from_raw(req) });
        req = next;
    }
}

/// Wake every worker on the wait-list, stamping each one's `wake_time` so it
/// can measure scheduler latency. Faithful to schbench's `xlist_wake_all`
/// (`schbench.c:969`): splice the whole list, read the clock ONCE, stamp every
/// worker with that single time, then post each. The single clock read is what
/// detects the scheduler preempting the waker mid-batch (`schbench.c:961-964`).
/// Pipe mode (`-p`) diverges, matching schbench (`schbench.c:980-984`): it memsets
/// each worker's transfer page to `1` and re-reads the clock PER worker (so the
/// memset cost is not charged to that worker's wakeup latency).
fn wake_all(wait_list: &TreiberStack<ThreadData>) {
    let mut cur = wait_list.splice();
    let now = monotonic_nanos();
    while !cur.is_null() {
        // SAFETY: `cur` is a worker `ThreadData` alive for the whole run; only
        // its atomic fields (next / wake_time / futex) are touched here.
        let td = unsafe { &*cur };
        let next = td.next.load(Ordering::Acquire);
        td.next.store(ptr::null_mut(), Ordering::Relaxed);
        if td.pipe_bytes > 0 {
            // Pipe mode: write this woken worker's transfer page to 1 (schbench
            // memsets list->pipe_page in the wake loop, schbench.c:980-981). We
            // spliced it off the wait-list and it is still blocked (our `post`
            // below wakes it), so no concurrent access -- see the Sync SAFETY note.
            // SAFETY: `td` is off the wait-list and blocked here.
            unsafe { td.pipe_fill(1) };
            // schbench re-reads the clock per worker in pipe mode (after the
            // memset, schbench.c:982) so the fill is not charged to the worker's
            // wakeup latency; non-pipe uses the single batched `now`.
            td.wake_time.store(monotonic_nanos(), Ordering::Release);
        } else {
            td.wake_time.store(now, Ordering::Release);
        }
        td.futex.post();
        cur = next;
    }
}

/// The message thread's loop. Faithful to schbench's `run_msg_thread`
/// (`schbench.c:1166`): batch-wake all waiting workers, then block until a
/// worker posts us back. On stop, drain once more (to wake any worker that
/// queued during the final wake) and exit.
///
/// `phase_epoch` drives the per-phase drain exactly as in [`worker_loop`], but
/// the message thread records only per-phase RUN-DELAY (its `wakeup`/`request`
/// histograms stay empty, taken empty by [`drain_phase`]). It polls the epoch
/// after each wake cycle; a phase in which no worker ever posts it leaves that
/// phase's run-delay drained late at the next wake (bounded boundary fuzz,
/// matching the worker drain).
fn run_msg_thread(
    msg_td: &ThreadData,
    wait_list: &TreiberStack<ThreadData>,
    stop: &AtomicBool,
    phase_epoch: Option<&AtomicU32>,
) {
    let tid = gettid_self();
    let mut cur_epoch = phase_epoch.map_or(0, |e| e.load(Ordering::Relaxed));
    let mut phase_ss_start = read_schedstat_raw(tid);
    loop {
        // msg_td.futex is BLOCKED here (consumed by the prior wait, or fresh).
        wake_all(wait_list);
        if stop.load(Ordering::Acquire) {
            wake_all(wait_list);
            break;
        }
        msg_td.futex.wait_forever();
        if let Some(pe) = phase_epoch {
            let new_epoch = pe.load(Ordering::Relaxed);
            if new_epoch != cur_epoch {
                let ss_end = read_schedstat_raw(tid);
                // SAFETY: this is the thread that owns msg_td (the coordinator
                // thread of run_one_message_thread).
                unsafe { drain_phase(msg_td, cur_epoch, phase_ss_start, ss_end, 0) };
                cur_epoch = new_epoch;
                phase_ss_start = ss_end;
            }
        }
    }
    // Final drain: close the still-open phase (the sole snapshot when non-phasic).
    let ss_end = read_schedstat_raw(tid);
    // SAFETY: this is the thread that owns msg_td.
    unsafe { drain_phase(msg_td, cur_epoch, phase_ss_start, ss_end, 0) };
    // Record the message thread's whole-run mean run-queue wait (`schbench.c:1664`),
    // reusing the cumulative `ss_end` pair just read — one /proc read, so the mean
    // and the final-phase delta derive from one consistent snapshot.
    // SAFETY: owner-only access to msg_td's sched_delay_ns cell.
    unsafe { *msg_td.sched_delay_ns.get() = mean_sched_delay(ss_end) };
}

/// The RPS-injector dispatcher loop, replacing [`run_msg_thread`] when
/// `requests_per_sec != 0`. Each second it enqueues `requests_per_sec` requests
/// round-robin across this group's `workers`, skipping a worker whose `pending`
/// exceeds the batch cap (backpressure, `schbench.c:1284-1290`); on stop, it
/// wakes every worker and exits. Runs on the message thread's calling thread, so
/// — like [`run_msg_thread`] — it records the dispatcher thread's per-phase +
/// whole-run run-queue wait into `msg_td` (the "message thread delay" in RPS
/// mode, `schbench.c:1664-1670`).
///
/// DIVERGENCE from schbench's `run_rps_thread` (`schbench.c:1258-1314`),
/// intentional: schbench FRONT-LOADS the second (bursts all N enqueues, then
/// `usleep`s to fill the rest of the second) and relies on its workers
/// interleaving with the burst to keep each queue under the cap. That interleave
/// is scheduling-dependent; ktstr's burst instead front-ran the workers (the
/// first splice grabbed a large batch the worker drained to completion before
/// re-splicing), so `pending` stayed pegged and the injector dropped the
/// remainder every second -- a ~BATCH/worker supply cap that under-delivered the
/// requested rate (e.g. ~257/s at a 400/s target). ktstr instead PACES the
/// enqueues evenly across the second (one every `1s/N`), which forces the
/// injector to interleave with the workers deterministically: `pending` drains
/// between enqueues and the full rate is delivered up to worker capacity. Above
/// capacity the pacing collapses to a flat-out loop and the `BATCH` backpressure
/// bounds the queue, matching schbench's bounded-backlog overload behavior. The
/// pacing sleep and the bounded backpressure usleep are workload-defined `-R`
/// cadence (like the per-request think-sleep), not synchronization waits. Uses
/// `CLOCK_MONOTONIC` (ruling), not schbench's `gettimeofday`.
///
/// Verified by `ktstr-schbench-validate` (release, standalone), not a nextest
/// unit test: the under-delivery signal only appears when worker capacity exceeds
/// the front-load cap (~2*BATCH/s), which requires a release build (debug matrix
/// work caps capacity well below that) on dedicated CPU (nextest's parallel
/// execution starves the workers, collapsing both the paced and a hypothetical
/// reverted injector to the same capacity-limited rate). The fixed `-R` axis in
/// the schbench validation comparison is the regression evidence.
fn run_rps_thread(
    workers: &[ThreadData],
    msg_td: &ThreadData,
    stop: &AtomicBool,
    phase_epoch: Option<&AtomicU32>,
    live_rate: &AtomicUsize,
) {
    let tid = gettid_self();
    let mut cur_epoch = phase_epoch.map_or(0, |e| e.load(Ordering::Relaxed));
    let mut phase_ss_start = read_schedstat_raw(tid);
    // schbench's per-worker queue-depth cap before the injector backs off
    // (`schbench.c:1267`).
    const BATCH: u64 = 128;
    const ONE_SEC_NS: u64 = 1_000_000_000;
    let worker_count = workers.len().max(1);
    let mut cur_tid: usize = 0;
    while !stop.load(Ordering::Acquire) {
        // Re-read the live per-message-thread rate each second. `live_rate` is
        // already the per-thread value (schbench's post-`/= message_threads`
        // global `requests_per_sec`, `:1899`), injected directly with no further
        // division (`schbench.c:1290`). Auto-RPS retargets it from the control
        // thread; a fixed `-R` leaves it constant.
        let requests_per_sec = live_rate.load(Ordering::Relaxed);
        let start = monotonic_nanos();
        // PACE the enqueues evenly across the second rather than front-loading
        // them. schbench bursts all N then fills the rest of the second
        // (`schbench.c:1300-1306`), relying on its workers interleaving with the
        // burst to keep each worker's queue under the `BATCH` cap. ktstr's burst
        // front-ran the workers: the first splice grabbed a large batch the worker
        // processed to completion before re-splicing, so `pending` stayed pegged
        // and the injector dropped the remainder every second (a steady-state
        // ~BATCH/worker supply cap -> only ~257/s at a 400/s target). Pacing one
        // enqueue every `1s/N` forces the injector to interleave with the workers,
        // so `pending` drains between enqueues and the full rate is delivered up
        // to worker capacity. Above capacity `due` falls behind real time, the
        // pacing sleep collapses to zero, and the `BATCH` backpressure bounds the
        // queue -- matching schbench's overload behavior (a bounded backlog).
        let interval_ns = if requests_per_sec > 0 {
            ONE_SEC_NS / requests_per_sec as u64
        } else {
            ONE_SEC_NS
        };
        let mut due = start;
        for _ in 0..requests_per_sec {
            if stop.load(Ordering::Acquire) {
                break;
            }
            // Wait until this enqueue's scheduled time. A no-op once `due` falls
            // behind real time (rate above what the workers can drain), so the
            // loop then runs flat-out and the backpressure below bounds the queue.
            // `due` is re-based to `start` each second and accrues at most
            // N*(ONE_SEC_NS/N) ~= 1s within a pass, so saturating_add never wraps
            // (the saturating form documents that the per-pass re-base is the
            // load-bearing invariant).
            due = due.saturating_add(interval_ns);
            // Pace to this enqueue's scheduled time, but cap each sleep at
            // STOP_POLL_QUANTUM_NS and re-check `stop`, so a shutdown during a long
            // inter-arrival gap (a degenerate low rate, where the interval can
            // exceed the cleanup/watchdog budget) is observed within the quantum
            // instead of after a full interval — mirroring the taobench open-loop
            // pacing. A stop observed here breaks the enqueue loop BEFORE issuing
            // this slot's request.
            let mut stop_during_pace = false;
            loop {
                if stop.load(Ordering::Acquire) {
                    stop_during_pace = true;
                    break;
                }
                let now = monotonic_nanos();
                if now >= due {
                    break;
                }
                let nap = (due - now).min(STOP_POLL_QUANTUM_NS);
                std::thread::sleep(std::time::Duration::from_nanos(nap));
            }
            if stop_during_pace {
                break;
            }
            let worker = &workers[cur_tid % worker_count];
            cur_tid += 1;
            // Backpressure: don't queue more to a worker already `BATCH` deep
            // (`schbench.c:1284-1290`). The bounded usleep is schbench's injector
            // throttle (workload behavior), not a sync poll. The round-robin
            // cursor already advanced, so the skipped slot moves to the next
            // worker, matching schbench (the request for this slot is dropped).
            if worker.pending.load(Ordering::Acquire) > BATCH {
                std::thread::sleep(std::time::Duration::from_micros(100));
                continue;
            }
            worker.pending.fetch_add(1, Ordering::AcqRel);
            // schbench mallocs one request per enqueue; the worker frees it after
            // processing (`schbench.c:1292`/`:1476`). Box matches that profile.
            let req = Box::into_raw(Box::new(Request::new()));
            worker.requests.add(req);
            // Stamp the target's wake_time so it measures wakeup latency as
            // now - enqueue (`schbench.c:1294`), then post it.
            worker.wake_time.store(monotonic_nanos(), Ordering::Release);
            worker.futex.post();
        }
        // The pacing loop already spans ~1 second; top up only if it finished
        // early (rate 0, or `stop`), preserving schbench's per-second cadence. Cap
        // each sleep at STOP_POLL_QUANTUM_NS and re-check `stop` so a shutdown
        // during the top-up is observed within the quantum, not after a full
        // remaining second (the same teardown bound as the pacing sleep above).
        loop {
            if stop.load(Ordering::Acquire) {
                break;
            }
            let elapsed = monotonic_nanos().saturating_sub(start);
            if elapsed >= ONE_SEC_NS {
                break;
            }
            let nap = (ONE_SEC_NS - elapsed).min(STOP_POLL_QUANTUM_NS);
            std::thread::sleep(std::time::Duration::from_nanos(nap));
        }
        // Dispatcher per-phase run-delay drain (mirrors run_msg_thread): on epoch
        // change, finalize this thread's run-delay for the ended phase. Empty
        // histograms (the dispatcher records only run-delay).
        if let Some(pe) = phase_epoch {
            let new_epoch = pe.load(Ordering::Relaxed);
            if new_epoch != cur_epoch {
                let ss_end = read_schedstat_raw(tid);
                // SAFETY: this thread owns msg_td (the dispatcher's ThreadData).
                unsafe { drain_phase(msg_td, cur_epoch, phase_ss_start, ss_end, 0) };
                cur_epoch = new_epoch;
                phase_ss_start = ss_end;
            }
        }
        if stop.load(Ordering::Acquire) {
            // Wake every worker so a blocked one observes stop and exits
            // (`schbench.c:1308-1312`).
            for w in workers {
                w.futex.post();
            }
            break;
        }
    }
    // Final drain + whole-run mean run-delay for the dispatcher thread, matching
    // run_msg_thread's exit (`schbench.c:1664`).
    let ss_end = read_schedstat_raw(tid);
    // SAFETY: this thread owns msg_td.
    unsafe { drain_phase(msg_td, cur_epoch, phase_ss_start, ss_end, 0) };
    // SAFETY: owner-only access to msg_td's sched_delay_ns cell.
    unsafe { *msg_td.sched_delay_ns.get() = mean_sched_delay(ss_end) };
}

/// The calling thread's kernel tid (`gettid`), for reading its own schedstat.
fn gettid_self() -> libc::pid_t {
    // SAFETY: gettid takes no arguments and only returns the caller's tid.
    unsafe { libc::syscall(libc::SYS_gettid) as libc::pid_t }
}

/// Raw `(run_delay_ns, pcount)` pair from `/proc/<tid>/schedstat` (fields 2, 3),
/// UNDIVIDED — schbench's `read_sched_delay` source (`schbench.c:1118`) without
/// the division. Both consumers want the raw pair, not a pre-divided mean: the
/// per-phase path re-pools `Σrun_delay / Σpcount` host-side, and the whole-run
/// per-thread mean is [`mean_sched_delay`] of the thread's final cumulative
/// pair. An absent file (the thread exited) yields `(0, 0)`, like schbench's
/// fopen-failure path.
///
/// `CONFIG_SCHED_INFO` (selected by `CONFIG_SCHEDSTATS`, and also by
/// `CONFIG_TASK_DELAY_ACCT` -- ktstr.kconfig enables both) populates these
/// fields even under sched_ext and regardless of the `kernel.sched_schedstats`
/// sysctl (that sysctl gates the separate per-rq/domain counters, not
/// `sched_info`).
fn read_schedstat_raw(tid: libc::pid_t) -> (u64, u64) {
    match std::fs::read_to_string(format!("/proc/{tid}/schedstat")) {
        Ok(s) => parse_schedstat_raw(&s),
        // The thread may have exited; schbench's fopen-failure path also -> 0.
        Err(_) => (0, 0),
    }
}

/// Parse a `/proc/<tid>/schedstat` line into the raw `(run_delay_ns, pcount)`
/// pair (field 2 = run_delay ns, field 3 = pcount = timeslices). The kernel's
/// `proc_pid_schedstat` always emits three integer fields, so a malformed
/// *present* line is a kernel/parse bug: panic rather than silently report 0
/// (matching the handshake's fail-loud stance + the no-silent-wrong-answer
/// rule). An absent file is the caller's concern, handled as `(0, 0)`.
fn parse_schedstat_raw(s: &str) -> (u64, u64) {
    let mut fields = s.split_whitespace();
    let _run = fields.next(); // field 1: sum_exec_runtime (on-CPU ns), unused
    let run_delay: u64 = fields
        .next()
        .and_then(|f| f.parse().ok())
        .expect("schedstat field 2 (run_delay) must be a present integer");
    let pcount: u64 = fields
        .next()
        .and_then(|f| f.parse().ok())
        .expect("schedstat field 3 (pcount) must be a present integer");
    (run_delay, pcount)
}

/// Mean per-schedule run-queue wait (ns) from a raw `(run_delay_ns, pcount)`
/// pair: `run_delay / pcount`, schbench's `read_sched_delay` arithmetic
/// (`schbench.c:1146`). Guards `pcount == 0` (a never-scheduled thread, or the
/// `!sched_info_on()` "0 0 0" line) to 0, where schbench divides by zero. Used
/// for the WHOLE-RUN per-thread mean (the mean-of-means component); the
/// per-phase path instead keeps the raw pair and re-pools `Σrd/Σpc` host-side.
fn mean_sched_delay((run_delay, pcount): (u64, u64)) -> u64 {
    if pcount == 0 { 0 } else { run_delay / pcount }
}

/// Finalize the just-ended phase on the OWNING thread: `take` the live
/// histograms (snapshot-and-reset, so the next phase starts clean) and push a
/// [`PhaseSnapshot`] tagged `epoch`, carrying the schedstat run-delay/pcount
/// delta (`ss_end − ss_start`) and this thread's `loop_count` for the phase.
/// Mirrors the worker/mod.rs:3858-3894 backdrop drain-on-change. For the
/// message thread the histograms are unwritten (taken empty) and `loop_count`
/// is 0; an empty histogram folds harmlessly host-side ([`PlatStats::combine`]).
///
/// # Safety
/// Must be called by the thread that owns `td` — it is the sole writer of
/// `td`'s `wakeup_stats` / `request_stats` / `phase_snapshots` cells (the
/// [`ThreadData`] `Sync` contract).
unsafe fn drain_phase(
    td: &ThreadData,
    epoch: u32,
    ss_start: (u64, u64),
    ss_end: (u64, u64),
    loop_count: u64,
) {
    // SAFETY: owner-only cell access, per this function's contract.
    unsafe {
        let wakeup = (*td.wakeup_stats.get()).take();
        let request = (*td.request_stats.get()).take();
        (*td.phase_snapshots.get()).push(PhaseSnapshot {
            epoch,
            wakeup,
            request,
            run_delay_ns: ss_end.0.saturating_sub(ss_start.0),
            pcount: ss_end.1.saturating_sub(ss_start.1),
            loop_count,
        });
    }
}

/// The per-message-thread shared context every worker borrows — bundled so
/// [`worker_loop`]'s signature stays within clippy's argument budget. All
/// fields are `Copy` shared references; every worker spawned by one
/// [`run_one_message_thread`] reads the same instance.
struct WorkerCtx<'a> {
    msg_td: &'a ThreadData,
    wait_list: &'a TreiberStack<ThreadData>,
    locks: Option<&'a PerCpuLocks>,
    config: &'a SchbenchConfig,
    stop: &'a AtomicBool,
    progress: &'a AtomicU64,
    phase_epoch: Option<&'a AtomicU32>,
    /// The ONE process-global shared matrix (`--split` mode), shared across
    /// every worker of every message thread; empty slice when not splitting.
    shared: &'a [core::sync::atomic::AtomicU64],
    shared_matrix_size: usize,
}

/// One worker thread's loop. Faithful to schbench's `worker_thread`
/// (`schbench.c:1419`, default branch): block until woken (recording wakeup
/// latency), then think-sleep + matrix work under the per-CPU lock (recording
/// request latency), until stop. `ctx.progress` counts completed work cycles
/// across all workers (the live cycle counter / achieved request rate).
///
/// `ctx.phase_epoch` (when `Some`) is the shared per-phase generation the
/// scenario engine bumps at each step boundary. The worker polls it once per
/// cycle and, on change, drains the just-ended phase ([`drain_phase`]) —
/// `take`ing its own histograms + a schedstat raw delta — so per-phase
/// percentiles are isolated across e.g. an scx→detached-EEVDF transition. A
/// final drain at exit closes the last phase (and is the SOLE snapshot when
/// non-phasic, `cur_epoch` 0).
fn worker_loop(td: &ThreadData, ctx: &WorkerCtx) {
    // Destructure the all-`Copy` context into the same bindings the body used
    // before the bundle, so the loop logic below reads unchanged.
    let WorkerCtx {
        msg_td,
        wait_list,
        locks,
        config,
        stop,
        progress,
        phase_epoch,
        shared,
        shared_matrix_size,
    } = *ctx;
    let tid = gettid_self();
    // Per-worker PRIVATE matrix (schbench.c:1568-1574: private_matrix_size when
    // splitting, else the full matrix_size). The shared matrix (split mode) is
    // the process-global `shared` allocated once in run().
    let private_matrix_size = config.private_matrix_size();
    let mut private_buf = if private_matrix_size > 0 {
        vec![0u64; 3 * private_matrix_size * private_matrix_size]
    } else {
        Vec::new()
    };
    let mut work_units = 0u64;
    // Per-phase drain-on-change state. `cur_epoch` is the epoch the current
    // phase's samples belong to; `phase_ss_start` baselines the schedstat raw
    // pair so each phase reports its own delta.
    let mut cur_epoch = phase_epoch.map_or(0, |e| e.load(Ordering::Relaxed));
    let mut phase_ss_start = read_schedstat_raw(tid);
    let mut phase_loop_count = 0u64;
    // RPS mode iff the per-thread rate is non-zero (the `-R` total covers at
    // least one request/sec per message thread). Below that it rounds to 0 and
    // the worker runs the default message-handshake mode -- matching schbench's
    // startup-divide-before-gate ([`SchbenchConfig::rps_per_message_thread`]).
    // Pipe mode (`-p`): the memory-transfer workload. It runs the message-handshake
    // path (where the pipe-page memsets live), NOT the RPS injector -- a ktstr
    // divergence; schbench composes -p+-R half-broken (see the `pipe_transfer_bytes`
    // field doc).
    let pipe_mode = config.pipe_transfer_bytes > 0;
    let rps_mode = !pipe_mode && config.rps_per_message_thread() != 0;
    while !stop.load(Ordering::Acquire) {
        // Acquire work. Default mode: the message-thread handshake (records
        // wakeup latency; no request chain -> a single work cycle). RPS mode:
        // splice this worker's request queue, blocking on its own futex until
        // the RPS thread posts; returns the spliced chain (FIFO) to drain.
        let mut req: *mut Request = if rps_mode {
            let chain = rps_wait(td, stop);
            if stop.load(Ordering::Acquire) {
                // Free any spliced-but-unprocessed requests, then exit.
                free_request_chain(chain);
                break;
            }
            if chain.is_null() {
                continue; // spurious wake with an empty queue
            }
            chain
        } else {
            msg_and_wait(td, msg_td, wait_list, stop);
            if stop.load(Ordering::Acquire) {
                break;
            }
            ptr::null_mut()
        };
        // Process the work: one cycle in default mode (`req` null), or one cycle
        // per queued request in RPS mode (freeing each). `work_start` is stamped
        // before the think-sleep so the request latency covers think-time +
        // matrix work (`schbench.c:1464-1481`).
        loop {
            let work_start = monotonic_nanos();
            // Pipe mode: the per-cycle transfer is the wake/block memsets
            // (msg_and_wait fills our page to 2; wake_all fills it to 1). The work
            // loop only counts cycles for the MB/s throughput; schbench likewise
            // skips do_work + the think-sleep in pipe mode (schbench.c:1448).
            if !pipe_mode {
                if config.sleep_usec > 0 {
                    think_sleep(config.sleep_usec);
                }
                do_work(
                    &mut private_buf,
                    private_matrix_size,
                    shared,
                    shared_matrix_size,
                    config.split_percent,
                    config.operations,
                    locks,
                    &mut work_units,
                );
            }
            let now = monotonic_nanos();
            let delta_us = now.saturating_sub(work_start) / 1000;
            if delta_us > 0 {
                // SAFETY: only this worker thread accesses its own request_stats cell.
                unsafe { (*td.request_stats.get()).add_lat(delta_us.min(u32::MAX as u64) as u32) };
            }
            progress.fetch_add(1, Ordering::Relaxed);
            phase_loop_count += 1;
            if req.is_null() {
                break; // default mode: a single work cycle
            }
            // RPS mode: advance to the next queued request and free this one.
            // SAFETY: `req` was `Box::into_raw`'d by the RPS thread and handed to
            // this worker exclusively via the queue splice; we own it and free it
            // exactly once here.
            let next = unsafe { (*req).next.load(Ordering::Acquire) };
            drop(unsafe { Box::from_raw(req) });
            req = next;
            if req.is_null() {
                break;
            }
        }
        // Drain-on-change: when the parent advances `phase_epoch`, finalize the
        // phase just ended (tagged with the OLD epoch the samples were recorded
        // under) and re-baseline. A worker blocked across a whole phase simply
        // contributes an empty histogram for it (no cycle ran), which folds
        // harmlessly.
        if let Some(pe) = phase_epoch {
            let new_epoch = pe.load(Ordering::Relaxed);
            if new_epoch != cur_epoch {
                let ss_end = read_schedstat_raw(tid);
                // SAFETY: this is the thread that owns `td`.
                unsafe { drain_phase(td, cur_epoch, phase_ss_start, ss_end, phase_loop_count) };
                cur_epoch = new_epoch;
                phase_ss_start = ss_end;
                phase_loop_count = 0;
            }
        }
    }
    // Shutdown wake: the loop exits on `stop` without a final `msg_and_wait`, so
    // the message thread may still be parked in `wait_forever` (its only waker is
    // a worker post). schbench's MAIN thread handles this -- it fposts each
    // message thread after `stopping = 1` (`schbench.c:1832`, `:1933`) -- but our
    // `run()` joins passively and `msg_td` is private to `run_one_message_thread`,
    // so the exiting worker wakes its own message thread instead. Without this, a
    // worker that exits mid-`do_work` leaves the message thread parked forever and
    // `run()` deadlocks. Idempotent across workers: the first post the message
    // thread observes makes it re-check `stop` and break; later posts are no-ops.
    // In RPS mode this is itself a harmless no-op: the dispatcher runs
    // `run_rps_thread` (which never parks on `msg_td`) and observes `stop` itself.
    msg_td.futex.post();
    // Final drain: close the still-open phase. When non-phasic this is the only
    // snapshot (`cur_epoch` 0), so run() builds the whole-run result uniformly
    // from snapshots.
    let ss_end = read_schedstat_raw(tid);
    // SAFETY: this is the thread that owns `td`.
    unsafe { drain_phase(td, cur_epoch, phase_ss_start, ss_end, phase_loop_count) };
    // Record this worker's whole-run mean run-queue wait at exit — the
    // mean-of-means component for the whole-run SchbenchResult (schbench reads
    // each thread's schedstat for the final aggregate, `schbench.c:1664-1670`).
    // Reuses the cumulative `ss_end` pair just read (one /proc read; mean and
    // final-phase delta share one consistent snapshot).
    // SAFETY: owner-only access to this thread's sched_delay_ns cell.
    unsafe { *td.sched_delay_ns.get() = mean_sched_delay(ss_end) };
}

/// Resolve the worker-thread default and the per-CPU lock-array size from the
/// calling thread's CPU affinity (the allocated cpuset, per ruling). Returns
/// `(allowed_cpu_count, lock_array_size)` where the array size is the highest
/// allowed CPU id + 1, so `sched_getcpu` indexes it without clamping even on a
/// sparse cpuset.
fn resolve_cpu_topology() -> (usize, usize) {
    // SAFETY: a zeroed cpu_set_t filled by sched_getaffinity for the calling
    // thread (pid 0); CPU_ISSET only reads it.
    unsafe {
        let mut set: libc::cpu_set_t = core::mem::zeroed();
        let rc = libc::sched_getaffinity(0, core::mem::size_of::<libc::cpu_set_t>(), &mut set);
        if rc != 0 {
            return (1, 1);
        }
        let mut count = 0usize;
        let mut max_id = 0usize;
        for cpu in 0..libc::CPU_SETSIZE as usize {
            if libc::CPU_ISSET(cpu, &set) {
                count += 1;
                max_id = cpu;
            }
        }
        (count.max(1), (max_id + 1).max(1))
    }
}

/// Combined WHOLE-RUN results of a schbench run: the merged wakeup + request
/// latency percentiles and the achieved request rate (completed work
/// cycles/second). The histograms are the union of every phase; `sched_delay_*`
/// keep schbench's mean-of-means (`collect_sched_delay`) so the side-by-side
/// validation matches real schbench's reported number.
pub(crate) struct SchbenchResult {
    pub(crate) wakeup: Percentiles,
    pub(crate) request: Percentiles,
    /// Per-second achieved-RPS distribution (schbench's `rps_stats`), sampled
    /// once per second by the control thread. For auto-RPS, samples are gated
    /// until the target is hit (`schbench.c:1781`); for fixed/default mode every
    /// second is sampled.
    pub(crate) rps: Percentiles,
    pub(crate) loop_count: u64,
    /// Resolved total worker-thread count (`message_threads * worker_threads`).
    /// Divisor for schbench's PER-WORKER pipe-mode rate (`avg worker transfer`):
    /// schbench reports `loop_count / Σ worker runtimes` (`schbench.c:1697`,
    /// `:1942-1943`/`:1979`), and `Σ worker runtimes ≈ nr_workers * elapsed`, so
    /// the per-worker rate is `achieved_rps / nr_workers`.
    pub(crate) nr_workers: usize,
    pub(crate) achieved_rps: f64,
    /// Auto-RPS final TOTAL target rate at run exit: the live per-message-thread
    /// rate * message_threads (schbench's `requests_per_sec * message_threads`,
    /// `schbench.c:1995`). For fixed `-R` and default mode this equals the seeded
    /// total; only auto-RPS makes it diverge as the control loop retargets.
    pub(crate) final_rps_goal: usize,
    /// Mean message-thread run-queue wait (ns), averaged across message threads
    /// (schbench's `message_thread_delay`, `schbench.c:1673`).
    pub(crate) sched_delay_msg_ns: u64,
    /// Mean worker-thread run-queue wait (ns), averaged across all workers
    /// (schbench's `worker_thread_delay`, `schbench.c:1674`).
    pub(crate) sched_delay_worker_ns: u64,
}

/// The full result of [`run`]: the whole-run [`SchbenchResult`] plus the
/// per-phase aggregates keyed by `phase_epoch` (each the cross-thread merge for
/// one scenario step's HOLD window). When non-phasic (`phase_epoch == None`)
/// `phases` holds the single `(0, ..)` baseline entry the host discards; tests
/// read `whole_run`. The worker dispatch turns each `(epoch, SchbenchPhaseStats)`
/// into a `PhaseSlice`.
pub(crate) struct SchbenchOutcome {
    pub(crate) whole_run: SchbenchResult,
    pub(crate) phases: Vec<(u32, SchbenchPhaseStats)>,
}

/// One message thread's pooled results, returned from [`run_one_message_thread`]
/// to [`run`]: the whole-run histograms (Σ over its workers' phase snapshots),
/// the run-delay components for the whole-run mean-of-means, and the per-epoch
/// aggregate keyed by `phase_epoch`.
struct MessageThreadResult {
    whole_wakeup: PlatStats,
    whole_request: PlatStats,
    msg_sched_delay_ns: u64,
    workers_sched_delay_sum: u64,
    phases: std::collections::BTreeMap<u32, SchbenchPhaseStats>,
}

/// Run one message thread plus its workers, returning the whole-run histograms
/// AND the per-epoch aggregate. The message thread runs on the calling thread;
/// workers are scoped so they are joined before their owner-only phase snapshots
/// are drained.
#[allow(clippy::too_many_arguments)]
fn run_one_message_thread(
    worker_threads: usize,
    locks: Option<&PerCpuLocks>,
    config: &SchbenchConfig,
    stop: &AtomicBool,
    progress: &AtomicU64,
    phase_epoch: Option<&AtomicU32>,
    live_rate: &AtomicUsize,
    shared: &[core::sync::atomic::AtomicU64],
    shared_matrix_size: usize,
) -> MessageThreadResult {
    // schbench clamps -p to PIPE_TRANSFER_BUFFER (schbench.c:291-294). Workers get
    // the per-thread transfer page; the message thread (waker) needs none.
    let pipe_bytes = config.pipe_transfer_bytes.min(PIPE_TRANSFER_BUFFER);
    let workers: Vec<ThreadData> = (0..worker_threads)
        .map(|_| ThreadData::new(pipe_bytes))
        .collect();
    let msg_td = ThreadData::new(0);
    let wait_list = TreiberStack::new();
    let ctx = WorkerCtx {
        msg_td: &msg_td,
        wait_list: &wait_list,
        locks,
        config,
        stop,
        progress,
        phase_epoch,
        shared,
        shared_matrix_size,
    };

    std::thread::scope(|inner| {
        for w in &workers {
            inner.spawn(|| worker_loop(w, &ctx));
        }
        // Dispatcher: RPS-injector mode runs the rate-driven request thread;
        // default mode runs the message-thread handshake. Both run on this
        // calling thread and record the dispatcher's run-delay into `msg_td`
        // (schbench `message_thread`, `:1594`).
        // Pipe mode (`-p`) uses the message-handshake waker (run_msg_thread does
        // the per-worker pipe-page fill in wake_all) and takes precedence over -R.
        // This DIVERGES from schbench, where -R alone picks the path
        // (`schbench.c:1594`): schbench's `-p -R` runs the RPS injector (no
        // xlist_wake_all -> no waker-side memset -> a half-pipe). ktstr runs the
        // full pipe; the realistic use is `-p` alone. Otherwise -R selects the
        // injector.
        if config.pipe_transfer_bytes == 0 && config.rps_per_message_thread() != 0 {
            run_rps_thread(&workers, &msg_td, stop, phase_epoch, live_rate);
        } else {
            run_msg_thread(&msg_td, &wait_list, stop, phase_epoch);
        }
        // Stop is set: wake every worker so a blocked one observes stop and
        // exits (schbench fposts each worker before joining, `:1599-1602`).
        for w in &workers {
            w.futex.post();
        }
        // The inner scope joins the workers here.
    });

    // RPS mode: free any requests the RPS thread enqueued after a worker's last
    // splice (the worker exited on stop without draining them). After the join,
    // this is the sole access to each worker's queue, so no request leaks across
    // run() calls.
    if config.rps_per_message_thread() != 0 {
        for w in &workers {
            free_request_chain(w.requests.splice_reversed());
        }
    }

    // After join (happens-before): drain each thread's owner-only phase
    // snapshots into the whole-run histograms + the per-epoch aggregate.
    let mut whole_wakeup = PlatStats::default();
    let mut whole_request = PlatStats::default();
    let mut workers_sched_delay_sum = 0u64;
    let mut phases: std::collections::BTreeMap<u32, SchbenchPhaseStats> =
        std::collections::BTreeMap::new();
    for w in &workers {
        // SAFETY: every worker has joined (inner scope ended), so this is the
        // sole access to their cells.
        unsafe {
            workers_sched_delay_sum =
                workers_sched_delay_sum.saturating_add(*w.sched_delay_ns.get());
            for snap in (*w.phase_snapshots.get()).drain(..) {
                whole_wakeup.combine(&snap.wakeup);
                whole_request.combine(&snap.request);
                let e = phases.entry(snap.epoch).or_default();
                e.wakeup.combine(&snap.wakeup);
                e.request.combine(&snap.request);
                e.worker_run_delay_ns = e.worker_run_delay_ns.saturating_add(snap.run_delay_ns);
                e.worker_pcount = e.worker_pcount.saturating_add(snap.pcount);
                e.loop_count = e.loop_count.saturating_add(snap.loop_count);
            }
        }
    }
    // SAFETY: run_msg_thread ran on this thread inside the scope above (before
    // the join), so msg_td's cells are settled and this is sole access.
    let msg_sched_delay = unsafe { *msg_td.sched_delay_ns.get() };
    // SAFETY: same — sole access to msg_td's snapshot cell after the scope.
    unsafe {
        for snap in (*msg_td.phase_snapshots.get()).drain(..) {
            let e = phases.entry(snap.epoch).or_default();
            e.msg_run_delay_ns = e.msg_run_delay_ns.saturating_add(snap.run_delay_ns);
            e.msg_pcount = e.msg_pcount.saturating_add(snap.pcount);
        }
    }
    MessageThreadResult {
        whole_wakeup,
        whole_request,
        msg_sched_delay_ns: msg_sched_delay,
        workers_sched_delay_sum,
        phases,
    }
}

/// Persistent /proc/stat reader state for auto-RPS, carrying the previous
/// cumulative totals so each call computes a delta over the interval (schbench
/// keeps one fd + the prior total/idle, `schbench.c` `read_busy`, `:1046`).
#[derive(Default)]
struct ReadBusyState {
    fd: Option<File>,
    prev_total: u64,
    prev_idle: u64,
}

/// Host-busy percentage over the interval since the last call, from the
/// aggregate `cpu` line of /proc/stat (schbench `read_busy`,
/// `schbench.c:1046-1112`). `None` means "no usable reading this tick": the first
/// call (baseline seed, schbench's `first_run`) OR a zero jiffy-delta (two reads
/// in the same tick -- schbench has no guard and divides by zero into a NaN that
/// spuriously trips its target-hit `else` branch; we skip the tick instead). A
/// real reading is `Some(0.0..=100.0)`, including a genuine fully-idle `Some(0.0)`
/// -- distinguished from the seed via the `prev_total` state, NOT via the value,
/// so an idle interval still drives a grow (the bug a `busy == 0.0` test had). In
/// the guest VM /proc/stat covers exactly the allocated cpuset (the guest has
/// only `cores=N`), so this system-wide read is correctly scoped to the
/// workload's CPUs. f32 to match schbench's `float` arithmetic.
fn read_busy(s: &mut ReadBusyState) -> Option<f32> {
    if s.fd.is_none() {
        s.fd = File::open("/proc/stat").ok();
    }
    let f = s.fd.as_mut()?;
    f.seek(SeekFrom::Start(0)).ok()?;
    let mut buf = [0u8; 512];
    let n = f.read(&mut buf).ok()?;
    let text = core::str::from_utf8(&buf[..n]).ok()?;
    // First line only; first token must be "cpu" (the aggregate line). Sum every
    // numeric field -> total; field index 3 (after "cpu") is idle -- kernel
    // show_stat order is user/nice/system/IDLE/iowait/irq/softirq/steal/guest/
    // guest_nice. `split_whitespace` collapses the "cpu  " double space, matching
    // schbench's `strtok_r` (`schbench.c:1082-1096`).
    let line = text.lines().next().unwrap_or("");
    let mut fields = line.split_whitespace();
    if fields.next() != Some("cpu") {
        return None;
    }
    let mut total = 0u64;
    let mut idle = 0u64;
    for (i, tok) in fields.enumerate() {
        let v: u64 = tok.parse().unwrap_or(0);
        if i == 3 {
            idle = v;
        }
        total += v;
    }
    // First call: seed the baseline, no busy% yet (`schbench.c:1098-1101`).
    if s.prev_total == 0 {
        s.prev_total = total;
        s.prev_idle = idle;
        return None;
    }
    let dt = total.saturating_sub(s.prev_total);
    let di = idle.saturating_sub(s.prev_idle);
    s.prev_total = total;
    s.prev_idle = idle;
    if dt == 0 {
        // Zero jiffy-delta: skip rather than NaN (see the doc; schbench diverges).
        return None;
    }
    Some(100.0 - (di as f32 / dt as f32) * 100.0)
}

/// Grow or shrink the live per-message-thread request rate toward `target_pct`
/// host-busy, given an already-read `busy` percentage (schbench `auto_scale_rps`'s
/// scaling body, `schbench.c:1203-1250`). Pure: no I/O, so the scaling decision is
/// unit-tested with injected `busy` values. Stores the new per-thread rate into
/// `live_rate` -- schbench scales its global `requests_per_sec` (already per-thread
/// after `:1899`) and writes it back at `:1250`. Returns true on the transition
/// INTO the near-target band (when `target_hit` flips false->true), so the caller
/// resets the rps histogram (schbench memsets `rps_stats` on that transition). The
/// near-target test reads the delta AFTER its damping reassignment, exactly as
/// schbench does (`:1210`,`:1233`). f32 math + `ceil` (grow) / `floor` (shrink)
/// match schbench's float deltas + rounding.
fn scale_rps_for_busy(
    busy: f32,
    live_rate: &AtomicUsize,
    target_hit: &AtomicBool,
    target_pct: usize,
) -> bool {
    let target = target_pct as f32;
    let rps = live_rate.load(Ordering::Relaxed) as f32;
    let already_hit = target_hit.load(Ordering::Relaxed);
    let mut just_hit = false;
    let new_rate: usize = if busy < target {
        // Under target: grow (`schbench.c:1203-1224`).
        let mut delta = target / busy;
        if delta > 3.0 {
            delta = 3.0;
        } else if delta < 1.2 {
            delta = 1.0 + (delta - 1.0) / 8.0;
            // Threshold check AFTER the damping reassignment (`schbench.c:1209-1213`).
            if delta < 1.05 && !already_hit {
                just_hit = true;
            }
        } else if delta < 1.5 {
            delta = 1.0 + (delta - 1.0) / 4.0;
        }
        let t = (rps * delta).ceil();
        // Not enough capacity to reach the target -> hold (`schbench.c:1219-1224`).
        if t >= (1u64 << 31) as f32 {
            rps as usize
        } else {
            t as usize
        }
    } else if busy > target {
        // Over target: shrink (`schbench.c:1226-1242`).
        let mut delta = target / busy;
        if delta < 0.3 {
            delta = 0.3;
        } else if delta > 0.9 {
            delta += (1.0 - delta) / 8.0;
            // Threshold check AFTER the damping reassignment (`schbench.c:1232-1236`).
            if delta > 0.95 && !already_hit {
                just_hit = true;
            }
        } else if delta > 0.8 {
            delta += (1.0 - delta) / 4.0;
        }
        (rps * delta).floor().max(0.0) as usize
    } else {
        // Exactly on target (`schbench.c:1243-1248`).
        if !already_hit {
            just_hit = true;
        }
        rps as usize
    };
    live_rate.store(new_rate, Ordering::Relaxed);
    if just_hit {
        target_hit.store(true, Ordering::Relaxed);
    }
    just_hit
}

/// Read host-busy from /proc/stat and scale the live rate toward the target
/// (schbench `auto_scale_rps`, `schbench.c:1180-1251`). The first read only seeds
/// the baseline (schbench's `first_run` early return, `:1201`); a zero-delta tick
/// is skipped (see [`read_busy`]). Returns [`scale_rps_for_busy`]'s just-hit flag.
fn auto_scale_rps(
    busy_state: &mut ReadBusyState,
    live_rate: &AtomicUsize,
    target_hit: &AtomicBool,
    target_pct: usize,
) -> bool {
    let Some(busy) = read_busy(busy_state) else {
        return false;
    };
    scale_rps_for_busy(busy, live_rate, target_hit, target_pct)
}

/// Reset BOTH per-phase rps accumulators in LOCKSTEP at an auto-RPS target-hit.
/// schbench memsets `rps_stats` on the target-hit transition to discard the
/// pre-target ramp; the per-epoch map MUST reset with it, or the per-phase view
/// keeps ramp samples the whole-run view dropped and `Σ per-epoch == whole-run`
/// breaks (asymmetric-counter-bookkeeping).
fn reset_rps_accumulators(
    whole: &mut PlatStats,
    per_epoch: &mut std::collections::BTreeMap<u32, PlatStats>,
) {
    *whole = PlatStats::default();
    per_epoch.clear();
}

/// The once-per-second control thread, schbench's main control loop
/// (`schbench.c:1756-1827`): sample the achieved per-second RPS into `rps_stats`
/// and -- when auto-RPS is on -- adjust the live rate toward the target. Returns
/// BOTH the whole-run `rps_stats` AND a per-epoch RPS map (the same samples
/// bucketed by the phase epoch observed at sample time), moved out on join. The
/// whole-run `rps_stats` is byte-identical to the pre-per-phase behavior -- the
/// per-epoch map is PURELY ADDITIVE (each sample lands in the whole-run histogram
/// AND exactly one epoch bucket), so `Σ per-epoch == whole-run`. `phase_epoch` is
/// read Relaxed (telemetry, gates no coupled memory); a 1s sample straddling an
/// epoch change is attributed wholly to the sample-time epoch (bounded fuzz,
/// mirroring the worker drain-on-change). The 1-second cadence is the workload's
/// defined sampling/scaling interval, not a synchronization wait; it observes
/// `stop` after each interval.
fn control_loop(
    progress: &AtomicU64,
    stop: &AtomicBool,
    config: &SchbenchConfig,
    live_rate: &AtomicUsize,
    target_hit: &AtomicBool,
    phase_epoch: Option<&AtomicU32>,
) -> (PlatStats, std::collections::BTreeMap<u32, PlatStats>) {
    let mut rps_stats = PlatStats::default();
    let mut per_epoch_rps: std::collections::BTreeMap<u32, PlatStats> =
        std::collections::BTreeMap::new();
    let mut last_loop = 0u64;
    let mut last_t = monotonic_nanos();
    let mut busy_state = ReadBusyState::default();
    while !stop.load(Ordering::Acquire) {
        // Sleep ~1s between samples, but in STOP_POLL_QUANTUM_NS chunks that
        // re-check `stop`, so a shutdown is observed within a quantum instead of
        // blocking run()'s teardown for a full second (the control thread is joined
        // by run(), so an uncapped sleep here is the dominant teardown latency —
        // worse than, and unconditional unlike, the RPS injector's pacing sleep).
        let sleep_target = monotonic_nanos().saturating_add(1_000_000_000);
        loop {
            if stop.load(Ordering::Acquire) {
                break;
            }
            let now = monotonic_nanos();
            if now >= sleep_target {
                break;
            }
            std::thread::sleep(std::time::Duration::from_nanos(
                (sleep_target - now).min(STOP_POLL_QUANTUM_NS),
            ));
        }
        // Sample the just-elapsed window BEFORE re-checking stop at the loop top,
        // so the in-progress second is recorded rather than dropped. `dt` is the
        // ACTUAL elapsed time (now - last_t).
        //
        // schbench samples-first then sleeps the full second (`sleep(1)` only
        // `if (!done)`, schbench.c:1827), so every schbench sample -- including the
        // boundary one taken when `done` is detected -- spans a real ~1s window
        // (rps over a real `delta = tvdelta(..)`, schbench.c:1774,1777). ktstr
        // sleeps-first/samples-last with a BOUNDED sleep, which diverges two ways:
        //   - On early `stop` this boundary sample spans a true PARTIAL window: for
        //     a substantial window (>= one quantum) the real rate over the
        //     sub-window the workers were active (more granular than schbench, which
        //     would have waited the full second); a degenerate sub-quantum window is
        //     dropped by `control_loop_rps_sample` (a count/dt rate is unbounded as
        //     dt->0). The bounded sleep is a ktstr scenario-teardown requirement
        //     schbench does not face.
        //   - The sleep-first shape omits schbench's tiny FIRST-window sample
        //     (schbench reads `now` at loop start before any work, schbench.c:1757,
        //     so its first `delta` is ~us); ktstr's first sample follows a full ~1s
        //     sleep.
        let now = monotonic_nanos();
        let lc = progress.load(Ordering::Relaxed);
        let dt = now.saturating_sub(last_t);
        // Achieved per-second rate = Δcompleted-cycles / Δt (schbench
        // `combine_message_thread_rps` over the shared loop count, schbench.c:1777),
        // floored at one quantum by `control_loop_rps_sample` (a ktstr teardown
        // guard, not in schbench — see its doc).
        if let Some(sample) = control_loop_rps_sample(lc.saturating_sub(last_loop), dt) {
            // Gate: for auto-RPS, sample only once the target is hit
            // (`schbench.c:1781`); fixed/default mode samples every second.
            if config.auto_rps == 0 || target_hit.load(Ordering::Relaxed) {
                rps_stats.add_lat(sample);
                // Per-phase parity: also bucket the sample by the epoch observed
                // NOW (the sample-time epoch; a straddling 1s sample lands wholly
                // here, bounded fuzz). 0/u32::MAX sentinel epochs route into the
                // same all_phases entries the host discards (the run() merge).
                let epoch = phase_epoch.map_or(0, |e| e.load(Ordering::Relaxed));
                per_epoch_rps.entry(epoch).or_default().add_lat(sample);
            }
        }
        last_loop = lc;
        last_t = now;
        if config.auto_rps != 0 {
            let just_hit = auto_scale_rps(&mut busy_state, live_rate, target_hit, config.auto_rps);
            if just_hit {
                // Discard ALL pre-target ramp samples (schbench memsets rps_stats
                // on the target-hit transition -- grow/shrink/on-target bands all
                // funnel through just_hit, `schbench.c:1212`/`:1235`/`:1247`). The
                // per-epoch map resets in LOCKSTEP so the per-phase view never
                // includes ramp samples the whole-run view discards (the
                // Σ-per-epoch == whole-run invariant survives the reset).
                reset_rps_accumulators(&mut rps_stats, &mut per_epoch_rps);
            }
        }
    }
    (rps_stats, per_epoch_rps)
}

/// Achieved per-second rate sample for one control-loop window:
/// Δcompleted-cycles over `dt_ns`, in cycles/sec, clamped to `u32`. `None` when
/// the window is shorter than one `STOP_POLL_QUANTUM_NS` quantum.
///
/// The floor is a correctness guard, not a nicety. The bounded teardown sleep in
/// `control_loop` can return on `stop` with a sub-millisecond `dt` (the window
/// between a sample's `last_t = now` and the next iteration's first stop check),
/// and a count/dt RATE is unbounded as `dt -> 0` (a single completion in a 1us
/// window reads as ~1e6 cycles/sec), so an unfloored sub-quantum sample would
/// spike `rps_max` and the top rps percentiles -- assertable metrics. A real
/// partial window is always >= one quantum (the sleep loop only observes `stop`
/// at a quantum boundary), so the floor drops only the degenerate race window; an
/// admitted window bounds the sample to `<= delta_loops * 1e9 /
/// STOP_POLL_QUANTUM_NS` (~ the real rate, no inflation). This diverges from
/// schbench's `isfinite(rps)` guard (schbench.c:1782), which catches only an
/// exact-zero delta -- a finite-but-huge rate from a tiny window slips past it,
/// but schbench's full-second cadence (schbench.c:1827) never produces one.
fn control_loop_rps_sample(delta_loops: u64, dt_ns: u64) -> Option<u32> {
    if dt_ns < STOP_POLL_QUANTUM_NS {
        return None;
    }
    let rps = delta_loops as f64 * 1e9 / dt_ns as f64;
    Some((rps as u64).min(u32::MAX as u64) as u32)
}

/// Run the schbench workload until `stop` is set, returning the whole-run
/// percentiles + achieved rate AND the per-phase aggregates. `progress` is the
/// live count of completed work cycles across all workers. `phase_epoch` (when
/// `Some`) is the scenario engine's shared per-phase generation; the engine
/// splits its histograms at each transition (see [`worker_loop`]).
pub(crate) fn run(
    config: &SchbenchConfig,
    stop: &AtomicBool,
    progress: &AtomicU64,
    phase_epoch: Option<&AtomicU32>,
) -> SchbenchOutcome {
    let (allowed_count, lock_array_size) = resolve_cpu_topology();
    let worker_threads = config.resolve_worker_count(allowed_count);
    let locks = if config.skip_locking {
        None
    } else {
        Some(PerCpuLocks::new(lock_array_size))
    };
    // schbench rejects a `--split` outside 0..=100 at parse and exits
    // (schbench.c:362-365). ktstr's analog is a loud panic here at the
    // consumption boundary -- before any matrix sizing or the shared Arc
    // allocation below -- so an out-of-range config (a `pub split_percent` set
    // via struct literal bypasses the builder's debug_assert) can never
    // underflow `100 - p` (a release `usize` wrap that would size a garbage
    // shared matrix and OOM the allocation) or silently mis-split.
    if let Some(p) = config.split_percent {
        assert!(
            p <= 100,
            "schbench split_percent must be in 0..=100, got {p} (schbench.c:362-365 rejects out of range)"
        );
    }
    // schbench.c:1871-1872: ONE process-global shared matrix for `--split`,
    // allocated once and shared (interior-mutable AtomicU64) by every worker
    // across all message threads. Empty when not splitting (`None`) or when the
    // shared portion rounds to 0 (split=100): the `shared_matrix_size > 0`
    // guard in do_work then skips the shared branch, so the empty slice is
    // never indexed.
    let shared_matrix_size = config.shared_matrix_size();
    let shared: std::sync::Arc<[core::sync::atomic::AtomicU64]> = if shared_matrix_size > 0 {
        (0..3 * shared_matrix_size * shared_matrix_size)
            .map(|_| core::sync::atomic::AtomicU64::new(0))
            .collect()
    } else {
        std::sync::Arc::from([] as [core::sync::atomic::AtomicU64; 0])
    };

    let start = monotonic_nanos();
    let mut all_wakeup = PlatStats::default();
    let mut all_request = PlatStats::default();
    let mut total_msg_sched_delay = 0u64;
    let mut total_worker_sched_delay = 0u64;
    let mut all_phases: std::collections::BTreeMap<u32, SchbenchPhaseStats> =
        std::collections::BTreeMap::new();

    // The live PER-MESSAGE-THREAD request rate the RPS injectors read each second
    // -- schbench's global `requests_per_sec` AFTER its startup `/= message_threads`
    // (`schbench.c:1899`), which is exactly the value `auto_scale_rps` scales
    // (`schbench.c:1250`) and each `run_rps_thread` injects directly with no
    // further division (`schbench.c:1290`). Seeded from `rps_per_message_thread()`
    // (the -R total / m, or the auto-RPS seed 10 / m). The control thread
    // auto-scales it when auto-RPS is on; otherwise it stays constant -- a fixed
    // -R then injects that seed rate unchanged. `target_hit` gates the rps_stats sampling
    // for auto-RPS (`schbench.c:1781`); `rps_stats` is moved out of the control
    // thread on join. `live_rate` is read back after the scope as the auto-RPS
    // "final rps goal" (`schbench.c:1995`, per-thread * m).
    //
    // Ordering: both atomics are lone scalars that gate NO coupled memory --
    // `live_rate` is a plain count the RPS thread loops on (the requests it
    // enqueues publish via the Treiber stack's own AcqRel), and `target_hit` is
    // touched only by the control thread. So Relaxed is correct (a 1-second-stale
    // read is benign within the auto-RPS cadence; schbench reads its global with
    // no sync at all). The final read after the scope is synchronized by the join.
    let live_rate = AtomicUsize::new(config.rps_per_message_thread());
    let target_hit = AtomicBool::new(false);
    let mut rps_stats = PlatStats::default();

    std::thread::scope(|outer| {
        let handles: Vec<_> = (0..config.message_threads)
            .map(|_| {
                let locks = locks.as_ref();
                let live_rate = &live_rate;
                let shared = &shared;
                outer.spawn(move || {
                    run_one_message_thread(
                        worker_threads,
                        locks,
                        config,
                        stop,
                        progress,
                        phase_epoch,
                        live_rate,
                        &shared[..],
                        shared_matrix_size,
                    )
                })
            })
            .collect();
        // Control thread: schbench's main control loop -- once/sec, sample the
        // achieved RPS into rps_stats and (for auto-RPS) auto-scale `live_rate`.
        let control = outer
            .spawn(|| control_loop(progress, stop, config, &live_rate, &target_hit, phase_epoch));
        for h in handles {
            let mtr = h.join().expect("schbench message thread panicked");
            all_wakeup.combine(&mtr.whole_wakeup);
            all_request.combine(&mtr.whole_request);
            total_msg_sched_delay = total_msg_sched_delay.saturating_add(mtr.msg_sched_delay_ns);
            total_worker_sched_delay =
                total_worker_sched_delay.saturating_add(mtr.workers_sched_delay_sum);
            for (epoch, sps) in mtr.phases {
                all_phases.entry(epoch).or_default().merge(&sps);
            }
        }
        let (whole_rps, control_per_epoch_rps) =
            control.join().expect("schbench control thread panicked");
        rps_stats = whole_rps;
        // Merge the control thread's per-epoch RPS into the per-phase aggregate
        // EXACTLY ONCE, post-join, OUTSIDE the per-message-thread loop above (else
        // it would double-count by message_threads). The control thread is the
        // SOLE rps source; the workers contribute empty rps via mtr.phases.
        for (epoch, hist) in control_per_epoch_rps {
            all_phases.entry(epoch).or_default().rps.combine(&hist);
        }
    });

    // The auto-RPS "final rps goal" (`schbench.c:1995`): the live per-thread rate
    // at exit * message_threads. Read after the scope (all threads joined, so the
    // control loop's last store is visible).
    let final_rps_goal = live_rate.load(Ordering::Relaxed) * config.message_threads;
    let loop_count = progress.load(Ordering::Relaxed);
    let elapsed_ns = monotonic_nanos().saturating_sub(start);
    let achieved_rps = if elapsed_ns > 0 {
        loop_count as f64 / (elapsed_ns as f64 / 1e9)
    } else {
        0.0
    };
    // Average the per-thread run-queue waits, matching schbench's
    // collect_sched_delay (`schbench.c:1673-1674`): message delay over
    // message_threads, worker delay over all workers. (Whole-run mean-of-means;
    // the per-phase path uses sample-weighted Σrd/Σpc — see SchbenchPhaseStats.)
    let sched_delay_msg_ns = total_msg_sched_delay / (config.message_threads.max(1) as u64);
    let total_workers = (config.message_threads * worker_threads).max(1) as u64;
    let sched_delay_worker_ns = total_worker_sched_delay / total_workers;

    SchbenchOutcome {
        whole_run: SchbenchResult {
            wakeup: all_wakeup.percentiles(),
            request: all_request.percentiles(),
            rps: rps_stats.percentiles(),
            loop_count,
            nr_workers: total_workers as usize,
            achieved_rps,
            final_rps_goal,
            sched_delay_msg_ns,
            sched_delay_worker_ns,
        },
        phases: all_phases.into_iter().collect(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Lightweight node for stack stress tests (ThreadData is far heavier —
    /// two boxed `PlatStats` histograms plus per-thread state — too heavy to
    /// allocate by the thousand).
    struct TestNode {
        next: AtomicPtr<TestNode>,
    }
    impl Linked for TestNode {
        fn next_link(&self) -> &AtomicPtr<Self> {
            &self.next
        }
    }
    impl TestNode {
        fn new() -> Self {
            Self {
                next: AtomicPtr::new(ptr::null_mut()),
            }
        }
    }

    #[test]
    fn resolve_worker_count_divides_cpuset_across_message_threads() {
        // Explicit non-zero worker_threads is honored as-is (per message thread).
        let c = SchbenchConfig::default()
            .worker_threads(3)
            .message_threads(4);
        assert_eq!(c.resolve_worker_count(8), 3);

        // 0-default mirrors schbench's ceil(cpuset_cpus / message_threads)
        // (schbench.c:1849-1852), scoped to the cpuset count:
        //   m=1: 8/1 = 8 per thread, total 8 (== the cpuset count; no divide).
        let c = SchbenchConfig::default().message_threads(1);
        assert_eq!(c.resolve_worker_count(8), 8);
        //   m=2: 8/2 = 4 per thread, total 8.
        let c = SchbenchConfig::default().message_threads(2);
        assert_eq!(c.resolve_worker_count(8), 4);
        //   m=3: ceil(8/3) = 3 per thread, total 9 (≈ 8; schbench rounds up too).
        let c = SchbenchConfig::default().message_threads(3);
        assert_eq!(c.resolve_worker_count(8), 3);
        //   message_threads floored at 1 — no div-by-zero.
        let c = SchbenchConfig::default().message_threads(0);
        assert_eq!(c.resolve_worker_count(8), 8);
    }

    #[test]
    fn matrix_size_matches_schbench_formula() {
        // sqrt(256*1024/3/8) = sqrt(10922) = 104.
        assert_eq!(
            SchbenchConfig {
                cache_footprint_kib: 256,
                operations: 5,
                ..Default::default()
            }
            .matrix_size(),
            104
        );
        // Zero operations -> no matrix work.
        assert_eq!(
            SchbenchConfig {
                operations: 0,
                ..Default::default()
            }
            .matrix_size(),
            0
        );
    }

    #[test]
    fn split_matrix_sizes_match_schbench_formula() {
        // schbench.c:1858-1863: with --split=p the footprint splits into a
        // private matrix (p%) and a shared matrix (100-p%); each dimension is
        // sqrt(kib*frac/100 * 1024/3/sizeof(ulong)). None => the legacy single
        // all-private matrix (full footprint) and no shared matrix.
        let cfg = |split| SchbenchConfig {
            cache_footprint_kib: 256,
            operations: 5,
            split_percent: split,
            ..Default::default()
        };
        // None: private == the full-footprint matrix_size (104), no shared.
        assert_eq!(cfg(None).private_matrix_size(), 104);
        assert_eq!(cfg(None).shared_matrix_size(), 0);
        // 0%: nothing private; the whole footprint is the shared matrix.
        assert_eq!(cfg(Some(0)).private_matrix_size(), 0);
        assert_eq!(cfg(Some(0)).shared_matrix_size(), 104);
        // 25%: 64 KiB private (sqrt(64*1024/3/8)=52), 192 KiB shared (=90).
        assert_eq!(cfg(Some(25)).private_matrix_size(), 52);
        assert_eq!(cfg(Some(25)).shared_matrix_size(), 90);
        // 100%: all private (full footprint), no shared work.
        assert_eq!(cfg(Some(100)).private_matrix_size(), 104);
        assert_eq!(cfg(Some(100)).shared_matrix_size(), 0);
        // Zero operations -> no matrix work either way.
        let none_ops = SchbenchConfig {
            operations: 0,
            split_percent: Some(50),
            ..Default::default()
        };
        assert_eq!(none_ops.private_matrix_size(), 0);
        assert_eq!(none_ops.shared_matrix_size(), 0);
    }

    #[test]
    fn ops_split_matches_schbench_integer_division() {
        // schbench.c:1391-1392: ops_private = operations*split/100 (integer
        // truncation), ops_shared = operations - ops_private. Returns
        // (ops_shared, ops_private).
        assert_eq!(ops_split(5, 0), (5, 0)); // all shared
        assert_eq!(ops_split(5, 25), (4, 1)); // 5*25/100 = 1 private
        assert_eq!(ops_split(5, 50), (3, 2)); // 5*50/100 = 2 private
        assert_eq!(ops_split(5, 100), (0, 5)); // all private
    }

    #[test]
    fn engine_split_runs_shared_matrix_concurrently() {
        // --split with two workers: both concurrently multiply into the ONE
        // process-global shared AtomicU64 matrix (per-k stores to the shared C
        // cells -- the deliberate cache contention schbench models). Reaching the
        // assertion proves the shared-matrix path
        // neither deadlocks nor trips a data-race trap -- the atomics make the
        // shared race sound (vs schbench's plain shared-memory race). operations=8
        // @ split=50 => 4 shared + 4 private ops/cycle, so both paths run. Stop is
        // event-driven (spin on the progress counter, not a sleep).
        let config = SchbenchConfig {
            message_threads: 1,
            worker_threads: 2,
            cache_footprint_kib: 16,
            operations: 8,
            sleep_usec: 0,
            skip_locking: false,
            requests_per_sec: 0,
            auto_rps: 0,
            split_percent: Some(50),
            pipe_transfer_bytes: 0,
        };
        // Sanity: both split matrices are non-empty at this footprint, so the
        // test exercises shared + private work, not a degenerate 0-dim path.
        assert!(config.shared_matrix_size() > 0, "shared matrix present");
        assert!(config.private_matrix_size() > 0, "private matrix present");
        let stop = AtomicBool::new(false);
        let progress = AtomicU64::new(0);
        let outcome = std::thread::scope(|s| {
            let runner = s.spawn(|| run(&config, &stop, &progress, None));
            while progress.load(Ordering::Relaxed) < 50 {
                core::hint::spin_loop();
            }
            stop.store(true, Ordering::Release);
            runner.join().expect("run panicked")
        });
        assert!(
            outcome.whole_run.loop_count >= 50,
            "engine did split work: {}",
            outcome.whole_run.loop_count
        );
    }

    #[test]
    fn pipe_fill_writes_every_byte_and_sizes_the_page() {
        // pipe_fill is the per-cycle memory transfer (schbench's pipe_page memset,
        // schbench.c:980-981/:1003-1004): it must touch EVERY byte of a
        // pipe_bytes-sized page. The bytes are never read back, so a dead-store
        // elision would silently zero the workload -- the black_box in pipe_fill
        // prevents that; this pins the observable effect (every byte written).
        let td = ThreadData::new(64);
        assert_eq!(td.pipe_bytes, 64);
        // SAFETY: single-threaded test, exclusive access to this td's page.
        unsafe { td.pipe_fill(2) };
        // SAFETY: same thread; no concurrent access.
        assert_eq!(
            unsafe { &*td.pipe_page.get() }.len(),
            64,
            "page sized to pipe_bytes"
        );
        assert!(
            unsafe { &*td.pipe_page.get() }.iter().all(|&b| b == 2),
            "worker fill (2) touches every byte"
        );
        // SAFETY: exclusive.
        unsafe { td.pipe_fill(1) };
        assert!(
            unsafe { &*td.pipe_page.get() }.iter().all(|&b| b == 1),
            "waker fill (1) overwrites every byte"
        );
        // Non-pipe ThreadData (pipe_bytes == 0) allocates an empty page: no transfer.
        let none = ThreadData::new(0);
        assert_eq!(none.pipe_bytes, 0);
        // SAFETY: exclusive.
        assert_eq!(unsafe { &*none.pipe_page.get() }.len(), 0);
    }

    #[test]
    fn pipe_transfer_bytes_clamps_to_one_mib() {
        // schbench clamps -p to PIPE_TRANSFER_BUFFER (schbench.c:41,291-294); run()
        // applies the same `.min()` before sizing each worker's page.
        assert_eq!(PIPE_TRANSFER_BUFFER, 1024 * 1024);
        assert_eq!(
            (2 * PIPE_TRANSFER_BUFFER).min(PIPE_TRANSFER_BUFFER),
            PIPE_TRANSFER_BUFFER,
            "over-cap pipe size clamps to the 1 MiB buffer"
        );
        // At/under-cap values pass through unchanged.
        assert_eq!(4096_usize.min(PIPE_TRANSFER_BUFFER), 4096);
    }

    #[test]
    fn pipe_transfer_report_is_per_worker_scales_and_clamps() {
        // PER-WORKER: ops/sec = achieved_rps / nr_workers
        // (schbench.c:1942-1943 divide by Σ worker runtimes ≈ nr_workers*elapsed).
        // 2048 aggregate cycles/sec / 2 workers = 1024 per-worker ops/sec.
        let r = pipe_transfer_report(2048.0, 1, 2);
        assert_eq!(r.ops_per_sec, 1024.0);
        // pretty_size (schbench.c:1606-1620): 1024 per-worker B/s -> 1.00 KB/s.
        assert_eq!((r.scaled, r.unit), (1.0, "KB"));
        // 1 worker, 1 B/cycle @ 1 cycle/s -> 1.00 B/s.
        let one = pipe_transfer_report(1.0, 1, 1);
        assert_eq!((one.ops_per_sec, one.scaled, one.unit), (1.0, 1.0, "B"));
        // 1 MiB/cycle @ 1 cycle/s/worker -> 1.00 MB/s.
        assert_eq!(pipe_transfer_report(1.0, 1 << 20, 1).unit, "MB");
        // CLAMP: an over-cap pipe size reports the clamped 1 MiB, not
        // the requested 2 MiB -- the engine moves only the clamped size per cycle
        // (schbench clamps at parse, schbench.c:291-294).
        let over = pipe_transfer_report(1.0, 2 << 20, 1);
        let cap = pipe_transfer_report(1.0, 1 << 20, 1);
        assert_eq!(
            (over.scaled, over.unit),
            (cap.scaled, cap.unit),
            "over-cap pipe size clamps to 1 MiB"
        );
        // EB cap: a huge byte rate never scales past the last unit (no overflow).
        assert_eq!(pipe_transfer_report(f64::MAX, 1 << 20, 1).unit, "EB");
        // nr_workers 0 -> floored at 1, never a division by zero.
        assert_eq!(pipe_transfer_report(100.0, 4096, 0).ops_per_sec, 100.0);
    }

    #[test]
    fn engine_pipe_mode_transfers_without_matrix_work() {
        // Pipe mode (-p): the worker loop skips do_work + the think-sleep, and the
        // per-cycle work is the cross-thread pipe_page handshake -- the worker
        // memsets its page to 2 before blocking, the waker memsets it to 1 on wake
        // (schbench.c:980-981/:1003-1004). Two workers exercise the concurrent
        // waker->worker page writes. Reaching the assertions proves the handshake
        // runs to completion: no deadlock and no data-race trap on the cross-thread
        // `pipe_page` UnsafeCell (the wait-list + futex ordering keeps it race-free,
        // see the Sync SAFETY note). `operations`/`cache_footprint_kib` are set
        // LARGE on purpose: if do_work erroneously ran, every cycle would log a
        // multi-us request-latency sample -- the second assertion would then fail.
        // Stop is event-driven (spin on the progress counter, not a sleep).
        let config = SchbenchConfig::default()
            .message_threads(1)
            .worker_threads(2)
            .sleep_usec(0)
            .operations(50)
            .cache_footprint_kib(256)
            .pipe_transfer_bytes(4096);
        assert!(config.pipe_transfer_bytes > 0, "pipe mode engaged");
        assert!(
            config.matrix_size() > 0,
            "matrix work would be non-trivial if do_work ran (regression guard)"
        );
        let stop = AtomicBool::new(false);
        let progress = AtomicU64::new(0);
        let outcome = std::thread::scope(|s| {
            let runner = s.spawn(|| run(&config, &stop, &progress, None));
            while progress.load(Ordering::Relaxed) < 50 {
                core::hint::spin_loop();
            }
            stop.store(true, Ordering::Release);
            runner.join().expect("run panicked")
        });
        // loop_count accrues one per completed transfer cycle (schbench.c:1479),
        // proving the pipe handshake + memsets ran end-to-end without deadlock.
        assert!(
            outcome.whole_run.loop_count >= 50,
            "pipe engine transferred {} cycles",
            outcome.whole_run.loop_count
        );
        // Discriminating assertion: do_work is SKIPPED, so the
        // back-to-back work_start->now span yields sub-us request deltas that the
        // `delta_us > 0` filter drops -- request stats stay ~empty. If matrix work
        // erroneously ran at operations=50/256KiB, nearly every cycle would record a
        // >0 request delta and request samples would approach loop_count, failing
        // this. (A rare us-straddle may record a handful; `* 4 < loop_count` tolerates
        // that while still catching a real do_work regression.)
        assert!(
            outcome.whole_run.request.nr_samples * 4 < outcome.whole_run.loop_count,
            "pipe mode skips matrix work: {} request samples vs {} cycles",
            outcome.whole_run.request.nr_samples,
            outcome.whole_run.loop_count
        );
    }

    #[test]
    #[should_panic(expected = "split_percent must be in 0..=100")]
    fn engine_panics_on_out_of_range_split() {
        // Regression pin for the out-of-range finding: a `pub split_percent` set
        // past 100 via STRUCT LITERAL (the path that bypasses the builder's
        // debug_assert) must fail LOUDLY at the run() consumption boundary in
        // every build profile -- the analog of schbench exiting on a bad
        // `--split` -- never silently underflow `100 - p` into a garbage-huge
        // shared matrix. The assert fires before any thread spawn or allocation,
        // so this is cheap (`stop` is pre-set just in case).
        let config = SchbenchConfig {
            message_threads: 1,
            worker_threads: 1,
            cache_footprint_kib: 16,
            operations: 1,
            sleep_usec: 0,
            skip_locking: false,
            requests_per_sec: 0,
            auto_rps: 0,
            split_percent: Some(101),
            pipe_transfer_bytes: 0,
        };
        let stop = AtomicBool::new(true);
        let progress = AtomicU64::new(0);
        let _ = run(&config, &stop, &progress, None);
    }

    #[test]
    fn stack_add_splice_is_lifo() {
        let a = TestNode::new();
        let b = TestNode::new();
        let stack: TreiberStack<TestNode> = TreiberStack::new();
        assert!(stack.splice().is_null(), "empty stack splices to null");
        stack.add(&a as *const _ as *mut _);
        stack.add(&b as *const _ as *mut _);
        // LIFO: b (pushed last) is the head; a follows.
        let head = stack.splice();
        assert_eq!(head.cast_const(), &b as *const TestNode);
        // SAFETY: head -> b (alive on stack); its link -> a.
        let second = unsafe { (*head).next.load(Ordering::Acquire) };
        assert_eq!(second.cast_const(), &a as *const TestNode);
        // SAFETY: second -> a; its link is null.
        assert!(unsafe { (*second).next.load(Ordering::Acquire) }.is_null());
        assert!(stack.splice().is_null(), "splice emptied the stack");
    }

    #[test]
    fn stack_concurrent_add_loses_no_nodes() {
        // N threads each concurrently push K distinct nodes; after all join, a
        // single splice must return exactly N*K nodes. A broken CAS (lost
        // update / ABA) would drop nodes and the count would be short. Push is
        // deterministic (each node pushed once), so this never hangs.
        const THREADS: usize = 8;
        const PER_THREAD: usize = 2000;
        let nodes: Vec<TestNode> = (0..THREADS * PER_THREAD).map(|_| TestNode::new()).collect();
        let stack = TreiberStack::new();

        std::thread::scope(|s| {
            for chunk in nodes.chunks(PER_THREAD) {
                let stack = &stack;
                s.spawn(move || {
                    for n in chunk {
                        stack.add(n as *const TestNode as *mut TestNode);
                    }
                });
            }
        });

        // All pushes complete (scope joined). Drain and count distinct nodes.
        let mut seen = std::collections::HashSet::new();
        let mut cur = stack.splice();
        while !cur.is_null() {
            assert!(seen.insert(cur), "node observed twice");
            // SAFETY: cur is one of the live `nodes`; next is its link.
            cur = unsafe { (*cur).next.load(Ordering::Acquire) };
        }
        assert_eq!(
            seen.len(),
            THREADS * PER_THREAD,
            "no node lost under contention"
        );
    }

    #[test]
    fn engine_runs_and_produces_latency_samples() {
        // Small topology; run until ~50 work cycles complete, then stop. The
        // test completing proves the shutdown does not hang (a scope-join
        // shutdown bug would block here until the nextest timeout). Stop is
        // event-driven: spin on the shared progress counter, not a sleep.
        let config = SchbenchConfig {
            message_threads: 1,
            worker_threads: 2,
            cache_footprint_kib: 16,
            operations: 1,
            sleep_usec: 0,
            skip_locking: false,
            requests_per_sec: 0,
            auto_rps: 0,
            split_percent: None,
            pipe_transfer_bytes: 0,
        };
        let stop = AtomicBool::new(false);
        let progress = AtomicU64::new(0);
        let outcome = std::thread::scope(|s| {
            let runner = s.spawn(|| run(&config, &stop, &progress, None));
            while progress.load(Ordering::Relaxed) < 50 {
                core::hint::spin_loop();
            }
            stop.store(true, Ordering::Release);
            runner.join().expect("run panicked")
        });
        let result = &outcome.whole_run;
        assert!(
            result.loop_count >= 50,
            "engine did work: {}",
            result.loop_count
        );
        assert!(result.wakeup.nr_samples > 0, "wakeup samples recorded");
        assert!(result.request.nr_samples > 0, "request samples recorded");
        assert!(result.achieved_rps > 0.0, "positive achieved rps");
        // Non-phasic (phase_epoch None): the only snapshot is the baseline
        // epoch 0, which the host discards. No per-phase metrics are emitted.
        assert_eq!(
            outcome.phases.len(),
            1,
            "non-phasic => single baseline phase"
        );
        assert_eq!(outcome.phases[0].0, 0, "the lone phase is BASELINE epoch 0");
    }

    #[test]
    fn engine_terminates_when_lone_worker_stops() {
        // Regression for the shutdown deadlock: with a SINGLE worker there is no
        // second worker to post the message thread, so a worker that exits on
        // `stop` while the message thread is parked in `wait_forever` must wake it
        // (the unconditional post at worker_loop's exit), or run() hangs forever.
        // Reaching the assertion at all proves run() returned. `sleep_usec` 0
        // keeps the lone worker almost always mid-`do_work` when stop fires (the
        // deadlock-prone window). engine_runs / engine_splits use 2 workers and
        // missed this -- a second worker posting the message thread masks the bug;
        // a hang here is the nextest timeout, exactly how the bug first surfaced.
        let config = SchbenchConfig {
            message_threads: 1,
            worker_threads: 1,
            cache_footprint_kib: 256,
            operations: 5,
            sleep_usec: 0,
            skip_locking: false,
            requests_per_sec: 0,
            auto_rps: 0,
            split_percent: None,
            pipe_transfer_bytes: 0,
        };
        let stop = AtomicBool::new(false);
        let progress = AtomicU64::new(0);
        let outcome = std::thread::scope(|s| {
            let runner = s.spawn(|| run(&config, &stop, &progress, None));
            while progress.load(Ordering::Relaxed) < 10 {
                core::hint::spin_loop();
            }
            stop.store(true, Ordering::Release);
            // Deadlocks here on regression: the lone worker exits without waking
            // the parked message thread, so this join never returns.
            runner.join().expect("run panicked")
        });
        assert!(
            outcome.whole_run.loop_count >= 10,
            "engine did work and returned: {}",
            outcome.whole_run.loop_count
        );
    }

    #[test]
    fn engine_rps_mode_injects_drains_and_terminates() {
        // RPS-injector mode (requests_per_sec != 0): a dedicated thread enqueues
        // requests round-robin across the workers, which splice + drain their
        // queues instead of the message-thread handshake. Verify the RPS path
        // runs (loop_count + request samples) and terminates cleanly -- run()
        // returns (no deadlock: the RPS thread is the waker, the worker the
        // waitee, and shutdown wakes the workers via run_one_message_thread's
        // worker-wake + the RPS thread's stop-time fpost-all; a hang is the
        // nextest timeout) with no request leak (the post-join free).
        let config = SchbenchConfig {
            message_threads: 1,
            worker_threads: 2,
            cache_footprint_kib: 16,
            operations: 1,
            sleep_usec: 0,
            skip_locking: false,
            requests_per_sec: 10_000,
            auto_rps: 0,
            split_percent: None,
            pipe_transfer_bytes: 0,
        };
        let stop = AtomicBool::new(false);
        let progress = AtomicU64::new(0);
        let outcome = std::thread::scope(|s| {
            let runner = s.spawn(|| run(&config, &stop, &progress, None));
            while progress.load(Ordering::Relaxed) < 50 {
                core::hint::spin_loop();
            }
            stop.store(true, Ordering::Release);
            // Deadlocks here on regression: a worker parked in rps_wait that the
            // shutdown path fails to wake, or the RPS thread failing to observe
            // stop, would hang this join.
            runner.join().expect("run panicked")
        });
        let result = &outcome.whole_run;
        assert!(
            result.loop_count >= 50,
            "RPS workers serviced requests: {}",
            result.loop_count
        );
        assert!(
            result.request.nr_samples > 0,
            "RPS request-latency samples recorded"
        );
    }

    #[test]
    fn rps_injector_pacing_sleep_is_bounded_for_prompt_shutdown() {
        // requests_per_sec=1, message_threads=1 => per-thread inter-arrival
        // interval ~1s. A stop set while the injector is in that paced sleep must be
        // observed within STOP_POLL_QUANTUM_NS (50 ms), so run()'s message-thread
        // join completes well under one interval. An unbounded pacing sleep would
        // block the join ~1s. 500 ms is 10x the quantum (robust to scheduling
        // jitter) and half the interval (cleanly distinguishes bounded from
        // unbounded). The 20 ms warmup reaches the long paced sleep before stop (at
        // 1 req/s a progress-spin could outlast the test).
        let config = SchbenchConfig {
            message_threads: 1,
            worker_threads: 1,
            cache_footprint_kib: 16,
            operations: 1,
            sleep_usec: 0,
            skip_locking: false,
            requests_per_sec: 1, // interval = 1s / 1 = 1s
            auto_rps: 0,
            split_percent: None,
            pipe_transfer_bytes: 0,
        };
        let stop = AtomicBool::new(false);
        let progress = AtomicU64::new(0);
        let start = std::time::Instant::now();
        std::thread::scope(|s| {
            let runner = s.spawn(|| run(&config, &stop, &progress, None));
            std::thread::sleep(std::time::Duration::from_millis(20));
            stop.store(true, Ordering::Release);
            let _ = runner.join().expect("run panicked");
        });
        let elapsed = start.elapsed();
        assert!(
            elapsed < std::time::Duration::from_millis(500),
            "RPS injector joined in {elapsed:?}; the paced sleep must be bounded \
             (interval ~1s — an unbounded sleep would block the join that long)",
        );
    }

    #[test]
    fn engine_rps_below_message_threads_falls_to_default() {
        // Regression: when the -R total is below the message-thread count,
        // the per-thread rate (rps_per_message_thread) rounds to 0, and the
        // engine must run the DEFAULT message-handshake mode -- workers do real
        // wake-cycle work -- matching schbench's startup-divide-before-gate. NOT
        // a zero-rate RPS mode (which would leave the workers parked with no
        // requests, never advancing progress -- the spin below would then hang,
        // caught by the nextest timeout).
        let config = SchbenchConfig {
            message_threads: 2,
            worker_threads: 1,
            cache_footprint_kib: 16,
            operations: 1,
            sleep_usec: 0,
            skip_locking: false,
            requests_per_sec: 1, // 1 / 2 message threads = 0 per thread -> default
            auto_rps: 0,
            split_percent: None,
            pipe_transfer_bytes: 0,
        };
        let stop = AtomicBool::new(false);
        let progress = AtomicU64::new(0);
        let outcome = std::thread::scope(|s| {
            let runner = s.spawn(|| run(&config, &stop, &progress, None));
            while progress.load(Ordering::Relaxed) < 50 {
                core::hint::spin_loop();
            }
            stop.store(true, Ordering::Release);
            runner.join().expect("run panicked")
        });
        assert!(
            outcome.whole_run.loop_count >= 50,
            "sub-1-per-thread RPS ran default-mode work: {}",
            outcome.whole_run.loop_count
        );
    }

    #[test]
    fn auto_scale_grows_when_idle_and_shrinks_when_busy() {
        // Pure scaling math (no /proc/stat I/O) at injected busy levels, matching
        // schbench's auto_scale_rps body (`schbench.c:1203-1250`).

        // Far under target: delta = 80/10 = 8, capped to 3 -> ceil(100*3) = 300.
        // Not a near-target transition, so just_hit is false (the >3 cap branch
        // never trips target_hit).
        let rate = AtomicUsize::new(100);
        let hit = AtomicBool::new(false);
        assert!(!scale_rps_for_busy(10.0, &rate, &hit, 80));
        assert_eq!(rate.load(Ordering::Acquire), 300);
        assert!(!hit.load(Ordering::Acquire));

        // Far over target: delta = 20/100 = 0.2, clamped to 0.3 -> floor(300*0.3)
        // = 90. Also not a near-target transition.
        let rate = AtomicUsize::new(300);
        let hit = AtomicBool::new(false);
        assert!(!scale_rps_for_busy(100.0, &rate, &hit, 20));
        assert_eq!(rate.load(Ordering::Acquire), 90);
        assert!(!hit.load(Ordering::Acquire));

        // Exactly on target: rate held, target_hit set on the first arrival
        // (`schbench.c:1243-1248`).
        let rate = AtomicUsize::new(123);
        let hit = AtomicBool::new(false);
        assert!(scale_rps_for_busy(50.0, &rate, &hit, 50));
        assert_eq!(rate.load(Ordering::Acquire), 123);
        assert!(hit.load(Ordering::Acquire));
    }

    #[test]
    fn auto_scale_target_hit_uses_post_damping_delta() {
        // The near-target test reads the delta AFTER its damping reassignment
        // (`schbench.c:1209-1213` grow, `:1232-1236` shrink) -- the bug the
        // pre-reassignment check had. These cases trip target_hit ONLY under the
        // correct post-damping order.

        // Grow band: busy 90, target 100 -> delta 1.111 (NOT < 1.05), damped to
        // 1 + 0.111/8 = 1.0139 (< 1.05) -> target_hit. A pre-damping check on
        // 1.111 would miss it.
        let rate = AtomicUsize::new(1000);
        let hit = AtomicBool::new(false);
        assert!(scale_rps_for_busy(90.0, &rate, &hit, 100));
        assert!(hit.load(Ordering::Acquire));

        // Shrink band: busy 100, target 95 -> delta 0.95 (NOT > 0.95), damped to
        // 0.95 + 0.05/8 = 0.95625 (> 0.95) -> target_hit. A pre-damping check on
        // 0.95 would miss it.
        let rate = AtomicUsize::new(1000);
        let hit = AtomicBool::new(false);
        assert!(scale_rps_for_busy(100.0, &rate, &hit, 95));
        assert!(hit.load(Ordering::Acquire));

        // Already hit: no second transition is reported (just_hit guards on
        // !already_hit), even on an exact-target tick.
        let rate = AtomicUsize::new(1000);
        let hit = AtomicBool::new(true);
        assert!(!scale_rps_for_busy(50.0, &rate, &hit, 50));
    }

    #[test]
    fn reset_rps_accumulators_clears_both_in_lockstep() {
        // The auto-RPS target-hit reset MUST clear the whole-run AND per-epoch rps
        // accumulators together; if a future edit drops one, Σ-per-epoch != whole-run
        // (asymmetric-counter-bookkeeping). Pins the lockstep deterministically — the
        // engine just_hit path is timing-dependent (the controller must reach target).
        let mut whole = PlatStats::default();
        whole.add_lat(500);
        let mut per_epoch: std::collections::BTreeMap<u32, PlatStats> =
            std::collections::BTreeMap::new();
        per_epoch.entry(1).or_default().add_lat(500);
        reset_rps_accumulators(&mut whole, &mut per_epoch);
        assert_eq!(whole.sample_count(), 0, "whole-run rps reset");
        assert!(per_epoch.is_empty(), "per-epoch rps reset in lockstep");
    }

    #[test]
    fn control_loop_rps_sample_floors_sub_quantum_window() {
        // The bounded teardown sleep can return on `stop` with a tiny dt; a
        // count/dt rate is unbounded as dt->0, so a sub-quantum window is dropped
        // rather than spiking rps_max / the top rps percentiles (assertable
        // metrics). Pins the floor deterministically -- the race that produces the
        // tiny dt is timing-dependent, but the math + floor it feeds is pure.
        // dt==0 is dropped, not a divide-by-zero:
        assert_eq!(control_loop_rps_sample(3, 0), None);
        // A 1us / one-completion window would read as ~1e6 cycles/sec -- dropped:
        assert_eq!(control_loop_rps_sample(1, 1_000), None);
        // Just under one quantum is still dropped:
        assert_eq!(control_loop_rps_sample(5, STOP_POLL_QUANTUM_NS - 1), None);
        // Exactly one quantum is admitted; the rate is bounded to
        // delta_loops * 1e9 / quantum, no inflation: 1 cycle / 50ms = 20/s.
        assert_eq!(control_loop_rps_sample(1, STOP_POLL_QUANTUM_NS), Some(20));
        // Even a burst of 1000 completions in one quantum caps at 1000*20 =
        // 20_000/s -- not the millions an unfloored 1us window would yield.
        assert_eq!(
            control_loop_rps_sample(1_000, STOP_POLL_QUANTUM_NS),
            Some(20_000)
        );
        // The common case -- a full ~1s window -- is unchanged: 5000/s.
        assert_eq!(control_loop_rps_sample(5_000, 1_000_000_000), Some(5_000));
    }

    #[test]
    fn engine_auto_rps_mode_runs_and_terminates() {
        // auto_rps != 0 turns on the once-per-second control thread that
        // auto-scales the live rate toward the busy target. Seeded with an
        // explicit -R so the injector services requests immediately (progress
        // hits 50 well before the first 1s control tick), this proves the
        // auto-RPS path boots, injects, and run() terminates cleanly (the control
        // thread observes stop; a hang is the nextest timeout). The scaling MATH
        // is pinned by the scale_rps_for_busy unit tests; host-busy + target-hit
        // timing is environment-dependent, so there is no sample-count assertion.
        let config = SchbenchConfig {
            message_threads: 1,
            worker_threads: 2,
            cache_footprint_kib: 16,
            operations: 1,
            sleep_usec: 0,
            skip_locking: false,
            requests_per_sec: 10_000,
            auto_rps: 50,
            split_percent: None,
            pipe_transfer_bytes: 0,
        };
        let stop = AtomicBool::new(false);
        let progress = AtomicU64::new(0);
        let outcome = std::thread::scope(|s| {
            let runner = s.spawn(|| run(&config, &stop, &progress, None));
            while progress.load(Ordering::Relaxed) < 50 {
                core::hint::spin_loop();
            }
            stop.store(true, Ordering::Release);
            runner.join().expect("run panicked")
        });
        assert!(
            outcome.whole_run.loop_count >= 50,
            "auto-RPS injector serviced requests: {}",
            outcome.whole_run.loop_count
        );
    }

    #[test]
    fn engine_splits_stats_across_phase_epochs() {
        // Drive the shared phase_epoch 1 -> 2 mid-run and confirm the engine
        // partitions its histograms + loop_count into per-epoch snapshots
        // (the scx-phase vs detached-EEVDF-phase mechanism). Event-driven via
        // the progress counter, no sleeps.
        let config = SchbenchConfig {
            message_threads: 1,
            worker_threads: 2,
            cache_footprint_kib: 16,
            operations: 1,
            sleep_usec: 0,
            skip_locking: false,
            requests_per_sec: 0,
            auto_rps: 0,
            split_percent: None,
            pipe_transfer_bytes: 0,
        };
        let stop = AtomicBool::new(false);
        let progress = AtomicU64::new(0);
        let epoch = AtomicU32::new(1); // start in a real step epoch (phase 1)
        let outcome = std::thread::scope(|s| {
            let runner = s.spawn(|| run(&config, &stop, &progress, Some(&epoch)));
            while progress.load(Ordering::Relaxed) < 50 {
                core::hint::spin_loop();
            }
            let after_phase1 = progress.load(Ordering::Relaxed);
            epoch.store(2, Ordering::Release); // transition to phase 2
            while progress.load(Ordering::Relaxed) < after_phase1 + 50 {
                core::hint::spin_loop();
            }
            stop.store(true, Ordering::Release);
            runner.join().expect("run panicked")
        });

        let by_epoch: std::collections::BTreeMap<u32, &SchbenchPhaseStats> =
            outcome.phases.iter().map(|(e, s)| (*e, s)).collect();
        let p1 = by_epoch.get(&1).expect("phase 1 present");
        let p2 = by_epoch.get(&2).expect("phase 2 present");
        // Both phases ran work cycles (we waited for >=50 cycles in each
        // window). loop_count is the robust split proof: latency samples gate
        // on delta_us>0, which a sub-µs cycle can miss, but a cycle always
        // increments loop_count.
        assert!(p1.loop_count > 0, "phase 1 ran cycles: {}", p1.loop_count);
        assert!(p2.loop_count > 0, "phase 2 ran cycles: {}", p2.loop_count);
        // Per-phase run-delay split is populated per thread class. This guards a
        // DISTINCT path the loop_count/histogram asserts don't touch: the
        // schedstat raw-pair baseline/re-baseline (phase_ss_start) + the
        // per-class Σ fold (worker vs msg). pcount is the deterministic
        // denominator — any thread scheduled in the phase accrues pcount>=1
        // (sched_info_arrive), and both phases ran cycles (workers dispatched)
        // while the msg thread is scheduled on every wake. run_delay_ns itself
        // is NOT asserted: it can be 0 on an uncontended host, so a value check
        // would flake; pcount>0 pins that the class was measured and folded.
        assert!(
            p1.worker_pcount > 0,
            "phase 1 worker run-delay split populated"
        );
        assert!(
            p2.worker_pcount > 0,
            "phase 2 worker run-delay split populated"
        );
        assert!(p1.msg_pcount > 0, "phase 1 msg run-delay split populated");
        assert!(p2.msg_pcount > 0, "phase 2 msg run-delay split populated");
        // loop_count partitions across phases, summing to the global progress —
        // no cycle is lost or double-counted at the boundary.
        let loop_sum: u64 = outcome.phases.iter().map(|(_, s)| s.loop_count).sum();
        assert_eq!(
            loop_sum, outcome.whole_run.loop_count,
            "per-phase loop_count partitions the whole-run count"
        );
        // The whole-run histogram is EXACTLY the union of the per-phase ones
        // (the drained snapshots, recombined), so the per-phase split loses no
        // samples vs the whole-run accounting.
        let phase_request_sum: u64 = outcome
            .phases
            .iter()
            .map(|(_, s)| s.request.percentiles().nr_samples)
            .sum();
        assert_eq!(
            outcome.whole_run.request.nr_samples, phase_request_sum,
            "whole-run request count == Σ per-phase counts"
        );
        let phase_wakeup_sum: u64 = outcome
            .phases
            .iter()
            .map(|(_, s)| s.wakeup.percentiles().nr_samples)
            .sum();
        assert_eq!(
            outcome.whole_run.wakeup.nr_samples, phase_wakeup_sum,
            "whole-run wakeup count == Σ per-phase counts"
        );
        // Per-phase RPS: the additive invariant (deterministic) + a timing-tolerant
        // bound on the brief first phase. These phases run far under the 1s control
        // cadence, so phase 1 normally gets ZERO rps ticks (the ~1s tick lands in
        // phase 2, epoch already advanced); on a slow/contended host phase 1 may catch
        // a single straddle tick, so we tolerate <= 1 rather than a wall-clock-fragile
        // == 0. The deterministic empty-rps -> ABSENT contract is pinned by
        // derive_rps_distribution_values_and_absent_guard; this bound still catches
        // gross mis-attribution of the bulk of rps to the brief first phase.
        assert!(
            p1.rps.sample_count() <= 1,
            "brief phase 1 gets at most one straddle rps tick, got {}",
            p1.rps.sample_count()
        );
        let phase_rps_sum: u64 = outcome
            .phases
            .iter()
            .map(|(_, s)| s.rps.sample_count())
            .sum();
        assert_eq!(
            outcome.whole_run.rps.nr_samples, phase_rps_sum,
            "whole-run rps count == Σ per-phase rps counts"
        );
    }

    #[test]
    fn engine_per_phase_rps_populates_and_sums_to_whole_run() {
        // POPULATED per-phase RPS: the control thread samples once/sec, so a phase
        // window >= ~2s gets multiple per-phase rps samples. Time-gated (the 1s
        // control cadence is the granularity floor) -- the progress-gated
        // engine_splits phases are too short to tick. Drive epoch 1 for ~2.2s then
        // epoch 2 for ~2.2s and confirm BOTH epochs carry rps samples AND the
        // per-epoch samples sum to the whole-run count (the additive invariant:
        // every sample lands in one epoch bucket AND the whole-run histogram).
        // The ~4.4s wall-clock is the irreducible cost of exercising the real 1s control
        // cadence end-to-end (2 ticks/epoch for robustness against a delayed tick on a
        // loaded host); the deterministic per-epoch invariants — the lockstep reset and
        // Σ-per-epoch==whole-run — are pinned sleep-free by
        // reset_rps_accumulators_clears_both_in_lockstep + engine_splits_stats_across_phase_epochs.
        // This test uniquely confirms the LIVE control thread POPULATES both epochs.
        let config = SchbenchConfig {
            message_threads: 1,
            worker_threads: 2,
            cache_footprint_kib: 16,
            operations: 1,
            sleep_usec: 0,
            skip_locking: false,
            requests_per_sec: 0,
            auto_rps: 0,
            split_percent: None,
            pipe_transfer_bytes: 0,
        };
        let stop = AtomicBool::new(false);
        let progress = AtomicU64::new(0);
        let epoch = AtomicU32::new(1);
        let outcome = std::thread::scope(|s| {
            let runner = s.spawn(|| run(&config, &stop, &progress, Some(&epoch)));
            // ~2.2s per epoch => >= 2 control ticks each (first tick at ~1s).
            std::thread::sleep(std::time::Duration::from_millis(2200));
            epoch.store(2, Ordering::Release);
            std::thread::sleep(std::time::Duration::from_millis(2200));
            stop.store(true, Ordering::Release);
            runner.join().expect("run panicked")
        });
        let by_epoch: std::collections::BTreeMap<u32, &SchbenchPhaseStats> =
            outcome.phases.iter().map(|(e, s)| (*e, s)).collect();
        let p1 = by_epoch.get(&1).expect("phase 1 present");
        let p2 = by_epoch.get(&2).expect("phase 2 present");
        assert!(
            p1.rps.sample_count() > 0,
            "phase 1 per-phase rps populated: {}",
            p1.rps.sample_count()
        );
        assert!(
            p2.rps.sample_count() > 0,
            "phase 2 per-phase rps populated: {}",
            p2.rps.sample_count()
        );
        // Additive invariant (no auto-RPS reset in default mode, so EXACT): Σ
        // per-epoch rps samples == whole-run rps samples.
        let phase_rps_sum: u64 = outcome
            .phases
            .iter()
            .map(|(_, s)| s.rps.sample_count())
            .sum();
        assert_eq!(
            phase_rps_sum, outcome.whole_run.rps.nr_samples,
            "Σ per-epoch rps samples == whole-run rps samples"
        );
    }

    #[test]
    fn schbench_config_serde_roundtrips() {
        // The new serialized type roundtrips unchanged.
        let cfg = SchbenchConfig::default()
            .message_threads(3)
            .worker_threads(7)
            .cache_footprint_kib(512)
            .operations(9)
            .sleep_usec(250)
            .skip_locking(true)
            // Exercise the new field's Some(p) serde surface, not just the None
            // default (the API-review roundtrip requirement).
            .split_percent(Some(33))
            // Exercise the pipe field's non-default serde surface too.
            .pipe_transfer_bytes(4096);
        let json = serde_json::to_string(&cfg).expect("SchbenchConfig must serialize");
        let back: SchbenchConfig =
            serde_json::from_str(&json).expect("SchbenchConfig must deserialize");
        assert_eq!(cfg, back, "config roundtrips unchanged");
    }

    #[test]
    fn worktype_schbench_registration_and_serde() {
        use crate::workload::WorkType;
        let wt = WorkType::schbench(
            SchbenchConfig::default()
                .message_threads(2)
                .worker_threads(4),
        );
        assert_eq!(wt.name(), "Schbench");
        // from_name yields the default-config variant.
        assert_eq!(
            WorkType::from_name("Schbench"),
            Some(WorkType::Schbench {
                config: SchbenchConfig::default()
            })
        );
        // The variant serde-roundtrips, carrying its config.
        let json = serde_json::to_string(&wt).expect("WorkType::Schbench must serialize");
        let back: WorkType =
            serde_json::from_str(&json).expect("WorkType::Schbench must deserialize");
        assert_eq!(wt, back);
    }

    #[test]
    fn schbench_config_reachable_via_prelude() {
        // Regression-pin the prelude placement: test authors construct the
        // config via `use ktstr::prelude::*`. Dropping SchbenchConfig from the
        // prelude would fail this compile. Also exercises the Eq derive.
        let cfg: crate::prelude::SchbenchConfig = crate::prelude::SchbenchConfig::default();
        assert_eq!(cfg, SchbenchConfig::default());
    }

    #[test]
    fn read_schedstat_raw_parses_own_and_handles_missing() {
        // The current thread has been scheduled, so its /proc/<tid>/schedstat
        // parses without panic into a (run_delay, pcount) pair.
        let _own = read_schedstat_raw(gettid_self());
        // A non-existent tid -> (0,0) via the file-read-failure path (thread
        // exited), matching schbench's fopen-failure handling. The parse
        // boundaries (incl. the pcount==0 mean guard) are covered below.
        assert_eq!(
            read_schedstat_raw(-1),
            (0, 0),
            "absent schedstat yields (0,0), no panic"
        );
    }

    #[test]
    fn schedstat_raw_parse_and_mean_pcount_guard() {
        // Raw parse keeps run_delay + pcount undivided (the re-poolable pair).
        assert_eq!(parse_schedstat_raw("123456 50 5"), (50, 5));
        // mean_sched_delay divides with the pcount==0 guard (no div-by-zero).
        assert_eq!(mean_sched_delay((50, 5)), 10); // run_delay/pcount
        assert_eq!(mean_sched_delay((50, 0)), 0); // pcount==0 guard
        assert_eq!(mean_sched_delay(parse_schedstat_raw("0 0 0")), 0); // !sched_info_on()
        assert_eq!(mean_sched_delay((0, 5)), 0); // 0 run_delay -> 0 mean
    }

    #[test]
    #[should_panic(expected = "schedstat field 3")]
    fn parse_schedstat_raw_short_line_panics() {
        // A present-but-short line is a kernel/parse bug: fail loud, not a
        // silent 0 (the fail-loud ruling).
        parse_schedstat_raw("100 50");
    }

    #[test]
    #[should_panic(expected = "schedstat field 2")]
    fn parse_schedstat_raw_nonnumeric_panics() {
        parse_schedstat_raw("alpha beta gamma");
    }
}