Skip to content

Commit d4f72fa

Browse files
feat(iroh-relay)!: use explicit key cache (#3053)
## Description This wires up an explicit key cache to replace the implicit one that was removed in #3051. ~~The default for a key cache is Disabled. A disabled key cache has a size of 1 pointer and otherwise zero performance overhead.~~ I have removed the Default instance for both KeyCache and DerpProtocol so you don't accidentally pass the default despite having a cache available. We use the lru crate for the cache for now. Please comment if it should be something else. Benchmarks have shown that conversion from a [u8;32] to a VerifyingKey is relatively cheap, so the purpose of the cache is solely to validate incoming public keys. We add a Borrow instance to PublicKey so we can use it as a cache key. Some performance measurements: ``` Benchmarking validate valid ed25519 key: 3.7674 µs Benchmarking validate invalid ed25519 key: 3.6637 µs Benchmarking validate valid iroh ed25519 key: 67.089 ns Benchmarking validate invalid iroh ed25519 key: 64.004 ns ``` So just from validating incoming keys, without cache you would be limited to ~250 000 msgs/s per thread. At a message size of 1KiB that would be 250MB/s, which is not great. With the cache deserialization can do 14 000 000 msgs/s, which means that this is no longer a bottleneck. ## Breaking Changes - RelayConfig has new public field key_cache_capacity ## Notes & open questions - Size of the cache - source_and_box has a PublicKey::try_from. Is that perf critical? ## Change checklist - [ ] Self-review. - [ ] Documentation updates following the [style guide](https://rust-lang.github.io/rfcs/1574-more-api-documentation-conventions.html#appendix-a-full-conventions-text), if relevant. - [ ] Tests if relevant. - [ ] All breaking changes documented. --------- Co-authored-by: dignifiedquire <me@dignifiedquire.com>
1 parent ac74c53 commit d4f72fa

18 files changed

+211
-47
lines changed

Cargo.lock

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

iroh-base/src/key.rs

+7
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
//! Cryptographic key handling for `iroh`.
22
33
use std::{
4+
borrow::Borrow,
45
cmp::{Ord, PartialOrd},
56
fmt::{Debug, Display},
67
hash::Hash,
@@ -21,6 +22,12 @@ use serde::{Deserialize, Serialize};
2122
#[repr(transparent)]
2223
pub struct PublicKey(CompressedEdwardsY);
2324

25+
impl Borrow<[u8; 32]> for PublicKey {
26+
fn borrow(&self) -> &[u8; 32] {
27+
self.as_bytes()
28+
}
29+
}
30+
2431
impl PartialOrd for PublicKey {
2532
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
2633
Some(self.cmp(other))

iroh-relay/Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ url = { version = "2.5", features = ["serde"] }
9696
webpki = { package = "rustls-webpki", version = "0.102" }
9797
webpki-roots = "0.26"
9898
data-encoding = "2.6.0"
99+
lru = "0.12"
99100

100101
[dev-dependencies]
101102
clap = { version = "4", features = ["derive"] }

iroh-relay/src/client.rs

+19-3
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ use crate::{
4545
defaults::timeouts::*,
4646
http::{Protocol, RELAY_PATH},
4747
protos::relay::DerpCodec,
48+
KeyCache,
4849
};
4950

5051
pub(crate) mod conn;
@@ -159,6 +160,7 @@ struct Actor {
159160
ping_tasks: JoinSet<()>,
160161
dns_resolver: DnsResolver,
161162
proxy_url: Option<Url>,
163+
key_cache: KeyCache,
162164
}
163165

164166
#[derive(Default, Debug)]
@@ -208,6 +210,8 @@ pub struct ClientBuilder {
208210
insecure_skip_cert_verify: bool,
209211
/// HTTP Proxy
210212
proxy_url: Option<Url>,
213+
/// Capacity of the key cache
214+
key_cache_capacity: usize,
211215
}
212216

213217
impl ClientBuilder {
@@ -223,6 +227,7 @@ impl ClientBuilder {
223227
#[cfg(any(test, feature = "test-utils"))]
224228
insecure_skip_cert_verify: false,
225229
proxy_url: None,
230+
key_cache_capacity: 128,
226231
}
227232
}
228233

@@ -281,6 +286,12 @@ impl ClientBuilder {
281286
self
282287
}
283288

289+
/// Set the capacity of the cache for public keys.
290+
pub fn key_cache_capacity(mut self, capacity: usize) -> Self {
291+
self.key_cache_capacity = capacity;
292+
self
293+
}
294+
284295
/// Build the [`Client`]
285296
pub fn build(self, key: SecretKey, dns_resolver: DnsResolver) -> (Client, ClientReceiver) {
286297
// TODO: review TLS config
@@ -320,6 +331,7 @@ impl ClientBuilder {
320331
tls_connector,
321332
dns_resolver,
322333
proxy_url: self.proxy_url,
334+
key_cache: KeyCache::new(self.key_cache_capacity),
323335
};
324336

325337
let (msg_sender, inbox) = mpsc::channel(64);
@@ -629,7 +641,9 @@ impl Actor {
629641

630642
let (writer, reader) = tokio_tungstenite_wasm::connect(dial_url).await?.split();
631643

632-
let reader = ConnReader::Ws(reader);
644+
let cache = self.key_cache.clone();
645+
646+
let reader = ConnReader::Ws(reader, cache);
633647
let writer = ConnWriter::Ws(writer);
634648

635649
Ok((reader, writer))
@@ -683,8 +697,10 @@ impl Actor {
683697
let (reader, writer) =
684698
downcast_upgrade(upgraded).map_err(|e| ClientError::Upgrade(e.to_string()))?;
685699

686-
let reader = ConnReader::Derp(FramedRead::new(reader, DerpCodec));
687-
let writer = ConnWriter::Derp(FramedWrite::new(writer, DerpCodec));
700+
let cache = self.key_cache.clone();
701+
702+
let reader = ConnReader::Derp(FramedRead::new(reader, DerpCodec::new(cache.clone())));
703+
let writer = ConnWriter::Derp(FramedWrite::new(writer, DerpCodec::new(cache)));
688704

689705
Ok((reader, writer, local_addr))
690706
}

iroh-relay/src/client/conn.rs

+4-3
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ use tokio_util::{
2727
};
2828
use tracing::{debug, info_span, trace, Instrument};
2929

30+
use super::KeyCache;
3031
use crate::{
3132
client::streams::{MaybeTlsStreamReader, MaybeTlsStreamWriter},
3233
defaults::timeouts::CLIENT_RECV_TIMEOUT,
@@ -270,7 +271,7 @@ pub struct ConnBuilder {
270271

271272
pub(crate) enum ConnReader {
272273
Derp(FramedRead<MaybeTlsStreamReader, DerpCodec>),
273-
Ws(SplitStream<WebSocketStream>),
274+
Ws(SplitStream<WebSocketStream>, KeyCache),
274275
}
275276

276277
pub(crate) enum ConnWriter {
@@ -291,9 +292,9 @@ impl Stream for ConnReader {
291292
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
292293
match *self {
293294
Self::Derp(ref mut ws) => Pin::new(ws).poll_next(cx),
294-
Self::Ws(ref mut ws) => match Pin::new(ws).poll_next(cx) {
295+
Self::Ws(ref mut ws, ref cache) => match Pin::new(ws).poll_next(cx) {
295296
Poll::Ready(Some(Ok(tokio_tungstenite_wasm::Message::Binary(vec)))) => {
296-
Poll::Ready(Some(Frame::decode_from_ws_msg(vec)))
297+
Poll::Ready(Some(Frame::decode_from_ws_msg(vec, cache)))
297298
}
298299
Poll::Ready(Some(Ok(msg))) => {
299300
tracing::warn!(?msg, "Got websocket message of unsupported type, skipping.");

iroh-relay/src/defaults.rs

+7
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ pub const DEFAULT_HTTPS_PORT: u16 = 443;
2020
/// The default metrics port used by the Relay server.
2121
pub const DEFAULT_METRICS_PORT: u16 = 9090;
2222

23+
/// The default capacity of the key cache for the relay server.
24+
///
25+
/// Sized for 1 million concurrent clients.
26+
/// memory usage will be (32 + 8 + 8 + 8) * 1_000_000 = 56MB on 64 bit,
27+
/// which seems reasonable for a server.
28+
pub const DEFAULT_KEY_CACHE_CAPACITY: usize = 1024 * 1024;
29+
2330
/// Contains all timeouts that we use in `iroh`.
2431
pub(crate) mod timeouts {
2532
use std::time::Duration;

iroh-relay/src/key_cache.rs

+63
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
use std::{
2+
num::NonZeroUsize,
3+
sync::{Arc, Mutex},
4+
};
5+
6+
use iroh_base::PublicKey;
7+
8+
type SignatureError = <PublicKey as TryFrom<&'static [u8]>>::Error;
9+
type PublicKeyBytes = [u8; PublicKey::LENGTH];
10+
11+
/// A cache for public keys.
12+
///
13+
/// This is used solely to make parsing public keys from byte slices more
14+
/// efficient for the very common case where a large number of identical keys
15+
/// are being parsed, like in the relay server.
16+
///
17+
/// The cache stores only successful parse results.
18+
#[derive(Debug, Clone)]
19+
pub enum KeyCache {
20+
/// The key cache is disabled.
21+
Disabled,
22+
/// The key cache is enabled with a fixed capacity. It is shared between
23+
/// multiple threads.
24+
Shared(Arc<Mutex<lru::LruCache<PublicKey, ()>>>),
25+
}
26+
27+
impl KeyCache {
28+
/// Key cache to be used in tests.
29+
#[cfg(test)]
30+
pub fn test() -> Self {
31+
Self::Disabled
32+
}
33+
34+
/// Create a new key cache with the given capacity.
35+
///
36+
/// If the capacity is zero, the cache is disabled and has zero overhead.
37+
pub fn new(capacity: usize) -> Self {
38+
let Some(capacity) = NonZeroUsize::new(capacity) else {
39+
return Self::Disabled;
40+
};
41+
let cache = lru::LruCache::new(capacity);
42+
Self::Shared(Arc::new(Mutex::new(cache)))
43+
}
44+
45+
/// Get a key from a slice of bytes.
46+
pub fn key_from_slice(&self, slice: &[u8]) -> Result<PublicKey, SignatureError> {
47+
let Self::Shared(cache) = self else {
48+
return PublicKey::try_from(slice);
49+
};
50+
let Ok(bytes) = PublicKeyBytes::try_from(slice) else {
51+
// if the size is wrong, use PublicKey::try_from to fail with a
52+
// SignatureError.
53+
return Err(PublicKey::try_from(slice).expect_err("invalid length"));
54+
};
55+
let mut cache = cache.lock().expect("not poisoned");
56+
if let Some((key, _)) = cache.get_key_value(&bytes) {
57+
return Ok(*key);
58+
}
59+
let key = PublicKey::from_bytes(&bytes)?;
60+
cache.put(key, ());
61+
Ok(key)
62+
}
63+
}

iroh-relay/src/lib.rs

+2
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@ pub mod quic;
3838
#[cfg(feature = "server")]
3939
pub mod server;
4040

41+
mod key_cache;
4142
mod relay_map;
43+
pub(crate) use key_cache::KeyCache;
4244

4345
#[cfg(test)]
4446
mod dns;

iroh-relay/src/main.rs

+4
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,8 @@ struct Config {
168168
/// Defaults to `http_bind_addr` with the port set to [`DEFAULT_METRICS_PORT`]
169169
/// (`[::]:9090` when `http_bind_addr` is set to the default).
170170
metrics_bind_addr: Option<SocketAddr>,
171+
/// The capacity of the key cache.
172+
key_cache_capacity: Option<usize>,
171173
}
172174

173175
impl Config {
@@ -199,6 +201,7 @@ impl Default for Config {
199201
limits: None,
200202
enable_metrics: cfg_defaults::enable_metrics(),
201203
metrics_bind_addr: None,
204+
key_cache_capacity: Default::default(),
202205
}
203206
}
204207
}
@@ -570,6 +573,7 @@ async fn build_relay_config(cfg: Config) -> Result<relay::ServerConfig<std::io::
570573
// if `dangerous_http_only` is set, do not pass in any tls configuration
571574
tls: relay_tls.and_then(|tls| if dangerous_http_only { None } else { Some(tls) }),
572575
limits,
576+
key_cache_capacity: cfg.key_cache_capacity,
573577
};
574578
let stun_config = relay::StunConfig {
575579
bind_addr: cfg.stun_bind_addr(),

0 commit comments

Comments
 (0)