perf(bit-buffer): fix pre-sync per-code-block allocation in track!#194
Conversation
…ind_bit `_buffer_find_bit` assigns the local `sync` in two branches (soft vs. hard detector) and then captures it in the trailing `reduce` closure via `Int(sync.polarity)`. Julia boxes any variable that is both assigned in more than one place and captured by a closure, so this produced a per-call `Core.Box` plus heap-boxing of each `SyncResult` (~80 B). The box exists regardless of runtime path, so it fired on every code block during acquisition, making `track!` allocate in proportion to the signal length (352 B at 1 code period → 1072 B at 10) rather than staying allocation-free after warmup as the docstring claims. Hoist `sync.polarity` into a plain `Int` local before the closure so the closure captures the `Int` and `sync` stays unboxed. Acquisition is now per-code-block allocation-free (flat 272 B regardless of length); the post-sync locked steady state was already clean. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Benchmark Results (minimum time) — macos-14Reporting the minimum over all samples (robust to shared-runner contention), not the median. Alternative backends vs Float32 (
|
| Scenario | F32 | I16 | 1b | 2b | ×I16 | ×1b | ×2b |
|---|---|---|---|---|---|---|---|
| 4-antenna @ 5 MHz | 11.6 μs | 8.1 μs | 4.35 μs | 8.56 μs | 1.43 ✅ | 2.67 ✅ | 1.35 ✅ |
| GPS L1CA, 8 sats @ 40 MHz | 381.0 μs | 145.0 μs | 69.5 μs | 104.0 μs | 2.62 ✅ | 5.48 ✅ | 3.67 ✅ |
| GPS L1CA, 8 sats @ 5 MHz | 52.8 μs | 24.0 μs | 16.5 μs | 21.2 μs | 2.2 ✅ | 3.19 ✅ | 2.49 ✅ |
| Galileo E1B, 4 sats @ 25 MHz | 146.0 μs | 72.0 μs | 2.03 ✅ | ||||
| dynamic taps @ 5 MHz (kernel) | 6.68 μs | 2.6 μs | 1.65 μs | 2.57 ✅ | 4.06 ✅ | ||
| multi-signal N=3 @ 5 MHz | 10.8 μs | 6.2 μs | 4.05 μs | 8.28 μs | 1.75 ✅ | 2.68 ✅ | 1.31 ✅ |
Time benchmarks (base vs PR head)
Ratio = 81e5299… / c2c9b4e…: >1 means the PR is faster. ✅ ≥ 5 % faster,
| 81e5299… | c2c9b4e… | 81e5299… / c2c9b4e… | |
|---|---|---|---|
| downconvert and correlate/CPU/Float32 | 2.5 μs | 2.57 μs | 0.969 |
| downconvert and correlate/CPU/Float32 4ant | 4.89 μs | 5.08 μs | 0.964 |
| downconvert and correlate/CPU/Float64 | 2.8 μs | 2.74 μs | 1.02 |
| downconvert and correlate/CPU/Int16 | 2.63 μs | 2.74 μs | 0.963 |
| downconvert and correlate/CPU/Int16 4ant | 4.86 μs | 4.86 μs | 1.0 |
| downconvert and correlate/CPU/Int32 | 2.55 μs | 2.55 μs | 0.998 |
| fused kernel/1-ant dynamic taps | 2.55 μs | 2.56 μs | 0.996 |
| fused kernel/1-ant static taps | 2.06 μs | 2.06 μs | 0.995 |
| fused kernel/4-ant dynamic taps | 7.78 μs | 7.78 μs | 1.0 |
| fused kernel/4-ant static taps | 4.43 μs | 4.43 μs | 1.0 |
| fused tuple kernel/1-ant N=2 | 3.27 μs | 3.27 μs | 1.0 |
| fused tuple kernel/1-ant N=3 | 3.4 μs | 3.4 μs | 1.0 |
| fused tuple kernel/2-ant N=2 | 6.3 μs | 6.31 μs | 0.999 |
| fused tuple kernel/2-ant N=3 | 5.62 μs | 5.62 μs | 1.0 |
| fused tuple kernel/4-ant N=2 | 11.5 μs | 11.7 μs | 0.989 |
| fused tuple kernel/4-ant N=3 | 10.1 μs | 10.1 μs | 1.0 |
| track/1. Float32/2K – track | 2.67 μs | 2.67 μs | 1.0 |
| track/1. Float32/2K – track! | 2.72 μs | 2.79 μs | 0.977 |
| track/2. L1 8sat/5K – track | 49.6 μs | 49.8 μs | 0.996 |
| track/2. L1 8sat/5K – track! | 49.3 μs | 49.7 μs | 0.993 |
| track/2. L1 8sat/5K – track!-threaded | 49.7 μs | 49.9 μs | 0.996 |
| track/2. L1 8sat/5K – track-threaded | 49.9 μs | 49.9 μs | 1.0 |
| track/3. E1B 4sat/25K – track | 136.0 μs | 136.0 μs | 1.0 |
| track/3. E1B 4sat/25K – track! | 135.0 μs | 135.0 μs | 1.0 |
| track/3. E1B 4sat/25K – track!-threaded | 135.0 μs | 136.0 μs | 1.0 |
| track/3. E1B 4sat/25K – track-threaded | 136.0 μs | 136.0 μs | 1.0 |
| track/4. 8L1+8E1B/25K – track | 490.0 μs | 490.0 μs | 1.0 |
| track/4. 8L1+8E1B/25K – track! | 489.0 μs | 489.0 μs | 1.0 |
| track/4. 8L1+8E1B/25K – track!-threaded | 488.0 μs | 488.0 μs | 1.0 |
| track/4. 8L1+8E1B/25K – track-threaded | 490.0 μs | 490.0 μs | 1.0 |
| track/5. multi-signal N=1/5K – track | 6.48 μs | 6.63 μs | 0.977 |
| track/5. multi-signal N=1/5K – track! | 6.46 μs | 6.41 μs | 1.01 |
| track/6. multi-signal N=2/5K – track | 11.0 μs | 10.3 μs | 1.06 ✅ |
| track/6. multi-signal N=2/5K – track! | 10.5 μs | 10.8 μs | 0.977 |
| track/7. multi-signal N=3/5K – track | 11.8 μs | 12.0 μs | 0.983 |
| track/7. multi-signal N=3/5K – track! | 11.8 μs | 11.7 μs | 1.01 |
| track/8. L1CA presync 2 blk – track! | 12.5 μs | 12.4 μs | 1.01 |
| track/8. L1CA presync 20 blk – track! | 122.0 μs | 120.0 μs | 1.01 |
| track/8. L1CA synced 2 blk – track! | 12.2 μs | 12.3 μs | 0.993 |
| track/8. L1CA synced 20 blk – track! | 118.0 μs | 119.0 μs | 0.997 |
| time_to_load | 161.0 μs | 197.0 μs | 0.816 |
Memory benchmarks (base vs PR head)
| 81e5299… | c2c9b4e… | |
|---|---|---|
| downconvert and correlate/CPU/Float32 | 2 allocs: 576 B | 2 allocs: 576 B |
| downconvert and correlate/CPU/Float32 4ant | 2 allocs: 848 B | 2 allocs: 848 B |
| downconvert and correlate/CPU/Float64 | 2 allocs: 576 B | 2 allocs: 576 B |
| downconvert and correlate/CPU/Int16 | 2 allocs: 576 B | 2 allocs: 576 B |
| downconvert and correlate/CPU/Int16 4ant | 2 allocs: 848 B | 2 allocs: 848 B |
| downconvert and correlate/CPU/Int32 | 2 allocs: 576 B | 2 allocs: 576 B |
| fused kernel/1-ant dynamic taps | 0 allocs: 0 B | 0 allocs: 0 B |
| fused kernel/1-ant static taps | 0 allocs: 0 B | 0 allocs: 0 B |
| fused kernel/4-ant dynamic taps | 0 allocs: 0 B | 0 allocs: 0 B |
| fused kernel/4-ant static taps | 0 allocs: 0 B | 0 allocs: 0 B |
| fused tuple kernel/1-ant N=2 | 0 allocs: 0 B | 0 allocs: 0 B |
| fused tuple kernel/1-ant N=3 | 0 allocs: 0 B | 0 allocs: 0 B |
| fused tuple kernel/2-ant N=2 | 0 allocs: 0 B | 0 allocs: 0 B |
| fused tuple kernel/2-ant N=3 | 0 allocs: 0 B | 0 allocs: 0 B |
| fused tuple kernel/4-ant N=2 | 0 allocs: 0 B | 0 allocs: 0 B |
| fused tuple kernel/4-ant N=3 | 0 allocs: 0 B | 0 allocs: 0 B |
| track/1. Float32/2K – track | 9 allocs: 944 B | 9 allocs: 944 B |
| track/1. Float32/2K – track! | 1 allocs: 26 B | 0 allocs: 0 B |
| track/2. L1 8sat/5K – track | 10 allocs: 4656 B | 10 allocs: 4656 B |
| track/2. L1 8sat/5K – track! | 0 allocs: 0 B | 0 allocs: 0 B |
| track/2. L1 8sat/5K – track!-threaded | 0 allocs: 0 B | 0 allocs: 0 B |
| track/2. L1 8sat/5K – track-threaded | 10 allocs: 4656 B | 10 allocs: 4656 B |
| track/3. E1B 4sat/25K – track | 10 allocs: 3056 B | 10 allocs: 3056 B |
| track/3. E1B 4sat/25K – track! | 0 allocs: 0 B | 0 allocs: 0 B |
| track/3. E1B 4sat/25K – track!-threaded | 0 allocs: 0 B | 0 allocs: 0 B |
| track/3. E1B 4sat/25K – track-threaded | 10 allocs: 3056 B | 10 allocs: 3056 B |
| track/4. 8L1+8E1B/25K – track | 26 allocs: 10464 B | 26 allocs: 10464 B |
| track/4. 8L1+8E1B/25K – track! | 0 allocs: 0 B | 0 allocs: 0 B |
| track/4. 8L1+8E1B/25K – track!-threaded | 0 allocs: 0 B | 0 allocs: 0 B |
| track/4. 8L1+8E1B/25K – track-threaded | 26 allocs: 10464 B | 26 allocs: 10464 B |
| track/5. multi-signal N=1/5K – track | 12 allocs: 1024 B | 9 allocs: 944 B |
| track/5. multi-signal N=1/5K – track! | 3 allocs: 80 B | 0 allocs: 0 B |
| track/6. multi-signal N=2/5K – track | 15 allocs: 1568 B | 9 allocs: 1408 B |
| track/6. multi-signal N=2/5K – track! | 6 allocs: 160 B | 0 allocs: 0 B |
| track/7. multi-signal N=3/5K – track | 18 allocs: 2000 B | 9 allocs: 1760 B |
| track/7. multi-signal N=3/5K – track! | 9 allocs: 240 B | 0 allocs: 0 B |
| track/8. L1CA presync 2 blk – track! | 13 allocs: 480 B | 7 allocs: 320 B |
| track/8. L1CA presync 20 blk – track! | 67 allocs: 1920 B | 7 allocs: 320 B |
| track/8. L1CA synced 2 blk – track! | 7 allocs: 320 B | 7 allocs: 320 B |
| track/8. L1CA synced 20 blk – track! | 7 allocs: 320 B | 7 allocs: 320 B |
| time_to_load | 196 allocs: 13984 B | 196 allocs: 13984 B |
Benchmark Results (minimum time) — ubuntu-latestReporting the minimum over all samples (robust to shared-runner contention), not the median. Alternative backends vs Float32 (
|
| Scenario | F32 | I16 | 1b | 2b | ×I16 | ×1b | ×2b |
|---|---|---|---|---|---|---|---|
| 4-antenna @ 5 MHz | 11.4 μs | 6.51 μs | 4.88 μs | 8.29 μs | 1.76 ✅ | 2.34 ✅ | 1.38 ✅ |
| GPS L1CA, 8 sats @ 40 MHz | 337.0 μs | 133.0 μs | 70.1 μs | 105.0 μs | 2.53 ✅ | 4.8 ✅ | 3.2 ✅ |
| GPS L1CA, 8 sats @ 5 MHz | 50.1 μs | 27.3 μs | 18.6 μs | 25.2 μs | 1.84 ✅ | 2.69 ✅ | 1.99 ✅ |
| Galileo E1B, 4 sats @ 25 MHz | 126.0 μs | 60.2 μs | 2.1 ✅ | ||||
| dynamic taps @ 5 MHz (kernel) | 6.26 μs | 3.49 μs | 1.79 μs | 1.8 ✅ | 3.49 ✅ | ||
| multi-signal N=3 @ 5 MHz | 10.1 μs | 5.59 μs | 4.67 μs | 8.27 μs | 1.8 ✅ | 2.16 ✅ | 1.22 ✅ |
Time benchmarks (base vs PR head)
Ratio = 81e5299… / c2c9b4e…: >1 means the PR is faster. ✅ ≥ 5 % faster,
| 81e5299… | c2c9b4e… | 81e5299… / c2c9b4e… | |
|---|---|---|---|
| downconvert and correlate/CPU/Float32 | 2.36 μs | 2.38 μs | 0.993 |
| downconvert and correlate/CPU/Float32 4ant | 4.4 μs | 4.4 μs | 1.0 |
| downconvert and correlate/CPU/Float64 | 2.75 μs | 2.76 μs | 0.996 |
| downconvert and correlate/CPU/Int16 | 2.48 μs | 2.47 μs | 1.0 |
| downconvert and correlate/CPU/Int16 4ant | 4.88 μs | 4.93 μs | 0.991 |
| downconvert and correlate/CPU/Int32 | 2.39 μs | 2.42 μs | 0.989 |
| fused kernel/1-ant dynamic taps | 2.3 μs | 2.31 μs | 0.994 |
| fused kernel/1-ant static taps | 1.91 μs | 1.91 μs | 1.0 |
| fused kernel/4-ant dynamic taps | 5.51 μs | 5.57 μs | 0.989 |
| fused kernel/4-ant static taps | 3.87 μs | 3.89 μs | 0.996 |
| fused tuple kernel/1-ant N=2 | 2.57 μs | 2.65 μs | 0.969 |
| fused tuple kernel/1-ant N=3 | 3.08 μs | 3.08 μs | 1.0 |
| fused tuple kernel/2-ant N=2 | 3.92 μs | 3.95 μs | 0.991 |
| fused tuple kernel/2-ant N=3 | 4.96 μs | 4.98 μs | 0.995 |
| fused tuple kernel/4-ant N=2 | 6.48 μs | 6.42 μs | 1.01 |
| fused tuple kernel/4-ant N=3 | 10.2 μs | 10.2 μs | 0.998 |
| track/1. Float32/2K – track | 2.64 μs | 2.59 μs | 1.02 |
| track/1. Float32/2K – track! | 2.68 μs | 2.65 μs | 1.01 |
| track/2. L1 8sat/5K – track | 48.3 μs | 48.2 μs | 1.0 |
| track/2. L1 8sat/5K – track! | 47.6 μs | 47.6 μs | 0.999 |
| track/2. L1 8sat/5K – track!-threaded | 47.8 μs | 47.9 μs | 0.997 |
| track/2. L1 8sat/5K – track-threaded | 48.3 μs | 48.6 μs | 0.995 |
| track/3. E1B 4sat/25K – track | 121.0 μs | 121.0 μs | 0.999 |
| track/3. E1B 4sat/25K – track! | 121.0 μs | 121.0 μs | 1.0 |
| track/3. E1B 4sat/25K – track!-threaded | 121.0 μs | 120.0 μs | 1.0 |
| track/3. E1B 4sat/25K – track-threaded | 121.0 μs | 121.0 μs | 0.999 |
| track/4. 8L1+8E1B/25K – track | 443.0 μs | 443.0 μs | 0.999 |
| track/4. 8L1+8E1B/25K – track! | 442.0 μs | 441.0 μs | 1.0 |
| track/4. 8L1+8E1B/25K – track!-threaded | 442.0 μs | 442.0 μs | 1.0 |
| track/4. 8L1+8E1B/25K – track-threaded | 445.0 μs | 444.0 μs | 1.0 |
| track/5. multi-signal N=1/5K – track | 6.27 μs | 6.19 μs | 1.01 |
| track/5. multi-signal N=1/5K – track! | 6.16 μs | 6.15 μs | 1.0 |
| track/6. multi-signal N=2/5K – track | 9.09 μs | 9.1 μs | 0.999 |
| track/6. multi-signal N=2/5K – track! | 9.23 μs | 9.1 μs | 1.01 |
| track/7. multi-signal N=3/5K – track | 12.0 μs | 11.6 μs | 1.04 |
| track/7. multi-signal N=3/5K – track! | 11.8 μs | 11.6 μs | 1.02 |
| track/8. L1CA presync 2 blk – track! | 11.8 μs | 11.7 μs | 1.01 |
| track/8. L1CA presync 20 blk – track! | 115.0 μs | 113.0 μs | 1.01 |
| track/8. L1CA synced 2 blk – track! | 11.6 μs | 11.6 μs | 0.998 |
| track/8. L1CA synced 20 blk – track! | 111.0 μs | 112.0 μs | 0.995 |
| time_to_load | 120.0 μs | 125.0 μs | 0.957 |
Memory benchmarks (base vs PR head)
| 81e5299… | c2c9b4e… | |
|---|---|---|
| downconvert and correlate/CPU/Float32 | 2 allocs: 576 B | 2 allocs: 576 B |
| downconvert and correlate/CPU/Float32 4ant | 2 allocs: 848 B | 2 allocs: 848 B |
| downconvert and correlate/CPU/Float64 | 2 allocs: 576 B | 2 allocs: 576 B |
| downconvert and correlate/CPU/Int16 | 2 allocs: 576 B | 2 allocs: 576 B |
| downconvert and correlate/CPU/Int16 4ant | 2 allocs: 848 B | 2 allocs: 848 B |
| downconvert and correlate/CPU/Int32 | 2 allocs: 576 B | 2 allocs: 576 B |
| fused kernel/1-ant dynamic taps | 0 allocs: 0 B | 0 allocs: 0 B |
| fused kernel/1-ant static taps | 0 allocs: 0 B | 0 allocs: 0 B |
| fused kernel/4-ant dynamic taps | 0 allocs: 0 B | 0 allocs: 0 B |
| fused kernel/4-ant static taps | 0 allocs: 0 B | 0 allocs: 0 B |
| fused tuple kernel/1-ant N=2 | 0 allocs: 0 B | 0 allocs: 0 B |
| fused tuple kernel/1-ant N=3 | 0 allocs: 0 B | 0 allocs: 0 B |
| fused tuple kernel/2-ant N=2 | 0 allocs: 0 B | 0 allocs: 0 B |
| fused tuple kernel/2-ant N=3 | 0 allocs: 0 B | 0 allocs: 0 B |
| fused tuple kernel/4-ant N=2 | 0 allocs: 0 B | 0 allocs: 0 B |
| fused tuple kernel/4-ant N=3 | 0 allocs: 0 B | 0 allocs: 0 B |
| track/1. Float32/2K – track | 9 allocs: 944 B | 9 allocs: 944 B |
| track/1. Float32/2K – track! | 1 allocs: 26 B | 0 allocs: 0 B |
| track/2. L1 8sat/5K – track | 10 allocs: 4536 B | 10 allocs: 4536 B |
| track/2. L1 8sat/5K – track! | 0 allocs: 0 B | 0 allocs: 0 B |
| track/2. L1 8sat/5K – track!-threaded | 0 allocs: 0 B | 0 allocs: 0 B |
| track/2. L1 8sat/5K – track-threaded | 10 allocs: 4536 B | 10 allocs: 4536 B |
| track/3. E1B 4sat/25K – track | 10 allocs: 2808 B | 10 allocs: 2808 B |
| track/3. E1B 4sat/25K – track! | 0 allocs: 0 B | 0 allocs: 0 B |
| track/3. E1B 4sat/25K – track!-threaded | 0 allocs: 0 B | 0 allocs: 0 B |
| track/3. E1B 4sat/25K – track-threaded | 10 allocs: 2808 B | 10 allocs: 2808 B |
| track/4. 8L1+8E1B/25K – track | 26 allocs: 10352 B | 26 allocs: 10352 B |
| track/4. 8L1+8E1B/25K – track! | 0 allocs: 0 B | 0 allocs: 0 B |
| track/4. 8L1+8E1B/25K – track!-threaded | 0 allocs: 0 B | 0 allocs: 0 B |
| track/4. 8L1+8E1B/25K – track-threaded | 26 allocs: 10352 B | 26 allocs: 10352 B |
| track/5. multi-signal N=1/5K – track | 12 allocs: 1024 B | 9 allocs: 944 B |
| track/5. multi-signal N=1/5K – track! | 3 allocs: 80 B | 0 allocs: 0 B |
| track/6. multi-signal N=2/5K – track | 15 allocs: 1568 B | 9 allocs: 1408 B |
| track/6. multi-signal N=2/5K – track! | 6 allocs: 160 B | 0 allocs: 0 B |
| track/7. multi-signal N=3/5K – track | 18 allocs: 2000 B | 9 allocs: 1760 B |
| track/7. multi-signal N=3/5K – track! | 9 allocs: 240 B | 0 allocs: 0 B |
| track/8. L1CA presync 2 blk – track! | 13 allocs: 480 B | 7 allocs: 320 B |
| track/8. L1CA presync 20 blk – track! | 67 allocs: 1920 B | 7 allocs: 320 B |
| track/8. L1CA synced 2 blk – track! | 7 allocs: 320 B | 7 allocs: 320 B |
| track/8. L1CA synced 20 blk – track! | 7 allocs: 320 B | 7 allocs: 320 B |
| time_to_load | 145 allocs: 11216 B | 145 allocs: 11216 B |
45826ff to
95c61b7
Compare
Heads-up: the Julia 1.10 CI failure is pre-existing and not caused by this PRThe This is an LLVM 15 register-allocation crash while compiling the Int16 SIMD downconvert kernels — not a test failure (every testset that runs passes). I verified it is not caused by this PR:
So the breakage is branch-wide and appeared between 2026-07-04 and today — consistent with a freshly-resolved SIMD-stack dependency ( This PR itself only touches scalar code in Fixing the 1.10 dependency/toolchain crash is a separate, branch-wide concern (e.g. a |
Update: the Julia 1.10 crash is flaky, not deterministic — it passed on re-runCorrecting my earlier comment: the So this is a flaky, branch-wide CI issue (it also hit the unmodified base in probe #195), not caused by this PR and not a hard blocker — a re-run clears it. If the flakiness proves annoying, it may be worth a separate look (a This PR is now green across the board. |
…sions Adds a testset that tracks noise (which never bit-syncs, so the per-code-block bit-edge search `_buffer_find_bit` runs on every block) and asserts `track!` is allocation-free after warmup at both a short and a long chunk length. The existing per-stage allocation test misses this: it tracks a real signal that reaches bit sync within the warmup, so it only ever measures the post-sync path. With the closure-capture box this branch removes, the pre-sync search allocated ~80 B per code block, so the test fails on the old implementation (160 B at 2 blocks, 1600 B at 20 — the 20-block assertion catches the scaling) and passes now. Gated to Julia >= 1.11: on 1.10 the compiler leaves a ~770 B/block allocation in the pre-sync soft-bit path (unrelated to the box) that 1.11+ elides, so the `== 0` assertion only holds on 1.11+. The regression still shows on 1.11+ and in the benchmark suite's memory table. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
f10e3a7 to
c2c9b4e
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## ss/two-bit-downconvert-and-correlate #194 +/- ##
========================================================================
- Coverage 98.05% 97.78% -0.28%
========================================================================
Files 29 29
Lines 2933 2934 +1
========================================================================
- Hits 2876 2869 -7
- Misses 57 65 +8 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
62b2425
into
ss/two-bit-downconvert-and-correlate
Summary
track!allocates in proportion to the signal length during acquisition (before bit sync), instead of being per-iteration allocation-free after warmup as the docstring promises. Confirmed by measurement on a single GPS L1CA sat with the correlator hoisted (the recommended real-time pattern), after a warmup call:Root cause
The allocation profiler pinned the growth to
_buffer_find_bitinsrc/bit_buffer.jl: ~80 B per code block = 2×SyncResult(boxed, 64 B) + 1×Core.Box(16 B).It's a closure-capture box. The local
syncis assigned in two branches (soft vs. hard detector), then captured by the trailingreduceclosure viaInt(sync.polarity). Julia boxes any variable that is both assigned in more than one place and captured by a closure — producing aCore.Boxper call plus heap-boxing of eachSyncResult. The box exists regardless of runtime path, so it fired on every code block during acquisition.Fix
Hoist
sync_polarity = Int(sync.polarity)into a plain local before the closure, so the closure captures a plainIntandsyncstays unboxed. One line, no behavior change — the full test suite passes (including the bit-decode / L1CA sync tests that exercise this path). Acquisition is now flat at 272 B regardless of length; the post-sync locked steady state was already allocation-free.Benchmark guard
Adds a multi-code-period
track!allocation guard tobenchmark/benchmarks.jl(presyncandsyncedstates at 2 and 20 code blocks).setupbuilds a fresh state and warms it with one call so the measured call is warm — flat memory ⇒ correct, scaling memory ⇒ regression. The benchmark comment's memory table shows the fix on thepresync 20 blkrow (was ~1920 B / 67 allocs, now ~320 B / 7 allocs; the 320 B residual is a constant BenchmarkTools kwarg-harness artifact).🤖 Generated with Claude Code