Skip to content

[BugFix][v0.23.0][KV Pool] Align scheduler num_layers with registered MTP layers - #13633

Closed
tyy0829 wants to merge 1 commit into
vllm-project:releases/v0.23.0from
tyy0829:fix/scheduler-mtp-num-layers-v0.23.0
Closed

tyy0829 wants to merge 1 commit into
vllm-project:releases/v0.23.0from
tyy0829:fix/scheduler-mtp-num-layers-v0.23.0

Conversation

@tyy0829

@tyy0829 tyy0829 commented Aug 5, 2026 •

Copy link
Copy Markdown
Contributor

What this PR does / why we need it?

Follow-up to #13454, which only updated KVPoolWorker.num_layers to include MTP draft layers. KVPoolScheduler.num_layers was left at model_config.get_num_layers(), which excludes MTP -- breaking the "Keep this in sync with pool_worker.py" invariant (pool_scheduler.py:179) and leaving the scheduler's per-layer lookup keys / GVA allocation sizing (num_layer_keys, keys_per_block_hash, split_layers) short of the MTP layer. Under memcache/gva layerwise this caused fluctuating MTP draft acceptance: the scheduler declared a block "hit" without verifying the MTP layer key, then the worker attempted an MTP load that could fail depending on whether the producer had saved the MTP KV yet.

Changes:

  • config_data.py: add shared extract_physical_layer_index helper (stdlib re; mirrors KVPoolWorker._extract_physical_layer_index). Covers both DeepSeek-style model.layers.{N>=num_hidden_layers} and model.mtp.{N} naming.
  • pool_scheduler.py: KVPoolScheduler now derives num_layers (and recomputes keys_per_block_hash) from kv_cache_config.kv_cache_groups[*].layer_names, so MTP layers are counted. For PP, the scheduler receives kv_cache_configs[0] (PP rank 0, no MTP), matching its [self.pp_rank] query scope.
  • pool_worker.py: _extract_physical_layer_index delegates to the shared helper (behavior-preserving; drops the inline regex dependency).
  • tests/ut/distributed/ascend_store/test_pool_scheduler.py: add tests for DeepSeek-style MTP, mtp..-prefix MTP, no-config fallback, and keys_per_block_hash under gva layerwise, plus direct extract_physical_layer_index unit tests.

Refs #13454.

Does this PR introduce any user-facing change?

No user-facing API changes. Internal fix for KV pool scheduler layer accounting including MTP layers.

How was this patch tested?

  • Unit tests added/updated: tests/ut/distributed/ascend_store/test_pool_scheduler.py

    • test_num_layers_includes_deepseek_mtp_layer
    • test_num_layers_includes_mtp_prefix_layer
    • test_num_layers_without_kv_cache_config_falls_back
    • test_keys_per_block_hash_includes_mtp_for_gva
    • TestExtractPhysicalLayerIndex (helper unit tests)
  • Manual verification (standalone script with project _mock_deps shims): helper extraction, worker delegation, scheduler num_layers/keys_per_block_hash for MTP/gva all pass; py_compile clean.

  • CI verification (please run pytest tests/ut/distributed/ascend_store/test_pool_scheduler.py tests/ut/distributed/ascend_store/test_pool_worker.py + bash format.sh ci).

  • vLLM version: v0.23.0

  • vLLM main: vllm-project/vllm@ee0da84

The worker-side MTP transfer (vllm-project#13454) only updated KVPoolWorker.num_layers to include MTP draft layers; KVPoolScheduler.num_layers stayed at model_config.get_num_layers(), which excludes MTP. This broke the 'Keep this in sync with pool_worker.py' invariant and left the scheduler's per-layer lookup keys and GVA allocation sizing short of the MTP layer, causing fluctuating MTP draft acceptance under memcache/gva layerwise.

Add a shared extract_physical_layer_index helper in config_data (stdlib re, mirrors the worker). KVPoolScheduler now derives num_layers (and keys_per_block_hash) from kv_cache_config.kv_cache_groups[*].layer_names so MTP layers are counted; for PP the scheduler receives kv_cache_configs[0] (PP rank 0, no MTP), matching its [self.pp_rank] query scope. KVPoolWorker._extract_physical_layer_index delegates to the shared helper (behavior-preserving; drops the inline regex dependency).

Signed-off-by: tyy0829 <1455207791@qq.com>
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request addresses a synchronization issue between the KV pool scheduler and worker regarding MTP (Multi-Token Prediction) draft layers. Previously, the scheduler's layer count excluded MTP layers, leading to inconsistent GVA allocation and potential failures in draft acceptance. By unifying the layer indexing logic and aligning the scheduler's layer accounting with the worker's authoritative configuration, this change ensures consistent behavior across the distributed KV cache system.

Highlights

  • Shared Layer Indexing Helper: Introduced a centralized extract_physical_layer_index helper in config_data.py to consistently parse MTP and standard layer indices across both the scheduler and worker.
  • Scheduler Layer Accounting: Updated KVPoolScheduler to derive num_layers from registered KV cache layer names rather than relying solely on model_config, ensuring MTP draft layers are correctly accounted for in GVA allocation and key generation.
  • Test Coverage: Added comprehensive unit tests in test_pool_scheduler.py covering DeepSeek-style MTP, prefix-based MTP, and fallback scenarios to ensure robust layer index extraction and scheduler behavior.
New Features

🧠 You can now enable Memory (public preview) to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution. ↩

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

👋 Hi! Thank you for contributing to the vLLM Ascend project. The following points will speed up your PR merge:‌‌

  • A PR should do only one thing, smaller PRs enable faster reviews.
  • Every PR should include unit tests and end-to-end tests ‌to ensure it works and is not broken by other future PRs.
  • Write the commit message by fulfilling the PR description to help reviewer and future developers understand.

If CI fails, you can run linting and testing checks locally according Contributing and Testing.


Tip

💡 Consider Linking a Related Issue or RFC

Your PR title contains the [BugFix] tag, indicating a bug fix or new feature.

Linking a related issue or RFC in the PR description is strongly encouraged — it gives reviewers helpful context and speeds up the review. You can use any of these keywords:

  • Fixes #<issue_number>
  • Closes #<issue_number>
  • Resolves #<issue_number>
  • Refs #<rfc_or_issue_number> (for RFCs)

🙏 Thanks for helping us keep the project well-organized!

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

Suggested PR Title:

[Ops][Feature] Support MTP layers in KVPoolScheduler and share physical layer index extraction

Suggested PR Summary:

### What this PR does / why we need it?
This PR introduces a shared helper function `extract_physical_layer_index` to extract physical layer indices (including MTP draft layers) from registered KV cache layer names. It updates `KVPoolScheduler` to compute the registered number of layers dynamically from `KVCacheConfig`, ensuring the scheduler's per-layer key generation and GVA allocation stay in sync with `KVPoolWorker`.

### Does this PR introduce _any_ user-facing change?
No.

### How was this patch tested?
Added unit tests in `test_pool_scheduler.py` to verify the physical layer index extraction and the scheduler's layer count computation.

Review Feedback:
All review comments are valid and point out an actual improvement opportunity: the fallback parameter in extract_physical_layer_index is unused and should be removed to simplify the API.

Comment on lines +24 to +46
def extract_physical_layer_index(
layer_name: str, num_hidden_layers: int, fallback: int
) -> int:
"""Extract the physical layer index from a registered KV cache layer name.

MTP draft layers are mapped after the main model layers so they sort
behind the standard attention layers:

* DeepSeek-style MTP reuses the ``layers.{N}`` naming with
``N >= num_hidden_layers`` (e.g. ``model.layers.61.self_attn.attn``).
* Other models use an ``mtp.{N}`` prefix (e.g. ``model.mtp.0.self_attn``)
which is mapped to ``num_hidden_layers + N``.

Shared by ``KVPoolWorker`` and ``KVPoolScheduler`` so both sides derive an
identical layer layout (including MTP) from the registered layer names.

Args:
layer_name: registered KV cache layer name.
num_hidden_layers: main model hidden layer count, used to offset MTP
layers that use the ``mtp.{N}`` prefix.
fallback: value used when an ``mtp.{N}`` prefix is matched but no main
layer count is available.
"""

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.

high

The fallback parameter is defined in the function signature and documented in the docstring, but it is completely unused within the function body. Since the fallback logic is already handled by the callers before invoking this function, we should remove the fallback parameter to simplify the API and avoid confusion.

def extract_physical_layer_index(
    layer_name: str, num_hidden_layers: int
) -> int:
    """Extract the physical layer index from a registered KV cache layer name.

    MTP draft layers are mapped after the main model layers so they sort
    behind the standard attention layers:

    * DeepSeek-style MTP reuses the ``layers.{N}`` naming with
      ``N >= num_hidden_layers`` (e.g. ``model.layers.61.self_attn.attn``).
    * Other models use an ``mtp.{N}`` prefix (e.g. ``model.mtp.0.self_attn``)
      which is mapped to ``num_hidden_layers + N``.

    Shared by ``KVPoolWorker`` and ``KVPoolScheduler`` so both sides derive an
    identical layer layout (including MTP) from the registered layer names.

    Args:
        layer_name: registered KV cache layer name.
        num_hidden_layers: main model hidden layer count, used to offset MTP
            layers that use the ``mtp.{N}`` prefix.
    """

Comment on lines +212 to +214
physical_layers.add(
extract_physical_layer_index(layer_name, num_hidden_layers, fallback)
)

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.

high

Remove the unused fallback argument when calling extract_physical_layer_index.

Suggested change
physical_layers.add(
extract_physical_layer_index(layer_name, num_hidden_layers, fallback)
)
physical_layers.add(
extract_physical_layer_index(layer_name, num_hidden_layers)
)

m = re.search(r"(\d+)", layer_name)
return int(m.group(1)) if m else 0
num_hidden_layers = getattr(self.hf_config, "num_hidden_layers", self.num_layers)
return extract_physical_layer_index(layer_name, num_hidden_layers, self.num_layers)

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.

high

Remove the unused self.num_layers fallback argument when calling extract_physical_layer_index.

Suggested change
return extract_physical_layer_index(layer_name, num_hidden_layers, self.num_layers)
return extract_physical_layer_index(layer_name, num_hidden_layers)

Comment on lines +1341 to +1361
self.assertEqual(
extract_physical_layer_index("model.layers.0.self_attn.attn", 2, 2), 0
)
self.assertEqual(
extract_physical_layer_index("model.layers.60.self_attn.attn", 61, 61), 60
)
# First MTP layer for a 61-layer model (index == num_hidden_layers).
self.assertEqual(
extract_physical_layer_index("model.layers.61.self_attn.attn", 61, 61), 61
)

def test_mtp_prefix_maps_after_main_layers(self):
self.assertEqual(
extract_physical_layer_index("model.mtp.0.self_attn", 2, 2), 2
)
self.assertEqual(
extract_physical_layer_index("model.mtp.1.self_attn", 2, 2), 3
)

def test_fallback_when_no_numeric_token(self):
self.assertEqual(extract_physical_layer_index("no_index", 2, 2), 0)

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.

high

Update the unit tests to match the simplified signature of extract_physical_layer_index by removing the unused third argument.

        self.assertEqual(
            extract_physical_layer_index("model.layers.0.self_attn.attn", 2), 0
        )
        self.assertEqual(
            extract_physical_layer_index("model.layers.60.self_attn.attn", 61), 60
        )
        # First MTP layer for a 61-layer model (index == num_hidden_layers).
        self.assertEqual(
            extract_physical_layer_index("model.layers.61.self_attn.attn", 61), 61
        )

    def test_mtp_prefix_maps_after_main_layers(self):
        self.assertEqual(
            extract_physical_layer_index("model.mtp.0.self_attn", 2), 2
        )
        self.assertEqual(
            extract_physical_layer_index("model.mtp.1.self_attn", 2), 3
        )

    def test_fallback_when_no_numeric_token(self):
        self.assertEqual(extract_physical_layer_index("no_index", 2), 0)

@LCAIZJ

LCAIZJ commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Does the main branch not have this issue?

@LCAIZJ

LCAIZJ commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Was this issue not discovered when testing #13454?

@tyy0829 tyy0829 closed this Aug 10, 2026
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