ADR 0004: Type-Erase std::format at the Logging ABI Boundary¶
Status¶
Accepted.
Context¶
Build-time profiling (./scripts/profile-build, see
docs/development/build-profiling.md) showed that log.hpp was by far the most
expensive header in the engine — ~19 s of aggregate parse time across the
translation units that log, and the dominant entry in every "expensive
templates / functions" list. The cause was structural: the public Log(...)
function template called std::vformat(...) in the header, so the entire
<format> instantiation machinery (std::vformat_to, _Formatting_scanner,
the per-type __formatter_*, both char and wchar_t sinks) was re-generated
in every logging translation unit.
Decision¶
Erase the format arguments to std::format_args at the call site and perform
the actual formatting in a single non-template function compiled once in
logger.cpp.
// log.hpp — trivial pack expansion only; no <format> machinery instantiated
template <typename... Args>
void Log(LogLevel lvl, LogCategory cat, const std::source_location& loc,
std::format_string<Args...> fmt, Args&&... args)
{
if constexpr (sizeof...(Args) == 0)
detail::DispatchMessage(lvl, cat, loc, fmt.get()); // no formatting
else
detail::VLog(lvl, cat, loc, fmt.get(), std::make_format_args(args...));
}
// logger.cpp — std::vformat instantiated exactly once for the whole engine
void detail::VLog(LogLevel, LogCategory, const std::source_location&,
std::string_view fmt, std::format_args args);
Call sites keep compile-time format-string checking (std::format_string) and
still pay only the cheap std::make_format_args pack expansion. std::vformat
and its template explosion live in one place.
Consequences¶
Measured on the linux-clang-development preset (Clang 18, -ftime-trace),
clean build, before vs after:
| Metric | Before | After | Change |
|---|---|---|---|
| Frontend parsing (summed) | 52.5 s | 22.5 s | −57% |
| Backend codegen (summed) | 21.9 s | 9.0 s | −59% |
| Slowest single TU | 5.8 s | 2.7 s | −54% |
log.hpp aggregate parse |
~19.0 s | ~9.0 s | −53% |
The std::__format::* / std::vformat_to template instantiations that
dominated the baseline no longer appear in the per-TU profile; the wchar_t
format paths are no longer instantiated per TU either.
The residual log.hpp cost is <format> being parsed (still included for
std::format_string / std::make_format_args), plus <chrono> pulled in
transitively. Those are the next levers (precompiled header for the stable heavy
STL, or trimming the header further); they are tracked separately and must be
justified with a profile-build measurement.
Generalization¶
This establishes a project rule (see AGENTS.md): do not instantiate heavy
standard-library templates in public headers. Expensive facilities are
type-erased or PIMPL'd behind a .cpp boundary; templated public APIs erase to
a non-template implementation as early as possible.
Amendment (logging redesign, phases 0–4)¶
The original decision type-erased std::format to std::format_args so that the
std::vformat instantiation happened once instead of per translation unit.
That removed replicated execution machinery, but it did not remove the cost
of parsing <format> (and its transitive <chrono>), which every logging TU
still paid because log.hpp included <format>. Erasure of execution and
elimination of parse cost are different things; this ADR previously conflated
them.
The logging redesign supersedes the std::format boundary with a narrow,
bounded, no-heap typed formatter (internal/format_engine.{hpp,cpp}), selected
after measuring both candidates on the pinned toolchain
(.kiro/specs/logging-redesign/decision-log.md):
- Build: the common
log.hppno longer includes<format>/<filesystem>; measured preprocessed expansion fell from ~79,790 to ~20,232 lines, and the opt-inlog_format.hppfrom ~65,599 to ~21,213 lines (clang-18, this host). - Contract: unlike
std::vformat, the narrow formatter never allocates and never terminates on a bad/oversize format under-fno-exceptions; it returns{BytesWritten, Truncated, FormatError}. This is a hard acceptance gate thestd::formatpath could not meet, and was the deciding factor — not build time alone. - Cost accepted: the narrow packer adds ~40 bytes of
.textper call site versusmake_format_args; this is bounded and tracked, and does not violate any hard gate.
std::format is therefore no longer used anywhere in the logging headers or
engine translation units. The temporary log_compat_format.hpp adapter
contemplated by the spec was not needed: every existing call site already fits
the narrow grammar. The rule this ADR established — do not instantiate heavy
standard-library templates in public headers — still holds and is now enforced
by header parse budgets (ADR 0005) plus the split-header structure.