How do I know a const fn is executing in const context

I want to execute different codes in const context and non-const context for better optimization.

Such as:

const fn const_add(a: u32) -> u32 {
    a + 1
}

fn non_const_add(a: u32) -> u32 {
    let mut rng = rand::thread_rng();
    let n: u32 = rng.gen();
    a + n
}

fn foo(a: u32) -> u32 {
    if is_const_val(a) {
        return const_add(a);
    } else {
        return non_const_add(a);
    }
}

The const keyword is unrelated to optimizations. It is only important for expressions that must be evaluated at compile time such as the value of a const. The optimization that simplifies constant expressions will run regardless of whether or not the function is marked const.

3 Likes

If some values can be determined at compile time, Other values depend on it can be fine-tuned by invoking special commands.

For example, I have a bit array, and I want to set a continuous block of bits according to two values start and length.

fn set_bits(start: usize, len: usize) {
    if is_const(start) && start % 8 == 0 && is_const(len) && len % 8 == 0 {
        memset(array + start, 1, start + len);
    } else {
        // set bits one by one.
    }
}

If start and len can be determined at compiling time, and their values are propley aligned to 8. Then
I can diretly use memset() function. Other wilse, I have to set bits one by one by.

If you want different behavior at compile time versus run time, just use different functions. But optimizing the compile-time version of a function is of extremely limited benefit and not practical.

I'm wondering if there's a misunderstanding of Rust const here, as I don't see why the constness of the parameters matters in the example. Additionally, libc::memset is both unsafe and not const (i.e. your example can't work in a const context).

Incidentally,

  • [_]::fill is the safe variant (also not const though)
  • Rust raw pointers use offset for pointer math, not +
  • I think you meant memset(array + start/8, 255, len/8) as well
1 Like

If you want different behavior at compile time versus run time, just use different functions.

I want to provide a consistent interface to my user, that is a concern.

I'm wondering if there's a misunderstanding of Rust const here, as I don't see why the constness of the parameters matters in the example.

To my understanding, const value/expression can be evaluated during compile-time, and based on this we can take optimization during compiling other than runtime.

Take the example below:

const START: usize = 8;
const LEN: usize = 16;

fn set_bit(arr: &mut [u8], start: usize, len: usize) {
    if start % 8 == 0 && len % 8 == 0 {
        let s = start / 8;
        let e = s + (len / 8);
        arr[s..e].fill(1);
        return
    }

    if len > 8 {
        let start = ((start + 8) / 8) as usize * 8;
        let len = (len / 8) as usize * 8;
        set_bit(arr, start, len);
    }
        
    if start % 8 != 0 {
        let first_byte_mask = !(0 as u8) << (start % 8);
        arr[start / 8] = first_byte_mask;
    }
    
    let end = start + len;
    if end % 8 != 0 {
        let last_byte_mask = !(0 as u8) >> (8 - (end % 8));
        arr[(start + len) / 8] = last_byte_mask;
    }
}

fn main() {
    let mut arr = [0 as u8; 16];
    dbg!(START);
    set_bit(&mut arr, START, LEN);
    dbg!(arr);

    let start = 55;
    let len = 10;
    set_bit(&mut arr, start, len);
    dbg!(arr);
}

When set_bit(&mut arr, START, LEN); is invoked, the compiler exactly knows the start position and length, and can directly inline this function by a simple memset(arr + START, 0xFF, LEN) function.

And the the second call to set_bit(&mut arr, start, len);, the compiler doesn't know the start and len at compile time, and can do a function call as normal.

So, after this optimisation. The main() function will be something like this:

fn main() {
    let mut arr = [0 as u8; 16];
    dbg!(START);
    memset(arr + START, 0xff, LEN);
    dbg!(arr);

    let start = 55;
    let len = 10;
    set_bit(&mut arr, start, len);
    dbg!(arr);
}

Additionally, libc::memset is both unsafe and not const (i.e. your example can't work in a const context).

I use memset() function here for conception proven. What I mean here is that the compiler can simply expand this code snippet to a rep stosb in assembly instead of complex instructions.

After disassembling the generated binary, the compiler generates:

 7931:       0f 28 05 c8 c6 02 00    movaps 0x2c6c8(%rip),%xmm0        # 34000 <_fini+0xac>
 7938:       0f 11 84 24 81 00 00    movups %xmm0,0x81(%rsp)

The behavior seems as expected.

But, I still wonder what I can do to tell the compiler what the result we expect. For a very complex scenario, I don't very sure if the compiler is smart enough to make optimization as expect.

I understand the motivation better now. A rough answer is "you don't detect how much const is being used, the compiler does -- and hopefully you get the optimizations you want." So for this example, maybe you end up with something like

fn use_the_defaults() -> Vec<u8> {
    let mut v = vec![...];
    // Hopefully this gets inlined
    set_bit(&mut v, START, LEN);
    v
}

fn use_something_else(start: usize, len: usize) -> Vec<u8> {
    let mut v = vec![...];
    // Maybe this gets inlined but we don't care as much
    set_bit(&mut v, start, len);
    v
}

You can guide the compiler to some extent by using #[inline] annotations (example below). I don't know of a direct way to test if you're in a const context -- and as const functions are supposed to act exactly the same at runtime as at compile time, I doubt the Rust teams would be willing to guarantee such a mechanism as such. Some way to detect which parameters happened to be const would also break the "function signature is the API" barrier. Maybe some day there could be some form of specialization by specifying ?const on the parameters or something. But I don't think we're anywhere close to that today.

A potential inlining example:

#[inline(always)]
fn bit_set_byte_boundaries(bytes: &mut [u8], offset_in_bytes: usize, len_in_bytes: usize) {
    bytes[offset_in_bytes..(offset_in_bytes + len)].fill(255);
}

// Maybe make this `unsafe`...
#[inline(always)]
fn bit_set_assume_aligned_unchecked(bytes: &mut [u8], offset_in_bits: usize, len_in_bits: usize) {
    bit_set_byte_boundaries(bytes, offset_in_bits / 8, len_in_bits / 8);
}

// Maybe #[inline] if you really need to :-|
fn bit_set(bytes: &mut [u8], offset_in_bits: usize, len_in_bits: usize) {
    // Check the offsets, call `bit_set_assume_aligned_unchecked(...)`
    // when aligned, otherwise loop or handle the non-aligned ends
    // first, etc
}

/*
** Elsewhere
*/

fn use_the_defaults() -> Vec<u8> {
    let mut v = vec![...];
    // Hopefully this gets inlined
    //set_bit(&mut v, START, LEN);
    // EDIT: It didn't :-(  So force the issue since measurement
    // showed a reliable increase in performance
    bit_set_assume_aligned_unchecked(&mut v, START, LEN);
    v
}

It's true, the optimizers don't always catch everything we wish they did. There's #[inline] and other attributes you can hint with. For your own compilations, you can look also into whatever optimization tuning flags Rust has. You can look into the flags LLVM has and set those too. If it really matters, the closest I can think of to "tell the compiler what [asm] we expect" is to make a test and check the output. (I haven't done this myself, but I know rustc has them, and apparently these are the docs on how to do it.)

I wouldn't do any of these unless I had a performance problem that they definitely fixed.

1 Like

Thanks quinedot, you helped a lot.

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.