Skip to content

feat(collect): fused per-read SMEM collection (passes 1+2) + FFI - #32

Merged
nh13 merged 1 commit into
mainfrom
feat/v0.3-collect-core
Jun 13, 2026
Merged

feat(collect): fused per-read SMEM collection (passes 1+2) + FFI#32
nh13 merged 1 commit into
mainfrom
feat/v0.3-collect-core

Conversation

@nh13

@nh13 nh13 commented Jun 12, 2026

Copy link
Copy Markdown

v0.3 perf series — PR-D2: the fused collect_smems module (the central deliverable; built on #31's forward_truncate_below_maximal and the merged TRUNC_IV primitives).

What

Adds LearnedIndex::collect_smems + the prmi_collect_smems FFI — the native port of the consumer's zigzag SMEM-collection driver. Today the consumer crosses the C→Rust FFI boundary 91–155 times per read (stateless: each zz_left_span / zz_right_emit re-issues a cold prmi_mem_search). This collapses the whole per-read walk into one call, carrying the SA-interval state across zz_left_span → zz_right_emit → next-step in-register — the thing the FFI boundary forces cold.

The per-call search cost is already at parity with bwa-meme (MODE2: prmi 7.0 vs 7.76 probes/call); the entire residual was call count (bwa-meme ~53). Fusing the walk in-process is what collapses it.

How

Built entirely on the merged, byte-identity-tested per-call primitivesmem_search, mem_search_backward, mem_search_backward_truncated_span_rc (#27), forward_truncate_below_maximal (#31). No new search math. Pass 1 (zigzag, min_intv=1 = the definitional MEM set), reseed selection, pass 2 (per-pivot reseed walk), then the within-read two-stage sort. #[repr(C)] Smem mirrors the consumer's SMEM field-for-field (layout-pinned by a Rust offset test + an FFI const _ size/align assert).

Byte-identity (the safety net)

  • pass 1 == an independent definitional MEM-set oracle (enumerate windows, test left/right maximality — no shared control flow);
  • pass 2 == transcribed reseed selection + zz_step1_reseed;
  • full pipeline == the composed oracle through the two-stage sort;
  • zz_left_span / zz_right_emit each vs their own oracle; sort / overflow / determinism / thread-safety covered;
  • the FFI returns SMEMs byte-for-byte equal to the Rust path (+ empty-read, pass-3-reject, overflow contracts).

Scope — Pass 3 deferred

max_mem_intv > 0 (the pass-3 max_mem_intv strategy) needs the still-unmerged forward-routing primitive, so it is deferred: the FFI rejects max_mem_intv != 0 with rc -5, and collect_smems panics on the precondition violation (the FFI pre-checks, so the panic is unreachable on the consumer path). Passes 1+2 (max_mem_intv == 0) are byte-identical to FMI seeding today. Pass 3 lands in a follow-up (PR-D′) once the forward routing merges.

CAVEAT for the box-gate: if the consumer config sets max_mem_intv > 0 (bwa-mem ships a nonzero long-MEM-reseed default), full-SAM e2e parity is gated on the whole D→C→D′ chain — this PR's correctness rests on the passes-1+2 byte-identity proptests, not the box-gate.

Adaptations to main

Self-review

Pre-PR /coderabbitai-review: 1 actionable + 1 nitpick, both fixed — (1) upgraded the deferred-pass-3 guard from debug_assert to a hard assert so a direct Rust caller can't silently get an under-seeded result in release; (2) clarified the attrib profiling-bucket doc (slot 4 reserved for pass 3).

Summary by CodeRabbit

  • New Features

    • Added SMEM collection accessible from both C API and Rust, including C-compatible option/types and a new exported entrypoint; supports empty-read semantics and reports unsupported modes or buffer-too-small situations.
    • Native per-read SMEM discovery with learned-index traversal, reseeding passes, in-read ordering, and optional probe-count profiling.
  • Tests

    • Expanded unit and FFI tests covering correctness, edge cases, API contract, overflow handling, and panic-safety.

@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e168fffc-a512-4339-88ad-f0db41827d0f

📥 Commits

Reviewing files that changed from the base of the PR and between 5b1e836 and d97bfe9.

📒 Files selected for processing (4)
  • prmi-sys/src/lib.rs
  • prmi-sys/tests/spectrum_ffi.rs
  • prmi/src/index/collect.rs
  • prmi/src/index/mod.rs
🚧 Files skipped from review as they are similar to previous changes (3)
  • prmi/src/index/mod.rs
  • prmi-sys/src/lib.rs
  • prmi/src/index/collect.rs

📝 Walkthrough

Walkthrough

Adds fused per-read SMEM collection: a Rust zigzag implementation (pass-1 + pass-2), ABI-compatible types, a public LearnedIndex::collect_smems API, C-FFI entrypoint prmi_collect_smems, and end-to-end tests validating Rust and FFI behavior.

Changes

Per-Read SMEM Collection Engine

Layer / File(s) Summary
Data types and module structure
prmi/src/index/collect.rs, prmi/src/index/mod.rs
Introduces Smem and CollectOpts as ABI-compatible types, adds optional attrib profiling under feature spectrum-probe-count, and exports collect submodule.
Pass-1 zigzag traversal
prmi/src/index/collect.rs
Implements pass-1 via zz_step1: computes left spans (zz_left_span), clamps forward extent (fwd_qlen), emits matches (zz_right_emit), and produces unsorted SMEM candidates.
Pass-2 reseed traversal
prmi/src/index/collect.rs
Implements reseed logic: bounds reseed forward extent (reseed_bounded_fwd), computes reseed left/right spans (zz_left_span_reseed, zz_right_emit_reseed), and runs zz_step1_reseed.
Collection orchestration and integration
prmi/src/index/collect.rs
Adds LearnedIndex::collect_smems: enforces opts.max_mem_intv == 0, runs passes 1+2, sorts within-read (sort_within_read), and enforces output buffer capacity (Ok(count) or Err(needed)).
Rust-side implementation validation
prmi/src/index/collect.rs
Extensive tests validate Smem ABI/layout, emission gates, equivalence to definitional MEMs, overflow behavior, full pipeline correctness across option sweeps, and concurrency safety.
C-ABI binding and validation
prmi-sys/src/lib.rs
Adds prmi_smem_t, prmi_collect_opts_t, a layout assertion, and prmi_collect_smems with pointer/numeric validation, pac packing, pass-3 rejection (-5), unwind protection, and out-count semantics.
FFI end-to-end test
prmi-sys/tests/spectrum_ffi.rs
Adds tests comparing prmi_collect_smems output to Rust collect_smems byte-for-byte and checks empty-read/null handling, pass-3 rejection, and buffer-overflow reporting.
sequenceDiagram
  participant Caller as C Caller
  participant FFI as prmi_collect_smems
  participant RustAPI as LearnedIndex::collect_smems
  participant Pass1 as zz_step1
  participant Pass2 as zz_step1_reseed
  participant Output as Output Buffer

  Caller->>FFI: call with read, opts, packed pac, out buffer/cap
  FFI->>FFI: validate pointers/lengths, pack pac, reject max_mem_intv != 0
  FFI->>RustAPI: invoke collect_smems with decoded opts and pac
  RustAPI->>Pass1: run pass-1 zigzag collection
  Pass1->>RustAPI: emit SMEMs
  RustAPI->>Pass2: select pivots and run pass-2 reseed
  Pass2->>RustAPI: emit SMEMs
  RustAPI->>RustAPI: sort SMEMs within read
  RustAPI->>Output: copy SMEMs into caller buffer (or return needed size)
  RustAPI-->>FFI: return result (count or needed)
  FFI->>FFI: write *out_n, map to return codes (0, -4, -5, -3)
  FFI-->>Caller: return status
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • fg-labs/prmi#11: Established the C-ABI shim foundation (handle, thread-local error plumbing, prmi_open/prmi_close) that this PR extends with prmi_smem_t, prmi_collect_opts_t, and prmi_collect_smems.

Poem

🐰 I hop through zigzags, seeds in tow,

pass-one zigs and pass-two we know,
I pack the pac, validate the call,
copy the matches, one and all,
a tiny rabbit with SMEMs to show.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main change: adding fused per-read SMEM collection (passes 1+2) with FFI support, which aligns with the primary modifications across all four modified files.
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 docstrings
  • Create stacked PR
  • Commit on current branch

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.

@nh13

nh13 commented Jun 13, 2026

Copy link
Copy Markdown
Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 13, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@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 (4)
prmi/src/index/collect.rs (3)

155-158: 📐 Maintainability & Code Quality | ⚡ Quick win

Add a regression test for the direct-Rust pass-3 guard.

This public entrypoint intentionally panics on max_mem_intv != 0, but that caller path is not locked down by a focused unit test in this file. A small #[should_panic]/catch_unwind test would keep the “fail hard instead of silently under-seeding” contract from regressing.

🤖 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 `@prmi/src/index/collect.rs` around lines 155 - 158, Add a focused regression
test that verifies the public entrypoint panics when max_mem_intv != 0: call the
function collect_smems (the guarded code path) with an options struct where
opts.max_mem_intv is set > 0 and assert the panic using either #[should_panic]
on the test or std::panic::catch_unwind; ensure the test constructs the same
options type used by collect_smems and exercises the public call path so the
panic guard remains covered by CI.

159-163: 🚀 Performance & Scalability | ⚡ Quick win

Return Err(needed) before sorting the retry path.

When out is too small, this still pays the full within-read sort even though the caller has to retry anyway. The overflow contract only needs the count, so the capacity check can happen immediately after collect_smems_unsorted().

♻️ Proposed change
-        let mut smems = self.collect_smems_unsorted(read, rid, opts, pac, enc);
-        Self::sort_within_read(&mut smems);
-        if smems.len() > out.len() {
+        let mut smems = self.collect_smems_unsorted(read, rid, opts, pac, enc);
+        if smems.len() > out.len() {
             return Err(smems.len());
         }
+        Self::sort_within_read(&mut smems);
         out[..smems.len()].copy_from_slice(&smems);
         Ok(smems.len())
🤖 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 `@prmi/src/index/collect.rs` around lines 159 - 163, The current code calls
Self::sort_within_read(&mut smems) before checking capacity and returning
Err(smems.len()), wasting work when the caller must retry; move the capacity
check immediately after calling collect_smems_unsorted(read, rid, opts, pac,
enc) and return Err(smems.len()) if smems.len() > out.len() so sort_within_read
is only invoked when the output buffer is large enough (i.e., call
collect_smems_unsorted -> if smems.len() > out.len() return Err(smems.len()) ->
then call Self::sort_within_read(&mut smems) and proceed).

197-220: 🚀 Performance & Scalability | ⚡ Quick win

Pre-reserve the per-read vectors on this hot path.

collect_smems_unsorted runs once per read, but both smems and reseeds start at capacity 0 and grow via repeated reallocations. The method docs already advertise a 2 * read.len() safe output bound, and pass-2 can reserve num1, so reserving here would remove avoidable allocator churn from the path this PR is optimizing.

♻️ Proposed change
-        let mut smems: Vec<Smem> = Vec::new();
+        let mut smems: Vec<Smem> = Vec::with_capacity(rlen.saturating_mul(2));
@@
-        let mut reseeds: Vec<(usize, i64)> = Vec::new();
+        let mut reseeds: Vec<(usize, i64)> = Vec::with_capacity(num1);
🤖 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 `@prmi/src/index/collect.rs` around lines 197 - 220, Reserve the per-read
vectors to avoid reallocations: before the zigzag pass, call smems.reserve(2 *
rlen) (smems is Vec<Smem>) since the method docs guarantee a 2 * read.len()
bound; after the first pass, once num1 = smems.len() is known, call
reseeds.reserve(num1) (reseeds is Vec<(usize, i64)>) so the reseed collection
does not reallocate while being filled.
prmi-sys/tests/spectrum_ffi.rs (1)

1962-2020: 📐 Maintainability & Code Quality | ⚡ Quick win

Exercise the documented out == NULL && out_cap == 0 sizing path.

The new API explicitly special-cases a null output buffer when capacity is zero, but this test only checks overflow with a real buffer. Add a dry-run call on the non-empty read with out = ptr::null_mut() and out_cap = 0, then assert rc == -4 and out_n == rust_n so regressions in that branch are caught.

Suggested test addition
+    // Dry-run sizing path: NULL out is allowed when out_cap == 0.
+    let mut dry_run_n: i32 = -1;
+    let rc_dry_run = unsafe {
+        prmi_collect_smems(
+            handle,
+            read.as_ptr(),
+            read.len() as i32,
+            9,
+            &copts,
+            pac_packed.as_ptr(),
+            pnb,
+            ptr::null_mut(),
+            0,
+            &mut dry_run_n,
+        )
+    };
+    assert_eq!(rc_dry_run, -4, "NULL/0 dry-run must report required capacity");
+    assert_eq!(dry_run_n as usize, rust_n, "dry-run must report the exact SMEM count");
+
     // Overflow: out_cap one short -> -4, out_n set to the required count.

Based on the new FFI contract in prmi-sys/src/lib.rs, out may be null iff out_cap == 0.

🤖 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 `@prmi-sys/tests/spectrum_ffi.rs` around lines 1962 - 2020, Add a dry-run
invocation of prmi_collect_smems that exercises the documented "out == NULL &&
out_cap == 0" sizing path: call prmi_collect_smems with the existing non-empty
read buffer (read.as_ptr(), read.len() as i32), copts, pac_packed.as_ptr(), pnb,
out = ptr::null_mut(), out_cap = 0 and a new out_n (e.g., dry_n) initialized to
-1; then assert the return code is -4 and that dry_n equals rust_n. Place this
before or alongside the existing overflow test so regressions in
prmi_collect_smems’s null-out sizing branch are caught (refer to
prmi_collect_smems, read, rust_n, and the overflow assertions for placement and
naming).
🤖 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 `@prmi-sys/src/lib.rs`:
- Around line 1877-1884: The entrypoint prmi_collect_smems currently rejects any
NULL `read` pointer unconditionally; change the null-pointer validation so
`read` is allowed to be NULL when `read_len == 0` (i.e. treat NULL+0 as a valid
empty slice). Concretely, replace the `read.is_null()` requirement with a check
that fails only if `read.is_null() && read_len != 0`, keeping the other null
checks for `handle`, `opts`, `pac`, and `out_n` and preserving the existing
negative-length/out-cap validations (`read_len < 0 || out_cap < 0`).

---

Nitpick comments:
In `@prmi-sys/tests/spectrum_ffi.rs`:
- Around line 1962-2020: Add a dry-run invocation of prmi_collect_smems that
exercises the documented "out == NULL && out_cap == 0" sizing path: call
prmi_collect_smems with the existing non-empty read buffer (read.as_ptr(),
read.len() as i32), copts, pac_packed.as_ptr(), pnb, out = ptr::null_mut(),
out_cap = 0 and a new out_n (e.g., dry_n) initialized to -1; then assert the
return code is -4 and that dry_n equals rust_n. Place this before or alongside
the existing overflow test so regressions in prmi_collect_smems’s null-out
sizing branch are caught (refer to prmi_collect_smems, read, rust_n, and the
overflow assertions for placement and naming).

In `@prmi/src/index/collect.rs`:
- Around line 155-158: Add a focused regression test that verifies the public
entrypoint panics when max_mem_intv != 0: call the function collect_smems (the
guarded code path) with an options struct where opts.max_mem_intv is set > 0 and
assert the panic using either #[should_panic] on the test or
std::panic::catch_unwind; ensure the test constructs the same options type used
by collect_smems and exercises the public call path so the panic guard remains
covered by CI.
- Around line 159-163: The current code calls Self::sort_within_read(&mut smems)
before checking capacity and returning Err(smems.len()), wasting work when the
caller must retry; move the capacity check immediately after calling
collect_smems_unsorted(read, rid, opts, pac, enc) and return Err(smems.len()) if
smems.len() > out.len() so sort_within_read is only invoked when the output
buffer is large enough (i.e., call collect_smems_unsorted -> if smems.len() >
out.len() return Err(smems.len()) -> then call Self::sort_within_read(&mut
smems) and proceed).
- Around line 197-220: Reserve the per-read vectors to avoid reallocations:
before the zigzag pass, call smems.reserve(2 * rlen) (smems is Vec<Smem>) since
the method docs guarantee a 2 * read.len() bound; after the first pass, once
num1 = smems.len() is known, call reseeds.reserve(num1) (reseeds is Vec<(usize,
i64)>) so the reseed collection does not reallocate while being filled.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 64928c79-89ef-429f-87bf-93fb009f05f4

📥 Commits

Reviewing files that changed from the base of the PR and between 309c793 and 5b1e836.

📒 Files selected for processing (4)
  • prmi-sys/src/lib.rs
  • prmi-sys/tests/spectrum_ffi.rs
  • prmi/src/index/collect.rs
  • prmi/src/index/mod.rs

Comment thread prmi-sys/src/lib.rs Outdated
Add `LearnedIndex::collect_smems` and the `prmi_collect_smems` FFI: the native
port of the consumer's zigzag SMEM-collection driver, collapsing the per-read
walk (91-155 stateless C->Rust FFI crossings) into ONE call. The per-call search
cost is already at parity with bwa-meme; the residual is call COUNT, and running
the zigzag in-process lets the SA-interval state cross zz_left_span ->
zz_right_emit -> next-step in-register (the thing the FFI boundary forces cold).

Built entirely on the merged, byte-identity-tested per-call primitives
(mem_search, mem_search_backward, mem_search_backward_truncated_span_rc,
forward_truncate_below_maximal) -- no new search math. Pass 1 (zigzag, min_intv=1
== the definitional MEM set), reseed selection, and pass 2 (per-pivot reseed
walk), then the within-read two-stage sort. `#[repr(C)] Smem` mirrors the
consumer's SMEM field-for-field (layout-pinned).

Adaptations to main: `zz_left_span` uses `mem_search_backward(0,1,1,..)` (the
byte-identical match_len of the perf `mem_search_backward_impl(..,false)`, one
extra discarded gallop); `zz_left_span_reseed` uses main's 7-arg `_span_rc`.

Pass 3 (max_mem_intv > 0) is DEFERRED -- it needs the still-unmerged forward
routing. The FFI rejects max_mem_intv != 0 with rc -5 (passes 1+2 are
byte-identical for max_mem_intv == 0); `collect_smems` documents the precondition.

Byte-identity: pass-1 == independent MEM-set oracle; pass-2 == transcribed reseed
selection + zz_step1_reseed; full pipeline == composed oracle; sort/overflow/
determinism/thread-safety covered; the FFI returns SMEMs byte-for-byte equal to
the Rust path (+ empty-read, pass-3-reject -5, overflow -4 contracts).
@nh13
nh13 force-pushed the feat/v0.3-collect-core branch from 5b1e836 to d97bfe9 Compare June 13, 2026 03:54
@nh13

nh13 commented Jun 13, 2026

Copy link
Copy Markdown
Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 13, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@nh13
nh13 merged commit b658018 into main Jun 13, 2026
4 checks passed
@nh13
nh13 deleted the feat/v0.3-collect-core branch June 13, 2026 05:03
nh13 added a commit that referenced this pull request Jun 14, 2026
Extend the cpp_caller FFI smoke with a prmi_collect_smems block: it covers the
fused per-read SMEM collector's shape (no prior coverage) AND demonstrates the
idiomatic C calling pattern for a consumer adopting it —

  - prmi_collect_opts_t setup (min_seed_len/split_len/split_width/max_mem_intv,
    with max_mem_intv > 0 enabling pass 3);
  - the out_cap=0 size-probe (rc -4 with *out_n = required count);
  - the grow-on-(-4) retry loop (resize to *out_n, retry) — the buffer-sizing
    contract a C caller must implement;
  - iterating the returned prmi_smem_t[] and validating each (rid/span/occ).

Built against the cbindgen-generated prmi.h (the collect symbols land via the
#32 FFI). run_smoke.sh passes: size-probe rc=-4 needed=1, then one well-formed
SMEM spanning the reference-derived read. -Wall -Wextra clean.
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