Skip to content

Commit d8048a2

Browse files
authored
Rollup merge of #154865 - sunshowers:binary-search-test, r=Mark-Simulacrum
libtest: use binary search for --exact test filtering When `--exact` is passed in, use binary search for O(f log n) lookups instead of an O(n) linear scan, under the assumption that f << n (which is true for the most relevant cases). This is important for Miri, where the interpreted execution makes the linear scan very expensive. I measured this against a repo with 1000 empty tests, running `cargo +stage1 miri nextest run test_00` (100 tests) under hyperfine: * Before (linear scan): 49.7s ± 0.6s * After (binary search): 41.9s ± 0.2s (-15.7%) I also tried a few other variations (particularly swapping matching tests to the front of the list + truncating the list), but the index + swap_remove approach proved to be the fastest. Questions: - [ ] To be conservative, I've assumed that test_main can potentially receive an unsorted list of tests. Is this assumption correct?
2 parents 877c205 + bbb9e3b commit d8048a2

5 files changed

Lines changed: 121 additions & 28 deletions

File tree

compiler/rustc_builtin_macros/src/test_harness.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,8 @@ fn mk_tests_slice(cx: &TestCtxt<'_>, sp: Span) -> Box<ast::Expr> {
370370
let ecx = &cx.ext_cx;
371371

372372
let mut tests = cx.test_cases.clone();
373+
// Note that this sort is load-bearing: the libtest harness uses binary search to find tests by
374+
// name.
373375
tests.sort_by(|a, b| a.name.as_str().cmp(b.name.as_str()));
374376

375377
ecx.expr_array_ref(

library/test/src/console.rs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use super::helpers::metrics::MetricMap;
1616
use super::options::{Options, OutputFormat};
1717
use super::test_result::TestResult;
1818
use super::time::{TestExecTime, TestSuiteExecTime};
19-
use super::types::{NamePadding, TestDesc, TestDescAndFn};
19+
use super::types::{NamePadding, TestDesc, TestDescAndFn, TestList};
2020
use super::{filter_tests, run_tests, term};
2121

2222
/// Generic wrapper over stdout.
@@ -170,7 +170,7 @@ impl ConsoleTestState {
170170
}
171171

172172
// List the tests to console, and optionally to logfile. Filters are honored.
173-
pub(crate) fn list_tests_console(opts: &TestOpts, tests: Vec<TestDescAndFn>) -> io::Result<()> {
173+
pub(crate) fn list_tests_console(opts: &TestOpts, tests: TestList) -> io::Result<()> {
174174
let output = match term::stdout() {
175175
None => OutputLocation::Raw(io::stdout().lock()),
176176
Some(t) => OutputLocation::Pretty(t),
@@ -186,7 +186,7 @@ pub(crate) fn list_tests_console(opts: &TestOpts, tests: Vec<TestDescAndFn>) ->
186186
let mut st = ConsoleTestDiscoveryState::new(opts)?;
187187

188188
out.write_discovery_start()?;
189-
for test in filter_tests(opts, tests).into_iter() {
189+
for test in filter_tests(opts, tests) {
190190
use crate::TestFn::*;
191191

192192
let TestDescAndFn { desc, testfn } = test;
@@ -307,8 +307,9 @@ pub(crate) fn get_formatter(opts: &TestOpts, max_name_len: usize) -> Box<dyn Out
307307

308308
/// A simple console test runner.
309309
/// Runs provided tests reporting process and results to the stdout.
310-
pub fn run_tests_console(opts: &TestOpts, tests: Vec<TestDescAndFn>) -> io::Result<bool> {
310+
pub fn run_tests_console(opts: &TestOpts, tests: TestList) -> io::Result<bool> {
311311
let max_name_len = tests
312+
.tests
312313
.iter()
313314
.max_by_key(|t| len_if_padded(t))
314315
.map(|t| t.desc.name.as_slice().len())

library/test/src/lib.rs

Lines changed: 78 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ pub mod test {
4949
pub use crate::time::{TestExecTime, TestTimeOptions};
5050
pub use crate::types::{
5151
DynTestFn, DynTestName, StaticBenchFn, StaticTestFn, StaticTestName, TestDesc,
52-
TestDescAndFn, TestId, TestName, TestType,
52+
TestDescAndFn, TestId, TestList, TestListOrder, TestName, TestType,
5353
};
5454
pub use crate::{assert_test_result, filter_tests, run_test, test_main, test_main_static};
5555
}
@@ -106,6 +106,16 @@ pub fn test_main_with_exit_callback<F: FnOnce()>(
106106
tests: Vec<TestDescAndFn>,
107107
options: Option<Options>,
108108
exit_callback: F,
109+
) {
110+
let tests = TestList::new(tests, TestListOrder::Unsorted);
111+
test_main_inner(args, tests, options, exit_callback)
112+
}
113+
114+
fn test_main_inner<F: FnOnce()>(
115+
args: &[String],
116+
tests: TestList,
117+
options: Option<Options>,
118+
exit_callback: F,
109119
) {
110120
let mut opts = match cli::parse_opts(args) {
111121
Some(Ok(o)) => o,
@@ -180,7 +190,9 @@ pub fn test_main_with_exit_callback<F: FnOnce()>(
180190
pub fn test_main_static(tests: &[&TestDescAndFn]) {
181191
let args = env::args().collect::<Vec<_>>();
182192
let owned_tests: Vec<_> = tests.iter().map(make_owned_test).collect();
183-
test_main(&args, owned_tests, None)
193+
// Tests are sorted by name at compile time by mk_tests_slice.
194+
let tests = TestList::new(owned_tests, TestListOrder::Sorted);
195+
test_main_inner(&args, tests, None, || {})
184196
}
185197

186198
/// A variant optimized for invocation with a static test vector.
@@ -229,7 +241,9 @@ pub fn test_main_static_abort(tests: &[&TestDescAndFn]) {
229241

230242
let args = env::args().collect::<Vec<_>>();
231243
let owned_tests: Vec<_> = tests.iter().map(make_owned_test).collect();
232-
test_main(&args, owned_tests, Some(Options::new().panic_abort(true)))
244+
// Tests are sorted by name at compile time by mk_tests_slice.
245+
let tests = TestList::new(owned_tests, TestListOrder::Sorted);
246+
test_main_inner(&args, tests, Some(Options::new().panic_abort(true)), || {})
233247
}
234248

235249
/// Clones static values for putting into a dynamic vector, which test_main()
@@ -298,7 +312,7 @@ impl FilteredTests {
298312

299313
pub fn run_tests<F>(
300314
opts: &TestOpts,
301-
tests: Vec<TestDescAndFn>,
315+
tests: TestList,
302316
mut notify_about_test_event: F,
303317
) -> io::Result<()>
304318
where
@@ -334,7 +348,7 @@ where
334348
timeout: Instant,
335349
}
336350

337-
let tests_len = tests.len();
351+
let tests_len = tests.tests.len();
338352

339353
let mut filtered = FilteredTests { tests: Vec::new(), benches: Vec::new(), next_id: 0 };
340354

@@ -512,25 +526,48 @@ where
512526
Ok(())
513527
}
514528

515-
pub fn filter_tests(opts: &TestOpts, tests: Vec<TestDescAndFn>) -> Vec<TestDescAndFn> {
529+
pub fn filter_tests(opts: &TestOpts, tests: TestList) -> Vec<TestDescAndFn> {
530+
let TestList { tests, order } = tests;
516531
let mut filtered = tests;
517-
let matches_filter = |test: &TestDescAndFn, filter: &str| {
518-
let test_name = test.desc.name.as_slice();
519-
520-
match opts.filter_exact {
521-
true => test_name == filter,
522-
false => test_name.contains(filter),
523-
}
524-
};
525532

526-
// Remove tests that don't match the test filter
533+
// Remove tests that don't match the test filter.
527534
if !opts.filters.is_empty() {
528-
filtered.retain(|test| opts.filters.iter().any(|filter| matches_filter(test, filter)));
535+
if opts.filter_exact && order == TestListOrder::Sorted {
536+
// Let's say that `f` is the number of filters and `n` is the number
537+
// of tests.
538+
//
539+
// The test array is sorted by name (guaranteed by the caller via
540+
// TestListOrder::Sorted), so use binary search for O(f log n)
541+
// exact-match lookups instead of an O(n) linear scan.
542+
//
543+
// This is important for Miri, where the interpreted execution makes
544+
// the linear scan very expensive.
545+
filtered = filter_exact_match(filtered, &opts.filters);
546+
} else {
547+
filtered.retain(|test| {
548+
let test_name = test.desc.name.as_slice();
549+
opts.filters.iter().any(|filter| {
550+
if opts.filter_exact {
551+
test_name == filter.as_str()
552+
} else {
553+
test_name.contains(filter.as_str())
554+
}
555+
})
556+
});
557+
}
529558
}
530559

531560
// Skip tests that match any of the skip filters
561+
//
562+
// After exact positive filtering above, the filtered set is small, so a
563+
// linear scan is acceptable even under Miri.
532564
if !opts.skip.is_empty() {
533-
filtered.retain(|test| !opts.skip.iter().any(|sf| matches_filter(test, sf)));
565+
filtered.retain(|test| {
566+
let name = test.desc.name.as_slice();
567+
!opts.skip.iter().any(|sf| {
568+
if opts.filter_exact { name == sf.as_str() } else { name.contains(sf.as_str()) }
569+
})
570+
});
534571
}
535572

536573
// Excludes #[should_panic] tests
@@ -553,6 +590,30 @@ pub fn filter_tests(opts: &TestOpts, tests: Vec<TestDescAndFn>) -> Vec<TestDescA
553590
filtered
554591
}
555592

593+
/// Extract tests whose names exactly match one of the given `filters`, using
594+
/// binary search on the (assumed sorted) test list.
595+
fn filter_exact_match(mut tests: Vec<TestDescAndFn>, filters: &[String]) -> Vec<TestDescAndFn> {
596+
// Binary search for each filter in the sorted test list.
597+
let mut indexes: Vec<usize> = filters
598+
.iter()
599+
.filter_map(|f| tests.binary_search_by(|t| t.desc.name.as_slice().cmp(f.as_str())).ok())
600+
.collect();
601+
indexes.sort_unstable();
602+
indexes.dedup();
603+
604+
// Extract matching tests. Process indexes in descending order so that
605+
// swap_remove (which replaces the removed element with the last) does not
606+
// invalidate indexes we haven't visited yet.
607+
let mut result = Vec::with_capacity(indexes.len());
608+
for &idx in indexes.iter().rev() {
609+
result.push(tests.swap_remove(idx));
610+
}
611+
// Reverse to restore the original sorted order, since we extracted the
612+
// matching tests in descending index order.
613+
result.reverse();
614+
result
615+
}
616+
556617
pub fn convert_benchmarks_to_tests(tests: Vec<TestDescAndFn>) -> Vec<TestDescAndFn> {
557618
// convert benchmarks to tests, if we're not benchmarking them
558619
tests

library/test/src/tests.rs

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -477,7 +477,7 @@ fn filter_for_ignored_option() {
477477
opts.run_tests = true;
478478
opts.run_ignored = RunIgnored::Only;
479479

480-
let tests = one_ignored_one_unignored_test();
480+
let tests = TestList::new(one_ignored_one_unignored_test(), TestListOrder::Unsorted);
481481
let filtered = filter_tests(&opts, tests);
482482

483483
assert_eq!(filtered.len(), 1);
@@ -494,7 +494,7 @@ fn run_include_ignored_option() {
494494
opts.run_tests = true;
495495
opts.run_ignored = RunIgnored::Yes;
496496

497-
let tests = one_ignored_one_unignored_test();
497+
let tests = TestList::new(one_ignored_one_unignored_test(), TestListOrder::Unsorted);
498498
let filtered = filter_tests(&opts, tests);
499499

500500
assert_eq!(filtered.len(), 2);
@@ -527,16 +527,16 @@ fn exclude_should_panic_option() {
527527
testfn: DynTestFn(Box::new(move || Ok(()))),
528528
});
529529

530-
let filtered = filter_tests(&opts, tests);
530+
let filtered = filter_tests(&opts, TestList::new(tests, TestListOrder::Unsorted));
531531

532532
assert_eq!(filtered.len(), 2);
533533
assert!(filtered.iter().all(|test| test.desc.should_panic == ShouldPanic::No));
534534
}
535535

536536
#[test]
537537
fn exact_filter_match() {
538-
fn tests() -> Vec<TestDescAndFn> {
539-
["base", "base::test", "base::test1", "base::test2"]
538+
fn tests() -> TestList {
539+
let tests = ["base", "base::test", "base::test1", "base::test2"]
540540
.into_iter()
541541
.map(|name| TestDescAndFn {
542542
desc: TestDesc {
@@ -555,7 +555,8 @@ fn exact_filter_match() {
555555
},
556556
testfn: DynTestFn(Box::new(move || Ok(()))),
557557
})
558-
.collect()
558+
.collect();
559+
TestList::new(tests, TestListOrder::Sorted)
559560
}
560561

561562
let substr =
@@ -908,7 +909,8 @@ fn test_dyn_bench_returning_err_fails_when_run_as_test() {
908909
}
909910
Ok(())
910911
};
911-
run_tests(&TestOpts { run_tests: true, ..TestOpts::new() }, vec![desc], notify).unwrap();
912+
let tests = TestList::new(vec![desc], TestListOrder::Unsorted);
913+
run_tests(&TestOpts { run_tests: true, ..TestOpts::new() }, tests, notify).unwrap();
912914
let result = rx.recv().unwrap().result;
913915
assert_eq!(result, TrFailed);
914916
}

library/test/src/types.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,3 +284,30 @@ impl TestDescAndFn {
284284
}
285285
}
286286
}
287+
288+
/// Whether a [`TestList`]'s tests are known to be sorted by name.
289+
///
290+
/// When tests are sorted, `filter_tests` can use binary search for `--exact`
291+
/// matches instead of a linear scan.
292+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
293+
pub enum TestListOrder {
294+
/// Tests are sorted by name. This is guaranteed for tests generated by
295+
/// `rustc --test` (see `mk_tests_slice` in
296+
/// `compiler/rustc_builtin_macros/src/test_harness.rs`).
297+
Sorted,
298+
/// Test order is unknown; binary search must not be used.
299+
Unsorted,
300+
}
301+
302+
/// A list of tests, tagged with whether they are sorted by name.
303+
#[derive(Debug)]
304+
pub struct TestList {
305+
pub tests: Vec<TestDescAndFn>,
306+
pub order: TestListOrder,
307+
}
308+
309+
impl TestList {
310+
pub fn new(tests: Vec<TestDescAndFn>, order: TestListOrder) -> Self {
311+
Self { tests, order }
312+
}
313+
}

0 commit comments

Comments
 (0)