perf: cut per-render handler allocations - #511
Merged
Mohamed Mansour (mohamedmansour) merged 2 commits intoSep 3, 2026
Merged
Conversation
Reduce steady-state heap pressure in the render hot path without
changing a single output byte.
Fragment-graph walks (reachability, inventory, chain, nested params)
queued one `QueuedFragment { id: String, route_base: String }` per graph
edge and keyed their visited set on `(String, String)`, so every edge
cost two heap allocations plus two more to record the visit. These walks
run per request - `collect_reachable_component_order_for_request` fires
at `body_end` on every full render with a hydration plugin. Queue entries
now borrow the protocol's fragment name and index route bases through a
small interning arena, making `QueuedFragment` `Copy` and the traversal
allocation-free.
Interpolating a non-string signal called `Value::to_string()` and then
ran the result through `encode_safe`. Numbers, booleans, and null can
never contain HTML-significant bytes, so they now render through a
fixed-capacity stack buffer and skip escaping entirely. Overflow is
reported rather than truncated, so the allocating fallback still covers
any value the buffer cannot hold.
Also drop the residual per-route clones: `route_base` is saved with
`mem::replace` instead of a deep `Cow` clone, `process_component` and
`emit_css_module` take `&str` so generated route hosts no longer build a
throwaway `WebUIFragmentComponent`, and nested route params are merged
entry-wise instead of cloning a whole `HashMap` to move out of.
| Path | Before | After | Change |
|---|---:|---:|---:|
| string allocs/run | 156 | 141 | -9.6% |
| streaming allocs/run | 170 | 155 | -8.8% |
| streaming POOLED allocs/run | 163 | 148 | -9.2% |
| streaming bytes/run | 43.7 KiB | 43.6 KiB | -0.2% |
| POOLED wall us/run @1000 | 15.46 | 13.74 | -11.1% |
| process RSS @1000 | 14.12 MiB | 12.83 MiB | -9.1% |
| Output size | 24152 B | 24152 B | unchanged |
Savings scale with route and component-graph size, so apps with deeper
route trees than the contact-book fixture gain proportionally more.
Validation: `cargo xtask check`, `cargo xtask bench streaming-resource`.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2edb7e98-5706-46bc-b191-51620cd290e5
Mohamed Mansour (mohamedmansour)
requested review from
007sunny
and
a lite review from Copilot
September 2, 2026 18:59
Copilot started reviewing on behalf of
Mohamed Mansour (mohamedmansour)
September 2, 2026 18:59
View session
Mohamed Mansour (mohamedmansour)
removed the request for review
from 007sunny
September 2, 2026 19:02
Contributor
There was a problem hiding this comment.
🔵 Needs a closer look
It changes core routing/rendering hot paths and threads new lifetimes through traversal logic, so it warrants final human review despite added tests and benchmark validation.
Pull request overview
This PR continues the allocation-reduction work in webui-handler by eliminating several per-request heap allocations in fragment-graph walks and scalar JSON rendering on the hot render path.
Changes:
- Reworked fragment-graph traversal to queue fragments using borrowed IDs and route-base interning (
RouteBaseArena), reducing per-edgeStringallocations. - Added stack-based JSON scalar formatting (
ScalarBuffer) to avoidValue::to_string()allocations and unnecessary escaping for numbers/bools/null. - Reduced route/render clones by switching some APIs to take
&strand usingmem::replace/entry-wise merges instead of deep clones.
File summaries
| File | Description |
|---|---|
| crates/webui-handler/src/streaming/vm.rs | Updates VM-to-handler calls to pass borrowed fragment IDs, avoiding temporary component wrappers. |
| crates/webui-handler/src/route_handler.rs | Introduces route-base interning + borrowed queued fragments across multiple graph walks, threading a protocol lifetime through traversal helpers. |
| crates/webui-handler/src/lib.rs | Adds ScalarBuffer + scalar fast-path rendering and refactors component/CSS emission APIs to accept &str, reducing clones/allocations in render paths. |
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
`RouteBaseArena::intern` took `&str` and stored misses via `to_string()`, but five of its six call sites pass a temporary produced by `route_matcher::compute_route_base`, which already returns an owned `String`. Discovering a new route level therefore allocated twice: once to build the path and once to copy it into the arena. Split the dedup scan into `find` and add `intern_owned`, which moves the caller's `String` in on a miss. The borrowed `intern` stays for the one site that genuinely holds a `&str`. Allocation counts and output bytes are unchanged on the contact-book fixture (141 / 155 / 148 allocs per run, 24152 B at scale 1000) because its route graph is shallow enough that no nested base is ever interned. The saving applies per newly discovered route level, so it shows up on deeper route trees. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2edb7e98-5706-46bc-b191-51620cd290e5
Mohamed Mansour (mohamedmansour)
requested review from
Bang Lee (Qusic),
Akrosh Gandhi (akroshg),
Jane Chu (janechu) and
mcritzjam
September 2, 2026 21:26
Bang Lee (Qusic)
approved these changes
Sep 3, 2026
Mohamed Mansour (mohamedmansour)
deleted the
mohamedmansour-reduce-handler-render-allocations
branch
September 3, 2026 05:58
Mohamed Mansour (mohamedmansour)
added a commit
that referenced
this pull request
Sep 3, 2026
Matched route descent deep-cloned the entire remaining `WebUiFragmentRoute` subtree. Because each route recursively owns strings, child vectors, cache tags, and invalidation data, the cost grows with route depth and sibling count on every request. This change keeps protocol-owned route levels borrowed during ordinary rendering and materializes them only when a streaming continuation must outlive that borrow. ## Approach - Represent the active route level as `Cow<'protocol, [WebUiFragmentRoute]>`. - Borrow child levels directly while descending protocol-owned routes. - Keep parked `SessionCore` and `ContinuationVm` route levels owned across host calls. - Move children out of an already-owned route with `mem::take` instead of recursively cloning them. - Preserve the existing second-`<outlet />` behavior exactly; its latent bug remains isolated in #515. The scope maps and name sets deliberately remain `String`-keyed, and the scope-map pool remains live across suspension steps. An earlier broad `Cow<str>` prototype improved one-step rendering but a real three-boundary session exposed a deterministic regression: 243 to 249 allocations and 25,081 to 29,539 allocated bytes per run. That design was rejected rather than accepting a streaming trade-off. ## Exact allocation results Baseline is the current stacked base, #511 at `e908d327`. Counts come from the benchmark counting allocator and were deterministic across repeated runs. `streaming_resource_bench`, scale 1000: | Path | Before allocs / bytes | After allocs / bytes | Output | |---|---:|---:|---:| | string | 141 / 31,237 | 127 / 29,488 | 24,152 B | | streaming one-step | 155 / 44,637 | 141 / 42,888 | 24,152 B | | streaming one-step POOLED | 148 / 8,893 | 134 / 7,144 | 24,152 B | The same 14-allocation and 1,749-byte reduction holds at scales 10 and 100. Output remains byte-identical at all scales: 24,114 / 24,134 / 24,152 B. Nested routes: | Request path | Before allocs / bytes | After allocs / bytes | Output | |---|---:|---:|---:| | `/` | 58 / 8,418 | 49 / 7,662 | 3,366 B | | `/sections/a` | 86 / 9,944 | 77 / 9,188 | 3,902 B | | `/sections/a/topics/b` | 107 / 11,388 | 98 / 10,632 | 4,455 B | | `/sections/a/topics/b/lessons/c` | 138 / 18,121 | 129 / 17,365 | 4,740 B | Each route output length and checksum is identical before and after. ## Suspension regression checks The existing benchmark's streaming rows do not encounter a runtime boundary, so they cannot validate state parked across host calls. #517 tracks adding a durable suspending case. This PR was additionally measured with two real `StreamingSession` probes: | Session path | Before allocs / bytes | After allocs / bytes | Output | |---|---:|---:|---:| | Three boundaries with populated scopes | 243 / 25,081 | 243 / 25,081 | 4,940 B | | VM-owned nested routes delegated to ordinary outlets | 233 / 24,687 | 233 / 24,687 | 2,144 B | Both paths exactly match the base for allocations, allocated bytes, and output. The nested-route probe specifically covers the borrowed-to-owned transition and caught an intermediate recursive clone before the final move-based implementation restored exact parity. ## Wall time No speedup is claimed. Separately built release binaries were run in alternating order, n=9 per build and path, with warm-ups. Every distribution overlaps, so the defensible result is **no measurable wall-time regression**. | Path, wall us/run | Before median [range] | After median [range] | |---|---:|---:| | resource string/1000 | 13.02 [12.87-13.76] | 12.85 [12.52-13.15] | | resource POOLED/1000 | 13.64 [13.56-13.87] | 13.49 [13.08-13.80] | | deepest ordinary route | 7.300 [7.237-7.391] | 7.179 [7.096-7.350] | | VM-owned nested streaming route | 11.013 [10.906-11.092] | 11.056 [10.990-11.623] | RSS is intentionally omitted from the comparison because #516 demonstrates that the current column is multimodal and dominated by fixture construction. Exact allocator counts are unaffected by that issue. ## Validation - `cargo test -p microsoft-webui-handler`: 457 unit and 36 integration tests passed. - `cargo xtask check`: full workspace gate passed. - Added tests for borrowed pointer identity, move-based owned descent, owned session parking, scope-pool retention across suspension, and the `ContinuationVm` to ordinary-render nested outlet path. - Independent performance review found no remaining correctness, lifetime, or allocation regression. No public API or behavioral contract changed, so no `DESIGN.md` or developer-doc update is required. --- Stacked on #511 and targeting `mohamedmansour-reduce-handler-render-allocations`. Review #511 first. --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2edb7e98-5706-46bc-b191-51620cd290e5 Copilot-Session: 07ffb414-b15a-4409-a020-b2e406287de0
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Server memory is not cheap, and the handler's render path still paid a steady stream of avoidable heap allocations on every request. Following up on #510, this pass targets the remaining per-request allocation sources in
webui-handlerwithout giving up any throughput.The largest one was hiding in the fragment-graph walks. Every walk (reachability, inventory, chain, nested params) queued a
QueuedFragment { id: String, route_base: String }per graph edge and keyed its visited set on(String, String), so traversing a component graph cost roughly fourStringallocations per edge. These are not startup-only paths:collect_reachable_component_order_for_requestfires at thebody_endboundary on every full render that has a hydration plugin attached, which is the standard configuration.What changed
Allocation-free graph traversal.
QueuedFragmentnow borrows the protocol's fragment name (&'protocol str) and stores its route base as au32index into a small interning arena, making the structCopy. Route nesting is shallow, so the arena's linear dedup scan beats hashing and stops allocating after each level is seen once. The visited key becomes(&'protocol str, Option<u32>), whereNonecollapses every base into one bucket for route-insensitive walks, matching the previous empty-string sentinel. This required threading a'protocollifetime throughcollect_inventoryable_components_from_stack,walk_route_children,collect_route_boundary_components,collect_inventory_and_chain, andwalk_children_for_inventory_and_chain.Stack-rendered JSON scalars. Interpolating a non-string signal called
Value::to_string()and then ran the result throughencode_safe. Numbers, booleans, and null can never contain HTML-significant bytes, so they now render through a fixed-capacityScalarBufferand skip escaping entirely. Applied towrite_signal_value, dynamic attribute emission, and template attribute assembly.Residual per-route clones.
route_baseis saved withmem::replaceinstead of a deepCowclone (insideis_matched,best_routeis alwaysSome, so the slot is unconditionally overwritten).process_componentandemit_css_moduletake&str, so generated route hosts no longer build a throwawayWebUIFragmentComponentjust to pass a name. Nested route params merge entry-wise instead of cloning an entireHashMaponly to move its entries out.Performance
Measured with
cargo xtask bench streaming-resource(exact counts from a customGlobalAlloc), release build, 2000 iterations per row.Output bytes are byte-identical at all three scales, which is the correctness proof. Wall time improved alongside the allocation count, so nothing was traded away for the memory win.
Notes for reviewers
These numbers understate the real-world gain. The
streaming-resourcefixture renders the contact-book dashboard, whose output is ~24 KB whether the state carries 10, 100, or 1000 contacts, because the scaledcontactsarray is not rendered at/. That makes it a good zero-copy proof (state tree size does not affect render allocations) but it exercises a shallow route graph. Savings here scale with route depth and component-graph size, so apps with deeper route trees benefit proportionally more.ScalarBufferoverflow is reported, not truncated.write_strreturnsfmt::Errorrather than silently clipping, soformat_plain_json_scalardeclines and the caller falls back to the allocating path. The 48-byte capacity is comfortably above the 24-byte worst case for an f64 (serde_jsonis built withoutarbitrary_precision), so the fallback is unreachable in practice while staying correct if that ever changes. Addedscalar_buffer_testsasserting byte-identical output versusValue::to_string()acrossi64::MIN,i64::MAX,u64::MAX,f64extremes, andMIN_POSITIVE, plus coverage that strings/arrays/objects correctly decline so nothing escapes unescaped.Deliberately left out of scope.
context.route_childrenis still aVec<WebUiFragmentRoute>deep-cloned from the protocol per matched route, andrendered_components/ thelocal_varsandcomponent_attrskeys are stillStringdespite always originating from&'protocol str. All three want the same fix, but it requires threading a lifetime throughSessionCoreandContinuationVm, which are currently lifetime-free because they hold this data across streaming suspension. That is a real payoff but a much riskier refactor, so it belongs in its own PR rather than bundled here.Validation
cargo xtask check(all phases pass)cargo xtask bench streaming-resourceNo public API, protocol schema, FFI surface, or user-facing behavior changed, so
DESIGN.mdanddocs/need no updates. No new dependencies.