Chuckles
Astoundingly, when this was standardised (as std::format for C++ 20) the committee didn't add back this mistake (which is present in numerous other parts of the standard). Which does give small hope for the proposers who plead with the committee to not make things unnecessarily worse in order to make C++ "consistent".
The linked dragonbox [1] project is also worth a read. Pretty optimized for the least used branches.
Usually the Zig compiler can generate binaries smaller than MSVC because it doesn't link in a bunch of useless junk from the C Runtime (on Windows, Zig has no dependency on the C runtime). But this time the binary seemed to be much larger than I've seen Zig generate before and it didn't make sense based on how little the tool was actually doing. Dropping it into Binary Ninja revealed that the majority of the code was there to support floating point formatting. So I changed the code to cast the floating point number to an integer before printing it out. That change resulted in a binary that was down at the size I had been expecting.
MSVC defaults to linking against the UCRT, just like how Clang and GCC on Linux default to linking against the system libc. This is to provide a reasonably useful C environment as a sane default.
If you don't want UCRT under MSVC, supply `/MT /NODEFAULTLIB /ENTRY:<function-name>` in the command-line invocation (or in the Visual Studio MSBuild options).
It is perfectly possible to build a Win32-only binary that is fully self-contained and only around 1 KiB.
We have been doing some experiment on optimizing for size, and currently it can be reduced to ~3k on 8-bit AVR. It only contains impl/table for single-precision binary32, and double-precision requires quite more, but at the same time much of the bloat is due to how limited AVR is. On platforms like x64 it should be much smaller.
You can certainly say 3k is still huge though.
If you want it to be fast. The baseline implementation isn’t terrible[1,2] even if it is still ultimately an implementation of arbitrary-precision arithmetic.
In any case, what Dragonbox and other modern floating-point formatting algorithms do is already roughly what you describe: they compute the integer consisting of digits to be printed, and then print those digits, except:
- Dragonbox and some of other algorithms have totally different requirements than `printf`. The user does not request the precision, rather the algorithm determines the number of digits to print. So `1.2` is printed as `1.2` and `1.199999999999` is printed as `1.199999999999`. You can read about the exact requirements in the Readme page of Dragonbox.
- The core of modern floating-point formatting algorithms is on how to compute the needed multiplication by a power of 10 without needing to do it by the plain bignum arithmetic (which is incredibly slow). Note that a `float` (assuming it's IEEE-754 binary32) instance can be as large as 2^100 or as small as 2^-100. It's nontrivial to deal with these numbers without incorporating bignum arithmetic, and even if you just give up avoiding it, bignum arithmetic itself is quite nontrivial in terms of the code size it requires.
C++ noob here, but is libc++'s default allocator (I mean, the default implementation of new and delete) actually doing something different than calling libc's malloc and free under the hood? If so, why?
strings are ~4 instructions (test for null terminator, output character, branch back two).
Ints are ~20 instructions. Check if negative and if so output '-' and invert. Put 1000000000 into R1. divide input by R1, saving remainder. add ASCII '0' to result. Output character. Divide R1 by 10. put remainder into input. Loop unless R1=0.
Floats aren't used by many programs so shouldn't be compiled unless needed. Same with hex and pointers and leading zeros etc.
I can assure you that when writing code for microcontrollers with 2 kilobytes of code space, we don't include a 14 kilobyte string formatting library...
Please note that a direct comparison would be apples-to-oranges though.
Interesting, I've never done this test!
In order for delete[] to work, C++ must track the allocation size somewhere. This could be co-located with the allocation (at ptr - sizeof(size_t) for example), or it could be in some other structure. Using another structure lowers the odds of it getting trampled if/when something writes to memory beyond an object, but comes with a lookup cost, and code to handle this new structure.
I'm sure proper C++ libraries are doing even more, but you already get the idea, new and delete are not the same as malloc and free.
That is super-interesting, I had never considered this, but you're absolutely right. I am now incredibly curious how the standard library implementations do this. I've heard normal malloc() sometimes colocates data in similar ways, I wonder if C++ then "doubles up" on that metadata. Or maybe the standard library has it's own entirely custom allocator that doesn't use malloc() at all? I can't imagine that's true, because you'd want to be able to swap system allocators with e.g. LD_PRELOAD (especially for Valgrind and stuff). They could also just be tracking it "to the side" in some hash table or something, but that seems bad for performance.
Many implementations do it, only because it is already there and thus it is easy just to reach for them.
Notably I thought the issue would be the throwing of `std::bad_alloc`, but the new version still implements std::allocator, and throws bad_alloc.
And so I assume the issue is that the global `operator new` is concrete (it just takes the size of the allocation), thus you need to link to the C++ runtime just to get that function? In which case you might be able to get the same gains by redefining the global `operator new` and `operator delete`, without touching the allocator.
Alternatively, you might be able to statically link the C++ runtime and have DCE take care of the rest.
The code for an algorithm like Dragonbox or Dragon4 alone is already blowing your size budget, so the "optional" stuff doesn't really matter. And that's 1 of like 20 features people want.
then the thing to do is publish the libraries you do use, right, then document what formatting features they support? then other people might discover more and clever ways to pack more features in than you thought of
otherwise, I don't get your point.
If you want something that has to be microscopic at the cost of not supporting basic features there are definitely better options.
> I can assure you that when writing code for microcontrollers with 2 kilobytes of code space, we don't include a 14 kilobyte string formatting library...
No shit. If you only have 2kB (unlikely these days) don't use this. Fortunately the vast majority of modern microcontrollers have way more than that. E.g. esp32 starts at 1MB. Perfectly reasonable to use a 14kB formatting library there.
Which models? The most I've ever seen on an ESP32 is 512KB of SRAM.
I learned to program in Atari 8-bit systems, and know there are limitations on what you can output. londons_explore had a completely valid comment. I was just looking for a perspective for developers getting involved in microcontroller environments, and how they could best debug their code. We all know that a debugger is better than a "print" statement, but not always the fastest, especially with logic. If my answer is just "debugging", and there isn't another, that satisfies me. I am always looking for unique ways developers solve problems in ANY environment, because I would enjoy being able to work in any environment that's best for the solution being provided. I guess I have been blessed with environments where the client is willing to pay for something higher-level than what is actually required.
I enjoy all aspects of development across devices, so I hope nobody took my comment as a challenge, it absolutely was not.
I'm pretty sure you wouldn't use C++ in that situation anyway, so I don't really see your point.
Iostream is… far bigger than this, for example.
Even then, if you read the disassembled code, you can usually find within a few minutes looking some stupid/unused/inefficient code - so you could totally do a better job if you wrote the assembly by hand, but it would take much more time (especially since most of these architectures tend to have very irregular instruction sets)
But there are plenty of other similar things - like making the code that determines the flashing pattern of a bicycle light or flashlight. Or the code that does the countdown timer on a microwave. Or the code that makes the 'ding' sound on a non-smart doorbell. Or the code that makes a hotel safe open when the right combination is entered. Or the code that measures the battery voltage on a USB battery bank and puts 1-4 indicator LED's on so you know how full it is.
You don't tend to hear about it because the design of most of this stuff doesn't happen in the USA anymore - the software devs are now in China for all except high-end stuff.
that's C strings. You also need to handle (size, data) strings like std::string_view
For the record, here's what fmt allows (from the docs):
fmt::print(fmt::emphasis::bold | fg(fmt::color::red)
, "Elapsed time: {0:.2f} seconds", 1.23);
fmt::print("Elapsed time: {0:.2f} seconds"
, fmt::styled(1.23, fmt::fg(fmt::color::green) fmt::bg(fmt::color::blue)));
fmt::print("{}", fmt::join(std::vector<int>{1, 2, 3}, ", "));
fmt::print("strftime-like format: {:%H:%M:%S}\n", 3h + 15min + 30s);
you really think you can replicate that in 50 bytes?Pretty easy to accidentally use some iostream and accidentally pull in loads of code you didn't want though.
Indeed a decade ago I would not have any doubts. Using "k" to refer to "kB" was much more common.
The Dragonbox author reports[1] about 25 ns/conversion, Cox reports 1e5 conversions/s, so that’s a factor of 400. We can probably knock off half an order of magnitude for CPU differences if we’re generous (midrange performance-oriented Kaby Lake laptop CPU from 2017 vs Cox’s unspecified laptop CPU ca. 2010), but that’s still a factor of 100. Still a performance chasm.
You can likely get some of the performance back by picking the low-hanging fruit, e.g. switching from dumb one-byte bigint limbs in [0,10) to somewhat less dumb 32-bit limbs in [0,1e9). But generally, yes, this looks like a teaching- and microcontroller-class algorithm more than anything I’d want to use on a modern machine.
[1] https://github.com/jk-jeon/dragonbox/blob/master/README.md#p...
The others may have a serial port setup during development, too. If you have a truly small formatter, you can just disable it for final builds (or leave it on, asssuming output is non blocking, if someone finds the serial pins, great for them), rather than having larger rom for development and smaller for production.
Besides that, microcontrollers have megabytes of storage these days. To be restricted by a dozen or two kilobytes of code storage, you need to be _very_ storage restricted.
I have run into code size issues myself when trying to run Rust on an ESP32 with 2MiB of storage (thought I bought 16MB, but MB stood for megabits, oops). Through tweaking the default Rust options, I managed to save half a megabyte or more to make the code work again. The article also links to a project where the fmt library is still much bigger (over 300KiB rather than the current 57KiB).
There are microcontrollers where you need to watch out for your dependencies and compiler options, and then there are _tiny_ microcontrollers where every bit matters. For those specific constraints, it doesn't make a lot of sense to assume you can touch every language feature and load every standard library to just work. Much older language features (such as template classes) will also add hundreds of kilobytes of code to your program already, you have to work around that stuff if you're in an extremely constrained environment.
The important thing with language features that includes targets like these is that you can disable the entire feature and enable your own. Sharing design goals between x64 supercomputers and RISC-V chips with literal dozens of bytes of RAM makes for an unreasonably restricted language for anything but the minimal spec. Floats are just expensive on minimum cost chips.
The most famous (technically a C defect) is probably DR#260: https://www.open-std.org/jtc1/sc22/wg14/www/docs/dr_260.htm
When a destructor doesn't - e.g., new int[] - operator new[] is called upon to allocate N*sizeof(T) bytes. The code stores off no metadata. The result of operator new[] is the array address.
When a destructor does - e.g., new std::string[] - operator new[] is called upon to allocate sizeof(size_t)+N*sizeof(T) bytes. The code stores off the item count in the size_t, adds sizeof(size_t) to the value returned by operator new[], uses that as the address for the array, and calls T() on each item. And delete[] performs the opposite: fishes out the size_t, calls ~T() on each item, subtracts sizeof(size_t) from the array address, and passes that to operator delete[] to free the buffer.
(There are also some additional things to cater for: null checks, alignment, and so on. Just details.)
Note that operator new[] is not given any information about whether a destructor needs to run, or whether there is any metadata being stored off. It just gets called with a byte count. Exercise caution when using placement operator new[], because a preallocated buffer of N*sizeof(T) may not be large enough.
The new version uses `FMT_THROW` macro instead of a bare throw. The article says "One obvious problem is exceptions and those can be disabled via FMT_THROW, e.g. by defining it to abort". If you check the `g++` invocation, that's exactly what the author does.
Good luck actually distributing that binary to users without all the various kinds of scareware in the way yelling DANGER.
The surest bet would be a compile time feature flag to disable floating point formatting support which it does have.
Still, that’s 8kib of string formatting library code without floating point and a bunch of other optimizations which is really heavy in a microcontroller context
Especially if you extended them to indicate assumptions about the values at compile time. E.g., possible ranges for integers, whether or not a floating point value can have certain special values, etc.
They probably should be passed at compile time, like how zig does it. It seems so weird to me that in C & C++ something as simple as format strings are handled dynamically.
Clang even parses format strings anyway, to look for mismatched arguments. It just - I suppose - doesn’t do anything with that.
It’s also important to note that the floating point code only contributed ~44kib out of 75kib but they stopped once the library got down to ~23kib and then removed the c++ runtime completely to shave off another ~10kib.
However, it’s also equally important to remember that these shavings are interesting and completely useless:
1. In a typical codebase this would contribute 0% of overall size and not be important at all
2. A codebase where this would be important and you care about it (ie embedded) is not served well by this library eating up at least 10kib even after significant optimization as that 10kib that is intractible is still too large for this space when you’re working with a max ~128-256kib binary size (or even less sometimes).
And run time format strings are a concise encoding for calling this functionality. I would assume that compile time alternatives take more space.
Then you shouldn't prioritize compatibility with 1980s Unix code, which is what C++ is for.
On very old archs (16bits, 8bits), there was no OO, because the code could not be complex enough to warrant it. It could not be complex because there wasn't room enough.
That being said, there are templated libraries (like fmt...) which may result in zero overhead in code size, so if the thread OP is using C++, then surely he could also use that library...
modern compilers are way better about de-duplicating "different types but same instruction" template specializations so it's less of an issue than you may expect , especially if you're coming with template specialization generation from the mid/late 2000's.
Even in an embedded context you have classes and destructors, operator overloading and templates. You can still make data structures that exist in flat constrained memory.
Even demo scene people use C++ and windows binaries can start at 1KB. Classes, operator overloading and destructors are all still useful. There is no reason there has to be more overhead than C.
But anyway, the point that I am refuting is the use of C++ to write programs for such an extremely constrained runtime environnement, and at the same time refuse to use this library (which is template based afaik).
That said, that's a very good point. Maybe they'd even use that fmt library?
https://en.wikipedia.org/wiki/Gish_gallop
You said this:
I'm pretty sure you wouldn't use C++ in that situation anyway, so I don't really see your point.
People have pointed out to you why you both can and would use C++, especially in place of C.
Let me coin a new term to help me in that task: the "swarm gallop", which would be that technique applied by a group of people, instead of just one person (somewhat like a DDOS).
Thank you.
Have fun,
"Rich Code for Tiny Computers: A Simple Commodore 64 Game in C++17"