Skip to content

perf: reduce partial response allocations - #510

Merged
Mohamed Mansour (mohamedmansour) merged 2 commits into
mainfrom
mohamedmansour-optimize-rust-memory
Sep 2, 2026
Merged

perf: reduce partial response allocations#510
Mohamed Mansour (mohamedmansour) merged 2 commits into
mainfrom
mohamedmansour-optimize-rust-memory

Conversation

@mohamedmansour

Copy link
Copy Markdown
Contributor

Why

Rust partial-navigation requests already own a parsed serde_json::Value, but the server path serialized the complete state tree before the handler parsed it again and discarded unselected keys. Large sparsely projected states therefore paid substantial temporary memory and CPU costs per request.

What changed

  • Make Protocol::render_partial(Value, ...) the ownership-taking Rust path so selected values move directly into the response.
  • Keep Protocol::render_partial_json(&str, ...) for Node, FFI, WASM, and Python boundaries, where the streaming JSON visitor avoids materializing a full duplicate state tree.
  • Update Rust server, CLI, integration, and commerce call sites to use the owned path and move route parameters without cloning.
  • Preserve $webui exclusion for both full and keyed owned-state projection.
  • Add a large sparse-projection benchmark and repair the stale FAST v2 benchmark payload.
  • Synchronize the specification and Rust integration documentation.

Performance

Metric Before After Change
P50 latency 157.8 us 66.1 us -58.1%
Allocated bytes/request 266,283 B 4,739 B -98.2%
Allocations/request 66 55 -16.7%
Output size 231 B 231 B unchanged

The complete 12-target Rust benchmark suite completed successfully.

Validation

  • cargo xtask check
  • cargo xtask bench all

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

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.

🟡 Changes recommended

The commerce path still deep-clones state, benchmark setup needs safer batching, and the specification remains internally inconsistent.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Optimizes Rust partial navigation by projecting owned JSON state directly while retaining streaming JSON handling at host boundaries.

Changes:

  • Adds owned-state render_partial and serialized-state render_partial_json.
  • Migrates Rust and host-binding call sites.
  • Adds tests, benchmarks, and documentation updates.
File summaries
File Description
examples/app/commerce/server/src/frontend.rs Uses owned-state partial rendering.
docs/guide/integrations/rust.md Documents both Rust rendering paths.
docs/guide/concepts/routing.md Updates partial-navigation API guidance.
DESIGN.md Specifies owned and serialized-state contracts.
crates/webui/tests/example_apps_hydration.rs Migrates integration coverage.
crates/webui/src/server.rs Removes state serialization and parameter cloning.
crates/webui/README.md Updates API examples.
crates/webui/benches/server_request_bench.rs Adds sparse-state benchmark coverage.
crates/webui/benches/README.md Documents the benchmark.
crates/webui-wasm/src/handler.rs Retains serialized WASM boundary.
crates/webui-python/src/lib.rs Retains serialized Python boundary.
crates/webui-node/src/lib.rs Retains serialized Node boundary.
crates/webui-handler/src/route_handler.rs Implements owned-state projection and tests.
crates/webui-handler/benches/handler_bench.rs Repairs FAST v2 payload.
crates/webui-handler/benches/bootstrap_state_bench.rs Migrates raw-state benchmarks.
crates/webui-ffi/src/lib.rs Retains serialized FFI boundary.
crates/webui-cli/src/commands/serve.rs Moves parsed CLI state directly.
BENCHMARKS.md Describes sparse projection coverage.
Review details
  • Files reviewed: 18/18 changed files
  • Comments generated: 4
  • Review effort level: Balanced

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

Comment thread crates/webui-handler/benches/handler_bench.rs Outdated
Comment thread crates/webui/benches/server_request_bench.rs Outdated
Comment thread examples/app/commerce/server/src/frontend.rs
Comment thread DESIGN.md
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@mohamedmansour
Mohamed Mansour (mohamedmansour) merged commit 78b7617 into main Sep 2, 2026
35 checks passed
@mohamedmansour
Mohamed Mansour (mohamedmansour) deleted the mohamedmansour-optimize-rust-memory branch September 2, 2026 17:47
Mohamed Mansour (mohamedmansour) added a commit that referenced this pull request Sep 3, 2026
## 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-handler` without 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 four `String` allocations per edge. These are not startup-only
paths: `collect_reachable_component_order_for_request` fires at the
`body_end` boundary on every full render that has a hydration plugin
attached, which is the standard configuration.

## What changed

**Allocation-free graph traversal.** `QueuedFragment` now borrows the
protocol's fragment name (`&'protocol str`) and stores its route base as
a `u32` index into a small interning arena, making the struct `Copy`.
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>)`, where `None` collapses every
base into one bucket for route-insensitive walks, matching the previous
empty-string sentinel. This required threading a `'protocol` lifetime
through `collect_inventoryable_components_from_stack`,
`walk_route_children`, `collect_route_boundary_components`,
`collect_inventory_and_chain`, and
`walk_children_for_inventory_and_chain`.

**Stack-rendered JSON scalars.** 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
`ScalarBuffer` and skip escaping entirely. Applied to
`write_signal_value`, dynamic attribute emission, and template attribute
assembly.

**Residual per-route clones.** `route_base` is saved with `mem::replace`
instead of a deep `Cow` clone (inside `is_matched`, `best_route` is
always `Some`, so the slot is unconditionally overwritten).
`process_component` and `emit_css_module` take `&str`, so generated
route hosts no longer build a throwaway `WebUIFragmentComponent` just to
pass a name. Nested route params merge entry-wise instead of cloning an
entire `HashMap` only to move its entries out.

## Performance

Measured with `cargo xtask bench streaming-resource` (exact counts from
a custom `GlobalAlloc`), release build, 2000 iterations per row.

| Metric | 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 (median, n=5) | 13.92 | 13.65 | -1.9%, within
noise |
| Output size | 24152 B | 24152 B | unchanged |

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.

> **Correction.** An earlier revision of this description listed
`process RSS @1000 | 14.12 MiB | 12.83 MiB | -9.1%` as a headline
metric. **That row was sampling noise and has been removed.** It was a
single sample per build, and the benchmark's `process RSS` column turns
out to be multimodal. Re-running the *identical* binary 8 times gives
12.66 / 12.73 / 12.94 / 13.20 / 13.70 / 13.97 / 13.98 / 14.02 MiB - a
1.36 MiB spread, which is wider than the 1.29 MiB "improvement"
originally claimed. The column is dominated by where the allocator
places the ~6.8 MiB scale-1000 fixture state, which is built before any
render runs, so it is largely blind to the render path regardless.
`allocs/run` and `bytes/run` are exact counts from a custom
`GlobalAlloc` and are the numbers to judge this PR on. Tracked in #516.

> **Second correction, same cause.** The wall-time row originally read
`15.46 -> 13.74 us, -11.1%`, also a single sample per build. A proper
A/B - baseline built from `78b7617f` in a separate worktree, the two
binaries run alternately five times each - gives medians of 13.92 vs
13.65 us (POOLED/1000) and 13.28 vs 13.05 us (string/1000), about
**-1.9% and -1.7%, with the two ranges overlapping**. The original
`15.46` baseline was a cold first-run outlier. The honest statement is
that this PR shows **no measurable wall-time regression and possibly a
small improvement that n=5 cannot separate from noise** - not the
double-digit speedup first claimed.
>
> The same A/B re-confirms the allocation counts are exact and
deterministic: **156 and 163 in all five baseline runs, 141 and 148 in
all five patched runs, zero variance.** That is the result this PR rests
on.

## Notes for reviewers

**These numbers understate the real-world gain.** The
`streaming-resource` fixture renders the contact-book dashboard, whose
output is ~24 KB whether the state carries 10, 100, or 1000 contacts,
because the scaled `contacts` array 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.

**`ScalarBuffer` overflow is reported, not truncated.** `write_str`
returns `fmt::Error` rather than silently clipping, so
`format_plain_json_scalar` declines 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_json` is built without
`arbitrary_precision`), so the fallback is unreachable in practice while
staying correct if that ever changes. Added `scalar_buffer_tests`
asserting byte-identical output versus `Value::to_string()` across
`i64::MIN`, `i64::MAX`, `u64::MAX`, `f64` extremes, and `MIN_POSITIVE`,
plus coverage that strings/arrays/objects correctly decline so nothing
escapes unescaped.

**Deliberately left out of scope.** `context.route_children` is still a
`Vec<WebUiFragmentRoute>` deep-cloned from the protocol per matched
route, and `rendered_components` / the `local_vars` and
`component_attrs` keys are still `String` despite always originating
from `&'protocol str`. All three want the same fix, but it requires
threading a lifetime through `SessionCore` and `ContinuationVm`, 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-resource`
- 489 handler tests pass, including the 3 new scalar-rendering tests

No public API, protocol schema, FFI surface, or user-facing behavior
changed, so `DESIGN.md` and `docs/` need no updates. No new
dependencies.

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2edb7e98-5706-46bc-b191-51620cd290e5
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