Skip to content

feat(classifier): add switched classifier slots - #134

Open
IRENEKO wants to merge 11 commits into
generative-computing:mainfrom
IRENEKO:port/108-classifier-public
Open

IRENEKO wants to merge 11 commits into
generative-computing:mainfrom
IRENEKO:port/108-classifier-public

Conversation

@IRENEKO

@IRENEKO IRENEKO commented Sep 29, 2026

Copy link
Copy Markdown

What

Adds the switched classifier: a classifier slot is an alternative occupant of an adapter slot, holding a small linear classification head instead of a LoRA. It is fired by the same control-token machinery — one <|name|> token, one adapter index — but where a LoRA modifies the hidden states, a classifier reads the clean base hidden state and produces per-label logits.

  • Compose: classifier slots declared in a YAML manifest via kind: classifier and labels:, alongside LoRA slots in one checkpoint. type (token placement) and kind (LoRA vs classifier) are independent axes. Slots are stably reordered LoRAs-first / classifiers-last so the LoRA weight transfer stays positional and the head bank pads uniformly.
  • Trained-head loading with a different label count per slot: heads live in one stacked bank padded to max_classifier_labels, mirroring the padding rank in the LoRA bank.
  • Label words resolve to single token ids at compose time, failing loud and listing offenders when a word is not atomic under the base tokenizer.
  • Verdict exit: the request's final logit row is rewritten so the winning label's token id wins the argmax, so the endpoint emits the literal label word through the standard OpenAI-compatible server with no custom client. HF and vLLM backends kept in sync.
  • Chat template places the control token at the end of the last user turn, before that turn's closing marker, for both the role-marker (4.0/4.1) and ChatML (4.2) template families.
  • MultiSwitch: an identity split_indices on both backends, routing the classifier stream to zeros, since a classifier slot loads only for a quick verdict.

Tutorial: tutorials/guides/classifier_slots.md

Why

Detection/classification tasks do not need a generative adapter. A linear head over the base hidden state is far cheaper than a LoRA, and riding the existing control-token and slot machinery means it composes, serves and activates exactly like any other adapter — no separate endpoint, no custom decode path.

Provenance

Port of granite-switch-staging PR #108 (feature/switched-classifier, head e7f6bff), squashed into one commit. That commit's tree is byte-identical to the staging PR head, so this is a content-faithful port rather than a re-implementation.

A second commit corrects documentation found stale while porting: the tutorial's Step 3 paragraph named two config.json fields that do not exist in src/. It now lists adapter_kinds and classifier_label_token_ids as the stored fields, with the per-slot label counts and control-token ids derived from them. The same stale text is still present in staging and wants the same fix there.

Scope note: this PR is the feature code only. Classifier training infrastructure and trained safety-guard artifacts live on a separate internal branch and are not included here.

Testing

Run on this branch; GPU suites on H100 nodes.

Suite Result
tests/unit/ 275 passed, 2 failed
tests/composer/ 453 passed
tests/composer/test_skinning_equivalence.py (vLLM, GPU) 2 passed
tests/hf/test_classifier.py 11 passed
tests/vllm/test_classifier_chunked_prefill.py (GPU) 3 passed

All 17 classifier tests pass (3 composer, 11 HF, 3 vLLM).

The 2 unit failures are tests/unit/test_asr.py::TestMonoAndResample::test_resample_real_vllm_backend[8000-16000] and [44100-16000], both ModuleNotFoundError: No module named 'resampy' — a missing optional audio dependency in the local venv, in ASR code this change does not touch. Flagged rather than proven pre-existing: no baseline run on main was done to confirm they fail there too.

All pre-commit hooks pass, including SPDX headers on the two new classifier.py files and the link validator on the new tutorial.

@AlonMalach

Copy link
Copy Markdown
Collaborator

/gpu-test-multi

@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 79.72973% with 15 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/granite_switch/token_exchange.py 77.14% 8 Missing ⚠️
src/granite_switch/config.py 82.05% 7 Missing ⚠️

📢 Thoughts on this report? Let us know!

@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

❌ GPU tests failed — vllm26-multi

No pytest summary — the suite did not start. See the log below.

Commit: 861b917cf8a9e88959dc4c9010d051a03cdb66b9
Full run & artifact log

Last 40 log lines
Processed prompts:   0%| | 0/1 [00:00<?, ?it/s, est. speed input: 0.00 toks/s, 
Processed prompts: 100%|█| 1/1 [00:02<00:00,  2.10s/it, est. speed input: 30.42
Processed prompts: 100%|█| 1/1 [00:02<00:00,  2.10s/it, est. speed input: 30.42
Processed prompts: 100%|█| 1/1 [00:02<00:00,  2.10s/it, est. speed input: 30.42

Rendering prompts:   0%|                                 | 0/1 [00:00<?, ?it/s]
Rendering prompts: 100%|███████████████████████| 1/1 [00:00<00:00, 2170.96it/s]

Processed prompts:   0%| | 0/1 [00:00<?, ?it/s, est. speed input: 0.00 toks/s, 
Processed prompts: 100%|█| 1/1 [00:00<00:00, 15.68it/s, est. speed input: 1506.
Processed prompts: 100%|█| 1/1 [00:00<00:00, 15.63it/s, est. speed input: 1506.


============================================================
  Step: compare
  Command: /tmp/granite-switch/.venv/bin/python /tmp/granite-switch/tests/vllm/_generation_equivalence_worker.py compare --work-dir /tmp/gen_equiv_1zjo2wea --label ibm-granite/granite-4.0-micro
============================================================

GEN-EQUIVALENCE (distribution) ibm-granite/granite-4.0-micro  positio (63 prompt + 32 generation)
     k   mean(1-Jacc)    mean JSD     max JSD   @pos
     1       0.073684    0.000050    0.001580     64
     5       0.062155    0.000412    0.002894     20
    10       0.058373    0.000427    0.002460     20
    20       0.061165    0.000463    0.001940     20
  mean |captured top-64 mass diff| = 0.002523  (informational)
  greedy first-divergence step: 0  (informational, not gated)
    ref greedy[:8]   = [287, 279, 220, 22, 13, 3146, 44534, 334]
    switch greedy[:8]= [291, 13, 578, 220, 22, 13, 578, 220]

PASS: ibm-granite/granite-4.0-micro — distribution equivalence over 95 positions (mean JSD <= 0.003, max JSD <= 0.05, mean 1-Jaccard <= 0.2)

PASSED
tests/vllm/test_granite4_fullsize.py::TestGranite4FullSizeWeightTransfer::test_weight_transfer[4.0-1b] PASSED
tests/vllm/test_granite4_fullsize.py::TestGranite4FullSizeWeightTransfer::test_weight_transfer[4.0-350m] PASSED
tests/vllm/test_granite4_fullsize.py::TestGranite4FullSizeWeightTransfer::test_weight_transfer[4.0-micro] PASSED
tests/vllm/test_granite4_fullsize.py::TestGranite4FullSizeWeightTransfer::test_weight_transfer[4.0-micro] PASSED
tests/vllm/test_granite4_fullsize.py::TestGranite4FullSizeEquivalence::test_suite [07:38:26] no new pod output for 10m

Verdict: FAILED — the test suite started but never reported a summary
         (killed mid-run: in-pod timeout, OOM, or a crashed process)

@github-actions

Copy link
Copy Markdown

❌ GPU tests failed — vllm27-multi

1 failed, 3006 passed, 67 skipped, 23 warnings in 17465.67s (4:51:05)

Commit: db41758bc319b44110d9b1419217722591a86022
Full run & artifact log

Last 40 log lines

tests/composer/test_upstream_files.py:218
  /tmp/granite-switch/tests/composer/test_upstream_files.py:218: PytestUnknownMarkWarning: Unknown pytest.mark.xdist_group - is this a typo?  You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
    @pytest.mark.xdist_group("upstream_build_e2e")

tests/composer/test_classifier_compose.py::test_classifier_only_compose_loads_trained_head
tests/composer/test_classifier_compose.py::test_classifier_only_compose_loads_trained_head
tests/integration/test_asr_ctc_default_gpu.py::test_backend_is_detected_as_ctc
  /tmp/granite-switch/.venv/lib/python3.12/site-packages/huggingface_hub/file_download.py:1842: DeprecationWarning: hf_xet.download_files() is deprecated. Use XetSession().new_file_download_group().start_download_file() instead.
    xet_get(

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
=========================== short test summary info ============================
FAILED tests/composer/test_adapter_loader.py::TestManifestClassifierMetadata::test_kind_and_labels_are_parsed
==== 1 failed, 3006 passed, 67 skipped, 23 warnings in 17465.67s (4:51:05) =====
sys:1: DeprecationWarning: builtin type swigvarlink has no __module__ attribute
[rank0]:[W1013 12:43:13.404888040 ProcessGroupNCCL.cpp:1624] Warning: WARNING: destroy_process_group() was not called before program exit, which can leak resources. For more info, please see https://pytorch.org/docs/stable/distributed.html#shutdown (function operator())
FATAL pytest failed
[12:43:25] <job> Failed
[12:43:26] --- post-mortem: why the job ended (status and events only) ---
[12:43:26] <job> status:
  QuotaReserved=True Resuming: Suspend is false
  ResourcesDeployed=True Resuming: Suspend is false
  PodsReady=True SufficientPodsReady: 1 pods running; 0 pods succeeded
  Unhealthy=False Resuming: Suspend is false
[12:43:27] pod <job> phase and conditions:
  phase=Failed
  Initialized=True : 
  Ready=False PodFailed: 
  ContainersReady=False PodFailed: 
  PodScheduled=True : 
[12:43:28] pod <job> container states:
[12:43:29]   (unavailable)
[12:43:29] kubelet events for <job>:
TIME   TYPE   REASON   MESSAGE
[12:43:29] --- end post-mortem ---
[12:43:29] cleanup: deleting <job> (exit=1)
<job> "<job>" deleted

Verdict: FAILED — 1 failed, 3006 passed, 67 skipped, 23 warnings in 17465.67s (4:51:05)

Port of granite-switch-staging PR generative-computing#108 (feature/switched-classifier).

Adds the switched classifier: a classifier slot is an alternative occupant
of an adapter slot, holding a small linear classification head instead of a
LoRA. It is fired by the same control-token machinery -- one <|name|> token,
one adapter index -- but where a LoRA modifies the hidden states, a
classifier reads the clean base hidden state and produces per-label logits.

WHAT

* Compose: classifier slots declared in a YAML manifest via `kind:
  classifier` and `labels:`, alongside LoRA slots in one checkpoint. `type`
  (token placement) and `kind` (LoRA vs classifier) are independent axes.
  Slots are stably reordered LoRAs-first / classifiers-last so the LoRA
  weight transfer stays positional and the head bank pads uniformly.
* Trained-head loading with a different label count per slot: heads live in
  one stacked bank padded to `max_classifier_labels`, mirroring the padding
  rank in the LoRA bank.
* Label words resolve to single token ids at compose time, failing loud and
  listing offenders when a word is not atomic under the base tokenizer.
* Verdict exit: the request's final logit row is rewritten so the winning
  label's token id wins the argmax, so the endpoint emits the literal label
  word through the standard OpenAI-compatible server with no custom client.
  HF and vLLM backends kept in sync.
* Chat template places the control token at the end of the last user turn,
  before that turn's closing marker, for both the role-marker (4.0/4.1) and
  ChatML (4.2) template families.
* MultiSwitch: an identity `split_indices` on both backends, routing the
  classifier stream to zeros, since a classifier slot loads only for a
  quick verdict.

WHY

Detection/classification tasks do not need a generative adapter. A linear
head over the base hidden state is far cheaper than a LoRA, and riding the
existing control-token and slot machinery means it composes, serves and
activates exactly like any other adapter -- no separate endpoint, no custom
decode path.

Tutorial: tutorials/guides/classifier_slots.md

Signed-off-by: IRENEKO <cyko@ibm.com>
Update the Step 3 paragraph to match GraniteSwitchConfig: `adapter_kinds`
and `classifier_label_token_ids` are the stored fields, with the per-slot
label counts and control-token ids derived from them.

Signed-off-by: IRENEKO <cyko@ibm.com>
The read point was ``marker - 1``; it is now the marker's own position.
The marker's row is always in the pass that contains the marker, so the
cross-pass stash that served the one-token look-back is gone, along with
its bookkeeping and the error for a missing stash entry.

Signed-off-by: IRENEKO <cyko@ibm.com>
A classifier reads its verdict at the marker, so no generation prompt may
follow it. vLLM's chat-completions endpoint defaults add_generation_prompt
to True, so relying on callers to turn it off would silently place the
assistant prompt after the marker. The composed template now forces
add_generation_prompt to false when adapter_name names a classifier slot;
renders with other adapters or no adapter still honor the flag.

test_classifier_drops_generation_prompt pins this on granite-4.0-micro,
granite-4.1-3b and granite-4.2-3b (ChatML).

Signed-off-by: IRENEKO <cyko@ibm.com>
Append the classifier control token after the whole template instead of
inside the last user turn. Since classifier renders already drop the
generation prompt, a single append makes the marker the last token in
both template families, replacing the per-format turn-close rewrites.
The tutorial's raw-completions examples follow the new layout.

Signed-off-by: IRENEKO <cyko@ibm.com>
With the marker as the prompt's last token, it always lands in the final
prefill chunk, so the cross-pass verdict carry is unnecessary. Run the
head only on a pass's marker tokens and rewrite just their logit rows;
raise when a marker is not its request's last token. Drop the
read-position tests that no longer apply.

Signed-off-by: IRENEKO <cyko@ibm.com>
Compute the verdict only at classifier markers, require each marker to
be its row's last real token, and write it to the marker's own logit row.
Decode steps after the verdict now run as plain LM steps instead of
raising, matching vLLM.

Signed-off-by: IRENEKO <cyko@ibm.com>
Give classifier slots their own substitute instead of inheriting the
LoRA probe: the base's role-open marker, which follows the last turn's
close where the classifier token sits. The tutorial tells trainers to
read the hidden state at that marker.

Signed-off-by: IRENEKO <cyko@ibm.com>
Add per-slot classifier read-layer configuration to the manifest,
composer, and model config. Score intermediate-layer classifiers as
decoder layers run, in both the HF and vLLM backends, while preserving
the active LoRA selection at the classifier marker.

In vLLM, keep classifier scores aligned with sampled rows through the
hidden-state/logits path. Add coverage for CUDA graph replay, chunked
prefill, mixed classifier and plain requests, LoRA coactivation, and
intermediate-layer reads.

Intermediate reads require pipeline parallelism of 1 and are not
supported with Shadow Residual. Speculative decoding and microbatching
are unsupported with classifier slots, and classifier slot ids are
limited to 256. Classifier markers must be the request's final token;
vLLM misplaced-marker validation is deferred.

Signed-off-by: IRENEKO <cyko@ibm.com>
…ages

Compose classifier heads alongside Shadow Residual adapters, choosing
SR mode from the adapting slots, and keep HF classifier routing through
save/reload. SR classifier slots can now read any decoder layer: the
adapted stream for active tokens and the base stream otherwise, before
final normalization. Marker-only indices and accumulated scores are
carried across pipeline ranks, and layer-output selection is shared
through DecoderInterface. The packed slot-count guard is removed.

Stack the two PP halves in SRDecoderInterface.enter_decoder_stack
instead of concatenating them, so vLLM's graph splitter keeps the 2*M
row relation; concatenation produced a partition cycle under compile.
Test recorder callbacks move to an importable module so PP workers can
receive them.

Known issue, deferred: under vLLM 0.26, FULL CUDA-graph replay diverges
from eager for the standard (non-SR) 3B classifier fixtures.

Signed-off-by: IRENEKO <cyko@ibm.com>
Chat templates now accept classifier_name alongside adapter_name: the
adapter activates at its usual location and the classifier marker is
placed last. adapter_name naming a classifier keeps the classifier-only
behavior. SR and assistant-boundary aLoRA keep the activation
generation prompt; LoRA and user-invoked aLoRA use the transcript-only
read point. Existing checkpoints need regenerated templates, not new
weights.

Signed-off-by: IRENEKO <cyko@ibm.com>
@AlonMalach
AlonMalach force-pushed the port/108-classifier-public branch from db41758 to 861b917 Compare October 19, 2026 05:39
@AlonMalach

Copy link
Copy Markdown
Collaborator

/gpu-test-multi

@github-actions

Copy link
Copy Markdown

❌ GPU tests failed — vllm30-multi

No pytest summary — the suite did not start. See the log below.

Commit: 861b917cf8a9e88959dc4c9010d051a03cdb66b9
Full run & artifact log

Last 40 log lines
Rendering prompts: 100%|█████████████████████████| 1/1 [00:00<00:00, 51.88it/s]

Processed prompts:   0%| | 0/1 [00:00<?, ?it/s, est. speed input: 0.00 toks/s, 
Processed prompts: 100%|█| 1/1 [00:01<00:00,  1.49s/it, est. speed input: 42.86
Processed prompts: 100%|█| 1/1 [00:01<00:00,  1.49s/it, est. speed input: 42.86
Processed prompts: 100%|█| 1/1 [00:01<00:00,  1.49s/it, est. speed input: 42.86

Rendering prompts:   0%|                                 | 0/1 [00:00<?, ?it/s]
Rendering prompts: 100%|███████████████████████| 1/1 [00:00<00:00, 1888.48it/s]

Processed prompts:   0%| | 0/1 [00:00<?, ?it/s, est. speed input: 0.00 toks/s, 
Processed prompts: 100%|█| 1/1 [00:00<00:00, 17.71it/s, est. speed input: 1701.
Processed prompts: 100%|█| 1/1 [00:00<00:00, 17.66it/s, est. speed input: 1701.


============================================================
  Step: compare
  Command: /tmp/granite-switch/.venv/bin/python /tmp/granite-switch/tests/vllm/_generation_equivalence_worker.py compare --work-dir /tmp/gen_equiv_95da9ys8 --label ibm-granite/granite-4.0-micro
============================================================

GEN-EQUIVALENCE (distribution) ibm-granite/granite-4.0-micro  positio (63 prompt + 32 generation)
     k   mean(1-Jacc)    mean JSD     max JSD   @pos
     1       0.073684    0.000050    0.001580     64
     5       0.062155    0.000412    0.002894     20
    10       0.058373    0.000427    0.002460     20
    20       0.061165    0.000463    0.001940     20
  mean |captured top-64 mass diff| = 0.002523  (informational)
  greedy first-divergence step: 0  (informational, not gated)
    ref greedy[:8]   = [287, 279, 220, 22, 13, 3146, 44534, 334]
    switch greedy[:8]= [291, 13, 578, 220, 22, 13, 578, 220]

PASS: ibm-granite/granite-4.0-micro — distribution equivalence over 95 positions (mean JSD <= 0.003, max JSD <= 0.05, mean 1-Jaccard <= 0.2)

PASSED
tests/vllm/test_granite4_fullsize.py::TestGranite4FullSizeWeightTransfer::test_weight_transfer[4.0-1b] PASSED
tests/vllm/test_granite4_fullsize.py::TestGranite4FullSizeWeightTransfer::test_weight_transfer[4.0-350m] PASSED
tests/vllm/test_granite4_fullsize.py::TestGranite4FullSizeWeightTransfer::test_weight_transfer[4.0-micro] PASSED
tests/vllm/test_granite4_fullsize.py::TestGranite4FullSizeEquivalence::test_suite 
Verdict: FAILED — the test suite started but never reported a summary
         (killed mid-run: in-pod timeout, OOM, or a crashed process)

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants