Skip to content

EDBT22-LSH-JOIN - #93

Merged
LuckyWindovo merged 8 commits into
DataSysResearch:main-devfrom
LuckyWindovo:edbt22-lsh-join
Jan 14, 2026
Merged

LuckyWindovo merged 8 commits into
DataSysResearch:main-devfrom
LuckyWindovo:edbt22-lsh-join

Conversation

@LuckyWindovo

Copy link
Copy Markdown
Collaborator

…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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 LSHMethod class 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 before state->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 (via onRecordAdded) and in the WindowState (via addRecord). 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 @@
---

Copilot AI Dec 26, 2025

Copy link

Choose a reason for hiding this comment

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

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.

Suggested change
---
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.

Copilot uses AI. Check for mistakes.
Comment on lines +42 to +46
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);

Copilot AI Dec 26, 2025

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
cfg.num_tables = config.lsh_num_tables;
cfg.num_hashes = config.lsh_num_hashes;
cfg.dimension = config.dimension;
cfg.seed = config.lsh_seed;

Copilot AI Dec 26, 2025

Copy link

Choose a reason for hiding this comment

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

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.

Suggested change
cfg.seed = config.lsh_seed;
cfg.seed = config.lsh_seed;
cfg.window_size_ms = config.window_size_ms;

Copilot uses AI. Check for mistakes.
Comment on lines +217 to +219
bucket.erase(std::remove_if(bucket.begin(), bucket.end(), [&](const Entry& entry) {
return !entry.record || entry.record->timestamp_ < window_lower_bound;
}), bucket.end()); // 窗口淘汰

Copilot AI Dec 26, 2025

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +444 to +445
sageFlow::PartitionStrategy::ROUND_ROBIN,
sageFlow::WindowStateType::SHARED,

Copilot AI Dec 26, 2025

Copy link

Choose a reason for hiding this comment

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

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.

Suggested change
sageFlow::PartitionStrategy::ROUND_ROBIN,
sageFlow::WindowStateType::SHARED,
sageFlow::PartitionStrategy::LSH,
sageFlow::WindowStateType::PARTITIONED_VECTOR,

Copilot uses AI. Check for mistakes.
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;

Copilot AI Dec 26, 2025

Copy link

Choose a reason for hiding this comment

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

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.

Suggested change
std::vector<float> normalizeForSignature(const VectorRecord& record) const;

Copilot uses AI. Check for mistakes.
Comment on lines +439 to +440
"Hyperplane-based Locality-Sensitive Hashing join (vector cosine). "
"Uses multiple random hyperplane tables as coarse buckets, then cosine verify.",

Copilot AI Dec 26, 2025

Copy link

Choose a reason for hiding this comment

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

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.

Suggested change
"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.",

Copilot uses AI. Check for mistakes.
Comment on lines +64 to +92
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 预过滤

Copilot AI Dec 26, 2025

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +122 to +129
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>());
}
}

Copilot AI Dec 26, 2025

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +182 to +189
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>());
}
}

Copilot AI Dec 26, 2025

Copy link

Choose a reason for hiding this comment

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

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.

Suggested change
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>());
}
}

Copilot uses AI. Check for mistakes.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 19 out of 20 changed files in this pull request and generated 6 comments.

Comment thread src/operator/join_operator_methods/lsh_method.cpp Outdated
Comment on lines +440 to +444
if (auto* lsh = dynamic_cast<LSHMethod*>(join_method_.get())) {
if (data_ptr) {
lsh->onRecordAdded(*data_ptr, slot);
}
}

Copilot AI Dec 26, 2025

Copy link

Choose a reason for hiding this comment

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

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).

Copilot uses AI. Check for mistakes.
uint64_t LSHMethod::hashVector(const VectorRecord& record,
const std::vector<Hyperplane>& planes) const {
const auto vec = toFloatVector(record);
if (vec.size() < planes.size()) {

Copilot AI Dec 26, 2025

Copy link

Choose a reason for hiding this comment

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

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.

Suggested change
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());

Copilot uses AI. Check for mistakes.
@@ -0,0 +1 @@
---

Copilot AI Dec 26, 2025

Copy link

Choose a reason for hiding this comment

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

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.

Suggested change
---

Copilot uses AI. Check for mistakes.

uint64_t LSHMethod::sketchVector(const VectorRecord& record) const {
const auto vec = toFloatVector(record);
if (vec.size() < sketch_planes_.size()) {

Copilot AI Dec 26, 2025

Copy link

Choose a reason for hiding this comment

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

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.

Suggested change
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);

Copilot uses AI. Check for mistakes.
Comment on lines +82 to +83
left_mask_ = (left_bits_ >= 32) ? 0xFFFFFFFFu : ((1u << left_bits_) - 1u);
right_mask_ = (right_bits_ >= 32) ? 0xFFFFFFFFu : ((1u << right_bits_) - 1u);

Copilot AI Dec 26, 2025

Copy link

Choose a reason for hiding this comment

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

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_.

Suggested change
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));

Copilot uses AI. Check for mistakes.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@ZeroJustMe

Copy link
Copy Markdown
Collaborator

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.

@LuckyWindovo
LuckyWindovo merged commit 1b8b312 into DataSysResearch:main-dev Jan 14, 2026
3 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.

3 participants