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 shaderBut 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 hintThat 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 sourceThe 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 digitsThe WGSL generator used std::to_string():
6 digits after the decimal pointThose are not the same decimal representation.
The PR recorded the following pair:
1000015.0
1000025.0Both values became the same old cache-key text:
1.00002e+06But the generated WGSL contained two different constants:
1000015.000000
1000025.000000The cache could therefore conclude:
same activation parameterwhile the shader generator concluded:
different literal constantThe 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` expressionFor Clip:
minimum and maximum
→ inserted into `clamp`For HardSigmoid:
alpha and beta
→ inserted into the multiply-add expressionA 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 = HardSigmoidTwo operations could still differ like this:
Operation A
kind:
HardSigmoid
alpha:
0.2
beta:
0.5Operation B
kind:
HardSigmoid
alpha:
0.25
beta:
0.5Their WGSL programs contain different arithmetic.
0.2 * value + 0.5versus:
0.25 * value + 0.5One 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 valuesActivation::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 digitsSignificant digits are counted across the entire number.
For a small value:
0.1234567six significant digits can preserve something close to:
0.123457For a large value near one million:
1000015the 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+061000025
→ 1.00002e+06The 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+06WGSL:
1000015.0
→ 1000015.000000
1000025.0
→ 1000025.000000The 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 apartAround one million, adjacent IEEE-754 float32 values are separated by:
0.0625The difference between the chosen values is:
1000025 - 1000015
=
10Therefore:
10 / 0.0625
=
160The 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 identityA valid cache key needs an implication in one direction
The required relationship is:
Different generated WGSL
→ different cache keyEquivalently:
Same cache key
→ same generated WGSLThe 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 separateThat creates a false cache hit.
The opposite relationship is safer:
Key serializer distinguishes two values
while
WGSL serializer happens to emit the same textThat 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 identityand:
different generated constant text
→ different parameter identityThat directly aligns the two representations.
The code-review discussion explicitly suggested either:
the same formatting as shader generationor:
a lossless representation of the raw float valueas 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_digits10For ordinary IEEE-754 float32, this is:
9 significant decimal digitsThat 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 stringThe two recorded values remain distinct:
1000015
1000025The 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.123457inside WGSL while producing different max_digits10 cache strings.
That creates:
two cache entries
for
the same emitted shader textThis is potentially less efficient than a perfectly canonical shader-text identity.
It is still correct.
Over-distinguishing programs
→ possible additional compilationUnder-distinguishing programs
→ wrong shader reuseThe 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 keyThat 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:
MatMulNaiveProgramThe 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 riskFixing 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 objectCache identity contract:
never equate two objects
that generate different programsThose 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.01HardSigmoid alpha
→ 0.2HardSigmoid beta
→ 0.5Clip bounds
→ -6 and 6Default six-significant-digit formatting distinguishes these values without difficulty.
A test suite using only common defaults can therefore show:
activation serialization workswhile 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.0was chosen because:
they are distinct float32 values
their WGSL decimal strings are different
their old cache strings are equalThe 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 smallor:
values will have no more than six meaningful digitsSuch 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.0alpha B
=
1000025.0and one shared beta:
beta
=
-8000160.0The test arranges for both convolutions to produce:
value
=
8before activation.
Now calculate the first activation.
1000015 × 8
=
80001208000120 - 8000160
=
-40After clamping:
HardSigmoid output A
=
0For the second:
1000025 × 8
=
80002008000200 - 8000160
=
40After clamping:
HardSigmoid output B
=
1The two shaders therefore produce maximally different results at that point.
Shader A
→ zeroShader B
→ oneIf one pipeline is incorrectly reused for both operations:
both outputs become zero
or
both outputs become onedepending 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 = 8Second path
weights contribute 2
bias contributes 6
total = 8This 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 secondand:
alpha B first
alpha A secondThis checks both possible cache-population outcomes.
Before the fix:
first shader under key K
→ can dominate second operationChanging 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.625These are distinct representable values.
Their strings must differ.
Distinct Clip bounds
1000000.0
1000000.0625The second activation parameter is varied.
Its strings must also differ.
Identical activations
same kind
same parametersTheir strings must remain equal.
Distinct activation kinds
ReLU
SigmoidTheir strings must differ.
The tests therefore protect both sides:
different compiled state
→ different identityand:
same compiled state
→ stable reusable identityThe 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 reproductionmax_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 equivalenceA 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 precisionIt 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 metadatainto 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 hashas 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 constantBut 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 generationwithin 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 valueThis 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
→ trueSemantic compatibility
→ falseA 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 tomax_digits10,applies that fix centrally to all current users of the serializer,
adds direct tests for distinct
LeakyReluandClipparameter values,retains equality for identical activation configurations,
adds an end-to-end fused
HardSigmoidregression,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_digits10creates 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_digits10and 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 mismatchThe repaired path is:
float32 parameter
↓
serialize with `max_digits10`
↓
distinct finite float values remain distinct
↓
different WGSL configurations receive different keysThe 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 approximationIt was not enough to distinguish:
1000015.0
from
1000025.0even 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_lastThe 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