Skip to content

Commit dbcdb1e

Browse files
authored
New article on eager differences (#1818)
1 parent dcb7b3d commit dbcdb1e

File tree

1 file changed

+381
-0
lines changed

1 file changed

+381
-0
lines changed
Lines changed: 381 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,381 @@
1+
---
2+
layout: post
3+
title: "Lazy BDDs with eager literal differences"
4+
authors:
5+
- José Valim
6+
category: Internals
7+
excerpt: "This is a follow up to our batch of set-theoretic types optimizations, this time targetting differences"
8+
---
9+
10+
In [a previous article](/blog/2026/02/26/eager-literal-intersections/),
11+
we discussed how we optimized intersections in Elixir set-theoretic types
12+
to improve performance.
13+
14+
In a nutshell, lazy BDDs allow us to represent set-theoretic operations at
15+
any depth. And while this is useful in many cases, they offer a downside
16+
when it comes to intersections. For example, take this type:
17+
18+
```elixir
19+
(%Foo{} or %Bar{} or %Baz{} or %Bat{}) and %Bar{}
20+
```
21+
22+
While we could store the above as-is in the BDD, from a quick glance
23+
it is clear that the above can only be equal to `%Bar{}`. To address
24+
this, we made intersections eager, removing the size of BDDs and
25+
drastically improving compilation times.
26+
27+
Lately, Elixir v1.20.0-rc.2 introduced new improvements to the type
28+
checker. Among them is the ability to propagate type information
29+
across clauses and check for redundant clauses. For example, take
30+
this code:
31+
32+
```elixir
33+
def example(x) when is_binary(x), do: ...
34+
def example(x) when is_integer(x), do: ...
35+
def example(x), do: ...
36+
```
37+
38+
In the first clause, we know the argument is a binary. In the second,
39+
we know it is an integer. Therefore, in the third one, even though there
40+
are no guards, we know `x` has type `not binary() and not integer()`.
41+
In other words, the type of a given clause is computed by the type of its
42+
patterns and guards, **minus** the types of the previous clauses.
43+
44+
Furthermore, we can now check if a clause is redundant by checking if its
45+
type definition is a subset/subtype of the previous ones. For example, if
46+
you have three clauses, each with type `clause1`, `clause2`, and `clause3`,
47+
you know `clause3` is redundant if its type is contained in the union of
48+
the types of `clause1` and `clause2`:
49+
50+
```
51+
clause3 ⊆ (clause1 ∪ clause2)
52+
```
53+
54+
In set-theoretic types, a type is a subtype of the other if it is a subset
55+
of said type, so we will use these terms interchangeably. Furthermore,
56+
checking if a type is a subset/subtype of another can be done by checking
57+
if the difference between `clause3` and the union of the clauses is empty.
58+
In Elixir terms:
59+
60+
```elixir
61+
empty?(difference(clause3, union(clause1, clause2)))
62+
```
63+
64+
Long story short: with Elixir v1.20.0-rc.2, the type system is seeing an
65+
increase number of differences. Projects where modules had 1000+ of clauses
66+
were taking too long to compile, so it was time to derive new formulas and
67+
optimizations.
68+
69+
> As with previous articles, we discuss implementation details of the
70+
> type system. You don’t need to understand these internals to use the type
71+
> system. Our goal is simply to document our progress and provide guidance
72+
> for future maintainers and implementers. Let’s get started.
73+
74+
## A recap on lazy BDDs and literals
75+
76+
A lazy BDD has type:
77+
78+
```elixir
79+
type lazy_bdd() = :top or :bottom or
80+
{type(), constrained :: lazy_bdd(), uncertain :: lazy_bdd(), dual :: lazy_bdd()}
81+
```
82+
83+
where `type()` is the representation of the actual type. For example,
84+
if the type being represented is a `tuple`, `type()` would be a list of
85+
all elements in the tuple. In literature, `type()` is known as literal.
86+
87+
Throughout this article, we will use the following notation to represent
88+
lazy BDDs:
89+
90+
```elixir
91+
B = {a, C, U, D}
92+
```
93+
94+
where `B` stands for BDD, `a` is the literal, `C` is constrained, `U`
95+
is uncertain, and `D` is dual. Semantically, the BDD above is the same as:
96+
97+
```elixir
98+
B = (a and C) or U or (not a and D)
99+
```
100+
101+
Which means the following expression, where `foo`, `bar`, `baz`,
102+
and `bat` below represent types:
103+
104+
```elixir
105+
(foo and not (bar or (baz and bat))
106+
```
107+
108+
will be stored as:
109+
110+
```elixir
111+
{foo,
112+
{bar, :bottom, :bottom,
113+
{baz, :bottom,
114+
{bat, :bottom, :bottom, :top}, :top}, :bottom, :bottom}
115+
```
116+
117+
## Eager literal differences
118+
119+
The main insight of the previous article was, when intersecting two BDDs:
120+
121+
```elixir
122+
B1 = {a1, C1, U1, D1}
123+
B2 = {a2, C2, U2, D2}
124+
```
125+
126+
if the intersection between `a1 and a2` is disjoint (i.e. it returns
127+
the empty type), we can likely build new formulas that eliminate many
128+
nodes from the BDD recursively.
129+
130+
The goal is to apply the same optimization for differences. In particular,
131+
there are two properties that we can leverage from differences. Take the
132+
difference between `a1` and `a2`. If they are disjoint, they have nothing
133+
in common, and the result is `a1`. On the other hand, if `a1` is a subtype
134+
of `a2`, then the difference is empty.
135+
136+
Furthermore, for simplicity, we will only optimize the cases where at least
137+
one of the sides is exclusively a literal, which means that `C = :top`,
138+
`U = :bottom`, and `D = :bottom`. Let's get to work!
139+
140+
### Literal on the right-hand side
141+
142+
We want to derive new formulas for difference when `B2` is a literal.
143+
Let's start with the base formula:
144+
145+
```
146+
B1 and not B2
147+
```
148+
149+
where `B1` is `(a1 and C1) or U1 or (not a1 and D1)` and `B2` is
150+
simply `a2`. So we have:
151+
152+
```
153+
((a1 and C1) or U1 or (not a1 and D1)) and not a2
154+
```
155+
156+
Now let's distribute `and not a2`:
157+
158+
```
159+
(a1 and not a2 and C1) or (U1 and not a2) or (not a1 and not a2 and D1)
160+
```
161+
162+
When they are disjoint, `a1 and not a2` is simply `a1`, so we have:
163+
164+
```
165+
(a1 and C1) or (U1 and not a2) or (not a1 and not a2 and D1)
166+
```
167+
168+
When `a1` is a subtype of `a2`, `a1 and not a2` is empty,
169+
plus `not a1 and not a2` is the same as `not (a1 or a2)`,
170+
which is the same as `not a2`. So we have:
171+
172+
```
173+
(U1 and not a2) or (D1 and not a2)
174+
```
175+
176+
In both formulas, `and not a2` is then applied using the same
177+
eager literal difference recursively.
178+
179+
### Literal on the left-hand side
180+
181+
Now let's derive new formulas for difference when `B1` is a literal.
182+
This means we want to compute:
183+
184+
```
185+
B1 and not B2
186+
```
187+
188+
Which we can expand to:
189+
190+
```
191+
a1 and not ((a2 and C2) or U2 or (not a2 and D2))
192+
```
193+
194+
Now let's distribute the `not` over the right-hand side:
195+
196+
```
197+
a1 and (not a2 or not C2) and (not U2) and (a2 or not D2)
198+
```
199+
200+
When `a1` and `a2` are disjoint, we know that `a1 and (not a2 or not C2)`
201+
is `a1`. This is because if we distribute the intersection,
202+
we end up with `(a1 and not a2) or (a1 and not C2)`. And since
203+
`a1 and not a2` is `a1`, we end up with `a1` unioned with a type
204+
that is a subset of `a1`, hence `a1`.
205+
206+
So we end up with:
207+
208+
```
209+
a1 and (not U2) and (a2 or not D2)
210+
```
211+
212+
And if `a1` and `a2` are disjoint, the intersection between them is empty,
213+
so we are left with the following disjoint formula:
214+
215+
```
216+
a1 and not D2 and not U2
217+
```
218+
219+
When `a1` is a subtype of `a2`, we can simplify two expressions
220+
in the initial formula. Let's look at it again:
221+
222+
```
223+
a1 and (not a2 or not C2) and (not U2) and (a2 or not D2)
224+
```
225+
226+
First we distribute the intersection in `a1 and (not a2 or not C2)`.
227+
We will have two parts, `a1 and not a2`, which is empty, unioned
228+
with `a1 and not C2`, resulting in:
229+
230+
```
231+
a1 and (not C2) and (not U2) and (a2 or not D2)
232+
```
233+
234+
Now we can distribute `a1 and (a2 or not D2)`. And because
235+
`a1 and a2` is `a1` (since `a1` is a subset), we end up with
236+
`a1 or (a1 and not D2)`, which is `a1`. So our subset formula
237+
becomes:
238+
239+
```
240+
a1 and not C2 and not U2
241+
```
242+
243+
As you can see, these new formulas can reduce the amount
244+
of nodes in the BDD drastically, which lead to much better
245+
performance.
246+
247+
## One last trick: one field difference
248+
249+
The optimizations above lead to excellent performance. Projects
250+
that would take dozens of seconds to compile could now do so in
251+
milliseconds. However, there were still some cases where the
252+
optimizations could not kick-in, leading to worse performance.
253+
In particular, with structs.
254+
255+
When working with a struct in Elixir, the fields will most often
256+
have the same type, except for one. For example:
257+
258+
```
259+
def example(%MyStruct{x: x}) when is_binary(x)
260+
def example(%MyStruct{x: x}) when is_integer(x)
261+
def example(%MyStruct{x: x})
262+
```
263+
264+
In the example above, `x` in the third clause starts with the value
265+
of `term`, so the last struct is a supertype of the other ones,
266+
and our optimizations do not apply. Therefore, the type of the third
267+
clause would be:
268+
269+
```elixir
270+
%MyStruct{x: term()} and not %MyStruct{x: integer()} and not %MyStruct{x: binary()}
271+
```
272+
273+
However, whenever only one of the fields are different, we can translate
274+
the above as the difference of said field, so instead we could have:
275+
276+
```elixir
277+
%MyStruct{x: term() and not integer() and not binary()}
278+
```
279+
280+
All we need to do is to compute new formulas. So let's do it one last time.
281+
For our last batch of formulas, we will need three new types: `a_diff`
282+
which is a new literal where we compute the difference between the only
283+
different field (as done above), as well as `a_int` and `a_union`, which
284+
is respectively the intersection and union of the distinct field.
285+
286+
### Literal on the right-hand side
287+
288+
Our formula for `B1 and not B2` with a literal on the right-hand side is:
289+
290+
```
291+
(a1 and not a2 and C1) or (U1 and not a2) or (not a1 and not a2 and D1)
292+
```
293+
294+
`a1 and not a2` is `a_diff`. `not a1 and not a2` is the same as
295+
`(not (a1 or a2))` which is the same as `not a_union`, so we end up with:
296+
297+
```
298+
(a_diff and C1) or (U1 and not a2) or (not a_union and D1)
299+
```
300+
301+
### Literal on the left-hand side
302+
303+
Our starting point is:
304+
305+
```
306+
a1 and (not a2 or not C2) and (not U2) and (a2 or not D2)
307+
```
308+
309+
By distributing the first intersection, we have:
310+
311+
```
312+
((a1 and not a2) or (a1 and not C2)) and not U2 and (a2 or not D2)
313+
```
314+
315+
We know that `a1 and not a2` is `a_diff`. So let's slot that in
316+
and change the order of operations:
317+
318+
```
319+
(a_diff or (a1 and not C2)) and (a2 or not D2) and not U2
320+
```
321+
322+
We now distribute `(a_diff or (a1 and not C2)) and (a2 or not D2)`:
323+
324+
```
325+
((a_diff and (a2 or not D2)) or
326+
((a1 and not C2) and (a2 or not D2))) and not U2
327+
```
328+
329+
`a_diff and a2` is empty, so the first `and` becomes `a_diff and not D2`.
330+
Then we distribute the second `and` and, after replacing `a1 and a2` by
331+
`a_int`, we get the following:
332+
333+
```
334+
((a_diff and not D2) or
335+
(a_int and not C2) or
336+
(a1 and not C2 and not D2)) and not U2
337+
```
338+
339+
At this point, I thought no further simplifications were possible. That's
340+
when I reached to Claude Opus 4.6 to explore alternative variations and it
341+
suggested the following "obvious-only-in-hindsight" simplication.
342+
We know that `a1 = a_diff or a_int`, so let's slot that in:
343+
344+
```
345+
((a_diff and not D2) or
346+
(a_int and not C2) or
347+
((a_diff or a_int) and not C2 and not D2)) and not U2
348+
```
349+
350+
Now if we distribute `(a_diff or a_int) and not C2 and not D2)`, we get:
351+
352+
```
353+
((a_diff and not D2) or
354+
(a_int and not C2) or
355+
(a_diff and not C2 and not D2) or
356+
(a_int and not C2 and not D2)) and not U2
357+
```
358+
359+
However, we know that `(a_diff and not D2 and not C2)` is
360+
a subtype of `(a_diff and not D2)` (as it is the same set
361+
minus C2), and the same applies to `(a_int and not C2 and not D2)`.
362+
And then union of two types `a or b`, when `b` is a subset,
363+
is always `a`. Therefore both terms can be fully discarded,
364+
so we end up with:
365+
366+
```
367+
((a_diff and not D2) or (a_int and not C2)) and not U2
368+
```
369+
370+
## Summary
371+
372+
We implemented all simplifications above and they will be available
373+
in full as part of Elixir v1.20.0-rc4. At the moment, we have measured
374+
clear impact from the "literal on the left-hand side" optimizations,
375+
allowing us to drastically improve the type system performance when
376+
checking thousands of clauses or large structs. At the moment,
377+
we did not spot any scenarios where the right-hand side optimizations
378+
were useful, most likely because it does not show up in codebases (yet).
379+
380+
We will continue assessing the performance of the type system as we add
381+
more features based on community feedback.

0 commit comments

Comments
 (0)