Skip to content

Commit

Permalink
feat: parallel unit test runners (FuelLabs#4395)
Browse files Browse the repository at this point in the history
## Description
closes FuelLabs#3953

This PR adds parallel test runners to improve our test execution times
by utilizing the fact that each test is actually completely separate
from each other. Also a flag `--test-threads` added to `forc-test` to
manually control number of thread used for execution.

Simple benchmarks taken from my local system with m1 max can be seen in
below: (
*Each config is executed 10 times)*
| Num Threads      | Average Time |
| ----------- | ----------- |
| 1      | 1.019   ms  |
| 2   | 938 ms        |
| 4   | 883 ms        |


As it can be seen speed-ups aren't great as this includes building as
well which is done with single thread. So I tested just building with
tests enabled on the same project (which is sway-lib-std btw) which
takes 810ms (again averaged 10 times). So after I normalize the results
above with this data
| Num Threads      | Average Time |
| ----------- | ----------- |
| 1      | 209    ms |
| 2   | 128 ms       |
| 4   | 73 ms         |

Speed-ups are more visible with more complex tests with increased number
of tests.
  • Loading branch information
kayagokalp authored Apr 5, 2023
1 parent 45e826b commit 91ed981
Show file tree
Hide file tree
Showing 6 changed files with 150 additions and 55 deletions.
58 changes: 57 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions docs/book/src/testing/unit-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,13 @@ Example `Forc.toml` for contract above:
```toml
{{#include ../../../../examples/multi_contract_calls/caller/Forc.toml:multi_contract_call_toml}}
```

## Running Tests in Parallel or Serially

By default, all unit tests in your project are run in parallel. Note that this does not lead to any data races in storage because each unit test has its own storage space that is not shared by any other unit test.

By default, `forc test` will use all the available threads in your system. To request that a specific number of threads be used, the flag `--test-threads <val>` can be provided to `forc test`.

```console
forc test --test-threads 1
```
3 changes: 2 additions & 1 deletion forc-test/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,6 @@ fuel-abi-types = "0.2"
fuel-tx = { workspace = true, features = ["builder"] }
fuel-vm = { workspace = true, features = ["random"] }
rand = "0.8"
rayon = "1.7.0"
sway-core = { version = "0.36.1", path = "../sway-core" }
sway-types = { version = "0.36.1", path = "../sway-types" }
sway-types = { version = "0.36.1", path = "../sway-types" }
119 changes: 69 additions & 50 deletions forc-test/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use fuel_vm::{self as vm, fuel_asm, prelude::Instruction};
use pkg::TestPassCondition;
use pkg::{Built, BuiltPackage};
use rand::{Rng, SeedableRng};
use rayon::prelude::*;
use std::{collections::HashMap, fs, path::PathBuf, sync::Arc};
use sway_core::BuildTarget;
use sway_types::Span;
Expand Down Expand Up @@ -272,54 +273,59 @@ impl<'a> PackageTests {
}

/// Run all tests for this package and collect their results.
pub(crate) fn run_tests(&self) -> anyhow::Result<TestedPackage> {
pub(crate) fn run_tests(
&self,
test_runners: &rayon::ThreadPool,
) -> anyhow::Result<TestedPackage> {
let pkg_with_tests = self.built_pkg_with_tests();
// TODO: We can easily parallelise this, but let's wait until testing is stable first.
let tests = pkg_with_tests
.bytecode
.entries
.iter()
.filter_map(|entry| entry.kind.test().map(|test| (entry, test)))
.map(|(entry, test_entry)| {
let offset = u32::try_from(entry.finalized.imm)
.expect("test instruction offset out of range");
let name = entry.finalized.fn_name.clone();
let test_setup = self.setup()?;
let (state, duration, receipts) =
exec_test(&pkg_with_tests.bytecode.bytes, offset, test_setup);

let gas_used = *receipts
.iter()
.find_map(|receipt| match receipt {
tx::Receipt::ScriptResult { gas_used, .. } => Some(gas_used),
_ => None,
})
.ok_or_else(|| {
anyhow::anyhow!("missing used gas information from test execution")
})?;

// Only retain `Log` and `LogData` receipts.
let logs = receipts
.into_iter()
.filter(|receipt| {
matches!(receipt, fuel_tx::Receipt::Log { .. })
|| matches!(receipt, fuel_tx::Receipt::LogData { .. })
let tests = test_runners.install(|| {
pkg_with_tests
.bytecode
.entries
.par_iter()
.filter_map(|entry| entry.kind.test().map(|test| (entry, test)))
.map(|(entry, test_entry)| {
let offset = u32::try_from(entry.finalized.imm)
.expect("test instruction offset out of range");
let name = entry.finalized.fn_name.clone();
let test_setup = self.setup()?;
let (state, duration, receipts) =
exec_test(&pkg_with_tests.bytecode.bytes, offset, test_setup);

let gas_used = *receipts
.iter()
.find_map(|receipt| match receipt {
tx::Receipt::ScriptResult { gas_used, .. } => Some(gas_used),
_ => None,
})
.ok_or_else(|| {
anyhow::anyhow!("missing used gas information from test execution")
})?;

// Only retain `Log` and `LogData` receipts.
let logs = receipts
.into_iter()
.filter(|receipt| {
matches!(receipt, fuel_tx::Receipt::Log { .. })
|| matches!(receipt, fuel_tx::Receipt::LogData { .. })
})
.collect();

let span = test_entry.span.clone();
let condition = test_entry.pass_condition.clone();
Ok(TestResult {
name,
duration,
span,
state,
condition,
logs,
gas_used,
})
.collect();

let span = test_entry.span.clone();
let condition = test_entry.pass_condition.clone();
Ok(TestResult {
name,
duration,
span,
state,
condition,
logs,
gas_used,
})
})
.collect::<anyhow::Result<_>>()?;
.collect::<anyhow::Result<_>>()
})?;

let tested_pkg = TestedPackage {
built: Box::new(pkg_with_tests.clone()),
tests,
Expand Down Expand Up @@ -413,6 +419,13 @@ impl TestResult {
}
}

/// Used to control test runner count for forc-test. Number of runners to use can be specified using
/// `Manual` or can be left forc-test to decide by using `Auto`.
pub enum TestRunnerCount {
Manual(usize),
Auto,
}

impl BuiltTests {
/// The total number of tests.
pub fn test_count(&self) -> usize {
Expand All @@ -433,8 +446,14 @@ impl BuiltTests {
}

/// Run all built tests, return the result.
pub fn run(self) -> anyhow::Result<Tested> {
run_tests(self)
pub fn run(self, test_runner_count: TestRunnerCount) -> anyhow::Result<Tested> {
let test_runners = match test_runner_count {
TestRunnerCount::Manual(runner_count) => rayon::ThreadPoolBuilder::new()
.num_threads(runner_count)
.build(),
TestRunnerCount::Auto => rayon::ThreadPoolBuilder::new().build(),
}?;
run_tests(self, &test_runners)
}
}

Expand Down Expand Up @@ -506,16 +525,16 @@ fn deployment_transaction(
}

/// Build the given package and run its tests, returning the results.
fn run_tests(built: BuiltTests) -> anyhow::Result<Tested> {
fn run_tests(built: BuiltTests, test_runners: &rayon::ThreadPool) -> anyhow::Result<Tested> {
match built {
BuiltTests::Package(pkg) => {
let tested_pkg = pkg.run_tests()?;
let tested_pkg = pkg.run_tests(test_runners)?;
Ok(Tested::Package(Box::new(tested_pkg)))
}
BuiltTests::Workspace(workspace) => {
let tested_pkgs = workspace
.into_iter()
.map(|pkg| pkg.run_tests())
.map(|pkg| pkg.run_tests(test_runners))
.collect::<anyhow::Result<Vec<TestedPackage>>>()?;
Ok(Tested::Workspace(tested_pkgs))
}
Expand Down
13 changes: 11 additions & 2 deletions forc/src/cli/commands/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use ansi_term::Colour;
use anyhow::{bail, Result};
use clap::Parser;
use forc_pkg as pkg;
use forc_test::TestedPackage;
use forc_test::{TestRunnerCount, TestedPackage};
use forc_util::format_log_receipts;
use tracing::info;

Expand Down Expand Up @@ -32,6 +32,10 @@ pub struct Command {
pub test_print: TestPrintOpts,
/// When specified, only tests containing the given string will be executed.
pub filter: Option<String>,
#[clap(long)]
/// Number of threads to utilize when running the tests. By default, this is the number of
/// threads available in your system.
pub test_threads: Option<usize>,
}

/// The set of options provided for controlling output of a test.
Expand All @@ -50,12 +54,17 @@ pub(crate) fn exec(cmd: Command) -> Result<()> {
bail!("unit test filter not yet supported");
}

let test_runner_count = match cmd.test_threads {
Some(runner_count) => TestRunnerCount::Manual(runner_count),
None => TestRunnerCount::Auto,
};

let test_print_opts = cmd.test_print.clone();
let opts = opts_from_cmd(cmd);
let built_tests = forc_test::build(opts)?;
let start = std::time::Instant::now();
info!(" Running {} tests", built_tests.test_count());
let tested = built_tests.run()?;
let tested = built_tests.run(test_runner_count)?;
let duration = start.elapsed();

// Eventually we'll print this in a fancy manner, but this will do for testing.
Expand Down
2 changes: 1 addition & 1 deletion test/src/e2e_vm_tests/harness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ pub(crate) async fn compile_and_run_unit_tests(
},
..Default::default()
})?;
let tested = built_tests.run()?;
let tested = built_tests.run(forc_test::TestRunnerCount::Auto)?;

match tested {
forc_test::Tested::Package(tested_pkg) => Ok(vec![*tested_pkg]),
Expand Down

0 comments on commit 91ed981

Please sign in to comment.