A complete floating-point to_chars in 18 kB<br>Posts<br>Talks<br>Papers<br>Projects- CATALOG -What "complete" actually means<br>The size cost<br>Why the size matters<br>The exact problem<br>Mind the gaps<br>The small print<br>Performance<br>Room to shrink<br>The point
vitaut.net
A complete floating-point to_chars in 18 kB2026-08-18
libstdc++'s floating-point std::to_chars, every format and precision for<br>float through long double, adds about 256 kB to a statically linked binary.<br>Żmij does the same job in about 18 kB, and<br>formats shortest doubles about 7x faster in the benchmark below.<br>std::to_chars for floating<br>point has been in the standard since C++17. It is the low-level,<br>locale-independent, non-throwing primitive that everything else<br>(std::to_string, std::format, your favorite logging library) is supposed to<br>sit on top of. It took years to land in the major standard libraries, some cases<br>are still not handled correctly, and where it does exist it is more bloated than<br>you might expect for printing a number.<br>So I implemented the whole thing, correctly rounded, in Żmij, a Slavic dragon,<br>because the naming convention in this field is not negotiable. It fits in one<br>source file and two headers, one for the core library and one for the<br>to_chars API, and draws on almost ten years of implementing floating-point<br>formatting algorithms in {fmt} and recent<br>developments. This post is about how small<br>"complete" can be, and why the standard version isn't.<br>What "complete" actually means<br>std::to_chars isn't one function. The floating-point overloads span:<br>four formats: chars_format::scientific (%e), fixed (%f), general<br>(%g), and hex (%a);<br>the shortest form and an arbitrary explicit precision;<br>three types: float, double, and long double;<br>all of it correctly rounded (round-half-to-even) and locale-independent.<br>Another way to see it: this is everything printf gives you (those formats at an<br>explicit precision), plus the shortest form, which printf lacks but almost<br>every modern language has, and usually as the default when you print a float.<br>Shortest formatting, the part that gets the most attention, is only a part of<br>this. The explicit-precision paths, fixed and scientific to a caller-chosen<br>number of digits, are a different problem, and they make up most of<br>the API surface.<br>The size cost<br>To illustrate, take a program that does nothing but print a floating-point value,<br>shortest by default or to a requested precision, with the type chosen at runtime:<br>#include<br>#include // C I/O, to avoid pulling in extra C++ symbols<br>#include
template typename T><br>char* convert(char* buf, size_t n, double v, int argc, char** argv) {<br>T x = static_castT>(v); // convert to the target type<br>std::to_chars_result r;<br>if (argc > 3) { // precision (+ optional format f/e/g/a)<br>std::chars_format f = std::chars_format::general;<br>switch (argc > 4 ? argv[4][0] : 'g') {<br>case 'f': f = std::chars_format::fixed; break;<br>case 'e': f = std::chars_format::scientific; break;<br>case 'a': f = std::chars_format::hex; break;<br>r = std::to_chars(buf, buf + n, x, f, atoi(argv[3]));<br>} else { // no precision -> shortest<br>r = std::to_chars(buf, buf + n, x);<br>return r.ptr;
int main(int argc, char** argv) {<br>char t = argc > 1 ? argv[1][0] : 'd'; // f/d/l -> float/double/long double<br>double v = argc > 2 ? strtod(argv[2], nullptr) : 0.1;<br>char buf[400] = {};<br>char* end = buf;<br>if (t == 'f')<br>end = convertfloat>(buf, sizeof(buf), v, argc, argv);<br>else if (t == 'l')<br>end = convertlong double>(buf, sizeof(buf), v, argc, argv);<br>else<br>end = convertdouble>(buf, sizeof(buf), v, argc, argv);<br>fwrite(buf, 1, size_t(end - buf), stdout);
The type, value, precision, and format all come from the command-line arguments<br>on purpose, so the optimizer can't fold the call away and we measure the real<br>conversion code.<br>Instantiating convert for float, double, and long double, each with the<br>shortest form plus fixed, scientific, general, and hex at an explicit<br>precision, is what exercises the complete API that the numbers below measure.<br>I compiled it with the same recipe as Honey, I shrunk {fmt}, -flto -DNDEBUG then strip, at two optimization levels:<br>-Os (for size) and -O2 (for speed). The one addition is -static-libstdc++ -static-libgcc, so the library's to_chars code and its tables land in the<br>executable instead of hiding in libstdc++.so where a naive ls -l wouldn't<br>count them. To isolate the conversion I subtract a baseline binary with identical<br>scaffolding but no conversion.<br>Numbers are from an Apple M-series arm64 machine, Homebrew GCC 16.1.0<br>(libstdc++). I use libstdc++ rather than libc++ because libc++'s long double<br>to_chars is still incomplete, as discussed below, so it can't produce the<br>complete API correctly.<br>build-Os-O2baseline (no conversion)33.6 kB33.6 kBŻmij52.1 kB, +18 kB68.6 kB, +35 kBstd::to_chars (stock libstdc++)289.9 kB, +256 kBsame binaryŻmij covers all of that in about 18 kB optimized for size and 35 kB optimized<br>for speed. libstdc++ adds about 256 kB, and it gets a single row on purpose:<br>-static-libstdc++ links...