Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@ jobs:
run: cargo test --locked -j 2 --no-default-features --features rustls,http2,json,query,form,cookies,multipart,stream,blocking -- --test-threads=2
- name: Optional QUIC tracing
run: cargo check --locked -j 2 --features http3-qlog
- name: Compile cache benchmark
run: cargo check --locked -j 2 --no-default-features --features http3 --bench alt_svc_lookup
- name: Compile performance benchmarks
run: cargo check --locked -j 2 --no-default-features --features http3 --benches
- name: Documentation
env:
RUSTDOCFLAGS: -D warnings
Expand Down
5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,11 @@ name = "alt_svc_lookup"
harness = false
required-features = ["http3"]

[[bench]]
name = "h3_pool_miss"
harness = false
required-features = ["http3"]

[[example]]
name = "blocking"
path = "examples/blocking.rs"
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,19 @@ The benchmark emits JSON records for empty, single-origin, and populated caches,
including four-thread shared-cache cases. Timings are aggregate wall time per
lookup; they exclude cache population, thread creation, and HTTP network I/O.

Measure HTTP/3 connection preparation as the pool grows:

```sh
cargo bench --locked -j 2 --no-default-features --features http3 --bench h3_pool_miss -- --iterations 10000 --samples 9
```

The pool contains real loopback QUIC connections. Misses use a local resolver
that immediately returns an error, isolating preparation from network waits.
Enabled coalescing and existing-connection hits provide controls. JSON records
report wall time per `preconnect` call, excluding pool population and handshakes;
these are not HTTP request latency or throughput measurements.
Use `--case pooled_hit` (or another emitted case name) to measure one scenario.

The [local dependency crates](crates/README.md) are committed in this workspace.
Quinn, its protocol engine, and its UDP adapter are sibling workspace crates. See [UPSTREAM.md](UPSTREAM.md) for pinned
source revisions, licenses, and maintenance boundaries.
Expand Down
161 changes: 161 additions & 0 deletions benches/h3_pool_miss.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
//! Measure connection preparation with real pooled QUIC connections and local DNS.

#[path = "../tests/support/local_tls.rs"]
mod local_tls;

use std::hint::black_box;
use std::net::SocketAddr;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Instant;

use htrio::dns::{Addrs, Name, Resolve, Resolving};
use htrio::{Client, Url};
use local_tls::LocalServer;

struct Resolver {
address: SocketAddr,
failures: AtomicUsize,
}

impl Resolve for Resolver {
fn resolve(&self, name: Name) -> Resolving {
if name.as_str() == "missing.bench.invalid" {
self.failures.fetch_add(1, Ordering::Relaxed);
Box::pin(std::future::ready(Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"benchmark DNS miss",
)
.into())))
} else {
let addresses: Addrs = Box::new(std::iter::once(self.address));
Box::pin(std::future::ready(Ok(addresses)))
}
}
}

struct Case {
name: &'static str,
origins: usize,
coalescing: bool,
hit: bool,
}

impl Case {
async fn run(&self, server: &LocalServer, iterations: usize, samples: usize) {
let resolver = Arc::new(Resolver {
address: server.quic_addr,
failures: AtomicUsize::new(0),
});
let client = server
.client_builder()
.http3_prior_knowledge()
.http3_coalescing(self.coalescing)
.pool_idle_timeout(None)
.dns_resolver(resolver.clone())
.build()
.unwrap();
for index in 0..self.origins {
client
.preconnect(Url::parse(&format!("https://origin{index}.bench.invalid/")).unwrap())
.await
.unwrap();
}
assert_eq!(client.http3_pool_stats().available, self.origins);
let origin = Url::parse(if self.hit {
"https://origin0.bench.invalid/"
} else {
"https://missing.bench.invalid/"
})
.unwrap();
prepare(&client, &origin, self.hit, 1_000).await;
let failures = resolver.failures.load(Ordering::Relaxed);
let mut times = Vec::with_capacity(samples);
for _ in 0..samples {
let started = Instant::now();
prepare(&client, &origin, self.hit, iterations).await;
times.push(started.elapsed().as_secs_f64() * 1e9 / iterations as f64);
}
assert_eq!(client.http3_pool_stats().available, self.origins);
assert_eq!(client.http3_pool_stats().connecting, 0);
assert_eq!(
resolver.failures.load(Ordering::Relaxed) - failures,
if self.hit { 0 } else { iterations * samples }
);
times.sort_by(f64::total_cmp);
println!(
"{{\"case\":\"{}\",\"origins\":{},\"coalescing\":{},\"hit\":{},\"iterations\":{},\"samples\":{},\"median_ns_per_prepare\":{:.2},\"ns_per_prepare_samples\":{:?}}}",
self.name, self.origins, self.coalescing, self.hit, iterations,
samples, times[samples / 2], times
);
}
}

async fn prepare(client: &Client, origin: &Url, hit: bool, iterations: usize) {
for _ in 0..iterations {
let result = black_box(client.preconnect(black_box(origin.clone())).await);
assert_eq!(result.is_ok(), hit);
}
}

fn main() {
let mut iterations = 10_000;
let mut samples = 9;
let mut selected_case = None;
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--iterations" => {
iterations = args
.next()
.expect("missing iteration count")
.parse()
.unwrap()
}
"--samples" => samples = args.next().expect("missing sample count").parse().unwrap(),
"--case" => selected_case = Some(args.next().expect("missing case name")),
"--bench" => {}
_ => panic!("unknown argument: {arg}"),
}
}
assert!(iterations > 0 && samples > 0);
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
runtime.block_on(async {
let names: Vec<_> = (0..256)
.map(|index| format!("origin{index}.bench.invalid"))
.collect();
let names: Vec<_> = names.iter().map(String::as_str).collect();
let server = LocalServer::with_names(&names, |_, _| http::Response::default()).await;
let mut matched = false;
for (name, origins, coalescing, hit) in [
("empty_miss", 0, false, false),
("single_miss", 1, false, false),
("64_origins_miss", 64, false, false),
("256_origins_miss", 256, false, false),
("enabled_256_origins_miss", 256, true, false),
("pooled_hit", 256, false, true),
("enabled_pooled_hit", 256, true, true),
] {
if selected_case
.as_ref()
.is_some_and(|selected| selected != name)
{
continue;
}
matched = true;
Case {
name,
origins,
coalescing,
hit,
}
.run(&server, iterations, samples)
.await;
tokio::task::yield_now().await;
}
assert!(matched, "unknown benchmark case: {selected_case:?}");
});
}
6 changes: 5 additions & 1 deletion src/async_impl/h3_client/connect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,10 @@ impl H3Connector {
.cloned()
}

pub(super) fn coalescing_enabled(&self) -> bool {
self.client_config.coalescing
}

pub(crate) fn can_coalesce(
&self,
origin: &Uri,
Expand All @@ -242,7 +246,7 @@ impl H3Connector {
let connection = &info.connection;
if connection.is_handshaking()
|| info.uses_ech()
|| !self.client_config.coalescing
|| !self.coalescing_enabled()
|| self.tls.client_auth_cert_resolver.has_certs()
|| self
.service(origin, destination)
Expand Down
39 changes: 27 additions & 12 deletions src/async_impl/h3_client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,18 +113,8 @@ impl H3Client {
}
self.pool.remove(&key);
}
let may_coalesce = {
let health = self.health.lock().unwrap();
health.coalescing_rejected.len() < 256 && !health.coalescing_rejected.contains(&key)
};
if may_coalesce && !requires_ech {
for (previous, client) in self.pool.coalescing_candidates(&key) {
if self.connector.can_coalesce(origin, &key.2, &client.info) {
if let Some(client) = self.pool.alias(key.clone(), &previous) {
return Ok(client);
}
}
}
if let Some(client) = self.try_coalesce(origin, &key, requires_ech) {
return Ok(client);
}
let lock = match self.pool.connecting(&key) {
pool::Connecting::InProgress(waiter) => {
Expand Down Expand Up @@ -171,6 +161,31 @@ impl H3Client {
))
}

fn try_coalesce(
&self,
origin: &Uri,
key: &pool::Key,
requires_ech: bool,
) -> Option<PoolClient> {
if requires_ech || !self.connector.coalescing_enabled() {
return None;
}
let may_coalesce = {
let health = self.health.lock().unwrap();
health.coalescing_rejected.len() < 256 && !health.coalescing_rejected.contains(key)
};
if may_coalesce {
for (previous, client) in self.pool.coalescing_candidates(key) {
if self.connector.can_coalesce(origin, &key.2, &client.info) {
if let Some(client) = self.pool.alias(key.clone(), &previous) {
return Some(client);
}
}
}
}
None
}

pub(crate) async fn discover(
&self,
origin: &Uri,
Expand Down
14 changes: 8 additions & 6 deletions tests/alt_svc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -712,17 +712,19 @@ async fn tcp_connection_races_a_blackholed_quic_handshake() {

#[tokio::test]
async fn cross_origin_coalescing_is_opt_in_and_rechecks_certificate_authority() {
for enabled in [false, true] {
for setting in [None, Some(false), Some(true)] {
let enabled = setting.unwrap_or(false);
let server = LocalServer::with_names(&["origin.test", "second.test"], advertise).await;
let cache = Arc::new(AltSvcCache::new());
let client = server
let mut builder = server
.client_builder()
.resolve("second.test", server.addr)
.resolve("uncovered.test", server.addr)
.http3_coalescing(enabled)
.alt_svc_cache(cache.clone())
.build()
.unwrap();
.alt_svc_cache(cache.clone());
if let Some(enabled) = setting {
builder = builder.http3_coalescing(enabled);
}
let client = builder.build().unwrap();
let second = format!("https://second.test:{}/", server.addr.port());
for url in [server.url("/seed"), format!("{second}seed")] {
client.get(url).send().await.unwrap().bytes().await.unwrap();
Expand Down
Loading