Skip to content

feat(scheduler): LoRA adapter identity, max_loras batch cap, and per-adapter KV namespace - #976

Open
towillwu wants to merge 5 commits into
mainfrom
towillwu/lora-scheduler
Open

feat(scheduler): LoRA adapter identity, max_loras batch cap, and per-adapter KV namespace#976
towillwu wants to merge 5 commits into
mainfrom
towillwu/lora-scheduler

Conversation

@towillwu

@towillwu towillwu commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Makes the tokenspeed scheduler LoRA-aware. Scheduler only — no kernels, no adapter runtime, no model code.

Two independent changes, one commit each, plus a small wire-format change.


1 — Per-adapter KV prefix-cache namespace

Without isolation, tokens cached under adapter A can be served to adapter B or to the base model: a page hash derives only from its tokens and the chain before it, so identical prompt prefixes collide across adapters.

page_hasher.h already accepted per-page extra_keys — documented upstream for exactly this ("e.g. LoRA name"). This adds ComputeNamespacedPagedHashes, which applies one key set to every page rather than a per-page list, and threads the request's adapter id through it.

Why every page, not just page 0. Chaining would make a page-0 key sufficient — each later page folds in prior_hash. But appendCompletedPageHashes starts a fresh chain with an empty prior whenever page_hashes is empty, which happens for any prompt shorter than one page: such a request produces no hashes at admission, so the first hash it ever computes comes from the incremental path. With a page-0-only key that page would silently land in the base-model namespace. Keying every page makes isolation independent of where the chain starts.

AdvancePagedHashes silently dropped keys. It neither accepted nor forwarded extra_keys, so two of the three hash sites would have lost the adapter id on every extension. It now takes namespace_keys. All three sites pass the request's keys: admission (matchPrefixAtAdmission), incremental extension (appendCompletedPageHashes), and stable-page publication (publishCompletedPages).

2 — max_loras batch cap

SchedulerConfig::max_loras (0 = disabled) bounds the number of distinct adapters in one batch, because the runtime materializes one adapter slot per unique id and exceeding the cap would overrun that fixed pool.

buildForwardOperations tracks the adapters already pushed and defers a request that would introduce a new one past the cap — deferring rather than breaking out of the loop, because a later candidate may reuse an adapter already in the batch and still belongs in this step. A request whose adapter is already present costs no additional slot; base-model requests are never charged.


Existing functionality is unchanged

This is the property the PR is built around, and it is asserted rather than argued:

Concern Why it holds
Page hashes An empty key list is byte-identical to the previous two-argument hash form. NoKeysIsByteIdenticalToUnnamespacedChain asserts it directly, so existing cache entries stay valid and enabling LoRA invalidates nothing.
Batch composition max_loras == 0 means LoRA scheduling is off, not "admit zero adapters" — the cap is simply not applied. ZeroMaxLorasEnforcesNoCap pins it.
Defaults RequestSpec::lora_id defaults to 0 (base model) and max_loras to 0, so a build that never sets either behaves exactly as before.
IPC wire format Untouched. See below.
Request handling request_handler reads the id with getattr(..., 0), so it cannot raise on a request input that has no such field.

On the IPC wire format

request_handler reads lora_id defensively rather than directly, and this PR deliberately does not add the field to TokenizedGenerateReqInput.

That struct is a positionally-encoded msgspec.Struct (array_like=True) whose own docstring states that declaration order IS the wire contract — append only. Adding a field changes the tuple the frontend decodes; test_zmq_msgpack.py pins the arity precisely to catch that, and inserting anywhere but the end also shifts existing fields' positions. An earlier revision of this branch did exactly that and the test caught it.

That contract belongs to the front-end, so the field is declared by the LoRA front-end change that also populates it. Until then every request resolves to lora_id = 0 and scheduling is byte-for-byte what it was before this PR.

Relationship to #735

Supersedes #735, which was written against the radix prefix cache that #864 removed. Every core file that PR touched — csrc/resource/kv_prefix_cache/, hybrid_prefix_cache/, radix_tree/, resource/types.h — is gone from main, and tests/check_no_radix_cache_sources.py now hard-fails if those directories or the symbols RadixTree / KVPrefixCache / HybridPrefixCache reappear. This is a rewrite against the hash-chain cache, not a rebase.

Testing

  • 368/368 C++ scheduler tests pass, including 10 new ones across test_lora_namespace.cpp and test_max_loras.cpp.
  • 5/5 Python plumbing tests, against a freshly rebuilt scheduler extension.
  • 102/102 IPC codec and request-construction tests (test_io_struct_codec.py, test_zmq_msgpack.py, test_max_new_tokens_context_cap.py) — the wire-format guard.
  • check_no_radix_cache_sources.py guard passes.
  • pre-commit, ruff (CI's select list), clang-format, clang-tidy (0 diagnostics): clean.

No GPU path is involved in this PR.

@towillwu
towillwu force-pushed the towillwu/lora-scheduler branch from 336066a to b032688 Compare August 7, 2026 22:03
@towillwu
towillwu force-pushed the towillwu/lora-scheduler branch from b032688 to 2c9a86b Compare August 7, 2026 22:12
@towillwu
towillwu changed the base branch from main to towillwu/fix-mla-unused-import August 7, 2026 22:12
@towillwu
towillwu force-pushed the towillwu/lora-scheduler branch from 2c9a86b to 9fe157d Compare August 7, 2026 22:54
Base automatically changed from towillwu/fix-mla-unused-import to main August 8, 2026 01:13
Introduce the scheduler's notion of a LoRA adapter and cap how many
distinct adapters may appear in a single batch.

Request identity:
  - RequestSpec::lora_id carries the front-end's resolved adapter id, or
    empty for a base-model request. The scheduler treats it as opaque: it
    only compares it for equality, so the front-end is free to change how
    it mints the value.
  - Surfaced as Request::LoraId().

max_loras batch cap:
  - SchedulerConfig::max_loras (0 = disabled) bounds the number of unique
    adapters in a batch, because the runtime materializes one adapter slot
    per unique id and exceeding the cap would overrun that fixed pool.
  - buildForwardOperations tracks the adapters already pushed and defers a
    request that would introduce a new one past the cap. It defers rather
    than breaking out of the loop: a later candidate may reuse an adapter
    already in the batch and still belongs in this step. A request whose
    adapter is already present costs no additional slot, and base-model
    requests are never charged.
  - max_loras == 0 means LoRA scheduling is off, not "admit zero
    adapters" -- the cap is simply not applied, so the scheduler behaves
    exactly as it did before this change.

Exposed through the nanobind binding, make_config/make_spec, and a new
--max-loras server flag. request_handler reads lora_id defensively via
getattr: the adapter registry that resolves and attaches it to the request
input lands separately, and until it does every request is base-model.

Tests: tests/cpp/test_max_loras.cpp covers the cap, adapter reuse,
base-model requests, and the disabled case;
test/runtime/test_lora_scheduler_plumbing.py pins the None-to-empty-string
translation at the Python boundary.

Signed-off-by: towillwu <toqywu@gmail.com>
Without isolation, tokens cached under adapter A can be served to adapter
B or to the base model, because a page hash is derived only from its
tokens and the chain before it.

The page hasher already accepts per-page extra_keys for exactly this
purpose. Add ComputeNamespacedPagedHashes, which applies one key set to
every page rather than a per-page list, and thread the request's adapter
id through it. An empty key list is byte-identical to the previous
two-argument form, so base-model requests hash exactly as before and
existing cache entries stay valid.

Keying every page, rather than only page 0:

  Chaining alone would make a page-0 key sufficient, since every later
  page folds in prior_hash. But appendCompletedPageHashes starts a fresh
  chain with an empty prior whenever page_hashes is empty, and that
  happens for a prompt shorter than one page: such a request produces no
  hashes at admission, so the first hash it ever computes comes from the
  incremental path. With a page-0-only key that page would land in the
  base-model namespace. Keying every page makes isolation independent of
  where the chain starts.

AdvancePagedHashes previously neither accepted nor forwarded extra keys,
so it silently dropped them; it now takes namespace_keys and passes them
down. All three hash sites supply the request's keys: admission
(matchPrefixAtAdmission), incremental extension
(appendCompletedPageHashes), and stable-page publication
(publishCompletedPages).

Request::CacheNamespaceKeys() holds the adapter id as a 0-or-1 element
vector so it can be handed over as a span without materializing a
temporary on every hash call.

Tests: tests/cpp/test_lora_namespace.cpp pins that empty keys are
byte-identical to the unnamespaced chain, that adapters diverge from each
other and from the base model, that an incremental extension matches the
one-shot chain, and that a chain starting mid-request -- or at page 0 via
the incremental path -- stays isolated.

Signed-off-by: towillwu <toqywu@gmail.com>
Switch RequestSpec::lora_id from std::string to std::int32_t, with 0
meaning base model instead of an empty string.

This matches the wire format the LoRA front-end already uses. That runtime
keeps three distinct identifiers -- adapter name (user-facing), an integer
registry id (stable, minted by a counter), and a GPU buffer slot (recycled
on evict) -- and it is the integer registry id that travels on the request.
Carrying a string here would have forced the front-end to collapse that
layer, so the scheduler adopts the int instead.

The id stays opaque to the scheduler: it compares ids for equality for the
max_loras cap and renders one to a string for the page hasher's extra_keys.
Rendering happens once per request into lora_hash_keys_, so
CacheNamespaceKeys() still hands out a span without formatting on every
hash call.

request_handler reads the id with getattr rather than directly.
TokenizedGenerateReqInput is a positionally-encoded msgspec Struct
(array_like=True) whose docstring states that declaration order is the wire
contract and that fields are append-only; adding lora_id there changes the
tuple the frontend decodes (test_zmq_msgpack.py pins the arity for exactly
this reason). That contract belongs to the front-end, so the field is
declared by the LoRA front-end change that also populates it, and this
branch only reads it if present.

Base-model requests are unaffected either way: id 0 produces an empty key
list, so their page hashes stay byte-identical to a build without LoRA
support, and the max_loras cap is not applied at all when max_loras is 0.

Signed-off-by: towillwu <toqywu@gmail.com>
@towillwu
towillwu force-pushed the towillwu/lora-scheduler branch 2 times, most recently from 0790734 to f12e16b Compare August 10, 2026 01:23
@qywu
qywu marked this pull request as ready for review August 10, 2026 02:05
@qywu
qywu requested a review from a team as a code owner August 10, 2026 02:05
@towillwu
towillwu force-pushed the towillwu/lora-scheduler branch from f12e16b to 9640571 Compare August 10, 2026 02:08

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f12e16b5f2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

std::vector<std::span<const std::int32_t>> paged_tokens = request->FullPagedTokens(false);
paged_tokens.resize(std::min(paged_tokens.size(), static_cast<std::size_t>(cacheable_pages)));
std::vector<std::string> hashes = ComputePagedHashes(paged_tokens, "");
std::vector<std::string> hashes = ComputeNamespacedPagedHashes(paged_tokens, "", request->CacheNamespaceKeys());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Namespace the published KV-event block hashes

When KV-cache events are enabled and two LoRA adapters process the same token prefix, this creates distinct internal cache keys, but registerKvEventPages in scheduler.cpp still derives the published block_hashes solely from tokens and parent hashes via HashKvBlock. The adapters therefore emit indistinguishable store/removal events, so cache-aware consumers can incorrectly treat one adapter's incompatible KV block as the other's or remove availability while the other block remains; include the adapter namespace in the event hash lineage as well.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — fixed in b6b5c933.

The two hash lineages had diverged: cache keys were namespaced, published block_hashes were not. HashKvBlock now takes the same namespace keys as the page hash and registerKvEventPages passes the request's. Keys are mixed in as a terminal length-prefixed block, so an empty list is skipped and base-model event digests stay byte-identical.

Three tests added: byte-identity when unkeyed, divergence between adapters and vs. base, and that a first-block divergence propagates through the parent chain.

Comment on lines +1096 to +1103
"--max-loras",
metavar="MAX_LORAS",
type=int,
default=ServerArgs.max_loras,
help=(
"Maximum number of distinct LoRA adapters in a single batch. "
"Requests that would exceed it are deferred to a later step. "
"0 disables LoRA scheduling."

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Document the new scheduler option

The user-facing --max-loras option is added without updating the scheduler option table in docs/configuration/server.md or the compatible-parameters documentation, leaving its distinct-adapter semantics and zero sentinel absent from the repository's configuration guide. Add the new option to the relevant documentation as required for changed code.

AGENTS.md reference: AGENTS.md:L13-L13

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 352b19b4.

  • docs/configuration/server.md: added to the scheduler option table, covering the distinct-adapter semantics, that reusing an in-batch adapter never defers a request, that base-model requests are not charged, and that the 0 default disables LoRA scheduling rather than admitting zero adapters.
  • docs/configuration/compatible-parameters.md: listed under "Similar But Not Identical", since the name matches other serving stacks but the zero sentinel does not.

Addresses a Codex review finding on #976.

The prefix cache's page hashes were namespaced per adapter, but the KV
cache event stream carries its own block hash lineage: registerKvEventPages
derived block_hashes from HashKvBlock(tokens, parent) alone. Two adapters
sharing a token prefix therefore published identical block hashes even
though their cache keys differed, so a cache-aware consumer could treat one
adapter's KV block as the other's -- or drop availability for one while the
other's block is still resident. That is the same cross-adapter confusion
this branch exists to prevent, just relocated from the local cache to the
event consumers.

HashKvBlock now takes the same namespace keys the page hash uses, and
registerKvEventPages passes the request's. The keys are mixed in as a
terminal, length-prefixed block, so an empty list is skipped outright and
leaves the digest byte-identical to the un-namespaced form -- base-model
event streams are unchanged.

Tests pin the byte-identity for an empty key list, divergence between two
adapters and against the base model, and that a divergence in the first
block keeps later blocks apart through the parent chain.

Signed-off-by: towillwu <toqywu@gmail.com>
Addresses a Codex review finding on #976; AGENTS.md requires docs updates
for changed code.

  - configuration/server.md: adds --max-loras to the scheduler option
    table, covering the distinct-adapter semantics, that reusing an
    in-batch adapter never defers a request, that base-model requests are
    not charged, and that the 0 default disables LoRA scheduling rather
    than admitting zero adapters.
  - configuration/compatible-parameters.md: lists it under "Similar But
    Not Identical", since the name matches other serving stacks but the
    zero sentinel does not.

Signed-off-by: towillwu <toqywu@gmail.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 352b19b415

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

spec.max_new_tokens = max_new_tokens
# 0 is the base model on both sides of the boundary; tolerate None from
# callers that have not resolved an adapter.
spec.lora_id = lora_id or 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Forward adapter ids before building RequestSpec

When a LoRA-aware request reaches the normal engine path, RequestHandler.handle_generate_request still calls make_spec with only rid and tokens, so this new field always takes the default and every scheduler Request is created with lora_id == 0. That means real adapter requests are treated as base-model requests: per-adapter prefix-cache namespaces collapse together and the max_loras cap never counts those adapters. Pass the resolved request adapter id, e.g. via getattr(recv_req, "lora_id", 0), into this argument before constructing the scheduler spec.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not applicable to this PR — intentionally.

lora_id is not a field on TokenizedGenerateReqInput on this branch ('lora_id' in TokenizedGenerateReqInput.__struct_fields__ is False), so the suggested getattr(recv_req, "lora_id", 0) can only ever return 0, which is already make_spec's default. It would be dead code.

The field cannot be added here: that struct is positionally encoded (array_like=True) and its docstring states declaration order is the IPC wire contract with the frontend. test_zmq_msgpack.py pins the arity for exactly this reason and fails on the change (24 → 25). That contract belongs to the front-end.

So the field is declared and forwarded together in the next PR of this stack (#980):

  • io_struct.pylora_id: int = 0 on TokenizedGenerateReqInput
  • request_handler.pylora_id=recv_req.lora_id

Verified there: a base-model request yields RequestSpec.lora_id == 0, an adapter-7 request yields 7 — so per-adapter namespaces do separate and max_loras does count them.

This PR only provides the scheduler mechanism; with no adapter ids in flight, every request is base-model and scheduling is unchanged, which is the intended state until #980 lands.

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.

1 participant