diff --git a/src/granite_switch/composer/adapter_discovery.py b/src/granite_switch/composer/adapter_discovery.py index 52ddb522..9fd2cd83 100644 --- a/src/granite_switch/composer/adapter_discovery.py +++ b/src/granite_switch/composer/adapter_discovery.py @@ -108,17 +108,40 @@ def discover_adapters( def discover_adapters_from_yaml( manifest_path: str, -) -> list[tuple[str, str, str, str | None]]: +) -> tuple[list[tuple[str, str, str, str | None]], dict[str, dict]]: """Discover adapters from a YAML manifest file. Reads a YAML manifest that maps adapter names to their paths and types. + An entry can also mark itself a **probe** slot with these extra keys: + + .. code-block:: yaml + + hallucination: + path: /path/to/head + type: lora # lora/alora technology (token placement) + kind: probe # default "lora" when omitted + labels: [faithful, hallucinated] + layer: 12 # optional decoder layer; omit for the final layer + + ``type`` and ``kind`` are independent axes: ``type`` is the LoRA technology + (lora vs alora, which drives token placement), ``kind`` is whether the slot + is a real LoRA or a probe head. ``kind`` and ``labels`` are optional; + without them an entry is a plain LoRA adapter. The label words become token + ids at compose time (see :func:`tokenizer_setup.resolve_label_token_ids`). + + ``layer: K`` reads the residual stream after zero-based decoder layer ``K`` + (switch cache layers excluded). Omitting it reads the final post-norm state. Args: manifest_path: Path to the YAML manifest file. Returns: + ``(found, probe_meta)``. ``found`` is: List of ``(adapter_path, adapter_name, technology, source)`` tuples. The source is set to the manifest path for traceability. + ``probe_meta`` maps name -> + ``{"kind", "labels", "layer"}`` for each entry that set any of them; + names not in it are plain LoRA slots. """ import yaml @@ -126,6 +149,7 @@ def discover_adapters_from_yaml( print(f" Loading adapters manifest: {path.name}") found = [] + probe_meta: dict[str, dict] = {} if path.is_file() and path.suffix in (".yaml", ".yml"): with open(path) as f: adapters_config = yaml.safe_load(f) @@ -137,7 +161,18 @@ def discover_adapters_from_yaml( # Use manifest path as source for traceability found.append((adapter_path, name, tech, manifest_path)) - return found + # Optional probe slots. + kind = info.get("kind") + labels = info.get("labels") + layer = info.get("layer") + if kind is not None or labels is not None or layer is not None: + probe_meta[name] = { + "kind": kind or "lora", + "labels": labels, + "layer": layer, + } + + return found, probe_meta def _report_module_contributions( diff --git a/src/granite_switch/composer/arch.py b/src/granite_switch/composer/arch.py index 62163433..fa0ff8b5 100644 --- a/src/granite_switch/composer/arch.py +++ b/src/granite_switch/composer/arch.py @@ -112,12 +112,14 @@ class ArchDescriptor: # LoRA parameter keywords (for filtering) lora_keywords: list[str] = field(default_factory=lambda: ["lora_A", "lora_B"]) - # Non-LoRA buffer keywords to exclude from base validation + # Non-LoRA/probe buffer keywords to exclude from base validation buffer_keywords: list[str] = field( default_factory=lambda: [ "adapter_token_ids", "adapter_scalings", "control_to_substitute_lut", + "probe_head", + "adapter_kind_lut", ] ) @@ -392,14 +394,6 @@ def granite_dense_arch(base_config=None) -> ArchDescriptor: ) -_SR_BUFFER_KEYWORDS: list[str] = [ - "adapter_token_ids", - "adapter_scalings", - "control_to_substitute_lut", - "cross_stream", # zero-initialized, no base source -] - - def granite_moe_hybrid_sr_arch(base_config=None) -> ArchDescriptor: """GraniteMoeHybrid Shadow Residual architecture. @@ -408,7 +402,7 @@ def granite_moe_hybrid_sr_arch(base_config=None) -> ArchDescriptor: """ arch = granite_moe_hybrid_arch(base_config=base_config) arch.groups = arch.groups + _cross_stream_groups() - arch.buffer_keywords = list(_SR_BUFFER_KEYWORDS) + arch.buffer_keywords.append("cross_stream") # zero-initialized, no base source return arch @@ -416,7 +410,7 @@ def granite_moe_sr_arch(base_config=None) -> ArchDescriptor: """GraniteMoe Shadow Residual architecture (attention + ``cross_stream``).""" arch = granite_moe_arch(base_config=base_config) arch.groups = arch.groups + _cross_stream_groups() - arch.buffer_keywords = list(_SR_BUFFER_KEYWORDS) + arch.buffer_keywords.append("cross_stream") return arch @@ -424,7 +418,7 @@ def granite_dense_sr_arch(base_config=None) -> ArchDescriptor: """Granite dense Shadow Residual architecture (fused + ``cross_stream``).""" arch = granite_dense_arch(base_config=base_config) arch.groups = arch.groups + _cross_stream_groups() - arch.buffer_keywords = list(_SR_BUFFER_KEYWORDS) + arch.buffer_keywords.append("cross_stream") return arch diff --git a/src/granite_switch/composer/compose_granite_switch.py b/src/granite_switch/composer/compose_granite_switch.py index 8636ffa0..d7971982 100755 --- a/src/granite_switch/composer/compose_granite_switch.py +++ b/src/granite_switch/composer/compose_granite_switch.py @@ -69,14 +69,17 @@ from granite_switch.composer.compose_utils import GraniteSwitchComposer from granite_switch.composer.reporting import generate_compose_report, write_build_doc from granite_switch.composer.tokenizer_setup import ( + ANCHOR_MODE_PROBE, ANCHOR_MODE_SR, add_audio_token, add_control_tokens, build_substitute_token_ids, configure_audio_chat_template, configure_chat_template, + detect_template_format, find_reserved_never_emitted_token_id, load_activation_anchor, + resolve_label_token_ids, ) from granite_switch.composer.validator import validate_control_lut from granite_switch.composer.weight_transfer import validate_untied_lm_head_saved @@ -119,10 +122,26 @@ def build_control_token_lists(tokenizer, all_discovered, base_reset): all_discovered, _probe_lora_substitute_token_id(tokenizer), base_reset=base_reset, + probe_slot_substitute_id=_probe_slot_substitute_token_id(tokenizer), ) return adapter_token_ids, special_tokens, adapter_substitute_token_ids +def _probe_slot_substitute_token_id(tokenizer) -> int: + """The base's role-open marker id, which follows the last turn's close.""" + fmt = detect_template_format(tokenizer.chat_template) + if fmt is None: + raise ValueError( + "Unrecognized chat template; cannot pick the probe substitute." + ) + ids = tokenizer.encode(fmt.role_open_marker, add_special_tokens=False) + if len(ids) != 1: + raise ValueError( + f"Role-open marker {fmt.role_open_marker!r} encodes to {len(ids)} tokens." + ) + return ids[0] + + def _probe_lora_substitute_token_id(tokenizer) -> int: """Ask the tokenizer which token naturally appears at sequence position 0 of a rendered no-adapter chat. @@ -981,6 +1000,7 @@ def build(): step_start = time.time() discovered_adapters = [] + probe_meta: dict[str, dict] = {} if args.adapters: print("\n" + "=" * 80) print("Processing adapters") @@ -1022,8 +1042,9 @@ def build(): print(msg) discovered_adapters.extend(found) elif (path := Path(entry)).is_file() and path.suffix in (".yaml", ".yml"): - found = discover_adapters_from_yaml(entry) + found, manifest_probe_meta = discover_adapters_from_yaml(entry) discovered_adapters.extend(found) + probe_meta.update(manifest_probe_meta) else: # Single adapter directory resolved = Path(resolved_path) @@ -1065,11 +1086,21 @@ def build(): # token lands, and SR has exactly one usable activation point — the anchor at # the end of the generation prompt — so a mislabelled SR checkpoint must not # be able to fall through to aLoRA or sequence-start placement. + # A probe slot is labelled here for the same reason: its control token is + # an end-locator, which is a placement mode, not a weight format, and the + # manifest's ``kind`` decides it. An adapter satisfies at most one test -- an SR + # adapter carries ``cross_stream`` weights, a probe slot carries a + # probe head instead -- and SR goes first because it is read from the + # artifact rather than from a label. external_discovered = [ ( path, name, - ANCHOR_MODE_SR if path and is_shadow_residual_adapter(path) else tech, + ANCHOR_MODE_SR + if path and is_shadow_residual_adapter(path) + else ANCHOR_MODE_PROBE + if probe_meta.get(name, {}).get("kind") == "probe" + else tech, source, ) for path, name, tech, source in discovered_adapters @@ -1079,6 +1110,11 @@ def build(): ] all_discovered = external_discovered + built_in_discovered + # Order LoRA slots first, probe slots last (Python sorts False before True) + all_discovered.sort( + key=lambda t: probe_meta.get(t[1], {}).get("kind", "lora") == "probe" + ) + has_external = len(external_discovered) > 0 has_built_in = len(built_in_discovered) > 0 @@ -1227,6 +1263,61 @@ def build(): _anchor_text, _sub, _mode = load_activation_anchor(_path, tokenizer) adapter_substitute_token_ids[_sub_offset + _i] = _sub + # adapter_kinds marks which slots are probes (same control token as a + # LoRA); resolve each slot's label words to token ids for the vLLM verdict. + if probe_meta: + adapter_kinds = [ + probe_meta.get(name, {}).get("kind", "lora") for name in adapter_names + ] + probe_names = [ + name + for name in adapter_names + if probe_meta.get(name, {}).get("kind") == "probe" + ] + + if probe_names: + # Full width (length num_adapters, indexed by adapter_index - 1): each + # probe slot's label token ids. The config derives the per-slot + # counts and the padded bank width from this. + per_slot_label_names: list[list[str] | None] = [] + per_slot_label_token_ids: list[list[int] | None] = [] + # Full width too: each probe slot's read layer (None = final). + per_slot_read_layer: list[int | None] = [] + for name, kind in zip(adapter_names, adapter_kinds): + if kind != "probe": + per_slot_label_names.append(None) + per_slot_label_token_ids.append(None) + per_slot_read_layer.append(None) + continue + labels = probe_meta[name].get("labels") + if not labels: + raise ValueError( + f"Adapter '{name}' is declared kind=probe but has no " + f"'labels' in the manifest. Probe slots need label " + f"words (e.g. labels: [safe, unsafe]) so the verdict exit " + f"can emit them." + ) + label_names = list(labels) + per_slot_label_names.append(label_names) + per_slot_label_token_ids.append( + resolve_label_token_ids(tokenizer, label_names) + ) + per_slot_read_layer.append(probe_meta[name].get("layer")) + + optional_kwargs["adapter_kinds"] = adapter_kinds + optional_kwargs["probe_label_token_ids"] = per_slot_label_token_ids + optional_kwargs["probe_read_layers"] = per_slot_read_layer + print("\nProbe slots:") + for name, words, ids, layer in zip( + adapter_names, + per_slot_label_names, + per_slot_label_token_ids, + per_slot_read_layer, + ): + if ids is not None: + where = "final norm" if layer is None else f"layer {layer}" + print(f" {name} → labels {words} → token ids {ids} @ {where}") + model = GraniteSwitchComposer.from_base_and_adapters( base_model_name_or_path=base_model_local_path, adapter_paths=adapter_paths, diff --git a/src/granite_switch/composer/compose_utils.py b/src/granite_switch/composer/compose_utils.py index 21e3595f..75642f8e 100644 --- a/src/granite_switch/composer/compose_utils.py +++ b/src/granite_switch/composer/compose_utils.py @@ -17,7 +17,11 @@ ) from .arch import resolve_arch from .validator import validate_all_parameters, validate_cross_stream_population -from .weight_transfer import transfer_adapter_weights, transfer_base_weights +from .weight_transfer import ( + transfer_adapter_weights, + transfer_base_weights, + transfer_probe_weights, +) class GraniteSwitchComposer: @@ -80,17 +84,43 @@ def from_base_and_adapters( num_built_in = len(built_in_adapter_names) num_total = num_external + num_built_in + # Partition external slots by kind. + ext_kinds = kwargs.get("adapter_kinds") + if ext_kinds is None: + ext_kinds = ["lora"] * num_external + else: + ext_kinds = list(ext_kinds)[:num_external] + lora_slots = [i for i in range(num_external) if ext_kinds[i] != "probe"] + probe_slots = [i for i in range(num_external) if ext_kinds[i] == "probe"] + + # Compose orders LoRAs first, probes last, so probe slots must + # form a contiguous suffix (LoRAs occupy 0..k-1, probes k..). + if probe_slots and probe_slots != list( + range(lora_slots[-1] + 1 if lora_slots else 0, num_external) + ): + raise ValueError( + "Probe slots must form a contiguous suffix (all LoRA slots " + f"first, then probes); got kinds {ext_kinds}. Order the " + "adapters LoRAs-first (compose does this automatically)." + ) + lora_adapter_paths = [adapter_paths[i] for i in lora_slots] + probe_adapter_paths = [adapter_paths[i] for i in probe_slots] + lora_adapter_names = ( + [adapter_names[i] for i in lora_slots] + if adapter_names is not None + else None + ) + # --- Step 1: Resolve architecture --- # Pre-scan for cross_stream (SR dual-stream) to select the right arch. print(f"Loading config from {base_model_name_or_path}...") base_config = load_base_config(base_model_name_or_path) - # Classify every adapter as single-stream (LoRA/aLoRA) or dual-stream - # (Shadow Residual). A checkpoint holds one kind or the other: the whole - # decoder runs in one mode, chosen from ``dual_stream``. + # Classify adapting slots as single-stream (LoRA/aLoRA) or dual-stream + # (Shadow Residual). Probe heads can read either decoder mode. sr_adapters = [] non_sr_adapters = [] - for ap in adapter_paths: + for ap in lora_adapter_paths: if is_shadow_residual_adapter(ap): sr_adapters.append(ap) else: @@ -140,16 +170,27 @@ def from_base_and_adapters( f"(cross_stream_rank={cross_stream_rank})" ) - if adapter_paths: - lora_rank, _lora_alpha, adapter_ranks, adapter_alphas = detect_lora_config( - adapter_paths + # adapter_ranks/adapter_alphas stay full external width (one entry per + # slot, probes included) so config.adapter_ranks is length + # num_adapters. A probe slot itself has no LoRA weights. + if lora_adapter_paths: + lora_rank, _lora_alpha, lora_ranks, lora_alphas = detect_lora_config( + lora_adapter_paths ) lora_target_modules, source_analysis = detect_present_modules( - adapter_paths, + lora_adapter_paths, arch, - adapter_names=adapter_names, + adapter_names=lora_adapter_names, ) + # LoRAs lead, probes follow, so the full lists are a concat: + # detected LoRA values, then a placeholder per probe slot. The + # placeholder is the detected max rank, keeping max(adapter_ranks) == + # max_lora_rank. + num_ext_probe = num_external - len(lora_slots) + adapter_ranks = list(lora_ranks) + [lora_rank] * num_ext_probe + adapter_alphas = list(lora_alphas) + [float(lora_rank)] * num_ext_probe + # Extend adapter_ranks with built-in entries if num_built_in > 0: if built_in_lora_rank != lora_rank: @@ -162,21 +203,22 @@ def from_base_and_adapters( list(adapter_ranks) + [built_in_lora_rank] * num_built_in ) lora_rank = max(lora_rank, built_in_lora_rank) + elif probe_adapter_paths or num_built_in > 0: + # No LoRA slots but the model still has slots (probe and/or + # built-in). Give every slot a placeholder LoRA rank so the + # (unused) LoRA bank is well-formed and config validation passes. + lora_rank = built_in_lora_rank + adapter_ranks = [built_in_lora_rank] * num_total + adapter_alphas = {} + lora_target_modules = None + source_analysis = {} else: - # Built-in only or zero-adapter skinning - if num_built_in > 0: - lora_rank = built_in_lora_rank - adapter_ranks = [built_in_lora_rank] * num_built_in - adapter_alphas = {} - # Auto-detect lora_target_modules (attention-only switch model) - lora_target_modules = None - source_analysis = {} - else: - lora_rank = 0 - adapter_ranks = None - adapter_alphas = {} - lora_target_modules = [] - source_analysis = {} + # Zero-adapter skinning (base model only). + lora_rank = 0 + adapter_ranks = None + adapter_alphas = {} + lora_target_modules = [] + source_analysis = {} # --- Step 4: Build switch config from arch descriptor --- # Copy config fields driven by architecture descriptor @@ -261,10 +303,17 @@ def from_base_and_adapters( base_model_name_or_path, model, switch_config, arch ) - if adapter_paths: - # --- Step 7: Transfer adapter weights --- + if lora_adapter_paths: + # --- Step 7: Transfer LoRA adapter weights --- + # LoRAs are the contiguous leading slots, so the leading k entries of + # adapter_ranks/adapter_alphas are exactly the LoRA slots' values and + # each path's list position is its bank row (no slot remapping). + n_lora = len(lora_adapter_paths) adapter_mapping = transfer_adapter_weights( - adapter_paths, model, adapter_alphas, arch + lora_adapter_paths, + model, + list(adapter_alphas)[:n_lora], + arch, ) # --- Step 8: Validate --- @@ -273,8 +322,8 @@ def from_base_and_adapters( validate_all_parameters( model, arch, - adapter_paths=adapter_paths, - adapter_names=adapter_names[:num_external], + adapter_paths=lora_adapter_paths, + adapter_names=lora_adapter_names, target_module_sets=target_module_sets, ) if dual_stream: @@ -282,6 +331,18 @@ def from_base_and_adapters( else: adapter_mapping = {} + if probe_adapter_paths: + # --- Step 7b: Transfer trained probe-head weights --- + # Each slot's head is validated against its own label count and + # copied into the top rows of the padded bank. + transfer_probe_weights( + probe_adapter_paths, + probe_slots, + model, + num_labels_per_slot=switch_config.probe_num_labels_per_slot, + hidden_size=switch_config.hidden_size, + ) + print("\nModel created successfully!") print(f" Base model: {base_model_name_or_path}") print( diff --git a/src/granite_switch/composer/reporting/population_table.py b/src/granite_switch/composer/reporting/population_table.py index 0e94da46..e19fc263 100644 --- a/src/granite_switch/composer/reporting/population_table.py +++ b/src/granite_switch/composer/reporting/population_table.py @@ -49,12 +49,16 @@ def generate_adapter_population_table( if target_module_sets is None: target_module_sets = load_adapter_target_modules(adapter_paths) + # target_module_sets covers LoRA slots only (probe slots carry no LoRA + # weights, so source analysis never produced a target set for them). adapter_configs = [ { "rank": adapter_ranks[i] if adapter_ranks else max_rank, - "target_modules": target_module_sets[i], + "target_modules": target_module_sets[i] + if i < len(target_module_sets) + else set(), } - for i in range(len(adapter_paths)) + for i in range(num_adapters) ] # Define module types (rows) — only modules in lora_target_modules diff --git a/src/granite_switch/composer/tokenizer_setup.py b/src/granite_switch/composer/tokenizer_setup.py index 29be363b..a127835d 100644 --- a/src/granite_switch/composer/tokenizer_setup.py +++ b/src/granite_switch/composer/tokenizer_setup.py @@ -145,6 +145,10 @@ def detect_template_format(chat_template: str | None) -> TemplateFormat | None: #: the end of the generation prompt (Shadow Residual, ``last_context_token``). ANCHOR_MODE_SR = "sr" +#: Placement mode where the control token is emitted as the very last token, +#: after the last turn's close (probe slot, manifest ``kind``). +ANCHOR_MODE_PROBE = "probe" + def _load_alora_invocation_token_ids(adapter_path: str) -> list[int]: """Load alora_invocation_tokens from adapter_config.json. @@ -832,6 +836,7 @@ def build_substitute_token_ids( discovered_adapters: list[tuple[str | None, str, str, str | None]], lora_substitute_id: int, base_reset: bool = False, + probe_slot_substitute_id: int | None = None, ) -> list[int]: """Build the token-exchange substitute ids, index-aligned with the control tokens. @@ -845,12 +850,14 @@ def build_substitute_token_ids( * base-reset: ``lora_substitute_id`` as well. The token is placed at a turn boundary, where the role-open marker is exactly what the decoder expects at that position. + * probe: ``probe_slot_substitute_id``, the base's role-open marker. Args: discovered_adapters: ``(adapter_path, adapter_name, technology, source)`` tuples. lora_substitute_id: Probed sequence-start token id. base_reset: When True, prepend the base-reset slot's substitute so the list stays aligned with ``add_control_tokens(base_reset=True)``. + probe_slot_substitute_id: The base's role-open marker id. Returns: One substitute id per control token, in the same order. @@ -859,11 +866,39 @@ def build_substitute_token_ids( for adapter_path, _name, technology, _source in discovered_adapters: if technology == "alora": substitute_ids.append(get_alora_first_invocation_token_id(adapter_path)) + elif technology == "probe": + if probe_slot_substitute_id is None: + raise ValueError("A probe slot needs probe_slot_substitute_id.") + substitute_ids.append(probe_slot_substitute_id) else: substitute_ids.append(lora_substitute_id) return substitute_ids +def resolve_label_token_ids(tokenizer, label_names: list[str]) -> list[int]: + """Turn each probe label word into its single token id.""" + token_ids: list[int] = [] + non_atomic: list[str] = [] + for word in label_names: + # Same encode as the control-token check: bare word, no special tokens. + encoded = tokenizer.encode(word, add_special_tokens=False) + if len(encoded) != 1: + sub_tokens = tokenizer.convert_ids_to_tokens(encoded) + non_atomic.append(f"{word!r} → {len(encoded)} tokens: {sub_tokens}") + else: + token_ids.append(encoded[0]) + + if non_atomic: + raise ValueError( + f"{len(non_atomic)} probe label word(s) do not encode to a " + f"single token, but the vLLM probe exit writes the verdict at " + f"one token id per label. Choose a single-token synonym for each:\n " + + "\n ".join(non_atomic) + ) + + return token_ids + + def configure_chat_template( tokenizer, discovered_adapters: list[tuple[str | None, str, str, str | None]], @@ -935,6 +970,11 @@ def configure_chat_template( # still costing compute. Callers therefore classify SR from the checkpoint # weights (``is_shadow_residual_adapter``) and pass ``sr`` here, rather than # trusting a directory label that no SR training run produces. + # A probe slot arrives as technology ``probe``, like SR arrives as + # ``sr``: the caller derives the placement mode (SR from the checkpoint + # weights, probe from the manifest's ``kind``) and passes it in + # the discovered tuple. ``technology`` is the placement axis this function + # dispatches on; a probe is a fourth mode alongside lora / alora / sr. adapter_mapping: dict[str, dict[str, str]] = {} sr_anchors: set[str] = set() for adapter_info in discovered_adapters: @@ -983,6 +1023,9 @@ def configure_chat_template( mapping_entries = [] for adapter_name, info in adapter_mapping.items(): + # ``type`` is the placement mode: lora (sequence start) / alora (before + # the invocation text) / sr (replaces the generation-prompt anchor) / + # probe (very last token, after the last turn's close). if "invocation_text" in info: mapping_entries.append( f" '{adapter_name}': {{'token': '{info['token']}', " @@ -1011,6 +1054,12 @@ def configure_chat_template( {%- set adapter_invocation_drop_chars = adapter_map[adapter_name]['invocation_drop_chars'] %} {%- endif %} {%- endif %} +{#- A probe reads its verdict at the marker, so nothing may follow it: + drop the generation prompt even when the caller asks for one. -#} +{%- set probe_mode = adapter_type == 'probe' %} +{%- if probe_mode %} +{%- set add_generation_prompt = false %} +{%- endif %} """ @@ -1282,6 +1331,13 @@ def configure_chat_template( + modified_chat_template[insertion_point:] ) + # Probe placement: the marker is the very last token, after the last + # turn's close. + if any(i["type"] == ANCHOR_MODE_PROBE for i in adapter_mapping.values()): + modified_chat_template += ( + "\n{%- if probe_mode %}{{- adapter_token }}{%- endif %}" + ) + # Insert alora fallback before generation prompt gen_prompt_pattern = r"(\{%- if add_generation_prompt %\})" match = re.search(gen_prompt_pattern, modified_chat_template) @@ -1385,7 +1441,9 @@ def _split_merged(match: "re.Match") -> str: tokenizer.chat_template = modified_chat_template print(f"Chat template configured with {len(adapter_mapping)} adapter mappings:") for adapter_name, info in adapter_mapping.items(): - if info["type"] == ANCHOR_MODE_SR: + if info["type"] == ANCHOR_MODE_PROBE: + placement = "after the last turn's close (last token; read point = marker)" + elif info["type"] == ANCHOR_MODE_SR: placement = f"replacing '{sr_site}' in the generation prompt" anchor = next(iter(sr_anchors)) if sr_site != anchor: diff --git a/src/granite_switch/composer/validator.py b/src/granite_switch/composer/validator.py index 56710936..a6c7b455 100644 --- a/src/granite_switch/composer/validator.py +++ b/src/granite_switch/composer/validator.py @@ -65,7 +65,7 @@ def validate_control_lut(model) -> None: def validate_cross_stream_population(model): - """Check that every adapter in a Shadow Residual checkpoint loaded its + """Check that every SR adapter in a Shadow Residual checkpoint loaded its ``cross_stream`` weights. An all-zero slot means the base→adapter injection never fires for that @@ -85,6 +85,8 @@ def validate_cross_stream_population(model): # slots in a freshly built model, but lora_B decides whether the branch # contributes anything. for adapter_idx in range(param.shape[0]): + if model.config.adapter_kinds[adapter_idx] == "probe": + continue if bool(torch.all(param[adapter_idx] == 0)): empty.append(f"{name}[{adapter_idx}]") diff --git a/src/granite_switch/composer/weight_transfer.py b/src/granite_switch/composer/weight_transfer.py index cff280db..adebda4c 100644 --- a/src/granite_switch/composer/weight_transfer.py +++ b/src/granite_switch/composer/weight_transfer.py @@ -15,6 +15,11 @@ from .arch import ArchDescriptor +# Filename + keys of a trained probe-head artifact inside a probe +# adapter directory. +PROBE_HEAD_FILE = "probe_head.safetensors" +PROBE_HEAD_KEYS = ("weight", "bias") + # --------------------------------------------------------------------------- # Base weight loading # --------------------------------------------------------------------------- @@ -824,3 +829,113 @@ def validate_untied_lm_head_saved(output_path, expected_vocab_size, hidden_size) f" Untied LM head validated: lm_head.weight present, shape " f"[{expected_vocab_size}, {hidden_size}]" ) + + +# --------------------------------------------------------------------------- +# Probe head transfer +# --------------------------------------------------------------------------- + + +def transfer_probe_weights( + probe_paths: list[str], + slot_indices: list[int], + model, + num_labels_per_slot: list[int], + hidden_size: int, +) -> None: + """Load trained probe heads and transfer them into the switch model. + + A probe slot is the probe-head analog of a LoRA adapter: it shares + the same control-token / adapter-index space, so its head lives in bank row + ``slot = adapter_index - 1`` of ``model.model.probe_head``. Each + probe directory holds a ``probe_head.safetensors`` with a + ``weight`` ``[n_i, hidden]`` and a ``bias`` ``[n_i]`` where ``n_i`` is this + slot's own label count. The head bank is padded to ``max_probe_labels``. + + Args: + probe_paths: Directories of the probe slots (probe-only). + slot_indices: Global bank row for each path (``adapter_index - 1``), + aligned with *probe_paths*. Probes are the non-zero-based + ``k+1..n`` suffix, so these are not ``range(len)`` and must be passed. + model: GraniteSwitch model whose ``probe_head`` is filled in place. + num_labels_per_slot: Full-width per-slot label counts + (``config.probe_num_labels_per_slot``, indexed by + ``adapter_index - 1``). + hidden_size: Expected hidden size (``config.hidden_size``). + """ + from safetensors.torch import load_file + + head = getattr(model.model, "probe_head", None) + if head is None: + raise ValueError( + "transfer_probe_weights was given probe paths " + f"{probe_paths} but the model built no probe_head. This " + "means adapter_kinds never reached the config (no slot is " + "kind=probe). Pass adapter_kinds with a 'probe' entry so " + "the head is constructed before loading trained weights." + ) + + # Two directories on one bank row, or a row outside the bank, would + # silently corrupt another slot's head. + if len(slot_indices) != len(set(slot_indices)): + raise ValueError( + f"Duplicate probe slot indices {slot_indices}; each probe " + "must map to a distinct bank row." + ) + num_rows = head.weight.shape[0] + for slot in slot_indices: + if not (0 <= slot < num_rows): + raise ValueError( + f"Probe slot {slot} is out of range for a probe bank " + f"of {num_rows} rows (num_adapters)." + ) + + print(f"Loading {len(probe_paths)} trained probe head(s)...") + with torch.no_grad(): + for path, slot in zip(probe_paths, slot_indices): + n_labels = num_labels_per_slot[slot] + head_file = Path(path) / PROBE_HEAD_FILE + if not head_file.exists(): + raise FileNotFoundError( + f"Probe slot '{path}' is missing its trained head " + f"'{PROBE_HEAD_FILE}'. A probe directory must " + f"contain {PROBE_HEAD_FILE} with keys " + f"{PROBE_HEAD_KEYS} (weight [n_labels, hidden], " + f"bias [n_labels]); it has no adapter_model.safetensors." + ) + + sd = load_file(str(head_file)) + keys = set(sd.keys()) + if keys != set(PROBE_HEAD_KEYS): + raise ValueError( + f"{head_file} must contain exactly keys " + f"{set(PROBE_HEAD_KEYS)}, got {keys}." + ) + + weight, bias = sd["weight"], sd["bias"] + if tuple(weight.shape) != (n_labels, hidden_size): + raise ValueError( + f"{head_file}: weight shape {tuple(weight.shape)} does not " + f"match this slot's expected (n_labels, hidden) = " + f"({n_labels}, {hidden_size}). Check the head's label count " + f"against probe_num_labels_per_slot[{slot}]={n_labels}." + ) + if tuple(bias.shape) != (n_labels,): + raise ValueError( + f"{head_file}: bias shape {tuple(bias.shape)} does not " + f"match this slot's expected (n_labels,) = ({n_labels},)." + ) + + # Copy into the top n_labels rows of the padded bank row; the rest + # stay zero. + dst_w = head.weight[slot] + head.weight[slot, :n_labels, :].copy_( + weight.to(dtype=dst_w.dtype, device=dst_w.device) + ) + head.bias[slot, :n_labels].copy_( + bias.to(dtype=dst_w.dtype, device=dst_w.device) + ) + + print(f" Probe: {path} -> probe_head[{slot}, :{n_labels}]") + + print(f"Loaded {len(probe_paths)} probe head(s)") diff --git a/src/granite_switch/config.py b/src/granite_switch/config.py index 9ee20816..f5f358cb 100644 --- a/src/granite_switch/config.py +++ b/src/granite_switch/config.py @@ -27,12 +27,13 @@ class GraniteSwitchConfig(GraniteMoeHybridConfig): substitution. Args: - num_adapters (int): Number of LoRA adapters available. Default: 0 (no adapters). - This counts real LoRA adapters only (not base). Index 0 always means "base / no adapter". + num_adapters (int): Number of adapter slots available (LoRA and + probe). Default: 0. Index 0 always means "base / no adapter". adapter_token_ids (List[int]): Token IDs for adapter control. - Length: num_adapters (one token per real adapter). Must be unique. - adapter_token_ids[i] activates adapter i+1 (1-indexed output). - Output 0 = base (implicit default, no token needed to return to base). + Length: num_adapters (or num_adapters + 1 with a leading base-reset + token). Must be unique. adapter_token_ids[i] activates slot i+1 + (1-indexed output), a LoRA adapter or a probe head. + Output 0 = base. adapter_substitute_token_ids (List[int]): Token IDs whose embeddings replace the control-token embeddings before the decoder runs. Length: num_adapters (or num_adapters + 1 with a leading base-reset @@ -181,6 +182,10 @@ def __init__( # Shadow Residual (SR) parameters cross_stream_rank: int | None = None, dual_stream: bool = False, + # Probe substitution parameters + adapter_kinds: list[str] | None = None, + probe_label_token_ids: list[list[int] | None] | None = None, + probe_read_layers: list[int | None] | None = None, # vLLM residual-norm convention (for bit-exact skinning equivalence) fused_add_norm: bool = False, # Parent class defaults (Granite 4 dense configuration) @@ -381,6 +386,105 @@ def __init__( self.max_lora_rank = max_lora_rank self.adapter_ranks = adapter_ranks + # Each adapter slot is either a LoRA adapter ("lora", the default) or + # a probe head ("probe"). A probe slot still uses a + # control token and adapter index; the switch's adapter_kind_lut routes + # its positions out of the LoRA stream and into the probe head. + _VALID_KINDS = ("lora", "probe") + if adapter_kinds is None: + adapter_kinds = ["lora"] * num_adapters + else: + if len(adapter_kinds) != num_adapters: + raise ValueError( + f"adapter_kinds length ({len(adapter_kinds)}) must equal " + f"num_adapters ({num_adapters})." + ) + invalid = [k for k in adapter_kinds if k not in _VALID_KINDS] + if invalid: + raise ValueError( + f"adapter_kinds values must be one of {_VALID_KINDS}; " + f"got invalid entries {invalid}" + ) + self.adapter_kinds = adapter_kinds + # Probe metadata resolved at compose time. + if probe_label_token_ids is not None: + if len(probe_label_token_ids) != num_adapters: + raise ValueError( + f"probe_label_token_ids length " + f"({len(probe_label_token_ids)}) must equal num_adapters " + f"({num_adapters}); it is indexed by adapter_index - 1, with " + f"None on non-probe slots." + ) + for i, (kind, ids) in enumerate(zip(adapter_kinds, probe_label_token_ids)): + if kind == "probe": + if not ids: + raise ValueError( + f"probe slot {i} must declare its label token ids; " + f"got {ids!r}." + ) + if any(tid < 0 for tid in ids): + raise ValueError( + f"probe_label_token_ids[{i}] must all be >= 0 " + f"(real token ids); got {ids}" + ) + elif ids: + raise ValueError( + f"non-probe slot {i} must have no label token ids; got {ids!r}." + ) + elif any(k == "probe" for k in adapter_kinds): + raise ValueError( + "adapter_kinds declares a probe slot but " + "probe_label_token_ids is unset. The probe exit emits its " + "verdict as a label word, so the per-label token ids are required. " + "Re-compose so config.json carries probe_label_token_ids." + ) + self.probe_label_token_ids = probe_label_token_ids + + # Per slot: zero-based decoder layer a probe reads (switch cache + # layers excluded); None reads the final post-norm hidden state. + if probe_read_layers is not None: + if len(probe_read_layers) != num_adapters: + raise ValueError( + f"probe_read_layers length ({len(probe_read_layers)}) " + f"must equal num_adapters ({num_adapters})." + ) + num_decoder_layers = self.num_hidden_layers - SWITCH_CACHE_LAYERS + for i, (kind, layer) in enumerate(zip(adapter_kinds, probe_read_layers)): + if layer is None: + continue + if kind != "probe": + raise ValueError( + f"non-probe slot {i} must have no read layer; got {layer!r}." + ) + # bool is an int subclass. + if ( + not isinstance(layer, int) + or isinstance(layer, bool) + or not 0 <= layer < num_decoder_layers + ): + raise ValueError( + f"probe_read_layers[{i}] = {layer!r} must be an int in " + f"0..{num_decoder_layers - 1}; omit it for the final layer." + ) + self.probe_read_layers = probe_read_layers + + # Derived: per-slot label counts (0 on LoRA slots) and the padded bank + # width the probe head is built with. + self.probe_num_labels_per_slot = [ + len(ids) if ids else 0 for ids in (probe_label_token_ids or []) + ] or [0] * num_adapters + self.max_probe_labels = max(self.probe_num_labels_per_slot, default=0) + + # Derived: the control-token ids that mark a probe slot. Both backends + # locate a verdict's read point by matching these in the original input_ids, + # so the id set is defined once here. + probe_token_offset = len(adapter_token_ids or []) - num_adapters + self.probe_control_token_ids = [ + (adapter_token_ids or [])[i + probe_token_offset] + for i, kind in enumerate(adapter_kinds) + if kind == "probe" and adapter_token_ids is not None + ] + # Default LoRA target module groups. # Dynamically determined based on model architecture. # Empty when num_adapters == 0 (no LoRA to apply). diff --git a/src/granite_switch/hf/core/__init__.py b/src/granite_switch/hf/core/__init__.py index 38341501..da1a92ac 100644 --- a/src/granite_switch/hf/core/__init__.py +++ b/src/granite_switch/hf/core/__init__.py @@ -6,9 +6,11 @@ MergedSwitchedLoRALinear, SwitchedLoRALinear, ) +from .probe import SwitchedProbeHead __all__ = [ "GraniteLoRAEmbeddedAttention", "MergedSwitchedLoRALinear", "SwitchedLoRALinear", + "SwitchedProbeHead", ] diff --git a/src/granite_switch/hf/core/probe.py b/src/granite_switch/hf/core/probe.py new file mode 100644 index 00000000..735d4218 --- /dev/null +++ b/src/granite_switch/hf/core/probe.py @@ -0,0 +1,111 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Switched activation probe heads for Granite Switch. + +An activation probe head, a linear head over a hidden state, is an optional +substitute for a LoRA adapter: an adapter slot is either a LoRA adapter or a +probe head, selected by the same control token and adapter index. Where a probe +is selected, the LoRA path no-ops. +The model supplies the configured layer's hidden states (the final normalized +state by default), and the head produces per-label scores. + +Probe positions are expected to be sparse, and the weight bank is replicated +on every rank at this point; revisit if probes ever cover most tokens or +num_labels grows large. +""" + +import torch +import torch.nn as nn + + +class SwitchedProbeHead(nn.Module): + """Per-token probe heads selected by probe indices. + + Stacked weight bank indexed by ``(index - 1)``, like ``SwitchedLoRALinear``. + Index ``0`` = no probe (base); ``1+`` selects that slot's head. + Positions not selected are left at zero. + + Args: + hidden_size: Input feature dimension (the model hidden size). + num_probe_slots: ``num_adapters`` (the full index space), so + ``index - 1`` addresses the bank directly like the LoRA bank. Slots + that are not probes simply never get selected. + max_num_labels: Padded label width of the bank — ``max`` over the + per-slot label counts. Slots with fewer labels leave their extra + rows zero. The verdict exit slices each slot back to its real label + count. + """ + + def __init__( + self, + hidden_size: int, + num_probe_slots: int, + max_num_labels: int, + ): + super().__init__() + self.hidden_size = hidden_size + self.num_probe_slots = num_probe_slots + self.num_labels = max_num_labels + + # Slot s (0-based) serves adapter index s + 1, matching the LoRA bank's + # tensor_idx = adapter_idx - 1. + # + # A frozen inference bank whose real values are copied in from a trained + # head at compose time. Zero value for both an unloaded slot's row and a + # loaded slot's pad rows (label counts below max_num_labels). + self.weight = nn.Parameter( + torch.zeros(num_probe_slots, max_num_labels, hidden_size) + ) + self.bias = nn.Parameter(torch.zeros(num_probe_slots, max_num_labels)) + + def forward( + self, + x: torch.Tensor, + probe_indices: torch.Tensor, + ) -> torch.Tensor: + """Compute per-token probe logits for selected positions. + + Args: + x: Input tensor [batch, seq_len, hidden_size] or [num_tokens, hidden_size]. + probe_indices: Per-token probe selection [batch, seq_len] + or [num_tokens]; 0 = no probe, 1+ = slot index. + + Returns: + Logits with the selected slot's head applied per token: + [batch, seq_len, num_labels] or [num_tokens, num_labels]. Positions + with index 0 (and any not selected by this bank) are left at zero. + """ + # Flatten for token-level processing (same as SwitchedLoRALinear). + original_shape = x.shape + if x.dim() == 3: + batch_size, seq_len, _ = x.shape + x_flat = x.reshape(-1, self.hidden_size) + else: + x_flat = x + + logits_flat = x_flat.new_zeros((x_flat.shape[0], self.num_labels)) + + idx_flat = probe_indices.reshape(-1) + + # Per-token gather/scatter loop (same as SwitchedLoRALinear.forward) + mask = idx_flat > 0 + if mask.any(): + active = idx_flat[mask].unique() + for adapter_idx in active: + token_mask = idx_flat == adapter_idx + token_indices = torch.where(token_mask)[0] + + if len(token_indices) == 0: + continue + + slot = adapter_idx - 1 # 1-indexed → 0-indexed, like the LoRA bank + w = self.weight[slot] # [num_labels, hidden_size] + b = self.bias[slot] # [num_labels] + + x_sel = x_flat[token_indices] # [num_sel, hidden_size] + # Write (not add): a probe replaces the LoRA. + logits_flat[token_indices] = x_sel @ w.t() + b + + # Reshape back if needed. + if len(original_shape) == 3: + return logits_flat.view(batch_size, seq_len, self.num_labels) + return logits_flat diff --git a/src/granite_switch/hf/modeling_granite_switch.py b/src/granite_switch/hf/modeling_granite_switch.py index 22d5c4c7..7a0608b1 100644 --- a/src/granite_switch/hf/modeling_granite_switch.py +++ b/src/granite_switch/hf/modeling_granite_switch.py @@ -34,10 +34,12 @@ SwitchedLoRALinear, replace_shared_mlp_projections_with_lora, ) +from .core.probe import SwitchedProbeHead from .switch import create_switch logger = logging.get_logger(__name__) + # transformers 5.9.0 renamed `input_embeds` -> `inputs_embeds` and dropped the # unused `cache_position` kwarg in `create_causal_mask`. _TRANSFORMERS_GE_5_9 = _parse_version(transformers.__version__) >= _parse_version( @@ -417,6 +419,22 @@ def __init__(self, config: GraniteSwitchConfig): self.switch = None self.adapter_token_ids = None + # Probe heads: optional alternative for LoRA adapters. A probe + # slot shares the adapter index/token machinery, but its index is tagged + # as "probe" (via the switch's adapter_kind_lut). The switch's + # split_indices() routes those positions out of the LoRA stream (so LoRA + # no-ops there) and into a probe stream. The head reads its + # configured layer (the final post-norm state by default), scoring + # intermediate layers as the decoder runs and keeping only marker rows. + if "probe" in getattr(config, "adapter_kinds", []): + self.probe_head = SwitchedProbeHead( + hidden_size=config.hidden_size, + num_probe_slots=config.num_adapters, + max_num_labels=config.max_probe_labels, + ) + else: + self.probe_head = None + # Decoder layers if config.num_adapters > 0: layer_offset = self.switch.num_cache_layers @@ -461,6 +479,57 @@ def __init__(self, config: GraniteSwitchConfig): for layer in self.layers: nn.init.zeros_(layer.cross_stream.base_layer.weight) + def _locate_probe_markers(self, input_ids, probe_indices, attention_mask): + """``(rows, pos, slots, markers_by_layer)`` for this forward's markers. + + ``markers_by_layer`` maps each read layer (None = final) to the marker + indices that read it. ``None`` when there is no probe marker. Each + marker must be its row's last real token, since the verdict replaces + that position's logits. + """ + if self.probe_head is None: + return None + if input_ids is None: + if bool((probe_indices > 0).any()): + raise RuntimeError("Probe requests need input_ids, not inputs_embeds.") + return None + marker_ids = torch.tensor( + self.config.probe_control_token_ids, + device=input_ids.device, + dtype=input_ids.dtype, + ) + rows, pos = torch.isin(input_ids, marker_ids).nonzero(as_tuple=True) + if rows.numel() == 0: + return None + + batch_size, seq_length = input_ids.shape + if attention_mask is not None and attention_mask.dim() == 2: + real = attention_mask[:, -seq_length:].long() + cols = torch.arange(seq_length, device=real.device) + last = (real * cols).max(dim=1).values + else: + last = torch.full((batch_size,), seq_length - 1, device=input_ids.device) + misplaced = pos != last[rows] + if bool(misplaced.any()): + bad = sorted(set(rows[misplaced].tolist())) + raise RuntimeError( + f"Probe request(s) {bad}: control token is not their last token." + ) + + slots = probe_indices[rows, pos] + read_layers = self.config.probe_read_layers or [None] * len( + self.config.adapter_kinds + ) + by_layer: dict[int | None, list[int]] = {} + for marker_idx, slot in enumerate(slots.tolist()): + layer = read_layers[slot - 1] if slot > 0 else None + by_layer.setdefault(layer, []).append(marker_idx) + markers_by_layer = { + layer: torch.tensor(indices, device=input_ids.device) + for layer, indices in by_layer.items() + } + return rows, pos, slots, markers_by_layer + def forward( self, input_ids: torch.LongTensor | None = None, @@ -587,6 +656,15 @@ def forward( device=device, ) + # Split the single adapter_indices stream into a LoRA stream and + # a probe stream. Probe positions are zeroed in lora_indices + # (so the LoRA path no-ops there). + if self.switch is not None: + lora_indices, probe_indices = self.switch.split_indices(adapter_indices) + else: + lora_indices = adapter_indices + probe_indices = torch.zeros_like(adapter_indices) + # Embed once, on the (possibly-rewritten) input_ids. The decoder is # token-exchange-agnostic — it just embeds whatever the switch # passed through. @@ -594,8 +672,13 @@ def forward( inputs_embeds = self.embed_tokens(modified_input_ids) inputs_embeds = inputs_embeds * self.embedding_multiplier - # Expose adapter_indices for tests and debugging. + # Expose adapter_indices for tests and debugging. _last_adapter_indices is + # the full stream (LoRA + probe); _last_lora_indices is what the + # decoder routes on; _last_probe_indices is the probe-only + # stream the verdict reads. self._last_adapter_indices = adapter_indices + self._last_lora_indices = lora_indices + self._last_probe_indices = probe_indices position_embeddings = None if self.rotary_emb is not None: @@ -615,7 +698,27 @@ def forward( all_hidden_states = () if output_hidden_states else None all_self_attns = () if output_attentions else None - for decoder_layer in self.layers: + # Resolve probe read positions before the decoder runs, so each read + # layer scores only its marker rows as it produces them. + markers = self._locate_probe_markers(input_ids, probe_indices, attention_mask) + self._last_probe_verdict = None + if markers is not None: + probe_rows, probe_pos, probe_slots, markers_by_layer = markers + probe_verdict = inputs_embeds.new_zeros( + (probe_rows.numel(), self.config.max_probe_labels) + ) + + def score_markers(layer, hidden): + """Score the markers that read ``layer`` (None = final) from ``hidden``.""" + if markers is None or layer not in markers_by_layer: + return + idx = markers_by_layer[layer] + probe_verdict[idx] = self.probe_head( + hidden[probe_rows[idx], probe_pos[idx]], + probe_slots[idx], + ) + + for layer_idx, decoder_layer in enumerate(self.layers): if output_hidden_states: all_hidden_states += (hidden_states,) @@ -628,7 +731,7 @@ def forward( use_cache=use_cache, cache_position=cache_position, position_embeddings=position_embeddings, - adapter_indices=adapter_indices, + adapter_indices=lora_indices, **kwargs, ) @@ -637,6 +740,8 @@ def forward( else: hidden_states = layer_outputs[0] + score_markers(layer_idx, hidden_states) + if output_attentions: if layer_outputs[1] is not None: all_self_attns += (layer_outputs[1],) @@ -646,6 +751,16 @@ def forward( if output_hidden_states: all_hidden_states += (hidden_states,) + if markers is not None: + score_markers(None, hidden_states) + self._last_probe_verdict = ( + probe_rows, + probe_pos, + probe_verdict, + probe_slots, + seq_length, + ) + if not return_dict: return tuple( v @@ -764,6 +879,10 @@ def forward( logits = self.lm_head(hidden_states[:, slice_indices, :]) logits = logits / self.config.logits_scaling + # Probe exit: a probe request reports its verdict as a + # generated label word (like a guardian LoRA). + logits = self._apply_probe_verdict(logits) + loss = None if labels is not None: loss = self.loss_function( @@ -780,3 +899,33 @@ def forward( hidden_states=outputs.hidden_states, attentions=outputs.attentions, ) + + def _apply_probe_verdict(self, logits: torch.Tensor) -> torch.Tensor: + """Rewrite each probe marker's logit row to emit its label word. + + The row is blanked to ``-inf`` and the slot's per-label verdict scores are + placed on its label token ids (``probe_label_token_ids``). + """ + pending = getattr(self.model, "_last_probe_verdict", None) + if pending is None: + return logits + rows, pos, verdict, slots, seq_length = pending + + # logits keeps the last logits.shape[1] positions (logits_to_keep). + cols = pos - (seq_length - logits.shape[1]) + if bool((cols < 0).any()): + raise RuntimeError( + "A probe marker's logits were not kept (logits_to_keep)." + ) + + # Each marker writes only its own slot's label ids and only that slot's + # real label count (verdict[i, :n]); padded columns are never read. + per_slot_label_ids = self.config.probe_label_token_ids + logits[rows, cols, :] = float("-inf") + for i, (r, c, slot) in enumerate( + zip(rows.tolist(), cols.tolist(), slots.tolist()) + ): + ids = per_slot_label_ids[slot - 1] + label_ids = torch.tensor(ids, dtype=torch.long, device=logits.device) + logits[r, c, label_ids] = verdict[i, : len(ids)].to(logits.dtype) + return logits diff --git a/src/granite_switch/hf/switch/multi.py b/src/granite_switch/hf/switch/multi.py index 2212ffce..0ec61111 100644 --- a/src/granite_switch/hf/switch/multi.py +++ b/src/granite_switch/hf/switch/multi.py @@ -66,6 +66,7 @@ from transformers.cache_utils import Cache from transformers.modeling_utils import ALL_ATTENTION_FUNCTIONS +from ...probe_routing import build_adapter_kind_lut, split_adapter_indices from ...token_exchange import apply_token_exchange, build_control_to_substitute_lut from .codes import KerdockDGCodeGenerator, recover_count_from_signal @@ -275,6 +276,16 @@ def __init__( # non-persistent buffer is zeroed by ``from_pretrained``. This LUT uses # -1 as the "not a control token" sentinel, so an all-zero LUT would # rewrite EVERY token id to 0 in apply_token_exchange. + # adapter_kind_lut: marks which adapter indices are probe slots, so + # split_indices can route those positions out of the LoRA stream. None when + # no slot is a probe. persistent=True so it survives HF's + # meta-device loading, like the token-exchange LUT below. + kind_lut = build_adapter_kind_lut(config) + if kind_lut is not None: + self.register_buffer("adapter_kind_lut", kind_lut, persistent=True) + else: + self.adapter_kind_lut = None + lut = build_control_to_substitute_lut(config) if lut is not None: self.register_buffer("control_to_substitute_lut", lut, persistent=True) @@ -308,6 +319,15 @@ def _apply(self, *args, **kwargs): self.codebook = self.codebook.to(torch.float32) return out + def split_indices( + self, adapter_indices: torch.Tensor + ) -> tuple[torch.Tensor, torch.Tensor]: + """Split ``[batch, seq_len]`` or ``[num_tokens]`` into (lora, probe). + + See :func:`granite_switch.probe_routing.split_adapter_indices`. + """ + return split_adapter_indices(self.adapter_kind_lut, adapter_indices) + @property def num_cache_layers(self) -> int: """Cache slots used by this switch: counting slot + memory slot.""" diff --git a/src/granite_switch/probe_routing.py b/src/granite_switch/probe_routing.py new file mode 100644 index 00000000..0c4c23f3 --- /dev/null +++ b/src/granite_switch/probe_routing.py @@ -0,0 +1,58 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Activation-probe slot routing helpers, shared by both backends' switches. + +An adapter slot is either a generation adapter or an activation probe head, +selected by the same control token and adapter index. These helpers split the +switch's single per-token index stream so that probe positions no-op the LoRA +path and feed the probe head instead. + +Backend-neutral on purpose: the table is a pure function of +``config.adapter_kinds``, so both switch engines (HF and vLLM MultiSwitch) +build the identical table. +""" + +import torch + + +def build_adapter_kind_lut(config) -> torch.Tensor | None: + """Bool LUT over adapter indices marking which slots are probes. + + ``[num_adapters + 1]``, indexed by ADAPTER INDEX: entry ``i`` is True when + adapter index ``i`` is a probe slot (index 0, base, is always False). + ``None`` when no slot is a probe, which lets the switch skip the split + entirely. Built from ``config.adapter_kinds``, so both backends' switches + agree on the table by construction. + """ + kinds = getattr(config, "adapter_kinds", None) if config is not None else None + if kinds is None or not any(k == "probe" for k in kinds): + return None + lut = torch.zeros(len(kinds) + 1, dtype=torch.bool) + for slot, kind in enumerate(kinds): # slot is the 0-based adapter position + if kind == "probe": + lut[slot + 1] = True # adapter index is slot + 1 + return lut + + +def split_adapter_indices( + kind_lut: torch.Tensor | None, adapter_indices: torch.Tensor +) -> tuple[torch.Tensor, torch.Tensor]: + """Split a per-token index stream into LoRA and probe streams. + + Shapes are preserved (``[batch, seq_len]`` on HF, ``[total_tokens]`` on + vLLM), so this serves both backends: + + - ``lora_indices``: probe positions zeroed, so the LoRA path no-ops + there. + - ``probe_indices``: the adapter index at probe positions, else 0. + + With ``kind_lut`` None (no probe slots) the LoRA stream passes through + and the probe stream is all zeros. + """ + if kind_lut is None: + return adapter_indices, torch.zeros_like(adapter_indices) + is_probe = kind_lut[adapter_indices] + zeros = torch.zeros_like(adapter_indices) + return ( + torch.where(is_probe, zeros, adapter_indices), + torch.where(is_probe, adapter_indices, zeros), + ) diff --git a/src/granite_switch/vllm/core/__init__.py b/src/granite_switch/vllm/core/__init__.py index 96afa952..eefeb042 100644 --- a/src/granite_switch/vllm/core/__init__.py +++ b/src/granite_switch/vllm/core/__init__.py @@ -10,9 +10,11 @@ from .lora import SwitchedLoRALinear from .lora_kernel_meta import FusedLoRAKernelMeta, LoRAContext +from .probe import SwitchedProbeHead __all__ = [ "FusedLoRAKernelMeta", "LoRAContext", "SwitchedLoRALinear", + "SwitchedProbeHead", ] diff --git a/src/granite_switch/vllm/core/probe.py b/src/granite_switch/vllm/core/probe.py new file mode 100644 index 00000000..140b332d --- /dev/null +++ b/src/granite_switch/vllm/core/probe.py @@ -0,0 +1,95 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Switched activation probe heads for Granite Switch (vLLM backend). + +An activation probe head, a linear head over a hidden state, is an optional +substitute for a LoRA adapter: an adapter slot is either a LoRA adapter or a +probe head, selected by the same control token and adapter index. Where a probe +is selected, the LoRA path no-ops. +The model supplies the configured layer's hidden states (the final normalized +state by default), and the head produces per-label scores. + +Probe positions are expected to be sparse, and the weight bank is replicated +on every rank at this point; revisit if probes ever cover most tokens or +num_labels grows large. +""" + +import torch +import torch.nn as nn + + +class SwitchedProbeHead(nn.Module): + """Per-token probe heads selected by probe indices (vLLM). + + Compile-safe, on the flat ``[total_tokens, hidden_size]`` layout vLLM's + scheduler produces. Index ``0`` = no probe (base -> zero logits); + ``1+`` selects that slot's head. + + The weight bank is stacked ``[num_slots, ...]`` and indexed by + ``(index - 1)``, matching ``SwitchedLoRALinear``. + + Args: + hidden_size: Input feature dimension (the model hidden size). + num_probe_slots: ``num_adapters`` (the full index space). Slot ``s`` + (0-based) serves adapter index ``s + 1``. Slots that are not + probes simply never get selected. + max_num_labels: Padded label width of the bank (``max`` over the per-slot + label counts). Keeping this uniform is what lets the per-token logits + be one fused ``einsum``. Slots with fewer labels leave their extra + rows zero. The verdict exit slices each slot back to its real label + count. + """ + + def __init__( + self, + hidden_size: int, + num_probe_slots: int, + max_num_labels: int, + ): + super().__init__() + self.hidden_size = hidden_size + self.num_probe_slots = num_probe_slots + self.num_labels = max_num_labels + + # Stacked bank indexed by (adapter_idx - 1), like the LoRA bank + # weight: [num_slots, max_num_labels, hidden_size] + # bias: [num_slots, max_num_labels] + # + # A frozen inference bank whose real values are copied in from a trained + # head at compose time. Zero value for both an unloaded slot's row and a + # loaded slot's pad rows (label counts below max_num_labels). + self.weight = nn.Parameter( + torch.zeros(num_probe_slots, max_num_labels, hidden_size) + ) + self.bias = nn.Parameter(torch.zeros(num_probe_slots, max_num_labels)) + + def forward( + self, + x: torch.Tensor, + probe_indices: torch.Tensor, + ) -> torch.Tensor: + """Compute per-token probe logits for selected positions. + + Branchless and loop-free so it traces cleanly under + ``@support_torch_compile``. + + Args: + x: Input tensor [total_tokens, hidden_size]. + probe_indices: [total_tokens]; 0 = no probe, 1+ = slot + index (the un-offset adapter index). + + Returns: + Logits [total_tokens, num_labels]. Positions with index 0 are zero. + """ + is_probe = probe_indices > 0 # [total_tokens] + slot = (probe_indices - 1).clamp_min(0) # [total_tokens]; base -> row 0 + + w_per_token = self.weight[slot] # [total_tokens, num_labels, hidden] + b_per_token = self.bias[slot] # [total_tokens, num_labels] + + # Per-token linear, batched over tokens as one fused einsum. + logits = torch.einsum("tlh,th->tl", w_per_token, x) + logits = logits + b_per_token + + # Zero out base positions. + logits = logits * is_probe.unsqueeze(-1).to(logits.dtype) + return logits diff --git a/src/granite_switch/vllm/decoder/interface.py b/src/granite_switch/vllm/decoder/interface.py index adcd324a..dbcc93cd 100644 --- a/src/granite_switch/vllm/decoder/interface.py +++ b/src/granite_switch/vllm/decoder/interface.py @@ -305,6 +305,10 @@ def to_intermediate(self, state) -> tuple[torch.Tensor, torch.Tensor | None]: ``residual=None``) so vLLM's per-token IntermediateTensors slice is safe. """ + @abc.abstractmethod + def layer_output(self, state, adapter_indices) -> torch.Tensor: + """Read this layer's per-token residual stream before the final norm.""" + @abc.abstractmethod def exit_decoder_stack(self, state, adapter_indices, norm, config) -> torch.Tensor: """Last-rank finalize: produce the final ``[M, H]`` hidden states.""" @@ -424,6 +428,11 @@ def run_layer(self, layer, positions, state): def to_intermediate(self, state): return state.hidden_states, state.residual + def layer_output(self, state, adapter_indices): + if state.residual is None: + return state.hidden_states + return state.residual + state.hidden_states + def exit_decoder_stack(self, state, adapter_indices, norm, config): # Fold in the last residual via rms_norm_select so the same fused/separate # convention is used throughout (bit-exact with the original vLLM class). @@ -567,7 +576,10 @@ def enter_decoder_stack(self, hidden_states, residual): if residual is None: hs = torch.cat([hidden_states, hidden_states], dim=0) # [2M, H] else: - hs = torch.cat([hidden_states, residual], dim=0) # [2M, H] + # Both PP halves have M rows. Stack preserves that relation in the + # symbolic shape (2*M), avoiding a late slice-bound dependency when + # vLLM partitions post-attention LoRA metadata reads. + hs = torch.stack([hidden_states, residual], dim=0).flatten(0, 1) return SRStackState(hs=hs) def run_layer(self, layer, positions, state): @@ -580,15 +592,18 @@ def to_intermediate(self, state): m = state.hs.shape[0] // 2 return state.hs[:m].contiguous(), state.hs[m:].contiguous() - def exit_decoder_stack(self, state, adapter_indices, norm, config): + def layer_output(self, state, adapter_indices): hs = state.hs m = adapter_indices.shape[0] - # Per-token merge: adapter-active tokens read the adapter stream, the rest - # read the (base-equivalent) base stream. SR uses plain norm, not - # rms_norm_select (its residual is materialized inside each layer). + # Read after the layer's cross-stream shunt: adapter-active tokens use + # the adapted stream, the rest (including probe markers, whose LoRA + # stream is zeroed) use the base stream. select = (adapter_indices > 0).unsqueeze(1) # [M, 1] - merged = torch.where(select, hs[m:], hs[:m]) - return norm(merged) + return torch.where(select, hs[m:], hs[:m]) + + def exit_decoder_stack(self, state, adapter_indices, norm, config): + # SR materializes residuals inside each layer, so use a plain norm. + return norm(self.layer_output(state, adapter_indices)) # --------------------------------------------------------------------------- # diff --git a/src/granite_switch/vllm/granite_switch_model.py b/src/granite_switch/vllm/granite_switch_model.py index a6dd1c5b..853aa0f6 100644 --- a/src/granite_switch/vllm/granite_switch_model.py +++ b/src/granite_switch/vllm/granite_switch_model.py @@ -168,6 +168,65 @@ def __init__( self.lora_meta = None self.lora_ctx = None + # Probe heads: optional alternative for LoRA adapters. A probe + # slot shares the adapter index/token machinery, but its index is tagged + # as "probe" (via the switch's adapter_kind_lut). The switch's + # split_indices() routes those positions out of the LoRA stream (so LoRA + # no-ops there) and into a probe stream. The head scores each slot + # at its read layer (the final post-norm state by default). + if "probe" in getattr(config, "adapter_kinds", []): + from .core import SwitchedProbeHead + + self.probe_head = SwitchedProbeHead( + hidden_size=config.hidden_size, + num_probe_slots=config.num_adapters, + max_num_labels=config.max_probe_labels, + ) + + # Packed verdicts assume the ordinary sampler's row selection. + if vllm_config.speculative_config is not None: + raise ValueError("Probe slots do not support speculative decoding.") + if vllm_config.parallel_config.use_ubatching: + raise ValueError("Probe slots do not support microbatching.") + + # Built once: copying a list to CUDA inside forward breaks capture. + self.register_buffer( + "probe_control_token_ids_t", + torch.tensor(config.probe_control_token_ids, dtype=torch.long), + persistent=False, + ) + + is_probe = [kind == "probe" for kind in config.adapter_kinds] + # Index 0 is a sentinel so a 1-based slot id indexes it directly. + self.register_buffer( + "probe_slot_mask_t", + torch.tensor([False, *is_probe]), + persistent=False, + ) + + # One buffer of slot ids per read layer (None = final). Store buffer + # names, not tensors: Module.to() replaces registered buffers. + read_layers = config.probe_read_layers or [None] * num_adapters + slots_by_layer: dict[int | None, list[int]] = {} + for slot, (is_clf, layer) in enumerate(zip(is_probe, read_layers), start=1): + if is_clf: + slots_by_layer.setdefault(layer, []).append(slot) + self._probe_final_layer_group = None + self._probe_layer_groups: dict[int, str] = {} + for layer, slot_ids in slots_by_layer.items(): + name = f"_probe_slots_{'final' if layer is None else layer}" + self.register_buffer( + name, torch.tensor(slot_ids, dtype=torch.long), persistent=False + ) + if layer is None: + self._probe_final_layer_group = name + else: + self._probe_layer_groups[layer] = name + else: + self.probe_head = None + self._probe_final_layer_group = None + self._probe_layer_groups = {} + # 3. Base transformer layers with custom LoRA # # When adapters are present, config.num_hidden_layers includes placeholder @@ -246,6 +305,15 @@ def make_empty_intermediate_tensors( device=device, ), } + if self.probe_head is not None: + tensors["probe_indices"] = torch.zeros( + batch_size, dtype=torch.long, device=device + ) + tensors["probe_scores"] = torch.zeros( + (batch_size, self.config.max_probe_labels), + dtype=dtype, + device=device, + ) return IntermediateTensors(tensors) @@ -317,40 +385,38 @@ def forward( ) modified_input_ids = input_ids - # Prepare kernel metadata ONCE for all decoder layers (adaptation-specific). - if self.lora_meta is not None and self.lora_ctx is not None: - self.decoder_interface.prepare_kernel_meta( - self.lora_meta, adapter_indices, self.lora_ctx - ) + else: + assert intermediate_tensors is not None + adapter_indices = intermediate_tensors["adapter_indices"] - # Store metadata in intermediate_tensors for pipeline parallelism. - if intermediate_tensors is None: - intermediate_tensors = IntermediateTensors({}) - intermediate_tensors["adapter_indices"] = adapter_indices + # Split the single adapter_indices stream into a LoRA stream and a + # probe stream. Probe positions are zeroed in lora_indices (so + # the LoRA path no-ops there). + if self.switch is not None: + lora_indices, probe_indices = self.switch.split_indices(adapter_indices) else: - # Subsequent ranks: recompute fixed-size LoRA metadata from - # token-leading adapter_indices received through PP. - if intermediate_tensors is not None: - adapter_indices = intermediate_tensors["adapter_indices"] - if self.lora_ctx is not None: - self.decoder_interface.prepare_kernel_meta( - self.lora_meta, adapter_indices, self.lora_ctx - ) - else: - # Fallback: no metadata available (should not happen in normal operation) - num_tokens = input_ids.shape[0] if input_ids is not None else 0 - if input_ids is not None: - fallback_device = input_ids.device - elif self.lora_meta is not None: - fallback_device = self.lora_meta.device - else: - fallback_device = self.embed_tokens.weight.device - adapter_indices = torch.zeros( - num_tokens, - dtype=torch.long, - device=fallback_device, + lora_indices = adapter_indices + probe_indices = torch.zeros_like(adapter_indices) + + # The verdict is read only at the marker itself, not at tokens that + # inherit its slot afterwards. Later PP ranks have no input ids, so they + # receive the marker-only indices from the first rank. + if self.probe_head is not None: + if not get_pp_group().is_first_rank: + probe_indices = intermediate_tensors["probe_indices"] + elif input_ids is not None: + is_marker = torch.isin(input_ids, self.probe_control_token_ids_t) + probe_indices = torch.where( + is_marker, probe_indices, torch.zeros_like(probe_indices) ) + # Rebuild this rank's fixed-size kernel metadata from token-leading + # adapter ids. The fused kernel routes on the LoRA stream. + if self.lora_meta is not None and self.lora_ctx is not None: + self.decoder_interface.prepare_kernel_meta( + self.lora_meta, lora_indices, self.lora_ctx + ) + # ═══════════════════════════════════════════════════════════════ # Get embeddings (or hidden states from previous pipeline stage) # ═══════════════════════════════════════════════════════════════ @@ -377,17 +443,52 @@ def forward( # threads (hidden_states, residual); SR re-stacks to [2M,H] rank-locally # in enter_decoder_stack and collapses it in exit_decoder_stack. All # per-forward metadata (kernel meta + hiding masks) is on the shared ctx. + # Probe slots reading an intermediate layer are scored as soon as + # that layer runs; only the small score tensor is carried forward. + if self.probe_head is not None: + if get_pp_group().is_first_rank: + probe_scores = torch.zeros( + probe_indices.shape[0], + self.config.max_probe_labels, + device=probe_indices.device, + dtype=hidden_states.dtype, + ) + else: + probe_scores = intermediate_tensors["probe_scores"] state = self.decoder_interface.enter_decoder_stack(hidden_states, residual) for i in range(self.start_layer, self.end_layer): state = self.decoder_interface.run_layer(self.layers[i], positions, state) + group = self._probe_layer_groups.get(i) + if group is not None: + probe_scores = self._score_group( + group, + self.decoder_interface.layer_output(state, lora_indices), + probe_indices, + probe_scores, + ) if get_pp_group().is_last_rank: # Adaptation-specific finalize: LoRA folds the last residual via # rms_norm_select (fused/separate convention); SR does the per-token - # where-merge of its two streams then a plain norm. - return self.decoder_interface.exit_decoder_stack( - state, adapter_indices, self.norm, self.config + # where-merge of its two streams then a plain norm. The returned + # [M, H] tensor is the final post-norm hidden state (what the LM head + # consumes). + hidden_states = self.decoder_interface.exit_decoder_stack( + state, lora_indices, self.norm, self.config ) + + # Pack scores and slot ids onto the hidden states so vLLM's own + # logits_indices gather selects them; compute_logits unpacks them. + if self.probe_head is not None: + probe_scores, probe_slot = self._compute_probe_outputs( + hidden_states, probe_indices, probe_scores + ) + hidden_states = torch.cat( + [hidden_states, probe_scores, probe_slot.unsqueeze(-1)], + dim=-1, + ) + + return hidden_states else: # Non-last rank: ship two token-leading [M,H] tensors so vLLM's # per-token IntermediateTensors slice stays correct. LoRA sends @@ -397,8 +498,48 @@ def forward( h_out, r_out = self.decoder_interface.to_intermediate(state) intermediate_tensors["hidden_states"] = h_out intermediate_tensors["residual"] = r_out + intermediate_tensors["adapter_indices"] = adapter_indices + if self.probe_head is not None: + intermediate_tensors["probe_indices"] = probe_indices + intermediate_tensors["probe_scores"] = probe_scores return intermediate_tensors + def _score_group( + self, + group: str, + hidden: torch.Tensor, + probe_indices: torch.Tensor, + probe_scores: torch.Tensor, + ) -> torch.Tensor: + """Write the scores of the slots in buffer ``group``, read from ``hidden``.""" + in_group = torch.isin(probe_indices, getattr(self, group)) + slot_logits = self.probe_head(hidden, probe_indices) + return torch.where(in_group.unsqueeze(-1), slot_logits, probe_scores) + + def _compute_probe_outputs( + self, + final_hidden_states: torch.Tensor, + probe_indices: torch.Tensor, + probe_scores: torch.Tensor, + ) -> tuple[torch.Tensor, torch.Tensor]: + """Add final-layer scores and return per-token (scores, slot ids). + + ``probe_indices`` is nonzero only at this forward's marker tokens, + computed on the first PP rank and passed unchanged between stages. + Callers must place one probe marker last in each request; + misplaced or duplicate markers are not rejected (request-boundary + validation is deferred). + """ + probe_slot = probe_indices.to(final_hidden_states.dtype) + if self._probe_final_layer_group is not None: + probe_scores = self._score_group( + self._probe_final_layer_group, + final_hidden_states, + probe_indices, + probe_scores, + ) + return probe_scores, probe_slot + @MULTIMODAL_REGISTRY.register_processor( GraniteSwitchASRMultiModalProcessor, @@ -582,14 +723,13 @@ def forward( intermediate_tensors: IntermediateTensors | None = None, inputs_embeds: torch.Tensor | None = None, ) -> torch.Tensor | IntermediateTensors: - """Forward pass returning hidden states.""" - hidden_states = self.model( + """Forward pass returning hidden states (probe columns packed on).""" + return self.model( input_ids=input_ids, positions=positions, intermediate_tensors=intermediate_tensors, inputs_embeds=inputs_embeds, ) - return hidden_states def compute_logits( self, @@ -605,8 +745,61 @@ def compute_logits( per-token adapter_indices computed in forward(). Any suppression would have to act where adapter_indices and hidden_states still share the same token dimension. + + Probe exit: the probe request reports its verdict as a + generated token, like a guardian LoRA. With a probe head the + hidden states carry packed (scores, slot id) columns; split them off, + run the LM head on the real hidden state, then rewrite each probe + row: the whole vocab to -inf and the per-label scores onto the label + words' token ids (``probe_label_token_ids``). Other rows keep + their LM logits. + """ + if self.model.probe_head is not None: + hidden_size = self.config.hidden_size + real_hidden = hidden_states[:, :hidden_size] + verdict = hidden_states[:, hidden_size:-1] + slot = hidden_states[:, -1] + else: + real_hidden = hidden_states + verdict = slot = None + logits = self.logits_processor(self.lm_head, real_hidden) + return self._apply_probe_verdict(logits, verdict, slot) + + def _apply_probe_verdict( + self, + logits: torch.Tensor | None, + verdict: torch.Tensor | None, + slot: torch.Tensor | None, + ) -> torch.Tensor | None: + """Rewrite probe requests' logit rows to emit their label word. + + Rows are classified by the slot mask, not just ``slot > 0``: vLLM's + profiling sampler run feeds random hidden states, so the packed slot + column can land on a LoRA slot, which has no label ids. """ - return self.logits_processor(self.lm_head, hidden_states) + if logits is None or verdict is None or slot is None: + return logits + + slot_mask = self.model.probe_slot_mask_t + num_slots = slot_mask.shape[0] - 1 + slot_int = slot.round().long() + # Out-of-range values land on the sentinel row 0. + safe_idx = slot_int.clamp(min=0, max=num_slots) + is_probe = (slot_int == safe_idx) & slot_mask[safe_idx] + if not bool(is_probe.any()): + return logits + + # Each row writes only its own slot's real label count; padded + # columns are never read. + per_slot_label_ids = self.config.probe_label_token_ids + rows = is_probe.nonzero(as_tuple=True)[0] + logits[rows] = float("-inf") + for r in rows.tolist(): + row_slot = int(slot_int[r]) + ids = per_slot_label_ids[row_slot - 1] + label_ids = torch.tensor(ids, dtype=torch.long, device=logits.device) + logits[r, label_ids] = verdict[r, : len(ids)].to(logits.dtype) + return logits def sample( self, diff --git a/src/granite_switch/vllm/switch/multi.py b/src/granite_switch/vllm/switch/multi.py index 56a6d828..a2292706 100644 --- a/src/granite_switch/vllm/switch/multi.py +++ b/src/granite_switch/vllm/switch/multi.py @@ -102,6 +102,10 @@ KerdockDGCodeGenerator, recover_count_from_signal, ) +from granite_switch.probe_routing import ( + build_adapter_kind_lut, + split_adapter_indices, +) from granite_switch.token_exchange import ( apply_token_exchange, build_control_to_substitute_lut, @@ -312,12 +316,30 @@ def __init__( # non-persistent buffer is zeroed by checkpoint loading. This LUT uses -1 # as the "not a control token" sentinel, so an all-zero LUT would rewrite # EVERY token id to 0 in apply_token_exchange. + # adapter_kind_lut: marks which adapter indices are probe slots, so + # split_indices can route those positions out of the LoRA stream. None when + # no slot is a probe. + kind_lut = build_adapter_kind_lut(hf_config) + if kind_lut is not None: + self.register_buffer("adapter_kind_lut", kind_lut, persistent=False) + else: + self.adapter_kind_lut = None + lut = build_control_to_substitute_lut(hf_config) if lut is not None: self.register_buffer("control_to_substitute_lut", lut, persistent=True) else: self.control_to_substitute_lut = None + def split_indices( + self, adapter_indices: torch.Tensor + ) -> tuple[torch.Tensor, torch.Tensor]: + """Split ``[total_tokens]`` into (lora_indices, probe_indices). + + See :func:`granite_switch.probe_routing.split_adapter_indices`. + """ + return split_adapter_indices(self.adapter_kind_lut, adapter_indices) + @property def num_cache_layers(self) -> int: """KV-cache slots used by this switch: counting slot + memory slot.""" diff --git a/tests/composer/test_adapter_loader.py b/tests/composer/test_adapter_loader.py index 4d633a6b..9cb54254 100644 --- a/tests/composer/test_adapter_loader.py +++ b/tests/composer/test_adapter_loader.py @@ -537,7 +537,9 @@ def test_fallback_precedence_and_yaml_parity(self, tmp_path): yaml.dump(manifest_data, f) input_path = str(manifest_file) - yaml_adapters = discover_adapters_from_yaml(input_path) + yaml_adapters, probe_meta = discover_adapters_from_yaml(input_path) + # No entry declared kind/labels, so no probe metadata. + assert probe_meta == {} # Verification of Parity: compare (path, name, technology) - source differs by design # discover_adapters returns source=None, discover_adapters_from_yaml returns source=manifest_path @@ -547,3 +549,42 @@ def test_fallback_precedence_and_yaml_parity(self, tmp_path): adapters_without_source = [(p, n, t) for p, n, t, _ in adapters] yaml_without_source = [(p, n, t) for p, n, t, _ in yaml_adapters] assert sorted(adapters_without_source) == sorted(yaml_without_source) + + +class TestManifestProbeMetadata: + """discover_adapters_from_yaml surfaces optional probe declarations.""" + + def test_kind_and_labels_are_parsed(self, tmp_path): + manifest = tmp_path / "manifest.yaml" + yaml.dump( + { + "rag": {"path": "/p/rag", "type": "lora"}, + "hallucination": { + "path": "/p/hall", + "type": "lora", + "kind": "probe", + "labels": ["faithful", "hallucinated"], + }, + }, + manifest.open("w"), + ) + + found, probe_meta = discover_adapters_from_yaml(str(manifest)) + + # The adapter tuples are unchanged (still 4-wide). + assert {(n) for _, n, _, _ in found} == {"rag", "hallucination"} + # Only the probe entry appears in the metadata dict. + assert probe_meta == { + "hallucination": { + "kind": "probe", + "labels": ["faithful", "hallucinated"], + "layer": None, + } + } + + def test_plain_manifest_has_no_probe_metadata(self, tmp_path): + manifest = tmp_path / "manifest.yaml" + yaml.dump({"rag": {"path": "/p/rag", "type": "lora"}}, manifest.open("w")) + + _found, probe_meta = discover_adapters_from_yaml(str(manifest)) + assert probe_meta == {} diff --git a/tests/composer/test_base_reset_token.py b/tests/composer/test_base_reset_token.py index a0010b6d..8f677d6d 100644 --- a/tests/composer/test_base_reset_token.py +++ b/tests/composer/test_base_reset_token.py @@ -22,6 +22,9 @@ _PROBE = ( "granite_switch.composer.compose_granite_switch._probe_lora_substitute_token_id" ) +_PROBE_SLOT_SUB = ( + "granite_switch.composer.compose_granite_switch._probe_slot_substitute_token_id" +) _ALORA = "granite_switch.composer.tokenizer_setup.get_alora_first_invocation_token_id" @@ -50,7 +53,11 @@ class TestBuildControlTokenLists: def _run(self, base_reset): tokenizer = MockTokenizer(initial_vocab_size=500) - with patch(_PROBE, return_value=42), patch(_ALORA, return_value=77): + with ( + patch(_PROBE, return_value=42), + patch(_PROBE_SLOT_SUB, return_value=43), + patch(_ALORA, return_value=77), + ): return build_control_token_lists(tokenizer, self._ADAPTERS, base_reset) def test_base_reset_grows_both_lists_and_leads_with_base(self): @@ -84,7 +91,11 @@ def test_compose_layout_is_the_one_the_engine_reads_as_base_reset(self): from .test_tokenizer_setup import MockTokenizer as _Tok tokenizer = _Tok(initial_vocab_size=500) - with patch(_PROBE, return_value=42), patch(_ALORA, return_value=77): + with ( + patch(_PROBE, return_value=42), + patch(_PROBE_SLOT_SUB, return_value=43), + patch(_ALORA, return_value=77), + ): token_ids, _special, _subs = build_control_token_lists( tokenizer, self._ADAPTERS, base_reset=True ) @@ -125,7 +136,11 @@ def test_default_layout_keeps_the_adapter_offset(self): from .test_tokenizer_setup import MockTokenizer as _Tok tokenizer = _Tok(initial_vocab_size=500) - with patch(_PROBE, return_value=42), patch(_ALORA, return_value=77): + with ( + patch(_PROBE, return_value=42), + patch(_PROBE_SLOT_SUB, return_value=43), + patch(_ALORA, return_value=77), + ): token_ids, _special, _subs = build_control_token_lists( tokenizer, self._ADAPTERS, base_reset=False ) diff --git a/tests/composer/test_control_lut_refresh.py b/tests/composer/test_control_lut_refresh.py index 9bee8f61..08561779 100644 --- a/tests/composer/test_control_lut_refresh.py +++ b/tests/composer/test_control_lut_refresh.py @@ -43,7 +43,7 @@ _VOCAB_WITH_AUDIO = 203 -def _config(switch_type): +def _config(switch_type, **overrides): return GraniteSwitchConfig( vocab_size=_BASE_VOCAB, hidden_size=32, @@ -63,6 +63,7 @@ def _config(switch_type): adapter_substitute_token_ids=list(_SUB_IDS), switch_type=switch_type, torch_dtype=torch.float32, + **overrides, ) @@ -129,3 +130,41 @@ def test_no_mapping_is_not_an_error(switch_type): assert model.model.switch.control_to_substitute_lut is None assert refresh_switch_control_lut(model) is False + + +def test_probe_routing_survives_audio_resize_and_strict_reload(tmp_path): + """Probe + audio: every retained routing table must match the saved config. + + The control LUT is vocabulary-sized and is rebuilt after the resize; the + probe kind LUT is indexed by adapter, so the resize cannot stale it. + A strict reload (no ``ignore_mismatched_sizes``) must keep both, and the + probe marker must still route to its head and emit a label word. + """ + from granite_switch.composer.validator import validate_control_lut + + config = _config( + "multi", + adapter_kinds=["lora", "probe"], + probe_label_token_ids=[None, [10, 11]], + ) + model = GraniteSwitchForCausalLM(config).eval() + with torch.no_grad(): + model.model.probe_head.bias[1, :2] = torch.tensor([-5.0, 5.0]) + model.resize_token_embeddings(_VOCAB_WITH_AUDIO) + assert refresh_switch_control_lut(model) is True + validate_control_lut(model) + + kind_lut = model.model.switch.adapter_kind_lut + assert kind_lut.tolist() == [False, False, True] + + model.save_pretrained(tmp_path / "composed") + loaded = GraniteSwitchForCausalLM.from_pretrained(tmp_path / "composed").eval() + switch = loaded.model.switch + assert switch.control_to_substitute_lut.numel() == loaded.config.vocab_size + torch.testing.assert_close(switch.adapter_kind_lut, kind_lut) + + with torch.no_grad(): + out = loaded(input_ids=torch.tensor([[1, 2, 3, _CTRL_IDS[1]]]), use_cache=False) + assert loaded.model._last_lora_indices.tolist() == [[0, 0, 0, 0]] + assert loaded.model._last_probe_indices.tolist() == [[0, 0, 0, 2]] + assert int(out.logits[0, -1].argmax()) == 11 diff --git a/tests/composer/test_multi_audio_compose_e2e.py b/tests/composer/test_multi_audio_compose_e2e.py index 1b4d24e3..a2b1de5e 100644 --- a/tests/composer/test_multi_audio_compose_e2e.py +++ b/tests/composer/test_multi_audio_compose_e2e.py @@ -153,3 +153,125 @@ def test_multi_audio_checkpoint_loads_without_size_overrides(audio_multi): assert lut[ctrl_id].item() == sub_id, ctrl_id # Every non-control id stays a sentinel, so ordinary tokens pass through. assert int((lut >= 0).sum()) == len(model.config.adapter_token_ids) + + +# --------------------------------------------------------------------------- # +# A LoRA + probe checkpoint composed with and without --enable-audio must +# load strictly (no ignore_mismatched_sizes) and still route the probe +# marker to its head. The audio marker grows the vocabulary past the control +# ids, so this also checks the saved control LUT matches the shipped config. +# --------------------------------------------------------------------------- # +_PROBE_BASE = "ibm-granite/granite-4.1-3b" +_PROBE_LABELS = ["safe", "unsafe"] + + +def _write_probe_manifest(root): + """A real LoRA plus a synthetic bias-only head (label 1 always wins).""" + import torch + from safetensors.torch import save_file + from transformers import AutoConfig + + from .test_probe_compose import _find_rag_lora_dir + + hidden = AutoConfig.from_pretrained(_PROBE_BASE).hidden_size + head_dir = root / "safety" / "safety" / "granite-4.1-3b" / "lora" + head_dir.mkdir(parents=True) + bias = torch.tensor([-10.0, 10.0]) + save_file( + {"weight": torch.zeros(len(_PROBE_LABELS), hidden), "bias": bias}, + str(head_dir / "probe_head.safetensors"), + ) + (head_dir / "io.yaml").write_text("name: safety\nmodel: ~\n") + manifest = root / "adapters.yaml" + manifest.write_text( + "rag:\n" + f" path: {_find_rag_lora_dir()}\n" + " type: lora\n" + "safety:\n" + f" path: {head_dir}\n" + " type: lora\n" + " kind: probe\n" + f" labels: [{', '.join(_PROBE_LABELS)}]\n" + ) + return manifest + + +def _compose_probe(root, enable_audio): + manifest = _write_probe_manifest(root) + output_dir = root / "model" + cmd = [ + sys.executable, + "-m", + "granite_switch.composer.compose_granite_switch", + "--base-model", + _PROBE_BASE, + "--adapters", + str(manifest), + "--output", + str(output_dir), + ] + if enable_audio: + cmd.append("--enable-audio") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=BUILD_TIMEOUT) + assert result.returncode == 0, ( + f"probe compose failed (exit {result.returncode}).\n" + f"STDOUT tail:\n{result.stdout[-2000:]}\n" + f"STDERR tail:\n{result.stderr[-2000:]}" + ) + return output_dir + + +@pytest.fixture(scope="module", params=[True, False], ids=["audio", "text"]) +def probe_checkpoint(request, tmp_path_factory): + root = tmp_path_factory.mktemp("probe-audio" if request.param else "probe") + return request.param, _compose_probe(root, enable_audio=request.param) + + +@pytest.mark.xdist_group("multi_audio_compose_e2e") +def test_probe_checkpoint_loads_strictly_and_routes(probe_checkpoint): + torch = pytest.importorskip("torch") + from transformers import AutoTokenizer + + from granite_switch.hf import GraniteSwitchForCausalLM + + enable_audio, output_dir = probe_checkpoint + config = json.loads((output_dir / "config.json").read_text()) + if enable_audio: + # Guard the guard: the marker must push the vocabulary past the controls. + assert config["vocab_size"] > max(config["adapter_token_ids"]) + 1 + _assert_lut_matches(output_dir, config["vocab_size"]) + + model = GraniteSwitchForCausalLM.from_pretrained( + str(output_dir), dtype=torch.float32 + ).eval() + switch = model.model.switch + assert switch.control_to_substitute_lut.numel() == model.config.vocab_size + assert switch.adapter_kind_lut.tolist() == [False, False, True] + + tokenizer = AutoTokenizer.from_pretrained(str(output_dir)) + input_ids = tokenizer.apply_chat_template( + [{"role": "user", "content": "Is this text safe? The weather is nice."}], + adapter_name="safety", + add_generation_prompt=True, + return_tensors="pt", + return_dict=True, + )["input_ids"] + marker = tokenizer.convert_tokens_to_ids("<|safety|>") + assert input_ids[0, -1].item() == marker + with torch.no_grad(): + out = model(input_ids=input_ids, use_cache=False) + assert model.model._last_probe_indices[0, -1].item() == 2 + assert not model.model._last_lora_indices.any() + label_ids = model.config.probe_label_token_ids[1] + assert int(out.logits[0, -1].argmax()) == label_ids[1] + + +def _assert_lut_matches(output_dir, vocab_size): + from safetensors import safe_open + + for path in sorted(output_dir.glob("*.safetensors")): + with safe_open(str(path), framework="pt") as f: + if _LUT_KEY in f.keys(): + assert f.get_slice(_LUT_KEY).get_shape()[0] == vocab_size + return + pytest.fail(f"{_LUT_KEY} not found in {output_dir}") diff --git a/tests/composer/test_probe_compose.py b/tests/composer/test_probe_compose.py new file mode 100644 index 00000000..0af60d29 --- /dev/null +++ b/tests/composer/test_probe_compose.py @@ -0,0 +1,515 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Compose-time loading of trained probe-head weights. + +A "probe" slot is the probe-head analog of a LoRA adapter: it shares +the same control-token / adapter-index machinery, but instead of LoRA weights it +carries a small linear head (``weight [n_labels, hidden]`` + ``bias [n_labels]``) +that emits a per-request verdict. Each probe slot may declare a DIFFERENT +number of labels; the head bank is padded to ``max_probe_labels`` (like the +LoRA bank pads ranks to ``max_lora_rank``), so a slot's trained head lands in the +top ``n_labels`` rows of its bank row and the pad rows stay zero. The compose +pipeline builds the ``SwitchedProbeHead`` from config and transfers the +trained head weights from each probe directory's +``probe_head.safetensors`` into bank row ``slot = adapter_index - 1``. + +These tests build the model through :class:`GraniteSwitchComposer` (per CLAUDE.md +rule #5 — never hand-assemble the config) against the real ``granite-4.0-micro`` +base, with a *synthetic* probe head whose weights make one label win +deterministically, and assert: + +1. ``probe_head.weight[slot, :n_labels]`` / ``.bias[slot, :n_labels]`` exactly + equal the synthetic tensors, and the pad rows above ``n_labels`` stay zero. +2. A probe control-token forward emits the winning label's token id (the HF + verdict exit scatters the head's argmax onto that slot's + ``probe_label_token_ids``). +3. In a mixed ``[lora, probe]`` compose the LoRA slot's bank row is populated + while the probe slot's LoRA row stays zero (compose orders LoRAs first, so + slot numbering is stable), and the probe row lands in its slot. +4. Two probe slots with DIFFERENT label counts (2 and 3) each land padded + correctly and each emit a token id from their own label set. + +Marked slow + requires_model: composing the ~3B micro base is expensive, but it is +the only sanctioned construction path. +""" + +import glob +import json +import os + +import pytest +import torch + +import granite_switch.hf # noqa: F401 — registers AutoModel/AutoConfig +from granite_switch.composer.compose_utils import GraniteSwitchComposer +from granite_switch.composer.weight_transfer import PROBE_HEAD_FILE + +BASE_MODEL = "ibm-granite/granite-4.0-micro" +# The mixed test embeds a real LoRA, which must match its base. The RAG library +# ships granite-4.1-3b LoRAs, so the mixed case composes against that base. +MIXED_BASE = "ibm-granite/granite-4.1-3b" +MIXED_LORA_TARGET = "granite-4.1-3b" +RAG_LIBRARY = "ibm-granite/granite-lib-rag-r1.0" + +pytestmark = [pytest.mark.slow, pytest.mark.requires_model] + + +# --------------------------------------------------------------------------- # +# Helpers +# --------------------------------------------------------------------------- # +def _tokenizer(model=BASE_MODEL): + from transformers import AutoTokenizer + + try: + return AutoTokenizer.from_pretrained(model) + except Exception as e: # pragma: no cover - network/env dependent + pytest.skip(f"could not fetch tokenizer {model!r}: {e}") + + +def _hidden_size(model=BASE_MODEL): + from transformers import AutoConfig + + try: + return AutoConfig.from_pretrained(model).hidden_size + except Exception as e: # pragma: no cover + pytest.skip(f"could not fetch config {model!r}: {e}") + + +def _single_token_labels(tokenizer, candidates, count=2): + """Pick ``count`` label words that each encode to exactly one token id. + + The probe verdict exit writes at one logit position per request, so a + label must be a single token. Returns ``(names, ids)``. + """ + names, ids = [], [] + for word in candidates: + enc = tokenizer.encode(word, add_special_tokens=False) + if len(enc) == 1 and enc[0] not in ids: + names.append(word) + ids.append(enc[0]) + if len(names) == count: + return names, ids + pytest.skip(f"could not find {count} single-token labels among {candidates}") + + +def _write_synthetic_head(directory, weight, bias, labels): + """Write a probe slot dir: probe_head.safetensors (+ config).""" + from safetensors.torch import save_file + + os.makedirs(directory, exist_ok=True) + save_file( + {"weight": weight.contiguous(), "bias": bias.contiguous()}, + os.path.join(directory, PROBE_HEAD_FILE), + ) + # Optional manifest metadata — the composer reads kinds/labels from kwargs, + # not this file, but a real probe dir carries it, so include it. + with open(os.path.join(directory, "adapter_config.json"), "w") as f: + json.dump({"kind": "probe", "labels": list(labels)}, f) + + +def _winning_head(num_labels, hidden, winner, dtype=torch.float32): + """Head whose winning label wins for ANY hidden state (bias-only). + + All weight rows are zero, so each label's logit reduces to its bias alone — + independent of the (real, arbitrary-magnitude) hidden state. Row ``winner`` + gets a large positive bias and the rest a large negative one, so ``argmax`` + over labels is deterministically ``winner``. A zero weight row is a valid + trained artifact shape and is the only construction that is robust to the + hidden state's sign and scale on a real base model. + """ + weight = torch.zeros((num_labels, hidden), dtype=dtype) + bias = torch.full((num_labels,), -10.0, dtype=dtype) + bias[winner] = 10.0 + return weight, bias + + +def _probe_prompt_ids(tokenizer, control_token_id): + """`` as a batch of input ids for a detect forward. + + The control token (marker) is the last token, as in a chat-template render, + and is itself the read point. + """ + body = tokenizer.encode("Is this text safe?", add_special_tokens=False) + ids = [*body, control_token_id] + return torch.tensor([ids], dtype=torch.long) + + +def _find_rag_lora_dir(): + """Locate a real cached LoRA adapter dir for the mixed-case test. + + Reads the local HF cache directly (via ``scan_cache_dir``) rather than + ``snapshot_download``: the RAG library is large and only partially cached + here, and ``snapshot_download(local_files_only=True)`` refuses an incomplete + snapshot even when the one adapter this test needs is present. Skip cleanly + if no matching adapter is cached. + """ + from huggingface_hub import scan_cache_dir + + snapshot_dirs = [] + try: + for repo in scan_cache_dir().repos: + if repo.repo_id == RAG_LIBRARY: + snapshot_dirs.extend(str(rev.snapshot_path) for rev in repo.revisions) + except Exception as e: # pragma: no cover + pytest.skip(f"could not scan HF cache for {RAG_LIBRARY!r}: {e}") + + for root in snapshot_dirs: + hits = glob.glob( + os.path.join( + root, "**", MIXED_LORA_TARGET, "lora", "adapter_model.safetensors" + ), + recursive=True, + ) + # Follow the cache symlink to the real blob; skip broken (missing) ones. + hits = [h for h in sorted(hits) if os.path.exists(os.path.realpath(h))] + if hits: + return os.path.dirname(hits[0]) + + pytest.skip( + f"no cached {MIXED_LORA_TARGET} LoRA adapter with weights in {RAG_LIBRARY!r}" + ) + + +# --------------------------------------------------------------------------- # +# Tests +# --------------------------------------------------------------------------- # +def test_probe_only_compose_loads_trained_head(tmp_path): + """A single probe slot: weights land in the bank and the verdict fires.""" + tokenizer = _tokenizer() + hidden = _hidden_size() + + label_names, label_ids = _single_token_labels( + tokenizer, ["safe", "unsafe", "yes", "no", "true", "false"] + ) + num_labels = len(label_names) + winner = 1 # second label wins + weight, bias = _winning_head(num_labels, hidden, winner) + + clf_dir = str(tmp_path / "detect") + _write_synthetic_head(clf_dir, weight, bias, label_names) + + # A control token id for the single probe slot. Any spare vocab id + # works for construction; the switch fires on this id, which the prompt + # helper places last, as the chat template does. + control_id = tokenizer.vocab_size - 5 + + model = GraniteSwitchComposer.from_base_and_adapters( + base_model_name_or_path=BASE_MODEL, + adapter_paths=[clf_dir], + adapter_token_ids=[control_id], + adapter_substitute_token_ids=[control_id], # inert for a probe slot + adapter_names=["detect"], + adapter_kinds=["probe"], + # Full-width per-slot label count (single probe slot -> [num_labels]); + # ragged label metadata is one list per probe slot. + probe_label_token_ids=[label_ids], + ) + model.eval() + + # (1) The trained head is in bank row slot = index - 1 = 0, top n_labels rows. + head = model.model.probe_head + assert head is not None, "probe_head was not built" + torch.testing.assert_close( + head.weight[0, :num_labels].float(), weight.to(head.weight.dtype).float() + ) + torch.testing.assert_close( + head.bias[0, :num_labels].float(), bias.to(head.bias.dtype).float() + ) + + # (2) A detect forward emits the winning label's token id. + input_ids = _probe_prompt_ids(tokenizer, control_id).to( + next(model.parameters()).device + ) + with torch.no_grad(): + out = model(input_ids=input_ids, use_cache=False) + emitted = int(out.logits[0, -1].argmax().item()) + assert emitted == label_ids[winner], ( + f"expected winning label id {label_ids[winner]} ({label_names[winner]}), " + f"got {emitted}" + ) + + +def test_mixed_lora_and_probe_preserves_slot_numbering(tmp_path): + """[lora, probe]: LoRA slot 0 populated, probe slot 1 loaded.""" + tokenizer = _tokenizer(MIXED_BASE) + hidden = _hidden_size(MIXED_BASE) + lora_dir = _find_rag_lora_dir() + + label_names, label_ids = _single_token_labels( + tokenizer, ["safe", "unsafe", "yes", "no", "true", "false"] + ) + num_labels = len(label_names) + winner = 0 + weight, bias = _winning_head(num_labels, hidden, winner) + + clf_dir = str(tmp_path / "detect") + _write_synthetic_head(clf_dir, weight, bias, label_names) + + lora_ctrl = tokenizer.vocab_size - 6 + clf_ctrl = tokenizer.vocab_size - 5 + + model = GraniteSwitchComposer.from_base_and_adapters( + base_model_name_or_path=MIXED_BASE, + adapter_paths=[lora_dir, clf_dir], + adapter_token_ids=[lora_ctrl, clf_ctrl], + adapter_substitute_token_ids=[lora_ctrl, clf_ctrl], + adapter_names=["rag_lora", "detect"], + adapter_kinds=["lora", "probe"], + # Full width: None on the LoRA slot, ids on the probe slot. + probe_label_token_ids=[None, label_ids], + ) + model.eval() + + head = model.model.probe_head + assert head is not None + + # Probe slot is index 1 -> bank row 1; slot 0 (the LoRA slot) is never + # touched by the probe transfer, so it stays at the bank's zero init. + torch.testing.assert_close( + head.weight[1, :num_labels].float(), weight.to(head.weight.dtype).float() + ) + torch.testing.assert_close( + head.bias[1, :num_labels].float(), bias.to(head.bias.dtype).float() + ) + # Slot 0's probe row must stay all-zero (never loaded) — a stronger check + # than "not equal to the synthetic head" now that the bank is zero-init. + assert head.weight[0].abs().sum() == 0, "the LoRA slot's probe row was overwritten" + assert head.bias[0].abs().sum() == 0, "the LoRA slot's probe bias was overwritten" + + # The LoRA bank row for slot 0 must be populated, while the probe + # slot's LoRA row (slot 1) must stay unloaded. lora_B is the discriminator: + # it is ZERO-initialized and only becomes nonzero when an adapter is loaded + # (lora_A is kaiming-initialized, so an unloaded row is nonzero there too and + # can't distinguish loaded from unloaded). So: slot 0 lora_B nonzero (loaded), + # slot 1 lora_B all-zero (probe slot, never received LoRA weights). + lora_B = _first_lora_b_tensor(model) + assert lora_B is not None, "no stacked LoRA B tensor found on the model" + assert lora_B[0].abs().sum() > 0, "LoRA slot 0 bank row is unexpectedly all-zero" + assert lora_B[1].abs().sum() == 0, ( + "probe slot 1 LoRA row is not zero — slot numbering leaked" + ) + + # A detect forward on the probe control token emits the winning label. + device = next(model.parameters()).device + input_ids = _probe_prompt_ids(tokenizer, clf_ctrl).to(device) + with torch.no_grad(): + out = model(input_ids=input_ids, use_cache=False) + emitted = int(out.logits[0, -1].argmax().item()) + assert emitted == label_ids[winner] + assert model.model._last_lora_indices[0, -1].item() == 0 + assert model.model._last_probe_indices[0, -1].item() == 2 + + # The LoRA slot is selected on its own and leaves the LM logits intact. + lora_ids = torch.cat( + [torch.tensor([[lora_ctrl]]), input_ids[:, :-1].cpu()], dim=1 + ).to(device) + with torch.no_grad(): + out = model(input_ids=lora_ids, use_cache=False) + assert model.model._last_lora_indices[0, -1].item() == 1 + assert model.model._last_probe_verdict is None + assert torch.isfinite(out.logits[0, -1]).all() + + +def test_ragged_label_counts_pad_and_fire_per_slot(tmp_path): + """Two probe slots with DIFFERENT label counts (2 and 3). + + Each head lands in the top rows of its (padded) bank row, the pad rows above + its own label count stay zero, and a detect forward on each slot's control + token emits a token id from THAT slot's label set — proving the padded + einsum/scatter never leaks a phantom (padded) label across slots. + """ + tokenizer = _tokenizer() + hidden = _hidden_size() + + # Slot A: 2 labels; slot B: 3 labels. Distinct token ids across both sets. + names_a, ids_a = _single_token_labels( + tokenizer, ["safe", "unsafe", "yes", "no", "true", "false"], count=2 + ) + names_b, ids_b = _single_token_labels( + tokenizer, + ["good", "bad", "neutral", "high", "low", "medium", "left", "right", "up"], + count=3, + ) + n_a, n_b = len(names_a), len(names_b) # 2, 3 + max_labels = max(n_a, n_b) + + winner_a, winner_b = 1, 2 # last label of each set wins + w_a, b_a = _winning_head(n_a, hidden, winner_a) + w_b, b_b = _winning_head(n_b, hidden, winner_b) + + dir_a = str(tmp_path / "detect_a") + dir_b = str(tmp_path / "detect_b") + _write_synthetic_head(dir_a, w_a, b_a, names_a) + _write_synthetic_head(dir_b, w_b, b_b, names_b) + + ctrl_a = tokenizer.vocab_size - 5 + ctrl_b = tokenizer.vocab_size - 6 + + model = GraniteSwitchComposer.from_base_and_adapters( + base_model_name_or_path=BASE_MODEL, + adapter_paths=[dir_a, dir_b], + adapter_token_ids=[ctrl_a, ctrl_b], + adapter_substitute_token_ids=[ctrl_a, ctrl_b], + adapter_names=["detect_a", "detect_b"], + adapter_kinds=["probe", "probe"], + probe_label_token_ids=[ids_a, ids_b], + ) + model.eval() + + head = model.model.probe_head + assert head is not None + # Bank is padded to max_labels; each slot's head fills its top n rows. + assert head.weight.shape[1] == max_labels + + torch.testing.assert_close( + head.weight[0, :n_a].float(), w_a.to(head.weight.dtype).float() + ) + torch.testing.assert_close( + head.bias[0, :n_a].float(), b_a.to(head.bias.dtype).float() + ) + torch.testing.assert_close( + head.weight[1, :n_b].float(), w_b.to(head.weight.dtype).float() + ) + torch.testing.assert_close( + head.bias[1, :n_b].float(), b_b.to(head.bias.dtype).float() + ) + + # Slot A has fewer labels than max: its pad rows (n_a..max-1) must stay zero. + assert torch.count_nonzero(head.weight[0, n_a:]) == 0, "slot A pad rows not zero" + assert torch.count_nonzero(head.bias[0, n_a:]) == 0, "slot A pad bias not zero" + + device = next(model.parameters()).device + # Slot A fires -> emits one of A's ids (never a B id, never a padded slot). + with torch.no_grad(): + out_a = model( + input_ids=_probe_prompt_ids(tokenizer, ctrl_a).to(device), + use_cache=False, + ) + emitted_a = int(out_a.logits[0, -1].argmax().item()) + assert emitted_a == ids_a[winner_a], f"slot A emitted {emitted_a}, want {ids_a}" + assert emitted_a not in ids_b, "slot A leaked a slot-B label" + + # Slot B fires -> emits one of B's ids, including its 3rd (only-in-B) label. + with torch.no_grad(): + out_b = model( + input_ids=_probe_prompt_ids(tokenizer, ctrl_b).to(device), + use_cache=False, + ) + emitted_b = int(out_b.logits[0, -1].argmax().item()) + assert emitted_b == ids_b[winner_b], f"slot B emitted {emitted_b}, want {ids_b}" + + +def _first_lora_b_tensor(model): + """Return the first stacked LoRA-B bank tensor [num_adapters, 1, out, r]. + + lora_B is zero-init and only nonzero once an adapter is loaded, so it cleanly + distinguishes a loaded LoRA slot from an unloaded (probe) slot — unlike + lora_A, which is kaiming-init and nonzero even when never loaded. + """ + for name, buf in model.named_parameters(): + if "lora_B" in name and buf.dim() == 4: + return buf.detach() + for name, buf in model.named_buffers(): + if "lora_B" in name and buf.dim() == 4: + return buf.detach() + return None + + +@pytest.mark.parametrize( + "base", + [ + "ibm-granite/granite-4.0-micro", + "ibm-granite/granite-4.1-3b", + "ibm-granite/granite-4.2-3b", # ChatML family + ], +) +def test_probe_drops_generation_prompt(base): + """A probe render ignores ``add_generation_prompt=True``. + + The verdict is read at the marker, so the assistant generation prompt must + never follow it. vLLM's chat endpoint defaults the flag to True, so the + template forces it off instead of relying on the caller. + """ + from granite_switch.composer.tokenizer_setup import ( + add_control_tokens, + configure_chat_template, + ) + + tokenizer = _tokenizer(base) + discovered = [(None, "safety", "probe", None)] + add_control_tokens(tokenizer, discovered) + configure_chat_template(tokenizer, discovered) + messages = [{"role": "user", "content": "Is this text safe?"}] + + def render(gen, **kwargs): + return tokenizer.apply_chat_template( + messages, add_generation_prompt=gen, tokenize=False, **kwargs + ) + + assert render(True, adapter_name="safety") == render(False, adapter_name="safety") + # Base renders still honor the flag. + assert render(True) != render(False) + + +@pytest.mark.parametrize( + "base", + [ + "ibm-granite/granite-4.0-micro", + "ibm-granite/granite-4.1-3b", + "ibm-granite/granite-4.2-3b", # ChatML family + ], +) +def test_probe_marker_is_last_token(base): + """The marker is the very last token, after the last turn's close. + + Everything before it is byte-identical to a no-adapter render without a + generation prompt, so the decoder sees exactly the base transcript. + """ + from granite_switch.composer.tokenizer_setup import ( + add_control_tokens, + configure_chat_template, + ) + + tokenizer = _tokenizer(base) + discovered = [(None, "safety", "probe", None)] + (control_id,), _ = add_control_tokens(tokenizer, discovered) + configure_chat_template(tokenizer, discovered) + messages = [ + {"role": "user", "content": "Is this text safe?"}, + {"role": "assistant", "content": "It looks fine."}, + {"role": "user", "content": "And this one?"}, + ] + + plain = tokenizer.apply_chat_template( + messages, add_generation_prompt=False, tokenize=False + ) + ids = tokenizer.apply_chat_template( + messages, add_generation_prompt=True, tokenize=True, adapter_name="safety" + ) + if not isinstance(ids, list): # BatchEncoding on newer transformers + ids = ids["input_ids"] + + assert ids[-1] == control_id + assert ids.count(control_id) == 1 + assert tokenizer.decode(ids[:-1]) == plain + + +@pytest.mark.parametrize( + "base, role_open", + [ + ("ibm-granite/granite-4.0-micro", "<|start_of_role|>"), + ("ibm-granite/granite-4.1-3b", "<|start_of_role|>"), + ("ibm-granite/granite-4.2-3b", "<|im_start|>"), + ], +) +def test_probe_substitute_is_the_bases_role_open_marker(base, role_open): + from granite_switch.composer.compose_granite_switch import ( + build_control_token_lists, + ) + + tokenizer = _tokenizer(base) + discovered = [(None, "safety", "probe", None)] + _, _, substitutes = build_control_token_lists( + tokenizer, discovered, base_reset=False + ) + + assert substitutes == [tokenizer.convert_tokens_to_ids(role_open)] diff --git a/tests/composer/test_sr_compose_e2e.py b/tests/composer/test_sr_compose_e2e.py index 7a889b58..a32c7bbf 100644 --- a/tests/composer/test_sr_compose_e2e.py +++ b/tests/composer/test_sr_compose_e2e.py @@ -184,6 +184,107 @@ def _create_sr_adapter( save_file(state_dict, str(path / "adapter_model.safetensors")) +def _compose_sr_probe(path: Path, read_layers=(None,)): + """Tiny SR + probe checkpoint, also used by the vLLM test. + + An identity head exposes every component of its input, so its scores pin + the exact layer and normalization read without random margins. + """ + from granite_switch.composer import GraniteSwitchComposer + from granite_switch.composer.weight_transfer import PROBE_HEAD_FILE + + base, sr, probe = (path / name for name in ("base", "sr", "probe")) + base.mkdir(parents=True) + probe.mkdir() + _create_base_model(base) + _create_sr_adapter(sr) + save_file( + {"weight": torch.eye(HIDDEN), "bias": torch.zeros(HIDDEN)}, + str(probe / PROBE_HEAD_FILE), + ) + return GraniteSwitchComposer.from_base_and_adapters( + base_model_name_or_path=str(base), + adapter_paths=[str(sr), *[str(probe)] * len(read_layers)], + adapter_token_ids=list( + range(CONTROL_TOKEN_ID, CONTROL_TOKEN_ID + 1 + len(read_layers)) + ), + adapter_substitute_token_ids=[1, *[2] * len(read_layers)], + adapter_names=["sr", *[f"detect{i}" for i in range(len(read_layers))]], + adapter_kinds=["lora", *["probe"] * len(read_layers)], + probe_label_token_ids=[ + None, + *[list(range(100, 100 + HIDDEN))] * len(read_layers), + ], + probe_read_layers=[None, *read_layers], + switch_head_dim=16, + ).eval() + + +@pytest.mark.parametrize("read_layer", [None, 0, NUM_LAYERS - 1]) +def test_sr_and_probe_slots_select_separately(tmp_path, capsys, read_layer): + """Identity scores prove layer and normalization before/after reload. + + The probe marker routes out of the SR stream, so the probe reads the + base stream at its layer; an SR request in the same checkpoint still adapts. + """ + from granite_switch.hf import load_model + + model = _compose_sr_probe(tmp_path, read_layers=[read_layer]) + assert "cross_stream slots are all-zero" not in capsys.readouterr().out + assert model.config.dual_stream + head = model.model.probe_head + torch.testing.assert_close(head.weight[1], torch.eye(HIDDEN)) + assert torch.count_nonzero(head.weight[0]) == 0 + for layer in model.model.layers: + assert torch.count_nonzero(layer.cross_stream.lora_B[0]) > 0 + assert torch.count_nonzero(layer.cross_stream.lora_B[1]) == 0 + + def run(m, ids): + streams = [] + hook = m.model.layers[ + -1 if read_layer is None else read_layer + ].register_forward_hook(lambda _mod, _args, out: streams.append(out[0])) + try: + with torch.no_grad(): + output = m(input_ids=ids, use_cache=False) + finally: + hook.remove() + base, adapted = ( + (m.model.norm(h) if read_layer is None else h)[0, -1] for h in streams[0] + ) + return output, base, adapted + + labels = model.config.probe_label_token_ids[1] + non_labels = torch.ones(VOCAB_SIZE, dtype=torch.bool) + non_labels[labels] = False + + probe_ids = torch.tensor([[10, 21, 20, CONTROL_TOKEN_ID + 1]]) + probe, base, _ = run(model, probe_ids) + assert model.model._last_lora_indices.tolist() == [[0, 0, 0, 0]] + assert model.model._last_probe_indices.tolist() == [[0, 0, 0, 2]] + torch.testing.assert_close(probe.logits[0, -1, labels], base) + assert torch.isneginf(probe.logits[0, -1, non_labels]).all() + + sr_ids = torch.tensor([[10, CONTROL_TOKEN_ID, 20, 30]]) + sr, base, adapted = run(model, sr_ids) + assert model.model._last_lora_indices.tolist() == [[0, 1, 1, 1]] + assert not model.model._last_probe_indices.any() + assert not torch.allclose(adapted, base), "fixture must exercise SR adaptation" + assert torch.isfinite(sr.logits[0, -1]).all() + + checkpoint = tmp_path / "composed" + model.save_pretrained(checkpoint) + loaded = load_model(str(checkpoint)).eval() + torch.testing.assert_close( + loaded.model.switch.adapter_kind_lut, model.model.switch.adapter_kind_lut + ) + for ids, expected in ((probe_ids, probe), (sr_ids, sr)): + with torch.no_grad(): + restored = loaded(input_ids=ids, use_cache=False) + torch.testing.assert_close(restored.logits, expected.logits) + assert loaded.model._last_lora_indices[0, -1].item() == 1 + + # --------------------------------------------------------------------------- # Fixtures: compose once, save to disk, share across all tests # --------------------------------------------------------------------------- diff --git a/tests/composer/test_tokenizer_setup.py b/tests/composer/test_tokenizer_setup.py index 36ca607b..c2c0ca23 100644 --- a/tests/composer/test_tokenizer_setup.py +++ b/tests/composer/test_tokenizer_setup.py @@ -12,6 +12,7 @@ add_control_tokens, build_substitute_token_ids, configure_chat_template, + resolve_label_token_ids, ) _PATCH_TARGET = "granite_switch.composer.tokenizer_setup._decode_alora_invocation_text" @@ -343,6 +344,21 @@ def test_builtin_technology_uses_the_probed_substitute(self): assert subs == [42] + def test_probe_uses_its_own_substitute(self): + adapters = [("/b", "code", "lora", None), (None, "safety", "probe", None)] + + subs = build_substitute_token_ids( + adapters, lora_substitute_id=42, probe_slot_substitute_id=9 + ) + + assert subs == [42, 9] + + def test_probe_without_its_substitute_raises(self): + adapters = [(None, "safety", "probe", None)] + + with pytest.raises(ValueError, match="probe_slot_substitute_id"): + build_substitute_token_ids(adapters, lora_substitute_id=42) + class TestConfigureChatTemplate: """Structural tests for configure_chat_template — verify template assembly.""" @@ -601,3 +617,46 @@ def test_multi_token_anchor_raises(self, tmp_path): with pytest.raises(ValueError, match="encodes to 3 tokens"): configure_chat_template(tokenizer, [(path, "answerability", "sr")]) + + +class _LabelTokenizer: + """Minimal tokenizer stub for resolve_label_token_ids. + + ``single_token`` maps a word -> its single id; any word not present is + treated as multi-token (splits into one id per character) so the multi-token + failure path can be exercised deterministically. + """ + + def __init__(self, single_token: dict[str, int]): + self._single = single_token + + def encode(self, text, add_special_tokens=False): + if text in self._single: + return [self._single[text]] + # Deterministic multi-token split: one synthetic id per character. + return [1000 + i for i, _ in enumerate(text)] + + def convert_ids_to_tokens(self, ids): + return [f"" for i in ids] + + +class TestResolveLabelTokenIds: + """Tests for resolve_label_token_ids (probe label word -> token id).""" + + def test_single_token_labels_resolve_in_order(self): + tok = _LabelTokenizer({"safe": 19193, "unsafe": 39257}) + assert resolve_label_token_ids(tok, ["safe", "unsafe"]) == [19193, 39257] + + def test_order_is_preserved(self): + tok = _LabelTokenizer({"yes": 9891, "no": 2201}) + assert resolve_label_token_ids(tok, ["no", "yes"]) == [2201, 9891] + + def test_multi_token_label_fails_loud(self): + tok = _LabelTokenizer({"safe": 19193}) # "unsafe" absent -> multi-token + with pytest.raises(ValueError, match="single token"): + resolve_label_token_ids(tok, ["safe", "unsafe"]) + + def test_error_names_the_offending_word(self): + tok = _LabelTokenizer({"safe": 1}) + with pytest.raises(ValueError, match="Ambiguous"): + resolve_label_token_ids(tok, ["Ambiguous"]) diff --git a/tests/hf/test_probe.py b/tests/hf/test_probe.py new file mode 100644 index 00000000..51abf06a --- /dev/null +++ b/tests/hf/test_probe.py @@ -0,0 +1,396 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Probe-substitute path tests for GraniteSwitchForCausalLM (HF backend). + +A probe slot is an alternative to a LoRA adapter: it shares the adapter +index/control-token machinery, the switch splits probe positions out of +the LoRA stream, and the probe head reads its slot's read layer (the final +hidden state by default) to emit ONE verdict per request. The verdict is read at +the control token, which must be the request's last token. + +The verdict exits as a GENERATED LABEL WORD, matching the vLLM backend: the LM +head rewrites the marker's logit row so the vocab is -inf except at +``probe_label_token_ids``, where the per-label verdict scores are placed. There is no separate float ``probe_logits`` output field. + +CPU, random weights, no checkpoint — mirrors tests/hf/test_model_forward.py. +""" + +import pytest +import torch + +from granite_switch.config import GraniteSwitchConfig +from granite_switch.hf import GraniteSwitchForCausalLM + + +def _set_adapter_token_ids(model, token_ids): + model.model.adapter_token_ids.data = torch.tensor(token_ids, dtype=torch.long) + + +def _probe_config( + adapter_kinds, + num_labels=2, + label_token_ids=(100, 200), +): + # Full-width label token ids (length num_adapters, None on LoRA slots). Every + # probe slot gets the same ids here; mixed counts are covered by the + # compose tests. + label_token_ids = list(label_token_ids) + return GraniteSwitchConfig( + vocab_size=300, + hidden_size=64, + intermediate_size=128, + num_hidden_layers=4, # 2 switch cache layers + 2 decoder layers + num_attention_heads=4, + num_key_value_heads=4, + num_adapters=2, + adapter_token_ids=[250, 251], + adapter_substitute_token_ids=[1, 1], + adapter_names=["adapter_1", "adapter_2"], + adapter_kinds=adapter_kinds, + probe_label_token_ids=[ + list(label_token_ids) if k == "probe" else None for k in adapter_kinds + ], + max_lora_rank=4, + adapter_ranks=[4, 4], + switch_head_dim=16, + ) + + +def test_probe_head_built_only_when_slot_present(): + m_lora = GraniteSwitchForCausalLM(_probe_config(["lora", "lora"])) + m_clf = GraniteSwitchForCausalLM(_probe_config(["lora", "probe"])) + assert m_lora.model.probe_head is None + assert m_clf.model.probe_head is not None + # Bank sized to the full index space so slot = index - 1 addresses it. + assert m_clf.model.probe_head.num_probe_slots == 2 + assert m_clf.model.probe_head.num_labels == 2 + + +def test_probe_rewrites_last_logit_row_to_label_word(): + # adapter 2 (token 251) is a probe. Its verdict rides the LM logits: + # the last prompt row is -inf everywhere except at the label token ids, + # where the per-label verdict scores land — so decoding emits a label word. + config = _probe_config(["lora", "probe"], label_token_ids=(100, 200)) + model = GraniteSwitchForCausalLM(config) + _set_adapter_token_ids(model, config.adapter_token_ids) + model.eval() + + # Marker (251) last; this test checks the label-word rewrite of the emitted row. + input_ids = torch.tensor([[10, 20, 30, 40, 50, 60, 70, 251]]) + with torch.no_grad(): + out = model(input_ids=input_ids) + + last_row = out.logits[0, -1, :] # [vocab] + # Single probe slot -> probe position 0; its own label ids. + label_ids = config.probe_label_token_ids[1] # slot 2 -> index 1 + n = len(label_ids) + non_label = [t for t in range(config.vocab_size) if t not in label_ids] + + # Every non-label token is blanked; the label ids carry the verdict. + assert torch.isinf(last_row[non_label]).all() + assert (last_row[non_label] < 0).all() + assert torch.isfinite(last_row[torch.tensor(label_ids)]).all() + # The label-id scores equal the probe head's per-label verdict, sliced + # to this slot's real label count (padded columns are never scattered). + verdict = model.model._last_probe_verdict[2][0, :n] # [n_labels] + assert torch.allclose(last_row[torch.tensor(label_ids)], verdict.to(last_row.dtype)) + # The argmax (emitted token) is one of the label ids. + assert int(last_row.argmax()) in label_ids + + +def test_lora_and_probe_slots_select_separately(): + config = _probe_config(["lora", "probe"]) + model = GraniteSwitchForCausalLM(config).eval() + _set_adapter_token_ids(model, config.adapter_token_ids) + + # The LoRA control token selects slot 1 for the following tokens. + with torch.no_grad(): + model(input_ids=torch.tensor([[10, 250, 20, 30]])) + assert model.model._last_lora_indices.tolist() == [[0, 1, 1, 1]] + assert not model.model._last_probe_indices.any() + assert model.model._last_probe_verdict is None + + # The probe marker selects slot 2 and routes out of the LoRA stream. + input_ids = torch.tensor([[10, 20, 30, 251]]) + with torch.no_grad(): + output = model(input_ids=input_ids) + + assert model.model._last_lora_indices.tolist() == [[0, 0, 0, 0]] + assert model.model._last_probe_indices.tolist() == [[0, 0, 0, 2]] + label_ids = config.probe_label_token_ids[1] + last_logits = output.logits[0, -1] + assert torch.isfinite(last_logits[torch.tensor(label_ids)]).all() + assert torch.isneginf( + last_logits[[i for i in range(config.vocab_size) if i not in label_ids]] + ).all() + + +def test_non_probe_request_logits_untouched(): + # No probe token (251) present -> last token is base (index 0), the + # head's last-token verdict row is zero, and NO row is blanked: a + # non-probe request keeps a full finite vocab row. + config = _probe_config(["lora", "probe"]) + model = GraniteSwitchForCausalLM(config) + _set_adapter_token_ids(model, config.adapter_token_ids) + model.eval() + + input_ids = torch.tensor([[10, 20, 30, 40]]) + with torch.no_grad(): + out = model(input_ids=input_ids) + + assert (model.model._last_probe_indices == 0).all() + # Full finite vocab row — nothing blanked. + assert torch.isfinite(out.logits[0, -1, :]).all() + + +def test_mixed_batch_only_probe_row_rewritten(): + # Batch: row 0 is a probe request (251 fires), row 1 is plain LM. + config = _probe_config(["lora", "probe"], label_token_ids=(100, 200)) + model = GraniteSwitchForCausalLM(config) + _set_adapter_token_ids(model, config.adapter_token_ids) + model.eval() + + input_ids = torch.tensor( + [ + [10, 20, 30, 40, 251], # probe + [10, 20, 30, 40, 50], # plain + ] + ) + with torch.no_grad(): + out = model(input_ids=input_ids) + + label_ids = torch.tensor(config.probe_label_token_ids[1]) # slot 2 + non_label = [ + t for t in range(config.vocab_size) if t not in set(label_ids.tolist()) + ] + + # Probe row rewritten; plain row fully finite. + assert torch.isinf(out.logits[0, -1, non_label]).all() + assert torch.isfinite(out.logits[1, -1, :]).all() + + +def test_decode_step_after_verdict_is_plain_lm(): + # After the verdict token, a cached decode step carries no marker, so it runs + # as a plain LM step: no verdict, full finite vocab row. + config = _probe_config(["lora", "probe"]) + model = GraniteSwitchForCausalLM(config) + _set_adapter_token_ids(model, config.adapter_token_ids) + model.eval() + + input_ids = torch.tensor([[10, 20, 30, 251]]) + with torch.no_grad(): + out = model(input_ids=input_ids, use_cache=True) + assert model.model._last_probe_verdict is not None + + with torch.no_grad(): + step = model( + input_ids=torch.tensor([[100]]), + past_key_values=out.past_key_values, + use_cache=True, + ) + assert model.model._last_probe_verdict is None + assert torch.isfinite(step.logits).all() + + +def test_probe_missing_label_token_ids_fails_loud(): + # A probe slot with no resolved label token ids cannot emit a label word. + # Rejected when the config is built, so a bad checkpoint fails at load rather + # than at the first inference. + with pytest.raises(ValueError, match="probe_label_token_ids"): + GraniteSwitchConfig( + vocab_size=300, + hidden_size=64, + intermediate_size=128, + num_hidden_layers=3, + num_attention_heads=4, + num_key_value_heads=4, + num_adapters=2, + adapter_token_ids=[250, 251], + adapter_substitute_token_ids=[1, 1], + adapter_names=["adapter_1", "adapter_2"], + adapter_kinds=["lora", "probe"], + probe_label_token_ids=None, + max_lora_rank=4, + adapter_ranks=[4, 4], + switch_head_dim=16, + ) + + +def test_lora_only_path_unaffected(): + # A model with no probe slot behaves exactly as before: no probe + # head, no verdict, full finite LM logits. + config = _probe_config(["lora", "lora"]) + model = GraniteSwitchForCausalLM(config) + _set_adapter_token_ids(model, config.adapter_token_ids) + model.eval() + + input_ids = torch.tensor([[10, 250, 20, 251, 30, 40]]) + with torch.no_grad(): + out = model(input_ids=input_ids) + + assert model.model.probe_head is None + assert model.model._last_probe_verdict is None + assert torch.isfinite(out.logits).all() + assert out.logits.shape == (1, 6, config.vocab_size) + + +def test_lora_generation_on_mixed_model_decodes(): + # A model composed WITH a probe slot also serves plain LoRA/base + # generation. Firing the LoRA (adapter 1, token 250) — not the probe — + # must decode multiple tokens with no verdict and full LM logits. + config = _probe_config(["lora", "probe"]) + model = GraniteSwitchForCausalLM(config) + _set_adapter_token_ids(model, config.adapter_token_ids) + model.eval() + assert model.model.probe_head is not None # mixed model has the bank + + # Prefill fires the LoRA (250), never the probe (251). + input_ids = torch.tensor([[10, 250, 20, 30, 40]]) + with torch.no_grad(): + out = model(input_ids=input_ids, use_cache=True) + # No probe fired -> no verdict, full finite vocab row. + assert model.model._last_probe_verdict is None + assert torch.isfinite(out.logits).all() + + # Cached decode step of the same (LoRA) request yields finite logits. + with torch.no_grad(): + step = model( + input_ids=torch.tensor([[60]]), + past_key_values=out.past_key_values, + use_cache=True, + ) + assert step.logits.shape == (1, 1, config.vocab_size) + assert torch.isfinite(step.logits).all() + assert model.model._last_probe_verdict is None + + +@pytest.mark.parametrize( + "kinds, input_ids", + [ + # Content after the marker. + (["lora", "probe"], [[10, 20, 251, 30, 40]]), + # Two different probe slots, the second one last. + (["probe", "probe"], [[250, 10, 20, 30, 251]]), + # The same slot's marker twice. + (["lora", "probe"], [[251, 10, 20, 30, 251]]), + ], + ids=["content-after", "two-slots", "repeated"], +) +def test_marker_that_is_not_the_last_token_raises(kinds, input_ids): + """The verdict is read at the last token, so any other marker would be + ignored silently; it raises instead.""" + config = _probe_config(kinds, label_token_ids=(100, 200)) + model = GraniteSwitchForCausalLM(config) + _set_adapter_token_ids(model, config.adapter_token_ids) + model.eval() + + with pytest.raises(RuntimeError, match="not their last token"): + with torch.no_grad(): + model(input_ids=torch.tensor(input_ids)) + + +def test_right_padded_row_rewrites_the_marker_column(): + """With right padding the marker is the row's last real token, not its last + column; the verdict lands on the marker's own logit row.""" + config = _probe_config(["lora", "probe"], label_token_ids=(100, 200)) + model = GraniteSwitchForCausalLM(config) + _set_adapter_token_ids(model, config.adapter_token_ids) + model.eval() + + input_ids = torch.tensor([[10, 20, 251, 0, 0], [10, 20, 30, 40, 251]]) + attention_mask = torch.tensor([[1, 1, 1, 0, 0], [1, 1, 1, 1, 1]]) + with torch.no_grad(): + out = model(input_ids=input_ids, attention_mask=attention_mask) + + label_ids = config.probe_label_token_ids[1] + non_label = [t for t in range(config.vocab_size) if t not in label_ids] + assert torch.isinf(out.logits[0, 2, non_label]).all() + assert torch.isfinite(out.logits[0, -1, :]).all() # padding untouched + assert torch.isinf(out.logits[1, -1, non_label]).all() + + # Keeping only the last position drops row 0's marker row: fail loud. + with pytest.raises(RuntimeError, match="logits_to_keep"): + with torch.no_grad(): + model(input_ids=input_ids, attention_mask=attention_mask, logits_to_keep=1) + + +def _randomize_probe_head(model): + """The head bank starts at zero; give it weights so the read layer matters.""" + generator = torch.Generator().manual_seed(0) + head = model.model.probe_head + with torch.no_grad(): + head.weight.copy_(torch.randn(head.weight.shape, generator=generator)) + head.bias.copy_(torch.randn(head.bias.shape, generator=generator)) + + +def test_intermediate_layer_probe_scores_that_layers_output(): + """An intermediate read scores the marker row at the configured layer.""" + config = _probe_config(["lora", "probe"], label_token_ids=(100, 200)) + config.probe_read_layers = [None, 0] # slot 2: an intermediate layer + model = GraniteSwitchForCausalLM(config).eval() + _set_adapter_token_ids(model, config.adapter_token_ids) + _randomize_probe_head(model) + with torch.no_grad(): + out = model( + input_ids=torch.tensor([[10, 20, 30, 40, 50, 60, 70, 251]]), + output_hidden_states=True, + ) + + assert model.model._last_lora_indices[0, -1].item() == 0 + assert model.model._last_probe_indices[0, -1].item() == 2 + rows, pos, verdict, slots, _ = model.model._last_probe_verdict + expected = model.model.probe_head(out.hidden_states[1][rows, pos], slots) + final = model.model.probe_head(out.hidden_states[-1][rows, pos], slots) + torch.testing.assert_close(verdict, expected) + assert not torch.allclose(verdict, final) + torch.testing.assert_close(out.logits[0, -1, [100, 200]], verdict[0]) + + +def test_final_decoder_layer_read_uses_pre_norm_output(): + """The last decoder layer's residual read precedes the final model norm.""" + config = _probe_config(["lora", "probe"], label_token_ids=(100, 200)) + config.probe_read_layers = [None, 1] + model = GraniteSwitchForCausalLM(config).eval() + _set_adapter_token_ids(model, config.adapter_token_ids) + _randomize_probe_head(model) + captured = [] + hook = model.model.layers[1].register_forward_hook( + lambda _module, _args, output: captured.append(output[0].detach()) + ) + try: + with torch.no_grad(): + out = model( + input_ids=torch.tensor([[10, 20, 30, 40, 50, 60, 70, 251]]), + output_hidden_states=True, + ) + finally: + hook.remove() + + rows, pos, verdict, slots, _ = model.model._last_probe_verdict + expected = model.model.probe_head(captured[0][rows, pos], slots) + final_norm = model.model.probe_head(out.hidden_states[-1][rows, pos], slots) + torch.testing.assert_close(verdict, expected) + assert not torch.allclose(verdict, final_norm) + + +def test_batch_slots_score_at_their_own_read_layers(): + config = _probe_config(["probe", "probe"]) + config.probe_read_layers = [None, 0] + model = GraniteSwitchForCausalLM(config).eval() + _set_adapter_token_ids(model, config.adapter_token_ids) + _randomize_probe_head(model) + with torch.no_grad(): + out = model( + input_ids=torch.tensor([[10, 20, 250], [10, 20, 251]]), + output_hidden_states=True, + ) + + rows, pos, verdict, slots, _ = model.model._last_probe_verdict + assert slots.tolist() == [1, 2] + final_score = model.model.probe_head( + out.hidden_states[-1][rows[:1], pos[:1]], slots[:1] + ) + intermediate_score = model.model.probe_head( + out.hidden_states[1][rows[1:], pos[1:]], slots[1:] + ) + torch.testing.assert_close(verdict[:1], final_score) + torch.testing.assert_close(verdict[1:], intermediate_score) diff --git a/tests/shared/probe_recorder.py b/tests/shared/probe_recorder.py new file mode 100644 index 00000000..59c87719 --- /dev/null +++ b/tests/shared/probe_recorder.py @@ -0,0 +1,54 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Importable model-RPC callbacks for recording sampled probe rows. + +PP's multiprocessing executor uses standard pickle, so callbacks must live at +module scope. Each worker owns its records; the driver reads them through RPC. +""" + + +def install_probe_recorder(model): + """Record after compute_logits, outside the compiled/captured forward.""" + from granite_switch.vllm.granite_switch_model import GraniteSwitchForCausalLM + + target = next(m for m in model.modules() if isinstance(m, GraniteSwitchForCausalLM)) + records = [] + target._probe_test_records = records + original = target.compute_logits + config = target.config + head = target.model.probe_head + + def compute_logits(hidden_states): + logits = original(hidden_states) + slots = hidden_states[:, -1].round().long() + for row in (slots > 0).nonzero(as_tuple=True)[0].tolist(): + slot = int(slots[row]) + labels = config.probe_label_token_ids[slot - 1] + n = len(labels) + h = config.hidden_size + record = { + "slot": slot, + "packed_scores": hidden_states[row, h : h + n].float().cpu(), + "rewritten_label_logits": logits[row, labels].float().cpu(), + } + # Only a final-layer head reads the packed hidden-state prefix. + if config.probe_read_layers[slot - 1] is None: + record["served_hidden"] = hidden_states[row, :h].float().cpu() + record["loaded_weight"] = head.weight[slot - 1, :n].float().cpu() + record["loaded_bias"] = head.bias[slot - 1, :n].float().cpu() + records.append(record) + return logits + + target.compute_logits = compute_logits + + +def read_probe_records(model): + """Return and clear this worker's rows; non-sampling PP ranks return [].""" + from granite_switch.vllm.granite_switch_model import GraniteSwitchForCausalLM + + target = next(m for m in model.modules() if isinstance(m, GraniteSwitchForCausalLM)) + records = target._probe_test_records + result = [ + {k: v if k == "slot" else v.tolist() for k, v in r.items()} for r in records + ] + records.clear() + return result diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index bfc8b780..8637fa21 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -258,3 +258,65 @@ def test_lora_targets_include_shared_mlp_groups_when_present(self): "shared_input_linear", "shared_output_linear", } + + +# ════════════════════════════════════════════════════════════════════ +# N. probe_read_layers +# ════════════════════════════════════════════════════════════════════ + + +def _probe_kwargs(num_adapters=2, num_decoder_layers=4, **overrides): + """A LoRA slot(s) then one probe slot, over ``num_decoder_layers``.""" + from granite_switch.config import SWITCH_CACHE_LAYERS + + base = _valid_kwargs( + num_adapters=num_adapters, + num_hidden_layers=num_decoder_layers + SWITCH_CACHE_LAYERS, + adapter_kinds=["lora"] * (num_adapters - 1) + ["probe"], + probe_label_token_ids=[None] * (num_adapters - 1) + [[100, 200]], + ) + base.update(overrides) + return base + + +class TestProbeReadLayers: + def test_omitted_reads_the_final_layer(self): + assert GraniteSwitchConfig(**_probe_kwargs()).probe_read_layers is None + + @pytest.mark.parametrize("layers", [[None, None], [None, 0], [None, 3]]) + def test_valid_layers_are_accepted(self, layers): + cfg = GraniteSwitchConfig(**_probe_kwargs(probe_read_layers=layers)) + assert cfg.probe_read_layers == layers + + def test_wrong_length_raises(self): + with pytest.raises(ValueError, match="probe_read_layers length"): + GraniteSwitchConfig(**_probe_kwargs(probe_read_layers=[None, 2, 3])) + + def test_non_probe_slot_with_a_layer_raises(self): + with pytest.raises(ValueError, match="non-probe slot 0"): + GraniteSwitchConfig(**_probe_kwargs(probe_read_layers=[1, 2])) + + @pytest.mark.parametrize("layer", [-1, 4, 99, True, 1.0]) + def test_out_of_range_or_non_int_layer_raises(self, layer): + # 4 == num_decoder_layers: the final state is requested by omitting it. + with pytest.raises(ValueError, match=r"in 0\.\.3"): + GraniteSwitchConfig(**_probe_kwargs(probe_read_layers=[None, layer])) + + def test_dual_stream_with_intermediate_layer(self): + cfg = GraniteSwitchConfig( + **_probe_kwargs( + probe_read_layers=[None, 2], + dual_stream=True, + cross_stream_rank=8, + ) + ) + assert cfg.probe_read_layers == [None, 2] + restored = GraniteSwitchConfig.from_dict(cfg.to_dict()) + assert restored.dual_stream + assert restored.probe_read_layers == cfg.probe_read_layers + + def test_probe_slot_id_has_no_config_cap(self): + GraniteSwitchConfig(**_probe_kwargs(num_adapters=257)) + + def test_dual_stream_with_final_layer_is_accepted(self): + GraniteSwitchConfig(**_probe_kwargs(dual_stream=True, cross_stream_rank=8)) diff --git a/tests/unit/test_probe_routing.py b/tests/unit/test_probe_routing.py new file mode 100644 index 00000000..35b74d5c --- /dev/null +++ b/tests/unit/test_probe_routing.py @@ -0,0 +1,72 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Probe-slot routing: the kind LUT and the LoRA/probe index split.""" + +import torch + +from granite_switch.config import GraniteSwitchConfig +from granite_switch.probe_routing import ( + build_adapter_kind_lut, + split_adapter_indices, +) + + +def _config(adapter_kinds, label_ids, base_reset=False): + num_adapters = len(adapter_kinds) + token_ids = list(range(500, 500 + num_adapters)) + if base_reset: + token_ids = [499, *token_ids] + return GraniteSwitchConfig( + vocab_size=600, + hidden_size=64, + num_attention_heads=4, + num_key_value_heads=4, + num_hidden_layers=4, + intermediate_size=128, + num_adapters=num_adapters, + adapter_token_ids=token_ids, + adapter_names=[f"a{i}" for i in range(num_adapters)], + adapter_substitute_token_ids=[1] * len(token_ids), + adapter_ranks=[4] * num_adapters, + max_lora_rank=4, + adapter_kinds=adapter_kinds, + probe_label_token_ids=label_ids, + ) + + +class TestAdapterKindLut: + def test_none_without_probe_slots(self): + assert build_adapter_kind_lut(_config(["lora", "lora"], None)) is None + + def test_marks_probe_adapter_indices(self): + cfg = _config(["lora", "probe", "probe"], [None, [100], [101, 102]]) + assert build_adapter_kind_lut(cfg).tolist() == [False, False, True, True] + + def test_base_reset_token_does_not_shift_slots(self): + """The LUT is indexed by adapter index, not by control-token position.""" + cfg = _config(["lora", "probe"], [None, [100, 101]], base_reset=True) + assert build_adapter_kind_lut(cfg).tolist() == [False, False, True] + assert cfg.probe_control_token_ids == [501] + + +class TestSplitAdapterIndices: + def test_probe_positions_leave_the_lora_stream(self): + cfg = _config(["lora", "probe"], [None, [100, 101]]) + lora, probe = split_adapter_indices( + build_adapter_kind_lut(cfg), torch.tensor([0, 1, 1, 0, 2]) + ) + assert lora.tolist() == [0, 1, 1, 0, 0] + assert probe.tolist() == [0, 0, 0, 0, 2] + + def test_preserves_batched_shape(self): + cfg = _config(["probe", "lora"], [[100, 101], None]) + lora, probe = split_adapter_indices( + build_adapter_kind_lut(cfg), torch.tensor([[0, 2, 1], [1, 1, 0]]) + ) + assert lora.tolist() == [[0, 2, 0], [0, 0, 0]] + assert probe.tolist() == [[0, 0, 1], [1, 1, 0]] + + def test_no_probe_slots_pass_through(self): + indices = torch.tensor([0, 1, 2]) + lora, probe = split_adapter_indices(None, indices) + assert lora is indices + assert not probe.any() diff --git a/tests/vllm/_model_forward_tests.py b/tests/vllm/_model_forward_tests.py index 73cba6ff..885e1bc2 100644 --- a/tests/vllm/_model_forward_tests.py +++ b/tests/vllm/_model_forward_tests.py @@ -59,7 +59,10 @@ def _try_import_vllm(): # ── Helpers ────────────────────────────────────────────────────────── -def _tiny_vllm_config(): +def _tiny_vllm_config( + adapter_kinds=None, + probe_label_token_ids=None, +): """Minimal GraniteSwitchConfig for single-GPU vLLM tests.""" return GraniteSwitchConfig( vocab_size=300, @@ -76,6 +79,8 @@ def _tiny_vllm_config(): # finalize_weights asserts this for applicable adapters. max_lora_rank=16, adapter_ranks=[16, 16], + adapter_kinds=adapter_kinds, + probe_label_token_ids=probe_label_token_ids, switch_head_dim=32, max_position_embeddings=512, attention_multiplier=1.0, @@ -184,7 +189,7 @@ def setup_model(self): _ensure_distributed() self.device = torch.device("cuda") - self.config = _tiny_vllm_config() + self.config = self._make_config() old_dtype = torch.get_default_dtype() torch.set_default_dtype(torch.bfloat16) @@ -229,6 +234,9 @@ def setup_model(self): for name in list(self._layer_names): sfc.pop(name, None) + def _make_config(self): + return _tiny_vllm_config() + def _setup_kv_caches(self): self._kv_caches = [] self._attention_map = {} @@ -466,6 +474,40 @@ def test_different_adapters_produce_different_logits(self): ) +class TestProbeSlotRouting(_VLLMModelTestBase): + def _make_config(self): + return _tiny_vllm_config( + adapter_kinds=["lora", "probe"], + probe_label_token_ids=[None, [100, 101]], + ) + + def test_marker_routes_out_of_lora_and_emits_label(self, monkeypatch): + self.model.eval() + seen = {} + switch = self.model.model.switch + original_split = switch.split_indices + + def record_split(adapter_indices): + lora_indices, probe_indices = original_split(adapter_indices) + seen["lora"] = lora_indices.detach().cpu() + seen["probe"] = probe_indices.detach().cpu() + return lora_indices, probe_indices + + monkeypatch.setattr(switch, "split_indices", record_split) + + with torch.no_grad(): + logits = self._run_forward_and_logits([10, 20, 30, 251]) + + assert seen["lora"].tolist() == [0, 0, 0, 0] + assert seen["probe"].tolist() == [0, 0, 0, 2] + final_logits = logits[-1] + assert torch.isfinite(final_logits[[100, 101]]).all() + non_label_ids = [ + i for i in range(self.config.vocab_size) if i not in (100, 101) + ] + assert torch.isneginf(final_logits[non_label_ids]).all() + + # ════════════════════════════════════════════════════════════════════ # 5. KV visibility tests # ════════════════════════════════════════════════════════════════════ diff --git a/tests/vllm/_probe_chunked_prefill_worker.py b/tests/vllm/_probe_chunked_prefill_worker.py new file mode 100644 index 00000000..c80c2fff --- /dev/null +++ b/tests/vllm/_probe_chunked_prefill_worker.py @@ -0,0 +1,711 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Subprocess worker for the vLLM probe chunked-prefill test. + +Each sub-command is a separate process invocation so only one vLLM engine is ever +resident (same discipline as ``_generation_equivalence_worker``):: + + python worker.py compose-multilabel # CPU only; prints {"result": meta} + python worker.py run # one engine, one budget +""" + +import json +import os +import sys +import traceback + +_REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +if _REPO not in sys.path: + sys.path.insert(0, _REPO) + + +# Single-token label candidates, in priority order (only single-token ones are kept). +_LABEL_CANDIDATES = [ + "safe", + "unsafe", + "yes", + "no", + "true", + "false", + "good", + "bad", + "high", + "low", + "hot", + "cold", +] + + +def compose(base, outdir, control_offset=5, num_labels=6, scale=0.5): + """Compose a single-probe-slot checkpoint. + + ``num_labels`` single-token labels are taken from ``_LABEL_CANDIDATES`` and + ``scale`` is the std of the random head weight. + """ + from transformers import AutoConfig, AutoTokenizer + + from granite_switch.composer.compose_utils import GraniteSwitchComposer + + tok = AutoTokenizer.from_pretrained(base) + labels, ids = _pick_labels(tok, num_labels) + # Weight-based (not bias-only) head so the verdict depends on the hidden state. + weight, bias = _fixed_head( + 0, len(ids), AutoConfig.from_pretrained(base).hidden_size, scale + ) + slot = os.path.join(outdir, "detect") + _write_head(slot, weight, bias, labels) + + control_id = tok.vocab_size - control_offset + model = GraniteSwitchComposer.from_base_and_adapters( + base_model_name_or_path=base, + adapter_paths=[slot], + adapter_token_ids=[control_id], + adapter_substitute_token_ids=[control_id], + adapter_names=["detect"], + adapter_kinds=["probe"], + probe_label_token_ids=[ids], + ) + ckpt = os.path.join(outdir, "composed") + model.save_pretrained(ckpt) + tok.save_pretrained(ckpt) + del model + + words = ( + "machine translation quietly reshaped how distant villages argued " + "about rainfall while engineers debated whether copper cables or " + "glass fibre carried gossip faster than a startled horse could run " + "downhill past the old mill where children once traded marbles for " + "stories about comets volcanoes submarines and the strange arithmetic " + "of tides that neither king nor merchant ever fully trusted yet " + "everyone quoted at dinner as though the ocean kept a ledger of debts " + "owed to the moon each evening without fail or apparent complaint " + "although sailors insisted otherwise over cheap wine and louder songs" + ) + # The chat template's probe layout: the user turn, its close, then the + # marker as the very last token, which is itself the read point. + body = tok.encode( + f"<|start_of_role|>user<|end_of_role|>Is this text safe? {words}" + "<|end_of_text|>\n", + add_special_tokens=False, + ) + prompt_ids = [*body, control_id] + return { + "ckpt": ckpt, + "labels": labels, + "control_id": control_id, + "label_token_ids": ids, + "prompt_ids": prompt_ids, + } + + +def _reference_hidden(model, prompt_ids, read_layer=None): + """Read the marker's raw decoder output, or final post-norm output. + + HF replaces hidden_states[-1] with the normalized final output, so a + decoder hook is necessary for the last raw layer. Save only the marker + row and always remove the hook, including when forward raises. + """ + import torch + + captured = [] + handle = None + if read_layer is not None: + + def capture(_module, _args, output): + hidden = output[0] if isinstance(output, tuple) else output + captured.append(hidden[0, -1].detach().clone()) + + handle = model.model.layers[read_layer].register_forward_hook(capture) + try: + with torch.no_grad(): + output = model.model(input_ids=torch.tensor([prompt_ids]), use_cache=False) + return ( + captured[0] if read_layer is not None else output.last_hidden_state[0, -1] + ) + finally: + if handle is not None: + handle.remove() + + +def _pick_labels(tok, num_labels): + """The first ``num_labels`` single-token words from ``_LABEL_CANDIDATES``.""" + labels, ids = [], [] + for c in _LABEL_CANDIDATES: + e = tok.encode(c, add_special_tokens=False) + if len(e) == 1 and e[0] not in ids: + labels.append(c) + ids.append(e[0]) + if len(labels) == num_labels: + return labels, ids + raise RuntimeError(f"only {len(labels)} single-token labels, need {num_labels}") + + +def _fixed_head(seed, num_labels, hidden_size, scale=0.5): + """Fixed seed-``seed`` weight (std ``scale``) and zero bias. Do not search seeds.""" + import torch + + g = torch.Generator().manual_seed(seed) + weight = torch.randn((num_labels, hidden_size), generator=g, dtype=torch.float32) + return weight * scale, torch.zeros(num_labels, dtype=torch.float32) + + +def _write_head(slot_dir, weight, bias, labels): + from safetensors.torch import save_file + + from granite_switch.composer.weight_transfer import PROBE_HEAD_FILE + + os.makedirs(slot_dir, exist_ok=True) + save_file( + {"weight": weight.contiguous(), "bias": bias.contiguous()}, + os.path.join(slot_dir, PROBE_HEAD_FILE), + ) + json.dump( + {"kind": "probe", "labels": labels}, + open(os.path.join(slot_dir, "adapter_config.json"), "w"), + ) + open(os.path.join(slot_dir, "io.yaml"), "w").write( + f"name: {os.path.basename(slot_dir)}\nmodel: ~\n" + ) + + +def _prompt(tok, control_id): + return [*tok.encode("Is this text safe?", add_special_tokens=False), control_id] + + +def compose_at_layer(base, outdir, read_layer, control_offset=5, num_labels=2): + """Compose one probe slot reading ``read_layer``, plus its fp32 HF reference.""" + import torch + from transformers import AutoConfig, AutoModelForCausalLM, AutoTokenizer + + from granite_switch.composer.compose_utils import GraniteSwitchComposer + + tok = AutoTokenizer.from_pretrained(base) + labels, ids = _pick_labels(tok, num_labels) + control_id = tok.vocab_size - control_offset + prompt_ids = _prompt(tok, control_id) + + plain = AutoModelForCausalLM.from_pretrained(base, dtype=torch.float32).eval() + reference_hidden = _reference_hidden(plain, prompt_ids, read_layer) + del plain + + weight, bias = _fixed_head( + 0, len(ids), AutoConfig.from_pretrained(base).hidden_size + ) + slot = os.path.join(outdir, "detect") + _write_head(slot, weight, bias, labels) + + model = GraniteSwitchComposer.from_base_and_adapters( + base_model_name_or_path=base, + adapter_paths=[slot], + adapter_token_ids=[control_id], + adapter_substitute_token_ids=[control_id], + adapter_names=["detect"], + adapter_kinds=["probe"], + probe_label_token_ids=[ids], + probe_read_layers=[read_layer], + ) + ckpt = os.path.join(outdir, "composed") + model.save_pretrained(ckpt) + tok.save_pretrained(ckpt) + del model + + scores = weight.double() @ reference_hidden.double() + bias.double() + return { + "ckpt": ckpt, + "control_id": control_id, + "label_token_ids": ids, + "prompt_ids": prompt_ids, + "read_layer": read_layer, + "ground_truth_label_id": ids[int(scores.argmax())], + "ground_truth_scores": scores.tolist(), + } + + +def compose_two_slot(base, outdir, read_layer, control_offset=5, num_labels=2): + """Compose a final-layer and a ``read_layer`` slot, plus fp32 HF references.""" + import torch + from transformers import AutoConfig, AutoModelForCausalLM, AutoTokenizer + + from granite_switch.composer.compose_utils import GraniteSwitchComposer + + tok = AutoTokenizer.from_pretrained(base) + labels, ids = _pick_labels(tok, num_labels) + hidden_size = AutoConfig.from_pretrained(base).hidden_size + control_ids = [tok.vocab_size - control_offset, tok.vocab_size - control_offset - 1] + heads = [_fixed_head(seed, len(ids), hidden_size) for seed in (0, 1)] + slot_dirs = [ + os.path.join(outdir, "detect_final"), + os.path.join(outdir, "detect_mid"), + ] + for d, (w, b) in zip(slot_dirs, heads): + _write_head(d, w, b, labels) + + model = GraniteSwitchComposer.from_base_and_adapters( + base_model_name_or_path=base, + adapter_paths=slot_dirs, + adapter_token_ids=control_ids, + adapter_substitute_token_ids=control_ids, + adapter_names=["detect_final", "detect_mid"], + adapter_kinds=["probe", "probe"], + probe_label_token_ids=[ids, ids], + probe_read_layers=[None, read_layer], + ) + ckpt = os.path.join(outdir, "composed_two_slot") + model.save_pretrained(ckpt) + tok.save_pretrained(ckpt) + del model + + plain = AutoModelForCausalLM.from_pretrained(base, dtype=torch.float32).eval() + result = {"ckpt": ckpt, "label_token_ids": ids, "read_layer": read_layer} + for name, control_id, (w, b), layer in zip( + ("slot1", "slot2"), control_ids, heads, (None, read_layer) + ): + prompt_ids = _prompt(tok, control_id) + hidden = _reference_hidden(plain, prompt_ids, layer) + scores = w.double() @ hidden.double() + b.double() + result[f"{name}_prompt_ids"] = prompt_ids + result[f"{name}_reference_hidden"] = hidden.tolist() + result[f"{name}_ground_truth_label_id"] = ids[int(scores.argmax())] + result[f"{name}_ground_truth_scores"] = scores.tolist() + return result + + +def _record_probe_rows(llm): + """Install after startup; PP callbacks must be importable for standard pickle.""" + from tests.shared.probe_recorder import install_probe_recorder + + llm.apply_model(install_probe_recorder) + + +def _read_probe_rows(llm): + from tests.shared.probe_recorder import read_probe_records + + return [row for rows in llm.apply_model(read_probe_records) for row in rows] + + +def run_two_slot(ckpt, prompt_ids_1, prompt_ids_2, label_token_ids, out_path, *, eager): + """Serve both probe slots' prompts, one request each, eager or FULL. + + Under FULL both requests share one captured batch shape, exercising + replay with two different slots/read layers. Reports per-label logprobs + and the recorded probe row (packed scores, rewritten label logits, + and for the final-layer slot its actual activation and loaded head). + """ + os.environ["VLLM_ENABLE_V1_MULTIPROCESSING"] = "0" + from vllm import LLM, SamplingParams + from vllm.inputs import TokensPrompt + + kw = dict( + model=ckpt, + enforce_eager=eager, + gpu_memory_utilization=0.55, + max_model_len=4096, + enable_prefix_caching=False, + dtype="bfloat16", + ) + if not eager: + kw["compilation_config"] = {"cudagraph_mode": "FULL"} + llm = LLM(**kw) + _record_probe_rows(llm) + sp = SamplingParams(max_tokens=1, temperature=0.0, logprobs=len(label_token_ids)) + + results = [] + for expected_slot, prompt_ids in ((1, prompt_ids_1), (2, prompt_ids_2)): + out = llm.generate([TokensPrompt(prompt_token_ids=prompt_ids)], sp)[0].outputs[ + 0 + ] + records = _read_probe_rows(llm) + recorded_slots = [r["slot"] for r in records] + if recorded_slots != [expected_slot]: + raise RuntimeError( + f"expected one recorded slot-{expected_slot} row, got {recorded_slots}" + ) + lp_dict = out.logprobs[0] + label_logprobs = [ + lp_dict[tid].logprob if tid in lp_dict else None for tid in label_token_ids + ] + results.append( + { + "token_id": int(out.token_ids[0]), + "text": out.text, + "label_logprobs": label_logprobs, + **{k: v for k, v in records[0].items() if k != "slot"}, + } + ) + + json.dump({"slot1": results[0], "slot2": results[1]}, open(out_path, "w")) + + +def run_at_layer(ckpt, prompt_ids, label_token_ids, out_path, full_cudagraph=False): + """Serve one prompt; report the emitted token and every label's logprob.""" + from vllm import LLM, SamplingParams + from vllm.inputs import TokensPrompt + + kw = dict( + model=ckpt, + gpu_memory_utilization=0.55, + max_model_len=4096, + enable_prefix_caching=False, + dtype="bfloat16", + ) + if full_cudagraph: + kw["enforce_eager"] = False + kw["compilation_config"] = {"cudagraph_mode": "FULL"} + else: + kw["enforce_eager"] = True + llm = LLM(**kw) + sp = SamplingParams(max_tokens=1, temperature=0.0, logprobs=len(label_token_ids)) + out = llm.generate([TokensPrompt(prompt_token_ids=prompt_ids)], sp)[0].outputs[0] + lp_dict = out.logprobs[0] + label_logprobs = [ + lp_dict[tid].logprob if tid in lp_dict else None for tid in label_token_ids + ] + json.dump( + { + "token_id": int(out.token_ids[0]), + "text": out.text, + "label_logprobs": label_logprobs, + }, + open(out_path, "w"), + ) + + +def run_sr_probe(ckpt, out_path, *, eager, pp_size=1): + """Serve each SR-checkpoint probe and an SR request, sequentially and mixed. + + Each slot is selected on its own: a probe request carries only its marker, + and the SR request carries only the SR control token. + """ + os.environ["VLLM_ENABLE_V1_MULTIPROCESSING"] = "0" + if pp_size > 1: + # Only trusted, importable test callbacks cross the local RPC boundary. + os.environ["VLLM_ALLOW_INSECURE_SERIALIZATION"] = "1" + os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" + from transformers import AutoConfig + from vllm import LLM, SamplingParams + from vllm.inputs import TokensPrompt + + from tests.composer.test_sr_compose_e2e import CONTROL_TOKEN_ID, HIDDEN + + labels = list(range(100, 100 + HIDDEN)) + config = AutoConfig.from_pretrained(ckpt) + markers = config.probe_control_token_ids + kw = dict( + model=ckpt, + skip_tokenizer_init=True, + enforce_eager=eager, + dtype="bfloat16", + gpu_memory_utilization=0.3, + max_model_len=128, + max_num_batched_tokens=128, + max_num_seqs=16, + max_logprobs=HIDDEN + 1, + enable_prefix_caching=False, + pipeline_parallel_size=pp_size, + ) + if pp_size > 1: + kw["distributed_executor_backend"] = "mp" + if not eager: + kw["compilation_config"] = { + "cudagraph_mode": "FULL", + "cudagraph_capture_sizes": [1, 2, 4, 16], + } + llm = LLM(**kw) + _record_probe_rows(llm) + sampling = SamplingParams(max_tokens=1, temperature=0.0, logprobs=HIDDEN + 1) + sr_request = [10, CONTROL_TOKEN_ID, 20, 30] + + def serve(prompts): + outputs = llm.generate( + [TokensPrompt(prompt_token_ids=p) for p in prompts], sampling + ) + return { + "records": _read_probe_rows(llm), + "outputs": [ + { + "token_id": int(o.outputs[0].token_ids[0]), + "label_logprobs": [ + o.outputs[0].logprobs[0][tid].logprob + if tid in o.outputs[0].logprobs[0] + else None + for tid in labels + ], + "has_non_label_support": any( + tid not in labels and lp.logprob > float("-inf") + for tid, lp in o.outputs[0].logprobs[0].items() + ), + } + for o in outputs + ], + } + + # Each probe twice: the second run is the replay of the first's shape. + result = { + "sequential": [ + serve([[10, 21, 20, marker]]) for marker in markers for _ in range(2) + ] + } + mixed = [[10, 21, 20, markers[0]], sr_request, [11, 21, 20, markers[-1]]] + if len(markers) > 1: + mixed.append([12, 21, 20, markers[1]]) + result["mixed"] = serve(mixed) + with open(out_path, "w") as f: + json.dump(result, f) + + +def run_full_cudagraph_replay( + ckpt, control_id, label_token_ids, out_path, *, eager=False +): + """Run identical fixed-length prompts in eager or FULL mode. + + Includes six changed inputs, a plain request, and a mixed batch. Markers + remain last. Equal prompt lengths permit graph reuse; this test does not + instrument the dispatcher to count actual graph replays. + """ + import math + import random + + from vllm import LLM, SamplingParams + from vllm.inputs import TokensPrompt + + label_ids = set(label_token_ids) + kw = dict( + model=ckpt, + enforce_eager=eager, + gpu_memory_utilization=0.55, + max_model_len=4096, + enable_prefix_caching=False, + dtype="bfloat16", + ) + if not eager: + kw["compilation_config"] = {"cudagraph_mode": "FULL"} + llm = LLM(**kw) + sp = SamplingParams(max_tokens=1, temperature=0.0, logprobs=len(label_ids) + 1) + + def summarize(output): + return { + "token_id": int(output.token_ids[0]), + "text": output.text, + "is_label": int(output.token_ids[0]) in label_ids, + "has_non_label_support": any( + tid not in label_ids and math.isfinite(lp.logprob) + for tid, lp in output.logprobs[0].items() + ), + } + + rng = random.Random(0) + base_len = 40 + results = [] + for _ in range(6): + body = [rng.randint(1000, 2000) for _ in range(base_len)] + prompt = TokensPrompt(prompt_token_ids=[*body, control_id]) + o = llm.generate([prompt], sp)[0].outputs[0] + results.append(summarize(o)) + + # Same shape (base_len + 1 tokens), no probe marker at all -- the + # verdict from the PRECEDING call must not leak into this one. This is a + # SEQUENTIAL check (separate generate() calls), not a same-batch mix. + plain_body = [rng.randint(1000, 2000) for _ in range(base_len + 1)] + plain_prompt = TokensPrompt(prompt_token_ids=plain_body) + o = llm.generate([plain_prompt], sp)[0].outputs[0] + plain_result = summarize(o) + + # A GENUINE mixed batch: 3 requests in ONE generate() call -- probe, + # plain, probe -- so vLLM schedules and samples all three rows in the + # same forward pass. Each must get its own correct result: the plain + # row's logits must not pick up either probe row's verdict, and the + # two probe rows (same slot here, since this checkpoint has one) + # must each still fire independently. + mix_cls_body_1 = [rng.randint(1000, 2000) for _ in range(base_len)] + mix_plain_body = [rng.randint(1000, 2000) for _ in range(base_len + 1)] + mix_cls_body_2 = [rng.randint(1000, 2000) for _ in range(base_len)] + mix_prompts = [ + TokensPrompt(prompt_token_ids=[*mix_cls_body_1, control_id]), + TokensPrompt(prompt_token_ids=mix_plain_body), + TokensPrompt(prompt_token_ids=[*mix_cls_body_2, control_id]), + ] + mix_outputs = llm.generate(mix_prompts, sp) + mixed_results = [summarize(o.outputs[0]) for o in mix_outputs] + + json.dump( + { + "results": results, + "plain_result": plain_result, + "mixed_results": mixed_results, + }, + open(out_path, "w"), + ) + + +def run_eager_reference(ckpt, control_id, out_path): + # Reuse the exact prompt sequence, including the plain and mixed requests. + run_full_cudagraph_replay( + ckpt, control_id, json.loads(os.environ["CLS_LABEL_IDS"]), out_path, eager=True + ) + + +def run_budget(ckpt, prompt_ids, budget, out_path): + from vllm import LLM, SamplingParams + from vllm.inputs import TokensPrompt + + kw = dict( + model=ckpt, + enforce_eager=True, + gpu_memory_utilization=0.55, + max_model_len=4096, + enable_prefix_caching=False, + dtype="bfloat16", + ) + if budget is None: + kw["enable_chunked_prefill"] = False + else: + kw["max_num_batched_tokens"] = budget + kw["enable_chunked_prefill"] = True + llm = LLM(**kw) + sp = SamplingParams(max_tokens=1, temperature=0.0) + o = llm.generate([TokensPrompt(prompt_token_ids=prompt_ids)], sp)[0].outputs[0] + out = {"budget": budget, "token_id": int(o.token_ids[0]), "text": o.text} + json.dump(out, open(out_path, "w")) + + +def main(): + mode = sys.argv[1] + if mode in ("compose-sr-probe", "compose-sr-probes"): + from pathlib import Path + + from tests.composer.test_sr_compose_e2e import _compose_sr_probe + + read_layers = [0, 1, None] if mode == "compose-sr-probes" else [None] + model = _compose_sr_probe(Path(os.environ["CLS_OUT"]), read_layers=read_layers) + ckpt = os.path.join(os.environ["CLS_OUT"], "composed") + model.save_pretrained(ckpt) + print(json.dumps({"result": {"ckpt": ckpt}})) + return + if mode in ("run-sr-eager", "run-sr-full", "run-sr-pp-eager", "run-sr-pp-full"): + run_sr_probe( + os.environ["CLS_CKPT"], + sys.argv[3] if mode.startswith("run-sr-pp-") else sys.argv[2], + eager=mode.endswith("eager"), + pp_size=int(sys.argv[2]) if mode.startswith("run-sr-pp-") else 1, + ) + return + if mode == "compose-multilabel": + try: + meta = compose(os.environ["CLS_BASE"], os.environ["CLS_OUT"]) + print(json.dumps({"result": meta})) + except Exception as e: + print(json.dumps({"error": f"{e}\n{traceback.format_exc()}"})) + return + if mode == "run": + budget = None if sys.argv[2] == "none" else int(sys.argv[2]) + out_path = sys.argv[3] + prompt_ids = json.load(open(os.environ["CLS_PIDS"])) + try: + run_budget(os.environ["CLS_CKPT"], prompt_ids, budget, out_path) + except Exception as e: + json.dump({"fatal": f"{e}\n{traceback.format_exc()}"}, open(out_path, "w")) + sys.exit(1) + return + if mode == "run-full-cudagraph": + out_path = sys.argv[2] + control_id = int(os.environ["CLS_CONTROL_ID"]) + label_token_ids = json.loads(os.environ["CLS_LABEL_IDS"]) + try: + run_full_cudagraph_replay( + os.environ["CLS_CKPT"], control_id, label_token_ids, out_path + ) + except Exception as e: + json.dump({"fatal": f"{e}\n{traceback.format_exc()}"}, open(out_path, "w")) + sys.exit(1) + return + if mode == "run-eager-reference": + out_path = sys.argv[2] + control_id = int(os.environ["CLS_CONTROL_ID"]) + try: + run_eager_reference(os.environ["CLS_CKPT"], control_id, out_path) + except Exception as e: + json.dump({"fatal": f"{e}\n{traceback.format_exc()}"}, open(out_path, "w")) + sys.exit(1) + return + if mode == "compose-at-layer": + read_layer = int(sys.argv[2]) + try: + meta = compose_at_layer( + os.environ["CLS_BASE"], os.environ["CLS_OUT"], read_layer + ) + print(json.dumps({"result": meta})) + except Exception as e: + print(json.dumps({"error": f"{e}\n{traceback.format_exc()}"})) + return + if mode == "run-at-layer": + out_path = sys.argv[2] + prompt_ids = json.load(open(os.environ["CLS_PIDS"])) + label_token_ids = json.loads(os.environ["CLS_LABEL_IDS"]) + try: + run_at_layer(os.environ["CLS_CKPT"], prompt_ids, label_token_ids, out_path) + except Exception as e: + json.dump({"fatal": f"{e}\n{traceback.format_exc()}"}, open(out_path, "w")) + sys.exit(1) + return + if mode == "run-at-layer-full-cudagraph": + out_path = sys.argv[2] + prompt_ids = json.load(open(os.environ["CLS_PIDS"])) + label_token_ids = json.loads(os.environ["CLS_LABEL_IDS"]) + try: + run_at_layer( + os.environ["CLS_CKPT"], + prompt_ids, + label_token_ids, + out_path, + full_cudagraph=True, + ) + except Exception as e: + json.dump({"fatal": f"{e}\n{traceback.format_exc()}"}, open(out_path, "w")) + sys.exit(1) + return + if mode == "compose-two-slot": + read_layer = int(sys.argv[2]) + try: + meta = compose_two_slot( + os.environ["CLS_BASE"], os.environ["CLS_OUT"], read_layer + ) + print(json.dumps({"result": meta})) + except Exception as e: + print(json.dumps({"error": f"{e}\n{traceback.format_exc()}"})) + return + if mode in ("run-two-slot-eager", "run-two-slot-full-cudagraph"): + out_path = sys.argv[2] + prompt_ids_1 = json.loads(os.environ["CLS_PIDS_1"]) + prompt_ids_2 = json.loads(os.environ["CLS_PIDS_2"]) + label_token_ids = json.loads(os.environ["CLS_LABEL_IDS"]) + try: + run_two_slot( + os.environ["CLS_CKPT"], + prompt_ids_1, + prompt_ids_2, + label_token_ids, + out_path, + eager=mode == "run-two-slot-eager", + ) + except Exception as e: + json.dump({"fatal": f"{e}\n{traceback.format_exc()}"}, open(out_path, "w")) + sys.exit(1) + return + raise SystemExit(f"unknown mode {mode!r}") + + +if __name__ == "__main__": + if sys.argv[1].startswith("run-sr-pp-"): + # Like _pp_generation_worker, contain distributed-worker teardown in + # this subprocess so a completed test cannot hang in Python finalizers. + exit_code = 1 + try: + main() + exit_code = 0 + except BaseException: + traceback.print_exc() + finally: + sys.stdout.flush() + sys.stderr.flush() + os._exit(exit_code) + else: + main() diff --git a/tests/vllm/test_probe_capture_safety.py b/tests/vllm/test_probe_capture_safety.py new file mode 100644 index 00000000..e9fe64df --- /dev/null +++ b/tests/vllm/test_probe_capture_safety.py @@ -0,0 +1,341 @@ +# SPDX-License-Identifier: Apache-2.0 +"""CPU method tests for token-aligned, score-at-layer probe verdicts. + +GPU capture/replay and packed-output transport are tested in +``test_probe_chunked_prefill.py``. Request-boundary validation is deferred. +""" + +from types import SimpleNamespace + +import pytest +import torch + +pytest.importorskip("vllm") + +from vllm.sequence import IntermediateTensors + +from granite_switch.vllm.decoder.interface import ( + LoRADecoderInterface, + SRDecoderInterface, +) +from granite_switch.vllm.granite_switch_model import ( + GraniteSwitchForCausalLM, + GraniteSwitchModel, +) + + +class _StubProbeHead: + """Score = slot id * hidden[:, 0], so a score shows which slot and source it read.""" + + def __call__(self, x, probe_indices): + return (probe_indices.to(x.dtype) * x[:, 0]).unsqueeze(-1).expand(-1, 2) + + +class _Model(torch.nn.Module): + """Stands in for ``GraniteSwitchModel`` with the attributes its __init__ builds. + + ``read_layers``/``kinds`` are per 1-based slot; None reads the final layer. + """ + + _score_group = GraniteSwitchModel._score_group + + def __init__(self, slot_of_marker, read_layers=None, kinds=None): + super().__init__() + num_slots = max(slot_of_marker.values()) + read_layers = read_layers or [None] * num_slots + is_probe = [k == "probe" for k in kinds or ["probe"] * num_slots] + self.config = type("Cfg", (), {"max_probe_labels": 2})() + self.probe_head = _StubProbeHead() + self.probe_slot_mask_t = torch.tensor([False, *is_probe]) + slots_by_layer = {} + for slot, (is_clf, layer) in enumerate(zip(is_probe, read_layers), start=1): + if is_clf: + slots_by_layer.setdefault(layer, []).append(slot) + self._probe_final_layer_group = None + self._probe_layer_groups = {} + for layer, slot_ids in slots_by_layer.items(): + name = f"_probe_slots_{'final' if layer is None else layer}" + self.register_buffer(name, torch.tensor(slot_ids)) + if layer is None: + self._probe_final_layer_group = name + else: + self._probe_layer_groups[layer] = name + + +def _run(model, slot_of_marker, tokens): + """Call ``_compute_probe_outputs`` on a flat token list; hidden[t] = t + 1.""" + indices = [slot_of_marker.get(t, 0) for t in tokens] + hidden = torch.arange(1.0, len(tokens) + 1).unsqueeze(1).expand(-1, 4) + return GraniteSwitchModel._compute_probe_outputs( + model, hidden, torch.tensor(indices), torch.zeros(len(tokens), 2) + ) + + +def _score(model, layer, hidden_value, indices, scores): + hidden = torch.full((len(indices), 4), hidden_value) + return GraniteSwitchModel._score_group( + model, + model._probe_layer_groups[layer], + hidden, + torch.tensor(indices), + scores, + ) + + +def test_every_marker_token_gets_its_slot_and_score(): + model = _Model({90: 2, 91: 3}) + scores, slot = _run(model, {90: 2, 91: 3}, [5, 6, 90, 7, 8, 9, 91]) + assert slot.tolist() == [0, 0, 2, 0, 0, 0, 3] + assert scores[2].tolist() == [6.0, 6.0] # slot 2 * hidden 3 + assert scores[6].tolist() == [21.0, 21.0] # slot 3 * hidden 7 + + +def test_marker_is_identified_by_id_at_any_position(): + model = _Model({90: 2, 91: 3}) + _, slot = _run(model, {90: 2, 91: 3}, [90, 5, 6, 91]) + assert slot.tolist() == [2, 0, 0, 3] + + +def test_repeated_marker_of_the_same_slot_is_reported_each_time(): + model = _Model({90: 2, 91: 3}) + _, slot = _run(model, {90: 2, 91: 3}, [90, 5, 90, 6]) + assert slot.tolist() == [2, 0, 2, 0] + + +def test_no_marker_is_all_zero(): + model = _Model({90: 2, 91: 3}) + scores, slot = _run(model, {90: 2, 91: 3}, [5, 6, 7]) + assert slot.tolist() == [0, 0, 0] + assert not scores.any() + + +def test_transferred_marker_indices_need_no_input_ids(): + model = _Model({90: 2, 91: 3}) + scores, slot = GraniteSwitchModel._compute_probe_outputs( + model, torch.ones(3, 4), torch.tensor([0, 2, 0]), torch.zeros(3, 2) + ) + assert slot.tolist() == [0, 2, 0] + assert scores.tolist() == [[0.0, 0.0], [2.0, 2.0], [0.0, 0.0]] + + +def test_final_layer_scoring_leaves_intermediate_slot_scores_untouched(): + model = _Model({90: 2, 91: 3}, read_layers=[None, 1, None]) + pre_scores = torch.tensor([[0.0, 0.0], [99.0, 99.0], [0.0, 0.0]]) + scores, slot = GraniteSwitchModel._compute_probe_outputs( + model, + torch.ones(3, 4), + torch.tensor([0, 2, 3]), + pre_scores, + ) + assert slot.tolist() == [0, 2, 3] + assert scores[1].tolist() == [99.0, 99.0] # slot 2 reads layer 1 + assert scores[2].tolist() == [3.0, 3.0] + + +def test_each_intermediate_layer_scores_only_its_own_slots(): + model = _Model({90: 1, 91: 2}, read_layers=[3, 7]) + scores = _score(model, 3, 2.0, [0, 1, 2], torch.zeros(3, 2)) + assert scores[1].tolist() == [2.0, 2.0] + assert scores[2].tolist() == [0.0, 0.0] + scores = _score(model, 7, 5.0, [0, 1, 2], scores) + assert scores[1].tolist() == [2.0, 2.0] # layer 3's value kept + assert scores[2].tolist() == [10.0, 10.0] + + +def test_slots_sharing_a_layer_form_one_group(): + model = _Model({90: 1, 91: 2}) + assert model._probe_layer_groups == {} + assert getattr(model, model._probe_final_layer_group).tolist() == [1, 2] + scores, slot = _run(model, {90: 1, 91: 2}, [90, 5, 91]) + assert slot.tolist() == [1, 0, 2] + assert scores[0].tolist() == [1.0, 1.0] + assert scores[2].tolist() == [6.0, 6.0] + + +def test_lora_slots_form_no_group(): + model = _Model({91: 2}, kinds=["lora", "probe"]) + assert getattr(model, model._probe_final_layer_group).tolist() == [2] + assert model._probe_layer_groups == {} + _, slot = _run(model, {91: 2}, [5, 91, 6]) + assert slot.tolist() == [0, 2, 0] + + +def test_profiling_random_slot_on_a_lora_slot_leaves_logits_untouched(): + """vLLM's profiling sampler run feeds random hidden states; 0.75 rounds to LoRA slot 1.""" + wrapper = GraniteSwitchForCausalLM.__new__(GraniteSwitchForCausalLM) + torch.nn.Module.__init__(wrapper) + wrapper.model = _Model({91: 2}, kinds=["lora", "probe"]) + wrapper.config = type("Cfg", (), {"probe_label_token_ids": [None, [100, 200]]})() + logits = torch.randn(1, 300) + expected = logits.clone() + out = GraniteSwitchForCausalLM._apply_probe_verdict( + wrapper, logits, torch.randn(1, 2), torch.tensor([0.75]) + ) + assert torch.equal(out, expected) + + +def test_scoring_resolves_replaced_registered_buffers(): + model = _Model({90: 1, 91: 2}, read_layers=[None, 0]) + old_buffers = list(model.buffers()) + # Device moves replace buffers; poison the old storage to catch stale aliases. + model._apply(lambda tensor: tensor.clone()) + for tensor in old_buffers: + tensor.zero_() + scores = _score(model, 0, 3.0, [1, 2], torch.zeros(2, 2)) + scores, slots = GraniteSwitchModel._compute_probe_outputs( + model, + torch.full((2, 4), 5.0), + torch.tensor([1, 2]), + scores, + ) + assert scores.tolist() == [[5.0, 5.0], [6.0, 6.0]] + assert slots.tolist() == [1.0, 2.0] + + +@pytest.mark.parametrize("sr", [False, True], ids=["lora", "sr"]) +@pytest.mark.parametrize("stages", [2, 3]) +@pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16]) +def test_pipeline_handoff_without_later_token_ids(monkeypatch, sr, stages, dtype): + """Run the real forward and decoder strategies, with deterministic layer outputs. + + A layer-0 probe crosses the boundary; another probe and final norm are scored + on the last rank. With three stages, the middle rank only relays scores. + Distinct values expose resets, wrong layers, stream selection and reliance + on input_ids. The switch keeps the latest slot, like MultiSwitch, so only + the markers themselves may carry a probe index. Markers route out of + the LoRA stream; the trailing LoRA token reads the adapted SR stream. The + token slice matches vLLM's PP buffer contract. + """ + from granite_switch.vllm import granite_switch_model as model_module + + class Switch: + def __call__(self, input_ids, **kwargs): + control_slot = {89: 1, 90: 2, 91: 3, 92: 4} + active, indices = 0, [] + for token in input_ids.tolist(): + active = control_slot.get(token, active) + indices.append(active) + return torch.tensor(indices), input_ids + + def split_indices(self, indices): + is_probe = indices >= 2 + zeros = torch.zeros_like(indices) + return ( + torch.where(is_probe, zeros, indices), + torch.where(is_probe, indices, zeros), + ) + + class Layer(torch.nn.Module): + def forward(self, positions, hidden_states, residual=None): + if sr: + base, adapted = hidden_states.chunk(2) + return torch.cat([base + 1, adapted + 10]) + if residual is None: + residual = torch.zeros_like(hidden_states) + return hidden_states + 1, residual + + model = _Model( + {90: 2, 91: 3, 92: 4}, + read_layers=[None, 0, stages - 1, None], + kinds=["lora", "probe", "probe", "probe"], + ) + model.config.hidden_size = 4 + model.config.embedding_multiplier = 1.0 + model.config.fused_add_norm = False + model.switch = Switch() + model.adapter_token_ids = torch.tensor([89, 90, 91, 92]) + model.probe_control_token_ids_t = torch.tensor([90, 91, 92]) + model.lora_meta = model.lora_ctx = None + model.decoder_interface = SRDecoderInterface() if sr else LoRADecoderInterface() + model.layers = torch.nn.ModuleList([Layer() for _ in range(stages)]) + model.norm = torch.nn.Identity() + model._compute_probe_outputs = GraniteSwitchModel._compute_probe_outputs.__get__( + model + ) + model.start_layer, model.end_layer = 0, 1 + monkeypatch.setattr( + model_module, + "get_pp_group", + lambda: SimpleNamespace(is_first_rank=True, is_last_rank=False), + ) + wire = GraniteSwitchModel.forward( + model, + torch.tensor([5, 90, 91, 92, 89]), + torch.arange(5), + inputs_embeds=torch.ones(5, 4, dtype=dtype), + )[1:] + assert isinstance(wire, IntermediateTensors) + assert wire["probe_indices"].tolist() == [2, 3, 4, 0] + assert wire["probe_scores"][:, 0].tolist() == [4, 0, 0, 0] + buffers = GraniteSwitchModel.make_empty_intermediate_tensors( + model, 4, dtype, torch.device("cpu") + ) + assert buffers.tensors.keys() == wire.tensors.keys() + for key, tensor in wire.items(): + assert tensor.shape == buffers[key].shape + assert tensor.dtype == buffers[key].dtype + buffers[key].copy_(tensor) + + for rank in range(1, stages): + model.start_layer, model.end_layer = rank, rank + 1 + group = SimpleNamespace(is_first_rank=False, is_last_rank=rank == stages - 1) + monkeypatch.setattr(model_module, "get_pp_group", lambda group=group: group) + packed = GraniteSwitchModel.forward( + model, None, torch.arange(4), intermediate_tensors=buffers + ) + if not group.is_last_rank: + assert torch.equal(packed["probe_scores"], wire["probe_scores"]) + for key, tensor in packed.items(): + buffers[key].copy_(tensor) + assert packed[:, -1].tolist() == [2, 3, 4, 0] + # Markers read the base stream; the LoRA token reads the adapted SR stream. + source = 1 + stages + assert packed[:, 4].tolist() == [4, 3 * source, 4 * source, 0] + assert packed[-1, 0] == 1 + (10 if sr else 1) * stages + + +def test_sr_pipeline_stack_partitions_post_attention_metadata(): + """A later PP stage must retain 2*M through attention and metadata slicing. + + Concatenating independently symbolic PP inputs produces a late 2*((A+B)//2) + slice bound. vLLM moves that metadata getitem to its producer's partition, + creating a cycle. The real SR entry hook must allow splitting and reuse for + changed token counts, without changing the two streams' values. + """ + from vllm.compilation.backends import split_graph + + @torch.library.custom_op("granite_switch_test::sr_pp_attention", mutates_args=()) + def attention(query: torch.Tensor) -> torch.Tensor: + return query.clone() + + @attention.register_fake + def fake_attention(query): + return torch.empty_like(query) + + interface = SRDecoderInterface() + compilations = [] + + def backend(graph, inputs): + compilations.append(graph) + return split_graph(graph, ["granite_switch_test::sr_pp_attention"])[0].forward + + def stage(base, adapted, indices): + metadata = torch.cat([torch.zeros_like(indices), indices]).unsqueeze(0) + hs = interface.enter_decoder_stack(base, adapted).hs + m = hs.shape[0] // 2 + query = torch.cat([hs[:m], hs[m:]], dim=1) + attended = attention(query) + stacked = torch.cat([attended[:, :4], attended[:, 4:]], dim=0) + remapped = metadata[0, : stacked.shape[0]] + return stacked + remapped.unsqueeze(-1).to(stacked.dtype) + + compiled = torch.compile(stage, backend=backend, dynamic=True, fullgraph=True) + for m in (4, 6): + base = torch.arange(m * 4, dtype=torch.float32).reshape(m, 4) + adapted = base + 17 + indices = torch.arange(1, m + 1) + for tensor in (base, adapted, indices): + torch._dynamo.mark_dynamic(tensor, 0) + expected = torch.cat([base, adapted + indices.unsqueeze(-1)]) + assert torch.equal(compiled(base, adapted, indices), expected) + assert len(compilations) == 1 diff --git a/tests/vllm/test_probe_chunked_prefill.py b/tests/vllm/test_probe_chunked_prefill.py new file mode 100644 index 00000000..53a54f96 --- /dev/null +++ b/tests/vllm/test_probe_chunked_prefill.py @@ -0,0 +1,545 @@ +# SPDX-License-Identifier: Apache-2.0 +"""GPU coverage for the vLLM probe verdict exit: FULL capture/replay, +chunked prefill, intermediate-layer reads, and two slots in one checkpoint. + +``enable_chunked_prefill`` is on by default in serving. The prompt is laid out as +the chat template renders it, with the probe control token as the very last +token, so it always lands in the final chunk. The same prompt served at +``max_num_batched_tokens`` in {unchunked, 256, 101, 48} must emit the same label +token. + +Not asserted: logprob or hidden-state equality across budgets. Chunked and unchunked +serving differ by bf16 reduction order in the attention/KV path (a magnitude that is +hardware- and version-specific), so a numeric band would be flaky. + +Each budget runs in its own subprocess so only one vLLM engine is resident. + +Requires GPU + vLLM. +""" + +import json +import os +import subprocess +import sys + +import pytest +import torch + +pytestmark = pytest.mark.skipif( + not torch.cuda.is_available(), reason="vLLM probe serving test requires a GPU" +) + +WORKER = os.path.join(os.path.dirname(__file__), "_probe_chunked_prefill_worker.py") +_REPO = os.path.dirname(os.path.dirname(os.path.dirname(WORKER))) +BASE_MODEL = os.environ.get("CLS_TEST_BASE", "ibm-granite/granite-4.1-3b") + +# One budget per distinct final-chunk shape: ``None`` disables chunked prefill +# (single pass); 256 leaves a wide final chunk; 101 a narrow one; 48 splits into many +# passes. Budgets differing only in pass count exercise no additional path. +BUDGETS = [None, 256, 101, 48] + + +def _env(): + env = dict(os.environ) + env["VLLM_USE_V1"] = "1" + env["TOKENIZERS_PARALLELISM"] = "false" + env.setdefault("PYTHONPATH", _REPO) + return env + + +def _compose(tmp_path_factory, argv, tag): + """Compose a probe checkpoint in a CPU subprocess, before any CUDA init.""" + outdir = str(tmp_path_factory.mktemp(tag)) + proc = subprocess.run( + [sys.executable, WORKER, *argv], + env={**_env(), "CLS_BASE": BASE_MODEL, "CLS_OUT": outdir}, + capture_output=True, + text=True, + timeout=1800, + ) + lines = [l for l in (proc.stdout or "").splitlines() if l.startswith("{")] + if proc.returncode != 0 or not lines: + pytest.fail( + f"{argv[0]} failed:\nstdout={proc.stdout[-2000:]}\n" + f"stderr={proc.stderr[-2000:]}", + pytrace=False, + ) + meta = json.loads(lines[-1]) + if "error" in meta: + pytest.fail(f"{argv[0]} error: {meta['error'][:2000]}", pytrace=False) + result = meta["result"] + result["outdir"] = outdir + return result + + +def _write_pids(result): + path = os.path.join(result["outdir"], "pids.json") + json.dump(result["prompt_ids"], open(path, "w")) + return path + + +def _run_worker(outdir, argv, env, timeout=1800): + """Run one worker invocation in its own subprocess; return the parsed result.""" + out_path = os.path.join(outdir, "_".join(argv) + ".json") + proc = subprocess.run( + [sys.executable, WORKER, *argv, out_path], + env={**_env(), **env}, + capture_output=True, + text=True, + timeout=timeout, + ) + # Keep complete worker traces; pytest's concise failure message has only + # the tail and can otherwise lose the graph-compiler's originating error. + for stream in ("stdout", "stderr"): + with open(out_path.removesuffix(".json") + f".{stream}.log", "w") as log: + log.write(getattr(proc, stream)) + label = " ".join(argv) + if not os.path.exists(out_path): + pytest.fail( + f"{label}: worker wrote no result\n" + f"stdout={proc.stdout[-1500:]}\nstderr={proc.stderr[-1500:]}", + pytrace=False, + ) + res = json.load(open(out_path)) + if "fatal" in res: + pytest.fail(f"{label}: worker crashed:\n{res['fatal'][:2000]}", pytrace=False) + return res + + +@pytest.fixture(scope="module") +def composed(tmp_path_factory): + """Multi-label probe over a varied prompt.""" + result = _compose(tmp_path_factory, ["compose-multilabel"], "cls_ckpt") + result["pids_path"] = _write_pids(result) + return result + + +def _composed_env(composed, **extra): + return {"CLS_CKPT": composed["ckpt"], "CLS_PIDS": composed["pids_path"], **extra} + + +@pytest.fixture(scope="module") +def composed_sr(tmp_path_factory): + return _compose(tmp_path_factory, ["compose-sr-probe"], "sr_cls_ckpt") + + +@pytest.fixture(scope="module") +def composed_sr_probes(tmp_path_factory): + return _compose(tmp_path_factory, ["compose-sr-probes"], "sr_probes_ckpt") + + +@pytest.mark.parametrize("mode", ["eager", "full"]) +def test_sr_checkpoint_serves_final_probe_slot(composed_sr, mode): + """The identity head scores the served hidden state; transport stays exact.""" + served = _run_worker( + composed_sr["outdir"], + [f"run-sr-{mode}"], + {"CLS_CKPT": composed_sr["ckpt"]}, + ) + sequential = served["sequential"] + assert [len(case["records"]) for case in sequential] == [1, 1] + probe, replay = [case["records"][0] for case in sequential] + assert replay["packed_scores"] == probe["packed_scores"] + for case in [*sequential, served["mixed"]]: + for record in case["records"]: + assert record["slot"] == 2 + assert record["packed_scores"] == record["served_hidden"] + assert record["rewritten_label_logits"] == record["packed_scores"] + for case in sequential: + output, record = case["outputs"][0], case["records"][0] + assert not output["has_non_label_support"] + logprobs, scores = output["label_logprobs"], record["packed_scores"] + assert all(lp is not None for lp in logprobs) + assert output["token_id"] == 100 + scores.index(max(scores)) + for lp, score in zip(logprobs, scores): + assert lp - logprobs[0] == pytest.approx(score - scores[0], abs=1e-5) + assert len(served["mixed"]["records"]) == 2 + assert [o["has_non_label_support"] for o in served["mixed"]["outputs"]] == [ + False, + True, + False, + ] + + +@pytest.fixture(scope="module", params=["eager", "full"]) +def sr_probe_reference(composed_sr_probes, request): + mode = request.param + return mode, _run_worker( + composed_sr_probes["outdir"], + [f"run-sr-pp-{mode}", "1"], + {"CLS_CKPT": composed_sr_probes["ckpt"]}, + ) + + +def _check_sr_layer_probes(result, mode, pp): + """Distinct identity-head rows expose wrong layers, masks and stale replay.""" + failures = [] + assert len(result["sequential"]) == 6 + slot_scores = [] + for slot in (2, 3, 4): + probe, replay = result["sequential"][2 * (slot - 2) : 2 * (slot - 1)] + assert [r["slot"] for r in probe["records"]] == [slot] + assert [r["slot"] for r in replay["records"]] == [slot] + scores = probe["records"][0]["packed_scores"] + slot_scores.append(scores) + assert scores == replay["records"][0]["packed_scores"] + for name, case in (("probe", probe), ("replay", replay)): + record, output = case["records"][0], case["outputs"][0] + failures.extend( + _check_transport( + f"PP{pp}/{mode}/slot{slot}/{name}", + {**record, **output}, + list(range(100, 164)), + ) + ) + assert output["token_id"] == 100 + record["packed_scores"].index( + max(record["packed_scores"]) + ) + assert not output["has_non_label_support"] + assert all(slot_scores[a] != slot_scores[b] for a, b in ((0, 1), (0, 2), (1, 2))) + assert sorted(r["slot"] for r in result["mixed"]["records"]) == [2, 3, 4] + assert [o["has_non_label_support"] for o in result["mixed"]["outputs"]] == [ + False, + True, + False, + False, + ] + assert not failures, "\n".join(failures) + + +def test_sr_probe_reads_each_layer_under_replay(sr_probe_reference): + """One GPU: probes at layer 0, layer 1 and final norm beside an SR slot.""" + mode, served = sr_probe_reference + _check_sr_layer_probes(served, mode, 1) + + +@pytest.mark.skipif(torch.cuda.device_count() < 2, reason="requires two GPUs for PP=2") +def test_sr_layer_probes_across_pipeline_stages(composed_sr_probes, sr_probe_reference): + """Same checkpoint and execution mode: PP=2 must preserve PP=1 score rows.""" + mode, reference = sr_probe_reference + served = _run_worker( + composed_sr_probes["outdir"], + [f"run-sr-pp-{mode}", "2"], + {"CLS_CKPT": composed_sr_probes["ckpt"]}, + ) + _check_sr_layer_probes(served, mode, 2) + # Compare execution modes separately: graph compilation can change bf16 + # rounding, whereas the pipeline handoff itself must preserve score rows. + for case1, case2 in zip( + [*reference["sequential"], reference["mixed"]], + [*served["sequential"], served["mixed"]], + ): + assert case1["outputs"] == case2["outputs"] + assert len(case1["records"]) == len(case2["records"]) + for r1, r2 in zip(case1["records"], case2["records"]): + assert r1["slot"] == r2["slot"] + assert r1["packed_scores"] == r2["packed_scores"] + assert r1["rewritten_label_logits"] == r2["rewritten_label_logits"] + + +def test_full_cudagraph_mode_does_not_crash_and_matches_eager(composed): + """Exercise FULL startup and changed inputs, including mixed requests. + + The original path copied marker IDs to CUDA during capture and crashed. + Compare the selected tokens with the same prompts served eagerly. + """ + label_ids = set(composed["label_token_ids"]) + env = _composed_env( + composed, + CLS_CONTROL_ID=str(composed["control_id"]), + CLS_LABEL_IDS=json.dumps(composed["label_token_ids"]), + ) + full = _run_worker(composed["outdir"], ["run-full-cudagraph"], env) + eager = _run_worker(composed["outdir"], ["run-eager-reference"], env) + + assert len(full["results"]) == len(eager["results"]) == 6 + for i, (f, e) in enumerate(zip(full["results"], eager["results"])): + assert f["is_label"], ( + f"call {i}: FULL-cudagraph verdict {f['token_id']} ({f['text']!r}) " + f"is not a label token {label_ids}; the probe exit did not " + f"fire under real CUDA-graph capture/replay" + ) + assert f["token_id"] == e["token_id"], ( + f"call {i}: FULL-cudagraph emitted {f['token_id']} ({f['text']!r}) " + f"!= eager reference {e['token_id']} ({e['text']!r})" + ) + + # Ordinary generation may legitimately choose a label word. Check that + # non-label tokens remain possible, rather than banning those words. + assert full["plain_result"]["has_non_label_support"] + assert full["plain_result"]["token_id"] == eager["plain_result"]["token_id"] + mixed = full["mixed_results"] + assert len(mixed) == len(eager["mixed_results"]) == 3 + for i, (actual, reference) in enumerate(zip(mixed, eager["mixed_results"])): + assert actual["token_id"] == reference["token_id"], f"mixed row {i}" + assert actual["has_non_label_support"] == (i == 1) + if i != 1: + assert actual["is_label"] + + +def test_probe_token_stable_across_chunk_budgets(composed): + """Emitted verdict token identical across chunk budgets.""" + label_ids = set(composed["label_token_ids"]) + assert len(composed["prompt_ids"]) > min(b for b in BUDGETS if b), ( + "the prompt must be longer than the smallest budget so it chunks" + ) + + results = { + b: _run_worker( + composed["outdir"], + ["run", "none" if b is None else str(b)], + _composed_env(composed), + timeout=1200, + ) + for b in BUDGETS + } + + ref = results[None] + assert ref["token_id"] in label_ids, ( + f"unchunked verdict {ref['token_id']} is not a label token {label_ids}; " + f"the probe exit did not fire" + ) + for b, r in results.items(): + assert r["token_id"] == ref["token_id"], ( + f"budget={b} emitted {r['token_id']} ({r['text']!r}) != unchunked " + f"{ref['token_id']} ({ref['text']!r})" + ) + + +# Score-gap tolerance against the fp32 HF reference; this comparison also +# includes HF/vLLM backbone differences, not only probe arithmetic. +SCORE_DIFF_ATOL = 0.3 + + +def _check_served_scores( + name, served, ref_scores, ref_label_id, label_token_ids, min_label_margin +): + """Print one served verdict beside its CPU reference; return failure messages. + + Reference values are raw head scores; served values are logprobs, so only + label-to-label differences are comparable. The winning-label check runs + only when the reference margin is at least ``min_label_margin`` (``None`` + always runs it); below that a near-tie makes it inconclusive. + """ + ref_sorted = sorted(ref_scores, reverse=True) + margin = ref_sorted[0] - ref_sorted[1] + label_logprobs = served["label_logprobs"] + print(f"\n[{name}] label token ids={label_token_ids}") + print(f" reference raw scores (fp32): {ref_scores}") + print(f" reference label={ref_label_id}, decision margin={margin:.4f}") + print(f" served label logprobs: {label_logprobs}") + print(f" served selected token={served['token_id']} ({served['text']!r})") + + failures = [] + if any(lp is None for lp in label_logprobs): + failures.append( + f"{name}: not every label token appeared in the top-" + f"{len(label_token_ids)} logprobs ({label_logprobs})" + ) + else: + for j in range(1, len(label_token_ids)): + served_diff = label_logprobs[j] - label_logprobs[0] + ref_diff = ref_scores[j] - ref_scores[0] + ok = served_diff == pytest.approx(ref_diff, abs=SCORE_DIFF_ATOL) + print( + f" label[{j}]-label[0]: served logprob diff={served_diff:.4f}, " + f"reference score diff={ref_diff:.4f} -> {'ok' if ok else 'MISMATCH'}" + ) + if not ok: + failures.append( + f"{name}: served logprob diff label[{j}]-label[0] = " + f"{served_diff:.4f} != reference score diff {ref_diff:.4f} " + f"+/- {SCORE_DIFF_ATOL}" + ) + + if min_label_margin is not None and margin < min_label_margin: + print( + f" winning-label check INCONCLUSIVE: reference margin " + f"{margin:.4f} < {min_label_margin} (near-tie)" + ) + elif served["token_id"] != ref_label_id: + failures.append( + f"{name}: served token {served['token_id']} ({served['text']!r}) " + f"!= reference label {ref_label_id} (margin={margin:.4f})" + ) + return failures + + +# Allowance for fp32 log-softmax subtraction only (fp32 spacing is ~6e-5 at +# |logit| < 512); not a numerical-equivalence tolerance. +LOGPROB_DIFF_ATOL = 1e-3 + + +def _check_transport(name, served, label_token_ids): + """Packed scores -> rewritten label logits -> returned logprobs.""" + packed = served["packed_scores"] + rewritten = served["rewritten_label_logits"] + label_logprobs = served["label_logprobs"] + print(f"\n[{name}] transport") + print(f" packed head scores (raw): {packed}") + print(f" rewritten label logits (raw): {rewritten}") + print(f" served label logprobs: {label_logprobs}") + + failures = [] + if rewritten != packed: + failures.append( + f"{name}: rewritten label logits {rewritten} != packed scores {packed}" + ) + if any(lp is None for lp in label_logprobs): + failures.append( + f"{name}: not every label token appeared in the top-" + f"{len(label_token_ids)} logprobs ({label_logprobs})" + ) + return failures + for j in range(1, len(label_token_ids)): + lp_diff = label_logprobs[j] - label_logprobs[0] + logit_diff = rewritten[j] - rewritten[0] + if lp_diff != pytest.approx(logit_diff, abs=LOGPROB_DIFF_ATOL): + failures.append( + f"{name}: logprob diff label[{j}]-label[0] = {lp_diff} != " + f"rewritten logit diff {logit_diff} +/- {LOGPROB_DIFF_ATOL}" + ) + return failures + + +def _check_final_layer_arithmetic(name, served): + """Packed scores against the loaded head applied to the actual activation.""" + weight = torch.tensor(served["loaded_weight"]) + bias = torch.tensor(served["loaded_bias"]) + hidden = torch.tensor(served["served_hidden"]) + recomputed = weight @ hidden + bias + rounded = recomputed.to(torch.bfloat16).float().tolist() + print(f"\n[{name}] arithmetic on the served activation") + print(f" loaded head in fp32: {recomputed.tolist()}") + print(f" ... rounded to bf16: {rounded}") + print(f" packed head scores (raw): {served['packed_scores']}") + if rounded != served["packed_scores"]: + return [ + f"{name}: packed scores {served['packed_scores']} != loaded head " + f"on served activation, rounded to bf16, {rounded}" + ] + return [] + + +def _print_final_layer_activation(name, served, reference_hidden, reference_scores): + """Record the HF/vLLM final-activation difference; no acceptance threshold.""" + weight = torch.tensor(served["loaded_weight"]) + bias = torch.tensor(served["loaded_bias"]) + hf = torch.tensor(reference_hidden, dtype=torch.float32) + vllm = torch.tensor(served["served_hidden"]) + + def gap(scores): + return float(scores[1] - scores[0]) + + hf_gap = gap(weight @ hf + bias) + vllm_gap = gap(weight @ vllm + bias) + print(f"\n[{name}] HF/vLLM final activation (recorded, not asserted)") + print(f" HF / vLLM norm: {hf.norm():.4f} / {vllm.norm():.4f}") + print( + f" relative L2 / max abs diff: " + f"{(vllm - hf).norm() / hf.norm():.6f} / {(vllm - hf).abs().max():.4f}" + ) + print( + f" reference score gap (fp32 head, HF activation): {gap(reference_scores):.4f}" + ) + print(f" loaded bf16 head, HF activation: {hf_gap:.4f}") + print(f" loaded bf16 head, vLLM activation: {vllm_gap:.4f}") + print(f" activation contribution: {vllm_gap - hf_gap:.4f}") + + +def test_probe_reads_an_intermediate_layer_correctly(tmp_path_factory): + """A layer-20 slot under eager and FULL, against an fp32 HF layer-20 reference. + + The fixed seed-0 head's reference margin (about 0.07) is below the score + tolerance, so the winning label is inconclusive and this does not identify + layer 20 against nearby layers; it shows the path serves in both modes with + a score gap within tolerance of the layer-20 reference. + """ + read_layer = 20 # a middle layer of the 40-layer base + result = _compose( + tmp_path_factory, ["compose-at-layer", str(read_layer)], "cls_layer_ckpt" + ) + label_token_ids = result["label_token_ids"] + env = { + "CLS_CKPT": result["ckpt"], + "CLS_PIDS": _write_pids(result), + "CLS_LABEL_IDS": json.dumps(label_token_ids), + } + # Serve both modes before asserting so one failure cannot hide the other. + served_by_mode = { + mode: _run_worker(result["outdir"], [mode], env) + for mode in ("run-at-layer", "run-at-layer-full-cudagraph") + } + failures = [] + for mode, served in served_by_mode.items(): + failures += _check_served_scores( + f"{mode} (layer={read_layer})", + served, + result["ground_truth_scores"], + result["ground_truth_label_id"], + label_token_ids, + min_label_margin=1.0, + ) + assert not failures, "\n".join(failures) + + +def test_two_probe_slots_replay_correctly_in_one_captured_shape(tmp_path_factory): + """Serve a final-layer and a layer-15 slot under eager and FULL. + + Probe arithmetic and transport are checked against the head's + actual input, recorded in-process at ``compute_logits``: + + - slot 1 (final layer): packed scores equal the loaded bf16 head applied + in fp32 to the served final activation, rounded to bf16 (exact); + - both slots: rewritten label logits equal packed scores (exact), and + label logprob differences equal logit differences up to fp32 + log-softmax rounding. + + Slot 2's input (layer 15) is not observable at ``compute_logits``, so its + source-selection coverage remains the comparison against the layer-15 HF + reference. Slot 1's HF/vLLM activation difference is printed, with no + acceptance threshold. The test does not prove which captured graph replayed. + """ + read_layer = 15 + result = _compose( + tmp_path_factory, ["compose-two-slot", str(read_layer)], "cls_two_slot_ckpt" + ) + label_token_ids = result["label_token_ids"] + env = { + "CLS_CKPT": result["ckpt"], + "CLS_PIDS_1": json.dumps(result["slot1_prompt_ids"]), + "CLS_PIDS_2": json.dumps(result["slot2_prompt_ids"]), + "CLS_LABEL_IDS": json.dumps(label_token_ids), + } + # Serve both modes before asserting so one failure cannot hide another. + served_by_mode = { + mode: _run_worker(result["outdir"], [mode], env) + for mode in ("run-two-slot-eager", "run-two-slot-full-cudagraph") + } + failures = [] + for mode, served in served_by_mode.items(): + slot1, slot2 = served["slot1"], served["slot2"] + failures += _check_transport(f"{mode} slot1", slot1, label_token_ids) + failures += _check_final_layer_arithmetic(f"{mode} slot1", slot1) + _print_final_layer_activation( + f"{mode} slot1", + slot1, + result["slot1_reference_hidden"], + result["slot1_ground_truth_scores"], + ) + if slot1["token_id"] != result["slot1_ground_truth_label_id"]: + failures.append( + f"{mode} slot1: served token {slot1['token_id']} " + f"({slot1['text']!r}) != reference label " + f"{result['slot1_ground_truth_label_id']}" + ) + failures += _check_transport(f"{mode} slot2", slot2, label_token_ids) + failures += _check_served_scores( + f"{mode} slot2 (read_layer={read_layer})", + slot2, + result["slot2_ground_truth_scores"], + result["slot2_ground_truth_label_id"], + label_token_ids, + min_label_margin=None, + ) + assert not failures, "\n".join(failures) diff --git a/tutorials/README.md b/tutorials/README.md index 62d120c7..f745b951 100644 --- a/tutorials/README.md +++ b/tutorials/README.md @@ -26,6 +26,7 @@ Step-by-step walkthroughs covering adapter function invocation, pipeline constru |-------|-------------| | [Using Mellea with Granite Switch](guides/mellea_with_granite_switch.md) | Connect Mellea to a Granite Switch model | | [Bring Your Own Adapter](guides/build_your_own_adapter.md) | Train, compose, and use custom adapters | +| [Activation Probe Slots](guides/activation_probe_slots.md) | Compose and serve an activation probe head that emits a label word | | [Compare Inference Throughput](guides/compare_inference_throughput.md) | Compare LoRA vs aLoRA based models in an inference race setup | diff --git a/tutorials/guides/activation_probe_slots.md b/tutorials/guides/activation_probe_slots.md new file mode 100644 index 00000000..3928a1d6 --- /dev/null +++ b/tutorials/guides/activation_probe_slots.md @@ -0,0 +1,347 @@ +# Activation Probe Slots + +This guide explains how to compose a Granite Switch model with an **activation probe slot** — a +lightweight linear head over a hidden state, fired by the same control token as a LoRA adapter — +and how to serve it so the model emits a label *word* (`safe`, `unsafe`, …) for a detection +request. + +## Overview + +An **activation probe slot** (probe for short) holds a small linear head instead of a LoRA. It +uses the same control-token machinery (one `<|name|>` token, one adapter index) and reads the +configured layer's activation to produce per-label logits. Probe and generation-adapter slots can +coexist in one checkpoint; a request selects one slot. + +At inference the verdict does not come back as a raw score vector. Each label word is resolved to a +single token id at compose time, and the model rewrites the request's final logit row so that the +winning label's token id wins the argmax. The endpoint therefore emits the literal label word — the +same shape as any other generation, so it works through the standard vLLM OpenAI server with no +custom client. + +``` +1. Train a head ──> 2. Manifest (kind/labels) ──> 3. Compose ──> 4. Serve + detect + (weight/bias) (io.yaml or YAML list) (granite-switch) (vLLM OpenAI API) +``` + +Multiple probe slots may coexist, and **each slot may have its own number of labels** (e.g. a +2-label `safe`/`unsafe` slot next to a 3-label `good`/`bad`/`neutral` slot). + +## Prerequisites + +- Granite base model (e.g., `ibm-granite/granite-4.1-3b`) +- A trained probe head (see Step 1) +- `granite-switch[vllm,compose]` installed + +See [PREREQUISITES.md](../PREREQUISITES.md) for detailed setup. + +## Step 1: Train (or stage) a probe head + +A probe slot is **not** a LoRA, so its directory holds a different artifact. There is no +`adapter_model.safetensors`; instead the directory must contain a **`probe_head.safetensors`** +with exactly two tensors: + +| Key | Shape | Meaning | +|-----|-------|---------| +| `weight` | `[num_labels, hidden_size]` | the linear head | +| `bias` | `[num_labels]` | per-label bias | + +`num_labels` is *this slot's own* label count, and `hidden_size` must match the base model's hidden +size. That is the whole artifact — a single linear layer that maps a base-model hidden state to one +logit per label. + +Train the head however you like (a frozen base model plus a trainable linear layer over the last +hidden state is the usual recipe). At serving time the head reads the hidden state of the control +token, which the model embeds as the base's role-open marker: `<|start_of_role|>` on Granite +4.0/4.1, `<|im_start|>` on 4.2. So train on the chat-rendered prompt with no generation prompt, +with that marker appended, and read the hidden state there. + +By default that is the **final post-norm** hidden state (HF's `last_hidden_state`). A slot can +instead read an intermediate decoder layer (see `layer` in Step 2); train it on that layer's output. +With HF's `output_hidden_states=True` that is `hidden_states[layer + 1]`, except for the last +decoder layer, whose entry HF replaces with the normalized output; read that one with a forward hook +on `model.model.layers[layer]`. The head must be trained on the same representation it will read at +serving time. + +Here is a helper that writes a **synthetic** head. + +```python +from pathlib import Path +import torch +from safetensors.torch import save_file + +HIDDEN = 2560 # granite-4.1-3b hidden size + +def make_head(name, labels): + """Write a synthetic probe head for `name` under the library layout. + + A real head would carry trained weights; this one is zero-weight with a bias + that forces row 0 to win, so the emitted label is deterministic. + """ + head_dir = Path(f"./{name}-head/{name}/granite-4.1-3b/lora") + head_dir.mkdir(parents=True, exist_ok=True) + weight = torch.zeros(len(labels), HIDDEN) + bias = torch.full((len(labels),), -10.0) + bias[0] = 10.0 # row 0 (labels[0]) always wins + save_file({"weight": weight, "bias": bias}, str(head_dir / "probe_head.safetensors")) + # Every composed slot (LoRA or probe) also needs an io.yaml — compose copies + # it verbatim into the checkpoint's io_configs/. A two-line stub is enough here. + (head_dir / "io.yaml").write_text(f"name: {name}\nmodel: ~\n") + return str(head_dir) +``` + +> The directory layout `////` mirrors the library layout the +> composer expects for a local adapter. The trailing `lora`/`alora` segment selects **token +> placement** (LoRA vs activated-LoRA), independent of the fact that this slot is a probe. +> +> The `io.yaml` is required for **every** slot: compose copies each slot's `io.yaml` into the +> output's `io_configs//`, so a slot without one fails the copy step. Real adapters ship a +> full `io.yaml` (instruction, response schema, sampling parameters); a synthetic slot only needs +> the two-line stub above. + +### Label words must be single-token + +Each label word is resolved to a **single token id** at compose time — the verdict is written at one +token id per label. If a label word does not encode to a single token in the base model's tokenizer, +compose fails loud and lists the offenders, e.g.: + +``` +1 probe label word(s) do not encode to a single token, ... + 'Ambiguous' → 3 tokens: ['Amb', 'igu', 'ous'] +``` + +Pick a single-token synonym for each label. Short lowercase words (`safe`, `unsafe`, `yes`, `no`, +`good`, `bad`, `neutral`) are single-token in Granite BPE; capitalized or rarer words often are not. +If no natural single-token word fits, the tokenizer's reserved `<|unused_N|>` tokens are a fallback. + +## Step 2: Declare the slot in a manifest + +Probe slots are declared with two extra keys — `kind: probe` and `labels:` — on top of the +normal adapter fields. The simplest way to pass one or more of them to compose is a **YAML manifest** +that maps each slot name to its path and metadata: + +```yaml +# ./adapters.yaml (one probe slot) +safety: + path: ./safety-head/safety/granite-4.1-3b/lora + type: lora # token placement (lora vs alora); independent of `kind` + kind: probe # marks this slot a probe head (default "lora" when omitted) + labels: [safe, unsafe] # label words, resolved to single token ids at compose +``` + +`type` and `kind` are independent axes: `type` is the LoRA *technology* (drives control-token +placement), while `kind` is whether the slot is a real LoRA or a probe head. A plain LoRA entry +simply omits `kind`/`labels`. + +A probe entry may also set an optional `layer`, the zero-based decoder layer whose output the +head reads (the residual stream after that layer, before the final norm). Omit it to read the final +post-norm hidden state: + +```yaml +safety_mid: + path: ./safety-mid-head/safety_mid/granite-4.1-3b/lora + type: lora + kind: probe + labels: [safe, unsafe] + layer: 20 # 0..num_decoder_layers - 1; omit for final +``` + +Probe slots can also be composed alongside Shadow Residual (SR) adapters; a probe +request reads that layer's base stream. Decoder layer numbers exclude the switch's KV-cache slots. +Pipeline parallelism preserves the same numbering across stages. + +You can mix LoRA and probe entries in one manifest, and give each probe its own `labels` +list of any length: + +```yaml +# ./adapters.yaml — one real LoRA + two synthetic probe slots (2 and 3 labels) +answerability: + path: /path/to/answerability/granite-4.1-3b/lora # a real LoRA (see below) + type: lora +safety: + path: ./safety-head/safety/granite-4.1-3b/lora + type: lora + kind: probe + labels: [safe, unsafe] +faithfulness: + path: ./faithfulness-head/faithfulness/granite-4.1-3b/lora + type: lora + kind: probe + labels: [good, bad, neutral] # single-token words — see the label rule above +``` + +> **Slot ordering is automatic.** Compose stably reorders slots so all LoRA slots come first and +> probe slots last, then derives every downstream artifact (control tokens, chat template, +> adapter indices) from that order. You do not need to order the manifest yourself; probes +> ending up as the contiguous suffix is what lets the LoRA weight transfer stay positional and the +> probe head bank pad uniformly. + +The script below download a rag lora and constructed two synthetic probes for testing. + +```python +from pathlib import Path +import yaml +from huggingface_hub import snapshot_download +# make_head is the helper defined in Step 1. + +# 1. A real LoRA slot: download just the answerability adapter for granite-4.1-3b. +snap = snapshot_download( + repo_id="ibm-granite/granite-lib-rag-r1.0", + allow_patterns=["answerability/granite-4.1-3b/lora/**"], +) +lora_dir = str(Path(snap) / "answerability" / "granite-4.1-3b" / "lora") + +# 2. Two synthetic probe slots with different label counts (make_head from Step 1). +safety_dir = make_head("safety", ["safe", "unsafe"]) +faith_dir = make_head("faithfulness", ["good", "bad", "neutral"]) + +# 3. Emit the manifest pairing the real LoRA with the two synthetic probes. +manifest = { + "answerability": {"path": lora_dir, "type": "lora"}, + "safety": {"path": safety_dir, "type": "lora", "kind": "probe", + "labels": ["safe", "unsafe"]}, + "faithfulness": {"path": faith_dir, "type": "lora", "kind": "probe", + "labels": ["good", "bad", "neutral"]}, +} +Path("./adapters.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False)) +print("wrote ./adapters.yaml") +``` + + +## Step 3: Compose + +Pass the manifest to the compose CLI exactly like any other adapter source — a `.yaml`/`.yml` path is +auto-detected as a manifest: + +```bash +python -m granite_switch.composer.compose_granite_switch \ + --base-model ibm-granite/granite-4.1-3b \ + --adapters ./adapters.yaml \ + --output ./granite-switch-detect +``` + +Compose will: + +1. Load the base model and discover the slots from the manifest. +2. Resolve each probe's label words to single token ids (failing loud on multi-token words). +3. Read each probe's `probe_head.safetensors` into the head bank at its slot. +4. Stack everything into one checkpoint under `--output`. + + +``` +Probe slots: + safety → labels ['safe', 'unsafe'] → token ids [19193, 39257] @ final norm + faithfulness → labels ['good', 'bad', 'neutral'] → token ids [19045, 14176, 60668] @ final norm +``` + +A slot with `layer: 20` prints `@ layer 20` instead. + +The resulting `config.json` carries the per-slot probe metadata as plain lists/ints, so it +round-trips through `save_pretrained`/`from_pretrained` with no custom code, and both the HF and +vLLM backends read it identically. Three fields are the stored contract: + +| Field | Meaning | +|-------|---------| +| `adapter_kinds` | per-slot `"lora"` / `"probe"`, one entry per adapter — what marks a slot a probe | +| `probe_label_token_ids` | per-slot list of label token ids, `null` on LoRA slots | +| `probe_read_layers` | per-slot zero-based decoder layer a probe reads; `null` for the final post-norm state and on LoRA slots | + +The rest are **derived** from those in `GraniteSwitchConfig.__init__`, so they appear on the config +object but are not independent inputs: + +| Derived field | From | +|---------------|------| +| `probe_num_labels_per_slot` | `len()` of each slot's label token ids (`0` on LoRA slots) | +| `max_probe_labels` | `max()` of the above — the padded width the head bank is built with | +| `probe_control_token_ids` | the control-token ids of the probe slots; both backends match these in `input_ids` to locate a verdict's read point | + +Note that the label *words* are not stored — only their token ids. Compose resolves and prints the +words, but `config.json` keeps ids, so recovering the words means decoding them with the +checkpoint's tokenizer. + +## Step 4: Serve and send a detection request + +Serve the composed checkpoint with the standard vLLM OpenAI-compatible server: + +```bash +python -m vllm.entrypoints.openai.api_server \ + --model ./granite-switch-detect \ + --port 8000 +``` + +Fire a probe by placing its control token at the very end of the prompt, after the last turn's +close — one token per slot, `<|safety|>` for the `safety` slot, `<|faithfulness|>` for the other. The +verdict is read at the control token, so leave out the generation prompt. The completion is the +emitted label word: + +```bash +curl http://localhost:8000/v1/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "./granite-switch-detect", + "prompt": "<|start_of_role|>user<|end_of_role|>Is this text safe? The weather is nice today.<|end_of_text|>\n<|safety|>", + "max_tokens": 1 + }' +``` + +```json +{"choices": [{"text": "safe", ...}], ...} +``` + +Only the probe request's final logit row is rewritten; every other (non-probe) request runs +untouched with its full vocabulary. If you request logprobs, you will see the label token ids +carrying the verdict scores and the rest of the vocabulary pinned at the blanking floor — exactly the +two labels for a 2-label slot, three for a 3-label slot. + +### Firing via the chat template (optional) + +Instead of placing `<|safety|>` by hand, you can let the composed model's chat template place it for +you by passing `adapter_name` — a probe slot is activated exactly like any other adapter. The +template appends the control token after the last turn: + +```bash +curl http://localhost:8000/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "./granite-switch-detect", + "messages": [{"role": "user", "content": "Is this text safe? The weather is nice today."}], + "max_tokens": 1, + "chat_template_kwargs": {"adapter_name": "safety"} + }' +``` + +### Reading logprobs (optional) + +To see the raw verdict rather than just the winning word, ask for a couple of logprobs: + +```bash +curl http://localhost:8000/v1/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "./granite-switch-detect", + "prompt": "<|start_of_role|>user<|end_of_role|>Is this text safe? ...<|end_of_text|>\n<|safety|>", + "max_tokens": 1, + "logprobs": 5 + }' +``` + +Only the label ids (`safe`, `unsafe`) will show finite logprobs; all other tokens sit at the blanking +floor. That is the probe head's per-label verdict surfaced through the standard logprobs +channel. + +## How it works (brief) + +- **Routing.** A probe control token selects its slot like any adapter; its position is routed + out of the LoRA stream and the head reads the activation there. +- **Padded head bank.** All heads share one tensor padded to `max_probe_labels` (like the LoRA + bank pads to `max_lora_rank`), keeping the vLLM head one compile-safe operation. +- **Scoring.** Intermediate-layer slots are scored as that layer runs, final-layer slots after the + final norm. Scores and slot id travel with the hidden states to the logits step, so this also + works under CUDA-graph capture. Under pipeline parallelism the first stage finds the markers and + each stage passes scores and marker indices on. +- **Verdict exit.** The marker's logit row is blanked and the head's scores are written onto that + slot's label token ids, so the sampler emits the label word. + +## Related + +- [Bring Your Own Adapter](build_your_own_adapter.md) — train and compose a LoRA adapter. +- [Composer README](../../src/granite_switch/composer/README.md) — full compose CLI reference.