|
| 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