From d74ac3502da9cc8848d5c30a3f6cd54c63470f04 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 23:06:59 +0000 Subject: [PATCH] ci(miri): stop the 6-hour timeout on every push The miri job on master was cancelled at GitHub's 6-hour limit on every push (last run: four tests still running after 2 to 3.4 hours each), so master's CI was red and gave no UB signal at all. - SpatialHash::query_knn: stop once every entry in the grid has been visited. A query for more neighbours than the grid holds walked all 129^3 cells up to max_ring; results are unchanged because outer rings hold nothing. Under miri the five k-NN tests now take 40 s; test_knn_k_larger_than_count alone was still running after 3.4 h on CI, and with the exit removed it is still running at a 5-minute limit locally. New test: a far point (10 rings out) is still found when k > count, and an empty grid answers empty. - Exhaustive 511 x 511 kernel sweeps (6 tests) and flush-bound worst-case runs (5 tests) carry #[cfg_attr(miri, ignore = "...")] with the reason; the 300-block cases and the flush part of a2_mixed are left out under miri only. The native test jobs still run all of them on every PR. - Safety net: nextest profile `miri` stops any single test after 20 minutes, so a slow test fails by name; the job has timeout-minutes: 120. Native: spatial_hash 19 pass; u9_abacus 43, pof_antennas 20, u9_head_to_head 5 pass, none ignored. fmt and clippy clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMQJzQD5KvNm8BtbsWG --- .config/nextest.toml | 8 ++++++ .github/workflows/ci.yaml | 3 ++ scripts/miri-tests.sh | 8 +++++- src/spatial_hash.rs | 28 +++++++++++++++++- tests/pof_antennas.rs | 14 ++++++++- tests/u9_abacus.rs | 60 ++++++++++++++++++++++++++++++++++++--- tests/u9_head_to_head.rs | 4 +++ 7 files changed, 118 insertions(+), 7 deletions(-) diff --git a/.config/nextest.toml b/.config/nextest.toml index 6a109e17..75fc15f7 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -3,3 +3,11 @@ fail-fast = false [profile.ci.junit] path = "junit.xml" + +# Used by scripts/miri-tests.sh. Miri interprets every operation, so a test +# that takes milliseconds natively can take hours. Stop any single test after +# 20 minutes so it fails with its name instead of silently eating the job's +# time limit (the job was cancelled at 6 h on every push before this). +[profile.miri] +fail-fast = false +slow-timeout = { period = "60s", terminate-after = 20 } diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0ebf2ed6..0ccb28da 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -394,6 +394,9 @@ jobs: if: github.event_name == 'merge_group' || github.event_name == 'push' runs-on: ubuntu-latest name: miri + # A healthy run takes well under this; the per-test limit is in the + # `miri` nextest profile (.config/nextest.toml). + timeout-minutes: 120 steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@nightly diff --git a/scripts/miri-tests.sh b/scripts/miri-tests.sh index f39a48b0..4cdbac31 100755 --- a/scripts/miri-tests.sh +++ b/scripts/miri-tests.sh @@ -56,7 +56,7 @@ export RUSTFLAGS="-Zrandomize-layout" # The polyfill is no longer the bottleneck — the missing piece is a # `cfg(miri)` switch in `src/simd.rs` that re-exports from `simd_nightly` # instead of `simd_avx*` under Miri. -cargo +nightly miri nextest run -v \ +cargo +nightly miri nextest run -v --profile miri \ --no-fail-fast \ -p ndarray -p ndarray-rand \ --features approx,serde,nightly-simd \ @@ -67,6 +67,12 @@ cargo +nightly miri nextest run -v \ and !test(/^cpu_guard::/) ' # +# Tests that are correct but take hours under interpretation (the exhaustive +# 511 x 511 kernel sweeps, the flush-bound worst-case runs) carry +# `#[cfg_attr(miri, ignore = "...")]` in their own file, and spatial_hash's +# k-NN stops once every entry is visited. The `miri` nextest profile stops any +# test after 20 minutes, so a new slow test fails by name. +# # Filter rationale (4-clause AND): # # 1. `!(test(/^hpc::/) - test(/^hpc::byte_scan/))` diff --git a/src/spatial_hash.rs b/src/spatial_hash.rs index 84a4f117..75301535 100644 --- a/src/spatial_hash.rs +++ b/src/spatial_hash.rs @@ -153,6 +153,12 @@ impl SpatialHash { let mut ring = 0i32; let max_ring = 64; // safety cap + // Once every entry in the grid has been visited, no further ring can + // add a candidate. Without this, a query for more neighbours than the + // grid holds walks all 129^3 cells up to `max_ring`. + let total = self.len(); + let mut visited = 0usize; + loop { // Collect candidates from all cells in this ring shell. for dx in -ring..=ring { @@ -165,6 +171,7 @@ impl SpatialHash { } let key = (cx + dx, cy + dy, cz + dz); if let Some(cell) = self.grid.get(&key) { + visited += cell.len(); for &eid in cell { if let Some(&pos) = positions.get(&eid) { let d2 = sq_dist_f32(query, pos); @@ -176,7 +183,7 @@ impl SpatialHash { } } - if ring >= max_ring { + if ring >= max_ring || visited >= total { break; } @@ -537,6 +544,25 @@ mod tests { assert_eq!(result.len(), 1); } + #[test] + fn test_knn_k_larger_than_count_still_reaches_far_rings() { + // One entry in ring 0 and one 10 rings out: the early exit on + // "every entry visited" must not fire before the far one is seen. + let mut sh = SpatialHash::new(1.0); + let pts = vec![(0u32, [0.5f32, 0.5, 0.5]), (1, [10.5, 0.5, 0.5])]; + for &(id, pos) in &pts { + sh.insert(id, pos[0], pos[1], pos[2]); + } + let positions = make_positions(&pts); + let result = sh.query_knn(0.5, 0.5, 0.5, 100, &positions); + let ids: Vec = result.iter().map(|r| r.0).collect(); + assert_eq!(ids, vec![0, 1]); + // An empty grid answers empty. + assert!(SpatialHash::new(1.0) + .query_knn(0.0, 0.0, 0.0, 3, &positions) + .is_empty()); + } + // -- negative coordinates -- #[test] diff --git a/tests/pof_antennas.rs b/tests/pof_antennas.rs index fd6c915d..1aca65aa 100644 --- a/tests/pof_antennas.rs +++ b/tests/pof_antennas.rs @@ -72,6 +72,10 @@ fn check_a2(a: &[Raw], b: &[Raw]) { } #[test] +#[cfg_attr( + miri, + ignore = "511 x 511 = 261 121 kernel calls take hours under miri; the native test jobs run it" +)] fn a2_all_511_by_511_pairs_are_exact() { let mut n = 0usize; for va in -255..=255i32 { @@ -100,7 +104,12 @@ fn a2_mixed_blocks_and_worst_case_runs_are_exact() { .wrapping_add(1_442_695_040_888_963_407); ((s >> 33) % 511) as i32 - 255 }; - for blocks in [1usize, 3, 64, 300] { + // Under miri: no 300-block case and no flush-bound runs below (hours of + // interpretation); the native test jobs run both. + for blocks in [1usize, 3, 64, 300] + .into_iter() + .filter(|&b| !cfg!(miri) || b < 300) + { let mut mk = || { let mut m = [0u8; 64]; let mut p = 0u64; @@ -115,6 +124,9 @@ fn a2_mixed_blocks_and_worst_case_runs_are_exact() { let b: Vec<_> = (0..blocks).map(|_| mk()).collect(); check_a2(&a, &b); } + if cfg!(miri) { + return; + } let n = 2 * B_FLUSH_BLOCKS + 3; for (pa, pb) in [(u64::MAX, u64::MAX), (u64::MAX, 0), (0xF0F0_F0F0_0F0F_0F0F, 0x3333_CCCC_5555_AAAA)] { check_a2(&vec![([255u8; 64], pa); n], &vec![([255u8; 64], pb); n]); diff --git a/tests/u9_abacus.rs b/tests/u9_abacus.rs index 131032ad..f53adaaa 100644 --- a/tests/u9_abacus.rs +++ b/tests/u9_abacus.rs @@ -121,6 +121,10 @@ fn abacus_carries_through_many_columns() { } #[test] +#[cfg_attr( + miri, + ignore = "511 x 511 = 261 121 kernel calls take hours under miri; the native test jobs run it" +)] fn all_511_by_511_pairs_are_exact() { let mut n = 0usize; for va in -255..=255i32 { @@ -150,7 +154,11 @@ fn full_blocks_match_the_oracle_and_b() { .wrapping_add(1_442_695_040_888_963_407); ((s >> 33) % 511) as i32 - 255 }; - for blocks in [1usize, 2, 7, 64, 300] { + // 300 blocks take too long under miri; the smaller sizes keep the kernels covered there. + for blocks in [1usize, 2, 7, 64, 300] + .into_iter() + .filter(|&b| !cfg!(miri) || b < 300) + { let mut mk = || { let mut m = [0u8; 64]; let mut p = 0u64; @@ -170,6 +178,10 @@ fn full_blocks_match_the_oracle_and_b() { /// +-255 x +-255 in every lane, past B's flush bound, totals beyond `i32`. #[test] +#[cfg_attr( + miri, + ignore = "tens of thousands of all-255 blocks past the flush bound take hours under miri; the native test jobs run it" +)] fn worst_case_runs_exceed_i32_exactly() { let n = 3 * B_FLUSH_BLOCKS + 5; for (pa, pb) in [(u64::MAX, u64::MAX), (u64::MAX, 0), (0, 0), (0xF0F0_F0F0_0F0F_0F0F, 0x3333_CCCC_5555_AAAA)] { @@ -247,6 +259,10 @@ fn carry_rules_match_u32_for_every_low() { } #[test] +#[cfg_attr( + miri, + ignore = "511 x 511 = 261 121 kernel calls take hours under miri; the native test jobs run it" +)] fn d_all_511_by_511_pairs_are_exact() { let mut n = 0usize; for va in -255..=255i32 { @@ -276,7 +292,11 @@ fn d_full_blocks_match_the_oracle() { .wrapping_add(1_442_695_040_888_963_407); ((s >> 33) % 511) as i32 - 255 }; - for blocks in [1usize, 2, 7, 64, 300] { + // 300 blocks take too long under miri; the smaller sizes keep the kernels covered there. + for blocks in [1usize, 2, 7, 64, 300] + .into_iter() + .filter(|&b| !cfg!(miri) || b < 300) + { let mut mk = || { let mut m = [0u8; 64]; let mut p = 0u64; @@ -296,6 +316,10 @@ fn d_full_blocks_match_the_oracle() { /// All-255 runs past D's flush bound: a carry on almost every add, `high` /// close to its limit, totals far beyond `i32`. #[test] +#[cfg_attr( + miri, + ignore = "tens of thousands of all-255 blocks past the flush bound take hours under miri; the native test jobs run it" +)] fn d_worst_case_runs_cross_the_flush_bound() { let n = 2 * D_FLUSH_BLOCKS + 5; for (pa, pb) in [(u64::MAX, u64::MAX), (u64::MAX, 0), (0xF0F0_F0F0_0F0F_0F0F, 0x3333_CCCC_5555_AAAA)] { @@ -346,6 +370,10 @@ fn anchor_split_and_sign_bytes() { } #[test] +#[cfg_attr( + miri, + ignore = "511 x 511 = 261 121 kernel calls take hours under miri; the native test jobs run it" +)] fn e_all_511_by_511_pairs_are_exact() { let mut n = 0usize; for va in -255..=255i32 { @@ -375,7 +403,11 @@ fn e_full_blocks_match_the_oracle() { .wrapping_add(1_442_695_040_888_963_407); ((s >> 33) % 511) as i32 - 255 }; - for blocks in [1usize, 2, 7, 64, 300] { + // 300 blocks take too long under miri; the smaller sizes keep the kernels covered there. + for blocks in [1usize, 2, 7, 64, 300] + .into_iter() + .filter(|&b| !cfg!(miri) || b < 300) + { let mut mk = || { let mut m = [0u8; 64]; let mut p = 0u64; @@ -393,6 +425,10 @@ fn e_full_blocks_match_the_oracle() { } #[test] +#[cfg_attr( + miri, + ignore = "tens of thousands of all-255 blocks past the flush bound take hours under miri; the native test jobs run it" +)] fn e_worst_case_runs_cross_the_flush_bound() { let n = 3 * B_FLUSH_BLOCKS + 5; for (pa, pb) in [(u64::MAX, u64::MAX), (u64::MAX, 0), (0, 0), (0xF0F0_F0F0_0F0F_0F0F, 0x3333_CCCC_5555_AAAA)] { @@ -417,6 +453,10 @@ fn run_e5(a: &[Raw], b: &[Raw]) -> [i64; 3] { } #[test] +#[cfg_attr( + miri, + ignore = "511 x 511 = 261 121 kernel calls take hours under miri; the native test jobs run it" +)] fn e5_all_511_by_511_pairs_are_exact() { let mut n = 0usize; for va in -255..=255i32 { @@ -446,7 +486,11 @@ fn e5_full_blocks_with_mixed_signs_match_the_oracle() { .wrapping_add(1_442_695_040_888_963_407); ((s >> 33) % 511) as i32 - 255 }; - for blocks in [1usize, 2, 7, 64, 300] { + // 300 blocks take too long under miri; the smaller sizes keep the kernels covered there. + for blocks in [1usize, 2, 7, 64, 300] + .into_iter() + .filter(|&b| !cfg!(miri) || b < 300) + { let mut mk = || { let mut m = [0u8; 64]; let mut p = 0u64; @@ -470,6 +514,10 @@ fn e5_full_blocks_with_mixed_signs_match_the_oracle() { /// All-255 runs past three flush bounds, every sign mix; totals beyond `i32`. #[test] +#[cfg_attr( + miri, + ignore = "tens of thousands of all-255 blocks past the flush bound take hours under miri; the native test jobs run it" +)] fn e5_worst_case_runs_cross_the_flush_bound() { let n = 3 * B_FLUSH_BLOCKS + 5; for (pa, pb) in [(u64::MAX, u64::MAX), (u64::MAX, 0), (0, 0), (0xF0F0_F0F0_0F0F_0F0F, 0x3333_CCCC_5555_AAAA)] { @@ -482,6 +530,10 @@ fn e5_worst_case_runs_cross_the_flush_bound() { /// The anchor accumulators are reduced once per flush, never per block: /// `n` blocks give `n / B_FLUSH_BLOCKS` flushes, plus one at `finish`. #[test] +#[cfg_attr( + miri, + ignore = "tens of thousands of all-255 blocks past the flush bound take hours under miri; the native test jobs run it" +)] fn e5_reduces_the_anchor_only_at_flush() { let n = 2 * B_FLUSH_BLOCKS + 7; let (x, y) = (block(&([200u8; 64], 0x0F0F)), block(&([131u8; 64], u64::MAX))); diff --git a/tests/u9_head_to_head.rs b/tests/u9_head_to_head.rs index 9eeed3ee..539c117f 100644 --- a/tests/u9_head_to_head.rs +++ b/tests/u9_head_to_head.rs @@ -60,6 +60,10 @@ fn run_scan(a: &[([u8; 64], u64)], b: &[([u8; 64], u64)]) -> (i64, i64) { /// other 63 lanes are touched +0), at a lane that rotates with the pair so /// every lane position is exercised. #[test] +#[cfg_attr( + miri, + ignore = "511 x 511 = 261 121 kernel calls take hours under miri; the native test jobs run it" +)] fn all_511_by_511_pairs_are_exact() { let mut n = 0usize; for va in -255..=255i32 {