Erroneous "immutable value was never used" warning

Right, I just meant that if that strategy is used just to silence the warning, it has to be placed at the end of the function—I agree that withExtendedLifetime is the better approach!

1 Like

Ah, sorry. Apparently my brain slightly blanked out when reading your comment :p

1 Like

No, it will silence the warning regardless of where it is placed.

Furthermore, it will not guarantee the lifetime of c, regardless of where it is placed. The compiler is free to rearrange the lines “_ = c” and “d.call()”, because semantically they do not affect one another.

Ah, I elided the rest of the thought which was "...in order to fix the underlying issue that my code was exhibiting".

Is this documented somewhere? That seems somewhat circular—clearly they do semantically affect one another, since if c is used after d.call(), the program will print "blah" and if c is not used after d.call() the program may print nothing at all. The order in which the loads and stores actually occur (or not) post-optimization is irrelevant to whether the lifetime of c outlasts d.call().

The closest you have is threads with Swift developers in which they said, in effect, that when an object is deinited is not considered to be observable behaviour from the perspective of the optimising compiler. That is, the compiler is not required to enforce that optimisations do not change the order in which things happen with respect to deinits being invoked.

The rules here may not be obvious but they are clear once you know them: you must not rely on deinit being called at a specific time. It may be called later than you expect, or earlier, or not at all. The only ordering rule that applies to deinit is that deinit of an object will always happen before deinit of an object it holds. That’s it.

2 Likes

Well, there’s also the “deinit will not be called before the last use,” rule, which makes the question here subtly different than just the ordering of deinit. Specifically, is the ordering of last use not considered observable behavior? I.e. is c not guaranteed to live at least as long as d if, in source order, c is used after d?

Not necessary. If the usage does not require that the object is actually alive, then it's acceptable for that object to die early. For example, if the "last use" of an object is to assign it into an array that is never read from. Arrays that are never read from can just as easily not exist (they don't have any effects that count from the "as-if" rule perspective), so they are free to be optimised out. If they are optimised out, then the store is now meaningless (again, the "as-if" rule indicates that we couldn't observe that the object was stored in the array: if we could, it wouldn't have been legal to optimise the array out of existence). Thus, from the perspective of when to call release, the store to the array is completely irrelevant: it can be treated as not existing.

So...no, this is not a sufficient guarantee.

I'm not convinced that this follows logically from the previous lines (though it may very well be the model Swift uses)—is this also based on authoritative statements you've seen here? It would also be a perfectly reasonable model for the store to be eliminated while leaving the retain/release ordering according to source order.

(Not intending to come off as overly contradictory here, I'm just interested in developing the most accurate model I can since this has bitten me a couple times now!)

I believe the rules are as follows:

  1. a reference is guaranteed to be alive while at least one strong reference remains.
  2. references are considered dead at the point the compiler can prove they are no longer accessed.

Thus in the original example, the local reference c is dead right after the line which declares it. It is provable by inspection that test() does not access the reference after that point.

Nope, but it's based on observational evidence combined with the logical application of previous facts.

Here's what we know:

  1. People want Swift to be able to eliminate dead code.

    This is a reasonable inference, but it seems like users of Swift would like the following optimisation to be possible, to have the following:

    func t() -> Int {
        let x = [1, 2, 3, 4]
        return x.reduce(0, +)
    }
    

    be optimised to what amounts to:

    func t() -> Int { 10 }
    

    Swift is capable of this optimisation today.

  2. We know that Array is a class-backed-struct. That is, it's defined approximately like this:

    public struct Array {
        private var backingStorage: BackingStorage
    
        class BackingStorage { }
    }
    

    This means that creating an Array must allocate storage for that backing class. The backing class is also where the elements go.

    It also means that passing an Array around incurs reference counting traffic. You can see this by considering this code:

    class F {
        var arr: [Int]?
    }
    

    As shown in Godbolt, the getter for arr emits a call to swift_retain. What is it retaining? The answer is, the backing storage for the array.

    I point this out to indicate that creating an Array implicitly creates an object with managed lifetime. That backing object essentially has a deinit. This will be relevant.

  3. Returning to the code from part (1), we notice that the optimiser has not just improved the math, but it has eliminated the array entirely. The array no longer exists in this code. That's meaningful, because it means the Array backing storage doesn't exist either.

  4. For the Array backing storage to not exist, the compiler must have eliminated a release. This would have been the release that caused the array backing storage to vanish. Of course, as the array backing storage never came into existence, it never needs to vanish. Thus, we can conclude a new rule, derived logically from the above: malloc/free/retain/release counts are not part of the observable behaviour of a Swift program. It is acceptable to change them. If this were not true, we could not avoid constructing the Array in our sample program, and we'd like to avoid it because it makes the code way faster.

  5. So what, you say? You didn't ask about whether the retain/releases could be removed, only if their order persisted! Well, there are two follow-on points.

    The first is to consider a case where one object is eliminatable, but the other is not. Let's update our code:

    func test() -> (Int, Int?) {
        let x = [1, 2, 3, 4]
        let y = [5, 6, 7, 8]
        let second = y.randomElement()
        let first = x.reduce(0, +)
        return (first, second)
    }
    

    This new program has the original test program contained within it, but now creates a different array and calls randomElement on it. To call randomElement we must actually have an Array: this function is complex enough to require the real object.

    That real object has an unambiguous last usage site: the line let second = y.randomElement(). That unambiguous last usage site unambiguously precedes the equally unambiguous last usage site of array x, which is the line after.

    This now represents a problem. The rule we're investigating is "can an optimizing compiler change the ordering of releases". In this context, we need y to be released before x, as that's the source order of the last usage sites. However, we also want x to be eliminated entirely! It should have no releases. What to do?

    If we want to have the rule "x must be released after y", that rule must require that x actually be allocated. It's the only way to satisfy the rule. We could have a more nuanced rule, "x must be released after y unless x can be proven not to be needed at all", but that's a weirdly specific rule, and very hard for a compiler to actually implement.

    So, what happens? Godbolt to the rescue again: we see clearly that both a) array y is created, retained, and released; and b), array x still does not exist.

    This is incompatible with the clear "retain/release ordering follows source order". It could be compatible with the less clear rule involving complete compiler elimination of the object, but that rule is, as noted above, not really plausible for a compiler to implement.

Where does this leave us?

Well, we can see that not only can Swift say that the "last usage site" of an object is well before its last usage site in the code, we can see that Swift can say that an object has no usage sites at all! It can even do this in cases where the object is clearly used: reduce is implemented on top of a bunch of Array machinery, but the Swift compiler can eliminate all of it.

We can also see that we want Swift to be able to do this. It makes our programs much faster! Not allocating an intermediate Array here is great: we can spell things clearly to signal intent, but Swift can give us the behaviour we want quickly.

But if Swift can say that objects have no usage sites, it follows that Swift can also say that objects that appear to exist in the source will never have their deinit called. They never existed at all. Thus, we can conclude that having deinit called cannot possibly be an observable behaviour from the perspective of the Swift compiler.

If having deinit called at all is not observable, it cannot be that deinits must be called in source order. If that was the rule, we'd defeat these optimisations that involve eliminating objects entirely. (As a side note, we'd also eliminate a whole class of cases where CoW's can be elided, forcing way more memory traffic than we currently see.)

Here is my mental model of deinit, which so far as I can tell is all that is promised:

  1. deinit will never be called more times than init
  2. Each object will have deinit called no more than once
  3. deinit will not be called while necessary strong references still exist (that is, where the strong reference cannot be eliminated without violating a different language invariant).
  4. If object A holds a strong reference to object B, the deinit of object B cannot be called before the deinit of object A. Note that if A has been eliminated entirely, the strong reference never existed and so this rule does not apply.

That's it. Note that these rules do not include:

  1. deinit will be called. Swift does not promise this! deinit may never be called.
  2. deinit will be called at the end of function scope. As we've seen before, deinit may be called well before that.
  3. deinit will be called once per class object created. As we see above, Swift is free to eliminate the class instance altogether.
  4. deinit will be called in a timely fashion. deinit may be arbitrarily delayed if Swift choses to do so.
  5. deinit will be called after the last appearance of a strong reference in source. Swift is capable of shortening the lifetime of strong references if it can prove they are unneeded.
10 Likes

Thanks for the comprehensive write-up @lukasa. Super helpful!

It's not actually immediately apparent to me why this would be difficult to implement (despite the fact that it would maybe eliminate certain optimization opportunities). The machinery for eliminating allocation entirely is obviously already available, and "last use" ordering for local variables is plainly apparent from the source. What additional complexity am I missing?

Do you know how the reference count/swift_release fit in here? If swift_release is called and decrements the refcount to 0, is deinit guaranteed to be called at that point (i.e., are these rules more accurately about the placement of calls to swift_release)?

I'm not a compiler engineer, so I'm working with one hand tied behind my back here. But my understanding is that the algorithm required to implement a conditional rule like this is a lot more complex. In particular, it requires keeping track of a lot more state.

It can also inhibit dead-code elimination. To eliminate the array in current Swift it's sufficient for the compiler to observe that the array does not invoke any code that leads to an observable effect other than the math, and so it can simply hoist the math out and remove the array. If retain/release ordering were considered an observable effect, the dead-code elimination pass can't do this anymore: there exists a swift_release for this array, so the array cannot be eliminated.

This means we need a compiler pass that looks for things that could have been dead-code eliminated but weren't, and then remove their retain/releases so that dead-code elimination can be unblocked. This makes optimisation pipelines longer, compiles slower, and risks us failing to achieve dead-code elimination in some cases where there weren't enough passes.

I wouldn't dismiss the optimisation penalties either. Any time Swift calls out to code it cannot see (e.g. that comes from a library or framework) it cannot know what happens in that code. That means it has to assume that that code may do anything, including call swift_release. As our rule ("releases occur in source code order") does not exempt function calls to other code, we can never move a release earlier than the last opaque function call in a function body. That's a pretty serious barrier: in practice, it'll cause us to lose many optimisation opportunities.

2 Likes

Yes, that's correct. The rules are not actually about deinit but about calls to swift_release.

I can't promise that deinit is guaranteed to be called: Swift does not have a formal language specification, so that kind of promise is meaningless. However, in practice, I don't believe swiftc will ever diverge from that behaviour.

2 Likes

Ah, in my imagined model the dead-code pass would not be affected—it would just be the ARC pass's responsibility to properly order any swift_release calls for the objects that survive the dead-code pass.

The rule I'm imagining is closer to "swift_release will not be called on an object at least until the site of last use (according to source order)". So if foo(x: AnyObject) were an opaque function call in the following snippet.

class C {}

func bar() {
    let c1 = C()
    let c2 = C()
    let c3 = C()
    foo(c2)
    foo(c1)
    _ = c3
}

We would have that c3 cannot be released before c1, and c1 cannot be released before c2. However, the compiler is free to a) eliminate c3 entirely (since swift_release is never called, the rule is not violated), and b) release c2 before the call to foo(c1) (since any uses of any references inside of foo occur after the last use of c2). Anyway, my speculation about a hypothetical rule isn't particularly productive :slightly_smiling_face:

Ultimately, I'm less concerned about late-release than I am about early-release, but I suppose it all comes back to "if you need to guarantee the lifetime then just use withExtendedLifetime." For some reason reaching out for a library function is just not that satisfying to me—I wish we had something like objc_precise_lifetime in Swift.

Thank you for taking all the time to write these extensive responses! Will be very helpful to refer back to when I inevitably hit an issue like this again in a few months :wink:

Right, but the dead-code pass can't eliminate those objects because there are calls to swift_release. This is a common problem in compilers, which is why there usually isn't just one dead-code pass, but several: optimisations enable each other.

I'm sympathetic to this position, but in practice I think it's best to try to train yourself out of reliance on object lifetimes. Where possible, avoid code that is brittle in the face of deallocations moving around. This means trying not to put logic in your own deinits.

This is part of why SwiftNIO has so many objects with explicit start and stop/shutdown/close functions: we don't want these lifetime bugs to manifest. If you need to call close to close one of our resources, you'll automatically keep it alive (how else could you call close? :wink:).

Sometimes you'll use code you don't own that has logic in its own deinits. In those cases, your best bet is, if you need something to live within a single function scope, to use withExtendedLifetime to ensure it does. However, if you only need to tie one lifetime to another, you can store the object in a stored property on the object whose lifetime you want to tie it to. In practice, Swift will never release these objects before the ones holding them.

1 Like

The main area where I've had problems with this model is in test code, where I'll create local variables containing objects that, during normal execution of the program, are owned by other objects and/or have singleton instances. When I have a full object graph, the dependency rules regarding strongly-referenced properties make things easier to reason about. I guess I'll just have to get into the habit of using withExtendedLifetime more liberally in my tests!

As it stands today, the dead-code pass must either run before or after ARC inserts retain/release pairs. If it runs before, nothing would change if we modified the order in which ARC inserts said pairs. If it runs after, then it's clearly able to cope with eliminations in the face of existing calls to swift_release. It not clear to me why changing the order of the calls to swift_release would inhibit the ability of the compiler to identify dead code.

IMO, this begs the question. Of course we shouldn't rely on precise lifetimes in Swift, since they don't exist! :slightly_smiling_face:

I read this and think, "What a great use-case for more-explicit lifetime control!" The fact that libraries have to build solutions around the non-deterministic1 nature of lifetimes in Swift should be motivation for surfacing that control more explicitly. E.g., what if there were a type-level attribute that could be applied to enforce precise lifetimes? Then API authors could actually prevent some classes of improper use by auto-close-ing whenever the lifetime ends.

I'm reminded of @Hoon_H's post from a couple weeks ago where they ran into a similar issue with Combine. I'm similarly unsettled by the unpredictable-by-default nature of Swift object lifetimes every time it crops up.

1: ETA: is "non-deterministic" the right word here? I would hope that the ordering of calls to deinits remains consistent from one run of a program to the next (though I'm not sure how the runtime is implemented).

Is it really unpredictable? As explained, the compiler is free to optimize away or release a variable after its last concrete use. For something like Combine, this is easy to manage, as every subscription is maintained by an AnyCancellable value, and you just need to keep those alive for as long as you need the observation. In my case I usually subclass XCTest case with API to store the tokens for me during test methods. For other tests, referencing the value in your assertions works fine (or referencing something that keeps the other value alive), or, similar to the Combine case, special storage for values you need to keep alive.

As an alternative to guaranteed lifetimes, it might be interesting to consider an annotation which would cause the compiler to warn about immediate release, similar to how the warning for weak variables works. That way the developer can't just shoot themselves in the foot by using _ = to stop a warning.

By unpredictable-by-default I mean that (AFAICT) the only ways for me to effectively reason about lifetimes is to a) store a reference in an object whose lifetime is otherwise known, which just moves the reasoning one level up, b) use withExtendedLifetime, or c) introduce a series of known dependences via print or other extremely obviously "observable-effect" calls to force lifetime extension. It's not even obvious to me that referencing the values in assertions is sufficient—if the compiler can prove that the reference is not side-effective and that the condition is false, could the call be eliminated altogether?

IMO, it's not a great solution to have to guess what optimizations the compiler will perform in order to reason about lifetimes. It's unpredictable in the sense that it's impossible to give definitive answer to the question "what does the program in the original post print when run?". The word "concrete" in your post is hiding a lot of complexity.

This is why my recommendation remains to stop trying to reason about lifetimes. The answer to "what does this program print" should not depend on the lifetime of any one object.

The less you reason about lifetimes, the easier time you'll have of things.

Sure, there is scope for interesting work in this area. However, I think move-only types are the most immediately-promising solution to this problem. Move-only types would have much clearer ownership semantics because they are always singly-owned. This property makes reasoning about their lifetime much clearer, and therefore makes it much more valuable to clarify the remaining uncertainty in lifetime.

This is also the model adopted by Rust: move-only types in combination with RAII.

2 Likes
Terms of Service

Privacy Policy

Cookie Policy