Skip to content

Storage lowering lets Inductor recompute an in-place indexed copy (x.copy_(x[idx])), mixing rows across requests in batched speculative verification #136

Closed

Description

@jwmacd

Summary

Since 317c1ec ("Preserve compiled storage mutations without cloning backing buffers"), _lower_storage in ignition/preparation/compilation.py skips the flare::storage_call boundary for a complete aten.copy_ when no later node writes the source's storage. It does not check whether the source expression reads the destination's own storage. Two packed-to-logical expansions in the DeepSeek V4.1 path are exactly that shape, and Inductor then recomputes the gather while writing the same buffer, so some logical rows read packed rows that another row has already overwritten.

On DeepSeek-V4.1-Flash-1017, TP4, with speculative decoding (DSpark, adaptive verification), batched requests produce corrupted text and tool calls, including fragments of other concurrent requests' content. The same build serves single requests and requests with speculatation off correctly.

Affected code (unchanged at acbf285)

  • ignition/preparation/compilation.py:503-506, the copy_ skip condition.
  • ignition/components/vocabulary.py:41 sets self.gathered = output[:rows] when the TP shards cover the vocabulary exactly (for example 129280 / 4), and :53 runs self.output.copy_(gathered[self.row_indices, :self.output.shape[1]]). The comment there relies on the indexed read materializing first.
  • ignition/recipes/deepseek41/decoder.py:311 sets self.head_hidden = scratch.hidden[:head_rows], and :351 runs s.hidden.copy_(self.head_hidden[samples]).

Generated code

For the logits expansion, Inductor's output computes the gather correctly into a fresh buffer, then recomputes it in place instead of copying that buffer:

buf2 = empty_strided_cuda((32, 129280), (129280, 1), torch.float32)
triton_poi_fused_index_slice_view_0.run(arg0_1, arg1_1, buf2, 4136960, stream=raw_stream0)
triton_poi_fused_copy__index_slice_view_1.run(arg0_1, arg1_1, arg1_1, 4136960, stream=raw_stream0)

Reproduction (component level, no model)

A module whose forward writes a packed prefix of a buffer through a mutating custom op, then runs storage.copy_(storage[:physical][indices]) with the packed-to-logical row map that Metadata.set builds (begins[:, None] + arange(width), clamped to physical capacity). Geometry: batch 4, width 8, physical capacity 12, columns 129280 (FP32) or 5120 (BF16), lengths such as (3,3,3,3), (1,7,2,2) and (0,4,7,1). Compiled with compile_forward and with aot_forward, checked against eager over 33 trials each (3 direct calls, 30 CUDA-graph replays):

Case Unguarded, RTX PRO 5000 Blackwell (SM120) Unguarded, RTX 4090 (SM89) Guarded, both
logits, compile_forward wrong valid rows in 33/33 trials 33/33 0 wrong
logits, aot_forward 33/33 33/33 0 wrong
hidden, compile_forward 25/33 padding rows only 0 wrong
hidden, aot_forward 23/33 31/33 0 wrong

For lengths (3,3,3,3), the third and fourth requests' logical rows carry the second request's packed rows.

Environment: flare-prepare built at a fork of NVIDIA main through 225d466 (no local changes to the files above), torch 2.14.1+cu132, CUDA 13.2.

Proposed fix

Keep the opaque boundary whenever the copy's source reads storage the copy writes:

if (target is torch.ops.aten.copy_.default
        and not (accesses[node.args[1]] & mutable_keys)
        and not any(last_write.get(key, -1) > positions[node] for key in accesses[node.args[1]])
        and all(complete_write(arg) for arg in mutated)):
    continue

With this condition, every case above passes. In the full model (stock recipe, TP4 on RTX PRO 6000 Blackwell), a guarded export served with the same runtime passes a concurrent test of tool, reasoning, plain and exact-copy prompts: exact tool arguments, byte-exact copies and no cross-request content.

Prepared with the assistance of AI.

Activity

  1. mark-yong commented on Nov 3, 2026

    @mark-yong
    Contributor

    Reproduced on one SM120 GPU, think #158 already fixed it.

    Using vocabulary projection shape: a pack op writes rows into a storage, output.copy_(gathered[row_indices, :cols]), torch.compile, four concurrent requests, 33 trials. Same harness against three trees:

    Tree compile/logits compile/hidden aot/logits aot/hidden
    acbf2858 (as filed) 33/33 corrupt 33/33 corrupt 33/33 corrupt 33/33 corrupt
    acbf2858 + the fix proposed above 0/33 0/33 - -
    current main (cc696a6b) 0/33 0/33 0/33 0/33

    The bad rows are the later requests' logical rows ([13, 14, 15, 18, ...]) picking up content the copy had already overwritten, and the vulnerable tree does generate a triton_poi_fused_copy__index_slice_view_* kernel that gathers inline and stores into the same buffer. My hidden case corrupted 33/33 rather than the 25/33 reported, so presumably it depends on what the scheduler does that run.

    Putting a print in the skip condition shows every tree computes the hazard correctly (disjoint is False for the gather source); acbf2858 just never consults that clause. The fix proposed above clears the corruption on the vulnerable tree, and #158 lands basically the same clause on the copy_ skip condition (mutable_keys.isdisjoint(accesses[node.args[1]])), so a complete copy whose source reads the region it writes stays an ordered storage call instead of getting folded into the writeback. The #158 description independently walks through this failure, wrong tokens from other requests when two or more requests verify in one round, and it merged Oct 30, two days after this issue was filed.

    @jwmacd can you rerun against current main? If it's clean for you too I'd close this as fixed by #158.

  2. jwmacd commented on Nov 4, 2026

    @jwmacd
    Author

    Thanks. Confirmed it runs clean against current main.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions