Skip to content

Commit 9200fbe

Browse files
authored
perf: bound long fractional parsing work (#26)
1 parent 7df774f commit 9200fbe

3 files changed

Lines changed: 144 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ All significant changes to this software be documented in this file.
88

99
* Parse arbitrarily precise fractional byte sizes without double rounding or false overflow.
1010

11+
### Performance
12+
13+
* Bound arithmetic work when parsing long fractional byte sizes while preserving exact rounding.
14+
1115
## v0.3.0 (2026-06-28)
1216

1317
### Breaking changes

bsize/benches/parse.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ const fn case(name: &'static str, input: &'static str) -> ParseCase {
3333
ParseCase { name, input }
3434
}
3535

36-
const CASES: [ParseCase; 10] = [
36+
const CASES: [ParseCase; 12] = [
3737
case("plain", "42"),
3838
case("decimal-unit", "42 MB"),
3939
case("binary-unit", "1 KiB"),
@@ -42,10 +42,26 @@ const CASES: [ParseCase; 10] = [
4242
case("grouped", "1_234_567_890"),
4343
case("u64-max", "18_446_744_073_709_551_615"),
4444
case("high-precision-decimal", "1.84467440737095516145 EB"),
45+
case(
46+
"excess-precision-decimal",
47+
concat!(
48+
"1.84467440737095516145",
49+
"0000000000000000000000000000000000000000000000000000000000000000",
50+
"0000000000000000000000000000000000000000000000000000000000000000 EB",
51+
),
52+
),
4553
case(
4654
"high-precision-binary",
4755
"0.0000000000000000004336808689942017736029811203479766845703125 EiB",
4856
),
57+
case(
58+
"excess-precision-binary",
59+
concat!(
60+
"0.0000000000000000004336808689942017736029811203479766845703125",
61+
"0000000000000000000000000000000000000000000000000000000000000000",
62+
"0000000000000000000000000000000000000000000000000000000000000000 EiB",
63+
),
64+
),
4965
case("malformed", "not-a-size"),
5066
];
5167

bsize/src/parse.rs

Lines changed: 123 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ macroweave::repeat!(Ty in [u8, u16, u32, u64, usize] {
4747
impl FromStr for ByteSize<Ty> {
4848
type Err = ParseError;
4949

50+
#[inline]
5051
fn from_str(s: &str) -> Result<Self, Self::Err> {
5152
bsize_from_u64(parse_size(s.as_bytes())?)
5253
}
@@ -62,6 +63,11 @@ where
6263
.map_err(|_| ParseError::Overflow)
6364
}
6465

66+
// Half-ceil transitions occur at (2n + 1) / (2 * multiplier). Supported multipliers factor only
67+
// into 2s and 5s, so every transition terminates in decimal; 2^60 is the worst case at 61 digits.
68+
const MAX_RELEVANT_FRACTION_DIGITS: usize = 61;
69+
70+
#[inline]
6571
fn parse_size(mut src: &[u8]) -> Result<u64, ParseError> {
6672
// trim starting and trailing spaces
6773
while let [b' ', init @ ..] = src {
@@ -146,13 +152,20 @@ fn parse_size(mut src: &[u8]) -> Result<u64, ParseError> {
146152
let integer_bytes = integer.checked_mul(multiplier);
147153

148154
if let Some(start) = fraction_start {
155+
let fraction = &src[start..];
156+
let fraction = if fraction.len() > MAX_RELEVANT_FRACTION_DIGITS {
157+
relevant_fraction_prefix(fraction, multiplier)?
158+
} else {
159+
fraction
160+
};
161+
149162
// Multiply the fraction by the unit multiplier from right to left in base 10.
150-
// Once all fractional digits are consumed, carry is the integral byte count and
151-
// the last remainder digit determines rounding to the nearest byte.
163+
// Once all digits are consumed, carry is the integral byte count and the last remainder
164+
// digit determines rounding to the nearest byte.
152165
debug_assert!(multiplier <= u64::MAX / 10);
153166
let mut carry = 0u64;
154167
let mut rounding_digit = 0u64;
155-
for b in src[start..].iter().copied().rev() {
168+
for b in fraction.iter().copied().rev() {
156169
match b {
157170
b'0'..=b'9' => {
158171
let product = u64::from(b - b'0') * multiplier + carry;
@@ -174,9 +187,36 @@ fn parse_size(mut src: &[u8]) -> Result<u64, ParseError> {
174187
integer_bytes.ok_or(ParseError::Overflow)
175188
}
176189

190+
fn relevant_fraction_prefix(src: &[u8], multiplier: u64) -> Result<&[u8], ParseError> {
191+
// A half-byte boundary has a terminating decimal representation for every supported unit.
192+
// Digits after that representation cannot change half-ceil rounding, so validate them without
193+
// including them in the multiplication loop below.
194+
let relevant_digits = if multiplier == 1 || !multiplier.is_power_of_two() {
195+
multiplier.ilog10() as usize + 1
196+
} else {
197+
multiplier.ilog2() as usize + 1
198+
};
199+
let mut digits = 0usize;
200+
let mut end = src.len();
201+
for (index, b) in src.iter().copied().enumerate() {
202+
match b {
203+
b'0'..=b'9' => {
204+
digits += 1;
205+
if digits == relevant_digits {
206+
end = index + 1;
207+
}
208+
}
209+
b'_' => {}
210+
_ => return Err(ParseError::Malformed),
211+
}
212+
}
213+
Ok(&src[..end])
214+
}
215+
177216
#[cfg(test)]
178217
mod tests {
179218
use alloc::format;
219+
use alloc::string::String;
180220
use alloc::string::ToString;
181221

182222
use super::*;
@@ -280,6 +320,14 @@ mod tests {
280320
"1.3 42.0 B",
281321
"1.3 ... B",
282322
"19.aE",
323+
concat!(
324+
"1.0000000000000000000000000000000000000000000000000000000000000000",
325+
"x EB",
326+
),
327+
concat!(
328+
"0.0004882812500000000000000000000000000000000000000000000000000000",
329+
"x KiB",
330+
),
283331
"IB",
284332
"iB",
285333
"1iB",
@@ -312,7 +360,79 @@ mod tests {
312360
assert_eq!("4GiB".parse::<ByteSize<u32>>(), Err(ParseError::Overflow));
313361
}
314362

363+
#[test]
364+
fn ignores_fractional_digits_that_cannot_affect_half_ceil() {
365+
assert_parse_ok("0.499999999999999999999999999999 B", 0);
366+
assert_parse_ok("0.500000000000000000000000000001 B", 1);
367+
assert_parse_ok("0.000488281249999999999999999999 KiB", 0);
368+
assert_parse_ok("0.000488281250000000000000000001 KiB", 1);
369+
assert_parse_ok(
370+
"0.000000000000000000433680868994201773602981120347976684570312499999999 EiB",
371+
0,
372+
);
373+
assert_parse_ok(
374+
"0.000000000000000000433680868994201773602981120347976684570312500000001 EiB",
375+
1,
376+
);
377+
}
378+
379+
fn scale_fraction_reference(src: &[u8], multiplier: u64) -> u64 {
380+
let mut carry = 0u64;
381+
let mut rounding_digit = 0u64;
382+
for b in src.iter().copied().rev() {
383+
match b {
384+
b'0'..=b'9' => {
385+
let product = u64::from(b - b'0') * multiplier + carry;
386+
rounding_digit = product % 10;
387+
carry = product / 10;
388+
}
389+
b'_' => {}
390+
_ => unreachable!(),
391+
}
392+
}
393+
carry + u64::from(rounding_digit >= 5)
394+
}
395+
315396
quickcheck::quickcheck! {
397+
fn long_fractions_match_full_precision_reference(
398+
unit_index: u8,
399+
digits: alloc::vec::Vec<u8>
400+
) -> bool {
401+
const UNITS: [(&str, u64); 13] = [
402+
("B", 1),
403+
("kB", 1_000),
404+
("MB", 1_000_000),
405+
("GB", 1_000_000_000),
406+
("TB", 1_000_000_000_000),
407+
("PB", 1_000_000_000_000_000),
408+
("EB", 1_000_000_000_000_000_000),
409+
("KiB", 1 << 10),
410+
("MiB", 1 << 20),
411+
("GiB", 1 << 30),
412+
("TiB", 1 << 40),
413+
("PiB", 1 << 50),
414+
("EiB", 1 << 60),
415+
];
416+
417+
let (unit, multiplier) = UNITS[usize::from(unit_index) % UNITS.len()];
418+
let mut fraction = String::with_capacity(146);
419+
for index in 0..128 {
420+
if index > 0 && index % 7 == 0 {
421+
fraction.push('_');
422+
}
423+
let digit = digits
424+
.get(index % digits.len().max(1))
425+
.copied()
426+
.unwrap_or(index as u8)
427+
% 10;
428+
fraction.push(char::from(b'0' + digit));
429+
}
430+
431+
let expected = scale_fraction_reference(fraction.as_bytes(), multiplier);
432+
let input = format!("0.{fraction} {unit}");
433+
input.parse::<ByteSize<u64>>() == Ok(ByteSize::b(expected))
434+
}
435+
316436
fn parses_eib_fractions_exactly(whole: u8, fraction: u64) -> bool {
317437
const MULTIPLIER: u128 = 1 << 60;
318438
const SCALE: u128 = 1_000_000_000_000_000_000;

0 commit comments

Comments
 (0)