Skip to content

Commit 063f698

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`). - `resolve_request_uri` reuses that rendering to join only the resolved base on every attempt (never re-rendering), trusting the cache only for built requests. - `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 | 4877 | 305 ns | 193 ns | | router_resolve prefixed | 6773 | 6133 | 308 ns | 261 ns | Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 10499452-0720-40ab-9e9f-506e1fd3ccaf
1 parent 6ed6999 commit 063f698

15 files changed

Lines changed: 1076 additions & 19 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: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -371,19 +371,21 @@ impl<R> HttpRequestBuilder<'_, R> {
371371
.uri
372372
.ok_or_else(|| HttpError::validation_with_label("URI is required when building the request", LABEL_URI_MISSING))??;
373373

374-
// Attach both a `RequestUris` carrying the caller's templated target
375-
// and its `PathAndQuery`:
376-
// - `RequestUris::original` preserves the unrouted target so
377-
// `Router::resolve_request_uri` can re-route from it on every retry.
378-
// - `PathAndQuery` backs `RequestExt::path_and_query` and
379-
// `ExtensionsExt::uri_template_label`.
374+
// Attach a `RequestUris` (its `original` lets `Router::resolve_request_uri` re-route
375+
// on every retry) and the `PathAndQuery` (backing `RequestExt::path_and_query` and
376+
// `ExtensionsExt::uri_template_label`). The templated path is rendered once here and
377+
// cached as a `RenderedPath` so routing can reuse it without re-rendering the template.
380378
let path = uri.to_path_and_query();
381-
let http_uri = http::Uri::try_from(uri.clone())?;
379+
let rendered = path.as_ref().map(http::uri::PathAndQuery::try_from).transpose()?;
380+
let http_uri = uri.to_http_uri(rendered.as_ref())?;
382381
let mut request = self.builder.uri(http_uri).body(body)?;
383382
request.extensions_mut().insert(crate::routing::RequestUris::new(uri));
384383
if let Some(path) = path {
385384
request.extensions_mut().insert(path);
386385
}
386+
if let Some(rendered) = rendered {
387+
request.extensions_mut().insert(crate::routing::RenderedPath(rendered));
388+
}
387389

388390
Ok(request)
389391
}

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;

0 commit comments

Comments
 (0)