benchset 0.0.3__tar.gz → 0.0.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. {benchset-0.0.3 → benchset-0.0.4}/Cargo.lock +1 -1
  2. {benchset-0.0.3 → benchset-0.0.4}/Cargo.toml +1 -1
  3. {benchset-0.0.3 → benchset-0.0.4}/PKG-INFO +3 -3
  4. {benchset-0.0.3 → benchset-0.0.4}/Package.swift +2 -2
  5. {benchset-0.0.3 → benchset-0.0.4}/README.md +2 -2
  6. {benchset-0.0.3 → benchset-0.0.4}/benchset/cli.py +1 -1
  7. {benchset-0.0.3 → benchset-0.0.4}/benchset.egg-info/PKG-INFO +3 -3
  8. {benchset-0.0.3 → benchset-0.0.4}/native/src/kernel_tests.rs +52 -0
  9. {benchset-0.0.3 → benchset-0.0.4}/native/src/lib.rs +38 -23
  10. {benchset-0.0.3 → benchset-0.0.4}/native/src/runtime_tests.rs +183 -0
  11. {benchset-0.0.3 → benchset-0.0.4}/pyproject.toml +1 -1
  12. {benchset-0.0.3 → benchset-0.0.4}/tests/swift/BenchsetTests.swift +53 -0
  13. {benchset-0.0.3 → benchset-0.0.4}/tests/test_cli.py +124 -15
  14. {benchset-0.0.3 → benchset-0.0.4}/tests/test_semantics.py +11 -6
  15. {benchset-0.0.3 → benchset-0.0.4}/LICENSE +0 -0
  16. {benchset-0.0.3 → benchset-0.0.4}/MANIFEST.in +0 -0
  17. {benchset-0.0.3 → benchset-0.0.4}/Sources/Benchset/Benchset.swift +0 -0
  18. {benchset-0.0.3 → benchset-0.0.4}/benchset/__init__.py +0 -0
  19. {benchset-0.0.3 → benchset-0.0.4}/benchset/__main__.py +0 -0
  20. {benchset-0.0.3 → benchset-0.0.4}/benchset/_native.py +0 -0
  21. {benchset-0.0.3 → benchset-0.0.4}/benchset/api.py +0 -0
  22. {benchset-0.0.3 → benchset-0.0.4}/benchset/models.py +0 -0
  23. {benchset-0.0.3 → benchset-0.0.4}/benchset/py.typed +0 -0
  24. {benchset-0.0.3 → benchset-0.0.4}/benchset/reference.py +0 -0
  25. {benchset-0.0.3 → benchset-0.0.4}/benchset.egg-info/SOURCES.txt +0 -0
  26. {benchset-0.0.3 → benchset-0.0.4}/benchset.egg-info/dependency_links.txt +0 -0
  27. {benchset-0.0.3 → benchset-0.0.4}/benchset.egg-info/entry_points.txt +0 -0
  28. {benchset-0.0.3 → benchset-0.0.4}/benchset.egg-info/requires.txt +0 -0
  29. {benchset-0.0.3 → benchset-0.0.4}/benchset.egg-info/top_level.txt +0 -0
  30. {benchset-0.0.3 → benchset-0.0.4}/examples/native/c.c +0 -0
  31. {benchset-0.0.3 → benchset-0.0.4}/examples/native/rust.rs +0 -0
  32. {benchset-0.0.3 → benchset-0.0.4}/examples/native/swift.swift +0 -0
  33. {benchset-0.0.3 → benchset-0.0.4}/include/benchset.h +0 -0
  34. {benchset-0.0.3 → benchset-0.0.4}/include/module.modulemap +0 -0
  35. {benchset-0.0.3 → benchset-0.0.4}/native/reference-calibration.json +0 -0
  36. {benchset-0.0.3 → benchset-0.0.4}/native/src/ffi.rs +0 -0
  37. {benchset-0.0.3 → benchset-0.0.4}/native/src/ffi_reference_tests.rs +0 -0
  38. {benchset-0.0.3 → benchset-0.0.4}/native/src/ffi_tests.rs +0 -0
  39. {benchset-0.0.3 → benchset-0.0.4}/native/src/kernel.rs +0 -0
  40. {benchset-0.0.3 → benchset-0.0.4}/native/src/reference.rs +0 -0
  41. {benchset-0.0.3 → benchset-0.0.4}/native/src/reference_tests.rs +0 -0
  42. {benchset-0.0.3 → benchset-0.0.4}/rust-toolchain.toml +0 -0
  43. {benchset-0.0.3 → benchset-0.0.4}/scripts/__init__.py +0 -0
  44. {benchset-0.0.3 → benchset-0.0.4}/scripts/build_backend.py +0 -0
  45. {benchset-0.0.3 → benchset-0.0.4}/scripts/build_native.py +0 -0
  46. {benchset-0.0.3 → benchset-0.0.4}/scripts/check_native_coverage.py +0 -0
  47. {benchset-0.0.3 → benchset-0.0.4}/scripts/check_source_install.py +0 -0
  48. {benchset-0.0.3 → benchset-0.0.4}/scripts/package_native.py +0 -0
  49. {benchset-0.0.3 → benchset-0.0.4}/scripts/release.py +0 -0
  50. {benchset-0.0.3 → benchset-0.0.4}/scripts/version.py +0 -0
  51. {benchset-0.0.3 → benchset-0.0.4}/setup.cfg +0 -0
  52. {benchset-0.0.3 → benchset-0.0.4}/setup.py +0 -0
  53. {benchset-0.0.3 → benchset-0.0.4}/tests/__init__.py +0 -0
  54. {benchset-0.0.3 → benchset-0.0.4}/tests/helpers.py +0 -0
  55. {benchset-0.0.3 → benchset-0.0.4}/tests/swift/ReferenceTests.swift +0 -0
  56. {benchset-0.0.3 → benchset-0.0.4}/tests/test_benchset.py +0 -0
  57. {benchset-0.0.3 → benchset-0.0.4}/tests/test_build.py +0 -0
  58. {benchset-0.0.3 → benchset-0.0.4}/tests/test_errors.py +0 -0
  59. {benchset-0.0.3 → benchset-0.0.4}/tests/test_reference.py +0 -0
  60. {benchset-0.0.3 → benchset-0.0.4}/tests/test_reference_artifacts.py +0 -0
  61. {benchset-0.0.3 → benchset-0.0.4}/tests/test_reference_native.py +0 -0
  62. {benchset-0.0.3 → benchset-0.0.4}/tests/test_release.py +0 -0
  63. {benchset-0.0.3 → benchset-0.0.4}/tests/test_resources.py +0 -0
  64. {benchset-0.0.3 → benchset-0.0.4}/tests/test_signals.py +0 -0
  65. {benchset-0.0.3 → benchset-0.0.4}/tests/test_version.py +0 -0
  66. {benchset-0.0.3 → benchset-0.0.4}/tests/typing/__init__.py +0 -0
  67. {benchset-0.0.3 → benchset-0.0.4}/tests/typing/reference_negative.py +0 -0
  68. {benchset-0.0.3 → benchset-0.0.4}/tests/typing/reference_positive.py +0 -0
  69. {benchset-0.0.3 → benchset-0.0.4}/tests/typing/test_reference_types.py +0 -0
@@ -4,4 +4,4 @@ version = 3
4
4
 
5
5
  [[package]]
6
6
  name = "benchset"
7
- version = "0.0.3"
7
+ version = "0.0.4"
@@ -1,7 +1,7 @@
1
1
  [package]
2
2
  name = "benchset"
3
3
  # Generated from [project].version in pyproject.toml by scripts/version.py.
4
- version = "0.0.3"
4
+ version = "0.0.4"
5
5
  edition = "2021"
6
6
  rust-version = "1.74"
7
7
  description = "Embeddable sustained scalar CPU benchmarks with a C ABI"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: benchset
3
- Version: 0.0.3
3
+ Version: 0.0.4
4
4
  Summary: Sustained scalar CPU benchmarks with an embeddable Rust core
5
5
  Author-email: Evgeniy Blinov <zheni-b@yandex.ru>
6
6
  Project-URL: Source, https://github.com/mutating/benchset
@@ -356,7 +356,7 @@ settings are comparable.
356
356
 
357
357
  ```toml
358
358
  [dependencies]
359
- benchset = "0.0.3"
359
+ benchset = "0.0.4"
360
360
  # For changes from a local source checkout, use:
361
361
  # benchset = { path = "../benchset" }
362
362
  ```
@@ -471,7 +471,7 @@ the same repository. Rust is not required by the consumer.
471
471
  For a Swift manifest, add:
472
472
 
473
473
  ```swift
474
- .package(url: "https://github.com/mutating/benchset.git", .exact("0.0.3"))
474
+ .package(url: "https://github.com/mutating/benchset.git", .exact("0.0.4"))
475
475
  ```
476
476
 
477
477
  Add `.product(name: "Benchset", package: "benchset")` to your target's
@@ -9,8 +9,8 @@ let package = Package(
9
9
  products: [.library(name: "Benchset", targets: ["Benchset"])],
10
10
  targets: [
11
11
  .binaryTarget(name: "CBenchset",
12
- url: "https://github.com/mutating/benchset/releases/download/v0.0.3/Benchset.xcframework.zip",
13
- checksum: "f50ee204c5a636497bdae1f0a2bb8a7987042f58bd84c5153cae6201e1e35aea"),
12
+ url: "https://github.com/mutating/benchset/releases/download/v0.0.4/Benchset.xcframework.zip",
13
+ checksum: "0e6643b1daa58773a93aec6b4228b2d81580f058dc5348609457363a9b5ec4ae"),
14
14
  .target(name: "Benchset", dependencies: ["CBenchset"]),
15
15
  .testTarget(name: "BenchsetTests", dependencies: ["Benchset"], path: "tests/swift")
16
16
  ]
@@ -322,7 +322,7 @@ settings are comparable.
322
322
 
323
323
  ```toml
324
324
  [dependencies]
325
- benchset = "0.0.3"
325
+ benchset = "0.0.4"
326
326
  # For changes from a local source checkout, use:
327
327
  # benchset = { path = "../benchset" }
328
328
  ```
@@ -437,7 +437,7 @@ the same repository. Rust is not required by the consumer.
437
437
  For a Swift manifest, add:
438
438
 
439
439
  ```swift
440
- .package(url: "https://github.com/mutating/benchset.git", .exact("0.0.3"))
440
+ .package(url: "https://github.com/mutating/benchset.git", .exact("0.0.4"))
441
441
  ```
442
442
 
443
443
  Add `.product(name: "Benchset", package: "benchset")` to your target's
@@ -53,7 +53,7 @@ class _Parser(argparse.ArgumentParser):
53
53
 
54
54
 
55
55
  def settings(argv: List[str]) -> Settings:
56
- source = _Arguments(named_arguments=['bench', 'warmup', 'duration'])
56
+ source = _Arguments(named_arguments=[])
57
57
  source.parser = _Parser(
58
58
  prog='benchset', argument_default=argparse.SUPPRESS,
59
59
  description='Sustained scalar CPU throughput and speed relative to Raspberry Pi 5.',
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: benchset
3
- Version: 0.0.3
3
+ Version: 0.0.4
4
4
  Summary: Sustained scalar CPU benchmarks with an embeddable Rust core
5
5
  Author-email: Evgeniy Blinov <zheni-b@yandex.ru>
6
6
  Project-URL: Source, https://github.com/mutating/benchset
@@ -356,7 +356,7 @@ settings are comparable.
356
356
 
357
357
  ```toml
358
358
  [dependencies]
359
- benchset = "0.0.3"
359
+ benchset = "0.0.4"
360
360
  # For changes from a local source checkout, use:
361
361
  # benchset = { path = "../benchset" }
362
362
  ```
@@ -471,7 +471,7 @@ the same repository. Rust is not required by the consumer.
471
471
  For a Swift manifest, add:
472
472
 
473
473
  ```swift
474
- .package(url: "https://github.com/mutating/benchset.git", .exact("0.0.3"))
474
+ .package(url: "https://github.com/mutating/benchset.git", .exact("0.0.4"))
475
475
  ```
476
476
 
477
477
  Add `.product(name: "Benchset", package: "benchset")` to your target's
@@ -45,6 +45,22 @@ fn golden_workload_vectors() {
45
45
  8459165180143766608,
46
46
  ],
47
47
  ),
48
+ (
49
+ 2,
50
+ [
51
+ 6438600776746354456,
52
+ 496348541366349225,
53
+ 10204253244531584854,
54
+ ],
55
+ ),
56
+ (
57
+ 3,
58
+ [
59
+ 8423339373471318280,
60
+ 5810453192619338186,
61
+ 1329735283187018493,
62
+ ],
63
+ ),
48
64
  (
49
65
  usize::MAX,
50
66
  [
@@ -78,6 +94,42 @@ fn crafted_parser_inputs_and_wrapping_states() {
78
94
  }
79
95
  }
80
96
 
97
+ #[test]
98
+ fn parser_preserves_initial_state_before_any_separator() {
99
+ // A leading separator resets parser state. Exercise prefixes before that
100
+ // reset, including invalid tokens, while preserving the calibrated workload.
101
+ for (state, expected) in [
102
+ (
103
+ 0,
104
+ [412704983584011650, 9308271035056677818, 3612450324633344040],
105
+ ),
106
+ (
107
+ 1,
108
+ [
109
+ 9009395174570800451,
110
+ 17241420472984297642,
111
+ 8483684467978543156,
112
+ ],
113
+ ),
114
+ (
115
+ u64::MAX,
116
+ [
117
+ 5890298480000480609,
118
+ 1796841914283376693,
119
+ 15172682340896178227,
120
+ ],
121
+ ),
122
+ ] {
123
+ for (prefix, checksum) in b"+.e".iter().zip(expected) {
124
+ let mut kernel = Kernel::new(0).unwrap();
125
+ kernel.state = state;
126
+ kernel.input.fill(b' ');
127
+ kernel.input[0] = *prefix;
128
+ assert_eq!(kernel.step(), checksum);
129
+ }
130
+ }
131
+ }
132
+
81
133
  #[test]
82
134
  fn capacity_failure_is_reported_without_real_oom() {
83
135
  assert!(allocate_table(usize::MAX).unwrap_err().contains("capacity"));
@@ -169,6 +169,20 @@ struct Timing {
169
169
  end: Instant,
170
170
  }
171
171
 
172
+ impl Timing {
173
+ fn is_warmup(self, now: Instant) -> bool {
174
+ now < self.warm_end
175
+ }
176
+
177
+ fn sample_elapsed(self, now: Instant, last_elapsed: f64) -> Option<f64> {
178
+ if self.is_warmup(now) || now >= self.end {
179
+ return None;
180
+ }
181
+ let elapsed = now.duration_since(self.warm_end).as_secs_f64();
182
+ (elapsed - last_elapsed >= 1.0).then_some(elapsed)
183
+ }
184
+ }
185
+
172
186
  struct Workers {
173
187
  handles: Vec<JoinHandle<()>>,
174
188
  stop: Arc<AtomicBool>,
@@ -319,7 +333,7 @@ fn run_with(
319
333
  {
320
334
  break;
321
335
  }
322
- let warmup = now < times.warm_end;
336
+ let warmup = times.is_warmup(now);
323
337
  // Publish once per eight iterations, never contend on a shared counter.
324
338
  for _ in 0..8 {
325
339
  checksum = kernel.step();
@@ -387,24 +401,21 @@ fn run_with(
387
401
  let mut last_sample_iterations = 0;
388
402
  loop {
389
403
  let now = Instant::now();
390
- let warmup = now < times.warm_end;
404
+ let warmup = times.is_warmup(now);
391
405
  let (phase, phase_start, phase_duration) = if warmup {
392
406
  (Phase::Warmup, times.start, options.warmup)
393
407
  } else {
394
408
  (Phase::Measurement, times.warm_end, options.measurement)
395
409
  };
396
410
  let iterations = sum(&counters, warmup);
397
- if phase == Phase::Measurement {
398
- let elapsed = now.saturating_duration_since(times.warm_end).as_secs_f64();
399
- if elapsed - last_sample_elapsed >= 1.0 && now < times.end {
400
- append_sample(
401
- &mut result.samples,
402
- elapsed,
403
- iterations,
404
- &mut last_sample_elapsed,
405
- &mut last_sample_iterations,
406
- );
407
- }
411
+ if let Some(elapsed) = times.sample_elapsed(now, last_sample_elapsed) {
412
+ append_sample(
413
+ &mut result.samples,
414
+ elapsed,
415
+ iterations,
416
+ &mut last_sample_elapsed,
417
+ &mut last_sample_iterations,
418
+ );
408
419
  }
409
420
  if last_phase != phase || now.duration_since(last_progress) >= Duration::from_millis(100) {
410
421
  on_progress(Progress {
@@ -433,16 +444,7 @@ fn run_with(
433
444
  for handle in workers.handles.drain(..) {
434
445
  panicked |= handle.join().is_err();
435
446
  }
436
- result.iterations = sum(&counters, false);
437
- result.elapsed_seconds = counters
438
- .iter()
439
- .map(|c| c.finish_ns.load(Ordering::Relaxed))
440
- .max()
441
- .unwrap_or(0) as f64
442
- / 1e9;
443
- result.checksum = counters
444
- .iter()
445
- .fold(0, |value, c| value ^ c.checksum.load(Ordering::Relaxed));
447
+ collect_counters(&mut result, &counters);
446
448
  finish_samples(&mut result);
447
449
  finish_result(&mut result, panicked, cancellation.is_cancelled());
448
450
  on_progress(Progress {
@@ -469,6 +471,19 @@ fn release(gate: &(std::sync::Mutex<bool>, std::sync::Condvar)) {
469
471
  gate.1.notify_all();
470
472
  }
471
473
 
474
+ fn collect_counters(result: &mut BenchmarkResult, counters: &[Arc<Counters>]) {
475
+ result.iterations = sum(counters, false);
476
+ result.elapsed_seconds = counters
477
+ .iter()
478
+ .map(|c| c.finish_ns.load(Ordering::Relaxed))
479
+ .max()
480
+ .unwrap_or(0) as f64
481
+ / 1e9;
482
+ result.checksum = counters
483
+ .iter()
484
+ .fold(0, |value, c| value ^ c.checksum.load(Ordering::Relaxed));
485
+ }
486
+
472
487
  fn sum(counters: &[Arc<Counters>], warmup: bool) -> u64 {
473
488
  counters
474
489
  .iter()
@@ -49,6 +49,8 @@ fn assert_recovered() {
49
49
 
50
50
  #[test]
51
51
  fn public_defaults_names_and_shared_cancellation() {
52
+ assert_eq!(DEFAULT_WARMUP_SECONDS, 120.0);
53
+ assert_eq!(DEFAULT_MEASUREMENT_SECONDS, 60.0);
52
54
  assert_eq!(Benchmark::SingleCore.name(), "single_core");
53
55
  assert_eq!(Benchmark::MultiCore.name(), "multi_core");
54
56
  assert_eq!(
@@ -141,6 +143,13 @@ fn real_runs_account_for_work_and_progress_on_caller() {
141
143
  assert!((0.0..=1.0).contains(&p.fraction));
142
144
  if matches!(p.phase, Phase::Warmup | Phase::Measurement) {
143
145
  assert!(p.phase_elapsed_seconds <= p.phase_duration_seconds);
146
+ let elapsed = p.phase_elapsed_seconds
147
+ + if p.phase == Phase::Measurement {
148
+ 0.02
149
+ } else {
150
+ 0.0
151
+ };
152
+ assert!((p.fraction - (elapsed / 0.06).min(1.0)).abs() < 1e-12);
144
153
  } else {
145
154
  assert_eq!(p.phase_elapsed_seconds, result.elapsed_seconds);
146
155
  }
@@ -807,3 +816,177 @@ fn final_sample_repairs_snapshot_after_worker_finish() {
807
816
  }
808
817
  }
809
818
  }
819
+
820
+ #[test]
821
+ fn timing_uses_half_open_phases_and_periodic_measurement_samples() {
822
+ let start = Instant::now();
823
+ let times = Timing {
824
+ start,
825
+ warm_end: start + Duration::from_secs(1),
826
+ end: start + Duration::from_secs(5),
827
+ };
828
+ assert!(times.is_warmup(times.warm_end - Duration::from_nanos(1)));
829
+ assert!(!times.is_warmup(times.warm_end));
830
+ assert!(!times.is_warmup(times.end));
831
+ assert!(!Timing {
832
+ warm_end: start,
833
+ ..times
834
+ }
835
+ .is_warmup(start));
836
+ for (offset_ns, last_elapsed, expected) in [
837
+ (0, 0.0, None),
838
+ (1_000_000_000, 0.0, None),
839
+ (1_999_999_999, 0.0, None),
840
+ (2_000_000_000, 0.0, Some(1.0)),
841
+ (2_100_000_000, 1.0, None),
842
+ (2_500_000_000, 0.5, Some(1.5)),
843
+ (3_000_000_000, 1.0, Some(2.0)),
844
+ (5_000_000_000, 0.0, None),
845
+ (5_000_000_001, 0.0, None),
846
+ ] {
847
+ assert_eq!(
848
+ times.sample_elapsed(start + Duration::from_nanos(offset_ns), last_elapsed),
849
+ expected
850
+ );
851
+ }
852
+ }
853
+
854
+ #[test]
855
+ fn cancelled_warmup_excludes_work_and_reports_elapsed_fraction() {
856
+ let _serial = TEST_LOCK.lock().unwrap();
857
+ let token = Cancellation::default();
858
+ let before = Instant::now();
859
+ let mut events = Vec::new();
860
+ let mut observed_work = false;
861
+ let result = run(
862
+ Benchmark::SingleCore,
863
+ Options {
864
+ warmup: Duration::from_secs(10),
865
+ measurement: Duration::from_secs(20),
866
+ },
867
+ &token,
868
+ |p| {
869
+ events.push(p);
870
+ if p.phase == Phase::Warmup && p.iterations > 0 {
871
+ observed_work = true;
872
+ token.cancel();
873
+ } else if p.phase == Phase::Measurement {
874
+ token.cancel();
875
+ }
876
+ },
877
+ );
878
+ assert!(observed_work, "warmup must publish its completed work");
879
+ assert_eq!(result.status, Status::Cancelled);
880
+ assert_eq!(
881
+ result.iterations, 0,
882
+ "warmup work must not enter measurement"
883
+ );
884
+ assert_eq!(result.elapsed_seconds, 0.0);
885
+ let final_event = events.last().unwrap();
886
+ assert_eq!(final_event.phase, Phase::Cancelled);
887
+ assert!(final_event.fraction > 0.0);
888
+ assert!(final_event.fraction <= before.elapsed().as_secs_f64() / 30.0 + 1e-12);
889
+ assert!(result.score.is_none() && result.pi_equivalent.is_none());
890
+ }
891
+
892
+ #[test]
893
+ fn observed_measurement_time_produces_periodic_samples_before_the_tail() {
894
+ let _serial = TEST_LOCK.lock().unwrap();
895
+ let token = Cancellation::default();
896
+ let mut observed_snapshot = None;
897
+ let mut previous_progress = None;
898
+ let result = run(
899
+ Benchmark::SingleCore,
900
+ Options {
901
+ warmup: Duration::ZERO,
902
+ measurement: Duration::from_secs(10),
903
+ },
904
+ &token,
905
+ |p| {
906
+ if p.phase == Phase::Measurement {
907
+ if let Some(previous) = previous_progress {
908
+ assert!(
909
+ p.phase_elapsed_seconds - previous >= 0.1 - 1e-9,
910
+ "same-phase progress must be throttled to 10 Hz"
911
+ );
912
+ }
913
+ previous_progress = Some(p.phase_elapsed_seconds);
914
+ }
915
+ if p.phase == Phase::Measurement && p.phase_elapsed_seconds >= 1.0 {
916
+ let first = *observed_snapshot.get_or_insert(p.phase_elapsed_seconds);
917
+ if p.phase_elapsed_seconds - first >= 0.2 {
918
+ token.cancel();
919
+ }
920
+ }
921
+ },
922
+ );
923
+ assert_eq!(result.status, Status::Cancelled);
924
+ assert!(
925
+ result.samples.len() >= 2,
926
+ "an observed snapshot must precede the cancellation tail"
927
+ );
928
+ assert!(result.samples[0].elapsed_seconds <= observed_snapshot.unwrap());
929
+ assert!(result.samples[..result.samples.len() - 1]
930
+ .iter()
931
+ .all(|s| s.duration_seconds >= 1.0));
932
+ }
933
+
934
+ #[test]
935
+ fn callback_panic_stops_already_running_workers_before_the_deadline() {
936
+ let _serial = TEST_LOCK.lock().unwrap();
937
+ let token = Cancellation::default();
938
+ let cleanup = token.clone();
939
+ let (done_tx, done_rx) = mpsc::channel();
940
+ let worker = thread::spawn(move || {
941
+ let outcome = std::panic::catch_unwind(|| {
942
+ run(
943
+ Benchmark::SingleCore,
944
+ Options {
945
+ warmup: Duration::from_secs(60),
946
+ measurement: Duration::from_secs(60),
947
+ },
948
+ &token,
949
+ |p| {
950
+ if p.phase == Phase::Warmup && p.iterations > 0 {
951
+ panic!("callback fault after observed work");
952
+ }
953
+ },
954
+ )
955
+ });
956
+ done_tx.send(outcome.is_err()).unwrap();
957
+ });
958
+ let stopped = done_rx.recv_timeout(Duration::from_secs(5));
959
+ // Always unblock a broken worker before asserting, including on a mutant.
960
+ cleanup.cancel();
961
+ worker.join().unwrap();
962
+ assert!(stopped.unwrap());
963
+ assert_recovered();
964
+ }
965
+
966
+ #[test]
967
+ fn counter_aggregation_preserves_measurement_work_max_finish_and_xor() {
968
+ let counters: Vec<_> = [
969
+ (50, 8, 2_000_000_000, 0b1100),
970
+ (500, 16, 1_500_000_000, 0b1010),
971
+ ]
972
+ .into_iter()
973
+ .map(|(warmup, measurement, finish, checksum)| {
974
+ Arc::new(Counters {
975
+ warmup: AtomicU64::new(warmup),
976
+ measurement: AtomicU64::new(measurement),
977
+ finish_ns: AtomicU64::new(finish),
978
+ checksum: AtomicU64::new(checksum),
979
+ })
980
+ })
981
+ .collect();
982
+ let mut result = fresh_result();
983
+ collect_counters(&mut result, &counters);
984
+ assert_eq!(result.iterations, 24);
985
+ assert_eq!(result.elapsed_seconds, 2.0);
986
+ assert_eq!(result.checksum, 0b0110);
987
+ collect_counters(&mut result, &[]);
988
+ assert_eq!(
989
+ (result.iterations, result.elapsed_seconds, result.checksum),
990
+ (0, 0.0, 0)
991
+ );
992
+ }
@@ -5,7 +5,7 @@ backend-path = ["."]
5
5
 
6
6
  [project]
7
7
  name = "benchset"
8
- version = "0.0.3"
8
+ version = "0.0.4"
9
9
  authors = [
10
10
  { name="Evgeniy Blinov", email="zheni-b@yandex.ru" },
11
11
  ]
@@ -371,6 +371,59 @@ struct BenchsetTests {
371
371
  #expect(stub.creates == 2 && stub.frees == 2 && stub.cancels == 0)
372
372
  }
373
373
 
374
+ @Test func cleanupWaitsForAnInFlightNativeCancellation() throws {
375
+ let stub = NativeStub()
376
+ let running = DispatchSemaphore(value: 0)
377
+ let releaseRun = DispatchSemaphore(value: 0)
378
+ let cancelling = DispatchSemaphore(value: 0)
379
+ let releaseCancel = DispatchSemaphore(value: 0)
380
+ let freed = DispatchSemaphore(value: 0)
381
+ let finished = DispatchSemaphore(value: 0)
382
+ let cancelDone = DispatchSemaphore(value: 0)
383
+ let cancelInFlight = LockedBox(false)
384
+ let output = LockedBox<Result<BenchmarkResult, Error>?>(nil)
385
+ stub.action = { _, _ in
386
+ running.signal()
387
+ #expect(releaseRun.wait(timeout: .now() + 5) == .success)
388
+ }
389
+ var native = stub.api
390
+ native.cancel = { handle in
391
+ #expect(handle == stub.pointer)
392
+ cancelInFlight.value = true
393
+ cancelling.signal()
394
+ #expect(releaseCancel.wait(timeout: .now() + 5) == .success)
395
+ cancelInFlight.value = false
396
+ }
397
+ native.free = { handle in
398
+ #expect(handle == stub.pointer)
399
+ #expect(!cancelInFlight.value, "free must not overlap a native call using the handle")
400
+ stub.frees += 1
401
+ freed.signal()
402
+ }
403
+ let runner = BenchmarkRunner(native: native)
404
+ defer { releaseRun.signal(); releaseCancel.signal() }
405
+ DispatchQueue.global().async {
406
+ output.value = Result { try runner.run(.singleCore) }
407
+ finished.signal()
408
+ }
409
+ #expect(running.wait(timeout: .now() + 5) == .success)
410
+ DispatchQueue.global().async {
411
+ runner.cancel()
412
+ cancelDone.signal()
413
+ }
414
+ #expect(cancelling.wait(timeout: .now() + 5) == .success)
415
+ releaseRun.signal()
416
+ // Give cleanup a chance to attempt free while cancellation owns the
417
+ // handle. The assertion in free checks actual overlap, not elapsed time.
418
+ _ = freed.wait(timeout: .now() + 0.2)
419
+ releaseCancel.signal()
420
+ #expect(cancelDone.wait(timeout: .now() + 5) == .success)
421
+ #expect(finished.wait(timeout: .now() + 5) == .success)
422
+ let result = try #require(output.value)
423
+ #expect(try result.get().success)
424
+ #expect(stub.frees == 1)
425
+ }
426
+
374
427
  @Test func terminalCancellationKeepsCompletedResult() throws {
375
428
  let runner = BenchmarkRunner()
376
429
  let result = try runner.run(.singleCore, warmupSeconds: 0, measurementSeconds: 0.03) {
@@ -26,6 +26,10 @@ from benchset import (
26
26
  cli,
27
27
  )
28
28
 
29
+ # Content checks allow slow interpreter startup on shared runners. Cancellation
30
+ # and terminal-disconnect responsiveness retain their separate 5-second bounds.
31
+ CLI_PROCESS_TIMEOUT = 30
32
+
29
33
 
30
34
  def assert_short_cli_completed_or_no_work(
31
35
  result: subprocess.CompletedProcess, names: List[str], warmup: float, encoding: str = 'utf-8',
@@ -57,6 +61,7 @@ def assert_short_cli_completed_or_no_work(
57
61
  assert float(fields['Measurement seconds']) == 0.03
58
62
  assert fields['Unit'] == 'iterations/s'
59
63
  if index < len(completed):
64
+ assert fields['Reason'] in ('—', '-', '\\u2014')
60
65
  assert fields['Status'] == 'completed'
61
66
  number, unit = fields['Pi equivalent'].split(' \u00d7 ' if encoding == 'utf-8' else ' x ')
62
67
  assert float(number) > 0
@@ -163,10 +168,8 @@ def test_information_is_complete_and_has_no_stderr(
163
168
  invocation: Tuple[List[str], Dict[str, str]], option: str,
164
169
  ) -> None:
165
170
  command, environment = invocation
166
- # Shared CI runners can delay interpreter startup; this checks content,
167
- # while cancellation responsiveness has its own stricter deadlines.
168
171
  result = subprocess.run([*command, option], env=environment, capture_output=True,
169
- encoding='utf-8', check=False, timeout=30)
172
+ encoding='utf-8', check=False, timeout=CLI_PROCESS_TIMEOUT)
170
173
  assert result.returncode == 0, result.stderr
171
174
  assert not result.stderr
172
175
  if option == '--version':
@@ -200,7 +203,7 @@ def test_invalid_arguments_are_actionable_without_a_traceback(
200
203
  ) -> None:
201
204
  command, environment = invocation
202
205
  result = subprocess.run([*command, *arguments], env=environment, capture_output=True,
203
- encoding='utf-8', check=False, timeout=10)
206
+ encoding='utf-8', check=False, timeout=CLI_PROCESS_TIMEOUT)
204
207
  assert result.returncode == 2
205
208
  assert not result.stdout
206
209
  assert 'usage: benchset' in result.stderr
@@ -223,13 +226,13 @@ def test_real_entrypoints_accept_selection_order_numeric_forms_and_redirected_ou
223
226
  [f'--{name}={value}'] if equals else ['--' + name, value]
224
227
  )]
225
228
  result = subprocess.run([*command, *arguments], env=environment, capture_output=True,
226
- check=False, timeout=10)
229
+ check=False, timeout=CLI_PROCESS_TIMEOUT)
227
230
  names = ['single_core', 'multi_core'] if benchmark is None or benchmark == 'all' else [benchmark]
228
231
  assert_short_cli_completed_or_no_work(result, names, float(warmup))
229
232
 
230
233
 
231
234
  def test_defaults_repeated_options_and_environment_independence(
232
- monkeypatch: pytest.MonkeyPatch,
235
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
233
236
  ) -> None:
234
237
  calls: List[Tuple[str, float, float]] = []
235
238
 
@@ -243,12 +246,94 @@ def test_defaults_repeated_options_and_environment_independence(
243
246
  monkeypatch.setenv('BENCHSET_DURATION', '0')
244
247
  assert cli.main([]) == 0
245
248
  assert calls == [('single_core', 120, 60), ('multi_core', 120, 60)]
249
+ output = capsys.readouterr()
250
+ assert not output.err
251
+ sections = output.out.rstrip('\n').split('\n\n')
252
+ assert len(sections) == 2
253
+ assert sections[0].startswith('Benchmark')
254
+ assert sections[1].startswith('Benchmark')
255
+ assert output.out.endswith(' —\n')
246
256
  calls.clear()
247
257
  assert cli.main(['--bench=all', '--bench', 'multi_core', '--warmup=0.5', '--warmup=-0.0',
248
258
  '--duration=2', '--duration', '+.03']) == 0
249
259
  assert calls == [('multi_core', -0.0, 0.03)]
250
260
 
251
261
 
262
+ @pytest.mark.parametrize(('arguments', 'message'), [
263
+ (['--warmup=oops'], 'argument --warmup: must be a number of seconds'),
264
+ (['--duration=oops'], 'argument --duration: must be a number of seconds'),
265
+ (['--warmup', '-1e-3'], '--warmup must be finite and nonnegative'),
266
+ (['--warmup=nan'], '--warmup must be finite and nonnegative'),
267
+ (['--duration=0'], '--duration must be finite and positive'),
268
+ (['--duration', '-NaN'], '--duration must be finite and positive'),
269
+ ])
270
+ def test_programmatic_cli_rejects_invalid_durations_with_option_diagnostic(
271
+ arguments: List[str], message: str, capsys: pytest.CaptureFixture[str],
272
+ ) -> None:
273
+ assert cli.main(arguments) == 2
274
+ captured = capsys.readouterr()
275
+ assert not captured.out
276
+ assert captured.err.startswith('usage: benchset ')
277
+ assert captured.err.splitlines()[-1] == 'benchset: error: ' + message
278
+
279
+
280
+ def test_cli_help_keeps_explanations_and_copyable_examples_on_separate_lines(
281
+ capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch,
282
+ ) -> None:
283
+ monkeypatch.setenv('COLUMNS', '80')
284
+ # A calling application's argv must not change the CLI's displayed name.
285
+ monkeypatch.setattr(sys, 'argv', ['embedding-application'])
286
+ assert cli.main(['--help']) == 0
287
+ captured = capsys.readouterr()
288
+ assert not captured.err
289
+ paragraphs = captured.out.strip().split('\n\n')
290
+ assert paragraphs[0].startswith('usage: benchset ')
291
+ assert paragraphs[1] == 'Sustained scalar CPU throughput and speed relative to Raspberry Pi 5.'
292
+ assert paragraphs[-2].splitlines() == [
293
+ 'Higher iterations/s is faster. Pi equivalent: 0.5 = half the Pi speed; 2 = twice.',
294
+ 'With no options, runs single_core then multi_core (about 6 minutes total).',
295
+ 'Ctrl+C cancels the run.',
296
+ 'Exit codes: 0 success, 1 failed, 2 invalid arguments, 130 cancelled.',
297
+ ]
298
+ assert paragraphs[-1].splitlines() == [
299
+ 'Examples:',
300
+ ' benchset --bench single_core',
301
+ ' benchset --bench multi_core --warmup 120 --duration 60',
302
+ ' benchset --warmup 0 --duration 1 # quick smoke test',
303
+ ]
304
+ options = ' '.join(paragraphs[2].split())
305
+ for entry in (
306
+ '--bench {all,single_core,multi_core} Benchmark to run (default: all).',
307
+ '--warmup SECONDS Warmup excluded from the score (default: 120; zero allowed).',
308
+ '--duration SECONDS Measurement duration, must be positive (default: 60).',
309
+ ):
310
+ assert entry in options
311
+
312
+
313
+ @pytest.mark.parametrize('benchmark', ['single_core', 'multi_core'])
314
+ def test_cli_worker_exception_retains_benchmark_and_stops_the_suite(
315
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], benchmark: str,
316
+ ) -> None:
317
+ calls: List[str] = []
318
+
319
+ def fail(_runner: BenchmarkRunner, name: str, **_options: object) -> BenchmarkResult:
320
+ calls.append(name)
321
+ raise RuntimeError('backend unavailable')
322
+
323
+ monkeypatch.setattr(BenchmarkRunner, 'run', fail)
324
+ assert cli.main(['--bench', benchmark]) == 1
325
+ captured = capsys.readouterr()
326
+ assert not captured.out
327
+ fields = {key.strip(): value for key, value in (
328
+ line.split(' : ', 1) for line in captured.err.split('Samples:\n')[0].splitlines()
329
+ )}
330
+ assert fields['Benchmark'] == benchmark
331
+ assert fields['Status'] == 'failed'
332
+ assert fields['Reason'] == 'backend unavailable'
333
+ assert fields['Score'] == fields['Pi equivalent'] == '—'
334
+ assert calls == [benchmark]
335
+
336
+
252
337
  @pytest.mark.parametrize('arguments', [
253
338
  ['--warmup', '.0', '--duration', ' .03 '],
254
339
  ['--war=0', '--dur=3e-2', '--ben=single_core'],
@@ -268,7 +353,7 @@ def test_negative_zero_has_equivalent_cli_spellings(
268
353
  command, environment = invocation
269
354
  option = ['--warmup=' + value] if equals else ['--warmup', value]
270
355
  result = subprocess.run([*command, '--bench=single_core', '--duration=.03', *option],
271
- env=environment, capture_output=True, encoding='utf-8', check=False, timeout=10)
356
+ env=environment, capture_output=True, encoding='utf-8', check=False, timeout=CLI_PROCESS_TIMEOUT)
272
357
  assert_short_cli_completed_or_no_work(result, ['single_core'], float(value))
273
358
 
274
359
 
@@ -281,7 +366,7 @@ def test_negative_numeric_values_reach_their_option_validator(
281
366
  ) -> None:
282
367
  command, environment = invocation
283
368
  result = subprocess.run([*command, option, value], env=environment, capture_output=True,
284
- encoding='utf-8', check=False, timeout=10)
369
+ encoding='utf-8', check=False, timeout=CLI_PROCESS_TIMEOUT)
285
370
  assert result.returncode == 2
286
371
  assert not result.stdout
287
372
  assert option in result.stderr
@@ -295,7 +380,7 @@ def test_real_redirected_output_supports_non_unicode_encodings(
295
380
  command, environment = invocation
296
381
  environment['PYTHONIOENCODING'] = encoding + ':strict'
297
382
  result = subprocess.run([*command, '--bench=single_core', '--warmup=0', '--duration=.03'],
298
- env=environment, capture_output=True, check=False, timeout=10)
383
+ env=environment, capture_output=True, check=False, timeout=CLI_PROCESS_TIMEOUT)
299
384
  assert_short_cli_completed_or_no_work(result, ['single_core'], 0, encoding)
300
385
 
301
386
 
@@ -307,7 +392,7 @@ def test_native_duration_limits_report_failure_and_stop_the_suite(
307
392
  ) -> None:
308
393
  command, environment = invocation
309
394
  result = subprocess.run([*command, '--warmup=0', '--duration=.03', option, value],
310
- env=environment, capture_output=True, encoding='utf-8', check=False, timeout=10)
395
+ env=environment, capture_output=True, encoding='utf-8', check=False, timeout=CLI_PROCESS_TIMEOUT)
311
396
  assert result.returncode == 1
312
397
  assert not result.stdout
313
398
  assert 'failed' in result.stderr
@@ -358,6 +443,12 @@ def test_result_calibration_links_keep_cli_output_compact(status: str) -> None:
358
443
  assert text.count('Reference machine') == 1
359
444
  assert f'{result.reference_machine.name} ({result.reference_machine.short_name})' in text
360
445
  assert 'Pi equivalent' in text
446
+ fields = {key.strip(): value for key, value in (
447
+ line.split(' : ', 1) for line in text.split('Samples:\n')[0].splitlines()
448
+ )}
449
+ for name in ('Reason', 'Score', 'Pi equivalent', 'Variation percent'):
450
+ assert fields[name] == '—'
451
+ assert text.endswith('Samples:\n —\n')
361
452
  for nested_metadata in ('reference_machine', 'reference_run', 'ReferenceMachine(', 'ReferenceRun(',
362
453
  'started_at_unix_seconds', 'start_temperature_c', 'warmup_seconds=1248'):
363
454
  assert nested_metadata not in text
@@ -503,7 +594,7 @@ def test_real_terminal_disconnect_has_execution_failure_exit_without_traceback(
503
594
  os.close(slave)
504
595
  try:
505
596
  observed = b''
506
- deadline = time.monotonic() + 10
597
+ deadline = time.monotonic() + CLI_PROCESS_TIMEOUT
507
598
  while b'\rsingle_core: measurement' not in observed and time.monotonic() < deadline:
508
599
  readable, _writeable, _errors = select.select([master], [], [], 0.1)
509
600
  if readable:
@@ -552,7 +643,7 @@ def test_closed_output_pipe_exits_without_traceback_or_shutdown_warning(
552
643
  stdout=writer if destination != 'stderr' else subprocess.PIPE,
553
644
  stderr=writer if destination == 'stderr' else (
554
645
  subprocess.STDOUT if destination == 'merged' else subprocess.PIPE
555
- ), check=False, timeout=10)
646
+ ), check=False, timeout=CLI_PROCESS_TIMEOUT)
556
647
  finally:
557
648
  os.close(writer)
558
649
  assert result.returncode == 1
@@ -610,7 +701,7 @@ else:
610
701
  command = [sys.executable, '-c', program, ','.join(map(str, closed)),
611
702
  'module' if len(command) > 1 else command[0], str(completed_fixture)]
612
703
  result = subprocess.run([*command, *arguments], env=environment, capture_output=True,
613
- encoding='utf-8', check=False, timeout=10)
704
+ encoding='utf-8', check=False, timeout=CLI_PROCESS_TIMEOUT)
614
705
  assert result.returncode == status, (result.returncode, result.stdout, result.stderr)
615
706
  assert 'Traceback' not in result.stderr
616
707
  assert 'Exception ignored' not in result.stderr
@@ -697,7 +788,7 @@ raise SystemExit(main(sys.argv[1:]))
697
788
  '--unknown' if destination == 'stderr' else '--version'],
698
789
  env=environment, stdout=sink if destination != 'stderr' else subprocess.PIPE,
699
790
  stderr=sink if destination != 'stdout' else subprocess.PIPE,
700
- text=True, check=False, timeout=10)
791
+ text=True, check=False, timeout=CLI_PROCESS_TIMEOUT)
701
792
  assert result.returncode == 1
702
793
  assert 'Traceback' not in (result.stderr or '')
703
794
  assert 'Exception ignored' not in (result.stderr or '')
@@ -716,6 +807,24 @@ def test_error_report_write_failure_does_not_escape(monkeypatch: pytest.MonkeyPa
716
807
  assert cli.main([]) == 1
717
808
 
718
809
 
810
+ def test_missing_stdout_still_redirects_a_broken_stderr_at_shutdown(
811
+ monkeypatch: pytest.MonkeyPatch,
812
+ ) -> None:
813
+ class Broken(io.StringIO):
814
+ def flush(self) -> None:
815
+ raise OSError('destination is unavailable')
816
+
817
+ def fileno(self) -> int:
818
+ return 42
819
+
820
+ redirects: List[int] = []
821
+ monkeypatch.setattr(cli.sys, 'stdout', None)
822
+ monkeypatch.setattr(cli.sys, 'stderr', Broken())
823
+ monkeypatch.setattr(cli.os, 'dup2', lambda _source, target: redirects.append(target))
824
+ assert cli.main([]) == 1
825
+ assert redirects == [42]
826
+
827
+
719
828
  @pytest.mark.skipif(os.name == 'nt', reason='POSIX terminal signals; Windows cancellation has direct tests')
720
829
  @pytest.mark.parametrize('phase', ['warmup', 'measurement'])
721
830
  def test_real_ctrl_c_cancels_each_phase_and_stops_the_suite(
@@ -733,7 +842,7 @@ def test_real_ctrl_c_cancels_each_phase_and_stops_the_suite(
733
842
  os.close(slave)
734
843
  try:
735
844
  observed = b''
736
- deadline = time.monotonic() + 10
845
+ deadline = time.monotonic() + CLI_PROCESS_TIMEOUT
737
846
  while phase.encode() not in observed and time.monotonic() < deadline:
738
847
  readable, _writeable, _errors = select.select([master], [], [], 0.1)
739
848
  if readable:
@@ -198,23 +198,28 @@ class ManagedNative(Native):
198
198
 
199
199
 
200
200
  @pytest.mark.parametrize('entrypoint', ['runner', 'run', 'run_all'])
201
- def test_public_api_forwards_default_durations_without_running_long_benchmarks(
202
- monkeypatch: pytest.MonkeyPatch, entrypoint: str,
201
+ @pytest.mark.parametrize('durations', [None, (1.2345678901234567, 9.876543210987654)])
202
+ def test_public_api_forwards_durations_and_callback_without_running_long_benchmarks(
203
+ monkeypatch: pytest.MonkeyPatch, entrypoint: str, durations: Optional[Tuple[float, float]],
203
204
  ) -> None:
204
205
  backend = ManagedNative()
205
206
  monkeypatch.setattr(api, 'Native', lambda: backend)
207
+ options = {} if durations is None else {'warmup_seconds': durations[0], 'measurement_seconds': durations[1]}
208
+ expected_durations = (120.0, 60.0) if durations is None else durations
209
+ events: List[Progress] = []
206
210
  results: Tuple[BenchmarkResult, ...]
207
211
  if entrypoint == 'runner':
208
- results = (BenchmarkRunner().run('single_core'),)
212
+ results = (BenchmarkRunner().run('single_core', on_progress=events.append, **options),)
209
213
  elif entrypoint == 'run':
210
- results = (run('single_core'),)
214
+ results = (run('single_core', on_progress=events.append, **options),)
211
215
  else:
212
- results = run_all()
216
+ results = run_all(on_progress=events.append, **options)
213
217
  for result in results:
214
218
  assert_result_references(result)
215
219
  benchmarks = ['single_core', 'multi_core'] if entrypoint == 'run_all' else ['single_core']
216
220
  assert [(result.benchmark, result.warmup_seconds, result.measurement_seconds)
217
- for result in results] == [(benchmark, 120.0, 60.0) for benchmark in benchmarks]
221
+ for result in results] == [(benchmark, *expected_durations) for benchmark in benchmarks]
222
+ assert [event.benchmark for event in events] == benchmarks
218
223
  assert all(result.success for result in results)
219
224
  assert backend.calls == ['create', 'execute', 'free'] * len(benchmarks)
220
225
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes