Skip to content

Commit 7191cb3

Browse files
authored
Merge pull request #114 from generative-computing/bugfix/51-52-audio-prompt-placeholder-invariants
Fixes three defects in the ASR audio cascade, plus the packaging gap that blocked testing them.
2 parents cae2898 + 6dcf80d commit 7191cb3

21 files changed

Lines changed: 1022 additions & 252 deletions

.github/workflows/gpu-tests.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ jobs:
1616
with:
1717
enable-cache: true
1818

19-
- run: uv sync --frozen --group dev --extra hf --extra vllm --extra compose
19+
# `--extra audio` is redundant with the dev group (which now includes it) but
20+
# stated explicitly: the audio tests need soundfile/librosa at runtime, and a
21+
# group refactor should not silently drop them again.
22+
- run: uv sync --frozen --group dev --extra hf --extra vllm --extra compose --extra audio
2023

2124
- name: Run GPU tests
2225
run: |

docs/AUDIO.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,22 @@ intentionally simple and requires no training. The "proper" upgrade (feeding a
99
trained projection of a speech encoder's embeddings straight into the LLM) reuses
1010
the same hooks — see [Design](#design) below.
1111

12+
## Installing
13+
14+
The audio path needs `soundfile` and `librosa` on top of the vLLM backend — they
15+
decode and resample the incoming waveform. They live in the `audio` extra, which is
16+
**not** part of `vllm`, so a plain `uv sync --extra vllm` gives you a checkpoint that
17+
fails on any non-16 kHz input:
18+
19+
```bash
20+
# Serving an audio-enabled checkpoint
21+
uv sync --extra vllm --extra audio # or --extra vllm20 --extra audio
22+
23+
# Development / running the test suite (the dev groups include audio already)
24+
uv sync --group dev # vLLM 0.19.x
25+
uv sync --group dev-vllm20 # vLLM 0.20.x
26+
```
27+
1228
## Building an audio-enabled checkpoint
1329

1430
Add `--enable-audio` when composing:
@@ -183,6 +199,12 @@ Per request, before the scheduler allocates KV cache:
183199
3. A `PromptReplacement` swaps the `<|audio|>` marker for those transcript token
184200
ids. The scheduler then sizes KV for the **real** length — the audio "window"
185201
is variable and decided at runtime, not reserved in advance.
202+
A clip with no recognizable speech in it — silence, music, noise, or a clip
203+
too short to hold a word — transcribes to the empty string. Since every audio
204+
item has to occupy at least one prompt position (vLLM discards a zero-length
205+
placeholder and then rejects the request), those clips are replaced with a
206+
single space instead: the model sees an audio turn that said nothing, rather
207+
than an error.
186208
4. The model's `embed_multimodal` supplies embeddings for those positions. In the
187209
alpha that is simply the transcript's own token embeddings (identical to
188210
embedding them as text). **This is the seam the future encoder reuses:** swap
@@ -220,6 +242,17 @@ adapter behaves identically to the text equivalent.
220242

221243
## Tests
222244

245+
Everything on the audio path carries the `audio` marker, so the whole tier selects
246+
in one command regardless of where the tests live:
247+
248+
```bash
249+
# All audio tests (13 of them need a GPU and a real checkpoint)
250+
pytest -m audio -v -s --tb=short
251+
252+
# CPU tier only — runs in a few seconds
253+
pytest -m "audio and not gpu" -v -s --tb=short
254+
```
255+
223256
- `tests/unit/test_asr.py` — CPU unit tests for the ASR backend (audio coercion,
224257
resampling, transcription with a mocked pipeline, pipeline-kwargs cache keying,
225258
and per-request decode-kwargs resolution). No GPU/vLLM required.

pyproject.toml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,17 @@ markers = [
5353
"slow: takes > 30s",
5454
"deep: expensive code-theory tests (m=8 / 256-dim); run with: pytest -m deep",
5555
"requires_model: needs a real model checkpoint",
56+
"audio: exercises the audio/ASR path; run with: pytest -m audio",
5657
]
5758

5859
[dependency-groups]
5960
vllm19 = ["vllm>=0.19.1,<0.20.0"]
6061
vllm20 = ["vllm>=0.20.0,<0.21.0"]
61-
dev = ["pytest", "pytest-cov", { include-group = "vllm19" }, "granite-switch[hf,compose]"]
62-
dev-vllm20 = ["pytest", "pytest-cov", { include-group = "vllm20" }, "granite-switch[hf,compose]"]
62+
# `audio` is included so the audio tests can actually run: the ASR path needs
63+
# soundfile/librosa at runtime, and no group pulled them in before (integration
64+
# tests failed with ModuleNotFoundError on a synced pod).
65+
dev = ["pytest", "pytest-cov", { include-group = "vllm19" }, "granite-switch[hf,compose,audio]"]
66+
dev-vllm20 = ["pytest", "pytest-cov", { include-group = "vllm20" }, "granite-switch[hf,compose,audio]"]
6367
test = ["pytest", "pytest-cov", "bitsandbytes", "optimum-quanto", { include-group = "dev" }]
6468

6569
[tool.uv]

src/granite_switch/composer/compose_granite_switch.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
configure_chat_template,
6868
get_alora_first_invocation_token_id,
6969
)
70+
from granite_switch.composer.validator import validate_control_lut
7071
from granite_switch.config import ASR_DTYPES
7172

7273
# ---------------------------------------------------------------------------
@@ -871,7 +872,14 @@ def build():
871872
or args.asr_chunk_length_s is not None
872873
or args.asr_chunk_overlap_s is not None
873874
)
874-
audio_token_id = add_audio_token(tokenizer) if audio_enabled else None
875+
# The control tokens are re-passed so this call doesn't drop them from the
876+
# tokenizer's additional-special-tokens list (add_special_tokens replaces
877+
# that list rather than extending it).
878+
audio_token_id = (
879+
add_audio_token(tokenizer, keep_special_tokens=special_tokens)
880+
if audio_enabled
881+
else None
882+
)
875883

876884
# Configure chat template with adapter mappings (Granite models only).
877885
# Non-Granite models preserve the upstream template verbatim because
@@ -1011,6 +1019,23 @@ def build():
10111019
new_embed_size = model.model.embed_tokens.weight.shape[0]
10121020
print(f"Embeddings resized: {old_embed_size} -> {new_embed_size}")
10131021

1022+
# The switch sized its control->substitute table from the pre-resize
1023+
# config.vocab_size (copied from the base model), so the resize above leaves
1024+
# it short of the config this checkpoint will ship with. Re-derive it, then
1025+
# assert the two agree — see validate_control_lut for why a mismatch is not
1026+
# something the loader can recover from.
1027+
switch = getattr(model.model, "switch", None)
1028+
if switch is not None:
1029+
lut = getattr(switch, "control_to_substitute_lut", None)
1030+
if lut is not None and lut.numel() != model.config.vocab_size:
1031+
old_lut_size = lut.numel()
1032+
switch.rebuild_control_to_substitute_lut(model.config)
1033+
print(
1034+
"Switch control LUT rebuilt: "
1035+
f"{old_lut_size} -> {switch.control_to_substitute_lut.numel()}"
1036+
)
1037+
validate_control_lut(model)
1038+
10141039
print(f"\nStep 3 complete in {time.time() - step_start:.2f}s")
10151040

10161041
return (

src/granite_switch/composer/tokenizer_setup.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,21 +94,38 @@ def add_control_tokens(
9494
return adapter_token_ids, special_tokens
9595

9696

97-
def add_audio_token(tokenizer, marker: str = "<|audio|>") -> int:
97+
def add_audio_token(
98+
tokenizer,
99+
marker: str = "<|audio|>",
100+
keep_special_tokens: list[str] | None = None,
101+
) -> int:
98102
"""Add the audio placeholder marker token to the tokenizer.
99103
100104
Used for the audio cascade: this single special token is placed in the
101105
prompt and the vLLM ASR processor replaces it with the transcript tokens at
102106
request time (see granite_switch.vllm.audio). Registering it as one special
103107
token keeps the processor's prompt-replacement match clean.
104108
109+
``keep_special_tokens`` must list every token an earlier
110+
``add_special_tokens({"additional_special_tokens": ...})`` call registered —
111+
in practice the adapter control tokens from :func:`add_control_tokens`.
112+
That call *replaces* the additional-special-tokens list instead of appending
113+
to it, and transformers exposes no way to read the current list back, so any
114+
token not re-passed here silently drops out of ``all_special_tokens`` and
115+
out of the saved ``tokenizer_config.json``. Re-passing an already-added
116+
token is free: it keeps its id and does not grow the vocabulary.
117+
105118
Must be called before the model's embedding resize so the new row is sized
106119
in. Returns the marker's token id.
107120
"""
108121
print(f"\nAdding audio marker token: {marker}")
109-
tokenizer.add_special_tokens({"additional_special_tokens": [marker]})
122+
# Marker last so it takes the next free id and the kept tokens keep theirs.
123+
kept = [t for t in (keep_special_tokens or []) if t != marker]
124+
tokenizer.add_special_tokens({"additional_special_tokens": [*kept, marker]})
110125
token_id = tokenizer.convert_tokens_to_ids(marker)
111126
print(f" {marker}: {token_id}")
127+
if kept:
128+
print(f" (preserved {len(kept)} existing special token(s))")
112129
return token_id
113130

114131

src/granite_switch/composer/validator.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,39 @@
1313
from .arch import ArchDescriptor
1414

1515

16+
def validate_control_lut(model) -> None:
17+
"""Check the switch's control->substitute table matches the shipped config.
18+
19+
The table is a persistent buffer sized from ``config.vocab_size``, so a
20+
checkpoint whose buffer length disagrees with its own ``config.json`` is
21+
internally inconsistent. Loading one is not a graceful degradation:
22+
``from_pretrained`` discards the mismatched tensor and leaves the buffer as
23+
uninitialised memory, which turns every token into a "control" token and
24+
sends out-of-range ids into the embedding gather — surfacing as an opaque
25+
CUDA ``srcIndex < srcSelectDimSize`` device-side assert far from the cause.
26+
27+
Raises:
28+
ValueError: if the table length differs from ``config.vocab_size``.
29+
"""
30+
switch = getattr(getattr(model, "model", None), "switch", None)
31+
lut = getattr(switch, "control_to_substitute_lut", None)
32+
if lut is None:
33+
return # no token-exchange mapping on this model
34+
35+
expected = getattr(model.config, "vocab_size", None)
36+
if expected is None or lut.numel() == expected:
37+
return
38+
39+
raise ValueError(
40+
f"control_to_substitute_lut has {lut.numel()} entries but "
41+
f"config.vocab_size is {expected}. Saving this model would produce a "
42+
f"checkpoint that cannot be loaded correctly. The table is derived from "
43+
f"vocab_size and the adapter token ids, so rebuild it after any "
44+
f"vocabulary change with "
45+
f"switch.rebuild_control_to_substitute_lut(model.config)."
46+
)
47+
48+
1649
def validate_all_parameters(
1750
model,
1851
arch: ArchDescriptor,

src/granite_switch/hf/switch/single.py

Lines changed: 63 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,35 @@
1919
from transformers.models.granite.modeling_granite import eager_attention_forward
2020

2121

22+
def build_control_to_substitute_lut(config) -> torch.Tensor | None:
23+
"""Derive the control->substitute lookup table from *config*.
24+
25+
Shape ``[max(vocab_size, max_ctrl_id + 1)]``: ``-1`` at every non-control id
26+
and the substitute id at each control slot. The ``max`` keeps every control
27+
id addressable even when ``vocab_size`` lags the tokenizer.
28+
29+
Returns ``None`` when *config* carries no token-exchange mapping, in which
30+
case the switch leaves ``input_ids`` untouched.
31+
32+
Single source of truth for the sizing rule: the table is a pure function of
33+
``vocab_size``, ``adapter_token_ids`` and ``adapter_substitute_token_ids``,
34+
so anything that changes those must re-derive it (see
35+
:meth:`SingleSwitch.rebuild_control_to_substitute_lut`).
36+
"""
37+
if config is None:
38+
return None
39+
ctrl_ids = getattr(config, "adapter_token_ids", None)
40+
sub_ids = getattr(config, "adapter_substitute_token_ids", None)
41+
if not ctrl_ids or not sub_ids:
42+
return None
43+
44+
lut_size = max(getattr(config, "vocab_size", 0), max(ctrl_ids) + 1)
45+
lut = torch.full((lut_size,), -1, dtype=torch.long)
46+
for ctrl_id, sub_id in zip(ctrl_ids, sub_ids):
47+
lut[ctrl_id] = sub_id
48+
return lut
49+
50+
2251
class SingleSwitch(nn.Module):
2352
"""Single-head attention-based switch for adapter selection.
2453
@@ -89,22 +118,44 @@ def __init__(
89118
# control-token positions carry the substitute id by the time the
90119
# decoder embeds them. The decoder is then oblivious — it just calls
91120
# embed_tokens(input_ids) and gets the right result by construction.
92-
if (
93-
config is not None
94-
and getattr(config, "adapter_token_ids", None) is not None
95-
and getattr(config, "adapter_substitute_token_ids", None) is not None
96-
):
97-
ctrl_ids = config.adapter_token_ids
98-
sub_ids = config.adapter_substitute_token_ids
99-
max_ctrl_id = max(ctrl_ids)
100-
lut_size = max(getattr(config, "vocab_size", 0), max_ctrl_id + 1)
101-
lut = torch.full((lut_size,), -1, dtype=torch.long)
102-
for ctrl_id, sub_id in zip(ctrl_ids, sub_ids):
103-
lut[ctrl_id] = sub_id
121+
lut = build_control_to_substitute_lut(config)
122+
if lut is not None:
104123
self.register_buffer("control_to_substitute_lut", lut)
105124
else:
106125
self.control_to_substitute_lut = None
107126

127+
def rebuild_control_to_substitute_lut(self, config=None) -> bool:
128+
"""Re-derive the control->substitute table after a vocabulary change.
129+
130+
``__init__`` sizes the table from ``config.vocab_size``, so anything that
131+
grows the vocabulary afterwards — notably
132+
``resize_token_embeddings`` when compose adds control and marker tokens —
133+
leaves the buffer shorter than the config it will be saved alongside.
134+
135+
That matters because the buffer is persistent. On ``from_pretrained``,
136+
a stored tensor whose shape disagrees with the freshly-constructed one is
137+
discarded and the buffer is left as uninitialised memory (there is no
138+
``_init_weights`` rule for it), so every id reads as a control id and the
139+
rewrite sends out-of-range ids into the embedding gather. Re-derive the
140+
table before saving so the checkpoint and its config agree.
141+
142+
Returns ``True`` if a table was rebuilt, ``False`` if this switch has no
143+
token-exchange mapping to rebuild.
144+
"""
145+
lut = build_control_to_substitute_lut(
146+
config if config is not None else self.config
147+
)
148+
if lut is None:
149+
return False
150+
151+
existing = getattr(self, "control_to_substitute_lut", None)
152+
if existing is not None:
153+
lut = lut.to(device=existing.device)
154+
self.control_to_substitute_lut = lut
155+
else:
156+
self.register_buffer("control_to_substitute_lut", lut)
157+
return True
158+
108159
@property
109160
def num_cache_layers(self) -> int:
110161
"""Number of cache slots used."""

src/granite_switch/vllm/audio/processor.py

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@
4242

4343
AUDIO_MARKER = "<|audio|>"
4444
_TARGET_SR = 16_000
45+
# Stands in for a transcript with no speech in it. Must tokenize to at least one
46+
# token: vLLM discards a zero-length placeholder and then reports the item as
47+
# missing, so every audio item has to contribute something to the prompt.
48+
_EMPTY_TRANSCRIPT_TEXT = " "
4549
# Keeps the transcript budget finite if max_model_len cannot be read.
4650
_FALLBACK_CONTEXT_LEN = 8192
4751
_DUMMY_AUDIO_SECONDS = 5
@@ -159,7 +163,14 @@ def _transcribe(
159163
generate_kwargs: Mapping[str, object] | None = None,
160164
) -> list[int]:
161165
"""Transcribe one audio item to token ids. Never truncated here — an
162-
oversized prompt is rejected by vLLM's own length check."""
166+
oversized prompt is rejected by vLLM's own length check.
167+
168+
Guaranteed non-empty: silence, music, non-speech or a clip too short to
169+
contain a word all transcribe to ``""``, and a zero-length replacement
170+
would make vLLM drop the placeholder and reject the request. Those clips
171+
get :data:`_EMPTY_TRANSCRIPT_TEXT` instead, so the model sees an audio
172+
turn that simply said nothing.
173+
"""
163174
transcriber = get_transcriber(
164175
model_id=self.info._asr_model_id(),
165176
device=self.info._asr_device(),
@@ -176,7 +187,19 @@ def _transcribe(
176187
chunk_overlap_s=self.info._asr_chunk_overlap_s(),
177188
)
178189
tokenizer = self.info.get_tokenizer()
179-
return tokenizer.encode(text, add_special_tokens=False)
190+
if not text or not text.strip():
191+
text = _EMPTY_TRANSCRIPT_TEXT
192+
ids = tokenizer.encode(text, add_special_tokens=False)
193+
if not ids:
194+
# Only reachable if the tokenizer drops _EMPTY_TRANSCRIPT_TEXT
195+
# entirely. Fail loudly rather than emit a zero-length placeholder,
196+
# which surfaces as an opaque "found 0 prompt placeholders".
197+
raise ValueError(
198+
f"Tokenizer produced no tokens for {text!r}; cannot build a "
199+
"prompt placeholder for this audio item. Choose an "
200+
"_EMPTY_TRANSCRIPT_TEXT this tokenizer encodes to >=1 token."
201+
)
202+
return ids
180203

181204
def _call_hf_processor(
182205
self,
@@ -215,6 +238,31 @@ def _call_hf_processor(
215238
tensor_type="pt",
216239
)
217240

241+
def _hf_processor_applies_updates(
242+
self,
243+
prompt_text: str,
244+
mm_items: MultiModalDataItems,
245+
hf_processor_mm_kwargs: Mapping[str, object],
246+
tokenization_kwargs: Mapping[str, object],
247+
) -> bool:
248+
"""Always False: ``_call_hf_processor`` leaves the marker in place.
249+
250+
The base implementation returns True for raw (non-embedding) items,
251+
which tells vLLM the processor already expanded the placeholder itself —
252+
so vLLM skips applying our ``PromptReplacement`` and merely *searches*
253+
the returned prompt for the transcript token ids. They are not there, and
254+
it raises ``Expected there to be 1 audio prompt placeholders ... found 0``.
255+
256+
Unlike a real HF processor (e.g. Ultravox's), we tokenize the prompt with
257+
the ``<|audio|>`` marker untouched and hand the transcript back out of
258+
band, so the replacement must be applied by vLLM.
259+
260+
Only the uncached path consults this hook; the cached path already
261+
hardcodes False, which is why audio works with the default
262+
``mm_processor_cache_gb=4`` and breaks under ``--mm-processor-cache-gb 0``.
263+
"""
264+
return False
265+
218266
def _get_mm_fields_config(
219267
self,
220268
hf_inputs: BatchFeature,

0 commit comments

Comments
 (0)