Skip to content

Commit b0b342a

Browse files
committed
perf(wasm): format numbers without libc printf
- add utils/number_text with snprintf-compatible %f/%e/%g and integer/hex formatting - take float digits from double-conversion, rounding exact binary ties to even as printf does (double-conversion alone rounds them up) - move every engine snprintf and std::to_string(double) onto it - route pugixml XPath number formatting through a WASM-only --wrap=snprintf in FormulonWasm.cmake, next to the existing strtod wrap - musl's printf machinery leaves the module - output is byte-identical: number_text_test.cpp checks parity against host snprintf over hand-picked values, random magnitudes, exact ties and random bit patterns - oracle diff: fast tier passes unchanged - WASM: 3,293,540 -> 3,292,036 raw, Brotli -2.1 KB (901,702)
1 parent 70ab350 commit b0b342a

26 files changed

Lines changed: 874 additions & 76 deletions

‎CMakeLists.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ set(FORMULON_CORE_SOURCES
112112
src/utils/double_parse.cpp
113113
src/utils/index_sort.cpp
114114
src/utils/japanese_era.cpp
115+
src/utils/number_text.cpp
115116
src/utils/structured_log.cpp
116117
src/utils/text_ops.cpp
117118
src/utils/thread_launch.cpp

‎cmake/FormulonWasm.cmake‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -402,15 +402,18 @@ set_target_properties(formulon_wasm_stack_probe PROPERTIES
402402
# always-active SAX). Native builds keep the threshold at 256 KiB.
403403
target_compile_definitions(formulon_core PRIVATE FORMULON_WASM=1)
404404

405-
# pugixml parses numbers with strtod. Wrapping the symbol routes those calls
406-
# to the engine's decimal parser (`__wrap_strtod` in src/utils/double_parse.cpp),
407-
# which keeps libc's scanner and the 128-bit float routines it needs out of
408-
# the module: ~10 KB uncompressed, ~5 KB Brotli.
405+
# pugixml parses numbers with strtod and prints them with snprintf. Wrapping
406+
# the symbols routes those calls to the engine's decimal parser
407+
# (`__wrap_strtod` in src/utils/double_parse.cpp) and formatter
408+
# (`__wrap_snprintf` in src/utils/number_text.cpp), which keeps libc's scanner,
409+
# its printf machinery and the 128-bit float routines they need out of the
410+
# module.
409411
target_link_options(formulon_wasm PRIVATE
410412
${_FM_WASM_OPT_FLAGS}
411413
${_FM_WASM_LINK_OPT_FLAGS}
412414
${_FM_WASM_COMMON_LINK_FLAGS}
413415
"-Wl,--wrap=strtod"
416+
"-Wl,--wrap=snprintf"
414417
)
415418

416419
# Land the artifact at <build>/<base>.{js,wasm}. Emscripten infers the

‎src/c_api/parts/cf_mutate.cpp‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
#include <algorithm>
2020
#include <cstddef>
2121
#include <cstdint>
22-
#include <cstdio>
2322
#include <string>
2423
#include <utility>
2524
#include <vector>
@@ -30,6 +29,7 @@
3029
#include "color_resolve.h"
3130
#include "io/cf_overlay.h"
3231
#include "utils/error.h"
32+
#include "utils/number_text.h"
3333
#include "workbook.h"
3434

3535
using formulon::c_api::BorrowedArrayArena;
@@ -422,9 +422,9 @@ extern "C" fm_status_t fm_sheet_cf_add_rule(fm_workbook_t* wb, std::size_t sheet
422422
// nibble is `0`, which no random (version 4) GUID Excel generates
423423
// can carry, so a synthesized id can never collide with a loaded
424424
// one.
425-
char buf[40];
426-
std::snprintf(buf, sizeof(buf), "{FC000000-0000-0000-0000-%012X}", static_cast<unsigned>(out_rule.priority));
427-
out_rule.id = buf;
425+
char buf[16];
426+
formulon::format_hex(buf, sizeof(buf), static_cast<unsigned>(out_rule.priority), 12, true);
427+
out_rule.id = std::string("{FC000000-0000-0000-0000-") + buf + "}";
428428
}
429429
if (rule.formula1 != nullptr) {
430430
out_rule.formula1 = std::string(rule.formula1);

‎src/c_api/parts/common.cpp‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
#include "sheet.h"
1616
#include "styles.h"
1717
#include "utils/error.h"
18+
#include "utils/number_text.h"
1819
#include "value.h"
1920

2021
namespace formulon {
@@ -126,15 +127,15 @@ fm_status_t check_finite_non_negative(double value, const char* api, const char*
126127
return 0;
127128
}
128129
return set_binding_error(formulon::FormulonErrorCode::kInvalidArgument, "value must be finite and non-negative",
129-
std::string(api) + ": " + field + "=" + std::to_string(value));
130+
std::string(api) + ": " + field + "=" + formulon::fixed_string(value, 6));
130131
}
131132

132133
fm_status_t check_finite(double value, const char* api, const char* field) {
133134
if (std::isfinite(value)) {
134135
return 0;
135136
}
136137
return set_binding_error(formulon::FormulonErrorCode::kInvalidArgument, "value must be finite",
137-
std::string(api) + ": " + field + "=" + std::to_string(value));
138+
std::string(api) + ": " + field + "=" + formulon::fixed_string(value, 6));
138139
}
139140

140141
fm_status_t value_from_fm(const fm_value_t& in, formulon::Value* out, const char* api) {

‎src/c_api/parts/pivot_table.cpp‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
#include "pivot/field_lookup.h"
2222
#include "sheet.h"
2323
#include "utils/error.h"
24+
#include "utils/number_text.h"
2425
#include "workbook.h"
2526

2627
using formulon::c_api::parts::check_enum_domain;
@@ -543,10 +544,10 @@ extern "C" fm_status_t fm_workbook_pivot_field_set_date_group(
543544
"fm_workbook_pivot_field_set_date_group: interval_days=0");
544545
}
545546
if (has_start && has_end && start_serial_or_neg1 > end_serial_or_neg1) {
546-
return set_binding_error(formulon::FormulonErrorCode::kInvalidArgument,
547-
"start_serial_or_neg1 must not exceed end_serial_or_neg1",
548-
"fm_workbook_pivot_field_set_date_group: start=" + std::to_string(start_serial_or_neg1) +
549-
" end=" + std::to_string(end_serial_or_neg1));
547+
return set_binding_error(
548+
formulon::FormulonErrorCode::kInvalidArgument, "start_serial_or_neg1 must not exceed end_serial_or_neg1",
549+
"fm_workbook_pivot_field_set_date_group: start=" + formulon::fixed_string(start_serial_or_neg1, 6) +
550+
" end=" + formulon::fixed_string(end_serial_or_neg1, 6));
550551
}
551552
if ((has_start && !is_valid_date_serial(start_serial_or_neg1, wb->workbook().date1904())) ||
552553
(has_end && !is_valid_date_serial(end_serial_or_neg1, wb->workbook().date1904()))) {

‎src/cf/validation_eval.cpp‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
#include <algorithm>
44
#include <cmath>
5-
#include <cstdio>
65
#include <cstdlib>
76
#include <optional>
87
#include <string>
@@ -17,6 +16,7 @@
1716
#include "eval/function_registry.h"
1817
#include "sheet.h"
1918
#include "utils/arena.h"
19+
#include "utils/number_text.h"
2020
#include "utils/resource_budget.h"
2121
#include "utils/utf8_length.h"
2222
#include "workbook.h"
@@ -227,7 +227,7 @@ bool inline_list_matches(const std::vector<std::string>& items, const Value& pro
227227
std::optional<std::string> literal_of(const Value& proposed) {
228228
if (proposed.is_number()) {
229229
char buf[40];
230-
std::snprintf(buf, sizeof(buf), "%.17g", proposed.as_number());
230+
format_general(buf, sizeof(buf), proposed.as_number(), 17);
231231
return std::string(buf);
232232
}
233233
if (proposed.is_boolean()) {

‎src/eval/builtins/engineering.cpp‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222

2323
#include <cmath>
2424
#include <cstdint>
25-
#include <cstdio>
2625
#include <cstdlib>
2726
#include <string>
2827
#include <string_view>
@@ -32,6 +31,7 @@
3231
#include "eval/function_registry.h"
3332
#include "utils/arena.h"
3433
#include "utils/expected.h"
34+
#include "utils/number_text.h"
3535
#include "value.h"
3636

3737
namespace formulon {
@@ -192,7 +192,7 @@ Expected<std::string, ErrorCode> input_digit_string(const Value& v) {
192192
// Use a fixed-width scratch buffer: the magnitude fits in a 64-bit
193193
// integer (signed range is well below 2^63 for our bases).
194194
char buf[32];
195-
std::snprintf(buf, sizeof(buf), "%lld", static_cast<long long>(t));
195+
format_signed(buf, sizeof(buf), static_cast<long long>(t));
196196
return std::string(buf);
197197
}
198198
case ValueKind::Bool:

‎src/eval/text_format/render_date.cpp‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,14 @@
99
#include <cmath>
1010
#include <cstddef>
1111
#include <cstdint>
12-
#include <cstdio>
1312
#include <string>
1413
#include <string_view>
1514

1615
#include "eval/text_format/number_format_types.h"
1716
#include "eval/text_format/render_common.h"
1817
#include "utils/date_time.h"
1918
#include "utils/japanese_era.h"
19+
#include "utils/number_text.h"
2020

2121
namespace formulon {
2222
namespace text_format {
@@ -206,7 +206,7 @@ FormatStatus render_date(const Section& section, std::string_view fmt, double se
206206
}
207207
case Tok::DateY4: {
208208
char buf[16];
209-
const int n = std::snprintf(buf, sizeof(buf), "%04d", ymd.y);
209+
const int n = format_signed(buf, sizeof(buf), ymd.y, 4);
210210
if (n > 0) {
211211
if (dbnum == DbNumMode::kNone) {
212212
out.append(buf, static_cast<std::size_t>(n));

‎src/eval/text_format/render_fraction.cpp‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,14 @@
99
#include <cmath>
1010
#include <cstddef>
1111
#include <cstdint>
12-
#include <cstdio>
1312
#include <limits>
1413
#include <string>
1514
#include <string_view>
1615

1716
#include "eval/text_format/number_format_scanner.h"
1817
#include "eval/text_format/number_format_types.h"
1918
#include "eval/text_format/render_common.h"
19+
#include "utils/number_text.h"
2020

2121
namespace formulon {
2222
namespace text_format {
@@ -296,7 +296,7 @@ FormatStatus render_fraction(const Section& section, std::string_view fmt, doubl
296296
// `# ?/?`), and a zero integer is shown even under `#`.
297297
const bool suppress_fraction = has_int_group && fraction_num == 0;
298298
char int_buf[400];
299-
const int int_len = std::snprintf(int_buf, sizeof(int_buf), "%.0f", integer_part);
299+
const int int_len = format_fixed(int_buf, sizeof(int_buf), integer_part, 0);
300300
if (int_len < 0 || static_cast<std::size_t>(int_len) >= sizeof(int_buf)) {
301301
return FormatStatus::kOverflow;
302302
}

‎src/eval/text_format/render_numeric.cpp‎

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
#include <cmath>
1111
#include <cstddef>
1212
#include <cstdint>
13-
#include <cstdio>
1413
#include <string>
1514
#include <string_view>
1615
#include <vector>
@@ -19,6 +18,7 @@
1918
#include "eval/text_format/render_common.h"
2019
#include "eval/text_format/render_fraction.h"
2120
#include "eval/text_format/rounding.h"
21+
#include "utils/number_text.h"
2222

2323
namespace formulon {
2424
namespace text_format {
@@ -44,7 +44,7 @@ void format_fixed_digits(double v, int decimals, bool* negative, std::string* in
4444
const double rounded = std::isfinite(decimal_rounded) ? decimal_rounded : v;
4545
*negative = rounded < 0.0;
4646
const double abs_v = std::fabs(rounded);
47-
// `snprintf` only converts an already Excel-rounded decimal value to its
47+
// `format_fixed` only converts an already Excel-rounded decimal value to its
4848
// digit string. It must not decide the tie direction: Apple libc rounds
4949
// `%.0f` ties to even, while Excel rounds them away from zero.
5050
const int requested_decimals = decimals < 0 ? 0 : decimals;
@@ -57,12 +57,12 @@ void format_fixed_digits(double v, int decimals, bool* negative, std::string* in
5757
rendered_decimals = std::min(requested_decimals, std::max(0, 14 - exponent));
5858
}
5959
char buf[64];
60-
const int n = std::snprintf(buf, sizeof(buf), "%.*f", rendered_decimals, abs_v);
60+
const int n = format_fixed(buf, sizeof(buf), abs_v, rendered_decimals);
6161
if (n < 0 || static_cast<std::size_t>(n) >= sizeof(buf)) {
62-
// Fallback: fall back to sprintf with a heap buffer (extremely rare).
62+
// Fallback: a heap buffer for the oversized text (extremely rare).
6363
std::string out;
6464
out.resize(static_cast<std::size_t>(rendered_decimals) + 32u);
65-
const int m = std::snprintf(&out[0], out.size(), "%.*f", rendered_decimals, abs_v);
65+
const int m = format_fixed(&out[0], out.size(), abs_v, rendered_decimals);
6666
if (m > 0) {
6767
out.resize(static_cast<std::size_t>(m));
6868
} else {
@@ -165,9 +165,10 @@ void format_general(std::string& out, double v) {
165165
frac = 0;
166166
}
167167
char buf[64];
168-
const int n = std::snprintf(buf, sizeof(buf), "%.*e", frac, v);
168+
const int n = format_exponential(buf, sizeof(buf), v, frac);
169169
if (n <= 0) {
170-
out.append(std::to_string(v));
170+
format_fixed(buf, sizeof(buf), v, 6);
171+
out.append(buf);
171172
return;
172173
}
173174
std::string_view s(buf, static_cast<std::size_t>(n));
@@ -210,9 +211,10 @@ void format_general(std::string& out, double v) {
210211
frac = 0;
211212
}
212213
char buf[64];
213-
const int n = std::snprintf(buf, sizeof(buf), "%.*f", frac, v);
214+
const int n = format_fixed(buf, sizeof(buf), v, frac);
214215
if (n <= 0) {
215-
out.append(std::to_string(v));
216+
format_fixed(buf, sizeof(buf), v, 6);
217+
out.append(buf);
216218
return;
217219
}
218220
out.append(buf, trimmed_fraction_length(std::string_view(buf, static_cast<std::size_t>(n))));
@@ -403,7 +405,7 @@ FormatStatus render_numeric(const Section& section, std::string_view fmt, double
403405
int_digits.clear();
404406
}
405407

406-
// Fraction adjustment: the snprintf produced exactly `frac_digits` chars.
408+
// Fraction adjustment: the formatter produced exactly `frac_digits` chars.
407409
// Trim trailing zeros matching the `#` tokens (scan right-to-left).
408410
int trim = section.fraction_opt_digits;
409411
while (trim > 0 && !frac_digits_str.empty() && frac_digits_str.back() == '0') {

0 commit comments

Comments
 (0)