Benchmarks, every number#

Every point, interval, timing and command of the benchmark, by scenario and by machine. The same links simulated with comnum and with the Python libraries that offer them — komm, OptiCommPy (with and without numba) and Sionna — with the same parameters and the same number of samples: do the results agree (with the closed form when there is one, with each other otherwise), and how long does each library take? The comparisons page compares the outputs of single blocks value by value; this one runs whole links.

A feature a library does not offer is marked not available, with the reason: no substitute is written for it. The scripts import every library as a user would (the GPL-3.0 code of OptiCommPy is imported by the benchmark scripts only, never copied).

Rules#

  • Same work. Same parameters, same number of bits, vectors, frames or samples for every library; every library draws its own bits and noise with its own generator and independent seeds, from the same distributions, so the results agree statistically, not bit for bit (the input field of the split-step case and the LLRs of the case “same LLRs” are identical in every library).

  • Confidence intervals. 95 % intervals: Wilson’s for independent errors; for coded frames and MIMO vectors, whose errors come in bursts, the interval of the mean of the per-frame (per-vector) error counts. A result agrees when every point lies within four standard errors of the closed form (or of comnum, when there is no closed form; points without errors on both sides are skipped).

  • Timing. time.perf_counter; one warm-up run first, reported apart as first call (it contains the numba compilation, from an empty cache, and the lazy initialisations of PyTorch); then the median of five timed runs. The times are per unit of work (bit, vector, frame, propagation).

  • Threads. Every library runs in its own process. 1 thread: OMP_NUM_THREADS, OPENBLAS_NUM_THREADS, MKL_NUM_THREADS, VECLIB_MAXIMUM_THREADS, NUMBA_NUM_THREADS and RAYON_NUM_THREADS set to 1, torch.set_num_threads(1), threads=1 for comnum.run_spec and --threads 1 for the binary. All threads: the defaults of every library (every core; threads=0 for comnum). The functions of comnum are single-threaded; its Monte Carlo runner (run_spec, the binary) runs the trials in parallel.

  • Precision. What every library computes in is stated next to its times: comnum in float64 (float32 on request for the split-step), komm in float64, Sionna in float32 by default, OptiCommPy in float64 except its Gray constellations (complex64) and its LDPC decoder (float32 by default).

  • Sionna 2.x is built on PyTorch (not TensorFlow); it runs eagerly, under torch.inference_mode(), without torch.compile: on the CPU in the modes 1 thread and all threads (config.device = "cpu": Sionna’s own default is the first CUDA device as soon as it sees one), and on the GPU in the mode GPU (--threads gpu: config.device = "cuda:0", the device every block of Sionna creates its tensors on, the result brought back to the host inside the timed call). OptiCommPy has a CuPy copy of its split-step (optic.models.modelsGPU.ssfm), run in the mode GPU of the split-step scenario, in complex128 and complex64, the transfers to and from the device inside the timed call; its other blocks are CPU only. comnum and komm have no GPU path: in the GPU column they do not run, and the comparison is a library on the device against comnum on the CPU of the same machine.

  • OptiCommPy without numba means NUMBA_DISABLE_JIT=1: numba’s own switch, which runs every @njit function of OptiCommPy as plain Python (OptiCommPy has no option of its own). Its results are those of the compiled code; it is timed only.

Limits of this comparison#

  • The CPU of a laptop, and a GPU. Sionna is designed for GPUs, large batches, learning and automatic differentiation. The first set of results runs it on the CPU of the same machine as comnum, eagerly, without torch.compile: its home ground is elsewhere. A second set, when present below, runs the same scripts on a machine with a GPU, Sionna (and OptiCommPy’s split-step) on the device and comnum on that machine’s CPU; comnum has no GPU path, so that comparison is one of a CPU library against an accelerator, which is what a user choosing between the two faces.

  • Python libraries only. komm, OptiCommPy and Sionna are NumPy, numba and PyTorch code. Specialised C++ libraries (AFF3CT, SIMD decoders) are not timed here: the decoders of comnum are timed against those of AFF3CT, one thread, in comparisons.

  • Precision. comnum computes in float64, Sionna in float32 by default: the faster library is not always doing less work, but the two are not strictly the same computation.

  • One machine, cases chosen by comnum. The scenarios are the classical links that several libraries offer; other machines, sizes and cases may rank the libraries differently. The scripts are published to be rerun.

  • What this benchmark changed in comnum. Over flat Rayleigh fading the first run found the comnum functions 3.4 times slower than Sionna on one thread (293 against 87 ns per bit for BPSK): the linear detector built a dozen 1 × 1 matrices for every symbol. It now has a single-stream path (the same filter, as scalars), with the same error counts at every point, and the binding passes the vectors without a copy.

  • Why Sionna is fast on a CPU. It processes the whole batch in one call per operation: the cost of Python is paid once per operation, not once per symbol, and the kernels of PyTorch are vectorised C++ working in float32.

Libraries#

Name on this page

What runs

Version

comnum (functions)

comnum from Python: the functions of the comnum package, called block by block

0.2.0

comnum.run_spec

comnum from Python: comnum.run_spec, the parallel Monte Carlo runner of Rust

0.2.0

comnum binary (Rust)

comnum from Rust: the comnum command-line binary on the same specification

0.2.0

komm

komm (NumPy)

0.37.0

OptiCommPy (numba)

OptiCommPy with numba (its @njit kernels compiled)

0.11.0

OptiCommPy (no numba)

OptiCommPy without numba (NUMBA_DISABLE_JIT=1: the same @njit kernels run as plain Python)

0.11.0

Sionna

Sionna PHY (PyTorch, CPU, eager mode)

2.2.0

Machine and versions#

CPU

Apple M6

Cores

12 logical (2 super, 4 performance, 6 efficiency)

Memory

24.0 GB

System

macOS 27.0

Python

3.12.15

Rust

rustc 1.99.1 (58cf8f9c3 2026-10-13)

comnum commit

f20118f9160d

Generated

2026-10-30T08:05:23+00:00

comnum

0.2.0

komm

0.37.0

OptiCommPy

0.11.0

sionna

2.2.0

torch

2.15.0

numba

0.68.1

numpy

2.5.4

scipy

1.18.1

Reproducing#

On a quiet machine (the timings are wrong on a loaded one), from the root of the repository:

uv venv --python 3.12 benchmarks/libraries/.venv
source benchmarks/libraries/.venv/bin/activate
uv pip install -r benchmarks/libraries/requirements.txt
maturin develop --release                    # comnum, from this checkout
cd benchmarks/libraries
python run_all.py                            # builds target/release/comnum first
python ../../tools/benchmarks_page.py

python run_all.py --quick runs everything at small sizes in about two minutes, to check that it works (its timings mean nothing). The versions are pinned in requirements.txt; see benchmarks/libraries/README.md.

Uncoded QPSK and 16-QAM over AWGN#

Scenario 1: uncoded Gray QPSK and 16-QAM over AWGN, BER against the exact closed form.

Parameters:

  • Eb/N0 (dB): 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10

  • bits per point: 10^7 (quick: 2·10^5), in blocks of 10^6 (quick: 10^5)

  • timing: one block of 10^6 bits (quick: 2·10^5) at Eb/N0 = 6 dB

  • theory: exact BER of Gray square QAM, Cho & Yoon, IEEE Trans. Commun. 50(7), 2002

Script: benchmarks/libraries/s1_awgn.py.

The script
"""Scenario 1: uncoded Gray QPSK and 16-QAM over AWGN, BER against the exact closed form.

Every library draws its own bits and noise, maps with its own Gray mapper, adds the
noise of its own channel for the Eb/N0 of the point and decides by minimum distance
(Sionna: hard decisions of max-log LLRs, the same decisions). The BER is counted on
the same number of bits per point for every library, in blocks of the same size.
Timing: one block of ``TIMING_BITS`` bits at Eb/N0 = 6 dB (bits, mapping, noise,
decisions, error count), median of 5 runs after a warm-up run.

    python s1_awgn.py --lib comnum --threads 1 [--quick] --out results/_work/s1.json
"""

from __future__ import annotations

import math
import time

import common
import numpy as np
import theory

SCENARIO = "s1_awgn"
TITLE = "Uncoded QPSK and 16-QAM over AWGN"
ORDERS = {"qpsk": 4, "16qam": 16}
ALL = {lib: True for lib in common.LIBRARIES}
CASES = {"qpsk": ALL, "16qam": ALL}
EBN0 = [float(e) for e in range(11)]
TIMING_EBN0 = 6.0
TIMING_TRIALS = 40  # trials of the specification runner per timing run


def sizes(quick: bool) -> dict:
    if quick:
        return {"bits_per_point": 200_000, "block": 100_000, "timing_bits": 200_000, "trials": 10}
    return {
        "bits_per_point": 10_000_000,
        "block": 1_000_000,
        "timing_bits": 1_000_000,
        "trials": 100,
    }


PARAMETERS = {
    "ebn0_db": EBN0,
    "bits_per_point": "10^7 (quick: 2·10^5), in blocks of 10^6 (quick: 10^5)",
    "timing": "one block of 10^6 bits (quick: 2·10^5) at Eb/N0 = 6 dB",
    "theory": "exact BER of Gray square QAM, Cho & Yoon, IEEE Trans. Commun. 50(7), 2002",
}


# ---------------------------------------------------------------------------- chains
# chain(n_bits, ebn0_db) -> bit errors, for one library and one constellation


def chain_comnum(m: int, seed: int):
    import comnum

    c = comnum.Constellation("qam", m).normalized()
    k = int(math.log2(m))
    rng = comnum.Rng(seed)

    def run(n: int, ebn0: float) -> int:
        b = comnum.bits(n, rng)
        y = comnum.awgn_ebn0(c.map_bits(b), ebn0, k, rng)
        return int(np.count_nonzero(c.demap_bits(y) != b))

    return run, "float64"


def chain_komm(m: int, seed: int):
    import komm

    const = komm.QAMConstellation(m)
    lab = komm.ReflectedRectangularLabeling(int(math.log2(m)))
    k = int(math.log2(m))
    es = const.mean_energy()
    rng = np.random.default_rng(seed)

    def run(n: int, ebn0: float) -> int:
        b = rng.integers(0, 2, n)
        x = const.indices_to_symbols(lab.bits_to_indices(b))
        n0 = es / (k * 10 ** (ebn0 / 10))
        y = komm.GaussianChannel(n0, rng=rng).transmit(x)
        bh = lab.indices_to_bits(const.closest_indices(y))
        return int(np.count_nonzero(bh.ravel() != b))

    return run, "float64"


def chain_opticommpy(m: int, seed: int):
    from optic.comm.modulation import demodulateGray, modulateGray
    from optic.models.channels import awgn
    from optic.utils import parameters

    k = int(math.log2(m))
    rng = np.random.default_rng(seed)
    state = {"block": 0}

    def run(n: int, ebn0: float) -> int:
        b = rng.integers(0, 2, n)
        x = modulateGray(b, m, "qam")
        p = parameters()
        p.snr = ebn0 + 10 * math.log10(k)  # Es/N0 (Fs = B = 1)
        p.seed = seed * 1_000_003 + state["block"]  # awgn re-seeds its generator per call
        state["block"] += 1
        y = awgn(x, p)
        bh = demodulateGray(y, m, "qam")
        return int(np.count_nonzero(bh != b))

    return run, "complex64 symbols (its Gray constellation), float64 noise"


def chain_sionna(m: int, seed: int):
    import torch
    from sionna.phy import config
    from sionna.phy.channel import AWGN
    from sionna.phy.mapping import BinarySource, Demapper, Mapper
    from sionna.phy.utils import ebnodb2no

    config.seed = seed
    k = int(math.log2(m))
    source, mapper, channel = BinarySource(), Mapper("qam", k), AWGN()
    demapper = Demapper("maxlog", "qam", k, hard_out=True)

    def run(n: int, ebn0: float) -> int:
        with torch.inference_mode():
            b = source([1, n])
            no = ebnodb2no(ebn0, k, 1.0)
            y = channel(mapper(b), no)
            bh = demapper(y, no)
            return int((bh != b).sum())

    return run, "float32 (config.precision = 'single')"


CHAINS = {
    "comnum": chain_comnum,
    "komm": chain_komm,
    "opticommpy": chain_opticommpy,
    "opticommpy-nojit": chain_opticommpy,
    "sionna": chain_sionna,
}


def run_chains(args, todo: list[str]) -> list[dict]:
    if args.lib == "sionna":
        threading = common.setup_torch(args.threads)
    else:
        threading = {}
    sz = sizes(args.quick)
    out = []
    for case in todo:
        m = ORDERS[case]
        run, precision = CHAINS[args.lib](m, args.seed)
        res = {"case": case, "precision": precision, **threading}
        n = sz["timing_bits"]
        if args.lib == "opticommpy-nojit":
            n = min(n, 100_000)  # plain-Python kernels: a smaller block, the time is per bit
        t0 = time.perf_counter()
        run(n, TIMING_EBN0)  # the first call: JIT compilation (numba), lazy initialisation
        res["first_call_s"] = time.perf_counter() - t0
        if not args.timing_only:
            pts = []
            t0 = time.perf_counter()
            for e in EBN0:
                errors = 0
                for _ in range(sz["bits_per_point"] // sz["block"]):
                    errors += run(sz["block"], e)
                pts.append(
                    common.rate_point(
                        errors, sz["bits_per_point"], float(theory.ber_qam_awgn(m, e)), ebn0_db=e
                    )
                )
            res["points"] = pts
            res["sweep_s"] = time.perf_counter() - t0
        last = {}
        res["timing"] = common.time_it(
            lambda n=n, run=run, last=last: last.__setitem__("e", run(n, TIMING_EBN0))
        )
        res["timing"].update(units=n, unit="bit", workload=f"{n} bits at Eb/N0 = {TIMING_EBN0} dB")
        res["timing_ber"] = last["e"] / n
        out.append(res)
    return out


# ------------------------------------------------------------- specification runner


def spec(m: int, ebn0: list[float], bits: int, trials: int, seed: int) -> dict:
    k = int(math.log2(m))
    return {
        "name": f"bench_awgn_{m}qam",
        "seed": seed,
        "batch": trials,
        "grid": {"ebn0_db": ebn0},
        "link": [
            {
                "type": "source",
                "constellation": {"kind": "qam", "m": m},
                "n_symbols": bits // (k * trials),
            },
            {"type": "awgn_ebn0", "ebn0_db": "$ebn0_db"},
        ],
        "metrics": {"report": ["ber"], "scale": "none"},
        "stop": {"min_trials": trials, "max_trials": trials},
    }


def spec_points(results: list[dict], m: int) -> list[dict]:
    pts = []
    for r in results:
        e = float(r["condition"]["ebn0_db"])
        ber = r["metrics"]["ber"]
        errors = ber["count"] if "count" in ber else ber["errors"]  # run_spec / the binary's report
        pts.append(
            common.rate_point(errors, ber["total"], float(theory.ber_qam_awgn(m, e)), ebn0_db=e)
        )
    return pts


def run_spec_lib(args, todo: list[str], *, rust: bool = False) -> list[dict]:
    """``comnum-spec`` (``comnum.run_spec``), or with ``rust`` ``comnum-rust`` (the binary)."""
    sz = sizes(args.quick)
    n = sz["timing_bits"]
    workload = f"{n} bits at Eb/N0 = {TIMING_EBN0} dB, {TIMING_TRIALS} trials"
    if rust:
        workload += " (process wall time, start-up included)"
    out = []
    for case in todo:
        m = ORDERS[case]
        out.append(
            common.spec_case(
                args,
                {"case": case, "precision": "float64"},
                spec(m, EBN0, sz["bits_per_point"], sz["trials"], args.seed),
                spec(m, [TIMING_EBN0], n, TIMING_TRIALS, args.seed + 1),
                lambda rows, m=m: spec_points(rows, m),
                rust=rust,
                units=n,
                unit="bit",
                workload=workload,
            )
        )
    return out


def run_rust_lib(args, todo: list[str]) -> list[dict]:
    return run_spec_lib(args, todo, rust=True)


RUNNERS = {
    **{lib: run_chains for lib in CHAINS},
    "comnum-spec": run_spec_lib,
    "comnum-rust": run_rust_lib,
}

if __name__ == "__main__":
    common.main(SCENARIO, CASES, RUNNERS)

Support

Case

comnum (functions)

comnum.run_spec

comnum binary (Rust)

komm

OptiCommPy (numba)

OptiCommPy (no numba)

Sionna

QPSK

yes

yes

yes

yes

yes

yes

yes

16-QAM

yes

yes

yes

yes

yes

yes

yes

QPSK: error rates

Library

Precision

Against

max abs(z), BER

Agrees

BER interval

comnum (functions)

float64

theory

1.90

yes

binomial

comnum.run_spec

float64

theory

2.53

yes

binomial

comnum binary (Rust)

float64

theory

2.53

yes

binomial

komm

float64

theory

2.37

yes

binomial

OptiCommPy (numba)

complex64 symbols (its Gray constellation), float64 noise

theory

3.31

yes

binomial

Sionna

float32 (config.precision = ‘single’)

theory

2.13

yes

binomial

BER and its 95 % interval, every point (10,000,000 bits per point)

Eb/N0 (dB)

theory

comnum (functions)

comnum.run_spec

comnum binary (Rust)

komm

OptiCommPy (numba)

Sionna

0

7.86e-2

7.86e-2 [7.84e-2, 7.88e-2]

7.87e-2 [7.85e-2, 7.89e-2]

7.87e-2 [7.85e-2, 7.89e-2]

7.86e-2 [7.85e-2, 7.88e-2]

7.86e-2 [7.84e-2, 7.88e-2]

7.86e-2 [7.84e-2, 7.88e-2]

1

5.63e-2

5.63e-2 [5.62e-2, 5.65e-2]

5.63e-2 [5.61e-2, 5.64e-2]

5.63e-2 [5.61e-2, 5.64e-2]

5.64e-2 [5.63e-2, 5.65e-2]

5.63e-2 [5.61e-2, 5.64e-2]

5.61e-2 [5.60e-2, 5.63e-2]

2

3.75e-2

3.75e-2 [3.74e-2, 3.76e-2]

3.74e-2 [3.73e-2, 3.76e-2]

3.74e-2 [3.73e-2, 3.76e-2]

3.75e-2 [3.74e-2, 3.77e-2]

3.75e-2 [3.73e-2, 3.76e-2]

3.76e-2 [3.75e-2, 3.77e-2]

3

2.29e-2

2.28e-2 [2.27e-2, 2.29e-2]

2.30e-2 [2.29e-2, 2.31e-2]

2.30e-2 [2.29e-2, 2.31e-2]

2.29e-2 [2.28e-2, 2.29e-2]

2.29e-2 [2.28e-2, 2.30e-2]

2.29e-2 [2.28e-2, 2.30e-2]

4

1.25e-2

1.25e-2 [1.25e-2, 1.26e-2]

1.25e-2 [1.24e-2, 1.26e-2]

1.25e-2 [1.24e-2, 1.26e-2]

1.25e-2 [1.24e-2, 1.26e-2]

1.25e-2 [1.24e-2, 1.26e-2]

1.25e-2 [1.25e-2, 1.26e-2]

5

5.95e-3

5.95e-3 [5.90e-3, 6.00e-3]

5.96e-3 [5.92e-3, 6.01e-3]

5.96e-3 [5.92e-3, 6.01e-3]

5.98e-3 [5.93e-3, 6.03e-3]

5.93e-3 [5.89e-3, 5.98e-3]

5.93e-3 [5.88e-3, 5.98e-3]

6

2.39e-3

2.39e-3 [2.36e-3, 2.42e-3]

2.41e-3 [2.38e-3, 2.44e-3]

2.41e-3 [2.38e-3, 2.44e-3]

2.36e-3 [2.33e-3, 2.39e-3]

2.43e-3 [2.40e-3, 2.46e-3]

2.39e-3 [2.36e-3, 2.42e-3]

7

7.73e-4

7.56e-4 [7.39e-4, 7.73e-4]

7.77e-4 [7.60e-4, 7.95e-4]

7.77e-4 [7.60e-4, 7.95e-4]

7.64e-4 [7.47e-4, 7.81e-4]

8.02e-4 [7.84e-4, 8.20e-4]

7.88e-4 [7.70e-4, 8.05e-4]

8

1.91e-4

1.84e-4 [1.76e-4, 1.92e-4]

1.93e-4 [1.84e-4, 2.01e-4]

1.93e-4 [1.84e-4, 2.01e-4]

1.93e-4 [1.84e-4, 2.01e-4]

1.90e-4 [1.82e-4, 1.99e-4]

1.82e-4 [1.73e-4, 1.90e-4]

9

3.36e-5

3.21e-5 [2.88e-5, 3.58e-5]

3.41e-5 [3.07e-5, 3.79e-5]

3.41e-5 [3.07e-5, 3.79e-5]

3.36e-5 [3.02e-5, 3.74e-5]

3.42e-5 [3.08e-5, 3.80e-5]

3.57e-5 [3.22e-5, 3.96e-5]

10

3.87e-6

4.00e-6 [2.94e-6, 5.45e-6]

3.20e-6 [2.27e-6, 4.52e-6]

3.20e-6 [2.27e-6, 4.52e-6]

2.40e-6 [1.61e-6, 3.57e-6]

4.10e-6 [3.02e-6, 5.56e-6]

3.70e-6 [2.68e-6, 5.10e-6]

16-QAM: error rates

Library

Precision

Against

max abs(z), BER

Agrees

BER interval

comnum (functions)

float64

theory

2.15

yes

binomial

comnum.run_spec

float64

theory

1.64

yes

binomial

comnum binary (Rust)

float64

theory

1.64

yes

binomial

komm

float64

theory

2.08

yes

binomial

OptiCommPy (numba)

complex64 symbols (its Gray constellation), float64 noise

theory

1.42

yes

binomial

Sionna

float32 (config.precision = ‘single’)

theory

2.41

yes

binomial

BER and its 95 % interval, every point (10,000,000 bits per point)

Eb/N0 (dB)

theory

comnum (functions)

comnum.run_spec

comnum binary (Rust)

komm

OptiCommPy (numba)

Sionna

0

1.41e-1

1.41e-1 [1.41e-1, 1.41e-1]

1.41e-1 [1.41e-1, 1.41e-1]

1.41e-1 [1.41e-1, 1.41e-1]

1.41e-1 [1.41e-1, 1.41e-1]

1.41e-1 [1.41e-1, 1.41e-1]

1.41e-1 [1.41e-1, 1.41e-1]

1

1.19e-1

1.19e-1 [1.19e-1, 1.19e-1]

1.19e-1 [1.19e-1, 1.19e-1]

1.19e-1 [1.19e-1, 1.19e-1]

1.19e-1 [1.19e-1, 1.19e-1]

1.19e-1 [1.19e-1, 1.19e-1]

1.19e-1 [1.19e-1, 1.19e-1]

2

9.77e-2

9.78e-2 [9.76e-2, 9.80e-2]

9.77e-2 [9.75e-2, 9.79e-2]

9.77e-2 [9.75e-2, 9.79e-2]

9.77e-2 [9.75e-2, 9.79e-2]

9.78e-2 [9.76e-2, 9.80e-2]

9.75e-2 [9.73e-2, 9.77e-2]

3

7.75e-2

7.75e-2 [7.74e-2, 7.77e-2]

7.74e-2 [7.73e-2, 7.76e-2]

7.74e-2 [7.73e-2, 7.76e-2]

7.76e-2 [7.74e-2, 7.77e-2]

7.75e-2 [7.73e-2, 7.76e-2]

7.74e-2 [7.73e-2, 7.76e-2]

4

5.86e-2

5.87e-2 [5.85e-2, 5.88e-2]

5.86e-2 [5.84e-2, 5.87e-2]

5.86e-2 [5.84e-2, 5.87e-2]

5.86e-2 [5.85e-2, 5.88e-2]

5.85e-2 [5.84e-2, 5.87e-2]

5.85e-2 [5.84e-2, 5.87e-2]

5

4.19e-2

4.18e-2 [4.17e-2, 4.19e-2]

4.19e-2 [4.17e-2, 4.20e-2]

4.19e-2 [4.17e-2, 4.20e-2]

4.19e-2 [4.18e-2, 4.20e-2]

4.20e-2 [4.19e-2, 4.21e-2]

4.20e-2 [4.19e-2, 4.21e-2]

6

2.79e-2

2.79e-2 [2.78e-2, 2.80e-2]

2.79e-2 [2.78e-2, 2.80e-2]

2.79e-2 [2.78e-2, 2.80e-2]

2.79e-2 [2.78e-2, 2.80e-2]

2.78e-2 [2.77e-2, 2.79e-2]

2.79e-2 [2.78e-2, 2.81e-2]

7

1.70e-2

1.70e-2 [1.69e-2, 1.71e-2]

1.70e-2 [1.69e-2, 1.71e-2]

1.70e-2 [1.69e-2, 1.71e-2]

1.70e-2 [1.69e-2, 1.70e-2]

1.70e-2 [1.69e-2, 1.70e-2]

1.70e-2 [1.69e-2, 1.71e-2]

8

9.25e-3

9.24e-3 [9.18e-3, 9.30e-3]

9.24e-3 [9.18e-3, 9.30e-3]

9.24e-3 [9.18e-3, 9.30e-3]

9.31e-3 [9.25e-3, 9.37e-3]

9.22e-3 [9.16e-3, 9.28e-3]

9.24e-3 [9.19e-3, 9.30e-3]

9

4.39e-3

4.35e-3 [4.30e-3, 4.39e-3]

4.42e-3 [4.38e-3, 4.47e-3]

4.42e-3 [4.38e-3, 4.47e-3]

4.39e-3 [4.35e-3, 4.43e-3]

4.40e-3 [4.36e-3, 4.44e-3]

4.37e-3 [4.33e-3, 4.41e-3]

10

1.75e-3

1.75e-3 [1.73e-3, 1.78e-3]

1.78e-3 [1.75e-3, 1.80e-3]

1.78e-3 [1.75e-3, 1.80e-3]

1.73e-3 [1.71e-3, 1.76e-3]

1.76e-3 [1.73e-3, 1.79e-3]

1.76e-3 [1.73e-3, 1.78e-3]

QPSK: time

Median of 5 runs, ns per bit; workload: 1000000 bits at Eb/N0 = 6.0 dB. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

Relative

First call

comnum (functions)

float64

7.58

7.56

1.00

0.0198 s

comnum.run_spec

float64

9.60

1.71

1.27

0.00968 s

comnum binary (Rust)

float64

12.9

4.57

1.70

0.0136 s

komm

float64

249

252

32.9

0.253 s

OptiCommPy (numba)

complex64 symbols (its Gray constellation), float64 noise

24.2

15.1

3.19

0.921 s

OptiCommPy (no numba)

complex64 symbols (its Gray constellation), float64 noise

373

373

49.2

0.0376 s

Sionna

float32 (config.precision = ‘single’)

25.2

16.4

3.33

0.0377 s

16-QAM: time

Median of 5 runs, ns per bit; workload: 1000000 bits at Eb/N0 = 6.0 dB. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

Relative

First call

comnum (functions)

float64

4.44

4.39

1.00

0.00451 s

comnum.run_spec

float64

5.41

1.11

1.22

0.00539 s

comnum binary (Rust)

float64

8.14

3.76

1.83

0.0086 s

komm

float64

136

139

30.6

0.137 s

OptiCommPy (numba)

complex64 symbols (its Gray constellation), float64 noise

20.9

9.85

4.70

0.0213 s

OptiCommPy (no numba)

complex64 symbols (its Gray constellation), float64 noise

192

191

43.2

0.0192 s

Sionna

float32 (config.precision = ‘single’)

32.9

19.1

7.41

0.0362 s

The commands of every run
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s1_awgn.py --lib comnum --threads 1 --work results/_work --out results/_work/s1_awgn_comnum_1.json
 python s1_awgn.py --lib comnum --threads all --work results/_work --out results/_work/s1_awgn_comnum_all.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s1_awgn.py --lib comnum-spec --threads 1 --work results/_work --out results/_work/s1_awgn_comnum-spec_1.json
 python s1_awgn.py --lib comnum-spec --threads all --work results/_work --out results/_work/s1_awgn_comnum-spec_all.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s1_awgn.py --lib comnum-rust --threads 1 --work results/_work --out results/_work/s1_awgn_comnum-rust_1.json
 python s1_awgn.py --lib comnum-rust --threads all --work results/_work --out results/_work/s1_awgn_comnum-rust_all.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s1_awgn.py --lib komm --threads 1 --work results/_work --out results/_work/s1_awgn_komm_1.json
 python s1_awgn.py --lib komm --threads all --work results/_work --out results/_work/s1_awgn_komm_all.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s1_awgn.py --lib opticommpy --threads 1 --work results/_work --out results/_work/s1_awgn_opticommpy_1.json
 python s1_awgn.py --lib opticommpy --threads all --work results/_work --out results/_work/s1_awgn_opticommpy_all.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 NUMBA_DISABLE_JIT=1 python s1_awgn.py --lib opticommpy-nojit --threads 1 --work results/_work --out results/_work/s1_awgn_opticommpy-nojit_1.json --timing-only
NUMBA_DISABLE_JIT=1 python s1_awgn.py --lib opticommpy-nojit --threads all --work results/_work --out results/_work/s1_awgn_opticommpy-nojit_all.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s1_awgn.py --lib sionna --threads 1 --work results/_work --out results/_work/s1_awgn_sionna_1.json
 python s1_awgn.py --lib sionna --threads all --work results/_work --out results/_work/s1_awgn_sionna_all.json --timing-only

BPSK and QPSK over flat Rayleigh fading#

Scenario 2: BPSK and Gray QPSK over flat Rayleigh fading (one coefficient per symbol, perfectly known at the receiver), BER against the closed form.

Parameters:

  • Eb/N0 (dB): 0, 5, 10, 15, 20, 25, 30

  • bits per point: 10^7 (quick: 2·10^5), in blocks of 10^6 (quick: 10^5)

  • channel: y = h x + w, h ~ CN(0, 1) independent for every symbol, known at the receiver

  • timing: one block of 10^6 bits (quick: 2·10^5) at Eb/N0 = 10 dB

  • theory: BPSK: (1 - sqrt(G / (1 + G))) / 2; Gray QPSK: the same per bit (Proakis & Salehi, 5th ed., Sec. 13.3)

Script: benchmarks/libraries/s2_rayleigh.py.

The script
"""Scenario 2: BPSK and Gray QPSK over flat Rayleigh fading (one coefficient per
symbol, perfectly known at the receiver), BER against the closed form.

Every library draws its own bits, channel coefficients h ~ CN(0, 1) and noise, and
detects with its own coherent detector given h (zero forcing over the 1 x 1 channel:
comnum.mimo_detect, Sionna's LinearDetector, the mimo_rx block of the specification).
komm and OptiCommPy have no fading channel. Timing: one block of ``TIMING_BITS`` bits
at Eb/N0 = 10 dB, median of 5 runs after a warm-up run.

    python s2_rayleigh.py --lib sionna --threads 1 [--quick] --out results/_work/s2.json
"""

from __future__ import annotations

import math
import time

import common
import numpy as np
import theory

SCENARIO = "s2_rayleigh"
TITLE = "BPSK and QPSK over flat Rayleigh fading"
ORDERS = {"bpsk": 2, "qpsk": 4}
SUPPORT = {
    "comnum": True,
    "comnum-spec": True,
    "komm": "no fading channel",
    "opticommpy": "no fading channel",
    "opticommpy-nojit": "no fading channel",
    "sionna": True,
}
CASES = {"bpsk": SUPPORT, "qpsk": SUPPORT}
EBN0 = [0.0, 5.0, 10.0, 15.0, 20.0, 25.0, 30.0]
TIMING_EBN0 = 10.0
TIMING_TRIALS = 40

PARAMETERS = {
    "ebn0_db": EBN0,
    "bits_per_point": "10^7 (quick: 2·10^5), in blocks of 10^6 (quick: 10^5)",
    "channel": "y = h x + w, h ~ CN(0, 1) independent for every symbol, known at the receiver",
    "timing": "one block of 10^6 bits (quick: 2·10^5) at Eb/N0 = 10 dB",
    "theory": "BPSK: (1 - sqrt(G / (1 + G))) / 2; Gray QPSK: the same per bit (Proakis & Salehi, 5th ed., Sec. 13.3)",
}


def sizes(quick: bool) -> dict:
    if quick:
        return {"bits_per_point": 200_000, "block": 100_000, "timing_bits": 200_000, "trials": 10}
    return {
        "bits_per_point": 10_000_000,
        "block": 1_000_000,
        "timing_bits": 1_000_000,
        "trials": 100,
    }


def chain_comnum(m: int, seed: int):
    import comnum

    c = comnum.Constellation("psk" if m == 2 else "qam", m).normalized()
    k = int(math.log2(m))
    rng = comnum.Rng(seed)

    def run(n: int, ebn0: float) -> int:
        b = comnum.bits(n, rng)
        x = c.map_bits(b)
        h = comnum.mimo_channels(x.size, 1, 1, rng)
        n0 = 1.0 / (k * 10 ** (ebn0 / 10))
        y = h[:, 0, 0] * x + rng.complex_noise(x.size, n0)
        labels = comnum.mimo_detect(h, y[None, :], c, n0, "zf")
        bh = comnum.unpack_symbols(labels.ravel(), k)
        return int(np.count_nonzero(bh != b))

    return run, "float64"


def chain_sionna(m: int, seed: int):
    import torch
    from sionna.phy import config
    from sionna.phy.channel import FlatFadingChannel
    from sionna.phy.mapping import BinarySource, Mapper
    from sionna.phy.mimo import LinearDetector
    from sionna.phy.utils import ebnodb2no

    config.seed = seed
    k = int(math.log2(m))
    kind = "pam" if m == 2 else "qam"
    source, mapper = BinarySource(), Mapper(kind, k)
    channel = FlatFadingChannel(1, 1, return_channel=True)
    detector = LinearDetector(
        "zf", "bit", "maxlog", constellation_type=kind, num_bits_per_symbol=k, hard_out=True
    )

    def run(n: int, ebn0: float) -> int:
        with torch.inference_mode():
            b = source([n // k, k])
            x = mapper(b)  # [n/k, 1]
            no = ebnodb2no(ebn0, k, 1.0)
            y, h = channel(x, no)
            s = torch.full((n // k, 1, 1), float(no), dtype=y.dtype)
            bh = detector(y, h, s)  # [n/k, 1, k]
            return int((bh.reshape(b.shape) != b).sum())

    return run, "float32 (config.precision = 'single')"


CHAINS = {"comnum": chain_comnum, "sionna": chain_sionna}


def run_chains(args, todo: list[str]) -> list[dict]:
    threading = common.setup_torch(args.threads) if args.lib == "sionna" else {}
    sz = sizes(args.quick)
    out = []
    for case in todo:
        m = ORDERS[case]
        run, precision = CHAINS[args.lib](m, args.seed)
        res = {"case": case, "precision": precision, **threading}
        n = sz["timing_bits"]
        t0 = time.perf_counter()
        run(n, TIMING_EBN0)
        res["first_call_s"] = time.perf_counter() - t0
        if not args.timing_only:
            pts = []
            t0 = time.perf_counter()
            for e in EBN0:
                errors = sum(
                    run(sz["block"], e) for _ in range(sz["bits_per_point"] // sz["block"])
                )
                pts.append(
                    common.rate_point(
                        errors,
                        sz["bits_per_point"],
                        float(theory.ber_qam_rayleigh(m, e)),
                        ebn0_db=e,
                    )
                )
            res["points"] = pts
            res["sweep_s"] = time.perf_counter() - t0
        res["timing"] = common.time_it(lambda n=n, run=run: run(n, TIMING_EBN0))
        res["timing"].update(units=n, unit="bit", workload=f"{n} bits at Eb/N0 = {TIMING_EBN0} dB")
        out.append(res)
    return out


def spec(m: int, ebn0: list[float], bits: int, trials: int, seed: int) -> dict:
    k = int(math.log2(m))
    return {
        "name": f"bench_rayleigh_{m}",
        "seed": seed,
        "batch": trials,
        "grid": {"ebn0_db": ebn0},
        "link": [
            {
                "type": "source",
                "constellation": {"kind": "psk" if m == 2 else "qam", "m": m},
                "n_symbols": bits // (k * trials),
            },
            {"type": "rayleigh", "n_rx": 1, "coherence_samples": 1},
            {"type": "awgn_ebn0", "ebn0_db": "$ebn0_db"},
            {"type": "mimo_rx", "detector": "zf"},
        ],
        "metrics": {"report": ["ber"], "scale": "none"},
        "stop": {"min_trials": trials, "max_trials": trials},
    }


def spec_points(rows: list[dict], m: int) -> list[dict]:
    pts = []
    for row in rows:
        e = float(row["condition"]["ebn0_db"])
        ber = row["metrics"]["ber"]
        pts.append(
            common.rate_point(
                ber["count"], ber["total"], float(theory.ber_qam_rayleigh(m, e)), ebn0_db=e
            )
        )
    return pts


def run_spec_lib(args, todo: list[str]) -> list[dict]:
    sz = sizes(args.quick)
    n = sz["timing_bits"]
    out = []
    for case in todo:
        m = ORDERS[case]
        out.append(
            common.spec_case(
                args,
                {"case": case, "precision": "float64"},
                spec(m, EBN0, sz["bits_per_point"], sz["trials"], args.seed),
                spec(m, [TIMING_EBN0], n, TIMING_TRIALS, args.seed + 1),
                lambda rows, m=m: spec_points(rows, m),
                units=n,
                unit="bit",
                workload=f"{n} bits at Eb/N0 = {TIMING_EBN0} dB, {TIMING_TRIALS} trials",
            )
        )
    return out


RUNNERS = {"comnum": run_chains, "sionna": run_chains, "comnum-spec": run_spec_lib}

if __name__ == "__main__":
    common.main(SCENARIO, CASES, RUNNERS)

Support

Case

comnum (functions)

comnum.run_spec

komm

OptiCommPy (numba)

OptiCommPy (no numba)

Sionna

BPSK

yes

yes

not available: no fading channel

not available: no fading channel

not available: no fading channel

yes

QPSK

yes

yes

not available: no fading channel

not available: no fading channel

not available: no fading channel

yes

BPSK: error rates

Library

Precision

Against

max abs(z), BER

Agrees

BER interval

comnum (functions)

float64

theory

2.22

yes

binomial

comnum.run_spec

float64

theory

1.78

yes

binomial

Sionna

float32 (config.precision = ‘single’)

theory

2.35

yes

binomial

BER and its 95 % interval, every point (10,000,000 bits per point)

Eb/N0 (dB)

theory

comnum (functions)

comnum.run_spec

Sionna

0

1.46e-1

1.46e-1 [1.46e-1, 1.47e-1]

1.46e-1 [1.46e-1, 1.47e-1]

1.47e-1 [1.46e-1, 1.47e-1]

5

6.42e-2

6.43e-2 [6.41e-2, 6.44e-2]

6.42e-2 [6.41e-2, 6.44e-2]

6.42e-2 [6.40e-2, 6.43e-2]

10

2.33e-2

2.33e-2 [2.32e-2, 2.34e-2]

2.32e-2 [2.32e-2, 2.33e-2]

2.33e-2 [2.32e-2, 2.34e-2]

15

7.72e-3

7.73e-3 [7.67e-3, 7.78e-3]

7.76e-3 [7.71e-3, 7.82e-3]

7.75e-3 [7.69e-3, 7.80e-3]

20

2.48e-3

2.51e-3 [2.48e-3, 2.54e-3]

2.46e-3 [2.43e-3, 2.49e-3]

2.52e-3 [2.49e-3, 2.55e-3]

25

7.89e-4

8.08e-4 [7.91e-4, 8.26e-4]

7.83e-4 [7.66e-4, 8.00e-4]

7.88e-4 [7.71e-4, 8.06e-4]

30

2.50e-4

2.52e-4 [2.42e-4, 2.62e-4]

2.41e-4 [2.31e-4, 2.51e-4]

2.43e-4 [2.34e-4, 2.53e-4]

QPSK: error rates

Library

Precision

Against

max abs(z), BER

Agrees

BER interval

comnum (functions)

float64

theory

2.08

yes

binomial

comnum.run_spec

float64

theory

1.17

yes

binomial

Sionna

float32 (config.precision = ‘single’)

theory

3.13

yes

binomial

BER and its 95 % interval, every point (10,000,000 bits per point)

Eb/N0 (dB)

theory

comnum (functions)

comnum.run_spec

Sionna

0

1.46e-1

1.46e-1 [1.46e-1, 1.46e-1]

1.46e-1 [1.46e-1, 1.47e-1]

1.46e-1 [1.46e-1, 1.47e-1]

5

6.42e-2

6.41e-2 [6.39e-2, 6.42e-2]

6.42e-2 [6.40e-2, 6.43e-2]

6.42e-2 [6.40e-2, 6.43e-2]

10

2.33e-2

2.34e-2 [2.33e-2, 2.34e-2]

2.33e-2 [2.32e-2, 2.34e-2]

2.31e-2 [2.30e-2, 2.32e-2]

15

7.72e-3

7.77e-3 [7.72e-3, 7.82e-3]

7.73e-3 [7.67e-3, 7.78e-3]

7.67e-3 [7.61e-3, 7.72e-3]

20

2.48e-3

2.51e-3 [2.48e-3, 2.54e-3]

2.50e-3 [2.47e-3, 2.53e-3]

2.46e-3 [2.43e-3, 2.50e-3]

25

7.89e-4

8.02e-4 [7.84e-4, 8.19e-4]

7.81e-4 [7.64e-4, 7.99e-4]

7.92e-4 [7.75e-4, 8.10e-4]

30

2.50e-4

2.53e-4 [2.44e-4, 2.63e-4]

2.46e-4 [2.37e-4, 2.56e-4]

2.51e-4 [2.41e-4, 2.61e-4]

BPSK: time

Median of 5 runs, ns per bit; workload: 1000000 bits at Eb/N0 = 10.0 dB. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

Relative

First call

comnum (functions)

float64

76.5

77.2

1.00

0.0814 s

comnum.run_spec

float64

99.7

18.0

1.30

0.1 s

Sionna

float32 (config.precision = ‘single’)

87.6

72.6

1.15

0.099 s

QPSK: time

Median of 5 runs, ns per bit; workload: 1000000 bits at Eb/N0 = 10.0 dB. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

Relative

First call

comnum (functions)

float64

39.1

39.4

1.00

0.0404 s

comnum.run_spec

float64

50.9

9.96

1.30

0.0514 s

Sionna

float32 (config.precision = ‘single’)

63.0

46.7

1.61

0.0628 s

The commands of every run
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s2_rayleigh.py --lib comnum --threads 1 --work results/_work --out results/_work/s2_rayleigh_comnum_1.json
 python s2_rayleigh.py --lib comnum --threads all --work results/_work --out results/_work/s2_rayleigh_comnum_all.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s2_rayleigh.py --lib comnum-spec --threads 1 --work results/_work --out results/_work/s2_rayleigh_comnum-spec_1.json
 python s2_rayleigh.py --lib comnum-spec --threads all --work results/_work --out results/_work/s2_rayleigh_comnum-spec_all.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s2_rayleigh.py --lib sionna --threads 1 --work results/_work --out results/_work/s2_rayleigh_sionna_1.json
 python s2_rayleigh.py --lib sionna --threads all --work results/_work --out results/_work/s2_rayleigh_sionna_all.json --timing-only

4 x 4 MIMO, 16-QAM, i.i.d. Rayleigh fading: ZF, MMSE and ML detection#

Scenario 3: 4 x 4 spatial multiplexing of Gray 16-QAM over i.i.d. Rayleigh fading (one channel matrix per vector, known at the receiver), detected by zero forcing, MMSE and maximum likelihood.

Parameters:

  • Eb/N0 (dB): 0, 4, 8, 12, 16

  • vectors per point: linear detectors and sphere: 10^5 (quick: 5·10^3); exhaustive ML: 2·10^4 (quick: 10^3)

  • timing: 10^4 vectors at Eb/N0 = 12 dB (exhaustive ML: 10^3; quick: 2·10^3 and 2·10^2)

  • theory: ZF: 16-QAM over SISO Rayleigh fading (Winters, Salz & Gitlin, IEEE Trans. Commun. 42(2/3/4), 1994)

Script: benchmarks/libraries/s3_mimo.py.

The script
"""Scenario 3: 4 x 4 spatial multiplexing of Gray 16-QAM over i.i.d. Rayleigh fading
(one channel matrix per vector, known at the receiver), detected by zero forcing, MMSE
and maximum likelihood.

Every stream carries unit-energy symbols (no power split between the antennas) and
Eb/N0 is the energy per bit received on one antenna over N0: N0 = 1 / (4 Eb/N0).
Zero forcing over 4 x 4 leaves one diversity branch per stream, so its BER is that of
16-QAM over SISO Rayleigh fading (Winters, Salz & Gitlin 1994); MMSE and ML have no
closed form here and are compared between the libraries. Maximum likelihood is an
exhaustive search over the 16^4 = 65536 vectors in Sionna and in comnum ("ml"), and
the Schnorr-Euchner sphere decoder in comnum ("sphere", the same decisions).
The specification runner splits the power between the antennas (mimo_tx), so it runs
at Eb/N0 + 10 log10(4) dB to have the same SNR. Timing: ``TIMING_VECTORS`` vectors at
Eb/N0 = 12 dB (channel, noise, detection), median of 5 runs after a warm-up run.

    python s3_mimo.py --lib comnum --threads 1 [--quick] --out results/_work/s3.json
"""

from __future__ import annotations

import math
import time

import common
import numpy as np
import theory

SCENARIO = "s3_mimo"
TITLE = "4 x 4 MIMO, 16-QAM, i.i.d. Rayleigh fading: ZF, MMSE and ML detection"
NT = NR = 4
M = 16
K = 4
NO_MIMO = "no MIMO detection"
SUPPORT = {
    "comnum": True,
    "comnum-spec": True,
    "komm": NO_MIMO,
    "opticommpy": "no MIMO detection (only the 2 x 2 adaptive equalisers of polarisation multiplexing)",
    "opticommpy-nojit": "no MIMO detection (only the 2 x 2 adaptive equalisers of polarisation multiplexing)",
    "sionna": True,
}
CASES = {
    "zf": SUPPORT,
    "mmse": SUPPORT,
    "ml": SUPPORT,
    "sphere": {
        **SUPPORT,
        "sionna": "no sphere decoder (its exhaustive ML detector is the case ml)",
    },
}
SPEC_DETECTOR = {"zf": "zf", "mmse": "mmse", "ml": "ml", "sphere": "sphere"}
EBN0 = [0.0, 4.0, 8.0, 12.0, 16.0]
TIMING_EBN0 = 12.0
SPEC_OFFSET_DB = 10 * math.log10(NT)

PARAMETERS = {
    "ebn0_db": EBN0,
    "vectors_per_point": "linear detectors and sphere: 10^5 (quick: 5·10^3); exhaustive ML: 2·10^4 (quick: 10^3)",
    "timing": "10^4 vectors at Eb/N0 = 12 dB (exhaustive ML: 10^3; quick: 2·10^3 and 2·10^2)",
    "theory": "ZF: 16-QAM over SISO Rayleigh fading (Winters, Salz & Gitlin, IEEE Trans. Commun. 42(2/3/4), 1994)",
}


def sizes(quick: bool, case: str) -> dict:
    heavy = case == "ml"
    if quick:
        return {
            "vectors": 1_000 if heavy else 5_000,
            "timing": 200 if heavy else 2_000,
            "blocks": 1,
        }
    return {
        "vectors": 20_000 if heavy else 100_000,
        "timing": 1_000 if heavy else 10_000,
        "blocks": 10,
    }


def theory_ber(case: str, ebn0: float) -> float | None:
    return float(theory.ber_qam_rayleigh(M, ebn0)) if case == "zf" else None


def chain_comnum(case: str, seed: int):
    import comnum

    c = comnum.Constellation("qam", M).normalized()
    rng = comnum.Rng(seed)

    def run(n: int, ebn0: float) -> np.ndarray:
        b = comnum.bits(n * NT * K, rng)
        x = c.map_bits(b).reshape(n, NT)
        h = comnum.mimo_channels(n, NR, NT, rng)
        n0 = 1.0 / (K * 10 ** (ebn0 / 10))
        # y = H x + w (the Python API draws H; the product is NumPy's)
        y = np.matmul(h, x[:, :, None])[:, :, 0] + rng.complex_noise(n * NR, n0).reshape(n, NR)
        labels = comnum.mimo_detect(h, y.T, c, n0, case)  # stream-major (NT, n)
        bh = comnum.unpack_symbols(labels.T.ravel(), K)
        return np.count_nonzero((bh != b).reshape(n, NT * K), axis=1)  # bit errors per vector

    return run, "float64"


def chain_sionna(case: str, seed: int):
    import torch
    from sionna.phy import config
    from sionna.phy.channel import FlatFadingChannel
    from sionna.phy.mapping import BinarySource, Mapper
    from sionna.phy.mimo import LinearDetector, MaximumLikelihoodDetector
    from sionna.phy.utils import ebnodb2no

    config.seed = seed
    source, mapper = BinarySource(), Mapper("qam", K)
    channel = FlatFadingChannel(NT, NR, return_channel=True)
    if case == "ml":
        det = MaximumLikelihoodDetector(
            "bit", "maxlog", NT, constellation_type="qam", num_bits_per_symbol=K, hard_out=True
        )
        chunk = 64  # vectors per call: the search holds 65536 candidates per vector
    else:
        eq = {"zf": "zf", "mmse": "lmmse"}[case]
        det = LinearDetector(
            eq, "bit", "maxlog", constellation_type="qam", num_bits_per_symbol=K, hard_out=True
        )
        chunk = None

    def run(n: int, ebn0: float) -> np.ndarray:
        with torch.inference_mode():
            b = source([n, NT * K])
            x = mapper(b)  # [n, NT]
            no = ebnodb2no(ebn0, K, 1.0)  # Es / (K Eb/N0), Es = 1
            y, h = channel(x, no)
            s = (float(no) * torch.eye(NR, dtype=y.dtype)).expand(n, NR, NR)
            if chunk is None:
                bh = det(y, h, s)
            else:
                bh = torch.cat(
                    [
                        det(y[i : i + chunk], h[i : i + chunk], s[i : i + chunk])
                        for i in range(0, n, chunk)
                    ]
                )
            return common.to_numpy((bh.reshape(b.shape) != b).sum(dim=1))  # bit errors per vector

    return run, "float32 (config.precision = 'single')"


CHAINS = {"comnum": chain_comnum, "sionna": chain_sionna}


def run_chains(args, todo: list[str]) -> list[dict]:
    threading = common.setup_torch(args.threads) if args.lib == "sionna" else {}
    out = []
    for case in todo:
        sz = sizes(args.quick, case)
        run, precision = CHAINS[args.lib](case, args.seed)
        res = {"case": case, "precision": precision, **threading}
        n = sz["timing"]
        t0 = time.perf_counter()
        run(n, TIMING_EBN0)
        res["first_call_s"] = time.perf_counter() - t0
        if not args.timing_only:
            pts = []
            t0 = time.perf_counter()
            for e in EBN0:
                # the 16 bits of a vector share its channel: the interval is per vector
                errs = np.concatenate(
                    [run(sz["vectors"] // sz["blocks"], e) for _ in range(sz["blocks"])]
                )
                pts.append(
                    common.frame_ber_point(
                        errs, NT * K, theory_ber(case, e), ebn0_db=e, vectors=errs.size
                    )
                )
            res["points"] = pts
            res["sweep_s"] = time.perf_counter() - t0
        res["timing"] = common.time_it(lambda n=n, run=run: run(n, TIMING_EBN0))
        res["timing"].update(
            units=n, unit="vector", workload=f"{n} vectors at Eb/N0 = {TIMING_EBN0} dB"
        )
        out.append(res)
    return out


def spec(case: str, ebn0: list[float], vectors: int, trials: int, seed: int) -> dict:
    return {
        "name": f"bench_mimo_{case}",
        "seed": seed,
        "batch": trials,
        "grid": {"ebn0_db": [e + SPEC_OFFSET_DB for e in ebn0]},
        "link": [
            {"type": "source", "constellation": "16-QAM", "n_symbols": NT * vectors // trials},
            {"type": "mimo_tx", "n_tx": NT, "scheme": "spatial"},
            {"type": "rayleigh", "n_rx": NR, "coherence_samples": 1},
            {"type": "awgn_ebn0", "ebn0_db": "$ebn0_db"},
            {"type": "mimo_rx", "detector": SPEC_DETECTOR[case]},
        ],
        "metrics": {"report": ["ber"], "scale": "none"},
        "stop": {"min_trials": trials, "max_trials": trials},
    }


def run_spec_lib(args, todo: list[str]) -> list[dict]:
    trials = 40
    out = []
    for case in todo:
        sz = sizes(args.quick, case)

        def points(rows: list[dict], case=case, vectors=sz["vectors"]) -> list[dict]:
            pts = []
            for e, row in zip(EBN0, rows, strict=True):
                ber = row["metrics"]["ber"]
                pts.append(
                    common.rate_point(
                        ber["count"], ber["total"], theory_ber(case, e), ebn0_db=e, vectors=vectors
                    )
                )
            return pts

        n = sz["timing"]
        out.append(
            common.spec_case(
                args,
                {"case": case, "precision": "float64", "ebn0_offset_db": SPEC_OFFSET_DB},
                spec(case, EBN0, sz["vectors"], trials, args.seed),
                spec(case, [TIMING_EBN0], n, trials, args.seed + 1),
                points,
                units=n,
                unit="vector",
                workload=f"{n} vectors at Eb/N0 = {TIMING_EBN0} dB, {trials} trials",
            )
        )
    return out


RUNNERS = {"comnum": run_chains, "sionna": run_chains, "comnum-spec": run_spec_lib}

if __name__ == "__main__":
    common.main(SCENARIO, CASES, RUNNERS)

Support

Case

comnum (functions)

comnum.run_spec

komm

OptiCommPy (numba)

OptiCommPy (no numba)

Sionna

zero forcing

yes

yes

not available: no MIMO detection

not available: no MIMO detection (only the 2 x 2 adaptive equalisers of polarisation multiplexing)

not available: no MIMO detection (only the 2 x 2 adaptive equalisers of polarisation multiplexing)

yes

MMSE

yes

yes

not available: no MIMO detection

not available: no MIMO detection (only the 2 x 2 adaptive equalisers of polarisation multiplexing)

not available: no MIMO detection (only the 2 x 2 adaptive equalisers of polarisation multiplexing)

yes

ML (exhaustive)

yes

yes

not available: no MIMO detection

not available: no MIMO detection (only the 2 x 2 adaptive equalisers of polarisation multiplexing)

not available: no MIMO detection (only the 2 x 2 adaptive equalisers of polarisation multiplexing)

yes

ML (sphere decoder)

yes

yes

not available: no MIMO detection

not available: no MIMO detection (only the 2 x 2 adaptive equalisers of polarisation multiplexing)

not available: no MIMO detection (only the 2 x 2 adaptive equalisers of polarisation multiplexing)

not available: no sphere decoder (its exhaustive ML detector is the case ml)

zero forcing: error rates

Library

Precision

Against

max abs(z), BER

Agrees

BER interval

comnum (functions)

float64

theory

1.88

yes

per-frame

comnum.run_spec

float64

theory

2.98

yes

binomial

Sionna

float32 (config.precision = ‘single’)

theory

1.79

yes

per-frame

BER and its 95 % interval, every point (1,600,000 bits per point)

Eb/N0 (dB)

theory

comnum (functions)

comnum.run_spec

Sionna

0

1.98e-1

1.98e-1 [1.97e-1, 1.99e-1]

1.98e-1 [1.97e-1, 1.98e-1]

1.98e-1 [1.97e-1, 1.99e-1]

4

1.20e-1

1.19e-1 [1.18e-1, 1.20e-1]

1.19e-1 [1.19e-1, 1.20e-1]

1.19e-1 [1.19e-1, 1.20e-1]

8

6.20e-2

6.16e-2 [6.10e-2, 6.23e-2]

6.14e-2 [6.10e-2, 6.18e-2]

6.26e-2 [6.19e-2, 6.32e-2]

12

2.83e-2

2.82e-2 [2.78e-2, 2.87e-2]

2.81e-2 [2.79e-2, 2.84e-2]

2.83e-2 [2.78e-2, 2.87e-2]

16

1.20e-2

1.21e-2 [1.18e-2, 1.24e-2]

1.19e-2 [1.17e-2, 1.20e-2]

1.19e-2 [1.16e-2, 1.22e-2]

MMSE: error rates

Library

Precision

Against

max abs(z), BER

Agrees

BER interval

comnum (functions)

float64

—

—

—

per-frame

comnum.run_spec

float64

comnum

3.63

yes

binomial

Sionna

float32 (config.precision = ‘single’)

comnum

1.57

yes

per-frame

BER and its 95 % interval, every point (1,600,000 bits per point)

Eb/N0 (dB)

comnum (functions)

comnum.run_spec

Sionna

0

1.39e-1 [1.38e-1, 1.40e-1]

1.38e-1 [1.38e-1, 1.39e-1]

1.38e-1 [1.38e-1, 1.39e-1]

4

8.27e-2 [8.21e-2, 8.33e-2]

8.40e-2 [8.36e-2, 8.44e-2]

8.33e-2 [8.28e-2, 8.39e-2]

8

4.31e-2 [4.26e-2, 4.35e-2]

4.29e-2 [4.26e-2, 4.32e-2]

4.32e-2 [4.28e-2, 4.37e-2]

12

1.97e-2 [1.94e-2, 2.00e-2]

1.99e-2 [1.97e-2, 2.01e-2]

1.96e-2 [1.93e-2, 1.99e-2]

16

8.42e-3 [8.20e-3, 8.64e-3]

8.33e-3 [8.19e-3, 8.47e-3]

8.23e-3 [8.02e-3, 8.45e-3]

ML (exhaustive): error rates

Library

Precision

Against

max abs(z), BER

Agrees

BER interval

comnum (functions)

float64

—

—

—

per-frame

comnum.run_spec

float64

comnum

2.53

yes

binomial

Sionna

float32 (config.precision = ‘single’)

comnum

1.09

yes

per-frame

BER and its 95 % interval, every point (320,000 bits per point)

Eb/N0 (dB)

comnum (functions)

comnum.run_spec

Sionna

0

1.25e-1 [1.23e-1, 1.26e-1]

1.25e-1 [1.24e-1, 1.26e-1]

1.25e-1 [1.23e-1, 1.27e-1]

4

4.03e-2 [3.90e-2, 4.16e-2]

3.99e-2 [3.92e-2, 4.06e-2]

4.05e-2 [3.92e-2, 4.17e-2]

8

4.12e-3 [3.68e-3, 4.56e-3]

4.77e-3 [4.54e-3, 5.02e-3]

4.47e-3 [4.02e-3, 4.93e-3]

12

2.34e-4 [1.24e-4, 3.45e-4]

2.28e-4 [1.81e-4, 2.87e-4]

1.59e-4 [7.61e-5, 2.43e-4]

16

0 [0, 9.38e-6]

0 [0, 1.20e-5]

1.25e-5 [0, 3.70e-5]

ML (sphere decoder): error rates

Library

Precision

Against

max abs(z), BER

Agrees

BER interval

comnum (functions)

float64

—

—

—

per-frame

comnum.run_spec

float64

comnum

2.84

yes

binomial

BER and its 95 % interval, every point (1,600,000 bits per point)

Eb/N0 (dB)

comnum (functions)

comnum.run_spec

0

1.24e-1 [1.23e-1, 1.25e-1]

1.25e-1 [1.24e-1, 1.25e-1]

4

3.97e-2 [3.92e-2, 4.03e-2]

4.06e-2 [4.03e-2, 4.09e-2]

8

4.44e-3 [4.23e-3, 4.64e-3]

4.62e-3 [4.51e-3, 4.73e-3]

12

1.91e-4 [1.49e-4, 2.32e-4]

1.95e-4 [1.75e-4, 2.18e-4]

16

3.13e-6 [0, 6.37e-6]

1.25e-5 [8.09e-6, 1.93e-5]

zero forcing: time

Median of 5 runs, µs per vector; workload: 10000 vectors at Eb/N0 = 12.0 dB. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

Relative

First call

comnum (functions)

float64

0.76

0.763

1.00

0.00823 s

comnum.run_spec

float64

0.776

0.175

1.02

0.00794 s

Sionna

float32 (config.precision = ‘single’)

1.06

0.796

1.40

0.0148 s

MMSE: time

Median of 5 runs, µs per vector; workload: 10000 vectors at Eb/N0 = 12.0 dB. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

Relative

First call

comnum (functions)

float64

0.761

0.76

1.00

0.00759 s

comnum.run_spec

float64

0.778

0.174

1.02

0.00782 s

Sionna

float32 (config.precision = ‘single’)

1.28

0.98

1.68

0.0129 s

ML (exhaustive): time

Median of 5 runs, µs per vector; workload: 1000 vectors at Eb/N0 = 12.0 dB. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

Relative

First call

comnum (functions)

float64

393

394

1.00

0.393 s

comnum.run_spec

float64

393

105

1

0.398 s

Sionna

float32 (config.precision = ‘single’)

1519

908

3.87

1.57 s

ML (sphere decoder): time

Median of 5 runs, µs per vector; workload: 10000 vectors at Eb/N0 = 12.0 dB. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

Relative

First call

comnum (functions)

float64

1.02

1.02

1.00

0.0108 s

comnum.run_spec

float64

1.03

0.209

1.01

0.0104 s

The commands of every run
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s3_mimo.py --lib comnum --threads 1 --work results/_work --out results/_work/s3_mimo_comnum_1.json
 python s3_mimo.py --lib comnum --threads all --work results/_work --out results/_work/s3_mimo_comnum_all.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s3_mimo.py --lib comnum-spec --threads 1 --work results/_work --out results/_work/s3_mimo_comnum-spec_1.json
 python s3_mimo.py --lib comnum-spec --threads all --work results/_work --out results/_work/s3_mimo_comnum-spec_all.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s3_mimo.py --lib sionna --threads 1 --work results/_work --out results/_work/s3_mimo_sionna_1.json
 python s3_mimo.py --lib sionna --threads all --work results/_work --out results/_work/s3_mimo_sionna_all.json --timing-only

Channel coding over BPSK and AWGN#

Scenario 4: channel coding over BPSK and AWGN.

Parameters:

  • convolutional (133, 171), soft Viterbi: K = 7, (133, 171), k = 1024 + 6 tail bits, soft Viterbi; Eb/N0 = 1, 2, 3, 4 dB

  • 5G NR LDPC (1024, 2048): 5G NR LDPC, k = 1024, n = 2048 (base graph 2, Z = 104), flooding sum-product, 50 iterations; Eb/N0 = 1, 1.5, 2, 2.5 dB

  • LDPC (1024, 512), same LLRs: PEG (1024, 512), flooding sum-product, 50 iterations, identical LLRs; Eb/N0 = 1.5, 2, 2.5, 3 dB

  • frames per point: 2000 (quick: 100)

  • timing: 200 frames (quick: 20) at Eb/N0 = 3 dB (conv) or 2 dB (LDPC); OptiCommPy without numba: 2 frames

Script: benchmarks/libraries/s4_coding.py.

The script
"""Scenario 4: channel coding over BPSK and AWGN.

- ``conv``: the rate-1/2 convolutional code of constraint length 7, generators
  (133, 171) in octal, 1024 information bits per frame terminated by 6 tail bits
  (rate 1024/2060), soft-decision Viterbi decoding. komm counts the taps from the
  least significant bit: the same code is ``ConvolutionalCode([[0o155, 0o117]])``
  there (checked on the impulse response). Reference: the union bound with the
  distance spectrum of the trellis (Viterbi 1971).
- ``nr_ldpc``: the LDPC code of 5G NR (3GPP TS 38.212, Sec. 5.3.2), k = 1024, rate
  matched to n = 2048 (base graph 2 by the rule of Sec. 7.2.2, lifting size 104, the
  first 2Z bits punctured; the same in both libraries), sum-product
  decoding by flooding, 50 iterations at most, in both libraries (comnum's default is
  the layered schedule, which converges in about half the iterations; it is set to
  flooding here so that the error rates compare the same decoder). comnum stops early on
  a codeword, Sionna runs the 50 iterations (its default).
- ``ldpc_same_llr``: one (1024, 512) LDPC code (PEG construction of comnum, variable
  degree 3, seed 1), its parity-check matrix given to the three decoders that accept
  one (comnum, OptiCommPy, Sionna), and the same channel LLRs (drawn by comnum from the
  same seed in every process): flooding sum-product, 50 iterations at most. The
  decoders see identical inputs, so they should take the same decisions frame by
  frame.

Every library uses its own encoder, BPSK mapper, AWGN channel and LLR computation in
``conv`` and ``nr_ldpc`` (independent seeds); Eb/N0 counts the energy per information
bit. Timings: the decoder alone on ``TIMING_FRAMES`` frames of LLRs at the timing
point, and the whole chain (bits, encoding, mapping, noise, LLRs, decoding, error
count) on the same number of frames; median of 5 runs after a warm-up run.

    python s4_coding.py --lib sionna --threads 1 [--quick] --out results/_work/s4.json
"""

from __future__ import annotations

import time

import common
import numpy as np
import theory

SCENARIO = "s4_coding"
TITLE = "Channel coding over BPSK and AWGN"
K_CONV = 1024
CONV_GEN = [0o133, 0o171]
K_NR, N_NR = 1024, 2048
N_PEG, K_PEG = 1024, 512
ITERATIONS = 50
EBN0 = {
    "conv": [1.0, 2.0, 3.0, 4.0],
    "nr_ldpc": [1.0, 1.5, 2.0, 2.5],
    "ldpc_same_llr": [1.5, 2.0, 2.5, 3.0],
}
TIMING_EBN0 = {"conv": 3.0, "nr_ldpc": 2.0, "ldpc_same_llr": 2.0}
SPEC_ONLY_OWN = "the runner draws its own channel (this case feeds the same LLRs to every decoder)"
CASES = {
    "conv": {
        "comnum": True,
        "comnum-spec": True,
        "comnum-rust": True,
        "komm": True,
        "opticommpy": "no convolutional code",
        "opticommpy-nojit": "no convolutional code",
        "sionna": True,
    },
    "nr_ldpc": {
        "comnum": True,
        "comnum-spec": "the specification format has no 5G NR LDPC code (NrLdpc: Python and Rust API)",
        "comnum-rust": "the specification format has no 5G NR LDPC code (NrLdpc: Python and Rust API)",
        "komm": "no LDPC code",
        "opticommpy": "no 5G NR LDPC code (its decoder takes a parity-check matrix: see ldpc_same_llr)",
        "opticommpy-nojit": "no 5G NR LDPC code (its decoder takes a parity-check matrix: see ldpc_same_llr)",
        "sionna": True,
    },
    "ldpc_same_llr": {
        "comnum": True,
        "comnum-spec": SPEC_ONLY_OWN,
        "comnum-rust": SPEC_ONLY_OWN,
        "komm": "no LDPC (belief-propagation) decoder",
        "opticommpy": True,
        "opticommpy-nojit": True,
        "sionna": True,
    },
}

PARAMETERS = {
    "conv": "K = 7, (133, 171), k = 1024 + 6 tail bits, soft Viterbi; Eb/N0 = 1, 2, 3, 4 dB",
    "nr_ldpc": "5G NR LDPC, k = 1024, n = 2048 (base graph 2, Z = 104), flooding sum-product, 50 iterations; Eb/N0 = 1, 1.5, 2, 2.5 dB",
    "ldpc_same_llr": "PEG (1024, 512), flooding sum-product, 50 iterations, identical LLRs; Eb/N0 = 1.5, 2, 2.5, 3 dB",
    "frames_per_point": "2000 (quick: 100)",
    "timing": "200 frames (quick: 20) at Eb/N0 = 3 dB (conv) or 2 dB (LDPC); OptiCommPy without numba: 2 frames",
}


def sizes(quick: bool) -> dict:
    return (
        {"frames": 100, "timing": 20, "block": 50}
        if quick
        else {"frames": 2000, "timing": 200, "block": 200}
    )


def n0_of(ebn0: float, rate: float) -> float:
    """N0 of unit-energy BPSK for an Eb/N0 per information bit (complex noise of
    variance N0, N0/2 on the real axis)."""
    return 1.0 / (rate * 10 ** (ebn0 / 10))


# ---------------------------------------------------------------------- the codecs
# A codec gives: encode(F) -> (info bits [F, k], channel input), llrs(F, ebn0) ->
# (reference bits, LLRs in the library's convention), decode(llrs) -> decided bits
# [F, k_out], and the number k_out of bits compared per frame.


def codec_comnum(case: str, seed: int):
    import comnum

    rng = comnum.Rng(seed)
    bpsk = comnum.Constellation("psk", 2)
    if case == "conv":
        code = comnum.ConvolutionalCode(K_CONV, generators=CONV_GEN, constraint=7)
    elif case == "nr_ldpc":
        code = comnum.NrLdpc(
            K_NR, N_NR, max_iterations=ITERATIONS, rule="sum_product", layered=False
        )
    else:
        return codec_same_llr_comnum(seed)
    k, n = code.k, code.n

    def llrs(f: int, ebn0: float):
        b = comnum.bits(f * k, rng)
        x = bpsk.map_bits(code.encode(b))
        n0 = n0_of(ebn0, k / n)
        y = x + rng.complex_noise(x.size, n0)
        return b.reshape(f, k), bpsk.llr(y, n0)

    def decode(llr):
        return code.decode(llr).reshape(-1, k)

    return llrs, decode, k, "float64"


def peg_code():
    import comnum

    code = comnum.LdpcCode.peg(N_PEG, K_PEG, var_degree=3, seed=1)
    code.set_decoder("sum_product", layered=False, max_iterations=ITERATIONS)
    return code


def same_llrs(seed: int):
    """The codewords and LLRs of ldpc_same_llr: the same in every process."""
    import comnum

    code = peg_code()
    rng = comnum.Rng(seed)
    bpsk = comnum.Constellation("psk", 2)

    def llrs(f: int, ebn0: float):
        b = comnum.bits(f * code.k, rng)
        c = code.encode(b)
        n0 = n0_of(ebn0, code.k / code.n)
        y = bpsk.map_bits(c) + rng.complex_noise(c.size, n0)
        return c.reshape(f, code.n), bpsk.llr(y, n0).reshape(f, code.n)  # positive for a 0

    return code, llrs


def codec_same_llr_comnum(seed: int):
    code, llrs = same_llrs(seed)

    def decode(llr):
        return np.stack([(code.decode_llr(row)[0] < 0).astype(np.uint8) for row in llr])

    return llrs, decode, code.n, "float64"


def codec_opticommpy(case: str, seed: int):
    import scipy.sparse as sp
    from optic.comm.fec import decodeLDPC
    from optic.utils import parameters

    code, llrs = same_llrs(seed)
    rows = code.rows
    h = sp.csr_matrix(
        (
            np.ones(sum(map(len, rows)), dtype=np.uint8),
            (np.repeat(np.arange(len(rows)), list(map(len, rows))), np.concatenate(rows)),
        ),
        shape=(len(rows), code.n),
    )
    p = parameters()
    p.H, p.maxIter, p.alg = h, ITERATIONS, "SPA"

    def decode(llr):
        bits, _out, _fail = decodeLDPC(np.ascontiguousarray(llr.T), p)  # columns are codewords
        return bits.T.astype(np.uint8)

    return llrs, decode, code.n, "float32 (decodeLDPC default prec = np.float32)"


def codec_komm(case: str, seed: int):
    import komm

    # (133, 171) with the current input on the most significant bit = (155, 117) in komm
    cc = komm.ConvolutionalCode([[0o155, 0o117]])
    code = komm.TerminatedConvolutionalCode(cc, num_blocks=K_CONV, mode="zero-termination")
    decoder = komm.ViterbiDecoder(code, input_type="soft")
    bpsk, lab = komm.PAMConstellation(2), komm.NaturalLabeling(1)
    rng = np.random.default_rng(seed)
    k, n = code.dimension, code.length

    def llrs(f: int, ebn0: float):
        b = rng.integers(0, 2, (f, k))
        x = bpsk.indices_to_symbols(lab.bits_to_indices(code.encode(b).ravel()))
        sigma2 = n0_of(ebn0, k / n) / 2  # real channel: N0/2 per dimension
        y = komm.GaussianChannel(sigma2, rng=rng).transmit(x)
        return b, lab.marginalize(bpsk.posteriors(y, sigma2))  # log P(0) / P(1)

    def decode(llr):
        return decoder.decode(llr).reshape(-1, k)

    return llrs, decode, k, "float64"


def codec_sionna(case: str, seed: int):
    import torch
    from sionna.phy import config
    from sionna.phy.channel import AWGN
    from sionna.phy.fec.conv import ConvEncoder, ViterbiDecoder
    from sionna.phy.fec.ldpc import LDPC5GDecoder, LDPC5GEncoder, LDPCBPDecoder
    from sionna.phy.mapping import BinarySource, Demapper, Mapper

    config.seed = seed
    if case == "ldpc_same_llr":
        code, same = same_llrs(seed)
        dec = LDPCBPDecoder(peg_pcm(code), num_iter=ITERATIONS, hard_out=True)

        def decode(llr):
            with torch.inference_mode():
                # Sionna's LLRs are logits, log P(1) / P(0): the opposite sign
                llr_dev = torch.from_numpy(-llr.astype(np.float32)).to(common.TORCH_DEVICE)
                return common.to_numpy(dec(llr_dev)).astype(np.uint8)

        return same, decode, code.n, "float32 (config.precision = 'single')"

    if case == "conv":
        enc = ConvEncoder(rate=0.5, constraint_length=7, terminate=True)
        dec = ViterbiDecoder(encoder=enc, method="soft_llr", return_info_bits=True)
        k, n = K_CONV, 2 * (K_CONV + 6)
    else:
        enc = LDPC5GEncoder(K_NR, N_NR)
        dec = LDPC5GDecoder(enc, num_iter=ITERATIONS, hard_out=True, return_infobits=True)
        k, n = K_NR, N_NR
    source, mapper, channel = BinarySource(), Mapper("pam", 1), AWGN()
    demapper = Demapper("app", "pam", 1)

    def llrs(f: int, ebn0: float):
        with torch.inference_mode():
            b = source([f, k])
            no = n0_of(ebn0, k / n)
            y = channel(mapper(enc(b)), no)
            return b, demapper(y, no)  # logits, log P(1) / P(0)

    def decode(llr):
        with torch.inference_mode():
            return dec(llr)

    return llrs, decode, k, "float32 (config.precision = 'single')"


def peg_pcm(code):
    h = np.zeros((len(code.rows), code.n), dtype=np.float32)
    for i, r in enumerate(code.rows):
        h[i, r] = 1
    return h


CODECS = {
    "comnum": codec_comnum,
    "komm": codec_komm,
    "opticommpy": codec_opticommpy,
    "opticommpy-nojit": codec_opticommpy,
    "sionna": codec_sionna,
}


def as_np(a) -> np.ndarray:
    return common.to_numpy(a)


def run_codecs(args, todo: list[str]) -> list[dict]:
    threading = common.setup_torch(args.threads) if args.lib == "sionna" else {}
    sz = sizes(args.quick)
    out = []
    for case in todo:
        llrs, decode, k_out, precision = CODECS[args.lib](case, args.seed)
        res = {"case": case, "precision": precision, **threading}

        def chain(f: int, ebn0: float, llrs=llrs, decode=decode) -> np.ndarray:
            ref, llr = llrs(f, ebn0)
            return np.count_nonzero(
                as_np(decode(llr)).reshape(f, -1) != as_np(ref).reshape(f, -1), axis=1
            )

        nt = 2 if args.lib == "opticommpy-nojit" else sz["timing"]
        e_t = TIMING_EBN0[case]
        t0 = time.perf_counter()
        chain(min(nt, 2), e_t)
        res["first_call_s"] = time.perf_counter() - t0
        if not args.timing_only:
            pts = []
            t0 = time.perf_counter()
            for e in EBN0[case]:
                errs = np.concatenate(
                    [chain(sz["block"], e) for _ in range(sz["frames"] // sz["block"])]
                )
                bound = (
                    float(theory.conv_union_bound(CONV_GEN, 7, K_CONV / (2 * (K_CONV + 6)), e))
                    if case == "conv"
                    else None
                )
                pt = common.frame_ber_point(errs, k_out, None, ebn0_db=e)
                if bound is not None:
                    pt["union_bound"] = bound
                fe = int(np.count_nonzero(errs))
                pt["bler"] = common.rate_point(fe, errs.size)
                if case == "ldpc_same_llr":
                    pt["errors_per_frame"] = errs.astype(int).tolist()
                pts.append(pt)
            res["points"] = pts
            res["sweep_s"] = time.perf_counter() - t0
            res["bits_per_frame"] = k_out
        _, llr = llrs(nt, e_t)
        res["timing"] = common.time_it(lambda llr=llr, decode=decode: decode(llr))
        res["timing"].update(
            units=nt, unit="frame", workload=f"decoder only, {nt} frames at Eb/N0 = {e_t} dB"
        )
        res["timing_chain"] = common.time_it(lambda nt=nt, e_t=e_t, chain=chain: chain(nt, e_t))
        res["timing_chain"].update(
            units=nt, unit="frame", workload=f"whole chain, {nt} frames at Eb/N0 = {e_t} dB"
        )
        out.append(res)
    return out


# ------------------------------------------------------------- specification runner


def spec(ebn0: list[float], frames: int, trials: int, seed: int) -> dict:
    code = {"type": "conv", "k": K_CONV}
    return {
        "name": "bench_conv",
        "seed": seed,
        "batch": trials,
        "grid": {"ebn0_db": ebn0},
        "link": [
            {"type": "fec_encode", "code": code, "blocks": frames // trials},
            {"type": "map", "constellation": {"kind": "psk", "m": 2}},
            {"type": "awgn_ebn0", "ebn0_db": "$ebn0_db"},
            "demap",
            {"type": "fec_decode", "code": code},
        ],
        "metrics": {"report": ["coded_ber", "bler"]},  # coded_ber: decoded information bits
        "stop": {"min_trials": trials, "max_trials": trials},
    }


def spec_points(rows: list[dict]) -> list[dict]:
    pts = []
    for r in rows:
        e = float(r["condition"]["ebn0_db"])
        m = r["metrics"]
        get = lambda d: d["count"] if "count" in d else d["errors"]  # noqa: E731
        # a binomial interval: the runner keeps the totals, not the errors of every frame
        pt = common.rate_point(get(m["coded_ber"]), m["coded_ber"]["total"], None, ebn0_db=e)
        pt["union_bound"] = float(
            theory.conv_union_bound(CONV_GEN, 7, K_CONV / (2 * (K_CONV + 6)), e)
        )
        pt["bler"] = common.rate_point(get(m["bler"]), m["bler"]["total"])
        pts.append(pt)
    return pts


def run_spec_lib(args, todo: list[str], *, rust: bool = False) -> list[dict]:
    """``comnum-spec`` (``comnum.run_spec``), or with ``rust`` ``comnum-rust`` (the binary)."""
    sz = sizes(args.quick)
    trials = 20
    out = []
    for case in todo:
        nt, e_t = sz["timing"], TIMING_EBN0[case]
        workload = f"whole chain, {nt} frames at Eb/N0 = {e_t} dB, {trials} trials"
        res = common.spec_case(
            args,
            {"case": case, "precision": "float64"},
            spec(EBN0[case], sz["frames"], trials, args.seed),
            spec([e_t], nt, trials, args.seed + 1),
            spec_points,
            rust=rust,
            key="timing_chain",
            units=nt,
            unit="frame",
            workload=workload + (" (process wall time)" if rust else ""),
        )
        if not args.timing_only:
            res["bits_per_frame"] = K_CONV
        out.append(res)
    return out


def run_rust_lib(args, todo: list[str]) -> list[dict]:
    return run_spec_lib(args, todo, rust=True)


def postprocess(merged: dict, work) -> dict:
    """ldpc_same_llr: the decoders saw the same LLRs; compare their errors frame by frame
    with those of comnum (same number of bit errors in a frame, same frame errors)."""
    per_lib = {}
    for run in merged["runs"]:
        if run.get("threads") != "1":
            continue
        for case in run.get("cases", []):
            if case["case"] == "ldpc_same_llr" and "points" in case:
                per_lib[run["library"]] = case["points"]
    base = per_lib.get("comnum")
    rows = []
    for lib, pts in per_lib.items():
        if lib == "comnum" or base is None:
            continue
        for p, q in zip(pts, base, strict=True):
            a, b = np.array(p["errors_per_frame"]), np.array(q["errors_per_frame"])
            rows.append(
                {
                    "library": lib,
                    "ebn0_db": p["ebn0_db"],
                    "frames": int(a.size),
                    "same_bit_errors": int(np.count_nonzero(a == b)),
                    "same_frame_outcome": int(np.count_nonzero((a > 0) == (b > 0))),
                }
            )
    return {"same_llr_vs_comnum": rows}


RUNNERS = {
    **{lib: run_codecs for lib in CODECS},
    "comnum-spec": run_spec_lib,
    "comnum-rust": run_rust_lib,
}

if __name__ == "__main__":
    common.main(SCENARIO, CASES, RUNNERS)

Support

Case

comnum (functions)

comnum.run_spec

comnum binary (Rust)

komm

OptiCommPy (numba)

OptiCommPy (no numba)

Sionna

convolutional (133, 171), soft Viterbi

yes

yes

yes

yes

not available: no convolutional code

not available: no convolutional code

yes

5G NR LDPC (1024, 2048)

yes

not available: the specification format has no 5G NR LDPC code (NrLdpc: Python and Rust API)

not available: the specification format has no 5G NR LDPC code (NrLdpc: Python and Rust API)

not available: no LDPC code

not available: no 5G NR LDPC code (its decoder takes a parity-check matrix: see ldpc_same_llr)

not available: no 5G NR LDPC code (its decoder takes a parity-check matrix: see ldpc_same_llr)

yes

LDPC (1024, 512), same LLRs

yes

not available: the runner draws its own channel (this case feeds the same LLRs to every decoder)

not available: the runner draws its own channel (this case feeds the same LLRs to every decoder)

not available: no LDPC (belief-propagation) decoder

yes

yes

yes

convolutional (133, 171), soft Viterbi: error rates

Library

Precision

Against

max abs(z), BER

max abs(z), FER

Agrees

BER interval

comnum (functions)

float64

—

—

—

—

per-frame

comnum.run_spec

float64

comnum

1.35

1.33

yes

binomial

comnum binary (Rust)

float64

comnum

1.35

1.33

yes

binomial

komm

float64

comnum

0.974

1.81

yes

per-frame

Sionna

float32 (config.precision = ‘single’)

comnum

1.41

2.53

yes

per-frame

BER and its 95 % interval, every point (2,048,000 bits per point)

Eb/N0 (dB)

union bound

comnum (functions)

comnum.run_spec

comnum binary (Rust)

komm

Sionna

1

3.05e-1

4.02e-2 [3.91e-2, 4.13e-2]

4.02e-2 [3.99e-2, 4.05e-2]

4.02e-2 [3.99e-2, 4.05e-2]

4.05e-2 [3.94e-2, 4.17e-2]

4.03e-2 [3.91e-2, 4.14e-2]

2

1.65e-2

4.97e-3 [4.64e-3, 5.30e-3]

5.16e-3 [5.06e-3, 5.26e-3]

5.16e-3 [5.06e-3, 5.26e-3]

5.21e-3 [4.86e-3, 5.55e-3]

5.32e-3 [4.97e-3, 5.67e-3]

3

6.27e-4

3.31e-4 [2.59e-4, 4.02e-4]

2.98e-4 [2.76e-4, 3.23e-4]

2.98e-4 [2.76e-4, 3.23e-4]

3.71e-4 [3.04e-4, 4.38e-4]

3.64e-4 [2.94e-4, 4.34e-4]

4

2.02e-5

2.54e-5 [8.46e-6, 4.23e-5]

1.32e-5 [9.06e-6, 1.92e-5]

1.32e-5 [9.06e-6, 1.92e-5]

2.00e-5 [3.66e-6, 3.64e-5]

3.08e-5 [9.04e-6, 5.25e-5]

Frame error rate and its 95 % interval (2,000 frames per point)

Eb/N0 (dB)

comnum (functions)

comnum.run_spec

comnum binary (Rust)

komm

Sionna

1

9.73e-1 [9.66e-1, 9.80e-1]

9.77e-1 [9.69e-1, 9.83e-1]

9.77e-1 [9.69e-1, 9.83e-1]

9.82e-1 [9.75e-1, 9.87e-1]

9.85e-1 [9.79e-1, 9.89e-1]

2

5.05e-1 [4.84e-1, 5.27e-1]

4.84e-1 [4.63e-1, 5.06e-1]

4.84e-1 [4.63e-1, 5.06e-1]

5.01e-1 [4.79e-1, 5.23e-1]

5.13e-1 [4.91e-1, 5.35e-1]

3

5.95e-2 [5.00e-2, 7.07e-2]

6.05e-2 [5.09e-2, 7.18e-2]

6.05e-2 [5.09e-2, 7.18e-2]

7.15e-2 [6.10e-2, 8.36e-2]

6.70e-2 [5.69e-2, 7.88e-2]

4

6.00e-3 [3.44e-3, 1.05e-2]

5.00e-3 [2.72e-3, 9.18e-3]

5.00e-3 [2.72e-3, 9.18e-3]

5.00e-3 [2.72e-3, 9.18e-3]

5.50e-3 [3.07e-3, 9.82e-3]

5G NR LDPC (1024, 2048): error rates

Library

Precision

Against

max abs(z), BER

max abs(z), FER

Agrees

BER interval

comnum (functions)

float64

—

—

—

—

per-frame

Sionna

float32 (config.precision = ‘single’)

comnum

0.893

1.21

yes

per-frame

BER and its 95 % interval, every point (2,048,000 bits per point)

Eb/N0 (dB)

comnum (functions)

Sionna

1

8.79e-3 [7.46e-3, 1.01e-2]

9.30e-3 [7.94e-3, 1.07e-2]

1.5

3.47e-5 [0, 1.03e-4]

9.38e-5 [0, 2.04e-4]

2

0 [0, 1.46e-6]

0 [0, 1.46e-6]

2.5

0 [0, 1.46e-6]

0 [0, 1.46e-6]

Frame error rate and its 95 % interval (2,000 frames per point)

Eb/N0 (dB)

comnum (functions)

Sionna

1

9.40e-2 [8.20e-2, 1.08e-1]

1.05e-1 [9.28e-2, 1.20e-1]

1.5

5.00e-4 [8.83e-5, 2.83e-3]

1.50e-3 [5.10e-4, 4.40e-3]

2

0 [0, 1.92e-3]

0 [0, 1.92e-3]

2.5

0 [0, 1.92e-3]

0 [0, 1.92e-3]

LDPC (1024, 512), same LLRs: error rates

Library

Precision

Against

max abs(z), BER

max abs(z), FER

Agrees

BER interval

comnum (functions)

float64

—

—

—

—

per-frame

OptiCommPy (numba)

float32 (decodeLDPC default prec = np.float32)

comnum

0.076

0

yes

per-frame

Sionna

float32 (config.precision = ‘single’)

comnum

0.0906

0.0381

yes

per-frame

BER and its 95 % interval, every point (2,048,000 bits per point)

Eb/N0 (dB)

comnum (functions)

OptiCommPy (numba)

Sionna

1.5

1.53e-2 [1.39e-2, 1.66e-2]

1.53e-2 [1.40e-2, 1.67e-2]

1.54e-2 [1.40e-2, 1.67e-2]

2

1.37e-3 [9.56e-4, 1.78e-3]

1.37e-3 [9.55e-4, 1.78e-3]

1.37e-3 [9.57e-4, 1.78e-3]

2.5

0 [0, 1.46e-6]

0 [0, 1.46e-6]

0 [0, 1.46e-6]

3

0 [0, 1.46e-6]

0 [0, 1.46e-6]

0 [0, 1.46e-6]

Frame error rate and its 95 % interval (2,000 frames per point)

Eb/N0 (dB)

comnum (functions)

OptiCommPy (numba)

Sionna

1.5

2.21e-1 [2.03e-1, 2.40e-1]

2.21e-1 [2.03e-1, 2.40e-1]

2.21e-1 [2.04e-1, 2.40e-1]

2

2.30e-2 [1.73e-2, 3.05e-2]

2.30e-2 [1.73e-2, 3.05e-2]

2.30e-2 [1.73e-2, 3.05e-2]

2.5

0 [0, 1.92e-3]

0 [0, 1.92e-3]

0 [0, 1.92e-3]

3

0 [0, 1.92e-3]

0 [0, 1.92e-3]

0 [0, 1.92e-3]

Same LLRs: the decisions frame by frame

The decoders received identical LLRs. Over all the points: the frames where a library makes as many bit errors as comnum, and the frames it decodes (or fails to decode) as comnum does.

Library

Points

Frames

Same bit errors

Same frame outcome

OptiCommPy (numba)

4

8000

7996 (99.95 %)

8000 (100.00 %)

Sionna

4

8000

7980 (99.75 %)

7999 (99.99 %)

convolutional (133, 171), soft Viterbi: decoder time

Median of 5 runs, ms per frame; workload: decoder only, 200 frames at Eb/N0 = 3.0 dB. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

Relative

First call

comnum (functions)

float64

0.276

0.277

1.00

0.000821 s

komm

float64

0.566

0.564

2.05

0.0188 s

Sionna

float32 (config.precision = ‘single’)

1.13

0.916

4.10

0.0305 s

5G NR LDPC (1024, 2048): decoder time

Median of 5 runs, ms per frame; workload: decoder only, 200 frames at Eb/N0 = 2.0 dB. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

Relative

First call

comnum (functions)

float64

1.70

1.68

1.00

0.00361 s

Sionna

float32 (config.precision = ‘single’)

7.74

4.23

4.54

0.0213 s

LDPC (1024, 512), same LLRs: decoder time

Median of 5 runs, ms per frame; workload: decoder only, 200 frames at Eb/N0 = 2.0 dB. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

Relative

First call

comnum (functions)

float64

0.316

0.323

1.00

0.000748 s

OptiCommPy (numba)

float32 (decodeLDPC default prec = np.float32)

1.47

4.53

4.66

1.47 s

OptiCommPy (no numba)

float32 (decodeLDPC default prec = np.float32)

42.4

42.0

134

0.082 s

Sionna

float32 (config.precision = ‘single’)

2.29

1.26

7.25

0.00648 s

convolutional (133, 171), soft Viterbi: whole-chain time

Median of 5 runs, ms per frame; workload: whole chain, 200 frames at Eb/N0 = 3.0 dB. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

Relative

First call

comnum (functions)

float64

0.325

0.325

1.00

0.000821 s

comnum.run_spec

float64

0.324

0.0513

0.997

0.0651 s

comnum binary (Rust)

float64

0.336

0.0688

1.03

0.0696 s

komm

float64

0.687

0.687

2.11

0.0188 s

Sionna

float32 (config.precision = ‘single’)

1.23

1.01

3.79

0.0305 s

5G NR LDPC (1024, 2048): whole-chain time

Median of 5 runs, ms per frame; workload: whole chain, 200 frames at Eb/N0 = 2.0 dB. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

Relative

First call

comnum (functions)

float64

1.75

1.74

1.00

0.00361 s

Sionna

float32 (config.precision = ‘single’)

7.84

4.28

4.48

0.0213 s

LDPC (1024, 512), same LLRs: whole-chain time

Median of 5 runs, ms per frame; workload: whole chain, 200 frames at Eb/N0 = 2.0 dB. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

Relative

First call

comnum (functions)

float64

0.356

0.363

1.00

0.000748 s

OptiCommPy (numba)

float32 (decodeLDPC default prec = np.float32)

1.44

4.31

4.04

1.47 s

OptiCommPy (no numba)

float32 (decodeLDPC default prec = np.float32)

37.0

36.5

104

0.082 s

Sionna

float32 (config.precision = ‘single’)

2.35

1.32

6.60

0.00648 s

The commands of every run
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s4_coding.py --lib comnum --threads 1 --work results/_work --out results/_work/s4_coding_comnum_1.json
 python s4_coding.py --lib comnum --threads all --work results/_work --out results/_work/s4_coding_comnum_all.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s4_coding.py --lib comnum-spec --threads 1 --work results/_work --out results/_work/s4_coding_comnum-spec_1.json
 python s4_coding.py --lib comnum-spec --threads all --work results/_work --out results/_work/s4_coding_comnum-spec_all.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s4_coding.py --lib comnum-rust --threads 1 --work results/_work --out results/_work/s4_coding_comnum-rust_1.json
 python s4_coding.py --lib comnum-rust --threads all --work results/_work --out results/_work/s4_coding_comnum-rust_all.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s4_coding.py --lib komm --threads 1 --work results/_work --out results/_work/s4_coding_komm_1.json
 python s4_coding.py --lib komm --threads all --work results/_work --out results/_work/s4_coding_komm_all.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s4_coding.py --lib opticommpy --threads 1 --work results/_work --out results/_work/s4_coding_opticommpy_1.json
 python s4_coding.py --lib opticommpy --threads all --work results/_work --out results/_work/s4_coding_opticommpy_all.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 NUMBA_DISABLE_JIT=1 python s4_coding.py --lib opticommpy-nojit --threads 1 --work results/_work --out results/_work/s4_coding_opticommpy-nojit_1.json --timing-only
NUMBA_DISABLE_JIT=1 python s4_coding.py --lib opticommpy-nojit --threads all --work results/_work --out results/_work/s4_coding_opticommpy-nojit_all.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s4_coding.py --lib sionna --threads 1 --work results/_work --out results/_work/s4_coding_sionna_1.json
 python s4_coding.py --lib sionna --threads all --work results/_work --out results/_work/s4_coding_sionna_all.json --timing-only

Split-step Fourier propagation, 16-QAM, 32 GBd, 10 x 80 km#

Scenario 5: single-polarisation split-step Fourier propagation of 32 GBd 16-QAM over 10 x 80 km of standard single-mode fibre, a fixed step of 1 km (80 steps per span), noiseless amplification at the end of every span (gain e^{alpha L}).

Parameters:

  • fibre: alpha = 0.2 dB/km, D = 17 ps/nm/km, gamma = 1.3 /W/km, fc = 193.1 THz

  • link: 10 x 80 km, ideal (noiseless) amplification after every span

  • step: 1 km (800 steps); reference: comnum at 0.05 km (16000 steps)

  • signal: 16-QAM, 32 GBd, RRC roll-off 0.1, 4 samples per symbol, 4 dBm; 2^14 symbols (quick: 2^11)

  • timing: one propagation over the 10 spans

Script: benchmarks/libraries/s5_ssfm.py.

The script
"""Scenario 5: single-polarisation split-step Fourier propagation of 32 GBd 16-QAM over
10 x 80 km of standard single-mode fibre, a fixed step of 1 km (80 steps per span),
noiseless amplification at the end of every span (gain e^{alpha L}).

The input field is the same for every library: 16-QAM symbols of comnum (seed 1),
shaped by a root-raised cosine (roll-off 0.1, 4 samples per symbol, 128 GS/s) and
scaled to 4 dBm; it is drawn again, identically, by every process. Every library
propagates it with its own split-step solver (symmetric steps, nonlinear phase over
the whole step in all three); run_all.py compares the output fields (NMSE) with that
of comnum in double precision and with a step-converged reference (comnum, 0.05 km).

Sionna's equation is the complex conjugate of the one of comnum and OptiCommPy
(dE/dz = ... + j beta_2/2 d^2E/dt^2 - j gamma |E|^2 E): its field is compared in their
convention, conj(sionna(conj(x))), and the NMSE of the plain output is reported too.
Sionna propagates one span per call (SSFM) and amplifies with its EDFA of noise figure
0 (no noise). OptiCommPy's ssfm is NumPy only (no numba kernel): its two variants run
the same code. In the mode gpu, OptiCommPy runs its CuPy solver
(optic.models.modelsGPU.ssfm: the same steps, every array on the device; the input is
copied to the device and the output back to the host inside the timed call) in
complex128 and in complex64. Timing: one propagation over the 10 spans, median of 5
runs after a warm-up run.

    python s5_ssfm.py --lib opticommpy --threads 1 [--quick] --out results/_work/s5.json
"""

from __future__ import annotations

import math
import time
from pathlib import Path

import common
import numpy as np

SCENARIO = "s5_ssfm"
TITLE = "Split-step Fourier propagation, 16-QAM, 32 GBd, 10 x 80 km"
CASES = {
    "ssfm": {
        "comnum": True,
        "komm": "no fibre model",
        "opticommpy": True,
        "opticommpy-nojit": True,
        "sionna": True,
    }
}
# The libraries with a GPU path in this scenario (run_all.py: the mode gpu).
GPU_LIBS = {"sionna", "opticommpy"}
ALPHA_DB_KM, D_PS_NM_KM, GAMMA, FC = 0.2, 17.0, 1.3, 193.1e12
SPAN_KM, N_SPANS, STEP_KM = 80.0, 10, 1.0
REF_STEP_KM = 0.05
SYMBOL_RATE, SPS = 32e9, 4
FS = SYMBOL_RATE * SPS
POWER_DBM = 4.0

PARAMETERS = {
    "fibre": "alpha = 0.2 dB/km, D = 17 ps/nm/km, gamma = 1.3 /W/km, fc = 193.1 THz",
    "link": "10 x 80 km, ideal (noiseless) amplification after every span",
    "step": "1 km (800 steps); reference: comnum at 0.05 km (16000 steps)",
    "signal": "16-QAM, 32 GBd, RRC roll-off 0.1, 4 samples per symbol, 4 dBm; 2^14 symbols (quick: 2^11)",
    "timing": "one propagation over the 10 spans",
}


def n_symbols(quick: bool) -> int:
    return 2**11 if quick else 2**14


def input_field(quick: bool) -> np.ndarray:
    import comnum

    rng = comnum.Rng(1)
    c = comnum.Constellation("qam", 16).normalized()
    _, s = comnum.symbols(c, n_symbols(quick), rng)
    h = comnum.pulse_shape("rrc", SPS, n_taps=1025, rolloff=0.1)
    x = comnum.pnorm(comnum.fir_filter(h, comnum.upsample(s, SPS)))
    return x * math.sqrt(comnum.dbm2w(POWER_DBM))


def beta2_ps2_km() -> float:
    c = 299_792_458.0
    lam = c / FC
    return -(D_PS_NM_KM * 1e-6) * lam**2 / (2 * math.pi * c) * 1e24 * 1e3  # s^2/m -> ps^2/km


def prop_comnum(precision: str, step_km: float = STEP_KM):
    import comnum

    f = comnum.FiberParams(
        alpha_db_km=ALPHA_DB_KM,
        d_ps_nm_km=D_PS_NM_KM,
        gamma_w_km=GAMMA,
        span_km=SPAN_KM,
        n_spans=N_SPANS,
        fc_hz=FC,
    )
    link = comnum.Ssfm(f, step_km=step_km, amplification="ideal")
    return lambda x: link.propagate(x, FS, precision=precision)


def prop_opticommpy(gpu: bool = False, prec=np.complex128):
    """OptiCommPy's split-step: the NumPy solver, or its CuPy copy on the GPU
    (``optic.models.modelsGPU.ssfm``, which takes and returns NumPy arrays: the
    transfers to and from the device are inside the timed call)."""
    if gpu:
        from optic.models.modelsGPU import ssfm
    else:
        from optic.models.channels import ssfm
    from optic.utils import parameters

    p = parameters()
    p.Ltotal, p.Lspan, p.hz = SPAN_KM * N_SPANS, SPAN_KM, STEP_KM
    p.alpha, p.D, p.gamma, p.Fc, p.Fs = ALPHA_DB_KM, D_PS_NM_KM, GAMMA, FC, FS
    p.amp, p.prgsBar, p.prec = "ideal", False, prec
    return lambda x: ssfm(x, p)


def prop_sionna(precision: str):
    import torch
    from sionna.phy.channel.optical import EDFA, SSFM

    alpha = ALPHA_DB_KM / (10 * math.log10(math.e))  # 1/km
    span = SSFM(
        alpha=alpha,
        beta_2=beta2_ps2_km(),
        f_c=FC,
        gamma=GAMMA,
        length=SPAN_KM,
        n_ssfm=round(SPAN_KM / STEP_KM),
        sample_duration=1e12 / FS,  # ps
        t_norm=1e-12,
        with_amplification=False,
        precision=precision,
    )
    amp = EDFA(g=math.exp(alpha * SPAN_KM), f=0.0, f_c=FC, dt=1 / FS, precision=precision)
    cdtype = torch.complex64 if precision == "single" else torch.complex128

    def run(x: np.ndarray) -> np.ndarray:
        with torch.inference_mode():
            u = torch.from_numpy(x).to(device=common.TORCH_DEVICE, dtype=cdtype)
            for _ in range(N_SPANS):
                u = amp(span(u))
            return common.to_numpy(u)

    return run


def run_lib(args, todo: list[str]) -> list[dict]:
    if args.lib == "sionna":
        threading = common.setup_torch(args.threads)
    elif args.lib == "opticommpy" and args.threads == "gpu":
        threading = common.setup_cupy()
    else:
        threading = {}
    x = input_field(args.quick)
    work = Path(args.work)
    work.mkdir(parents=True, exist_ok=True)
    if args.lib == "comnum":
        variants = {"f64": (prop_comnum("f64"), "float64"), "f32": (prop_comnum("f32"), "float32")}
    elif args.lib == "sionna":
        variants = {
            "single": (prop_sionna("single"), "float32 (precision = 'single', the default)"),
            "double": (prop_sionna("double"), "float64 (precision = 'double')"),
        }
    elif args.lib == "opticommpy" and args.threads == "gpu":
        import cupy as cp

        variants = {
            "f64": (prop_opticommpy(True, cp.complex128), "float64 (prec = cp.complex128)"),
            "f32": (prop_opticommpy(True, cp.complex64), "float32 (prec = cp.complex64)"),
        }
    else:
        variants = {"f64": (prop_opticommpy(), "float64 (prec = np.complex128)")}
    out = []
    for name, (prop, precision) in variants.items():
        res = {
            "case": "ssfm",
            "variant": name,
            "precision": precision,
            "samples": int(x.size),
            **threading,
        }
        if args.lib == "sionna":
            fwd = lambda prop=prop: np.conj(prop(np.conj(x)))  # noqa: E731
        else:
            fwd = lambda prop=prop: prop(x)  # noqa: E731
        t0 = time.perf_counter()
        y = fwd()
        res["first_call_s"] = time.perf_counter() - t0
        if not args.timing_only:
            np.save(work / f"{SCENARIO}_{args.lib}_{name}.npy", np.asarray(y, dtype=np.complex128))
            if args.lib == "sionna":
                np.save(
                    work / f"{SCENARIO}_{args.lib}_{name}_plain.npy",
                    np.asarray(prop(x), dtype=np.complex128),
                )
        res["timing"] = common.time_it(fwd)
        res["timing"].update(
            units=1,
            unit="propagation",
            workload=f"{x.size} samples, {N_SPANS} x {SPAN_KM:g} km, {STEP_KM:g} km steps",
        )
        out.append(res)
    if args.lib == "comnum" and not args.timing_only:
        t0 = time.perf_counter()
        np.save(work / f"{SCENARIO}_reference.npy", prop_comnum("f64", REF_STEP_KM)(x))
        out.append(
            {
                "case": "ssfm",
                "variant": "reference",
                "precision": "float64",
                "step_km": REF_STEP_KM,
                "wall_s": time.perf_counter() - t0,
            }
        )
    return out


def nmse(a: np.ndarray, b: np.ndarray) -> float:
    return float(np.sum(np.abs(a - b) ** 2) / np.sum(np.abs(b) ** 2))


def postprocess(merged: dict, work: Path) -> dict:
    """The NMSE of every output field against comnum (f64, 1 km) and the reference."""
    fields = {}
    for path in sorted(work.glob(f"{SCENARIO}_*.npy")):
        fields[path.stem.removeprefix(f"{SCENARIO}_")] = np.load(path)
    base, ref = fields.get("comnum_f64"), fields.get("reference")
    rows = []
    for name, y in fields.items():
        if name == "reference":
            continue
        rows.append(
            {
                "field": name,
                "nmse_vs_comnum_f64": nmse(y, base) if base is not None else None,
                "nmse_vs_reference": nmse(y, ref) if ref is not None else None,
            }
        )
    return {"nmse": rows, "reference_step_km": REF_STEP_KM}


RUNNERS = {"comnum": run_lib, "opticommpy": run_lib, "opticommpy-nojit": run_lib, "sionna": run_lib}

if __name__ == "__main__":
    common.main(SCENARIO, CASES, RUNNERS)

Support

Case

comnum (functions)

komm

OptiCommPy (numba)

OptiCommPy (no numba)

Sionna

split-step Fourier

yes

not available: no fibre model

yes

yes

yes

Output fields

NMSE of the output field, sum |a - b|^2 / sum |b|^2, against comnum in float64 at the same step and against the step-converged reference (comnum, 0.05 km). The NMSE against the reference is the error of the 1 km step itself.

Field

NMSE vs comnum (float64, 1 km)

NMSE vs reference

comnum, float32

6.35e-9

6.14e-8

comnum, float64

0

3.69e-8

OptiCommPy, float64

5.72e-26

3.69e-8

Sionna, float64

4.83e-26

3.69e-8

Sionna, float64, its own convention (not conjugated)

2.03e0

2.03e0

Sionna, float32

1.02e-8

7.34e-8

Sionna, float32, its own convention (not conjugated)

2.03e0

2.03e0

split-step Fourier: time

Median of 5 runs, s per propagation; workload: 65536 samples, 10 x 80 km, 1 km steps. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

Relative

First call

comnum (functions), f64

float64

0.556

0.554

1.00

0.561 s

comnum (functions), f32

float32

0.365

0.364

0.656

0.365 s

OptiCommPy (numba), f64

float64 (prec = np.complex128)

1.28

1.28

2.31

1.29 s

OptiCommPy (no numba), f64

float64 (prec = np.complex128)

1.28

1.28

2.30

1.29 s

Sionna, single

float32 (precision = ‘single’, the default)

1.00

0.872

1.80

0.999 s

Sionna, double

float64 (precision = ‘double’)

1.30

1.11

2.34

1.29 s

The commands of every run
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s5_ssfm.py --lib comnum --threads 1 --work results/_work --out results/_work/s5_ssfm_comnum_1.json
 python s5_ssfm.py --lib comnum --threads all --work results/_work --out results/_work/s5_ssfm_comnum_all.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s5_ssfm.py --lib opticommpy --threads 1 --work results/_work --out results/_work/s5_ssfm_opticommpy_1.json
 python s5_ssfm.py --lib opticommpy --threads all --work results/_work --out results/_work/s5_ssfm_opticommpy_all.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 NUMBA_DISABLE_JIT=1 python s5_ssfm.py --lib opticommpy-nojit --threads 1 --work results/_work --out results/_work/s5_ssfm_opticommpy-nojit_1.json --timing-only
NUMBA_DISABLE_JIT=1 python s5_ssfm.py --lib opticommpy-nojit --threads all --work results/_work --out results/_work/s5_ssfm_opticommpy-nojit_all.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s5_ssfm.py --lib sionna --threads 1 --work results/_work --out results/_work/s5_ssfm_sionna_1.json
 python s5_ssfm.py --lib sionna --threads all --work results/_work --out results/_work/s5_ssfm_sionna_all.json --timing-only

On another machine: h100 (NVIDIA H100 80GB HBM3)#

The same scripts, run on a second machine (run_all.py --results results/h100): Sionna (and, for the split-step, OptiCommPy) on its GPU in the column GPU, and every library on its CPU in the other columns. The Relative column stays the 1-thread time over that of comnum (functions) on 1 thread of this machine.

This machine is a RunPod Secure Cloud pod with one NVIDIA H100 SXM (80 GB HBM3), the PyTorch template (torch 2.13, CUDA 13.0): a container with a CPU quota on a shared host.

Machine and versions#

CPU

Intel(R) Xeon(R) Platinum 8480+

Cores

224 logical; 24 available to the process (a container’s CPU quota: the thread pools of the mode all threads are sized to it)

Memory

2015.3 GB

GPU

NVIDIA H100 80GB HBM3, 81559 MiB, driver 595.91.07

System

Ubuntu 24.04.5 LTS

Python

3.12.3

Rust

—

comnum commit

45a7c78f80fc

Generated

2026-11-06T17:48:24+00:00

comnum

0.3.1

komm

not installed

OptiCommPy

0.11.0

sionna

2.2.0

torch

2.13.0+cu130

numba

0.68.1

numpy

2.5.2

scipy

1.18.1

Uncoded QPSK and 16-QAM over AWGN#

Scenario 1: uncoded Gray QPSK and 16-QAM over AWGN, BER against the exact closed form.

Parameters:

  • Eb/N0 (dB): 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10

  • bits per point: 10^7 (quick: 2·10^5), in blocks of 10^6 (quick: 10^5)

  • timing: one block of 10^6 bits (quick: 2·10^5) at Eb/N0 = 6 dB

  • theory: exact BER of Gray square QAM, Cho & Yoon, IEEE Trans. Commun. 50(7), 2002

Script: benchmarks/libraries/s1_awgn.py.

The script
"""Scenario 1: uncoded Gray QPSK and 16-QAM over AWGN, BER against the exact closed form.

Every library draws its own bits and noise, maps with its own Gray mapper, adds the
noise of its own channel for the Eb/N0 of the point and decides by minimum distance
(Sionna: hard decisions of max-log LLRs, the same decisions). The BER is counted on
the same number of bits per point for every library, in blocks of the same size.
Timing: one block of ``TIMING_BITS`` bits at Eb/N0 = 6 dB (bits, mapping, noise,
decisions, error count), median of 5 runs after a warm-up run.

    python s1_awgn.py --lib comnum --threads 1 [--quick] --out results/_work/s1.json
"""

from __future__ import annotations

import math
import time

import common
import numpy as np
import theory

SCENARIO = "s1_awgn"
TITLE = "Uncoded QPSK and 16-QAM over AWGN"
ORDERS = {"qpsk": 4, "16qam": 16}
ALL = {lib: True for lib in common.LIBRARIES}
CASES = {"qpsk": ALL, "16qam": ALL}
EBN0 = [float(e) for e in range(11)]
TIMING_EBN0 = 6.0
TIMING_TRIALS = 40  # trials of the specification runner per timing run


def sizes(quick: bool) -> dict:
    if quick:
        return {"bits_per_point": 200_000, "block": 100_000, "timing_bits": 200_000, "trials": 10}
    return {
        "bits_per_point": 10_000_000,
        "block": 1_000_000,
        "timing_bits": 1_000_000,
        "trials": 100,
    }


PARAMETERS = {
    "ebn0_db": EBN0,
    "bits_per_point": "10^7 (quick: 2·10^5), in blocks of 10^6 (quick: 10^5)",
    "timing": "one block of 10^6 bits (quick: 2·10^5) at Eb/N0 = 6 dB",
    "theory": "exact BER of Gray square QAM, Cho & Yoon, IEEE Trans. Commun. 50(7), 2002",
}


# ---------------------------------------------------------------------------- chains
# chain(n_bits, ebn0_db) -> bit errors, for one library and one constellation


def chain_comnum(m: int, seed: int):
    import comnum

    c = comnum.Constellation("qam", m).normalized()
    k = int(math.log2(m))
    rng = comnum.Rng(seed)

    def run(n: int, ebn0: float) -> int:
        b = comnum.bits(n, rng)
        y = comnum.awgn_ebn0(c.map_bits(b), ebn0, k, rng)
        return int(np.count_nonzero(c.demap_bits(y) != b))

    return run, "float64"


def chain_komm(m: int, seed: int):
    import komm

    const = komm.QAMConstellation(m)
    lab = komm.ReflectedRectangularLabeling(int(math.log2(m)))
    k = int(math.log2(m))
    es = const.mean_energy()
    rng = np.random.default_rng(seed)

    def run(n: int, ebn0: float) -> int:
        b = rng.integers(0, 2, n)
        x = const.indices_to_symbols(lab.bits_to_indices(b))
        n0 = es / (k * 10 ** (ebn0 / 10))
        y = komm.GaussianChannel(n0, rng=rng).transmit(x)
        bh = lab.indices_to_bits(const.closest_indices(y))
        return int(np.count_nonzero(bh.ravel() != b))

    return run, "float64"


def chain_opticommpy(m: int, seed: int):
    from optic.comm.modulation import demodulateGray, modulateGray
    from optic.models.channels import awgn
    from optic.utils import parameters

    k = int(math.log2(m))
    rng = np.random.default_rng(seed)
    state = {"block": 0}

    def run(n: int, ebn0: float) -> int:
        b = rng.integers(0, 2, n)
        x = modulateGray(b, m, "qam")
        p = parameters()
        p.snr = ebn0 + 10 * math.log10(k)  # Es/N0 (Fs = B = 1)
        p.seed = seed * 1_000_003 + state["block"]  # awgn re-seeds its generator per call
        state["block"] += 1
        y = awgn(x, p)
        bh = demodulateGray(y, m, "qam")
        return int(np.count_nonzero(bh != b))

    return run, "complex64 symbols (its Gray constellation), float64 noise"


def chain_sionna(m: int, seed: int):
    import torch
    from sionna.phy import config
    from sionna.phy.channel import AWGN
    from sionna.phy.mapping import BinarySource, Demapper, Mapper
    from sionna.phy.utils import ebnodb2no

    config.seed = seed
    k = int(math.log2(m))
    source, mapper, channel = BinarySource(), Mapper("qam", k), AWGN()
    demapper = Demapper("maxlog", "qam", k, hard_out=True)

    def run(n: int, ebn0: float) -> int:
        with torch.inference_mode():
            b = source([1, n])
            no = ebnodb2no(ebn0, k, 1.0)
            y = channel(mapper(b), no)
            bh = demapper(y, no)
            return int((bh != b).sum())

    return run, "float32 (config.precision = 'single')"


CHAINS = {
    "comnum": chain_comnum,
    "komm": chain_komm,
    "opticommpy": chain_opticommpy,
    "opticommpy-nojit": chain_opticommpy,
    "sionna": chain_sionna,
}


def run_chains(args, todo: list[str]) -> list[dict]:
    if args.lib == "sionna":
        threading = common.setup_torch(args.threads)
    else:
        threading = {}
    sz = sizes(args.quick)
    out = []
    for case in todo:
        m = ORDERS[case]
        run, precision = CHAINS[args.lib](m, args.seed)
        res = {"case": case, "precision": precision, **threading}
        n = sz["timing_bits"]
        if args.lib == "opticommpy-nojit":
            n = min(n, 100_000)  # plain-Python kernels: a smaller block, the time is per bit
        t0 = time.perf_counter()
        run(n, TIMING_EBN0)  # the first call: JIT compilation (numba), lazy initialisation
        res["first_call_s"] = time.perf_counter() - t0
        if not args.timing_only:
            pts = []
            t0 = time.perf_counter()
            for e in EBN0:
                errors = 0
                for _ in range(sz["bits_per_point"] // sz["block"]):
                    errors += run(sz["block"], e)
                pts.append(
                    common.rate_point(
                        errors, sz["bits_per_point"], float(theory.ber_qam_awgn(m, e)), ebn0_db=e
                    )
                )
            res["points"] = pts
            res["sweep_s"] = time.perf_counter() - t0
        last = {}
        res["timing"] = common.time_it(
            lambda n=n, run=run, last=last: last.__setitem__("e", run(n, TIMING_EBN0))
        )
        res["timing"].update(units=n, unit="bit", workload=f"{n} bits at Eb/N0 = {TIMING_EBN0} dB")
        res["timing_ber"] = last["e"] / n
        out.append(res)
    return out


# ------------------------------------------------------------- specification runner


def spec(m: int, ebn0: list[float], bits: int, trials: int, seed: int) -> dict:
    k = int(math.log2(m))
    return {
        "name": f"bench_awgn_{m}qam",
        "seed": seed,
        "batch": trials,
        "grid": {"ebn0_db": ebn0},
        "link": [
            {
                "type": "source",
                "constellation": {"kind": "qam", "m": m},
                "n_symbols": bits // (k * trials),
            },
            {"type": "awgn_ebn0", "ebn0_db": "$ebn0_db"},
        ],
        "metrics": {"report": ["ber"], "scale": "none"},
        "stop": {"min_trials": trials, "max_trials": trials},
    }


def spec_points(results: list[dict], m: int) -> list[dict]:
    pts = []
    for r in results:
        e = float(r["condition"]["ebn0_db"])
        ber = r["metrics"]["ber"]
        errors = ber["count"] if "count" in ber else ber["errors"]  # run_spec / the binary's report
        pts.append(
            common.rate_point(errors, ber["total"], float(theory.ber_qam_awgn(m, e)), ebn0_db=e)
        )
    return pts


def run_spec_lib(args, todo: list[str], *, rust: bool = False) -> list[dict]:
    """``comnum-spec`` (``comnum.run_spec``), or with ``rust`` ``comnum-rust`` (the binary)."""
    sz = sizes(args.quick)
    n = sz["timing_bits"]
    workload = f"{n} bits at Eb/N0 = {TIMING_EBN0} dB, {TIMING_TRIALS} trials"
    if rust:
        workload += " (process wall time, start-up included)"
    out = []
    for case in todo:
        m = ORDERS[case]
        out.append(
            common.spec_case(
                args,
                {"case": case, "precision": "float64"},
                spec(m, EBN0, sz["bits_per_point"], sz["trials"], args.seed),
                spec(m, [TIMING_EBN0], n, TIMING_TRIALS, args.seed + 1),
                lambda rows, m=m: spec_points(rows, m),
                rust=rust,
                units=n,
                unit="bit",
                workload=workload,
            )
        )
    return out


def run_rust_lib(args, todo: list[str]) -> list[dict]:
    return run_spec_lib(args, todo, rust=True)


RUNNERS = {
    **{lib: run_chains for lib in CHAINS},
    "comnum-spec": run_spec_lib,
    "comnum-rust": run_rust_lib,
}

if __name__ == "__main__":
    common.main(SCENARIO, CASES, RUNNERS)

Support

Case

comnum (functions)

comnum.run_spec

OptiCommPy (numba)

Sionna

QPSK

yes

yes

yes

yes

16-QAM

yes

yes

yes

yes

QPSK: error rates

Library

Precision

Against

max abs(z), BER

Agrees

BER interval

comnum (functions)

float64

theory

1.90

yes

binomial

comnum.run_spec

float64

theory

2.53

yes

binomial

OptiCommPy (numba)

complex64 symbols (its Gray constellation), float64 noise

theory

3.31

yes

binomial

Sionna

float32 (config.precision = ‘single’)

theory

2.13

yes

binomial

BER and its 95 % interval, every point (10,000,000 bits per point)

Eb/N0 (dB)

theory

comnum (functions)

comnum.run_spec

OptiCommPy (numba)

Sionna

0

7.86e-2

7.86e-2 [7.84e-2, 7.88e-2]

7.87e-2 [7.85e-2, 7.89e-2]

7.86e-2 [7.84e-2, 7.88e-2]

7.86e-2 [7.84e-2, 7.88e-2]

1

5.63e-2

5.63e-2 [5.62e-2, 5.65e-2]

5.63e-2 [5.61e-2, 5.64e-2]

5.63e-2 [5.61e-2, 5.64e-2]

5.61e-2 [5.60e-2, 5.63e-2]

2

3.75e-2

3.75e-2 [3.74e-2, 3.76e-2]

3.74e-2 [3.73e-2, 3.76e-2]

3.75e-2 [3.73e-2, 3.76e-2]

3.76e-2 [3.75e-2, 3.77e-2]

3

2.29e-2

2.28e-2 [2.27e-2, 2.29e-2]

2.30e-2 [2.29e-2, 2.31e-2]

2.29e-2 [2.28e-2, 2.30e-2]

2.29e-2 [2.28e-2, 2.30e-2]

4

1.25e-2

1.25e-2 [1.25e-2, 1.26e-2]

1.25e-2 [1.24e-2, 1.26e-2]

1.25e-2 [1.24e-2, 1.26e-2]

1.25e-2 [1.25e-2, 1.26e-2]

5

5.95e-3

5.95e-3 [5.90e-3, 6.00e-3]

5.96e-3 [5.92e-3, 6.01e-3]

5.93e-3 [5.89e-3, 5.98e-3]

5.93e-3 [5.88e-3, 5.98e-3]

6

2.39e-3

2.39e-3 [2.36e-3, 2.42e-3]

2.41e-3 [2.38e-3, 2.44e-3]

2.43e-3 [2.40e-3, 2.46e-3]

2.39e-3 [2.36e-3, 2.42e-3]

7

7.73e-4

7.56e-4 [7.39e-4, 7.73e-4]

7.77e-4 [7.60e-4, 7.95e-4]

8.02e-4 [7.84e-4, 8.20e-4]

7.88e-4 [7.70e-4, 8.05e-4]

8

1.91e-4

1.84e-4 [1.76e-4, 1.92e-4]

1.93e-4 [1.84e-4, 2.01e-4]

1.90e-4 [1.82e-4, 1.99e-4]

1.82e-4 [1.73e-4, 1.90e-4]

9

3.36e-5

3.21e-5 [2.88e-5, 3.58e-5]

3.41e-5 [3.07e-5, 3.79e-5]

3.42e-5 [3.08e-5, 3.80e-5]

3.57e-5 [3.22e-5, 3.96e-5]

10

3.87e-6

4.00e-6 [2.94e-6, 5.45e-6]

3.20e-6 [2.27e-6, 4.52e-6]

4.10e-6 [3.02e-6, 5.56e-6]

3.70e-6 [2.68e-6, 5.10e-6]

16-QAM: error rates

Library

Precision

Against

max abs(z), BER

Agrees

BER interval

comnum (functions)

float64

theory

2.15

yes

binomial

comnum.run_spec

float64

theory

1.64

yes

binomial

OptiCommPy (numba)

complex64 symbols (its Gray constellation), float64 noise

theory

1.42

yes

binomial

Sionna

float32 (config.precision = ‘single’)

theory

2.41

yes

binomial

BER and its 95 % interval, every point (10,000,000 bits per point)

Eb/N0 (dB)

theory

comnum (functions)

comnum.run_spec

OptiCommPy (numba)

Sionna

0

1.41e-1

1.41e-1 [1.41e-1, 1.41e-1]

1.41e-1 [1.41e-1, 1.41e-1]

1.41e-1 [1.41e-1, 1.41e-1]

1.41e-1 [1.41e-1, 1.41e-1]

1

1.19e-1

1.19e-1 [1.19e-1, 1.19e-1]

1.19e-1 [1.19e-1, 1.19e-1]

1.19e-1 [1.19e-1, 1.19e-1]

1.19e-1 [1.19e-1, 1.19e-1]

2

9.77e-2

9.78e-2 [9.76e-2, 9.80e-2]

9.77e-2 [9.75e-2, 9.79e-2]

9.78e-2 [9.76e-2, 9.80e-2]

9.75e-2 [9.73e-2, 9.77e-2]

3

7.75e-2

7.75e-2 [7.74e-2, 7.77e-2]

7.74e-2 [7.73e-2, 7.76e-2]

7.75e-2 [7.73e-2, 7.76e-2]

7.74e-2 [7.73e-2, 7.76e-2]

4

5.86e-2

5.87e-2 [5.85e-2, 5.88e-2]

5.86e-2 [5.84e-2, 5.87e-2]

5.85e-2 [5.84e-2, 5.87e-2]

5.85e-2 [5.84e-2, 5.87e-2]

5

4.19e-2

4.18e-2 [4.17e-2, 4.19e-2]

4.19e-2 [4.17e-2, 4.20e-2]

4.20e-2 [4.19e-2, 4.21e-2]

4.20e-2 [4.19e-2, 4.21e-2]

6

2.79e-2

2.79e-2 [2.78e-2, 2.80e-2]

2.79e-2 [2.78e-2, 2.80e-2]

2.78e-2 [2.77e-2, 2.79e-2]

2.79e-2 [2.78e-2, 2.81e-2]

7

1.70e-2

1.70e-2 [1.69e-2, 1.71e-2]

1.70e-2 [1.69e-2, 1.71e-2]

1.70e-2 [1.69e-2, 1.70e-2]

1.70e-2 [1.69e-2, 1.71e-2]

8

9.25e-3

9.24e-3 [9.18e-3, 9.30e-3]

9.24e-3 [9.18e-3, 9.30e-3]

9.22e-3 [9.16e-3, 9.28e-3]

9.24e-3 [9.19e-3, 9.30e-3]

9

4.39e-3

4.35e-3 [4.30e-3, 4.39e-3]

4.42e-3 [4.38e-3, 4.47e-3]

4.40e-3 [4.36e-3, 4.44e-3]

4.37e-3 [4.33e-3, 4.41e-3]

10

1.75e-3

1.75e-3 [1.73e-3, 1.78e-3]

1.78e-3 [1.75e-3, 1.80e-3]

1.76e-3 [1.73e-3, 1.79e-3]

1.76e-3 [1.73e-3, 1.78e-3]

QPSK: time

Median of 5 runs, ns per bit; workload: 1000000 bits at Eb/N0 = 6.0 dB. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

GPU

Relative

First call

comnum (functions)

float64

36.1

36.9

—

1.00

0.0419 s

comnum.run_spec

float64

34.0

6.32

—

0.942

0.0345 s

OptiCommPy (numba)

complex64 symbols (its Gray constellation), float64 noise

86.7

50.3

—

2.40

2.94 s

Sionna

float32 (config.precision = ‘single’)

98.2

17.7

0.548

2.72

0.116 s

16-QAM: time

Median of 5 runs, ns per bit; workload: 1000000 bits at Eb/N0 = 6.0 dB. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

GPU

Relative

First call

comnum (functions)

float64

19.5

20.0

—

1.00

0.0205 s

comnum.run_spec

float64

19.9

4.25

—

1.02

0.0219 s

OptiCommPy (numba)

complex64 symbols (its Gray constellation), float64 noise

87.5

33.7

—

4.48

0.0959 s

Sionna

float32 (config.precision = ‘single’)

172

23.1

0.702

8.82

0.206 s

The commands of every run
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s1_awgn.py --lib comnum --threads 1 --work h100/_work --out h100/_work/s1_awgn_comnum_1.json
OMP_NUM_THREADS=24 OPENBLAS_NUM_THREADS=24 MKL_NUM_THREADS=24 VECLIB_MAXIMUM_THREADS=24 NUMBA_NUM_THREADS=24 RAYON_NUM_THREADS=24 python s1_awgn.py --lib comnum --threads all --work h100/_work --out h100/_work/s1_awgn_comnum_all.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s1_awgn.py --lib comnum-spec --threads 1 --work h100/_work --out h100/_work/s1_awgn_comnum-spec_1.json
OMP_NUM_THREADS=24 OPENBLAS_NUM_THREADS=24 MKL_NUM_THREADS=24 VECLIB_MAXIMUM_THREADS=24 NUMBA_NUM_THREADS=24 RAYON_NUM_THREADS=24 python s1_awgn.py --lib comnum-spec --threads all --work h100/_work --out h100/_work/s1_awgn_comnum-spec_all.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s1_awgn.py --lib opticommpy --threads 1 --work h100/_work --out h100/_work/s1_awgn_opticommpy_1.json
OMP_NUM_THREADS=24 OPENBLAS_NUM_THREADS=24 MKL_NUM_THREADS=24 VECLIB_MAXIMUM_THREADS=24 NUMBA_NUM_THREADS=24 RAYON_NUM_THREADS=24 python s1_awgn.py --lib opticommpy --threads all --work h100/_work --out h100/_work/s1_awgn_opticommpy_all.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s1_awgn.py --lib sionna --threads 1 --work h100/_work --out h100/_work/s1_awgn_sionna_1.json
OMP_NUM_THREADS=24 OPENBLAS_NUM_THREADS=24 MKL_NUM_THREADS=24 VECLIB_MAXIMUM_THREADS=24 NUMBA_NUM_THREADS=24 RAYON_NUM_THREADS=24 python s1_awgn.py --lib sionna --threads all --work h100/_work --out h100/_work/s1_awgn_sionna_all.json --timing-only
OMP_NUM_THREADS=24 OPENBLAS_NUM_THREADS=24 MKL_NUM_THREADS=24 VECLIB_MAXIMUM_THREADS=24 NUMBA_NUM_THREADS=24 RAYON_NUM_THREADS=24 python s1_awgn.py --lib sionna --threads gpu --work h100/_work --out h100/_work/s1_awgn_sionna_gpu.json --timing-only

BPSK and QPSK over flat Rayleigh fading#

Scenario 2: BPSK and Gray QPSK over flat Rayleigh fading (one coefficient per symbol, perfectly known at the receiver), BER against the closed form.

Parameters:

  • Eb/N0 (dB): 0, 5, 10, 15, 20, 25, 30

  • bits per point: 10^7 (quick: 2·10^5), in blocks of 10^6 (quick: 10^5)

  • channel: y = h x + w, h ~ CN(0, 1) independent for every symbol, known at the receiver

  • timing: one block of 10^6 bits (quick: 2·10^5) at Eb/N0 = 10 dB

  • theory: BPSK: (1 - sqrt(G / (1 + G))) / 2; Gray QPSK: the same per bit (Proakis & Salehi, 5th ed., Sec. 13.3)

Script: benchmarks/libraries/s2_rayleigh.py.

The script
"""Scenario 2: BPSK and Gray QPSK over flat Rayleigh fading (one coefficient per
symbol, perfectly known at the receiver), BER against the closed form.

Every library draws its own bits, channel coefficients h ~ CN(0, 1) and noise, and
detects with its own coherent detector given h (zero forcing over the 1 x 1 channel:
comnum.mimo_detect, Sionna's LinearDetector, the mimo_rx block of the specification).
komm and OptiCommPy have no fading channel. Timing: one block of ``TIMING_BITS`` bits
at Eb/N0 = 10 dB, median of 5 runs after a warm-up run.

    python s2_rayleigh.py --lib sionna --threads 1 [--quick] --out results/_work/s2.json
"""

from __future__ import annotations

import math
import time

import common
import numpy as np
import theory

SCENARIO = "s2_rayleigh"
TITLE = "BPSK and QPSK over flat Rayleigh fading"
ORDERS = {"bpsk": 2, "qpsk": 4}
SUPPORT = {
    "comnum": True,
    "comnum-spec": True,
    "komm": "no fading channel",
    "opticommpy": "no fading channel",
    "opticommpy-nojit": "no fading channel",
    "sionna": True,
}
CASES = {"bpsk": SUPPORT, "qpsk": SUPPORT}
EBN0 = [0.0, 5.0, 10.0, 15.0, 20.0, 25.0, 30.0]
TIMING_EBN0 = 10.0
TIMING_TRIALS = 40

PARAMETERS = {
    "ebn0_db": EBN0,
    "bits_per_point": "10^7 (quick: 2·10^5), in blocks of 10^6 (quick: 10^5)",
    "channel": "y = h x + w, h ~ CN(0, 1) independent for every symbol, known at the receiver",
    "timing": "one block of 10^6 bits (quick: 2·10^5) at Eb/N0 = 10 dB",
    "theory": "BPSK: (1 - sqrt(G / (1 + G))) / 2; Gray QPSK: the same per bit (Proakis & Salehi, 5th ed., Sec. 13.3)",
}


def sizes(quick: bool) -> dict:
    if quick:
        return {"bits_per_point": 200_000, "block": 100_000, "timing_bits": 200_000, "trials": 10}
    return {
        "bits_per_point": 10_000_000,
        "block": 1_000_000,
        "timing_bits": 1_000_000,
        "trials": 100,
    }


def chain_comnum(m: int, seed: int):
    import comnum

    c = comnum.Constellation("psk" if m == 2 else "qam", m).normalized()
    k = int(math.log2(m))
    rng = comnum.Rng(seed)

    def run(n: int, ebn0: float) -> int:
        b = comnum.bits(n, rng)
        x = c.map_bits(b)
        h = comnum.mimo_channels(x.size, 1, 1, rng)
        n0 = 1.0 / (k * 10 ** (ebn0 / 10))
        y = h[:, 0, 0] * x + rng.complex_noise(x.size, n0)
        labels = comnum.mimo_detect(h, y[None, :], c, n0, "zf")
        bh = comnum.unpack_symbols(labels.ravel(), k)
        return int(np.count_nonzero(bh != b))

    return run, "float64"


def chain_sionna(m: int, seed: int):
    import torch
    from sionna.phy import config
    from sionna.phy.channel import FlatFadingChannel
    from sionna.phy.mapping import BinarySource, Mapper
    from sionna.phy.mimo import LinearDetector
    from sionna.phy.utils import ebnodb2no

    config.seed = seed
    k = int(math.log2(m))
    kind = "pam" if m == 2 else "qam"
    source, mapper = BinarySource(), Mapper(kind, k)
    channel = FlatFadingChannel(1, 1, return_channel=True)
    detector = LinearDetector(
        "zf", "bit", "maxlog", constellation_type=kind, num_bits_per_symbol=k, hard_out=True
    )

    def run(n: int, ebn0: float) -> int:
        with torch.inference_mode():
            b = source([n // k, k])
            x = mapper(b)  # [n/k, 1]
            no = ebnodb2no(ebn0, k, 1.0)
            y, h = channel(x, no)
            s = torch.full((n // k, 1, 1), float(no), dtype=y.dtype)
            bh = detector(y, h, s)  # [n/k, 1, k]
            return int((bh.reshape(b.shape) != b).sum())

    return run, "float32 (config.precision = 'single')"


CHAINS = {"comnum": chain_comnum, "sionna": chain_sionna}


def run_chains(args, todo: list[str]) -> list[dict]:
    threading = common.setup_torch(args.threads) if args.lib == "sionna" else {}
    sz = sizes(args.quick)
    out = []
    for case in todo:
        m = ORDERS[case]
        run, precision = CHAINS[args.lib](m, args.seed)
        res = {"case": case, "precision": precision, **threading}
        n = sz["timing_bits"]
        t0 = time.perf_counter()
        run(n, TIMING_EBN0)
        res["first_call_s"] = time.perf_counter() - t0
        if not args.timing_only:
            pts = []
            t0 = time.perf_counter()
            for e in EBN0:
                errors = sum(
                    run(sz["block"], e) for _ in range(sz["bits_per_point"] // sz["block"])
                )
                pts.append(
                    common.rate_point(
                        errors,
                        sz["bits_per_point"],
                        float(theory.ber_qam_rayleigh(m, e)),
                        ebn0_db=e,
                    )
                )
            res["points"] = pts
            res["sweep_s"] = time.perf_counter() - t0
        res["timing"] = common.time_it(lambda n=n, run=run: run(n, TIMING_EBN0))
        res["timing"].update(units=n, unit="bit", workload=f"{n} bits at Eb/N0 = {TIMING_EBN0} dB")
        out.append(res)
    return out


def spec(m: int, ebn0: list[float], bits: int, trials: int, seed: int) -> dict:
    k = int(math.log2(m))
    return {
        "name": f"bench_rayleigh_{m}",
        "seed": seed,
        "batch": trials,
        "grid": {"ebn0_db": ebn0},
        "link": [
            {
                "type": "source",
                "constellation": {"kind": "psk" if m == 2 else "qam", "m": m},
                "n_symbols": bits // (k * trials),
            },
            {"type": "rayleigh", "n_rx": 1, "coherence_samples": 1},
            {"type": "awgn_ebn0", "ebn0_db": "$ebn0_db"},
            {"type": "mimo_rx", "detector": "zf"},
        ],
        "metrics": {"report": ["ber"], "scale": "none"},
        "stop": {"min_trials": trials, "max_trials": trials},
    }


def spec_points(rows: list[dict], m: int) -> list[dict]:
    pts = []
    for row in rows:
        e = float(row["condition"]["ebn0_db"])
        ber = row["metrics"]["ber"]
        pts.append(
            common.rate_point(
                ber["count"], ber["total"], float(theory.ber_qam_rayleigh(m, e)), ebn0_db=e
            )
        )
    return pts


def run_spec_lib(args, todo: list[str]) -> list[dict]:
    sz = sizes(args.quick)
    n = sz["timing_bits"]
    out = []
    for case in todo:
        m = ORDERS[case]
        out.append(
            common.spec_case(
                args,
                {"case": case, "precision": "float64"},
                spec(m, EBN0, sz["bits_per_point"], sz["trials"], args.seed),
                spec(m, [TIMING_EBN0], n, TIMING_TRIALS, args.seed + 1),
                lambda rows, m=m: spec_points(rows, m),
                units=n,
                unit="bit",
                workload=f"{n} bits at Eb/N0 = {TIMING_EBN0} dB, {TIMING_TRIALS} trials",
            )
        )
    return out


RUNNERS = {"comnum": run_chains, "sionna": run_chains, "comnum-spec": run_spec_lib}

if __name__ == "__main__":
    common.main(SCENARIO, CASES, RUNNERS)

Support

Case

comnum (functions)

comnum.run_spec

OptiCommPy (numba)

Sionna

BPSK

yes

yes

not available: no fading channel

yes

QPSK

yes

yes

not available: no fading channel

yes

BPSK: error rates

Library

Precision

Against

max abs(z), BER

Agrees

BER interval

comnum (functions)

float64

theory

2.22

yes

binomial

comnum.run_spec

float64

theory

1.78

yes

binomial

Sionna

float32 (config.precision = ‘single’)

theory

2.35

yes

binomial

BER and its 95 % interval, every point (10,000,000 bits per point)

Eb/N0 (dB)

theory

comnum (functions)

comnum.run_spec

Sionna

0

1.46e-1

1.46e-1 [1.46e-1, 1.47e-1]

1.46e-1 [1.46e-1, 1.47e-1]

1.47e-1 [1.46e-1, 1.47e-1]

5

6.42e-2

6.43e-2 [6.41e-2, 6.44e-2]

6.42e-2 [6.41e-2, 6.44e-2]

6.42e-2 [6.40e-2, 6.43e-2]

10

2.33e-2

2.33e-2 [2.32e-2, 2.34e-2]

2.32e-2 [2.32e-2, 2.33e-2]

2.33e-2 [2.32e-2, 2.34e-2]

15

7.72e-3

7.73e-3 [7.67e-3, 7.78e-3]

7.76e-3 [7.71e-3, 7.82e-3]

7.75e-3 [7.70e-3, 7.80e-3]

20

2.48e-3

2.51e-3 [2.48e-3, 2.54e-3]

2.46e-3 [2.43e-3, 2.49e-3]

2.52e-3 [2.49e-3, 2.55e-3]

25

7.89e-4

8.08e-4 [7.91e-4, 8.26e-4]

7.83e-4 [7.66e-4, 8.00e-4]

7.88e-4 [7.71e-4, 8.06e-4]

30

2.50e-4

2.52e-4 [2.42e-4, 2.62e-4]

2.41e-4 [2.31e-4, 2.51e-4]

2.43e-4 [2.34e-4, 2.53e-4]

QPSK: error rates

Library

Precision

Against

max abs(z), BER

Agrees

BER interval

comnum (functions)

float64

theory

2.08

yes

binomial

comnum.run_spec

float64

theory

1.17

yes

binomial

Sionna

float32 (config.precision = ‘single’)

theory

3.13

yes

binomial

BER and its 95 % interval, every point (10,000,000 bits per point)

Eb/N0 (dB)

theory

comnum (functions)

comnum.run_spec

Sionna

0

1.46e-1

1.46e-1 [1.46e-1, 1.46e-1]

1.46e-1 [1.46e-1, 1.47e-1]

1.46e-1 [1.46e-1, 1.47e-1]

5

6.42e-2

6.41e-2 [6.39e-2, 6.42e-2]

6.42e-2 [6.40e-2, 6.43e-2]

6.42e-2 [6.40e-2, 6.43e-2]

10

2.33e-2

2.34e-2 [2.33e-2, 2.34e-2]

2.33e-2 [2.32e-2, 2.34e-2]

2.31e-2 [2.30e-2, 2.32e-2]

15

7.72e-3

7.77e-3 [7.72e-3, 7.82e-3]

7.73e-3 [7.67e-3, 7.78e-3]

7.67e-3 [7.61e-3, 7.72e-3]

20

2.48e-3

2.51e-3 [2.48e-3, 2.54e-3]

2.50e-3 [2.47e-3, 2.53e-3]

2.46e-3 [2.43e-3, 2.50e-3]

25

7.89e-4

8.02e-4 [7.84e-4, 8.19e-4]

7.81e-4 [7.64e-4, 7.99e-4]

7.92e-4 [7.75e-4, 8.10e-4]

30

2.50e-4

2.53e-4 [2.44e-4, 2.63e-4]

2.46e-4 [2.37e-4, 2.56e-4]

2.51e-4 [2.41e-4, 2.61e-4]

BPSK: time

Median of 5 runs, ns per bit; workload: 1000000 bits at Eb/N0 = 10.0 dB. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

GPU

Relative

First call

comnum (functions)

float64

273

249

—

1.00

0.264 s

comnum.run_spec

float64

291

20.7

—

1.07

0.305 s

Sionna

float32 (config.precision = ‘single’)

327

196

8.11

1.20

0.348 s

QPSK: time

Median of 5 runs, ns per bit; workload: 1000000 bits at Eb/N0 = 10.0 dB. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

GPU

Relative

First call

comnum (functions)

float64

133

136

—

1.00

0.124 s

comnum.run_spec

float64

151

10.9

—

1.14

0.154 s

Sionna

float32 (config.precision = ‘single’)

217

106

4.46

1.64

0.215 s

The commands of every run
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s2_rayleigh.py --lib comnum --threads 1 --work h100/_work --out h100/_work/s2_rayleigh_comnum_1.json
OMP_NUM_THREADS=24 OPENBLAS_NUM_THREADS=24 MKL_NUM_THREADS=24 VECLIB_MAXIMUM_THREADS=24 NUMBA_NUM_THREADS=24 RAYON_NUM_THREADS=24 python s2_rayleigh.py --lib comnum --threads all --work h100/_work --out h100/_work/s2_rayleigh_comnum_all.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s2_rayleigh.py --lib comnum-spec --threads 1 --work h100/_work --out h100/_work/s2_rayleigh_comnum-spec_1.json
OMP_NUM_THREADS=24 OPENBLAS_NUM_THREADS=24 MKL_NUM_THREADS=24 VECLIB_MAXIMUM_THREADS=24 NUMBA_NUM_THREADS=24 RAYON_NUM_THREADS=24 python s2_rayleigh.py --lib comnum-spec --threads all --work h100/_work --out h100/_work/s2_rayleigh_comnum-spec_all.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s2_rayleigh.py --lib sionna --threads 1 --work h100/_work --out h100/_work/s2_rayleigh_sionna_1.json
OMP_NUM_THREADS=24 OPENBLAS_NUM_THREADS=24 MKL_NUM_THREADS=24 VECLIB_MAXIMUM_THREADS=24 NUMBA_NUM_THREADS=24 RAYON_NUM_THREADS=24 python s2_rayleigh.py --lib sionna --threads all --work h100/_work --out h100/_work/s2_rayleigh_sionna_all.json --timing-only
OMP_NUM_THREADS=24 OPENBLAS_NUM_THREADS=24 MKL_NUM_THREADS=24 VECLIB_MAXIMUM_THREADS=24 NUMBA_NUM_THREADS=24 RAYON_NUM_THREADS=24 python s2_rayleigh.py --lib sionna --threads gpu --work h100/_work --out h100/_work/s2_rayleigh_sionna_gpu.json --timing-only

4 x 4 MIMO, 16-QAM, i.i.d. Rayleigh fading: ZF, MMSE and ML detection#

Scenario 3: 4 x 4 spatial multiplexing of Gray 16-QAM over i.i.d. Rayleigh fading (one channel matrix per vector, known at the receiver), detected by zero forcing, MMSE and maximum likelihood.

Parameters:

  • Eb/N0 (dB): 0, 4, 8, 12, 16

  • vectors per point: linear detectors and sphere: 10^5 (quick: 5·10^3); exhaustive ML: 2·10^4 (quick: 10^3)

  • timing: 10^4 vectors at Eb/N0 = 12 dB (exhaustive ML: 10^3; quick: 2·10^3 and 2·10^2)

  • theory: ZF: 16-QAM over SISO Rayleigh fading (Winters, Salz & Gitlin, IEEE Trans. Commun. 42(2/3/4), 1994)

Script: benchmarks/libraries/s3_mimo.py.

The script
"""Scenario 3: 4 x 4 spatial multiplexing of Gray 16-QAM over i.i.d. Rayleigh fading
(one channel matrix per vector, known at the receiver), detected by zero forcing, MMSE
and maximum likelihood.

Every stream carries unit-energy symbols (no power split between the antennas) and
Eb/N0 is the energy per bit received on one antenna over N0: N0 = 1 / (4 Eb/N0).
Zero forcing over 4 x 4 leaves one diversity branch per stream, so its BER is that of
16-QAM over SISO Rayleigh fading (Winters, Salz & Gitlin 1994); MMSE and ML have no
closed form here and are compared between the libraries. Maximum likelihood is an
exhaustive search over the 16^4 = 65536 vectors in Sionna and in comnum ("ml"), and
the Schnorr-Euchner sphere decoder in comnum ("sphere", the same decisions).
The specification runner splits the power between the antennas (mimo_tx), so it runs
at Eb/N0 + 10 log10(4) dB to have the same SNR. Timing: ``TIMING_VECTORS`` vectors at
Eb/N0 = 12 dB (channel, noise, detection), median of 5 runs after a warm-up run.

    python s3_mimo.py --lib comnum --threads 1 [--quick] --out results/_work/s3.json
"""

from __future__ import annotations

import math
import time

import common
import numpy as np
import theory

SCENARIO = "s3_mimo"
TITLE = "4 x 4 MIMO, 16-QAM, i.i.d. Rayleigh fading: ZF, MMSE and ML detection"
NT = NR = 4
M = 16
K = 4
NO_MIMO = "no MIMO detection"
SUPPORT = {
    "comnum": True,
    "comnum-spec": True,
    "komm": NO_MIMO,
    "opticommpy": "no MIMO detection (only the 2 x 2 adaptive equalisers of polarisation multiplexing)",
    "opticommpy-nojit": "no MIMO detection (only the 2 x 2 adaptive equalisers of polarisation multiplexing)",
    "sionna": True,
}
CASES = {
    "zf": SUPPORT,
    "mmse": SUPPORT,
    "ml": SUPPORT,
    "sphere": {
        **SUPPORT,
        "sionna": "no sphere decoder (its exhaustive ML detector is the case ml)",
    },
}
SPEC_DETECTOR = {"zf": "zf", "mmse": "mmse", "ml": "ml", "sphere": "sphere"}
EBN0 = [0.0, 4.0, 8.0, 12.0, 16.0]
TIMING_EBN0 = 12.0
SPEC_OFFSET_DB = 10 * math.log10(NT)

PARAMETERS = {
    "ebn0_db": EBN0,
    "vectors_per_point": "linear detectors and sphere: 10^5 (quick: 5·10^3); exhaustive ML: 2·10^4 (quick: 10^3)",
    "timing": "10^4 vectors at Eb/N0 = 12 dB (exhaustive ML: 10^3; quick: 2·10^3 and 2·10^2)",
    "theory": "ZF: 16-QAM over SISO Rayleigh fading (Winters, Salz & Gitlin, IEEE Trans. Commun. 42(2/3/4), 1994)",
}


def sizes(quick: bool, case: str) -> dict:
    heavy = case == "ml"
    if quick:
        return {
            "vectors": 1_000 if heavy else 5_000,
            "timing": 200 if heavy else 2_000,
            "blocks": 1,
        }
    return {
        "vectors": 20_000 if heavy else 100_000,
        "timing": 1_000 if heavy else 10_000,
        "blocks": 10,
    }


def theory_ber(case: str, ebn0: float) -> float | None:
    return float(theory.ber_qam_rayleigh(M, ebn0)) if case == "zf" else None


def chain_comnum(case: str, seed: int):
    import comnum

    c = comnum.Constellation("qam", M).normalized()
    rng = comnum.Rng(seed)

    def run(n: int, ebn0: float) -> np.ndarray:
        b = comnum.bits(n * NT * K, rng)
        x = c.map_bits(b).reshape(n, NT)
        h = comnum.mimo_channels(n, NR, NT, rng)
        n0 = 1.0 / (K * 10 ** (ebn0 / 10))
        # y = H x + w (the Python API draws H; the product is NumPy's)
        y = np.matmul(h, x[:, :, None])[:, :, 0] + rng.complex_noise(n * NR, n0).reshape(n, NR)
        labels = comnum.mimo_detect(h, y.T, c, n0, case)  # stream-major (NT, n)
        bh = comnum.unpack_symbols(labels.T.ravel(), K)
        return np.count_nonzero((bh != b).reshape(n, NT * K), axis=1)  # bit errors per vector

    return run, "float64"


def chain_sionna(case: str, seed: int):
    import torch
    from sionna.phy import config
    from sionna.phy.channel import FlatFadingChannel
    from sionna.phy.mapping import BinarySource, Mapper
    from sionna.phy.mimo import LinearDetector, MaximumLikelihoodDetector
    from sionna.phy.utils import ebnodb2no

    config.seed = seed
    source, mapper = BinarySource(), Mapper("qam", K)
    channel = FlatFadingChannel(NT, NR, return_channel=True)
    if case == "ml":
        det = MaximumLikelihoodDetector(
            "bit", "maxlog", NT, constellation_type="qam", num_bits_per_symbol=K, hard_out=True
        )
        chunk = 64  # vectors per call: the search holds 65536 candidates per vector
    else:
        eq = {"zf": "zf", "mmse": "lmmse"}[case]
        det = LinearDetector(
            eq, "bit", "maxlog", constellation_type="qam", num_bits_per_symbol=K, hard_out=True
        )
        chunk = None

    def run(n: int, ebn0: float) -> np.ndarray:
        with torch.inference_mode():
            b = source([n, NT * K])
            x = mapper(b)  # [n, NT]
            no = ebnodb2no(ebn0, K, 1.0)  # Es / (K Eb/N0), Es = 1
            y, h = channel(x, no)
            s = (float(no) * torch.eye(NR, dtype=y.dtype)).expand(n, NR, NR)
            if chunk is None:
                bh = det(y, h, s)
            else:
                bh = torch.cat(
                    [
                        det(y[i : i + chunk], h[i : i + chunk], s[i : i + chunk])
                        for i in range(0, n, chunk)
                    ]
                )
            return common.to_numpy((bh.reshape(b.shape) != b).sum(dim=1))  # bit errors per vector

    return run, "float32 (config.precision = 'single')"


CHAINS = {"comnum": chain_comnum, "sionna": chain_sionna}


def run_chains(args, todo: list[str]) -> list[dict]:
    threading = common.setup_torch(args.threads) if args.lib == "sionna" else {}
    out = []
    for case in todo:
        sz = sizes(args.quick, case)
        run, precision = CHAINS[args.lib](case, args.seed)
        res = {"case": case, "precision": precision, **threading}
        n = sz["timing"]
        t0 = time.perf_counter()
        run(n, TIMING_EBN0)
        res["first_call_s"] = time.perf_counter() - t0
        if not args.timing_only:
            pts = []
            t0 = time.perf_counter()
            for e in EBN0:
                # the 16 bits of a vector share its channel: the interval is per vector
                errs = np.concatenate(
                    [run(sz["vectors"] // sz["blocks"], e) for _ in range(sz["blocks"])]
                )
                pts.append(
                    common.frame_ber_point(
                        errs, NT * K, theory_ber(case, e), ebn0_db=e, vectors=errs.size
                    )
                )
            res["points"] = pts
            res["sweep_s"] = time.perf_counter() - t0
        res["timing"] = common.time_it(lambda n=n, run=run: run(n, TIMING_EBN0))
        res["timing"].update(
            units=n, unit="vector", workload=f"{n} vectors at Eb/N0 = {TIMING_EBN0} dB"
        )
        out.append(res)
    return out


def spec(case: str, ebn0: list[float], vectors: int, trials: int, seed: int) -> dict:
    return {
        "name": f"bench_mimo_{case}",
        "seed": seed,
        "batch": trials,
        "grid": {"ebn0_db": [e + SPEC_OFFSET_DB for e in ebn0]},
        "link": [
            {"type": "source", "constellation": "16-QAM", "n_symbols": NT * vectors // trials},
            {"type": "mimo_tx", "n_tx": NT, "scheme": "spatial"},
            {"type": "rayleigh", "n_rx": NR, "coherence_samples": 1},
            {"type": "awgn_ebn0", "ebn0_db": "$ebn0_db"},
            {"type": "mimo_rx", "detector": SPEC_DETECTOR[case]},
        ],
        "metrics": {"report": ["ber"], "scale": "none"},
        "stop": {"min_trials": trials, "max_trials": trials},
    }


def run_spec_lib(args, todo: list[str]) -> list[dict]:
    trials = 40
    out = []
    for case in todo:
        sz = sizes(args.quick, case)

        def points(rows: list[dict], case=case, vectors=sz["vectors"]) -> list[dict]:
            pts = []
            for e, row in zip(EBN0, rows, strict=True):
                ber = row["metrics"]["ber"]
                pts.append(
                    common.rate_point(
                        ber["count"], ber["total"], theory_ber(case, e), ebn0_db=e, vectors=vectors
                    )
                )
            return pts

        n = sz["timing"]
        out.append(
            common.spec_case(
                args,
                {"case": case, "precision": "float64", "ebn0_offset_db": SPEC_OFFSET_DB},
                spec(case, EBN0, sz["vectors"], trials, args.seed),
                spec(case, [TIMING_EBN0], n, trials, args.seed + 1),
                points,
                units=n,
                unit="vector",
                workload=f"{n} vectors at Eb/N0 = {TIMING_EBN0} dB, {trials} trials",
            )
        )
    return out


RUNNERS = {"comnum": run_chains, "sionna": run_chains, "comnum-spec": run_spec_lib}

if __name__ == "__main__":
    common.main(SCENARIO, CASES, RUNNERS)

Support

Case

comnum (functions)

comnum.run_spec

OptiCommPy (numba)

Sionna

zero forcing

yes

yes

not available: no MIMO detection (only the 2 x 2 adaptive equalisers of polarisation multiplexing)

yes

MMSE

yes

yes

not available: no MIMO detection (only the 2 x 2 adaptive equalisers of polarisation multiplexing)

yes

ML (exhaustive)

yes

yes

not available: no MIMO detection (only the 2 x 2 adaptive equalisers of polarisation multiplexing)

yes

ML (sphere decoder)

yes

yes

not available: no MIMO detection (only the 2 x 2 adaptive equalisers of polarisation multiplexing)

not available: no sphere decoder (its exhaustive ML detector is the case ml)

zero forcing: error rates

Library

Precision

Against

max abs(z), BER

Agrees

BER interval

comnum (functions)

float64

theory

1.88

yes

per-frame

comnum.run_spec

float64

theory

2.98

yes

binomial

Sionna

float32 (config.precision = ‘single’)

theory

1.79

yes

per-frame

BER and its 95 % interval, every point (1,600,000 bits per point)

Eb/N0 (dB)

theory

comnum (functions)

comnum.run_spec

Sionna

0

1.98e-1

1.98e-1 [1.97e-1, 1.99e-1]

1.98e-1 [1.97e-1, 1.98e-1]

1.98e-1 [1.97e-1, 1.99e-1]

4

1.20e-1

1.19e-1 [1.18e-1, 1.20e-1]

1.19e-1 [1.19e-1, 1.20e-1]

1.19e-1 [1.19e-1, 1.20e-1]

8

6.20e-2

6.16e-2 [6.10e-2, 6.23e-2]

6.14e-2 [6.10e-2, 6.18e-2]

6.26e-2 [6.19e-2, 6.32e-2]

12

2.83e-2

2.82e-2 [2.78e-2, 2.87e-2]

2.81e-2 [2.79e-2, 2.84e-2]

2.83e-2 [2.78e-2, 2.87e-2]

16

1.20e-2

1.21e-2 [1.18e-2, 1.24e-2]

1.19e-2 [1.17e-2, 1.20e-2]

1.19e-2 [1.16e-2, 1.22e-2]

MMSE: error rates

Library

Precision

Against

max abs(z), BER

Agrees

BER interval

comnum (functions)

float64

—

—

—

per-frame

comnum.run_spec

float64

comnum

3.63

yes

binomial

Sionna

float32 (config.precision = ‘single’)

comnum

1.57

yes

per-frame

BER and its 95 % interval, every point (1,600,000 bits per point)

Eb/N0 (dB)

comnum (functions)

comnum.run_spec

Sionna

0

1.39e-1 [1.38e-1, 1.40e-1]

1.38e-1 [1.38e-1, 1.39e-1]

1.38e-1 [1.38e-1, 1.39e-1]

4

8.27e-2 [8.21e-2, 8.33e-2]

8.40e-2 [8.36e-2, 8.44e-2]

8.33e-2 [8.28e-2, 8.39e-2]

8

4.31e-2 [4.26e-2, 4.35e-2]

4.29e-2 [4.26e-2, 4.32e-2]

4.32e-2 [4.28e-2, 4.37e-2]

12

1.97e-2 [1.94e-2, 2.00e-2]

1.99e-2 [1.97e-2, 2.01e-2]

1.96e-2 [1.93e-2, 1.99e-2]

16

8.42e-3 [8.20e-3, 8.64e-3]

8.33e-3 [8.19e-3, 8.47e-3]

8.23e-3 [8.02e-3, 8.45e-3]

ML (exhaustive): error rates

Library

Precision

Against

max abs(z), BER

Agrees

BER interval

comnum (functions)

float64

—

—

—

per-frame

comnum.run_spec

float64

comnum

2.53

yes

binomial

Sionna

float32 (config.precision = ‘single’)

comnum

1.09

yes

per-frame

BER and its 95 % interval, every point (320,000 bits per point)

Eb/N0 (dB)

comnum (functions)

comnum.run_spec

Sionna

0

1.25e-1 [1.23e-1, 1.26e-1]

1.25e-1 [1.24e-1, 1.26e-1]

1.25e-1 [1.23e-1, 1.27e-1]

4

4.03e-2 [3.90e-2, 4.16e-2]

3.99e-2 [3.92e-2, 4.06e-2]

4.05e-2 [3.92e-2, 4.17e-2]

8

4.12e-3 [3.68e-3, 4.56e-3]

4.77e-3 [4.54e-3, 5.02e-3]

4.47e-3 [4.02e-3, 4.93e-3]

12

2.34e-4 [1.24e-4, 3.45e-4]

2.28e-4 [1.81e-4, 2.87e-4]

1.59e-4 [7.61e-5, 2.43e-4]

16

0 [0, 9.38e-6]

0 [0, 1.20e-5]

1.25e-5 [0, 3.70e-5]

ML (sphere decoder): error rates

Library

Precision

Against

max abs(z), BER

Agrees

BER interval

comnum (functions)

float64

—

—

—

per-frame

comnum.run_spec

float64

comnum

2.84

yes

binomial

BER and its 95 % interval, every point (1,600,000 bits per point)

Eb/N0 (dB)

comnum (functions)

comnum.run_spec

0

1.24e-1 [1.23e-1, 1.25e-1]

1.25e-1 [1.24e-1, 1.25e-1]

4

3.97e-2 [3.92e-2, 4.03e-2]

4.06e-2 [4.03e-2, 4.09e-2]

8

4.44e-3 [4.23e-3, 4.64e-3]

4.62e-3 [4.51e-3, 4.73e-3]

12

1.91e-4 [1.49e-4, 2.32e-4]

1.95e-4 [1.75e-4, 2.18e-4]

16

3.13e-6 [0, 6.37e-6]

1.25e-5 [8.09e-6, 1.93e-5]

zero forcing: time

Median of 5 runs, µs per vector; workload: 10000 vectors at Eb/N0 = 12.0 dB. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

GPU

Relative

First call

comnum (functions)

float64

2.13

2.11

—

1.00

0.0241 s

comnum.run_spec

float64

2.28

0.355

—

1.07

0.0209 s

Sionna

float32 (config.precision = ‘single’)

5.09

1.86

0.143

2.40

0.0513 s

MMSE: time

Median of 5 runs, µs per vector; workload: 10000 vectors at Eb/N0 = 12.0 dB. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

GPU

Relative

First call

comnum (functions)

float64

2.16

2.12

—

1.00

0.0265 s

comnum.run_spec

float64

2.42

0.341

—

1.12

0.0231 s

Sionna

float32 (config.precision = ‘single’)

4.34

2.41

0.158

2.01

0.052 s

ML (exhaustive): time

Median of 5 runs, µs per vector; workload: 1000 vectors at Eb/N0 = 12.0 dB. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

GPU

Relative

First call

comnum (functions)

float64

969

984

—

1.00

0.985 s

comnum.run_spec

float64

1015

55.8

—

1.05

0.986 s

Sionna

float32 (config.precision = ‘single’)

13203

2919

61.8

13.6

13.1 s

ML (sphere decoder): time

Median of 5 runs, µs per vector; workload: 10000 vectors at Eb/N0 = 12.0 dB. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

GPU

Relative

First call

comnum (functions)

float64

2.65

2.67

—

1.00

0.0283 s

comnum.run_spec

float64

3.17

0.435

—

1.20

0.0299 s

The commands of every run
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s3_mimo.py --lib comnum --threads 1 --work h100/_work --out h100/_work/s3_mimo_comnum_1.json
OMP_NUM_THREADS=24 OPENBLAS_NUM_THREADS=24 MKL_NUM_THREADS=24 VECLIB_MAXIMUM_THREADS=24 NUMBA_NUM_THREADS=24 RAYON_NUM_THREADS=24 python s3_mimo.py --lib comnum --threads all --work h100/_work --out h100/_work/s3_mimo_comnum_all.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s3_mimo.py --lib comnum-spec --threads 1 --work h100/_work --out h100/_work/s3_mimo_comnum-spec_1.json
OMP_NUM_THREADS=24 OPENBLAS_NUM_THREADS=24 MKL_NUM_THREADS=24 VECLIB_MAXIMUM_THREADS=24 NUMBA_NUM_THREADS=24 RAYON_NUM_THREADS=24 python s3_mimo.py --lib comnum-spec --threads all --work h100/_work --out h100/_work/s3_mimo_comnum-spec_all.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s3_mimo.py --lib sionna --threads 1 --work h100/_work --out h100/_work/s3_mimo_sionna_1.json
OMP_NUM_THREADS=24 OPENBLAS_NUM_THREADS=24 MKL_NUM_THREADS=24 VECLIB_MAXIMUM_THREADS=24 NUMBA_NUM_THREADS=24 RAYON_NUM_THREADS=24 python s3_mimo.py --lib sionna --threads all --work h100/_work --out h100/_work/s3_mimo_sionna_all.json --timing-only
OMP_NUM_THREADS=24 OPENBLAS_NUM_THREADS=24 MKL_NUM_THREADS=24 VECLIB_MAXIMUM_THREADS=24 NUMBA_NUM_THREADS=24 RAYON_NUM_THREADS=24 python s3_mimo.py --lib sionna --threads gpu --work h100/_work --out h100/_work/s3_mimo_sionna_gpu.json --timing-only

Channel coding over BPSK and AWGN#

Scenario 4: channel coding over BPSK and AWGN.

Parameters:

  • convolutional (133, 171), soft Viterbi: K = 7, (133, 171), k = 1024 + 6 tail bits, soft Viterbi; Eb/N0 = 1, 2, 3, 4 dB

  • 5G NR LDPC (1024, 2048): 5G NR LDPC, k = 1024, n = 2048 (base graph 2, Z = 104), flooding sum-product, 50 iterations; Eb/N0 = 1, 1.5, 2, 2.5 dB

  • LDPC (1024, 512), same LLRs: PEG (1024, 512), flooding sum-product, 50 iterations, identical LLRs; Eb/N0 = 1.5, 2, 2.5, 3 dB

  • frames per point: 2000 (quick: 100)

  • timing: 200 frames (quick: 20) at Eb/N0 = 3 dB (conv) or 2 dB (LDPC); OptiCommPy without numba: 2 frames

Script: benchmarks/libraries/s4_coding.py.

The script
"""Scenario 4: channel coding over BPSK and AWGN.

- ``conv``: the rate-1/2 convolutional code of constraint length 7, generators
  (133, 171) in octal, 1024 information bits per frame terminated by 6 tail bits
  (rate 1024/2060), soft-decision Viterbi decoding. komm counts the taps from the
  least significant bit: the same code is ``ConvolutionalCode([[0o155, 0o117]])``
  there (checked on the impulse response). Reference: the union bound with the
  distance spectrum of the trellis (Viterbi 1971).
- ``nr_ldpc``: the LDPC code of 5G NR (3GPP TS 38.212, Sec. 5.3.2), k = 1024, rate
  matched to n = 2048 (base graph 2 by the rule of Sec. 7.2.2, lifting size 104, the
  first 2Z bits punctured; the same in both libraries), sum-product
  decoding by flooding, 50 iterations at most, in both libraries (comnum's default is
  the layered schedule, which converges in about half the iterations; it is set to
  flooding here so that the error rates compare the same decoder). comnum stops early on
  a codeword, Sionna runs the 50 iterations (its default).
- ``ldpc_same_llr``: one (1024, 512) LDPC code (PEG construction of comnum, variable
  degree 3, seed 1), its parity-check matrix given to the three decoders that accept
  one (comnum, OptiCommPy, Sionna), and the same channel LLRs (drawn by comnum from the
  same seed in every process): flooding sum-product, 50 iterations at most. The
  decoders see identical inputs, so they should take the same decisions frame by
  frame.

Every library uses its own encoder, BPSK mapper, AWGN channel and LLR computation in
``conv`` and ``nr_ldpc`` (independent seeds); Eb/N0 counts the energy per information
bit. Timings: the decoder alone on ``TIMING_FRAMES`` frames of LLRs at the timing
point, and the whole chain (bits, encoding, mapping, noise, LLRs, decoding, error
count) on the same number of frames; median of 5 runs after a warm-up run.

    python s4_coding.py --lib sionna --threads 1 [--quick] --out results/_work/s4.json
"""

from __future__ import annotations

import time

import common
import numpy as np
import theory

SCENARIO = "s4_coding"
TITLE = "Channel coding over BPSK and AWGN"
K_CONV = 1024
CONV_GEN = [0o133, 0o171]
K_NR, N_NR = 1024, 2048
N_PEG, K_PEG = 1024, 512
ITERATIONS = 50
EBN0 = {
    "conv": [1.0, 2.0, 3.0, 4.0],
    "nr_ldpc": [1.0, 1.5, 2.0, 2.5],
    "ldpc_same_llr": [1.5, 2.0, 2.5, 3.0],
}
TIMING_EBN0 = {"conv": 3.0, "nr_ldpc": 2.0, "ldpc_same_llr": 2.0}
SPEC_ONLY_OWN = "the runner draws its own channel (this case feeds the same LLRs to every decoder)"
CASES = {
    "conv": {
        "comnum": True,
        "comnum-spec": True,
        "comnum-rust": True,
        "komm": True,
        "opticommpy": "no convolutional code",
        "opticommpy-nojit": "no convolutional code",
        "sionna": True,
    },
    "nr_ldpc": {
        "comnum": True,
        "comnum-spec": "the specification format has no 5G NR LDPC code (NrLdpc: Python and Rust API)",
        "comnum-rust": "the specification format has no 5G NR LDPC code (NrLdpc: Python and Rust API)",
        "komm": "no LDPC code",
        "opticommpy": "no 5G NR LDPC code (its decoder takes a parity-check matrix: see ldpc_same_llr)",
        "opticommpy-nojit": "no 5G NR LDPC code (its decoder takes a parity-check matrix: see ldpc_same_llr)",
        "sionna": True,
    },
    "ldpc_same_llr": {
        "comnum": True,
        "comnum-spec": SPEC_ONLY_OWN,
        "comnum-rust": SPEC_ONLY_OWN,
        "komm": "no LDPC (belief-propagation) decoder",
        "opticommpy": True,
        "opticommpy-nojit": True,
        "sionna": True,
    },
}

PARAMETERS = {
    "conv": "K = 7, (133, 171), k = 1024 + 6 tail bits, soft Viterbi; Eb/N0 = 1, 2, 3, 4 dB",
    "nr_ldpc": "5G NR LDPC, k = 1024, n = 2048 (base graph 2, Z = 104), flooding sum-product, 50 iterations; Eb/N0 = 1, 1.5, 2, 2.5 dB",
    "ldpc_same_llr": "PEG (1024, 512), flooding sum-product, 50 iterations, identical LLRs; Eb/N0 = 1.5, 2, 2.5, 3 dB",
    "frames_per_point": "2000 (quick: 100)",
    "timing": "200 frames (quick: 20) at Eb/N0 = 3 dB (conv) or 2 dB (LDPC); OptiCommPy without numba: 2 frames",
}


def sizes(quick: bool) -> dict:
    return (
        {"frames": 100, "timing": 20, "block": 50}
        if quick
        else {"frames": 2000, "timing": 200, "block": 200}
    )


def n0_of(ebn0: float, rate: float) -> float:
    """N0 of unit-energy BPSK for an Eb/N0 per information bit (complex noise of
    variance N0, N0/2 on the real axis)."""
    return 1.0 / (rate * 10 ** (ebn0 / 10))


# ---------------------------------------------------------------------- the codecs
# A codec gives: encode(F) -> (info bits [F, k], channel input), llrs(F, ebn0) ->
# (reference bits, LLRs in the library's convention), decode(llrs) -> decided bits
# [F, k_out], and the number k_out of bits compared per frame.


def codec_comnum(case: str, seed: int):
    import comnum

    rng = comnum.Rng(seed)
    bpsk = comnum.Constellation("psk", 2)
    if case == "conv":
        code = comnum.ConvolutionalCode(K_CONV, generators=CONV_GEN, constraint=7)
    elif case == "nr_ldpc":
        code = comnum.NrLdpc(
            K_NR, N_NR, max_iterations=ITERATIONS, rule="sum_product", layered=False
        )
    else:
        return codec_same_llr_comnum(seed)
    k, n = code.k, code.n

    def llrs(f: int, ebn0: float):
        b = comnum.bits(f * k, rng)
        x = bpsk.map_bits(code.encode(b))
        n0 = n0_of(ebn0, k / n)
        y = x + rng.complex_noise(x.size, n0)
        return b.reshape(f, k), bpsk.llr(y, n0)

    def decode(llr):
        return code.decode(llr).reshape(-1, k)

    return llrs, decode, k, "float64"


def peg_code():
    import comnum

    code = comnum.LdpcCode.peg(N_PEG, K_PEG, var_degree=3, seed=1)
    code.set_decoder("sum_product", layered=False, max_iterations=ITERATIONS)
    return code


def same_llrs(seed: int):
    """The codewords and LLRs of ldpc_same_llr: the same in every process."""
    import comnum

    code = peg_code()
    rng = comnum.Rng(seed)
    bpsk = comnum.Constellation("psk", 2)

    def llrs(f: int, ebn0: float):
        b = comnum.bits(f * code.k, rng)
        c = code.encode(b)
        n0 = n0_of(ebn0, code.k / code.n)
        y = bpsk.map_bits(c) + rng.complex_noise(c.size, n0)
        return c.reshape(f, code.n), bpsk.llr(y, n0).reshape(f, code.n)  # positive for a 0

    return code, llrs


def codec_same_llr_comnum(seed: int):
    code, llrs = same_llrs(seed)

    def decode(llr):
        return np.stack([(code.decode_llr(row)[0] < 0).astype(np.uint8) for row in llr])

    return llrs, decode, code.n, "float64"


def codec_opticommpy(case: str, seed: int):
    import scipy.sparse as sp
    from optic.comm.fec import decodeLDPC
    from optic.utils import parameters

    code, llrs = same_llrs(seed)
    rows = code.rows
    h = sp.csr_matrix(
        (
            np.ones(sum(map(len, rows)), dtype=np.uint8),
            (np.repeat(np.arange(len(rows)), list(map(len, rows))), np.concatenate(rows)),
        ),
        shape=(len(rows), code.n),
    )
    p = parameters()
    p.H, p.maxIter, p.alg = h, ITERATIONS, "SPA"

    def decode(llr):
        bits, _out, _fail = decodeLDPC(np.ascontiguousarray(llr.T), p)  # columns are codewords
        return bits.T.astype(np.uint8)

    return llrs, decode, code.n, "float32 (decodeLDPC default prec = np.float32)"


def codec_komm(case: str, seed: int):
    import komm

    # (133, 171) with the current input on the most significant bit = (155, 117) in komm
    cc = komm.ConvolutionalCode([[0o155, 0o117]])
    code = komm.TerminatedConvolutionalCode(cc, num_blocks=K_CONV, mode="zero-termination")
    decoder = komm.ViterbiDecoder(code, input_type="soft")
    bpsk, lab = komm.PAMConstellation(2), komm.NaturalLabeling(1)
    rng = np.random.default_rng(seed)
    k, n = code.dimension, code.length

    def llrs(f: int, ebn0: float):
        b = rng.integers(0, 2, (f, k))
        x = bpsk.indices_to_symbols(lab.bits_to_indices(code.encode(b).ravel()))
        sigma2 = n0_of(ebn0, k / n) / 2  # real channel: N0/2 per dimension
        y = komm.GaussianChannel(sigma2, rng=rng).transmit(x)
        return b, lab.marginalize(bpsk.posteriors(y, sigma2))  # log P(0) / P(1)

    def decode(llr):
        return decoder.decode(llr).reshape(-1, k)

    return llrs, decode, k, "float64"


def codec_sionna(case: str, seed: int):
    import torch
    from sionna.phy import config
    from sionna.phy.channel import AWGN
    from sionna.phy.fec.conv import ConvEncoder, ViterbiDecoder
    from sionna.phy.fec.ldpc import LDPC5GDecoder, LDPC5GEncoder, LDPCBPDecoder
    from sionna.phy.mapping import BinarySource, Demapper, Mapper

    config.seed = seed
    if case == "ldpc_same_llr":
        code, same = same_llrs(seed)
        dec = LDPCBPDecoder(peg_pcm(code), num_iter=ITERATIONS, hard_out=True)

        def decode(llr):
            with torch.inference_mode():
                # Sionna's LLRs are logits, log P(1) / P(0): the opposite sign
                llr_dev = torch.from_numpy(-llr.astype(np.float32)).to(common.TORCH_DEVICE)
                return common.to_numpy(dec(llr_dev)).astype(np.uint8)

        return same, decode, code.n, "float32 (config.precision = 'single')"

    if case == "conv":
        enc = ConvEncoder(rate=0.5, constraint_length=7, terminate=True)
        dec = ViterbiDecoder(encoder=enc, method="soft_llr", return_info_bits=True)
        k, n = K_CONV, 2 * (K_CONV + 6)
    else:
        enc = LDPC5GEncoder(K_NR, N_NR)
        dec = LDPC5GDecoder(enc, num_iter=ITERATIONS, hard_out=True, return_infobits=True)
        k, n = K_NR, N_NR
    source, mapper, channel = BinarySource(), Mapper("pam", 1), AWGN()
    demapper = Demapper("app", "pam", 1)

    def llrs(f: int, ebn0: float):
        with torch.inference_mode():
            b = source([f, k])
            no = n0_of(ebn0, k / n)
            y = channel(mapper(enc(b)), no)
            return b, demapper(y, no)  # logits, log P(1) / P(0)

    def decode(llr):
        with torch.inference_mode():
            return dec(llr)

    return llrs, decode, k, "float32 (config.precision = 'single')"


def peg_pcm(code):
    h = np.zeros((len(code.rows), code.n), dtype=np.float32)
    for i, r in enumerate(code.rows):
        h[i, r] = 1
    return h


CODECS = {
    "comnum": codec_comnum,
    "komm": codec_komm,
    "opticommpy": codec_opticommpy,
    "opticommpy-nojit": codec_opticommpy,
    "sionna": codec_sionna,
}


def as_np(a) -> np.ndarray:
    return common.to_numpy(a)


def run_codecs(args, todo: list[str]) -> list[dict]:
    threading = common.setup_torch(args.threads) if args.lib == "sionna" else {}
    sz = sizes(args.quick)
    out = []
    for case in todo:
        llrs, decode, k_out, precision = CODECS[args.lib](case, args.seed)
        res = {"case": case, "precision": precision, **threading}

        def chain(f: int, ebn0: float, llrs=llrs, decode=decode) -> np.ndarray:
            ref, llr = llrs(f, ebn0)
            return np.count_nonzero(
                as_np(decode(llr)).reshape(f, -1) != as_np(ref).reshape(f, -1), axis=1
            )

        nt = 2 if args.lib == "opticommpy-nojit" else sz["timing"]
        e_t = TIMING_EBN0[case]
        t0 = time.perf_counter()
        chain(min(nt, 2), e_t)
        res["first_call_s"] = time.perf_counter() - t0
        if not args.timing_only:
            pts = []
            t0 = time.perf_counter()
            for e in EBN0[case]:
                errs = np.concatenate(
                    [chain(sz["block"], e) for _ in range(sz["frames"] // sz["block"])]
                )
                bound = (
                    float(theory.conv_union_bound(CONV_GEN, 7, K_CONV / (2 * (K_CONV + 6)), e))
                    if case == "conv"
                    else None
                )
                pt = common.frame_ber_point(errs, k_out, None, ebn0_db=e)
                if bound is not None:
                    pt["union_bound"] = bound
                fe = int(np.count_nonzero(errs))
                pt["bler"] = common.rate_point(fe, errs.size)
                if case == "ldpc_same_llr":
                    pt["errors_per_frame"] = errs.astype(int).tolist()
                pts.append(pt)
            res["points"] = pts
            res["sweep_s"] = time.perf_counter() - t0
            res["bits_per_frame"] = k_out
        _, llr = llrs(nt, e_t)
        res["timing"] = common.time_it(lambda llr=llr, decode=decode: decode(llr))
        res["timing"].update(
            units=nt, unit="frame", workload=f"decoder only, {nt} frames at Eb/N0 = {e_t} dB"
        )
        res["timing_chain"] = common.time_it(lambda nt=nt, e_t=e_t, chain=chain: chain(nt, e_t))
        res["timing_chain"].update(
            units=nt, unit="frame", workload=f"whole chain, {nt} frames at Eb/N0 = {e_t} dB"
        )
        out.append(res)
    return out


# ------------------------------------------------------------- specification runner


def spec(ebn0: list[float], frames: int, trials: int, seed: int) -> dict:
    code = {"type": "conv", "k": K_CONV}
    return {
        "name": "bench_conv",
        "seed": seed,
        "batch": trials,
        "grid": {"ebn0_db": ebn0},
        "link": [
            {"type": "fec_encode", "code": code, "blocks": frames // trials},
            {"type": "map", "constellation": {"kind": "psk", "m": 2}},
            {"type": "awgn_ebn0", "ebn0_db": "$ebn0_db"},
            "demap",
            {"type": "fec_decode", "code": code},
        ],
        "metrics": {"report": ["coded_ber", "bler"]},  # coded_ber: decoded information bits
        "stop": {"min_trials": trials, "max_trials": trials},
    }


def spec_points(rows: list[dict]) -> list[dict]:
    pts = []
    for r in rows:
        e = float(r["condition"]["ebn0_db"])
        m = r["metrics"]
        get = lambda d: d["count"] if "count" in d else d["errors"]  # noqa: E731
        # a binomial interval: the runner keeps the totals, not the errors of every frame
        pt = common.rate_point(get(m["coded_ber"]), m["coded_ber"]["total"], None, ebn0_db=e)
        pt["union_bound"] = float(
            theory.conv_union_bound(CONV_GEN, 7, K_CONV / (2 * (K_CONV + 6)), e)
        )
        pt["bler"] = common.rate_point(get(m["bler"]), m["bler"]["total"])
        pts.append(pt)
    return pts


def run_spec_lib(args, todo: list[str], *, rust: bool = False) -> list[dict]:
    """``comnum-spec`` (``comnum.run_spec``), or with ``rust`` ``comnum-rust`` (the binary)."""
    sz = sizes(args.quick)
    trials = 20
    out = []
    for case in todo:
        nt, e_t = sz["timing"], TIMING_EBN0[case]
        workload = f"whole chain, {nt} frames at Eb/N0 = {e_t} dB, {trials} trials"
        res = common.spec_case(
            args,
            {"case": case, "precision": "float64"},
            spec(EBN0[case], sz["frames"], trials, args.seed),
            spec([e_t], nt, trials, args.seed + 1),
            spec_points,
            rust=rust,
            key="timing_chain",
            units=nt,
            unit="frame",
            workload=workload + (" (process wall time)" if rust else ""),
        )
        if not args.timing_only:
            res["bits_per_frame"] = K_CONV
        out.append(res)
    return out


def run_rust_lib(args, todo: list[str]) -> list[dict]:
    return run_spec_lib(args, todo, rust=True)


def postprocess(merged: dict, work) -> dict:
    """ldpc_same_llr: the decoders saw the same LLRs; compare their errors frame by frame
    with those of comnum (same number of bit errors in a frame, same frame errors)."""
    per_lib = {}
    for run in merged["runs"]:
        if run.get("threads") != "1":
            continue
        for case in run.get("cases", []):
            if case["case"] == "ldpc_same_llr" and "points" in case:
                per_lib[run["library"]] = case["points"]
    base = per_lib.get("comnum")
    rows = []
    for lib, pts in per_lib.items():
        if lib == "comnum" or base is None:
            continue
        for p, q in zip(pts, base, strict=True):
            a, b = np.array(p["errors_per_frame"]), np.array(q["errors_per_frame"])
            rows.append(
                {
                    "library": lib,
                    "ebn0_db": p["ebn0_db"],
                    "frames": int(a.size),
                    "same_bit_errors": int(np.count_nonzero(a == b)),
                    "same_frame_outcome": int(np.count_nonzero((a > 0) == (b > 0))),
                }
            )
    return {"same_llr_vs_comnum": rows}


RUNNERS = {
    **{lib: run_codecs for lib in CODECS},
    "comnum-spec": run_spec_lib,
    "comnum-rust": run_rust_lib,
}

if __name__ == "__main__":
    common.main(SCENARIO, CASES, RUNNERS)

Support

Case

comnum (functions)

comnum.run_spec

OptiCommPy (numba)

Sionna

convolutional (133, 171), soft Viterbi

yes

yes

not available: no convolutional code

yes

5G NR LDPC (1024, 2048)

yes

not available: the specification format has no 5G NR LDPC code (NrLdpc: Python and Rust API)

not available: no 5G NR LDPC code (its decoder takes a parity-check matrix: see ldpc_same_llr)

yes

LDPC (1024, 512), same LLRs

yes

not available: the runner draws its own channel (this case feeds the same LLRs to every decoder)

yes

yes

convolutional (133, 171), soft Viterbi: error rates

Library

Precision

Against

max abs(z), BER

max abs(z), FER

Agrees

BER interval

comnum (functions)

float64

—

—

—

—

per-frame

comnum.run_spec

float64

comnum

1.35

1.33

yes

binomial

Sionna

float32 (config.precision = ‘single’)

comnum

1.41

2.53

yes

per-frame

BER and its 95 % interval, every point (2,048,000 bits per point)

Eb/N0 (dB)

union bound

comnum (functions)

comnum.run_spec

Sionna

1

3.05e-1

4.02e-2 [3.91e-2, 4.13e-2]

4.02e-2 [3.99e-2, 4.05e-2]

4.03e-2 [3.91e-2, 4.14e-2]

2

1.65e-2

4.97e-3 [4.64e-3, 5.30e-3]

5.16e-3 [5.06e-3, 5.26e-3]

5.32e-3 [4.97e-3, 5.67e-3]

3

6.27e-4

3.31e-4 [2.59e-4, 4.02e-4]

2.98e-4 [2.76e-4, 3.23e-4]

3.64e-4 [2.94e-4, 4.34e-4]

4

2.02e-5

2.54e-5 [8.46e-6, 4.23e-5]

1.32e-5 [9.06e-6, 1.92e-5]

3.08e-5 [9.04e-6, 5.25e-5]

Frame error rate and its 95 % interval (2,000 frames per point)

Eb/N0 (dB)

comnum (functions)

comnum.run_spec

Sionna

1

9.73e-1 [9.66e-1, 9.80e-1]

9.77e-1 [9.69e-1, 9.83e-1]

9.85e-1 [9.79e-1, 9.89e-1]

2

5.05e-1 [4.84e-1, 5.27e-1]

4.84e-1 [4.63e-1, 5.06e-1]

5.13e-1 [4.91e-1, 5.35e-1]

3

5.95e-2 [5.00e-2, 7.07e-2]

6.05e-2 [5.09e-2, 7.18e-2]

6.70e-2 [5.69e-2, 7.88e-2]

4

6.00e-3 [3.44e-3, 1.05e-2]

5.00e-3 [2.72e-3, 9.18e-3]

5.50e-3 [3.07e-3, 9.82e-3]

5G NR LDPC (1024, 2048): error rates

Library

Precision

Against

max abs(z), BER

max abs(z), FER

Agrees

BER interval

comnum (functions)

float64

—

—

—

—

per-frame

Sionna

float32 (config.precision = ‘single’)

comnum

0.893

1.21

yes

per-frame

BER and its 95 % interval, every point (2,048,000 bits per point)

Eb/N0 (dB)

comnum (functions)

Sionna

1

8.79e-3 [7.46e-3, 1.01e-2]

9.31e-3 [7.94e-3, 1.07e-2]

1.5

3.47e-5 [0, 1.03e-4]

9.38e-5 [0, 2.04e-4]

2

0 [0, 1.46e-6]

0 [0, 1.46e-6]

2.5

0 [0, 1.46e-6]

0 [0, 1.46e-6]

Frame error rate and its 95 % interval (2,000 frames per point)

Eb/N0 (dB)

comnum (functions)

Sionna

1

9.40e-2 [8.20e-2, 1.08e-1]

1.05e-1 [9.28e-2, 1.20e-1]

1.5

5.00e-4 [8.83e-5, 2.83e-3]

1.50e-3 [5.10e-4, 4.40e-3]

2

0 [0, 1.92e-3]

0 [0, 1.92e-3]

2.5

0 [0, 1.92e-3]

0 [0, 1.92e-3]

LDPC (1024, 512), same LLRs: error rates

Library

Precision

Against

max abs(z), BER

max abs(z), FER

Agrees

BER interval

comnum (functions)

float64

—

—

—

—

per-frame

OptiCommPy (numba)

float32 (decodeLDPC default prec = np.float32)

comnum

0.076

0

yes

per-frame

Sionna

float32 (config.precision = ‘single’)

comnum

0.0795

0.0381

yes

per-frame

BER and its 95 % interval, every point (2,048,000 bits per point)

Eb/N0 (dB)

comnum (functions)

OptiCommPy (numba)

Sionna

1.5

1.53e-2 [1.39e-2, 1.66e-2]

1.53e-2 [1.40e-2, 1.67e-2]

1.53e-2 [1.40e-2, 1.67e-2]

2

1.37e-3 [9.56e-4, 1.78e-3]

1.37e-3 [9.55e-4, 1.78e-3]

1.37e-3 [9.56e-4, 1.78e-3]

2.5

0 [0, 1.46e-6]

0 [0, 1.46e-6]

0 [0, 1.46e-6]

3

0 [0, 1.46e-6]

0 [0, 1.46e-6]

0 [0, 1.46e-6]

Frame error rate and its 95 % interval (2,000 frames per point)

Eb/N0 (dB)

comnum (functions)

OptiCommPy (numba)

Sionna

1.5

2.21e-1 [2.03e-1, 2.40e-1]

2.21e-1 [2.03e-1, 2.40e-1]

2.21e-1 [2.04e-1, 2.40e-1]

2

2.30e-2 [1.73e-2, 3.05e-2]

2.30e-2 [1.73e-2, 3.05e-2]

2.30e-2 [1.73e-2, 3.05e-2]

2.5

0 [0, 1.92e-3]

0 [0, 1.92e-3]

0 [0, 1.92e-3]

3

0 [0, 1.92e-3]

0 [0, 1.92e-3]

0 [0, 1.92e-3]

Same LLRs: the decisions frame by frame

The decoders received identical LLRs. Over all the points: the frames where a library makes as many bit errors as comnum, and the frames it decodes (or fails to decode) as comnum does.

Library

Points

Frames

Same bit errors

Same frame outcome

OptiCommPy (numba)

4

8000

7996 (99.95 %)

8000 (100.00 %)

Sionna

4

8000

7981 (99.76 %)

7999 (99.99 %)

convolutional (133, 171), soft Viterbi: decoder time

Median of 5 runs, ms per frame; workload: decoder only, 200 frames at Eb/N0 = 3.0 dB. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

GPU

Relative

First call

comnum (functions)

float64

0.71

0.703

—

1.00

0.00199 s

Sionna

float32 (config.precision = ‘single’)

3.55

2.85

0.8

5.00

0.0987 s

5G NR LDPC (1024, 2048): decoder time

Median of 5 runs, ms per frame; workload: decoder only, 200 frames at Eb/N0 = 2.0 dB. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

GPU

Relative

First call

comnum (functions)

float64

7.81

7.79

—

1.00

0.016 s

Sionna

float32 (config.precision = ‘single’)

32.8

4.73

0.135

4.20

0.0476 s

LDPC (1024, 512), same LLRs: decoder time

Median of 5 runs, ms per frame; workload: decoder only, 200 frames at Eb/N0 = 2.0 dB. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

GPU

Relative

First call

comnum (functions)

float64

1.51

1.51

—

1.00

0.00307 s

OptiCommPy (numba)

float32 (decodeLDPC default prec = np.float32)

7.88

6.99

—

5.23

5.34 s

Sionna

float32 (config.precision = ‘single’)

6.39

0.873

0.114

4.24

0.0188 s

convolutional (133, 171), soft Viterbi: whole-chain time

Median of 5 runs, ms per frame; workload: whole chain, 200 frames at Eb/N0 = 3.0 dB. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

GPU

Relative

First call

comnum (functions)

float64

0.896

0.895

—

1.00

0.00199 s

comnum.run_spec

float64

0.882

0.0723

—

0.984

0.177 s

Sionna

float32 (config.precision = ‘single’)

3.84

3.12

1.31

4.29

0.0987 s

5G NR LDPC (1024, 2048): whole-chain time

Median of 5 runs, ms per frame; workload: whole chain, 200 frames at Eb/N0 = 2.0 dB. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

GPU

Relative

First call

comnum (functions)

float64

7.96

8.20

—

1.00

0.016 s

Sionna

float32 (config.precision = ‘single’)

33.8

4.79

0.132

4.24

0.0476 s

LDPC (1024, 512), same LLRs: whole-chain time

Median of 5 runs, ms per frame; workload: whole chain, 200 frames at Eb/N0 = 2.0 dB. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

GPU

Relative

First call

comnum (functions)

float64

1.59

1.55

—

1.00

0.00307 s

OptiCommPy (numba)

float32 (decodeLDPC default prec = np.float32)

7.46

6.36

—

4.68

5.34 s

Sionna

float32 (config.precision = ‘single’)

6.62

1.09

0.26

4.15

0.0188 s

The commands of every run
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s4_coding.py --lib comnum --threads 1 --work h100/_work --out h100/_work/s4_coding_comnum_1.json
OMP_NUM_THREADS=24 OPENBLAS_NUM_THREADS=24 MKL_NUM_THREADS=24 VECLIB_MAXIMUM_THREADS=24 NUMBA_NUM_THREADS=24 RAYON_NUM_THREADS=24 python s4_coding.py --lib comnum --threads all --work h100/_work --out h100/_work/s4_coding_comnum_all.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s4_coding.py --lib comnum-spec --threads 1 --work h100/_work --out h100/_work/s4_coding_comnum-spec_1.json
OMP_NUM_THREADS=24 OPENBLAS_NUM_THREADS=24 MKL_NUM_THREADS=24 VECLIB_MAXIMUM_THREADS=24 NUMBA_NUM_THREADS=24 RAYON_NUM_THREADS=24 python s4_coding.py --lib comnum-spec --threads all --work h100/_work --out h100/_work/s4_coding_comnum-spec_all.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s4_coding.py --lib opticommpy --threads 1 --work h100/_work --out h100/_work/s4_coding_opticommpy_1.json
OMP_NUM_THREADS=24 OPENBLAS_NUM_THREADS=24 MKL_NUM_THREADS=24 VECLIB_MAXIMUM_THREADS=24 NUMBA_NUM_THREADS=24 RAYON_NUM_THREADS=24 python s4_coding.py --lib opticommpy --threads all --work h100/_work --out h100/_work/s4_coding_opticommpy_all.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s4_coding.py --lib sionna --threads 1 --work h100/_work --out h100/_work/s4_coding_sionna_1.json
OMP_NUM_THREADS=24 OPENBLAS_NUM_THREADS=24 MKL_NUM_THREADS=24 VECLIB_MAXIMUM_THREADS=24 NUMBA_NUM_THREADS=24 RAYON_NUM_THREADS=24 python s4_coding.py --lib sionna --threads all --work h100/_work --out h100/_work/s4_coding_sionna_all.json --timing-only
OMP_NUM_THREADS=24 OPENBLAS_NUM_THREADS=24 MKL_NUM_THREADS=24 VECLIB_MAXIMUM_THREADS=24 NUMBA_NUM_THREADS=24 RAYON_NUM_THREADS=24 python s4_coding.py --lib sionna --threads gpu --work h100/_work --out h100/_work/s4_coding_sionna_gpu.json --timing-only

Split-step Fourier propagation, 16-QAM, 32 GBd, 10 x 80 km#

Scenario 5: single-polarisation split-step Fourier propagation of 32 GBd 16-QAM over 10 x 80 km of standard single-mode fibre, a fixed step of 1 km (80 steps per span), noiseless amplification at the end of every span (gain e^{alpha L}).

Parameters:

  • fibre: alpha = 0.2 dB/km, D = 17 ps/nm/km, gamma = 1.3 /W/km, fc = 193.1 THz

  • link: 10 x 80 km, ideal (noiseless) amplification after every span

  • step: 1 km (800 steps); reference: comnum at 0.05 km (16000 steps)

  • signal: 16-QAM, 32 GBd, RRC roll-off 0.1, 4 samples per symbol, 4 dBm; 2^14 symbols (quick: 2^11)

  • timing: one propagation over the 10 spans

Script: benchmarks/libraries/s5_ssfm.py.

The script
"""Scenario 5: single-polarisation split-step Fourier propagation of 32 GBd 16-QAM over
10 x 80 km of standard single-mode fibre, a fixed step of 1 km (80 steps per span),
noiseless amplification at the end of every span (gain e^{alpha L}).

The input field is the same for every library: 16-QAM symbols of comnum (seed 1),
shaped by a root-raised cosine (roll-off 0.1, 4 samples per symbol, 128 GS/s) and
scaled to 4 dBm; it is drawn again, identically, by every process. Every library
propagates it with its own split-step solver (symmetric steps, nonlinear phase over
the whole step in all three); run_all.py compares the output fields (NMSE) with that
of comnum in double precision and with a step-converged reference (comnum, 0.05 km).

Sionna's equation is the complex conjugate of the one of comnum and OptiCommPy
(dE/dz = ... + j beta_2/2 d^2E/dt^2 - j gamma |E|^2 E): its field is compared in their
convention, conj(sionna(conj(x))), and the NMSE of the plain output is reported too.
Sionna propagates one span per call (SSFM) and amplifies with its EDFA of noise figure
0 (no noise). OptiCommPy's ssfm is NumPy only (no numba kernel): its two variants run
the same code. In the mode gpu, OptiCommPy runs its CuPy solver
(optic.models.modelsGPU.ssfm: the same steps, every array on the device; the input is
copied to the device and the output back to the host inside the timed call) in
complex128 and in complex64. Timing: one propagation over the 10 spans, median of 5
runs after a warm-up run.

    python s5_ssfm.py --lib opticommpy --threads 1 [--quick] --out results/_work/s5.json
"""

from __future__ import annotations

import math
import time
from pathlib import Path

import common
import numpy as np

SCENARIO = "s5_ssfm"
TITLE = "Split-step Fourier propagation, 16-QAM, 32 GBd, 10 x 80 km"
CASES = {
    "ssfm": {
        "comnum": True,
        "komm": "no fibre model",
        "opticommpy": True,
        "opticommpy-nojit": True,
        "sionna": True,
    }
}
# The libraries with a GPU path in this scenario (run_all.py: the mode gpu).
GPU_LIBS = {"sionna", "opticommpy"}
ALPHA_DB_KM, D_PS_NM_KM, GAMMA, FC = 0.2, 17.0, 1.3, 193.1e12
SPAN_KM, N_SPANS, STEP_KM = 80.0, 10, 1.0
REF_STEP_KM = 0.05
SYMBOL_RATE, SPS = 32e9, 4
FS = SYMBOL_RATE * SPS
POWER_DBM = 4.0

PARAMETERS = {
    "fibre": "alpha = 0.2 dB/km, D = 17 ps/nm/km, gamma = 1.3 /W/km, fc = 193.1 THz",
    "link": "10 x 80 km, ideal (noiseless) amplification after every span",
    "step": "1 km (800 steps); reference: comnum at 0.05 km (16000 steps)",
    "signal": "16-QAM, 32 GBd, RRC roll-off 0.1, 4 samples per symbol, 4 dBm; 2^14 symbols (quick: 2^11)",
    "timing": "one propagation over the 10 spans",
}


def n_symbols(quick: bool) -> int:
    return 2**11 if quick else 2**14


def input_field(quick: bool) -> np.ndarray:
    import comnum

    rng = comnum.Rng(1)
    c = comnum.Constellation("qam", 16).normalized()
    _, s = comnum.symbols(c, n_symbols(quick), rng)
    h = comnum.pulse_shape("rrc", SPS, n_taps=1025, rolloff=0.1)
    x = comnum.pnorm(comnum.fir_filter(h, comnum.upsample(s, SPS)))
    return x * math.sqrt(comnum.dbm2w(POWER_DBM))


def beta2_ps2_km() -> float:
    c = 299_792_458.0
    lam = c / FC
    return -(D_PS_NM_KM * 1e-6) * lam**2 / (2 * math.pi * c) * 1e24 * 1e3  # s^2/m -> ps^2/km


def prop_comnum(precision: str, step_km: float = STEP_KM):
    import comnum

    f = comnum.FiberParams(
        alpha_db_km=ALPHA_DB_KM,
        d_ps_nm_km=D_PS_NM_KM,
        gamma_w_km=GAMMA,
        span_km=SPAN_KM,
        n_spans=N_SPANS,
        fc_hz=FC,
    )
    link = comnum.Ssfm(f, step_km=step_km, amplification="ideal")
    return lambda x: link.propagate(x, FS, precision=precision)


def prop_opticommpy(gpu: bool = False, prec=np.complex128):
    """OptiCommPy's split-step: the NumPy solver, or its CuPy copy on the GPU
    (``optic.models.modelsGPU.ssfm``, which takes and returns NumPy arrays: the
    transfers to and from the device are inside the timed call)."""
    if gpu:
        from optic.models.modelsGPU import ssfm
    else:
        from optic.models.channels import ssfm
    from optic.utils import parameters

    p = parameters()
    p.Ltotal, p.Lspan, p.hz = SPAN_KM * N_SPANS, SPAN_KM, STEP_KM
    p.alpha, p.D, p.gamma, p.Fc, p.Fs = ALPHA_DB_KM, D_PS_NM_KM, GAMMA, FC, FS
    p.amp, p.prgsBar, p.prec = "ideal", False, prec
    return lambda x: ssfm(x, p)


def prop_sionna(precision: str):
    import torch
    from sionna.phy.channel.optical import EDFA, SSFM

    alpha = ALPHA_DB_KM / (10 * math.log10(math.e))  # 1/km
    span = SSFM(
        alpha=alpha,
        beta_2=beta2_ps2_km(),
        f_c=FC,
        gamma=GAMMA,
        length=SPAN_KM,
        n_ssfm=round(SPAN_KM / STEP_KM),
        sample_duration=1e12 / FS,  # ps
        t_norm=1e-12,
        with_amplification=False,
        precision=precision,
    )
    amp = EDFA(g=math.exp(alpha * SPAN_KM), f=0.0, f_c=FC, dt=1 / FS, precision=precision)
    cdtype = torch.complex64 if precision == "single" else torch.complex128

    def run(x: np.ndarray) -> np.ndarray:
        with torch.inference_mode():
            u = torch.from_numpy(x).to(device=common.TORCH_DEVICE, dtype=cdtype)
            for _ in range(N_SPANS):
                u = amp(span(u))
            return common.to_numpy(u)

    return run


def run_lib(args, todo: list[str]) -> list[dict]:
    if args.lib == "sionna":
        threading = common.setup_torch(args.threads)
    elif args.lib == "opticommpy" and args.threads == "gpu":
        threading = common.setup_cupy()
    else:
        threading = {}
    x = input_field(args.quick)
    work = Path(args.work)
    work.mkdir(parents=True, exist_ok=True)
    if args.lib == "comnum":
        variants = {"f64": (prop_comnum("f64"), "float64"), "f32": (prop_comnum("f32"), "float32")}
    elif args.lib == "sionna":
        variants = {
            "single": (prop_sionna("single"), "float32 (precision = 'single', the default)"),
            "double": (prop_sionna("double"), "float64 (precision = 'double')"),
        }
    elif args.lib == "opticommpy" and args.threads == "gpu":
        import cupy as cp

        variants = {
            "f64": (prop_opticommpy(True, cp.complex128), "float64 (prec = cp.complex128)"),
            "f32": (prop_opticommpy(True, cp.complex64), "float32 (prec = cp.complex64)"),
        }
    else:
        variants = {"f64": (prop_opticommpy(), "float64 (prec = np.complex128)")}
    out = []
    for name, (prop, precision) in variants.items():
        res = {
            "case": "ssfm",
            "variant": name,
            "precision": precision,
            "samples": int(x.size),
            **threading,
        }
        if args.lib == "sionna":
            fwd = lambda prop=prop: np.conj(prop(np.conj(x)))  # noqa: E731
        else:
            fwd = lambda prop=prop: prop(x)  # noqa: E731
        t0 = time.perf_counter()
        y = fwd()
        res["first_call_s"] = time.perf_counter() - t0
        if not args.timing_only:
            np.save(work / f"{SCENARIO}_{args.lib}_{name}.npy", np.asarray(y, dtype=np.complex128))
            if args.lib == "sionna":
                np.save(
                    work / f"{SCENARIO}_{args.lib}_{name}_plain.npy",
                    np.asarray(prop(x), dtype=np.complex128),
                )
        res["timing"] = common.time_it(fwd)
        res["timing"].update(
            units=1,
            unit="propagation",
            workload=f"{x.size} samples, {N_SPANS} x {SPAN_KM:g} km, {STEP_KM:g} km steps",
        )
        out.append(res)
    if args.lib == "comnum" and not args.timing_only:
        t0 = time.perf_counter()
        np.save(work / f"{SCENARIO}_reference.npy", prop_comnum("f64", REF_STEP_KM)(x))
        out.append(
            {
                "case": "ssfm",
                "variant": "reference",
                "precision": "float64",
                "step_km": REF_STEP_KM,
                "wall_s": time.perf_counter() - t0,
            }
        )
    return out


def nmse(a: np.ndarray, b: np.ndarray) -> float:
    return float(np.sum(np.abs(a - b) ** 2) / np.sum(np.abs(b) ** 2))


def postprocess(merged: dict, work: Path) -> dict:
    """The NMSE of every output field against comnum (f64, 1 km) and the reference."""
    fields = {}
    for path in sorted(work.glob(f"{SCENARIO}_*.npy")):
        fields[path.stem.removeprefix(f"{SCENARIO}_")] = np.load(path)
    base, ref = fields.get("comnum_f64"), fields.get("reference")
    rows = []
    for name, y in fields.items():
        if name == "reference":
            continue
        rows.append(
            {
                "field": name,
                "nmse_vs_comnum_f64": nmse(y, base) if base is not None else None,
                "nmse_vs_reference": nmse(y, ref) if ref is not None else None,
            }
        )
    return {"nmse": rows, "reference_step_km": REF_STEP_KM}


RUNNERS = {"comnum": run_lib, "opticommpy": run_lib, "opticommpy-nojit": run_lib, "sionna": run_lib}

if __name__ == "__main__":
    common.main(SCENARIO, CASES, RUNNERS)

Support

Case

comnum (functions)

OptiCommPy (numba)

Sionna

split-step Fourier

yes

yes

yes

Output fields

NMSE of the output field, sum |a - b|^2 / sum |b|^2, against comnum in float64 at the same step and against the step-converged reference (comnum, 0.05 km). The NMSE against the reference is the error of the 1 km step itself.

Field

NMSE vs comnum (float64, 1 km)

NMSE vs reference

comnum, float32

1.14e-8

7.45e-8

comnum, float64

0

3.69e-8

OptiCommPy, float64

2.24e-26

3.69e-8

Sionna, float64

4.17e-26

3.69e-8

Sionna, float64, its own convention (not conjugated)

2.03e0

2.03e0

Sionna, float32

2.61e-8

1.05e-7

Sionna, float32, its own convention (not conjugated)

2.03e0

2.03e0

split-step Fourier: time

Median of 5 runs, s per propagation; workload: 65536 samples, 10 x 80 km, 1 km steps. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

GPU

Relative

First call

comnum (functions), f64

float64

1.21

1.20

—

1.00

1.21 s

comnum (functions), f32

float32

0.627

0.637

—

0.519

0.673 s

OptiCommPy (numba), f64

float64 (prec = np.complex128)

3.56

3.65

0.0929

2.95

3.87 s

OptiCommPy (numba), f32

float32 (prec = cp.complex64)

—

—

0.0938

—

1.78 s

Sionna, single

float32 (precision = ‘single’, the default)

7.31

1.12

0.167

6.05

7.39 s

Sionna, double

float64 (precision = ‘double’)

6.99

2.26

0.171

5.79

7.37 s

The commands of every run
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s5_ssfm.py --lib comnum --threads 1 --work h100/_work --out h100/_work/s5_ssfm_comnum_1.json
OMP_NUM_THREADS=24 OPENBLAS_NUM_THREADS=24 MKL_NUM_THREADS=24 VECLIB_MAXIMUM_THREADS=24 NUMBA_NUM_THREADS=24 RAYON_NUM_THREADS=24 python s5_ssfm.py --lib comnum --threads all --work h100/_work --out h100/_work/s5_ssfm_comnum_all.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s5_ssfm.py --lib opticommpy --threads 1 --work h100/_work --out h100/_work/s5_ssfm_opticommpy_1.json
OMP_NUM_THREADS=24 OPENBLAS_NUM_THREADS=24 MKL_NUM_THREADS=24 VECLIB_MAXIMUM_THREADS=24 NUMBA_NUM_THREADS=24 RAYON_NUM_THREADS=24 python s5_ssfm.py --lib opticommpy --threads all --work h100/_work --out h100/_work/s5_ssfm_opticommpy_all.json --timing-only
OMP_NUM_THREADS=24 OPENBLAS_NUM_THREADS=24 MKL_NUM_THREADS=24 VECLIB_MAXIMUM_THREADS=24 NUMBA_NUM_THREADS=24 RAYON_NUM_THREADS=24 python s5_ssfm.py --lib opticommpy --threads gpu --work h100/_work --out h100/_work/s5_ssfm_opticommpy_gpu.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s5_ssfm.py --lib sionna --threads 1 --work h100/_work --out h100/_work/s5_ssfm_sionna_1.json
OMP_NUM_THREADS=24 OPENBLAS_NUM_THREADS=24 MKL_NUM_THREADS=24 VECLIB_MAXIMUM_THREADS=24 NUMBA_NUM_THREADS=24 RAYON_NUM_THREADS=24 python s5_ssfm.py --lib sionna --threads all --work h100/_work --out h100/_work/s5_ssfm_sionna_all.json --timing-only
OMP_NUM_THREADS=24 OPENBLAS_NUM_THREADS=24 MKL_NUM_THREADS=24 VECLIB_MAXIMUM_THREADS=24 NUMBA_NUM_THREADS=24 RAYON_NUM_THREADS=24 python s5_ssfm.py --lib sionna --threads gpu --work h100/_work --out h100/_work/s5_ssfm_sionna_gpu.json --timing-only

On another machine: rtx4090 (NVIDIA GeForce RTX 4090)#

The same scripts, run on a second machine (run_all.py --results results/rtx4090): Sionna (and, for the split-step, OptiCommPy) on its GPU in the column GPU, and every library on its CPU in the other columns. The Relative column stays the 1-thread time over that of comnum (functions) on 1 thread of this machine.

This machine is a RunPod Secure Cloud pod with one NVIDIA GeForce RTX 4090 (24 GB), the PyTorch template (torch 2.13, CUDA 13.0): a container with a CPU quota on a shared host.

Machine and versions#

CPU

AMD EPYC 7K62 48-Core Processor

Cores

96 logical; 11 available to the process (a container’s CPU quota: the thread pools of the mode all threads are sized to it)

Memory

251.5 GB

GPU

NVIDIA GeForce RTX 4090, 24564 MiB, driver 595.91.07

System

Ubuntu 24.04.5 LTS

Python

3.12.3

Rust

—

comnum commit

45a7c78f80fc

Generated

2026-11-06T17:38:20+00:00

comnum

0.3.1

komm

not installed

OptiCommPy

0.11.0

sionna

2.2.0

torch

2.13.0+cu130

numba

0.68.1

numpy

2.5.2

scipy

1.18.1

Uncoded QPSK and 16-QAM over AWGN#

Scenario 1: uncoded Gray QPSK and 16-QAM over AWGN, BER against the exact closed form.

Parameters:

  • Eb/N0 (dB): 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10

  • bits per point: 10^7 (quick: 2·10^5), in blocks of 10^6 (quick: 10^5)

  • timing: one block of 10^6 bits (quick: 2·10^5) at Eb/N0 = 6 dB

  • theory: exact BER of Gray square QAM, Cho & Yoon, IEEE Trans. Commun. 50(7), 2002

Script: benchmarks/libraries/s1_awgn.py.

The script
"""Scenario 1: uncoded Gray QPSK and 16-QAM over AWGN, BER against the exact closed form.

Every library draws its own bits and noise, maps with its own Gray mapper, adds the
noise of its own channel for the Eb/N0 of the point and decides by minimum distance
(Sionna: hard decisions of max-log LLRs, the same decisions). The BER is counted on
the same number of bits per point for every library, in blocks of the same size.
Timing: one block of ``TIMING_BITS`` bits at Eb/N0 = 6 dB (bits, mapping, noise,
decisions, error count), median of 5 runs after a warm-up run.

    python s1_awgn.py --lib comnum --threads 1 [--quick] --out results/_work/s1.json
"""

from __future__ import annotations

import math
import time

import common
import numpy as np
import theory

SCENARIO = "s1_awgn"
TITLE = "Uncoded QPSK and 16-QAM over AWGN"
ORDERS = {"qpsk": 4, "16qam": 16}
ALL = {lib: True for lib in common.LIBRARIES}
CASES = {"qpsk": ALL, "16qam": ALL}
EBN0 = [float(e) for e in range(11)]
TIMING_EBN0 = 6.0
TIMING_TRIALS = 40  # trials of the specification runner per timing run


def sizes(quick: bool) -> dict:
    if quick:
        return {"bits_per_point": 200_000, "block": 100_000, "timing_bits": 200_000, "trials": 10}
    return {
        "bits_per_point": 10_000_000,
        "block": 1_000_000,
        "timing_bits": 1_000_000,
        "trials": 100,
    }


PARAMETERS = {
    "ebn0_db": EBN0,
    "bits_per_point": "10^7 (quick: 2·10^5), in blocks of 10^6 (quick: 10^5)",
    "timing": "one block of 10^6 bits (quick: 2·10^5) at Eb/N0 = 6 dB",
    "theory": "exact BER of Gray square QAM, Cho & Yoon, IEEE Trans. Commun. 50(7), 2002",
}


# ---------------------------------------------------------------------------- chains
# chain(n_bits, ebn0_db) -> bit errors, for one library and one constellation


def chain_comnum(m: int, seed: int):
    import comnum

    c = comnum.Constellation("qam", m).normalized()
    k = int(math.log2(m))
    rng = comnum.Rng(seed)

    def run(n: int, ebn0: float) -> int:
        b = comnum.bits(n, rng)
        y = comnum.awgn_ebn0(c.map_bits(b), ebn0, k, rng)
        return int(np.count_nonzero(c.demap_bits(y) != b))

    return run, "float64"


def chain_komm(m: int, seed: int):
    import komm

    const = komm.QAMConstellation(m)
    lab = komm.ReflectedRectangularLabeling(int(math.log2(m)))
    k = int(math.log2(m))
    es = const.mean_energy()
    rng = np.random.default_rng(seed)

    def run(n: int, ebn0: float) -> int:
        b = rng.integers(0, 2, n)
        x = const.indices_to_symbols(lab.bits_to_indices(b))
        n0 = es / (k * 10 ** (ebn0 / 10))
        y = komm.GaussianChannel(n0, rng=rng).transmit(x)
        bh = lab.indices_to_bits(const.closest_indices(y))
        return int(np.count_nonzero(bh.ravel() != b))

    return run, "float64"


def chain_opticommpy(m: int, seed: int):
    from optic.comm.modulation import demodulateGray, modulateGray
    from optic.models.channels import awgn
    from optic.utils import parameters

    k = int(math.log2(m))
    rng = np.random.default_rng(seed)
    state = {"block": 0}

    def run(n: int, ebn0: float) -> int:
        b = rng.integers(0, 2, n)
        x = modulateGray(b, m, "qam")
        p = parameters()
        p.snr = ebn0 + 10 * math.log10(k)  # Es/N0 (Fs = B = 1)
        p.seed = seed * 1_000_003 + state["block"]  # awgn re-seeds its generator per call
        state["block"] += 1
        y = awgn(x, p)
        bh = demodulateGray(y, m, "qam")
        return int(np.count_nonzero(bh != b))

    return run, "complex64 symbols (its Gray constellation), float64 noise"


def chain_sionna(m: int, seed: int):
    import torch
    from sionna.phy import config
    from sionna.phy.channel import AWGN
    from sionna.phy.mapping import BinarySource, Demapper, Mapper
    from sionna.phy.utils import ebnodb2no

    config.seed = seed
    k = int(math.log2(m))
    source, mapper, channel = BinarySource(), Mapper("qam", k), AWGN()
    demapper = Demapper("maxlog", "qam", k, hard_out=True)

    def run(n: int, ebn0: float) -> int:
        with torch.inference_mode():
            b = source([1, n])
            no = ebnodb2no(ebn0, k, 1.0)
            y = channel(mapper(b), no)
            bh = demapper(y, no)
            return int((bh != b).sum())

    return run, "float32 (config.precision = 'single')"


CHAINS = {
    "comnum": chain_comnum,
    "komm": chain_komm,
    "opticommpy": chain_opticommpy,
    "opticommpy-nojit": chain_opticommpy,
    "sionna": chain_sionna,
}


def run_chains(args, todo: list[str]) -> list[dict]:
    if args.lib == "sionna":
        threading = common.setup_torch(args.threads)
    else:
        threading = {}
    sz = sizes(args.quick)
    out = []
    for case in todo:
        m = ORDERS[case]
        run, precision = CHAINS[args.lib](m, args.seed)
        res = {"case": case, "precision": precision, **threading}
        n = sz["timing_bits"]
        if args.lib == "opticommpy-nojit":
            n = min(n, 100_000)  # plain-Python kernels: a smaller block, the time is per bit
        t0 = time.perf_counter()
        run(n, TIMING_EBN0)  # the first call: JIT compilation (numba), lazy initialisation
        res["first_call_s"] = time.perf_counter() - t0
        if not args.timing_only:
            pts = []
            t0 = time.perf_counter()
            for e in EBN0:
                errors = 0
                for _ in range(sz["bits_per_point"] // sz["block"]):
                    errors += run(sz["block"], e)
                pts.append(
                    common.rate_point(
                        errors, sz["bits_per_point"], float(theory.ber_qam_awgn(m, e)), ebn0_db=e
                    )
                )
            res["points"] = pts
            res["sweep_s"] = time.perf_counter() - t0
        last = {}
        res["timing"] = common.time_it(
            lambda n=n, run=run, last=last: last.__setitem__("e", run(n, TIMING_EBN0))
        )
        res["timing"].update(units=n, unit="bit", workload=f"{n} bits at Eb/N0 = {TIMING_EBN0} dB")
        res["timing_ber"] = last["e"] / n
        out.append(res)
    return out


# ------------------------------------------------------------- specification runner


def spec(m: int, ebn0: list[float], bits: int, trials: int, seed: int) -> dict:
    k = int(math.log2(m))
    return {
        "name": f"bench_awgn_{m}qam",
        "seed": seed,
        "batch": trials,
        "grid": {"ebn0_db": ebn0},
        "link": [
            {
                "type": "source",
                "constellation": {"kind": "qam", "m": m},
                "n_symbols": bits // (k * trials),
            },
            {"type": "awgn_ebn0", "ebn0_db": "$ebn0_db"},
        ],
        "metrics": {"report": ["ber"], "scale": "none"},
        "stop": {"min_trials": trials, "max_trials": trials},
    }


def spec_points(results: list[dict], m: int) -> list[dict]:
    pts = []
    for r in results:
        e = float(r["condition"]["ebn0_db"])
        ber = r["metrics"]["ber"]
        errors = ber["count"] if "count" in ber else ber["errors"]  # run_spec / the binary's report
        pts.append(
            common.rate_point(errors, ber["total"], float(theory.ber_qam_awgn(m, e)), ebn0_db=e)
        )
    return pts


def run_spec_lib(args, todo: list[str], *, rust: bool = False) -> list[dict]:
    """``comnum-spec`` (``comnum.run_spec``), or with ``rust`` ``comnum-rust`` (the binary)."""
    sz = sizes(args.quick)
    n = sz["timing_bits"]
    workload = f"{n} bits at Eb/N0 = {TIMING_EBN0} dB, {TIMING_TRIALS} trials"
    if rust:
        workload += " (process wall time, start-up included)"
    out = []
    for case in todo:
        m = ORDERS[case]
        out.append(
            common.spec_case(
                args,
                {"case": case, "precision": "float64"},
                spec(m, EBN0, sz["bits_per_point"], sz["trials"], args.seed),
                spec(m, [TIMING_EBN0], n, TIMING_TRIALS, args.seed + 1),
                lambda rows, m=m: spec_points(rows, m),
                rust=rust,
                units=n,
                unit="bit",
                workload=workload,
            )
        )
    return out


def run_rust_lib(args, todo: list[str]) -> list[dict]:
    return run_spec_lib(args, todo, rust=True)


RUNNERS = {
    **{lib: run_chains for lib in CHAINS},
    "comnum-spec": run_spec_lib,
    "comnum-rust": run_rust_lib,
}

if __name__ == "__main__":
    common.main(SCENARIO, CASES, RUNNERS)

Support

Case

comnum (functions)

comnum.run_spec

OptiCommPy (numba)

Sionna

QPSK

yes

yes

yes

yes

16-QAM

yes

yes

yes

yes

QPSK: error rates

Library

Precision

Against

max abs(z), BER

Agrees

BER interval

comnum (functions)

float64

theory

1.90

yes

binomial

comnum.run_spec

float64

theory

2.53

yes

binomial

OptiCommPy (numba)

complex64 symbols (its Gray constellation), float64 noise

theory

3.31

yes

binomial

Sionna

float32 (config.precision = ‘single’)

theory

2.13

yes

binomial

BER and its 95 % interval, every point (10,000,000 bits per point)

Eb/N0 (dB)

theory

comnum (functions)

comnum.run_spec

OptiCommPy (numba)

Sionna

0

7.86e-2

7.86e-2 [7.84e-2, 7.88e-2]

7.87e-2 [7.85e-2, 7.89e-2]

7.86e-2 [7.84e-2, 7.88e-2]

7.86e-2 [7.84e-2, 7.88e-2]

1

5.63e-2

5.63e-2 [5.62e-2, 5.65e-2]

5.63e-2 [5.61e-2, 5.64e-2]

5.63e-2 [5.61e-2, 5.64e-2]

5.61e-2 [5.60e-2, 5.63e-2]

2

3.75e-2

3.75e-2 [3.74e-2, 3.76e-2]

3.74e-2 [3.73e-2, 3.76e-2]

3.75e-2 [3.73e-2, 3.76e-2]

3.76e-2 [3.75e-2, 3.77e-2]

3

2.29e-2

2.28e-2 [2.27e-2, 2.29e-2]

2.30e-2 [2.29e-2, 2.31e-2]

2.29e-2 [2.28e-2, 2.30e-2]

2.29e-2 [2.28e-2, 2.30e-2]

4

1.25e-2

1.25e-2 [1.25e-2, 1.26e-2]

1.25e-2 [1.24e-2, 1.26e-2]

1.25e-2 [1.24e-2, 1.26e-2]

1.25e-2 [1.25e-2, 1.26e-2]

5

5.95e-3

5.95e-3 [5.90e-3, 6.00e-3]

5.96e-3 [5.92e-3, 6.01e-3]

5.93e-3 [5.89e-3, 5.98e-3]

5.93e-3 [5.88e-3, 5.98e-3]

6

2.39e-3

2.39e-3 [2.36e-3, 2.42e-3]

2.41e-3 [2.38e-3, 2.44e-3]

2.43e-3 [2.40e-3, 2.46e-3]

2.39e-3 [2.36e-3, 2.42e-3]

7

7.73e-4

7.56e-4 [7.39e-4, 7.73e-4]

7.77e-4 [7.60e-4, 7.95e-4]

8.02e-4 [7.84e-4, 8.20e-4]

7.88e-4 [7.70e-4, 8.05e-4]

8

1.91e-4

1.84e-4 [1.76e-4, 1.92e-4]

1.93e-4 [1.84e-4, 2.01e-4]

1.90e-4 [1.82e-4, 1.99e-4]

1.82e-4 [1.73e-4, 1.90e-4]

9

3.36e-5

3.21e-5 [2.88e-5, 3.58e-5]

3.41e-5 [3.07e-5, 3.79e-5]

3.42e-5 [3.08e-5, 3.80e-5]

3.57e-5 [3.22e-5, 3.96e-5]

10

3.87e-6

4.00e-6 [2.94e-6, 5.45e-6]

3.20e-6 [2.27e-6, 4.52e-6]

4.10e-6 [3.02e-6, 5.56e-6]

3.70e-6 [2.68e-6, 5.10e-6]

16-QAM: error rates

Library

Precision

Against

max abs(z), BER

Agrees

BER interval

comnum (functions)

float64

theory

2.15

yes

binomial

comnum.run_spec

float64

theory

1.64

yes

binomial

OptiCommPy (numba)

complex64 symbols (its Gray constellation), float64 noise

theory

1.42

yes

binomial

Sionna

float32 (config.precision = ‘single’)

theory

2.41

yes

binomial

BER and its 95 % interval, every point (10,000,000 bits per point)

Eb/N0 (dB)

theory

comnum (functions)

comnum.run_spec

OptiCommPy (numba)

Sionna

0

1.41e-1

1.41e-1 [1.41e-1, 1.41e-1]

1.41e-1 [1.41e-1, 1.41e-1]

1.41e-1 [1.41e-1, 1.41e-1]

1.41e-1 [1.41e-1, 1.41e-1]

1

1.19e-1

1.19e-1 [1.19e-1, 1.19e-1]

1.19e-1 [1.19e-1, 1.19e-1]

1.19e-1 [1.19e-1, 1.19e-1]

1.19e-1 [1.19e-1, 1.19e-1]

2

9.77e-2

9.78e-2 [9.76e-2, 9.80e-2]

9.77e-2 [9.75e-2, 9.79e-2]

9.78e-2 [9.76e-2, 9.80e-2]

9.75e-2 [9.73e-2, 9.77e-2]

3

7.75e-2

7.75e-2 [7.74e-2, 7.77e-2]

7.74e-2 [7.73e-2, 7.76e-2]

7.75e-2 [7.73e-2, 7.76e-2]

7.74e-2 [7.73e-2, 7.76e-2]

4

5.86e-2

5.87e-2 [5.85e-2, 5.88e-2]

5.86e-2 [5.84e-2, 5.87e-2]

5.85e-2 [5.84e-2, 5.87e-2]

5.85e-2 [5.84e-2, 5.87e-2]

5

4.19e-2

4.18e-2 [4.17e-2, 4.19e-2]

4.19e-2 [4.17e-2, 4.20e-2]

4.20e-2 [4.19e-2, 4.21e-2]

4.20e-2 [4.19e-2, 4.21e-2]

6

2.79e-2

2.79e-2 [2.78e-2, 2.80e-2]

2.79e-2 [2.78e-2, 2.80e-2]

2.78e-2 [2.77e-2, 2.79e-2]

2.79e-2 [2.78e-2, 2.81e-2]

7

1.70e-2

1.70e-2 [1.69e-2, 1.71e-2]

1.70e-2 [1.69e-2, 1.71e-2]

1.70e-2 [1.69e-2, 1.70e-2]

1.70e-2 [1.69e-2, 1.71e-2]

8

9.25e-3

9.24e-3 [9.18e-3, 9.30e-3]

9.24e-3 [9.18e-3, 9.30e-3]

9.22e-3 [9.16e-3, 9.28e-3]

9.24e-3 [9.19e-3, 9.30e-3]

9

4.39e-3

4.35e-3 [4.30e-3, 4.39e-3]

4.42e-3 [4.38e-3, 4.47e-3]

4.40e-3 [4.36e-3, 4.44e-3]

4.37e-3 [4.33e-3, 4.41e-3]

10

1.75e-3

1.75e-3 [1.73e-3, 1.78e-3]

1.78e-3 [1.75e-3, 1.80e-3]

1.76e-3 [1.73e-3, 1.79e-3]

1.76e-3 [1.73e-3, 1.78e-3]

QPSK: time

Median of 5 runs, ns per bit; workload: 1000000 bits at Eb/N0 = 6.0 dB. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

GPU

Relative

First call

comnum (functions)

float64

50.4

50.7

—

1.00

0.0588 s

comnum.run_spec

float64

42.2

8.56

—

0.836

0.0421 s

OptiCommPy (numba)

complex64 symbols (its Gray constellation), float64 noise

105

73.0

—

2.09

3.3 s

Sionna

float32 (config.precision = ‘single’)

123

48.4

0.748

2.44

0.148 s

16-QAM: time

Median of 5 runs, ns per bit; workload: 1000000 bits at Eb/N0 = 6.0 dB. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

GPU

Relative

First call

comnum (functions)

float64

27.1

27.2

—

1.00

0.0273 s

comnum.run_spec

float64

29.0

5.16

—

1.07

0.0271 s

OptiCommPy (numba)

complex64 symbols (its Gray constellation), float64 noise

100

51.0

—

3.71

0.108 s

Sionna

float32 (config.precision = ‘single’)

219

63.1

0.832

8.10

0.25 s

The commands of every run
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s1_awgn.py --lib comnum --threads 1 --work runpod/_work --out runpod/_work/s1_awgn_comnum_1.json
OMP_NUM_THREADS=11 OPENBLAS_NUM_THREADS=11 MKL_NUM_THREADS=11 VECLIB_MAXIMUM_THREADS=11 NUMBA_NUM_THREADS=11 RAYON_NUM_THREADS=11 python s1_awgn.py --lib comnum --threads all --work runpod/_work --out runpod/_work/s1_awgn_comnum_all.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s1_awgn.py --lib comnum-spec --threads 1 --work runpod/_work --out runpod/_work/s1_awgn_comnum-spec_1.json
OMP_NUM_THREADS=11 OPENBLAS_NUM_THREADS=11 MKL_NUM_THREADS=11 VECLIB_MAXIMUM_THREADS=11 NUMBA_NUM_THREADS=11 RAYON_NUM_THREADS=11 python s1_awgn.py --lib comnum-spec --threads all --work runpod/_work --out runpod/_work/s1_awgn_comnum-spec_all.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s1_awgn.py --lib opticommpy --threads 1 --work runpod/_work --out runpod/_work/s1_awgn_opticommpy_1.json
OMP_NUM_THREADS=11 OPENBLAS_NUM_THREADS=11 MKL_NUM_THREADS=11 VECLIB_MAXIMUM_THREADS=11 NUMBA_NUM_THREADS=11 RAYON_NUM_THREADS=11 python s1_awgn.py --lib opticommpy --threads all --work runpod/_work --out runpod/_work/s1_awgn_opticommpy_all.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s1_awgn.py --lib sionna --threads 1 --work runpod/_work --out runpod/_work/s1_awgn_sionna_1.json
OMP_NUM_THREADS=11 OPENBLAS_NUM_THREADS=11 MKL_NUM_THREADS=11 VECLIB_MAXIMUM_THREADS=11 NUMBA_NUM_THREADS=11 RAYON_NUM_THREADS=11 python s1_awgn.py --lib sionna --threads all --work runpod/_work --out runpod/_work/s1_awgn_sionna_all.json --timing-only
OMP_NUM_THREADS=11 OPENBLAS_NUM_THREADS=11 MKL_NUM_THREADS=11 VECLIB_MAXIMUM_THREADS=11 NUMBA_NUM_THREADS=11 RAYON_NUM_THREADS=11 python s1_awgn.py --lib sionna --threads gpu --work runpod/_work --out runpod/_work/s1_awgn_sionna_gpu.json --timing-only

BPSK and QPSK over flat Rayleigh fading#

Scenario 2: BPSK and Gray QPSK over flat Rayleigh fading (one coefficient per symbol, perfectly known at the receiver), BER against the closed form.

Parameters:

  • Eb/N0 (dB): 0, 5, 10, 15, 20, 25, 30

  • bits per point: 10^7 (quick: 2·10^5), in blocks of 10^6 (quick: 10^5)

  • channel: y = h x + w, h ~ CN(0, 1) independent for every symbol, known at the receiver

  • timing: one block of 10^6 bits (quick: 2·10^5) at Eb/N0 = 10 dB

  • theory: BPSK: (1 - sqrt(G / (1 + G))) / 2; Gray QPSK: the same per bit (Proakis & Salehi, 5th ed., Sec. 13.3)

Script: benchmarks/libraries/s2_rayleigh.py.

The script
"""Scenario 2: BPSK and Gray QPSK over flat Rayleigh fading (one coefficient per
symbol, perfectly known at the receiver), BER against the closed form.

Every library draws its own bits, channel coefficients h ~ CN(0, 1) and noise, and
detects with its own coherent detector given h (zero forcing over the 1 x 1 channel:
comnum.mimo_detect, Sionna's LinearDetector, the mimo_rx block of the specification).
komm and OptiCommPy have no fading channel. Timing: one block of ``TIMING_BITS`` bits
at Eb/N0 = 10 dB, median of 5 runs after a warm-up run.

    python s2_rayleigh.py --lib sionna --threads 1 [--quick] --out results/_work/s2.json
"""

from __future__ import annotations

import math
import time

import common
import numpy as np
import theory

SCENARIO = "s2_rayleigh"
TITLE = "BPSK and QPSK over flat Rayleigh fading"
ORDERS = {"bpsk": 2, "qpsk": 4}
SUPPORT = {
    "comnum": True,
    "comnum-spec": True,
    "komm": "no fading channel",
    "opticommpy": "no fading channel",
    "opticommpy-nojit": "no fading channel",
    "sionna": True,
}
CASES = {"bpsk": SUPPORT, "qpsk": SUPPORT}
EBN0 = [0.0, 5.0, 10.0, 15.0, 20.0, 25.0, 30.0]
TIMING_EBN0 = 10.0
TIMING_TRIALS = 40

PARAMETERS = {
    "ebn0_db": EBN0,
    "bits_per_point": "10^7 (quick: 2·10^5), in blocks of 10^6 (quick: 10^5)",
    "channel": "y = h x + w, h ~ CN(0, 1) independent for every symbol, known at the receiver",
    "timing": "one block of 10^6 bits (quick: 2·10^5) at Eb/N0 = 10 dB",
    "theory": "BPSK: (1 - sqrt(G / (1 + G))) / 2; Gray QPSK: the same per bit (Proakis & Salehi, 5th ed., Sec. 13.3)",
}


def sizes(quick: bool) -> dict:
    if quick:
        return {"bits_per_point": 200_000, "block": 100_000, "timing_bits": 200_000, "trials": 10}
    return {
        "bits_per_point": 10_000_000,
        "block": 1_000_000,
        "timing_bits": 1_000_000,
        "trials": 100,
    }


def chain_comnum(m: int, seed: int):
    import comnum

    c = comnum.Constellation("psk" if m == 2 else "qam", m).normalized()
    k = int(math.log2(m))
    rng = comnum.Rng(seed)

    def run(n: int, ebn0: float) -> int:
        b = comnum.bits(n, rng)
        x = c.map_bits(b)
        h = comnum.mimo_channels(x.size, 1, 1, rng)
        n0 = 1.0 / (k * 10 ** (ebn0 / 10))
        y = h[:, 0, 0] * x + rng.complex_noise(x.size, n0)
        labels = comnum.mimo_detect(h, y[None, :], c, n0, "zf")
        bh = comnum.unpack_symbols(labels.ravel(), k)
        return int(np.count_nonzero(bh != b))

    return run, "float64"


def chain_sionna(m: int, seed: int):
    import torch
    from sionna.phy import config
    from sionna.phy.channel import FlatFadingChannel
    from sionna.phy.mapping import BinarySource, Mapper
    from sionna.phy.mimo import LinearDetector
    from sionna.phy.utils import ebnodb2no

    config.seed = seed
    k = int(math.log2(m))
    kind = "pam" if m == 2 else "qam"
    source, mapper = BinarySource(), Mapper(kind, k)
    channel = FlatFadingChannel(1, 1, return_channel=True)
    detector = LinearDetector(
        "zf", "bit", "maxlog", constellation_type=kind, num_bits_per_symbol=k, hard_out=True
    )

    def run(n: int, ebn0: float) -> int:
        with torch.inference_mode():
            b = source([n // k, k])
            x = mapper(b)  # [n/k, 1]
            no = ebnodb2no(ebn0, k, 1.0)
            y, h = channel(x, no)
            s = torch.full((n // k, 1, 1), float(no), dtype=y.dtype)
            bh = detector(y, h, s)  # [n/k, 1, k]
            return int((bh.reshape(b.shape) != b).sum())

    return run, "float32 (config.precision = 'single')"


CHAINS = {"comnum": chain_comnum, "sionna": chain_sionna}


def run_chains(args, todo: list[str]) -> list[dict]:
    threading = common.setup_torch(args.threads) if args.lib == "sionna" else {}
    sz = sizes(args.quick)
    out = []
    for case in todo:
        m = ORDERS[case]
        run, precision = CHAINS[args.lib](m, args.seed)
        res = {"case": case, "precision": precision, **threading}
        n = sz["timing_bits"]
        t0 = time.perf_counter()
        run(n, TIMING_EBN0)
        res["first_call_s"] = time.perf_counter() - t0
        if not args.timing_only:
            pts = []
            t0 = time.perf_counter()
            for e in EBN0:
                errors = sum(
                    run(sz["block"], e) for _ in range(sz["bits_per_point"] // sz["block"])
                )
                pts.append(
                    common.rate_point(
                        errors,
                        sz["bits_per_point"],
                        float(theory.ber_qam_rayleigh(m, e)),
                        ebn0_db=e,
                    )
                )
            res["points"] = pts
            res["sweep_s"] = time.perf_counter() - t0
        res["timing"] = common.time_it(lambda n=n, run=run: run(n, TIMING_EBN0))
        res["timing"].update(units=n, unit="bit", workload=f"{n} bits at Eb/N0 = {TIMING_EBN0} dB")
        out.append(res)
    return out


def spec(m: int, ebn0: list[float], bits: int, trials: int, seed: int) -> dict:
    k = int(math.log2(m))
    return {
        "name": f"bench_rayleigh_{m}",
        "seed": seed,
        "batch": trials,
        "grid": {"ebn0_db": ebn0},
        "link": [
            {
                "type": "source",
                "constellation": {"kind": "psk" if m == 2 else "qam", "m": m},
                "n_symbols": bits // (k * trials),
            },
            {"type": "rayleigh", "n_rx": 1, "coherence_samples": 1},
            {"type": "awgn_ebn0", "ebn0_db": "$ebn0_db"},
            {"type": "mimo_rx", "detector": "zf"},
        ],
        "metrics": {"report": ["ber"], "scale": "none"},
        "stop": {"min_trials": trials, "max_trials": trials},
    }


def spec_points(rows: list[dict], m: int) -> list[dict]:
    pts = []
    for row in rows:
        e = float(row["condition"]["ebn0_db"])
        ber = row["metrics"]["ber"]
        pts.append(
            common.rate_point(
                ber["count"], ber["total"], float(theory.ber_qam_rayleigh(m, e)), ebn0_db=e
            )
        )
    return pts


def run_spec_lib(args, todo: list[str]) -> list[dict]:
    sz = sizes(args.quick)
    n = sz["timing_bits"]
    out = []
    for case in todo:
        m = ORDERS[case]
        out.append(
            common.spec_case(
                args,
                {"case": case, "precision": "float64"},
                spec(m, EBN0, sz["bits_per_point"], sz["trials"], args.seed),
                spec(m, [TIMING_EBN0], n, TIMING_TRIALS, args.seed + 1),
                lambda rows, m=m: spec_points(rows, m),
                units=n,
                unit="bit",
                workload=f"{n} bits at Eb/N0 = {TIMING_EBN0} dB, {TIMING_TRIALS} trials",
            )
        )
    return out


RUNNERS = {"comnum": run_chains, "sionna": run_chains, "comnum-spec": run_spec_lib}

if __name__ == "__main__":
    common.main(SCENARIO, CASES, RUNNERS)

Support

Case

comnum (functions)

comnum.run_spec

OptiCommPy (numba)

Sionna

BPSK

yes

yes

not available: no fading channel

yes

QPSK

yes

yes

not available: no fading channel

yes

BPSK: error rates

Library

Precision

Against

max abs(z), BER

Agrees

BER interval

comnum (functions)

float64

theory

2.22

yes

binomial

comnum.run_spec

float64

theory

1.78

yes

binomial

Sionna

float32 (config.precision = ‘single’)

theory

2.35

yes

binomial

BER and its 95 % interval, every point (10,000,000 bits per point)

Eb/N0 (dB)

theory

comnum (functions)

comnum.run_spec

Sionna

0

1.46e-1

1.46e-1 [1.46e-1, 1.47e-1]

1.46e-1 [1.46e-1, 1.47e-1]

1.47e-1 [1.46e-1, 1.47e-1]

5

6.42e-2

6.43e-2 [6.41e-2, 6.44e-2]

6.42e-2 [6.41e-2, 6.44e-2]

6.42e-2 [6.40e-2, 6.43e-2]

10

2.33e-2

2.33e-2 [2.32e-2, 2.34e-2]

2.32e-2 [2.32e-2, 2.33e-2]

2.33e-2 [2.32e-2, 2.34e-2]

15

7.72e-3

7.73e-3 [7.67e-3, 7.78e-3]

7.76e-3 [7.71e-3, 7.82e-3]

7.75e-3 [7.70e-3, 7.80e-3]

20

2.48e-3

2.51e-3 [2.48e-3, 2.54e-3]

2.46e-3 [2.43e-3, 2.49e-3]

2.52e-3 [2.49e-3, 2.55e-3]

25

7.89e-4

8.08e-4 [7.91e-4, 8.26e-4]

7.83e-4 [7.66e-4, 8.00e-4]

7.88e-4 [7.71e-4, 8.06e-4]

30

2.50e-4

2.52e-4 [2.42e-4, 2.62e-4]

2.41e-4 [2.31e-4, 2.51e-4]

2.43e-4 [2.34e-4, 2.53e-4]

QPSK: error rates

Library

Precision

Against

max abs(z), BER

Agrees

BER interval

comnum (functions)

float64

theory

2.08

yes

binomial

comnum.run_spec

float64

theory

1.17

yes

binomial

Sionna

float32 (config.precision = ‘single’)

theory

3.13

yes

binomial

BER and its 95 % interval, every point (10,000,000 bits per point)

Eb/N0 (dB)

theory

comnum (functions)

comnum.run_spec

Sionna

0

1.46e-1

1.46e-1 [1.46e-1, 1.46e-1]

1.46e-1 [1.46e-1, 1.47e-1]

1.46e-1 [1.46e-1, 1.47e-1]

5

6.42e-2

6.41e-2 [6.39e-2, 6.42e-2]

6.42e-2 [6.40e-2, 6.43e-2]

6.42e-2 [6.40e-2, 6.43e-2]

10

2.33e-2

2.34e-2 [2.33e-2, 2.34e-2]

2.33e-2 [2.32e-2, 2.34e-2]

2.31e-2 [2.30e-2, 2.32e-2]

15

7.72e-3

7.77e-3 [7.72e-3, 7.82e-3]

7.73e-3 [7.67e-3, 7.78e-3]

7.67e-3 [7.61e-3, 7.72e-3]

20

2.48e-3

2.51e-3 [2.48e-3, 2.54e-3]

2.50e-3 [2.47e-3, 2.53e-3]

2.46e-3 [2.43e-3, 2.50e-3]

25

7.89e-4

8.02e-4 [7.84e-4, 8.19e-4]

7.81e-4 [7.64e-4, 7.99e-4]

7.92e-4 [7.75e-4, 8.10e-4]

30

2.50e-4

2.53e-4 [2.44e-4, 2.63e-4]

2.46e-4 [2.37e-4, 2.56e-4]

2.51e-4 [2.41e-4, 2.61e-4]

BPSK: time

Median of 5 runs, ns per bit; workload: 1000000 bits at Eb/N0 = 10.0 dB. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

GPU

Relative

First call

comnum (functions)

float64

303

295

—

1.00

0.317 s

comnum.run_spec

float64

322

48.9

—

1.06

0.347 s

Sionna

float32 (config.precision = ‘single’)

437

487

8.61

1.44

0.447 s

QPSK: time

Median of 5 runs, ns per bit; workload: 1000000 bits at Eb/N0 = 10.0 dB. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

GPU

Relative

First call

comnum (functions)

float64

156

151

—

1.00

0.156 s

comnum.run_spec

float64

166

25.5

—

1.06

0.162 s

Sionna

float32 (config.precision = ‘single’)

289

298

3.67

1.86

0.32 s

The commands of every run
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s2_rayleigh.py --lib comnum --threads 1 --work runpod/_work --out runpod/_work/s2_rayleigh_comnum_1.json
OMP_NUM_THREADS=11 OPENBLAS_NUM_THREADS=11 MKL_NUM_THREADS=11 VECLIB_MAXIMUM_THREADS=11 NUMBA_NUM_THREADS=11 RAYON_NUM_THREADS=11 python s2_rayleigh.py --lib comnum --threads all --work runpod/_work --out runpod/_work/s2_rayleigh_comnum_all.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s2_rayleigh.py --lib comnum-spec --threads 1 --work runpod/_work --out runpod/_work/s2_rayleigh_comnum-spec_1.json
OMP_NUM_THREADS=11 OPENBLAS_NUM_THREADS=11 MKL_NUM_THREADS=11 VECLIB_MAXIMUM_THREADS=11 NUMBA_NUM_THREADS=11 RAYON_NUM_THREADS=11 python s2_rayleigh.py --lib comnum-spec --threads all --work runpod/_work --out runpod/_work/s2_rayleigh_comnum-spec_all.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s2_rayleigh.py --lib sionna --threads 1 --work runpod/_work --out runpod/_work/s2_rayleigh_sionna_1.json
OMP_NUM_THREADS=11 OPENBLAS_NUM_THREADS=11 MKL_NUM_THREADS=11 VECLIB_MAXIMUM_THREADS=11 NUMBA_NUM_THREADS=11 RAYON_NUM_THREADS=11 python s2_rayleigh.py --lib sionna --threads all --work runpod/_work --out runpod/_work/s2_rayleigh_sionna_all.json --timing-only
OMP_NUM_THREADS=11 OPENBLAS_NUM_THREADS=11 MKL_NUM_THREADS=11 VECLIB_MAXIMUM_THREADS=11 NUMBA_NUM_THREADS=11 RAYON_NUM_THREADS=11 python s2_rayleigh.py --lib sionna --threads gpu --work runpod/_work --out runpod/_work/s2_rayleigh_sionna_gpu.json --timing-only

4 x 4 MIMO, 16-QAM, i.i.d. Rayleigh fading: ZF, MMSE and ML detection#

Scenario 3: 4 x 4 spatial multiplexing of Gray 16-QAM over i.i.d. Rayleigh fading (one channel matrix per vector, known at the receiver), detected by zero forcing, MMSE and maximum likelihood.

Parameters:

  • Eb/N0 (dB): 0, 4, 8, 12, 16

  • vectors per point: linear detectors and sphere: 10^5 (quick: 5·10^3); exhaustive ML: 2·10^4 (quick: 10^3)

  • timing: 10^4 vectors at Eb/N0 = 12 dB (exhaustive ML: 10^3; quick: 2·10^3 and 2·10^2)

  • theory: ZF: 16-QAM over SISO Rayleigh fading (Winters, Salz & Gitlin, IEEE Trans. Commun. 42(2/3/4), 1994)

Script: benchmarks/libraries/s3_mimo.py.

The script
"""Scenario 3: 4 x 4 spatial multiplexing of Gray 16-QAM over i.i.d. Rayleigh fading
(one channel matrix per vector, known at the receiver), detected by zero forcing, MMSE
and maximum likelihood.

Every stream carries unit-energy symbols (no power split between the antennas) and
Eb/N0 is the energy per bit received on one antenna over N0: N0 = 1 / (4 Eb/N0).
Zero forcing over 4 x 4 leaves one diversity branch per stream, so its BER is that of
16-QAM over SISO Rayleigh fading (Winters, Salz & Gitlin 1994); MMSE and ML have no
closed form here and are compared between the libraries. Maximum likelihood is an
exhaustive search over the 16^4 = 65536 vectors in Sionna and in comnum ("ml"), and
the Schnorr-Euchner sphere decoder in comnum ("sphere", the same decisions).
The specification runner splits the power between the antennas (mimo_tx), so it runs
at Eb/N0 + 10 log10(4) dB to have the same SNR. Timing: ``TIMING_VECTORS`` vectors at
Eb/N0 = 12 dB (channel, noise, detection), median of 5 runs after a warm-up run.

    python s3_mimo.py --lib comnum --threads 1 [--quick] --out results/_work/s3.json
"""

from __future__ import annotations

import math
import time

import common
import numpy as np
import theory

SCENARIO = "s3_mimo"
TITLE = "4 x 4 MIMO, 16-QAM, i.i.d. Rayleigh fading: ZF, MMSE and ML detection"
NT = NR = 4
M = 16
K = 4
NO_MIMO = "no MIMO detection"
SUPPORT = {
    "comnum": True,
    "comnum-spec": True,
    "komm": NO_MIMO,
    "opticommpy": "no MIMO detection (only the 2 x 2 adaptive equalisers of polarisation multiplexing)",
    "opticommpy-nojit": "no MIMO detection (only the 2 x 2 adaptive equalisers of polarisation multiplexing)",
    "sionna": True,
}
CASES = {
    "zf": SUPPORT,
    "mmse": SUPPORT,
    "ml": SUPPORT,
    "sphere": {
        **SUPPORT,
        "sionna": "no sphere decoder (its exhaustive ML detector is the case ml)",
    },
}
SPEC_DETECTOR = {"zf": "zf", "mmse": "mmse", "ml": "ml", "sphere": "sphere"}
EBN0 = [0.0, 4.0, 8.0, 12.0, 16.0]
TIMING_EBN0 = 12.0
SPEC_OFFSET_DB = 10 * math.log10(NT)

PARAMETERS = {
    "ebn0_db": EBN0,
    "vectors_per_point": "linear detectors and sphere: 10^5 (quick: 5·10^3); exhaustive ML: 2·10^4 (quick: 10^3)",
    "timing": "10^4 vectors at Eb/N0 = 12 dB (exhaustive ML: 10^3; quick: 2·10^3 and 2·10^2)",
    "theory": "ZF: 16-QAM over SISO Rayleigh fading (Winters, Salz & Gitlin, IEEE Trans. Commun. 42(2/3/4), 1994)",
}


def sizes(quick: bool, case: str) -> dict:
    heavy = case == "ml"
    if quick:
        return {
            "vectors": 1_000 if heavy else 5_000,
            "timing": 200 if heavy else 2_000,
            "blocks": 1,
        }
    return {
        "vectors": 20_000 if heavy else 100_000,
        "timing": 1_000 if heavy else 10_000,
        "blocks": 10,
    }


def theory_ber(case: str, ebn0: float) -> float | None:
    return float(theory.ber_qam_rayleigh(M, ebn0)) if case == "zf" else None


def chain_comnum(case: str, seed: int):
    import comnum

    c = comnum.Constellation("qam", M).normalized()
    rng = comnum.Rng(seed)

    def run(n: int, ebn0: float) -> np.ndarray:
        b = comnum.bits(n * NT * K, rng)
        x = c.map_bits(b).reshape(n, NT)
        h = comnum.mimo_channels(n, NR, NT, rng)
        n0 = 1.0 / (K * 10 ** (ebn0 / 10))
        # y = H x + w (the Python API draws H; the product is NumPy's)
        y = np.matmul(h, x[:, :, None])[:, :, 0] + rng.complex_noise(n * NR, n0).reshape(n, NR)
        labels = comnum.mimo_detect(h, y.T, c, n0, case)  # stream-major (NT, n)
        bh = comnum.unpack_symbols(labels.T.ravel(), K)
        return np.count_nonzero((bh != b).reshape(n, NT * K), axis=1)  # bit errors per vector

    return run, "float64"


def chain_sionna(case: str, seed: int):
    import torch
    from sionna.phy import config
    from sionna.phy.channel import FlatFadingChannel
    from sionna.phy.mapping import BinarySource, Mapper
    from sionna.phy.mimo import LinearDetector, MaximumLikelihoodDetector
    from sionna.phy.utils import ebnodb2no

    config.seed = seed
    source, mapper = BinarySource(), Mapper("qam", K)
    channel = FlatFadingChannel(NT, NR, return_channel=True)
    if case == "ml":
        det = MaximumLikelihoodDetector(
            "bit", "maxlog", NT, constellation_type="qam", num_bits_per_symbol=K, hard_out=True
        )
        chunk = 64  # vectors per call: the search holds 65536 candidates per vector
    else:
        eq = {"zf": "zf", "mmse": "lmmse"}[case]
        det = LinearDetector(
            eq, "bit", "maxlog", constellation_type="qam", num_bits_per_symbol=K, hard_out=True
        )
        chunk = None

    def run(n: int, ebn0: float) -> np.ndarray:
        with torch.inference_mode():
            b = source([n, NT * K])
            x = mapper(b)  # [n, NT]
            no = ebnodb2no(ebn0, K, 1.0)  # Es / (K Eb/N0), Es = 1
            y, h = channel(x, no)
            s = (float(no) * torch.eye(NR, dtype=y.dtype)).expand(n, NR, NR)
            if chunk is None:
                bh = det(y, h, s)
            else:
                bh = torch.cat(
                    [
                        det(y[i : i + chunk], h[i : i + chunk], s[i : i + chunk])
                        for i in range(0, n, chunk)
                    ]
                )
            return common.to_numpy((bh.reshape(b.shape) != b).sum(dim=1))  # bit errors per vector

    return run, "float32 (config.precision = 'single')"


CHAINS = {"comnum": chain_comnum, "sionna": chain_sionna}


def run_chains(args, todo: list[str]) -> list[dict]:
    threading = common.setup_torch(args.threads) if args.lib == "sionna" else {}
    out = []
    for case in todo:
        sz = sizes(args.quick, case)
        run, precision = CHAINS[args.lib](case, args.seed)
        res = {"case": case, "precision": precision, **threading}
        n = sz["timing"]
        t0 = time.perf_counter()
        run(n, TIMING_EBN0)
        res["first_call_s"] = time.perf_counter() - t0
        if not args.timing_only:
            pts = []
            t0 = time.perf_counter()
            for e in EBN0:
                # the 16 bits of a vector share its channel: the interval is per vector
                errs = np.concatenate(
                    [run(sz["vectors"] // sz["blocks"], e) for _ in range(sz["blocks"])]
                )
                pts.append(
                    common.frame_ber_point(
                        errs, NT * K, theory_ber(case, e), ebn0_db=e, vectors=errs.size
                    )
                )
            res["points"] = pts
            res["sweep_s"] = time.perf_counter() - t0
        res["timing"] = common.time_it(lambda n=n, run=run: run(n, TIMING_EBN0))
        res["timing"].update(
            units=n, unit="vector", workload=f"{n} vectors at Eb/N0 = {TIMING_EBN0} dB"
        )
        out.append(res)
    return out


def spec(case: str, ebn0: list[float], vectors: int, trials: int, seed: int) -> dict:
    return {
        "name": f"bench_mimo_{case}",
        "seed": seed,
        "batch": trials,
        "grid": {"ebn0_db": [e + SPEC_OFFSET_DB for e in ebn0]},
        "link": [
            {"type": "source", "constellation": "16-QAM", "n_symbols": NT * vectors // trials},
            {"type": "mimo_tx", "n_tx": NT, "scheme": "spatial"},
            {"type": "rayleigh", "n_rx": NR, "coherence_samples": 1},
            {"type": "awgn_ebn0", "ebn0_db": "$ebn0_db"},
            {"type": "mimo_rx", "detector": SPEC_DETECTOR[case]},
        ],
        "metrics": {"report": ["ber"], "scale": "none"},
        "stop": {"min_trials": trials, "max_trials": trials},
    }


def run_spec_lib(args, todo: list[str]) -> list[dict]:
    trials = 40
    out = []
    for case in todo:
        sz = sizes(args.quick, case)

        def points(rows: list[dict], case=case, vectors=sz["vectors"]) -> list[dict]:
            pts = []
            for e, row in zip(EBN0, rows, strict=True):
                ber = row["metrics"]["ber"]
                pts.append(
                    common.rate_point(
                        ber["count"], ber["total"], theory_ber(case, e), ebn0_db=e, vectors=vectors
                    )
                )
            return pts

        n = sz["timing"]
        out.append(
            common.spec_case(
                args,
                {"case": case, "precision": "float64", "ebn0_offset_db": SPEC_OFFSET_DB},
                spec(case, EBN0, sz["vectors"], trials, args.seed),
                spec(case, [TIMING_EBN0], n, trials, args.seed + 1),
                points,
                units=n,
                unit="vector",
                workload=f"{n} vectors at Eb/N0 = {TIMING_EBN0} dB, {trials} trials",
            )
        )
    return out


RUNNERS = {"comnum": run_chains, "sionna": run_chains, "comnum-spec": run_spec_lib}

if __name__ == "__main__":
    common.main(SCENARIO, CASES, RUNNERS)

Support

Case

comnum (functions)

comnum.run_spec

OptiCommPy (numba)

Sionna

zero forcing

yes

yes

not available: no MIMO detection (only the 2 x 2 adaptive equalisers of polarisation multiplexing)

yes

MMSE

yes

yes

not available: no MIMO detection (only the 2 x 2 adaptive equalisers of polarisation multiplexing)

yes

ML (exhaustive)

yes

yes

not available: no MIMO detection (only the 2 x 2 adaptive equalisers of polarisation multiplexing)

yes

ML (sphere decoder)

yes

yes

not available: no MIMO detection (only the 2 x 2 adaptive equalisers of polarisation multiplexing)

not available: no sphere decoder (its exhaustive ML detector is the case ml)

zero forcing: error rates

Library

Precision

Against

max abs(z), BER

Agrees

BER interval

comnum (functions)

float64

theory

1.88

yes

per-frame

comnum.run_spec

float64

theory

2.98

yes

binomial

Sionna

float32 (config.precision = ‘single’)

theory

1.79

yes

per-frame

BER and its 95 % interval, every point (1,600,000 bits per point)

Eb/N0 (dB)

theory

comnum (functions)

comnum.run_spec

Sionna

0

1.98e-1

1.98e-1 [1.97e-1, 1.99e-1]

1.98e-1 [1.97e-1, 1.98e-1]

1.98e-1 [1.97e-1, 1.99e-1]

4

1.20e-1

1.19e-1 [1.18e-1, 1.20e-1]

1.19e-1 [1.19e-1, 1.20e-1]

1.19e-1 [1.19e-1, 1.20e-1]

8

6.20e-2

6.16e-2 [6.10e-2, 6.23e-2]

6.14e-2 [6.10e-2, 6.18e-2]

6.26e-2 [6.19e-2, 6.32e-2]

12

2.83e-2

2.82e-2 [2.78e-2, 2.87e-2]

2.81e-2 [2.79e-2, 2.84e-2]

2.83e-2 [2.78e-2, 2.87e-2]

16

1.20e-2

1.21e-2 [1.18e-2, 1.24e-2]

1.19e-2 [1.17e-2, 1.20e-2]

1.19e-2 [1.16e-2, 1.22e-2]

MMSE: error rates

Library

Precision

Against

max abs(z), BER

Agrees

BER interval

comnum (functions)

float64

—

—

—

per-frame

comnum.run_spec

float64

comnum

3.63

yes

binomial

Sionna

float32 (config.precision = ‘single’)

comnum

1.57

yes

per-frame

BER and its 95 % interval, every point (1,600,000 bits per point)

Eb/N0 (dB)

comnum (functions)

comnum.run_spec

Sionna

0

1.39e-1 [1.38e-1, 1.40e-1]

1.38e-1 [1.38e-1, 1.39e-1]

1.38e-1 [1.38e-1, 1.39e-1]

4

8.27e-2 [8.21e-2, 8.33e-2]

8.40e-2 [8.36e-2, 8.44e-2]

8.33e-2 [8.28e-2, 8.39e-2]

8

4.31e-2 [4.26e-2, 4.35e-2]

4.29e-2 [4.26e-2, 4.32e-2]

4.32e-2 [4.28e-2, 4.37e-2]

12

1.97e-2 [1.94e-2, 2.00e-2]

1.99e-2 [1.97e-2, 2.01e-2]

1.96e-2 [1.93e-2, 1.99e-2]

16

8.42e-3 [8.20e-3, 8.64e-3]

8.33e-3 [8.19e-3, 8.47e-3]

8.23e-3 [8.02e-3, 8.45e-3]

ML (exhaustive): error rates

Library

Precision

Against

max abs(z), BER

Agrees

BER interval

comnum (functions)

float64

—

—

—

per-frame

comnum.run_spec

float64

comnum

2.53

yes

binomial

Sionna

float32 (config.precision = ‘single’)

comnum

1.09

yes

per-frame

BER and its 95 % interval, every point (320,000 bits per point)

Eb/N0 (dB)

comnum (functions)

comnum.run_spec

Sionna

0

1.25e-1 [1.23e-1, 1.26e-1]

1.25e-1 [1.24e-1, 1.26e-1]

1.25e-1 [1.23e-1, 1.27e-1]

4

4.03e-2 [3.90e-2, 4.16e-2]

3.99e-2 [3.92e-2, 4.06e-2]

4.05e-2 [3.92e-2, 4.17e-2]

8

4.12e-3 [3.68e-3, 4.56e-3]

4.77e-3 [4.54e-3, 5.02e-3]

4.47e-3 [4.02e-3, 4.93e-3]

12

2.34e-4 [1.24e-4, 3.45e-4]

2.28e-4 [1.81e-4, 2.87e-4]

1.59e-4 [7.61e-5, 2.43e-4]

16

0 [0, 9.38e-6]

0 [0, 1.20e-5]

1.25e-5 [0, 3.70e-5]

ML (sphere decoder): error rates

Library

Precision

Against

max abs(z), BER

Agrees

BER interval

comnum (functions)

float64

—

—

—

per-frame

comnum.run_spec

float64

comnum

2.84

yes

binomial

BER and its 95 % interval, every point (1,600,000 bits per point)

Eb/N0 (dB)

comnum (functions)

comnum.run_spec

0

1.24e-1 [1.23e-1, 1.25e-1]

1.25e-1 [1.24e-1, 1.25e-1]

4

3.97e-2 [3.92e-2, 4.03e-2]

4.06e-2 [4.03e-2, 4.09e-2]

8

4.44e-3 [4.23e-3, 4.64e-3]

4.62e-3 [4.51e-3, 4.73e-3]

12

1.91e-4 [1.49e-4, 2.32e-4]

1.95e-4 [1.75e-4, 2.18e-4]

16

3.13e-6 [0, 6.37e-6]

1.25e-5 [8.09e-6, 1.93e-5]

zero forcing: time

Median of 5 runs, µs per vector; workload: 10000 vectors at Eb/N0 = 12.0 dB. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

GPU

Relative

First call

comnum (functions)

float64

2.99

2.87

—

1.00

0.0328 s

comnum.run_spec

float64

2.51

0.451

—

0.839

0.0274 s

Sionna

float32 (config.precision = ‘single’)

5.49

2.24

0.162

1.84

0.0569 s

MMSE: time

Median of 5 runs, µs per vector; workload: 10000 vectors at Eb/N0 = 12.0 dB. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

GPU

Relative

First call

comnum (functions)

float64

3.21

2.87

—

1.00

0.0306 s

comnum.run_spec

float64

2.53

0.488

—

0.789

0.0362 s

Sionna

float32 (config.precision = ‘single’)

4.66

2.61

0.189

1.45

0.0545 s

ML (exhaustive): time

Median of 5 runs, µs per vector; workload: 1000 vectors at Eb/N0 = 12.0 dB. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

GPU

Relative

First call

comnum (functions)

float64

1315

1335

—

1.00

1.59 s

comnum.run_spec

float64

1306

169

—

0.993

1.35 s

Sionna

float32 (config.precision = ‘single’)

19059

5567

71.3

14.5

19 s

ML (sphere decoder): time

Median of 5 runs, µs per vector; workload: 10000 vectors at Eb/N0 = 12.0 dB. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

GPU

Relative

First call

comnum (functions)

float64

3.27

3.36

—

1.00

0.0359 s

comnum.run_spec

float64

3.37

0.641

—

1.03

0.0309 s

The commands of every run
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s3_mimo.py --lib comnum --threads 1 --work runpod/_work --out runpod/_work/s3_mimo_comnum_1.json
OMP_NUM_THREADS=11 OPENBLAS_NUM_THREADS=11 MKL_NUM_THREADS=11 VECLIB_MAXIMUM_THREADS=11 NUMBA_NUM_THREADS=11 RAYON_NUM_THREADS=11 python s3_mimo.py --lib comnum --threads all --work runpod/_work --out runpod/_work/s3_mimo_comnum_all.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s3_mimo.py --lib comnum-spec --threads 1 --work runpod/_work --out runpod/_work/s3_mimo_comnum-spec_1.json
OMP_NUM_THREADS=11 OPENBLAS_NUM_THREADS=11 MKL_NUM_THREADS=11 VECLIB_MAXIMUM_THREADS=11 NUMBA_NUM_THREADS=11 RAYON_NUM_THREADS=11 python s3_mimo.py --lib comnum-spec --threads all --work runpod/_work --out runpod/_work/s3_mimo_comnum-spec_all.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s3_mimo.py --lib sionna --threads 1 --work runpod/_work --out runpod/_work/s3_mimo_sionna_1.json
OMP_NUM_THREADS=11 OPENBLAS_NUM_THREADS=11 MKL_NUM_THREADS=11 VECLIB_MAXIMUM_THREADS=11 NUMBA_NUM_THREADS=11 RAYON_NUM_THREADS=11 python s3_mimo.py --lib sionna --threads all --work runpod/_work --out runpod/_work/s3_mimo_sionna_all.json --timing-only
OMP_NUM_THREADS=11 OPENBLAS_NUM_THREADS=11 MKL_NUM_THREADS=11 VECLIB_MAXIMUM_THREADS=11 NUMBA_NUM_THREADS=11 RAYON_NUM_THREADS=11 python s3_mimo.py --lib sionna --threads gpu --work runpod/_work --out runpod/_work/s3_mimo_sionna_gpu.json --timing-only

Channel coding over BPSK and AWGN#

Scenario 4: channel coding over BPSK and AWGN.

Parameters:

  • convolutional (133, 171), soft Viterbi: K = 7, (133, 171), k = 1024 + 6 tail bits, soft Viterbi; Eb/N0 = 1, 2, 3, 4 dB

  • 5G NR LDPC (1024, 2048): 5G NR LDPC, k = 1024, n = 2048 (base graph 2, Z = 104), flooding sum-product, 50 iterations; Eb/N0 = 1, 1.5, 2, 2.5 dB

  • LDPC (1024, 512), same LLRs: PEG (1024, 512), flooding sum-product, 50 iterations, identical LLRs; Eb/N0 = 1.5, 2, 2.5, 3 dB

  • frames per point: 2000 (quick: 100)

  • timing: 200 frames (quick: 20) at Eb/N0 = 3 dB (conv) or 2 dB (LDPC); OptiCommPy without numba: 2 frames

Script: benchmarks/libraries/s4_coding.py.

The script
"""Scenario 4: channel coding over BPSK and AWGN.

- ``conv``: the rate-1/2 convolutional code of constraint length 7, generators
  (133, 171) in octal, 1024 information bits per frame terminated by 6 tail bits
  (rate 1024/2060), soft-decision Viterbi decoding. komm counts the taps from the
  least significant bit: the same code is ``ConvolutionalCode([[0o155, 0o117]])``
  there (checked on the impulse response). Reference: the union bound with the
  distance spectrum of the trellis (Viterbi 1971).
- ``nr_ldpc``: the LDPC code of 5G NR (3GPP TS 38.212, Sec. 5.3.2), k = 1024, rate
  matched to n = 2048 (base graph 2 by the rule of Sec. 7.2.2, lifting size 104, the
  first 2Z bits punctured; the same in both libraries), sum-product
  decoding by flooding, 50 iterations at most, in both libraries (comnum's default is
  the layered schedule, which converges in about half the iterations; it is set to
  flooding here so that the error rates compare the same decoder). comnum stops early on
  a codeword, Sionna runs the 50 iterations (its default).
- ``ldpc_same_llr``: one (1024, 512) LDPC code (PEG construction of comnum, variable
  degree 3, seed 1), its parity-check matrix given to the three decoders that accept
  one (comnum, OptiCommPy, Sionna), and the same channel LLRs (drawn by comnum from the
  same seed in every process): flooding sum-product, 50 iterations at most. The
  decoders see identical inputs, so they should take the same decisions frame by
  frame.

Every library uses its own encoder, BPSK mapper, AWGN channel and LLR computation in
``conv`` and ``nr_ldpc`` (independent seeds); Eb/N0 counts the energy per information
bit. Timings: the decoder alone on ``TIMING_FRAMES`` frames of LLRs at the timing
point, and the whole chain (bits, encoding, mapping, noise, LLRs, decoding, error
count) on the same number of frames; median of 5 runs after a warm-up run.

    python s4_coding.py --lib sionna --threads 1 [--quick] --out results/_work/s4.json
"""

from __future__ import annotations

import time

import common
import numpy as np
import theory

SCENARIO = "s4_coding"
TITLE = "Channel coding over BPSK and AWGN"
K_CONV = 1024
CONV_GEN = [0o133, 0o171]
K_NR, N_NR = 1024, 2048
N_PEG, K_PEG = 1024, 512
ITERATIONS = 50
EBN0 = {
    "conv": [1.0, 2.0, 3.0, 4.0],
    "nr_ldpc": [1.0, 1.5, 2.0, 2.5],
    "ldpc_same_llr": [1.5, 2.0, 2.5, 3.0],
}
TIMING_EBN0 = {"conv": 3.0, "nr_ldpc": 2.0, "ldpc_same_llr": 2.0}
SPEC_ONLY_OWN = "the runner draws its own channel (this case feeds the same LLRs to every decoder)"
CASES = {
    "conv": {
        "comnum": True,
        "comnum-spec": True,
        "comnum-rust": True,
        "komm": True,
        "opticommpy": "no convolutional code",
        "opticommpy-nojit": "no convolutional code",
        "sionna": True,
    },
    "nr_ldpc": {
        "comnum": True,
        "comnum-spec": "the specification format has no 5G NR LDPC code (NrLdpc: Python and Rust API)",
        "comnum-rust": "the specification format has no 5G NR LDPC code (NrLdpc: Python and Rust API)",
        "komm": "no LDPC code",
        "opticommpy": "no 5G NR LDPC code (its decoder takes a parity-check matrix: see ldpc_same_llr)",
        "opticommpy-nojit": "no 5G NR LDPC code (its decoder takes a parity-check matrix: see ldpc_same_llr)",
        "sionna": True,
    },
    "ldpc_same_llr": {
        "comnum": True,
        "comnum-spec": SPEC_ONLY_OWN,
        "comnum-rust": SPEC_ONLY_OWN,
        "komm": "no LDPC (belief-propagation) decoder",
        "opticommpy": True,
        "opticommpy-nojit": True,
        "sionna": True,
    },
}

PARAMETERS = {
    "conv": "K = 7, (133, 171), k = 1024 + 6 tail bits, soft Viterbi; Eb/N0 = 1, 2, 3, 4 dB",
    "nr_ldpc": "5G NR LDPC, k = 1024, n = 2048 (base graph 2, Z = 104), flooding sum-product, 50 iterations; Eb/N0 = 1, 1.5, 2, 2.5 dB",
    "ldpc_same_llr": "PEG (1024, 512), flooding sum-product, 50 iterations, identical LLRs; Eb/N0 = 1.5, 2, 2.5, 3 dB",
    "frames_per_point": "2000 (quick: 100)",
    "timing": "200 frames (quick: 20) at Eb/N0 = 3 dB (conv) or 2 dB (LDPC); OptiCommPy without numba: 2 frames",
}


def sizes(quick: bool) -> dict:
    return (
        {"frames": 100, "timing": 20, "block": 50}
        if quick
        else {"frames": 2000, "timing": 200, "block": 200}
    )


def n0_of(ebn0: float, rate: float) -> float:
    """N0 of unit-energy BPSK for an Eb/N0 per information bit (complex noise of
    variance N0, N0/2 on the real axis)."""
    return 1.0 / (rate * 10 ** (ebn0 / 10))


# ---------------------------------------------------------------------- the codecs
# A codec gives: encode(F) -> (info bits [F, k], channel input), llrs(F, ebn0) ->
# (reference bits, LLRs in the library's convention), decode(llrs) -> decided bits
# [F, k_out], and the number k_out of bits compared per frame.


def codec_comnum(case: str, seed: int):
    import comnum

    rng = comnum.Rng(seed)
    bpsk = comnum.Constellation("psk", 2)
    if case == "conv":
        code = comnum.ConvolutionalCode(K_CONV, generators=CONV_GEN, constraint=7)
    elif case == "nr_ldpc":
        code = comnum.NrLdpc(
            K_NR, N_NR, max_iterations=ITERATIONS, rule="sum_product", layered=False
        )
    else:
        return codec_same_llr_comnum(seed)
    k, n = code.k, code.n

    def llrs(f: int, ebn0: float):
        b = comnum.bits(f * k, rng)
        x = bpsk.map_bits(code.encode(b))
        n0 = n0_of(ebn0, k / n)
        y = x + rng.complex_noise(x.size, n0)
        return b.reshape(f, k), bpsk.llr(y, n0)

    def decode(llr):
        return code.decode(llr).reshape(-1, k)

    return llrs, decode, k, "float64"


def peg_code():
    import comnum

    code = comnum.LdpcCode.peg(N_PEG, K_PEG, var_degree=3, seed=1)
    code.set_decoder("sum_product", layered=False, max_iterations=ITERATIONS)
    return code


def same_llrs(seed: int):
    """The codewords and LLRs of ldpc_same_llr: the same in every process."""
    import comnum

    code = peg_code()
    rng = comnum.Rng(seed)
    bpsk = comnum.Constellation("psk", 2)

    def llrs(f: int, ebn0: float):
        b = comnum.bits(f * code.k, rng)
        c = code.encode(b)
        n0 = n0_of(ebn0, code.k / code.n)
        y = bpsk.map_bits(c) + rng.complex_noise(c.size, n0)
        return c.reshape(f, code.n), bpsk.llr(y, n0).reshape(f, code.n)  # positive for a 0

    return code, llrs


def codec_same_llr_comnum(seed: int):
    code, llrs = same_llrs(seed)

    def decode(llr):
        return np.stack([(code.decode_llr(row)[0] < 0).astype(np.uint8) for row in llr])

    return llrs, decode, code.n, "float64"


def codec_opticommpy(case: str, seed: int):
    import scipy.sparse as sp
    from optic.comm.fec import decodeLDPC
    from optic.utils import parameters

    code, llrs = same_llrs(seed)
    rows = code.rows
    h = sp.csr_matrix(
        (
            np.ones(sum(map(len, rows)), dtype=np.uint8),
            (np.repeat(np.arange(len(rows)), list(map(len, rows))), np.concatenate(rows)),
        ),
        shape=(len(rows), code.n),
    )
    p = parameters()
    p.H, p.maxIter, p.alg = h, ITERATIONS, "SPA"

    def decode(llr):
        bits, _out, _fail = decodeLDPC(np.ascontiguousarray(llr.T), p)  # columns are codewords
        return bits.T.astype(np.uint8)

    return llrs, decode, code.n, "float32 (decodeLDPC default prec = np.float32)"


def codec_komm(case: str, seed: int):
    import komm

    # (133, 171) with the current input on the most significant bit = (155, 117) in komm
    cc = komm.ConvolutionalCode([[0o155, 0o117]])
    code = komm.TerminatedConvolutionalCode(cc, num_blocks=K_CONV, mode="zero-termination")
    decoder = komm.ViterbiDecoder(code, input_type="soft")
    bpsk, lab = komm.PAMConstellation(2), komm.NaturalLabeling(1)
    rng = np.random.default_rng(seed)
    k, n = code.dimension, code.length

    def llrs(f: int, ebn0: float):
        b = rng.integers(0, 2, (f, k))
        x = bpsk.indices_to_symbols(lab.bits_to_indices(code.encode(b).ravel()))
        sigma2 = n0_of(ebn0, k / n) / 2  # real channel: N0/2 per dimension
        y = komm.GaussianChannel(sigma2, rng=rng).transmit(x)
        return b, lab.marginalize(bpsk.posteriors(y, sigma2))  # log P(0) / P(1)

    def decode(llr):
        return decoder.decode(llr).reshape(-1, k)

    return llrs, decode, k, "float64"


def codec_sionna(case: str, seed: int):
    import torch
    from sionna.phy import config
    from sionna.phy.channel import AWGN
    from sionna.phy.fec.conv import ConvEncoder, ViterbiDecoder
    from sionna.phy.fec.ldpc import LDPC5GDecoder, LDPC5GEncoder, LDPCBPDecoder
    from sionna.phy.mapping import BinarySource, Demapper, Mapper

    config.seed = seed
    if case == "ldpc_same_llr":
        code, same = same_llrs(seed)
        dec = LDPCBPDecoder(peg_pcm(code), num_iter=ITERATIONS, hard_out=True)

        def decode(llr):
            with torch.inference_mode():
                # Sionna's LLRs are logits, log P(1) / P(0): the opposite sign
                llr_dev = torch.from_numpy(-llr.astype(np.float32)).to(common.TORCH_DEVICE)
                return common.to_numpy(dec(llr_dev)).astype(np.uint8)

        return same, decode, code.n, "float32 (config.precision = 'single')"

    if case == "conv":
        enc = ConvEncoder(rate=0.5, constraint_length=7, terminate=True)
        dec = ViterbiDecoder(encoder=enc, method="soft_llr", return_info_bits=True)
        k, n = K_CONV, 2 * (K_CONV + 6)
    else:
        enc = LDPC5GEncoder(K_NR, N_NR)
        dec = LDPC5GDecoder(enc, num_iter=ITERATIONS, hard_out=True, return_infobits=True)
        k, n = K_NR, N_NR
    source, mapper, channel = BinarySource(), Mapper("pam", 1), AWGN()
    demapper = Demapper("app", "pam", 1)

    def llrs(f: int, ebn0: float):
        with torch.inference_mode():
            b = source([f, k])
            no = n0_of(ebn0, k / n)
            y = channel(mapper(enc(b)), no)
            return b, demapper(y, no)  # logits, log P(1) / P(0)

    def decode(llr):
        with torch.inference_mode():
            return dec(llr)

    return llrs, decode, k, "float32 (config.precision = 'single')"


def peg_pcm(code):
    h = np.zeros((len(code.rows), code.n), dtype=np.float32)
    for i, r in enumerate(code.rows):
        h[i, r] = 1
    return h


CODECS = {
    "comnum": codec_comnum,
    "komm": codec_komm,
    "opticommpy": codec_opticommpy,
    "opticommpy-nojit": codec_opticommpy,
    "sionna": codec_sionna,
}


def as_np(a) -> np.ndarray:
    return common.to_numpy(a)


def run_codecs(args, todo: list[str]) -> list[dict]:
    threading = common.setup_torch(args.threads) if args.lib == "sionna" else {}
    sz = sizes(args.quick)
    out = []
    for case in todo:
        llrs, decode, k_out, precision = CODECS[args.lib](case, args.seed)
        res = {"case": case, "precision": precision, **threading}

        def chain(f: int, ebn0: float, llrs=llrs, decode=decode) -> np.ndarray:
            ref, llr = llrs(f, ebn0)
            return np.count_nonzero(
                as_np(decode(llr)).reshape(f, -1) != as_np(ref).reshape(f, -1), axis=1
            )

        nt = 2 if args.lib == "opticommpy-nojit" else sz["timing"]
        e_t = TIMING_EBN0[case]
        t0 = time.perf_counter()
        chain(min(nt, 2), e_t)
        res["first_call_s"] = time.perf_counter() - t0
        if not args.timing_only:
            pts = []
            t0 = time.perf_counter()
            for e in EBN0[case]:
                errs = np.concatenate(
                    [chain(sz["block"], e) for _ in range(sz["frames"] // sz["block"])]
                )
                bound = (
                    float(theory.conv_union_bound(CONV_GEN, 7, K_CONV / (2 * (K_CONV + 6)), e))
                    if case == "conv"
                    else None
                )
                pt = common.frame_ber_point(errs, k_out, None, ebn0_db=e)
                if bound is not None:
                    pt["union_bound"] = bound
                fe = int(np.count_nonzero(errs))
                pt["bler"] = common.rate_point(fe, errs.size)
                if case == "ldpc_same_llr":
                    pt["errors_per_frame"] = errs.astype(int).tolist()
                pts.append(pt)
            res["points"] = pts
            res["sweep_s"] = time.perf_counter() - t0
            res["bits_per_frame"] = k_out
        _, llr = llrs(nt, e_t)
        res["timing"] = common.time_it(lambda llr=llr, decode=decode: decode(llr))
        res["timing"].update(
            units=nt, unit="frame", workload=f"decoder only, {nt} frames at Eb/N0 = {e_t} dB"
        )
        res["timing_chain"] = common.time_it(lambda nt=nt, e_t=e_t, chain=chain: chain(nt, e_t))
        res["timing_chain"].update(
            units=nt, unit="frame", workload=f"whole chain, {nt} frames at Eb/N0 = {e_t} dB"
        )
        out.append(res)
    return out


# ------------------------------------------------------------- specification runner


def spec(ebn0: list[float], frames: int, trials: int, seed: int) -> dict:
    code = {"type": "conv", "k": K_CONV}
    return {
        "name": "bench_conv",
        "seed": seed,
        "batch": trials,
        "grid": {"ebn0_db": ebn0},
        "link": [
            {"type": "fec_encode", "code": code, "blocks": frames // trials},
            {"type": "map", "constellation": {"kind": "psk", "m": 2}},
            {"type": "awgn_ebn0", "ebn0_db": "$ebn0_db"},
            "demap",
            {"type": "fec_decode", "code": code},
        ],
        "metrics": {"report": ["coded_ber", "bler"]},  # coded_ber: decoded information bits
        "stop": {"min_trials": trials, "max_trials": trials},
    }


def spec_points(rows: list[dict]) -> list[dict]:
    pts = []
    for r in rows:
        e = float(r["condition"]["ebn0_db"])
        m = r["metrics"]
        get = lambda d: d["count"] if "count" in d else d["errors"]  # noqa: E731
        # a binomial interval: the runner keeps the totals, not the errors of every frame
        pt = common.rate_point(get(m["coded_ber"]), m["coded_ber"]["total"], None, ebn0_db=e)
        pt["union_bound"] = float(
            theory.conv_union_bound(CONV_GEN, 7, K_CONV / (2 * (K_CONV + 6)), e)
        )
        pt["bler"] = common.rate_point(get(m["bler"]), m["bler"]["total"])
        pts.append(pt)
    return pts


def run_spec_lib(args, todo: list[str], *, rust: bool = False) -> list[dict]:
    """``comnum-spec`` (``comnum.run_spec``), or with ``rust`` ``comnum-rust`` (the binary)."""
    sz = sizes(args.quick)
    trials = 20
    out = []
    for case in todo:
        nt, e_t = sz["timing"], TIMING_EBN0[case]
        workload = f"whole chain, {nt} frames at Eb/N0 = {e_t} dB, {trials} trials"
        res = common.spec_case(
            args,
            {"case": case, "precision": "float64"},
            spec(EBN0[case], sz["frames"], trials, args.seed),
            spec([e_t], nt, trials, args.seed + 1),
            spec_points,
            rust=rust,
            key="timing_chain",
            units=nt,
            unit="frame",
            workload=workload + (" (process wall time)" if rust else ""),
        )
        if not args.timing_only:
            res["bits_per_frame"] = K_CONV
        out.append(res)
    return out


def run_rust_lib(args, todo: list[str]) -> list[dict]:
    return run_spec_lib(args, todo, rust=True)


def postprocess(merged: dict, work) -> dict:
    """ldpc_same_llr: the decoders saw the same LLRs; compare their errors frame by frame
    with those of comnum (same number of bit errors in a frame, same frame errors)."""
    per_lib = {}
    for run in merged["runs"]:
        if run.get("threads") != "1":
            continue
        for case in run.get("cases", []):
            if case["case"] == "ldpc_same_llr" and "points" in case:
                per_lib[run["library"]] = case["points"]
    base = per_lib.get("comnum")
    rows = []
    for lib, pts in per_lib.items():
        if lib == "comnum" or base is None:
            continue
        for p, q in zip(pts, base, strict=True):
            a, b = np.array(p["errors_per_frame"]), np.array(q["errors_per_frame"])
            rows.append(
                {
                    "library": lib,
                    "ebn0_db": p["ebn0_db"],
                    "frames": int(a.size),
                    "same_bit_errors": int(np.count_nonzero(a == b)),
                    "same_frame_outcome": int(np.count_nonzero((a > 0) == (b > 0))),
                }
            )
    return {"same_llr_vs_comnum": rows}


RUNNERS = {
    **{lib: run_codecs for lib in CODECS},
    "comnum-spec": run_spec_lib,
    "comnum-rust": run_rust_lib,
}

if __name__ == "__main__":
    common.main(SCENARIO, CASES, RUNNERS)

Support

Case

comnum (functions)

comnum.run_spec

OptiCommPy (numba)

Sionna

convolutional (133, 171), soft Viterbi

yes

yes

not available: no convolutional code

yes

5G NR LDPC (1024, 2048)

yes

not available: the specification format has no 5G NR LDPC code (NrLdpc: Python and Rust API)

not available: no 5G NR LDPC code (its decoder takes a parity-check matrix: see ldpc_same_llr)

yes

LDPC (1024, 512), same LLRs

yes

not available: the runner draws its own channel (this case feeds the same LLRs to every decoder)

yes

yes

convolutional (133, 171), soft Viterbi: error rates

Library

Precision

Against

max abs(z), BER

max abs(z), FER

Agrees

BER interval

comnum (functions)

float64

—

—

—

—

per-frame

comnum.run_spec

float64

comnum

1.35

1.33

yes

binomial

Sionna

float32 (config.precision = ‘single’)

comnum

1.41

2.53

yes

per-frame

BER and its 95 % interval, every point (2,048,000 bits per point)

Eb/N0 (dB)

union bound

comnum (functions)

comnum.run_spec

Sionna

1

3.05e-1

4.02e-2 [3.91e-2, 4.13e-2]

4.02e-2 [3.99e-2, 4.05e-2]

4.03e-2 [3.91e-2, 4.14e-2]

2

1.65e-2

4.97e-3 [4.64e-3, 5.30e-3]

5.16e-3 [5.06e-3, 5.26e-3]

5.32e-3 [4.97e-3, 5.67e-3]

3

6.27e-4

3.31e-4 [2.59e-4, 4.02e-4]

2.98e-4 [2.76e-4, 3.23e-4]

3.64e-4 [2.94e-4, 4.34e-4]

4

2.02e-5

2.54e-5 [8.46e-6, 4.23e-5]

1.32e-5 [9.06e-6, 1.92e-5]

3.08e-5 [9.04e-6, 5.25e-5]

Frame error rate and its 95 % interval (2,000 frames per point)

Eb/N0 (dB)

comnum (functions)

comnum.run_spec

Sionna

1

9.73e-1 [9.66e-1, 9.80e-1]

9.77e-1 [9.69e-1, 9.83e-1]

9.85e-1 [9.79e-1, 9.89e-1]

2

5.05e-1 [4.84e-1, 5.27e-1]

4.84e-1 [4.63e-1, 5.06e-1]

5.13e-1 [4.91e-1, 5.35e-1]

3

5.95e-2 [5.00e-2, 7.07e-2]

6.05e-2 [5.09e-2, 7.18e-2]

6.70e-2 [5.69e-2, 7.88e-2]

4

6.00e-3 [3.44e-3, 1.05e-2]

5.00e-3 [2.72e-3, 9.18e-3]

5.50e-3 [3.07e-3, 9.82e-3]

5G NR LDPC (1024, 2048): error rates

Library

Precision

Against

max abs(z), BER

max abs(z), FER

Agrees

BER interval

comnum (functions)

float64

—

—

—

—

per-frame

Sionna

float32 (config.precision = ‘single’)

comnum

0.893

1.21

yes

per-frame

BER and its 95 % interval, every point (2,048,000 bits per point)

Eb/N0 (dB)

comnum (functions)

Sionna

1

8.79e-3 [7.46e-3, 1.01e-2]

9.30e-3 [7.94e-3, 1.07e-2]

1.5

3.47e-5 [0, 1.03e-4]

9.38e-5 [0, 2.04e-4]

2

0 [0, 1.46e-6]

0 [0, 1.46e-6]

2.5

0 [0, 1.46e-6]

0 [0, 1.46e-6]

Frame error rate and its 95 % interval (2,000 frames per point)

Eb/N0 (dB)

comnum (functions)

Sionna

1

9.40e-2 [8.20e-2, 1.08e-1]

1.05e-1 [9.28e-2, 1.20e-1]

1.5

5.00e-4 [8.83e-5, 2.83e-3]

1.50e-3 [5.10e-4, 4.40e-3]

2

0 [0, 1.92e-3]

0 [0, 1.92e-3]

2.5

0 [0, 1.92e-3]

0 [0, 1.92e-3]

LDPC (1024, 512), same LLRs: error rates

Library

Precision

Against

max abs(z), BER

max abs(z), FER

Agrees

BER interval

comnum (functions)

float64

—

—

—

—

per-frame

OptiCommPy (numba)

float32 (decodeLDPC default prec = np.float32)

comnum

0.076

0

yes

per-frame

Sionna

float32 (config.precision = ‘single’)

comnum

0.0846

0.0381

yes

per-frame

BER and its 95 % interval, every point (2,048,000 bits per point)

Eb/N0 (dB)

comnum (functions)

OptiCommPy (numba)

Sionna

1.5

1.53e-2 [1.39e-2, 1.66e-2]

1.53e-2 [1.40e-2, 1.67e-2]

1.53e-2 [1.40e-2, 1.67e-2]

2

1.37e-3 [9.56e-4, 1.78e-3]

1.37e-3 [9.55e-4, 1.78e-3]

1.37e-3 [9.58e-4, 1.78e-3]

2.5

0 [0, 1.46e-6]

0 [0, 1.46e-6]

0 [0, 1.46e-6]

3

0 [0, 1.46e-6]

0 [0, 1.46e-6]

0 [0, 1.46e-6]

Frame error rate and its 95 % interval (2,000 frames per point)

Eb/N0 (dB)

comnum (functions)

OptiCommPy (numba)

Sionna

1.5

2.21e-1 [2.03e-1, 2.40e-1]

2.21e-1 [2.03e-1, 2.40e-1]

2.21e-1 [2.04e-1, 2.40e-1]

2

2.30e-2 [1.73e-2, 3.05e-2]

2.30e-2 [1.73e-2, 3.05e-2]

2.30e-2 [1.73e-2, 3.05e-2]

2.5

0 [0, 1.92e-3]

0 [0, 1.92e-3]

0 [0, 1.92e-3]

3

0 [0, 1.92e-3]

0 [0, 1.92e-3]

0 [0, 1.92e-3]

Same LLRs: the decisions frame by frame

The decoders received identical LLRs. Over all the points: the frames where a library makes as many bit errors as comnum, and the frames it decodes (or fails to decode) as comnum does.

Library

Points

Frames

Same bit errors

Same frame outcome

OptiCommPy (numba)

4

8000

7996 (99.95 %)

8000 (100.00 %)

Sionna

4

8000

7984 (99.80 %)

7999 (99.99 %)

convolutional (133, 171), soft Viterbi: decoder time

Median of 5 runs, ms per frame; workload: decoder only, 200 frames at Eb/N0 = 3.0 dB. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

GPU

Relative

First call

comnum (functions)

float64

0.75

0.756

—

1.00

0.0021 s

Sionna

float32 (config.precision = ‘single’)

4.14

3.85

1.26

5.52

0.171 s

5G NR LDPC (1024, 2048): decoder time

Median of 5 runs, ms per frame; workload: decoder only, 200 frames at Eb/N0 = 2.0 dB. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

GPU

Relative

First call

comnum (functions)

float64

8.53

8.28

—

1.00

0.0173 s

Sionna

float32 (config.precision = ‘single’)

47.3

12.5

0.171

5.55

0.0652 s

LDPC (1024, 512), same LLRs: decoder time

Median of 5 runs, ms per frame; workload: decoder only, 200 frames at Eb/N0 = 2.0 dB. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

GPU

Relative

First call

comnum (functions)

float64

1.54

1.62

—

1.00

0.0032 s

OptiCommPy (numba)

float32 (decodeLDPC default prec = np.float32)

7.45

5.00

—

4.83

5.59 s

Sionna

float32 (config.precision = ‘single’)

8.59

2.14

0.172

5.57

0.0275 s

convolutional (133, 171), soft Viterbi: whole-chain time

Median of 5 runs, ms per frame; workload: whole chain, 200 frames at Eb/N0 = 3.0 dB. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

GPU

Relative

First call

comnum (functions)

float64

0.967

0.966

—

1.00

0.0021 s

comnum.run_spec

float64

0.966

0.202

—

0.999

0.192 s

Sionna

float32 (config.precision = ‘single’)

4.65

4.34

2.05

4.81

0.171 s

5G NR LDPC (1024, 2048): whole-chain time

Median of 5 runs, ms per frame; workload: whole chain, 200 frames at Eb/N0 = 2.0 dB. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

GPU

Relative

First call

comnum (functions)

float64

8.72

8.69

—

1.00

0.0173 s

Sionna

float32 (config.precision = ‘single’)

47.8

12.6

0.181

5.47

0.0652 s

LDPC (1024, 512), same LLRs: whole-chain time

Median of 5 runs, ms per frame; workload: whole chain, 200 frames at Eb/N0 = 2.0 dB. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

GPU

Relative

First call

comnum (functions)

float64

1.64

1.68

—

1.00

0.0032 s

OptiCommPy (numba)

float32 (decodeLDPC default prec = np.float32)

7.08

4.91

—

4.32

5.59 s

Sionna

float32 (config.precision = ‘single’)

9.09

2.24

0.357

5.55

0.0275 s

The commands of every run
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s4_coding.py --lib comnum --threads 1 --work runpod/_work --out runpod/_work/s4_coding_comnum_1.json
OMP_NUM_THREADS=11 OPENBLAS_NUM_THREADS=11 MKL_NUM_THREADS=11 VECLIB_MAXIMUM_THREADS=11 NUMBA_NUM_THREADS=11 RAYON_NUM_THREADS=11 python s4_coding.py --lib comnum --threads all --work runpod/_work --out runpod/_work/s4_coding_comnum_all.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s4_coding.py --lib comnum-spec --threads 1 --work runpod/_work --out runpod/_work/s4_coding_comnum-spec_1.json
OMP_NUM_THREADS=11 OPENBLAS_NUM_THREADS=11 MKL_NUM_THREADS=11 VECLIB_MAXIMUM_THREADS=11 NUMBA_NUM_THREADS=11 RAYON_NUM_THREADS=11 python s4_coding.py --lib comnum-spec --threads all --work runpod/_work --out runpod/_work/s4_coding_comnum-spec_all.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s4_coding.py --lib opticommpy --threads 1 --work runpod/_work --out runpod/_work/s4_coding_opticommpy_1.json
OMP_NUM_THREADS=11 OPENBLAS_NUM_THREADS=11 MKL_NUM_THREADS=11 VECLIB_MAXIMUM_THREADS=11 NUMBA_NUM_THREADS=11 RAYON_NUM_THREADS=11 python s4_coding.py --lib opticommpy --threads all --work runpod/_work --out runpod/_work/s4_coding_opticommpy_all.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s4_coding.py --lib sionna --threads 1 --work runpod/_work --out runpod/_work/s4_coding_sionna_1.json
OMP_NUM_THREADS=11 OPENBLAS_NUM_THREADS=11 MKL_NUM_THREADS=11 VECLIB_MAXIMUM_THREADS=11 NUMBA_NUM_THREADS=11 RAYON_NUM_THREADS=11 python s4_coding.py --lib sionna --threads all --work runpod/_work --out runpod/_work/s4_coding_sionna_all.json --timing-only
OMP_NUM_THREADS=11 OPENBLAS_NUM_THREADS=11 MKL_NUM_THREADS=11 VECLIB_MAXIMUM_THREADS=11 NUMBA_NUM_THREADS=11 RAYON_NUM_THREADS=11 python s4_coding.py --lib sionna --threads gpu --work runpod/_work --out runpod/_work/s4_coding_sionna_gpu.json --timing-only

Split-step Fourier propagation, 16-QAM, 32 GBd, 10 x 80 km#

Scenario 5: single-polarisation split-step Fourier propagation of 32 GBd 16-QAM over 10 x 80 km of standard single-mode fibre, a fixed step of 1 km (80 steps per span), noiseless amplification at the end of every span (gain e^{alpha L}).

Parameters:

  • fibre: alpha = 0.2 dB/km, D = 17 ps/nm/km, gamma = 1.3 /W/km, fc = 193.1 THz

  • link: 10 x 80 km, ideal (noiseless) amplification after every span

  • step: 1 km (800 steps); reference: comnum at 0.05 km (16000 steps)

  • signal: 16-QAM, 32 GBd, RRC roll-off 0.1, 4 samples per symbol, 4 dBm; 2^14 symbols (quick: 2^11)

  • timing: one propagation over the 10 spans

Script: benchmarks/libraries/s5_ssfm.py.

The script
"""Scenario 5: single-polarisation split-step Fourier propagation of 32 GBd 16-QAM over
10 x 80 km of standard single-mode fibre, a fixed step of 1 km (80 steps per span),
noiseless amplification at the end of every span (gain e^{alpha L}).

The input field is the same for every library: 16-QAM symbols of comnum (seed 1),
shaped by a root-raised cosine (roll-off 0.1, 4 samples per symbol, 128 GS/s) and
scaled to 4 dBm; it is drawn again, identically, by every process. Every library
propagates it with its own split-step solver (symmetric steps, nonlinear phase over
the whole step in all three); run_all.py compares the output fields (NMSE) with that
of comnum in double precision and with a step-converged reference (comnum, 0.05 km).

Sionna's equation is the complex conjugate of the one of comnum and OptiCommPy
(dE/dz = ... + j beta_2/2 d^2E/dt^2 - j gamma |E|^2 E): its field is compared in their
convention, conj(sionna(conj(x))), and the NMSE of the plain output is reported too.
Sionna propagates one span per call (SSFM) and amplifies with its EDFA of noise figure
0 (no noise). OptiCommPy's ssfm is NumPy only (no numba kernel): its two variants run
the same code. In the mode gpu, OptiCommPy runs its CuPy solver
(optic.models.modelsGPU.ssfm: the same steps, every array on the device; the input is
copied to the device and the output back to the host inside the timed call) in
complex128 and in complex64. Timing: one propagation over the 10 spans, median of 5
runs after a warm-up run.

    python s5_ssfm.py --lib opticommpy --threads 1 [--quick] --out results/_work/s5.json
"""

from __future__ import annotations

import math
import time
from pathlib import Path

import common
import numpy as np

SCENARIO = "s5_ssfm"
TITLE = "Split-step Fourier propagation, 16-QAM, 32 GBd, 10 x 80 km"
CASES = {
    "ssfm": {
        "comnum": True,
        "komm": "no fibre model",
        "opticommpy": True,
        "opticommpy-nojit": True,
        "sionna": True,
    }
}
# The libraries with a GPU path in this scenario (run_all.py: the mode gpu).
GPU_LIBS = {"sionna", "opticommpy"}
ALPHA_DB_KM, D_PS_NM_KM, GAMMA, FC = 0.2, 17.0, 1.3, 193.1e12
SPAN_KM, N_SPANS, STEP_KM = 80.0, 10, 1.0
REF_STEP_KM = 0.05
SYMBOL_RATE, SPS = 32e9, 4
FS = SYMBOL_RATE * SPS
POWER_DBM = 4.0

PARAMETERS = {
    "fibre": "alpha = 0.2 dB/km, D = 17 ps/nm/km, gamma = 1.3 /W/km, fc = 193.1 THz",
    "link": "10 x 80 km, ideal (noiseless) amplification after every span",
    "step": "1 km (800 steps); reference: comnum at 0.05 km (16000 steps)",
    "signal": "16-QAM, 32 GBd, RRC roll-off 0.1, 4 samples per symbol, 4 dBm; 2^14 symbols (quick: 2^11)",
    "timing": "one propagation over the 10 spans",
}


def n_symbols(quick: bool) -> int:
    return 2**11 if quick else 2**14


def input_field(quick: bool) -> np.ndarray:
    import comnum

    rng = comnum.Rng(1)
    c = comnum.Constellation("qam", 16).normalized()
    _, s = comnum.symbols(c, n_symbols(quick), rng)
    h = comnum.pulse_shape("rrc", SPS, n_taps=1025, rolloff=0.1)
    x = comnum.pnorm(comnum.fir_filter(h, comnum.upsample(s, SPS)))
    return x * math.sqrt(comnum.dbm2w(POWER_DBM))


def beta2_ps2_km() -> float:
    c = 299_792_458.0
    lam = c / FC
    return -(D_PS_NM_KM * 1e-6) * lam**2 / (2 * math.pi * c) * 1e24 * 1e3  # s^2/m -> ps^2/km


def prop_comnum(precision: str, step_km: float = STEP_KM):
    import comnum

    f = comnum.FiberParams(
        alpha_db_km=ALPHA_DB_KM,
        d_ps_nm_km=D_PS_NM_KM,
        gamma_w_km=GAMMA,
        span_km=SPAN_KM,
        n_spans=N_SPANS,
        fc_hz=FC,
    )
    link = comnum.Ssfm(f, step_km=step_km, amplification="ideal")
    return lambda x: link.propagate(x, FS, precision=precision)


def prop_opticommpy(gpu: bool = False, prec=np.complex128):
    """OptiCommPy's split-step: the NumPy solver, or its CuPy copy on the GPU
    (``optic.models.modelsGPU.ssfm``, which takes and returns NumPy arrays: the
    transfers to and from the device are inside the timed call)."""
    if gpu:
        from optic.models.modelsGPU import ssfm
    else:
        from optic.models.channels import ssfm
    from optic.utils import parameters

    p = parameters()
    p.Ltotal, p.Lspan, p.hz = SPAN_KM * N_SPANS, SPAN_KM, STEP_KM
    p.alpha, p.D, p.gamma, p.Fc, p.Fs = ALPHA_DB_KM, D_PS_NM_KM, GAMMA, FC, FS
    p.amp, p.prgsBar, p.prec = "ideal", False, prec
    return lambda x: ssfm(x, p)


def prop_sionna(precision: str):
    import torch
    from sionna.phy.channel.optical import EDFA, SSFM

    alpha = ALPHA_DB_KM / (10 * math.log10(math.e))  # 1/km
    span = SSFM(
        alpha=alpha,
        beta_2=beta2_ps2_km(),
        f_c=FC,
        gamma=GAMMA,
        length=SPAN_KM,
        n_ssfm=round(SPAN_KM / STEP_KM),
        sample_duration=1e12 / FS,  # ps
        t_norm=1e-12,
        with_amplification=False,
        precision=precision,
    )
    amp = EDFA(g=math.exp(alpha * SPAN_KM), f=0.0, f_c=FC, dt=1 / FS, precision=precision)
    cdtype = torch.complex64 if precision == "single" else torch.complex128

    def run(x: np.ndarray) -> np.ndarray:
        with torch.inference_mode():
            u = torch.from_numpy(x).to(device=common.TORCH_DEVICE, dtype=cdtype)
            for _ in range(N_SPANS):
                u = amp(span(u))
            return common.to_numpy(u)

    return run


def run_lib(args, todo: list[str]) -> list[dict]:
    if args.lib == "sionna":
        threading = common.setup_torch(args.threads)
    elif args.lib == "opticommpy" and args.threads == "gpu":
        threading = common.setup_cupy()
    else:
        threading = {}
    x = input_field(args.quick)
    work = Path(args.work)
    work.mkdir(parents=True, exist_ok=True)
    if args.lib == "comnum":
        variants = {"f64": (prop_comnum("f64"), "float64"), "f32": (prop_comnum("f32"), "float32")}
    elif args.lib == "sionna":
        variants = {
            "single": (prop_sionna("single"), "float32 (precision = 'single', the default)"),
            "double": (prop_sionna("double"), "float64 (precision = 'double')"),
        }
    elif args.lib == "opticommpy" and args.threads == "gpu":
        import cupy as cp

        variants = {
            "f64": (prop_opticommpy(True, cp.complex128), "float64 (prec = cp.complex128)"),
            "f32": (prop_opticommpy(True, cp.complex64), "float32 (prec = cp.complex64)"),
        }
    else:
        variants = {"f64": (prop_opticommpy(), "float64 (prec = np.complex128)")}
    out = []
    for name, (prop, precision) in variants.items():
        res = {
            "case": "ssfm",
            "variant": name,
            "precision": precision,
            "samples": int(x.size),
            **threading,
        }
        if args.lib == "sionna":
            fwd = lambda prop=prop: np.conj(prop(np.conj(x)))  # noqa: E731
        else:
            fwd = lambda prop=prop: prop(x)  # noqa: E731
        t0 = time.perf_counter()
        y = fwd()
        res["first_call_s"] = time.perf_counter() - t0
        if not args.timing_only:
            np.save(work / f"{SCENARIO}_{args.lib}_{name}.npy", np.asarray(y, dtype=np.complex128))
            if args.lib == "sionna":
                np.save(
                    work / f"{SCENARIO}_{args.lib}_{name}_plain.npy",
                    np.asarray(prop(x), dtype=np.complex128),
                )
        res["timing"] = common.time_it(fwd)
        res["timing"].update(
            units=1,
            unit="propagation",
            workload=f"{x.size} samples, {N_SPANS} x {SPAN_KM:g} km, {STEP_KM:g} km steps",
        )
        out.append(res)
    if args.lib == "comnum" and not args.timing_only:
        t0 = time.perf_counter()
        np.save(work / f"{SCENARIO}_reference.npy", prop_comnum("f64", REF_STEP_KM)(x))
        out.append(
            {
                "case": "ssfm",
                "variant": "reference",
                "precision": "float64",
                "step_km": REF_STEP_KM,
                "wall_s": time.perf_counter() - t0,
            }
        )
    return out


def nmse(a: np.ndarray, b: np.ndarray) -> float:
    return float(np.sum(np.abs(a - b) ** 2) / np.sum(np.abs(b) ** 2))


def postprocess(merged: dict, work: Path) -> dict:
    """The NMSE of every output field against comnum (f64, 1 km) and the reference."""
    fields = {}
    for path in sorted(work.glob(f"{SCENARIO}_*.npy")):
        fields[path.stem.removeprefix(f"{SCENARIO}_")] = np.load(path)
    base, ref = fields.get("comnum_f64"), fields.get("reference")
    rows = []
    for name, y in fields.items():
        if name == "reference":
            continue
        rows.append(
            {
                "field": name,
                "nmse_vs_comnum_f64": nmse(y, base) if base is not None else None,
                "nmse_vs_reference": nmse(y, ref) if ref is not None else None,
            }
        )
    return {"nmse": rows, "reference_step_km": REF_STEP_KM}


RUNNERS = {"comnum": run_lib, "opticommpy": run_lib, "opticommpy-nojit": run_lib, "sionna": run_lib}

if __name__ == "__main__":
    common.main(SCENARIO, CASES, RUNNERS)

Support

Case

comnum (functions)

OptiCommPy (numba)

Sionna

split-step Fourier

yes

yes

yes

Output fields

NMSE of the output field, sum |a - b|^2 / sum |b|^2, against comnum in float64 at the same step and against the step-converged reference (comnum, 0.05 km). The NMSE against the reference is the error of the 1 km step itself.

Field

NMSE vs comnum (float64, 1 km)

NMSE vs reference

comnum, float32

1.14e-8

7.45e-8

comnum, float64

0

3.69e-8

OptiCommPy, float64

2.24e-26

3.69e-8

Sionna, float64

3.55e-25

3.69e-8

Sionna, float64, its own convention (not conjugated)

2.03e0

2.03e0

Sionna, float32

2.15e-8

2.45e-8

Sionna, float32, its own convention (not conjugated)

2.03e0

2.03e0

split-step Fourier: time

Median of 5 runs, s per propagation; workload: 65536 samples, 10 x 80 km, 1 km steps. Relative: the 1-thread time over that of comnum (functions) on 1 thread.

Library

Precision

1 thread

all threads

GPU

Relative

First call

comnum (functions), f64

float64

1.29

1.31

—

1.00

1.31 s

comnum (functions), f32

float32

0.614

0.619

—

0.476

0.625 s

OptiCommPy (numba), f64

float64 (prec = np.complex128)

4.54

4.51

0.144

3.52

5.28 s

OptiCommPy (numba), f32

float32 (prec = cp.complex64)

—

—

0.144

—

0.155 s

Sionna, single

float32 (precision = ‘single’, the default)

5.36

1.51

0.277

4.16

5.39 s

Sionna, double

float64 (precision = ‘double’)

6.80

5.65

0.272

5.28

6.4 s

The commands of every run
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s5_ssfm.py --lib comnum --threads 1 --work runpod/_work --out runpod/_work/s5_ssfm_comnum_1.json
OMP_NUM_THREADS=11 OPENBLAS_NUM_THREADS=11 MKL_NUM_THREADS=11 VECLIB_MAXIMUM_THREADS=11 NUMBA_NUM_THREADS=11 RAYON_NUM_THREADS=11 python s5_ssfm.py --lib comnum --threads all --work runpod/_work --out runpod/_work/s5_ssfm_comnum_all.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s5_ssfm.py --lib opticommpy --threads 1 --work runpod/_work --out runpod/_work/s5_ssfm_opticommpy_1.json
OMP_NUM_THREADS=11 OPENBLAS_NUM_THREADS=11 MKL_NUM_THREADS=11 VECLIB_MAXIMUM_THREADS=11 NUMBA_NUM_THREADS=11 RAYON_NUM_THREADS=11 python s5_ssfm.py --lib opticommpy --threads all --work runpod/_work --out runpod/_work/s5_ssfm_opticommpy_all.json --timing-only
OMP_NUM_THREADS=11 OPENBLAS_NUM_THREADS=11 MKL_NUM_THREADS=11 VECLIB_MAXIMUM_THREADS=11 NUMBA_NUM_THREADS=11 RAYON_NUM_THREADS=11 python s5_ssfm.py --lib opticommpy --threads gpu --work runpod/_work --out runpod/_work/s5_ssfm_opticommpy_gpu.json --timing-only
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 NUMBA_NUM_THREADS=1 RAYON_NUM_THREADS=1 python s5_ssfm.py --lib sionna --threads 1 --work runpod/_work --out runpod/_work/s5_ssfm_sionna_1.json
OMP_NUM_THREADS=11 OPENBLAS_NUM_THREADS=11 MKL_NUM_THREADS=11 VECLIB_MAXIMUM_THREADS=11 NUMBA_NUM_THREADS=11 RAYON_NUM_THREADS=11 python s5_ssfm.py --lib sionna --threads all --work runpod/_work --out runpod/_work/s5_ssfm_sionna_all.json --timing-only
OMP_NUM_THREADS=11 OPENBLAS_NUM_THREADS=11 MKL_NUM_THREADS=11 VECLIB_MAXIMUM_THREADS=11 NUMBA_NUM_THREADS=11 RAYON_NUM_THREADS=11 python s5_ssfm.py --lib sionna --threads gpu --work runpod/_work --out runpod/_work/s5_ssfm_sionna_gpu.json --timing-only