Withdraw the fused multiply-add, and measure what the grid costs

The section claimed what was left of fixed point's cost was "an integer
multiply with a rounding against a float fused multiply-add". Both halves are
wrong and the evidence is in the binaries.

`5ed9e87` contains no FMA instruction, and rebuilding it with
`-C target-feature=+fma,+avx2` still produces none: Rust does not contract
`a + b * c`, so `LerpUtil::lerp` is `subss`, `mulss`, `addss` on any target.
The float side never had the instruction the grid was being compared against.

Nor is the multiply the cost. Replacing `shift_round` in `Fixed::mul` with a
truncating shift -- all twenty-seven work counters diffed identical -- takes
43M instructions off and leaves cycles inside the noise. What moves cycles is
whether `UiSpan::within` inlines: the rounding and both short-circuits removed
together are worth 118M instructions and 57M cycles where separately they are
worth 61M and 8M, and `<UiSpan>::within` stops being a symbol. None of that is
shippable, but it is where to push.

Three other entries corrected against measurement rather than argument. The
division is not 0.00% of cycles -- that was `apply_scalar`; `Holds::through`
runs 2.23M `i64` divisions a 500-frame `many` and holds the divider busy 21.3M
cycles. `d75a1e2` and `4cbb242`'s short-circuits no longer earn anything now
that `Fixed` wraps. Branchless `shift_round` still loses, for the opposite
reason to the one recorded: it is the rounding's size, not its branch.

And the measurement posture: cycles spread 1-3% across nine runs of one
binary rather than 0.23%, `instructions:u` returns a garbage value in roughly
a quarter of readings, and an instruction count moves 0.5% across a rebuild of
identical source. Quote deltas against a baseline built beside the thing
measured.

Checked: `/home/bob/repos/iris-float-cmp` carries `5ed9e87` with
`Edits::fixed_branches` applied, and is recorded under "Rigs and reproduction"
so the comparison can be re-run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
iris-aiandClaude Opus 5 committed 2026-09-16 15:07:02 -04:00
1 parent 663a95f4a2
commit 10e46522b4
1 file changed
+118 -24
+118 -24
View File
@@ -163,29 +163,100 @@ Measured on the fixed-shape fixture, seed 1, depth 8, 500 frames of `many`:
| | drawn widgets | primitive writes | instructions | cycles | | | drawn widgets | primitive writes | instructions | cycles |
| --- | ---: | ---: | ---: | ---: | | --- | ---: | ---: | ---: | ---: |
| `5ed9e87`, before fixed point | 100 | 4,272 | 1,761M | ~699M | | `5ed9e87`, before fixed point | 100 | 4,272 | 1,763M | 693M |
| `4cbb242` | 97 | 3,951 | 2,093M | ~819M | | `4cbb242` | 97 | 3,951 | 2,093M | ~819M |
| `4febabf`, wrapping | 97 | 3,951 | 1,918M | ~771M | | `4febabf`, wrapping | 97 | 3,951 | 1,912M | 771M |
Normalised by primitive writes the grid is now about 1.18x the float head The two ends were re-measured 2026-09-16 as medians of nine runs of two
on instructions and 1.19x on cycles; per drawn widget it is 1.12x. What is freshly built binaries and are the numbers above; `4cbb242`'s row is still
left is the shape of an integer multiply with a rounding against a float the single reading it was taken from. Raw, the head is 1.085x the float head
fused multiply-add, spread across `UiSpan::within` (4.7% of cycles), on instructions and 1.113x on cycles. Normalised by primitive writes it is
`remap_subtree` and `Holds::through`; nothing in the profile above those 1.17x and 1.20x; per drawn widget 1.12x. **Take none of those for the grid's
is the grid. Text line breaking is 10% and `InstanceList::push` 9%. cost.** The two heads do different work rather than the same work at
different speeds -- `4febabf` remaps 53 subtrees a frame and redraws 27
where `5ed9e87` remaps 38 and redraws 43 -- and dividing by primitive writes
does not correct for a different mix of remapping and redrawing.
**Cycles are measurable on this machine and instructions alone are not **The fused multiply-add this section used to name does not exist,
enough.** Three runs of one binary varied 0.23%. It is *wall time* that withdrawn 2026-09-16.** `5ed9e87`'s binary holds no FMA instruction at all,
varies 2x here, which is what the machine notes say; the counters are and none appears when it is rebuilt with `-C target-feature=+fma,+avx2`
steady. IPC is 2.58 before fixed point and 2.59 after, so the cost is either. Rust does not contract `a + b * c`, so `LerpUtil::lerp`'s
instruction count at unchanged throughput -- not stalls, not branch misses `from + (to - from) * self` is `subss`, `mulss`, `addss` on every target
(0.69%), not the division. there is. The float side never had the single instruction the grid's
multiply was being compared against, here or anywhere.
**What the cycle profile says the cost is**, against what the previous **Nor is the multiply the cost.** Each row below is `4febabf` with one
session recorded from the disassembly. In `AxisRemap::apply_scalar` the thing taken out, in one worktree so the rows compare to each other, 500
`i64` division is **0.00% of cycles** and the multiply 1.5%: the time is in frames of `many` at seed 1 depth 8, medians of nine to eleven runs. **Every
`saturating_add`, which is five instructions and has no `i32` vector form. row leaves the work counters where they were**, so unlike the cross-commit
The division is rare because most moves are translations. numbers above these compare one tree against itself: all twenty-seven
counters diffed identical for the all-three row that the conclusion rests
on, and six of them checked for the others.
| | instructions | cycles |
| --- | ---: | ---: |
| `4febabf` | 1,921M | 772M |
| `shift_round` out of `Fixed::mul`, truncating instead | 1,878M | 774M |
| `is_full` short-circuits out of `UiSpan::within` | 1,910M | 765M |
| zero short-circuit out of `Fixed::scaled` | 1,914M | 768M |
| all three | **1,803M** | **715M** |
| branchless `shift_round`, both short-circuits out | 1,964M | 773M |
Taking the rounding out alone removes 43M instructions and moves cycles by
+2M, which is inside the noise: the grid's arithmetic is instructions the
machine has spare issue width for, not time.
**IPC has reversed since this section recorded it**, and that is the same
finding from the other side. It was 2.58 before fixed point and 2.59 after,
which is what "the cost is instruction count at unchanged throughput" was
read from. On the re-measured medians it is **2.54 before and 2.48 after**:
the head both executes more instructions and retires them more slowly, so
there is something to find beyond counting them. The all-three build
recovers half of it against its own worktree's baseline, 2.49 to 2.52.
**What moves cycles is whether `UiSpan::within` inlines, and the three cuts
are superadditive because of it.** Separately they are worth 61M
instructions and 8M cycles between them; together, 118M and 57M -- four
fifths of the instruction gap and two thirds to three quarters of the cycle
gap, which is the looser number. The mechanism is visible in `nm`:
`<UiSpan>::within` is a symbol in `4febabf` and in neither `5ed9e87` nor the
all-three build. Shortening its body past the inliner's threshold is what
pays, and only the whole set of cuts reaches it. None of them is shippable
-- truncating breaks the one rounding the grid is for -- but that is where
to push, and a cheaper `Holds` or a smaller `Len::within` is worth trying
for this reason rather than for its own instruction count.
**Cycle noise is larger than this section used to claim.** Across ten sets
of nine to eleven runs, each of one unchanged binary, cycles spread 1-3%
usually and 6.7% in the worst set -- not the 0.23% recorded from three
runs, which was a lucky set read as the machine's precision. Worse,
`instructions:u` in this VM intermittently returns a garbage value
-- 11.0e9, 25.8e9 and 28.4e9 against a true 1.9e9, in roughly a quarter of
readings. Take medians of nine or more, drop anything off by a factor, and
do not trust a single `perf stat`. `ex_div_busy` was the one counter that
held to 0.1% across runs.
**Instruction counts hold to 0.02% within a binary and move 0.5% across a
rebuild of the same source**, which is why the two tables above disagree
about `4febabf` by 9M: their baselines were built in different worktrees.
The *difference* against `5ed9e87` was 149M in both builds. So compare
against a baseline built beside the thing being measured, quote a delta
rather than an absolute, and re-measure the baseline whenever anything is
rebuilt.
**"The division is 0.00% of cycles" was read off the wrong function.** It
is true of `AxisRemap::apply_scalar`, where most moves are translations and
the multiply is 1.5%. It is not true of the run: `Holds::through` divides
twice on every call and accounts for essentially all of the 2.23M `i64`
divisions a 500-frame `many` executes, which hold the integer divider busy
**21.30M cycles** -- the steadiest counter on this machine, 0.1% across
runs, and 2.8% of the run. A controlled extra `div_toward` in `through`,
against a de-const control so the inlining does not move, costs 6.3 cycles
each, so the two real ones are about 14M. `5ed9e87` divides twice there too,
in floats, so this is a real cost but a smaller *difference* than the
source-line profile makes it look, where `div_toward`'s `num / den` is the
largest single grid line in the head at 106M of 5,361M over 4,000 frames,
against `shift_round`'s 71M.
Recovered this session, each measured before and after on one commit with Recovered this session, each measured before and after on one commit with
byte-identical renders proving the layout unchanged -- on the old fixture, byte-identical renders proving the layout unchanged -- on the old fixture,
@@ -197,21 +268,34 @@ byte-identical renders proving the layout unchanged -- on the old fixture,
translation is: -4% instructions, -3.4% cycles. translation is: -4% instructions, -3.4% cycles.
- `d75a1e2` and `4cbb242` skip multiplies that cannot change anything -- - `d75a1e2` and `4cbb242` skip multiplies that cannot change anything --
composing through a full box, and `lerp` where the ends are equal: composing through a full box, and `lerp` where the ends are equal:
together -2.7% instructions, -1.8% cycles. together -2.7% instructions, -1.8% cycles when they landed. **That no
longer reproduces at `4febabf`**: removing either one now *saves* a
little, and removing both is part of the 118M/57M above. Wrapping changed
what a multiply costs, and a guard priced against a saturating multiply is
priced against something that is gone. Re-price a short-circuit before
keeping it.
Tried and rejected, with numbers, so they are not tried again: Tried and rejected, with numbers, so they are not tried again:
- A float reciprocal for the division: **+6% cycles**. The conversions cost - A float reciprocal for the division: **+6% cycles**. The conversions cost
more than the `idiv` that is not on the hot path anyway. more than the `idiv` did there. That was `apply_scalar`'s division; the
- Branchless `shift_round`: **+6.7% cycles**. The sign of a product predicts one in `Holds::through` is a different question and has not been tried.
well, and the positive path is two instructions. - Branchless `shift_round`: **+6.7% cycles** when tried alone, and
re-tested 2026-09-16 with both short-circuits also removed, where the
branchy form reaches 1,803M/715M: 1,964M instructions and 773M cycles,
worse than the head on both. It is the *size* of the rounding that keeps
`within` out of line, not its branch, so making it branchless makes it
bigger and loses twice.
- Removing the per-child hash lookup in `remap_subtree` with `mem::take`: - Removing the per-child hash lookup in `remap_subtree` with `mem::take`:
**0.0%**. `FxHashMap` on a hot line is not the cost. **0.0%**. `FxHashMap` on a hot line is not the cost.
- Short-circuiting `apply_scalar` where the fraction is nought or one: +17%. - Short-circuiting `apply_scalar` where the fraction is nought or one: +17%.
(From the previous session.) (From the previous session.)
- `#[inline]` on `UiSpan::within`, whose prologue and epilogue were a sixth - `#[inline]` on `UiSpan::within`, whose prologue and epilogue were a sixth
of its own cycles: -0.2% instructions, **+1.5% cycles**. of its own cycles: -0.2% instructions, **+1.5% cycles**. That is not in
tension with the inlining finding above: forcing the body it has now into
every caller loses, and shrinking the body until the inliner takes it of
its own accord wins. Shrink it; do not annotate it.
**`4febabf` makes `Fixed` wrap rather than saturate** (Bryan, 2026-09-16: a **`4febabf` makes `Fixed` wrap rather than saturate** (Bryan, 2026-09-16: a
coordinate past the range is not going to draw reasonably anyway, so wrap coordinate past the range is not going to draw reasonably anyway, so wrap
@@ -541,6 +625,16 @@ same state. `IRIS_GENERATED_SEED`, `IRIS_GENERATED_SEEDS`, and
tree; use it to turn a seed into a readable regression rather than leaving the tree; use it to turn a seed into a readable regression rather than leaving the
seed as the only record. seed as the only record.
**The float head is checked out at `/home/bob/repos/iris-float-cmp`**, at
`5ed9e87` with `394d514`'s `Edits::fixed_branches` cherry-applied to
`src/random.rs` and `tests/layout_diagnostics.rs` but not committed, since
the flag is the only way to measure the two on one tree. Build it and
`iris-pr18` with `--test layout_diagnostics` and run the binaries directly
rather than through cargo. A comparison is only worth reading when the
`--features layout-diagnostics` counters match on both sides; check
`primitive writes`, `widget draws`, `reuse remapped` and `placed by
redrawing` before any timing.
The headless reference set must be run one process at a time because the rig The headless reference set must be run one process at a time because the rig
reuses one compositor. Comparison worktrees need separate target directories. reuses one compositor. Comparison worktrees need separate target directories.
Useful commands: Useful commands: