Skip to content

Performance improvements for shuffle and partial_shuffle - #1272

Merged
dhardy merged 11 commits into
rust-random:masterfrom
wainwrightmark:shuffle
Jan 8, 2023
Merged

Performance improvements for shuffle and partial_shuffle#1272
dhardy merged 11 commits into
rust-random:masterfrom
wainwrightmark:shuffle

Conversation

@wainwrightmark

Copy link
Copy Markdown
Contributor

This is Related to #1266 but completely orthogonal to #1268 which improves the performance of a different set of methods in a different way.

This improves the performance of SliceRandom::shuffle() and SliceRandom::partial_shuffle() by essentially batching the random number generation.

It seems to be about 50-100% faster for most slice lengths, with less performance improvement for longer slices. It will use the old method for slices with length longer than 2^32.

This is a value breaking change.

Benchmark results

Partial Shuffle

Partial shuffle half of the slice

Number of ElementsRngOld ns/iterNew ns/iterRatio (new:old)
10CryptoRng44271.62962963
10SmallRng30211.428571429
100CryptoRng4091642.493902439
100SmallRng2701302.076923077
1000CryptoRng3,6161,9931.814350226
1000SmallRng2,4741,3641.813782991
10000CryptoRng38,60726,2861.468728601
10000SmallRng27,82416,9271.6437644

Shuffle

Number of ElementsRngOld ns/iterNew ns/iterRatio (new:old)
1CryptoRng00N/A
1SmallRng00N/A
2CryptoRng1181.375
2SmallRng851.6
3CryptoRng1892
3SmallRng1362.166666667
10CryptoRng88253.52
10SmallRng61163.8125
100CryptoRng8723252.683076923
100SmallRng5522452.253061224
1000CryptoRng7,2193,9101.84629156
1000SmallRng5,0572,6501.908301887
10000CryptoRng76,06150,0411.519973622
10000SmallRng55,68232,7151.702032707

@TheIronBorn

Copy link
Copy Markdown
Contributor

Huh, I've always thought shuffling was memory bound. Impressive work

Comment threadbenches/shuffle.rs Outdated
Comment threadbenches/shuffle.rs Outdated
@wainwrightmark

Copy link
Copy Markdown
ContributorAuthor

If we did have a way of determining native output size (#1261) using 64 bit chunks would give significant performance improvements when shuffling longer sequences. Unfortunately this would lead to different values on 32 and 64 bit machines.

@dhardy

Copy link
Copy Markdown
Member

Unfortunately this would lead to different values on 32 and 64 bit machines.

Only where a different RNG is used on these machines (e.g. SmallRng which is a platform-dependent type-def), in which case results would already differ.

There is a question of which chunk size we should use by default given that 64-bit CPUs are now the norm, though it would penalise results for short lists with e.g. ChaCha.

Actually, we should run benchmarks with both chunk sizes with several RNGs (e.g. Pcg32, Pcg64 and ChaCha12; don't need two variants of ChaCha) — as a hack that doesn't need to be committed. I'll do this for your other PR.

@wainwrightmark

Copy link
Copy Markdown
ContributorAuthor

I ran the benchmarks comparing u32 and u64 versions.
Unsurprisingly, the main factor seems to be the number of elements. Regardless of the RNG, with 10 or fewer elements using u64 is a lot slower, with 100 it's about the same, and with 1000 or 10000 you start to see the benefit.
Of course this is me running on a 64 bit machine - on a 32 bit machine all those 64 bit div operations would probably be a lot slower.

MethodElementsRngunitsu32u64Ratio
shuffle1ChaCha12ps147.861611.088867848
shuffle2ChaCha12ns7.675517.9232.335092176
shuffle3ChaCha12ns9.096719.4452.137588356
shuffle10ChaCha12ns19.8343.9892.218305598
partial_shuffle10ChaCha12ns22.27837.4431.680716402
shuffle100ChaCha12ns296310.141.04777027
partial_shuffle100ChaCha12ns153.41164.681.073463268
shuffle1000ChaCha12µs3.39723.2390.9534322383
partial_shuffle1000ChaCha12µs1.76981.56420.8838286812
shuffle10000ChaCha12µs42.68935.480.8311274567
partial_shuffle10000ChaCha12µs22.19917.560.7910266228
shuffle1Pcg32ps153.99158.821.031365673
shuffle2Pcg32ns6.206213.4582.168476685
shuffle3Pcg32ns7.248315.4992.138294497
shuffle10Pcg32ns16.55938.72.337097651
partial_shuffle10Pcg32ns29.09833.4071.148085779
shuffle100Pcg32ns238.94281.991.180170754
partial_shuffle100Pcg32ns128.43147.251.146538971
shuffle1000Pcg32µs2.6672.63140.9866516685
partial_shuffle1000Pcg32µs1.39331.24010.8900452164
shuffle10000Pcg32µs32.28328.930.8961372859
partial_shuffle10000Pcg32µs16.6214.0750.8468712395
shuffle1Pcg64ps176.42149.30.8462759324
shuffle2Pcg64ns7.493113.8651.850369006
shuffle3Pcg64ns8.597415.2911.77856096
shuffle10Pcg64ns19.85137.8431.906352325
partial_shuffle10Pcg64ns21.43430.9141.442287954
shuffle100Pcg64ns265.08291.271.098800362
partial_shuffle100Pcg64ns141.66151.391.068685585
shuffle1000Pcg64µs3.06942.71470.8844399557
partial_shuffle1000Pcg64µs1.60571.30930.8154076104
shuffle10000Pcg64µs39.19929.1330.7432077349
partial_shuffle10000Pcg64µs20.26414.0980.6957165417

@dhardy

Copy link
Copy Markdown
Member

Pcg64 being faster with 64-bit chunks for large sizes is not surprising since the 32-bit version is discarding random bits, but the significant losses for ≤ 10 elements and only moderate wins at 10'000 elements means it's still questionable whether 64-bit chunks is an improvement.

Meanwhile ChaCha and Pcg32 also see gains despite not discarding random bits in the same way.

We could use this to select a different shuffling implementation based on the slice length, regardless of RNG algorithm. But is there enough interest in shuffling large slices to justify the extra complexity? Probably better to stick with 32-bit only.

Thanks for the extra benchmarks @wainwrightmark.

@wainwrightmark

Copy link
Copy Markdown
ContributorAuthor

Pcg64 being faster with 64-bit chunks for large sizes is not surprising since the 32-bit version is discarding random bits, but the significant losses for ≤ 10 elements and only moderate wins at 10'000 elements means it's still questionable whether 64-bit chunks is an improvement.

Meanwhile ChaCha and Pcg32 also see gains despite not discarding random bits in the same way.

We could use this to select a different shuffling implementation based on the slice length, regardless of RNG algorithm. But is there enough interest in shuffling large slices to justify the extra complexity? Probably better to stick with 32-bit only.

Thanks for the extra benchmarks @wainwrightmark.

I agree.

@dhardydhardy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please rebase and add a copyright header to increasing_uniform.rs (also coin_flipper.rs, missed in the last PR).

Please also run rustfmt src/seq/increasing_uniform.rs and wrap long comments. Less comments may be better — detailed explanations like this run the risk of becoming outdated (I mostly didn't read them).

Comment threadsrc/seq/increasing_uniform.rs Outdated
Comment threadsrc/seq/increasing_uniform.rs Outdated
Comment threadsrc/seq/mod.rs
self.swap(i, index);
}
} else {
for i in m..self.len() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You need to reverse the iterator (both loops). Your code can only "choose" the last element of the list with probability 1/len when it should be m/len.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ermm, I'm pretty sure I've got this right. The last element gets swapped to a random place in the list so it has a m/len probability of being in the first m elements. Earlier elements are more likely to be chosen initially but can get booted out by later ones. The test_shuffle test is checking this and I've also tried similar tests with longer lists and more runs.

The reason I don't reverse the iterator is because the increasing_uniform needs i to increase and a decreasing version would be more complicated.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay. We previously reversed since this way the proof by induction is easier. But we can also prove this algorithm works.

First, lets not use m = len - amount since in the last PR we used m = amount. I'll continue to use end = len - amount.

Lets say we have a list elts = [e0, e1, .., ei, ..] of length len. Elements are "chosen" if they appear in elts[end..len] after the algorithm; additionally we need to show that this slice is fully shuffled.

Algorithm is:

for i in end..len {
elts.swap(i, rng.sample_range(0..=i));}

For any length, for amount = 0 or amount = 1, this is clearly correct. We'll prove by induction, assuming that the algorithm is already proven correct for amount-1 and len-1 (so that end does not change and the algorithm only has one last swap to perform).

Thus, we assume:

  • For any elt ei, we have P(ei in elts[0..end]) = end/(len-1) [here we say nothing about element order]
  • For any elt ei, for any k in end..(len-1), P(elts[k] = ei) = (amount-1)/(len-1) [fully shuffled]

We perform the last step of the algorithm: let x = sample_range(0..=len); elts.swap(len-1, x);. Now:

  • Any element in elts[0..end] is moved to elts[len-1] with probability 1/len, thus for any elt ei except e_last, P(ei in elts[0..end]) = end/(len-1) * (len-1)/len = end/len
  • For any elt ei previously in elts[end..len-1], the chance it is not moved is (len-1)/len, thus, for these ei, for any k in end..(len-1), P(elts[k] = ei) = (amount-1)/(len-1) * (len-1)/len = (amount-1)/len
  • For any elt ei previously in elts[end..len-1], P(elts[len-1] = ei) = 1/len
  • The previous two points together imply that for any ei previously in elts[end..len-1], for any k in end..len, P(elts[k] = ei) = (amount-1+1)/len = amount/len
  • Element e_last may appear in any position with probability 1/len

Thus each element has chance amount/len to appear in ents[end..len] and this slice is fully shuffled.

Comment threadsrc/seq/mod.rs
Comment threadsrc/seq/mod.rs Outdated
Comment threadsrc/seq/increasing_uniform.rs Outdated
Comment threadsrc/seq/increasing_uniform.rs
Comment on lines +49 to +62
let r = self.chunk % next_n;
self.chunk /= next_n;
r as usize

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's probably also room for further optimisation here: modulus is a slow operation (see https://www.pcg-random.org/posts/bounded-rands.html).

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did read that article and it helped me find some of the optimizations I used for this. I also tried using a method based on bitmask but it turned out about 50% slower than this. Obviously I could easily have missed something.

@dhardydhardy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

@dhardy
dhardy merged commit 4bde8a0 into rust-random:masterJan 8, 2023
benjamin-lieser pushed a commit to benjamin-lieser/rand that referenced this pull request Feb 5, 2025
…ndom#1272)
* Made shuffle and partial_shuffle faster
* Use criterion benchmarks for shuffle
* Added a note about RNG word size
* Tidied comments
* Added a debug_assert
* Added a comment re possible further optimization
* Added and updated copyright notices
* Revert cfg mistake
* Reverted change to mod.rs
* Removed ChaCha20 benches from shuffle
* moved debug_assert out of a const fn
vedhavyas added a commit to autonomys/subspace that referenced this pull request May 14, 2026
API migrations to land alongside the dependabot batch:
- derive_more 1→2: `with_trait::` import in pieces.rs; drop unused
AsMut/AsRef/From/Into imports in sectors.rs and solutions.rs
- bincode 1→2: pinned to 2.0.1 (3.0.0 is an upstream compile_error
placeholder; bincode-org repo archived 2025-08), staking fuzz now
calls `bincode::serde::decode_from_slice` with legacy config,
enabled `serde` feature on the pallet-domains dep
- chacha20 0.9→0.10: enabled the new `cipher` feature in
subspace-proof-of-space
- array-bytes 6→9: replaced `array_bytes::bytes2hex("", x)` with
`hex::encode(x)` across MMR / fraud-proof / snap-sync; dropped
the array-bytes dep from those three crates
- bytesize 1→2: `bytesize::to_string(n, iec)` →
`bytesize::ByteSize::b(n).display().iec()|.si()` across farmer
+ archiver
- fs4 0.9→1.x: `fs4::fs_std::FileExt::try_lock_exclusive` →
`fs4::FileExt::try_lock`
- async-nats 0.37→0.48: cover new `RequestErrorKind::InvalidSubject`
variant in nats_client + plotter
- criterion 0.5→0.8: drop `criterion::black_box` (deprecated) and
use `std::hint::black_box` across all bench files
Reverted because polkadot-sdk pins them transitively:
- prometheus 0.14.0 → 0.13.4
- async-channel 2.5 → 1.9.0
- jsonrpsee 0.26 → 0.24.10
Reverted because the libp2p fork pins it:
- prometheus-client 0.24.1 → 0.23.1
Reverted as a consensus-break risk:
- rand 0.8.5 (kept), rand_chacha 0.9.0 → 0.3.1, rand_core 0.10.1 →
0.6.4. rand 0.9 rewrote `SliceRandom::shuffle` to a faster
non-reproducible algorithm (rust-random/rand#1272). Since
`sp_domains::shuffle_extrinsics` uses `positions.shuffle(&mut rng)`
with a ChaCha8Rng to determine on-chain extrinsic ordering,
taking rand 0.9 would silently change that ordering at the fork
point — a consensus break. rand_chacha and rand_core are bytestream-
stable (empirically verified) but blocked transitively: rand_chacha
0.9 needs rand_core 0.9, while rand 0.8 needs rand_core 0.6, and
the SeedableRng trait impls don't cross those version boundaries.
The shuffle_extrinsics_should_work test in domain-block-preprocessor
catches it.
vedhavyas added a commit to autonomys/subspace that referenced this pull request May 14, 2026
Permanent skips:
- rand 0.9 changed SliceRandom::shuffle to a non-reproducible algorithm
(rust-random/rand#1272). sp_domains::shuffle_extrinsics relies on
deterministic shuffle output for on-chain extrinsic ordering, so
taking rand 0.9 would be a consensus break. rand_chacha and rand_core
are bytestream-stable but blocked transitively by rand 0.8 → rand_core
0.6 dependency.
- bincode-org/bincode was archived 2025-08; 3.0.0 on crates.io is a
compile_error! placeholder. Stay on 2.0.x until an ecosystem successor
emerges.
Temporarily fork-pinned (kept in dependabot, recheck on next upgrade):
- jsonrpsee, prometheus, async-channel — polkadot-sdk fork
- prometheus-client — libp2p fork
vedhavyas added a commit to autonomys/subspace that referenced this pull request May 14, 2026
Adds permanent and temporary skips so dependabot stops opening grouped
cargo PRs that include deps we have to revert.
Permanent (would break us if taken):
- rand 0.9 changed SliceRandom::shuffle to a non-reproducible algorithm
(rust-random/rand#1272). sp_domains::shuffle_extrinsics relies on
deterministic shuffle output, so the bump is a consensus break.
rand_chacha and rand_core are bytestream-stable but blocked
transitively by rand 0.8's rand_core 0.6 dependency. TODO: lift
once shuffle_extrinsics is rewritten to drive Fisher-Yates directly
over RngCore.
- bincode 3.0.0 on crates.io is a compile_error! placeholder; the
upstream repo was archived 2025-08. TODO: lift when the ecosystem
picks a maintained successor.
Temporary, gated on fork rebases (in ignore so dependabot doesn't
re-open weekly PRs that can't merge; TODOs flag the unlock signal):
- jsonrpsee, prometheus, async-channel — polkadot-sdk fork pins
- prometheus-client — libp2p fork pins
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@wainwrightmark@TheIronBorn@dhardy