diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 2d945a6..b54ebe0 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -8,7 +8,7 @@ log. Canonical Iris `main` is **`ca2b4b2`** (#17, the headless rig). **#18 `split/18-position-chain`** is open in `/home/bob/repos/iris-pr18`; its local -head is **`4f5e27c`**, sixty-three commits. Built-in alignment is complete there; +head is **`394d514`**, seventy commits. Built-in alignment is complete there; see "Built-in alignment" below for the retained-layout details. No PR reviews were present when checked on 2026-09-15. @@ -37,6 +37,17 @@ length is reported and any relative or `leftover` child makes it report `Holds` ranges propagate through final-box drawing, so a resize repositions them without redrawing when their own contracts permit it. +`a8898aa` gives a length with no share in it its own type again, which is the +separation canonical `main` had and this branch had collapsed: `Len` is pixels +plus a fraction of a box -- a position being the length from the box's start, +which is why a span is two of them -- and `LayoutLen` is a `Len` plus a claim +only a container dividing its room can answer. What was `UiScalar` is `Len`, +what was `Len` is `LayoutLen`, and `From for LayoutLen` is the one-way +step. `490918b` renames `Painter::ruled` to `has_exact_size`, over +`SizeRule::exact`: "is there a rule here" and "is my report moot" are the same +question only while `Exact` is the only rule, and `Min`/`Max`/`Clamp` are +queued. + `71c9c39` replaces the public `Painter::place` distinction with an opt-in widget property. `.region_node()` gives a widget one independently movable retained region; `Widgets::set_region_node` can change that choice at runtime @@ -78,7 +89,7 @@ rewrite: `core/src/ui/holds.rs`, the retained tests, and the generated cold-layout oracle pin those rules. Seeds 10 and 86 are now in the ordinary generated set. -## Verification at `4f5e27c` +## Verification at `394d514` - `cargo fmt --all --check` - `cargo build --workspace --all-features` @@ -92,7 +103,8 @@ pin those rules. Seeds 10 and 86 are now in the ordinary generated set. (159,024 widgets per case, largest tree 587) at `5ed9e87`, before fixed point; re-run that before quoting it again. - `tabs`, `view`, `minimal`, `text` and `random` render byte-identical at - 1920x1200 across the whole fixed-point sequence. + 1920x1200 across the whole fixed-point sequence, and across this session's + renames and arithmetic changes on top of it. - `tests/cases/drift.rs` passed its 20,000-move exactness check in release mode. - The seeded `random` example, live-resized from 1920x1200 to 1280x800, is @@ -127,69 +139,78 @@ choice and the incompatible visual effect of making `Full` the default. ### Performance -Measured 2026-09-16 on the release `layout_diagnostics` fixture, seed 1, -depth 8, 500 frames, as median milliseconds a frame. `5ed9e87` is the commit -before fixed point; `39e4ca2` is fixed point complete. +**The 3x this section used to report was a fixture artifact, withdrawn +2026-09-16.** `random.rs`'s `Branch` picks which of two subtrees to draw by +comparing a measured pixel length with a threshold, so the fixture's shape +moves with the thing being measured. Seed 1 at depth 8 draws 88 widgets and +writes 2,298 primitives a frame at `5ed9e87`, and 115 and 8,209 at `bd6de71` +-- three and a half times the work behind a number read as three and a half +times the cost. `394d514` gives the rig `Edits::fixed_branches`; the oracle +keeps measured branches, which is the whole point of them. -| phase | `5ed9e87` | `39e4ca2` | `4f5e27c` | -| --- | ---: | ---: | ---: | -| many | 0.179 ms | 0.544 | 0.274 | -| resize | 0.020 | 0.035 | 0.033 | -| scroll | 0.011 | 0.030 | 0.019 | -| repaint | 0.012 | 0.031 | 0.021 | +**Check the work counters before comparing two commits' times.** The rig +prints drawn widgets, widget draws and primitive writes. An undrawn +`leftover` child still moves them and no flag removes that, so the trees are +near rather than equal -- and `9d8415d` changed the generator itself, since +an X span's `OrthoSize::Full` became a size rule. -**Fixed point cost 3x, and two thirds of that was not the grid.** The -counters said eight more "placed by redrawing" a frame in `scroll`, all of -them "reuse: another layer": a retained drawing belongs to the layer it was -made on, and `Stack` measured the child that sizes it on its own layer before -drawing it again on the child layer. `97cc8b3` measures on the layer the -child ends up on, which puts the scroll phase's counters back exactly where -they were -- 4 widget draws, 12 draw requests. +Measured on the fixed-shape fixture, seed 1, depth 8, 500 frames of `many`: -What is left is per-operation cost, not more work: the `many` phase does 125 -widget draws against 121 before, and takes 1.5x as long. Three roundings and -divisions were taken out after measuring (`11c55bc`, `4f5e27c`, and halving -the divisions in `Holds::through`); `RegionRemap::apply_span` is still a -fifth of the phase, and the rest is spread thin. Measure with `perf stat -e -instructions:u` rather than the clock, which varies 2x here. +| | drawn widgets | primitive writes | instructions | cycles | +| --- | ---: | ---: | ---: | ---: | +| `5ed9e87`, before fixed point | 100 | 4,272 | 1,761M | ~699M | +| `4cbb242` | 97 | 3,951 | 2,093M | ~819M | -**Why the grid is slower than floats, from the disassembly at `4f5e27c`.** -`RegionRemap::apply_span` -- two scalars, every branch -- is 352 -instructions, of which two are 64-bit `idiv`, six `imul`, and about sixty -are jumps and `cmov`s over twenty-eight compares. The same arithmetic in -`f32` is a couple of dozen SSE instructions. The difference is in three -places, and none of them is the grid being a worse idea: +So the grid costs something like a fifth to a quarter, on a head that draws +slightly less. Normalised by primitive writes it is about 1.27x. -- **A float's rounding is free and a fixed-point one is code.** `Fixed::mul` - widens to `i64`, multiplies, adds a half step, shifts back, and saturates - into `i32` -- roughly eight instructions and a sign branch where `mulss` - is one instruction with the rounding in hardware. -- **Integer division is the slowest instruction on the core**, is not - pipelined, and has no vector form; `divss` is pipelined and vectorises. - `div_round` also needs the remainder and its sign. This is why removing - divisions is where the wins have been, and why the remaining ones are - worth structural effort rather than micro-optimisation. -- **Saturation costs what floats get from infinities**, and it breaks the - pairing. Adding four `i32` lanes with saturation is a dozen SSE ops - (`pcmpgtd`/`paddd`/`pandn`/`psrad`/`pxor`/`por`) against one `addps`; and - the widening multiply cannot stay in a vector register at all, since there - is no packed 64-bit `imul` here, so lanes that an `f32` `Vec2` did at once - serialise. +**Cycles are measurable on this machine and instructions alone are not +enough.** Three runs of one binary varied 0.23%. It is *wall time* that +varies 2x here, which is what the machine notes say; the counters are +steady. IPC is 2.58 before fixed point and 2.59 after, so the cost is +instruction count at unchanged throughput -- not stalls, not branch misses +(0.69%), not the division. -"Fixed point is faster" is a rule from machines without an FPU. On this one -the grid buys exactness, and exactness is what the warm-against-cold oracle -demands -- so the cost is the price of the property, not a defect to chase -back to 1.0x. +**What the cycle profile says the cost is**, against what the previous +session recorded from the disassembly. In `AxisRemap::apply_scalar` the +`i64` division is **0.00% of cycles** and the multiply 1.5%: the time is in +`saturating_add`, which is five instructions and has no `i32` vector form. +The division is rare because most moves are translations. -One thing tried and reverted, recorded so it is not tried again: short- -circuiting `apply_scalar` where the fraction is nought or one. Those are not -the common cases, and the comparisons cost 17% more than the divisions they -saved. +Recovered this session, each measured before and after on one commit with +byte-identical renders proving the layout unchanged -- on the old fixture, +689.6M cycles and 1,886M instructions down to 638.9M and 1,657M: + +- `cb1bba4` works a move out once per subtree rather than per scalar: -3.8% + instructions, -0.8% cycles. The gap between those two is the finding. +- `1940e85` translates a whole region at once, since that is what a + translation is: -4% instructions, -3.4% cycles. +- `d75a1e2` and `4cbb242` skip multiplies that cannot change anything -- + composing through a full box, and `lerp` where the ends are equal: + together -2.7% instructions, -1.8% cycles. + +Tried and rejected, with numbers, so they are not tried again: + +- A float reciprocal for the division: **+6% cycles**. The conversions cost + more than the `idiv` that is not on the hot path anyway. +- Branchless `shift_round`: **+6.7% cycles**. The sign of a product predicts + well, and the positive path is two instructions. +- Removing the per-child hash lookup in `remap_subtree` with `mem::take`: + **0.0%**. `FxHashMap` on a hot line is not the cost. +- Short-circuiting `apply_scalar` where the fraction is nought or one: +17%. + (From the previous session.) + +**The one lever left that is worth a decision:** `Fixed`'s `saturating_add` +and `saturating_sub`, measured at **8.1% of instructions and 2.7% of +cycles**. Wrapping instead is one instruction where saturating is five. That +trades away "a clamped coordinate keeps the ordering a wrapped one inverts", +which is a posture rather than a micro-optimisation, so it needs Bryan. +Everything else is spread thin. Earlier, against #18's own history: the retained rewrite took `many` from 25.17M instructions a frame at `691e3eb` to 6.14M at `29c7881`, and `resize` -from 16.08M to 8.39M. That fixture has since changed; do not compare across -it. +from 16.08M to 8.39M. That fixture has since changed twice; do not compare +across it. ## Retained-layout invariants @@ -200,9 +221,16 @@ it. -- `Painter::child_layer_at` addresses one -- or it pays two draws a frame forever and keeps whichever the second ask left. - A widget that clips its contents to its box reports its box: `Scroll` and - `Masked` both report `LEFTOVER`, and a `debug_assert` in `draw_at` holds - any widget that set a mask to it. Overflowing is otherwise ordinary and a - text too tall for its box says so. + `Masked` both report `LEFTOVER`, and a `debug_assert` holds any widget that + **set a mask this draw** to it. Overflowing is otherwise ordinary and a text + too tall for its box says so -- which is why the assertion is narrowed to + mask-setters rather than to every widget. + `Masked` is the only one that sets a mask; `Scroll` reports its box because + a drawing placed in a longer box is *scaled* by recomposition, not because + it masks. So a `LazySpan` is unaffected: it clips by not drawing, and may + report its whole content length, which is what the `Scroll` above it + measures. One that set its own mask would have to report its box and hand + the content length up another way. - A span is as long across itself as its longest child, unless a rule beside it says how long it is -- and then it does not read its children there at all, since the answer is not wanted and reading one is what makes its size @@ -261,9 +289,10 @@ it. where there is none. The box a parent hands back and the sum of what the children asked for are counts of the same step, so the boundary needs no margin and the validity interval is split exactly at it. -- `Scroll` reports its content's first measured size. Its drawing can survive - container-length changes only over the interval in which clamping and its - current offset do not change. +- `Scroll` reports `Size::LEFTOVER`; what it takes from the first box it asked + about is its *content's* length, which it clamps and holds by rather than + reports. Its drawing can survive container-length changes only over the + interval in which clamping and its current offset do not change. - **A move that keeps a box's length is a translation, and an offset is exact on the grid.** A box that also changed length has to re-express each part as a fraction of the new one, and that division and multiplication @@ -562,11 +591,9 @@ Queued from this work, in order: one offer per child. `min(report, cap)` is also not a `Len`: `Len` is a sum of the three parts, and the smaller of two of them is not. - So a cap takes pixels and a fraction and no share, which is `UiScalar`'s - shape (`core/src/orientation/pos.rs`) -- either that type or a `Len` - without `leftover`. Awaiting Bryan: whether to split the type, and whether - a `Max` narrows the box the child draws in or only what the parent reports - for it. + So a cap takes pixels and a fraction and no share, which is what `Len` is + since `a8898aa`. Still awaiting Bryan: whether a `Max` narrows the box the + child draws in, or only what the parent reports for it. Other queued work, in dependency order: