commkit 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- commkit/__init__.py +74 -0
- commkit/_cuda/__init__.py +321 -0
- commkit/_cuda/compiler.py +88 -0
- commkit/_cuda/src/bps_min_d2.cu +104 -0
- commkit/_cuda/src/cs_block.cu +119 -0
- commkit/_cuda/src/selftest.cu +14 -0
- commkit/analysis/__init__.py +55 -0
- commkit/analysis/_common.py +236 -0
- commkit/analysis/allan.py +108 -0
- commkit/analysis/drift.py +213 -0
- commkit/analysis/interferometry.py +887 -0
- commkit/analysis/linewidth.py +480 -0
- commkit/analysis/trajectory.py +91 -0
- commkit/backend.py +507 -0
- commkit/coding/__init__.py +23 -0
- commkit/coding/base.py +17 -0
- commkit/coding/bch.py +6 -0
- commkit/coding/convolutional.py +7 -0
- commkit/coding/crc.py +7 -0
- commkit/coding/galois.py +8 -0
- commkit/coding/hamming.py +6 -0
- commkit/coding/interleaving.py +7 -0
- commkit/coding/ldpc.py +8 -0
- commkit/coding/polar.py +8 -0
- commkit/coding/ratematch.py +6 -0
- commkit/coding/reed_solomon.py +6 -0
- commkit/coding/turbo.py +8 -0
- commkit/core/__init__.py +32 -0
- commkit/core/frame.py +992 -0
- commkit/core/generation.py +581 -0
- commkit/core/signal.py +725 -0
- commkit/equalization/__init__.py +49 -0
- commkit/equalization/_block.py +1855 -0
- commkit/equalization/_common.py +606 -0
- commkit/equalization/_kernels_jax.py +1720 -0
- commkit/equalization/_kernels_numba.py +1704 -0
- commkit/equalization/blind.py +223 -0
- commkit/equalization/linear.py +365 -0
- commkit/equalization/polarization.py +790 -0
- commkit/equalization/result.py +191 -0
- commkit/equalization/sequential.py +2805 -0
- commkit/filtering.py +1120 -0
- commkit/frequency.py +1191 -0
- commkit/helpers.py +489 -0
- commkit/impairments/__init__.py +43 -0
- commkit/impairments/channel/__init__.py +20 -0
- commkit/impairments/channel/linear.py +310 -0
- commkit/impairments/channel/nonlinear.py +11 -0
- commkit/impairments/frontend.py +229 -0
- commkit/impairments/noise.py +105 -0
- commkit/impairments/source.py +219 -0
- commkit/io.py +308 -0
- commkit/logger.py +103 -0
- commkit/mapping/__init__.py +46 -0
- commkit/mapping/bits.py +240 -0
- commkit/mapping/constellation.py +153 -0
- commkit/mapping/gray.py +429 -0
- commkit/mapping/llr.py +253 -0
- commkit/mapping/shaping.py +218 -0
- commkit/metrics.py +949 -0
- commkit/multirate.py +476 -0
- commkit/plotting/__init__.py +78 -0
- commkit/plotting/analysis.py +627 -0
- commkit/plotting/constellation.py +483 -0
- commkit/plotting/equalizer.py +390 -0
- commkit/plotting/eye.py +388 -0
- commkit/plotting/spectral.py +575 -0
- commkit/plotting/sync.py +953 -0
- commkit/plotting/theme.py +203 -0
- commkit/plotting/waveform.py +200 -0
- commkit/py.typed +0 -0
- commkit/recovery/__init__.py +51 -0
- commkit/recovery/bps.py +337 -0
- commkit/recovery/corrections.py +751 -0
- commkit/recovery/pilots.py +803 -0
- commkit/recovery/pll.py +482 -0
- commkit/recovery/tikhonov.py +424 -0
- commkit/recovery/viterbi_viterbi.py +227 -0
- commkit/spectral.py +560 -0
- commkit/timing.py +841 -0
- commkit-1.0.0.dist-info/METADATA +145 -0
- commkit-1.0.0.dist-info/RECORD +84 -0
- commkit-1.0.0.dist-info/WHEEL +4 -0
- commkit-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1855 @@
|
|
|
1
|
+
"""Block / frequency-domain equalizer engine (block_lms, FDAF)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from ..backend import ArrayType, _get_jax, dispatch, to_device, to_jax
|
|
11
|
+
from ..logger import logger
|
|
12
|
+
from ._common import (
|
|
13
|
+
_build_padded_samples,
|
|
14
|
+
_cpr_symmetry,
|
|
15
|
+
_init_butterfly_weights_numpy,
|
|
16
|
+
_normalize_inputs,
|
|
17
|
+
_sq_qam_slicer_params,
|
|
18
|
+
_unpack_result_jax,
|
|
19
|
+
_unpack_result_numpy,
|
|
20
|
+
_validate_sps,
|
|
21
|
+
_validate_w_init,
|
|
22
|
+
)
|
|
23
|
+
from ._kernels_jax import _get_jax_cma_block, _get_jax_lms_block, _get_jax_rde_block
|
|
24
|
+
from ._kernels_numba import _get_numba_cs_block
|
|
25
|
+
from .result import CPRState, EqualizerResult, _log_equalizer_exit
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _block_eq_xp(
|
|
29
|
+
kind,
|
|
30
|
+
x_padded,
|
|
31
|
+
W,
|
|
32
|
+
mu,
|
|
33
|
+
D,
|
|
34
|
+
stride,
|
|
35
|
+
*,
|
|
36
|
+
constellation=None,
|
|
37
|
+
n_train=0,
|
|
38
|
+
training=None,
|
|
39
|
+
r2=1.0,
|
|
40
|
+
radii=None,
|
|
41
|
+
pref=None,
|
|
42
|
+
pmask=None,
|
|
43
|
+
sq_side=0,
|
|
44
|
+
sq_lev_min=0.0,
|
|
45
|
+
sq_d_grid=1.0,
|
|
46
|
+
):
|
|
47
|
+
"""Array-native (NumPy/CuPy) block-update butterfly equalizer.
|
|
48
|
+
|
|
49
|
+
A plain Python loop over ``ceil(n_sym/D)`` chunks: the per-chunk work is
|
|
50
|
+
matmul-sized so interpreter overhead is amortised, and the same code runs
|
|
51
|
+
on NumPy (CPU) and CuPy (GPU) via the array module of ``x_padded``. The
|
|
52
|
+
forward/gradient einsums promote to ``complex128`` per CLAUDE.md, with
|
|
53
|
+
``complex64`` weight storage.
|
|
54
|
+
|
|
55
|
+
Parameters mirror the per-symbol kernels; ``kind`` is ``'lms'``/``'cma'``/
|
|
56
|
+
``'rde'`` and pilots (``pref``/``pmask``) invert the error to ``y - pref``.
|
|
57
|
+
|
|
58
|
+
Returns ``(y_out (n_sym, C), e_out (n_sym, C), W)`` on the input's backend.
|
|
59
|
+
"""
|
|
60
|
+
x, xp, _ = dispatch(x_padded)
|
|
61
|
+
num_ch = W.shape[0]
|
|
62
|
+
num_taps = W.shape[2]
|
|
63
|
+
n_pad_samples = x.shape[1]
|
|
64
|
+
n_sym = (n_pad_samples - num_taps) // stride + 1
|
|
65
|
+
|
|
66
|
+
y_out = xp.empty((n_sym, num_ch), dtype=xp.complex64)
|
|
67
|
+
e_out = xp.empty((n_sym, num_ch), dtype=xp.complex64)
|
|
68
|
+
|
|
69
|
+
has_pilots = pref is not None and pmask is not None
|
|
70
|
+
if radii is not None:
|
|
71
|
+
radii = xp.asarray(radii).astype(xp.float64)
|
|
72
|
+
if constellation is not None:
|
|
73
|
+
constellation = xp.asarray(constellation).astype(xp.complex64)
|
|
74
|
+
|
|
75
|
+
for start in range(0, n_sym, D):
|
|
76
|
+
stop = min(start + D, n_sym)
|
|
77
|
+
d = stop - start # actual chunk length (D, or shorter for the tail)
|
|
78
|
+
# Window gather for this chunk -> (d, C, T)
|
|
79
|
+
base = (start + xp.arange(d))[:, None] * stride + xp.arange(num_taps)[None, :]
|
|
80
|
+
X_chunk = xp.transpose(x[:, base], (1, 0, 2)) # (d, C, T)
|
|
81
|
+
X64 = X_chunk.astype(xp.complex128)
|
|
82
|
+
Y = xp.einsum("ijt,djt->di", xp.conj(W).astype(xp.complex128), X64) # (d, C)
|
|
83
|
+
Y = Y.astype(xp.complex64)
|
|
84
|
+
|
|
85
|
+
if kind == "lms":
|
|
86
|
+
dd = _slice_block_xp(Y, xp, constellation, sq_side, sq_lev_min, sq_d_grid)
|
|
87
|
+
sym_idx = start + xp.arange(d)
|
|
88
|
+
use_train = (sym_idx < n_train)[:, None]
|
|
89
|
+
tr = training[:, start:stop].T if training is not None else xp.zeros_like(Y)
|
|
90
|
+
dsym = xp.where(use_train, tr, dd)
|
|
91
|
+
E = Y - dsym
|
|
92
|
+
elif kind == "cma":
|
|
93
|
+
E = Y * (xp.real(Y * xp.conj(Y)) - xp.float32(r2))
|
|
94
|
+
elif kind == "rde":
|
|
95
|
+
abs_y2 = xp.real(Y * xp.conj(Y))
|
|
96
|
+
abs_y = xp.sqrt(abs_y2)
|
|
97
|
+
rd = radii[
|
|
98
|
+
xp.argmin(xp.abs(abs_y[..., None] - radii[None, None, :]), axis=-1)
|
|
99
|
+
]
|
|
100
|
+
E = Y * (abs_y2 - rd.astype(xp.float32) ** 2)
|
|
101
|
+
else:
|
|
102
|
+
raise ValueError(f"unknown block kind {kind!r}")
|
|
103
|
+
|
|
104
|
+
if has_pilots:
|
|
105
|
+
pm = pmask[start:stop].astype(bool)[:, None]
|
|
106
|
+
pr = pref[:, start:stop].T
|
|
107
|
+
E = xp.where(pm, Y - pr, E)
|
|
108
|
+
|
|
109
|
+
grad = xp.einsum(
|
|
110
|
+
"di,djt->ijt", xp.conj(E).astype(xp.complex128), X64
|
|
111
|
+
) # (C, C, T)
|
|
112
|
+
W = (W - (xp.complex128(mu) * grad)).astype(xp.complex64)
|
|
113
|
+
|
|
114
|
+
y_out[start:stop] = Y
|
|
115
|
+
e_out[start:stop] = E
|
|
116
|
+
|
|
117
|
+
return y_out, e_out, W
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _slice_block_xp(Y, xp, constellation, sq_side, sq_lev_min, sq_d_grid):
|
|
121
|
+
"""Vectorised nearest-constellation slicer for the array-native block path."""
|
|
122
|
+
if sq_side > 0:
|
|
123
|
+
ir = xp.clip(
|
|
124
|
+
xp.round((Y.real - sq_lev_min) / sq_d_grid).astype(xp.int32),
|
|
125
|
+
0,
|
|
126
|
+
sq_side - 1,
|
|
127
|
+
)
|
|
128
|
+
ii = xp.clip(
|
|
129
|
+
xp.round((Y.imag - sq_lev_min) / sq_d_grid).astype(xp.int32),
|
|
130
|
+
0,
|
|
131
|
+
sq_side - 1,
|
|
132
|
+
)
|
|
133
|
+
nr = sq_lev_min + ir.astype(xp.float32) * xp.float32(sq_d_grid)
|
|
134
|
+
ni = sq_lev_min + ii.astype(xp.float32) * xp.float32(sq_d_grid)
|
|
135
|
+
return (nr + 1j * ni).astype(xp.complex64)
|
|
136
|
+
d2 = xp.abs(Y[..., None] - constellation) ** 2 # (d, C, M)
|
|
137
|
+
return constellation[xp.argmin(d2, axis=-1)]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _validate_block_mode(
|
|
141
|
+
update_mode, block_len, backend, *, cpr_type=None, store_weights=False
|
|
142
|
+
):
|
|
143
|
+
"""Validate ``update_mode``/``block_len`` and the block-mode constraints.
|
|
144
|
+
|
|
145
|
+
No-op for ``update_mode='sequential'``. For ``'block'`` it enforces the
|
|
146
|
+
following: ``backend in {'jax', 'xp'}`` (``'numba'`` is a pointless
|
|
147
|
+
combination), a positive ``block_len``, and no ``cpr_type``/``store_weights``
|
|
148
|
+
(unsupported in v1).
|
|
149
|
+
"""
|
|
150
|
+
if update_mode not in ("sequential", "block"):
|
|
151
|
+
raise ValueError(
|
|
152
|
+
f"update_mode must be 'sequential' or 'block'. Got {update_mode!r}."
|
|
153
|
+
)
|
|
154
|
+
if update_mode == "sequential":
|
|
155
|
+
return
|
|
156
|
+
if block_len < 1:
|
|
157
|
+
raise ValueError(f"block_len must be >= 1. Got {block_len}.")
|
|
158
|
+
if backend not in ("jax", "xp"):
|
|
159
|
+
raise ValueError(
|
|
160
|
+
"update_mode='block' requires backend='jax' (chunked scan) or "
|
|
161
|
+
f"backend='xp' (array-native NumPy/CuPy). Got backend={backend!r}; "
|
|
162
|
+
"backend='numba' is a pointless combination for block updates."
|
|
163
|
+
)
|
|
164
|
+
if cpr_type is not None:
|
|
165
|
+
raise ValueError(
|
|
166
|
+
"cpr_type is not supported with update_mode='block' (v1). Run CPR "
|
|
167
|
+
"as a separate stage, or use update_mode='sequential'."
|
|
168
|
+
)
|
|
169
|
+
if store_weights:
|
|
170
|
+
raise ValueError(
|
|
171
|
+
"store_weights is not supported with update_mode='block' (v1)."
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _resolve_jax_platform(x_jax, device):
|
|
176
|
+
"""Resolve the JAX placement platform string for a transferred input."""
|
|
177
|
+
try:
|
|
178
|
+
if device is not None:
|
|
179
|
+
return device.lower()
|
|
180
|
+
if hasattr(x_jax, "device"):
|
|
181
|
+
return x_jax.device.platform
|
|
182
|
+
return list(x_jax.devices())[0].platform
|
|
183
|
+
except Exception:
|
|
184
|
+
return "cpu"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _build_slicer_constellation(modulation, order, unipolar, training_np, pmf):
|
|
188
|
+
"""Build the NumPy slicer constellation for the DD/block paths.
|
|
189
|
+
|
|
190
|
+
Mirrors the per-symbol kernels: a Gray constellation when ``modulation``
|
|
191
|
+
and ``order`` are given (PS-QAM scaled to unit power when ``pmf`` is
|
|
192
|
+
supplied), otherwise the unique rounded training symbols.
|
|
193
|
+
"""
|
|
194
|
+
if modulation is not None and order is not None:
|
|
195
|
+
from ..mapping import constellation_power, gray_constellation
|
|
196
|
+
|
|
197
|
+
reference_constellation = gray_constellation(
|
|
198
|
+
modulation, order, unipolar=unipolar
|
|
199
|
+
)
|
|
200
|
+
constellation_np = (
|
|
201
|
+
to_device(reference_constellation, "cpu").flatten().astype(np.complex64)
|
|
202
|
+
)
|
|
203
|
+
elif training_np is not None:
|
|
204
|
+
constellation_np = np.unique(np.round(training_np.reshape(-1), decimals=8))
|
|
205
|
+
else:
|
|
206
|
+
raise ValueError("modulation and order must be provided for DD mode.")
|
|
207
|
+
|
|
208
|
+
if pmf is not None and modulation is not None and order is not None:
|
|
209
|
+
_e_ps = constellation_power(constellation_np, pmf)
|
|
210
|
+
if _e_ps < 1.0 - 1e-6:
|
|
211
|
+
constellation_np = (
|
|
212
|
+
constellation_np * np.float32(1.0 / np.sqrt(_e_ps))
|
|
213
|
+
).astype(np.complex64)
|
|
214
|
+
return constellation_np
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _prep_blind_block_inputs(
|
|
218
|
+
samples,
|
|
219
|
+
*,
|
|
220
|
+
sps,
|
|
221
|
+
stride,
|
|
222
|
+
pad_left,
|
|
223
|
+
pad_right,
|
|
224
|
+
samples_prefix,
|
|
225
|
+
pad_mode,
|
|
226
|
+
input_norm_factor,
|
|
227
|
+
use_pilots,
|
|
228
|
+
pilot_gain_db,
|
|
229
|
+
pilot_mask,
|
|
230
|
+
pilot_ref,
|
|
231
|
+
c_ps,
|
|
232
|
+
num_ch,
|
|
233
|
+
num_taps,
|
|
234
|
+
center_tap,
|
|
235
|
+
w_init,
|
|
236
|
+
):
|
|
237
|
+
"""Normalise/pad inputs and build pilot arrays for blind block CMA/RDE.
|
|
238
|
+
|
|
239
|
+
Mirrors the per-symbol ``cma``/``rde`` numba prep: pilot deboost before the
|
|
240
|
+
global RMS normalisation, overlap padding, initial butterfly weights, and
|
|
241
|
+
the dense pilot reference/mask (PS-QAM-scaled). Returns NumPy arrays for
|
|
242
|
+
the backend-agnostic block dispatch.
|
|
243
|
+
"""
|
|
244
|
+
samples_np = np.ascontiguousarray(to_device(samples, "cpu"), dtype=np.complex64)
|
|
245
|
+
if use_pilots and pilot_gain_db != 0.0:
|
|
246
|
+
_amp = np.float32(10.0 ** (pilot_gain_db / 20.0))
|
|
247
|
+
_smask = np.repeat(pilot_mask.astype(bool), stride)
|
|
248
|
+
samples_np[..., _smask] /= _amp
|
|
249
|
+
samples_np, _, eq_norm = _normalize_inputs(
|
|
250
|
+
samples_np, None, sps, input_norm_factor=input_norm_factor
|
|
251
|
+
)
|
|
252
|
+
samples_padded_np = _build_padded_samples(
|
|
253
|
+
samples_np, pad_left, pad_right, samples_prefix, pad_mode, eq_norm, sps
|
|
254
|
+
)
|
|
255
|
+
if w_init is not None:
|
|
256
|
+
w_arr = _validate_w_init(
|
|
257
|
+
np.ascontiguousarray(to_device(w_init, "cpu"), dtype=np.complex64),
|
|
258
|
+
num_ch,
|
|
259
|
+
num_taps,
|
|
260
|
+
)
|
|
261
|
+
else:
|
|
262
|
+
w_arr = _init_butterfly_weights_numpy(num_ch, num_taps, center_tap=center_tap)
|
|
263
|
+
pref_np = pmask_np = None
|
|
264
|
+
if use_pilots:
|
|
265
|
+
pref_np = np.ascontiguousarray(to_device(pilot_ref, "cpu"), dtype=np.complex64)
|
|
266
|
+
if c_ps is not None:
|
|
267
|
+
pref_np = (pref_np * c_ps).astype(np.complex64)
|
|
268
|
+
pmask_np = np.ascontiguousarray(pilot_mask, dtype=np.uint8)
|
|
269
|
+
return samples_padded_np, w_arr, eq_norm, pref_np, pmask_np
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _run_block_equalizer(
|
|
273
|
+
kind,
|
|
274
|
+
*,
|
|
275
|
+
samples_padded_np,
|
|
276
|
+
w_arr,
|
|
277
|
+
num_ch,
|
|
278
|
+
num_taps,
|
|
279
|
+
n_sym,
|
|
280
|
+
stride,
|
|
281
|
+
block_len,
|
|
282
|
+
step_size,
|
|
283
|
+
backend,
|
|
284
|
+
device,
|
|
285
|
+
was_1d,
|
|
286
|
+
xp,
|
|
287
|
+
eq_norm,
|
|
288
|
+
name,
|
|
289
|
+
debug_plot=False,
|
|
290
|
+
plot_smoothing=50,
|
|
291
|
+
check_convergence=False,
|
|
292
|
+
constellation_np=None,
|
|
293
|
+
train_full=None,
|
|
294
|
+
n_train_aligned=0,
|
|
295
|
+
sq_side=0,
|
|
296
|
+
sq_lev_min=0.0,
|
|
297
|
+
sq_d_grid=1.0,
|
|
298
|
+
r2=1.0,
|
|
299
|
+
radii_np=None,
|
|
300
|
+
pref_np=None,
|
|
301
|
+
pmask_np=None,
|
|
302
|
+
):
|
|
303
|
+
"""Backend dispatch shared by the ``update_mode='block'`` path of the
|
|
304
|
+
time-domain equalizers (``lms``/``cma``/``rde``).
|
|
305
|
+
|
|
306
|
+
``backend='xp'`` runs the array-native loop on the input's module
|
|
307
|
+
(NumPy/CuPy); ``backend='jax'`` runs the chunked ``lax.scan``. Returns a
|
|
308
|
+
finalised ``EqualizerResult`` (via ``_log_equalizer_exit``).
|
|
309
|
+
"""
|
|
310
|
+
D = int(block_len)
|
|
311
|
+
has_pilots = pref_np is not None and pmask_np is not None
|
|
312
|
+
|
|
313
|
+
if backend == "xp":
|
|
314
|
+
x_pad = xp.asarray(samples_padded_np)
|
|
315
|
+
W0 = xp.asarray(w_arr)
|
|
316
|
+
kwargs = {}
|
|
317
|
+
if kind == "lms":
|
|
318
|
+
kwargs.update(
|
|
319
|
+
constellation=xp.asarray(constellation_np),
|
|
320
|
+
n_train=int(n_train_aligned),
|
|
321
|
+
training=xp.asarray(train_full),
|
|
322
|
+
sq_side=int(sq_side),
|
|
323
|
+
sq_lev_min=float(sq_lev_min),
|
|
324
|
+
sq_d_grid=float(sq_d_grid),
|
|
325
|
+
)
|
|
326
|
+
elif kind == "cma":
|
|
327
|
+
kwargs.update(r2=float(r2))
|
|
328
|
+
elif kind == "rde":
|
|
329
|
+
kwargs.update(radii=xp.asarray(radii_np))
|
|
330
|
+
if has_pilots:
|
|
331
|
+
kwargs.update(pref=xp.asarray(pref_np), pmask=xp.asarray(pmask_np))
|
|
332
|
+
y_out, e_out, W_final = _block_eq_xp(
|
|
333
|
+
kind, x_pad, W0, float(step_size), D, stride, **kwargs
|
|
334
|
+
)
|
|
335
|
+
result = _unpack_result_numpy(
|
|
336
|
+
y_out,
|
|
337
|
+
e_out,
|
|
338
|
+
W_final,
|
|
339
|
+
np.empty((1, num_ch, num_ch, num_taps), dtype=np.complex64),
|
|
340
|
+
was_1d,
|
|
341
|
+
False,
|
|
342
|
+
n_sym=None,
|
|
343
|
+
xp=xp,
|
|
344
|
+
num_train_symbols=int(n_train_aligned),
|
|
345
|
+
input_norm_factor=eq_norm,
|
|
346
|
+
)
|
|
347
|
+
return _log_equalizer_exit(
|
|
348
|
+
result,
|
|
349
|
+
name=name,
|
|
350
|
+
debug_plot=debug_plot,
|
|
351
|
+
check_convergence=check_convergence,
|
|
352
|
+
plot_smoothing=plot_smoothing,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
# backend == "jax"
|
|
356
|
+
jax, jnp, _ = _get_jax()
|
|
357
|
+
if jax is None or jnp is None:
|
|
358
|
+
raise ImportError("JAX is required for backend='jax'.")
|
|
359
|
+
x_jax = to_jax(samples_padded_np, device=device) # (C, N_pad)
|
|
360
|
+
platform = _resolve_jax_platform(x_jax, device)
|
|
361
|
+
W_jax = to_jax(w_arr, device=platform)
|
|
362
|
+
mu_jax = to_jax(jnp.float32(step_size), device=platform)
|
|
363
|
+
|
|
364
|
+
if kind == "lms":
|
|
365
|
+
const_jax = to_jax(constellation_np, device=platform)
|
|
366
|
+
train_jax = to_jax(train_full, device=platform)
|
|
367
|
+
n_train_jax = to_jax(jnp.int32(n_train_aligned), device=platform)
|
|
368
|
+
run = _get_jax_lms_block(
|
|
369
|
+
num_taps,
|
|
370
|
+
stride,
|
|
371
|
+
len(constellation_np),
|
|
372
|
+
num_ch,
|
|
373
|
+
n_sym,
|
|
374
|
+
D,
|
|
375
|
+
int(sq_side),
|
|
376
|
+
float(sq_lev_min),
|
|
377
|
+
float(sq_d_grid),
|
|
378
|
+
)
|
|
379
|
+
y_jax, e_jax, W_out, _ = run(
|
|
380
|
+
x_jax, train_jax, const_jax, W_jax, mu_jax, n_train_jax
|
|
381
|
+
)
|
|
382
|
+
else:
|
|
383
|
+
if has_pilots:
|
|
384
|
+
pref_jax = to_jax(pref_np, device=platform) # (C, n_sym)
|
|
385
|
+
pmask_jax = to_jax(pmask_np.astype(bool), device=platform) # (n_sym,)
|
|
386
|
+
else:
|
|
387
|
+
pref_jax = to_jax(np.zeros((num_ch, n_sym), np.complex64), device=platform)
|
|
388
|
+
pmask_jax = to_jax(np.zeros((n_sym,), bool), device=platform)
|
|
389
|
+
if kind == "cma":
|
|
390
|
+
r2_jax = to_jax(jnp.float32(r2), device=platform)
|
|
391
|
+
run = _get_jax_cma_block(num_taps, stride, num_ch, n_sym, D, has_pilots)
|
|
392
|
+
y_jax, e_jax, W_out, _ = run(
|
|
393
|
+
x_jax, W_jax, mu_jax, r2_jax, pref_jax, pmask_jax
|
|
394
|
+
)
|
|
395
|
+
else: # rde
|
|
396
|
+
radii_jax = to_jax(np.asarray(radii_np, np.float32), device=platform)
|
|
397
|
+
run = _get_jax_rde_block(
|
|
398
|
+
num_taps, stride, len(radii_np), num_ch, n_sym, D, has_pilots
|
|
399
|
+
)
|
|
400
|
+
y_jax, e_jax, W_out, _ = run(
|
|
401
|
+
x_jax, W_jax, mu_jax, radii_jax, pref_jax, pmask_jax
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
result = _unpack_result_jax(
|
|
405
|
+
y_jax,
|
|
406
|
+
e_jax,
|
|
407
|
+
W_out,
|
|
408
|
+
None,
|
|
409
|
+
was_1d,
|
|
410
|
+
False,
|
|
411
|
+
n_sym=None,
|
|
412
|
+
xp=xp,
|
|
413
|
+
num_train_symbols=int(n_train_aligned),
|
|
414
|
+
input_norm_factor=eq_norm,
|
|
415
|
+
)
|
|
416
|
+
return _log_equalizer_exit(
|
|
417
|
+
result,
|
|
418
|
+
name=name,
|
|
419
|
+
debug_plot=debug_plot,
|
|
420
|
+
check_convergence=check_convergence,
|
|
421
|
+
plot_smoothing=plot_smoothing,
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def block_lms(
|
|
426
|
+
samples: ArrayType,
|
|
427
|
+
training_symbols: ArrayType | None = None,
|
|
428
|
+
num_taps: int = 21,
|
|
429
|
+
sps: int = 2,
|
|
430
|
+
step_size: float = 2e-4,
|
|
431
|
+
block_size: int = 256,
|
|
432
|
+
modulation: str | None = None,
|
|
433
|
+
order: int | None = None,
|
|
434
|
+
unipolar: bool = False,
|
|
435
|
+
store_weights: bool = False,
|
|
436
|
+
w_init: ArrayType | None = None,
|
|
437
|
+
pmf: Any | None = None,
|
|
438
|
+
cpr_type: str | None = None,
|
|
439
|
+
cpr_bps_test_phases: int = 64,
|
|
440
|
+
cpr_bps_block_size: int = 32,
|
|
441
|
+
cpr_joint_channels: bool = False,
|
|
442
|
+
cpr_cycle_slip_correction: bool = False,
|
|
443
|
+
cpr_cycle_slip_history: int = 100,
|
|
444
|
+
cpr_cycle_slip_threshold: float = np.pi / 4,
|
|
445
|
+
debug_plot: bool = False,
|
|
446
|
+
plot_smoothing: int = 50,
|
|
447
|
+
cpr_state: CPRState | None = None,
|
|
448
|
+
input_norm_factor: float | np.ndarray | None = None,
|
|
449
|
+
samples_prefix: ArrayType | None = None,
|
|
450
|
+
pad_mode: str = "zeros",
|
|
451
|
+
cuda_graph: bool = True,
|
|
452
|
+
) -> EqualizerResult:
|
|
453
|
+
"""Block LMS equalizer with frequency-domain gradient accumulation.
|
|
454
|
+
|
|
455
|
+
Processes the signal in fixed-size blocks of ``block_size`` symbols.
|
|
456
|
+
Within each block the filter is held frozen, all ``block_size`` errors are
|
|
457
|
+
accumulated into a single frequency-domain gradient, and the weights are
|
|
458
|
+
updated once per block. This amortises the FFT overhead over many symbols,
|
|
459
|
+
making it significantly more efficient than per-symbol LMS on GPU for large
|
|
460
|
+
MIMO configurations (C ≥ 4) or long sequences.
|
|
461
|
+
|
|
462
|
+
The primary target is **GPU** via CuPy. On CPU, per-symbol LMS with the
|
|
463
|
+
Numba backend (``lms(..., backend='numba')``) is typically faster because
|
|
464
|
+
the block-FFT overhead outweighs the gradient-accumulation saving for
|
|
465
|
+
small channel counts.
|
|
466
|
+
|
|
467
|
+
``block_lms`` is the **trained / decision-directed** frequency-domain
|
|
468
|
+
equalizer. Its blind siblings share the same overlap-save engine but use a
|
|
469
|
+
phase-blind error: :func:`block_cma` (Godard constant-modulus) and
|
|
470
|
+
:func:`block_rde` (ring-directed, for multi-ring QAM). For a time-domain
|
|
471
|
+
block update with shorter adaptation lag (fast dynamics) use
|
|
472
|
+
``lms(..., update_mode='block')``.
|
|
473
|
+
|
|
474
|
+
Algorithm (per block b)
|
|
475
|
+
-----------------------
|
|
476
|
+
1. **Forward pass** - frequency-domain butterfly filter::
|
|
477
|
+
|
|
478
|
+
Y_fd[i] = sum_j conj(H_fd[i,j]) * X_fd[j]
|
|
479
|
+
|
|
480
|
+
where ``H_fd = FFT(h, n=F)`` and ``X_fd = FFT(x_block, n=F)``.
|
|
481
|
+
Output symbols are extracted at decimated positions ``y[n] = y_time[n*sps]``.
|
|
482
|
+
|
|
483
|
+
2. **BPS phase recovery** (if ``cpr_type='bps'``) - for each symbol in the
|
|
484
|
+
block, averages the min-distance metric over a causal trailing window of
|
|
485
|
+
``cpr_bps_block_size`` symbols and picks the minimum-metric candidate
|
|
486
|
+
rotation. This produces one phase estimate ``phi_n`` per symbol
|
|
487
|
+
(not one per block), so ``cpr_bps_block_size`` and ``block_size`` are
|
|
488
|
+
independent parameters: ``block_size`` controls FFT/gradient efficiency
|
|
489
|
+
while ``cpr_bps_block_size`` controls phase noise suppression. The raw
|
|
490
|
+
``[0, pi/2)`` argmin is converted to full-range radians by a causal 4-fold
|
|
491
|
+
unwrap, and stored in a float64 accumulator in ``phase_trajectory``.
|
|
492
|
+
|
|
493
|
+
3. **Cycle-slip correction** (if ``cpr_cycle_slip_correction=True``) - for
|
|
494
|
+
each symbol of the per-symbol BPS phase tensor ``phi_n`` (shape
|
|
495
|
+
``(C, B)``) the phase is compared to a linear-regression prediction
|
|
496
|
+
built from a circular buffer of ``cpr_cycle_slip_history`` past
|
|
497
|
+
corrected phases (identical algorithm to ``lms`` with
|
|
498
|
+
``cpr_type='bps'``). If ``|phi_n - phi_pred| > cpr_cycle_slip_threshold``
|
|
499
|
+
the nearest ``2*pi/symmetry`` quantum is subtracted and the corrected
|
|
500
|
+
value is stored in the history buffer. On GPU the detector runs as a
|
|
501
|
+
small sequential CUDA kernel on device-resident buffers (no host
|
|
502
|
+
round-trip); when that kernel is unavailable the ``(C, B)`` float64
|
|
503
|
+
block is transferred device->host, corrected by the Numba/Python
|
|
504
|
+
detector, and written back - one D->H + H->D round-trip per block.
|
|
505
|
+
|
|
506
|
+
4. **Error** - training or DD slicer on CPR-corrected output; back-rotated
|
|
507
|
+
to the tap plane using the block-average phase ``phi_b``::
|
|
508
|
+
|
|
509
|
+
e_taps[n] = e_clean[n] * exp(+j*phi_b)
|
|
510
|
+
|
|
511
|
+
5. **Gradient** - scatter ``e_taps`` to sample positions, then::
|
|
512
|
+
|
|
513
|
+
dH_fd[i,j] = conj(E_fd[i]) * X_fd[j]
|
|
514
|
+
h += mu * IFFT(dH_fd)[...:T]
|
|
515
|
+
|
|
516
|
+
``mu`` is applied to the **summed** block gradient (all B per-symbol
|
|
517
|
+
contributions). This sum is exactly what a frozen-weight per-symbol LMS
|
|
518
|
+
would accumulate over the same B symbols, so ``step_size`` is on the
|
|
519
|
+
**same scale as** ``lms``: the same ``mu`` yields the same
|
|
520
|
+
convergence and steady-state MSE (see ``step_size`` below). Only the
|
|
521
|
+
*stability ceiling* is Bx lower - the operating step that matches
|
|
522
|
+
``lms`` is unchanged.
|
|
523
|
+
|
|
524
|
+
Parameters
|
|
525
|
+
----------
|
|
526
|
+
samples : array_like
|
|
527
|
+
Input signal samples. Shape: ``(N_samples,)`` for SISO or
|
|
528
|
+
``(C, N_samples)`` for MIMO butterfly equalization.
|
|
529
|
+
Typically at 2 samples/symbol for fractionally-spaced equalization.
|
|
530
|
+
training_symbols : array_like, optional
|
|
531
|
+
Known transmitted symbols at 1 SPS.
|
|
532
|
+
Shape: ``(N_train,)`` for SISO or ``(C, N_train)`` for MIMO.
|
|
533
|
+
num_taps : int, default 21
|
|
534
|
+
Number of taps per FIR filter (tap count in samples).
|
|
535
|
+
sps : int, default 2
|
|
536
|
+
Samples per symbol. ``sps=2`` (T/2-spaced) is the default.
|
|
537
|
+
step_size : float, default 2e-4
|
|
538
|
+
LMS step size μ, on the **same scale as** ``lms``. Use the same
|
|
539
|
+
value you would use for ``lms``: because the block update is the
|
|
540
|
+
summed gradient over all B symbols - exactly what a frozen-weight
|
|
541
|
+
per-symbol LMS accumulates over those symbols - the same μ produces the
|
|
542
|
+
same convergence speed and steady-state MSE, independent of
|
|
543
|
+
``block_size``. **Do not** divide by ``block_size``; doing so
|
|
544
|
+
under-adapts the filter by that factor.
|
|
545
|
+
|
|
546
|
+
The only ``block_size`` dependence is the *stability ceiling*: because
|
|
547
|
+
the weights are frozen across the block, the maximum stable μ is
|
|
548
|
+
``2/(B·C·T·P_x)`` - roughly ``block_size`` times lower than
|
|
549
|
+
``lms``. Reduce μ below your ``lms`` value **only if** it
|
|
550
|
+
exceeds this ceiling (i.e. the run raises the divergence error); the
|
|
551
|
+
default ``2e-4`` is conservative and safe for ``block_size`` up to a
|
|
552
|
+
few thousand.
|
|
553
|
+
block_size : int, default 256
|
|
554
|
+
Number of output symbols per LMS gradient accumulation block. Larger
|
|
555
|
+
values increase GPU efficiency but reduce adaptation speed. Independent
|
|
556
|
+
of the BPS averaging window (see ``cpr_bps_block_size``).
|
|
557
|
+
modulation : str, optional
|
|
558
|
+
Modulation scheme (e.g., ``'qam'``, ``'psk'``). Required when
|
|
559
|
+
``training_symbols`` is ``None``.
|
|
560
|
+
order : int, optional
|
|
561
|
+
Modulation order (e.g., 16, 64).
|
|
562
|
+
unipolar : bool, default False
|
|
563
|
+
Unipolar PAM flag.
|
|
564
|
+
store_weights : bool, default False
|
|
565
|
+
If ``True``, stores the weight tensor at every block start in
|
|
566
|
+
``EqualizerResult.weights_history``.
|
|
567
|
+
w_init : array_like, optional
|
|
568
|
+
Initial tap weights, shape ``(C, C, T)`` or SISO short-hands.
|
|
569
|
+
pmf : array_like, optional
|
|
570
|
+
Probability mass function for PS-QAM constellation scaling.
|
|
571
|
+
cpr_type : {'bps', None}, default None
|
|
572
|
+
Inline carrier phase recovery. Only ``'bps'`` is supported; PLL is
|
|
573
|
+
not available because its per-symbol PI integration does not fit the
|
|
574
|
+
block gradient model.
|
|
575
|
+
|
|
576
|
+
**BPS dual-path design:** within each equalizer block, the wrapped
|
|
577
|
+
float32 phase estimate rotates the pre-CPR symbol ``y_raw`` to
|
|
578
|
+
produce the weight-update error, while the unwrapped float64
|
|
579
|
+
accumulator is written to ``phase_trajectory``. This separation
|
|
580
|
+
prevents float32 rounding errors from accumulating over long signals.
|
|
581
|
+
|
|
582
|
+
**4-fold causal unwrap:** the BPS ``argmin`` is in ``[0, π/2)``.
|
|
583
|
+
A per-symbol causal tracker adds or subtracts ``π/2`` multiples to
|
|
584
|
+
maintain continuity, then scales to full-range radians.
|
|
585
|
+
cpr_bps_test_phases : int, default 64
|
|
586
|
+
Number of BPS candidate angles in ``[0, π/2)``. 32-64 is
|
|
587
|
+
sufficient for ≤ 16-QAM; use 64-128 for 64-QAM.
|
|
588
|
+
cpr_bps_block_size : int, default 32
|
|
589
|
+
Trailing-window length (symbols) summed before the BPS ``argmin``.
|
|
590
|
+
This is evaluated per symbol (not per equalizer block), so it is
|
|
591
|
+
independent of ``block_size``. Larger values reduce phase-noise
|
|
592
|
+
variance at the cost of increased tracking latency.
|
|
593
|
+
``cpr_bps_block_size=1`` gives single-symbol BPS.
|
|
594
|
+
cpr_joint_channels : bool, default False
|
|
595
|
+
For MIMO inputs (C > 1): if ``True``, the BPS distance metrics are
|
|
596
|
+
summed across all C channels before ``argmin``, producing one shared
|
|
597
|
+
phase estimate broadcast to all channels. Reduces estimation
|
|
598
|
+
variance by ~√C for shared-LO transmitters. When ``False``, each
|
|
599
|
+
channel estimates its phase independently. Ignored for SISO inputs.
|
|
600
|
+
cpr_cycle_slip_correction : bool, default False
|
|
601
|
+
Enable per-symbol cycle-slip detection and correction using the same
|
|
602
|
+
algorithm as ``lms``: after each BPS block every symbol phase is
|
|
603
|
+
compared to a regression prediction, corrected if a slip is detected,
|
|
604
|
+
and added to the circular history buffer. On GPU the detector runs
|
|
605
|
+
on-device (custom CUDA kernel); without it each block costs one
|
|
606
|
+
``(C, B)`` float64 D->H + H->D round-trip through the CPU detector.
|
|
607
|
+
cpr_cycle_slip_history : int, default 100
|
|
608
|
+
Length of the per-symbol phase history buffer used for the linear
|
|
609
|
+
regression predictor. Same semantics as in ``lms``: one entry
|
|
610
|
+
per symbol, so ``100`` means 100 past corrected symbol phases.
|
|
611
|
+
Ignored when ``cpr_cycle_slip_correction=False``.
|
|
612
|
+
cpr_cycle_slip_threshold : float, default π/4
|
|
613
|
+
Maximum phase step (radians) between adjacent symbols before a
|
|
614
|
+
cycle slip is declared. Set to half the constellation's angular
|
|
615
|
+
symmetry quantum (``π/4`` for QPSK/QAM). Ignored when
|
|
616
|
+
``cpr_cycle_slip_correction=False``.
|
|
617
|
+
debug_plot : bool, default False
|
|
618
|
+
Show a convergence + phase diagnostic plot on exit.
|
|
619
|
+
plot_smoothing : int, default 50
|
|
620
|
+
Moving-average window for the MSE curve in the debug plot.
|
|
621
|
+
cpr_state : CPRState, optional
|
|
622
|
+
Warm-start BPS CPR state from a previous ``block_lms()`` call.
|
|
623
|
+
When provided, the BPS 4-fold unwrap accumulators (``bps_prev4``,
|
|
624
|
+
``bps_offset4``) and the block-distance history matrix
|
|
625
|
+
(``bps_d2_hist``, shape ``(B, C, K-1)``) are restored from the
|
|
626
|
+
previous block boundary. This prevents the BPS from re-converging
|
|
627
|
+
its phase estimate at each block boundary, which otherwise causes
|
|
628
|
+
a ~``cpr_bps_block_size``-symbol transient of increased phase error.
|
|
629
|
+
Pass ``None`` (default) to cold-start. Only BPS state is used;
|
|
630
|
+
PLL/cycle-slip fields are ignored.
|
|
631
|
+
input_norm_factor : float or ndarray, optional
|
|
632
|
+
Pre-computed RMS normalization factor. See ``lms()`` for the full
|
|
633
|
+
description; behaviour is identical.
|
|
634
|
+
samples_prefix : array_like, optional
|
|
635
|
+
Signal history from the end of the previous block. See ``lms()``
|
|
636
|
+
for the full description; behaviour is identical.
|
|
637
|
+
pad_mode : {'zeros', 'edge'}, default 'zeros'
|
|
638
|
+
Padding strategy when ``samples_prefix`` is ``None``. See
|
|
639
|
+
``lms()`` for the full description; behaviour is identical.
|
|
640
|
+
cuda_graph : bool, default True
|
|
641
|
+
On the GPU (CuPy) backend, capture the per-block compute into a CUDA
|
|
642
|
+
graph and replay it once per block, collapsing the ~30-50 per-block
|
|
643
|
+
kernel launches into a single launch. This removes the launch-overhead
|
|
644
|
+
floor that dominates small/medium ``block_size`` runs. Only full blocks
|
|
645
|
+
in the decision-directed region are captured; the training and final
|
|
646
|
+
partial blocks always run eagerly. Has no effect on CPU, when
|
|
647
|
+
``store_weights=True``, or when cycle-slip correction is enabled without
|
|
648
|
+
the ``cs_block`` CUDA kernel; capture failures fall back to the eager
|
|
649
|
+
loop with a warning, so output is unaffected either way. Set ``False``
|
|
650
|
+
to force the eager loop (e.g. for debugging or profiling).
|
|
651
|
+
|
|
652
|
+
Returns
|
|
653
|
+
-------
|
|
654
|
+
EqualizerResult
|
|
655
|
+
Same fields as ``lms``, plus:
|
|
656
|
+
|
|
657
|
+
* ``input_norm_factor`` - RMS factor used to normalize inputs.
|
|
658
|
+
* ``cpr_state`` - ``CPRState`` with BPS accumulators after the last
|
|
659
|
+
block. ``None`` when ``cpr_type=None``.
|
|
660
|
+
|
|
661
|
+
``phase_trajectory`` is populated when ``cpr_type='bps'``; shape
|
|
662
|
+
``(N_sym,)`` SISO or ``(C, N_sym)`` MIMO, one estimate per symbol.
|
|
663
|
+
|
|
664
|
+
Warnings
|
|
665
|
+
--------
|
|
666
|
+
**GPU throughput - use large block_size:** On GPU (CuPy) each Python
|
|
667
|
+
loop iteration launches ~10-20 CUDA kernels (FFT, einsum, IFFT, BPS
|
|
668
|
+
rotations, ...). At ``block_size=64`` and 100k symbols that is ~1 500
|
|
669
|
+
blocks x kernel-launch overhead; at ``block_size=2048`` it drops to
|
|
670
|
+
~49 blocks. Throughput improves markedly once the cuFFT/cuBLAS work
|
|
671
|
+
per block dominates the Python overhead. On GPU prefer
|
|
672
|
+
``block_size`` ≥ 512, ideally 1024-4096.
|
|
673
|
+
|
|
674
|
+
**BPS cycle-slip correction (``cpr_cycle_slip_correction=True``):**
|
|
675
|
+
On GPU the detector runs as a sequential CUDA kernel on device-resident
|
|
676
|
+
history buffers, so enabling it adds one extra kernel launch per block
|
|
677
|
+
and no host synchronization. Only when that kernel is unavailable
|
|
678
|
+
(no custom-kernel support) does each block fall back to a synchronous
|
|
679
|
+
``(C, block_size)`` float64 device->host round-trip through the CPU
|
|
680
|
+
detector, which serialises the GPU pipeline.
|
|
681
|
+
|
|
682
|
+
**CPU (NumPy) backend:** even slower than GPU because the Python loop
|
|
683
|
+
dominates at any practical block size. Use ``lms(backend='numba')``
|
|
684
|
+
or ``lms(backend='jax')`` for CPU workloads instead.
|
|
685
|
+
|
|
686
|
+
**Stability / overflow:** ``step_size`` is applied to the **summed**
|
|
687
|
+
gradient over all ``block_size`` symbols (not averaged). This keeps μ on
|
|
688
|
+
the same scale as ``lms`` (same μ -> same convergence and steady-state
|
|
689
|
+
MSE), but it also means the *stability ceiling* -
|
|
690
|
+
``0 < μ < 2/(block_size·C·T·P_x)`` - is roughly ``block_size`` times lower
|
|
691
|
+
than per-symbol LMS, because the weights are frozen across the block. So
|
|
692
|
+
start from the **same** ``step_size`` you use for ``lms``; if a large
|
|
693
|
+
``block_size`` pushes that value above the ceiling the run diverges (NaN
|
|
694
|
+
weights, detected at end of run), in which case reduce μ until stable -
|
|
695
|
+
do **not** routinely divide by ``block_size`` (that under-adapts the
|
|
696
|
+
filter by the same factor).
|
|
697
|
+
"""
|
|
698
|
+
if cpr_type is not None and cpr_type != "bps":
|
|
699
|
+
raise ValueError(
|
|
700
|
+
f"block_lms only supports cpr_type='bps' or None. Got {cpr_type!r}. "
|
|
701
|
+
"PLL is not available for block processing."
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
num_taps = int(num_taps)
|
|
705
|
+
sps = int(sps)
|
|
706
|
+
block_size = int(block_size)
|
|
707
|
+
|
|
708
|
+
_validate_sps(sps, num_taps)
|
|
709
|
+
|
|
710
|
+
samples, xp, _ = dispatch(samples)
|
|
711
|
+
if xp is np:
|
|
712
|
+
logger.warning(
|
|
713
|
+
"block_lms is running on CPU (NumPy). "
|
|
714
|
+
"For CPU workloads lms(..., backend='numba') is typically 2-10x faster. "
|
|
715
|
+
"Move samples to GPU (CuPy) to benefit from block-FFT acceleration."
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
was_1d = samples.ndim == 1
|
|
719
|
+
if was_1d:
|
|
720
|
+
samples = samples[np.newaxis, :]
|
|
721
|
+
|
|
722
|
+
C = samples.shape[0]
|
|
723
|
+
N = samples.shape[1]
|
|
724
|
+
n_sym = N // sps
|
|
725
|
+
|
|
726
|
+
if training_symbols is not None:
|
|
727
|
+
training_symbols, _, _ = dispatch(training_symbols)
|
|
728
|
+
if training_symbols.ndim == 1:
|
|
729
|
+
training_symbols = training_symbols[np.newaxis, :]
|
|
730
|
+
|
|
731
|
+
samples, training_symbols, eq_norm = _normalize_inputs(
|
|
732
|
+
samples, training_symbols, sps, input_norm_factor=input_norm_factor
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
# -- Constellation ---------------------------------------------------------
|
|
736
|
+
if modulation is not None and order is not None:
|
|
737
|
+
from ..mapping import gray_constellation
|
|
738
|
+
|
|
739
|
+
reference_constellation = gray_constellation(
|
|
740
|
+
modulation, order, unipolar=unipolar
|
|
741
|
+
)
|
|
742
|
+
constellation_np = (
|
|
743
|
+
to_device(reference_constellation, "cpu").flatten().astype(np.complex64)
|
|
744
|
+
)
|
|
745
|
+
elif training_symbols is not None:
|
|
746
|
+
train_flat = to_device(training_symbols, "cpu").reshape(-1)
|
|
747
|
+
constellation_np = np.unique(np.round(train_flat, decimals=8)).astype(
|
|
748
|
+
np.complex64
|
|
749
|
+
)
|
|
750
|
+
else:
|
|
751
|
+
raise ValueError("Provide modulation+order or training_symbols for DD slicer.")
|
|
752
|
+
|
|
753
|
+
if pmf is not None and modulation is not None and order is not None:
|
|
754
|
+
_pmf_arr = np.asarray(pmf, dtype=np.float64)
|
|
755
|
+
_e_ps = float(
|
|
756
|
+
np.dot(_pmf_arr, np.abs(constellation_np).astype(np.float64) ** 2)
|
|
757
|
+
)
|
|
758
|
+
if _e_ps < 1.0 - 1e-6:
|
|
759
|
+
constellation_np = (constellation_np / np.sqrt(_e_ps)).astype(np.complex64)
|
|
760
|
+
|
|
761
|
+
constellation = xp.asarray(constellation_np) # (M,) on device
|
|
762
|
+
_sq_side, _sq_lev_min_f, _sq_d_grid_f = _sq_qam_slicer_params(constellation_np)
|
|
763
|
+
_sq_lev_min = float(_sq_lev_min_f)
|
|
764
|
+
_sq_d_grid = float(_sq_d_grid_f)
|
|
765
|
+
_sq_m1 = _sq_side - 1 # clip upper bound (0 when sq_side==0 - never used)
|
|
766
|
+
|
|
767
|
+
# -- Training alignment ----------------------------------------------------
|
|
768
|
+
if training_symbols is not None:
|
|
769
|
+
n_train_aligned = min(int(training_symbols.shape[-1]), n_sym)
|
|
770
|
+
else:
|
|
771
|
+
n_train_aligned = 0
|
|
772
|
+
|
|
773
|
+
# -- Weight initialisation -------------------------------------------------
|
|
774
|
+
if w_init is not None:
|
|
775
|
+
w_arr = np.ascontiguousarray(to_device(w_init, "cpu"), dtype=np.complex64)
|
|
776
|
+
w_arr = _validate_w_init(w_arr, C, num_taps)
|
|
777
|
+
h = xp.asarray(w_arr.copy())
|
|
778
|
+
else:
|
|
779
|
+
h = xp.asarray(_init_butterfly_weights_numpy(C, num_taps)) # (C, C, T)
|
|
780
|
+
|
|
781
|
+
# -- BPS setup -------------------------------------------------------------
|
|
782
|
+
if cpr_type == "bps":
|
|
783
|
+
symmetry = _cpr_symmetry(modulation, order)
|
|
784
|
+
P = int(cpr_bps_test_phases)
|
|
785
|
+
bps_angles_np = np.linspace(
|
|
786
|
+
0.0, np.pi / 2.0, P, endpoint=False, dtype=np.float32
|
|
787
|
+
)
|
|
788
|
+
bps_phases_neg = xp.asarray(
|
|
789
|
+
np.exp(-1j * bps_angles_np).astype(np.complex64)
|
|
790
|
+
) # (P,)
|
|
791
|
+
bps_angles = xp.asarray(bps_angles_np) # (P,)
|
|
792
|
+
quantum = np.float64(2.0 * np.pi / symmetry)
|
|
793
|
+
_two_pi = xp.float64(2.0 * np.pi)
|
|
794
|
+
# Cross-block 4-fold unwrap state (one float64 per channel)
|
|
795
|
+
_cs_H = min(int(cpr_cycle_slip_history), n_sym)
|
|
796
|
+
_bps_K = int(cpr_bps_block_size)
|
|
797
|
+
_bps_hist_len = max(0, _bps_K - 1)
|
|
798
|
+
_st = cpr_state
|
|
799
|
+
_st_ok = (
|
|
800
|
+
_st is not None
|
|
801
|
+
and _st.cpr_type == cpr_type
|
|
802
|
+
and _st.num_ch == C
|
|
803
|
+
and _st.cs_H == _cs_H
|
|
804
|
+
and _st.bps_P == P
|
|
805
|
+
and _st.bps_K == _bps_K
|
|
806
|
+
and _st.bps_prev4 is not None
|
|
807
|
+
)
|
|
808
|
+
if _st_ok:
|
|
809
|
+
assert _st is not None
|
|
810
|
+
assert _st.bps_prev4 is not None
|
|
811
|
+
assert _st.bps_offset4 is not None
|
|
812
|
+
assert _st.cs_buf_x is not None
|
|
813
|
+
assert _st.cs_buf_y is not None
|
|
814
|
+
assert _st.cs_buf_ptr is not None
|
|
815
|
+
assert _st.cs_buf_n is not None
|
|
816
|
+
assert _st.cs_stats is not None
|
|
817
|
+
bps_prev4 = _st.bps_prev4.copy()
|
|
818
|
+
bps_offset4 = _st.bps_offset4.copy()
|
|
819
|
+
cs_buf_x = _st.cs_buf_x.copy()
|
|
820
|
+
cs_buf_y = _st.cs_buf_y.copy()
|
|
821
|
+
cs_buf_ptr = _st.cs_buf_ptr.copy()
|
|
822
|
+
cs_buf_n = _st.cs_buf_n.copy()
|
|
823
|
+
cs_stats = _st.cs_stats.copy()
|
|
824
|
+
# xp.array (not asarray): the history buffer is updated in place
|
|
825
|
+
# inside the block loop, so it must never alias the caller's state.
|
|
826
|
+
bps_d2_hist = (
|
|
827
|
+
xp.array(_st.bps_d2_hist, dtype=xp.float32)
|
|
828
|
+
if _st.bps_d2_hist is not None
|
|
829
|
+
else xp.zeros((P, C, _bps_hist_len), dtype=xp.float32)
|
|
830
|
+
)
|
|
831
|
+
else:
|
|
832
|
+
bps_prev4 = np.zeros(C, dtype=np.float64)
|
|
833
|
+
bps_offset4 = np.zeros(C, dtype=np.float64)
|
|
834
|
+
cs_buf_x = np.zeros((C, _cs_H), dtype=np.float64)
|
|
835
|
+
cs_buf_y = np.zeros((C, _cs_H), dtype=np.float64)
|
|
836
|
+
cs_buf_ptr = np.zeros(C, dtype=np.int64)
|
|
837
|
+
cs_buf_n = np.zeros(C, dtype=np.int64)
|
|
838
|
+
cs_stats = np.zeros((C, 4), dtype=np.float64)
|
|
839
|
+
bps_d2_hist = xp.zeros((P, C, _bps_hist_len), dtype=xp.float32)
|
|
840
|
+
# Promote the unwrap carries to the active device once - every
|
|
841
|
+
# per-block update then runs on-device, with no per-block H2D/D2H
|
|
842
|
+
# sync. The CPRState CPU-NumPy contract is honoured at the API
|
|
843
|
+
# boundary only: ingested above, exported back via to_device() at
|
|
844
|
+
# state export. Cycle-slip buffers stay CPU-resident because the
|
|
845
|
+
# slip kernel itself still runs on the host.
|
|
846
|
+
bps_prev4 = xp.asarray(bps_prev4)
|
|
847
|
+
bps_offset4 = xp.asarray(bps_offset4)
|
|
848
|
+
|
|
849
|
+
# -- Fused CUDA kernels (CuPy only; None => xp fallback) -------------------
|
|
850
|
+
# _k_bps: per-block inline-BPS min-distance metric (GRID for square QAM,
|
|
851
|
+
# TABLE otherwise). _k_dd: nearest-point search for the non-square DD
|
|
852
|
+
# slicer (TABLE + argmin, called with a single unit phasor). _k_cs: the
|
|
853
|
+
# sequential cycle-slip detector (one thread per channel) operating on
|
|
854
|
+
# device-resident history buffers.
|
|
855
|
+
_k_bps = None
|
|
856
|
+
_k_dd = None
|
|
857
|
+
_k_cs = None
|
|
858
|
+
_dd_phasor = None
|
|
859
|
+
if xp is not np:
|
|
860
|
+
from .. import _cuda
|
|
861
|
+
|
|
862
|
+
_M_const = int(constellation_np.size)
|
|
863
|
+
if cpr_type == "bps" and P <= 128:
|
|
864
|
+
if _sq_side > 0:
|
|
865
|
+
_k_bps = _cuda.get_kernel("bps_min_d2", mode="grid")
|
|
866
|
+
elif _M_const <= 1024:
|
|
867
|
+
_k_bps = _cuda.get_kernel("bps_min_d2", mode="table")
|
|
868
|
+
if n_train_aligned < n_sym and _sq_side == 0 and _M_const <= 1024:
|
|
869
|
+
_k_dd = _cuda.get_kernel("bps_min_d2", mode="table", return_argmin=True)
|
|
870
|
+
if _k_dd is not None:
|
|
871
|
+
_dd_phasor = xp.ones(1, dtype=xp.complex64)
|
|
872
|
+
if cpr_type == "bps" and cpr_cycle_slip_correction and C <= 1024:
|
|
873
|
+
_k_cs = _cuda.get_kernel("cs_block")
|
|
874
|
+
if _k_cs is not None:
|
|
875
|
+
# Cycle-slip state lives in device memory for the whole block loop;
|
|
876
|
+
# the kernel mutates it in place. Exported back to CPU NumPy at
|
|
877
|
+
# state export, preserving the CPRState contract. cs_buf_x is
|
|
878
|
+
# unused by the detector and stays on CPU.
|
|
879
|
+
cs_buf_y = xp.asarray(cs_buf_y)
|
|
880
|
+
cs_buf_ptr = xp.asarray(cs_buf_ptr)
|
|
881
|
+
cs_buf_n = xp.asarray(cs_buf_n)
|
|
882
|
+
cs_stats = xp.asarray(cs_stats)
|
|
883
|
+
|
|
884
|
+
# -- OLS block size --------------------------------------------------------
|
|
885
|
+
# fftsize must be >= block_size * sps + num_taps - 1 (linear OLS condition)
|
|
886
|
+
_ols_min = block_size * sps + num_taps - 1
|
|
887
|
+
fftsize = 1 << (_ols_min - 1).bit_length() # next power of 2
|
|
888
|
+
|
|
889
|
+
_cpr_info = ""
|
|
890
|
+
if cpr_type == "bps":
|
|
891
|
+
_cs = (
|
|
892
|
+
f", cs_corr=True(thr={cpr_cycle_slip_threshold:.3f})"
|
|
893
|
+
if cpr_cycle_slip_correction
|
|
894
|
+
else ", cs_corr=False"
|
|
895
|
+
)
|
|
896
|
+
_joint = ", joint" if cpr_joint_channels and C > 1 else ""
|
|
897
|
+
_cpr_info = (
|
|
898
|
+
f", cpr=bps(P={cpr_bps_test_phases}, K={cpr_bps_block_size}{_joint}{_cs})"
|
|
899
|
+
)
|
|
900
|
+
logger.info(
|
|
901
|
+
"Block-LMS: C=%s, num_taps=%s, sps=%s, block_size=%s, fftsize=%s, mu=%s, n_sym=%s%s",
|
|
902
|
+
C,
|
|
903
|
+
num_taps,
|
|
904
|
+
sps,
|
|
905
|
+
block_size,
|
|
906
|
+
fftsize,
|
|
907
|
+
step_size,
|
|
908
|
+
n_sym,
|
|
909
|
+
_cpr_info,
|
|
910
|
+
)
|
|
911
|
+
|
|
912
|
+
# -- Padding - matches lms() convention -----------------------------------
|
|
913
|
+
c_tap = num_taps // 2
|
|
914
|
+
pad_total = max(0, n_sym * sps - N + num_taps - 1)
|
|
915
|
+
pad_left = min(c_tap, pad_total)
|
|
916
|
+
pad_right = pad_total - pad_left
|
|
917
|
+
if samples_prefix is not None or xp is np or pad_mode != "zeros":
|
|
918
|
+
_samp_cpu_blms = to_device(samples, "cpu").astype(np.complex64)
|
|
919
|
+
x_padded = xp.asarray(
|
|
920
|
+
_build_padded_samples(
|
|
921
|
+
_samp_cpu_blms,
|
|
922
|
+
pad_left,
|
|
923
|
+
pad_right,
|
|
924
|
+
samples_prefix,
|
|
925
|
+
pad_mode,
|
|
926
|
+
eq_norm,
|
|
927
|
+
sps,
|
|
928
|
+
)
|
|
929
|
+
) # (C, N_pad)
|
|
930
|
+
else:
|
|
931
|
+
# Fast on-device path: avoid D->H->D round-trip when there is no prefix.
|
|
932
|
+
# _normalize_inputs already normalized samples; eq_norm is only needed
|
|
933
|
+
# by _build_padded_samples to normalize the samples_prefix, which is
|
|
934
|
+
# handled by the branch above.
|
|
935
|
+
_samp_f32 = (
|
|
936
|
+
samples if samples.dtype == xp.complex64 else samples.astype(xp.complex64)
|
|
937
|
+
)
|
|
938
|
+
_left = xp.zeros((C, pad_left), dtype=xp.complex64)
|
|
939
|
+
_right = (
|
|
940
|
+
xp.zeros((C, pad_right), dtype=xp.complex64)
|
|
941
|
+
if pad_right > 0
|
|
942
|
+
else xp.empty((C, 0), dtype=xp.complex64)
|
|
943
|
+
)
|
|
944
|
+
x_padded = xp.concatenate([_left, _samp_f32, _right], axis=1) # (C, N_pad)
|
|
945
|
+
N_padded = x_padded.shape[1]
|
|
946
|
+
|
|
947
|
+
# -- Output buffers --------------------------------------------------------
|
|
948
|
+
y_all = xp.empty((C, n_sym), dtype=xp.complex64)
|
|
949
|
+
e_all = xp.empty((C, n_sym), dtype=xp.complex64)
|
|
950
|
+
w_hist = (
|
|
951
|
+
xp.empty((n_sym, C, C, num_taps), dtype=xp.complex64) if store_weights else None
|
|
952
|
+
)
|
|
953
|
+
phi_all = xp.zeros((C, n_sym), dtype=xp.float32) if cpr_type == "bps" else None
|
|
954
|
+
|
|
955
|
+
# Pre-allocate scratch buffers - reused every block to avoid per-block heap
|
|
956
|
+
# pressure, and (on GPU) to give the CUDA-graph capture stable pointers.
|
|
957
|
+
x_win = xp.zeros((C, fftsize), dtype=xp.complex64)
|
|
958
|
+
e_scatter = xp.zeros((C, fftsize), dtype=xp.complex64)
|
|
959
|
+
# Fixed-width output workspaces (block_size columns). The block body always
|
|
960
|
+
# writes its per-symbol outputs here; the driver copies the valid [:, :B]
|
|
961
|
+
# slice into the full-length result arrays at the right offset. Routing
|
|
962
|
+
# through fixed buffers (rather than writing y_all[:, b_start:b_end]
|
|
963
|
+
# directly) is what lets the body be captured once and replayed: the only
|
|
964
|
+
# thing that varies per block is the eager input fill and output copy.
|
|
965
|
+
y_rot_ws = xp.empty((C, block_size), dtype=xp.complex64)
|
|
966
|
+
e_clean_ws = xp.empty((C, block_size), dtype=xp.complex64)
|
|
967
|
+
phi_ws = xp.empty((C, block_size), dtype=xp.float32) if cpr_type == "bps" else None
|
|
968
|
+
|
|
969
|
+
n_blocks = (n_sym + block_size - 1) // block_size
|
|
970
|
+
# On-device divergence flag - accumulates with |= inside the loop, zero D->H
|
|
971
|
+
# syncs during iteration. Checked once with bool() after the loop exits.
|
|
972
|
+
_div_flag = xp.zeros(1, dtype=xp.bool_)
|
|
973
|
+
|
|
974
|
+
def _run_block(B, b_start, n_train_blk):
|
|
975
|
+
"""Compute one block: forward filter, CPR, error, gradient, weight update.
|
|
976
|
+
|
|
977
|
+
Reads the input window from ``x_win`` (filled by the caller), reads and
|
|
978
|
+
updates the persistent filter/CPR state in place, and writes the
|
|
979
|
+
per-symbol outputs into the fixed workspaces ``y_rot_ws``/``e_clean_ws``/
|
|
980
|
+
``phi_ws`` (columns ``[:, :B]``). Issues no host synchronization, so on
|
|
981
|
+
GPU it is safe to capture into a CUDA graph and replay.
|
|
982
|
+
"""
|
|
983
|
+
nonlocal h, bps_prev4, bps_offset4, _div_flag
|
|
984
|
+
|
|
985
|
+
# -- Forward pass (frequency-domain butterfly) ---------------------
|
|
986
|
+
X_fd = xp.fft.fft(x_win, axis=-1) # (C, F)
|
|
987
|
+
H_fd = xp.fft.fft(h, n=fftsize, axis=-1) # (C, C, F)
|
|
988
|
+
# Butterfly contraction Y[i,k] = Σ_j conj(H[i,j,k]) X[j,k]. Written as a
|
|
989
|
+
# broadcast-multiply + reduction (not einsum) for two reasons: einsum
|
|
990
|
+
# dispatches to cuBLAS, which cannot be called inside a CUDA-graph stream
|
|
991
|
+
# capture; and the reduction is accumulated in complex128 before the
|
|
992
|
+
# complex64 downcast, per the CLAUDE.md filter-dot-product precision rule.
|
|
993
|
+
Y_fd = (
|
|
994
|
+
(xp.conj(H_fd).astype(xp.complex128) * X_fd.astype(xp.complex128)[None])
|
|
995
|
+
.sum(axis=1)
|
|
996
|
+
.astype(xp.complex64)
|
|
997
|
+
) # (C, F)
|
|
998
|
+
y_time = xp.fft.ifft(Y_fd, axis=-1) # (C, F)
|
|
999
|
+
y_block = y_time[:, : B * sps : sps].astype(xp.complex64) # (C, B)
|
|
1000
|
+
|
|
1001
|
+
# -- BPS phase recovery --------------------------------------------
|
|
1002
|
+
if cpr_type == "bps":
|
|
1003
|
+
# min_d2: (P, C, B) - min squared distance to constellation over
|
|
1004
|
+
# all candidate rotations of all block symbols.
|
|
1005
|
+
if _k_bps is not None:
|
|
1006
|
+
# Fused kernel: single pass over y_block, no (P, C, B[, M])
|
|
1007
|
+
# rotated/distance intermediates.
|
|
1008
|
+
if _sq_side > 0:
|
|
1009
|
+
min_d2 = _k_bps(
|
|
1010
|
+
y_block,
|
|
1011
|
+
bps_phases_neg,
|
|
1012
|
+
lev_min=_sq_lev_min,
|
|
1013
|
+
d_grid=_sq_d_grid,
|
|
1014
|
+
side=_sq_side,
|
|
1015
|
+
)
|
|
1016
|
+
else:
|
|
1017
|
+
min_d2 = _k_bps(
|
|
1018
|
+
y_block, bps_phases_neg, constellation=constellation
|
|
1019
|
+
)
|
|
1020
|
+
else:
|
|
1021
|
+
# rotated: (P, C, B) - all candidate rotations for all block symbols
|
|
1022
|
+
rotated = bps_phases_neg[:, None, None] * y_block[None, :, :]
|
|
1023
|
+
# O(1) square-QAM: snap I/Q independently to nearest level grid point.
|
|
1024
|
+
if _sq_side > 0:
|
|
1025
|
+
_nr = (
|
|
1026
|
+
_sq_lev_min
|
|
1027
|
+
+ xp.clip(
|
|
1028
|
+
xp.round((rotated.real - _sq_lev_min) / _sq_d_grid),
|
|
1029
|
+
0,
|
|
1030
|
+
_sq_m1,
|
|
1031
|
+
)
|
|
1032
|
+
* _sq_d_grid
|
|
1033
|
+
)
|
|
1034
|
+
_ni = (
|
|
1035
|
+
_sq_lev_min
|
|
1036
|
+
+ xp.clip(
|
|
1037
|
+
xp.round((rotated.imag - _sq_lev_min) / _sq_d_grid),
|
|
1038
|
+
0,
|
|
1039
|
+
_sq_m1,
|
|
1040
|
+
)
|
|
1041
|
+
* _sq_d_grid
|
|
1042
|
+
)
|
|
1043
|
+
min_d2 = (
|
|
1044
|
+
(rotated.real - _nr) ** 2 + (rotated.imag - _ni) ** 2
|
|
1045
|
+
).astype(xp.float32)
|
|
1046
|
+
else:
|
|
1047
|
+
d2_all = (
|
|
1048
|
+
xp.abs(rotated[..., None] - constellation[None, None, None, :])
|
|
1049
|
+
** 2
|
|
1050
|
+
).real
|
|
1051
|
+
min_d2 = xp.min(d2_all, axis=-1).astype(xp.float32)
|
|
1052
|
+
|
|
1053
|
+
# Causal sliding-window average of width K along the B (symbol) axis.
|
|
1054
|
+
# win_sum[:,:,n] = sum of min_d2[:,:, n-K+1..n] (with K-1 samples from
|
|
1055
|
+
# previous block as prefix so the window is full from symbol 0).
|
|
1056
|
+
K = min(_bps_K, B)
|
|
1057
|
+
hist_prefix = (
|
|
1058
|
+
bps_d2_hist[:, :, -(K - 1) :]
|
|
1059
|
+
if K > 1
|
|
1060
|
+
else xp.empty((P, C, 0), dtype=xp.float32)
|
|
1061
|
+
)
|
|
1062
|
+
cat_d2 = xp.concatenate([hist_prefix, min_d2], axis=2) # (P, C, K-1+B)
|
|
1063
|
+
cs_d2 = xp.concatenate(
|
|
1064
|
+
[xp.zeros((P, C, 1), dtype=xp.float32), cat_d2.cumsum(axis=2)], axis=2
|
|
1065
|
+
) # (P, C, K+B)
|
|
1066
|
+
win_sum = cs_d2[:, :, K:] - cs_d2[:, :, :-K] # (P, C, B)
|
|
1067
|
+
metric = win_sum / xp.float32(K) # (P, C, B) - always full K-sample window
|
|
1068
|
+
|
|
1069
|
+
# Per-symbol argmin over P phases -> raw (C, B) in [0, π/2)
|
|
1070
|
+
if cpr_joint_channels and C > 1:
|
|
1071
|
+
best_k = xp.argmin(metric.sum(axis=1), axis=0) # (B,)
|
|
1072
|
+
phi_raw = xp.broadcast_to(
|
|
1073
|
+
bps_angles[best_k][None, :], (C, B)
|
|
1074
|
+
).copy() # (C, B)
|
|
1075
|
+
else:
|
|
1076
|
+
best_k = xp.argmin(metric, axis=0) # (C, B)
|
|
1077
|
+
phi_raw = bps_angles[best_k] # (C, B)
|
|
1078
|
+
|
|
1079
|
+
# Slide BPS distance history across block boundary (for next block's
|
|
1080
|
+
# prefix). In-place write into the persistent buffer (not a rebind)
|
|
1081
|
+
# so the captured graph reads/writes the same address every replay.
|
|
1082
|
+
if _bps_hist_len > 0:
|
|
1083
|
+
combined_hist = xp.concatenate([bps_d2_hist, min_d2], axis=2)
|
|
1084
|
+
bps_d2_hist[...] = combined_hist[:, :, -_bps_hist_len:]
|
|
1085
|
+
|
|
1086
|
+
# 4-fold causal unwrap: equivalent to np.unwrap(phi*4)/4 via
|
|
1087
|
+
# diff->wrap[-π,π]->cumsum. CPU: np.unwrap directly (no transfer cost).
|
|
1088
|
+
# GPU: fully on-device - the carries are device arrays, so the
|
|
1089
|
+
# block loop issues no host sync for the unwrap state.
|
|
1090
|
+
if xp is np:
|
|
1091
|
+
raw4 = phi_raw.astype(np.float64) * 4.0 # (C, B)
|
|
1092
|
+
extended = np.concatenate(
|
|
1093
|
+
[bps_prev4[:, np.newaxis], raw4], axis=1
|
|
1094
|
+
) # (C, B+1)
|
|
1095
|
+
unwrapped_ext = np.unwrap(extended, axis=1) # (C, B+1)
|
|
1096
|
+
_cumul_cpu = unwrapped_ext[:, 1:] - unwrapped_ext[:, 0:1] # (C, B)
|
|
1097
|
+
_phi_f64 = (bps_offset4[:, np.newaxis] + _cumul_cpu) / 4.0 # (C, B)
|
|
1098
|
+
bps_prev4[:] = unwrapped_ext[:, -1]
|
|
1099
|
+
bps_offset4 += _cumul_cpu[:, -1]
|
|
1100
|
+
else:
|
|
1101
|
+
raw4_dev = phi_raw.astype(xp.float64) * xp.float64(4.0) # (C, B)
|
|
1102
|
+
ext_dev = xp.concatenate(
|
|
1103
|
+
[bps_prev4[:, None], raw4_dev], axis=1
|
|
1104
|
+
) # (C, B+1)
|
|
1105
|
+
_two_pi_d = xp.float64(2.0 * np.pi)
|
|
1106
|
+
d4 = ext_dev[:, 1:] - ext_dev[:, :-1] # (C, B)
|
|
1107
|
+
d4 -= xp.round(d4 / _two_pi_d) * _two_pi_d # wrap to [-π, π]
|
|
1108
|
+
_cumul_dev = xp.cumsum(d4, axis=1) # (C, B)
|
|
1109
|
+
_phi_f64 = (bps_offset4[:, None] + _cumul_dev) / xp.float64(4.0)
|
|
1110
|
+
bps_prev4 += _cumul_dev[:, -1]
|
|
1111
|
+
bps_offset4 += _cumul_dev[:, -1]
|
|
1112
|
+
# -- Per-symbol cycle-slip correction --------------------------
|
|
1113
|
+
# Identical algorithm to per-symbol lms: for each symbol in the
|
|
1114
|
+
# block, compare its BPS phase to the regression prediction, snap
|
|
1115
|
+
# to the nearest quantum if |diff| > threshold, then add the
|
|
1116
|
+
# corrected (x, y) pair to the circular history buffer.
|
|
1117
|
+
# GPU with the cs_block CUDA kernel: one launch per block on the
|
|
1118
|
+
# device-resident history buffers - zero host syncs. Fallback
|
|
1119
|
+
# (CPU, or kernel unavailable): D->H transfer of the full (C, B)
|
|
1120
|
+
# float64 phase block, CPU detector (Numba or Python), H->D
|
|
1121
|
+
# write-back; cost is O(C·B) per block.
|
|
1122
|
+
if cpr_cycle_slip_correction:
|
|
1123
|
+
if _k_cs is not None:
|
|
1124
|
+
_phi_corr_dev = xp.empty_like(_phi_f64)
|
|
1125
|
+
_k_cs(
|
|
1126
|
+
_phi_f64,
|
|
1127
|
+
_phi_corr_dev,
|
|
1128
|
+
cs_buf_y,
|
|
1129
|
+
cs_buf_ptr,
|
|
1130
|
+
cs_buf_n,
|
|
1131
|
+
cs_stats,
|
|
1132
|
+
float(quantum),
|
|
1133
|
+
float(cpr_cycle_slip_threshold),
|
|
1134
|
+
_cs_H,
|
|
1135
|
+
)
|
|
1136
|
+
else:
|
|
1137
|
+
phi_blk_np = to_device(_phi_f64, "cpu").astype(np.float64) # (C, B)
|
|
1138
|
+
phi_corr_np = phi_blk_np.copy()
|
|
1139
|
+
|
|
1140
|
+
_cs_kernel = _get_numba_cs_block()
|
|
1141
|
+
if _cs_kernel is not None:
|
|
1142
|
+
_cs_kernel(
|
|
1143
|
+
phi_blk_np,
|
|
1144
|
+
phi_corr_np,
|
|
1145
|
+
cs_buf_x,
|
|
1146
|
+
cs_buf_y,
|
|
1147
|
+
cs_buf_ptr,
|
|
1148
|
+
cs_buf_n,
|
|
1149
|
+
cs_stats,
|
|
1150
|
+
b_start,
|
|
1151
|
+
float(quantum),
|
|
1152
|
+
float(cpr_cycle_slip_threshold),
|
|
1153
|
+
_cs_H,
|
|
1154
|
+
)
|
|
1155
|
+
else:
|
|
1156
|
+
_H_f = float(_cs_H)
|
|
1157
|
+
for ci in range(C):
|
|
1158
|
+
for i in range(B):
|
|
1159
|
+
y_b = phi_blk_np[ci, i]
|
|
1160
|
+
n_b = int(cs_buf_n[ci])
|
|
1161
|
+
ptr = int(cs_buf_ptr[ci])
|
|
1162
|
+
|
|
1163
|
+
if n_b == 0:
|
|
1164
|
+
phi_expected = y_b
|
|
1165
|
+
elif n_b < 10:
|
|
1166
|
+
last_pos = (ptr - 1 + _cs_H) % _cs_H
|
|
1167
|
+
phi_expected = cs_buf_y[ci, last_pos]
|
|
1168
|
+
else:
|
|
1169
|
+
sy = cs_stats[ci, 0]
|
|
1170
|
+
sxy = cs_stats[ci, 1]
|
|
1171
|
+
n_f = float(n_b)
|
|
1172
|
+
if n_b < _cs_H:
|
|
1173
|
+
Sx_c = n_f * (n_f - 1.0) / 2.0
|
|
1174
|
+
Sxx_c = (
|
|
1175
|
+
n_f * (n_f - 1.0) * (2.0 * n_f - 1.0) / 6.0
|
|
1176
|
+
)
|
|
1177
|
+
denom = n_f * Sxx_c - Sx_c * Sx_c
|
|
1178
|
+
else:
|
|
1179
|
+
Sx_c = _H_f * (_H_f - 1.0) / 2.0
|
|
1180
|
+
Sxx_c = (
|
|
1181
|
+
_H_f
|
|
1182
|
+
* (_H_f - 1.0)
|
|
1183
|
+
* (2.0 * _H_f - 1.0)
|
|
1184
|
+
/ 6.0
|
|
1185
|
+
)
|
|
1186
|
+
denom = _H_f * Sxx_c - Sx_c * Sx_c
|
|
1187
|
+
if abs(denom) > 1e-30:
|
|
1188
|
+
slope = (n_f * sxy - Sx_c * sy) / denom
|
|
1189
|
+
intercept = (sy - slope * Sx_c) / n_f
|
|
1190
|
+
else:
|
|
1191
|
+
slope = 0.0
|
|
1192
|
+
intercept = sy / n_f
|
|
1193
|
+
phi_expected = slope * n_f + intercept
|
|
1194
|
+
|
|
1195
|
+
diff = y_b - phi_expected
|
|
1196
|
+
k_slip = int(round(diff / quantum))
|
|
1197
|
+
if (
|
|
1198
|
+
abs(diff) > float(cpr_cycle_slip_threshold)
|
|
1199
|
+
and k_slip != 0
|
|
1200
|
+
):
|
|
1201
|
+
y_b -= float(k_slip) * quantum
|
|
1202
|
+
phi_corr_np[ci, i] = y_b
|
|
1203
|
+
|
|
1204
|
+
# Update circular buffer - relative coords, only y needed
|
|
1205
|
+
write_pos = ptr % _cs_H
|
|
1206
|
+
if n_b == _cs_H:
|
|
1207
|
+
old_y = cs_buf_y[ci, write_pos]
|
|
1208
|
+
old_sy = cs_stats[ci, 0]
|
|
1209
|
+
# Sxy_new = Sxy_old - Sy_old + y_old + (H-1)*y_new
|
|
1210
|
+
cs_stats[ci, 1] = (
|
|
1211
|
+
cs_stats[ci, 1]
|
|
1212
|
+
- old_sy
|
|
1213
|
+
+ old_y
|
|
1214
|
+
+ (_H_f - 1.0) * y_b
|
|
1215
|
+
)
|
|
1216
|
+
cs_stats[ci, 0] = old_sy - old_y + y_b
|
|
1217
|
+
else:
|
|
1218
|
+
cs_stats[ci, 1] += float(n_b) * y_b
|
|
1219
|
+
cs_stats[ci, 0] += y_b
|
|
1220
|
+
cs_buf_y[ci, write_pos] = y_b
|
|
1221
|
+
cs_buf_ptr[ci] = ptr + 1
|
|
1222
|
+
if n_b < _cs_H:
|
|
1223
|
+
cs_buf_n[ci] = n_b + 1
|
|
1224
|
+
|
|
1225
|
+
_phi_corr_dev = xp.asarray(phi_corr_np)
|
|
1226
|
+
|
|
1227
|
+
# Carry the net slip correction into bps_offset4 so that
|
|
1228
|
+
# subsequent blocks do not re-detect the same slip.
|
|
1229
|
+
# bps_offset4 tracks the 4x-domain accumulated phase; the slip
|
|
1230
|
+
# quantum is π/2 -> 2π in 4x, which is a multiple of 2π and
|
|
1231
|
+
# therefore transparent to the 4-fold unwrap of bps_prev4.
|
|
1232
|
+
# Vectorized on-device op - a zero net slip adds 0.0 for free,
|
|
1233
|
+
# so no per-channel host-side != 0.0 test is needed.
|
|
1234
|
+
bps_offset4 += (_phi_corr_dev[:, -1] - _phi_f64[:, -1]) * 4.0
|
|
1235
|
+
_phi_f64 = _phi_corr_dev
|
|
1236
|
+
|
|
1237
|
+
# Wrap unbounded float64 phase to [-π, π] before float32 cast so that
|
|
1238
|
+
# the GPU exp() argument is bounded (dual-path: wrapped for rotation,
|
|
1239
|
+
# unwrapped for trajectory storage).
|
|
1240
|
+
phi_c_dev = (_phi_f64 - xp.round(_phi_f64 / _two_pi) * _two_pi).astype(
|
|
1241
|
+
xp.float32
|
|
1242
|
+
)
|
|
1243
|
+
phi_c_traj = _phi_f64.astype(xp.float32) # unwrapped, for output trajectory
|
|
1244
|
+
|
|
1245
|
+
phi_c = phi_c_dev # wrapped float32, for rotation
|
|
1246
|
+
y_rot = y_block * xp.exp(-1j * phi_c.astype(xp.complex64)) # (C, B)
|
|
1247
|
+
assert phi_ws is not None
|
|
1248
|
+
phi_ws[:, :B] = phi_c_traj # unwrapped float32, for trajectory
|
|
1249
|
+
else:
|
|
1250
|
+
y_rot = y_block
|
|
1251
|
+
|
|
1252
|
+
# -- Error computation (training or DD slicer) ---------------------
|
|
1253
|
+
e_clean = xp.empty((C, B), dtype=xp.complex64)
|
|
1254
|
+
|
|
1255
|
+
if n_train_blk > 0:
|
|
1256
|
+
d_train = training_symbols[:, b_start : b_start + n_train_blk]
|
|
1257
|
+
e_clean[:, :n_train_blk] = d_train - y_rot[:, :n_train_blk]
|
|
1258
|
+
|
|
1259
|
+
if n_train_blk < B:
|
|
1260
|
+
y_dd = y_rot[:, n_train_blk:]
|
|
1261
|
+
if _sq_side > 0:
|
|
1262
|
+
_dd_r = (
|
|
1263
|
+
_sq_lev_min
|
|
1264
|
+
+ xp.clip(
|
|
1265
|
+
xp.round((y_dd.real - _sq_lev_min) / _sq_d_grid), 0, _sq_m1
|
|
1266
|
+
)
|
|
1267
|
+
* _sq_d_grid
|
|
1268
|
+
)
|
|
1269
|
+
_dd_i = (
|
|
1270
|
+
_sq_lev_min
|
|
1271
|
+
+ xp.clip(
|
|
1272
|
+
xp.round((y_dd.imag - _sq_lev_min) / _sq_d_grid), 0, _sq_m1
|
|
1273
|
+
)
|
|
1274
|
+
* _sq_d_grid
|
|
1275
|
+
)
|
|
1276
|
+
d_dd = xp.empty(y_dd.shape, dtype=xp.complex64)
|
|
1277
|
+
d_dd.real[:] = _dd_r
|
|
1278
|
+
d_dd.imag[:] = _dd_i
|
|
1279
|
+
elif _k_dd is not None:
|
|
1280
|
+
# Fused nearest-point search: (1, C, B_dd) argmin indices with
|
|
1281
|
+
# a unit phasor, replacing the (C, B_dd, M) distance tensor.
|
|
1282
|
+
_, _dd_idx = _k_dd(y_dd, _dd_phasor, constellation=constellation)
|
|
1283
|
+
d_dd = constellation[_dd_idx[0]]
|
|
1284
|
+
else:
|
|
1285
|
+
d2_sl = (
|
|
1286
|
+
xp.abs(y_dd[:, :, None] - constellation[None, None, :]) ** 2
|
|
1287
|
+
).real
|
|
1288
|
+
d_dd = constellation[xp.argmin(d2_sl, axis=-1)]
|
|
1289
|
+
e_clean[:, n_train_blk:] = d_dd - y_dd
|
|
1290
|
+
|
|
1291
|
+
# -- Store per-symbol outputs into the fixed workspaces ------------
|
|
1292
|
+
y_rot_ws[:, :B] = y_rot
|
|
1293
|
+
e_clean_ws[:, :B] = e_clean
|
|
1294
|
+
if store_weights:
|
|
1295
|
+
assert w_hist is not None
|
|
1296
|
+
w_hist[b_start : b_start + B] = h[None, :, :, :]
|
|
1297
|
+
|
|
1298
|
+
# -- Back-rotate error to tap plane and compute gradient -----------
|
|
1299
|
+
if cpr_type == "bps":
|
|
1300
|
+
e_taps = e_clean * xp.exp(1j * phi_c.astype(xp.complex64)) # (C, B)
|
|
1301
|
+
else:
|
|
1302
|
+
e_taps = e_clean
|
|
1303
|
+
|
|
1304
|
+
# Scatter e_taps to sample positions within the block window
|
|
1305
|
+
e_scatter.fill(0)
|
|
1306
|
+
e_scatter[:, : B * sps : sps] = e_taps
|
|
1307
|
+
|
|
1308
|
+
# Frequency-domain gradient: dH_fd[i,j,k] = conj(E_fd[i,k]) * X_fd[j,k].
|
|
1309
|
+
# Outer product over the channel axes - no contracted index, hence no
|
|
1310
|
+
# accumulation (complex64 is exact enough) and no cuBLAS, so the
|
|
1311
|
+
# broadcast form is both capture-safe and replaces the einsum directly.
|
|
1312
|
+
E_fd = xp.fft.fft(e_scatter, axis=-1) # (C, F)
|
|
1313
|
+
dH_fd = xp.conj(E_fd)[:, None, :] * X_fd[None, :, :] # (C, C, F)
|
|
1314
|
+
dh = xp.fft.ifft(dH_fd, axis=-1)[:, :, :num_taps] # (C, C, T)
|
|
1315
|
+
|
|
1316
|
+
# In-place weight update so the captured graph reads/writes one buffer.
|
|
1317
|
+
h += xp.float32(step_size) * dh
|
|
1318
|
+
|
|
1319
|
+
# Accumulate divergence flag on-device - no D->H sync here.
|
|
1320
|
+
_div_flag |= ~xp.isfinite(h).all()
|
|
1321
|
+
|
|
1322
|
+
# CUDA-graph invariant: _fill_x_win and _store_outputs run eagerly *between*
|
|
1323
|
+
# graph replays, so they MUST NOT allocate from the device memory pool. The
|
|
1324
|
+
# captured graph's intermediates were freed back to the pool after capture;
|
|
1325
|
+
# any allocation here could hand those exact blocks out and clobber the
|
|
1326
|
+
# pointers the replay reads/writes (intermittent garbage / divergence). Both
|
|
1327
|
+
# helpers touch only pre-allocated buffers (x_win, y_all/e_all/phi_all,
|
|
1328
|
+
# *_ws) via fill/slice-assign - keep them allocation-free.
|
|
1329
|
+
def _fill_x_win(b_start):
|
|
1330
|
+
"""Eager (per-block, varying offset) load of the input window into x_win.
|
|
1331
|
+
|
|
1332
|
+
Kept outside ``_run_block`` because the source offset changes every
|
|
1333
|
+
block - a varying-pointer copy cannot live inside the captured graph.
|
|
1334
|
+
"""
|
|
1335
|
+
x_start = b_start * sps
|
|
1336
|
+
x_win.fill(0)
|
|
1337
|
+
available = min(fftsize, N_padded - x_start)
|
|
1338
|
+
if available > 0:
|
|
1339
|
+
x_win[:, :available] = x_padded[:, x_start : x_start + available]
|
|
1340
|
+
|
|
1341
|
+
def _store_outputs(b_start, b_end, B):
|
|
1342
|
+
"""Eager copy of the fixed workspaces into the result arrays."""
|
|
1343
|
+
y_all[:, b_start:b_end] = y_rot_ws[:, :B]
|
|
1344
|
+
e_all[:, b_start:b_end] = e_clean_ws[:, :B]
|
|
1345
|
+
if cpr_type == "bps":
|
|
1346
|
+
assert phi_all is not None and phi_ws is not None
|
|
1347
|
+
phi_all[:, b_start:b_end] = phi_ws[:, :B]
|
|
1348
|
+
|
|
1349
|
+
# -- CUDA-graph eligibility ------------------------------------------------
|
|
1350
|
+
# The block-loop body is host-sync-free, so on GPU it can
|
|
1351
|
+
# be captured once and replayed per block, collapsing ~30-50 kernel launches
|
|
1352
|
+
# into one. Only full blocks (B == block_size) lying entirely in the
|
|
1353
|
+
# decision-directed region (n_train_blk == 0) share one fixed control flow
|
|
1354
|
+
# and shape, so only those are captured; the training/straddle blocks and
|
|
1355
|
+
# the final partial block always run eagerly. Cycle-slip correction is
|
|
1356
|
+
# capturable only via the cs_block CUDA kernel (the CPU fallback syncs).
|
|
1357
|
+
_first_dd_full = ((n_train_aligned + block_size - 1) // block_size) * block_size
|
|
1358
|
+
_n_dd_full = max(0, (n_sym - _first_dd_full) // block_size)
|
|
1359
|
+
_use_graph = (
|
|
1360
|
+
cuda_graph
|
|
1361
|
+
and xp is not np
|
|
1362
|
+
and not store_weights
|
|
1363
|
+
and (not cpr_cycle_slip_correction or _k_cs is not None)
|
|
1364
|
+
and _n_dd_full >= 2 # need ≥1 warmup block + ≥1 captured block to pay off
|
|
1365
|
+
)
|
|
1366
|
+
try:
|
|
1367
|
+
import cupy as _cp_graph
|
|
1368
|
+
|
|
1369
|
+
_graph_stream = _cp_graph.cuda.Stream(non_blocking=True) if _use_graph else None
|
|
1370
|
+
except Exception:
|
|
1371
|
+
_use_graph = False
|
|
1372
|
+
_graph_stream = None
|
|
1373
|
+
|
|
1374
|
+
# All loop work - eager blocks, input fills, output copies, and graph
|
|
1375
|
+
# capture/replay - runs on a single stream so the shared state buffers
|
|
1376
|
+
# (h, bps_*, cs_*) stay ordered across the eager<->replay boundary. On the
|
|
1377
|
+
# eager (CPU or graph-disabled) path this is a no-op context.
|
|
1378
|
+
_loop_stream_ctx: Any
|
|
1379
|
+
if _use_graph:
|
|
1380
|
+
assert _graph_stream is not None # set together with _use_graph above
|
|
1381
|
+
# _graph_stream is non-blocking, so it does NOT implicitly serialize
|
|
1382
|
+
# with the default stream that produced x_padded, h, the scratch
|
|
1383
|
+
# buffers, and the BPS/cs constants above. Without an explicit join the
|
|
1384
|
+
# first block (and the graph capture) races those still-in-flight setup
|
|
1385
|
+
# writes - intermittently reading/capturing garbage, which surfaces as
|
|
1386
|
+
# divergence or bad convergence that "fixes itself" on rerun (the race
|
|
1387
|
+
# is timing-dependent). Make the loop stream wait for that setup work.
|
|
1388
|
+
_setup_done = _cp_graph.cuda.Event()
|
|
1389
|
+
_setup_done.record() # records on the current (default) stream
|
|
1390
|
+
_graph_stream.wait_event(_setup_done)
|
|
1391
|
+
_loop_stream_ctx = _graph_stream
|
|
1392
|
+
else:
|
|
1393
|
+
_loop_stream_ctx = contextlib.nullcontext()
|
|
1394
|
+
|
|
1395
|
+
# -- Block loop ------------------------------------------------------------
|
|
1396
|
+
_graph = None # captured CUDA graph, built lazily on the 2nd full DD block
|
|
1397
|
+
_graph_warmed = False # True once one full DD block has primed the mem pool
|
|
1398
|
+
with _loop_stream_ctx:
|
|
1399
|
+
for b in range(n_blocks):
|
|
1400
|
+
b_start = b * block_size
|
|
1401
|
+
b_end = min(b_start + block_size, n_sym)
|
|
1402
|
+
B = b_end - b_start # symbols this block (may be < block_size for last)
|
|
1403
|
+
n_train_blk = max(0, min(n_train_aligned - b_start, B))
|
|
1404
|
+
_capturable = _use_graph and block_size == B and n_train_blk == 0
|
|
1405
|
+
|
|
1406
|
+
_fill_x_win(b_start)
|
|
1407
|
+
|
|
1408
|
+
if not _capturable:
|
|
1409
|
+
_run_block(B, b_start, n_train_blk) # eager
|
|
1410
|
+
elif _graph is not None:
|
|
1411
|
+
_graph.launch() # replay (current stream == _graph_stream)
|
|
1412
|
+
elif not _graph_warmed:
|
|
1413
|
+
_run_block(B, b_start, 0) # eager warmup - primes the memory pool
|
|
1414
|
+
_graph_warmed = True
|
|
1415
|
+
else:
|
|
1416
|
+
# Capture the body once. Stream capture records the kernel
|
|
1417
|
+
# sequence without executing it (pool allocations reuse the
|
|
1418
|
+
# blocks the warmup freed, so no cudaMalloc occurs); launch()
|
|
1419
|
+
# then executes this block. On any capture failure, fall back
|
|
1420
|
+
# to running the remaining blocks eagerly.
|
|
1421
|
+
assert (
|
|
1422
|
+
_graph_stream is not None
|
|
1423
|
+
) # _capturable => _use_graph => stream set
|
|
1424
|
+
try:
|
|
1425
|
+
_graph_stream.begin_capture()
|
|
1426
|
+
_run_block(B, b_start, 0)
|
|
1427
|
+
_graph = _graph_stream.end_capture()
|
|
1428
|
+
_graph.launch()
|
|
1429
|
+
except Exception as exc: # pragma: no cover - hw/version dependent
|
|
1430
|
+
with contextlib.suppress(Exception):
|
|
1431
|
+
_graph_stream.end_capture()
|
|
1432
|
+
_graph = None
|
|
1433
|
+
_use_graph = False
|
|
1434
|
+
logger.warning(
|
|
1435
|
+
"block_lms CUDA-graph capture failed (%s); "
|
|
1436
|
+
"continuing with the eager block loop.",
|
|
1437
|
+
exc,
|
|
1438
|
+
)
|
|
1439
|
+
_run_block(B, b_start, 0) # ensure this block runs once
|
|
1440
|
+
|
|
1441
|
+
_store_outputs(b_start, b_end, B)
|
|
1442
|
+
|
|
1443
|
+
if _graph_stream is not None:
|
|
1444
|
+
_graph_stream.synchronize()
|
|
1445
|
+
|
|
1446
|
+
# Single D->H sync after the full loop to check for divergence.
|
|
1447
|
+
if bool(_div_flag[0]):
|
|
1448
|
+
raise RuntimeError(
|
|
1449
|
+
f"block_lms diverged (step_size={step_size}, block_size={block_size}). "
|
|
1450
|
+
f"step_size is on the same scale as lms(), but because the weights are "
|
|
1451
|
+
f"frozen across the block the stability ceiling is ~{block_size}x lower "
|
|
1452
|
+
f"than per-symbol LMS. Reduce step_size until stable (e.g. try "
|
|
1453
|
+
f"{step_size / 2:.2e}, then keep halving) rather than dividing by "
|
|
1454
|
+
f"block_size, which would under-adapt the filter by that factor."
|
|
1455
|
+
)
|
|
1456
|
+
|
|
1457
|
+
# -- Pack result -----------------------------------------------------------
|
|
1458
|
+
if was_1d:
|
|
1459
|
+
y_out = y_all[0]
|
|
1460
|
+
e_out = e_all[0]
|
|
1461
|
+
W_out = h[0, 0]
|
|
1462
|
+
if store_weights and w_hist is not None:
|
|
1463
|
+
w_history = w_hist[:, 0, 0, :]
|
|
1464
|
+
else:
|
|
1465
|
+
w_history = None
|
|
1466
|
+
|
|
1467
|
+
if cpr_type == "bps" and phi_all is not None:
|
|
1468
|
+
phase_traj = phi_all[0]
|
|
1469
|
+
else:
|
|
1470
|
+
phase_traj = None
|
|
1471
|
+
else:
|
|
1472
|
+
y_out = y_all
|
|
1473
|
+
e_out = e_all
|
|
1474
|
+
W_out = h
|
|
1475
|
+
w_history = w_hist if store_weights else None
|
|
1476
|
+
phase_traj = phi_all if cpr_type == "bps" else None
|
|
1477
|
+
|
|
1478
|
+
result = EqualizerResult(
|
|
1479
|
+
y_hat=y_out,
|
|
1480
|
+
weights=W_out,
|
|
1481
|
+
error=e_out,
|
|
1482
|
+
weights_history=w_history,
|
|
1483
|
+
num_train_symbols=n_train_aligned,
|
|
1484
|
+
input_norm_factor=eq_norm,
|
|
1485
|
+
phase_trajectory=phase_traj,
|
|
1486
|
+
)
|
|
1487
|
+
if cpr_type == "bps":
|
|
1488
|
+
result.cpr_state = CPRState(
|
|
1489
|
+
bps_prev4=to_device(bps_prev4, "cpu").copy(),
|
|
1490
|
+
bps_offset4=to_device(bps_offset4, "cpu").copy(),
|
|
1491
|
+
bps_d2_hist=to_device(bps_d2_hist, "cpu"),
|
|
1492
|
+
cs_buf_x=cs_buf_x.copy(),
|
|
1493
|
+
cs_buf_y=to_device(cs_buf_y, "cpu").copy(),
|
|
1494
|
+
cs_buf_ptr=to_device(cs_buf_ptr, "cpu").copy(),
|
|
1495
|
+
cs_buf_n=to_device(cs_buf_n, "cpu").copy(),
|
|
1496
|
+
cs_stats=to_device(cs_stats, "cpu").copy(),
|
|
1497
|
+
cpr_type=cpr_type,
|
|
1498
|
+
num_ch=C,
|
|
1499
|
+
symmetry=_cpr_symmetry(modulation, order),
|
|
1500
|
+
bps_P=P,
|
|
1501
|
+
bps_K=_bps_K,
|
|
1502
|
+
cs_H=_cs_H,
|
|
1503
|
+
)
|
|
1504
|
+
return _log_equalizer_exit(
|
|
1505
|
+
result, name="Block-LMS", debug_plot=debug_plot, plot_smoothing=plot_smoothing
|
|
1506
|
+
)
|
|
1507
|
+
|
|
1508
|
+
|
|
1509
|
+
# -----------------------------------------------------------------------------
|
|
1510
|
+
# BLIND FREQUENCY-DOMAIN EQUALIZERS (block_cma / block_rde)
|
|
1511
|
+
# -----------------------------------------------------------------------------
|
|
1512
|
+
#
|
|
1513
|
+
# block_cma / block_rde are the blind, phase-directed siblings of block_lms:
|
|
1514
|
+
# the same overlap-save frequency-domain adaptive filter (FDAF), but with the
|
|
1515
|
+
# Godard / ring-radius error instead of the trained/DD slicer. They share the
|
|
1516
|
+
# two FDAF primitives below (forward butterfly + frequency-domain gradient);
|
|
1517
|
+
# block_lms keeps its own CPR/cycle-slip/CUDA-graph-optimised body.
|
|
1518
|
+
#
|
|
1519
|
+
# All three use the same additive update ``h += mu * conj(E_fd) * X_fd`` with
|
|
1520
|
+
# the gradient correlation in the frequency domain, so the blind error is cast
|
|
1521
|
+
# into the trained convention's sign: CMA ``E = y*(R2 - |y|^2)`` and RDE
|
|
1522
|
+
# ``E = y*(R_d^2 - |y|^2)`` (the negated Godard/ring gradient), and pilot
|
|
1523
|
+
# positions use the LMS residual ``E = pilot_ref - y`` directly.
|
|
1524
|
+
|
|
1525
|
+
|
|
1526
|
+
def _fdaf_forward(h, x_win, fftsize, sps, B, xp):
|
|
1527
|
+
"""One overlap-save FDAF forward block.
|
|
1528
|
+
|
|
1529
|
+
Returns ``(y_block (C, B), X_fd (C, F))`` - the decimated output symbols and
|
|
1530
|
+
the input spectrum (reused by the gradient). The butterfly contraction is a
|
|
1531
|
+
complex128-accumulated broadcast-reduce (CLAUDE.md filter-dot-product rule),
|
|
1532
|
+
matching ``block_lms``'s capture-safe form.
|
|
1533
|
+
"""
|
|
1534
|
+
X_fd = xp.fft.fft(x_win, axis=-1) # (C, F)
|
|
1535
|
+
H_fd = xp.fft.fft(h, n=fftsize, axis=-1) # (C, C, F)
|
|
1536
|
+
Y_fd = (
|
|
1537
|
+
(xp.conj(H_fd).astype(xp.complex128) * X_fd.astype(xp.complex128)[None])
|
|
1538
|
+
.sum(axis=1)
|
|
1539
|
+
.astype(xp.complex64)
|
|
1540
|
+
) # (C, F)
|
|
1541
|
+
y_time = xp.fft.ifft(Y_fd, axis=-1)
|
|
1542
|
+
y_block = y_time[:, : B * sps : sps].astype(xp.complex64) # (C, B)
|
|
1543
|
+
return y_block, X_fd
|
|
1544
|
+
|
|
1545
|
+
|
|
1546
|
+
def _fdaf_gradient_update(h, X_fd, e_block, e_scatter, sps, B, num_taps, mu, xp):
|
|
1547
|
+
"""Scatter ``e_block`` to sample positions, form the frequency-domain
|
|
1548
|
+
gradient ``dH[i,j] = conj(E_fd[i]) * X_fd[j]``, and apply the in-place
|
|
1549
|
+
update ``h += mu * IFFT(dH)[..., :num_taps]``."""
|
|
1550
|
+
e_scatter.fill(0)
|
|
1551
|
+
e_scatter[:, : B * sps : sps] = e_block
|
|
1552
|
+
E_fd = xp.fft.fft(e_scatter, axis=-1) # (C, F)
|
|
1553
|
+
dH_fd = xp.conj(E_fd)[:, None, :] * X_fd[None, :, :] # (C, C, F)
|
|
1554
|
+
dh = xp.fft.ifft(dH_fd, axis=-1)[:, :, :num_taps] # (C, C, T)
|
|
1555
|
+
h += xp.float32(mu) * dh
|
|
1556
|
+
|
|
1557
|
+
|
|
1558
|
+
def _block_fdaf_blind(
|
|
1559
|
+
kind,
|
|
1560
|
+
samples,
|
|
1561
|
+
*,
|
|
1562
|
+
num_taps,
|
|
1563
|
+
sps,
|
|
1564
|
+
step_size,
|
|
1565
|
+
block_size,
|
|
1566
|
+
r2,
|
|
1567
|
+
radii_np,
|
|
1568
|
+
w_init,
|
|
1569
|
+
input_norm_factor,
|
|
1570
|
+
samples_prefix,
|
|
1571
|
+
pad_mode,
|
|
1572
|
+
pilot_ref,
|
|
1573
|
+
pilot_mask,
|
|
1574
|
+
pilot_gain_db,
|
|
1575
|
+
c_ps,
|
|
1576
|
+
cuda_graph,
|
|
1577
|
+
debug_plot,
|
|
1578
|
+
plot_smoothing,
|
|
1579
|
+
name,
|
|
1580
|
+
):
|
|
1581
|
+
"""Shared overlap-save FDAF engine for ``block_cma``/``block_rde``.
|
|
1582
|
+
|
|
1583
|
+
Blind, phase-directed adaptation with no CPR - the per-block error is the
|
|
1584
|
+
Godard (``kind='cma'``) or nearest-ring (``kind='rde'``) gradient, with
|
|
1585
|
+
pilot positions overridden by the LMS residual. ``r2`` is the Godard radius
|
|
1586
|
+
(CMA) and ``radii_np`` the unique ring radii (RDE).
|
|
1587
|
+
"""
|
|
1588
|
+
num_taps = int(num_taps)
|
|
1589
|
+
sps = int(sps)
|
|
1590
|
+
block_size = int(block_size)
|
|
1591
|
+
_validate_sps(sps, num_taps)
|
|
1592
|
+
|
|
1593
|
+
samples, xp, _ = dispatch(samples)
|
|
1594
|
+
if xp is np:
|
|
1595
|
+
logger.warning(
|
|
1596
|
+
"%s is running on CPU (NumPy). For CPU workloads %s(..., backend='numba') is typically faster. Move samples to GPU (CuPy) to benefit from block-FFT acceleration.",
|
|
1597
|
+
name,
|
|
1598
|
+
kind,
|
|
1599
|
+
)
|
|
1600
|
+
|
|
1601
|
+
was_1d = samples.ndim == 1
|
|
1602
|
+
if was_1d:
|
|
1603
|
+
samples = samples[np.newaxis, :]
|
|
1604
|
+
C = samples.shape[0]
|
|
1605
|
+
N = samples.shape[1]
|
|
1606
|
+
n_sym = N // sps
|
|
1607
|
+
|
|
1608
|
+
use_pilots = pilot_ref is not None and pilot_mask is not None
|
|
1609
|
+
# Deboost pilot positions before normalisation so boosted pilots don't
|
|
1610
|
+
# inflate the RMS and bias the Godard target (matches cma/rde).
|
|
1611
|
+
if use_pilots and pilot_gain_db != 0.0:
|
|
1612
|
+
amp = xp.float32(10.0 ** (pilot_gain_db / 20.0))
|
|
1613
|
+
smask = xp.asarray(np.repeat(np.asarray(pilot_mask).astype(bool), sps))
|
|
1614
|
+
samples = samples.copy()
|
|
1615
|
+
samples[..., smask] /= amp
|
|
1616
|
+
samples, _, eq_norm = _normalize_inputs(
|
|
1617
|
+
samples, None, sps, input_norm_factor=input_norm_factor
|
|
1618
|
+
)
|
|
1619
|
+
|
|
1620
|
+
# -- OLS block size + padding (matches block_lms) -----------------------
|
|
1621
|
+
_ols_min = block_size * sps + num_taps - 1
|
|
1622
|
+
fftsize = 1 << (_ols_min - 1).bit_length()
|
|
1623
|
+
logger.info(
|
|
1624
|
+
"%s: C=%s, num_taps=%s, sps=%s, block_size=%s, fftsize=%s, mu=%s, n_sym=%s, pilot_aided=%s",
|
|
1625
|
+
name,
|
|
1626
|
+
C,
|
|
1627
|
+
num_taps,
|
|
1628
|
+
sps,
|
|
1629
|
+
block_size,
|
|
1630
|
+
fftsize,
|
|
1631
|
+
step_size,
|
|
1632
|
+
n_sym,
|
|
1633
|
+
use_pilots,
|
|
1634
|
+
)
|
|
1635
|
+
c_tap = num_taps // 2
|
|
1636
|
+
pad_total = max(0, n_sym * sps - N + num_taps - 1)
|
|
1637
|
+
pad_left = min(c_tap, pad_total)
|
|
1638
|
+
pad_right = pad_total - pad_left
|
|
1639
|
+
if samples_prefix is not None or xp is np or pad_mode != "zeros":
|
|
1640
|
+
_cpu = to_device(samples, "cpu").astype(np.complex64)
|
|
1641
|
+
x_padded = xp.asarray(
|
|
1642
|
+
_build_padded_samples(
|
|
1643
|
+
_cpu, pad_left, pad_right, samples_prefix, pad_mode, eq_norm, sps
|
|
1644
|
+
)
|
|
1645
|
+
)
|
|
1646
|
+
else:
|
|
1647
|
+
_f32 = (
|
|
1648
|
+
samples if samples.dtype == xp.complex64 else samples.astype(xp.complex64)
|
|
1649
|
+
)
|
|
1650
|
+
_l = xp.zeros((C, pad_left), dtype=xp.complex64)
|
|
1651
|
+
_r = (
|
|
1652
|
+
xp.zeros((C, pad_right), dtype=xp.complex64)
|
|
1653
|
+
if pad_right > 0
|
|
1654
|
+
else xp.empty((C, 0), dtype=xp.complex64)
|
|
1655
|
+
)
|
|
1656
|
+
x_padded = xp.concatenate([_l, _f32, _r], axis=1)
|
|
1657
|
+
N_padded = x_padded.shape[1]
|
|
1658
|
+
|
|
1659
|
+
# -- Weight initialisation ----------------------------------------------
|
|
1660
|
+
if w_init is not None:
|
|
1661
|
+
w_arr = _validate_w_init(
|
|
1662
|
+
np.ascontiguousarray(to_device(w_init, "cpu"), dtype=np.complex64),
|
|
1663
|
+
C,
|
|
1664
|
+
num_taps,
|
|
1665
|
+
)
|
|
1666
|
+
h = xp.asarray(w_arr.copy())
|
|
1667
|
+
else:
|
|
1668
|
+
h = xp.asarray(_init_butterfly_weights_numpy(C, num_taps)) # (C, C, T)
|
|
1669
|
+
|
|
1670
|
+
# -- Pilot / radii device arrays ----------------------------------------
|
|
1671
|
+
if use_pilots:
|
|
1672
|
+
pref = xp.asarray(
|
|
1673
|
+
np.ascontiguousarray(to_device(pilot_ref, "cpu"), dtype=np.complex64)
|
|
1674
|
+
)
|
|
1675
|
+
if pref.ndim == 1:
|
|
1676
|
+
pref = xp.tile(pref[None, :], (C, 1))
|
|
1677
|
+
if c_ps is not None:
|
|
1678
|
+
pref = (pref * xp.complex64(c_ps)).astype(xp.complex64)
|
|
1679
|
+
pmask_dev = xp.asarray(np.asarray(pilot_mask).astype(bool))
|
|
1680
|
+
if kind == "rde":
|
|
1681
|
+
radii = xp.asarray(np.asarray(radii_np, dtype=np.float64))
|
|
1682
|
+
|
|
1683
|
+
# -- Scratch + output buffers -------------------------------------------
|
|
1684
|
+
# Pre-allocated and reused every block: avoids per-block heap pressure and
|
|
1685
|
+
# (on GPU) gives the CUDA-graph capture stable read/write pointers.
|
|
1686
|
+
x_win = xp.zeros((C, fftsize), dtype=xp.complex64)
|
|
1687
|
+
e_scatter = xp.zeros((C, fftsize), dtype=xp.complex64)
|
|
1688
|
+
y_all = xp.empty((C, n_sym), dtype=xp.complex64)
|
|
1689
|
+
e_all = xp.empty((C, n_sym), dtype=xp.complex64)
|
|
1690
|
+
# Fixed-width (block_size) output workspaces. The capturable body always
|
|
1691
|
+
# writes its per-symbol outputs here; the eager driver copies the valid
|
|
1692
|
+
# [:, :B] slice into the full-length arrays at the right offset. Routing
|
|
1693
|
+
# through fixed buffers (rather than writing y_all[:, b_start:b_end]
|
|
1694
|
+
# directly) is what lets the body be captured once and replayed: only the
|
|
1695
|
+
# input fill and output copy vary per block.
|
|
1696
|
+
y_ws = xp.empty((C, block_size), dtype=xp.complex64)
|
|
1697
|
+
e_ws = xp.empty((C, block_size), dtype=xp.complex64)
|
|
1698
|
+
n_blocks = (n_sym + block_size - 1) // block_size
|
|
1699
|
+
|
|
1700
|
+
def _run_block(B, b_start):
|
|
1701
|
+
"""Compute one block: forward FDAF, blind error, gradient + in-place
|
|
1702
|
+
weight update. Reads the window from ``x_win`` (filled by the caller),
|
|
1703
|
+
updates ``h`` in place, and writes outputs into ``y_ws``/``e_ws`` (cols
|
|
1704
|
+
``[:, :B]``). Issues no host sync, so on GPU it is capturable. The
|
|
1705
|
+
``b_start``-varying pilot slice is only touched on the (non-captured)
|
|
1706
|
+
pilot-aided path."""
|
|
1707
|
+
nonlocal h
|
|
1708
|
+
y_block, X_fd = _fdaf_forward(h, x_win, fftsize, sps, B, xp)
|
|
1709
|
+
|
|
1710
|
+
abs2 = xp.real(y_block * xp.conj(y_block)) # (C, B) strict-real |y|^2
|
|
1711
|
+
if kind == "cma":
|
|
1712
|
+
e = y_block * (xp.float32(r2) - abs2)
|
|
1713
|
+
else: # rde - nearest ring radius per symbol
|
|
1714
|
+
abs_y = xp.sqrt(abs2)
|
|
1715
|
+
rd = radii[
|
|
1716
|
+
xp.argmin(xp.abs(abs_y[:, :, None] - radii[None, None, :]), axis=-1)
|
|
1717
|
+
]
|
|
1718
|
+
e = y_block * (rd.astype(xp.float32) ** 2 - abs2)
|
|
1719
|
+
if use_pilots:
|
|
1720
|
+
pm = pmask_dev[b_start : b_start + B][None, :]
|
|
1721
|
+
e = xp.where(pm, pref[:, b_start : b_start + B] - y_block, e)
|
|
1722
|
+
|
|
1723
|
+
y_ws[:, :B] = y_block
|
|
1724
|
+
e_ws[:, :B] = e
|
|
1725
|
+
_fdaf_gradient_update(h, X_fd, e, e_scatter, sps, B, num_taps, step_size, xp)
|
|
1726
|
+
|
|
1727
|
+
# CUDA-graph invariant: _fill_x_win / _store_outputs run eagerly *between*
|
|
1728
|
+
# replays and MUST NOT allocate from the device memory pool (a fresh
|
|
1729
|
+
# allocation could hand out the blocks the captured graph's freed
|
|
1730
|
+
# intermediates still reference). Both touch only pre-allocated buffers.
|
|
1731
|
+
def _fill_x_win(b_start):
|
|
1732
|
+
"""Eager (per-block, varying offset) load of the input window."""
|
|
1733
|
+
x_start = b_start * sps
|
|
1734
|
+
x_win.fill(0)
|
|
1735
|
+
avail = min(fftsize, N_padded - x_start)
|
|
1736
|
+
if avail > 0:
|
|
1737
|
+
x_win[:, :avail] = x_padded[:, x_start : x_start + avail]
|
|
1738
|
+
|
|
1739
|
+
def _store_outputs(b_start, b_end, B):
|
|
1740
|
+
"""Eager copy of the fixed workspaces into the result arrays."""
|
|
1741
|
+
y_all[:, b_start:b_end] = y_ws[:, :B]
|
|
1742
|
+
e_all[:, b_start:b_end] = e_ws[:, :B]
|
|
1743
|
+
|
|
1744
|
+
# -- CUDA-graph eligibility ----------------------------------------------
|
|
1745
|
+
# The block body is host-sync-free, so on GPU it can be captured once and
|
|
1746
|
+
# replayed per block, collapsing the per-block kernel launches into one.
|
|
1747
|
+
# Only full blocks (B == block_size) share a fixed shape and control flow,
|
|
1748
|
+
# so only those are captured; the final partial block runs eagerly. The
|
|
1749
|
+
# pilot-aided path reads b_start-varying pilot slices inside the body and is
|
|
1750
|
+
# therefore not capturable - it always runs the eager loop.
|
|
1751
|
+
n_full = n_sym // block_size
|
|
1752
|
+
_use_graph = (
|
|
1753
|
+
cuda_graph
|
|
1754
|
+
and xp is not np
|
|
1755
|
+
and not use_pilots
|
|
1756
|
+
and n_full >= 2 # need ≥1 warmup block + ≥1 captured block to pay off
|
|
1757
|
+
)
|
|
1758
|
+
try:
|
|
1759
|
+
import cupy as _cp_graph
|
|
1760
|
+
|
|
1761
|
+
_graph_stream = _cp_graph.cuda.Stream(non_blocking=True) if _use_graph else None
|
|
1762
|
+
except Exception:
|
|
1763
|
+
_use_graph = False
|
|
1764
|
+
_graph_stream = None
|
|
1765
|
+
|
|
1766
|
+
# Run the whole loop on one stream so the shared state (h) stays ordered
|
|
1767
|
+
# across the eager<->replay boundary. The non-blocking graph stream does not
|
|
1768
|
+
# implicitly serialize with the default stream that produced x_padded/h/the
|
|
1769
|
+
# scratch buffers, so join it to that setup work before the first block to
|
|
1770
|
+
# avoid capturing still-in-flight garbage.
|
|
1771
|
+
_loop_stream_ctx: Any
|
|
1772
|
+
if _use_graph:
|
|
1773
|
+
assert _graph_stream is not None
|
|
1774
|
+
_setup_done = _cp_graph.cuda.Event()
|
|
1775
|
+
_setup_done.record()
|
|
1776
|
+
_graph_stream.wait_event(_setup_done)
|
|
1777
|
+
_loop_stream_ctx = _graph_stream
|
|
1778
|
+
else:
|
|
1779
|
+
_loop_stream_ctx = contextlib.nullcontext()
|
|
1780
|
+
|
|
1781
|
+
# -- Block loop ----------------------------------------------------------
|
|
1782
|
+
_graph = None # captured CUDA graph, built lazily on the 2nd full block
|
|
1783
|
+
_graph_warmed = False # True once one full block has primed the mem pool
|
|
1784
|
+
with _loop_stream_ctx:
|
|
1785
|
+
for b in range(n_blocks):
|
|
1786
|
+
b_start = b * block_size
|
|
1787
|
+
b_end = min(b_start + block_size, n_sym)
|
|
1788
|
+
B = b_end - b_start
|
|
1789
|
+
_capturable = _use_graph and block_size == B
|
|
1790
|
+
|
|
1791
|
+
_fill_x_win(b_start)
|
|
1792
|
+
|
|
1793
|
+
if not _capturable:
|
|
1794
|
+
_run_block(B, b_start) # eager (partial block, or graph off)
|
|
1795
|
+
elif _graph is not None:
|
|
1796
|
+
_graph.launch() # replay (current stream == _graph_stream)
|
|
1797
|
+
elif not _graph_warmed:
|
|
1798
|
+
_run_block(B, b_start) # eager warmup - primes the memory pool
|
|
1799
|
+
_graph_warmed = True
|
|
1800
|
+
else:
|
|
1801
|
+
# Capture the body once; pool allocations reuse the blocks the
|
|
1802
|
+
# warmup freed, so no cudaMalloc occurs during capture. On any
|
|
1803
|
+
# failure, fall back to the eager loop for the rest of the run.
|
|
1804
|
+
assert _graph_stream is not None
|
|
1805
|
+
try:
|
|
1806
|
+
_graph_stream.begin_capture()
|
|
1807
|
+
_run_block(B, b_start)
|
|
1808
|
+
_graph = _graph_stream.end_capture()
|
|
1809
|
+
_graph.launch()
|
|
1810
|
+
except Exception as exc: # pragma: no cover - hw/version dependent
|
|
1811
|
+
with contextlib.suppress(Exception):
|
|
1812
|
+
_graph_stream.end_capture()
|
|
1813
|
+
_graph = None
|
|
1814
|
+
_use_graph = False
|
|
1815
|
+
logger.warning(
|
|
1816
|
+
"%s CUDA-graph capture failed (%s); "
|
|
1817
|
+
"continuing with the eager block loop.",
|
|
1818
|
+
name,
|
|
1819
|
+
exc,
|
|
1820
|
+
)
|
|
1821
|
+
_run_block(B, b_start) # ensure this block runs once
|
|
1822
|
+
|
|
1823
|
+
_store_outputs(b_start, b_end, B)
|
|
1824
|
+
|
|
1825
|
+
if _graph_stream is not None:
|
|
1826
|
+
_graph_stream.synchronize()
|
|
1827
|
+
|
|
1828
|
+
# Single D->H sync to surface divergence after the whole loop.
|
|
1829
|
+
if not bool(xp.isfinite(h).all()):
|
|
1830
|
+
raise RuntimeError(
|
|
1831
|
+
f"{name} diverged (step_size={step_size}, block_size={block_size}). "
|
|
1832
|
+
f"step_size is on the same scale as {kind}(); because the weights are "
|
|
1833
|
+
f"frozen across the block the stability ceiling is ~{block_size}x lower. "
|
|
1834
|
+
f"Reduce step_size (e.g. {step_size / 2:.2e}, then keep halving)."
|
|
1835
|
+
)
|
|
1836
|
+
|
|
1837
|
+
if was_1d:
|
|
1838
|
+
y_out, e_out, W_out = y_all[0], e_all[0], h[0, 0]
|
|
1839
|
+
else:
|
|
1840
|
+
y_out, e_out, W_out = y_all, e_all, h
|
|
1841
|
+
result = EqualizerResult(
|
|
1842
|
+
y_hat=y_out,
|
|
1843
|
+
weights=W_out,
|
|
1844
|
+
error=e_out,
|
|
1845
|
+
weights_history=None,
|
|
1846
|
+
num_train_symbols=0,
|
|
1847
|
+
input_norm_factor=eq_norm,
|
|
1848
|
+
)
|
|
1849
|
+
return _log_equalizer_exit(
|
|
1850
|
+
result,
|
|
1851
|
+
name=name,
|
|
1852
|
+
debug_plot=debug_plot,
|
|
1853
|
+
check_convergence=True,
|
|
1854
|
+
plot_smoothing=plot_smoothing,
|
|
1855
|
+
)
|