Rusty Fizz buzz with some Divan

Recently I watched a Rust video by Andy Balaam covering a test-driven implementation of one brand of the Fizz buzz game. Watching it inspired me to learn about benchmarking Rust with Divan, and that's what this write up is about.

Watch the video first!

I'm not sure that this article will make much sense unless you watch Andy's video first, or possibly at the same time.

So there I was enjoying Andy's video. When he got to the part where he'd implemented his variation of Fizz buzz to the point that it passed all his unit tests he started wondering about what changes he could make to make the code more pleasing and possibly more performant.

Throughout this Andy stated repeatedly that it's always a bad idea to do performance changes without checking if they actually make things better (or even if they are needed), but his main focus was on correctness and how you might test for that as the code evolves.

I'd seen a few people use Divan for benchmarking Rust code and thought to myself, oh, as a learning exercise for Divan I could write out the different versions of Andy's implementation and try benchmarking them. So that's what I've done.

NOTE
  1. I am a novice with Rust and I've never previously actually used Divan! πŸ˜€
  2. I do realise that an implementation of Fizz buzz isn't a great thing to try to benchmark as it's rather too simple. This was mainly about learning how to use the crate.

Nevertheless, I did find out some things that interested me.

Fizz buzz implementations

In the src/lib.rs file you can see all the different implementations that Andy came up with, in the order that he discussed them in his video. Of course, in his video he was just iterating on the one implementation, but I've captured what I think were each of the key stages and kept them as separate functions so they can be evaluated together. They are:

naive()

A straightforward test of every scenario as a list of if … / else if … predicates.

mod_then_match()

A neater looking version which does all the tests first and then uses a single match block to check all possible states.

early_return_before_mod()

Like mod_then_match(), but first checks for the case where both '5' and '7' appear in the number string in order to short circuit before doing any of the modulus tests.

single_string_scan()

Like early_return_before_mod() but instead of doing multiple checks with n_str.contains(…), this version does just one scan through the string.

single_string_scan_early_fizzbuzz()

Like single_string_scan() but do a check for the "FizzBuzz" case due to character matches, to be able to sometimes avoid having to do any modulus checks.

And one more…

At the end I added one more of my own, and one variant on it. Read on!

Testing

Andy spent a lot of time covering his testing strategy, probably you could say it was the main thrust of the video. I only altered it to use the test_case crate so that I could pass in a pointer to a function that is the desired Fizz buzz implementation to be tested. That way it was easy to do all the same unit tests on every implementation.

You can see them all in the src/main.rs file, but here's an example of one of the unit tests.

    #[test_case(naive ; "using naive implementation")]
    #[test_case(mod_then_match ; "using mod first then match cases implementation")]
    #[test_case(early_return_before_mod ; "using early return before mod")]
    #[test_case(single_string_scan ; "using single string scan")]
    #[test_case(single_string_scan_early_fizzbuzz ; "using single string scan with early fizzbuzz shortcircuit")]
    fn fizzbuzz_all_counts_up_to_max(fzbz_fn: fn(i32) -> Answer) {
        let answers = fizzbuzz_all(fzbz_fn, 50);
        assert_eq!(answers[0], Number(1));
        assert_eq!(answers[4], Buzz);
        assert_eq!(answers[6], Fizz);
        assert_eq!(answers[34], FizzBuzz);
        assert_eq!(answers[35], Number(36));
        assert_eq!(answers.len(), 50);
    }

Benchmarking with Divan

Again, a reminder that Andy was at pains to point out that he didn't know if any of the changes he was making were actually making the code more performant and that wasn't the goal of his video. I was interested in that though!

The Divan crate has good instructions, but basically it's a case of adding it to Cargo.toml as a development dependency and then adding a [[bench]] section:

[dev-dependencies]
divan = "0.1.21"
test-case = "3.3.1"

[[bench]]
name = "fizzbuzz"
harness = false
INFO

The harness = false bit disables the built-in benchmarking so that Divan can take over.

The name = "fizzbuzz" part corresponds to my benches/fizzbuzz.rs file where my benchmark functions live. In that file are just a list of functions each of which calls an implementation of Fizz buzz.

#[divan::bench]
fn naive_bench() -> Vec<Answer> {
    fizzbuzz_all(naive, divan::black_box(2_000_000))
}

#[divan::bench] tells rustc that the following function is a Divan benchmark and benchmarking code should be generated. The divan::black_box(2_000_000) part avoids the compiler optimising away code that seemingly is not actually used for anything.

This is going to generate Fizz buzz for every number between 1 and 2,000,000 many many times and sample how long it took to do it, with that particular implementation (naive()).

I'm pretty sure there is a nicer way to organise that benches/fizzbuzz.rs file but this was good enough for my first try!

Results

On my (8 year old, rather slow Intel(R) Core(TM) i7-8700 CPU @ 3.20GHz) desktop computer it comes out like this:

Timer precision: 12 ns
fizzbuzz                                    fastest       β”‚ slowest       β”‚ median        β”‚ mean          β”‚ samples β”‚ iters
β”œβ”€ early_return_before_mod_bench            53.56 ms      β”‚ 65.34 ms      β”‚ 54.2 ms       β”‚ 54.66 ms      β”‚ 100     β”‚ 100
β”œβ”€ mod_then_match_bench                     56.45 ms      β”‚ 60.22 ms      β”‚ 57.23 ms      β”‚ 57.38 ms      β”‚ 100     β”‚ 100
β”œβ”€ naive_bench                              59.62 ms      β”‚ 63.31 ms      β”‚ 60.16 ms      β”‚ 60.33 ms      β”‚ 100     β”‚ 100
β”œβ”€ single_string_scan_bench                 59.59 ms      β”‚ 64.17 ms      β”‚ 60.3 ms       β”‚ 60.48 ms      β”‚ 100     β”‚ 100
╰─ single_string_scan_early_fizzbuzz_bench  56.86 ms      β”‚ 59.98 ms      β”‚ 57.65 ms      β”‚ 57.81 ms      β”‚ 100     β”‚ 100

Now, if you recall, the order in which these implementations had been thought up in the video was:

  1. naive()
  2. mod_then_match()
  3. early_return_before_mod()
  4. single_string_scan()
  5. single_string_scan_early_fizzbuzz()

The thought was that each iteration would hopefully be faster than the previous one.

When I had first got the benchmarking done, I think that due to a combination of cold CPU cache and some busy tasks on my desktop at the time (leading to varying CPU time being available), I got quite an extreme result for single_string_scan() and its variant single_string_scan_early_fizzbuzz(). It came out about 12% slower than the fastest of the other implementations, and I rather excitedly told Andy about this.

On further checking this became a bit less dramatic, however, as can be seen above. The mean time for early_return_before_mod() is 54.66 ms while the mean time for single_string_scan() is 60.48 ms. That's about 10% slower.

This is consistently reproducible on my desktop and what it means is that a for loop iterating through n_str.chars() once is slower than doing n_str.contains(…) twice! That's the only difference between those two implementations.

That last single_string_scan() version was thought to be a good place to leave it, but in fact it ended up slower than most of the other versions. I think it's a quite good real-world example of why not to try performance tuning without checking.

It's also interesting to see what happens on a faster computer. I refreshed my home fileserver's hardware within the last year so it is actually one of the newest computers I own at home (AMD Ryzen 9 7900 12-Core Processor). Results here look like:

Timer precision: 10 ns
fizzbuzz                                    fastest       β”‚ slowest       β”‚ median        β”‚ mean          β”‚ samples β”‚ iters
β”œβ”€ early_return_before_mod_bench            30.6 ms       β”‚ 42.58 ms      β”‚ 30.93 ms      β”‚ 32.37 ms      β”‚ 100     β”‚ 100
β”œβ”€ mod_then_match_bench                     28.09 ms      β”‚ 32.54 ms      β”‚ 28.94 ms      β”‚ 28.97 ms      β”‚ 100     β”‚ 100
β”œβ”€ naive_bench                              29.44 ms      β”‚ 31.55 ms      β”‚ 30.11 ms      β”‚ 30.14 ms      β”‚ 100     β”‚ 100
β”œβ”€ single_string_scan_bench                 31.63 ms      β”‚ 50.32 ms      β”‚ 31.83 ms      β”‚ 32.62 ms      β”‚ 100     β”‚ 100
╰─ single_string_scan_early_fizzbuzz_bench  31.28 ms      β”‚ 32.26 ms      β”‚ 31.53 ms      β”‚ 31.54 ms      β”‚ 100     β”‚ 100

Here the results for all implementations are much closer. Possibly there is nothing really to judge between them, performance-wise. Okay, the machines are of different vintages, but they're both AMD64 running the same version of Debian Linux and the Rust toolchain, and the workload is single-threaded.

On this newer, faster machine, mod_then_match() is consistently very slightly better than every other implementation from the video. This was only the second of five tries at improving this! It's also pleasing that it's one of the nicest to look at. There really is no point in making it look complicated if it has no bearing on the performance, right?

I suppose another way to look at it is that even the very straightforward list of if … tests (naive()) is amongst the best performers. I will guess that's because this is a pretty simple problem that the compiler ends up optimising down to very similar machine code no matter which of these you choose. I am not smart enough to prove that hypothesis.

Relight my fire

At the beginning I'd sort of expected this outcome, though I hadn't expected for c in n_str.chars() to be noticeably worse than n_str.contains(…). I'd expected the results to be very close, and they are, except for that. It still felt like a bit of an anticlimax though, and I wondered if there was anything else I could learn.

This is a very noticeably CPU-bound task. My desktop's fans go crazy while running cargo benchmark and I see that single-threaded process at 100% the whole time. I wondered what a flame graph might look like.

This turns out to be really easy with Rust.

$ sudo apt install linux-perf
$ cargo install flamegraph
$ CARGO_PROFILE_RELEASE_DEBUG=true cargo flamegraph
INFO

The CARGO_PROFILE_RELEASE_DEBUG=true causes the release build that cargo will generate and run to still include debug symbols, which give the generated graph more details. Normally release builds don't include debug symbols.

That generates a flamegraph.svg file, which I do this to:

$ sed -i 's/eeeeee/111111/g; s/eeeeb0/111100/g' flamegraph.svg

because I have some vision issues and prefer a dark background.

Let's look at the mod_then_match() implementation.

It's a good idea to click on this to view it directly in your browser. The SVG will provide detailed hover text for each function but that might be easier to see when it's the full width of your browser, and that way you can also click on a function to exclude all others.

Flame Graph Reset ZoomSearch <alloc::vec::Vec<T> as alloc::vec::spec_from_iter_nested::SpecFromIterNested<T,I>>::from_iter (4,461,869 samples, 1.53%)<alloc::vec::Vec<T,A> as alloc::vec::spec_extend::SpecExtend<T,I>>::spec_extend (4,461,869 samples, 1.53%)alloc::vec::Vec<T,A>::extend_trusted (4,461,869 samples, 1.53%)core::iter::traits::iterator::Iterator::for_each (4,461,869 samples, 1.53%)<core::iter::adapters::map::Map<I,F> as core::iter::traits::iterator::Iterator>::fold (4,461,869 samples, 1.53%)core::iter::range::<impl core::iter::traits::iterator::Iterator for core::ops::range::RangeInclusive<A>>::fold (4,461,869 samples, 1.53%)core::iter::range::<impl core::iter::traits::iterator::Iterator for core::ops::range::RangeInclusive<A>>::try_fold (4,461,869 samples, 1.53%)<core::ops::range::RangeInclusive<T> as core::iter::range::RangeInclusiveIteratorImpl>::spec_try_fold (4,461,869 samples, 1.53%)core::ops::try_trait::NeverShortCircuit<T>::wrap_mut_2::_{{closure}} (4,461,869 samples, 1.53%)core::iter::adapters::map::map_fold::_{{closure}} (4,461,869 samples, 1.53%)core::ops::function::FnMut::call_mut (4,461,869 samples, 1.53%)[[stack]] (15,322,135 samples, 5.26%)[[stac..malloc (10,860,266 samples, 3.73%)mall..[ld-linux-x86-64.so.2] (443,153 samples, 0.15%)[ld-linux-x86-64.so.2] (443,153 samples, 0.15%)[ld-linux-x86-64.so.2] (886,306 samples, 0.30%)[ld-linux-x86-64.so.2] (886,306 samples, 0.30%)[ld-linux-x86-64.so.2] (886,306 samples, 0.30%)[ld-linux-x86-64.so.2] (886,306 samples, 0.30%)[ld-linux-x86-64.so.2] (886,306 samples, 0.30%)_dl_catch_exception (443,153 samples, 0.15%)[ld-linux-x86-64.so.2] (443,153 samples, 0.15%)[ld-linux-x86-64.so.2] (443,153 samples, 0.15%)[ld-linux-x86-64.so.2] (443,153 samples, 0.15%)[ld-linux-x86-64.so.2] (443,153 samples, 0.15%)[ld-linux-x86-64.so.2] (443,153 samples, 0.15%)entry_SYSCALL_64_after_hwframe (443,153 samples, 0.15%)do_syscall_64 (443,153 samples, 0.15%)__x64_sys_openat (443,153 samples, 0.15%)do_sys_openat2 (443,153 samples, 0.15%)do_filp_open (443,153 samples, 0.15%)path_openat (443,153 samples, 0.15%)<i32 as alloc::string::SpecToString>::spec_to_string (13,404,267 samples, 4.61%)<i32 ..alloc::string::String::with_capacity (4,418,022 samples, 1.52%)alloc::vec::Vec<T>::with_capacity (4,418,022 samples, 1.52%)alloc::vec::Vec<T,A>::with_capacity_in (4,418,022 samples, 1.52%)alloc::raw_vec::RawVec<T,A>::with_capacity_in (4,418,022 samples, 1.52%)alloc::raw_vec::RawVecInner<A>::with_capacity_in (4,418,022 samples, 1.52%)alloc::raw_vec::RawVecInner<A>::try_allocate_in (4,418,022 samples, 1.52%)cfree (8,949,299 samples, 3.07%)cfr..fzbz::mod_then_match (11,857,983 samples, 4.07%)fzbz..core::ptr::drop_in_place<alloc::string::String> (7,445,187 samples, 2.56%)co..core::ptr::drop_in_place<alloc::vec::Vec<u8>> (7,445,187 samples, 2.56%)co..core::ptr::drop_in_place<alloc::raw_vec::RawVec<u8>> (7,445,187 samples, 2.56%)co..<alloc::raw_vec::RawVec<T,A> as core::ops::drop::Drop>::drop (7,445,187 samples, 2.56%)<a..alloc::raw_vec::RawVecInner<A>::deallocate (7,445,187 samples, 2.56%)al..<alloc::alloc::Global as core::alloc::Allocator>::deallocate (7,445,187 samples, 2.56%)<a..alloc::alloc::dealloc (7,445,187 samples, 2.56%)al..[unknown] (38,609,265 samples, 13.27%)[unknown]malloc (4,397,716 samples, 1.51%)__rustc::__rust_no_alloc_shim_is_unstable_v2 (4,480,454 samples, 1.54%)<core::slice::iter::Iter<T> as core::iter::traits::iterator::Iterator>::fold (8,923,704 samples, 3.07%)<co..fzbz::main::_{{closure}} (8,923,704 samples, 3.07%)fzb..core::iter::traits::iterator::Iterator::for_each::call::_{{closure}} (8,885,181 samples, 3.05%)cor..alloc::vec::Vec<T,A>::extend_trusted::_{{closure}} (8,885,181 samples, 3.05%)all..core::ptr::write (8,885,181 samples, 3.05%)cor..alloc::vec::Vec<T,A>::reserve (4,453,129 samples, 1.53%)alloc::raw_vec::RawVec<T,A>::reserve (4,453,129 samples, 1.53%)alloc::raw_vec::RawVecInner<A>::reserve (4,453,129 samples, 1.53%)alloc::raw_vec::RawVecInner<A>::needs_to_grow (4,453,129 samples, 1.53%)core::num::<impl usize>::wrapping_sub (4,453,129 samples, 1.53%)alloc::string::String::push_str (35,612,989 samples, 12.24%)alloc::string::Str..alloc::vec::Vec<T,A>::extend_from_slice (35,612,989 samples, 12.24%)alloc::vec::Vec<T,..<alloc::vec::Vec<T,A> as alloc::vec::spec_extend::SpecExtend<&T,core::slice::iter::Iter<T>>>::spec_extend (35,612,989 samples, 12.24%)<alloc::vec::Vec<T..alloc::vec::Vec<T,A>::append_elements (35,612,989 samples, 12.24%)alloc::vec::Vec<T,..core::ptr::copy_nonoverlapping (27,715,418 samples, 9.52%)core::ptr::cop..[libc.so.6] (23,439,510 samples, 8.05%)[libc.so.6]alloc::string::String::with_capacity (12,644,976 samples, 4.34%)alloc..alloc::vec::Vec<T>::with_capacity (12,644,976 samples, 4.34%)alloc..alloc::vec::Vec<T,A>::with_capacity_in (12,644,976 samples, 4.34%)alloc..alloc::raw_vec::RawVec<T,A>::with_capacity_in (12,644,976 samples, 4.34%)alloc..alloc::raw_vec::RawVecInner<A>::with_capacity_in (12,644,976 samples, 4.34%)alloc..alloc::raw_vec::RawVecInner<A>::try_allocate_in (12,644,976 samples, 4.34%)alloc..<alloc::alloc::Global as core::alloc::Allocator>::allocate (12,644,976 samples, 4.34%)<allo..alloc::alloc::Global::alloc_impl (12,644,976 samples, 4.34%)alloc..alloc::alloc::alloc (12,644,976 samples, 4.34%)alloc..malloc (12,644,976 samples, 4.34%)malloc<T as alloc::string::ToString>::to_string (117,880,820 samples, 40.50%)<T as alloc::string::ToString>::to_string<i32 as alloc::string::SpecToString>::spec_to_string (117,880,820 samples, 40.50%)<i32 as alloc::string::SpecToString>::spec_to_stringcore::fmt::num::imp::<impl u32>::_fmt (65,177,948 samples, 22.39%)core::fmt::num::imp::<impl u32>::_f..core::fmt::num::imp::<impl u32>::_fmt_inner (60,670,740 samples, 20.85%)core::fmt::num::imp::<impl u32>::..core::mem::maybe_uninit::MaybeUninit<T>::write (4,429,047 samples, 1.52%)<alloc::string::String as core::ops::deref::Deref>::deref (4,441,679 samples, 1.53%)alloc::string::String::as_str (4,441,679 samples, 1.53%)alloc::vec::Vec<T,A>::as_slice (4,441,679 samples, 1.53%)alloc::vec::Vec<T,A>::as_ptr (4,441,679 samples, 1.53%)alloc::raw_vec::RawVec<T,A>::ptr (4,441,679 samples, 1.53%)alloc::raw_vec::RawVecInner<A>::ptr (4,441,679 samples, 1.53%)alloc::raw_vec::RawVecInner<A>::non_null (4,441,679 samples, 1.53%)core::ptr::drop_in_place<alloc::string::String> (25,539,184 samples, 8.77%)core::ptr::d..core::ptr::drop_in_place<alloc::vec::Vec<u8>> (25,539,184 samples, 8.77%)core::ptr::d..core::ptr::drop_in_place<alloc::raw_vec::RawVec<u8>> (25,539,184 samples, 8.77%)core::ptr::d..<alloc::raw_vec::RawVec<T,A> as core::ops::drop::Drop>::drop (25,539,184 samples, 8.77%)<alloc::raw_..alloc::raw_vec::RawVecInner<A>::deallocate (25,539,184 samples, 8.77%)alloc::raw_v..<alloc::alloc::Global as core::alloc::Allocator>::deallocate (25,539,184 samples, 8.77%)<alloc::allo..alloc::alloc::dealloc (25,539,184 samples, 8.77%)alloc::alloc..cfree (25,539,184 samples, 8.77%)cfreestd::panic::catch_unwind (221,711,749 samples, 76.18%)std::panic::catch_unwindstd::panicking::catch_unwind (221,711,749 samples, 76.18%)std::panicking::catch_unwindstd::panicking::catch_unwind::do_call (221,711,749 samples, 76.18%)std::panicking::catch_unwind::do_callcore::ops::function::impls::<impl core::ops::function::FnOnce<A> for &F>::call_once (221,711,749 samples, 76.18%)core::ops::function::impls::<impl core::ops::function::FnOnce<A> for &F>::call_oncestd::rt::lang_start::_{{closure}} (221,711,749 samples, 76.18%)std::rt::lang_start::_{{closure}}std::sys::backtrace::__rust_begin_short_backtrace (221,711,749 samples, 76.18%)std::sys::backtrace::__rust_begin_short_backtracecore::ops::function::FnOnce::call_once (221,711,749 samples, 76.18%)core::ops::function::FnOnce::call_oncefzbz::main (221,711,749 samples, 76.18%)fzbz::mainfzbz::fizzbuzz_all (212,788,045 samples, 73.11%)fzbz::fizzbuzz_allcore::iter::traits::iterator::Iterator::collect (212,788,045 samples, 73.11%)core::iter::traits::iterator::Iterator::collect<alloc::vec::Vec<T> as core::iter::traits::collect::FromIterator<T>>::from_iter (212,788,045 samples, 73.11%)<alloc::vec::Vec<T> as core::iter::traits::collect::FromIterator<T>>::from_iter<alloc::vec::Vec<T> as alloc::vec::spec_from_iter::SpecFromIter<T,I>>::from_iter (212,788,045 samples, 73.11%)<alloc::vec::Vec<T> as alloc::vec::spec_from_iter::SpecFromIter<T,I>>::from_iter<alloc::vec::Vec<T> as alloc::vec::spec_from_iter_nested::SpecFromIterNested<T,I>>::from_iter (212,788,045 samples, 73.11%)<alloc::vec::Vec<T> as alloc::vec::spec_from_iter_nested::SpecFromIterNested<T,I>>::from_iter<alloc::vec::Vec<T,A> as alloc::vec::spec_extend::SpecExtend<T,I>>::spec_extend (212,788,045 samples, 73.11%)<alloc::vec::Vec<T,A> as alloc::vec::spec_extend::SpecExtend<T,I>>::spec_extendalloc::vec::Vec<T,A>::extend_trusted (212,788,045 samples, 73.11%)alloc::vec::Vec<T,A>::extend_trustedcore::iter::traits::iterator::Iterator::for_each (212,788,045 samples, 73.11%)core::iter::traits::iterator::Iterator::for_each<core::iter::adapters::map::Map<I,F> as core::iter::traits::iterator::Iterator>::fold (212,788,045 samples, 73.11%)<core::iter::adapters::map::Map<I,F> as core::iter::traits::iterator::Iterator>::foldcore::iter::range::<impl core::iter::traits::iterator::Iterator for core::ops::range::RangeInclusive<A>>::fold (212,788,045 samples, 73.11%)core::iter::range::<impl core::iter::traits::iterator::Iterator for core::ops::range::RangeInclusive<A>>::foldcore::iter::range::<impl core::iter::traits::iterator::Iterator for core::ops::range::RangeInclusive<A>>::try_fold (212,788,045 samples, 73.11%)core::iter::range::<impl core::iter::traits::iterator::Iterator for core::ops::range::RangeInclusive<A>>::try_fold<core::ops::range::RangeInclusive<T> as core::iter::range::RangeInclusiveIteratorImpl>::spec_try_fold (212,788,045 samples, 73.11%)<core::ops::range::RangeInclusive<T> as core::iter::range::RangeInclusiveIteratorImpl>::spec_try_foldcore::ops::try_trait::NeverShortCircuit<T>::wrap_mut_2::_{{closure}} (212,788,045 samples, 73.11%)core::ops::try_trait::NeverShortCircuit<T>::wrap_mut_2::_{{closure}}core::iter::adapters::map::map_fold::_{{closure}} (212,788,045 samples, 73.11%)core::iter::adapters::map::map_fold::_{{closure}}core::ops::function::FnMut::call_mut (203,902,864 samples, 70.06%)core::ops::function::FnMut::call_mutfzbz::mod_then_match (203,902,864 samples, 70.06%)fzbz::mod_then_matchcore::str::<impl str>::contains (33,804,254 samples, 11.61%)core::str::<impl ..<char as core::str::pattern::Pattern>::is_contained_in (33,804,254 samples, 11.61%)<char as core::st..core::slice::<impl [T]>::contains (33,804,254 samples, 11.61%)core::slice::<imp..<u8 as core::slice::cmp::SliceContains>::slice_contains (33,804,254 samples, 11.61%)<u8 as core::slic..core::slice::memchr::memchr (33,804,254 samples, 11.61%)core::slice::memc..core::slice::memchr::memchr_naive (29,297,675 samples, 10.07%)core::slice::me.._start (222,651,397 samples, 76.50%)_start__libc_start_main (222,651,397 samples, 76.50%)__libc_start_main[libc.so.6] (222,651,397 samples, 76.50%)[libc.so.6]main (222,651,397 samples, 76.50%)mainstd::rt::lang_start_internal (222,651,397 samples, 76.50%)std::rt::lang_start_internalstd::panic::catch_unwind (222,651,397 samples, 76.50%)std::panic::catch_unwindstd::panicking::catch_unwind (222,651,397 samples, 76.50%)std::panicking::catch_unwindstd::panicking::catch_unwind::do_call (222,651,397 samples, 76.50%)std::panicking::catch_unwind::do_callstd::rt::lang_start_internal::_{{closure}} (222,651,397 samples, 76.50%)std::rt::lang_start_internal::_{{closure}}std::rt::init (939,648 samples, 0.32%)std::sys::pal::unix::init (939,648 samples, 0.32%)std::sys::pal::unix::stack_overflow::imp::init (939,648 samples, 0.32%)std::sys::pal::unix::stack_overflow::imp::install_main_guard (939,648 samples, 0.32%)std::sys::pal::unix::stack_overflow::imp::install_main_guard_linux (939,648 samples, 0.32%)std::sys::pal::unix::stack_overflow::imp::stack_start_aligned (939,648 samples, 0.32%)std::sys::pal::unix::stack_overflow::imp::get_stack_start (939,648 samples, 0.32%)pthread_getattr_np (939,648 samples, 0.32%)[libc.so.6] (939,648 samples, 0.32%)malloc (939,648 samples, 0.32%)[libc.so.6] (939,648 samples, 0.32%)[libc.so.6] (939,648 samples, 0.32%)[libc.so.6] (939,648 samples, 0.32%)__default_morecore (939,648 samples, 0.32%)__sbrk (939,648 samples, 0.32%)brk (939,648 samples, 0.32%)entry_SYSCALL_64_after_hwframe (939,648 samples, 0.32%)do_syscall_64 (939,648 samples, 0.32%)__do_sys_brk (939,648 samples, 0.32%)up_write (939,648 samples, 0.32%)core::fmt::num::imp::<impl u32>::_fmt (4,492,682 samples, 1.54%)all (291,053,842 samples, 100%)fzbz (291,053,842 samples, 100.00%)fzbzfzbz::mod_then_match (4,611,603 samples, 1.58%)

You see in there that fzbz::mod_then_match is using 70.06% of the CPU time. So, not much to gain from improving anything outside of that function. Inside it, we've got:

  • <T as alloc::string::ToString>::to_string using 40.50% of CPU; then
  • core::ptr::drop_in_place<alloc::string::String> using 8.77%; then
  • core::str::<impl str>::contains using 11.61%

So 60.88% of the CPU time of the whole thing is spent converting numbers to strings and then looking for characters inside them.

It hurts when I do this

Yeah, so in this case it's not very hard to avoid turning these numbers into strings.

pub fn only_using_mod(n: i32) -> Answer {
    let (buzzy, fizzy) = test_for_fives_and_sevens(n);

    let buzzy = buzzy || n % 5 == 0;
    let fizzy = fizzy || n % 7 == 0;

    match (buzzy, fizzy) {
        (true, true) => Answer::FizzBuzz,
        (true, _) => Answer::Buzz,
        (_, true) => Answer::Fizz,
        _ => Answer::Number(n),
    }
}

fn test_for_fives_and_sevens(mut n: i32) -> (bool, bool) {
    let mut five = false;
    let mut seven = false;

    // Doing `n % 10` separates off the last (right-most, least significant)
    // digit, then dividing by 10 lops off that digit and lets us consider
    // the next one. e.g. given `n = 4567`:
    // n % 10 = 7
    // seven = true
    // n / 10 = 456
    // n % 10 = 6
    // n / 10 = 45
    // n % 10 = 5
    // five = true
    // n / 10 = 4
    // n % 10 = 4
    // n / 10 = 0, stop there returning (true, true).
    while n > 0 {
        let digit = n % 10;

        match digit {
            5 => five = true,
            7 => seven = true,
            _ => {}
        };

        n /= 10;
    }

    (five, seven)
}

Plus another variant (only_using_mod_with_early_return()) that returns early if both a 5 and a 7 have been seen.

Slow desktop benchmark

fizzbuzz                                    fastest       β”‚ slowest       β”‚ median        β”‚ mean          β”‚ samples β”‚ iters
β”œβ”€ early_return_before_mod_bench            53.3 ms       β”‚ 56.87 ms      β”‚ 53.89 ms      β”‚ 54.11 ms      β”‚ 100     β”‚ 100
β”œβ”€ mod_then_match_bench                     56.4 ms       β”‚ 62.62 ms      β”‚ 57.03 ms      β”‚ 57.24 ms      β”‚ 100     β”‚ 100
β”œβ”€ naive_bench                              59.34 ms      β”‚ 62.13 ms      β”‚ 60 ms         β”‚ 60.12 ms      β”‚ 100     β”‚ 100
β”œβ”€ only_using_mod_bench                     22.9 ms       β”‚ 24.24 ms      β”‚ 23.16 ms      β”‚ 23.26 ms      β”‚ 100     β”‚ 100
β”œβ”€ only_using_mod_with_early_return_bench   14.28 ms      β”‚ 15.68 ms      β”‚ 14.6 ms       β”‚ 14.65 ms      β”‚ 100     β”‚ 100
β”œβ”€ single_string_scan_bench                 59.32 ms      β”‚ 62.9 ms       β”‚ 60.15 ms      β”‚ 60.25 ms      β”‚ 100     β”‚ 100
╰─ single_string_scan_early_fizzbuzz_bench  56.73 ms      β”‚ 59.24 ms      β”‚ 57.59 ms      β”‚ 57.68 ms      β”‚ 100     β”‚ 100

Faster server benchmark

fizzbuzz                                    fastest       β”‚ slowest       β”‚ median        β”‚ mean          β”‚ samples β”‚ iters
β”œβ”€ early_return_before_mod_bench            30.6 ms       β”‚ 42.58 ms      β”‚ 30.93 ms      β”‚ 32.37 ms      β”‚ 100     β”‚ 100
β”œβ”€ mod_then_match_bench                     28.09 ms      β”‚ 32.54 ms      β”‚ 28.94 ms      β”‚ 28.97 ms      β”‚ 100     β”‚ 100
β”œβ”€ naive_bench                              29.44 ms      β”‚ 31.55 ms      β”‚ 30.11 ms      β”‚ 30.14 ms      β”‚ 100     β”‚ 100
β”œβ”€ only_using_mod_bench                     11.63 ms      β”‚ 12.1 ms       β”‚ 11.64 ms      β”‚ 11.67 ms      β”‚ 100     β”‚ 100
β”œβ”€ only_using_mod_with_early_return_bench   8.274 ms      β”‚ 9.495 ms      β”‚ 8.492 ms      β”‚ 8.786 ms      β”‚ 100     β”‚ 100
β”œβ”€ single_string_scan_bench                 31.63 ms      β”‚ 50.32 ms      β”‚ 31.83 ms      β”‚ 32.62 ms      β”‚ 100     β”‚ 100
╰─ single_string_scan_early_fizzbuzz_bench  31.28 ms      β”‚ 32.26 ms      β”‚ 31.53 ms      β”‚ 31.54 ms      β”‚ 100     β”‚ 100

Flame graph for this version

Flame Graph Reset ZoomSearch [[stack]] (4,377,220 samples, 5.10%)[[stac..<alloc::vec::Vec<T> as alloc::vec::spec_from_iter_nested::SpecFromIterNested<T,I>>::from_iter (4,377,220 samples, 5.10%)<alloc..<alloc::vec::Vec<T,A> as alloc::vec::spec_extend::SpecExtend<T,I>>::spec_extend (4,377,220 samples, 5.10%)<alloc..alloc::vec::Vec<T,A>::extend_trusted (4,377,220 samples, 5.10%)alloc:..core::iter::traits::iterator::Iterator::for_each (4,377,220 samples, 5.10%)core::..<core::iter::adapters::map::Map<I,F> as core::iter::traits::iterator::Iterator>::fold (4,377,220 samples, 5.10%)<core:..core::iter::range::<impl core::iter::traits::iterator::Iterator for core::ops::range::RangeInclusive<A>>::fold (4,377,220 samples, 5.10%)core::..core::iter::range::<impl core::iter::traits::iterator::Iterator for core::ops::range::RangeInclusive<A>>::try_fold (4,377,220 samples, 5.10%)core::..<core::ops::range::RangeInclusive<T> as core::iter::range::RangeInclusiveIteratorImpl>::spec_try_fold (4,377,220 samples, 5.10%)<core:..core::ops::try_trait::NeverShortCircuit<T>::wrap_mut_2::_{{closure}} (4,377,220 samples, 5.10%)core::..core::iter::adapters::map::map_fold::_{{closure}} (4,377,220 samples, 5.10%)core::..core::ops::function::FnMut::call_mut (4,377,220 samples, 5.10%)core::..[anon] (4,262,238 samples, 4.97%)[anon][libc.so.6] (4,262,238 samples, 4.97%)[libc...fzbz::only_using_mod_with_early_return (4,262,238 samples, 4.97%)fzbz::..[ld-linux-x86-64.so.2] (501,504 samples, 0.58%)[ld-linux-x86-64.so.2] (1,003,008 samples, 1.17%)[ld-linux-x86-64.so.2] (1,003,008 samples, 1.17%)[ld-linux-x86-64.so.2] (1,003,008 samples, 1.17%)[ld-linux-x86-64.so.2] (1,003,008 samples, 1.17%)[ld-linux-x86-64.so.2] (1,003,008 samples, 1.17%)_dl_catch_exception (501,504 samples, 0.58%)[ld-linux-x86-64.so.2] (501,504 samples, 0.58%)[ld-linux-x86-64.so.2] (501,504 samples, 0.58%)[ld-linux-x86-64.so.2] (501,504 samples, 0.58%)[ld-linux-x86-64.so.2] (501,504 samples, 0.58%)entry_SYSCALL_64_after_hwframe (501,504 samples, 0.58%)do_syscall_64 (501,504 samples, 0.58%)ksys_mmap_pgoff (501,504 samples, 0.58%)vm_mmap_pgoff (501,504 samples, 0.58%)do_mmap (501,504 samples, 0.58%)__get_unmapped_area (501,504 samples, 0.58%)thp_get_unmapped_area_vmflags (501,504 samples, 0.58%)arch_get_unmapped_area_topdown (501,504 samples, 0.58%)vm_unmapped_area (501,504 samples, 0.58%)mas_empty_area_rev (501,504 samples, 0.58%)<core::slice::iter::Iter<T> as core::iter::traits::iterator::Iterator>::fold (13,339,551 samples, 15.55%)<core::slice::iter::Iter..fzbz::main::_{{closure}} (4,428,916 samples, 5.16%)fzbz::..exc_page_fault (6,077,848 samples, 7.09%)exc_page_f..do_user_addr_fault (6,077,848 samples, 7.09%)do_user_ad..handle_mm_fault (6,077,848 samples, 7.09%)handle_mm_..__handle_mm_fault (4,098,499 samples, 4.78%)__hand..do_huge_pmd_anonymous_page (4,098,499 samples, 4.78%)do_hug..vma_alloc_folio_noprof (4,098,499 samples, 4.78%)vma_al..folio_alloc_mpol_noprof (4,098,499 samples, 4.78%)folio_..alloc_pages_mpol_noprof (4,098,499 samples, 4.78%)alloc_..__alloc_pages_noprof (4,098,499 samples, 4.78%)__allo..get_page_from_freelist (4,098,499 samples, 4.78%)get_pa..prep_new_page (4,098,499 samples, 4.78%)prep_n..clear_page_erms (4,098,499 samples, 4.78%)clear_..core::iter::traits::iterator::Iterator::for_each::call::_{{closure}} (10,510,989 samples, 12.25%)core::iter::traits..alloc::vec::Vec<T,A>::extend_trusted::_{{closure}} (10,510,989 samples, 12.25%)alloc::vec::Vec<T,..core::ptr::write (10,510,989 samples, 12.25%)core::ptr::writeasm_exc_page_fault (10,510,989 samples, 12.25%)asm_exc_page_faultsync_regs (4,433,141 samples, 5.17%)sync_r..<alloc::vec::Vec<T,A> as alloc::vec::spec_extend::SpecExtend<T,I>>::spec_extend (61,790,064 samples, 72.04%)<alloc::vec::Vec<T,A> as alloc::vec::spec_extend::SpecExtend<T,I>>::spec_extendalloc::vec::Vec<T,A>::extend_trusted (61,790,064 samples, 72.04%)alloc::vec::Vec<T,A>::extend_trustedcore::iter::traits::iterator::Iterator::for_each (61,790,064 samples, 72.04%)core::iter::traits::iterator::Iterator::for_each<core::iter::adapters::map::Map<I,F> as core::iter::traits::iterator::Iterator>::fold (61,790,064 samples, 72.04%)<core::iter::adapters::map::Map<I,F> as core::iter::traits::iterator::Iterator>::foldcore::iter::range::<impl core::iter::traits::iterator::Iterator for core::ops::range::RangeInclusive<A>>::fold (61,790,064 samples, 72.04%)core::iter::range::<impl core::iter::traits::iterator::Iterator for core::ops::range::RangeInclusive<A>>::foldcore::iter::range::<impl core::iter::traits::iterator::Iterator for core::ops::range::RangeInclusive<A>>::try_fold (61,790,064 samples, 72.04%)core::iter::range::<impl core::iter::traits::iterator::Iterator for core::ops::range::RangeInclusive<A>>::try_fold<core::ops::range::RangeInclusive<T> as core::iter::range::RangeInclusiveIteratorImpl>::spec_try_fold (61,790,064 samples, 72.04%)<core::ops::range::RangeInclusive<T> as core::iter::range::RangeInclusiveIteratorImpl>::spec_try_foldcore::ops::try_trait::NeverShortCircuit<T>::wrap_mut_2::_{{closure}} (61,790,064 samples, 72.04%)core::ops::try_trait::NeverShortCircuit<T>::wrap_mut_2::_{{closure}}core::iter::adapters::map::map_fold::_{{closure}} (61,790,064 samples, 72.04%)core::iter::adapters::map::map_fold::_{{closure}}core::ops::function::FnMut::call_mut (51,279,075 samples, 59.79%)core::ops::function::FnMut::call_mutfzbz::only_using_mod_with_early_return (51,279,075 samples, 59.79%)fzbz::only_using_mod_with_early_returnfzbz::test_for_fives_and_sevens_with_early_return (47,024,211 samples, 54.83%)fzbz::test_for_fives_and_sevens_with_early_returnall (85,769,493 samples, 100%)fzbz (85,769,493 samples, 100.00%)fzbz_start (76,127,027 samples, 88.76%)_start__libc_start_main (76,127,027 samples, 88.76%)__libc_start_main[libc.so.6] (76,127,027 samples, 88.76%)[libc.so.6]main (76,127,027 samples, 88.76%)mainstd::rt::lang_start_internal (76,127,027 samples, 88.76%)std::rt::lang_start_internalstd::panic::catch_unwind (76,127,027 samples, 88.76%)std::panic::catch_unwindstd::panicking::catch_unwind (76,127,027 samples, 88.76%)std::panicking::catch_unwindstd::panicking::catch_unwind::do_call (76,127,027 samples, 88.76%)std::panicking::catch_unwind::do_callstd::rt::lang_start_internal::_{{closure}} (76,127,027 samples, 88.76%)std::rt::lang_start_internal::_{{closure}}std::panic::catch_unwind (76,127,027 samples, 88.76%)std::panic::catch_unwindstd::panicking::catch_unwind (76,127,027 samples, 88.76%)std::panicking::catch_unwindstd::panicking::catch_unwind::do_call (76,127,027 samples, 88.76%)std::panicking::catch_unwind::do_callcore::ops::function::impls::<impl core::ops::function::FnOnce<A> for &F>::call_once (76,127,027 samples, 88.76%)core::ops::function::impls::<impl core::ops::function::FnOnce<A> for &F>::call_oncestd::rt::lang_start::_{{closure}} (76,127,027 samples, 88.76%)std::rt::lang_start::_{{closure}}std::sys::backtrace::__rust_begin_short_backtrace (76,127,027 samples, 88.76%)std::sys::backtrace::__rust_begin_short_backtracecore::ops::function::FnOnce::call_once (76,127,027 samples, 88.76%)core::ops::function::FnOnce::call_oncefzbz::main (76,127,027 samples, 88.76%)fzbz::mainfzbz::fizzbuzz_all (62,787,476 samples, 73.20%)fzbz::fizzbuzz_allcore::iter::traits::iterator::Iterator::collect (62,787,476 samples, 73.20%)core::iter::traits::iterator::Iterator::collect<alloc::vec::Vec<T> as core::iter::traits::collect::FromIterator<T>>::from_iter (62,787,476 samples, 73.20%)<alloc::vec::Vec<T> as core::iter::traits::collect::FromIterator<T>>::from_iter<alloc::vec::Vec<T> as alloc::vec::spec_from_iter::SpecFromIter<T,I>>::from_iter (62,787,476 samples, 73.20%)<alloc::vec::Vec<T> as alloc::vec::spec_from_iter::SpecFromIter<T,I>>::from_iter<alloc::vec::Vec<T> as alloc::vec::spec_from_iter_nested::SpecFromIterNested<T,I>>::from_iter (62,787,476 samples, 73.20%)<alloc::vec::Vec<T> as alloc::vec::spec_from_iter_nested::SpecFromIterNested<T,I>>::from_iteralloc::vec::Vec<T>::with_capacity (997,412 samples, 1.16%)alloc::vec::Vec<T,A>::with_capacity_in (997,412 samples, 1.16%)alloc::raw_vec::RawVec<T,A>::with_capacity_in (997,412 samples, 1.16%)alloc::raw_vec::RawVecInner<A>::with_capacity_in (997,412 samples, 1.16%)alloc::raw_vec::RawVecInner<A>::try_allocate_in (997,412 samples, 1.16%)<alloc::alloc::Global as core::alloc::Allocator>::allocate (997,412 samples, 1.16%)alloc::alloc::Global::alloc_impl (997,412 samples, 1.16%)alloc::alloc::alloc (997,412 samples, 1.16%)malloc (997,412 samples, 1.16%)[libc.so.6] (997,412 samples, 1.16%)[libc.so.6] (997,412 samples, 1.16%)[libc.so.6] (997,412 samples, 1.16%)__mmap (997,412 samples, 1.16%)error_entry (997,412 samples, 1.16%)

With that, fizzbuzz_all() uses 62,787,476 samples while test_for_fives_and_sevens_with_early_return() uses 47.024,211 samples, so just that function is still 74.89% of the CPU time of the meaningful part of the whole program.

Where to go next?

I'm not sure if this performance can be improved but I would like to improve my knowledge of Divan.

I'd like to try benchmarking different end values for each implementation, so for example to see how fast each can do up to 1,000, up to 10,000, up to 100,000 and so on. There might be some distributions that are faster than others.

Once the numbers get long, is it worth trying to short circuit the "divisible by both 5 and 7" case in order to sometimes avoid having to scan through the whole number?

Maybe not but I'd like to work out how to do it anyway.

What if you spent some memory to cache the outcome of expensive calculations? fizzbuzz(i32::MAX) is always Fizz (in this variant)!

Then there is multithreading, but that is definitely for a later date!

Found a typo? Feel free to just submit a pull request on GitHub to fix it. πŸ˜€