korallll commited on
Commit
552e29f
·
verified ·
1 Parent(s): e949ad0

Wrapper code (speech-spoof-bench)

Browse files
Files changed (8) hide show
  1. LICENSE.source +21 -0
  2. _net.py +309 -0
  3. dpda_ga_tcm.py +45 -0
  4. implementation-notes.md +58 -0
  5. make_avg.py +47 -0
  6. meta.yaml +45 -0
  7. test_dpda_ga_tcm.py +54 -0
  8. trt_dpda_ga_tcm.py +569 -0
LICENSE.source ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Duc-Tuan Truong
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
_net.py ADDED
@@ -0,0 +1,309 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DPDA-GA XLS-R + TCM-Conformer network, ported from
2
+ https://github.com/ductuantruong/dpda_ga (MIT) @ aab89614ee90252e10e5df35ec6aaa5ee2e179a7:
3
+ nets/conformer.py (ConformerBlock + Head-Token Attention, verbatim below)
4
+ nets/conformer_tcm.py (sinusoidal_embedding, MyConformer, SSLModel, Model)
5
+ Hyper-parameters = main.py defaults (the README trains `--model conformer_tcm` with
6
+ them): emb_size 144, heads 4, kernel_size 31, num_encoders 4 (ff_mult 4, exp_fac 2);
7
+ verified against the released checkpoint shapes (strict load).
8
+
9
+ Edits (inference-only, numerically identical; parameter/module names unchanged so
10
+ the released state_dict loads strictly):
11
+ 1. SSLModel builds the XLS-R 300M wav2vec2 architecture from the base
12
+ xlsr2_300m.pt model cfg merged onto the installed fairseq Wav2Vec2Config
13
+ (merge_with_parent; cross-version cfg-field fix, as W2V2-AASIST/_net.py),
14
+ instead of load_model_ensemble_and_task on a hard-coded path. The base
15
+ weights are not loaded -- the fine-tuned checkpoint overwrites every weight.
16
+ 2. SSLModel.extract_feat: the `self.model.train()` call on a device/dtype
17
+ mismatch is removed (it would re-enable dropout at inference); the wrapper
18
+ moves the net to the device and calls .eval() itself.
19
+ 3. Model.__init__ takes the hyper-parameters as keyword args (default = main.py)
20
+ instead of an argparse namespace; `device` arg dropped (unused at inference).
21
+ 4. MyConformer.forward: the class-token prepend
22
+ `torch.stack([torch.vstack((cls, x[i])) for i in range(len(x))])` is written as
23
+ `torch.cat([cls.expand(B, 1, E), x], 1)` (same tensor; the python loop over the
24
+ batch would freeze the batch size into a traced ONNX graph).
25
+ 5. Model.forward returns only the (B, 2) logits (attention maps dropped).
26
+ 6. MyConformer.positional_emb is initialised to zeros instead of the slow python-list
27
+ sinusoid build: it is a frozen nn.Parameter stored in (and loaded from) the checkpoint.
28
+ """
29
+ import os
30
+
31
+ import numpy as np
32
+ import torch
33
+ import torch.nn as nn
34
+ import torch.nn.functional as F
35
+ from einops.layers.torch import Rearrange
36
+ from torch.nn.modules.transformer import _get_clones
37
+
38
+ _XLSR_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "xlsr2_300m.pt")
39
+ WINDOW = 66800 # source data_utils.Dataset_eval / OOD_Dataset_eval: self.cut = 66800
40
+
41
+
42
+ def pad(x: np.ndarray, max_len: int = WINDOW) -> np.ndarray:
43
+ """source utils.pad: first max_len samples; shorter clips repeat-tiled."""
44
+ x = np.asarray(x, dtype=np.float32).reshape(-1)
45
+ x_len = x.shape[0]
46
+ if x_len >= max_len:
47
+ return x[:max_len]
48
+ num_repeats = int(max_len / x_len) + 1
49
+ return np.tile(x, (1, num_repeats))[:, :max_len][0]
50
+
51
+
52
+ # ============================ nets/conformer.py ============================
53
+ def exists(val):
54
+ return val is not None
55
+
56
+
57
+ def default(val, d):
58
+ return val if exists(val) else d
59
+
60
+
61
+ def calc_same_padding(kernel_size):
62
+ pad = kernel_size // 2
63
+ return (pad, pad - (kernel_size + 1) % 2)
64
+
65
+
66
+ class Swish(nn.Module):
67
+ def forward(self, x):
68
+ return x * x.sigmoid()
69
+
70
+
71
+ class GLU(nn.Module):
72
+ def __init__(self, dim):
73
+ super().__init__()
74
+ self.dim = dim
75
+
76
+ def forward(self, x):
77
+ out, gate = x.chunk(2, dim=self.dim)
78
+ return out * gate.sigmoid()
79
+
80
+
81
+ class DepthWiseConv1d(nn.Module):
82
+ def __init__(self, chan_in, chan_out, kernel_size, padding):
83
+ super().__init__()
84
+ self.padding = padding
85
+ self.conv = nn.Conv1d(chan_in, chan_out, kernel_size, groups=chan_in)
86
+
87
+ def forward(self, x):
88
+ x = F.pad(x, self.padding)
89
+ return self.conv(x)
90
+
91
+
92
+ class Scale(nn.Module):
93
+ def __init__(self, scale, fn):
94
+ super().__init__()
95
+ self.fn = fn
96
+ self.scale = scale
97
+
98
+ def forward(self, x, **kwargs):
99
+ return self.fn(x, **kwargs) * self.scale
100
+
101
+
102
+ class PreNorm(nn.Module):
103
+ def __init__(self, dim, fn):
104
+ super().__init__()
105
+ self.fn = fn
106
+ self.norm = nn.LayerNorm(dim)
107
+
108
+ def forward(self, x, **kwargs):
109
+ x = self.norm(x)
110
+ return self.fn(x, **kwargs)
111
+
112
+
113
+ class Attention(nn.Module):
114
+ # Head Token attention: https://arxiv.org/pdf/2210.05958.pdf
115
+ def __init__(self, dim, heads=8, dim_head=64, qkv_bias=False, dropout=0., proj_drop=0.):
116
+ super().__init__()
117
+ self.num_heads = heads
118
+ inner_dim = dim_head * heads
119
+ self.scale = dim_head ** -0.5
120
+
121
+ self.qkv = nn.Linear(dim, inner_dim * 3, bias=qkv_bias)
122
+
123
+ self.attn_drop = nn.Dropout(dropout)
124
+ self.proj = nn.Linear(inner_dim, dim)
125
+ self.proj_drop = nn.Dropout(proj_drop)
126
+
127
+ self.act = nn.GELU()
128
+ self.ht_proj = nn.Linear(dim_head, dim, bias=True)
129
+ self.ht_norm = nn.LayerNorm(dim_head)
130
+ self.pos_embed = nn.Parameter(torch.zeros(1, self.num_heads, dim))
131
+
132
+ def forward(self, x, mask=None):
133
+ B, N, C = x.shape
134
+
135
+ # head token
136
+ head_pos = self.pos_embed.expand(x.shape[0], -1, -1)
137
+ x_ = x.reshape(B, -1, self.num_heads, C // self.num_heads).permute(0, 2, 1, 3)
138
+ x_ = x_.mean(dim=2) # now the shape is [B, h, 1, d//h]
139
+ x_ = self.ht_proj(x_).reshape(B, -1, self.num_heads, C // self.num_heads)
140
+ x_ = self.act(self.ht_norm(x_)).flatten(2)
141
+ x_ = x_ + head_pos
142
+ x = torch.cat([x, x_], dim=1)
143
+
144
+ # normal mhsa
145
+ qkv = self.qkv(x).reshape(B, N + self.num_heads, 3, self.num_heads, C // self.num_heads).permute(2, 0, 3, 1, 4)
146
+ q, k, v = qkv[0], qkv[1], qkv[2]
147
+
148
+ attn = (q @ k.transpose(-2, -1)) * self.scale
149
+ attn = attn.softmax(dim=-1)
150
+ attn = self.attn_drop(attn)
151
+
152
+ x = (attn @ v).transpose(1, 2).reshape(B, N + self.num_heads, C)
153
+ x = self.proj(x)
154
+
155
+ # merge head tokens into cls token
156
+ cls, patch, ht = torch.split(x, [1, N - 1, self.num_heads], dim=1)
157
+ cls = cls + torch.mean(ht, dim=1, keepdim=True) + torch.mean(patch, dim=1, keepdim=True)
158
+ x = torch.cat([cls, patch], dim=1)
159
+
160
+ x = self.proj_drop(x)
161
+
162
+ return x, attn
163
+
164
+
165
+ class FeedForward(nn.Module):
166
+ def __init__(self, dim, mult=4, dropout=0.):
167
+ super().__init__()
168
+ self.net = nn.Sequential(
169
+ nn.Linear(dim, dim * mult),
170
+ Swish(),
171
+ nn.Dropout(dropout),
172
+ nn.Linear(dim * mult, dim),
173
+ nn.Dropout(dropout)
174
+ )
175
+
176
+ def forward(self, x):
177
+ return self.net(x)
178
+
179
+
180
+ class ConformerConvModule(nn.Module):
181
+ def __init__(self, dim, causal=False, expansion_factor=2, kernel_size=31, dropout=0.):
182
+ super().__init__()
183
+
184
+ inner_dim = dim * expansion_factor
185
+ padding = calc_same_padding(kernel_size) if not causal else (kernel_size - 1, 0)
186
+
187
+ self.net = nn.Sequential(
188
+ nn.LayerNorm(dim),
189
+ Rearrange('b n c -> b c n'),
190
+ nn.Conv1d(dim, inner_dim * 2, 1),
191
+ GLU(dim=1),
192
+ DepthWiseConv1d(inner_dim, inner_dim, kernel_size=kernel_size, padding=padding),
193
+ nn.BatchNorm1d(inner_dim) if not causal else nn.Identity(),
194
+ Swish(),
195
+ nn.Conv1d(inner_dim, dim, 1),
196
+ Rearrange('b c n -> b n c'),
197
+ nn.Dropout(dropout)
198
+ )
199
+
200
+ def forward(self, x):
201
+ return self.net(x)
202
+
203
+
204
+ class ConformerBlock(nn.Module):
205
+ def __init__(self, *, dim, dim_head=64, heads=8, ff_mult=4, conv_expansion_factor=2,
206
+ conv_kernel_size=31, attn_dropout=0., ff_dropout=0., conv_dropout=0.,
207
+ conv_causal=False):
208
+ super().__init__()
209
+ self.ff1 = FeedForward(dim=dim, mult=ff_mult, dropout=ff_dropout)
210
+ self.attn = Attention(dim=dim, dim_head=dim_head, heads=heads, dropout=attn_dropout)
211
+ self.conv = ConformerConvModule(dim=dim, causal=conv_causal, expansion_factor=conv_expansion_factor,
212
+ kernel_size=conv_kernel_size, dropout=conv_dropout)
213
+ self.ff2 = FeedForward(dim=dim, mult=ff_mult, dropout=ff_dropout)
214
+
215
+ self.attn = PreNorm(dim, self.attn)
216
+ self.ff1 = Scale(0.5, PreNorm(dim, self.ff1))
217
+ self.ff2 = Scale(0.5, PreNorm(dim, self.ff2))
218
+
219
+ self.post_norm = nn.LayerNorm(dim)
220
+
221
+ def forward(self, x, mask=None):
222
+ x = self.ff1(x) + x
223
+ attn_x, attn_weight = self.attn(x, mask=mask)
224
+ x = attn_x + x
225
+ x = self.conv(x) + x
226
+ x = self.ff2(x) + x
227
+ x = self.post_norm(x)
228
+ return x, attn_weight
229
+
230
+
231
+ # ============================ nets/conformer_tcm.py ============================
232
+ def sinusoidal_embedding(n_channels, dim):
233
+ pe = torch.FloatTensor([[p / (10000 ** (2 * (i // 2) / dim)) for i in range(dim)]
234
+ for p in range(n_channels)])
235
+ pe[:, 0::2] = torch.sin(pe[:, 0::2])
236
+ pe[:, 1::2] = torch.cos(pe[:, 1::2])
237
+ return pe.unsqueeze(0)
238
+
239
+
240
+ class MyConformer(nn.Module):
241
+ def __init__(self, emb_size=128, heads=4, ffmult=4, exp_fac=2, kernel_size=16, n_encoders=1):
242
+ super(MyConformer, self).__init__()
243
+ self.dim_head = int(emb_size / heads)
244
+ self.dim = emb_size
245
+ self.heads = heads
246
+ self.kernel_size = kernel_size
247
+ self.n_encoders = n_encoders
248
+ # edit 6: zeros placeholder; the checkpoint stores this (frozen) table and overwrites it
249
+ self.positional_emb = nn.Parameter(torch.zeros(1, 10000, emb_size), requires_grad=False)
250
+ self.encoder_blocks = _get_clones(ConformerBlock(dim=emb_size, dim_head=self.dim_head, heads=heads,
251
+ ff_mult=ffmult, conv_expansion_factor=exp_fac,
252
+ conv_kernel_size=kernel_size),
253
+ n_encoders)
254
+ self.class_token = nn.Parameter(torch.rand(1, emb_size))
255
+ self.fc5 = nn.Linear(emb_size, 2)
256
+
257
+ def forward(self, x): # x shape [bs, tiempo, frecuencia]
258
+ x = x + self.positional_emb[:, :x.size(1), :]
259
+ # edit 4: == torch.stack([torch.vstack((self.class_token, x[i])) for i in range(len(x))])
260
+ x = torch.cat([self.class_token.unsqueeze(0).expand(x.size(0), -1, -1), x], dim=1) # [bs,1+tiempo,emb]
261
+ for layer in self.encoder_blocks:
262
+ x, _ = layer(x) # [bs,1+tiempo,emb_size]
263
+ embedding = x[:, 0, :] # [bs, emb_size]
264
+ out = self.fc5(embedding) # [bs,2]
265
+ return out
266
+
267
+
268
+ class SSLModel(nn.Module): # W2V
269
+ def __init__(self, cp_path=_XLSR_PATH):
270
+ super(SSLModel, self).__init__()
271
+ import fairseq
272
+ from fairseq.dataclass.utils import merge_with_parent
273
+ from fairseq.models.wav2vec.wav2vec2 import Wav2Vec2Config, Wav2Vec2Model
274
+ state = fairseq.checkpoint_utils.load_checkpoint_to_cpu(cp_path)
275
+ cfg = merge_with_parent(Wav2Vec2Config(), state["cfg"]["model"], remove_missing=True)
276
+ del state
277
+ self.model = Wav2Vec2Model.build_model(cfg, task=None)
278
+ self.out_dim = 1024
279
+
280
+ def extract_feat(self, input_data):
281
+ # edit 2: no `self.model.to(...); self.model.train()` here.
282
+ if input_data.ndim == 3:
283
+ input_tmp = input_data[:, :, 0]
284
+ else:
285
+ input_tmp = input_data
286
+ # [batch, length, dim]
287
+ emb = self.model(input_tmp, mask=False, features_only=True)['x']
288
+ return emb
289
+
290
+
291
+ class Model(nn.Module):
292
+ def __init__(self, emb_size=144, heads=4, kernel_size=31, num_encoders=4):
293
+ super().__init__()
294
+ self.ssl_model = SSLModel()
295
+ self.LL = nn.Linear(1024, emb_size)
296
+ self.first_bn = nn.BatchNorm2d(num_features=1)
297
+ self.selu = nn.SELU(inplace=True)
298
+ self.conformer = MyConformer(emb_size=emb_size, n_encoders=num_encoders,
299
+ heads=heads, kernel_size=kernel_size)
300
+
301
+ def forward(self, x):
302
+ x_ssl_feat = self.ssl_model.extract_feat(x.squeeze(-1))
303
+ x = self.LL(x_ssl_feat) # (bs,frame_number,feat_out_dim)
304
+ x = x.unsqueeze(dim=1) # add channel
305
+ x = self.first_bn(x)
306
+ x = self.selu(x)
307
+ x = x.squeeze(dim=1)
308
+ out = self.conformer(x)
309
+ return out
dpda_ga_tcm.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ import numpy as np
6
+ import torch
7
+ from speech_spoof_bench.model import AntiSpoofingModel
8
+
9
+ from _net import WINDOW, Model as DPDAGATCMNet, pad
10
+
11
+ _DIR = os.path.dirname(os.path.abspath(__file__))
12
+ # Elementwise mean of HF tuantd/dpda_ga pcgrad/conformer_tcm/best/best_{0..3}.pth (make_avg.py);
13
+ # best_4.pth on HF is truncated, so the authors' 5-checkpoint average is not reproducible.
14
+ _CKPT = os.path.join(_DIR, "dpda_ga_tcm_avg4.pth")
15
+
16
+
17
+ class DPDAGATCM(AntiSpoofingModel):
18
+ name = "DPDA-GA-TCM"
19
+ expected_sample_rate = 16000
20
+ batch_size = 24 # sweep 2026-10-30 (cudnn TF32 off): RTX 4070 Ti SUPER 121.7 utt/s @24 (plateau 16-64); RTX 5060 Ti -> 4 (see load)
21
+
22
+ def load(self) -> None:
23
+ self.device = "cuda" if torch.cuda.is_available() else "cpu"
24
+ # Strict FP32: cuDNN's default TF32 convs (XLS-R CNN front-end) make scores batch-dependent
25
+ # (~2e-4); off -> batched == single within 2e-6. No speed cost on the 4070 Ti SUPER.
26
+ torch.backends.cudnn.allow_tf32 = False
27
+ net = DPDAGATCMNet() # emb 144, heads 4, kernel 31, 4 encoders (main.py defaults)
28
+ sd = torch.load(_CKPT, map_location="cpu")
29
+ net.load_state_dict(sd, strict=True)
30
+ # .to() before .eval(); extract_feat no longer flips XLS-R back to train().
31
+ self.net = net.to(self.device).eval()
32
+ if self.device == "cuda" and "5060" in torch.cuda.get_device_name(0):
33
+ self.batch_size = 4 # sweep 2026-10-30: RTX 5060 Ti peaks at bs 4 (22.2 utt/s), ~15 at bs >= 8
34
+
35
+ @torch.no_grad()
36
+ def score_batch(self, audios, srs):
37
+ x = np.stack([pad(a, WINDOW) for a in audios])
38
+ logits = self.net(torch.from_numpy(x).to(self.device)).float().reshape(-1, 2)
39
+ # source main.py produce_evaluation_file: score = batch_out[:, 1]; label 1 == bonafide
40
+ return logits[:, 1].cpu().tolist()
41
+
42
+ def unload(self) -> None:
43
+ self.net = None
44
+ if torch.cuda.is_available():
45
+ torch.cuda.empty_cache()
implementation-notes.md ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # DPDA-GA-TCM — implementation notes
2
+
3
+ Plan: `speech-spoof-bench/docs/plans/2026-10-29-mamba-batch-arena-submission.md` (model #2).
4
+
5
+ ## Source
6
+ - Code: github.com/ductuantruong/dpda_ga @ `aab89614ee90252e10e5df35ec6aaa5ee2e179a7` (MIT, `LICENSE.source`):
7
+ `nets/conformer.py` + `nets/conformer_tcm.py` → `_net.py` (edits listed in its header).
8
+ - Weights: HF `tuantd/dpda_ga` @ `531554e0a1c28d6a6ccf22a355281e3d3acefeb5`,
9
+ `pcgrad/conformer_tcm/best/best_{0..4}.pth` (downloaded by `benchmarks/_ckpt_dl/dl.py`).
10
+ - Hyper-parameters = `main.py` defaults: emb 144, heads 4, kernel 31, 4 encoders; checkpoint
11
+ shapes confirm (LL 144×1024, ht_proj 144×36, depthwise conv k=31, encoder_blocks 0..3).
12
+ - XLS-R 300M architecture built from `xlsr2_300m.pt` cfg (symlink to `../W2V2-AASIST/`) via
13
+ `merge_with_parent`; base weights not loaded (the checkpoint overwrites all 588 tensors).
14
+
15
+ ## Checkpoint averaging (`make_avg.py` → `dpda_ga_tcm_avg4.pth`)
16
+ - `best_4.pth` is truncated on HF: 547,680,256 bytes vs 1,285,107,746 for the others, no ZIP
17
+ central directory; `torch.load` → "failed finding central directory". Confirmed locally.
18
+ - `main.py` eval averages `n_average_model` (default 5) n-best checkpoints, but
19
+ `sd = model.state_dict()` aliases the live parameters, so the following
20
+ `model.load_state_dict(best_1)` overwrites `sd` in place. Net effect:
21
+ `(2*b1 + b2 + … + b_{n-1}) / n`; best_0 never contributes. Maintainer decision (2026-10-30):
22
+ reproduce main.py exactly with the 4 intact files → `(2*best_1 + best_2 + best_3) / 4`,
23
+ float32, main.py's summation order; verified bit-identical to literally running main.py's
24
+ loop on a module holding the tensors. Int buffers (num_batches_tracked) get the truncated
25
+ float quotient, as main.py's copy into a long buffer does (unused at eval).
26
+ - The paper's numbers come from the 5-file version `(2*b1+b2+b3+b4)/5`, so small deviations
27
+ from the paper are expected.
28
+
29
+ ## Wrapper decisions
30
+ - Input: first 66,800 samples; shorter clips repeat-tiled (`utils.pad`). No resampling/normalisation.
31
+ - Score = `logits[:, 1]` (label 1 = bonafide in `utils.read_metadata`; `produce_evaluation_file`).
32
+ - `.to(device)` then `.eval()`; `extract_feat`'s `self.model.train()` removed.
33
+ - cuDNN TF32 disabled in `load()`: with it on, batched vs single scores differed by up to 2e-4
34
+ (XLS-R conv front-end); off → 1.7e-6. 4070 Ti SUPER speed unchanged (121.7 utt/s @24 both ways);
35
+ 5060 Ti PyTorch drops 47.5 → 22.2 utt/s (bs 4), but that card runs TensorRT.
36
+ - Params: 321,213,220 (incl. the frozen 10000×144 positional table, 1.44 M).
37
+
38
+ ## Batch sweep (sweep.py, 66,800-sample random input, 2026-10-30)
39
+ | card | TF32 off best | TF32 on best |
40
+ |---|---|---|
41
+ | RTX 4070 Ti SUPER (idx 2) | 121.7 utt/s @24 | 121.7 @24 |
42
+ | RTX 5060 Ti (idx 3) | 22.2 @4 | 47.5 @4 |
43
+ Raw: `logs_sweep_g{2,3}_tf{0,1}.txt`.
44
+
45
+ ## Sanity (full J-SPAW_LA, PyTorch, RTX 4070 Ti SUPER)
46
+ - EER 12.90% (2397 trials, 0 skipped), ~110 utt/s. Comparable XLS-R models: DPDA-GA-AASIST 13.65,
47
+ XLSR-SLS 8.70, W2V2-AASIST 26.17. Not flipped.
48
+
49
+ ## TensorRT (`trt_dpda_ga_tcm.py`, sidecar `trt_dpda-ga-tcm.json`, TRT 10.16)
50
+ Adapted from XLSR-MamBo's script (config block only). The class-token loop rewrite (`_net.py`
51
+ edit 4) keeps the batch axis dynamic in the traced ONNX.
52
+ | card | precision | best batch | utt/s (sweep) | build parity Spearman (64 ITW) | J-SPAW EER | J-SPAW Spearman vs PT / max Δ |
53
+ |---|---|---|---|---|---|---|
54
+ | RTX 4070 Ti SUPER (idx 2) | FP32 (FP16 Spearman 0.99963 < 0.9999) | 8 | 173.3 | 1.0000 | 12.8992 (=PT) | 0.999998 / 0.031 |
55
+ | RTX 5060 Ti (idx 3) | FP16 | 16 | 362.2 | 0.99991 | 12.8992 (=PT) | 0.999917 / 0.287 |
56
+ Both cards pass (|ΔEER| = 0 ≤ 0.25, Spearman ≥ 0.999). J-SPAW_LA is submitted from PyTorch scores
57
+ (`results_pt/`); TRT runs in `results_trt/` (idx 2) and `results_trt3/` (idx 3). `cmp_scores.py`
58
+ does the comparison. The unused 4070 FP16 engine was deleted.
make_avg.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # make_avg.py — build the averaged DPDA-GA-TCM checkpoint (2026-10-29).
2
+ # Source: HF tuantd/dpda_ga @ 531554e0a1c28d6a6ccf22a355281e3d3acefeb5, pcgrad/conformer_tcm/best/best_{0..4}.pth.
3
+ # best_4.pth on HF is truncated (547,680,256 bytes vs ~1,285,107,746; no ZIP central directory,
4
+ # torch.load fails), so we use the 4 intact n-best checkpoints best_0..best_3 (n_average_model 5 -> 4)
5
+ # and reproduce main.py's averaging EXACTLY, including its aliasing bug (maintainer decision
6
+ # 2026-10-30): `sd = model.state_dict()` returns tensors that share storage with the model, so the
7
+ # next `model.load_state_dict(best_1)` overwrites sd in place; the loop then adds best_1 to itself.
8
+ # main.py's result is therefore (2*b1 + b2 + ... + b_{n-1}) / n -- best_0 never contributes.
9
+ # For n = 4: (2*best_1 + best_2 + best_3) / 4, computed in float32 in main.py's order. Integer
10
+ # buffers (BatchNorm num_batches_tracked, unused at eval) get main.py's float quotient truncated
11
+ # by the copy into the long buffer.
12
+ import os
13
+ import sys
14
+
15
+ import torch
16
+
17
+ SRC = sys.argv[1] if len(sys.argv) > 1 else os.path.join(
18
+ os.path.dirname(os.path.abspath(__file__)), "..", "_ckpt_dl", "tuantd__dpda_ga", "pcgrad", "conformer_tcm", "best")
19
+ OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dpda_ga_tcm_avg4.pth")
20
+ N = 4
21
+
22
+ try:
23
+ torch.load(os.path.join(SRC, "best_4.pth"), map_location="cpu")
24
+ raise SystemExit("best_4.pth loads -- average all 5 instead")
25
+ except RuntimeError as e:
26
+ print("best_4.pth unreadable as expected:", str(e)[:100])
27
+
28
+ sds = [torch.load(os.path.join(SRC, f"best_{i}.pth"), map_location="cpu") for i in range(N)]
29
+ keys = list(sds[0].keys())
30
+ for i, sd in enumerate(sds[1:], 1):
31
+ assert list(sd.keys()) == keys, f"key mismatch in best_{i}"
32
+ for k in keys:
33
+ assert sd[k].shape == sds[0][k].shape and sd[k].dtype == sds[0][k].dtype, (i, k)
34
+ avg = {}
35
+ for k in keys:
36
+ t0 = sds[0][k]
37
+ acc = sds[1][k].clone() # sd aliased to the params after load_state_dict(best_1)
38
+ acc = acc + sds[1][k] # i = 1: sd[key] = sd[key] + sd2[key] -> 2*b1
39
+ for i in range(2, N):
40
+ acc = acc + sds[i][k] # i = 2..N-1
41
+ acc = acc / N # true division (float even for int buffers)
42
+ avg[k] = acc.to(t0.dtype) # load_state_dict copy_ into the original dtype (long: trunc)
43
+ dtypes = sorted({str(v.dtype) for v in avg.values()})
44
+ print(f"{len(keys)} keys, dtypes {dtypes}; identical ckpts? ",
45
+ all(torch.equal(sds[0][k], sds[1][k]) for k in keys))
46
+ torch.save(avg, OUT)
47
+ print("wrote", OUT, os.path.getsize(OUT))
meta.yaml ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ system:
2
+ name: "DPDA-GA-TCM"
3
+ slug: "dpda-ga-tcm"
4
+ description: >
5
+ DPDA-GA (dual-path data-augmented training with gradient alignment) applied to an
6
+ XLS-R 300M + TCM-Conformer detector: a fully fine-tuned wav2vec 2.0 XLS-R 300M
7
+ front-end projected to 144-d (BatchNorm + SELU), followed by 4 Conformer blocks with
8
+ Head-Token (TCM) multi-head attention (4 heads, depthwise conv kernel 31) and a class
9
+ token feeding a linear 2-way head. Trained on ASVspoof2019 LA with each batch seen both
10
+ clean and RawBoost-augmented, the two losses' gradients aligned with PCGrad.
11
+ Checkpoint = weight average of the authors' n-best checkpoints, reproducing their
12
+ main.py averaging exactly: the release's 5th checkpoint (best_4.pth) is truncated on
13
+ HuggingFace and cannot be loaded, so 4 checkpoints are averaged instead of 5, and
14
+ main.py's averaging code drops best_0 and double-counts best_1, giving
15
+ (2*best_1 + best_2 + best_3)/4. FP32, deterministic first-66800-sample window
16
+ (shorter clips repeat-tiled).
17
+ code: "https://github.com/ductuantruong/dpda_ga"
18
+ checkpoint: "https://huggingface.co/SpeechAntiSpoofingBenchmarks/DPDA-GA-TCM"
19
+ params_millions: 321.2132
20
+ paper:
21
+ arxiv_id: "2509.20682"
22
+ url: "https://arxiv.org/abs/2509.20682"
23
+ bibtex: |
24
+ @inproceedings{truong2026dpdaga,
25
+ author = {Truong, Duc-Tuan and Liu, Tianchi and Li, Junjie and Tao, Ruijie and Lee, Kong Aik and Chng, Eng Siong},
26
+ title = {Addressing Gradient Misalignment in Data-Augmented Training for Robust Speech Deepfake Detection},
27
+ booktitle = {ICASSP 2026 - 2026 IEEE International Conference on Acoustics, Speech and Signal Processing (ICASSP)},
28
+ year = {2026},
29
+ pages = {16252--16256},
30
+ doi = {10.1109/ICASSP55912.2026.11464739}
31
+ }
32
+ notes: >
33
+ DPDA-GA XLS-R + TCM-Conformer (PCGrad) from ductuantruong/dpda_ga @ aab8961 (MIT), weights
34
+ from HF tuantd/dpda_ga @ 531554e pcgrad/conformer_tcm/best/best_{0..4}.pth. best_4.pth is
35
+ truncated on HF (547,680,256 of ~1,285,107,746 bytes; no ZIP central directory), so the
36
+ authors' 5-checkpoint average (main.py --average_model True --n_average_model 5) cannot be
37
+ rebuilt; we apply main.py's averaging to the 4 intact checkpoints. main.py's loop has a
38
+ state_dict aliasing bug (sd = model.state_dict() shares storage with the model, so loading
39
+ best_1 overwrites it): its result is (2*b1 + b2 + ... + b_{n-1})/n with best_0 dropped. We
40
+ reproduce it exactly: (2*best_1 + best_2 + best_3)/4 (make_avg.py). Strict load.
41
+ Deterministic first-66800-sample window, shorter clips repeat-tiled (source utils.pad), no
42
+ resampling/normalisation. score = logits[:, 1] (bona fide = class 1; source
43
+ produce_evaluation_file); higher = more bona fide. FP32 with cuDNN TF32 off (TF32 convs made
44
+ scores batch-dependent by ~2e-4). Paper EERs (5-checkpoint model): 21DF 1.81%, ITW 6.48%.
45
+ Trained on ASVspoof2019 LA train (dev for model selection) only.
test_dpda_ga_tcm.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import pytest
3
+ import torch
4
+
5
+ import _net
6
+ import dpda_ga_tcm as D
7
+
8
+ N_PARAMS = 321_213_220 # incl. the frozen 10000x144 positional table (1.44 M)
9
+
10
+
11
+ def test_pad_long_truncated():
12
+ x = np.arange(100000, dtype=np.float32)
13
+ assert np.array_equal(_net.pad(x), x[:66800])
14
+
15
+
16
+ def test_pad_short_repeated():
17
+ x = np.arange(1000, dtype=np.float32)
18
+ out = _net.pad(x)
19
+ assert out.shape == (66800,) and np.array_equal(out[:1000], x) and np.array_equal(out[1000:2000], x)
20
+
21
+
22
+ @pytest.fixture(scope="module")
23
+ def model():
24
+ m = D.DPDAGATCM(); m.load() # strict=True load
25
+ yield m
26
+ m.unload()
27
+
28
+
29
+ def test_eval_mode_and_params(model):
30
+ assert not any(mod.training for mod in model.net.modules())
31
+ assert sum(p.numel() for p in model.net.parameters()) == N_PARAMS
32
+
33
+
34
+ def test_cls_token_cat_matches_source_stack(model):
35
+ c = model.net.conformer
36
+ x = torch.randn(3, 10, 144, device=model.device)
37
+ src = torch.stack([torch.vstack((c.class_token, x[i])) for i in range(len(x))])
38
+ new = torch.cat([c.class_token.unsqueeze(0).expand(3, -1, -1), x], dim=1)
39
+ assert torch.equal(src, new)
40
+
41
+
42
+ def test_batch_matches_single(model):
43
+ rng = np.random.default_rng(0)
44
+ a = [rng.standard_normal(n).astype(np.float32) * s for n, s in ((48000, 0.1), (20000, 0.5), (70000, 0.02), (3000, 0.3))]
45
+ batched = model.score_batch(a, [16000] * 4)
46
+ single = [model.score_batch([x], [16000])[0] for x in a]
47
+ assert len(batched) == 4 and all(np.isfinite(batched))
48
+ assert np.allclose(batched, single, atol=1e-4)
49
+
50
+
51
+ def test_deterministic_and_finite(model):
52
+ a = [np.random.randn(40000).astype(np.float32) for _ in range(3)]
53
+ s1 = model.score_batch(a, [16000] * 3)
54
+ assert all(np.isfinite(s1)) and s1 == model.score_batch(a, [16000] * 3)
trt_dpda_ga_tcm.py ADDED
@@ -0,0 +1,569 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """TensorRT export + inference for DPDAGATCM (DPDA-GA XLS-R + TCM conformer). Self-contained (no shared package).
3
+
4
+ Exports only the model's `net` (all preprocessing already lives in the original
5
+ `score_batch`) with a fixed time axis and a dynamic batch axis, builds a FP16
6
+ engine (FP32 fallback if parity drifts), finds the fastest batch on the current
7
+ GPU, and exposes a drop-in `DPDAGATCMTRT` class identical to the PyTorch path except
8
+ the neural forward runs on TensorRT.
9
+
10
+ CLI:
11
+ python trt_dpda_ga_tcm.py export # ONNX -> engine -> parity -> sweep -> sidecar
12
+ python trt_dpda_ga_tcm.py sweep # re-run the batch sweep, update sidecar
13
+ python trt_dpda_ga_tcm.py parity # PyTorch vs TRT parity report
14
+ python trt_dpda_ga_tcm.py score AUDIO.wav
15
+
16
+ Pin the GPU with: CUDA_DEVICE_ORDER=PCI_BUS_ID CUDA_VISIBLE_DEVICES=<n>
17
+ """
18
+ from __future__ import annotations
19
+ import argparse
20
+ import io
21
+ import json
22
+ import os
23
+ import sys
24
+ import time
25
+ from pathlib import Path
26
+
27
+ import numpy as np
28
+
29
+ HERE = Path(__file__).resolve().parent
30
+ sys.path.insert(0, str(HERE)) # import dir-local entry + _net
31
+ # Pin GPU deterministically: PCI order makes CUDA indices match `nvidia-smi`.
32
+ os.environ.setdefault("CUDA_DEVICE_ORDER", "PCI_BUS_ID")
33
+ os.environ.setdefault("CUDA_VISIBLE_DEVICES", os.environ.get("SSB_TRT_GPU", "2"))
34
+
35
+ import torch # noqa: E402 (after env pin)
36
+
37
+ # ======================= per-model config =======================
38
+ ENTRY_MODULE = "dpda_ga_tcm" # module exposing the AntiSpoofingModel subclass
39
+ ENTRY_CLASS = "DPDAGATCM" # the subclass name
40
+ SLUG = "dpda-ga-tcm"
41
+ PARITY_DATASET = "InTheWild" # sibling dataset dir with data/*.parquet
42
+ MAX_BATCH_CAP = 32 # VRAM ceiling for the profile + sweep
43
+ PARITY_CHUNK = 8 # safe mini-batch for the parity comparison
44
+ OPSET = 17
45
+ # Keep FP16 iff it preserves the score RANKING (Spearman) -> identical EER.
46
+ # This is the metric that matters for the benchmark and is scale-invariant, so
47
+ # small absolute-logit drift (harmless for EER) does not force an FP32 fallback.
48
+ # FP16 is also mandatory for the largest models (FP32 would not fit in VRAM).
49
+ PARITY_SPEARMAN_TOL = 0.9999 # min Spearman rank-corr to keep FP16
50
+ PARITY_FLOOR = 0.99 # hard floor: below this the engine is wrong -> FAIL
51
+ PARITY_MAD_TOL = 1e-2 # informational only
52
+ PARITY_R_TOL = 0.9999 # informational only
53
+ FORCE_FP32 = bool(int(os.environ.get("TRT_FORCE_FP32", "0")))
54
+ NO_TF32 = bool(int(os.environ.get("TRT_NO_TF32", "0"))) # per-GPU choice, see build_engine
55
+ FORCE_FP16 = False # skip FP32 (for giant models where FP32 won't fit VRAM)
56
+ DYNAMO_EXPORT = False # 1.28 GB < 2 GB protobuf limit
57
+ ALLOW_ORT_FALLBACK = False
58
+ # ================================================================
59
+
60
+ from importlib import import_module as _imp # noqa: E402
61
+ _OrigClass = getattr(_imp(ENTRY_MODULE), ENTRY_CLASS)
62
+
63
+
64
+ # ----------------------------------------------------------------------------
65
+ # helpers
66
+ # ----------------------------------------------------------------------------
67
+ def gpu_slug() -> str:
68
+ name = torch.cuda.get_device_name(0)
69
+ return name.replace("NVIDIA ", "").replace("GeForce ", "").strip().replace(" ", "_")
70
+
71
+
72
+ def load_model():
73
+ m = _OrigClass()
74
+ m.load()
75
+ return m
76
+
77
+
78
+ def real_audio(n=64):
79
+ """Decode up to n real 16 kHz mono utterances from PARITY_DATASET/data/*.parquet."""
80
+ import pyarrow.parquet as pq
81
+ import soundfile as sf
82
+ import torchaudio.functional as AF
83
+
84
+ data_dir = HERE.parent / PARITY_DATASET / "data"
85
+ files = sorted(data_dir.glob("test-*.parquet")) or sorted(data_dir.glob("*.parquet"))
86
+ out = []
87
+ for f in files:
88
+ t = pq.read_table(f)
89
+ col = "audio" if "audio" in t.column_names else t.column_names[0]
90
+ for row in t.column(col).to_pylist():
91
+ b = row["bytes"] if isinstance(row, dict) else row
92
+ if not b:
93
+ continue
94
+ a, sr = sf.read(io.BytesIO(b), dtype="float32")
95
+ if a.ndim > 1:
96
+ a = a.mean(1)
97
+ a = np.ascontiguousarray(a, dtype=np.float32)
98
+ if sr != 16000:
99
+ a = AF.resample(torch.from_numpy(a), sr, 16000).numpy().astype(np.float32)
100
+ sr = 16000
101
+ out.append((a, sr))
102
+ if len(out) >= n:
103
+ return out
104
+ if not out:
105
+ raise RuntimeError(f"no parity audio found under {data_dir}")
106
+ return out
107
+
108
+
109
+ class _Capture:
110
+ """Wrap net: pass through to the real net, record input tensor + output."""
111
+
112
+ def __init__(self, net):
113
+ self.net = net
114
+ self.x = None
115
+ self.out = None
116
+
117
+ def __call__(self, x, *a, **k):
118
+ self.x = x.detach()
119
+ self.out = self.net(x, *a, **k)
120
+ return self.out
121
+
122
+ def __getattr__(self, name):
123
+ return getattr(self.net, name)
124
+
125
+
126
+ def _logits_index(out):
127
+ """Return (L, i, n_classes): tuple length (None if tensor), logits slot, n_classes.
128
+
129
+ Heuristic: the class-logits tensor is the 2-D (B, C) tensor with the smallest C.
130
+ """
131
+ if isinstance(out, torch.Tensor):
132
+ return None, None, int(out.shape[-1])
133
+ cands = [(j, t) for j, t in enumerate(out)
134
+ if isinstance(t, torch.Tensor) and t.dim() == 2]
135
+ if not cands:
136
+ raise RuntimeError("could not locate a 2-D (B,C) logits tensor in net output")
137
+ j, t = min(cands, key=lambda it: int(it[1].shape[-1]))
138
+ return len(out), j, int(t.shape[-1])
139
+
140
+
141
+ def analyze(model):
142
+ """One real forward through the capture shim -> (T, L, i, n_classes)."""
143
+ data = real_audio(1)
144
+ audios = [a for a, _ in data]
145
+ srs = [s for _, s in data]
146
+ cap = _Capture(model.net)
147
+ model.net = cap
148
+ with torch.no_grad():
149
+ model.score_batch(audios, srs)
150
+ model.net = cap.net
151
+ T = int(cap.x.shape[-1])
152
+ L, i, n_classes = _logits_index(cap.out)
153
+ return T, L, i, n_classes
154
+
155
+
156
+ def _extractor(L, i):
157
+ """Pick the logits tensor out of a net's raw output."""
158
+ if L is None:
159
+ return lambda y: y
160
+ return lambda y, i=i: y[i]
161
+
162
+
163
+ def _rebuild(L, i):
164
+ """Wrap a bare logits tensor back into the net's original output structure."""
165
+ if L is None:
166
+ return lambda y: y
167
+ return lambda y, L=L, i=i: tuple(y if j == i else None for j in range(L))
168
+
169
+
170
+ def _prep_for_export(net):
171
+ """Make export-hostile layers traceable. No-op for non-fairseq models.
172
+
173
+ fairseq wav2vec2/hubert call `pad_to_multiple`, which does `(tsz/multiple)
174
+ .is_integer()`; under torch.jit tracing `tsz` becomes a Tensor with no
175
+ `.is_integer()`. Our time axis is static, so we swap in a constant-length
176
+ pad that traces cleanly. Patches every fairseq module that bound the name.
177
+ """
178
+ def _safe_pad(x, multiple, dim=-1, value=0):
179
+ import torch.nn.functional as F
180
+ if x is None:
181
+ return None, 0
182
+ tsz = int(x.shape[dim]) # static: time axis is fixed
183
+ rem = (multiple - tsz % multiple) % multiple
184
+ if rem == 0:
185
+ return x, 0
186
+ pad_offset = (0,) * (-1 - dim) * 2
187
+ return F.pad(x, (*pad_offset, 0, rem), value=value), rem
188
+
189
+ for modname in ("fairseq.models.wav2vec.utils",
190
+ "fairseq.models.wav2vec.wav2vec2",
191
+ "fairseq.models.hubert.hubert"):
192
+ mod = sys.modules.get(modname)
193
+ if mod is not None and hasattr(mod, "pad_to_multiple"):
194
+ mod.pad_to_multiple = _safe_pad
195
+ _freeze_sinc(net)
196
+ # optional per-model export patch (dir-local `_trt_patch.py` with `patch(net)`)
197
+ try:
198
+ import importlib
199
+ importlib.import_module("_trt_patch").patch(net)
200
+ except ModuleNotFoundError:
201
+ pass
202
+ if DYNAMO_EXPORT:
203
+ _replace_global_avgpool(net)
204
+ return net
205
+
206
+
207
+ class _MeanPool(torch.nn.Module):
208
+ """Global average over `dims` (keepdim) — == AdaptiveAvgPool{1,2}d(1)."""
209
+
210
+ def __init__(self, dims):
211
+ super().__init__()
212
+ self.dims = dims
213
+
214
+ def forward(self, x):
215
+ return x.mean(dim=self.dims, keepdim=True)
216
+
217
+
218
+ def _replace_global_avgpool(net):
219
+ """Swap AdaptiveAvgPool1d/2d(output_size=1) for an explicit mean. The dynamo
220
+ exporter lowers the adaptive pool to as_strided/SequenceEmpty, which TensorRT
221
+ rejects; a plain mean lowers to ReduceMean. Identical for output_size==1."""
222
+ import torch.nn as nn
223
+ for full_name, mod in list(net.named_modules()):
224
+ is1d = isinstance(mod, nn.AdaptiveAvgPool1d) and mod.output_size in (1, (1,))
225
+ is2d = isinstance(mod, nn.AdaptiveAvgPool2d) and mod.output_size in (1, (1, 1))
226
+ if not (is1d or is2d):
227
+ continue
228
+ parent = net
229
+ *parents, attr = full_name.split(".")
230
+ for p in parents:
231
+ parent = getattr(parent, p)
232
+ setattr(parent, attr, _MeanPool((-1,) if is1d else (-2, -1)))
233
+ return net
234
+
235
+
236
+ def _freeze_sinc(net):
237
+ """Replace SincConv-style layers with an equivalent nn.Conv1d holding the
238
+ precomputed band-pass filters. At eval the filters are constant, but their
239
+ in-forward construction (torch.sin/cat/flip from learnable params) either
240
+ won't build in TensorRT or constant-folds to wrong values. Baking them into a
241
+ plain Conv1d removes the sinc math from the graph. No-op when no Sinc layer.
242
+ """
243
+ import torch.nn as nn
244
+ sincs = [(n, m) for n, m in net.named_modules() if "Sinc" in type(m).__name__]
245
+ if not sincs:
246
+ return net
247
+ dev = next(net.parameters()).device
248
+ for full_name, mod in sincs:
249
+ kernel = int(getattr(mod, "kernel_size", 0)) or 1
250
+ with torch.no_grad():
251
+ try:
252
+ mod(torch.zeros(1, 1, max(kernel * 4, 4096), device=dev))
253
+ except Exception: # noqa: BLE001 — filters are set before the conv call
254
+ pass
255
+ W = mod.filters.detach().clone() # [out, 1, kernel] (or [out, kernel])
256
+ if W.dim() == 2:
257
+ W = W.unsqueeze(1)
258
+ conv = nn.Conv1d(W.shape[1], W.shape[0], W.shape[2],
259
+ stride=int(getattr(mod, "stride", 1)),
260
+ padding=int(getattr(mod, "padding", 0)),
261
+ dilation=int(getattr(mod, "dilation", 1)),
262
+ bias=False).to(dev).eval()
263
+ conv.weight.data.copy_(W)
264
+ parent = net
265
+ *parents, attr = full_name.split(".")
266
+ for p in parents:
267
+ parent = getattr(parent, p)
268
+ setattr(parent, attr, conv)
269
+ return net
270
+
271
+
272
+ class _ExportNet(torch.nn.Module):
273
+ """forward(x[B,T]) -> logits[B,C] (single tensor) for ONNX/TRT."""
274
+
275
+ def __init__(self, net, L, i):
276
+ super().__init__()
277
+ self.net = net
278
+ self._extract = _extractor(L, i)
279
+
280
+ def forward(self, x):
281
+ return self._extract(self.net(x)) # Model takes (B,T)
282
+
283
+
284
+ # ----------------------------------------------------------------------------
285
+ # export + build
286
+ # ----------------------------------------------------------------------------
287
+ def export_onnx(model, T, L, i, onnx_path):
288
+ net = _prep_for_export(model.net)
289
+ wrap = _ExportNet(net, L, i).eval().to("cuda")
290
+ dummy = torch.zeros(2, T, device="cuda", dtype=torch.float32)
291
+ if DYNAMO_EXPORT:
292
+ # >2 GB models: TorchScript exporter's shape-inference overflows the 2 GB
293
+ # protobuf limit. The dynamo exporter writes weights as external data.
294
+ batch = torch.export.Dim("b", min=1, max=MAX_BATCH_CAP)
295
+ torch.onnx.export(
296
+ wrap, (dummy,), str(onnx_path), dynamo=True, external_data=True,
297
+ input_names=["wav"], output_names=["logits"],
298
+ dynamic_shapes={"x": {0: batch}},
299
+ )
300
+ else:
301
+ torch.onnx.export(
302
+ wrap, dummy, str(onnx_path), opset_version=OPSET,
303
+ input_names=["wav"], output_names=["logits"],
304
+ dynamic_axes={"wav": {0: "batch"}, "logits": {0: "batch"}},
305
+ do_constant_folding=True,
306
+ )
307
+ return onnx_path
308
+
309
+
310
+ def build_engine(onnx_path, T, precision, max_batch, opt_batch, engine_path, timing_cache):
311
+ import tensorrt as trt
312
+
313
+ sev = trt.Logger.VERBOSE if os.environ.get("SSB_TRT_VERBOSE") else trt.Logger.WARNING
314
+ logger = trt.Logger(sev)
315
+ builder = trt.Builder(logger)
316
+ network = builder.create_network(0)
317
+ parser = trt.OnnxParser(network, logger)
318
+ # parse_from_file resolves external-data sidecars (needed for >2 GB models);
319
+ # works for inline ONNX too.
320
+ if not parser.parse_from_file(str(onnx_path)):
321
+ errs = "; ".join(str(parser.get_error(k)) for k in range(parser.num_errors))
322
+ raise RuntimeError(f"onnx parse failed: {errs}")
323
+
324
+ cfg = builder.create_builder_config()
325
+ cfg.builder_optimization_level = 1 # minimum build time
326
+ if precision == "fp16":
327
+ cfg.set_flag(trt.BuilderFlag.FP16)
328
+ if NO_TF32:
329
+ # strict FP32: the RTX 5060 Ti TF32 engine drifted (J-SPAW Spearman 0.9969 vs PyTorch)
330
+ cfg.clear_flag(trt.BuilderFlag.TF32)
331
+
332
+ tc_bytes = Path(timing_cache).read_bytes() if Path(timing_cache).exists() else b""
333
+ tc = cfg.create_timing_cache(tc_bytes)
334
+ cfg.set_timing_cache(tc, ignore_mismatch=False)
335
+
336
+ profile = builder.create_optimization_profile()
337
+ profile.set_shape("wav", (1, T), (opt_batch, T), (max_batch, T))
338
+ cfg.add_optimization_profile(profile)
339
+
340
+ plan = builder.build_serialized_network(network, cfg)
341
+ if plan is None:
342
+ raise RuntimeError("engine build returned None")
343
+ Path(engine_path).write_bytes(bytes(plan))
344
+ Path(timing_cache).write_bytes(bytes(tc.serialize()))
345
+ return engine_path
346
+
347
+
348
+ # ----------------------------------------------------------------------------
349
+ # runtime
350
+ # ----------------------------------------------------------------------------
351
+ class _TRTCallable:
352
+ """Mimics net(xt): runs the engine on a [B,T] float32 CUDA tensor."""
353
+
354
+ def __init__(self, engine_path, n_classes, L, i):
355
+ import tensorrt as trt
356
+
357
+ self.n_classes = n_classes
358
+ self.rebuild = _rebuild(L, i)
359
+ logger = trt.Logger(trt.Logger.WARNING)
360
+ self.runtime = trt.Runtime(logger)
361
+ self.engine = self.runtime.deserialize_cuda_engine(Path(engine_path).read_bytes())
362
+ self.ctx = self.engine.create_execution_context()
363
+ if self.ctx is None:
364
+ raise RuntimeError(
365
+ "could not create execution context (likely OOM reserving max-profile "
366
+ "memory) — lower MAX_BATCH_CAP")
367
+ # resolve I/O tensor names
368
+ self.in_name, self.out_name = "wav", "logits"
369
+
370
+ def __call__(self, x, *a, **k):
371
+ x = x.to("cuda", torch.float32).reshape(x.shape[0], -1).contiguous() # (B,1,T)->(B,T)
372
+ B = x.shape[0]
373
+ self.ctx.set_input_shape(self.in_name, tuple(x.shape))
374
+ out = torch.empty((B, self.n_classes), device="cuda", dtype=torch.float32)
375
+ self.ctx.set_tensor_address(self.in_name, x.data_ptr())
376
+ self.ctx.set_tensor_address(self.out_name, out.data_ptr())
377
+ stream = torch.cuda.current_stream().cuda_stream
378
+ self.ctx.execute_async_v3(stream)
379
+ torch.cuda.current_stream().synchronize()
380
+ return self.rebuild(out)
381
+
382
+
383
+ # ----------------------------------------------------------------------------
384
+ # parity + sweep
385
+ # ----------------------------------------------------------------------------
386
+ def _chunked_scores(model, audios, srs, chunk):
387
+ out = []
388
+ for k in range(0, len(audios), chunk):
389
+ out.extend(model.score_batch(audios[k:k + chunk], srs[k:k + chunk]))
390
+ return np.asarray(out, dtype=np.float64)
391
+
392
+
393
+ def _spearman(a, b):
394
+ if len(a) < 2:
395
+ return 1.0
396
+ ra = np.argsort(np.argsort(a)).astype(np.float64)
397
+ rb = np.argsort(np.argsort(b)).astype(np.float64)
398
+ return float(np.corrcoef(ra, rb)[0, 1])
399
+
400
+
401
+ def parity(model, trt_call, n=64, chunk=PARITY_CHUNK):
402
+ data = real_audio(n)
403
+ audios = [a for a, _ in data]
404
+ srs = [s for _, s in data]
405
+ torch_net = model.net
406
+ py = _chunked_scores(model, audios, srs, chunk)
407
+ model.net = trt_call
408
+ tr = _chunked_scores(model, audios, srs, chunk)
409
+ model.net = torch_net
410
+ mad = float(np.max(np.abs(py - tr)))
411
+ pear = float(np.corrcoef(py, tr)[0, 1]) if len(py) > 1 else 1.0
412
+ spear = _spearman(py, tr)
413
+ return {"n": len(py), "max_abs_score_diff": mad, "pearson": pear,
414
+ "spearman": spear}
415
+
416
+
417
+ def sweep(model, trt_call,
418
+ batches=(1, 2, 4, 8, 16, 24, 32, 48, 64, 96, 128), iters=20):
419
+ a, sr = real_audio(1)[0]
420
+ model.net = trt_call
421
+ res = {}
422
+ for B in batches:
423
+ if B > MAX_BATCH_CAP:
424
+ break
425
+ ab, sb = [a] * B, [sr] * B
426
+ try:
427
+ for _ in range(3):
428
+ model.score_batch(ab, sb) # warmup
429
+ torch.cuda.synchronize()
430
+ t0 = time.time()
431
+ for _ in range(iters):
432
+ model.score_batch(ab, sb)
433
+ torch.cuda.synchronize()
434
+ dt = time.time() - t0
435
+ res[B] = B * iters / dt
436
+ except RuntimeError as e:
437
+ if "out of memory" in str(e).lower():
438
+ torch.cuda.empty_cache()
439
+ break
440
+ raise
441
+ best = max(res, key=res.get)
442
+ return best, res
443
+
444
+
445
+ # ----------------------------------------------------------------------------
446
+ # drop-in inference class
447
+ # ----------------------------------------------------------------------------
448
+ class DPDAGATCMTRT(_OrigClass):
449
+ """Drop-in: original preprocessing/score_batch; net replaced by the TRT engine."""
450
+
451
+ def load(self):
452
+ self.device = "cuda"
453
+ side = json.loads((HERE / f"trt_{SLUG}.json").read_text())[gpu_slug()]
454
+ eng = HERE / side["engine"]
455
+ self.net = _TRTCallable(str(eng), side["n_classes"], side["L"], side["i"])
456
+ self.batch_size = side["best_batch"]
457
+
458
+
459
+ # ----------------------------------------------------------------------------
460
+ # CLI
461
+ # ----------------------------------------------------------------------------
462
+ def _do_export():
463
+ gpu = gpu_slug()
464
+ side_path = HERE / f"trt_{SLUG}.json"
465
+ tc = HERE / f".trt_timing_{gpu}.cache"
466
+ m = load_model()
467
+ T, L, i, n_classes = analyze(m)
468
+ print(f"[analyze] T={T} n_classes={n_classes} L={L} i={i}")
469
+ onnx_path = HERE / f"{SLUG}.onnx"
470
+ export_onnx(m, T, L, i, onnx_path)
471
+ print(f"[onnx] wrote {onnx_path.name}")
472
+
473
+ # PyTorch reference scores while the model is on GPU, then free it so the
474
+ # engine build + TRT inference never co-reside with the model (giant >2 GB
475
+ # models would otherwise OOM the 16 GB card).
476
+ pdata = real_audio(64)
477
+ paud, psr = [a for a, _ in pdata], [s for _, s in pdata]
478
+ py = _chunked_scores(m, paud, psr, PARITY_CHUNK)
479
+ m.net.to("cpu")
480
+ torch.cuda.empty_cache()
481
+
482
+ opt_batch = min(32, MAX_BATCH_CAP)
483
+ if FORCE_FP16:
484
+ precisions = ["fp16"]
485
+ elif FORCE_FP32:
486
+ precisions = ["fp32"]
487
+ else:
488
+ precisions = ["fp16", "fp32"]
489
+ chosen = None
490
+ last_err = None
491
+ for prec in precisions:
492
+ eng = HERE / f"engine_{gpu}_{prec}_b1-{opt_batch}-{MAX_BATCH_CAP}.plan"
493
+ try:
494
+ t0 = time.time()
495
+ build_engine(str(onnx_path), T, prec, MAX_BATCH_CAP, opt_batch, str(eng), str(tc))
496
+ bt = time.time() - t0
497
+ trt_call = _TRTCallable(str(eng), n_classes, L, i)
498
+ m.net = trt_call
499
+ tr = _chunked_scores(m, paud, psr, PARITY_CHUNK)
500
+ p = {"n": len(py),
501
+ "max_abs_score_diff": float(np.max(np.abs(py - tr))),
502
+ "pearson": float(np.corrcoef(py, tr)[0, 1]) if len(py) > 1 else 1.0,
503
+ "spearman": _spearman(py, tr)}
504
+ except Exception as e: # noqa: BLE001 — try the next precision (e.g. FP16 layer not buildable)
505
+ last_err = e
506
+ print(f"[{prec}] FAILED: {type(e).__name__}: {e}")
507
+ continue
508
+ print(f"[{prec}] build={bt:.1f}s parity={p}")
509
+ chosen = (prec, eng, p, trt_call)
510
+ if prec == "fp16" and p["spearman"] >= PARITY_SPEARMAN_TOL:
511
+ break
512
+
513
+ if chosen is None:
514
+ raise RuntimeError(f"all precisions failed to build; last error: {last_err}")
515
+ prec, eng, p, trt_call = chosen
516
+ if p["spearman"] < PARITY_FLOOR:
517
+ raise RuntimeError(
518
+ f"parity too low (spearman={p['spearman']:.4f} < {PARITY_FLOOR}): "
519
+ f"engine output does not match PyTorch — not accepting")
520
+ m.net = trt_call
521
+ best, table = sweep(m, trt_call)
522
+ side = json.loads(side_path.read_text()) if side_path.exists() else {}
523
+ side[gpu] = {
524
+ "precision": prec, "engine": eng.name, "window_samples": T,
525
+ "n_classes": n_classes, "L": L, "i": i, "best_batch": best,
526
+ "throughput_utt_s": {str(k): round(v, 2) for k, v in table.items()},
527
+ "parity": p, "trt_version": __import__("tensorrt").__version__,
528
+ }
529
+ side_path.write_text(json.dumps(side, indent=2, default=str))
530
+ print(f"[done] {SLUG}: prec={prec} best_batch={best} "
531
+ f"utt/s={table[best]:.1f} parity_mad={p['max_abs_score_diff']:.2e}")
532
+
533
+
534
+ def main():
535
+ ap = argparse.ArgumentParser()
536
+ ap.add_argument("cmd", choices=["export", "sweep", "parity", "score"])
537
+ ap.add_argument("audio", nargs="?")
538
+ args = ap.parse_args()
539
+ gpu = gpu_slug()
540
+ side_path = HERE / f"trt_{SLUG}.json"
541
+
542
+ if args.cmd == "export":
543
+ _do_export()
544
+ elif args.cmd in ("sweep", "parity"):
545
+ m = load_model()
546
+ side = json.loads(side_path.read_text())[gpu]
547
+ eng = HERE / side["engine"]
548
+ trt_call = _TRTCallable(str(eng), side["n_classes"], side["L"], side["i"])
549
+ if args.cmd == "parity":
550
+ print(parity(m, trt_call))
551
+ else:
552
+ best, table = sweep(m, trt_call)
553
+ full = json.loads(side_path.read_text())
554
+ full[gpu]["best_batch"] = best
555
+ full[gpu]["throughput_utt_s"] = {str(k): round(v, 2) for k, v in table.items()}
556
+ side_path.write_text(json.dumps(full, indent=2, default=str))
557
+ print(f"best_batch={best} utt/s={table[best]:.1f}")
558
+ elif args.cmd == "score":
559
+ import soundfile as sf
560
+ a, sr = sf.read(args.audio, dtype="float32")
561
+ if a.ndim > 1:
562
+ a = a.mean(1)
563
+ m = DPDAGATCMTRT()
564
+ m.load()
565
+ print(m.score_batch([a.astype(np.float32)], [sr])[0])
566
+
567
+
568
+ if __name__ == "__main__":
569
+ main()