-
Notifications
You must be signed in to change notification settings - Fork 2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
make small_version fit in a ptr #6
Conversation
src/small_version.rs
Outdated
|
||
ret |= if v.pre.is_empty() { | ||
Elem::MAX as usize | ||
} else if v.pre.as_str() == "0" { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this happen enough to be worth it? Or is this for translating version reqs into version ranges?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Versions with 0
as a pre release are not organically common. But they are the smallest version. So at least with the current version of the code, they do get generated a lot when translating version requirements into PubGrub ranges. A req like ^2
ends up being desugared as the range between 2.0.0-0
(inclusive) and 3.0.0-0
(exclusive).
In general I've done no actual statistics to figure out the best use of each available bit. The fact that I have effectively 15 bits unused (on a 64 bit cpu) is pretty clear evidence that we are not at a maximum.
After this has gotten appropriate safety review. I am totally open to altering the code so that this pattern is generated less. And/Or finding a more structured way to decide what patterns to support.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There are 46163 unique versions on crates.io. So if we generated the perfect lookup table it should take 16 bits to be able to uniquely address all of them. Clearly, there's a lot of room for improvement in what we consider small.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
40k unique versions seems like an extremely small number for the whole of crates.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe not too far off. The crates with the most number of versions have 50k versions it seems.
src/small_version.rs
Outdated
/// A one pointer wide representation of common `semver::Version`s or a `Arc<semver::Version>` | ||
/// | ||
/// A `semver::Version` is quite large (5 ptr) to support all kinds of uncommon use cases. | ||
/// A `Arc<semver::Version>` is 1 aligned ptr, but always allocates and has a cash miss when read. | ||
/// In practice most versions could be accurately represented by `[u8; 3]`, which is smaller than 1 ptr. | ||
/// So this type represents common versions as a usize and uses `Arc` for full generality. | ||
/// The discriminant is hidden in the unused alignment bits of the `Arc`. | ||
/// | ||
/// The exact set of versions that are common enough to get a small representation depends on the size of a pointer | ||
/// and is subject to change between releases. | ||
#[derive(Debug, Eq)] | ||
#[repr(packed)] | ||
pub struct SmallVersion(*const semver::Version); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A technique we've adopted in zerocopy in lieu of unsafe fields is defining invariant-carrying structs in a def
module that limits access to its implicit constructor; e.g.:
/// A one pointer wide representation of common `semver::Version`s or a `Arc<semver::Version>` | |
/// | |
/// A `semver::Version` is quite large (5 ptr) to support all kinds of uncommon use cases. | |
/// A `Arc<semver::Version>` is 1 aligned ptr, but always allocates and has a cash miss when read. | |
/// In practice most versions could be accurately represented by `[u8; 3]`, which is smaller than 1 ptr. | |
/// So this type represents common versions as a usize and uses `Arc` for full generality. | |
/// The discriminant is hidden in the unused alignment bits of the `Arc`. | |
/// | |
/// The exact set of versions that are common enough to get a small representation depends on the size of a pointer | |
/// and is subject to change between releases. | |
#[derive(Debug, Eq)] | |
#[repr(packed)] | |
pub struct SmallVersion(*const semver::Version); | |
mod def { | |
pub struct SmallVersion { | |
/// The version, either packed into a pointer, or allocated on the heap. | |
/// | |
/// # Safety | |
/// | |
/// If and only if the least significant is `0`, `raw` is derived from | |
/// [`Arc::into_raw`]. | |
/// | |
/// # Invariants | |
/// | |
/// If and only if the least significant bit is `1`, the value of `raw` | |
/// should be interpreted as having the layout of [`PackedVersion`]. | |
raw: *const semver::Version | |
} | |
impl SmallVersion { | |
/// The version, either packed into a pointer, or allocated on the heap. | |
/// | |
/// # Safety | |
/// | |
/// See [`SmallVersion::raw`]. | |
pub(super) unsafe fn from_raw(raw: *const semver::Version) -> Self { | |
// SAFETY: Invariants guaranteed by caller. | |
unsafe { Self { raw } } | |
} | |
pub(super) unsafe fn into_raw(self) -> *const semver::Version { | |
self.raw | |
} | |
} | |
pub(super) PackedVersion { | |
/// # Safety | |
/// | |
/// The least significant bit is `0`. | |
/// | |
/// # Invariants | |
/// | |
/// The most significant [`Elem`] encodes a SemVer major version, | |
/// the next encodes a minor version, and the next encodes a patch | |
/// version. The most significant bit of the least significant | |
/// `Elem` is `1` if the version is a pre-release; otherwise `0`. | |
raw: usize, | |
} | |
impl PackedVersion { | |
/// Constructs a packed version. | |
/// | |
/// # Panics | |
/// | |
/// Panics if the least significant bit of `raw` is `0`. | |
pub(super) fn from_raw(raw: usize) -> Self { | |
// Enforce | |
assert_eq!(raw & 1, 1) | |
// SAFETY: Invariants guaranteed by above assertion. | |
unsafe { Self { raw } } | |
} | |
/// Produces the raw packed representation. | |
pub(super) fn into_raw(self) -> usize { | |
self.raw | |
} | |
} | |
} | |
pub use def::*; |
This ensures that consumers of SmallVersion
must write unsafe
before doing something unsafe (like constructing an invalid SmallVersion
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I had consider having a separate module, a separate file seemed too much overhead given that it's going to end up having ~1/3 code of this already not very big module. But an in-line module might be a good compromise here. I will give it a try.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note that most of your existing impl
s should still be placed outside the def
module. The only things that go in the def
module are constructors, getters, and setters (i.e., things that touch or construct conceptually unsafe fields).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Trying to get a sense of what should be inside and outside this module. It feels natural to have SmallVersion::from_arc(arc: Arc<semver::Version>) -> Self
and a SmallVersion::from_packed(pack: PackedVersion) -> Self
inside the module, instead of having one unsafe from_raw
inside and from_arc
and from_packed
outside. Does that seem reasonable to you?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since the whole thing is a workaround for not having unsafe fields, my preference is to keep the things inside the def
module extremely lightweight — just basic constructors/getters/setters. The more complicated, safe items then call into these unsafe items, and Rust's safety tooling makes sure all these use sites are properly unsafe { ... }
'd and documented with safety comments. See zerocopy's Ptr
definition for an example of the pattern. IMO, this pattern has helped us evolve our highly unsafe codebase in a more sustainable way over a long period of time.
Once unsafe fields are stabilized, the def
hack can be removed.
What you suggest isn't unreasonable though! I think from_arc
and from_packed
are basically one-liners (especially with the zerocopy struct trick), so they're probably lightweight enough to put in the def
module.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Trying to wrap my head around what should be in the def
and what should be outside. On one hand almost nothing, this is a hack for unsafe fields. On the other hand anything that accesses the raw data, which ends up being almost everything.
I did the best I could, and pushed it. Let me know what you think.
src/small_version.rs
Outdated
fn from(v: semver::Version) -> Self { | ||
match (&v).try_into() { | ||
Ok(Small(s)) => { | ||
let out = SmallVersion(without_provenance(s)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is missing a // SAFETY
comment — why does without_provenance(s)
conform to the invariants of SmallVersion
? (This is the sort of missed safety comment that the aforementioned def
trick helps avoid.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Odd that std::without_provenance is safe, given that it should have safety comments. Misunderstood what you were saying, I get it now.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's the SmallVersion(...)
invocation that's conceptually unsafe — its field has a safety invariant that must be upheld. The without_provenance(s)
invocation is fine.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
perhaps split it over two lines:
let out = SmallVersion(without_provenance(s)); | |
let raw = without_provenance(s); | |
// SAFETY: the least significant bit of `raw` is `1`. (but say more about why this is true) | |
let out = SmallVersion(raw); |
src/small_version.rs
Outdated
patch: u16, | ||
pre: bool, | ||
#[derive(Debug, PartialEq, Eq, Clone, Hash, PartialOrd, Ord)] | ||
struct Small(usize); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think Small
's field carries a safety invariant that needs documentation. Perhaps PackedVersion
might be a more apt name? I kept finding myself mixed up between SmallVersion
and Small
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also, rather than manipulating bits yourself, you could do a target_endian
cfg to switch between two struct definitions with the correct layout, then just use normal field accesses:
#[cfg(target_endian = "little")]
#[repr(C)]
struct PackedVersion {
tag_and_pre: Elem,
patch: Elem,
minor: Elem,
major: Elem,
}
#[cfg(target_endian = "big")]
#[repr(C)]
struct PackedVersion {
major: Elem,
minor: Elem,
patch: Elem,
tag_and_pre: Elem,
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
^This probably gets messy because Elem
can be two bytes, and so endian matters there, too. But maybe less messy than bitops?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've tried several representations:
- A plain struct, reading fields is very simple but converting from and to a usize is ugly.
- A
[Elme; 4]
, reading fields is not to bad but converting from and to a usize is not grate. - A
[u8; size_of::<ptr>()]
, reading fields is ugly but converting from and to a usize is just afrom_bl_bits
. - A
usize
, you can see the consistent mess that is. It has the advantage of not havingendian
specific code I need to test. but is otherwise pretty painful.
But not yet tried #[repr(C)]
. I imagine that reading fields would be as easy as the plane struct. The question is how hard would it be to convert into a pointer/usize?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The question is how hard would it be to convert into a pointer/usize?
With zerocopy, you'd roughly achieve it like so:
#[derive(zerocopy::FromBytes, zerocopy::IntoBytes)]
#[cfg(target_endian = "little")]
#[repr(C)]
struct PackedVersion {
tag_and_pre: Elem,
patch: Elem,
minor: Elem,
major: Elem,
}
impl PackedVersion {
fn into_usize(self) -> usize {
zerocopy::transmute!(self)
}
fn from_usize(raw: usize) -> Self {
assert_eq!(raw & 1, 1)
zerocopy::transmute!(raw)
}
}
The safety boundary here is weaker (since FromBytes
is an unchecked constructor), but we can shore that up with TryFromBytes
and a few more lines of code.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Concretely, you'd do so like this:
#[derive(TryFromBytes, IntoBytes)]
#[cfg_attr(target_pointer_width = "32", repr(u8))]
#[cfg_attr(target_pointer_width = "64", repr(u16))]
enum Pre {
No = 1,
Yes = 3,
}
#[derive(IntoBytes, TryFromBytes)]
#[repr(C)]
struct PackedVersion {
pre: Pre,
patch: Elem,
minor: Elem,
major: Elem,
}
impl PackedVersion {
fn into_raw(self) -> usize {
zerocopy::transmute!(self)
}
fn from_raw(raw: usize) -> Option<Self> {
zerocopy::try_transmute!(raw).ok()
}
}
This has the advantage, I think, that the LSB constraint is now impossible to violate. (But note that the pre
bit is now the second-least significant.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
and, without a unsafe field it doesn't need to live in def
! I pushed more code, let me know what you think. I needed to custom impl Ord, to get it to match Version
. and while I was at it I did a bunch of others as well. Although whether it's more efficient to convert and then do a compare or the other way round is not clear to me. If ever shows up in a benchmark I can revisit it.
What you think?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A few nits, but this looks good to me! I'm extremely pleased we managed to encode PackedVersion
as a Rust type with no unsafe code!
Co-authored-by: Jack Wrenn <me@jswrenn.com>
Co-authored-by: Jack Wrenn <me@jswrenn.com>
Co-authored-by: Jack Wrenn <me@jswrenn.com>
Co-authored-by: Jack Wrenn <me@jswrenn.com>
Some notes for safety reviewers. We never dereference a modified pointer. If a pointer is
Full
then it was constructed fromArc::into_raw
and never modified. When we can construct aSmall
we arrange the appropriate bits in ausize
and pretend it's a*const
, but we never dereference that value. I believe this makes it fall under thestrict provenance
model, or at least thefuzzy_provenance_casts
andlossy_provenance_casts
lints now pass.to quote https://doc.rust-lang.org/std/ptr/index.html