pre-RFC `const` function arguments


#41

The proper place would be probably the tracking issue for RFC2000: https://github.com/rust-lang/rust/issues/44580

Then Max3 is a kind in the context of , and N is a type. Since it is a type promoted from a value, you can also use N at the expression / value level.

That does make sense to me. I wish RFC2000 would have had more examples to make these things clearer.


#42

(Warning: Larger wall of text than intended, with some ideas in it. Maybe they’ll serve as inspiration, or more likely I’ve gotten something completely wrong.)


I’m having trouble understanding why this makes Max3 a kind (in the Haskell definition of “kind”). It seems to me that Max3 has the same kind as a trait/typeclass - say, std::cmp::Eq. That is, I think the statement T: Eq (the type T is a subtype of the typeclass Eq) is analogous to the statement const N: Max3 (the type const N is a subtype of the typeclass (type) Max3).

(From a purely set-theoretic standpoint, these things are all the same - a value Max3::One is an element of a single-valued set {Max3::One}, which is a subset of Max3 = {Max3::One, Max3::Two, Max3::Three}, which could be a subset of Eq. In that model, n: Max3 is an element n, while const N: Max3 is a single-valued set {N}.)

BTW, in Haskell, the kind of various typeclasses (Eq, Num, etc.) is * -> Constraint (where * is the kind of a type). But since the Constraint type is sort of opaque and I don’t understand it, I don’t know if these really are the same kind.


One observation from the above is that there is a one-to-one-mapping between values n: Max3 (n) and their lowest types (consts) const n: Max3 ({n}). We could say that there is an implicit conversion from the type const n to the value n because const n is a subset of {n}. We could also say that a const declaration like const N: Max3 = Max3::One is actually a type declaration, and by extension that a literal Max3::One is actually a type regardless of context.

Given all of this (just when you thought I had long gone off the rails, this is where I tie it back into the original conversation): We could say we are actually already putting types (consts) in value position: f(Max3::One). it seems entirely reasonable, then, to actually constrain an argument to be a type (a further constraint than currently possible):

fn f<const N: Max3>(n: N);

Breaking down the type constraints here:

  • const N is a subset of Max3
  • n is a subset of const N

GIven an invocation f(Max3::One), the type N could therefore be inferred:

  • n is a subset of const N
  • n is single-valued
  • N is single-valued

Therefore, n == N == Max3::One, so the inferred invocation is: f<Max3::One>(Max3::One).

My conclusion is basically that the function declaration syntax I used seems sensible, and it seems (to me) to tie well back into RFC2000. (Although I’m actually having trouble deciding which of the following makes more sense. It’s late and I have confused myself.)

fn f<const N: Max3>(n: N);
fn f<const N: Max3>(const n: N); // n needs to be const, so that may need to be explicit
fn f<const N: Max3>(const n: const N);

The latter two probably resolve naturally back into the original syntax proposal by contracting the two constraints: fn f(const n: Max3).


#43

So IIUC what you are suggesting is that, instead of what this RFC proposes:

fn foo(const N: usize) { }

the syntax:

fn foo<const N: usize>(const N: usize) { ... }

leveraging “type deduction” (if consts are types) meshes better with RFC2000 ?

If so, I wholeheartedly agree, what you propose:

    1. Gets the job done:
    • It allows specifying that function arguments must be compile-time constants, solving the problem for SIMD and related interfaces
    • It improves ergonomics by allowing users of functions specified this way to pass constants as foo(2 * N) instead of foo::<{2 * N}>()
    1. Is a smaller incremental step than what this RFC proposes:
    • It is analogous to how type arguments and type-deduction work today: one can write fn foo<const N: usize>() and force callers to do foo::<{2 * N}>(), but one can also write fn foo<const N: usize>(const N: usize) and allow callers to use foo(2 * N). This looks analogous to fn bar<T>() vs fn bar<T>(x: T).
    • If we wanted (not saying we do), we could still allow fn foo(const N: usize) in the future maybe in a way that is more consistent with universal impl Trait. In any case, we don’t need to do this now

So I think this is a great idea which in retrospect seems obvious. Thanks @kainino !


#44

One general question I have is:

fn foo<const N: usize>(const N: usize) { ... }

Is it a problem (e.g. parsing) that N and const appears twice there, in both type positions ?


#45

IMO, const is the kind, N is “type” (a member of the kind) and Max3 is the type-class.

Therefore…

to leverage “type deduction” the proper declaration should be

fn foo<const N: usize>(_n: N) { ... }

Compare this with Typescript’s literal types.


#46

For SIMD, another possible design is to just match the parameter with a constant value for each match and make the function inline, since the constants are usually 8-bit or smaller.

This way, if the value is constant the match is optimized out, and otherwise the user doesn’t have to add the match itself.

The only drawback is hiding slow performance, but a match doesn’t necessarily need to be that slow.


#47

That’s not the only drawback.

There are intrinsics taking 16-bit immediate values, those would require 16_384 match arms. I tried with one, a single use of the intrinsic will bump compile-times from ~3-4 minutes to “I gave up after 30 minutes” in the initial version. Since not all values were used, I was able to reduce the match statement of this intrinsic to only 4096 match arms. For reference, stdsimd test suite takes ~3-4 minutes to compile. Each single call to the intrinsic in the tests would bump compile-times by ~5-6 minutes. This was unacceptable, so we decided not to implement the intrinsic.

The current constify! macros with 256 match arms already incur a pretty high compile-time cost; >= 4000 match arms is just something that is not currently usable.


#48

First, remember that a kind is simply “one level higher” in the type of types of types of types… infinite tower. In Haskell, bounds such as Eq a are of the kind Constraint. When writing a type class, you can optionally write:

{-# LANGUAGE KindSignatures #-}
class Monad :: (f :: * -> *) where 
   pure :: a -> f a
   join :: f (f a) -> f a

When we introduce DataKinds you can write thing such as:

data Nat = Z | S Nat deriving (Eq, Ord, Show, Read)

Now we proceed to query ghci about some kinds and types:

:k Nat
Nat :: *
:t S
S :: Nat -> Nat

:k S
S :: Nat -> Nat
:k Z
Z:: Nat

We see that indeed, Z is a kind.

You can read more about these things here: https://www.schoolofhaskell.com/user/konn/prove-your-haskell-for-great-safety/dependent-types-in-haskell

(nit: it is called type inference…)

To me, the syntax

fn foo<const N: usize>(const N: usize) { ... }

is wholly redundant.

I think we can just go with fn foo(const N: usize) under the interpretation that turbofish sites are for implicit arguments and are not specific to types or lifetimes… This could provide a future method to specify default runtime value arguments to functions… I think the proposals in this RFC are optimal as is for the goal the RFC wants to achieve.

Certainly, Max3 is not a type class as type classes are used to define classes of types according to common behavior - associated consts and items fuzzies this a bit, but at their core, type classes are about specifying dictionaries of functions for types. You may say that 1 is a value which is the only inhabitant of the type 1 which has the type usize, which has the type const.


#49

I think this is nice as well.


#50

This syntax implies to me that fn foo(_n: 5) { ... } is valid syntax, but I don’t know what it could possibly mean.

Function parameters have types (in the old fashioned sense, ignoring other kinds for now). Const parameters aren’t types. So putting const parameters does not make sense to me.


#51

You could imagine a ZST UsizeConst<5> where 5 is the only valid value and the above is desugared as

fn foo<const N: usize>(_n: UsizeConst<N>) { ... }

#52

That makes it work but it doesn’t really make the surface syntax any less counter-intuitive. And I think UsizeConstant (why restrict it to usize btw? wouldn’t we want this for all types that can be const parameters?) would also have to be special compiler magic sauce to enable the inference that motivates the proposal.

I’m unconvinced that this is any less special or confusing than fn foo(const N: usize).


#53

The fn foo<const N: usize>(_n: N) suggestion was compared to fn foo<const N: usize>(const N: usize), not fn foo(const N: usize).


#54

Ah, right, sorry, but the latter is also less objectionable to me than mixing const parameters with types.


#55

That’s a bad idea. We need to discuss possible alternative ideas before things get implemented, because once code is written down, it’s much harder to change things.


#56

Nobody’s proposing a full alternative syntax for const generics, right? I haven’t seen anything like that, at least. This pre-RFC, for example, explicitly only proposes a shortcut for one particular usage patterns of const generics.


#57

Let’s get back to the original pre-RFC.

I’m :-1: for desugaring the const parameter directly as a const generic. In particular the following produces different types is concerning:

fn foo(const n: usize) -> [T; n] { ... }

let a = foo(16);  // a has type [T; 16]
let b = foo(32);  // b has type [T; 32]

Given the motivation is just to support SIMD, I prefer to heavy scale down the scope to what SIMD needs, and make it behave like a normal function, where the type of foo must not involve N (i.e. it can’t take [T; N] as input or return a [T; N]).

If we want to forward a const parameter, it is unavoidable that functions having a const parameter must be monomorphized. For instance this AVX instruction is defined as following in clang:

// __m256d _mm256_shuffle_pd(__m256d a, __m256d b, const int mask);
#define _mm256_shuffle_pd(a, b, mask) __extension__ ({ \
  (__m256d)__builtin_shufflevector((__v4df)(__m256d)(a), \
                                   (__v4df)(__m256d)(b), \
                                   0 + (((mask) >> 0) & 0x1), \
                                   4 + (((mask) >> 1) & 0x1), \
                                   2 + (((mask) >> 2) & 0x1), \
                                   6 + (((mask) >> 3) & 0x1)); })

Translated to Rust would be:

fn _mm256_shuffle_pd(a: __m256d, b: __m256d, const mask: u8) -> __m256d {
    simd_shuffle4(a, b, [
        0 + ((mask >> 0) & 1),
        4 + ((mask >> 1) & 1),
        2 + ((mask >> 2) & 1),
        6 + ((mask >> 3) & 1),
    ]) // <-- the compiler needs to recognize [...] is a constant
}
extern "platform-intrinsic" {
    fn simd_shuffle4<T, U>(x: T, y: T, const mask: [u8; 4]) -> U;
//                                     ^~~~~ should `const` be allowed here anyway?
}

The comparison to Swift in the “Drawbacks” section is unfounded. In Swift T.self is a runtime value of type T.Type, and everything done through T.self is going through the runtime reflection. This is more similar to the Class<T> object in Java. That is, Swift should not be used as an argument to support or reject passing “types” as a function argument since it is totally irrelevant.

If the point is just to use inference to avoid the turbofish, well this is already supported in stable Rust.

use std::marker::PhantomData;
use std::mem::transmute_copy;

#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
struct TypeOf<T: ?Sized>(PhantomData<T>);

trait HasTypeOf {
    fn type_of() -> TypeOf<Self> {
        TypeOf(PhantomData)
    }
}
impl<T: ?Sized> HasTypeOf for T {}

unsafe fn unsafe_bit_cast<T, U>(x: T, _: TypeOf<U>) -> U {
    transmute_copy(&x) // (can't use transmute(), size of T and U are unknown)
}

fn main() {
    unsafe {
        let a = 0x3ff00000_00000000u64;
        let b = unsafe_bit_cast(a, f64::type_of()); // <-- look ma, no turbofish
        assert_eq!(b, 1.0f64);
    }
}

#58

Personally I think there is quite good sense in your simplification. To regurgitate what I think you mean, what’s needed for SIMD is not const generics that could be used in [T; N], but simply function parameters that are guaranteed to be static constants. This actually sounds totally orthogonal to const generics.

Notably, in addition to disallowing the parameter in [T; N], this also prevents (as I understand it) specializing functions for particular values of the const. [EDIT: To clarify, I meant writing multiple specializations with different implementations (currently not possible afaik), not generating multiple monomorphizations of the function, which is crucial functionality.]


#59

@kennytm Would this introduce the possibility of producing monomorphization time errors?

The point was that Swift has syntax for passing “types” as arguments. But yes, they are not really types, and the semantics are very different. I was just mentioning that other languages have syntax for doing similar things.

If the point is just to use inference to avoid the turbofish, well this is already supported in stable Rust.

Under the const-generics RFC foo(HasValue<N>) won’t work but foo(HasUsizeValue<N>) would. This is because struct TypeOf<T, const N: T> { ... } is explicitly forbidden, so one needs one struct per type to do this.

@kanino

Personally I think there is quite good sense in your simplification. To regurgitate what I think you mean, what’s needed for SIMD is not const generics that could be used in [T; N], but simply function parameters that are guaranteed to be static constants. This actually sounds totally orthogonal to const generics.

I’ve discussed this with @eddyb on IRC and IIUC this feature in any flavor that solves the SIMD problem (that includes what @kennytm just proposed) needs to propagate the const through the function body to other functions. Implementation-wise at least this is exactly equivalent to const-generics.

I did not discussed the following with @eddyb, but I’d guess that limiting in scope with respect to const-generics will involve extra work to actually forbid these “constants” from being used where const generics can.

In any case, maybe @eddyb can chime in and correct me, or explain how this might work in the compiler.

Obviously, I am not suggesting that we should go to full const generics here just to save some implementaiton work. IMO this feature should stand on its own as a valuable addition to the language.

I think that scaling it back to what @kennytm proposes is a good idea because it is even a more minimal step. But since I could imagine that we “might” want to scale this up to full const-generics in the future, it would be wise to do so with a syntax that would be forwards compatible with that “just in case”.

In particular, @kennytm’s scaled back solution is otherwise semantically equivalent to const-generics AFAICT: the value of the const still must somehow be part of the function’s type, we can’t take function pointers to these functions, they are not object safe, we can’t use them on C FFI, etc. So this all matches well with the rest of the pre-RFC. @kennytm raised a good point about using it in extern "platform-intrinsics". I’d think that we could allow it there because that’s an ABI that we control and thus we can evolve it to do whatever we want.


#60

You could find out whether an expression is const before monomorphization, so no, this won’t introduce any post-monomorphization errors.

My point is that, these values have to be evaluated as constants before sending to LLVM, so _mm256_shuffle_pd cannot be translated to a single function. It must be fully inlined or use const generics under the hood. I believe all of us agree with this :slight_smile: