Optimize MapSet.symmetric_difference/2 when sizes mismatched - #15471
Conversation
By folding over the smaller set and using the larger set as the starting accumulator, the time complexity is reduced from O(large) to O(small) iterations. This provides a over 100x speedup when set sizes are mismatched.
|
Hi @preciz, for documentation purposes, can you share the benchmarks you ran, alongside input sizes. |
|
It's skewed towards that mismatch size case. Mix.install([{:benchee, "~> 1.0"}])
defmodule Bench do
def old_sym_diff(map_set1 = %MapSet{map: set1}, _map_set2 = %MapSet{map: set2}) do
{small, large} = if :sets.size(set1) <= :sets.size(set2), do: {set1, set2}, else: {set2, set1}
disjointer_fun = fn elem, {small, acc} ->
if :sets.is_element(elem, small) do
{:sets.del_element(elem, small), acc}
else
{small, [elem | acc]}
end
end
{new_small, list} = :sets.fold(disjointer_fun, {small, []}, large)
%{map_set1 | map: :sets.union(new_small, :sets.from_list(list, version: 2))}
end
def new_sym_diff(map_set1 = %MapSet{map: set1}, _map_set2 = %MapSet{map: set2}) do
{small, large} = if :sets.size(set1) <= :sets.size(set2), do: {set1, set2}, else: {set2, set1}
map =
:sets.fold(
fn elem, acc ->
if :sets.is_element(elem, acc) do
:sets.del_element(elem, acc)
else
:sets.add_element(elem, acc)
end
end,
large,
small
)
%{map_set1 | map: map}
end
end
equal_small = MapSet.new(1..100)
equal_large = MapSet.new(101..200)
diff_huge1 = MapSet.new(1..100000)
diff_huge2 = MapSet.new(50000..150000)
small_1 = MapSet.new(1..10)
large_1 = MapSet.new(1..100000)
Benchee.run(
%{
"old" => fn {set1, set2} -> Bench.old_sym_diff(set1, set2) end,
"new" => fn {set1, set2} -> Bench.new_sym_diff(set1, set2) end
},
inputs: %{
"Equal Small (100)" => {equal_small, equal_large},
"Huge Overlapping (100,000)" => {diff_huge1, diff_huge2},
"Mismatched Sizes (10 vs 100,000)" => {small_1, large_1}
}
)On my noisy heat throttling machine: ##### With input Equal Small (100) #####
Name ips average deviation median 99th %
new 217.85 K 4.59 μs ±65.02% 4.37 μs 9.13 μs
old 161.03 K 6.21 μs ±110.95% 5.95 μs 9.81 μs
Comparison:
new 217.85 K
old 161.03 K - 1.35x slower +1.62 μs
##### With input Huge Overlapping (100,000) #####
Name ips average deviation median 99th %
old 65.69 15.22 ms ±11.68% 14.78 ms 23.03 ms
new 63.87 15.66 ms ±12.26% 14.83 ms 23.21 ms
Comparison:
old 65.69
new 63.87 - 1.03x slower +0.43 ms
##### With input Mismatched Sizes (10 vs 100,000) #####
Name ips average deviation median 99th %
new 973.09 K 0.00103 ms ±875.87% 0.00094 ms 0.00169 ms
old 0.0777 K 12.88 ms ±7.94% 12.70 ms 15.89 ms
Comparison:
new 973.09 K
old 0.0777 K - 12530.18x slower +12.88 ms |
|
I see. For both scenarios (different sizes and similar sizes), We should probably test the cases they have half in common, most in common, and nothing. |
|
Yes, and also we should bench with |
|
Thank you for the feedback. You were both right about it, I'm now trying to see if there is a better solution than this. |
|
New benchmarks after the update: Mix.install([{:benchee, "~> 1.0"}])
defmodule Bench do
# Implementation in main branch
def main_sym_diff(map_set1 = %MapSet{map: set1}, _map_set2 = %MapSet{map: set2}) do
{small, large} = if :sets.size(set1) <= :sets.size(set2), do: {set1, set2}, else: {set2, set1}
disjointer_fun = fn elem, {small, acc} ->
if :sets.is_element(elem, small) do
{:sets.del_element(elem, small), acc}
else
{small, [elem | acc]}
end
end
{new_small, list} = :sets.fold(disjointer_fun, {small, []}, large)
%{map_set1 | map: :sets.union(new_small, :sets.from_list(list, version: 2))}
end
# Implementation in current branch (optimize-mapset-symmetric-difference)
def branch_sym_diff(map_set1 = %MapSet{map: set1}, _map_set2 = %MapSet{map: set2}) do
map =
if :sets.is_disjoint(set1, set2) do
:sets.union(set1, set2)
else
:sets.union(:sets.subtract(set1, set2), :sets.subtract(set2, set1))
end
%{map_set1 | map: map}
end
end
defmodule Helper do
def make_sets(s1, s2, overlap_percentage) do
min_size = min(s1, s2)
overlap = round(min_size * overlap_percentage)
set1 = MapSet.new(1..s1)
set2 = MapSet.new((s1 - overlap + 1)..(s1 - overlap + s2))
{set1, set2}
end
end
inputs =
for {s1_name, s1, s2_name, s2} <- [
{"Equal Large", 10_000, "Equal Large", 10_000},
{"Small", 100, "Large", 10_000},
{"Large", 10_000, "Small", 100}
],
{overlap_name, overlap_pct} <- [
{"0% Overlap", 0.0},
{"10% Overlap", 0.1},
{"50% Overlap", 0.5},
{"90% Overlap", 0.9},
{"100% Overlap", 1.0}
],
into: %{} do
key = "#{s1_name} vs #{s2_name} (#{overlap_name})"
val = Helper.make_sets(s1, s2, overlap_pct)
{key, val}
end
Benchee.run(
%{
"main" => fn {set1, set2} -> Bench.main_sym_diff(set1, set2) end,
"branch" => fn {set1, set2} -> Bench.branch_sym_diff(set1, set2) end
},
time: 2,
memory_time: 2,
inputs: inputs
)Noisy system and heat throttling mini PC: Operating System: Linux
CPU Information: AMD Ryzen 7 8845HS w
Number of Available Cores: 16
Available memory: 54.72 GB
Elixir 1.20.0
Erlang 29.0.1
JIT enabled: true
Benchmark suite executing with the following configuration:
warmup: 2 s
time: 2 s
memory time: 2 s
reduction time: 0 ns
parallel: 1
Estimated total run time: 3 min
Excluding outliers: false
##### With input Equal Large vs Equal Large (0% Overlap) #####
Name ips average deviation median 99th %
branch 2.69 K 0.37 ms ±18.33% 0.36 ms 0.46 ms
main 0.82 K 1.22 ms ±5.39% 1.21 ms 1.42 ms
Comparison:
branch 2.69 K
main 0.82 K - 3.29x slower +0.85 ms
Memory usage statistics:
Name average deviation median 99th %
branch 0.35 MB ±0.00% 0.35 MB 0.35 MB
main 1.09 MB ±0.00% 1.09 MB 1.09 MB
Comparison:
branch 0.35 MB
main 1.09 MB - 3.11x memory usage +0.74 MB
##### With input Equal Large vs Equal Large (10% Overlap) #####
Name ips average deviation median 99th %
branch 898.09 1.11 ms ±14.09% 1.09 ms 2.01 ms
main 731.28 1.37 ms ±15.51% 1.37 ms 2.42 ms
Comparison:
branch 898.09
main 731.28 - 1.23x slower +0.25 ms
Memory usage statistics:
Name Memory usage
branch 1.65 MB
main 1.39 MB - 0.84x memory usage -0.26058 MB
**All measurements for memory usage were the same**
##### With input Equal Large vs Equal Large (100% Overlap) #####
Name ips average deviation median 99th %
branch 3.23 K 309.85 μs ±9.24% 308.09 μs 333.80 μs
main 1.15 K 871.33 μs ±5.19% 868.37 μs 923.52 μs
Comparison:
branch 3.23 K
main 1.15 K - 2.81x slower +561.48 μs
Memory usage statistics:
Name Memory usage
branch 0.92 MB
main 2.79 MB - 3.04x memory usage +1.87 MB
**All measurements for memory usage were the same**
##### With input Equal Large vs Equal Large (50% Overlap) #####
Name ips average deviation median 99th %
branch 800.81 1.25 ms ±5.99% 1.24 ms 1.44 ms
main 787.59 1.27 ms ±16.38% 1.24 ms 2.42 ms
Comparison:
branch 800.81
main 787.59 - 1.02x slower +0.0210 ms
Memory usage statistics:
Name average deviation median 99th %
branch 1.29 MB ±0.01% 1.29 MB 1.29 MB
main 2.67 MB ±0.00% 2.67 MB 2.67 MB
Comparison:
branch 1.29 MB
main 2.67 MB - 2.07x memory usage +1.38 MB
##### With input Equal Large vs Equal Large (90% Overlap) #####
Name ips average deviation median 99th %
branch 2.02 K 495.33 μs ±7.72% 492.07 μs 557.78 μs
main 1.09 K 915.82 μs ±11.77% 904.47 μs 1383.35 μs
Comparison:
branch 2.02 K
main 1.09 K - 1.85x slower +420.49 μs
Memory usage statistics:
Name average deviation median 99th %
branch 0.98 MB ±0.00% 0.98 MB 0.98 MB
main 3.71 MB ±0.00% 3.71 MB 3.71 MB
Comparison:
branch 0.98 MB
main 3.71 MB - 3.80x memory usage +2.74 MB
##### With input Large vs Small (0% Overlap) #####
Name ips average deviation median 99th %
branch 168.45 K 0.00594 ms ±65.92% 0.00562 ms 0.0123 ms
main 0.86 K 1.16 ms ±10.31% 1.14 ms 1.74 ms
Comparison:
branch 168.45 K
main 0.86 K - 195.32x slower +1.15 ms
Memory usage statistics:
Name average deviation median 99th %
branch 19.18 KB ±0.00% 19.18 KB 19.18 KB
main 723.64 KB ±0.00% 723.64 KB 723.66 KB
Comparison:
branch 19.18 KB
main 723.64 KB - 37.73x memory usage +704.46 KB
##### With input Large vs Small (10% Overlap) #####
Name ips average deviation median 99th %
branch 96.98 K 0.0103 ms ±41.16% 0.00980 ms 0.0161 ms
main 0.85 K 1.17 ms ±13.03% 1.14 ms 1.83 ms
Comparison:
branch 96.98 K
main 0.85 K - 113.70x slower +1.16 ms
Memory usage statistics:
Name average deviation median 99th %
branch 29.27 KB ±0.00% 29.27 KB 29.27 KB
main 782.78 KB ±0.00% 782.78 KB 782.80 KB
Comparison:
branch 29.27 KB
main 782.78 KB - 26.75x memory usage +753.52 KB
##### With input Large vs Small (100% Overlap) #####
Name ips average deviation median 99th %
branch 137.83 K 0.00726 ms ±67.15% 0.00670 ms 0.0194 ms
main 0.95 K 1.05 ms ±11.70% 1.03 ms 1.73 ms
Comparison:
branch 137.83 K
main 0.95 K - 145.38x slower +1.05 ms
Memory usage statistics:
Name Memory usage
branch 50.57 KB
main 671.23 KB - 13.27x memory usage +620.66 KB
**All measurements for memory usage were the same**
##### With input Large vs Small (50% Overlap) #####
Name ips average deviation median 99th %
branch 92.16 K 0.0109 ms ±26.89% 0.0103 ms 0.0167 ms
main 0.83 K 1.20 ms ±11.68% 1.18 ms 1.83 ms
Comparison:
branch 92.16 K
main 0.83 K - 110.81x slower +1.19 ms
Memory usage statistics:
Name average deviation median 99th %
branch 40.39 KB ±0.00% 40.39 KB 40.39 KB
main 690.43 KB ±0.00% 690.43 KB 690.43 KB
Comparison:
branch 40.39 KB
main 690.43 KB - 17.09x memory usage +650.04 KB
##### With input Large vs Small (90% Overlap) #####
Name ips average deviation median 99th %
branch 116.05 K 0.00862 ms ±39.30% 0.00810 ms 0.0147 ms
main 0.93 K 1.08 ms ±15.26% 1.04 ms 1.98 ms
Comparison:
branch 116.05 K
main 0.93 K - 125.05x slower +1.07 ms
Memory usage statistics:
Name average deviation median 99th %
branch 49.31 KB ±0.00% 49.31 KB 49.31 KB
main 736.09 KB ±0.00% 736.09 KB 736.12 KB
Comparison:
branch 49.31 KB
main 736.09 KB - 14.93x memory usage +686.78 KB
##### With input Small vs Large (0% Overlap) #####
Name ips average deviation median 99th %
branch 168.92 K 0.00592 ms ±64.90% 0.00560 ms 0.0124 ms
main 0.82 K 1.21 ms ±12.31% 1.19 ms 1.94 ms
Comparison:
branch 168.92 K
main 0.82 K - 205.11x slower +1.21 ms
Memory usage statistics:
Name average deviation median 99th %
branch 19.13 KB ±0.00% 19.13 KB 19.13 KB
main 671.16 KB ±0.00% 671.16 KB 671.18 KB
Comparison:
branch 19.13 KB
main 671.16 KB - 35.08x memory usage +652.03 KB
##### With input Small vs Large (10% Overlap) #####
Name ips average deviation median 99th %
branch 101.33 K 0.00987 ms ±37.65% 0.00933 ms 0.0159 ms
main 0.82 K 1.22 ms ±11.46% 1.19 ms 1.90 ms
Comparison:
branch 101.33 K
main 0.82 K - 123.97x slower +1.21 ms
Memory usage statistics:
Name average deviation median 99th %
branch 29.70 KB ±0.00% 29.70 KB 29.70 KB
main 721.11 KB ±0.00% 721.11 KB 721.13 KB
Comparison:
branch 29.70 KB
main 721.11 KB - 24.28x memory usage +691.41 KB
##### With input Small vs Large (100% Overlap) #####
Name ips average deviation median 99th %
branch 140.07 K 0.00714 ms ±49.29% 0.00666 ms 0.0169 ms
main 0.94 K 1.06 ms ±16.46% 1.02 ms 1.95 ms
Comparison:
branch 140.07 K
main 0.94 K - 148.93x slower +1.06 ms
Memory usage statistics:
Name Memory usage
branch 50.73 KB
main 704.20 KB - 13.88x memory usage +653.46 KB
**All measurements for memory usage were the same**
##### With input Small vs Large (50% Overlap) #####
Name ips average deviation median 99th %
branch 92.81 K 0.0108 ms ±44.52% 0.0102 ms 0.0186 ms
main 0.85 K 1.18 ms ±12.05% 1.17 ms 1.90 ms
Comparison:
branch 92.81 K
main 0.85 K - 109.39x slower +1.17 ms
Memory usage statistics:
Name Memory usage
branch 41.05 KB
main 768.08 KB - 18.71x memory usage +727.03 KB
**All measurements for memory usage were the same**
##### With input Small vs Large (90% Overlap) #####
Name ips average deviation median 99th %
branch 119.01 K 0.00840 ms ±44.81% 0.00769 ms 0.0160 ms
main 0.92 K 1.08 ms ±13.83% 1.05 ms 1.85 ms
Comparison:
branch 119.01 K
main 0.92 K - 128.77x slower +1.07 ms
Memory usage statistics:
Name average deviation median 99th %
branch 49.75 KB ±0.00% 49.75 KB 49.75 KB
main 701.66 KB ±0.00% 701.66 KB 701.66 KB
Comparison:
branch 49.75 KB
main 701.66 KB - 14.10x memory usage +651.91 KB |
|
@sabiwara thx, I have edited my comment above so it has the updated benchmark and results. |
sabiwara
left a comment
There was a problem hiding this comment.
Oh, this new version looks quite fast indeed. Thank you @preciz 💜
Was wondering why the subtract was so fast, turns out José implemented nice heuristics on these:
erlang/otp@1a9df67
|
💚 💙 💜 💛 ❤️ |
By folding over the smaller set and using the larger set as the starting accumulator, the time complexity is reduced from O(large) to O(small) iterations. This provides a over 100x speedup when set sizes are mismatched.
When set sizes match the performance is the same as before.
Assisted-by: Antigravity CLI : Claude Opus 4.6 & Gemini Flash 3.5