Incremental doc-values updates - #16418
Conversation
d1eed2f to
e852fa8
Compare
b77d41e to
25b7c80
Compare
|
I like the idea, it would make updatable DV more appealing. quick Q: I read the proposal but not all the changes. Does it cover all the DocValues, or just Numeric? What about multi-valued (SortedNumeric I think it's called)? |
|
Yeah, builds on the existing update support so numeric and binary only. For multi-valued you'd have to pack it into a binary field with a custom format today, there's no native SortedNumeric update path. SortedNumeric could be added (it's ordinal-free, so tractable), but that's really about extending the core update support, which is out of scope here: this PR just builds sparse deltas on top of what's already updatable. |
|
I like this a lot. The sparse deltas with the existing codec encoding is a clean approach, and the write amplification numbers speak for themselves. The thing I keep coming back to is whether deltas need to survive across commits. That's the decision that forces the segments version bump and the compatibility implications (old readers can't open the index while overlays are active). What if you just folded back to dense at commit time? You'd still get most of the win: if refreshes are every 1s and commits every 30s, that's 30 cheap sparse delta writes between commits and then one dense rewrite when you commit. You've avoided 29 out of 30 full-column rewrites compared to today. Not as optimal as never rewriting until merge, but zero compatibility cost, segments_N stays unchanged, old Lucene can still read it, and the committed state is always a plain dense column. Curious whether you considered that path and rejected it, or whether it just fell out naturally that deltas persist. It might be worth it as a simpler first step even if the full "persist across commits" mode comes later? |
|
One thought for the future: the feature is all-or-nothing across the index today. In practice some fields are read-heavy (sorting, aggregations) and would benefit from a flat dense column, while others are write-heavy and would prefer cheap sparse deltas. A per-field policy could be a nice follow-up. The plumbing already knows which field is being updated, so it should be straightforward to add later. Something like: |
|
The conditional version bump in |
|
Yeah I looked at that. Deltas need to survive commits because the whole point is to commit often without rewriting the full column each time. Fold-at-commit only helps when commits are rare compared to refreshes. In the case I care about a refresh is also a commit, so you'd fold to dense on every refresh, which is just what we do today. Your 29/30 holds when refresh:commit is big, at 1:1 it's 0/1. I don't think the compat cost is a real blocker either. An old reader rejects the index up front ( The real cost of keeping deltas around is on the read side (the overlay merge). But there's a lot we can do there: |
|
Yeah I like this. It's index-wide for now to keep the first cut simple, but the write path already routes updates per field, so a per-field override should be easy to add on top. Good follow-up. |
|
Fair point. The conditional bump made readability depend on merge timing, which isn't something operators should have to reason about. Done in 3eb7cf1: it now always bumps when the feature is enabled, so it's deterministic from the config. With the feature off (and no inherited overlay) it stays on the old version, so nothing changes for backports. |
3eb7cf1 to
62339ed
Compare
62339ed to
4bdf0cd
Compare
|
Simplified the version bump to unconditional: every commit just writes the current segments version ( |
|
Thanks for addressing my feedback, and answering my questions. This makes a lot of sense to me, and I think that the direction is good. |
| format = "Lucene 8.6 or later"; | ||
| } else if (actualVersion > SegmentInfos.VERSION_86) { | ||
| format = "Lucene 8.6 or later (UNSUPPORTED)"; | ||
| } else if (actualVersion == SegmentInfos.VERSION_11_0) { |
There was a problem hiding this comment.
Are you planning to backport this to 10.6? Should the version be encoded as VERSION_10_6?
There was a problem hiding this comment.
Same as the note on the version constant: it is just naming, the value is 11 regardless. I would rename to VERSION_10_6 if/when we backport, deferring it until then.
|
|
||
| static final int VERSION_CURRENT = VERSION_86; | ||
| /** The version that records per-field incremental doc-values overlay generations. */ | ||
| public static final int VERSION_11_0 = 11; |
There was a problem hiding this comment.
Are you planning to backport this to 10.6? Should the version be encoded as VERSION_10_6?
There was a problem hiding this comment.
Just the name, not the value: the fence is the value (11), which is higher than VERSION_86 so an older reader rejects it either way. On main it is VERSION_11_0 since main is 11.0. If/when we backport to 10.6 I would rename it to VERSION_10_6 (value unchanged), a trivial rename on both branches. So I would rather defer the naming to the actual backport.
|
+1 for the overall idea/approach -- it is insanely surprising / unexpected that updating a DV field has the cost / write amplification (whole column for that segment even if just one doc was updated) it does today. This would fix that, at least for It does presumably make lookup slower (it's trading off faster indexing for slower searching), since each lookup will go through N stacks (newest to oldest), and those docs that were never updated would have the most added cost I think? Have you tested lookup performance vs number of updates applied? In practice it's likely negligible -- the other costs of searching (HNSW crawl, decoding/intersecting postings, whatnot) usually dominate vs looking up DV valuews for sorting, say. |
|
Also, CC @shaie -- long ago he had thought about a similar idea for updatable postings I think? |
|
Thanks @mikemccand for CC'ing me. I like the idea and have few comments, although I haven't reviewed the changes so if you've implemented them already, it's even better :). On the per-field override / cost of writing deltas vs full vectors: we can make the decision dynamic per the number of updates. For example, say up to 30% of docs were updated, you store the deltas. More than 30% fold all the updates to a single vector. If a segment is updated over several flushes and accumulates over 30% total updates, they're collapsed too. That way, it's per-field per-segment policy which changes dynamically with the rate of updates. The threshold (30%) is a hyperparameter you can evaluate how it impacts indexing and search throughput. On the runtime cost, how about if we folded all the updates to a single buffer, just like a segment without deltas? That way, you pay the cost once at segment open time, but otherwise reads behave just as if the segment had no deltas? And yes @mikemccand this idea resembles a discussion we've had many years ago about posting updates too - you maintain the updates in "layers/overlays" of the postings and resolve the final values at read time. At the time when we've implemented DV updates, the dominating scenario was updating numeric values such as update timestamps etc., which naturally affected the majority of the index. But I can totally see usecases for this capability, such as updating sparse fields (price, ratings_count) which will benefit from this enhancement. |
|
Thanks both. @mikemccand right, it's a write-for-read trade: a read merges the doc across the layers with a small min-heap, newest wins. On never-updated docs paying the most, the delta layers are sparse ( @shaie on the dynamic per-field-per-segment policy: that's already the shape of it, the fold is per field per segment and it collapses back to a single dense column once the deltas cover the whole column. Right now that triggers at full coverage; your %-threshold (say 30%) is a nice way to cap read cost earlier instead of waiting for 100%, and it lines up with the size-tiered compaction I flagged as a TODO. Today the bound is the overlay count; a coverage-% trigger could complement or replace it. Your fold-at-open idea is exactly the read-side optimization I want, and it answers Mike's question: merge the deltas into one buffer at reader open so reads behave like a plain column and you pay the merge once. Not in the first cut, to keep the mechanism minimal, but it's the top follow-up, along with |
setIncrementalDocValuesUpdates (default true; opt out for the classic full-column rewrite) and setMaxDocValuesDeltaGenerations (default 16), plus the CHANGES.txt entry.
Format-agnostic read primitive: DocValuesOverlayMerger does a min-heap
k-way merge over layered generations (newest wins, base last), driving
Overlay{Numeric,Binary}DocValues. Advancing only re-positions the
layers sitting on the current doc. Includes unit tests.
4bdf0cd to
ad9e075
Compare
|
Added the probe: forward |
Store the per-field sparse overlay generations ({baseGen, deltas...})
as first-class SegmentCommitInfo state, serialized in the segments
file. A commit carrying an overlay is written at a bumped segments
version (VERSION_11_0), so a Lucene version that predates the feature
rejects it before any codec runs rather than misreading a delta as the
whole column; commits without an overlay stay at the previous version
and remain readable by older versions.
Each flush writes only the updated docs as a sparse delta generation overlaid at read time (via SegmentDocValuesProducer), turning per-update amplification from O(column) into O(updated docs). Deltas fold into one sparse generation past maxDocValuesDeltaGenerations, and fold to a dense column once they cover it or stack on a dense base; removals fall back to the dense rewrite. The overlay generations live on SegmentCommitInfo and are carried over by addIndexes. Threads IndexWriterConfig through ReaderPool to ReadersAndUpdates.
Randomized set-only updates with reopens and merges, merge-flatten, removal-to-dense fallback, continuing a feature-off index, fold-to-dense, multiple fields, updates interleaved with deletes, sorted index, addIndexes, binary, the skip-index rejection, and the older-reader segments-version fence. The classic delete-unused-files test pins the feature off to keep exercising the dense path.
Standalone benchmark (a main, not a JUnit test, run outside the test framework so the JVM is production-like) that applies the same updates three ways - full document reindex, dense column rewrite, sparse delta - over docs carrying a vector, reporting update throughput, bytes written per update against the raw value size, live file and generation count, and an aggregation scan; plus a maxDocValuesDeltaGenerations sweep and optional concurrent query threads. To be removed before merge; here so reviewers can reproduce.
ad9e075 to
229cec4
Compare
Collapse setIncrementalDocValuesUpdates(boolean) and setMaxDocValuesDeltaGenerations(int) into one setMaxDocValuesOverlays(int) (0 disables, N keeps up to N overlays before a fold), drop the now-unnecessary volatile, and use "overlay" not "generation" in the public API. Write the segments version unconditionally: every commit uses VERSION_CURRENT (VERSION_11_0), like past segments-file changes, rather than gating on whether overlays are present. An older Lucene cannot read a newer index anyway (forward-only), so gating the bump bought nothing and it keeps versioning simple. Teach Luke the new version and document the overlay field in the segments-file format.
229cec4 to
3a8cb3b
Compare
Incremental doc-values updates
When you update a doc-values field today, Lucene rewrites the whole column for that field, even if you only touched a
few docs. So a tiny change writes a lot, and it gets worse as the segment grows.
The idea is simple: when an update only sets values (no unset), write just the changed docs as a sparse "delta"
generation, and stack the deltas on top of the base column at read time, newest wins. Updating a field becomes
O(changed docs)instead ofO(column).Deltas would pile up, so there's a small lifecycle per field:
Numeric and binary only, set-only (a value unset falls back to the current dense rewrite). No doc-values format
change, the deltas use the codec's existing sparse encoding.
An old Lucene can't read the overlay, so this rolls the segments format version (
VERSION_11_0), the same way pastsegments-file changes did (like the SegmentCommitInfo id in 8.6): every commit written by this version uses it, and an
older reader rejects the index up front with
IndexFormatTooNewExceptioninstead of reading a delta as if it was thewhole column. Reading a newer index with an older Lucene isn't in the contract anyway (Lucene is forward-only), so
nothing is lost. On
mainthe feature is on by default; passIndexWriterConfig#setMaxDocValuesOverlays(0)for theclassic full-column rewrite (0 = off, N = keep up to N overlays before a fold). A 10.x backport would be opt-in via
that default.
This is a proposal, I'd like your opinion on the approach before polishing it more. Two things I'm not sure about:
where the overlay bookkeeping should live (I put it on
SegmentCommitInfo), and the compaction, which right nowre-folds all the deltas every time. A size-tiered policy would be better, left as a TODO.
Numbers
There are three ways to change one field on a doc, so I compared them head to head. The benchmark is in the last
commit, a plain
main, not a test, run as a normal Java app (assertions off, real JIT, mmap directory) so the JVMbehaves like production and not the test harness. It'll go before merge, it's just there so you can reproduce. It
indexes 5M docs, each with an 8-byte field and a 512-dim vector, lets the natural merges finish, then applies updates
in random order (not ingestion order, so the overlay actually gets stressed) from 4 threads, refreshing an NRT reader
every second. It reports update throughput, the bytes written during the update phase, write amplification, and a scan
that reads the field back. All three arms end on the same values.
updateDocument, what you do today when the field isn't doc-values-updatable. Rewrites the whole doc.The thing that matters is cadence. Write amplification isn't really a property of the feature, it's a property of how
many docs you change between commits. So I ran every arm two ways: a batch that updates as fast as it can (lots of docs
per refresh), and a throttled stream at 5k updates/s (a few thousand per refresh, closer to a steady trickle on a live
index).
Numeric 8-byte field:
Amplification is bytes written over the 8 bytes actually changed. In batch, dense amortizes: thousands of updated docs
share one full-column rewrite, so it lands at 10×. Throttle it and that same full-column rewrite now covers only a few
thousand docs, so it jumps to 900×. Sparse doesn't care about cadence, it writes the changed docs and nothing else, so
it stays flat: 2.2× vs 900× at 5k/s, about 400× less written.
32-byte binary field, same two runs:
Same shape, bigger column so dense costs more (27 KB/update throttled against 55 bytes for sparse).
Soft deletes come along for free, because a soft delete is a numeric doc-values update on the soft-deletes field, so it
takes the same path. The arms here are a hard delete (liveDocs, no column), and the soft-delete mark with the feature
off (dense) and on (sparse):
The soft-delete column only holds the marked docs, so the absolute numbers are small, but the shape is the same: batch
amortizes and both are basically free, throttle it and dense rewrites the growing column every commit (33 bytes/update)
while sparse writes just the newly marked docs (6 bytes/update).
Reads are the tradeoff. The sparse scan merges the overlay at read time, so it's a few times slower than a plain
column (numeric 118 ms vs 35 ms, binary 327 ms vs 29 ms over 5M docs). Random-access lookup (the sort/facet pattern,
forward
advanceExactover a 10% sample) is ~18 ms vs ~1 ms for a plain column, tens of ns per lookup, about the sameper-doc as the scan since a doc absent from a sparse layer is skipped rather than scanned. It's all bounded by
setMaxDocValuesOverlays(default 16, more overlays means cheaper writes but more layers to merge on read), and a mergeflattens it back. I also ran the batch with 4 threads querying while the updates landed and everything stayed correct
with the write numbers holding.
The benchmark is only here so you can look at the numbers for this review, it isn't meant to land. Here's the line I
ran:
(add
-Ddvbench.rate=5000to throttle,-Ddvbench.type=binaryor-Ddvbench.type=softdeletefor the other fields,-Ddvbench.queryThreads=4to query under load.)Commits
Split so each piece is easy to look at on its own:
setMaxDocValuesOverlaysknob