Бенчмаркинг, преобразования String в &str и обратно

Добрый день. Разбираюсь со строками в Rust, возник следующий вопрос: есть код с 2-я функциями calculator - одна принимает String, другая соответственно - &str. Так же бенчмарки к ним с разной вариацией передачи внутри бенчмарка в эти функции параметров:

#[bench]
fn bench_calculator(b: &mut Bencher) {
    b.iter(|| calculator("+-*/", 8));
}

#[bench]
fn bench_calculator_with_string(b: &mut Bencher) {
    b.iter(|| calculator_with_string("+-*/".to_string(), 8));
}

#[bench]
fn bench_calculator_with_preconverted_string(b: &mut Bencher) {

    b.iter(||
        {
            let preconverted_string = "+-*/".to_string();
            calculator_with_string(preconverted_string, 8)
        }
    );
}

#[bench]
fn bench_calculator_with_string_to_str(b: &mut Bencher) {
    b.iter(||
    {
        let preconverted_string = "+-*/".to_string();
        calculator(&preconverted_string, 8);
    });
}

#[bench]
fn bench_calculator_with_string_to_precompiled_str(b: &mut Bencher) {
    b.iter(||
    {
        let preconverted_string = "+-*/".to_string();
        let preconverted_str = &preconverted_string;
        calculator(preconverted_str, 8);
    });
}

Результат бенчмарков таков:

Executing: cargo bench --verbose
Fresh hello_world v0.1.0 (file:///D:/Projects/Rust/hello_world)
Running D:\Projects\Rust\hello_world\target\release\hello_world-8986e24de7249bbb.exe --bench

running 5 tests
test tests::bench_calculator … bench: 7 ns/iter (+/- 0)
test tests::bench_calculator_with_preconverted_string … bench: 90 ns/iter (+/- 10)
test tests::bench_calculator_with_string … bench: 90 ns/iter (+/- 12)
test tests::bench_calculator_with_string_to_precompiled_str … bench: 85 ns/iter (+/- 3)
test tests::bench_calculator_with_string_to_str … bench: 85 ns/iter (+/- 2)

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

Каким образом нужно написать код в bench, чтобы оптимизировать работу bench программы там где происходит преобразование String в &str? Единственное, что приходит в голову - это вынести инициализацию связанного имени строки за пределы цикла b.iter(…); но как это передать в замыкание? И ускорит ли это работу бенчмарка?

У тебя bench_calculator_with_preconverted_string и bench_calculator_with_string идентичны,
равно как bench_calculator_with_string_to_precompiled_str и bench_calculator_with_string_to_str
(что видно по таймингам). Сейчас из-за выделения памяти ты реально тестируешь свою функцию только в первом тесте, в остальных ты тестируешь скорость аллокатора.

Зачем вообще нужно морочиться с выделением памяти под строки для calculator(&str)? Что статичная строка, что нет — без разницы. Или хочется в рантайме генерировать строку для вычисления? Тогда создавай строку вне b.iter(||), а внутри используй срез от строки.

С calculator_with_string(String) сложнее, нужно каждый раз создавать клон строки, если её инициировать снаружи b.iter(||), что в принципе аналогично построению строки заново (String::from("abc") занимает примерно то же время, что и s.clone(), т.к. происходит выделение памяти под строку и копирование строки), так что и выносить создание строки за b.iter(||) смысла нет. Разве что алгоритм генерации строки достаточно сложный, тогда можно и вынести, а в теле замыкания просто делать s.clone(). Bencher::iter() принимает F: FnMut, поэтому передавать владение строкой в замыкание не надо, а вызов clone() владения не требует, так что будет работать на ура.

Другое дело, что я не вижу никакого смысла в существовании функции calculator_with_string(String). Я же правильно понимаю, что эта функция строку не модифицирует? Получается только лишняя передача владения, что не идиоматично и не эргономично.

P.S. Да, и попробуй заменить "abc".to_string() на String::from("abc") или "abc".to_owned(), должно стать быстрее.

2 лайка

На самом деле цель не тестирования функции как таковой, а изучение разницы в скорости между работой со String, &str. А так же в том, как оптимизации компилятора могут уменьшить объём выделяемой памяти в случае им мутабельности используемого в b.iter(||) связанного имени. Вот результаты работы from и to_owned :

#[bench]
fn bench_calculator_with_string_from(b: &mut Bencher) {
    b.iter(|| {
        calculator_with_string(String::from("+-*/"), 8);
    });
}

#[bench]
fn bench_calculator_with_owned_str(b: &mut Bencher) {
    b.iter(|| {
        calculator_with_string("+-*/".to_owned(), 8);
    });
} 

Результаты:

test tests::bench_calculator_with_string_from … bench: 59 ns/iter (+/- 6)
test tests::bench_calculator_with_owned_str … bench: 68 ns/iter (+/- 2)

Да, действительно оно работает быстрее.
Как можно видеть практически идентичны учитывая указанные погрешности между итерациями.

Меня больше интересует возможен ли случай когда оптимизатор поймёт, что строка статична и выделит под неё память лишь раз?(и это без выноса объявления связанного имени строки вне цикла с последующей её передачей) Ведь в текущем варианте программа выделяет для каждого вызова замыкания память для &str в своём отдельном стеке? Если нет, то поправьте.

P.S Есть ли встроенная возможность отслеживать, сколько было выделено памяти в среднем по всем итерациям? Подскажите каким образом, я мог бы включить подобную “профилировку”.

P.S.S В предыдущих бенчмарках я делал вынос объявления строки до передачи её в calculator, чтобы узнать заработает ли оно быстрее, указывая явно, что строка не изменяема.

Разве что на уровне LLVM, компилятор раста, насколько мне известно, такие оптимизации не делает.
Статический литерал да, должен заинтерниться и существовать в одном экземпляре, но выделение памяти под String, будучи явным, должно происходить всегда по запросу.

Проверить можно, заглянув в сгенерированный IR.

1 лайк