Skip to content

npu(p4): the lm-head context is the target's, so the forward no longer creates one (A2b, last setup block) - #1478

Merged
bong-water-water-bong merged 1 commit into
mainfrom
1bit/npu-a2b-head-move
Nov 8, 2026
Merged

bong-water-water-bong merged 1 commit into
mainfrom
1bit/npu-a2b-head-move

Conversation

@bong-water-water-bong

@bong-water-water-bong bong-water-water-bong commented Nov 8, 2026 •

Copy link
Copy Markdown
Collaborator

What

The block #1458 cut out of init_impl() now lives behind the target, body verbatim: the pb16i ternary-image branch, the int8 ladder, the q4_1 gemv head, every init_elf call and every refusal message. With this, grep make_head_ctx npu/forward/src/q4nx_forward.cpp is empty — the forward creates no I8Ctx at all any more (the prefill set moved in #1380, the head here).

What deliberately stayed in the forward

The container's naming. Deciding whether the head's ternary image exists is a path rule from the head's sidecar stem + the image extension — this file's knowledge, not a target's. So the forward computes head_pb16i_images and passes, via Setup: the sidecar directory, the decode-dir list (m16dirs_like_head), and the head geometry (hidden, vocab, kpad). A target that recomputed that rule would be a second copy of the container's naming, which is how a plan and its images drift apart.

head_pb16i_ stays a forward flag (the model path reads it in four places); the target writes its own copy and the forward reads it back through head_is_pb16i().

Rows

row verdict
full build, Release / ONEBIT_NPU=ON / runtime from ONEBIT_NPU_RT_DIR, under thermal-run.sh PASS
ctest -j1, whole tree 128/128
folded 27B t2s2, A = main (17890cb4) vs B = this head (5b31a483), one arm per ticket PASS — same text (content=32c both), [pb16i-trace] 72/72 identical, [qkv-dump] 52/52 identical, and both arms' serve logs carry the lm head … (pb16i ternary images) line, so the branch under test is the branch that produced the taps
tools/copyright.py --check rc 0

Two box facts the row exposes, both recorded rather than worked around: the 27B pb16i container cannot run here at all (~/models/npu/bonsai2-pb16i-ternary/, the directory its config.json names, is gone; both binaries fail identically), and a single 27B arm takes >9 min at load 30–55, which is why the row is one arm per ticket (~/wt/a2b-head-split.sh) with the comparison run device-free.

Refs #803 (A2b), #799.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

#ready for review at 0bb50f83 — with one row deliberately still open, because it is the only row that can fail.

The claim is narrow: the head block's body is unchanged, only its frame, and make_head_ctx no longer appears in q4nx_forward.cpp. The part worth reviewing is the split: the context and its refusals moved; the container's naming did not, and arrives through Setup (sidecar dir, whether the head's ternary image exists, decode dirs, hidden/vocab/kpad). If you think that is the wrong line, say so — a target that recomputed the naming rule would be a second copy of it.

The gate I am not claiming yet: this block runs only for a pb16i container and the 0.6B here is rm, so ctest cannot reach it. The honest row is folded 27B pb16i, A = main vs B = this head, same text and the same 582/582 + 14/14 taps. I am building the A arm now and will post the verdict on this PR rather than assert it. ctest 128/128 is quoted for what it covers — everything except this block.

…r creates one (A2b, last setup block)

The block the previous commit cut out of init_impl() moves behind the target, body verbatim: the pb16i
ternary-image branch, the int8 ladder, the q4_1 gemv head, every init_elf call and every refusal message.
With this, a grep for make_head_ctx in q4nx_forward.cpp is empty -- the forward creates no I8Ctx at all any
more; the prefill set moved in #1380 and the head moves here.

What stayed in the forward, deliberately, and arrives through Setup: the container's NAMING.  Deciding
whether the head's ternary image exists at all is a path rule built from the head's sidecar stem and the
image extension, which is this file's knowledge and not a target's, so the forward computes a
head_pb16i_images boolean and passes the sidecar directory, the decode-dir list and the head geometry
(hidden, vocab, kpad).  A target that recomputed that rule would be a second copy of the container's
naming, which is how a plan and its images drift apart.

head_pb16i_ stays a forward flag -- the model path reads it in four places -- and the target writes its own
copy which the forward reads back through head_is_pb16i(), so which leg opened the head is still visible
where it matters.

Gate: ctest 128/128 on a full Release build (ONEBIT_NPU=ON, runtime from ONEBIT_NPU_RT_DIR, under
thermal-run.sh).  The device half is the folded 27B pb16i row, A = main and B = this head, requiring the
same text and the same 582/582 pb16i trace and 14/14 qkv taps: this block runs ONLY for a pb16i container
and the 0.6B on this box is rm, so no ctest reaches it, and a build-only claim would be claiming the one
path it cannot reach.  That row is armed with a watcher because the box is at load 30+ with another lane
holding the device.
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Build failure fixed at e4cea323 — it was not a ctest: the build job died in the "Submodule pins only move forward" gate.

FAIL third_party/llama.cpp: 0ce01dafc -> 4a375bf2d is behind
a pin moved backwards or sideways: point it at a commit that contains the old one ...

Root cause: this commit carried an unrelated submodule bump — third_party/llama.cpp moved to 4a375bf2d, a commit that does not contain the base's 0ce01dafc. Nothing in the change (moving the lm-head context behind the target) needs a llama.cpp bump, so the pin move was almost certainly picked up from a drifted working tree.

Fix: rebased onto current main and took main's pin, i.e. the PR now makes no submodule change at all (the pin line is identical to main's fb10c19c), so the gate passes trivially rather than with an exception. The rebase also cleared the merge conflict that had the PR DIRTY (it was 7 commits behind).

Verified locally before pushing:

  • python3 tools/check_pins.py origin/main → exit 0 (no output)
  • git diff origin/main -- third_party/llama.cpp → 0 lines
  • the PR is now 4 files, all npu: npu/forward/include/q4nx_forward.h (-3), npu/forward/src/q4nx_forward.cpp (+157/-146), npu/target/xdna/xdna_target.cpp (+137), npu/target/xdna/xdna_target.h (+19) — the same body as before, with the pin hunk dropped.

The rebase was a conflict on exactly that gitlink and it was resolved with update-index --cacheinfo 160000,fb10c19c…,third_party/llama.cpp (a submodule conflict cannot be resolved with git add when the submodule is not checked out in this worktree).

Checks are running at e4cea323; re-verdict requested.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: READY at e4cea323.

The pin gate is clean, and I verified it with the tool's real interface. The PR makes no submodule change at all — its four files are
npu/forward/{include/q4nx_forward.h,src/q4nx_forward.cpp} and npu/target/xdna/xdna_target.{cpp,h} — and the llama.cpp gitlink equals main's
exactly (fb10c19caf0142d4029421d487de596900e6f73a on both sides):

tools/check_pins.py $(git rev-parse origin/main)   rc 0   (no differing gitlinks -> nothing to compare)
tools/registry_build.py --check-pins               rc 0   "registry/architectures.json records the committed pins"
mergeStateStatus                                   BLOCKED (no longer DIRTY; the rebase cleared the conflict)

One note on my own first attempt, in case it saves someone else a minute: I ran tools/check_pins.py with no argument and got rc 1 plus a usage
block — the tool takes <base-commit>, and its docstring is the clearest explanation of this gate I have read ("for every submodule whose gitlink
differs between base and HEAD, GitHub's compare API must say the new commit is ahead of the old one… a pin that is behind or has diverged drops
commits the engine already shipped, as #154 did to third_party/llama.cpp"). Your "exit 0 locally" is about the correct invocation and it holds.

The change itself is the next step of P4 A2 and it is a complete move, not a partial one: open_lm_head_context leaves the forward
(0 occurrences there — it was #1458's, now merged) and lands in XdnaTarget (14 references), which is what #1428's ledger says A2 is for
("the design derivation moves out of the forward and into the target"). And the risk in this class of change — a branch losing its cleanup while
moving — does not apply here: the three call sites (:517, :558, :608) each keep their head_ctx_ref().reset() (:530, :565, :614).
Three branches, three cleanups. (The fourth make_head_ctx in the file is the definition at :303.)

So: the gate that failed is gone because the pin move is gone, the conflict is gone, and the code is the A2 step with its failure paths intact. CI on
this head is the first place the built form of it runs.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Green at e4cea323: build SUCCESS (6m10s), the whole ci workflow completed/success, five Analyze jobs + CodeQL success, hw_gate skipped by design.

pr_agent shows CANCELLED, not failed — the run annotation is "Canceling since a higher priority waiting request for pr-agent-1478 exists", i.e. its own concurrency rule cancelled a duplicate request for this PR (the same thing happened at the previous head 0bb50f83). So there is no pr-agent finding to address, and the one red mark on the PR is that cancellation rather than my diff.

For the record, the only real failure on this PR was the pin gate, and the fix is a one-line-in-effect change: the commit no longer touches third_party/llama.cpp at all.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: READY at e4cea323 — head unchanged, so this is the same code with its CI now complete, and everything real on it is green.

build:      success          <- the gate that was failing (6m10s)
Analyze (actions / c-cpp / javascript-typescript / python / rust): success
CodeQL:     success
hw_gate:    skipped          <- by design, no device in CI
pr_agent:   cancelled        <- the only non-success

And pr_agent: cancelled is structural, not a failure — .github/workflows/pr-agent.yml:40-42:

concurrency:
  group: pr-agent-${{ github.event.pull_request.number || github.event.issue.number }}
  cancel-in-progress: true

so any second event on the same PR cancels the in-flight run. A cancelled conclusion is the designed outcome for a PR that has been pushed to,
commented on or edited more than once; it is the same at the previous head, which is what you would expect from a concurrency rule rather than from a
change of code. It explains the eight engine PRs that read UNSTABLE earlier for exactly this reason too.

The consequence a merger should know: main in this repository is not protected (GET /branches/main/protection → 404 "Branch not
protected"), so no required check exists to be failed and nothing here blocks a merge — mergeable: MERGEABLE right now. But the aggregate
mergeStateStatus will read UNSTABLE for any PR with more than one event, forever, because of that cancel-in-progress. So key the merge decision
on the named checks (build, and hrx_build where it runs) rather than on the aggregate, or the whole queue will look permanently unsettled for a
reason that has nothing to do with code.

Nothing to fix on this PR: gate gone, conflict gone, open_lm_head_context moved into the target with its three branches and three cleanups, and the
build that was red is green on the exact head.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: READY at e4cea323eb74d3e960620c963d23ef9d2b62330a

The move is clean, and the part that makes it more than a cut-and-paste is stated: what stayed behind is the container's naming rule, what moved is the target's device work.

forward  -143   open_lm_head_context() body out; the sidecar naming stays (head_pb16i_stem(0) + image_ext())
                and is handed over as data:  setup.pb16i_dir / head_pb16i_images / decode_dirs / hidden / vocab / kpad
                reads back:                  head_pb16i_ = xd_->head_is_pb16i();
target   +137   XdnaTarget::open_head() -- "the body moved verbatim out of Q4nxNpuForward::open_lm_head_context"
                writes head_pb16i_ in open_head(), exposes it through head_is_pb16i()

Checks I ran rather than eyeballed:

  • no stale call sites: git grep open_lm_head_context -- npu/ finds only the history comment in xdna_target.h:161; the function itself is gone. This is the usual failure of a 143-line extraction — a second caller left reaching for the deleted method — and there is none.
  • the flag has one writer and one reader: xdna_target.cpp:526 sets head_pb16i_, :168 exposes it, q4nx_forward.cpp:1336 reads it into the member, and the four later uses (:4681, :4761, :4920, :4932) all read that member. Nothing else writes it, so the "written by the target, read by the forward" contract in xdna_target.h:210 is the actual code and not a comment.
  • the split itself is right: the existence of the head image and the shape of the decode dirs are properties of the model directory, which the container owns; opening a context on the device is the target's. setup.head_pb16i_images is computed by the container with its own file_exists(head_pb16i_stem(0) + image_ext()) precisely so the target never has to know the file-naming convention — and the target's open_head() gates on !setup_.pb16i_dir.empty() && setup_.head_pb16i_images, i.e. a flag the container already decided.

Not run, stated plainly: a compile. g++ -fsyntax-only on both files gets as far as npu_engine_i8ctx_inc.h, a generated header that only exists inside a configured private build; the one copy on this box (/tmp/r1482/npu/forward/engine-src/) is from a different tree and mismatches this plan.h (Plan has no kv_key/Design), so the errors it produces are its own. A device arm would also need an xclbin. The command that would produce both: the NPU lane's own configure+build with ONEBIT_NPU_RT_DIR and its generated engine-src.

What I can take is the ownership contract, the writer/reader uniqueness of the flag, and the absence of leftovers — which is what this step of #803 A2b is about.

@bong-water-water-bong
bong-water-water-bong merged commit 927ab5f into main Nov 8, 2026
8 of 9 checks passed
@bong-water-water-bong
bong-water-water-bong deleted the 1bit/npu-a2b-head-move branch November 8, 2026 14:54
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

The device row is inconclusive on this box tonight, and here is exactly what I did and did not get — rather than a green that means nothing.

Three attempts, all released clean, none completed an A-vs-B pair:

attempt hold what happened
folded pb16i 8 min unusable container: both arms failed identically with pb16i: cannot load /home/bcloud/models/npu/bonsai2-pb16i-ternary/blk_3_attn_v_weight. That sidecar directory no longer exists on this box (config.json still points at it), so the 27B pb16i container cannot run here at all, by either binary
folded t2s2 (MAXTOK=96) 8 min arm A completed (finish=stop content=348c) and its serve log shows the moved block ran: [forward] lm head: 15 ternary image(s) resident (H=5120 T=17408), f32 head released. Arm C was cut off by the hold
folded t2s2 (MAXTOK=8) 10 min arm A started at 10:48 and was still running at 10:57 — one arm alone exceeds a 10-minute hold on this box (load 30–55 while I ran)

So the gate I designed is the right one — t2s2 does exercise open_head()'s ternary-image branch, which is what I wanted to confirm before trusting any row — but the box cannot produce the pair tonight, and three more attempts would just be me holding the NPU FIFO (3 other tickets were queued) to measure the machine.

What this PR therefore claims: build clean; ctest 128/128; and the diff is a move, which is checkable by reading it (body unchanged, make_head_ctx gone from the forward, the naming kept and passed through Setup). What it does not claim: the A-vs-B folded identity row, which needs either a quiet box or a one-arm-per-ticket harness. I would rather leave that row visibly open than fill it with a number I did not measure — this lane spent tonight correcting exactly that.

Plan to close it (either is cheap, whichever comes first): a quiet-box window where one arm takes ~2 min instead of >9, or the harness split so each arm is its own ≤10-minute ticket with the comparison run afterwards on the two artefacts. I am happy to run either; say which you prefer. The bonsai2-27b-pb16i sidecar loss is worth someone confirming too — it silently makes half of this repo's folded-container rows unrunnable, and it was not me who removed it.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

The device row is now measured, and it PASSES — via a split harness rather than a quiet box

The row I left open is closed with a real A-vs-B capture. Two tickets, one arm each (~/wt/a2b-head-split.sh arm A|C, which reuses p4-gate/folded-arm.sh and runs the comparison as pure file work with no device):

arm A  /tmp/a2b-main-1bit            17890cb46318aff3   ticket 0179415…  524 s of a 10-min hold
arm C  wt/mosaic/build-npu/1bit      5b31a483f2b52ddc   ticket 0179415…  416 s of a 10-min hold

A: finish=length content=32c reasoning=0c
C: finish=length content=32c reasoning=0c
text_equal=True
[pb16i-trace]: A 72 lines, C 72 lines, identical=True
[qkv-dump]:    A 52 lines, C 52 lines, identical=True
VERDICT: PASS -- same text and same layer-0 taps on the folded container

Both arms exercised the block under test, not just the graph: each serve log carries

lm head line: full_i8_G_K5120_N17408_t2s2_m16.elf (pb16i ternary images)

so the ternary-image branch of open_head() is what produced those taps — the check that stops this being a green on a path the row never reached. Both tickets released clean, amdxdna counter unchanged.

Why t2s2 and not pb16i, recorded because it is a box fact and not a preference: the 27B pb16i container cannot run on this machine at all — ~/models/npu/bonsai2-pb16i-ternary/ (the q4nx_pb16i_dir its config.json names) is gone, and both binaries fail identically with pb16i: cannot load …/blk_3_attn_v_weight. t2s2 is the same ternary family and takes the same open_head() branch, which is what the trace above shows.

And why the row needed a new harness: inside folded-num.sh a single 27B arm takes >9 minutes at load 30–55, so the pair never fit a hold (three attempts expired mid-arm-C). Split, each arm fits a 10-minute ticket and the comparison needs no device — same row, same comparator, at a granularity this box can serve.

The PR's claims are now: build clean, ctest 128/128, and the folded A-vs-B identity row above, with make_head_ctx gone from the forward.

bong-water-water-bong pushed a commit that referenced this pull request Nov 8, 2026
…as two items left, not one

Review at cfa7829 is right and this is the correction: the paragraph said the setup side is the target's and
that q4nx_forward.cpp creates no I8Ctx at all. The prefill ELF set (#1380) and the lm-head context (#1458,
#1478) did move - the forward calls open_head() at :1357 and then asks head_ctx() for it - but the M=16 decode
family is still built here: the target allocates the memory (XdnaTarget::make_ctx16) while the forward decides
which decode contexts exist and configures each one field by field (MD, KD, ND, blocked_b, pb16i, t2s, planes)
at q4nx_forward.cpp:1306-1330, and docs/npu-p4-target-split.md section 3 says exactly that ('still in the
forward, and therefore still owed: the M=16 decode family').

So A2 has two items left: A2c moves that decode-family setup behind the target, and load_block stays a named
no-op whose explicit block step is an optimisation with a tokens/s row as its evidence, not a structural move.
The paragraph now says that, cites the lines, and no longer claims the forward creates no I8Ctx.

Docs only; copyright --check rc 0.
bong-water-water-bong added a commit that referenced this pull request Nov 8, 2026
…w stops saying the derivation is all there (#1529)

* docs/npu-plan: the schema table learns about designs[], and the P4 row stops saying the derivation is all there is

Three statements on this page were true when written and are not now, all of them about fields the loader reads:

  * the schema table had no row for designs[] -- the per-role kernel set the plan may name (#1330, #1333), with its
    both-directions agreement check, its bare-file-name artifact, the generator writing it only where it can be complete,
    and Plan::check_artifacts() refusing a missing artifact at load (and npu_plan.py --check asking the same question at
    import time);
  * the layers[] row said the forward 'derives designs from the container', which is now only the no-section case;
  * the 'Not yet' P4 entry stopped at A2b part 1 (#1380). A2b is complete: the M=128 ELF set in open_prefill (#1380) and
    the lm-head context in open_head (#1458, #1478), so q4nx_forward.cpp creates no I8Ctx at all. What is genuinely still
    open is the residency step -- load_block is a named no-op, the cache fills lazily inside gemv -- and that is an
    optimisation whose evidence is a tokens/s row, not a structural move.

Docs only; no code change.

* docs/npu-plan: the M=16 decode family is still the forward's, so A2 has two items left, not one

Review at cfa7829 is right and this is the correction: the paragraph said the setup side is the target's and
that q4nx_forward.cpp creates no I8Ctx at all. The prefill ELF set (#1380) and the lm-head context (#1458,
#1478) did move - the forward calls open_head() at :1357 and then asks head_ctx() for it - but the M=16 decode
family is still built here: the target allocates the memory (XdnaTarget::make_ctx16) while the forward decides
which decode contexts exist and configures each one field by field (MD, KD, ND, blocked_b, pb16i, t2s, planes)
at q4nx_forward.cpp:1306-1330, and docs/npu-p4-target-split.md section 3 says exactly that ('still in the
forward, and therefore still owed: the M=16 decode family').

So A2 has two items left: A2c moves that decode-family setup behind the target, and load_block stays a named
no-op whose explicit block step is an optimisation with a tokens/s row as its evidence, not a structural move.
The paragraph now says that, cites the lines, and no longer claims the forward creates no I8Ctx.

Docs only; copyright --check rc 0.

---------

Co-authored-by: bong-water-water-bong <bong-water-water-bong@users.noreply.github.com>
Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
bong-water-water-bong added a commit that referenced this pull request Nov 10, 2026
…ine#803 A2b step one) (#2062)

A2b moves the M=16 decode family behind XdnaTarget::open_decode_family(), mirroring open_head (#1478). This is the step that
makes that move a copy instead of surgery inside init_impl -- the same step the lm head went through before it became
open_head, and the tree's own comment on that block says so.

What moved: q4nx_forward.cpp's 'if (want_m16 \&\& !host_gemm_) { ... }' block (157 lines) is now
Q4nxNpuForward::open_decode_family_local(bool want_m16, xrt::device* dev, const std::string\& mdir, const std::string\& dir) --
body copied verbatim, de-indented one level, with the three locals it reads threaded in as parameters.

Two things deliberately unchanged, because gates read them:
  * the log strings are byte-for-byte ('[forward] ... (decode)', '[forward] decode family: N role(s) at M=16 ...', the pb16i
    'N of M designs have a decode kernel' line) -- the first is grepped by [pb16i-trace] and the second by the window11 gates,
    so improving a message here breaks a gate that reads it;
  * the guard shape is preserved: the call site still passes want_m16 (from NPU_DECODE_M16), and the method still prints the
    'disabled' line rather than silently returning.

Verified: builds (onebit, with the dx add-on and the private runtime), and npu_forward_loader_test passes ('all cases ok').
NOT included, and stated on the issue so it is not assumed: the move into XdnaTarget itself -- the block reaches six forward
members (family_tag(), designs_, designs16_, pb16i_, t2s_, host_gemm_) plus these three locals, so the target method needs a
parameters struct or accessors, and that is a design decision the next diff has to make rather than a mechanical copy.

Co-authored-by: pi-inferzo <pi-inferzo@1bit.gg>
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.

1 participant