neoremind opened a new issue, #16574:
URL: https://github.com/apache/lucene/issues/16574

   ### Description
   
   ## Background
   
   I am looking at the `ByteBlockPool` sharing design in `TermsHash` (related 
to #11608). Today, `TermsHash` uses a single shared `ByteBlockPool` to store 
both term bytes (used by `BytesRefHash`) and terms' postings data (doc ID 
deltas, freqs, pos, offsets, payloads). 16 years ago, in the initial 
[commit](https://github.com/apache/lucene/commit/42b9f0caa32ff71782a949264d033a346ede7b26#diff-22ab6c31dc6317827019d7c86faa08fc826efe921188da71743bc3b884d058ee),
 the `termBytePool` has been pointed to the same object as `bytePool`:
   
   ```java
   // In TermsHash constructor
   if (nextTermsHash != null) {
       termBytePool = bytePool; // reuse the same pool here
       nextTermsHash.termBytePool = bytePool;
   }
   ```
   
   This means term bytes and their respective postings slices are interleaved 
in the same byte pool. I drew a diagram to illustrate:
   
   
![pool-sharing-diagram](https://github.com/user-attachments/assets/40c19ae8-87ea-4b94-ac72-48ef464c3996)
   
   ## The Change and Benchmark
   
   Separating the two pools is simple (see my 
[commit](https://github.com/apache/lucene/commit/8138180422371fe9f24b6171985de749aa4d2543))
 with just a few lines of code. I ran both micro and end-to-end benchmarks to 
vet.
   
   All benchmarks run on EC2 c5.4xlarge (16 vCPU, 32G RAM), OpenJDK 25.0.2.
   
   ### JMH Microbenchmark
   
   I wrote a JMH benchmark that runs `TermsHashPerField.add()` in a tight loop 
for a single field, see the benchmark 
[here](https://github.com/apache/lucene/commit/58c86c4326d36482fc0fcc37a639b4ce6c0466fe).
 It uses 256k vocab size, 2M token stream with different Zipfian-ish skew 
configs, and a UUID unique workload, 1k tokens per doc. The Zipfian-ish skew 
way simulates the frequent seen terms in real workloads as I observed in 
https://github.com/apache/lucene/issues/11608#issuecomment-5265355538. Two 
microbenchmarks: `indexSegment` (add only) and `indexSegmentAndSort` (add + 
radix sort, mimic the in-memory accumulation phase before segment flush).
   
   To mitigate noise, I use below JMH config and run multiple times to average:
   
   ```
   # Warmup: 3 iterations, 3 s each
   # Measurement: 5 iterations, 3 s each
   # Fork: 5
   # JVM args: -Xmx12g -Xms12g -XX:+AlwaysPreTouch
   # Params: shortRatio=0.75, tokensPerDoc=1000
   ```
   
   - Thread 1 (10 runs averaged)
   
   | Benchmark | Skew | Baseline (ns/op) | Candidate (ns/op) | Delta |
   |---|---|---|---|---|
   | indexSegment | 1.0 | 259.5 | 265.3 | +2.2% |
   | indexSegment | 3.0 | 230.2 | 235.6 | +2.3% |
   | indexSegment | 6.0 | 193.8 | 199.2 | +2.8% |
   | indexSegment | UUID | 315.6 | 315.3 | −0.1% |
   | indexSegmentAndSort | 1.0 | 269.1 | 274.0 | +1.8% |
   | indexSegmentAndSort | 3.0 | 243.1 | 245.9 | +1.1% |
   | indexSegmentAndSort | 6.0 | 197.6 | 202.3 | +2.4% |
   | indexSegmentAndSort | UUID | 620.8 | 601.1 | **−3.2%** |
   
   - Thread 4 (5 runs averaged)
   
   | Benchmark | Skew | Baseline (ns/op) | Candidate (ns/op) | Delta |
   |---|---|---|---|---|
   | indexSegment | 1.0 | 430.2 | 452.6 | +5.2% |
   | indexSegment | 3.0 | 355.2 | 366.9 | +3.3% |
   | indexSegment | 6.0 | 249.5 | 258.2 | +3.5% |
   | indexSegment | UUID | 375.5 | 372.3 | −0.9% |
   | indexSegmentAndSort | 1.0 | 442.0 | 462.9 | +4.7% |
   | indexSegmentAndSort | 3.0 | 370.0 | 376.5 | +1.8% |
   | indexSegmentAndSort | 6.0 | 261.6 | 269.6 | +3.1% |
   | indexSegmentAndSort | UUID | 703.9 | 686.3 | **−2.5%** |
   
   The JMH shows **no improvement or even slight regression** for typical 
workloads. My read is that by separating pools, we introduce an extra 
indirection to locate posting slices in a different pool. The benchmark is 
genuinely **CPU-bound** in a tight single-field loop, the Zipfian-ish skew 
term-bytes total working set is ~8 MB fitting in L3 cache with no memory 
pressure or thrashing. For UUID (97 MB term bytes, exceeds L3), the sort phase 
benefits most because term bytes are densely packed without posting 
interleaving, better spatial locality for the radix sort.
   
   ### End-to-End luceneutil Wikipedia benchmark
   
   This is a totally different story. I ran full 33M Wikipedia documents, no 
in-flight merge, `ramBufferMB=2G` to allow purely flushing segments one by one, 
about ~5.6M unique terms per segment, ~550M+ total `add()` calls per segment.
   
   <details>
   <summary>Luceneutil indexing configuration details</summary>
   
   ```python
   index = comp.newIndex(
       args.baseline,
       sourceData,
         postingsFormat="Lucene104",
         idFieldPostingsFormat="Lucene104",
         directory="MMapDirectory",
         ramBufferMB=2048,
         waitForMerges=False,
         waitForCommit=False,
         grouping=False,
         verbose=False,
         mergePolicy="NoMergePolicy",
         useCMS=False,
     )
   ```
   </details>
   
   #### Thread 1 (10 runs)
   
   | Metric | Baseline | Candidate | Delta |
   |---|---|---|---|
   | docs/sec (mean) | 11,513.9 | 11,749.4 | **+2.05%** |
   | docs/sec (median) | 11,499.4 | 11,710.5 | +1.83% |
   | GB/hour (mean) | 36.539 | 37.286 | **+2.04%** |
   
   Due to Amdahl's law, the change only targets `sortTerms` in the flush phase 
and `findHash` which together consumes <20% of total CPU, so the absolute 
improvement is small. But it is consistent, the best baseline run (11,614 
docs/sec) is still slower than the candidate's worst (11,672 docs/sec).
   
   Flamegraph: 
   
   - Baseline
   
   <img width="1420" height="647" alt="Image" 
src="https://github.com/user-attachments/assets/92c7c2b7-a7d0-47b1-a1d5-128f1ea0a904";
 />
   
   - Candidate
   
   <img width="1419" height="633" alt="Image" 
src="https://github.com/user-attachments/assets/25062d69-8484-464d-beeb-b4b817e65754";
 />
   
   
[Baseline](https://neoremind.com/report/log/lucene/TermHash/flamegraph/thread-1/bench-index-baseline_vs_patch-wikimediumall.lucene.Lucene104.nd33.3326M.html)
 | 
[Candidate](https://neoremind.com/report/log/lucene/TermHash/flamegraph/thread-1/bench-index-baseline_vs_patch-wikimediumall.fork_lucene.candidate.Lucene104.nd33.3326M.html)
   
   If zooming into the flamegraph, `sortTerms` and `findHash` shrinked in the 
candidate.
   
   #### Thread 8 (10 runs)
   
   | Metric | Baseline | Candidate | Delta      |
   |---|---|---|------------|
   | docs/sec (mean) | 74,583.2 | 76,336.4 | **+2.35%** |
   | docs/sec (median) | 75,902.5 | 76,343.4 | +0.58%     |
   | GB/hour (mean) | 234.061 | 239.710 | **+2.41%** |
   
   For multi-threaded cases, there are more variance here, throughput ranges 
overlap between runs, but the candidate's overall numbers still win.
   
   Flamegraph: 
   
   - Baseline
   
   <img width="1419" height="630" alt="Image" 
src="https://github.com/user-attachments/assets/343c3bac-1b88-43e4-bf8d-00f6f1358921";
 />
   
   - Candidate
   
   <img width="1419" height="651" alt="Image" 
src="https://github.com/user-attachments/assets/89e4652a-4194-4f38-8ef2-f698f79d5f99";
 />
   
   
[Baseline](https://neoremind.com/report/log/lucene/TermHash/flamegraph/thread-8/bench-index-baseline_vs_patch-wikimediumall.lucene.Lucene104.nd33.3326M.html)
 | 
[Candidate](https://neoremind.com/report/log/lucene/TermHash/flamegraph/thread-8/bench-index-baseline_vs_patch-wikimediumall.fork_lucene.candidate.Lucene104.nd33.3326M.html)
   
   #### Thread 1 w/ body field disabled (10 runs)
   
   To isolate the heavy full-text indexing, I disabled the body text field, use 
only mono-incremental IDs, title, date, and numeric fields. Without the heavy 
inverted-index workload, the result is neutral, no regression and slightly 
better:
   
   | Metric | Baseline | Candidate | Delta |
   |---|---|---|---|
   | docs/sec (mean) | 148,345.6 | 149,069.7 | +0.49% |
   | docs/sec (median) | 148,418.3 | 149,328.0 | +0.61% |
   | GB/hour (mean) | 470.566 | 472.911 | +0.50% |
   
   Flamegraph: 
   - Baseline
   
   <img width="1422" height="596" alt="Image" 
src="https://github.com/user-attachments/assets/518adc97-6c4a-4356-a008-1e355ee77f60";
 />
   
   - Candidate
   
   <img width="1419" height="638" alt="Image" 
src="https://github.com/user-attachments/assets/b2e65757-f2a0-4e41-afb7-c4e27fa65b77";
 />
   
   
[Baseline](https://neoremind.com/report/log/lucene/TermHash/flamegraph/thread-1-disable-body/bench-index-baseline_vs_patch-wikimediumall.lucene.Lucene104.nd33.3326M.html)
 | 
[Candidate](https://neoremind.com/report/log/lucene/TermHash/flamegraph/thread-1-disable-body/bench-index-baseline_vs_patch-wikimediumall.fork_lucene.candidate.Lucene104.nd33.3326M.html)
   
   #### JFR analysis
   
   Thread 1: total CPU samples 290,263 (baseline) -> 284,515 (candidate), −2.0%:
   
   | Method | Baseline | Candidate | Delta |
   |---|---|---|---|
   | `BytesRefHash.findHash` | 43844 / 290263 = 15.10% | 40217 / 284515 = 
14.14% | -3627 |
   | `TermsHashPerField.positionStreamSlice` | 47500 / 290263 = 16.36% | 47771 
/ 284515 = 16.79% | +271  |
   | `BytesRefHash.buildHistogram` | 4603 / 290263 = 1.59% | 2504 / 284515 = 
0.88% | -2099 (relatively -45.6%) |
   
   Thread 8: total CPU samples 346,346 (baseline) -> 330,370 (candidate), −4.6%:
   
   | Method  | Baseline | Candidate | Delta |
   |---|---|---|---|
   | `BytesRefHash.findHash` | 45939 / 346346 = 13.26% | 43304 / 330370 = 
13.11% | -2635  |
   | `TermsHashPerField.positionStreamSlice` | 63528 / 346346 = 18.34% | 64041 
/ 330370 = 19.38% | +513  |
   | `BytesRefHash.buildHistogram` | 5321 / 346346 = 1.54% | 3015 / 330370 = 
0.91% | -2306 (relatively -43.3%) |
   
   The JFR confirms that the speedup comes from `findHash` and the sort phase 
where term bytes are now denser, while the expected overhead shows up a minor 
increase in `positionStreamSlice` because the postings write path now has an 
extra pool indirection.
   
   ## Why end-to-end wins but the microbenchmark doesn't
   
   My 2 cents:
   
   1. **Inter-doc and intra-doc (inter-field) cache pollution.**: Though both 
benchmarks hit the same code path, the workload is different. In real indexing, 
`IndexingChain` processes each doc with multiple consumers including inverted 
index, stored fields, or dv, points/BKD, and between each doc there are 
pre/post-processing as well. Each phase touches different memory regions. By 
the time we finish doc N's stored fields or dv and come back to process doc 
N+1's text field, the CPU cache lines holding term bytes may have been evicted. 
Separating provides better spatial and temporal locality.
   
   2. **Denser cache lines for frequent-term lookups.**: Luceneutil Wikipedia 
is biased towards frequently-seen terms, the vast majority of add() calls are 
lookups of already-existing terms (>98%), 56.4% of all seen-term lookups are 
<=5 bytes. With the shared pool, when `BytesRefHash.equals()` reloads a cache 
line to compare term bytes, that cache line also carries unnecessary 
interleaving postings, it is burden. With a separated pool, pure term bytes per 
cache line, increasing the chance of cache line reuse in L1–L3 across lookups.
   
   3. **The sort phase is the most significant contributor**: −45% samples in 
`buildHistogram`. The radix sort walks term bytes multiple times. With a dense 
term-only pool, it is better.
   
   4. The UUID microbenchmark case (all unique) is neutral, an disabling the 
body field in end-to-end is also neutral, so without heavy inverted-index work, 
there is not much term-hash cache pressure to optimize and UUID benefits most 
from denser terms sort.
   
   ## Asks and guidance required
   
   I remember @rmuir once expressed in 
https://github.com/apache/lucene/pull/16371#issuecomment-5053592144 that "It 
might look good in a microbenchmark but not in an overall indexer run.". My 
findings are the opposite, the microbenchmark shows no gain (or slight cost 
from the extra indirection), but the end-to-end indexer run shows a consistent 
improvement or no regression. So I'd like to seek guidance from experts and 
folks who are familiar with this area to help justify whether such separation 
is worth shipping. Happy to discuss.
   


-- 
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