pre-RFC `const` function arguments

We do have function overloading via traits:

trait Foo {
    type Bar: ?Sized;
}

struct A;
struct B;

impl Foo for A {
    type Bar = [u8];
}

impl Foo for B {
    type Bar = [u8; 3];
}

fn foo<T: Foo>(_: T) -> &'static T::Bar {
    unimplemented!()
}

fn main() {
    let _: &[u8; 3] = foo(A); // ERROR: [u8] vs [u8; 3]
    let _: &[u8; 3] = foo(B);
}
2 Likes
// not in plain sight:
// trait Foo { const N: usize; }
// fn foo<T: Foo>(x: T) -> [T; <T as Foo>::N];  // RFC 2000
// impl Foo for usize { const N: usize = 3; }
// impl Foo for i32 { const N: i32 = 4; }

let a = foo(0);  // [usize; 3]
let b = foo(0);  // [i32; 4]

// let _: usize = a[0]; 
// let _: i32 = b[0];    

Point being: there are a lot of things in Rust that one does not know about while reading code already. It is rather more about whether:

  • Is it useful to hide these things? (maybe? it solves some problems)
  • How hard is it to be explicit when you need it? (its easy: annotate your types)
  • How hard is it to get the information when you need it? (easy: annotate types and create a compiler error, hover a with your mouse and let your IDE show you the type, etc.)
  • How hard can you screw up? (not very hard: the compiler gives you a type error)
  • How do all these things balance against each other? Aka: is doing this worth it?
5 Likes

I realized these days that the pre-RFC does not mention anything about how this would interact with const fn. I have been trying to make progress but that’s really hard because RFC 2000 does not say how const-generics interact with const fn, for example, is the following allowed?

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

Or in other words, if we had something like what this rfc proposes, would this be allowed?

fn meow(const N: usize) { }
const fn baf(n: usize) { meow(n) } 

or would one need:

const fn meow(const N: usize) { }
const fn baf(n: usize) { meow(n) } 

If only the later is allowed, then there is nothing to discuss about how this pre-RFC interacts with const fn (it interacts with it just like the rest of Rust does, by writing const fn).

None of these three examples can work. All const fns can be called with runtime values, thus n in baf might not be a constant at all. const fn describes a computation that can be performed at compile time, not one that has to.

7 Likes

Completely missed that! Haven't used const fn enough to notice this. Then I guess there is nothing to discuss about that :slight_smile:

When this becomes an actual RFC, I’d like to see it discuss why macros cannot be used to solve the usability issue given in the Motivation section. For example, I could imagine a macro _mm_blend_ps! that can be called like this:

_mm_blend_ps!(a, b, CONSTANT);

which expands to:

_mm_blend_ps::<{CONSTANT}>(a, b);

AFAIK, this would solve the motivating issue without any language changes, provided that the documentation would point users to the macro.

7 Likes

We have considered using macros (see below), but we have not considered using macros with const-generics. If we use a macro it can just expand to __rustc_llvm_intrinsic_blend_(a, b, CONSTANT).

  • What would be the advantage of using const-generics here?

This is actually what clang does for some of the intrinsics. It just defines them using C macros. Since C macros are expanded without a bang (!), this allows clang to exactly match the vendor API.

The main advantage of using macros is they are not incompatible with adding the bare function later on if we ever get language support for them. That is, std could export foo! now, and add a std::arch::foo in the future without any issues.

The macros approach has a couple of smaller issues:

  • some intrinsics will be macros and some won't. This is not a problem for those users who understand "why" this is the case, but it might be a minor inconvenience otherwise. Since nothing can go wrong here (you get a compiler error), this is pretty minor.

  • everything that wants to call them while still allowing users to pass a constant will need to be a macro as well . Arguably, the same can be said about const-generic based approaches.

  • macros might have a slightly larger impact on compile-times than bare functions or const generics. If only a fraction of your code uses the SIMD macros, one probably won't care, but if you use them through higher-level SIMD libraries that might need to be macros as well, this might hit you a bit.

  • macros 2.0 aren't there yet, so the macros would need to be exported by std. Re-exporting macros is not a thing either, so this might add some level of pain to libraries up the stack wanting to re-export the std macros in some cases, and overriding them in others. Macros 2.0 will fix all of these issues, so this isn't a big deal either.

From the POV of stabilizing the intrinsics, the main goal is to stay as close to the C-like specs as possible. Using macros makes us deviate from the spec a bit. We already do so in other cases, so it can be done, but this pre-RFC explores one of the alternatives. Some other alternatives we have consider are:

  • macros ! We use them internally to deal with this all over the place, we could just expose these macros to the user, and we don't need const generics for it, so this wouldn't block stabilization, and should be backwards compatible.
  • Function attributes to prevent users from taking pointers to these API and for the compiler to emit an error if the arguments aren't constants. This is half-way implemented into the compiler, and currently being tested. This approach makes the most sense to me as a temporary solution to something better, so this pre-RFC explores how that might look like.
  • using const-generics in some form (this pre-RFC explores one of those forms, but others here have come up with great alternatives)
  • not stabilize these APIs initially and re-evaluate first when const-generics lands (that is, require nightly in the mean time to use them): this is a no-go, some of these intrinsics are fundamental, and without them SIMD programming will take a serious hit (or become impossible).

I think once the function attribute approach is implemented, we will go over all the alternatives again and try to make a decision.

2 Likes

(Finishing/stabilizing macros 2.0 is at least officially part of the 2018 roadmap, so if that ends up panning out the macros solution sounds appealing to me…)

1 Like

So I proposed using macros in the SIMD RFC, but that was not well received, so the intrinsics are going to be stabilized as functions with the hopes that something like this can be stabilized in the future.

The workaround is to have two compiler attributes (AFAIK already implemented) that: prevent users from taking a function pointer to the intrinsic, and prevent users from passing a run-time value to the intrinsic.

The motivating example looks really wrong to me. The real _mm_blend_ps intrinsic has a mask argument which is of type u4 (4 bits, unsigned), not i32. For the i32 version, what should happen if you pass 33? A compile-time error? Maybe even an error issued from the assembler?

At a more abstract level, I’m concerned with the underlying premise here. Most CPUs do not have an add instruction with an immediate usize operand with arbitrary values, yet we all want to write

val + 1099511627776

and the compiler will load the constant into a register first if necessary. This is why we have compilers.

Again, more specifically, if it makes sense to perform arithmetic on the mask argument of _mm_blend_ps (so that a type-based encoding quickly becomes silly), then I think there will be many applications which benefit from doing the calculation at run time, with an expectation that the compiler will select the appropriate instruction, perhaps one with an immediate mask operand, perhaps not.

The real intrinsic takes a const int and the ISA specifies that only the bits that are relevant for the registers involved are taken into account. That is, the higher bits of the mask are ignored, and the higher bits of the destination registers are unmodified.

what should happen if you pass 33? A compile-time error? Maybe even an error issued from the assembler?

Neither. The blendps instruction is generated with the immediate value specified by the user. Even if there was an intrinsic for which the wrong value would result in hardware undefined behavior, which AFAIK is not the case (but I don't know all intrinsics by heart), that would just mean that there is a pre-condition on the value that the user must uphold, but that's ok because the intrinsic is unsafe anyways, which means that the user is responsible of upholding such preconditions.

I think there will be many applications which benefit from doing the calculation at run time, with an expectation that the compiler will select the appropriate instruction, perhaps one with an immediate mask operand, perhaps not.

That's what the portable packed-SIMD vector types are for. The ISA intrinsics map to the ISA.

1 Like

But how do you go from the vector types and their variable mask arguments to a const argument? With a large match statement? This does not seem realistic.

These things simply belong in the compiler, which has ample support for instruction selection.

That's how the portable packed-SIMD vector types are already implemented.

Let's take "shuffle" operations as an example of something portable that compilers actually have good support for. At least LLVM does not have support for shuffles with non-constant shuffle indices (i.e. v2[i] = v1[shuf[i]] where shuf is variable). If you want to do that, you have to do it yourself -- e.g., with a large match statement, or by storing the to-be-shuffled vector to memory and then doing a gather load, or other workarounds. So I contest this statement. Instruction selection is not magic.

1 Like

Would this provide support for evolution of a function from using const generics to a normal parameter? e.g. Today, I design

fn foo<T, const N: usize>(t: T) // or foo<T>(t: T, const N: usize)
{
    let x: [i32; N] = [0; N];
    ...
}

Tomorrow, I see that we need more flexibility (or less bloat) and I change the signature to:

fn foo<T>(t: T, n: usize) 
{
    let x = vec!()[0; n];
    ...
}

If my callers had all used foo(t, 32) (or I was able to force this), then this only requires a change to this function.

A related question: could this be used for specialisation? For some values of T we use const generics, for others, a normal parameter?

If we proceed with the solution discussed in the pre-RFC, then yes.

If you write this code:

fn foo(const N: usize) { ...} // A
foo(3);  // B

and then change the signature of foo in A to fn foo(n: usize) { ... }, the code at point B will still compile because 3 is a valid argument for both signatures.

Changing the signature is an ABI/API breaking change anyways though.

The discussion mentions that const function arguments are just “sugar” for generics, but some things that follow from that and have not yet been mentioned (maybe they were just too obvious back then) are that const fn functions taking only const arguments:

  • cannot be called with run-time arguments (all arguments are compile-time constants),
  • cannot be refereed by a function pointer (unless we extend the language to support function pointers to generics),
  • (therefore) might not need any code generation iff we guarantee that const fn called with all const arguments are always evaluated at compile-time (which is something that might make sense doing).

I haven’t gone too deep into reviewing this thread, but has anybody explained why this kind of desugaring couldn’t be done with macros?

That is, couldn’t we make a macro foo!(1, 2, 3) expand to foo<{2}>(1, 3)? I don’t see it listed in the alternatives or mentioned anywhere in this thread…

EDIT: Ok, I see it was mentioned here. Gotta remember that search exists!

(therefore) might not need any code generation iff we guarantee that const fn called with all const arguments are always evaluated at compile-time (which is something that might make sense doing).

To be clear, we don't have to guarantee this; the compiler is allowed to guarantee this to itself, tho.

Also, side note: isocpp/constexpr-parameters.md at main · davidstone/isocpp · GitHub

Here's a paper that proposes to do the same thing for C++.

1 Like

Personally i feel the proposed syntax is a little strange. I’d imagine some alternative syntax for turbo fish like:

fn foo1<T: Default>() -> T { T::Default() }
fn foo2<T: Default>(_: u8) -> T { T::Default() }
fn foo3<const N: usize>() -> usize { N + N }
fn foo4<const N: usize>(v: usize) -> usize { v + N }
fn foo5<'a>(s: &'a str) -> String { s.to_str() }

let f1 = foo1(where T = usize);
let f2 = foo2(42, where T = usize);
let f3 = foo3(where N = 42 + 5);
let f4 = foo4(42, where N = 42 + 5);
let f5 = foo5("foo", where 'a = 'static);

and in the original example:

const C: i32 = ...;
let r = _mm_blend_ps(a, b, where imm8 = C);
let r = _mm_blend_ps(a, b, where imm8 = 2 * C);
1 Like