feat: GPU-accelerated downconvert and correlate via KernelAbstractions.jl#99
Open
zsoerenm wants to merge 40 commits into
Open
feat: GPU-accelerated downconvert and correlate via KernelAbstractions.jl#99zsoerenm wants to merge 40 commits into
zsoerenm wants to merge 40 commits into
Conversation
Contributor
Benchmark ResultsTime benchmarks (vs base branch)
Memory benchmarks (vs base branch)
Time benchmarks (vs master)
Memory benchmarks (vs master)
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## ss/cpu-threaded-downconvert-and-correlate #99 +/- ##
=============================================================================
+ Coverage 86.80% 94.46% +7.65%
=============================================================================
Files 24 23 -1
Lines 796 940 +144
=============================================================================
+ Hits 691 888 +197
+ Misses 105 52 -53 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
zsoerenm
force-pushed
the
ss/ka-downconvert-and-correlate
branch
5 times, most recently
from
March 9, 2026 12:21
27a042a to
4d39905
Compare
zsoerenm
changed the base branch from
master
to
ss/cpu-threaded-downconvert-and-correlate
March 9, 2026 12:23
zsoerenm
force-pushed
the
ss/ka-downconvert-and-correlate
branch
3 times, most recently
from
March 9, 2026 12:41
b1d66f2 to
153ed5a
Compare
zsoerenm
force-pushed
the
ss/cpu-threaded-downconvert-and-correlate
branch
from
March 9, 2026 12:47
6d675ec to
73abb76
Compare
zsoerenm
force-pushed
the
ss/ka-downconvert-and-correlate
branch
from
March 9, 2026 12:48
153ed5a to
574c27d
Compare
zsoerenm
force-pushed
the
ss/cpu-threaded-downconvert-and-correlate
branch
2 times, most recently
from
March 9, 2026 12:59
b8b08a7 to
fa0ae1c
Compare
Replace separate carrier replica generation and downconversion with a fused approach that generates the carrier on-the-fly during downconversion. This eliminates the carrier replica buffer, saving memory bandwidth. - Add @generated _fused_downconvert_unrolled! that unrolls the antenna dimension at compile time via NumAnts, allowing @avx to vectorize purely along the sample dimension while computing sincos once per sample - Update downconvert_and_correlate! to use fused downconvert! with NumAnts - Remove carrier_replica buffer allocation from @no_escape block - Add 4-antenna benchmark to benchmark/benchmarks.jl ~11-18% faster across all antenna configurations (1-4 antennas). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…eneration - Eliminate carrier replica buffer by generating carrier on-the-fly during downconversion using FastSinCos.fast_sincos_u100k (SIMD) - Use @nexprs for 4x loop unrolling, _deinterleave_load with shufflevector for interleaved complex signal loading - Auto-detect SIMD width via VectorizationBase.pick_vector_width (supports AVX2, AVX-512, ARM NEON) - Merge single/multi-antenna into one method (compiler unrolls for j in 1:1) - Remove unused StructArray fused downconvert methods - ~15% faster than master, zero allocations in downconvert!, 56% fewer lines Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Single-pass kernel that generates the carrier on-the-fly, downconverts, and immediately accumulates against shifted code replicas — keeping downconverted samples in SIMD registers instead of writing to an intermediate buffer. Supports both single-antenna and multi-antenna correlators with 4xW unrolled, 1xW cleanup, and scalar remainder loops. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Use @generated to fully unroll M×NC accumulator dimensions, keeping all accumulators in named local variables (registers) instead of heap-allocated arrays - Precompute per-tap code pointers (p_code_k) before the loop, removing repeated sample_shifts[k] - min_shift from the hot path - Use muladd-based phase computation: precompute per-vector initial phases and broadcast a single muladd per iteration (drift-free) - Remove downconvert signal buffer allocation from CPU path since the fused kernel keeps downconverted samples in registers - Wire fused kernel into downconvert_and_correlate! replacing the separate downconvert! + correlate calls - Add test comparing fused vs split path results Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The fused downconvert+correlate kernel replaces all @avx-based split paths (gen_carrier_replica!, downconvert!, correlate), eliminating the need for LoopVectorization and VectorizationBase. StructArrays is no longer used since the fused kernel works directly on interleaved complex arrays. Also removes reshape allocations in the 1-antenna dynamic path (0 allocs across all variants), cleans up benchmarks by removing legacy PACKAGE_VERSION branches, and adds fused kernel microbenchmarks for 1-ant/4-ant static/dynamic. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Use VectorizationBase.pick_vector_width(T) instead of hardcoded 256-bit assumption, so the SIMD width adapts to the actual CPU features (AVX-512, etc.). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
[sources] in Project.toml is not supported in Julia 1.10, which is needed for the unregistered FastSinCos.jl dependency. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Wrap fused kernel microbenchmarks in isdefined check so the benchmark script works on master where the function doesn't exist. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Test fused kernel against independent scalar reference (rtol=1e-4) instead of only comparing dynamic vs static paths - Add strict cross-path comparison (rtol=1e-6) - Rename benchmark labels to "static taps"/"dynamic taps" - Fix benchmark CI by using AirspeedVelocity fork with [sources] support Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
SIMD codegen differs between Julia 1.11 and 1.12, causing larger floating-point accumulation differences (~1e-5) between the static and dynamic paths on 1.11. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… head Add multi-antenna (M=2) test for the fused downconvert+correlate kernel, covering the _add_antenna SVector path and multi-antenna branches in both static (@generated) and dynamic (AbstractVector) code paths. Fix benchmark CI by using PR HEAD SHA for --bench-on instead of default branch, since master's benchmarks.jl still has `using CUDA`. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
zsoerenm
force-pushed
the
ss/cpu-threaded-downconvert-and-correlate
branch
from
March 9, 2026 13:05
fa0ae1c to
20e0bb2
Compare
Use non-SIMD-aligned signal length (5003) to exercise the scalar remainder loop (lines 310-320) in the dynamic-shifts multi-antenna branch of the fused kernel. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…al config Add test for signal_samples_to_integrate == 0 branch in CPUThreadedDownconvertAndCorrelator to achieve full patch coverage. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
zsoerenm
force-pushed
the
ss/cpu-threaded-downconvert-and-correlate
branch
from
March 9, 2026 14:10
46924de to
dbe91a9
Compare
zsoerenm
force-pushed
the
ss/ka-downconvert-and-correlate
branch
from
March 9, 2026 14:53
574c27d to
9779aa1
Compare
_to_vec and _make_offset used ntuple() to construct SIMD vectors, which Julia failed to stack-allocate for N=16 (AVX-512). This caused 149K heap allocations per call on AVX-512 machines, making the CPU path ~275x slower than necessary. Fix: convert both to @generated functions that emit literal tuple constructors, ensuring the compiler sees constant N at generation time. Before (AVX-512): L1 8sat/5K = 3,522 μs, 149K allocs After (AVX-512): L1 8sat/5K = 12.8 μs, 3 allocs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…s.jl Add a portable GPU implementation of the GNSS downconvert-and-correlate pipeline using KernelAbstractions.jl. The kernel fuses carrier wipe-off, code lookup, and correlation into a single pass with in-kernel workgroup reduction via shared memory. Started with a straightforward port: per-sample sincos() for carrier, Float64 accumulators, per-thread partial sums transferred back to CPU for reduction. This was slower than CPU for all configurations due to the massive GPU→CPU transfer of partial arrays and expensive FP64 sincos on GPU. Replaced CPU-side reduction with in-kernel tree reduction using shared memory (@LocalMem). Only a compact ComplexF64 result array (sats × ants × taps) is transferred back. Combined the separate Float64 re/im arrays into a single ComplexF64 array, halving the number of GPU→CPU copies. Also introduced Val{num_taps} for compile-time kernel specialization, letting @Private allocate exact-sized accumulators and enabling loop unrolling. Added support for batching multiple GNSS systems (e.g., GPSL1 + GalileoE1B) into the same struct. Initially tried a single kernel with a tuple of code tables and per-satellite system_idx — but the @generated dispatch overhead caused a 25-34% regression. Switched to per-system kernel launches, each specialized at compile time for modulation type (LOC/BOC/CBOC), code length, and num_taps. This recovered the regression and added GalileoE1B (CBOC) support. Replaced per-sample sincos() with incremental carrier rotation using FP32 multiply-add (Givens rotation). FP32 accumulators during the inner loop, promoting to FP64 only at the final reduction step. On RDNA 4 (Radeon 8060S): ~1.7x kernel speedup at 25K samples. For ≤8 correlator taps (the common case), replaced sequential per-tap reduction (num_taps × 8 barriers) with a single combined pass storing all taps in shared memory simultaneously (8 barriers total). ~22% kernel speedup for EarlyPromptLate (3 taps). Precomputed subcarrier values (BOC/CBOC) into a lookup table indexed by sub-chip phase. Hoisted per-tap code phase offsets out of the inner loop. LUT size as compile-time Val eliminates dead subcarrier code for LOC signals (GPSL1). GalileoE1B CBOC: 214→127μs (1.69x). Tried pre-generating code replicas on CPU via gen_code_replica! and uploading per call. The kernel became trivially fast (pure indexed reads, no floor/mod) — GPSL1 72→39μs, GalE1B 127→55μs — but the PCIe transfer of code replicas dominated: 175μs for E1B 16sat (47% of total time), plus 79μs for CPU generation (21%). Together: 68% overhead. This approach only won at very high satellite counts. Returned to GPU-resident code tables with subcarrier baked into expanded LUTs (e.g., E1B: 4092 chips × 12 sub-per-chip = 49104 entries/PRN), but replaced the expensive float floor+mod code lookup from v6 with fixed-point integer arithmetic. The key insight: encode code phase as a fixed-point integer (fractional bits after the radix point). Code chip index = phase >> fractional_bits. Sub-chip index for the expanded LUT comes naturally from the integer phase. No floor(), no float-to-int conversion. Two modes via parametric phase type: - Int32 (18 fractional bits): Fast, ~4% sub-chip quantization error. Max expanded phase ~1.6B fits Int32. - Int64 (32 fractional bits): Default. Zero quantization errors across 100K samples (verified against BigFloat reference). Eliminates the 3/100K errors that even the CPU accumulator approach produces. Profiling showed the kernel was 86% of total time, and within the kernel, Int64 mod (emulated as ~20 ALU instructions on AMD GPU) was the bottleneck. Microbenchmarked four alternatives: - mod(Int64): 28.4μs (baseline) - conditional subtract: 18.7μs - mod(Int32) truncated: 22.5μs - accumulate+wrap: 15.1μs (1.88x faster) The accumulate+wrap pattern tracks code phase as a running accumulator, initialized once with mod(), then advanced by delta×stride per grid step with a branchless conditional subtract for wrapping. Per-tap offsets use branchless wrap for negative phases (early correlator). | Config | GPU (μs) | CPU (μs) | GPU/CPU | |------------------|----------|----------|---------| | L1 4sat/5K | 34.9 | 9.0 | 3.88x | | L1 16sat/5K | 36.9 | 36.7 | 1.01x | | E1B 4sat/25K | 58.3 | 47.6 | 1.22x | | E1B 16sat/25K | 61.3 | 187.8 | 0.33x | | E1B 4sat/100K | 129.7 | 228.2 | 0.57x | | E1B 16sat/100K | 131.2 | 919.8 | 0.14x | | 4L1+4E1B/25K | 86.3 | 84.5 | 1.02x | | 8L1+8E1B/25K | 87.9 | 167.8 | 0.52x | | 8L1+8E1B/100K | 157.5 | 515.1 | 0.31x | GPU/CPU < 1.0 means GPU is faster. The GPU wins decisively for E1B with ≥16 satellites (3-7x faster) and for multi-system configurations (2-3x faster). L1 with few satellites still favors CPU due to the ~35μs fixed GPU launch overhead. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Compares the old CUDA extension (texture memory), the new KA implementation (Int32 and Int64 modes), and CPU across GPSL1, GalileoE1B, and multi-system configurations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Detects available backends (CUDA, AMDGPU) at runtime and benchmarks KernelAbstractions.jl (Int32/Int64) alongside CPU. Also benchmarks the old CUDA texture memory extension when available. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Split benchmarks.jl into: - bench_cpu.jl: CPU downconvert-and-correlate + track suite - bench_gpu_vs_cpu.jl: GPU (CUDA-ext, KA+CUDA, KA+AMDGPU) vs CPU suite benchmarks.jl now includes both and merges their suites. The GPU benchmark gracefully skips KA when not available (e.g., on master). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The BenchmarkTools UUID was wrong (copy-paste error), causing Pkg.instantiate() to fail on CI. Also fix BenchmarkGroup composition in benchmarks.jl — merge! is not supported, use iteration instead. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Int64 with accumulate+wrap is both faster and more accurate than Int32: - E1B 8sat/25K: 60μs (Int64) vs 85μs (Int32) - E1B 8sat/100K: 132μs (Int64) vs 236μs (Int32) Removes ~400 lines: Int32 kernels, param packing, phase_type kwarg, and associated tests/benchmarks. The struct drops the P type parameter. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Merge ka_dc_kernel! and ka_dc_multi_ant_kernel! into a single kernel that takes num_ants as a parameter. For single-antenna (num_ants=1), signal[i, 1] works for both vectors and matrices in Julia. Removes ~170 lines of duplication with zero performance regression (benchmarked on AMD Radeon 8060S). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Verify that KADownconvertAndCorrelator (CPU backend) converges to correct code phase and carrier phase over 2000 tracking iterations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
GPUDownconvertAndCorrelator requires homogeneous NTuple type, so multi-system (GPSL1+GalileoE1B) benchmarks can't use the old extension. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…enchmarks Replace the texture-memory CUDA extension (TrackingCUDAExt) with KernelAbstractions.jl-based GPU support throughout. Add CUDA-conditional tests for downconvert_and_correlate and tracking. Update Buildkite to run KA+CUDA tests and GPU benchmarks with annotated results. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Replace @Spawn with @threads for zero-allocation threading - Add fused downconvert! that generates carrier on-the-fly, eliminating carrier_replica buffer and saving memory bandwidth - Use @generated to unroll antenna dimension at compile time via NumAnts, giving optimal SIMD for all antenna counts (1.3x faster at 1 ant, 1.25x at 2 ant, ~equal at 4-8 ant vs unfused) - Remove carrier_replica_re/im fields from CPUThreadedDownconvertAndCorrelator - Add max_num_samples kwarg to constructor to fix DimensionMismatch - Detect low-CU iGPUs and limit GPU benchmark configs to avoid display freeze - Access Dictionary internals directly (states.values[i]) to avoid allocation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Wrap CPUThreadedDownconvertAndCorrelator and KADownconvertAndCorrelator usage with isdefined guards so benchmarks don't fail on master where these types don't exist. - Set JULIA_NUM_THREADS=auto in Buildkite benchmark step so CPU-Threaded actually uses multiple cores. - Restructure GPU benchmark table: single table per GPU backend showing GPU, CPU-Threaded, CPU columns with speedup ratios (vs CPU, vs Threaded). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
GPU benchmarks require a GPU runner and are run separately via Buildkite
(benchmark/run_gpu_benchmarks.jl). Including them in the AirspeedVelocity
SUITE caused errors on master where the multi-system CPU correlator
buffer allocation failed with size(::Val{...}, ::Int64).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The benchmark suite covers all backends (CPU, CPU-Threaded, KA-CUDA, KA-AMDGPU) for multi-satellite/multi-system workloads, not just GPU. Renames: - bench_gpu_vs_cpu.jl → bench_multi_sat.jl - run_gpu_benchmarks.jl → run_multi_sat_benchmarks.jl - gpu_suite() → multi_sat_suite() Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Julia's soft scope rules treat assignment inside a for loop as a new local variable. Add explicit `global` to update the outer variable. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
zsoerenm
force-pushed
the
ss/ka-downconvert-and-correlate
branch
from
March 9, 2026 16:42
4c5aafc to
091934b
Compare
Remove [sources] section now that FastSinCos is in the registry. Lower minimum Julia version from 1.11 to 1.10 across Project.toml, CI, and Buildkite. Add Julia latest to Buildkite GPU test matrix. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
zsoerenm
force-pushed
the
ss/cpu-threaded-downconvert-and-correlate
branch
2 times, most recently
from
March 25, 2026 08:39
83f89dc to
62f4e8f
Compare
Base automatically changed from
ss/cpu-threaded-downconvert-and-correlate
to
master
March 25, 2026 10:19
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Add a portable GPU implementation of the GNSS downconvert-and-correlate pipeline using KernelAbstractions.jl. The kernel fuses carrier wipe-off, code lookup, and correlation into a single pass with in-kernel workgroup reduction via shared memory.
The journey
v1: Naive GPU kernel (baseline)
Started with a straightforward port: per-sample sincos() for carrier, Float64 accumulators, per-thread partial sums transferred back to CPU for reduction. This was slower than CPU for all configurations due to the massive GPU→CPU transfer of partial arrays and expensive FP64 sincos on GPU.
v2: In-kernel reduction + ComplexF64 results
Replaced CPU-side reduction with in-kernel tree reduction using shared memory (@LocalMem). Only a compact ComplexF64 result array (sats × ants × taps) is transferred back. Combined the separate Float64 re/im arrays into a single ComplexF64 array, halving the number of GPU→CPU copies. Also introduced Val{num_taps} for compile-time kernel specialization, letting @Private allocate exact-sized accumulators and enabling loop unrolling.
v3: Cross-system batching + per-system kernel launches
Added support for batching multiple GNSS systems (e.g., GPSL1 + GalileoE1B) into the same struct. Initially tried a single kernel with a tuple of code tables and per-satellite system_idx — but the @generated dispatch overhead caused a 25-34% regression. Switched to per-system kernel launches, each specialized at compile time for modulation type (LOC/BOC/CBOC), code length, and num_taps. This recovered the regression and added GalileoE1B (CBOC) support.
v4: Carrier rotation + FP32 accumulation
Replaced per-sample sincos() with incremental carrier rotation using FP32 multiply-add (Givens rotation). FP32 accumulators during the inner loop, promoting to FP64 only at the final reduction step. On RDNA 4 (Radeon 8060S): ~1.7x kernel speedup at 25K samples.
v5: Combined-tap reduction
For ≤8 correlator taps (the common case), replaced sequential per-tap reduction (num_taps × 8 barriers) with a single combined pass storing all taps in shared memory simultaneously (8 barriers total). ~22% kernel speedup for EarlyPromptLate (3 taps).
v6: Subcarrier LUT + tap phase hoisting
Precomputed subcarrier values (BOC/CBOC) into a lookup table indexed by sub-chip phase. Hoisted per-tap code phase offsets out of the inner loop. LUT size as compile-time Val eliminates dead subcarrier code for LOC signals (GPSL1). GalileoE1B CBOC: 214→127μs (1.69x).
v7: CPU-generated code replicas (dead end)
Tried pre-generating code replicas on CPU via gen_code_replica! and uploading per call. The kernel became trivially fast (pure indexed reads, no floor/mod) — GPSL1 72→39μs, GalE1B 127→55μs — but the PCIe transfer of code replicas dominated: 175μs for E1B 16sat (47% of total time), plus 79μs for CPU generation (21%). Together: 68% overhead. This approach only won at very high satellite counts.
v8: Fixed-point LUT on GPU (the winner)
Returned to GPU-resident code tables with subcarrier baked into expanded LUTs (e.g., E1B: 4092 chips × 12 sub-per-chip = 49104 entries/PRN), but replaced the expensive float floor+mod code lookup from v6 with fixed-point integer arithmetic.
The key insight: encode code phase as a fixed-point integer (fractional bits after the radix point). Code chip index = phase >> fractional_bits. Sub-chip index for the expanded LUT comes naturally from the integer phase. No floor(), no float-to-int conversion.
Two modes via parametric phase type:
v9: Accumulate+wrap (final optimization)
Profiling showed the kernel was 86% of total time, and within the kernel, Int64 mod (emulated as ~20 ALU instructions on AMD GPU) was the bottleneck. Microbenchmarked four alternatives:
The accumulate+wrap pattern tracks code phase as a running accumulator, initialized once with mod(), then advanced by delta×stride per grid step with a branchless conditional subtract for wrapping.
Final benchmark results (AMD Radeon 8060S, RDNA 4)
GPU/CPU < 1.0 means GPU is faster. The GPU wins decisively for E1B with ≥16 satellites (3-7x faster) and for multi-system configurations (2-3x faster). L1 with few satellites still favors CPU due to the ~35μs fixed GPU launch overhead.
CUDA (NVIDIA A100-PCIE-40GB MIG 1g.5gb)
Test plan
🤖 Generated with Claude Code