Skip to content

update lm head last token pruning - #288

Open
rui-ren wants to merge 6 commits into
mainfrom
ruiren/update-lm-head-last-token-pruning
Open

update lm head last token pruning#288
rui-ren wants to merge 6 commits into
mainfrom
ruiren/update-lm-head-last-token-pruning

Conversation

@rui-ren

@rui-ren rui-ren commented May 8, 2026

Copy link
Copy Markdown
Contributor

PR Description

Adds an opt-in prune_lm_head flag to Mobius, mirroring ONNX Runtime GenAI Model Builder behavior.

When enabled (MOBIUS_PRUNE_LM_HEAD=1 or flags.prune_lm_head = True), Mobius inserts a Gather(axis=1, idx=-1) before the LM head so only the final token logits are computed during prefill.

This reduces unnecessary [B, S, vocab] logit computation and improves prefill performance.

Default is False to preserve compatibility with:

  • logprob scoring
  • speculative decoding
  • multi-token generation workflows

Currently supported by models using the base CausalLMModel.forward() path (Llama, Mistral, Qwen2/2.5/3, etc.). Models with custom forward() implementations currently ignore the flag.

@rui-ren

rui-ren commented May 8, 2026

Copy link
Copy Markdown
Contributor Author

@copilot merge main branch into this PR.

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

This PR adds an opt-in prune_lm_head flag that, when enabled, prunes the LM head computation to only the last token (prefill optimization) by inserting a Gather before the LM head. It also introduces an export_package() helper in the ORT-GenAI integration to produce a fully loadable onnxruntime-genai directory (ONNX + config artifacts) from an already-built ModelPackage.

Changes:

  • Add flags.prune_lm_head (env: MOBIUS_PRUNE_LM_HEAD) and wire it into the base CausalLMModel.forward() path.
  • Add unit tests for the pruning flag behavior in the base model build path.
  • Add export_package() to the ORT-GenAI integration, refactor auto_export() to delegate to it, and add tests/docs for the new helper.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/mobius/models/base.py Adds last-token hidden-state selection before lm_head when flags.prune_lm_head is enabled.
src/mobius/models/_models_test.py Adds tests covering default vs pruned logits shape behavior for CausalLMModel.
src/mobius/integrations/ort_genai/auto_export.py Adds export_package() and refactors auto_export() to use it; updates module docstring.
src/mobius/integrations/ort_genai/auto_export_test.py Adds unit tests for export_package() behavior and argument propagation.
src/mobius/integrations/ort_genai/init.py Exposes export_package in the integration public API.
src/mobius/_model_package.py Updates ModelPackage.save() docstring to point users to export_package() for ORT-GenAI directories.
src/mobius/_flags.py Adds the prune_lm_head flag, env var support, and documentation.

Comment thread src/mobius/models/base.py Outdated
Comment thread src/mobius/models/_models_test.py
Comment thread src/mobius/integrations/ort_genai/auto_export.py Outdated
Comment thread src/mobius/models/_models_test.py
…-last-token-pruning

Signed-off-by: GitHub <noreply@github.com>

# Conflicts:
#	src/mobius/_flags.py
#	src/mobius/integrations/ort_genai/auto_export.py
#	src/mobius/integrations/ort_genai/auto_export_test.py

Co-authored-by: rui-ren <15321482+rui-ren@users.noreply.github.com>

Copilot AI commented May 8, 2026

Copy link
Copy Markdown
Contributor

@copilot merge main branch into this PR.

Done. Merged main into this branch in commit 9ec4e7a. Resolved conflicts in _flags.py (table docstring), auto_export.py (docstring improvements), and auto_export_test.py (test docstring).

Copilot finished work on behalf of rui-ren May 8, 2026 01:24
@rui-ren rui-ren changed the title Ruiren/update lm head last token pruning update lm head last token pruning May 8, 2026
@github-actions

github-actions Bot commented May 8, 2026

Copy link
Copy Markdown

🏗️ Architecture Diff

Comparing 9b22d8d0e78f1c

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)

@github-actions

github-actions Bot commented May 8, 2026

Copy link
Copy Markdown

Performance Comparison

Comparing 9b22d8d0e78f1c

Model Metric Baseline Current Delta
bert (feature-extraction) model_size_bytes 359 KB 359 KB +0.0%
bert (feature-extraction) num_nodes 60 60 +0.0%
falcon model_size_bytes 364 KB 364 KB +0.0%
falcon num_nodes 68 68 +0.0%
gemma2 model_size_bytes 428 KB 428 KB +0.0%
gemma2 num_nodes 107 107 +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 62 62 +0.0%
llama (static-cache) model_size_bytes 425 KB 425 KB +0.0%
llama (static-cache) num_nodes 58 58 +0.0%
mamba (ssm-text-generation) model_size_bytes 296 KB 296 KB +0.0%
mamba (ssm-text-generation) num_nodes 98 98 +0.0%
phi3 model_size_bytes 421 KB 421 KB +0.0%
phi3 num_nodes 60 60 +0.0%
phi3 (static-cache) model_size_bytes 421 KB 421 KB +0.0%
phi3 (static-cache) num_nodes 56 56 +0.0%
qwen2 model_size_bytes 425 KB 425 KB +0.0%
qwen2 num_nodes 62 62 +0.0%
qwen2 (static-cache) model_size_bytes 425 KB 425 KB +0.0%
qwen2 (static-cache) num_nodes 58 58 +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 275 275 +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 129 129 +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 413 413 +0.0%
t5 (seq2seq) model_size_bytes 836 KB 836 KB +0.0%
t5 (seq2seq) num_nodes 166 166 +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.

@codecov

codecov Bot commented May 8, 2026

Copy link
Copy Markdown

The author of this PR, rui-ren, is not an activated member of this organization on Codecov.
Please activate this user on Codecov to display this PR comment.
Coverage data is still being uploaded to Codecov.io for purposes of overall coverage calculations.
Please don't hesitate to email us at support@codecov.io with any questions.

@justinchuby

Copy link
Copy Markdown
Member

Maybe we should consider creating an option like we do for use_static_cache, as the feature flags are designed to be temporary and would eventually be removed when the feature stabilizes. Do you think it would make sense to create a new task for this? Or maybe create an option to an existing task?

@rui-ren

rui-ren commented May 8, 2026

Copy link
Copy Markdown
Contributor Author

Maybe we should consider creating an option like we do for use_static_cache, as the feature flags are designed to be temporary and would eventually be removed when the feature stabilizes. Do you think it would make sense to create a new task for this? Or maybe create an option to an existing task?

ok, sure, make sense to create a new task for this.

@justinchuby

Copy link
Copy Markdown
Member

@copilot please address comments and update from main

Remove the MOBIUS_PRUNE_LM_HEAD feature flag and replace it with a
prune_lm_head constructor parameter on CausalLMTask (and
HybridCausalLMTask), mirroring the stable-API pattern already used by
the static_cache option.

When prune_lm_head=True, the task inserts Gather(axis=1, index=-1)
followed by Unsqueeze(axis=1) on the logits output after calling
module(), changing the output shape from [B, S, vocab] to [B, 1, vocab].
ONNX Runtime's graph optimizer can push this Gather backward through the
LM head MatMul to avoid computing all-token logits during prefill.

Update tests to use CausalLMTask(prune_lm_head=True) instead of the
override_flags context manager. Also merges main branch changes
(tencent_q1_0_use_native_2bit and static_cache_bias flags).

Signed-off-by: Copilot <copilot@github.com>
Signed-off-by: GitHub <noreply@github.com>
Copilot AI review requested due to automatic review settings July 30, 2026 05:25
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
0 out of 2 committers have signed the CLA.

❌ rui-ren
❌ Copilot
You have signed the CLA already but the status is still pending? Let us recheck it.

Copilot AI commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

@copilot please address comments and update from main

Done in commit 0e78f1c.

Moved prune_lm_head from _flags.py to a constructor parameter on CausalLMTask (and HybridCausalLMTask), mirroring the static_cache option pattern:

# Before (global flag)
with override_flags(prune_lm_head=True):
    pkg = build(model_id)

# After (task option)
pkg = build(model_id, task=CausalLMTask(prune_lm_head=True))

The Gather(axis=1, index=-1) + Unsqueeze is now applied in CausalLMTask.build() on the logits output after calling module(), so the transformation is task-controlled rather than a global side effect. Also merged latest main.

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

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

Comments suppressed due to low confidence (5)

src/mobius/tasks/_causal_lm.py:332

  • Same as above: Gather(..., idx=-1) depends on negative indices. Derive the last index from Shape(logits) to avoid relying on undefined/implementation-specific behavior.
            last_idx = op.Constant(value_int=-1)  # scalar (rank-0) INT64
            last_token_logits = op.Gather(logits, last_idx, axis=1)  # [B, vocab]
            logits = op.Unsqueeze(last_token_logits, op.Constant(value_ints=[1]))  # [B, 1, vocab]

src/mobius/tasks/_causal_lm.py:87

  • The docstring says pruning inserts a Gather "before the LM head" so only the last hidden state is projected, but the implementation below prunes after logits are computed (Gather on [B,S,vocab]). This is misleading and also overstates the guaranteed perf win (it relies on ORT pushing the Gather backward through the projection).
        prune_lm_head: If ``True``, insert ``Gather(axis=1, index=-1)``
            before the LM head so only the last token's hidden state is
            projected to logits.  Output logits shape becomes ``[B, 1,
            vocab]`` instead of ``[B, S, vocab]``, reducing prefill cost
            for large-vocabulary models.  Set this when the downstream

src/mobius/tasks/_causal_lm.py:215

  • Using Gather(..., idx=-1) relies on negative indices, which are not guaranteed by the ONNX Gather spec across runtimes. Compute the last index from Shape(logits) instead to keep the graph spec-compliant and portable.

This issue also appears on line 330 of the same file.

            last_idx = op.Constant(value_int=-1)  # scalar (rank-0) INT64
            last_token_logits = op.Gather(logits, last_idx, axis=1)  # [B, vocab]
            logits = op.Unsqueeze(last_token_logits, op.Constant(value_ints=[1]))  # [B, 1, vocab]

src/mobius/tasks/_causal_lm.py:99

  • The PR description says this is enabled via MOBIUS_PRUNE_LM_HEAD=1 or flags.prune_lm_head=True, but the implementation only supports passing prune_lm_head when constructing CausalLMTask/HybridCausalLMTask. If the env-var / global flags integration is a requirement, it’s currently missing.
    def __init__(
        self,
        *,
        static_cache: bool = False,
        max_seq_len: int | None = None,
        prune_lm_head: bool = False,
    ):
        self._static_cache = static_cache
        self._max_seq_len = max_seq_len
        self._prune_lm_head = prune_lm_head

src/mobius/models/_models_test.py:233

  • test_prune_emits_gather_on_logits only checks the inferred output shape. That can miss regressions where shape inference still yields [B,1,V] but the pruning ops are not actually wired (or get optimized away unexpectedly). Making the test assert the Unsqueeze <- Gather producer chain keeps it directly tied to the intended graph edit.
        logits = next(v for v in model.graph.outputs if v.name == "logits")
        # Logits must still be rank-3 [B, 1, vocab] (NOT rank-4 [B, 1, 1, V])
        assert len(logits.shape) == 3, (
            f"Expected rank-3 logits [B, 1, V] after pruning, got rank "
            f"{len(logits.shape)}: shape={list(logits.shape)!r}"
        )
        # Pruned: dim 1 must be the literal integer 1
        seq_dim = logits.shape[1]
        assert seq_dim == 1, f"Expected logits dim 1 to be 1 after pruning, got {seq_dim!r}"
        # Last dim is still the vocabulary size
        config = make_config()
        assert logits.shape[2] == config.vocab_size

@justinchuby
justinchuby marked this pull request as ready for review July 30, 2026 05:33
@justinchuby
justinchuby requested a review from a team July 30, 2026 05:33
assert logits.shape[2] == config.vocab_size

def test_prune_does_not_change_input_shapes(self):
"""Pruning only affects output; input_ids still has dynamic sequence_length
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants