WIP: [FEATURE] Add the LSH fast layout algorithm. - #343
smehringer wants to merge 35 commits into
Conversation
60aad46 to
a220fa2
Compare
|
Documentation preview available at https://docs.seqan.de/preview/seqan/chopper/343 |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #343 +/- ##
==========================================
+ Coverage 93.35% 94.50% +1.14%
==========================================
Files 20 25 +5
Lines 753 1293 +540
Branches 18 22 +4
==========================================
+ Hits 703 1222 +519
- Misses 50 71 +21 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
95dc15c to
fce830c
Compare
ffb3a48 to
7597f19
Compare
0/0.0 is nan on gcc, but -nan on clang...
determine_best_number_of_technical_bins always uses the DP layout, so --fast-layout was silently ignored. Reject the combination right after parsing, before any sketching. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The parallel region had no num_threads clause, so it used all hardware threads (or OMP_NUM_THREADS) and ignored --threads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
lsh_sim_approach runs concurrently for different merged bins, but concurrent_timer::start() and stop() write shared, non-atomic time points; only operator+=() is thread-safe. Besides wrong timings, stop()'s assertion stop_point >= start_point failed in Debug when another thread restarted the timer in between (12 of 40 runs of the new fast layout recursion test with 8 threads). Time with local serial_timers and add them to the configuration's timers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No test reached fast_layout_recursion and add_level_to_layout: a merged bin needs at least 64 * tmax user bins for that, and the largest fast layout test had 96 user bins with tmax = 64. With tmax = 4 and 2000 user bins, each of the 4 top-level merged bins (about 500 user bins) is laid out recursively, and the DP layout handles the level below. The layout is checked structurally: every user bin is stored exactly once, merged and stored technical bins do not overlap, and there is one max bin entry per lower-level IBF. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
find_best_partition computed union_estimate - current_partition_size and asserted that the union estimate is not smaller. HyperLogLog estimates are not monotonic under merging: at the switch from linear counting to the raw estimate, a superset can be estimated smaller (4 of 800000 steps in a probe with 12 sketch bits, by up to 51). Debug builds then aborted and Release builds wrapped to a huge penalty that excluded the partition. Clamp the penalty to 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The documentation claimed that the threshold is multiplied by max(1.01, sum / (threshold * max_bins)) and that the number of split user bins is clamped. The code sets the threshold to max(threshold + 1, sum / max_bins), and the number of split user bins cannot exceed the number of technical bins, which is only asserted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
post_process_clusters sorted the clusters after the first tmax by the cardinality of their largest user bin, with 0 for empty clusters. This put empty clusters last only if every non-empty cluster has a cardinality estimate above 0. Otherwise, a non-empty cluster could end up among the empty ones. Debug builds then failed the is_partitioned assertion, and in Release builds lsh_sim_approach stopped at the first empty cluster, so the user bins of such clusters were not assigned. Sort by (non-empty, cardinality) instead. For all other clusters, the order is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ined macros partition_user_bins_test.cpp and check_filenames.cpp use the CHOPPER_WORKAROUND_GCC_BOGUS_* macros without including chopper/workarounds.hpp. The undefined macros evaluate to 0 in #if, so the diagnostic pragmas were never active. This is why the GCC 16 -Warray-bounds workaround in partition_user_bins_test.cpp had no effect. -Wundef turns such mistakes into errors. For it, the feature test in fast_layout_cluster.hpp uses #ifdef, because __cpp_lib_containers_ranges is not defined before C++23 or with older standard libraries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fast_layout duplicated add_level_to_layout to record the top-level IBF. add_level_to_layout now sets the idx of the user bins it records and returns the maximum technical bin. fast_layout stores it as top_level_max_bin_id, fast_layout_recursion appends it to max_bins. The layouts are unchanged (compared on a recursive and a mixed input). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
find_best_partition always returned true and threw only if no partition was selected. With at least one partition, partition 0 is always selected unless its cost is SIZE_MAX, and lsh_sim_approach always passes at least one partition. Assert that instead of tracking best_p_found. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Document Cluster instead of the "foo" placeholder, including that is_valid accepts both valid and moved clusters. * Make the members private; nothing derives from Cluster. * Make the single-argument constructor explicit. * Replace the comments above LSH_find_representative_cluster and in very_similar_LSH_clustering, which described an older representation (cluster[i][0] == i). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
append_range and insert do the same for a std::vector<size_t>, but each toolchain only compiled and tested one of the two branches. Keep insert. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Document determine_split_bins. * Remove commented-out debug output and the unused max_id. * Remove the unused print_matrix.hpp include, include what is used instead of <numeric>, and fix the namespace in the file brief. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
general_layout started and stopped a local dp_algorithm_timer that was never read. Return the result of compute_layout directly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
remaining_clusters.insert(remaining_clusters.end(), x) is push_back(x). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The parameter holds the user bins per partition, and the caller passes partitions. Name it partitions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rename intital_partition_timer to initial_partition_timer. This also changes the column header in the --timing-output file to initial_partition_timer_in_seconds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
determine_best_number_of_technical_bins always uses the DP layout, so execute silently ignored fast_layout if determine_best_tmax was set. The command line already rejects the combination; the API now throws std::invalid_argument. The two execute_estimation_test cases with both options set expected the DP output. They are kept under #if 0 until the combination is supported, and same-named tests check the throw instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It had a plain comment, so it was missing from the documentation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
post_process_clusters sorts the first tmax clusters by size, but lsh_sim_approach seeds only tmax - number_of_split_tbs partitions. The alternative, sorting the first number_of_remaining_tbs clusters, was measured on 60 synthetic inputs (400 to 50000 user bins). There was no systematic difference in expected HIBF size or query cost, so chopper keeps the current order. The directory contains the write-up, the measurement code (driver, patch for the alternative, build, run and analysis scripts) and the results. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Looks good. I pushed commits.
Overview
- Fixes: everything before
refactor: record the top level with add_level_to_layout. Each fix usually comes with a test that fails before the fix and passes after it. - Refactoring and docs: everything from
refactor: record the top level with add_level_to_layouton.
test: add the measurement of the post_process_clusters seeding variants
This commit checks whether post_process_clusters should partially sort the first tmax clusters by size, or only the first tmax - number_of_split_bins.
With tmax, only tmax - number_of_split_bins of the sorted clusters are used as seeds. The remaining number_of_split_bins sorted clusters are then the first ones assigned to the merged bins. So the clusters assigned after seeding are not all sorted by cardinality: the first number_of_split_bins of them are still in the order of the partial sort.
It doesn't seem to make a difference (on rather small data), but I asked Claude to write it up. The commit can be removed after you have read it.
determine_best_tmax + --fast-layout
Using the two together throws, by design. The tests for the combination are deliberately kept under #if 0: we probably want to add this feature, and it should be pretty straightforward, but the PR is already big enough as it is :)
| // HyperLogLog estimates are not monotonic under merging: Where the estimate switches from linear counting to | ||
| // the raw estimate, the union can be estimated smaller than the partition. Clamp to 0 instead of underflowing. |
There was a problem hiding this comment.
Good to know :)
Doesn't happen often:
Probe: a superset was estimated smaller in 4 of 800,000 steps, by up to 51
| std::ranges::sort(clusters | std::views::drop(config.hibf_config.tmax), | ||
| std::ranges::greater{}, | ||
| [&largest_user_bin_cardinality](Cluster const & c) | ||
| { | ||
| return std::tuple{!c.empty(), largest_user_bin_cardinality(c)}; | ||
| }); |
There was a problem hiding this comment.
I am pretty sure that an empty cluster has a cardinality of 0 and any non-empty cluster also has a non-zero cardinality. So, the two properties should be equivalent. On the other hand, it also doesn't hurt to make it explicit.
There was a problem hiding this comment.
yes, I think it is rather unnecessary but does not hurt so we can keep it
| // User bins that were not placed, either because at most tmax are placed or because the partitions ran out. | ||
| if (end < cluster.size()) | ||
| remaining_clusters.emplace_back(cluster.begin() + end, cluster.end()); | ||
|
|
There was a problem hiding this comment.
Interesting. How did you catch this?
(commit fix: do not drop user bins of large seed clusters)
I have a sanity check in fast_layout(..) which, in debug mode, checks that all user bins have been assigned to the partitioning. Did that check catch this case?
There was a problem hiding this comment.
Yes, in Debug mode the assert fired
| path.push_back(tb); | ||
| } | ||
|
|
||
| ASSERT_GE(user_bin.number_of_technical_bins, 1u); |
There was a problem hiding this comment.
In the comment above it says, each TB stores EXACTLY 1 UB.
Wht does this test for greater or equal?
|
|
||
| ASSERT_GE(user_bin.number_of_technical_bins, 1u); | ||
| ASSERT_LE(user_bin.storage_TB_id + user_bin.number_of_technical_bins, max_technical_bins); | ||
| auto & ibf = get_ibf(path); |
There was a problem hiding this comment.
If I understand the code up until here correctly, the former for loop already initializes all possible paths representing ibfs.
The get_ibf function uses try_emplace so it never fails. But at this point we should directly access the map with path and if something fails, than the layout is not correctly set up, no?
So what I'm trying to say is we should use:
| auto & ibf = get_ibf(path); | |
| auto & ibf = ibfs.at(path); |
|
|
||
| std::set<std::vector<size_t>> lower_level_ibfs{}; | ||
| for (auto const & [path, bins] : ibfs) | ||
| if (!path.empty()) |
There was a problem hiding this comment.
If there exists a path that is actually empty, doesn't that represent an empty technical bin? ANd that should not happen. This is not tested though
| EXPECT_TRUE(max_bin_ibfs.insert(max_bin.previous_TB_indices).second) << "duplicate max bin entry"; | ||
| } | ||
|
|
||
| EXPECT_EQ(max_bin_ibfs, lower_level_ibfs); |
There was a problem hiding this comment.
UH, Google test can now compare std::set? nice.
| } | ||
|
|
||
| EXPECT_EQ(max_bin_ibfs, lower_level_ibfs); | ||
| } |
There was a problem hiding this comment.
I find the whole test quite complex and potentially not correct (see other comments).
Is the Layout in text form too large to be directly checked?
|
|
||
| Measurement behind the decision to keep how `post_process_clusters` (`src/layout/partition_user_bins.cpp`) orders the | ||
| clusters for the fast layout. | ||
|
|
There was a problem hiding this comment.
DO we really want to keep this rather "extensive" benchmark which just checked if the sorting within post_process_clusters makes a difference?
I would rahter add a benchmark dp vs fast_layout. Or a forced recursive fast layout vs using the DP after the top level layout.
I know its work that has already been done but it seems rather insignificant to keep.
This would add the fast layout algorithm in its current status (dissertation status mehringer) to chopper.
Open questions: Should the fast layout algorithm instead be added to the hibf library?
Note to the code:
Currently most of the algorithm lives in
src/layout/fast_layout.cppDuring coding, I added some detail tests so there are two files with some extra code (that is again only used in fast_layout.cpp)
with their respective tests.