Skip to content

[Core] Add per-hook request-row gather, PackedDemandPlanner, and fused inline projection heads - #172

Merged
zhaoyboo merged 1 commit into
mainfrom
feat/per-hook-gather-and-inline-heads
Nov 5, 2026
Merged

zhaoyboo merged 1 commit into
mainfrom
feat/per-hook-gather-and-inline-heads

Conversation

@zhaoyboo

@zhaoyboo zhaoyboo commented Nov 4, 2026

Copy link
Copy Markdown
Collaborator

Depends on #171.

Summary

Each HookPoint can copy only the rows it needs -- some requests, or absolute
token windows of them -- chosen per step through a device row table that
captured graphs re-read on every replay. Optional fused projection heads score
selected rows inside the graph so only K fp32 scores leave the GPU.

Implementation details

  • Native: gather, head and fused producers (one-warp prefix scan over the
    table, -1 disables the producer, host-mirror skip of eager launches,
    device-updatable gather nodes gated by a planner); 8-wide head loads only
    from 16-byte aligned bases (views at odd storage offsets take the scalar
    path); per-hook request layouts and orig_position in metadata;
    parse_hook_requests.
  • ring::producer_gather/head/fused validate every row table (device,
    contiguity, int64, a segment count) and the head arena and accumulator
    devices, raising instead of skipping a reserved producer.
  • Python: dmi.hook_rows (plan_hook_rows, HookRowPlanner,
    PackedDemandPlanner, full_row_plan, encode_row_table, row_signature,
    segment_array, configure_hook_gather, configure_hook_heads);
    StepContext.hook_rows / orig_positions;
    RingTransport.set_prefill_table_gate; BackendAdapter.replace_active_specs;
    head hook types resid_pre_head, final_ln_head. The eager head path
    refuses an output larger than the ring's effective capacity before
    reserving.
  • docs/request-row-gather.md.

Compatibility and scope

Opt-in per HookPoint.

Validation

  • Native: test_producer_equivalence, test_inline_head, test_fused_head
    (each DMI_TEST_MUTATE_* mutant fails as required).
  • Python: test_hook_rows.py (planners against independent enumerations),
    test_per_hook_gather_gpu.py (22 passed, including misaligned-base
    heads, which fault with a misaligned address without the alignment check,
    and malformed-table and device-mismatch rejection),
    test_inline_head_eager.py (2 passed).
  • CPU tests: 2323 passed, 458 skipped.

@Samfisheryu

Copy link
Copy Markdown
Collaborator

Independent audit of #172 at 4362f6faf49c2d74d13c26c9e71bc63a3764b809: five findings; changes requested before merge. This reviews the increment on #171 and interactions in the stacked series. Inherited #171 findings are reported in the #171 audit rather than counted again here.

Evidence below separates executed CPU reproductions from static CUDA-path analysis. No GPU tests, CUDA initialization, inference, profiler, or performance benchmarks were run. All GPU verification remains deferred until the owner's explicit instruction. The scripts use the real checked-out Python implementation with producer/graph boundary doubles as identified below; they do not establish an observed GPU failure.

G172-1 — [P1] Clear gather/head attachment state on detach and ordinary reattach

Code: fused dispatch, new retained head configuration, and existing uninstall path.

Trigger: Install a row HookPoint, configure gather plus heads, detach it through uninstall_ring_hooks, then reinstall that same HookPoint with only its ordinary row spec. This is the install/uninstall lifecycle used by adapter attachment.

Actual, reproduced on CPU: The reattached HookPoint still dispatches producer_fused, retains the old gather table, head arena and accumulator, and retains head type 24 although the new spec list contains only (0,0). Uninstall clears _ring_hook_type and _ring_payload but none of the added feature state; reinstall makes that retained state live again.

Expected: A plain new attachment uses the ordinary producer and releases the prior attachment's feature resources. A retained enabled head table makes the native fused publication logic include an extra task without corresponding new metadata/reservation; retained row selection can also affect the row payload. That downstream consequence follows from the code and still needs GPU delivery validation.

Fix: Give gather/head configuration an explicit attachment lifetime and clear it in detach/reinstall, including tensor references and restoration of the previous strip configuration. Cover detach/reattach after enabled, disabled and exceptional configurations. See the shared executable reproduction below (detach_reattach_plain_spec).

G172-2 — [P1] Adding heads to a plain HookPoint removes its original row publication

Code: accepted public head configuration, unconditional fused dispatch, native enable predicates, and task publication count.

Trigger: Call configure_hook_heads on an installed ordinary/static HookPoint without first configuring gather, retain its row spec, and append its head spec with the real BackendAdapter.replace_active_specs API. The helper accepts this configuration and documents no gather prerequisite.

Actual, reproduced on CPU: For four fp32 rows with hidden width 4 and one head slot, the real host planner returns 2 tasks / 80 bytes (64 row bytes + 16 head bytes). Before configuring heads, forward calls producer; afterward it calls only producer_fused with gather_table=None. In the native kernel that makes g_on=false, while the non-disabled head table makes h_on=true; the publication code therefore emits only the head task. This is an accepted two-spec host plan, not a complaint about the valid low-level head-only API.

Expected: Both the original row task and the head task, in matching metadata order. Alternatively, reject an unsupported configuration before capture/reservation. The current mismatch breaks reservation/metadata/publication agreement; actual GPU misattribution or drain behavior remains untested. The eager safety branch dispatches the row separately and thus disagrees with the fused fast path.

Fix: Preserve static/prefix row dispatch when no gather table is installed, or reject that configuration and provide an explicit full-row initialization path. Cover plain+heads, prefix+heads, gather+heads and independent row/head on/off states. See the shared reproduction (heads_on_plain_hook).

G172-3 — [P2] full_row_plan keeps absolute offsets after packing away leading rows

Code: full-row contract and returned offsets.

Trigger: A valid physical layout with leading padding: requests A/B, token ranges (0,2)/(0,1), physical offsets 2/4, and total_rows=8.

Actual, reproduced on CPU: The real planner returns segments=((2,3),), row_count=3, and destination offsets=(2,4). The gather payload packs source rows [2,3,4] at destination zero. Applying the returned offsets gives A=[4], B=[], instead of A=[2,3], B=[4]. This independent payload/slice model demonstrates the indexing mismatch; the native consumer may reject the out-of-bounds slice rather than deliver those truncated Python slices.

Expected: Correct request attribution, and a full-row baseline that copies the same physical payload as the ungathered producer, as documented.

Fix: For that baseline contract, use the entire (0,total_rows) run and retain physical offsets. If compaction is intended, rebase destination offsets and explicitly change the contract instead. Include leading/trailing padding and a scheduled/filtered request list whose first physical offset is nonzero. See the shared reproduction (full_row_plan_leading_padding).

Executable CPU reproduction for G172-1, G172-2 and G172-3 (real helpers, planner and HookPoint; fake producer boundary)

Run from a checkout of the audited commit in the normal Python test environment (Python 3.10+, PyTorch and project Python dependencies). No native extension build is required. The payload object models only the CUDA-facing Python dispatch attributes; every actual tensor allocated here is a CPU tensor. CUDA initialization is replaced with a hard failure.

env CUDA_VISIBLE_DEVICES='' OMP_NUM_THREADS=1 MKL_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 PYTHONNOUSERSITE=1 nice -n 15 python - <<'PY'
"""Pure CPU audit evidence; native extension and CUDA initialization forbidden."""
import json
import sys
import types
from pathlib import Path

root = Path.cwd()
sys.path.insert(0, str(root / 'src'))
import torch

def forbid_cuda(*args, **kwargs):
    raise AssertionError('CUDA initialization forbidden during audit')
torch.cuda._lazy_init = forbid_cuda
assert not torch.cuda.is_initialized()

from dmi.hook_rows import full_row_plan, configure_hook_gather, configure_hook_heads
from dmi.hooks.dispatch import dispatch_producer, install_ring_hooks, uninstall_ring_hooks
from dmi.hooks.specs import HookSpec
from dmi.hooks.point import HookPoint
from dmi.adapters.base import BackendAdapter
from dmi.adapters.types import StepContext
from dmi.hooks.specs import ModelShapeConfig

# Stub only the runtime transport global. No native engine is loaded or constructed.
import dmi.transport
rt = types.ModuleType('dmi.transport.ring')
rt._active_transport = None
sys.modules['dmi.transport.ring'] = rt
dmi.transport.ring = rt
calls = []
for name in ('producer', 'producer_prefix', 'producer_chunked', 'producer_gather', 'producer_fused'):
    setattr(torch.ops.ring, name, lambda *args, _name=name: calls.append((_name, args)))

class FakeCudaTensor:
    is_cuda = True
    nbytes = 4 * 16
    shape = (4, 4)
    def contiguous(self):
        return self
x = FakeCudaTensor()
report = {'commit_root': str(root), 'cuda_initialized': False, 'checks': []}

# full_row_plan accepts legal leading padding, but keeps absolute source offsets
# after its gather segment packs those source rows starting at destination zero.
p = full_row_plan(['A', 'B'], [(0, 2), (0, 1)], [2, 4], [0, 1], 8)
source = list(range(8))
payload = [source[i] for start, count in p.segments for i in range(start, start + count)]
actual = [payload[off:off + hi - lo] for (lo, hi), off in zip(p.token_ranges, p.offsets)]
expected = [source[off:off + hi - lo] for (lo, hi), off in zip(p.token_ranges, p.source_offsets)]
assert actual != expected
report['checks'].append({'case': 'full_row_plan_leading_padding', 'segments': p.segments,
                        'row_count': p.row_count, 'offsets': p.offsets,
                        'payload': payload, 'expected_request_rows': expected,
                        'actual_request_rows': actual})

# Configure a head on an ordinary installed row HookPoint, as accepted by helper.
hook = HookPoint()
spec = HookSpec(hook_type=0, layer_no=0, module=hook)
install_ring_hooks([spec], ring_payload=object())
hook.forward(x)
assert calls[-1][0] == 'producer'
table = torch.tensor([1, 0, 4], dtype=torch.int64)
arena = torch.zeros(68, dtype=torch.int32)
acc = torch.zeros(4, 16)
configure_hook_heads(hook, head_type=24, layer_no=0, table=table, arena=arena,
                     acc=acc, slots=1, weight_bf16=False)
head_spec = HookSpec(hook_type=24, layer_no=0, module=hook, dtype=torch.float32)
adapter = types.SimpleNamespace(
    model_cfg=ModelShapeConfig(hidden_dim=4, num_heads=1, num_kv_heads=1,
                               head_dim=4, dtype=torch.float32, head_slots=1),
    active_specs=[spec], transport=types.SimpleNamespace(_active_specs=[spec]),
    _spec_needs_eager=lambda _: False)
# Use the real public method to append the head spec as documented, then the
# real host planner. No native ring or CUDA tensors are constructed.
BackendAdapter.replace_active_specs(adapter, [spec, head_spec])
ctx = StepContext(model_id='m', flattened=True, req_ids=['A'],
                  token_ranges=[(0, 4)], dim0_offsets=[0], kv_offsets=[0],
                  q_len=4, batch=0)
host_plan = BackendAdapter.plan_step(adapter, ctx)
assert host_plan.hook_count == 2 and host_plan.total_bytes == 80
calls.clear()
hook.forward(x)
assert len(calls) == 1 and calls[0][0] == 'producer_fused'
assert calls[0][1][2] is None  # CUDA kernel g_on = (gtab != nullptr && ...)
report['checks'].append({'case': 'heads_on_plain_hook', 'calls': [c[0] for c in calls],
                        'gather_table': None, 'row_task_enabled_by_kernel_predicate': False,
                        'head_task_enabled_by_kernel_predicate': True,
                        'active_specs': [[s.hook_type, s.layer_no] for s in adapter.active_specs],
                        'host_planned_hook_count': host_plan.hook_count,
                        'host_planned_bytes': host_plan.total_bytes,
                        'native_fused_publication_count_from_predicate': 1})

# Detach/reinstall canonical APIs leave added feature state armed.
configure_hook_gather(hook, table, 16)
uninstall_ring_hooks([spec])
assert hook._ring_hook_type is None
install_ring_hooks([spec], ring_payload=object())
calls.clear()
hook.forward(x)
assert calls[0][0] == 'producer_fused'
assert hook._gather_table is table and hook._head_arena is arena
report['checks'].append({'case': 'detach_reattach_plain_spec', 'calls': [c[0] for c in calls],
                        'gather_table_retained': hook._gather_table is table,
                        'head_arena_retained': hook._head_arena is arena,
                        'head_accumulator_retained': hook._head_acc is acc,
                        'reattached_specs': [[spec.hook_type, spec.layer_no]],
                        'unexpected_head_type': hook._head_hook_type})

assert not torch.cuda.is_initialized()
assert 'dmi.transport.native' not in sys.modules
print(json.dumps(report, indent=2))
PY

Observed at the audited PR commit (also reproduced at the later #174 endpoint):

full_row_plan_leading_padding:
  segments=[[2,3]], row_count=3, offsets=[2,4], payload=[2,3,4]
  expected_request_rows=[[2,3],[4]]
  actual_request_rows=[[4],[]]
heads_on_plain_hook:
  calls=["producer_fused"], gather_table=null
  active_specs=[[0,0],[24,0]], host_planned_hook_count=2, host_planned_bytes=80
  native_fused_publication_count_from_predicate=1
detach_reattach_plain_spec:
  calls=["producer_fused"], reattached_specs=[[0,0]], unexpected_head_type=24
  gather_table_retained=true, head_arena_retained=true, head_accumulator_retained=true
cuda_initialized=false

The native publication count is derived from the linked CUDA predicate, not from executing a CUDA kernel.

T172-4 — [P1] Excluded device-toggle graphs reach first replay without the required upload

Code: new device-updatable gather path, inherited capture-end upload helper, excluded replay, and excluded bind rejection.

Trigger: Device toggling is supported/armed and a capture is excluded from the host toggle registry, e.g. a partial-model segment graph containing the new gather producer. This PR creates an in-graph planner plus a device-updatable producer when device mode is armed and host capture recording is off. The upload helper originated in #171; #172 makes the defect reachable for this new gather graph.

Actual, reproduced with a CPU graph-lifecycle double: The wrapper forces keep_graph=True. At capture_end, no exec exists, so raw_cuda_graph_exec() raises; the helper swallows that exception and skips upload. bind_graph refuses excluded captures. On an allowed ungated excluded replay, the guard calls the underlying replay directly, which instantiates and launches without the helper uploading. The trace is capture_end(no exec) -> raw_exec raises -> bind refused -> instantiate -> replay, with zero upload calls.

Expected: Instantiate and perform the required upload before the first launch of a graph that updates its device-updatable nodes from within that graph. PyTorch documents that keep_graph=True defers instantiation until an explicit call or first replay (CUDAGraph lifecycle); CUDA requires upload for this in-graph device-update case (CUDA 13.0 launch-attribute contract). The double follows those lifecycle rules; no real GPU launch/error was observed or claimed.

Fix: Ensure instantiation and the necessary upload occur before first replay, including excluded captures, without forcing them into the host-toggle registry. Ensure upload/launch ordering. Add a realistic keep_graph=True fake (uninstantiated raw_cuda_graph_exec must raise), then validate excluded gather replay and repeated row off/on transitions on GPU after authorization.

Portable CPU lifecycle reproduction for T172-4 (loads real node_toggle.py; imports no torch/CUDA libraries)

Run from the same audited checkout. This reproduces API order, not device-node behavior.

env CUDA_VISIBLE_DEVICES='' OMP_NUM_THREADS=1 MKL_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 PYTHONNOUSERSITE=1 nice -n 15 python - <<'PY'
import importlib.util
import pathlib
import sys
import types

trace = []

class Graph:
    def __new__(cls, keep_graph=False):
        return object.__new__(cls)

    def __init__(self, keep_graph=False):
        self.keep_graph = keep_graph
        self.exec_ptr = None
        self.replays = 0

    def capture_begin(self):
        trace.append('capture_begin')

    def capture_end(self):
        if not self.keep_graph:
            self.instantiate()
        trace.append('capture_end(no exec)' if self.exec_ptr is None else 'capture_end(exec)')

    def instantiate(self):
        trace.append('instantiate')
        self.exec_ptr = 0x1100

    def raw_cuda_graph(self):
        return 0x100

    def raw_cuda_graph_exec(self):
        if self.exec_ptr is None:
            trace.append('raw_exec raises')
            raise RuntimeError('instantiate must be called when keep_graph=True')
        return self.exec_ptr

    def replay(self):
        if self.exec_ptr is None:
            self.instantiate()
        trace.append('replay')
        self.replays += 1

class Engine:
    def __init__(self):
        self.capture = False
        self.device = False
        self.uploads = []

    def clear_toggle_registry(self):
        self.capture = self.device = False

    def enable_device_toggle(self, enabled):
        self.device = enabled

    def toggle_device_mode(self):
        return self.device

    def enable_toggle_capture(self, enabled):
        self.capture = enabled

    def toggle_capture_enabled(self):
        return self.capture

    def upload_graph_exec(self, raw, exec_ptr):
        trace.append('upload')
        self.uploads.append((raw, exec_ptr))
        return 0

fake_torch = types.ModuleType('torch')
fake_torch.cuda = types.SimpleNamespace(CUDAGraph=Graph)
sys.modules['torch'] = fake_torch
pkg = types.ModuleType('audit_dmi')
pkg.__path__ = []
sys.modules[pkg.__name__] = pkg
transport = types.SimpleNamespace(toggle_gate_active=True,
                                  gated_step_requires_registry=lambda: False)
transports = types.ModuleType('audit_dmi.transport')
transports.ring = types.SimpleNamespace(get_active=lambda: transport)
sys.modules[transports.__name__] = transports
spec = importlib.util.spec_from_file_location(
    'audit_dmi.node_toggle', pathlib.Path('src/dmi/node_toggle.py'))
nt = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = nt
spec.loader.exec_module(nt)

engine = Engine()
nt.begin_capture_window(engine, exclude_capture=lambda: True, device_toggle=True)
graph = fake_torch.cuda.CUDAGraph()
graph.capture_begin()
graph.capture_end()
try:
    nt.bind_graph(engine, graph)
except RuntimeError as exc:
    bind_error = str(exc)
else:
    raise AssertionError('expected excluded bind rejection')
nt.end_capture_window(engine)
nt.set_replay_guard(True)
graph.replay()
assert graph.keep_graph and graph.replays == 1 and engine.uploads == []
assert trace == ['capture_begin', 'capture_end(no exec)', 'raw_exec raises',
                 'instantiate', 'replay']
print('bind_error=' + bind_error)
print('trace=' + repr(trace))
print(f'upload_calls={len(engine.uploads)}, replay_calls={graph.replays}')
PY

Observed:

bind_error=node toggle: refusing to bind a capture excluded from the registry
trace=['capture_begin', 'capture_end(no exec)', 'raw_exec raises', 'instantiate', 'replay']
upload_calls=0, replay_calls=1

G172-4 — [P2] Row-table validation does not bound segment reads by storage capacity

Code: accepted storage checks, pointer-only handoff, and shared table loader.

Evidence classification: static access-bound proof only. No GPU execution or sanitizer run.

Trigger/counterexample: A contiguous int64 CUDA table of one element on the payload's device, with table[0]=1, passes every table-storage check. The loader clamps the count only against GATHER_MAX_SEGS=256; for segment zero it reads table[1] and table[2], outside a one-element allocation. The actual element count is not passed to the kernel. The same loader serves gather, standalone heads and fused producers.

Expected: Reject or handle an invalid count-versus-capacity combination safely without reading outside the table. The existing clamps on source row indices and payload row count do not bound table reads.

Fix: Pass table capacity to producers and bound the dynamic count by both physical capacity and the semantic limit, or enforce sufficient fixed-width storage. Preserve capture/replay without a new per-replay host read/synchronization of table[0].

CPU-only counterexample arithmetic and exact source checks

This script inspects the real audited implementation and demonstrates the index arithmetic; it deliberately does not create a CUDA tensor or claim to reproduce a GPU fault.

python - <<'PY'
from pathlib import Path
op = Path('native/csrc/ring/ring_torch_op.cpp').read_text()
cu = Path('native/csrc/ring/producer.cu').read_text()
assert 'TORCH_CHECK(table.numel() >= 1' in op
assert 'int64_t n_raw = table[0];' in cu
assert 'table[1 + 2 * k]' in cu and 'table[2 + 2 * k]' in cu
storage = [1]
n_segs = max(0, min(storage[0], 256))
reads = [index for k in range(n_segs) for index in (1 + 2*k, 2 + 2*k)]
outside = [index for index in reads if index >= len(storage)]
assert outside == [1, 2]
print({'numel': len(storage), 'segment_count': storage[0], 'out_of_bounds_indices': outside})
PY

Expected: {'numel': 1, 'segment_count': 1, 'out_of_bounds_indices': [1, 2]}.

Deferred device tests: one-word table/count 0; one-word/count 1; three-word/count 2; odd/even storage lengths; 256/257 segment limits; and mutation of the count across replays. Actual GPU memory validation waits for authorization.

GPU performance remains unverified. Fused launch savings, occupancy/register/shared-memory cost, independent disable paths and the never-enabled/full pipeline baseline need measured comparison; this CPU/static review does not certify a speedup or zero overhead.

@zhaoyboo
zhaoyboo force-pushed the feat/per-hook-gather-and-inline-heads branch from 4362f6f to dc06342 Compare November 4, 2026 21:41
@zhaoyboo
zhaoyboo force-pushed the feat/cuda-graph-node-toggle branch from 2323c99 to 4e234b4 Compare November 4, 2026 21:41
@zhaoyboo

zhaoyboo commented Nov 4, 2026

Copy link
Copy Markdown
Collaborator Author

@Samfisheryu thanks for the audit. All five findings are addressed. G172-1..4 are in dc06342, rebased onto the revised #171. T172-4 is fixed in #171 (4e234b4), because the upload helper lives in node_toggle.py there.

G172-1 — gather/head state belongs to one attachment

  • install_ring_hooks and uninstall_ring_hooks call dmi.hook_rows.clear_hook_row_features. That releases the row table, head table, arena and accumulator, and restores the _strip_row_bytes that configure_hook_gather replaced. A detached HookPoint therefore drops those tensors, and a plain re-attach runs its ordinary static/prefix/chunked producer.
  • Installing also drops the previous row key. A head spec binds the payload but never sets the row key, so re-installing a former row HookPoint with only its head spec leaves it head-only.
  • Tests in tests/test_hook_row_features.py cover detach/reattach after an enabled configuration, a disabled one (-1 table), a forward that raised, a rejected configuration, a reinstall without detach, and restoration of the prefix and chunked strip modes. A weakref check confirms the tensors are released.

G172-2 — heads never replace the row publication

Dispatch now follows the spec order (row task, then head task):

  • row spec + gather table + heads → one producer_fused node publishing both;
  • static, prefix or chunked row producer + heads → the row producer, then a head-only producer_fused node, recorded and toggled under the head key only;
  • heads alone → the head-only node.

The eager safety net makes the same launches in the same order, under the same per-key gates. For every row mode × heads combination, test_fast_path_publishes_the_planned_tasks_in_spec_order and test_eager_safety_net_matches_the_fast_path_and_the_toggles check that BackendAdapter.plan_step, the fast path and the eager path agree. test_reviewed_plain_plus_heads_configuration_publishes_both_tasks is your heads_on_plain_hook case.

G172-3 — full_row_plan is the ungathered payload

It now returns the whole physical tensor ((0, total_rows),) with the step's physical offsets, so leading, interior and trailing padding match the ungathered producer. For your case it gives segments=((0,8),), row_count=8, offsets=(2,4) and request rows [[2,3],[4]]. tests/test_hook_rows.py adds leading/trailing padding and a filtered request list whose first physical offset is nonzero.

G172-4 — table reads are bounded by storage

  • The gather, head and fused ops pass table.numel() down to the kernels. There is no host read of table[0], so captured replays still re-read the count on the device.
  • The shared loader clamps the count to min(GATHER_MAX_SEGS, (numel - 1) / 2) via row_table_segment_count in the CUDA-free row_table.h.
  • The Python helpers and the launchers reject tables with fewer than one word.
  • tests/test_row_table_clamp.py compiles tests/native/test_row_table_clamp.cpp against that header (no CUDA). It covers one-word tables with counts 0, 1, 256 and INT64_MAX (no segment is read); three-word tables with counts 1 and 2; odd and even lengths; negative counts including -1; and the 255/256/257 limits on a full-width table. For each case it checks that every index read stays below numel. A second test checks that every table reader in the kernels receives the capacity. A count that changes across real graph replays still needs the deferred GPU test.
  • The native kernel tests now pass the element count.

T172-4 — excluded device-toggle graphs are uploaded before first replay (fixed in #171)

  • In device-toggle mode, any capture that may hold producers is instantiated and uploaded at capture end, excluded captures included. Excluded captures are not enrolled in the host registry.
  • An exec that was re-instantiated afterwards is uploaded before it replays. The upload record is kept per exec, so an unchanged exec is not uploaded again.
  • With your lifecycle double (whose raw_cuda_graph_exec raises before instantiation), the trace is capture_end(no exec) → raw_exec raises → instantiate → upload → replay. After a forced re-instantiation it adds upload → replay; further replays of the same exec add no upload.
  • Tests: test_excluded_device_toggle_capture_uploads_before_first_replay and test_device_toggle_capture_without_producers_is_not_instantiated in tests/test_node_toggle.py.

Your G172-1..3 script now fails its "current behavior" assertion at full_row_plan_leading_padding. The G172-4 source check fails as expected because the unbounded int64_t n_raw = table[0]; is gone.

Verification for this revision, CPU only: the native extension builds, and the -m cpu suite passes (2379 passed, 459 skipped). The one failure, test_cpu_native_build.py::test_every_torch_including_compile_requests_cxx20[host], also fails on main (e43b404). No GPU tests, sanitizer runs or benchmarks were run. Waiting for the owner's go-ahead: device-memory validation of the table bounds, excluded gather replay with row off/on transitions, and the fused-launch performance/occupancy comparison against the never-enabled baseline.

@Samfisheryu

Copy link
Copy Markdown
Collaborator

Independent follow-up audit at dc063424409108b23d8a2078a8b6a3f2e2c9971b, also cross-checked at #174 abaf9b18edf2ebc06fb6f7839cc6a396dbbf7cb8: G172-1/2/3/4 are resolved at their original CPU/static evidence level; T172-4 remains unresolved, and one additional P1 was found. Changes are still needed before merge.

No GPU tests, CUDA initialization, inference, GPU queries, sanitizers, or performance benchmarks were run. Device correctness and performance verification remain deferred until the owner's explicit instruction. Producer publication sizes below are derived from the native code; they are not observed GPU output.

Repaired findings positively verified

The targeted test_hook_row_features.py, test_hook_rows.py, and test_row_table_clamp.py run passed 81 tests. Separately, an independent CPU reproduction compared real BackendAdapter.plan_step and RingTransport.pre_push_all_metas with the producer dispatch and native publication predicates: 10 mode/path combinations plus 16 independent eager row/head gate combinations passed. The same runner retains the old 4362f6fa counterexamples; this was not merely checking that an old assertion now fails.

Original finding Re-audit result
G172-1: attachment state survives detach Resolved. Disabled-table detach/reattach releases table/arena/accumulator references, clears heads/gather, restores the saved strip stride, and returns to static/prefix/chunked dispatch. Weakrefs confirm release.
G172-2: adding heads drops ordinary rows Resolved. Static/prefix/chunked rows are followed by a head-only node; gather+heads remains fused. Head-only attachment has no row key. Planning, metadata order and publication sizes agree in the CPU matrix.
G172-3: full-row offsets after leading padding Resolved. Leading/trailing padding and a filtered first request retain physical offsets in the complete payload. The original example now returns ((0,8),), eight rows, and request rows [[2,3],[4]].
G172-4: table reads exceed storage capacity Resolved at the original static-bound level. The shared C++ clamp passes CPU tests, and every gather/head/fused path—including device-updatable gather—passes its table's numel through to the loader. No new host read of the dynamic count is introduced. Device/sanitizer validation is still pending.

T172-4 — [P1, still open] New producer ops do not update the counter used to decide upload and replay protection

The revised helper does instantiate/upload when it recognizes a producer. However, the actual new ops do not increment g_producer_launches: gather, standalone head, and fused. Static/prefix/chunked do increment it.

The inherited helper returns the counter comparison directly, so an available but unchanged counter suppresses the node-count fallback. Consequently:

  • An excluded capture containing device-updatable gather is classified producer=False, skips upload setup, and reaches first replay without the required upload. The original T172-4 path therefore remains reachable.
  • A late, unbound capture containing only gather/head/fused also appears producer-free and bypasses the replay guard. Bound graphs still take the bound-session path; this is not a claim that every gather graph bypasses the guard.

These are two consequences of one missing-counter wiring defect, not two separate findings. The mandatory device-node upload consequence specifically applies to the excluded device-updatable gather path.

Executed evidence: An independent CPU harness extracted and compiled the unchanged six C++ op bodies with ATen/engine API doubles, retaining their validation, engine, recording and counter calls. Results were identical at #172 and #174:

op                 engine_calls  record_calls  counter_delta
static/prefix/chunked     1             1            1
gather/head              1             1            0
fused                    1             2            0

Feeding those measured counter deltas into the real node_toggle.py, with a graph double that correctly defers instantiation for keep_graph=True, produced:

excluded gather/head/fused: producer_detected=false, uploads_before=0,
                           uploads_after=0, replays=1
late unbound gather/head/fused: producer_detected=false, replays=1, error=null
late static control: producer_detected=true, replays=0,
                     error="...not registered and bound..."
excluded counter-delta=1 control: uploads_before_replay=1

The CPU graph model demonstrates API order and classification, not CUDA device execution. The original execution records are retained with the audit; the copyable wiring check below exposes the missing connection directly.

Why the added test passes: LifecycleEngine.produce unconditionally increments its fake counter. The excluded upload test assumes the very connection missing from these real C++ ops.

Small reproducible source-wiring check (CPU only; complements the executed C++/lifecycle evidence above)

Run from this revision's checkout:

python - <<'PY'
from pathlib import Path
s = Path('native/csrc/ring/ring_torch_op.cpp').read_text()
for label, fn in [('static', 'ring_producer_impl'),
                  ('prefix', 'ring_producer_prefix_impl'),
                  ('chunked', 'ring_producer_chunked_impl'),
                  ('gather', 'ring_producer_gather_impl'),
                  ('head', 'ring_producer_head_impl'),
                  ('fused', 'ring_producer_fused_impl')]:
    body = s[s.index('void ' + fn + '('):]
    body = body[:body.index('\n}\n')]
    print(label, 'counter_increment=' + str('g_producer_launches.fetch_add' in body))
PY

Output: True for static/prefix/chunked; False for gather/head/fused. This small check alone is static evidence; the execution table above comes from compiling the unchanged op bodies, not from this string search.

Fix direction: Count all actual producer launches at a common boundary, or wire every new op and test that connection against the real wrapper. A partial counter must not override evidence that a graph contains registered producer nodes. Re-run excluded gather upload and late/unbound guard cases using the actual op counter behavior, then perform the deferred device tests.

G172-R2-1 — [P1, newly found] Batched eager heads reserve fewer bytes than the producer writes

Code: _eager_head size/capacity calculation. This uses x_cont.shape[0] * K * 4. The native entry point accepts rank ≥ 1 and uses the last dimension as hidden, and the launcher computes physical rows as nbytes / (hidden * element_size).

Trigger: A batched fp32 HookPoint [B,S,H]=[2,3,4], K=2, full-row head table [1,0,6], and eager safety fallback. This is an accepted configuration: compute_hook_shape explicitly supports [batch,q_len,K] heads. The reproduction supplies two requests, token ranges (0,3), batch offsets 0/1, and flattened=False, matching the ordinary batched context convention.

Executed CPU result: The real planner budgets row 96 + head 48 = 144 bytes. Real metadata shapes are [2,3,4] and [2,3,2]. The eager forward actually calls reserve_one(96) and reserve_one(16): 112 bytes reserved, 32 fewer than the native producer's size contract. The same calculation also allows a head-only 48-byte output through a 32-byte effective-capacity limit, reserving only 16 and raising no error.

The original #172 4362f6fa and both current #172/#174 exhibit this discrepancy. This is a previously missed original #172 defect, not a regression introduced by the repair commit. GPU overwrite/drain behavior was not executed; the reservation calls and disagreement with the native sizing contract are confirmed.

Copyable minimal CPU reproduction using the real public configuration, planner and HookPoint forward

Run from the reviewed checkout with Python, PyTorch and the project Python dependencies. No native build is needed. The fake payload exposes only dispatch attributes; all allocated tensors are CPU tensors. Producer calls and engine reservations are recorded instead of executing CUDA.

env CUDA_VISIBLE_DEVICES='' OMP_NUM_THREADS=1 MKL_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 PYTHONNOUSERSITE=1 nice -n 15 python - <<'PY'
import json, sys, types
from pathlib import Path
sys.path.insert(0, str(Path.cwd() / 'src'))
import torch
def forbid_cuda(*a, **kw):
    raise AssertionError('CUDA initialization forbidden')
torch.cuda._lazy_init = forbid_cuda
from dmi.hook_rows import configure_hook_heads
from dmi.hooks.point import HookPoint
from dmi.hooks.dispatch import install_ring_hooks
from dmi.hooks.specs import HookSpec, ModelShapeConfig
from dmi.adapters.base import BackendAdapter
from dmi.adapters.types import StepContext
import dmi.transport
rt = types.ModuleType('dmi.transport.ring')
sys.modules[rt.__name__] = rt
dmi.transport.ring = rt
calls, reserved = [], []
for name in ('producer', 'producer_fused'):
    setattr(torch.ops.ring, name,
            lambda *a, _name=name: calls.append(_name))
engine = types.SimpleNamespace(
    available_capacity=lambda: 128, available_task_slots=lambda: 16,
    flush_and_wait=lambda: None, reserve_one=reserved.append)
transport = types.SimpleNamespace(
    capture_step=True, force_eager=True, _ring_engine=engine,
    toggle_gate_active=False, effective_cap=128)
rt._active_transport = transport
class Payload:
    is_cuda = True
    shape = (2, 3, 4)
    nbytes = 96
    def contiguous(self): return self
x = Payload()
hook = HookPoint()
row = HookSpec(hook_type=0, layer_no=0, module=hook)
head = HookSpec(hook_type=24, layer_no=0, module=hook, dtype=torch.float32)
def arm():
    # Full physical source: six rows, two fp32 head slots; all actual tensors CPU.
    configure_hook_heads(hook, head_type=24, layer_no=0,
        table=torch.tensor([1, 0, 6], dtype=torch.int64),
        arena=torch.zeros(72, dtype=torch.int32),
        acc=torch.zeros(6, 16), slots=2, weight_bf16=False)
install_ring_hooks([row], ring_payload=object())
arm()
cfg = ModelShapeConfig(hidden_dim=4, num_heads=1, num_kv_heads=1,
                       head_dim=4, dtype=torch.float32, head_slots=2)
adapter = types.SimpleNamespace(model_cfg=cfg, active_specs=[row],
    transport=transport, _spec_needs_eager=lambda _: False)
BackendAdapter.replace_active_specs(adapter, [row, head])
ctx = StepContext(model_id='m', flattened=False, req_ids=['A', 'B'],
    token_ranges=[(0, 3), (0, 3)], dim0_offsets=[0, 1], kv_offsets=[0, 0],
    batch=2, q_len=3)
plan = BackendAdapter.plan_step(adapter, ctx)
hook.forward(x)
print(json.dumps({'plan_tasks': plan.hook_count, 'plan_bytes': plan.total_bytes,
    'reserve_one_calls': reserved, 'producer_calls': calls,
    'native_head_bytes_from_size_contract': (x.nbytes // (x.shape[-1]*4))*2*4}))
assert plan.total_bytes == 144 and reserved == [96, 16]
# A 48-byte head also bypasses the explicit 32-byte effective-capacity guard.
install_ring_hooks([head], ring_payload=object())
arm()
transport.effective_cap = 32
reserved.clear(); calls.clear()
hook.forward(x)
assert reserved == [16] and calls == ['producer_fused']
print(json.dumps({'head_only_effective_cap': 32, 'reserve_one_calls': reserved,
                  'native_head_bytes_from_size_contract': 48, 'raised': False}))
assert not torch.cuda.is_initialized()
assert 'dmi.transport.native' not in sys.modules
PY

Observed:

plan_tasks=2, plan_bytes=144
reserve_one_calls=[96,16], producer_calls=["producer","producer_fused"]
native_head_bytes_from_size_contract=48

head_only_effective_cap=32, reserve_one_calls=[16]
native_head_bytes_from_size_contract=48, raised=false

The 128-byte capacity in the first case is smaller than the 144-byte step plan, so this is precisely a situation where the adapter can select the per-hook eager safety path. The script directly selects that path to isolate the faulty calculation.

The short script above was independently executed on this revision. The larger cross-revision matrix and original-version control are retained with the audit.

Fix direction: Derive the eager head's row count from all dimensions preceding hidden, consistently with the native launcher, before both capacity checking and reservation. Alternatively, if heads intentionally support only packed 2-D inputs, reject batched configuration before publishing metadata or reserving, and align the shape/documentation contract. Add [B,S,H], B=1/S>1, S=1, row+heads/head-only, and capacity-crossing cases; GPU delivery/capacity validation remains deferred.

@zhaoyboo
zhaoyboo force-pushed the feat/per-hook-gather-and-inline-heads branch from dc06342 to 22a6482 Compare November 5, 2026 05:59
@zhaoyboo
zhaoyboo force-pushed the feat/cuda-graph-node-toggle branch from 4e234b4 to 541b10d Compare November 5, 2026 05:59
@zhaoyboo

zhaoyboo commented Nov 5, 2026

Copy link
Copy Markdown
Collaborator Author

@Samfisheryu thanks for the re-audit. T172-4 and G172-R2-1 are fixed in 22a6482. It is rebased on the revised #171, which carries the helper half of T172-4.

T172-4: the launch counter now covers every producer op

  • All six producer ops count their launch through a single count_producer_launch() in ring_torch_op.cpp: static, prefix, chunked, gather, head and fused. Each counts once per kernel launched, so a fused rows-plus-heads node is one launch. An op that returns before launching (no part present, not a CUDA tensor, or a refused table) counts nothing.
  • _captured_producers() (from [Core] Add CUDA-graph node toggling and device-side toggle support #171) treats a positive counter delta or recorded producer nodes as a producer. An unchanged counter can no longer hide registered nodes.
  • New test, tests/test_ring_torch_op_host.py, exercises the real op bodies rather than a counter double. It compiles the unchanged ring_torch_op.cpp against CPU-only Torch, using tests/native/ring/cpu_cuda_stub/ for the CUDA runtime and <ATen/cuda/CUDAContext.h>. A recording RingEnginePy double (tests/native/ring/test_ring_torch_op_host.cpp) is linked in. The library is loaded in a fresh interpreter and drives the real dmi.node_toggle lifecycle; the graph's producer is the compiled op, and producer_launch_count reads the real counter. Measured on the final stacked head:
    op           launcher_calls counter_delta | excluded: producers uploaded_before_replay | late unbound: producers replays fails_closed
    static                    1             1 |                True                   True |                   True       0 True
    prefix                    1             1 |                True                   True |                   True       0 True
    chunked                   1             1 |                True                   True |                   True       0 True
    gather                    1             1 |                True                   True |                   True       0 True
    head                      1             1 |                True                   True |                   True       0 True
    fused                     1             1 |                True                   True |                   True       0 True
    fused_rows                1             1 |                True                   True |                   True       0 True
    fused_heads               1             1 |                True                   True |                   True       0 True
    
    "Excluded" is a device-toggle capture excluded from the registry. It is now instantiated and uploaded at capture end, before its first replay. "Late unbound" is a capture after the window closed; its replay now raises "not registered and bound". The same test also checks the captured nodes: gather records the row key, head the head key, and fused both keys on one node, while chunked still records an anomaly. Against the previous ring_torch_op.cpp, 20 of the 35 cases fail, all on gather, head and fused.
  • Your source-wiring check now prints counter_increment=True for all six ops (matching count_producer_launch()).

G172-R2-1: batched eager heads

  • _eager_head now sizes the heads from every physical source row, which is the product of all dimensions before hidden. That is the count the launcher derives as nbytes / (hidden * element_size) and the one plan_step budgets for the [..., K] head shape. The same byte count feeds both the effective_cap check and reserve_one. On eager steps the integration writes the full-row head table, so this is the exact output size; gather and device-updated tables apply only on the captured path.
  • Your reproduction, with the assertions changed to the corrected expectations:
    plan_tasks=2, plan_bytes=144, reserve_one_calls=[96, 48], producer_calls=["producer","producer_fused"]
    head_only_effective_cap=32, reserve_one_calls=[], producer_calls=[],
      raised="in-graph head output (48 bytes for 6 rows x 2 slots) exceeds the ring's effective capacity (32 bytes)"
    
  • New tests in tests/test_hook_row_features.py cover [B,S,H], B=1/S>1, S=1, packed [N,H] and 1-D [H], each with row plus heads and with head only. Each checks the reservations against the 16-byte-aligned plan. A further test checks that a 48-byte head is refused under a 32-byte cap before any reservation or launch, and admitted at exactly 48.

The targeted files pass on this branch: node toggle, toggle registry, hook row features, hook rows, row table clamp and the new op test, 178 tests. All ring test binaries link. CPU only: no GPU tests, CUDA initialization or benchmarks were run. Device and sanitizer validation stays deferred until the owner gives the go-ahead.

@Samfisheryu

Copy link
Copy Markdown
Collaborator

Independent third review of 22a6482275d38c7bc0cb2de49eb236c74e65c13f: both remaining round-two items are resolved at the reviewed CPU/source level.

R2-172-counter / T172-4: I verified the real C++ op bodies, the common counter helper and the real Python lifecycle together. All six producer families now increment the counter once, including gather/head/fused. A fused producer still records both keys while counting one launch. The independent new/previous-version controls show:

latest gather/head/fused: counter_delta=1
excluded capture: producer_detected=true, uploads_before_first_replay=1
same exec replay: no repeated upload
late unbound capture: producer_detected=true, replays=0, registration error raised
previous revision: counter_delta=0, upload skipped, late replay forwarded

Recorded node evidence also remains positive when the counter has not changed. These observations establish corrected host call ordering and classification; device execution remains pending.

R2-172-batched / G172-R2-1: the original batched case now agrees across the actual planner, metadata and eager forward:

source [2,3,4], K=2:
  host/meta bytes=144
  reserve_one calls=[96,48]  # previously [96,16]
head-only output=48:
  effective_cap=32 -> rejected before reservation and launch
  effective_cap=48 -> reserve48, launch accepted

The independent matrix passed 10 mode/path combinations, 16 row/head gate combinations, 14 shape combinations and 16 capacity combinations at both this head and the full stack. It includes packed, batched, head-only, one-dimensional and empty-row inputs. The same runner retains the previous revision's contrary results.

The targeted gather/features/table-capacity files passed 93 CPU tests. The combined toggle/registry/real-op host files passed 85 CPU tests. The earlier four gather findings remain resolved. The latest workflow is successful.

No additional confirmed issue was found in this revision. GPU task delivery, numerical checks, actual device upload/replay and performance are still pending the owner's explicit instruction; no GPU work was performed.

@Samfisheryu Samfisheryu 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.

Approved at 22a6482275d38c7bc0cb2de49eb236c74e65c13f following the completed independent code and CPU/host review. The issues raised for this PR have been addressed and independently rechecked. The reviewed commit's CI is successful.

Review record: #172 (comment)

GPU execution and end-to-end performance benchmarks remain a separate follow-up, pending the owner's explicit instruction as agreed. This approval records the completed code/CPU review and does not claim those measurements.

@zhaoyboo
zhaoyboo force-pushed the feat/cuda-graph-node-toggle branch from 541b10d to ffbedf5 Compare November 5, 2026 23:12
…d inline projection heads

Let every HookPoint copy only the rows it needs, chosen per HookPoint and per
step through a per-HookPoint row table that captured CUDA graphs re-read at
every replay, and let a HookPoint compute linear projection scores inside the
graph so only the scores leave the GPU.

Native:
- producer_gather: packed copy of a row table's (source, rows) runs; -1
  disables the producer for the step (no reservation, task or payload). The
  table is loaded by all threads and the destination prefix is one warp's
  __shfl_up_sync scan; a single run takes a flat whole-grid copy.
- producer_head / producer_fused: K fp32 affine scores per selected row from
  a stable weight arena. The fused node runs the row producer and the heads
  in one kernel with separate tables and toggle keys, supports linear heads
  over several layers (FIRST/MID/LAST partial sums) and uses a fixed
  reduction order, bit-identical across batch compositions.
- The new kernels publish through the existing publication-word protocol
  (block release fences, last-block acquire, system-scope release store).
- Gather, head and fused producers record their kernel nodes in the toggle
  capture window; a fused node carries both keys. A gather producer that
  recorded no node could not be disabled with its key.
- Device-side toggling: in device-toggle mode, gather producers of captures
  outside the toggle window are captured as device-updatable nodes behind
  one planner node per captured forward.
- A pinned host mirror of the row tables lets uncaptured launches of
  disabled rows be skipped.
- Metadata carries optional per-hook request layouts (tuples or int64
  arrays, parsed once per shared layout) and each request's original
  position; the consumer slices final_logits by it.
- HOOK_TYPE_RESID_PRE_HEAD / HOOK_TYPE_FINAL_LN_HEAD score rows.

Python:
- dmi.hook_rows: reference planners (plan_hook_rows, plan_hook_groups,
  HookRowPlanner) with absolute token windows and table-capacity fallback;
  PackedDemandPlanner for declared per-request demand (rules compiled once
  per demand change, NumPy per step, plans equal to the reference planner's);
  full_row_plan (static full-row baseline) and encode_row_table.
- StepContext/RingTransport carry hook_rows and orig_positions; reservation
  and metadata use each planned hook's row count; the per-HookPoint table
  gate lets the per-step set follow the toggle on every step.
- HookPoint gather mode and fused heads, with their eager safety-net paths.

Attachment lifecycle, dispatch and table bounds:
- Gather tables and in-graph heads belong to one attachment.
  install_ring_hooks and uninstall_ring_hooks clear them
  (dmi.hook_rows.clear_hook_row_features): the row table, head table, arena
  and accumulator references are released and the strip stride gather
  replaced is restored, so a detached HookPoint drops those tensors and a
  plain re-attach runs its ordinary producer. Installing also drops the
  previous row key; a head spec binds the payload but never sets the row key.
- Heads on a HookPoint without a gather table keep its row publication: with
  a row spec, a gather table and heads one fused node publishes both;
  with a static, prefix or chunked row producer and heads the row producer
  launches first and a head-only fused node follows; heads alone launch the
  head-only node. The eager safety net makes the same launches in the same
  order, so the host plan, the fast path and the eager path agree on the
  tasks (row, then head) and their toggle keys.
- full_row_plan copies the whole physical tensor (0, total_rows) with the
  step's physical offsets, the ungathered producer's payload including
  leading, interior and trailing padding.
- The gather, head and fused ops pass table.numel() to the kernels (no host
  read of the count word), and the shared loader bounds the segment count by
  GATHER_MAX_SEGS and by the table's storage (row_table.h), so a count larger
  than the table never reads past it.
@zhaoyboo
zhaoyboo changed the base branch from feat/cuda-graph-node-toggle to main November 5, 2026 23:13
@zhaoyboo
zhaoyboo force-pushed the feat/per-hook-gather-and-inline-heads branch from 22a6482 to 6d7e741 Compare November 5, 2026 23:13
@zhaoyboo
zhaoyboo merged commit efc6238 into main Nov 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants