Тесты производительности

Rust поддерживает тесты производительности, которые помогают измерить производительность вашего кода. Давайте изменим наш src/lib.rs, чтобы он выглядел следующим образом (комментарии опущены):

#![feature(test)]

extern crate test;

pub fn add_two(a: i32) -> i32 {
    a + 2
}

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

    #[test]
    fn it_works() {
        assert_eq!(4, add_two(2));
    }

    #[bench]
    fn bench_add_two(b: &mut Bencher) {
        b.iter(|| add_two(2));
    }
}

Обратите внимание на включение возможности (feature gate) test, что включает эту нестабильную возможность.

Мы импортировали контейнер test, который включает поддержку измерения производительности. У нас есть новая функция, аннотированная с помощью атрибута bench. В отличие от обычных тестов, которые не принимают никаких аргументов, тесты производительности в качестве аргумента принимают &mut Bencher. Bencher предоставляет метод iter, который в качестве аргумента принимает замыкание. Это замыкание содержит код, производительность которого мы хотели бы протестировать.

Запуск тестов производительности осуществляется командой cargo bench:

$ cargo bench
   Compiling adder v0.0.1 (file:///home/steve/tmp/adder)
     Running target/release/adder-91b3e234d4ed382a

running 2 tests
test tests::it_works ... ignored
test tests::bench_add_two ... bench:         1 ns/iter (+/- 0)

test result: ok. 0 passed; 0 failed; 1 ignored; 1 measured

Все тесты, не относящиеся к тестам производительности, были проигнорированы. Вы, наверное, заметили, что выполнение cargo bench занимает немного больше времени чем cargo test. Это происходит потому, что Rust запускает наш тест несколько раз, а затем выдает среднее значение. Так как мы выполняем слишком мало полезной работы в этом примере, у нас получается 1 ns/iter (+/- 0), но была бы выведена дисперсия, если бы был один.

Советы по написанию тестов производительности:

  • Внутри iter цикла пишите только тот код, производительность которого вы хотите измерить; инициализацию выполняйте за пределами iter цикла
  • Внутри iter цикла пишите код, который будет идемпотентным (будет делать «то же самое» на каждой итерации); не накапливайте и не изменяйте состояние
  • Вне iter цикла пишите код который также будет идемпотентным; скорее всего, он будет запущен много раз во время теста
  • Внутри iter цикла пишите код, который будет коротким и быстрым, так чтобы запуски тестов происходили быстро и калибратор мог настроить длину пробега с точным разрешением
  • Внутри iter цикла пишите код, делающий что-то простое, чтобы помочь в выявлении улучшения (или уменьшения) производительности

Особенности оптимизации

А вот другой сложный момент, относящийся к написанию тестов производительности: тесты, скомпилированные с оптимизацией, могут быть значительно изменены оптимизатором, после чего тест будет мерить производительность не так, как мы этого ожидаем. Например, компилятор может определить, что некоторые выражения не оказывают каких-либо внешних эффектов и просто удалит их полностью.

#![feature(test)]

extern crate test;
use test::Bencher;

#[bench]
fn bench_xor_1000_ints(b: &mut Bencher) {
    b.iter(|| {
        (0..1000).fold(0, |old, new| old ^ new);
    });
}

выведет следующие результаты

running 1 test
test bench_xor_1000_ints ... bench:         0 ns/iter (+/- 0)

test result: ok. 0 passed; 0 failed; 0 ignored; 1 measured

Движок для запуска тестов производительности оставляет две возможности, позволяющие этого избежать. Либо использовать замыкание, передаваемое в метод iter, которое возвращает какое-либо значение; тогда это заставит оптимизатор думать, что возвращаемое значение будет использовано, из-за чего удалить вычисления полностью будет не возможно. Для примера выше этого можно достигнуть, изменив вызова b.iter

# struct X;
# impl X { fn iter<T, F>(&self, _: F) where F: FnMut() -> T {} } let b = X;
b.iter(|| {
    // note lack of `;` (could also use an explicit `return`).
    (0..1000).fold(0, |old, new| old ^ new)
});

Либо использовать вызов функции test::black_box, которая представляет собой «черный ящик», непрозрачный для оптимизатора, тем самым заставляя его рассматривать любой аргумент как используемый.

#![feature(test)]

extern crate test;

# fn main() {
# struct X;
# impl X { fn iter<T, F>(&self, _: F) where F: FnMut() -> T {} } let b = X;
b.iter(|| {
    let n = test::black_box(1000);

    (0..n).fold(0, |a, b| a ^ b)
})
# }

В этом примере не происходит ни чтения, ни изменения значения, что очень дешево для малых значений. Большие значения могут быть переданы косвенно для уменьшения издержек (например, black_box(&huge_struct)).

Выполнение одного из вышеперечисленных изменений дает следующие результаты измерения производительности

running 1 test
test bench_xor_1000_ints ... bench:       131 ns/iter (+/- 3)

test result: ok. 0 passed; 0 failed; 0 ignored; 1 measured

Тем не менее, оптимизатор все еще может вносить нежелательные изменения в определенных случаях, даже при использовании любого из вышеописанных приемов.