Skip to content

MemorySegmentIndexInput: always prefetch on RANDOM mode - #16145

Open
michaeljmarshall wants to merge 8 commits into
apache:mainfrom
michaeljmarshall:always-prefetch-on-random
Open

MemorySegmentIndexInput: always prefetch on RANDOM mode#16145
michaeljmarshall wants to merge 8 commits into
apache:mainfrom
michaeljmarshall:always-prefetch-on-random

Conversation

@michaeljmarshall

Copy link
Copy Markdown
Member

Description

The power-of-two back off in MemorySegmentIndexInput.prefetch() assumes consecutive page-cache hits suggest the file is warming up, so madvise overhead can be skipped until the next power-of-two call. That assumption breaks for ReadAdvice.RANDOM, where each request accesses unpredictable pages — a warm page in the past says nothing about whether the next page is warm (e.g. HNSW graph traversal touches a different path per query).

Add a volatile boolean isRandom field, set by updateReadAdvice() and also by slice(String, long, long, IOContext), the primary entry point for HNSW vector files, and skip the backoff in prefetch() when it is true and also skip resetting the sharedPrefetchCounter.

The one drawback: for RANDOM-advised files that are fully warm in the page cache, isLoaded() is now called on every prefetch rather than being throttled. In practice this seems acceptable because isLoaded() on warm pages should be cheap, and a fully warm RANDOM file means prefetch will now return a more accurate answer than before.

On x86 (TSO) a volatile load before LOCK XADD requires no hardware fence and costs nothing measurable. On ARM the ldar before ldadd adds a small ordering cost, still dwarfed by the atomic itself. The miss-reset lambda returns to a plain set(0) with no special-casing.

I used claude to generate the tests and then I manually reviewed them.

I also considered encoding RANDOM mode as a negative sentinel in the counter itself (Integer.MIN_VALUE) to avoid any new field. Rejected: the counter walks toward zero on every getAndIncrement(), losing RANDOM mode after ~2B calls without a miss; preserving it through misses required a CAS loop (getAndUpdate) in the miss handler, adding complexity that doesn't seem necessary.

The power-of-two back off in MemorySegmentIndexInput.prefetch() assumes
consecutive page-cache hits suggest the file is warming up,
so madvise overhead can be skipped until the next power-of-two call.
That assumption breaks for ReadAdvice.RANDOM, where each request
accesses unpredictable pages — a warm page in the past says nothing
about whether the next page is warm (e.g. HNSW graph traversal touches
a different path per query).

Add a `volatile boolean isRandom` field, set by updateReadAdvice() and
also by slice(String, long, long, IOContext), the primary entry point
for HNSW vector files, and skip the backoff in prefetch() when it is
true.

The one drawback: for RANDOM-advised files that are fully warm in the
page cache, isLoaded() is now called on every prefetch rather than being
throttled. In practice this seems acceptable because isLoaded() on warm
pages should be cheap, and a fully warm RANDOM file means prefetch will
now return a more accurate answer than before.

On x86 (TSO) a volatile load before LOCK XADD requires no hardware
fence and costs nothing measurable. On ARM the ldar before ldadd adds
a small ordering cost, still dwarfed by the atomic itself. The miss-
reset lambda returns to a plain set(0) with no special-casing.

I used claude to generate the tests and then I manually reviewed them.

I also considered encoding RANDOM mode as a negative sentinel in the
counter itself (Integer.MIN_VALUE) to avoid any new field. Rejected: the
counter walks toward zero on every getAndIncrement(), losing RANDOM mode
after ~2B calls without a miss; preserving it through misses required
a CAS loop (getAndUpdate) in the miss handler, adding complexity
that doesn't seem necessary.
The miss handler in prefetch() was calling sharedPrefetchCounter.set(0)
unconditionally, even in RANDOM mode where the counter is never read.
This was inconsistent with the isRandom short-circuit that already
prevents getAndIncrement() from running in RANDOM mode. Guard the reset
with `if (isRandom == false)` so the counter is completely untouched
when RANDOM advice is active.
@github-actions github-actions Bot added this to the 11.0.0 milestone May 28, 2026
When NATIVE_ACCESS.isEmpty() returns true, we don't ever update
isRandom. This isn't a true problem with the current usage, but in order
to ensure the boolean is set correctly, we can move the assignment to
before the NATIVE_ACCESS.isEmpty() check.
Comment thread lucene/CHANGES.txt
Comment on lines -610 to 648
MemorySegmentIndexInput slice = slice(sliceDescription, offset, length);
ReadAdvice advice = toReadAdvice.apply(context);
MemorySegmentIndexInput slice =
buildSlice(sliceDescription, offset, length, advice == ReadAdvice.RANDOM);
if (NATIVE_ACCESS.isPresent() && advice != ReadAdvice.NORMAL) {
// No need to madvise with a normal advice, since it's the OS' default.
final NativeAccess nativeAccess = NATIVE_ACCESS.get();
if (length >= nativeAccess.getPageSize()) {
// Only set the read advice if the inner file is large enough. Otherwise the cons are likely
// outweighing the pros as we're:
// - potentially overriding the advice of other files that share the same pages,
// - paying the cost of a madvise system call for little value.
// We could align inner files with the page size to avoid the first issue, but again the
// pros don't clearly overweigh the cons.
slice.advise(
0,
slice.length,
segment -> {
nativeAccess.madvise(segment, advice);
return true;
});
}
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is actually wrong in a subtle way. A slice can pull in a subset of segments from the parent, then result in different advice, which ultimately modifies the internal advice, and can therefore lead to out of sync values for isRandom. As such, this extra metadata needs rethinking.

@navneet1v

Copy link
Copy Markdown
Contributor

@michaeljmarshall did we see any improvements after adding this change?

@@ -349,8 +359,12 @@
length,
segment -> {
if (segment.isLoaded() == false) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This could be avoided too in the random case since we don't use this information?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I think it makes sense to remove this check when we have advised RANDOM, but note that we do use the isLoaded() information to determine whether to call madvise and whether to return true or false where true indicates the caller should defer access to the prefetched page. From the method's javadoc:

   * @return true if prefetch actually prefetched something, hence user can benefit from deferring
   *     reading the prefetched memory block.

@uschindler

uschindler commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Why does the field need to be volatile at all? It is per-instance and each instance should only be used from one thread. This is also true for random access slices because some implementations of RandomAccess are NOT thread-safe (e.g., the default impl used by NIOFSDirectory).

Documentation:

/**
* Random Access Index API. Unlike {@link IndexInput}, this has no concept of file position, all
* reads are absolute. However, like IndexInput, it is only intended for use by a single thread.
*/

@mikemccand

Copy link
Copy Markdown
Member

I like the overall idea here, to turn off the exponential backoff behavior for the MADV_WILLNEED call when we MADV_RANDOM'd this slice. But we need to be a bit careful -- isLoaded() is a surprisingly costly API, at least on Linux. It malloc()s a byte[] (one byte per page), then calls mincore, which is a walk through kernel's virtual address mapping / tree (likely costly, not sure). Then a for loop checking if there are any 0 in the returned byte[], then free it and return.

The one drawback: for RANDOM-advised files that are fully warm in the page cache, isLoaded() is now called on every prefetch rather than being throttled. In practice this seems acceptable because isLoaded() on warm pages should be cheap, and a fully warm RANDOM file means prefetch will now return a more accurate answer than before.

Strangely, it's the opposite: warm / hot pages are more costly than the cold case, because it cannot early-break that for loop when all bytes are 1.

I think we can turn off all the isLoaded() checking entirely when MADV_RANDOM? (I think that's also @jimczi comment above?).

@neoremind

neoremind commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

I spent some time spinning up a JMH benchmark to simulate and vet.

The setup is an 8G file on my EC2 m5.2xlarge (8 vCPU, 16G mem, io2 EBS with ~338us 4K random read latency direct io bypass page cache verified with fio); openjdk "25.0.2" 2026-01-20.

The idea is: each read (4k/8k/16k) randomly picks between a small hot region (16MB, stays cached) and a random offset across the full 8G file. I assume this could roughly mimic HNSW scenario, some nodes are warm while others are deep in the graph and cold. Kernal page cache is cleared before each iteration. Test run for 6s for each iter, not enough time to fully warm page cache.

Benchmark results (focus on 4k read)

With this PR, compare all hint modes:

coldReadPct noPrefetch Normal Sequential Random
10% 81 us 82 us 86 us 40 us
50% 400 us 334 us 328 us 188 us
90% 734 us 349 us 345 us 348 us

With PR vs. without PR comparison:

coldReadPct Without PR With PR Speed up
10% 66 us 40 us 1.7x faster
50% 270 us 188 us 1.4x faster
90% 361 us 348 us almost same

Fully warm page cache (no page cache clear before each iter), with PR applied:

coldReadPct noPrefetch / Normal / Sequential Random
10% ~0.30 us 1.37 us
50% ~0.47 us 1.54 us
90% ~0.61 us 1.64 us

Key findings

  1. The change does help random access pattern. At 10%/50% cold reads, it is ~1.7x/~1.4x faster than without the PR (40us/188us vs. 66us/270us). It is because the backoff logic using shared counter skips madvise when warm hits are often at certain time period, making prefetch a no-op. What's worth noting is, even without this PR, the RANDOM hint already helps compared to noPrefetch and NORMAL, the existing backoff does let some madvise calls through by chance, this PR makes RANDOM access faster with consistent prefetch.

  2. At 90% cold, no improvement, because cold reads keep resetting the shared counter to 0, so the backoff never kicks in. The problem only stands when warm hits push the shared counter away before a cold read arrives.

  3. In page cache fully warm case, there is indeed overhead, just ~1.1us per read call. This aligns with what @mikemccand points out isLoaded() is somewhat costly. But this is the tradeoff, as long as there are any cold pages, the savings of prefetching pages outweigh this 1.1us overhead, and the net win is bigger with more cold reads. As @mikemccand, @jimczi point out, if we remove isLoaded(), we also have to verify the overhead of isLoaded() probe vs. always madvise?

Note that this is a microbenchmark, I think it would be more sound to vet with a real-world workload like in HNSW scenario, but the direction is positive.

JMH result details

candidate benchmark (clear page cache - mimic cold page access)
Benchmark                                     (coldReadPct)                   (dataDir)              (fileName)  (fileSizeGB)  (hotRegionMB)  (prefetchLength)  Mode  Cnt    Score      Error  Units
PrefetchBenchmark.noPrefetchReadRandom                   10  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              4096  avgt    3   80.835 ±   70.514  us/op
PrefetchBenchmark.noPrefetchReadRandom                   10  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              8192  avgt    3   80.708 ±  119.700  us/op
PrefetchBenchmark.noPrefetchReadRandom                   10  /home/ec2-user/environment  prefetch_bench_data_8G             8             16             16384  avgt    3   82.326 ±   80.527  us/op
PrefetchBenchmark.noPrefetchReadRandom                   50  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              4096  avgt    3  399.633 ±  419.646  us/op
PrefetchBenchmark.noPrefetchReadRandom                   50  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              8192  avgt    3  408.596 ±  326.960  us/op
PrefetchBenchmark.noPrefetchReadRandom                   50  /home/ec2-user/environment  prefetch_bench_data_8G             8             16             16384  avgt    3  405.251 ±  474.788  us/op
PrefetchBenchmark.noPrefetchReadRandom                   90  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              4096  avgt    3  734.384 ± 1010.659  us/op
PrefetchBenchmark.noPrefetchReadRandom                   90  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              8192  avgt    3  734.215 ±  932.702  us/op
PrefetchBenchmark.noPrefetchReadRandom                   90  /home/ec2-user/environment  prefetch_bench_data_8G             8             16             16384  avgt    3  739.150 ±  701.159  us/op
PrefetchBenchmark.prefetchNormalThenRead                 10  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              4096  avgt    3   82.132 ±  121.437  us/op
PrefetchBenchmark.prefetchNormalThenRead                 10  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              8192  avgt    3   85.855 ±  107.871  us/op
PrefetchBenchmark.prefetchNormalThenRead                 10  /home/ec2-user/environment  prefetch_bench_data_8G             8             16             16384  avgt    3   85.510 ±   73.271  us/op
PrefetchBenchmark.prefetchNormalThenRead                 50  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              4096  avgt    3  334.346 ±  131.156  us/op
PrefetchBenchmark.prefetchNormalThenRead                 50  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              8192  avgt    3  347.240 ±  478.895  us/op
PrefetchBenchmark.prefetchNormalThenRead                 50  /home/ec2-user/environment  prefetch_bench_data_8G             8             16             16384  avgt    3  373.239 ±  842.645  us/op
PrefetchBenchmark.prefetchNormalThenRead                 90  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              4096  avgt    3  348.674 ±  311.629  us/op
PrefetchBenchmark.prefetchNormalThenRead                 90  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              8192  avgt    3  382.573 ±  104.376  us/op
PrefetchBenchmark.prefetchNormalThenRead                 90  /home/ec2-user/environment  prefetch_bench_data_8G             8             16             16384  avgt    3  423.491 ±  202.213  us/op
PrefetchBenchmark.prefetchRandomThenRead                 10  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              4096  avgt    3   39.944 ±   15.283  us/op
PrefetchBenchmark.prefetchRandomThenRead                 10  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              8192  avgt    3   43.664 ±   20.228  us/op
PrefetchBenchmark.prefetchRandomThenRead                 10  /home/ec2-user/environment  prefetch_bench_data_8G             8             16             16384  avgt    3   50.725 ±    7.693  us/op
PrefetchBenchmark.prefetchRandomThenRead                 50  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              4096  avgt    3  188.247 ±   55.046  us/op
PrefetchBenchmark.prefetchRandomThenRead                 50  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              8192  avgt    3  207.542 ±  143.460  us/op
PrefetchBenchmark.prefetchRandomThenRead                 50  /home/ec2-user/environment  prefetch_bench_data_8G             8             16             16384  avgt    3  236.605 ±  120.293  us/op
PrefetchBenchmark.prefetchRandomThenRead                 90  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              4096  avgt    3  348.257 ±  206.218  us/op
PrefetchBenchmark.prefetchRandomThenRead                 90  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              8192  avgt    3  368.074 ±  325.322  us/op
PrefetchBenchmark.prefetchRandomThenRead                 90  /home/ec2-user/environment  prefetch_bench_data_8G             8             16             16384  avgt    3  431.100 ±  241.599  us/op
PrefetchBenchmark.prefetchSequentialThenRead             10  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              4096  avgt    3   86.163 ±   74.133  us/op
PrefetchBenchmark.prefetchSequentialThenRead             10  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              8192  avgt    3   84.713 ±   85.901  us/op
PrefetchBenchmark.prefetchSequentialThenRead             10  /home/ec2-user/environment  prefetch_bench_data_8G             8             16             16384  avgt    3   84.914 ±  105.107  us/op
PrefetchBenchmark.prefetchSequentialThenRead             50  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              4096  avgt    3  327.904 ±  134.254  us/op
PrefetchBenchmark.prefetchSequentialThenRead             50  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              8192  avgt    3  338.804 ±  127.809  us/op
PrefetchBenchmark.prefetchSequentialThenRead             50  /home/ec2-user/environment  prefetch_bench_data_8G             8             16             16384  avgt    3  354.642 ±   39.018  us/op
PrefetchBenchmark.prefetchSequentialThenRead             90  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              4096  avgt    3  345.348 ±  148.671  us/op
PrefetchBenchmark.prefetchSequentialThenRead             90  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              8192  avgt    3  370.560 ±  364.124  us/op
PrefetchBenchmark.prefetchSequentialThenRead             90  /home/ec2-user/environment  prefetch_bench_data_8G             8             16             16384  avgt    3  429.385 ±  311.389  us/op
candidate benchmark (all warm pages in page cache)

cat file > /dev/null before benchmark, but disable page cache clearing.

Benchmark                                     (coldReadPct)                   (dataDir)              (fileName)  (fileSizeGB)  (hotRegionMB)  (prefetchLength)  Mode  Cnt  Score   Error  Units
PrefetchBenchmark.noPrefetchReadRandom                   10  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              4096  avgt    3  0.286 ± 0.098  us/op
PrefetchBenchmark.noPrefetchReadRandom                   10  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              8192  avgt    3  0.463 ± 0.040  us/op
PrefetchBenchmark.noPrefetchReadRandom                   10  /home/ec2-user/environment  prefetch_bench_data_8G             8             16             16384  avgt    3  0.832 ± 0.055  us/op
PrefetchBenchmark.noPrefetchReadRandom                   50  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              4096  avgt    3  0.472 ± 0.076  us/op
PrefetchBenchmark.noPrefetchReadRandom                   50  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              8192  avgt    3  0.733 ± 0.119  us/op
PrefetchBenchmark.noPrefetchReadRandom                   50  /home/ec2-user/environment  prefetch_bench_data_8G             8             16             16384  avgt    3  1.286 ± 0.049  us/op
PrefetchBenchmark.noPrefetchReadRandom                   90  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              4096  avgt    3  0.600 ± 0.106  us/op
PrefetchBenchmark.noPrefetchReadRandom                   90  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              8192  avgt    3  0.948 ± 0.100  us/op
PrefetchBenchmark.noPrefetchReadRandom                   90  /home/ec2-user/environment  prefetch_bench_data_8G             8             16             16384  avgt    3  1.654 ± 0.221  us/op
PrefetchBenchmark.prefetchNormalThenRead                 10  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              4096  avgt    3  0.296 ± 0.028  us/op
PrefetchBenchmark.prefetchNormalThenRead                 10  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              8192  avgt    3  0.468 ± 0.007  us/op
PrefetchBenchmark.prefetchNormalThenRead                 10  /home/ec2-user/environment  prefetch_bench_data_8G             8             16             16384  avgt    3  0.851 ± 0.043  us/op
PrefetchBenchmark.prefetchNormalThenRead                 50  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              4096  avgt    3  0.471 ± 0.097  us/op
PrefetchBenchmark.prefetchNormalThenRead                 50  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              8192  avgt    3  0.758 ± 0.061  us/op
PrefetchBenchmark.prefetchNormalThenRead                 50  /home/ec2-user/environment  prefetch_bench_data_8G             8             16             16384  avgt    3  1.325 ± 0.219  us/op
PrefetchBenchmark.prefetchNormalThenRead                 90  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              4096  avgt    3  0.612 ± 0.063  us/op
PrefetchBenchmark.prefetchNormalThenRead                 90  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              8192  avgt    3  0.951 ± 0.014  us/op
PrefetchBenchmark.prefetchNormalThenRead                 90  /home/ec2-user/environment  prefetch_bench_data_8G             8             16             16384  avgt    3  1.643 ± 0.033  us/op
PrefetchBenchmark.prefetchRandomThenRead                 10  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              4096  avgt    3  1.366 ± 0.512  us/op
PrefetchBenchmark.prefetchRandomThenRead                 10  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              8192  avgt    3  1.560 ± 0.299  us/op
PrefetchBenchmark.prefetchRandomThenRead                 10  /home/ec2-user/environment  prefetch_bench_data_8G             8             16             16384  avgt    3  1.967 ± 0.644  us/op
PrefetchBenchmark.prefetchRandomThenRead                 50  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              4096  avgt    3  1.541 ± 0.040  us/op
PrefetchBenchmark.prefetchRandomThenRead                 50  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              8192  avgt    3  1.815 ± 0.177  us/op
PrefetchBenchmark.prefetchRandomThenRead                 50  /home/ec2-user/environment  prefetch_bench_data_8G             8             16             16384  avgt    3  2.424 ± 0.078  us/op
PrefetchBenchmark.prefetchRandomThenRead                 90  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              4096  avgt    3  1.641 ± 0.056  us/op
PrefetchBenchmark.prefetchRandomThenRead                 90  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              8192  avgt    3  1.986 ± 0.107  us/op
PrefetchBenchmark.prefetchRandomThenRead                 90  /home/ec2-user/environment  prefetch_bench_data_8G             8             16             16384  avgt    3  2.699 ± 0.150  us/op
PrefetchBenchmark.prefetchSequentialThenRead             10  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              4096  avgt    3  0.295 ± 0.015  us/op
PrefetchBenchmark.prefetchSequentialThenRead             10  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              8192  avgt    3  0.490 ± 0.097  us/op
PrefetchBenchmark.prefetchSequentialThenRead             10  /home/ec2-user/environment  prefetch_bench_data_8G             8             16             16384  avgt    3  0.839 ± 0.038  us/op
PrefetchBenchmark.prefetchSequentialThenRead             50  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              4096  avgt    3  0.484 ± 0.077  us/op
PrefetchBenchmark.prefetchSequentialThenRead             50  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              8192  avgt    3  0.746 ± 0.021  us/op
PrefetchBenchmark.prefetchSequentialThenRead             50  /home/ec2-user/environment  prefetch_bench_data_8G             8             16             16384  avgt    3  1.286 ± 0.043  us/op
PrefetchBenchmark.prefetchSequentialThenRead             90  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              4096  avgt    3  0.608 ± 0.081  us/op
PrefetchBenchmark.prefetchSequentialThenRead             90  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              8192  avgt    3  0.945 ± 0.005  us/op
PrefetchBenchmark.prefetchSequentialThenRead             90  /home/ec2-user/environment  prefetch_bench_data_8G             8             16             16384  avgt    3  1.654 ± 0.252  us/op
baseline benchmark

Without this PR changes, only test prefetchRandomThenRead to do apple-to-apple comparison.

Benchmark                                 (coldReadPct)                   (dataDir)              (fileName)  (fileSizeGB)  (hotRegionMB)  (prefetchLength)  Mode  Cnt    Score      Error  Units
PrefetchBenchmark.prefetchRandomThenRead             10  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              4096  avgt    3   66.179 ±   38.100  us/op
PrefetchBenchmark.prefetchRandomThenRead             10  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              8192  avgt    3   98.342 ±   38.340  us/op
PrefetchBenchmark.prefetchRandomThenRead             10  /home/ec2-user/environment  prefetch_bench_data_8G             8             16             16384  avgt    3  164.219 ±   14.844  us/op
PrefetchBenchmark.prefetchRandomThenRead             50  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              4096  avgt    3  270.401 ±  138.404  us/op
PrefetchBenchmark.prefetchRandomThenRead             50  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              8192  avgt    3  368.736 ±  564.566  us/op
PrefetchBenchmark.prefetchRandomThenRead             50  /home/ec2-user/environment  prefetch_bench_data_8G             8             16             16384  avgt    3  643.159 ± 1996.410  us/op
PrefetchBenchmark.prefetchRandomThenRead             90  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              4096  avgt    3  360.715 ±  227.521  us/op
PrefetchBenchmark.prefetchRandomThenRead             90  /home/ec2-user/environment  prefetch_bench_data_8G             8             16              8192  avgt    3  394.207 ±  381.675  us/op
PrefetchBenchmark.prefetchRandomThenRead             90  /home/ec2-user/environment  prefetch_bench_data_8G             8             16             16384  avgt    3  446.483 ±  270.331  us/op
fio test

Average: 338 µs per 4K random read
P50: 310 µs
P90: 383 µs
P99: 840 µs

$ sudo fio --name=randread --ioengine=libaio --direct=1 --bs=4k \
>   --iodepth=1 --rw=randread --size=1G --runtime=10 --time_based \
>   --filename=/home/ec2-user/environment/prefetch_bench_data_8G
randread: (g=0): rw=randread, bs=(R) 4096B-4096B, (W) 4096B-4096B, (T) 4096B-4096B, ioengine=libaio, iodepth=1
fio-3.32
Starting 1 process
Jobs: 1 (f=1): [r(1)][100.0%][r=11.4MiB/s][r=2930 IOPS][eta 00m:00s]
randread: (groupid=0, jobs=1): err= 0: pid=65728: Mon Jun  1 10:04:31 2026
  read: IOPS=2954, BW=11.5MiB/s (12.1MB/s)(115MiB/10001msec)
    slat (nsec): min=2224, max=25262, avg=2487.67, stdev=450.97
    clat (usec): min=215, max=5466, avg=335.40, stdev=133.15
     lat (usec): min=217, max=5469, avg=337.88, stdev=133.15
    clat percentiles (usec):
     |  1.00th=[  265],  5.00th=[  277], 10.00th=[  281], 20.00th=[  293],
     | 30.00th=[  297], 40.00th=[  306], 50.00th=[  310], 60.00th=[  318],
     | 70.00th=[  326], 80.00th=[  343], 90.00th=[  383], 95.00th=[  457],
     | 99.00th=[  840], 99.50th=[ 1074], 99.90th=[ 2114], 99.95th=[ 2474],
     | 99.99th=[ 4752]
   bw (  KiB/s): min=11248, max=12104, per=100.00%, avg=11829.89, stdev=237.18, samples=19
   iops        : min= 2812, max= 3026, avg=2957.47, stdev=59.30, samples=19
  lat (usec)   : 250=0.09%, 500=96.27%, 750=2.25%, 1000=0.76%
  lat (msec)   : 2=0.52%, 4=0.09%, 10=0.02%
  cpu          : usr=0.45%, sys=1.48%, ctx=29548, majf=0, minf=12
  IO depths    : 1=100.0%, 2=0.0%, 4=0.0%, 8=0.0%, 16=0.0%, 32=0.0%, >=64=0.0%
     submit    : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.0%
     complete  : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.0%
     issued rwts: total=29548,0,0,0 short=0,0,0,0 dropped=0,0,0,0
     latency   : target=0, window=0, percentile=100.00%, depth=1

Run status group 0 (all jobs):
   READ: bw=11.5MiB/s (12.1MB/s), 11.5MiB/s-11.5MiB/s (12.1MB/s-12.1MB/s), io=115MiB (121MB), run=10001-10001msec

Disk stats (read/write):
  nvme0n1: ios=29257/106, merge=0/37, ticks=9714/54, in_queue=9769, util=96.35%

@michaeljmarshall

Copy link
Copy Markdown
Member Author

I don't have benchmark numbers yet, but I can work on getting those. Is this the recommended benchmark to run to get HNSW numbers? https://github.com/mikemccand/luceneutil/blob/main/README.md#running-the-knn-benchmark

As for the current state of this PR, I am concerned that the implementation has a latent bug in the state tracking logic. Because we allow cloning of the MemorySegmentIndexInput, we could get invalid state by:

  1. Init MemorySegmentIndexInput with RANDOM read advise.
  2. Clone the instance.
  3. Update the read advise on the clone, which holds references to the same blocks of OS memory, to NOT RANDOM.

This problem also exists for the slice method, which could clone a subset of the segments[].

One solution could be to store the advice per segment in a synchronized bitmap (in a way similar to the sharedPrefetchCounter), but that introduces more overhead to track state.

My primary concern with this (latent) bug is that it is very nuanced and would like lead to unpredictable behavior.

@github-actions

Copy link
Copy Markdown
Contributor

This PR has not had activity in the past 2 weeks, labeling it as stale. If the PR is waiting for review, notify the dev@lucene.apache.org list. Thank you for your contribution!

@neoremind

Copy link
Copy Markdown
Contributor

I spent some time benchmarking and pressure-testing the prefetch path. Sharing the results here, hopefully these add some useful data and insights to the discussion.

The key takeaways:

  1. The backoff counter is two sides of the same coin. It's genuinely great when data is hot, genuinely harmful right at the mix-warm-cold memory pressure case (suppresses the prefetch we need most), and roughly a no-op when reads are mostly cold.
  2. isLoaded() is stable and relatively cheap, worth keeping. It lets us skip the unnecessary madvise on already-resident pages, this shows advantage in hot/mixed workloads under concurrency workload, and it's cheap enough ~1us no matter hot/cold and concurrency level.
  3. The real question isn't "keep the backoff or remove it", it's how we balance prefetch across hot and mixed warm/cold scenarios, a better backoff strategy could help here.

Here are my rationales.

The numbers below are based on

  1. RandomReadIOBenchmark that I reuse from Add JMH benchmarks comparing read I/O strategies under memory pressure #16279, it measures backoff (current impl.) vs. remove backoff but keep only isLoaded() vs. always-prefetch (what the PR promotes) vs. customized slow-climb better backoff strategy, check out the code in the links.
  2. I also tested a real-workload StoredFieldsPrefetchBenchmark to test top 100 field batch prefetch (16 per batch) and for-loop read, see code here.
  3. A micro benchmark on prefetch vs. isLoaded(), see code here.

Platforms tested.

Platform CPU RAM Storage 4K random read latency (QD=1) Saturated random read throughput
Linux EBS (EC2 c5.4xlarge) 16 vCPU 32G EBS io2, 200G, 20K provisioned IOPS ~334us ~80 MB/s
Linux NVME (EC2 c6id.4xlarge) 16 vCPU 32G NVME SSD ~103us ~1.25 GB/s

The latency and throughput numbers are calculated via fio with rand read 16k + different iodepth/numjobs to find the saturation point.

1. When data is hot in the page cache, the backoff avoids prefetch overhead

This is the case #13381 was built for and introduced the backoff counter. When everything is in RAM, every prefetch does an isLoaded()/mincore and/or a madviseWillNeed(), both are pure overhead when the pages already reside in RAM. The backoff counter is super useful to skip the needless overhead.

Removing the backoff counter and isLoaded() = always-prefetch unconditionally, just adds cost on the prefetch path. The no-prefetch read should be the ceiling for hot warm scenario, look at below table for RandomReadIOBenchmark, the backoff sits right close to the ceiling while always-prefetch drops well below it:

no prefetch backoff (current impl.) always-prefetch (this PR) regression (always-prefetch vs. backoff)
EBS T1 40 40 27 -33%
EBS T16 300 299 179 -40%
NVMe T1 42 46 34 -26%
NVMe T16 310 314 217 -31%

(unit: ops/ms, higher is better)

With the backoff on, the prefetch path almost matches the plain-read, because prefetch is almost free due to counter climbs up in power-of-two increments when hot, backoff skips the mincore/madvise overhead to the maximum. That's the whole point of #13381.

The real stored-fields workload tells the same story. I tested only on NVMe, top-k (k=100) store fields retrieval (batch prefetch on store fields to load 4K sized doc then for-loop read), index fully hot:

no prefetch backoff (current impl.) always-prefetch (this PR) regression (always-prefetch vs. backoff)
NVMe T1 2.86 2.66 1.95 -27%
NVMe T8 17.65 16.83 12.23 -27%

2. When most reads are cold, the backoff doesn't matter

For a large portion of cold reads (say like >50%), removing backoff counter or not doesn't matter a lot, because page cache misses triggering page fault keep resetting the counter (isLoaded() == false then set backoff counter.set(0)), so it never climbs into large step "skip" territory. Prefetch is always ON beating no-prefetch version hard. Backoff does no harm for prefetch here.

Here's the all-cold read scenario (64G file, page cache dropped each iteration) for RandomReadIOBenchmark run, backoff and always-prefetch are very close.

no prefetch backoff (current impl.) always-prefetch (this PR) regression
EBS T1 0.04 1.35 1.34 ~0%
EBS T16 0.26 1.36 1.37 ~0%
NVMe T1 0.15 2.69 2.91 +8%
NVMe T16 2.09 6.68 6.66 ~0%

And the stored-fields workload at ~50% cold (64G index, ~half in RAM on Linux NVMe) says the same, backoff and always-prefetch are almost a tie, both ~6.5x better than no-prefetch read at T1 thanks for the batch async I/O firing upfront. Note that at T8, a bit surprising, I suspect the I/O queue is full from the 80K stored-fields blocks (default BEST_SPEED), so there's no spare parallelism left for prefetch to exploit. The prefetch effect saturates.

no prefetch backoff (current impl.) always-prefetch (this PR)
NVMe T1 0.042 0.275 0.268
NVMe T8 0.321 0.286 0.266

So, fully hot warm and heavily cold are both fine, the backoff neither helps nor hurts in the two scenarios.

3. At the mixed-warm-cold scenario, the backoff suppresses the prefetch it needs most

This is where the PR attempts to address, in the mixed warm/cold, aggressive prefetch is much-needed, it attempts to load pages in ahead of the real read. Say in memory pressure scenario when ~90% of reads hit page cache and only ~10% are cold, misses are too rare to reset the backoff counter, it climbs the power-of-two ladder and suppresses prefetch on the cold pages that truly needed.

Under memory pressure (index ≈ RAM), RandomReadIOBenchmark, backoff vs. always-prefetch:

backoff (current impl.) always-prefetch (this PR) speedup
EBS T1 0.49 ops/ms 2.27 ops/ms 4.6× (+365%)
EBS T16 3.33 ops/ms 7.11 ops/ms 2.1× (+113%)
NVMe T1 2.16 ops/ms 7.13 ops/ms 3.3× (+230%)
NVMe T16 25.8 ops/ms 60.4 ops/ms 2.3× (+134%)

Note that with prefetch, a single thread can already saturate the device (2.16 -> 7.13 ops/ms at T1 for NVMe), we don't need extra threads to pile on I/O to keep the queue deep. That's the real power of async prefetch to make I/O parallel even with a single thread.

The stored-fields workload shows the same at T1: prefetch-then-read is 0.55 ops/ms with always-prefetch vs just 0.19 with the backoff on (~2.9×). But the benefit diminishes as thread count increases. In the 16K × 16 RandomReadIOBenchmark, always-prefetch keeps winning even at T8/T16 (e.g. NVMe 43.8 vs 14.9 for backoff), whereas, stored fields is not: at T8 always-prefetch (0.99) actually lags backoff current impl (1.32). Like described above, the reason could be stored fields default to 80K block (BEST_SPEED strategy), so a "4K doc x 100" op is really ~100 scattered 80K block reads. Those big-block reads could saturate the device at much lower concurrency, leaving no room for async I/O prefetch to exploit. Anyway, the T1 in store fields case already proves the story that always-prefetch aggressively is better than backoff in mixed-warm-cold.

4. isLoaded() is a cheap and stable guard worth keeping

Zoom in isLoaded(), I ran a micro on the NVMe (c6id.4xlarge, 4K page), aligned the start address for madvise to ensure prefetch did work, compare apples-to-apples:

T1 T8 T1 -> T8 scaling
isLoaded() / mincore, warm 0.57us 1.28us ~2×
isLoaded() / mincore, cold 0.43us 1.25us ~2×
madvise(WILLNEED), warm 0.33us 2.5us ~7×
madvise(WILLNEED), cold 5.3us 42us ~8×

isLoaded() is cheap and stable, and it scales only ~2× from T1->T8. madvise(WILLNEED) latency is lower than isLoaded() single-threaded when pages are warm (0.33us madvise vs. 0.57us isLoaded() check). But it's no free lunch, counter-intuitively, it's contention-prone, madvise on warm is 2x the latency of isLoaded() at T8 (2.5us vs. 1.28us), and its latency climbs ~7–8× with threads and blows up on cold pages (the async WILLNEED hint isn't free, still has to submit and queue the I/O).

So, in terms of isLoaded()'s value: it saves the madvise on warm pages which is a bit cheap single-threaded, but it contended and expensive under concurrency. To be fair, I admit that prefetch in real use case usually interleaves with other computations, so the contention may not be as severe as in this tight micro-benchmark loop.

Looking benchmarks at Linux NVMe for hot and mixed-hot-cold scenario, keeping isLoaded() check is better than always-prefetch except the acceptable loss in single threaded:

  • RandomReadIOBenchmark, hot: 33.3 / 224.1 / 256.6 (keep isLoaded) vs 34.0 / 169.4 / 216.7 (always-prefetch), T1/T8/T16
  • stored fields, hot: 2.14 / 13.86 (keep isLoaded) vs 1.95 / 12.23 (always-prefetch), T1/T8
  • RandomReadIOBenchmark, mixed hot-warm: 6.55 / 44.9 / 61.2 (keep isLoaded) vs 7.13 / 43.8 / 60.4 (always-prefetch), T1/T8/T16
  • stored fields, mixed hot-warm: 0.56 / 1.0 (keep isLoaded) vs. 0.55 / 0.99 (always-prefetch), T1/T8

Looking benchmarks at Linux NVMe for cold scenario. The extra isLoaded() is definitely overhead, but compare with EBS random-read latency ~340us, normal SSD ~100us, a performant NVMe 10–20us, the guard latency looks negligible relative to end-to-end I/O overall.

5. My 2 cents: a better backoff instead of removing it, and keep isLoaded()

So the problem really isn't "backoff yes/no", removing it is great for mixed warm/cold but bad for hot, there is no one-size-fits-all. I think the power-of-two backoff strategy is just a bit too aggressive, what if we soften it to balance hot vs. mixed hot-warm reads? I tried a slow-climbing backoff, fire madvise for the first 8 prefetch, then every Nth with N growing slowly, cap at 64 to force a prefetch. Tested on StoredFieldsPrefetchBenchmark (Linux NVMe, prefetch-then-read ops/ms, higher = better):

thread no prefetch backoff (current impl.) always-prefetch (this PR) slow-climb (customized backoff)
Hot 16G T1 2.86 2.66 1.95 2.37
Hot 16G T8 17.65 16.83 12.23 15.15
Boundary 32G (≈RAM) T1 0.15 0.19 0.55 0.47
Boundary 32G (≈RAM) T8 1.30 1.32 0.99 1.53

In the hot rows, slow-climb sits just under the no-prefetch ceiling (only -9%, vs always-prefetch's -26%), nearly as cheap as the current backoff. At the boundary, T1 it does ~3.0x over its no-prefetch baseline (vs 1.4x for the current power-of-two backoff and ~3.7x for always-prefetch which is the best), a somewhat good middle ground.

We could explore a softer better backoff strategy alongside the isLoaded() guard, like I described, this is really a balance.

Appendix — raw results

Columns across all tables:

1. RandomReadIOBenchmark (reused from #16279) — RANDOM + batched prefetch, ops/ms (higher = better)

Numbers under test of mmapRandomBatchedPrefetch (RANDOM advice, prefetch the 16 random offsets then read). readSize=16K, readsPerOp=16.

Linux EBS (c5.4xlarge)

Screenshot 2026-07-16 at 2 30 42 PM
Case Threads backoff (current impl.) rm-backoff always-prefetch slow-climb
Warm 16G (hot) T1 39.98 25.36 26.95 35.35
Warm 16G T4 151.18 96.67 85.65 130.40
Warm 16G T8 272.70 180.33 129.56 243.83
Warm 16G T16 298.66 218.23 179.06 279.37
Mem pressure 32G (≈RAM) T1 0.49 2.29 2.27 0.76
Mem pressure 32G T4 1.98 7.21 7.19 3.21
Mem pressure 32G T8 3.30 7.18 7.24 4.33
Mem pressure 32G T16 3.33 7.16 7.11 4.37
Many cold 64G (no drop) T1 0.64 1.58 1.52 1.63
Many cold 64G T4 1.50 1.65 1.66 1.74
Many cold 64G T8 1.50 1.64 1.65 1.71
Many cold 64G T16 1.47 1.64 1.65 1.70
All cold 64G (drop cache) T1 1.35 1.35 1.34 1.35
All cold 64G T4 1.35 1.35 1.35 1.35
All cold 64G T8 1.35 1.35 1.35 1.35
All cold 64G T16 1.36 1.36 1.37 1.37

Linux NVMe (c6id.4xlarge)

Screenshot 2026-07-16 at 2 30 55 PM
Case Threads backoff (current impl.) rm-backoff always-prefetch slow-climb
Warm 16G (hot) T1 45.75 33.27 34.02 41.44
Warm 16G T4 170.53 122.45 111.72 154.45
Warm 16G T8 310.24 224.10 169.38 283.71
Warm 16G T16 313.69 256.63 216.72 315.65
Mem pressure 32G (≈RAM) T1 2.16 6.55 7.13 3.36
Mem pressure 32G T4 8.14 25.01 23.81 12.21
Mem pressure 32G T8 14.88 44.91 43.84 22.35
Mem pressure 32G T16 25.78 61.21 60.38 38.13
Many cold 64G (no drop) T1 2.61 3.11 3.16 3.09
Many cold 64G T4 7.21 7.21 7.18 7.21
Many cold 64G T8 7.21 7.21 7.18 7.21
Many cold 64G T16 7.21 7.21 7.18 7.19
All cold 64G (drop cache) T1 2.69 2.70 2.91 2.68
All cold 64G T4 6.24 6.21 6.24 6.24
All cold 64G T8 6.28 6.27 6.27 6.29
All cold 64G T16 6.68 6.67 6.66 6.65

Baseline mmapRandom (no prefetch) is constant baseline across branches, EBS T16 ~300 ops/ms warm / ~3.3 mem-pressure, NVMe T16 ~310 ops/ms / ~26 ops/ms.

2. Micro: isLoaded() vs madvise(WILLNEED), page-offset aligned, us/op (lower = better)
Platform Case Threads readSize isLoaded() madvise(WILLNEED)
EBS warm T1 4K 1.114 0.676
EBS warm T1 16K 1.448 0.743
EBS warm T8 4K 1.712 2.940
EBS warm T8 16K 1.810 3.097
EBS cold T1 4K 0.805 47.467
EBS cold T1 16K 0.863 46.521
EBS cold T8 4K 1.306 390.060
EBS cold T8 16K 1.334 379.543
NVMe warm T1 4K 0.572 0.326
NVMe warm T1 16K 0.642 0.378
NVMe warm T8 4K 1.282 2.515
NVMe warm T8 16K 1.252 2.500
NVMe cold T1 4K 0.429 5.351
NVMe cold T1 16K 0.454 13.849
NVMe cold T8 4K 1.254 42.509
NVMe cold T8 16K 1.215 109.740
3. StoredFieldsPrefetchBenchmark (NVMe) — top-100 retrieval, ops/ms (higher = better)

Real workload: collect top-100 hits, prefetch a window of 16, then retrieve. docSizeBytes=4096, RANDOM advice. Note stored fields use an 80KB BEST_SPEED block by default, so each doc read faults/decompresses a large block, not 4KB.

Hot (16G index, fully cached)

Method Threads backoff (current impl.) rm-backoff always-prefetch slow-climb
retrieveNoPrefetch T1 2.86 2.66 2.64 2.61
retrieveNoPrefetch T8 17.65 16.13 16.33 16.27
prefetchThenRead T1 2.66 2.14 1.95 2.37
prefetchThenRead T8 16.83 13.86 12.23 15.15

Memory pressure (32G ≈ RAM)

Method Threads backoff (current impl.) rm-backoff always-prefetch slow-climb
retrieveNoPrefetch T1 0.134 0.150 0.150 0.156
retrieveNoPrefetch T8 1.165 1.255 1.296 1.307
prefetchThenRead T1 0.191 0.558 0.554 0.474
prefetchThenRead T8 1.322 1.007 0.986 1.534

50% cold (64G, ~half resident)

Method Threads backoff (current impl.) rm-backoff always-prefetch slow-climb
retrieveNoPrefetch T1 0.042 0.040 0.042 0.042
retrieveNoPrefetch T8 0.321 0.310 0.321 0.320
prefetchThenRead T1 0.275 0.259 0.268 0.270
prefetchThenRead T8 0.286 0.259 0.266 0.267

@github-actions github-actions Bot removed the Stale label Jul 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants