Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/.cspell/project-dictionary.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
binstall
objc
PRNG
qpmember
subcrate
vvpmember
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ Note: In this file, do not use the hard wrap in the middle of a sentence for com

## [Unreleased]

- Add `--partition-seed <SEED>` option for deterministic shuffling of `--partition` assignment. Accepts any string (e.g. a git short hash), hashed stably with FNV-1a; the same seed produces the same assignment across all `M/N` runs, balancing load when some workspace members are heavier than others.

## [0.6.44] - 2026-03-20

- Publish [artifact attestations](https://docs.github.com/en/actions/concepts/security/artifact-attestations).
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,12 @@ OPTIONS:
--partition <M/N>
Partition runs and execute only its subset according to M/N.

--partition-seed <SEED>
Seed string to shuffle partition assignment for load balancing.

Requires --partition. Any string is accepted (e.g. a git commit hash); the same seed
produces the same assignment across all M/N runs.

--log-group <KIND>
Log grouping: none, github-actions.

Expand Down
18 changes: 18 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ pub(crate) struct Args {
pub(crate) keep_going: bool,
/// --partition
pub(crate) partition: Option<Partition>,
/// --partition-seed (hashed via FNV-1a so the user can pass any string, e.g. a git short hash)
pub(crate) partition_seed: Option<u64>,
/// --print-command-list
pub(crate) print_command_list: bool,
/// --version-range/--rust-version
Expand Down Expand Up @@ -159,6 +161,7 @@ impl Args {
let mut clean_per_version = false;
let mut keep_going = false;
let mut partition = None;
let mut partition_seed: Option<String> = None;
let mut print_command_list = false;
let mut no_manifest_path = false;
let mut locked = false;
Expand Down Expand Up @@ -313,6 +316,7 @@ impl Args {
Long("clean-per-version") => parse_flag!(clean_per_version),
Long("keep-going") => parse_flag!(keep_going),
Long("partition") => parse_opt!(partition, false),
Long("partition-seed") => parse_opt!(partition_seed, false),
Long("print-command-list") => parse_flag!(print_command_list),
Long("no-manifest-path") => parse_flag!(no_manifest_path),
Long("locked") => parse_flag!(locked),
Expand Down Expand Up @@ -574,6 +578,10 @@ impl Args {
};

let partition = partition.as_deref().map(str::parse).transpose()?;
let partition_seed = partition_seed.as_deref().map(crate::fnv1a_64);
if partition_seed.is_some() && partition.is_none() {
bail!("--partition-seed can only be used together with --partition");
}

if no_dev_deps || no_private {
let flag = if no_dev_deps && no_private {
Expand Down Expand Up @@ -625,6 +633,7 @@ impl Args {
clean_per_version,
keep_going,
partition,
partition_seed,
print_command_list,
no_manifest_path,
include_features: include_features.into_iter().map(Into::into).collect(),
Expand Down Expand Up @@ -844,6 +853,15 @@ const HELP: &[HelpText<'_>] = &[
("", "--keep-going", "", "Keep going on failure", &[]),
("", "--partition", "<M/N>", "Partition runs and execute only its subset according to M/N", &[
]),
(
"",
"--partition-seed",
"<SEED>",
"Seed string to shuffle partition assignment for load balancing",
&[
"Requires --partition. Any string is accepted (e.g. a git commit hash); the same seed produces the same assignment across all M/N runs.",
],
),
("", "--log-group", "<KIND>", "Log grouping: none, github-actions", &[
"If this option is not used, the environment will be automatically detected.",
]),
Expand Down
50 changes: 48 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ fn try_main() -> Result<()> {
}
}
}
progress.ensure_permutation(cx.partition_seed);

// First, generate the lockfile using the oldest cargo specified.
// https://github.com/taiki-e/cargo-hack/issues/105
Expand All @@ -151,6 +152,7 @@ fn try_main() -> Result<()> {
} else {
let total = packages.iter().map(|p| p.feature_count).sum();
progress.total = total;
progress.ensure_permutation(cx.partition_seed);
default_cargo_exec_on_packages(cx, &packages, &mut progress, &mut keep_going)?;
}
if keep_going.count > 0 {
Expand All @@ -165,12 +167,56 @@ fn try_main() -> Result<()> {
struct Progress {
total: usize,
count: usize,
/// Permutation of `0..total` controlling partition assignment when `--partition-seed` is set.
/// `None` preserves the original contiguous-chunk behavior.
permutation: Option<Vec<usize>>,
}

impl Progress {
fn ensure_permutation(&mut self, seed: Option<u64>) {
let Some(seed) = seed else { return };
if self.permutation.is_some() {
return;
}
let mut perm: Vec<usize> = (0..self.total).collect();
let mut rng = SplitMix64(seed);
for i in (1..perm.len()).rev() {
// `rng.next() % bound` is in `0..=i`, which always fits in usize.
#[allow(clippy::cast_possible_truncation)]
let j = (rng.next() % (i as u64 + 1)) as usize;
perm.swap(i, j);
}
self.permutation = Some(perm);
}

fn in_partition(&self, partition: &Partition) -> bool {
let current_index = self.count / self.total.div_ceil(partition.count);
current_index == partition.index
let pos = match &self.permutation {
Some(p) => p[self.count],
None => self.count,
};
pos / self.total.div_ceil(partition.count) == partition.index
}
}

/// FNV-1a 64-bit. Stable across platforms and Rust versions, unlike `std::hash::DefaultHasher`.
pub(crate) fn fnv1a_64(s: &str) -> u64 {
let mut h: u64 = 0xCBF2_9CE4_8422_2325;
for &b in s.as_bytes() {
h ^= u64::from(b);
h = h.wrapping_mul(0x0000_0100_0000_01B3);
}
h
}

/// SplitMix64 PRNG. Used to drive a deterministic Fisher-Yates shuffle from a single u64 seed.
struct SplitMix64(u64);
impl SplitMix64 {
fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
}

Expand Down
6 changes: 6 additions & 0 deletions tests/long-help.txt
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,12 @@ OPTIONS:
--partition <M/N>
Partition runs and execute only its subset according to M/N.

--partition-seed <SEED>
Seed string to shuffle partition assignment for load balancing.

Requires --partition. Any string is accepted (e.g. a git commit hash); the same seed
produces the same assignment across all M/N runs.

--log-group <KIND>
Log grouping: none, github-actions.

Expand Down
2 changes: 2 additions & 0 deletions tests/short-help.txt
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ OPTIONS:
--keep-going Keep going on failure
--partition <M/N> Partition runs and execute only its subset according to
M/N
--partition-seed <SEED> Seed string to shuffle partition assignment for load
balancing
--log-group <KIND> Log grouping: none, github-actions
--print-command-list Print commands without run (Unstable)
--no-manifest-path Do not pass --manifest-path option to cargo (Unstable)
Expand Down
68 changes: 68 additions & 0 deletions tests/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2015,6 +2015,74 @@ fn partition_bad() {
.stderr_contains(
"The argument '--partition' was provided more than once, but cannot be used multiple times",
);

cargo_hack(["check", "--each-feature", "--partition-seed", "abc"])
.assert_failure("real")
.stderr_contains("--partition-seed can only be used together with --partition");
}

#[test]
fn partition_seeded() {
/// Captured stderr is collapsed onto one line by the test helper. Scan the joined string
/// for `(running|skipping) `<cmd>`` markers and return them sorted, so we can compare
/// without depending on per-run noise (tempdir paths, cargo build output, etc).
fn extract_decisions(stderr: &str) -> Vec<String> {
let mut out = vec![];
for marker in ["running `", "skipping `"] {
let mut rest = stderr;
while let Some(idx) = rest.find(marker) {
let after = &rest[idx..];
let end = after.find(')').expect("decision line ends with `(N/M)`");
out.push(after[..=end].to_owned());
rest = &after[marker.len()..];
}
}
out.sort();
out
}

fn extract_running(stderr: &str) -> Vec<String> {
extract_decisions(stderr).into_iter().filter(|l| l.starts_with("running `")).collect()
}

let seed = "abc1234";

// Same seed -> identical partition decisions (determinism).
let out1 =
cargo_hack(["check", "--feature-powerset", "--partition", "1/3", "--partition-seed", seed])
.assert_success("real");
let out2 =
cargo_hack(["check", "--feature-powerset", "--partition", "1/3", "--partition-seed", seed])
.assert_success("real");
assert_eq!(
extract_decisions(&out1.0.as_ref().unwrap().stderr),
extract_decisions(&out2.0.as_ref().unwrap().stderr),
);

// Each partition runs the expected unseeded chunk size, and the union of "running" commands
// across all partitions covers exactly the 17 invocations with no duplicates.
let mut running = vec![];
let mut sizes = vec![];
for m in 1..=3 {
let out = cargo_hack([
"check",
"--feature-powerset",
"--partition",
&format!("{m}/3"),
"--partition-seed",
seed,
])
.assert_success("real");
let lines = extract_running(&out.0.as_ref().unwrap().stderr);
sizes.push(lines.len());
running.extend(lines);
}
assert_eq!(sizes, vec![6, 6, 5], "partition sizes should match the unseeded chunking");
assert_eq!(running.len(), 17);
let mut unique = running.clone();
unique.sort();
unique.dedup();
assert_eq!(unique.len(), 17, "no invocation should run in more than one partition");
}

#[test]
Expand Down
Loading