Skip to content

DiskSeismic: add scalar-quantized index (disk_seismic_sq) - #37

Merged
chishui merged 2 commits into
opensearch-project:mainfrom
zirui-song-18:disk-seismic-sq
Aug 28, 2026
Merged

chishui merged 2 commits into
opensearch-project:mainfrom
zirui-song-18:disk-seismic-sq

Conversation

@zirui-song-18

Copy link
Copy Markdown
Collaborator

Description

This PR is to implement the scalar quantization of DiskSeismicIndex.

  ┌─────────┬────────────────────────────────┬───────────────────┬─────────────────┬───────────────────┬───────────┐  
  │         │           index size           │    build total    │ p50 / p99 (ms)  │    bytes read     │ recall@10 │
  ├─────────┼────────────────────────────────┼───────────────────┼─────────────────┼───────────────────┼───────────┤
  │ float32 │ 122,609,780,704 B (114.19 GiB) │ 254.19 s          │ 0.1393 / 0.2112 │ 57.54 GB          │ 0.900530  │
  ├─────────┼────────────────────────────────┼───────────────────┼─────────────────┼───────────────────┼───────────┤     
  │ 16-bit  │ 92,078,408,368 B (0.7510×)     │ 216.62 s (0.852×) │ 0.1379 / 0.2092 │ 43.46 GB (0.755×) │ 0.900530  │
  ├─────────┼────────────────────────────────┼───────────────────┼─────────────────┼───────────────────┼───────────┤
  │ 8-bit   │ 77,254,839,584 B (0.6301×)     │ 194.84 s (0.767×) │ 0.1348 / 0.2050 │ 35.10 GB (0.610×) │ 0.893696  │
  └─────────┴────────────────────────────────┴───────────────────┴─────────────────┴───────────────────┴───────────┘

Issues Resolved

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
For more information on following Developer Certificate of Origin and signing off your commits, please check here.

Signed-off-by: Zirui Song <zrsong@amazon.com>
sq_(quantizer_type, vmin, vmax),
cluster_parameter_(parameter) {}

void DiskSeismicScalarQuantizedIndex::read_csr(const char* file_path,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

do you need to override this? I think you only want to handle kMmap use case

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yes — the override exists only to reject the kMmap case: a mapped CSR is borrowed as float, but this index searches over codes, so mapping it would misread. The in-memory case just delegates to the base. So it's scoped to exactly the kMmap use case you mention. DiskSeismicIndex doesn't need it (it's float), and this mirrors SeismicScalarQuantizedIndex::read_csr. Kept.

// A quantizer described by an index file. bytes_per_value() treats anything but
// QT_8bit as 16-bit, so an undefined type would silently pick an element width
// rather than be rejected.
ScalarQuantizer quantizer_from_file(QuantizerType type, float vmin,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

while it's named "quantizer from file", but the signature has nothing to do with file

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good catch — renamed to make_scalar_quantizer(type, vmin, vmax);

}

// A candidate block: its summary score and its (posting list, cluster) address.
struct BlockCandidate {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

it's shared with diskseismicindex, reuse

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Ack

// Score every document of one block against the dense query codes, 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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

seems these helper functions share the logic with DiskSeismicIndex, could you refactor them to make them work for both indices?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Ack

// Resolve `cut` and `k_prime`. A DiskSeismicSearchParameters (including a
// DiskSeismicSQSearchParameters) carries k_prime; a plain
// SeismicSearchParameters (or null) uses the default budget.
const DiskSeismicSearchParameters defaults;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

duplicate logic block, make it shared. Check even if search logic can be refactored to a shared function

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Ack

query_sq.encode(values, codes.data(), nnz);
const uint8_t* query_values = codes.data();

std::vector<std::vector<float>> result_distances(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

share code with line 204,205

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Ack


auto DiskSeismicScalarQuantizedIndex::single_query(
std::vector<uint8_t>& dense, absl::flat_hash_set<idx_t>& visited,
const term_t* q_idx, const uint8_t* q_val_bytes, size_t q_len,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

expand q_xxx to query_xxx

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Ack

}

void DiskSeismicScalarQuantizedIndex::write_index(IOWriter* io_writer) {
write_header(io_writer);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

change to write_quantization_header

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Renamed to write_quantization_header. One note: the sibling SeismicScalarQuantizedIndex (from #36) calls its equivalent write_quantizer_header — want me to rename that one to match, or should I keep write_quantizer_header here for parity with SESQ? Happy either way; just want them consistent.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

sorry, please make them consistent

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Sure.


DiskSeismicScalarQuantizedIndex* DiskSeismicScalarQuantizedIndex::mmap_index(
int dimension, const char* index_file, size_t pos) {
throw_if_null(index_file, "index_file must not be null");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

there is a valid file check in check.h, use that for file check, you may also want to apply in DiskSeismicIndex

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I'm using throw_if_null(index_file, ...) from checks.h here, same as SeismicIndex/InvertedIndex/DiskSeismicIndex::mmap_index; the file's openability is then enforced by MmapFile's constructor, which throws if it can't open. I didn't spot a stronger file-validity helper in checks.h (it has null/positive/overflow checks). Did you have a specific one in mind — or want me to add a shared throw_if_invalid_index_file and apply it across all the mmap readers? Happy to do that consistently.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

interesting, I remember I implemented a check for file pointer and whether file exist, but it's not there, I guess it's removed somehow or in a local branch, never mind.

@zirui-song-18
zirui-song-18 force-pushed the disk-seismic-sq branch 2 times, most recently from 9fa616b to a5110f9 Compare August 27, 2026 09:34
@zirui-song-18
zirui-song-18 requested a review from chishui August 27, 2026 09:38
Comment thread nsparse/disk_seismic_search.cpp Outdated
return {cut, k_prime};
}

pair_of_score_id_vectors_t padded_results(idx_t n, int k) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

what about initialize_padded_results

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Renamed to initialize_padded_results.

Comment thread nsparse/disk_seismic_search.cpp Outdated
n, std::vector<idx_t>(k, INVALID_IDX))};
}

pair_of_score_id_vector_t groc_search_query(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

groc is not a common term here, right?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

You are right. Renamed the function to block_budget_query and dropped "GroC" from the comments.

query_values + i * element_size, element_size,
dense + static_cast<size_t>(query_indices[i]) * element_size);
}
visited.clear();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

the function will only be called once per query, right?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yes — one call per query inside the #pragma omp for.

@chishui chishui left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

  1. write_index / read_index / mmap_index / add / build and the whole batch-search loop are still near-verbatim copies of DiskSeismicIndex's, differing only in the value width and the quantizer header. Extract a shared base instead of keeping a third parallel copy.
  2. Same for the tests -- both the C++ helpers and the python file are forks of the disk_seismic ones.
  3. write_quantization_header vs SESQ's write_quantizer_header is still inconsistent.

Comment thread nsparse/disk_seismic_search.cpp Outdated
// summary (stored at the same width), so scores are comparable across
// posting lists for the global ranking.
std::vector<BlockCandidate> candidates;
std::vector<float> score_scratch;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

both allocate per query on the hot path. pass them in as per-thread scratch like dense/visited, or at least reserve

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Moved candidates and score_scratch out to per-thread scratch, passed in alongside dense/visited, so nothing allocates per query now.

"read_index(file, IndexIoFlag::kUseMmap)");
}

void DiskSeismicScalarQuantizedIndex::write_quantization_header(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

SeismicScalarQuantizedIndex still has write_quantizer_header, please rename that one too

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done — renamed SESQ's write_quantizer_header and read_quantizer_header to write_quantization_header/read_quantization_header so both indexes match.

inv_list_writer.mmap_deserialize(&cursor);
detail::InlineForwardIndex forward;
forward.mmap_deserialize(&cursor);
throw_if_element_size_mismatch(forward, sq);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

this only checks fwd. score_summaries_transposed dispatches on the summaries' own element_size_, verify that against the quantizer too?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good catch — added an element_size() accessor on InvertedListClusters and validate_mapped_payload now checks the summaries' width against the quantizer in addition to the forward index's.

return results;
}

void DiskSeismicScalarQuantizedIndex::write_index(IOWriter* io_writer) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

this and read_index/mmap_index/add/build are still copies of DiskSeismicIndex's, differing only in the code width and the quantizer header. shared base?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done — added DiskSeismicIndexBase (disk_seismic_index_base.{h,cpp}). It owns add/build/search/write_index/read_index and the mmap payload load; both indexes derive from it and supply only the differences via hooks (code_element_size, encode_values, encode_query, decode_scores, write_payload_header, validate_mapped_payload). The two .cpps dropped ~730 lines between them. DiskSeismic's bit-exact parity tests still pass, so behavior is unchanged.


void DiskSeismicScalarQuantizedIndex::write_index(IOWriter* io_writer) {
write_quantization_header(io_writer);
const uint64_t nv = num_vectors_;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: drop the const instead of const_cast-ing it away. same in disk_seismic_index.cpp

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Dropped the const on nv instead of casting it away, here and in disk_seismic_index.cpp

query_sq.encode(values, codes.data(), nnz);
const uint8_t* query_batch = codes.data();

pair_of_score_id_vectors_t results = detail::padded_results(n, k);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

every row is overwritten below, so the -1.0/INVALID_IDX fill is n*k wasted writes on this path

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed

Comment thread nsparse/index_factory.cpp Outdated
}

if (index_type == "disk_seismic_sq") {
std::string quantizer_str = get_param("quantizer", "8bit");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

  1. duplicate of the seismic_sq block below, move the quantizer/vmin/vmax/cluster parsing into a function
  2. an unknown quantizer= value silently becomes 8bit, reject it?
  3. FYI the seismic_sq copy has .lambda = lambda = lambda (line 157) -- a shared helper kills that too

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

  1. Extracted parse_cluster_params and parse_quantizer_config, shared by seismic/disk_seismic/seismic_sq/disk_seismic_sq.
  2. An unrecognized quantizer= now throws instead of defaulting to 8bit (test added).
  3. That also removed the .lambda = lambda = lambda self-assignments.

Comment thread nsparse/python/swignsparse.swig Outdated
// Use %factory to enable proper downcasting based on runtime type
%newobject nsparse::index_factory;
%factory(nsparse::Index* nsparse::index_factory, nsparse::BrutalIndex, nsparse::SeismicIndex, nsparse::SeismicScalarQuantizedIndex, nsparse::IDMapIndex, nsparse::InvertedIndex);
%factory(nsparse::Index* nsparse::index_factory, nsparse::BrutalIndex, nsparse::SeismicIndex, nsparse::SeismicScalarQuantizedIndex, nsparse::DiskSeismicScalarQuantizedIndex, nsparse::IDMapIndex, nsparse::InvertedIndex);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nsparse::DiskSeismicIndex is missing from both %factory lists, add it?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Added nsparse::DiskSeismicIndex to both %factory lists.

Signed-off-by: Zirui Song <zrsong@amazon.com>
@zirui-song-18

Copy link
Copy Markdown
Collaborator Author
  1. write_index / read_index / mmap_index / add / build and the whole batch-search loop are still near-verbatim copies of DiskSeismicIndex's, differing only in the value width and the quantizer header. Extract a shared base instead of keeping a third parallel copy.
  2. Same for the tests -- both the C++ helpers and the python file are forks of the disk_seismic ones.
  3. write_quantization_header vs SESQ's write_quantizer_header is still inconsistent.

Thanks — I think this predated my last push. Here's where each stands on the latest revision:

  1. Shared base — done: DiskSeismicIndexBase (disk_seismic_index_base.{h,cpp}) now owns add/build/search/write_index/read_index/mmap-load and the batch loop; DiskSeismicIndex and DiskSeismicScalarQuantizedIndex are thin, supplying only the width/encode/decode/header via virtual hooks. No third parallel copy.
  2. Tests — now deduped too: shared C++ helpers moved to tests/disk_seismic_test_util.h (both suites use it), and the Python contract tests moved to a DiskSeismicContract base class in disk_seismic_contract.py that both test_disk_seismic_index.py and test_disk_seismic_sq_index.py subclass — the SQ file keeps only its quantization-specific tests.
  3. Naming — done: renamed SESQ's write_quantizer_header/read_quantizer_header to write_quantization_header/read_quantization_header, so both indexes match.

@zirui-song-18
zirui-song-18 requested a review from chishui August 27, 2026 12:53
@chishui
chishui merged commit 9a74567 into opensearch-project:main Aug 28, 2026
9 checks passed
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.

2 participants