Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

First-class random access API for KnnVectorValues #13779

Open
wants to merge 25 commits into
base: main
Choose a base branch
from

Conversation

msokolov
Copy link
Contributor

@msokolov msokolov commented Sep 12, 2024

addresses #13778

Key things in this PR:

  1. Introduces abstract KnnVectorValues from which ByteVectorValues and FloatVectorValues derive;
  2. Folds RandomAccessVectorValues into KnnVectorValues thus eliminating some casts.
  3. RandomAccessVectorValues.Floats becomes FloatVectorValues and RandomAccessVectorValues.Bytes becomes ByteVectorValues. RandomAccessQuantizedByteVectorValues folded into QuantizedByteVectorValues.
  4. IndexInput getSlice() moved to a new HasIndexSlice interface.
  5. Introduces VectorEncoding KnnVectorValues.getEncoding() to enable type-specific branches in a few places where we are dealing with abstract KnnVectorValues (tests only IIRC). Also used that to provide a default getVectorByteLength().
  6. KnnVectorValues no longer extends DocIdSetIterator; rather it provides a tightly-coupled iterator(). This enables refactoring common iteration patterns that were repeated many times in the code base. This new iterator, DocIndexIterator provides an additional method index() analogous to IndexedDISI.

Some of the methods on KnnVectorValues have default impls that throw UnsupportedOperationException enabling subclasses to provide partial implementations and relying on testing to catch missing required methods. I'd like feedback on this. Should we provide implementations we never use, just to make these classes complete? That didn't make sense to me. But the previous alternative of attempting to provide strict adherence to declarative contracts was becoming in my view, overly restrictive and leading to hard-to-maintain code. Some of these readers would only ever be used iteratively. Random access is required for search, but not used when merging the values themselves, and when we merge we do search, but using a temporary file so that searching is always done over a file-based value. Random access also gets used during merging when the index is sorted, again this is provided by specialized readers, so not every reader needs to implement random access. But the API maintenance is greatly simplified if we allow partial implementation. Anyway that is the idea I am trying out here. Can we live with a little less API purity and gain some simplicity and less boilerplate?

Notes for reviewers:

There is a lot of code change here, but much of it is repetitive. I recommend starting with KnnVectorValues and checking its DocIndexIterator inner class. The rest of the changes are basically consequences of introducing those abstrations in place of the Random*Values we removed.

@msokolov
Copy link
Contributor Author

another concern I have is how this would impact ongoing work to enable multiple vectors per doc/field. There would almost certainly be conflicts with that PR on the surface, but I hope this could actually simplify things in that the new DocIndexIterator class could be enhanced / extended to provide access to a series of values (maybe a list or array?) instead of (or in addition to?) a single one, possibly centralizing the required changes (since we have many fewer iterator implementations after this change).

@benwtrent
Copy link
Member

but I hope this could actually simplify things

That is my intuition as well.

Copy link
Contributor

@jpountz jpountz left a comment

Choose a reason for hiding this comment

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

I left a few thoughts/questions. In general, I see how such a random-access API change could help with e.g. your BP reordering work and be valuable in general. I was wondering if this API may be too tailored to HNSW and prevent us from supporting other interesting algorithms, but actually I don't think that this is the case?

* Creates a new copy of this {@link KnnVectorValues}. This is helpful when you need to access
* different values at once, to avoid overwriting the underlying vector returned.
*/
public abstract KnnVectorValues copy() throws IOException;
Copy link
Contributor

Choose a reason for hiding this comment

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

I wonder if we could make the API a bit nicer by removing this copy() and instead have something like a FloatVectorDictionary { float[] vectorValue(int ord); } and a method here that can return a new FloatVectorDictionary (a bit like SortedDocValues and TermsEnum).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The way SortedDocValuesTermsEnum is, calling its next method will overwrite the internal buffer ofd the SortedDocValues on which it is built, defeating the purpose of copy() which is to provide two completely independent sources. Another thing we could do is to add vectorValue(int ord, float[] scratch) allowing the caller to provide the memory to write to. If we had that, we wouldn't need copy(). Maybe we could manage to squeeze that into 10.0 too, but I'd rather do it in a separate PR

Copy link
Contributor

Choose a reason for hiding this comment

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

But if you call SortedDocValues#termsEnum twice, this would give you two independent sources of terms?

Copy link
Contributor

Choose a reason for hiding this comment

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

I always found copy very strange, but I get why it is there. I'd be tempted to leave it as is in this PR, changing the access model and cache of 1 float[] will be a bit tricky.

if (iterator == null) {
iterator = createIterator();
}
return iterator;
Copy link
Contributor

Choose a reason for hiding this comment

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

Could we make this return a new iterator every time to make the API a bit nicer? From a quick look, it seems that call sites could easily be adjusted to not rely on this method returning a shared instance?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Let me try - I was also a bit unhappy about this, but at one point along this journey I was relying on being able to recover the shared state - maybe I finally was able to get rid of that and just didn't notice!

Copy link
Contributor

Choose a reason for hiding this comment

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

a new iterator would be cleaner, if the use sites allow for it.

* Creates an iterator from this instance's ordinal-to-docid mapping which must be monotonic
* (docid increases when ordinal does).
*/
protected DocIndexIterator fromOrdToDoc() {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: could we make it look a bit more like DocIdSetIterator#all by moving it to DocIndexIterator#all?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

ah, you mean rename this method to all? sure, makes sense

@Override
public int advance(int target) throws IOException {
return slowAdvance(target);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

This looks like it could be a performance trap, which is why DocIdSetIterator offers this helper method without making it the default impl. Should we leave it without a default impl here too?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yes, I don't think anything relies on this, makes sense


@Override
public long cost() {
throw new UnsupportedOperationException();
Copy link
Contributor

Choose a reason for hiding this comment

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

Likewise here, I'd rather leave it unimplemented to force implementers to decide if having cost() throw an exception is fine. Presumably, most of the time it's not.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

hmm I think cost() is rarely used in the vector reader/writers which instead are concerned with KnnVectorValues.size() -- they typically want to know how many vector values there are and to the extent they care about the number of docs it's only when they must iterate through all of them and have no use for an estimate. These iterators aren't really used during searching?

Copy link
Contributor

Choose a reason for hiding this comment

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

If we default cost() to returning size(), that would work for me. But I'm not comfortable with having implementations of DocIdSetIterator#cost that may throw, which means e.g. that they cannot be put in a Conjunction DISI(which will want to sort its clauses by cost).

Copy link
Contributor

Choose a reason for hiding this comment

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

Copy link
Member

Choose a reason for hiding this comment

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

I agree here. Either it should default to size() via some provided dependency or it shouldn't implement at all and force sub-classes.

@jpountz
Copy link
Contributor

jpountz commented Sep 12, 2024

Am guessing correctly that you're targeting 10.0 for this change?

@msokolov
Copy link
Contributor Author

Thanks for the quick review! I will get started on addressing. As for timeline for this change, it would definitely be convenient to get in to 10.0 release. I think you had said 9/22 would be a feature freeze date; it seems we could possibly meet that timeline. I will be traveling starting tomorrow for a week, but I should be able to put in some quality time on the plane LOL

public byte[] vectorValue() throws IOException {
return current.values.vectorValue();
public byte[] vectorValue(int ord) throws IOException {
return current.values.vectorValue(current.values.iterator().index());
Copy link
Contributor

Choose a reason for hiding this comment

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

This part feels a bit hacky, could we instead merge the ord->vector mappings of the various vector values by concatenating them?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Maybe we can enhance DocIDMerger by adding random access to it

@jpountz
Copy link
Contributor

jpountz commented Sep 13, 2024

think you had said 9/22 would be a feature freeze date

I was thinking of doing it next week, but we can backport this PR even though the branch has been cut if it looks ready/safe.

Copy link
Contributor

@ChrisHegarty ChrisHegarty left a comment

Choose a reason for hiding this comment

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

I really like this change. I see a lot of refactoring similar to what I half started at one point or the other, but never finished. There are some specific comments to be addressed, but otherwise the approach LGTM.

@Override
RandomAccessQuantizedByteVectorValues copy() throws IOException;
/** Returns an IndexInput from which to read this instance's values. */
IndexInput getSlice();
Copy link
Contributor

Choose a reason for hiding this comment

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

I very much like this, and had something similar in a past unmarked PR. 👍

* Creates a new copy of this {@link KnnVectorValues}. This is helpful when you need to access
* different values at once, to avoid overwriting the underlying vector returned.
*/
public abstract KnnVectorValues copy() throws IOException;
Copy link
Contributor

Choose a reason for hiding this comment

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

I always found copy very strange, but I get why it is there. I'd be tempted to leave it as is in this PR, changing the access model and cache of 1 float[] will be a bit tricky.

if (iterator == null) {
iterator = createIterator();
}
return iterator;
Copy link
Contributor

Choose a reason for hiding this comment

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

a new iterator would be cleaner, if the use sites allow for it.

@msokolov
Copy link
Contributor Author

I pushed a new revision here addressing some of the major comments:

  1. KnnVectorValues.iterator() now generally provides a new iterator; no caching is done. I removed createIterator(). Main impact was on VectorScorer (and in tests) where we now create iterators and store them locally. This is much better; thanks for the feedback.
  2. I added implementations for advance() and got rid of the default impl.
  3. I removed impls of cost() and added a default impl that throws UOE. This method is only ever used during search() and most of these values sources will never be searched. The exceptions are those that can be used by the ValueSource API: basically the indexed values returned by a reader. We have lots and lots of other values impls that are used during indexing for which we don't need cost. I briefly considered separating these new iterators from DISI, but that ended up in some trouble.
  4. re: getVectorByteLength() @ChrisHegarty is correct that this is needed as it is today. We could in theory make it final (or inline it whatever) if we added some more VectorEncodings to represent the compressed cases, but I'm inclined to leave it as is. This way we could in theory support a variable size encoding? And anyway it isn't clear we want to mix up the "encoding" with compression?

I didn't have a chance to look seriously at removing copy() API. I don't think we ought to create a simple wrapper though since afaict it would require an additional memory copy of every vector value.

@msokolov
Copy link
Contributor Author

msokolov commented Sep 16, 2024

OK there seem to be some test failures ... I did a complete run, but randomized testing always seems to ferret out something interesting!

Actually those really should have failed on any test run -- not sure how I missed them, oops

@msokolov
Copy link
Contributor Author

Regarding the rename of fromOrdToDoc to all I think it was not helpful and plan to revert or maybe come up with some other name. The problem is we also have createDenseIterator which is also all. Essentially we have Sparse and Dense all-iterators. Maybe instead of fromOrdToDoc we can say createSparseIterator?

@jpountz
Copy link
Contributor

jpountz commented Sep 16, 2024

FWIW I started playing with removing copy() by replacing it with a factory method for a dictionary: msokolov@ae7aca3. Not sure how far I'll go. :)

@msokolov
Copy link
Contributor Author

I also just started trying to replace copy() with the approach of adding vectorValue(int ord, float[] outValue) although this does add a copy operation in some cases where previously we would expose internal storage so I'm not sure it's great

Copy link
Member

@benwtrent benwtrent left a comment

Choose a reason for hiding this comment

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

I like the simplicity we gain here.

Comment on lines 353 to 355
// FIXME what can we assert here?
// assert ord == iterator.index();
return current.values.vectorValue(current.index());
Copy link
Member

Choose a reason for hiding this comment

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

This is the biggest "gotcha" in this whole thing I think. Does DocIdMerger allow random access at all?

It seems like vectorValue(ord) should also be able to jump between current sub iterators and move backwards and forwards. But, I don't think DocIdMerger allows backwards movement at all.

Copy link
Member

Choose a reason for hiding this comment

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

I think unless we can fix DocIdMerger, we should throw an error here indicating that only forward iteration is allowed.

Copy link
Contributor

Choose a reason for hiding this comment

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

I had thought we'd fix it by concatenating the vector dictionaries of each segment. Then based on the requested vector ordinal, you could compute the segment that the vector belongs to via ReaderUtil#subIndex.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

There is something similar here in SlowCompositeCodecReaderWrapper - binary search across sub-values


@Override
public long cost() {
throw new UnsupportedOperationException();
Copy link
Member

Choose a reason for hiding this comment

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

I agree here. Either it should default to size() via some provided dependency or it shouldn't implement at all and force sub-classes.

Comment on lines +50 to 52
public QuantizedByteVectorValues copy() throws IOException {
return this;
}
Copy link
Member

Choose a reason for hiding this comment

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

defaulting copy to this is dangerous. I would recommend against it.

@@ -269,11 +270,12 @@ static ScalarQuantizer fromVectors(
if (totalVectorCount == 0) {
return new ScalarQuantizer(0f, 0f, bits);
}
KnnVectorValues.DocIndexIterator iterator = floatVectorValues.iterator();
Copy link
Member

Choose a reason for hiding this comment

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

I think this is ok for now, but this quantization code can be made much simpler if indeed we can randomly access even across various merged doc sub iterators.

Comment on lines 36 to 37
public FloatVectorValues copy() throws IOException {
return this;
Copy link
Member

Choose a reason for hiding this comment

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

we shouldn't default to this that is dangerous. If an off-heap thing that assumes caching and doesn't allow multi-threaded access overrides but forgets to override, we are in a bad place.

Comment on lines 36 to 37
public ByteVectorValues copy() throws IOException {
return this;
Copy link
Member

Choose a reason for hiding this comment

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

again, this default is dangerous to me.

@jpountz
Copy link
Contributor

jpountz commented Sep 18, 2024

I iterated a bit on my branch, so that there is no more call site for FloatVectorValues#copy: https://github.com/msokolov/lucene/compare/knn-vector-random...jpountz:lucene:knn-vector-random?expand=1. The API looks better to me this way, but I'm keen on getting feedback before pursuing as this is quite a tedious refactoring.

@msokolov
Copy link
Contributor Author

FWIW I tried removing copy() and using caller-supplied storage in vectorValue. In many ways this looks nicer, but it leads to substantial slowdown in indexing/merging because of the additional copy required, so I think it's not really viable. We could provide both vectorValue(int) and vectorValue(int, float[]) and avoid copy() that way. Maybe that's best

@msokolov
Copy link
Contributor Author

ooh I just saw the Dictionary branch - that looks like a nice approach, I don't think I really understood what you were proposing before. One Q I have is can we remove copy()? Do we need to deprecate it first -- and if so, could we deprecate in 9x branch? Or perhaps it is too late for that

@benwtrent
Copy link
Member

One Q I have is can we remove copy()? Do we need to deprecate it first -- and if so, could we deprecate in 9x branch?

You are already removing RandomAccessVectorValues, which is the thing that used copy(), so, I am not sure what there is to deprecate 😂

@msokolov
Copy link
Contributor Author

I'll post one more iteration here addressing the concerns about dangerous default impls that adds back impls of copy() and cost(). I also added a test-and-throw ensuring that the vectorValues impls that require forward-iteration enforce it. We can fully implement random access later without breaking any APIs.

I also think we should go ahead with Adrien's Dictionary idea, but do this in two steps because there is a lot going on here already.

@benwtrent
Copy link
Member

The dictionary idea is OK, but I still don't see how it removes copy(). Besides the caching of values, copy gives us multi-threaded safety by copying the underlying index readers. Otherwise we are using the same reader between threads. For concurrent merging of graphs, this is important.

I agree, any further refactoring should be done in another PR.

@msokolov
Copy link
Contributor Author

I think the idea w/Dictionary is that callers, instead of calling copy().vectorValue(int ord) would call dictionary().vectorValue(int ord). So then the scratch vector storage (if needed) would be in the Dictionary not in the VectorValues, and thus not shared by multiple users of the same values instance. In some sense it's not very different, but in the sense that the Dictionary has a much more limited API than the source it came from, it is different.

@jpountz
Copy link
Contributor

jpountz commented Sep 20, 2024

Exactly. I tried to model it similarly to what doc values do, where SortedDocValues#termsEnum() returns a dictionary with a different backing IndexInput clone on every call.

@msokolov
Copy link
Contributor Author

OK I think we've addressed the blocking concerns that have been raised here and I plan to push later today if nothing else comes up. Regarding removing copy() in favor of dictionary() I'll open a separate issue. If Adrien finishes it up, great, but I may also see if I can find time to take that forward soon; it would be good to get it done for 10 since it would be a breaking change and ideally we don't want copy() to linger as deprecated. As for implementing better random access in merged values I think we can take that up at a more relaxed pace since it doesn't require any API change.

@msokolov
Copy link
Contributor Author

hm interesting there was an EOFException in there - I'll dig

@msokolov
Copy link
Contributor Author

OK, I found an off-by-one error plus a problem with lazy iterator creation that slipped in when we got rid of createIterator(). It makes me a little nervous these didn't show up in earlier testing. I'm now running with tests.iter=20

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.

5 participants