pre-RFC `const` function arguments

Yes. I have been holding on updating the pre-RFC until things settle a bit (will probably submit the modified version as a reply here).

To explain my position with examples, I am a bit torn about the following two ways to proceed:

  • scaling it down to what @kennytm proposes
  • layering this as "type-inference" for const parameters on top of RFC2000

First, independently of what we choose here, this "feature" requires RFC2000 to be implemented, so this must necessarily come after const-generics. That is, by the time this lands, we will already have const generics. AFAIK there is no way around this.

At that point, adding type inference for consts like this:

// before:
fn foo<const N: usize>() { }
foo::<3>(); // OK
// RFC on top of const generics:
fn foo<const N: usize>(n: N) { }
foo::<3>(3); // STILL OK
foo(3);  // NOW ALSO OK

seems like a natural extension that is consistent to how we already treat type-parameters. @kennytm is concerned about the following example

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

However, I think this situation is analogous to:

fn foo<T>(x: [T; 3]) -> [T; 3] { x }
let a = foo(3); // a: [i32; 3]
let b = foo(3); // b: [usize; 3]
// ...
let (x: [i32; _], y: [usize; _]) = (a, b);

and in general many similar situations that can happen with type-inference today. If anything, I think that this way of going forward would at least be consistent, which means that users won't need to learn new ways of thinking to figure out how type-inference works.

OTOH if we were to allow this:

fn foo(const N: usize) { }

It is unclear to me if it would be worth it to make this play with const-parameters (<const N: usize>), and if so, how that interaction would look like. The ones we have discussed here all have drawbacks when trying to scale them up to just sugar over full const-generics:

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

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

This is not a problem if we never intend to scale them up, but the "unknown" makes me a bit uncomfortable.

Because of this, I tend to favor the "adding type-inference for const-parameters" route. But of course, for that to happen, we would need to settle the type/kind/value related issues. I'd prefer to settle those first, because we need to settle them anyways for const-generics even if we never move forward with this pre-RFC. After those are settled, revisiting this might be easier, and the path forward much more clearer.

This is surprising to me. If I compare impl Trait, I'd expect something more like

fn foo(n: const usize) { ... }
foo::<3>(3) // <-- not ok (maybe eventually, but not ok today)

Yeah, when compared with impl Trait that might not make much sense. You need to compare it to:

fn foo<T>(x: T) { }
fn foo::<i32>(0_i32) { }

in that you are specifying i32 twice (while leaving the 0 aside). Sure, it is not exactly analogous, because in this case you have a value 0 and a type i32, but for const in practice "the value is the type and the type is the value". This is not technically correct, because we have different contexts where in one we deal with a type and in another with a value that can be used to infer a type. But coming back to the practice of using these, a mental model that considers them to be the same is enough for the trivial cases at least.

What's concerning about that? It seems quite useful to me, making it easier to write functions that are generic on different sizes of array, and giving sort of a 'dependent types' feel (even though it doesn't provide the functionality of real dependent types).

This is incorrect. T.self is a singleton value of type T.Type, where T.Type is a different type for every T (and has zero size), so it allows for monomorphization and has no overhead at runtime. In fact, it's pretty much the same thing as your TypeOf example. But I admit it's only semi-relevant to what's being proposed here :slight_smile:

2 Likes

It looks like a runtime value affects a compile-time value. While I know it's a compile-time value, I won't know that from just reading the code. I need to know more about foo than is visible in the syntax of the call.

6 Likes

It could be solved by the following modification:

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

let a = foo(const 16);
let b = foo(const FOO_BAR);

Though it's quite verbose and does not look that good with named constants.

1 Like

One advantage of the ā€œargument position constantsā€ is that we could potentially do something fancy in the future where a Sized type is returned if the argument is constant and a !Sized type is returned otherwise.

An example that comes to mind is indexing by constant or dynamic ranges. Constant ranges can result in &[T; N] while dynamic ranges result in &[T].

1 Like

In some sense it’s already possible for it to ā€œlook likeā€ a runtime value affects a compile-time value - this is really what type inference is. Consider this extreme example, which incidentally kind of does what we’re discussing here:

extern crate typenum;
use typenum::*;
fn id<U: Unsigned>(u: U) -> U { u }
fn main() {
    let four = U4::new();
    id(four);
}

In any case, I don’t feel strongly between foo(16) and foo(const 16) at the moment. I would lean toward foo(16), because it looks more like the above.

The story so far has been not to do fn overloading. I think we should not change that.

2 Likes

I see why a simple ā€œfunction parameter but it’s constā€ isn’t quite an accurate description. One of the core requirements, as you said, is monomorphization of the function over the const parameter. So to some extent, it depends on whether (syntactically) you want to introduce mandatory monomorphization over things that appear in the parameter list instead of the generics list. I don’t feel qualified to have an opinion one way or the other.

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.