articles

Doctests - How were they improved?

rustdoc, the Rust documentation generator, just got a massive improvement which greatly reduces the time required for doctests in this pull request. This blog post will explain what are doctests, how they work and finally how we were able to improve them to this point.

Doc... tests?

You can write documentation in your source code with ///. This documentation follows the commonmark specification. With it, you can add "code blocks" which can be used as code examples:

Run/// This function prints "hello". You can use it like this:
///
/// ```
/// hello();
/// ```
pub fn hello() {
    print!("hello");
}

In here, the code example is not very useful, but now we all know what it is. Now if you run cargo test, you will realize that the crate's unit tests are run, but also doctests! All code examples in the documentation are tested. It allows for code examples to never be outdated but not only! Doctests even allow to ensure that a code doesn't compile or a few other things. You can find more information about doctests in the rustdoc book.

How they work

Now the interesting question is: how is rustdoc running these doctests? When running rustdoc in "test mode", it will go through all items, retrieve their documentation and extract all code examples from them.

Then, each doctest will be generated into its own file with some changes to allow them to compile. If there is no main function, we will add one for example. But if there are crate attributes, we need to be careful keeping them out of the main function, which requires some parsing, so we can generate the doctest correctly.

Once we have the doctest source code generated, we call the libtest crate and we provide it information about the doctest such as:

Then, each doctest is compiled on its own and (maybe) run.

How they were improved

Like mentioned previously, each doctest was compiled on its own. That's the main issue. If we take a look at where libcore time is spent when running its doctests:

total compile time: 775.204s
total runtime: 15.487s

So out of a total of 790 seconds, only 15 seconds are actually used for runtime (less than 2%!). 98% of the time is lost in compilation.

So needless to say, this compilation for each doctest is taking a huge amount of time. The obvious idea to improve things is to run as few compilations as possible. However, some doctests cannot be merged together because of various reasons:

As you can see, there are quite a lot of cases where a doctest cannot be merged with others. If a doctest cannot be merged, we compile it on its own as we did before and move on.

Handling corner cases

Now we enter more interesting cases: what happens if a merged doctest failed to compile? We cannot guess which doctest failed compilation, so in this case we revert to the previous behaviour and compile each doctest on its own so users will be able to know which one(s) failed to compile so they can fix them.

Originally, the feature was implemented so that all doctests were running in the same process using threads. However, this brought two issues:

  1. Doesn't work if you use non-thread-local statics.
  2. If the exit function is called, everything stops.

A good example to illustrate the non-thread-local statics can be displayed here:

Run/// ```
/// init_log();
/// log_info("hello");
/// ```
pub fn log_info(msg: &str) {
    // ...
}

/// ```
/// init_log();
/// log_error("hello");
/// ```
pub fn log_error(msg: &str) {
    // ...
}

fn init_log() {
    static mut CONTEXT: Option<String> = None;

    unsafe {
        if CONTEXT.is_some() {
            panic!("`init_log` must only be called once!");
        }
        CONTEXT = Some(String::new());
    }
}

If you run those doctests in the same process, init_log will panic. To prevent this issue, each doctest runs in its own process.

I mentioned above that if we encountered crate-level attributes, we didn't put the doctest into the merged doctests. However there is one exception:

Run/// ```
/// #![allow(some_warning)]
/// let x = 12;
/// ```
pub fn foo() {}

For allow, warn, deny, we still keep the doctest and move the attribute with it.

Generated merged doctest

Now comes the interesting part! What does the generated merged doctests source code look like? Let's first start with the "common" code:

Run#![allow(unused_extern_crates)]
#![allow(internal_features)]
#![feature(test)]
#![feature(rustc_attrs)]

// All `#![doc(test(attr(...)))]` attributes are added here.

extern crate test; // importing libtest

// Some utils.
mod _doctest_mod {
    use std::sync::OnceLock;
    use std::path::PathBuf;

    pub static BINARY_PATH: OnceLock<PathBuf> = OnceLock::new();
    pub const RUN_OPTION: &str = "*doctest-inner-test";
    pub const BIN_OPTION: &str = "*doctest-bin-path";

    #[allow(unused)]
    pub fn doctest_path() -> Option<&'static PathBuf> {
        self::BINARY_PATH.get()
    }

    #[allow(unused)]
    pub fn doctest_runner(
        bin: &std::path::Path,
        test_nb: usize,
    ) -> Result<(), String> {
        let out = std::process::Command::new(bin)
            .arg(self::RUN_OPTION)
            .arg(test_nb.to_string())
            .output()
            .expect("failed to run command");
        if !out.status.success() {
            Err(String::from_utf8_lossy(&out.stderr).to_string())
        } else {
            Ok(())
        }
    }
}

// Where the merged doctests are placed. We will see the code of a doctest
// just below.

// This attribute allows us to tell `rustc` that this `main` function is the
// REAL one. Remember: all doctests are wrapped in a `main` function! It makes
// it much easier for us to know what's the starting point of all doctests too.
#[rustc_main]
fn main() -> std::process::ExitCode {
    // We list all doctests in this constant. In this case, it means that
    // there is only one doctest.
    const TESTS: [test::TestDescAndFn; 1] = [_doctest_0::TEST];

    let bin_marker = std::ffi::OsStr::new(_doctest_mod::BIN_OPTION);
    let test_marker = std::ffi::OsStr::new(_doctest_mod::RUN_OPTION);
    // Test args provided to rustdoc, so none in this case.
    let test_args = &[];
    let mut args = std::env::args_os().skip(1);
    while let Some(arg) = args.next() {
        if arg == bin_marker {
            let Some(binary) = args.next() else {
                panic!(
                    "missing argument after `{}`", _doctest_mod::BIN_OPTION);
            };
            if crate::_doctest_mod::BINARY_PATH.set(binary.into()).is_err() {
                panic!(
                    "`{}` option was used more than once",
                    bin_marker.to_string_lossy(),
                );
            }
            // Starting to run `libtest` with all doctests.
            return std::process::Termination::report(test::test_main(
                test_args,
                Vec::from(TESTS),
                None,
            ));
        } else if arg == test_marker {
            let Some(nb_test) = args.next() else {
                panic!(
                    "missing argument after `{}`",
                    _doctest_mod::RUN_OPTION,
                );
            };
            if let Some(nb_test) = nb_test.to_str().and_then(|nb| {
                nb.parse::<usize>().ok()
            }) {
                if let Some(test) = TESTS.get(nb_test) {
                    if let test::StaticTestFn(f) = test.testfn {
                        return std::process::Termination::report(f());
                    }
                }
            }
            panic!("Unexpected value after `{}`", _doctest_mod::RUN_OPTION);
        }
    }
    eprintln!("WARNING: No argument provided so doctests will be run in the same process");
    std::process::Termination::report(test::test_main(
        test_args,
        Vec::from(TESTS),
        None,
    ))
}

As you can see, the binary has two paths: one to call all doctests and the other to run specifically ONE doctest. It works as follows:

  1. We call the binary with its own path.
  2. Each doctest runs the binary with the provided path and provides its own ID.
  3. The doctest is finally run (in its own process).

Now let's take this doctest (it's the full content of a file named foo.rs):

Run/// ```
/// let x = 12;
/// ```

Now let's see what its (formatted) generated code looks like:

Runmod _doctest_0 {
    #![allow(some_warning)]

    // This small piece of code is the actual doctest!
    fn main()  {
        let x = 12;
    }

    #[rustc_test_marker = "foo.rs - foo (line 1)"]
    pub const TEST: test::TestDescAndFn = test::TestDescAndFn::new_doctest(
        "foo.rs - foo (line 1)", // test name
        false, // is `ignore`
        "foo.rs", // name of the file where the doctest is
        1, // line where the doctest is located
        false, // is `no_run`
        false, // should panic
        test::StaticTestFn(
            || {
                // If we have a binary path, we call it with the ID of the
                // current doctest (so `0` in here).
                if let Some(bin_path) = crate::_doctest_mod::doctest_path() {
                    test::assert_test_result(
                        crate::_doctest_mod::doctest_runner(bin_path, 0),
                    )
                // If we don't have a binary path, it means we can actually run
                // the doctest, so let's go!
                } else {
                    test::assert_test_result(self::main())
                }
            },
        ),
    );
}

Why they require a new edition

For two reasons: the first one is that in some extremely rare cases, we cannot detect that a doctests cannot be merged and thus will make it fail. For example if you want to test the location of your doctest with std::panic::Location. Obviously, it will not work if the doctest source code is moved into a merged doctest. It's for such cases that we added the new standalone code block attribute. More information about it in the rustdoc book.

The second reason is because it might change the output. Let's take this code as example:

Run/// ```
/// #![allow(some_warning)]
/// let x = 12;
/// ```
///
/// ```compile_fail
/// x
/// ```
pub fn foo() {}

Without the merged doctests feature, if you run doctest you will have:

running 2 tests
test foo.rs - foo (line 6) - compile fail ... ok
test foo.rs - foo (line 1) ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s

But with the feature enabled you will have:

running 1 test
test foo.rs - foo (line 1) ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 1 test
test foo.rs - foo (line 6) - compile fail ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s

It's because there are now different doctests passes where doctests which cannot be merged with others are run at the end.

Comparison numbers

Just to show off, here are some comparison numbers:

crate before this feature with this feature
sysinfo 4.6s 1.11s
geos 3.95s 0.45s
core 54.08s 13.5s (merged: 0.9s, standalone: 12.6s)
std 12s 3.56s (merged: 2.1s, standalone: 1.46s)
jiff 4min39 7.2s

But it was just the first step

I'm quite happy with this improvement... but it was only the first step! I hope to achieve other improvements, like the possibility to have useful doctests on binary crates by allowing them to have access to the crate API like unit tests. I have a few ideas on how to achieve it. We will see if we will be able to make it a reality!

Words of the end

All this was done thanks to enlightenment(?) from my cat:


my cat
Posted on the 17/08/2024 at 13:30 by @GuillaumeGomez

Previous article

docs.rs switching jinja template framework from tera to rinja
Back to articles list
RSS feedRSS feed