Skip to content

const if to supress instantiation/codegen of unreachable branches #3582

Open

Description

@the8472

Currently there's a suboptimal interaction between const asserts and const generics.

A more general function that is valid for all N cannot call a function that asserts at compile time that N fulfills some conditions even if the call is on a branch that's dead for the invalid N.

I.e. this does not compile:

#![feature(inline_const)]

// method in std
fn method_with_precondition<const N: usize>() {
    const { assert!(N > 0) };
}

// user method, no way to opt out of asserts for N = 0
fn generic_caller_method<const N: usize>() -> fn() {
    if N > 0 {
        panic_on_zero::<N>
    } else {
        || {} // fallback
    }
}

fn main() {
    let _fun = foo::<0>();
}
error[E0080]: evaluation of `panic_on_zero::<0>::{constant#0}` failed
 --> src/main.rs:5:13
  |
5 |     const { assert!(N > 0) };
  |             ^^^^^^^^^^^^^^ the evaluated program panicked at 'assertion failed: N > 0', src/main.rs:5:13
  |
  = note: this error originates in the macro `assert` (in Nightly builds, run with -Z macro-backtrace for more info)

This blocks / requires unpleasant choices in several T-libs features that want to check for N != 0 or similar constraints:

If const-eval in the dead branches is expensive it might also help compile perf.

There are efforts (rust-lang/rust#99682) to move monomorphization-time errors to check time and also apply those checks to dead code (rust-lang/rust#112879), which will make it even more difficult to discharge compile-time obligations imposed by callees.

Currently the language does not seem to have any way to select different code paths in generic contexts without also instantiating those paths. Even generic_const_exprs does not offer this unless it gets extended rust-lang/project-const-generics#26

Therefore it would be useful if we could write the case above as

fn foo<const N: usize>() -> fn() {
    const if N > 0 { 
        panic_on_zero::<N>
    } else {
        || {} // fallback
    }
}

so that panic_on_zero::<0> will never be instantiated.

Activity

  1. Jules-Bertholet commented on Mar 7, 2024

    @Jules-Bertholet
    Contributor

    I would note pattern types as an alternative; you could constrain an input const parameter with a pattern (eg const N: usize in 1..), and combine this with trait machinery to select an impl based on the value of the const:

    #![feature(inline_const)]
    
    fn panic_on_zero<const N: usize>() {
        const { assert!(N > 0) };
    }
    
    trait FooImpl<const N: usize> {
        const FN: fn();
    }
    
    impl<const N: usize in 1..> FooImpl<N> for () {
        const FN: fn() = panic_on_zero::<N>;
    }
    
    impl FooImpl<0> for () {
        const FN: fn() = || {};
    }
    
    fn foo<const N: usize>() -> fn() {
        <() as FooImpl<N>>::FN
    }
    
    fn main() {
        let _fun = foo::<0>();
    }
  2. Lokathor commented on Mar 7, 2024

    @Lokathor
    Contributor

    That is significantly harder to understand/review.

  3. the8472 commented on Mar 7, 2024

    @the8472
    MemberAuthor

    That looks like the same kind of unification as the const expr issue that I linked to.

  4. oli-obk commented on Mar 7, 2024

    @oli-obk
    Contributor

    I'm a big proponent of pattern types, but I have to agree this is not a good use case for it.

    On the topic at hand, do you want a generic_const_exprs like scheme where this shows up in where bounds and needs to have where bounds all the way up to where it is monomorphic? Or do you want this to be a post monomorphization error explicitly? Or well, in the case of the example, a way to avoid post monomorphization errors?

  5. the8472 commented on Mar 7, 2024

    @the8472
    MemberAuthor

    Oh, having the issue written like this (returning a function pointer) I now realize that anonymous consts + function pointers might help, if the optimizer plays along.

    I.e.

    fn foo<const N: usize>() -> fn() {
        const {
           if N > 0 { 
              panic_on_zero::<N>
           } else {
              || {} // fallback
           }
        } 
    }

    But this is not as ergonomic as a real const-if because it means callers needs to hide the dead branches inside functions that get selected by a const block.

    At least the playground doesn't complain.

    Edit: #3582 (comment) says that this will stop working in the future.

  6. Jules-Bertholet commented on Mar 7, 2024

    @Jules-Bertholet
    Contributor

    That is significantly harder to understand/review.

    I fully agree, but if pattern types are added to the language for other reasons anyway, then perhaps the ergonomics improvement alone wouldn't justify a separate dedicated const if. (I'm not arguing for this claim, just raising the possibility.)

    I would also note, that with pattern types as const generic parameters, much of what is now implemented as post-mono assertions could become pre-mono checks, eg:

    // Now checked pre-mono
    fn require_nonzero<const N: usize in 1..>() {}  
    
    trait FooImpl<const N: usize> {
        const FN: fn();
    }
    
    impl<const N: usize in 1..> FooImpl<N> for () {
        const FN: fn() = require_nonzero::<N>;
    }
    
    impl FooImpl<0> for () {
        const FN: fn() = || {};
    }
    
    fn foo<const N: usize>() -> fn() {
        <() as FooImpl<N>>::FN
    }
    
    fn main() {
        let _fun = foo::<0>();
    }

    A hypothetical const match could then improve the ergonomics:

    // Now checked pre-mono
    fn require_nonzero<const N: usize in 1..>() {} 
    
    fn foo<const N: usize>() -> fn() {
        const match N {
            N_1_PLUS @ 1.. => require_nonzero::<N_1_PLUS>,
            0 => || {},
        }
    }
    
    fn main() {
        let _fun = foo::<0>();
    }
  7. Lokathor commented on Mar 7, 2024

    @Lokathor
    Contributor

    Well, once there's const match then there's not a strong argument against const if, since it would seem that const if is a strict subset of const match.

  8. the8472 commented on Mar 7, 2024

    @the8472
    MemberAuthor

    Imo that's just restating the problem. In this context pattern types are ~isomorphic to generic_const_exprs with
    Conditional<{(1..).contains(N)}>. And here too impls may very well only exist for some pattern of N and not all N, meaning more-generic caller faces the same issue.
    And const-match is the ~same solution to the problem.

  9. Jules-Bertholet commented on Mar 7, 2024

    @Jules-Bertholet
    Contributor

    Pattern types are ~isomorphic to generic_const_exprs with Conditional<{(1..).contains(N)}>.

    The advantage of the pattern types approach specifically is that the compiler can do exhaustiveness, disjointness etc checking, so rust-lang/project-const-generics#26 is potentially tractable even for impls involving them. With arbitrary generic_const_exprs, you need a full SMT solver or post-mono errors

  10. oli-obk commented on Mar 7, 2024

    @oli-obk
    Contributor

    I think even if we come up with a perfect where bound based system, having an escape hatch that you explicitly need to opt into is good.

    It may be easier on the compiler implementation side to require a const if expression to be of function pointer type and its branches to only be path expressions. So basically sugar for

    #[rustc_arg_required_const(0)]
    #[rustc_intrinsic]
    fn const_if<A, R>(cond: bool, on_true: fn(A) -> R, on_false: fn(A) -> R) -> fn(A) -> R;

    where, due to the magical nature of rustc_arg_required_const being an explicit hack (that we already have for legacy SIMD reasons) where the argument must be const, we can do expressions without triggering generic_const_exprs checks.

  11. the8472 commented on Mar 7, 2024

    @the8472
    MemberAuthor

    It may be easier on the compiler implementation side to require a const if expression to be of function pointer type and its branches to only be path expressions. So basically sugar for

    #[rustc_arg_required_const(0)]
    #[rustc_intrinsic]
    fn const_if<A, R>(cond: bool, on_true: fn(A) -> R, on_false: fn(A) -> R) -> fn(A) -> R;

    Would that mean less work for the compiler compared to #3582 (comment)

  12. oli-obk commented on Mar 7, 2024

    @oli-obk
    Contributor

    Would that mean less work for the compiler compared to #3582 (comment)

    that only works because rust-lang/rust#112879 hasn't landed in forever because it's such a massive perf regression

  13. the8472 commented on Mar 7, 2024

    @the8472
    MemberAuthor

    I'll cite that as another reason to have const-if then.

  14. tgross35 commented on Mar 8, 2024

    @tgross35
    Member

    This seems like a component of something more generally usable, along the lines of Zig's comptime type operations. That is, if we support branching and pruning on const generics, it would be cohesive to allow behavior based on generic types (if T == u32 { ... }) - which we may or may not want.

    Pattern types do indeed make selecting behavior more complicated, but something like a pattern type in the where bounds does seem more consistent with generic types if the intent is simply that compiling fails in certain conditions.

    fn foo<const N: usize>() -> fn() 
    where N == 0
    {
        || {} // fallback
    }
  15. added
    T-langRelevant to the language team, which will review and decide on the RFC.
    on Mar 17, 2024
  16. 8 remaining items

  17. the8472 commented on May 8, 2024

    @the8472
    MemberAuthor

    I have already used rust-lang/rust#122301 to optimized compile performance and would use const if instead if it were available.

    Do you have links to those PRs?

    rust-lang/rust#122785 to prove that it's useful. I have not yet added more because I wasn't sure how shaky that foundation is.

    Also, even const if cannot protect monomorphic functions from being "monomorphized" (in the sense of having their consts evaluated).

    Would that also be true for crate-private methods?
    The cases I have in mind involve generic callees.
    I could imagine a case where someone wants to use it as a sort of late cfg. E.g. to select code-paths based on size_of or other compiletime calculations and not having to deal with asserts from the unused impls could be useful. But if that does arise I guess we could tell them to add a phantom generic param.

  18. RalfJung commented on May 8, 2024

    @RalfJung
    Member

    rust-lang/rust#122785 to prove that it's useful. I have not yet added more because I wasn't sure how shaky that foundation is.

    Lol that landed a day after rust-lang/rust#122568. ^^

    @saethlin's rust-lang/rust#123272 landed later. I can't tell how much of an effect rust-lang/rust#121421 had without that later fix. It may be worth trying to revert rust-lang/rust#122785 to see if it still makes a difference -- it should not make a difference in terms of how much we codegen, and I would be somewhat surprised if just the traversal of "mentioned" items makes such a difference.

    Would that also be true for crate-private methods?

    Yes. The initial reachability analysis is necessarily conservative and there's no good way to exclude any monomorphic function from being considered. We can do our best to fix issues like rust-lang/rust#119214 but it's not possible to fix them all (without completely re-thinking how we handle monomorphic items that "may or may not" be needed by downstream crates).

    As I said, I think we should actually do item collection starting with all monomorphic items as roots, and const blocks in monomorphic items should be considered like const items (which we also always evaluate). The only way I see to fix the problem of "we sometimes do evaluate them and sometimes don't" (rust-lang/rust#122814) is to always evaluate them.

    But if that does arise I guess we could tell them to add a phantom generic param.

    ... until polymorphization optimizes away the pram and codegen's the function anyway. ;)

  19. the8472 commented on May 9, 2024

    @the8472
    MemberAuthor

    It may be worth trying to revert rust-lang/rust#122785 to see if it still makes a difference

    rust-lang/rust#124907 the difference is tiny now

  20. RalfJung commented on May 9, 2024

    @RalfJung
    Member

    Thanks for checking!

  21. CAD97 commented on May 26, 2024

    @CAD97

    I think we'd prefer to avoid a situation where const if condition { … } and if (const { condition }) { … } are both valid but have subtly different guarantees, especially if common compilation modes skip monomorphization of dead arms of the latter as an optimization. Avoiding this is one of two reasons I typically spell this as static if instead.

    The second is that there's a desire for a construct which guards not just post-mono asserts but also pre-mono bounds. In an ideal future, I'd like to be able to have a function declare a pre-mono bound of e.g. where const { N > 0 } and still be able to call it from a function without that bound by handling the fallback case. Translating the pattern refinement types example:

    fn require_nonzero<const N: usize>()
    where
        const { N > 0 },
    {
    }
    
    fn foo<const N: usize>() {
        static if N > 0 {
            // this scope checked with the added bound of
            // where const { N > 0 }
            // (purely syntactic resolution, no SMT solving)
            require_nonzero::<N>();
        } else {
            // ideally only instantiated when bound is false
            // allowing even compile_error! as a fallback
            // but also ideally still otherwise typechecked
            fallback();
        }
    }

    Further overloading the meaning of static isn't great, but neither is overloading the meaning of const to include controlling code instantiation.

    const match as used above functions by rebinding a new, more tightly bound const name. static if would function by a kind of refinement typing, introducing additional generic bounds to the controlled scope.

    This is wishful thinking, to be sure, but despite the details being hairy, the base concept is straightforward (and basically just a port of the C++ constexpr if semantics to Rust generics).

  22. the8472 commented on May 30, 2024

    @the8472
    MemberAuthor

    The second is that there's a desire for a construct which guards not just post-mono asserts but also pre-mono bounds. In an ideal future, I'd like to be able to have a function declare a pre-mono bound of e.g. where const { N > 0 } and still be able to call it from a function without that bound by handling the fallback case.

    Yes, this is critical for the libs usecase. Without it we can't promote a const assert to a bound later on.

  23. bstrie commented on Jul 18, 2024

    @bstrie
    Contributor

    Further overloading the meaning of static isn't great, but neither is overloading the meaning of const to include controlling code instantiation.

    I don't think we need to burden either const or static with this unfortunate overload. For that matter, we don't need to burden if with the complexity of handling both branching and code instantiation.

    We already have where for this use case, so I'd suggest it makes more sense just to allow where blocks in these positions for selective code instantiation:

    fn foo<const N: usize>() {
        where N > 0 {
            require_nonzero::<N>();
        } else {
            fallback();
        }
    }

    This seems like a natural and straightforward extension that doesn't conflict with the existing semantics of where.

  24. cramertj commented on Jan 17, 2025

    @cramertj
    Member

    We already have where for this use case

    Personally, I'd expect the code following where to allow type bounds, not just const bounds, but obviously that triggers all the typical issues with specialization.

    e.g.

    fn maybe_dbg<T>(x: T) -> T {
        where T: Debug { println!("{:?}", x); }
        x
    }
  25. CAD97 commented on Jan 17, 2025

    @CAD97

    My current thinking is also that an expression-level where, if allowed, should syntactically take a where-clause the same as item-level where, even if (most) type bounds aren't specializable this way. However, if where const is a thing, that opens up the fairly natural looking composed syntax of:

    fn require_nonzero<const N: usize>()
    where
        const { N > 0 },
    {
        // ...
    }
    
    fn foo<const N: usize>() {
        where
            const { N > 0 },
        {
            require_nonzero::<N>();
        } else {
            fallback();
        }
    }
  26. deline-oss commented on Jan 17, 2025

    @deline-oss

    sorry for my ignorance but why we need new construct for pruning branches at compile-time when we could reuse existing inline consts like:

    if const { T::ASSOC }  /* or const { expensive_const_fn(10, 20) } */  {
        /// dont monophorsize this block if ASSOC is false
    }
    if T::ASSOC {} // this wont get same behavior as ^

    it seems everyone mentioning some new ways to achieve it but no one explained why simplest solution wont cut it

  27. RalfJung commented on Jan 17, 2025

    @RalfJung
    Member

    We generally guarantee that all items mentioned in a function get monomorphized. We could add special hacks that exempt items mentioned inside a if const, but that would be exactly that -- a hack, giving magic meaning to a composition of two constructs that neither construct has in isolation. It would also be confusing since your example would in fact not hit that case: you are not using if const but if or (const, const) (if we replace || by prefix syntax). We could extend the hack further but it should be clear that this is not going to produce a nice end state.

  28. deline-oss commented on Jan 17, 2025

    @deline-oss

    your example would in fact not hit that case: you are not using if const but if or (const, const) (if we replace || by prefix syntax).

    sorry it was badly crafted example, it would prune only based on if const {}, not arbitrary if const {} || const {}, edited.
    It seems like there are actually 2 use-cases:

    • completely prune block(like it doesnt exist) based on const {} (i think zig has this feature where it doesnt typecheck or even emit ir for comptime known false branches)
    • recursively suppress evaluation of any const blocks inside branch but still borrowcheck/typecheck it
  29. bstrie commented on Mar 20, 2025

    @bstrie
    Contributor

    I want to ask a clarifying question to make sure we're all on the same page here.

    So far it looks like many here are focusing on the use case of monomorphization/const generics.

    But I think a few others are also thinking about providing a general Zig-style method for conditionally compiling code (skipping all typechecking of untaken branches in the process) based on arbitrary compile-time evaluation (const fn or otherwise).

    So the question is, does anyone foresee any problem with having a single mechanism that provides both?

    // Alternative to const-if crate
    fn foo() {
        where cfg!(windows) {
            this_only_compiles_on_windows();
        } else {
            platform_independent_fallback();
        }
    }
    
    // Arbitrary wacky shenanigans
    fn bar() {
        where eval!{
            // Shell some code out to a different rustc
            // process at compile-time, because why not
            let score = launch_tetris_and_get_player_score();
            score > 200
        }{
            do_the_thing();
        } else {
            compiler_error!("Error: Too bad at Tetris");
        }
    }

    In particular, are there any problems with allowing this feature to accept the result of evaluating arbitrary constant expressions, rather than specifically const generics?

  30. bstrie commented on Mar 22, 2025

    @bstrie
    Contributor

    Personally, I'd expect the code following where to allow type bounds, not just const bounds, but obviously that triggers all the typical issues with specialization.

    @cramertj Can you elaborate on what issues with specialization that example would cause? AIUI this feature wouldn't impact trait resolution, so I don't see what the problem is with allowing the generic metadata to instantiate differently based on the concrete type.

  31. cramertj commented on Mar 22, 2025

    @cramertj
    Member

    @bstrie #3582 (comment)

    One of the easiest specialization soundness demos is rust-lang/rust#40582:

    #![feature(specialization)]
    
    trait FromRef<'a, T: ?Sized> {
        fn from_ref(r: &'a T) -> Self;
    }
    
    impl<'a, T: ?Sized, R> FromRef<'a, T> for R {
        default fn from_ref(_: &'a T) -> Self {
            unimplemented!()
        }
    }
    
    impl<'a, T: ?Sized> FromRef<'a, T> for &'a T {
        fn from_ref(r: &'a T) -> Self {
            r
        }
    }
    
    fn main() {
        let s = "specialization".to_owned();
        println!("{:?}", make_static(s.as_str()));
    }
    
    fn make_static<T: ?Sized>(data: &T) -> &'static T {
        fn helper<T: ?Sized, R>(data: &T) -> R {
            R::from_ref(data)
        }
        helper(data)
    }

    This prints "specialization", when it should panic. The lifetime of the R being produced ('static) does not match that of the input ref. My understanding is that helper is monomorphized in a way that does not respect the lifetime bounds on the specialized impl of from_ref, so it chooses the specialized impl (which should not be applicable) rather than the default impl.

    Inline where has, I think, the same problem-- it just allows us to remove the default impl of FromRef:

    trait FromRef<'a, T: ?Sized> {
        fn from_ref(r: &'a T) -> Self;
    }
    
    impl<'a, T: ?Sized> FromRef<'a, T> for &'a T {
        fn from_ref(r: &'a T) -> Self { r }
    }
    
    fn main() {
        let s = "specialization".to_owned();
        println!("{:?}", make_static(s.as_str()));
    }
    
    fn make_static<T: ?Sized>(data: &T) -> &'static T {
        fn helper<'a, T: ?Sized + 'a, R>(data: &'a T) -> R {
            where R: FromRef<'a, T> { R::from_ref(data) } else { unimplemented!() }
        }
        helper(data)
    }

    My understanding is that when helper is monomorphized, it will incorrectly ignore lifetime bounds and will choose the where branch rather than the else branch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    T-langRelevant to the language team, which will review and decide on the RFC.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions