Template Instantiation
One template becomes one concrete function per set of arguments used, in every translation unit that used it — and then the linker throws nearly all of those copies away. The bill arrives as compile time, object-file size and link work, in that order.
Where does the compile time and the binary size actually go in a template-heavy project?
While one translation unit is being compiled, the instantiations live in a table keyed by (template, argument list). Each entry is a fully substituted declaration and, if the specialisation was used in a way that demands a body, a fully substituted definition that then walks the ordinary pipeline — optimization, code generation — as if you had written it out by hand. The object file that comes out is therefore not "the code for this unit": it is a set of *candidate* definitions, each tagged with the mangled name that identifies it and packaged so the linker may keep one and discard the rest. The question the representation exists to answer is which concrete functions this unit needs, and which of them it is obliged to supply.
A specialisation must be implicitly instantiated exactly where it is odr-used, and not before: naming std::vector<Widget> requires the class, but only the member functions you actually call are instantiated, and an unused member is never even checked. Across units, C++ grants templates an explicit exception to the One Definition Rule — the same specialisation may be defined in many translation units, and the linker may keep any one, *provided* every definition consists of the same token sequence and every name in it means the same entity in every unit. Compile two units with different -D flags, or with a struct that has an extra field in one of them, and the exception does not apply: the program is ill-formed with no diagnostic required, the linker still silently keeps one definition, and the other unit calls it with the wrong layout.
Key points
- A specialisation is instantiated once per translation unit that uses it; nothing coordinates the units, so the compile-time cost multiplies by the include fan-out.
- The linker recovers the space with link-once symbols, but never the time — the work was already done in every unit.
- Member functions of a class template are instantiated only when used, which saves code and means unused members were never type-checked.
- Section-group folding is required and safe; identical code folding is optional, opt-in, and makes distinct functions share an address.
- The ODR exception for templates holds only if every definition is token-identical and every name means the same thing — differing macros or layouts break it with no diagnostic.
extern templateconverts a specialisation back into an ordinary separately compiled function, trading inlining for build time.
The demand is per translation unit, and nothing coordinates it
W class and the section-group mechanism behind it are ELF, as produced by GCC and Clang on Linux. Mach-O spells the same idea weak external with weak_def_can_be_hidden, and PE/COFF uses COMDAT sections with an explicit selection kind chosen by the compiler. The guarantee is equivalent on all three; the tooling output and the failure messages are not, so a script that greps for W works on exactly one platform.The compiler has no idea what any other translation unit is doing. When unit A calls twice(21) it instantiates int twice(int) and emits a definition; when unit B does the same it instantiates and emits its own. Neither can skip the work on the grounds that someone else did it, because compiling units independently is the entire point of the model — see [[cpp-pipeline]].
So the same specialisation is parsed, substituted, checked, optimized and encoded once per unit that needs it. In a project where a header-only library is included by four hundred files, the compiler produces four hundred copies of every specialisation those files touch. This is the term that dominates template-heavy build times, and it is not visible in any one file: no single compilation looks slow, the total is enormous, and the profile is flat.
What saves the binary is not the compiler but the linker. Each definition is emitted with *vague linkage* — a weak, link-once symbol in its own section group — so the linker can discard all but one. The compile-time work is still paid four hundred times; only the space is recovered.
1// twice.h2template <typename T> T twice(T x) { return x + x; }3 4// a.cpp: int f() { return twice(21); }5// b.cpp: long g() { return twice(21L); }6 7$ g++ -c a.cpp b.cpp8$ nm -C a.o | grep twice90000000000000000 W int twice<int>(int)10$ nm -C b.o | grep twice110000000000000000 W long twice<long>(long)12 13$ nm a.o | grep twice140000000000000000 W _Z5twiceIiET_S0_W is a weak symbol: this unit offers a definition and consents to another one winning. That single letter is the whole cross-unit contract for templates — and _Z5twiceIiET_S0_ is why the linker can tell twice<int> from twice<long> without understanding a word of C++.
Only what is used, and that rule cuts both ways
Implicit instantiation is lazy at member granularity. Instantiating std::vector<Widget> instantiates the class — enough to know its size, layout and member declarations — but a member function body is instantiated only when that member is odr-used. A container used only for push_back and iteration never generates code for the two dozen members you did not call.
The saving is real and it is also a trap, because an uninstantiated member is an unchecked member. A class template can contain a member whose body is ill-formed for Widget and compile perfectly for years, until someone calls it. That is the same instantiation-time checking described in [[templates]], seen from the build side: the set of things the compiler has verified is exactly the set of things somebody happened to use.
Explicit instantiation (template class Foo<int>;) forces every member, which is how library authors both pin down the code generation and get the whole class checked in one place. It is also the standard way to test a template properly: instantiate it explicitly for the types you claim to support, in a translation unit whose only job is to fail loudly.
The linker deduplicates, and one of its tools is dangerous
--icf=all and --icf=safe, mold implements folding with its own heuristics, and MSVC's /OPT:ICF is on by default in release builds — which means a Windows release build may already be folding functions that a Linux release build of the same source keeps distinct. The saving is also entirely program-dependent; treat any percentage as a measurement of one binary.Two separate mechanisms are at work at link time and they are constantly confused. The first is section-group deduplication: definitions that share a mangled name are grouped, and the linker keeps one group and discards the identical ones. This is required for the model to work at all, it is on by default everywhere, and it is safe by construction because the discarded definitions were required to be identical in the first place.
The second is identical code folding — ICF. Here the linker compares the *machine code* of functions with different names and merges those whose bodies happen to be byte-identical after relocation. Templates generate a great deal of this: twice<int> and twice<long> may compile to the same instructions on a 64-bit target, and a std::vector<Foo*> and a std::vector<Bar*> share almost everything.
ICF saves real space and it changes an observable property: two functions that were distinct now have the same address. Code that compares function pointers for identity, or that uses a function address as a map key or a type tag, silently starts finding two things equal that the language says are different. This is why ICF is opt-in and why linkers offer a "safe" mode that folds only functions whose address is never taken.
| Property | Section-group / COMDAT folding | Identical code folding (ICF) |
|---|---|---|
| What it compares | The mangled symbol name | The bytes of the function body, after relocations |
| Why it is allowed | The language required the definitions to be identical | Nothing in the language allows it; it is a deliberate deviation |
| Defaultimplementation | On, always — the model does not work without it | Off; --icf=all or --icf=safe in lld and gold, /OPT:ICF in MSVC |
| Observable effect | None | Distinct functions can compare equal by address |
| Typical savingtypical | Everything above one copy per specialisation | Single-digit to low-double-digit percent of .text in template-heavy code |
| How you check | readelf -g lists the section groups in an object file | -Wl,--print-icf-sections lists what was folded |
Paying it down: extern template
If a specialisation is used in hundreds of units and its definition is large, the compile-time cost of instantiating it hundreds of times is pure waste — the linker was always going to keep one. extern template says so explicitly: it suppresses implicit instantiation in every unit that sees the declaration, and one designated unit supplies the definition with an explicit instantiation.
The effect is to convert a template back into an ordinary separately compiled function for the argument lists you name. That buys build time and costs the things separate compilation always costs: the definition is no longer visible at the call site, so the optimizer cannot inline it or specialise it against the caller, and adding a new argument list means editing the designated unit rather than just using it.
This is a targeted tool, not a policy. Applied to a small function it makes the program slower for no build-time gain, because a one-line body that used to inline is now a call. Measure first, with -ftime-trace, and apply it to the handful of large specialisations that actually dominate.
// bigthing.h — included by 400 files
template <typename T> class BigThing { /* large members */ };
// every one of the 400 units instantiates BigThing<int> and emits it// bigthing.h
template <typename T> class BigThing { /* large members */ };
extern template class BigThing<int>; // do not instantiate here
// bigthing.cpp — exactly one unit
template class BigThing<int>; // instantiate and emit hereOnly if exactly one translation unit in the final link provides the explicit instantiation definition for that exact argument list, that unit is actually part of the link, and every unit sees the extern template declaration before any use that would otherwise instantiate. The specialisation must not be one the language forbids suppressing — an inline function whose address is required, or a member the standard says is implicitly instantiated regardless.
If the designated unit is in a static library that the linker discards for having no referenced symbols, or if a unit uses BigThing<short> — an argument list nobody explicitly instantiated — and the extern template declaration for int gave a false sense that all of them were covered. The first produces "undefined reference" at link time; the second quietly instantiates as normal, so the build-time gain you measured evaporates as the argument set grows.
How it works
The steps, in the order the compiler takes them.
- A use of a template with concrete arguments triggers deduction, constraint checking, and a lookup in the unit's table of already-produced specialisations.
- On a miss, the compiler substitutes the arguments through the declaration; if the use requires a definition, it substitutes through the body as well and adds the result to the table.
- The substituted definition is type-checked, lowered, optimized and encoded exactly like a hand-written function, and emitted into its own section group under the mangled name of the specialisation.
- Members of a class template are handled the same way individually, so only the ones odr-used produce code.
- At link time the linker keeps one group per mangled name and discards the rest, applying relocations to point every reference at the survivor.
- If identical code folding is enabled, it then compares the remaining function bodies byte for byte and merges matches across different names, updating relocations again.
- An
extern templatedeclaration suppresses step two in every unit that sees it, leaving one explicit instantiation as the sole supplier.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- A full build takes twenty minutes and no individual file takes more than four seconds;
-ftime-traceshows the time is instantiation, spread evenly across everything. - The binary grows by megabytes after a refactor that introduced a handful of new template arguments, and
bloatyattributes it to symbols nobody wrote. - A member function of a widely used class template turns out to be ill-formed for one of the types it is documented to support, discovered years later by the first person to call it.
- A function-pointer comparison that always worked starts returning true for two different functions after a release build enables identical code folding — usually on one platform only.
- Two libraries are built with different
-Dflags, both define the same specialisation, the linker keeps one, and a struct is read with the wrong layout at run time with no diagnostic from any stage. extern templateis added, build time improves, and a hot small function that used to inline becomes a call — a measurable slowdown with nothing in the source to point at.
When it helps
- Diagnosing build time in a C++ project, where the answer is almost always instantiation count rather than lines of code.
- Understanding why a header-only library is convenient to consume and expensive to consume at scale.
- Deciding what belongs in a header versus behind an explicit instantiation, which is the physical-design decision that sets build time for every consumer.
- Reading a linker map or a size report, where most large symbols in template-heavy code are instantiations rather than functions anyone typed.
When it hurts
- Applying
extern templatebroadly as a policy: it removes inlining opportunities across the board to fix a cost that a few specialisations were responsible for. - Reasoning about binary size from source size. The relationship runs through the number of distinct argument lists, which is not visible in any file.
- Assuming deduplication makes duplicate instantiation free. It makes it free in space; the compile time is spent regardless, and it is the term that hurts.
What it costs
Every one of these is paid by something.
- Instantiating per translation unit buys independent, parallel compilation with no coordination between units, and pays with compile time proportional to uses times include fan-out — the dominant term in template-heavy builds.
- Link-once vague linkage buys a binary that contains one copy of each specialisation, and pays with an unchecked global obligation: the definitions must be identical, and nothing verifies it.
- Lazy member instantiation buys smaller output and faster builds, and pays with members that are never type-checked until somebody calls them.
- Identical code folding buys real space in template-heavy binaries and pays by breaking function-pointer identity, which is why it is off by default on most toolchains.
extern templatebuys build time and pays with the inlining and cross-function optimization that visibility of the definition made possible.
What else you could do
What a different compiler or language does instead, and when that is better.
- Type erasure behind a virtual interface or a
void*implementation core compiles once, keeps size constant and permits a stable ABI, paying an indirect call — see[[type-erasure]]and[[abi-stability]]. - The "thin template" idiom does both: a non-template implementation carries the bulk of the code and a tiny inline template adds type safety on top, so only a few instructions are duplicated per argument list.
- Rust monomorphizes the same way but generates code in the crate that uses the generic, so deduplication is a compiler-internal concern instead of the linker's — see
[[rust-pipeline]]. - Go's generics avoid full duplication entirely by sharing one instantiation per memory shape and passing a dictionary — smaller output, some indirection. See
[[go-pipeline]]. - C++20 modules remove the reparse-per-unit half of the cost by compiling an interface once, though the instantiation itself still happens where it is used — see
[[modules]].
See it for yourself
The flag, dump or tool that shows you this directly.
clang -ftime-tracewrites a Chrome-tracing JSON per translation unit with a span per instantiation;ClangBuildAnalyzeraggregates them across a whole build and names the worst templates.nm -C --size-sort object.oshows which instantiations are large; theW(orweak external) class marks the link-once ones.readelf -g object.olists the section groups — one per link-once definition — which is the mechanism itself rather than its effect.bloaty --domain=vm -d symbols binaryattributes binary size to demangled symbols and makes instantiation bloat obvious in one screen.ld.lld -Wl,--print-icf-sectionsreports exactly which functions were folded, and--icf=saferestricts folding to functions whose address is never taken.-Wl,-Map=out.mapproduces a linker map showing which object file each surviving definition came from.
Plausible wrong readings
Stated the way a confident engineer states them.
- "The linker deduplicates the instantiations, so duplicate instantiation is free." It is free in the binary. It is paid in full, once per translation unit, in the compiler — and that is the cost people actually feel.
- "Instantiating
std::vector<Widget>generates all ofstd::vector." It generates the class and only the members you use. The rest is neither emitted nor checked. - "Two identical functions folded to one address, so the linker has a bug." That is identical code folding doing what it was asked to do. It is off by default on most toolchains precisely because it is observable.
- "
extern templatemakes the program faster." It makes the *build* faster and can make the program slower, by removing the definition the inliner needed. - "If both libraries built successfully, their instantiations must agree." Nothing checked. The ODR exception for templates is an obligation on you, and a violation links cleanly.
Misconceptions
The claim, and what is actually true.
Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
Using a template with a type makes the compiler write out a real function for that type. It does this separately in every file that uses it, because files are compiled independently and cannot know about each other. At the end, the linker notices the copies are identical and keeps one. The binary is fine; the build time is not, because the work happened in every file.
practical
When a C++ build is slow, run clang -ftime-trace and aggregate it — the answer is usually a handful of templates instantiated everywhere. The fixes, in order of how often they work: reduce the include fan-out so fewer units see the template at all; move large template bodies out of widely included headers; use extern template for a small number of large, heavily used specialisations. When a binary is unexpectedly large, run bloaty -d symbols and look for instantiations of things you did not write.
advanced
The interesting structural point is that C++ pushed a compiler problem into the object format. Because instantiation must happen where the template is used, and because units cannot coordinate, the language needed a linkage kind meaning "here is a definition; another unit may offer the same one; keep any". That requirement shaped ELF section groups and COMDAT alike, and it is why inline functions, virtual tables, static data members of class templates and default argument thunks all travel the same way. Rust reaches the same destination differently: monomorphization happens inside the compiler for the whole crate graph, so deduplication is a compiler-internal decision about codegen units rather than an obligation on the linker — which is why a Rust build has no ODR to violate, and why its equivalent cost shows up as LLVM time on a mountain of monomorphized functions instead.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
W symbols under GCC and Clang, weak external definitions in Mach-O, COMDAT sections with a selection kind in PE/COFF. The behaviour is equivalent; the tool output, the flag names and the diagnostics are not, so instructions written for one platform mislead on the others.--icf=all and --icf=safe and default to off, mold folds with its own heuristics, and MSVC enables /OPT:ICF in release builds by default. The same source can therefore fold on Windows and not on Linux, which makes any function-pointer-identity bug platform-specific.-ftime-trace reports on mainstream projects using libstdc++ or libc++, not a law. A project whose headers are thin and whose bodies are large can be dominated by optimization instead, and a build with heavy constexpr metaprogramming can be dominated by constant evaluation — measure before acting.If you were asked this in an interview
- A C++ build takes twenty minutes and no single file is slow. Where would you look, and with what tool?
- Why can the same template specialisation be defined in fifty object files without a "multiple definition" error?
- What does identical code folding change about a program's observable behaviour, and why is it off by default?
Connections
- DevOps / Production Engineering — Build caching, distributed compilation and remote executionDuplicate instantiation across units is exactly the work a compile cache or a distributed build farm can avoid re-doing, so the cost model here is the reason those tools pay for themselves on C++ codebases and rarely on others. The cache infrastructure itself belongs there.