goankur opened a new pull request, #16666:
URL: https://github.com/apache/lucene/pull/16666

   ### Title:
   
   (Implemented with AI)
   Sandbox `IoUringDirectory`: batched `O_DIRECT` rerank reads via io_uring
   
   ### Depends on #16656
   
   This builds on #16656 and will not compile without it: `UringInput implements
   org.apache.lucene.store.ParallelVectorReadable`, the capability interface 
added there. #16656
   supplies the search-path wiring (`FlatVectorsReader.readRawVectors` → the 
codec's
   `ParallelVectorReadable` check); this PR only adds a second storage backend 
behind that same hook,
   so the two can be reviewed independently. **Nothing outside 
`lucene/sandbox/` is touched.**
   
   ### Description
   
   In #16656 a rerank shortlist is fetched by fanning ~350–500 `pread` calls 
across a read thread
   pool. That works, but each read costs a syscall, a thread hand-off, and a 
blocked worker. io_uring
   replaces the whole pattern: one thread prepares the entire shortlist as SQEs 
and issues a single
   `io_uring_enter` (submit-and-wait) against a pre-registered `O_DIRECT` 
buffer, then reaps
   completions in a batch. No per-read syscall, no cross-thread dispatch, and 
no read-thread pool at
   all.
   
   - **`IoUringDirectory`** (`FilterDirectory`): serves only `.vec` through 
io_uring; every other file
     goes to the wrapped `FSDirectory` and stays page-cached. Opt-in via 
constructor or
     `-Dlucene.store.ioUring=true`, and a **transparent pass-through** when a 
recent kernel or
     `liburing-ffi` is missing — io_uring is never a hard dependency of a 
running Lucene.
   - **`IoUring`** (package-private engine): pure-FFM binding of `liburing-ffi` 
— no JNI and no
     third-party Java wrapper; liburing owns the ring mmap, memory ordering and 
arch specifics. Rings
     come from a **bounded pool** (sized to read concurrency, not to how many 
threads ever reranked),
     and the `O_DIRECT` fd is owned by the `IndexInput`, so a merged-away 
segment's reader releases the
     file instead of pinning it for the directory's lifetime.
   
   ### Why this was worth doing: the bottleneck was neither CPU nor the device
   
   The pread path was measured at its own SLA point (130 QPS Poisson) and the 
machine looked *idle*:
   
   | Signal | Measured | Reading |
   |---|---|---|
   | `mpstat` | 31% busy, **50% idle**, iowait 18.7% | not CPU-bound |
   | PSI `cpu.pressure` | `some` 5% | threads rarely queue for a core |
   | PSI `io.pressure` | `some` ~24%, **`full` ~19%** | for ~19% of wall time 
*no* runnable task could run — all waiting on I/O |
   | eBPF `offcputime --state 2` | **82.8 thread-seconds blocked in a 12 s 
window** (~6.9 threads parked), 81% in the read pool | stack: `pread64 → 
xfs_file_dio_read → iomap_dio_rw → io_schedule_timeout` |
   | `iostat` vs `fio` ceiling | ~47k of 136k IOPS | device had ~3× headroom |
   
   So the loss was *waiting*, and faster storage would not have fixed it. Worth 
recording because an
   on-CPU profile says the opposite: `cpu-clock` attributed **79.8%** of 
samples to stage-1 scoring and
   only **11.4%** to the read path, while a **wall-clock** profile of the same 
run attributed **3.6%**
   and **78.1%** respectively. cpu-clock cannot see a blocked thread, so it 
hides exactly the time that
   sets p99. `perf stat` then showed the plumbing directly: **188k 
context-switches/s against 91k
   `pread64`/s ≈ 2.3 thread swaps per 4 KB read**, pure dispatch overhead.
   
   ### Benchmark setup
   
   - **Box**: AWS g6.4xlarge — 16 vCPU AMD EPYC 7R13 (Zen 3), 60 GB RAM, local 
NVMe instance store
     measured with `fio` at **136k random-4KB read IOPS / 1.1 GB/s**.
   - **Data**: 25M Cohere-v3 Wikipedia embeddings, 1024-dim fp32; 1-bit BBQ + 
fp32 rerank; HNSW
     maxConn 64 / beamWidth 250; **102 GB index**.
   - **Larger-than-RAM**: searcher pinned to a **10 GB cgroup v2 hard limit** 
(swap off, 4 GB heap), so
     the graph and quantized codes stay resident and every rerank read hits the 
SSD — ~10× the cap.
   - **Queries**: 10,000, recall measured against exact top-100 ground truth 
(GPU brute force) on every
     configuration, so no result here trades accuracy for speed. Page cache 
dropped before each run.
   - **Single-stream**: run to completion, per-query latency.
   - **Concurrent**: open-loop with **Poisson arrivals**, non-blocking 
dispatch, a **bounded** handler
     pool (32 threads = 2× cores, 32-deep queue) and **load shedding** rather 
than unbounded queueing;
     latency measured from *intended* arrival time (coordinated-omission-safe); 
120 s per rate.
     **SLA-QPS is the highest rate holding p99 ≤ 50 ms with ≤ 0.1% shed.**
   
   ### Results
   
   Single-stream, oversample 5 / fanout 100, 4 KB-aligned index — same recall, 
same read count, only
   the submission mechanism differs:
   
   | Metric | pread pool (#16656) | io_uring (this PR) |
   |---|---|---|
   | p99 latency | 18.3 ms | **14.1 ms** (−23%) |
   | 10k queries wall-clock | 148 s | **110 s** |
   | recall | 0.967 | 0.967 |
   | avg queue depth | 3.8 | **7.6** |
   | read IOPS / avg read size | 35.1k / 4 KB | 42.9k / 4 KB |
   
   Concurrent, oversample 3.5 / fanout 25 (the recall-tuned operating point):
   
   | Metric | pread pool | io_uring |
   |---|---|---|
   | **SLA-QPS** (p99 ≤ 50 ms, shed ≤ 0.1%) | ~130 | **~250 (+92%)** |
   | p99 @ 240 QPS (fixed-rate diagnostic) | 46.8 ms | **19.7 ms** |
   | context-switches @ 240 QPS | ~188k/s | **~33k/s** |
   | `pread64` syscalls @ 240 QPS | ~91k/s | ~0 (`io_uring_enter` ~7k/s) |
   | CPU used @ 240 QPS (`perf stat`) | 10.3 of 16 cores | **8.2 of 16 cores** |
   | recall | 0.937 | 0.937 |
   
   io_uring holds p99 ≤ 50 ms through 250 QPS (36.2 ms @190, 42.7 @230, 48.6 
@250) with **zero
   shedding at every rate**, failing only at 270 (62.9 ms) — while the pread 
path crosses 50 ms by
   ~150 QPS and sheds 14% at 170. It is also *cheaper*: better latency on ~2 
fewer cores.
   
   ### Testing
   
   `TestIoUringDirectory` extends **`BaseDirectoryTestCase`**, so the full 
`Directory`/clone/slice
   contract runs against this directory (2 tests skip because a 
`FilterDirectory` is not an
   `FSDirectory` subclass — the same two that upstream `TestDirectIODirectory` 
skips). On top of that,
   8 targeted tests: randomized batch equivalence (random sizes, repeated and 
unordered positions,
   batches larger than the queue depth), unaligned positions, agreement with 
`MMapDirectory`,
   concurrency over a deliberately undersized 2-ring pool, reopen-after-close, 
disabled pass-through,
   merge-context exclusion, and dims of 1/512/1025/2048/3000 to exercise 
multi-block spans.
   
   Two invariants that only fail in rare conditions are asserted through 
package-private counters and
   were **mutation-verified** — each defect was reinstated to confirm the test 
fails, then reverted:
   a batch that aborts must destroy its ring rather than return it to the pool 
(`ringCount`), and
   closing an input must release its descriptor (`openFdCount`). Verified on 
**x86_64 and aarch64**
   (different `O_DIRECT` flag values); `./gradlew tidy check` is clean.
   
   ### Notes for reviewers
   
   - **Runtime native dependency.** This needs `liburing-ffi.so.2` present to 
do anything, which is
     unlike anything else Lucene ships. It is opt-in, absent-safe, and 
quarantined in sandbox — but
     whether Lucene wants it *at all*, versus an external module, is a fair 
question and I have no
     stake in the answer.
   - **Why not `NativeAccess`?** That SPI is hint-oriented 
(`madvise`/`fadvise`) rather than a general
     downcall framework, so io_uring does not fit it. The FFM style here 
deliberately mirrors
     `PosixNativeAccess`: `MH$name` handles, `invokeExact` in a try/catch that 
throws `AssertionError`,
     `UnsupportedOperationException` for a missing symbol.
   - **O_DIRECT is verified, not assumed.** `open(2)` may ignore an 
unrecognised `O_DIRECT` flag on an
     unfamiliar architecture and silently give buffered reads, so `openDirect` 
confirms with
     `fcntl(F_GETFL)` and falls back to the delegate if the kernel did not 
honour it.
   - **Alignment handling.** O_DIRECT requires block-aligned offset, length 
*and* buffer, so each
     vector is fetched as the aligned span containing it and copied out at its 
offset within that span.
     Unaligned vector data therefore costs one extra block per vector — which 
is why #16656's 4 KB
     alignment halves the bytes read rather than merely tidying the layout.
   - **Memory.** Each ring registers `queueDepth × 4 KB`; the default 256/64 
can hold ~64 MB of
     registered buffers off-heap. Size `maxRings` to the read concurrency you 
want.
   - **Known gaps** (why this is sandbox, not core): no deterministic test 
forces the
     partially-reaped-submission case that the ring-discard logic guards; 
NRT/merge behaviour under
     sustained churn is untested at scale; there is no minimum kernel/liburing 
version policy yet; and
     only the `.vec` file is served this way.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to