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 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.