Skip to content

One Cache Slot for a Whole Background Field

Cache footprint vs population, and one gain moving the whole field

Plan is fast because it caches every source of a scene separately: each render is a re-weighted sum of buffers that were synthesised once, so any source can be re-levelled, rotated or dropped without touching the DSP. The price of that separability is one full-length buffer per source.

For most scenes that is the right trade. For a scene dominated by emitters that never move it is the wrong one. A crowded uplink, a co-channel user population, an interference field — those sources exist to be present, not to be swept, and paying a separate cache buffer for each one buys an override nobody will ever use. At a realistic capture rate the bill is brutal: 400 users at 122.88 MHz over 10 ms is ~1.3 M samples each, about 4 GB of cache for a scene in which exactly one interferer actually varies.

Marking those sources background=True folds them into one pre-summed cache entry.

What you're seeing

The scene is a crowded uplink: a population of static QPSK users spread across the band, a wanted signal at DC carrying the SNR, and one strong interferer at +330 kHz.

Left — cache footprint. Bytes of signal cache against population size, the same scene built both ways. Per-source grows linearly — 200 users is 13.2 MB even at this toy 8192-sample ON-time. Folded is flat, because the entire population is always a single buffer, whatever its size: 67× smaller at 200 users, and the ratio keeps growing with the population. The inset quotes the workload the feature exists for.

Right — the field as one control. Welch PSD of three renders that differ by a single number. The background is the broad shelf; the wanted signal is the flat-topped block at DC, the interferer the one at +330 kHz. Trim the composite by 10 dB and the whole field drops by 10 dB — measured at 9.98 dB — while the wanted signal moves 0.06 dB. Disable it and the field vanishes, leaving exactly the two signals and the noise floor.

That is the part worth internalising: the fold does not merely save memory, it gives you a control you did not have before. The background is now one entry in gains / phases / enable, so scaling, rotating or removing the entire interference field is a single scalar — and re-rendering it is ~5× faster, because each render touches 3 buffers instead of 102.

Building it

import numpy as np

from doppler.wfm import Composer, Segment, prepare, qpsk

# The scene's parameters come from the tested example script itself, so this
# page and the figure above can never drift apart.
from doppler.examples.plan_background_demo import FS, NS, WANTED_SNR

The population is written first and flagged. That ordering is load-bearing, not stylistic — see the note below.

def uplink(n_users: int, *, background: bool = True) -> Composer:
    """A crowded uplink: `n_users` static users, a wanted signal, a jammer.

    The static population is written FIRST and flagged ``background=True``.
    That ordering is required, not stylistic: the composite sums from zero, so
    it reproduces a full compose bit-for-bit only when nothing precedes it.
    """
    field = [
        qpsk(
            seed=1000 + k,
            sps=8,
            pn_length=9,
            freq=-4.0e5 + 8.0e5 * k / max(n_users - 1, 1),
            level=-30.0,
            pulse="rrc",
            background=background,  # <-- the whole feature
        )
        for k in range(n_users)
    ]
    wanted = qpsk(
        seed=7, sps=8, pn_length=9, freq=0.0, snr=WANTED_SNR, pulse="rrc"
    )
    jammer = qpsk(
        seed=11, sps=8, pn_length=9, freq=3.3e5, level=-12.0, pulse="rrc"
    )
    return Composer(Segment.sum(*field, wanted, jammer, fs=FS, num_samples=NS))

Folding changes what the cache costs, not what it produces:

def cache_bytes(plan) -> int:
    """Signal-cache footprint: one cf32 buffer per slot, ON-time long."""
    return plan.n_sources * NS * 8


def footprint(n_users: int) -> tuple[int, int]:
    """Cache bytes for `n_users` background sources, folded vs per-source."""
    folded = prepare(uplink(n_users, background=True))
    per_source = prepare(uplink(n_users, background=False))

    # Folding is invisible to the output: the composite is summed in spec
    # order, from zero, over a prefix — exactly the partial sum a full
    # compose holds at that point — so the contract still holds to the bit.
    assert np.array_equal(folded.render(), per_source.render()), (
        "folding changed the rendered samples"
    )
    # ...and the population really did collapse to a single slot.
    assert folded.n_sources == 3, "expected background + wanted + jammer"
    assert per_source.n_sources == n_users + 2

    return cache_bytes(folded), cache_bytes(per_source)
folded, per_source = footprint(50)
assert folded == 3 * NS * 8  # background + wanted + jammer
assert per_source == 52 * NS * 8  # one buffer each

And the whole field answers to one override — index 0 is the composite, then the foreground sources in spec order:

def overrides(plan) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Three renders of one Plan that differ by a single number.

    Slot 0 is the whole background field: one entry in gains/phases/enable,
    whatever the population. Slots 1 and 2 are the wanted signal and the
    interferer, which keep their own controls.
    """
    base = plan.render()  # field at its own levels
    trimmed = plan.render(gains=[-10.0, 0.0, -12.0])  # field down 10 dB
    off = plan.render(enable=[False, True, True])  # field removed
    return base, trimmed, off
plan = prepare(uplink(100))
base, trimmed, off = overrides(plan)
assert plan.n_sources == 3  # 100 users, 1 slot

How it works

prepare() renders each background source once, multiplies it by its own 10**(level/20), and accumulates it into a single buffer whose base_gain is 1.0. From render()'s point of view nothing is special about that slot — it is just a cache entry that happens to be pre-summed — which is why the whole feature needs no changes in the render path, and why n_sources drops from population + 2 to 3.

Why the background must come first. A Composer sums its sources into a running accumulator in spec order, and float addition is not associative. The composite sums from zero, so it equals the composer's partial sum at that point only if nothing precedes it. A background source sitting behind a foreground one would still be nearly right — and would quietly cost the bit-exactness guarantee the whole Plan design rests on. prepare() rejects that ordering with a ValueError instead of silently degrading.

Why it is still fast to prepare. The sum has to run in spec order, so it cannot simply be fanned out one source per core. Instead a group of background sources is synthesised concurrently and then accumulated in order, and the ordered accumulation itself is split by sample — each worker owns a contiguous slice of the composite and applies the whole group to it in sequence. That is bit-identical to a serial fold and still uses every core. The group is capped by a memory budget, so a 400-source background never materialises 400 live buffers; and the slice size keeps the accumulator L2-resident, so it is touched once per group rather than once per source.

Gain semantics. For an ordinary source, gains[k] replaces its level — it is an absolute dBFS setting. For the composite it is a trim: members keep their relative levels and the whole mix scales. There is no bit-exact alternative (factoring a shared reference gain back out reintroduces a multiply that breaks the exactness proof), and a trim is the meaningful control for a field anyway.

Notes

A bundled segment — a lone source carrying its own real SNR — folds nothing. It has nothing to sum with, and its baked-in noise amplitude rides on the same base_gain the fold would overwrite, so the flag is a no-op there rather than a silent amplitude error.

See also

Reproduce

python -m doppler.examples.plan_background_demo plan_background_demo.png

Source: src/doppler/examples/plan_background_demo.py.