Feat: refactor fixed-point math in pox_5_make_reward_set#7087
Feat: refactor fixed-point math in pox_5_make_reward_set#7087aaronb-stacks wants to merge 3 commits intostacks-network:pox-5-integrationfrom
pox_5_make_reward_set#7087Conversation
* Add new FixedPointU256 struct to `uint` in common * Use fewer bits of scaling -- 128 bits is totally unnecessary * Add checked variants and some more efficient calculations to wide uints * Add proptests and extreme value tests to ensure PoX calculations do not overflow * Refactor wide uint macros to use `const`s, `Self`, `Self::N_WORDS` where possible
772a43a to
427b3cd
Compare
pox_5_make_reward_set (draft)pox_5_make_reward_set
| for i in 0..(2 * $n_words) { | ||
| let to_mul = (other >> (32 * i)).low_u32(); | ||
| me = me + (self.mul_u32(to_mul) << (32 * i)); | ||
| } |
There was a problem hiding this comment.
Each "add" here can do 2-4 clones of the wide type (because of how it implements carry and because, despite consuming the input arg, it allocates new arrays for the return and carry). Each shift is another clone, and mul_u32 also clones. This means that each iteration of the loop performs 5-7 clones, so multiplication ends up performing (10-14 * N_WORDS) clones.
brice-stacks
left a comment
There was a problem hiding this comment.
This looks great! 🙏
Coverage Report for CI Build 24153681013Coverage increased (+39.0%) to 85.62%Details
Uncovered Changes
Coverage Regressions1591 previously-covered lines in 39 files lost coverage.
Coverage Stats
💛 - Coveralls |
| impl<const SCALE: u16> FixedPointU256<SCALE> { | ||
| /// Compile-time assertion that `SCALE < 128`, ensuring that even | ||
| /// `u128::MAX` can always be represented without overflow. | ||
| const _ASSERT_SCALE: () = assert!(SCALE < 128, "SCALE must be less than 128"); |
There was a problem hiding this comment.
You might want to make this 64 instead of 128, since it impacts how big the numbers can be under multiplication. For example, if SCALE = 127, then FixedPointU256::from_u64(1) * FixedPointU256::from_u64(1) would require computing 1 << 254. You couldn't even compute FixedPointU256::from_u64(10) * FixedPointU256::from_u64(1).
There was a problem hiding this comment.
I think that's fine, though. Checked math would return None in those cases, and the other options would wrap. This assertion is basically here to avoid needing to make from_u128() and from_u64() fallible.
| /// Returns None if overflowed | ||
| pub fn pow(&self, exp: u32) -> Option<Self> { | ||
| if exp == 0 { | ||
| return Some(Self::MINIMAL); |
There was a problem hiding this comment.
Shouldn't this be Some(Self::from_u64(1))?
| let mut high = self.clone(); | ||
| loop { | ||
| if high <= low { | ||
| return Some(high.min(low)); |
|
|
||
| /// Find the `n`th root of self using binary search, trimming the scale to `self.scaling` on | ||
| /// iterations | ||
| pub fn find_root_floor(&self, n: u32) -> Option<Self> { |
There was a problem hiding this comment.
I get that the algorithm calls for computing a weighted geomean, but you don't need to employ an nth-root function if you have an integer log-2 function (which I think is easier to come by and less risky to implement). I mentioned this elsewhere, but I'll repost it here:
You don't have to take the 15th-root of anything; you could instead compute this:
( 5 * ilog2(STX0) + 4 * ilog2(STX1) + 3 * ilog2(STX2) + 2 * ilog2(STX3) + ilog2(STX4) )
( ------------------------------------------------------------------------------------ )
( 5 * ilog2(BTC0) + 4 * ilog2(BTC1) + 3 * ilog2(BTC2) + 2 * ilog2(BTC3) + ilog2(BTC4) )
GeoMean(STX/BTC) = 2
where ilog2(x) is the integer base-2 logarithm of x (which is easy to compute on fix-point numbers).
The nice thing IMHO about ilog2(x) is that because its range is bounded for all reasonable values of x, it's trivial to implement as a look-up table. If needed, you could interpolate between two points in the table for extra precision.
There was a problem hiding this comment.
Yeah, that's better.
There was a problem hiding this comment.
Hmm. This means dealing with negative values, right?
There was a problem hiding this comment.
If the units of STX and BTC are uSTX and satoshis, respectively, then the range of ilog2(x) would always be positive since you can't have a fractional uSTX or satoshi.
There was a problem hiding this comment.
Actually, I'm not sure how much this alternative representation saves us in complexity. You're still computing a fractional power of 2 in essentially all cases, which necessitates an nth-root fixed point function (unless there's a fast fixed-point algorithm that works specifically for fractional powers of 2).
| if high <= low { | ||
| return Some(high.min(low)); | ||
| } | ||
| let guess = high.clone().add(&low)?.div(Uint256::from_u64(2))?; |
There was a problem hiding this comment.
You could perhaps speed up this algorithm if your initial guess was simply computed from an f64, cast to a fixed-point.
This does a bit of refactoring of
pox_5_make_reward_setand theuintmodule instacks-common. In the first case, it introduces and uses aFixedPointU256struct to perform the reward set math -- this changed the precision, but the previous precision was pretty unnecessary (64 fractional bits is plenty).The refactor of the wide uint types should bring some performance gains -- using rust's native multiplication, subtraction, and addition operations for supporting wider types (
carrying_*andborrowing_sub) allowing for in-place modification for the self-consuming implementations, and using reference args for the cloning implementations. This avoids a fair number of clones in multiplication in particular.uintin common