rustoptimizationclonebenchmarkingstatic-analysis

Is there a way to find "hidden" unused variables in Rust?


The Rust linter is great at picking up unused variables. However, I'm interested in variables, where in certain conditions (release mode, features) aren't used.

A simple example is something like this:

pub struct Thing {
  pub values: [u64; 5],
}

pub fn do_stuff(thing: &mut Thing) {
  let before = thing.clone();
  thing.values[0] = 1;
  tracing::debug!("before: {:?}, after: {:?}", before, thing);
}

In debug mode, we'd like to see the changes in do_stuff. But in production we turn on tracing/release_max_level_warn to avoid a bunch of debug printing (important in performance-critical sections). The linter obviously doesn't mention that before is unused, since in some cases it is used.

Essentially, is that .clone() call eventually optimized out in release builds? Or is there a way to find unused variables like "before" when taking into consideration feature flags?

Here is a benchmark that looks like it doesn't get optimized out (even just as a unused var, none of the feature confusion)? I used a Box so that it couldn't just be a memcopy, but I'm terrible at benchmarks, so I'd love to be wrong here.

#[derive(Clone, Debug)]
pub struct Thing {
  pub values: Vec<Box<u64>>,
}

#[inline(never)]
pub fn cloned(thing: &mut Thing) {
  let before = thing.clone();
  thing.values[0] = Box::new(2);
}

#[inline(never)]
pub fn uncloned(thing: &mut Thing) {
  thing.values[0] = Box::new(2);
}


#[cfg(test)]
mod tests {
    use super::test::Bencher;

    #[bench]
    fn bench_clone_test(b: &mut Bencher) {
      let mut thing = crate::Thing {
        values: vec![Box::new(1); 1000],
      };
    
      b.iter(|| crate::cloned(&mut thing));
    }

    #[bench]
    fn bench_uncloned_test(b: &mut Bencher) {
      let mut thing = crate::Thing {
        values: vec![Box::new(1); 1000],
      };
    
      b.iter(|| crate::uncloned(&mut thing));
    }
}

Results:

test tests::bench_clone_test    ... bench:      54,280.90 ns/iter (+/- 241.36)
test tests::bench_uncloned_test ... bench:          31.91 ns/iter (+/- 0.39)

Solution

  • The variables passed to tracing::debug! are considered "used" even when disabled by feature because the macro expands to something like this:

    if tracing::Level::DEBUG <= tracing::level_filters::STATIC_MAX_LEVEL ... {
        ...
    }
    

    which the compiler considers is still used even when the condition is constant.

    To really avoid that code being compiled, it needs to be hidden behind a #[cfg] attribute or cfg! macro. This could be done by the tracing macro, but there is an unwritten rule that errors and warnings specific to a profile or target should be avoided.

    To get this behavior for yourself, you could make your own macros that proxy to the tracing macros wrapped with #[cfg(debug_assertions)] (which is an imprecise but practical way to detect release builds). Here's that working in action:

    #[derive(Debug, Clone)]
    pub struct Thing {
        values: [u64; 5],
    }
    
    macro_rules! debug {
        ($($args:tt)*) => {
            #[cfg(debug_assertions)] { tracing::debug!($($args)*) }
        };
    }
    
    pub fn do_stuff(thing: &mut Thing) {
        let before = thing.clone();
        thing.values[0] = 1;
        debug!("before: {:?}, after: {:?}", before, thing);
    }
    

    Building this code with --release will emit a warning:

    warning: unused variable: `before`
      --> src/lib.rs:13:9
       |
    13 |     let before = thing.clone();
       |         ^^^^^^ help: if this is intentional, prefix it with an underscore: `_before`
       |
       = note: `#[warn(unused_variables)]` on by default