[Pitch] Compile-Time Constant Values

Neat, reminds me of the async await syntax. In C++20 functions are marked consteval and values constexpr. Is there any reason Swift would need two keywords as well?

cpp:

consteval int foo(int n) {
  return n*2;
}

constexpr int a= foo(1); 
int b = foo(1); // error, because foo is consteval

I don't see a reason for that and I find it interesting that c++ doesn't let you downgrade from a constexpr back to a regular int. I think 'downgrading' constexpr functions should definitely be possible in Swift.

By downgrade I mean being able to call a constexpr function with non compile-time values and it would just be executed at runtime and not return a constexpr value. If expressions are not clearly marked they get evaluated at runtime like usual.

Consider the following function:

func fibonacci(_ n: constexpr Int) -> constexpr Int {
  if n <= 1 {
    // I can't decide whether this line should have `constexpr` or not
    // given that the value is constexpr already and nothing is happening
    // to it. But it's more consistent this way maybe?
    return constexpr n
  } else {
    return constexpr fibonacci(n - 1) + fibonacci(n - 2)
  }
}

What if a developer wants to compute the fibonacci value of regular runtime values? If it were not possible to 'downgrade' the function, a developer would have to implement two identical fibonacci functions, one with constexpr and one without. And so would the standard library. Leading to lots of replicated code.

In the example above I use +, which would have to support constexpr arguments. If downgrading was possible this would be as simple as changing + to:

func +(_ lhs: constexpr Int, _ rhs: constexpr Int) -> constexpr Int

If called without constexpr arguments it would simply be run at runtime and not return constexpr. This would be expected too because if the developer was expecting a constexpr return value they would have to make it explicit:

let values = constexpr 3 + 2

(Of course the compiler would precompute 3 + 2 anyway even without compile-time constant value support, but this is just a simple example)

2 Likes

Another thing to consider, is the specifics of functions that return constexpr values. Clearly, they should only be allowed to use variables and functions that are also constexpr, because otherwise their output could technically change from build to build. For example, a non-constexpr function could be used in the condition for an if statement and the result would not be constexpr anymore. The most intuitive way to solve this would be to get developers to make every constexpr expression explicitly constexpr. However, I fear this could get overly verbose for more complex functions.

/// Example of a function that is a bit more complex
/// - Returns: a (bad) pseudo-random number.
func badPseudoRandom(_ seed: constexpr Int) -> constexpr Int {
  let shifted = constexpr seed >> 8
  // It feels a bit ridiculous in if conditions.
  if constexpr shifted % 2 == 0 {
    let modFive = constexpr shifted % 5
    // The `constexpr` here also feels a bit redundant, but
    // what would be a rock solid rule for allowing it to be
    // left out here? e.g. would it be required if `modFive + 1`
    // was the return value instead?
    return constexpr modFive
  } else {
    let modFour = constexpr shifted % 4
    return constexpr modFour
  }
}

One solution would be to infer it everywhere in functions that return constexpr. Because the developer has clearly marked the intent of the function being constexpr, and the compiler can easily do the rest of the checking for them. I don't think doing it that way would lose any clarity.


In constexpr shifted % 4 it also looks a little bit like constexpr is just for shifted, but syntax highlighting would probably make it pretty clear that it's a modifier. Maybe brackets would need to be introduced just like try.

1 Like

Ignore previous post, I've had a better idea

I return with yet another thought (sorry for all the posts in a row, I've been thinking about this a lot).

Consider the following pure function (I have omitted any constexpr notation or such because that would take away from my point)

func triangularNumber(_ n: Int) -> Int {
  var total = 0
  for i in 1...n {
    total += i
  }
  return total
}

My point is that the function's result is able to be computed at compile time when the parameter is known at compile time. However, notation that involves marking every expression as constexpr would not work (because total changes during execution). What approaches could be taken that allow this kind of function to be used in compile-time expressions? It might be best for now to simply add some kind of notation that marks a function as pure (and ignore use cases where not all parameters are constant), and then limit constexpr expressions to using pure functions and literal values.

Marking functions as pure also eliminates any confusion about constexpr functions being able to be downgraded, because a pure function makes no guarantees about the nature of the arguments, only that the same output is produced for the same input. This keeps pure functions completely separate from constexpr notation. This is good because seeing pure func add(_ lhs: Int, _ rhs: Int) -> Int is a lot less confusing than add(_ lhs: constexpr Int, _ rhs: constexpr Int) -> constexpr Int. The former conveys the true intent a lot more succinctly. (I just used the pure keyword as an example, that's not the point of this post).

TLDR

In my opinion, it would simplify this initial pitch if partially constant functions were left out and the scope was limited to just allowing advanced compile-time values and expressions. This means adding notation to mark a function as pure and then limiting constexpr expressions to only using combinations of pure functions and literals. The compiler would check that functions are pure.

2 Likes

Technically, when it comes to evaluating expressions, all you need is a way to request a compile-time constant. Nothing else is strictly necessary, as can be demonstrated in D with its compile-time function evaluation (CTFE) [more details].

With CTFE, if the context calls for a compile-time constant, the compiler evaluates the expression and calls functions in a limited interpreter until it either succeeds or encounters an instruction the interpreter won't allow at compile time. There's nothing special about function calls here, except that the compiler must have the source code to interpret it.

A Swift version of CTFE could look like this:

func foo(wantsRandom: Bool) -> Int {
   if wantsRandom {
     return Int.random(0..<10)
   } else {
     return 1
   }
}

// using `const` to force a compile-time evaluation:

const let x = foo(wantsRandom: true)
// error: in bar(): in Int.random(): function body is not available for compile-time evaluation

const let y = foo(wantsRandom: false)
// ok: interpreter never reaches an unevaluable function

At module boundaries, functions would need to be inlinable for that to work, or else there's no source code to interpret. Only the inlinable (and usable-from-inline) code could be reached through CTFE.

Not having an attribute has the drawback that a library cannot prevent users from compile-time evaluating what it can't promise will be compile-time evaluable in future versions. But perhaps the attribute could be a sub-attribute of @inlinable, like @inlinable(allowCompileTimeEval). It'd only be needed at module boundaries.

I don't know if this is the right model for Swift, but it is interesting to consider for its simplicity.

8 Likes

It sounds simple, but then immediately I wonder - if:

const let y = foo(wantsRandom: false)

Can be evaluated at compile-time, why wouldn't the compiler always do that? Why wouldn't it evaluate:

let y = foo(wantsRandom: false)

Why do I have to manually tell the compiler to optimise things on a case-by-case basis?

My understanding is that attributes like this are meant for functions and parameters which must be known at compile-time for semantic reasons (e.g. the function is being used for some other compile-time feature). It shouldn't be necessary to achieve peak performance IMO

5 Likes

Maybe the function you are calling is calculating a few million decimals of pi. Or perhaps it's some kind of infinite loop. If the compiler was attempting to run every function every time you compile the code, you'd be in for a very slow compile.

With this very short function, I'd expect the optimizer to squash things to a compile-time constant. There's just no guaranty it'll be a compile-time constant since you haven't requested one.

The whole discussion here is about optimization, but I think that’s not the point Apple has in mind. My guess is security: a function with a const argument can be proven well-behaving, because it cannot be called with runtime-computed and possibly malicious values. This can be useful for instance when compiling a PM manifest or other DSL‘s. The first formatter argument of some print functions is another example.

Of course it can be something completely different as well…

2 Likes

This.

I was under the impression that a basic goal of optimization would be that anything declared with let would be initialized at the earliest opportunity, and then never changed.

So, a top level let x = 1 would obviously be compiled as 1, and uses of x in that scope basically inline replacements. But, of course, a Type declaring a let value initialized by parameters would be a runtime thing, but never the less insist on exactly one assignment, and there after never changing.

I’ve been ruminating on this, and I don’t think it should be possible (or at least routine) to mark anything as a compile-time constant.

Instead, you could be able to mark declarations as pure; that is, deterministic and free of side-effects. Pure functions should be replaced by compile-time constants at the compiler’s discretion, just like inlining.

It is not difficult to think of reasons you would want to adjust compile-time evaluation: binary size, compilation speed, compiling for a different target (it may not be possible for the compilation environment to run the code).

1 Like

My main reason for saying that a developer should be able to explicitly request it be run at compile time is an example use case in one of my projects. I have a function that generates a huge lookup table which takes quite a bit of time, and it would be really nice if I could guarantee that it was created at compile time. If I wasn’t able to mark it to run at compile time the compiler probably wouldn’t run it at compile time, because it would affect binary size quite a bit.

The compiler should be able to do it itself but it would also be nice to have a way to explicitly ask the compiler to run it at compile time.

2 Likes

Wouldn’t it be better to let it determine that based on the optimization mode? -Osize vs. -Ofast, for instance.

Again, these tradeoffs are extremely similar to function inlining. You aren’t (officially) able to force inlining in Swift.

1 Like

Yeah possibly, that is a good point.

If this was implemented, the compiler would have at least three representations to choose from for a pure function call:

  • A function call
  • The inlined body of the function
  • The result of the function being called during compilation

The tradeoffs implicit in each are quite complex. For instance, it’s impossible to say which would be the most compact without more information.

Functions are seldom used in isolation, either. A function may have a massive result (like a lookup table), but if it is called as the argument to another function it might allow that to be a compile-time constant in turn, which may take up less space in the end.

The compiler might even be able to replace every lookup with the resulting value, meaning the lookup table and its creation could be skipped entirely at runtime!

What if the function is from a library and it’s possible that the function will change in the future? Unlike marking a function as inlineable, marking a function as pure doesn’t necessarily mean that the developer has guaranteed it won’t change in a breaking way.

In that sense, automatically calculating a pure functions result at compile time would be like inlining a non-inlineable function from a library (which i don’t think the compiler does).

As I implied earlier, I’m assuming that any function eligible for compile-time evaluation is also eligible for inlining. In other words, compile-time evaluation wouldn’t even be considered across modules without either CMO or @inlinable, regardless of whether the declaration is pure.

@inlinable doesn't make any such guarantees, unless the library offers ABI stability (i.e. system libraries). For your regular Swift package, cross-module inlining has basically no downside, except perhaps increased compile time.

Anyway, I feel the whole point about compiler-evaluable functions is diverging from the point of this pitch: that you sometimes need to ensure that a particular value or function parameter is known at compile time.

One example is memory ordering parameters used by swift-atomics. This currently requires special @_semantics attributes, and is another example of important libraries being released with underscored language features.

Perhaps @lorentey can correct me if I'm wrong, but I believe the feature pitched here would be sufficient for them to drop that particular attribute. That alone would be a big win IMO.

1 Like

If a function is going to change in a breaking way and the library is a dynamic library, then inlining the function could break stuff when the app is used with a newer version of the library (dynamic linking allows the use of newer versions as long as the api stays the same im pretty sure, that’s one of the advantages of dynamic linking).

And yes, I agree that the conversation is getting a bit far from the original pitch. Could you please elaborate on the swift atomics switutation? Is it that it requires the ordering parameter to be known at compile time? If so, why?

I agree with this. I have used templates with value components in Metal Shading Language (very similar to C++) to enforce compile-time constants. The const keyword would provide an alternative version of such in Swift without corrupting the intention of generics.

Furthermore, this could be a solution to the use of values instead of types in C++ templates when doing C++ interop. Potentially, the value components of a C++ function could be const parameters, while the typename components map to a Swift generic argument.

You aren't allowed to explicitly put brackets around a generic function call in Swift, as you must incorporate the type in a way the compiler can type-infer. I have experienced a related constraint in Metal. Combined with const being used in the way described above, we could overcome an otherwise impossible barrier to C++ interop. It may not be idiomatic, but it's the only solution that would get the job done.

I am planning to explore a DirectX backend for either a Swift for TensorFlow resurrection or a spinoff project, and good C++ interop in Swift 5.6 would be very helpful. For more on the S4TF stuff, I have a post on it that was just freed from the spam filter: Swift for TensorFlow Resurrection: Differentiation running on iOS

Also, I have used Swift Atomics before while trying to parallelize MultiPendulum and the @_semantics stuff indeed looks wierd.

4 Likes

It isn't. There are two kinds of stability guarantee: source stability (so your code won't break if you compile with an updated library), and binary stability (so your code won't break even if it loads a newer/older library at runtime). The ability to distribute independent updates to a dynamic library requires that it offers binary stability, and that comes with so many costs that it's only really worth it for system libraries.

Within a single application, dynamic libraries might also make sense if you have several executables using common dependencies (e.g. XPC services). But those dependencies won't independently update.

Memory ordering is basically a compiler feature. It puts barriers in the code, across which the compiler may not re-order certain instructions.

Compilers otherwise assume code is single-threaded. They'd see you acquire a lock and then write to memory, and think that since no other code can possibly be running in parallel, nobody will notice if it reorders those operations. But that's bad - we definitely need the lock before we write to memory, and that write needs to happen before we release the lock.

That's why it needs to be known at compile time. It's not just an optimisation; memory ordering is part of the semantics of atomic operations.

1 Like