perf: tier 1 and tier 2 performance audit - #137
Merged
Conversation
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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):
memchr— the single hottest function in the parser now processes 16-32 bytes per cycle (P-01)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):
Vec<char>allocations where N = keyspace size (P-03)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)Arc<str>between BTreeMap and HashMap indexes, cutting per-member string memory roughly in half (P-13)command parsing / connection (ember-protocol, ember-server):
extract_bytes()double allocation on Simple frames (P-09)framesVec above connection read loop, reuse withdrain(..)(P-10)formatted.clone()in INCRBYFLOAT (P-11)build:
panic = "abort"— enables cross-crate inlining across the workspace (P-06)what was tested
cargo test --workspace— 898 unit/lib tests pass across all cratescargo fmt --all -- --checkcleancargo clippy(no new warnings)design considerations
Incompletefromtry_parse()which causes the Frame Vec allocations to be dropped. for pipelined workloads where most frames are complete, the single pass is strictly better.Send. the atomic overhead is negligible vs the memory savings from deduplication.