Repository navigation
npu-pack: two imports of one 1.9 pack no longer read each other's half-written canonical copy (Fixes #1985) - #2043
Conversation
…d renamed into place (Refs #1985) The canonical Token .fconst lives at a path every import of the same pack shares, was written in place with O_TRUNC, and is reused on length. A second import that started while the first was writing saw a short file, truncated it and wrote again; the first then read its own copy while it was being refilled. On Qwen3-1.7B, three `npu-pack info` 0.15 s apart on clean main: 4 of 24 runs reported 280 refused tensors, qk_norm false, tiles_measured 0, exit 0 -- the numbers of engine#1985, from one binary on one pack. write_canonical_copy() writes beside the copy and renames. The temporary name starts with the scratch stem, so the age sweep removes one a killed run left, and not with a pack prefix, so another import's sweep cannot delete a copy that is being written. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
|
ready for review at 9e45031 |
VERDICT at
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 9e45031 — Two imports of one 1.9 pack no longer read each other's halves (+45/+10, docs +9, tests): a scratch name that collides is a race whose result depends on timing, which is the worst kind — the fix is a per-import name, and the test is what keeps it that way.
|
VERDICT: READY at 9e45031 — I built the writer from this head and drove it, and the four properties the fix depends on all hold. The fourth and fifth arms are the design, and they are the part a reader will not think of: And the mechanism is measured rather than argued: "three imports 0.15 s apart: 4 of 24 runs read '0 in-block offsets', refused all 280 attention tensors and exited 0" → 0 of 144 with the rename. The exit-0 part is what makes this worth a PR of its own: a concurrent import did not fail, it succeeded with a different pack. Three details I checked in the code:
One nit: the arms in the PR's test cover the race with threads in one process, which exercises the atomic serial; the My own correction, since it is the third time tonight: my first run failed "the temp name starts with the scratch stem" — my assertion ran |
PR Reviewer Guide 🔍Here are some key observations to aid the review process:
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 78627ff: READY
The new head is a merge of main, and the PR's own diff is identical to 9e45031 (verdicted READY). A three-way merge onto current main is clean. I checked it at this head (worktree at 78627ff):
npu_pack_import_test tests/golden: all npu-pack checks pass, including "atomic canonical copy: 11 arms (bytes, replace, failed write, temporary name x4, … racing writes / 144 reads, no leftovers)".- The racing arm can fail. With
write_canonical_copywriting in place again (temp name =work), the suite fails "atomic copy: no import ever reads a copy another import is still writing (engine#1985)". Restored, it passes. - Read: the copy is written under
1bit-fconst19tmp-<pid>-<n>-<name>in the same directory (so the rename is atomic) and renamed ontowork. A short write or a failed rename removes the temp file and throws. Thetmp-infix keeps it out of any pack's1bit-fconst19v<n>-prefix, so another import's sweep can't delete it mid-write, while the 6 h age rule still removes one a killed run left.
What I could not reproduce: the end-to-end race on main. Two binaries built from main d4dc5e7 and this head, Qwen3-1.7B_rai_1.9.0_npu, three npu-pack info per trio on a private ONEBIT_FCONST_SCRATCH, under thermal-run: main was 72/72 normal across staggers 0.15 / 0.3 / 0.6 / 1.0 / 1.5 s (24 at 0.15 s), and this head 24/24. The window is timing-sensitive (your table shows 0.10 and 0.20 s clean on main too), and the thermal-run scope probably shifts it. So the failing case is your measurement, not mine. It doesn't need to be: the fix is right by construction and the unit arm races writers against readers and catches the in-place form.
This also accounts for what I saw on #1986: I couldn't make Qwen3-1.7B misread from any clean build, and the canonical copies of two different pack directories in the shared cache were byte-identical. One shared path plus an in-place write is exactly the mechanism. #1986's "short vote" premise was this race, so #1986 can close in favour of this one.
Fixes #1985.
Qwen3-1.7B"imported with 0 gaps yesterday and refuses with 280 today" was not a change in the reader. It is a race between two imports of the same pack, and it reproduces on a clean build of main with one binary.What was wrong
A 1.9 pack's Token
.fconstis rewritten into a canonical copy before it is read. That copy lives at a path every import of the same pack on the box shares (~/.cache/1bit/fconst19/<name>), it was written in place (std::ofstream(work, trunc)), and it is reused when its length is right. So:A then reads a layout out of a partly written file and reports it, with exit 0.
The fix
write_canonical_copy()writes under a temporary name in the same directory and renames it into place. A reader finds no copy or a complete one; two writers each install the same bytes.The temporary name is
1bit-fconst19tmp-<pid>-<n>-<name>. It starts with the scratch stem, so the existing 6-hour rule removes one a killed run left behind. It does not start with any pack's1bit-fconst19v<n>-...prefix, so another import's sweep cannot delete a copy that is still being written.Measured on strixhalo
Qwen3-1.7B_rai_1.9.0_npu(2,660,258,221 bytes), an empty private scratch directory per trial (ONEBIT_FCONST_SCRATCH), three1bit npu-pack info <pack>started a fixed interval apart. "Normal" is exit 0,incomplete0,qk_normtrue,tiles_measured64.233fcad4, clean buildA wrong run on main (always the first process of its trio), exit 0:
280 gaps,
qk_normtrue to false andtiles_measured64 to 0 are the issue's three numbers. The note text is not the issue's ("attention payload holds 64 tiles; q/k/v/o at 64 rows each need 96"). Which wrong layout is read depends on how much of the file had been refilled, so I expect that text from a different interleaving, but I did not catch it and do not claim it.The 144 runs with the fix were made at the commit before the final rebase onto
468d029f; the rebase touched none of this PR's lines. The unit test below was run at the pushed head.Tests
tests/npu_pack_import_test.cpp,test_write_canonical_copy(), 11 arms, no pack needed:evict_stale_canonical_copies()), inside the age sweep (a 2-hour-old one is removed);At the head
9e45031:It passed five runs in a row before the rebase. Red against the old behaviour (the function body changed back to an in-place write, nothing else):
What this does not do
1bitand a new one import the same pack at the same moment can still show the failure, until the old binaries are gone. The docs say so.npu-pack inforeports 280 refused tensors and returns success by design; this PR removes the cause it found, not that convention.Docs:
docs/npu.md, "Where a 1.9 pack's rewritten.fconstgoes".🤖 Generated with Claude Code