Auto merge of #9955 - ehuss:benchsuite, r=Eh2406

Add the start of a basic benchmarking suite.

This adds the start of a basic benchmarking suite for cargo.  This is fairly rough, but I figure it will change and evolve over time based on what we decide to add and how we use it.

There is some documentation in the `benches/README.md` file which gives an overview of what is here and how to use it.

Closes #9935
This commit is contained in:
bors 2021-10-12 21:59:48 +00:00
commit c8b38af5d0
18 changed files with 676 additions and 12 deletions

View File

@ -19,7 +19,7 @@ jobs:
- run: rustup component add rustfmt
- run: cargo fmt --all -- --check
- run: |
for manifest in `find crates -name Cargo.toml`
for manifest in `find crates benches/benchsuite benches/capture -name Cargo.toml`
do
echo check fmt for $manifest
cargo fmt --all --manifest-path $manifest -- --check
@ -79,6 +79,15 @@ jobs:
if: matrix.os == 'macos-latest'
- run: cargo build --manifest-path crates/credential/cargo-credential-wincred/Cargo.toml
if: matrix.os == 'windows-latest'
- name: Check benchmarks
env:
# Share the target dir to try to cache a few build-time deps.
CARGO_TARGET_DIR: target
run: |
# This only tests one benchmark since it can take over 10 minutes to
# download all workspaces.
cargo test --manifest-path benches/benchsuite/Cargo.toml --all-targets -- cargo
cargo check --manifest-path benches/capture/Cargo.toml
- name: Fetch smoke test
run: ci/fetch-smoke-test.sh

124
benches/README.md Normal file
View File

@ -0,0 +1,124 @@
# Cargo Benchmarking
This directory contains some benchmarks for cargo itself. This uses
[Criterion] for running benchmarks. It is recommended to read the Criterion
book to get familiar with how to use it. A basic usage would be:
```sh
cd benches/benchsuite
cargo bench
```
The tests involve downloading the index and benchmarking against some
real-world and artificial workspaces located in the [`workspaces`](workspaces)
directory.
**Beware** that the initial download can take a fairly long amount of time (10
minutes minimum on an extremely fast network) and require significant disk
space (around 4.5GB). The benchsuite will cache the index and downloaded
crates in the `target/tmp/bench` directory, so subsequent runs should be
faster. You can (and probably should) specify individual benchmarks to run to
narrow it down to a more reasonable set, for example:
```sh
cargo bench -- resolve_ws/rust
```
This will only download what's necessary for the rust-lang/rust workspace
(which is about 330MB) and run the benchmarks against it (which should take
about a minute). To get a list of all the benchmarks, run:
```sh
cargo bench -- --list
```
## Viewing reports
The benchmarks display some basic information on the command-line while they
run. A more complete HTML report can be found at
`target/criterion/report/index.html` which contains links to all the
benchmarks and summaries. Check out the Criterion book for more information on
the extensive reporting capabilities.
## Comparing implementations
Knowing the raw numbers can be useful, but what you're probably most
interested in is checking if your changes help or hurt performance. To do
that, you need to run the benchmarks multiple times.
First, run the benchmarks from the master branch of cargo without any changes.
To make it easier to compare, Criterion supports naming the baseline so that
you can iterate on your code and compare against it multiple times.
```sh
cargo bench -- --save-baseline master
```
Now you can switch to your branch with your changes. Re-run the benchmarks
compared against the baseline:
```sh
cargo bench -- --baseline master
```
You can repeat the last command as you make changes to re-compare against the
master baseline.
Without the baseline arguments, it will compare against the last run, which
can be helpful for comparing incremental changes.
## Capturing workspaces
The [`workspaces`](workspaces) directory contains several workspaces that
provide a variety of different workspaces intended to provide good exercises
for benchmarks. Some of these are shadow copies of real-world workspaces. This
is done with the tool in the [`capture`](capture) directory. The tool will
copy `Cargo.lock` and all of the `Cargo.toml` files of the workspace members.
It also adds an empty `lib.rs` so Cargo won't error, and sanitizes the
`Cargo.toml` to some degree, removing unwanted elements. Finally, it
compresses everything into a `tgz`.
To run it, do:
```sh
cd benches/capture
cargo run -- /path/to/workspace/foo
```
The resolver benchmarks also support the `CARGO_BENCH_WORKSPACES` environment
variable, which you can point to a Cargo workspace if you want to try
different workspaces. For example:
```sh
CARGO_BENCH_WORKSPACES=/path/to/some/workspace cargo bench
```
## TODO
This is just a start for establishing a benchmarking suite for Cargo. There's
a lot that can be added. Some ideas:
* Fix the benchmarks so that the resolver setup doesn't run every iteration.
* Benchmark [this section of
code](https://github.com/rust-lang/cargo/blob/a821e2cb24d7b6013433f069ab3bad53d160e100/src/cargo/ops/cargo_compile.rs#L470-L549)
which builds the unit graph. The performance there isn't great, and it would
be good to keep an eye on it. Unfortunately that would mean doing a bit of
work to make `generate_targets` publicly visible, and there is a bunch of
setup code that may need to be duplicated.
* Benchmark the fingerprinting code.
* Benchmark running the `cargo` executable. Running something like `cargo
build` or `cargo check` with everything "Fresh" would be a good end-to-end
exercise to measure the overall overhead of Cargo.
* Benchmark pathological resolver scenarios. There might be some cases where
the resolver can spend a significant amount of time. It would be good to
identify if these exist, and create benchmarks for them. This may require
creating an artificial index, similar to the `resolver-tests`. This should
also consider scenarios where the resolver ultimately fails.
* Benchmark without `Cargo.lock`. I'm not sure if this is particularly
valuable, since we are mostly concerned with incremental builds which will
always have a lock file.
* Benchmark just
[`resolve::resolve`](https://github.com/rust-lang/cargo/blob/a821e2cb24d7b6013433f069ab3bad53d160e100/src/cargo/core/resolver/mod.rs#L122)
without anything else. This can help focus on just the resolver.
[Criterion]: https://bheisler.github.io/criterion.rs/book/

View File

@ -0,0 +1,21 @@
[package]
name = "benchsuite"
version = "0.1.0"
edition = "2018"
license = "MIT OR Apache-2.0"
homepage = "https://github.com/rust-lang/cargo"
repository = "https://github.com/rust-lang/cargo"
documentation = "https://docs.rs/cargo-platform"
description = "Benchmarking suite for Cargo."
[dependencies]
cargo = { path = "../.." }
# Consider removing html_reports in 0.4 and switching to `cargo criterion`.
criterion = { version = "0.3.5", features = ["html_reports"] }
flate2 = { version = "1.0.3", default-features = false, features = ["zlib"] }
tar = { version = "0.4.35", default-features = false }
url = "2.2.2"
[[bench]]
name = "resolve"
harness = false

View File

@ -0,0 +1,327 @@
use cargo::core::compiler::{CompileKind, RustcTargetData};
use cargo::core::resolver::features::{CliFeatures, FeatureOpts, FeatureResolver, ForceAllTargets};
use cargo::core::resolver::{HasDevUnits, ResolveBehavior};
use cargo::core::{PackageIdSpec, Workspace};
use cargo::ops::WorkspaceResolve;
use cargo::Config;
use criterion::{criterion_group, criterion_main, Criterion};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use url::Url;
// This is an arbitrary commit that existed when I started. This helps
// ensure consistent results. It can be updated if needed, but that can
// make it harder to compare results with older versions of cargo.
const CRATES_IO_COMMIT: &str = "85f7bfd61ea4fee08ec68c468762e886b2aebec6";
fn setup() {
create_home();
create_target_dir();
clone_index();
unpack_workspaces();
}
fn root() -> PathBuf {
let mut p = PathBuf::from(env!("CARGO_TARGET_TMPDIR"));
p.push("bench");
p
}
fn target_dir() -> PathBuf {
let mut p = root();
p.push("target");
p
}
fn cargo_home() -> PathBuf {
let mut p = root();
p.push("chome");
p
}
fn index() -> PathBuf {
let mut p = root();
p.push("index");
p
}
fn workspaces_path() -> PathBuf {
let mut p = root();
p.push("workspaces");
p
}
fn registry_url() -> Url {
Url::from_file_path(index()).unwrap()
}
fn create_home() {
let home = cargo_home();
if !home.exists() {
fs::create_dir_all(&home).unwrap();
}
fs::write(
home.join("config.toml"),
format!(
r#"
[source.crates-io]
replace-with = 'local-snapshot'
[source.local-snapshot]
registry = '{}'
"#,
registry_url()
),
)
.unwrap();
}
fn create_target_dir() {
// This is necessary to ensure the .rustc_info.json file is written.
// Otherwise it won't be written, and it is very expensive to create.
if !target_dir().exists() {
std::fs::create_dir_all(target_dir()).unwrap();
}
}
/// This clones crates.io at a specific point in time into tmp/index.
fn clone_index() {
let index = index();
let maybe_git = |command: &str| {
let status = Command::new("git")
.current_dir(&index)
.args(command.split_whitespace().collect::<Vec<_>>())
.status()
.expect("git should be installed");
status.success()
};
let git = |command: &str| {
if !maybe_git(command) {
panic!("failed to run git command: {}", command);
}
};
if index.exists() {
if maybe_git(&format!(
"rev-parse -q --verify {}^{{commit}}",
CRATES_IO_COMMIT
)) {
// Already fetched.
return;
}
} else {
fs::create_dir_all(&index).unwrap();
git("init --bare");
git("remote add origin https://github.com/rust-lang/crates.io-index");
}
git(&format!("fetch origin {}", CRATES_IO_COMMIT));
git("branch -f master FETCH_HEAD");
}
/// This unpacks the compressed workspace skeletons into tmp/workspaces.
fn unpack_workspaces() {
let ws_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.join("workspaces");
let archives = fs::read_dir(ws_dir)
.unwrap()
.map(|e| e.unwrap().path())
.filter(|p| p.extension() == Some(std::ffi::OsStr::new("tgz")));
for archive in archives {
let name = archive.file_stem().unwrap();
let f = fs::File::open(&archive).unwrap();
let f = flate2::read::GzDecoder::new(f);
let dest = workspaces_path().join(&name);
if dest.exists() {
fs::remove_dir_all(&dest).unwrap();
}
let mut archive = tar::Archive::new(f);
archive.unpack(workspaces_path()).unwrap();
}
}
struct ResolveInfo<'cfg> {
ws: Workspace<'cfg>,
requested_kinds: [CompileKind; 1],
target_data: RustcTargetData<'cfg>,
cli_features: CliFeatures,
specs: Vec<PackageIdSpec>,
has_dev_units: HasDevUnits,
force_all_targets: ForceAllTargets,
ws_resolve: WorkspaceResolve<'cfg>,
}
/// Vec of `(ws_name, ws_root)`.
fn workspaces() -> Vec<(String, PathBuf)> {
// CARGO_BENCH_WORKSPACES can be used to override, otherwise it just uses
// the workspaces in the workspaces directory.
let mut ps: Vec<_> = match std::env::var_os("CARGO_BENCH_WORKSPACES") {
Some(s) => std::env::split_paths(&s).collect(),
None => fs::read_dir(workspaces_path())
.unwrap()
.map(|e| e.unwrap().path())
// These currently fail in most cases on Windows due to long
// filenames in the git checkouts.
.filter(|p| {
!(cfg!(windows)
&& matches!(p.file_name().unwrap().to_str().unwrap(), "servo" | "tikv"))
})
.collect(),
};
// Sort so it is consistent.
ps.sort();
ps.into_iter()
.map(|p| (p.file_name().unwrap().to_str().unwrap().to_owned(), p))
.collect()
}
/// Helper for resolving a workspace. This will run the resolver once to
/// download everything, and returns all the data structures that are used
/// during resolution.
fn do_resolve<'cfg>(config: &'cfg Config, ws_root: &Path) -> ResolveInfo<'cfg> {
let requested_kinds = [CompileKind::Host];
let ws = cargo::core::Workspace::new(&ws_root.join("Cargo.toml"), config).unwrap();
let target_data = RustcTargetData::new(&ws, &requested_kinds).unwrap();
let cli_features = CliFeatures::from_command_line(&[], false, true).unwrap();
let pkgs = cargo::ops::Packages::Default;
let specs = pkgs.to_package_id_specs(&ws).unwrap();
let has_dev_units = HasDevUnits::Yes;
let force_all_targets = ForceAllTargets::No;
// Do an initial run to download anything necessary so that it does
// not confuse criterion's warmup.
let ws_resolve = cargo::ops::resolve_ws_with_opts(
&ws,
&target_data,
&requested_kinds,
&cli_features,
&specs,
has_dev_units,
force_all_targets,
)
.unwrap();
ResolveInfo {
ws,
requested_kinds,
target_data,
cli_features,
specs,
has_dev_units,
force_all_targets,
ws_resolve,
}
}
/// Creates a new Config.
///
/// This is separate from `do_resolve` to deal with the ownership and lifetime.
fn make_config(ws_root: &Path) -> Config {
let shell = cargo::core::Shell::new();
let mut config = cargo::util::Config::new(shell, ws_root.to_path_buf(), cargo_home());
// Configure is needed to set the target_dir which is needed to write
// the .rustc_info.json file which is very expensive.
config
.configure(
0,
false,
None,
false,
false,
false,
&Some(target_dir()),
&[],
&[],
)
.unwrap();
config
}
/// Benchmark of the full `resovle_ws_with_opts` which runs the resolver
/// twice, the feature resolver, and more. This is a major component of a
/// regular cargo build.
fn resolve_ws(c: &mut Criterion) {
setup();
let mut group = c.benchmark_group("resolve_ws");
for (ws_name, ws_root) in workspaces() {
let config = make_config(&ws_root);
// The resolver info is initialized only once in a lazy fashion. This
// allows criterion to skip this workspace if the user passes a filter
// on the command-line (like `cargo bench -- resolve_ws/tikv`).
//
// Due to the way criterion works, it tends to only run the inner
// iterator once, and we don't want to call `do_resolve` in every
// "step", since that would just be some useless work.
let mut lazy_info = None;
group.bench_function(&ws_name, |b| {
let ResolveInfo {
ws,
requested_kinds,
target_data,
cli_features,
specs,
has_dev_units,
force_all_targets,
..
} = lazy_info.get_or_insert_with(|| do_resolve(&config, &ws_root));
b.iter(|| {
cargo::ops::resolve_ws_with_opts(
ws,
target_data,
requested_kinds,
cli_features,
specs,
*has_dev_units,
*force_all_targets,
)
.unwrap();
})
});
}
group.finish();
}
/// Benchmark of the feature resolver.
fn feature_resolver(c: &mut Criterion) {
setup();
let mut group = c.benchmark_group("feature_resolver");
for (ws_name, ws_root) in workspaces() {
let config = make_config(&ws_root);
let mut lazy_info = None;
group.bench_function(&ws_name, |b| {
let ResolveInfo {
ws,
requested_kinds,
target_data,
cli_features,
specs,
has_dev_units,
ws_resolve,
..
} = lazy_info.get_or_insert_with(|| do_resolve(&config, &ws_root));
b.iter(|| {
let feature_opts = FeatureOpts::new_behavior(ResolveBehavior::V2, *has_dev_units);
FeatureResolver::resolve(
ws,
target_data,
&ws_resolve.targeted_resolve,
&ws_resolve.pkg_set,
cli_features,
specs,
requested_kinds,
feature_opts,
)
.unwrap();
})
});
}
group.finish();
}
// Criterion complains about the measurement time being too small, but the
// measurement time doesn't seem important to me, what is more important is
// the number of iterations which defaults to 100, which seems like a
// reasonable default. Otherwise, the measurement time would need to be
// changed per workspace. We wouldn't want to spend 60s on every workspace,
// that would take too long and isn't necessary for the smaller workspaces.
criterion_group!(benches, resolve_ws, feature_resolver);
criterion_main!(benches);

View File

@ -0,0 +1,12 @@
[package]
name = "capture"
version = "0.1.0"
edition = "2018"
license = "MIT OR Apache-2.0"
description = "Tool for capturing a real-world workspace for benchmarking."
[dependencies]
cargo_metadata = "0.14.0"
flate2 = { version = "1.0.3", default-features = false, features = ["zlib"] }
tar = { version = "0.4.35", default-features = false }
toml = "0.5.8"

164
benches/capture/src/main.rs Normal file
View File

@ -0,0 +1,164 @@
//! This tool helps to capture the `Cargo.toml` files of a workspace.
//!
//! Run it by passing a list of workspaces to capture.
//! Use the `-f` flag to allow it to overwrite existing captures.
//! The workspace will be saved in a `.tgz` file in the `../workspaces` directory.
use flate2::{Compression, GzBuilder};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
fn main() {
let force = std::env::args().any(|arg| arg == "-f");
let dest = Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.join("workspaces");
if !dest.exists() {
panic!("expected {} to exist", dest.display());
}
for arg in std::env::args().skip(1).filter(|arg| !arg.starts_with("-")) {
let source_root = fs::canonicalize(arg).unwrap();
capture(&source_root, &dest, force);
}
}
fn capture(source_root: &Path, dest: &Path, force: bool) {
let name = Path::new(source_root.file_name().unwrap());
let mut dest_gz = PathBuf::from(dest);
dest_gz.push(name);
dest_gz.set_extension("tgz");
if dest_gz.exists() {
if !force {
panic!(
"dest {:?} already exists, use -f to force overwriting",
dest_gz
);
}
fs::remove_file(&dest_gz).unwrap();
}
let vcs_info = capture_vcs_info(source_root, force);
let dst = fs::File::create(&dest_gz).unwrap();
let encoder = GzBuilder::new()
.filename(format!("{}.tar", name.to_str().unwrap()))
.write(dst, Compression::best());
let mut ar = tar::Builder::new(encoder);
ar.mode(tar::HeaderMode::Deterministic);
if let Some(info) = &vcs_info {
add_ar_file(&mut ar, &name.join(".cargo_vcs_info.json"), info);
}
// Gather all local packages.
let metadata = cargo_metadata::MetadataCommand::new()
.manifest_path(source_root.join("Cargo.toml"))
.features(cargo_metadata::CargoOpt::AllFeatures)
.exec()
.expect("cargo_metadata failed");
let mut found_root = false;
for package in &metadata.packages {
if package.source.is_some() {
continue;
}
let manifest_path = package.manifest_path.as_std_path();
copy_manifest(&manifest_path, &mut ar, name, &source_root);
found_root |= manifest_path == source_root.join("Cargo.toml");
}
if !found_root {
// A virtual workspace.
let contents = fs::read_to_string(source_root.join("Cargo.toml")).unwrap();
assert!(!contents.contains("[package]"));
add_ar_file(&mut ar, &name.join("Cargo.toml"), &contents);
}
let lock = fs::read_to_string(source_root.join("Cargo.lock")).unwrap();
add_ar_file(&mut ar, &name.join("Cargo.lock"), &lock);
let encoder = ar.into_inner().unwrap();
encoder.finish().unwrap();
eprintln!("created {}", dest_gz.display());
}
fn copy_manifest<W: std::io::Write>(
manifest_path: &Path,
ar: &mut tar::Builder<W>,
name: &Path,
source_root: &Path,
) {
let relative_path = manifest_path
.parent()
.unwrap()
.strip_prefix(source_root)
.expect("workspace member should be under workspace root");
let relative_path = name.join(relative_path);
let contents = fs::read_to_string(&manifest_path).unwrap();
let mut manifest: toml::Value = toml::from_str(&contents).unwrap();
let remove = |obj: &mut toml::Value, name| {
let table = obj.as_table_mut().unwrap();
if table.contains_key(name) {
table.remove(name);
}
};
remove(&mut manifest, "lib");
remove(&mut manifest, "bin");
remove(&mut manifest, "example");
remove(&mut manifest, "test");
remove(&mut manifest, "bench");
remove(&mut manifest, "profile");
if let Some(package) = manifest.get_mut("package") {
remove(package, "default-run");
}
let contents = toml::to_string(&manifest).unwrap();
add_ar_file(ar, &relative_path.join("Cargo.toml"), &contents);
add_ar_file(ar, &relative_path.join("src").join("lib.rs"), "");
}
fn add_ar_file<W: std::io::Write>(ar: &mut tar::Builder<W>, path: &Path, contents: &str) {
let mut header = tar::Header::new_gnu();
header.set_entry_type(tar::EntryType::file());
header.set_mode(0o644);
header.set_size(contents.len() as u64);
header.set_mtime(123456789);
header.set_cksum();
ar.append_data(&mut header, path, contents.as_bytes())
.unwrap();
}
fn capture_vcs_info(ws_root: &Path, force: bool) -> Option<String> {
let maybe_git = |command: &str| {
Command::new("git")
.current_dir(ws_root)
.args(command.split_whitespace().collect::<Vec<_>>())
.output()
.expect("git should be installed")
};
assert!(ws_root.join("Cargo.toml").exists());
let relative = maybe_git("ls-files --full-name Cargo.toml");
if !relative.status.success() {
if !force {
panic!("git repository not detected, use -f to force");
}
return None;
}
let p = Path::new(std::str::from_utf8(&relative.stdout).unwrap().trim());
let relative = p.parent().unwrap();
if !force {
let has_changes = !maybe_git("diff-index --quiet HEAD .").status.success();
if has_changes {
panic!("git repo appears to have changes, use -f to force, or clean the repo");
}
}
let commit = maybe_git("rev-parse HEAD");
assert!(commit.status.success());
let commit = std::str::from_utf8(&commit.stdout).unwrap().trim();
let remote = maybe_git("remote get-url origin");
assert!(remote.status.success());
let remote = std::str::from_utf8(&remote.stdout).unwrap().trim();
let info = format!(
"{{\n \"git\": {{\n \"sha1\": \"{}\",\n \"remote\": \"{}\"\n }},\
\n \"path_in_vcs\": \"{}\"\n}}\n",
commit,
remote,
relative.display()
);
eprintln!("recording vcs info:\n{}", info);
Some(info)
}

Binary file not shown.

BIN
benches/workspaces/diem.tgz Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
benches/workspaces/rust.tgz Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
benches/workspaces/tikv.tgz Normal file

Binary file not shown.

Binary file not shown.

View File

@ -28,6 +28,7 @@ pub use self::registry::{needs_custom_http_transport, registry_login, registry_l
pub use self::registry::{publish, registry_configuration, RegistryConfig};
pub use self::resolve::{
add_overrides, get_resolved_packages, resolve_with_previous, resolve_ws, resolve_ws_with_opts,
WorkspaceResolve,
};
pub use self::vendor::{vendor, VendorOptions};

View File

@ -16,5 +16,5 @@
- [Tests](./tests/index.md)
- [Running Tests](./tests/running.md)
- [Writing Tests](./tests/writing.md)
- [Profiling](./tests/profiling.md)
- [Benchmarking and Profiling](./tests/profiling.md)
- [Design Principles](./design.md)

View File

@ -1,4 +1,4 @@
# Profiling
# Benchmarking and Profiling
## Internal profiler
@ -11,7 +11,15 @@ profile stack to print results for.
CARGO_PROFILE=3 cargo generate-lockfile
```
## Informal profiling
## Benchmarking
### Benchsuite
Head over to the [`benches`
directory](https://github.com/rust-lang/cargo/tree/master/benches) for more
information about the benchmarking suite.
### Informal benchmarking
The overhead for starting a build should be kept as low as possible
(preferably, well under 0.5 seconds on most projects and systems). Currently,
@ -23,12 +31,10 @@ the primary parts that affect this are:
* Scanning the local project.
* Building the unit dependency graph.
We currently don't have any automated systems or tools for measuring or
tracking the startup time. We informally measure these on changes that are
likely to affect the performance. Usually this is done by measuring the time
for `cargo build` to finish in a large project where the build is fresh (no
actual compilation is performed). [Hyperfine] is a command-line tool that can
be used to roughly measure the difference between different commands and
settings.
One way to test this is to use [hyperfine]. This is a tool that can be used to
measure the difference between different commands and settings. Usually this
is done by measuring the time it takes for `cargo build` to finish in a large
project where the build is fresh (no actual compilation is performed). Just
run `cargo build` once before using hyperfine.
[Hyperfine]: https://github.com/sharkdp/hyperfine
[hyperfine]: https://github.com/sharkdp/hyperfine