Add JNI layer and core library improvements for OpenSearch integration#13
Conversation
|
| namespace nsparse { | ||
| namespace { | ||
|
|
||
| /** |
There was a problem hiding this comment.
Restored the summarize_ doxygen block (fixed the 'Generage' typo and documented the offsets param that the current signature added). Pushed in 7bfa0b0.
|
|
||
| // Dense buffer for max-pooling (reused across clusters) | ||
| std::vector<T> dense_max; | ||
| if (dimension > 0) { |
There was a problem hiding this comment.
IIRC, dimension now is required, better to ensure it's non-null and just throw exception at the beginning if (dimension == 0) to make logic simplified
There was a problem hiding this comment.
Added if (dimension == 0) throw std::runtime_error(...) at the entry of both builders in seismic_common.cpp (5250b82), since the native engine always supplies the true dimension. I left the dimension == 0 fallback branches inside summarize_ and map_docs_to_clusters_sparse_invindex in place, because those key off vectors->get_dimension() (the vectors payload) rather than the config dimension guarded at build entry — removing them is a separate, larger change. Let me know if you want those torn out too and I can do it in a follow-up.
| const term_t* indices = vectors->indices_data(); | ||
| const auto* values = reinterpret_cast<const T*>(vectors->values_data()); | ||
| size_t dimension = vectors->get_dimension(); | ||
| if (dimension == 0) { |
There was a problem hiding this comment.
same here, move dimension zero handling to entry point to avoid check spread to many places
There was a problem hiding this comment.
Agreed — I'll hoist this out of the helpers. With the explicit strategy parameter (see the thread on line 315), the path decision is made once at the entry point (map_docs_to_clusters) and passed down, so map_docs_to_clusters_sparse_invindex and the other helpers no longer each re-check dimension == 0. That removes the duplicated guard entirely rather than just relocating it.
| if (n_clusters == 0 || n_docs == 0) { | ||
| return; | ||
| } | ||
| if (vectors->get_dimension() > 0) { |
There was a problem hiding this comment.
why using dimension equal 0 as a switch between your added logic and existing logic? If we want to make it a static choice, we can use compilation option.
There was a problem hiding this comment.
Agreed that dimension is the wrong thing to switch on — it's a data property of the vectors, not a strategy flag, and overloading it this way makes the control flow unreadable (you have to know the convention "dimension==0 means old path" to follow the code). I'll fix that.
On the compile option suggestion specifically, I'd push back: I don't think the old-vs-new path should be a static/compile-time choice. A #ifdef/CMake option bakes the decision into the binary — the plugin ships a single .so, so a compile flag would force one path for all users and require a rebuild (or shipping multiple artifacts) to change it. The path selection is better made at runtime.
Proposal: replace the dimension == 0 switch with an explicit, self-documenting strategy — e.g. a ClusteringStrategy { Auto, Dense, SparseInvIndex } enum on the build config — decided once at the entry point and threaded down, so no helper re-derives it from dimension. That addresses both the readability problem and the "check spread to many places" comment on the other thread.
One open question back to you: do we actually expect both paths to coexist long-term, or is the new sparse-invindex path meant to fully replace the old one? If it strictly dominates for our use, the cleaner move is to delete the old path entirely rather than parameterize the choice — happy to go that way instead if that's the intent.
There was a problem hiding this comment.
Resolved by removing the dead path rather than parameterizing it.
Digging in, the two implementations selected by dimension == 0 were:
dimension > 0→ the sparse inverted-index clustering path (added in this PR)dimension == 0→ the older dense-centroid AVX512 path
The dense path turned out to be unreachable and inconsistent with the tests:
- In the plugin,
createIndexalways passes a real dimension (65536), soget_dimension()is never 0 and the sparse inverted-index path is always taken. - Every kmeans/seismic test uses
dimension = 10(the sparse path); none exercise the dense path. - The existing invariant test
no_duplicate_and_no_missing_docs_when_centroids_in_docsencodes the sparse path's centroid-skip behavior as required; the dense path (which re-clusters centroid docs via argmax) would violate it.
So instead of a compile option or a runtime strategy flag, I deleted the dense-centroid path entirely (map_docs_to_clusters_avx512 + its centroids_to_dense / initialize_cluster_representatives / get_dense_vector_max_dimension helpers, plus an unused dot_product_typed_dense). map_docs_to_clusters now dispatches purely by element_size — no more dimension == 0 switch. Net -166 lines; full suite (365 tests) still passes.
Pushed as b414549.
|
|
||
| // Build offset table (prefix sum) | ||
| std::vector<uint32_t> offsets(dimension + 1); | ||
| offsets[0] = 0; |
There was a problem hiding this comment.
nit: it's already initialized to 0
There was a problem hiding this comment.
Removed — the vector is value-initialized to 0. Fixed in the latest push.
| if (n_batches < 1) n_batches = 1; | ||
| size_t batch_size = (dimension + n_batches - 1) / n_batches; | ||
|
|
||
| fprintf(stderr, "[nsparse] build_inverted_lists: n_docs=%zu, dimension=%zu, " |
There was a problem hiding this comment.
Firstly, are these logs useful for all users of this library? They seems to me only for debugging. If so, we'd better introduce a verbose parameter similar to https://github.com/facebookresearch/faiss/blob/main/faiss/Index.h#L107 and only print logs when it's true
There was a problem hiding this comment.
Done in 5250b82. Added a verbose flag to SeismicClusterParameters (default false, same spirit as faiss Index::verbose) and gated all 7 per-batch fprintf(stderr, ...) diagnostics behind it. It is parsed from the index description (verbose=1) in index_factory for both seismic and seismic_sq, so production builds stay quiet by default.
| size_t mem_budget = 0; | ||
| #ifdef __linux__ | ||
| // Use MemAvailable from /proc/meminfo | ||
| FILE* meminfo = fopen("/proc/meminfo", "r"); |
There was a problem hiding this comment.
Done in 4896001 — extracted to detail::query_available_memory_bytes() (the /proc/meminfo reader) and detail::resolve_build_mem_budget() (the budget policy), both unit-tested.
| // Choose batch count to fit inverted lists within available memory. | ||
| // Reserve 4 GB for clustering overhead and OS. | ||
| constexpr size_t kReserve = 4ULL * 1024 * 1024 * 1024; | ||
| size_t mem_budget = 0; |
There was a problem hiding this comment.
Can we not use mem budget to calculate batch but simply have a hard-coded batch size and let users to control thread number used below to tune the memory usage?
There was a problem hiding this comment.
A hard-coded batch size is what I'd like to avoid, because the batch count is the dominant control over build-phase peak RSS, and the right value differs by an order of magnitude across deployments — a 128 GB node vs a 256 GB node want very different batching for the same index. Thread count doesn't govern this: the inverted-list memory (total_nnz * (sizeof(idx_t) + element_size)) is materialized per batch regardless of how many threads process it, so tuning threads down doesn't cap the peak, only the parallelism.
That said, your two concerns are both valid and I'll fix them:
- The Linux-only path leaving
mem_budget = 0elsewhere is a real bug — on non-Linux it currently falls through to the 16 GB fallback, which is accidental, not intended. /proc/meminfoparsing should be a util function (noted on the other thread too).
Proposed compromise: keep budget-driven batching but make the budget an explicit parameter — default = auto-detect (Linux) with a documented fixed fallback on other platforms, and allow the caller (the plugin) to override it. That keeps the adaptivity that actually controls memory while removing the platform-specific silent behavior and giving users a real knob. Does an explicit mem_budget_bytes parameter (0 = auto) work for you?
There was a problem hiding this comment.
Implemented as proposed, in 4896001. mem_budget_bytes is now an explicit field on SeismicClusterParameters (0 = auto), threaded from the index description string ("...|mem_budget=") — no JNI change needed. Auto-detect stays as the default (with the non-Linux fallback fixed), but callers/the plugin can now set an exact budget to control build-phase peak RSS instead of relying on thread count. Added tests for the explicit and auto paths.
| @@ -129,30 +133,236 @@ inline int calculate_beta(int beta, int lambda) { | |||
| inline std::vector<InvertedListClusters> build_inverted_lists_clusters( | |||
There was a problem hiding this comment.
as inline functions now build_inverted_lists_clusters and build_and_save_inverted_lists_clusters are too big, better create a seismic_common.cpp and move implementation there and not mark them inline
There was a problem hiding this comment.
Done in 5250b82. Created nsparse/seismic_common.cpp and moved both build_inverted_lists_clusters and build_and_save_inverted_lists_clusters there (declarations remain in the header, no longer inline). Added the file to NSPARSE_SRC so it is still compiled once per SIMD variant (generic/avx2/avx512/sve) and keeps its per-arch specialization.
| for (const size_t& cluster_id : cluster_order) { | ||
| const auto& cluster_score = summary_scores[cluster_id]; | ||
| if (heap.full() && (cluster_score * heap_factor < heap.peek_score())) { | ||
| if (first_list) { |
There was a problem hiding this comment.
why removed first_list check? That's an optimization
There was a problem hiding this comment.
The first_list check is not always making positive effect, I am considering to make it as an option to the user.
Adds JNI bindings for the native sparse vector library to integrate with OpenSearch's neural-search plugin. Key capabilities: - Index lifecycle: create, add vectors (with IDs), build, save, load, delete - Search: dense-scoring SEISMIC and SEISMIC-SQ (scalar quantized) search - Memory management: streaming build_and_save, release_build_memory, malloc_trim - Page cache eviction: madvise(MADV_DONTNEED) via /proc/self/maps scanning Core library changes for production-scale operation (138M+ documents): - int64 offsets (offset_t) to handle >2B non-zeros in CSR indptr - Streaming build_and_save: writes clusters batch-by-batch to reduce peak RSS - Scalar quantizer improvements: configurable quantization ceilings - IDMapIndex: add_with_ids support for external document ID mapping - K-means clustering: OpenMP parallelization for 32-thread builds - Distance functions: unified SIMD dispatch across AVX2/AVX512/NEON/SVE Validated at 138M docs (3 shards × 46M), uint8 SQ SEISMIC: - Build time: 12.7 min per shard (32 threads) - Peak RSS: 59-61 GB during force merge (on 128 GB nodes) - Search: 3ms p50 at heap_factor=1.08, top_n=3 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
57e5763 to
0f20144
Compare
Per upstream review feedback on PR opensearch-project#13: language-client / integration glue should not live in the core C++ library. The JNI bindings (jni/nsparse_jni.cpp + its CMake target) move to the OpenSearch neural-search plugin, which compiles them against this library. - Delete jni/ (nsparse_jni.cpp, CMakeLists.txt) - Remove NSPARSE_BUILD_JNI option and add_subdirectory(jni) from the top-level CMakeLists; leave a pointer comment. - .gitignore: also ignore out-of-source build-*/ build_*/ __pycache__/ dirs. Library + full test suite (365 tests) build and pass unchanged. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
map_docs_to_clusters had two implementations selected by `dimension == 0`:
- dimension > 0 -> sparse inverted-index path (Path B)
- dimension == 0 -> dense-centroid AVX512 path (Path A), else Path B anyway
Path A is unreachable in practice and inconsistent with the tests:
- The plugin always builds with a real dimension (createIndex passes 65536),
so get_dimension() is never 0 in production and Path B is always taken.
- Every kmeans/seismic test uses dimension=10 (Path B). None exercise Path A.
- The existing invariant test `no_duplicate_and_no_missing_docs_when_
centroids_in_docs` encodes Path B's centroid-skip behavior as required;
Path A (which re-clusters centroid docs via argmax) would violate it.
Addresses the PR opensearch-project#13 review comment about using `dimension == 0` as a
code-path switch: rather than parameterize the choice, remove the dead path.
- Delete map_docs_to_clusters_avx512 + its helpers (centroids_to_dense,
initialize_cluster_representatives, get_dense_vector_max_dimension) and the
AVX512-only include block.
- Delete the unused dot_product_typed_dense helper.
- map_docs_to_clusters now dispatches purely by element_size to the sparse
inverted-index path.
Net -166 lines. Full test suite (365 tests) builds and passes unchanged
(Release, AVX-512 host).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The swignsparse_avx2/avx512/sve.swig files are generated at build time from swignsparse.swig via configure_file(COPYONLY). They were removed from the tree in b414549 but the .gitignore rule to keep them out wasn't committed, so a build + `git add` would re-track them. Commit the ignore rule. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…allback Addresses the PR opensearch-project#13 review: `mem_budget` was computed inline from /proc/meminfo and left at 0 on non-Linux, silently falling through to a hardcoded 16 GB. Two problems: the value was undetectable/opaque off Linux, and callers had no way to control build-phase peak RSS. Changes: - SeismicClusterParameters gains `mem_budget_bytes` (default 0 = auto). This rides the existing index description string, so no JNI signature change: "seismic[_sq],...|mem_budget=<bytes>". - New detail::query_available_memory_bytes() — the /proc/meminfo reader extracted into a documented util (returns 0 where unsupported). - New detail::resolve_build_mem_budget(requested): explicit budget wins; else auto-detect minus a 4 GB reserve; else a documented 16 GB fallback. Never returns 0 (guards the batch divisor). - Both build_inverted_lists_clusters and build_and_save_inverted_lists_clusters honor an explicit budget; the streaming path keeps its 8 GB auto-cap when none is given. - index_factory parses `mem_budget` for seismic and seismic_sq. - Tests: 4 cases covering explicit-verbatim, small-explicit, auto-nonzero, and auto == query/fallback. Full suite 369/369; plugin JNI rebuild verified. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
std::vector<uint32_t> offsets(dimension + 1) already value-initializes all elements to 0, so the explicit assignment was redundant. Addresses a PR opensearch-project#13 review nit. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The PR's rewrite of summarize_() accidentally removed its @brief/@param/@return block. Restore it (fix the "Generage" typo, document the offsets param that the current signature added). Addresses PR opensearch-project#13 "keep the comments?". Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Addresses review comments on PR opensearch-project#13: - Gate the 7 per-batch progress fprintf(stderr) diagnostics behind a new SeismicClusterParameters.verbose flag (default false), so production builds stay quiet. Parsed from the index description ("verbose=1") in index_factory for both seismic and seismic_sq. - Move build_inverted_lists_clusters and build_and_save_inverted_lists_clusters out of the header into a new seismic_common.cpp (declarations remain in the header). Added to NSPARSE_SRC so every SIMD variant still specializes it. - Split the two large builders into shared helpers (compute_batch_plan, build_batch_invlists, count_non_empty_lists). Both distinct mem_budget policies (resolve_build_mem_budget vs explicit-or-8GB-cap) and all OMP pragmas are preserved byte-for-byte. - Guard against dimension == 0 at the entry of both builders. All 369 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
chishui
left a comment
There was a problem hiding this comment.
- Please fix DCO for each commit
- Run clang-tidy and clang-format
| const float* values) = 0; | ||
| virtual void reserve(size_t num_vectors, size_t total_nnz) {} | ||
| virtual void build(); | ||
| virtual void build_and_save(const char* path); |
There was a problem hiding this comment.
Please remove build_and_save and release_build_memory as we were aligned offline
|
|
||
| for (const size_t& cluster_id : cluster_order) { | ||
| const auto& cluster_score = summary_scores[cluster_id]; | ||
| if (heap.full() && (cluster_score * heap_factor < heap.peek_score())) { |
There was a problem hiding this comment.
As we discussed offline, let's keep first_list optimization and have the change in a dedicated PR when there are strong evidence showing that it's benefit to remove
| add_subdirectory(nsparse/python) | ||
| endif() | ||
|
|
||
| # Note: JNI bindings for the OpenSearch neural-search plugin live in that |
There was a problem hiding this comment.
this comment is no longer needed?
| __pycache__/ | ||
|
|
||
| # SWIG SIMD variants are generated from swignsparse.swig at build time (configure_file COPYONLY) | ||
| nsparse/python/swignsparse_avx2.swig |
| active_terms.clear(); | ||
| std::vector<std::pair<term_t, T>> summary_vec; | ||
|
|
||
| if (dimension > 0) { |
There was a problem hiding this comment.
as we discussed, dimension check should be removed?
| total_nnz * (sizeof(idx_t) + element_size); | ||
|
|
||
| // Cap per-batch inverted list memory to keep peak RSS under physical RAM. | ||
| // The CSR (~46 GB for float32 at 46M docs) remains throughout; each batch |
There was a problem hiding this comment.
the comments are specific to certain dataset and should be removed.
| // interface it falls back to a fixed default. Set explicitly (e.g. from | ||
| // the native-engine index description, "mem_budget=<bytes>") to control | ||
| // build-phase peak RSS deterministically. | ||
| size_t mem_budget_bytes = 0; |
There was a problem hiding this comment.
We'd better make it mem_budget_in_megabytes or even mem_budget_in_gigabytes to make it simple for user.
nit: you can ask LLM to make comments precise and concise
| constexpr size_t kMaxBatchBytes = 8ULL * 1024 * 1024 * 1024; // 8 GB | ||
| mem_budget = std::min(kMaxBatchBytes, invlist_bytes_full / 3); | ||
| } | ||
| if (mem_budget == 0) mem_budget = invlist_bytes_full; |
There was a problem hiding this comment.
if mem_budget is 0 here, then invlist_bytes_full must be 0
| return clustered_inverted_lists; | ||
| } | ||
|
|
||
| void build_and_save_inverted_lists_clusters( |
There was a problem hiding this comment.
can we only have one responsibility for one function? already have build_inverted_lists_clusters above, not necessary to have a build_and_save_inverted_lists_clusters with duplicate code plus save to file. any places which need to build then save, they can simply call build_inverted_lists_clusters then save to whatever they want.
| return clustered_inverted_lists; | ||
| } | ||
|
|
||
| void build_and_save_inverted_lists_clusters( |
There was a problem hiding this comment.
also, this function has quite different implementation with build_inverted_lists_clusters
Summary
jni/) for integrating the native sparse vector library with OpenSearch's neural-search pluginChanges
JNI Layer (
jni/nsparse_jni.cpp)createIndex,addVectors,addVectorsWithIds,buildIndex,buildAndSaveIndex,saveIndex,loadIndex,deleteIndexsearch(SEISMIC) andsearchSQ(scalar-quantized SEISMIC)release_build_memory()+malloc_trim()to return RSS after buildevictPageCacheusingmadvise(MADV_DONTNEED)via/proc/self/mapsscanning with dynamic buffer (getline) and error diagnosticsCore Library
offset_t): handles >2B non-zeros in CSR indptr arrays (required at 45M+ docs per shard)build_and_save: writes clusters batch-by-batch to reduce peak RSS from ~104 GB to ~50 GBquantization_ceilingparameter for ingest and search pathsadd_with_idssupport for external document ID mappingTests
IDMapIndextests foradd_with_idsand save/load round-tripBenchmark Results (138M docs, 3 nodes × 46M docs/shard, uint8 SQ)
Force Merge (Index Build)
Search Latency (3903 queries, k=10, top_n=3)
Test plan
cmake -S . -B build -DNSPARSE_ENABLE_TESTS=ON && cmake --build build -j && ctest --test-dir buildcmake -S . -B build -DNSPARSE_ENABLE_JNI=ON && cmake --build build --target nsparse_jni🤖 Generated with Claude Code