Skip to content

Commit d91bf60

Browse files
zaewcclaude
andcommitted
Make digit separator / prefix support performance-neutral
Re-implements the optional digit-separator and base-prefix parsing (originally PR #369) on top of the current store_spans hot-path architecture, with the goal of zero overhead when the features are not used. Key changes vs. the original PR: - has_separator is now a *compile-time* template parameter on parse_number_string, dispatched once (from_chars_advanced -> parse_number_string_options) based on options.digit_separator. The has_separator==false instantiation that every default caller uses is byte-for-byte the separator-free parser: no separator comparison ever enters a digit loop and the SIMD eight-digit fast path is preserved. The separator-aware code lives only in the cold true instantiation. - The store_spans no-span hot path (added to main after the original PR was branched) is preserved. - parse_options_t fields are ordered so the two new single-byte fields (digit_separator, format_options) fall into the existing padding for UC == char. sizeof(parse_options_t<char>) stays 16 bytes, so the struct is still register-passed and the call boundary is unchanged. Result: for the default (no separator, no prefix) path, the generated assembly of from_chars<double> is identical to main, and benchmarks show no measurable regression (the original PR was 5-10% slower). Adds basictest coverage for digit separators (including the >19-digit overflow re-scan paths) and prefix skipping. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 34164f5 commit d91bf60

5 files changed

Lines changed: 266 additions & 51 deletions

File tree

include/fast_float/ascii_number.h

Lines changed: 169 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -337,12 +337,24 @@ report_parse_error(UC const *p, parse_error error) {
337337
// spans (read only by the rare digit_comp slow path) are not materialized,
338338
// which keeps the fat parsed_number_string_t off the hot path. The caller
339339
// re-parses with store_spans=true if the slow path is actually reached.
340-
template <bool basic_json_fmt, typename UC>
340+
//
341+
// has_separator is a *compile-time* flag (the opposite choice from store_spans,
342+
// and deliberately so): the separator-aware code paths are an opt-in feature
343+
// that the vast majority of callers never enable. Gating them on a template
344+
// parameter means the has_separator==false instantiation -- the default that
345+
// everybody uses -- compiles to exactly the same code as if the feature did not
346+
// exist: no separator comparison ever enters a digit loop, and the SIMD
347+
// eight-digit fast path stays intact. The has_separator==true instantiation is
348+
// cold code that default callers never execute. See parse_number_string_options
349+
// for the runtime->compile-time dispatch.
350+
template <bool basic_json_fmt, bool has_separator, typename UC>
341351
fastfloat_really_inline FASTFLOAT_CONSTEXPR20 parsed_number_string_t<UC>
342352
parse_number_string(UC const *p, UC const *pend, parse_options_t<UC> options,
343353
bool store_spans = true) noexcept {
344354
chars_format const fmt = detail::adjust_for_feature_macros(options.format);
345355
UC const decimal_point = options.decimal_point;
356+
UC const separator = options.digit_separator;
357+
(void)separator; // unused when has_separator == false
346358

347359
parsed_number_string_t<UC> answer;
348360
answer.valid = false;
@@ -375,16 +387,19 @@ parse_number_string(UC const *p, UC const *pend, parse_options_t<UC> options,
375387
UC const *const start_digits = p;
376388

377389
uint64_t i = 0; // an unsigned int avoids signed overflows (which are bad)
378-
379-
// Straight-line unroll of the integer-part scan: most integer parts are
380-
// 1-5 digits, so peeling the first iterations eliminates the loop back-edge
381-
// for the common case. Semantics are identical to the original `while` loop:
382-
// i = 10*i + digit, advancing p.
383-
if ((p != pend) && is_integer(*p)) {
384-
i = uint64_t(*p - UC('0'));
385-
++p;
390+
int64_t digit_count = 0;
391+
// Points at the first actual digit (== start_digits when no separator
392+
// precedes it). Used only by the basic_json leading-zero check.
393+
UC const *first_digit_ptr = start_digits;
394+
(void)first_digit_ptr; // only read in the basic_json_fmt path
395+
396+
FASTFLOAT_IF_CONSTEXPR17(!has_separator) {
397+
// Straight-line unroll of the integer-part scan: most integer parts are
398+
// 1-5 digits, so peeling the first iterations eliminates the loop back-edge
399+
// for the common case. Semantics are identical to the original `while` loop:
400+
// i = 10*i + digit, advancing p.
386401
if ((p != pend) && is_integer(*p)) {
387-
i = 10 * i + uint64_t(*p - UC('0'));
402+
i = uint64_t(*p - UC('0'));
388403
++p;
389404
if ((p != pend) && is_integer(*p)) {
390405
i = 10 * i + uint64_t(*p - UC('0'));
@@ -395,29 +410,58 @@ parse_number_string(UC const *p, UC const *pend, parse_options_t<UC> options,
395410
if ((p != pend) && is_integer(*p)) {
396411
i = 10 * i + uint64_t(*p - UC('0'));
397412
++p;
398-
while ((p != pend) && is_integer(*p)) {
399-
// a multiplication by 10 is cheaper than an arbitrary integer
400-
// multiplication
401-
i = 10 * i +
402-
uint64_t(*p - UC('0')); // might overflow, handled later
413+
if ((p != pend) && is_integer(*p)) {
414+
i = 10 * i + uint64_t(*p - UC('0'));
403415
++p;
416+
while ((p != pend) && is_integer(*p)) {
417+
// a multiplication by 10 is cheaper than an arbitrary integer
418+
// multiplication
419+
i = 10 * i +
420+
uint64_t(*p - UC('0')); // might overflow, handled later
421+
++p;
422+
}
404423
}
405424
}
406425
}
407426
}
408427
}
428+
digit_count = int64_t(p - start_digits);
429+
}
430+
else {
431+
// Separator-aware scan: a configured digit separator (e.g. '\'') may appear
432+
// between digits. It is skipped and does not contribute to the value or the
433+
// digit count, but it is retained in the integer span below so the overflow
434+
// re-scan can re-tokenize correctly.
435+
while (p != pend) {
436+
if (*p == separator) {
437+
++p;
438+
continue;
439+
}
440+
if (!is_integer(*p)) {
441+
break;
442+
}
443+
if (digit_count == 0) {
444+
first_digit_ptr = p;
445+
}
446+
i = 10 * i + uint64_t(*p - UC('0')); // might overflow, handled later
447+
++p;
448+
++digit_count;
449+
}
409450
}
410451
UC const *const end_of_integer_part = p;
411-
int64_t digit_count = int64_t(end_of_integer_part - start_digits);
412452
if (store_spans) {
413-
answer.integer = span<UC const>(start_digits, size_t(digit_count));
453+
// The span keeps the raw characters (separators included) so the overflow
454+
// re-scan below can re-tokenize correctly; for has_separator == false the
455+
// length equals digit_count.
456+
answer.integer =
457+
span<UC const>(start_digits, size_t(end_of_integer_part - start_digits));
414458
}
415459
FASTFLOAT_IF_CONSTEXPR17(basic_json_fmt) {
416460
// at least 1 digit in integer part, without leading zeros
417461
if (digit_count == 0) {
418462
return report_parse_error<UC>(p, parse_error::no_digits_in_integer_part);
419463
}
420-
if ((start_digits[0] == UC('0') && digit_count > 1)) {
464+
if ((*first_digit_ptr == UC('0') && digit_count > 1)) {
421465
return report_parse_error<UC>(start_digits,
422466
parse_error::leading_zeros_in_integer_part);
423467
}
@@ -428,20 +472,40 @@ parse_number_string(UC const *p, UC const *pend, parse_options_t<UC> options,
428472
if (has_decimal_point) {
429473
++p;
430474
UC const *before = p;
431-
// can occur at most twice without overflowing, but let it occur more, since
432-
// for integers with many digits, digit parsing is the primary bottleneck.
433-
loop_parse_if_eight_digits(p, pend, i);
475+
int64_t fractional_digit_count = 0;
476+
FASTFLOAT_IF_CONSTEXPR17(!has_separator) {
477+
// can occur at most twice without overflowing, but let it occur more,
478+
// since for integers with many digits, digit parsing is the primary
479+
// bottleneck.
480+
loop_parse_if_eight_digits(p, pend, i);
434481

435-
while ((p != pend) && is_integer(*p)) {
436-
uint8_t digit = uint8_t(*p - UC('0'));
437-
++p;
438-
i = i * 10 + digit; // in rare cases, this will overflow, but that's ok
482+
while ((p != pend) && is_integer(*p)) {
483+
uint8_t digit = uint8_t(*p - UC('0'));
484+
++p;
485+
i = i * 10 + digit; // in rare cases, this will overflow, but that's ok
486+
}
487+
fractional_digit_count = int64_t(p - before);
439488
}
440-
exponent = before - p;
489+
else {
490+
while (p != pend) {
491+
if (*p == separator) {
492+
++p;
493+
continue;
494+
}
495+
if (!is_integer(*p)) {
496+
break;
497+
}
498+
uint8_t digit = uint8_t(*p - UC('0'));
499+
++p;
500+
i = i * 10 + digit; // in rare cases, this will overflow, but that's ok
501+
++fractional_digit_count;
502+
}
503+
}
504+
exponent = -fractional_digit_count;
441505
if (store_spans) {
442506
answer.fraction = span<UC const>(before, size_t(p - before));
443507
}
444-
digit_count -= exponent;
508+
digit_count += fractional_digit_count;
445509
}
446510
FASTFLOAT_IF_CONSTEXPR17(basic_json_fmt) {
447511
// at least 1 digit in fractional part
@@ -483,12 +547,30 @@ parse_number_string(UC const *p, UC const *pend, parse_options_t<UC> options,
483547
// Otherwise, we will be ignoring the 'e'.
484548
p = location_of_e;
485549
} else {
486-
while ((p != pend) && is_integer(*p)) {
487-
uint8_t digit = uint8_t(*p - UC('0'));
488-
if (exp_number < 0x10000000) {
489-
exp_number = 10 * exp_number + digit;
550+
FASTFLOAT_IF_CONSTEXPR17(!has_separator) {
551+
while ((p != pend) && is_integer(*p)) {
552+
uint8_t digit = uint8_t(*p - UC('0'));
553+
if (exp_number < 0x10000000) {
554+
exp_number = 10 * exp_number + digit;
555+
}
556+
++p;
557+
}
558+
}
559+
else {
560+
while (p != pend) {
561+
if (*p == separator) {
562+
++p;
563+
continue;
564+
}
565+
if (!is_integer(*p)) {
566+
break;
567+
}
568+
uint8_t digit = uint8_t(*p - UC('0'));
569+
if (exp_number < 0x10000000) {
570+
exp_number = 10 * exp_number + digit;
571+
}
572+
++p;
490573
}
491-
++p;
492574
}
493575
if (neg_exp) {
494576
exp_number = -exp_number;
@@ -514,9 +596,12 @@ parse_number_string(UC const *p, UC const *pend, parse_options_t<UC> options,
514596
// It is possible that the integer had an overflow.
515597
// We have to handle the case where we have 0.0000somenumber.
516598
// We need to be mindful of the case where we only have zeroes...
517-
// E.g., 0.000000000...000.
599+
// E.g., 0.000000000...000. The `has_separator &&` guard below is a
600+
// compile-time constant, so this loop is identical to the original when the
601+
// feature is disabled.
518602
UC const *start = start_digits;
519-
while ((start != pend) && (*start == UC('0') || *start == decimal_point)) {
603+
while ((start != pend) && (*start == UC('0') || *start == decimal_point ||
604+
(has_separator && *start == separator))) {
520605
if (*start == UC('0')) {
521606
digit_count--;
522607
}
@@ -537,20 +622,60 @@ parse_number_string(UC const *p, UC const *pend, parse_options_t<UC> options,
537622
p = answer.integer.ptr;
538623
UC const *int_end = p + answer.integer.len();
539624
uint64_t const minimal_nineteen_digit_integer{1000000000000000000};
540-
while ((i < minimal_nineteen_digit_integer) && (p != int_end)) {
541-
i = i * 10 + uint64_t(*p - UC('0'));
542-
++p;
625+
FASTFLOAT_IF_CONSTEXPR17(!has_separator) {
626+
while ((i < minimal_nineteen_digit_integer) && (p != int_end)) {
627+
i = i * 10 + uint64_t(*p - UC('0'));
628+
++p;
629+
}
630+
if (i >= minimal_nineteen_digit_integer) { // We have a big integer
631+
exponent = end_of_integer_part - p + exp_number;
632+
} else { // We have a value with a fractional component.
633+
p = answer.fraction.ptr;
634+
UC const *frac_end = p + answer.fraction.len();
635+
while ((i < minimal_nineteen_digit_integer) && (p != frac_end)) {
636+
i = i * 10 + uint64_t(*p - UC('0'));
637+
++p;
638+
}
639+
exponent = answer.fraction.ptr - p + exp_number;
640+
}
543641
}
544-
if (i >= minimal_nineteen_digit_integer) { // We have a big integer
545-
exponent = end_of_integer_part - p + exp_number;
546-
} else { // We have a value with a fractional component.
547-
p = answer.fraction.ptr;
548-
UC const *frac_end = p + answer.fraction.len();
549-
while ((i < minimal_nineteen_digit_integer) && (p != frac_end)) {
642+
else {
643+
// Separator-aware re-scan: separators are skipped and excluded from
644+
// the digit counts that determine the exponent.
645+
while ((i < minimal_nineteen_digit_integer) && (p != int_end)) {
646+
if (*p == separator) {
647+
++p;
648+
continue;
649+
}
550650
i = i * 10 + uint64_t(*p - UC('0'));
551651
++p;
552652
}
553-
exponent = answer.fraction.ptr - p + exp_number;
653+
if (i >= minimal_nineteen_digit_integer) { // We have a big integer
654+
int64_t remaining_integer_digits = 0;
655+
while (p != int_end) {
656+
if (*p == separator) {
657+
++p;
658+
continue;
659+
}
660+
++p;
661+
++remaining_integer_digits;
662+
}
663+
exponent = remaining_integer_digits + exp_number;
664+
} else { // We have a value with a fractional component.
665+
p = answer.fraction.ptr;
666+
UC const *frac_end = p + answer.fraction.len();
667+
int64_t fraction_digits_consumed = 0;
668+
while ((i < minimal_nineteen_digit_integer) && (p != frac_end)) {
669+
if (*p == separator) {
670+
++p;
671+
continue;
672+
}
673+
i = i * 10 + uint64_t(*p - UC('0'));
674+
++p;
675+
++fraction_digits_consumed;
676+
}
677+
exponent = exp_number - fraction_digits_consumed;
678+
}
554679
}
555680
// We have now corrected both exponent and i, to a truncated value
556681
}

include/fast_float/float_common.h

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,15 +70,35 @@ using from_chars_result = from_chars_result_t<char>;
7070

7171
template <typename UC> struct parse_options_t {
7272
constexpr explicit parse_options_t(chars_format fmt = chars_format::general,
73-
UC dot = UC('.'), int b = 10)
74-
: format(fmt), decimal_point(dot), base(b) {}
73+
UC dot = UC('.'), int b = 10,
74+
UC sep = UC('\0'), uint8_t opts = 0)
75+
: format(fmt), decimal_point(dot), digit_separator(sep),
76+
format_options(opts), base(b) {}
77+
78+
// Member order is chosen so that, for the common UC == char case, the two
79+
// new single-byte fields land in the padding that already existed between
80+
// decimal_point and base. This keeps sizeof(parse_options_t<char>) == 16, so
81+
// the struct is still passed in registers (ARM64/x86-64) and the default
82+
// parse path is unaffected. Reordering would grow the struct and force it
83+
// onto the stack at the call boundary.
7584

7685
/** Which number formats are accepted */
7786
chars_format format;
7887
/** The character used as decimal point */
7988
UC decimal_point;
89+
/** The character used as digit separator (e.g. '\''). Use '\0' to disable.
90+
* When disabled (the default), the parser compiles to the exact same code as
91+
* if this option did not exist: separator handling is gated on a compile-time
92+
* template parameter, so the default hot path carries no extra branches. */
93+
UC digit_separator;
94+
/** Additional format options (bitmask), see the static flags below. */
95+
uint8_t format_options;
8096
/** The base used for integers */
8197
int base;
98+
99+
/** Skip a leading base prefix (0x/0X, 0b/0B) before parsing. Decimal-only:
100+
* the digits are still parsed in base 10, the prefix is merely consumed. */
101+
static constexpr uint8_t skip_prefix = 1;
82102
};
83103

84104
using parse_options = parse_options_t<char>;

include/fast_float/parse_number.h

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,29 @@ from_chars_advanced(parsed_number_string_t<UC> &pns, T &value) noexcept {
289289
return answer;
290290
}
291291

292+
// Runtime -> compile-time dispatch over both boolean knobs of
293+
// parse_number_string. basic_json_fmt was already dispatched this way; the digit
294+
// separator is selected here too so that the separator-aware code paths stay
295+
// confined to the (cold) has_separator==true instantiation. Callers that never
296+
// set a separator -- the overwhelming majority -- run the has_separator==false
297+
// instantiation, which is byte-for-byte the original separator-free parser.
298+
template <typename UC>
299+
fastfloat_really_inline FASTFLOAT_CONSTEXPR20 parsed_number_string_t<UC>
300+
parse_number_string_options(UC const *first, UC const *last,
301+
parse_options_t<UC> options, bool bjf,
302+
bool store_spans) noexcept {
303+
if (options.digit_separator != UC('\0')) {
304+
return bjf ? parse_number_string<true, true, UC>(first, last, options,
305+
store_spans)
306+
: parse_number_string<false, true, UC>(first, last, options,
307+
store_spans);
308+
}
309+
return bjf ? parse_number_string<true, false, UC>(first, last, options,
310+
store_spans)
311+
: parse_number_string<false, false, UC>(first, last, options,
312+
store_spans);
313+
}
314+
292315
// Slow path: re-parse materializing the integer/fraction spans the hot no-span
293316
// parse skipped, then run the full algorithm. The two callers reach it only
294317
// through a fastfloat_unlikely branch, so the optimizer keeps this re-parse off
@@ -301,8 +324,7 @@ FASTFLOAT_CONSTEXPR20 from_chars_result_t<UC>
301324
parse_number_slow_path(UC const *first, UC const *last, T &value,
302325
parse_options_t<UC> options, bool bjf) noexcept {
303326
parsed_number_string_t<UC> pns =
304-
bjf ? parse_number_string<true, UC>(first, last, options, true)
305-
: parse_number_string<false, UC>(first, last, options, true);
327+
parse_number_string_options(first, last, options, bjf, true);
306328
return from_chars_advanced(pns, value);
307329
}
308330

@@ -336,8 +358,7 @@ from_chars_float_advanced(UC const *first, UC const *last, T &value,
336358
// parsed_number_string_t off the hot path. store_spans is a runtime argument,
337359
// so this reuses the single parse_number_string instantiation.
338360
parsed_number_string_t<UC> pns =
339-
bjf ? parse_number_string<true, UC>(first, last, options, false)
340-
: parse_number_string<false, UC>(first, last, options, false);
361+
parse_number_string_options(first, last, options, bjf, false);
341362
if (!pns.valid) {
342363
if (uint64_t(fmt & chars_format::no_infnan)) {
343364
answer.ec = std::errc::invalid_argument;
@@ -539,6 +560,13 @@ template <typename T, typename UC>
539560
fastfloat_really_inline FASTFLOAT_CONSTEXPR20 from_chars_result_t<UC>
540561
from_chars_advanced(UC const *first, UC const *last, T &value,
541562
parse_options_t<UC> options) noexcept {
563+
if (((options.format_options & parse_options_t<UC>::skip_prefix) != 0) &&
564+
(last - first >= 2) && (*first == UC('0'))) {
565+
UC const c_low = UC(first[1] | UC(0x20));
566+
if (c_low == UC('x') || c_low == UC('b')) {
567+
first += 2;
568+
}
569+
}
542570
return from_chars_advanced_caller<
543571
size_t(is_supported_float_type<T>::value) +
544572
2 * size_t(is_supported_integer_type<T>::value)>::call(first, last, value,

0 commit comments

Comments
 (0)