ONNX Runtime #32048 — Why Six Significant Digits Could Map Two Different WGSL Constants to One Cache Key

ONNX Runtime WebGPU Pipeline Cache, Part 2 of 3 — Float serialization, max_digits10, and why a human-readable string was not a canonical compiled-program identity

Part 1 examined the first cache-identity defect in ONNX Runtime’s WebGPU MatMulNaiveProgram.

The program could embed an activation directly into generated WGSL.

ReLU
→ one shader

Sigmoid
→ another shader

But the old custom cache hint omitted the activation entirely.

Two different shaders could therefore receive the same pipeline-cache key, allowing the first compiled shader to be reused for the second operation.

The initial repair was straightforward:

Generated WGSL depends on activation

→ include the activation in the cache hint

That closed collisions between different activation kinds.

It did not yet close collisions between different parameters of the same activation.

Parameterized activations include:

LeakyRelu(alpha)

Clip(minimum, maximum)

HardSigmoid(alpha, beta)

Their constants are embedded directly into WGSL.

Two HardSigmoid operations can therefore have:

the same activation kind

the same tensor shapes

the same packing configuration

but

different shader source

The cache hint now contained the activation.

The activation serialization was still too lossy.

Activation::ToString() used an ordinary std::stringstream with its default precision:

6 significant digits

The WGSL generator used std::to_string():

6 digits after the decimal point

Those are not the same decimal representation.

The PR recorded the following pair:

1000015.0

1000025.0

Both values became the same old cache-key text:

1.00002e+06

But the generated WGSL contained two different constants:

1000015.000000

1000025.000000

The cache could therefore conclude:

same activation parameter

while the shader generator concluded:

different literal constant

The resulting pipeline identity was still incomplete.

PR #32048 fixed the shared serialization by setting:

std::setprecision(
    std::numeric_limits<float>::max_digits10
)

before writing activation parameters into the cache string.

For ordinary finite float32 values, that precision is sufficient to preserve the original float through a decimal round trip and to distinguish different representable values.

A cache key does not need to look readable. It needs to preserve every distinction that can change the compiled program.

The merged PR records the parameter-formatting mismatch, the colliding values, and the central max_digits10 repair.


The activation parameter was compiled into the shader

MatMulNaiveProgram does not pass every activation parameter to one generic shader as a runtime uniform.

Instead, shader generation calls:

GetActivationSnippet(
    activation_,
    "output_value_t",
    "output_element_t"
);

That helper constructs WGSL text.

For LeakyRelu:

alpha
→ inserted into the `select` expression

For Clip:

minimum and maximum
→ inserted into `clamp`

For HardSigmoid:

alpha and beta
→ inserted into the multiply-add expression

A simplified HardSigmoid snippet is:

value = clamp(
    alpha * value + beta,
    0.0,
    1.0
);

Changing alpha changes the shader text.

HardSigmoid(alpha = 0.2)

≠

HardSigmoid(alpha = 0.3)

The parameter is therefore compilation state.

It is not merely one runtime input value that can vary while reusing the same pipeline.

The landed source continues to generate these constants through std::to_string() inside GetActivationSnippet().


Adding the activation name was not enough

Suppose the cache key contained only:

ActivationKind = HardSigmoid

Two operations could still differ like this:

Operation A

kind:
HardSigmoid

alpha:
0.2

beta:
0.5
Operation B

kind:
HardSigmoid

alpha:
0.25

beta:
0.5

Their WGSL programs contain different arithmetic.

0.2 * value + 0.5

versus:

0.25 * value + 0.5

One compiled pipeline cannot correctly represent both unless alpha is moved into runtime state.

The current implementation embeds it.

Therefore the complete identity is:

activation kind

+

activation parameter values

Activation::ToString() already attempted to serialize both fields.

The defect was not total omission.

It was that different parameter values could collapse into the same string.


“Precision 6” did not mean six digits after the decimal point

An ordinary std::stringstream uses a default precision of six.

Under the default floating-point formatting mode, that generally means:

six significant digits

Significant digits are counted across the entire number.

For a small value:

0.1234567

six significant digits can preserve something close to:

0.123457

For a large value near one million:

1000015

the same six-digit budget is already consumed by the large integer portion.

The output may switch to scientific notation and round at a much coarser scale:

1000015
→ 1.00002e+06
1000025
→ 1.00002e+06

The two original values differ by ten.

The six-significant-digit representation does not preserve that distinction at this magnitude.


std::to_string() used a different rule

The WGSL generation path converted the same floats through:

std::to_string(value)

In this code path, that produced six digits after the decimal point.

1000015.0
→ "1000015.000000"
1000025.0
→ "1000025.000000"

The integer difference remains visible because the formatting budget is not limited to six total significant digits.

The two serialization paths therefore answered different questions.

Cache serialization

Represent the value compactly
with six significant digits.

WGSL serialization

Emit the value with six fractional digits.

The mismatch can be written directly:

Cache:

1000015.0
→ 1.00002e+06

1000025.0
→ 1.00002e+06
WGSL:

1000015.0
→ 1000015.000000

1000025.0
→ 1000025.000000

The cache sees one identity.

The compiler emits two programs.


The values were not separated by a tiny rounding accident

The PR describes the pair as being:

160 float32 ULPs apart

Around one million, adjacent IEEE-754 float32 values are separated by:

0.0625

The difference between the chosen values is:

1000025 - 1000015
=
10

Therefore:

10 / 0.0625
=
160

The two values are not nearly identical bit patterns separated by one unusual rounding edge.

There are 159 other representable float32 values between them.

Yet the old cache serialization collapsed both into one decimal string.

This demonstrates that:

human-readable approximation

≠

lossless float identity

A valid cache key needs an implication in one direction

The required relationship is:

Different generated WGSL

→ different cache key

Equivalently:

Same cache key

→ same generated WGSL

The key does not necessarily need to be byte-for-byte identical to the shader text.

It needs to be at least as discriminating as the code generator.

A dangerous relationship is:

Key serializer merges two values

while

WGSL serializer keeps them separate

That creates a false cache hit.

The opposite relationship is safer:

Key serializer distinguishes two values

while

WGSL serializer happens to emit the same text

That may create an unnecessary cache miss and an extra pipeline compilation.

It does not cause the wrong shader to be reused.

Correctness requires avoiding false equivalence first.


Why using the WGSL formatting directly would have worked

One possible repair was:

Build the cache parameter string
using exactly the same formatting as WGSL.

For example:

std::to_string(alpha)

could be used in both paths.

Then:

same generated constant text
→ same parameter identity

and:

different generated constant text
→ different parameter identity

That directly aligns the two representations.

The code-review discussion explicitly suggested either:

the same formatting as shader generation

or:

a lossless representation of the raw float value

as valid directions.

The final patch chose a more precise cache representation instead.


Why max_digits10 was a safe central fix

C++ exposes:

std::numeric_limits<float>::max_digits10

For ordinary IEEE-754 float32, this is:

9 significant decimal digits

That number of digits is sufficient for a finite float value to be converted to decimal text and then parsed back into the same float.

The cache serializer now writes activation parameters with that precision.

Conceptually:

float32 value

→ enough decimal digits to identify that float

→ cache string

The two recorded values remain distinct:

1000015

1000025

The key no longer collapses them.

The landed implementation applies this precision inside the shared Activation::ToString() method.


The cache representation can be more precise than the WGSL representation

The WGSL path still uses six decimal places.

The cache path now retains enough precision to distinguish the underlying float32 values.

This means the cache can sometimes distinguish two parameters that the current WGSL formatter emits identically.

For example, two very close float values might both become:

0.123457

inside WGSL while producing different max_digits10 cache strings.

That creates:

two cache entries

for

the same emitted shader text

This is potentially less efficient than a perfectly canonical shader-text identity.

It is still correct.

Over-distinguishing programs

→ possible additional compilation
Under-distinguishing programs

→ wrong shader reuse

The patch chose the safe side of that boundary.

No performance cost from additional parameter-specific cache entries was reported in the PR.


Why raw float bits would also be a strong identity

Another possible serialization is:

reinterpret the float as 32 raw bits

→ encode those bits in the key

That would preserve distinctions such as:

  • every finite float32 value,

  • positive and negative zero,

  • and potentially different NaN payloads.

It would avoid dependence on decimal formatting rules.

But a raw-bit identity can also be more specific than generated WGSL.

If shader generation normalizes two bit patterns into the same text, the cache still separates them.

Again, that is safe but potentially less reusable.

PR #32048 did not redesign activation identity around raw bits.

It used the standard decimal round-trip precision already available through C++ streams.


The fix was placed in the shared serializer

The formatting mismatch was not repaired only at:

MatMulNaiveProgram

The patch changed:

Activation::ToString()

itself.

That method is used as cache-hint material by several WebGPU programs.

The PR states that six current call sites benefited from the central correction.

This was important because the lossy serialization was not unique to the newly repaired matmul call site.

Activation string used in several program keys

+
shared serializer loses float distinctions

→ every user inherits the same collision risk

Fixing the call site alone could have left the same defect in other programs.

The central repair establishes one activation-key serialization policy.


A shared serializer is a semantic API

Activation::ToString() may look like a debugging helper.

Its name does not explicitly say:

Generate a canonical cache identity.

But once its output becomes part of a compiled pipeline key, the method has a stronger contract.

It must preserve every activation distinction relevant to generated code.

The method is no longer merely responsible for producing readable text.

Debug string contract:

help a human recognize the object
Cache identity contract:

never equate two objects
that generate different programs

Those are different requirements.

A readable debug string may legally omit detail.

A cache identity cannot.


String representations inherit the assumptions of their original purpose

Default stream formatting is convenient for:

  • logs,

  • error messages,

  • diagnostics,

  • and approximate human inspection.

It is usually not designed to preserve an exact float identity.

The failure arose because a representation suitable for humans was reused as a representation for compiled artifacts.

This pattern appears beyond floats.

Examples include:

unordered map iteration order

locale-dependent formatting

pointer addresses

abbreviated type names

rounded timestamps

default object `repr`

A string can be deterministic enough for a log and still be too weak for a cache key.


Why common activation values did not expose the bug

Typical parameters are often small and simple.

LeakyRelu alpha
→ 0.01
HardSigmoid alpha
→ 0.2
HardSigmoid beta
→ 0.5
Clip bounds
→ -6 and 6

Default six-significant-digit formatting distinguishes these values without difficulty.

A test suite using only common defaults can therefore show:

activation serialization works

while never crossing the precision boundary where two values merge.

The regression needed parameter values whose decimal representations diverged under the two formatting policies.


Large values made the hidden precision budget visible

The pair:

1000015.0

1000025.0

was chosen because:

they are distinct float32 values

their WGSL decimal strings are different

their old cache strings are equal

The magnitude forces six significant digits to discard lower integer information.

This is a useful testing principle:

To test a serializer, choose values near the boundary of the serializer’s precision—not only values common in ordinary workloads.

The test does not claim that such a large HardSigmoid alpha is a normal model parameter.

It demonstrates that the cache-key relation was not correct for the valid value domain.


Unusual parameters are still valid identity tests

A cache key must remain correct for every accepted configuration, not only popular configurations.

If the runtime accepts an activation parameter as a float, the key should not silently assume:

values will always be small

or:

values will have no more than six meaningful digits

Such a restriction would need to be explicit and validated before shader generation.

No such restriction defined the activation contract.

The test therefore uses an unusual but representable value to expose the missing guarantee.


The HardSigmoid regression turned the key collision into a visible output split

The end-to-end regression does not merely verify that the two serialized strings differ.

It constructs two fused HardSigmoid convolutions with:

alpha A
=
1000015.0
alpha B
=
1000025.0

and one shared beta:

beta
=
-8000160.0

The test arranges for both convolutions to produce:

value
=
8

before activation.

Now calculate the first activation.

1000015 × 8
=
8000120
8000120 - 8000160
=
-40

After clamping:

HardSigmoid output A
=
0

For the second:

1000025 × 8
=
8000200
8000200 - 8000160
=
40

After clamping:

HardSigmoid output B
=
1

The two shaders therefore produce maximally different results at that point.

Shader A
→ zero
Shader B
→ one

If one pipeline is incorrectly reused for both operations:

both outputs become zero

or

both outputs become one

depending on which shader populated the cache first.

The test converts a subtle string collision into an obvious numerical failure.


Distinct weights preserved two separate fused paths

Both convolutions were designed to produce eight.

They use different weight and bias combinations to reach that value.

First path

weights contribute 4

bias contributes 4

total = 8

Second path

weights contribute 2

bias contributes 6

total = 8

This prevents another graph optimization from treating the convolution computations as identical and eliminating one branch.

The activation parameters remain the intended cache-identity difference.


Both parameter orderings were executed

As with the activation-kind regression, the test runs:

alpha A first

alpha B second

and:

alpha B first

alpha A second

This checks both possible cache-population outcomes.

Before the fix:

first shader under key K
→ can dominate second operation

Changing order changes which shader is first.

A test using only one ordering might detect one wrong output while leaving the reverse relationship unexamined.

Both directions protect the equivalence contract rather than one accidental victim.


Unit tests isolate the serializer directly

The patch also adds focused tests around Activation::ToString().

Distinct LeakyRelu alphas

1234567.5

1234567.625

These are distinct representable values.

Their strings must differ.

Distinct Clip bounds

1000000.0

1000000.0625

The second activation parameter is varied.

Its strings must also differ.

Identical activations

same kind

same parameters

Their strings must remain equal.

Distinct activation kinds

ReLU

Sigmoid

Their strings must differ.

The tests therefore protect both sides:

different compiled state
→ different identity

and:

same compiled state
→ stable reusable identity

The serializer tests and pipeline tests prove different things

The unit tests establish:

Activation::ToString()
does not collapse the tested values.

The pipeline regressions establish:

That serialized identity actually participates
in the WebGPU program key

and prevents wrong shader reuse.

One test layer does not replace the other.

A correct serializer that is never included in the key would not fix the pipeline.

A key containing a lossy serializer would still collide.

The complete repair needs:

precise serialization

+

key integration

+

shared-cache reproduction

max_digits10 is about round-trip identity, not visual consistency

The new cache string may look less tidy.

It may contain:

  • more decimal digits,

  • scientific notation,

  • or values that appear overly precise to a human reader.

That is not a defect.

The string is internal cache material.

Its primary purpose is:

preserve the float value sufficiently
to prevent false equivalence

A cache-key serializer should be judged by its equivalence relation, not by how concise it appears in a log.


Canonical serialization requires an explicit domain

Even max_digits10 should be understood within its direct scope.

The merged tests cover ordinary finite activation values.

The PR does not establish a complete policy for:

  • NaN payload distinctions,

  • signaling versus quiet NaNs,

  • locale changes,

  • every special floating-point value,

  • or whether all such values are valid activation parameters.

The source uses the standard stream representation and sufficient finite-value precision.

The confirmed repair is:

distinct finite float32 parameters
used by the tested activations
no longer collapse through default six-digit precision

It is not a universal serialization theorem for every C++ floating-point object.


Locale is not addressed as a separate contract in this PR

std::stringstream formatting can be influenced by locale unless a locale is explicitly controlled.

The public patch does not introduce:

std::locale::classic()

or a raw-bit encoding.

No locale-induced cache failure was reported or reproduced.

The relevant defect was the default precision under the environment used by ONNX Runtime’s build and tests.

A future canonical-key design could make locale independence explicit.

That is outside the direct fix in #32048.


The key remains a structured string

The generic WebGPU cache-key builder combines:

program name

custom cache hint

workgroup state

dispatch state

uniform structure

tensor metadata

into one string.

Activation::ToString() is one component of that structure.

The patch does not replace the complete key with:

  • a hash of the WGSL source,

  • a serialized binary schema,

  • or a typed cache-key object.

It repairs the component whose precision was insufficient.

The broader string-key architecture remains.


Hashing the final WGSL would be another possible design

A cache could generate the entire shader first and use:

WGSL source hash

as part of its identity.

That would naturally include every textual code-generation difference.

Such a design also has tradeoffs.

  • Shader generation may need to happen before lookup.

  • Equivalent shaders with different formatting may receive different hashes.

  • Pipeline state outside the source still needs representation.

  • Resource layouts and compilation options may not be fully encoded by WGSL alone.

ONNX Runtime’s existing architecture builds identity from program metadata and custom hints.

PR #32048 repairs that architecture rather than replacing it.


A cache key is a proof of equivalence

When a runtime returns an existing pipeline for a new request, it is making an implicit claim:

The cached compiled artifact
is valid for this program configuration.

The key is the evidence for that claim.

A lossy parameter string weakened the evidence.

same rounded text

was treated as

same activation constant

But rounding agreement did not imply compiled-program agreement.

The fix strengthens the proof:

same high-precision activation serialization

→ same finite float32 parameters

→ same activation constant generation

within the covered domain.


This bug was not caused by floating-point arithmetic in the shader

The wrong result did not originate from:

  • precision loss during multiplication,

  • WebGPU’s treatment of fused operations,

  • GPU rounding mode,

  • or an inaccurate activation implementation.

Each shader could compute its own activation correctly.

The failure occurred before execution:

the runtime selected the shader compiled
for a different parameter value

This is a compiled-artifact identity bug involving floats, not an arithmetic-accuracy bug inside the selected artifact.


The wrong pipeline could remain valid at every structural boundary

The two parameterized shaders shared:

  • the same bindings,

  • the same tensor shapes,

  • the same tensor types,

  • the same workgroup size,

  • the same dispatch size,

  • the same program name,

  • and the same activation kind.

Only the embedded float differed.

That is why the pipeline could be accepted and executed without a device error.

The missing distinction existed entirely inside the arithmetic text.

Structural compatibility
→ true
Semantic compatibility
→ false

A cache key must protect both.


This fix may increase pipeline count for parameterized models

After the patch, each distinct activation parameter can produce a distinct pipeline key.

A model containing many LeakyRelu or Clip configurations may therefore create more entries than before.

But those parameters already produced potentially different WGSL.

The old lower count represented false sharing.

The patch does not publish measurements of:

  • pipeline-count increase,

  • initialization-time change,

  • memory use of the cache,

  • or model-level latency.

The direct result is correctness.

Any cache-efficiency tradeoff remains unquantified.


What PR #32048 directly changed for this second defect

The merged patch directly:

  • increases Activation::ToString() precision to max_digits10,

  • applies that fix centrally to all current users of the serializer,

  • adds direct tests for distinct LeakyRelu and Clip parameter values,

  • retains equality for identical activation configurations,

  • adds an end-to-end fused HardSigmoid regression,

  • uses a known old-key collision pair,

  • creates outputs that separate to zero and one,

  • and executes both insertion orders.

The activation serialization remains at max_digits10 in the merged source.


What the patch does not establish

The public evidence does not establish that:

  • every C++ float-to-string use in ONNX Runtime was unsafe,

  • every WebGPU program used Activation::ToString(),

  • every parameterized activation encountered a collision in ordinary models,

  • max_digits10 creates the smallest possible cache key,

  • the key now exactly equals WGSL textual identity,

  • NaN payloads receive a formally defined cache contract,

  • locale-independent serialization was added,

  • or the fix improves performance.

The supported conclusion is narrower:

The activation cache serializer used six significant digits while shader generation used six decimal places. Distinct float32 parameters could therefore produce different WGSL constants but the same pipeline key. The merged fix raises the cache serialization to max_digits10 and adds direct and end-to-end regressions.


The complete failure chain

The parameter collision can be written as:

Two HardSigmoid operations
        ↓
same activation kind
        ↓
different float32 alpha values
        ↓
WGSL generator uses `std::to_string`
        ↓
different constants appear in shader source
        ↓
cache serializer uses default stream precision
        ↓
both values become `1.00002e+06`
        ↓
same pipeline key
        ↓
first compiled shader reused
        ↓
second operation executes wrong alpha
        ↓
silent numerical mismatch

The repaired path is:

float32 parameter
        ↓
serialize with `max_digits10`
        ↓
distinct finite float values remain distinct
        ↓
different WGSL configurations receive different keys

The final lesson is that readable text is not necessarily identity

A human-readable string often answers:

Can a developer recognize approximately
what value this object contained?

A cache key must answer:

Can two objects safely share one compiled artifact?

Approximate formatting can satisfy the first question while failing the second.

1.00002e+06

→ readable approximation

It was not enough to distinguish:

1000015.0

from

1000025.0

even though the shader generator did distinguish them.

When floating-point values become compile-time constants, their cache representation must preserve the distinctions that survive into generated code.

ONNX Runtime #32048 fixed the parameter key not by changing the activation arithmetic, but by making the cache remember which arithmetic it had actually compiled.


Previous article

ONNX Runtime #32048 — Why WebGPU Reused the Wrong Shader When the Cache Key Omitted the Activation

Link the title above to Part 1 after publication.

Part 3: why isolated sessions could hide a shared-cache layout bug

The final article examines the third missing field:

is_channels_last

The flag changes generated WGSL bias indexing.

channels last

→ bias[col]
channels first

→ bias[row + i]

But two sequential sessions could each receive a fresh WebGPU context and a fresh cache, allowing both to pass independently.

The regression had to keep two sessions alive concurrently under one shared context_id.

Part 3 examines why cache correctness cannot be tested without reproducing the lifetime and ownership of the cache itself.


Related material


Patch status: Merged into ONNX Runtime main
Affected serializer: WebGPU Activation::ToString()
Old cache formatting: Default std::stringstream precision, six significant digits
WGSL formatting: std::to_string(), six digits after the decimal point in this path
Recorded collision: 1000015.0 and 1000025.0
Old cache string: Both became 1.00002e+06
WGSL constants: 1000015.000000 and 1000025.000000
Float distance: 160 float32 ULPs
Repair: std::numeric_limits<float>::max_digits10
Direct unit coverage: Distinct LeakyRelu alphas, distinct Clip bounds, identical activations, and distinct kinds
End-to-end coverage: Two fused HardSigmoid paths producing zero and one, tested in both cache insertion orders
Evidence boundary: Finite activation-parameter identity and pipeline correctness; no universal special-value or locale contract claimed

This is Part 2 of a three-part series on ONNX Runtime WebGPU pipeline-cache identity.

Part 1 examined why the activation kind had to appear in the key when its code was embedded into WGSL.

This article examined why activation presence was still insufficient when lossy float formatting could merge two different shader constants.

Part 3 examines why the layout-dependent cache collision required two simultaneously alive sessions rather than two isolated tests.

#ONNXRuntime #WebGPU #WGSL #PipelineCache #FloatingPoint #CacheKey #ShaderCompilation #HardSigmoid #CompilerCorrectness #EdgeAI #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