Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions benchmark/benchmarks.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 12 additions & 6 deletions src/bit_buffer.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
51 changes: 51 additions & 0 deletions test/track_in_place.jl
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Loading