Repository navigation
npu: the host vision tower uses the machine's threads and a blocked product: 37.7 -> 3.2 s per 448x448 view, same bits (engine#1788) - #2142
Conversation
…roduct: 37.7 -> 3.2 s per 448x448 view, same bits (engine#1788) tower_forward() ran every loop on one thread and walked each weight matrix once per token, so a 448x448 view (1,024 patches) of the 1B pack took 37.7 s. The row and head loops are now spread over threads (ONEBIT_VISION_THREADS, vision::tower_threading) and linear() applies each weight row to 16 tokens before moving on. Every token and head is still computed by one thread with the same sequence of operations, so the output is the same bits. Measured, box load ~13: 37.7 s before; 18.1 s on one thread (the blocked product); 3.2 s on 16 threads. Test: the fixture's goldens hold, and 1, 2, 5 and 16 threads (forced, no size threshold) give byte-identical rows. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
|
ready for review at 11b91ce |
PR Reviewer Guide 🔍(Review updated until commit 73ca0f2)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 11b91ce: READY
Reviewed head 11b91ce (refs #1788). This is the first verdict. The claim that matters is "same bits", and I checked it against main directly rather than against the goldens' tolerance.
Same bits as main. I added one temporary line after the fixture's tower_forward(…, GH, GW, &tower), at both main and this head, printing an FNV-1a hash of the raw bytes of out and tower, and built each from an ONEBIT_NPU=ON configure:
[main] CL-HASH out=3323f3d55094d39c tower=c789b25fa29074a8 vision tower: 14 checks, 0 failed
[pr] CL-HASH out=3323f3d55094d39c tower=c789b25fa29074a8 vision tower: 15 checks, 0 failed
So the blocked linear() reproduces main's single-token loop byte for byte. That run used the default threading, and the fixture sits under min_work, so it is one thread. The new arm then forces 1/2/5/16 threads with min_work = 0 and memcmps them against each other, which chains the threaded result back to main. I removed the probe line (git status clean).
The new arm discriminates. A mutant where the calling thread's slice in parallel_for stops one unit short (fn(0, count/parts - 1)) fails it: rc 1, "fixture: 1, 2, 5 and 16 threads give bit-identical rows". I restored the file and the test is 15/15 again.
Read and checked
parallel_for's partition is exact: workerp ≥ 1takes[count·p/parts, count·(p+1)/parts)and the calling thread takes[0, count/parts). Each attention unit (one head of one group) writes its ownctxslice and has its ownprobbuffer.groups = n / groupdrops no tail.main's inner loops already rani < groupfrom eachg0, so a partial group was never valid. The two callers passgroup = n, or4on a grid whose sidestower_forwardrequires to be multiples of 4 (vision_tower.cpp:149).- In a fresh
ONEBIT_NPU=ONconfigure of this head, ctestnpu_vision_pack,npu_vision_tower,npu_vision_plan,npu_vision_imageandnpu_vision_promptpass, 5/5.git merge-treeonto main: no conflicts. CIbuildand CodeQL green.
Not re-run by me: the 37.7 → 3.2 s timings. They are on a loaded box, as the body says, and correctness does not depend on them.
|
Persistent review updated to latest commit 73ca0f2 |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
Review at 73ca0f2 — no verdict token (author's lane). The claim that matters is "same bits", and it is proven the only way that makes it believable: the parallelism is over outputs (a token, a head), so each unit is computed by exactly one thread with the same arithmetic as the serial loop, and the arm then runs the fixture at 1, 2, 5 and 16 threads and compares with memcmp. Including 5 is the nice part — it does not divide the work evenly, so it exercises unequal chunking rather than the two friendly counts.
That is the difference between this and a threaded reduction: an inner-product split across threads would give a plausible answer that changes in the last bits with the thread count, and no tolerance-based arm would catch it.
Also right, and each for a specific reason:
- A
min_workfloor (4e6 multiply-adds) keeps small jobs on the calling thread, so a future small tensor cannot pay 16 thread spawns to do nothing — the usual way a threading change becomes a slowdown. - Capped at 16 with
ONEBIT_VISION_THREADSto override and1to turn it off; documented atdocs/npu.md:1636rather than only in the code. - No perf threshold in
tests/— correctly omitted. A wall-clock assertion in a unit test is a flake generator, so the 37.7 → 3.2 s number belongs in the body, not in ctest.
One thing the test cannot protect, worth a line of visibility: the 12× rests on the min_work threshold and on the loops being large enough to cross it. If someone later changes g_min_work, or a shape change makes the loops smaller, the code silently takes the inline path — every arm above still passes, because bit-identity holds trivially when nothing is threaded, and the only symptom is "image runs are slow again" some days later. Not a reason to add a timing arm; a reason to make the fallback observable, the same way the sealed-set paths announce themselves:
[vision] tower: N thread(s), M of K loops threaded (min_work 4.0e6)
one line under ONEBIT_VISION_DEBUG or into the image probe's output would turn "why is this slow" into a reading. That is the only gap I found, and it is about seeing the fast path rather than believing it.
|
VERDICT: READY at The 37.7 s → 3.2 s is the headline, and the assertion that makes it safe is the one stated as a property rather than measured as an average: "every token and every attention head is still computed by exactly one thread, with the same sequence of floating-point operations". That is the only kind of threading change I will approve on a reference implementation without a bit-exactness arm: if each work item is owned by one thread and the per-item order is unchanged, the output is identical by construction, which is what the unchanged 7.15e-07 shows for the fixture. Two things I like more than the speedup:
Two asks:
Scope of my check: fresh configure (rc 0), built the tower test in a scratch dir, ran it (15 checks, the published-pack arm skipped without |
Refs #1788 (F6, MiniCPM-V 4.7). The issue stays open.
Why
vision::tower_forward()(#2056) is the host reference for the vision tower and the only tower the image probe (#2132) has. It ran every loop on one thread and walked each weight matrix once per token, so one 448 × 448 view (1,024 patches) of the 1B pack took 23 to 56 s depending on what else the box was doing. That made every image run mostly a wait for the CPU.What
ONEBIT_VISION_THREADS, default the machine's count capped at 16, orvision::tower_threading(threads, min_work)in code. Loops under 4e6 multiply-adds stay on the calling thread.linear()now applies each weight row to 16 tokens before moving to the next row. The MLP weight of a block is about 20 MB and a token's input 4.6 kB, so the old loop streamed the whole matrix once per token and was bound by memory.Neither changes the output. Every token and every attention head is still computed by exactly one thread, with the same sequence of floating-point operations as before.
Measured on this box (1B pack, one 448 × 448 view, box load about 13)
The comparison with the reference implementation is unchanged at every thread count (16 × 20 grid, 1B pack):
output 20480 values, max |d| 2.38e-06, corr 1.000000000.The box is shared and loaded, so these are single readings, not a benchmark: the same 16 × 20 comparison on one thread read 9.7 s and 20.3 s in two runs minutes apart. The order of magnitude is the claim (tens of seconds to a few seconds), not the third digit.
Test
npu_vision_tower(existing ctest row):max |out - reference| = 7.15e-07, as before);memcmp). The fixture is small, so without forcing it would never leave the calling thread and the arm would prove nothing; that is what themin_work = 0argument is for.Limits
std::threadper loop, no pool. At these sizes thread start-up is noise; it would matter for a tower run many times a second.Docs:
docs/npu.md, the tower paragraph.🤖 Generated with Claude Code