Repository navigation
DiskSeismic: Add disk_seismic_index #32
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,346 @@ | ||
| /** | ||
| * Copyright OpenSearch Contributors | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| * | ||
| * The OpenSearch Contributors require contributions made to | ||
| * this file be licensed under the Apache-2.0 license or a | ||
| * compatible open source license. | ||
| */ | ||
|
|
||
| #include "nsparse/disk_seismic_index.h" | ||
|
|
||
| #include <algorithm> | ||
| #include <cstddef> | ||
| #include <cstdint> | ||
| #include <memory> | ||
| #include <stdexcept> | ||
| #include <string> | ||
| #include <vector> | ||
|
|
||
| #include "absl/container/flat_hash_set.h" | ||
| #include "nsparse/cluster/inverted_list_clusters.h" | ||
| #include "nsparse/id_selector.h" | ||
| #include "nsparse/index.h" | ||
| #include "nsparse/io/inline_forward_index_io.h" | ||
| #include "nsparse/io/seismic_invlists_writer.h" | ||
| #include "nsparse/seismic_common.h" | ||
| #include "nsparse/sparse_vectors.h" | ||
| #include "nsparse/types.h" | ||
| #include "nsparse/utils/checks.h" | ||
| #include "nsparse/utils/distance_simd.h" | ||
| #include "nsparse/utils/mmap_cursor.h" | ||
| #include "nsparse/utils/mmap_file.h" | ||
| #include "nsparse/utils/ranker.h" | ||
| #include "nsparse/utils/vector_process.h" | ||
|
|
||
| namespace nsparse { | ||
| namespace { | ||
|
|
||
| constexpr int kElementSize = U32; | ||
|
|
||
| // Score one doc's vector against the dense query and push it, honoring | ||
| // visited-dedup and the id selector. Shared by both vector sources. | ||
| inline void score_doc(idx_t doc_id, const term_t* comps, const float* vals, | ||
| size_t nnz, const std::vector<float>& dense, | ||
| const IDSelector* id_selector, | ||
| detail::TopKHolder<idx_t>& heap, | ||
| absl::flat_hash_set<idx_t>& visited) { | ||
| if (!visited.insert(doc_id).second) { | ||
| return; | ||
| } | ||
| if (id_selector != nullptr && !id_selector->is_member(doc_id)) { | ||
| return; | ||
| } | ||
| const float score = | ||
| detail::dot_product_float_dense(comps, vals, nnz, dense.data()); | ||
| heap.add(score, doc_id); | ||
| } | ||
|
|
||
| // A candidate block: its summary score and its (posting list, cluster) address. | ||
| struct BlockCandidate { | ||
| float score; | ||
| uint32_t pl; | ||
| uint32_t cid; | ||
| }; | ||
|
|
||
| // Score every document of one block against the dense query, dedup via | ||
| // `visited` and honor the id selector. Vectors come from the inline forward | ||
| // index (fwd) when loaded, else from the in-RAM CSR of a fresh build. | ||
| void score_block(const detail::InlineForwardIndex* fwd, | ||
| const SparseVectors* vectors, | ||
| const std::vector<InvertedListClusters>& clusters, uint32_t pl, | ||
| uint32_t cid, const std::vector<float>& dense, | ||
| const IDSelector* id_selector, size_t element_size, | ||
| detail::TopKHolder<idx_t>& heap, | ||
| absl::flat_hash_set<idx_t>& visited) { | ||
| if (fwd != nullptr) { | ||
| const detail::BlockView bv = fwd->block(pl, cid); | ||
| if (bv.absent()) { | ||
| return; | ||
| } | ||
| for (uint32_t i = 0; i < bv.n_docs; ++i) { | ||
| score_doc( | ||
| static_cast<idx_t>(bv.doc_ids[i]), bv.doc_comps(i), | ||
| reinterpret_cast<const float*>(bv.doc_vals(i, element_size)), | ||
| bv.nnz(i), dense, id_selector, heap, visited); | ||
| } | ||
| } else if (vectors != nullptr) { | ||
| const auto data = vectors->get_all_data(); | ||
| const idx_t* const indptr = data.indptr_data; | ||
| const term_t* const indices = data.indices_data; | ||
| const float* const values = data.values_data; | ||
| for (const idx_t doc_id : clusters[pl].get_docs(cid)) { | ||
| const idx_t start = indptr[doc_id]; | ||
| const size_t len = indptr[doc_id + 1] - start; | ||
| score_doc(doc_id, indices + start, values + start, len, dense, | ||
| id_selector, heap, visited); | ||
| } | ||
| } | ||
| } | ||
| } // namespace | ||
|
|
||
| DiskSeismicIndex::DiskSeismicIndex(int dim) | ||
| : MmapIndex(dim), | ||
| cluster_parameter_(detail::kDefaultSeismicClusterParams) {} | ||
|
|
||
| DiskSeismicIndex::DiskSeismicIndex(int dim, SeismicClusterParameters parameter) | ||
| : MmapIndex(dim), cluster_parameter_(parameter) {} | ||
|
|
||
| void DiskSeismicIndex::add(idx_t n, const idx_t* indptr, const term_t* indices, | ||
| const float* values) { | ||
| throw_if_not_positive(n); | ||
| throw_if_any_null(indptr, indices, values); | ||
| const size_t indptr_size = n + 1; | ||
| const size_t nnz = indptr[n]; | ||
| if (vectors_ == nullptr) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. reset
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ack. |
||
| // Fresh container: start the count at 0 so a stale num_vectors_ (e.g. | ||
| // left by a prior mmap load, which has no vectors_) cannot accumulate. | ||
| num_vectors_ = 0; | ||
| vectors_ = std::make_unique<SparseVectors>( | ||
| SparseVectorsConfig{.element_size = kElementSize, | ||
| .dimension = static_cast<size_t>(dimension_)}); | ||
| } | ||
| vectors_->add_vectors(indptr, indptr_size, indices, nnz, | ||
| reinterpret_cast<const uint8_t*>(values), | ||
| nnz * kElementSize); | ||
| num_vectors_ += n; | ||
| } | ||
|
|
||
| void DiskSeismicIndex::build() { | ||
| clustered_inverted_lists = detail::build_inverted_lists_clusters( | ||
| get_vectors(), | ||
| {.element_size = kElementSize, | ||
| .dimension = static_cast<size_t>(get_dimension())}, | ||
| cluster_parameter_); | ||
| } | ||
|
|
||
| auto DiskSeismicIndex::search(idx_t n, const idx_t* indptr, | ||
| const term_t* indices, const float* values, int k, | ||
| SearchParameters* search_parameters) | ||
| -> pair_of_score_id_vectors_t { | ||
| // Quit early when there is nothing to score: no vectors, no queries, or no | ||
| // forward-vector source (fwd_ empty and vectors_ null — a corrupt or | ||
| // uninitialized index). | ||
| if (num_vectors_ == 0 || n == 0 || | ||
| (fwd_.num_blocks() == 0 && vectors_ == nullptr)) { | ||
| return { | ||
| std::vector<std::vector<float>>(n, std::vector<float>(k, -1.0F)), | ||
| std::vector<std::vector<idx_t>>( | ||
| n, std::vector<idx_t>(k, detail::INVALID_IDX))}; | ||
| } | ||
|
|
||
| // Resolve `cut` and `k_prime`. A DiskSeismicSearchParameters carries | ||
| // k_prime; a plain SeismicSearchParameters (or null) uses the default | ||
| // budget. The id-selector exact-match fast path is omitted (it needs an | ||
| // in-RAM SparseVectors a mapped index lacks); the selector is still honored | ||
| // in score_block. | ||
| const DiskSeismicSearchParameters defaults; | ||
| int cut = defaults.cut; | ||
| int k_prime = defaults.k_prime; | ||
| if (const auto* disk_parameters = | ||
| dynamic_cast<const DiskSeismicSearchParameters*>( | ||
| search_parameters)) { | ||
| cut = disk_parameters->cut; | ||
| k_prime = disk_parameters->k_prime; | ||
| } else if (const auto* seismic_parameters = | ||
| dynamic_cast<const SeismicSearchParameters*>( | ||
| search_parameters)) { | ||
| cut = seismic_parameters->cut; | ||
| } | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what about else situation?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. cut/k_prime has been initialized with default. else should be fine with nothing. |
||
| // else: search_parameters is null or an unrelated SearchParameters subtype, | ||
| // so cut and k_prime keep the defaults initialized above. | ||
|
|
||
| // k_prime is a block budget, not a document count: a block holds many docs, | ||
| // so k_prime < k is valid (a few blocks can still fill k, and a short-fall | ||
| // is padded like any under-budget search). Only a non-positive budget is | ||
| // rejected. | ||
| if (k_prime <= 0) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what if k_prime is smaller than k?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. k_prime here refers to the number of blocks to be fully examined. It has nothing to do with k. |
||
| throw std::invalid_argument( | ||
| "DiskSeismicIndex: k_prime (block budget) must be positive"); | ||
| } | ||
|
|
||
| std::vector<std::vector<float>> result_distances(n, std::vector<float>(k, -1.0F)); | ||
| std::vector<std::vector<idx_t>> result_labels( | ||
| n, std::vector<idx_t>(k, detail::INVALID_IDX)); | ||
| const size_t dim = static_cast<size_t>(dimension_); | ||
|
|
||
| #pragma omp parallel | ||
| { | ||
| std::vector<float> dense(dim, 0.0F); | ||
| absl::flat_hash_set<idx_t> visited; | ||
| visited.reserve(static_cast<size_t>(std::max(k, 1)) * 4096); | ||
|
|
||
| #pragma omp for schedule(dynamic, 64) | ||
| for (idx_t query_idx = 0; query_idx < n; ++query_idx) { | ||
| const idx_t start = indptr[query_idx]; | ||
| const size_t len = indptr[query_idx + 1] - start; | ||
| const term_t* q_indices = indices + start; | ||
| const float* q_values = values + start; | ||
| const auto& cuts = | ||
| detail::top_k_tokens(q_indices, q_values, len, cut); | ||
| auto [distances, labels] = | ||
| single_query(dense, visited, q_indices, q_values, len, cuts, k, | ||
| k_prime, search_parameters); | ||
| result_distances[query_idx] = std::move(distances); | ||
| result_labels[query_idx] = std::move(labels); | ||
| } | ||
| } | ||
| return {result_distances, result_labels}; | ||
| } | ||
|
|
||
| auto DiskSeismicIndex::single_query( | ||
| std::vector<float>& dense, absl::flat_hash_set<idx_t>& visited, | ||
| const term_t* q_indices, const float* q_values, size_t q_len, | ||
| const std::vector<term_t>& cuts, int k, int k_prime, | ||
| SearchParameters* search_parameters) -> pair_of_score_id_vector_t { | ||
| if (num_vectors_ == 0) { | ||
| return {{}, {}}; | ||
| } | ||
| // Scatter the query into the dense lookup table. | ||
| for (size_t i = 0; i < q_len; ++i) { | ||
| dense[q_indices[i]] = q_values[i]; | ||
| } | ||
| visited.clear(); | ||
|
|
||
| // Prefer the mapped inline forward index; fall back to the in-RAM vectors | ||
| // of a fresh build. | ||
| const detail::InlineForwardIndex* fwd = | ||
| fwd_.num_blocks() > 0 ? &fwd_ : nullptr; | ||
| const SparseVectors* vectors = fwd == nullptr ? vectors_.get() : nullptr; | ||
| const size_t element_size = fwd != nullptr | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. so here, can you throw when fwd and vectors are null?
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. oh, never mind
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. but can you quit early though?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done — switched from throwing to quitting early. The no-source case (fwd_.num_blocks()==0 && vectors_==nullptr) is now folded into the early-return at the top of search(), alongside num_vectors_==0 || n==0, returning the k-length padded empty result. A plain return is safe even though the scoring runs under OpenMP (only an escaping throw would be a problem), and it's checked once before the parallel region rather than per block. |
||
| ? fwd->element_size() | ||
| : static_cast<size_t>(kElementSize); | ||
| const IDSelector* id_selector = search_parameters == nullptr | ||
| ? nullptr | ||
| : search_parameters->get_id_selector(); | ||
|
|
||
| // Collect every candidate block across the cut lists with its summary | ||
| // score. score_summaries_transposed dots the full query with each cluster | ||
| // summary, so scores are comparable across posting lists for the global | ||
| // ranking. | ||
| std::vector<BlockCandidate> candidates; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. are sizes of both variables known already? can you call
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Now add a pre-scan:
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. no, no, I mean if you already know the size, if not, we don't do this just to calculate the size and reserve which could be even heavier.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Got it. That makes sense. |
||
| std::vector<float> score_scratch; | ||
| for (const term_t term : cuts) { | ||
| if (term >= clustered_inverted_lists.size()) [[unlikely]] { | ||
| continue; | ||
| } | ||
| const InvertedListClusters& cluster_invlist = | ||
| clustered_inverted_lists[term]; | ||
| const size_t n_clusters = cluster_invlist.cluster_size(); | ||
| if (n_clusters == 0) { | ||
| continue; | ||
| } | ||
| cluster_invlist.score_summaries_transposed( | ||
| q_indices, reinterpret_cast<const uint8_t*>(q_values), q_len, | ||
| score_scratch); | ||
| for (size_t cid = 0; cid < n_clusters; ++cid) { | ||
| candidates.push_back( | ||
| {score_scratch[cid], term, static_cast<uint32_t>(cid)}); | ||
| } | ||
| } | ||
|
|
||
| // Select the global top-k_prime blocks by summary score. The order among | ||
| // them does not affect the result (dedup + exact scoring are | ||
| // order-independent). | ||
| const size_t budget = | ||
| std::min(static_cast<size_t>(k_prime), candidates.size()); | ||
| if (budget < candidates.size()) { | ||
| std::nth_element(candidates.begin(), candidates.begin() + budget, | ||
| candidates.end(), | ||
| [](const BlockCandidate& a, const BlockCandidate& b) { | ||
| return a.score > b.score; | ||
| }); | ||
| candidates.resize(budget); | ||
| } | ||
|
|
||
| // Score the selected blocks; visited dedups docs shared across them. | ||
| detail::TopKHolder<idx_t> holder(k); | ||
| for (const BlockCandidate& candidate : candidates) { | ||
| score_block(fwd, vectors, clustered_inverted_lists, candidate.pl, | ||
| candidate.cid, dense, id_selector, element_size, holder, | ||
| visited); | ||
| } | ||
|
|
||
| // Reset only the query's own positions (q_len is small) instead of | ||
| // memset-ing the whole dim-sized table; mirrors the scatter at entry. The | ||
| // scattered writes are bounded by q_len and negligible next to the block | ||
| // reads/scoring that dominate the query. | ||
| for (size_t i = 0; i < q_len; ++i) { | ||
| dense[q_indices[i]] = 0.0F; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this for loop may have multiple cache misses, is the performance of this for loop fine?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We only reset the number of q_len position in query, which is quite small. This is much cheaper than memset the whole dim table. |
||
| } | ||
| auto [scores, ids] = holder.top_k_items_descending(); | ||
| scores.resize(k, -1.0F); | ||
| ids.resize(k, detail::INVALID_IDX); | ||
| return {scores, ids}; | ||
| } | ||
|
|
||
| void DiskSeismicIndex::write_index(IOWriter* io_writer) { | ||
| const uint64_t nv = num_vectors_; | ||
| io_writer->write(const_cast<uint64_t*>(&nv), sizeof(uint64_t), 1); | ||
| SeismicInvertedListsWriter inv_list_writer(clustered_inverted_lists); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. check if clustered list are written twice
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch, partially. The summaries are written once (only in the invlists section) and the per-doc vectors are written once (only in the inline forward section — DiskSeismic drops plain seismic's full-CSR forward write). What is written twice is the per-cluster doc-id membership: docs_/offsets_ in the invlists section and again as per-block doc_id[] in the inline forward. And on the mmap search path the invlists docs_/offsets_ are never read (membership comes from the inline doc_id[]; only the summaries + cluster counts are used) — so that copy is dead weight on disk. It's ~M×4 bytes (M = total doc-cluster memberships, ~0.4% of the file here), so it's a cleanliness/correctness fix rather than a size lever (quantization is the size lever). I can add a summaries-only serialize/deserialize path for the DiskSeismic invlists so docs_/offsets_ aren't persisted — docs_ is still needed in RAM at build time to lay out the blocks, just not on disk. But the cost of that change is not small. Maybe we can leave it a separate commit later. |
||
| inv_list_writer.serialize(io_writer); | ||
| // Inline forward index, built from the same clusters + vectors. An empty | ||
| // corpus uses a correctly-typed empty SparseVectors (element_size must be a | ||
| // valid width even with zero vectors) so the section still round-trips. | ||
| SparseVectors empty_vectors({.element_size = kElementSize, | ||
| .dimension = static_cast<size_t>(dimension_)}); | ||
| const SparseVectors& v = vectors_ != nullptr ? *vectors_ : empty_vectors; | ||
| detail::InlineForwardIndex forward(clustered_inverted_lists, v); | ||
| forward.serialize(io_writer); | ||
| } | ||
|
|
||
| void DiskSeismicIndex::read_index(IOReader* /*io_reader*/, int /*io_flags*/) { | ||
| // The inline forward index is borrowed from a mapping, never copied onto | ||
| // the heap, so this index has no copying read path. | ||
| throw std::runtime_error( | ||
| "DiskSeismicIndex is mmap-only; load with read_index(file, " | ||
| "IndexIoFlag::kUseMmap)"); | ||
| } | ||
|
|
||
| DiskSeismicIndex* DiskSeismicIndex::mmap_index(int dimension, | ||
| const char* index_file, | ||
| size_t pos) { | ||
| throw_if_null(index_file, "index_file must not be null"); | ||
| auto index = std::make_unique<DiskSeismicIndex>(dimension); | ||
|
|
||
| MmapFile mmap_file(std::string{index_file}); | ||
| MmapCursor cursor(mmap_file.data(), mmap_file.size()); | ||
| cursor.skip(pos); | ||
|
|
||
| // Same order write_index wrote them: doc count, summaries, inline forward. | ||
| const uint64_t nv = cursor.read_scalar<uint64_t>(); | ||
| SeismicInvertedListsWriter inv_list_writer; | ||
| inv_list_writer.mmap_deserialize(&cursor); | ||
| detail::InlineForwardIndex forward; | ||
| forward.mmap_deserialize(&cursor); | ||
|
|
||
| // Committed only once everything parsed. mapped_file_ last: the summaries | ||
| // and the forward index borrow from it, and moving it does not move the | ||
| // mapping. | ||
| index->clustered_inverted_lists = std::move(inv_list_writer.release()); | ||
| index->fwd_ = std::move(forward); | ||
| index->num_vectors_ = nv; | ||
| index->mapped_file_ = std::move(mmap_file); | ||
| return index.release(); | ||
| } | ||
| } // namespace nsparse | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what about throw exceptions if either
fwdorvectorsis null as I think you can do nothing without themThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It will throw, but in
search()before entering OpenMP. If we throw in score_block(), it will go out of OMP parallel.