Skip to content

perf: deep-review hot-path optimizations (storage, WAL, SPSC, protocol, vector, io_uring)#168

Merged
pilotspacex-byte merged 8 commits into
mainfrom
perf/deep-review-optimizations
Jun 10, 2026
Merged

perf: deep-review hot-path optimizations (storage, WAL, SPSC, protocol, vector, io_uring)#168
pilotspacex-byte merged 8 commits into
mainfrom
perf/deep-review-optimizations

Conversation

@pilotspacex-byte

@pilotspacex-byte pilotspacex-byte commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements the 11 verified findings from a 5-area deep performance review (hot path, data store, persistence, vector, graph). 60 findings were collected by parallel specialist reviews; the top 15 were adversarially verified against the code (11 confirmed, 3 downgraded, 1 refuted) and the confirmed quick wins implemented. Full report: tmp/perf-review/REPORT.md (local, untracked).

Benchmark (moon-dev VM, aarch64 Linux, 1 shard, monoio, 200k req, r=100k)

Workload Baseline (6a8dcb0) This branch Δ
SET P=1 375,235 rps 396,040 rps +5.5%
GET P=1 321,543 rps 331,675 rps +3.2%
SET P=32 1,204,819 rps (p50 1.255ms) 1,666,667 rps (p50 0.887ms) +38.3%
GET P=32 5,555,556 rps (p50 0.247ms) 6,896,552 rps (p50 0.183ms) +24.1%

Changes

  • storage: Database::get 3→2 DashTable probes on hit, 2→1 on miss (single-probe classify); allocation-free EvictionPolicy::from_str
  • persistence: WAL v2 flush 3→1 write syscalls via reusable staging buffer (on-disk layout unchanged); WAL v3 record append to_vec()Cow::Borrowed; CheckpointState clone-per-tick → Copy
  • shard: drain_spsc_shared per-call Vec pair → thread-local scratch (re-entrancy-safe mem::take)
  • protocol: Frame::Double format! → stack F64Display (byte-identical Display output, RESP2+RESP3); new edge-case tests pin f64::MAX / 5e-324 worst-case lengths
  • vector: SQ8 per-query heap allocs removed in HNSW + both brute-force paths (inline SmallVec, L2 borrows raw query); IVF q_rotated/lut_buf hoisted out of the segment loop in search_filtered (mirrors search_mvcc)
  • io_uring: drain_completions per-batch CQE + event Vecs → persistent driver scratch loaned to the event loop

Validation

  • 1,102 targeted unit tests green on macOS (vector 531, storage 319, shard 123, protocol 129)
  • cargo fmt --check, cargo clippy -D warnings (default + tokio,jemalloc feature sets) green on Linux VM
  • Cold cargo check green on Linux (covers #[cfg(target_os = "linux")] io_uring changes)
  • WAL on-disk format byte-identical; covered by existing wal/wal_v3/checkpoint round-trip tests

Deferred (follow-up candidates from the review)

Single-pass RESP parse (HP-F1), Cypher columnar rows (GR-F1, est. 40-70% multi-hop win), ParallelBfs flat neighbor buffer (GR-F2), NEON SQ8 ADC kernel (VE-F6, est. 3-4x kernel), key_hash_to_key clone-per-FT.SEARCH (VE-F7), monoio snapshot finalize blocking the event loop (PS-F10).

Summary by CodeRabbit

  • Refactor
    • Optimized hot-path memory and allocation patterns across I/O, event handling, persistence, vector search, serialization, and storage lookup via buffer reuse and stack/allocation-free strategies.
  • Documentation
    • Added changelog entry summarizing the hot-path performance improvements.
  • Tests
    • Extended tests to verify zero-allocation float formatting and serializer output.

…policy parse

Database::get previously probed the DashTable three times on a live hit
(expiry check, is_some guard, return get) and twice on a miss. A single
probe now classifies the key as Live/Expired/Absent; NLL forces one
re-probe on the live path (the cold-tier fallback mutates self), giving
2 probes on hit and 1 on miss.

EvictionPolicy::from_str allocated a lowercase String per call — invoked
once per write command under memory pressure. Replaced with
eq_ignore_ascii_case table matching; EvictionPolicy now derives Copy.

Deep-review findings ST-F1 (P0) and ST-F4. Behavior unchanged; covered
by existing storage::db and storage::eviction unit tests.

author: Tin Dang
… checkpoint state

WAL v2 do_write issued three write(2) syscalls per 1ms flush (7B header,
payload, 4B CRC). The full block frame is now assembled in a reusable
staging buffer and written with one syscall; on-disk layout unchanged:
[block_len:4][cmd_count:2][db_idx:1][payload][crc32:4].

write_wal_v3_record called payload.to_vec() on every non-FPI record to
type-unify with the LZ4 branch — an unconditional heap alloc per append
in disk-offload/CDC mode. Cow<[u8]> keeps the common path borrowed.

CheckpointState::advance_tick cloned the state enum every 1ms tick; all
fields are scalar so it now derives Copy.

Deep-review findings PS-F2 (P0), PS-F1, PS-F6. Format compatibility
covered by existing wal/wal_v3/checkpoint round-trip tests (70+48+11).

author: Tin Dang
drain_spsc_shared allocated two fresh Vec<ShardMessage> on every call —
it runs on the 1ms tick and every I/O select arm, so ~2K+ allocations/s
per shard even when idle. The batch buffers are now thread-local scratch
(one shard per OS thread) taken with mem::take for re-entrancy safety
and returned drained, retaining capacity across cycles.

Deep-review finding HP-F2 (P0). Behavior unchanged; covered by existing
shard unit tests.

author: Tin Dang
Frame::Double heap-allocated via format!/to_string in both the RESP2
downgrade and native RESP3 paths — on the response hot path for graph
commands and RESP3 clients. A 400-byte stack formatter (F64Display)
now renders through the same std Display impl, guaranteeing
byte-identical wire output (Display never switches to scientific
notation, unlike ryu); inf/nan use static slices.

New tests pin F64Display against format!("{}") across extremes
(f64::MAX, 5e-324 subnormal — the ~326-char worst case that guards the
buffer capacity) and assert RESP2/RESP3 wire bytes.

Deep-review finding HP-F8 (P1).

author: Tin Dang
…iltered

Three per-query allocations introduced with SQ8 (PR #166) removed:

- hnsw_search_filtered allocated a Vec<f32> query copy per SQ8 query,
  breaking the VEC-HNSW-03 zero-allocation guarantee. Now an inline
  SmallVec<[f32; 512]> (stack for all realistic dims).
- Both SQ8 brute-force paths (filtered + MVCC) did query_f32.to_vec()
  per search. New Sq8Query helper borrows the raw query outright for L2
  (encode side stores raw values) and inline-normalizes for the
  unit-sphere metrics.
- search_filtered allocated q_rotated + lut_buf per IVF segment inside
  the fan-out loop; hoisted above the loop, mirroring the fix already
  applied to search_mvcc.

Deep-review findings VE-F1 (P0), VE-F2 (P0), VE-F10. Recall/correctness
covered by the existing 531 vector unit tests incl. SQ8 exact-match,
cosine ranking, IP angle-ranking and persistence round-trip suites.

author: Tin Dang
drain_completions allocated two Vecs per CQE batch (decoded-CQE staging
+ event list), called on every eventfd wake and the 1ms tick fallback.
The CQE staging now lives in a persistent driver field (mem::take dance
avoids the ring/buf_ring borrow conflict), and the event list is loaned
to the event loop via take_event_scratch/return_event_scratch so the
drain loop is allocation-free at steady state. The allocating
drain_completions() remains as a thin wrapper.

Deep-review finding HP-F3 (P0). Linux-only code; verified with a cold
cargo check on the moon-dev VM.

author: Tin Dang
Linux clippy (default features) flags clippy::large_enum_variant on
Sq8Query. The 2KB inline variant is the intended stack buffer that keeps
the SQ8 per-query path heap-free; add a per-item allow with rationale.

author: Tin Dang
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9ccee44a-abc8-4c09-9ab3-c08b1a13aebb

📥 Commits

Reviewing files that changed from the base of the PR and between 2f8e437 and d8a8fff.

📒 Files selected for processing (1)
  • CHANGELOG.md
✅ Files skipped from review due to trivial changes (1)
  • CHANGELOG.md

📝 Walkthrough

Walkthrough

This PR removes per-call heap allocations across hot paths by reusing scratch buffers, introducing stack-backed/borrowed buffers and formatters, deriving Copy on small enums, and consolidating writes into single buffered syscalls.

Changes

Allocation-Free Optimization Pass

Layer / File(s) Summary
I/O Driver Buffer Infrastructure & Shard Integration
src/io/uring_driver.rs, src/shard/event_loop.rs
UringDriver gains persistent cqe_scratch and event_scratch vectors initialized at construction. Public take_event_scratch() / return_event_scratch() APIs allow the shard event loop to borrow the event buffer across nonblocking drain iterations, replacing fresh allocations. New drain_completions_into() reuses cqe_scratch internally and emits events into the borrowed buffer.
Persistence Layer Optimizations
src/persistence/checkpoint.rs, src/persistence/wal.rs, src/persistence/wal_v3/record.rs
CheckpointState derives Copy to eliminate clone() during hot-path state matching. WalWriter adds frame_buf staging buffer to assemble and emit WAL v2 block frames (header + payload + CRC) as a single write_all syscall. WAL v3 write_wal_v3_record uses Cow<[u8]> to borrow non-compressed payloads directly instead of allocating copies.
SPSC Message Handler Thread-Local Reuse
src/shard/spsc_handler.rs
drain_spsc_shared switches from allocating fresh Vec<ShardMessage> pairs on every call to per-thread thread_local! scratch buffers swapped with mem::take() and reused across drain cycles.
Protocol Float Serialization with Stack Formatter
src/protocol/serialize.rs
Introduces F64Display stack-allocated formatter producing format!()-identical f64 output without heap allocation. RESP2 and RESP3 serializers use F64Display for finite doubles and static inf/-inf/nan byte slices. Test suite validates output equivalence across representative and extreme float values.
Storage Lookup & Eviction Optimizations
src/storage/db.rs, src/storage/eviction.rs
Database::get refactored to classify key state (Live/Expired/Absent) from a single DashTable probe, avoiding re-lookups on the hot path. EvictionPolicy derives Copy and replaces per-call to_ascii_lowercase() with allocation-free static-table lookup using eq_ignore_ascii_case().
Vector Search Buffer Reuse & Stack Queries
src/vector/hnsw/search.rs, src/vector/segment/holder.rs, src/vector/segment/mutable.rs
HNSW SQ8 query buffer uses SmallVec<[f32; 512]> for heap-free queries up to 512 dimensions. SegmentHolder allocates IVF rotation and LUT buffers once per search and reuses them across segments. MutableSegment introduces Sq8Query helper enum for stack-backed normalized queries without per-call allocation, supporting both L2 (borrowed) and unit-sphere (normalized) distance metrics.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested labels

enhancement

Poem

🐰 I tidy up the noisy heap,
I stash the buffers, save each keep.
SmallVecs hum and copies fly light,
One write, one tick, the drains take flight.
A rabbit nods — the hot path sleeps tonight.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title concisely summarizes the main change: deep-review hot-path optimizations across multiple subsystems (storage, WAL, SPSC, protocol, vector, io_uring).
Description check ✅ Passed The description is comprehensive and complete, covering all required template sections: a clear summary paragraph, a performance benchmark table, detailed changes across subsystems, validation results, and deferred follow-up work. All checklist items are addressed.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/deep-review-optimizations

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
src/protocol/serialize.rs (1)

702-714: 💤 Low value

Consider adding direct wire tests for NaN serialization.

The finite and infinity paths are well-tested, but there's no direct assertion that Frame::Double(f64::NAN) produces the expected wire bytes ($3\r\nnan\r\n for RESP2, ,nan\r\n for RESP3). Round-trip tests don't work for NaN due to NaN != NaN, so a direct wire-format assertion would close this gap.

🧪 Optional test to cover NaN wire format
#[test]
fn test_serialize_double_nan_wire_format() {
    // RESP2: NaN → BulkString "nan"
    let buf = serialize_frame(&Frame::Double(f64::NAN));
    assert_eq!(&buf[..], b"$3\r\nnan\r\n");

    // RESP3: NaN → native double "nan"
    let buf3 = serialize_resp3_frame(&Frame::Double(f64::NAN));
    assert_eq!(&buf3[..], b",nan\r\n");
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/protocol/serialize.rs` around lines 702 - 714, Add a direct wire-format
unit test for NaN by creating a test (e.g.,
test_serialize_double_nan_wire_format) that calls
serialize_frame(&Frame::Double(f64::NAN)) and asserts the returned bytes equal
b"$3\r\nnan\r\n", and also calls serialize_resp3_frame(&Frame::Double(f64::NAN))
and asserts the returned bytes equal b",nan\r\n"; reference the existing helpers
serialize_frame, serialize_resp3_frame and the Frame::Double variant so the test
mirrors the other double serialization tests.
src/io/uring_driver.rs (1)

302-303: ⚡ Quick win

Size the scratch buffers from the configured ring depth.

ring_size is user-configurable, but both scratch vecs always start at 256. Any shard running with a larger ring will reallocate on its first busy batch, which defeats the steady-state allocation win this change is aiming for. cqe_scratch should track CQ depth (ring_size * 2), and event_scratch likely wants one more step of headroom because some CQEs emit two IoEvents.

Suggested sizing change
-        Ok(Self {
+        let cqe_scratch_cap = (config.ring_size as usize).saturating_mul(2);
+        let event_scratch_cap = cqe_scratch_cap.saturating_mul(2);
+        Ok(Self {
             ring,
             fd_table,
             buf_ring,
             send_buf_pool,
             connections: HashMap::new(),
@@
             pending_sqes: 0,
             tick: 0,
             cqe_eventfd: efd,
-            cqe_scratch: Vec::with_capacity(256),
-            event_scratch: Vec::with_capacity(256),
+            cqe_scratch: Vec::with_capacity(cqe_scratch_cap),
+            event_scratch: Vec::with_capacity(event_scratch_cap),
         })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/io/uring_driver.rs` around lines 302 - 303, The scratch buffers are
hard-coded to 256 which will reallocate when ring_size is larger; change the
initial capacities to be derived from the configured ring_size: set cqe_scratch
capacity to ring_size * 2 (to match CQ depth) and size event_scratch with extra
headroom (e.g., ring_size * 3 or ring_size * 2 + ring_size/2) so it can
accommodate CQEs that emit up to two IoEvent entries; update the
Vec::with_capacity calls that initialize cqe_scratch and event_scratch to use
these ring_size-based capacities.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/io/uring_driver.rs`:
- Around line 618-620: The drain_completions() helper currently allocates a
fresh Vec via Vec::new() which breaks scratch-buffer reuse; change it to reuse
the existing event_scratch buffer (or at minimum seed capacity from it) and then
call drain_completions_into(&mut events). Specifically, in the drain_completions
method replace the new Vec creation with either taking/clearing
self.event_scratch (e.g., swap or drain into a local Vec) or creating events
with Vec::with_capacity(self.event_scratch.capacity()) so the call to
drain_completions_into(&mut events) reuses retained capacity and avoids
allocating on each call; reference drain_completions, drain_completions_into,
and the event_scratch field to locate the code.

---

Nitpick comments:
In `@src/io/uring_driver.rs`:
- Around line 302-303: The scratch buffers are hard-coded to 256 which will
reallocate when ring_size is larger; change the initial capacities to be derived
from the configured ring_size: set cqe_scratch capacity to ring_size * 2 (to
match CQ depth) and size event_scratch with extra headroom (e.g., ring_size * 3
or ring_size * 2 + ring_size/2) so it can accommodate CQEs that emit up to two
IoEvent entries; update the Vec::with_capacity calls that initialize cqe_scratch
and event_scratch to use these ring_size-based capacities.

In `@src/protocol/serialize.rs`:
- Around line 702-714: Add a direct wire-format unit test for NaN by creating a
test (e.g., test_serialize_double_nan_wire_format) that calls
serialize_frame(&Frame::Double(f64::NAN)) and asserts the returned bytes equal
b"$3\r\nnan\r\n", and also calls serialize_resp3_frame(&Frame::Double(f64::NAN))
and asserts the returned bytes equal b",nan\r\n"; reference the existing helpers
serialize_frame, serialize_resp3_frame and the Frame::Double variant so the test
mirrors the other double serialization tests.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ee4dd505-b4ef-41a4-8f9d-465adc6cdf00

📥 Commits

Reviewing files that changed from the base of the PR and between 6a8dcb0 and 2f8e437.

📒 Files selected for processing (12)
  • src/io/uring_driver.rs
  • src/persistence/checkpoint.rs
  • src/persistence/wal.rs
  • src/persistence/wal_v3/record.rs
  • src/protocol/serialize.rs
  • src/shard/event_loop.rs
  • src/shard/spsc_handler.rs
  • src/storage/db.rs
  • src/storage/eviction.rs
  • src/vector/hnsw/search.rs
  • src/vector/segment/holder.rs
  • src/vector/segment/mutable.rs

Comment thread src/io/uring_driver.rs
Comment on lines +618 to +620
pub fn drain_completions(&mut self) -> Vec<IoEvent> {
let mut events = Vec::new();
self.drain_completions_into(&mut events);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

drain_completions() reintroduces a fresh Vec allocation.

This helper now allocates a new zero-capacity buffer on every call, which bypasses the scratch-buffer reuse added elsewhere and violates the repo rule for src/io/**. Reuse event_scratch here too, or at least seed from its retained capacity instead of Vec::new(). As per coding guidelines, src/io/**/*.rs must not use Vec::new() in I/O-path code.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/io/uring_driver.rs` around lines 618 - 620, The drain_completions()
helper currently allocates a fresh Vec via Vec::new() which breaks
scratch-buffer reuse; change it to reuse the existing event_scratch buffer (or
at minimum seed capacity from it) and then call drain_completions_into(&mut
events). Specifically, in the drain_completions method replace the new Vec
creation with either taking/clearing self.event_scratch (e.g., swap or drain
into a local Vec) or creating events with
Vec::with_capacity(self.event_scratch.capacity()) so the call to
drain_completions_into(&mut events) reuses retained capacity and avoids
allocating on each call; reference drain_completions, drain_completions_into,
and the event_scratch field to locate the code.

Source: Coding guidelines

Lint gate requires a CHANGELOG entry; documents the 11 implemented
findings and single/multi-shard benchmark outcomes.

author: Tin Dang
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.

2 participants