Thoughts on fixing a SIL verification error and/or bad codegen involving default arguments & implicitly-opened existentials

i happened to notice this recent bug report, which i believe is a duplicate of this older one regarding a confusing/broken interaction between implicitly opened existentials, inout and default (non-nil) parameters. the problem appears to be when you have a pattern like this, in which a parameter with a default value precedes an inout generic argument:

protocol P {}

func fn<Value: P>(a: Bool = false, b: inout Value) {}

when you try to call such a method by passing an implicitly opened existential and do not provide a value for the default parameter:

func example(_ input: any P) {
  var local = input
  fn(b: &local)
}

you get a SIL verification failure as the opened existential type is seemingly referenced by the default parameter initializer before it's actually been opened:

SIL verification failed: instruction isn't dominated by its operand: properlyDominates(valueI, I)
Verifying instruction:
     // function_ref default argument 0 of fn<A>(a:b:)
  %6 = function_ref @$s6output2fn1a1bySb_xztAA1PRzlFfA_ : $@convention(thin) <τ_0_0 where τ_0_0 : P> () -> Bool // user: %7
     %9 = open_existential_addr mutable_access %8 : $*any P to $*@opened("0AD2AB08-9C5E-11F0-963F-9BA727167DA4", any P) Self // users: %11, %11, %7
->   %7 = apply %6<@opened("0AD2AB08-9C5E-11F0-963F-9BA727167DA4", any P) Self>() : $@convention(thin) <τ_0_0 where τ_0_0 : P> () -> Bool // type-defs: %9; user: %11
     %11 = apply %10<@opened("0AD2AB08-9C5E-11F0-963F-9BA727167DA4", any P) Self>(%7, %9) : $@convention(thin) <τ_0_0 where τ_0_0 : P> (Bool, @inout τ_0_0) -> () // type-defs: %9
In function:
// example(_:)
// Isolation: unspecified
sil hidden [ossa] @$s6output7exampleyyAA1P_pF : $@convention(thin) (@in_guaranteed any P) -> () {
// %0 "input"                                     // users: %5, %1
bb0(%0 : $*any P):
  debug_value %0, let, name "input", argno 1, expr op_deref // id: %1
  %2 = alloc_box ${ var any P }, var, name "local" // users: %14, %3
  %3 = begin_borrow [lexical] [var_decl] %2       // users: %13, %4
  %4 = project_box %3, 0                          // users: %8, %5
  copy_addr %0 to [init] %4                       // id: %5
  // function_ref default argument 0 of fn<A>(a:b:)
  %6 = function_ref @$s6output2fn1a1bySb_xztAA1PRzlFfA_ : $@convention(thin) <τ_0_0 where τ_0_0 : P> () -> Bool // user: %7
  %7 = apply %6<@opened("0AD2AB08-9C5E-11F0-963F-9BA727167DA4", any P) Self>() : $@convention(thin) <τ_0_0 where τ_0_0 : P> () -> Bool // type-defs: %9; user: %11
  %8 = begin_access [modify] [unknown] %4         // users: %12, %9
  %9 = open_existential_addr mutable_access %8 to $*@opened("0AD2AB08-9C5E-11F0-963F-9BA727167DA4", any P) Self // users: %11, %11, %7
  // function_ref fn<A>(a:b:)
  %10 = function_ref @$s6output2fn1a1bySb_xztAA1PRzlF : $@convention(thin) <τ_0_0 where τ_0_0 : P> (Bool, @inout τ_0_0) -> () // user: %11
  %11 = apply %10<@opened("0AD2AB08-9C5E-11F0-963F-9BA727167DA4", any P) Self>(%7, %9) : $@convention(thin) <τ_0_0 where τ_0_0 : P> (Bool, @inout τ_0_0) -> () // type-defs: %9
  end_access %8                                   // id: %12
  end_borrow %3                                   // id: %13
  destroy_value %2                                // id: %14
  %15 = tuple ()                                  // user: %16
  return %15                                      // id: %16
} // end sil function '$s6output7exampleyyAA1P_pF'

my impression of the problem is this:

  1. default parameter initializers of generic functions have access to the function's generic parameters. in this case they receive the type of the opened existential value.
  2. default parameter initializer(s) must be evaluated in both 'left-to-right' order and resolved before their corresponding function is called.
  3. per ownership rules, the inout generic parameter requires that there be exclusive access to the opened existential value that spans the duration of the function call.
  4. due to... something, in the case that a default parameter initializer precedes the generic inout parameter, the generated SIL ends up somehow producing instructions such that the default parameter initializer has access to the opened existential value before it actually 'exists'.

the issue seems fairly sensitive to the particular combination of preconditions, and so there appear to be a number of workarounds, including:

  1. re-ordering the default parameters to occur after the inout generic (if the function signature is under developer control).
  2. providing explicit values for all parameters that precede the inout generic parameter.
  3. performing the implicit existential opening 'manually', and forward the opened value through to the original function.

however, having to do any of these things does feel a bit awkward, particularly with the custom TextOutputStream + print() examples from the bug reports (seems unfortunate that those cases don’t 'just work').

i'm wondering what possible solutions to this problem might be (reasonably) implemented. is this something that could be straightforwardly handled within SILGen? could the open existential instruction somehow be 'hoisted' such that it occurs before any default argument initializers? if a 'proper' solution isn't particularly tractable, should/could this case be diagnosed instead? given that this hasn't been fixed in multiple years, i'm assuming it's probably not as simple to address as i'm hoping (in which case i'd be interested to know why), but curious to know if anyone has any thoughts on the matter.

2 Likes

You’d be amazed at how many trivial bugs there are outstanding which haven’t been fixed in multiple years :)

In this case, the original proposal was underspecified. It should have addressed argument evaluation order in a more rigorous manner.

The reason inout accesses begin late is that we want this to work where foo() is mutating, without it being an exclusivity violation:

self.foo(self.x)

I think today when we evaluate an argument list, we make a pass to evaluate all ordinary arguments, and then we make a second pass where we evaluate default arguments and inouts.

Perhaps if we evaluate default arguments at the very end, after inout accesses have begun, we can fix this.

Technically that would break source compatibility if you had a side effect in your default argument expression or whatever, but I think it shouldn’t matter.

2 Likes

thanks!

what proposal would this have been?

indeed, when i tested locally, hacking in some logic to emit the default arguments after all inouts did seem to avoid the issue.

2 Likes

Cool, this sounds like it might the right fix then. Can you try different combinations of argument order, inout arguments, and mutating self as well?

1 Like

here's a draft PR with an additional 'delay' of already-delayed default arguments added in. it turns out the logic to support this behavior seems to already basically have been implemented for the purpose of handling isolated default arguments. since those have to be called in the correct isolation, they're collected rather than immediately emitted so that an executor hop can be inserted before they occur.

the implementation & new tests will need some further cleaning up, but the changes do so far seem to resolve the original issue without (yet) appearing to obviously break something else.

edit: jinxed it. CI is choking on something in swift-testing with these changes.


a couple tangential questions that occurred to me while poking around this particular area of the code that i'd be curious to learn more about, if you happen to know the answers:

  1. what actually causes an argument emission to be 'delayed' in the first place? originally i was thinking some introspection would be needed to determine if we were in the 'implicitly opened existential case', but the emitDelayedArguments() method seems to already handle those cases and changing it unconditionally did not obviously regress other things in the unit test suite (at least when testing locally).
  2. what's the distinction between 'site args' and 'delayed args' in this code? i haven't yet tried to sort out exactly what's going on... does it have something to do with handling calls with multiple 'levels' of arguments like fn(a: 1)(b: 2, c: 3)?

Perhaps something went wrong with that consuming parameter. I don't know how those are lowered here.

It's really just for default arguments and inout arguments. We have to delay the inout access until after all rvalue arguments are evaluated. I don't think there is any reason to delay default arguments, except for this opened existential case.

For rvalue arguments, existential opening actually happens before ordinary argument evaluation via yet another mechanism. Suppose you have something like

func f(_: Int, _: some P) {}

func g() -> Int {}
func h() -> any P {}

f(g(), h())

This basically lowers to the equivalent of this:

let x = h() // second argument is evaluated first
_openExistential(x, do: {
  xx in // type of xx is a type parameter now
  let y = g() // first argument is evaluated last
  g(y, xx)
})

In the AST this uses OpenExistentialExpr / OpaqueValueExpr instead of actually calling _openExistential, but the end result is that the expression being opened is evaluated before other arguments. (However, I believe that according to the rules of the proposal, this "delay everything that is not an open existential" step is not actually necessary, and after we sort out the other issues here, we could perform this opening in SILGen argument evaluation instead! Here's another fun idea. Try to write down all the combinations of orders of lvalue/rvalue open/not-open default/not-default arguments, and see how many different evaluation orders you can get. ;-) )

So the only problem arises when we're opening an existential that is an lvalue. In this case, we still form the OpenExistentialExpr as before, but SILGen doesn't actually open it until after it evaluates the arguments.

SILGen has some vestigial support for the old curried function call syntax that was removed in Swift 3. We still use it to represent the self parameter, so a method has type (Self) -> (Args...) -> Result in the AST, but in SIL it's (Args..., Self) -> Result. There are two "call sites" for a single "call", basically. But now it only ever comes up in this case.

Thank you for digging into this! Sorting out the details of fundamentals like call argument evaluation order is really important work.

2 Likes

i don't quite understand why default argument emissions necessarily need to be delayed. if they don't in practice depend on the function's generic type, could they in theory be emitted before any existential opening occurs? e.g. in the motivating print() case, the default arguments are Strings, and don't obviously depend on the generic type conforming to TextOutputStream. i do see in the SIL output that the default argument functions have the generic parameter of their corresponding 'parent' function somehow involved in their mangled name though, so i suspect i'm missing something about how those things are modeled that requires the generic type be involved.

this is kind of confusing, as it violates my assumption of left-to-right argument evaluation order. is that behavior necessary (or desirable)? also it seems a bit at-odds with the example from the implicitly opened existentials proposal regarding 'order of evaluation restrictions', which uses very similar sample code to justify banning the use of the implicitly opened generic type in any parameter that precedes the implicitly-opened existential. was that behavior introduced by this feature?

nit: i assume the final line of that... 'psuedo-SIL' should be f(y, xx)?

sorry i don't think i follow what you're envisioning/alluding to (but appreciate the enthusiasm!)... what does this mean more concretely?

Consider this example:

func f<T: P>(_: T, _: Int = { print(T.self); return 3 }())

It’s probably neither necessary or desirable. The OpenExistentialExpr was originally intended for opening self, which was the only thing you could do pre-5.7. The support for opening other arguments was added later and it reused the same mechanism for simplicity.

I’m suggesting opening existentials as part of ordinary argument evaluation instead of with an OpenExistentialExpr. This would simplify argument evaluation rules. This would require changes to the AST and type checker though.

1 Like

tangential to the primary discussion, but i think i'm probably still missing something because in that example the generic type seems like it is used within the default argument function (granted, as a side-effect). if T were not referenced within the default argument function, could it be treated as if it were a 'regular' rvalue expression (and therefore not be delayed)?

ah, i see, thank you for elaborating. certainly interested to hear more about how we might do this (could opening occur more than once when evaluating the arguments?), though it does somewhat deflate my hopes that there would be a 'simple' solution here (perhaps something along the lines of the original idea would still be an acceptable short-term approach?).


this discussion has made me wonder: is there actually a 'formal' answer to how Swift evaluates arguments? i've searched a bit but found no clear, definitive answer/documentation. the 'left-to-right' rule seems oft-cited, but clearly that's not the whole story. the fact that default arguments are evaluated after rvalues was surprising to me, despite nearly a decade of using the language... is even that behavior explicitly spelled out anywhere?

edit: heh, it seems my past self also wondered about this...

Yes, I don't think there is any other reason to delay default arguments except for opened existentials, but I don't remember the history here.

Sorry, I'm not suggesting changing the OpenExistentialExpr as part of your fix. I think your fix to split off default argument evaluation to happen after formal accesses begin is the right fix, independent of my aside about OpenExistentialExpr.

Cleaning up the OpenExistentialExpr handling of rvalue existentials would eliminate one of the delayed passes, though. Today, we do this:

  1. Open rvalue existentials
  2. Evaluate all other rvalue arguments
  3. Evaluate default arguments and begin inout accesses

WIth your fix, I believe it becomes:

  1. Open rvalue existentials
  2. Evaluate all other rvalue arguments
  3. Begin inout accesses
  4. Evaluate default arguments

What I'm saying is we could further simplify the rule down to just:

  1. Evaluate all rvalue arguments, including existential opening in order
  2. Begin inout accesses
  3. Evaluate default arguments

However, it's not necessary for your fix.

@hborla describes the argument evaluation rules in SE-0411, but it doesn't specifically address existential opening: swift-evolution/proposals/0411-isolated-default-values.md at main · swiftlang/swift-evolution · GitHub

Something based on this description with more details about existentials should probably go into TSPL.

1 Like

okay, now that i think i understand what's going on here a bit better, some more focused thoughts on the matter:

first, the CI issue that was hit with the draft implementation of a fix seems to have surfaced an existing bug with the current implementation that handles isolated default arguments (proposed solution to that here).

next, i assume the 'jumping the queue' behavior implicitly opened existentials currently exhibit is itself a bug, and filed that issue here: Implicitly opened existential parameter unexpectedly changes order of evaluation of function call arguments · Issue #84678 · swiftlang/swift · GitHub.

finally, in regards to actually fixing the motivating issue, a few questions remain in my mind...

  1. if we unconditionally push default argument emission until after all other forms of 'delayed arguments'[1], that would be a potentially observable change to existing programs since we would no longer necessarily evaluate both 'formal access' parameters and the default arguments in the same relative order. is that a problem?
  2. alternatively, we could try and detect this specific case more precisely and only apply the new logic conditionally. the downside of that would be increased complexity and a sort of strange variation of behaviors for no obvious reason.
  3. lastly, should the evaluation order be 'formal access args' and then default args, or the other way around? it seems like the ownership rules really want formal accesses to be the last things that happen (to minimize their extent), but also if we are to think of default args as sort of 'part of the callee's implementation', then it makes sense (to me, at least) to begin formal accesses before they're called. a perk of moving the default args all to the end would be that it unifies the logic that was added for isolated default arguments.

i really appreciate your taking the time to provide feedback Slava – this would make far less sense to me now without your help!


  1. which, a current accounting suggests are: default arguments, inouts, consumed/borrowed expressions, and various XYZToPointer expressions. ↩︎

1 Like

I would be very surprised if this broke anyone’s code. The only problem I’d want to carefully rule out is any potential for new exclusivity violations. (For example, we cannot “fix” this by evaluating formal accesses in the first pass with rvalues, because that breaks self.foo(self.bar)).

My response to this sort of overly-magic hackery is almost always “sounds like instant technical debt, not worth it”, to the point where my coworkers are sick of hearing it every time;-)

Doesn’t it have to be the first, because formal accesses might open existential types that appear in default arguments? The subtle point here is that with an lvalue, an OpenExistentialExpr does not immediately open the existential, it just produces an “open existential lvalue”, which is then opened when the formal access of that argument begins.

1 Like

the draft i put up does the simplest thing i could think of based on your suggestion, and just moves default arguments to the end of the 'delayed argument' processing logic in the argument emitter. this means they will occur after all inout accesses. with this approach, we can contrive an example that would produce a runtime exclusivity violation that previously did not:

nonisolated(unsafe)
var global = 0

func usedToWork(
  d: Int = global,
  io: inout Int
) {}

usedToWork(io: &global)
/*
Simultaneous accesses to 0x10404c000, but modification requires exclusive access.
Previous access (a modification) started at opened-ex-def-param`main + 56 (0x104044610).
Current access (a read) started at:
0    libswiftCore.dylib                 0x0000000193043f10 swift::runtime::AccessSet::insert(swift::runtime::Access*, void*, void*, swift::ExclusivityFlags) + 436
1    libswiftCore.dylib                 0x000000019304412c swift_beginAccess + 84
2    opened-ex-def-param                0x0000000104044634 default argument 0 of usedToWork(d:io:) + 52
3    opened-ex-def-param                0x00000001040445d8 main + 60
4    dyld                               0x000000018118d3dc start + 6076
Fatal access conflict detected.
*/

currently this code emits the default argument before the inout argument, so doesn't violate exclusivity rules. with the proposed change the order is flipped, and so there's an exclusivity violation when evaluating the default argument (at runtime... not sure why it's not seen statically). i suspect we could probably find a similar issue with the current implementation too if you use isolated default args and inout parameters.

i think a 'simple' fix might basically require this, but given your previous idea that opening could perhaps be refactored to occur as part of argument emission, i was thinking maybe the lvalue case could be detected such that the opened type is accessible to the default argument generators too, but the formal access of the underlying value is still pushed to be as 'late' as possible (i may still be misunderstanding some things here though). IMO there is a sort of philosophical question here regarding the nature of the default argument generators... if they are 'part of the function body' then starting formal access before they're executed seems consistent. if they're not, then presumably we'd reduce the potential for exclusivity violations by evaluating them before the formal parameter accesses occur.

1 Like

Just chiming in to say that the user-facing model articulated before is that default args are part of the caller’s (not callee’s) implementation.

thanks for highlighting this. i found a past post of yours on this subject, but wondering if you happen to know of any other references on the topic that may offer the 'right mental model' in this domain (or its history/evolution/etc. i'm currently trawling through these search results). as an implementation matter, at the moment i have the sense that things are somewhat blurrier – for instance, macro expression default arguments seem 'more caller side' than non-macro default arguments.

going to digress a bit from the original topic with some meandering thoughts, primarily to try and clarify my thinking and document various issues here...


as Holly covered in SE-411, and with the additional points Slava noted earlier, the current evaluation order is (each category evaluates left-to-right):

no isolated default parameters:

  1. rvalue opened existential args
  2. explicit rvalue args
  3. default args & formal access args

isolated default parameters:

  1. rvalue opened existential args
  2. explicit rvalue args
  3. formal access args
  4. default args

when existential opening comes into play, the issues it can cause of which i'm currently aware are:

  1. opening an rvalue existential can change the 'normal' evaluation order of rvalue arguments (Slava pointed this out above)
  2. opening an lvalue existential crashes the compiler if default arguments precede it (the motivation for this thread)

there seem to be a number of competing concerns involved in ordering the evaluation of call arguments:

  1. argument evaluation should generally occur left-to-right
    • this aligns with most programmers' model of how call argument evaluation order works, and has been stated in various places as how Swift behaves
  2. formal accesses should generally occur 'late'
    • minimizing the extent of formal access reduces 'spurious' access violations
    • allows patterns like self.foo(self.x) where foo() is mutating to work
    • various desired behaviors involving opened existentials have put pressure on the language to work this way (see here, here)
  3. default argument evaluations should occur after rvalue evaluations
    • for reasons John cited here
  4. isolated default argument evaluations may require an executor hop
    • i think this incentivizes evaluating them later than usual to keep the hop 'close' to where the ultimate method invocation will happen
  5. opening existentials should 'just work' and be transparent to users to the extent possible
    • existential opening must occur before processing default arguments as the opened type is provided to the default arguments' generic context (someone fact check me on this?). this is why the compiler currently crashes in the motivating issue for this thread.

some issues with the current behavior:

formal accesses

it seems... confusing and somewhat irritating that you can get dynamic exclusivity violations when evaluating default arguments that go away if you 'manually inline' the argument generator implementation:

var i = 0
func orderMatters(_: inout Int, d: Int = i) {}

orderMatters(&i) // đź’Ą runtime exclusivity violation
orderMatters(&i, d: i) // âś…

this sort of thing makes me feel that the formal access phase should maybe just be its own special thing that happens as late as possible, but it's not clear if that would break some desired language semantics, or even makes sense as a proposition. this quote from the 'ownership manifesto' makes me wonder if the existing interaction with default arguments is correct/desirable:

The evaluation of a storage reference expression is divided into two phases: it is first formally evaluated to a storage reference, and then a formal access to that storage reference occurs for some duration. The two phases are often evaluated in immediate succession, but they can be separated in complex cases, such as when an inout argument is not the last argument to a call. The purpose of this phase division is to minimize the duration of the formal access while still preserving, to the greatest extent possible, Swift's left-to-right evaluation rules.

is this justification that we should be postponing formal accesses until after default argument evaluation entirely?

isolated defaults

while i'm not sure whether it comes up much in practice, it seems kind of confusing to have different evaluation orders of inouts & default arguments depending on whether or not there's a default argument present.

opened existentials

breaking left-to-right evaluation order when opening an rvalue existential parameter is confusing.


i'm not sure all of this actually makes sense, but my current intuition about how the ordering perhaps should work is (all sections left-to-right):

  1. evaluate non-default arguments
    • open existentials as they occur (waves hands)
    • perform first evaluation phase of lvalues requiring formal access as they occur (only resolve the 'storage reference', do not begin formal access)
  2. evaluate 'caller side' default arguments
    • are these just expression macros currently?
  3. evaluate 'callee side' default arguments
    • perform an executor hop first if needed for isolated default args
  4. begin formal accesses

i think there's an argument that default args should be processed left-to-right in declaration order as well but historically they haven't been for reasons alluded to earlier in the thread, and the isolated use case sort of pushes toward handling them separately anyway. given the constraints, overall it seems it may be simpler to treat them distinctly as a group, and processed separately from non-default arguments.


returning to the motivating issue... i'm a bit less sanguine about moving default arguments unconditionally, since it seems that could introduce exclusivity violations that didn't previously occur (and they may not show up until runtime). if that still seems like a plausible path forward, i can try to clean up the draft, but maybe we'd need to be somewhat more targeted about when such logic could be applied (cases in which we'd previously just fail to compile), or aim for a more holistic solution that ensures the opened existential types are somehow made available after the rvalue evaluation phase.

2 Likes

Yeah, for sure I learned this from others in the forums (or its predecessor mailing list), but I too am having trouble locating a nice succinct summary. However, this particular thread is a nice bit of history re implementation as of a decade ago as well as the rationale:

1 Like

The LSG talked about this today, and we think there's a sufficient lack of clarity (especially in the community) about exactly what the argument evaluation order is, what's changing, and what that means for programmers that we'd like to see a written proposal. It doesn't need to be a terribly in-depth document, and it can mostly focus on the potential changes in observable program behavior.

Ideally, I think we'd get back to essentially what Holly wrote in SE-0411:

  1. Left-to-right formal evaluation of explicit arguments
  2. Left-to-right initiation of formal accesses in explicit arguments
  3. Hop to the callee's isolation domain (if needed)
  4. Left-to-right evaluation of default arguments

Note that we're pretty sure that there doesn't need to be any sort of special case for opened existentials, and the separate phase of evaluation for opened existential r-values is unnecessary. This is because there's supposed to be a general restriction that type information cannot flow from an opened existential to another explicit argument. If that were not true, there would be a lot of other problems with this evaluation order.

This could lead to two observable changes from the current behavior:

  • An inout argument might cause an exclusivity conflict with an earlier default argument that it didn't before.
  • The relative order of evaluation of opened existential r-values would change.
    I'm not too worried about either of them; the opened existential r-value order issue feels more like a bug than something anybody should be relying on, and Swift's default argument model does not make side-effectful default arguments very useful.

The only true special case that this would leave us with in the argument evaluation ordering is that optional chains in the self argument that cover the method call may require that formal access to happen before any other formal evaluation.

3 Likes

This would require a slightly non-trivial implementation change.

Today we open existentials with OpenExistentialExpr, which evaluates the existential expression before binding the result to an OpaqueValueExpr within the body. This is why we get the funky argument evaluation order, because OpenExistentialExpr was originally intended for self.foo() calls only, and it was sort of re-used for swift-evolution/proposals/0352-implicit-open-existentials.md at main · swiftlang/swift-evolution · GitHub.

So to implement the behavior you describe (which I'm fully in favor of, to be clear), SILGen would either need to special-case the lowering of OpenExistentialExpr to delay evaluating the existential until its first (and only, I believe) use within the body, or we would need to change the AST representation somehow to make this more explicit. The former is already how lvalue open existentials work, so maybe it's a bit simpler overall to extend this to the rvalue case, instead of inventing a new bit of AST.

Which one makes more sense?

Oh, we put the entire call inside an OpenExistentialExpr? I can see why, but yeah, that's pretty unfortunate.

I don't think there's any truly elegant AST design here, so I don't hate the idea of just putting the OEE in the argument position. But just applying the same special case to r-values works too.