Introduction
The printf family of functions are commonly used on size-constrained platforms for debugging, logging, and string formatting. Code for these platforms is commonly written in C, where type-safe APIs like iostreams and fmt are unavailable. Even for C++, these have somewhat of a reputation for increasing code size over printf, since C++ template monomorphization typically occurs at point of use. By comparison, calls to printf only have C variable argument list overhead. Uses tend to outnumber definitions, so code size overall tends to be an issue unless there are quite few format calls.
However, while uses are tight, a printf implementation must include every possible aspect of string formatting into the final binary. Real programs commonly use very little of that functionality. Runtime-variable format strings are quite rare, and clang already statically analyzes the contents and semantics of format strings to turn printf calls into e.g. puts calls. However, without the cooperation of libc, its transformations are limited to those expressible in the public C API.
Weak Implementation Functions
The pairing of clang and a statically-linked llvm-libc presents an opportunity to develop transformations that would allow dropping unused printf implementation code at a finer granularity. Here is an approach to doing so suggested in an earlier Discourse thread. (This was also mentioned as having been used in an ARM toolchain.)
The basic idea is that clang would analyze printf calls with statically-known format strings and emit a series of strong, but vacuous, references to symbols that declare that a various printf format string characteristics are present. Then, that call to printf would be rewritten to an alternative entry point that would be allowed to drop any aspect of its implementation that is not required by a characteristic present in the link.
For example, following call:
printf(“%f”, 42);
Would be rewritten to the equivalent of:
__printf_core(“%f”, 42);
asm(“.globl __printf_f”);
Here __printf_core is the printf implementation, and __printf_f is a symbol that declares that the format string contains a %f specifier. The implementation of __printf_core could freely place implementation functions that are needed for the %f specifier in a translation unit that defines that symbol. If these implementation functions were only weakly referenced by __printf_core, then they would not be brought in to the link unless some call actually required them.
printf itself would be a thin wrapper around __printf_core, but it would also declare that all possible printf characteristics are present. This provides a conservative way to drop-out of the feature.
llvm-libc Implementation
llvm-libc is actually quite well suited to this; most of the meat of the implementation is hidden behind convenient convert_<type> functions. Making these weak should provide most of the benefit of this approach with relatively little effort. The format specifier parsing logic is separate, and it seems like there’s relatively little opportunity to break parts of it out. It would be nice to have data on how much size removing all conversion functions from printf saves.
Extensibility
Rewriting a call from printf to __printf_core establishes a contract between the compiler and libc implementation that supports this feature. Extending this contract isn’t trivial.
For example, say the only characteristic is “supports floats” with symbol __printf_float. A __printf_core with this contract must retain float-related implementation if __printf_float is present in the link.
Say we wanted to make this finer-grained later, and we add __printf_f, __printf_g, and __printf_e for specifiers. If the compiler no longer included __printf_float, then it would make an older libc crash on %f.
Conversely, say we made the compiler include __printf_float. A libc would want to take presence of __printf_float but absence of __printf_f as evidence that %f isn’t used. This wouldn’t be true for an older compiler, since it only knows about __printf_float.
One way around this would be to include a version identifier to disambiguate the contract, e.g. __printf_core_v1. This problem seems akin to other symbol versioning concerns in compilers, but I have little direct experience with these, so advice here would be helpful.
Another option is to get it right the first time. Famous last words though.
Other Possible Optimizations
iprintf and small_printf are pre-existing alternative entry points for printf that preclude certain implementation characteristics (floats and large integers, respectively). This is a coarser mechanism than the one presented here, since it doesn’t compose well.
- The core implementation of
printf could be inlined by the compiler into a series of calls to a new cluster of API routines. This would essentially turn a printf call into an iostreams/fmt call at compile time. This may be appropriate in some cases, but it would likely come with a size cost if applied generally.