SE-0359: Build-Time Constant Values

If I understand correctly, the current feature (and its implementation) do not change the "physical" ABI for variables declared @const; such variables are accessed by calling a function that returns the value, i.e. with a getter. At most, @const becomes a semantic guarantee that the function has no significant side effects and always returns semantically-equivalent values, which we could certainly use during optimization by e.g. coalescing redundant calls or removing unused ones. However, it would still be necessary to destroy the returned value (if it isn't trivial), because the return values wouldn't necessarily be permanently allocated, which would complicate that optimization.

This is clearly not the optimal ABI for accessing constant variables, which would be either:

  1. to make the variable simply resolve to an address known to be initialized prior to the access, or if not that,
  2. to call an "addressor" function which returns a consistent address of an immutable value.

In the first option, a @const global variable would define a symbol that simply resolves to the address of an immutable object known to be initialized at load time, and a @const protocol requirement would cause the protocol witness table to contain a pointer to an immutable object initialized either at load time or, at worst, during the initialization of the witness table (e.g. if the conformance were generic and the value was dependent on generic parameters). In the second option, a @const global variable would define a global function symbol for the addressor, and a @const protocol requirement would cause the protocol witness table to include an entry for an addressor (rather than for a getter, as it would today). The addressor would then either just return the address of an immutable object, if one can be emitted statically, or else memoize the allocation and initialization of that object.

The first of these is clearly more efficient for accesses. It would also allow the value to be fairly easily recovered by binary analysis (as opposed to either source analysis or running code — all of these options have their own trade-offs for different applications). However, it would require the memory to be eagerly initialized before access, which in the most general case would require load-time execution in order to compute type layouts and produce unique metadata. To avoid this and guarantee that the initialization could be done "statically" (which is to say, within the limitations of what common program loaders can do automatically without running any code from the loaded image), the following restrictions would be required:

  • Initialization would have to be resolved to a fully concrete initializer value. This means that all of the semantics of initialization for the initializing expression would have to be known to the variable's defining module and, furthermore, be constant-evaluable. Among other things, this would imply that all the types involved are frozen (or defined in the module), all the initializers are inlinable (or defined in the module), etc. We seem to want this restriction regardless, and in the initial proposal the restrictions are much stricter than this and exclude all user-defined types; I mention it only for clarity.

  • The internal layout of every component value in the initializer value would have to be known statically. This is almost implied by the restriction above, since resilient types cannot have inlinable initializers. However, the internal layout of an enum includes direct cases that aren't necessarily part of the value and therefore do not need to be initialized; if such a case included a non-frozen type, this would preclude the direct-address implementation because the internal layout of the enum would not be known, even if that case were not chosen for the actual initializing value. Again, I believe this is implied by the current restrictions in the proposal, but it should be noted for future directions.

  • Any class instance or metatype value appearing in the initializing value would have to fall into one of the cases where Swift's type metadata system can guarantee complete emission at compile time. (This is an implementation detail we haven't previously needed to expose to programmers.) For example, if the initializing value includes an instance of a generic class MyClass<MyX>, both MyClass and the concrete generic argument MyX would be heavily restricted. Some of the conditions for this overlap with the restrictions above, but not all of them. I believe this restriction probably wouldn't extend to indirect enum cases. Once more, I believe this is implied by the current restrictions in the proposal, unless perhaps we need unique metadata for array and dictionary buffers.

These restrictions would not be necessary with the "addressor" approach, which allows the variable to be emitted lazily at the cost of making accesses somewhat more expensive. But someone might say that achieving the direct-address implementation would be desirable enough to design these extra restrictions in. I'm not sure I would agree, but it's not completely unreasonable.

The most important thing here is that, if we want to be able to use either of these better ABIs for @const variables, we do actually need to do that ABI work in the first release. Otherwise, @const alone won't be enough, and we'll need to introduce a new attribute in the future which actually requests the new ABI treatment. That seems to me like it would partially undermine the story laid out in this proposal for how the proposed attribute will be gradually generalized to address more and more constant-evaluation needs. @const would enable some semantic optimization and source-tool analysis, but we'd be fundamentally limited by these early ABI decisions about what low-level features we could build on top of it. For example, we would not be able to say that a pointer to a @const variable has global lifetime.

If we want the direct-address ABI specifically, then as part of that ABI work, we will also need to ensure that we can actually emit String, Array, and Dictionary literals statically, since those are the only complex types allowed by the current proposal. The optimizer would then presumably be free to rely on a guarantee that any allocated objects in the immutable object are in fact permanently allocated and have trivial reference counting. This work could be elided if we just use addressors, although of course the optimizer would then lose its ability to rely on permanent allocation. But permanent allocation might not actually be feasible in the long run if we hope to include general class types in the set of things that can be constant-emitted, since a class instance stored in a @const generic variable would semantically need to be unique for a set of generic arguments.

15 Likes

Is this essentially talking about the cost of the call to swift_once?

Because in all the benchmarking I've done, swift_once is always negligible. Like, 0.0%.

Currently, there is also a lot of setup code for globals in main. I've never understood why we have that setup code and have thread-safe lazy initialization. Why both?

1 Like

It would be the dynamic cost of swift_once plus the code-size and optimization-barrier costs of doing the setup for it, yes.

I’m not aware of any eager setup costs for normal globals that would go into main. But globals in script files are strange and have their own, not always sound rules.

3 Likes

Regarding the "Protocol @const property" aspect of the proposal, I'm struggling to see the motivation.

If the intent is to enforce that the property's value is "static" (eg, make sure the database keys or printf-string are not runtime-able values), then that should be expressed in the type, as you'd want to propagate this quality through function calls, even if you select among values at runtime (flag ? key1 : key2). Expressing frozen/static typing is interesting and useful independent of this proposal.

If the intent is to enforce that the value doesn't change for an instance/class (eg, the property will be used as a key in a dictionary or an input to a hash that should be stable), then that is an interesting direction! But I would propose that we should implement that independent of any "build-time-ness" requirement as it's more generally useful.

For example, the proposal offers an example:

protocol NeedsConstGreeting {
    @const static let greeting: String
}

It's not clear what @const actually adds here. Wouldn't just plain static let greeting: String express the interesting part? You could even use StaticString to cover the other axis too.

I suggest the authors expand the proposal with a more concrete example that actually relies on the compiler doing something interesting at build time for the protocol property use-case.

9 Likes

I assume there's something which will be announced in two weeks which relies on this feature? If so, the timing of this review is really awkward. If not, then I really struggle to see the point. The examples of uses for it are extremely underwhelming and very much look like a solution in search of a problem. "The compiler might find a way to optimize based on this" is the sort of thing that makes me think that we'll have a @const2 in the future with slightly different semantics that the compiler actually can use for optimizations.

10 Likes

The rules around this feature seem to resemble a hypothetical ‘pure’ function annotation, which would denote functions free of side-effects.

I'd like to see the proposal authors consider the interplay with pure functions as a future direction. It seems there's an opportunity for calls to pure functions with only const parameters, to be a valid const expression itself, if we do this right.

9 Likes

For me the logical use cases of build time constant values is relevant when using perfomance sensitive data structs, for example a Vector or Matrix having a compile time known size lets the compiler un-role loops for numerical operations on these data types, the compile could even auto vectorise expressions using such data types leading to massive perf improvements.

From the "Supported Types" section:

[...] The current scope of the proposal includes:

[...]
• Array and Dictionary literals consisting of literal values of above types.
•Tuple literals consisting of the above list items.

This list excludes tuple literals inside array/dictionary literals, and nested collections. Is that simply a miswording, an oversight, or an intentional limitation? If the latter, could it be explained?

Additionally

Enum cases with no associated values

(Emphasis added.) Why is const nesting not permitted where the associated value types are on the Supported Types list?

3 Likes

As a newcomer to Swift, but with two decades of programming in other languages I was a bit blown away by the abundance of keywords, especially those that are in my opinion more stand in the way than help. “Guard” version of “if”is one example. I think that the proposed @const or its variants will have the same impact on learning and consequently popularity of Swift.
More keywords - less elegant language.

Sounds like you have lots of experience. What specifically do you suggest instead?

I only saw mentioning that this feature could be potentially useful. If the purpose is to generate a more efficient code then we should not ask the user to help with the optimization. Instead we could add a compiler directive/function that would determine if the constant is initialized at the compile time and the initialized value then we can achieve the same (even better) result without complicating the language.
Something like:
func Clamp(val, min, max) -> …
{
if CompileTimeDefined(min){
let Min = GetComplieTimeValue(min)

}
}

So only a small number of expert users would have to learn about CompileTimeDefined/GetComplieTimeValue while the rest of us mortals would not care.

Slight miscommunication @Panajev . In this example, i is the imaginary unit. Presumably all the constants from the Numerics and Complex packages would be marked as @const if this proposal is accepted

1 Like

Thanks for the clarification, I was thinking it would have to be as the rules around built time evaluation are necessarily infectious :).

WWDC didn't reveal anything which obviously depends on this, so this proposal still seems like a solution in search of a problem. Since my view on this is that I think the motivating use-cases are extremely uncompelling, I thought it might be worth writing up why they don't excite me.

Enforcement of Compile-Time Attribute Parameters

I don't understand why requiring the upper and lower bound of a clamp to be a compile-time value would be a desireable thing. It certainly isn't addressing any problem I've ever had. If the compiler was able to optimize away the runtime storage of the bounds then that'd be exciting, but implementing that isn't actually part of the proposal.

Similarly, accidentally inconsistent runtime values for a serialization key just isn't a bug I've ever seen happen, and so a feature to prevent that is not very useful.

Enforcement of Non-Failable Initializers

Compile-time validation of string arguments would be wonderful, but that isn't part of this proposal. Without that, this is just removing a single character from the call site. I'm not convinced that removing that character is even a good thing to begin with.

Facilitate Compile-time Extraction of Values

The specific example of using this for Package.swift sounds like a hilariously overcomplicated way to turn a turing-complete config file into a declarative config file. I can see some advantages to it compared to just using a normal config file format (e.g. Xcode doesn't have to implement autocomplete and syntax highlighting for that format), but it also preserves the giant downside of Swift being very slow to compile. Skipping codegen and executing the package doesn't speed up the part of package resolution that causes performance problems.

The broader idea of having some sort of API to inspect the AST at build time to generate code or something is exciting, but @const seems like a very small piece of a very large project that wouldn't need @const to be useful.

Guaranteed Optimization Hints

The obvious question here is if the hints are actually useful, and it's not obvious to me how they would be.

Is the assumption that @const will result in more inlining? Or that the compiler will specialize the function for each value of the @const parameter? The latter is something that we've done in C++ plenty of times (shifting the parameter from a runtime argument to a template argument), but I'd have a lot of questions about how exactly that'd work and I'm not sure that something like @const is the right way to do it. A more explicit @specialize(where: value == 1 || value == 4 || value == 8) func foo(_ value: Int) might be better for that sort of thing.

7 Likes

Given that implicit conversion from @const T to T is very natural, I really think we can consider adopting @const into the type system directly. I agree that this adds some complexity, but it will enable far more power.

Consider the example given in the proposal:

typealias CI = @const Int
let x: CI?

What is the type of x? It appears to be Optional<@const Int>, which is not a meaningful or useful type, and the programmer most likely intended to have a @const Optional.

How to determine the type of x here?

We can force generics types to add explicit declaration (eg. by attribute) if they accept @const type arguments. In the Optional case, it should choose not to do so because that makes no sense.

Then the case above will emit a compiler warning saying that Optional doesn’t allow Wrapped to be explicitly @const, so the type of x is still Optional<Int>.

But wait? How to get an @const Optional?

Generic types can provide a build-time evaluated initializer, that takes an explicitly @const value, and return an explicitly @const T. Build-time evaluable overrides will always be preferred.

That is, types of the following declarations will be resolved as:

let a = 1              // a: @const Int (Swift 6)
let b: Int = 1         // b: Int
let c = Optional(x)    // c: @const Optional<Int>
let d: Int? = a        // d: Optional<Int>
let e: @const Int? = x // e: @const Optional<Int>

let x: @const _ = 1    // The exact alternative to `@const let x = 1`

Will it introduce source breakage?

Explicit type annotations are still taken as-is, and @const needs to be explicitly marked. It has the least combination priority so @const _ is valid.

Since @const values can fit everywhere non-@consts are accepted, there shouldn’t be any source breakage. Not implicitly inferring @const in Swift 5 is simply for preserving behavior of existing codes.

Implicit @const will largely push the adoption of build-time evaluation as we can have @const types in much more places without explicit annotation.

Proposed @const conversion rules

  • For every type T, @const T implicitly converts to T.
  • For value type T with stored property prop: U, prop on explicit @const T has type @const U.
  • For reference type T with let property prop: U, prop on explicit @const T has type @const U.
  • For every type T with computed property prop: U that has a build-time evaluable getter, prop on explicit @const T has type @const U, otherwise it has type U.

One more thing…

If @const is emitted into the type interface, we should consider if it should be named const as a modifier instead of an attribute.

3 Likes

I just read the proposal while writing my monthly summary issue and the Facilitate Compile-time Extraction of Values section got me thinking in its last sentence:

More-generally, providing the possibility of declarative APIs that can express build-time-knowable abstractions can (...) allow for further novel use-cases of Swift’s DSL capabilities (e.g. build-time extractable database schema, etc.).

Do I understand it right that this should also allow the community to write a new DSL for Config Files based on Result Builders and the @const attribute to replace e.g. YAML files with Swift files?

1 Like

Would it be allowed to define an enum of @const values?

@const let thirdCase: String = "case3"

enum Test: String {
  case first = "case1"
  case first = "case2"
  case third = thirdCase
}
4 Likes

I would be interested in this feature. It feels weird to require JSON or YAML as a configuration file for a Swift script.

1 Like

As WWDC has come and gone there still doesn't seem to be any sign of an actual use for this that isn't relegated to a future direction. As such I am strongly -1.

A pitch that included any of the future directions, or was at least proposed concurrently with one, would likely be different story. Having said that, I am not willing to support the use of compile time values for things like URL literals unless that also comes with compile time validation.

3 Likes

Thanks, everyone, for the great discussion. The language workgroup has returned this proposal for revision. Rationale is posted here.

Doug

4 Likes