Skip to content

Default ASR to Granite Speech TurboCTC, and raise transformers to 5.16 - #130

Draft
aviv1ron1 wants to merge 4 commits into
mainfrom
feature/granite-turboctc-default
Draft

Default ASR to Granite Speech TurboCTC, and raise transformers to 5.16#130
aviv1ron1 wants to merge 4 commits into
mainfrom
feature/granite-turboctc-default

Conversation

@aviv1ron1

@aviv1ron1 aviv1ron1 commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Makes Granite Speech TurboCTC the default ASR backend for the audio cascade, and raises transformers to the release that ships its architecture.

Four commits, no change to the decoder, switch or LoRA paths.

1. TurboCTC becomes the ASR default (ef43e69)

DEFAULT_ASR_MODEL_ID moves from distil-whisper/distil-small.en to ibm-granite/granite-speech-5.0-470m-turboctc — a 470M English conformer CTC encoder.

Being CTC rather than generative changes three things that callers can see, all documented in docs/AUDIO.md:

  • No generate() to steer. Decode kwargs (language, task) are dropped rather than forwarded, so the per-request language override is inert on the default model. A multilingual generative backend is still one --asr-model away.
  • Transcripts are lowercase and unpunctuated (what is the capital of israel). They are spliced into the prompt as ordinary text, so the model reads them that way.
  • Defaults retuned for a fixed-window encoder: asr_self_chunks=False and asr_chunk_length_s=120.0, so clips up to two minutes reach the backend whole and only longer ones are split by our own chunker. The HF pipeline's own CTC chunking is deliberately never used — it rescales stride by inputs_to_logits_ratio, which this checkpoint does not publish, so it would silently fall back to 1 and trim every seam at the wrong offset.

asr_device now defaults to cuda and asr_dtype resolves to bfloat16 there — the checkpoint's own dtype, and it keeps float32's exponent range, which suits an encoder carrying BatchNorm in every conv block.

Dependency change: transformers>=5.5.1,<5.17.0 in the core package, and transformers>=5.16.0 on the audio extra — 5.16 is where granite_speech5_ctc landed. The requirement sits on the extra so the rest of the package still works on an older release. uv.lock regenerated: transformers 5.8.1 → 5.16.1.

Two consequences of that bump are handled in the same commit:

  • ATTENTION_LAYER_TYPES now accepts both attention and full_attention. transformers 5.16 renamed the layer-type value and rewrites it inside PreTrainedConfig.__init__, so comparing against the bare string silently dropped the attention LoRA target groups and left adapters with MLP targets only.
  • IsHybrid is no longer declared. It requires mamba-state hooks this model neither implements nor needs — the composer normalizes every layer to attention, so a composed checkpoint has zero mamba layers. vLLM's escape hatch for exactly that shape compares the literal "attention", so the rename flipped is_hybrid true and engine init began failing for a mamba dtype hook. Dropping the marker is the fix rather than implementing the hooks: several other vLLM behaviours gate on is_hybrid and none should apply to a pure-attention model.

2. A test helper stops leaking a mock (7b7faa6) — closes #121

_patched_pipeline in tests/unit/test_asr.py patched transformers.pipelines.pipeline before transformers.pipeline. mock.patch.__enter__ records the current value to restore later, and transformers is a lazy module: resolving transformers.pipeline when it is not yet cached goes through transformers.pipelines. With the submodule patched first, the second patch read back the mock, recorded it as "the original", and faithfully restored it on exit — leaving the mock installed for the rest of the process.

Swapping the two lines is the whole fix. TestPatchedPipelineRestores guards both attributes and forces the lazy-resolve precondition rather than relying on collection order; it was verified to fail on the old ordering, so it is a real guard rather than a tautology.

3. Supply vLLM's missing layer-type key (8a07b33) — closes #122

vLLM ≤0.25 keys its Granite-hybrid layer table on the pre-5.16 spelling:

ALL_DECODER_LAYER_TYPES = {"attention": ..., "mamba": ...}
layer_class = ALL_DECODER_LAYER_TYPES[config.layer_types[layer_idx]]   # KeyError: 'full_attention'

Since transformers ≥5.16 can only produce the new spelling, register() now aliases it onto the same layer class. Two things this repairs:

  • The equivalence tier's reference model. Those tests build their upstream comparison with transformers, save_pretrained it, and hand the directory to vllm.LLM; the saved config carries the new spelling and its architectures field routes to vLLM's own class, so the reference could not load. GraniteSwitchForCausalLM was never affected — it builds layers through a closure and does not consult that table.
  • Serving an un-composed Granite 4.0 hybrid checkpoint. In a venv built from this branch the pairing is transformers>=5.16 (from our audio extra) with vLLM pinned ≤0.25, so vllm serve ibm-granite/granite-4.0-micro raised. Composed Granite Switch checkpoints did not.

Why it lives in register(): the lookup runs in vLLM's spawned engine-core process, so nothing patched in the parent survives. vLLM loads its plugins in that process during init, reading the vllm.general_plugins entry point we already declare — it is the only hook we own that executes there.

Aliasing adds a name, not behaviour: both keys resolve to the identical class object. setdefault makes the block a no-op on vLLM 0.26+, where upstream added the key themselves, so it can be deleted on a version bump without touching anything else.

4. Audio support for the Granite MoE architecture

No source code changes, only documentration and tests.

  • docs/AUDIO.md — the audio cascade over a pure sparse MoE (granitemoe) base. Hand-merged rather than overwritten, since this file had already diverged here.
  • tests/composer/test_audio_marker_output_row.py — the marker/reserved-row copy is now parametrized over MLP topology as well as embedding tying, 2 cases → 4 (untied/tied × dense/sparse-MoE). The fixup only ever touches embedding rows, so both topologies must behave identically; the sparse arm is what would notice a shared-MLP-shaped assumption creeping into the model construction it runs against.
  • tests/composer/test_granitemoe_audio_compose.py — new, 9 tests: audio does not resurrect the shared MLP, does not widen the adapter surface and leaves no zero-width parameter; the control LUT goes stale on the marker and refresh fixes it idempotently and passes the validator; the marker output row; and survival across save/load.

… 5.16

Re-applies the TurboCTC work onto public main by hand, rather than by
cherry-picking the six commits it was developed as (09c52c2..9f1e5e8 on the
staging branch). Public main has since absorbed #95, #104, #116 and #121, so
several of the files had moved underneath the original patches.

What changes, in five parts:

  * DEFAULT_ASR_MODEL_ID becomes ibm-granite/granite-speech-5.0-470m-turboctc,
    a 470M English conformer CTC encoder, replacing distil-whisper/distil-small.en.
    Being CTC it has no generate(), so decode kwargs (language/task) are dropped
    rather than forwarded, and transcripts are lowercase and unpunctuated.
  * ASR defaults retuned for a CTC backend: asr_self_chunks False and
    asr_chunk_length_s 120.0, since the encoder has no internal chunking and the
    HF pipeline's own CTC chunking mis-trims every seam (it rescales stride by
    inputs_to_logits_ratio, which this checkpoint does not publish). asr_device
    defaults to cuda and asr_dtype resolves to bfloat16 there.
  * transformers pinned >=5.5.1,<5.17.0 in the core package and >=5.16.0 on the
    audio extra, which is where granite_speech5_ctc landed. Only the audio path
    needs 5.16, so the requirement sits on the extra.
  * ATTENTION_LAYER_TYPES accepts both "attention" and "full_attention".
    transformers 5.16 renamed the value and rewrites it inside
    PreTrainedConfig.__init__, so comparing against the bare string silently
    dropped the attention LoRA target groups and left adapters with MLP targets
    only.
  * IsHybrid is no longer declared. It requires mamba-state hooks this model
    neither implements nor needs -- the composer normalizes every layer to
    attention, so a composed checkpoint has zero mamba layers. vLLM's escape
    hatch for exactly this shape compares the literal "attention", so the 5.16
    rename flipped is_hybrid true and engine init began failing for the mamba
    dtype hook.

Two places where main had moved and the original patch could not be taken as-is.
Both are in compose_granite_switch.py's argparse help, and both would have been
regressions if cherry-picked:

  * main established that --enable-audio is the only flag that switches audio on
    ("Requires --enable-audio; ignored without it"), replacing the older "Implies
    --enable-audio". The re-applied text keeps main's rule and carries only the
    TurboCTC facts across.
  * docs/AUDIO.md was hand-merged rather than overwritten, so main's confirmation
    from the Granite authors that <|unused_N|> ids are reserved survives
    alongside the new TurboCTC sections.

uv.lock regenerated rather than patched: transformers 5.8.1 -> 5.16.1.

Known limitation, carried over and unresolved: vLLM <=0.25 keys a layer-type
table on the old "attention" spelling in granitemoehybrid.py, so in a venv built
from this branch, serving a *stock* Granite 4.0 hybrid checkpoint raises
KeyError: 'full_attention'. Composed Granite Switch checkpoints are unaffected --
they declare GraniteSwitchForCausalLM, which builds its own layers and never
consults that table. vLLM fixed it in 0.26.0.

Verified: ruff check and format clean; tests/unit/test_asr.py, test_config.py and
test_config_edge_cases.py pass 90/90 (2 skipped for lack of a local vLLM).

Signed-off-by: aviv ron <rona@il.ibm.com>
….pipeline

Closes #121.

_patched_pipeline patched transformers.pipelines.pipeline before
transformers.pipeline. mock.patch.__enter__ records the current value so it can
restore it, and transformers is a lazy module: resolving transformers.pipeline
when it is not yet cached on the top-level module goes through
transformers.pipelines. With the submodule patched first, the second patch read
back the mock, recorded it as "the original", and faithfully restored it on exit.

The mock then stayed installed for the rest of the process, and load() resolves
`from transformers import pipeline` at call time, so every later real
transcription picked it up. One of the mocks in this file raises the "does not
recognize this architecture" ValueError, which _unsupported_architecture_error
converts into

    ImportError: transformers 5.16.0 cannot load the ASR model
      'ibm-granite/granite-speech-5.0-470m-turboctc': its architecture requires
      transformers>=5.16

-- naming the installed version as too old for itself, from a GPU test that had
nothing to do with the unit test that leaked. That false trail is the reason this
is worth more than a one-line diff of explanation.

Swapping the two patches is the whole fix: transformers.pipeline is now read
while transformers.pipelines is still real, so both record and restore the real
function.

Why it went unnoticed: the leak only occurs when transformers.pipeline is not
already cached on the top-level module, which depends on what else ran first.
tests/unit/test_asr.py alone restores correctly; the full tests/unit/ directory
leaks. CI always runs the full suite, so CI always leaked.

TestPatchedPipelineRestores guards both attributes, and forces the lazy-resolve
precondition with transformers.__dict__.pop("pipeline", None) rather than relying
on collection order. Verified to fail on the old ordering
(assert <Mock> is not <Mock>) and pass on the new one, so it is a real guard
rather than a tautology.

Verified on transformers 5.16.1 and 5.8.1: full tests/unit/ is 287 passed / 1448
skipped on both, and a probe asserting transformers.pipeline is the real function
after the session passes on both. ruff check and format clean.

Note this fixes the misleading diagnosis, not the GPU-tier failures it was
masking: the leaked vLLM engine that starves later GPU tests is a separate
teardown defect (#123) and is deliberately left for its own PR.

Signed-off-by: aviv ron <rona@il.ibm.com>
…n config

Closes #122.

transformers 5.16 renamed the layer type "attention" to "full_attention" and
rewrites the value inside PreTrainedConfig.__init__, so it is now the only
spelling a written config can carry -- writing the old one back does not help.
vLLM <=0.25 keys its Granite-hybrid layer table on the old name:

    # vllm/model_executor/models/granitemoehybrid.py:319
    ALL_DECODER_LAYER_TYPES = {"attention": ..., "mamba": ...}
    layer_class = ALL_DECODER_LAYER_TYPES[config.layer_types[layer_idx]]
    KeyError: 'full_attention'

register() now aliases the new name onto the same layer class. Reproduced on both
0.19.1 and 0.20.2; upstream fixed it in 0.26.0 by adding the key themselves, and
setdefault makes this a no-op there, so the block can be deleted on a version
bump without touching anything else.

Two things this repairs, which is why it is not only a test fix:

  * The vLLM equivalence tier -- 14 failures plus 19 nested. Those tests build
    their upstream reference with transformers, save_pretrained it, and hand the
    directory to vllm.LLM; the saved config now says full_attention and its
    architectures field routes to vLLM's stale class, so the *control group*
    never starts. GraniteSwitchForCausalLM was never affected: it builds layers
    through a closure and does not consult that table.
  * Serving a stock Granite 4.0 hybrid checkpoint. In a venv built from this
    branch -- transformers>=5.16 comes from our own audio extra, vLLM is pinned
    <=0.25 -- `vllm serve ibm-granite/granite-4.0-micro` crashed. Composed
    Granite Switch checkpoints did not; the published 4.2-30b carries
    layer_types: ["full_attention", ...] and serves correctly.

Why it lives in register() and not a test fixture: the lookup runs inside vLLM's
*spawned* engine-core process, so nothing patched in the parent survives. vLLM
loads its plugins in that process during init (v1/engine/core.py calls
load_general_plugins()), reading the vllm.general_plugins entry point we already
declare. It is the only hook we own that executes there.

Aliasing adds a name, not behaviour: both keys resolve to the identical class
object, so the decoder layer built is the one that was built before the rename.
The broad except is deliberate -- if a future vLLM moves the module or the dict,
skipping silently costs us this KeyError again, whereas raising would take down
every engine start.

A larger alternative was considered and rejected as too big for this change:
retargeting the whole equivalence tier off granitemoehybrid onto the families we
actually ship. That gap is real -- granite (4.1/4.2/30b) has no equivalence
coverage in any tier and granitemoe has HF-only -- but it is separate work.

tests/vllm/test_plugin_registration.py asserts both keys are present and resolve
to the same object, and that register() stays re-entrant as its docstring
promises. It runs in-process rather than through the usual subprocess wrapper
because register() creates no engine and so opens no CUDA context; it skips
cleanly where vLLM is absent. The assertions hold on 0.26+ too, so the test does
not need editing when the alias becomes redundant.

Verified: ruff check and format clean over 210 files; tests/unit/ is 286 passed /
1448 skipped on transformers 5.16.1, unchanged. The alias itself only
demonstrates on GPU -- the proof is the 14 equivalence failures going green.

Not addressed here: #123, the leaked vLLM engine that starves later GPU tests
(8 failures + 8 errors), which is its own PR; and #127's tokenizer-fetch flake.
Also untouched is the second stale comparison, is_hybrid in vllm/config/model.py,
still broken in 0.28.0 -- we sidestep it by not declaring IsHybrid.

Signed-off-by: aviv ron <rona@il.ibm.com>
Absorbs feature/moe-audio-support so it does not need a PR of its own: that
branch carried no functional code, only documentation plus the tests that back
it. Taken from staging/feature/moe-audio-support @ 8bf0bb3.

What came across:

  * docs/AUDIO.md -- the audio cascade over a pure sparse MoE base. Hand-merged,
    not overwritten: this file had already diverged here (63+/25- from the
    TurboCTC edits, which touch the same sections). Verified afterwards that both
    sides survive -- the TurboCTC default, the transformers>=5.16 requirement and
    the 120s chunker window on one side, the granitemoe material on the other.
  * tests/composer/test_audio_marker_output_row.py -- the marker/reserved-row copy
    is now parametrized over MLP topology as well as embedding tying, taking it
    from 2 cases to 4 (untied/tied x dense/sparse-MoE). The fixup only ever
    touches embedding rows, so both topologies must behave identically; the sparse
    arm is what would notice if a shared-MLP-shaped assumption crept into the
    model construction it runs against.
  * tests/composer/test_granitemoe_audio_compose.py -- new, 9 tests over 4
    classes: audio does not resurrect the shared MLP, does not widen the adapter
    surface and leaves no zero-width parameter; the control LUT goes stale on the
    marker and refresh fixes it idempotently and passes the validator; the marker
    output row; and survival across save/load.

Deliberately NOT taken: a 5-line comment block in
src/granite_switch/composer/tokenizer_setup.py noting that the <|unused_N|>
convention also holds on granitemoe bases. True, but prose, and keeping src/
out of this commit makes the "no functional change" claim checkable rather than
asserted -- `git show --stat` shows no src/ path at all.

Verified: the two test files give 32 passed here; ruff check and format clean
over 211 files.

One thing a reviewer should not misread. The composer tier on this base reports
pre-existing failures that predate this commit and are unrelated to it:
TestGraniteMoeSR fails 6 of 8 even in isolation, in 0.37s, with
"ValueError: not enough values to unpack (expected 5, got 3)". Public main is 15
commits behind staging and is missing #119, which changed composer return
signatures and updated test_granitemoe_compose_e2e.py to match; the two are out
of step here. It has gone unnoticed because the public repo's automatic CI runs
only tests/unit/, so tests/composer/ is unchecked on every PR. #119 is expected
to reach public main shortly, which resolves it. The test file imported above is
based on #116, before that refactor, so it matches the signatures this base has.

Signed-off-by: aviv ron <rona@il.ibm.com>
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants