Skip to content

Commit a7ba11c

Browse files
ivanbasovclaude
andcommitted
Fix ONNX export of channels_last_3d models; fail fast on requested export failure
The eval-input layout match introduced with the AMP training work calls contiguous(memory_format=channels_last_3d) inside PreDecoderMemoryEvalModule.forward. The surface inference path converts every model to channels_last_3d before export, so the traced graph contains a memory-format op the legacy exporter cannot lower and torch.onnx.export(..., dynamo=False) fails with "onnx memory_format support is not implemented". Skip the layout match during export: ONNX has no memory-format concept, so the artifact is value-identical. Also stop silently downgrading to PyTorch when ONNX_WORKFLOW=1/2 was explicitly requested and the export fails: broadcast the failure to all ranks (so multi-GPU runs exit promptly instead of blocking in the next collective) and raise, making local_run.sh exit nonzero. INT8 quantization failure in the ablation path now falls back to the FP32 ONNX like the LER path instead of aborting the run; FP8 stays fail-fast. Add onnx to the public inference requirements (the legacy exporter needs it to serialize the requested artifact) and add a CI-collected regression test that exports a channels_last_3d wrapper and checks the ONNX output against eager. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 79d2d1e commit a7ba11c

7 files changed

Lines changed: 164 additions & 44 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,9 @@ ONNX_WORKFLOW=3 WORKFLOW=inference bash code/scripts/local_run.sh
362362
Notes:
363363

364364
- TensorRT workflows (`ONNX_WORKFLOW=2` or `3`) require `tensorrt` and `modelopt`.
365+
- A failed ONNX export (`ONNX_WORKFLOW=1` or `2`) is fatal (nonzero exit) instead of silently
366+
falling back to PyTorch. A TensorRT build/load failure after a successful export still falls
367+
back to PyTorch.
365368
- FP8 quantization failure is fatal. INT8 failure falls back to the FP32 ONNX model silently.
366369
- ONNX and engine files are written to the current working directory.
367370
- `ONNX_WORKFLOW` is also honoured by the `decoder_ablation` workflow — see below.

code/evaluation/failure_analysis.py

Lines changed: 39 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
_build_stab_maps,
3030
_decode_batch,
3131
_parse_quant_format,
32+
_sync_and_raise_on_export_failure,
3233
map_grid_to_stabilizer_tensor,
3334
sample_predictions,
3435
)
@@ -750,6 +751,7 @@ def _setup_trt_for_ablation(model, cfg, dist, device, basis, D, half, stim_dets)
750751
)
751752

752753
elif onnx_workflow in (OnnxWorkflow.EXPORT_ONNX_ONLY, OnnxWorkflow.EXPORT_AND_USE_TRT):
754+
export_error = None
753755
if dist.rank == 0:
754756
try:
755757
fp32_onnx_path = (
@@ -781,39 +783,47 @@ def _setup_trt_for_ablation(model, cfg, dist, device, basis, D, half, stim_dets)
781783
print(f"[Ablation] Exported FP32 ONNX: {fp32_onnx_path}")
782784

783785
if quant_format:
784-
calib_samples = int(os.environ.get("QUANT_CALIB_SAMPLES", "256"))
785-
calib_dets = stim_dets[:calib_samples].astype(np.uint8)
786+
# Quantization failure semantics mirror the LER path: FP8 is
787+
# fail-fast, INT8 falls back to the FP32 ONNX (README notes).
786788
try:
787-
import modelopt.onnx.quantization as mq
788-
quant_kwargs = {}
789-
if quant_format == "fp8":
790-
quant_kwargs["op_types_to_quantize"] = ["Conv"]
791-
quant_kwargs["high_precision_dtype"] = "fp16"
792-
mq.quantize(
793-
onnx_path=fp32_onnx_path,
794-
quantize_mode=quant_format,
795-
calibration_data={"dets": calib_dets.astype("float32")},
796-
output_path=onnx_path,
797-
**quant_kwargs,
798-
)
799-
except ImportError:
789+
calib_samples = int(os.environ.get("QUANT_CALIB_SAMPLES", "256"))
790+
calib_dets = stim_dets[:calib_samples].astype(np.uint8)
791+
try:
792+
import modelopt.onnx.quantization as mq
793+
quant_kwargs = {}
794+
if quant_format == "fp8":
795+
quant_kwargs["op_types_to_quantize"] = ["Conv"]
796+
quant_kwargs["high_precision_dtype"] = "fp16"
797+
mq.quantize(
798+
onnx_path=fp32_onnx_path,
799+
quantize_mode=quant_format,
800+
calibration_data={"dets": calib_dets.astype("float32")},
801+
output_path=onnx_path,
802+
**quant_kwargs,
803+
)
804+
except ImportError:
805+
if quant_format == "fp8":
806+
raise RuntimeError(
807+
"[Ablation] FP8 quantization requires nvidia-modelopt."
808+
)
809+
from evaluation.logical_error_rate import _ort_quantize_int8
810+
_ort_quantize_int8(fp32_onnx_path, onnx_path, calib_dets)
811+
print(f"[Ablation] Exported quantized ONNX: {onnx_path}")
812+
except Exception as e:
800813
if quant_format == "fp8":
801814
raise RuntimeError(
802-
"[Ablation] FP8 quantization requires nvidia-modelopt."
803-
)
804-
from evaluation.logical_error_rate import _ort_quantize_int8
805-
_ort_quantize_int8(fp32_onnx_path, onnx_path, calib_dets)
806-
print(f"[Ablation] Exported quantized ONNX: {onnx_path}")
815+
f"[Ablation] FP8 ONNX quantization failed (fail-fast): {e}"
816+
) from e
817+
print(f"[Ablation] ONNX quantization failed: {e}; using FP32 ONNX.")
818+
onnx_path = fp32_onnx_path
807819
except Exception as e:
808-
print(f"[Ablation] ONNX export failed: {e}; using PyTorch.")
809-
onnx_workflow = OnnxWorkflow.TORCH_ONLY
810-
811-
if dist.world_size > 1:
812-
# Broadcast rank 0's onnx_workflow (may have been set to TORCH_ONLY on
813-
# export failure) so non-zero ranks skip the TRT build when rank 0 failed.
814-
wf_list = [onnx_workflow]
815-
torch.distributed.broadcast_object_list(wf_list, src=0)
816-
onnx_workflow = wf_list[0]
820+
# Stash instead of raising here: non-zero ranks must first learn
821+
# about the failure through the collective below, or they would
822+
# block in it after rank 0 dies.
823+
export_error = e
824+
# Doubles as the post-export sync so non-zero ranks don't race ahead to
825+
# the TRT build.
826+
_sync_and_raise_on_export_failure(export_error, onnx_workflow, dist, "[Ablation]")
817827
engine_path = onnx_path.replace(".onnx", ".engine")
818828

819829
if onnx_workflow == OnnxWorkflow.EXPORT_AND_USE_TRT and device.type == "cuda":

code/evaluation/logical_error_rate.py

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,28 @@ def rewind(self):
254254
)
255255

256256

257+
def _sync_and_raise_on_export_failure(export_error, onnx_workflow, dist, tag: str) -> None:
258+
"""Fail fast (on every rank) when a requested ONNX export failed on rank 0.
259+
260+
ONNX_WORKFLOW defaults to 0, so a failed export here was explicitly
261+
requested: raising instead of silently falling back to PyTorch makes the
262+
run exit nonzero, so automation cannot mistake a fallback run for a
263+
produced artifact. The broadcast doubles as the post-export barrier and
264+
lets non-zero ranks exit promptly instead of blocking in a later
265+
collective once rank 0 dies.
266+
"""
267+
if dist.world_size > 1:
268+
msg_list = [None if export_error is None else str(export_error)]
269+
torch.distributed.broadcast_object_list(msg_list, src=0)
270+
if export_error is None and msg_list[0] is not None:
271+
export_error = RuntimeError(msg_list[0])
272+
if export_error is not None:
273+
raise RuntimeError(
274+
f"{tag} ONNX export failed with ONNX_WORKFLOW={onnx_workflow.value} "
275+
f"({onnx_workflow.name}) explicitly requested: {export_error}"
276+
) from export_error
277+
278+
257279
def _time_single_shot_latency_stim(
258280
matcher,
259281
baseline_syndromes: np.ndarray,
@@ -1168,6 +1190,7 @@ def run_inference_and_decode_pre_decoder_memory(model, device, dist, cfg) -> dic
11681190
)
11691191

11701192
elif onnx_workflow in (OnnxWorkflow.EXPORT_ONNX_ONLY, OnnxWorkflow.EXPORT_AND_USE_TRT):
1193+
export_error = None
11711194
if dist.rank == 0:
11721195
try:
11731196
example_dets = torch.randint(0, 2, example_shape, dtype=torch.uint8, device=device)
@@ -1250,11 +1273,11 @@ def run_inference_and_decode_pre_decoder_memory(model, device, dist, cfg) -> dic
12501273
print(f"[LER] ONNX quantization failed: {e}; using FP32 ONNX.")
12511274
onnx_path = fp32_onnx_path
12521275
except Exception as e:
1253-
if dist.rank == 0:
1254-
print(f"[LER] ONNX export failed: {e}; falling back to PyTorch.")
1255-
onnx_workflow = OnnxWorkflow.TORCH_ONLY
1256-
if dist.world_size > 1:
1257-
torch.distributed.barrier()
1276+
# Stash instead of raising here: non-zero ranks must first learn
1277+
# about the failure through the collective below, or they would
1278+
# block in it after rank 0 dies.
1279+
export_error = e
1280+
_sync_and_raise_on_export_failure(export_error, onnx_workflow, dist, "[LER]")
12581281
# Re-derive engine_path from the final onnx_path (may have changed on quant fallback)
12591282
engine_path = str(Path(onnx_path).with_suffix(".engine"))
12601283
if onnx_workflow == OnnxWorkflow.EXPORT_AND_USE_TRT and device.type == "cuda":

code/requirements_public_inference.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ safetensors>=0.4.0
2727
scipy
2828
ldpc
2929
beliefmatching
30+
# Required by the legacy TorchScript ONNX exporter at serialization time
31+
# (ONNX_WORKFLOW=1/2 cannot produce an artifact without it).
32+
onnx
3033
# Color-code support (Torch + cuStabilizer runtime).
3134
chromobius
3235
# Optional GPU-only prerequisites (not pip-installed here due to size and CUDA dependency):

code/tests/test_failure_analysis.py

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1161,6 +1161,32 @@ def test_invalid_value_raises_valueerror(self):
11611161
OnnxWorkflow(99)
11621162

11631163

1164+
class TestSyncAndRaiseOnExportFailure(unittest.TestCase):
1165+
"""A failed explicitly-requested export must fail fast on every rank.
1166+
1167+
Covers the shared helper used by both the LER inference path
1168+
(run_inference_and_decode_pre_decoder_memory) and decoder_ablation_study.
1169+
"""
1170+
1171+
def test_no_error_is_a_noop(self):
1172+
from evaluation.logical_error_rate import (OnnxWorkflow, _sync_and_raise_on_export_failure)
1173+
_sync_and_raise_on_export_failure(
1174+
None, OnnxWorkflow.EXPORT_ONNX_ONLY, _DummyDist(), "[LER]"
1175+
)
1176+
1177+
def test_export_error_raises_with_context(self):
1178+
from evaluation.logical_error_rate import (OnnxWorkflow, _sync_and_raise_on_export_failure)
1179+
cause = ValueError("onnx memory_format support is not implemented")
1180+
with self.assertRaisesRegex(
1181+
RuntimeError, r"\[LER\] ONNX export failed with ONNX_WORKFLOW=1 "
1182+
r"\(EXPORT_ONNX_ONLY\) explicitly requested"
1183+
) as ctx:
1184+
_sync_and_raise_on_export_failure(
1185+
cause, OnnxWorkflow.EXPORT_ONNX_ONLY, _DummyDist(), "[LER]"
1186+
)
1187+
self.assertIs(ctx.exception.__cause__, cause)
1188+
1189+
11641190
class TestDecoderAblationStudyTRTFallback(unittest.TestCase):
11651191
"""
11661192
ONNX_WORKFLOW=3 with a missing engine file must fall back to PyTorch silently
@@ -1220,8 +1246,9 @@ def test_missing_engine_sample_count_correct(self):
12201246

12211247
class TestDecoderAblationStudyOnnxExport(unittest.TestCase):
12221248
"""
1223-
ONNX_WORKFLOW=1 must attempt ONNX export (rank 0) then fall back to PyTorch for inference.
1224-
Results must be identical in structure to the default PyTorch path.
1249+
ONNX_WORKFLOW=1 must attempt ONNX export (rank 0) and run inference with
1250+
PyTorch; results must be identical in structure to the default PyTorch
1251+
path. A failed export must raise instead of silently falling back.
12251252
"""
12261253

12271254
_D = 3
@@ -1265,9 +1292,13 @@ def _fake_onnx_export(module, *args, **kwargs):
12651292
self.assertEqual(result["total_samples"], self._N)
12661293
self.assertTrue(set(DECODER_NAMES).issubset(set(result["decoder_errors"].keys())))
12671294

1268-
def test_workflow1_export_failure_falls_back_gracefully(self):
1269-
"""If ONNX export raises, results must still be valid (PyTorch fallback)."""
1270-
from evaluation.failure_analysis import decoder_ablation_study, DECODER_NAMES
1295+
def test_workflow1_export_failure_raises(self):
1296+
"""If an explicitly requested ONNX export raises, the run must fail fast.
1297+
1298+
Silently falling back to PyTorch (and exiting 0) would let automation
1299+
believe the requested ONNX artifact was generated when it was not.
1300+
"""
1301+
from evaluation.failure_analysis import decoder_ablation_study
12711302
from data.datapipe_stim import QCDataPipePreDecoder_Memory_inference
12721303
real_ds = QCDataPipePreDecoder_Memory_inference(
12731304
distance=self._D,
@@ -1288,11 +1319,8 @@ def test_workflow1_export_failure_falls_back_gracefully(self):
12881319
patch("torch.onnx.export", side_effect=RuntimeError("export broken")), \
12891320
patch("os.getcwd", return_value=tmpdir):
12901321
mf.create_datapipe_inference.return_value = real_ds
1291-
result = decoder_ablation_study(
1292-
_ZeroModel(), torch.device("cpu"), _DummyDist(), cfg
1293-
)
1294-
self.assertEqual(result["total_samples"], self._N)
1295-
self.assertTrue(set(DECODER_NAMES).issubset(set(result["decoder_errors"].keys())))
1322+
with self.assertRaisesRegex(RuntimeError, "ONNX export failed"):
1323+
decoder_ablation_study(_ZeroModel(), torch.device("cpu"), _DummyDist(), cfg)
12961324

12971325

12981326
class TestDecoderAblationStudyTRTExecution(unittest.TestCase):

code/tests/test_precision.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
from __future__ import annotations
1111

1212
import sys
13+
import tempfile
14+
import unittest
1315
from pathlib import Path
1416

1517
import pytest
@@ -110,3 +112,48 @@ def test_match_input_noop_for_contiguous_model():
110112
conv = torch.nn.Conv3d(4, 8, kernel_size=3) # default contiguous
111113
x = torch.randn(1, 4, 3, 5, 5)
112114
assert match_input_to_model_memory_format(x, conv) is x
115+
116+
117+
# unittest.TestCase so CI's `unittest discover` collects it (the pytest-style
118+
# tests above are only run by pytest).
119+
class TestMatchInputDuringOnnxExport(unittest.TestCase):
120+
"""The layout-match helper must be a no-op inside torch.onnx.export.
121+
122+
Regression test: an unguarded contiguous(memory_format=channels_last_3d)
123+
in a traced forward makes the legacy exporter fail with
124+
"onnx memory_format support is not implemented".
125+
"""
126+
127+
def test_match_input_skipped_during_onnx_export(self):
128+
# The legacy TorchScript exporter needs the onnx package to serialize.
129+
try:
130+
from onnx.reference import ReferenceEvaluator
131+
except ImportError:
132+
self.skipTest("onnx is not installed")
133+
134+
class Wrap(torch.nn.Module):
135+
136+
def __init__(self):
137+
super().__init__()
138+
self.model = module_to_channels_last_3d(torch.nn.Conv3d(4, 8, kernel_size=3), True)
139+
140+
def forward(self, x):
141+
x = match_input_to_model_memory_format(x, self.model)
142+
return self.model(x)
143+
144+
wrap = Wrap().eval()
145+
x = torch.randn(1, 4, 3, 8, 8)
146+
with tempfile.TemporaryDirectory() as tmpdir:
147+
out_path = Path(tmpdir) / "wrap.onnx"
148+
# Without the in-export guard the legacy exporter raises
149+
# SymbolicValueError: "onnx memory_format support is not implemented".
150+
torch.onnx.export(
151+
wrap, (x,), str(out_path), opset_version=18, input_names=["x"], dynamo=False
152+
)
153+
self.assertTrue(out_path.exists())
154+
# Skipping the layout conversion is value-neutral: the exported
155+
# graph must reproduce the eager output.
156+
(onnx_out,) = ReferenceEvaluator(str(out_path)).run(None, {"x": x.numpy()})
157+
with torch.no_grad():
158+
eager = wrap(x)
159+
self.assertTrue(torch.allclose(eager, torch.from_numpy(onnx_out), atol=1e-5))

code/training/precision.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,13 @@ def match_input_to_model_memory_format(tensor: torch.Tensor, model) -> torch.Ten
119119
If the model runs in channels_last_3d, a contiguous half-precision input
120120
forces the slow Conv3D kernel; converting the input keeps eval on the fast
121121
Tensor-Core path and consistent with training. No-op for contiguous models.
122+
123+
Skipped during ONNX export: the legacy exporter cannot lower
124+
``contiguous(memory_format=channels_last_3d)``, and ONNX has no
125+
memory-format concept — layout only affects kernel dispatch, not values.
122126
"""
127+
if getattr(torch.onnx, "is_in_onnx_export", lambda: False)():
128+
return tensor
123129
if tensor.dim() == 5 and model_is_channels_last_3d(model):
124130
return tensor.contiguous(memory_format=torch.channels_last_3d)
125131
return tensor

0 commit comments

Comments
 (0)