Shader Coverage Design
This document describes the shader coverage implementation in Slang and the role of the main pieces in the pipeline.
Overview
Shader coverage has two separate jobs:
- insert execution counters into generated shader code
- let the host discover the counter buffer and map source coverage entries back to runtime counter slots
These are handled by two different mechanisms:
- IR-time synthesis of
RWStructuredBuffer<uint64_t> __slang_coverage(orRWStructuredBuffer<uint>under-trace-coverage-counter-width 32) in theslang-ir-coverage-instrumentpass - post-emit metadata exposed as
ICoverageTracingMetadatafor today’s source-entry attribution view andISyntheticResourceMetadatafor hidden resource binding
The first is about getting the buffer into the compiled shader. The second is about letting the host discover where the buffer lives through synthetic-resource metadata and attribute counter values back to source through coverage metadata.
Binding and attribution
Coverage instrumentation has two jobs that don’t reduce to one primitive:
-
Binding — getting the host to allocate a GPU buffer of the right size and put it where the shader expects. The synthesized
__slang_coveragebuffer is created inside the IR coverage pass rather than via an AST decl. This keeps it out of Slang’s public reflection surface (no synthetic decl leaking into IDE / language- server /IComponentType::getLayout()views) and produces exactly one buffer per linked program by construction. The binding the buffer ends up at is reported viaISyntheticResourceMetadata, and hosts use that information to declare the slot in their own pipeline-layout / root-signature / descriptor-set machinery. -
Attribution — turning counter values back into source locations. Reflection doesn’t carry “entry 7 uses counter slot 7 for
physics.slang:22”; that’s not what reflection is about. Reflection knows parameter names, types, and layouts. We need a side-channel that records the source-entry semantic intent for the current coverage mode.ICoverageTracingMetadata(and its on-disk twin.coverage-manifest.json) is that channel.
The two channels carry complementary data — binding info answers “where” and attribution answers “what.” A host needs both: without the binding it cannot allocate or bind the buffer; without the attribution it cannot interpret the counter values it reads back. The metadata is also intentionally a little richer than LCOV line coverage — some entries may not map to a real source file and line, and that fact is preserved in the metadata and JSON sidecar. The LCOV conversion step then applies gcov-style reporting rules by filtering those entries out of line-oriented output.
Terminology:
- Coverage manifest means the canonical JSON payload produced by
slang_writeCoverageManifestJson. - Coverage manifest sidecar means that JSON payload written to disk
next to a compiled artifact, normally as
<output>.coverage-manifest.json. -coverage-manifest-outputis the slangc option that overrides the sidecar path; it controls output location only, not instrumentation.
Line, function, branch, and future source-region coverage should grow
this attribution side of the design, not the binding side. In
particular, ISyntheticResourceMetadata should remain about hidden
resource binding, while richer source-based coverage metadata describes
how runtime counters map to source regions, functions, and branch
outcomes.
Buffer synthesis at IR-pass time
Coverage is a runtime-instrumentation concern. It has no language- visible behaviour, no surface in the source program, and no role in semantic checking — counter ops are inserted by the front-end to mark statement boundaries, and the buffer they target exists purely to receive atomic increments at dispatch.
Putting buffer synthesis in the IR pass keeps it at the layer that owns the rest of the feature:
- One buffer per linked program by construction. The pass runs on linked-program IR; there is no opportunity to produce more than one.
- Not in user-facing reflection. No synthetic decl appears in
IModule::getLayout(), IDE completion, or language-server views. Hosts that need to bind the buffer consultISyntheticResourceMetadata; hosts that need to interpret counter values consultICoverageTracingMetadata. - Self-contained pass.
slang-ir-coverage-instrument.cppowns buffer creation, layout assignment, target-policy selection, marker-op rewriting, and metadata recording. Disabling all coverage tracing options keeps the rest of the compiler unaware of the feature’s existence.
Hosts integrate by reading the hidden binding record from
ISyntheticResourceMetadata and declaring the slot in their own
pipeline-layout / root-signature / descriptor-set code, just like
they would for any user-declared resource — the metadata-driven
binding info is the canonical source for any compiler-synthesized
resource.
Pipeline architecture
Enabling one or more coverage tracing options runs three pipeline stages:
For a focused description of exactly where line, function, and branch
counters are inserted, with examples, see
shader-coverage-counter-placement.md.
- AST lowering (
source/slang/slang-lower-to-ir.cpp). Before source code is lowered to IR, the front-end emits semantic marker ops:-trace-coverageemitsIncrementCoverageCounterbefore each executable statement. Purely structural compound statements (BlockStmt,SeqStmt,EmptyStmt) are filtered to keep line counter density proportional to real execution events.-trace-function-coverageemitsIncrementFunctionCoverageCounterat user-authored function entry.-trace-branch-coverageemitsIncrementBranchCoverageCounterforif/elsearms and loop-condition true/false arms (for,while,do while) and sourceswitchcase/default dispatch arms, including the implicit no-match default path when nodefaultlabel exists. Expression-level short-circuit and ternary branches are not instrumented yet.- Marker ops are opaque void IR instructions. They do not reference a buffer at this point; the IR coverage pass rewrites them later.
- The marker source position rides on the standard per-instruction
sourceLocfield, so it survivesstripDebugInfoand every IR transform that preserves operands (inline, clone, link).
- IR pass (
source/slang/slang-ir-coverage-instrument.cpp). Runs afterlinkIRhas produced the linked-program IR, and beforecollectGlobalUniformParameterspacks globals into theGlobalParamsstruct. The pass:- Synthesizes the coverage buffer as a fresh
IRGlobalParamof typeRWStructuredBuffer<uint64_t>by default, orRWStructuredBuffer<uint>when the effective width is 32 — requested via-trace-coverage-counter-width 32, or capped automatically on Metal targets. See the “Counter element width” section below for the tradeoff. The buffer carries a target-aware layout:UnorderedAccessfor D3D-style targets,MetalBufferfor Metal,DescriptorTableSlotfor Khronos / SPIR-V / GLSL. For CPU and CUDA targets it additionally reportsUniformsize, which the global-uniform packaging pass uses to fold the buffer into the standardGlobalParamsstruct. WGSL and LLVM-emitted CPU targets currently skip instrumentation entirely (warning E45102) until their coverage atomic lowering paths are supported. - Reserves the implementation resource name
__slang_coveragewhenever any coverage tracing mode is enabled. User shaders that declare a global parameter with that name now fail code generation with error E45100; rename the user declaration or compile without coverage tracing. - Picks a (set, binding) — either honoring
-trace-coverage-binding <reg> <space>if supplied, or auto-allocating a non-conflicting location for the chosen resource kind. Hosts can also pass-trace-coverage-reserved-space <space>one or more times to mark descriptor sets that belong to the runtime pipeline layout even if the compiled shader does not reference them. Duplicate reserved spaces are idempotent. The option applies to Khronos descriptor-set targets; Metal, CPU, CUDA, and D3D ignore it with a warning. On Khronos / SPIR-V / GLSL descriptor-set targets, auto-allocation picks the descriptor set after the highest shader-visible or host-reserved set and binds coverage at binding 0 so the compiler does not mutate or fill holes in a user-owned set layout. D3D register-space reservation is left to a follow-up design (tracked at shader-slang/slang#11169) rather than freezing D3D allocation policy prematurely. - Extends the program-scope var layout to include the new
buffer as a struct field so
collectGlobalUniformParameterspacks it alongside user globals on targets that pack ordinary uniforms (CPU, CUDA). Graphics targets don’t pack and the extension is a no-op for them; the buffer flows through emit as a standaloneIRGlobalParam. - Assigns a counter slot to each coverage marker op (per-inst
UID, consecutive index in traversal order). Multiple line markers
on the same source line get distinct slots and are aggregated by
the LCOV exporter. Function and branch markers produce their own
CoverageEntryInfo::kindvalues and use the same counter buffer. - Rewrites each op as
AtomicAdd(__slang_coverage[slot], 1, Relaxed)in the default counting mode, or as a plain non-atomic store of1under-trace-coverage-boolean(hit/not-hit; seeCoverageCounterModein the roadmap section). - Records source entries on the artifact’s
ICoverageTracingMetadataand the synthesized buffer binding onISyntheticResourceMetadata. A source entry is unattributable when its marker op carried an invalidsourceLoc, or one whose humane-loc has no positive line — typically because the underlying statement came from code synthesis (autodiff reverse- mode, generic specialization, struct constructor synthesis) rather than user-authored source. The LCOV converter skips these slots so the report stays line-oriented; consumers readingICoverageTracingMetadatadirectly see them asentry.file == nullptr/entry.line == 0aftergetEntryInfo.
- Synthesizes the coverage buffer as a fresh
- Emission. Each backend already handles
kIROp_AtomicAddon the synthesized buffer. The exact spelling shifts with the chosen counter width (see “Counter element width” below); both 32-bit and 64-bit forms reduce to the backend’s native atomic-add intrinsic. (The corresponding host-side obligations — device features to enable per backend before dispatch — are documented inshader-coverage-host-interface.md.)- HLSL/DXIL →
InterlockedAdd(DXC SM6.6 has auint64_toverload; SM5.x / SM6.0–6.5 require uint32). - SPIR-V →
OpAtomicIAdd. The emitter auto-declaresOpCapability Int64Atomicswhenever the atomic op operates on a 64-bit integer pointer. - GLSL →
atomicAdd - Metal →
atomic_fetch_add_explicitonatomic_uintonly. MSL provides no 64-bit fetch-add (its_valid_fetch_add_typeconstraint rejectsatomic_ulong), so the pipeline automatically caps counting-mode counters to 32-bit for Metal targets before instrumentation; an explicitly requested 64-bit width is capped with warning W45115. Boolean mode is exempt from the cap — its plain non-atomic stores compile at either width. - WGSL / LLVM-emitted CPU targets → not reached today; coverage instrumentation is skipped before rewrite for these targets
- CUDA →
atomicAdd((unsigned long long*)..., 1ULL)for uint64 slots;atomicAdd((unsigned*)..., 1U)for uint32. - CPU source →
_slang_atomic_add_u64/_slang_atomic_add_u32prelude helpers (GCC/Clang__atomic_fetch_add, MSVC_InterlockedExchangeAdd64/_InterlockedExchangeAdd)
- HLSL/DXIL →
Counter element width
The synthesized __slang_coverage buffer’s element width is
controlled by -trace-coverage-counter-width <bits>:
64(default) —RWStructuredBuffer<uint64_t>. A single counter can absorb ~1.84e19 increments before wrapping; in practice this is unreachable within any process lifetime.32—RWStructuredBuffer<uint>. A single counter wraps silently at 2^32 (~4.3e9) increments. Use this only when the runtime driver does not support 64-bit shader atomic add.
The compiler cannot see the runtime driver and therefore cannot
auto-pick the width on the customer’s behalf — the choice is the
caller’s, informed by what the runtime driver supports. The chosen
width is recorded on CoverageBufferInfo::elementByteWidth and the
manifest so hosts never have to guess it back.
Invalid widths are rejected loudly on both entry paths (CLI:
E45113; API: E45114) rather than silently coerced to uint32 —
silent coercion would hide a caller’s misconfiguration, most
realistically forwarding the bit width to the byte-width API option
without dividing by 8.
The host-facing side of this contract — per-backend device
requirements for the 64-bit default, the cases where 32-bit is the
only executable choice, the exact accepted option values, and the
readback stride rules — is documented in
shader-coverage-host-interface.md
under “Counter element width and device requirements”.
Coverage marker ops are side-effectful by default in DCE analysis, so they survive optimizations untouched until the coverage pass rewrites them.
Where each stage lives
| Path | Role |
|---|---|
source/slang/slang-ir-coverage-instrument.{h,cpp} |
IR pass — synthesizes the buffer, extends program-scope layout, rewrites marker ops, writes metadata |
source/slang/slang-ir-insts.lua |
Declares the line/function/branch coverage marker IR ops |
source/slang/slang-lower-to-ir.cpp |
Emits marker ops during AST lowering; filters structural statements for line coverage |
source/slang/slang-emit.cpp |
Integrates the pass into the pipeline + allocates metadata + plumbs -trace-coverage-binding / -trace-coverage-reserved-space |
source/slang/slang-options.cpp |
Registers the coverage tracing CLI flags |
source/slang/slang-end-to-end-request.cpp |
Writes the .coverage-manifest.json sidecar from slangc |
include/slang.h |
slang::ICoverageTracingMetadata public interface |
source/compiler-core/slang-artifact-associated-impl.{h,cpp} |
ArtifactPostEmitMetadata implements the interface |
prelude/slang-cpp-prelude.h |
CPU-target atomic helpers (_slang_atomic_add_u32/u64) |
source/slang/slang-emit-cpp.cpp |
CPU emitter’s kIROp_AtomicAdd handling |
source/slang/slang-emit-metal.cpp |
Metal emitter’s atomic handling (atomic_fetch_add_explicit, 32-bit atomic_uint only) |
tests/language-feature/coverage/ |
End-to-end tests |
tools/slang-unit-test/unit-test-coverage-metal-runtime.cpp |
GPU execution test — validates the Metal binding contract and exact line/function/branch counter values on a real dispatch |
examples/shader-coverage-image-pipeline/, examples/shader-coverage-bvh-traversal/ |
Runnable raw-Vulkan reference hosts — compile with coverage, bind via metadata, dispatch, read back, render LCOV reports |
tools/slang-unit-test/unit-test-descriptor-set-space-offset-reflection.cpp |
Reflection unit test for DescriptorSetInfo::spaceOffset (regression-watch for the non-zero space mis-binding bug) |
Two reporting channels
Coverage metadata is exposed through two channels because two
distinct audiences need it in two distinct shapes. The metadata API
is the canonical form; the sidecar is its on-disk serialization,
generated from the API at slangc’s request — there’s no second
producer that could drift out of sync.
In-process consumers — ICoverageTracingMetadata
In-process consumers are programs that compile and dispatch the
shader in the same process: test runners, in-engine compile
pipelines, slangpy bindings, JIT compilers. They hold the compiled
artifact in memory, the canonical metadata is right there, and they
need typed access without going through serialization, schema
versioning, or file I/O. The artifact’s IMetadata exposes the two
query interfaces needed by the host: ICoverageTracingMetadata
returns the runtime counter count plus source coverage entries, while
ISyntheticResourceMetadata returns the chosen hidden-resource
binding.
A companion free function — slang_writeCoverageManifestJson —
serializes an ICoverageTracingMetadata to the canonical
.coverage-manifest.json shape on demand. When the same metadata object
also supports ISyntheticResourceMetadata (the normal Slang artifact
case), the serializer includes the buffer binding fields as well:
space / binding for descriptor-backed targets and
uniform_offset / uniform_stride for CPU/CUDA uniform-marshaling
targets when those locations are available.
Hosts that want the sidecar bytes without going through disk (to feed
the Python LCOV converter or a network channel) call it directly;
hosts that consume the typed accessors don’t need it.
Today the in-tree consumers are:
slangcitself — its_maybeWriteCoverageManifestcallsslang_writeCoverageManifestJsonand writes the bytes to disk, either as<output>.coverage-manifest.jsonfor normal file outputs or at the explicit-coverage-manifest-output <path>. This is what makes the metadata API + manifest shape one contract, not two:slangcis its own first consumer of the public serializer.- The example programs (
examples/shader-coverage-image-pipeline,examples/shader-coverage-bvh-traversal) — reference end-to-end host integrations that compile in-process, bind the counter buffer fromISyntheticResourceMetadata, dispatch on raw Vulkan, and serialize the manifest viaslang_writeCoverageManifestJsonfor the LCOV conversion step.
The intended longer-term audience is in-process integrators that don’t exist in-tree today: slangpy bindings, in-engine compile pipelines, custom test runners that JIT-compile shaders. They’re the audience the API shape is designed for.
Cross-process / offline consumers — <output>.coverage-manifest.json
The other audience runs later, possibly on a different machine,
without Slang linked: a runtime dispatching the precompiled shader;
a CI script processing test output; a Python tool converting to
LCOV; a custom dashboard. By the time these consumers run, the
slangc invocation that produced the artifact has long since
exited, so the in-process API is unreachable. They need the
metadata frozen to disk in a language-agnostic format. The
<output>.coverage-manifest.json sidecar is that disk form.
This is the pre-built-shader pattern: shaders are compiled offline,
binaries plus sidecars ship with the game engine, DCC tool, or
vendor runtime, and a later dispatch on the end user’s machine
attributes counters back to source using the sidecar alone — no
Slang toolchain needed on the target. The same companion-file role
is played by .pdb files for native Windows binaries and .dSYM
bundles for Mach-O: build-time information preserved for
runtime/post-mortem use.
Today the in-tree consumer is:
tools/shader-coverage/slang-coverage-to-lcov.py— Python converter. Reads the sidecar plus a counter-buffer snapshot, emits LCOV consumable bygenhtml,reportgenerator, VS Code Coverage Gutters, Codecov.
A helper library for hosts that want to accumulate hits and emit reports programmatically without linking the Slang compiler is a possible follow-up.
The intended longer-term audience is external integrators and CI pipelines that need to attribute coverage values without linking against Slang: Codecov adapters, in-engine coverage exporters, language bindings that don’t ship with the compiler. The file format is shaped for them.
Rule of thumb
slangcCLI workflow, consumer runs later → sidecar- compile-API workflow, consumer runs in-process → metadata API
Either channel alone would force the wrong cost on one audience.
Sidecar-only would make in-process integrators round-trip through
disk and a JSON parser to get data they could otherwise get from a
typed virtual call — and slangc itself would become a consumer of
its own output file rather than a producer-via-API. API-only would
force every offline workflow (Python tools, CI scripts, custom
dashboards, any non-Slang-linked tool) to embed a Slang process to
query the metadata, defeating the point of shipping precompiled
shaders without the toolchain.
Host integration
The practical workflow walkthroughs for both channels — the
in-process compile-API path and the precompiled-with-sidecar path,
with diagrams and code — live in
tools/shader-coverage/README.md
under “Integration workflows”. The per-target binding mechanics
(descriptor sets, [[buffer(N)]], uniform marshaling) live in
shader-coverage-host-interface.md.
Roadmap
The current implementation provides line, function-entry, and initial branch-arm coverage instrumentation end-to-end across supported Slang backends. WGSL is intentionally skipped by the current coverage instrumentation path. Directions scoped for follow-up work, grouped by category. Per-test attribution and source-region coverage are the highest-leverage near-term picks.
ICoverageTracingMetadata is intentionally source-entry based rather
than LCOV-line-only. Today line, function, and branch coverage emit one
entry per counter, with counterMode == Count and counterIndex
pointing at the runtime counter slot. This is an implementation detail
of the current producers, not a permanent metadata contract. The same
object already has room for future lower-density region entries:
source ranges, function names, and branch site/arm ids live on
CoverageEntryInfo, while getCounterCount() continues to describe
the size of the runtime counter buffer. Region entries may later use
direct counters, shared counters, derived counter expressions, or
counterless metadata entries represented through tail-extended fields
or a derived metadata interface. CoverageCounterMode currently defines Count (exact execution counts
via atomic add, the default) and Boolean (hit/not-hit via a plain
non-atomic store of 1, selected by -trace-coverage-boolean).
Warp/group-aggregated and other modes may be appended in future
extensions. LCOV remains a
compatibility export (DA:, FN/FNDA:, BRDA:), not the only
internal coverage model.
Attribution expansion
Line, function (FN: / FNDA:), and branch (BRDA:) coverage have
shipped; the remaining attribution work extends how counters group,
not what gets counted:
- Per-test attribution (
TN:groupings). Group accumulated counters by an optional test name. Turns coverage from a flat aggregate into a test-quality signal — often the highest-leverage next feature for teams running coverage in CI. - Specialization-aware metadata. Per-specialization counter attribution for codebases with heavy generic / template use — matters most for neural-slang.
Cross-repo follow-ups
Tracked outside this repository:
- slang-rhi synthetic-resource binding. Lets hosts that dispatch
via slang-rhi bind the hidden coverage buffer through
bindSyntheticResource(...)instead of their own pipeline-layout code. shader-slang/slang-rhi#739. - slang-rhi Metal backend binding quirk. The slang-rhi Metal backend returns garbage counter values; direct Metal hosts are unaffected. shader-slang/slang-rhi#724.
Possible extensions
Directions that have come up but have no design or commitment behind them; none gates any of the work above.
- Hot-line profiling. Sample the same counters over time for per-line heatmaps.
- Per-lane attribution. Divergence-oriented reporting would need dedicated metadata and storage semantics when implemented; it is not part of the current source-coverage API.
- Native vendor-tool exports. Nsight, RenderDoc, PIX — so graphics engineers see coverage in tools they already use.
- Codecov / Coveralls upload shim. One-liner CI recipe for pushing per-PR coverage to SaaS dashboards.
- Python bindings for
ICoverageTracingMetadata+ pytest plugin for slangpy users (unified Python + shader coverage in one report).