Skip to content

Commit 6a20463

Browse files
authored
Refactor ONNX export script for nemotron-3.5-asr-streaming-0.6b (k2-fsa#3734)
1 parent 4392c45 commit 6a20463

2 files changed

Lines changed: 73 additions & 179 deletions

File tree

.github/workflows/export-nemotron-3.5-asr-streaming-0.6b.yaml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,18 @@ jobs:
4242
pip install kaldi-native-fbank
4343
pip install soundfile librosa
4444
45+
- name: Install sherpa-onnx for testing
46+
shell: bash
47+
run: |
48+
pip install sherpa-onnx-bin
49+
which sherpa-onnx
50+
51+
sherpa-onnx-version
52+
4553
- name: Run
4654
shell: bash
55+
env:
56+
HF_TOKEN: ${{ secrets.HF_TOKEN }}
4757
run: |
4858
cd scripts/nemo/nemotron-3.5-asr-streaming-0.6b
4959
@@ -56,6 +66,36 @@ jobs:
5666
5767
echo "---"
5868
69+
- name: Test onnx models
70+
shell: bash
71+
run: |
72+
pip install sherpa-onnx-bin
73+
which sherpa-onnx
74+
75+
wget https://dldata-public.s3.us-east-2.amazonaws.com/2086-149220-0033.wav
76+
77+
src=scripts/nemo/nemotron-3.5-asr-streaming-0.6b
78+
79+
for chunk in 80 160 560 1120; do
80+
echo "test $chunk int8"
81+
82+
sherpa-onnx \
83+
--encoder=$src/$chunk/encoder.int8.onnx \
84+
--decoder=$src/$chunk/decoder.int8.onnx \
85+
--joiner=$src/$chunk/joiner.int8.onnx \
86+
--tokens=$src/tokens.txt \
87+
./2086-149220-0033.wav
88+
89+
echo "test $chunk float32"
90+
91+
sherpa-onnx \
92+
--encoder=$src/$chunk/encoder.onnx \
93+
--decoder=$src/$chunk/decoder.onnx \
94+
--joiner=$src/$chunk/joiner.onnx \
95+
--tokens=$src/tokens.txt \
96+
./2086-149220-0033.wav
97+
done
98+
5999
- name: Collect results
60100
shell: bash
61101
run: |

scripts/nemo/nemotron-3.5-asr-streaming-0.6b/export_onnx.py

Lines changed: 33 additions & 179 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import json
55
import os
66
from pathlib import Path
7-
from typing import Any, Dict, Optional
7+
from typing import Any, Dict
88

99
import nemo.collections.asr as nemo_asr
1010
import onnx
@@ -37,31 +37,6 @@
3737
]
3838

3939

40-
def add_meta_data(filename: str, meta_data: Dict[str, str]):
41-
"""Add meta data to an ONNX model. It is changed in-place."""
42-
model = onnx.load(filename)
43-
44-
while len(model.metadata_props):
45-
model.metadata_props.pop()
46-
47-
for key, value in meta_data.items():
48-
meta = model.metadata_props.add()
49-
meta.key = key
50-
meta.value = str(value)
51-
52-
external_filename = filename.split(".onnx")[0]
53-
# onnx.save refuses to overwrite an existing external-data file; the
54-
# prompted-encoder export already wrote one, so remove it first.
55-
Path(external_filename + ".data").unlink(missing_ok=True)
56-
onnx.save(
57-
model,
58-
filename,
59-
save_as_external_data=True,
60-
all_tensors_to_one_file=True,
61-
location=external_filename + ".data",
62-
)
63-
64-
6540
def _to_plain_container(obj: Any) -> Any:
6641
try:
6742
from omegaconf import DictConfig, ListConfig, OmegaConf
@@ -82,132 +57,36 @@ def _normalize_prompt_dictionary(obj: Any) -> Dict[str, int]:
8257
return {str(k): int(v) for k, v in obj.items()}
8358

8459

85-
def _get_config_value(obj: Any, key: str) -> Any:
86-
if obj is None:
87-
return None
88-
89-
try:
90-
return getattr(obj, key)
91-
except (AttributeError, KeyError):
92-
pass
93-
94-
obj = _to_plain_container(obj)
95-
if isinstance(obj, dict):
96-
return obj.get(key)
97-
98-
return None
99-
100-
101-
def get_prompt_dictionary(asr_model) -> Dict[str, int]:
102-
"""Return the model's language prompt dictionary from NeMo artifacts."""
103-
cfg = getattr(asr_model, "cfg", None)
104-
model_defaults = _get_config_value(cfg, "model_defaults")
105-
if model_defaults is None:
106-
raise RuntimeError("Could not find cfg.model_defaults in the NeMo model")
107-
108-
prompt_dictionary = _get_config_value(model_defaults, "prompt_dictionary")
109-
if prompt_dictionary is None:
110-
raise RuntimeError(
111-
"Could not find cfg.model_defaults.prompt_dictionary in the NeMo model"
112-
)
113-
114-
try:
115-
ans = _normalize_prompt_dictionary(prompt_dictionary)
116-
except (TypeError, ValueError) as e:
117-
raise RuntimeError(
118-
"cfg.model_defaults.prompt_dictionary must map language strings "
119-
"to integer prompt ids"
120-
) from e
121-
122-
num_prompts = int(asr_model.num_prompts)
123-
for language, prompt_id in ans.items():
124-
if not 0 <= prompt_id < num_prompts:
125-
raise ValueError(
126-
"cfg.model_defaults.prompt_dictionary has out-of-range "
127-
f"prompt id for '{language}': {prompt_id}; expected "
128-
f"0 <= id < {num_prompts}"
129-
)
130-
131-
auto_prompt_id = ans.get("auto")
132-
if auto_prompt_id != 101:
133-
raise ValueError(f"Expected auto prompt id 101, got {auto_prompt_id}")
134-
135-
# The dictionary may use locale-style keys such as en-US or ja-JP; the
136-
# runtime derives base-code aliases, so accept either form here.
137-
for language in ["en", "ja"]:
138-
if not any(k == language or k.startswith(f"{language}-") for k in ans):
139-
raise RuntimeError(
140-
"cfg.model_defaults.prompt_dictionary is missing " f"'{language}'"
141-
)
142-
143-
return ans
144-
145-
146-
def _find_sentencepiece_processor(obj: Any, max_depth: int = 5) -> Optional[Any]:
147-
seen = set()
148-
149-
def is_sentencepiece_processor(value: Any) -> bool:
150-
return callable(getattr(value, "get_piece_size", None)) and callable(
151-
getattr(value, "id_to_piece", None)
152-
)
153-
154-
def visit(value: Any, depth: int) -> Optional[Any]:
155-
if value is None or depth > max_depth:
156-
return None
157-
158-
if is_sentencepiece_processor(value):
159-
return value
160-
161-
obj_id = id(value)
162-
if obj_id in seen:
163-
return None
164-
seen.add(obj_id)
165-
166-
for name in ["tokenizer", "sp_model", "model", "processor"]:
167-
if hasattr(value, name):
168-
found = visit(getattr(value, name), depth + 1)
169-
if found is not None:
170-
return found
60+
def add_meta_data(filename: str, meta_data: Dict[str, str]):
61+
"""Add meta data to an ONNX model. It is changed in-place."""
62+
model = onnx.load(filename)
17163

172-
if isinstance(value, dict):
173-
for v in value.values():
174-
found = visit(v, depth + 1)
175-
if found is not None:
176-
return found
64+
while len(model.metadata_props):
65+
model.metadata_props.pop()
17766

178-
return None
67+
for key, value in meta_data.items():
68+
meta = model.metadata_props.add()
69+
meta.key = key
70+
meta.value = str(value)
17971

180-
return visit(obj, 0)
72+
external_filename = filename.split(".onnx")[0]
73+
# onnx.save refuses to overwrite an existing external-data file; the
74+
# prompted-encoder export already wrote one, so remove it first.
75+
Path(external_filename + ".data").unlink(missing_ok=True)
76+
onnx.save(
77+
model,
78+
filename,
79+
save_as_external_data=True,
80+
all_tensors_to_one_file=True,
81+
location=external_filename + ".data",
82+
)
18183

18284

18385
def save_tokens(asr_model, filename: str = "tokens.txt") -> int:
184-
sp = _find_sentencepiece_processor(getattr(asr_model, "tokenizer", None))
185-
if sp is None:
186-
raise RuntimeError("Could not find the SentencePiece tokenizer in the model")
187-
188-
vocab_size = sp.get_piece_size()
18986
with open(filename, "w", encoding="utf-8") as f:
190-
for i in range(vocab_size):
191-
f.write(f"{sp.id_to_piece(i)} {i}\n")
192-
f.write(f"<blk> {vocab_size}\n")
193-
194-
print(f"Saved {filename}")
195-
return vocab_size
196-
197-
198-
def assert_forward_for_export_signature(encoder):
199-
if not hasattr(encoder, "forward_for_export"):
200-
raise RuntimeError("Expected encoder.forward_for_export for ONNX export")
201-
202-
signature = inspect.signature(encoder.forward_for_export)
203-
missing = [
204-
name for name in FORWARD_FOR_EXPORT_ARGS if name not in signature.parameters
205-
]
206-
if missing:
207-
raise RuntimeError(
208-
"encoder.forward_for_export is missing expected argument(s): "
209-
f"{missing}. Signature: {signature}"
210-
)
87+
for i, s in enumerate(asr_model.joint.vocabulary):
88+
f.write(f"{s} {i}\n")
89+
f.write(f"<blk> {i+1}\n")
21190

21291

21392
class PromptedStreamingEncoder(torch.nn.Module):
@@ -221,7 +100,6 @@ def __init__(self, asr_model):
221100
)
222101

223102
self.encoder = asr_model.encoder
224-
assert_forward_for_export_signature(self.encoder)
225103

226104
self.prompt_kernel = asr_model.prompt_kernel
227105
self.num_prompts = int(asr_model.num_prompts)
@@ -283,24 +161,6 @@ def remove_export_scratch_files():
283161
p.unlink()
284162

285163

286-
def assert_encoder_graph(filename: str):
287-
model = onnx.load(filename, load_external_data=False)
288-
289-
input_names = [i.name for i in model.graph.input]
290-
if input_names != ENCODER_INPUT_NAMES:
291-
raise RuntimeError(
292-
f"{filename}: expected encoder inputs {ENCODER_INPUT_NAMES}, "
293-
f"got {input_names}"
294-
)
295-
296-
output_names = [o.name for o in model.graph.output]
297-
if output_names != ENCODER_OUTPUT_NAMES:
298-
raise RuntimeError(
299-
f"{filename}: expected encoder outputs {ENCODER_OUTPUT_NAMES}, "
300-
f"got {output_names}"
301-
)
302-
303-
304164
def _module_device_and_dtype(module):
305165
try:
306166
p = next(module.parameters())
@@ -322,7 +182,9 @@ def export_prompted_encoder(
322182
):
323183
device, dtype = _module_device_and_dtype(asr_model.encoder)
324184

325-
audio_signal = torch.zeros(1, 128, window_size, dtype=dtype, device=device)
185+
feat_dim = asr_model.cfg.preprocessor.features
186+
187+
audio_signal = torch.zeros(1, feat_dim, window_size, dtype=dtype, device=device)
326188
length = torch.full((1,), window_size, dtype=torch.int64, device=device)
327189
cache_last_channel = torch.zeros(
328190
1,
@@ -365,7 +227,7 @@ def export_prompted_encoder(
365227
"encoder.export.onnx",
366228
input_names=ENCODER_INPUT_NAMES,
367229
output_names=ENCODER_OUTPUT_NAMES,
368-
opset_version=17,
230+
opset_version=13,
369231
dynamic_axes={
370232
"audio_signal": {0: "batch", 2: "time"},
371233
"length": {0: "batch"},
@@ -391,7 +253,6 @@ def export_prompted_encoder(
391253
location="encoder.data",
392254
size_threshold=0,
393255
)
394-
assert_encoder_graph("encoder.onnx")
395256
for p in Path(".").glob("encoder.export.onnx*"):
396257
p.unlink()
397258

@@ -402,17 +263,10 @@ def main():
402263

403264
asr_model = nemo_asr.models.ASRModel.from_pretrained(model_name=model_name)
404265

405-
vocab_size = save_tokens(asr_model)
406-
if vocab_size != asr_model.decoder.vocab_size:
407-
raise ValueError(
408-
f"SentencePiece vocab size {vocab_size} != decoder vocab size "
409-
f"{asr_model.decoder.vocab_size}"
410-
)
266+
save_tokens(asr_model)
411267

412-
prompt_dictionary = get_prompt_dictionary(asr_model)
268+
prompt_dictionary = asr_model.cfg.model_defaults.prompt_dictionary
413269
auto_prompt_id = prompt_dictionary["auto"]
414-
if auto_prompt_id != 101:
415-
raise ValueError(f"Expected auto prompt id 101, got {auto_prompt_id}")
416270

417271
asr_model.eval()
418272

@@ -508,20 +362,20 @@ def main():
508362
"model_author": "NeMo",
509363
"url": f"https://huggingface.co/{model_name}",
510364
"comment": "Only the transducer branch is exported",
511-
"prompt_dictionary": json.dumps(prompt_dictionary, sort_keys=True),
365+
"prompt_dictionary": json.dumps(
366+
_normalize_prompt_dictionary(prompt_dictionary), sort_keys=True
367+
),
512368
"auto_prompt_id": auto_prompt_id,
513369
}
514370
print("meta_data", meta_data)
515371
add_meta_data("encoder.onnx", meta_data)
516-
assert_encoder_graph("encoder.onnx")
517372

518373
for m in ["encoder", "decoder", "joiner"]:
519374
quantize_dynamic(
520375
model_input=f"{m}.onnx",
521376
model_output=f"{m}.int8.onnx",
522377
weight_type=QuantType.QUInt8,
523378
)
524-
assert_encoder_graph("encoder.int8.onnx")
525379

526380
Path(str(ms)).mkdir(exist_ok=True)
527381
for suffix in ["onnx", "data"]:

0 commit comments

Comments
 (0)