| |
| """TensorRT export + inference for AASIST3. Self-contained (no shared package). |
| |
| Exports only the model's `net` (all preprocessing already lives in the original |
| `score_batch`) with a fixed time axis and a dynamic batch axis, builds a FP16 |
| engine (FP32 fallback if parity drifts), finds the fastest batch on the current |
| GPU, and exposes a drop-in `AASIST3TRT` class identical to the PyTorch path except |
| the neural forward runs on TensorRT. |
| |
| CLI: |
| python trt_aasist3.py export # ONNX -> engine -> parity -> sweep -> sidecar |
| python trt_aasist3.py sweep # re-run the batch sweep, update sidecar |
| python trt_aasist3.py parity # PyTorch vs TRT parity report |
| python trt_aasist3.py score AUDIO.wav |
| |
| Pin the GPU with: CUDA_DEVICE_ORDER=PCI_BUS_ID CUDA_VISIBLE_DEVICES=<n> |
| """ |
| from __future__ import annotations |
| import argparse |
| import io |
| import json |
| import os |
| import sys |
| import time |
| from pathlib import Path |
|
|
| import numpy as np |
|
|
| HERE = Path(__file__).resolve().parent |
| sys.path.insert(0, str(HERE)) |
| |
| os.environ.setdefault("CUDA_DEVICE_ORDER", "PCI_BUS_ID") |
| os.environ.setdefault("CUDA_VISIBLE_DEVICES", os.environ.get("SSB_TRT_GPU", "3")) |
|
|
| import torch |
|
|
| |
| ENTRY_MODULE = "aasist3" |
| ENTRY_CLASS = "AASIST3" |
| SLUG = "aasist3" |
| PARITY_DATASET = "InTheWild" |
| MAX_BATCH_CAP = 24 |
| PARITY_CHUNK = 8 |
| OPSET = 17 |
| |
| |
| |
| |
| PARITY_SPEARMAN_TOL = 0.9999 |
| PARITY_FLOOR = 0.99 |
| PARITY_MAD_TOL = 1e-2 |
| PARITY_R_TOL = 0.9999 |
| FORCE_FP32 = False |
| FORCE_FP16 = False |
| DYNAMO_EXPORT = False |
| ALLOW_ORT_FALLBACK = False |
| |
|
|
| from importlib import import_module as _imp |
| _OrigClass = getattr(_imp(ENTRY_MODULE), ENTRY_CLASS) |
|
|
|
|
| |
| |
| |
| def gpu_slug() -> str: |
| name = torch.cuda.get_device_name(0) |
| return name.replace("NVIDIA ", "").replace("GeForce ", "").strip().replace(" ", "_") |
|
|
|
|
| def load_model(): |
| m = _OrigClass() |
| m.load() |
| return m |
|
|
|
|
| def real_audio(n=64): |
| """Decode up to n real 16 kHz mono utterances from PARITY_DATASET/data/*.parquet.""" |
| import pyarrow.parquet as pq |
| import soundfile as sf |
| import torchaudio.functional as AF |
|
|
| data_dir = HERE.parent / PARITY_DATASET / "data" |
| files = sorted(data_dir.glob("test-*.parquet")) or sorted(data_dir.glob("*.parquet")) |
| out = [] |
| for f in files: |
| t = pq.read_table(f) |
| col = "audio" if "audio" in t.column_names else t.column_names[0] |
| for row in t.column(col).to_pylist(): |
| b = row["bytes"] if isinstance(row, dict) else row |
| if not b: |
| continue |
| a, sr = sf.read(io.BytesIO(b), dtype="float32") |
| if a.ndim > 1: |
| a = a.mean(1) |
| a = np.ascontiguousarray(a, dtype=np.float32) |
| if sr != 16000: |
| a = AF.resample(torch.from_numpy(a), sr, 16000).numpy().astype(np.float32) |
| sr = 16000 |
| out.append((a, sr)) |
| if len(out) >= n: |
| return out |
| if not out: |
| raise RuntimeError(f"no parity audio found under {data_dir}") |
| return out |
|
|
|
|
| class _Capture: |
| """Wrap net: pass through to the real net, record input tensor + output.""" |
|
|
| def __init__(self, net): |
| self.net = net |
| self.x = None |
| self.out = None |
|
|
| def __call__(self, x, *a, **k): |
| self.x = x.detach() |
| self.out = self.net(x, *a, **k) |
| return self.out |
|
|
| def __getattr__(self, name): |
| return getattr(self.net, name) |
|
|
|
|
| def _logits_index(out): |
| """Return (L, i, n_classes): tuple length (None if tensor), logits slot, n_classes. |
| |
| Heuristic: the class-logits tensor is the 2-D (B, C) tensor with the smallest C. |
| """ |
| if isinstance(out, torch.Tensor): |
| return None, None, int(out.shape[-1]) |
| cands = [(j, t) for j, t in enumerate(out) |
| if isinstance(t, torch.Tensor) and t.dim() == 2] |
| if not cands: |
| raise RuntimeError("could not locate a 2-D (B,C) logits tensor in net output") |
| j, t = min(cands, key=lambda it: int(it[1].shape[-1])) |
| return len(out), j, int(t.shape[-1]) |
|
|
|
|
| def analyze(model): |
| """One real forward through the capture shim -> (T, L, i, n_classes).""" |
| data = real_audio(1) |
| audios = [a for a, _ in data] |
| srs = [s for _, s in data] |
| cap = _Capture(model.net) |
| model.net = cap |
| with torch.no_grad(): |
| model.score_batch(audios, srs) |
| model.net = cap.net |
| T = int(cap.x.shape[-1]) |
| L, i, n_classes = _logits_index(cap.out) |
| return T, L, i, n_classes |
|
|
|
|
| def _extractor(L, i): |
| """Pick the logits tensor out of a net's raw output.""" |
| if L is None: |
| return lambda y: y |
| return lambda y, i=i: y[i] |
|
|
|
|
| def _rebuild(L, i): |
| """Wrap a bare logits tensor back into the net's original output structure.""" |
| if L is None: |
| return lambda y: y |
| return lambda y, L=L, i=i: tuple(y if j == i else None for j in range(L)) |
|
|
|
|
| def _prep_for_export(net): |
| """Make export-hostile layers traceable. No-op for non-fairseq models. |
| |
| fairseq wav2vec2/hubert call `pad_to_multiple`, which does `(tsz/multiple) |
| .is_integer()`; under torch.jit tracing `tsz` becomes a Tensor with no |
| `.is_integer()`. Our time axis is static, so we swap in a constant-length |
| pad that traces cleanly. Patches every fairseq module that bound the name. |
| """ |
| def _safe_pad(x, multiple, dim=-1, value=0): |
| import torch.nn.functional as F |
| if x is None: |
| return None, 0 |
| tsz = int(x.shape[dim]) |
| rem = (multiple - tsz % multiple) % multiple |
| if rem == 0: |
| return x, 0 |
| pad_offset = (0,) * (-1 - dim) * 2 |
| return F.pad(x, (*pad_offset, 0, rem), value=value), rem |
|
|
| for modname in ("fairseq.models.wav2vec.utils", |
| "fairseq.models.wav2vec.wav2vec2", |
| "fairseq.models.hubert.hubert"): |
| mod = sys.modules.get(modname) |
| if mod is not None and hasattr(mod, "pad_to_multiple"): |
| mod.pad_to_multiple = _safe_pad |
| _freeze_sinc(net) |
| |
| try: |
| import importlib |
| importlib.import_module("_trt_patch").patch(net) |
| except ModuleNotFoundError: |
| pass |
| if DYNAMO_EXPORT: |
| _replace_global_avgpool(net) |
| return net |
|
|
|
|
| class _MeanPool(torch.nn.Module): |
| """Global average over `dims` (keepdim) — == AdaptiveAvgPool{1,2}d(1).""" |
|
|
| def __init__(self, dims): |
| super().__init__() |
| self.dims = dims |
|
|
| def forward(self, x): |
| return x.mean(dim=self.dims, keepdim=True) |
|
|
|
|
| def _replace_global_avgpool(net): |
| """Swap AdaptiveAvgPool1d/2d(output_size=1) for an explicit mean. The dynamo |
| exporter lowers the adaptive pool to as_strided/SequenceEmpty, which TensorRT |
| rejects; a plain mean lowers to ReduceMean. Identical for output_size==1.""" |
| import torch.nn as nn |
| for full_name, mod in list(net.named_modules()): |
| is1d = isinstance(mod, nn.AdaptiveAvgPool1d) and mod.output_size in (1, (1,)) |
| is2d = isinstance(mod, nn.AdaptiveAvgPool2d) and mod.output_size in (1, (1, 1)) |
| if not (is1d or is2d): |
| continue |
| parent = net |
| *parents, attr = full_name.split(".") |
| for p in parents: |
| parent = getattr(parent, p) |
| setattr(parent, attr, _MeanPool((-1,) if is1d else (-2, -1))) |
| return net |
|
|
|
|
| def _freeze_sinc(net): |
| """Replace SincConv-style layers with an equivalent nn.Conv1d holding the |
| precomputed band-pass filters. At eval the filters are constant, but their |
| in-forward construction (torch.sin/cat/flip from learnable params) either |
| won't build in TensorRT or constant-folds to wrong values. Baking them into a |
| plain Conv1d removes the sinc math from the graph. No-op when no Sinc layer. |
| """ |
| import torch.nn as nn |
| sincs = [(n, m) for n, m in net.named_modules() if "Sinc" in type(m).__name__] |
| if not sincs: |
| return net |
| dev = next(net.parameters()).device |
| for full_name, mod in sincs: |
| kernel = int(getattr(mod, "kernel_size", 0)) or 1 |
| with torch.no_grad(): |
| try: |
| mod(torch.zeros(1, 1, max(kernel * 4, 4096), device=dev)) |
| except Exception: |
| pass |
| W = mod.filters.detach().clone() |
| if W.dim() == 2: |
| W = W.unsqueeze(1) |
| conv = nn.Conv1d(W.shape[1], W.shape[0], W.shape[2], |
| stride=int(getattr(mod, "stride", 1)), |
| padding=int(getattr(mod, "padding", 0)), |
| dilation=int(getattr(mod, "dilation", 1)), |
| bias=False).to(dev).eval() |
| conv.weight.data.copy_(W) |
| parent = net |
| *parents, attr = full_name.split(".") |
| for p in parents: |
| parent = getattr(parent, p) |
| setattr(parent, attr, conv) |
| return net |
|
|
|
|
| class _ExportNet(torch.nn.Module): |
| """forward(x[B,T]) -> logits[B,C] (single tensor) for ONNX/TRT.""" |
|
|
| def __init__(self, net, L, i): |
| super().__init__() |
| self.net = net |
| self._extract = _extractor(L, i) |
|
|
| def forward(self, x): |
| return self._extract(self.net(x)) |
|
|
|
|
| |
| |
| |
| def export_onnx(model, T, L, i, onnx_path): |
| net = _prep_for_export(model.net) |
| wrap = _ExportNet(net, L, i).eval().to("cuda") |
| dummy = torch.zeros(2, T, device="cuda", dtype=torch.float32) |
| if DYNAMO_EXPORT: |
| |
| |
| batch = torch.export.Dim("b", min=1, max=MAX_BATCH_CAP) |
| torch.onnx.export( |
| wrap, (dummy,), str(onnx_path), dynamo=True, external_data=True, |
| input_names=["wav"], output_names=["logits"], |
| dynamic_shapes={"x": {0: batch}}, |
| ) |
| else: |
| torch.onnx.export( |
| wrap, dummy, str(onnx_path), opset_version=OPSET, |
| input_names=["wav"], output_names=["logits"], |
| dynamic_axes={"wav": {0: "batch"}, "logits": {0: "batch"}}, |
| do_constant_folding=True, |
| ) |
| return onnx_path |
|
|
|
|
| def build_engine(onnx_path, T, precision, max_batch, opt_batch, engine_path, timing_cache): |
| import tensorrt as trt |
|
|
| sev = trt.Logger.VERBOSE if os.environ.get("SSB_TRT_VERBOSE") else trt.Logger.WARNING |
| logger = trt.Logger(sev) |
| builder = trt.Builder(logger) |
| network = builder.create_network(0) |
| parser = trt.OnnxParser(network, logger) |
| |
| |
| if not parser.parse_from_file(str(onnx_path)): |
| errs = "; ".join(str(parser.get_error(k)) for k in range(parser.num_errors)) |
| raise RuntimeError(f"onnx parse failed: {errs}") |
|
|
| cfg = builder.create_builder_config() |
| cfg.builder_optimization_level = 1 |
| if precision == "fp16": |
| cfg.set_flag(trt.BuilderFlag.FP16) |
|
|
| tc_bytes = Path(timing_cache).read_bytes() if Path(timing_cache).exists() else b"" |
| tc = cfg.create_timing_cache(tc_bytes) |
| cfg.set_timing_cache(tc, ignore_mismatch=False) |
|
|
| profile = builder.create_optimization_profile() |
| profile.set_shape("wav", (1, T), (opt_batch, T), (max_batch, T)) |
| cfg.add_optimization_profile(profile) |
|
|
| plan = builder.build_serialized_network(network, cfg) |
| if plan is None: |
| raise RuntimeError("engine build returned None") |
| Path(engine_path).write_bytes(bytes(plan)) |
| Path(timing_cache).write_bytes(bytes(tc.serialize())) |
| return engine_path |
|
|
|
|
| |
| |
| |
| class _TRTCallable: |
| """Mimics net(xt): runs the engine on a [B,T] float32 CUDA tensor.""" |
|
|
| def __init__(self, engine_path, n_classes, L, i): |
| import tensorrt as trt |
|
|
| self.n_classes = n_classes |
| self.rebuild = _rebuild(L, i) |
| logger = trt.Logger(trt.Logger.WARNING) |
| self.runtime = trt.Runtime(logger) |
| self.engine = self.runtime.deserialize_cuda_engine(Path(engine_path).read_bytes()) |
| self.ctx = self.engine.create_execution_context() |
| if self.ctx is None: |
| raise RuntimeError( |
| "could not create execution context (likely OOM reserving max-profile " |
| "memory) — lower MAX_BATCH_CAP") |
| |
| self.in_name, self.out_name = "wav", "logits" |
|
|
| def __call__(self, x, *a, **k): |
| x = x.to("cuda", torch.float32).contiguous() |
| B = x.shape[0] |
| self.ctx.set_input_shape(self.in_name, tuple(x.shape)) |
| out = torch.empty((B, self.n_classes), device="cuda", dtype=torch.float32) |
| self.ctx.set_tensor_address(self.in_name, x.data_ptr()) |
| self.ctx.set_tensor_address(self.out_name, out.data_ptr()) |
| stream = torch.cuda.current_stream().cuda_stream |
| self.ctx.execute_async_v3(stream) |
| torch.cuda.current_stream().synchronize() |
| return self.rebuild(out) |
|
|
|
|
| |
| |
| |
| def _chunked_scores(model, audios, srs, chunk): |
| out = [] |
| for k in range(0, len(audios), chunk): |
| out.extend(model.score_batch(audios[k:k + chunk], srs[k:k + chunk])) |
| return np.asarray(out, dtype=np.float64) |
|
|
|
|
| def _spearman(a, b): |
| if len(a) < 2: |
| return 1.0 |
| ra = np.argsort(np.argsort(a)).astype(np.float64) |
| rb = np.argsort(np.argsort(b)).astype(np.float64) |
| return float(np.corrcoef(ra, rb)[0, 1]) |
|
|
|
|
| def parity(model, trt_call, n=64, chunk=PARITY_CHUNK): |
| data = real_audio(n) |
| audios = [a for a, _ in data] |
| srs = [s for _, s in data] |
| torch_net = model.net |
| py = _chunked_scores(model, audios, srs, chunk) |
| model.net = trt_call |
| tr = _chunked_scores(model, audios, srs, chunk) |
| model.net = torch_net |
| mad = float(np.max(np.abs(py - tr))) |
| pear = float(np.corrcoef(py, tr)[0, 1]) if len(py) > 1 else 1.0 |
| spear = _spearman(py, tr) |
| return {"n": len(py), "max_abs_score_diff": mad, "pearson": pear, |
| "spearman": spear} |
|
|
|
|
| def sweep(model, trt_call, |
| batches=(1, 2, 4, 8, 16, 24, 32, 48, 64, 96, 128), iters=20): |
| a, sr = real_audio(1)[0] |
| model.net = trt_call |
| res = {} |
| for B in batches: |
| if B > MAX_BATCH_CAP: |
| break |
| ab, sb = [a] * B, [sr] * B |
| try: |
| for _ in range(3): |
| model.score_batch(ab, sb) |
| torch.cuda.synchronize() |
| t0 = time.time() |
| for _ in range(iters): |
| model.score_batch(ab, sb) |
| torch.cuda.synchronize() |
| dt = time.time() - t0 |
| res[B] = B * iters / dt |
| except RuntimeError as e: |
| if "out of memory" in str(e).lower(): |
| torch.cuda.empty_cache() |
| break |
| raise |
| best = max(res, key=res.get) |
| return best, res |
|
|
|
|
| |
| |
| |
| class AASIST3TRT(_OrigClass): |
| """Drop-in: original preprocessing/score_batch; net replaced by the TRT engine.""" |
|
|
| def load(self): |
| self.device = "cuda" |
| side = json.loads((HERE / f"trt_{SLUG}.json").read_text())[gpu_slug()] |
| eng = HERE / side["engine"] |
| self.net = _TRTCallable(str(eng), side["n_classes"], side["L"], side["i"]) |
| self.batch_size = side["best_batch"] |
|
|
|
|
| |
| |
| |
| def _do_export(): |
| gpu = gpu_slug() |
| side_path = HERE / f"trt_{SLUG}.json" |
| tc = HERE / f".trt_timing_{gpu}.cache" |
| m = load_model() |
| T, L, i, n_classes = analyze(m) |
| print(f"[analyze] T={T} n_classes={n_classes} L={L} i={i}") |
| onnx_path = HERE / f"{SLUG}.onnx" |
| export_onnx(m, T, L, i, onnx_path) |
| print(f"[onnx] wrote {onnx_path.name}") |
|
|
| |
| |
| |
| pdata = real_audio(64) |
| paud, psr = [a for a, _ in pdata], [s for _, s in pdata] |
| py = _chunked_scores(m, paud, psr, PARITY_CHUNK) |
| m.net.to("cpu") |
| torch.cuda.empty_cache() |
|
|
| opt_batch = min(32, MAX_BATCH_CAP) |
| if FORCE_FP16: |
| precisions = ["fp16"] |
| elif FORCE_FP32: |
| precisions = ["fp32"] |
| else: |
| precisions = ["fp16", "fp32"] |
| chosen = None |
| last_err = None |
| for prec in precisions: |
| eng = HERE / f"engine_{gpu}_{prec}_b1-{opt_batch}-{MAX_BATCH_CAP}.plan" |
| try: |
| t0 = time.time() |
| build_engine(str(onnx_path), T, prec, MAX_BATCH_CAP, opt_batch, str(eng), str(tc)) |
| bt = time.time() - t0 |
| trt_call = _TRTCallable(str(eng), n_classes, L, i) |
| m.net = trt_call |
| tr = _chunked_scores(m, paud, psr, PARITY_CHUNK) |
| p = {"n": len(py), |
| "max_abs_score_diff": float(np.max(np.abs(py - tr))), |
| "pearson": float(np.corrcoef(py, tr)[0, 1]) if len(py) > 1 else 1.0, |
| "spearman": _spearman(py, tr)} |
| except Exception as e: |
| last_err = e |
| print(f"[{prec}] FAILED: {type(e).__name__}: {e}") |
| continue |
| print(f"[{prec}] build={bt:.1f}s parity={p}") |
| chosen = (prec, eng, p, trt_call) |
| if prec == "fp16" and p["spearman"] >= PARITY_SPEARMAN_TOL: |
| break |
|
|
| if chosen is None: |
| raise RuntimeError(f"all precisions failed to build; last error: {last_err}") |
| prec, eng, p, trt_call = chosen |
| if p["spearman"] < PARITY_FLOOR: |
| raise RuntimeError( |
| f"parity too low (spearman={p['spearman']:.4f} < {PARITY_FLOOR}): " |
| f"engine output does not match PyTorch — not accepting") |
| m.net = trt_call |
| best, table = sweep(m, trt_call) |
| side = json.loads(side_path.read_text()) if side_path.exists() else {} |
| side[gpu] = { |
| "precision": prec, "engine": eng.name, "window_samples": T, |
| "n_classes": n_classes, "L": L, "i": i, "best_batch": best, |
| "throughput_utt_s": {str(k): round(v, 2) for k, v in table.items()}, |
| "parity": p, "trt_version": __import__("tensorrt").__version__, |
| } |
| side_path.write_text(json.dumps(side, indent=2, default=str)) |
| print(f"[done] {SLUG}: prec={prec} best_batch={best} " |
| f"utt/s={table[best]:.1f} parity_mad={p['max_abs_score_diff']:.2e}") |
|
|
|
|
| def main(): |
| ap = argparse.ArgumentParser() |
| ap.add_argument("cmd", choices=["export", "sweep", "parity", "score"]) |
| ap.add_argument("audio", nargs="?") |
| args = ap.parse_args() |
| gpu = gpu_slug() |
| side_path = HERE / f"trt_{SLUG}.json" |
|
|
| if args.cmd == "export": |
| _do_export() |
| elif args.cmd in ("sweep", "parity"): |
| m = load_model() |
| side = json.loads(side_path.read_text())[gpu] |
| eng = HERE / side["engine"] |
| trt_call = _TRTCallable(str(eng), side["n_classes"], side["L"], side["i"]) |
| if args.cmd == "parity": |
| print(parity(m, trt_call)) |
| else: |
| best, table = sweep(m, trt_call) |
| full = json.loads(side_path.read_text()) |
| full[gpu]["best_batch"] = best |
| full[gpu]["throughput_utt_s"] = {str(k): round(v, 2) for k, v in table.items()} |
| side_path.write_text(json.dumps(full, indent=2, default=str)) |
| print(f"best_batch={best} utt/s={table[best]:.1f}") |
| elif args.cmd == "score": |
| import soundfile as sf |
| a, sr = sf.read(args.audio, dtype="float32") |
| if a.ndim > 1: |
| a = a.mean(1) |
| m = AASIST3TRT() |
| m.load() |
| print(m.score_batch([a.astype(np.float32)], [sr])[0]) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|