Skip to content
Closed
39 changes: 39 additions & 0 deletions e2e/power-efficiency.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { availableParallelism } from "node:os";

import { expect, test } from "./fixtures/electron-app";
import { scanFolderFromPicker } from "./fixtures/steps";

// The header's Power Efficiency menu saves a preset, and the next scan's
// walker runs that many workers. The Unix walker logs its count to
// crash.log. The Windows walkers hand a tree this small to the
// sequential walker, which logs none.
test("a Power Efficiency choice reaches the next scan's walker", async ({ launch, scanTree }) => {
test.skip(process.platform === "win32", "the Windows walker logs no worker count for a small tree");
const cpus = availableParallelism();
const handle = await launch();
const { page } = handle;
const walking = async () => ((await page.evaluate(() => window.diskhound.getCrashLog())).text
.match(/walking with dua-core, \d+ threads/g) ?? []);

await scanFolderFromPicker(handle, scanTree.root);
// Balanced by default: 4 workers, or every CPU on a smaller machine.
expect(await walking()).toEqual([`walking with dua-core, ${Math.min(4, cpus)} threads`]);

const gauge = page.locator(".header .power-btn");
await expect(gauge).toHaveAttribute("aria-label", /^Power Efficiency: (Balanced|Miser), /);
await gauge.click();
await expect(page.getByRole("menu", { name: "Power Efficiency" })).toBeVisible();
await page.getByRole("menuitemradio", { name: /^Miser/ }).click();
await expect(page.getByRole("menu", { name: "Power Efficiency" })).toBeHidden();
const miser = Math.min(2, cpus);
await expect(page.locator(".toast-title")).toHaveText(`Miser (${miser} worker${miser === 1 ? "" : "s"}) from the next scan`);
await expect(gauge.locator(".power-gauge i.lit")).toHaveCount(1);

await page.getByRole("button", { name: "Rescan now" }).click();
await expect.poll(walking, { timeout: 45_000 }).toEqual([
`walking with dua-core, ${Math.min(4, cpus)} threads`,
`walking with dua-core, ${miser} threads`,
]);
const settings = await page.evaluate(() => window.diskhound.getSettings());
expect(settings.scanning.powerEfficiency).toBe("miser");
});
117 changes: 93 additions & 24 deletions native/diskhound-native-scanner/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ struct ScanInput {
/// which it has no other use for.
#[cfg_attr(windows, allow(dead_code))]
expected_total_files: Option<u64>,
/// Walker workers, from the app's Power Efficiency setting
/// (`--workers`). None keeps the walker's own default. See
/// [`walk_workers`].
workers: Option<usize>,
}

/// Disk touches one scan makes. The visit-once tests check these against
Expand Down Expand Up @@ -1537,16 +1541,16 @@ fn scan_generic_with_plan(
// Directory enumeration is embarrassingly parallel at the I/O layer,
// since reads of separate directories hit different inode blocks.
//
// At most 8 threads, like the Windows walker. On an 18-core M5 Max a
// full `/` scan (21.4M files) took 2m 51s at 16 threads and 3m 22s at
// 8, but 16 used 31% more CPU time (995 s vs 759 s) and peaked near
// 1,000% CPU instead of 650%. Past 8, extra threads mostly add kernel
// time. DISKHOUND_PARALLEL_THREADS overrides it.
let thread_override = std::env::var("DISKHOUND_PARALLEL_THREADS")
.ok()
.and_then(|s| s.parse::<usize>().ok())
.filter(|&n| n >= 1);
let thread_count = thread_override.unwrap_or_else(|| {
// The app passes the Power Efficiency choice as --workers (Balanced,
// 4, by default). On an 18-core M5 Max a full `/` scan (20.7M files,
// index and sidecars written; medians of 3 from
// scripts/bench-scan-workers.py) took 250 s and 661 CPU-s at 4
// workers, 267 s and 913 CPU-s at 8, 234 s and 1,088 CPU-s at 18, and
// 334 s and 558 CPU-s at 2. The loop below takes every entry on one
// thread and runs near a full core, so past 4 more walkers mostly add
// CPU time. Without --workers (the CLI), at most 8, like the Windows
// walker. DISKHOUND_PARALLEL_THREADS overrides both.
let thread_count = walk_workers(state.input.workers, {
let logical = num_cpus::get().max(1);
if logical <= 2 {
logical
Expand Down Expand Up @@ -2018,15 +2022,16 @@ fn emit_mft_records_into_state(
// merging at end remains correct because the global top-K is
// guaranteed to be contained in the union of all shards' top-Ks.
//
// Shard count: env override or num_cpus(), clamped to [1, 8] to
// match the pre-seed parallel walker's tuning. 8 threads on a
// 16-logical-core box gives good speedup without saturating L3.
let shard_count = std::env::var("DISKHOUND_EMIT_THREADS")
.ok()
.and_then(|s| s.parse::<usize>().ok())
.filter(|&n| n >= 1)
.unwrap_or_else(|| num_cpus::get().clamp(1, 8));
let shard_count = shard_count.max(1);
// Shard count: the MFT-specific env override wins for experiments,
// then the app's Power Efficiency choice, then num_cpus() clamped
// to [1, 8]. 8 threads on a 16-logical-core box gives good speedup
// without saturating L3.
let emit_override = std::env::var("DISKHOUND_EMIT_THREADS").ok();
let shard_count = choose_mft_emit_workers(
emit_override.as_deref(),
state.input.workers,
num_cpus::get().clamp(1, 8),
);

// Slice the Vec into contiguous chunks. Using split_off shuffles
// Strings between heaps unnecessarily; Vec::chunks_mut would work
Expand Down Expand Up @@ -3018,11 +3023,7 @@ fn try_scan_windows_parallel(root_path: &Path, state: &mut ScanState) -> Paralle
}

// Step 2 — set up parallel walk.
let worker_count = std::env::var("DISKHOUND_PARALLEL_THREADS")
.ok()
.and_then(|s| s.parse::<usize>().ok())
.filter(|&n| n >= 1)
.unwrap_or_else(|| num_cpus::get().clamp(2, 8));
let worker_count = walk_workers(state.input.workers, num_cpus::get().clamp(2, 8));
// Don't clamp to root_children.len(). Extra workers block on the
// Condvar waiting for subdirs to appear in the queue, then steal
// them as the initial workers enumerate. Clamping here would starve
Expand Down Expand Up @@ -3980,6 +3981,7 @@ fn parse_args() -> Result<ScanInput, String> {
let mut folder_tree_output: Option<PathBuf> = None;
let mut dev_artifacts_output: Option<PathBuf> = None;
let mut expected_total_files: Option<u64> = None;
let mut workers: Option<usize> = None;
let mut args = std::env::args().skip(1);

while let Some(argument) = args.next() {
Expand Down Expand Up @@ -4038,6 +4040,18 @@ fn parse_args() -> Result<ScanInput, String> {
value.parse::<u64>().map_err(|_| format!("Invalid --expected-files: {value}"))?,
);
}
"--workers" => {
let value = args
.next()
.ok_or_else(|| String::from("Expected a number after --workers"))?;
workers = Some(
value
.parse::<usize>()
.ok()
.filter(|&n| n >= 1)
.ok_or_else(|| format!("Invalid --workers: {value}"))?,
);
}
unknown => {
return Err(format!("Unknown argument: {unknown}"));
}
Expand All @@ -4058,9 +4072,36 @@ fn parse_args() -> Result<ScanInput, String> {
folder_tree_output,
dev_artifacts_output,
expected_total_files,
workers,
})
}

/// How many workers a walker runs. `DISKHOUND_PARALLEL_THREADS` wins, for
/// experiments; then `--workers`, the app's Power Efficiency choice; then
/// the walker's own `default`.
fn walk_workers(requested: Option<usize>, default: usize) -> usize {
let env = std::env::var("DISKHOUND_PARALLEL_THREADS").ok();
choose_walk_workers(env.as_deref(), requested, default)
}

fn choose_walk_workers(env: Option<&str>, requested: Option<usize>, default: usize) -> usize {
let from_env = env
.and_then(|s| s.trim().parse::<usize>().ok())
.filter(|&n| n >= 1);
from_env.or(requested).unwrap_or(default).max(1)
}

/// MFT emission uses its own diagnostic override, but otherwise honors
/// the same Power Efficiency worker count as the directory walkers.
#[cfg(any(windows, test))]
fn choose_mft_emit_workers(
emit_env: Option<&str>,
requested: Option<usize>,
default: usize,
) -> usize {
choose_walk_workers(emit_env, requested, default)
}

fn file_extension(file_name: &str) -> String {
Path::new(file_name)
.extension()
Expand Down Expand Up @@ -4850,6 +4891,33 @@ fn windows_extended_path(path: &Path) -> String {
format!(r"\\?\{}", normalized)
}

#[cfg(test)]
mod walk_workers_tests {
use super::{choose_mft_emit_workers, choose_walk_workers};

#[test]
fn the_env_override_wins_then_the_app_choice_then_the_walker_default() {
assert_eq!(choose_walk_workers(None, None, 8), 8);
assert_eq!(choose_walk_workers(None, Some(2), 8), 2);
assert_eq!(choose_walk_workers(None, Some(18), 8), 18);
assert_eq!(choose_walk_workers(Some("12"), Some(2), 8), 12);
assert_eq!(choose_walk_workers(Some(" 3 "), None, 8), 3);
// A bad or zero override is ignored, as before.
assert_eq!(choose_walk_workers(Some("0"), Some(4), 8), 4);
assert_eq!(choose_walk_workers(Some("lots"), None, 8), 8);
assert_eq!(choose_walk_workers(None, None, 0), 1);
}

#[test]
fn mft_emit_honors_the_app_choice_but_keeps_its_explicit_override() {
assert_eq!(choose_mft_emit_workers(None, Some(2), 8), 2);
assert_eq!(choose_mft_emit_workers(None, Some(4), 8), 4);
assert_eq!(choose_mft_emit_workers(Some("6"), Some(2), 8), 6);
assert_eq!(choose_mft_emit_workers(Some("bad"), Some(4), 8), 4);
assert_eq!(choose_mft_emit_workers(None, None, 8), 8);
}
}

#[cfg(test)]
mod index_line_parse_tests {
use super::index_line::parse_index_line;
Expand Down Expand Up @@ -4989,6 +5057,7 @@ mod scaling_tests {
folder_tree_output: None,
dev_artifacts_output: None,
expected_total_files: None,
workers: None,
};
let mut state = ScanState::new(input, root, None, None, Arc::new(IoStats::default()));
// Never emit: tests call refresh_hottest_directories directly.
Expand Down
1 change: 1 addition & 0 deletions native/diskhound-native-scanner/src/test_support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ pub fn scan_input(root: &Path, index_output: &Path) -> ScanInput {
folder_tree_output: None,
dev_artifacts_output: None,
expected_total_files: None,
workers: None,
}
}

Expand Down
Loading
Loading