Skip to content

perf: avoid cloning nested route trees - #513

Merged
Mohamed Mansour (mohamedmansour) merged 4 commits into
mainfrom
mohamedmansour-borrow-protocol-data-in-context
Sep 3, 2026
Merged

perf: avoid cloning nested route trees#513
Mohamed Mansour (mohamedmansour) merged 4 commits into
mainfrom
mohamedmansour-borrow-protocol-data-in-context

Conversation

@mohamedmansour

@mohamedmansour Mohamed Mansour (mohamedmansour) commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

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 [Bug]: Second <outlet /> at the same route level silently renders nothing #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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The ownership transitions are coherent, preserve behavior, and have focused regression coverage.

Pull request overview

Avoids deep-cloning nested route subtrees during handler rendering while preserving streaming ownership requirements and existing behavior.

Changes:

  • Represents active route levels with Cow.
  • Borrows protocol routes and moves already-owned descendants.
  • Adds ownership, suspension, and nested-route regression tests.
File summaries
File Description
crates/webui-handler/src/lib.rs Implements borrowed and move-based route descent.
crates/webui-handler/src/streaming/vm.rs Preserves owned routes in continuation frames.
crates/webui-handler/src/streaming/session.rs Parks route levels as owned session state and tests retention.
crates/webui-handler/src/streaming/inventory.rs Updates test context initialization for Cow.
crates/webui-handler/tests/streaming.rs Tests nested owned-route descent through streaming rendering.
Review details
  • Files reviewed: 5/5 changed files
  • Comments generated: 0
  • Review effort level: Balanced

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Base automatically changed from mohamedmansour-reduce-handler-render-allocations to main September 3, 2026 05:58
The render context materialized data it only ever reads from the
protocol. `route_children` held `Vec<WebUiFragmentRoute>`, so descending
into a matched route deep-cloned the entire remaining route subtree -
two strings, the recursive children vector, and the cache-tag and
invalidation vectors, for every route on the matched chain of every
request. The scope maps, the rendered-component set, and the
document-style set were all `String`-keyed even though every key they
receive is a component prop name, a `<for>` moniker, or a style resource
name that the protocol already owns and that outlives the request.

Those fields now hold `Cow<'protocol, _>`, so a render binds names and
descends route levels without copying a byte. The owned variant is not
removed because a suspended streaming continuation outlives the borrow
it rendered against: `SessionCore` detaches what it parks between host
calls, which moves every entry and allocates only for names that render
newly bound - the same allocation the `String`-keyed map used to pay on
insert, just deferred to the suspension that actually needs it.

Keeping the owned variant is also what keeps the change safe. The
workspace denies `unsafe_code`, and `StreamingSession` owns its
`Arc<Protocol>`, so a context lifetime threaded into the session state
would have no caller to name it and would need a self-referential
struct. `Cow` lets the render path borrow while the session parks
something that stands on its own, so the session, the FFI, and the WASM
and Python bindings are untouched.

An outlet still consumes its route level rather than restoring it, which
preserves the previous behavior exactly.

| Path | Before | After | Change |
|---|---:|---:|---:|
| string allocs/run | 141 | 122 | -13.5% |
| streaming allocs/run | 155 | 136 | -12.3% |
| streaming POOLED allocs/run | 148 | 129 | -12.8% |
| string bytes/run | 30.5 KiB | 28.7 KiB | -5.9% |
| streaming POOLED bytes/run | 8.7 KiB | 6.9 KiB | -20.7% |
| string wall us/run @1000 | 13.60 | 13.22 | -2.8% |
| Contact book Render/1000 P50 | 1.52 ms | 1.48 ms | -2.6% |
| Output size | 24152 B | 24152 B | unchanged |

The contact-book fixture has a shallow route graph, so it understates
the route-tree win. On the three-level nested `examples/app/routes`
tree, allocations per render drop 58 -> 48 at `/` and 141 -> 128 at
`/sections/:id/topics/:id/lessons/:id`, with byte-identical output at
every depth.

Validation: `cargo xtask check`, `cargo xtask bench streaming-resource`,
`cargo bench -p microsoft-webui --bench contact_book_bench`.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2edb7e98-5706-46bc-b191-51620cd290e5
The comments read as if consuming the route level were intended design.
It is the previous behavior preserved on purpose, and #515 records why
it is a bug, so say that and link it rather than leaving a future reader
to conclude the emptying is load-bearing.

Comment-only; no behavior change.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2edb7e98-5706-46bc-b191-51620cd290e5
Keep route children borrowed during ordinary rendering while restoring owned name maps and sets across the handler. Preserve the scope-map pool between streaming suspension steps and materialize route levels before parking them.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 2edb7e98-5706-46bc-b191-51620cd290e5
Transfer a parked route's child level into ordinary outlet rendering instead of recursively cloning it. Keep protocol-backed levels borrowed and cover the ContinuationVm-to-ordinary nested outlet path.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 07ffb414-b15a-4409-a020-b2e406287de0
@mohamedmansour
Mohamed Mansour (mohamedmansour) force-pushed the mohamedmansour-borrow-protocol-data-in-context branch from 99ca3f3 to bc5f414 Compare September 3, 2026 05:58
@mohamedmansour
Mohamed Mansour (mohamedmansour) merged commit 47b2131 into main Sep 3, 2026
35 checks passed
@mohamedmansour
Mohamed Mansour (mohamedmansour) deleted the mohamedmansour-borrow-protocol-data-in-context branch September 3, 2026 23:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants