Skip to content

Template- and Header-Heavy Libraries

What a template library exports

A class template exports nothing. Buffer<T> is a recipe; a symbol exists only when someone instantiates it, and an implicit instantiation is compiled into the consumer's binary, where the library never sees it. The only symbols the library owns are its explicit instantiations — the template class Buffer<int>; lines its build emits — and those carry the same hazard every other symbol does, plus one of their own: adding a field keeps the mangled name identical while sizeof(Buffer<int>) grows, and a consumer that stack-allocated the old size is smashed with no header-level signal. Part 4 § Templates and inline is the mechanism; this page is what to do about a library that is mostly this.

The contract is the instantiation matrix

For a template library the public contract is not a header and not an export table. It is the matrix of explicit instantiations the build system enumerates — every (Float, Method, Task) triple the release promises to have compiled — and a comparison that does not know the matrix cannot tell a promised instantiation from an incidental one. The instantiation manifest states it directly:

version: 1
provides:
  - template: oneapi::dal::train_ops
    instantiations:
      - {Float: float,  Method: "method::dense",  Task: "task::train"}
      - {Float: float,  Method: "method::sparse", Task: "task::train"}
      - {Float: double, Method: "method::dense",  Task: "task::train"}
      - {Float: double, Method: "method::sparse", Task: "task::train"}
    library: libonedal_core.so.1
    optional_provider: false

Dozens of entries describe thousands of mangled symbols, and each one the new release stops exporting is a broken promise rather than one more removal in a long list. Two cases show the two ways the matrix breaks: the instantiation is still exported but its layout changed (case17), and the instantiation is simply missing from the shipped binary because the build dropped a line (case79). Parameter order in each entry must match the template's own; the shapes, matching rules and the bootstrap script that produces a first manifest from a release are owned by Multi-binary § --instantiation-manifest, and the manifest is applied on a product comparison (Products, Not Libraries):

abicheck compare release-1.0/ release-2.0/ -H include/ --instantiation-manifest manifest.yaml

What the header side can and cannot see

The header tier sees a template through the compile context it was given. The default castxml backend emits template instantiations only, never the uninstantiated pattern; the clang backend records the pattern as well (Header Backend Capabilities). Neither backend detects a change to an uninstantiated template's signature: nothing was instantiated, so nothing was compared. That is the source tier's job — L4 replays the declaration itself (case122, the same case How a Break Shows Up cites for "the source changed, no binary did"). Three neighbours are visible at the header tier and worth knowing by name: a default template argument changed, which silently changes what every consumer instantiates (case87); an internal template's signature changed while public code depends on it (case85); and a detail:: templated base class whose layout change propagates into every public derived class (case77). Which tier each break needs is defined by Evidence & Detectability.

The cost cliff

The one cost cliff in the evidence ladder is at the source tier, and it tracks template depth: a single template-heavy translation unit's AST dump can reach gigabytes, so a full-target replay of a large tree costs hours and memory, while a replay seeded by the changed translation units costs minutes. The replay caps its worker count by available memory, including a container's cgroup limit, rather than by CPU count alone. Ask before you spend:

--since is not wired to --no-baseline's one-sided audit path yet (it exits 64 there today) — changed-path seeding is inherently a two-sided concept anyway (narrowing to what a revision range touched relative to a baseline), so this is a two-sided compare:

abicheck compare old.so new.so -H include/ --sources . --depth source \
  --since origin/main

That seeds the L4/L5 replay to the changed translation units instead of the full target, which is where the minutes-versus-hours saving above actually comes from. --dry-run's own cost preview does not reflect this seeding yet — verified live: adding --dry-run to the command above still prints source scope: target on each side (compare has no PR change seed) and leaves the "Cost preview" TU counts unchanged, because --since's changed-path resolution runs after --dry-run has already emitted and exited (a documented gap: known-gaps.md, "compare --dry-run's cost preview does not reflect --since's changed-path seeding" entry). So a dry run here only ever previews the unseeded upper-bound cost; run the command above for real (without --dry-run) to get the actual seeded, cheaper replay. In CI, --budget fails loudly on overflow rather than shrinking scope, so a scan that finished is a scan that did what it claims. Numbers, knobs and the reasoning behind the memory cap are owned by Performance § L4 source-replay performance; the moments to run the seeded versus the unseeded scan by Where in the Pipeline.

Multi-TU surfaces and comparability

A template library's public surface is rarely one header. --dump-manifest describes the translation units that together form one side's snapshot — each with its own compile context — and is side-scoped, so old and new can carry different manifests. That is also where the comparability gate earns its keep: two snapshots extracted under different manifests, flags or scope settings differ in thousands of instantiations that are not contract changes, and the gate refuses the pair with no verdict rather than producing a page of phantom additions and removals. "Not comparable" is the better answer; rebuild one side under the other's profile, or pass --diagnostic-comparison to see the tentative diff with its assurance stamped as none. The fingerprint and its carve-outs are owned by Build Profile Comparability; the manifest flag by the CLI Reference.

Header-only libraries

A header-only library is the limit case: every function is inline or a template, the export table is empty, and the whole contract is the inline bodies and the types they name. The evidence question is then the header graph rather than any binary — a public struct that gains a field of a private type is a break with no symbol anywhere (case191). Static & Header-Only Contracts is the shape without a binary.


Ladder:Products, Not Libraries · Step 8 · At Scale · How System Libraries Stay Compatible