Repository navigation
Block generation & buffering API design #31
Description
Activity
Attempted design:
pub trait BlockRngCore { type Item: Word; const LEN: usize; fn generate(&mut self, results: &mut [Self::Item; Self::LEN]); }
Problem: Rust doesn't support
generic_const_exprson stable yet. (I don't think the design even works on unstable Rust: it complains that theresults: [R::Item; R::LEN]field is an unconstrained generic constant.)Every design which attempts to precisely specify the buffer type hits this problem: it requires that support for generic const exprs.
We also can't constrain the buffer type to
Array<u32>orArray<W, _>since there is noArraytrait, hence why the status-quo uses the much looser boundAsMut<[W]>.Possible design from #26:
pub trait Generator { type Result; fn generate(&mut self, result: &mut Self::Result); }
This is a variation on the status quo which drops bounds on the
Resulttype, moving bounds to implementations.This succeeds in doing something the above attempted design does not: precisely specifying the buffer type. This fixes both issues pertaining to
BlockRngCore::Resultsabove.Possible minor simplification: remove
BlockRng64. Of our RNGs, onlyIsaac64Rnguses it, and this RNG is rather unimportant.It isn't quite so simple to move the
BlockRng64code to therand_isaaccrate since it usesfill_via_chunkswhich is internal (though that and itsObservabletrait could be copied too).Attempted design from #24: replace
blockmodule with helper fns:pub fn new_buffer<W: Word, const N: usize>() -> [W; N] { /* .. */ } pub fn next_word_via_gen_block<W: Word, const N: usize>( buf: &mut [W; N], mut generate_block: impl FnMut(&mut [W; N]), ) -> W { /* .. */ } pub fn next_u64_via_gen_block<const N: usize>( buf: &mut [u32; N], mut generate_block: impl FnMut(&mut [u32; N]), ) -> u64 { /* .. */ } pub fn fill_bytes_via_gen_block<W: Word, const N: usize>( mut dst: &mut [u8], buf: &mut [W; N], mut generate_block: impl FnMut(&mut [W; N]), ) { /* .. */ }
Issue: lack of type-safety for the buffer.
Issue: performance issues for
u32/u64generation withrand::rng()due toReseedingRnglacking direct access to a block-generation fn.I think the main question is whether we want a trait for block generation or not. Its primary use is
ReseedingRngand I am not sure it's a sufficient motivation. I have a rough idea of how we can implement it efficiently (albeit slightly ugly), but unfortunately I did not have time to work on it during previous week.The motivation is to avoid a significant penalty to
rand::rng()performance (reportedly 20-50% in rust-random/rand#1686). I believe we need some type of block generation trait to avoid that.#24 was interesting, but personally I did not like the lack of type safety around the buffer. I briefly considered adding a
Bufferstruct, but the result is mostly the same asBlockRng.I'm reasonably positive about #26 as a starting point. It can be worked further (e.g. integrating the index into the buffer, supporting serialization) but I'd prefer not to push that into the same PR.
I also think it's acceptable to just drop
BlockRng64(andu64support). It would be nice if we could simply move it intorand_isaac(the only user), but the current code usesfill_via_chunkswhich is internal torand_core.@vks @josephlr
Could you chime in? It does not seem I and @dhardy to reach any kind of agreement here. And I am getting a bit frustrated with the one-sided unapproved PR merges (why even have the merge restriction in the first place then?).I believe that we don't need
BlockRngand(Crypto)Generatortraits and the simple helperBlockBuffertype is sufficient. The problem withThreadRngperformance is more or less resolved in rust-random/rand#1686 by not relying onReseedingRng(since ChaCha already stores block counter inside itself we do not need to track size of generated data separately). The new versions ofReseedingRngis still a bit slower, but since it's no longer an important type without it being used inThreadRng, I think it's fine (also see this comment).For context, our disagreement started with #24 which makes a lot of changes at once — largely a rewrite of the
leandblockcode. My requests for reviews in #26 and #34 were dismissive of the general direction (and noting omissions which I had already noted potential solutions for, but didn't wish to merge in the same PR). Since responses largely ignored changes within the PR itself and we have not agreed on the central question of whether to keep theblock::Generatortrait I decided to move ahead with my plans so that we can compare the end result with @newpavlov's proposal (I do not wish to push all my changes into one large PR).As noted here, the
BlockBufferdesign requiresunsafecode inchacha20.Otherwise, the primary differences in approach appear to be whether to use the trait
block::Generatoras an interface and expose "core PRNGs" likeChaCha12Corevs passing a closure toBlockBuffermethods and hiding PRNG cores (with an exception inchacha20for use byThreadRng).I opened #36 which is the last of my planned changes to
block, though leaveslemostly unchanged. There are a bunch of changes tolein #24 that we might also want to merge.Shall I go ahead and merge this then rebase #24? (The conflicts are easy to resolve.)
I'd still like to evaluate the non-block-changes in #24 separately from the
blockchanges.I don't see the point of splitting the changes in so many PRs. Everything except the
serderemoval PR could've been just one PR. It would've been easier for users to inspect and result in a cleaner history. And if in the end it will be decided to follow #24, all those PRs will do nothing more than pollute the commit history. This is why I would've preferred if we first decided on the approach to follow before merging any PRs.As noted #34 (comment), the BlockBuffer design requires unsafe code in chacha20.
It's true for the current version of
zeroize, but it could be easily amended by adding ablack_box-like function. This way we will be able to write:self.buffer = Default::default(); zeroize::observe(&self.buffer);
pollute the commit history
Really?
getrandomhas many PRs just to updateCargo.lock(one of the reasons I do not want to commit that).#24 is a lot to review at once. I don't want to try because (a) it would be hard following the discussions of review comments and (b) every time you push changes there would be a lot of redundant re-reviewing (and no, reviewing only the changes since the last commit is often not enough). Pushing your test cases into doc examples only makes this harder to review in my experience.
It's true for the current version of
zeroize, but it could be easily amended by adding ablack_box-like function.zeroize::observedoesn't exist yet. I guess it would work, but would be fragile: someone who doesn't understand the buffer-zeroing code could well assume that those two lines of code can be evaluated independently (especially withoutunsafe).zeroize_flat_typeis arguably the better way to do this, if the caller cannot see the internals of the buffer type.getrandom has many PRs just to update Cargo.lock
These PRs can be easily skipped by users just based on the name, while your PRs layer on each other. And my point about merging unapproved PRs (which may be overwritten by #24) still stands. If you single handily committed to the
Generatortrait just say so, otherwise the merges were arguably premature.#24 is a lot to review at once.
~600 LoC out of which ~500 LoC are self-contained additions (out of which ~300 LoC are new docs) is "a lot to review"? We clearly have very different scales of "lot".
I could move the new docs into a separate PR if you prefer such "minimization", but IMO documentation should be added in the same PR which adds new APIs.
P.S.: We are not getting anywhere, so this comment probably will be my last here until others express their opinion.
Yes, I made the executive decision to merge the
GeneratorPR in its current state. I haven't decided what to release yet, though I am leaning towards keeping theGeneratortrait.I have merged #36.
Next, I would like to consider the subset of #24 not concerning
blockcode. If you'd like to make a PR for that I would be delighted. You may move code to new modules if you wish, though please clarify what is moved vs new code.Reacted by Artyom PavlovFeel free to adapt whatever you want from my PRs based on your sole vision. They represent what I would've liked to see (well apart from the
le->utilsrenaming) and I do not plan to split them or work on them any further.I will not block any future PRs in this repository to hopefully speed up the v0.10 release, but I probably also will not be submitting my approvals either, so feel free to just merge your PRs.
Of the issues noted in the opening comment above (largely inferred from #24):
- the results buffer is internal: the original impl of Replace
blockmodule with helper functions #24 did not use an internal buffer, but all subsequent approaches have moved away from this on the grounds that the original design had an obvious footgun (constructing the buffer withDefault::default()would result in bad output), thus no current proposals solve this issue. BlockRng64shouldn't need to be separate fromBlockRng: solved by both my code (merged) and Replaceblockmodule with helper functions #24BlockRngCore::Resultssupports variable-sized buffer types likeVec<u32>which it really shouldn't: solved by both my code (merged) and Replaceblockmodule with helper functions #24BlockRngCore::Resultsrequires aDefaultbound: solved by both my code (merged) and Replaceblockmodule with helper functions #24- this is quite a lot of code for what is supposed to just be an RNG-interface crate: 8007948 (last before Replace
blockmodule with helper functions #24) had 762 lines of Rust + 444 lines of comments. Updatedblock_bufferbranch has 545 + 535 lines. The current master has 587 + 445 lines. - (Not listed above but mentioned multiple times) Optimise buffer size by placing index in
buffer[0]: done by both my code (merged) and master
Alternatives
Issue (1) implies another possible design we haven't tried: allowing usage of a naked
[W; N]buffer without embedding the index in the buffer. This would of course not meet goal (6). It would also complicate helper functions since (1) the buffer, (2) the buffer index and (3) the generator closure would all need to be passed in as parameters (probably not a perf. issue due to inlining but certainly not a neat API).GeneratortraitI think the main question is whether we want a trait for block generation or not. Its primary use is
ReseedingRngand I am not sure it's a sufficient motivation.I believe that we don't need
BlockRngand(Crypto)Generatortraits and the simple helperBlockBuffertype is sufficient.@newpavlov hit the nail on the head two weeks ago: this is the largest point of contention. I apologise for annoyingly want to look at all the other questions first, but I do like to be able to break multi-faceted issues down into goals and sub-components.
The biggest cost/benefit from removing
block::Generatoris that we'd need/want to removeReseedingRngtoo. rust-random/rand#1686 shows that it's not strictly necessary forrand::rngs::ThreadRngandReseedingRngdoesn't have much other usage — a GitHub search shows some cases but most look unmaintained. It would also imply thatChaCha*Coreshould perhaps not be public (visible) exports.I'm still unsure on this, though leaning towards keeping
block::Generator.It would potentially pair better with the above alternative (closer to the original #24 but without the index in the buffer) since the block_buffer branch (i.e. #24) would require an
unsafecall tozeroize_flat_typeinchacha20.- the results buffer is internal: the original impl of Replace
the block_buffer branch (i.e. #24) would require an unsafe call to zeroize_flat_type in chacha20.
- It's not a big concern for
chacha20ifrand_coreis to promise thatBlockBufferis a wrapper around[u32/u64; N]. - We are working on
zeroize::optimization_barrier, which would allow us to implement buffer erasure as:
self.buffer = Default::default(); zeroize::optimization_barrier(&self.buffer);
- It's not a big concern for
To quote @tarcieri from the RustCrypto/utils#1252 PR:
I'm a bit worried about the notion that there's a user pulling in one crate which is expected to do some otherwise insecure zeroing, then separately pulling in
zeroizeto observe the writes the other crate is supposedly doing. Ensuring the entire operation is secure relies on implicit coupling between those two crates which the user is orchestrating, and the user has little way to tell if e.g. the zeroing operation in one crate has been removed and the observe is useless.It's worse than just this: the result relies on all future editors of that code understanding the link between two operations without an obvious code link and without even
unsafeto remind people to pay close attention. In my opinion it is a misguided approach to avoid usage ofunsafeinchacha20.And the current approach with the
Generator::dropis better? IMO it's even more coupled and convoluted. As you can see, @tarcieri could not understand how its application works from the first glance.The quote discusses the snippet with inherent
zeroizemethod which breaks the buffer invariant. It was a bad example from me and it's not worth to discuss it here.The quote does not apply to the snippet above. The worst what could happen with it is that writing the
defaultvalue will not overwrite everything in the destination (e.g. if we are to change the impl to useMaybeUninit). But it can be simply amended by promising in the type docs that the default value initializes all bits of the buffer state.I do not understand what kind of "link" you are talking about. We overwrite the buffer field with the default value and "observe" it to prevent the compiler from removing the write. That's it.
I do not understand what kind of "link" you are talking about. We overwrite the buffer field with the default value and "observe" it to prevent the compiler from removing the write. That's it.
These two operations are each ineffective on their own, yet together they have some effect. (See also the linked PR.)
And the current approach with the
Generator::dropis better? IMO it's even more coupled and convoluted.Coupled? Yes. (Perhaps semantically it should be separate from
block::Generatorbut realistically we don't have any use for another trait.) Convoluted? It's more code than straight up callingzeroize_flat_typeon the buffer but less than writing a custom wrapper around buffer just to implement aDrophandler, so it's fine in my opinion.
The above is rather besides the point IMO however, which is that we should choose between:
- Keeping "block generators" like
ChaCha20Coreas documented public exports of CSPRNG crates along withblock::Generatoras a formal interface over these. Additionally, keepingReseedingRngas a public adapter - Hiding block generators (but still exporting
ChaCha12Coreas a hidden public item since it is used byrand::rngs::ThreadRng) and removingReseedingRng(inlining functionality intoThreadRng).
- Keeping "block generators" like
I released https://crates.io/crates/rand_core/0.10.0-rc-3 with the new
block::GeneratorAPI.We have some motivation for keeping
block::Generator:ThreadRngevidences a use forChaCha12CoreandGeneratorprovides a description of this. (I'm less convinced thatReseedingRngshould remain public, but that's arandissue.)I will leave this issue open until the v0.10.0 release in case anyone else wishes to comment.
Have you checked generated assembly for
fill_bytes? I suspect it will be far from most efficient. The code in #24 was carefully optimized using generated assembly as a guide, but I guess you just went and ignored it...This is what I find strangest about your approach actually: doing a lot of work on a PR significantly altering the API before getting buy-in on the general direction.
As for optimization, I just used benchmarks and a rough comparison between the code (including #24 which uses quite a lot more code for this function). Measured speed was good; see #38.
What does "most efficient" mean? Lowest instruction count? Theoretically fastest on some simple CPU, maybe?
altering the API
The public API is the same, I am talking about internal implementation of the
fill_bytesmethod.What does "most efficient" mean?
Absence of panics, minimal number of branches, smaller instruction count, efficient "hot" loop which enables optimization of writing generated blocks directly into destination buffer.
Many (but not all) of the noted issues have been solved and v0.10 is released; with that this issue is closed (but see also #71).
Status quo (derived from last release): we have
Issue: the results buffer is internal, which makes it hard to do things like implement serde support externally. #30 allows this; the result is somewhat ugly but acceptable in my opinion.
Issue:
BlockRng64shouldn't need to be separate fromBlockRng(if we ignore thehalf_usedoptimisation), however Rust does not (yet) support deconflicting implementations over associated types (i.e. the twoimpls above are considered to conflict if both apply toBlockRng).Issue:
BlockRngCore::Resultssupports variable-sized buffer types likeVec<u32>which it really shouldn't.Issue:
BlockRngCore::Resultsrequires aDefaultbound (for construction), but[u32; 64]doesn't (yet) support this. Bothchacha20andrand_chachacrates use a customArraytype to work around this. Rust should fix this in the future: rust-lang/rust#61415.Issue: this is quite a lot of code for what is supposed to just be an RNG-interface crate.