Skip to content

Add GraniteSWA (granite_swa) with a shared SinkAttention component - #590

Open
justinchuby wants to merge 5 commits into
mainfrom
justinchuby-add-granite-swa
Open

Add GraniteSWA (granite_swa) with a shared SinkAttention component#590
justinchuby wants to merge 5 commits into
mainfrom
justinchuby-add-granite-swa

Conversation

@justinchuby

@justinchuby justinchuby commented Aug 24, 2026

Copy link
Copy Markdown
Member

Adds Mobius support for the HuggingFace architecture granite_swa (GraniteSWAForCausalLM), validated against ibm-granite/granite-swash-2b pinned at revision af1e3227100b61088eead48389ab5409b5d0e39c.

What GraniteSWA is

Granite plus three changes, all implemented against the upstream modular_granite_swa.py (transformers 5.15.0):

  1. Mixed attention spansconfig.layer_types selects, per layer, full causal attention or a sliding_window-wide local window (128 for this checkpoint; every fourth layer is full attention).
  2. Learnable per-head attention sinks — one extra logit per head in the softmax denominator, letting a head shed probability mass instead of being forced to distribute it over real tokens.
  3. Per-layer RoPE baseconfig.layer_rope_theta[i], where 0 means NoPE for that layer.

Granite's four scaling multipliers (embedding_multiplier, attention_multiplier, logits_scaling, residual_multiplier) still apply and are wired through the existing create_decoder_layer path.

Implementation

Shared SinkAttention component, reused from GPT-OSS

Upstream's eager kernel computes an ordinary softmax and rescales the output by sigmoid(logsumexp(scores) - sink). Writing Z = sum(exp(scores)), that factor is Z / (Z + exp(sink)) — which is exactly the mass removed by keeping the sink as one extra softmax column and then dropping it. That column form is already how GPT-OSS expresses sinks in Mobius, so rather than writing a second implementation, the GPT-OSS one is promoted into a reusable SinkAttention component (subclassing Attention, so projections, Q/K-norm and RoPE setup are shared) and _GptOssAttention becomes a thin subclass.

DecoderLayer / create_decoder_layer gained an attention_class keyword so a model can swap the attention module without duplicating the layer.

Because the sink lives inside the softmax, no fused Attention or GroupQueryAttention op can be used — the score matrix is built explicitly, and the attention bias therefore carries causality, the sliding window and padding (is_causal is not available). This is asserted by a test.

The two sink models have different precision contracts

This is a genuine divergence upstream, so it is encoded in the type rather than in a constructor flag:

  • GPT-OSS softmaxes in the compute dtype — F.softmax(combined_logits, dim=-1, dtype=combined_logits.dtype). It uses SinkAttention (upcast_sink_softmax = False).
  • GraniteSWA forces float32 — (lse - sinks).to(torch.float32).sigmoid() and F.softmax(attn_weights, dim=-1, dtype=torch.float32). It uses Float32SinkAttention (upcast_sink_softmax = True). In the extra-column formulation the logsumexp/sigmoid pair and the softmax are the same reduction, so upcasting that one softmax reproduces both upstream upcasts at once.

Keeping this in the type means a model cannot silently acquire or lose the upcast by being constructed differently. For float32 builds the two classes emit an identical, Cast-free graph.

GPT-OSS is graph-neutral at every dtype. Verified by building the tiny gpt_oss graph at 059bf5f1 and at this branch: identical node op_type sequence and identical sorted initializer names for float32, float16 and bfloat16 (Cast counts 8 / 10 / 10 on both sides). GPT-OSS synthetic parity also still passes. Regression tests now assert both halves of the contract — GPT-OSS has no Cast on the sink-softmax path at any dtype, GraniteSWA has the upcast at f16/bf16 and not at f32.

Config

GraniteSwaConfig.from_transformers reproduces HF __post_init__ semantics — layer_types defaulting to full attention on every fourth layer, layer_rope_theta defaulting to the global rope_theta, 0 preserved as NoPE — and additionally re-asserts RoPE for the raw config.json path: the checkpoint carries only a flat rope_theta: 10000 with no rope_parameters, which the generic extractor otherwise reads as "no RoPE signal at all". Without this, a raw-JSON build would silently produce an all-NoPE model.

config_class is declared on both the registry entry and the model class, so the model can never silently fall back to CausalLMConfig and drop layer_rope_theta.

Revision and eager-attention pinning

load_torch_model gained revision and attn_implementation parameters, and scripts/generate_golden.py now forwards both. The sink is not SDPA-expressible — upstream sets GraniteSWAPreTrainedModel._supports_sdpa = False for exactly this reason — so the reference is pinned to eager and the resolved implementation is asserted, rather than relying on transformers' default resolution.

Evidence

All numbers below are measured, not estimated.

L1

  • pytest tests/build_graph_test.py -k granite_swa — 4 passed
  • pytest tests/weight_alignment_test.py -k granite_swa — 1 passed
  • pytest src/mobius/models/granite_swa_test.py — 22 passed

The unit tests include an executed check of the sink algebra: a one-layer sink attention graph is run in ORT and compared against upstream's sigmoid(logsumexp - sink) formula at rtol/atol 1e-4. Others assert the pinned config.json extraction, one rotary module per distinct non-zero theta (6 RotaryEmbedding nodes for 3 RoPE layers + 1 NoPE layer), tied embeddings collapsing to a single model.embed_tokens.weight initializer, model.layers.N.self_attn.sinks matching HF naming, that no fused attention op is emitted, and the per-dtype precision contract.

L2

  • pytest tests/arch_validation_test.py -m arch_validation -k granite_swa — 3 passed (full-size real HF config, graph build + shape consistency)
  • pytest tests/yaml_schema_test.py — all passed

L3

  • Synthetic parity: pytest tests/synthetic_parity_test.py -k granite_swa — passed at the default atol 1e-3, no tolerance override was needed. The HF reference is forced to eager and asserted. The tiny config deliberately carries 4 layers with mixed spans, two distinct non-zero thetas and one NoPE layer.
  • Real-weight CPU fp32: test_granite_swa_prefill_logits_match and test_granite_swa_decode_step_logits_match both pass at rtol/atol 1e-3 against revision-pinned HF eager. Both use a 169-token prompt against a 128-token window on purpose — below the window a sliding layer and a full-attention layer see the same keys, so a window bug would pass unnoticed.

L4 / L5

Goldens generated by an independently invoked HuggingFace pipeline (transformers 5.15.0, float32, eager, revision pinned) and committed.

  • L4 prefill: top-1 330, top-2 2355
  • L5 greedy, 20/20 tokens: ' "The Last of the Mohicans" by James Fenimore Cooper. It is a poem about the'
  • pytest tests/e2e_golden_test.py -m "golden or generation" -k granite_swash — 2 passed (505 s, CPU fp32)

Multi-dtype and multi-EP

CUDA EP creation was verified via get_providers() and the CUDA-specific memcpy log — not assumed. Reference is HF fp32 CPU, eager, 169-token prompt, full-logit comparison including a native cached decode step:

run stage max abs diff mean abs diff cosine argmax top-10 Jaccard
CUDA f16 vs HF fp32 prefill 0.0367 0.00391 0.9999890 match 1.0
CUDA f16 vs HF fp32 decode 0.0196 0.00353 0.9999983 match 1.0
CPU f16 vs HF fp32 prefill 0.0241 0.00244 0.9999899 match 1.0
CPU f16 vs HF fp32 decode 0.0125 0.00196 0.9999990 match 1.0
CPU vs CUDA, identical f16 prefill 0.0391 0.00427 0.9999840 match 1.0
CPU vs CUDA, identical f16 decode 0.0234 0.00414 0.9999976 match 1.0

Generation, 20-token greedy, compared against the committed float32 L5 golden token sequence:

  • CUDA f16 — exact 20/20 match
  • CPU f16 — exact 20/20 match
  • CUDA bf16 — exact 20/20 match (logits dtype confirmed bfloat16 end to end)
  • CPU f16 token sequence == CUDA f16 token sequence

So fp32, fp16 and bf16, on both CPU and CUDA, agree semantically; no dtype is degraded or silently downgraded.

CLI build and metadata

mobius build --model ibm-granite/granite-swash-2b --revision af1e3227... --dtype f16 --runtime ort-genai completes; genai_config.json faithfully reflects the graph (type: granite_swa, head_size: 128, 20 / 4 heads, 24 layers, correct input/output names and cache templates). The bf16 build completes as well. Docs are auto-generated from the class metadata and pick the model up (docs/models/granite_swa.md, Text Generation).

Olive INT4 quantization

OnnxKQuantQuantization (4-bit, block size 32) runs to completion on the f16 export in 1020 s on CPU, and the result loads and generates:

  • 168 MatMulNBits nodes = 24 layers × 7 projections — every projection was quantized.
  • 49 MatMul remain: the 48 attention score/context matmuls (activation × activation, correctly left alone) plus the LM head.
  • 4.29 GB → 1.61 GB, a 2.67× compression.
  • Generates coherent text: ' "The Last of the Mohicans" by James Fenimore Cooper. I hope you enjoy it.' — the first 14 of 20 tokens match the fp32 golden exactly before diverging into an equally coherent continuation, which is the expected behaviour for INT4.

Two workarounds were needed on this machine and are worth recording: olive run --config aborts because Olive 0.13 auto-registers every provider DLL bundled in the ORT GPU wheel and nvinfer_10.dll is absent, so the documented suppression of olive.systems.local.maybe_register_ep_libraries around olive.workflows.run is required; and cupy is not installed here, so the pass ran on CPU.

Notes and caveats (no waiver needed, but worth recording)

Sliding-cache representation differs from HuggingFace, by design. HF's DynamicSlidingWindowLayer physically drops keys once they leave the window — a 20-token prefill with sliding_window=8 returns 7 cached keys for sliding layers and 20 for full-attention layers. The exported graph keeps the full cache and masks the same keys out through the sliding bias. Both are correct for every reachable query (the window only moves forward, so dropped keys can never be attended to again), but the two caches cannot be cross-fed. This is why the decode parity test runs each side against its own cache; the test asserts the trimming difference explicitly so the reason is not lost. Consequence: the exported KV cache grows with sequence length rather than being bounded by the window, which is the existing Mobius behaviour for sliding-window models.

414 CUDA Memcpy nodes (performance, not correctness). Attention sinks force a manual score matrix, so sink attention builds per-layer dynamic shape tensors (Shape/Concat/Expand) that ORT pins to CPU — roughly 17 per layer across 24 layers, on both the f16 and bf16 graphs. This is inherent to any sink model (GPT-OSS has the same shape) and is not introduced by this change.

No fp32 GPU golden run. granite-swash-2b is 2.1B parameters, so an fp32 CUDA session needs ~8.6 GB against ~7.3 GB free on the available A1000 — it OOMs. The f16 and bf16 CUDA runs above target the same committed fp32 golden token sequence and match it exactly, so the GPU evidence is equivalent rather than missing.

ORT GenAI 0.15.2 rejects the model type string, but runs the graph. Loading the exported package fails with Unsupported model_type in config.json: granite_swa. A diagnostic probe (a scratch copy of genai_config.json with the type string changed, not a change to the exporter) loads and generates successfully, reproducing the exact golden continuation:

'Here is my poem: "The Last of the Mohicans" by James Fenimore Cooper. It is a poem about the'

So this is purely a gap in that runtime's model-type registry, not a topology or export problem. Per the quality checklist, downstream runtime acceptance is not an export gate, and deliberately no compatibility alias was added to the exporter — claiming granite_swa is granite would be a false alias that hides the sliding window, the sinks and the per-layer theta.

A CUDA gotcha on this machine, for anyone reproducing the numbers. A raw ort.InferenceSession(..., providers=["CUDAExecutionProvider"]) silently falls back to CPU here unless torch is imported first, because cublasLt64_12.dll only reaches the DLL search path via torch's bundled CUDA libraries. All CUDA numbers above were re-verified with get_providers() after hitting exactly this.

Pre-existing failures in this environment (not caused by this change)

Verified by git stash on the base commit 059bf5f1. This machine has ORT 1.26.0 while pyproject.toml asks for >=1.28.0, which explains the softcap ones:

  • src/mobius/rewrite_rules/_decompose_attention_test.py::TestDecomposeAttentionParity::test_matches_fused_op[softcap] and [decode_softcap_past]
  • src/mobius/models/deepseek_v4_test.py::TestDeepSeekV4QMoEExport::... (2 params)
  • src/mobius/integrations/gguf/_builder_test.py::TestBuildQuantizedGguf::test_gatherblockquantized_zero_point_dequantizes_q4_0
  • scripts/generate_golden_test.py::TestDryRun (2) — Windows cp1252 console UnicodeEncodeError on the character; passes with PYTHONIOENCODING=utf-8

Full non-GPU sweep on this branch: 5084 passed, with only the failures listed above (plus one qwen_image_test CUDA diffusion test that contended for the GPU with another session).

justinchuby and others added 3 commits August 24, 2026 05:45
…onent

GraniteSWA is Granite plus three architectural changes, all of which this
change implements faithfully against upstream `GraniteSWAForCausalLM`:

* Mixed attention spans driven by `config.layer_types` (full vs a
  `sliding_window`-wide local window), dispatched per layer from two
  precomputed float additive biases.
* Learnable per-head attention sinks. Upstream's eager kernel rescales the
  attention output by `sigmoid(logsumexp(scores) - sink)`; that is
  algebraically the same as keeping the sink as one extra column in the
  softmax denominator, which is how GPT-OSS already expresses it in mobius.
  The GPT-OSS implementation is therefore promoted to a reusable
  `SinkAttention` component (subclassing `Attention` so the projection,
  Q/K-norm and RoPE setup is shared) and GPT-OSS now uses it. A unit test
  runs the exported graph in ORT against the upstream sigmoid-LSE formula.
* Per-layer RoPE base frequency, with `0` meaning NoPE. One rotary module
  is built per distinct non-zero theta, mirroring `GraniteSWAModel.rotary_embs`.

Because the sink lives inside the softmax, no fused `Attention` or
`GroupQueryAttention` op can be used; the score matrix is built explicitly
and the bias carries causality, the window and padding. Reduced-precision
builds upcast the sink softmax to float32, matching the upstream forced-fp32
path, while float32 builds keep an unchanged (Cast-free) graph.

`GraniteSwaConfig` re-applies HuggingFace's `__post_init__` defaults
(every fourth layer full attention, per-layer theta defaulting to the global
one) and re-asserts RoPE, so a raw `config.json` mapping builds the same
architecture as a materialised HF config object.

Also pins the revision and the eager attention kernel through the causal-LM
golden reference loader: the sink is not SDPA-expressible, so the reference
must never silently drift onto a kernel that drops it.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Signed-off-by: justinchuby <justinchu@microsoft.com>
…rity tests

The golden references were produced by an independently invoked HuggingFace
pipeline (transformers 5.15.0 GraniteSWAForCausalLM, float32, eager
attention, revision af1e3227100b61088eead48389ab5409b5d0e39c), not by the
implementation under test:

  L4 prefill top-1 = 330, top-2 = 2355
  L5 greedy, 20/20 tokens, decoding to:
  ' "The Last of the Mohicans" by James Fenimore Cooper. It is a poem about the'

Both e2e_golden_test levels pass against the float32 CPU export.

The new integration tests prompt with more than sliding_window (128) tokens
on purpose. Below that length a windowed layer and a full-attention layer see
exactly the same keys, so a window bug would pass unnoticed; the decode test
then takes a cached step whose query lies outside the window of the oldest
cached keys. Both pin the checkpoint revision and assert that the HuggingFace
reference really resolved to the eager kernel.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Signed-off-by: justinchuby <justinchu@microsoft.com>
…sink softmax

The decode parity test cross-fed HuggingFace's KV cache into a plain
DynamicCache, which crashed inside HuggingFace's own mask builder. The root
cause is a representation difference, not a numerical one: HuggingFace's
DynamicSlidingWindowLayer physically drops keys once they leave the window
(prefill of 20 tokens with sliding_window=8 returns 7 cached keys for sliding
layers and 20 for full-attention layers), whereas the exported graph keeps the
full cache and masks the same keys out through the sliding attention bias.
Both are correct and neither cache can be fed to the other, so each side now
runs prefill and decode against its own cache and only the decode logits are
compared. The test asserts the trimming difference explicitly so the reason
for the split is not lost.

Also adds graph assertions for the float16/bfloat16 sink softmax: upstream
forces the logsumexp/sigmoid and the softmax to float32 regardless of compute
dtype, which here means an upcast before the softmax and a downcast after the
sink column is dropped. float32 builds are asserted to stay Cast-free.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Signed-off-by: justinchuby <justinchu@microsoft.com>
@justinchuby
justinchuby requested review from a team and a lite review from Copilot August 24, 2026 13:42
@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown

Performance Comparison

Comparing 059bf5f8648b80

Model Metric Baseline Current Delta
bert (feature-extraction) model_size_bytes 359 KB 359 KB +0.0%
bert (feature-extraction) num_nodes 68 68 +0.0%
falcon model_size_bytes 364 KB 364 KB +0.0%
falcon num_nodes 66 66 +0.0%
gemma2 model_size_bytes 428 KB 428 KB +0.0%
gemma2 num_nodes 105 105 +0.0%
gpt2 model_size_bytes 388 KB 388 KB +0.0%
gpt2 num_nodes 54 54 +0.0%
llama model_size_bytes 425 KB 425 KB +0.0%
llama num_nodes 60 60 +0.0%
llama (static-cache) model_size_bytes 425 KB 425 KB +0.0%
llama (static-cache) num_nodes 56 56 +0.0%
mamba (ssm-text-generation) model_size_bytes 296 KB 296 KB +0.0%
mamba (ssm-text-generation) num_nodes 94 94 +0.0%
phi3 model_size_bytes 421 KB 421 KB +0.0%
phi3 num_nodes 58 58 +0.0%
phi3 (static-cache) model_size_bytes 421 KB 421 KB +0.0%
phi3 (static-cache) num_nodes 54 54 +0.0%
qwen2 model_size_bytes 425 KB 425 KB +0.0%
qwen2 num_nodes 60 60 +0.0%
qwen2 (static-cache) model_size_bytes 425 KB 425 KB +0.0%
qwen2 (static-cache) num_nodes 56 56 +0.0%
qwen3_5_moe (hybrid-text-generation) model_size_bytes 506 KB 506 KB +0.0%
qwen3_5_moe (hybrid-text-generation) num_nodes 264 264 +0.0%
qwen3_5_text (hybrid-text-generation) model_size_bytes 458 KB 458 KB +0.0%
qwen3_5_text (hybrid-text-generation) num_nodes 126 126 +0.0%
qwen3_5_vl (hybrid-qwen-vl) model_size_bytes 977 KB 977 KB +0.0%
qwen3_5_vl (hybrid-qwen-vl) num_nodes 428 428 +0.0%
t5 (seq2seq) model_size_bytes 836 KB 836 KB +0.0%
t5 (seq2seq) num_nodes 176 176 +0.0%
whisper (speech-to-text) model_size_bytes 1008 KB 1008 KB +0.0%
whisper (speech-to-text) num_nodes 128 128 +0.0%

No performance regressions.

Comment on lines +215 to +217
logits, present_key_values = super().forward(
op, input_ids, attention_mask, position_ids, past_key_values
)
@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown

🏗️ Architecture Diff

Comparing 059bf5f8648b80

Model Sub-model Changes Status
bert (feature-extraction) model 0
falcon model 0
gemma2 model 0
gemma4 (gemma4) decoder 0
gemma4 (gemma4) embedding 0
gemma4 (gemma4) vision_encoder 0
gemma4_text model 0
gpt2 model 0
llama model 0
llama (static-cache) model 0
mamba (ssm-text-generation) model 0
phi3 model 0
phi3 (static-cache) model 0
qwen model 0
qwen (static-cache) model 0
qwen2 model 0
qwen2 (static-cache) model 0
qwen2_moe model 0
qwen2_moe (static-cache) model 0
qwen3 model 0
qwen3 (static-cache) model 0
qwen3_5_moe (hybrid-text-generation) model 0
qwen3_5_text (hybrid-text-generation) model 0
qwen3_5_vl (hybrid-qwen-vl) decoder 0
qwen3_5_vl (hybrid-qwen-vl) embedding 0
qwen3_5_vl (hybrid-qwen-vl) vision_encoder 0
qwen3_moe model 0
qwen3_moe (static-cache) model 0
qwen3_next (hybrid-text-generation) model 0
t5 (seq2seq) decoder 0
t5 (seq2seq) encoder 0
whisper (speech-to-text) decoder 0
whisper (speech-to-text) encoder 0

No architecture changes detected.


Legend: ⚪ No change · 🔵 Minor (attrs/inits) · 🟡 Moderate (nodes added/removed) · 🔴 Major (interface changed)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds Mobius support for HuggingFace granite_swa (GraniteSWAForCausalLM) by introducing a reusable SinkAttention component (shared with GPT-OSS), wiring it through decoder-layer construction, and adding config extraction + parity/golden coverage for ibm-granite/granite-swash-2b.

Changes:

  • Add SinkAttention as an Attention subclass and allow create_decoder_layer/DecoderLayer to swap attention implementations via attention_class.
  • Introduce GraniteSwaConfig + GraniteSwaCausalLMModel/backbone with mixed sliding/full attention, per-layer RoPE theta (including NoPE), and sink logits.
  • Add/extend synthetic parity, integration parity, and L4/L5 golden test cases for granite_swa, plus golden-generation plumbing (revision + eager attention pinning).

Reviewed changes

Copilot reviewed 18 out of 18 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/synthetic_parity_test.py Adds eager-pinned HF factory for granite_swa and tiny-config override for in-range BOS/EOS IDs.
tests/integration_test.py Adds revision-pinned real-weight GraniteSWA prefill+decode parity tests against HF eager attention.
tests/_test_configs.py Registers a tiny representative granite_swa config for graph-build and validation suites.
testdata/golden/causal-lm/granite-swash-2b.json Adds L4 prefill golden reference for ibm-granite/granite-swash-2b.
testdata/golden/causal-lm/granite-swash-2b_generation.json Adds L5 greedy generation golden reference for ibm-granite/granite-swash-2b.
testdata/cases/causal-lm/granite-swash-2b.yaml Adds L4+L5 golden test case metadata (model_id/type/revision, generation settings).
src/mobius/models/granite_swa.py Implements GraniteSWA model/backbone: mixed attention spans, per-layer RoPE theta (NoPE via 0), sink attention, Granite multipliers.
src/mobius/models/granite_swa_test.py Adds unit tests for GraniteSWA config extraction, RoPE dispatch, sink algebra, and attention-op constraints.
src/mobius/models/gptoss.py Refactors GPT-OSS sink attention to reuse shared SinkAttention.
src/mobius/models/init.py Exports GraniteSwaCausalLMModel.
src/mobius/components/_decoder.py Adds attention_class override to DecoderLayer and create_decoder_layer.
src/mobius/components/_attention.py Adds shared _apply_qk_norm helper and introduces SinkAttention implementation.
src/mobius/components/init.py Re-exports SinkAttention from the public components API.
src/mobius/_testing/torch_reference.py Extends load_torch_model with revision and attn_implementation pinning + validation.
src/mobius/_registry.py Registers granite_swa model type and adds default test model id mapping.
src/mobius/_configs/_base.py Adds GraniteSwaConfig extraction to match HF defaults and re-assert RoPE for raw config.json path.
src/mobius/_configs/init.py Exports GraniteSwaConfig.
scripts/generate_golden.py Pins HF reference attention backend to eager for granite_swa and forwards Hub revision into reference loads.
Suppressed comments (1)

tests/integration_test.py:5074

  • This test pins the HF weights/build to _GRANITE_SWA_REVISION but loads ArchitectureConfig from the model’s default branch via _get_config(). If the Hub config drifts, the feeds (KV cache layout, sliding_window, layer_types) may stop matching the pinned checkpoint.
    config = _get_config(_GRANITE_SWA_MODEL_ID)

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread tests/integration_test.py
load_weights=True,
)
torch_model, tokenizer = _load_granite_swa_reference()
config = _get_config(_GRANITE_SWA_MODEL_ID)
Comment on lines +587 to +596
if isinstance(attention_bias, GQAContext):
raise TypeError(
"SinkAttention cannot emit GroupQueryAttention: the sink logit "
"must take part in the softmax denominator. Build this model "
"with a float additive attention bias instead."
)
if static_cache is not None:
raise NotImplementedError(
"SinkAttention does not support the opset-24 static KV cache."
)
justinchuby and others added 2 commits August 24, 2026 06:52
TestRegistryConsistency::test_config_class_declared_on_model requires that a
registry entry declaring a non-default config_class agrees with the model
class, so that a model can never silently inherit a different config from its
parent. GraniteSwaCausalLMModel was inheriting CausalLMConfig from
CausalLMModel while the registry declared GraniteSwaConfig, which would drop
layer_rope_theta on any path that resolves the config from the class rather
than the registry.

The local run that missed this used `-k granite_swa`, which does not select
the consistency test; the model type only appears in its assertion message.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Signed-off-by: justinchuby <justinchu@microsoft.com>
The shared SinkAttention component unconditionally forced the sink softmax to
float32 on non-float32 builds. That reproduces GraniteSWA, whose eager kernel
does

    sink_scale = (lse - sinks).to(torch.float32).sigmoid()
    attn_weights = F.softmax(attn_weights, dim=-1, dtype=torch.float32)

but it is wrong for GPT-OSS, which is explicit about staying in the compute
dtype:

    probs = F.softmax(combined_logits, dim=-1, dtype=combined_logits.dtype)

So the shared component was changing GPT-OSS float16/bfloat16 numerics and
doubling the size of the largest score tensor, neither of which main does.

The precision contract now lives in the type rather than in a constructor
flag, because it is a property of the architecture and not a tuning knob:
SinkAttention keeps upcast_sink_softmax = False (GPT-OSS), and the new
Float32SinkAttention subclass sets it to True (GraniteSWA). A model cannot
silently acquire or lose the upcast by being constructed differently.

Verified: the tiny gpt_oss graph built at origin/main (059bf5f) and at this
commit has an identical node op_type sequence and identical sorted initializer
names for float32, float16 and bfloat16 alike (Cast counts 8 / 10 / 10 on both
sides), so GPT-OSS is graph-neutral again at every dtype rather than only at
float32. gpt_oss and all granite synthetic parity tests pass.

New regression tests assert both halves of the contract: GPT-OSS has no Cast
on the sink-softmax path at f32/f16/bf16, and GraniteSWA keeps the upcast at
f16/bf16 while staying Cast-free at f32.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Signed-off-by: justinchuby <justinchu@microsoft.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants