Skip to content

Commit c78da63

Browse files
ehsun-shclaude
andcommitted
Let the kernels take their array library from their inputs
The propagation kernels are the only part of this worth a GPU: a loop over FFTs on a long array, where everything else is scalar arithmetic or a closed form evaluated a few dozen times. They were written as array-to-array functions from the beginning so this could be added without touching anything above them, and `maiman.backend` is the whole of the addition. The arrays decide, not a setting. A kernel handed CuPy arrays runs on CuPy and returns CuPy arrays; handed NumPy arrays it runs on NumPy. No global mode and no flag on the context — the kernels are pure functions, and a hidden mode would be the one piece of state that could make the same inputs give two answers. Dispatch is on the array's own type, so there is no registry to keep in sync. CuPy is not exercised here and the docs say so: there is no device and no install in CI. What is tested is the half that would break a port. A second array library sets `__array_function__` to None — NumPy's own way for a type to say it is not NumPy's — so `np.fft.fft` on one of its arrays raises. Universal functions are deliberately left working, because `np.exp` on a CuPy array dispatches and comes back a CuPy array; refusing them would be testing a rule that is not true. What breaks a port is anything that *allocates*, and that is what the guard covers. Every converted kernel runs on both libraries and the results are compared, and the names the second was asked for are recorded and asserted as an equality rather than a lower bound: thirteen, all of which CuPy provides. A change that reaches for something only NumPy has now fails in this repository rather than on somebody's GPU, and one that stops needing something fails too. Only the propagation path is converted, and a test names which functions are in and which are out so the line is a decision rather than an oversight. 9 of 9 sabotages caught, after two got through. One was a host copy taken with `np.asarray` instead of the array's own `get`, which works on a NumPy subclass and raises on a real device array — the stub grew a `get` that records being called. The other was the split-step building its frequency grid on the host: the numbers come out right, because multiplying a host array by a device array works by transferring the host one, once per step, over a bus. Correctness could not see it, so the test asserts the allocation instead. 860 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent fc0f53d commit c78da63

6 files changed

Lines changed: 618 additions & 32 deletions

File tree

README.md

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,43 @@ noise bins rendered onto one grid, the way an instrument shows them. Its resolut
447447
not cosmetic — widening it raises the ASE trace decibel for decibel and leaves a carrier exactly
448448
where it is, which is the clearest demonstration of why OSNR needs a stated reference bandwidth.
449449

450+
## The kernels do not know what they are running on
451+
452+
The propagation kernels are the only part of this worth a GPU: a loop over FFTs on a long array,
453+
where everything else is scalar arithmetic or a closed form evaluated a few dozen times.
454+
[`maiman/kernels.py`](src/maiman/kernels.py) was written as array-to-array functions from the
455+
beginning so that this could be added without touching anything above it, and
456+
[`maiman/backend.py`](src/maiman/backend.py) is the whole of the addition.
457+
458+
**The arrays decide, not a setting.** A kernel handed CuPy arrays runs on CuPy and returns CuPy
459+
arrays; handed NumPy arrays it runs on NumPy. There is no global mode and no flag on the context,
460+
which matters because the kernels are pure functions and a hidden mode would be the one piece of
461+
state that could make the same inputs give different answers. Dispatch is on the array's own type —
462+
`type(a).__module__` names the package it came from — so there is no registry to keep in sync.
463+
464+
**CuPy is not exercised here**, and saying otherwise would be the kind of claim this project exists
465+
to avoid: there is no device and no install in CI. What *is* tested is the half that would actually
466+
break a port. A second array library — [`tests/hostile_backend.py`](tests/hostile_backend.py) — sets
467+
`__array_function__` to `None`, which is NumPy's own way for a type to say it is not NumPy's, so
468+
`np.fft.fft` on one of its arrays raises. Universal functions are deliberately left working, because
469+
`np.exp` on a CuPy array dispatches and comes back a CuPy array; refusing them would be testing a
470+
rule that is not true. What breaks a port is anything that *allocates*`np.fft.fftfreq` and
471+
`np.zeros` build on the host, and a kernel calling one inside its loop pays a transfer every step.
472+
473+
Every kernel is then run on both libraries and the results compared, and the names the second one
474+
was asked for are recorded. That set is the contract, and it is asserted as an equality rather than
475+
a lower bound:
476+
477+
abs complex128 conj exp float64 max pi
478+
fft.fft fft.fftfreq fft.ifft fft.irfft fft.rfft fft.rfftfreq
479+
480+
Thirteen names. A change that reaches for something only NumPy has fails in this repository rather
481+
than on somebody's GPU, and one that stops needing something fails too. CuPy provides all thirteen.
482+
483+
Only the propagation path is converted. The four-wave-mixing closed forms and the 2×2 Jones algebra
484+
are scalar work a device would slow down, and there is a test naming which functions are in and
485+
which are out, so the line is a decision rather than an oversight.
486+
450487
## 400G and 800G, and what they cost
451488

452489
Three reference transceivers, all dual-polarization coherent, all derived from one number and
@@ -966,7 +1003,7 @@ time window, and results are reproducible.
9661003
| **1 — MVP: linear link** *(essentially done)* | ✅ PRBS → NRZ → laser → MZM → fiber (α + CD) → PIN → filter → eye/Q/BER, validated end to end. **Python only, no GUI.** | ~2–3 months |
9671004
| **1.5 — Nonlinear & amplified**| Adaptive-step SSFM, Kerr, EDFA with ASE, OSNR, PMD, APD, dispersion slope and its third-order term, cross-polarization Kerr coupling, inter-channel stimulated Raman scattering | ~2 months |
9681005
| **2 — Coherent transceiver**| Gray-coded M-QAM to 256, IQ modulator with bias and quadrature error, 90° hybrid, balanced detection, blind carrier phase recovery, dual polarization with a blind butterfly equaliser, root-raised-cosine shaping and matched filtering, differential quadrant encoding, receiver-side dispersion compensation over spans to 1000 km with blind estimation of the accumulated value, EVM/MER, constellation diagram, validated against closed-form SER | ~3 months |
969-
| **3 — GUI & WDM** | ✅ Wavelength-selective filters, an OSA, coupled-channel propagation (XPM with walk-off, FWM accumulating coherently across spans), the session server, a schematic editor — add, wire, move and delete blocks, edit parameters, run, sweep, open and save — and 400G/800G reference designs validated against the OSNR relations · CuPy back-end | ~6 months |
1006+
| **3 — GUI & WDM** | ✅ Wavelength-selective filters, an OSA, coupled-channel propagation (XPM with walk-off, FWM accumulating coherently across spans), the session server, a schematic editor — add, wire, move and delete blocks, edit parameters, run, sweep, open and save — and 400G/800G reference designs validated against the OSNR relations, and a back-end indirection the propagation kernels dispatch through — CuPy runs it where a device exists; it is not exercised in CI | ~6 months |
9701007
| **4 — PIC** | Waveguides, ring resonators, MMI, MZI via integration with an existing S-matrix solver; PDK import ||
9711008

9721009
¹ One developer, part-time. Estimates, not commitments.

docs/ARCHITECTURE.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,15 @@ The original plan specified a C++20 core with pybind11 bindings. Reconsidered:
350350
GPU, and a native C++/Rust module later if profiling shows a specific kernel needs it. The
351351
architecture keeps that door open; it does not walk through it on day one.
352352

353+
**Since built:** `maiman.backend` is the door, and it opens onto the propagation kernels only —
354+
the closed forms and the 2x2 Jones algebra are scalar work a device would slow down. Dispatch is on
355+
the array's own type rather than on a mode: a kernel handed CuPy arrays runs on CuPy, because the
356+
kernels are pure functions and a global mode would be the one piece of state that could make the
357+
same inputs give two answers. The surface a back-end must provide is thirteen names, asserted as an
358+
equality in `tests/test_backend.py` against a second array library that refuses NumPy's allocating
359+
API. CuPy itself is not exercised: there is no device in CI, and the part that is untested is
360+
CuPy's numerics rather than this interface.
361+
353362
### 6.2 FFT library and licensing — a real constraint
354363

355364
**FFTW is GPL-2.0-or-later** (commercial licenses are sold separately). Linking it makes the

src/maiman/backend.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""Which array library a kernel runs on, and how it finds out.
2+
3+
The propagation kernels are the only part of this project a GPU would help: they
4+
are a loop over FFTs on a long array, and everything else is either scalar
5+
arithmetic or a closed form evaluated a few dozen times. :mod:`maiman.kernels`
6+
was written as array-to-array functions from the beginning so that this could be
7+
added without touching anything above it, and this module is the whole of the
8+
addition.
9+
10+
**The arrays decide, not a setting.** A kernel handed CuPy arrays runs on CuPy
11+
and returns CuPy arrays; handed NumPy arrays it runs on NumPy. There is no global
12+
mode, no flag on the context, and nothing to get out of step between two calls —
13+
which matters because the kernels are pure functions and a hidden mode would be
14+
the one piece of state that could make the same inputs give different answers.
15+
It is also the idiom CuPy itself recommends.
16+
17+
Dispatch is by the array's own type: ``type(a).__module__`` names the package it
18+
came from, and if that package is imported and looks like an array library it is
19+
the one to use. No registry, nothing to keep in sync, and a library nobody has
20+
heard of works if it presents the same surface.
21+
22+
**What a back-end has to provide** is small, and
23+
``tests/test_backend.py`` holds it to exactly that list — a kernel that reaches
24+
past it for something only NumPy has fails there rather than on somebody's GPU.
25+
26+
**CuPy is not exercised here.** No CUDA device or CuPy install is available in
27+
this project's CI, so what is tested is that the kernels never touch NumPy
28+
directly and that a second, deliberately hostile array library gets identical
29+
answers out of them. That is the part that would break a port; the part that
30+
remains untested is CuPy's own numerics, which are not this project's to test.
31+
"""
32+
33+
from __future__ import annotations
34+
35+
import sys
36+
from types import ModuleType
37+
38+
import numpy as np
39+
40+
#: Attributes a module must have before it is believed to be an array library.
41+
#: ``fft`` is the one every kernel here needs and no ordinary module has.
42+
_MARKERS = ("fft", "exp", "abs")
43+
44+
45+
def array_module(*arrays: object) -> ModuleType:
46+
"""The array library ``arrays`` belong to, defaulting to NumPy.
47+
48+
The first argument that names a plausible array library wins, so a kernel
49+
mixing a device array with a plain Python float still runs on the device.
50+
Scalars, lists and NumPy arrays all fall through to NumPy, which is what
51+
makes this free for every existing caller.
52+
"""
53+
for array in arrays:
54+
root = type(array).__module__.partition(".")[0]
55+
module = sys.modules.get(root)
56+
if module is None or module is np:
57+
continue
58+
if all(hasattr(module, marker) for marker in _MARKERS):
59+
return module
60+
return np
61+
62+
63+
def to_numpy(array: object) -> np.ndarray:
64+
"""Bring a result back to the host, whatever it was computed on.
65+
66+
Measurement and encoding are host work — a constellation histogram is
67+
kilobytes and a JSON payload is not going anywhere near a device — so this is
68+
the boundary the rest of the project sees.
69+
"""
70+
getter = getattr(array, "get", None)
71+
if callable(getter): # cupy.ndarray.get, and anything that copies it
72+
return np.asarray(getter())
73+
return np.asarray(array)
74+
75+
76+
def available() -> dict[str, bool]:
77+
"""Which back-ends this interpreter could actually use.
78+
79+
Reported rather than assumed: ``pip install cupy-cuda12x`` and a device is
80+
what makes the second entry true, and neither is something this project can
81+
check for by importing hopefully.
82+
"""
83+
import importlib.util
84+
85+
return {
86+
"numpy": True,
87+
"cupy": importlib.util.find_spec("cupy") is not None,
88+
}

src/maiman/kernels.py

Lines changed: 51 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,25 @@
1919

2020
import numpy as np
2121

22+
from .backend import array_module
2223
from .units import C_LIGHT
2324

2425

25-
def angular_frequency_grid(num_samples: int, sample_rate: float) -> np.ndarray:
26+
def angular_frequency_grid(
27+
num_samples: int, sample_rate: float, *, like: object = None
28+
) -> np.ndarray:
2629
"""Angular frequency offsets from the band centre [rad/s], in FFT order.
2730
2831
Returned in `numpy.fft` output order (positive frequencies first, then
2932
negative), so it multiplies an un-shifted spectrum directly.
33+
34+
``like`` names the array library to build it on — pass the field it is about
35+
to multiply and the grid is created wherever that field lives, which is the
36+
difference between one kernel and a device round trip per step. See
37+
:mod:`maiman.backend`.
3038
"""
31-
return 2.0 * np.pi * np.fft.fftfreq(num_samples, d=1.0 / sample_rate)
39+
xp = array_module(like)
40+
return 2.0 * xp.pi * xp.fft.fftfreq(num_samples, d=1.0 / sample_rate)
3241

3342

3443
def dispersion_to_beta2(dispersion: float, wavelength: float) -> float:
@@ -108,12 +117,13 @@ def propagate_dispersion(
108117
the caller casts the result back. Correctness first; if profiling later shows
109118
this matters, the precision policy belongs here, in one place.
110119
"""
120+
xp = array_module(field)
111121
if distance == 0.0 or (beta2 == 0.0 and beta3 == 0.0):
112-
return field.astype(np.complex128, copy=True)
122+
return field.astype(xp.complex128, copy=True)
113123

114-
omega = angular_frequency_grid(field.shape[0], sample_rate)
115-
transfer = np.exp(1j * (0.5 * beta2 * omega**2 - beta3 * omega**3 / 6.0) * distance)
116-
return np.fft.ifft(np.fft.fft(field.astype(np.complex128)) * transfer)
124+
omega = angular_frequency_grid(field.shape[0], sample_rate, like=field)
125+
transfer = xp.exp(1j * (0.5 * beta2 * omega**2 - beta3 * omega**3 / 6.0) * distance)
126+
return xp.fft.ifft(xp.fft.fft(field.astype(xp.complex128)) * transfer)
117127

118128

119129
def soliton_peak_power(beta2: float, gamma: float, width: float, order: int = 1) -> float:
@@ -405,7 +415,8 @@ def propagate_coupled_ssfm(
405415
if any(value not in (0, 1) for value in axis):
406416
raise ValueError(f"polarization entries must be 0 or 1, got {sorted(set(axis))}")
407417

408-
a = [f.astype(np.complex128, copy=True) for f in fields]
418+
xp = array_module(*fields)
419+
a = [f.astype(xp.complex128, copy=True) for f in fields]
409420
if not a:
410421
return a, PropagationDiagnostics(0, distance, 0.0, 0.0, 0.0)
411422

@@ -421,7 +432,7 @@ def propagate_coupled_ssfm(
421432
if distance == 0.0:
422433
return a, PropagationDiagnostics(0, 0.0, 0.0, 0.0, 0.0)
423434

424-
omega = angular_frequency_grid(a[0].shape[0], sample_rate)
435+
omega = angular_frequency_grid(a[0].shape[0], sample_rate, like=a[0])
425436
# One expansion of beta(omega), one operator: the group delay, the dispersion
426437
# and its slope are the first three terms of the same series, which is what
427438
# fixes their relative signs. See propagate_dispersion.
@@ -451,11 +462,11 @@ def propagate_coupled_ssfm(
451462
# always did.
452463
peak_effective = 0.0
453464
for field, ax in zip(a, axis, strict=True):
454-
effective = 2.0 * _axis_power[ax] - np.abs(field) ** 2
465+
effective = 2.0 * _axis_power[ax] - xp.abs(field) ** 2
455466
other = _axis_power.get(1 - ax)
456467
if other is not None:
457468
effective = effective + ORTHOGONAL_KERR_WEIGHT * other
458-
peak_effective = max(peak_effective, float(np.max(effective)))
469+
peak_effective = max(peak_effective, float(xp.max(effective)))
459470
if peak_effective > 0.0:
460471
step = min(step, max_nonlinear_phase / (abs(gamma) * peak_effective))
461472
# Only the nonlinear operator cares where the channels sit relative
@@ -467,8 +478,8 @@ def propagate_coupled_ssfm(
467478
# without this an extreme peak power would stall the loop.
468479
step = max(min(step, remaining), remaining * 1e-9)
469480

470-
half = [np.exp(-alpha * step / 4.0 + op * (step / 2.0)) for op in operators]
471-
a = [np.fft.ifft(np.fft.fft(f) * h) for f, h in zip(a, half, strict=True)]
481+
half = [xp.exp(-alpha * step / 4.0 + op * (step / 2.0)) for op in operators]
482+
a = [xp.fft.ifft(xp.fft.fft(f) * h) for f, h in zip(a, half, strict=True)]
472483

473484
if gamma != 0.0:
474485
# Summed per polarization, because power in the orthogonal component
@@ -478,16 +489,16 @@ def propagate_coupled_ssfm(
478489
per_axis = _power_per_axis(a, axis)
479490
rotated = []
480491
for field, ax in zip(a, axis, strict=True):
481-
effective = 2.0 * per_axis[ax] - np.abs(field) ** 2
492+
effective = 2.0 * per_axis[ax] - xp.abs(field) ** 2
482493
other = per_axis.get(1 - ax)
483494
if other is not None:
484495
effective = effective + ORTHOGONAL_KERR_WEIGHT * other
485496
phase = gamma * effective * step
486-
peak_phase = max(peak_phase, float(np.max(np.abs(phase))))
487-
rotated.append(field * np.exp(1j * phase))
497+
peak_phase = max(peak_phase, float(xp.max(xp.abs(phase))))
498+
rotated.append(field * xp.exp(1j * phase))
488499
a = rotated
489500

490-
a = [np.fft.ifft(np.fft.fft(f) * h) for f, h in zip(a, half, strict=True)]
501+
a = [xp.fft.ifft(xp.fft.fft(f) * h) for f, h in zip(a, half, strict=True)]
491502

492503
travelled += step
493504
steps += 1
@@ -500,7 +511,7 @@ def propagate_coupled_ssfm(
500511
# of the accumulated -1j*w*omega*distance, so it cannot remove anything
501512
# the propagation put there.
502513
a = [
503-
np.fft.ifft(np.fft.fft(f) * np.exp(1j * w * omega * distance))
514+
xp.fft.ifft(xp.fft.fft(f) * xp.exp(1j * w * omega * distance))
504515
for f, w in zip(a, walkoff, strict=True)
505516
]
506517

@@ -534,9 +545,10 @@ def _power_per_axis(fields: Sequence[np.ndarray], axis: Sequence[int]) -> dict[i
534545

535546
def _total_power(fields: Sequence[np.ndarray]) -> np.ndarray:
536547
"""Summed instantaneous power of co-propagating fields [W], sample by sample."""
537-
total = np.abs(fields[0]) ** 2
548+
xp = array_module(fields[0])
549+
total = xp.abs(fields[0]) ** 2
538550
for field in fields[1:]:
539-
total += np.abs(field) ** 2
551+
total += xp.abs(field) ** 2
540552
return total
541553

542554

@@ -918,21 +930,26 @@ def apply_pmd(
918930
what the DGD measurement reports are the same chain.
919931
"""
920932
if not sections:
921-
return ex.astype(np.complex128, copy=True), ey.astype(np.complex128, copy=True)
933+
module = array_module(ex, ey)
934+
return (
935+
ex.astype(module.complex128, copy=True),
936+
ey.astype(module.complex128, copy=True),
937+
)
922938

923-
omega = angular_frequency_grid(ex.shape[0], sample_rate)
924-
spectrum_x = np.fft.fft(ex.astype(np.complex128))
925-
spectrum_y = np.fft.fft(ey.astype(np.complex128))
939+
xp = array_module(ex, ey)
940+
omega = angular_frequency_grid(ex.shape[0], sample_rate, like=ex)
941+
spectrum_x = xp.fft.fft(ex.astype(xp.complex128))
942+
spectrum_y = xp.fft.fft(ey.astype(xp.complex128))
926943

927944
for section in sections:
928-
phase = np.exp(0.5j * omega * section.dgd)
945+
phase = xp.exp(0.5j * omega * section.dgd)
929946
delayed_x = spectrum_x * phase
930-
delayed_y = spectrum_y * np.conj(phase)
947+
delayed_y = spectrum_y * xp.conj(phase)
931948
u = section.unitary
932949
spectrum_x = u[0, 0] * delayed_x + u[0, 1] * delayed_y
933950
spectrum_y = u[1, 0] * delayed_x + u[1, 1] * delayed_y
934951

935-
return np.fft.ifft(spectrum_x), np.fft.ifft(spectrum_y)
952+
return xp.fft.ifft(spectrum_x), xp.fft.ifft(spectrum_y)
936953

937954

938955
def gaussian_lowpass_response(frequency: np.ndarray, bandwidth: float) -> np.ndarray:
@@ -943,7 +960,8 @@ def gaussian_lowpass_response(frequency: np.ndarray, bandwidth: float) -> np.nda
943960
"""
944961
if bandwidth <= 0.0:
945962
raise ValueError(f"bandwidth must be positive, got {bandwidth}")
946-
return np.exp(-0.5 * np.log(2.0) * (frequency / bandwidth) ** 2)
963+
xp = array_module(frequency)
964+
return xp.exp(-0.5 * math.log(2.0) * (frequency / bandwidth) ** 2)
947965

948966

949967
def super_gaussian_response(frequency: np.ndarray, bandwidth: float, order: int) -> np.ndarray:
@@ -964,7 +982,8 @@ def super_gaussian_response(frequency: np.ndarray, bandwidth: float, order: int)
964982
raise ValueError(f"bandwidth must be positive, got {bandwidth}")
965983
if order < 1:
966984
raise ValueError(f"order must be >= 1, got {order}")
967-
return np.exp(-0.5 * np.log(2.0) * (2.0 * frequency / bandwidth) ** (2 * order))
985+
xp = array_module(frequency)
986+
return xp.exp(-0.5 * math.log(2.0) * (2.0 * frequency / bandwidth) ** (2 * order))
968987

969988

970989
def super_gaussian_noise_bandwidth(bandwidth: float, order: int) -> float:
@@ -1015,6 +1034,7 @@ def lowpass_filter(samples: np.ndarray, sample_rate: float, bandwidth: float) ->
10151034
window are contaminated by the wrap; analysis blocks drop them.
10161035
"""
10171036
n = samples.shape[0]
1018-
spectrum = np.fft.rfft(samples.astype(np.float64))
1019-
response = gaussian_lowpass_response(np.fft.rfftfreq(n, d=1.0 / sample_rate), bandwidth)
1020-
return np.fft.irfft(spectrum * response, n)
1037+
xp = array_module(samples)
1038+
spectrum = xp.fft.rfft(samples.astype(xp.float64))
1039+
frequency = xp.asarray(xp.fft.rfftfreq(n, d=1.0 / sample_rate))
1040+
return xp.fft.irfft(spectrum * gaussian_lowpass_response(frequency, bandwidth), n)

0 commit comments

Comments
 (0)