Repository navigation
[LingBot World] Eleven bit-identical removals of repeated work in the realtime path - #7648
princepride merged 23 commits into
Conversation
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
This PR appears to belong to: docs/design/module/cache_management.md, docs/design/module/diffusion/offloader.md, docs/design/module/diffusion/index.md, docs/design/module/diffusion/diffusion_model_integration.md. Module owners: @RuixiangMa @wtomin @david6666666 Routing: @RuixiangMa via module of the changed files, module named in the PR description, semantic router; @wtomin via module of the changed files, module named in the PR description, semantic router; @david6666666 via module of the changed files, module named in the PR description @tzhouam, please review your own changes and leave a short self-review comment describing what you checked. PRs without author self-review may not be assigned a reviewer. Please take a look when you have a chance. If you would like an automated review, mention @vllm-omni-review-bot in a comment. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1197ef8a79
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| block_ids, | ||
| n_blocks, | ||
| block_size, | ||
| first_block=(n_blocks - current_blocks) if reusable else 0, |
There was a problem hiding this comment.
Refresh current blocks at their actual table offset
When reuse_history_staging=True and LINGBOT_KV_GATHER=1, the second probe of an AR block takes this reuse path, but n_blocks includes the action-capacity padding block that build_block_table() reserves even when action_len == 0 (and may include additional unused window padding). Consequently, the current video blocks are not the last current_blocks entries: this copies null padding while leaving the staged current K/V from the previous probe unchanged, and those stale rows are still read because seq_lens includes the current video tokens. Compute the refresh start from the current blocks' position in block_ids, not from the padded table width.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in c5b20b0. The refresh now starts at the visible history's block count (kv_len // block_size - num_current_video_blocks, carried from the host-side metadata build_block_table already computes, as stage_first_block on the layer inputs) instead of n_blocks - current_blocks, so the padding blocks are never mistaken for the current chunk and nothing is synchronised inside attention. Test: two probes of one block through the real build_block_table layout with an empty, partial, full and slid window; the staged buffers equal a full gather byte for byte, and the old end-of-table guess is asserted to differ from the live offset.
| shape = (int(self.max_seq_len), *key_pool.shape[1:]) | ||
| buffers = tuple(torch.empty(shape, device=pool.device, dtype=pool.dtype) for pool in (key_pool, value_pool)) |
There was a problem hiding this comment.
Reserve staging buffers in the KV memory budget
With reuse_history_staging=True, this lazily allocates a full-length K and V buffer for every layer, but ARDiffusionKVCache._required_bytes() accounts only for scratch, cross-attention, model-owned state, and managed blocks. On memory-constrained configurations—especially when the one-session calculation already raises the budget close to available_bytes—capacity admission succeeds and the first prepared forward can then OOM while allocating these unbudgeted buffers. Include this worker-wide staging allocation in the preallocation budget before selecting session capacity and pool size.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in c5b20b0. The manager now reserves the staging pair before choosing session capacity and pool size: 2 x layers x (sink + window + the action-capacity block build_block_table always reserves) tokens x heads x head size x element size, added to _required_bytes and subtracted from the self-attention pool budget. Both the reservation and the lazy allocation are gated on the contiguous-gather path that is the only consumer (LINGBOT_KV_GATHER=1, via one helper in config.py), so the config flag alone no longer keeps the buffers for nothing. Tests: with the same geometry as the capacity-two case, staging drops the capacity from two to one, and the one-session check rejects the boundary budget that fit without staging.
Omni ReviewBot triage noteAutomated triage of commit
These are automated triage suggestions only — the final decision belongs to the maintainers. |
1197ef8 to
429120c
Compare
The DiT already runs on sequence_parallel_size ranks, but the decode of one AR block ran on every one of them in full: each rank decoded the whole frame so that its own post_decode could carry on. At 480x832 on 4xH200 that decode is the largest single stage left on the served path after the DiT. Whenever this pipeline runs on more than one Ulysses rank, install the existing halo-exchanging Wan decoder shard on those same ranks, split along the width. There is no switch and no new option: the ranks the decode has are the ranks the DiT has, and a multi-rank deployment is what asks for the shard. Every rank keeps the assembled frame because the gather is an all-gather anyway, so post_decode on every rank is unchanged, and the streaming decoder composes with it as is: feat_cache, feat_idx and first_chunk pass straight through to the patched decoder and the per-session temporal cache shrinks by the same factor (1801 -> 450 MiB per rank). vae_patch_parallel_size stays rejected for this pipeline, as before: it would make the registry force tiling and set up the tiled distributed executor underneath a decoder that is already sharded. Measured on 4xH200, 480x832, one AR block: 269.9 -> 98.7 ms. Served, with the lossless path of vllm-project#7648 underneath: 916 -> 721 ms per chunk. Numerically a multi-rank decode is not bit-exact against a single-rank one -- the halo-exchanged convolutions accumulate in a different order at the shard borders, max abs diff 0.0195 on [-1, 1]. The one change outside the pipeline: install_wan_spatial_shard_decode gains a dst argument so a caller can ask for the frame on every rank instead of only rank 0; the default is unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: tzhouam <tzhouam@connect.ust.hk>
princepride
left a comment
There was a problem hiding this comment.
Reviewed the full diff against 507cb1d8, focusing on dead code, over-defensive code and over-abstraction. Inline comments below; this is the summary.
Scope. By the PR's own measurements, several of the eleven changes produce no served gain or never execute on the served path:
- the source-image cache "cannot fire" (the tick image is inline);
- cross-attention without all-to-all and the K/V restage are within run-to-run noise;
- the static timestep buffer and the single-pass conv input are explicitly "not claimed as a served gain".
The K/V restage is the largest of these: ~250 lines in paged_attention.py plus budget logic in the manager, off by default, gated by both a config flag and an env var. The 1326 → 920 ms comes from uint8 output, the condition fixed point, the camera window, step grouping and the sync removals.
I'd suggest splitting the unmeasured items out into follow-ups that come with served numbers. That roughly halves this PR and makes the rest much easier to review.
Summary of inline comments:
- Dead code: source-image cache; write-only
session_state.prompt; leftover Ulysses state,num_sp_headsalias andshard_kv_headsin cross-attention; unuseddeviceparameter; stalereuse_historytype/comment; a redundant slice branch. - Over-defensive code:
getattrdefaults on attributes that always exist; a silent fallback in the restage path;data_ptr-keyedobserve();get/pop/nullcontextfor an always-presentcamera_cache; arequires_gradguard in inference; the env var read in two places. - Over-abstraction: two mechanisms for camera reuse; staging state monkey-patched onto the KV cache as a dict; the grouping capability placed in the shared protocol;
_assemble_input. - Behavior change: stepwise vs. request mode now disagree on
output_type.
| _MAX_SOURCE_IMAGE_PIXELS = 4096 * 4096 | ||
| # One decoded source image, keyed by the file it came from; see _load_source_image. A realtime session repeats | ||
| # its own image every tick, so one entry serves the pattern that matters and bounds the memory at one image. | ||
| _SOURCE_IMAGE_CACHE: dict[tuple[str, int, int, int, int, int], PIL.Image.Image] = {} |
There was a problem hiding this comment.
Dead code on the served path. Per the PR description, this cache "cannot fire" on the realtime server because a tick carries its image inline rather than as a path. Yet it brings a module-level dict + threading.Lock, a 5-field stat fingerprint, a second stat after decode, a copy on both get and put, and a broad except fallthrough.
Suggest dropping this commit from the PR. If the offline/request path really needs it, functools.lru_cache(maxsize=1) keyed on the fingerprint would cover it; a one-entry cache implemented as dict + clear() is also more machinery than needed.
There was a problem hiding this comment.
Kept, but reduced to what it needs: the module-level dict, lock, second stat and broad except are gone; it is now functools.lru_cache(maxsize=1) on the stat fingerprint, with a copy on return (5127705). On the served path it indeed never fires (the tick carries the image inline); it is there for request mode, where the same source path is replayed per request: in-process A/B on 4xH200 480x832, 732.7/732.3 ms original vs 714.6/716.2 ms cached (n=20 each, two independent launches per arm, 40/40 bit-exact eager gate), i.e. -17 ms. The PR description states the served result (no gain) for it.
| @@ -159,9 +163,20 @@ class _LingBotARSessionState: | |||
|
|
|||
| next_chunk_index: int = 0 | |||
| prompt: str | None = None | |||
There was a problem hiding this comment.
_LingBotARSessionState.prompt is now write-only: its only reader was prompt_changed, which this PR replaced with _session_text_caches / text_cache_key, but session_state.prompt = inputs.prompt (L1585) is still assigned. Please remove the field and the assignment.
There was a problem hiding this comment.
Removed the field and the assignment (5127705). The one remaining prompt read on that path is on StepRequestState, a different object.
| else: | ||
| video = self._streaming_decode_chunk(decoder, vae_latents, cast(str, session_id)) | ||
| return self._video_processor().postprocess_video(video, output_type=output_type) | ||
| return _uint8_frames(video) |
There was a problem hiding this comment.
Behavior change worth making explicit: on the stepwise path every pixel output_type (pil, np, pt) now returns a (F, H, W, 3) uint8 ndarray, while request mode (L543) still goes through VideoProcessor and honors output_type. The same pipeline now gives different types for the same parameter depending on execution mode. If intended, please validate/document it at the request level rather than only in this docstring.
There was a problem hiding this comment.
Documented at the request boundary (5127705): the _LingBotRequestInputs.output_type field now states that step execution returns every pixel type as (F, H, W, 3) uint8 frames (what the streaming endpoint muxes) and only latent differs, while request mode goes through VideoProcessor. The endpoint consumes uint8 frames whatever the client named, so I kept the behaviour rather than making a pixel output_type an error on sessions; happy to reject non-np pixel types there instead if you prefer a hard boundary.
| @@ -422,12 +432,12 @@ def __init__( | |||
| self.head_dim = dim // num_heads | |||
| self.num_local_heads = num_heads // tp_size | |||
| self.ulysses_world_size, self.ulysses_rank, self.ulysses_group = _ulysses_state() | |||
There was a problem hiding this comment.
Leftover dead state after dropping the cross-attention all-to-alls:
self.ulysses_world_size / ulysses_rank / ulysses_grouphave no readers in this class anymore.self.num_sp_heads = self.num_local_heads(L440) is just an alias.shard_kv_heads(L490) no longer shards; its docstring says it is "kept as a seam". It now only doesclone(), which is an extra copy of the text K/V per layer:linear(...).unflatten(2, ...)is already contiguous.
Suggest removing the method and the alias and using the tensors directly (or .contiguous() at most).
There was a problem hiding this comment.
Removed (5127705): the Ulysses state, the num_sp_heads alias and shard_kv_heads are gone; the cross-attention cache takes the projection outputs directly (they are already contiguous), the pipeline's pre-population does the same, and the test that pinned the clone went with the method.
There was a problem hiding this comment.
| self.key: tuple[int, tuple[int, ...], torch.dtype, torch.device] | None = None | ||
| self.entries: dict[int, tuple[torch.Tensor, torch.Tensor]] = {} | ||
|
|
||
| def observe(self, camera_hidden_states: torch.Tensor) -> None: |
There was a problem hiding this comment.
observe() is both redundant and not a sound guard:
- Redundant: both paths already scope the cache to exactly one block (stepwise creates one per block;
generate_blockuses thewithwindow), and the camera cannot change within it. - Not sound: keying on
data_ptr()can alias under the CUDA caching allocator — a different camera tensor of the same shape can land at the same address — so the docstring's claim that a misplaced window "costs the speedup but never correctness" doesn't hold.
Either trust the caller-owned lifetime and delete observe, or compare by value if a guard is really wanted.
There was a problem hiding this comment.
Deleted observe() (5127705) and with it the data_ptr key: the cache now only holds entries, its lifetime is the caller's, and the docstring says so instead of claiming a guard.
| history is already staged. Skipping the leading blocks is the whole point: they were gathered by an | ||
| earlier forward of the same AR block and cannot have changed since. | ||
| """ | ||
| ids = block_ids if first_block == 0 else block_ids[first_block:] |
There was a problem hiding this comment.
block_ids if first_block == 0 else block_ids[first_block:] — both branches are equivalent (block_ids[0:]); just use block_ids[first_block:].
|
|
||
|
|
||
| # Experimental contiguous-K/V gather (see ar_diffusion_paged_attention). | ||
| _KV_GATHER_ENABLED = contiguous_kv_gather_enabled() |
There was a problem hiding this comment.
LINGBOT_KV_GATHER is read two different ways: frozen at import here as _KV_GATHER_ENABLED, but called at runtime via contiguous_kv_gather_enabled() in manager.py L302. If the env var is set after import (common in tests), the budget reservation and the actual allocation disagree. Please read it in one place.
Separately, kv_cache/config.py is a shared AR-diffusion module but now carries a LINGBOT_* env var reader, which contradicts "nothing model-specific in shared modules". Having both an env var and a config flag (reuse_history_staging) gate one feature is also one knob too many.
There was a problem hiding this comment.
One reader now (5127705): contiguous_kv_gather_enabled() in kv_cache/config.py is called where the decision is made, by the manager when it budgets and allocates staging and by the attention dispatch, so a value set after import is seen by both; the frozen module constant is gone. The switch is renamed VLLM_OMNI_AR_DIFFUSION_KV_GATHER: it selects an experimental attention path of the shared AR-diffusion module, not anything LingBot-specific. On the two knobs: staging is a sub-option of that path (it only has a consumer there), so the manager now warns when reuse_history_staging is set while the path is off, instead of silently doing nothing. I kept them separate because the gather path is useful on its own and the staging buffers cost two max-sequence buffers per layer per rank.
| k_flat = stage_key | ||
| v_flat = stage_value | ||
| current_blocks = current_tokens // block_size | ||
| reusable = ( |
There was a problem hiding this comment.
Silent fallback: if any of these four conditions fails (current_tokens > 0, divisibility, 0 <= stage_first_block, upper bound), we quietly do a full restage. None of them should fail for a correct caller; if one does, the metadata is wrong, and this hides it as "the optimization didn't kick in" — the offset bug Codex found is exactly this class. Please make these asserts (or drop them).
There was a problem hiding this comment.
Asserts now (5127705), only on the reuse branch: a block-aligned current chunk and stage_first_block + current_blocks <= n_blocks. A wrong offset fails instead of restaging; the non-reuse branch still restages the full window, which is its job.
| return isinstance(pipeline, SupportsStepExecution) | ||
|
|
||
|
|
||
| def supports_chunk_step_grouping(pipeline: object) -> bool: |
There was a problem hiding this comment.
supports_chunk_step_grouping extends the shared step-execution protocol, but its only consumer is ARDiffusionModelRunner and its only declarer is LingBot. Suggest moving the capability into the AR protocol in experimental/ar_diffusion/capability.py. Also, the supports_step_execution(pipeline) check in this helper is redundant: anything reaching _execute_stepwise_core already supports step execution.
There was a problem hiding this comment.
Moved to experimental/ar_diffusion/capability.py (5127705) and the redundant supports_step_execution check dropped; the runner imports it from there.
| out = _trim_local_conv_output(out, self.halo_size, start, upper_bound, split_dim=self.split_dim) | ||
| return _zero_invalid_extent(out, split_dim=self.split_dim) | ||
|
|
||
| def _assemble_input(self, x: torch.Tensor, cache_x: torch.Tensor | None) -> tuple[torch.Tensor, int]: |
There was a problem hiding this comment.
Per the PR description this is not claimed as a served gain ("the sharded decode turns out not to be bound by" this traffic at 480x832), yet it replaces cat / F.pad / halo_exchange with ~75 lines of hand-written indexing. Two details also don't match the description: the halo region is zeroed and then overwritten, and halos are received into separate recv buffers and then copied in, rather than "received into their columns" as the commit message says.
A definite readability cost with no measured benefit. Suggest dropping this commit from this PR.
There was a problem hiding this comment.
Kept, with the two inaccuracies fixed (5127705): the halo slots are no longer zeroed before being written, and the docstring now says what the code does: the neighbours' rows are received into contiguous buffers because a P2P op needs a contiguous tensor and the halo slot of a 5D tensor is a strided view, then copied into their slots. On the measurement: it removes two of the three full writes per causal conv call (cat, pad, cat), which is memory traffic that the ledger shows and that matters below 480x832 sharding as well; on the served 4xH200 480x832 arm it sits inside the ±10 ms floor, as the description says. I would rather keep it here than carry it as a follow-up, because the width-sharded decode of #7651 and the decoder fast path in #7749 both build on this one-pass input.
… call With stepwise_chunk_per_call the runner looped denoise_step and step_scheduler over one InputBatch built before the loop. The batch holds gathered copies of the request states' latents and timesteps, not views, so a denoiser that reads them from the batch -- Helios stage 1 does -- saw the first step's values on every step of the chunk. The per-step path never had this problem because every scheduler cycle rebuilds the batch. Rebuild it (reusing its storage) before each further step, exactly as the next cycle would have. The test now uses a denoiser whose prediction depends on both batch fields and checks that the inputs each step sees, and the final latent, are identical between the per-step and whole-chunk modes. Reported in review; reproduced on CPU before the fix. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: tzhouam <tzhouam@connect.ust.hk>
…e the staging buffers Three review findings on reuse_history_staging, all reproduced on CPU. The reuse path refreshed the current chunk's K/V at n_blocks - current_blocks, the end of the padded block table. The table always ends in at least one action-capacity block, and while the window is still growing in unused window capacity as well, so that refreshed padding and left the current rows staged by the previous probe in place -- rows attention still reads, since seq_lens covers them. The offset is now the visible history's block count, carried from the host-side metadata that build_block_table already computes (kv_len, which is the visible video window on this action-free path), so nothing is synchronised inside attention. The test drives two probes of one block through the real build_block_table layout with an empty, a partial, a full and a slid window, and checks the staged buffers equal a full gather byte for byte each time. The staging pair per layer is worker-wide memory allocated lazily on the first prepared forward, after admission, and it was outside the KV budget: admission could succeed and that forward run out of memory. The manager now reserves it -- 2 x layers x (sink + window + the action-capacity block) tokens x heads x head size x element size -- before choosing the session capacity and the pool size, and the tests show the capacity dropping and the one-session check rejecting at the boundary. Finally, only the contiguous-gather attention path (LINGBOT_KV_GATHER=1) consumes the buffers, but they were allocated whenever the config flag was set. Both the allocation and the reservation are now gated on that path through one helper, so the flag alone no longer keeps the buffers for nothing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: tzhouam <tzhouam@connect.ust.hk>
Review of the whole-chunk-per-call change made the right point: a public Boolean was a design choice, not a requirement, and the argument that "nothing else can be scheduled because this path refuses batches" conflated batch size with scheduling opportunities. Grouping a chunk's steps into one runner call removes the scheduler/executor hops between them, and with them the scheduler's chances to act between those steps; whether that trade is acceptable is a property of the execution path, not something a user should have to know to switch on. Drop stepwise_chunk_per_call from the config stack. In its place: - a step-execution pipeline may declare the optional class attribute supports_chunk_step_grouping = True, meaning its request state advances through every denoise step of its current chunk without needing a serving-scheduler cycle in between. It is optional so it does not become part of the runtime protocol check. LingBot World declares it: a realtime block is four probes and a commit over one prepared chunk state. - DiffusionModelRunner._group_steps_per_chunk() is the policy hook, off for every pipeline in the base runner. ARDiffusionModelRunner overrides it for a lone streaming request of a pipeline that declares the capability: that path binds one session's KV per call and regains control at every chunk boundary, where its interactions are applied anyway. The pipeline's interrupt flag is still checked between grouped steps, and the batch is rebuilt between them. Same measured effect as before (777 -> 736 ms per chunk served, three sessions per arm); nothing else changes for pipelines that do not declare the capability. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: tzhouam <tzhouam@connect.ust.hk>
…a chunk Follow-up to the capability/policy split. The shared DiffusionModelRunner still carried the loop that ran a chunk's remaining steps in one call, switched by a policy hook the base never turned on: model-path code in a shared module, with the batch-refresh subtlety it brought along. Restore the shared core to its original one-step-per-call semantics and drop the hook. ARDiffusionModelRunner overrides _execute_stepwise_core instead: it runs the parent's single-step core once with the scheduler's request, and when its policy applies (a lone streaming request of a pipeline declaring supports_chunk_step_grouping) keeps calling the same core with a continuation of that scheduler output -- the request presented as a cached request, nothing new to admit, nothing to retire, no prefetch job or connector metadata -- until a call emits a result, finishes the request, or leaves it without state. Every grouped step is therefore exactly what the next scheduler cycle would have asked for, including the per-call batch rebuild, and it all happens inside the one session KV binding and the fail-closed scope execute_stepwise already wraps around the call; the chunk barrier is unchanged and no per-step device sync is added. Tests move to an AR runner instance: the new request is prepared once and every further step of the chunk arrives as a cached-request continuation; per-step inputs and the final latent equal the per-step path's; a grouped call never crosses a chunk boundary; a failure mid-chunk ends the call with that step's error, runs nothing after it and drops the state; the shared runner never groups, and the AR policy needs the capability, streaming output and a single request. The served effect is re-measured for this revision and reported in the pull request, since the per-step entry cost changes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: tzhouam <tzhouam@connect.ust.hk>
…evice read-back InputBatch.make_batch builds the default index mapping as a torch.arange on the latents' device and then reads it straight back with tolist() and cpu(), so every batch build is two device synchronisations: the host stops until everything queued on the stream -- the previous denoise step -- has finished. The batch is rebuilt on every step of every stepwise request, which is why the served LingBot World path always showed ~170 ms per chunk of the host waiting inside the runner between steps, and why rebuilding the batch between the steps of a grouped chunk (added in review to keep batch-reading denoisers correct) gave that wait back: 916 -> 970 ms per chunk served, three sessions per arm. The identity mapping is known on the host. Build the device tensor as a launch and the host array from NumPy; only a caller-supplied mapping is still read back. Nothing about the batch changes: same ids, same order, same device tensor, same host array. The test forbids Tensor.tolist and Tensor.cpu during the default build and the cached repack, and checks the explicit-mapping path still selects and orders states. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: tzhouam <tzhouam@connect.ust.hk>
…staging and the gather switch Review round 3 (princepride) on the full diff, applied item by item: - The camera-modulation cache is an explicit forward argument (camera_modulation_cache=...) instead of ambient state opened by a context manager: generate_block creates one per loop, the stepwise path passes the block's cache it holds, and observe() with its data_ptr key is gone; the cache's lifetime is the caller's. - The history staging buffers are allocated and owned by the KV manager next to the pools it already budgets them in, sized to the padded window it admits and marked static at construction; the forward context only narrows a layer's pair and keeps the reuse decision in a small dataclass the cache owns. No more dict monkey-patched onto the cache, no lazy allocation, no (layer, max_seq_len) key. - The contiguous K/V gather switch is read in one place, at decision time, under a name that says what it is (VLLM_OMNI_AR_DIFFUSION_KV_GATHER), and the manager warns when staging is requested while the path is off. - supports_chunk_step_grouping lives with the other AR-diffusion capabilities; the shared step-execution protocol no longer carries it. - Dead code removed: the source-image cache is a one-entry lru_cache on the stat fingerprint; the write-only session prompt, the cross-attention Ulysses state, num_sp_heads and shard_kv_heads, the inference-time requires_grad branch, the unused device parameter, the redundant slice branch and the stale reuse_history type are gone. - Over-defensive code removed: plain attribute access where the attribute always exists, extra["camera_cache"] without defaults, and the staged reuse asserts its metadata instead of silently restaging. - The sharded conv input no longer zeroes the halo slots it then writes, and its docstring says why the halos go through contiguous buffers. - output_type on step execution is documented at the request boundary. Signed-off-by: tzhouam <tzhouam@connect.ust.hk> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The previous commit dropped the cross-attention's num_sp_heads alias but the KV cache spec in the pipeline still read it, which the CPU pipeline tests hide behind a stand-in transformer; the served workers failed at model load. Read num_local_heads there and in the tests' stand-ins. Signed-off-by: tzhouam <tzhouam@connect.ust.hk> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
6fa891b to
917715e
Compare
The DiT already runs on sequence_parallel_size ranks, but the decode of one AR block ran on every one of them in full: each rank decoded the whole frame so that its own post_decode could carry on. At 480x832 on 4xH200 that decode is the largest single stage left on the served path after the DiT. Whenever this pipeline runs on more than one Ulysses rank, install the existing halo-exchanging Wan decoder shard on those same ranks, split along the width. There is no switch and no new option: the ranks the decode has are the ranks the DiT has, and a multi-rank deployment is what asks for the shard. Every rank keeps the assembled frame because the gather is an all-gather anyway, so post_decode on every rank is unchanged, and the streaming decoder composes with it as is: feat_cache, feat_idx and first_chunk pass straight through to the patched decoder and the per-session temporal cache shrinks by the same factor (1801 -> 450 MiB per rank). vae_patch_parallel_size stays rejected for this pipeline, as before: it would make the registry force tiling and set up the tiled distributed executor underneath a decoder that is already sharded. Measured on 4xH200, 480x832, one AR block: 269.9 -> 98.7 ms. Served, with the lossless path of vllm-project#7648 underneath: 916 -> 721 ms per chunk. Numerically a multi-rank decode is not bit-exact against a single-rank one -- the halo-exchanged convolutions accumulate in a different order at the shard borders, max abs diff 0.0195 on [-1, 1]. The one change outside the pipeline: install_wan_spatial_shard_decode gains a dst argument so a caller can ask for the frame on every rank instead of only rank 0; the default is unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: tzhouam <tzhouam@connect.ust.hk>
princepride
left a comment
There was a problem hiding this comment.
Reviewed the full diff at 917715e1b, with an eye on whether the two earlier rounds (Codex's P1/P2, @princepride's set) actually landed rather than just got answered.
What I checked and believe holds:
- The restage offset fix.
stage_first_block = kv_len // block_size - num_current_video_blocksreally is the visible-history block count, so the refresh no longer lands on the trailing padding blocks; the new empty/partial/full/slid-window test drives the real table layout. - The staging budget.
history_staging_tokens = sliding_window + sink_chunks*chunk_size + block_sizeis exactlymax_seq_lenwhenaction_len == 0, so the capacity check in_prepare_history_stagingcannot spuriously fire, and the reservation is taken before capacity selection. - Staging under CFG. The buffers are per-layer and shared across KV branches, but
HistoryStagingState.adapteris a weakref to the per-(session, branch) adapter, so an alternating branch or session dropsreuse_historyback to a full gather. Correct, and the weakref also rules out an adapter-address ABA. (Cost is that the optimisation never fires with more than one branch, which seems fine for an opt-in path.) - The condition fixed point. For
start_frame > 0the encoder's only inputs are the zero frames andpending_cache, and_condition_from_latents(first_frame=False)does not readstart_frame, so "same input + same state implies same output and same state" is sound rather than empirical. _assemble_input's halo layout.buf[halo:2*halo]/buf[extent:extent+halo]are the same rowsF.pad+halo_exchangesent, the send and receive slices do not overlap, andlocal_extentmatches the oldx.shape[dim]after padding._to_device_async.pin_memory().to(non_blocking=True)is lifetime-safe here; the caching host allocator records a stream event on the copy.
Three comments inline. Only the first is one I'd want an answer on before merge.
Small extra: kv_cache/manager.py now defines both logger = init_logger(__name__) (new) and the pre-existing _log = init_logger(__name__). Two names for the same logger; worth collapsing to one.
Dong1017
left a comment
There was a problem hiding this comment.
Nice work — no blockers from my read. Each of the eleven changes removes work that looks provably redundant within one AR block or one session, and each carries a targeted property test. I traced the load-bearing invariants: the single-pass conv input matches the old cat → F.pad → halo_exchange layout exactly, the staging-reuse signature and stage_first_block offset are consistent with the padded block-table layout (and action tokens disable staging), the staging buffers are reserved inside the KV budget before admission, the camera modulation cache is request-scoped and rebuilt when the camera changes, and the condition fixed point is detected by bit-identical history comparison rather than assumed. The new text-cache key also replaces the removed session_state.prompt tracking with equivalent-or-stricter invalidation, as far as I can tell.
Two small notes:
- The PR description mentions
transformer.reuse_camera_modulation()andnew_camera_modulation_cache; I couldn't find either name at head. The implemented contract seems to be the caller-heldCameraModulationCachepassed viacamera_modulation_cache=(transformer.py:771, per-block construction ingenerate_block/_prepare_next_chunk). Behavior matches the description as far as I can tell, but the names might be worth syncing. - My guess is the 4xH200 parity and CUDA-graph paths are author-run only (CI here doesn't exercise them), with the CPU property tests as the standing guard — worth keeping in mind for future regressions.
| resident_block_ids, | ||
| ) | ||
|
|
||
| logger = init_logger(__name__) |
There was a problem hiding this comment.
Small nit: this adds a second module logger right above the existing _log = init_logger(__name__) two lines down, and the new staging warning uses logger while the rest of the module uses _log. Might be simplest to drop this binding and call _log.warning at line 339.
| return True | ||
|
|
||
|
|
||
| def _uint8_frames(video: torch.Tensor) -> np.ndarray: |
There was a problem hiding this comment.
Non-blocking thought: _uint8_frames is now the third near-copy of the denorm→uint8 conversion (postprocess/device_reduction.py, dreamzero/video_export_worker.py:_latents_to_uint8_frames). This one is deliberately pinned byte-for-byte to VideoProcessor's arithmetic, so no change requested — just noting that a shared "PIL-identical" helper could keep the three from drifting apart someday.
Signed-off-by: tzhouam <tzhouam@connect.ust.hk>
Require explicit staging arguments in the custom-op schema so older PyTorch mutation handlers cannot index dispatcher-elided defaults. Register and document the experimental KV gather switch, and cover staging-disabled and staging-enabled fullgraph compilation. Validation: 75 CPU tests passed; all 14 paged-attention GPU tests passed using the local CUDA compatibility driver. Non-mypy pre-commit checks passed; existing mypy findings remain. Signed-off-by: tzhouam <tzhouam@connect.ust.hk>
) CI fixes can pass on a newer local runtime while still failing in the target lane, and small realtime timing changes can be indistinguishable from baseline drift. Add reusable rules from vllm-project/vllm-omni#7648 to the CI, configuration, diffusion, and benchmark owners, with index and routing updates. The rules cover Buildkite job evidence and runtime boundaries, environment-variable inventory consistency, mutable custom-op schema defaults and fullgraph validation, and repeated-control performance checks. Preserve existing upstream rules and use distinct rule IDs. No raw logs or machine-specific data are included. Validation: - Knowledge tree: zero errors (22 size reminders). - Wiki lint: zero errors or reminders. - vLLM-Omni release audit in enforce mode against the current main baseline: CLEAN, zero issues. - git diff --check passed. --------- Signed-off-by: tzhouam <tzhouam@connect.ust.hk>
… realtime path (vllm-project#7648) Signed-off-by: tzhouam <tzhouam@connect.ust.hk> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matthieu Laneuville <matthieu.laneuville@surf.nl>
… realtime path (vllm-project#7648) Signed-off-by: tzhouam <tzhouam@connect.ust.hk> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Conflicts in paged_attention.py and two test files: main moved the host-to-device copies onto _to_device_async (vllm-project#7648). This branch keeps its scratch-path spill and its block-counted max_seq_len and takes those copies. Both sides' new tests are kept, and make_state takes both sides' parameters. Two changes on main assumed a frame is one block, which this branch undoes: - compact_block_table (vllm-project#7498) started the evicted gap at index sink_chunks. With a frame spanning many blocks that index is inside the sink, so the gap was never found and the table never compacted. It now starts where remove_skipped_blocks frees, after the sink's blocks. The gap removed is also rounded down to whole chunks: storage positions then differ from model positions by whole chunks and whole blocks, so eviction's chunk snapping and every block offset are the same as without compaction. At 832x480 that leaves at most 194 null entries (two frames) in the table. Both are no-ops when a frame is a block. - History staging (vllm-project#7648) sized its buffers from token counts, short of the block-counted width this branch pads the table to whenever the sink or window is not a whole number of blocks, so the first prepared forward raised. It now uses the same width, through visible_window_blocks, which max_video_blocks now shares. A chunk that is not a whole number of blocks shares its first block with the history's tail, and the staged reuse restages the current chunk in whole blocks, so such a forward restages the whole window instead of reusing it. Tests that tracked positions through num_computed_tokens now track model positions, since compaction makes it a storage position. The test that counts block-id reads on a long table switches compaction off, as compaction is now what keeps tables short. New tests: compaction leaves the resident tokens exactly as they are without it, for sliding and reset windows with and without a sink; and history staging runs a ragged window. Reverting either compaction change, the staging width or the reuse guard fails at least one of them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TvwmwjAphrTMcKjVd8TbyE Signed-off-by: linzhenpl07 <linzhenpl07@gmail.com>
Purpose
Eleven changes to the LingBot-World realtime path that leave the output bit-identical. Each removes work that reproduces a result an earlier forward already had, recomputes something that cannot change within an AR block or a session, or converts data on the wrong side of a device boundary.
reuse_history_staging)generate_block, which the realtime server never callsAll are verified bit-identical against the unoptimized path: the first four over 40 saved chunks (
max_abs 0.0) teacher-forced against a bitwise-repeatable eager reference; the uint8 conversion againstVideoProcessoritself and byte-for-byte on the fragmented-MP4 output of the real server; the fixed point by construction (a block whose returned encoder history is bit-identical to the history it was given proves the encoder is at a fixed point, and every block is still checked against the whole-clip encode in the tests).Measured: served path, cumulative
reuse_history_staging: true)Run-to-run on this protocol: the same code measured 736 and 715 ms on two different four-card sets (three sessions each), so the resolution of any single row is about ±10 ms. The last two commits sit inside that floor served: the static timestep buffer is justified by the campaign's same-run ledger (200 -> 5 device copies per chunk, -7 ms of DiT wall in its harness) rather than by a served delta, and the conv input assembly removes memory traffic the sharded decode turns out not to be bound by at 480x832. Both are bit-exact and tested; neither is claimed as a served gain.
This PR alone, with the stock VAE decode, takes the served path from 1326 to 920 ms per chunk at the current head (1.44x; 916 before the review rounds, inside the run-to-run floor), which is real time at the 12 fps basis (RTF 0.94). With #7651 on top, both PRs at their current heads, it is 721 ms (718 on the control run); the same combination measured 715-736 ms before this PR's review rounds, so those rounds cost nothing on the all-in path either.
Protocol: 4xH200 (GPUs on one NUMA node), 480x832,
benchmarks/lingbot_world/configs/usp4_compiled.yaml(Ulysses SP-4, regionaltorch.compile+ CUDA graphs,dtype: bfloat16), the WebSocket realtime endpoint,benchmark_lingbot_world_realtime.pywith one warm-up session and one measured 40-chunk session per arm, steady state = chunks after the sixth. Every arm is a fresh server on the exact commit, in one held reservation, on an idle host (arms whose std exceeded 60 ms were re-measured; the stock arm was measured at both ends of the chain and moved 14 ms). Single-chunk end-to-end latency (session tick to muxed bytes) tracks cadence plus ~45 ms of overlapped delivery on every arm.Reading the first rows honestly: the four original changes are worth nothing on the served path as they stood. The camera cache's window was only ever opened by
generate_block, which the stepwise server path does not call, so the injector still ran five times per block; that is what the last commit fixes, and it is the -62 ms row. The source-image cache cannot fire either, because a realtime tick carries its image inline rather than as a path. The cross-attention change and the K/V restage are DiT-side and within the run-to-run spread here. The lossless set is stock 1326 -> 1026 ms (-300, 1.29x); with the camera window it is -362 on the DiT/host side. In-process numbers on an oldermainthat this PR used to quote are gone; these replace them.Driver-rank stage medians per chunk (ms), from a wall-clock overlay; the pipeline profiler (
--enable-diffusion-pipeline-profiler, device-synchronised) puts the width-sharded streaming decode at 104 ms of GPU time where the unsynchronised span shows 54, so the difference sits in the "GPU tail" row:Decisions worth reviewing
Camera reuse is scoped to one AR block. The caller constructs a
CameraModulationCacheper block and passes it explicitly ascamera_modulation_cache=.generate_blockshares it across its forwards; the stepwise pipeline keeps it in request state across the four probes and commit, then drops it. Interleaved sessions therefore hold separate caches. The caller must keep the camera fixed for that block. The retained scale and shift tensors are included inARDiffusionKVCacheSpec.model_owned_state_bytes_per_sessionbefore KV admission: at 480x832, 40 layers and bf16 they cost 958,464,000 bytes per session per rank under Ulysses SP-4, or 3,833,856,000 bytes without sequence sharding.Cross-attention replicates text K/V instead of sharding it. Every query token attends the whole text sequence independently, so the sequence split Ulysses already provides is the only split needed. The cost is text K/V per rank rather than head-sharded (about 314 MB/rank at this size).
ARDiffusionCrossAttentionKVSpecgrows an optionalnum_kv_heads; absent, it still means "same as self-attention".K/V restaging is off by default (
ARDiffusionKVConfig.reuse_history_staging): two max-sequence buffers per layer per rank, allocated by the KV manager alongside the pools outside any capture,mark_static_address, declared in the custom op'smutates_args. The measurement row above is the first for this implementation (the earlier number came from a Triton runtime patch).Realtime ticks always deliver
(F, H, W, 3)uint8. The streaming endpoint muxes uint8 frames, sopilandnponly ever differed in how much work stood between the decoder and those bytes; onlylatentkeeps its meaning. The conversion isVideoProcessor's arithmetic in its order, on the device.The condition fixed point is detected, never assumed. It is per session, cleared with the session's encoder history, and a session restarted from a new source image encodes again until it settles. On the served path it settles by the eleventh block.
Step grouping is a pipeline capability plus an AR-runner policy, not a public switch, and the shared runner stays single-step. Step execution hands the runner one denoise step per scheduler cycle; for a lone streaming request that is four hops per chunk through the scheduler, the executor and the workers, with the GPU queue draining at each. Grouping the steps to the chunk boundary removes the hops but also means the scheduler gets no control between them, so it is not folded into
step_executionand not exposed as a user-facing Boolean either (an earlier revision hadstepwise_chunk_per_call; review argued, rightly, that a single-request batch is not a scheduling argument). Instead: a step-execution pipeline may declare the optionalsupports_chunk_step_grouping = True(LingBot World does: a block is four probes and a commit over one prepared chunk state), and everything else lives inexperimental/ar_diffusion/runner.py.DiffusionModelRunner._execute_stepwise_core()keeps its original one-step-per-call semantics with no policy hook;ARDiffusionModelRunneroverrides it to run the parent's core once with the scheduler's request and then, for a lone streaming request of a pipeline that declares the capability, to keep calling that same core with a continuation of the scheduler output (the request as a cached request, nothing new to admit, nothing to retire, no prefetch job or connector metadata) until a call emits a result, finishes the request or leaves it without state. Grouped execution is capped at the declaredchunk_num_steps; failure to reach a boundary returns a request error and drops its state. Every grouped step is thus exactly what the next scheduler cycle would have asked for, batch rebuild included, and it all runs inside the one session KV binding and the fail-closed scope the AR runner already wraps around a stepwise call; the chunk barrier is unchanged and no per-step device sync is added. The AR path regains control at every chunk boundary, where its interactions are applied, and the pipeline'sinterruptis still checked between grouped steps. Other pipelines keep step granularity until they declare the capability and are covered by equivalent tests. Measured served with three sessions per arm: 777 -> 736 ms with the loop in the shared runner; re-measured after moving it into the AR runner: 966 ms, because the per-step batch rebuild that review asked for re-enteredInputBatch.make_batch, whose index mapping was built on the device and read straight back (tolist()andcpu()), a device synchronisation between every two steps that gave the grouping gain back. Building that mapping on the host (next paragraph) brings the same code to 920 ms.Nothing model-specific in shared modules. The shared changes are generic AR-diffusion ones (paged attention staging, cross-attention K/V head count, one pinned async copy helper already used by the slot mappings). A benchmarking env override that an earlier revision of this branch had put into the shared model runner is gone.
Review fixes (2026-09-17)
Three findings on the opt-in paths, all reproduced on CPU before fixing:
The step batch's index mapping is built without a device read-back.
InputBatch.make_batchcreated its identity index mapping withtorch.arangeon the latents' device and then calledtolist()andcpu()on it, two host waits for everything queued on the stream, on every batch build, which is every step of every stepwise request. That is the ~170 ms per chunk of the host waiting inside the runner that the ungrouped served path always showed, and it is what re-appeared when the batch was rebuilt between grouped steps. The identity mapping is known on the host: the device tensor is now a plain launch and the host array comes from NumPy; only a caller-supplied mapping is still read back. Same ids, order, device tensor and host array as before; the test forbidsTensor.tolistandTensor.cpuduring the default build and the cached repack. Measured served, three sessions per arm: grouped 966 -> 920 ms; the same fix applies to the ungrouped path of every step-execution pipeline.stepwise_chunk_per_callBoolean with the capability/policy split described above; no user-facing setting is added. A second round moved the grouping loop itself out of the shared runner intoARDiffusionModelRunner._execute_stepwise_core(), sodiffusion_model_runner.pycarries no model-path code for it.InputBatchbuilt before it; a denoiser that reads latents or timesteps from the batch (Helios stage 1) saw the first step's values throughout. The batch is now rebuilt between steps exactly as the next scheduler cycle would; the test drives a denoiser that depends on both fields through both modes and checks the per-step inputs and the final latent are identical.current_blocksentries of the padded table, which are padding (the action-capacity block, plus unused window capacity while the window grows), so the current rows from the previous probe stayed in place and were read. The refresh now starts at the visible history's block count, carried from the host-side metadatabuild_block_tablealready computes, no device sync. Test: two probes of one block through the real table layout with an empty, partial, full and slid window, staged buffers equal to a full gather byte for byte.Prerequisites
The branch carries
[LingBot-World] Cut launch/collective count under Ulysses + CUDA graphsand[AR-Diffusion] Allocate the flat K/V slot pools as the base tensorsunderneath, which the K/V restaging extends; they can be split into a prerequisite PR if preferred.Test Plan
pytest tests/diffusion/ar_diffusion tests/diffusion/models/lingbot_world tests/diffusion/distributed/test_comm.pyon CPU.pytest tests/diffusion/models/lingbot_world/test_lingbot_world_transformer.py -k "sp2 or sp4 or tp2"on 4xH200 under a GPU reservation.pre-commit run ruff-check --all-filesandruff-format --all-files.New tests pin the properties each change relies on: that a decode cache never serves a file rewritten under it; that the camera modulation depends only on the camera tokens and the injector runs once per window; that cross-attention K/V is replicated rather than sharded; that staged reuse equals a full gather byte-for-byte; that every pixel output type streams uint8 frames and the bytes equal the PIL path's; that the encoder stops once its history stops changing, restarts with the session, and every block still equals the whole-clip encode; that the five forwards of a stepwise block share one camera cache and the next block gets its own; that a chunk run in one call emits the same outputs at the same step indices as one run step by step; that two forwards' timestep projections land in one buffer per shape; that the single-pass conv input equals the cat/pad/halo sequence byte for byte on one rank and on both ranks of a pair, and equals the stock conv for a halo-free kernel.
Test Result
tests/diffusion/ar_diffusion tests/diffusion/models/lingbot_world tests/diffusion/distributed/test_comm.py-- 318 passed, the 3 multi-GPU parity tests deselected; the lingbot_world suites alone -- 263 passed;tests/diffusion/test_diffusion_step_pipeline.py tests/diffusion/test_diffusion_config_fields.py tests/config-- 619 passed, 1 skipped.main.test_lingbot_world_transformer.py -k "sp2 or sp4 or tp2"-- 3 passed (164 s) on the pushed head under a GPU reservation.pre-commit run ruff-check --all-files/ruff-format --all-files-- Passed.vLLM Version: 0.29.0
vLLM-Omni Commit: branched from
507cb1d83🤖 Generated with Claude Code
CPU property tests provide the standing regression coverage; the 4xH200 parity, CUDA-graph and served timings above are author-run measurements, not results from ordinary CI. They have not been remeasured for this merge and review cleanup.
Validation after merging main: 295 passed, 5 skipped across the LingBot pipeline, stepwise runner, Wan spatial sharding, AR KV cache and capability-runner tests. Other pre-commit checks passed; mypy still reports existing typing errors in surrounding code.