pelagos-decoder 0.1.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.
pelagos/decoder.py ADDED
@@ -0,0 +1,3891 @@
1
+ # (C) Copyright IBM 2025, 2026
2
+ #
3
+ # This code is licensed under the Apache License, Version 2.0. You may
4
+ # obtain a copy of this license in the LICENSE file in the root directory
5
+ # of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
6
+ #
7
+ # Any modifications or derivative works of this code must retain this
8
+ # copyright notice, and modified files need to carry a notice indicating
9
+ # that they have been altered from the originals.
10
+
11
+ """Relay-BP-S decoder with Triton GPU kernels.
12
+
13
+ This module implements the Relay-BP (Relay Belief Propagation) algorithm for quantum error correction
14
+ using GPU-accelerated Triton kernels. The algorithm performs ensemble decoding with disordered memory
15
+ strengths to improve decoding performance on quantum error correction codes.
16
+
17
+ The implementation closely follows the Rust reference implementation in crates/relay_bp/src/bp/,
18
+ providing GPU acceleration while maintaining algorithmic fidelity.
19
+ """
20
+
21
+ import math
22
+
23
+ import torch
24
+ import numpy as np
25
+ from scipy.sparse import csr_matrix
26
+ from typing import Dict, Optional, Tuple, Union, List, Any
27
+ import os
28
+ from dataclasses import dataclass
29
+
30
+ from .graph import CSRGraph
31
+ from . import autotune as ac
32
+ from .kernels import (
33
+ c2v_min_sum_kernel,
34
+ v2c_and_marginals_fused_gamma_kernel,
35
+ parity_per_check_kernel,
36
+ parity_from_hard_kernel,
37
+ parity_from_hard_compact_kernel,
38
+ parity_from_hard_compact_with_zero_kernel,
39
+ init_messages_kernel,
40
+ init_messages_with_priors_EB_kernel,
41
+ c2v_min_sum_btile_kernel,
42
+ v2c_and_gamma_btile_kernel,
43
+ post_from_L_kernel,
44
+ reduce_all_ge_kernel,
45
+ freeze_finished_lanes_kernel,
46
+ unfreeze_finished_lanes_kernel,
47
+ count_active_blocks_kernel,
48
+ compact_active_indices_kernel,
49
+ c2v_min_sum_grid_stride_kernel,
50
+ check_and_select_kernel,
51
+ dtype_str_to_enum,
52
+ )
53
+ from .utils import (
54
+ compute_log_prior_ratios,
55
+ sample_gamma_uniform,
56
+ sample_gamma_scalar,
57
+ sample_gamma_per_variable,
58
+ GAMMA_DRAW_MODES,
59
+ validate_error_priors,
60
+ validate_csr_matrix,
61
+ bitpack_errors,
62
+ )
63
+ from .autotune import (
64
+ V2C_BTILE_BLOCK_SIZE,
65
+ build_c2v_configs,
66
+ build_v2c_configs,
67
+ build_parity_configs,
68
+ build_init_messages_configs,
69
+ build_c2v_btile_configs,
70
+ build_v2c_btile_configs,
71
+ build_check_select_configs,
72
+ build_c2v_grid_stride_configs,
73
+ )
74
+
75
+
76
+ def _build_check_conflict_adjacency(H_csr: csr_matrix) -> List[np.ndarray]:
77
+ """Build adjacency of the check–check conflict graph from CSR H.
78
+
79
+ Nodes are checks (rows). Two checks are adjacent if they share at least one variable.
80
+ """
81
+ # Work in CSC so we can iterate variable → incident checks efficiently
82
+ H_csc = H_csr.tocsc()
83
+ C = H_csr.shape[0]
84
+
85
+ adj_sets: List[set[int]] = [set() for _ in range(C)]
86
+
87
+ indptr = H_csc.indptr
88
+ indices = H_csc.indices # row indices (checks) for each column
89
+
90
+ for v in range(H_csc.shape[1]):
91
+ a, b = indptr[v], indptr[v + 1]
92
+ checks = indices[a:b]
93
+ dv = checks.size
94
+ if dv <= 1:
95
+ continue
96
+ # Add clique among incident checks
97
+ for i in range(dv):
98
+ ci = int(checks[i])
99
+ for j in range(i + 1, dv):
100
+ cj = int(checks[j])
101
+ if ci == cj:
102
+ continue
103
+ adj_sets[ci].add(cj)
104
+ adj_sets[cj].add(ci)
105
+
106
+ adj: List[np.ndarray] = []
107
+ for s in adj_sets:
108
+ if s:
109
+ arr = np.fromiter(s, dtype=np.int32)
110
+ adj.append(np.sort(arr))
111
+ else:
112
+ adj.append(np.empty(0, dtype=np.int32))
113
+ return adj
114
+
115
+
116
+ def _dsatur_coloring(adj: List[np.ndarray]) -> Tuple[np.ndarray, int]:
117
+ """DSATUR heuristic coloring of the check–check conflict graph.
118
+
119
+ Returns:
120
+ color: np.int32 array of size C with color IDs [0..L-1]
121
+ L: number of colors (layers)
122
+ """
123
+ C = len(adj)
124
+ color = -np.ones(C, dtype=np.int32)
125
+
126
+ # degrees for tie-break
127
+ deg = np.array([len(a) for a in adj], dtype=np.int32)
128
+
129
+ # saturation sets: which colors are present among colored neighbors
130
+ sat: List[set[int]] = [set() for _ in range(C)]
131
+
132
+ uncolored: set[int] = set(range(C))
133
+
134
+ def pick_next() -> int:
135
+ # pick vertex with max saturation, tie-break by degree
136
+ best = None
137
+ best_key = None
138
+ for c in uncolored:
139
+ key = (len(sat[c]), deg[c])
140
+ if best is None or key > best_key:
141
+ best = c
142
+ best_key = key
143
+ return int(best) # type: ignore[arg-type]
144
+
145
+ while uncolored:
146
+ c = pick_next()
147
+
148
+ # forbidden colors from colored neighbors
149
+ forbidden: set[int] = set()
150
+ for nb in adj[c]:
151
+ nb_i = int(nb)
152
+ if color[nb_i] >= 0:
153
+ forbidden.add(int(color[nb_i]))
154
+
155
+ # smallest available color
156
+ k = 0
157
+ while k in forbidden:
158
+ k += 1
159
+ color[c] = k
160
+
161
+ # update saturation of its uncolored neighbors
162
+ for nb in adj[c]:
163
+ nb_i = int(nb)
164
+ if color[nb_i] < 0:
165
+ sat[nb_i].add(k)
166
+
167
+ uncolored.remove(c)
168
+
169
+ L = int(color.max() + 1) if C > 0 else 0
170
+ return color, L
171
+
172
+
173
+ def _color_layers_from_H(H_csr: csr_matrix) -> Tuple[np.ndarray, np.ndarray]:
174
+ """Compute conflict-free layers (graph coloring) from H_csr.
175
+
176
+ Returns:
177
+ check_permutation: np.int32 array of length C giving new row order
178
+ grouped by layer (layer 0, then layer 1, ...)
179
+ layer_sizes: np.int32 array of length L with number of checks per layer
180
+ """
181
+ adj = _build_check_conflict_adjacency(H_csr)
182
+ color, L = _dsatur_coloring(adj)
183
+ if L == 0:
184
+ C = H_csr.shape[0]
185
+ return np.arange(C, dtype=np.int32), np.zeros(0, dtype=np.int32)
186
+
187
+ layers: List[List[int]] = [[] for _ in range(L)]
188
+ for chk, k in enumerate(color):
189
+ layers[int(k)].append(chk)
190
+
191
+ layer_sizes = np.array([len(layer) for layer in layers], dtype=np.int32)
192
+ perm_list: List[int] = []
193
+ for layer in layers:
194
+ perm_list.extend(layer)
195
+ check_permutation = np.asarray(perm_list, dtype=np.int32)
196
+ return check_permutation, layer_sizes
197
+
198
+
199
+ def _clone_bench_state(args: Tuple, meta_base: Dict[str, Any]) -> Tuple[Tuple, Dict[str, Any]]:
200
+ """Return (args, meta_base) with every tensor replaced by a scratch clone.
201
+
202
+ Autotuning benchmarks a kernel by launching it repeatedly on the argument
203
+ tensors it is handed.
204
+ """
205
+ seen: Dict[int, torch.Tensor] = {}
206
+
207
+ def _clone(x):
208
+ if not isinstance(x, torch.Tensor):
209
+ return x
210
+ key = id(x)
211
+ hit = seen.get(key)
212
+ if hit is None:
213
+ hit = x.clone()
214
+ seen[key] = hit
215
+ return hit
216
+
217
+ cloned_args = tuple(_clone(a) for a in args)
218
+ cloned_meta = {k: _clone(v) for k, v in meta_base.items()}
219
+ return cloned_args, cloned_meta
220
+
221
+
222
+ # Values accepted by ``unconverged_correction``.
223
+ UNCONVERGED_CORRECTIONS = ("first_leg", "last_leg")
224
+
225
+
226
+ @dataclass
227
+ class PerfCfg:
228
+ """Performance configuration for kernel tuning.
229
+
230
+ Stores autotuned parameters for different performance modes.
231
+ Corresponds to kernel configuration in the Rust implementation.
232
+ """
233
+
234
+ perf: str
235
+ msg_dtype: str
236
+ btile: int
237
+ rows_per_chk: int
238
+ rows_per_var: int
239
+ c2v_block: int
240
+ c2v_warps: int
241
+ c2v_stages: int
242
+ v2c_block: int
243
+ v2c_warps: int
244
+ v2c_stages: int
245
+
246
+
247
+ class RelayBPDecoder:
248
+ """Relay-BP-S decoder with Triton GPU kernels.
249
+
250
+ Implements the Relay-BP (Relay Belief Propagation) algorithm for quantum error correction
251
+ using GPU-accelerated message passing. The algorithm uses ensemble decoding with disordered
252
+ memory strengths (gamma mixing) to improve decoding performance on quantum error correction codes.
253
+
254
+ This implementation closely follows the Rust reference in crates/relay_bp/src/bp/relay.rs
255
+ and crates/relay_bp/src/bp/min_sum.rs, providing GPU acceleration while maintaining
256
+ algorithmic fidelity to the original min-sum belief propagation with memory.
257
+
258
+ Key features:
259
+ - Batch processing for multiple syndrome samples
260
+ - Two performance modes: default, throughput
261
+ - Support for both plain BP and Relay-BP algorithms
262
+ - Configurable min-sum parameters (alpha, beta)
263
+ - Memory strength (gamma) mixing for ensemble decoding
264
+ - Early stopping after finding stop_nconv valid solutions
265
+ - Optional explicit per-leg gamma matrices (K×V) for deterministic relay legs
266
+ - unconverged_correction selects the correction for a lane with no valid solution:
267
+ the hard decision at the end of the first leg ("first_leg", default) or of the last
268
+ leg that ran ("last_leg")
269
+ """
270
+
271
+ def __init__(
272
+ self,
273
+ H_csr: csr_matrix,
274
+ error_priors: np.ndarray,
275
+ pre_iter: int = 80,
276
+ num_sets: int = 100,
277
+ set_max_iter: int = 60,
278
+ gamma0: float = 0.65,
279
+ gamma_dist_interval: Tuple[float, float] = (-0.24, 0.66),
280
+ stop_nconv: int = 5,
281
+ normalized_min_sum_alpha: Optional[float] = None,
282
+ offset_min_sum_beta: Optional[float] = None,
283
+ data_type: str = "float32",
284
+ device: Union[str, List[int], int] = [0],
285
+ seed: int = 1234,
286
+ gamma_draw: str = "broadcast",
287
+ bitpack_output: bool = False,
288
+ algo: Optional[str] = None, # "relay" or "plain"
289
+ perf: Optional[str] = None, # "default" | "throughput"
290
+ alpha_iteration_scaling_factor: Optional[float] = 1.0,
291
+ explicit_gammas: Optional[np.ndarray] = None,
292
+ gamma_dist_interval_by_error: Optional[np.ndarray] = None,
293
+ fast_max_unconverged_pct: Optional[
294
+ float
295
+ ] = None, # Optional early exit threshold: stop when unconverged percentage <= this value (0.0-1.0)
296
+ scheduling: str = "flood-fill", # "flood-fill" (default) or "layered" (block-layered for first K iterations)
297
+ layered_k_iters: int = 0, # Number of layered iterations per leg (0 = disabled, only used when scheduling="layered")
298
+ layered_block_policy: str = "contiguous", # "contiguous" (default), "permute", "strided_interleaving", "color", or "color_use_delta_trick"
299
+ layered_num_blocks: Optional[
300
+ int
301
+ ] = None, # Explicit number of blocks (if None, computed from target_edges_per_block)
302
+ layered_target_edges_per_block: Optional[
303
+ int
304
+ ] = None, # Target edges per block (default: 200000)
305
+ layered_block_seed: int = 42, # Random seed for "permute" policy
306
+ unconverged_correction: str = "first_leg",
307
+ ):
308
+ """Initialize Relay-BP decoder.
309
+
310
+ Args:
311
+ H_csr: Check matrix (C x V) in CSR format representing the quantum code's
312
+ stabilizer generators. Each row is a check constraint.
313
+ error_priors: [V] error probabilities in (0, 0.5) for each qubit
314
+ pre_iter: T0 - iterations for first set (ordered memory phase)
315
+ num_sets: R - number of relay legs/ensembles (disordered memory phase)
316
+ set_max_iter: Tr - iterations per relay set
317
+ gamma0: γ for first set (ordered memory strength, corresponds to Rust gamma0)
318
+ gamma_dist_interval: (min, max) for disordered γ sampling in relay legs
319
+ stop_nconv: S - number of valid solutions to collect before stopping
320
+ normalized_min_sum_alpha: α for normalized min-sum (0 < α ≤ 1, corresponds to Rust alpha)
321
+ offset_min_sum_beta: β for offset min-sum (mutually exclusive with α)
322
+ data_type: "float16", "float32", or "float64" for uniform precision (storage and computation). Default: "float32".
323
+ device: GPU device selection. Can be:
324
+ - List[int]: Explicit device IDs, e.g. [0, 1, 2, 3] for 4 GPUs
325
+ - int: Number of GPUs to auto-detect, e.g. 4 -> [0, 1, 2, 3]
326
+ - str: Legacy support - "auto" or "cuda" -> [0] (single GPU)
327
+ On ROCm builds, PyTorch uses device type "cuda" for ROCm.
328
+ seed: RNG seed for γ sampling (corresponds to Rust seed)
329
+ gamma_draw: how disordered-leg γ is drawn from gamma_dist_interval or
330
+ gamma_dist_interval_by_error. "broadcast": one [1, V] row per leg from
331
+ seed + leg, shared by every shot. "per_row": one [B, V] draw per leg
332
+ from seed + leg, so a shot's γ depends on its row in the batch.
333
+ Ignored when explicit_gammas is set.
334
+ bitpack_output: whether to return packed error bits for memory efficiency
335
+ algo: "relay" or "plain" algorithm mode
336
+ perf: "default" or "throughput" performance mode
337
+ alpha_iteration_scaling_factor: scaling factor for alpha ramp schedule
338
+ explicit_gammas: optional np.ndarray of shape (K, V) providing per-leg, per-variable
339
+ memory strengths γ. Relay leg l (0-based) uses row ((l + 1) % K), the row Rust
340
+ uses for set l + 1. When provided, uniform γ sampling is disabled.
341
+ fast_max_unconverged_pct: Optional early exit threshold (0.0-1.0) for difficult case collection.
342
+ If set, decoder stops when the percentage of unconverged syndromes drops to this threshold
343
+ or below (e.g., 0.1 = stop when <=10% unconverged) and returns unconverged indices.
344
+ If None, runs full num_sets relay legs. Default: None.
345
+ When using for difficult case collection, collect unconverged_indices across batches
346
+ and process them separately at the end with full num_sets.
347
+ scheduling: Iteration scheduling mode. "flood-fill" (default) processes all checks then all vars.
348
+ "layered" uses block-layered scheduling for first K iterations (Gauss-Seidel style), then
349
+ switches to flood-fill. Only supported in throughput mode.
350
+ layered_k_iters: Number of layered iterations per leg (default: 0, disabled). When scheduling="layered"
351
+ and layered_k_iters > 0, first K iterations use block-layered, remaining use flood-fill.
352
+ Only used when scheduling="layered" and perf="throughput".
353
+ unconverged_correction: Correction returned for a lane with no valid solution in
354
+ any leg. "first_leg" (default, as in the Rust reference) returns the hard
355
+ decision at the end of the pre-iter leg, or of relay leg 1 when pre_iter is 0.
356
+ "last_leg" returns the hard decision at the end of the last leg that ran,
357
+ including a leg after which fast_max_unconverged_pct stops the decode.
358
+
359
+ Raises:
360
+ ValueError: If unconverged_correction is not one of UNCONVERGED_CORRECTIONS.
361
+ """
362
+ if unconverged_correction not in UNCONVERGED_CORRECTIONS:
363
+ raise ValueError(
364
+ f"unconverged_correction must be one of {UNCONVERGED_CORRECTIONS} "
365
+ f"(got {unconverged_correction!r})"
366
+ )
367
+ self.unconverged_correction = unconverged_correction
368
+ # Validate inputs
369
+ validate_csr_matrix(H_csr)
370
+ error_priors = validate_error_priors(error_priors)
371
+
372
+ if normalized_min_sum_alpha is not None and offset_min_sum_beta is not None:
373
+ raise ValueError("Cannot specify both normalized_min_sum_alpha and offset_min_sum_beta")
374
+
375
+ # Allow alpha==0.0 to enable the ramp schedule (Rust-like semantics)
376
+ if normalized_min_sum_alpha is not None and not (0 <= normalized_min_sum_alpha <= 1):
377
+ raise ValueError("normalized_min_sum_alpha must be in [0, 1]")
378
+
379
+ if offset_min_sum_beta is not None and offset_min_sum_beta < 0:
380
+ raise ValueError("offset_min_sum_beta must be >= 0")
381
+ # Store parameters
382
+ self.H_csr = H_csr
383
+ # Store check_permutation if used (needed for syndrome permutation in adapter)
384
+ self.check_permutation = None
385
+ self.error_priors = error_priors
386
+ self.pre_iter = pre_iter
387
+ self.num_sets = num_sets
388
+ self.set_max_iter = set_max_iter
389
+ self.gamma0 = gamma0
390
+ self.gamma_dist_interval = gamma_dist_interval
391
+ self.stop_nconv = stop_nconv
392
+ self.fast_max_unconverged_pct = fast_max_unconverged_pct
393
+ if fast_max_unconverged_pct is not None:
394
+ if not (0.0 <= fast_max_unconverged_pct <= 1.0):
395
+ raise ValueError(
396
+ f"fast_max_unconverged_pct must be between 0.0 and 1.0, got {fast_max_unconverged_pct}"
397
+ )
398
+ self.normalized_min_sum_alpha = normalized_min_sum_alpha
399
+ self.offset_min_sum_beta = offset_min_sum_beta
400
+
401
+ # Unified dtype system: support float16, float32, float64
402
+ _dtype_aliases = {
403
+ "float": "float32",
404
+ "half": "float16",
405
+ "double": "float64",
406
+ "fp16": "float16",
407
+ "fp32": "float32",
408
+ "fp64": "float64",
409
+ }
410
+ data_type = _dtype_aliases.get(data_type, data_type)
411
+ if data_type not in ("float16", "float32", "float64"):
412
+ raise ValueError(
413
+ f"data_type must be 'float16', 'float32', or 'float64', got {data_type}"
414
+ )
415
+
416
+ self.dtype_str = data_type
417
+ self.dtype = {
418
+ "float16": torch.float16,
419
+ "float32": torch.float32,
420
+ "float64": torch.float64,
421
+ }[data_type]
422
+ # Cache enum once to avoid recomputing it on every kernel launch
423
+ self._dtype_enum = dtype_str_to_enum(self.dtype_str)
424
+ # Parse device specification to list of device IDs
425
+ self.device_ids = self._parse_device_spec(device)
426
+ self.num_gpus = len(self.device_ids)
427
+
428
+ # Detect backend (CUDA or ROCm) - PyTorch uses "cuda" device type for both.
429
+ has_gpu = bool(torch.cuda.is_available())
430
+ is_rocm = getattr(torch.version, "hip", None) is not None
431
+ is_cuda_drv = torch.version.cuda is not None
432
+ if not has_gpu:
433
+ raise RuntimeError(
434
+ "GPU not available (install a GPU-enabled PyTorch build: CUDA or ROCm)"
435
+ )
436
+ # Track backend for logging/metrics only (device type is always "cuda" for both CUDA and ROCm)
437
+ self._backend = "rocm" if (is_rocm and not is_cuda_drv) else "cuda"
438
+ # Device type string for PyTorch (works for both CUDA and ROCm)
439
+ self.device_type = "cuda"
440
+ # Cache per-GPU device strings to avoid rebuilding on every use
441
+ self._device_strs: Dict[int, str] = {
442
+ gpu_id: f"{self.device_type}:{gpu_id}" for gpu_id in self.device_ids
443
+ }
444
+
445
+ self.seed = seed
446
+ if gamma_draw not in GAMMA_DRAW_MODES:
447
+ raise ValueError(f"gamma_draw must be one of {GAMMA_DRAW_MODES}; got {gamma_draw!r}")
448
+ self.gamma_draw = gamma_draw
449
+ self.bitpack_output = bitpack_output
450
+ # Normalize separate axes
451
+ algo_norm = algo
452
+ if algo_norm not in ("relay", "plain"):
453
+ raise ValueError(f"Unknown algo '{algo_norm}'. Use 'relay' or 'plain'.")
454
+ self.algo = algo_norm
455
+
456
+ perf_norm = (perf or "throughput").strip().lower()
457
+ perf_aliases = {
458
+ "default": "default",
459
+ "row": "default",
460
+ "rowwise": "default",
461
+ "throughput": "throughput",
462
+ "btile": "throughput",
463
+ }
464
+ if perf_norm not in perf_aliases:
465
+ raise ValueError(f"Unknown perf '{perf_norm}'. Use 'default'/'throughput'.")
466
+ self.perf = perf_aliases[perf_norm]
467
+ self.alpha_iteration_scaling_factor = float(alpha_iteration_scaling_factor or 1.0)
468
+
469
+ # Scheduling validation
470
+ scheduling_norm = (scheduling or "flood-fill").strip().lower()
471
+ if scheduling_norm not in ("flood-fill", "layered"):
472
+ raise ValueError(
473
+ f"Unknown scheduling '{scheduling_norm}'. Use 'flood-fill' or 'layered'."
474
+ )
475
+ self.scheduling = scheduling_norm
476
+ self.layered_k_iters = int(layered_k_iters) if layered_k_iters is not None else 0
477
+ # Layered scheduling only works in throughput mode
478
+ if self.scheduling == "layered" and self.perf != "throughput":
479
+ raise ValueError("scheduling='layered' requires perf='throughput'")
480
+ # Explicit gammas (for relay legs only). Replicate on each GPU as [K,V] fp32.
481
+ self.explicit_gammas_per_gpu = None
482
+ if explicit_gammas is not None:
483
+ eg = np.asarray(explicit_gammas, dtype=np.float32)
484
+ if eg.ndim != 2 or eg.shape[1] != int(H_csr.shape[1]):
485
+ raise ValueError(f"explicit_gammas must have shape (K, V={int(H_csr.shape[1])})")
486
+ # Replicate on each GPU
487
+ self.explicit_gammas_per_gpu = []
488
+ for gpu_id in self.device_ids:
489
+ device_str = self._device_strs[gpu_id]
490
+ eg_gpu = torch.from_numpy(eg).to(device_str, dtype=self.dtype)
491
+ self.explicit_gammas_per_gpu.append(eg_gpu)
492
+
493
+ # Per-variable gamma intervals (local gammas feature).
494
+ # Mirrors Rust `gamma_dist_interval_by_error` behavior in crates/relay_bp/src/bp/relay.rs.
495
+ # Stored as [V, 2] tensors (low, high) replicated per GPU.
496
+ self.gamma_dist_interval_by_error_per_gpu = None
497
+ if gamma_dist_interval_by_error is not None:
498
+ gd = np.asarray(gamma_dist_interval_by_error, dtype=np.float32)
499
+ V = int(H_csr.shape[1])
500
+ if gd.ndim != 2 or gd.shape[0] != V or gd.shape[1] != 2:
501
+ raise ValueError(
502
+ f"gamma_dist_interval_by_error must have shape (V={V}, 2), got {tuple(gd.shape)}"
503
+ )
504
+ if np.any(gd[:, 0] > gd[:, 1]):
505
+ raise ValueError(
506
+ "gamma_dist_interval_by_error: lower bounds must be <= upper bounds"
507
+ )
508
+ self.gamma_dist_interval_by_error_per_gpu = []
509
+ for gpu_id in self.device_ids:
510
+ device_str = self._device_strs[gpu_id]
511
+ gd_gpu = torch.from_numpy(gd).to(device_str, dtype=self.dtype)
512
+ self.gamma_dist_interval_by_error_per_gpu.append(gd_gpu)
513
+
514
+ # Set up device (availability already validated above)
515
+
516
+ # Build block metadata for layered scheduling
517
+ # For "permute", "strided_interleaving", and "color" policies, we need to apply
518
+ # a permutation before building graphs
519
+ self.block_metadata = None
520
+ self.block_overlap_stats = None
521
+ self._color_layer_sizes: Optional[np.ndarray] = None
522
+ self.layered_block_policy = layered_block_policy
523
+ self._use_delta_trick: bool = (
524
+ self.scheduling == "layered"
525
+ and self.perf == "throughput"
526
+ and layered_block_policy == "color_use_delta_trick"
527
+ )
528
+ check_permutation: Optional[np.ndarray] = None
529
+ if self.scheduling == "layered" and self.perf == "throughput":
530
+ # Validate layered_block_policy
531
+ if layered_block_policy not in (
532
+ "contiguous",
533
+ "permute",
534
+ "strided_interleaving",
535
+ "color",
536
+ "color_use_delta_trick",
537
+ ):
538
+ raise ValueError(
539
+ f"layered_block_policy must be 'contiguous', 'permute', 'strided_interleaving', 'color', "
540
+ f"or 'color_use_delta_trick', got {layered_block_policy!r}"
541
+ )
542
+
543
+ # Apply check permutation if needed
544
+ if layered_block_policy == "permute":
545
+ # Random permutation of checks (deterministic with seed)
546
+ # Use default_rng to avoid global RNG state mutation
547
+ rng = np.random.default_rng(layered_block_seed)
548
+ check_permutation = rng.permutation(self.H_csr.shape[0]).astype(np.int32)
549
+ # Store permutation for syndrome reordering in adapter
550
+ self.check_permutation = check_permutation
551
+ elif layered_block_policy == "strided_interleaving":
552
+ # Cyclic interleaving: check k goes to group (k % num_groups)
553
+ # Exploits quasi-cyclic structure of bicycle codes to spread checks across blocks.
554
+ if layered_num_blocks is None:
555
+ raise ValueError(
556
+ "layered_block_policy='strided_interleaving' requires layered_num_blocks to be set"
557
+ )
558
+ C = self.H_csr.shape[0]
559
+ num_groups = int(layered_num_blocks)
560
+ check_permutation = np.array(
561
+ [i for g in range(num_groups) for i in range(g, C, num_groups)],
562
+ dtype=np.int32,
563
+ )
564
+ self.check_permutation = check_permutation
565
+ elif layered_block_policy in ("color", "color_use_delta_trick"):
566
+ # Conflict-free layers via DSATUR coloring. Each layer contains checks that do not
567
+ # share variables (overlap=0 within a layer). We build a permutation that groups
568
+ # layers contiguously.
569
+ check_permutation, layer_sizes = _color_layers_from_H(self.H_csr)
570
+ self.check_permutation = check_permutation
571
+ self._color_layer_sizes = layer_sizes
572
+
573
+ # Create graph on each GPU (read-only, can be replicated).
574
+ # Apply check_permutation if it was computed above.
575
+ self.graphs = []
576
+ for gpu_id in self.device_ids:
577
+ device_str = self._device_strs[gpu_id]
578
+ graph = CSRGraph(H_csr, device_str, check_permutation=check_permutation)
579
+ self.graphs.append(graph)
580
+
581
+ # Set graph shape info
582
+ self.C, self.V = self.graphs[0].C, self.graphs[0].V
583
+ self.E = self.graphs[0].E
584
+
585
+ # Build block metadata for layered scheduling
586
+ if self.scheduling == "layered" and self.perf == "throughput":
587
+ graph0 = self.graphs[0]
588
+
589
+ # First, construct block check ranges and variable lists depending on policy
590
+ if (
591
+ layered_block_policy in ("color", "color_use_delta_trick")
592
+ and self._color_layer_sizes is not None
593
+ ):
594
+ # One block per color layer; after applying check_permutation, each layer
595
+ # occupies a contiguous range of checks.
596
+ layer_sizes = self._color_layer_sizes
597
+ num_blocks = int(layer_sizes.shape[0])
598
+ block_chk_start_cpu = np.zeros(num_blocks, dtype=np.int32)
599
+ block_chk_end_cpu = np.zeros(num_blocks, dtype=np.int32)
600
+ offset = 0
601
+ for b in range(num_blocks):
602
+ block_chk_start_cpu[b] = offset
603
+ offset += int(layer_sizes[b])
604
+ block_chk_end_cpu[b] = offset
605
+
606
+ # Build per-block variable lists using the same CSR-based logic as build_block_metadata
607
+ chk_ptr_host = graph0.chk_ptr.cpu().numpy()
608
+ chk_edges_host = graph0.chk_edges.cpu().numpy()
609
+ edge_var_host = graph0.edge_var.cpu().numpy()
610
+
611
+ block_vars_list: List[List[int]] = []
612
+ block_var_ptr_list = [0]
613
+
614
+ for b in range(num_blocks):
615
+ chk_start = int(block_chk_start_cpu[b])
616
+ chk_end = int(block_chk_end_cpu[b])
617
+
618
+ e0 = chk_ptr_host[chk_start]
619
+ e1 = chk_ptr_host[chk_end]
620
+
621
+ if e1 > e0:
622
+ all_edge_ids = chk_edges_host[e0:e1]
623
+ all_var_ids = edge_var_host[all_edge_ids]
624
+ block_vars_sorted = np.unique(all_var_ids).astype(np.int32)
625
+ else:
626
+ block_vars_sorted = np.array([], dtype=np.int32)
627
+
628
+ block_vars_list.append(block_vars_sorted.tolist())
629
+ block_var_ptr_list.append(block_var_ptr_list[-1] + len(block_vars_sorted))
630
+
631
+ total_vars = block_var_ptr_list[-1]
632
+ block_vars_flat = np.zeros(total_vars, dtype=np.int32)
633
+ for b, var_list in enumerate(block_vars_list):
634
+ start = block_var_ptr_list[b]
635
+ end = block_var_ptr_list[b + 1]
636
+ block_vars_flat[start:end] = var_list
637
+
638
+ dev = graph0.chk_ptr.device
639
+ block_chk_start = torch.from_numpy(block_chk_start_cpu).to(dev)
640
+ block_chk_end = torch.from_numpy(block_chk_end_cpu).to(dev)
641
+ block_var_ptr = torch.from_numpy(np.array(block_var_ptr_list, dtype=np.int32)).to(
642
+ dev
643
+ )
644
+ block_vars = torch.from_numpy(block_vars_flat).to(dev)
645
+
646
+ block_var_ptr_cpu = block_var_ptr.cpu().numpy()
647
+ else:
648
+ # Default edge-balanced block construction (contiguous / permute / strided_interleaving)
649
+ block_chk_start, block_chk_end, block_var_ptr, block_vars, num_blocks = (
650
+ graph0.build_block_metadata(
651
+ num_blocks=layered_num_blocks,
652
+ target_edges_per_block=layered_target_edges_per_block,
653
+ )
654
+ )
655
+ block_chk_start_cpu = block_chk_start.cpu().numpy()
656
+ block_chk_end_cpu = block_chk_end.cpu().numpy()
657
+ block_var_ptr_cpu = block_var_ptr.cpu().numpy()
658
+
659
+ # Compute block overlap diagnostics (for debugging/validation)
660
+ block_overlap_stats = self._compute_block_overlap_stats(
661
+ block_chk_start_cpu,
662
+ block_chk_end_cpu,
663
+ block_var_ptr_cpu,
664
+ block_vars.cpu().numpy(),
665
+ )
666
+
667
+ # Log block statistics for validation/optimization
668
+ import logging
669
+
670
+ logger = logging.getLogger(__name__)
671
+ if logger.isEnabledFor(logging.INFO):
672
+ logger.info(
673
+ f"Layered scheduling: {num_blocks} blocks, policy='{layered_block_policy}'"
674
+ )
675
+ logger.info(
676
+ f" Inter-block overlap (adj blocks share): "
677
+ f"avg={block_overlap_stats['avg_overlap']:.3f}, "
678
+ f"min={block_overlap_stats['min_overlap']:.3f}, "
679
+ f"max={block_overlap_stats['max_overlap']:.3f}"
680
+ )
681
+ if "max_intra_block_overlap" in block_overlap_stats:
682
+ logger.info(
683
+ f" Max intra-block overlap (max checks/var within block): "
684
+ f"{block_overlap_stats['max_intra_block_overlap']}"
685
+ )
686
+ # Per-block details at DEBUG level
687
+ if logger.isEnabledFor(logging.DEBUG):
688
+ chk_ptr_host = graph0.chk_ptr.cpu().numpy()
689
+ for b in range(num_blocks):
690
+ chk_start = int(block_chk_start_cpu[b])
691
+ chk_end = int(block_chk_end_cpu[b])
692
+ var_start = int(block_var_ptr_cpu[b])
693
+ var_end = int(block_var_ptr_cpu[b + 1])
694
+ e0 = chk_ptr_host[chk_start]
695
+ e1 = chk_ptr_host[chk_end]
696
+ edges_in_block = e1 - e0
697
+ vars_in_block = var_end - var_start
698
+ overlap_ratio = (
699
+ block_overlap_stats["overlap_ratios"][b - 1] if b > 0 else 0.0
700
+ )
701
+ logger.debug(
702
+ f" Block {b}: checks [{chk_start}, {chk_end}), "
703
+ f"edges={edges_in_block}, vars={vars_in_block}, "
704
+ f"overlap_with_prev={overlap_ratio:.3f}"
705
+ )
706
+
707
+ # Store metadata and CPU copies
708
+ self.block_metadata = {
709
+ "block_chk_start": block_chk_start,
710
+ "block_chk_end": block_chk_end,
711
+ "block_var_ptr": block_var_ptr,
712
+ "block_vars": block_vars,
713
+ "num_blocks": num_blocks,
714
+ "block_chk_start_cpu": block_chk_start_cpu,
715
+ "block_chk_end_cpu": block_chk_end_cpu,
716
+ "block_var_ptr_cpu": block_var_ptr_cpu,
717
+ "overlap_stats": block_overlap_stats,
718
+ }
719
+ self._block_chk_start_cpu = block_chk_start_cpu
720
+ self._block_chk_end_cpu = block_chk_end_cpu
721
+ self._block_var_ptr_cpu = block_var_ptr_cpu
722
+ self.block_overlap_stats = block_overlap_stats
723
+
724
+ # Compute log prior ratios (Rust: log_prior_ratios)
725
+ log_ratios = compute_log_prior_ratios(error_priors)
726
+ # Replicate log_prior_ratios on each GPU
727
+ self.log_prior_ratios_per_gpu = []
728
+ for gpu_id in self.device_ids:
729
+ device_str = self._device_strs[gpu_id]
730
+ # Use unified dtype for log_prior_ratios
731
+ log_prior = (
732
+ torch.from_numpy(log_ratios).to(device_str, dtype=self.dtype).unsqueeze(0)
733
+ ) # [1, V]
734
+ self.log_prior_ratios_per_gpu.append(log_prior)
735
+
736
+ # Precompute decoding weights for solution selection (matches Rust decoding_quality)
737
+ # Weight = sum of log-priors for error bits, used to select best solutions
738
+ # Rust: decoding_quality += log_prior_ratios[i] when errors[i] == 1 and is_finite(log_prior_ratios[i])
739
+ # log_prior_ratios can be negative when error probability > 0.5
740
+ # Use first GPU's log_prior_ratios (all are identical)
741
+ wj = (
742
+ self.log_prior_ratios_per_gpu[0]
743
+ .squeeze(0)
744
+ .to(self._device_strs[self.device_ids[0]], dtype=self.dtype)
745
+ ) # [V]
746
+ # Filter finite values: set non-finite to 0 (matches Rust's is_finite check)
747
+ wj = torch.where(torch.isfinite(wj), wj, torch.zeros_like(wj))
748
+ self.wj = wj.contiguous()
749
+ # Cache wj on each GPU to avoid .to(...) on every selection call
750
+ self.wj_per_gpu: Dict[int, torch.Tensor] = {}
751
+ for gpu_id in self.device_ids:
752
+ device_str = self._device_strs[gpu_id]
753
+ self.wj_per_gpu[gpu_id] = self.wj.to(device_str)
754
+
755
+
756
+
757
+
758
+ # Performance configuration (fallback values, overridden by autotuning)
759
+ self.cfg = PerfCfg(
760
+ perf=self.perf,
761
+ msg_dtype=self.dtype_str,
762
+ btile=64, # fallback only; tuned meta overrides
763
+ rows_per_chk=16, # fallback only
764
+ rows_per_var=16, # fallback only
765
+ c2v_block=0,
766
+ c2v_warps=0,
767
+ c2v_stages=0,
768
+ v2c_block=0,
769
+ v2c_warps=0,
770
+ v2c_stages=0,
771
+ )
772
+
773
+ # Set up RNG
774
+ torch.manual_seed(seed)
775
+
776
+ # Pre-allocate device tensors (will be allocated in decode)
777
+ # Per-GPU tensor caches for multi-GPU support
778
+ self._device_tensors_per_gpu = {gpu_id: {} for gpu_id in self.device_ids}
779
+ # Cache dummy L tensor per GPU (used when L is not allocated but kernel requires valid pointer)
780
+ self._dummy_L_per_gpu = {}
781
+
782
+ # Optional logging for cached tuning usage
783
+ self._tune_log = os.getenv("RELAY_TUNE_VERBOSE", "0") == "1"
784
+ self._tune_logged: set[str] = set()
785
+
786
+ def override_llr_columns(self, cols, value):
787
+ """Override the prior log-likelihood-ratio (LLR) on specific variable cols.
788
+
789
+ The per-variable prior LLR is ln((1-p)/p). Building from an
790
+ error_priors probability cannot express a belief that a fault did
791
+ fire, because priors are clipped to <= 0.5 (LLR >= 0). This method
792
+ writes such values directly into the kernel's init-LLR tensor
793
+ (log_prior_ratios_per_gpu) and the solution-selection weights
794
+ (wj / wj_per_gpu), bypassing the clip. Useful for staged decoding
795
+ schemes that set per-column priors between rounds.
796
+
797
+ Args:
798
+ cols: 1-D int array of variable (column) indices to override.
799
+ value: LLR to write. Negative => biased toward firing. Either a
800
+ scalar (same LLR on all cols) or a length-len(cols) array
801
+ (per-column graded LLRs, e.g. confidence-graded conditioning).
802
+
803
+ Returns:
804
+ A restore-callable that reverts exactly these entries to their
805
+ previous values when invoked (so callers can patch-per-shot then
806
+ restore cheaply, no decoder rebuild).
807
+ """
808
+ cols = np.asarray(cols, dtype=np.int64)
809
+ # Save all originals before writing anything: self.wj and
810
+ # wj_per_gpu[gpu] may alias the same tensor (single-GPU case), so
811
+ # interleaving save/write would capture already-overwritten values.
812
+ saved_lpr = [
813
+ (gpu_idx, lpr[0, cols].clone())
814
+ for gpu_idx, lpr in enumerate(self.log_prior_ratios_per_gpu)
815
+ ]
816
+ saved_wj = {gpu_id: wj[cols].clone() for gpu_id, wj in self.wj_per_gpu.items()}
817
+ saved_self_wj = self.wj[cols].clone()
818
+
819
+ # value may be a scalar (broadcasts) or a length-len(cols) array
820
+ # (per-column LLRs supplied by a caller).
821
+ # Cast per-tensor to match device/dtype before advanced-index assign.
822
+ for gpu_idx, lpr in enumerate(self.log_prior_ratios_per_gpu):
823
+ lpr[0, cols] = torch.as_tensor(value, dtype=lpr.dtype, device=lpr.device)
824
+ for gpu_id, wj in self.wj_per_gpu.items():
825
+ wj[cols] = torch.as_tensor(value, dtype=wj.dtype, device=wj.device)
826
+ self.wj[cols] = torch.as_tensor(value, dtype=self.wj.dtype, device=self.wj.device)
827
+
828
+ def _restore():
829
+ for gpu_idx, old in saved_lpr:
830
+ self.log_prior_ratios_per_gpu[gpu_idx][0, cols] = old
831
+ for gpu_id, old in saved_wj.items():
832
+ self.wj_per_gpu[gpu_id][cols] = old
833
+ self.wj[cols] = saved_self_wj
834
+
835
+ return _restore
836
+
837
+ def _compute_block_overlap_stats(
838
+ self, block_chk_start, block_chk_end, block_var_ptr, block_vars
839
+ ):
840
+ """Compute block overlap statistics for diagnostics.
841
+
842
+ Returns:
843
+ dict with overlap metrics:
844
+ - var_counts: list of variable counts per block
845
+ - overlap_ratios: list of overlap ratios between adjacent blocks
846
+ - avg_overlap: average overlap ratio
847
+ - max_intra_block_overlap: maximum checks-per-variable within any single block
848
+ """
849
+ num_blocks = len(block_chk_start)
850
+ var_counts = []
851
+ overlap_ratios = []
852
+
853
+ # Get variable sets for each block
854
+ block_var_sets = []
855
+ for b in range(num_blocks):
856
+ var_start = int(block_var_ptr[b])
857
+ var_end = int(block_var_ptr[b + 1])
858
+ var_set = set(block_vars[var_start:var_end])
859
+ block_var_sets.append(var_set)
860
+ var_counts.append(len(var_set))
861
+
862
+ # Compute max intra-block overlap (max checks per variable within a block)
863
+ max_intra = 0
864
+ if hasattr(self, "graphs") and self.graphs:
865
+ chk_ptr_host = self.graphs[0].chk_ptr.cpu().numpy().astype(np.int32)
866
+ edge_var_host = self.graphs[0].edge_var.cpu().numpy().astype(np.int32)
867
+ for b in range(num_blocks):
868
+ chk_start = int(block_chk_start[b])
869
+ chk_end = int(block_chk_end[b])
870
+ var_counts_in_block: Dict[int, int] = {}
871
+ for c in range(chk_start, chk_end):
872
+ e0 = chk_ptr_host[c]
873
+ e1 = chk_ptr_host[c + 1]
874
+ for e in range(e0, e1):
875
+ v = int(edge_var_host[e])
876
+ var_counts_in_block[v] = var_counts_in_block.get(v, 0) + 1
877
+ if var_counts_in_block:
878
+ local_max = max(var_counts_in_block.values())
879
+ if local_max > max_intra:
880
+ max_intra = local_max
881
+
882
+ # Compute overlap between adjacent blocks
883
+ for b in range(num_blocks - 1):
884
+ var_set_i = block_var_sets[b]
885
+ var_set_j = block_var_sets[b + 1]
886
+ intersection = var_set_i & var_set_j
887
+ min_size = min(len(var_set_i), len(var_set_j))
888
+ if min_size > 0:
889
+ overlap_ratio = len(intersection) / min_size
890
+ else:
891
+ overlap_ratio = 0.0
892
+ overlap_ratios.append(overlap_ratio)
893
+
894
+ avg_overlap = np.mean(overlap_ratios) if overlap_ratios else 0.0
895
+
896
+ return {
897
+ "var_counts": var_counts,
898
+ "overlap_ratios": overlap_ratios,
899
+ "avg_overlap": float(avg_overlap),
900
+ "min_overlap": float(min(overlap_ratios)) if overlap_ratios else 0.0,
901
+ "max_overlap": float(max(overlap_ratios)) if overlap_ratios else 0.0,
902
+ "max_intra_block_overlap": int(max_intra),
903
+ }
904
+
905
+ def _get_tuned_meta(
906
+ self,
907
+ *,
908
+ kernel_name: str,
909
+ problem_key: Dict[str, Any],
910
+ build_cfgs_fn,
911
+ kernel,
912
+ grid,
913
+ args: Tuple,
914
+ meta_base: Dict[str, Any],
915
+ number: int = 20,
916
+ grid_fn=None,
917
+ log_name: Optional[str] = None,
918
+ ) -> Dict[str, Any]:
919
+ """Shared helper for cache-based autotuning.
920
+
921
+ Wraps the common pattern:
922
+ - try_get_saved
923
+ - bench_and_select if missing
924
+ - set_saved
925
+ - optional one-time logging via self._tune_log / self._tune_logged
926
+ """
927
+ saved = ac.try_get_saved(kernel_name, problem_key)
928
+ if saved is None:
929
+ cfgs = [
930
+ dict(c.kwargs, num_warps=c.num_warps, num_stages=c.num_stages)
931
+ for c in build_cfgs_fn()
932
+ ]
933
+ # Tune on scratch clones so benchmarking is side-effect free.
934
+ bench_args, bench_meta = _clone_bench_state(args, meta_base)
935
+ best = ac.bench_and_select(
936
+ kernel=kernel,
937
+ grid=grid,
938
+ args=bench_args,
939
+ meta_base=bench_meta,
940
+ configs=cfgs,
941
+ number=number,
942
+ grid_fn=grid_fn,
943
+ )
944
+ del bench_args, bench_meta
945
+ ac.set_saved(kernel_name, problem_key, best)
946
+ saved = best
947
+
948
+ name = log_name or kernel_name
949
+ if self._tune_log and name not in self._tune_logged:
950
+ config_summary = {}
951
+ for key in [
952
+ "BLOCK_SIZE",
953
+ "ROWS_PER_CHK",
954
+ "ROWS_PER_VAR",
955
+ "BTILE",
956
+ "BLOCK_C",
957
+ "BLOCK_V",
958
+ "num_warps",
959
+ "num_stages",
960
+ ]:
961
+ if key in saved:
962
+ config_summary[key] = saved[key]
963
+ config_str = f" config={config_summary}" if config_summary else ""
964
+ print(
965
+ f"[relay-bp-triton][{name}] Using cached meta from {ac.DEFAULT_CACHE} key={problem_key}{config_str}"
966
+ )
967
+ self._tune_logged.add(name)
968
+
969
+ return saved
970
+
971
+ def _resolve_configs(self, B, tensors, graph, gpu_idx):
972
+ """Resolve all autotuning configs once per decode call."""
973
+ device_str = self._device_strs[self.device_ids[gpu_idx]]
974
+ log_prior_ratios = self.log_prior_ratios_per_gpu[gpu_idx]
975
+ alpha = self.normalized_min_sum_alpha or 0.0
976
+ beta = self.offset_min_sum_beta or 0.0
977
+ use_alpha = self.normalized_min_sum_alpha is not None
978
+ use_beta = self.offset_min_sum_beta is not None
979
+
980
+ # Full-batch active index for benchmarking
981
+ active_idx = torch.arange(B, device=device_str, dtype=torch.int32)
982
+ B_active = B
983
+
984
+ if self.cfg.perf == "throughput":
985
+ self._resolve_throughput_configs(
986
+ B,
987
+ B_active,
988
+ active_idx,
989
+ tensors,
990
+ graph,
991
+ gpu_idx,
992
+ device_str,
993
+ log_prior_ratios,
994
+ alpha,
995
+ beta,
996
+ use_alpha,
997
+ use_beta,
998
+ )
999
+ else:
1000
+ self._resolve_default_configs(
1001
+ B,
1002
+ tensors,
1003
+ graph,
1004
+ gpu_idx,
1005
+ device_str,
1006
+ log_prior_ratios,
1007
+ alpha,
1008
+ beta,
1009
+ use_alpha,
1010
+ use_beta,
1011
+ )
1012
+
1013
+ def _resolve_throughput_configs(
1014
+ self,
1015
+ B,
1016
+ B_active,
1017
+ active_idx,
1018
+ tensors,
1019
+ graph,
1020
+ gpu_idx,
1021
+ device_str,
1022
+ log_prior_ratios,
1023
+ alpha,
1024
+ beta,
1025
+ use_alpha,
1026
+ use_beta,
1027
+ ):
1028
+ """Resolve autotuning configs for throughput (btile) mode."""
1029
+ # --- C2V btile ---
1030
+ c2v_key = {
1031
+ "B": B,
1032
+ "C": self.C,
1033
+ "E": self.E,
1034
+ "dtype": self.dtype_str,
1035
+ "sparsity_hash": graph.sparsity_hash,
1036
+ }
1037
+ dummy_L = self._dummy_L_per_gpu.get(gpu_idx)
1038
+ if dummy_L is None:
1039
+ dummy_L = torch.zeros(1, 1, dtype=self.dtype, device=device_str)
1040
+ self._dummy_L_per_gpu[gpu_idx] = dummy_L
1041
+
1042
+ def _c2v_grid_fn(cfg):
1043
+ bt = int(cfg.get("BTILE", self.cfg.btile))
1044
+ return (self.C, (B_active + bt - 1) // bt)
1045
+
1046
+ saved = self._get_tuned_meta(
1047
+ kernel_name="c2v_min_sum_btile_kernel",
1048
+ problem_key=c2v_key,
1049
+ build_cfgs_fn=build_c2v_btile_configs,
1050
+ kernel=c2v_min_sum_btile_kernel,
1051
+ grid=(self.C, 1),
1052
+ args=(
1053
+ tensors["muT"],
1054
+ tensors["nuT"],
1055
+ graph.chk_ptr,
1056
+ graph.chk_edges,
1057
+ tensors["syndrome"],
1058
+ active_idx,
1059
+ tensors["active"],
1060
+ ),
1061
+ meta_base=dict(
1062
+ B=B,
1063
+ B_active=B_active,
1064
+ C=self.C,
1065
+ E=self.E,
1066
+ alpha=alpha,
1067
+ beta=beta,
1068
+ use_alpha=use_alpha,
1069
+ use_beta=use_beta,
1070
+ dtype_enum=self._dtype_enum,
1071
+ chk_start=0,
1072
+ chk_end=0,
1073
+ edge_var=graph.edge_var,
1074
+ L=dummy_L,
1075
+ V=self.V,
1076
+ use_delta=False,
1077
+ ),
1078
+ number=20,
1079
+ grid_fn=_c2v_grid_fn,
1080
+ log_name=f"c2v_min_sum_btile_kernel[gpu={gpu_idx}]",
1081
+ )
1082
+ self._rc_c2v_btile = int(saved.get("BTILE", self.cfg.btile))
1083
+ self._rc_c2v_block = int(saved.get("BLOCK_SIZE", 64))
1084
+ self._rc_c2v_warps = int(saved.get("num_warps", 0))
1085
+ self._rc_c2v_stages = int(saved.get("num_stages", 0))
1086
+
1087
+ # --- V2C btile ---
1088
+ v2c_key = {
1089
+ "B": B,
1090
+ "V": self.V,
1091
+ "E": self.E,
1092
+ "dtype": self.dtype_str,
1093
+ "WRITE_HARD": True,
1094
+ "sparsity_hash": graph.sparsity_hash,
1095
+ "BLOCK_SIZE": V2C_BTILE_BLOCK_SIZE,
1096
+ }
1097
+
1098
+ def _v2c_grid_fn(cfg):
1099
+ bt = int(cfg.get("BTILE", self.cfg.btile))
1100
+ return (self.V, (B_active + bt - 1) // bt)
1101
+
1102
+ saved = self._get_tuned_meta(
1103
+ kernel_name="v2c_and_gamma_btile_kernel",
1104
+ problem_key=v2c_key,
1105
+ build_cfgs_fn=build_v2c_btile_configs,
1106
+ kernel=v2c_and_gamma_btile_kernel,
1107
+ grid=(self.V, 1),
1108
+ args=(
1109
+ tensors["nuT"],
1110
+ tensors["muT"],
1111
+ graph.var_ptr,
1112
+ graph.var_edges,
1113
+ tensors["lambda_"],
1114
+ log_prior_ratios,
1115
+ tensors["gamma"],
1116
+ tensors["posterior_ratios"],
1117
+ tensors["hard_dec"],
1118
+ active_idx,
1119
+ tensors["active"],
1120
+ ),
1121
+ meta_base=dict(
1122
+ B=B,
1123
+ B_active=B_active,
1124
+ V=self.V,
1125
+ E=self.E,
1126
+ WRITE_HARD=True,
1127
+ dtype_enum=self._dtype_enum,
1128
+ block_vars=(
1129
+ graph.var_edges[:1] if graph.var_edges.shape[0] > 0 else graph.var_ptr[:1]
1130
+ ),
1131
+ block_var_ptr=graph.var_ptr[:2],
1132
+ block_idx=-1,
1133
+ ),
1134
+ number=20,
1135
+ grid_fn=_v2c_grid_fn,
1136
+ log_name=f"v2c_and_gamma_btile_kernel[gpu={gpu_idx}]",
1137
+ )
1138
+ self._rc_v2c_btile = int(saved.get("BTILE", self.cfg.btile))
1139
+ # Fixed, not tuned: see autotune.V2C_BTILE_BLOCK_SIZE.
1140
+ self._rc_v2c_block = V2C_BTILE_BLOCK_SIZE
1141
+ self._rc_v2c_warps = int(saved.get("num_warps", 0))
1142
+ self._rc_v2c_stages = int(saved.get("num_stages", 0))
1143
+
1144
+ # --- Parity (shared by compact_with_zero and compact kernels) ---
1145
+ parity_key = {
1146
+ "B": B,
1147
+ "C": self.C,
1148
+ "V": self.V,
1149
+ "E": self.E,
1150
+ "ROWS_PER_CHK": self.cfg.rows_per_chk,
1151
+ "dtype": self.dtype_str,
1152
+ "sparsity_hash": graph.sparsity_hash,
1153
+ }
1154
+ parity_meta = dict(B=B, C=self.C, V=self.V, E=self.E, ROWS_PER_CHK=self.cfg.rows_per_chk)
1155
+ total = B * self.C
1156
+ parity_grid = ((total + self.cfg.rows_per_chk - 1) // self.cfg.rows_per_chk,)
1157
+
1158
+ saved = self._get_tuned_meta(
1159
+ kernel_name="parity_from_hard_compact_with_zero_kernel",
1160
+ problem_key=parity_key,
1161
+ build_cfgs_fn=build_parity_configs,
1162
+ kernel=parity_from_hard_compact_with_zero_kernel,
1163
+ grid=parity_grid,
1164
+ args=(
1165
+ tensors["hard_dec"],
1166
+ graph.chk_ptr,
1167
+ graph.chk_edges,
1168
+ graph.edge_var,
1169
+ tensors["syndrome"],
1170
+ tensors["check_ok"],
1171
+ tensors["active"],
1172
+ ),
1173
+ meta_base=parity_meta,
1174
+ number=20,
1175
+ log_name=f"parity_from_hard_compact_with_zero_kernel[gpu={gpu_idx}]",
1176
+ )
1177
+ self._rc_parity_block = int(saved["BLOCK_SIZE"])
1178
+ self._rc_parity_warps = int(saved.get("num_warps", 0))
1179
+ self._rc_parity_stages = int(saved.get("num_stages", 0))
1180
+
1181
+ # --- Select ---
1182
+ gpu_id = self.device_ids[gpu_idx]
1183
+ wj_gpu = self.wj_per_gpu[gpu_id]
1184
+ select_key = {
1185
+ "B": B,
1186
+ "C": self.C,
1187
+ "V": self.V,
1188
+ "dtype": self.dtype_str,
1189
+ "sparsity_hash": graph.sparsity_hash,
1190
+ }
1191
+
1192
+ saved = self._get_tuned_meta(
1193
+ kernel_name="check_and_select_kernel",
1194
+ problem_key=select_key,
1195
+ build_cfgs_fn=build_check_select_configs,
1196
+ kernel=check_and_select_kernel,
1197
+ grid=(B,),
1198
+ args=(
1199
+ tensors["check_ok"],
1200
+ tensors["posterior_ratios"],
1201
+ tensors["hard_dec"],
1202
+ wj_gpu,
1203
+ tensors["best_weights"],
1204
+ tensors["best_errors"],
1205
+ tensors["valid_solutions"],
1206
+ tensors["found_count"],
1207
+ tensors["active"],
1208
+ ),
1209
+ meta_base=dict(
1210
+ B=B,
1211
+ C=self.C,
1212
+ V=self.V,
1213
+ dtype_enum=self._dtype_enum,
1214
+ NUM_SOLS=int(self.stop_nconv),
1215
+ ),
1216
+ number=20,
1217
+ grid_fn=lambda _cfg: (B,),
1218
+ log_name=f"check_and_select_kernel[gpu={gpu_idx}]",
1219
+ )
1220
+ self._rc_select_block_c = int(saved.get("BLOCK_C", 256))
1221
+ self._rc_select_block_v = int(saved.get("BLOCK_V", 128))
1222
+ self._rc_select_warps = int(saved.get("num_warps", 1))
1223
+
1224
+ def _resolve_default_configs(
1225
+ self,
1226
+ B,
1227
+ tensors,
1228
+ graph,
1229
+ gpu_idx,
1230
+ device_str,
1231
+ log_prior_ratios,
1232
+ alpha,
1233
+ beta,
1234
+ use_alpha,
1235
+ use_beta,
1236
+ ):
1237
+ """Resolve autotuning configs for default (row-wise) mode."""
1238
+ # --- C2V ---
1239
+ c2v_key = {
1240
+ "B": B,
1241
+ "C": self.C,
1242
+ "E": self.E,
1243
+ "dtype": self.dtype_str,
1244
+ "sparsity_hash": graph.sparsity_hash,
1245
+ }
1246
+
1247
+ def _c2v_grid_fn(cfg):
1248
+ rows = int(cfg.get("ROWS_PER_CHK", self.cfg.rows_per_chk))
1249
+ return ((B * self.C + rows - 1) // rows,)
1250
+
1251
+ saved = self._get_tuned_meta(
1252
+ kernel_name="c2v_min_sum_kernel",
1253
+ problem_key=c2v_key,
1254
+ build_cfgs_fn=build_c2v_configs,
1255
+ kernel=c2v_min_sum_kernel,
1256
+ grid=(1,),
1257
+ args=(
1258
+ tensors["mu"],
1259
+ tensors["nu"],
1260
+ graph.chk_ptr,
1261
+ graph.chk_edges,
1262
+ tensors["syndrome"],
1263
+ tensors["active"],
1264
+ ),
1265
+ meta_base=dict(
1266
+ B=B,
1267
+ C=self.C,
1268
+ E=self.E,
1269
+ alpha=alpha,
1270
+ beta=beta,
1271
+ use_alpha=use_alpha,
1272
+ use_beta=use_beta,
1273
+ dtype_enum=self._dtype_enum,
1274
+ ),
1275
+ number=20,
1276
+ grid_fn=_c2v_grid_fn,
1277
+ log_name=f"c2v_min_sum_kernel[gpu={gpu_idx}]",
1278
+ )
1279
+ self._rc_c2v_rows = int(saved.get("ROWS_PER_CHK", self.cfg.rows_per_chk))
1280
+ self._rc_c2v_block = int(saved.get("BLOCK_SIZE", 64))
1281
+ self._rc_c2v_warps = int(saved.get("num_warps", 0))
1282
+ self._rc_c2v_stages = int(saved.get("num_stages", 0))
1283
+
1284
+ # --- V2C ---
1285
+ v2c_key = {
1286
+ "B": B,
1287
+ "V": self.V,
1288
+ "E": self.E,
1289
+ "dtype": self.dtype_str,
1290
+ "sparsity_hash": graph.sparsity_hash,
1291
+ }
1292
+
1293
+ def _v2c_grid_fn(cfg):
1294
+ rows = int(cfg.get("ROWS_PER_VAR", self.cfg.rows_per_var))
1295
+ return ((B * self.V + rows - 1) // rows,)
1296
+
1297
+ saved = self._get_tuned_meta(
1298
+ kernel_name="v2c_and_marginals_fused_gamma_kernel",
1299
+ problem_key=v2c_key,
1300
+ build_cfgs_fn=build_v2c_configs,
1301
+ kernel=v2c_and_marginals_fused_gamma_kernel,
1302
+ grid=(1,),
1303
+ args=(
1304
+ tensors["nu"],
1305
+ tensors["mu"],
1306
+ graph.var_ptr,
1307
+ graph.var_edges,
1308
+ tensors["lambda_"],
1309
+ tensors["posterior_ratios"],
1310
+ tensors["hard_dec"],
1311
+ tensors["active"],
1312
+ log_prior_ratios,
1313
+ tensors["gamma"],
1314
+ ),
1315
+ meta_base=dict(B=B, V=self.V, E=self.E, dtype_enum=self._dtype_enum),
1316
+ number=20,
1317
+ grid_fn=_v2c_grid_fn,
1318
+ log_name=f"v2c_and_marginals_fused_gamma_kernel[gpu={gpu_idx}]",
1319
+ )
1320
+ self._rc_v2c_rows = int(saved.get("ROWS_PER_VAR", self.cfg.rows_per_var))
1321
+ self._rc_v2c_block = int(saved.get("BLOCK_SIZE", 64))
1322
+ self._rc_v2c_warps = int(saved.get("num_warps", 0))
1323
+ self._rc_v2c_stages = int(saved.get("num_stages", 0))
1324
+
1325
+ # --- Parity ---
1326
+ parity_key = {
1327
+ "B": B,
1328
+ "C": self.C,
1329
+ "V": self.V,
1330
+ "E": self.E,
1331
+ "ROWS_PER_CHK": self.cfg.rows_per_chk,
1332
+ "dtype": self.dtype_str,
1333
+ "sparsity_hash": graph.sparsity_hash,
1334
+ }
1335
+ parity_meta = dict(B=B, C=self.C, V=self.V, E=self.E, ROWS_PER_CHK=self.cfg.rows_per_chk)
1336
+ parity_grid = ((B * self.C + self.cfg.rows_per_chk - 1) // self.cfg.rows_per_chk,)
1337
+
1338
+ saved = self._get_tuned_meta(
1339
+ kernel_name="parity_per_check_kernel",
1340
+ problem_key=parity_key,
1341
+ build_cfgs_fn=build_parity_configs,
1342
+ kernel=parity_per_check_kernel,
1343
+ grid=parity_grid,
1344
+ args=(
1345
+ tensors["posterior_ratios"],
1346
+ graph.chk_ptr,
1347
+ graph.chk_edges,
1348
+ graph.edge_var,
1349
+ tensors["syndrome"],
1350
+ tensors["check_ok"],
1351
+ ),
1352
+ meta_base=parity_meta,
1353
+ number=20,
1354
+ log_name=f"parity_per_check_kernel[gpu={gpu_idx}]",
1355
+ )
1356
+ self._rc_parity_block = int(saved["BLOCK_SIZE"])
1357
+ self._rc_parity_warps = int(saved.get("num_warps", 0))
1358
+ self._rc_parity_stages = int(saved.get("num_stages", 0))
1359
+
1360
+ # --- Select (shared config for both modes) ---
1361
+ gpu_id = self.device_ids[gpu_idx]
1362
+ wj_gpu = self.wj_per_gpu[gpu_id]
1363
+ select_key = {
1364
+ "B": B,
1365
+ "C": self.C,
1366
+ "V": self.V,
1367
+ "dtype": self.dtype_str,
1368
+ "sparsity_hash": graph.sparsity_hash,
1369
+ }
1370
+
1371
+ saved = self._get_tuned_meta(
1372
+ kernel_name="check_and_select_kernel",
1373
+ problem_key=select_key,
1374
+ build_cfgs_fn=build_check_select_configs,
1375
+ kernel=check_and_select_kernel,
1376
+ grid=(B,),
1377
+ args=(
1378
+ tensors["check_ok"],
1379
+ tensors["posterior_ratios"],
1380
+ tensors["hard_dec"],
1381
+ wj_gpu,
1382
+ tensors["best_weights"],
1383
+ tensors["best_errors"],
1384
+ tensors["valid_solutions"],
1385
+ tensors["found_count"],
1386
+ tensors["active"],
1387
+ ),
1388
+ meta_base=dict(
1389
+ B=B,
1390
+ C=self.C,
1391
+ V=self.V,
1392
+ dtype_enum=self._dtype_enum,
1393
+ NUM_SOLS=int(self.stop_nconv),
1394
+ ),
1395
+ number=20,
1396
+ grid_fn=lambda _cfg: (B,),
1397
+ log_name=f"check_and_select_kernel[gpu={gpu_idx}]",
1398
+ )
1399
+ self._rc_select_block_c = int(saved.get("BLOCK_C", 256))
1400
+ self._rc_select_block_v = int(saved.get("BLOCK_V", 128))
1401
+ self._rc_select_warps = int(saved.get("num_warps", 1))
1402
+
1403
+ def _parse_device_spec(self, device: Union[str, List[int], int]) -> List[int]:
1404
+ """Parse device specification into list of device IDs.
1405
+
1406
+ Supports:
1407
+ - List[int]: Explicit device IDs, e.g. [0, 1, 2, 3]
1408
+ - int: Number of GPUs to auto-detect, e.g. 4 → [0, 1, 2, 3]
1409
+ - str: Legacy support - "auto" or "cuda" → [0] (single GPU)
1410
+
1411
+ Args:
1412
+ device: Device specification
1413
+
1414
+ Returns:
1415
+ List of device IDs
1416
+ """
1417
+ if isinstance(device, int):
1418
+ # Auto-detect N GPUs
1419
+ num_gpus = device
1420
+ if num_gpus < 1:
1421
+ raise ValueError(f"Number of GPUs must be >= 1, got {num_gpus}")
1422
+ available = torch.cuda.device_count()
1423
+ if num_gpus > available:
1424
+ raise ValueError(f"Requested {num_gpus} GPUs but only {available} available")
1425
+ return list(range(num_gpus))
1426
+
1427
+ elif isinstance(device, list):
1428
+ # Explicit list of device IDs
1429
+ if not device:
1430
+ raise ValueError("Device list cannot be empty")
1431
+ available = torch.cuda.device_count()
1432
+ for gpu_id in device:
1433
+ if not isinstance(gpu_id, int):
1434
+ raise ValueError(f"Device IDs must be integers, got {type(gpu_id)}")
1435
+ if gpu_id < 0 or gpu_id >= available:
1436
+ raise ValueError(f"Device ID {gpu_id} out of range [0, {available - 1}]")
1437
+ return device
1438
+
1439
+ elif isinstance(device, str):
1440
+ # Legacy string support: "auto" or "cuda" → single GPU [0]
1441
+ dev_arg = device.strip().lower()
1442
+ if dev_arg in ("auto", "cuda", "rocm"):
1443
+ return [0] # Single GPU
1444
+ else:
1445
+ raise ValueError(
1446
+ f"Unsupported device string '{device}'. Use List[int], int, or 'auto'/'cuda'."
1447
+ )
1448
+
1449
+ else:
1450
+ raise ValueError(
1451
+ f"Invalid device type: {type(device)}. Expected List[int], int, or str."
1452
+ )
1453
+
1454
+ def _allocate_device_tensors(self, B: int, gpu_id: int):
1455
+ """Allocate device tensors for batch size B on specific GPU, resetting if reused.
1456
+
1457
+ Args:
1458
+ B: Batch size
1459
+ gpu_id: GPU device ID
1460
+
1461
+ Returns:
1462
+ Tensors dictionary on specified GPU
1463
+ """
1464
+ device_str = self._device_strs[gpu_id]
1465
+
1466
+ # Safety: Check for integer overflow in E * B calculation
1467
+ # Triton uses int32 for pointer arithmetic, so E * B must not exceed int32 max
1468
+ # This prevents illegal memory access from overflow in kernels that compute offsets like evec * B + bs
1469
+ INT32_MAX = 2**31 - 1
1470
+ if self.E > 0 and B > 0:
1471
+ max_safe_B = INT32_MAX // self.E
1472
+ if B > max_safe_B:
1473
+ raise RuntimeError(
1474
+ f"Batch size {B} exceeds int32 overflow limit for E={self.E}: "
1475
+ f"E * B = {self.E * B:,} > int32 max ({INT32_MAX:,}). "
1476
+ f"Maximum safe batch size: {max_safe_B}."
1477
+ )
1478
+
1479
+ # Check if we're reusing existing tensors
1480
+ if B in self._device_tensors_per_gpu[gpu_id]:
1481
+ tensors = self._device_tensors_per_gpu[gpu_id][B]
1482
+ # Reset stateful tensors when reusing
1483
+ self._reset_reused_tensors(tensors)
1484
+ return tensors
1485
+
1486
+ # Allocate new tensors on specified GPU (already zero-initialized by torch.zeros/torch.full)
1487
+ tensors = {
1488
+ # Beliefs - use unified dtype
1489
+ # lambda_: Current mixed beliefs [B,V] (updated each iteration)
1490
+ "lambda_": torch.zeros(B, self.V, dtype=self.dtype, device=device_str),
1491
+ # posterior_ratios: Posterior ratios/marginals [B,V] (matches Rust: posterior_ratios)
1492
+ "posterior_ratios": torch.zeros(B, self.V, dtype=self.dtype, device=device_str),
1493
+ "hard_dec": torch.zeros(B, self.V, dtype=torch.uint8, device=device_str),
1494
+ # Gamma: Memory strengths [B,V] (Rust equivalent: memory_strengths)
1495
+ "gamma": torch.zeros(B, self.V, dtype=self.dtype, device=device_str),
1496
+ # Syndrome and validation
1497
+ "syndrome": torch.zeros(B, self.C, dtype=torch.uint8, device=device_str),
1498
+ "check_ok": torch.zeros(B, self.C, dtype=torch.uint8, device=device_str),
1499
+ # Solution tracking
1500
+ "best_weights": torch.full((B,), float("inf"), dtype=self.dtype, device=device_str),
1501
+ "best_errors": torch.zeros(B, self.V, dtype=torch.uint8, device=device_str),
1502
+ # Hard decision at the end of the first leg. Returned for lanes with no solution.
1503
+ "first_leg_hard_dec": torch.zeros(B, self.V, dtype=torch.uint8, device=device_str),
1504
+ "found_count": torch.zeros(B, dtype=torch.int32, device=device_str),
1505
+ "valid_solutions": torch.zeros(B, dtype=torch.uint8, device=device_str),
1506
+ "sought_solutions": torch.ones(B, dtype=torch.int32, device=device_str),
1507
+ # Device-side active lane management (eliminates host-device syncs)
1508
+ "active_idx_buffer": torch.zeros(
1509
+ B, dtype=torch.int32, device=device_str
1510
+ ), # Pre-allocated buffer for compacted indices
1511
+ "active_count": torch.zeros(
1512
+ 1, dtype=torch.int32, device=device_str
1513
+ ), # Device tensor for count (no sync needed)
1514
+ "block_counts": torch.zeros(
1515
+ (B + 255) // 256, dtype=torch.int32, device=device_str
1516
+ ), # Per-block counts (BLOCK_SIZE=256)
1517
+ "prefix_sum": torch.zeros(
1518
+ (B + 255) // 256 + 1, dtype=torch.int32, device=device_str
1519
+ ), # Prefix sum for compaction
1520
+ "old_active_count": torch.zeros(1, dtype=torch.int32, device=device_str),
1521
+ "count_changed_flag": torch.zeros(1, dtype=torch.uint8, device=device_str),
1522
+ "all_done_flag": torch.zeros(1, dtype=torch.uint8, device=device_str),
1523
+ "arrival_counter": torch.zeros(1, dtype=torch.int32, device=device_str),
1524
+ "in_leg": torch.zeros(1, dtype=torch.uint8, device=device_str),
1525
+ }
1526
+ # Messages - use unified dtype
1527
+ if self.cfg.perf == "throughput":
1528
+ tensors["muT"] = torch.zeros(self.E, B, dtype=self.dtype, device=device_str)
1529
+ tensors["nuT"] = torch.zeros(self.E, B, dtype=self.dtype, device=device_str)
1530
+ # Optional L tensor for delta-trick layered scheduling (only used when self._use_delta_trick)
1531
+ if self._use_delta_trick:
1532
+ tensors["L"] = torch.zeros(B, self.V, dtype=self.dtype, device=device_str)
1533
+ else:
1534
+ tensors["mu"] = torch.zeros(B, self.E, dtype=self.dtype, device=device_str)
1535
+ tensors["nu"] = torch.zeros(B, self.E, dtype=self.dtype, device=device_str)
1536
+
1537
+
1538
+ self._device_tensors_per_gpu[gpu_id][B] = tensors
1539
+
1540
+ # Report max safe batch size
1541
+ import logging
1542
+
1543
+ _logger = logging.getLogger(__name__)
1544
+ if _logger.isEnabledFor(logging.INFO):
1545
+ max_safe = (2**31 - 1) // self.E if self.E > 0 else B
1546
+ _logger.info(
1547
+ "[GPU %d] Batch size %d allocated (max safe batch size: %d for E=%d)",
1548
+ gpu_id,
1549
+ B,
1550
+ max_safe,
1551
+ self.E,
1552
+ )
1553
+
1554
+ return tensors
1555
+
1556
+
1557
+ def _reset_reused_tensors(self, tensors: Dict):
1558
+ """Reset stateful tensors when reusing from cache.
1559
+
1560
+ Performance: In-place zero operations are much faster than reallocating tensors.
1561
+ Only resets what's necessary for correctness.
1562
+ """
1563
+ # Reset all message buffers to zero (will be properly initialized by init kernels)
1564
+ # These MUST be reset as they contain state from previous decode calls
1565
+ if self.cfg.perf == "throughput":
1566
+ tensors["muT"].zero_()
1567
+ tensors["nuT"].zero_()
1568
+ if "L" in tensors:
1569
+ tensors["L"].zero_()
1570
+ else:
1571
+ tensors["mu"].zero_()
1572
+ tensors["nu"].zero_()
1573
+
1574
+ # Reset beliefs that are used in first iteration BEFORE being overwritten
1575
+ # posterior_ratios is used in gamma mixing in the first iteration, so must be clean
1576
+ tensors["posterior_ratios"].zero_()
1577
+ # lambda_ is immediately overwritten, but reset for safety
1578
+ tensors["lambda_"].zero_()
1579
+
1580
+ # Reset solution tracking (these are NOT immediately overwritten)
1581
+ tensors["hard_dec"].zero_()
1582
+ tensors["gamma"].zero_()
1583
+ tensors["best_weights"].fill_(float("inf"))
1584
+ tensors["best_errors"].zero_()
1585
+ tensors["first_leg_hard_dec"].zero_()
1586
+ tensors["found_count"].zero_()
1587
+ tensors["valid_solutions"].zero_()
1588
+ tensors["sought_solutions"].fill_(1)
1589
+ tensors["check_ok"].zero_()
1590
+
1591
+ # Note: syndrome is NOT zeroed here because it's immediately overwritten
1592
+ # with new syndrome values, avoiding redundant work.
1593
+
1594
+
1595
+
1596
+
1597
+ def _get_active_count_from_blocks(self, tensors: Dict, B: int, gpu_idx: int) -> torch.Tensor:
1598
+ """Get active count by summing block_counts (avoids separate count kernel).
1599
+
1600
+ Args:
1601
+ tensors: Device tensors dictionary
1602
+ B: Batch size
1603
+ gpu_idx: GPU index for device string
1604
+
1605
+ Returns:
1606
+ Active count as device tensor (no sync) - use device-side comparison
1607
+ """
1608
+ device_str = self._device_strs[self.device_ids[gpu_idx]]
1609
+ BLOCK_SIZE = 256
1610
+ num_blocks = (B + BLOCK_SIZE - 1) // BLOCK_SIZE
1611
+
1612
+ # Count active per block (reuses block_counts from compaction if available)
1613
+ count_active_blocks_kernel[(num_blocks,)](
1614
+ tensors["active"],
1615
+ tensors["block_counts"],
1616
+ B=B,
1617
+ BLOCK_SIZE=BLOCK_SIZE,
1618
+ num_blocks=num_blocks,
1619
+ )
1620
+ # Sum block counts on device (no sync) - return tensor for device-side comparison
1621
+ if "active_count_tensor" not in tensors:
1622
+ tensors["active_count_tensor"] = torch.zeros(1, dtype=torch.int32, device=device_str)
1623
+ # torch.sum() returns a 0D scalar tensor, use in-place copy to avoid sync
1624
+ total_sum = torch.sum(tensors["block_counts"]).to(torch.int32)
1625
+ # Copy scalar to 1D tensor element without sync (both on device)
1626
+ if total_sum.dim() == 0:
1627
+ # 0D scalar - expand to 1D and copy in-place
1628
+ tensors["active_count_tensor"].copy_(total_sum.unsqueeze(0))
1629
+ else:
1630
+ # Already 1D - direct copy
1631
+ tensors["active_count_tensor"].copy_(total_sum)
1632
+ return tensors["active_count_tensor"]
1633
+
1634
+ def _compact_active_indices_on_device(
1635
+ self, tensors: Dict, B: int, gpu_idx: int
1636
+ ) -> Tuple[torch.Tensor, int]:
1637
+ """Compact active indices on device (replaces torch.nonzero()).
1638
+
1639
+ Args:
1640
+ tensors: Device tensors dictionary
1641
+ B: Batch size
1642
+ gpu_idx: GPU index (for device string if needed)
1643
+
1644
+ Returns:
1645
+ Tuple of (active_idx_buffer slice, B_active count)
1646
+ """
1647
+ BLOCK_SIZE = 256
1648
+ num_blocks = (B + BLOCK_SIZE - 1) // BLOCK_SIZE
1649
+
1650
+ # Phase 1: Count active per block (reuse if already computed)
1651
+ count_active_blocks_kernel[(num_blocks,)](
1652
+ tensors["active"],
1653
+ tensors["block_counts"],
1654
+ B=B,
1655
+ BLOCK_SIZE=BLOCK_SIZE,
1656
+ num_blocks=num_blocks,
1657
+ )
1658
+ # Phase 2: Compute prefix sum on device (no host sync)
1659
+ # Use torch.cumsum which is efficient on device
1660
+ torch.cumsum(tensors["block_counts"], dim=0, out=tensors["prefix_sum"][1:])
1661
+ tensors["prefix_sum"][0] = 0 # First element is 0
1662
+
1663
+ # Phase 3: Compact indices using prefix sum
1664
+ # Validate grid size matches buffer size to catch bugs early
1665
+ expected_prefix_sum_size = num_blocks + 1
1666
+ if expected_prefix_sum_size != tensors["prefix_sum"].shape[0]:
1667
+ raise RuntimeError(
1668
+ f"Grid size mismatch: num_blocks={num_blocks} (expects prefix_sum size={expected_prefix_sum_size}) "
1669
+ f"but prefix_sum has {tensors['prefix_sum'].shape[0]} elements. B={B}, BLOCK_SIZE={BLOCK_SIZE}"
1670
+ )
1671
+ compact_active_indices_kernel[(num_blocks,)](
1672
+ tensors["active"],
1673
+ tensors["active_idx_buffer"],
1674
+ tensors["prefix_sum"],
1675
+ B=B,
1676
+ BLOCK_SIZE=BLOCK_SIZE,
1677
+ num_blocks=num_blocks,
1678
+ )
1679
+
1680
+ # Get count from last element of prefix sum (sync only when needed)
1681
+ B_active = int(tensors["prefix_sum"][num_blocks].item())
1682
+
1683
+ return tensors["active_idx_buffer"][:B_active], B_active
1684
+
1685
+ def _launch_c2v_btile(
1686
+ self,
1687
+ tensors: Dict,
1688
+ B: int,
1689
+ active_idx: torch.Tensor = None,
1690
+ B_active: int = None,
1691
+ graph: "CSRGraph" = None,
1692
+ gpu_idx: int = 0,
1693
+ chk_start: int = 0,
1694
+ chk_end: int = 0,
1695
+ use_delta: bool = False,
1696
+ ):
1697
+ """Launch C2V BTILE kernel with optional block range for layered scheduling.
1698
+
1699
+ Args:
1700
+ chk_start: Start check index for block-layered (0 = all checks)
1701
+ chk_end: End check index for block-layered (0 = all checks, means C)
1702
+ use_delta: If True, use delta-trick mode (requires L tensor to be allocated)
1703
+ """
1704
+ if graph is None:
1705
+ graph = self.graphs[gpu_idx]
1706
+ device_str = f"{self.device_type}:{self.device_ids[gpu_idx]}"
1707
+ if active_idx is None:
1708
+ active_idx = torch.arange(B, device=device_str, dtype=torch.int32)
1709
+ B_active = B
1710
+ alpha = getattr(self, "_alpha_current", self.normalized_min_sum_alpha or 0.0)
1711
+ beta = self.offset_min_sum_beta or 0.0
1712
+ use_alpha = self.normalized_min_sum_alpha is not None
1713
+ use_beta = self.offset_min_sum_beta is not None
1714
+
1715
+ # Use pre-resolved config (resolved once in _resolve_configs)
1716
+ btile = self._rc_c2v_btile
1717
+ grid_dim1 = (B_active + btile - 1) // btile
1718
+ grid_dim0 = (chk_end - chk_start) if chk_end > 0 else self.C
1719
+ grid = (grid_dim0, grid_dim1)
1720
+
1721
+ L_tensor = tensors.get("L")
1722
+ if L_tensor is None and use_delta:
1723
+ raise RuntimeError("Delta-trick mode requires L tensor to be allocated")
1724
+ if L_tensor is None:
1725
+ if gpu_idx not in self._dummy_L_per_gpu:
1726
+ self._dummy_L_per_gpu[gpu_idx] = torch.zeros(
1727
+ 1, 1, dtype=self.dtype, device=device_str
1728
+ )
1729
+ L_tensor = self._dummy_L_per_gpu[gpu_idx]
1730
+ c2v_min_sum_btile_kernel[grid](
1731
+ tensors["muT"],
1732
+ tensors["nuT"],
1733
+ graph.chk_ptr,
1734
+ graph.chk_edges,
1735
+ tensors["syndrome"],
1736
+ active_idx,
1737
+ tensors["active"],
1738
+ B,
1739
+ B_active,
1740
+ self.C,
1741
+ self.E,
1742
+ alpha,
1743
+ beta,
1744
+ use_alpha,
1745
+ use_beta,
1746
+ self._dtype_enum,
1747
+ self._rc_c2v_block,
1748
+ btile,
1749
+ chk_start,
1750
+ chk_end,
1751
+ graph.edge_var,
1752
+ L_tensor,
1753
+ self.V,
1754
+ use_delta,
1755
+ num_warps=self._rc_c2v_warps,
1756
+ num_stages=self._rc_c2v_stages,
1757
+ )
1758
+
1759
+ def _launch_c2v_kernel(self, tensors: Dict, B: int, graph: "CSRGraph" = None, gpu_idx: int = 0):
1760
+ """Launch check-to-variable kernel."""
1761
+ if graph is None:
1762
+ graph = self.graphs[gpu_idx]
1763
+ alpha = getattr(self, "_alpha_current", self.normalized_min_sum_alpha or 0.0)
1764
+ beta = self.offset_min_sum_beta or 0.0
1765
+ use_alpha = self.normalized_min_sum_alpha is not None
1766
+ use_beta = self.offset_min_sum_beta is not None
1767
+
1768
+ rows = self._rc_c2v_rows
1769
+ grid = (((B * self.C) + rows - 1) // rows,)
1770
+
1771
+ c2v_min_sum_kernel[grid](
1772
+ tensors["mu"],
1773
+ tensors["nu"],
1774
+ graph.chk_ptr,
1775
+ graph.chk_edges,
1776
+ tensors["syndrome"],
1777
+ tensors["active"],
1778
+ B=B,
1779
+ C=self.C,
1780
+ E=self.E,
1781
+ alpha=alpha,
1782
+ beta=beta,
1783
+ use_alpha=use_alpha,
1784
+ use_beta=use_beta,
1785
+ dtype_enum=self._dtype_enum,
1786
+ BLOCK_SIZE=self._rc_c2v_block,
1787
+ ROWS_PER_CHK=rows,
1788
+ num_warps=self._rc_c2v_warps,
1789
+ num_stages=self._rc_c2v_stages,
1790
+ )
1791
+
1792
+ def _launch_v2c_fused_gamma_kernel(
1793
+ self,
1794
+ tensors: Dict,
1795
+ B: int,
1796
+ graph: "CSRGraph" = None,
1797
+ log_prior_ratios: torch.Tensor = None,
1798
+ gpu_idx: int = 0,
1799
+ prior_batched: bool = False,
1800
+ ):
1801
+ """Launch variable-to-check and marginals kernel with fused gamma mixing."""
1802
+ if graph is None:
1803
+ graph = self.graphs[gpu_idx]
1804
+ if log_prior_ratios is None:
1805
+ log_prior_ratios = self.log_prior_ratios_per_gpu[gpu_idx]
1806
+
1807
+ rows = self._rc_v2c_rows
1808
+ grid = (((B * self.V) + rows - 1) // rows,)
1809
+
1810
+ v2c_and_marginals_fused_gamma_kernel[grid](
1811
+ tensors["nu"],
1812
+ tensors["mu"],
1813
+ graph.var_ptr,
1814
+ graph.var_edges,
1815
+ tensors["lambda_"],
1816
+ tensors["posterior_ratios"],
1817
+ tensors["hard_dec"],
1818
+ tensors["active"],
1819
+ log_prior_ratios,
1820
+ tensors["gamma"],
1821
+ B=B,
1822
+ V=self.V,
1823
+ E=self.E,
1824
+ dtype_enum=self._dtype_enum,
1825
+ BLOCK_SIZE=self._rc_v2c_block,
1826
+ ROWS_PER_VAR=rows,
1827
+ PRIOR_BATCHED=prior_batched,
1828
+ num_warps=self._rc_v2c_warps,
1829
+ num_stages=self._rc_v2c_stages,
1830
+ )
1831
+
1832
+ def _launch_parity_check_kernel(
1833
+ self, tensors: Dict, B: int, graph: "CSRGraph" = None, gpu_idx: int = 0
1834
+ ):
1835
+ """Launch parity check kernel with pre-resolved config."""
1836
+ if graph is None:
1837
+ graph = self.graphs[gpu_idx]
1838
+ assert not (self.cfg.perf == "throughput"), (
1839
+ "Throughput mode must use parity_from_hard_kernel"
1840
+ )
1841
+ total = B * self.C
1842
+ grid = ((total + self.cfg.rows_per_chk - 1) // self.cfg.rows_per_chk,)
1843
+ meta_base = dict(B=B, C=self.C, V=self.V, E=self.E, ROWS_PER_CHK=self.cfg.rows_per_chk)
1844
+
1845
+ parity_per_check_kernel[grid](
1846
+ tensors["posterior_ratios"],
1847
+ graph.chk_ptr,
1848
+ graph.chk_edges,
1849
+ graph.edge_var,
1850
+ tensors["syndrome"],
1851
+ tensors["check_ok"],
1852
+ **meta_base,
1853
+ BLOCK_SIZE=self._rc_parity_block,
1854
+ num_warps=self._rc_parity_warps,
1855
+ num_stages=self._rc_parity_stages,
1856
+ )
1857
+
1858
+ def _launch_init_kernel(
1859
+ self,
1860
+ tensors: Dict,
1861
+ B: int,
1862
+ graph: "CSRGraph" = None,
1863
+ log_prior_ratios: torch.Tensor = None,
1864
+ gpu_idx: int = 0,
1865
+ ):
1866
+ """Launch message initialization kernel for default mode.
1867
+
1868
+ Initializes messages to match Rust:
1869
+ - nu (check_to_variable) -> zero
1870
+ - mu (variable_to_check) -> log_prior_ratios
1871
+ """
1872
+ if graph is None:
1873
+ graph = self.graphs[gpu_idx]
1874
+ if log_prior_ratios is None:
1875
+ log_prior_ratios = self.log_prior_ratios_per_gpu[gpu_idx]
1876
+ N = B * self.E
1877
+ BLOCK = 1024
1878
+ grid = ((N + BLOCK - 1) // BLOCK,)
1879
+ # Estimate: init kernel uses default 1 warp per program
1880
+ num_warps = 1
1881
+ dtype_enum = self._dtype_enum
1882
+ init_messages_kernel[grid](
1883
+ tensors["mu"],
1884
+ tensors["nu"],
1885
+ graph.edge_var,
1886
+ log_prior_ratios.squeeze(0), # [V] prior LLRs
1887
+ B,
1888
+ self.E,
1889
+ self.V,
1890
+ dtype_enum=dtype_enum,
1891
+ BLOCK=BLOCK,
1892
+ )
1893
+
1894
+ def _launch_init_messages_with_priors_EB(
1895
+ self,
1896
+ muT: torch.Tensor,
1897
+ nuT: torch.Tensor,
1898
+ B: int,
1899
+ active_idx: Optional[torch.Tensor] = None,
1900
+ B_active: Optional[int] = None,
1901
+ graph: "CSRGraph" = None,
1902
+ log_prior_ratios: Optional[torch.Tensor] = None,
1903
+ gpu_idx: int = 0,
1904
+ prior_batched: bool = False,
1905
+ ):
1906
+ """Launch initialization kernel for throughput mode: nu=zero, mu=priors (Rust-compatible).
1907
+
1908
+ This matches Rust's initialization:
1909
+ - initialize_check_to_variable() -> nu = zero
1910
+ - initialize_variable_to_check() -> mu = log_prior_ratios
1911
+
1912
+ If active_idx and B_active are provided and B_active < B, only initializes messages for active lanes,
1913
+ dramatically reducing memory traffic when B_active << B.
1914
+
1915
+ Args:
1916
+ muT, nuT: [E,B] message tensors
1917
+ B: total batch size
1918
+ active_idx: Optional [B_active] compact list of active batch indices
1919
+ B_active: Optional number of active lanes (if None, uses B)
1920
+ graph: CSRGraph instance (defaults to self.graphs[gpu_idx])
1921
+ log_prior_ratios: [1, V] log prior ratios tensor (defaults to self.log_prior_ratios_per_gpu[gpu_idx])
1922
+ gpu_idx: GPU index (for device string)
1923
+ """
1924
+ if graph is None:
1925
+ graph = self.graphs[gpu_idx]
1926
+ if log_prior_ratios is None:
1927
+ log_prior_ratios = self.log_prior_ratios_per_gpu[gpu_idx]
1928
+ device_str = self._device_strs[self.device_ids[gpu_idx]]
1929
+
1930
+ prior_arg = log_prior_ratios if prior_batched else log_prior_ratios.squeeze(0)
1931
+
1932
+ # Determine if we should use active lanes only
1933
+ use_active = (
1934
+ (active_idx is not None)
1935
+ and (B_active is not None)
1936
+ and (B_active < B)
1937
+ and (B_active > 0)
1938
+ )
1939
+
1940
+ if use_active:
1941
+ # Active lanes path: use hardcoded config for now (will add autotuning later)
1942
+ # Note: Kernels use int64 for offset calculations, so overflow is prevented
1943
+ total = self.E * B_active
1944
+ dummy_active_idx = active_idx
1945
+ grid = ((total + 1024 - 1) // 1024,)
1946
+ num_warps = 1
1947
+ dtype_enum = self._dtype_enum
1948
+ init_messages_with_priors_EB_kernel[grid](
1949
+ muT,
1950
+ nuT,
1951
+ graph.edge_var,
1952
+ prior_arg, # [V] shared or [B,V] per-shot prior LLRs
1953
+ self.E,
1954
+ self.V,
1955
+ B,
1956
+ dummy_active_idx, # active_idx
1957
+ B_active, # Number of active lanes
1958
+ use_active=use_active, # Flag to enable active-lanes-only mode
1959
+ dtype_enum=dtype_enum,
1960
+ BLOCK=1024,
1961
+ PRIOR_BATCHED=prior_batched,
1962
+ num_warps=num_warps,
1963
+ num_stages=1,
1964
+ )
1965
+ else:
1966
+ # Full initialization path: use autotuning
1967
+ # Note: Kernels use int64 for offset calculations, so overflow is prevented
1968
+ total = self.E * B
1969
+ B_active = B # Use full batch
1970
+ # Create dummy tensor (won't be used, but Triton requires it)
1971
+ dummy_active_idx = torch.zeros(1, dtype=torch.int32, device=device_str)
1972
+
1973
+ problem_key = {
1974
+ "B": B,
1975
+ "E": self.E,
1976
+ "V": self.V,
1977
+ "dtype": self.dtype_str,
1978
+ "use_active": False, # Full path
1979
+ "sparsity_hash": graph.sparsity_hash,
1980
+ }
1981
+
1982
+ def _grid_fn(cfg):
1983
+ block = int(cfg.get("BLOCK", 1024))
1984
+ return ((total + block - 1) // block,)
1985
+
1986
+ dtype_enum = self._dtype_enum
1987
+
1988
+ saved = self._get_tuned_meta(
1989
+ kernel_name="init_messages_with_priors_EB_kernel",
1990
+ problem_key=problem_key,
1991
+ build_cfgs_fn=build_init_messages_configs,
1992
+ kernel=init_messages_with_priors_EB_kernel,
1993
+ grid=(1,), # Dummy grid, overridden by grid_fn
1994
+ args=(
1995
+ muT,
1996
+ nuT,
1997
+ graph.edge_var,
1998
+ prior_arg, # [V] shared or [B,V] per-shot prior LLRs
1999
+ self.E,
2000
+ self.V,
2001
+ B,
2002
+ dummy_active_idx, # active_idx (dummy for full path)
2003
+ B_active, # Number of active lanes (B for full path)
2004
+ ),
2005
+ meta_base=dict(
2006
+ use_active=False, # Full path
2007
+ dtype_enum=dtype_enum,
2008
+ PRIOR_BATCHED=prior_batched,
2009
+ ),
2010
+ number=20,
2011
+ grid_fn=_grid_fn,
2012
+ log_name=f"init_messages_with_priors_EB_kernel[gpu={gpu_idx}]",
2013
+ )
2014
+
2015
+ block = int(saved.get("BLOCK", 1024))
2016
+ grid = ((total + block - 1) // block,)
2017
+ num_warps = int(saved.get("num_warps", 1))
2018
+ num_stages = int(saved.get("num_stages", 1))
2019
+
2020
+ dtype_enum = self._dtype_enum
2021
+ init_messages_with_priors_EB_kernel[grid](
2022
+ muT,
2023
+ nuT,
2024
+ graph.edge_var,
2025
+ prior_arg, # [V] shared or [B,V] per-shot prior LLRs
2026
+ self.E,
2027
+ self.V,
2028
+ B,
2029
+ dummy_active_idx, # active_idx (dummy for full path)
2030
+ B_active, # Number of active lanes (B for full path)
2031
+ use_active=False, # Full path
2032
+ dtype_enum=dtype_enum,
2033
+ BLOCK=block,
2034
+ PRIOR_BATCHED=prior_batched,
2035
+ num_warps=num_warps,
2036
+ num_stages=num_stages,
2037
+ )
2038
+
2039
+ def _launch_v2c_btile(
2040
+ self,
2041
+ tensors: Dict,
2042
+ B: int,
2043
+ write_hard: bool,
2044
+ active_idx: torch.Tensor = None,
2045
+ B_active: int = None,
2046
+ graph: "CSRGraph" = None,
2047
+ log_prior_ratios: torch.Tensor = None,
2048
+ gpu_idx: int = 0,
2049
+ block_vars: torch.Tensor = None,
2050
+ block_var_ptr: torch.Tensor = None,
2051
+ block_idx: int = -1,
2052
+ prior_batched: bool = False,
2053
+ ):
2054
+ """Launch variable-to-check BTILE kernel with fused gamma mixing.
2055
+
2056
+ Note: posterior_ratios is always stored (required for next iteration's gamma mixing).
2057
+
2058
+ Args:
2059
+ block_vars: Variable indices for block-layered (None = all vars)
2060
+ block_var_ptr: Prefix sum for block_vars (None = not used)
2061
+ block_idx: Block index for block-layered (-1 = flood-fill, use all vars)
2062
+ """
2063
+ if graph is None:
2064
+ graph = self.graphs[gpu_idx]
2065
+ if log_prior_ratios is None:
2066
+ log_prior_ratios = self.log_prior_ratios_per_gpu[gpu_idx]
2067
+ device_str = self._device_strs[self.device_ids[gpu_idx]]
2068
+ if active_idx is None:
2069
+ active_idx = torch.arange(B, device=device_str, dtype=torch.int32)
2070
+ B_active = B
2071
+
2072
+ # Use pre-resolved config
2073
+ btile = self._rc_v2c_btile
2074
+ grid_dim1 = (B_active + btile - 1) // btile
2075
+ if block_idx >= 0 and block_var_ptr is not None:
2076
+ block_var_ptr_cpu = self._block_var_ptr_cpu
2077
+ var_start = int(block_var_ptr_cpu[block_idx])
2078
+ var_end = int(block_var_ptr_cpu[block_idx + 1])
2079
+ grid_dim0 = var_end - var_start
2080
+ else:
2081
+ grid_dim0 = self.V
2082
+ grid = (grid_dim0, grid_dim1)
2083
+
2084
+ # Block-layered vs flood-fill tensor selection
2085
+ if block_idx >= 0 and block_vars is not None and block_var_ptr is not None:
2086
+ block_vars_actual = block_vars
2087
+ block_var_ptr_actual = block_var_ptr
2088
+ block_idx_actual = block_idx
2089
+ else:
2090
+ block_vars_actual = (
2091
+ graph.var_edges[:1] if graph.var_edges.shape[0] > 0 else graph.var_ptr[:1]
2092
+ )
2093
+ block_var_ptr_actual = graph.var_ptr[:2]
2094
+ block_idx_actual = -1
2095
+
2096
+ v2c_and_gamma_btile_kernel[grid](
2097
+ tensors["nuT"],
2098
+ tensors["muT"],
2099
+ graph.var_ptr,
2100
+ graph.var_edges,
2101
+ tensors["lambda_"],
2102
+ log_prior_ratios,
2103
+ tensors["gamma"],
2104
+ tensors["posterior_ratios"],
2105
+ tensors["hard_dec"],
2106
+ active_idx,
2107
+ tensors["active"],
2108
+ B=B,
2109
+ B_active=B_active,
2110
+ V=self.V,
2111
+ E=self.E,
2112
+ dtype_enum=self._dtype_enum,
2113
+ BLOCK_SIZE=self._rc_v2c_block,
2114
+ BTILE=btile,
2115
+ WRITE_HARD=write_hard,
2116
+ block_vars=block_vars_actual,
2117
+ block_var_ptr=block_var_ptr_actual,
2118
+ block_idx=block_idx_actual,
2119
+ PRIOR_BATCHED=prior_batched,
2120
+ num_warps=self._rc_v2c_warps,
2121
+ num_stages=self._rc_v2c_stages,
2122
+ )
2123
+
2124
+ def _launch_parity_from_hard_kernel(
2125
+ self, tensors: Dict, B: int, graph: "CSRGraph" = None, gpu_idx: int = 0
2126
+ ):
2127
+ """Launch parity check using hard_dec (for throughput mode)."""
2128
+ if graph is None:
2129
+ graph = self.graphs[gpu_idx]
2130
+ total = B * self.C
2131
+ grid = ((total + self.cfg.rows_per_chk - 1) // self.cfg.rows_per_chk,)
2132
+ problem_key = {
2133
+ "B": B,
2134
+ "C": self.C,
2135
+ "V": self.V,
2136
+ "E": self.E,
2137
+ "ROWS_PER_CHK": self.cfg.rows_per_chk,
2138
+ "dtype": self.dtype_str,
2139
+ "sparsity_hash": graph.sparsity_hash,
2140
+ }
2141
+ meta_base = dict(B=B, C=self.C, V=self.V, E=self.E, ROWS_PER_CHK=self.cfg.rows_per_chk)
2142
+
2143
+ saved = self._get_tuned_meta(
2144
+ kernel_name="parity_from_hard_kernel",
2145
+ problem_key=problem_key,
2146
+ build_cfgs_fn=build_parity_configs,
2147
+ kernel=parity_from_hard_kernel,
2148
+ grid=grid,
2149
+ args=(
2150
+ tensors["hard_dec"],
2151
+ graph.chk_ptr,
2152
+ graph.chk_edges,
2153
+ graph.edge_var,
2154
+ tensors["syndrome"],
2155
+ tensors["check_ok"],
2156
+ ),
2157
+ meta_base=meta_base,
2158
+ number=20,
2159
+ log_name=f"parity_from_hard_kernel[gpu={gpu_idx}]",
2160
+ )
2161
+
2162
+ block_size = int(saved["BLOCK_SIZE"])
2163
+ num_warps = int(saved.get("num_warps", 0))
2164
+ num_stages = int(saved.get("num_stages", 0))
2165
+
2166
+ parity_from_hard_kernel[grid](
2167
+ tensors["hard_dec"],
2168
+ graph.chk_ptr,
2169
+ graph.chk_edges,
2170
+ graph.edge_var,
2171
+ tensors["syndrome"],
2172
+ tensors["check_ok"],
2173
+ **meta_base,
2174
+ BLOCK_SIZE=block_size,
2175
+ num_warps=num_warps,
2176
+ num_stages=num_stages,
2177
+ )
2178
+
2179
+ def _launch_parity_from_hard_compact_with_zero_kernel(
2180
+ self, tensors: Dict, B: int, graph: "CSRGraph" = None, gpu_idx: int = 0
2181
+ ):
2182
+ """Launch fused parity check kernel: zeros inactive lanes and checks parity for active lanes."""
2183
+ if graph is None:
2184
+ graph = self.graphs[gpu_idx]
2185
+ total = B * self.C
2186
+ grid = ((total + self.cfg.rows_per_chk - 1) // self.cfg.rows_per_chk,)
2187
+
2188
+ parity_from_hard_compact_with_zero_kernel[grid](
2189
+ tensors["hard_dec"],
2190
+ graph.chk_ptr,
2191
+ graph.chk_edges,
2192
+ graph.edge_var,
2193
+ tensors["syndrome"],
2194
+ tensors["check_ok"],
2195
+ tensors["active"],
2196
+ B,
2197
+ self.C,
2198
+ self.V,
2199
+ self.E,
2200
+ BLOCK_SIZE=self._rc_parity_block,
2201
+ ROWS_PER_CHK=self.cfg.rows_per_chk,
2202
+ num_warps=self._rc_parity_warps,
2203
+ num_stages=self._rc_parity_stages,
2204
+ )
2205
+
2206
+ def _launch_parity_from_hard_compact_kernel(
2207
+ self,
2208
+ tensors: Dict,
2209
+ B: int,
2210
+ active_idx: torch.Tensor,
2211
+ B_active: int,
2212
+ graph: "CSRGraph" = None,
2213
+ gpu_idx: int = 0,
2214
+ ):
2215
+ """Launch parity check kernel for active lanes only (compact version).
2216
+
2217
+ Uses active_idx indirection to process only B_active lanes instead of all B.
2218
+ """
2219
+ if graph is None:
2220
+ graph = self.graphs[gpu_idx]
2221
+ total = B_active * self.C
2222
+ grid = ((total + self.cfg.rows_per_chk - 1) // self.cfg.rows_per_chk,)
2223
+
2224
+ parity_from_hard_compact_kernel[grid](
2225
+ tensors["hard_dec"],
2226
+ graph.chk_ptr,
2227
+ graph.chk_edges,
2228
+ graph.edge_var,
2229
+ tensors["syndrome"],
2230
+ tensors["check_ok"],
2231
+ active_idx,
2232
+ B,
2233
+ B_active,
2234
+ self.C,
2235
+ self.V,
2236
+ self.E,
2237
+ BLOCK_SIZE=self._rc_parity_block,
2238
+ ROWS_PER_CHK=self.cfg.rows_per_chk,
2239
+ num_warps=self._rc_parity_warps,
2240
+ num_stages=self._rc_parity_stages,
2241
+ )
2242
+
2243
+
2244
+
2245
+ def _check_parity_and_select(
2246
+ self,
2247
+ tensors: Dict,
2248
+ B: int,
2249
+ active_idx: torch.Tensor = None,
2250
+ B_active: int = None,
2251
+ *,
2252
+ force_hard: bool = False,
2253
+ graph: "CSRGraph" = None,
2254
+ gpu_idx: int = 0,
2255
+ prior_batched: bool = False,
2256
+ wj_override: Optional[torch.Tensor] = None,
2257
+ ):
2258
+ """Check parity and select best solutions fully on device (no host reductions).
2259
+
2260
+ Args:
2261
+ tensors: device tensors dict
2262
+ B: batch size
2263
+ active_idx: optional compact active indices
2264
+ B_active: number of active lanes
2265
+ force_hard: if True, use hard_dec parity path regardless of perf mode
2266
+ graph: CSRGraph instance (defaults to self.graphs[gpu_idx])
2267
+ gpu_idx: GPU index (for device string if needed)
2268
+ """
2269
+ if graph is None:
2270
+ graph = self.graphs[gpu_idx]
2271
+ if B_active is not None and B_active == 0:
2272
+ return
2273
+ # Launch the right parity kernel
2274
+ if self.cfg.perf == "throughput" or force_hard:
2275
+ if (
2276
+ self.cfg.perf == "throughput"
2277
+ and B_active is not None
2278
+ and active_idx is not None
2279
+ and B_active < B
2280
+ ):
2281
+ self._launch_parity_from_hard_compact_kernel(
2282
+ tensors, B, active_idx, B_active, graph=graph, gpu_idx=gpu_idx
2283
+ )
2284
+ else:
2285
+ self._launch_parity_from_hard_compact_with_zero_kernel(
2286
+ tensors, B, graph=graph, gpu_idx=gpu_idx
2287
+ )
2288
+ else:
2289
+ self._launch_parity_check_kernel(tensors, B, graph=graph, gpu_idx=gpu_idx)
2290
+
2291
+ # Device-side select using pre-resolved config
2292
+ gpu_id = self.device_ids[gpu_idx]
2293
+ wj_gpu = wj_override if prior_batched else self.wj_per_gpu[gpu_id]
2294
+
2295
+ check_and_select_kernel[(B,)](
2296
+ tensors["check_ok"],
2297
+ tensors["posterior_ratios"],
2298
+ tensors["hard_dec"],
2299
+ wj_gpu,
2300
+ tensors["best_weights"],
2301
+ tensors["best_errors"],
2302
+ tensors["valid_solutions"],
2303
+ tensors["found_count"],
2304
+ tensors["active"],
2305
+ B,
2306
+ self.C,
2307
+ self.V,
2308
+ dtype_enum=self._dtype_enum,
2309
+ BLOCK_C=self._rc_select_block_c,
2310
+ BLOCK_V=self._rc_select_block_v,
2311
+ NUM_SOLS=int(self.stop_nconv),
2312
+ PRIOR_BATCHED=prior_batched,
2313
+ num_warps=self._rc_select_warps,
2314
+ num_stages=1,
2315
+ )
2316
+
2317
+ def decode(
2318
+ self,
2319
+ syndromes: torch.Tensor,
2320
+ *,
2321
+ init_posterior: Optional[torch.Tensor] = None,
2322
+ init_gamma: Optional[torch.Tensor] = None,
2323
+ skip_relay_legs: bool = False,
2324
+ pre_iter_override: Optional[int] = None,
2325
+ return_last_posterior: bool = False,
2326
+ prior_llr: Optional[torch.Tensor] = None,
2327
+ ) -> Dict[str, torch.Tensor]:
2328
+ """Decode syndromes using Relay-BP algorithm with multi-GPU support.
2329
+
2330
+ Args:
2331
+ syndromes: [B, C] syndrome bits (uint8)
2332
+ init_posterior: optional [B, V] tensor; when set, overrides the default
2333
+ log_prior_ratios initialization of posterior_ratios so the
2334
+ pre-iter phase resumes from a caller-supplied posterior, e.g. to
2335
+ continue decoding from a previous call.
2336
+ init_gamma: optional [B, V] tensor; when set, overrides the per-lane
2337
+ gamma vector used during the pre-iter phase (replaces the scalar
2338
+ gamma0 broadcast), so each shot can use its own memory strengths.
2339
+ skip_relay_legs: when True, return immediately after the pre-iter
2340
+ phase instead of running the relay legs (num_sets loop).
2341
+ pre_iter_override: optional override for the number of pre-iter
2342
+ iterations (replaces self.pre_iter for this call only).
2343
+ return_last_posterior: when True, the result dict additionally
2344
+ contains "last_posterior": [B, V]: a clone of
2345
+ posterior_ratios at the end of decode.
2346
+ prior_llr: optional [B, V] tensor of per-shot prior LLRs replacing the
2347
+ shared [1, V] log_prior_ratios for this call (each shot gets
2348
+ its own prior).
2349
+
2350
+ Returns:
2351
+ Dictionary with keys:
2352
+ - "errors": [B, V] decoded errors (uint8) or packed if bitpack_output=True
2353
+ - "weights": [B] solution weights (float32)
2354
+ - "valid_mask": [B] bool, True where found_count >= stop_nconv
2355
+ - "iterations": [B] iteration counts
2356
+ - "found_count": [B] int32 tensor of number of valid solutions found per lane
2357
+ - "unconverged_indices": [U] int32 tensor of batch indices that didn't converge
2358
+ (found_count < stop_nconv). Empty tensor if all converged or fast_max_unconverged_pct=None.
2359
+ Only non-empty when fast_max_unconverged_pct is set and early exit occurred.
2360
+ - "last_posterior" (optional): [B, V] cloned posterior at end of decode.
2361
+ """
2362
+ B = syndromes.shape[0]
2363
+
2364
+ if syndromes.shape[1] != self.C:
2365
+ raise ValueError(f"Expected {self.C} syndrome bits, got {syndromes.shape[1]}")
2366
+
2367
+ # Validate the advanced kwargs.
2368
+ advanced_args_used = (
2369
+ init_posterior is not None
2370
+ or init_gamma is not None
2371
+ or skip_relay_legs
2372
+ or pre_iter_override is not None
2373
+ or return_last_posterior
2374
+ or prior_llr is not None
2375
+ )
2376
+ if advanced_args_used:
2377
+ if self.cfg.perf != "throughput":
2378
+ raise ValueError(
2379
+ "init_posterior / init_gamma / skip_relay_legs / pre_iter_override / "
2380
+ "return_last_posterior are only supported in throughput perf mode "
2381
+ f"(got perf={self.cfg.perf!r})."
2382
+ )
2383
+ if self.num_gpus > 1:
2384
+ raise ValueError(
2385
+ "init_posterior / init_gamma / skip_relay_legs / pre_iter_override / "
2386
+ "return_last_posterior / prior_llr require single-GPU decode "
2387
+ f"(self.num_gpus={self.num_gpus})."
2388
+ )
2389
+ if init_posterior is not None and tuple(init_posterior.shape) != (B, self.V):
2390
+ raise ValueError(
2391
+ f"init_posterior shape {tuple(init_posterior.shape)} != ({B}, {self.V})"
2392
+ )
2393
+ if init_gamma is not None and tuple(init_gamma.shape) != (B, self.V):
2394
+ raise ValueError(f"init_gamma shape {tuple(init_gamma.shape)} != ({B}, {self.V})")
2395
+ if prior_llr is not None and tuple(prior_llr.shape) != (B, self.V):
2396
+ raise ValueError(f"prior_llr shape {tuple(prior_llr.shape)} != ({B}, {self.V})")
2397
+
2398
+ # Path for single GPU: avoid thread/gather overhead
2399
+ if self.num_gpus == 1:
2400
+ gpu_id = self.device_ids[0]
2401
+ gpu_idx = 0 # Index in our device_ids list (always 0 for single GPU)
2402
+ device_str = f"{self.device_type}:{gpu_id}"
2403
+
2404
+ # Set device context for Triton kernel launches
2405
+ with torch.cuda.device(gpu_id):
2406
+ # Move syndromes to GPU (if not already there)
2407
+ if syndromes.device.type != self.device_type or syndromes.device.index != gpu_id:
2408
+ syndromes = syndromes.to(device_str)
2409
+
2410
+ # Allocate tensors on this GPU
2411
+ tensors = self._allocate_device_tensors(B, gpu_id)
2412
+
2413
+ # Use GPU-specific graph and log_prior_ratios
2414
+ graph = self.graphs[gpu_idx]
2415
+ log_prior_ratios = self.log_prior_ratios_per_gpu[gpu_idx]
2416
+ explicit_gammas = (
2417
+ self.explicit_gammas_per_gpu[gpu_idx] if self.explicit_gammas_per_gpu else None
2418
+ )
2419
+
2420
+ prior_batched = prior_llr is not None
2421
+ wj_override = None
2422
+ if prior_batched:
2423
+ plr = prior_llr.to(device=device_str, dtype=self.dtype).contiguous()
2424
+ log_prior_ratios = plr
2425
+ wj_override = torch.where(
2426
+ torch.isfinite(plr), plr, torch.zeros_like(plr)
2427
+ ).contiguous()
2428
+
2429
+ # Run decode loop directly (no thread overhead)
2430
+ return self._decode_loop_single_gpu(
2431
+ syndromes,
2432
+ tensors,
2433
+ graph,
2434
+ log_prior_ratios,
2435
+ explicit_gammas,
2436
+ gpu_idx,
2437
+ init_posterior=init_posterior,
2438
+ init_gamma=init_gamma,
2439
+ skip_relay_legs=skip_relay_legs,
2440
+ pre_iter_override=pre_iter_override,
2441
+ return_last_posterior=return_last_posterior,
2442
+ prior_batched=prior_batched,
2443
+ wj_override=wj_override,
2444
+ )
2445
+ else:
2446
+ # Multi-GPU path: split batch and process in parallel
2447
+ batch_splits = self._split_batch(B, self.num_gpus)
2448
+ results = self._decode_parallel(batch_splits, syndromes)
2449
+ return self._gather_results(results, B, batch_splits)
2450
+
2451
+ def _split_batch(self, B: int, num_gpus: int) -> List[Tuple[int, int]]:
2452
+ """Split batch into roughly equal chunks across GPUs.
2453
+
2454
+ Args:
2455
+ B: Total batch size
2456
+ num_gpus: Number of GPUs
2457
+
2458
+ Returns:
2459
+ List of (start, end) tuples for each GPU
2460
+ """
2461
+ batch_per_gpu = B // num_gpus
2462
+ remainder = B % num_gpus
2463
+
2464
+ splits = []
2465
+ start = 0
2466
+ for i in range(num_gpus):
2467
+ # Distribute remainder across first few GPUs
2468
+ size = batch_per_gpu + (1 if i < remainder else 0)
2469
+ end = start + size
2470
+ splits.append((start, end))
2471
+ start = end
2472
+
2473
+ return splits
2474
+
2475
+ def _decode_parallel(
2476
+ self,
2477
+ batch_splits: List[Tuple[int, int]],
2478
+ syndromes: torch.Tensor,
2479
+ ) -> List[Dict[str, torch.Tensor]]:
2480
+ """Process batch splits on multiple GPUs in parallel.
2481
+
2482
+ Args:
2483
+ batch_splits: List of (start, end) tuples for each GPU
2484
+ syndromes: [B, C] syndrome tensor
2485
+
2486
+ Returns:
2487
+ List of result dictionaries
2488
+ """
2489
+ import threading
2490
+
2491
+ results = [None] * self.num_gpus
2492
+ threads = []
2493
+
2494
+ def decode_on_gpu(gpu_idx: int, start: int, end: int):
2495
+ """Decode batch slice on specific GPU."""
2496
+ # Set device context
2497
+ with torch.cuda.device(self.device_ids[gpu_idx]):
2498
+ # Extract batch slice
2499
+ batch_slice = syndromes[start:end].clone() # Clone to avoid issues
2500
+ B_slice = batch_slice.shape[0]
2501
+
2502
+ if B_slice == 0:
2503
+ # Empty batch slice
2504
+ results[gpu_idx] = None
2505
+ return
2506
+
2507
+ # Move to target GPU
2508
+ device_str = f"{self.device_type}:{self.device_ids[gpu_idx]}"
2509
+ batch_slice = batch_slice.to(device_str)
2510
+
2511
+ # Allocate tensors on this GPU
2512
+ tensors = self._allocate_device_tensors(B_slice, self.device_ids[gpu_idx])
2513
+
2514
+ # Use GPU-specific graph and log_prior_ratios
2515
+ graph = self.graphs[gpu_idx]
2516
+ log_prior_ratios = self.log_prior_ratios_per_gpu[gpu_idx]
2517
+ explicit_gammas = (
2518
+ self.explicit_gammas_per_gpu[gpu_idx] if self.explicit_gammas_per_gpu else None
2519
+ )
2520
+
2521
+ # Run decode loop on this GPU
2522
+ result = self._decode_loop_single_gpu(
2523
+ batch_slice,
2524
+ tensors,
2525
+ graph,
2526
+ log_prior_ratios,
2527
+ explicit_gammas,
2528
+ gpu_idx,
2529
+ )
2530
+ results[gpu_idx] = result
2531
+
2532
+ # Launch threads for parallel execution
2533
+ for gpu_idx, (start, end) in enumerate(batch_splits):
2534
+ if start < end: # Only if batch slice is non-empty
2535
+ thread = threading.Thread(target=decode_on_gpu, args=(gpu_idx, start, end))
2536
+ thread.start()
2537
+ threads.append(thread)
2538
+
2539
+ # Wait for all threads
2540
+ for thread in threads:
2541
+ thread.join()
2542
+
2543
+ return results
2544
+
2545
+ def _gather_results(
2546
+ self,
2547
+ results: List[Dict[str, torch.Tensor]],
2548
+ B: int,
2549
+ batch_splits: List[Tuple[int, int]],
2550
+ ) -> Dict[str, torch.Tensor]:
2551
+ """Gather results from all GPUs and concatenate.
2552
+
2553
+ Args:
2554
+ results: List of result dictionaries from each GPU
2555
+ B: Total batch size
2556
+ batch_splits: List of (start, end) tuples for each GPU (for unconverged index adjustment)
2557
+
2558
+ Returns:
2559
+ Concatenated result dictionary
2560
+ """
2561
+ # Move all results to first GPU for concatenation
2562
+ target_device = f"{self.device_type}:{self.device_ids[0]}"
2563
+
2564
+ gathered = {
2565
+ "errors": [],
2566
+ "weights": [],
2567
+ "valid_mask": [],
2568
+ "iterations": [],
2569
+ "found_count": [],
2570
+ "unconverged_indices": [],
2571
+ }
2572
+
2573
+ for gpu_idx, result in enumerate(results):
2574
+ if result is not None:
2575
+ gathered["errors"].append(result["errors"].to(target_device))
2576
+ gathered["weights"].append(result["weights"].to(target_device))
2577
+ gathered["valid_mask"].append(result["valid_mask"].to(target_device))
2578
+ gathered["iterations"].append(result["iterations"].to(target_device))
2579
+ gathered["found_count"].append(result["found_count"].to(target_device))
2580
+ # Adjust unconverged indices to global batch coordinates
2581
+ if "unconverged_indices" in result and len(result["unconverged_indices"]) > 0:
2582
+ start, _ = batch_splits[gpu_idx]
2583
+ local_indices = result["unconverged_indices"].to(target_device)
2584
+ global_indices = start + local_indices
2585
+ gathered["unconverged_indices"].append(global_indices)
2586
+
2587
+
2588
+ # Concatenate on target device
2589
+ # Handle case where all results might be None (shouldn't happen, but be safe)
2590
+ if not gathered["errors"]:
2591
+ raise RuntimeError("No valid results from any GPU. All decode threads may have failed.")
2592
+
2593
+ # Concatenate unconverged indices if any
2594
+ if gathered["unconverged_indices"]:
2595
+ unconverged = torch.cat(gathered["unconverged_indices"], dim=0)
2596
+ else:
2597
+ unconverged = torch.tensor([], dtype=torch.int32, device=target_device)
2598
+
2599
+ out = {
2600
+ "errors": torch.cat(gathered["errors"], dim=0),
2601
+ "weights": torch.cat(gathered["weights"], dim=0),
2602
+ "valid_mask": torch.cat(gathered["valid_mask"], dim=0),
2603
+ "iterations": torch.cat(gathered["iterations"], dim=0),
2604
+ "found_count": torch.cat(gathered["found_count"], dim=0),
2605
+ "unconverged_indices": unconverged,
2606
+ }
2607
+
2608
+ return out
2609
+
2610
+ def _run_bp_iteration(
2611
+ self,
2612
+ t: int,
2613
+ tensors: Dict,
2614
+ B: int,
2615
+ active_idx: torch.Tensor,
2616
+ B_active: int,
2617
+ graph: "CSRGraph",
2618
+ log_prior_ratios: torch.Tensor,
2619
+ gpu_idx: int,
2620
+ in_leg_python: bool,
2621
+ force_hard: bool = False,
2622
+ skip_all_done_sync: bool = False,
2623
+ prior_batched: bool = False,
2624
+ wj_override: Optional[torch.Tensor] = None,
2625
+ ) -> Tuple[torch.Tensor, int, bool]:
2626
+ """Run a single BP iteration (shared between pre-iter and relay legs).
2627
+
2628
+ Args:
2629
+ t: Iteration index within the current leg
2630
+ tensors: Device tensors dictionary
2631
+ B: Batch size
2632
+ active_idx: Active lane indices tensor
2633
+ B_active: Number of active lanes
2634
+ graph: CSRGraph instance
2635
+ log_prior_ratios: [1, V] log prior ratios
2636
+ gpu_idx: GPU index
2637
+ in_leg_python: Whether we're in a relay leg (affects sought_solutions logic)
2638
+ force_hard: Whether to force hard_dec parity check (pre-iter only)
2639
+
2640
+ Returns:
2641
+ Tuple of (active_idx, B_active, all_done)
2642
+ """
2643
+ # Make explicit local copies of active_idx / B_active so we can mutate
2644
+ # them safely without confusing Python's scoping rules. The function
2645
+ # returns the updated versions to the caller.
2646
+ local_active_idx = active_idx
2647
+ local_B_active = B_active
2648
+
2649
+ # Compute Rust-like alpha schedule
2650
+ if self.normalized_min_sum_alpha is None:
2651
+ self._alpha_current = 1.0
2652
+ else:
2653
+ a = float(self.normalized_min_sum_alpha)
2654
+ if a == 0.0:
2655
+ s = float(self.alpha_iteration_scaling_factor or 1.0)
2656
+ self._alpha_current = 1.0 - (2.0 ** (-((t + 1) / s)))
2657
+ elif a < 0.0:
2658
+ self._alpha_current = 1.0
2659
+ else:
2660
+ self._alpha_current = a
2661
+
2662
+ # Build active lane compaction for efficiency (only for non-throughput mode)
2663
+ # In throughput mode we keep the caller-provided active index set.
2664
+ # For batch=1, skip expensive compaction (always active, no sync needed)
2665
+ if self.cfg.perf == "default":
2666
+ if B == 1:
2667
+ # Skip compaction for batch=1 - always active, avoid sync overhead
2668
+ device_str = self._device_strs[self.device_ids[gpu_idx]]
2669
+ compact_active_idx = torch.tensor([0], dtype=torch.int32, device=device_str)
2670
+ compact_B_active = 1
2671
+ else:
2672
+ compact_active_idx, compact_B_active = self._compact_active_indices_on_device(
2673
+ tensors, B, gpu_idx
2674
+ )
2675
+ else:
2676
+ compact_active_idx, compact_B_active = local_active_idx, local_B_active
2677
+
2678
+ # Message passing: C2V and V2C
2679
+ if self.cfg.perf == "throughput":
2680
+ if local_B_active == 0:
2681
+ pass # nothing to do for BTILE kernels this iteration
2682
+ else:
2683
+ # Check if we should use layered scheduling for this iteration.
2684
+ # layered_k_iters semantics:
2685
+ # 0 -> flood-fill only (no layered iterations)
2686
+ # <0 -> always layered (all iterations in layered mode)
2687
+ # >0 -> use layered for first K iterations, then flood-fill
2688
+ use_layered = (
2689
+ self.scheduling == "layered"
2690
+ and self.block_metadata is not None
2691
+ and (self.layered_k_iters < 0 or t < self.layered_k_iters)
2692
+ )
2693
+
2694
+ if use_layered:
2695
+ # Block-layered scheduling: process blocks sequentially
2696
+ block_meta = self.block_metadata
2697
+ num_blocks = block_meta["num_blocks"]
2698
+ block_chk_start = block_meta["block_chk_start"]
2699
+ block_chk_end = block_meta["block_chk_end"]
2700
+ block_var_ptr = block_meta["block_var_ptr"]
2701
+ block_vars = block_meta["block_vars"]
2702
+
2703
+ # Rotate block order (simple rotation: shift by iteration)
2704
+ # This helps with early syndrome drop by varying block processing order
2705
+ block_perm = [(t + b) % num_blocks for b in range(num_blocks)]
2706
+
2707
+ # Use CPU-side precomputed ranges
2708
+ block_chk_start_cpu = block_meta["block_chk_start_cpu"]
2709
+ block_chk_end_cpu = block_meta["block_chk_end_cpu"]
2710
+ block_var_ptr_cpu = block_meta["block_var_ptr_cpu"]
2711
+
2712
+ if self.layered_block_policy == "color_use_delta_trick":
2713
+ # Delta-trick layered path: use modified C2V that updates L in-place
2714
+ for block_idx_perm in block_perm:
2715
+ chk_start = int(block_chk_start_cpu[block_idx_perm])
2716
+ chk_end = int(block_chk_end_cpu[block_idx_perm])
2717
+ if chk_end <= chk_start:
2718
+ continue
2719
+ var_start = int(block_var_ptr_cpu[block_idx_perm])
2720
+ var_end = int(block_var_ptr_cpu[block_idx_perm + 1])
2721
+ if var_end <= var_start:
2722
+ continue
2723
+ # C2V delta-trick for this block (updates nuT and L)
2724
+ self._launch_c2v_btile(
2725
+ tensors,
2726
+ B,
2727
+ local_active_idx,
2728
+ local_B_active,
2729
+ graph=graph,
2730
+ gpu_idx=gpu_idx,
2731
+ chk_start=chk_start,
2732
+ chk_end=chk_end,
2733
+ use_delta=True,
2734
+ )
2735
+ # After all blocks, update posterior_ratios and hard_dec from L
2736
+ BLOCK_V = 128
2737
+ bt = BLOCK_V
2738
+ grid_dim1 = (local_B_active + bt - 1) // bt
2739
+ grid = (self.V, grid_dim1)
2740
+ post_from_L_kernel[grid](
2741
+ tensors["L"],
2742
+ tensors["posterior_ratios"],
2743
+ tensors["hard_dec"],
2744
+ local_active_idx,
2745
+ B,
2746
+ local_B_active,
2747
+ self.V,
2748
+ dtype_enum=self._dtype_enum,
2749
+ BLOCK_V=BLOCK_V,
2750
+ )
2751
+ else:
2752
+ for block_idx_perm in block_perm:
2753
+ chk_start = int(block_chk_start_cpu[block_idx_perm])
2754
+ chk_end = int(block_chk_end_cpu[block_idx_perm])
2755
+
2756
+ # Skip empty blocks (can occur with edge-balanced splitting on irregular degree distributions)
2757
+ if chk_end <= chk_start:
2758
+ continue
2759
+
2760
+ # Get variable range for this block
2761
+ var_start = int(block_var_ptr_cpu[block_idx_perm])
2762
+ var_end = int(block_var_ptr_cpu[block_idx_perm + 1])
2763
+
2764
+ # Skip if no variables in this block
2765
+ if var_end <= var_start:
2766
+ continue
2767
+
2768
+ # C2V for block
2769
+ self._launch_c2v_btile(
2770
+ tensors,
2771
+ B,
2772
+ local_active_idx,
2773
+ local_B_active,
2774
+ graph=graph,
2775
+ gpu_idx=gpu_idx,
2776
+ chk_start=chk_start,
2777
+ chk_end=chk_end,
2778
+ )
2779
+ # V2C for block vars
2780
+ self._launch_v2c_btile(
2781
+ tensors,
2782
+ B,
2783
+ write_hard=True,
2784
+ active_idx=local_active_idx,
2785
+ B_active=local_B_active,
2786
+ graph=graph,
2787
+ log_prior_ratios=log_prior_ratios,
2788
+ gpu_idx=gpu_idx,
2789
+ block_vars=block_vars,
2790
+ block_var_ptr=block_var_ptr,
2791
+ block_idx=block_idx_perm,
2792
+ prior_batched=prior_batched,
2793
+ )
2794
+ else:
2795
+ # Flood-fill scheduling: process all checks then all vars
2796
+ self._launch_c2v_btile(
2797
+ tensors,
2798
+ B,
2799
+ local_active_idx,
2800
+ local_B_active,
2801
+ graph=graph,
2802
+ gpu_idx=gpu_idx,
2803
+ )
2804
+ self._launch_v2c_btile(
2805
+ tensors,
2806
+ B,
2807
+ write_hard=True,
2808
+ active_idx=local_active_idx,
2809
+ B_active=local_B_active,
2810
+ graph=graph,
2811
+ log_prior_ratios=log_prior_ratios,
2812
+ gpu_idx=gpu_idx,
2813
+ block_vars=None,
2814
+ block_var_ptr=None,
2815
+ block_idx=-1,
2816
+ prior_batched=prior_batched,
2817
+ )
2818
+ else:
2819
+ self._launch_c2v_kernel(tensors, B, graph=graph, gpu_idx=gpu_idx)
2820
+ # Fused V2C + gamma mixing updates lambda_ internally
2821
+ self._launch_v2c_fused_gamma_kernel(
2822
+ tensors,
2823
+ B,
2824
+ graph=graph,
2825
+ log_prior_ratios=log_prior_ratios,
2826
+ gpu_idx=gpu_idx,
2827
+ prior_batched=prior_batched,
2828
+ )
2829
+
2830
+ # Check parity and select solutions every iteration
2831
+ # Use compacted indices in non-throughput mode; in throughput mode we
2832
+ # pass through the caller-provided active index set.
2833
+ self._check_parity_and_select(
2834
+ tensors,
2835
+ B,
2836
+ compact_active_idx,
2837
+ compact_B_active,
2838
+ force_hard=force_hard,
2839
+ graph=graph,
2840
+ gpu_idx=gpu_idx,
2841
+ prior_batched=prior_batched,
2842
+ wj_override=wj_override,
2843
+ )
2844
+ # Device-side freeze + iter_counter increment + all-done reduction (fused)
2845
+ freeze_finished_lanes_kernel[(B,)](
2846
+ tensors["best_errors"],
2847
+ tensors["hard_dec"],
2848
+ tensors["gamma"],
2849
+ tensors["active"],
2850
+ tensors["found_count"],
2851
+ tensors["first_iter"],
2852
+ tensors["iter_counter"],
2853
+ tensors["sought_solutions"],
2854
+ self.stop_nconv,
2855
+ in_leg_python,
2856
+ True, # count_iteration
2857
+ self.V,
2858
+ tensors["all_done_flag"],
2859
+ tensors["arrival_counter"],
2860
+ B,
2861
+ )
2862
+ # Active compaction eliminated: btile kernels use active[] mask directly to skip
2863
+ # frozen lanes, avoiding count_active_blocks + compare + .item() sync overhead.
2864
+
2865
+ # Check flag every iteration to match Rust behavior exactly
2866
+ if skip_all_done_sync:
2867
+ # Skip sync - will be checked at chunk boundary
2868
+ all_done = False
2869
+ else:
2870
+ all_done = tensors["all_done_flag"].item()
2871
+ if all_done:
2872
+ tensors["all_done_flag"].fill_(0)
2873
+ tensors["in_leg"].fill_(0)
2874
+
2875
+ return local_active_idx, local_B_active, all_done
2876
+
2877
+ def _unconverged_hard_dec(self, tensors: Dict) -> torch.Tensor:
2878
+ """Return the [B, V] hard decision used for lanes with no valid solution.
2879
+
2880
+ Args:
2881
+ tensors: Device tensors dictionary.
2882
+
2883
+ Returns:
2884
+ ``first_leg_hard_dec`` for "first_leg", the live ``hard_dec`` for "last_leg".
2885
+ """
2886
+ if self.unconverged_correction == "first_leg":
2887
+ return tensors["first_leg_hard_dec"]
2888
+ return tensors["hard_dec"]
2889
+
2890
+ def _check_fast_max_unconverged_pct(
2891
+ self, tensors: Dict, B: int, leg_count: int = -1
2892
+ ) -> Optional[Dict[str, torch.Tensor]]:
2893
+ """Check if early exit threshold is met and return results if so.
2894
+
2895
+ Args:
2896
+ tensors: Device tensors dictionary
2897
+ B: Batch size
2898
+ leg_count: Current leg count (for debugging/logging, -1 if unknown)
2899
+
2900
+ Returns:
2901
+ Result dictionary if early exit threshold met, None otherwise
2902
+ """
2903
+ if self.fast_max_unconverged_pct is None:
2904
+ return None
2905
+
2906
+ unconverged_count = (tensors["found_count"] < self.stop_nconv).sum().item()
2907
+ unconverged_pct = unconverged_count / B if B > 0 else 0.0
2908
+ # Only exit early if the percentage of unconverged cases is < threshold
2909
+ # AND at least some cases have converged (unconverged_pct < 1.0)
2910
+ # This ensures we don't exit when all cases are still unconverged
2911
+ if unconverged_pct < self.fast_max_unconverged_pct and unconverged_pct < 1.0:
2912
+ # Early exit: compute unconverged indices and return
2913
+ unconverged_mask = tensors["found_count"] < self.stop_nconv
2914
+ unconverged_idx = torch.where(unconverged_mask)[0]
2915
+ has_solution = torch.isfinite(tensors["best_weights"])
2916
+ fallback_errors = torch.where(
2917
+ has_solution.view(-1, 1),
2918
+ tensors["best_errors"],
2919
+ self._unconverged_hard_dec(tensors),
2920
+ )
2921
+ errors = bitpack_errors(fallback_errors) if self.bitpack_output else fallback_errors
2922
+ # Compute iterations for all lanes (converged lanes use first_iter, unconverged use iter_counter)
2923
+ # Note: Unconverged lanes have partial iterations from early exit, but they will be filtered out
2924
+ # by the caller and reprocessed with full iterations in the difficult batch.
2925
+ iters = torch.where(
2926
+ tensors["first_iter"] >= 0, tensors["first_iter"], tensors["iter_counter"]
2927
+ )
2928
+ result = {
2929
+ "errors": errors,
2930
+ "weights": tensors["best_weights"],
2931
+ "valid_mask": tensors["found_count"] >= self.stop_nconv,
2932
+ "iterations": iters,
2933
+ "found_count": tensors["found_count"].to(torch.int32),
2934
+ "unconverged_indices": unconverged_idx.to(torch.int32),
2935
+ }
2936
+ return result
2937
+ return None
2938
+
2939
+ def _run_bp_iterations_grid_stride(
2940
+ self,
2941
+ start_iter: int,
2942
+ max_iter: int,
2943
+ tensors: Dict,
2944
+ B: int,
2945
+ active_idx: torch.Tensor,
2946
+ B_active: int,
2947
+ graph: "CSRGraph",
2948
+ log_prior_ratios: torch.Tensor,
2949
+ gpu_idx: int,
2950
+ in_leg_python: bool,
2951
+ force_hard: bool = False,
2952
+ prior_batched: bool = False,
2953
+ wj_override: Optional[torch.Tensor] = None,
2954
+ ) -> Tuple[torch.Tensor, int, bool, int]:
2955
+ """Run multiple BP iterations using C2V grid-stride kernel with separate V2C/parity/select phases.
2956
+
2957
+ This method uses c2v_min_sum_grid_stride_kernel (C2V only) with grid-stride parallelism
2958
+ to efficiently split work across GPU SMs. V2C, parity, and selection run in separate
2959
+ kernel launches after C2V completes.
2960
+
2961
+ Performance:
2962
+ - Uses grid-stride parallelism: grid = (B * work_items_per_batch,) splits work across SMs
2963
+ - 5 kernel launches per iteration (C2V, V2C, parity, select, freeze)
2964
+ - 1 sync per iteration for convergence checking
2965
+
2966
+ Returns:
2967
+ Tuple of (active_idx, B_active, all_done, iterations_run)
2968
+ """
2969
+ if graph is None:
2970
+ graph = self.graphs[gpu_idx]
2971
+ if log_prior_ratios is None:
2972
+ log_prior_ratios = self.log_prior_ratios_per_gpu[gpu_idx]
2973
+
2974
+ gpu_id = self.device_ids[gpu_idx]
2975
+ wj_gpu = wj_override if prior_batched else self.wj_per_gpu[gpu_id]
2976
+
2977
+ alpha_base = self.normalized_min_sum_alpha or 0.0
2978
+ alpha_scaling = float(self.alpha_iteration_scaling_factor or 1.0)
2979
+ use_alpha = self.normalized_min_sum_alpha is not None
2980
+ use_beta = self.offset_min_sum_beta is not None
2981
+ beta = self.offset_min_sum_beta or 0.0
2982
+
2983
+ # Grid calculation depends on ROWS_PER_CHK/ROWS_PER_VAR (will be determined by autotuner)
2984
+ # We need to provide a grid for autotuning, so use a reasonable default
2985
+ # The actual grid will be recalculated after we get the tuned config
2986
+ work_dim = max(self.C, self.V)
2987
+ default_rows_per_chk = 1
2988
+ default_rows_per_var = 1
2989
+ default_grid = (B * work_dim,) # Grid for ROWS_PER_CHK=1 (will be adjusted)
2990
+
2991
+ # Autotune iteration-fused kernel
2992
+ problem_key = {
2993
+ "B": B,
2994
+ "C": self.C,
2995
+ "V": self.V,
2996
+ "E": self.E,
2997
+ "sparsity_hash": graph.sparsity_hash,
2998
+ "dtype_enum": self._dtype_enum,
2999
+ "use_alpha": use_alpha,
3000
+ "use_beta": use_beta,
3001
+ "in_leg": 1 if in_leg_python else 0,
3002
+ }
3003
+
3004
+ # Grid function that adjusts grid based on ROWS_PER_CHK
3005
+ def _grid_fn(cfg):
3006
+ rows_chk = int(cfg.get("ROWS_PER_CHK", default_rows_per_chk))
3007
+ # Each program processes ROWS_PER_CHK consecutive checks/vars
3008
+ # work_items_per_batch = (work_dim + ROWS_PER_CHK - 1) // ROWS_PER_CHK
3009
+ # grid = B * work_items_per_batch
3010
+ work_dim = max(self.C, self.V)
3011
+ work_items_per_batch = (work_dim + rows_chk - 1) // rows_chk
3012
+ grid_size = B * work_items_per_batch
3013
+ return (grid_size,)
3014
+
3015
+ saved = self._get_tuned_meta(
3016
+ kernel_name="c2v_min_sum_grid_stride_kernel",
3017
+ problem_key=problem_key,
3018
+ build_cfgs_fn=build_c2v_grid_stride_configs,
3019
+ kernel=c2v_min_sum_grid_stride_kernel,
3020
+ grid=default_grid,
3021
+ args=(
3022
+ tensors["mu"],
3023
+ tensors["nu"],
3024
+ graph.chk_ptr,
3025
+ graph.chk_edges,
3026
+ graph.var_ptr,
3027
+ graph.var_edges,
3028
+ graph.edge_var,
3029
+ tensors["lambda_"],
3030
+ tensors["posterior_ratios"],
3031
+ tensors["hard_dec"],
3032
+ tensors["syndrome"],
3033
+ log_prior_ratios,
3034
+ tensors["gamma"],
3035
+ tensors["active"],
3036
+ tensors["check_ok"],
3037
+ tensors["best_weights"],
3038
+ tensors["best_errors"],
3039
+ tensors["valid_solutions"],
3040
+ tensors["found_count"],
3041
+ wj_gpu,
3042
+ B,
3043
+ self.C,
3044
+ self.V,
3045
+ self.E,
3046
+ 0, # iteration (will vary)
3047
+ alpha_base,
3048
+ alpha_scaling,
3049
+ use_alpha,
3050
+ use_beta,
3051
+ beta,
3052
+ 1 if in_leg_python else 0,
3053
+ self._dtype_enum,
3054
+ ),
3055
+ meta_base={},
3056
+ number=20,
3057
+ grid_fn=_grid_fn,
3058
+ log_name=f"c2v_min_sum_grid_stride_kernel[gpu={gpu_idx}]",
3059
+ )
3060
+
3061
+ block_size = int(saved["BLOCK_SIZE"])
3062
+ num_warps = int(saved.get("num_warps", 4))
3063
+ num_stages = int(saved.get("num_stages", 1))
3064
+ rows_per_chk = int(saved.get("ROWS_PER_CHK", default_rows_per_chk))
3065
+ rows_per_var = int(saved.get("ROWS_PER_VAR", default_rows_per_var))
3066
+
3067
+ # Recalculate grid based on tuned ROWS_PER_CHK
3068
+ # Each program processes ROWS_PER_CHK consecutive checks/vars
3069
+ work_dim = max(self.C, self.V)
3070
+ work_items_per_batch = (work_dim + rows_per_chk - 1) // rows_per_chk
3071
+ grid = (B * work_items_per_batch,)
3072
+
3073
+ # Cache other kernel configs to avoid overhead
3074
+ from .kernels import (
3075
+ check_and_select_kernel,
3076
+ v2c_and_marginals_fused_gamma_kernel,
3077
+ parity_from_hard_kernel,
3078
+ freeze_finished_lanes_kernel,
3079
+ )
3080
+ from .autotune import build_check_select_configs, build_v2c_configs, build_parity_configs
3081
+
3082
+ # Cache V2C config
3083
+ v2c_problem_key = {
3084
+ "B": B,
3085
+ "V": self.V,
3086
+ "E": self.E,
3087
+ "dtype": self.dtype_str,
3088
+ "sparsity_hash": graph.sparsity_hash,
3089
+ }
3090
+
3091
+ def _v2c_grid_fn(cfg):
3092
+ rows = int(cfg.get("ROWS_PER_VAR", self.cfg.rows_per_var))
3093
+ return (((B * self.V) + rows - 1) // rows,)
3094
+
3095
+ v2c_saved = self._get_tuned_meta(
3096
+ kernel_name="v2c_and_marginals_fused_gamma_kernel",
3097
+ problem_key=v2c_problem_key,
3098
+ build_cfgs_fn=build_v2c_configs,
3099
+ kernel=v2c_and_marginals_fused_gamma_kernel,
3100
+ grid=(1,),
3101
+ args=(
3102
+ tensors["nu"],
3103
+ tensors["mu"],
3104
+ graph.var_ptr,
3105
+ graph.var_edges,
3106
+ tensors["lambda_"],
3107
+ tensors["posterior_ratios"],
3108
+ tensors["hard_dec"],
3109
+ tensors["active"],
3110
+ log_prior_ratios,
3111
+ tensors["gamma"],
3112
+ ),
3113
+ meta_base=dict(
3114
+ B=B, V=self.V, E=self.E, dtype_enum=self._dtype_enum, PRIOR_BATCHED=prior_batched
3115
+ ),
3116
+ number=20,
3117
+ grid_fn=_v2c_grid_fn,
3118
+ log_name=f"v2c_and_marginals_fused_gamma_kernel[gpu={gpu_idx}]",
3119
+ )
3120
+ v2c_rows = int(v2c_saved.get("ROWS_PER_VAR", self.cfg.rows_per_var))
3121
+ v2c_grid = (((B * self.V) + v2c_rows - 1) // v2c_rows,)
3122
+ v2c_block_size = int(v2c_saved.get("BLOCK_SIZE", 64))
3123
+ v2c_num_warps = int(v2c_saved.get("num_warps", 0))
3124
+ v2c_num_stages = int(v2c_saved.get("num_stages", 0))
3125
+
3126
+ # Cache parity config
3127
+ parity_grid = ((B * self.C + self.cfg.rows_per_chk - 1) // self.cfg.rows_per_chk,)
3128
+ parity_problem_key = {
3129
+ "B": B,
3130
+ "C": self.C,
3131
+ "V": self.V,
3132
+ "E": self.E,
3133
+ "ROWS_PER_CHK": self.cfg.rows_per_chk,
3134
+ "dtype": self.dtype_str,
3135
+ "sparsity_hash": graph.sparsity_hash,
3136
+ }
3137
+ parity_meta_base = dict(
3138
+ B=B, C=self.C, V=self.V, E=self.E, ROWS_PER_CHK=self.cfg.rows_per_chk
3139
+ )
3140
+
3141
+ parity_saved = self._get_tuned_meta(
3142
+ kernel_name="parity_from_hard_kernel",
3143
+ problem_key=parity_problem_key,
3144
+ build_cfgs_fn=build_parity_configs,
3145
+ kernel=parity_from_hard_kernel,
3146
+ grid=parity_grid,
3147
+ args=(
3148
+ tensors["hard_dec"],
3149
+ graph.chk_ptr,
3150
+ graph.chk_edges,
3151
+ graph.edge_var,
3152
+ tensors["syndrome"],
3153
+ tensors["check_ok"],
3154
+ ),
3155
+ meta_base=parity_meta_base,
3156
+ number=20,
3157
+ log_name=f"parity_from_hard_kernel[gpu={gpu_idx}]",
3158
+ )
3159
+ parity_block_size = int(parity_saved["BLOCK_SIZE"])
3160
+ parity_num_warps = int(parity_saved.get("num_warps", 0))
3161
+ parity_num_stages = int(parity_saved.get("num_stages", 0))
3162
+
3163
+ # Cache select config
3164
+ select_grid = (B,)
3165
+ select_problem_key = {
3166
+ "B": B,
3167
+ "C": self.C,
3168
+ "V": self.V,
3169
+ "dtype": self.dtype_str,
3170
+ "sparsity_hash": graph.sparsity_hash,
3171
+ }
3172
+ select_saved = self._get_tuned_meta(
3173
+ kernel_name="check_and_select_kernel",
3174
+ problem_key=select_problem_key,
3175
+ build_cfgs_fn=build_check_select_configs,
3176
+ kernel=check_and_select_kernel,
3177
+ grid=select_grid,
3178
+ args=(
3179
+ tensors["check_ok"],
3180
+ tensors["posterior_ratios"],
3181
+ tensors["hard_dec"],
3182
+ wj_gpu,
3183
+ tensors["best_weights"],
3184
+ tensors["best_errors"],
3185
+ tensors["valid_solutions"],
3186
+ tensors["found_count"],
3187
+ tensors["active"],
3188
+ ),
3189
+ meta_base=dict(
3190
+ B=B,
3191
+ C=self.C,
3192
+ V=self.V,
3193
+ dtype_enum=self._dtype_enum,
3194
+ NUM_SOLS=int(self.stop_nconv),
3195
+ PRIOR_BATCHED=prior_batched,
3196
+ ),
3197
+ number=20,
3198
+ log_name=f"check_and_select_kernel[gpu={gpu_idx}]",
3199
+ )
3200
+ select_block_c = int(select_saved.get("BLOCK_C", 256))
3201
+ select_block_v = int(select_saved.get("BLOCK_V", 256))
3202
+ select_num_warps = int(select_saved.get("num_warps", 1))
3203
+ select_num_stages = int(select_saved.get("num_stages", 1))
3204
+
3205
+ C_val = self.C
3206
+ V_val = self.V
3207
+ E_val = self.E
3208
+ dtype_enum_val = self._dtype_enum
3209
+ stop_nconv_val = self.stop_nconv
3210
+ in_leg_val = 1 if in_leg_python else 0
3211
+
3212
+ # Cache tensor references
3213
+ active_tensor = tensors["active"]
3214
+ iter_counter_tensor = tensors["iter_counter"]
3215
+ mu_tensor = tensors["mu"]
3216
+ nu_tensor = tensors["nu"]
3217
+ lam_tensor = tensors["lambda_"]
3218
+ posterior_tensor = tensors["posterior_ratios"]
3219
+ hard_dec_tensor = tensors["hard_dec"]
3220
+ syndrome_tensor = tensors["syndrome"]
3221
+ gamma_tensor = tensors["gamma"]
3222
+ check_ok_tensor = tensors["check_ok"]
3223
+ best_weights_tensor = tensors["best_weights"]
3224
+ best_errors_tensor = tensors["best_errors"]
3225
+ valid_solutions_tensor = tensors["valid_solutions"]
3226
+ found_count_tensor = tensors["found_count"]
3227
+ all_done_flag_tensor = tensors["all_done_flag"]
3228
+ sought_solutions_tensor = tensors["sought_solutions"]
3229
+ first_iter_tensor = tensors["first_iter"]
3230
+
3231
+ # Cache graph pointers
3232
+ chk_ptr = graph.chk_ptr
3233
+ chk_edges = graph.chk_edges
3234
+ var_ptr = graph.var_ptr
3235
+ var_edges = graph.var_edges
3236
+ edge_var = graph.edge_var
3237
+
3238
+ # For B=1, ensure active[0] is always 1 (lane is always active)
3239
+ if B == 1:
3240
+ active_tensor[0] = 1
3241
+
3242
+ t = start_iter
3243
+ all_done = False
3244
+
3245
+ while t < start_iter + max_iter and not all_done:
3246
+ # Phase 1: C2V (grid-stride kernel with batch-organized pattern for better cache locality)
3247
+ c2v_min_sum_grid_stride_kernel[grid](
3248
+ mu_tensor,
3249
+ nu_tensor,
3250
+ chk_ptr,
3251
+ chk_edges,
3252
+ var_ptr,
3253
+ var_edges,
3254
+ edge_var,
3255
+ lam_tensor,
3256
+ posterior_tensor,
3257
+ hard_dec_tensor,
3258
+ syndrome_tensor,
3259
+ log_prior_ratios,
3260
+ gamma_tensor,
3261
+ active_tensor,
3262
+ check_ok_tensor,
3263
+ best_weights_tensor,
3264
+ best_errors_tensor,
3265
+ valid_solutions_tensor,
3266
+ found_count_tensor,
3267
+ wj_gpu,
3268
+ B=B,
3269
+ C=C_val,
3270
+ V=V_val,
3271
+ E=E_val,
3272
+ iteration=t,
3273
+ alpha_base=alpha_base,
3274
+ alpha_scaling=alpha_scaling,
3275
+ use_alpha=use_alpha,
3276
+ use_beta=use_beta,
3277
+ beta=beta,
3278
+ in_leg=in_leg_val,
3279
+ dtype_enum=dtype_enum_val,
3280
+ BLOCK_SIZE=block_size,
3281
+ ROWS_PER_CHK=rows_per_chk,
3282
+ ROWS_PER_VAR=rows_per_var,
3283
+ num_warps=num_warps,
3284
+ num_stages=num_stages,
3285
+ )
3286
+
3287
+ # Phase 2: V2C
3288
+ v2c_and_marginals_fused_gamma_kernel[v2c_grid](
3289
+ nu_tensor,
3290
+ mu_tensor,
3291
+ var_ptr,
3292
+ var_edges,
3293
+ lam_tensor,
3294
+ posterior_tensor,
3295
+ hard_dec_tensor,
3296
+ active_tensor,
3297
+ log_prior_ratios,
3298
+ gamma_tensor,
3299
+ B=B,
3300
+ V=V_val,
3301
+ E=E_val,
3302
+ dtype_enum=dtype_enum_val,
3303
+ BLOCK_SIZE=v2c_block_size,
3304
+ ROWS_PER_VAR=v2c_rows,
3305
+ PRIOR_BATCHED=prior_batched,
3306
+ num_warps=v2c_num_warps,
3307
+ num_stages=v2c_num_stages,
3308
+ )
3309
+
3310
+ # Phase 3: Parity
3311
+ parity_from_hard_kernel[parity_grid](
3312
+ hard_dec_tensor,
3313
+ chk_ptr,
3314
+ chk_edges,
3315
+ edge_var,
3316
+ syndrome_tensor,
3317
+ check_ok_tensor,
3318
+ **parity_meta_base,
3319
+ BLOCK_SIZE=parity_block_size,
3320
+ num_warps=parity_num_warps,
3321
+ num_stages=parity_num_stages,
3322
+ )
3323
+
3324
+ # Phase 4: Selection
3325
+ check_and_select_kernel[select_grid](
3326
+ check_ok_tensor,
3327
+ posterior_tensor,
3328
+ hard_dec_tensor,
3329
+ wj_gpu,
3330
+ best_weights_tensor,
3331
+ best_errors_tensor,
3332
+ valid_solutions_tensor,
3333
+ found_count_tensor,
3334
+ active_tensor,
3335
+ B=B,
3336
+ C=C_val,
3337
+ V=V_val,
3338
+ dtype_enum=dtype_enum_val,
3339
+ BLOCK_C=select_block_c,
3340
+ BLOCK_V=select_block_v,
3341
+ NUM_SOLS=int(self.stop_nconv),
3342
+ PRIOR_BATCHED=prior_batched,
3343
+ num_warps=select_num_warps,
3344
+ num_stages=select_num_stages,
3345
+ )
3346
+
3347
+ # Phase 5: Freeze and increment iter_counter
3348
+ freeze_finished_lanes_kernel[(B,)](
3349
+ best_errors_tensor,
3350
+ hard_dec_tensor,
3351
+ gamma_tensor,
3352
+ active_tensor,
3353
+ found_count_tensor,
3354
+ first_iter_tensor,
3355
+ iter_counter_tensor,
3356
+ sought_solutions_tensor,
3357
+ stop_nconv_val,
3358
+ in_leg_val,
3359
+ True, # count_iteration
3360
+ V_val,
3361
+ all_done_flag_tensor,
3362
+ tensors["arrival_counter"],
3363
+ B,
3364
+ )
3365
+
3366
+ # all-done check is fused into freeze kernel (last-arriving program does reduction)
3367
+ all_done = all_done_flag_tensor.item() != 0
3368
+ if all_done:
3369
+ all_done_flag_tensor.fill_(0)
3370
+ break
3371
+
3372
+ t += 1
3373
+
3374
+ # Get iterations run
3375
+ if B == 1:
3376
+ iterations_run = int(iter_counter_tensor[0].item())
3377
+ else:
3378
+ iterations_run = int(iter_counter_tensor.max().item())
3379
+
3380
+ # Update active indices
3381
+ if B == 1:
3382
+ active_idx = torch.tensor([0], dtype=torch.int32, device=tensors["active"].device)
3383
+ B_active = 1
3384
+ else:
3385
+ active_mask = tensors["active"] != 0
3386
+ active_idx = torch.where(active_mask)[0].to(torch.int32)
3387
+ B_active = int(active_idx.shape[0])
3388
+
3389
+ if all_done:
3390
+ tensors["in_leg"].fill_(0)
3391
+
3392
+ return active_idx, B_active, all_done, iterations_run
3393
+
3394
+ def _decode_loop_single_gpu(
3395
+ self,
3396
+ syndromes: torch.Tensor,
3397
+ tensors: Dict,
3398
+ graph: "CSRGraph",
3399
+ log_prior_ratios: torch.Tensor,
3400
+ explicit_gammas: Optional[torch.Tensor],
3401
+ gpu_idx: int,
3402
+ *,
3403
+ init_posterior: Optional[torch.Tensor] = None,
3404
+ init_gamma: Optional[torch.Tensor] = None,
3405
+ skip_relay_legs: bool = False,
3406
+ pre_iter_override: Optional[int] = None,
3407
+ return_last_posterior: bool = False,
3408
+ prior_batched: bool = False,
3409
+ wj_override: Optional[torch.Tensor] = None,
3410
+ ) -> Dict[str, torch.Tensor]:
3411
+ """Execute the decode loop on a single GPU.
3412
+
3413
+ Args:
3414
+ syndromes: [B, C] syndrome bits on this GPU
3415
+ tensors: Device tensors dictionary for this GPU
3416
+ graph: CSRGraph instance for this GPU
3417
+ log_prior_ratios: [1, V] log prior ratios on this GPU
3418
+ explicit_gammas: [K, V] explicit gammas on this GPU (or None)
3419
+ gpu_idx: GPU index (for device string)
3420
+ init_posterior, init_gamma, skip_relay_legs, pre_iter_override,
3421
+ return_last_posterior: see ``decode``. Validated by caller.
3422
+
3423
+ Returns:
3424
+ Result dictionary with errors, weights, valid_mask, iterations, unconverged_indices
3425
+ """
3426
+ B = syndromes.shape[0]
3427
+ device_str = f"{self.device_type}:{self.device_ids[gpu_idx]}"
3428
+
3429
+ # One-time allocations for per-lane freezing and iteration accounting
3430
+ if "first_iter" not in tensors:
3431
+ tensors["first_iter"] = torch.full((B,), -1, dtype=torch.int32, device=device_str)
3432
+ if "iter_counter" not in tensors:
3433
+ tensors["iter_counter"] = torch.zeros(B, dtype=torch.int32, device=device_str)
3434
+ if "active" not in tensors:
3435
+ tensors["active"] = torch.ones(B, dtype=torch.uint8, device=device_str)
3436
+ # Reset active lanes at the start of every decode
3437
+ tensors["active"].fill_(1)
3438
+ # Reset per-decode iteration accounting and frozen state
3439
+ tensors["first_iter"].fill_(-1)
3440
+ tensors["iter_counter"].fill_(0)
3441
+ tensors["all_done_flag"].fill_(0)
3442
+ tensors["in_leg"].fill_(0)
3443
+ tensors["old_active_count"].fill_(-1)
3444
+
3445
+ # Copy syndromes to device
3446
+ tensors["syndrome"][:] = syndromes
3447
+
3448
+ # Initialize beliefs from priors
3449
+ # lambda_: Start with prior log-likelihood ratios (Rust: log_prior_ratios)
3450
+ tensors["lambda_"].copy_(log_prior_ratios.expand(B, -1)) # Broadcast prior LLRs robustly
3451
+ # posterior_ratios: Initialize to log_prior_ratios for first iteration's gamma mixing
3452
+ # This matches Rust: posterior_ratios initialized to log_prior_ratios when gamma0 is set
3453
+ # (Rust line 157-161: posterior_ratios = log_prior_ratios if gamma0.is_some() else zeros)
3454
+ # For Relay-BP, gamma0 is always set, so we initialize posterior_ratios to log_prior_ratios.
3455
+ # Caller override: replace with the supplied posterior.
3456
+ if init_posterior is not None:
3457
+ tensors["posterior_ratios"].copy_(init_posterior.to(self.dtype))
3458
+ else:
3459
+ tensors["posterior_ratios"].copy_(log_prior_ratios.expand(B, -1))
3460
+ tensors["best_weights"].fill_(float("inf"))
3461
+ tensors["found_count"].fill_(0)
3462
+ tensors["valid_solutions"].fill_(0)
3463
+ # Initialize sought_solutions to 1 for pre_iter (will be updated for relay legs)
3464
+ tensors["sought_solutions"].fill_(1)
3465
+
3466
+ # Initialize messages (Rust-compatible: nu=zero, mu=priors)
3467
+ if self.cfg.perf == "throughput":
3468
+ # Rust: initialize_check_to_variable() -> nu = zero
3469
+ # Rust: initialize_variable_to_check() -> mu = log_prior_ratios
3470
+ self._launch_init_messages_with_priors_EB(
3471
+ tensors["muT"],
3472
+ tensors["nuT"],
3473
+ B,
3474
+ graph=graph,
3475
+ log_prior_ratios=log_prior_ratios,
3476
+ gpu_idx=gpu_idx,
3477
+ prior_batched=prior_batched,
3478
+ )
3479
+ # Delta-trick: initialize L to current marginals (lam_mixed + sum(nu)) for this phase
3480
+ # by running one standard V2C pass, then copying posterior_ratios -> L.
3481
+ if self._use_delta_trick:
3482
+ self._launch_v2c_btile(
3483
+ tensors,
3484
+ B,
3485
+ write_hard=True,
3486
+ active_idx=None,
3487
+ B_active=None,
3488
+ graph=graph,
3489
+ log_prior_ratios=log_prior_ratios,
3490
+ gpu_idx=gpu_idx,
3491
+ block_vars=None,
3492
+ block_var_ptr=None,
3493
+ block_idx=-1,
3494
+ prior_batched=prior_batched,
3495
+ )
3496
+ tensors["L"].copy_(tensors["posterior_ratios"])
3497
+ else:
3498
+ self._launch_init_kernel(
3499
+ tensors, B, graph=graph, log_prior_ratios=log_prior_ratios, gpu_idx=gpu_idx
3500
+ )
3501
+
3502
+ # Pre-iterations (T0) with uniform gamma0 (ordered memory phase)
3503
+ if self.algo == "plain":
3504
+ # Plain BP: no memory mixing; λ remains λ₀ each iteration
3505
+ tensors["gamma"].fill_(0.0)
3506
+ elif init_gamma is not None:
3507
+ # Caller override: per-lane gamma vector for the pre-iter phase.
3508
+ tensors["gamma"][:] = init_gamma.to(self.dtype)
3509
+ else:
3510
+ # Relay BP: use γ-mixing for ordered memory (even with num_sets=0 for MemBP)
3511
+ gamma0_tensor = sample_gamma_scalar(B, self.V, self.gamma0, device_str)
3512
+ tensors["gamma"][:] = gamma0_tensor
3513
+
3514
+ # Reset per-set valid flags so only one success can be counted in this set
3515
+ tensors["valid_solutions"].fill_(0)
3516
+
3517
+ # Initialize active_idx for pre-iter loop (all lanes start active)
3518
+ if self.cfg.perf == "throughput":
3519
+ if B == 1:
3520
+ # For B=1, skip compaction - lane is always active
3521
+ device_str = self._device_strs[self.device_ids[gpu_idx]]
3522
+ active_idx = torch.tensor([0], dtype=torch.int32, device=device_str)
3523
+ B_active = 1
3524
+ # Ensure active[0] is 1 (should already be, but be explicit)
3525
+ tensors["active"][0] = 1
3526
+ # Store old count for device-side comparison (though we won't use it for B=1)
3527
+ tensors["old_active_count"].fill_(1)
3528
+ else:
3529
+ active_idx, B_active = self._compact_active_indices_on_device(tensors, B, gpu_idx)
3530
+ # Store old count for device-side comparison
3531
+ tensors["old_active_count"].fill_(B_active)
3532
+ else:
3533
+ # Non-throughput mode: all lanes are active initially
3534
+ device_str = f"cuda:{gpu_idx}"
3535
+ if B == 1:
3536
+ # For B=1, use simple [0] tensor and ensure active[0] is 1
3537
+ active_idx = torch.tensor([0], dtype=torch.int32, device=device_str)
3538
+ B_active = 1
3539
+ tensors["active"][0] = 1
3540
+ else:
3541
+ active_idx = torch.arange(B, dtype=torch.int32, device=device_str)
3542
+ B_active = B
3543
+
3544
+ # Store in_leg as Python variable to avoid .item() calls in hot loop
3545
+ in_leg_python = True # We're in pre-iter phase
3546
+
3547
+ # Track leg count (pre_iter counts as leg 0, relay legs as 1..num_sets)
3548
+ leg_count = 0
3549
+
3550
+ # Resolve all autotuning configs once
3551
+ self._resolve_configs(B, tensors, graph, gpu_idx)
3552
+
3553
+ # Pre-iterations loop
3554
+ # Use C2V grid-stride kernel for default mode (batch-organized pattern, much faster)
3555
+ pre_iter_count = self.pre_iter if pre_iter_override is None else int(pre_iter_override)
3556
+ if self.cfg.perf == "default":
3557
+ tensors["in_leg"].fill_(1)
3558
+ active_idx, B_active, all_done, iterations_run = self._run_bp_iterations_grid_stride(
3559
+ 0,
3560
+ pre_iter_count,
3561
+ tensors,
3562
+ B,
3563
+ active_idx,
3564
+ B_active,
3565
+ graph,
3566
+ log_prior_ratios,
3567
+ gpu_idx,
3568
+ in_leg_python,
3569
+ force_hard=True,
3570
+ prior_batched=prior_batched,
3571
+ wj_override=wj_override,
3572
+ )
3573
+ # Update iteration counters (iteration-fused method handles this internally)
3574
+ else:
3575
+ # Throughput mode: per-iteration loop
3576
+ for t in range(pre_iter_count):
3577
+ tensors["in_leg"].fill_(1)
3578
+ active_idx, B_active, all_done = self._run_bp_iteration(
3579
+ t,
3580
+ tensors,
3581
+ B,
3582
+ active_idx,
3583
+ B_active,
3584
+ graph,
3585
+ log_prior_ratios,
3586
+ gpu_idx,
3587
+ in_leg_python,
3588
+ force_hard=True,
3589
+ prior_batched=prior_batched,
3590
+ wj_override=wj_override,
3591
+ )
3592
+ if all_done:
3593
+ break
3594
+
3595
+ # Parity and selection already ran after the last pre-iteration, in both perf modes.
3596
+ # With pre_iter_count == 0 no iteration has run yet; relay leg 1 overwrites this.
3597
+ if self.unconverged_correction == "first_leg":
3598
+ tensors["first_leg_hard_dec"].copy_(tensors["hard_dec"])
3599
+ # Device-side freeze + all-done check (fused).
3600
+ freeze_finished_lanes_kernel[(B,)](
3601
+ tensors["best_errors"],
3602
+ tensors["hard_dec"],
3603
+ tensors["gamma"],
3604
+ tensors["active"],
3605
+ tensors["found_count"],
3606
+ tensors["first_iter"],
3607
+ tensors["iter_counter"],
3608
+ tensors["sought_solutions"],
3609
+ self.stop_nconv,
3610
+ in_leg_python,
3611
+ False, # count_iteration
3612
+ self.V,
3613
+ tensors["all_done_flag"],
3614
+ tensors["arrival_counter"],
3615
+ B,
3616
+ )
3617
+ # If every lane has stop_nconv solutions, skip legs entirely and return results now.
3618
+ # Also returned here when the caller requested skip_relay_legs.
3619
+ reduce_all_ge_kernel[(1,)](
3620
+ tensors["found_count"],
3621
+ self.stop_nconv,
3622
+ tensors["all_done_flag"],
3623
+ B,
3624
+ )
3625
+ if tensors["all_done_flag"].item() or skip_relay_legs:
3626
+ # Use lifetime-best solutions where they exist. Otherwise _unconverged_hard_dec.
3627
+ # A lane has a valid solution iff its best_weights entry is finite.
3628
+ has_solution = torch.isfinite(tensors["best_weights"])
3629
+ fallback_errors = torch.where(
3630
+ has_solution.view(-1, 1),
3631
+ tensors["best_errors"],
3632
+ self._unconverged_hard_dec(tensors),
3633
+ )
3634
+ errors = bitpack_errors(fallback_errors) if self.bitpack_output else fallback_errors
3635
+ iters = torch.where(
3636
+ tensors["first_iter"] >= 0,
3637
+ tensors["first_iter"],
3638
+ tensors["iter_counter"].expand_as(tensors["first_iter"]),
3639
+ )
3640
+ # Compute unconverged indices (should be empty if all done)
3641
+ unconverged_mask = tensors["found_count"] < self.stop_nconv
3642
+ unconverged_idx = torch.where(unconverged_mask)[0].to(torch.int32)
3643
+ result = {
3644
+ "errors": errors,
3645
+ "weights": tensors["best_weights"],
3646
+ "valid_mask": tensors["found_count"] >= self.stop_nconv,
3647
+ "iterations": iters,
3648
+ "found_count": tensors["found_count"].to(torch.int32),
3649
+ "unconverged_indices": unconverged_idx,
3650
+ }
3651
+ if return_last_posterior:
3652
+ result["last_posterior"] = tensors["posterior_ratios"].detach().clone()
3653
+ return result
3654
+
3655
+ # Track leg count (pre-iter is leg 0, relay legs are 1..num_sets)
3656
+ leg_count = 0 # Pre-iter is leg 0
3657
+
3658
+ # Check fast_max_unconverged_pct early exit after pre-iter (leg_count = 0)
3659
+ # Some syndromes may converge during pre-iter, so we should check here too
3660
+ early_exit_result = self._check_fast_max_unconverged_pct(tensors, B, leg_count=0)
3661
+ if early_exit_result is not None:
3662
+ return early_exit_result
3663
+
3664
+ unfreeze_finished_lanes_kernel[(B,)](
3665
+ tensors["active"],
3666
+ tensors["found_count"],
3667
+ self.stop_nconv,
3668
+ )
3669
+
3670
+ # Relay legs (R times)
3671
+ in_leg_python = True
3672
+ for leg in range(self.num_sets):
3673
+ # Increment leg count (relay legs are 1..num_sets)
3674
+ leg_count = leg + 1
3675
+ tensors["in_leg"].fill_(1)
3676
+ tensors["sought_solutions"] = tensors["found_count"] + 1
3677
+
3678
+ # Set leg gamma with the following priority:
3679
+ # 1) explicit_gammas (K×V) if provided
3680
+ # 2) gamma_dist_interval_by_error (V×2) if provided
3681
+ # 3) global gamma_dist_interval (scalar (min, max))
3682
+ # Cases 2 and 3 draw from seed + leg: one [1, V] row broadcast to all shots
3683
+ # (gamma_draw="broadcast") or one row per shot (gamma_draw="per_row").
3684
+ gamma_rows = 1 if self.gamma_draw == "broadcast" else B
3685
+ if explicit_gammas is not None:
3686
+ K = int(explicit_gammas.shape[0])
3687
+ row = explicit_gammas[((leg + 1) % K)]
3688
+ tensors["gamma"].copy_(row.unsqueeze(0).expand(B, -1))
3689
+ elif self.gamma_dist_interval_by_error_per_gpu is not None:
3690
+ gamma_intervals = self.gamma_dist_interval_by_error_per_gpu[gpu_idx]
3691
+ gamma_leg = sample_gamma_per_variable(
3692
+ gamma_rows, self.V, gamma_intervals, device_str, self.seed + leg
3693
+ )
3694
+ tensors["gamma"].copy_(gamma_leg.expand(B, -1))
3695
+ else:
3696
+ gamma_min, gamma_max = self.gamma_dist_interval
3697
+ gamma_leg = sample_gamma_uniform(
3698
+ gamma_rows, self.V, gamma_min, gamma_max, device_str, self.seed + leg
3699
+ )
3700
+ tensors["gamma"].copy_(gamma_leg.expand(B, -1))
3701
+
3702
+ # Rust-equivalent per-leg reinitialization of messages:
3703
+ # reset variable<->check messages at the start of each leg while
3704
+ # keeping current beliefs (lambda_) carried over
3705
+ # Rust: initialize_check_to_variable() -> nu = zero
3706
+ # Rust: initialize_variable_to_check() -> mu = log_prior_ratios
3707
+ if self.cfg.perf == "throughput":
3708
+ # Always reset messages between legs per Relay-BP spec
3709
+ # Compute active_idx once for the first iteration of the leg
3710
+ if B == 1:
3711
+ # For B=1, skip compaction - lane is always active
3712
+ device_str = self._device_strs[self.device_ids[gpu_idx]]
3713
+ active_idx = torch.tensor([0], dtype=torch.int32, device=device_str)
3714
+ B_active = 1
3715
+ # Ensure active[0] is 1 (should already be, but be explicit)
3716
+ tensors["active"][0] = 1
3717
+ # Store old count for device-side comparison (though we won't use it for B=1)
3718
+ tensors["old_active_count"].fill_(1)
3719
+ else:
3720
+ active_idx, B_active = self._compact_active_indices_on_device(
3721
+ tensors, B, gpu_idx
3722
+ )
3723
+ # Update old count for device-side comparison
3724
+ tensors["old_active_count"].fill_(B_active)
3725
+
3726
+ # OPTIMIZATION: Only reinitialize messages for ACTIVE lanes (lazy init)
3727
+ # This aims to reduce memory traffic when B_active << B
3728
+ # For example, if only 10% of lanes are active, we only write 10% of messages
3729
+ self._launch_init_messages_with_priors_EB(
3730
+ tensors["muT"],
3731
+ tensors["nuT"],
3732
+ B,
3733
+ active_idx=active_idx,
3734
+ B_active=B_active,
3735
+ graph=graph,
3736
+ log_prior_ratios=log_prior_ratios,
3737
+ gpu_idx=gpu_idx,
3738
+ prior_batched=prior_batched,
3739
+ )
3740
+ # Re-initialize L for delta-trick to current marginals (lam_mixed + sum(nu)) for this leg.
3741
+ if self._use_delta_trick:
3742
+ self._launch_v2c_btile(
3743
+ tensors,
3744
+ B,
3745
+ write_hard=True,
3746
+ active_idx=active_idx,
3747
+ B_active=B_active,
3748
+ graph=graph,
3749
+ log_prior_ratios=log_prior_ratios,
3750
+ gpu_idx=gpu_idx,
3751
+ block_vars=None,
3752
+ block_var_ptr=None,
3753
+ block_idx=-1,
3754
+ prior_batched=prior_batched,
3755
+ )
3756
+ tensors["L"].copy_(tensors["posterior_ratios"])
3757
+ else:
3758
+ self._launch_init_kernel(tensors, B, graph=graph, gpu_idx=gpu_idx)
3759
+ # Non-throughput mode: initialize active_idx for the relay leg
3760
+ # (all lanes start active at the beginning of each leg)
3761
+ device_str = f"cuda:{gpu_idx}"
3762
+ active_idx = torch.arange(B, dtype=torch.int32, device=device_str)
3763
+ B_active = B
3764
+
3765
+ # Reset per-set valid flags so only one success can be counted in this set
3766
+ tensors["valid_solutions"].fill_(0)
3767
+
3768
+ # Relay leg iterations loop
3769
+ if self.cfg.perf == "default":
3770
+ active_idx, B_active, all_done, iterations_run = (
3771
+ self._run_bp_iterations_grid_stride(
3772
+ 0,
3773
+ self.set_max_iter,
3774
+ tensors,
3775
+ B,
3776
+ active_idx,
3777
+ B_active,
3778
+ graph,
3779
+ log_prior_ratios,
3780
+ gpu_idx,
3781
+ in_leg_python,
3782
+ force_hard=False,
3783
+ prior_batched=prior_batched,
3784
+ wj_override=wj_override,
3785
+ )
3786
+ )
3787
+ else:
3788
+ # Throughput mode: per-iteration loop (configs cached automatically on first call)
3789
+ for t in range(self.set_max_iter):
3790
+ active_idx, B_active, all_done = self._run_bp_iteration(
3791
+ t,
3792
+ tensors,
3793
+ B,
3794
+ active_idx,
3795
+ B_active,
3796
+ graph,
3797
+ log_prior_ratios,
3798
+ gpu_idx,
3799
+ in_leg_python,
3800
+ force_hard=False,
3801
+ prior_batched=prior_batched,
3802
+ wj_override=wj_override,
3803
+ )
3804
+ if all_done:
3805
+ break
3806
+
3807
+ # Parity and selection already ran after the last iteration of the leg.
3808
+ # The first leg that ran iterations is relay leg 1 when pre_iter_count == 0.
3809
+ if leg == 0 and pre_iter_count == 0 and self.unconverged_correction == "first_leg":
3810
+ tensors["first_leg_hard_dec"].copy_(tensors["hard_dec"])
3811
+ # Stop the legs only when every lane has stop_nconv solutions.
3812
+ reduce_all_ge_kernel[(1,)](
3813
+ tensors["found_count"],
3814
+ self.stop_nconv,
3815
+ tensors["all_done_flag"],
3816
+ B,
3817
+ )
3818
+ # Sync here is acceptable - only called once per leg, not in hot loop
3819
+ if tensors["all_done_flag"].item():
3820
+ break
3821
+
3822
+ # Check fast_max_unconverged_pct early exit after completing this relay leg (leg_count = leg + 1)
3823
+ # We check at leg boundaries (after final parity check) rather than every iteration because:
3824
+ # 1. A "leg" is a logical unit (pre_iter phase or a relay leg), matching the threshold semantics
3825
+ # 2. Less overhead: check once per leg vs once per iteration (set_max_iter can be 60+)
3826
+ # 3. Percentage-based threshold adapts to batch convergence behavior
3827
+ early_exit_result = self._check_fast_max_unconverged_pct(
3828
+ tensors, B, leg_count=leg_count
3829
+ )
3830
+ if early_exit_result is not None:
3831
+ return early_exit_result
3832
+
3833
+ unfreeze_finished_lanes_kernel[(B,)](
3834
+ tensors["active"],
3835
+ tensors["found_count"],
3836
+ self.stop_nconv,
3837
+ )
3838
+
3839
+ # Prepare output
3840
+ # A lane with no valid solution returns _unconverged_hard_dec (first or last leg)
3841
+ has_solution = torch.isfinite(tensors["best_weights"])
3842
+ fallback_errors = torch.where(
3843
+ has_solution.view(-1, 1),
3844
+ tensors["best_errors"],
3845
+ self._unconverged_hard_dec(tensors),
3846
+ )
3847
+ if self.bitpack_output:
3848
+ errors = bitpack_errors(fallback_errors)
3849
+ else:
3850
+ errors = fallback_errors
3851
+
3852
+ # Build per-lane iteration counts (Rust-like): first_iter if set, else per-lane iter_counter
3853
+ iters = torch.where(
3854
+ tensors["first_iter"] >= 0, tensors["first_iter"], tensors["iter_counter"]
3855
+ )
3856
+
3857
+ # Compute unconverged indices (found_count < stop_nconv)
3858
+ # If fast_max_unconverged_pct was set, this will be non-empty for early exits
3859
+ # If fast_max_unconverged_pct was None, this will be empty (all ran to completion)
3860
+ unconverged_mask = tensors["found_count"] < self.stop_nconv
3861
+ unconverged_idx = torch.where(unconverged_mask)[0].to(torch.int32)
3862
+
3863
+ result = {
3864
+ "errors": errors,
3865
+ "weights": tensors["best_weights"],
3866
+ "valid_mask": tensors["found_count"] >= self.stop_nconv,
3867
+ "iterations": iters,
3868
+ "found_count": tensors["found_count"].to(torch.int32),
3869
+ "unconverged_indices": unconverged_idx,
3870
+ }
3871
+ if return_last_posterior:
3872
+ result["last_posterior"] = tensors["posterior_ratios"].detach().clone()
3873
+ return result
3874
+
3875
+ def get_stats(self) -> Dict:
3876
+ """Get decoder statistics."""
3877
+ return {
3878
+ "graph": self.graphs[0].get_stats(),
3879
+ "parameters": {
3880
+ "pre_iter": self.pre_iter,
3881
+ "num_sets": self.num_sets,
3882
+ "set_max_iter": self.set_max_iter,
3883
+ "gamma0": self.gamma0,
3884
+ "gamma_dist_interval": self.gamma_dist_interval,
3885
+ "stop_nconv": self.stop_nconv,
3886
+ "dtype": self.dtype_str,
3887
+ "device": self.device_ids,
3888
+ "algo": self.algo,
3889
+ "perf": self.perf,
3890
+ },
3891
+ }