ajh-code commited on
Commit
0584718
·
verified ·
1 Parent(s): fa21d25

Add files using upload-large-folder tool

Browse files
Files changed (32) hide show
  1. .gitattributes +1 -34
  2. .gitignore +6 -0
  3. LICENSE +23 -0
  4. README.md +288 -0
  5. SHA256SUMS +31 -0
  6. THIRD_PARTY_NOTICES.md +39 -0
  7. benchmark_results.json +142 -0
  8. example.py +89 -0
  9. nvfp4_config.json +0 -0
  10. runtime/chatterbox-flash/.gitignore +30 -0
  11. runtime/chatterbox-flash/LICENSE +21 -0
  12. runtime/chatterbox-flash/README.md +11 -0
  13. runtime/chatterbox-flash/chatterbox_flash/__init__.py +36 -0
  14. runtime/chatterbox-flash/chatterbox_flash/attention_flashinfer.py +20 -0
  15. runtime/chatterbox-flash/chatterbox_flash/calibration.py +82 -0
  16. runtime/chatterbox-flash/chatterbox_flash/cfg_guidance.py +57 -0
  17. runtime/chatterbox-flash/chatterbox_flash/engines/__init__.py +168 -0
  18. runtime/chatterbox-flash/chatterbox_flash/engines/base.py +141 -0
  19. runtime/chatterbox-flash/chatterbox_flash/engines/flashinfer.py +1656 -0
  20. runtime/chatterbox-flash/chatterbox_flash/engines/mlx.py +621 -0
  21. runtime/chatterbox-flash/chatterbox_flash/engines/torch_sdpa.py +372 -0
  22. runtime/chatterbox-flash/chatterbox_flash/model.py +936 -0
  23. runtime/chatterbox-flash/chatterbox_flash/nvfp4.py +709 -0
  24. runtime/chatterbox-flash/chatterbox_flash/text_norm/__init__.py +15 -0
  25. runtime/chatterbox-flash/chatterbox_flash/text_norm/cleaners.py +129 -0
  26. runtime/chatterbox-flash/chatterbox_flash/text_norm/number_norm.py +366 -0
  27. runtime/chatterbox-flash/chatterbox_flash/text_norm/phone_norm.py +107 -0
  28. runtime/chatterbox-flash/chatterbox_flash/text_norm/time_norm.py +49 -0
  29. runtime/chatterbox-flash/chatterbox_flash/tts.py +398 -0
  30. runtime/chatterbox-flash/pyproject.toml +137 -0
  31. runtime/chatterbox-flash/uv.lock +0 -0
  32. t3_nvfp4.safetensors +3 -0
.gitattributes CHANGED
@@ -1,35 +1,2 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
  *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  *.safetensors filter=lfs diff=lfs merge=lfs -text
2
+
 
 
 
 
 
 
 
 
 
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ .cache/
2
+ .venv/
3
+ __pycache__/
4
+ *.py[cod]
5
+ output.wav
6
+
LICENSE ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Resemble AI
4
+ Copyright (c) 2026 Chatterbox-Flash NVFP4 contributors
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
23
+
README.md ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: mit
3
+ language:
4
+ - en
5
+ library_name: chatterbox-flash
6
+ pipeline_tag: text-to-speech
7
+ base_model: ResembleAI/chatterbox-flash
8
+ base_model_relation: quantized
9
+ inference: false
10
+ tags:
11
+ - text-to-speech
12
+ - zero-shot-tts
13
+ - voice-cloning
14
+ - chatterbox-flash
15
+ - nvfp4
16
+ - w4a16
17
+ - quantized
18
+ - nvidia
19
+ - blackwell
20
+ - flashinfer
21
+ - safetensors
22
+ ---
23
+
24
+ # Chatterbox-Flash T3 W4A16 NVFP4
25
+
26
+ This is an unofficial community NVFP4 derivative of
27
+ [ResembleAI/chatterbox-flash](https://huggingface.co/ResembleAI/chatterbox-flash).
28
+ It is not affiliated with or endorsed by Resemble AI or NVIDIA.
29
+
30
+ The package replaces all 120 Chatterbox-Flash T3 transformer projection
31
+ operators with source-free NVFP4 weights. Weights use native FP4 (E2M1) with
32
+ E4M3 block scales; activations remain BF16, so the precise description is
33
+ **T3 all-projection W4A16 NVFP4**. Embeddings, norms, conditioning layers,
34
+ speech head, S3Gen, and the voice encoder remain at their upstream precision.
35
+ This is not a whole-pipeline 4-bit model.
36
+
37
+ The canonical artifact does not contain the original BF16 T3 projection
38
+ weights. Its custom loader constructs those parameters on the meta device,
39
+ prepares the canonical FP4 codes/scales directly for FlashInfer, and never
40
+ calls `nvfp4_quantize` at load time. Engine rebuilds reuse the model-attached
41
+ projection provider.
42
+
43
+ ## What this release actually improves
44
+
45
+ The primary benefit is **much lower storage and VRAM with a modest speed gain**,
46
+ not a claim that NVFP4 makes the complete TTS pipeline dramatically faster.
47
+ S3Gen and several small T3 operations remain high precision, and some
48
+ irregular prefix projections are slower in FP4 than BF16.
49
+
50
+ On the tested RTX 5060 Ti, the recommended D16 quality profile delivered:
51
+
52
+ - **83.97% smaller T3 checkpoint**: 341 MB instead of 2.13 GB.
53
+ - **55.88% less complete model-asset storage/download** after adding the
54
+ unchanged S3Gen, voice encoder, and tokenizer: 1.412 GB instead of 3.200 GB.
55
+ - **33.2% lower allocated VRAM immediately after full pipeline load**:
56
+ 1.444 GB instead of 2.162 GB.
57
+ - **18.82% lower maximum allocated VRAM in the natural eight-text suite**:
58
+ 5.215 GB instead of 6.424 GB. Fixed-work peak allocation was 34.09% lower.
59
+ - **1.0811x aggregate-RTF speedup** (7.50% lower duration-normalized latency)
60
+ and **1.1131x valid-token T3 throughput**. The fixed-work T3 speedup was
61
+ 1.1260x; fixed whole-pipeline speedup was 1.0631x.
62
+
63
+ The D24 fast profile reaches 25.13% lower RTF than the D16 BF16 baseline, but
64
+ that is a combined block-size-plus-NVFP4 result. Against matched D24 BF16,
65
+ NVFP4 alone provides only a 1.0309x RTF speedup. This distinction matters when
66
+ deciding whether the model is useful for your deployment.
67
+
68
+ ## How quality was evaluated
69
+
70
+ A Gemma Audio evaluator listened to eight English test utterances generated
71
+ from one zero-shot reference voice. It transcribed each output, scored
72
+ intelligibility, pronunciation, voice similarity, naturalness, and artifacts,
73
+ and flagged hard failures. BF16 and NVFP4 were also compared blindly in both
74
+ A/B orders to expose position bias.
75
+
76
+ - D16 BF16/NVFP4 means were **94.125/94.625**. Both produced exact transcripts
77
+ for 8/8 texts with zero hard failures. After reversing A/B order, the
78
+ per-case result resolved to seven ties and one consistent BF16 preference.
79
+ - D24 BF16/NVFP4 means were **95.0/93.125**. Both produced exact transcripts
80
+ for 8/8 texts with zero hard failures. Order control resolved to six ties,
81
+ one BF16 preference, and one NVFP4 preference.
82
+
83
+ This is encouraging automated evidence, not human MOS, a standard Seed-TTS
84
+ benchmark, proof of perceptual identity, or a multi-speaker evaluation. The
85
+ audio was intentionally not published because consent for public
86
+ redistribution of the reference voice was not established.
87
+
88
+ ## Hardware and software
89
+
90
+ The accepted release configuration is intentionally narrow:
91
+
92
+ - Linux x86-64 and Python 3.12
93
+ - NVIDIA RTX 50-series SM120/SM121 (tested on RTX 5060 Ti)
94
+ - PyTorch 2.7.1+cu128 and CUDA 12.8 wheels
95
+ - FlashInfer 0.6.14 with `nvidia-cutlass-dsl` 4.5.2
96
+ - BF16 activations, FlashInfer CUDA graphs, user batch size 1
97
+
98
+ There is no CPU, MLX, Torch-SDPA, older-GPU, or non-NVFP4 fallback because the
99
+ BF16 projection sources are deliberately absent. RTX 5080 and SM121 are
100
+ allowed by the loader but have not yet been benchmarked. This is custom code,
101
+ not a Transformers `AutoModel` checkpoint or a standard Hub quantization
102
+ format, so the hosted inference widget is disabled.
103
+
104
+ `nvidia-cutlass-dsl` is installed under NVIDIA's separate software license.
105
+ Review [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) before installation.
106
+
107
+ ## Install
108
+
109
+ Clone the model repository, then use the included lock. Plain `pip install`
110
+ does not honor the upstream Torch override or the official cu128 wheel source.
111
+
112
+ ```bash
113
+ git clone https://huggingface.co/ajh-code/chatterbox-flash-t3-w4a16-nvfp4
114
+ cd chatterbox-flash-t3-w4a16-nvfp4
115
+
116
+ uv sync \
117
+ --project runtime/chatterbox-flash \
118
+ --locked \
119
+ --extra flashinfer \
120
+ --python 3.12
121
+ ```
122
+
123
+ The lock resolves the exact validated critical stack. A dry verification is:
124
+
125
+ ```bash
126
+ runtime/chatterbox-flash/.venv/bin/python - <<'PY'
127
+ import importlib.metadata as md
128
+ import flashinfer
129
+ import torch
130
+
131
+ assert torch.__version__ == "2.7.1+cu128"
132
+ assert torch.version.cuda == "12.8"
133
+ assert md.version("flashinfer-python") == "0.6.14"
134
+ assert md.version("nvidia-cutlass-dsl") == "4.5.2"
135
+ assert hasattr(flashinfer, "prepare_bf16_fp4_weights")
136
+ assert hasattr(flashinfer, "mm_bf16_fp4")
137
+ assert torch.cuda.get_device_capability() in {(12, 0), (12, 1)}
138
+ print(torch.cuda.get_device_name(), "is ready")
139
+ PY
140
+ ```
141
+
142
+ ## Zero-shot voice cloning
143
+
144
+ Use only reference audio that you have permission to clone.
145
+
146
+ ```bash
147
+ runtime/chatterbox-flash/.venv/bin/python example.py \
148
+ --prompt-wav your-reference.wav \
149
+ --text "This is Chatterbox Flash running native NVFP4 projections." \
150
+ --output output.wav \
151
+ --block-size 16
152
+ ```
153
+
154
+ The example downloads only the unchanged S3Gen, voice-encoder, and tokenizer
155
+ assets from the immutable base revision
156
+ `4385507288b8197e6dab8b4e6b1603328d549d9d`. It verifies their sizes and
157
+ SHA-256 fingerprints from `nvfp4_config.json`. It never downloads the original
158
+ 2.13 GB `t3_flash.safetensors`.
159
+
160
+ The first decode for a new GPU/exact block shape invokes FlashInfer setup and
161
+ tuning. On the RTX 5060 Ti, a fresh process with the selected D16 tactic already
162
+ cached took 10.57 seconds for the first 1.28-second utterance versus 0.154
163
+ seconds for the same-process warm repeat. A completely missing tactic has taken
164
+ roughly 25 seconds in development. Autotune results are GPU-specific, so this
165
+ repository does not ship a universal cache.
166
+
167
+ ## Profiles
168
+
169
+ `D=16` is the quality-default profile. `D=24` is an explicitly optional fast
170
+ profile: it is materially faster overall but had a slightly lower automated
171
+ mean score and less consistent pairwise preference. `D=32` was tested and
172
+ rejected after token-cap failures and an OOM in the natural suite; its cached
173
+ prior remains serialized for research provenance, but the release loader
174
+ refuses it.
175
+
176
+ | Profile | Intended use | NVFP4 aggregate RTF | NVFP4 T3 tok/s | Automated mean |
177
+ |---|---|---:|---:|---:|
178
+ | D16 | Quality default | 0.08725 | 365.4 | 94.625 |
179
+ | D24 | Optional fast | 0.07062 | 483.3 | 93.125 |
180
+
181
+ ## RTX 5060 Ti results
182
+
183
+ The natural test is an eight-text, one-reference suite. Pipeline and T3 times
184
+ are sums of per-case medians; aggregate RTF divides summed pipeline time by
185
+ summed generated-audio duration. Memory is the maximum per-case median
186
+ PyTorch CUDA peak, not total `nvidia-smi` process memory.
187
+
188
+ | Profile | Comparison | RTF speedup | T3 throughput | Peak allocated reduction |
189
+ |---|---|---:|---:|---:|
190
+ | D16 | W4A16 vs matched BF16 | 1.0811x | 1.1131x | 18.82% / 1.209 GB |
191
+ | D24 | W4A16 vs matched BF16 | 1.0309x | 1.0450x | 21.57% / 1.410 GB |
192
+
193
+ A fixed capped-work control removes sampled-length attribution. D16 T3 is
194
+ `1.1260x` faster and D24 T3 is `1.0656x` faster; each reduces fixed-work peak
195
+ allocated memory by about 34%. D16 fixed whole-pipeline speedup is `1.0631x`.
196
+
197
+ Do not quote the larger raw natural-suite wall-time ratios as pure NVFP4
198
+ speedups: the BF16 and W4A16 models sampled different token counts. The
199
+ D24-versus-D16 speed difference is primarily the larger block size, not FP4
200
+ alone. Complete numeric records and caveats are in
201
+ [benchmark_results.json](benchmark_results.json).
202
+
203
+ Automated quality was close but is not proof of perceptual identity:
204
+
205
+ - D16 BF16/NVFP4 means were 94.125/94.625; both transcribed 8/8 exactly with
206
+ zero hard failures. Order-controlled pairwise scoring resolved to seven ties
207
+ and one BF16 win.
208
+ - D24 BF16/NVFP4 means were 95.0/93.125; both transcribed 8/8 exactly with zero
209
+ hard failures. Order control resolved to six ties, one BF16 win, and one
210
+ NVFP4 win.
211
+
212
+ This was not human MOS, a standard Seed-TTS evaluation, or a multi-speaker
213
+ release study.
214
+
215
+ ## Artifact and load behavior
216
+
217
+ - `t3_nvfp4.safetensors`: 341,439,040 bytes, 447 tensors, SHA-256
218
+ `c5ea2d1d1441f657b383d8dad54bbae3778447d675822fb15918cb99b3587f1e`
219
+ - 360 canonical NVFP4 tensors cover 30 layers × 4 fused projection kinds ×
220
+ FP4 codes/E4M3 scales/FP32 alpha.
221
+ - 82 non-projection T3 tensors remain BF16; three fixed priors and two rotary
222
+ buffers support source-free construction.
223
+ - Original T3 checkpoint: 2,129,657,904 bytes. The derivative T3 file is
224
+ 83.97% smaller.
225
+ - Derivative T3 plus the three unchanged downloaded base assets totals
226
+ 1,412,035,402 bytes versus 3,200,254,266 bytes upstream: 55.88% less model
227
+ asset storage/download, excluding small runtime files and cache.
228
+
229
+ In the final source-free release smoke, fresh-process pipeline load with base
230
+ hash verification and warm JIT state took 2.675 seconds and settled at 1.444 GB
231
+ PyTorch allocated / 1.478 GB reserved before the first generation. It prepared
232
+ 120 projections into 283,116,000 bytes, without materializing BF16 projection
233
+ weights or calling the quantizer. The short generation peaked at 2.548 GB
234
+ allocated. These are one-machine diagnostics; first-generation setup is
235
+ reported separately above.
236
+
237
+ The canonical D16 loader regression reproduced the accepted stripped runtime
238
+ bit-for-bit: token history, waveform tensor SHA-256, and saved WAV SHA-256 all
239
+ matched. A longer request also rebuilt the engine successfully while retaining
240
+ the same source-free projection provider.
241
+
242
+ ## Limitations and responsible use
243
+
244
+ - Only RTX 5060 Ti / SM120, batch one, and the documented settings received the
245
+ complete technical and small automated-audio gate.
246
+ - D16 is the only quality-default profile. D24 is optional; D32 is rejected.
247
+ - S3Gen and voice-encoder execution are not quantized by this artifact.
248
+ - Prefix projections can remain slower than BF16 at irregular shapes; the
249
+ decode block projections provide the measured NVFP4 gain.
250
+ - Streaming, multi-request throughput, RTX 5080 tuning, and human multi-voice
251
+ listening remain future work.
252
+ - Generated length can change after quantization, affecting latency, memory,
253
+ pauses, and termination even when transcripts are correct.
254
+ - Voice cloning can enable impersonation or deception. Obtain the speaker's
255
+ consent, disclose synthetic audio where appropriate, and comply with local
256
+ law and platform policies.
257
+
258
+ No evaluation audio is included because the reference clip's redistribution
259
+ and voice-consent status was not established for a public model repository.
260
+
261
+ ## Attribution and citation
262
+
263
+ Chatterbox-Flash model weights and vendored runtime code are Copyright (c) 2026
264
+ Resemble AI and licensed under MIT. This runtime is based on source commit
265
+ `74e05baa8ce574bf2cc571702391a21f1b0d48c5`; the derivative modifies the
266
+ FlashInfer projection path and adds the canonical NVFP4 loader. See
267
+ [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
268
+
269
+ ```bibtex
270
+ @misc{seo2026chatterboxflashpriorcalibratedblockdiffusion,
271
+ title={Chatterbox-Flash: Prior-Calibrated Block Diffusion for Streaming Zero-Shot TTS},
272
+ author={Deokjin Seo and Gangin Park and Kihyun Nam},
273
+ year={2026},
274
+ eprint={2605.30748},
275
+ archivePrefix={arXiv},
276
+ primaryClass={cs.SD},
277
+ url={https://arxiv.org/abs/2605.30748}
278
+ }
279
+ ```
280
+
281
+ ## Repository files
282
+
283
+ - `t3_nvfp4.safetensors`: canonical source-free checkpoint
284
+ - `nvfp4_config.json`: complete schema, fingerprints, revisions, and profiles
285
+ - `runtime/chatterbox-flash/`: pinned installable derivative runtime and lock
286
+ - `example.py`: tested local-bundle zero-shot generation entry point
287
+ - `benchmark_results.json`: machine-readable release measurements
288
+ - `SHA256SUMS`: release integrity manifest
SHA256SUMS ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ d5fadc36773f0a6e03ae542aac79a3747abd47f77028ab304cb878dc2beb3ec1 ./.gitattributes
2
+ 672ba8b147a82d1ecc3f9f1dc899d69986efb2f7e4fd9f939ce9f38948922b41 ./.gitignore
3
+ 2eba8e98035070234c35a91bb6e3c4185505cf073ff2879c8ce5fc1a462f5534 ./LICENSE
4
+ c3c8b178f6fca0debb7e7072c6fce42376f922caee993d50c9fb36285ed60aea ./README.md
5
+ 6a8b7141a4bb5ac41462c6e4c0547bd6ca701aef28fa5c4453d191658193a485 ./THIRD_PARTY_NOTICES.md
6
+ aaf7a6043f9de80a4a9ad99082274310d2dbfa9d6024753dc106755327bc0c75 ./benchmark_results.json
7
+ ff1be650be410c64a7e7d9bc2288c40638aed4af7383cbc560e77c4e87de38be ./example.py
8
+ 7521540282c2b716c3135a768b810b8f39c01ab5f773b0f8c82c9f9d4f8c44b5 ./nvfp4_config.json
9
+ d6baef68ab47dd53741421f0440d62fc5632e7955c33d9c08b7b33a6c164b8c2 ./runtime/chatterbox-flash/.gitignore
10
+ a80ea1b21580d334f010acf2f1c055d57d0959b1e6f578cc2b0a3de2eb96a8bb ./runtime/chatterbox-flash/LICENSE
11
+ d41a6f6cdedb1284e64119630d61aba97bbe325f9a0a6e0c6cbeb8ba9057bcb4 ./runtime/chatterbox-flash/README.md
12
+ d1047075186b8f28646977a5e70e70f37a00abbd6d7ff5d77aa56a1cfd8a0697 ./runtime/chatterbox-flash/chatterbox_flash/__init__.py
13
+ d3676e229c668a2d12e9a08d8abf9990203f6f6664f2fb97d3bfa75494549a75 ./runtime/chatterbox-flash/chatterbox_flash/attention_flashinfer.py
14
+ 6c7769bb30d846985751513a97be0d962ed378a82dbef124beb2d7b422a38991 ./runtime/chatterbox-flash/chatterbox_flash/calibration.py
15
+ e8703a7bf949f6e8d119962fcf84775a3b62169cd0d77c4e99c0a2cc85089858 ./runtime/chatterbox-flash/chatterbox_flash/cfg_guidance.py
16
+ f77672b56d510ef9d78f03d64ff1e192ef341b57f114f94d907bf8d49aad5cfe ./runtime/chatterbox-flash/chatterbox_flash/engines/__init__.py
17
+ 0db90fd941bde83a834e5a3600806cfa8cab1fdb36c372de0dbfe95fe1f8dbd2 ./runtime/chatterbox-flash/chatterbox_flash/engines/base.py
18
+ 177d1d750b56e4922503c4b20b8ffb7bb04965effafccdcf52a0af56cc4a79f4 ./runtime/chatterbox-flash/chatterbox_flash/engines/flashinfer.py
19
+ 7ae970bfb9342a521bdf83576c736f6c8da5fd6324f5d54a13e053c20e4ddcd6 ./runtime/chatterbox-flash/chatterbox_flash/engines/mlx.py
20
+ b03799ffd9aad68eb33700fcfdeea490e999c884092687b9a16d877a334ec3ca ./runtime/chatterbox-flash/chatterbox_flash/engines/torch_sdpa.py
21
+ a6e51fb3237d6b5569749fdce66bea1056bdccdc4245ccc28bbeb5646ff314c3 ./runtime/chatterbox-flash/chatterbox_flash/model.py
22
+ 276d1bc84f5c6c911fbe2b7b52097c015ae4e838bee2759acfc9ddebf4d68560 ./runtime/chatterbox-flash/chatterbox_flash/nvfp4.py
23
+ 561e11514455dbab23bfcff7b7e9d8252c8aeedd0b36e1e906f585e5981220fa ./runtime/chatterbox-flash/chatterbox_flash/text_norm/__init__.py
24
+ 4b221a4829756695224bc62b2b4f0c1993f40311f21f299ca5d98e14feb6dee3 ./runtime/chatterbox-flash/chatterbox_flash/text_norm/cleaners.py
25
+ 1ffc6ea6d4649ee7498d1e3e184442b660811c2765cdd250d7e6c7021fa595ae ./runtime/chatterbox-flash/chatterbox_flash/text_norm/number_norm.py
26
+ 26e26739d80282692aeecc9c44c7c0c83a116e1dcfedbddb370e066094e56d24 ./runtime/chatterbox-flash/chatterbox_flash/text_norm/phone_norm.py
27
+ a4254d1b82781a79c18902d5151ee35e3c871926a72e87cb5b1562843b5bb055 ./runtime/chatterbox-flash/chatterbox_flash/text_norm/time_norm.py
28
+ 0c0665d0c1d9593422eb2c1bcb02f1ed2ce6bf6aed45729e10311525f985b57d ./runtime/chatterbox-flash/chatterbox_flash/tts.py
29
+ 3ed7cf63c7fb04f46b453b2b60a2a890d1c64eac7945dabeea19aef6b40585d1 ./runtime/chatterbox-flash/pyproject.toml
30
+ 763867108090620985de8aa8f3019f0eca356ffe37519b1d45ae56e1fdcc78b4 ./runtime/chatterbox-flash/uv.lock
31
+ c5ea2d1d1441f657b383d8dad54bbae3778447d675822fb15918cb99b3587f1e ./t3_nvfp4.safetensors
THIRD_PARTY_NOTICES.md ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Third-party notices
2
+
3
+ This bundle is an unofficial community derivative. It is not affiliated with
4
+ or endorsed by Resemble AI or NVIDIA.
5
+
6
+ ## Chatterbox-Flash
7
+
8
+ The model weights and vendored runtime are derived from
9
+ [ResembleAI/chatterbox-flash](https://github.com/resemble-ai/chatterbox-flash),
10
+ source commit `74e05baa8ce574bf2cc571702391a21f1b0d48c5`.
11
+
12
+ Copyright (c) 2026 Resemble AI. Licensed under the MIT License. The complete
13
+ license text is reproduced in [LICENSE](LICENSE) and in
14
+ `runtime/chatterbox-flash/LICENSE`.
15
+
16
+ The derivative changes the FlashInfer engine/provider integration and adds a
17
+ canonical, source-free NVFP4 loader. The unchanged S3Gen, voice-encoder, and
18
+ tokenizer assets are not redistributed; the loader fetches them from the
19
+ immutable Chatterbox-Flash Hub revision recorded in `nvfp4_config.json`.
20
+
21
+ ## Chatterbox-TTS
22
+
23
+ Chatterbox-Flash uses architecture and runtime components from
24
+ [ResembleAI/chatterbox](https://github.com/resemble-ai/chatterbox).
25
+
26
+ Copyright (c) 2025 Resemble AI. Licensed under the MIT License.
27
+
28
+ ## Runtime dependencies
29
+
30
+ [FlashInfer](https://github.com/flashinfer-ai/flashinfer) is an Apache-2.0
31
+ runtime dependency and is not vendored.
32
+
33
+ `nvidia-cutlass-dsl` and `nvidia-cutlass-dsl-libs-base` are installed from
34
+ their official Python packages and are not vendored. They are distributed
35
+ under the NVIDIA Software License Agreement included in those packages. Users
36
+ must review and accept that agreement when installing or using the dependency.
37
+
38
+ NVIDIA, CUDA, and Blackwell are trademarks and do not imply endorsement.
39
+
benchmark_results.json ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "schema_version": 1,
3
+ "date": "2026-07-12",
4
+ "hardware": {
5
+ "gpu": "NVIDIA GeForce RTX 5060 Ti",
6
+ "compute_capability": "SM120",
7
+ "user_batch_size": 1
8
+ },
9
+ "software": {
10
+ "python": "3.12",
11
+ "torch": "2.7.1+cu128",
12
+ "torch_cuda": "12.8",
13
+ "flashinfer": "0.6.14",
14
+ "nvidia_cutlass_dsl": "4.5.2"
15
+ },
16
+ "natural_eight_text_suite": {
17
+ "definition": "Pipeline and T3 seconds are sums of per-case medians; aggregate RTF is summed pipeline time divided by summed generated-audio duration; T3 throughput uses valid speech tokens; memory is maximum per-case median PyTorch CUDA peak.",
18
+ "d16_quality": {
19
+ "bf16": {
20
+ "audio_seconds": 98.64,
21
+ "pipeline_seconds": 9.304738,
22
+ "aggregate_rtf": 0.0943303,
23
+ "t3_seconds": 7.512483,
24
+ "valid_tokens": 2466,
25
+ "t3_tokens_per_second": 328.254,
26
+ "peak_allocated_bytes": 6423756800,
27
+ "peak_reserved_bytes": 7107248128
28
+ },
29
+ "nvfp4_w4a16": {
30
+ "audio_seconds": 92.52,
31
+ "pipeline_seconds": 8.072466,
32
+ "aggregate_rtf": 0.087251,
33
+ "t3_seconds": 6.330483,
34
+ "valid_tokens": 2313,
35
+ "t3_tokens_per_second": 365.375,
36
+ "peak_allocated_bytes": 5214659584,
37
+ "peak_reserved_bytes": 6322913280
38
+ },
39
+ "nvfp4_vs_bf16": {
40
+ "aggregate_rtf_speedup": 1.081136,
41
+ "t3_throughput_speedup": 1.113087,
42
+ "peak_allocated_reduction_bytes": 1209097216,
43
+ "peak_allocated_reduction_percent": 18.8223,
44
+ "peak_reserved_reduction_bytes": 784334848,
45
+ "peak_reserved_reduction_percent": 11.0357
46
+ }
47
+ },
48
+ "d24_fast": {
49
+ "bf16": {
50
+ "audio_seconds": 93.36,
51
+ "pipeline_seconds": 6.797084,
52
+ "aggregate_rtf": 0.0728051,
53
+ "t3_seconds": 5.04703,
54
+ "valid_tokens": 2334,
55
+ "t3_tokens_per_second": 462.45,
56
+ "peak_allocated_bytes": 6539530752,
57
+ "peak_reserved_bytes": 7411335168
58
+ },
59
+ "nvfp4_w4a16": {
60
+ "audio_seconds": 91.8,
61
+ "pipeline_seconds": 6.483264,
62
+ "aggregate_rtf": 0.0706238,
63
+ "t3_seconds": 4.748782,
64
+ "valid_tokens": 2295,
65
+ "t3_tokens_per_second": 483.282,
66
+ "peak_allocated_bytes": 5129031168,
67
+ "peak_reserved_bytes": 6064963584
68
+ },
69
+ "nvfp4_vs_bf16": {
70
+ "aggregate_rtf_speedup": 1.030886,
71
+ "t3_throughput_speedup": 1.045046,
72
+ "peak_allocated_reduction_bytes": 1410499584,
73
+ "peak_allocated_reduction_percent": 21.5688,
74
+ "peak_reserved_reduction_bytes": 1346371584,
75
+ "peak_reserved_reduction_percent": 18.1664
76
+ }
77
+ }
78
+ },
79
+ "fixed_capped_work": {
80
+ "definition": "One fixed-seed case, five-repeat medians, 1024-token reservation. D16 caps at 32 speech tokens; D24 caps at 48, so compare quantized and BF16 only within each block size.",
81
+ "d16": {
82
+ "bf16_t3_ms": 88.363,
83
+ "nvfp4_t3_ms": 78.475,
84
+ "t3_speedup": 1.125998,
85
+ "bf16_pipeline_ms": 162.897,
86
+ "nvfp4_pipeline_ms": 153.235,
87
+ "pipeline_speedup": 1.063052,
88
+ "bf16_peak_allocated_bytes": 4165647360,
89
+ "nvfp4_peak_allocated_bytes": 2745680896,
90
+ "peak_allocated_reduction_percent": 34.0875,
91
+ "peak_reserved_reduction_percent": 30.0444
92
+ },
93
+ "d24": {
94
+ "bf16_t3_ms": 91.192,
95
+ "nvfp4_t3_ms": 85.574,
96
+ "t3_speedup": 1.065645,
97
+ "bf16_pipeline_ms": 180.938,
98
+ "nvfp4_pipeline_ms": 176.006,
99
+ "pipeline_speedup": 1.028023,
100
+ "bf16_peak_allocated_bytes": 4173167104,
101
+ "nvfp4_peak_allocated_bytes": 2754244096,
102
+ "peak_allocated_reduction_percent": 34.0011,
103
+ "peak_reserved_reduction_percent": 30.0689
104
+ }
105
+ },
106
+ "automated_quality": {
107
+ "warning": "Small eight-text, one-reference automated evaluation; not human MOS or a multi-speaker benchmark.",
108
+ "d16": {
109
+ "bf16_mean_score": 94.125,
110
+ "nvfp4_mean_score": 94.625,
111
+ "exact_transcripts_each": "8/8",
112
+ "hard_failures_each": "0/8",
113
+ "order_controlled_pairwise": "7 ties, 1 BF16 win"
114
+ },
115
+ "d24": {
116
+ "bf16_mean_score": 95.0,
117
+ "nvfp4_mean_score": 93.125,
118
+ "exact_transcripts_each": "8/8",
119
+ "hard_failures_each": "0/8",
120
+ "order_controlled_pairwise": "6 ties, 1 BF16 win, 1 NVFP4 win"
121
+ }
122
+ },
123
+ "source_free_load_diagnostic": {
124
+ "fresh_process_load_seconds_with_base_hash_verification_and_jit_cache_hit": 2.675073,
125
+ "allocated_bytes_after_load": 1444340224,
126
+ "reserved_bytes_after_load": 1478492160,
127
+ "prepared_projection_bytes": 283116000,
128
+ "prepared_projection_count": 120,
129
+ "short_generation_peak_allocated_bytes": 2548431360,
130
+ "first_same_process_generation_seconds_with_exact_shape_config_cache": 10.567298,
131
+ "second_same_process_generation_seconds": 0.153511,
132
+ "first_to_warm_ratio": 68.8376,
133
+ "notes": "Canonical weights were prepared without BF16 projection materialization or nvfp4_quantize. The first-generation measurement includes engine creation, CUDA graph capture, and per-process FlashInfer setup even though the exact shape's selected config was cached. A completely missing exact-shape tuning entry can take longer. Caches are GPU-specific and are intentionally not distributed."
134
+ },
135
+ "interpretation_warnings": [
136
+ "Do not present raw natural-suite wall-time ratios as pure NVFP4 speedups because BF16 and NVFP4 sampled different token counts.",
137
+ "PyTorch allocated/reserved peaks are not total board VRAM or nvidia-smi process usage.",
138
+ "The D24 versus D16 speed difference is primarily the block-size change, not quantization alone.",
139
+ "The D16 BF16 natural control had one token-cap hit and one duration warning; NVFP4 did not.",
140
+ "D32 was rejected for release use after cap hits and an OOM in the natural suite."
141
+ ]
142
+ }
example.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Generate one zero-shot voice-cloned utterance with the local NVFP4 bundle."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import re
8
+ from pathlib import Path
9
+
10
+ import soundfile as sf
11
+ import torch
12
+ from huggingface_hub import snapshot_download
13
+
14
+ from chatterbox_flash.nvfp4 import (
15
+ BASE_ASSET_FILENAMES,
16
+ BASE_CHECKPOINT_REVISION,
17
+ BASE_REPO_ID,
18
+ load_prepacked_tts_local,
19
+ )
20
+
21
+
22
+ def parse_args() -> argparse.Namespace:
23
+ parser = argparse.ArgumentParser(description=__doc__)
24
+ parser.add_argument("--prompt-wav", type=Path, required=True)
25
+ parser.add_argument("--text", required=True)
26
+ parser.add_argument("--output", type=Path, default=Path("output.wav"))
27
+ parser.add_argument(
28
+ "--block-size",
29
+ type=int,
30
+ choices=(16, 24),
31
+ default=16,
32
+ help="16 is the quality profile; 24 is the optional faster profile",
33
+ )
34
+ parser.add_argument("--seed", type=int, default=4242)
35
+ return parser.parse_args()
36
+
37
+
38
+ def main() -> None:
39
+ args = parse_args()
40
+ if not args.prompt_wav.is_file():
41
+ raise SystemExit(f"Missing reference audio: {args.prompt_wav}")
42
+
43
+ bundle = Path(__file__).resolve().parent
44
+ base_dir = Path(snapshot_download(
45
+ repo_id=BASE_REPO_ID,
46
+ revision=BASE_CHECKPOINT_REVISION,
47
+ allow_patterns=list(BASE_ASSET_FILENAMES),
48
+ ))
49
+ capability = torch.cuda.get_device_capability()
50
+ gpu_slug = re.sub(r"[^a-zA-Z0-9_.-]+", "-", torch.cuda.get_device_name())
51
+ autotune_cache = (
52
+ Path.home()
53
+ / ".cache"
54
+ / "chatterbox-flash-nvfp4"
55
+ / f"flashinfer-{gpu_slug}-sm{capability[0]}{capability[1]}-d{args.block_size}.json"
56
+ )
57
+
58
+ torch.manual_seed(args.seed)
59
+ torch.cuda.manual_seed_all(args.seed)
60
+ tts = load_prepacked_tts_local(
61
+ bundle,
62
+ base_dir,
63
+ device="cuda",
64
+ drf_block_size=args.block_size,
65
+ autotune_cache=autotune_cache,
66
+ )
67
+ waveform = tts.generate(
68
+ args.text,
69
+ audio_prompt_path=args.prompt_wav,
70
+ exaggeration=0.5,
71
+ num_steps=10,
72
+ temperature=0.2,
73
+ time_shift_tau=0.5,
74
+ omnivoice_schedule_t_shift=0.5,
75
+ cfg_scale=1.0,
76
+ position_temperature=5.0,
77
+ n_cfm_timesteps=2,
78
+ backend="flashinfer",
79
+ use_cuda_graph=True,
80
+ )
81
+
82
+ args.output.parent.mkdir(parents=True, exist_ok=True)
83
+ sf.write(args.output, waveform.float().numpy(), tts.sr)
84
+ print(f"Wrote {args.output} at {tts.sr} Hz")
85
+
86
+
87
+ if __name__ == "__main__":
88
+ main()
89
+
nvfp4_config.json ADDED
The diff for this file is too large to render. See raw diff
 
runtime/chatterbox-flash/.gitignore ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ build/
6
+ dist/
7
+ .eggs/
8
+
9
+ # Virtual environments (uv venv, etc.)
10
+ .venv/
11
+ .venv*/
12
+ venv/
13
+ env/
14
+
15
+ # Model checkpoints — released via the Hugging Face Hub, too large for git
16
+ checkpoints/
17
+
18
+ # Generated audio / test outputs
19
+ /test_out/
20
+ /syn_out/
21
+ /out.wav
22
+ /*.wav
23
+ eval_out_smoke/
24
+
25
+ # Internal-only scripts (not part of the released package)
26
+ smoke_test.py
27
+ upload_hf.py
28
+ scripts/convert_checkpoint.py
29
+ # Demo-page sites are separate git repos (own GitHub remotes), not part of this package
30
+ *_demopage/
runtime/chatterbox-flash/LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Resemble AI
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.
runtime/chatterbox-flash/README.md ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Chatterbox-Flash NVFP4 runtime
2
+
3
+ This is the installable runtime shipped with the unofficial
4
+ Chatterbox-Flash T3 W4A16 NVFP4 model bundle. It vendors the MIT-licensed
5
+ Chatterbox-Flash source at commit
6
+ `74e05baa8ce574bf2cc571702391a21f1b0d48c5` and adds an engine-local,
7
+ source-free NVFP4 projection provider plus canonical checkpoint loader.
8
+
9
+ Install only through the bundle's locked `uv sync --extra flashinfer` command.
10
+ See the model card at the bundle root for hardware requirements, usage,
11
+ benchmarks, attribution, and limitations.
runtime/chatterbox-flash/chatterbox_flash/__init__.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Chatterbox-Flash — prior-calibrated block-diffusion zero-shot TTS.
2
+
3
+ Public API:
4
+ ChatterboxFlashTTS — end-to-end TTS pipeline (T3 + S3Gen + VE + tokenizer)
5
+ ChatterboxFlashT3 — block-diffusion T3 decoder (low-level)
6
+ Conditionals — cacheable speaker / prompt conditioning bundle
7
+ en_us_cleaner — English text normalization used at training / eval time
8
+ """
9
+
10
+ from .engines import FLASHINFER_AVAILABLE, MLX_AVAILABLE, build_engine
11
+ from .model import ChatterboxFlashT3
12
+ from .nvfp4 import (
13
+ CanonicalNVFP4ProjectionProvider,
14
+ load_prepacked_t3,
15
+ load_prepacked_tts_from_hub,
16
+ load_prepacked_tts_local,
17
+ )
18
+ from .text_norm import en_us_cleaner
19
+ from .tts import ChatterboxFlashTTS, Conditionals
20
+
21
+ __version__ = "0.1.0+nvfp4.1"
22
+
23
+ __all__ = [
24
+ "ChatterboxFlashT3",
25
+ "ChatterboxFlashTTS",
26
+ "CanonicalNVFP4ProjectionProvider",
27
+ "Conditionals",
28
+ "FLASHINFER_AVAILABLE",
29
+ "MLX_AVAILABLE",
30
+ "build_engine",
31
+ "en_us_cleaner",
32
+ "load_prepacked_t3",
33
+ "load_prepacked_tts_from_hub",
34
+ "load_prepacked_tts_local",
35
+ "__version__",
36
+ ]
runtime/chatterbox-flash/chatterbox_flash/attention_flashinfer.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Backwards-compatibility shim — :class:`FlashInferEngine` now lives in
2
+ :mod:`chatterbox_flash.engines.flashinfer`.
3
+
4
+ The original module path is preserved so existing scripts that did
5
+
6
+ from chatterbox_flash.attention_flashinfer import FlashInferEngine
7
+
8
+ continue to work. New code should import from
9
+ ``chatterbox_flash.engines`` (or call :func:`chatterbox_flash.engines.build_engine`)
10
+ instead.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from .engines.flashinfer import ( # noqa: F401
16
+ FlashInferEngine,
17
+ flashinfer_available,
18
+ )
19
+
20
+ __all__ = ["FlashInferEngine", "flashinfer_available"]
runtime/chatterbox-flash/chatterbox_flash/calibration.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Prior-calibrated PMI scoring for Chatterbox-Flash block decoding.
2
+
3
+ The decoder ranks masked positions by a self-calibrated pointwise mutual
4
+ information score that suppresses the dominant-token bias of discrete speech
5
+ codecs (no per-token silence IDs needed):
6
+
7
+ s_i = log p_i(x_i) - lambda * log p_bar(x_i)
8
+
9
+ where ``p_bar`` is a reference marginal — either the precomputed unconditional
10
+ block prior (see :meth:`ChatterboxFlashT3._compute_uncond_block_prior`) or the
11
+ step-0 block-mean ``mean_j p_j``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import torch
17
+ from torch import Tensor
18
+
19
+ _LOG_EPS = 1e-8
20
+
21
+
22
+ def _marginal_for_sampled(
23
+ probs_bl: Tensor,
24
+ sampled: Tensor,
25
+ marginal_prior: Tensor | None,
26
+ ) -> Tensor:
27
+ """Return per-position marginal probability for each sampled token.
28
+
29
+ If ``marginal_prior`` is given (shape ``(V,)``), index into it directly.
30
+ Otherwise compute the block-mean marginal on the fly.
31
+ """
32
+ if marginal_prior is not None:
33
+ return marginal_prior[sampled]
34
+ bl = probs_bl.size(0)
35
+ sampled_cols = sampled.unsqueeze(0).expand(bl, -1)
36
+ return probs_bl.gather(1, sampled_cols).mean(dim=0)
37
+
38
+
39
+ def self_calibrated_pmi(
40
+ probs_bl: Tensor,
41
+ sampled: Tensor,
42
+ eps: float = _LOG_EPS,
43
+ marginal_prior: Tensor | None = None,
44
+ prior_lambda: float = 1.0,
45
+ ) -> Tensor:
46
+ """
47
+ Per-position PMI-style score vs a marginal prior.
48
+
49
+ s_i = log p_i(x_i) - lambda * log p_bar(x_i),
50
+ p_bar(v) = mean_j p_j(v).
51
+
52
+ When ``marginal_prior`` (shape ``(V,)``) is provided, it is used as
53
+ ``p_bar`` instead of the current block-mean — typically the precomputed
54
+ unconditional block prior for a consistent, uninformed reference.
55
+ """
56
+ pi = probs_bl.gather(1, sampled.unsqueeze(1)).squeeze(1)
57
+ p_bar_sampled = _marginal_for_sampled(probs_bl, sampled, marginal_prior)
58
+ return torch.log(pi.clamp_min(eps)) - (
59
+ float(prior_lambda) * torch.log(p_bar_sampled.clamp_min(eps))
60
+ )
61
+
62
+
63
+ def compute_pmi(
64
+ probs_bl: Tensor,
65
+ sampled: Tensor,
66
+ *,
67
+ marginal_prior: Tensor | None = None,
68
+ prior_lambda: float = 1.0,
69
+ eps: float = _LOG_EPS,
70
+ ) -> Tensor:
71
+ """Compute self-calibrated PMI.
72
+
73
+ When ``marginal_prior`` (shape ``(V,)``) is given, it is used as the
74
+ denominator instead of computing ``probs_bl.mean(dim=0)`` on the fly.
75
+ """
76
+ return self_calibrated_pmi(
77
+ probs_bl,
78
+ sampled,
79
+ eps=eps,
80
+ marginal_prior=marginal_prior,
81
+ prior_lambda=prior_lambda,
82
+ )
runtime/chatterbox-flash/chatterbox_flash/cfg_guidance.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Classifier-free guidance helpers for Chatterbox-Flash.
2
+
3
+ We only keep the ``zero_text_batch`` recipe used in the paper: at each block
4
+ step the forward batch is doubled to ``2 * B``; rows ``0..B-1`` carry the full
5
+ text + conditioning, rows ``B..2B-1`` carry a null branch where the text and
6
+ the entire conditioning prefix are zeroed (``cfg_null_cond_mode="zero_all"``).
7
+ The two halves of the logits are then combined via
8
+ ``log p' = log p_c + w * (log p_c - log p_u)``.
9
+
10
+ The same combination is applied on the **PMI scale** as well (see
11
+ :func:`pmi_cfg_combine`) — this is the only PMI/CFG combination supported;
12
+ no alternative ``cfg_prior_mode`` is exposed.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import torch
18
+ import torch.nn.functional as F
19
+ from torch import Tensor
20
+
21
+
22
+ def omnivoice_cfg_log_probs(
23
+ c_log_probs: Tensor,
24
+ u_log_probs: Tensor,
25
+ guidance_scale: float,
26
+ ) -> Tensor:
27
+ """``log p' = log p_c + w * (log p_c - log p_u)`` (elementwise)."""
28
+ if guidance_scale == 0.0:
29
+ return c_log_probs
30
+ return c_log_probs + guidance_scale * (c_log_probs - u_log_probs)
31
+
32
+
33
+ def apply_zero_text_cfg_from_logits(
34
+ logits_cond: Tensor,
35
+ logits_u: Tensor,
36
+ guidance_scale: float,
37
+ ) -> Tensor:
38
+ """CFG combination from two logit tensors of identical shape.
39
+
40
+ Operates on log-softmax so the result is a valid scoring/sampling logit
41
+ (softmax recovers the guided distribution).
42
+ """
43
+ if guidance_scale == 0.0:
44
+ return logits_cond
45
+ c_log = F.log_softmax(logits_cond, dim=-1)
46
+ u_log = F.log_softmax(logits_u, dim=-1)
47
+ return omnivoice_cfg_log_probs(c_log, u_log, guidance_scale)
48
+
49
+
50
+ def pmi_cfg_combine(
51
+ pmi_c: Tensor,
52
+ pmi_u: Tensor,
53
+ guidance_scale: float,
54
+ ) -> Tensor:
55
+ """Guidance-combine PMI scores: ``pmi = (1+w) * pmi_c - w * pmi_u``."""
56
+ w = float(guidance_scale)
57
+ return (1.0 + w) * pmi_c - w * pmi_u
runtime/chatterbox-flash/chatterbox_flash/engines/__init__.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Inference engines for Chatterbox-Flash.
2
+
3
+ Three backends are provided, all implementing the :class:`InferenceEngine`
4
+ protocol consumed by :class:`~chatterbox_flash.model.ChatterboxFlashT3`:
5
+
6
+ * :class:`FlashInferEngine` — paged KV cache + custom attention kernels
7
+ via the ``flashinfer-python`` package; supports CUDA-graph capture of
8
+ the block forward. This is what the paper numbers were produced with
9
+ and what should be used for any throughput-sensitive workload on
10
+ CUDA.
11
+
12
+ * :class:`TorchSDPAEngine` — pure PyTorch fallback using the HuggingFace
13
+ transformers SDPA backend with a :class:`DynamicCache`. No extra
14
+ dependencies, runs on any CUDA / CPU / MPS device the underlying T3
15
+ supports. No CUDA graphs, slower per step, but produces numerically
16
+ matching output (modulo small fp32/bf16 reduction order differences).
17
+
18
+ * :class:`MLXEngine` *(experimental)* — Apple-Silicon native Metal
19
+ backend. Wraps the mlx-lm LLaMA backbone and bypasses its
20
+ ``embed_tokens`` layer so the chatterbox-flash custom embeddings can
21
+ be used unchanged. Requires ``mlx`` and ``mlx-lm`` installed.
22
+
23
+ Use :func:`build_engine` to instantiate; it auto-falls-back to SDPA when
24
+ FlashInfer is unavailable. MLX must be selected explicitly via
25
+ ``backend="mlx"`` (or the ``CHATTERBOX_FLASH_ENGINE=mlx`` env var).
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import importlib.util
31
+ import logging
32
+ import os
33
+ from typing import Literal
34
+
35
+ import torch
36
+
37
+ from .base import InferenceEngine
38
+ from .torch_sdpa import TorchSDPAEngine
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+
43
+ FLASHINFER_AVAILABLE: bool = importlib.util.find_spec("flashinfer") is not None
44
+ MLX_AVAILABLE: bool = (
45
+ importlib.util.find_spec("mlx") is not None
46
+ and importlib.util.find_spec("mlx_lm") is not None
47
+ )
48
+
49
+
50
+ _Backend = Literal["auto", "flashinfer", "torch", "mlx"]
51
+
52
+
53
+ def _resolve_backend(requested: _Backend) -> Literal["flashinfer", "torch", "mlx"]:
54
+ """Pick a backend honoring ``CHATTERBOX_FLASH_ENGINE`` env override."""
55
+ env = os.environ.get("CHATTERBOX_FLASH_ENGINE", "").strip().lower()
56
+ if env in ("flashinfer", "torch", "sdpa", "mlx"):
57
+ requested = "torch" if env == "sdpa" else env # type: ignore[assignment]
58
+
59
+ if requested == "auto":
60
+ return "flashinfer" if FLASHINFER_AVAILABLE else "torch"
61
+ if requested == "flashinfer":
62
+ if not FLASHINFER_AVAILABLE:
63
+ raise ImportError(
64
+ "flashinfer-python is not installed. Install with "
65
+ "`pip install flashinfer-python` or pass backend='torch'.",
66
+ )
67
+ return "flashinfer"
68
+ if requested == "mlx":
69
+ if not MLX_AVAILABLE:
70
+ raise ImportError(
71
+ "MLX backend requires the 'mlx' and 'mlx-lm' packages. "
72
+ "Install with `pip install mlx mlx-lm` "
73
+ "(Apple Silicon Mac only).",
74
+ )
75
+ return "mlx"
76
+ if requested in ("torch", "sdpa"):
77
+ return "torch"
78
+ raise ValueError(
79
+ f"backend must be one of 'auto', 'flashinfer', 'torch', 'mlx'; "
80
+ f"got {requested!r}",
81
+ )
82
+
83
+
84
+ def build_engine(
85
+ model: torch.nn.Module,
86
+ max_seq_len: int,
87
+ dtype: torch.dtype,
88
+ *,
89
+ backend: _Backend = "auto",
90
+ batch_size: int = 1,
91
+ page_size: int = 16,
92
+ ) -> InferenceEngine:
93
+ """Construct an inference engine.
94
+
95
+ Parameters
96
+ ----------
97
+ model: the :class:`ChatterboxFlashT3` instance.
98
+ max_seq_len: upper bound on ``prefix_len + total_speech_len`` for the
99
+ cache reservation; needed by the FlashInfer paged buffer. The
100
+ torch SDPA / MLX engines use this only as a hint.
101
+ dtype: model parameter dtype (bf16 / fp16 / fp32).
102
+ backend: ``"auto"`` (default, prefer FlashInfer) / ``"flashinfer"`` /
103
+ ``"torch"`` / ``"mlx"``. Environment variable
104
+ ``CHATTERBOX_FLASH_ENGINE`` overrides this.
105
+ batch_size: number of forward-batch rows (with zero-text-batch CFG
106
+ this is ``2 × B_usr``).
107
+ page_size: FlashInfer paged-cache page size (ignored elsewhere).
108
+ """
109
+ chosen = _resolve_backend(backend)
110
+ projection_provider = getattr(model, "_flashinfer_projection_provider", None)
111
+ if (
112
+ projection_provider is not None
113
+ and bool(getattr(projection_provider, "source_free", False))
114
+ and chosen != "flashinfer"
115
+ ):
116
+ raise RuntimeError(
117
+ "This model has no torch/MLX transformer projection weights. "
118
+ "Its source-free NVFP4 projection provider requires "
119
+ "backend='flashinfer'."
120
+ )
121
+ if chosen == "flashinfer":
122
+ from .flashinfer import FlashInferEngine
123
+ logger.debug(
124
+ "build_engine: backend=flashinfer max_seq=%d dtype=%s batch=%d page=%d",
125
+ max_seq_len, dtype, batch_size, page_size,
126
+ )
127
+ return FlashInferEngine(
128
+ model, max_seq_len, dtype,
129
+ batch_size=batch_size, page_size=page_size,
130
+ )
131
+ if chosen == "mlx":
132
+ from .mlx import MLXEngine
133
+ logger.debug(
134
+ "build_engine: backend=mlx max_seq=%d dtype=%s batch=%d",
135
+ max_seq_len, dtype, batch_size,
136
+ )
137
+ return MLXEngine(
138
+ model, max_seq_len, dtype, batch_size=batch_size,
139
+ )
140
+ logger.debug(
141
+ "build_engine: backend=torch (SDPA fallback) max_seq=%d dtype=%s batch=%d",
142
+ max_seq_len, dtype, batch_size,
143
+ )
144
+ return TorchSDPAEngine(
145
+ model, max_seq_len, dtype, batch_size=batch_size,
146
+ )
147
+
148
+
149
+ def __getattr__(name: str): # noqa: D401
150
+ """Lazy attribute access — only import optional backends if asked for."""
151
+ if name == "FlashInferEngine":
152
+ from .flashinfer import FlashInferEngine
153
+ return FlashInferEngine
154
+ if name == "MLXEngine":
155
+ from .mlx import MLXEngine
156
+ return MLXEngine
157
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
158
+
159
+
160
+ __all__ = [
161
+ "InferenceEngine",
162
+ "TorchSDPAEngine",
163
+ "FlashInferEngine",
164
+ "MLXEngine",
165
+ "FLASHINFER_AVAILABLE",
166
+ "MLX_AVAILABLE",
167
+ "build_engine",
168
+ ]
runtime/chatterbox-flash/chatterbox_flash/engines/base.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Abstract inference-engine protocol shared by the FlashInfer and the
2
+ torch-SDPA backends.
3
+
4
+ The block-diffusion ``generate()`` loop is engine-agnostic: it only ever
5
+ talks to the engine through these six methods. Concrete backends differ
6
+ in how they store the KV cache (paged buffer vs. HuggingFace
7
+ :class:`~transformers.DynamicCache`), how they batch the prefix
8
+ forward, and whether they can capture the per-block forward as a CUDA
9
+ graph.
10
+
11
+ Contract
12
+ --------
13
+ The engine manages a single KV cache shared by the conditioning prefix
14
+ and the speech-block forwards. Lifecycle for one ``generate()`` call::
15
+
16
+ engine.reset()
17
+ shift_ctx = engine.prefix_forward(prefix_emb_or_list) # (B, 1, dim)
18
+ engine.capture_cuda_graph(BS, speech_head=speech_head) # optional
19
+
20
+ for b in range(num_blocks):
21
+ for k in range(K):
22
+ engine.set_shift_ctx(shift_ctx) # graph engines only
23
+ hidden, logits = engine.block_forward_graph(block_emb, cs)
24
+ # or: hidden = engine.block_forward(block_emb, cs)
25
+
26
+ engine.advance_cache(block_len)
27
+ # caller commits the block's clean tokens via block_forward(...)
28
+ # before advance_cache; shift_ctx is updated from that hidden.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from typing import List, Optional, Protocol, Tuple, Union, runtime_checkable
34
+
35
+ import torch
36
+ from torch import Tensor
37
+
38
+
39
+ CacheStarts = Union[int, List[int]]
40
+
41
+
42
+ @runtime_checkable
43
+ class InferenceEngine(Protocol):
44
+ """Minimal protocol implemented by all Chatterbox-Flash engines."""
45
+
46
+ has_cuda_graph: bool
47
+ """``True`` iff :meth:`capture_cuda_graph` was called and succeeded."""
48
+
49
+ @property
50
+ def cache_len(self) -> int:
51
+ """Current per-row KV cache occupancy (uniform across the batch)."""
52
+ ...
53
+
54
+ def reset(self) -> None:
55
+ """Clear cache contents and free CUDA-graph state for the next call."""
56
+ ...
57
+
58
+ def can_reuse(
59
+ self,
60
+ max_seq_len: int,
61
+ dtype: torch.dtype,
62
+ *,
63
+ batch_size: int,
64
+ page_size: int = 16,
65
+ ) -> bool:
66
+ """Whether this engine can be re-used for a request with these shape/dtype."""
67
+ ...
68
+
69
+ # --- prefix / block forwards -------------------------------------- #
70
+
71
+ def prefix_forward(
72
+ self,
73
+ prefix_emb_or_list: Union[Tensor, List[Tensor]],
74
+ ) -> Tensor:
75
+ """Encode the conditioning prefix once.
76
+
77
+ ``prefix_emb_or_list`` is either a stacked tensor ``(B_fwd, lp, dim)``
78
+ (same-text path) or a list of length ``B_fwd`` of per-row prefixes
79
+ with different lengths (cross-text path). Writes the prefix KV
80
+ into the engine's cache and returns ``shift_ctx`` — the hidden
81
+ state of the SOS row, shape ``(B_fwd, 1, dim)``.
82
+ """
83
+ ...
84
+
85
+ def block_forward(
86
+ self,
87
+ block_emb: Tensor,
88
+ cache_starts: CacheStarts,
89
+ ) -> Tensor:
90
+ """Eager (non-CUDA-graph) block forward.
91
+
92
+ ``block_emb`` shape: ``(B_fwd, bl, dim)``.
93
+ ``cache_starts`` is either an int (uniform start position) or a
94
+ per-row list (cross-text / compact-null-prefix path).
95
+ Returns hidden state ``(B_fwd, bl, dim)``; logits are computed by
96
+ the caller via ``speech_head``.
97
+ """
98
+ ...
99
+
100
+ def block_forward_graph(
101
+ self,
102
+ block_emb: Tensor,
103
+ cache_starts: CacheStarts,
104
+ ) -> Tuple[Tensor, Optional[Tensor]]:
105
+ """CUDA-graph replay of the block forward.
106
+
107
+ Engines that don't support CUDA graphs (e.g. torch SDPA) should
108
+ return ``(self.block_forward(block_emb, cache_starts), None)`` so
109
+ the caller can fall back uniformly. When the engine pre-computes
110
+ logits inside the graph (FlashInfer), the second element of the
111
+ return tuple is the logits tensor ``(B_fwd, bl, V)``.
112
+ """
113
+ ...
114
+
115
+ def advance_cache(self, block_len: int) -> None:
116
+ """Commit ``block_len`` newly-written entries to the cache."""
117
+ ...
118
+
119
+ def set_shift_ctx(self, ctx: Tensor) -> None:
120
+ """Stage the SOS / previous-block-end hidden state for the graph.
121
+
122
+ FlashInfer copies this into a graph-captured buffer at the start
123
+ of each block; the SDPA engine ignores it (it computes the shift
124
+ in-line during :meth:`block_forward`).
125
+ """
126
+ ...
127
+
128
+ def capture_cuda_graph(
129
+ self,
130
+ block_size: int,
131
+ *,
132
+ speech_head: Optional[torch.nn.Linear] = None,
133
+ ) -> None:
134
+ """Capture the per-block forward as a CUDA graph.
135
+
136
+ ``block_size`` is the fixed ``D`` the graph is captured for.
137
+ Engines that don't support CUDA graphs should leave
138
+ ``has_cuda_graph = False``. Optionally folds the speech head
139
+ into the graph if supplied.
140
+ """
141
+ ...
runtime/chatterbox-flash/chatterbox_flash/engines/flashinfer.py ADDED
@@ -0,0 +1,1656 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FlashInfer attention engine with **paged KV cache** for batched
3
+ block-diffusion inference.
4
+
5
+ Supports ``batch_size >= 1`` via ``BatchPrefillWithPagedKVCacheWrapper``.
6
+ Each sample gets a static slice of pages in the pool; prefix pages are
7
+ computed once and replicated for same-text multi-generation, or computed
8
+ independently per sample for cross-text batching.
9
+
10
+ KV cache layout (NHD, per layer):
11
+ ``(total_pages, 2, page_size, num_kv_heads, head_dim)``
12
+
13
+ Static page allocation: sample *i* → pages
14
+ ``[i * pages_per_sample : (i+1) * pages_per_sample]``.
15
+
16
+ Fused projections (same as before):
17
+ - QKV: 1 GEMM per layer instead of 3.
18
+ - Gate-up MLP: 1 GEMM instead of 2 (Llama SwiGLU).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ import math
25
+ from typing import List, Protocol, runtime_checkable
26
+
27
+ import torch
28
+ import torch.nn.functional as F
29
+ from torch import Tensor
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ try:
34
+ import flashinfer
35
+ _FLASHINFER_AVAILABLE = True
36
+ # Module-level alias avoids a name collision with the engine's
37
+ # ``self._fi_fused_add_rmsnorm`` helper, which forwards through to the
38
+ # real flashinfer kernel.
39
+ _flashinfer_fused_add_rmsnorm = flashinfer.norm.fused_add_rmsnorm
40
+ try:
41
+ from flashinfer.rope import apply_rope_inplace as _fi_apply_rope
42
+ _FI_ROPE_AVAILABLE = True
43
+ except (ImportError, AttributeError):
44
+ _FI_ROPE_AVAILABLE = False
45
+ except ImportError:
46
+ _FLASHINFER_AVAILABLE = False
47
+ _FI_ROPE_AVAILABLE = False
48
+ _flashinfer_fused_add_rmsnorm = None # type: ignore[assignment]
49
+
50
+
51
+ def flashinfer_available() -> bool:
52
+ return _FLASHINFER_AVAILABLE
53
+
54
+
55
+ @runtime_checkable
56
+ class FlashInferProjectionProvider(Protocol):
57
+ """Model-attached source of engine-local transformer projections.
58
+
59
+ A provider may own backend-prepared quantized weights instead of PyTorch
60
+ ``Parameter`` objects. Dispatch is deliberately confined to the four
61
+ transformer projection boundaries in :class:`FlashInferEngine`; global
62
+ ``torch.nn.functional.linear`` behavior is never modified.
63
+ """
64
+
65
+ source_free: bool
66
+
67
+ def validate_engine(
68
+ self,
69
+ *,
70
+ device: torch.device,
71
+ dtype: torch.dtype,
72
+ num_layers: int,
73
+ ) -> None:
74
+ """Reject an incompatible engine before any forward executes."""
75
+
76
+ def prepare_for_engine(
77
+ self,
78
+ *,
79
+ batch_size: int,
80
+ block_size: int,
81
+ ) -> None:
82
+ """Optionally select/cache kernels for an engine shape."""
83
+
84
+ def project(
85
+ self,
86
+ kind: str,
87
+ layer_idx: int,
88
+ activation: Tensor,
89
+ ) -> Tensor:
90
+ """Apply one of ``qkv``, ``o``, ``gate_up``, or ``down``."""
91
+
92
+
93
+ class FlashInferEngine:
94
+ """FlashInfer engine with paged KV cache for batched block-diffusion."""
95
+
96
+ # ------------------------------------------------------------------ #
97
+ # Construction
98
+ # ------------------------------------------------------------------ #
99
+
100
+ def __init__(
101
+ self,
102
+ model,
103
+ max_seq_len: int,
104
+ dtype: torch.dtype,
105
+ batch_size: int = 1,
106
+ page_size: int = 16,
107
+ ):
108
+ if not _FLASHINFER_AVAILABLE:
109
+ raise ImportError(
110
+ "FlashInfer not installed. Install with: "
111
+ "pip install flashinfer-python"
112
+ )
113
+
114
+ device = model.device
115
+ config = model.tfmr.config
116
+
117
+ # ---- Hopper PDL (programmatic dependent launch) ----
118
+ # PDL lets back-to-back kernels overlap their epilogue/prologue on
119
+ # Hopper, which helps the memory-bound norm / activation kernels in the
120
+ # decoder hot loop. Auto-detected from the device capability; can be
121
+ # forced via ``RESEMBLETRON_FLASHINFER_PDL`` (``0`` to disable).
122
+ import os
123
+ _pdl_env = os.environ.get("RESEMBLETRON_FLASHINFER_PDL")
124
+ if _pdl_env is not None:
125
+ self._enable_pdl: bool = _pdl_env not in ("0", "false", "False")
126
+ elif device.type == "cuda":
127
+ cap = torch.cuda.get_device_capability(device)
128
+ self._enable_pdl = cap[0] >= 9
129
+ else:
130
+ self._enable_pdl = False
131
+ # ---- fp16 qk reduction for bf16 attention ----
132
+ # Costs one extra cast on QK but lets the tensor cores reduce in fp16,
133
+ # which is materially faster on Hopper. Numerically equivalent in
134
+ # practice for inference-time bf16 models (vLLM/SGLang default).
135
+ self._use_fp16_qk_red: bool = dtype in (torch.float16, torch.bfloat16)
136
+
137
+ num_layers = config.num_hidden_layers
138
+ num_heads = config.num_attention_heads
139
+ num_kv_heads = config.num_key_value_heads
140
+ head_dim = getattr(config, "head_dim", config.hidden_size // num_heads)
141
+
142
+ self._projection_provider: FlashInferProjectionProvider | None = getattr(
143
+ model, "_flashinfer_projection_provider", None,
144
+ )
145
+ if self._projection_provider is not None:
146
+ missing_provider_methods = [
147
+ name for name in ("validate_engine", "prepare_for_engine", "project")
148
+ if not callable(getattr(self._projection_provider, name, None))
149
+ ]
150
+ if missing_provider_methods:
151
+ raise TypeError(
152
+ "model._flashinfer_projection_provider is missing callable "
153
+ f"methods: {missing_provider_methods}"
154
+ )
155
+ self._projection_provider.validate_engine(
156
+ device=device,
157
+ dtype=dtype,
158
+ num_layers=num_layers,
159
+ )
160
+
161
+ B = max(1, batch_size)
162
+ ps = page_size
163
+ P = math.ceil(max_seq_len / ps)
164
+ total_pages = B * P
165
+
166
+ # ---- Paged KV caches ----
167
+ self._paged_kv: List[Tensor] = [
168
+ torch.zeros(
169
+ total_pages, 2, ps, num_kv_heads, head_dim,
170
+ device=device, dtype=dtype,
171
+ )
172
+ for _ in range(num_layers)
173
+ ]
174
+
175
+ self._max_seq_len = max_seq_len
176
+ self._num_layers = num_layers
177
+ self._num_heads = num_heads
178
+ self._num_kv_heads = num_kv_heads
179
+ self._head_dim = head_dim
180
+ self._device = device
181
+ self._dtype = dtype
182
+ self._batch_size = B
183
+ self._page_size = ps
184
+ self._pages_per_sample = P
185
+
186
+ q_dim = num_heads * head_dim
187
+ kv_dim = num_kv_heads * head_dim
188
+ self._q_dim = q_dim
189
+ self._kv_dim = kv_dim
190
+ hidden_size = num_heads * head_dim
191
+ self._hidden_size = hidden_size
192
+
193
+ # ---- Fused projection weights ----
194
+ self._fused_qkv_w: List[Tensor] = []
195
+ self._fused_gate_up_w: List[Tensor] = []
196
+ self._o_proj_w: List[Tensor] = []
197
+ self._down_proj_w: List[Tensor] = []
198
+ self._input_ln: list = []
199
+ self._post_ln: list = []
200
+ self._mlp_modules: list = []
201
+ # ---- Cached RMSNorm weight/eps for FlashInfer fused kernels ----
202
+ # ``flashinfer.norm.{rmsnorm, fused_add_rmsnorm}`` needs the raw weight
203
+ # tensor + scalar eps. Caching avoids re-fetching attrs in the hot loop.
204
+ self._input_ln_w: List[Tensor] = []
205
+ self._input_ln_eps: List[float] = []
206
+ self._post_ln_w: List[Tensor] = []
207
+ self._post_ln_eps: List[float] = []
208
+
209
+ for layer in model.tfmr.layers:
210
+ attn = layer.self_attn
211
+ mlp = layer.mlp
212
+ if self._projection_provider is None:
213
+ self._fused_qkv_w.append(
214
+ torch.cat(
215
+ [attn.q_proj.weight.data, attn.k_proj.weight.data,
216
+ attn.v_proj.weight.data], dim=0,
217
+ )
218
+ )
219
+ self._o_proj_w.append(attn.o_proj.weight)
220
+ if hasattr(mlp, "gate_proj") and hasattr(mlp, "up_proj"):
221
+ self._fused_gate_up_w.append(
222
+ torch.cat(
223
+ [mlp.gate_proj.weight.data, mlp.up_proj.weight.data],
224
+ dim=0,
225
+ )
226
+ )
227
+ self._down_proj_w.append(mlp.down_proj.weight)
228
+ self._input_ln.append(layer.input_layernorm)
229
+ self._post_ln.append(layer.post_attention_layernorm)
230
+ self._mlp_modules.append(mlp)
231
+ self._input_ln_w.append(layer.input_layernorm.weight.data)
232
+ self._input_ln_eps.append(
233
+ float(layer.input_layernorm.variance_epsilon)
234
+ )
235
+ self._post_ln_w.append(
236
+ layer.post_attention_layernorm.weight.data
237
+ )
238
+ self._post_ln_eps.append(
239
+ float(layer.post_attention_layernorm.variance_epsilon)
240
+ )
241
+
242
+ self._mlp_act_fn = model.tfmr.layers[0].mlp.act_fn
243
+ self._final_norm = model.tfmr.norm
244
+ self._final_norm_w = model.tfmr.norm.weight.data
245
+ self._final_norm_eps = float(model.tfmr.norm.variance_epsilon)
246
+ # FlashInfer rmsnorm kernels support fp16 / bf16. fp32 falls back to the
247
+ # legacy LlamaRMSNorm module path.
248
+ self._use_fused_norm: bool = dtype in (torch.float16, torch.bfloat16)
249
+ self._use_fused_silu: bool = (
250
+ self._use_fused_norm
251
+ and (
252
+ self._projection_provider is not None
253
+ or bool(self._fused_gate_up_w)
254
+ )
255
+ )
256
+
257
+ if self._projection_provider is not None:
258
+ self._projection_provider.prepare_for_engine(
259
+ batch_size=B,
260
+ block_size=int(model.drf_block_size),
261
+ )
262
+
263
+ # ---- RoPE ----
264
+ # transformers ≥5.9 consolidates rope settings into a single
265
+ # ``config.rope_parameters`` dict and DROPS the top-level
266
+ # ``config.rope_theta`` / ``config.rope_scaling`` attributes; older
267
+ # versions expose them at top level. Read both so we get the real
268
+ # values regardless of layout (the T3 backbone uses theta=500000 with
269
+ # llama3 scaling, both of which now live only in rope_parameters).
270
+ _rope_params = getattr(config, "rope_parameters", None) or {}
271
+ self._rope_theta: float = float(
272
+ getattr(config, "rope_theta", None)
273
+ or _rope_params.get("rope_theta")
274
+ or 10000.0
275
+ )
276
+ # ``flashinfer.rope.apply_rope_inplace`` only implements plain-theta
277
+ # RoPE (``rope_scale=1.0``); it does NOT apply llama3-style frequency
278
+ # scaling. With llama3 scaling the inplace kernel encodes wrong
279
+ # positions and the model degenerates into repetitive output. When any
280
+ # non-plain rope_type is configured, fall back to the precomputed HF
281
+ # cos/sin table below (built from ``model.tfmr.rotary_emb``, which bakes
282
+ # in the scaling) — exactly matching the torch_sdpa path.
283
+ _rope_scaling = getattr(config, "rope_scaling", None) or _rope_params or None
284
+ # transformers has used both "rope_type" (≥4.40) and the legacy "type"
285
+ # key; accept either, and treat anything other than default/linear
286
+ # (e.g. "llama3", "dynamic", "yarn") as needing the table fallback.
287
+ _rope_type = (
288
+ (_rope_scaling.get("rope_type") or _rope_scaling.get("type"))
289
+ if _rope_scaling
290
+ else None
291
+ )
292
+ _plain_rope = _rope_type in (None, "default", "linear")
293
+ self._use_rope_inplace: bool = (
294
+ _FI_ROPE_AVAILABLE and device.type == "cuda" and _plain_rope
295
+ )
296
+
297
+ if self._use_rope_inplace:
298
+ self._rope_indptr = torch.zeros(
299
+ B + 1, dtype=torch.int32, device=device,
300
+ )
301
+ self._rope_offset = torch.zeros(
302
+ B, dtype=torch.int32, device=device,
303
+ )
304
+ self._rope_indptr_1 = torch.zeros(
305
+ 2, dtype=torch.int32, device=device,
306
+ )
307
+ self._rope_offset_1 = torch.zeros(
308
+ 1, dtype=torch.int32, device=device,
309
+ )
310
+
311
+ dim = config.hidden_size
312
+ _dummy = torch.zeros(1, 1, dim, device=device, dtype=dtype)
313
+ _full_pos = torch.arange(max_seq_len, device=device).unsqueeze(0)
314
+ self._rope_cos, self._rope_sin = model.tfmr.rotary_emb(
315
+ _dummy, _full_pos,
316
+ )
317
+ self._rope_cos = self._rope_cos.detach()
318
+ self._rope_sin = self._rope_sin.detach()
319
+
320
+ from transformers.models.llama.modeling_llama import (
321
+ apply_rotary_pos_emb,
322
+ )
323
+ self._apply_rotary_pos_emb = apply_rotary_pos_emb
324
+
325
+ # ---- Per-sample cache state ----
326
+ self._cache_lens: List[int] = [0] * B
327
+ self._prefix_lens: List[int] = [0] * B
328
+
329
+ # ---- Eager paged wrapper (block forward without CUDA graph) ----
330
+ self._fi_workspace = torch.empty(
331
+ 256 * 1024 * 1024, dtype=torch.uint8, device=device,
332
+ )
333
+ self._fi_eager_wrapper = flashinfer.BatchPrefillWithPagedKVCacheWrapper(
334
+ self._fi_workspace, kv_layout="NHD",
335
+ )
336
+ # ---- Ragged prefill wrapper (cross-text prefix forward, eager only) ----
337
+ # Used to fuse per-sample ``single_prefill_with_kv_cache`` calls into one
338
+ # ragged-batched prefill per layer. Has its own workspace so its plan
339
+ # state cannot clobber the paged wrapper.
340
+ self._fi_ragged_workspace = torch.empty(
341
+ 64 * 1024 * 1024, dtype=torch.uint8, device=device,
342
+ )
343
+ self._fi_ragged_wrapper = (
344
+ flashinfer.BatchPrefillWithRaggedKVCacheWrapper(
345
+ self._fi_ragged_workspace, kv_layout="NHD",
346
+ )
347
+ )
348
+
349
+ # ---- CUDA graph state ----
350
+ self._use_cuda_graph: bool = False
351
+ self._cuda_graph = None
352
+ self._graph_pool = None
353
+ self._g_block_size: int = 0
354
+ self._g_inputs_embeds: Tensor | None = None
355
+ self._g_output_hidden: Tensor | None = None
356
+ self._g_cos: Tensor | None = None
357
+ self._g_sin: Tensor | None = None
358
+ self._fi_graph_wrapper = None
359
+ self._g_page_write_idx: Tensor | None = None
360
+ self._g_page_write_off: Tensor | None = None
361
+ self._last_plan_cache_start: int | tuple = -1
362
+ self._plan_qo_indptr: Tensor | None = None
363
+ self._plan_paged_kv_indptr: Tensor | None = None
364
+ self._plan_paged_kv_indices: Tensor | None = None
365
+ self._plan_paged_kv_last_page_len: Tensor | None = None
366
+
367
+ # ---- Speech head in graph (optional) ----
368
+ self._has_speech_head: bool = False
369
+ self._speech_head_w: Tensor | None = None
370
+ self._speech_head_b: Tensor | None = None
371
+ self._g_shift_ctx: Tensor | None = None
372
+ self._g_logits: Tensor | None = None
373
+
374
+ logger.info(
375
+ "FlashInferEngine: max_seq=%d layers=%d heads=%d/%d dim=%d "
376
+ "batch=%d page_size=%d pages/sample=%d total_pages=%d "
377
+ "rope_inplace=%s dtype=%s",
378
+ max_seq_len, num_layers, num_heads, num_kv_heads, head_dim,
379
+ B, ps, P, total_pages, self._use_rope_inplace, dtype,
380
+ )
381
+
382
+ # ------------------------------------------------------------------ #
383
+ # Page-index helpers
384
+ # ------------------------------------------------------------------ #
385
+
386
+ def _compute_page_write_indices(
387
+ self, cache_starts: int | List[int], bl: int,
388
+ ) -> tuple[Tensor, Tensor]:
389
+ """Flat page indices + offsets for writing ``B * bl`` KV entries."""
390
+ B = self._batch_size
391
+ P = self._pages_per_sample
392
+ ps = self._page_size
393
+ dev = self._device
394
+
395
+ if isinstance(cache_starts, int):
396
+ pos = torch.arange(cache_starts, cache_starts + bl, device=dev)
397
+ p_in_s = pos // ps
398
+ off = pos % ps
399
+ base = torch.arange(B, device=dev, dtype=torch.long) * P
400
+ return (
401
+ (base[:, None] + p_in_s[None, :]).reshape(-1),
402
+ off.repeat(B),
403
+ )
404
+
405
+ parts_idx: list[Tensor] = []
406
+ parts_off: list[Tensor] = []
407
+ for bi in range(B):
408
+ cs = cache_starts[bi]
409
+ pos = torch.arange(cs, cs + bl, device=dev)
410
+ parts_idx.append(bi * P + pos // ps)
411
+ parts_off.append(pos % ps)
412
+ return torch.cat(parts_idx), torch.cat(parts_off)
413
+
414
+ def _plan_paged_attention(
415
+ self,
416
+ wrapper,
417
+ kv_lens: int | List[int],
418
+ seq_len: int,
419
+ causal: bool = False,
420
+ *,
421
+ qo_indptr_buf: Tensor | None = None,
422
+ paged_kv_indptr_buf: Tensor | None = None,
423
+ paged_kv_indices_buf: Tensor | None = None,
424
+ paged_kv_last_page_len_buf: Tensor | None = None,
425
+ ):
426
+ """Call ``wrapper.plan()`` with page-table metadata."""
427
+ B = self._batch_size
428
+ P = self._pages_per_sample
429
+ ps = self._page_size
430
+ dev = self._device
431
+
432
+ kv_list = [kv_lens] * B if isinstance(kv_lens, int) else list(kv_lens)
433
+ pages_used = [math.ceil(kl / ps) for kl in kv_list]
434
+ total_pu = sum(pages_used)
435
+
436
+ use_buf = qo_indptr_buf is not None
437
+
438
+ qo = qo_indptr_buf if use_buf else torch.zeros(
439
+ B + 1, dtype=torch.int32, device=dev,
440
+ )
441
+ kvi = paged_kv_indptr_buf if use_buf else torch.zeros(
442
+ B + 1, dtype=torch.int32, device=dev,
443
+ )
444
+ kvidx = paged_kv_indices_buf if use_buf else torch.empty(
445
+ total_pu, dtype=torch.int32, device=dev,
446
+ )
447
+ kvlp = paged_kv_last_page_len_buf if use_buf else torch.empty(
448
+ B, dtype=torch.int32, device=dev,
449
+ )
450
+
451
+ if use_buf:
452
+ qo[0] = 0
453
+ kvi[0] = 0
454
+
455
+ idx = 0
456
+ for bi in range(B):
457
+ pu = pages_used[bi]
458
+ qo[bi + 1] = qo[bi] + seq_len
459
+ kvi[bi + 1] = kvi[bi] + pu
460
+ kvidx[idx: idx + pu] = torch.arange(
461
+ bi * P, bi * P + pu, dtype=torch.int32, device=dev,
462
+ )
463
+ idx += pu
464
+ last = kv_list[bi] % ps
465
+ kvlp[bi] = last if last > 0 else ps
466
+
467
+ wrapper.plan(
468
+ qo_indptr=qo,
469
+ paged_kv_indptr=kvi,
470
+ paged_kv_indices=kvidx[:total_pu],
471
+ paged_kv_last_page_len=kvlp,
472
+ num_qo_heads=self._num_heads,
473
+ num_kv_heads=self._num_kv_heads,
474
+ head_dim_qk=self._head_dim,
475
+ page_size=ps,
476
+ causal=causal,
477
+ q_data_type=self._dtype,
478
+ use_fp16_qk_reduction=self._use_fp16_qk_red,
479
+ )
480
+
481
+ # ------------------------------------------------------------------ #
482
+ # MLP helper (shared by prefix + block + graph paths)
483
+ # ------------------------------------------------------------------ #
484
+
485
+ def _project(
486
+ self,
487
+ kind: str,
488
+ layer_idx: int,
489
+ activation: Tensor,
490
+ ) -> Tensor:
491
+ """Apply an engine transformer projection at its explicit boundary."""
492
+ if self._projection_provider is not None:
493
+ return self._projection_provider.project(
494
+ kind, layer_idx, activation,
495
+ )
496
+ weights = {
497
+ "qkv": self._fused_qkv_w,
498
+ "o": self._o_proj_w,
499
+ "gate_up": self._fused_gate_up_w,
500
+ "down": self._down_proj_w,
501
+ }
502
+ try:
503
+ weight = weights[kind][layer_idx]
504
+ except (KeyError, IndexError) as exc:
505
+ raise RuntimeError(
506
+ f"Missing {kind} projection for transformer layer {layer_idx}"
507
+ ) from exc
508
+ return F.linear(activation, weight)
509
+
510
+ # ------------------------------------------------------------------ #
511
+ # FlashInfer kernel wrappers (forward PDL flag once)
512
+ # ------------------------------------------------------------------ #
513
+
514
+ def _fi_rmsnorm(self, x: Tensor, w: Tensor, eps: float) -> Tensor:
515
+ return flashinfer.norm.rmsnorm(
516
+ x, w, eps, enable_pdl=self._enable_pdl,
517
+ )
518
+
519
+ def _fi_fused_add_rmsnorm(
520
+ self, x: Tensor, residual: Tensor, w: Tensor, eps: float,
521
+ ) -> None:
522
+ _flashinfer_fused_add_rmsnorm(
523
+ x, residual, w, eps, enable_pdl=self._enable_pdl,
524
+ )
525
+
526
+ def _mlp_forward(self, layer_idx: int, hidden: Tensor) -> Tensor:
527
+ """Llama SwiGLU MLP. Accepts any leading dims ``(..., dim)``.
528
+
529
+ When fused gate-up weight + bf16/fp16 are available, the
530
+ ``silu(gate) * up`` elementwise op is replaced by FlashInfer's
531
+ ``silu_and_mul`` (single fused kernel, 1 read + 1 write of the
532
+ intermediate tensor instead of 2 reads + 1 write).
533
+ """
534
+ if self._projection_provider is None and not self._fused_gate_up_w:
535
+ return self._mlp_modules[layer_idx](hidden)
536
+
537
+ gate_up = self._project("gate_up", layer_idx, hidden)
538
+ if self._use_fused_silu:
539
+ orig_shape = gate_up.shape
540
+ gate_up_2d = gate_up.reshape(-1, orig_shape[-1])
541
+ act_2d = flashinfer.activation.silu_and_mul(
542
+ gate_up_2d, enable_pdl=self._enable_pdl,
543
+ )
544
+ act = act_2d.view(*orig_shape[:-1], act_2d.size(-1))
545
+ else:
546
+ mid = gate_up.size(-1) // 2
547
+ act = self._mlp_act_fn(gate_up[..., :mid]) * gate_up[..., mid:]
548
+ return self._project("down", layer_idx, act)
549
+
550
+ # ------------------------------------------------------------------ #
551
+ # Prefix forward (single-request per sample, paged KV write)
552
+ # ------------------------------------------------------------------ #
553
+
554
+ def prefix_forward(
555
+ self,
556
+ prefix_emb_or_list: Tensor | List[Tensor],
557
+ prefix_lens: List[int] | None = None,
558
+ ) -> Tensor:
559
+ """Compute prefix KV and store in paged cache.
560
+
561
+ Same-text mode
562
+ ``prefix_emb_or_list`` is ``(1, lp, dim)``.
563
+ Computed once for sample 0, pages copied to samples 1..B-1.
564
+
565
+ Cross-text mode
566
+ ``prefix_emb_or_list`` is a list of ``(1, lp_i, dim)``.
567
+ Each sample processed independently.
568
+
569
+ Returns:
570
+ ``(B, 1, dim)`` shift context (SOS hidden per sample).
571
+ """
572
+ B = self._batch_size
573
+
574
+ if isinstance(prefix_emb_or_list, list):
575
+ # Cross-text: pack all samples into one ragged batched prefill so
576
+ # we issue O(L) kernel launches instead of O(L * B).
577
+ return self._prefix_forward_batched(prefix_emb_or_list)
578
+
579
+ emb = prefix_emb_or_list[:1] if prefix_emb_or_list.size(0) > 1 else prefix_emb_or_list
580
+ ctx = self._prefix_forward_one(emb, sample_idx=0)
581
+ lp = prefix_emb_or_list.size(1)
582
+ pfx_pages = math.ceil(lp / self._page_size)
583
+ P = self._pages_per_sample
584
+ for li in range(self._num_layers):
585
+ src = self._paged_kv[li][:pfx_pages]
586
+ for bi in range(1, B):
587
+ s = bi * P
588
+ self._paged_kv[li][s: s + pfx_pages] = src
589
+ for bi in range(1, B):
590
+ self._cache_lens[bi] = lp
591
+ self._prefix_lens[bi] = lp
592
+ return ctx.expand(B, -1, -1).contiguous()
593
+
594
+ def _prefix_forward_one(
595
+ self, prefix_emb: Tensor, sample_idx: int,
596
+ ) -> Tensor:
597
+ """Single-sample prefix prefill, writes into ``sample_idx`` page slice.
598
+
599
+ Decoder body uses the vLLM-style ``(hidden, residual)`` carry pattern
600
+ with :func:`flashinfer.norm.fused_add_rmsnorm` (in-place residual add
601
+ + RMSNorm in a single kernel). Attention is the optimized
602
+ :func:`flashinfer.single_prefill_with_kv_cache` (causal).
603
+ """
604
+ lp = prefix_emb.size(1)
605
+ page_off = sample_idx * self._pages_per_sample
606
+ ps = self._page_size
607
+ dev = self._device
608
+
609
+ pos = torch.arange(lp, device=dev)
610
+ pw_idx = page_off + pos // ps
611
+ pw_off = pos % ps
612
+
613
+ if self._use_rope_inplace:
614
+ self._rope_indptr_1[1] = lp
615
+ self._rope_offset_1[0] = 0
616
+
617
+ nh = self._num_heads
618
+ nkv = self._num_kv_heads
619
+ hd = self._head_dim
620
+ qd = self._q_dim
621
+ kvd = self._kv_dim
622
+
623
+ if self._use_fused_norm:
624
+ return self._prefix_forward_one_fused(
625
+ prefix_emb, sample_idx, lp, pw_idx, pw_off,
626
+ nh, nkv, hd, qd, kvd,
627
+ )
628
+
629
+ hidden = prefix_emb
630
+
631
+ for li in range(self._num_layers):
632
+ residual = hidden
633
+ hidden = self._input_ln[li](hidden)
634
+
635
+ qkv = self._project("qkv", li, hidden)
636
+ q = qkv[:, :, :qd].view(1, lp, nh, hd)
637
+ k = qkv[:, :, qd: qd + kvd].view(1, lp, nkv, hd)
638
+ v = qkv[:, :, qd + kvd:].view(1, lp, nkv, hd)
639
+
640
+ qs = q.squeeze(0)
641
+ ks = k.squeeze(0)
642
+ vs = v.squeeze(0)
643
+
644
+ if self._use_rope_inplace:
645
+ _fi_apply_rope(
646
+ qs, ks, self._rope_indptr_1, self._rope_offset_1,
647
+ interleave=False, rope_scale=1.0,
648
+ rope_theta=self._rope_theta,
649
+ )
650
+ else:
651
+ cos = self._rope_cos[:, :lp, :]
652
+ sin = self._rope_sin[:, :lp, :]
653
+ q, k = self._apply_rotary_pos_emb(
654
+ q, k, cos, sin, unsqueeze_dim=2,
655
+ )
656
+ qs = q.squeeze(0)
657
+ ks = k.squeeze(0)
658
+
659
+ attn_out = flashinfer.single_prefill_with_kv_cache(
660
+ qs, ks, vs, causal=True, kv_layout="NHD",
661
+ use_fp16_qk_reduction=self._use_fp16_qk_red,
662
+ )
663
+
664
+ self._paged_kv[li][pw_idx, 0, pw_off] = ks
665
+ self._paged_kv[li][pw_idx, 1, pw_off] = vs
666
+
667
+ attn_out = attn_out.unsqueeze(0).reshape(1, lp, -1)
668
+ attn_out = self._project("o", li, attn_out)
669
+
670
+ hidden = residual + attn_out
671
+ residual = hidden
672
+ hidden = self._post_ln[li](hidden)
673
+ hidden = self._mlp_forward(li, hidden)
674
+ hidden = residual + hidden
675
+
676
+ hidden = self._final_norm(hidden)
677
+ self._cache_lens[sample_idx] = lp
678
+ self._prefix_lens[sample_idx] = lp
679
+ return hidden[:, lp - 1: lp, :]
680
+
681
+ def _prefix_forward_one_fused(
682
+ self,
683
+ prefix_emb: Tensor,
684
+ sample_idx: int,
685
+ lp: int,
686
+ pw_idx: Tensor,
687
+ pw_off: Tensor,
688
+ nh: int,
689
+ nkv: int,
690
+ hd: int,
691
+ qd: int,
692
+ kvd: int,
693
+ ) -> Tensor:
694
+ """fused_add_rmsnorm + silu_and_mul variant of :meth:`_prefix_forward_one`."""
695
+ L = self._num_layers
696
+ # Work in 2D ``(lp, dim)`` so fused_add_rmsnorm (which wants 2D contig)
697
+ # and silu_and_mul (any leading dims) both apply cleanly.
698
+ # Clone so we don't mutate the caller's ``prefix_emb``.
699
+ residual = prefix_emb.reshape(lp, -1).contiguous().clone()
700
+ hidden = self._fi_rmsnorm(
701
+ residual, self._input_ln_w[0], self._input_ln_eps[0],
702
+ )
703
+
704
+ for li in range(L):
705
+ qkv = self._project("qkv", li, hidden)
706
+ qs = qkv[:, :qd].view(lp, nh, hd)
707
+ ks = qkv[:, qd: qd + kvd].view(lp, nkv, hd)
708
+ vs = qkv[:, qd + kvd:].view(lp, nkv, hd)
709
+
710
+ if self._use_rope_inplace:
711
+ _fi_apply_rope(
712
+ qs, ks, self._rope_indptr_1, self._rope_offset_1,
713
+ interleave=False, rope_scale=1.0,
714
+ rope_theta=self._rope_theta,
715
+ )
716
+ else:
717
+ # Apply RoPE via HF helper (expects ``(B, S, h, hd)``).
718
+ q3 = qs.unsqueeze(0)
719
+ k3 = ks.unsqueeze(0)
720
+ cos = self._rope_cos[:, :lp, :]
721
+ sin = self._rope_sin[:, :lp, :]
722
+ q3, k3 = self._apply_rotary_pos_emb(
723
+ q3, k3, cos, sin, unsqueeze_dim=2,
724
+ )
725
+ qs = q3.squeeze(0)
726
+ ks = k3.squeeze(0)
727
+
728
+ attn_out = flashinfer.single_prefill_with_kv_cache(
729
+ qs, ks, vs, causal=True, kv_layout="NHD",
730
+ use_fp16_qk_reduction=self._use_fp16_qk_red,
731
+ )
732
+
733
+ self._paged_kv[li][pw_idx, 0, pw_off] = ks
734
+ self._paged_kv[li][pw_idx, 1, pw_off] = vs
735
+
736
+ attn_out_2d = attn_out.reshape(lp, -1)
737
+ attn_out_2d = self._project("o", li, attn_out_2d).contiguous()
738
+
739
+ # residual += attn_out; attn_out = rmsnorm(residual, post_ln_w)
740
+ self._fi_fused_add_rmsnorm(
741
+ attn_out_2d, residual,
742
+ self._post_ln_w[li], self._post_ln_eps[li],
743
+ )
744
+
745
+ mlp_out = self._mlp_forward(li, attn_out_2d)
746
+ if not mlp_out.is_contiguous():
747
+ mlp_out = mlp_out.contiguous()
748
+
749
+ if li + 1 < L:
750
+ self._fi_fused_add_rmsnorm(
751
+ mlp_out, residual,
752
+ self._input_ln_w[li + 1], self._input_ln_eps[li + 1],
753
+ )
754
+ hidden = mlp_out
755
+ else:
756
+ residual.add_(mlp_out)
757
+ hidden = residual
758
+
759
+ # Final norm and per-sample SOS hidden.
760
+ out = self._fi_rmsnorm(
761
+ hidden, self._final_norm_w, self._final_norm_eps,
762
+ )
763
+ self._cache_lens[sample_idx] = lp
764
+ self._prefix_lens[sample_idx] = lp
765
+ return out[lp - 1: lp, :].view(1, 1, -1)
766
+
767
+ # ------------------------------------------------------------------ #
768
+ # Cross-text ragged batched prefix forward
769
+ # ------------------------------------------------------------------ #
770
+
771
+ def _prefix_forward_batched(
772
+ self, emb_list: List[Tensor],
773
+ ) -> Tensor:
774
+ """Cross-text prefix prefill via ragged batched attention.
775
+
776
+ Replaces the per-sample loop of
777
+ :meth:`_prefix_forward_one` with a single
778
+ :class:`flashinfer.BatchPrefillWithRaggedKVCacheWrapper.run`
779
+ per layer. Sample ``bi`` may have its own prefix length ``lp_i``;
780
+ embeddings are packed into a flat ``(sum_i lp_i, dim)`` tensor.
781
+
782
+ Each sample's KV is scattered into its own paged-cache slice
783
+ ``[bi*P : bi*P + ceil(lp_i/ps)]``.
784
+
785
+ Returns:
786
+ ``(B_actual, 1, dim)`` per-sample SOS hidden.
787
+ """
788
+ B_actual = len(emb_list)
789
+ if B_actual == 1:
790
+ # Single sample: paged ragged setup overhead is wasteful. Reuse the
791
+ # well-tuned single-sample path which calls single_prefill directly.
792
+ return self._prefix_forward_one(emb_list[0], 0)
793
+
794
+ dev = self._device
795
+ ps = self._page_size
796
+ P = self._pages_per_sample
797
+ nh = self._num_heads
798
+ nkv = self._num_kv_heads
799
+ hd = self._head_dim
800
+ qd = self._q_dim
801
+ kvd = self._kv_dim
802
+ L = self._num_layers
803
+ dtype = self._dtype
804
+
805
+ lp_list = [int(e.size(1)) for e in emb_list]
806
+ total_lp = sum(lp_list)
807
+
808
+ # Pack embeddings to (total_lp, dim).
809
+ packed = torch.cat(
810
+ [e.reshape(-1, e.size(-1)) for e in emb_list], dim=0,
811
+ ).to(dtype=dtype).contiguous()
812
+
813
+ # qo_indptr == kv_indptr for self-attention prefill.
814
+ indptr_host = [0]
815
+ for lp_i in lp_list:
816
+ indptr_host.append(indptr_host[-1] + lp_i)
817
+ qo_indptr = torch.tensor(
818
+ indptr_host, dtype=torch.int32, device=dev,
819
+ )
820
+
821
+ # Paged-write indices: sample bi's positions [0, lp_i) → pages
822
+ # [bi*P, bi*P + ceil(lp_i/ps)).
823
+ pw_idx_parts: list[Tensor] = []
824
+ pw_off_parts: list[Tensor] = []
825
+ for bi, lp_i in enumerate(lp_list):
826
+ pos = torch.arange(lp_i, device=dev)
827
+ pw_idx_parts.append(bi * P + pos // ps)
828
+ pw_off_parts.append(pos % ps)
829
+ pw_idx = torch.cat(pw_idx_parts)
830
+ pw_off = torch.cat(pw_off_parts)
831
+
832
+ # RoPE: each sample starts from position 0 (prefix). Reuse indptr as
833
+ # rope_indptr; offsets are all zero. ``apply_rope_inplace`` wants int32.
834
+ if self._use_rope_inplace:
835
+ rope_indptr = qo_indptr.to(torch.int32)
836
+ rope_offset = torch.zeros(
837
+ B_actual, dtype=torch.int32, device=dev,
838
+ )
839
+ else:
840
+ # Pre-build packed cos/sin once; shape (total_lp, head_dim).
841
+ cos_parts = [self._rope_cos[0, :lp_i, :] for lp_i in lp_list]
842
+ sin_parts = [self._rope_sin[0, :lp_i, :] for lp_i in lp_list]
843
+ packed_cos = torch.cat(cos_parts, dim=0)
844
+ packed_sin = torch.cat(sin_parts, dim=0)
845
+
846
+ # Plan ragged wrapper once for the whole layer stack (causal).
847
+ self._fi_ragged_wrapper.plan(
848
+ qo_indptr=qo_indptr,
849
+ kv_indptr=qo_indptr,
850
+ num_qo_heads=nh,
851
+ num_kv_heads=nkv,
852
+ head_dim_qk=hd,
853
+ causal=True,
854
+ q_data_type=dtype,
855
+ use_fp16_qk_reduction=self._use_fp16_qk_red,
856
+ )
857
+
858
+ use_fused = self._use_fused_norm
859
+ if use_fused:
860
+ residual = packed.clone()
861
+ hidden = self._fi_rmsnorm(
862
+ residual, self._input_ln_w[0], self._input_ln_eps[0],
863
+ )
864
+ else:
865
+ hidden = packed # (total_lp, dim)
866
+
867
+ for li in range(L):
868
+ if use_fused:
869
+ qkv = self._project("qkv", li, hidden)
870
+ else:
871
+ # Module-style norm requires 3D ``(1, total_lp, dim)``.
872
+ normed = self._input_ln[li](hidden.unsqueeze(0)).squeeze(0)
873
+ qkv = self._project("qkv", li, normed)
874
+
875
+ qs = qkv[:, :qd].view(total_lp, nh, hd)
876
+ ks = qkv[:, qd: qd + kvd].view(total_lp, nkv, hd)
877
+ vs = qkv[:, qd + kvd:].view(total_lp, nkv, hd)
878
+
879
+ if self._use_rope_inplace:
880
+ _fi_apply_rope(
881
+ qs, ks, rope_indptr, rope_offset,
882
+ interleave=False, rope_scale=1.0,
883
+ rope_theta=self._rope_theta,
884
+ )
885
+ else:
886
+ # ``apply_rotary_pos_emb`` expects ``(B, S, h, hd)``; mimic with
887
+ # a synthetic batch dim over the packed sequence.
888
+ cos = packed_cos.unsqueeze(0)
889
+ sin = packed_sin.unsqueeze(0)
890
+ q3 = qs.unsqueeze(0)
891
+ k3 = ks.unsqueeze(0)
892
+ q3, k3 = self._apply_rotary_pos_emb(
893
+ q3, k3, cos, sin, unsqueeze_dim=2,
894
+ )
895
+ qs = q3.squeeze(0)
896
+ ks = k3.squeeze(0)
897
+
898
+ self._paged_kv[li][pw_idx, 0, pw_off] = ks
899
+ self._paged_kv[li][pw_idx, 1, pw_off] = vs
900
+
901
+ attn_out = self._fi_ragged_wrapper.run(qs, ks, vs)
902
+ attn_out_2d = attn_out.reshape(total_lp, -1)
903
+ attn_out_2d = self._project(
904
+ "o", li, attn_out_2d,
905
+ ).contiguous()
906
+
907
+ if use_fused:
908
+ self._fi_fused_add_rmsnorm(
909
+ attn_out_2d, residual,
910
+ self._post_ln_w[li], self._post_ln_eps[li],
911
+ )
912
+ mlp_out = self._mlp_forward(li, attn_out_2d)
913
+ if not mlp_out.is_contiguous():
914
+ mlp_out = mlp_out.contiguous()
915
+ if li + 1 < L:
916
+ self._fi_fused_add_rmsnorm(
917
+ mlp_out, residual,
918
+ self._input_ln_w[li + 1], self._input_ln_eps[li + 1],
919
+ )
920
+ hidden = mlp_out
921
+ else:
922
+ residual.add_(mlp_out)
923
+ hidden = residual
924
+ else:
925
+ # Non-fused fallback: keep classic residual carry.
926
+ hidden = hidden + attn_out_2d # residual after attn
927
+ residual_after_attn = hidden
928
+ hidden_n = self._post_ln[li](hidden.unsqueeze(0)).squeeze(0)
929
+ mlp_out = self._mlp_forward(li, hidden_n)
930
+ hidden = residual_after_attn + mlp_out
931
+
932
+ if use_fused:
933
+ out = self._fi_rmsnorm(
934
+ hidden, self._final_norm_w, self._final_norm_eps,
935
+ )
936
+ else:
937
+ out = self._final_norm(hidden.unsqueeze(0)).squeeze(0)
938
+
939
+ # Per-sample SOS hidden = out[lp_i - 1] within each sample's slice.
940
+ shift_idx = (qo_indptr[1:] - 1).to(torch.long)
941
+ shift_ctx = out.index_select(0, shift_idx).unsqueeze(1) # (B_actual, 1, dim)
942
+
943
+ for bi, lp_i in enumerate(lp_list):
944
+ self._cache_lens[bi] = lp_i
945
+ self._prefix_lens[bi] = lp_i
946
+
947
+ return shift_ctx.contiguous()
948
+
949
+ # ------------------------------------------------------------------ #
950
+ # Batched block forward (non-causal, paged attention)
951
+ # ------------------------------------------------------------------ #
952
+
953
+ def block_forward(
954
+ self,
955
+ block_emb: Tensor,
956
+ cache_starts: int | List[int],
957
+ ) -> Tensor:
958
+ """Batched block forward with non-causal paged attention.
959
+
960
+ Args:
961
+ block_emb: ``(B, bl, dim)``
962
+ cache_starts: int (uniform) or list[int] (per-sample).
963
+
964
+ Returns:
965
+ ``(B, bl, dim)`` after final RMSNorm.
966
+ """
967
+ B = self._batch_size
968
+ bl = block_emb.size(1)
969
+
970
+ pw_idx, pw_off = self._compute_page_write_indices(cache_starts, bl)
971
+
972
+ if isinstance(cache_starts, int):
973
+ kv_lens: int | List[int] = cache_starts + bl
974
+ else:
975
+ kv_lens = [cs + bl for cs in cache_starts]
976
+
977
+ self._plan_paged_attention(
978
+ self._fi_eager_wrapper, kv_lens, seq_len=bl, causal=False,
979
+ )
980
+
981
+ if self._use_rope_inplace:
982
+ for bi in range(B):
983
+ self._rope_indptr[bi + 1] = (bi + 1) * bl
984
+ if isinstance(cache_starts, int):
985
+ self._rope_offset[:] = cache_starts
986
+ else:
987
+ for bi in range(B):
988
+ self._rope_offset[bi] = cache_starts[bi]
989
+
990
+ nh = self._num_heads
991
+ nkv = self._num_kv_heads
992
+ hd = self._head_dim
993
+ qd = self._q_dim
994
+ kvd = self._kv_dim
995
+
996
+ if self._use_fused_norm:
997
+ return self._block_forward_fused(
998
+ block_emb, cache_starts, B, bl, pw_idx, pw_off,
999
+ nh, nkv, hd, qd, kvd,
1000
+ )
1001
+
1002
+ hidden = block_emb
1003
+
1004
+ for li in range(self._num_layers):
1005
+ residual = hidden
1006
+ hidden = self._input_ln[li](hidden)
1007
+
1008
+ qkv = self._project("qkv", li, hidden)
1009
+ q = qkv[:, :, :qd].view(B, bl, nh, hd)
1010
+ k = qkv[:, :, qd: qd + kvd].view(B, bl, nkv, hd)
1011
+ v = qkv[:, :, qd + kvd:].view(B, bl, nkv, hd)
1012
+
1013
+ qs = q.reshape(B * bl, nh, hd)
1014
+ ks = k.reshape(B * bl, nkv, hd)
1015
+ vs = v.reshape(B * bl, nkv, hd)
1016
+
1017
+ if self._use_rope_inplace:
1018
+ _fi_apply_rope(
1019
+ qs, ks, self._rope_indptr, self._rope_offset,
1020
+ interleave=False, rope_scale=1.0,
1021
+ rope_theta=self._rope_theta,
1022
+ )
1023
+ else:
1024
+ if isinstance(cache_starts, int):
1025
+ cos = self._rope_cos[
1026
+ :, cache_starts: cache_starts + bl, :
1027
+ ]
1028
+ sin = self._rope_sin[
1029
+ :, cache_starts: cache_starts + bl, :
1030
+ ]
1031
+ else:
1032
+ cos = torch.cat([
1033
+ self._rope_cos[:, cs: cs + bl, :]
1034
+ for cs in cache_starts
1035
+ ], dim=0)
1036
+ sin = torch.cat([
1037
+ self._rope_sin[:, cs: cs + bl, :]
1038
+ for cs in cache_starts
1039
+ ], dim=0)
1040
+ q, k = self._apply_rotary_pos_emb(
1041
+ q, k, cos, sin, unsqueeze_dim=2,
1042
+ )
1043
+ qs = q.reshape(B * bl, nh, hd)
1044
+ ks = k.reshape(B * bl, nkv, hd)
1045
+
1046
+ self._paged_kv[li][pw_idx, 0, pw_off] = ks
1047
+ self._paged_kv[li][pw_idx, 1, pw_off] = vs
1048
+
1049
+ attn_out = self._fi_eager_wrapper.run(
1050
+ qs, self._paged_kv[li],
1051
+ )
1052
+ attn_out = attn_out.reshape(B, bl, -1)
1053
+ attn_out = self._project("o", li, attn_out)
1054
+
1055
+ hidden = residual + attn_out
1056
+ residual = hidden
1057
+ hidden = self._post_ln[li](hidden)
1058
+ hidden = self._mlp_forward(li, hidden)
1059
+ hidden = residual + hidden
1060
+
1061
+ return self._final_norm(hidden)
1062
+
1063
+ def _block_forward_fused(
1064
+ self,
1065
+ block_emb: Tensor,
1066
+ cache_starts: int | List[int],
1067
+ B: int,
1068
+ bl: int,
1069
+ pw_idx: Tensor,
1070
+ pw_off: Tensor,
1071
+ nh: int,
1072
+ nkv: int,
1073
+ hd: int,
1074
+ qd: int,
1075
+ kvd: int,
1076
+ ) -> Tensor:
1077
+ """``block_forward`` body with fused_add_rmsnorm + silu_and_mul.
1078
+
1079
+ Carries ``(hidden, residual)`` as 2D ``(B*bl, dim)``. ``residual`` is
1080
+ a private clone of ``block_emb`` so the caller's tensor is not mutated.
1081
+ """
1082
+ L = self._num_layers
1083
+ N = B * bl
1084
+
1085
+ residual = block_emb.reshape(N, -1).contiguous().clone()
1086
+ hidden = self._fi_rmsnorm(
1087
+ residual, self._input_ln_w[0], self._input_ln_eps[0],
1088
+ )
1089
+
1090
+ for li in range(L):
1091
+ qkv = self._project("qkv", li, hidden)
1092
+ qs = qkv[:, :qd].view(N, nh, hd)
1093
+ ks = qkv[:, qd: qd + kvd].view(N, nkv, hd)
1094
+ vs = qkv[:, qd + kvd:].view(N, nkv, hd)
1095
+
1096
+ if self._use_rope_inplace:
1097
+ _fi_apply_rope(
1098
+ qs, ks, self._rope_indptr, self._rope_offset,
1099
+ interleave=False, rope_scale=1.0,
1100
+ rope_theta=self._rope_theta,
1101
+ )
1102
+ else:
1103
+ if isinstance(cache_starts, int):
1104
+ cos = self._rope_cos[
1105
+ :, cache_starts: cache_starts + bl, :
1106
+ ]
1107
+ sin = self._rope_sin[
1108
+ :, cache_starts: cache_starts + bl, :
1109
+ ]
1110
+ else:
1111
+ cos = torch.cat([
1112
+ self._rope_cos[:, cs: cs + bl, :]
1113
+ for cs in cache_starts
1114
+ ], dim=0)
1115
+ sin = torch.cat([
1116
+ self._rope_sin[:, cs: cs + bl, :]
1117
+ for cs in cache_starts
1118
+ ], dim=0)
1119
+ q3 = qs.view(B, bl, nh, hd)
1120
+ k3 = ks.view(B, bl, nkv, hd)
1121
+ q3, k3 = self._apply_rotary_pos_emb(
1122
+ q3, k3, cos, sin, unsqueeze_dim=2,
1123
+ )
1124
+ qs = q3.reshape(N, nh, hd)
1125
+ ks = k3.reshape(N, nkv, hd)
1126
+
1127
+ self._paged_kv[li][pw_idx, 0, pw_off] = ks
1128
+ self._paged_kv[li][pw_idx, 1, pw_off] = vs
1129
+
1130
+ attn_out = self._fi_eager_wrapper.run(
1131
+ qs, self._paged_kv[li],
1132
+ )
1133
+ attn_out_2d = attn_out.reshape(N, -1)
1134
+ attn_out_2d = self._project("o", li, attn_out_2d).contiguous()
1135
+
1136
+ self._fi_fused_add_rmsnorm(
1137
+ attn_out_2d, residual,
1138
+ self._post_ln_w[li], self._post_ln_eps[li],
1139
+ )
1140
+
1141
+ mlp_out = self._mlp_forward(li, attn_out_2d)
1142
+ if not mlp_out.is_contiguous():
1143
+ mlp_out = mlp_out.contiguous()
1144
+
1145
+ if li + 1 < L:
1146
+ self._fi_fused_add_rmsnorm(
1147
+ mlp_out, residual,
1148
+ self._input_ln_w[li + 1], self._input_ln_eps[li + 1],
1149
+ )
1150
+ hidden = mlp_out
1151
+ else:
1152
+ residual.add_(mlp_out)
1153
+ hidden = residual
1154
+
1155
+ out = self._fi_rmsnorm(
1156
+ hidden, self._final_norm_w, self._final_norm_eps,
1157
+ )
1158
+ return out.view(B, bl, -1)
1159
+
1160
+ # ------------------------------------------------------------------ #
1161
+ # Cache management
1162
+ # ------------------------------------------------------------------ #
1163
+
1164
+ @property
1165
+ def batch_size(self) -> int:
1166
+ return self._batch_size
1167
+
1168
+ @property
1169
+ def cache_len(self) -> int:
1170
+ """Backward-compat: cache length of sample 0."""
1171
+ return self._cache_lens[0]
1172
+
1173
+ @cache_len.setter
1174
+ def cache_len(self, value: int):
1175
+ self._cache_lens = [value] * self._batch_size
1176
+
1177
+ @property
1178
+ def cache_lens(self) -> List[int]:
1179
+ return self._cache_lens
1180
+
1181
+ @property
1182
+ def prefix_lens(self) -> List[int]:
1183
+ return self._prefix_lens
1184
+
1185
+ def advance_cache(self, block_len: int):
1186
+ for bi in range(self._batch_size):
1187
+ self._cache_lens[bi] += block_len
1188
+
1189
+ def reset(self):
1190
+ B = self._batch_size
1191
+ self._cache_lens = [0] * B
1192
+ self._prefix_lens = [0] * B
1193
+ self._last_plan_cache_start = -1
1194
+ # CUDA graph / pool are kept across generate() calls. Per-call prefix and
1195
+ # paged-KV state are rebuilt in prefix_forward; block_forward_graph replans
1196
+ # when cache_starts change. capture_cuda_graph early-returns when the same
1197
+ # block_size is already captured (avoids allocator issues + recapture cost).
1198
+
1199
+ def snapshot_kv(self):
1200
+ """Deep copy of paged KV + lengths for :meth:`restore_kv` (CFG uncond pass)."""
1201
+ kv_copy = [t.clone() for t in self._paged_kv]
1202
+ return (
1203
+ kv_copy,
1204
+ list(self._cache_lens),
1205
+ list(self._prefix_lens),
1206
+ int(getattr(self, "_last_plan_cache_start", -1)),
1207
+ )
1208
+
1209
+ def restore_kv(self, snap) -> None:
1210
+ """Restore state from :meth:`snapshot_kv`."""
1211
+ kv_copy, cache_lens, prefix_lens, last_plan = snap
1212
+ for li, t in enumerate(kv_copy):
1213
+ self._paged_kv[li].copy_(t)
1214
+ self._cache_lens = cache_lens
1215
+ self._prefix_lens = prefix_lens
1216
+ self._last_plan_cache_start = last_plan
1217
+
1218
+ # ------------------------------------------------------------------ #
1219
+ # CUDA Graph
1220
+ # ------------------------------------------------------------------ #
1221
+
1222
+ @property
1223
+ def has_cuda_graph(self) -> bool:
1224
+ return self._use_cuda_graph
1225
+
1226
+ def can_reuse(
1227
+ self,
1228
+ max_seq_len: int,
1229
+ dtype: torch.dtype,
1230
+ batch_size: int = 1,
1231
+ page_size: int = 16,
1232
+ ) -> bool:
1233
+ return (
1234
+ self._max_seq_len >= max_seq_len
1235
+ and self._dtype == dtype
1236
+ and self._batch_size == max(1, batch_size)
1237
+ and self._page_size == page_size
1238
+ )
1239
+
1240
+ def capture_cuda_graph(
1241
+ self,
1242
+ block_size: int,
1243
+ speech_head: "torch.nn.Linear | None" = None,
1244
+ ):
1245
+ if self._use_cuda_graph and self._g_block_size == block_size:
1246
+ return
1247
+
1248
+ import gc
1249
+ import os
1250
+ import shutil
1251
+ import glob as glob_mod
1252
+
1253
+ device = self._device
1254
+ dtype = self._dtype
1255
+ bl = block_size
1256
+ B = self._batch_size
1257
+ P = self._pages_per_sample
1258
+ ps = self._page_size
1259
+ hs = self._hidden_size
1260
+
1261
+ cache_root = os.path.expanduser("~/.cache/flashinfer")
1262
+ for partial in glob_mod.glob(
1263
+ os.path.join(cache_root, "**/cached_ops/batch_prefill_*"),
1264
+ recursive=True,
1265
+ ):
1266
+ if os.path.isdir(partial):
1267
+ so_files = glob_mod.glob(os.path.join(partial, "*.so"))
1268
+ if not so_files:
1269
+ logger.info(
1270
+ "FlashInferEngine: cleaning partial JIT: %s", partial,
1271
+ )
1272
+ shutil.rmtree(partial, ignore_errors=True)
1273
+
1274
+ # ---- Plan buffers (pre-allocated for CUDA-graph wrapper) ----
1275
+ max_plan_pages = B * P
1276
+ self._plan_qo_indptr = torch.zeros(
1277
+ B + 1, dtype=torch.int32, device=device,
1278
+ )
1279
+ self._plan_paged_kv_indptr = torch.zeros(
1280
+ B + 1, dtype=torch.int32, device=device,
1281
+ )
1282
+ self._plan_paged_kv_indices = torch.zeros(
1283
+ max_plan_pages, dtype=torch.int32, device=device,
1284
+ )
1285
+ self._plan_paged_kv_last_page_len = torch.zeros(
1286
+ B, dtype=torch.int32, device=device,
1287
+ )
1288
+
1289
+ fi_ws = torch.empty(
1290
+ 256 * 1024 * 1024, dtype=torch.uint8, device=device,
1291
+ )
1292
+ self._fi_graph_wrapper = flashinfer.BatchPrefillWithPagedKVCacheWrapper(
1293
+ fi_ws,
1294
+ kv_layout="NHD",
1295
+ use_cuda_graph=True,
1296
+ qo_indptr_buf=self._plan_qo_indptr,
1297
+ paged_kv_indptr_buf=self._plan_paged_kv_indptr,
1298
+ paged_kv_indices_buf=self._plan_paged_kv_indices,
1299
+ paged_kv_last_page_len_buf=self._plan_paged_kv_last_page_len,
1300
+ )
1301
+
1302
+ # ---- Graph IO ----
1303
+ self._g_inputs_embeds = torch.zeros(
1304
+ B, bl, hs, device=device, dtype=dtype,
1305
+ )
1306
+ self._g_output_hidden = torch.zeros(
1307
+ B, bl, hs, device=device, dtype=dtype,
1308
+ )
1309
+ self._g_block_size = bl
1310
+
1311
+ uniform_cache = len(set(self._cache_lens)) <= 1
1312
+ if uniform_cache:
1313
+ cs0 = self._cache_lens[0]
1314
+ pw_idx, pw_off = self._compute_page_write_indices(cs0, bl)
1315
+ kv_plan: int | List[int] = cs0 + bl
1316
+ else:
1317
+ starts = list(self._cache_lens)
1318
+ pw_idx, pw_off = self._compute_page_write_indices(starts, bl)
1319
+ kv_plan = [c + bl for c in self._cache_lens]
1320
+
1321
+ self._g_page_write_idx = pw_idx.clone()
1322
+ self._g_page_write_off = pw_off.clone()
1323
+
1324
+ if self._use_rope_inplace:
1325
+ for bi in range(B):
1326
+ self._rope_indptr[bi + 1] = (bi + 1) * bl
1327
+ if uniform_cache:
1328
+ self._rope_offset[:] = self._cache_lens[0]
1329
+ else:
1330
+ for bi in range(B):
1331
+ self._rope_offset[bi] = self._cache_lens[bi]
1332
+ else:
1333
+ self._g_cos = torch.zeros(
1334
+ B, bl, self._head_dim, device=device, dtype=dtype,
1335
+ )
1336
+ self._g_sin = torch.zeros(
1337
+ B, bl, self._head_dim, device=device, dtype=dtype,
1338
+ )
1339
+ for bi in range(B):
1340
+ cs_bi = self._cache_lens[bi]
1341
+ self._g_cos[bi].copy_(self._rope_cos[0, cs_bi: cs_bi + bl, :])
1342
+ self._g_sin[bi].copy_(self._rope_sin[0, cs_bi: cs_bi + bl, :])
1343
+
1344
+ # ---- Speech head in graph ----
1345
+ if speech_head is not None:
1346
+ self._speech_head_w = speech_head.weight.detach()
1347
+ self._speech_head_b = (
1348
+ speech_head.bias.detach()
1349
+ if speech_head.bias is not None
1350
+ else None
1351
+ )
1352
+ vocab_size = speech_head.weight.size(0)
1353
+ self._g_shift_ctx = torch.zeros(
1354
+ B, 1, hs, device=device, dtype=dtype,
1355
+ )
1356
+ self._g_logits = torch.zeros(
1357
+ B, bl, vocab_size, device=device, dtype=dtype,
1358
+ )
1359
+ self._has_speech_head = True
1360
+ logger.info(
1361
+ "FlashInferEngine: speech_head in graph (vocab=%d)",
1362
+ vocab_size,
1363
+ )
1364
+
1365
+ # ---- Initial plan ----
1366
+ logger.info(
1367
+ "FlashInferEngine: CUDA graph JIT compiling batch_prefill "
1368
+ "(MAX_JOBS=%s) ...",
1369
+ os.environ.get("MAX_JOBS", "unset"),
1370
+ )
1371
+ self._plan_paged_attention(
1372
+ self._fi_graph_wrapper, kv_plan, seq_len=bl, causal=False,
1373
+ qo_indptr_buf=self._plan_qo_indptr,
1374
+ paged_kv_indptr_buf=self._plan_paged_kv_indptr,
1375
+ paged_kv_indices_buf=self._plan_paged_kv_indices,
1376
+ paged_kv_last_page_len_buf=self._plan_paged_kv_last_page_len,
1377
+ )
1378
+ # Tuple matches :meth:`block_forward_graph` when ``cache_starts`` is a list;
1379
+ # same-text int ``cache_starts`` triggers one replan on first replay (harmless).
1380
+ self._last_plan_cache_start = tuple(self._cache_lens)
1381
+
1382
+ # ---- Warmup ----
1383
+ logger.info("FlashInferEngine: CUDA graph warmup forward ...")
1384
+ self._graph_body()
1385
+ torch.cuda.synchronize()
1386
+ gc.collect()
1387
+ torch.cuda.synchronize()
1388
+
1389
+ # ---- Capture ----
1390
+ logger.info("FlashInferEngine: CUDA graph capturing ...")
1391
+ self._cuda_graph = torch.cuda.CUDAGraph()
1392
+ with torch.cuda.graph(self._cuda_graph, pool=self._graph_pool):
1393
+ self._graph_body()
1394
+
1395
+ if self._graph_pool is None:
1396
+ self._graph_pool = self._cuda_graph.pool()
1397
+
1398
+ self._use_cuda_graph = True
1399
+ logger.info(
1400
+ "FlashInferEngine: CUDA graph captured "
1401
+ "(block_size=%d, batch=%d, speech_head=%s)",
1402
+ bl, B, self._has_speech_head,
1403
+ )
1404
+
1405
+ def _graph_body(self):
1406
+ """Graph-capturable batched forward for block denoising.
1407
+
1408
+ In fused-norm mode the layer body carries ``(hidden, residual)`` as 2D
1409
+ ``(B*bl, dim)`` and uses :func:`flashinfer.norm.fused_add_rmsnorm` for
1410
+ every "residual add + RMSNorm" pair. The persistent
1411
+ ``self._g_inputs_embeds`` buffer doubles as the running residual; it is
1412
+ overwritten via ``copy_(block_emb)`` before each replay, so in-place
1413
+ mutation inside the graph is safe.
1414
+ """
1415
+ B = self._batch_size
1416
+ bl = self._g_block_size
1417
+ nh = self._num_heads
1418
+ nkv = self._num_kv_heads
1419
+ hd = self._head_dim
1420
+ qd = self._q_dim
1421
+ kvd = self._kv_dim
1422
+ use_ri = self._use_rope_inplace
1423
+ L = self._num_layers
1424
+ N = B * bl
1425
+
1426
+ if self._use_fused_norm:
1427
+ # 2D view of the persistent input buffer; in-place mutation OK
1428
+ # because the buffer is reset every replay (see ``block_forward_graph``).
1429
+ residual = self._g_inputs_embeds.view(N, -1)
1430
+ hidden = self._fi_rmsnorm(
1431
+ residual, self._input_ln_w[0], self._input_ln_eps[0],
1432
+ )
1433
+
1434
+ for li in range(L):
1435
+ qkv = self._project("qkv", li, hidden)
1436
+ qs = qkv[:, :qd].view(N, nh, hd)
1437
+ ks = qkv[:, qd: qd + kvd].view(N, nkv, hd)
1438
+ vs = qkv[:, qd + kvd:].view(N, nkv, hd)
1439
+
1440
+ if use_ri:
1441
+ _fi_apply_rope(
1442
+ qs, ks, self._rope_indptr, self._rope_offset,
1443
+ interleave=False, rope_scale=1.0,
1444
+ rope_theta=self._rope_theta,
1445
+ )
1446
+ else:
1447
+ q3 = qs.view(B, bl, nh, hd)
1448
+ k3 = ks.view(B, bl, nkv, hd)
1449
+ q3, k3 = self._apply_rotary_pos_emb(
1450
+ q3, k3, self._g_cos, self._g_sin, unsqueeze_dim=2,
1451
+ )
1452
+ qs = q3.reshape(N, nh, hd)
1453
+ ks = k3.reshape(N, nkv, hd)
1454
+
1455
+ self._paged_kv[li][
1456
+ self._g_page_write_idx, 0, self._g_page_write_off
1457
+ ] = ks
1458
+ self._paged_kv[li][
1459
+ self._g_page_write_idx, 1, self._g_page_write_off
1460
+ ] = vs
1461
+
1462
+ attn_out = self._fi_graph_wrapper.run(
1463
+ qs, self._paged_kv[li],
1464
+ )
1465
+ attn_out_2d = attn_out.reshape(N, -1)
1466
+ attn_out_2d = self._project(
1467
+ "o", li, attn_out_2d,
1468
+ ).contiguous()
1469
+
1470
+ self._fi_fused_add_rmsnorm(
1471
+ attn_out_2d, residual,
1472
+ self._post_ln_w[li], self._post_ln_eps[li],
1473
+ )
1474
+
1475
+ mlp_out = self._mlp_forward(li, attn_out_2d)
1476
+ if not mlp_out.is_contiguous():
1477
+ mlp_out = mlp_out.contiguous()
1478
+
1479
+ if li + 1 < L:
1480
+ self._fi_fused_add_rmsnorm(
1481
+ mlp_out, residual,
1482
+ self._input_ln_w[li + 1], self._input_ln_eps[li + 1],
1483
+ )
1484
+ hidden = mlp_out
1485
+ else:
1486
+ residual.add_(mlp_out)
1487
+ hidden = residual
1488
+
1489
+ out = self._fi_rmsnorm(
1490
+ hidden, self._final_norm_w, self._final_norm_eps,
1491
+ )
1492
+ self._g_output_hidden[:] = out.view(B, bl, -1)
1493
+ else:
1494
+ hidden = self._g_inputs_embeds
1495
+
1496
+ for li in range(L):
1497
+ residual = hidden
1498
+ hidden = self._input_ln[li](hidden)
1499
+
1500
+ qkv = self._project("qkv", li, hidden)
1501
+ q = qkv[:, :, :qd].view(B, bl, nh, hd)
1502
+ k = qkv[:, :, qd: qd + kvd].view(B, bl, nkv, hd)
1503
+ v = qkv[:, :, qd + kvd:].view(B, bl, nkv, hd)
1504
+
1505
+ qs = q.reshape(B * bl, nh, hd)
1506
+ ks = k.reshape(B * bl, nkv, hd)
1507
+ vs = v.reshape(B * bl, nkv, hd)
1508
+
1509
+ if use_ri:
1510
+ _fi_apply_rope(
1511
+ qs, ks, self._rope_indptr, self._rope_offset,
1512
+ interleave=False, rope_scale=1.0,
1513
+ rope_theta=self._rope_theta,
1514
+ )
1515
+ else:
1516
+ cos = self._g_cos
1517
+ sin = self._g_sin
1518
+ q, k = self._apply_rotary_pos_emb(
1519
+ q, k, cos, sin, unsqueeze_dim=2,
1520
+ )
1521
+ qs = q.reshape(B * bl, nh, hd)
1522
+ ks = k.reshape(B * bl, nkv, hd)
1523
+
1524
+ self._paged_kv[li][
1525
+ self._g_page_write_idx, 0, self._g_page_write_off
1526
+ ] = ks
1527
+ self._paged_kv[li][
1528
+ self._g_page_write_idx, 1, self._g_page_write_off
1529
+ ] = vs
1530
+
1531
+ attn_out = self._fi_graph_wrapper.run(
1532
+ qs, self._paged_kv[li],
1533
+ )
1534
+ attn_out = attn_out.reshape(B, bl, -1)
1535
+ attn_out = self._project("o", li, attn_out)
1536
+
1537
+ hidden = residual + attn_out
1538
+ residual = hidden
1539
+ hidden = self._post_ln[li](hidden)
1540
+ hidden = self._mlp_forward(li, hidden)
1541
+ hidden = residual + hidden
1542
+
1543
+ self._g_output_hidden[:] = self._final_norm(hidden)
1544
+
1545
+ if self._has_speech_head:
1546
+ shift_hidden = torch.cat(
1547
+ [self._g_shift_ctx, self._g_output_hidden[:, : bl - 1]],
1548
+ dim=1,
1549
+ )
1550
+ self._g_logits[:] = F.linear(
1551
+ shift_hidden,
1552
+ self._speech_head_w,
1553
+ self._speech_head_b,
1554
+ )
1555
+
1556
+ def set_shift_ctx(self, ctx: Tensor):
1557
+ """Copy shift context into graph buffer (once per block).
1558
+
1559
+ Args:
1560
+ ctx: ``(B, 1, dim)`` or ``(1, 1, dim)`` (broadcast).
1561
+ """
1562
+ if self._g_shift_ctx is not None:
1563
+ if ctx.size(0) < self._batch_size:
1564
+ self._g_shift_ctx[:] = ctx.expand(
1565
+ self._batch_size, -1, -1,
1566
+ )
1567
+ else:
1568
+ self._g_shift_ctx.copy_(ctx)
1569
+
1570
+ def block_forward_graph(
1571
+ self,
1572
+ block_emb: Tensor,
1573
+ cache_starts: int | List[int],
1574
+ ) -> tuple[Tensor, Tensor | None]:
1575
+ """Batched block forward via CUDA graph replay.
1576
+
1577
+ Falls back to eager :meth:`block_forward` for partial blocks.
1578
+
1579
+ Args:
1580
+ block_emb: ``(B, bl, dim)``
1581
+ cache_starts: int (uniform) or list[int] per-sample.
1582
+
1583
+ Returns:
1584
+ ``(hidden, logits)`` — ``(B, bl, dim)`` and
1585
+ ``(B, bl, vocab)`` or ``None``.
1586
+ """
1587
+ bl = block_emb.size(1)
1588
+ if bl != self._g_block_size:
1589
+ return self.block_forward(block_emb, cache_starts), None
1590
+
1591
+ self._g_inputs_embeds.copy_(block_emb)
1592
+
1593
+ cs_key: int | tuple
1594
+ if isinstance(cache_starts, int):
1595
+ cs_key = cache_starts
1596
+ else:
1597
+ cs_key = tuple(cache_starts)
1598
+
1599
+ if cs_key != self._last_plan_cache_start:
1600
+ B = self._batch_size
1601
+ bl_ = bl
1602
+
1603
+ pw_idx, pw_off = self._compute_page_write_indices(
1604
+ cache_starts, bl_,
1605
+ )
1606
+ self._g_page_write_idx.copy_(pw_idx)
1607
+ self._g_page_write_off.copy_(pw_off)
1608
+
1609
+ if self._use_rope_inplace:
1610
+ for bi in range(B):
1611
+ self._rope_indptr[bi + 1] = (bi + 1) * bl_
1612
+ if isinstance(cache_starts, int):
1613
+ self._rope_offset[:] = cache_starts
1614
+ else:
1615
+ for bi in range(B):
1616
+ self._rope_offset[bi] = cache_starts[bi]
1617
+ else:
1618
+ if isinstance(cache_starts, int):
1619
+ for bi in range(B):
1620
+ self._g_cos[bi].copy_(
1621
+ self._rope_cos[0, cache_starts: cache_starts + bl_, :],
1622
+ )
1623
+ self._g_sin[bi].copy_(
1624
+ self._rope_sin[0, cache_starts: cache_starts + bl_, :],
1625
+ )
1626
+ else:
1627
+ for bi in range(B):
1628
+ csb = cache_starts[bi]
1629
+ self._g_cos[bi].copy_(
1630
+ self._rope_cos[0, csb: csb + bl_, :],
1631
+ )
1632
+ self._g_sin[bi].copy_(
1633
+ self._rope_sin[0, csb: csb + bl_, :],
1634
+ )
1635
+
1636
+ if isinstance(cache_starts, int):
1637
+ kv_lens: int | List[int] = cache_starts + bl_
1638
+ else:
1639
+ kv_lens = [c + bl_ for c in cache_starts]
1640
+
1641
+ self._plan_paged_attention(
1642
+ self._fi_graph_wrapper, kv_lens, seq_len=bl_,
1643
+ causal=False,
1644
+ qo_indptr_buf=self._plan_qo_indptr,
1645
+ paged_kv_indptr_buf=self._plan_paged_kv_indptr,
1646
+ paged_kv_indices_buf=self._plan_paged_kv_indices,
1647
+ paged_kv_last_page_len_buf=self._plan_paged_kv_last_page_len,
1648
+ )
1649
+ self._last_plan_cache_start = cs_key
1650
+
1651
+ self._cuda_graph.replay()
1652
+
1653
+ logits = (
1654
+ self._g_logits if self._has_speech_head else None
1655
+ )
1656
+ return self._g_output_hidden, logits
runtime/chatterbox-flash/chatterbox_flash/engines/mlx.py ADDED
@@ -0,0 +1,621 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MLX (Apple Silicon) inference engine for Chatterbox-Flash.
2
+
3
+ Runs the LLaMA backbone of the T3 decoder natively on Metal via the
4
+ `mlx <https://github.com/ml-explore/mlx>`_ framework, while keeping the
5
+ small auxiliary modules (speech / text / cond embeddings, speech head,
6
+ conditioning encoder) on PyTorch. The PyTorch ↔ MLX boundary is at
7
+ the engine surface, so the block-diffusion ``generate()`` loop does not
8
+ need to know about MLX at all.
9
+
10
+ Memory layout
11
+ -------------
12
+ * T3 conditioning encoder + speech_emb + text_emb + speech_head live
13
+ on the PyTorch device (CPU on a Mac; the host model is loaded with
14
+ ``device='cpu'`` for the MLX backend).
15
+ * The LLaMA stack (embed-free, 30 layers for Llama_520M) is rebuilt in
16
+ MLX, with weights converted once from the PyTorch ``state_dict``.
17
+ * A per-layer KV cache lives entirely inside MLX.
18
+
19
+ Tentative-then-commit pattern
20
+ -----------------------------
21
+ The block-diffusion loop calls :meth:`block_forward` multiple times for
22
+ the same block (one per inner-loop step), each time overwriting the
23
+ previous step's tentative KV. We implement this by tracking a
24
+ *committed* length per layer and cropping the cache back to that length
25
+ at the start of each block forward. :meth:`advance_cache` bumps the
26
+ committed length so the next block sees the just-written entries as
27
+ real prefix.
28
+
29
+ Status
30
+ ------
31
+ Experimental. Tested with mlx ≥ 0.13 and mlx-lm ≥ 0.10. This module
32
+ imports MLX lazily; the rest of the package works without MLX installed.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import importlib.util
38
+ import logging
39
+ from typing import List, Optional, Tuple, Union
40
+
41
+ import numpy as np
42
+ import torch
43
+ from torch import Tensor
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+
48
+ MLX_AVAILABLE: bool = (
49
+ importlib.util.find_spec("mlx") is not None
50
+ and importlib.util.find_spec("mlx_lm") is not None
51
+ )
52
+
53
+
54
+ def _require_mlx() -> tuple["module", "module"]: # type: ignore[name-defined]
55
+ if not MLX_AVAILABLE:
56
+ raise ImportError(
57
+ "MLX backend requires the 'mlx' and 'mlx-lm' packages.\n"
58
+ " Install (Apple Silicon Mac only):\n"
59
+ " pip install mlx mlx-lm\n"
60
+ " or:\n"
61
+ " pip install 'chatterbox-flash[mlx]'",
62
+ )
63
+ import mlx.core as mx
64
+ import mlx_lm # noqa: F401 (force import, validate version)
65
+ return mx, mlx_lm
66
+
67
+
68
+ # --------------------------------------------------------------------------- #
69
+ # Weight conversion: PyTorch HF LlamaModel state_dict → MLX state_dict
70
+ # --------------------------------------------------------------------------- #
71
+
72
+
73
+ def _torch_to_mx(t: torch.Tensor, mx_module): # noqa: ANN001
74
+ """Convert a torch tensor to an mx.array via numpy (zero extra copy)."""
75
+ arr = t.detach().to(torch.float32).cpu().numpy()
76
+ return mx_module.array(arr)
77
+
78
+
79
+ def _convert_llama_weights(
80
+ tfmr_state: dict[str, torch.Tensor],
81
+ mx_module,
82
+ ) -> dict[str, "mx_module.array"]: # type: ignore[name-defined]
83
+ """Convert HF LlamaModel state_dict into an MLX-flat dict.
84
+
85
+ Both PyTorch and mlx-lm store nn.Linear as ``(out, in)`` so no
86
+ transposition is needed. RoPE inv_freq / cos_cached / sin_cached
87
+ buffers are recomputed inside MLX and therefore skipped.
88
+ """
89
+ SKIP_SUBSTRINGS = (
90
+ "rotary_emb.inv_freq",
91
+ "rotary_emb.cos_cached",
92
+ "rotary_emb.sin_cached",
93
+ )
94
+ out: dict[str, object] = {}
95
+ for k, v in tfmr_state.items():
96
+ if any(s in k for s in SKIP_SUBSTRINGS):
97
+ continue
98
+ out[k] = _torch_to_mx(v, mx_module)
99
+ return out
100
+
101
+
102
+ # --------------------------------------------------------------------------- #
103
+ # Embed-free MLX LlamaModel wrapper
104
+ # --------------------------------------------------------------------------- #
105
+
106
+
107
+ class _EmbedFreeLlama:
108
+ """Thin wrapper around mlx-lm's LlamaModel that accepts inputs_embeds.
109
+
110
+ Reuses the entire mlx-lm LLaMA layer stack (incl. RoPE LLaMA3 scaling
111
+ that ships with mlx-lm) but bypasses the input ``embed_tokens`` layer
112
+ — we already have the speech / text / cond embeddings on the
113
+ PyTorch side.
114
+ """
115
+
116
+ def __init__(
117
+ self,
118
+ tfmr_state: dict[str, torch.Tensor],
119
+ hf_config, # noqa: ANN001
120
+ rotary_emb=None, # noqa: ANN001
121
+ ) -> None:
122
+ mx, _ = _require_mlx()
123
+ from mlx_lm.models.llama import LlamaModel, ModelArgs
124
+
125
+ self._mx = mx
126
+ args = ModelArgs(
127
+ model_type="llama",
128
+ hidden_size=int(hf_config.hidden_size),
129
+ num_hidden_layers=int(hf_config.num_hidden_layers),
130
+ intermediate_size=int(hf_config.intermediate_size),
131
+ num_attention_heads=int(hf_config.num_attention_heads),
132
+ num_key_value_heads=int(hf_config.num_key_value_heads),
133
+ rms_norm_eps=float(hf_config.rms_norm_eps),
134
+ vocab_size=int(hf_config.vocab_size),
135
+ head_dim=int(getattr(hf_config, "head_dim", 0))
136
+ or int(hf_config.hidden_size // hf_config.num_attention_heads),
137
+ max_position_embeddings=int(
138
+ getattr(hf_config, "max_position_embeddings", 4096)
139
+ ),
140
+ rope_theta=float(getattr(hf_config, "rope_theta", 10000.0)),
141
+ rope_traditional=False,
142
+ rope_scaling=dict(hf_config.rope_scaling)
143
+ if getattr(hf_config, "rope_scaling", None) is not None
144
+ else None,
145
+ tie_word_embeddings=bool(hf_config.tie_word_embeddings),
146
+ )
147
+ self._args = args
148
+ self._llama = LlamaModel(args)
149
+
150
+ # Convert weights (skip rotary buffers; mlx-lm rebuilds them).
151
+ mlx_state = _convert_llama_weights(tfmr_state, mx)
152
+ from mlx.utils import tree_unflatten
153
+ self._llama.load_weights(list(mlx_state.items()), strict=False)
154
+
155
+ # Override RoPE with HF's *exact* inv_freq. Reconstructing it from the
156
+ # config (rope_theta / rope_scaling) is fragile: across transformers
157
+ # versions ``config.rope_theta`` may be absent, in which case the
158
+ # ModelArgs above silently falls back to base=10000 while HF actually
159
+ # used e.g. 500000 — giving a position-dependent RoPE mismatch that
160
+ # scrambles generation. HF's ``rotary_emb.inv_freq`` already bakes in
161
+ # the base *and* any (llama3 / yarn / …) scaling, so using it verbatim
162
+ # is correct regardless of config layout or scaling type.
163
+ self._override_rope_from_hf(rotary_emb)
164
+
165
+ # Optional weight-only quantization of the backbone (4- / 8-bit).
166
+ # Driven by an env var so the tts/model signatures stay untouched
167
+ # (same convention as CHATTERBOX_FLASH_ENGINE). Must run AFTER
168
+ # load_weights so the fp weights are present to quantize, and only
169
+ # touches this LLaMA stack — S3Gen / VoiceEncoder live on the torch
170
+ # side and are unaffected.
171
+ self._quant_bits = self._maybe_quantize()
172
+
173
+ # Force materialization to catch missing keys early.
174
+ mx.eval(self._llama.parameters())
175
+ self._cache_cls = None
176
+ self._init_cache_cls()
177
+
178
+ def _override_rope_from_hf(self, rotary_emb) -> None: # noqa: ANN001
179
+ """Replace each layer's RoPE with one driven by HF's exact inv_freq."""
180
+ mx = self._mx
181
+ if rotary_emb is None or not hasattr(rotary_emb, "inv_freq"):
182
+ logger.warning(
183
+ "MLX backbone: HF rotary_emb/inv_freq unavailable; falling back "
184
+ "to config-reconstructed RoPE (may mismatch if config lacks "
185
+ "rope_theta).",
186
+ )
187
+ return
188
+
189
+ import numpy as _np
190
+ import mlx.nn as nn
191
+
192
+ inv = rotary_emb.inv_freq.detach().to(torch.float32).cpu().numpy()
193
+ # mx.fast.rope takes per-pair ``freqs`` = wavelengths = 1 / inv_freq.
194
+ freqs = mx.array((1.0 / inv).astype(_np.float32))
195
+ att_scale = float(getattr(rotary_emb, "attention_scaling", 1.0) or 1.0)
196
+ dims = int(self._args.head_dim)
197
+ traditional = bool(self._args.rope_traditional)
198
+
199
+ class _HFRoPE(nn.Module):
200
+ def __init__(self) -> None:
201
+ super().__init__()
202
+ self.dims = dims
203
+ self._freqs = freqs
204
+ self.scale = att_scale
205
+ self.traditional = traditional
206
+
207
+ def __call__(self, x, offset=0): # noqa: ANN001
208
+ y = mx.fast.rope(
209
+ x, self.dims, traditional=self.traditional,
210
+ base=None, scale=1.0, offset=offset, freqs=self._freqs,
211
+ )
212
+ return y if self.scale == 1.0 else y * self.scale
213
+
214
+ for layer in self._llama.layers:
215
+ layer.self_attn.rope = _HFRoPE()
216
+ logger.info(
217
+ "MLX backbone RoPE overridden from HF inv_freq "
218
+ "(dims=%d, attention_scaling=%.4f).", dims, att_scale,
219
+ )
220
+
221
+ def _maybe_quantize(self) -> Optional[int]:
222
+ import os
223
+
224
+ raw = os.environ.get("CHATTERBOX_FLASH_MLX_QUANT_BITS", "").strip()
225
+ if not raw:
226
+ return None
227
+ try:
228
+ bits = int(raw)
229
+ except ValueError:
230
+ logger.warning("Ignoring invalid CHATTERBOX_FLASH_MLX_QUANT_BITS=%r", raw)
231
+ return None
232
+ if bits not in (2, 3, 4, 6, 8):
233
+ logger.warning("Unsupported MLX quant bits=%d; skipping.", bits)
234
+ return None
235
+ group_size = int(os.environ.get("CHATTERBOX_FLASH_MLX_QUANT_GROUP", "64"))
236
+
237
+ import mlx.nn as nn
238
+
239
+ # Skip layers whose dims aren't divisible by group_size — mlx requires
240
+ # in_features % group_size == 0, and the speech/text heads live on the
241
+ # torch side anyway, so any odd-shaped Linear here is left in fp.
242
+ def _can_quant(_path: str, module) -> bool: # noqa: ANN001
243
+ if not hasattr(module, "to_quantized"):
244
+ return False
245
+ w = getattr(module, "weight", None)
246
+ return w is not None and w.ndim == 2 and (w.shape[-1] % group_size == 0)
247
+
248
+ nn.quantize(
249
+ self._llama, group_size=group_size, bits=bits,
250
+ class_predicate=_can_quant,
251
+ )
252
+ logger.info(
253
+ "MLX backbone quantized to %d-bit (group_size=%d).", bits, group_size,
254
+ )
255
+ return bits
256
+
257
+ def _init_cache_cls(self) -> None:
258
+ try:
259
+ from mlx_lm.models.cache import KVCache
260
+ self._cache_cls = KVCache
261
+ except ImportError: # very old mlx-lm
262
+ from mlx_lm.models.base import KVCache # type: ignore
263
+ self._cache_cls = KVCache
264
+
265
+ def make_cache(self) -> list:
266
+ return [self._cache_cls() for _ in range(self._args.num_hidden_layers)]
267
+
268
+ def forward(
269
+ self,
270
+ inputs_embeds_mx,
271
+ cache: list,
272
+ *,
273
+ mask=None,
274
+ ):
275
+ """Run the LLaMA stack on continuous embeddings, return hidden_states."""
276
+ mx = self._mx
277
+ x = inputs_embeds_mx
278
+ # Manually mirror mlx-lm LlamaModel.__call__ but without embed_tokens.
279
+ for layer, c in zip(self._llama.layers, cache):
280
+ x = layer(x, mask=mask, cache=c)
281
+ x = self._llama.norm(x)
282
+ return x
283
+
284
+
285
+ # --------------------------------------------------------------------------- #
286
+ # Engine
287
+ # --------------------------------------------------------------------------- #
288
+
289
+
290
+ CacheStarts = Union[int, List[int]]
291
+
292
+
293
+ class MLXEngine:
294
+ """Apple-Silicon MLX engine. Implements the chatterbox_flash engine protocol."""
295
+
296
+ has_cuda_graph: bool = False # MLX has no CUDA-graph concept
297
+
298
+ def __init__(
299
+ self,
300
+ model, # ChatterboxFlashT3
301
+ max_seq_len: int,
302
+ dtype: torch.dtype,
303
+ *,
304
+ batch_size: int = 1,
305
+ page_size: int = 16, # unused; protocol parity
306
+ ) -> None:
307
+ self._model = model
308
+ self._max_seq_len = int(max_seq_len)
309
+ self._dtype = dtype
310
+ self._batch_size = max(1, int(batch_size))
311
+ self._device = model.device
312
+
313
+ mx, _ = _require_mlx()
314
+ self._mx = mx
315
+ if dtype == torch.bfloat16:
316
+ self._mx_dtype = mx.bfloat16
317
+ elif dtype == torch.float16:
318
+ self._mx_dtype = mx.float16
319
+ else:
320
+ self._mx_dtype = mx.float32
321
+
322
+ # Build the MLX backbone exactly once per engine instance.
323
+ tfmr_state = {k: v for k, v in model.tfmr.state_dict().items()}
324
+ self._backbone = _EmbedFreeLlama(
325
+ tfmr_state, model.tfmr.config,
326
+ rotary_emb=getattr(model.tfmr, "rotary_emb", None),
327
+ )
328
+ self._cache = self._backbone.make_cache()
329
+
330
+ # Per-row batch-1 caches, populated only on the ragged (cross-text /
331
+ # per-row prefix length) path. A single dense batch-B cache cannot
332
+ # represent rows with differing sequence lengths, so the ragged path
333
+ # serializes over these instead of slicing ``self._cache`` live.
334
+ self._row_caches: Optional[List[list]] = None
335
+
336
+ # Per-row bookkeeping. MLX's KV cache is shared across the batch
337
+ # dim (same as the SDPA path) so this is uniform across rows.
338
+ self._cache_lens: List[int] = [0] * self._batch_size
339
+ self._prefix_lens: List[int] = [0] * self._batch_size
340
+
341
+ # ------------------------------------------------------------------ #
342
+ # Introspection / lifecycle
343
+ # ------------------------------------------------------------------ #
344
+
345
+ @property
346
+ def batch_size(self) -> int:
347
+ return self._batch_size
348
+
349
+ @property
350
+ def cache_len(self) -> int:
351
+ return self._cache_lens[0]
352
+
353
+ @cache_len.setter
354
+ def cache_len(self, value: int) -> None:
355
+ self._cache_lens = [int(value)] * self._batch_size
356
+
357
+ @property
358
+ def cache_lens(self) -> List[int]:
359
+ return self._cache_lens
360
+
361
+ @property
362
+ def prefix_lens(self) -> List[int]:
363
+ return self._prefix_lens
364
+
365
+ def reset(self) -> None:
366
+ self._cache = self._backbone.make_cache()
367
+ self._row_caches = None
368
+ self._cache_lens = [0] * self._batch_size
369
+ self._prefix_lens = [0] * self._batch_size
370
+
371
+ def can_reuse(
372
+ self,
373
+ max_seq_len: int,
374
+ dtype: torch.dtype,
375
+ *,
376
+ batch_size: int = 1,
377
+ page_size: int = 16,
378
+ ) -> bool:
379
+ return (
380
+ self._max_seq_len >= max_seq_len
381
+ and self._dtype == dtype
382
+ and self._batch_size == max(1, batch_size)
383
+ )
384
+
385
+ # ------------------------------------------------------------------ #
386
+ # Tensor boundary
387
+ # ------------------------------------------------------------------ #
388
+
389
+ def _torch_to_mx(self, t: Tensor):
390
+ """torch (cpu / mps / cuda) → mx.array (Metal). Routes via numpy on host."""
391
+ return self._mx.array(t.detach().to(torch.float32).cpu().numpy()).astype(
392
+ self._mx_dtype,
393
+ )
394
+
395
+ def _mx_to_torch(self, a) -> Tensor:
396
+ """mx.array → torch on the host device, restoring our dtype."""
397
+ # mx.array → numpy → torch (always lands on the model's device).
398
+ arr = np.array(a.astype(self._mx.float32))
399
+ return torch.from_numpy(arr).to(device=self._device, dtype=self._dtype)
400
+
401
+ # ------------------------------------------------------------------ #
402
+ # KV-cache cropping (tentative-then-commit pattern)
403
+ # ------------------------------------------------------------------ #
404
+
405
+ def _crop_cache(self, cache: list, length: int) -> None:
406
+ """Truncate every layer of ``cache`` to ``length`` time steps."""
407
+ mx = self._mx
408
+ for c in cache:
409
+ # mlx-lm's KVCache exposes ``keys`` / ``values`` (shape
410
+ # ``(B, n_kv_h, T, head_dim)``) and an ``offset`` int.
411
+ if c.keys is None:
412
+ continue
413
+ c.keys = c.keys[..., :length, :]
414
+ c.values = c.values[..., :length, :]
415
+ c.offset = int(length)
416
+ mx.eval(*[(c.keys, c.values) for c in cache if c.keys is not None])
417
+
418
+ def _crop_cache_to(self, length: int) -> None:
419
+ """Truncate the shared (uniform-path) KV cache to ``length`` steps."""
420
+ self._crop_cache(self._cache, length)
421
+
422
+ # ------------------------------------------------------------------ #
423
+ # Prefix forward
424
+ # ------------------------------------------------------------------ #
425
+
426
+ def prefix_forward(
427
+ self,
428
+ prefix_emb_or_list: Union[Tensor, List[Tensor]],
429
+ ) -> Tensor:
430
+ """Run the cond/text/SOS prefix once and seed the cache."""
431
+ if isinstance(prefix_emb_or_list, list):
432
+ return self._prefix_forward_ragged(prefix_emb_or_list)
433
+
434
+ prefix_emb = prefix_emb_or_list
435
+ B_pfx = prefix_emb.size(0)
436
+ if B_pfx not in (1, self._batch_size):
437
+ raise ValueError(
438
+ f"MLX prefix_forward: prefix batch {B_pfx} must be 1 or "
439
+ f"engine batch {self._batch_size}",
440
+ )
441
+ if B_pfx == 1 and self._batch_size > 1:
442
+ prefix_emb = prefix_emb.expand(self._batch_size, -1, -1).contiguous()
443
+
444
+ lp = int(prefix_emb.size(1))
445
+ # MLX expects a 3D input (B, T, D).
446
+ x_mx = self._torch_to_mx(prefix_emb)
447
+ # The conditioning prefix must be processed CAUSALLY (matching the HF
448
+ # SDPA path, which gets an implicit causal mask). _EmbedFreeLlama
449
+ # bypasses LlamaModel.__call__, so mask=None would leave the prefix
450
+ # fully bidirectional and corrupt the cached context. Use mlx's native
451
+ # "causal" string mask — exactly what mlx-lm passes for a causal
452
+ # forward — so it is dtype-safe regardless of the backbone weight dtype.
453
+ out_mx = self._backbone.forward(x_mx, self._cache, mask="causal")
454
+ self._mx.eval(out_mx)
455
+
456
+ for bi in range(self._batch_size):
457
+ self._cache_lens[bi] = lp
458
+ self._prefix_lens[bi] = lp
459
+
460
+ # SOS is at index lp - 1.
461
+ shift_ctx = self._mx_to_torch(out_mx[:, lp - 1 : lp, :])
462
+ return shift_ctx.contiguous()
463
+
464
+ def _prefix_forward_ragged(self, emb_list: List[Tensor]) -> Tensor:
465
+ """Pad-and-mask path for per-row prefix lengths (cross-text / null prefix)."""
466
+ if len(emb_list) == 1:
467
+ return self.prefix_forward(emb_list[0])
468
+
469
+ mx = self._mx
470
+ B = len(emb_list)
471
+ if B != self._batch_size:
472
+ raise ValueError(
473
+ f"MLX ragged prefix: list length {B} != engine batch "
474
+ f"{self._batch_size}",
475
+ )
476
+ lp_list = [int(e.size(1)) for e in emb_list]
477
+ lp_max = max(lp_list)
478
+ dim = emb_list[0].size(-1)
479
+
480
+ padded = torch.zeros(B, lp_max, dim, dtype=self._dtype, device=self._device)
481
+ for bi, e in enumerate(emb_list):
482
+ padded[bi, : e.size(1)] = e.squeeze(0).to(self._dtype)
483
+
484
+ # Additive padding mask, combined with causal triangular mask.
485
+ x_mx = self._torch_to_mx(padded)
486
+ neg_inf = mx.array(-1e9, dtype=self._mx_dtype)
487
+ pad_mask = mx.zeros((B, lp_max), dtype=self._mx_dtype)
488
+ for bi, lp_i in enumerate(lp_list):
489
+ if lp_i < lp_max:
490
+ pad_mask[bi, lp_i:] = neg_inf
491
+ causal = mx.triu(
492
+ mx.full((lp_max, lp_max), neg_inf, dtype=self._mx_dtype),
493
+ k=1,
494
+ )
495
+ attn_mask = pad_mask[:, None, None, :] + causal[None, None]
496
+ out_mx = self._backbone.forward(x_mx, self._cache, mask=attn_mask)
497
+ mx.eval(out_mx)
498
+
499
+ for bi, lp_i in enumerate(lp_list):
500
+ self._cache_lens[bi] = lp_i
501
+ self._prefix_lens[bi] = lp_i
502
+
503
+ # Split the padded batch-B prefix cache into per-row batch-1 caches,
504
+ # each trimmed to that row's *valid* prefix length (the padded tail
505
+ # carries masked garbage and must be dropped). The ragged
506
+ # ``block_forward`` serializes over these so a batch-1 block input
507
+ # never gets concatenated against batch-B cache keys.
508
+ row_caches: List[list] = []
509
+ for bi, lp_i in enumerate(lp_list):
510
+ rc = self._backbone.make_cache()
511
+ for src, dst in zip(self._cache, rc):
512
+ if src.keys is None:
513
+ continue
514
+ dst.keys = src.keys[bi : bi + 1, :, :lp_i, :]
515
+ dst.values = src.values[bi : bi + 1, :, :lp_i, :]
516
+ dst.offset = int(lp_i)
517
+ mx.eval(*[(c.keys, c.values) for c in rc if c.keys is not None])
518
+ row_caches.append(rc)
519
+ self._row_caches = row_caches
520
+
521
+ # SOS row index per sample (lp_i - 1). Gather on the torch side.
522
+ hidden = self._mx_to_torch(out_mx)
523
+ idx = torch.tensor(
524
+ [lp_i - 1 for lp_i in lp_list], device=self._device, dtype=torch.long,
525
+ )
526
+ shift_ctx = hidden.gather(
527
+ 1, idx[:, None, None].expand(B, 1, hidden.size(-1)),
528
+ )
529
+ return shift_ctx.contiguous()
530
+
531
+ # ------------------------------------------------------------------ #
532
+ # Block forward
533
+ # ------------------------------------------------------------------ #
534
+
535
+ def block_forward(
536
+ self,
537
+ block_emb: Tensor,
538
+ cache_starts: CacheStarts,
539
+ ) -> Tensor:
540
+ """Tentative block forward; KV is written, advance_cache commits it."""
541
+ B = int(block_emb.size(0))
542
+ bl = int(block_emb.size(1))
543
+
544
+ if isinstance(cache_starts, int):
545
+ cs_uniform: Optional[int] = cache_starts
546
+ else:
547
+ cs_list = list(cache_starts)
548
+ if len(set(cs_list)) == 1:
549
+ cs_uniform = cs_list[0]
550
+ else:
551
+ cs_uniform = None
552
+
553
+ if cs_uniform is not None:
554
+ self._crop_cache_to(int(cs_uniform))
555
+ x_mx = self._torch_to_mx(block_emb)
556
+ # mlx-lm builds a causal mask internally when T > 1 and the
557
+ # cache has an offset; the block sees the cropped prefix +
558
+ # itself. No explicit mask needed here.
559
+ out_mx = self._backbone.forward(x_mx, self._cache)
560
+ self._mx.eval(out_mx)
561
+ return self._mx_to_torch(out_mx)
562
+
563
+ # Ragged: per-row cache_starts differ (cross-text / null-prefix).
564
+ # mlx-lm exposes no per-row position offset, and a dense batch-B
565
+ # cache cannot hold rows of differing length, so we serialize over
566
+ # the per-row batch-1 caches seeded at prefix time. Each row's
567
+ # cache is cropped to that row's committed length (cs_i), the block
568
+ # is forwarded tentatively, and the write persists in the row cache
569
+ # for the next step / commit — mirroring the uniform path per row.
570
+ # We deliberately serialize to keep correctness > throughput.
571
+ mx = self._mx
572
+ if self._row_caches is None:
573
+ raise RuntimeError(
574
+ "MLX ragged block_forward requires per-row caches; "
575
+ "prefix_forward must run with a per-row prefix list first.",
576
+ )
577
+ outs = []
578
+ for bi, cs_i in enumerate(cs_list): # type: ignore[name-defined]
579
+ rc = self._row_caches[bi]
580
+ self._crop_cache(rc, int(cs_i))
581
+ x_mx_i = self._torch_to_mx(block_emb[bi : bi + 1])
582
+ out_mx_i = self._backbone.forward(x_mx_i, rc)
583
+ mx.eval(out_mx_i)
584
+ outs.append(self._mx_to_torch(out_mx_i))
585
+ return torch.cat(outs, dim=0)
586
+
587
+ def block_forward_graph(
588
+ self,
589
+ block_emb: Tensor,
590
+ cache_starts: CacheStarts,
591
+ ) -> Tuple[Tensor, Optional[Tensor]]:
592
+ """No graph capture on MLX → delegate to eager."""
593
+ return self.block_forward(block_emb, cache_starts), None
594
+
595
+ def advance_cache(self, block_len: int) -> None:
596
+ """Commit ``block_len`` newly-written entries."""
597
+ for bi in range(self._batch_size):
598
+ self._cache_lens[bi] += int(block_len)
599
+
600
+ def set_shift_ctx(self, ctx: Tensor) -> None: # noqa: D401
601
+ """No graph buffer to stage; the caller folds shift_ctx in-line."""
602
+ return None
603
+
604
+ def capture_cuda_graph(
605
+ self,
606
+ block_size: int,
607
+ *,
608
+ speech_head: Optional[torch.nn.Linear] = None,
609
+ ) -> None: # noqa: D401
610
+ """MLX has no CUDA-graph concept; intentionally a no-op."""
611
+ logger.debug(
612
+ "MLXEngine.capture_cuda_graph(block_size=%d) is a no-op.",
613
+ block_size,
614
+ )
615
+ return None
616
+
617
+
618
+ __all__ = [
619
+ "MLXEngine",
620
+ "MLX_AVAILABLE",
621
+ ]
runtime/chatterbox-flash/chatterbox_flash/engines/torch_sdpa.py ADDED
@@ -0,0 +1,372 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pure-PyTorch fallback inference engine for Chatterbox-Flash.
2
+
3
+ Implements the same engine protocol as :class:`FlashInferEngine` but on
4
+ top of the stock HuggingFace transformers ``LlamaModel`` SDPA backend.
5
+ No CUDA-graph capture, no paged buffer, no flashinfer kernels — just
6
+ ``transformers.DynamicCache`` with the standard causal/bidirectional
7
+ attention masks.
8
+
9
+ This is the engine selected automatically when ``flashinfer-python`` is
10
+ not importable, e.g. on Ampere/Turing GPUs or in environments where the
11
+ flashinfer wheels aren't available. It produces numerically matching
12
+ outputs (up to fp32/bf16 reduction-order differences) at lower
13
+ throughput.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ from typing import List, Optional, Tuple, Union
20
+
21
+ import torch
22
+ import torch.nn.functional as F
23
+ from torch import Tensor
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ CacheStarts = Union[int, List[int]]
29
+
30
+
31
+ def _crop_dynamic_cache(cache, max_length: int) -> None:
32
+ """Truncate a :class:`transformers.DynamicCache` to ``max_length`` tokens.
33
+
34
+ Compatible with both the new ``cache.crop()`` API (transformers ≥ 4.46)
35
+ and the older direct-tensor layout.
36
+ """
37
+ if max_length is None:
38
+ return
39
+ if hasattr(cache, "crop"):
40
+ cache.crop(max_length)
41
+ else:
42
+ for li in range(len(cache.key_cache)):
43
+ cache.key_cache[li] = cache.key_cache[li][:, :, :max_length, :]
44
+ cache.value_cache[li] = cache.value_cache[li][:, :, :max_length, :]
45
+ cache._seen_tokens = max_length
46
+
47
+
48
+ class TorchSDPAEngine:
49
+ """Pure-PyTorch SDPA + :class:`DynamicCache` fallback engine."""
50
+
51
+ has_cuda_graph: bool = False
52
+
53
+ def __init__(
54
+ self,
55
+ model,
56
+ max_seq_len: int,
57
+ dtype: torch.dtype,
58
+ *,
59
+ batch_size: int = 1,
60
+ page_size: int = 16, # ignored; protocol parity
61
+ ) -> None:
62
+ from transformers import DynamicCache # local import; lazy
63
+
64
+ self._model = model
65
+ self._max_seq_len = int(max_seq_len)
66
+ self._dtype = dtype
67
+ self._batch_size = max(1, int(batch_size))
68
+ self._device = model.device
69
+
70
+ self._DynamicCache = DynamicCache
71
+
72
+ self._dc = DynamicCache()
73
+ # ``cache_lens[bi]`` is the *committed* (advance_cache-ed) length per
74
+ # sample. In the SDPA path the underlying DynamicCache is uniform
75
+ # across the batch, so the per-sample bookkeeping is mostly for
76
+ # protocol parity with FlashInfer; the per-sample length is read only
77
+ # by capacity checks / introspection.
78
+ self._cache_lens: List[int] = [0] * self._batch_size
79
+ self._prefix_lens: List[int] = [0] * self._batch_size
80
+
81
+ # ------------------------------------------------------------------ #
82
+ # Introspection / lifecycle
83
+ # ------------------------------------------------------------------ #
84
+
85
+ @property
86
+ def batch_size(self) -> int:
87
+ return self._batch_size
88
+
89
+ @property
90
+ def cache_len(self) -> int:
91
+ """Committed cache length (uniform across the batch under SDPA)."""
92
+ return self._cache_lens[0]
93
+
94
+ @cache_len.setter
95
+ def cache_len(self, value: int) -> None:
96
+ self._cache_lens = [int(value)] * self._batch_size
97
+
98
+ @property
99
+ def cache_lens(self) -> List[int]:
100
+ return self._cache_lens
101
+
102
+ @property
103
+ def prefix_lens(self) -> List[int]:
104
+ return self._prefix_lens
105
+
106
+ def reset(self) -> None:
107
+ self._dc = self._DynamicCache()
108
+ self._cache_lens = [0] * self._batch_size
109
+ self._prefix_lens = [0] * self._batch_size
110
+
111
+ def can_reuse(
112
+ self,
113
+ max_seq_len: int,
114
+ dtype: torch.dtype,
115
+ *,
116
+ batch_size: int = 1,
117
+ page_size: int = 16,
118
+ ) -> bool:
119
+ return (
120
+ self._max_seq_len >= max_seq_len
121
+ and self._dtype == dtype
122
+ and self._batch_size == max(1, batch_size)
123
+ )
124
+
125
+ def snapshot_kv(self):
126
+ """Lightweight snapshot for CFG uncond restore."""
127
+ ks = [t.clone() for t in self._dc.key_cache]
128
+ vs = [t.clone() for t in self._dc.value_cache]
129
+ return (ks, vs, list(self._cache_lens), list(self._prefix_lens))
130
+
131
+ def restore_kv(self, snap) -> None:
132
+ ks, vs, cl, pl = snap
133
+ self._dc = self._DynamicCache()
134
+ for k, v in zip(ks, vs):
135
+ self._dc.key_cache.append(k.clone())
136
+ self._dc.value_cache.append(v.clone())
137
+ self._dc._seen_tokens = ks[0].shape[-2] if ks else 0
138
+ self._cache_lens = list(cl)
139
+ self._prefix_lens = list(pl)
140
+
141
+ # ------------------------------------------------------------------ #
142
+ # Prefix forward
143
+ # ------------------------------------------------------------------ #
144
+
145
+ def prefix_forward(
146
+ self,
147
+ prefix_emb_or_list: Union[Tensor, List[Tensor]],
148
+ ) -> Tensor:
149
+ """Run the cond/text/SOS prefix once and seed the cache."""
150
+ if isinstance(prefix_emb_or_list, list):
151
+ return self._prefix_forward_ragged(prefix_emb_or_list)
152
+
153
+ prefix_emb = prefix_emb_or_list
154
+ B_pfx = prefix_emb.size(0)
155
+ if B_pfx not in (1, self._batch_size):
156
+ raise ValueError(
157
+ f"SDPA prefix_forward: prefix batch {B_pfx} must be 1 or "
158
+ f"engine batch {self._batch_size}",
159
+ )
160
+ if B_pfx == 1 and self._batch_size > 1:
161
+ prefix_emb = prefix_emb.expand(self._batch_size, -1, -1).contiguous()
162
+
163
+ lp = prefix_emb.size(1)
164
+ device = self._device
165
+ pos_ids = (
166
+ torch.arange(lp, device=device)
167
+ .unsqueeze(0)
168
+ .expand(self._batch_size, -1)
169
+ .contiguous()
170
+ )
171
+
172
+ cfg = self._model.tfmr.config
173
+ old_impl = getattr(cfg, "_attn_implementation", "sdpa")
174
+ cfg._attn_implementation = "sdpa"
175
+ try:
176
+ out = self._model.tfmr(
177
+ input_ids=None,
178
+ inputs_embeds=prefix_emb.to(self._dtype),
179
+ position_ids=pos_ids,
180
+ past_key_values=self._dc,
181
+ use_cache=True,
182
+ return_dict=True,
183
+ )
184
+ finally:
185
+ cfg._attn_implementation = old_impl
186
+
187
+ self._dc = out.past_key_values
188
+ for bi in range(self._batch_size):
189
+ self._cache_lens[bi] = lp
190
+ self._prefix_lens[bi] = lp
191
+
192
+ shift_ctx = out.last_hidden_state[:, lp - 1 : lp, :].contiguous()
193
+ return shift_ctx
194
+
195
+ def _prefix_forward_ragged(self, emb_list: List[Tensor]) -> Tensor:
196
+ """Cross-text path: pad to max length, mask out padding in attention.
197
+
198
+ Each sample gets its own prefix length; the cache is built from a
199
+ left-padded forward. Padding tokens are attention-masked so they
200
+ contribute zero KV, but the cache slots still exist — downstream
201
+ block forwards know to skip them via per-sample ``cache_starts``.
202
+ """
203
+ if len(emb_list) == 1:
204
+ return self.prefix_forward(emb_list[0])
205
+
206
+ B = len(emb_list)
207
+ if B != self._batch_size:
208
+ raise ValueError(
209
+ f"SDPA ragged prefix: list length {B} != engine batch "
210
+ f"{self._batch_size}",
211
+ )
212
+ lp_list = [int(e.size(1)) for e in emb_list]
213
+ lp_max = max(lp_list)
214
+ dim = emb_list[0].size(-1)
215
+ device = self._device
216
+
217
+ padded = torch.zeros(B, lp_max, dim, device=device, dtype=self._dtype)
218
+ for bi, e in enumerate(emb_list):
219
+ padded[bi, : e.size(1)] = e.to(self._dtype).squeeze(0)
220
+ # Additive mask: 0 for visible, -inf for padding (additive on attn scores).
221
+ # transformers SDPA expects 4D mask (B, 1, Q, K).
222
+ pad_mask = torch.zeros(B, lp_max, device=device, dtype=self._dtype)
223
+ for bi, lp_i in enumerate(lp_list):
224
+ if lp_i < lp_max:
225
+ pad_mask[bi, lp_i:] = float("-inf")
226
+ attn_mask = pad_mask[:, None, None, :].expand(B, 1, lp_max, lp_max)
227
+ # Combine with causal: HF SDPA does causal internally if attention_mask
228
+ # is 2D (B, K); for the 4D additive mask path we have to OR in causal.
229
+ causal = torch.triu(
230
+ torch.full((lp_max, lp_max), float("-inf"), device=device, dtype=self._dtype),
231
+ diagonal=1,
232
+ )
233
+ attn_mask = attn_mask + causal[None, None]
234
+
235
+ pos_ids = (
236
+ torch.arange(lp_max, device=device).unsqueeze(0).expand(B, -1).contiguous()
237
+ )
238
+
239
+ cfg = self._model.tfmr.config
240
+ old_impl = getattr(cfg, "_attn_implementation", "sdpa")
241
+ cfg._attn_implementation = "sdpa"
242
+ try:
243
+ out = self._model.tfmr(
244
+ input_ids=None,
245
+ inputs_embeds=padded,
246
+ position_ids=pos_ids,
247
+ past_key_values=self._dc,
248
+ use_cache=True,
249
+ return_dict=True,
250
+ attention_mask=attn_mask,
251
+ )
252
+ finally:
253
+ cfg._attn_implementation = old_impl
254
+
255
+ self._dc = out.past_key_values
256
+ for bi, lp_i in enumerate(lp_list):
257
+ self._cache_lens[bi] = lp_i
258
+ self._prefix_lens[bi] = lp_i
259
+
260
+ # SOS hidden lives at index lp_i - 1 within each row.
261
+ idx = torch.tensor(
262
+ [lp_i - 1 for lp_i in lp_list], device=device, dtype=torch.long,
263
+ )
264
+ shift_ctx = out.last_hidden_state.gather(
265
+ 1,
266
+ idx[:, None, None].expand(B, 1, out.last_hidden_state.size(-1)),
267
+ )
268
+ return shift_ctx.contiguous()
269
+
270
+ # ------------------------------------------------------------------ #
271
+ # Block forward
272
+ # ------------------------------------------------------------------ #
273
+
274
+ def block_forward(
275
+ self,
276
+ block_emb: Tensor,
277
+ cache_starts: CacheStarts,
278
+ ) -> Tensor:
279
+ """Transient block forward; KV is written but not committed.
280
+
281
+ Caller must re-invoke with the same ``cache_starts`` between
282
+ unmasking steps; the cache is re-cropped each time so the new
283
+ block's keys/values overwrite the previous step's tentative KV.
284
+ """
285
+ B = block_emb.size(0)
286
+ bl = block_emb.size(1)
287
+ device = self._device
288
+ dtype = self._dtype
289
+
290
+ if isinstance(cache_starts, int):
291
+ cs_uniform: Optional[int] = cache_starts
292
+ else:
293
+ cs_list = list(cache_starts)
294
+ if len(set(cs_list)) == 1:
295
+ cs_uniform = cs_list[0]
296
+ else:
297
+ cs_uniform = None
298
+
299
+ if cs_uniform is not None:
300
+ _crop_dynamic_cache(self._dc, cs_uniform)
301
+ pos_ids = (
302
+ torch.arange(cs_uniform, cs_uniform + bl, device=device)
303
+ .unsqueeze(0).expand(B, -1).contiguous()
304
+ )
305
+ kv_len = cs_uniform + bl
306
+ # Full-visible mask within the block (already-causal prefix in cache).
307
+ attn_mask = torch.zeros((1, 1, bl, kv_len), dtype=dtype, device=device)
308
+ else:
309
+ cs_max = max(cs_list)
310
+ _crop_dynamic_cache(self._dc, cs_max)
311
+ # Pad each row's positions to cs_max + bl; mask out the prefix
312
+ # slots beyond cs_i so the block forward only attends to that
313
+ # sample's true prefix.
314
+ pos_rows = torch.zeros(B, bl, device=device, dtype=torch.long)
315
+ for bi, cs in enumerate(cs_list):
316
+ pos_rows[bi] = torch.arange(cs, cs + bl, device=device)
317
+ pos_ids = pos_rows
318
+ kv_len = cs_max + bl
319
+ attn_mask = torch.zeros(B, 1, bl, kv_len, dtype=dtype, device=device)
320
+ for bi, cs in enumerate(cs_list):
321
+ if cs < cs_max:
322
+ attn_mask[bi, 0, :, cs:cs_max] = float("-inf")
323
+
324
+ cfg = self._model.tfmr.config
325
+ old_impl = getattr(cfg, "_attn_implementation", "sdpa")
326
+ cfg._attn_implementation = "sdpa"
327
+ try:
328
+ out = self._model.tfmr(
329
+ input_ids=None,
330
+ inputs_embeds=block_emb.to(dtype),
331
+ position_ids=pos_ids,
332
+ past_key_values=self._dc,
333
+ use_cache=True,
334
+ return_dict=True,
335
+ attention_mask=attn_mask,
336
+ )
337
+ finally:
338
+ cfg._attn_implementation = old_impl
339
+
340
+ self._dc = out.past_key_values
341
+ return out.last_hidden_state
342
+
343
+ def block_forward_graph(
344
+ self,
345
+ block_emb: Tensor,
346
+ cache_starts: CacheStarts,
347
+ ) -> Tuple[Tensor, Optional[Tensor]]:
348
+ """SDPA has no CUDA-graph fast path → delegate to eager."""
349
+ return self.block_forward(block_emb, cache_starts), None
350
+
351
+ def advance_cache(self, block_len: int) -> None:
352
+ """Commit ``block_len`` newly-written entries; bump per-sample lens."""
353
+ for bi in range(self._batch_size):
354
+ self._cache_lens[bi] += int(block_len)
355
+
356
+ def set_shift_ctx(self, ctx: Tensor) -> None: # noqa: D401
357
+ """No-op; the SDPA path computes the shift in-line in the caller."""
358
+ return None
359
+
360
+ def capture_cuda_graph(
361
+ self,
362
+ block_size: int,
363
+ *,
364
+ speech_head: Optional[torch.nn.Linear] = None,
365
+ ) -> None: # noqa: D401
366
+ """No CUDA-graph support; intentionally a no-op."""
367
+ logger.debug(
368
+ "TorchSDPAEngine.capture_cuda_graph(block_size=%d) is a no-op "
369
+ "(SDPA backend has no CUDA-graph fast path).",
370
+ block_size,
371
+ )
372
+ return None
runtime/chatterbox-flash/chatterbox_flash/model.py ADDED
@@ -0,0 +1,936 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``ChatterboxFlashT3`` — block-diffusion T3 decoder for Chatterbox-Flash.
2
+
3
+ We extend Chatterbox-TTS's :class:`~chatterbox.models.t3.t3.T3` decoder with a
4
+ single extra ``[MASK]`` token (``id = V``) added to the speech embedding, and
5
+ replace the autoregressive ``inference()`` path with a block-diffusion
6
+ ``generate()`` method.
7
+
8
+ The decoder is otherwise unchanged: same Llama backbone, same conditioning
9
+ encoder, same text / speech heads. This keeps the released checkpoint
10
+ compatible with the public ``chatterbox-tts`` weights for everything but the
11
+ expanded ``speech_emb`` (which is initialized by copying the V rows from the
12
+ pretrained checkpoint and adding one normal-initialized row for ``[MASK]``).
13
+
14
+ The ``generate()`` method here implements the production configuration
15
+ described in the paper:
16
+
17
+ * dual-stream-free single-stream KV cache (``[prefix | speech]``);
18
+ * FlashInfer paged KV cache + CUDA-graph capture of the per-block forward;
19
+ * zero-text-batch classifier-free guidance with ``zero_all`` null prefix
20
+ (text and conditioning zeroed, speech-x_t duplicated); the only
21
+ PMI/CFG combination is ``pmi_cfg`` — ``(1+w)·pmi_c − w·pmi_u`` on the
22
+ PMI scale, no other mode exposed;
23
+ * **pmi_count_early** outlier rule — OmniVoice's r_n count schedule
24
+ (controlled by ``omnivoice_schedule_t_shift``) as the per-step
25
+ **floor**, plus the paper's time-shifted PMI-quantile
26
+ ``q_k = max(0, 1 - τ · (k+1)/K)`` as an early-decoding **ceiling**.
27
+ When ``time_shift_tau == 0`` the schedule reduces to plain pmi_count
28
+ (fixed-K, no early decoding);
29
+ * prior-calibrated PMI scoring
30
+ ``s_i = log p_i(\\hat x_i) - log p̄(\\hat x_i)`` with the **precomputed
31
+ unconditional block prior** ``p̄`` — a forward on
32
+ ``[SOS, MASK × block_size]`` with no conditioning, lazy-cached on the
33
+ model object so it's evaluated at most once per
34
+ ``(block_size, dtype, device)`` triple over the lifetime of the model;
35
+ * optional Gumbel ``position_temperature`` perturbing the PMI ranking
36
+ for stochastic top-k position selection (OmniVoice §3.4 uses T=5).
37
+
38
+ Training / experimental code paths (full block-diffusion forward, dual-stream,
39
+ speaker probe, magi attention, sdpa/flex inference backends, alternative CFG
40
+ modes and outlier methods, remasking, …) are intentionally omitted.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import contextlib
46
+ import logging
47
+ import math
48
+ from typing import Literal, Optional
49
+
50
+ import torch
51
+ import torch.nn as nn
52
+ import torch.nn.functional as F
53
+ from torch import Tensor
54
+
55
+ from chatterbox.models.t3.t3 import T3 as _ChatterboxT3
56
+ from chatterbox.models.t3.modules.cond_enc import T3Cond
57
+ from chatterbox.models.t3.modules.t3_config import T3Config
58
+
59
+ from .cfg_guidance import apply_zero_text_cfg_from_logits, pmi_cfg_combine
60
+ from .engines import FLASHINFER_AVAILABLE, InferenceEngine, build_engine
61
+ from .calibration import compute_pmi
62
+
63
+
64
+ logger = logging.getLogger(__name__)
65
+
66
+
67
+ # --------------------------------------------------------------------------- #
68
+ # Sampling helpers
69
+ # --------------------------------------------------------------------------- #
70
+
71
+
72
+ def _gumbel_sample(logits: Tensor, temperature: float) -> Tensor:
73
+ scaled = logits / temperature
74
+ u = torch.rand_like(scaled)
75
+ gumbel = -torch.log(-torch.log(u + 1e-10) + 1e-10)
76
+ return scaled + gumbel
77
+
78
+
79
+ def _sample_at_temperature(
80
+ logits: Tensor,
81
+ temperature: float,
82
+ *,
83
+ sampling: Literal["multinomial", "gumbel"] = "multinomial",
84
+ ) -> Tensor:
85
+ if sampling == "gumbel":
86
+ return _gumbel_sample(logits, temperature).argmax(dim=-1)
87
+ probs = F.softmax(logits / temperature, dim=-1)
88
+ return torch.multinomial(probs, num_samples=1).squeeze(-1)
89
+
90
+
91
+ # --------------------------------------------------------------------------- #
92
+ # Prefix-shaping helpers (CFG ``zero_all`` null branch)
93
+ # --------------------------------------------------------------------------- #
94
+
95
+
96
+ def _cond_emb_zero_all(ce: Tensor) -> Tensor:
97
+ """Zero the entire conditioning prefix (paper's ``zero_all`` null branch)."""
98
+ return torch.zeros_like(ce)
99
+
100
+
101
+ def _zero_text_content_keep_pad(
102
+ text_emb: Tensor,
103
+ text_tokens: Tensor,
104
+ text_token_lens: Tensor | None,
105
+ ) -> Tensor:
106
+ """Null-text replacement: non-pad text positions go to 0, pad positions copy.
107
+
108
+ Pad positions are detected from ``text_tokens == -100`` and / or
109
+ ``text_token_lens`` when supplied. This keeps the sequence length identical
110
+ so the CFG forward can be batched 2x.
111
+ """
112
+ squeeze_batch = text_tokens.ndim == 1
113
+ toks = text_tokens.unsqueeze(0) if squeeze_batch else text_tokens
114
+ emb = text_emb.unsqueeze(0) if text_emb.ndim == 2 else text_emb
115
+ Btxt, Tt = toks.shape[0], toks.size(1)
116
+ device = toks.device
117
+ is_pad = toks.eq(-100)
118
+ if text_token_lens is not None:
119
+ ttl = text_token_lens.to(device=device, dtype=torch.long).reshape(-1)
120
+ if ttl.numel() == 1 and Btxt > 1:
121
+ ttl = ttl.expand(Btxt)
122
+ is_pad = is_pad | (torch.arange(Tt, device=device)[None, :] >= ttl[:, None])
123
+ out = emb.clone()
124
+ out.masked_fill_((~is_pad).unsqueeze(-1), 0.0)
125
+ return out.squeeze(0) if squeeze_batch else out
126
+
127
+
128
+ # --------------------------------------------------------------------------- #
129
+ # OmniVoice §3.4 r_n count schedule
130
+ # --------------------------------------------------------------------------- #
131
+
132
+
133
+ def _omnivoice_unmask_schedule(
134
+ n_total_mask: int,
135
+ num_steps: int,
136
+ t_shift: float,
137
+ ) -> list[int]:
138
+ """Per-step unmask **counts** under OmniVoice's time-shifted ``r_n`` curve.
139
+
140
+ Cumulative fraction unmasked by the end of step ``s`` is
141
+ ``t_s = r_n(s/K, τ)`` where
142
+ ``r_n(s, τ) = τ * s / (1 + (τ - 1) * s)`` (eq. (3) of OmniVoice).
143
+ We then convert the cumulative target into per-step integer counts by
144
+ cumulative rounding (``target = round(N * t_{s+1})``,
145
+ ``num_s = max(0, target - cum)``), capped at the remaining masks.
146
+ The last step always closes out whatever is left.
147
+
148
+ Returns a list of length ``num_steps`` summing to ``n_total_mask``.
149
+ """
150
+ K = max(1, int(num_steps))
151
+ N = max(0, int(n_total_mask))
152
+ if N == 0:
153
+ return [0] * K
154
+ if K == 1:
155
+ return [N]
156
+ tau = float(t_shift)
157
+ if tau <= 0:
158
+ # Degenerate: split as evenly as possible, last step takes remainder.
159
+ base = N // K
160
+ out = [base] * K
161
+ out[-1] += N - sum(out)
162
+ return out
163
+ ts = [tau * (s / K) / (1.0 + (tau - 1.0) * (s / K)) for s in range(K + 1)]
164
+ counts: list[int] = []
165
+ cum = 0
166
+ rem = N
167
+ for s in range(K):
168
+ if s == K - 1:
169
+ num = rem
170
+ else:
171
+ target = int(round(N * ts[s + 1]))
172
+ num = max(0, target - cum)
173
+ num = min(num, rem)
174
+ counts.append(num)
175
+ cum += num
176
+ rem -= num
177
+ return counts
178
+
179
+
180
+ # --------------------------------------------------------------------------- #
181
+ # Speech-block step (block-diffusion inner loop)
182
+ # --------------------------------------------------------------------------- #
183
+
184
+
185
+ def _pmi_count_early_step_unmask(
186
+ blk_logits_2d: Tensor,
187
+ blk_toks_1d: Tensor,
188
+ *,
189
+ bl: int,
190
+ k: int,
191
+ K: int,
192
+ MASK: int,
193
+ device: torch.device,
194
+ temperature: float,
195
+ temperature_sampling: Literal["multinomial", "gumbel"],
196
+ omnivoice_unmask_schedule_k: int,
197
+ time_shift_tau: float,
198
+ prior_override_v: Tensor | None,
199
+ block_marginal_prior: Tensor | None,
200
+ pmi_cfg_probs_c_bl: Tensor | None,
201
+ pmi_cfg_probs_u_bl: Tensor | None,
202
+ cfg_scale_for_pmi: float,
203
+ position_temperature: float,
204
+ ) -> dict:
205
+ """One denoising step for a single sample block — ``pmi_count_early``.
206
+
207
+ Per-step unmask count combines OmniVoice's count schedule (floor) with
208
+ the paper's PMI-quantile (ceiling):
209
+
210
+ n_sched = omnivoice_unmask_schedule_k (OmniVoice r_n at step k)
211
+ q_k = max(0, 1 - time_shift_tau * (k+1) / K)
212
+ tau_k = Quantile({pmi_i}_{i in M}, q_k) (if time_shift_tau>0)
213
+ n_quant = |{i in M : pmi_i >= tau_k}| (else 0)
214
+ n_unmask = min(max(n_sched, n_quant), m_cur)
215
+
216
+ The top ``n_unmask`` masked positions by PMI get unmasked. Properties:
217
+ * ``time_shift_tau == 0`` reduces to plain pmi_count (fixed-K, no
218
+ early decoding); per-step count is exactly ``n_sched``.
219
+ * ``time_shift_tau > 0`` may unmask more than ``n_sched`` when PMI
220
+ is concentrated, allowing the block to early-finish (n_mask==0)
221
+ before step ``K-1`` — the *only* source of step savings above the
222
+ baseline.
223
+
224
+ PMI scoring:
225
+ * Numerator: probabilities from the conditional softmax of the
226
+ outer block logits (``probs_bl``).
227
+ * Denominator: ``prior_override_v`` (precomputed unconditional
228
+ block prior) when provided, otherwise the running per-block
229
+ marginal frozen at step 0 (``block_marginal_prior``).
230
+ * Under CFG (``cfg_scale > 0``) the caller passes
231
+ ``pmi_cfg_probs_{c,u}_bl`` and the CFG-combined PMI
232
+ ``(1+w) * pmi_c - w * pmi_u`` is used (this is the only PMI/CFG
233
+ combination supported — no other ``cfg_prior_mode`` is exposed).
234
+
235
+ Gumbel position scoring (OmniVoice §3.4 T=5): when
236
+ ``position_temperature > 0`` we add Gumbel noise scaled by ``T`` to
237
+ masked positions' PMI before ranking, turning deterministic top-k
238
+ into stochastic top-k without replacement.
239
+ """
240
+ probs_raw = F.softmax(blk_logits_2d, dim=-1)
241
+ if temperature > 0:
242
+ sampled = _sample_at_temperature(
243
+ blk_logits_2d, temperature, sampling=temperature_sampling,
244
+ )
245
+ else:
246
+ sampled = blk_logits_2d.argmax(dim=-1)
247
+
248
+ ar = torch.arange(bl, device=device)
249
+ is_mask = blk_toks_1d == MASK
250
+
251
+ # ---- PMI scoring ----
252
+ probs_bl = probs_raw
253
+ if k == 0 and block_marginal_prior is None:
254
+ block_marginal_prior = probs_bl.mean(dim=0)
255
+
256
+ if pmi_cfg_probs_c_bl is not None and pmi_cfg_probs_u_bl is not None:
257
+ mp_c = (
258
+ prior_override_v
259
+ if prior_override_v is not None
260
+ else pmi_cfg_probs_c_bl.mean(dim=0)
261
+ )
262
+ mp_u = (
263
+ prior_override_v
264
+ if prior_override_v is not None
265
+ else pmi_cfg_probs_u_bl.mean(dim=0)
266
+ )
267
+ pmi_c = compute_pmi(pmi_cfg_probs_c_bl, sampled, marginal_prior=mp_c)
268
+ pmi_u = compute_pmi(pmi_cfg_probs_u_bl, sampled, marginal_prior=mp_u)
269
+ pmi = pmi_cfg_combine(pmi_c, pmi_u, cfg_scale_for_pmi)
270
+ else:
271
+ _mp = (
272
+ prior_override_v
273
+ if prior_override_v is not None
274
+ else block_marginal_prior
275
+ )
276
+ pmi = compute_pmi(probs_bl, sampled, marginal_prior=_mp)
277
+
278
+ if position_temperature > 0.0:
279
+ noise = _gumbel_sample(torch.zeros_like(pmi), position_temperature)
280
+ pmi = torch.where(is_mask, pmi + noise, pmi)
281
+
282
+ # ---- Outlier rule (pmi_count_early): max(count-schedule, quantile) ----
283
+ if k == K - 1:
284
+ do_unmask = is_mask
285
+ else:
286
+ masked_idx = ar[is_mask]
287
+ m_cur = int(masked_idx.numel())
288
+ do_unmask = torch.zeros_like(is_mask, dtype=torch.bool)
289
+ if m_cur > 0:
290
+ pmi_m = pmi[masked_idx]
291
+ n_sched = max(0, min(int(omnivoice_unmask_schedule_k), m_cur))
292
+ if time_shift_tau > 0.0:
293
+ q_shifted = max(0.0, 1.0 - time_shift_tau * (k + 1) / K)
294
+ tau_k = torch.quantile(pmi_m.float(), q_shifted).to(dtype=pmi_m.dtype)
295
+ n_quant = int((pmi_m >= tau_k).sum().item())
296
+ else:
297
+ n_quant = 0
298
+ n_unmask = min(max(n_sched, n_quant), m_cur)
299
+ if n_unmask >= m_cur:
300
+ do_unmask[masked_idx] = True
301
+ elif n_unmask > 0:
302
+ _, _topi = pmi_m.topk(n_unmask)
303
+ do_unmask[masked_idx[_topi]] = True
304
+
305
+ xt_step = torch.where(do_unmask, sampled, blk_toks_1d)
306
+ n_mask = int((xt_step == MASK).sum().item())
307
+ return dict(
308
+ xt_step=xt_step,
309
+ n_mask=n_mask,
310
+ block_marginal_prior=block_marginal_prior,
311
+ )
312
+
313
+
314
+ # --------------------------------------------------------------------------- #
315
+ # ChatterboxFlashT3
316
+ # --------------------------------------------------------------------------- #
317
+
318
+
319
+ class ChatterboxFlashT3(_ChatterboxT3):
320
+ """Chatterbox-TTS T3 decoder + [MASK] token + block-diffusion generate().
321
+
322
+ The Llama backbone, conditioning encoder, text / speech embeddings and
323
+ heads come straight from the parent ``chatterbox.models.t3.t3.T3``. We
324
+ only add the extra mask-token row in :attr:`speech_emb` and override
325
+ :meth:`generate` with the block-diffusion inference loop.
326
+ """
327
+
328
+ def __init__(
329
+ self,
330
+ hp: T3Config | None = None,
331
+ *,
332
+ drf_block_size: int = 16,
333
+ ) -> None:
334
+ super().__init__(hp=hp)
335
+ if self.is_gpt:
336
+ raise NotImplementedError(
337
+ "ChatterboxFlashT3 currently supports the Llama backbone only "
338
+ "(hp.llama_config_name='Llama_520M').",
339
+ )
340
+ self._mask_token_id: int = self.hp.speech_tokens_dict_size
341
+ self.drf_block_size = int(drf_block_size)
342
+
343
+ # Extend speech_emb by one row for the [MASK] token (id = V).
344
+ old_speech_emb = self.speech_emb
345
+ self.speech_emb = nn.Embedding(self.hp.speech_tokens_dict_size + 1, self.dim)
346
+ with torch.no_grad():
347
+ self.speech_emb.weight[: self.hp.speech_tokens_dict_size].copy_(
348
+ old_speech_emb.weight,
349
+ )
350
+ nn.init.normal_(
351
+ self.speech_emb.weight[self.hp.speech_tokens_dict_size :], std=0.02,
352
+ )
353
+
354
+ # Backward-compat alias so :class:`FlashInferEngine` can read
355
+ # ``model.tfmr.config`` (chatterbox T3 only exposes ``.cfg``).
356
+ self.config = self.cfg
357
+ # Inference engine cache (re-used between calls when shape compatible).
358
+ self._cached_engine: InferenceEngine | None = None
359
+
360
+ # ------------------------------------------------------------------ #
361
+ # Properties / helpers
362
+ # ------------------------------------------------------------------ #
363
+
364
+ @property
365
+ def mask_token_id(self) -> int:
366
+ return self._mask_token_id
367
+
368
+ @property
369
+ def is_llama(self) -> bool:
370
+ return not self.is_gpt
371
+
372
+ def set_block_size(self, block_size: int) -> None:
373
+ self.drf_block_size = int(block_size)
374
+
375
+ def prime_uncond_block_prior(
376
+ self,
377
+ dtype: torch.dtype,
378
+ device: torch.device | str,
379
+ block_size: int | None = None,
380
+ ) -> None:
381
+ """Eagerly compute & cache the PMI unconditional block prior.
382
+
383
+ Called at load time so the first ``generate()`` does not pay for the
384
+ one-shot prior forward — keeping it out of the timed / RTF loop. Uses
385
+ ``self.drf_block_size`` unless ``block_size`` is given; a later
386
+ ``generate()`` with a different block size recomputes automatically
387
+ (the cache is keyed on ``(block_size, dtype, device)``).
388
+ """
389
+ device = torch.device(device) if isinstance(device, str) else device
390
+ bs = int(block_size) if block_size is not None else int(self.drf_block_size)
391
+ with torch.no_grad():
392
+ self._compute_uncond_block_prior(
393
+ bs, int(self.hp.start_speech_token), int(self.mask_token_id),
394
+ dtype, device,
395
+ )
396
+
397
+ def _embed_speech_tokens(self, tokens: Tensor) -> Tensor:
398
+ """Embed speech tokens (including the extra ``[MASK]`` id)."""
399
+ emb = self.speech_emb(tokens)
400
+ if self.hp.input_pos_emb == "learned":
401
+ emb = emb + self.speech_pos_emb(tokens)
402
+ return emb
403
+
404
+ # ------------------------------------------------------------------ #
405
+ # Precomputed unconditional block prior (PMI denominator)
406
+ # ------------------------------------------------------------------ #
407
+
408
+ def _compute_uncond_block_prior(
409
+ self,
410
+ block_size: int,
411
+ SOS: int,
412
+ MASK: int,
413
+ dtype: torch.dtype,
414
+ device: torch.device,
415
+ ) -> Tensor:
416
+ """PMI prior from a forward on ``[SOS, MASK × block_size]`` with **no
417
+ conditioning at all** — no speaker, no text, no prompt speech, not
418
+ even zero-filled embeddings of those. Captures the model's intrinsic
419
+ marginal over speech tokens at the very start of an utterance.
420
+
421
+ The result depends only on ``(block_size, SOS, MASK, dtype, device)``
422
+ and the (frozen, inference-time) model weights, so we **cache** it
423
+ on ``self._uncond_block_prior_cache`` keyed by that tuple. The actual
424
+ forward runs at most once per ``(block_size, dtype, device)`` triple
425
+ across the entire lifetime of the model object — subsequent
426
+ ``generate()`` calls reuse the cached ``(V,)`` tensor. Invalidated
427
+ implicitly when the model is recreated (e.g. on checkpoint reload).
428
+ """
429
+ cache: dict = getattr(self, "_uncond_block_prior_cache", None)
430
+ if cache is None:
431
+ cache = {}
432
+ self._uncond_block_prior_cache = cache
433
+ key = (int(block_size), int(SOS), int(MASK), str(dtype), str(device))
434
+ if key in cache:
435
+ return cache[key]
436
+
437
+ n_tok = 1 + int(block_size)
438
+ tok_seq = torch.cat(
439
+ [
440
+ torch.full((1, 1), int(SOS), device=device, dtype=torch.long),
441
+ torch.full(
442
+ (1, int(block_size)), int(MASK),
443
+ device=device, dtype=torch.long,
444
+ ),
445
+ ],
446
+ dim=1,
447
+ )
448
+ seq_emb = self._embed_speech_tokens(tok_seq).to(dtype)
449
+ position_ids = torch.arange(n_tok, device=device).unsqueeze(0)
450
+
451
+ # The FlashInfer engine swaps the LlamaAttention forward in-place;
452
+ # we explicitly route this one-shot prior pass through HuggingFace's
453
+ # SDPA implementation so it never touches the FlashInfer paged cache.
454
+ cfg = self.tfmr.config
455
+ old_impl = getattr(cfg, "_attn_implementation", "sdpa")
456
+ cfg._attn_implementation = "sdpa"
457
+ try:
458
+ out = self.tfmr(
459
+ input_ids=None,
460
+ inputs_embeds=seq_emb,
461
+ position_ids=position_ids,
462
+ use_cache=False,
463
+ return_dict=True,
464
+ )
465
+ finally:
466
+ cfg._attn_implementation = old_impl
467
+
468
+ # Standard token-shift: logits for mask position i come from hidden
469
+ # at position i (SOS → mask 0, mask k-1 → mask k).
470
+ shift_hidden = out.last_hidden_state[:, : int(block_size), :]
471
+ logits = self.speech_head(shift_hidden)
472
+ probs = F.softmax(logits.float(), dim=-1)
473
+ prior = probs.mean(dim=1).squeeze(0).detach()
474
+ cache[key] = prior
475
+ return prior
476
+
477
+ # ------------------------------------------------------------------ #
478
+ # State-dict surgery (lets the model accept either a pure chatterbox
479
+ # checkpoint of size V or a Chatterbox-Flash checkpoint of size V+1)
480
+ # ------------------------------------------------------------------ #
481
+
482
+ def _load_from_state_dict(
483
+ self, state_dict, prefix, *args, **kwargs,
484
+ ): # noqa: D401
485
+ key = prefix + "speech_emb.weight"
486
+ if key in state_dict:
487
+ ckpt_w = state_dict[key]
488
+ model_v = self.speech_emb.weight.shape[0]
489
+ if ckpt_w.shape[0] == model_v - 1:
490
+ logger.info(
491
+ "Expanding speech_emb from %d to %d (adding [MASK] token).",
492
+ ckpt_w.shape[0], model_v,
493
+ )
494
+ expanded = self.speech_emb.weight.detach().clone()
495
+ expanded[: ckpt_w.shape[0]] = ckpt_w
496
+ state_dict[key] = expanded
497
+ return super()._load_from_state_dict(state_dict, prefix, *args, **kwargs)
498
+
499
+ # ------------------------------------------------------------------ #
500
+ # Prefix forward (run once per generation, cached in FlashInfer)
501
+ # ------------------------------------------------------------------ #
502
+
503
+ def _build_prefix_emb(
504
+ self,
505
+ cond_emb: Tensor,
506
+ text_tokens: Tensor,
507
+ device: torch.device,
508
+ dtype: torch.dtype,
509
+ ) -> Tensor:
510
+ """Build ``[cond | text | <start_speech>]`` embedding."""
511
+ text_emb = self.text_emb(text_tokens)
512
+ if self.hp.input_pos_emb == "learned":
513
+ text_emb = text_emb + self.text_pos_emb(text_tokens)
514
+ sos = torch.full(
515
+ (1, 1), self.hp.start_speech_token, device=device, dtype=torch.long,
516
+ )
517
+ sos_emb = self._embed_speech_tokens(sos)
518
+ return torch.cat([cond_emb, text_emb, sos_emb], dim=1).to(dtype)
519
+
520
+ # ------------------------------------------------------------------ #
521
+ # Block-diffusion generate
522
+ # ------------------------------------------------------------------ #
523
+
524
+ @torch.inference_mode()
525
+ def generate(
526
+ self,
527
+ *,
528
+ t3_cond: "T3Cond | list[T3Cond] | tuple[T3Cond, ...]",
529
+ text_tokens: "torch.Tensor | list[torch.Tensor]",
530
+ text_token_lens: "torch.Tensor | None" = None,
531
+ total_speech_len: "int | list[int]" = 0,
532
+ num_steps: int = 10,
533
+ temperature: float = 0.6,
534
+ temperature_sampling: Literal["multinomial", "gumbel"] = "multinomial",
535
+ time_shift_tau: float = 0.1,
536
+ omnivoice_schedule_t_shift: float = 0.5,
537
+ cfg_scale: float = 1.0,
538
+ position_temperature: float = 5.0,
539
+ pmi_uncond_prior_precompute: bool = True,
540
+ batch_size: int = 1,
541
+ page_size: int = 16,
542
+ flashinfer_reserve_max_seq: int | None = None,
543
+ use_cuda_graph: bool = True,
544
+ backend: Literal["auto", "flashinfer", "torch"] = "auto",
545
+ ) -> Tensor:
546
+ """Block-wise denoising generation matching the paper's best config.
547
+
548
+ Inputs accept either a single sample (``text_tokens`` is a tensor and
549
+ ``t3_cond`` is a :class:`T3Cond`) or a heterogeneous batch
550
+ (``text_tokens`` is a list, ``t3_cond`` is a list of :class:`T3Cond`).
551
+ Both modes are dispatched through the same FlashInfer block-decoding
552
+ loop. Heterogeneous batches pad to ``max(total_speech_len)`` and stop
553
+ each row once it emits ``hp.stop_speech_token``.
554
+
555
+ Decoding-relevant parameters (paper defaults):
556
+
557
+ ``num_steps``
558
+ Maximum denoising iterations per block (``K``). Default 10.
559
+ ``time_shift_tau``
560
+ Early-decoding aggressiveness in
561
+ ``q_k = max(0, 1 - τ·(k+1)/K)``. ``0.0`` disables early decoding
562
+ (every block uses exactly the OmniVoice count schedule). Default
563
+ ``0.1`` (paper).
564
+ ``omnivoice_schedule_t_shift``
565
+ OmniVoice r_n schedule parameter that controls the per-step
566
+ unmask count floor. Default ``0.5``.
567
+ ``cfg_scale``
568
+ CFG strength. When ``> 0`` the forward batch is doubled with a
569
+ null branch (``zero_text_batch`` + ``zero_all`` null prefix +
570
+ text/cond zeroed + speech-x_t duplicated). Token sampling uses
571
+ the guided distribution; PMI scoring is combined on the PMI
572
+ scale via ``(1+w)·pmi_c − w·pmi_u`` (``pmi_cfg``) — this is the
573
+ only supported PMI/CFG combination, no other mode is exposed.
574
+ ``position_temperature``
575
+ Gumbel temperature added to PMI for stochastic top-k position
576
+ ranking. ``0`` = deterministic argmax-top-k; default ``5.0``
577
+ (OmniVoice §3.4 T=5).
578
+ ``pmi_uncond_prior_precompute``
579
+ When ``True`` (default), the PMI denominator is the
580
+ unconditional block prior computed once via
581
+ :meth:`_compute_uncond_block_prior` and cached on the model.
582
+ When ``False``, falls back to the running per-block marginal
583
+ from step 0.
584
+
585
+ Returns
586
+ -------
587
+ torch.LongTensor
588
+ Speech token ids of shape ``(B, T)`` with
589
+ ``T <= max(total_speech_len)``.
590
+ """
591
+ device = self.device
592
+ BS = self.drf_block_size
593
+ K = num_steps
594
+ MASK = self.mask_token_id
595
+ dtype = next(self.parameters()).dtype
596
+ stop_tok = self.hp.stop_speech_token
597
+
598
+ # ---- cross-text vs same-text batching ----
599
+ _cross_text = isinstance(text_tokens, list)
600
+ if _cross_text:
601
+ B = len(text_tokens)
602
+ _speech_lens: list[int] = (
603
+ list(total_speech_len)
604
+ if isinstance(total_speech_len, list)
605
+ else [total_speech_len] * B
606
+ )
607
+ N = max(_speech_lens) if _speech_lens else 0
608
+ _per_sample_num_blocks = [math.ceil(sl / BS) for sl in _speech_lens]
609
+ num_blocks = max(_per_sample_num_blocks) if _per_sample_num_blocks else 0
610
+ else:
611
+ B = max(1, batch_size)
612
+ _speech_lens = []
613
+ N = total_speech_len if isinstance(total_speech_len, int) else total_speech_len[0]
614
+ num_blocks = math.ceil(N / BS) if N > 0 else 0
615
+ _per_sample_num_blocks = []
616
+
617
+ if N <= 0:
618
+ return torch.empty((B, 0), device=device, dtype=torch.long)
619
+
620
+ B_usr = B
621
+ _ztb = cfg_scale > 0
622
+ B_fwd = (2 * B_usr) if _ztb else B_usr
623
+
624
+ # ---- prefix(es) ----
625
+ t3_is_seq = isinstance(t3_cond, (list, tuple))
626
+ if t3_is_seq and not _cross_text:
627
+ raise ValueError(
628
+ "t3_cond as a list/tuple is only supported when text_tokens is a list.",
629
+ )
630
+
631
+ cond_embs: list[Tensor] | None = None
632
+ cond_emb_single: Tensor | None = None
633
+ if _cross_text:
634
+ if t3_is_seq:
635
+ if len(t3_cond) != len(text_tokens):
636
+ raise ValueError(
637
+ f"len(t3_cond) ({len(t3_cond)}) must match "
638
+ f"len(text_tokens) ({len(text_tokens)}).",
639
+ )
640
+ cond_embs = [self.prepare_conditioning(c) for c in t3_cond]
641
+ else:
642
+ cond_emb_single = self.prepare_conditioning(t3_cond)
643
+ else:
644
+ cond_emb_single = self.prepare_conditioning(t3_cond)
645
+
646
+ prefix_emb_cond: Tensor | list[Tensor]
647
+ prefix_emb_null: Tensor | list[Tensor] | None = None
648
+ _prefix_lens: list[int]
649
+
650
+ if _cross_text:
651
+ cond_list: list[Tensor] = []
652
+ null_list: list[Tensor] = []
653
+ lens: list[int] = []
654
+ for i, tt in enumerate(text_tokens):
655
+ ce = cond_embs[i] if cond_embs is not None else cond_emb_single
656
+ p = self._build_prefix_emb(ce, tt, device, dtype)
657
+ cond_list.append(p)
658
+ lens.append(p.size(1))
659
+ if _ztb:
660
+ ce_null = _cond_emb_zero_all(ce)
661
+ text_emb = self.text_emb(tt)
662
+ if self.hp.input_pos_emb == "learned":
663
+ text_emb = text_emb + self.text_pos_emb(tt)
664
+ text_emb_zero = _zero_text_content_keep_pad(
665
+ text_emb, tt, None,
666
+ )
667
+ sos = torch.full(
668
+ (1, 1), self.hp.start_speech_token,
669
+ device=device, dtype=torch.long,
670
+ )
671
+ sos_emb = self._embed_speech_tokens(sos)
672
+ p_null = torch.cat(
673
+ [ce_null, text_emb_zero, sos_emb], dim=1,
674
+ ).to(dtype)
675
+ null_list.append(p_null)
676
+ prefix_emb_cond = cond_list
677
+ lp = max(lens)
678
+ _prefix_lens = list(lens)
679
+ if _ztb:
680
+ prefix_emb_null = null_list
681
+ _prefix_lens = _prefix_lens + [p.size(1) for p in null_list]
682
+ else:
683
+ assert cond_emb_single is not None
684
+ prefix_emb = self._build_prefix_emb(
685
+ cond_emb_single, text_tokens, device, dtype,
686
+ )
687
+ lp = prefix_emb.size(1)
688
+ if B_usr > 1:
689
+ prefix_emb = prefix_emb.expand(B_usr, -1, -1).contiguous()
690
+ prefix_emb_cond = prefix_emb
691
+ _prefix_lens = [lp] * B_fwd
692
+ if _ztb:
693
+ cond_for_null = _cond_emb_zero_all(cond_emb_single)
694
+ text_emb = self.text_emb(text_tokens)
695
+ if self.hp.input_pos_emb == "learned":
696
+ text_emb = text_emb + self.text_pos_emb(text_tokens)
697
+ text_emb_zero = _zero_text_content_keep_pad(
698
+ text_emb, text_tokens, text_token_lens,
699
+ )
700
+ sos = torch.full(
701
+ (1, 1), self.hp.start_speech_token,
702
+ device=device, dtype=torch.long,
703
+ )
704
+ sos_emb = self._embed_speech_tokens(sos)
705
+ p_null = torch.cat(
706
+ [cond_for_null, text_emb_zero, sos_emb], dim=1,
707
+ ).to(dtype)
708
+ if B_usr > 1:
709
+ p_null = p_null.expand(B_usr, -1, -1).contiguous()
710
+ prefix_emb_null = p_null
711
+
712
+ # ---- speech buffer (mask everywhere; we fill it block by block) ----
713
+ xt = torch.full((B_fwd, N), MASK, device=device, dtype=torch.long)
714
+
715
+ # ---- precomputed unconditional block prior (PMI denominator) ----
716
+ # Single forward on [SOS, MASK × BS] with no conditioning, lazy-cached
717
+ # on the model. Reused as PMI denominator for every step of every
718
+ # block in this generate() call (and across subsequent calls).
719
+ SOS = int(self.hp.start_speech_token)
720
+ prior_override_v: Tensor | None = None
721
+ if pmi_uncond_prior_precompute:
722
+ prior_override_v = self._compute_uncond_block_prior(
723
+ BS, SOS, MASK, dtype, device,
724
+ )
725
+
726
+ # ---- inference engine (FlashInfer if available, else torch SDPA) ----
727
+ engine_max_seq = max(lp + N + 64, flashinfer_reserve_max_seq or 0)
728
+ cached = self._cached_engine
729
+ if (
730
+ cached is not None
731
+ and cached.can_reuse(
732
+ engine_max_seq, dtype, batch_size=B_fwd, page_size=page_size,
733
+ )
734
+ ):
735
+ engine = cached
736
+ engine.reset()
737
+ else:
738
+ if (
739
+ use_cuda_graph
740
+ and cached is not None
741
+ and getattr(cached, "_max_seq_len", 0) < engine_max_seq
742
+ and getattr(cached, "_batch_size", -1) == B_fwd
743
+ and getattr(cached, "_dtype", None) == dtype
744
+ ):
745
+ # Grow allocation in 512-token buckets so we don't re-instantiate
746
+ # the engine every time the sequence length nudges up by one.
747
+ engine_max_seq = max(
748
+ ((engine_max_seq + 511) // 512) * 512,
749
+ int(getattr(cached, "_max_seq_len", 0) * 1.5),
750
+ )
751
+ engine = build_engine(
752
+ self, engine_max_seq, dtype,
753
+ backend=backend,
754
+ batch_size=B_fwd, page_size=page_size,
755
+ )
756
+
757
+ if _ztb:
758
+ assert prefix_emb_null is not None
759
+ if isinstance(prefix_emb_cond, list):
760
+ assert isinstance(prefix_emb_null, list)
761
+ pfx_for_engine = [*prefix_emb_cond, *prefix_emb_null]
762
+ else:
763
+ assert isinstance(prefix_emb_null, torch.Tensor)
764
+ pfx_for_engine = [
765
+ *[prefix_emb_cond[bi : bi + 1] for bi in range(B_usr)],
766
+ *[prefix_emb_null[bi : bi + 1] for bi in range(B_usr)],
767
+ ]
768
+ shift_ctx = engine.prefix_forward(pfx_for_engine)
769
+ else:
770
+ shift_ctx = engine.prefix_forward(prefix_emb_cond)
771
+
772
+ if use_cuda_graph:
773
+ # ``capture_cuda_graph`` is a no-op on the torch SDPA fallback.
774
+ engine.capture_cuda_graph(BS, speech_head=self.speech_head)
775
+ self._cached_engine = engine
776
+
777
+ # ---- block-by-block decoding ----
778
+ # ``_row_eos[bi]`` — row has emitted EOS, skip remaining blocks.
779
+ # ``_block_done[bi]`` — inner k-loop finished for this row in the
780
+ # current block (reset at every block boundary; not a sequence-level
781
+ # flag).
782
+ _row_eos = (
783
+ torch.zeros(B_usr, dtype=torch.bool, device=device)
784
+ if B_usr > 1
785
+ else None
786
+ )
787
+ _block_done = (
788
+ torch.zeros(B_usr, dtype=torch.bool, device=device)
789
+ if B_usr > 1
790
+ else None
791
+ )
792
+ block_marginal_priors: list[Tensor | None] = [None] * B_usr
793
+
794
+ for b_idx in range(num_blocks):
795
+ bs_ = b_idx * BS
796
+ be_ = min(bs_ + BS, N)
797
+ bl = be_ - bs_
798
+
799
+ # OmniVoice r_n count schedule for this block's K steps. Used as
800
+ # the per-step unmask **floor** by the pmi_count_early outlier
801
+ # rule. Computed once per block since ``bl`` only changes at the
802
+ # tail.
803
+ _omnivoice_sched = _omnivoice_unmask_schedule(
804
+ n_total_mask=bl,
805
+ num_steps=K,
806
+ t_shift=float(omnivoice_schedule_t_shift),
807
+ )
808
+
809
+ if _block_done is not None:
810
+ _block_done.zero_()
811
+ if _cross_text:
812
+ for bi in range(B_usr):
813
+ if (
814
+ b_idx >= _per_sample_num_blocks[bi]
815
+ or (_row_eos is not None and bool(_row_eos[bi]))
816
+ ):
817
+ _block_done[bi] = True
818
+ elif _row_eos is not None:
819
+ for bi in range(B_usr):
820
+ if bool(_row_eos[bi]):
821
+ _block_done[bi] = True
822
+
823
+ for k in range(K):
824
+ # Per-row cache-start positions (cross-text & null-prefix paths
825
+ # may differ between rows; same-text reuses a scalar).
826
+ if _cross_text:
827
+ cache_start: int | list[int] = [
828
+ _prefix_lens[bi] + bs_ for bi in range(B_fwd)
829
+ ]
830
+ else:
831
+ cache_start = lp + bs_
832
+
833
+ sp_blk = self._embed_speech_tokens(xt[:, bs_:be_]).to(dtype)
834
+
835
+ use_graph = engine.has_cuda_graph and bl == BS
836
+ if use_graph:
837
+ if k == 0:
838
+ engine.set_shift_ctx(shift_ctx)
839
+ blk_hidden, graph_logits = engine.block_forward_graph(
840
+ sp_blk, cache_start,
841
+ )
842
+ if graph_logits is not None:
843
+ blk_logits = graph_logits
844
+ else:
845
+ shift_hidden = torch.cat(
846
+ [shift_ctx, blk_hidden[:, : bl - 1]], dim=1,
847
+ )
848
+ blk_logits = self.speech_head(shift_hidden)
849
+ else:
850
+ blk_hidden = engine.block_forward(sp_blk, cache_start)
851
+ shift_hidden = torch.cat(
852
+ [shift_ctx, blk_hidden[:, : bl - 1]], dim=1,
853
+ )
854
+ blk_logits = self.speech_head(shift_hidden)
855
+
856
+ pmi_cfg_probs_c: Tensor | None = None
857
+ pmi_cfg_probs_u: Tensor | None = None
858
+ if _ztb:
859
+ logits_cond = blk_logits[:B_usr]
860
+ logits_uncond = blk_logits[B_usr : 2 * B_usr]
861
+ # Always pmi_cfg: PMI scores are combined on the PMI
862
+ # scale via (1+w)·pmi_c − w·pmi_u. No other CFG/PMI
863
+ # combination is exposed.
864
+ pmi_cfg_probs_c = F.softmax(logits_cond, dim=-1)
865
+ pmi_cfg_probs_u = F.softmax(logits_uncond, dim=-1)
866
+ blk_logits = apply_zero_text_cfg_from_logits(
867
+ logits_cond, logits_uncond, cfg_scale,
868
+ )
869
+
870
+ step_break = False
871
+ for bi in range(B_usr):
872
+ if _block_done is not None and _block_done[bi]:
873
+ continue
874
+ pmi_c_bi = None if pmi_cfg_probs_c is None else pmi_cfg_probs_c[bi]
875
+ pmi_u_bi = None if pmi_cfg_probs_u is None else pmi_cfg_probs_u[bi]
876
+ r = _pmi_count_early_step_unmask(
877
+ blk_logits[bi],
878
+ xt[bi, bs_:be_],
879
+ bl=bl,
880
+ k=k, K=K,
881
+ MASK=MASK, device=device,
882
+ temperature=temperature,
883
+ temperature_sampling=temperature_sampling,
884
+ omnivoice_unmask_schedule_k=_omnivoice_sched[k],
885
+ time_shift_tau=time_shift_tau,
886
+ prior_override_v=prior_override_v,
887
+ block_marginal_prior=block_marginal_priors[bi],
888
+ pmi_cfg_probs_c_bl=pmi_c_bi,
889
+ pmi_cfg_probs_u_bl=pmi_u_bi,
890
+ cfg_scale_for_pmi=cfg_scale,
891
+ position_temperature=position_temperature,
892
+ )
893
+ xt[bi, bs_:be_] = r["xt_step"]
894
+ block_marginal_priors[bi] = r["block_marginal_prior"]
895
+ if _ztb:
896
+ xt[B_usr + bi, bs_:be_] = r["xt_step"]
897
+ row_hit_eos = bool(
898
+ (xt[bi, bs_:be_] == stop_tok).any().item(),
899
+ )
900
+ if r["n_mask"] == 0 or row_hit_eos:
901
+ if _block_done is not None:
902
+ _block_done[bi] = True
903
+ if row_hit_eos and _row_eos is not None:
904
+ _row_eos[bi] = True
905
+ if B_usr == 1 and row_hit_eos:
906
+ step_break = True
907
+ elif B_usr == 1 and r["n_mask"] == 0:
908
+ step_break = True
909
+ if step_break:
910
+ break
911
+ if _block_done is not None and bool(_block_done.all().item()):
912
+ break
913
+
914
+ # Mid-block finalize: write the committed (possibly partial) tokens
915
+ # of this block to the KV cache so the next block sees clean context.
916
+ if b_idx < num_blocks - 1:
917
+ fin_emb = self._embed_speech_tokens(xt[:, bs_:be_]).to(dtype)
918
+ if _cross_text:
919
+ fin_cs: int | list[int] = [
920
+ _prefix_lens[bi] + bs_ for bi in range(B_fwd)
921
+ ]
922
+ else:
923
+ fin_cs = lp + bs_
924
+ fin_hidden = engine.block_forward(fin_emb, fin_cs)
925
+ engine.advance_cache(bl)
926
+ shift_ctx = fin_hidden[:, bl - 1 : bl, :].clone()
927
+
928
+ # Early termination if all (same-text) rows have finished.
929
+ if B_usr == 1:
930
+ eos_pos = (xt[0, bs_:be_] == stop_tok).nonzero(as_tuple=True)[0]
931
+ if len(eos_pos) > 0:
932
+ return xt[:B_usr, : bs_ + eos_pos[0].item()]
933
+ elif _row_eos is not None and bool(_row_eos.all().item()):
934
+ break
935
+
936
+ return xt[:B_usr]
runtime/chatterbox-flash/chatterbox_flash/nvfp4.py ADDED
@@ -0,0 +1,709 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Canonical, source-free NVFP4 loader for Chatterbox-Flash.
3
+
4
+ This module is the runtime counterpart to
5
+ ``export_chatterbox_flash_w4a16_hf.py``. It constructs T3 on the meta device,
6
+ loads only the BF16 non-projection tensors, prepares canonical NVFP4 tensors
7
+ one projection at a time, and attaches an engine-local projection provider.
8
+ No BF16 transformer projection is materialized and global ``F.linear`` is
9
+ never patched.
10
+
11
+ The canonical artifact remains independent of FlashInfer's prepared layout:
12
+ ``prepare_bf16_fp4_weights`` runs at load time, while ``nvfp4_quantize`` is
13
+ intentionally neither required nor called. The provider is attached to the
14
+ model rather than an individual engine, so cache-size engine rebuilds reuse
15
+ the same prepared weights.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import json
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ import torch
27
+ from safetensors import safe_open
28
+ from safetensors.torch import load_file
29
+
30
+
31
+ ARTIFACT_FILENAME = "t3_nvfp4.safetensors"
32
+ MANIFEST_FILENAME = "nvfp4_config.json"
33
+ BASE_REPO_ID = "ResembleAI/chatterbox-flash"
34
+ BASE_CHECKPOINT_REVISION = "4385507288b8197e6dab8b4e6b1603328d549d9d"
35
+ BASE_ASSET_FILENAMES = (
36
+ "ve.safetensors",
37
+ "s3gen.safetensors",
38
+ "tokenizer.json",
39
+ )
40
+ SUPPORTED_RELEASE_BLOCK_SIZES = (16, 24)
41
+ PROJECTION_KINDS = ("gate_up", "down", "qkv", "o")
42
+ PROJECTION_COMPONENTS = ("weight_fp4", "scale_128x4_e4m3", "alpha")
43
+ EXPECTED_LAYERS = 30
44
+ EXPECTED_PREPARED_BYTES = 283_116_000
45
+ EXPECTED_NONPROJECTION_TENSORS = 82
46
+ EXPECTED_NONPROJECTION_BYTES = 58_179_584
47
+ PROJECTION_DIMENSIONS = {
48
+ "gate_up": (8_192, 1_024),
49
+ "down": (1_024, 4_096),
50
+ "qkv": (3_072, 1_024),
51
+ "o": (1_024, 1_024),
52
+ }
53
+
54
+
55
+ def _tensor_bytes(tensor: torch.Tensor) -> int:
56
+ return tensor.numel() * tensor.element_size()
57
+
58
+
59
+ def _canonical_cuda_device(device: torch.device | str) -> torch.device:
60
+ """Resolve bare ``cuda`` to the indexed device used by model tensors."""
61
+ resolved = torch.device(device)
62
+ if resolved.type == "cuda" and resolved.index is None:
63
+ resolved = torch.device("cuda", torch.cuda.current_device())
64
+ return resolved
65
+
66
+
67
+ def _sha256_file(path: Path, chunk_bytes: int = 8 * 1024 * 1024) -> str:
68
+ digest = hashlib.sha256()
69
+ with path.open("rb") as handle:
70
+ while chunk := handle.read(chunk_bytes):
71
+ digest.update(chunk)
72
+ return digest.hexdigest()
73
+
74
+
75
+ def _is_projection_source_key(key: str) -> bool:
76
+ parts = key.split(".")
77
+ if (
78
+ len(parts) != 6
79
+ or parts[0] != "tfmr"
80
+ or parts[1] != "layers"
81
+ or not parts[2].isdigit()
82
+ or parts[-1] != "weight"
83
+ ):
84
+ return False
85
+ module, projection = parts[3], parts[4]
86
+ return (
87
+ module == "self_attn"
88
+ and projection in {"q_proj", "k_proj", "v_proj", "o_proj"}
89
+ ) or (
90
+ module == "mlp"
91
+ and projection in {"gate_proj", "up_proj", "down_proj"}
92
+ )
93
+
94
+
95
+ def _canonical_shapes(kind: str) -> dict[str, tuple[int, ...]]:
96
+ output_features, input_features = PROJECTION_DIMENSIONS[kind]
97
+ return {
98
+ "weight_fp4": (output_features, input_features // 2),
99
+ "scale_128x4_e4m3": (output_features, input_features // 16),
100
+ "alpha": (1,),
101
+ }
102
+
103
+
104
+ def _resolve_package(
105
+ package: str | Path,
106
+ manifest_path: str | Path | None,
107
+ ) -> tuple[Path, Path, dict[str, Any]]:
108
+ package_path = Path(package).expanduser().resolve()
109
+ if package_path.is_dir():
110
+ artifact_path = package_path / ARTIFACT_FILENAME
111
+ default_manifest = package_path / MANIFEST_FILENAME
112
+ else:
113
+ artifact_path = package_path
114
+ default_manifest = package_path.with_name(MANIFEST_FILENAME)
115
+ config_path = (
116
+ Path(manifest_path).expanduser().resolve()
117
+ if manifest_path is not None
118
+ else default_manifest
119
+ )
120
+ if not artifact_path.is_file():
121
+ raise FileNotFoundError(f"Missing canonical NVFP4 artifact: {artifact_path}")
122
+ if not config_path.is_file():
123
+ raise FileNotFoundError(f"Missing canonical NVFP4 manifest: {config_path}")
124
+ manifest = json.loads(config_path.read_text(encoding="utf-8"))
125
+ if manifest.get("format") != "chatterbox-flash-canonical-nvfp4":
126
+ raise RuntimeError(f"Unsupported NVFP4 format in {config_path}")
127
+ if manifest.get("format_version") != 1:
128
+ raise RuntimeError(
129
+ f"Unsupported NVFP4 format version: {manifest.get('format_version')!r}"
130
+ )
131
+ declared_filename = manifest.get("artifact", {}).get("filename")
132
+ if declared_filename != artifact_path.name:
133
+ raise RuntimeError(
134
+ f"Manifest declares artifact {declared_filename!r}, "
135
+ f"but loader received {artifact_path.name!r}"
136
+ )
137
+ return artifact_path, config_path, manifest
138
+
139
+
140
+ def _verify_base_assets(
141
+ paths: dict[str, Path],
142
+ manifest: dict[str, Any],
143
+ ) -> None:
144
+ """Verify every unchanged base asset before it reaches a model loader."""
145
+ expected_files = manifest.get("base_model", {}).get("files", {})
146
+ for filename in BASE_ASSET_FILENAMES:
147
+ path = paths.get(filename)
148
+ record = expected_files.get(filename)
149
+ if path is None or not path.is_file():
150
+ raise FileNotFoundError(f"Missing base-model asset: {filename}")
151
+ if not isinstance(record, dict):
152
+ raise RuntimeError(f"Manifest has no fingerprint for {filename}")
153
+ expected_bytes = record.get("bytes")
154
+ actual_bytes = path.stat().st_size
155
+ if actual_bytes != expected_bytes:
156
+ raise RuntimeError(
157
+ f"Base asset size mismatch for {filename}: expected "
158
+ f"{expected_bytes}, found {actual_bytes}"
159
+ )
160
+ expected_sha256 = record.get("sha256")
161
+ actual_sha256 = _sha256_file(path)
162
+ if not expected_sha256 or actual_sha256 != expected_sha256:
163
+ raise RuntimeError(
164
+ f"Base asset SHA-256 mismatch for {filename}: expected "
165
+ f"{expected_sha256}, found {actual_sha256}"
166
+ )
167
+
168
+
169
+ @dataclass(frozen=True)
170
+ class PreparedProjection:
171
+ kind: str
172
+ layer: int
173
+ input_features: int
174
+ output_features: int
175
+ prepared: tuple[torch.Tensor, torch.Tensor, torch.Tensor]
176
+ packed_bytes: int
177
+
178
+
179
+ class CanonicalNVFP4ProjectionProvider:
180
+ """FlashInfer projection provider prepared directly from canonical FP4."""
181
+
182
+ source_free = True
183
+
184
+ def __init__(
185
+ self,
186
+ artifact_path: str | Path,
187
+ *,
188
+ device: torch.device | str,
189
+ dtype: torch.dtype = torch.bfloat16,
190
+ autotune_cache: str | Path | None = None,
191
+ ) -> None:
192
+ import flashinfer
193
+
194
+ self.device = _canonical_cuda_device(device)
195
+ self.dtype = dtype
196
+ self.backend = "cute-dsl"
197
+ self.artifact_path = Path(artifact_path).expanduser().resolve()
198
+ self.autotune_cache = (
199
+ Path(autotune_cache).expanduser().resolve()
200
+ if autotune_cache is not None
201
+ else None
202
+ )
203
+ if self.device.type != "cuda":
204
+ raise RuntimeError("Canonical NVFP4 projections require a CUDA device")
205
+ if dtype is not torch.bfloat16:
206
+ raise RuntimeError(
207
+ f"Canonical W4A16 requires BF16 activations, found {dtype}"
208
+ )
209
+ if not torch.cuda.is_available():
210
+ raise RuntimeError("CUDA is unavailable")
211
+ capability = torch.cuda.get_device_capability(self.device)
212
+ if capability not in {(12, 0), (12, 1)}:
213
+ raise RuntimeError(
214
+ "The canonical CuTe-DSL NVFP4 profile is validated only on "
215
+ f"SM120/SM121, found compute capability {capability}"
216
+ )
217
+ required = (
218
+ "autotune",
219
+ "prepare_bf16_fp4_weights",
220
+ "mm_bf16_fp4",
221
+ )
222
+ missing = [name for name in required if not hasattr(flashinfer, name)]
223
+ if missing:
224
+ raise RuntimeError(
225
+ f"FlashInfer {getattr(flashinfer, '__version__', 'unknown')} "
226
+ f"lacks required runtime APIs: {missing}"
227
+ )
228
+
229
+ self._flashinfer = flashinfer
230
+ self._prepared: dict[str, list[PreparedProjection]] = {
231
+ kind: [] for kind in PROJECTION_KINDS
232
+ }
233
+ self._tuned_shapes: set[tuple[int, int]] = set()
234
+ self._load_canonical_projections()
235
+
236
+ def _load_canonical_projections(self) -> None:
237
+ expected_dtypes = {
238
+ "weight_fp4": torch.uint8,
239
+ "scale_128x4_e4m3": torch.uint8,
240
+ "alpha": torch.float32,
241
+ }
242
+ with safe_open(
243
+ str(self.artifact_path), framework="pt", device="cpu",
244
+ ) as handle:
245
+ available = set(handle.keys())
246
+ for layer in range(EXPECTED_LAYERS):
247
+ for kind in PROJECTION_KINDS:
248
+ prefix = f"nvfp4.layers.{layer:02d}.{kind}"
249
+ keys = {
250
+ component: f"{prefix}.{component}"
251
+ for component in PROJECTION_COMPONENTS
252
+ }
253
+ missing = [key for key in keys.values() if key not in available]
254
+ if missing:
255
+ raise RuntimeError(
256
+ f"Canonical artifact is missing projection tensors: {missing}"
257
+ )
258
+ expected_shapes = _canonical_shapes(kind)
259
+ canonical: dict[str, torch.Tensor] = {}
260
+ for component in PROJECTION_COMPONENTS:
261
+ tensor = handle.get_tensor(keys[component])
262
+ if tuple(tensor.shape) != expected_shapes[component]:
263
+ raise RuntimeError(
264
+ f"{keys[component]} shape {tuple(tensor.shape)} != "
265
+ f"{expected_shapes[component]}"
266
+ )
267
+ if tensor.dtype is not expected_dtypes[component]:
268
+ raise RuntimeError(
269
+ f"{keys[component]} dtype {tensor.dtype} != "
270
+ f"{expected_dtypes[component]}"
271
+ )
272
+ canonical[component] = tensor.to(
273
+ device=self.device, non_blocking=False,
274
+ )
275
+
276
+ # This is preparation, not quantization: canonical FP4
277
+ # codes/scales are consumed directly, one projection at a
278
+ # time. Synchronizing before advancing bounds transient
279
+ # device ownership to this projection plus prepared output.
280
+ prepared_raw = self._flashinfer.prepare_bf16_fp4_weights(
281
+ canonical["weight_fp4"],
282
+ canonical["scale_128x4_e4m3"],
283
+ canonical["alpha"],
284
+ backend=self.backend,
285
+ )
286
+ if len(prepared_raw) != 3 or any(
287
+ tensor is None for tensor in prepared_raw
288
+ ):
289
+ raise RuntimeError(
290
+ f"prepare_bf16_fp4_weights returned an invalid tuple "
291
+ f"for {kind}[{layer}]"
292
+ )
293
+ prepared = tuple(prepared_raw)
294
+ torch.cuda.synchronize(self.device)
295
+ output_features, input_features = PROJECTION_DIMENSIONS[kind]
296
+ record = PreparedProjection(
297
+ kind=kind,
298
+ layer=layer,
299
+ input_features=input_features,
300
+ output_features=output_features,
301
+ prepared=prepared,
302
+ packed_bytes=sum(_tensor_bytes(tensor) for tensor in prepared),
303
+ )
304
+ self._prepared[kind].append(record)
305
+ del canonical, prepared_raw, prepared
306
+
307
+ packed_bytes = sum(
308
+ record.packed_bytes
309
+ for records in self._prepared.values()
310
+ for record in records
311
+ )
312
+ if packed_bytes != EXPECTED_PREPARED_BYTES:
313
+ raise RuntimeError(
314
+ f"Prepared projection payload {packed_bytes} bytes != "
315
+ f"{EXPECTED_PREPARED_BYTES}"
316
+ )
317
+
318
+ def validate_engine(
319
+ self,
320
+ *,
321
+ device: torch.device,
322
+ dtype: torch.dtype,
323
+ num_layers: int,
324
+ ) -> None:
325
+ if _canonical_cuda_device(device) != self.device:
326
+ raise RuntimeError(
327
+ f"Projection provider is on {self.device}, engine requested {device}"
328
+ )
329
+ if dtype is not self.dtype:
330
+ raise RuntimeError(
331
+ f"Projection provider uses {self.dtype}, engine requested {dtype}"
332
+ )
333
+ if num_layers != EXPECTED_LAYERS or any(
334
+ len(records) != num_layers for records in self._prepared.values()
335
+ ):
336
+ raise RuntimeError(
337
+ f"Projection provider does not cover all {num_layers} layers"
338
+ )
339
+
340
+ def prepare_for_engine(self, *, batch_size: int, block_size: int) -> None:
341
+ shape = (int(batch_size), int(block_size))
342
+ if shape in self._tuned_shapes:
343
+ return
344
+ if self.autotune_cache is not None:
345
+ self.autotune_cache.parent.mkdir(parents=True, exist_ok=True)
346
+ m = shape[0] * shape[1]
347
+ representatives = [self._prepared[kind][0] for kind in PROJECTION_KINDS]
348
+ with self._flashinfer.autotune(
349
+ True,
350
+ cache=(str(self.autotune_cache) if self.autotune_cache else None),
351
+ tuning_buckets=(m,),
352
+ round_up=False,
353
+ ):
354
+ for record in representatives:
355
+ activation = torch.zeros(
356
+ (m, record.input_features),
357
+ device=self.device,
358
+ dtype=self.dtype,
359
+ )
360
+ self._run(record, activation)
361
+ torch.cuda.synchronize(self.device)
362
+ self._tuned_shapes.add(shape)
363
+
364
+ def _run(
365
+ self,
366
+ record: PreparedProjection,
367
+ activation: torch.Tensor,
368
+ ) -> torch.Tensor:
369
+ original_shape = activation.shape
370
+ flattened = activation.reshape(-1, record.input_features)
371
+ output = self._flashinfer.mm_bf16_fp4(
372
+ flattened,
373
+ *record.prepared,
374
+ backend=self.backend,
375
+ out_dtype=self.dtype,
376
+ )
377
+ return output.view(*original_shape[:-1], record.output_features)
378
+
379
+ def project(
380
+ self,
381
+ kind: str,
382
+ layer_idx: int,
383
+ activation: torch.Tensor,
384
+ ) -> torch.Tensor:
385
+ try:
386
+ record = self._prepared[kind][layer_idx]
387
+ except (KeyError, IndexError) as exc:
388
+ raise RuntimeError(
389
+ f"Unknown canonical projection {kind}[{layer_idx}]"
390
+ ) from exc
391
+ if activation.device != self.device or activation.dtype is not self.dtype:
392
+ raise RuntimeError(
393
+ f"{kind}[{layer_idx}] expected {self.device}/{self.dtype}, "
394
+ f"found {activation.device}/{activation.dtype}"
395
+ )
396
+ if activation.shape[-1] != record.input_features:
397
+ raise RuntimeError(
398
+ f"{kind}[{layer_idx}] expected K={record.input_features}, "
399
+ f"found {activation.shape[-1]}"
400
+ )
401
+ return self._run(record, activation)
402
+
403
+ def report(self) -> dict[str, Any]:
404
+ return {
405
+ "format": "chatterbox-flash-canonical-nvfp4",
406
+ "source_free": True,
407
+ "artifact": str(self.artifact_path),
408
+ "backend": self.backend,
409
+ "device": str(self.device),
410
+ "dtype": str(self.dtype),
411
+ "layers": EXPECTED_LAYERS,
412
+ "packed_linear_count": sum(map(len, self._prepared.values())),
413
+ "prepared_bytes": sum(
414
+ record.packed_bytes
415
+ for records in self._prepared.values()
416
+ for record in records
417
+ ),
418
+ "tuned_engine_shapes": sorted(self._tuned_shapes),
419
+ "calls_nvfp4_quantize": False,
420
+ }
421
+
422
+
423
+ def _load_source_free_model_state(
424
+ artifact_path: Path,
425
+ *,
426
+ device: torch.device,
427
+ dtype: torch.dtype,
428
+ drf_block_size: int,
429
+ ):
430
+ from chatterbox_flash.model import ChatterboxFlashT3
431
+
432
+ with torch.device("meta"):
433
+ model = ChatterboxFlashT3(drf_block_size=drf_block_size)
434
+
435
+ with safe_open(
436
+ str(artifact_path), framework="pt", device="cpu",
437
+ ) as handle:
438
+ keys = set(handle.keys())
439
+ forbidden = sorted(key for key in keys if _is_projection_source_key(key))
440
+ if forbidden:
441
+ raise RuntimeError(
442
+ "Canonical artifact unexpectedly contains BF16 projection sources: "
443
+ f"{forbidden[:3]}"
444
+ )
445
+ nonprojection_keys = sorted(
446
+ key for key in keys if not key.startswith("nvfp4.")
447
+ )
448
+ if len(nonprojection_keys) != EXPECTED_NONPROJECTION_TENSORS:
449
+ raise RuntimeError(
450
+ f"Expected {EXPECTED_NONPROJECTION_TENSORS} non-projection tensors, "
451
+ f"found {len(nonprojection_keys)}"
452
+ )
453
+ state = {
454
+ key: handle.get_tensor(key).to(
455
+ device=device,
456
+ dtype=dtype,
457
+ non_blocking=False,
458
+ )
459
+ for key in nonprojection_keys
460
+ }
461
+ state_bytes = sum(_tensor_bytes(tensor) for tensor in state.values())
462
+ if state_bytes != EXPECTED_NONPROJECTION_BYTES:
463
+ raise RuntimeError(
464
+ f"Non-projection payload {state_bytes} bytes != "
465
+ f"{EXPECTED_NONPROJECTION_BYTES} bytes"
466
+ )
467
+ incompatible = model.load_state_dict(state, strict=False, assign=True)
468
+ if incompatible.unexpected_keys:
469
+ raise RuntimeError(
470
+ f"Unexpected non-projection keys: {incompatible.unexpected_keys}"
471
+ )
472
+ unexpected_missing = [
473
+ key for key in incompatible.missing_keys
474
+ if not _is_projection_source_key(key)
475
+ ]
476
+ if unexpected_missing:
477
+ raise RuntimeError(
478
+ f"Artifact is missing non-projection model keys: {unexpected_missing}"
479
+ )
480
+ del state
481
+
482
+ rotary = model.tfmr.rotary_emb
483
+ rotary.inv_freq = handle.get_tensor(
484
+ "nvfp4.runtime.rotary.inv_freq"
485
+ ).to(device=device, dtype=dtype)
486
+ rotary.original_inv_freq = handle.get_tensor(
487
+ "nvfp4.runtime.rotary.original_inv_freq"
488
+ ).to(device=device, dtype=dtype)
489
+
490
+ prior_cache: dict[tuple[Any, ...], torch.Tensor] = {}
491
+ sos = int(model.hp.start_speech_token)
492
+ mask = int(model.mask_token_id)
493
+ prior_prefix = "nvfp4.priors.block_"
494
+ for key in sorted(key for key in keys if key.startswith(prior_prefix)):
495
+ block_size = int(key.removeprefix(prior_prefix))
496
+ prior = handle.get_tensor(key)
497
+ if tuple(prior.shape) != (8_194,) or prior.dtype is not torch.float32:
498
+ raise RuntimeError(
499
+ f"Invalid cached prior {key}: {tuple(prior.shape)}/{prior.dtype}"
500
+ )
501
+ cache_key = (
502
+ block_size,
503
+ sos,
504
+ mask,
505
+ str(dtype),
506
+ str(device),
507
+ )
508
+ prior_cache[cache_key] = prior.to(device=device, non_blocking=False)
509
+
510
+ if drf_block_size not in {
511
+ int(key[0]) for key in prior_cache
512
+ }:
513
+ raise RuntimeError(
514
+ f"Artifact has no cached prior for D={drf_block_size}; source-free "
515
+ "models cannot compute a missing prior through the BF16 transformer"
516
+ )
517
+
518
+ meta_projection_parameters = [
519
+ name for name, parameter in model.named_parameters()
520
+ if parameter.is_meta and _is_projection_source_key(name)
521
+ ]
522
+ unexpected_meta = [
523
+ name for name, parameter in model.named_parameters()
524
+ if parameter.is_meta and not _is_projection_source_key(name)
525
+ ]
526
+ unexpected_meta.extend(
527
+ name for name, buffer in model.named_buffers() if buffer.is_meta
528
+ )
529
+ if len(meta_projection_parameters) != EXPECTED_LAYERS * 7:
530
+ raise RuntimeError(
531
+ f"Expected {EXPECTED_LAYERS * 7} meta projection sentinels, "
532
+ f"found {len(meta_projection_parameters)}"
533
+ )
534
+ if unexpected_meta:
535
+ raise RuntimeError(f"Unexpected meta model tensors: {unexpected_meta}")
536
+
537
+ model._uncond_block_prior_cache = prior_cache
538
+ model._nvfp4_source_free = True
539
+ model.eval()
540
+ return model
541
+
542
+
543
+ def load_prepacked_t3(
544
+ package: str | Path,
545
+ *,
546
+ device: torch.device | str = "cuda",
547
+ dtype: torch.dtype = torch.bfloat16,
548
+ drf_block_size: int = 16,
549
+ autotune_cache: str | Path | None = None,
550
+ manifest_path: str | Path | None = None,
551
+ verify_sha256: bool = True,
552
+ ):
553
+ """Load a source-free T3 plus its reusable FlashInfer projection provider."""
554
+ if int(drf_block_size) not in SUPPORTED_RELEASE_BLOCK_SIZES:
555
+ raise RuntimeError(
556
+ f"Release profiles support D={SUPPORTED_RELEASE_BLOCK_SIZES}; "
557
+ f"D={drf_block_size} was not accepted for release use"
558
+ )
559
+ device = _canonical_cuda_device(device)
560
+ artifact_path, _config_path, manifest = _resolve_package(
561
+ package, manifest_path,
562
+ )
563
+ if verify_sha256:
564
+ expected_sha256 = manifest.get("artifact", {}).get("sha256")
565
+ actual_sha256 = _sha256_file(artifact_path)
566
+ if not expected_sha256 or actual_sha256 != expected_sha256:
567
+ raise RuntimeError(
568
+ f"Artifact SHA-256 mismatch: expected {expected_sha256}, "
569
+ f"found {actual_sha256}"
570
+ )
571
+ model = _load_source_free_model_state(
572
+ artifact_path,
573
+ device=device,
574
+ dtype=dtype,
575
+ drf_block_size=int(drf_block_size),
576
+ )
577
+ provider = CanonicalNVFP4ProjectionProvider(
578
+ artifact_path,
579
+ device=device,
580
+ dtype=dtype,
581
+ autotune_cache=autotune_cache,
582
+ )
583
+ model._flashinfer_projection_provider = provider
584
+ model._nvfp4_manifest = manifest
585
+ return model
586
+
587
+
588
+ def _assemble_tts(
589
+ t3,
590
+ *,
591
+ ve_path: Path,
592
+ s3gen_path: Path,
593
+ tokenizer_path: Path,
594
+ device: torch.device,
595
+ ):
596
+ from chatterbox.models.s3gen import S3Gen
597
+ from chatterbox.models.tokenizers import EnTokenizer
598
+ from chatterbox.models.voice_encoder import VoiceEncoder
599
+ from chatterbox_flash.tts import ChatterboxFlashTTS
600
+
601
+ ve = VoiceEncoder()
602
+ ve.load_state_dict(load_file(str(ve_path)))
603
+ ve.to(device).eval()
604
+
605
+ s3gen = S3Gen(meanflow=True)
606
+ s3gen.load_state_dict(load_file(str(s3gen_path)), strict=False)
607
+ s3gen.to(device).eval()
608
+ tokenizer = EnTokenizer(str(tokenizer_path))
609
+ return ChatterboxFlashTTS(t3, s3gen, ve, tokenizer, device)
610
+
611
+
612
+ def load_prepacked_tts_local(
613
+ package: str | Path,
614
+ base_model_dir: str | Path,
615
+ *,
616
+ device: torch.device | str = "cuda",
617
+ dtype: torch.dtype = torch.bfloat16,
618
+ drf_block_size: int = 16,
619
+ autotune_cache: str | Path | None = None,
620
+ verify_sha256: bool = True,
621
+ verify_base_sha256: bool = True,
622
+ ):
623
+ """Build the complete pipeline without loading ``t3_flash.safetensors``."""
624
+ device = _canonical_cuda_device(device)
625
+ base = Path(base_model_dir).expanduser().resolve()
626
+ t3 = load_prepacked_t3(
627
+ package,
628
+ device=device,
629
+ dtype=dtype,
630
+ drf_block_size=drf_block_size,
631
+ autotune_cache=autotune_cache,
632
+ verify_sha256=verify_sha256,
633
+ )
634
+ base_paths = {
635
+ filename: base / filename for filename in BASE_ASSET_FILENAMES
636
+ }
637
+ if verify_base_sha256:
638
+ _verify_base_assets(base_paths, t3._nvfp4_manifest)
639
+ return _assemble_tts(
640
+ t3,
641
+ ve_path=base_paths["ve.safetensors"],
642
+ s3gen_path=base_paths["s3gen.safetensors"],
643
+ tokenizer_path=base_paths["tokenizer.json"],
644
+ device=device,
645
+ )
646
+
647
+
648
+ def load_prepacked_tts_from_hub(
649
+ repo_id: str,
650
+ *,
651
+ device: torch.device | str = "cuda",
652
+ dtype: torch.dtype = torch.bfloat16,
653
+ drf_block_size: int = 16,
654
+ autotune_cache: str | Path | None = None,
655
+ revision: str | None = None,
656
+ base_repo_id: str = BASE_REPO_ID,
657
+ base_revision: str | None = BASE_CHECKPOINT_REVISION,
658
+ verify_sha256: bool = True,
659
+ verify_base_sha256: bool = True,
660
+ ):
661
+ """Download a derivative artifact plus unmodified base pipeline assets."""
662
+ from huggingface_hub import hf_hub_download
663
+
664
+ artifact_path = Path(hf_hub_download(
665
+ repo_id=repo_id,
666
+ filename=ARTIFACT_FILENAME,
667
+ revision=revision,
668
+ ))
669
+ manifest_path = Path(hf_hub_download(
670
+ repo_id=repo_id,
671
+ filename=MANIFEST_FILENAME,
672
+ revision=revision,
673
+ ))
674
+ base_paths = {
675
+ filename: Path(hf_hub_download(
676
+ repo_id=base_repo_id,
677
+ filename=filename,
678
+ revision=base_revision,
679
+ ))
680
+ for filename in BASE_ASSET_FILENAMES
681
+ }
682
+ device = _canonical_cuda_device(device)
683
+ t3 = load_prepacked_t3(
684
+ artifact_path,
685
+ manifest_path=manifest_path,
686
+ device=device,
687
+ dtype=dtype,
688
+ drf_block_size=drf_block_size,
689
+ autotune_cache=autotune_cache,
690
+ verify_sha256=verify_sha256,
691
+ )
692
+ if verify_base_sha256:
693
+ _verify_base_assets(base_paths, t3._nvfp4_manifest)
694
+ return _assemble_tts(
695
+ t3,
696
+ ve_path=base_paths["ve.safetensors"],
697
+ s3gen_path=base_paths["s3gen.safetensors"],
698
+ tokenizer_path=base_paths["tokenizer.json"],
699
+ device=device,
700
+ )
701
+
702
+
703
+ __all__ = [
704
+ "CanonicalNVFP4ProjectionProvider",
705
+ "PreparedProjection",
706
+ "load_prepacked_t3",
707
+ "load_prepacked_tts_local",
708
+ "load_prepacked_tts_from_hub",
709
+ ]
runtime/chatterbox-flash/chatterbox_flash/text_norm/__init__.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """English text normalization for Chatterbox-Flash inference.
2
+
3
+ This is a stripped-down port of Resemble's ``en_us_cleaner`` pipeline that
4
+ produces identical cleaned text but drops the ``SliceMap`` alignment plumbing
5
+ required for training. It expands numbers, currencies, units, time, phone
6
+ numbers and a small set of abbreviations so the input text matches the form
7
+ the model was trained on, which is essential for WER consistency on the
8
+ LibriSpeech-PC and Seed-TTS evaluation benchmarks.
9
+
10
+ The single entry point is :func:`en_us_cleaner`.
11
+ """
12
+
13
+ from .cleaners import en_us_cleaner
14
+
15
+ __all__ = ["en_us_cleaner"]
runtime/chatterbox-flash/chatterbox_flash/text_norm/cleaners.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``en_us_cleaner`` — exact-output port of Resemble's training-time text
2
+ normalization pipeline (number / time / phone / abbreviation expansion) with
3
+ the ``SliceMap`` alignment plumbing removed.
4
+
5
+ This pipeline runs at inference time before the speech tokenizer; matching the
6
+ training-time form is critical for WER on LibriSpeech-PC / Seed-TTS test-en.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ import unicodedata
13
+
14
+ from .number_norm import normalize_numbers
15
+ from .phone_norm import convert_phone_numbers_to_words
16
+ from .time_norm import convert_time_to_words
17
+
18
+
19
+ _whitespace_re = re.compile(r"\s+")
20
+
21
+ _abbreviations = [
22
+ (re.compile(r"\b%s\b\.?" % abbrev, re.IGNORECASE), expanded)
23
+ for abbrev, expanded in [
24
+ ('mrs', 'misess'),
25
+ ('mr', 'mister'),
26
+ ('dr', 'doctor'),
27
+ ('st', 'saint'),
28
+ ('jr', 'junior'),
29
+ ('maj', 'major'),
30
+ ('gen', 'general'),
31
+ ('drs', 'doctors'),
32
+ ('rev', 'reverend'),
33
+ ('lt', 'lieutenant'),
34
+ ('hon', 'honorable'),
35
+ ('sgt', 'sergeant'),
36
+ ('capt', 'captain'),
37
+ ('esq', 'esquire'),
38
+ ('ltd', 'limited'),
39
+ ('col', 'colonel'),
40
+ ('ft', 'feet'),
41
+ ('abbrev', 'abbreviation'),
42
+ ('ave', 'avenue'),
43
+ ('abstr', 'abstract'),
44
+ ('addr', 'address'),
45
+ ('jan', 'january'),
46
+ ('feb', 'february'),
47
+ ('mar', 'march'),
48
+ ('apr', 'april'),
49
+ ('jul', 'july'),
50
+ ('aug', 'august'),
51
+ ('sep', 'september'),
52
+ ('sept', 'september'),
53
+ ('oct', 'october'),
54
+ ('nov', 'november'),
55
+ ('dec', 'december'),
56
+ ('mon', 'monday'),
57
+ ('tue', 'tuesday'),
58
+ ('wed', 'wednesday'),
59
+ ('thur', 'thursday'),
60
+ ('fri', 'friday'),
61
+ ('sec', 'second'),
62
+ ('min', 'minute'),
63
+ ('mo', 'month'),
64
+ ('yr', 'year'),
65
+ ('cal', 'calorie'),
66
+ ('dept', 'department'),
67
+ ('gal', 'gallon'),
68
+ ('kg', 'kilogram'),
69
+ ('km', 'kilometer'),
70
+ ('mt', 'mount'),
71
+ ('oz', 'ounce'),
72
+ ('vol', 'volume'),
73
+ ('vs', 'versus'),
74
+ ('yd', 'yard'),
75
+ (r'e\.g', 'eg'),
76
+ (r'i\.e', 'ie'),
77
+ ('etc', 'etc'),
78
+ ('ai', 'a i'),
79
+ ('cms', 'c m s'),
80
+ ('cdn', 'c d n'),
81
+ ('ceo', 'c e o'),
82
+ ('tts', 't t s'),
83
+ ]
84
+ ]
85
+
86
+ _custom_symbol_replacements = [
87
+ (re.compile('–'), '-'),
88
+ (re.compile('—'), '-'),
89
+ (re.compile(r'(?<=\d\s)>(?=\s\d)', re.IGNORECASE), 'is greater than'),
90
+ (re.compile(r'(?<=\d\s)<(?=\s\d)', re.IGNORECASE), 'is less than'),
91
+ (re.compile(r'(?<=\D)\s*-\s+(?=\D)'), ', '),
92
+ (re.compile(r'(?<=\D)\s+-\s*(?=\D)'), ', '),
93
+ (re.compile(r'(?<=[a-z])-(?=[a-z])', re.IGNORECASE), ' '),
94
+ (re.compile(r'(?<=[a-z])-(?=\d)', re.IGNORECASE), ' '),
95
+ (re.compile(r'(?<=\d)-(?=[a-z])', re.IGNORECASE), ' '),
96
+ (re.compile(r'(?<=\d)-(?=\d)', re.IGNORECASE), ' - '),
97
+ ]
98
+
99
+ _abbreviations += _custom_symbol_replacements
100
+
101
+
102
+ def _expand_abbreviations(text: str) -> str:
103
+ for regex, replacement in _abbreviations:
104
+ text = regex.sub(replacement, text)
105
+ return text
106
+
107
+
108
+ def _standardize_characters(text: str) -> str:
109
+ return unicodedata.normalize("NFKC", text)
110
+
111
+
112
+ def _collapse_whitespace(text: str) -> str:
113
+ return _whitespace_re.sub(" ", text)
114
+
115
+
116
+ def en_us_cleaner(text: str) -> str:
117
+ """English text normalization pipeline used for both training and the
118
+ paper's evaluations.
119
+
120
+ Matches the cleaned-text output of Resemble's ``transforms.text.cleaners.en_us_cleaner``
121
+ for any normal English input.
122
+ """
123
+ text = _standardize_characters(text)
124
+ text = _collapse_whitespace(text)
125
+ text = _expand_abbreviations(text)
126
+ text = convert_time_to_words(text)
127
+ text = convert_phone_numbers_to_words(text)
128
+ text = normalize_numbers(text)
129
+ return text
runtime/chatterbox-flash/chatterbox_flash/text_norm/number_norm.py ADDED
@@ -0,0 +1,366 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Number / currency / unit / year / ordinal expansion.
2
+
3
+ Verbatim logic ported from Resemble's training-time ``transforms.text.number_norm``
4
+ with the ``SliceMap`` alignment machinery stripped. The resulting cleaned text
5
+ is byte-identical to the original pipeline for any normal English input.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+
13
+ _comma_number_re = re.compile(r'(\(?[A-Z]{2,3})?([\$|£|¥|€|#|\(]*[0-9][0-9\,\.]+[0-9])([^\s]+)?')
14
+ _decimal_number_re = re.compile(r'(number\s)?([0-9]+\.[0-9]+)(\.|,|\?|!)?')
15
+ _hash_number_re = re.compile(r'(#)([0-9]+(?:\.[0-9]+)?)(\.|,|\?|!)?')
16
+
17
+ _pounds_re = re.compile(r'(\(?£)([0-9\.]*[0-9]+)(\.|,|\?|\!)?')
18
+ _yen_re = re.compile(r'(\(?¥)([0-9]+)(\.|,|\?|\!)?')
19
+ _euro_re = re.compile(r'(\(?€)([0-9\.]*[0-9]+)(\.|,|\?|\!)?')
20
+ _dollars_re = re.compile(
21
+ r'(?P<curr>\(?\$)'
22
+ r'(?P<val>[0-9,]*\.?[0-9]+)'
23
+ r'(?P<punc>[\.|,|\?|\!|\)]+)?'
24
+ )
25
+
26
+ _curr_abbrev_re = re.compile(
27
+ r'(?P<curr>\(?[$£¥€])'
28
+ r'(?P<val>[0-9]*\.?[0-9]+)'
29
+ r'(?:(?P<unit1>[BKMT])|(?:\s(?P<unit2>[BMbmTtr]+illion)))'
30
+ r'(?P<punc>[.,?!)]+)?'
31
+ )
32
+
33
+ _ml_re = re.compile(r'([0-9\.]*[0-9]+)(ml)(\.|,|\?|!)?')
34
+ _cl_re = re.compile(r'([0-9\.]*[0-9]+)(cl)(\.|,|\?|!)?')
35
+ _g_re = re.compile(r'([0-9\.]*[0-9]+)(g)(\.|,|\?|!)?')
36
+ _l_re = re.compile(r'([0-9\.]*[0-9]+)(l)(\.|,|\?|!)?')
37
+ _m_re = re.compile(r'([0-9\.]*[0-9]+)(m)(\.|,|\?|!)?')
38
+ _kg_re = re.compile(r'([0-9\.]*[0-9]+)(kg)(\.|,|\?|!)?')
39
+ _mm_re = re.compile(r'([0-9\.]*[0-9]+)(mm)(\.|,|\?|!)?')
40
+ _cm_re = re.compile(r'([0-9\.]*[0-9]+)(cm)(\.|,|\?|!)?')
41
+ _km_re = re.compile(r'([0-9\.]*[0-9]+)(km)(\.|,|\?|!)?')
42
+ _in_re = re.compile(r'([0-9\.]*[0-9]+)(in)(\.|,|\?|!)?')
43
+ _ft_re = re.compile(r'([0-9\.]*[0-9]+)(ft)(\.|,|\?|!)?')
44
+ _yd_re = re.compile(r'([0-9\.]*[0-9]+)(yd[s]?)(\.|,|\?|!)?')
45
+ _s_re = re.compile(r'([0-9\.]*[0-9]+)(s[ecs]*)(\.|,|\?|!)?')
46
+ _percent = re.compile(r'([0-9]{1,3}(?:,[0-9]{3})*(\.[0-9]+)?)(%)([.,?!])?')
47
+
48
+ _ordinal_re = re.compile(r'([0-9]+)(st|nd|rd|th)')
49
+ _number_re = re.compile(r'([0-9]+)(\.|,|\?|!)?')
50
+ _year_re = re.compile(
51
+ r'(to|years|year|from|after|before|by|until|in|since|around|circa|during)'
52
+ r'(\s)?'
53
+ r'(?<!\$|£|¥|€|₩|₹|₽|฿|₫|₦|₪|₱|₴)'
54
+ r'(1[1-9]|20)'
55
+ r'([0-9]{2})'
56
+ r'($|\-|\s\-|\D)',
57
+ re.IGNORECASE,
58
+ )
59
+
60
+ _units = [
61
+ '', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine',
62
+ 'ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen',
63
+ 'seventeen', 'eighteen', 'nineteen',
64
+ ]
65
+ _tens = [
66
+ '', 'ten', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy',
67
+ 'eighty', 'ninety',
68
+ ]
69
+ _digit_groups = ['', 'thousand', 'million', 'billion', 'trillion', 'quadrillion']
70
+
71
+ _ordinal_suffixes = [
72
+ ('one', 'first'),
73
+ ('two', 'second'),
74
+ ('three', 'third'),
75
+ ('five', 'fifth'),
76
+ ('eight', 'eighth'),
77
+ ('nine', 'ninth'),
78
+ ('twelve', 'twelfth'),
79
+ ('ty', 'tieth'),
80
+ ]
81
+
82
+ _sub_ten_nums = {"00", "01", "02", "03", "04", "05", "06", "07", "08", "09"}
83
+
84
+ _curr_dict = {"$": "dollars", "£": "pounds", "¥": "yen", "€": "euros"}
85
+ _unit_dict = {"B": "billion", "K": "thousand", "M": "million", "T": "trillion"}
86
+
87
+
88
+ def standard_number_to_words(n: int, digit_group: int) -> str:
89
+ parts = []
90
+ if n >= 1000:
91
+ parts.append(standard_number_to_words(n // 1000, digit_group + 1))
92
+ n = n % 1000
93
+ if n >= 100:
94
+ parts.append('%s hundred' % _units[n // 100])
95
+ if n % 100 >= len(_units):
96
+ parts.append(_tens[(n % 100) // 10])
97
+ parts.append(_units[(n % 100) % 10])
98
+ else:
99
+ parts.append(_units[n % 100])
100
+ if n > 0:
101
+ parts.append(_digit_groups[digit_group])
102
+ return ' '.join(x for x in parts if x)
103
+
104
+
105
+ def _number_to_words(n: int) -> str:
106
+ if n >= 1000000000000000000:
107
+ return str(n)
108
+ if n == 0:
109
+ return 'zero'
110
+ if n % 100 == 0 and n % 1000 != 0 and n < 3000:
111
+ return standard_number_to_words(n // 100, 0) + ' hundred'
112
+ return standard_number_to_words(n, 0)
113
+
114
+
115
+ def _remove_commas(match):
116
+ _, m1, m2 = match.groups(default="")
117
+ return ''.join([m1.replace(",", ""), m2])
118
+
119
+
120
+ def _expand_year(match):
121
+ prep, _, mill_cent, dec_year, post = match.groups(default="")
122
+ if mill_cent == "20" and dec_year in _sub_ten_nums:
123
+ year_out = mill_cent + dec_year
124
+ elif dec_year in _sub_ten_nums:
125
+ year = dec_year[-1]
126
+ year_out = mill_cent + " " + "oh" + " " + year
127
+ else:
128
+ year_out = _number_to_words(int(mill_cent)) + " " + _number_to_words(int(dec_year))
129
+
130
+ if "-" in post:
131
+ year_out += " to"
132
+ else:
133
+ year_out += post
134
+ return prep + " " + year_out
135
+
136
+
137
+ def _re_sub(pattern, repl, text: str) -> str:
138
+ """Backwards (end-to-beginning) regex substitution, identical match order
139
+ to the training-time ``aligned_re_sub`` so non-overlapping match decisions
140
+ line up exactly."""
141
+ matches = sorted(re.finditer(pattern, text), key=lambda m: m.start(), reverse=True)
142
+ for m in matches:
143
+ middle = repl(m) if callable(repl) else repl
144
+ text = text[:m.start()] + middle + text[m.end():]
145
+ return text
146
+
147
+
148
+ # --- helpers operating on (word, _) mapping list, but with the SliceMap dropped ---
149
+
150
+
151
+ def _to_word_list(text: str):
152
+ words = re.split(r"(\s+)", text)
153
+ return [[w] for w in words]
154
+
155
+
156
+ def _from_word_list(mapping) -> str:
157
+ return ''.join(t[0] for t in mapping)
158
+
159
+
160
+ def _convert_hash(mapping):
161
+ text = _from_word_list(mapping)
162
+ matches = re.findall(_hash_number_re, text)
163
+ for result in matches:
164
+ m = ''.join(result)
165
+ r_clean = ' '.join(["number", result[1]])
166
+ if len(result) == 3:
167
+ r_clean = r_clean + result[2]
168
+ for i, t in enumerate(mapping):
169
+ if m == t[0]:
170
+ mapping[i] = [r_clean]
171
+ return mapping
172
+
173
+
174
+ def _expand_decimal_point(mapping):
175
+ text = _from_word_list(mapping)
176
+ matches = re.findall(_decimal_number_re, text)
177
+ for m in matches:
178
+ integral, fractional = m[1].split('.')
179
+ expanded_frac = ""
180
+ frac_it = iter(list(fractional))
181
+ for digit in frac_it:
182
+ if digit == "0":
183
+ expanded_frac += "zero "
184
+ else:
185
+ expanded_frac += digit + " "
186
+ for rest in frac_it:
187
+ expanded_frac += rest + " "
188
+ out = m[0] + integral + ' point ' + expanded_frac.strip()
189
+ if len(m) == 3:
190
+ out = out + m[2]
191
+ raw = ''.join(m)
192
+ for i, t in enumerate(mapping):
193
+ if t[0] == raw:
194
+ mapping[i] = [out]
195
+ return mapping
196
+
197
+
198
+ def _expand_dollars(mapping):
199
+ text = _from_word_list(mapping)
200
+ matches = re.findall(_dollars_re, text)
201
+ for m in matches:
202
+ val = m[1]
203
+ punc = m[2] if m[2] else ''
204
+ parts = val.split('.')
205
+ whole_num = int(parts[0]) if parts[0] else 0
206
+ fraction = int(parts[1]) if len(parts) > 1 and parts[1] else 0
207
+ dollar_unit = 'dollar' if whole_num == 1 else 'dollars'
208
+ cent_unit = 'cent' if fraction == 1 else 'cents'
209
+ if whole_num and fraction:
210
+ out = '%s %s, %s %s' % (whole_num, dollar_unit, fraction, cent_unit)
211
+ elif whole_num:
212
+ out = '%s %s' % (whole_num, dollar_unit)
213
+ elif fraction:
214
+ out = '%s %s' % (fraction, cent_unit)
215
+ else:
216
+ out = 'zero dollars'
217
+ if punc:
218
+ out = out + punc
219
+ raw = ''.join(m)
220
+ for i, t in enumerate(mapping):
221
+ if t[0] == raw:
222
+ mapping[i] = [out]
223
+ return mapping
224
+
225
+
226
+ def _expand_other_currency(mapping, regex, one, many):
227
+ text = _from_word_list(mapping)
228
+ matches = re.findall(regex, text)
229
+ for m in matches:
230
+ parts = m[1].split(".")
231
+ curr = one if int(parts[0]) == 1 else many
232
+ try:
233
+ out = parts[0] + " " + curr + " " + parts[1]
234
+ except IndexError:
235
+ out = parts[0] + " " + curr
236
+ raw = ''.join(m)
237
+ for i, t in enumerate(mapping):
238
+ if t[0] == raw:
239
+ mapping[i] = [out]
240
+ return mapping
241
+
242
+
243
+ def _expand_abbreviated_currency_unit(mapping):
244
+ text = _from_word_list(mapping)
245
+ matches = re.findall(_curr_abbrev_re, text)
246
+ to_remove = []
247
+ for m in matches:
248
+ curr = m[0]
249
+ val = m[1]
250
+ unit = m[2] if m[2] else m[3]
251
+ punc = m[4] if m[4] else ''
252
+ curr = curr.strip("(")
253
+ val_parts = val.split(".")
254
+ if len(val_parts) > 1:
255
+ val_out = val_parts[0] + ' ' + 'point ' + ' '.join(val_parts[1])
256
+ else:
257
+ val_out = val_parts[0]
258
+ try:
259
+ out = ' '.join([val_out, _unit_dict[unit], _curr_dict[curr]])
260
+ out = out + punc
261
+ raw = ''.join(m)
262
+ except KeyError:
263
+ out = ' '.join([val_out, unit, _curr_dict[curr]])
264
+ out = out + punc
265
+ raw = ''.join(m[:-2]) + " " + unit + punc
266
+ for i, t in enumerate(mapping):
267
+ if t[0] == raw:
268
+ mapping[i] = [out]
269
+ try:
270
+ join_text = ''.join([mapping[i][0], mapping[i + 1][0], mapping[i + 2][0]])
271
+ if join_text == raw:
272
+ mapping[i] = [out]
273
+ to_remove.append(i + 1)
274
+ to_remove.append(i + 2)
275
+ except IndexError:
276
+ continue
277
+ return [v for i, v in enumerate(mapping) if i not in to_remove]
278
+
279
+
280
+ def _expand_other_unit(mapping, regex, one, many):
281
+ text = _from_word_list(mapping)
282
+ matches = re.findall(regex, text)
283
+ for m in matches:
284
+ parts = re.split(r"\.", m[0])
285
+ unit = one if parts[0] == "1" else many
286
+ if len(parts) > 1:
287
+ dec = ''.join([i + " " for i in parts[1]])
288
+ parts = parts[0] + " " + "point" + " " + dec
289
+ unit = many
290
+ out = parts + unit
291
+ else:
292
+ out = parts[0] + " " + unit
293
+ out = out + m[-1]
294
+ raw = ''.join(m)
295
+ for i, t in enumerate(mapping):
296
+ if t[0] == raw:
297
+ mapping[i] = [out]
298
+ return mapping
299
+
300
+
301
+ def _expand_ordinal(mapping):
302
+ text = _from_word_list(mapping)
303
+ matches = re.findall(_ordinal_re, text)
304
+ for m in matches:
305
+ num = _number_to_words(int(m[0]))
306
+ out = num + 'th'
307
+ for suffix, replacement in _ordinal_suffixes:
308
+ if num.endswith(suffix):
309
+ out = num[:-len(suffix)] + replacement
310
+ break
311
+ raw = ''.join(m)
312
+ for i, t in enumerate(mapping):
313
+ if t[0] == raw:
314
+ mapping[i] = [out]
315
+ return mapping
316
+
317
+
318
+ def _expand_number(mapping):
319
+ text = _from_word_list(mapping)
320
+ matches = re.findall(_number_re, text)
321
+ for m in matches:
322
+ out = _number_to_words(int(m[0])) + m[1]
323
+ raw = ''.join(m)
324
+ for i, t in enumerate(mapping):
325
+ text_re_nums = re.search(r"\d+", t[0])
326
+ if text_re_nums is None:
327
+ continue
328
+ if m[0] == text_re_nums.group():
329
+ rep = re.escape(raw)
330
+ j = re.sub(rep, out, t[0])
331
+ mapping[i] = [j]
332
+ return mapping
333
+
334
+
335
+ def normalize_numbers(text: str) -> str:
336
+ text = _re_sub(_comma_number_re, _remove_commas, text)
337
+ # year norm is applied twice on purpose (matches training pipeline)
338
+ text = _re_sub(_year_re, _expand_year, text)
339
+ text = _re_sub(_year_re, _expand_year, text)
340
+
341
+ mapping = _to_word_list(text)
342
+ mapping = _expand_abbreviated_currency_unit(mapping)
343
+ mapping = _expand_other_currency(mapping, _pounds_re, "pound", "pounds")
344
+ mapping = _expand_other_currency(mapping, _yen_re, "yen", "yen")
345
+ mapping = _expand_other_currency(mapping, _euro_re, "euro", "euros")
346
+ mapping = _expand_other_unit(mapping, _ml_re, "milliliter", "milliliters")
347
+ mapping = _expand_other_unit(mapping, _cl_re, "centiliter", "centiliters")
348
+ mapping = _expand_other_unit(mapping, _g_re, "gram", "grams")
349
+ mapping = _expand_other_unit(mapping, _kg_re, "kilogram", "kilograms")
350
+ mapping = _expand_other_unit(mapping, _mm_re, "millimeter", "millimeters")
351
+ mapping = _expand_other_unit(mapping, _cm_re, "centimeter", "centimeters")
352
+ mapping = _expand_other_unit(mapping, _km_re, "kilometer", "kilometers")
353
+ mapping = _expand_other_unit(mapping, _in_re, "inch", "inches")
354
+ mapping = _expand_other_unit(mapping, _ft_re, "foot", "feet")
355
+ mapping = _expand_other_unit(mapping, _l_re, "liter", "liters")
356
+ mapping = _expand_other_unit(mapping, _m_re, "meter", "meters")
357
+ mapping = _expand_other_unit(mapping, _yd_re, "yard", "yards")
358
+ mapping = _expand_other_unit(mapping, _s_re, "second", "seconds")
359
+ mapping = _expand_other_unit(mapping, _percent, "%", "percent")
360
+ mapping = _expand_dollars(mapping)
361
+ mapping = _convert_hash(mapping)
362
+ mapping = _expand_decimal_point(mapping)
363
+ mapping = _expand_ordinal(mapping)
364
+ mapping = _expand_number(mapping)
365
+
366
+ return _from_word_list(mapping)
runtime/chatterbox-flash/chatterbox_flash/text_norm/phone_norm.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phone number expansion (e.g. "555-1234" -> "five five five, one two three four")
2
+ ported from ``transforms.text.phone_num_norm`` without SliceMap."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+
8
+
9
+ _NUM_WORDS = {
10
+ 0: 'zero', 1: 'one', 2: 'two', 3: 'three', 4: 'four',
11
+ 5: 'five', 6: 'six', 7: 'seven', 8: 'eight', 9: 'nine',
12
+ }
13
+
14
+ _NON_ALPHA = re.compile(r"(\s?[^a-zA-Z]+\s?)")
15
+ _PHONE_NUM_CHARS = set(list("0123456789.+-()[] "))
16
+
17
+
18
+ def is_phone_num(text: str) -> bool:
19
+ text = text.rstrip('.!?,;')
20
+ if text.count(".") == 1:
21
+ return False
22
+ if any(c.isalpha() for c in text):
23
+ return False
24
+ if any(c not in _PHONE_NUM_CHARS for c in text):
25
+ return False
26
+ nums = [c for c in text if c.isdigit()]
27
+ if not (5 < len(nums) < 14):
28
+ return False
29
+ if "+" not in text and "." not in text and list(text).count("-") == 1:
30
+ nums = [[c for c in subtext if c.isdigit()] for subtext in text.split("-")]
31
+ if len(nums) == 2 and all(
32
+ subnums[0] in ("1", "2") and len(subnums) == 4 for subnums in nums
33
+ ):
34
+ return False
35
+ return True
36
+
37
+
38
+ def _group_num(text):
39
+ prev_was_digit = False
40
+ group = []
41
+ num_groups = []
42
+ for c in text:
43
+ if c.isdigit():
44
+ if len(group) == 0 or prev_was_digit:
45
+ group.append(c)
46
+ else:
47
+ num_groups.append(group)
48
+ group = [c]
49
+ prev_was_digit = True
50
+ else:
51
+ prev_was_digit = False
52
+ if group:
53
+ num_groups.append(group)
54
+ return num_groups
55
+
56
+
57
+ def _natural_phone_num_grouping(nums, max_natural_group=4):
58
+ if len(nums) == 0:
59
+ return []
60
+ if len(nums) <= max_natural_group:
61
+ return [nums]
62
+ if len(nums) == 5:
63
+ return [nums[:2], nums[2:]]
64
+ if len(nums) == 8:
65
+ return [nums[:2], nums[2:5], nums[5:]]
66
+ return _natural_phone_num_grouping(nums[:-max_natural_group], 3) + [nums[-max_natural_group:]]
67
+
68
+
69
+ def _convert_phone_num_to_words(text: str) -> str:
70
+ original_punctuation = ''
71
+ while text and text[-1] in '.!?,;':
72
+ original_punctuation = text[-1] + original_punctuation
73
+ text = text[:-1]
74
+
75
+ num_groups = _group_num(text)
76
+ num_word_groups = [
77
+ [" " + _NUM_WORDS[int(c)] for c in nums if c.isdigit()] for nums in num_groups
78
+ ]
79
+
80
+ output = []
81
+ for num_word_group in reversed(num_word_groups):
82
+ grouplen = len(num_word_group)
83
+ regroupped = [num_word_group]
84
+ if grouplen > 4:
85
+ regroupped = reversed(_natural_phone_num_grouping(num_word_group))
86
+ for new_group in regroupped:
87
+ output = ["".join(new_group)] + output
88
+
89
+ output = ",".join(output).strip()
90
+ parts = re.split(r"\s", text)
91
+ output = (" " if parts[0] == "" else "") + output + (" " if parts[-1] == "" else "")
92
+ output += original_punctuation
93
+ return output
94
+
95
+
96
+ def _convert_phone_num_match_to_words(match: re.Match) -> str:
97
+ text = match.group()
98
+ if is_phone_num(text):
99
+ return _convert_phone_num_to_words(text)
100
+ return text
101
+
102
+
103
+ def convert_phone_numbers_to_words(text: str) -> str:
104
+ matches = sorted(_NON_ALPHA.finditer(text), key=lambda m: m.start(), reverse=True)
105
+ for m in matches:
106
+ text = text[: m.start()] + _convert_phone_num_match_to_words(m) + text[m.end():]
107
+ return text
runtime/chatterbox-flash/chatterbox_flash/text_norm/time_norm.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Time-of-day expansion (12:30, 3.45 pm, ...) ported from
2
+ ``transforms.text.time_norm`` without the SliceMap return value."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+
8
+ from .number_norm import standard_number_to_words
9
+
10
+
11
+ _time_pattern = re.compile(
12
+ r'(?P<hour>\d{1,2})(?P<delim>[\.\:])?\s*(?P<min>\d{2})?\s*(?P<am_pm>[AaPp]\.?[Mm]\.?)?'
13
+ )
14
+
15
+
16
+ def _time_to_words(match: re.Match) -> str:
17
+ gd = match.groupdict()
18
+ hours = int(gd['hour'])
19
+ delim = gd.get("delim")
20
+ minutes = gd.get("min")
21
+ am_pm = gd.get("am_pm")
22
+
23
+ has_minutes = minutes is not None
24
+ has_am_pm = am_pm is not None
25
+ is_time = has_am_pm or (delim == ":" and has_minutes) or (delim == "." and has_am_pm)
26
+ if not is_time:
27
+ return match.group()
28
+
29
+ minutes_i = int(minutes) if has_minutes else 0
30
+ am_pm = (' ' + am_pm.replace('.', '').strip().upper()) if has_am_pm else ''
31
+ hour_word = standard_number_to_words(hours, 0)
32
+
33
+ if minutes_i < 10 and minutes_i != 0:
34
+ minute_word = standard_number_to_words(minutes_i, 0)
35
+ minute_word = f"oh {minute_word}"
36
+ else:
37
+ minute_word = standard_number_to_words(minutes_i, 0) if minutes_i > 0 else ""
38
+
39
+ if minutes_i > 0:
40
+ minute_word = f" {minute_word}"
41
+
42
+ return f"{hour_word}{minute_word}{am_pm}"
43
+
44
+
45
+ def convert_time_to_words(text: str) -> str:
46
+ matches = sorted(_time_pattern.finditer(text), key=lambda m: m.start(), reverse=True)
47
+ for m in matches:
48
+ text = text[: m.start()] + _time_to_words(m) + text[m.end():]
49
+ return text
runtime/chatterbox-flash/chatterbox_flash/tts.py ADDED
@@ -0,0 +1,398 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """User-facing API for Chatterbox-Flash."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import logging
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Iterable, Literal
10
+
11
+ import librosa
12
+ import numpy as np
13
+ import torch
14
+ import torch.nn.functional as F
15
+ from huggingface_hub import hf_hub_download
16
+ from safetensors.torch import load_file
17
+
18
+ from chatterbox.models.s3gen import S3GEN_SR, S3Gen
19
+ from chatterbox.models.s3tokenizer import S3_SR
20
+ from chatterbox.models.t3.modules.cond_enc import T3Cond
21
+ from chatterbox.models.tokenizers import EnTokenizer
22
+ from chatterbox.models.voice_encoder import VoiceEncoder
23
+
24
+ from .model import ChatterboxFlashT3
25
+ from .text_norm import en_us_cleaner
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ DEFAULT_REPO_ID = "ResembleAI/chatterbox-flash"
30
+
31
+ # Per-file names expected inside a checkpoint dir / HF repo.
32
+ T3_FILENAME = "t3_flash.safetensors"
33
+ S3GEN_FILENAME = "s3gen.safetensors"
34
+ VE_FILENAME = "ve.safetensors"
35
+ TOKENIZER_FILENAME = "tokenizer.json"
36
+
37
+ # Conditioning durations (mirrored from chatterbox-tts).
38
+ ENC_COND_LEN = 6 * S3_SR
39
+ DEC_COND_LEN = 10 * S3GEN_SR
40
+
41
+
42
+ @dataclass
43
+ class Conditionals:
44
+ """Prepared reference-speech conditioning for one speaker.
45
+
46
+ Bundles the T3 conditioning (speaker embedding + prompt speech tokens)
47
+ and the S3Gen reference dict produced by :meth:`S3Gen.embed_ref`. Use
48
+ :meth:`save` / :meth:`load` to cache these for repeated generations.
49
+ """
50
+
51
+ t3: T3Cond
52
+ gen: dict
53
+
54
+ def to(self, device) -> "Conditionals":
55
+ self.t3 = self.t3.to(device=device)
56
+ for k, v in self.gen.items():
57
+ if torch.is_tensor(v):
58
+ self.gen[k] = v.to(device=device)
59
+ return self
60
+
61
+ def save(self, fpath: Path) -> None:
62
+ torch.save({"t3": self.t3.__dict__, "gen": self.gen}, fpath)
63
+
64
+ @classmethod
65
+ def load(cls, fpath, map_location="cpu") -> "Conditionals":
66
+ if isinstance(map_location, str):
67
+ map_location = torch.device(map_location)
68
+ kwargs = torch.load(fpath, map_location=map_location, weights_only=True)
69
+ return cls(T3Cond(**kwargs["t3"]), kwargs["gen"])
70
+
71
+
72
+ def _speech_len_for_text_tokens(n_text_tokens: int) -> int:
73
+ """Heuristic max-speech-token budget per utterance (matches paper config)."""
74
+ return max(n_text_tokens * 6, 300)
75
+
76
+
77
+ class ChatterboxFlashTTS:
78
+ """Zero-shot TTS pipeline matching the configuration in the paper.
79
+
80
+ Stage 1 is the :class:`ChatterboxFlashT3` block-diffusion decoder.
81
+ Stage 2 is the unmodified Chatterbox-TTS S3Gen flow-matching vocoder.
82
+ Reference encoding uses the GE2E-trained voice encoder from Chatterbox.
83
+ """
84
+
85
+ def __init__(
86
+ self,
87
+ t3: ChatterboxFlashT3,
88
+ s3gen: S3Gen,
89
+ ve: VoiceEncoder,
90
+ tokenizer: EnTokenizer,
91
+ device: str | torch.device,
92
+ conds: Conditionals | None = None,
93
+ ) -> None:
94
+ self.sr = S3GEN_SR
95
+ self.t3 = t3
96
+ self.s3gen = s3gen
97
+ self.ve = ve
98
+ self.tokenizer = tokenizer
99
+ self.device = torch.device(device) if isinstance(device, str) else device
100
+ self.conds = conds
101
+
102
+ # -------- construction ------------------------------------------------
103
+
104
+ @classmethod
105
+ def from_local(
106
+ cls,
107
+ ckpt_dir: str | Path,
108
+ device: str | torch.device,
109
+ *,
110
+ dtype: torch.dtype = torch.bfloat16,
111
+ drf_block_size: int = 16,
112
+ ) -> "ChatterboxFlashTTS":
113
+ """Load a Chatterbox-Flash checkpoint from a local directory.
114
+
115
+ Expects ``t3_flash.safetensors`` (with the expanded MASK row),
116
+ ``s3gen.safetensors``, ``ve.safetensors`` and ``tokenizer.json``.
117
+ """
118
+ ckpt_dir = Path(ckpt_dir)
119
+ device = torch.device(device) if isinstance(device, str) else device
120
+
121
+ ve = VoiceEncoder()
122
+ ve.load_state_dict(load_file(str(ckpt_dir / VE_FILENAME)))
123
+ ve.to(device).eval()
124
+
125
+ t3 = ChatterboxFlashT3(drf_block_size=drf_block_size)
126
+ t3_state = load_file(str(ckpt_dir / T3_FILENAME))
127
+ if "model" in t3_state: # legacy layout
128
+ t3_state = t3_state["model"][0]
129
+ t3.load_state_dict(t3_state)
130
+ t3.to(device=device, dtype=dtype).eval()
131
+ # Prime the PMI unconditional block prior now (at load) so the first
132
+ # generate() doesn't fold the one-shot prior forward into the timed /
133
+ # RTF loop. A generate() with a different block size recomputes it
134
+ # automatically (cache keyed on block_size/dtype/device).
135
+ t3.prime_uncond_block_prior(dtype, device)
136
+
137
+ # ``meanflow=True``: the released ``s3gen.safetensors`` is the
138
+ # meanflow-distilled vocoder, sampled with the meanflow ODE path at 2
139
+ # CFM steps (matches the internal ``S3GenStreamer`` defaults). On a
140
+ # standard-CFM checkpoint this would degrade quality — flip to False.
141
+ s3gen = S3Gen(meanflow=True)
142
+ s3gen.load_state_dict(
143
+ load_file(str(ckpt_dir / S3GEN_FILENAME)), strict=False,
144
+ )
145
+ s3gen.to(device).eval()
146
+
147
+ tokenizer = EnTokenizer(str(ckpt_dir / TOKENIZER_FILENAME))
148
+
149
+ return cls(t3, s3gen, ve, tokenizer, device)
150
+
151
+ @classmethod
152
+ def from_pretrained(
153
+ cls,
154
+ repo_id: str = DEFAULT_REPO_ID,
155
+ device: str | torch.device = "cuda",
156
+ *,
157
+ dtype: torch.dtype = torch.bfloat16,
158
+ drf_block_size: int = 16,
159
+ ) -> "ChatterboxFlashTTS":
160
+ """Fetch a Chatterbox-Flash release from the Hugging Face Hub."""
161
+ last = None
162
+ for fname in (
163
+ VE_FILENAME, T3_FILENAME, S3GEN_FILENAME, TOKENIZER_FILENAME,
164
+ ):
165
+ last = hf_hub_download(repo_id=repo_id, filename=fname)
166
+ return cls.from_local(
167
+ Path(last).parent, device,
168
+ dtype=dtype, drf_block_size=drf_block_size,
169
+ )
170
+
171
+ # -------- conditioning -----------------------------------------------
172
+
173
+ def prepare_conditionals(
174
+ self,
175
+ wav_fpath: str | Path,
176
+ exaggeration: float = 0.5,
177
+ ) -> Conditionals:
178
+ """Build the speaker / prompt-speech conditioning for a reference wav.
179
+
180
+ The reference is resampled and segmented identically to chatterbox-tts:
181
+ the first 6 s feed S3's speech tokenizer to produce prompt speech
182
+ tokens, the first 10 s feed S3Gen's :meth:`embed_ref`, and the GE2E
183
+ voice encoder produces the global speaker embedding.
184
+ """
185
+ s3gen_ref_wav, _sr = librosa.load(str(wav_fpath), sr=S3GEN_SR)
186
+ ref_16k_wav = librosa.resample(s3gen_ref_wav, orig_sr=S3GEN_SR, target_sr=S3_SR)
187
+
188
+ s3gen_ref_wav = s3gen_ref_wav[:DEC_COND_LEN]
189
+ s3gen_ref_dict = self.s3gen.embed_ref(
190
+ s3gen_ref_wav, S3GEN_SR, device=self.device,
191
+ )
192
+
193
+ t3_cond_prompt_tokens = None
194
+ if plen := self.t3.hp.speech_cond_prompt_len:
195
+ tokens, _ = self.s3gen.tokenizer.forward(
196
+ [ref_16k_wav[:ENC_COND_LEN]], max_len=plen,
197
+ )
198
+ t3_cond_prompt_tokens = torch.atleast_2d(tokens).to(self.device)
199
+
200
+ t3_dtype = next(self.t3.parameters()).dtype
201
+ ve_embed = torch.from_numpy(
202
+ self.ve.embeds_from_wavs([ref_16k_wav], sample_rate=S3_SR),
203
+ )
204
+ ve_embed = ve_embed.mean(axis=0, keepdim=True).to(device=self.device, dtype=t3_dtype)
205
+
206
+ t3_cond = T3Cond(
207
+ speaker_emb=ve_embed,
208
+ cond_prompt_speech_tokens=t3_cond_prompt_tokens,
209
+ emotion_adv=exaggeration * torch.ones(1, 1, 1, dtype=t3_dtype),
210
+ ).to(device=self.device)
211
+ self.conds = Conditionals(t3_cond, s3gen_ref_dict)
212
+ return self.conds
213
+
214
+ # -------- tokenization -----------------------------------------------
215
+
216
+ def _encode_text(
217
+ self, text: str, normalize_text: bool = True,
218
+ ) -> torch.Tensor:
219
+ if normalize_text:
220
+ text = en_us_cleaner(text)
221
+ text_tokens = self.tokenizer.text_to_tokens(text).to(self.device)
222
+ text_tokens = F.pad(text_tokens, (1, 0), value=self.t3.hp.start_text_token)
223
+ text_tokens = F.pad(text_tokens, (0, 1), value=self.t3.hp.stop_text_token)
224
+ return text_tokens
225
+
226
+ # -------- inference --------------------------------------------------
227
+
228
+ @torch.inference_mode()
229
+ def generate(
230
+ self,
231
+ text: str,
232
+ *,
233
+ audio_prompt_path: str | Path | None = None,
234
+ exaggeration: float = 0.5,
235
+ normalize_text: bool = True,
236
+ num_steps: int = 10,
237
+ temperature: float = 0.6,
238
+ time_shift_tau: float = 0.1,
239
+ omnivoice_schedule_t_shift: float = 0.5,
240
+ cfg_scale: float = 1.0,
241
+ position_temperature: float = 5.0,
242
+ pmi_uncond_prior_precompute: bool = True,
243
+ use_cuda_graph: bool = True,
244
+ backend: Literal["auto", "flashinfer", "torch", "mlx"] = "auto",
245
+ max_speech_tokens: int | None = None,
246
+ n_cfm_timesteps: int = 2,
247
+ ) -> torch.Tensor:
248
+ """Synthesize a single utterance.
249
+
250
+ Parameters mirror the inference defaults from the paper (best
251
+ configuration). Returns the synthesized waveform as a 1xT float
252
+ tensor at :attr:`sr` Hz. See
253
+ :meth:`ChatterboxFlashT3.generate` for the full parameter spec.
254
+ """
255
+ if audio_prompt_path is not None:
256
+ self.prepare_conditionals(audio_prompt_path, exaggeration=exaggeration)
257
+ if self.conds is None:
258
+ raise ValueError(
259
+ "Conditioning not prepared — pass audio_prompt_path or call "
260
+ "prepare_conditionals() first.",
261
+ )
262
+
263
+ text_tokens = self._encode_text(text, normalize_text=normalize_text)
264
+ n_tokens = int(text_tokens.size(1))
265
+ total_speech_len = _speech_len_for_text_tokens(n_tokens)
266
+ if max_speech_tokens is not None:
267
+ total_speech_len = min(total_speech_len, max_speech_tokens)
268
+
269
+ speech_tokens = self.t3.generate(
270
+ t3_cond=self.conds.t3,
271
+ text_tokens=text_tokens,
272
+ text_token_lens=torch.tensor([n_tokens], device=self.device),
273
+ total_speech_len=total_speech_len,
274
+ num_steps=num_steps,
275
+ temperature=temperature,
276
+ time_shift_tau=time_shift_tau,
277
+ omnivoice_schedule_t_shift=omnivoice_schedule_t_shift,
278
+ cfg_scale=cfg_scale,
279
+ position_temperature=position_temperature,
280
+ pmi_uncond_prior_precompute=pmi_uncond_prior_precompute,
281
+ use_cuda_graph=use_cuda_graph,
282
+ backend=backend,
283
+ batch_size=1,
284
+ )
285
+ speech_tokens = self._trim_to_eos(speech_tokens[0])
286
+ wav, _ = self.s3gen.inference(
287
+ speech_tokens=speech_tokens.to(self.device),
288
+ ref_dict=self.conds.gen,
289
+ n_cfm_timesteps=n_cfm_timesteps,
290
+ )
291
+ return wav.detach().cpu().squeeze(0)
292
+
293
+ @torch.inference_mode()
294
+ def generate_batch(
295
+ self,
296
+ texts: Iterable[str],
297
+ *,
298
+ audio_prompt_paths: Iterable[str | Path] | None = None,
299
+ conds_list: Iterable[Conditionals] | None = None,
300
+ exaggeration: float = 0.5,
301
+ normalize_text: bool = True,
302
+ num_steps: int = 10,
303
+ temperature: float = 0.6,
304
+ time_shift_tau: float = 0.1,
305
+ omnivoice_schedule_t_shift: float = 0.5,
306
+ cfg_scale: float = 1.0,
307
+ position_temperature: float = 5.0,
308
+ pmi_uncond_prior_precompute: bool = True,
309
+ use_cuda_graph: bool = True,
310
+ backend: Literal["auto", "flashinfer", "torch", "mlx"] = "auto",
311
+ max_speech_tokens: int | None = None,
312
+ n_cfm_timesteps: int = 2,
313
+ ) -> list[torch.Tensor]:
314
+ """Synthesize a heterogeneous batch (one prompt + text per row).
315
+
316
+ Exactly one of ``audio_prompt_paths`` and ``conds_list`` must be
317
+ provided. Both ``texts`` and the conditioning iterable must have the
318
+ same length. Returns a list of 1D waveforms (one per row).
319
+ """
320
+ texts = list(texts)
321
+ if audio_prompt_paths is not None and conds_list is not None:
322
+ raise ValueError("Pass exactly one of audio_prompt_paths / conds_list.")
323
+ if audio_prompt_paths is None and conds_list is None:
324
+ raise ValueError("Pass either audio_prompt_paths or conds_list.")
325
+
326
+ if conds_list is None:
327
+ conds_list = [
328
+ dataclasses.replace(
329
+ self.prepare_conditionals(p, exaggeration=exaggeration),
330
+ ) if False else self.prepare_conditionals(p, exaggeration=exaggeration)
331
+ for p in audio_prompt_paths
332
+ ]
333
+ else:
334
+ conds_list = list(conds_list)
335
+ if len(conds_list) != len(texts):
336
+ raise ValueError(
337
+ f"texts ({len(texts)}) and conditioning ({len(conds_list)}) "
338
+ "must have equal length.",
339
+ )
340
+
341
+ text_tokens_list: list[torch.Tensor] = []
342
+ speech_lens: list[int] = []
343
+ for t in texts:
344
+ tt = self._encode_text(t, normalize_text=normalize_text)
345
+ text_tokens_list.append(tt)
346
+ sl = _speech_len_for_text_tokens(int(tt.size(1)))
347
+ if max_speech_tokens is not None:
348
+ sl = min(sl, max_speech_tokens)
349
+ speech_lens.append(sl)
350
+
351
+ speech_tokens_batch = self.t3.generate(
352
+ t3_cond=[c.t3 for c in conds_list],
353
+ text_tokens=text_tokens_list,
354
+ text_token_lens=None,
355
+ total_speech_len=speech_lens,
356
+ num_steps=num_steps,
357
+ temperature=temperature,
358
+ time_shift_tau=time_shift_tau,
359
+ omnivoice_schedule_t_shift=omnivoice_schedule_t_shift,
360
+ cfg_scale=cfg_scale,
361
+ position_temperature=position_temperature,
362
+ pmi_uncond_prior_precompute=pmi_uncond_prior_precompute,
363
+ use_cuda_graph=use_cuda_graph,
364
+ backend=backend,
365
+ )
366
+
367
+ wavs: list[torch.Tensor] = []
368
+ for i, sl in enumerate(speech_lens):
369
+ row = speech_tokens_batch[i : i + 1, :sl]
370
+ row = self._trim_to_eos(row[0]).unsqueeze(0)
371
+ wav, _ = self.s3gen.inference(
372
+ speech_tokens=row.to(self.device),
373
+ ref_dict=conds_list[i].gen,
374
+ n_cfm_timesteps=n_cfm_timesteps,
375
+ )
376
+ wavs.append(wav.detach().cpu().squeeze(0))
377
+ return wavs
378
+
379
+ # -------- internal ---------------------------------------------------
380
+
381
+ def _trim_to_eos(self, speech_tokens: torch.Tensor) -> torch.Tensor:
382
+ """Truncate at the first stop-speech token and drop non-codec ids.
383
+
384
+ T3 generates ids in ``[0, hp.speech_tokens_dict_size)``, but S3Gen's
385
+ flow embedding is sized to the raw S3 codec vocab
386
+ (``hp.start_speech_token``). Anything ``>= start_speech_token`` —
387
+ the SOS / EOS specials, the ``[MASK]`` row, or never-unmasked slots —
388
+ must be removed before vocoding.
389
+ """
390
+ if speech_tokens.numel() == 0:
391
+ return speech_tokens.long()
392
+ stop = self.t3.hp.stop_speech_token
393
+ eos = (speech_tokens == stop).nonzero(as_tuple=True)[0]
394
+ if len(eos) > 0:
395
+ speech_tokens = speech_tokens[: eos[0].item()]
396
+ codec_vocab = int(self.t3.hp.start_speech_token) # 6561 for chatterbox
397
+ speech_tokens = speech_tokens[speech_tokens < codec_vocab]
398
+ return speech_tokens.long()
runtime/chatterbox-flash/pyproject.toml ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=61", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "chatterbox-flash-nvfp4"
7
+ version = "0.1.0+nvfp4.1"
8
+ description = "Unofficial source-free NVFP4 W4A16 runtime for Chatterbox-Flash T3 on NVIDIA Blackwell."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ { name = "Resemble AI" },
14
+ { name = "Community NVFP4 contributors" },
15
+ ]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "Intended Audience :: Science/Research",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Topic :: Multimedia :: Sound/Audio :: Speech",
26
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
27
+ ]
28
+ dependencies = [
29
+ # T3 / S3Gen / VoiceEncoder / EnTokenizer come from chatterbox-tts. Note that
30
+ # chatterbox-tts 0.1.7 hard-pins ``torch==2.6.0``, ``torchaudio==2.6.0``,
31
+ # ``transformers==5.2.0``, ``librosa==0.11.0`` and ``safetensors==0.5.3``.
32
+ # We deliberately leave those ranges loose here and let the chatterbox-tts
33
+ # solve drive the install — otherwise uv flips them back-and-forth between
34
+ # each invocation.
35
+ "chatterbox-tts>=0.1.7",
36
+ "torch>=2.6",
37
+ "torchaudio>=2.6",
38
+ "transformers>=4.46",
39
+ "safetensors>=0.4",
40
+ "huggingface-hub>=0.24",
41
+ "numpy>=1.24",
42
+ "tqdm>=4.66",
43
+ "soundfile>=0.12",
44
+ "librosa>=0.10",
45
+ "scipy>=1.11",
46
+ "regex",
47
+ "inflect>=7.0",
48
+ "Unidecode>=1.3",
49
+ # Used by the top-level ``synthesize.py`` CLI to parse YAML configs in
50
+ # ``configs/`` (best / best_no_early_dec / fast). The library itself
51
+ # does not import PyYAML.
52
+ "pyyaml>=6.0",
53
+ ]
54
+
55
+ [project.optional-dependencies]
56
+ # FlashInfer kernels (required for batched / CUDA-graph inference).
57
+ # Install matching your CUDA build: pip install flashinfer-python
58
+ flashinfer = [
59
+ # The canonical loader depends on the CuTe-DSL W4A16 preparation and GEMM
60
+ # APIs validated for this release. Older 0.6.11 builds resolve from the
61
+ # upstream lock but do not reproduce the accepted runtime.
62
+ "flashinfer-python==0.6.14",
63
+ "nvidia-cutlass-dsl==4.5.2",
64
+ ]
65
+
66
+ # Apple Silicon (Mac) native Metal backend. Pulls in mlx + mlx-lm; the
67
+ # mlx-lm LLaMA implementation is used as the backbone with our custom
68
+ # inputs_embeds bypass. CPU/GPU paths do not import these.
69
+ mlx = [
70
+ "mlx>=0.13; sys_platform == 'darwin'",
71
+ "mlx-lm>=0.10; sys_platform == 'darwin'",
72
+ ]
73
+
74
+ # Evaluation suite (SIM-o / WER / UTMOS) via the OmniVoice pip package.
75
+ eval = [
76
+ "omnivoice>=0.1.5",
77
+ # omnivoice's WER eval imports the legacy ``jiwer.compute_measures``, which
78
+ # was removed in jiwer 4.0. Cap below 4 so the import keeps working.
79
+ "jiwer>=3.0,<4",
80
+ "zhon>=2.0",
81
+ "whisper-normalizer>=0.0.10",
82
+ # omnivoice's SIM-o (speaker similarity) loads the WavLM-large upstream via
83
+ # torch.hub, whose hubconf imports ``s3prl`` (s3prl.util.download +
84
+ # s3prl.upstream.wavlm.expert). omnivoice does not declare it, so pull it in
85
+ # here — otherwise Stage 2/3 SIM-o crashes with ModuleNotFoundError: s3prl.
86
+ "s3prl>=0.4",
87
+ ]
88
+
89
+ [project.urls]
90
+ Upstream = "https://github.com/resemble-ai/chatterbox-flash"
91
+ BaseModel = "https://huggingface.co/ResembleAI/chatterbox-flash"
92
+ Model = "https://huggingface.co/ajh-code/chatterbox-flash-t3-w4a16-nvfp4"
93
+
94
+ [tool.setuptools.packages.find]
95
+ where = ["."]
96
+ include = ["chatterbox_flash*"]
97
+
98
+ [tool.setuptools.package-data]
99
+ chatterbox_flash = ["py.typed"]
100
+
101
+ # ----------------------------------------------------------------------------
102
+ # uv-specific configuration
103
+ # ----------------------------------------------------------------------------
104
+ #
105
+ # ``chatterbox-tts==0.1.7`` hard-pins ``torch==2.6.0`` / ``torchaudio==2.6.0`` /
106
+ # ``transformers==5.2.0``, but most modern wheels installed alongside it
107
+ # (``torchvision``, ``xformers``, ``flash-attn``, ``flashinfer-python``) ship
108
+ # only torch-2.7 ABI binaries. Letting uv honour the chatterbox-tts pin causes
109
+ # a cascade of ABI mismatches: ``torchvision::nms does not exist``,
110
+ # ``flash_attn_2_cuda.so: undefined symbol``, etc.
111
+ #
112
+ # We therefore override the upper-stream pins to ``>=2.7`` for the install
113
+ # itself. The chatterbox-tts code paths we use (T3 / S3Gen / VE / EnTokenizer)
114
+ # work correctly against torch 2.7 — only the install-time metadata is stale.
115
+ [tool.uv]
116
+ override-dependencies = [
117
+ # Pin to the 2.7.x ABI that the rest of the wheel ecosystem (torchvision,
118
+ # xformers, flash-attn, flashinfer-python) currently ships matched binaries
119
+ # against. Bumping the upper bound requires re-checking those wheels.
120
+ "torch==2.7.1",
121
+ "torchaudio==2.7.1",
122
+ "torchvision==0.22.1",
123
+ "transformers==5.9.0",
124
+ ]
125
+
126
+ # Use the exact CUDA 12.8 wheels validated on Blackwell. Marking this index
127
+ # explicit keeps unrelated packages on PyPI while routing only the PyTorch
128
+ # family below to the official cu128 wheel repository.
129
+ [[tool.uv.index]]
130
+ name = "pytorch-cu128"
131
+ url = "https://download.pytorch.org/whl/cu128"
132
+ explicit = true
133
+
134
+ [tool.uv.sources]
135
+ torch = { index = "pytorch-cu128" }
136
+ torchaudio = { index = "pytorch-cu128" }
137
+ torchvision = { index = "pytorch-cu128" }
runtime/chatterbox-flash/uv.lock ADDED
The diff for this file is too large to render. See raw diff
 
t3_nvfp4.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c5ea2d1d1441f657b383d8dad54bbae3778447d675822fb15918cb99b3587f1e
3
+ size 341439040