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/graph.py ADDED
@@ -0,0 +1,479 @@
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
+ """Graph utilities for converting SciPy CSR matrices to device-optimized edge-centric representation.
12
+
13
+ This module provides utilities for converting quantum error correction check matrices
14
+ from SciPy sparse format to GPU-optimized representations for belief propagation.
15
+ The CSRGraph class builds edge-centric data structures that enable efficient
16
+ message passing on GPU hardware.
17
+
18
+ The implementation follows the sparse graph representation used in the Rust
19
+ implementation in crates/relay_bp/src/bipartite_graph.rs.
20
+ """
21
+
22
+ import torch
23
+ import numpy as np
24
+ import math
25
+ from scipy.sparse import csr_matrix
26
+ from typing import Tuple, Optional
27
+
28
+ from scipy.sparse import issparse
29
+
30
+
31
+ class CSRGraph:
32
+ """GPU-optimized representation of quantum error correction check matrix.
33
+
34
+ This class converts a SciPy CSR matrix representing quantum stabilizer generators
35
+ into GPU-optimized edge-centric data structures for belief propagation.
36
+
37
+ The representation includes:
38
+ - Check-centric view: chk_ptr, chk_edges for check-to-variable message passing
39
+ - Variable-centric view: var_ptr, var_edges for variable-to-check message passing
40
+ - Edge mappings: edge_var, edge_chk for edge-to-node lookups
41
+
42
+ This follows the sparse bipartite graph representation in Rust bipartite_graph.rs.
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ H_csr: csr_matrix,
48
+ device: str = "cuda",
49
+ check_permutation: Optional[np.ndarray] = None,
50
+ ):
51
+ """Initialize CSRGraph from quantum check matrix.
52
+
53
+ Args:
54
+ H_csr: Check matrix (C x V) in CSR format representing stabilizer generators
55
+ device: Target GPU device ("cuda" or "rocm")
56
+ check_permutation: Optional permutation of check rows [C] int32 array. If provided,
57
+ reorders CSR rows before building graph structures (used for graph-aware
58
+ block definitions in layered scheduling).
59
+ """
60
+ if device == "cpu":
61
+ raise RuntimeError(
62
+ "CPU not supported - Triton kernels require GPU. Use device='cuda' or 'rocm'"
63
+ )
64
+
65
+ # Accept any scipy sparse, ensure CSR matrix
66
+ if not issparse(H_csr):
67
+ raise ValueError("Input must be a scipy.sparse matrix")
68
+ if not isinstance(H_csr, csr_matrix):
69
+ H_csr = H_csr.tocsr()
70
+
71
+ self.device = device
72
+ # Store shape before permutation (for reference)
73
+ self.C, self.V = H_csr.shape
74
+ self.E = H_csr.nnz
75
+
76
+ # Validate input
77
+ if not isinstance(H_csr, csr_matrix):
78
+ raise ValueError("Input must be a scipy.sparse.csr_matrix")
79
+
80
+ if H_csr.dtype != np.uint8 and H_csr.dtype != bool:
81
+ # Convert to binary if needed
82
+ H_csr = (H_csr != 0).astype(np.uint8)
83
+
84
+ # Apply check permutation if provided (reorder CSR rows)
85
+ if check_permutation is not None:
86
+ check_permutation = np.asarray(check_permutation, dtype=np.int32)
87
+ if check_permutation.shape[0] != H_csr.shape[0]:
88
+ raise ValueError(
89
+ f"check_permutation length ({check_permutation.shape[0]}) must match number of checks ({H_csr.shape[0]})"
90
+ )
91
+ if not (check_permutation.min() >= 0 and check_permutation.max() < H_csr.shape[0]):
92
+ raise ValueError(
93
+ f"check_permutation indices must be in range [0, {H_csr.shape[0]})"
94
+ )
95
+ # Reorder CSR matrix rows
96
+ H_csr = H_csr[check_permutation, :]
97
+
98
+ # Convert to device tensors
99
+ self._build_edge_representation(H_csr)
100
+
101
+ def _build_edge_representation(self, H_csr: csr_matrix):
102
+ """Build edge-centric representation from CSR matrix.
103
+
104
+ This method constructs the GPU-optimized data structures needed for
105
+ belief propagation message passing, following the sparse graph format
106
+ used in the Rust implementation.
107
+
108
+ Args:
109
+ H_csr: Check matrix in CSR format
110
+ """
111
+ # Get CSR data
112
+ chk_ptr = H_csr.indptr.astype(np.int32)
113
+ chk_edges = H_csr.indices.astype(np.int32) # These are variable indices
114
+
115
+ # Build variable-centric representation
116
+ var_ptr = np.zeros(self.V + 1, dtype=np.int32)
117
+ var_edges = np.zeros(self.E, dtype=np.int32)
118
+ edge_var = np.zeros(self.E, dtype=np.int32)
119
+ edge_chk = np.zeros(self.E, dtype=np.int32)
120
+
121
+ # Build edge list and variable-centric pointers
122
+ edge_idx = 0
123
+ for chk_idx in range(self.C):
124
+ start = chk_ptr[chk_idx]
125
+ end = chk_ptr[chk_idx + 1]
126
+
127
+ for local_idx in range(start, end):
128
+ var_idx = chk_edges[local_idx]
129
+
130
+ # Add to variable's edge list
131
+ var_ptr[var_idx + 1] += 1
132
+
133
+ # Store edge information
134
+ edge_var[edge_idx] = var_idx
135
+ edge_chk[edge_idx] = chk_idx
136
+ var_edges[edge_idx] = edge_idx # Will be updated after sorting
137
+
138
+ edge_idx += 1
139
+
140
+ # Convert to cumulative pointers for variables
141
+ for i in range(1, self.V + 1):
142
+ var_ptr[i] += var_ptr[i - 1]
143
+
144
+ # Sort edges by variable index and update var_edges
145
+ edge_order = np.argsort(edge_var)
146
+ for i, edge_idx in enumerate(edge_order):
147
+ var_idx = edge_var[edge_idx]
148
+ var_edges[var_ptr[var_idx]] = edge_idx
149
+ var_ptr[var_idx] += 1
150
+
151
+ # Reset var_ptr to start positions
152
+ for i in range(self.V - 1, 0, -1):
153
+ var_ptr[i] = var_ptr[i - 1]
154
+ var_ptr[0] = 0
155
+
156
+ # Now build chk_edges as edge IDs (not variable indices)
157
+ chk_edges_edge_ids = np.zeros(self.E, dtype=np.int32)
158
+ edge_idx = 0
159
+ for chk_idx in range(self.C):
160
+ start = chk_ptr[chk_idx]
161
+ end = chk_ptr[chk_idx + 1]
162
+
163
+ for local_idx in range(start, end):
164
+ chk_edges_edge_ids[local_idx] = edge_idx
165
+ edge_idx += 1
166
+
167
+ # Move to device
168
+ self.chk_ptr = torch.from_numpy(chk_ptr).to(self.device)
169
+ self.chk_edges = torch.from_numpy(chk_edges_edge_ids).to(self.device) # Edge IDs
170
+ self.var_ptr = torch.from_numpy(var_ptr).to(self.device)
171
+ self.var_edges = torch.from_numpy(var_edges).to(self.device)
172
+ self.edge_var = torch.from_numpy(edge_var).to(self.device)
173
+ self.edge_chk = torch.from_numpy(edge_chk).to(self.device)
174
+
175
+ # Compute degree statistics for autotuning
176
+ self.max_deg_chk = int((chk_ptr[1:] - chk_ptr[:-1]).max())
177
+ self.max_deg_var = int((var_ptr[1:] - var_ptr[:-1]).max())
178
+
179
+ # Compute sparsity pattern hash for autotuning (captures connectivity, not just dimensions)
180
+ # This ensures different H matrices with same (C, V, E) get different autotuned configs
181
+ # The hash captures memory access patterns that affect kernel performance (degree distribution,
182
+ # irregularity, locality) which influence optimal BLOCK_SIZE, BTILE, and ROWS_PER_CHK values
183
+ import hashlib
184
+
185
+ # Hash the actual edge structure: degree distribution + sorted edge list per check
186
+ chk_degrees = (chk_ptr[1:] - chk_ptr[:-1]).astype(np.int32)
187
+ var_degrees = (var_ptr[1:] - var_ptr[:-1]).astype(np.int32)
188
+ # Build sorted edge list per check (for consistent hashing regardless of input CSR column order)
189
+ # Sort variable indices within each check to get canonical representation
190
+ sorted_edges_per_check = []
191
+ for chk_idx in range(self.C):
192
+ start = chk_ptr[chk_idx]
193
+ end = chk_ptr[chk_idx + 1]
194
+ if end > start:
195
+ # Get variable indices for this check from original CSR and sort them
196
+ var_indices = chk_edges[start:end] # These are variable indices from CSR
197
+ sorted_edges_per_check.append(np.sort(var_indices))
198
+ else:
199
+ sorted_edges_per_check.append(np.array([], dtype=np.int32))
200
+ # Combine degree distributions and sorted edge lists into hash
201
+ # Sort degree lists for consistency (order doesn't matter for degree distribution)
202
+ pattern_blob = np.concatenate(
203
+ [
204
+ np.sort(chk_degrees).astype(np.int32), # Check degree distribution (sorted)
205
+ np.sort(var_degrees).astype(np.int32), # Variable degree distribution (sorted)
206
+ np.concatenate([np.sort(e) for e in sorted_edges_per_check]).astype(
207
+ np.int32
208
+ ), # All edges (sorted per check)
209
+ ]
210
+ ).tobytes()
211
+ self.sparsity_hash = hashlib.blake2b(pattern_blob, digest_size=16).hexdigest()
212
+
213
+ def get_device_tensors(self) -> Tuple[torch.Tensor, ...]:
214
+ """Get all device tensors for kernel calls.
215
+
216
+ Returns:
217
+ Tuple of (chk_ptr, chk_edges, var_ptr, var_edges, edge_var, edge_chk)
218
+ """
219
+ return (
220
+ self.chk_ptr,
221
+ self.chk_edges,
222
+ self.var_ptr,
223
+ self.var_edges,
224
+ self.edge_var,
225
+ self.edge_chk,
226
+ )
227
+
228
+ def validate(self) -> bool:
229
+ """Validate graph structure."""
230
+ # Check that all edge indices are valid
231
+ assert self.edge_var.max() < self.V, "Invalid variable index in edges"
232
+ assert self.edge_chk.max() < self.C, "Invalid check index in edges"
233
+
234
+ # Correctness checks: ensure CSR pointers have correct length
235
+ assert self.chk_ptr.shape[0] == self.C + 1, (
236
+ f"chk_ptr must have length C+1 ({self.C + 1}), got {self.chk_ptr.shape[0]}"
237
+ )
238
+ assert self.var_ptr.shape[0] == self.V + 1, (
239
+ f"var_ptr must have length V+1 ({self.V + 1}), got {self.var_ptr.shape[0]}"
240
+ )
241
+
242
+ # Check that pointers are monotonic
243
+ assert torch.all(self.chk_ptr[1:] >= self.chk_ptr[:-1]), "Invalid check pointers"
244
+ assert torch.all(self.var_ptr[1:] >= self.var_ptr[:-1]), "Invalid variable pointers"
245
+
246
+ # Check that edge counts match
247
+ assert self.chk_ptr[-1] == self.E, "Check pointer mismatch"
248
+ assert self.var_ptr[-1] == self.E, "Variable pointer mismatch"
249
+
250
+ return True
251
+
252
+ def get_stats(self) -> dict:
253
+ """Get graph statistics for debugging."""
254
+ return {
255
+ "checks": self.C,
256
+ "variables": self.V,
257
+ "edges": self.E,
258
+ "max_degree_check": self.max_deg_chk,
259
+ "max_degree_variable": self.max_deg_var,
260
+ "avg_degree_check": self.E / self.C,
261
+ "avg_degree_variable": self.E / self.V,
262
+ }
263
+
264
+ def build_block_metadata(
265
+ self,
266
+ num_blocks: Optional[int] = None,
267
+ target_edges_per_block: Optional[int] = None,
268
+ ):
269
+ """Build block metadata for layered scheduling using edge-based heuristics.
270
+
271
+ This method computes:
272
+ - block_chk_start[nb], block_chk_end[nb]: Check ranges for each block
273
+ - block_var_ptr[nb+1], block_vars[total]: Variable lists touched by each block
274
+
275
+ Block count is determined by edge-based heuristics (more stable than check-based)
276
+ to balance kernel launch overhead vs block size.
277
+
278
+ Args:
279
+ num_blocks: Explicit number of blocks (if None, computed from target_edges_per_block)
280
+ target_edges_per_block: Target edges per block for automatic sizing (default: 200000)
281
+
282
+ Returns:
283
+ Tuple of (block_chk_start, block_chk_end, block_var_ptr, block_vars, num_blocks)
284
+ All as torch tensors on device (except num_blocks which is int).
285
+ """
286
+ # Determine num_blocks from edge-based heuristic if not provided
287
+ if num_blocks is None:
288
+ if target_edges_per_block is None:
289
+ target_edges_per_block = 1000 # Default: 200k edges per block
290
+ # Compute num_blocks based on total edges (use ceil, not floor)
291
+ # No max cap - if user wants many blocks, let them (they can set num_blocks explicitly)
292
+ num_blocks = max(1, math.ceil(self.E / target_edges_per_block))
293
+
294
+ num_blocks = int(num_blocks)
295
+ if num_blocks < 1:
296
+ raise ValueError(f"num_blocks must be >= 1, got {num_blocks}")
297
+ if num_blocks > self.C:
298
+ raise ValueError(f"num_blocks ({num_blocks}) cannot exceed number of checks ({self.C})")
299
+
300
+ # Edge-balanced block splitting: split by cumulative edge count, not check count
301
+ # This ensures each block processes roughly the same number of edges
302
+ chk_ptr_host = self.chk_ptr.cpu().numpy()
303
+
304
+ # Compute edge count per check (degree)
305
+ edges_per_check = chk_ptr_host[1:] - chk_ptr_host[:-1]
306
+
307
+ # Compute prefix sum of edge counts (cumulative edges up to each check)
308
+ cumulative_edges = np.zeros(self.C + 1, dtype=np.int64)
309
+ cumulative_edges[1:] = np.cumsum(edges_per_check)
310
+ # cumulative_edges[i] = total edges in checks [0, i)
311
+
312
+ # Target edges per block
313
+ target_edges = self.E / num_blocks
314
+
315
+ # Find block boundaries by splitting on cumulative edge count
316
+ block_chk_start = np.zeros(num_blocks, dtype=np.int32)
317
+ block_chk_end = np.zeros(num_blocks, dtype=np.int32)
318
+
319
+ block_idx = 0
320
+ target_cumulative = target_edges
321
+ block_chk_start[0] = 0
322
+
323
+ for chk_idx in range(1, self.C + 1):
324
+ # Check if we've accumulated enough edges for current block
325
+ # Or if we're on the last block, we must end it at C
326
+ if cumulative_edges[chk_idx] >= target_cumulative:
327
+ # End current block at this check
328
+ block_chk_end[block_idx] = chk_idx
329
+ block_idx += 1
330
+ if block_idx < num_blocks:
331
+ block_chk_start[block_idx] = chk_idx
332
+ target_cumulative = (block_idx + 1) * target_edges
333
+ else:
334
+ # We've filled all blocks, break early
335
+ break
336
+
337
+ # Ensure all blocks are properly terminated
338
+ # If we didn't fill all blocks, the last one ends at C
339
+ if block_idx < num_blocks:
340
+ # Some blocks weren't filled - end the current block at C
341
+ block_chk_end[block_idx] = self.C
342
+ block_idx += 1
343
+ # Fill any remaining empty blocks (shouldn't happen, but handle gracefully)
344
+ for b in range(block_idx, num_blocks):
345
+ block_chk_start[b] = self.C
346
+ block_chk_end[b] = self.C
347
+ else:
348
+ # All blocks filled - ensure last block ends at C
349
+ block_chk_end[num_blocks - 1] = self.C
350
+
351
+ # Post-process: ensure no empty blocks (merge empty blocks into previous)
352
+ # This can happen with highly irregular degree distributions
353
+ non_empty_blocks = []
354
+ for b in range(num_blocks):
355
+ if block_chk_end[b] > block_chk_start[b]:
356
+ non_empty_blocks.append(b)
357
+
358
+ if len(non_empty_blocks) < num_blocks:
359
+ # Some blocks are empty - compact them
360
+ num_non_empty = len(non_empty_blocks)
361
+ if num_non_empty == 0:
362
+ # All blocks empty (shouldn't happen, but handle gracefully)
363
+ num_non_empty = 1
364
+ non_empty_blocks = [0]
365
+ block_chk_start[0] = 0
366
+ block_chk_end[0] = self.C
367
+
368
+ # Rebuild block arrays with only non-empty blocks
369
+ block_chk_start_new = np.zeros(num_non_empty, dtype=np.int32)
370
+ block_chk_end_new = np.zeros(num_non_empty, dtype=np.int32)
371
+ for i, b in enumerate(non_empty_blocks):
372
+ block_chk_start_new[i] = block_chk_start[b]
373
+ block_chk_end_new[i] = block_chk_end[b]
374
+
375
+ block_chk_start = block_chk_start_new
376
+ block_chk_end = block_chk_end_new
377
+ num_blocks = num_non_empty
378
+
379
+ # Correctness assertions: ensure blocks form a contiguous, non-empty partition
380
+ # These catch any edge cases where compaction or splitting logic might produce invalid blocks
381
+ assert block_chk_start[0] == 0, f"First block must start at 0, got {block_chk_start[0]}"
382
+ assert block_chk_end[-1] == self.C, (
383
+ f"Last block must end at C={self.C}, got {block_chk_end[-1]}"
384
+ )
385
+ assert all(block_chk_end[i] == block_chk_start[i + 1] for i in range(num_blocks - 1)), (
386
+ "Blocks must be contiguous (end[i] == start[i+1] for all adjacent pairs)"
387
+ )
388
+ assert all(block_chk_end[i] > block_chk_start[i] for i in range(num_blocks)), (
389
+ "All blocks must be non-empty (end[i] > start[i] for all blocks)"
390
+ )
391
+
392
+ # For each block, find unique variables touched by checks in that block
393
+ # Use CSR structure: checks in [chk_start, chk_end) have edges in [chk_ptr[chk_start], chk_ptr[chk_end])
394
+ # This is contiguous in check-major ordering, so we can use a single slice
395
+ chk_ptr_host = self.chk_ptr.cpu().numpy()
396
+ chk_edges_host = self.chk_edges.cpu().numpy()
397
+ edge_var_host = self.edge_var.cpu().numpy()
398
+
399
+ # Correctness check: ensure CSR pointers have correct length
400
+ assert chk_ptr_host.shape[0] == self.C + 1, (
401
+ f"chk_ptr must have length C+1 ({self.C + 1}), got {chk_ptr_host.shape[0]}"
402
+ )
403
+
404
+ block_vars_list = [] # List of variable lists per block
405
+ block_var_ptr = [0] # Prefix sum pointer
406
+
407
+ # Note: num_blocks may have been reduced if empty blocks were removed
408
+ for b in range(num_blocks):
409
+ chk_start = int(block_chk_start[b])
410
+ chk_end = int(block_chk_end[b])
411
+
412
+ # Fast numpy path: use contiguous edge range (much faster than list comprehension)
413
+ # Checks in [chk_start, chk_end) have edges in [chk_ptr[chk_start], chk_ptr[chk_end])
414
+ # This works because edges are stored in check-major order
415
+ e0 = chk_ptr_host[chk_start]
416
+ e1 = chk_ptr_host[chk_end]
417
+
418
+ if e1 > e0:
419
+ # Get all edge IDs for this block (contiguous slice)
420
+ all_edge_ids = chk_edges_host[e0:e1]
421
+ # Map edge IDs → var IDs (vectorized indexing)
422
+ all_var_ids = edge_var_host[all_edge_ids]
423
+ # Get unique var IDs (sorted automatically by np.unique)
424
+ block_vars_sorted = np.unique(all_var_ids).astype(np.int32)
425
+ else:
426
+ block_vars_sorted = np.array([], dtype=np.int32)
427
+
428
+ block_vars_list.append(block_vars_sorted.tolist())
429
+ block_var_ptr.append(block_var_ptr[-1] + len(block_vars_sorted))
430
+
431
+ # Flatten into single array
432
+ total_vars = block_var_ptr[-1]
433
+ block_vars_flat = np.zeros(total_vars, dtype=np.int32)
434
+ for b, var_list in enumerate(block_vars_list):
435
+ start = block_var_ptr[b]
436
+ end = block_var_ptr[b + 1]
437
+ block_vars_flat[start:end] = var_list
438
+
439
+ # Diagnostic output: block statistics for validation/optimization
440
+ import logging
441
+
442
+ logger = logging.getLogger(__name__)
443
+ if logger.isEnabledFor(logging.DEBUG):
444
+ logger.debug(f"Block metadata: {num_blocks} blocks, {total_vars} total variables")
445
+ for b in range(num_blocks):
446
+ chk_start = int(block_chk_start[b])
447
+ chk_end = int(block_chk_end[b])
448
+ var_start = int(block_var_ptr[b])
449
+ var_end = int(block_var_ptr[b + 1])
450
+ # Compute edges in block
451
+ e0 = chk_ptr_host[chk_start]
452
+ e1 = chk_ptr_host[chk_end]
453
+ edges_in_block = e1 - e0
454
+ vars_in_block = var_end - var_start
455
+ # Compute overlap with previous block
456
+ overlap_with_prev = 0
457
+ if b > 0:
458
+ prev_var_start = int(block_var_ptr[b - 1])
459
+ prev_var_end = int(block_var_ptr[b])
460
+ prev_vars = set(block_vars_flat[prev_var_start:prev_var_end])
461
+ curr_vars = set(block_vars_flat[var_start:var_end])
462
+ overlap_with_prev = len(prev_vars & curr_vars)
463
+ overlap_ratio = overlap_with_prev / vars_in_block if vars_in_block > 0 else 0.0
464
+ else:
465
+ overlap_ratio = 0.0
466
+ logger.debug(
467
+ f" Block {b}: checks [{chk_start}, {chk_end}), "
468
+ f"edges={edges_in_block}, vars={vars_in_block}, "
469
+ f"overlap_with_prev={overlap_with_prev} (ratio={overlap_ratio:.3f})"
470
+ )
471
+
472
+ # Convert to tensors on device (use existing tensor's device for safety)
473
+ dev = self.chk_ptr.device
474
+ block_chk_start_t = torch.from_numpy(block_chk_start).to(dev)
475
+ block_chk_end_t = torch.from_numpy(block_chk_end).to(dev)
476
+ block_var_ptr_t = torch.from_numpy(np.array(block_var_ptr, dtype=np.int32)).to(dev)
477
+ block_vars_t = torch.from_numpy(block_vars_flat).to(dev)
478
+
479
+ return block_chk_start_t, block_chk_end_t, block_var_ptr_t, block_vars_t, num_blocks