diff --git a/benchmark/benchmarks.jl b/benchmark/benchmarks.jl index 56fb267b..0887539c 100644 --- a/benchmark/benchmarks.jl +++ b/benchmark/benchmarks.jl @@ -758,6 +758,60 @@ if _HAS_TRACKED_SIGNAL end end +# ── Multi-code-period allocation guard (track!) ─────────────────────────────── +# `track!`'s inner loop runs once per coherent integration, so a signal spanning +# many primary-code blocks iterates it many times. The steady-state contract is +# that `track!` allocates only on its FIRST call (one-time buffer seating) and is +# per-iteration allocation-free thereafter — so `memory` (in the memory table) +# must stay flat as the block count grows. A per-iteration allocation — e.g. a +# closure-capture `Core.Box` / boxed `SyncResult` on the pre-sync bit-edge search +# path — instead makes `memory` scale with the block count. Registering a short +# and a long length side by side turns that scaling into a visible jump: with the +# leak, `20 blk` allocates ~10× the per-iteration bytes of `2 blk`; without it, +# both sit at the same one-time-seed floor. +# +# Two states per length: `presync` (bit not yet found — exercises the +# per-code-block bit-edge search every iteration, the path that regressed) and +# `synced` (`bit_buffer.found = true` — the post-sync steady state). To measure +# the STEADY-STATE (not the one-time-seed) allocation, `setup` builds a fresh +# state AND warms it with one `track!` call, so the *measured* call is the second +# call on that state — cold-call buffer seating is already done, and a warm call +# must be allocation-free at any length. `evals = 1` pins one measured call per +# sample; with the fresh+warm state rebuilt every sample, tracking these random +# samples can't drift the loop filter to a NaN across evals (same reasoning as +# the Int16-vs-Float32 rows above). +if _HAS_TRACKED_SIGNAL && isdefined(Tracking, :track!) + let sfreq = 5e6Hz, gpsl1 = GPSL1CA() + samples_per_block = 5000 # 1 ms GPS L1CA code period @ 5 MHz + for n_blocks in (2, 20) + nsamp = samples_per_block * n_blocks + sig = rand(ComplexF32, nsamp) + dc = _make_cpu_dc(sfreq) + SUITE["track"]["8. L1CA presync $(n_blocks) blk – track!"] = @benchmarkable( + Tracking.track!($sig, ts, $sfreq; downconvert_and_correlator = $dc), + setup = ( + ts = first(_make_multi_sat_state(; + systems = ($gpsl1,), nsats_list = [1], nsamp = $nsamp, + )); + Tracking.track!($sig, ts, $sfreq; downconvert_and_correlator = $dc) + ), + evals = 1, + ) + SUITE["track"]["8. L1CA synced $(n_blocks) blk – track!"] = @benchmarkable( + Tracking.track!($sig, ts, $sfreq; downconvert_and_correlator = $dc), + setup = ( + ts = first(_make_steady_state_track_state(; + systems = ($gpsl1,), nsats_list = [1], nsamp = $nsamp, + prn_max = 32, code_dop = 1000.0, + )); + Tracking.track!($sig, ts, $sfreq; downconvert_and_correlator = $dc) + ), + evals = 1, + ) + end + end +end + # ── Int16 vs Float32 backend, full track! ───────────────────────────────────── # Head-to-head of the Float32 default (CPUThreadedDownconvertAndCorrelator) and # the integer Complex{Int16} backend (Int16ThreadedDownconvertAndCorrelator) diff --git a/src/bit_buffer.jl b/src/bit_buffer.jl index 357b8c12..1c756d71 100644 --- a/src/bit_buffer.jl +++ b/src/bit_buffer.jl @@ -860,12 +860,18 @@ function _buffer_find_bit( div(code_block_buffer_length, num_code_blocks_that_form_a_bit), div(sizeof(code_block_buffer) * 8, num_code_blocks_that_form_a_bit), ) + # Hoist the one field the closure needs (`sync.polarity`) into a plain + # local so the closure below does not capture `sync` itself. `sync` is + # assigned in two branches above (the soft vs. hard detector), and + # capturing a variable that is assigned in more than one place forces + # Julia to box it: a per-call `Core.Box` plus heap-boxing of each + # `SyncResult`. That fires on every code block until sync, so it shows up + # as a per-code-block allocation on the pre-sync hot path (~80 B/block), + # making `track!` allocate in proportion to the signal length instead of + # staying allocation-free after warmup. Capturing the plain `Int` keeps + # `sync` unboxed. + sync_polarity = Int(sync.polarity) bits = reduce(num_bits:-1:1; init = UInt128(0)) do bits, bit_index - # Don't shadow the outer `integrated_code_blocks` argument here: - # because this closure reassigns its own local of the same name, - # Julia conservatively boxes the outer parameter even though this - # closure path does not always run, leading to a per-call - # Core.Box allocation. # Apply the lock polarity to the buffered pre-sync bits as well, so # they map symbol levels to bit values the same way as every # post-sync bit (which is sign-flipped via `bit_buffer.polarity` in @@ -876,7 +882,7 @@ function _buffer_find_bit( buffer_code_block_index = (bit_index - 1) * num_code_blocks_that_form_a_bit + code_block_index ((code_block_buffer & (one(B) << buffer_code_block_index)) > 0) * 2 - 1 - end * Int(sync.polarity) + end * sync_polarity # The pre-sync window only stores prompt signs, so `bit_sum` is a # ±1-per-block vote count (already polarity-corrected above). Scale it # by the sync-time prompt magnitude (the best available amplitude diff --git a/test/track_in_place.jl b/test/track_in_place.jl index 8d8474af..17cf66f1 100644 --- a/test/track_in_place.jl +++ b/test/track_in_place.jl @@ -1,6 +1,7 @@ module TrackInPlaceTest using Test: @test, @testset +using Random: MersenneTwister using Unitful: Hz using GNSSSignals: GPSL1CA, gen_code, get_code_center_frequency_ratio, get_code_frequency @@ -146,4 +147,54 @@ end end end +# Acquisition (pre-sync) allocation guard. Before bit sync, `track!` runs the +# per-code-block bit-edge search (`_buffer_find_bit`) once per code block. A +# regression there — e.g. a closure-capture `Core.Box` + boxed `SyncResult` +# (~80 B/block) — makes `track!` allocate in proportion to the signal length +# instead of staying allocation-free after the first (buffer-seating) call. The +# per-stage test above does NOT catch it: it tracks a real signal that reaches +# bit sync within the warmup, so by the time it measures it exercises only the +# post-sync path, where `_buffer_find_bit` is no longer called. +# +# Feed noise instead — its prompts never form a consistent energy peak, so the +# CFAR bit-edge detector never locks and the pre-sync search runs on every code +# block. After one warmup call (which seats the per-satellite buffers), a warm +# call must allocate nothing, at any signal length. Measuring both a short and a +# long chunk makes a per-block leak fail loudly: with the box it allocated ~80 B +# per block (160 B at 2 blocks, 1600 B at 20), so the 20-block assertion below +# would have caught it. Single-threaded backend so the assertion is a clean +# `== 0` (the threaded backend keeps a small Polyester residual — see above). +# +# Gated to Julia ≥ 1.11. On 1.10 the compiler leaves a per-block allocation in +# the pre-sync soft-bit path (~770 B/block — measured 2144 B at 2 blocks vs +# 15968 B at 20; unrelated to the box, which was ~80 B/block) that 1.11+ elides, +# so `== 0` only holds on 1.11+. The box regression this guards against still +# shows up on 1.11+ (and in the benchmark suite's memory table, which runs on +# the release Julia), so the guard is not lost. +if VERSION >= v"1.11" + @testset "track! is allocation-free during acquisition (pre-sync bit search)" begin + sampling_frequency = 5e6Hz + samples_per_block = 5000 # 1 ms GPS L1CA code period @ 5 MHz + # Build a fresh pre-sync tracker, warm it once, then measure one more call. + # `nblocks` sets the chunk length (and thus the pre-sync loop-iteration + # count); the RNG is seeded per length so the signal is deterministic. + function measure_track_alloc(nblocks) + gpsl1 = GPSL1CA() + track_state = TrackState(gpsl1, [TrackedSat(gpsl1, 1, 10.5, 1000.0Hz)]) + dc = CPUDownconvertAndCorrelator() + signal = rand(MersenneTwister(nblocks), ComplexF32, samples_per_block * nblocks) + call() = track!( + signal, + track_state, + sampling_frequency; + downconvert_and_correlator = dc, + ) + call() # warmup: seat buffers, compile + @allocated call() + end + @test measure_track_alloc(2) == 0 + @test measure_track_alloc(20) == 0 + end +end + end