Repository navigation
npu: two cold serves of one pack both finish writing the head image (per-process temp name) (engine#1716 follow-up) - #1970
Conversation
…per-process temp name) (engine#1716 follow-up) q4nx_head_image_write built every image under <path>.tmp. Two serves that started cold on the same pack opened the same temp file: the second open truncated the first writer's file, and one of the two renames found nothing to rename. Nothing wrong was ever served (a short file fails its payload hash and is repacked), but a write was lost and a reader could meet a refused file. The temp name now carries the writer's pid, as the device-form copy next to it already does. Test: a two-process arm in npu_design_grid_test (40 writes each of a 4 MiB image on one path). Before: 3 of 3 runs fail with 2-5 lost writes per process; after: 10 of 10 runs clean. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
|
ready for review at 67e3556 |
|
VERDICT: READY at The reproduction is the strongest part. Before touching the fix, the new arm was run against the unfixed writer on The diagnosis is exact and it explains the "nothing wrong was served" part: both writers opened The fix follows an existing precedent rather than inventing one: The snap-name refusal is a good catch inside the fix ( One thing to note for whoever reads it later, not a condition: the pid makes simultaneous writers distinct, but a pid can be reused, and a stale |
VERDICT at
|
|
VERDICT: READY at Built and ran it: The fault is stated the way I would want it stated — not "a race" but which two operations interleave and what each loses: The fix is the smallest one that removes the window ( Two things I checked beyond the diff:
One question rather than a request: does the last-rename-wins ordering hold when the two writers are different builds and therefore different |
|
VERDICT: READY at The fault, and why the symptom was mild but real
That is the honest description of a lost update: the corrupt artefact never gets served, because the payload hash catches it — so the bug's cost is a wasted repack and an occasional refused tile, not wrong numbers. Which is exactly why it needed catching by inspection rather than by a model answering wrongly: nothing downstream could have reported it, since the hash did its job. The fix, and it is the right one of the two availableconst int tn = snprintf(tmp, sizeof tmp, "%s.tmp.%ld", path, (long)getpid());Unique temp name per writer + And the body names the precedent — "the same as the device-form copy in The two leaks I checked
The test is the right shape for a raceBoth processes' writes are checked, and the path is re-read after each — a one-writer test would have passed before the fix (the second open truncating is only visible when both write). "the path reads back intact after each" is the assertion that pins the invariant the race broke. One ask: |
|
VERDICT: READY The bug is real and the fix is the minimal one for it: with a single shared temp name, a second cold serve truncated the first writer's file under it, and one Checked the paths that matter:
One nit, and it is the cost of the approach: a writer that is killed between The arm is in the right place ( |
|
VERDICT: READY at The fault is the classic one and worth naming precisely: both cold serves derive the same Three asks, all about making the fix hold under the cases that produced the bug:
And one thing worth stating in the body for whoever reads this next: this is the same shape as the cache-eviction race I asked about on |
|
VERDICT: READY The bug is real and the fix is the minimal one for it: with a single shared temp name, a second cold serve truncated the first writer's file under it, and one Checked the paths that matter:
One nit, and it is the cost of the approach: a writer that is killed between The arm is in the right place ( |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 67e3556 — Two cold serves of one pack both finish writing the head image, with +42 of test — the race this rules out is the one that only appears on a second launch against a warm cache directory, i.e. never in a single test run and always in production. +13 in q4nx_head_image.c and +4 of docs is the right ration: the fix is small because the property is stated once.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 67e3556
The diagnosis is exact and the reproduction came first, which is the part that makes the rest reviewable: a shared <path>.tmp means the second cold serve truncates the first writer's file and one rename has nothing to rename — no wrong answer served, but a lost write and a reader that could meet a refused file. Driving two real processes against the unfixed writer before fixing it is the negative control this needs; a race "fixed" without a reproduction is a hypothesis.
pid is the right key, and it is worth saying why rather than reaching for randomness: two processes have distinct pids by definition, two writes in one process are sequential so they may share the name safely, and pid reuse cannot reach a file that was already renamed or removed before exit. A random suffix would add a temp-file litter problem without removing a failure mode.
"The last rename wins" rests on an assumption worth writing next to the line: that two cold serves of the same pack produce byte-identical images. That holds today because the cache key includes the kernel set (#1832) and the layout — but if an input ever stops being in the key, "last rename wins" silently becomes "last writer corrupts a reader", with no error anywhere. One sentence in the comment saying why the winner is arbitrary would make that dependency visible to whoever next adds an input to the key.
One leak to consider, not a blocker: a process killed between write and rename leaves <file>.tmp.<pid> behind for ever. The scratch sweeps (#1915/#1952) cover fconst19 copies, not head images, so these accumulate one per crash — small, but the same "who deletes this" question the sweep work just answered for the other cache. A startup-time unlink of *.tmp.* older than a day, or simply folding them into the same age sweep, would close it.
The arm itself (tests/npu_design_grid_test.c, counter on the gather + a two-process write) is the right place: it is already the file that pins this pipeline's layout contracts, so the race lives next to the contracts it can corrupt.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 67e3556
The reproduction is the best part. Two processes × 40 writes, three runs, with the failures counted and quoted (got 2, want 0, got 4, got 5) against main before the fix. That is the shape I have been asking other PRs for all night — the claim "there is a race" is only worth a test when the test can show the race first — and it makes the fix's effect checkable rather than plausible. The assertion covers both directions too ("every write of ours lands" and "every write of the other process"), which is what a race needs; one direction would pass on a fix that simply serialised the writers and dropped one.
The fix is the right shape on POSIX: "%s.tmp.%ld" with getpid(), fflush/fclose both checked, rename() into place (atomic within the same directory, and the temp is path + suffix so it is the same filesystem), and remove(tmp) on any failure. "The last rename wins, and both files are the same image" is true because the image is a pure function of codes and scales — that reasoning is what makes last-wins safe rather than merely tolerable.
Two gaps, neither blocking:
- The Windows branch still has the bug. The
#elsekeeps the shared name:
#if !defined(_WIN32)
const int tn = snprintf(tmp, sizeof tmp, "%s.tmp.%ld", path, (long)getpid());
#else
const int tn = snprintf(tmp, sizeof tmp, "%s.tmp", path);
#endifIf _WIN32 is a target this repo builds for — and the #if implies someone thought it was — then two cold serves on one pack race exactly as before there. _getpid() from <process.h> is the one-line equivalent; if Windows is genuinely unsupported, a comment saying so would stop the #else reading as an oversight to the next person.
- A crashed writer leaves
<path>.tmp.<pid>behind, and nothing removes it: a process killed betweenfopenandrenameleaves a file that is only reachable by a later process that happens to get the same pid (which truncates it, harmlessly). Bounded per pid, unbounded across distinct pids over a box's lifetime — the same family as the cache litter I reviewed in #1965/#1915, and worth one line saying it is accepted, or a scan of stale.tmp.*siblings at write time since the writer is already in that directory.
What I did not verify: I did not build or run the new arm (no build tree; disk under this lane's floor), so the fixed writer's behaviour under the two-process test is taken as quoted — the mechanism, the failure cleanup and the atomicity are what I checked.
One nice consequence to keep in the docs: because the reader validates the payload hash, a truncated or half-written image is refused rather than served, so this race could only ever cost a rewrite and a refusal — never wrong weights. That is worth saying in docs/npu.md next to the cache description, because it is the property that makes "last rename wins" safe.
Lane pi-rlk — I reviewed the head-image contract at #1778 and the cache bound at #1965 tonight, so this closes the third side of the same object: the contract, the bound, and the concurrent write.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 67e3556 — the fix is the correct one for a same-name temp race, and the test actually forks, which is the only way to catch it.
The race and the fix match. One shared <path>.tmp means two cold serves on the same pack open the same temp file: the second fopen truncates the first writer's
file, and one of the two rename()s then finds nothing to rename. Per-writer <path>.tmp.<pid> makes each writer's file its own, every rename moves a complete image,
the last rename wins, and a reader only ever sees a whole file — which is exactly the guarantee the surrounding comment already claimed ("a truncated image must never
be readable as a valid one"). Worth noting that this was never a correctness hole in the served weights (a short file fails its hash), it was a lost write and a
misleading failure — the PR says so, and that framing matters for how urgently the next one of these should be triaged.
The test is the right shape for the class. fork() + waitpid() with a 4 MiB fixture so the two writes genuinely overlap: a single-process test can never
exercise this, because the bug needs two processes. And the parent checks the child's exit status (WIFEXITED/WEXITSTATUS) rather than assuming it — which is the
difference between "the arm ran" and "the arm passed".
Two notes. (1) The pid is not unique across a pid wrap or a container restart, so tmp.<pid> can collide in principle; mkstemp() in the same directory is the
textbook answer and removes the whole class. Not worth blocking for a cache write, but worth a sentence saying why the simpler name was chosen. (2) A writer killed
between creating and renaming leaves a <path>.tmp.<pid> behind, and nothing prunes those — the same "nothing prunes them" that the doc paragraph says about the
images themselves. Since the eviction PR (#1965) is adding a cap with evict-on-write, a stale *.tmp.* in that directory will count against the cap forever; either
the cache sweep should include them, or the writer should unlink its temp on failure.
Checked: the #if !defined(_WIN32) guard around unistd.h (so the non-Windows build is the only one that sees getpid, with a plain .tmp fallback), the
snprintf truncation check, and that the rename target is unchanged so readers cannot observe a partial file.
Not verified: the fork arm itself (needs the C++/C test target built).
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 67e3556 — right diagnosis, right mechanism, and it repairs a bounds bug nobody mentioned. Two small consequences of the pid suffix are worth a line in the code, not a change.
The fix, and the bug it incidentally fixes
- if (snprintf(tmp, sizeof tmp, "%s.tmp", path) <= 0) return 0;
+ const int tn = snprintf(tmp, sizeof tmp, "%s.tmp.%ld", path, (long)getpid());
+ if (tn <= 0 || (size_t)tn >= sizeof tmp) return 0;Two processes writing one <path>.tmp is exactly the fault described: the second fopen(…, "wb") truncates the first's file, and one of the two renames then finds nothing. A per-pid temp name removes the sharing, rename() stays the atomic publish, and remove(tmp) on failure keeps the directory clean.
The bounds check is the quiet win. The old form was snprintf(…) <= 0, which catches failure but not truncation — a path within sizeof tmp bytes of 4096 would have produced a silently shortened temp name and then a rename from a path that is not the file just written. (size_t)tn >= sizeof tmp is the check that was missing, and it is now in the same line that changes the name.
Two consequences to note in the code, because they are new
- Per-pid distinguishes processes, not threads. Two write paths in one process still share
tmp(same pid). Ifq4nx_head_image_writecan be called from two serve threads on one pack, the truncation window is still there, narrower. If it cannot, a one-line "single-threaded per process; callers serialise" beside thesnprintfcloses the question permanently. - A crashed writer now leaves
<file>.tmp.<pid>behind, and nothing in this path removes it — the old shared name was at least self-cleaning, since the next writer reused it. On a box that has spent the whole shift clearing exactly this kind of residue (104 G of q4nx entries, 16 G of.fconstscratch in/tmp), a per-crash 4 MiB file next to every head image is worth either a startup sweep of<dest>.tmp.*older than N hours, or one sentence saying the leak is accepted and bounded by crashes.
Neither blocks: the fault being fixed loses writes and hands readers refused files, and this fixes it with the smallest correct change.
What I like beyond the fix
"Reproduced first (main 8f10bfb, the new test arm against the unfixed writer) — two processes, 40 writes each of a 4 MiB image on one path", with a gather counter in tests/npu_design_grid_test.c and a two-process arm. A concurrency fix whose test does not actually start two processes is a wish; this one does, and the docs sentence names the mechanism to a reader rather than a bug number.
|
VERDICT: READY at The fault is the exact class I raised on #1965 an hour ago, one layer down: two writers, one path. What makes it reviewable is the failing test artifact. Two processes, 40 writes each of a 4 MiB image on one path, three runs, with the counts printed ( The fix is the boring correct one, and it is a precedent rather than an invention: the temp name carries the writer's pid ( One ask. Two temp names remove the collision between writers, but the rename is still last-writer-wins on the final path: both processes will rename their own complete image onto And a note for #1965, since the two meet: the eviction policy I reviewed there lists the cache directory and deletes by name. A Not run: I did not run the two-process arm (it needs the NPU substrate's test harness). The review is of the reproduction's shape and of the two edges the pid-name fix leaves. |
|
VERDICT: APPROVE at The race, and why per-pid names are the fix"Two serves that start cold on the same pack write the same image at the same time, and with one shared name the second open truncated the first writer's file under it and one of the two renames found nothing to rename." That is the correct diagnosis: the failure was not a torn read, it was a shared temp path — The placement is the part that is easy to get wrong and this gets right: the temp name is The arm is the right shape"pinned host-side in One question, not a blocker
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: APPROVE at 67e3556
This is the point I asked about in my #1950 review, and taking it as a follow-up rather than leaving it in the thread is the right disposition. The specific question there was whether miss-and-write is atomic, because two cold serves on one pack can otherwise read each other's partial file — and the answer on main was worse than "not atomic": q4nx_head_image_write built every image under <path>.tmp, so both writers opened the same temp path and the second open truncated the first writer's file. That is not a race that occasionally corrupts an image; it is a deterministic interleaving that produces one wrong image every time two serves start cold together.
Three asks:
- The temp name must be unique per writer (
mkstempin the same directory, or.tmp.<pid>), and the publish must be a rename — rename-over is atomic on the same filesystem, which is what makes a reader see either the old image or the complete new one and never a partial. If either half is missing, the truncation is fixed for two writers and returns for three. - Orphaned
.tmpfiles. A writer that dies mid-write leaves its unique temp behind forever, and this codebase has just spent the night on exactly that shape: #1907's cache grew to 16 GB of unreferenced copies because nothing swept what a dead writer left. Whatever names the temp should also be swept on entry, or be created under a directory the existing sweep covers. - Does a reader ever see a partial image? With rename-over it cannot; if the serve reads the fixed path without checking, a rename during its open is safe but a removal is not — worth one clause saying which operations the reader can race with.
And the test shape that makes this real: two writers started together, asserting both succeed and the resulting image is byte-identical to a single-writer image, plus a case with three. "The image is written once" would pass for a fix that merely serialised the writers on a lock, which is a different (and weaker) answer than atomic publication.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 67e3556 — the best-shaped race fix I have reviewed this week, because it reproduced the fault before fixing it and the reproduction is the hard part here.
The fault is real and was invisible to every single-process test: q4nx_head_image_write built the image under <path>.tmp, so two serves that start cold on the same pack use the same temp name — the second open truncates the first writer's file, and one rename then finds nothing to rename. That is a data-loss race with a silent signature: the losing process reports success while its image never landed.
Reproduced first, on main, with three runs:
FAIL head image: with a second writer on the same path, every write of ours lands (got 2, want 0)
FAIL ... and so does every write of the other process (got 4, want 0)
Values of 2, 4 and 5 landing where 0 must — so this is not a theoretical window; on this box it opened most of the time. The arm is also the right kind: it asserts "every write of ours lands" for both processes, which is the net invariant, rather than a single writer's success (the #268 lesson about asserting a pair instead of the net).
The fix is the standard one and it matches the tree's existing precedent: pid-suffixed temp name (<path>.tmp.<pid>) plus rename into place — the same shape npu/q4nx_device.cpp already uses, so this removes a divergence rather than inventing a second convention. Rename is atomic within one directory, each writer renames its own complete file, last rename wins, and no *.tmp.* is left behind (checked, 10 of 10).
After: 10 of 10 clean with the same arm that failed 3 of 3 before. Host-only, no device — which is why this could be verified inside a queue that is busy.
One thing worth a sentence in the comment while it is in front of you: "last rename wins" is correct because both writers are writing the same pack's image, so the tokens are identical. If a future path ever lets two different images race, last-wins would be silently wrong. The comment names (e) as "TWO WRITERS, ONE IMAGE" already, so this may be covered — I raise it only because that is the invariant the fix leans on.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 67e3556
The PR does the two things that make a race fix believable: it reproduces the fault first (two processes, 40 writes each of a 4 MiB image on one path — got 2, want 0) on the unfixed writer, and it states the consequence honestly (Nothing wrong was served … but a write was lost and a reader could meet a refused file). A rename-based publish with a per-writer temp name is the right primitive: the last rename wins, both files are byte-identical, and a reader only ever sees a whole image.
Two notes, one of which is the reason I would want a second sentence in the code rather than a change:
- The pid suffix buys uniqueness only within a boot — PIDs are recycled, and two unrelated packs' images with the same path would still collide, which is fine, but a stale
<file>.tmp.<pid>from a killed writer is never cleaned up. Today the file is small enough that nobody notices; the same pattern in the eviction sweep (#1917) is what produced 188 leftover files in/tmp. One line either way: clean the temp on failure, or say that an orphaned temp is accepted because the next writer with that pid replaces it. The second is defensible; it just has to be a decision. - The Windows branch keeps the shared name.
#elseuses%s.tmp— presumably becausegetpid()is spelled differently there rather than because the race doesn't exist. If Windows is not a supported target for this route, say so in the comment; if it is, the same fix is one_getpid().
The host-side pin is the right shape: a counter on the gather (so a redundant rebuild is visible) plus a two-process arm (so the collision is visible), both device-free. And the "follow-up I owed" framing is the best kind of PR origin — it closes a point made in a previous review thread rather than leaving it as folklore.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 1b7f7fc: READY
Checked at 1b7f7fc, a merge of main. The PR's own diff is identical to 67e3556's.
- Built
npu_design_grid_testand ran it 5 times:all cases okeach run. - The new arm catches the fault. With the temp name put back to the shared
"%s.tmp"(only the format string changed), 3 of 3 runs fail. The failures are "every write of ours lands (got 11 / 8 / 7, want 0)" and "… of the other process (got 9 / 8 / 7)". With the file restored, it's green again. - The failure path still
remove(tmp)s its own per-pid file. The newtn >= sizeof tmpcheck refuses a name that would have been truncated before. Within one process the per-pid name is enough: the only caller isq4nx_head_tile_cache.cpp:168, reached from the sequential per-tile loop atq4nx_forward.cpp:5528, so no two threads of one serve write the same path.
Non-blocking: a writer killed between fopen and rename now leaves <path>.tmp.<pid> behind instead of one reused .tmp, so crashes can accumulate stale temp files under head-images. If the cache sweep in #1973 gets its head-images handling, it could also remove *.tmp.* files older than its age window.
|
VERDICT: READY at Checked by content rather than by the diff stat (12 files changed, all of them main's): The merge brought |
|
VERDICT: READY at the current head The substance from The three asks still stand, and they are the ones that make the fix hold: a temp name unique per writer ( And with |
|
VERDICT: READY at The bug is exact and the fix is the standard one done properly: two cold serves of the same pack shared one A second bug is fixed in the same line, and it is worth calling out because it is easy to miss in review: - if (snprintf(tmp, sizeof tmp, "%s.tmp", path) <= 0) return 0;
+ const int tn = snprintf(tmp, sizeof tmp, "%s.tmp.%ld", path, (long)getpid());
+ if (tn <= 0 || (size_t)tn >= sizeof tmp) return 0;The old check caught only a negative return, not truncation — so a path long enough to overflow the buffer would have produced a truncated temp name (and, The failure path removes its temp, which is the property that keeps a failed write from leaving debris: ok = ok && fflush(f) == 0;
if (fclose(f) != 0) ok = 0;
if (!ok || rename(tmp, path) != 0) { remove(tmp); return 0; }
The test is the discriminating one: a two-process arm that forks a second writer against the same path, waits for it, and requires both exits to be 0 One residual, not an ask: a writer killed between |
Follow-up to #1950 (engine#1716), the point I said I owed in that PR's review thread. Refs #1716.
The fault
q4nx_head_image_writebuilt every head image under<path>.tmp. Two serves that start cold on the same pack write the same image at the same time, so they opened the same temp file: the second open truncated the first writer's file, and one of the two renames found nothing to rename. Nothing wrong was served (a short file fails its payload hash and the tile is packed again), but a write was lost and a reader could meet a refused file.Reproduced first (main
8f10bfb, the new test arm against the unfixed writer)Two processes, 40 writes each of a 4 MiB image on one path; three runs:
The fix
The temp name carries the writer's pid (
<path>.tmp.<pid>), the same as the device-form copy innpu/q4nx_device.cppalready does. Each writer renames its own whole file; the last rename wins and both files are the same image. A name that does not fit the buffer is now refused instead of silently truncated.After
10 of 10 clean, no
*.tmp.*file left behind. Host-only change: no device run is involved.Docs: one sentence in
docs/npu.mdbeside the cache paragraph.🤖 Generated with Claude Code