Repository navigation
EDBT22-LSH-JOIN - #93
Conversation
…ail with recall = 0.82 and precision = 0.50, wait to fix
Split hyperplane hash into even/odd signatures for cross-table dedup, mirroring Danny’s tensor split. Add gated sketch filtering (skip on small buckets) and two-stage adaptive probing with fallback radius/no-sketch to lift recall. Make probe key generation configurable by radius/probe budget to support adaptive scan. Clarify LSH entry comments and signature intent to document Danny-aligned behavior.
There was a problem hiding this comment.
Pull request overview
This PR implements the LSH (Locality-Sensitive Hashing) join method for the sageFlow stream processing engine, based on the Danny reference implementation. The LSH method uses hyperplane-based hashing to efficiently identify similar vector pairs by organizing candidates into buckets, avoiding exhaustive comparisons.
Key changes:
- Adds new
LSHMethodclass with hyperplane-based hashing, multi-radius probing, and sketch-based pre-filtering - Integrates LSH into the join strategy framework with configuration support, registry auto-registration, and partitioner adapter
- Provides unit tests for LSH method, partitioner adapter, strategy factory, and join operator integration
Reviewed changes
Copilot reviewed 19 out of 19 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
include/operator/join_operator_methods/lsh_method.h |
Header defining LSHMethod class with config, hyperplane tables, bucket structures, and method signatures |
src/operator/join_operator_methods/lsh_method.cpp |
Core implementation: hyperplane initialization, bucket insertion, multi-probe query execution with L2 similarity verification |
include/operator/join_operator_methods/base_method.h |
Adds LSH enum value to JoinMethodType |
src/operator/join_operator.cpp |
Integrates LSH into both legacy constructor and strategy-based initialization paths with onRecordAdded hooks |
include/operator/utils/join_strategy_config.h |
Adds LSH algorithm enum and configuration parameters (num_tables, num_hashes, seed) |
src/operator/utils/join_strategy_config.cpp |
Implements LSH parsing, validation, inferDefaults logic, and TOML loading for LSH parameters |
include/operator/utils/join_strategy_factory.h |
Declares createLshMethod factory method |
src/operator/utils/join_strategy_factory.cpp |
Implements LSH method creation, partitioner selection, and index type mapping |
src/operator/utils/join_config_validator.cpp |
Updates compatibility checks and recommended strategies for LSH partition strategy |
include/execution/partitioner.h |
Adds LSHPartitionerAdapter to bridge VectorSpacePartitioner to IPartitioner interface |
test/UnitTest/test_lsh_method.cpp |
Unit tests for basic LSH matching and threshold filtering behavior |
test/UnitTest/test_partitioner.cpp |
Test verifying LSHPartitionerAdapter produces stable hashes for identical vectors |
test/UnitTest/test_join_strategy_factory.cpp |
Tests for LSH method creation, strategy component creation, and window state/partitioner factory methods |
test/UnitTest/test_join_operator_strategy.cpp |
Integration tests for LSH config creation and inferDefaults behavior |
test/UnitTest/test_join_method_registry.cpp |
Tests for LSH auto-registration, method info retrieval, and method creation via registry |
test/CMakeLists.txt |
Adds test_lsh_method to unit test list |
src/operator/CMakeLists.txt |
Adds lsh_method.cpp to operator library sources |
config/perf_join_datasource_modes.toml |
Adds performance test configuration for LSH with random data generation |
Testing/Temporary/CTestCostData.txt |
Build artifact file (should not be committed) |
Comments suppressed due to low confidence (1)
src/operator/join_operator.cpp:757
- The call to
lsh->onRecordAdded(*data_ptr, slot)happens beforestate->addRecord(std::move(data_ptr), subtask_index), which moves the record into the window state. However, LSHMethod doesn't actually use the WindowState for storage - it maintains its own bucket structures. This creates duplicate storage: records are stored both in the LSH buckets (viaonRecordAdded) and in the WindowState (viaaddRecord). Consider whether WindowState storage is actually needed for LSH, or if the LSH buckets should be the sole storage mechanism. This redundancy wastes memory and could lead to inconsistencies.
if (auto* lsh = dynamic_cast<LSHMethod*>(join_method_.get())) {
if (data_ptr) {
lsh->onRecordAdded(*data_ptr, slot);
}
}
// 添加记录到窗口状态
{
MetricsTimer t_window_ins(JoinMetrics::instance().window_insert_ns);
state->addRecord(std::move(data_ptr), subtask_index);
| @@ -0,0 +1 @@ | |||
| --- | |||
There was a problem hiding this comment.
This file appears to be a CMake test execution artifact that should not be committed to version control. The Testing/Temporary/ directory typically contains transient build/test files. This file should be added to .gitignore to prevent it from being committed in future changes.
| --- | |
| This file is an intentional placeholder to keep the Testing/Temporary/ directory in version control. | |
| Do not commit CMake/CTest-generated artifacts here; all test output in this directory should be ignored. |
| auto candidate = makeRecord(1, {1.0f, 0.0f, 0.0f, 0.0f}); | ||
| right_state_->addRecord(std::move(candidate), /*subtask_index=*/0); | ||
|
|
||
| VectorRecord query = *makeRecord(2, {1.0f, 0.0f, 0.0f, 0.0f}); | ||
| auto results = method_->ExecuteEager(query, /*query_slot=*/0); |
There was a problem hiding this comment.
The test adds records directly to right_state_ using addRecord() (line 43), but does not call method_->onRecordAdded(). This means the records are stored in WindowState but not in the LSH buckets. Since ExecuteEager searches the LSH buckets (not WindowState), this test will fail unless onRecordAdded is also called. The test should either: (1) call method_->onRecordAdded(*candidate, 1) before adding to state, or (2) document why the test structure differs from actual runtime behavior.
| cfg.num_tables = config.lsh_num_tables; | ||
| cfg.num_hashes = config.lsh_num_hashes; | ||
| cfg.dimension = config.dimension; | ||
| cfg.seed = config.lsh_seed; |
There was a problem hiding this comment.
The factory lambda in the registration does not set the window_size_ms field in the Config struct, while the factory method createLshMethod at line 222 does set it from config.window_size_ms. This inconsistency means that methods created via the registry will use the default window size (10000ms) instead of the configured value, potentially causing incorrect windowing behavior when LSH is instantiated through the registry path.
| cfg.seed = config.lsh_seed; | |
| cfg.seed = config.lsh_seed; | |
| cfg.window_size_ms = config.window_size_ms; |
| bucket.erase(std::remove_if(bucket.begin(), bucket.end(), [&](const Entry& entry) { | ||
| return !entry.record || entry.record->timestamp_ < window_lower_bound; | ||
| }), bucket.end()); // 窗口淘汰 |
There was a problem hiding this comment.
The bucket.erase(remove_if(...)) call modifies the bucket while holding the lock, which is good. However, this eviction happens during every query for every probed bucket, which could be expensive. Consider whether eviction should be done less frequently (e.g., batched eviction), or moved to a background process. Additionally, this modifies shared state during a query operation, which may cause unnecessary contention if multiple queries are executing concurrently.
| sageFlow::PartitionStrategy::ROUND_ROBIN, | ||
| sageFlow::WindowStateType::SHARED, |
There was a problem hiding this comment.
Inconsistency between registry metadata and actual configuration defaults: The LSH method registration at lines 444-445 declares recommended_partition as ROUND_ROBIN and recommended_window_state as SHARED. However, the inferDefaults() implementation (lines 241-246 in join_strategy_config.cpp) sets partition_strategy = PartitionStrategy::LSH and window_state_type = WindowStateType::PARTITIONED_VECTOR. This mismatch could confuse users and lead to incorrect configurations when relying on registry recommendations versus inferDefaults. The registry metadata should match the actual inferred defaults.
| sageFlow::PartitionStrategy::ROUND_ROBIN, | |
| sageFlow::WindowStateType::SHARED, | |
| sageFlow::PartitionStrategy::LSH, | |
| sageFlow::WindowStateType::PARTITIONED_VECTOR, |
| void initSketchPlanes(); | ||
| uint64_t hashVector(const VectorRecord& record, const std::vector<Hyperplane>& planes) const; | ||
| uint64_t sketchVector(const VectorRecord& record) const; | ||
| std::vector<float> normalizeForSignature(const VectorRecord& record) const; |
There was a problem hiding this comment.
The method normalizeForSignature is declared in the header but is never defined or used in the implementation. This will cause linker errors if anyone attempts to call this method. The declaration should be removed.
| std::vector<float> normalizeForSignature(const VectorRecord& record) const; |
| "Hyperplane-based Locality-Sensitive Hashing join (vector cosine). " | ||
| "Uses multiple random hyperplane tables as coarse buckets, then cosine verify.", |
There was a problem hiding this comment.
The method description states "vector cosine" and "cosine verify", but the actual implementation uses L2 similarity via the l2Similarity function (lines 272-273 in lsh_method.cpp). This is misleading. Either update the description to accurately reflect L2-based similarity, or modify the implementation to use cosine similarity as described. Note that hyperplane-based LSH is indeed designed for cosine similarity, so this discrepancy may indicate an algorithmic issue.
| "Hyperplane-based Locality-Sensitive Hashing join (vector cosine). " | |
| "Uses multiple random hyperplane tables as coarse buckets, then cosine verify.", | |
| "Hyperplane-based Locality-Sensitive Hashing join (L2-based similarity scoring). " | |
| "Uses multiple random hyperplane tables as coarse buckets, then L2-based similarity (exp(-alpha * L2)) verify.", |
| struct Entry { | ||
| std::shared_ptr<const VectorRecord> record; | ||
| uint64_t hash = 0; ///< table 专属哈希(用于桶命中) | ||
| uint64_t sketch = 0; ///< 轻量 sketch(用于快速过滤) | ||
| uint16_t left_sig = 0; ///< 偶位签名(结构分片左) | ||
| uint16_t right_sig = 0; ///< 奇位签名(结构分片右) | ||
| }; | ||
|
|
||
| Config config_; | ||
| WindowState* left_state_ = nullptr; | ||
| WindowState* right_state_ = nullptr; | ||
| size_t subtask_index_ = 0; | ||
| std::vector<std::vector<Hyperplane>> tables_; // tables_[table][hash] | ||
| std::vector<Hyperplane> sketch_planes_; // 用于生成轻量级 sketch | ||
|
|
||
| // 左右签名掩码(兼容保留) | ||
| uint32_t left_mask_ = 0; | ||
| uint32_t right_mask_ = 0; | ||
| int left_bits_ = 0; | ||
| int right_bits_ = 0; | ||
|
|
||
| // 桶结构:tables -> hash -> candidates(左右分开,避免同侧自匹配) | ||
| using BucketMap = std::unordered_map<uint64_t, std::vector<Entry>>; | ||
| std::vector<BucketMap> left_buckets_; | ||
| std::vector<BucketMap> right_buckets_; | ||
| std::vector<std::unique_ptr<std::mutex>> left_bucket_mutexes_; | ||
| std::vector<std::unique_ptr<std::mutex>> right_bucket_mutexes_; | ||
| int64_t window_size_ms_ = 10000; | ||
| bool use_sketch_filter_ = true; ///< 是否启用 sketch 预过滤 |
There was a problem hiding this comment.
Inconsistent indentation: lines 64, 77, 86, 92 use leading spaces for alignment instead of following the consistent indentation pattern used elsewhere in the file (e.g., lines 63, 72-75). This violates code style consistency. All struct/class member declarations should use consistent indentation.
| if (target_buckets.size() != static_cast<size_t>(config_.num_tables)) { | ||
| target_buckets.clear(); | ||
| bucket_mutexes.clear(); | ||
| target_buckets.resize(static_cast<size_t>(config_.num_tables)); | ||
| for (int i = 0; i < config_.num_tables; ++i) { | ||
| bucket_mutexes.emplace_back(std::make_unique<std::mutex>()); | ||
| } | ||
| } |
There was a problem hiding this comment.
The check at line 122-129 appears to be defensive code for reinitialization after buckets have been cleared, but this reinitializes mutexes while records may still be in flight. If onRecordAdded is called from multiple threads and one thread finds target_buckets.size() != config_.num_tables, it will clear and resize without any synchronization. This creates a race condition where another thread might be holding or attempting to acquire a mutex that gets cleared and reallocated. The buckets and mutexes should only be initialized once in the open() method (which is already done), and this defensive check should either be removed or converted to a fatal error check with proper thread-safety.
| if (source_buckets.size() != tables_.size()) { | ||
| source_buckets.clear(); | ||
| bucket_mutexes.clear(); | ||
| source_buckets.resize(tables_.size()); | ||
| for (size_t i = 0; i < tables_.size(); ++i) { | ||
| bucket_mutexes.emplace_back(std::make_unique<std::mutex>()); | ||
| } | ||
| } |
There was a problem hiding this comment.
Similar race condition as in onRecordAdded: this defensive reinitialization of buckets and mutexes at lines 182-189 is not thread-safe. If multiple query threads call ExecuteEager concurrently and find mismatched sizes, they could all attempt to clear and reallocate the same data structures without synchronization. This should either be removed (relying on proper initialization in open()) or protected with proper synchronization.
| if (source_buckets.size() != tables_.size()) { | |
| source_buckets.clear(); | |
| bucket_mutexes.clear(); | |
| source_buckets.resize(tables_.size()); | |
| for (size_t i = 0; i < tables_.size(); ++i) { | |
| bucket_mutexes.emplace_back(std::make_unique<std::mutex>()); | |
| } | |
| } |
| if (auto* lsh = dynamic_cast<LSHMethod*>(join_method_.get())) { | ||
| if (data_ptr) { | ||
| lsh->onRecordAdded(*data_ptr, slot); | ||
| } | ||
| } |
There was a problem hiding this comment.
Inconsistent indentation detected. The if statement and its nested code block use a mix of spaces (8 spaces for line 440, 16 spaces for lines 441-443) while the surrounding code uses 2 or 4 spaces. This violates project coding style consistency. The indentation should match the surrounding code style (likely 4 spaces per level based on adjacent lines 435-439 and 445).
| uint64_t LSHMethod::hashVector(const VectorRecord& record, | ||
| const std::vector<Hyperplane>& planes) const { | ||
| const auto vec = toFloatVector(record); | ||
| if (vec.size() < planes.size()) { |
There was a problem hiding this comment.
The condition 'vec.size() < planes.size()' could silently fail when vector dimension doesn't match the configured dimension. If a vector has more elements than planes but fewer than config_.dimension, or if planes.size() doesn't match config_.dimension, the hashing will only use a subset of the vector data, leading to incorrect hash values. Consider adding explicit dimension validation that checks 'vec.size() != static_cast<size_t>(config_.dimension)' and logging a warning, similar to how other join methods handle dimension mismatches.
| if (vec.size() < planes.size()) { | |
| if (vec.size() != static_cast<size_t>(config_.dimension)) { | |
| SAGEFLOW_LOG_WARN( | |
| "LSHMethod", | |
| "Vector dimension mismatch in hashVector: expected {}, got {}", | |
| config_.dimension, | |
| vec.size()); |
| @@ -0,0 +1 @@ | |||
| --- | |||
There was a problem hiding this comment.
The Testing/Temporary/CTestCostData.txt file should not be committed to the repository. This is a CMake/CTest-generated temporary file that tracks test execution costs. While it's correctly added to .gitignore, the file itself shouldn't be in the PR. Consider removing this file from version control.
| --- |
|
|
||
| uint64_t LSHMethod::sketchVector(const VectorRecord& record) const { | ||
| const auto vec = toFloatVector(record); | ||
| if (vec.size() < sketch_planes_.size()) { |
There was a problem hiding this comment.
The condition 'vec.size() < sketch_planes_.size()' could fail silently when the vector dimension doesn't match expectations. Similar to hashVector, this should validate that the vector dimension matches config_.dimension. A dimension mismatch will cause incorrect sketch values, leading to false negatives in the sketch filter and reduced recall.
| if (vec.size() < sketch_planes_.size()) { | |
| if (vec.size() != static_cast<size_t>(config_.dimension)) { | |
| SAGEFLOW_LOG_ERROR("LSHMethod", | |
| "Vector dimension mismatch in sketchVector: got {}, expected {}", | |
| vec.size(), config_.dimension); |
| left_mask_ = (left_bits_ >= 32) ? 0xFFFFFFFFu : ((1u << left_bits_) - 1u); | ||
| right_mask_ = (right_bits_ >= 32) ? 0xFFFFFFFFu : ((1u << right_bits_) - 1u); |
There was a problem hiding this comment.
Potential integer overflow risk when num_hashes is large. The expression '1u << left_bits_' will overflow if left_bits_ >= 32, which can happen when num_hashes is 63 or 64 (left_bits_ would be 32). While there's a check 'left_bits_ >= 32' that sets the mask to 0xFFFFFFFFu, the mask calculation itself could overflow before the check. Consider reordering to check the value before the shift: 'left_mask_ = (left_bits_ == 0) ? 0 : (left_bits_ >= 32) ? 0xFFFFFFFFu : ((1u << left_bits_) - 1u);'. The same issue applies to right_mask_.
| left_mask_ = (left_bits_ >= 32) ? 0xFFFFFFFFu : ((1u << left_bits_) - 1u); | |
| right_mask_ = (right_bits_ >= 32) ? 0xFFFFFFFFu : ((1u << right_bits_) - 1u); | |
| left_mask_ = (left_bits_ == 0) | |
| ? 0u | |
| : (left_bits_ >= 32 ? 0xFFFFFFFFu : ((1u << left_bits_) - 1u)); | |
| right_mask_ = (right_bits_ == 0) | |
| ? 0u | |
| : (right_bits_ >= 32 ? 0xFFFFFFFFu : ((1u << right_bits_) - 1u)); |
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
|
Merged latest main-dev into this branch, fixed conflicts, and LSH unit/perf tests are passing.\n\nCurrent state of LSH parallelism (important):\n- The JoinOperator still uses the default RoundRobin partitioner for LSH because the string ctor sets use_strategy_config_=false and getPreferredPartitioner() returns nullptr for LSH.\n- LSHMethod is a single shared instance across subtasks; all threads share the same left/right buckets guarded by mutexes. This yields correctness but not true partitioned parallelism and can become a contention point.\n\nTo truly integrate LSH with the partition framework (multi-thread/partition aware), suggested follow-ups:\n1) Partitioning: make LSH go through strategy config path (set use_strategy_config_ for algo=lsh) and add getPreferredPartitioner case to return LSHPartitionerAdapter; let ExecutionGraph route vectors by LSH hash instead of RoundRobin.\n2) Per-partition state: either instantiate one LSHMethod per subtask or partition the bucket storage by subtask (no cross-thread shared buckets). That removes the global mutex hotspot.\n3) Window state alignment: keep PartitionedWindowState/PartitionedVectorState and ensure onRecordAdded writes only the owning partition’s buckets; queries should only touch the local partition.\n4) Optional multicast: reuse VectorSpacePartitioner::getCandidatePartitions for boundary multi-probe (similar to ClusteredJoin) and dedup on (uid, partition).\n5) Validation: rerun perf_join_lsh_random with parallelism>1 to confirm throughput gains and recall parity versus brute force baseline.\n\nSummary: code builds and tests pass, but LSH is still effectively RoundRobin + shared buckets. The above steps will make LSH genuinely partitioned and scalable. |
参考https://github.com/Cecca/danny,在sageFlow系统中实现LSH方法