The Pattern-Matching Bug: fix totality information#13152
Conversation
f76daf9 to
8ce4b1a
Compare
9351e57 to
828e27e
Compare
There was a problem hiding this comment.
The main question in my head when reviewing this was performance, as the correctness seems clear. I've run the change over a large corpus and only turned up a few cases where extra checks are inserted. Two of these examples are worth discussing. It feels to me that Case 2 might just be a larger example of Case 1.
Case 1: the simplest example I've found that hits the extra case. Can we avoid it?
type 'a t =
{ mutable x : 'a
; mutable y: 'a
}
let both_some t =
match t with
| { x = Some x; y = Some y } -> Some { x; y }
| { x = None; y = _ } | { x = _; y = None } -> NoneThe compilation prior to this PR:
(let
(both_some/270 =
(function t/272
(catch
(let (*match*/278 =o (field_mut 0 t/272))
(if (isint *match*/278) (exit 1)
(let (*match*/279 =o (field_mut 1 t/272))
(if (isint *match*/279) (exit 1)
(makeblock 0
(makemutable 0 (field_imm 0 *match*/278)
(field_imm 0 *match*/279)))))))
with (1) 0)))
...)
and after:
(let
(both_some/273 =
(function t/275
(catch
(let (*match*/281 =o (field_mut 0 t/275))
(if (isint *match*/281) (exit 2)
(let (*match*/282 =o (field_mut 1 t/275))
(if (isint *match*/282)
(let (*match*/284 =o (field_mut 1 t/275))
(if (isint *match*/284) (exit 2)
(raise
(makeblock 0 (global Match_failure/20!)
[0: "example2.ml" 7 2]))))
(makeblock 0
(makemutable 0 (field_imm 0 *match*/281)
(field_imm 0 *match*/282)))))))
with (2) 0)))
...)
In the new result, two checks are performed in sequence on field_mut 1 t. It seems that the second check could be elided.
Case 2: sensitivity to order of field matching. In principle, the asymmetry here seems weird.
Example:
type ab = A | B
type t =
{ mutable field1 : ab
; mutable field2 : ab
; mutable field3 : ab
}
let rank_of_last_a = function
| { field3 = A; _ } -> 3
| { field3 = B; field2 = A; _ } -> 2
| { field3 = B; field2 = B; field1 = A } -> 1
| { field3 = B; field2 = B; field1 = B } -> -1
;;
let rank_of_first_a = function
| { field1 = A; _ } -> 1
| { field1 = B; field2 = A; _ } -> 2
| { field1 = B; field2 = B; field3 = A } -> 3
| { field1 = B; field2 = B; field3 = B } -> -1
;;These functions look very similar but the compilation is affected differently by this PR.
It's true that their compilation is already a little different prior to this PR, but not in a way that seems important:
(let
(rank_of_last_a/274 =
(function param/276 : int
(let
(*match*/284 =o (field 0 param/276)
*match*/285 =o (field 1 param/276)
*match*/286 =o (field 2 param/276))
(if *match*/286 (if *match*/285 (if *match*/284 -1 1) 2) 3))))
...)
(let
(rank_of_first_a/277 =
(function param/279 : int
(let (*match*/292 =o (field 0 param/279))
(if *match*/292
(let (*match*/295 =o (field 1 param/279))
(if *match*/295
(let (*match*/297 =o (field 2 param/279))
(if *match*/297 -1 3))
2))
1))))
...)
After this PR, rank_of_last_a gets a worse compilation. It first checks whether all three fields are B, and if they are, it re-reads the fields and checks them again! And, if the earlier-checked fields have since changed to A, an exception is raised. This seems suboptimal, as it would have been equally fine to just return -1 in that case.
New compilation for rank_of_last_a
(let
(rank_of_last_a/277 =
(function param/279 : int
(catch
(let
(*match*/287 =o (field_int 0 param/279)
*match*/288 =o (field_int 1 param/279)
*match*/289 =o (field_int 2 param/279))
(if *match*/289
(if *match*/288
(if *match*/287
(let (*match*/293 =o (field_int 1 param/279))
(if *match*/293
(let (*match*/294 =o (field_int 2 param/279))
(if *match*/294 -1 (exit 1)))
(exit 1)))
(let (*match*/291 =o (field_int 1 param/279))
(if *match*/291
(let (*match*/292 =o (field_int 2 param/279))
(if *match*/292 1 (exit 1)))
(exit 1))))
(let (*match*/290 =o (field_int 2 param/279))
(if *match*/290 2 (exit 1))))
3))
with (1)
(raise
(makeblock 0 (global Match_failure/20!) [0: "example1.ml" 9 21])))))
...)
The compilation of rank_of_first_a is unaffected.
The asymmetry between the effect on rank_of_first_a and rank_of_last_a suggests that there might be a way to make rank_of_last_a emerge unscatched from this PR.
| a reference is pessimized. (Matching on non-GADT constructors | ||
| inside a reference is also arguably pessimized, given that cases | ||
| where side-effects occur are exceedingly rare; but we cannot know | ||
| that they will not occur.) |
There was a problem hiding this comment.
This remark may appear mystifying given that the only example is of a case where there is a trivial syntactic criterion to observe that there might be side effects (there is a when guard). I believe this remark is about multithreaded mutation; maybe mention that?
lambda/matching.ml
Outdated
| (* It is important to distinguish: | ||
| - [arg_partial]: the partiality information that will | ||
| be used to compile the 'upcoming' switch on the first argument | ||
| - [partial:] the partiality information that will be used |
There was a problem hiding this comment.
| - [partial:] the partiality information that will be used | |
| - [partial]: the partiality information that will be used |
| (* a type of per-argument partiality information used by | ||
| [mk_failaction_*] functions to reason statically about which | ||
| partiality information is used for these per-argument functions. *) |
There was a problem hiding this comment.
The comment is easy enough to reason about in the context of this PR, but I feel it leaves out enough connective tissue that future readers will be confused. (Per-argument partiality information as opposed to what? Of course a type helps us reason statically -- what is that remark about?)
It may be more clarifying (and simplify the comment) to instead create a new sum type, type arg_partiality = Arg_partial | Arg_total.
|
I am not highly confident that this is the right characterization, but it may be helpful anyway: If a pattern does not bind any variables, then there's no need to check that the relevant subfield of the scrutinee still matches that pattern before performing the action. |
|
I checked that #13154 doesn't de-pessimize either case I describe, though you might want to confirm. |
|
I'm afraid that I don't have an easy answer to your question. I don't see a feasible, non-invasive way to improve the pattern-matching compiler that would re-optimize these programs and not have other negative consequences. Below is to explain the situation of this examples in details, and I list a few avenues for improvement that I considered -- I did not found any of them to be convincing. My impression is that the right choice to do here is probably to favor correctness over (complexity or performance), and accept that those examples now generate slightly worse code. Understanding what happensIn those two examples, the compiler generates a split during pattern-matching compilation, and the matrices below the split get slightly worse compilation. First example: let both_some t =
match t with
| { x = Some x; y = Some y } -> Some { x; y }
| { x = None; y = _ } | { x = _; y = None } -> Nonewith The split comes from the pattern In this case it is a bit unfortunate as we know that in fact this then you get the nice compilation you expect, without a split. The conceptual difference is that all cases now start with a head constructor for the first column. Second example: let rank_of_first_a = function
| { field1 = A; _ } -> 1
| { field1 = B; field2 = A; _ } -> 2
| { field1 = B; field2 = B; field3 = A } -> 3
| { field1 = B; field2 = B; field3 = B } -> -1
;;
let rank_of_last_a = function
| { field3 = A; _ } -> 3
| { field3 = B; field2 = A; _ } -> 2
| { field3 = B; field2 = B; field1 = A } -> 1
| { field3 = B; field2 = B; field1 = B } -> -1
;;The The Re-optimizing the code?The following ideas come to mind to re-optimize the generated code. Column compilation order (second example)The For this reason, I don't think it is realistic to hope to move from the left-to-right compilation order in the context of this PR / fixing this bug. Avoiding unecessary splits (first example)I pointed out that it is possible to manually specialize Binding mutable fields earlierI think that the compilation of the first example would improve if we were to read mutable record fields eagerly, when matching on their record, instead of binding them only when they come up in the first column. In particular this would avoid the double read on This is an approach that we proposed in #12556. I ultimately decided against it because it was a more invasive change to the compilation strategy, that came with the risk of pessimizing other examples by generating un-necessary mutable reads. In particular, in the second example, notice how the compilation of Treating these failures as unspecified behaviorWith this PR, the pattern-matching compiler downgrades some submatrices from Total to Partial, and it will compile them like any other Partial submatrix, that is, by ensuring that there is a catch-all case that goes to a Another option would be to teach the compiler that (1) yes, the failure case is possible, but (2) we don't particularly care about getting a For example, if we look at your first example, a part of the generated and now it looks like this: This corresponds to a sub-clause of the form Before the pattern-matching compiler would generate code that reads the field With this PR, the compiler is aware that the value of Instead we could say: okay, I have a vague idea of how we could try to do this:
However, note that this optimization approach comes at the expense of weaker semantics. With the current approach, users know that if their program is affected by a value being mutated while it is matched, they will get a clean Aside: a remark on redundant readsThe first example that you give has the frustrating property that there are splits, as you can see in the Notice that the exit (The redundant load gets eliminated by CSE (Common Subexpression Elimination) later down in the native-compiler pipeline, but the redundant branch is preserved.) |
|
Thanks for the explanation. That clarifies to me what's going on with these examples. (Yes, indeed, I should have looked at I agree with your conclusion about the first three alternatives for re-optimization: either it's not clear if there's a viable approach, or there's a clear downside and it's hard to decide the right tradeoff. Treating these failures as unspecified behavior?I'm mostly unmoved by the argument about weaker semantics. Whether or not the failure case is inserted is already sensitive to the pattern match compiler's splitting algorithm. I don't think the user can reliably predict these choices — as you note, splitting handles In other words, I find the existing semantics to be weak already. (To be clear, I'm not complaining about this.) Even the type-checker is confused (I say somewhat facetiously): it doesn't produce a warning for Here's a perhaps silly approach: If the final case's pattern can be replaced by a catch-all pattern (i.e. the match is total and the final case binds no variables), then do so before invoking the pattern match compiler. I think it does improve the compilation of both of these cases. It may be too limited in utility to add this special case, but it could be worth considering. |
|
My last suggestion, to replace the last case in a “total” match with a wildcard case, doesn’t work in general because some wildcards can match a value that isn’t matched by the last pattern. (In particular, if the scrutinee is mutated during matching.) The cases I posted about are tantalizingly close to staying untouched by this PR. But I can’t think of a small variation on the existing approach that would fix them. Such cases seem infrequent &; in a codebase with many millions of lines of code, I only found four affected pattern matches where a The apparent infrequency (if you believe my claim) makes me wonder whether a new warning should be issued when pattern matching inserts a Do you have an opinion on a warning? (this would also reduce the concern I posted about earlier, where now a match can raise |
I don't understand, do you have an example? My intuition is that if a match is known to be Total, and the current submatrix has a single row, then a pattern that does not bind any variable can indeed be replaced by a wildcard. Instead of weakening Total into Partial under mutable positions we could weaken it into a new
My thinking:
To summarize: I agree that the pessimization will be observed very rarely in practice, and furthermore I think that when it does happen it will not degrade the program performance in practice. My intuition is that the proposed change is thus acceptable as a silent change. But if you want to be sure that performance-paranoid users have an easy way to evaluate the impact of the change on their codebase, I am happy to introduce a warning in this case, disabled by default. |
Example where my "replace with wildcard" suggestion produces dubious behavior
type 'a myref = { mutable mut : 'a }
type abc = A | B | C
type t = {a: bool; b: abc myref }
let example () =
let input = { a = true; b = { mut = A } } in
match input with
| {a = false; b = _} -> 1
| {a = _; b = { mut = B }} -> 2
| {a = _; b = _} when (input.b.mut <- B; false) -> 3
| {a = true; b = { mut = A }} -> 4
| {a = _; b = _} when (input.b.mut <- A; false) -> 5
| {a = true; b = { mut = C }} -> 6
;;
let example_with_wildcard () =
let input = { a = true; b = { mut = A } } in
match input with
| {a = false; b = _} -> 1
| {a = _; b = { mut = B }} -> 2
| {a = _; b = _} when (input.b.mut <- B; false) -> 3
| {a = true; b = { mut = A }} -> 4
| {a = _; b = _} when (input.b.mut <- A; false) -> 5
| _ -> 6
;;
let run f =
match f () with
| i -> string_of_int i
| exception Match_failure _ -> "raised Match_failure"
;;
let () = Printf.printf "example: %s\n" (run example)
let () = Printf.printf "example_with_wildcard: %s\n" (run example_with_wildcard)It produces the output: I also suspect this approach can be unsound if the final variable-less pattern introduces type equations, but I haven't tried to produce an example given that the previous one seems compelling enough. Clarifying my reason for suggesting for a warningThere are two reasons in my mind for suggesting a warning, but one reason seems much more important to me than the other:
I am more concerned about semantics than performance. Expert users might care about the performance overhead of extra checks, but less expert users will still care about avoiding partial match exceptions. Does that change how you feel about the warning? I would completely agree with your characterization if the warning was just about performance, but in light of the "semantics" reason, I feel differently about this point:
What kind of warning?I suspect this warning would be mostly straightforward to implement: "Match may be partial if scrutinee is mutated during the match." The criteria: pattern match compilation inserts a There are at least two problems with such a warning, in my view:
These problems make me feel wary of recommending a warning that is enabled by default. Fundamentally there is a mismatch between the type-checker's assumptions about pattern matching and the pattern match compiler's actual behavior. A warning would just paper over this difference. The present PR is a clear improvement over the previous unsoundness, so I'm inclined to say that that any work on the warning could be left until after we merge this PR. But I will wait to hear what you think about the warning, in light of the "semantics" reason I explain above (and also the problems I note). |
|
I agree that your dubious example is dubious. Indeed my thinking was that if a clause is taken because it is wildcardized, then we initially started with an input matching this clause before side-effects changed things. Your example demonstrates that this is not necessarily the case. I will add it to the testsuite right away. Regarding semantics: I don't disagree that suddenly raising Our intuition is that the segfault never occurs in practice, and that only a few programs are pessimized: that's infinitely more! Pessimizing very slightly some more programs is fine. But on the other hand, the warning is designed to pester their authors about a Fixing the code: In a few cases authors could tweak their code with guidance from a pattern-matching-compilation expert. Otherwise they can decide to catch My preference goes to adding the warning, disabled by default. The main value of the warning in my opinion is to let people that would worry about the compilation change understand what parts of their codebase are affected -- without the warning, it is fairly hard to do this analysis, you need to use a forked compiler over your codebase. |
|
Let me write down an idea I had in passing: instead of raising |
|
Adding the warning, disabled by default, sounds good to me. I see your argument that the performance change is what motivates the warning. I want to lodge one last argument in favor of mentioning the presence of the extra |
|
I started working on a warning, but this is proving more interesting than planned -- I can't get it to fire on a testsuite example which should, and it fires in the stdlib in places where it should not. |
|
Hi @gasche, there is something that puzzles me, for instance with Nick's first example: type 'a t =
{ mutable x : 'a
; mutable y: 'a
}
let both_some t =
match t with
| { x = Some x; y = Some y } -> Some { x; y }
| { x = None; y = _ } | { x = _; y = None } -> NoneIs there any reason for the mutable fields to be mutated? As there is no guard nor lazy pattern, do we assume that the mutable fields can be mutated by a concurrent thread, or something else? |
|
@maranget yes, in this case mutations would come from concurrent mutations. My general approach is to assume that any mutable field may be mutated "during" matching, so that two reads on the same field may always yield different results. |
0685413 to
fd1f73d
Compare
|
I implement and document a warning as discussed in #13341. That PR is on top of #13153, which removes the The reason for being on top of #13153 rather than on top of the current PR directly is that the natural/simple implementation of the warning is after the place where the |
fd1f73d to
ca82560
Compare
ncik-roberts
left a comment
There was a problem hiding this comment.
Thanks. The careful separation between arg_partial and partial seems correct and useful. I'm satisfied that this PR limits its scope to matches on transitively-mutable positions, and that the new warning will help keep interested users from being surprised about performance or semantics. (And, as I mentioned elsewhere, I've tested this change and found indeed that it flags only complex matches on mutable fields — I found only 3 instances in a codebase with millions of lines, and it was possible to rewrite them without much trouble.)
(no behavior change)
… default This un-does a small pessimization of ocaml#13076, which was analyzed in details in ocaml#13076 (comment), but at the time was believed to not affect any program in the wild. Since then Nick Roberts found instances of this pattern involving or-patterns and polymorphic variants. Thanks to the new presentation of partiality information that distingues the "global" and "current" information, the pessimization is now easy to undo, as done in this commit.
ca82560 to
106899d
Compare
lpw25
left a comment
There was a problem hiding this comment.
Approving on the basis of @ncik-roberts review
|
Thanks! Merging. |
This PR is part of the #7241 fix; it is the first un-merged PR in the stack and is ripe for review.)
#13138 introduces information on the "transitive mutability" of argument positions in pattern-matching submatrices: an argument is transitively mutable if it is located transitively under a mutable field (from the root of the value).
The present PR uses this information to pessimize the compilation of switches generated by the pattern-matching compiler. All switches that are in a transitively mutable position are assumed to be Partial, even if the type-checker says that they are Total. (If you want, there is a longer explanation as a code comment in the PR itself.)
This change fixes all known remaining instances of the issue describe in #7241 (unsound interaction between pattern-matching and mutation), in particular all known-wrong behaviors in the testsuite.
(TODO: this is missing a Changes entry.)
Degradation in generated code
The code generated by the pattern-matching compiler will be degraded if the following conditions are all met:
The position is transitively mutable.
At the position that we are generating a switch for, a language construction is used for which Total improves the quality of generated code. This can happen if either (A) only a strict subset of possible constructors is handled at in this submatrix, with the rest having been handled before, or (B) all valid constructors are handled by this submatrix, but this relies on checking that GADT equations are unsatisfiable, and the pattern-matching compiler does not know this. (On the other hand, the pattern-matching compiler does know about the set of constructors possible for a non-GADT sum type or for closed polymorphic variants.)
So the affected code looks like one of those:
In the case (A) (example
f), the behavior was previously unsound (in particular if we consider concurrent mutations). There are instances of (B) (exampleg) where the compiler was sound, and now generates slightly worse code -- the functiongabove is one such example -- by including a test with a Match_failure case.In a follow-up PR I implement a simple heuristic that re-optimizes the compilation of some GADT matchings in mutable position, so that the function
gabove is not pessimized anymore.