Skip to content
Closed
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
8 changes: 5 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,9 @@ jobs:
# shared one, cargo's mtime check can reuse one tree's build for the other
# and compare a binary against itself. Both trees build the PR's copy of
# the example, so both sides run one workload; a PR whose example calls
# API the base lacks cannot be compared and reads INCOMPLETE.
# API the base lacks cannot be compared and reads INCOMPLETE. Both build
# with `--features macros`, so the cases behind it, the `#[cachekit]` path,
# are measured too.
perf-ir:
name: Instruction counts vs base
if: github.event_name == 'pull_request'
Expand Down Expand Up @@ -320,8 +322,8 @@ jobs:
env:
BASE_LABEL: ${{ steps.base.outputs.label }}
run: |
(cd "$RUNNER_TEMP/ir/head" && CARGO_TARGET_DIR=../head-target cargo build --locked --release --example bench_hot_path)
(cd "$RUNNER_TEMP/ir/base" && CARGO_TARGET_DIR=../base-target cargo build --locked --release --example bench_hot_path) || {
(cd "$RUNNER_TEMP/ir/head" && CARGO_TARGET_DIR=../head-target cargo build --locked --release --features macros --example bench_hot_path)
(cd "$RUNNER_TEMP/ir/base" && CARGO_TARGET_DIR=../base-target cargo build --locked --release --features macros --example bench_hot_path) || {
{
echo '### Instruction counts: INCOMPLETE'
echo
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -924,9 +924,10 @@ reports either one as exit 2.

CI runs this comparison on every pull request (the **Instruction counts vs
base** check): it builds the pull request and the commit it merges into, each
in its own target directory, measures both on the same runner, and posts the
verdict as one comment it edits in place (a pull request from a fork gets no
comment; its verdict is in the run summary). A case it did not measure reads
in its own target directory and with `--features macros` (the `#[cachekit]`
cases need it), measures both on the same runner, and posts the verdict as
one comment it edits in place (a pull request from a fork gets no comment; its
verdict is in the run summary). A case it did not measure reads
INCOMPLETE, never PASS. A release pull request is measured against the last
release tag instead. The check is not required: a deliberate cost increase is
accepted at review.
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 All @@ -77,6 +82,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;

/// The value of `l1_hit_row`: a struct of 20 named fields, stored as a
/// MessagePack map of 20 entries, so a bounds walk, where one runs, covers 41
Expand Down Expand Up @@ -196,6 +208,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 @@ -242,11 +289,15 @@ fn writer(backend: &Arc<MemBackend>, mode: &str) -> Result<CacheKit, CachekitErr

/// Whether `<op>/<mode>` 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. A namespace adds the same key build
/// to every op, so `ns` and `nsenc` price it on the reads and `set` only.
/// it pairs only with the encrypted modes; `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) -> bool {
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 @@ -279,9 +330,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 @@ -348,6 +408,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