ONNX Runtime #32048 — Why the is_channels_last Cache Bug Required Two Live WebGPU Sessions
ONNX Runtime WebGPU Pipeline Cache, Part 3 of 3 — Layout-dependent bias indexing, ref-counted context lifetime, and a regression that had to preserve the cache it was testing
The first two articles in this series examined two ways a WebGPU pipeline key could describe less state than the generated shader contained.
Part 1 examined a missing field.
Generated WGSL
→ contained the activation
Old cache key
→ omitted the activationA ReLU shader and a Sigmoid shader could therefore receive the same pipeline identity.
Part 2 examined a lossy representation.
Generated WGSL
→ contained distinct float constants
Old cache serializer
→ rounded them into the same stringThe values:
1000015.0
1000025.0produced different WGSL constants but the same old six-significant-digit cache text.
PR #32048 fixed one more identity boundary.
MatMulNaiveProgram generated different bias-indexing code depending on:
is_channels_lastThe generated WGSL used one of two expressions.
Channels-last path
bias[col]Channels-first path
bias[row + i]The layouts could use:
the same program name,
the same tensor element types,
the same matrix dimensions,
the same packing widths,
and the same dispatch structure.
Before the patch, is_channels_last was not included in the custom pipeline-cache hint.
The two layout variants could therefore receive the same key even though they indexed the bias tensor differently.
That alone was enough to create a wrong-shader reuse bug.
But reproducing it required more than two ordinary session runs.
ONNX Runtime’s WebGPU context owns the program manager and its compiled-pipeline cache.
Contexts are stored in a process-level factory map keyed by context_id and managed through reference counting.
Under the default test configuration:
Session A created
→ context refcount becomes 1Session A destroyed
→ refcount becomes 0
→ context is erased
→ program cache disappearsSession B created afterward
→ new context
→ new empty cacheIf Session A and Session B were tested one after another with no overlapping lifetime, each session could compile the correct shader into its own fresh cache.
Both tests would pass.
The incomplete key would remain.
The regression therefore kept both sessions alive at the same time.
Session A
→ channels last
→ holds shared WebGPU contextSession B
→ channels first
→ receives the same context ID
→ shares the same program cacheThe two sessions did not need to execute in parallel.
Their lifetimes only needed to overlap.
That was enough for the first session’s pipeline to remain available when the second requested what the old key described as the same program.
A regression for shared cached state must preserve the ownership and lifetime that make the state shared.
The layout flag changed executable shader text
MatMulNaiveProgram can optionally add a bias before applying the activation and writing the output.
Its shader generator constructs the bias expression according to:
is_channels_last_The relevant logic is effectively:
if (has_bias_) {
process_bias =
is_channels_last_
? "value += bias[col];"
: "value += bias[row + i];";
}The exact emitted text includes the generated WebGPU value types, but the indexing distinction is the important part.
Channels-last layout
The output-channel position is represented by:
colThe shader reads:
bias[col]Channels-first layout
The relevant output position is represented by:
row + iThe shader reads:
bias[row + i]These are not two runtime values supplied to one shared indexing expression.
They are two different expressions chosen while the WGSL string is generated.
is_channels_last = true
↓
compile shader containing `bias[col]`is_channels_last = false
↓
compile shader containing `bias[row + i]`The flag is therefore compilation state.
Without bias, the layout difference would not become observable here
The affected code constructs process_bias only when:
has_bias = trueIf the program has no bias input:
process_bias
→ emptyThen the generated shader does not contain either:
bias[col]or:
bias[row + i]For this particular code-generation branch:
different `is_channels_last`
+
no bias
→ may produce no relevant shader differenceA regression containing no bias could therefore appear to cover both layouts while never selecting the code path whose identity was missing.
PR #32048’s layout test deliberately supplies a bias tensor.
It also uses distinct bias values:
100
200
300so the two indexing formulas do not accidentally read interchangeable values.
Wrong bias index
→ visibly different outputThe bias was not incidental test data.
It made the layout-dependent WGSL semantically observable.
The old key included many facts—but not this one
The WebGPU program-cache key contains several categories of state.
Its generic builder includes information such as:
program name
custom cache hint
workgroup size
subgroup size
dispatch dimensionality
uniform structure
declared input metadata
declared output metadata
index rankMatMulNaiveProgram also supplied a custom hint containing packing-related values.
Before the patch, that hint was approximately:
.CacheHint(
components,
a_components,
output_number
)Those fields described:
how B values were grouped,
how A values were grouped,
and how many output positions one invocation handled.
The generic key could distinguish many incompatible program configurations.
But none of its remaining fields necessarily answered:
Does this shader index bias as `bias[col]`
or as `bias[row + i]`?Two layout variants could have the same:
program name
N
K
packing widths
output count
input and output types
rank
uniform lengths
dispatch dimensionswhile generating different bias code.
The old key was therefore complete for many structural properties and incomplete for one compile-time branch.
A cache key does not become correct merely because it contains a large amount of metadata.
It must contain the metadata that changes the compiled artifact.
This was the same omission class as the activation bug
The activation-kind defect from Part 1 followed this pattern:
Activation changes WGSL
but
activation is absent from keyThe layout defect followed the same pattern:
Layout flag changes WGSL
but
layout flag is absent from keyThey differed in how the missing state entered the program.
Activation
Graph fusion metadata
→ activation object
→ activation snippetLayout
Execution-provider configuration
→ `is_channels_last`
→ bias-indexing expressionThe cache does not care where the state originated.
Its obligation is determined by the generated code.
Any state that changes WGSL
→ must change the pipeline identityThe layout was selected at the session boundary
The activation regressions could place two different activation configurations inside one graph.
Conv A + ReLU
Conv B + SigmoidBoth variants were compiled during one session and naturally shared the same WebGPU context and cache.
The layout case was different.
The tested layout preference belonged to the WebGPU execution-provider configuration associated with a session.
The same serialized model could be loaded into:
Session A
WebGPU configuration:
channels lastand:
Session B
WebGPU configuration:
channels firstEach session could transform or lower the same graph according to its own layout configuration.
To place both generated shader variants in one cache, the regression therefore needed:
two session configurations
+
one shared WebGPU context lifetimeA single isolated session did not express both sides of that boundary in the way this test was designed.
The cache belonged to the WebGPU context, not directly to one session
WebGpuContext creates and owns a:
ProgramManagerduring initialization.
ProgramManager contains:
std::unordered_map<
std::string,
ProgramArtifact
> programs_;Each ProgramArtifact contains the compiled compute pipeline and associated binding-layout information.
Conceptually:
WebGpuContext
↓
ProgramManager
↓
programs_[cache_key]
↓
compiled WebGPU pipelineThe cache is therefore attached to the context.
It is not stored as an independent map owned only by one inference session.
When two sessions receive the same WebGpuContext, they reach the same ProgramManager and the same pipeline map.
That sharing is intentional.
It allows equivalent programs created by different sessions to reuse already compiled pipelines.
The correctness requirement is that the cache key distinguish the non-equivalent ones.
Context identity was process-level and keyed by context_id
WebGpuContextFactory stores contexts in a map shaped like:
std::unordered_map<
int32_t,
WebGpuContextInfo
>Each entry contains:
one WebGpuContext
one reference countCreateContext() first searches the map using:
context_idIf no entry exists:
create context
insert it
initialize refcountIf an entry already exists:
return the existing context
increment its refcountThe default configuration uses:
context_id = 0Custom positive context IDs require a matching externally supplied instance and device.
The important point for the regression is:
same context ID
+
overlapping references
→ same live WebGpuContextThe factory does not create a separate program cache merely because another session asks for the context.
Reference counting determined whether the cache survived
When a session creates its WebGPU execution provider:
CreateContext(context_id)
→ refcount increasesWhen that provider is destroyed:
ReleaseContext(context_id)
→ refcount decreasesUnder the default non-preserved configuration, the context is erased when:
refcount == 0Erasing the context also destroys the ProgramManager owned by it.
The compiled pipeline cache disappears with that manager.
The execution-provider destructor explicitly calls:
WebGpuContextFactory::ReleaseContext(
context_id_
);after releasing its pooled buffers.
The factory’s release rule is:
if (
--ref_count == 0
&&
!preserve_device
) {
erase context;
}That lifecycle made an apparently reasonable sequential regression ineffective.
Why two sequential session tests could both pass
Imagine this test structure:
Create Session A with channels-last configuration.
Run model.
Destroy Session A.
Create Session B with channels-first configuration.
Run model.
Destroy Session B.Under the default context lifecycle:
Session A
Create context ID 0
↓
refcount = 1
↓
compile channels-last shader
↓
store pipeline under old key KSession A destruction
Release context ID 0
↓
refcount = 0
↓
erase context
↓
destroy pipeline cacheSession B
Create context ID 0 again
↓
new WebGpuContext
↓
new ProgramManager
↓
empty cache
↓
compile channels-first shader under KBoth sessions execute the shader they requested.
Session A
→ correctSession B
→ correctThe key remains incomplete.
The test failed to reproduce the cache lifetime required for the wrong reuse.
The PR description calls out this exact problem: sequentially created sessions could each receive a fresh pipeline cache after the preceding context reference dropped to zero.
A passing isolated test proved only local compilation
The sequential test would establish:
The channels-last configuration can generate and execute a correct shader in an empty cache.and:
The channels-first configuration can generate and execute a correct shader in another empty cache.It would not establish:
The two configurations receive different identities
when they coexist in one cache.These are separate claims.
Local shader correctness
Configuration X
→ fresh compilation
→ correct outputCache-equivalence correctness
Configuration X and Y
→ shared cache
→ correct reuse or correct separationThe defect belonged to the second claim.
The test had to create the second situation.
The sessions needed overlapping lifetimes, not parallel execution
The regression creates:
Session A
then
Session Bwhile keeping Session A alive.
The state becomes:
Session A reference
→ active
Session B reference
→ active
shared context refcount
→ at least 2The test can then run:
Session A
followed by
Session BThe executions do not need to occur simultaneously on separate host threads.
The word “concurrent” in this context refers to:
concurrently alive session objectsnot necessarily:
parallel model executionOverlapping ownership is sufficient to preserve the context and program cache between requests.
This distinction matters because the failure is not fundamentally a data race.
It is a cache-lifetime and identity error.
The first live session kept the cache resident
The working regression flow is:
Create Session A
↓
context refcount = 1Create Session B before destroying A
↓
same context returned
↓
context refcount = 2Run Session A
↓
compile layout-A pipeline
↓
store under key KRun Session B
↓
request layout-B pipeline
↓
old key also K before fix
↓
reuse layout-A shaderOnly after both outputs have been examined are the session objects destroyed.
The shared cache remains alive throughout the collision window.
The two sessions loaded the same serialized model
The test constructs one model and serializes it.
Both sessions load the same model data.
This removes unrelated model differences from the comparison.
Model graph
input
weights
bias
output
→ identicalThe intended difference is the execution-provider layout configuration.
Session A
→ one preferred layoutSession B
→ the opposite layoutThis makes the regression’s central question precise:
When the same model produces two layout-specialized shaders,
does the pipeline key distinguish them?The matrix configuration was held equal across both layouts
The regression comments explicitly preserve:
N
K
component countacross the channels-first and channels-last paths.
That prevents another cache-key field from separating the programs accidentally.
The old key already included packing-related values.
If the two layouts also caused:
different N
different K
different `components`
different `a_components`
or
different `output_number`then the old cache keys might already differ.
The test would pass without exercising the missing layout identity.
The regression therefore fixes those values and changes the layout only.
Old declared identity
→ equalGenerated bias expression
→ differentThat is the minimum collision boundary.
Small dimensions forced the affected implementation
As in Parts 1 and 2, the test needed to reach:
MatMulNaiveProgramThe relevant dispatch path selects it when:
N < 8
and
K < 8The layout test uses a small convolution configuration that satisfies those conditions.
A larger model could dispatch to a different WebGPU matmul implementation whose cache hint already contained other layout fields or whose shader structure differed.
Using a small test made the path deterministic.
Small N and K
→ naive matmul
→ known layout-dependent bias code
→ known incomplete keyThe bias values made the wrong indexing visible
The test uses:
bias = [100, 200, 300]Distinct values are important.
Suppose the bias had been:
[1, 1, 1]Then:
bias[col]and:
bias[row + i]could often return the same value even when they selected different positions.
The wrong shader might still produce the expected output.
That would make the regression insensitive to the code difference it intended to protect.
With distinct bias elements:
index 0
→ 100
index 1
→ 200
index 2
→ 300an incorrect index changes the arithmetic result.
The test converts an indexing-identity bug into an observable numerical mismatch.
The weights were chosen to make the expected output stable
The model uses a small identity-like convolution weight arrangement.
That makes it easier to compare WebGPU output with a CPU baseline and isolate the bias-indexing behavior.
The purpose is not to benchmark convolution performance.
It is to make the layout-dependent bias selection visible without introducing a complicated numerical reference.
The test asks:
Did both WebGPU sessions reproduce
the same model semantics as CPU?not:
Did both sessions produce the same physical layout?Different internal layouts are allowed.
The logical model output must remain the same.
The test confirmed the operations actually ran on WebGPU
A provider-specific regression can pass accidentally through fallback.
If one convolution were assigned to CPU:
No WebGPU shader generated
No pipeline-cache lookup
No collisionThe numerical output would still match the CPU reference.
The test inspects the transformed session graphs and verifies that the convolution-like nodes are assigned to:
WebGPUExecutionProviderfor both sessions.
It accepts the relevant transformed operation names, including fused or layout-specific forms, but requires WebGPU ownership.
Only then does a correct output provide evidence about the WebGPU pipeline cache.
The CPU run supplied a provider-independent reference
Before creating the two WebGPU sessions, the regression runs the serialized model through a CPU session and stores its outputs.
Those outputs become the reference for:
channels-last WebGPU session
and
channels-first WebGPU sessionThe test does not assume that one WebGPU layout is the reference for the other.
That matters because the first WebGPU result could itself be wrong if it received a stale cache entry from another test or configuration.
The reference path remains outside the affected WebGPU cache.
CPU semantics
↓
expected outputWebGPU layout A
→ compareWebGPU layout B
→ compareBoth cache-population orders were tested
The regression is invoked twice.
First ordering
channels-last session runs first
channels-first session runs secondSecond ordering
channels-first session runs first
channels-last session runs secondBefore the patch, the first executed layout could populate the shared key.
The second layout could then receive the first layout’s shader.
Testing both directions ensures that the regression does not merely protect one presumed cache owner.
Layout A first
→ Layout B must remain correctLayout B first
→ Layout A must remain correctThe cache-equivalence relation must be symmetric even though cache insertion has an order.
Why one order alone would be weaker
Suppose one of the generated shaders happened to produce correct results for both tested outputs under a degenerate input, while the reverse shader did not.
A one-direction test could pass or fail depending on which shader was compiled first.
Running both orderings makes such asymmetry visible.
It also protects against future changes that alter:
graph initialization order,
session setup order,
or the layout selected as the first cache resident.
The invariant is not:
one preferred layout must compile firstIt is:
neither layout may use the other layout’s shaderThe patch adds is_channels_last to both hints
The fixed convolution call site now includes:
std::to_string(
is_channels_last
)in the custom cache hint.
The standalone MatMul path explicitly sets:
constexpr bool is_channels_last = false;and includes that value in the same hint schema.
Conceptually:
Old hint
activation
|
components
|
a_components
|
output_numberNew hint
activation
|
components
|
a_components
|
output_number
|
is_channels_lastThe exact cache string is then incorporated into the generic program key along with the other program metadata.
channels last
→ hint ends with truechannels first
→ hint ends with falseThe two shaders no longer share one semantic identity.
Why the standalone path declares the layout explicitly
Standalone MatMul uses channels-first-style bias indexing in this implementation.
Before the patch, the constructor relied on the default argument:
is_channels_last = falseThe fixed call site introduces an explicit local constant:
constexpr bool is_channels_last = false;and passes it to both:
MatMulNaiveProgram constructor
and
CacheHintThis avoids a future divergence such as:
constructor default changes
but
cache hint still assumes falseThe same named fact now drives:
shader generation
and
shader identityThat is a stronger producer contract than duplicating one implicit assumption.
The key and generator now consume the same layout fact
The corrected flow is:
Session layout configuration
↓
`is_channels_last`
├──→ MatMulNaiveProgram
│ ↓
│ generated bias expression
│
└──→ CacheHint
↓
pipeline identityBefore:
layout flag
→ shader generator onlyAfter:
layout flag
→ shader generator
and
→ cache-key generatorThis is the same structural repair used for the activation.
One source fact feeds both the artifact and its identity.
A cache-lifetime test is different from a cache-key unit test
A direct unit test could construct two program objects and assert:
key(layout A)
!=
key(layout B)That would be useful.
It would not prove the actual session and context architecture exercised the same shared cache.
The end-to-end regression additionally proves:
two provider configurations can coexist,
they receive a shared context under the test setup,
both operations run on WebGPU,
the first pipeline remains resident,
the second pipeline lookup reaches that cache,
and the final outputs remain correct.
The identity unit and the lifetime integration are separate layers.
PR #32048’s layout regression protects the latter.
The state under test was not owned by the model
The model contains:
convolution inputs,
weights,
bias,
and output semantics.
It does not contain:
process-level WebGPU context map
context reference count
compiled pipeline cache
session lifetimeThose belong to the runtime environment.
A model-only fixture cannot represent them completely.
This is why systems regressions often need to construct more than one model invocation.
The failure can live in:
what survives between invocationsrather than in one invocation’s graph.
Fresh-process tests can hide cache defects
A test process that executes one configuration and exits gives every run an empty cache.
Process begins
→ empty cache
→ compile correct shader
→ process exitsRepeating that test with another configuration still provides no collision.
New process
→ new empty cache
→ compile other correct shaderBoth pass.
A cache-key regression needs at least two distinct configurations inside one relevant cache generation.
That can be achieved through:
one graph with two variants,
two sessions sharing one context,
or another controlled mechanism that preserves the cache.
The required topology depends on who owns the cache.
Clearing state can convert a wrong hit into a correct miss
This distinction is important when debugging.
Suppose a user reports that:
model output becomes correct after restarting the processThat can indicate a stateful cache or allocator problem.
A process restart removes the old compiled artifact.
Before restart
wrong cache entry exists
→ wrong hitAfter restart
cache empty
→ correct shader compiled
→ correct missThe restart does not repair the key.
It removes the conflicting history.
A regression must retain that history.
The first request becomes part of the second request’s environment
Ordinary functional tests often treat invocations as independent.
For a shared program cache:
Request 1changes the state observed by:
Request 2The complete input to Request 2 is therefore not only:
model
tensors
session optionsIt also includes:
pipelines previously compiled into the shared contextThe layout regression intentionally controls that hidden input by changing which session runs first.
Cache correctness has two dimensions
The complete cache contract can be divided into:
Identity correctness
Do equivalent programs receive the same key?
Do non-equivalent programs receive different keys?Lifetime correctness
Which callers can observe one cache entry?
How long does the entry survive?
When is the cache destroyed or reused?Parts 1 and 2 primarily examined identity.
Part 3 shows why identity cannot be tested without lifetime.
Incomplete key
+
fresh cache every time
→ defect hiddenIncomplete key
+
shared persistent cache
→ wrong shader reusedThe context factory’s reuse is not itself a defect
Sharing one WebGPU context across sessions can reduce expensive duplicate work.
It can allow:
pipeline reuse,
device reuse,
buffer-management reuse,
and consistent resource ownership.
The factory uses a mutex and reference count to manage that shared object.
The problem was not:
Sessions shared a cache.The problem was:
The cache treated two different shaders as equivalent.Disabling all cross-session sharing would avoid the collision but would also discard legitimate reuse.
The patch repairs the identity while preserving the architecture.
context_id is part of the cache-sharing boundary
Two sessions do not share merely because they exist in one process.
They must resolve to the same live context entry.
The factory key is:
context_idFor custom positive IDs, the factory also checks that the instance and device match the existing context.
Conceptually:
same process
+
same live context ID
+
compatible device identity
→ shared WebGpuContextDifferent context IDs receive different context objects and therefore different program managers.
The regression uses the default provider path, allowing both sessions to share the default context entry.
The article should not generalize this into:
Every pair of WebGPU sessions always shares one cache.The direct claim is narrower:
Sessions referencing the same live context ID share that context’s program cache.
preserve_device is another lifetime boundary
The factory erases a zero-reference context only when:
preserve_device
=
falseWhen preservation is enabled, a context can survive after the last ordinary reference is released.
PR #32048’s regression and description rely on the default non-preserved behavior to explain why sequential sessions could receive fresh caches.
This means the exact lifetime can depend on configuration.
The general test lesson remains:
Determine who owns the shared state.
Determine what destroys it.
Build the regression around that actual lifecycle.Simultaneous lifetime was sufficient even without a thread race
The test does not require:
two host threads entering the cache simultaneously,
concurrent pipeline compilation,
a data race in the cache map,
or nondeterministic callback ordering.
Its failure can occur with fully sequential execution.
Session A runs
then
Session B runsThe only concurrency required is resource ownership.
Session A still alive
while
Session B existsThis makes the bug deterministic and easier to isolate.
It is a state-history problem, not necessarily a synchronization bug.
The failure could depend on which test ran before it
Shared caches can also introduce test-order sensitivity when test fixtures reuse a process-level context.
A test may pass alone:
empty cacheand fail in a suite:
another test populated the conflicting keyOr the reverse can happen if another test destroys or resets the context.
PR #32048’s regression avoids relying on external suite order.
It constructs both conflicting configurations inside one controlled test and executes both insertion orders.
That makes the evidence self-contained.
Why numerical comparison alone would not identify the cache boundary
Suppose the second session produced an incorrect output.
That alone would not prove:
wrong pipeline reusePossible causes could include:
an incorrect layout transform,
an incorrect bias tensor,
an execution-provider assignment error,
a convolution lowering bug,
or a WebGPU arithmetic defect.
The test design narrows the explanation by holding the model constant and changing the layout configuration while intentionally preserving one cache.
The code diff further shows that the shader indexing branch and cache hint were driven by different state before the fix.
The evidence chain is:
source-level missing key field
+
two generated WGSL variants
+
shared-cache test topology
+
order-dependent regression
+
merged field addition
→ confirmed pipeline identity defectThe patch fixes the key rather than the test environment
A weaker reaction could have been:
Give each session a separate context ID.That would make the regression pass by preventing cache sharing.
It would not repair users who intentionally share the default context.
Another workaround could be:
clear the program cache between layout changes.That would turn wrong hits into repeated compilations.
PR #32048 instead changes:
what the cache considers equivalentThe architecture can continue sharing valid pipelines.
The complete three-field repair
After the merged patch, the MatMulNaiveProgram hint covers:
activation kind and parameters
packing values
number of outputs per invocation
layout-dependent bias modeThe full identity relationship is now closer to:
same key
→ same activation snippet
→ same embedded activation constants
→ same bias-indexing expression
→ same packing-dependent codeNo cache-key scheme is proven universally complete merely by one PR.
The patch closes the three WGSL dependencies identified for this program.
What PR #32048 directly changed for this third defect
The merged patch directly:
adds
is_channels_lastto bothMatMulNaiveProgramcache-hint call sites,makes the standalone MatMul layout value explicit,
retains the layout flag as a member used by shader generation,
adds a two-session WebGPU regression,
keeps both sessions alive so they share the context cache,
loads the same model under opposite layout configurations,
ensures the relevant convolution executes on WebGPU,
uses distinct bias values to expose the two indexing expressions,
compares both session outputs with a CPU reference,
and executes both layout insertion orders.
The repaired call site remains in ONNX Runtime main.
What the patch does not establish
The public evidence does not establish that:
every WebGPU session in one process always shares a cache,
sessions with different
context_idvalues share state,the two regression sessions execute in parallel,
every layout-sensitive WebGPU program had the same omission,
bias-free programs were affected by this exact flag,
every channels-first or channels-last convolution used
MatMulNaiveProgram,this bug caused a device crash,
the context-sharing architecture is unsafe,
or the fix changes model performance.
The supported conclusion is narrower:
is_channels_lastchanged the bias-indexing code emitted byMatMulNaiveProgrambut was absent from its pipeline key. The regression had to keep two oppositely configured sessions alive under one shared WebGPU context; sequential session destruction would erase the cache and hide the collision.
The complete layout failure chain
The third defect can be written as:
One model loaded into two WebGPU sessions
↓
Session A selects channels-last layout
↓
Session B selects channels-first layout
↓
both lower the convolution to MatMulNaiveProgram
↓
A emits `bias[col]`
↓
B emits `bias[row + i]`
↓
old cache hint omits `is_channels_last`
↓
other key fields remain equal
↓
both sessions share one live WebGpuContext
↓
first compiled pipeline remains in ProgramManager
↓
second request receives the first layout’s shader
↓
bias is read with the wrong index
↓
silent numerical mismatchThe repaired path is:
Layout configuration
↓
same `is_channels_last` value feeds:
├── shader generator
└── cache hint
↓
different bias-indexing programs receive different keysThe complete three-part series
Part 1 — A generated activation was missing from the key
ReLU shader
and
Sigmoid shader
→ same old identityThe cache reused whichever pipeline was compiled first.
Part 2 — The activation was present, but its float identity was lossy
1000015.0
and
1000025.0
→ same six-significant-digit cache text
→ different WGSL constantsmax_digits10 restored sufficient finite float32 distinction.
Part 3 — The cache collision existed only while the cache remained alive
Sequential sessions
→ first context released
→ cache destroyed
→ bug hiddenTwo live sessions
→ shared context
→ shared pipeline map
→ collision observableThe regression had to reproduce both program identity and state lifetime.
The final lesson is that a cache test must reproduce the cache
It is possible to test every configuration separately and still learn nothing about whether they can safely share one compiled artifact.
Configuration A
→ fresh cache
→ correctConfiguration B
→ another fresh cache
→ correctThe missing question is:
What happens when B enters the cache after A?That question depends on:
who owns the cache,
which objects share it,
when it is destroyed,
and which request populates it first.
A regression for cached state is incomplete when its fixture resets that state before the conflicting configuration arrives.
ONNX Runtime #32048 fixed the key.
Its strongest layout regression also fixed the test topology by keeping the shared context alive long enough for the incomplete identity to become observable.
Previous articles
ONNX Runtime #32048 — Why WebGPU Reused the Wrong Shader When the Cache Key Omitted the Activation
ONNX Runtime #32048 — Why Six Significant Digits Could Map Two Different WGSL Constants to One Cache Key
Link the titles above to Parts 1 and 2 after publication.
Related material
Patch status: Merged into ONNX Runtime main
Affected program: WebGPU MatMulNaiveProgram
Missing identity field: is_channels_last
Generated WGSL difference: bias[col] versus bias[row + i]
Activation condition: The layout-specific shader difference is relevant when bias is present
Cache owner: ProgramManager inside WebGpuContext
Context owner: Process-level WebGpuContextFactory, keyed by context_id
Lifetime model: Reference-counted; default non-preserved context is erased when the count reaches zero
Why sequential sessions were insufficient: Destroying the first session could destroy its context and pipeline cache
Regression topology: Two simultaneously alive sessions using opposite layout configurations and one shared context
Execution ordering: Sessions run sequentially while both remain alive; parallel execution is not required
Data design: Same model and matrix dimensions, distinct bias values, CPU reference
Order coverage: Channels-last first and channels-first first
Repair: Include is_channels_last in the cache hint at both call sites
Evidence boundary: Confirmed shared-cache numerical regression; no claim that every WebGPU session or program shares this exact cache boundary
This is Part 3 and the final article in the ONNX Runtime WebGPU pipeline-cache identity series.
Part 1 examined why a fused activation compiled into WGSL had to be included in the pipeline key.
Part 2 examined why activation parameters needed a serialization precise enough to preserve the float distinctions that reached generated shader code.
This final article examined why layout-dependent WGSL required a layout-dependent key—and why the regression had to preserve the process-level context lifetime that allowed the wrong pipeline to be reused.
#ONNXRuntime #WebGPU #WGSL #PipelineCache #ShaderCompilation #CacheLifetime #TensorLayout #Convolution #EdgeAI #RegressionTesting #CompilerCorrectness #CodeAnalysis