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 activation

A 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 string

The values:

1000015.0

1000025.0

produced 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_last

The 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 1
Session A destroyed
→ refcount becomes 0
→ context is erased
→ program cache disappears
Session B created afterward
→ new context
→ new empty cache

If 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 context
Session B
→ channels first
→ receives the same context ID
→ shares the same program cache

The 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:

col

The shader reads:

bias[col]

Channels-first layout

The relevant output position is represented by:

row + i

The 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 = true

If the program has no bias input:

process_bias
→ empty

Then 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 difference

A 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

300

so the two indexing formulas do not accidentally read interchangeable values.

Wrong bias index

→ visibly different output

The 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 rank

MatMulNaiveProgram 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 dimensions

while 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 key

The layout defect followed the same pattern:

Layout flag changes WGSL

but

layout flag is absent from key

They differed in how the missing state entered the program.

Activation

Graph fusion metadata

→ activation object

→ activation snippet

Layout

Execution-provider configuration

→ `is_channels_last`

→ bias-indexing expression

The 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 identity

The layout was selected at the session boundary

The activation regressions could place two different activation configurations inside one graph.

Conv A + ReLU

Conv B + Sigmoid

Both 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 last

and:

Session B

WebGPU configuration:
channels first

Each 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 lifetime

A 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:

ProgramManager

during 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 pipeline

The 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 count

CreateContext() first searches the map using:

context_id

If no entry exists:

create context

insert it

initialize refcount

If an entry already exists:

return the existing context

increment its refcount

The default configuration uses:

context_id = 0

Custom 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 WebGpuContext

The 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 increases

When that provider is destroyed:

ReleaseContext(context_id)

→ refcount decreases

Under the default non-preserved configuration, the context is erased when:

refcount == 0

Erasing 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 K

Session A destruction

Release context ID 0
        ↓
refcount = 0
        ↓
erase context
        ↓
destroy pipeline cache

Session B

Create context ID 0 again
        ↓
new WebGpuContext
        ↓
new ProgramManager
        ↓
empty cache
        ↓
compile channels-first shader under K

Both sessions execute the shader they requested.

Session A
→ correct
Session B
→ correct

The 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 output

Cache-equivalence correctness

Configuration X and Y
→ shared cache
→ correct reuse or correct separation

The 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 B

while keeping Session A alive.

The state becomes:

Session A reference
→ active

Session B reference
→ active

shared context refcount
→ at least 2

The test can then run:

Session A

followed by

Session B

The executions do not need to occur simultaneously on separate host threads.

The word “concurrent” in this context refers to:

concurrently alive session objects

not necessarily:

parallel model execution

Overlapping 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 = 1
Create Session B before destroying A
        ↓
same context returned
        ↓
context refcount = 2
Run Session A
        ↓
compile layout-A pipeline
        ↓
store under key K
Run Session B
        ↓
request layout-B pipeline
        ↓
old key also K before fix
        ↓
reuse layout-A shader

Only 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

→ identical

The intended difference is the execution-provider layout configuration.

Session A
→ one preferred layout
Session B
→ the opposite layout

This 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 count

across 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
→ equal
Generated bias expression
→ different

That is the minimum collision boundary.


Small dimensions forced the affected implementation

As in Parts 1 and 2, the test needed to reach:

MatMulNaiveProgram

The relevant dispatch path selects it when:

N < 8

and

K < 8

The 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 key

The 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
→ 300

an 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 collision

The 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:

WebGPUExecutionProvider

for 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 session

The 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 output
WebGPU layout A
→ compare
WebGPU layout B
→ compare

Both cache-population orders were tested

The regression is invoked twice.

First ordering

channels-last session runs first

channels-first session runs second

Second ordering

channels-first session runs first

channels-last session runs second

Before 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 correct
Layout B first
→ Layout A must remain correct

The 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 first

It is:

neither layout may use the other layout’s shader

The 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_number
New hint

activation
|
components
|
a_components
|
output_number
|
is_channels_last

The exact cache string is then incorporated into the generic program key along with the other program metadata.

channels last
→ hint ends with true
channels first
→ hint ends with false

The 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 = false

The fixed call site introduces an explicit local constant:

constexpr bool is_channels_last = false;

and passes it to both:

MatMulNaiveProgram constructor

and

CacheHint

This avoids a future divergence such as:

constructor default changes

but

cache hint still assumes false

The same named fact now drives:

shader generation

and

shader identity

That 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 identity

Before:

layout flag
→ shader generator only

After:

layout flag
→ shader generator

and

→ cache-key generator

This 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 lifetime

Those 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 invocations

rather 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 exits

Repeating that test with another configuration still provides no collision.

New process

→ new empty cache

→ compile other correct shader

Both 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 process

That can indicate a stateful cache or allocator problem.

A process restart removes the old compiled artifact.

Before restart

wrong cache entry exists
→ wrong hit
After restart

cache empty
→ correct shader compiled
→ correct miss

The 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 1

changes the state observed by:

Request 2

The complete input to Request 2 is therefore not only:

model

tensors

session options

It also includes:

pipelines previously compiled into the shared context

The 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 hidden
Incomplete key

+

shared persistent cache

→ wrong shader reused

The 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_id

For 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 WebGpuContext

Different 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
=
false

When 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 runs

The only concurrency required is resource ownership.

Session A still alive

while

Session B exists

This 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 cache

and fail in a suite:

another test populated the conflicting key

Or 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 reuse

Possible 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 defect

The 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 equivalent

The 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 mode

The full identity relationship is now closer to:

same key

→ same activation snippet

→ same embedded activation constants

→ same bias-indexing expression

→ same packing-dependent code

No 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_last to both MatMulNaiveProgram cache-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_id values 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_last changed the bias-indexing code emitted by MatMulNaiveProgram but 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 mismatch

The repaired path is:

Layout configuration
        ↓
same `is_channels_last` value feeds:
        ├── shader generator
        └── cache hint
        ↓
different bias-indexing programs receive different keys

The complete three-part series

Part 1 — A generated activation was missing from the key

ReLU shader

and

Sigmoid shader

→ same old identity

The 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 constants

max_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 hidden
Two live sessions

→ shared context
→ shared pipeline map
→ collision observable

The 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

→ correct
Configuration B

→ another fresh cache

→ correct

The 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

Popular posts from this blog

AMD Is Trying to Turn Kernel Diversity Into One Software Asset

PyTorch #188031 — Why Did Forward Use 64-Bit Indexing While Backward Still Used uint32_t?

NVIDIA CUTLASS #3017 — Why load() and store() Mapped the Same Tile Coordinate to Different Addresses