Skip to content

Commit c23a2f1

Browse files
Martin TailleferCopilot
andcommitted
perf(templated_uri): reuse the rendered path across build and routing
Eliminate the redundant per-attempt template render and validation scan on the per-request URI construction hot path. The templated path was rendered twice - once in `HttpRequestBuilder::build` and again in `Router::resolve_request_uri` (which only swaps the base, never the path) - and every base join re-parsed and re-validated the whole path. - `build` now renders the path once, caches it in a `RenderedPath` extension, and assembles the initial `http::Uri` by reuse (new `Uri::to_http_uri_with_rendered`). - `resolve_request_uri` reuses that rendering to join only the resolved base on every attempt (never re-rendering), and clones the typed `Uri` once instead of twice. - `BasePath::join_path_and_query` returns the already-validated path directly when the base path is root (`/`), skipping the allocation and re-validation scan. Output-preserving (no `unsafe`), verified with differential tests. Benchmarks added here; the isolated `hot_path_cg` and `escaped_string` benches are unchanged baselines. | Benchmark (per attempt) | Cycles before | Cycles after | Time before | Time after | |---------------------------|--------------:|-------------:|------------:|-----------:| | routing_rerender short | 4126 | 1952 | 182 ns | 65 ns | | routing_rerender heavy | 7208 | 2173 | 352 ns | 65 ns | | router_resolve root base | 6322 | 4403 | 305 ns | 163 ns | | router_resolve prefixed | 6773 | 5689 | 308 ns | 220 ns | Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 10499452-0720-40ab-9e9f-506e1fd3ccaf
1 parent 6ed6999 commit c23a2f1

15 files changed

Lines changed: 1078 additions & 18 deletions

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/http_extensions/Cargo.toml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,10 @@ tick = { path = "../tick", features = ["test-util", "tokio"] }
9494
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net"] }
9595
uuid = { workspace = true }
9696

97+
# Callgrind instruction-count benchmarks (Valgrind is Linux-only).
98+
[target.'cfg(target_os = "linux")'.dev-dependencies]
99+
gungraun = { workspace = true, features = ["default"] }
100+
97101
[lints]
98102
workspace = true
99103

@@ -110,3 +114,13 @@ required-features = ["test-util", "json"]
110114
harness = false
111115
name = "http_response_builder"
112116
required-features = ["test-util", "json"]
117+
118+
[[bench]]
119+
harness = false
120+
name = "router_resolve"
121+
required-features = ["test-util"]
122+
123+
[[bench]]
124+
harness = false
125+
name = "router_resolve_cg"
126+
required-features = ["test-util"]
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
//! Wall-clock benchmarks for [`Router::resolve_request_uri`], the per-request routing step.
5+
//!
6+
//! For every outgoing request an HTTP client resolves the target URI against the configured
7+
//! endpoint: it picks the [`BaseUri`] and materializes the request's `http::Uri`. This is run
8+
//! once per request, and again per attempt under retry/hedging. The benchmark exercises it on
9+
//! a request built through [`HttpRequestBuilder`] (so the build-time path rendering is cached
10+
//! and reused) for a fixed base endpoint.
11+
//!
12+
//! Paired with `router_resolve_cg.rs`, which measures the same operation under Callgrind.
13+
//! Instruction counts are the authoritative signal here; wall-clock cannot resolve the small
14+
//! per-attempt differences this path involves.
15+
16+
#![allow(
17+
clippy::missing_panics_doc,
18+
clippy::unwrap_used,
19+
missing_docs,
20+
reason = "improves readability in benchmarks"
21+
)]
22+
23+
use std::hint::black_box;
24+
25+
use criterion::{Criterion, criterion_group, criterion_main};
26+
use http_extensions::routing::{Router, RouterContext};
27+
use http_extensions::{HttpRequest, HttpRequestBuilder};
28+
use templated_uri::BaseUri;
29+
30+
fn built_request(path: &'static str) -> HttpRequest {
31+
HttpRequestBuilder::new_fake().get(path).build().unwrap()
32+
}
33+
34+
fn entry(c: &mut Criterion) {
35+
let mut group = c.benchmark_group("router_resolve");
36+
37+
// A fixed endpoint with a root base path (the common case): the reused rendered path is
38+
// returned directly without a re-validation scan.
39+
let router = Router::fixed(BaseUri::from_static("https://api.example.com"));
40+
let mut request = built_request("/users/42/posts/hello-world?active=true");
41+
42+
// `resolve_request_uri` re-routes idempotently from the request's preserved original
43+
// target, so repeated calls do identical work - exactly the per-attempt cost.
44+
group.bench_function("fixed_root_base", |b| {
45+
b.iter(|| {
46+
black_box(&router)
47+
.resolve_request_uri(RouterContext::new(), black_box(&mut request))
48+
.unwrap();
49+
});
50+
});
51+
52+
// A fixed endpoint with a non-root base path: the join must concatenate and re-validate.
53+
let router_prefixed = Router::fixed(BaseUri::from_static("https://api.example.com/v1/"));
54+
let mut request_prefixed = built_request("/users/42/posts/hello-world?active=true");
55+
56+
group.bench_function("fixed_prefixed_base", |b| {
57+
b.iter(|| {
58+
black_box(&router_prefixed)
59+
.resolve_request_uri(RouterContext::new(), black_box(&mut request_prefixed))
60+
.unwrap();
61+
});
62+
});
63+
64+
group.finish();
65+
}
66+
67+
criterion_group!(benches, entry);
68+
criterion_main!(benches);
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
//! Callgrind benchmarks for [`Router::resolve_request_uri`], the per-request routing step.
5+
//!
6+
//! For every outgoing request (and again per retry/hedge attempt) the router picks the
7+
//! [`BaseUri`] and materializes the request's `http::Uri`. This benchmark isolates that step
8+
//! on a request built through [`HttpRequestBuilder`] (so the build-time path rendering is
9+
//! cached and reused) for both a root and a prefixed base path.
10+
//!
11+
//! Paired with `router_resolve.rs`, which covers the same operation under wall-clock
12+
//! (Criterion) measurement.
13+
14+
#![allow(missing_docs, reason = "no need for API documentation on benchmark code")]
15+
#![allow(
16+
clippy::needless_pass_by_value,
17+
clippy::unwrap_used,
18+
reason = "gungraun benchmark inputs are passed and returned by value by the framework"
19+
)]
20+
#![cfg_attr(
21+
target_os = "linux",
22+
expect(
23+
clippy::exit,
24+
clippy::missing_docs_in_private_items,
25+
unused_qualifications,
26+
reason = "Triggered by Gungraun macro expansion. Upstream tracking issues are pending."
27+
)
28+
)]
29+
30+
#[cfg(not(target_os = "linux"))]
31+
fn main() {
32+
// Gungraun requires Valgrind, which is Linux-only.
33+
}
34+
35+
#[cfg(target_os = "linux")]
36+
mod linux {
37+
use std::hint::black_box;
38+
39+
use gungraun::{library_benchmark, library_benchmark_group};
40+
use http_extensions::routing::{Router, RouterContext};
41+
use http_extensions::{HttpRequest, HttpRequestBuilder};
42+
use templated_uri::BaseUri;
43+
44+
fn setup(base: &'static str) -> (Router, HttpRequest) {
45+
let router = Router::fixed(BaseUri::from_static(base));
46+
let request = HttpRequestBuilder::new_fake()
47+
.get("/users/42/posts/hello-world?active=true")
48+
.build()
49+
.unwrap();
50+
(router, request)
51+
}
52+
53+
// Fixed endpoint, root base path (common case): the reused rendered path is returned
54+
// without a re-validation scan.
55+
#[library_benchmark]
56+
#[bench::root(setup("https://api.example.com"))]
57+
fn resolve_root(input: (Router, HttpRequest)) -> HttpRequest {
58+
let (router, mut request) = input;
59+
router.resolve_request_uri(RouterContext::new(), black_box(&mut request)).unwrap();
60+
request
61+
}
62+
63+
// Fixed endpoint, non-root base path: the join concatenates and re-validates.
64+
#[library_benchmark]
65+
#[bench::prefixed(setup("https://api.example.com/v1/"))]
66+
fn resolve_prefixed(input: (Router, HttpRequest)) -> HttpRequest {
67+
let (router, mut request) = input;
68+
router.resolve_request_uri(RouterContext::new(), black_box(&mut request)).unwrap();
69+
request
70+
}
71+
72+
library_benchmark_group!(
73+
name = router_resolve;
74+
benchmarks = resolve_root, resolve_prefixed
75+
);
76+
}
77+
78+
#[cfg(target_os = "linux")]
79+
use gungraun::{Callgrind, LibraryBenchmarkConfig};
80+
#[cfg(target_os = "linux")]
81+
pub use linux::router_resolve;
82+
83+
#[cfg(target_os = "linux")]
84+
gungraun::main!(
85+
config = LibraryBenchmarkConfig::default()
86+
.tool(Callgrind::with_args(["--branch-sim=yes"]));
87+
library_benchmark_groups = router_resolve
88+
);

crates/http_extensions/src/http_request_builder.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -377,13 +377,22 @@ impl<R> HttpRequestBuilder<'_, R> {
377377
// `Router::resolve_request_uri` can re-route from it on every retry.
378378
// - `PathAndQuery` backs `RequestExt::path_and_query` and
379379
// `ExtensionsExt::uri_template_label`.
380+
//
381+
// The templated path is rendered to a standalone `http::PathAndQuery` exactly once
382+
// here; that single rendering backs the request's initial `http::Uri` (assembled
383+
// without a second render) and is cached as a `RenderedPath` so the router can join it
384+
// onto the resolved base on every attempt without ever re-rendering the template.
380385
let path = uri.to_path_and_query();
381-
let http_uri = http::Uri::try_from(uri.clone())?;
386+
let rendered = path.as_ref().map(http::uri::PathAndQuery::try_from).transpose()?;
387+
let http_uri = uri.to_http_uri_with_rendered(rendered.as_ref())?;
382388
let mut request = self.builder.uri(http_uri).body(body)?;
383389
request.extensions_mut().insert(crate::routing::RequestUris::new(uri));
384390
if let Some(path) = path {
385391
request.extensions_mut().insert(path);
386392
}
393+
if let Some(rendered) = rendered {
394+
request.extensions_mut().insert(crate::routing::RenderedPath(rendered));
395+
}
387396

388397
Ok(request)
389398
}

crates/http_extensions/src/routing/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
//! [`Uri`]: templated_uri::Uri
5858
5959
mod router;
60+
pub(crate) use router::RenderedPath;
6061
pub use router::{BaseUriConflict, RequestUris, Router};
6162

6263
mod router_context;

crates/http_extensions/src/routing/router.rs

Lines changed: 107 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,16 @@ pub struct RequestUris {
8686
routed: Option<Uri>,
8787
}
8888

89+
/// Request extension caching the standalone rendering of the request's templated
90+
/// path-and-query, produced once by
91+
/// [`HttpRequestBuilder::build`][crate::HttpRequestBuilder::build].
92+
///
93+
/// Routing only ever swaps the [`BaseUri`], never the path, so
94+
/// [`Router::resolve_request_uri`] reuses this rendering to join the resolved base without
95+
/// re-rendering the template - on the first attempt and on every retry/hedge attempt.
96+
#[derive(Clone, Debug)]
97+
pub(crate) struct RenderedPath(pub(crate) http::uri::PathAndQuery);
98+
8999
impl RequestUris {
90100
/// Creates a [`RequestUris`] with the given templated [`Uri`] as the
91101
/// [`original`](Self::original) and no [`routed`](Self::routed) yet.
@@ -263,23 +273,50 @@ impl Router {
263273
/// - [`Router::resolve_uri`] fails (e.g., a [`BaseUriConflict::Fail`] conflict), or
264274
/// - the resolved [`Uri`] cannot be converted back to an [`http::Uri`].
265275
pub fn resolve_request_uri(&self, context: RouterContext, request: &mut HttpRequest) -> Result<(), HttpError> {
266-
// Always route from the caller-supplied target so repeated calls
267-
// are idempotent. Clone rather than take, so a failure below leaves
268-
// the request unchanged.
269-
let original: Uri = match request.extensions().get::<RequestUris>() {
270-
Some(uris) => uris.original().clone(),
271-
None => request.uri().clone().try_into()?,
276+
// Route from the caller-supplied target so repeated calls are idempotent. A built
277+
// request carries its original target in `RequestUris`, so route from a single clone of
278+
// it and let `resolve_uri` consume that clone. A hand-built request has no such
279+
// extension, so derive the original from the request's URI and keep a copy to seed a
280+
// fresh `RequestUris` after routing; that extra clone is confined to the rarer branch.
281+
let (original, fallback_original): (Uri, Option<Uri>) = if let Some(uris) = request.extensions().get::<RequestUris>() {
282+
(uris.original().clone(), None)
283+
} else {
284+
let original: Uri = request.uri().clone().try_into()?;
285+
(original.clone(), Some(original))
286+
};
287+
let resolved = self.resolve_uri(context.with_request(request), original)?;
288+
289+
// Routing only swaps the base, never the path, so reuse the path rendered once at
290+
// build time (cached as `RenderedPath`) and join just the resolved base onto it -
291+
// avoiding a second template render, including across retry/hedge attempts.
292+
//
293+
// Only trust `RenderedPath` when this is a built request (`RequestUris` was present,
294+
// i.e. `fallback_original` is `None`): then `resolved`'s path derives from the same
295+
// original the cache was rendered from, so they provably correspond. A hand-built
296+
// request routes from its own `http::Uri` and may carry an unrelated `RenderedPath`,
297+
// so it always full-materializes from `resolved` rather than trusting the cache.
298+
let http_uri = if fallback_original.is_none() {
299+
match request.extensions().get::<RenderedPath>() {
300+
Some(rendered) => resolved.to_http_uri_with_rendered(Some(&rendered.0))?,
301+
None => resolved.clone().try_into()?,
302+
}
303+
} else {
304+
resolved.clone().try_into()?
272305
};
273-
let resolved = self.resolve_uri(context.with_request(request), original.clone())?;
274-
let http_uri = resolved.clone().try_into()?;
275306

276-
// Commit: update the request's URI and record the resolved URI.
277-
// Only `routed` is mutated; `original` is preserved across attempts.
307+
// Commit: update the request's URI and record the resolved URI. Only `routed` is
308+
// mutated; `original` is preserved across attempts.
278309
*request.uri_mut() = http_uri;
279-
request
280-
.extensions_mut()
281-
.get_or_insert_with(|| RequestUris::new(original))
282-
.set_routed(resolved);
310+
if let Some(original) = fallback_original {
311+
// Hand-built request: seed a fresh `RequestUris` from the preserved original.
312+
let mut uris = RequestUris::new(original);
313+
uris.set_routed(resolved);
314+
request.extensions_mut().insert(uris);
315+
} else if let Some(uris) = request.extensions_mut().get_mut::<RequestUris>() {
316+
// Built request: the `RequestUris` extension already exists (it is why
317+
// `fallback_original` is `None`); just record the routed URI.
318+
uris.set_routed(resolved);
319+
}
283320

284321
Ok(())
285322
}
@@ -626,6 +663,28 @@ mod tests {
626663
);
627664
}
628665

666+
#[test]
667+
fn resolve_request_uri_ignores_stale_rendered_path_without_request_uris() {
668+
// A hand-built request (no `RequestUris`) that happens to carry an unrelated
669+
// `RenderedPath` must NOT trust that cache: routing derives the target from the
670+
// request's own `http::Uri`, so it must full-materialize from there, never splicing in
671+
// the stale rendering. Guards the correct-by-construction reuse condition.
672+
let router = Router::fixed(BaseUri::from_static("https://api.example.com"));
673+
let body = crate::HttpBodyBuilder::new_fake().empty();
674+
let mut request = http::Request::new(body);
675+
*request.uri_mut() = http::Uri::from_static("/v1/items");
676+
// Inject a stale rendering that does not correspond to the request's URI.
677+
request
678+
.extensions_mut()
679+
.insert(RenderedPath(http::uri::PathAndQuery::from_static("/stale/wrong/path")));
680+
assert!(request.extensions().get::<RequestUris>().is_none(), "precondition: no RequestUris");
681+
682+
router.resolve_request_uri(RouterContext::new(), &mut request).unwrap();
683+
684+
// The routed URI reflects the request's own path, not the stale cache.
685+
assert_eq!(request.uri().to_string(), "https://api.example.com/v1/items");
686+
}
687+
629688
#[test]
630689
fn resolve_request_uri_attaches_base_uri() {
631690
let router = Router::fixed(BaseUri::from_static("https://api.example.com"));
@@ -636,6 +695,40 @@ mod tests {
636695
assert_eq!(request.uri().to_string(), "https://api.example.com/v1/items");
637696
}
638697

698+
#[test]
699+
fn resolve_request_uri_reuses_rendered_path_matching_full_render() {
700+
// `resolve_request_uri` reuses the path rendered once at build time (the `RenderedPath`
701+
// extension) and joins only the resolved base. Its result must be byte-identical to the
702+
// pre-optimization behavior, which fully re-materialized the resolved `Uri`. This pins
703+
// the reuse path against the full-render reference across base-path joins, leading-slash
704+
// normalization, and query strings.
705+
let bases = ["https://api.example.com", "https://api.example.com/v1/", "http://h:8080/deep/base/"];
706+
let paths = ["/items", "/users/42?active=true", "/a/b/c?x=1&y=2", "/only?q=1"];
707+
708+
for base_str in bases {
709+
for path_str in paths {
710+
let router = Router::fixed(BaseUri::from_static(base_str));
711+
let mut request = crate::HttpRequestBuilder::new_fake().get(path_str).build().unwrap();
712+
713+
// Sanity: build attached a cached rendering that the reuse path will consume.
714+
assert!(
715+
request.extensions().get::<RenderedPath>().is_some(),
716+
"build should cache a RenderedPath for {path_str:?}"
717+
);
718+
719+
router.resolve_request_uri(RouterContext::new(), &mut request).unwrap();
720+
let reused = request.uri().clone();
721+
722+
// Reference: the old behavior - fully re-materialize the resolved `Uri`.
723+
let original: Uri = request.extensions().get::<RequestUris>().unwrap().original().clone();
724+
let resolved = router.resolve_uri(RouterContext::new(), original).unwrap();
725+
let full_render: http::Uri = resolved.try_into().unwrap();
726+
727+
assert_eq!(reused, full_render, "mismatch for base {base_str:?} + path {path_str:?}");
728+
}
729+
}
730+
}
731+
639732
#[test]
640733
fn resolve_request_uri_attaches_resolved_uri_extension() {
641734
// After `resolve_request_uri`, the `RequestUris` extension's `routed`

crates/templated_uri/Cargo.toml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,22 @@ harness = false
8888
name = "hot_path_cg"
8989
harness = false
9090

91+
[[bench]]
92+
name = "routing_rerender"
93+
harness = false
94+
95+
[[bench]]
96+
name = "routing_rerender_cg"
97+
harness = false
98+
99+
[[bench]]
100+
name = "escaped_string"
101+
harness = false
102+
103+
[[bench]]
104+
name = "escaped_string_cg"
105+
harness = false
106+
91107
[[example]]
92108
name = "classified_templating"
93109
path = "examples/classified_templating.rs"

0 commit comments

Comments
 (0)