Skip to content

perf: tier 1 and tier 2 performance audit - #137

Merged
kacy merged 10 commits into
mainfrom
perf/audit-tier1-tier2
Feb 14, 2026
Merged

perf: tier 1 and tier 2 performance audit#137
kacy merged 10 commits into
mainfrom
perf/audit-tier1-tier2

Conversation

@kacy

@kacy kacy commented Feb 14, 2026

Copy link
Copy Markdown
Owner

summary

systematic performance audit covering the highest-impact optimizations identified in the ember codebase. this PR implements items P-01 through P-14 from the performance audit, organized as atomic commits.

parser (ember-protocol):

  • replace byte-by-byte CRLF scanning with SIMD-accelerated memchr — the single hottest function in the parser now processes 16-32 bytes per cycle (P-01)
  • combine check()+parse() into a single-pass try_parse() that eliminates double-scanning every byte in the buffer. for a 10-element pipeline command, this cuts line-scans from 20 to 10 (P-02)

core engine (emberkv-core):

  • pre-compile glob patterns for KEYS/SCAN — avoids N Vec<char> allocations where N = keyspace size (P-03)
  • eviction hot path: replace choose_multiple (internal Vec allocation) + key clone with inline reservoir sampling. reduces heap allocations on every eviction from 2 to 1 (P-04, P-05)
  • sorted set: share member strings via Arc<str> between BTreeMap and HashMap indexes, cutting per-member string memory roughly in half (P-13)
  • increase ENTRY_OVERHEAD from 96 to 128 bytes with a validation test that catches regressions if Entry grows (P-14)

command parsing / connection (ember-protocol, ember-server):

  • fix extract_bytes() double allocation on Simple frames (P-09)
  • hoist frames Vec above connection read loop, reuse with drain(..) (P-10)
  • remove redundant formatted.clone() in INCRBYFLOAT (P-11)

build:

  • switch release profile from thin LTO to full LTO, add panic = "abort" — enables cross-crate inlining across the workspace (P-06)

what was tested

  • cargo test --workspace — 898 unit/lib tests pass across all crates
  • integration tests pass (some pre-existing flakiness in auth timing tests unrelated to this PR)
  • cargo fmt --all -- --check clean
  • cargo clippy (no new warnings)

design considerations

  • memchr is the only new dependency added. it's a widely-used, zero-dependency crate maintained by Andrew Gallant (BurntSushi). the SIMD acceleration is the standard approach for this pattern — used by ripgrep, regex, and tokio.
  • single-pass parser: the old two-pass design (check then parse) was intentionally conservative — parsing only after validation ensures no partial Frame allocation on incomplete data. the new design handles this by returning Incomplete from try_parse() which causes the Frame Vec allocations to be dropped. for pipelined workloads where most frames are complete, the single pass is strictly better.
  • Arc vs Rc in sorted set: Rc would be slightly faster (non-atomic refcount) but shards are spawned as tokio tasks requiring Send. the atomic overhead is negligible vs the memory savings from deduplication.
  • ENTRY_OVERHEAD increase: 128 bytes is conservative enough that eviction will trigger before the OS OOM-kills us, without being so aggressive that it wastes significant capacity. the validation test ensures the constant stays calibrated as the Entry struct evolves.

kacy added 10 commits February 14, 2026 18:21
thin LTO only inlines within crate boundaries. full LTO enables
cross-crate inlining which is critical for a workspace with many
small crates calling into each other on every request.

panic=abort eliminates unwinding overhead and is safe given ember's
error handling discipline (no unwrap in library code, thiserror
everywhere).
find_crlf() is the hottest function in the parser — called for every
line in every RESP frame. the naive byte-by-byte loop processes 1 byte
per iteration. memchr uses SIMD intrinsics to scan 16-32 bytes per
cycle on x86-64 and aarch64.

handles bare \r (without following \n) correctly by continuing the
search past it.
replace choose_multiple() (which allocates a Vec internally for
reservoir sampling) with an inline reservoir sample of k=1. this
keeps only the single best victim candidate in a local variable.

the previous code also cloned the victim key string to satisfy
borrow checker constraints. the new approach borrows the key as
&str during sampling, then does a single to_owned() only for the
final victim — avoiding N-1 unnecessary clones.

net effect: eviction goes from 2 heap allocations (Vec + key clone)
to 1 (the final victim key), which matters at high write rates
under memory pressure.
glob_match() collected pattern.chars() into a Vec<char> on every call.
for KEYS/SCAN iterating 100k keys, that meant 100k identical Vec
allocations for the same pattern.

introduce GlobPattern::new() which compiles the pattern once, and
GlobPattern::matches() which reuses the compiled form. the original
glob_match() still works for one-off calls.
three small fixes:

- extract_bytes() on Simple frames: replace s.clone().into_bytes()
  (clone + convert) with copy_from_slice(s.as_bytes()) — single copy
  instead of two.

- hoist `frames` Vec above the connection read loop and use drain(..)
  instead of re-allocating on every iteration. retains capacity across
  pipeline batches.

- INCRBYFLOAT: replace Bytes::from(formatted.clone()) with
  Bytes::copy_from_slice(formatted.as_bytes()) — avoids cloning the
  formatted string just to move it into Bytes.
the old parser used two passes: check() scanned the entire buffer to
verify a complete frame existed, then parse() scanned the same bytes
again to build Frame values. for a 10-element array command, that
meant 20 line-scans instead of 10.

the new try_parse() does both in a single pass — it builds Frame
values directly while scanning, returning Incomplete if the buffer
doesn't contain enough data yet. all validation (nesting depth, array
element limits, bulk string length) is preserved.

the old check_* and parse() functions are removed since try_parse()
handles both roles.
the sorted set stores each member name in two places: the BTreeMap key
(for ordered iteration) and the HashMap key (for O(1) score lookups).
previously these were independent String allocations, doubling the
memory cost of member names.

now both indexes share a single Arc<str> allocation. adding a member
allocates the string once and clones the Arc pointer (8 bytes, atomic
refcount bump) for the second index. Arc is used instead of Rc because
shards are spawned as tokio tasks requiring Send.

for a sorted set with 100k members averaging 20 bytes each, this saves
~2 MB of heap allocations.
the old ENTRY_OVERHEAD of 96 bytes underestimated actual per-entry
memory usage. on 64-bit platforms, each HashMap<String, Entry> slot
costs: String struct (24) + Entry struct (Value enum ~48 + expires_at
8 + last_access 8 = ~64) + hashbrown control/waste (~16) = ~104+.

bumping to 128 gives a safe margin that prevents memory from exceeding
the configured limit before eviction kicks in. overestimating is always
safe — it just triggers eviction slightly earlier.

adds entry_overhead_not_too_small test that validates the constant
against actual struct sizes at compile time, catching any future
regressions if Entry grows.

updates two memory-limit tests whose hardcoded limits assumed the old
96-byte overhead.
@kacy
kacy merged commit 9618307 into main Feb 14, 2026
7 checks passed
@kacy
kacy deleted the perf/audit-tier1-tier2 branch February 14, 2026 23:44
kacy added a commit that referenced this pull request Feb 19, 2026
* perf: use full LTO and panic=abort in release profile

thin LTO only inlines within crate boundaries. full LTO enables
cross-crate inlining which is critical for a workspace with many
small crates calling into each other on every request.

panic=abort eliminates unwinding overhead and is safe given ember's
error handling discipline (no unwrap in library code, thiserror
everywhere).

* perf: use memchr for SIMD-accelerated CRLF scanning

find_crlf() is the hottest function in the parser — called for every
line in every RESP frame. the naive byte-by-byte loop processes 1 byte
per iteration. memchr uses SIMD intrinsics to scan 16-32 bytes per
cycle on x86-64 and aarch64.

handles bare \r (without following \n) correctly by continuing the
search past it.

* perf: eliminate allocations in eviction hot path

replace choose_multiple() (which allocates a Vec internally for
reservoir sampling) with an inline reservoir sample of k=1. this
keeps only the single best victim candidate in a local variable.

the previous code also cloned the victim key string to satisfy
borrow checker constraints. the new approach borrows the key as
&str during sampling, then does a single to_owned() only for the
final victim — avoiding N-1 unnecessary clones.

net effect: eviction goes from 2 heap allocations (Vec + key clone)
to 1 (the final victim key), which matters at high write rates
under memory pressure.

* perf: pre-compile glob pattern for KEYS and SCAN

glob_match() collected pattern.chars() into a Vec<char> on every call.
for KEYS/SCAN iterating 100k keys, that meant 100k identical Vec
allocations for the same pattern.

introduce GlobPattern::new() which compiles the pattern once, and
GlobPattern::matches() which reuses the compiled form. the original
glob_match() still works for one-off calls.

* perf: fix micro-allocations in command parsing and connection loop

three small fixes:

- extract_bytes() on Simple frames: replace s.clone().into_bytes()
  (clone + convert) with copy_from_slice(s.as_bytes()) — single copy
  instead of two.

- hoist `frames` Vec above the connection read loop and use drain(..)
  instead of re-allocating on every iteration. retains capacity across
  pipeline batches.

- INCRBYFLOAT: replace Bytes::from(formatted.clone()) with
  Bytes::copy_from_slice(formatted.as_bytes()) — avoids cloning the
  formatted string just to move it into Bytes.

* perf: single-pass RESP3 parser eliminates double-scanning

the old parser used two passes: check() scanned the entire buffer to
verify a complete frame existed, then parse() scanned the same bytes
again to build Frame values. for a 10-element array command, that
meant 20 line-scans instead of 10.

the new try_parse() does both in a single pass — it builds Frame
values directly while scanning, returning Incomplete if the buffer
doesn't contain enough data yet. all validation (nesting depth, array
element limits, bulk string length) is preserved.

the old check_* and parse() functions are removed since try_parse()
handles both roles.

* perf: share member strings in sorted set via Arc<str>

the sorted set stores each member name in two places: the BTreeMap key
(for ordered iteration) and the HashMap key (for O(1) score lookups).
previously these were independent String allocations, doubling the
memory cost of member names.

now both indexes share a single Arc<str> allocation. adding a member
allocates the string once and clones the Arc pointer (8 bytes, atomic
refcount bump) for the second index. Arc is used instead of Rc because
shards are spawned as tokio tasks requiring Send.

for a sorted set with 100k members averaging 20 bytes each, this saves
~2 MB of heap allocations.

* perf: increase ENTRY_OVERHEAD to 128 bytes, add validation test

the old ENTRY_OVERHEAD of 96 bytes underestimated actual per-entry
memory usage. on 64-bit platforms, each HashMap<String, Entry> slot
costs: String struct (24) + Entry struct (Value enum ~48 + expires_at
8 + last_access 8 = ~64) + hashbrown control/waste (~16) = ~104+.

bumping to 128 gives a safe margin that prevents memory from exceeding
the configured limit before eviction kicks in. overestimating is always
safe — it just triggers eviction slightly earlier.

adds entry_overhead_not_too_small test that validates the constant
against actual struct sizes at compile time, catching any future
regressions if Entry grows.

updates two memory-limit tests whose hardcoded limits assumed the old
96-byte overhead.

* cargo fmt: fix formatting

* update Cargo.lock for memchr dependency
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant