Skip to content

Optimize MapSet.symmetric_difference/2 when sizes mismatched - #15471

Merged
josevalim merged 3 commits into
elixir-lang:mainfrom
preciz:optimize-mapset-symmetric-difference
Jun 14, 2026
Merged

Optimize MapSet.symmetric_difference/2 when sizes mismatched#15471
josevalim merged 3 commits into
elixir-lang:mainfrom
preciz:optimize-mapset-symmetric-difference

Conversation

@preciz

@preciz preciz commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

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

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.
@josevalim

Copy link
Copy Markdown
Member

Hi @preciz, for documentation purposes, can you share the benchmarks you ran, alongside input sizes.

@preciz

preciz commented Jun 13, 2026

Copy link
Copy Markdown
Contributor Author

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

@josevalim

Copy link
Copy Markdown
Member

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.

@sabiwara

Copy link
Copy Markdown
Contributor

Yes, and also we should bench with memory_time as well.
In this case it seems we're mostly reducing memory usage, looks good.

@preciz

preciz commented Jun 14, 2026

Copy link
Copy Markdown
Contributor Author

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.

@preciz

preciz commented Jun 14, 2026

Copy link
Copy Markdown
Contributor Author

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

Comment thread lib/elixir/lib/map_set.ex Outdated
@preciz

preciz commented Jun 14, 2026

Copy link
Copy Markdown
Contributor Author

@sabiwara thx, I have edited my comment above so it has the updated benchmark and results.

@sabiwara sabiwara 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.

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

@josevalim
josevalim merged commit efcdfb8 into elixir-lang:main Jun 14, 2026
15 checks passed
@josevalim

Copy link
Copy Markdown
Member

💚 💙 💜 💛 ❤️

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants