# Continuously Variable Resampler

- Any rate change: from imperceptible to several orders of magnitude
- 32-bit resolution

# Architecture

## Interpolator (r = Fout/Fin ≥ 1, output-driven)

- NCO frequency = 1/r (overflows once per input sample consumed)
- Every output tick: select polyphase branch from NCO phase,
    dot-product with delay line, emit one output sample
- On NCO overflow: push next input sample into delay line

```
                                  ┌────────────────────────────┐
                                  │   Polyphase filter bank    │
                                  │                            │
                    *** phase ────|────────► h[0..N-1]         │
                                  └─────────────┬──────────────┘
                                                │ h
                                                ▼
x[n] ──► push ──► [ delay line, N taps ] ──dot(ptr, h)──► y[k]
           ▲                                    │
           │                                    │
           └──── on overflow ◄── NCO (freq=1/r) ┘
                                  │
                                  └──► phase ***
```

**Per output tick:**

1. Advance NCO → `(phase, overflow)`
1. Look up branch: `h = bank[phase >> (32 − log₂L)]`
1. Compute: `y[k] = Σ delay[j] · h[j]`, j = 0 … N−1
1. Emit `y[k]`
1. If overflow: `delay.push(x[next])`; advance input pointer

## Decimator (r = Fout/Fin < 1, input-driven, transposed form)

- NCO frequency = r (overflows once per output sample emitted)
- Every input tick: scalar `x[n]` × all N branch coefficients,
    products accumulate in N integrate-and-dump (I&D) registers
- On NCO overflow: dump all N I&D values into a transposed tapped
    delay line, shift the line, emit one output sample, reset I&D

```
     ┌────────────────────────────┐
     │   Polyphase filter bank    │
     │                            │
     │  phase ──► h[0..N-1]       │
     └─────────────┬──────────────┘
                   │ h (reversed, scaled by r)
                   ▼
         x[n] ──► (×) ──► [ N integrate-and-dump registers ]
                   │                     │
                   │            on overflow: dump + reset
                   │                     │
                   │                     ▼
                   │       d[N-1]     d[N-2]       d[1]       d[0]
                   │         │  ┌───┐   │           |   ┌───┐   │
                   │         └─►│ T ├─►(+)─► ··· ─►(+)─►│ T ├─►(+)──► y[k]
                   │            └───┘                   └───┘
                   │
              NCO (freq=r) ──► phase
```

**Per input tick:**

1. Advance NCO → `(phase, overflow)`
1. Look up branch: `h = bank[phase >> (32 − log₂L)]`
1. Accumulate: `iad[j] += x[n] · h[j]`, j = 0 … N−1
1. If overflow:
    - `d = iad;  iad = 0` (dump and reset)
    - Shift transposed delay line and emit:
        ```
        y[k]     = d[0] + s[0]
        s[0]     = d[1] + s[1]
          ⋮
        s[N−3]   = d[N−2] + s[N−2]
        s[N−2]   = d[N−1]
        ```
    - Emit `y[k]`

# Testing

- Python reference implementation made with native components
- Reference validation
    - Passband flatness
    - Stopband / alias attenuation

## Interpolator Test Procedure

- Purpose: validate passband flatness and image / artifact suppression.
- Rate change = Fout/Fin = r = 2.0333
- Method: two complex tones at 0.1·Fin and 0.4·Fin, both of which
    should appear in the output unmolested
- Measure
    - Frequency of each tone: f/r ± measurement error
    - Amplitude: ±0.1 dB
    - Relative level of largest non-tone peak: ≤ −60 dBc

## Decimator Test Procedure

- Purpose: validate passband flatness and alias rejection.
- Rate change = Fout/Fin = r = 0.50333
- Method: two complex tones at 0.4·Fout and 0.6·Fout — tone 1 lands
    in the passband; tone 2 is above Nyquist_out and must be rejected
    by the anti-alias filter before it can fold back
- Measure
    - Frequency of tone 1: 0.4 ± measurement error
    - Amplitude of tone 1: ±0.1 dB
    - Relative level of largest non-tone peak: ≤ −60 dBc

# Performance Optimizations

## Current numbers (cf32, AVX-512, -march=native)

| filter        | rate  | mode   | MSa/s |
| ------------- | ----- | ------ | ----- |
| 80 dB, L=4096 | 4.0×  | interp | 48.5  |
| 80 dB, L=4096 | 0.25× | decim  | 145.4 |
| 65 dB, L=1024 | 4.0×  | interp | 47.8  |
| 65 dB, L=1024 | 0.25× | decim  | 126.2 |

The 80 dB filter's coefficient table is 304 KB — spills past L2.
Branch strides at rate=0.25× jump 1024 rows per input sample,
guaranteeing L2/L3 misses on every iad_madd. The 65 dB table
(76 KB) fits in L2 but not L1, so it's slower at low rates where
branch locality is bad. Getting the table into L1 is the dominant
leverage point.

## Idea 1 — DPMFS: Dual Phase Modified Farrow Structure

Reference: M. T. Hunter and W. B. Mikhael, "A Novel Farrow
Structure with Reduced Complexity," ICASSP 2009.

Instead of a large phase table, compute coefficients on-the-fly
as polynomials in the fractional phase. The target structure is
the **DPMFS** (J=2, M=3), which combines two prior improvements:

- **MFS** (Modified Farrow): exploits linear phase symmetry →
    ~½ the multiplications of standard Farrow
- **GFS** (Generalized Farrow): uses J-fold oversampling to
    reduce polynomial order for a given frequency response

The DPMFS unifies both. J=2 splits the fractional interval μ
into a 1-bit coarse select and a fine residual:

```
j   = ⌊2μ⌋        ∈ {0, 1}   — selects polyphase branch
μ_J = 2μ − j      ∈ [0, 1)   — fine fractional offset
```

Each of the two subfilters c_m(p, j) is odd-length and symmetric
(linear phase), so each requires only ~½ the multiplications of a
non-symmetric filter. Both savings compound.

### Complexity comparison (70 dB, 0.3/0.7 transition — Table 2)

| Structure   | J   | M   | Multipliers | Coeff storage |
| ----------- | --- | --- | ----------- | ------------- |
| Standard FS | 1   | 3   | ~44         | ~44 floats    |
| MFS         | 1   | 4   | 32          | 28 floats     |
| GFS         | 8   | 2   | 32          | 240 floats    |
| **DPMFS**   | 2   | 3   | **27**      | 46 floats     |

DPMFS beats every prior structure: **~39% fewer multiplications
than naive cubic Farrow**, 15% fewer than MFS or GFS, and
coefficient storage of 46 floats = 184 bytes — a few cache lines,
fully L1-resident.

### Per output sample (runtime)

1. NCO gives raw phase word → μ = phase / 2³²
1. j = top bit of fractional phase (1 shift); μ_J = 2μ − j (1
    MAC)
1. Horner evaluation: 3 MACs (M=3 polynomial in μ_J)
1. Dot product with symmetric subfilter c_m(p, j): ~P/2
    multiplications + P additions (symmetry folds the tap pairs)
1. Emit output sample

### Design tool

`doppler.polyphase` builds the Kaiser prototype. A new
`to_dpmfs_coeffs()` method would:

1. Design optimal symmetric PBF with J=2 and odd length
1. Polyphase decompose: c_m(p, j) = d_m(2p + j)
1. Return two coefficient arrays (j=0, j=1) ready for the C
    runtime

## Idea 2 — Vectorize across output samples (interpolator)

For rate ≈ N (near-integer), consecutive outputs share the same
delay-line window — the window only advances on NCO overflow, once
every ~N outputs. Those N outputs are fully independent (different
branch / μ, same signal data).

Current: N sequential dot products, each reloading the delay line.
Proposed: load delay window once, compute N dot products in parallel.

```
delay window (19 taps, 76 bytes — stays in registers):
  output k+0: dot(window, h(μ₀))   ─┐
  output k+1: dot(window, h(μ₁))    ├─ all independent
  output k+2: dot(window, h(μ₂))    │
  output k+3: dot(window, h(μ₃))   ─┘
```

With Farrow coefficients the μᵢ are computed from the NCO phase
and the h vectors are tiny — fully register-resident. This turns
the interpolator hot path into a pure FMA chain with no memory
traffic between outputs.

## Idea 3 — Coefficient producer / FIFO pipeline

A producer thread precomputes coefficient vectors (advancing the NCO,
evaluating polynomials) and writes them into a lock-free ring buffer.
The filter thread reads coefficient vectors and does dot products.

**When this wins:** only if coefficient generation is the bottleneck
AND it can fully overlap with the filter computation.

**For our case:** with Farrow order-3, coefficient generation costs
~3N FLOPs ≈ 57 FLOPs per output. AVX-512 executes that in ~4
cycles — far below thread synchronization overhead (~100–1000
cycles). The bottleneck is the dot product, not the lookup.

**More useful form:** double-buffer the *input samples*. One thread
reads from DMA / network / file into a ring buffer; the filter thread
reads from it. This hides I/O latency without touching the inner
loop. Standard producer/consumer on the sample stream, not the
coefficient stream.

**Verdict:** coefficient FIFO — no; input sample double-buffer — yes,
for real streaming pipelines.

## Idea 4 — Halfband cascade pre-decimation

For large decimation ratios, prepend one or more halfband stages
before the polyphase resampler. A halfband filter:

- Cuts at fs/4 (passes 0–fs/4, rejects fs/4–fs/2)
- Exactly half its taps are zero — costs ≈ N/2 MACs/sample
- Decimates by exactly 2×

Cost of three halfband stages to pre-decimate by 8× before a fine
polyphase stage operating on the residual rate:

```
Stage 1: N/2 MACs at full rate  = N/2   MACs/input
Stage 2: N/2 MACs at fs/2       = N/4   MACs/input
Stage 3: N/2 MACs at fs/4       = N/8   MACs/input
Fine polyphase (on decimated stream): much shorter filter needed
```

The polyphase resampler then handles only the fractional part of the
rate (e.g., ÷1.0333 instead of ÷8.0333), which requires far fewer
taps for the same stopband attenuation. Both the filter length and
the sample rate it operates at shrink simultaneously.

Best fit: integer-ratio coarse decimation followed by arbitrary-rate
fine resampling. Halfband design belongs in `doppler.polyphase`.

## Idea 5 — Parallel phase computation / threading

The polyphase branches are independent, so the question is whether
computing them in parallel pays off.

**Per-output threading (inner loop):** for N=19 taps, one dot
product takes ~2 AVX-512 iterations ≈ a handful of cycles. Thread
launch overhead is 100–10000× that. Never worth it.

**Multi-output threading:** Ideas 1+2 above vectorize M outputs using
SIMD. Using threads instead of SIMD lanes for those M outputs gives
no advantage — SIMD is zero-overhead, threads are not.

**Block-level threading:** split a large input block (e.g., 1M
samples) into B chunks, replicate the delay-line state at each
boundary using the tail of the previous chunk, process B chunks on B
cores. Overhead is: B state copies + B thread launches + join.
Amortizes for blocks ≳ 100 K samples and B ≤ core count.

**Real-time SDR context:** single-core vectorized throughput is
already 50–150 MSa/s. Typical SDR sample rates are 1–30 MSa/s.
We are already well ahead. Threading adds latency and complexity
for a use case that doesn't need it yet.

**Verdict:** block-level threading is the right granularity if we
ever need it; leave the inner loop single-threaded.

## Priority order

1. **Farrow cubic (Idea 1)** — eliminates the cache-miss penalty
    that dominates at high phase counts; table shrinks from 304 KB
    to 304 *bytes*.
1. **Multi-output vectorization (Idea 2)** — once the table is
    register-resident, this removes the remaining memory traffic
    from the interpolator hot path.
1. **Halfband cascade (Idea 4)** — for large decimation ratios,
    reduces both filter length and sample rate simultaneously.
1. **Input sample double-buffer (Idea 3, revised)** — for streaming
    pipelines where I/O and compute should overlap.
1. **Block threading (Idea 5)** — only if we ever saturate a single
    core, which current benchmarks suggest won't happen at SDR rates.
