Skip to content

Commit 93da091

Browse files
cutn integration draft
Signed-off-by: vedika-saravanan <vsaravanan@nvidia.com>
1 parent f24271a commit 93da091

3 files changed

Lines changed: 163 additions & 15 deletions

File tree

libs/qec/python/cudaq_qec/plugins/decoders/tensor_network_utils/nm_optimizer.py

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,8 @@ class NMOptimizer(TensorNetworkDecoder):
283283
execute: Forward backend. ``"codegen"`` (default) partial-evaluates
284284
the path into a flat Python function; ``"unrolled"`` keeps an
285285
interpretive einsum list; ``"opt_einsum"`` dispatches via
286-
:func:`opt_einsum.contract_expression`.
286+
:func:`opt_einsum.contract_expression`; ``"cutensornet"``
287+
executes the reduced contraction with cuTensorNet on CUDA.
287288
compile_mode: Forwarded to :func:`torch.compile`; ignored when
288289
``compile=False``.
289290
dynamic_syndromes: If ``True`` (default), syndromes are runtime
@@ -324,11 +325,12 @@ def __init__(
324325
device: str = "cuda",
325326
*,
326327
compile: bool = False,
327-
execute: Literal["codegen", "unrolled", "opt_einsum"] = "codegen",
328+
execute: Literal["codegen", "unrolled", "opt_einsum",
329+
"cutensornet"] = "codegen",
328330
compile_mode: str | None = None,
329331
dynamic_syndromes: bool = True,
330332
) -> None:
331-
if execute not in ("unrolled", "opt_einsum", "codegen"):
333+
if execute not in ("unrolled", "opt_einsum", "codegen", "cutensornet"):
332334
raise ValueError(f"Invalid execute mode: {execute!r}")
333335
if dtype not in _SUPPORTED_DTYPES:
334336
raise ValueError(f"Invalid dtype {dtype!r}; expected one of "
@@ -347,6 +349,9 @@ def __init__(
347349
)
348350
target_device = "cpu"
349351

352+
if execute == "cutensornet" and "cuda" not in target_device:
353+
raise ValueError("execute='cutensornet' requires a CUDA device.")
354+
350355
# Build the topology directly so NMOptimizer never selects the
351356
# TensorNetworkDecoder cuTensorNet contractor during setup.
352357
qec.Decoder.__init__(self, H)
@@ -397,7 +402,9 @@ def __init__(
397402
self.slicing_batch = tuple()
398403
self.slicing_single = tuple()
399404

400-
self._set_contractor("oe_torch_compiled", target_device, "torch", dtype)
405+
contractor = ("cutensornet"
406+
if execute == "cutensornet" else "oe_torch_compiled")
407+
self._set_contractor(contractor, target_device, "torch", dtype)
401408
self.init_noise_model(
402409
factorized_noise_model(self.error_inds, noise_model),
403410
contract=False,
@@ -718,6 +725,7 @@ def _build_reduced_tn_state(self) -> None:
718725
})
719726

720727
self._batched_einsum_groups = batched_groups
728+
self._reduced_eq = reduced_eq
721729
self._reduced_static_positions = reduced_static
722730
self._reduced_syndrome_positions = reduced_syndrome
723731
self._reduced_recipe_positions = reduced_recipe_positions
@@ -735,6 +743,7 @@ def _build_predict_reduced(self):
735743
"opt_einsum": self._build_predict_reduced_opt_einsum,
736744
"unrolled": self._build_predict_reduced_unrolled,
737745
"codegen": self._build_predict_reduced_codegen,
746+
"cutensornet": self._build_predict_reduced_cutensornet,
738747
}
739748
return builders[self._execute_mode]()
740749

@@ -775,6 +784,34 @@ def _predict(noise_probs: torch.Tensor,
775784

776785
return _predict
777786

787+
def _build_predict_reduced_cutensornet(self):
788+
static_positions = self._reduced_static_positions
789+
syndrome_positions = self._reduced_syndrome_positions
790+
recipe_positions = self._reduced_recipe_positions
791+
eq = self._reduced_eq
792+
n = self._reduced_n_tensors
793+
contractor = self.contractor_config.contractor
794+
device_id = self.contractor_config.device_id
795+
796+
def _predict(noise_probs: torch.Tensor,
797+
syndrome_tuple: tuple[torch.Tensor, ...]) -> torch.Tensor:
798+
arrays: list[torch.Tensor] = [None] * n # type: ignore
799+
for pos, arr in static_positions.items():
800+
arrays[pos] = arr
801+
for pos, syndrome_idx in syndrome_positions:
802+
arrays[pos] = syndrome_tuple[syndrome_idx]
803+
for pos, arr in zip(recipe_positions,
804+
self._precontract_reduced_noise(noise_probs)):
805+
arrays[pos] = arr
806+
out = contractor(eq,
807+
arrays,
808+
optimize=self.path_batch,
809+
slicing=self.slicing_batch,
810+
device_id=device_id)
811+
return _normalize_prediction(out)
812+
813+
return _predict
814+
778815
def _build_predict_reduced_unrolled(self):
779816
static_positions = self._reduced_static_positions
780817
syndrome_positions = self._reduced_syndrome_positions
@@ -852,7 +889,7 @@ def _maybe_torch_compile(self, fn, *, kind: str):
852889
On any compile failure, warn and fall back to eager. ``kind``
853890
is included in the warning to disambiguate predict vs loss.
854891
"""
855-
if not self._use_torch_compile:
892+
if not self._use_torch_compile or self._execute_mode == "cutensornet":
856893
return fn
857894
try:
858895
kwargs = self._torch_compile_kwargs()

libs/qec/python/tests/test_nm_optimizer.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,23 @@ def test_invalid_dtype_rejected(device):
203203
dtype="float16")
204204

205205

206+
def test_cutensornet_execute_requires_cuda():
207+
H, logical, priors = _simple_repetition_code()
208+
syn, flips = _sample_synthetic_dataset(H,
209+
logical,
210+
priors,
211+
num_shots=4,
212+
rng=np.random.default_rng(11))
213+
with pytest.raises(ValueError, match="requires a CUDA device"):
214+
_make_opt(H,
215+
logical,
216+
priors,
217+
syn,
218+
flips,
219+
device="cpu",
220+
execute="cutensornet")
221+
222+
206223
# -- forward pass / gradient -------------------------------------------------
207224

208225

@@ -429,6 +446,53 @@ def test_reduced_path_matches_full_network_reference_static_codegen(device):
429446
rtol=1e-7)
430447

431448

449+
@pytest.mark.skipif(not _gpu_available(), reason="CUDA not available")
450+
def test_cutensornet_execute_matches_opt_einsum_and_backpropagates():
451+
"""cuTN can execute the reduced torch graph with autograd."""
452+
pytest.importorskip("cuquantum")
453+
rng = np.random.default_rng(31415)
454+
H, logical = _nondegenerate_code()
455+
init_priors = [0.2, 0.3, 0.4]
456+
syn, flips = _sample_synthetic_dataset(H,
457+
logical, [0.1, 0.15, 0.25],
458+
num_shots=12,
459+
rng=rng)
460+
ref = _make_opt(H,
461+
logical,
462+
init_priors,
463+
syn,
464+
flips,
465+
device="cuda",
466+
dtype="float64",
467+
execute="opt_einsum")
468+
cutn = _make_opt(H,
469+
logical,
470+
init_priors,
471+
syn,
472+
flips,
473+
device="cuda",
474+
dtype="float64",
475+
execute="cutensornet")
476+
477+
with torch.no_grad():
478+
p_ref = ref.decoder_prediction()
479+
p_cutn = cutn.decoder_prediction()
480+
loss_ref = ref.cross_entropy_loss()
481+
loss_cutn = cutn.cross_entropy_loss()
482+
483+
assert torch.allclose(p_cutn, p_ref, atol=1e-10, rtol=1e-10)
484+
assert torch.allclose(loss_cutn, loss_ref, atol=1e-10, rtol=1e-10)
485+
assert cutn.contractor_config.contractor_name == "cutensornet"
486+
487+
cutn.noise_params[0].grad = None
488+
loss = cutn.cross_entropy_loss()
489+
loss.backward()
490+
grad = cutn.noise_params[0].grad
491+
assert grad is not None
492+
assert torch.isfinite(grad).all()
493+
assert torch.any(grad != 0.0)
494+
495+
432496
# -- numerical guards --------------------------------------------------------
433497

434498

scripts/benchmark_nmoptimizer_d5r5_nico_dem.py

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@
5757

5858
from cudaq_qec import NMOptimizer, make_training_step
5959

60-
EXECUTE_MODES = ("codegen", "unrolled", "opt_einsum")
60+
EXECUTE_MODES = ("codegen", "unrolled", "opt_einsum", "cutensornet")
6161

6262

6363
@dataclass
@@ -198,6 +198,22 @@ def path_opt_cost(info: Any) -> float:
198198
return float("nan")
199199

200200

201+
def einsum_rank(eq: str) -> int:
202+
if "->" not in eq:
203+
return 0
204+
lhs, rhs = eq.split("->", 1)
205+
terms = [term for term in lhs.split(",") if term]
206+
terms.append(rhs)
207+
return max((len(term) for term in terms), default=0)
208+
209+
210+
def path_max_einsum_rank(info: Any) -> int:
211+
ranks = [
212+
einsum_rank(step[2]) for step in getattr(info, "contraction_list", ())
213+
]
214+
return max(ranks, default=0)
215+
216+
201217
def fmt_float(value: float) -> str:
202218
if math.isnan(value):
203219
return "n/a"
@@ -252,25 +268,32 @@ def construct_optimizer(problem: Problem, syn: np.ndarray, flips: np.ndarray,
252268
compile=args.compile)
253269

254270

255-
def run_case(problem: Problem, batch_size: int, execute: str,
256-
args: argparse.Namespace) -> dict[str, str]:
271+
def run_case(problem: Problem,
272+
batch_size: int,
273+
execute: str,
274+
args: argparse.Namespace,
275+
fixed_path: Any | None = None,
276+
fixed_path_source: str = "") -> tuple[dict[str, str], Any | None]:
257277
label = f"batch={batch_size}, execute={execute}"
258278
row = {
259279
"batch": str(batch_size),
260280
"construct_batch": str(batch_size),
261281
"execute": execute,
282+
"path_source": fixed_path_source if fixed_path is not None else "self",
262283
"status": "FAIL",
263284
"construct_s": "n/a",
264285
"largest_intermediate": "n/a",
265286
"intermediate_gib": "n/a",
266287
"opt_cost": "n/a",
288+
"max_einsum_rank": "n/a",
267289
"step_s": "n/a",
268290
"peak_mem_mib": "n/a",
269291
"error": "",
270292
}
271293
init_priors = make_initial_priors(problem.priors, args.init)
272294
syn, flips = sample_problem(problem, batch_size, args.seed + batch_size)
273295

296+
selected_path = None
274297
try:
275298
clear_cuda(args.device)
276299
with timed(f"Construct NMOptimizer ({label})"):
@@ -310,6 +333,10 @@ def _progress(done: int, total: int, elapsed: float) -> None:
310333
opt = construct_optimizer(problem, syn, flips, init_priors,
311334
args, execute)
312335
step_fn = None
336+
if fixed_path is not None:
337+
print(f"Reusing reduced path from {fixed_path_source}",
338+
flush=True)
339+
opt.optimize_path(fixed_path)
313340
row["construct_s"] = f"{time.perf_counter() - t0:.3f}"
314341

315342
construct_batch = int(getattr(opt, "_batch_size", batch_size))
@@ -319,10 +346,12 @@ def _progress(done: int, total: int, elapsed: float) -> None:
319346
run_label += f", construct_batch={construct_batch}"
320347

321348
info = getattr(opt, "_reduced_info", None)
349+
selected_path = getattr(opt, "path_batch", None)
322350
largest = path_largest_intermediate(info)
323351
cost = path_opt_cost(info)
324352
row["largest_intermediate"] = fmt_float(largest)
325353
row["opt_cost"] = fmt_float(cost)
354+
row["max_einsum_rank"] = str(path_max_einsum_rank(info))
326355
if not math.isnan(largest):
327356
gib = largest * element_size(args.dtype) / 2**30
328357
row["intermediate_gib"] = f"{gib:.3f}"
@@ -343,15 +372,15 @@ def _progress(done: int, total: int, elapsed: float) -> None:
343372

344373
row["peak_mem_mib"] = peak_mem_mib(args.device)
345374
row["status"] = "OK"
346-
return row
375+
return row, selected_path
347376
except Exception as exc: # intentionally broad: this is an OOM probe.
348377
row["error"] = repr(exc)
349378
with contextlib.suppress(Exception):
350379
row["peak_mem_mib"] = peak_mem_mib(args.device)
351380
print(f"FAILED CASE {label}: {exc!r}", flush=True)
352381
if args.stop_on_failure:
353382
raise
354-
return row
383+
return row, selected_path
355384
finally:
356385
with contextlib.suppress(Exception):
357386
del opt # type: ignore[name-defined]
@@ -396,7 +425,7 @@ def make_parser() -> argparse.ArgumentParser:
396425
parser.add_argument(
397426
"--execute",
398427
default="all",
399-
help="all, codegen, unrolled, opt_einsum, or comma list")
428+
help="all, codegen, unrolled, opt_einsum, cutensornet, or comma list")
400429
parser.add_argument("--device", default="cuda:0")
401430
parser.add_argument("--dtype",
402431
choices=["float32", "float64"],
@@ -423,6 +452,11 @@ def make_parser() -> argparse.ArgumentParser:
423452
type=int,
424453
default=10,
425454
help="Print batch-slice progress every N chunks.")
455+
parser.add_argument(
456+
"--reuse-first-path",
457+
action="store_true",
458+
help=("For each requested batch size, reuse the first execute mode's "
459+
"reduced contraction path for later execute modes."))
426460
parser.add_argument("--lr",
427461
type=float,
428462
default=1e-2,
@@ -456,6 +490,7 @@ def main() -> int:
456490
f"d={args.distance}, r={args.rounds}, device={args.device}, "
457491
f"dtype={args.dtype}, execute={execute_modes}, "
458492
f"batch_sizes={args.batch_sizes}, run_step={args.run_step}, "
493+
f"reuse_first_path={args.reuse_first_path}, "
459494
f"batch_slice_size={args.batch_slice_size}",
460495
flush=True)
461496

@@ -477,14 +512,26 @@ def main() -> int:
477512

478513
rows = []
479514
for batch_size in args.batch_sizes:
515+
fixed_path = None
516+
fixed_path_source = ""
480517
for execute in execute_modes:
481-
rows.append(run_case(problem, batch_size, execute, args))
518+
row, selected_path = run_case(problem,
519+
batch_size,
520+
execute,
521+
args,
522+
fixed_path=fixed_path,
523+
fixed_path_source=fixed_path_source)
524+
rows.append(row)
525+
if (args.reuse_first_path and fixed_path is None and
526+
row["status"] == "OK" and selected_path is not None):
527+
fixed_path = selected_path
528+
fixed_path_source = f"execute={execute}"
482529

483530
print("\n## NMOptimizer d=5/r=5 Large-DEM Memory Matrix")
484531
headers = [
485-
"batch", "construct_batch", "execute", "status", "construct_s",
486-
"largest_intermediate", "intermediate_gib", "opt_cost", "step_s",
487-
"peak_mem_mib", "error"
532+
"batch", "construct_batch", "execute", "path_source", "status",
533+
"construct_s", "largest_intermediate", "intermediate_gib", "opt_cost",
534+
"max_einsum_rank", "step_s", "peak_mem_mib", "error"
488535
]
489536
print("| " + " | ".join(headers) + " |")
490537
print("| " + " | ".join(["---"] * len(headers)) + " |")

0 commit comments

Comments
 (0)