Skip to content

Commit e34495e

Browse files
sabiwarajosevalim
authored andcommitted
Optimize map unions to avoid building long lists (#14215)
1 parent 64c4ecf commit e34495e

File tree

2 files changed

+158
-21
lines changed

2 files changed

+158
-21
lines changed

Diff for: lib/elixir/lib/module/types/descr.ex

+118-21
Original file line numberDiff line numberDiff line change
@@ -1264,8 +1264,115 @@ defmodule Module.Types.Descr do
12641264

12651265
defp map_only?(descr), do: empty?(Map.delete(descr, :map))
12661266

1267-
# Union is list concatenation
1268-
defp map_union(dnf1, dnf2), do: dnf1 ++ (dnf2 -- dnf1)
1267+
defp map_union(dnf1, dnf2) do
1268+
# Union is just concatenation, but we rely on some optimization strategies to
1269+
# avoid the list to grow when possible
1270+
1271+
# first pass trying to identify patterns where two maps can be fused as one
1272+
with [map1] <- dnf1,
1273+
[map2] <- dnf2,
1274+
optimized when optimized != nil <- maybe_optimize_map_union(map1, map2) do
1275+
[optimized]
1276+
else
1277+
# otherwise we just concatenate and remove structural duplicates
1278+
_ -> dnf1 ++ (dnf2 -- dnf1)
1279+
end
1280+
end
1281+
1282+
defp maybe_optimize_map_union({tag1, pos1, []} = map1, {tag2, pos2, []} = map2) do
1283+
case map_union_optimization_strategy(tag1, pos1, tag2, pos2) do
1284+
:all_equal ->
1285+
map1
1286+
1287+
:any_map ->
1288+
{:open, %{}, []}
1289+
1290+
{:one_key_difference, key, v1, v2} ->
1291+
new_pos = Map.put(pos1, key, union(v1, v2))
1292+
{tag1, new_pos, []}
1293+
1294+
:left_subtype_of_right ->
1295+
map2
1296+
1297+
:right_subtype_of_left ->
1298+
map1
1299+
1300+
nil ->
1301+
nil
1302+
end
1303+
end
1304+
1305+
defp maybe_optimize_map_union(_, _), do: nil
1306+
1307+
defp map_union_optimization_strategy(tag1, pos1, tag2, pos2)
1308+
defp map_union_optimization_strategy(tag, pos, tag, pos), do: :all_equal
1309+
defp map_union_optimization_strategy(:open, empty, _, _) when empty == %{}, do: :any_map
1310+
defp map_union_optimization_strategy(_, _, :open, empty) when empty == %{}, do: :any_map
1311+
1312+
defp map_union_optimization_strategy(tag, pos1, tag, pos2)
1313+
when map_size(pos1) == map_size(pos2) do
1314+
:maps.iterator(pos1)
1315+
|> :maps.next()
1316+
|> do_map_union_optimization_strategy(pos2, :all_equal)
1317+
end
1318+
1319+
defp map_union_optimization_strategy(:open, pos1, _, pos2)
1320+
when map_size(pos1) <= map_size(pos2) do
1321+
:maps.iterator(pos1)
1322+
|> :maps.next()
1323+
|> do_map_union_optimization_strategy(pos2, :right_subtype_of_left)
1324+
end
1325+
1326+
defp map_union_optimization_strategy(_, pos1, :open, pos2)
1327+
when map_size(pos1) >= map_size(pos2) do
1328+
:maps.iterator(pos2)
1329+
|> :maps.next()
1330+
|> do_map_union_optimization_strategy(pos1, :right_subtype_of_left)
1331+
|> case do
1332+
:right_subtype_of_left -> :left_subtype_of_right
1333+
nil -> nil
1334+
end
1335+
end
1336+
1337+
defp map_union_optimization_strategy(_, _, _, _), do: nil
1338+
1339+
defp do_map_union_optimization_strategy(:none, _, status), do: status
1340+
1341+
defp do_map_union_optimization_strategy({key, v1, iterator}, pos2, status) do
1342+
with %{^key => v2} <- pos2,
1343+
next_status when next_status != nil <- map_union_next_strategy(key, v1, v2, status) do
1344+
do_map_union_optimization_strategy(:maps.next(iterator), pos2, next_status)
1345+
else
1346+
_ -> nil
1347+
end
1348+
end
1349+
1350+
defp map_union_next_strategy(key, v1, v2, status)
1351+
1352+
# structurally equal values do not impact the ongoing strategy
1353+
defp map_union_next_strategy(_key, same, same, status), do: status
1354+
1355+
defp map_union_next_strategy(key, v1, v2, :all_equal) do
1356+
if key != :__struct__, do: {:one_key_difference, key, v1, v2}
1357+
end
1358+
1359+
defp map_union_next_strategy(_key, v1, v2, {:one_key_difference, _, d1, d2}) do
1360+
# we have at least two key differences now, we switch strategy
1361+
# if both are subtypes in one direction, keep checking
1362+
cond do
1363+
subtype?(d1, d2) and subtype?(v1, v2) -> :left_subtype_of_right
1364+
subtype?(d2, d1) and subtype?(v2, v1) -> :right_subtype_of_left
1365+
true -> nil
1366+
end
1367+
end
1368+
1369+
defp map_union_next_strategy(_key, v1, v2, :left_subtype_of_right) do
1370+
if subtype?(v1, v2), do: :left_subtype_of_right
1371+
end
1372+
1373+
defp map_union_next_strategy(_key, v1, v2, :right_subtype_of_left) do
1374+
if subtype?(v2, v1), do: :right_subtype_of_left
1375+
end
12691376

12701377
# Given two unions of maps, intersects each pair of maps.
12711378
defp map_intersection(dnf1, dnf2) do
@@ -1747,29 +1854,19 @@ defmodule Module.Types.Descr do
17471854

17481855
defp map_non_negated_fuse(maps) do
17491856
Enum.reduce(maps, [], fn map, acc ->
1750-
case Enum.split_while(acc, &non_fusible_maps?(map, &1)) do
1751-
{_, []} ->
1752-
[map | acc]
1753-
1754-
{others, [match | rest]} ->
1755-
fused = map_non_negated_fuse_pair(map, match)
1756-
others ++ [fused | rest]
1757-
end
1857+
fuse_with_first_fusible(map, acc)
17581858
end)
17591859
end
17601860

1761-
# Two maps are fusible if they differ in at most one element.
1762-
defp non_fusible_maps?({_, fields1, []}, {_, fields2, []}) do
1763-
Enum.count_until(fields1, fn {key, value} -> Map.fetch!(fields2, key) != value end, 2) > 1
1764-
end
1765-
1766-
defp map_non_negated_fuse_pair({tag, fields1, []}, {_, fields2, []}) do
1767-
fields =
1768-
symmetrical_merge(fields1, fields2, fn _k, v1, v2 ->
1769-
if v1 == v2, do: v1, else: union(v1, v2)
1770-
end)
1861+
defp fuse_with_first_fusible(map, []), do: [map]
17711862

1772-
{tag, fields, []}
1863+
defp fuse_with_first_fusible(map, [candidate | rest]) do
1864+
if fused = maybe_optimize_map_union(map, candidate) do
1865+
# we found a fusible candidate, we're done
1866+
[fused | rest]
1867+
else
1868+
[candidate | fuse_with_first_fusible(map, rest)]
1869+
end
17731870
end
17741871

17751872
# If all fields are the same except one, we can optimize map difference.

Diff for: lib/elixir/test/elixir/module/types/descr_test.exs

+40
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,46 @@ defmodule Module.Types.DescrTest do
101101
assert union(difference(list(term()), list(integer())), list(integer()))
102102
|> equal?(list(term()))
103103
end
104+
105+
test "optimizations" do
106+
# The tests are checking the actual implementation, not the semantics.
107+
# This is why we are using structural comparisons.
108+
# It's fine to remove these if the implementation changes, but breaking
109+
# these might have an important impact on compile times.
110+
111+
# Optimization one: same tags, all but one key are structurally equal
112+
assert union(
113+
open_map(a: float(), b: atom()),
114+
open_map(a: integer(), b: atom())
115+
) == open_map(a: union(float(), integer()), b: atom())
116+
117+
assert union(
118+
closed_map(a: float(), b: atom()),
119+
closed_map(a: integer(), b: atom())
120+
) == closed_map(a: union(float(), integer()), b: atom())
121+
122+
# Optimization two: we can tell that one map is a trivial subtype of the other:
123+
124+
assert union(
125+
closed_map(a: term(), b: term()),
126+
closed_map(a: float(), b: binary())
127+
) == closed_map(a: term(), b: term())
128+
129+
assert union(
130+
open_map(a: term()),
131+
closed_map(a: float(), b: binary())
132+
) == open_map(a: term())
133+
134+
assert union(
135+
closed_map(a: float(), b: binary()),
136+
open_map(a: term())
137+
) == open_map(a: term())
138+
139+
assert union(
140+
closed_map(a: term(), b: tuple([term(), term()])),
141+
closed_map(a: float(), b: tuple([atom(), binary()]))
142+
) == closed_map(a: term(), b: tuple([term(), term()]))
143+
end
104144
end
105145

106146
describe "intersection" do

0 commit comments

Comments
 (0)