Skip to content

Data worker: prepare requests on a pool - #1109

Merged
merceod merged 4 commits into
mainfrom
users/atindra/omni-preprocess-pool
Nov 2, 2026
Merged

merceod merged 4 commits into
mainfrom
users/atindra/omni-preprocess-pool

Conversation

@merceod

@merceod merceod commented Nov 1, 2026

Copy link
Copy Markdown
Collaborator

The data worker prepares one request at a time on its own thread. For Qwen3-Omni video that's 600 ms a clip, so at V2T c=4 a request sits behind the clips ahead of it and time in preprocess is most of its TTFT, and the output chunks of running requests wait behind the same thread. This moves media decode and process_prompt onto a pool for a model that says they can overlap.

Change

  • Model.PREPROCESS_PARALLEL (default False) declares that load_image / load_audio / load_video and process_prompt keep no shared state. Qwen3-Omni sets it, no other model does.
  • PreprocessWorkerThread._process_input is split into _start_input (the thread's counters), _prepare_input (decode, processors, prefix keys; reads only what __init__ fixed) and _finish_input (store, kwargs, send). Inline runs them in a row, as before.
  • With a pool, _submit_prepare runs _prepare_input on it and _release_prepared finishes the oldest done request, one per pass, on the thread. Requests reach the conductor in arrival order whichever finishes first. Tensors are still stored and sent from the data worker's thread only.
  • The pool is bounded: _next_inputs takes nothing while it's full, so requests wait in the queue undecoded, and a release waits while the outbox is full.
  • CpuPlan.preprocess_workers sizes it, min(8, cpus left // intra-op threads), 4 on 8 CPUs. preprocess_workers: in the serving config overrides it, 1 turns it off.

Guard and fallback: no declaration, a model with client streams (an append has to follow its request), or one worker means the old inline path.

A request abandoned while it prepares: the API server drops its pending entry before it queues the abort, so _release_prepared sees it as not pending and drops it unsent, as it would one still in the queue. The abort and READS_DONE that went out meanwhile are for a rid the conductor never saw, which it already handles.

Does each stage release the GIL

Stage cost alone and how each scales across threads, 8 pinned cores, torch intra-op 2 (dataworker_cost.py):

input stage ms alone (p50) 2 threads 4 threads
image decode 2.6 2.5x 5.3x
image image_processor 1.7 1.3x 2.3x
audio decode 0.9 2.2x 3.7x
audio feature extractor 3.1 2.0x 3.5x
video load_video (decode, resize) 331 1.8x 2.5x
video video_processor 238 1.9x 3.5x

Decode, resize, the video processor and the feature extractor release the GIL: the 2 and 4 thread columns are throughput against one thread. The one that holds it is the image processor, 2.3x at 4 threads, and it's 1.7 ms. load_video stops at 2.5x because a clip is about 1.1 s of CPU across ffmpeg's and torch's threads, so 4 clips run out of the 8 cores rather than the GIL. A whole video preparation is 1.9x at 2 threads and 2.9x at 4, images 1.6x and 3.1x, audio 2.0x and 3.7x.

On main

#1072 landed while this ran, so the gate below is against #1072 and the branch has since been rebased onto main. One more session per arm on main, same protocol, all arms sharing an Inductor cache that started from one cold compile:

cell main this change TTFT p50 ms main this
A2T c=1 RTFx 36.3 36.1 -0.7% 77 77
A2T c=4 RTFx 71.1 71.7 +0.8% 135 134
A2T c=16 RTFx 159.5 167.2 +4.8% 201 210
I2T c=1 tok/s 200 200 +0.0% 91 91
I2T c=4 tok/s 514 515 +0.1% 135 138
I2T c=16 tok/s 1192 1184 -0.7% 181 176
V2T c=1 tok/s 1.31 1.28 -2.1% 672 701
V2T c=4 tok/s 1.68 3.19 +89.9% 2429 1157
A2S c=1 audio-s/s 12.8 12.5 -2.4% 177 179
A2S c=16 audio-s/s 64.4 63.4 -1.4% 471 481
T2S c=1 audio-s/s 11.3 11.0 -2.7% 122 124
T2S c=32 audio-s/s 76.6 78.8 +2.9% 517 519

Failed requests: 0. The 100-input set is 100/100 identical to main, and matches HF fp32 on 48/100, as main.

Gate against #1072

cell #1072 (s1, s2) this (s1, s2) change TTFT p50/p95 ms #1072 this
A2T c=1 RTFx 35.8, 35.7 35.9, 35.7 +0.0% 76 / 94 76 / 94
A2T c=4 RTFx 68.6, 69.3 69.6, 68.9 +0.4% 136 / 175 136 / 178
A2T c=16 RTFx 161.2, 159.9 162.8, 166.6 +2.6% 202 / 274 203 / 305
I2T c=1 tok/s 198, 199 198, 198 -0.1% 90 / 95 91 / 95
I2T c=4 tok/s 496, 496 498, 499 +0.4% 137 / 173 140 / 179
I2T c=16 tok/s 1181, 1189 1199, 1190 +0.8% 177 / 417 185 / 468
V2T c=1 tok/s 1.30, 1.28 1.29, 1.28 -0.4% 696 / 1306 708 / 1288
V2T c=4 tok/s 1.67, 1.63 3.06, 3.15 +87.8% 2465 / 3313 1222 / 1847
A2S c=1 audio-s/s 12.4, 12.5 12.4, 12.5 -0.4% 178 / 187 179 / 187
A2S c=16 audio-s/s 62.9, 66.1 63.2, 64.8 -0.8% 462 / 739 467 / 790
T2S c=1 audio-s/s 11.0, 11.1 10.9, 11.0 -0.6% 123 / 129 125 / 129
T2S c=32 audio-s/s 76.6, 78.9 77.2, 77.9 -0.3% 525 / 784 540 / 796

Failed requests: 0 of 7600.

With #1072 at the base the gate cells are V2T c=4, which goes from 1.65 to 3.11 tok/s and halves TTFT, and everything else, which doesn't move beyond the 1-2% between sessions. I2T c=16 TTFT p50 is 8 ms up (177 to 185) and p95 51 ms up (417 to 468).

V2T c=4 TTFT is 1222 ms against 708 at c=1, so it doesn't reach c=1. What's left isn't this thread: with the pool, recv -> preprocess done is 877 ms against 531 at c=1, and ingest to first chunk 302 ms against 152, where clips queue at the encoder and the Thinker. A clip is 1.5 s of CPU, so four at once are about all of the 8 cores. The pool's size and ffmpeg's threads barely move it:

workers ffmpeg threads wall s TTFT p50 ms
2 8 18.6 1417
3 8 15.3 1161
4 (default) 8 16.2 1167
6 8 14.7 1055
4 4 15.1 1112
4 2 15.1 1079

On a server that hasn't seen the clips vllm-omni is at 1011 ms and 3.66 tok/s here. The 592 ms it posts when the same clips ran at c=1 first comes from its processor cache.

API process CPU per request:

cell #1072 ms this ms
V2T c=1 1405 1415
V2T c=4 1410 1448
I2T c=1 168 175
I2T c=16 83 93
A2T c=1 47 53
A2T c=16 33 45

That's up 7-12 ms a request on the small inputs. It moved no wall clock in any cell, and I haven't established where it goes.

Correctness

Tests

  • ruff check .: passes.
  • CPU Core (python -m pytest test/modular test/distributed test/chatterbox test/kokoro test/omnivoice test/sampling_test mstar/model/*/tests benchmark/worker_phases, xdist -n 8, OMP_NUM_THREADS=2): 9261 passed, 856 skipped, 49 failed. The same 49 fail on main, compared by id: test_loop_output_ownership (32), test_send_registration_failure (5), test_wan_vae_decode (4), test_z_image_native_equivalence (3), others 5. test_flex_attention_resource, test_waypoint_components and test_waypoint_dit segfault on this box on main too, so they run apart and aren't counted.
  • New: test/modular/test_data_worker_prepare_pool.py, 20 tests: order, one release a pass, a full pool and a full outbox, failure in its slot, an abandoned request, the fallbacks, sizing.
  • GPU, on 2 x B300 SXM6 (sm_103), torch 2.12.1+cu132, CUDA 13.2 driver 595.91.07: the gate above, two sessions per arm interleaved with Qwen3-Omni: encoders on ragged attention, no flash_attn #1072 (GPU 0+1, server on CPUs 96-103, client on 104-107), the 100-input end-to-end set, A2T WER on the 200 clips, T2S on 200 sentences and the seeded 20.
  • The bitwise check above, 450 inputs through a pool of 4.

Hardware: 2 x B300 SXM6 (sm_103), torch 2.12.1+cu132, FlashInfer 0.7.2, -O2, server on 8 cores, client on 4, prefix caching off. Two server sessions per arm, interleaved.

Not in this PR

  • Other models. A model with heavy image or audio preprocessing can set the flag once its loaders are checked.
  • The store of a prepared tensor stays on the thread.

@vasilevklart vasilevklart left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM. 2 nits:

Comment thread mstar/utils/cpus.py
return max(1, min(4, self.left // 4))

@property
def preprocess_workers(self) -> int:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: Sized from the default thread count, so a config with a higher preprocess_torch_threads oversubscribes the cpu, we can divide by the count _set_preprocess_threads

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done: CpuPlan.prepare_workers(threads) divides by the count the process runs, and the data worker passes torch.get_num_threads(), which _set_preprocess_threads has set by the time the API server is built. So preprocess_torch_threads: 8 with 8 CPUs left gives one worker (inline), and null (torch's default, every core) does too. preprocess_workers keeps the default-count value for the plan. Tests: test_a_higher_thread_count_shrinks_the_pool, test_the_pool_follows_the_threads_this_process_runs.

appends handled behind the request, so anything else runs inline."""
if not getattr(self.model, "PREPROCESS_PARALLEL", False) or self._client_edges:
return 1
workers = (self._model_config or {}).get("preprocess_workers")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: preprocess_workers:, should we also mention it in serving.rst table next to tp_async_sched?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Added a preprocess_workers row under tp_async_sched, with a pointer to preprocess_torch_threads and adding_models.rst.

A config's preprocess_torch_threads above the plan's default put the pool over the CPUs left. The pool now divides by torch's thread count, which is set before the API server is built, and serving.rst gets a preprocess_workers row.
merceod and others added 2 commits November 1, 2026 18:26
The pool now divides by torch's thread count, so the module fixes it at 2: 8 usable CPUs give 4 workers on any runner.
@merceod
merceod merged commit 6bef799 into main Nov 2, 2026
6 checks passed
@merceod
merceod deleted the users/atindra/omni-preprocess-pool branch November 2, 2026 02:51
dghosef added a commit that referenced this pull request Nov 2, 2026
Since #1109 a model with PREPROCESS_PARALLEL keys prompts on the prepare
pool, so the docstring names it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dghosef added a commit that referenced this pull request Nov 2, 2026
…rf/split-4-shared

main's #1109 prepares requests on a pool (Model.PREPROCESS_PARALLEL,
preprocess_workers in the serving config, _prepare_pool / _release_prepared),
the same thing this branch's preprocess pool did. main's is kept as the one
mechanism, and this branch's copy is deleted: Model.PREPROCESS_WORKERS, the
preprocess_workers() startup check and CPU cap, _prep_pool, _take_new_input /
_take_input / _publish_waiting, the queue gauge's waiting_inputs, their docs
rows (serving, adding_models, metrics) and their tests (the pool cases in
test_data_worker_wakeup.py and test_serving_metrics.py). _positive_int goes
back to entrypoint.py.

Kept from this branch's data-worker work: the read_time / postprocess_s
profiling of outputs (EncodedOutput, _encode's timing, postprocess_inline's
per-row share), and the message thread's poll inside its try.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dghosef added a commit that referenced this pull request Nov 2, 2026
V-JEPA2 opts into main's prepare pool (#1109): VJepa2Model declares
PREPROCESS_PARALLEL (the AC model inherits it), and the measured pool sizes
move from the classes' PREPROCESS_WORKERS (8 and 4) to the serving configs
as preprocess_workers (configs/vjepa2.yaml 8, configs/vjepa2_ac.yaml 4),
which main's data worker takes as given. The docs rows for the deleted
PREPROCESS_WORKERS go with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dghosef added a commit that referenced this pull request Nov 2, 2026
…config (3/9) (#820)

* [Distributed] One rank vote: CommGroup.agree and JointGroups.agree

Six places reduced a per-rank value across the ranks, each its own way:
all_reduce.py's _all_ranks_agree and ar_fusion.py's _all_ranks (a gloo MIN
over one group), the graph runner's agree_across_ranks, and
KVManager.post_warmup_validate and Worker._verify_tp_async_sched_agrees (a
device all_gather per TP/SP axis read back to the host), and the worker's
read-failure vote (all_reduce.py's flagged_on_any_rank, a gloo MAX). They now all go
through CommGroup.agree, an all-reduce of a CPU int64 tensor over the group's
cpu_group (the gloo twin, or the gloo side of XPU's mixed group), and
JointGroups.agree, which runs it over the TP x SP block. None of these votes
syncs the device any more; the two equality checks vote [v, -v] with MIN and
report the min and max.

agree raises ValueError for a multi-rank group with no cpu_group rather than
reducing over the default world group. agree_across_ranks is gone: a
piecewise runner built without a joint group gets a trivial one, so its votes
and barriers need no None guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* [Worker] TP lockstep: the admit fence waits out a slow admit

_await_admit_settled now waits for a TP lockstep step (tp_seq >= 0) however
long its admit and plan take, warning every 10 s, instead of giving up after
10 s. This applies to every TP lockstep step, with or without
MSTAR_TP_ASYNC_SCHED: a teardown landing inside the admit changes what it
decides on that rank alone, and the ranks split. A resource admit may
legitimately take longer than 10 s; the LoRA pool PR above this one is the
first such resource (its admit can wait on a peer's adapter read). Any other
step is still given up on after one interval.

The GPU thread now sets launch_started_event from _execute_on_gpu_thread's
outermost finally. It used to be set in a finally that a step whose plan
future raised never reached, which with an unbounded wait would hang the
fence. The wait also ends once the step's future is done: a GPU executor
that fails to start completes its queued futures without running them, so
the event is never set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* [Resources] LoRA adapter pool: per-request adapters in device slots

LoraSpec / LoraAdapterPool: a fixed set of device slots (slot 0 the zero
adapter), a pinned host tier read on the pool's own thread, and per-step
addressing that groups each step's tokens by slot for the kernels (Triton
shrink/expand/apply custom ops; a torch reference on CPU). Slots are held
for a request's lifetime; a request that cannot get one is not ready, so a
full pool is scheduler backpressure. Under TP every rank decides slots
itself from state the leader's step order fixes, and an adapter read is
agreed across the instance before it is installed.

The engine brackets each planned step's preprocess and forward in
StepRunner.compute_scope, so a delta called outside its step raises, and
captures every bucket of a node serving adapters a second time under
LoraBaseOnly, replayed by steps with no adapter row. Under TP the
pool's load agreement is JointGroups.agree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* [API server][Conductor] Serve the serving config's LoRA adapters

A resources: lora: block turns LoRA on. The OpenAI model field (every
route) or a lora_adapter kwarg selects an adapter, as vLLM reads it; an
unserved name is a 400, and /v1/models lists the adapters. The API server
checks every configured adapter at startup and pins its weights' digest,
which the conductor hands every worker; the conductor resolves resource
specs with the YAML's overrides applied (serving_resources), as the
workers do, and opens each request with its LoraReqConfig. Prefix-cache
keys of a request on an adapter chain from a root naming its weights.
The Rust frontend refuses a LoRA config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* [Diffusion] Read static LoRA merges through the shared checkpoint parser

The diffusion merge's checkpoint parsing moves to
mstar/engine/resources/lora/checkpoint.py, where the adapter pool reads
adapters too; LoraSpec there becomes LoraFile, and the loader's
_apply_stacked is public as apply_stacked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* [Docs][Benchmark] Per-request LoRA adapters: the resource, and its GPU checks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* [Resources] LoRA: refuse a Kohya DoRA adapter as PEFT's is

A Kohya-format DoRA checkpoint carries its magnitudes as `dora_scale` keys, which the
shared checkpoint parser skipped, so the adapter was served (and statically merged into
diffusion models) as plain LoRA, silently without its per-row rescaling. It is now refused
by name like PEFT's `lora_magnitude_vector`. With nothing left to skip, the parser's
None-for-skip path and its callers' checks are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* [Conductor] validate_deployment: LoRA declared beside the model's resources, autocast_dtype checked

The pass declared the model's resources and its LoRA specs in one call, so a
mistake in the `lora:` block (an unknown target, a model that serves no
adapters, a bad value sized onto a second LoRA spec) skipped every
`resources:` check and was labelled as get_node_resources failing. They are
now separate steps: a declaration problem is its own line, and the second
spec's sizing is left to the override step, which reports a bad value as
`resources.lora: ...` with the rest. A `lora:` value that is not a mapping is
refused as such, not as an unknown resource.

LoraSpec.apply_yaml_overrides checks every value before it sets any, so a
refused override leaves the spec as declared rather than holding the refused
value for the next copy of the spec to raise again.

`autocast_dtype` was refused only by each worker after spawn; the pass now
parses it with the worker's own parser (parse_autocast_dtype).

The API server read `resources:` before the conductor's pass for every
deployment; a non-mapping block, or a `lora:` value that is not a mapping, now
turns no LoRA on there (lora_enabled, served_lora_adapters, lora_specs), and
adapter_prefix_roots checks for LoRA before it reads the KV specs, so a
deployment without LoRA leaves the block to the pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test_lora_pool: the KV control asserts the warning, not a throttle

The control expected the 'No eviction candidate' warning once over three
calls, but the warning has never been rate-limited, so CPU Core failed
with 3 == 1. Assert that a KV refusal looks for a victim and warns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* [Worker] TP lockstep: the admit fence waits out a slow admit

_await_admit_settled now waits for a TP lockstep step (tp_seq >= 0) however
long its admit and plan take, warning every 10 s, instead of giving up after
10 s. This applies to every TP lockstep step, with or without
MSTAR_TP_ASYNC_SCHED: a teardown landing inside the admit changes what it
decides on that rank alone, and the ranks split. A resource admit may
legitimately take longer than 10 s; the LoRA pool PR above this one is the
first such resource (its admit can wait on a peer's adapter read). Any other
step is still given up on after one interval.

The GPU thread now sets launch_started_event from _execute_on_gpu_thread's
outermost finally. It used to be set in a finally that a step whose plan
future raised never reached, which with an unbounded wait would hang the
fence. The wait also ends once the step's future is done: a GPU executor
that fails to start completes its queued futures without running them, so
the event is never set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* lora_adapter is an engine kwarg: main's ignored-params log no longer warns on every LoRA request

Main now logs the model_kwargs a model does not read; lora_adapter is read by
the conductor for every model, like seed and prefix_cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* submit_request: check the generation kwargs before resolving the LoRA adapter

Main's check runs first on a server with no state (pinned by
test_submit_request_refuses_a_bad_seed_before_registering); the adapter lookup
reads served_models, so it goes after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test_lora_prefix_keys: the fake model gives main's data worker its partition topology

Main's PreprocessWorkerThread reads get_partition_topology() for client
streams at construction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ENGINE_KWARGS names LORA_ADAPTER_KWARG rather than repeating it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* warmup_compiles runs outside a planned step: say a LoRA delta refuses it

Main's new hook (#927) runs after capture with no compute scope; the LoRA docs
said the engine brackets every forward it runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* warmup_compiles: only an eager LoRA delta refuses a call outside a step

The refusal is skipped while dynamo traces, so say a node serving adapters runs
no adapted linear in warmup_compiles rather than that the delta refuses it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Worker: release the admit fence on the step's completion, never give up on it (R1.2, R1.4, R1.5, R1.7)

One release for "the step ended without launching": `_submit_gpu_step`
sets the launch event when the step's future completes, run or not, in
place of the outer `finally` and the `future.done()` poll. The GPU
thread's cleanup scope now starts at the top of the body, so a raising
plan future also releases the plan thread and publishes.

The fence waits however long any step's admit takes, warning every
`_ADMIT_FENCE_WARN_S` (renamed: it no longer times out). The give-up for
non-lockstep steps bought nothing, since the main thread blocks on that
step's result later in the same pass. The docstring now says what is
fenced: every admit, with later per-request rows planning after it lifts.
The fence test no longer depends on timing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Worker: correct the plan thread's commit-wait comment (R1.6)

A lockstep admit can now legitimately run past 10 s, so the timeout no
longer means the GPU thread died; it skips the pre-plan and exec plans
N+1 inline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test: a step the GPU thread never runs still lifts the admit fence (R2.1)

The fence now waits on the launch event with no limit, and the done-callback
in Worker._submit_gpu_step is what sets it for a future that fails without
running (BrokenThreadPool from a raising initializer). Pin that: the existing
plan-raise case passes even with the release moved back into the function body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Docs/tests: drop stale spec_launch_started, de-parametrize the fence test (R2.2, R2.3)

The async-worker skill named a local the fence change deleted; it now names the
step's launch_started_event. The fence no longer reads tp_seq, so its test keeps
one lone-rank case with a docstring that doesn't rest on lockstep. Rewrap the
edited admit-failure docstring.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: wait for a slot at the runner's admission, not in a queue of the pool's own (R1.1)

The pool now answers Resource.ask / reserve / unreserve / grew_since, as
RecurrentStatePool does: a request on an adapter waits in the runner's
Admission, all or nothing with KV and the other resources that ask, so it holds
no KV pages (or GDN slots) while it waits for a slot, and its node's readiness
check parks instead of being rescanned every pass. A reservation counts slots
(as the claims did) and pins none, so slot picks still follow from the slot
table alone and TP ranks stay in lockstep with no journal; only the deciding
rank (single GPU, or a lockstep leader) reserves. A slot is reserved only once
the adapter's host copy is in, so the admit never waits for a read.

Starvation is Admission's head-only rule: requests behind a cold adapter wait,
so the held slots drain. Deleted: admit_retrieve's screen, the claims, aging,
the drain-slot choice and the slot_wait_steps knob.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: guard deltas by the pool's own plan, not an engine compute scope (R1.4)

A delta called outside a planned step only needs the pool to know it has no
plan: it now clears its addressing at commit and rollback, and a delta called
eagerly with none refuses. That deletes Resource.enter_compute/exit_compute,
StepRunner.compute_scope, the engine's preprocess/forward split and the three
graph-runner wraps, so engine.py, runner.py and base.py read as on main.

The compile check now runs first, so a traced forward installs no guard on the
pool's state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: know an adapter is empty for a pool from its header, before any admit (R1.6)

A pool learned that an adapter has nothing for its targets only when an admit
read it, so the first request on a text-only adapter waited at a vision pool for
a slot it would never take, and its steps never took the base-only capture. The
pool now reads it from each served file's header at startup (and from a
runtime load's read), the same on every rank, skips preloading such adapters,
and runs_adapters counts only adapters with something for the pool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: the host tier holds what the ingests named, not what reads landed (R1.3)

Each rank decided its host tier's drops when a read landed, against the
requests still waiting at that moment, so the tiers diverged across a lockstep
instance once more adapters were in rotation than cpu_capacity. The leader
then reserved on its own copy while a follower's read had only just started,
and the follower's admit held the whole instance for the read, digest and
pinning.

LoraAdapterStore.hold now decides membership at ingest, in ingest order, the
same on every rank, and put only fills a held entry; an adapter a live request
names is never dropped. A runtime load keeps its validated copy until the first
request holds it, so it is not read twice. The eviction-time re-read and
_wanted are gone: a reserved row's adapter stays held.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: every device runs the custom ops; delete the two extra backend classes (R1.7)

make_backend returned TorchLoraBackend off CUDA, which called the reference
directly, so torch.compile on another accelerator traced its searchsorted /
scatter_ / gather instead of taking one opaque node. It now returns
LoraOpsBackend everywhere and imports triton_backend only to register the CUDA
kernels; TorchLoraBackend and the empty TritonLoraBackend are gone. "torch"
still selects the reference kernel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Delete the mstar.model.components.lora re-export shim (R1.10)

It re-exported mstar.engine.resources.lora.checkpoint under a second import
path; FLUX.2 and the diffusion LoRA tests now import from checkpoint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: check a served adapter from its header per spec, digest it once (R1.11)

check_adapter_file ran a full worker read (fp32, padded to max_rank, sharded,
SHA-256) once per LoRA spec, though the digest depends only on the file and how
the spec converts it. It now checks each spec from the header (inspect_adapter,
which covers the same refusals: no layers, rank, unmapped modules, misfits) and
digests the file once per distinct convert_keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Docs: the FLUX.2 static merge refuses a DoRA adapter (R1.12)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* OpenAI chat: name the adapter a lora_adapter kwarg picked in the response (R1.13)

The response renamed model only when the model field named an adapter; one
picked by the lora_adapter kwarg (extra_body) answered with the base model's
name. It now names whatever adapter ServedModels.select resolved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA admission: one reserve/unreserve helper; a base request waits behind a cold one (R1.1)

_take and rollback reuse _reserve / _unreserve instead of repeating the
counting. The KV-hold test now states the queue order main's admission gives:
a base request asking KV after a request that waits for a slot waits behind it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test: the base-only choice no longer meets per-rank empty-adapter reads (R1.6)

An adapter's emptiness now comes from its file's header on every rank, so the
case the test injected (one rank having read an adapter as empty) can't arise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test: the pool counts live adapter requests in _live (R1.3)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: reserve a slot for a request only once its adapter's host copy is in (R2.2b)

reserve() let a row through without its host copy when its adapter was resident in
a slot or another row had reserved it. A reservation pins no slot, so a miss admitted
first could evict that adapter, and the row's admit then waited on the GPU thread
(every rank, under TP) for the re-read its ingest had started. Reserve only once the
copy is in, or its read failed; a request on a resident adapter whose copy the tier
dropped waits at admission for that re-read instead.

Test: test_lora_pool::test_a_request_on_a_resident_adapter_waits_for_its_host_copy
(fails before: r is admitted at once on the resident shortcut). The lockstep module
now shares test_lora_pool's settle_reads helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: no rank waits on a read once a load has failed (R2.2b)

A row reserved because its adapter's read failed is meant to fail at its admit. A
request on the same adapter ingested before that admit started a retry, and the admit
then waited for the retry on the GPU thread: the leader for its own, and under TP a
follower whose read had failed too, while the leader waited in the agreement.

The leader no longer retries a failed read while a row reserved on the failure waits
for its admit (only the deciding rank reserves). And the load agreement is taken on
where each rank's copy stands (failed < still reading < in, the lowest decides) before
any rank waits: a rank still reading waits only when no rank has failed, and the ranks
then agree the outcome as before. The pool's agree now takes the lowest of an int
across the instance; one collective per installed adapter as before, a second only
when a rank was still reading.

Test: test_lora_tp_lockstep::test_no_rank_waits_on_a_retry_once_a_load_has_failed
(before: both ranks wait out the gated retry; with only the agreement change too; with
only the _hold change the follower waits alone and the barrier breaks).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: a queued request whose adapter read failed fails at once, not after a slot drains (R2.7)

reserve() checked the free slots before the read error, so a request queued while its
adapter was read, whose read then failed, sat at the head of admission until a slot
drained (on one GPU holding back every newer request), only to fail at its admit.

It is now let through at once, slots or not. An admit that finds no slot for such a
row fails it instead of refusing the step (which would refuse every step it rides in
until a slot drained): the full-pool refusal first agrees across the lockstep instance
whether that adapter's read failed on any rank. Every rank reaches that refusal for the
same miss, since all pick from the same slot table. The review's simpler fix (only
reorder reserve's checks) would have reached the full-pool refusal with no slot free.

Test: test_lora_pool::test_a_request_whose_read_fails_fails_without_waiting_for_a_slot
(before: admit_waiting lets nothing through while a1 holds the slot).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: a runtime load keeps no copy of the adapter outside the host tier (R2.3, R2.9)

load_adapter kept its read in _loaded until a request named the adapter, a pinned copy
per adapter, pool and rank with no bound: registering many adapters up front pinned
gigabytes of host memory that cpu_capacity was meant to cap. The load now checks the
file, pins its digest and drops the read; the first request on the adapter reads it
again as it is ingested, on every rank at that ingest, and waits at admission rather
than on the GPU thread. Costs one extra read per adapter loaded at runtime. _loaded is
deleted, and _hold reads in whatever it newly holds (or retries).

Also rewraps load_adapter_async's docstring, which had a broken line (R2.9).

Test: test_lora_pool::test_a_runtime_load_keeps_no_copy_until_a_request_names_it
(before: all three loads' copies stay alive).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: a delta refuses on another thread than the one that planned its step (R2.4)

The delta guard refused only while no step was planned. With the async worker (the
default) the main thread runs step N+1's prepare_inputs while the GPU thread is between
step N's plan and commit, so a delta called there ran against step N's rows, though the
error text and the docs name prepare_inputs as the call that refuses. The pool records
the planning thread in plan (a step's forward runs on it, a capture's included), and an
eager delta on any other thread refuses too. Docs and the error text say so.

Test: test_lora_graphs::test_a_delta_runs_only_between_its_steps_plan_and_commit gains
the cross-thread case (before: the delta on another thread runs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: ask() does not ask again for a row it has already reserved (R2.5)

Admission asks each node separately. For a pool serving two nodes, a request admitted
on the first and asked on the second before its first admit was reserved twice: the
second overwrote _reserved[rid] and left _reserved_count one count too high for good,
a phantom slot in _spare_slots. ask() now answers None for a reserved row, as
RecurrentStatePool.ask and KVManager.ask do. Unreachable today: every pool in the
stack serves one node.

Test: test_lora_pool::test_a_request_asked_again_by_a_second_node_is_reserved_once
(before: r2 waits on the phantom count once r1 is gone).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: admitting a reserved row does not wake the waiting head (R2.8)

_take gave the row's reservation back through _unreserve, which bumped _changes, so
every pass that admitted any LoRA row re-ran the waiting head's whole reservation, KV's
reserve/unreserve included, though turning a reservation into a hold frees nothing: the
slot it counted is now held instead. Only a row placed on the zero slot (its adapter
found empty at the admit) frees a count, and only that take bumps.

No test: no behaviour changes but the retries skipped (tens of microseconds a pass).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: say what backend "torch" does; the GPU kernel test registers Triton (R2.6)

Since every device runs the custom ops, "torch" only skips importing triton_backend,
and kernel registration is process-wide: once anything has imported it, a pool set to
"torch" runs Triton too. The config comment, make_backend's docstring and the docs said
"torch" forces the reference; they now say it registers no Triton kernel.

test/integration/test_lora_triton_gpu.py imported only backend and ops, so run alone it
compared the reference op with the reference function. It now imports triton_backend.
GPU-unverified: run `pytest test/integration/test_lora_triton_gpu.py -v` on a CUDA box.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* OpenAI chat: resolve the adapter once, before submitting (R2.9)

The chat handler resolved the request's adapter again after submit_request had, and
outside the 400 mapping lora_kwargs gives: had the adapter stopped being served between
the two calls (a runtime unload, #823), it raised after the request was submitted. The
handler now resolves it with lora_kwargs before submitting and passes the result on, so
the response names the adapter the submission carries. The router and Rust-parity test
stubs bind APIServer.lora_kwargs, as they bind open_result_stream.

Also reflows LoraSpec.apply_yaml_overrides' docstring, which ran past the file's width.

No test: at this PR adapters are not unloaded at runtime, so the two resolutions cannot
differ; test_lora_api's route test covers the response naming the adapter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Docs: LoRA admission holds back newer requests on one GPU; host tiers can differ per rank (R2.1, R2.2a)

R2.1: joining the runner's admission means a request waiting for a LoRA slot, or for
its adapter's host copy, is first in KV's queue too on one GPU (KV asks for every
request there), so it holds back every newer request on its node, base-model ones
included, until a slot drains or the read lands. Under TP KV does not ask and base
traffic flows. Letting a request pass a head that is short only on resources it does
not ask would be a change to Admission's rules for every resource, so it is left for a
decision of its own; the docs now state the behaviour and when it does not arise.

R2.2a: the comments and docs said the host tier follows from the ingests alone, so a
follower's read always starts at the leader's ingest. A tier also keeps what a live
request names, and a follower takes removes from rank 0 and ingests from the conductor,
unordered against each other: a remove that races an ingest can leave a follower without
a copy rank 0 kept, and the next request on that adapter waits there for a whole read.
No cheap exact fix (it needs ingests ordered against teardowns across the instance, or
replicated tier membership); the pool, store and docs now say so, and the agreement
before any wait is described as it now runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: a request on a resident adapter whose read failed still counts a slot (R2.7)

8107c82 let every row whose adapter read had failed through admission at once, slots
or not. A row whose adapter is still resident in a slot may yet run on it, so letting it
skip the count could promise one slot to two rows, and their admit then hit the backstop
refusal. Only a row whose adapter failed to read and is in no slot, which its admit fails
whatever the slots, is let through now, as the review's fix said; one on a resident
adapter is counted like any other.

Test: test_lora_pool::test_a_request_on_a_resident_adapter_whose_read_failed_still_counts_a_slot
(before: r is admitted beside y with the one slot already counted for y).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: wake a request waiting on an adapter that a miss evicts (R3.2)

A request counted on a resident adapter whose re-read failed waits on the
pool's free slots. When a miss's install evicts that adapter, nothing moved
the pool's change counter (the evicting rows' takes no longer do), so the
request waited for an unrelated slot release before it was let through to
fail. The eviction now bumps the counter, once per eviction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: agree every miss's copy in one collective, failing failed reads before any slot is handed out (R3.1, R3.3a/c/e)

A row let through on a failed read is reserved slots or not, so inside one
admit its miss could take the last free slot ahead of a row that was counted
on it; that row's step was then refused by the full-pool backstop. The admit
now picks a candidate slot for every miss from the slot table at its start,
agrees all the misses' copy states in one MIN collective (joint.agree takes
the list as is), fails every adapter that failed on any rank before handing
out a slot, then refuses if the misses left outnumber the slots, and only
then waits for a follower's read (and agrees once more). _refuse_full and
_load_miss are gone; an admit makes one collective with misses (two when a
rank was still reading) instead of one per miss plus one on a full pool.

ask shares _load_failed. Docs give the new collective count; the two-miss
teardown test describes the current path, and its control admits row by row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: say when _hold skips a failed read's retry (R3.3d)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA pool: read adapters on mstar.utils.threads.ThreadPool

Main bans ThreadPoolExecutor under mstar/ (ruff TID251): a pool started
from a pinned thread would share its core. ThreadPool starts its reader
on the process mask.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: follow main's signatures in the tests; check KV fit on serving specs

check_kv_pools_fit read the model's own specs and applied the YAML to them,
so a LoRA deployment's `lora:` block was an unknown resource and the check
returned without looking. It reads serving_resources, as every other spec
reader does.

The LoRA tests follow main: _kv_page_sizes keys only the caches some
rank opens (node_groups, a named checkpoint), StepRunner.plan takes a
phase, and _try_offload_cold_request takes the walk and reads
scheduler.arrival.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Piecewise capture: refuse a LoRA step in a one_row_per_step region

A one_row_per_step region (#977) declares and replays its step as one row
named by the step's first request, so a LoRA pool planned in it would give
every request's tokens that request's adapter. No model combines the two
today; prepare_for_capture now refuses it with a ValueError naming the region
and node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Nemotron duplex: check the session cap on the serving specs

Resolve the specs with serving_resources, as check_kv_pools_fit does, rather
than the declaration with the YAML overrides applied by hand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA serving: report a bad lora: key on a second spec as a ValueError

size_lora_specs applies the lora: block to the LoRA specs keyed apart from
lora before apply_yaml_overrides runs, and let a key no spec takes escape as
a TypeError. Readers of the serving specs (check_kv_pools_fit) leave a
ValueError to validation, so a typo on a two-spec model crashed them instead.
It now converts the TypeError as apply_yaml_overrides does for the first spec,
and validation's suppress around it needs only ValueError.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA prefix roots: reads come from the request-preparation threads

Since #1109 a model with PREPROCESS_PARALLEL keys prompts on the prepare
pool, so the docstring names it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
dghosef added a commit that referenced this pull request Nov 2, 2026
* [Distributed] One rank vote: CommGroup.agree and JointGroups.agree

Six places reduced a per-rank value across the ranks, each its own way:
all_reduce.py's _all_ranks_agree and ar_fusion.py's _all_ranks (a gloo MIN
over one group), the graph runner's agree_across_ranks, and
KVManager.post_warmup_validate and Worker._verify_tp_async_sched_agrees (a
device all_gather per TP/SP axis read back to the host), and the worker's
read-failure vote (all_reduce.py's flagged_on_any_rank, a gloo MAX). They now all go
through CommGroup.agree, an all-reduce of a CPU int64 tensor over the group's
cpu_group (the gloo twin, or the gloo side of XPU's mixed group), and
JointGroups.agree, which runs it over the TP x SP block. None of these votes
syncs the device any more; the two equality checks vote [v, -v] with MIN and
report the min and max.

agree raises ValueError for a multi-rank group with no cpu_group rather than
reducing over the default world group. agree_across_ranks is gone: a
piecewise runner built without a joint group gets a trivial one, so its votes
and barriers need no None guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* [Worker] TP lockstep: the admit fence waits out a slow admit

_await_admit_settled now waits for a TP lockstep step (tp_seq >= 0) however
long its admit and plan take, warning every 10 s, instead of giving up after
10 s. This applies to every TP lockstep step, with or without
MSTAR_TP_ASYNC_SCHED: a teardown landing inside the admit changes what it
decides on that rank alone, and the ranks split. A resource admit may
legitimately take longer than 10 s; the LoRA pool PR above this one is the
first such resource (its admit can wait on a peer's adapter read). Any other
step is still given up on after one interval.

The GPU thread now sets launch_started_event from _execute_on_gpu_thread's
outermost finally. It used to be set in a finally that a step whose plan
future raised never reached, which with an unbounded wait would hang the
fence. The wait also ends once the step's future is done: a GPU executor
that fails to start completes its queued futures without running them, so
the event is never set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* [Resources] LoRA adapter pool: per-request adapters in device slots

LoraSpec / LoraAdapterPool: a fixed set of device slots (slot 0 the zero
adapter), a pinned host tier read on the pool's own thread, and per-step
addressing that groups each step's tokens by slot for the kernels (Triton
shrink/expand/apply custom ops; a torch reference on CPU). Slots are held
for a request's lifetime; a request that cannot get one is not ready, so a
full pool is scheduler backpressure. Under TP every rank decides slots
itself from state the leader's step order fixes, and an adapter read is
agreed across the instance before it is installed.

The engine brackets each planned step's preprocess and forward in
StepRunner.compute_scope, so a delta called outside its step raises, and
captures every bucket of a node serving adapters a second time under
LoraBaseOnly, replayed by steps with no adapter row. Under TP the
pool's load agreement is JointGroups.agree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* [API server][Conductor] Serve the serving config's LoRA adapters

A resources: lora: block turns LoRA on. The OpenAI model field (every
route) or a lora_adapter kwarg selects an adapter, as vLLM reads it; an
unserved name is a 400, and /v1/models lists the adapters. The API server
checks every configured adapter at startup and pins its weights' digest,
which the conductor hands every worker; the conductor resolves resource
specs with the YAML's overrides applied (serving_resources), as the
workers do, and opens each request with its LoraReqConfig. Prefix-cache
keys of a request on an adapter chain from a root naming its weights.
The Rust frontend refuses a LoRA config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* [Diffusion] Read static LoRA merges through the shared checkpoint parser

The diffusion merge's checkpoint parsing moves to
mstar/engine/resources/lora/checkpoint.py, where the adapter pool reads
adapters too; LoraSpec there becomes LoraFile, and the loader's
_apply_stacked is public as apply_stacked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* [Model] LoRA from a model's own modules

The shared linears (column/row/QKV/merged parallel, FusedColumnLinear,
the delta-net's fused in-projection, VocabParallelEmbedding, plain
nn.Linear and patch convolutions) describe their own LoRA target. Binding
swaps a module to its family's twin class, which adds the delta before the
module's own gather or all-reduce; an unbound module keeps its class and
its forward. The parallel linears' forwards are split into local compute
and gather/reduce for that.

module_lora_spec builds a node's LoraSpec by walking its weights root
under the loader's CheckpointRules, so adapters are placed as weights are;
NodeSubmodule.bind_node_resources binds the placed modules, and
declared_step adds the pools' LoraStep for a node with lora_segment_rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* [Docs][Benchmark] Per-request LoRA adapters: the resource, and its GPU checks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* [Docs] Declaring LoRA from a model's modules

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* [Resources] LoRA: refuse a Kohya DoRA adapter as PEFT's is

A Kohya-format DoRA checkpoint carries its magnitudes as `dora_scale` keys, which the
shared checkpoint parser skipped, so the adapter was served (and statically merged into
diffusion models) as plain LoRA, silently without its per-row rescaling. It is now refused
by name like PEFT's `lora_magnitude_vector`. With nothing left to skip, the parser's
None-for-skip path and its callers' checks are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* [Model] LoRA binding: refuse a spec not built by module_lora_spec; widen names outside a stack

A pool whose spec was not declared with module_lora_spec would bind
nothing, so the node would get no LoraStep and no base-only captures
without saying so; binding now refuses it. A role outside every layer
stack has no stack prefix, so the second name widening crashed on None
instead of naming the clash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* [Conductor] validate_deployment: LoRA declared beside the model's resources, autocast_dtype checked

The pass declared the model's resources and its LoRA specs in one call, so a
mistake in the `lora:` block (an unknown target, a model that serves no
adapters, a bad value sized onto a second LoRA spec) skipped every
`resources:` check and was labelled as get_node_resources failing. They are
now separate steps: a declaration problem is its own line, and the second
spec's sizing is left to the override step, which reports a bad value as
`resources.lora: ...` with the rest. A `lora:` value that is not a mapping is
refused as such, not as an unknown resource.

LoraSpec.apply_yaml_overrides checks every value before it sets any, so a
refused override leaves the spec as declared rather than holding the refused
value for the next copy of the spec to raise again.

`autocast_dtype` was refused only by each worker after spawn; the pass now
parses it with the worker's own parser (parse_autocast_dtype).

The API server read `resources:` before the conductor's pass for every
deployment; a non-mapping block, or a `lora:` value that is not a mapping, now
turns no LoRA on there (lora_enabled, served_lora_adapters, lora_specs), and
adapter_prefix_roots checks for LoRA before it reads the KV specs, so a
deployment without LoRA leaves the block to the pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* [Test] LoRA targets: restore torch's dispatch-mode flags after the rank threads; a hand spec with no adapters

On torch 2.9.1 (CI's pin) a dispatch mode entered on two rank threads
at once leaves torch's module-level 'in dispatch mode' flags set, and
dynamo then skips every later frame on that worker. The hand-written
spec preloaded the YAML adapters and failed in the pool's constructor,
before binding was reached.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test_lora_pool: the KV control asserts the warning, not a throttle

The control expected the 'No eviction candidate' warning once over three
calls, but the warning has never been rate-limited, so CPU Core failed
with 3 == 1. Assert that a KV refusal looks for a victim and warns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* [Worker] TP lockstep: the admit fence waits out a slow admit

_await_admit_settled now waits for a TP lockstep step (tp_seq >= 0) however
long its admit and plan take, warning every 10 s, instead of giving up after
10 s. This applies to every TP lockstep step, with or without
MSTAR_TP_ASYNC_SCHED: a teardown landing inside the admit changes what it
decides on that rank alone, and the ranks split. A resource admit may
legitimately take longer than 10 s; the LoRA pool PR above this one is the
first such resource (its admit can wait on a peer's adapter read). Any other
step is still given up on after one interval.

The GPU thread now sets launch_started_event from _execute_on_gpu_thread's
outermost finally. It used to be set in a finally that a step whose plan
future raised never reached, which with an unbounded wait would hang the
fence. The wait also ends once the step's future is done: a GPU executor
that fails to start completes its queued futures without running them, so
the event is never set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* lora_adapter is an engine kwarg: main's ignored-params log no longer warns on every LoRA request

Main now logs the model_kwargs a model does not read; lora_adapter is read by
the conductor for every model, like seed and prefix_cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* submit_request: check the generation kwargs before resolving the LoRA adapter

Main's check runs first on a server with no state (pinned by
test_submit_request_refuses_a_bad_seed_before_registering); the adapter lookup
reads served_models, so it goes after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test_lora_prefix_keys: the fake model gives main's data worker its partition topology

Main's PreprocessWorkerThread reads get_partition_topology() for client
streams at construction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ENGINE_KWARGS names LORA_ADAPTER_KWARG rather than repeating it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* warmup_compiles runs outside a planned step: say a LoRA delta refuses it

Main's new hook (#927) runs after capture with no compute scope; the LoRA docs
said the engine brackets every forward it runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* warmup_compiles: only an eager LoRA delta refuses a call outside a step

The refusal is skipped while dynamo traces, so say a node serving adapters runs
no adapted linear in warmup_compiles rather than that the delta refuses it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: refuse children a module runs through their weights itself

The module walk decided capability by class and forward, so a linear its
parent runs as F.linear over its weight, or folds at load, was listed as a
target and an adapter on it validated and silently did nothing. A parent now
names such children in `lora_unservable` (child -> reason); the walk merges
them into the refused modules, so an adapter touching one is refused with the
reason. Declared by absorbed MLA (its folded source projections), SigLIP's
patch embedding (Command-A+), and EarTTS's attention, fusion and MoG head
projections (Nemotron duplex).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: check declared unservable children exist, look them up by path

A child named in `lora_unservable` that the module does not have was
dropped silently, so a typo or a later rename lost the refusal and an
adapter on the real child validated and did nothing. The walk now
resolves each declared child and raises a ValueError naming the module
and the child, as it does for the other model-supplied names.

The declarations also no longer join the `exclude` patterns: each became
an fnmatch pattern every later module was matched against (quadratic in
layers, and literal names gained glob semantics). They are kept by exact
path and looked up for the module and its ancestors before the `exclude`
patterns are tried.

The refusal test's table now names each case's parent module, rather than
the test body picking it by case name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Command A+: refuse LoRA on the projections its decoder layer fuses

On CUDA the language model fuses each decoder layer at load, and
`fused_branches` then runs attention's qkv/o projections and the shared
MLP's gate_up/down projections as slices of the layer's two GEMMs, never
through their forward. A walk of the (unfused) meta root still listed them
as targets, so an adapter on them would validate and do nothing. The layer
now names them in `lora_unservable`. CPU, the only unfused case, refuses
them as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA test: pin which MLA projections the walk serves, absorbed or not

Absorbed MLA folds q_a_proj, kv_a_proj_with_mqa and kv_b_proj into its
own weights at load, so it refuses them; unabsorbed it runs them, so they
are targets. A meta walk of the shared MLAAttention at GLM-5.2's reduced
config holds both sides of the condition.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: say in the module walk's docstring that modules declare refusals too

The module docstring listed only the call-site arguments as what a model
states; a module's own `lora_unservable` is the other source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: Command-A+'s refusal says it fuses on CUDA; the doc names a declared path's rules

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Worker: release the admit fence on the step's completion, never give up on it (R1.2, R1.4, R1.5, R1.7)

One release for "the step ended without launching": `_submit_gpu_step`
sets the launch event when the step's future completes, run or not, in
place of the outer `finally` and the `future.done()` poll. The GPU
thread's cleanup scope now starts at the top of the body, so a raising
plan future also releases the plan thread and publishes.

The fence waits however long any step's admit takes, warning every
`_ADMIT_FENCE_WARN_S` (renamed: it no longer times out). The give-up for
non-lockstep steps bought nothing, since the main thread blocks on that
step's result later in the same pass. The docstring now says what is
fenced: every admit, with later per-request rows planning after it lifts.
The fence test no longer depends on timing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Worker: correct the plan thread's commit-wait comment (R1.6)

A lockstep admit can now legitimately run past 10 s, so the timeout no
longer means the GPU thread died; it skips the pre-plan and exec plans
N+1 inline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test: a step the GPU thread never runs still lifts the admit fence (R2.1)

The fence now waits on the launch event with no limit, and the done-callback
in Worker._submit_gpu_step is what sets it for a future that fails without
running (BrokenThreadPool from a raising initializer). Pin that: the existing
plan-raise case passes even with the release moved back into the function body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Docs/tests: drop stale spec_launch_started, de-parametrize the fence test (R2.2, R2.3)

The async-worker skill named a local the fence change deleted; it now names the
step's launch_started_event. The fence no longer reads tp_seq, so its test keeps
one lone-rank case with a docstring that doesn't rest on lockstep. Rewrap the
edited admit-failure docstring.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: wait for a slot at the runner's admission, not in a queue of the pool's own (R1.1)

The pool now answers Resource.ask / reserve / unreserve / grew_since, as
RecurrentStatePool does: a request on an adapter waits in the runner's
Admission, all or nothing with KV and the other resources that ask, so it holds
no KV pages (or GDN slots) while it waits for a slot, and its node's readiness
check parks instead of being rescanned every pass. A reservation counts slots
(as the claims did) and pins none, so slot picks still follow from the slot
table alone and TP ranks stay in lockstep with no journal; only the deciding
rank (single GPU, or a lockstep leader) reserves. A slot is reserved only once
the adapter's host copy is in, so the admit never waits for a read.

Starvation is Admission's head-only rule: requests behind a cold adapter wait,
so the held slots drain. Deleted: admit_retrieve's screen, the claims, aging,
the drain-slot choice and the slot_wait_steps knob.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: guard deltas by the pool's own plan, not an engine compute scope (R1.4)

A delta called outside a planned step only needs the pool to know it has no
plan: it now clears its addressing at commit and rollback, and a delta called
eagerly with none refuses. That deletes Resource.enter_compute/exit_compute,
StepRunner.compute_scope, the engine's preprocess/forward split and the three
graph-runner wraps, so engine.py, runner.py and base.py read as on main.

The compile check now runs first, so a traced forward installs no guard on the
pool's state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: know an adapter is empty for a pool from its header, before any admit (R1.6)

A pool learned that an adapter has nothing for its targets only when an admit
read it, so the first request on a text-only adapter waited at a vision pool for
a slot it would never take, and its steps never took the base-only capture. The
pool now reads it from each served file's header at startup (and from a
runtime load's read), the same on every rank, skips preloading such adapters,
and runs_adapters counts only adapters with something for the pool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: the host tier holds what the ingests named, not what reads landed (R1.3)

Each rank decided its host tier's drops when a read landed, against the
requests still waiting at that moment, so the tiers diverged across a lockstep
instance once more adapters were in rotation than cpu_capacity. The leader
then reserved on its own copy while a follower's read had only just started,
and the follower's admit held the whole instance for the read, digest and
pinning.

LoraAdapterStore.hold now decides membership at ingest, in ingest order, the
same on every rank, and put only fills a held entry; an adapter a live request
names is never dropped. A runtime load keeps its validated copy until the first
request holds it, so it is not read twice. The eviction-time re-read and
_wanted are gone: a reserved row's adapter stays held.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: every device runs the custom ops; delete the two extra backend classes (R1.7)

make_backend returned TorchLoraBackend off CUDA, which called the reference
directly, so torch.compile on another accelerator traced its searchsorted /
scatter_ / gather instead of taking one opaque node. It now returns
LoraOpsBackend everywhere and imports triton_backend only to register the CUDA
kernels; TorchLoraBackend and the empty TritonLoraBackend are gone. "torch"
still selects the reference kernel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Delete the mstar.model.components.lora re-export shim (R1.10)

It re-exported mstar.engine.resources.lora.checkpoint under a second import
path; FLUX.2 and the diffusion LoRA tests now import from checkpoint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: check a served adapter from its header per spec, digest it once (R1.11)

check_adapter_file ran a full worker read (fp32, padded to max_rank, sharded,
SHA-256) once per LoRA spec, though the digest depends only on the file and how
the spec converts it. It now checks each spec from the header (inspect_adapter,
which covers the same refusals: no layers, rank, unmapped modules, misfits) and
digests the file once per distinct convert_keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Docs: the FLUX.2 static merge refuses a DoRA adapter (R1.12)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* OpenAI chat: name the adapter a lora_adapter kwarg picked in the response (R1.13)

The response renamed model only when the model field named an adapter; one
picked by the lora_adapter kwarg (extra_body) answered with the base model's
name. It now names whatever adapter ServedModels.select resolved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA admission: one reserve/unreserve helper; a base request waits behind a cold one (R1.1)

_take and rollback reuse _reserve / _unreserve instead of repeating the
counting. The KV-hold test now states the queue order main's admission gives:
a base request asking KV after a request that waits for a slot waits behind it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test: the base-only choice no longer meets per-rank empty-adapter reads (R1.6)

An adapter's emptiness now comes from its file's header on every rank, so the
case the test injected (one rank having read an adapter as empty) can't arise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test: the pool counts live adapter requests in _live (R1.3)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: reserve a slot for a request only once its adapter's host copy is in (R2.2b)

reserve() let a row through without its host copy when its adapter was resident in
a slot or another row had reserved it. A reservation pins no slot, so a miss admitted
first could evict that adapter, and the row's admit then waited on the GPU thread
(every rank, under TP) for the re-read its ingest had started. Reserve only once the
copy is in, or its read failed; a request on a resident adapter whose copy the tier
dropped waits at admission for that re-read instead.

Test: test_lora_pool::test_a_request_on_a_resident_adapter_waits_for_its_host_copy
(fails before: r is admitted at once on the resident shortcut). The lockstep module
now shares test_lora_pool's settle_reads helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: no rank waits on a read once a load has failed (R2.2b)

A row reserved because its adapter's read failed is meant to fail at its admit. A
request on the same adapter ingested before that admit started a retry, and the admit
then waited for the retry on the GPU thread: the leader for its own, and under TP a
follower whose read had failed too, while the leader waited in the agreement.

The leader no longer retries a failed read while a row reserved on the failure waits
for its admit (only the deciding rank reserves). And the load agreement is taken on
where each rank's copy stands (failed < still reading < in, the lowest decides) before
any rank waits: a rank still reading waits only when no rank has failed, and the ranks
then agree the outcome as before. The pool's agree now takes the lowest of an int
across the instance; one collective per installed adapter as before, a second only
when a rank was still reading.

Test: test_lora_tp_lockstep::test_no_rank_waits_on_a_retry_once_a_load_has_failed
(before: both ranks wait out the gated retry; with only the agreement change too; with
only the _hold change the follower waits alone and the barrier breaks).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: a queued request whose adapter read failed fails at once, not after a slot drains (R2.7)

reserve() checked the free slots before the read error, so a request queued while its
adapter was read, whose read then failed, sat at the head of admission until a slot
drained (on one GPU holding back every newer request), only to fail at its admit.

It is now let through at once, slots or not. An admit that finds no slot for such a
row fails it instead of refusing the step (which would refuse every step it rides in
until a slot drained): the full-pool refusal first agrees across the lockstep instance
whether that adapter's read failed on any rank. Every rank reaches that refusal for the
same miss, since all pick from the same slot table. The review's simpler fix (only
reorder reserve's checks) would have reached the full-pool refusal with no slot free.

Test: test_lora_pool::test_a_request_whose_read_fails_fails_without_waiting_for_a_slot
(before: admit_waiting lets nothing through while a1 holds the slot).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: a runtime load keeps no copy of the adapter outside the host tier (R2.3, R2.9)

load_adapter kept its read in _loaded until a request named the adapter, a pinned copy
per adapter, pool and rank with no bound: registering many adapters up front pinned
gigabytes of host memory that cpu_capacity was meant to cap. The load now checks the
file, pins its digest and drops the read; the first request on the adapter reads it
again as it is ingested, on every rank at that ingest, and waits at admission rather
than on the GPU thread. Costs one extra read per adapter loaded at runtime. _loaded is
deleted, and _hold reads in whatever it newly holds (or retries).

Also rewraps load_adapter_async's docstring, which had a broken line (R2.9).

Test: test_lora_pool::test_a_runtime_load_keeps_no_copy_until_a_request_names_it
(before: all three loads' copies stay alive).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: a delta refuses on another thread than the one that planned its step (R2.4)

The delta guard refused only while no step was planned. With the async worker (the
default) the main thread runs step N+1's prepare_inputs while the GPU thread is between
step N's plan and commit, so a delta called there ran against step N's rows, though the
error text and the docs name prepare_inputs as the call that refuses. The pool records
the planning thread in plan (a step's forward runs on it, a capture's included), and an
eager delta on any other thread refuses too. Docs and the error text say so.

Test: test_lora_graphs::test_a_delta_runs_only_between_its_steps_plan_and_commit gains
the cross-thread case (before: the delta on another thread runs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: ask() does not ask again for a row it has already reserved (R2.5)

Admission asks each node separately. For a pool serving two nodes, a request admitted
on the first and asked on the second before its first admit was reserved twice: the
second overwrote _reserved[rid] and left _reserved_count one count too high for good,
a phantom slot in _spare_slots. ask() now answers None for a reserved row, as
RecurrentStatePool.ask and KVManager.ask do. Unreachable today: every pool in the
stack serves one node.

Test: test_lora_pool::test_a_request_asked_again_by_a_second_node_is_reserved_once
(before: r2 waits on the phantom count once r1 is gone).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: admitting a reserved row does not wake the waiting head (R2.8)

_take gave the row's reservation back through _unreserve, which bumped _changes, so
every pass that admitted any LoRA row re-ran the waiting head's whole reservation, KV's
reserve/unreserve included, though turning a reservation into a hold frees nothing: the
slot it counted is now held instead. Only a row placed on the zero slot (its adapter
found empty at the admit) frees a count, and only that take bumps.

No test: no behaviour changes but the retries skipped (tens of microseconds a pass).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: say what backend "torch" does; the GPU kernel test registers Triton (R2.6)

Since every device runs the custom ops, "torch" only skips importing triton_backend,
and kernel registration is process-wide: once anything has imported it, a pool set to
"torch" runs Triton too. The config comment, make_backend's docstring and the docs said
"torch" forces the reference; they now say it registers no Triton kernel.

test/integration/test_lora_triton_gpu.py imported only backend and ops, so run alone it
compared the reference op with the reference function. It now imports triton_backend.
GPU-unverified: run `pytest test/integration/test_lora_triton_gpu.py -v` on a CUDA box.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* OpenAI chat: resolve the adapter once, before submitting (R2.9)

The chat handler resolved the request's adapter again after submit_request had, and
outside the 400 mapping lora_kwargs gives: had the adapter stopped being served between
the two calls (a runtime unload, #823), it raised after the request was submitted. The
handler now resolves it with lora_kwargs before submitting and passes the result on, so
the response names the adapter the submission carries. The router and Rust-parity test
stubs bind APIServer.lora_kwargs, as they bind open_result_stream.

Also reflows LoraSpec.apply_yaml_overrides' docstring, which ran past the file's width.

No test: at this PR adapters are not unloaded at runtime, so the two resolutions cannot
differ; test_lora_api's route test covers the response naming the adapter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Docs: LoRA admission holds back newer requests on one GPU; host tiers can differ per rank (R2.1, R2.2a)

R2.1: joining the runner's admission means a request waiting for a LoRA slot, or for
its adapter's host copy, is first in KV's queue too on one GPU (KV asks for every
request there), so it holds back every newer request on its node, base-model ones
included, until a slot drains or the read lands. Under TP KV does not ask and base
traffic flows. Letting a request pass a head that is short only on resources it does
not ask would be a change to Admission's rules for every resource, so it is left for a
decision of its own; the docs now state the behaviour and when it does not arise.

R2.2a: the comments and docs said the host tier follows from the ingests alone, so a
follower's read always starts at the leader's ingest. A tier also keeps what a live
request names, and a follower takes removes from rank 0 and ingests from the conductor,
unordered against each other: a remove that races an ingest can leave a follower without
a copy rank 0 kept, and the next request on that adapter waits there for a whole read.
No cheap exact fix (it needs ingests ordered against teardowns across the instance, or
replicated tier membership); the pool, store and docs now say so, and the agreement
before any wait is described as it now runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: a request on a resident adapter whose read failed still counts a slot (R2.7)

8107c82 let every row whose adapter read had failed through admission at once, slots
or not. A row whose adapter is still resident in a slot may yet run on it, so letting it
skip the count could promise one slot to two rows, and their admit then hit the backstop
refusal. Only a row whose adapter failed to read and is in no slot, which its admit fails
whatever the slots, is let through now, as the review's fix said; one on a resident
adapter is counted like any other.

Test: test_lora_pool::test_a_request_on_a_resident_adapter_whose_read_failed_still_counts_a_slot
(before: r is admitted beside y with the one slot already counted for y).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: wake a request waiting on an adapter that a miss evicts (R3.2)

A request counted on a resident adapter whose re-read failed waits on the
pool's free slots. When a miss's install evicts that adapter, nothing moved
the pool's change counter (the evicting rows' takes no longer do), so the
request waited for an unrelated slot release before it was let through to
fail. The eviction now bumps the counter, once per eviction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: agree every miss's copy in one collective, failing failed reads before any slot is handed out (R3.1, R3.3a/c/e)

A row let through on a failed read is reserved slots or not, so inside one
admit its miss could take the last free slot ahead of a row that was counted
on it; that row's step was then refused by the full-pool backstop. The admit
now picks a candidate slot for every miss from the slot table at its start,
agrees all the misses' copy states in one MIN collective (joint.agree takes
the list as is), fails every adapter that failed on any rank before handing
out a slot, then refuses if the misses left outnumber the slots, and only
then waits for a follower's read (and agrees once more). _refuse_full and
_load_miss are gone; an admit makes one collective with misses (two when a
rank was still reading) instead of one per miss plus one on a full pool.

ask shares _load_failed. Docs give the new collective count; the two-miss
teardown test describes the current path, and its control admits row by row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: say when _hold skips a failed read's retry (R3.3d)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: make_lora_capable refuses with ValueError, as every other bind refusal (R1.15)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: lora_local_shape defaults to the weight's shape; only the embedding overrides it (R1.12)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: a patch conv adds its delta at every output position, not only on one-patch inputs (R1.3)

A bound patch conv fed a whole image or latent (the usual patch embed) raised on
every forward, base-only ones included; its rows are now the output positions in
token order, each reading the patch the conv read (a dropped remainder excluded).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: find a layer stack by its module list, not by the names layers/blocks (R1.4)

A DiT's transformer_blocks (or any other stack) made one target per layer, so the
YAML and every rows/names pattern had to list each layer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: linked specs leave a module to the spec that places it, not to the longest prefix (R1.6)

A spec with no adapter roots beside one with a checkpoint prefix ignored all of its
own modules (the prefixed spec claims everything at length 0), and the other then
refused every adapter. Prefix length now only breaks ties between specs that both
place a module, and picks which spec refuses one that none places.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: a deferred embedding is served without embeddings=True, as the docstring offers (R1.7)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: refuse an exclude pattern that matches no LoRA-capable module (R1.8)

As rows, names and deferred already are: a typo left the module served, so every
request then failed at serve time on the delta guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: refuse a spec not built by module_lora_spec where specs are declared, not at worker bind (R1.16)

declared_lora_specs runs in the conductor, the workers and validate_deployment, so a
hand-written spec fails before any adapter check or worker start; the bind-time check
it replaces is deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: drop declared_step's lora_segment_rows test, which binding already guarantees (R1.17)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: name the engine's step builder engine_step, apart from the node's declare_step (R1.14)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: the no-plan errors name the real causes: a None step, a region with no LoraStep, a call outside the step (R1.9)

The delta guard sent an author whose declare_step returned None to prepare_inputs, and
the addressing error still described the hand-written LoraStep this PR retires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: say that nothing checks a piecewise region running adapted modules without its own LoraStep (R1.1)

The comment and docs said its capture is refused; that held under the compute scope
#820 deleted. A startup refusal of every region without a LoraStep would refuse
regions that run no adapted module (Qwen3-TTS's depth loop runs the CodePredictor,
outside the Talker root).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: declare the children main's models run through their weights (R1.2)

Waypoint's MLPFusion (fc1/fc2), CosyVoice3's float32 LM head and Qwen3-Omni's code
predictor (QKV in the unrolled depth loop, the stacked lm_head) read a linear's weight
directly, so a spec walking them would bind a delta that never runs. The docs say
why the declaration is the guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: ruff (line length, zip strict) on the round-1 fixes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test_lora_prefix_keys: build the fake model's spec map as module_lora_spec does (R1.16)

declared_lora_specs now refuses a spec whose module_map is not a ModuleLoraMap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: a module list whose entries differ in shape gives each entry a target (R2.1)

Finding stacks by type made every ModuleList / Sequential a stack, so a list whose
entries change width (UNet levels, vision stages, codec Sequentials) was refused with
no declaration to opt out. A role whose entries differ in shape now falls back to one
standalone target per entry, as such lists did before; docs say ModuleList or Sequential.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: drop the unreachable "step without segments" cause from the no-plan messages (R2.2)

engine_step refuses a step without segments for a node with pools bound before the
pool is reached.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: a split stack entry keeps its role for `rows` (R3.1)

A stack whose entries differ in shape at a role gives each entry a target
named by its path. `rows` now still matches those entries by the stack role,
so a model's key does not depend on whether the entries agree in shape, and a
`names` override on such a role, which cannot name several targets, is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA pool: read adapters on mstar.utils.threads.ThreadPool

Main bans ThreadPoolExecutor under mstar/ (ruff TID251): a pool started
from a pinned thread would share its core. ThreadPool starts its reader
on the process mask.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: follow main's signatures in the tests; check KV fit on serving specs

check_kv_pools_fit read the model's own specs and applied the YAML to them,
so a LoRA deployment's `lora:` block was an unknown resource and the check
returned without looking. It reads serving_resources, as every other spec
reader does.

The LoRA tests follow main: _kv_page_sizes keys only the caches some
rank opens (node_groups, a named checkpoint), StepRunner.plan takes a
phase, and _try_offload_cold_request takes the walk and reads
scheduler.arrival.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Piecewise capture: refuse a LoRA step in a one_row_per_step region

A one_row_per_step region (#977) declares and replays its step as one row
named by the step's first request, so a LoRA pool planned in it would give
every request's tokens that request's adapter. No model combines the two
today; prepare_for_capture now refuses it with a ValueError naming the region
and node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Nemotron duplex: check the session cap on the serving specs

Resolve the specs with serving_resources, as check_kv_pools_fit does, rather
than the declaration with the YAML overrides applied by hand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA serving: report a bad lora: key on a second spec as a ValueError

size_lora_specs applies the lora: block to the LoRA specs keyed apart from
lora before apply_yaml_overrides runs, and let a key no spec takes escape as
a TypeError. Readers of the serving specs (check_kv_pools_fit) leave a
ValueError to validation, so a typo on a two-spec model crashed them instead.
It now converts the TypeError as apply_yaml_overrides does for the first spec,
and validation's suppress around it needs only ValueError.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA prefix roots: reads come from the request-preparation threads

Since #1109 a model with PREPROCESS_PARALLEL keys prompts on the prepare
pool, so the docstring names it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: lora_unservable for main's code predictor and Wan2.2's compiled FFN

#859 moved the Qwen3 talkers' code predictor into ResidualCodePredictor:
its layers' qkv_proj now run through their forward, and the stacked
lm_head is what the depth loop reads. Wan2.2's gelu_tanh_ffn runs the
FFN's linear_in as an addmm over its weight when compiled on CUDA.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA pool: no plan left behind a region's capture or a base-only step (R1.10)

A piecewise region's capture plans outside any step, and its commit restored
an enclosing plan the capture never saved, so its addressing stayed current:
a region that declared no LoRA rows of its own replayed against it. And a
base-only step left `active` False, which a compiled delta reads before it
asks for the plan, so a later step without a LoraStep skipped its deltas
silently. Every end of a step that does not hand an enclosing step's plan
back now clears the plan and sets `active`, so the delta refuses in both
forms until the next plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: the engine adds a bound node's rows wherever its step has segments (R1.6, R1.11, R1.13)

engine_step raised for every step of a bound node that declared no
segments (Bagel's), even on walks that run no adapted module, and refused a
node's own LoraStep under a pool's key; binding refused a node without
lora_segment_rows, whose one legal value left nothing to say. Now
lora_rows adds each pool's LoraStep from the step's segments unless the step
declares its own (kept), and adds none to a step with no segments, where an
adapted module's delta refuses for want of a plan. The flag is gone.
admit_declared reads the step the engine would plan, and engine_step has a
test through a real NodeSubmodule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: a piecewise region's step carries the node's rows (R1.2)

engine_step added LoRA rows to the node's step only; a region's step is
built by the piecewise runner from its config, and an attn_key region (which
the config declares, so no model code can add to it) had no way to carry
them. Its deltas read whatever plan was current. The runner now builds every
region step (sizing, capture, per replay) through the same lora_rows, so a
region's rows come from its own segments, as the node's do. A region
replayed one row per step would give every request the first one's adapter
(Qwen3.5's vision block loop): on a node serving adapters it is dropped from
capture like a region whose resources cannot capture, and runs eagerly under
the node's step, whose declare_step then returns its segments. The docs and
the pool's refusals say so instead of asking models to declare region rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: the engine's step-less warm-ups run bound modules as a base-only step (R1.1)

compile_regions and warmup_compiles run a node's forwards outside any
planned step, after its modules are bound. Eagerly a bound delta refused
there for want of a plan; traced, it skipped the eager check and asked the
pool for an addressing it did not have. Qwen3.5's vision tower warms its
patch embed and merger this way, so serving adapters failed the boot.

The engine now runs both under each of the node's pools' base_only(): this
thread holds a plan in which no row names an adapter, as a base-only step
does, and the pool's own state comes back after. A compile traced there
takes the base-only branch, so the first step with an adapter row compiles
its own; warming a zero-adapter plan instead would need an addressing sized
to each target's rows in each example, which a warm-up does not know.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: refuse the children modules run other than through their forward over the step's tokens (R1.3, R1.5, R1.9)

The walk binds every LoRA-capable child a module does not name in
lora_unservable, and a bound child its parent never runs through its forward
silently drops its delta, or adds it to rows that are not the step's:
- FastConformer's relative_k_proj and Chatterbox's linear_pos project
  position encodings (FastConformer's once, at bind, for a stream);
- FastConformer's subsampling convs a ReLU follows run as F.conv2d over
  their weight on the Triton path;
- Bagel's ViT patch embedding is converted at load to a linear over
  (kh, kw, C) patches, which a conv adapter's (C, kh, kw) factors do not fit;
- OmniVoice's backbone runs a layer's projections, but for o_proj and
  down_proj, as F.linear over the fused weights apply_flashinfer leaves; the
  reference package builds those modules, so the mstar backbone names them
  per instance, whichever exist.
Whisper's word-timestamp pass ran four projections as F.linear over their
weights; it calls the modules now (the same ops unbound, test_whisper_alignment
holds it to HF), so a bound one is never skipped there, rather than refusing
LoRA on the decoder's attention for every walk.

One parametrized case walks each annotated module, and the code predictor's
from abff1d1, on the meta device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* gelu_tanh_ffn: run a LoRA-bound linear_in through its forward (R1.4)

Compiled on CUDA, the DiT FFN helper ran linear_in as an addmm over its
weight, so a bound delta was never added; abff1d1 refused Wan2.2's
linear_in for LoRA on every device to cover it. The helper now takes the
fused epilogue only for an unbound linear_in and calls the module otherwise,
and Wan2.2's FFN serves adapters again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: lease a one-row region only for a step whose rows run no adapter (R2.1)

A one_row_per_step region on a LoRA node was dropped from capture at startup
(logged at ERROR, and failing a start that requires accelerator graphs), so
the region ran eagerly for every step, base-only ones included. Capture it
as any region, and have PiecewiseAcceleratorGraphRunner.can_lease refuse a
batch any of whose rows runs an adapter (LoraAdapterPool.runs_adapters, set
at ingest and the same on every rank). A base-only step replays it with
every row on the zero slot; a step with an adapter row runs the region
eagerly under the node's own step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: refuse cross-attention's k/v projections, run over the encoder context (R2.2)

CrossAttention.compute_kv and ParallelCrossAttention.compute_kv run k_proj
and v_proj over the encoder's rows, not the step's tokens, so a delta bound
to either would be addressed by the decoder's segments and land on the wrong
rows. Both name them in lora_unservable, so an adapter touching them is
refused at load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Whisper: the align walk declares its rows, so a LoRA delta has a plan (R2.3)

Since the word-timestamp pass calls the decoder's projections as modules, a
LoRA deployment's align step (base-only included) refused at its first
projection: the walk declared no step, so no LoraStep addressed its rows.
It now declares one segment per request over the tokens it teacher-forces,
which are the rows the projections see, and no resource steps: the step is
not captured or chunked, so nothing else reads it. The cross k/v projections
it runs over the encoder output are refused for LoRA (R2.2). The docstring
said the delta was never skipped; it now says where it adds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: lora_rows returns a new step instead of filling the caller's (R2.6)

lora_rows added each pool's LoraStep with setdefault on the declared step's
own dict, so a declare_step that returned one step object across calls kept
the first call's rows on every later call. It now builds a new step with
dataclasses.replace when it adds anything, leaving the declaration as given.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* gelu_tanh_ffn: pin that a compiled LoRA-bound linear_in adds its delta (R2.5)

A CUDA-only case (it skips on the CPU box): compiled, gelu_tanh_ffn runs
linear_in as an addmm over its weight unless it is LoRA-bound (R1.4), so
without that check the bound delta is dropped and the compiled output
differs from the module's own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: capture a one-row-per-step region base-only on a LoRA node (R3.1)

A one_row_per_step region is leased only for steps with no adapter row
(can_lease), yet it was captured under the region's own key, so the graph
recorded the LoRA delta kernels and every replay planned an all-ZERO_SLOT
addressing. Capture it under LoraBaseOnly instead: the graph records no
delta, the replay's plan returns at once, and the pool's base-only guard
raises if an adapter row ever reaches the lease.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA: a one-row region refuses a replay any of whose rows runs an adapter (R4 P3s)

can_lease already refuses such a step; run now checks every row too, not
only the first one the replay names. Also corrects the can_lease
docstring (the region is captured base-only) and a test's wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LoRA test: match the one-row region's own refusal

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.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