C++ Module 9 – Templates and concepts
Templates in practice: build time and binary size
Measure where template-heavy C++ spends compile time, cut repeated instantiation with extern template, shrink object files and read long template errors.
What you will learn
- Predict how object files grow with the number of instantiations, and why the linker hides it
- Measure where template-heavy code spends compile time with -ftime-trace and -ftime-report
- Remove repeated instantiations with extern template, and measure the effect
- Move code that does not depend on the template parameters into a detail part compiled once
- Read a long template error from the instantiation chain to the line in your own code
Before you start
What you’ll learn · Before you start
What you will learn
- Predict how object files grow with the number of instantiations, and why the linker hides it
- Measure where template-heavy code spends compile time with -ftime-trace and -ftime-report
- Remove repeated instantiations with extern template, and measure the effect
- Move code that does not depend on the template parameters into a detail part compiled once
- Read a long template error from the instantiation chain to the line in your own code
Before you start
On this page
Sort vectors of 40 types that differ only in their names, and the object file grows from 5,344 bytes for one type to 168,744 with Clang, although the source did not get any longer. Templates cost build time and binary size because each instantiation: The compiler writing a real function or class from a template for particular template arguments, such as larger<int> from larger, the first time a use needs it. Each instantiation is compiled as a function or class of its own. More in “C++ function templates and argument deduction”, each distinct set of template arguments, is compiled on its own, and again in every translation unit that uses it.
You will measure that cost with Clang’s -ftime-trace and GCC’s -ftime-report, cut repeated instantiations with
extern template and see where it backfires, move code that ignores the template parameters into a part compiled
once, and read a long template error in three steps. Every example ran with Apple clang 21 and GCC 16.
Every instantiation is compiled on its own
std::sort on vectors of 40 different types is 40 copies of the sorting code, each instantiated, optimised and
emitted. many.cpp sorts a vector of Item<Tag> for COPIES different tags. The Item types are identical in every
way except their names, which is enough to make each one a separate instantiation of everything that touches it:
// One small template instantiated for COPIES different types: Item<0>, Item<1>, ... Item<COPIES - 1>.
#include <algorithm>
#include <compare>
#include <cstddef>
#include <utility>
#include <vector>
template <int Tag>
struct Item {
int key;
auto operator<=>(const Item&) const = default;
};
template <typename T>
std::size_t sort_and_count_unique(std::vector<T>& values) {
std::sort(values.begin(), values.end());
return static_cast<std::size_t>(std::unique(values.begin(), values.end()) - values.begin());
}
// Calls sort_and_count_unique once for each type Item<0> to Item<COPIES - 1>.
template <int... Tags>
std::size_t run_all(std::integer_sequence<int, Tags...>) {
std::size_t total = 0;
((total += [] {
std::vector<Item<Tags>> v{{3}, {1}, {3}, {2}};
return sort_and_count_unique(v);
}()),
...);
return total;
}
int main() { return static_cast<int>(run_all(std::make_integer_sequence<int, COPIES>{})); } """Compiles many.cpp with 1, 10 and 40 instantiations on both compilers (-O2) and prints the size of each object file
and of its machine code (the __text section)."""
import os
import re
import subprocess
env = dict(os.environ)
env["PATH"] = os.pathsep.join([env.get("PATH", ""), "/opt/homebrew/bin", "/usr/local/bin"])
for compiler in ("clang++", "g++-16"):
for copies in (1, 10, 40):
subprocess.run([compiler, "-std=c++23", "-O2", f"-DCOPIES={copies}", "-c", "many.cpp", "-o", "many.o"],
check=True, env=env)
sections = subprocess.run(["size", "-m", "many.o"], capture_output=True, text=True, check=True).stdout
code = int(re.search(r"\(__TEXT, __text\): (\d+)", sections).group(1))
print(f"{compiler:<8} {copies:>2} types: object file {os.path.getsize('many.o'):>7,} bytes, machine code {code:>7,} bytes") Output
clang++ 1 types: object file 5,344 bytes, machine code 3,644 bytes clang++ 10 types: object file 42,976 bytes, machine code 35,884 bytes clang++ 40 types: object file 168,744 bytes, machine code 143,564 bytes g++-16 1 types: object file 3,336 bytes, machine code 1,720 bytes g++-16 10 types: object file 24,456 bytes, machine code 17,000 bytes g++-16 40 types: object file 94,864 bytes, machine code 67,880 bytes
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 sizes_session.py
The object file grows almost in proportion to the number of types, and the machine code with it:
| Item types | Apple clang 21 object file | GCC 16 object file |
|---|---|---|
| 1 | 5,344 bytes | 3,336 bytes |
| 10 | 42,976 bytes | 24,456 bytes |
| 40 | 168,744 bytes | 94,864 bytes |
Nothing in the source got bigger; only the list of types did. With Clang, 40 types made the object file about 32 times larger than one, close to 4,190 bytes for every extra type. Code size has a run-time cost as well, because a larger program uses more of the processor’s instruction cache.
Checkpoint
Check: One compiled copy per set of arguments
Every file that uses an instantiation compiles it again
Templates live in headers, so every source file that uses Matrix<double> instantiates it again. Matrix<double>
used in 300 source files is compiled 300 times before the linker throws 299 copies away. The linker accepts the
duplicates because the compiler marks them:
C header · matrix/matrix.h
// A matrix class template whose larger members are defined outside the class, as in most real headers.
#pragma once
#include <cstddef>
#include <stdexcept>
#include <vector>
template <typename T>
class Matrix {
public:
Matrix(std::size_t rows, std::size_t cols) : rows_(rows), cols_(cols), data_(rows * cols) {}
T& at(std::size_t r, std::size_t c) { return data_.at(r * cols_ + c); }
const T& at(std::size_t r, std::size_t c) const { return data_.at(r * cols_ + c); }
Matrix multiply(const Matrix& other) const;
Matrix transpose() const;
T trace() const;
private:
std::size_t rows_, cols_;
std::vector<T> data_;
};
template <typename T>
Matrix<T> Matrix<T>::multiply(const Matrix& other) const {
if (cols_ != other.rows_) throw std::invalid_argument("sizes do not match");
Matrix out(rows_, other.cols_);
for (std::size_t i = 0; i < rows_; ++i)
for (std::size_t k = 0; k < cols_; ++k)
for (std::size_t j = 0; j < other.cols_; ++j) out.at(i, j) += at(i, k) * other.at(k, j);
return out;
}
template <typename T>
Matrix<T> Matrix<T>::transpose() const {
Matrix out(cols_, rows_);
for (std::size_t i = 0; i < rows_; ++i)
for (std::size_t j = 0; j < cols_; ++j) out.at(j, i) = at(i, j);
return out;
}
template <typename T>
T Matrix<T>::trace() const {
T sum{};
for (std::size_t i = 0; i < rows_ && i < cols_; ++i) sum += at(i, i);
return sum;
}
#ifdef USE_EXTERN
// Every file that includes this header: do not instantiate Matrix<double> here; matrix.cpp does.
extern template class Matrix<double>;
#endif C++ (a.cpp) · matrix/a.cpp
// One of three source files that use Matrix<double>.
#include "matrix.h"
double work_a(double x) {
Matrix<double> m(3, 3);
for (std::size_t i = 0; i < 3; ++i) m.at(i, i) = x + static_cast<double>(i);
return m.multiply(m.transpose()).trace();
} C++ (main.cpp) · matrix/main.cpp
#include <cstdio>
double work_a(double), work_b(double), work_c(double);
int main() { std::printf("%.1f %.1f %.1f\n", work_a(1), work_b(2), work_c(3)); } from session import run
# Three object files, each with its own copy of Matrix<double>::multiply, then the program the linker makes of them.
# nm -m shows each symbol's section and kind; c++filt turns the mangled names back into C++. On macOS, c++filt is
# started through a small launcher that may print a note about its cache on standard error; 2>/dev/null keeps that
# note out of the names.
run("""
cd matrix && clang++ -std=c++23 -c a.cpp b.cpp c.cpp && for f in a.o b.o c.o; do echo "$f:"; nm -m $f | c++filt 2>/dev/null | grep "Matrix<double>::multiply"; done
cd matrix && clang++ -std=c++23 -c a.cpp b.cpp c.cpp && clang++ main.cpp a.o b.o c.o -o program && ./program && nm -m program | c++filt 2>/dev/null | grep "Matrix<double>::multiply"
""") Output
$ cd matrix && clang++ -std=c++23 -c a.cpp b.cpp c.cpp && for f in a.o b.o c.o; do echo "$f:"; nm -m $f | c++filt 2>/dev/null | grep "Matrix<double>::multiply"; done a.o: 00000000000001b8 (__TEXT,__text) weak external Matrix<double>::multiply(Matrix<double> const&) const b.o: 00000000000001b8 (__TEXT,__text) weak external Matrix<double>::multiply(Matrix<double> const&) const c.o: 00000000000001b8 (__TEXT,__text) weak external Matrix<double>::multiply(Matrix<double> const&) const $ cd matrix && clang++ -std=c++23 -c a.cpp b.cpp c.cpp && clang++ main.cpp a.o b.o c.o -o program && ./program && nm -m program | c++filt 2>/dev/null | grep "Matrix<double>::multiply" 14.0 29.0 50.0 0000000100000700 (__TEXT,__text) weak external Matrix<double>::multiply(Matrix<double> const&) const
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 duplicates_session.py
a.o, b.o and c.o each hold a complete Matrix<double>::multiply, marked weak external: a
weak definition: A definition that the linker may meet in several object files and keep once, without a duplicate-symbol error. Compilers emit template instantiations and inline functions this way; nm -m shows them as weak external., which the linker: The last stage of a build: it joins the object files of a program and the libraries it uses into one executable, matching every symbol a file uses with exactly one definition. More in “How C++ compiles: preprocess, compile and link” may meet several times
and keep once. Step through the build:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The first file. a.cpp includes matrix.h and calls multiply on a Matrix<double>, so compiling it instantiates the whole member function: a.o defines Matrix<double>::multiply.
- The second file, again. b.cpp does the same work from the same header, compiled on its own, without knowing about a.cpp: b.o holds a second, identical multiply.
- And a third time. c.o gets the third copy. nm -m marks all three as weak external: a definition the linker may meet in several object files and keep once.
- The linker keeps one. The linker meets three definitions of one symbol. For an ordinary function that would be a duplicate-symbol error; for weak definitions it keeps one and drops the others.
- One copy in the program. The program holds a single Matrix<double>::multiply and printed 14.0 29.0 50.0. The compiler did the work three times, and the linker hid two of the results.
The one-definition rule: The rule that a program holds exactly one definition of each ordinary function it uses. An inline function may be defined in every translation unit that uses it, as long as all the definitions are identical. More in “How C++ compiles: preprocess, compile and link” allows a template’s definition in every translation unit, as the lesson on headers explained, and weak definitions are how the compiler and linker make that work. They are also why the work is invisible: the compiler did it three times, and the linker hid two of the results.
Checkpoint
Check: The same instantiation in every file
Measure where the compile time goes before changing anything
The cost is measured, not guessed: Clang’s -ftime-trace writes a timeline of the compile, and GCC’s
-ftime-report prints a table of where time went. Both split a compile the same way, into the
front end (of a compiler): The part of a compiler that reads the source: it preprocesses and parses the code, checks it and instantiates templates. The back end then optimises the result and generates machine code., which parses the code and instantiates templates, and the
back end (of a compiler): The part of a compiler that optimises the code the front end produced and generates machine code for it, function by function, so its work grows with every instantiated function, most of all with optimisation on., which optimises and generates machine code.
Clang: -ftime-trace
-ftime-trace makes Clang write a JSON file in the Chrome tracing format, with one event for each piece of work longer
than 500 microseconds (-ftime-trace-granularity changes that), next to totals per kind of work. Viewers for that
format show it as a timeline; for numbers, a short script is enough:
"""Summarises a Clang -ftime-trace file: the whole compile, front end and back end, the time spent instantiating
templates, and the three templates whose instantiations took longest. A template's time includes the instantiations
it caused in turn, so these numbers overlap and do not add up."""
import collections
import json
import sys
events = json.load(open(sys.argv[1]))["traceEvents"]
total = {e["name"][6:]: e["dur"] / 1e6 for e in events if e.get("name", "").startswith("Total ")}
whole = total["ExecuteCompiler"]
print(f"compile {whole:.1f} s: front end {total['Frontend']:.1f} s, back end {total['Backend']:.1f} s")
instantiation = total.get("InstantiateFunction", 0) + total.get("InstantiateClass", 0)
print(f"template instantiation: {instantiation:.1f} s, {100 * instantiation / whole:.0f} % of the compile")
by_template = collections.Counter()
for e in events:
if e.get("name") in ("InstantiateFunction", "InstantiateClass") and e.get("ph") == "X":
by_template[e.get("args", {}).get("detail", "").split("<")[0]] += e["dur"] / 1e6
for name, seconds in by_template.most_common(3):
print(f" {seconds:5.2f} s {name}") from session import run
# Twenty instantiations, without and with optimisation; -ftime-trace writes the JSON file named after the =.
run("""
clang++ -std=c++23 -O0 -DCOPIES=20 -c many.cpp -o many.o -ftime-trace=many-O0.json
python3 trace_summary.py many-O0.json
clang++ -std=c++23 -O2 -DCOPIES=20 -c many.cpp -o many.o -ftime-trace=many-O2.json
python3 trace_summary.py many-O2.json
""") Output
$ clang++ -std=c++23 -O0 -DCOPIES=20 -c many.cpp -o many.o -ftime-trace=many-O0.json $ python3 trace_summary.py many-O0.json compile 9.4 s: front end 6.2 s, back end 3.1 s template instantiation: 5.0 s, 54 % of the compile 4.30 s std::vector 3.13 s run_all 1.54 s sort_and_count_unique $ clang++ -std=c++23 -O2 -DCOPIES=20 -c many.cpp -o many.o -ftime-trace=many-O2.json $ python3 trace_summary.py many-O2.json compile 19.4 s: front end 7.9 s, back end 11.4 s template instantiation: 4.7 s, 24 % of the compile 3.32 s run_all 2.53 s sort_and_count_unique 2.36 s std::vector
This output changes from run to run: compile times differ from one run to the next, and so can the order of templates whose times are close
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 trace_session.py
Without optimisation the front end takes most of the time, and instantiating templates is a large share of it. With
-O2 the back end takes the larger part, because every instantiated function is now optimised on its own, while the
time spent instantiating stays about the same. Fewer instantiations therefore speed up release builds as well as debug
builds. The list of templates shows where the instantiation time went: building std::vector and the sorting code for
each Item type. A template’s time includes the instantiations it caused, so the three numbers overlap.
GCC: -ftime-report
GCC has no -ftime-trace. Its -ftime-report prints a table of timings to standard error when the compile ends, one
row per part of the compiler; time_report.py keeps four rows:
from session import run
# GCC has no -ftime-trace; -ftime-report prints a table of timings to standard error when the compile ends.
run("""
g++-16 -std=c++23 -O2 -DCOPIES=20 -c many.cpp -o many.o -ftime-report 2>&1 | python3 time_report.py
""") Output
$ g++-16 -std=c++23 -O2 -DCOPIES=20 -c many.cpp -o many.o -ftime-report 2>&1 | python3 time_report.py phase parsing 0.87 s 8 % phase lang. deferred 1.71 s 15 % phase opt and generate 8.61 s 77 % template instantiation 1.52 s 14 %
This output changes from run to run: compile times differ from one run to the next
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 report_session.py
The picture matches Clang’s with -O2: optimising and generating code (“phase opt and generate”) takes most of the
compile, and template instantiation is a small slice of the total. Step through the parts of one compile, and how the
two reports name them:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Reading the code. The front end reads many.cpp and every header it includes, and checks the code. In -ftime-trace this is part of Total Frontend.
- Making the instantiations. Still in the front end, the compiler makes every instantiation the code needs: std::vector of each Item type, std::sort and its helpers, run_all, twenty times over. The trace records them as InstantiateClass and InstantiateFunction.
- Without optimisation. At -O0 the back end has little to do, so the front end takes most of the compile, and instantiating templates is a large share of it.
- With -O2. At -O2 every instantiated function is optimised on its own, so the back end takes the larger part. Fewer instantiations therefore also speed up release builds.
- Totals that overlap. trace_summary.py also ranks templates by instantiation time. A template's time includes the instantiations it caused, so the three times overlap and do not add up.
- GCC's own account. GCC has no -ftime-trace; -ftime-report prints a table per phase. At -O2, phase opt and generate takes most of the compile and template instantiation a small slice, the same picture as Clang.
Measure a build before changing it, and measure it again afterwards: in a real project, the slowest files are often the ones that include one particular header.
Checkpoint
Check: Measuring where compile time goes
extern template compiles a common instantiation once
C++11 added a way to say “this instantiation is compiled elsewhere” (paper N1987). It has two halves:
- An explicit instantiation definition in one source file,
template class Matrix<double>;, compiles every member ofMatrix<double>there: the explicit instantiation: A line such as template int largest(const std::vector<int>&); in the file that holds a template's body. That file then writes the code of the named instantiation, so other files can link to it without seeing the body. More in “C++ header files and the one-definition rule” of the headers lesson, for a whole class. - An explicit instantiation declaration: A line such as extern template class Matrix<double>; that tells a translation unit not to instantiate the non-inline members of that specialisation itself, because an explicit instantiation definition in another file of the program provides them. in the header,
extern template class Matrix<double>;, tells every other file not to instantiate those members itself but to call the ones the definition provides.
Who compiles Matrix<double>, without and with extern template
Text description of the diagram
The diagram has two panels.
Without extern template: a.cpp, b.cpp and c.cpp each instantiate and compile Matrix<double>::multiply. The linker meets three weak copies of it and keeps one.
With extern template: matrix.h contains the explicit instantiation declaration extern template class Matrix<double>;, so a.cpp, b.cpp and c.cpp only call the member functions. matrix.cpp contains the explicit instantiation definition template class Matrix<double>;, which compiles every member once. The linker uses that one copy in matrix.o.
The arrow between the panels says that the program is the same, with less compiling.
matrix.h above has the declaration behind #ifdef USE_EXTERN, and matrix.cpp holds the definition:
// The one file that instantiates Matrix<double>: an explicit instantiation definition.
#include "matrix.h"
template class Matrix<double>; """Builds the matrix program twice with each compiler and optimisation level: as it is, and with
"extern template class Matrix<double>;" in the header plus matrix.cpp. Prints the bytes of a.o + b.o + c.o, the bytes
of matrix.o, how many of a.o, b.o and c.o define Matrix<double>::multiply, and what the program printed."""
import os
import subprocess
env = dict(os.environ)
env["PATH"] = os.pathsep.join([env.get("PATH", ""), "/opt/homebrew/bin", "/usr/local/bin"])
os.chdir("matrix")
def build(compiler: str, level: str, use_extern: bool) -> str:
flags = [compiler, "-std=c++23", level] + (["-DUSE_EXTERN"] if use_extern else [])
users = ["a.o", "b.o", "c.o"]
for obj in users:
subprocess.run([*flags, "-c", obj[:-2] + ".cpp", "-o", obj], check=True, env=env)
objects = list(users)
if use_extern:
subprocess.run([*flags, "-c", "matrix.cpp", "-o", "matrix.o"], check=True, env=env)
objects.append("matrix.o")
subprocess.run([*flags, "main.cpp", *objects, "-o", "program"], check=True, env=env)
printed = subprocess.run(["./program"], capture_output=True, text=True, check=True).stdout.strip()
defining = 0
for obj in users:
symbols = subprocess.run(["nm", "-C", "--defined-only", obj], capture_output=True, text=True).stdout
defining += any("Matrix<double>::multiply" in line for line in symbols.splitlines())
users_bytes = sum(os.path.getsize(o) for o in users)
extra = f" + matrix.o {os.path.getsize('matrix.o'):>6,}" if use_extern else " " * 18
return f"a+b+c {users_bytes:>6,} bytes{extra}; multiply in {defining} of 3; printed {printed}"
for compiler in ("clang++", "g++-16"):
for level in ("-O0", "-O2"):
print(f"{compiler:<7} {level} plain: {build(compiler, level, False)}")
print(f"{compiler:<7} {level} extern: {build(compiler, level, True)}") Output
clang++ -O0 plain: a+b+c 59,352 bytes ; multiply in 3 of 3; printed 14.0 29.0 50.0 clang++ -O0 extern: a+b+c 19,104 bytes + matrix.o 19,312; multiply in 0 of 3; printed 14.0 29.0 50.0 clang++ -O2 plain: a+b+c 16,104 bytes ; multiply in 3 of 3; printed 14.0 29.0 50.0 clang++ -O2 extern: a+b+c 4,152 bytes + matrix.o 5,824; multiply in 0 of 3; printed 14.0 29.0 50.0 g++-16 -O0 plain: a+b+c 37,008 bytes ; multiply in 3 of 3; printed 14.0 29.0 50.0 g++-16 -O0 extern: a+b+c 9,384 bytes + matrix.o 12,016; multiply in 0 of 3; printed 14.0 29.0 50.0 g++-16 -O2 plain: a+b+c 5,064 bytes ; multiply in 0 of 3; printed 14.0 29.0 50.0 g++-16 -O2 extern: a+b+c 5,688 bytes + matrix.o 5,016; multiply in 0 of 3; printed 14.0 29.0 50.0
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 extern_session.py
Without optimisation the effect is large. With Clang the three files that use the matrix shrink from 59,352 to 19,104
bytes, and with GCC from 37,008 to 9,384, while matrix.o holds the single copy; none of the three defines
multiply any more. With -O2 and Clang, 16,104 bytes become 4,152. With -O2 and GCC the result goes the other way,
from 5,064 to 5,688 bytes plus matrix.o: GCC had inlined multiply into each caller, so there was no separate copy
to remove, and the declaration stopped that inlining. Every build printed the same numbers. Step through the eight
builds:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Clang without optimisation. Plain, a.o, b.o and c.o take 59,352 bytes and each defines multiply. With extern template they take 19,104, none defines it, and matrix.o, 19,312 bytes, holds the one copy.
- Clang with -O2. Optimised code is smaller, and the saving stays: 16,104 bytes become 4,152, plus 5,824 for matrix.o, and again no user file defines multiply.
- GCC without optimisation. GCC 16 tells the same story at -O0: 37,008 bytes shrink to 9,384, and matrix.o, 12,016 bytes, compiles every member once.
- GCC with -O2 had no copy. In the plain -O2 build of GCC 16, no user file defines multiply at all: GCC inlined it into each caller, so 5,064 bytes hold no separate copy to remove.
- And extern made it larger. With extern template the three files may no longer instantiate multiply to inline it; they call the copy in matrix.o instead, and grow to 5,688 bytes, plus 5,016 for matrix.o.
- The same program every time. All eight builds printed 14.0 29.0 50.0. extern template saves the most where nothing is inlined, and can cost size and speed where the compiler would have inlined: measure release builds too.
Three rules come with extern template:
- Inline members are still instantiated. Members defined inside the class body are inline, and the standard
lets the compiler instantiate them anyway so that it can inline them; only the non-inline members, such as
multiply, defined outside the class withoutinline, are left to the definition. That also meansextern templatecan make hot code slower, as GCC’s-O2build shows: measure release builds too. - The definition must exist. Every specialisation declared
externand used must be explicitly instantiated in some file of the program; otherwise the program is ill-formed, in practice with “undefined symbols” from the linker. - Only for your own types in the standard library. A program may explicitly instantiate a standard library class
template only when the arguments name a type the program defines:
extern template class std::vector<Order>;is allowed,std::vector<double>is not.
extern template pays off for a few specialisations, used in many files, with large members: Matrix<double>, a
Stats<int>, a parser for one character type. It is pointless for small inline helpers and harmful where inlining
matters.
Checkpoint
Check: extern template
Code that ignores the template parameters can be compiled once
Libraries split a template into the part users call and the part they should not touch, the second usually in a
namespace called detail. The split pays off when the detail part does not depend on the template parameters: then it
can be an ordinary function, declared in the header and defined once in a source file. This is called
hoisting: Moving the code of a template that does not depend on its template parameters into an ordinary function, or a base class that is not a template, so that it is compiled once instead of once per instantiation.. Buffer<T, N> reports a full buffer with a message that uses neither T nor N
except as numbers:
C header (buffer_inline.h) · hoist/buffer_inline.h
#pragma once
#include <array>
#include <cstddef>
#include <stdexcept>
#include <string>
// Every member is a template member: report_full is compiled again for each Buffer<T, N>.
template <typename T, std::size_t N>
class Buffer {
public:
void push(const T& value) {
if (used_ == N) report_full(used_, N);
items_[used_++] = value;
}
std::size_t size() const { return used_; }
private:
[[noreturn]] static void report_full(std::size_t used, std::size_t capacity) {
std::string message = "buffer full: ";
message += std::to_string(used);
message += " of ";
message += std::to_string(capacity);
message += " slots used; raise the capacity or drain the buffer first";
throw std::length_error(message);
}
std::array<T, N> items_{};
std::size_t used_ = 0;
}; C header (buffer_hoisted.h) · hoist/buffer_hoisted.h
#pragma once
#include <array>
#include <cstddef>
namespace detail {
// Does not depend on T or N, so it is an ordinary function, compiled once in buffer.cpp.
[[noreturn]] void report_full(std::size_t used, std::size_t capacity);
}
template <typename T, std::size_t N>
class Buffer {
public:
void push(const T& value) {
if (used_ == N) detail::report_full(used_, N);
items_[used_++] = value;
}
std::size_t size() const { return used_; }
private:
std::array<T, N> items_{};
std::size_t used_ = 0;
}; C++ (buffer.cpp) · hoist/buffer.cpp
#include "buffer_hoisted.h"
#include <stdexcept>
#include <string>
namespace detail {
void report_full(std::size_t used, std::size_t capacity) {
std::string message = "buffer full: ";
message += std::to_string(used);
message += " of ";
message += std::to_string(capacity);
message += " slots used; raise the capacity or drain the buffer first";
throw std::length_error(message);
}
} C++ (use.cpp) · hoist/use.cpp
#include <cstddef>
#include <utility>
#include BUFFER_HEADER
// Twenty different buffers: five element types times four capacities.
template <typename T, std::size_t N>
std::size_t fill() {
Buffer<T, N> b;
for (std::size_t i = 0; i < N; ++i) b.push(T{});
return b.size();
}
template <typename T>
std::size_t fill_all() { return fill<T, 4>() + fill<T, 8>() + fill<T, 16>() + fill<T, 32>(); }
std::size_t use_all() {
return fill_all<char>() + fill_all<short>() + fill_all<int>() + fill_all<long>() + fill_all<double>();
} """Compiles use.cpp, which makes twenty different Buffer<T, N>, once against each header, and buffer.cpp once.
Prints the size of each object file and how many copies of report_full each one defines."""
import os
import subprocess
env = dict(os.environ)
env["PATH"] = os.pathsep.join([env.get("PATH", ""), "/opt/homebrew/bin", "/usr/local/bin"])
os.chdir("hoist")
def copies(obj: str) -> int:
symbols = subprocess.run(["nm", "-C", "--defined-only", obj], capture_output=True, text=True).stdout
return sum("report_full" in line for line in symbols.splitlines())
for compiler in ("clang++", "g++-16"):
for level in ("-O0", "-O2"):
flags = [compiler, "-std=c++23", level, "-c"]
subprocess.run([*flags, '-DBUFFER_HEADER="buffer_inline.h"', "use.cpp", "-o", "inline.o"], check=True, env=env)
subprocess.run([*flags, '-DBUFFER_HEADER="buffer_hoisted.h"', "use.cpp", "-o", "hoisted.o"], check=True, env=env)
subprocess.run([*flags, "buffer.cpp", "-o", "buffer.o"], check=True, env=env)
print(f"{compiler:<7} {level}: in the template {os.path.getsize('inline.o'):>6,} bytes ({copies('inline.o')} copies);"
f" hoisted {os.path.getsize('hoisted.o'):>6,} bytes + buffer.o {os.path.getsize('buffer.o'):>6,} once") Output
clang++ -O0: in the template 39,744 bytes (20 copies); hoisted 20,112 bytes + buffer.o 6,080 once clang++ -O2: in the template 6,904 bytes (2 copies); hoisted 944 bytes + buffer.o 4,312 once g++-16 -O0: in the template 55,392 bytes (20 copies); hoisted 23,904 bytes + buffer.o 17,088 once g++-16 -O2: in the template 19,728 bytes (20 copies); hoisted 1,032 bytes + buffer.o 5,024 once
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 hoist_session.py
Which members can move? Ask each one whether its code depends on T or N:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- push needs T. push copies a T into std::array<T, N>, so its code differs for each element type and each capacity. It has to stay a template member, instantiated for every Buffer<T, N>.
- size is tiny. size() returns a std::size_t member. It is one line, defined in the class, so it is inlined wherever it is called; moving it would gain nothing.
- report_full uses only numbers. report_full builds a message from two numbers, how many slots are used and the capacity, and throws std::length_error. Neither T nor N appears in its code: push passes N in as a plain number.
- Hoisted into detail. buffer_hoisted.h declares detail::report_full(std::size_t, std::size_t), and buffer.cpp defines it once. push now calls it with used_ and N.
- Twenty copies become one. use.cpp makes twenty Buffer types. With the message built in the template, Clang -O0 gave use.o 20 copies of report_full in 39,744 bytes; hoisted, use.o took 20,112 bytes and buffer.o, 6,080, the one copy.
use.cpp creates twenty different buffers, five element types times four capacities. With the message built inside
the template, the object file defines up to twenty copies of report_full, one per Buffer<T, N>. Hoisted into
detail::report_full, it is compiled once in buffer.o, and use.o shrinks from 39,744 to 20,112 bytes with Clang
without optimisation, and from 19,728 to 1,032 bytes with GCC at -O2. The same idea scales: a container template can
keep its memory management in a non-template base class that works with sizes in bytes, and only the typed interface
on top is instantiated per type.
Smaller habits help as well: include only the headers a file needs, declare types instead of including their headers where a pointer or a reference is enough, and keep rarely used templates out of widely included headers. C++20 modules, covered in a later module, and precompiled headers attack the same cost from the other side, by parsing headers once.
Key idea
Every instantiation is compiled on its own, so the code a template does not need to repeat, the parts that ignore its parameters, belongs in ordinary functions compiled once.
Checkpoint
Check: Hoisting code out of templates
A long template error needs three facts
A template error is long because the compiler shows the instantiation backtrace: The notes under a template error that list the instantiations which led to it, from the failing line inside a template back to the line of your own code that asked for it. -ftemplate-backtrace-limit sets how many are shown. that led to it. Here a function copies a vector whose elements hold a std::unique_ptr, which
cannot be copied:
// A snapshot of orders, copied into a new vector. Order holds a std::unique_ptr, which cannot be copied.
#include <memory>
#include <string>
#include <vector>
struct Order {
std::string id;
std::unique_ptr<std::string> note;
};
std::vector<Order> snapshot(const std::vector<Order>& orders) {
return orders; // copies the vector, and with it every Order
}
int main() {
std::vector<Order> orders;
orders.push_back({"A-1", nullptr});
return static_cast<int>(snapshot(orders).size());
} from session import run
# Size of each error, then only the lines that name this file, and the cause.
run("""
clang++ -std=c++23 -c copy_owners.cpp 2>&1 | awk '/error:/ { e++ } END { print NR " lines, " e (e == 1 ? " error" : " errors") }'
clang++ -std=c++23 -c copy_owners.cpp 2>&1 | grep -E "^copy_owners.cpp"
clang++ -std=c++23 -c copy_owners.cpp 2>&1 | grep -o "call to implicitly-deleted copy constructor of '[A-Za-z]*'"
g++-16 -std=c++23 -c copy_owners.cpp 2>&1 | awk '/error:/ { e++ } END { print NR " lines, " e (e == 1 ? " error" : " errors") }'
g++-16 -std=c++23 -c copy_owners.cpp 2>&1 | grep -E "^copy_owners.cpp" | cut -c1-150
""") Output
$ clang++ -std=c++23 -c copy_owners.cpp 2>&1 | awk '/error:/ { e++ } END { print NR " lines, " e (e == 1 ? " error" : " errors") }'
30 lines, 1 error
$ clang++ -std=c++23 -c copy_owners.cpp 2>&1 | grep -E "^copy_owners.cpp"
copy_owners.cpp:12:12: note: in instantiation of member function 'std::vector<Order>::vector' requested here
$ clang++ -std=c++23 -c copy_owners.cpp 2>&1 | grep -o "call to implicitly-deleted copy constructor of '[A-Za-z]*'"
call to implicitly-deleted copy constructor of 'Order'
$ g++-16 -std=c++23 -c copy_owners.cpp 2>&1 | awk '/error:/ { e++ } END { print NR " lines, " e (e == 1 ? " error" : " errors") }'
106 lines, 3 errors
$ g++-16 -std=c++23 -c copy_owners.cpp 2>&1 | grep -E "^copy_owners.cpp" | cut -c1-150
copy_owners.cpp:12:12: required from here
copy_owners.cpp:6:8: note: 'Order::Order(const Order&)' is implicitly deleted because the default definition would be ill-formed:
copy_owners.cpp:6:8: error: use of deleted function 'std::unique_ptr<_Tp, _Dp>::unique_ptr(const std::unique_ptr<_Tp, _Dp>&) [with _Tp = std::__cxx11:
copy_owners.cpp:6:8: note: use '-fdiagnostics-all-candidates' to display considered candidates
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 errors_session.py
Clang printed 30 lines and GCC 16 printed 106, but three facts are enough to fix it:
- What failed. The first
error:line says it: constructing anOrderinside the library’s copy code,__construct_atin libc++ andconstruct_atin libstdc++. - Where in your code. Among the notes, find the first line that names your own file:
copy_owners.cpp:12:12, thereturn orders;that copies the vector. Clang marks it “requested here” and GCC “required from here”. - Why. Clang’s last note says “call to implicitly-deleted copy constructor of ‘Order’”. GCC goes one step further,
to line 6, the struct: its copy constructor is deleted because the
std::unique_ptrmember cannot be copied.
Step through the same three facts in the order you read them:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- A long message. Copying a std::vector<Order> fails because Order holds a std::unique_ptr. Clang printed 30 lines with one error and GCC 16 106 lines with three, mostly frames inside the library.
- What failed. The first error: line says what failed: constructing an Order inside the library's copy code, __construct_at in libc++ and construct_at in libstdc++. It names library code, not yours.
- Where in your code. Among the notes, the first line that names your own file is copy_owners.cpp:12:12, the return orders; that copies the vector. Clang marks it requested here, GCC required from here.
- Why. Clang's last note says call to implicitly-deleted copy constructor of 'Order'. GCC goes one step further, to line 6: the copy constructor is deleted because the std::unique_ptr member cannot be copied.
- Decide the fix. The error says what is impossible; the fix is a choice: return the ids instead of the orders, move the vector when the caller no longer needs it, or give Order a copy constructor that copies the note.
The fix belongs to the design, not the error: return the ids instead of the orders, move the vector when the caller no
longer needs it, or give Order a copy constructor that copies the note. A struct with a
move-only type: A type whose objects can be moved but not copied, because its copy constructor is deleted, such as std::unique_ptr. Its objects cannot come out of a std::initializer_list, whose elements can only be copied. More in “Brace initialisation and initializer_list traps” member is move-only itself. Two options shorten such errors:
-ftemplate-backtrace-limit=n sets how many instantiation notes appear (10 by default in both compilers), and on GCC
16 -fno-diagnostics-show-nesting prints the flat layout of earlier releases instead of the new tree.
GCC 16 explains failed type traits
A constraint written with a standard trait used to fail with only “evaluated to false”. GCC 16 now explains why the trait is false:
// A callback that must accept an int, given a lambda that takes a string.
#include <string>
#include <type_traits>
template <typename F>
requires std::is_invocable_v<F, int>
void call_with_int(F f) { f(1); }
int main() {
call_with_int([](const std::string& s) { return s.size(); });
} from session import run
run("""
clang++ -std=c++23 -c trait_constraint.cpp 2>&1 | grep -E "note: because"
g++-16 -std=c++23 -c trait_constraint.cpp 2>&1 | grep -E "is not invocable|no known conversion"
""") Output
$ clang++ -std=c++23 -c trait_constraint.cpp 2>&1 | grep -E "note: because"
trait_constraint.cpp:6:14: note: because 'std::is_invocable_v<(lambda at trait_constraint.cpp:10:19), int>' evaluated to false
$ g++-16 -std=c++23 -c trait_constraint.cpp 2>&1 | grep -E "is not invocable|no known conversion"
• 'main()::<lambda(const std::string&)>' is not invocable by 'int', because
• no known conversion for argument 1 from 'int' to 'const std::string&' {aka 'const std::__cxx11::basic_string<char>&'}
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 trait_session.py
Apple clang 21 reports that std::is_invocable_v<…, int> “evaluated to false” and stops there. GCC 16 adds the reason: the
lambda “is not invocable by ‘int’, because” there is “no known conversion for argument 1 from ‘int’ to ‘const
std::string&’”. Its release notes describe the change for traits such as is_constructible_v and is_invocable_v.
When an error on one compiler is unclear, building the same file with the other is often the quickest explanation.
Checkpoint
Check: Reading a long template error
Practise: cut the build
slowbuild is a small project: stats.h defines a Stats<T> class template, four source files each use
Stats<double> and Stats<int>, and main.cpp prints their reports. check.py builds every .cpp file in the
folder as a debug build, runs the program and reports what it measures:
C header · slowbuild/stats.h
// A small statistics class template, used with double and int by four source files.
#pragma once
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <stdexcept>
#include <string>
#include <vector>
template <typename T>
class Stats {
public:
void add(T value) { values_.push_back(value); }
std::size_t count() const { return values_.size(); }
double mean() const;
double stddev() const;
T percentile(double p) const;
std::string summary(const std::string& name) const;
private:
std::vector<T> values_;
};
template <typename T>
double Stats<T>::mean() const {
if (values_.empty()) throw std::domain_error("mean of no values");
double sum = 0;
for (T v : values_) sum += static_cast<double>(v);
return sum / static_cast<double>(values_.size());
}
template <typename T>
double Stats<T>::stddev() const {
double m = mean();
double squares = 0;
for (T v : values_) squares += (static_cast<double>(v) - m) * (static_cast<double>(v) - m);
return std::sqrt(squares / static_cast<double>(values_.size()));
}
template <typename T>
T Stats<T>::percentile(double p) const {
if (values_.empty()) throw std::domain_error("percentile of no values");
if (p < 0 || p > 100) throw std::out_of_range("percentile outside 0 to 100");
std::vector<T> sorted = values_;
std::sort(sorted.begin(), sorted.end());
auto rank = static_cast<std::size_t>(std::ceil(p / 100.0 * static_cast<double>(sorted.size())));
return sorted[rank == 0 ? 0 : rank - 1];
}
template <typename T>
std::string Stats<T>::summary(const std::string& name) const {
std::string out = name + ": " + std::to_string(count()) + " values, mean " + std::to_string(mean());
out += ", standard deviation " + std::to_string(stddev());
out += ", median " + std::to_string(percentile(50)) + ", 90th percentile " + std::to_string(percentile(90));
return out;
} C++ (latency.cpp) · slowbuild/latency.cpp
#include "stats.h"
std::string latency_report() {
Stats<double> ms;
for (double v : {12.5, 8.0, 15.25, 9.75, 30.0, 11.0}) ms.add(v);
Stats<int> hops;
for (int v : {3, 4, 3, 5, 4}) hops.add(v);
return ms.summary("latency in ms") + "\n" + hops.summary("network hops");
} C++ (main.cpp) · slowbuild/main.cpp
// Prints the four reports, one line per Stats object.
#include <cstdio>
#include <string>
std::string latency_report();
std::string sizes_report();
std::string retries_report();
std::string scores_report();
int main() {
for (const std::string& report : {latency_report(), sizes_report(), retries_report(), scores_report()})
std::puts(report.c_str());
} Python · slowbuild/check.py
"""The exercise's sample check. Builds every .cpp file of this folder with Clang without optimisation, as a debug
build does, links and runs the program, and reports what the exercise measures: how many object files compile each
member of Stats<double> and Stats<int>, and the total size of the object files."""
import glob
import os
import subprocess
env = dict(os.environ)
env["PATH"] = os.pathsep.join([env.get("PATH", ""), "/opt/homebrew/bin", "/usr/local/bin"])
EXPECTED_LINES = 8
GOAL_BYTES = 200_000
sources = sorted(glob.glob("*.cpp"))
objects = []
for src in sources:
obj = src[:-4] + ".o"
subprocess.run(["clang++", "-std=c++23", "-O0", "-c", src, "-o", obj], check=True, env=env)
objects.append(obj)
subprocess.run(["clang++", *objects, "-o", "report"], check=True, env=env)
output = subprocess.run(["./report"], capture_output=True, text=True, check=True).stdout
print(f"{len(sources)} source files built; the program printed {len(output.splitlines())} of {EXPECTED_LINES} lines")
compiled_in = {}
for obj in objects:
symbols = subprocess.run(["nm", "-C", "--defined-only", obj], capture_output=True, text=True, env=env).stdout
for t in ("double", "int"):
for member in ("mean", "stddev", "percentile", "summary"):
name = f"Stats<{t}>::{member}"
if any(name + "(" in line for line in symbols.splitlines()):
compiled_in.setdefault(name, []).append(obj)
for name, where in compiled_in.items():
print(f"{name}: compiled in {len(where)} object file{'' if len(where) == 1 else 's'} (goal: 1)")
total = sum(os.path.getsize(o) for o in objects)
print(f"object files: {total:,} bytes in total (goal: under {GOAL_BYTES:,})") from session import run
# The sample check, run on the project as given.
run("""
cd slowbuild && python3 check.py
""") Output
$ cd slowbuild && python3 check.py 5 source files built; the program printed 8 of 8 lines Stats<double>::mean: compiled in 4 object files (goal: 1) Stats<double>::stddev: compiled in 4 object files (goal: 1) Stats<double>::percentile: compiled in 4 object files (goal: 1) Stats<double>::summary: compiled in 4 object files (goal: 1) Stats<int>::mean: compiled in 4 object files (goal: 1) Stats<int>::stddev: compiled in 4 object files (goal: 1) Stats<int>::percentile: compiled in 4 object files (goal: 1) Stats<int>::summary: compiled in 4 object files (goal: 1) object files: 350,344 bytes in total (goal: under 200,000)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 exercise_session.py
Every member of both specialisations is compiled in four object files, and the objects add up to 350,344 bytes. Make
the check report one object file for every member and a total under 200,000 bytes, while the program still prints its
eight lines. Two changes do it: extern template declarations at the end of stats.h for Stats<double> and
Stats<int>, and a new stats.cpp with the two explicit instantiation definitions (the check builds every .cpp
file, so it picks the new one up). Then change -O0 to -O2 in check.py and see whether the saving holds, and add
a file that uses Stats<float>: which object file compiles that one?
Then test what you can predict before building: how sizes grow, what the linker keeps, which part of a compile grows
with optimisation, what extern template covers, and where an error starts. The session begins with whichever of
these ideas has cost you most answers, and explains every choice against the measurements above:
Practice that adapts to you
Practise this lesson
9 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · What does extern template class Matrix<double>; tell the compiler?
It is an explicit instantiation declaration: it says that Matrix<double> is instantiated in some other translation
unit, so this one must not instantiate its members itself and should call the definitions found elsewhere. The other
half is an explicit instantiation definition, template class Matrix<double>;, in exactly one source file, which
compiles every member there. Together they replace many identical instantiations, one per file that uses the type,
with one. A good answer adds the limits: members defined inside the class are inline and may still be instantiated
for inlining, a declared specialisation that nobody defines ends in an undefined-symbol link error, and the saving
should be measured, since it is largest in unoptimised builds and can even reverse when the compiler would have
inlined the code.
Follow-ups usually ask what the declaration covers and what it does not. Decide these two:
Checkpoint
Interview round: two follow-up questions
Two harder questions take build cost into a real service: a fourteen-minute build to cut, and an extern template change that broke a test build and slowed a benchmark:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Senior to staff
A service takes 14 minutes to build. Clang's -ftime-trace shows that in 300 source files about 40 % of the front-end time goes to instantiating one JSON library's templates. Plan the fix: what you measure, what you change first, and how you prove it helped.
-
Mid to senior
After you add extern template class Matrix<double>; to matrix.h and template class Matrix<double>; to matrix.cpp, debug builds shrink, but a new test program fails to link with an undefined Matrix<double>::multiply, and a benchmark built with -O2 got slower. Explain both.
Key takeaways
- Each distinct instantiation is compiled separately: 40 types that differ only in their names made the object file about 32 times larger with Clang, about 4,190 bytes for every extra type.
- Every file that uses an instantiation compiles it again, and weak definitions let the linker keep one copy, so
three files compiled three copies of
multiplythat became one in the program. - Measure before changing anything: Clang’s
-ftime-tracesplits a compile into front end, back end and template instantiation, GCC’s-ftime-reportprints its phases, and with-O2optimising each instantiation takes most of the time. extern templatein the header plus one explicit instantiation in a source file compiles a common specialisation once, as 59,352 bytes became 19,104 in the matrix example, but inline members are still instantiated, the definition must exist, and GCC’s-O2build got larger.- Code that does not depend on the template parameters belongs in ordinary functions in a
detailnamespace, compiled once: twenty copies of an error path became one. - A long template error is read in three steps, the first
error:line for what failed, the first line in your own file for where and the last notes for why, and GCC 16 also explains failed standard traits.
Terms in this lesson
- back end (of a compiler)
- The part of a compiler that optimises the code the front end produced and generates machine code for it, function by function, so its work grows with every instantiated function, most of all with optimisation on.
- explicit instantiation declaration
- A line such as extern template class Matrix<double>; that tells a translation unit not to instantiate the non-inline members of that specialisation itself, because an explicit instantiation definition in another file of the program provides them.
- front end (of a compiler)
- The part of a compiler that reads the source: it preprocesses and parses the code, checks it and instantiates templates. The back end then optimises the result and generates machine code.
- hoisting
- Moving the code of a template that does not depend on its template parameters into an ordinary function, or a base class that is not a template, so that it is compiled once instead of once per instantiation.
- instantiation backtrace
- The notes under a template error that list the instantiations which led to it, from the failing line inside a template back to the line of your own code that asked for it. -ftemplate-backtrace-limit sets how many are shown.
- weak definition
- A definition that the linker may meet in several object files and keep once, without a duplicate-symbol error. Compilers emit template instantiations and inline functions this way; nm -m shows them as weak external.
References
- Working Draft, Programming Languages: C++ (N4950), 13.9.2 Implicit instantiation, 13.9.3 Explicit instantiation, 16.4.5.2.1 Namespace std (ISO/IEC JTC1/SC22/WG21)
- Clang Compiler User's Manual (-ftime-trace, -ftime-trace-granularity, -ftemplate-backtrace-limit) (LLVM Project)
- GCC Developer Options (-ftime-report) (GNU Project)
- Options Controlling C++ Dialect (-ftemplate-backtrace-limit) (GNU Project)
- GCC 16 Release Series, Changes, New Features, and Fixes (GNU Project)
- N1987: Adding "extern template" (version 2) (ISO/IEC JTC1/SC22/WG21)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress