Skip to content

npu: the forward is causal-only, so a noncausal_full_attention pack (d3-lite) cannot be reproduced — measured, not inferred #847

Open

Description

@bong-water-water-bong

What

vllm-sr/d3-lite (the pack engine #796's route serves) declares attention_mode: noncausal_full_attention in its
decision_config.json. The pack's own runtime honours it: d3_runtime.PromptEncoder runs the language model with no
causal mask on the full-attention layers
(the 18 GDN layers keep their recurrent mask). The engine has no such mode —
q4nx_forward.cpp only ever builds a causal mask — so a noncausal pack is served by a causal forward.

Measured (2026-11-02, strixhalo, host-GEMM, no device opened, reference = the pack's d3_runtime.py in a clean venv)

Running the reference both ways on the same pack (encoder on/off, one CPU run, nine contract requests) shows this
is not a cosmetic difference:

row result
reference noncausal vs reference causal worst |Δp| 0.2869 (score_three_levels), choice argmax 4/5

And the engine's disagreement with the noncausal reference cannot be attributed to weights (it survives with the
weights held identical) or to readout scaling (the 255-way code distribution has 0/10 top codes in common and
Spearman −0.15, i.e. an unrelated ranking):

comparison worst |Δp| choice argmax
engine(q4_1) vs reference(q4_1-rounded weights) 0.7202 2/5
engine(bf16) vs reference(bf16) 0.8382 3/5
reference sensitivity control, 9 % weight noise 0.2636 3/4

The intuition that "last-position pooling makes causal and noncausal identical" is wrong, and the measurement is what
shows it: earlier positions' full-attention outputs differ, those feed the GDN recurrence, and the last position's
hidden inherits the difference.

What it would take

  1. attention_mode (or noncausal: true) carried by the plan (npu/plan/plan.h, tools/npu_plan.py) and derived from
    layer_types + decision_config.json — a plan field, not another filename probe.
  2. The full-attention path honouring it for a prompt-only forward (step_batch/step with no KV reuse): a bidirectional
    mask for the layers whose layer_types entry is full_attention, with the GDN path untouched. Decode is causal by
    construction, so a noncausal pack is a prompt-encoding mode, not a generation mode — which is also why it should be
    refused, by name, when a request would use it for generation.
  3. A test that says the mask is what it claims: same weights, same prompt, noncausal vs causal must differ (the reference
    row above is the known answer), and a causal model must be byte-identical to today.

Validation is device-free: NPU_INFER_HOST_GEMM=1 on the 0.8B-GDN pack, compared against d3_runtime.py with
~/lb/d3parity (reference 10 requests in 7.7 s, engine side under one npu-hold ticket).

Not claimed

This is one cause of the parity gap, not the whole of it — 0.29 of the measured 0.72. After it lands the row must be
re-measured; the next isolation step is an engine-side last_hidden dump compared against the reference's hidden.

Activity

  1. bong-water-water-bong commented on Nov 2, 2026

    @bong-water-water-bong
    CollaboratorAuthor

    Triaged and verified at origin/main 906f966a2 (this box's pin, which already carries #846 — the bf16 converter that made the "same weights" arm cheap). Every claim below is a read of the tree or of the pack's own file, not a restatement of the report.

    The absence is confirmed, and it is an absence of two things, not one.
    git grep -n 'attention_mode\|noncausal\|non_causal' origin/main -- npu/forward/src npu/plan tools/npu_plan.py → no hits. The only causal strings in the forward are the depthwise-conv comments (npu/forward/src/q4nx_forward.cpp:1854, :2156), i.e. the GDN path, not attention. And npu/plan/plan.h's v1 schema (header comment: model / container / fusion / prefill / decode / kernels / moe / layers) has no attention block at all, so item 1 of "what it would take" is not started rather than half-done.

    The declaration is in the pack, and so is the control. ~/models/npu/d3-lite-src/decision_config.json → {"attention_mode": "noncausal_full_attention"} (same value in d3-lite-conv/, d3-lite-bf16/). A causal sibling from the same converter already sits on this box: ~/models/npu/d3-synth-0.6b/decision_config.json → {"attention_mode": "causal"}. That is the byte-identical control item 3 asks for, without generating anything.

    One constraint on the plan field: plan.h accepts exactly one format string and refuses anything else by name ("a newer/older file is refused by name, never interpreted"). So attention.mode must be an optional field defaulting to causal inside 1bit-npu-plan/1, not a version bump — existing configs/plans/*.plan.json must keep loading unchanged.

    Two causes, not one, for the 0.72/0.84 rows. The same pack also carries #849 (zero-point nibbles paired by halves while the codes pair even/odd), so neither number can be attributed to the mask alone. Order: #849 (weights) → this (mask) → re-measure. Both are device-free, and the reference side already exists here as ~/lb/d3parity/compare*.py.

    Owner pi-npufeat (it owns #796 and #832); bucket A. Closing acceptance, as the issue states it, is falsifiable in one host run: noncausal vs causal on d3-lite differing by at least the reference's own 0.2869, d3-synth-0.6b byte-identical to today, and a generation request against a noncausal pack refused by name.

  2. added
    bugSomething isn't working
    npuThe NPU (XDNA2) forward/serve route
    on Nov 2, 2026
  3. bong-water-water-bong commented on Nov 3, 2026

    @bong-water-water-bong
    CollaboratorAuthor

    Measured again on fixed weights: the mask is mechanically right, but its magnitude is downstream of a bigger forward gap — and the numbers now say which

    Re-ran the two arms on the bf16 container with engine#875's decoder fix (PR #882), so nothing here is the mis-decode that
    made my earlier #861 rows unreliable. Two serves, one npu-hold ticket, host-GEMM, amdxdna errors 0. Reference arm =
    the pack's own d3_runtime.py on the same pack, both ways (its encoder on/off is exactly this mask).

    1. The engine's causal arm is already 0.64 away from the reference's causal arm

    Measured over the nine contract cases (~/lb/d3parity/mask_cmp.py against ref_causal.json):

    comparison worst |Δp|
    engine(causal) vs reference(causal) 0.6367 (noul_criteria: ref 0.0762 vs engine 0.7130)
    engine(noncausal) vs reference(noncausal) 0.7319
    reference's own mask effect (nc vs c) 0.2869
    engine's own mask effect (nc vs c) 0.0109

    The first row is the one that matters for this issue: with the mask out of the picture entirely, the engine and the
    reference disagree by 0.64. So the 0.73 in the noncausal row is not the mask, and the acceptance's ≥ 0.2869 cannot be
    evaluated until the causal forward is faithful. Tuning the mask to reach 0.2869 would be fitting a number to a
    compensating error, which is the one thing this issue should not accept.

    2. The mask's mechanism is confirmed layer by layer — it is not silently dropped

    NPU_INFER_DUMP_LAYERS on both arms, same prompt, at the last prompt position (layer kinds from the pack's
    layer_types):

    layer kind causal mean|X| noncausal mean|X| Δ
    0–2 linear_attention 0.019362 / 0.019934 / 0.019791 identical +0.00 %
    3 full_attention 0.023084 0.023084 +0.00 %
    4 linear_attention 0.029500 0.029634 +0.45 %
    7 full_attention 0.034091 0.034096 +0.01 %
    11, 15, 19, 23 full_attention 0.042170 / 0.048050 / 0.070840 / 0.207799 0.042154 / 0.048086 / 0.070545 / 0.209717 −0.04 / +0.07 / −0.42 / +0.92 %
    16, 17 linear_attention 0.051244 / 0.053376 0.050592 / 0.052564 −1.27 / −1.52 %

    That is exactly the signature the mechanism requires, and it is worth having on record: at the last position the
    first full-attention layer's own output cannot change (its query has no future keys either way — its keys come from
    layers 0–2, which are causal and identical), so +0.00 % at layer 3 is correct, and the effect appears from layer 4
    on only through the GDN recurrence carrying the earlier positions' changed states forward. The engine reproduces that
    path; what it does not reproduce is the size of it (≤1.5 % on the residual, against the reference's 7–25 % on the pooled
    hidden measured earlier).

    3. So what is left for this issue

    1. npu: a real d3 pack (vllm-sr/d3-lite, 0.8B-GDN) answering through /v1/systemone — converter transforms, name map, and the 7-design ELF set #796's forward gap is the blocker, and it is now measured in the causal arm alone (0.6367) — that is the work to
      do next, and the mask magnitude will follow it rather than lead it.
    2. Nothing in the mask needs a code change on the evidence so far: attention.mode, the two-pass fill, the generation
      refusal and the causal path's byte-identity to the previous behaviour are all in from npu: attention.mode, so a noncausal_full_attention pack (d3-lite) is not answered causally (#847) #861 and behave as designed.
    3. When the causal forward is faithful, the row to re-run is exactly this one (mask_cmp.py + layers_arms_cmp.py), and
      the issue can then be closed on the magnitude with a number that means something.

    Not closing: the acceptance's magnitude is unmet, and now with a stated reason rather than a mystery. Harness:
    ~/lb/d3parity/{mask_arms.sh,mask_cmp.py,mask_layers.sh,layers_arms_cmp.py}, artifacts eng_eng_{causal,noncausal}_fixed.json,
    layers_{causal,noncausal}.log.

  4. bong-water-water-bong commented on Nov 3, 2026

    @bong-water-water-bong
    CollaboratorAuthor

    Short update, measured on fixed weights (bf16 container, causal + noncausal arms, one npu-hold ticket each step,
    amdxdna errors 0): the arm this issue's acceptance depends on moved a long way, and the acceptance still is not met —
    for a reason that is now a known, separate defect rather than an unexplained gap.

    engine#796 found the cause of the causal arm's 0.6367: Qwen3.5's RMSNorm is zero-centered (x/rms * (1 + w)) and
    the engine applied plain w, so every norm output in a d3 pack was scaled by the weight's own magnitude. With that
    fixed (#902, closes #901) the causal arm's worst |Δp| against the pack's own d3_runtime.py is 0.4140
    (choice argmax flips 2/5), and layers 0-2 (all GDN) now agree to cos ≥ 0.999968.

    The remaining gap starts at the first full-attention layer (layer 3: cos 0.452) — partial rotary 64/256,
    zero-centered q_norm/k_norm, attn_output_gate. Until that is fixed, re-running this issue's mask_cmp.py row
    would measure that defect, not the mask, so I am not quoting a new noncausal-vs-causal magnitude yet. The mask itself
    is unchanged and still verified layer by layer (#861); the re-run is queued behind engine#796's layer-3 split.

  5. bong-water-water-bong commented on Nov 3, 2026

    @bong-water-water-bong
    CollaboratorAuthor

    Re-measured on a faithful forward: the mask's effect is now reproduced — engine 0.2837 vs the reference's 0.2869

    The precondition this issue was waiting for is met. With engine#901/#903 fixed (#902, #904) the causal arm's gap
    against the pack's own d3_runtime.py is 0.0061 (was 0.6367 when this issue's earlier rows were taken), so the
    mask row can finally be read on its own. Both arms, same weights (bf16 container), same 9 requests + the 3-question
    alias, host-GEMM, one npu-hold ticket per arm, amdxdna errors 0.

    case engine c vs ref c engine nc vs ref nc reference mask engine mask
    choice_two_one_null 0.0031 0.0019 0.0458 0.0446
    choice_three_json_desc 0.0034 0.0043 0.0377 0.0423
    choice_unicode 0.0038 0.0015 0.0175 0.0152
    choice_instructions 0.0061 0.0022 0.2587 0.2626
    noul_plain 0.0032 0.0004 0.0574 0.0610
    noul_criteria 0.0025 0.0021 0.0004 0.0041
    noul_instructions 0.0003 0.0044 0.0685 0.0638
    score_three_levels 0.0056 0.0014 0.2869 0.2837
    score_json_levels 0.0050 0.0020 0.0692 0.0726
    value
    engine(causal) vs reference(causal), worst 0.0061
    engine(noncausal) vs reference(noncausal), worst 0.0044
    reference's own mask effect, worst 0.2869
    engine's own mask effect, worst 0.2837

    So the two-pass fill (attention.mode from #861) now reproduces the reference's noncausal behaviour rather than
    merely "differing from causal": the largest case is the same case on both sides (score_three_levels), the per-case
    values track within ~0.004, and the causal path is unchanged to 0.0061.

    On the acceptance's wording, one honest caveat: it asks for the engine's nc-vs-c to be at least 0.2869; it is
    0.2837, i.e. 0.0032 short — smaller than the engine-vs-reference forward gate on the same runs (0.0044–0.0061),
    so it reads as agreement rather than as an unmet threshold, but "at least" is not literally satisfied and I am not
    going to call it satisfied. Whether that closes the issue is a triage call.

    Device note unchanged: these are host-GEMM rows on the bf16 container; a device row still needs the six ELF designs.
    Harness: ~/lb/d3parity/{parity_run.sh,mask_cmp2.py} + eng_causal_norm.json, eng_noncausal_norm.json.

  6. bong-water-water-bong commented on Nov 3, 2026

    @bong-water-water-bong
    CollaboratorAuthor

    Same re-run applies to the two arms quoted in my last comment: re-executed at 12:29-12:30 (after the silent-holder window closed), the causal and noncausal answer files are byte-identical to the ones behind that table, so the 0.2837-vs-0.2869 row stands. Ticket additions in the ledger: 622 s wait + 8 s hold for the dump recheck, 127 s for the parity recheck; amdxdna errors 0.

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

    bugSomething isn't workingnpuThe NPU (XDNA2) forward/serve route

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions