Skip to content

npu: a d3 pack's declared mask and its plan have to agree, or the route serves it causally (Refs #796) - #1293

Merged
bong-water-water-bong merged 3 commits into
mainfrom
1bit/796-plan-attention
Nov 6, 2026
Merged

bong-water-water-bong merged 3 commits into
mainfrom
1bit/796-plan-attention

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

Refs #796, the pack side. A d3 pack declares the mask it was trained with in its own decision_config.json
(attention_mode: noncausal_full_attention). The forward reads attention.mode from plan.json and
nowhere else (npu/forward/src/q4nx_forward.cpp:534), and plan.json is written by tools/npu_plan.py —
which the d3 conversion recipe in docs/npu.md did not include. So the two documented steps (pack, decision)
produce a container that is answered causally, with nothing in the log, the health body or the route saying
so.

Measured (same container, same binary, host GEMM, no device, no ticket)

~/lb/i796-fresh: the real 1.7 GB vllm-sr/d3-lite pack through safetensors_to_q4nx.py pack --arch qwen3_5

  • decision — 320 tensors, 820,971,656 B, weight bytes byte-identical to the shipped d3-lite-conv
    (sha256 313ac4f9…), verify 320/320. Reference = the pack's own d3_runtime.py on the same q4_1 weights
    (~/lb/796-rung1/ref_q41c_fresh.json). 10 contract cases / 12 questions incl. the 3-question alias:
arm plan.json [plan] attention.mode line worst |Δp| choice flips
fresh container, the recipe's two steps absent absent (0 occurrences) 0.3702 (score_three_levels) 1 (choice_unicode, no vs ok)
the same container after npu_plan.py — and the 2026-11-06 rung-1 arm present (noncausal_full_attention) printed 0.0246 (score_json_levels) 0

Every case moves, worst-first: score_three_levels 0.3702 vs 0.0013, choice_instructions 0.2404 vs 0.0003,
multi_three/rate 0.1673 vs 0.0151, multi_three/yes 0.1497 vs 0.0103, choice_unicode 0.1336 (flip) vs
0.0075, noul_instructions 0.1076 vs 0.0091. The pack is not missing the information — tools/npu_plan.py --print on that directory derives attention.mode: noncausal_full_attention, gate_up: fused and the M=16
decode dir from the container. The recipe was missing the step, and skipping it silently changed the model.

Change

  • docs/npu.md (d3 conversion paragraph): the third step is named, with why it is not optional —
    attention.mode is read from the plan only, and a device row also needs the plan for gate/up fusion and the
    M=16 decode twins.
  • The load refuses the disagreement instead of serving it: app/npu_systemone.h gains
    systemone_attention_disagreement(declared, forward_noncausal, plan_present, dir), which returns the refusal
    when decision_config.json says noncausal_full_attention while the forward is causal, naming whether
    plan.json is absent or stale and printing the tools/npu_plan.py command that fixes it. Called once after
    the decision head loads in app/forward_serve.cpp; npu/forward/include/q4nx_forward.h exposes
    noncausal() so the route can ask (it already keeps the flag for step()'s own refusal).
  • tests/systemone_route_test.cpp pins all four combinations — noncausal pack + causal forward refused and
    names the command; a plan that disagrees is "stale", not "absent"; a causal pack and a pack that declares no
    mask are untouched — so the rule is covered in ctest systemone_route without a model or a device.

Verification

  • systemone_route_test → systemone_route: ok, the four new assertions included. Tested the test: with
    the guard's condition replaced by if (true) return "";, exactly the three refusal assertions fail; restored,
    they pass.
  • The refusal end-to-end on the built binary (a plan-less directory beside an identical one with a plan):
    forward-serve exits non-zero naming decision_config.json, plan.json is absent and tools/npu_plan.py …,
    while the same directory with the plan serves and prints
    [plan] attention.mode noncausal_full_attention: ….
  • docs and the tool agree with the field the forward actually reads (one read site, q4nx_forward.cpp:534);
    no new probe and no second source of truth — the plan stays the one place attention.mode is decided.
  • tools/copyright.py --check rc 0; device never opened in any arm (0 /dev/accel* fds, NPU_NO_DEVICE_LOCK=1,
    so the 16:1x fleet blocker is not re-created).

Not in this PR

A labelled accuracy row (rung 3 of #796) — the issue's own "deliberately separate" exercise — and any change to
the forward's mask itself, which is #847's and is already in main.

…te serves it causally

A d3 pack declares `attention_mode: noncausal_full_attention` in its decision_config.json, and the
forward reads `attention.mode` from plan.json and nowhere else (q4nx_forward.cpp:534). plan.json is
written by tools/npu_plan.py, which the d3 conversion recipe did not include -- so the documented two
steps (pack, decision) produce a container the route answers CAUSALLY, with nothing in the log, the
health body or the reply saying so.

Measured on the real vllm-sr/d3-lite pack, host GEMM, same container and same binary, against the
pack's own d3_runtime.py on the same q4_1 weights (12 questions incl. the 3-question alias):

  plan.json absent   worst |dp| 0.3702 (score_three_levels), 1 choice flip (choice_unicode no -> ok)
  plan.json present  worst |dp| 0.0246 (score_json_levels),  0 flips   -- the rung-1 gate

That is issue #847's defect reached by a skipped step, at 15x the gate, so it is refused by name
rather than documented alone: systemone_attention_disagreement() is called once after the decision
head loads and names the declaration, whether plan.json is absent or stale, and the npu_plan.py
command that supplies it. q4nx_forward.h exposes noncausal() so the route can ask; the four
combinations are pinned in ctest systemone_route, and docs/npu.md's d3 conversion paragraph names the
third step and why it is not optional (it is also what a device row needs for gate/up fusion and the
M=16 decode twins).

Refs #796
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Close sequencing for #796 (since this PR is Refs, not Fixes): the issue's rungs 1 and 2 are met and
measured — the evidence table is in the issue
comment
, including this branch's
before/after and the rung-1 re-take on current main. Rung 3 (a labelled accuracy row) is the issue's own
"deliberately separate" exercise and is not attempted here. So #796 can close when this lands; I left it open
rather than closing it ahead of the merge, because until this merges a freshly converted pack still answers
causally.

@bong-water-water-bong
bong-water-water-bong merged commit 9643854 into main Nov 6, 2026
9 checks passed
@bong-water-water-bong
bong-water-water-bong deleted the 1bit/796-plan-attention branch November 6, 2026 20:40
@github-actions

github-actions Bot commented Nov 6, 2026

Copy link
Copy Markdown

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

🎫 Ticket compliance analysis 🔶

796 - Partially compliant

Compliant requirements:

  • The route answers a real d3-shaped pack (vllm-sr/d3-lite) through /v1/systemone
  • The pack loads under NPU_INFER_HOST_GEMM=1 and answers choice/noul/score through the route
  • The pack's declared mask and the forward's plan are now required to agree
  • The conversion process now includes the tools/npu_plan.py step to generate plan.json
  • The engine now refuses to load a noncausal pack without a corresponding noncausal plan

Non-compliant requirements:

  • The converter does not yet handle 235 non-2-D tensors (this is a separate issue)
  • The name map is not yet fully implemented (this is a separate issue)
  • The three transforms are not yet fully implemented (this is a separate issue)

Requires further human verification:

  • The numeric parity against d3_runtime.py needs to be validated in a full run
  • The device-side execution with the 7-design ELF set needs to be validated

847 - Partially compliant

Compliant requirements:

  • The forward now checks that the pack's declared mask and the forward's plan agree
  • The engine refuses to load a noncausal pack without a corresponding noncausal plan
  • The engine now provides a clear error message naming the missing or stale plan file and the command to fix it

Non-compliant requirements:

  • The full-attention path does not yet honor the noncausal_full_attention mode for prompt-only forward passes (this is a separate issue)
  • The engine does not yet support noncausal_full_attention mode for prompt-only forward passes (this is a separate issue)

Requires further human verification:

  • The full-attention path must be updated to honor the noncausal_full_attention mode
  • The test for mask agreement must be validated with a full run
⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Attention Mode Disagreement Check

The check for attention mode disagreement between the d3 pack's declared mask and the forward's plan is added, but it only checks for the presence of the plan file and its content. It does not validate that the plan file actually contains the correct attention mode. This could lead to a false positive if the plan file exists but is incorrectly formatted or contains an invalid attention mode.

// engine#796: a d3 pack's declared mask and the forward's plan have to agree before a request runs, or
// the route answers a noncausal pack causally with nothing in the log saying so.
if (e.head) {
    const std::string why = npu::systemone_attention_disagreement(
        e.head->config().attention_mode, e.fwd->noncausal(),
        std::filesystem::exists(dir + "/plan.json"), dir);
    if (!why.empty()) throw std::runtime_error(why);
}
Attention Mode Agreement Logic

The logic in systemone_attention_disagreement only checks if the declared attention mode is "noncausal_full_attention" and the forward is causal. It does not cover all possible combinations of attention modes and forward settings that could lead to a mismatch. This could lead to false negatives where a mismatch is not detected.

inline std::string systemone_attention_disagreement(const std::string& declared, bool forward_noncausal,
                                                   bool plan_present, const std::string& dir) {
    if (declared != "noncausal_full_attention" || forward_noncausal) return "";
    return "this pack declares attention_mode \"noncausal_full_attention\" in its decision_config.json, but the "
           "forward is causal: " + dir + "/plan.json is " + (plan_present ? "stale" : "absent") +
           ". Run tools/npu_plan.py " + dir + " --kernels <elf dir>, whose attention.mode is what the forward "
           "reads; answering a noncausal pack causally is a different model, not a smaller one";
}

bong-water-water-bong added a commit that referenced this pull request Nov 7, 2026
… converted pack is not served causally (Fixes #796) (#1310)

* npu: a d3 pack's own attention_mode declaration is what decides, so a converted pack is not served causally

#1293 refused a noncausal pack whose plan was absent or stale, at the route. That stopped the wrong
answer but left the conversion recipe incomplete: `pack` + `decision` produce a container that says
`noncausal_full_attention` in decision_config.json, and the forward read the mask from plan.json only,
so the documented two steps gave a causally-answered pack until someone ran tools/npu_plan.py.

The declaration now decides. q4nx_semantics.c gains q4nx_attention_mode(declared, plan_loaded,
plan_noncausal, why): the pack's word wins when there is no plan, a plan that AGREES is unchanged, and
a plan that DISAGREES is refused as stale in either direction (that is the only silent-wrong-mask left,
and its fix is in the message). The forward reads decision_config.json's attention_mode next to the
container and prints where the mode came from; the route-level guard from #1293 and its four test
assertions are gone, so there is one place that decides and it is unit-tested.

Measured on the real vllm-sr/d3-lite pack, host GEMM, against the pack's own d3_runtime.py on the same
q4_1 weights (12 questions incl. the 3-question alias): the same directory that answered causally at
worst |dp| 0.3702 with a choice flip now answers at 0.0246 with none -- identical to the arm that has a
plan, with no third conversion step.

Tests: npu_rmsnorm_offset_test gains the eight-case rule table (declaration alone, agreement, no
declaration with and without a plan, empty declaration, disagreement both ways), and the causal path is
unchanged for every model without a decision_config.json (checked on Qwen3-0.6B: loads, no mode line).

Fixes #796

* npu: read the sidecar key as a JSON key, not a bare substring, so a prompt mentioning it cannot be mistaken for the field

---------

Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant