[swift-evolution] [REVIEW] SE-0193 - Cross-module inlining and specialization

Paul Cantrell cantrell at pobox.com
Fri Dec 22 12:56:11 CST 2017

> On Dec 22, 2017, at 1:29 AM, Slava Pestov <spestov at apple.com> wrote:
>> On Dec 21, 2017, at 12:42 PM, Paul Cantrell <cantrell at pobox.com <mailto:cantrell at pobox.com>> wrote:
>> 1. Presumably the portions of A inlined into B and C remain sensitive to the version-specific memory layout of A? Or will ABI stability mean that the compiler can magically rearrange memory offsets in already-compiled code when the layout changes? (Apologies if this is a too-obvious question; this part of Swift is all a mystery to me.)
> There is not really a notion of memory layout at the level of an entire module. For structs, classes and enums, you pretty much have the same concerns with both inlinable and non-inlinable functions — if the framework author can change the stored property layout of a struct or class (or adds a case to an enum), code that manipulates these data types must not make any compile-time assumptions that might be invalidated at runtime with a newer version of the framework.
> This is basically what the upcoming @fixedContents proposal for structs is about — giving framework authors a way to trade future flexibility for performance by allowing the compiler to make assumptions about the layout of a struct as it is written at compile-time. The @exhaustive proposal for enums has a similar implementation angle, but is of course more interesting because it affects the source language as well, with switch statements.
> We don’t plan on any kind of resilience opt-out for classes — already in shipping Swift compilers, accesses to stored properties of classes use accessor methods and not direct access across module boundaries.

Thanks, this is quite helpful.

My underlying concern here is that understanding even what kinds of breakage are _possible_ due to inlining currently requires fairly detailed knowledge of Swift’s guts, and even the best-intentioned among us are going to get it wrong. We’ll need tool help reasoning about it, not just documentation. At least I know _I_ will! (Actually, I’ll probably just avoid @inlinable altogether, but I’d certainly need tool help if I ever do use it.)

>> 2. Is there some class of statically identifiable breaking changes that the compiler does (or should) detect to flag incompatible inlined code? e.g. some version of A inlined into B references A.foo, then A.foo is deleted in a later version of A, so mixing older B with newer A in a project gives a compile- or link-time error?
> This is what an “ABI differ” tool would achieve, but like I said it has not yet been designed.

Yes. I would certainly use such a tool if it existed, and not just for dealing with @inlinable.

>> 3. Does this need some sort of poison pill feature for other sorts of breaking changes that are not statically detectable? e.g. invariants of a data structure in A change in release 2.0, so the author of A says “it is an error to include A ≥2.0 in any project that inlined any of my code from a version <2.0.” Is this what you were getting at with the mention of @inlinable(2.0) in the proposal? Sounded like that part was about something else, but I didn’t really grasp it tbh.
> This is an interesting point and I think it is outside of the scope of these proposals. If the ABI of a library changes in an incompatible manner and previous binaries are no longer compatible with it, you should think of it as shipping a *new* library, either by changing it’s name or bumping the major version number, so that the dynamic linker prevents the client binary from being run in the first place.

If the compiler/linker actively prohibits mixing of inlined code from different major version numbers, that eases my concern somewhat. A library author isn’t stuck with an ABI-sensitive mistake until the end of time.

>> Yes, frameworks+app built simultaneously are clearly the more common case. Though Carthage seems to be champing at the bit to create this problem, since it added a feature to download prebuilt binaries long before ABI stability! I can easily imagining this feature spreading via word of mouth as a “secret go faster switch,” and causing no end of problems in the wild.
> Perhaps, but I still think it is strictly better to formalize the feature through a proposal and document the pitfalls carefully — the underscored attribute is already spreading through word of mouth and in the absence of official documentation the potential for abuse is greater.

Fair point. Making this feature public & documented, albeit ill understood, is a safety improvement over undocumented & even iller-understood!

>> It might be safer — and better match the understanding of the typical user — to have @inlinable assume by default that an inlined version of any given method is only valid only for the specific version of the module it was inlined from. The compiler would by default flag any version mixing as an error, and require an explicit statement of compatibility intent for each piece of inlinable code to opt in to the danger zone of mixed versions.
> How would this be implemented?

Spitballing (and still out of my depth, so thanks for bearing with me!):

Each inlinable thing comes with a minimum version number for backwards compatibility:

    (syntax is just a placeholder here, could be Chris’s or whatever)

    // module A version 3.0
    @inlinable(≥ 3.0.0) func foo()  // introduced in 3.0

    // module A version 3.1
    @inlinable(≥ 3.0.0) func foo()  // we maintained compatibility this time

    // module A version 3.5.1
    @inlinable(≥ 3.4.0) func foo() // now dependent on something introduced in 3.4

(Aside: at first I thought this should be a range, but thinking it through, I’m not sure that an _upper_ bound serves any useful purpose.)

Any @inlinable declaration that does not explicitly state a backwards compatibility version in the source code defaults to the current version, i.e. Swift assumes breaking changes every time if the author hasn’t specified otherwise:

    @inlinable func foo()  // in source for version 3.5.2

    @inlinable(≥ 3.5.2) func foo()  // in ABI

Each module’s ABI specifies both the version number and compatibility version of each item inlined:

    // module B ABI

    requires module A
    inlined func A.foo() from 3.5.1, compatible with ≥ 3.4.0

Or perhaps it would be sufficient to emit only the maximum of the compatibility versions of all the items that were inlined:

    // module B ABI

    requires module A
    inlined some items from 3.5.1, compatible with ≥ 3.4.0

And then the key: at compile/link time, for every framework X, every version of X present — including both inlined versions and the version we’re actually building against — must satisfy the compatibility versions of all the items from X that were inlined by other frameworks.

In other words,

    min(versions of X present) ≥ max(inlined backward compatibility versions of X present).

• • •

This satisfies the three major scenarios I’m pondering:

(1) The stdlib and other similarly brave libraries can support long-term stability of inlined code.

(2) “I just build all my frameworks at the same time” users can use @inlinable safely, without shooting themselves in the foot when version mixing happens.

(3) A team of type #1 can safely make inline-breaking changes or correct mistakes.

• • •

An alternative would be a simpler, coarser-grained policy that says inline compatibility is either (1) same patch version, the default, or (2) same major version, i.e. the only choices are either “I’m not thinking about inline compatibility at all” or “I commit to using semantic versioning even for binary compatibility, not just source compatibility.” That approach smells funny to me, feels like it’s going to be clumsy in practice, but I could see the argument for it.



-------------- next part --------------
An HTML attachment was scrubbed...
URL: <https://lists.swift.org/pipermail/swift-evolution/attachments/20171222/1c6b3166/attachment.html>

More information about the swift-evolution mailing list