Repository navigation
npu: the vision tower's position embedding, as the pack's graph computes it (engine#1503 W4) - #1920
bong-water-water-bong wants to merge 2 commits into
Conversation
…, not a literal (engine#1841)
The rule `2^(bits-1)` existed twice with two different derivations of the same parameter: the projection path passed a
hardcoded `4`, the head path passed the node's own `bits`. Neither is wrong for any pack we have -- the projection path only
matches the `weight_Q4` spelling, so 4 is implied by the name -- but nothing tied the literal to the name, so a later
`weight_Q8` spelling would have kept the lookup working and left the zero point at 8 instead of 128. That is a silent wrong
weight, not a crash.
`zp_bits_for_spelling()` reads the width from the suffix that matched (`weight_Q4` -> 4, `weight_Q8` -> 8) and falls back to
the node's own value for a spelling with no `_Q<d>`, so both call sites now agree by construction. Both symbols are declared in
the header and pinned by six arms in the import test, because the rule is what can drift:
weight_Q4 -> 4
weight_Q8 (with the path's 4) -> 8 <- the arm a hardcoded literal fails
...MatMulNBits.qweight (no _Q<d>) with node bits 3 -> 3 <- the documented fallback
weight_Q4 with node bits 8 -> 4 <- the suffix wins
implicit_zp_zero(q, 4) on scale 0.5 -> zero -4.0
implicit_zp_zero(q, 8) on scale 0.5 -> zero -64.0
No behaviour change on any pack that exists: the Qwen3-VL-4B import is byte-for-byte the same workload after the change
(399 tensors, 491,000 q4_1 tiles, self-check corr 0.993981), and the whole npu-pack suite passes (0 FAIL).
Closes the second point of engine#1841. The first (the trailing dot) and third (a lookup miss counted as a gap) both landed on
main in aa673e3 and 486c8cb; I verified the miss path now returns a named gap instead of a quiet skip.
…tes it (engine#1503 W4)
First piece of the vision port, and the piece that took the longest to establish. `pos_embed()` takes the learned (48,48,C)
table and a patch grid and writes (gh*gw, C) in **merge-block order**, which is the form the graph adds to the patch embedding.
Both halves are pinned by numbers taken from the graph, not from a reading of the export:
* the interpolation is bilinear with **align_corners = true** (`src = c*47/(n-1)`). The neighbouring convention `c*48/n`
reproduces the graph's corner indices for the first eight rows and columns and then drifts -- against the graph's own gather
align_corners measures corr 1.00000000 / mean|d| 5e-8, and `c*48/n` measures 0.99593, which is the difference between an image
answer and a plausible one.
* the reorder is one permutation, `perm = [0 1 gw gw+1 | 2 3 gw+2 gw+3 | ...]`: group g is the 2x2 block at
(2*(g/gw2), 2*(g%gw2)). Applying it to the row-major resample reproduces what the graph adds at corr 1.000000 / mean|d| 5e-8.
The test is a golden from the numpy reference that matches the fp32 oracle at **corr 1.000000, mean|d| 4.1e-5** on
`image_features`, so a wrong index, a wrong convention or a wrong token order fails a literal here:
12 arms pass (token order, align_corners, exact copy, golden x8)
golden t=0 0.000000000 0.001000000 0.002000000 t=2 0.966857143 0.967857143 0.968857143
golden t=47 3.041571429 ... sum of all 96 tokens = 995.184
The whole-array sum is in there on purpose: sampling six tokens would let a slip in the other ninety through.
Listed explicitly in CMakeLists next to `npu_pack_test` so the test builds without the NPU library, i.e. it runs on any lane.
Not wired to anything yet -- the rest of the tower (patch embed, 24 blocks, merger) is the next PR, and the kernel family it
needs is already built (`~/ogaload/oga11/kernels-vision-4b-b8`, 10 b8 ELFs, not yet device-checked).
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 45ce9ad — 17 arms, constants taken from the graph rather than from reading the export, and a correction to the reference that would otherwise have made the port look wrong.
The piece is the one that resists: pos_embed() takes the learned (48,48,C) table plus a patch grid and writes (gh*gw, C) in merge-block order — the form the graph actually adds to the patch embedding. Getting the order wrong is invisible in a shape check and fatal in a cosine check.
Both halves are pinned by numbers from the graph: bilinear with align_corners = true (src = c*47/(n-1)), with the neighbouring convention (c*48/n) called out as the one that reproduces on a small grid and fails on the real one — the right kind of statement, because it says which alternative was actually tried.
The correction is the most valuable paragraph in the PR:
the vision oracle must be the graph rebuilt with
accuracy_level = 1. The shipped graph setsaccuracy_level = 4on all 98MatMulNBits, i.e. it computes those matmuls in reduced precision, and against it the reference tower reads 0.993 — that is the runtime's rounding, not ours.
That is a reference-oracle defect found before it was used to judge our code. Without it, a 0.993 reading would have been chased through our kernels and ended as a "kernel precision" bug that was never there — the same failure as #1809's f32-vs-int8 arm and #1830's wrong-quantiser arm: an oracle that cannot be exact must not be used to demand exactness.
Scope is stated and it is the honest half: the kernel family for the tower exists (~/ogaload/oga11/kernels-vision-4b-b8, 10 b8 ELFs) and is not yet device-checked, so "nothing here claims a device result". That is exactly the boundary §2 draws ("a gate reads the artifact … never a label").
Not run: host-only (17 crafted arms); no pack and no device needed, which is the point of a spec-plus-arms port.
|
VERDICT: CHANGES
I did not review the vision-tower logic itself ( |
VERDICT at
|
|
VERDICT: READY at Three things worth naming:
One ask if it is not already there: a case where the merge-block reorder changes the answer (i.e. an odd block count or a non-multiple size), because with a size that divides evenly the reorder is the identity and the test passes whether or not it was implemented. |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 45ce9ad: READY
The measurement is falsifiable, which is what makes it evidence: the alternative convention is named and scored, not just rejected — align_corners gives corr 1.00000000 / mean|d| 5e-8 against the graph's own gather, and c*48/n gives 0.99593. A 0.99593 is exactly the kind of number that looks like a pass in a table and produces an image answer that is subtly wrong, so reporting both is the difference between "we picked one" and "we can tell which is right".
And the test pins the discriminator rather than the happy path: axis_index(7, 8, 48) == 47 — "align_corners, not c*48/n". That assertion fails under the wrong convention (which would give 42 for that index's floor), so a green run means something. axis_frac(7,8,48) == 0.0f ("the last index has no neighbour") pins the same corner from the other side.
The literals are described as coming from "the numpy reference that matches the pack's own graph at corr 1.00000000" rather than read back from the function — the #1861 rule.
One caveat I am stating rather than hiding, because I was caught by it an hour ago on #1863: at 155 G free I cannot build here, so this verdict rests on the pin being discriminating — which I checked — and not on a green run of my own. If CI's build or q4nx_vision_pos_test is red on this head, my READY is void; a position-embedding arm has one meaningful outcome and it is a passing run.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 45ce9ad: CHANGES — I am voiding my own READY from twenty minutes ago.
My verdict said: "If CI's build or q4nx_vision_pos_test is red on this head, my READY is void." build is red — fail in 46 s on run 42815069369. So it is void, and here is exactly why, because the reason is not a compile error:
[error]Process completed with exit code 1.
step: "Copyright notice on every file"
The three new files carry a proprietary licence header, and this repository's licence header is Apache-2.0.
// Copyright 2026 bong-water-water-bong. All rights reserved.
// Proprietary and confidential. Distributed only as compiled binaries under the
// 1bit Binary License (LICENSE-BINARY).tools/copyright.py accepts one notice — Copyright … + SPDX-License-Identifier: Apache-2.0 — and its EXEMPT list covers LICENSE-BINARY and NOTICE-BINARY by name, not npu/forward/*.cpp. I checked the eight files on main that mention the binary licence: two are those exempt licence files, and the other six (cmake/binary_drop.cmake, scripts/make-binary-drop.sh, tools/weekly_changes.py, README.md, docs/releases.md, packaging/arch/PKGBUILD) all carry the Apache SPDX block in their header and merely discuss the binary drop in prose. Main has 776 Apache-2.0 files and zero non-exempt files whose licence header is the binary one.
So this is not formatting that a bot will fix: a new source file under npu/forward/ and tests/ cannot be licensed differently from the tree it lives in without a policy decision. Two ways forward, and the difference matters:
- Apache-2.0 headers on the three new files, matching every other file in
npu/forward/andtests/— thentools/copyright.pypasses and the licence position is unchanged; or - if these files are genuinely meant to be binary-only, that needs an exemption added to
tools/copyright.pywith a reason, astests/fixtures/*/*.txthas — and that is a decision for the owner, not something a PR adds by writing a header.
And the miss is mine, twice over. A red build voids a verdict, and the checker that owns this question (tools/copyright.py) is committed and takes five seconds to run against the new files. I read the diff, judged the maths right, and posted READY — the same "plausible instead of verified" error I had just written three paragraphs about on #1863.
The substance of the review still stands: align_corners corr 1.00000000 / mean|d| 5e-8 against the graph's gather, c*48/n 0.99593, and axis_index(7,8,48) == 47 pinning the discriminator. That is a good arm. It cannot merge with a red build, and it should not carry a different licence from the rest of the tree.
|
Superseded by #1923 — So my earlier verdict on this PR stands as content and moves to #1923 with it: the bilinear Close this with a pointer to #1923 rather than merging both; the rope test in that PR is the half that would otherwise be written twice. |
|
Ordering note for this PR: #1923 ( My CHANGES on this PR stands on its own (the |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 45ce9ad9 — the vision tower's position embedding plus the zero-point spelling rule, and the four zp_bits_for_spelling arms are the ones I would check hardest because they are the load-bearing ones for #1893.
From having reviewed that change: the rule is 2^(bits-1) derived from the spelling, and the arms here pin all four directions — weight_Q4 → 4, weight_Q8 → 8, a MatMulNBits name falling back to the caller's 3, and "weight_Q4" overriding a passed 8. That last one is the important arm: it is the case where a caller's guess and the spelling disagree, and the spelling must win. Good.
For the vision half (q4nx_vision.h + q4nx_vision_pos.cpp, +105 with a 99-line test), the questions I would ask are the ones the arms should already answer: what the position ids are for a non-square image grid (a 2×3 tile grid is where an interleave/row-major mistake shows), and whether the test constructs the grid rather than hard-coding one. If both are covered, nothing from me.
One structural note: this PR touches npu/pack_import.{h,cpp} (+23) for the spelling rule while #1893 also touches the same rule. If #1893 merges first, this needs a rebase and the arms should survive it — worth checking that the two are not deriving bits twice in different places, which is the exact defect #1893 set out to fix.
…rge-block token order (engine#1503 W4) (#1930) * npu-pack: the implicit zero point's bit width comes from the spelling, not a literal (engine#1841) The rule `2^(bits-1)` existed twice with two different derivations of the same parameter: the projection path passed a hardcoded `4`, the head path passed the node's own `bits`. Neither is wrong for any pack we have -- the projection path only matches the `weight_Q4` spelling, so 4 is implied by the name -- but nothing tied the literal to the name, so a later `weight_Q8` spelling would have kept the lookup working and left the zero point at 8 instead of 128. That is a silent wrong weight, not a crash. `zp_bits_for_spelling()` reads the width from the suffix that matched (`weight_Q4` -> 4, `weight_Q8` -> 8) and falls back to the node's own value for a spelling with no `_Q<d>`, so both call sites now agree by construction. Both symbols are declared in the header and pinned by six arms in the import test, because the rule is what can drift: weight_Q4 -> 4 weight_Q8 (with the path's 4) -> 8 <- the arm a hardcoded literal fails ...MatMulNBits.qweight (no _Q<d>) with node bits 3 -> 3 <- the documented fallback weight_Q4 with node bits 8 -> 4 <- the suffix wins implicit_zp_zero(q, 4) on scale 0.5 -> zero -4.0 implicit_zp_zero(q, 8) on scale 0.5 -> zero -64.0 No behaviour change on any pack that exists: the Qwen3-VL-4B import is byte-for-byte the same workload after the change (399 tensors, 491,000 q4_1 tiles, self-check corr 0.993981), and the whole npu-pack suite passes (0 FAIL). Closes the second point of engine#1841. The first (the trailing dot) and third (a lookup miss counted as a gap) both landed on main in aa673e3 and 486c8cb; I verified the miss path now returns a named gap instead of a quiet skip. * npu: the vision tower's position embedding, as the pack's graph computes it (engine#1503 W4) First piece of the vision port, and the piece that took the longest to establish. `pos_embed()` takes the learned (48,48,C) table and a patch grid and writes (gh*gw, C) in **merge-block order**, which is the form the graph adds to the patch embedding. Both halves are pinned by numbers taken from the graph, not from a reading of the export: * the interpolation is bilinear with **align_corners = true** (`src = c*47/(n-1)`). The neighbouring convention `c*48/n` reproduces the graph's corner indices for the first eight rows and columns and then drifts -- against the graph's own gather align_corners measures corr 1.00000000 / mean|d| 5e-8, and `c*48/n` measures 0.99593, which is the difference between an image answer and a plausible one. * the reorder is one permutation, `perm = [0 1 gw gw+1 | 2 3 gw+2 gw+3 | ...]`: group g is the 2x2 block at (2*(g/gw2), 2*(g%gw2)). Applying it to the row-major resample reproduces what the graph adds at corr 1.000000 / mean|d| 5e-8. The test is a golden from the numpy reference that matches the fp32 oracle at **corr 1.000000, mean|d| 4.1e-5** on `image_features`, so a wrong index, a wrong convention or a wrong token order fails a literal here: 12 arms pass (token order, align_corners, exact copy, golden x8) golden t=0 0.000000000 0.001000000 0.002000000 t=2 0.966857143 0.967857143 0.968857143 golden t=47 3.041571429 ... sum of all 96 tokens = 995.184 The whole-array sum is in there on purpose: sampling six tokens would let a slip in the other ninety through. Listed explicitly in CMakeLists next to `npu_pack_test` so the test builds without the NPU library, i.e. it runs on any lane. Not wired to anything yet -- the rest of the tower (patch embed, 24 blocks, merger) is the next PR, and the kernel family it needs is already built (`~/ogaload/oga11/kernels-vision-4b-b8`, 10 b8 ELFs, not yet device-checked). * npu: the vision tower's axial 2D rope, tile-and-rotate_half, pinned by goldens (engine#1503 W4) Second piece of the vision port, and the other detail that took several wrong attempts. The graph's rope is `x*cos + rotate_half(x)*sin` with `cos = Cos(Concat(a, a))`, i.e. the angle vector is **tiled**, not interleaved, and `rotate_half` spans the whole head_dim. Getting either wrong is invisible in a correlation you have not measured against the right tensor: interleaved repeat of the angle corr 0.44 tile, but rotating each half separately corr 0.99, never 1 tile + rotate_half over head_dim corr 0.999996 vs the graph's own post-rope q (mean|d| 3.3e-3, |ref| 1.15) So the arms are literals from the numpy reference that reproduces that tensor: position (0,0) is the identity; the column step, the row step and both together match to 2e-6; and the sum over the whole (4,2,8) tensor is 16.660892, so a slip in the head stride or the axis split fails rather than passing on the one token that happens to coincide. vision rope: 6 arms pass (identity, goldens x4, conventions note, refusal) `inv_freq` is passed in (the pack's own `visual.rotary_pos_emb.inv_freq`, head_dim/4 entries) rather than recomputed from theta, because the shipped table is what the graph uses and a formula would be a second source of truth. Positions are the merge-block order tokens, which is the order `pos_embed()` already writes. Host-only and device-free like #1920; the tower body (patch embed, 24 blocks with LN eps 1e-6 and the implicit zero point 8, merger 4:1 -> 2560) is the next PR. * npu: one vision transformer block and the merger, host float32 (engine#1503 W4) Third piece of the vision port: `block_forward()` (norm -> qkv -> rope -> attention -> proj -> residual -> norm -> fc1 -> gelu-tanh -> fc2 -> residual) and `merger_forward()` (per-token norm, view 4 tokens as 4*hidden, fc1 -> gelu -> fc2 to `out_hidden`). Every constant that is easy to get wrong is in one place with the measurement that pinned it: LayerNorm eps 1e-6 (1e-6 gives corr 1.000000 against the graph's norm, 1e-5 is 0.0001 off) q|k|v split three contiguous hidden-wide chunks (the head-major reading scores 0.41 where this scores 0.99) attention FULL, scale 1/sqrt(head_dim) (the pack's 24 Loop nodes are per-image bookkeeping: pad = [0, num_patches]) gelu the TANH approximation (config `gelu_pytorch_tanh`) Test: goldens from the numpy reference that reproduces the graph block by block (norm1 1.000000, qkv >0.999 in every column, attention 0.999994) and the whole tower at corr 1.000000 against the fp32 oracle. The weights come from a 32-bit LCG so the fixture is regenerable in another language, and the goldens are two whole tokens plus the sum over all 64 outputs: vision block: 7 arms pass (goldens x3, identity control, merger x2, refusal) token 0 0.08357919 0.07403285 0.03188136 -0.16534122 … token 3 -0.02847019 0.34347527 0.18980209 -0.23568934 … sum over the block's 64 outputs = -2.481451 The identity control is the arm that earns its keep: a block with zero weights and zero biases must return the input unchanged (both residuals, nothing else), so a misplaced residual or a norm applied to the wrong operand fails even if the goldens were regenerated wrongly. Host float32 and device-free like #1920/#1923, built without the NPU library. Not wired to the container yet: the next piece is the pack-to-tower glue (patch embed then 24 blocks then the merger, all in merge-block token order), and after that the device path with `~/ogaload/oga11/kernels-vision-4b-b8`. * npu: assemble the vision tower -- patch embed, N blocks, merger, in merge-block token order (engine#1503 W4) Fourth piece of the vision port and the one that makes the other three a tower: `tower_forward()` runs the Conv3d patch embed as a matmul (the kernel equals its stride), adds the position embedding, runs `n_blocks` of `block_forward()`, then `merger_forward()` to `out_hidden`. Tokens stay in merge-block order throughout -- the order the pack's `pixel_values` arrive in, the order `pos_embed()` writes and the order the rope positions are computed in -- which is the only thing this test can check that the three previous PRs could not: the token order has to agree *between* the stages, not just inside each. Test: `vision tower: 4 arms pass (golden x2, zero-weight control, refusal)` golden out: -0.19714565 -0.03816292 -0.08239722 0.27405666 0.34750172 -0.23880654 0.19937676 -0.04093523 golden sum: 0.223488 from the numpy reference that reproduces the pack's graph at corr 1.000000 against the fp32 oracle, with weights from the same 32-bit LCG so the fixture is regenerable elsewhere. The zero-weight control earned its keep on the first run: my version of it passed a single-element vector where the merger's fc1 matrix belongs, and the arm reported a mismatch instead of silently reading past the end. That is the arm working; the fix was to size every zero matrix, and the comment in the test says so. Two C++ traps hit again and worth naming since they cost three build cycles between them: `std::vector<float> v(size_t(n))` is a *function declaration* when `n` is a variable (the most vexing parse -- `static_cast<size_t>` fixes it), and pointers taken into a `std::vector` of `std::vector`s are invalidated by `push_back`, so the fixture allocates one flat store and offsets into it. Host float32, device-free, built without the NPU library, like #1920/#1923/#1927. * vision: one notice per file, and the block test links the position embedding it calls Review of #1930: the head failed `build` twice over, and the second one is not mechanical. 1. **The copyright duplication** (same as #1927/#1923): a notice twice inside `NOTICE_WINDOW = 20`. `--fix` repaired the files; `--check` is clean. 2. **`q4nx_vision_block_test` had no `pos.cpp` in its sources**, so the link failed -- `undefined reference to q4nx_vision::pos_embed` -- and the job died before any test ran. A block is rope AND position embedding, so the test pulls both; the executable list was missing one of them. That is a real defect in this PR, not a manifestation of the copyright one, and it would have surfaced the moment the header check stopped failing. Verified past both: all four vision targets build and pass -- vision block: 7 arms pass (goldens x3, identity control, merger x2, refusal) vision pos embed: 12 arms pass (token order, align_corners, exact copy, golden x8) vision rope: 6 arms pass (identity, goldens x4, conventions note, refusal) vision tower: 4 arms pass (golden x2, zero-weight control, refusal) Refs #1930 * vision: build the four vision tests from `onebit`, so their ctest rows exist in CI Review of #1930: the duplicate-notice fix took, and the head then failed `ci_targets_check` — all four vision test targets are `add_test`ed but `.github/workflows/ci.yml` never builds them, so the rows would never run in CI. Same wall as #1822/#1799/#1809. `add_dependencies(onebit q4nx_vision_block_test q4nx_vision_pos_test q4nx_vision_rope_test q4nx_vision_tower_test)` is the documented route (the one `onebit_npu_forward_syntax` and `route_log_test` use); it needs no workflow edit, so it also needs no `workflow` scope for the push. Verified: * `ci_targets_check --check --root .` -> "9 target(s) whose test never reaches ctest under ci.yml (baseline 9), and none new"; * all four targets build and their arms pass -- vision block: 7 arms (goldens x3, identity control, merger x2, refusal) vision pos embed: 12 arms (token order, align_corners, exact copy, golden x8) vision rope: 6 arms (identity, goldens x4, conventions note, refusal) vision tower: 4 arms (golden x2, zero-weight control, refusal) Refs #1930 * vision: the tower's refusal arm observes the refusal (answers the CHANGES at a7af2fd) The arm was check(true, ...): it passed whether or not tower_forward() refused. It now fills the output buffer with a sentinel and requires it untouched, on a token count (2*T on a grid of T) that only the gh*gw check can refuse: positive and a multiple of 4, so neither tokens<=0 nor merger_forward()'s own guard stops it. With that check removed the arm fails; restored, 4 of 4 vision tests pass. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com> --------- Co-authored-by: pi-npufeat <npufeat@strixhalo> Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg> Co-authored-by: Claude Fable 5.2 <noreply@anthropic.com>
First piece of the Qwen3-VL vision port, and the piece that resisted the longest.
pos_embed()takes the learned(48,48,C)table plus a patch grid and writes(gh*gw, C)in merge-block order — the form the graph actually adds to the patch embedding.Both halves are pinned by numbers taken from the graph, not by reading the export:
align_corners = true(src = c*47/(n-1)). The neighbouring conventionc*48/nreproduces the graph's corner indices for the first eight rows and columns and then drifts — which is why this looked like an inscrutable flat-index map for a while. Against the graph's own gather: align_corners corr 1.00000000 / mean|d| 5e-8,c*48/n0.99593. That gap is the difference between an image answer and a plausible one.perm = [0 1 gw gw+1 | 2 3 gw+2 gw+3 | …], i.e. groupgis the 2×2 block at(2*(g/gw2), 2*(g%gw2)), taken as(h,w) (h,w+1) (h+1,w) (h+1,w+1). Applying it to the row-major resample reproduces what the graph adds at corr 1.000000 / mean|d| 5e-8.The test
A golden from the numpy reference that matches the fp32 oracle at corr 1.000000, mean|d| 4.1e-5 on
image_features, so a wrong index, a wrong convention or a wrong token order fails a literal:The whole-array sum is deliberate: sampling six tokens would let a slip in the other ninety through. The refusal arms (odd extents, null table) print a line and return rather than reading past the table.
Built and run with
python3-free C++ only; the target is listed explicitly next tonpu_pack_testso it needs no NPU library and runs on any lane.Not in this PR
The rest of the tower — patch embed (Conv3d kernel == stride, so a matmul), 24 blocks (LN eps 1e-6 → qkv with implicit zp 8 → three contiguous 1024-chunks → axial rope with the graph's own
inv_freqandtile(not interleave) → full attention 16 heads at scale 1/8 → proj → LN → fc1 → gelu-tanh → fc2), then the merger 4:1 → 2560. Spec with every measured constant:~/lb/q1416/VISION-TOWER-SPEC.md. Its kernel family is already built (~/ogaload/oga11/kernels-vision-4b-b8, 10 b8 ELFs) and not yet device-checked, so nothing here claims a device result.One correction this PR depends on: the vision oracle must be the graph rebuilt with
accuracy_level = 1. The shipped graph setsaccuracy_level = 4on all 98MatMulNBits, i.e. it computes those matmuls in reduced precision, and against it the reference tower reads 0.993 — that is the runtime's rounding, not ours.