Skip to content
Merged
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 Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,10 @@ test-wasm:
# (e.g. FILTER=l2_hit); JOBS=<n> measures n cases at a time.
# CI's `perf-ir` job runs the same comparison on every PR.
bench:
$(CARGO) run --release --example bench_hot_path -- wall $(if $(FILTER),'$(FILTER)')
$(CARGO) run --release --features macros --example bench_hot_path -- wall $(if $(FILTER),'$(FILTER)')

bench-instr:
$(CARGO) build --release --example bench_hot_path
$(CARGO) build --release --features macros --example bench_hot_path
target/release/examples/bench_hot_path instr $(if $(JOBS),--jobs '$(JOBS)') $(if $(BASE),--base '$(BASE)') $(if $(FILTER),'$(FILTER)')

# Supply-chain gate — deny is identical to CI's; audit runs the strict form
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -885,6 +885,10 @@ MessagePack, is the smallest case, where per-call fixed costs dominate). Two mor
`prev` reads, L1 and L2 hits, on a client holding one previous key, of entries
written under that key; `oneshot` builds an encrypting client (`enc` or `prev`)
and reads one L2 hit, the cost a one-read process pays beyond its start-up.
`macro_hit` reads an L1 hit through a `#[cachekit]` function (`plain`, `rel`,
or `enc` through a `secure` function) whose interop key is 120 B, so it prices
the macro's key derivation; it needs the `macros` feature, which the `make`
targets turn on.
`ns` and `nsenc` run `l1_hit`, `l2_hit` and `set` as `plain` and `enc` on a
client with a `.namespace()`, which builds `{namespace}:{key}` on every op.
`delete/parked256` and `delete/parked1024` run the plain 64 B `delete` while
Expand All @@ -906,7 +910,7 @@ loop (`--toggle-collect`), on a single-threaded tokio runtime, five runs per
case, and reports the median and the spread. `JOBS=<n>` measures n cases at a
time; counts do not depend on it. For an A/B, check out the base commit, copy
your working tree's `crates/cachekit/examples/bench_hot_path.rs` into it so
both builds run one workload, build the example there, copy its
both builds run one workload, build the example there with `--features macros`, copy its
`target/release/examples/bench_hot_path` outside `target/`, and pass it as
`BASE`. The example copies both builds to paths of equal length
before it measures them, because the length of a binary's path moves its
Expand Down
20 changes: 14 additions & 6 deletions crates/cachekit-macros/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ struct MacroArgs {
/// Compile-time mirror of the interop/v1 segment grammar
/// (`^[a-z0-9][a-z0-9._-]{0,63}$` — see `cachekit::interop`'s
/// `validate_segment`, the canonical implementation). Duplicated because a
/// proc-macro crate cannot depend on the runtime crate; `interop_key`
/// proc-macro crate cannot depend on the runtime crate; `__KeyPrefix`
/// re-validates at runtime, so drift fails loudly, never silently. The `..`
/// ban and the reserved-namespace check are mirrored in `parse_segment`.
fn segment_is_valid(segment: &str) -> bool {
Expand Down Expand Up @@ -555,15 +555,23 @@ fn expand(args: &MacroArgs, mut func: ItemFn) -> syn::Result<TokenStream2> {
{
// interop/v1 key: {namespace}:{operation}:{args_hash} — the same
// key is computable from any SDK (protocol spec/interop-mode.md).
// The prefix is validated and joined once per function, so a call
// only hashes its arguments; the bytes equal `interop_key`'s.
// The arguments are evaluated outside the block that declares the
// static: an item is visible throughout its block, so a parameter
// with the static's name would otherwise resolve to the static.
// The generated `.clone()` per argument keeps the binding usable
// by the body; for Copy types it is a copy and for `&str` it is
// a deliberate reference copy — hence the allows.
#[allow(clippy::clone_on_copy, noop_method_call)]
let __ck_key = cachekit::interop::interop_key(
#namespace,
#operation,
&[#(#interop_args),*],
)?;
let __ck_key = {
let __ck_args: &[cachekit::interop::InteropValue] = &[#(#interop_args),*];
{
static __CK_KEY_PREFIX: cachekit::interop::__KeyPrefix<'static> =
cachekit::interop::__KeyPrefix::new(#namespace, #operation);
__CK_KEY_PREFIX.key(__ck_args)?
}
};

// Try cache hit, classified against the stale-while-revalidate
// freshness window. A Serialization error means the stored entry
Expand Down
85 changes: 79 additions & 6 deletions crates/cachekit/examples/bench_hot_path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
//! valgrind's callgrind, which is deterministic on one thread. The runtime is
//! tokio `current_thread`: work stealing would add scheduler noise.
//!
//! `macro_hit` reads through a `#[cachekit]` function, so it builds only with
//! the `macros` feature (`make bench` and `make bench-instr` turn it on); a
//! build without it lists no `macro_hit` case.
//!
//! ```text
//! bench_hot_path list every case name
//! bench_hot_path run <case> <size> <iters> one case; indicative wall ns/op
Expand Down Expand Up @@ -45,14 +49,15 @@ use cachekit::backend::{Backend, HealthStatus};
use cachekit::reliability::ReliabilityConfig;
use cachekit::{BackendError, CacheKit, CachekitError};

const OPS: [&str; 7] = [
const OPS: [&str; 8] = [
"l1_hit",
"l1_hit_row",
"l2_hit",
"set",
"delete",
"exists",
"oneshot",
"macro_hit",
];
/// `ns` and `nsenc` are `plain` and `enc` on a client with a namespace, so
/// every op builds `{namespace}:{key}`.
Expand Down Expand Up @@ -86,6 +91,13 @@ const TTL: Duration = Duration::from_secs(300);
const WARM: usize = 16;
/// Demangled name of the function callgrind counts (see [`measured`]).
const MEASURED: &str = "bench_hot_path::measured";
/// `macro_hit`'s interop segments and arguments. With the 64-hex hash, its
/// key is 120 B, the length of a real interop key: a short bench key
/// understates what a key-path change saves.
const MACRO_NAMESPACE: &str = "bench.accounts.v2";
const MACRO_OPERATION: &str = "users.fetch_profile_by_email_and_size";
const MACRO_EMAIL: &str = "alice@example.com";
const MACRO_ID: u64 = 42;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
/// Key prefix whose backend delete never returns (see [`parked`]).
const PARKED: &str = "bench:parked:";
/// The one value size a `parked<N>` case runs at: the map it prices never
Expand Down Expand Up @@ -216,6 +228,41 @@ impl Backend for MemBackend {

// ── Cases ────────────────────────────────────────────────────────────────────

/// `macro_hit/plain` and `macro_hit/rel`'s function. Setup writes its entry
/// under the key the macro derives, so the body runs only on a miss, which
/// fails the run.
#[cfg(feature = "macros")]
#[cachekit::cachekit(
client = cache,
ttl = 300,
interop = "users.fetch_profile_by_email_and_size",
namespace = "bench.accounts.v2"
)]
async fn macro_read(cache: &CacheKit, email: &str, id: u64) -> Result<String, CachekitError> {
Err(CachekitError::Config(format!(
"macro_hit: expected a hit for {email}/{id}, got a miss"
)))
}

/// `macro_hit/enc`'s function: [`macro_read`] with `secure`.
#[cfg(feature = "macros")]
#[cachekit::cachekit(
client = cache,
ttl = 300,
interop = "users.fetch_profile_by_email_and_size",
namespace = "bench.accounts.v2",
secure
)]
async fn macro_read_secure(
cache: &CacheKit,
email: &str,
id: u64,
) -> Result<String, CachekitError> {
Err(CachekitError::Config(format!(
"macro_hit: expected a hit for {email}/{id}, got a miss"
)))
}

/// One bench case: `<op>/<mode>`, e.g. `l2_hit/enc`.
struct Bench {
op: &'static str,
Expand Down Expand Up @@ -273,14 +320,18 @@ fn parked(mode: &str) -> usize {
/// Whether `<op>/<mode>/<size>` is a case. `prev` is the rotation read path,
/// so it pairs only with reads; `oneshot` prices building an encrypting
/// client, so it pairs only with the encrypted modes; `parked<N>` pairs only
/// with `delete`, at [`PARKED_SIZE`]. A namespace adds the same key build to
/// every op, so `ns` and `nsenc` price it on the reads and `set` only.
/// with `delete`, at [`PARKED_SIZE`]; `macro_hit` exists only with the
/// `macros` feature, and its `enc` mode reads through a `secure` function.
/// A namespace adds the same key build to every op, so `ns` and `nsenc` price
/// it on the reads and `set` only; not on `macro_hit`, whose interop read a
/// namespaced client refuses.
fn is_case(op: &str, mode: &str, size: usize) -> bool {
if parked(mode) > 0 {
return op == "delete" && size == PARKED_SIZE;
}
match op {
"oneshot" => matches!(mode, "enc" | "prev"),
"macro_hit" => cfg!(feature = "macros") && !matches!(mode, "prev" | "ns" | "nsenc"),
"l1_hit" | "l2_hit" => true,
// A plaintext decode: the encrypted modes decode decrypted plaintext,
// which is walked on every read.
Expand Down Expand Up @@ -313,9 +364,18 @@ impl Bench {
// its capacity into shards, and a shard sized exactly to its keys
// evicts a few of them when the hash spreads them unevenly.
let reader = client(&backend, mode, 2 * (iters + WARM) + 8)?;
let keys: Vec<String> = (0..iters + WARM)
.map(|i| format!("bench:{op}:{i:08}"))
.collect();
let keys: Vec<String> = if op == "macro_hit" {
// The key `#[cachekit]` derives for `macro_read(MACRO_EMAIL, MACRO_ID)`.
vec![cachekit::interop::interop_key(
MACRO_NAMESPACE,
MACRO_OPERATION,
&[MACRO_EMAIL.into(), MACRO_ID.into()],
)?]
} else {
(0..iters + WARM)
.map(|i| format!("bench:{op}:{i:08}"))
.collect()
};
let bench = Self {
op,
mode,
Expand Down Expand Up @@ -399,6 +459,19 @@ impl Bench {
// backend: what a one-read process pays past start-up (the warm-up
// has already paid first-use costs, and L1 holds 8 entries).
"oneshot" => read(&client(&self.backend, self.mode, 8)?, true, key).await,
// An L1 hit through `#[cachekit]`: `l1_hit`'s read plus the key
// derivation, the freshness check and the hit count.
#[cfg(feature = "macros")]
"macro_hit" => {
let (email, id) = (black_box(MACRO_EMAIL), black_box(MACRO_ID));
let got = if self.enc {
macro_read_secure(&self.reader, email, id).await?
} else {
macro_read(&self.reader, email, id).await?
};
drop(black_box(got));
Ok(())
}
"l1_hit_row" => {
let got: Option<Row> = self.reader.get(key).await?;
got.map(|v| drop(black_box(v))).ok_or_else(|| {
Expand Down
Loading
Loading