Skip to content

Ddcr — Real Passband to Baseband

Ddcr spectral demo

What you're seeing

Three panels share the same x-axis — normalised frequency in cycles/sample (−0.5 to +0.5). A red dashed marker labels the strongest spectral line in each panel.

Top panel — input. 16384 samples of a real float32 signal: a tone at fn = 0.18 (relative to the input sample rate fs_in) plus broadband noise. Because the input is real, the spectrum is symmetric — the tone shows at both ±0.18.

Middle panel — baseband output. The same signal run through Ddcr.execute with the NCO tuned so the carrier lands at DC. The output is complex64 and decimated 4× (rate = 0.25), so its x-axis is normalised to fs_out. The tone sits at fn = 0.000.

Bottom panel — retuned output. The LO is moved 0.04 (in fs_in) below the carrier via the norm_freq property. The tone shifts off DC by 0.04 / rate = 0.16 in fs_out units, landing at fn = +0.160 — exactly as predicted, and phase-continuously (no filter-history reset).

One object over opaque C state

Ddcr is a generated class: a typed Python object over an opaque ddcr_state_t *. It unifies what were once two faces (a DDCR object and ddcr_* free functions) into one — it owns the C state like an object, yet execute() writes into a caller-provided output buffer like the old functional API, so allocation and lifetime stay explicit.

Aspect Ddcr
State opaque ddcr_state_t *, owned by the object (RAII close)
Output written into a caller-owned complex64 buffer
Retune ddcr.norm_freq = … (live, phase-continuous)
Use when real-ADC input; you manage your own arrays / want zero
per-call allocation in a hot loop

How it works

A Ddcr takes a real passband signal, mixes it with a fine NCO (running at fs_in / 2), low-pass filters, and decimates to complex baseband. To park a real tone at carrier f_carrier (normalised to fs_in) at DC:

f_carrier = 0.18                            # real tone, normalised to fs_in
norm_freq = -(2 * f_carrier + 0.5)

The lifecycle is explicit — the object is yours to keep, reuse, and release:

import numpy as np
from doppler.ddc import Ddcr

stream = [np.zeros(4096, dtype=np.float32) for _ in range(3)]  # demo blocks

ddcr = Ddcr(norm_freq=-(2 * 0.18 + 0.5), rate=0.25)

out = np.empty(4096, dtype=np.complex64)        # reused every block
for block in stream:                            # block: ndarray[float32]
    y = ddcr.execute(block, out)                # zero-copy view out[:n_out]
    ...

ddcr.norm_freq = -(2 * 0.14 + 0.5)              # retune, phase-continuous
ddcr.reset()                                    # zero all history
ddcr.close()                                    # release C resources

After close(), any further call raises RuntimeError; live views of earlier output stay valid because they reference the caller's buffer, not the state. A with Ddcr(...) as ddcr: block closes it automatically.

Streaming semantics — the object wraps mutable C state

A Ddcr wraps a single C pointer to a struct that is mutated in place on every call. The actual state (the LO phase, the halfband taps, the polyphase resampler banks, the history buffers) lives entirely in C and never crosses the Python/C boundary: each call passes the object, not the kilobytes of state, so nothing is serialized, marshaled, or copied per call. That is also what makes block-by-block processing phase-continuous — the same object carries its history forward from one execute to the next, so feeding a signal as two halves through the same object is bit-identical to processing it in one shot:

lo = -(2 * 0.18 + 0.5)                          # tune carrier fn=0.18 to DC
x = np.cos(2 * np.pi * 0.18 * np.arange(8192)).astype(np.float32)
out = np.empty(8192, dtype=np.complex64)

# one shot ----------------------------------------------------------------
d = Ddcr(lo, 0.25)
y_whole = d.execute(x, out).copy()

# same input, same handle, two blocks -------------------------------------
d = Ddcr(lo, 0.25)
y0 = d.execute(x[:4096], out).copy()   # state advances in place
y1 = d.execute(x[4096:], out).copy()   # picks up exactly where y0 left off
# np.concatenate([y0, y1]) == y_whole   (max|Δ| == 0)

Run those same two halves through fresh handles instead and the seam shows a phase jump and a filter transient (max|Δ| ≈ 0.78) — the carried history is exactly the in-place state. Setting norm_freq and calling reset() likewise mutate the same handle.

Consequences:

  • One handle = one stream. Don't share a handle across threads concurrently; give each stream its own.
  • Deterministic lifetime. close() frees the C resources when you say so, not when the GC happens to run.
  • The only value handed back is the output view (out[:n_out]); everything else lives in — and mutates — the handle.

Performance — zero-copy, zero steady-state allocation

The handle is the same C core as the DDC object; on a 4096-sample block all paths land within ~2 % (≈16 µs/block on this machine). The benefit is where the output goes and what gets allocated, not raw throughput:

Path per-call allocation output lands in
ddcr.execute(x, out), out reused none (steady state) a buffer you own
ddcr.execute(x, np.empty(...)) one output array a fresh array each call

So the caller-buffer model buys you:

  • Zero-copy into caller memory. Write results straight into a slice of a larger array, a memory-mapped region, or the next pipeline stage's input buffer — no copy to move the result into place afterward.
  • Multiple live outputs. Target a different out per call, so several outputs stay valid at once.
  • No per-call allocation in the steady state when you reuse one out — small here (the allocator recycles the 32 KiB block cheaply), but it removes allocator traffic and GC pressure entirely, which matters under many parallel streams or tight real-time budgets.

Parallelism — execute releases the GIL

The C kernel runs with the GIL released (Py_BEGIN_ALLOW_THREADS around the ddcr_execute call). It's safe precisely because of the one-handle-per-stream contract: the kernel touches only that stream's state and the caller's buffers — no Python objects, no shared mutable state. So a thread-per-shard worker — each thread owning its own handle and out buffer — scales across cores instead of serialising on the GIL:

Ddcr thread-per-shard scaling

threads speedup efficiency
1 1.00× 100 %
2 1.73× 86 %
4 3.16× 79 %
8 5.41× 68 %
12 7.41× 62 %
16 8.08× 51 %

(8192-sample blocks, representative run on a 20-core box; regenerate with make gallery. Before releasing the GIL the same test was flat at ~1× regardless of thread count.) Scaling is near-linear through a handful of cores and then memory-bandwidth bound — the kernel streams large buffers, so past ~8–12 threads more cores stop helping. The takeaway for sizing: one replica can use several cores via threads before you reach for process-per-shard, which changes your replica-count math.

This pairs directly with the streaming model above: shard by stream, give each worker thread a disjoint set of handles, and there is no shared state to lock — the parallelism is embarrassingly so.

In short: same speed, but you own the buffer and the lifetime — exactly what a zero-copy streaming pipeline wants.

Run it

python src/doppler/examples/ddc_fn_demo.py

Source: src/doppler/examples/ddc_fn_demo.py