Shader Coverage Counter Placement

Making it easier to work with shaders


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 markers to an atomic increment — or, under -trace-coverage-boolean, to a plain non-atomic store of 1 (hit/not-hit). Placement is identical across the two rewrite forms; everything in this document applies to both. Slot numbers are not part of the placement contract; the metadata maps each source coverage entry to the slot chosen for that compile.

Markers and counters are not one-to-one. Line markers that provably execute together are coalesced onto a single counter and a single runtime probe — see Counter coalescing below. Every marker still produces its own source coverage entry, so this changes how many probes the shader carries, not what gets reported.

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.

Counter coalescing

Emitted shader size scales with the number of probe sequences, not with counter width: every probe expands to index arithmetic, an address computation, and an atomic. Line coverage therefore coalesces markers that provably execute together onto one counter and one probe.

The rule is simple because the IR is already in basic-block form when the coverage pass runs: markers in the same basic block, with nothing between them that can abandon the invocation, all execute exactly the same number of times. A basic block has one entry and one exit, so reaching any instruction in it means reaching all of them.

The someFunction example above therefore emits two counters for its six markers — one for the entry block, one for the loop body:

void someFunction(uint N)
{
    // `uint i = 0` and `while` are in the same block: one probe covers
    // both, and it sits at the last of the two.
    uint i = 0;
    coverageAtomic("region: i = 0, while");
    while (i < N)
    {
        // The four body statements are one straight-line region.
        buf[0] = buf[1] + buf[2];
        buf[3] = buf[4] + buf[5];
        buf[6] = buf[7] + buf[8];
        i++;
        coverageAtomic("region: loop body");
    }
}

Because the block boundary is the region boundary, every structural split falls out for free: if / else arms, switch cases, loop bodies versus loop exits, early return / break / continue, and short-circuit operands all begin new blocks during lowering, so none of them can be coalesced across.

The probe sits at the last marker of a region rather than the first. Reaching it proves every earlier marker in the region also executed; placing it first would over-report when a region is entered but abandoned partway.

Two cases break the “same block means same count” property, and both split the region:

  • An instruction that can abandon the invocation — discard, an abort, or a call to a function that transitively contains one, or that never returns at all. The analysis is a memoized depth-first walk of the call graph, not an iterated fixpoint: a re-entered function is reported as possibly not returning, which breaks cycles conservatively without letting an optimistic answer escape into another function’s cached result.
  • A call whose target cannot be resolved statically, such as an interface method dispatched through a witness table. Coverage runs before specialization, so these are common; the pass assumes the worst and splits, since an extra probe costs emitted code while a missed split would cost correctness.

Function and branch markers always take a dedicated counter. They are already one probe per function or per arm, and their counts carry per-site meaning that sharing would destroy.

Reported results are unaffected. Every source entry survives with its own file/line attribution, and hosts accumulate per entry, so several entries reading one slot produce exactly the LCOV records that dedicated slots did. What changes is counterCount, which drops markedly below the entry count — roughly half on the bundled demos.

Known gap: any core-module intrinsic that abandons the invocation lowers to a GenericAsm terminator like every other intrinsic, and the exit analysis treats GenericAsm as a normal exit — so the gap is general to any present or future abandoning intrinsic modeled that way, not specific to a fixed list. The ray-tracing hit terminators IgnoreHit and AcceptHitAndEndSearch are the concrete examples today: they end the invocation at the target level, but Slang’s IR models them as ordinary void functions that return normally, so the analysis cannot currently see them.

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. Generic specializations share one source-level function entry: specializing helper<T> for three different Ts still produces a single helper entry in the metadata, so reports stay source-oriented instead of fanning out per specialization. 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: if/else, for/while/do while loop-condition outcomes, switch case/default dispatch arms, and the expression-level branches of a scalar ?: and a short-circuiting && / ||. 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.”

Ternary ?:

A ?: with a scalar condition evaluates only the operand its condition selects, so it lowers to the same two-way branch as an if with an else. Slang emits one counter per arm, attributed to the ? token:

uint v = (t > 1u) ? a : b;

Conceptually:

uint v;
if (t > 1u)
{
    coverageAtomic("branch: ?: true");
    v = a;
}
else
{
    coverageAtomic("branch: ?: false");
    v = b;
}

A ?: with a vector condition selects per element and evaluates both operands (this form is deprecated in favor of select), so it does not branch and gets no counters.

Short-Circuit && and ||

&& and || evaluate their right operand only when the left operand does not already decide the result. That decision is the branch, so Slang emits a true-arm and a false-arm counter for the value of the left operand, attributed to the operator token:

bool r = p && q;

Conceptually:

bool r;
if (p)
{
    coverageAtomic("branch: && left operand true"); // q is evaluated
    r = q;
}
else
{
    coverageAtomic("branch: && left operand false"); // short-circuited
    r = false;
}

For || the arms are the same, true and false of the left operand, but their roles swap: the true arm short-circuits to true and the false arm evaluates the right operand.

Each operator is its own site. a && b || c parses as (a && b) || c and gets two sites: the && site records a, and the || site records the value of a && b. The last right operand of a chain is not a branch; it becomes the value of the whole expression. When the expression is the condition of an if or a loop, that statement’s own site records the final outcome, so every operand’s outcome is recoverable:

if (a && b) // `&&` site: a true/false; `if` site: (a && b) true/false

Here b was true as often as the if true arm ran, and false as often as the && true arm ran minus that. When the expression is used as a value instead, as in bool r = a && b;, the outcome of b is not recorded; branch coverage does not add control flow just to observe it.

The if in that example attributes its own site to its condition expression, which is located at the && token, so the two sites share a line and column. Reports tell sites apart by their branch-site ID, not by position.

Under -disable-short-circuit, && and || evaluate both operands without branching and get no counters. ?: is unaffected by that option.

Expressions that are lowered outside a function body, such as the initializer of a global (static bool g = a && b;), are not instrumented.

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.

Automatic differentiation runs after the coverage pass, so the forward and backward derivatives generated from a differentiable function carry that function’s counter increments as non-differentiable side effects. They follow autodiff’s rule for side effects: how many times a side effect runs inside a derivative is not guaranteed. A backward derivative can skip primal code whose results it does not need, counters included. Counts gathered while derivatives run therefore do not measure how often the differentiable function’s source executed.

Future Region Coverage

Line coverage already shares one counter across the source entries of a straight-line region (see Counter coalescing), but each entry still names a concrete counter and reports a real count. Future source-region coverage may move further toward a clang-style model where entries describe source ranges and some reported counts are derived arithmetically from other counters rather than read directly. That would extend coverage metadata — most likely by using kInvalidCoverageCounterIndex for derived entries — but it should not change the basic rule that hidden resource binding is separate from source attribution.