Repository navigation
perf(integer): back Int and UInt with OverflowingInt - #1064
Conversation
popematt
left a comment
There was a problem hiding this comment.
The direction of this PR is good, and I'm looking forward to publishing this in a new release because getting Element down to 64 bytes is really meaningful win. Everything appears to be correct; there are just a few maintainability issues that could be improved.
Earlier I said that OverflowingInt should have all of the necessary functionality for the types that wrap it. I should have been more specific, because I don't think that extends to every byte encoding. (I forgot to explicitly say "minimally necessary set".) I think the rule of thumb is:
OverflowingIntexposes nounsafeoperations.OverflowingInthas a minimal set of operations that are necessary for the wrapper types to do what they need to do. The wrapper types can have richer sets of operations, but those should be built on the minimal set.- Operations that involve two
OverflowingInts (selfandother) belong toOverflowingInt. - Basic traits like
Display,Debug, andClonebelong toOverflowingInt, both because many types should have them and so that the wrapper types can derive them.
Format-specific encodings belong with the format. Methods like magnitude_be_bytes only read the value and can't violate any invariant, so they don't need to be on OverflowingInt. This is what I meant by having as_overflowing_int() so that serialization code can borrow the underlying representation: the encoder borrows magnitude_ref(), produces its bytes at the point of writing, and only allocates when it has to (in the heap arm, or at a public API that returns a Vec<u8>).
Relatedly, where a wrapper enforces its invariant on a raw representation, it would be good to keep that invariant enforcement in only one place. In this case, we don't have complicated constructor logic, so we can just route all construction through a TryFrom impl. I've left the specifics as inline comments.
This doesn't need to block the PR. If you'd rather make the encoder changes in a follow-up, that's fine.
| return self.clone(); | ||
| /// Decodes an unsigned magnitude. Leading zero bytes don't affect the result, and an empty | ||
| /// slice is zero. | ||
| pub(crate) fn from_unsigned_bytes(bytes: &[u8], order: ByteOrder) -> Self { |
There was a problem hiding this comment.
Both callers pass a constant, and none of the other byte methods take a ByteOrder. Can you split from_unsigned_bytes into from_unsigned_le_bytes and from_unsigned_be_bytes and delete the enum so that we're not introducing unnecessary branching?
| let mut be = value.as_overflowing_int().magnitude_be_bytes(); | ||
| Self::write_sign_and_magnitude(sink, &mut be, value.is_negative()) |
There was a problem hiding this comment.
Within the crate, I think we should use magnitude_ref() so that we're borrowing the representation as much as possible/until the last possible moment when we can no longer borrow it. This will make it easier in future to eliminate some of the unnecessary allocation in the writer.
| let mut be = value.as_overflowing_int().magnitude_be_bytes(); | |
| Self::write_sign_and_magnitude(sink, &mut be, value.is_negative()) | |
| let magnitude = value.as_overflowing_int().magnitude_ref(); | |
| match magnitude { | |
| Magnitude::Small(small) => { | |
| let mut be = small.to_be_bytes(); | |
| Self::write_sign_and_magnitude(sink, &mut be, value.is_negative()) | |
| } | |
| Magnitude::Big(big) => cold_path! {{ | |
| let mut be = big.to_bytes_be(); | |
| Self::write_sign_and_magnitude(sink, &mut be, value.is_negative()) | |
| }} | |
| } |
| self.data.neg().into() | ||
| Int { | ||
| repr: self.repr.minus(), | ||
| } |
There was a problem hiding this comment.
This takes self by value, but minus(&self) → with_sign(&self) clones the heap BigUint and then drops the original. Every decoder builds negative values with .neg(), so with the 126-bit inline limit, reading i128::MIN now allocates twice. minus has no other non-test callers, so can you change it (and with_sign) to take self? Then the heap case moves the Box instead of cloning it, and the inline case becomes a single bit flip in the offset-0 word. That's a safe write to a Copy union field, and the value stays canonical because the magnitude doesn't change. abs(&self) would need an explicit self.clone().with_sign(...), which is fine.
This method body would then become:
self.repr.minus();
selfAlso who named it minus() when negate() would be a much better name?! (It was me 🤦🏻♂️)
| /// The value as the primitive integer `T` when it fits. | ||
| pub(crate) fn to_primitive<T: TryFrom<i128> + TryFrom<u128>>(&self) -> Option<T> { | ||
| match self.as_i128() { | ||
| Some(value) => T::try_from(value).ok(), | ||
| // Above `i128::MAX` (or below `i128::MIN`, where `as_u128` is `None`). | ||
| None => self.as_u128().and_then(|value| T::try_from(value).ok()), | ||
| } | ||
| } |
There was a problem hiding this comment.
as_i128 and as_u128 already exist to support conversions to primitives, so I don't think OverflowingInt needs a generic to_primitive as well. The wrappers can use as_i128() for conversions to signed types, and as_u128() for conversion to unsigned types, and then narrow from there as needed.
| /// arms, which is what lets the scaling guards and byte-length calculations | ||
| /// avoid a conversion. | ||
| /// arms, which is what lets the scaling guards avoid a conversion. |
There was a problem hiding this comment.
Was there a change in this PR that affects the correctness of the comment or was this something that was already inaccurate?
| } else { | ||
| magnitude | ||
| Ok(Int { | ||
| repr: value.clone(), |
There was a problem hiding this comment.
We should delete this impl and replace it with an owned TryFrom<OverflowingInt> for Int (and the equivalent for UInt). Those become the single guarded entry point from a raw representation for each wrapper.
Hiding a clone() inside a TryFrom is a reasonable convenience for public API (std does the same thing with From<&str> for String), but this one is a crate-private conversion between a wrapper and its representation, and it makes the cost invisible to performance-sensitive internal code. Its only caller is Coefficient::as_int(), and every caller of as_int() ends up cloning when it didn't need to:
TryFrom<Coefficient> for Intalready owns the coefficient, but clones the repr anywayTryFrom<&Coefficient> for Intclones the coefficient and then clones the repr again- The binary decimal writer (
binary/decimal.rs:62-65) clones the coefficient into anIntfor every non-zero decimal it writes, only to borrow it inDecodedInt::write Decimal→BigDecimal(decimal/mod.rs:483) clones only to reject-0
With only the owned impl, a clone has to be spelled out at the call site, and the callers that own the value move it instead. Those call sites aren't in this diff, so I'm fine with cleaning them up in a follow-up. For now, removing this impl only means changing as_int() to Int::try_from(self.repr.clone()).ok(), which at least puts the clone where it can be seen. (The public TryFrom<&Int> for UInt and TryFrom<&Coefficient> impls should stay; removing them would be a breaking change.)
Since the owned impls are pub(crate), you might consider type Error = OverflowingInt so that a failed conversion hands the value back. The public Coefficient impls can map that to an IonError.
| impl Display for Int { | ||
| fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), std::fmt::Error> { | ||
| write!(f, "{}", self.data) | ||
| write!(f, "{}", self.repr) |
There was a problem hiding this comment.
minor suggestion—we can avoid creating an unnecessary fmt::Arguments (created by the write! macro) in this method when all we really need to do is delegate to the Display impl for OverflowingInt:
| write!(f, "{}", self.repr) | |
| Display::fmt(&self.repr, f) |
(This also applies to impl Display for UInt.)
| } | ||
|
|
||
| #[test] | ||
| fn int_to_u128_above_i128_max() { |
There was a problem hiding this comment.
This needs some negative cases. Rejecting a negative value for an unsigned target depends on as_u128() returning None, and no test checks that for a value too large to fit in an i128. Can you turn this into an rstest table that includes cases like u128::try_from(Int::from(u128::MAX).neg()), i128::try_from(Int::from(i128::MIN)), and i128::try_from(Int::from(i128::MIN).neg())?
| } | ||
|
|
||
| #[test] | ||
| fn padded_bytes_equal_unpadded() { |
There was a problem hiding this comment.
This only covers the little-endian constructors, but if there is a regression it could show up on the binary reader path (which uses the big-endian constructor). Can you add a big-endian version of this test please?
|
|
||
| // Big fractional coefficient (>18 digits) is rejected | ||
| let big_coefficient: Int = Int::from(i128::MAX).data.mul(4).into(); | ||
| let big_coefficient = Int::from(u128::MAX); |
There was a problem hiding this comment.
u128::MAX fits in a u128, so this no longer reaches the .as_u128().unwrap_or(u128::MAX) fallback at timestamp.rs:1280-1284. The test still passes, but only because of the precision check. Can you build a coefficient wider than u128 instead? Int::from_le_signed_bytes with 16 zero bytes followed by 0x02 gives you 2^129.
…ngInt` docs `with_sign` now rewrites the sign bit of an owned value in place, so the module's immutability note and the `HeapValue::new` "only place that computes `meta`" note no longer held. Both now name `with_sign` and state that it touches only bit 62.
IntandUIntnow storeOverflowingIntdirectly, the same compact representationCoefficientadopted in #1057.Int,UIntElementElementnow fits in one cache line, and a compile-time assertion keeps it there.Design note:
IntData/UIntDataare removed. All integer logic now lives onOverflowingInt, soInt/UIntwrap it directly and only enforce their invariants:UIntis never negative. Every constructor takes an unsigned magnitude.Intis never-0. No constructor takes a sign, andneggoes throughminus, which maps zero to+0.Keeping
IntData/UIntDataas a middle layer would mean every method forwards twice without adding anything. This also matchesCoefficient { repr: OverflowingInt }. Happy to restore the layer if you'd prefer it.Behavior changes (both are fixes):
==to, and hashes the same as, its minimal encoding. Previously==andcmpdisagreed for these values.u128::try_from(Int)succeeds for values in (i128::MAX,u128::MAX]. It used to returnErreven though those values fit.Other:
BigOrSmallis deleted.TryFromimpls each had their own small/big branches. They now each make one call intoOverflowingInt.Debugis hand-written, so values print asInt(42)/UInt(42).api/default.txtis regenerated. The concreteFromimpls cover exactly the types the old blanket impl accepted, and the hand-writtenDebugimpls now appear in the snapshot.