Shader Coverage Counter Placement
This document describes where Slang inserts coverage counters for the
current shader coverage modes. It is about instrumentation placement,
not host binding, metadata querying, or report rendering. For the
overall implementation architecture, see
shader-coverage.md. For the host-facing
metadata and binding contract, see
shader-coverage-host-interface.md.
The examples below use a conceptual helper:
coverageAtomic("name"); // AtomicAdd(__slang_coverage[slot], 1)
The real compiler first emits marker IR ops during AST lowering. The
IR coverage pass later assigns numeric counter slots, synthesizes the
hidden __slang_coverage buffer, and rewrites each marker to an
atomic increment. Slot numbers are not part of the placement contract;
the metadata maps each source coverage entry to the slot chosen for
that compile.
Modes are independent. Enabling more than one mode adds the markers from each enabled mode into the same counter buffer.
Line Coverage: -trace-coverage
Line coverage inserts a counter before each executable statement that has a valid source location. Purely structural statement wrappers, such as blocks, statement sequences, and empty statements, are skipped.
This is statement coverage, not basic-block coverage. Multiple statements on the same source line can get multiple counters, and the LCOV conversion step aggregates those counters back to the source line.
Conceptually, this source:
void someFunction(uint N)
{
uint i = 0;
while (i < N)
{
x = y + z;
a = b + c;
d = e + f;
i++;
}
}
is instrumented like this under line coverage:
void someFunction(uint N)
{
coverageAtomic("line: uint i = 0");
uint i = 0;
coverageAtomic("line: while");
while (i < N)
{
coverageAtomic("line: x = y + z");
x = y + z;
coverageAtomic("line: a = b + c");
a = b + c;
coverageAtomic("line: d = e + f");
d = e + f;
coverageAtomic("line: i++");
i++;
}
}
The marker on the while statement counts execution reaching the loop
statement. It does not count each loop-condition evaluation. Per-arm
loop condition counts come from branch coverage.
Function Coverage: -trace-function-coverage
Function coverage inserts one counter at the entry of each
user-authored function body that is lowered to executable IR. It
counts function entry, not every basic block in the function.
This includes entry points, free functions, user-authored
constructors, instance/static methods, [ForceInline] functions, and
lambda bodies as they are represented by lowering. Compiler-synthesized
helpers that do not carry a user source location are not part of the
function-coverage contract.
Conceptually:
uint helper(uint x)
{
return x + 1;
}
void someFunction()
{
uint y = helper(41);
}
is instrumented like this:
uint helper(uint x)
{
coverageAtomic("function: helper");
return x + 1;
}
void someFunction()
{
coverageAtomic("function: someFunction");
uint y = helper(41);
}
The coverage metadata records both display and mangled function names
when available. Reports can aggregate multiple runtime counters that
attribute to the same source function, but hosts must use the metadata
rather than assuming counter-slot identity across compiles or shader
permutations. Lambda and constructor entries can have compiler-facing
display names such as $init or (); consumers should treat the
mangled name and source location as the stable identity for those
entries.
Branch Coverage: -trace-branch-coverage
Branch coverage inserts counters at selected control-flow arm entry points. It answers “which branch outcome was selected?” It does not insert counters before every statement inside the selected arm.
The current scope is source statement/control-flow coverage for:
if/else, for/while/do while loop-condition outcomes, and
switch case/default dispatch arms. Expression-level control flow,
including short-circuit && / || and ternary ?:, is intentionally
not instrumented by this mode yet. return, break, and continue
are represented through the branch arm that reaches them rather than
as separate branch entries.
If / Else
For an if with an else, Slang emits one counter for the true arm
and one for the false arm:
if (p)
{
x = y + z;
}
else
{
a = b + c;
}
Conceptually:
if (p)
{
coverageAtomic("branch: if true");
x = y + z;
}
else
{
coverageAtomic("branch: if false");
a = b + c;
}
For an if without an else, Slang still emits a false-arm counter
in an otherwise-empty false path:
if (p)
{
coverageAtomic("branch: if true");
x = y + z;
}
else
{
coverageAtomic("branch: if false");
}
This lets reports distinguish “the if was reached and the condition
was false” from “the if was never reached.”
While and For Loops
For while and for loops, Slang emits counters for the loop
condition’s true and false outcomes:
while (i < N)
{
x = y + z;
a = b + c;
d = e + f;
i++;
}
Conceptually:
while (true)
{
if (i < N)
{
coverageAtomic("branch: while condition true");
x = y + z;
a = b + c;
d = e + f;
i++;
}
else
{
coverageAtomic("branch: while condition false");
break;
}
}
For a loop that runs N times, the true-arm counter increments N
times and the false-arm counter increments once for the normal exit.
Statements inside the loop body do not receive branch counters unless
they contain their own branch constructs.
Do While Loops
For do while, the body executes before the condition is tested. The
branch counters are attached to the condition result after the body:
do
{
x = y + z;
i++;
} while (i < N);
Conceptually:
do
{
x = y + z;
i++;
if (i < N)
{
coverageAtomic("branch: do-while condition true");
continue;
}
else
{
coverageAtomic("branch: do-while condition false");
break;
}
} while (true);
For a do while loop that runs N iterations, the true-arm counter
increments N - 1 times when the loop continues, and the false-arm
counter increments once on exit.
Switch
For switch, counters are inserted on dispatch arms, not in the case
body after fallthrough. This preserves the meaning “which switch label
was selected by dispatch?”
For example:
switch (v)
{
case 0:
case 1:
x = 1;
break;
case 2:
x = 2;
// fall through
default:
x += 10;
break;
}
is conceptually lowered as:
switch (v)
{
case 0:
coverageAtomic("branch: switch case 0");
goto body_case_0_or_1;
case 1:
coverageAtomic("branch: switch case 1");
goto body_case_0_or_1;
case 2:
coverageAtomic("branch: switch case 2");
goto body_case_2;
default:
coverageAtomic("branch: switch default");
goto body_default;
}
body_case_0_or_1:
x = 1;
break;
body_case_2:
x = 2;
// fall through into default body, but the default arm counter does
// not increment because default was not selected by dispatch.
body_default:
x += 10;
break;
If a switch has no default, branch coverage creates a synthetic
no-match default arm so the report can distinguish “no case matched”
from “the switch was not reached.”
Combined Function and Branch Coverage
With function and branch coverage enabled, but line coverage disabled, the resulting instrumentation is closer to control-flow outcome coverage than statement coverage. Function entry and branch-arm entries receive counters; straight-line statements inside a selected arm do not.
For example:
void someFunction(uint N)
{
uint i = 0;
while (i < N)
{
x = y + z;
a = b + c;
d = e + f;
i++;
}
}
is conceptually:
void someFunction(uint N)
{
coverageAtomic("function: someFunction");
uint i = 0;
while (true)
{
if (i < N)
{
coverageAtomic("branch: while condition true");
x = y + z;
a = b + c;
d = e + f;
i++;
}
else
{
coverageAtomic("branch: while condition false");
break;
}
}
}
This is the mode to use when the goal is to reduce probe density while still answering whether functions and control-flow outcomes were exercised.
Interactions with Optimization and Variants
Coverage marker ops are emitted before most IR optimization and are rewritten to atomics after linking. Normal compiler transformations can clone, inline, specialize, or remove code before the final coverage pass sees it. The pass assigns slots only to surviving marker ops in the final linked-program IR.
Preprocessor variants and specialization-heavy builds are separate
compiles. Each compile gets its own counter buffer layout and metadata
mapping. Hosts and report converters should aggregate by the source
attribution fields in CoverageEntryInfo or .coverage-manifest.json,
not by assuming that counter slot K means the same source location
in two different compiles.
Future Region Coverage
The current modes all use one direct runtime counter per emitted source entry. Future source-region coverage may move toward a clang-style model where entries describe source ranges and some reported counts are derived from other counters. That should extend coverage metadata, but it should not change the basic rule that hidden resource binding is separate from source attribution.