From 30ff1d04fda8beaa51388a3861c14b2a200b27c5 Mon Sep 17 00:00:00 2001 From: Timo Stripf Date: Sun, 5 Jul 2026 08:54:00 +0000 Subject: [PATCH 1/8] Add RandomUniform-28 with deterministic Philox4x32-10 generator attribute Adds an optional 'generator' string attribute to RandomUniform (new opset-28 schema) so the operator can be made deterministic and testable: - generator="unspecified" (default): implementation-defined PRNG, exactly the previous behavior, with no determinism guarantee. - generator="philox4x32_10": fully specified Philox-4x32-10 counter-based generator (Salmon et al., SC'11) keyed with the 64-bit seed. Element i depends only on (seed, i), so outputs are order-independent and parallelizable. Values are produced in the target data type: one 32-bit word per element for bfloat16/float16/float, two words (res53) for double, with low + r * (high - low) evaluated in dtype under IEEE 754 round-to-nearest-even. seed is required in this mode (enforced by shape inference). The v22 schema is preserved in old.cc. The version converter upgrades 27->28 compatibly and downgrades 28->27 only for generator="unspecified" (attribute dropped). The reference implementation gains a vectorized _Philox4x32 verified against the Random123 known-answer vectors. First-ever backend node tests for RandomUniform (4 cases) generate expected outputs from an independent inline Philox implementation, cross-checking the reference runtime bit-exactly. Docs and backend test data regenerated. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PVuibCDPDmYP2zoMawf9sp Signed-off-by: Timo Stripf --- docs/Changelog.md | 96 ++++++++++ docs/Operators.md | 165 +++++++++++++++++- docs/TestCoverage.md | 105 ++++++++++- onnx/backend/test/case/node/randomuniform.py | 141 +++++++++++++++ .../node/test_randomuniform_philox/model.onnx | Bin 0 -> 156 bytes .../test_data_set_0/output_0.pb | 1 + .../model.onnx | Bin 0 -> 177 bytes .../test_data_set_0/output_0.pb | Bin 0 -> 75 bytes .../model.onnx | Bin 0 -> 172 bytes .../test_data_set_0/output_0.pb | Bin 0 -> 29 bytes .../model.onnx | Bin 0 -> 196 bytes .../test_data_set_0/output_0.pb | Bin 0 -> 35 bytes onnx/defs/doc_strings.cc | 59 +++++++ onnx/defs/doc_strings.h | 1 + onnx/defs/generator/defs.cc | 29 ++- onnx/defs/generator/old.cc | 26 +++ onnx/defs/operator_sets.h | 2 + onnx/reference/ops/_op_common_random.py | 130 ++++++++++++++ onnx/reference/ops/op_random_uniform.py | 12 +- onnx/test/reference_evaluator_test.py | 105 +++++++++++ onnx/test/schema_test.py | 12 ++ onnx/test/shape_inference_test.py | 53 ++++++ onnx/test/version_converter_test.py | 33 ++++ .../version_converter/adapters/CMakeLists.txt | 1 + .../adapters/random_uniform_28_27.h | 42 +++++ onnx/version_converter/convert.h | 4 + 26 files changed, 1005 insertions(+), 12 deletions(-) create mode 100644 onnx/backend/test/case/node/randomuniform.py create mode 100644 onnx/backend/test/data/node/test_randomuniform_philox/model.onnx create mode 100644 onnx/backend/test/data/node/test_randomuniform_philox/test_data_set_0/output_0.pb create mode 100644 onnx/backend/test/data/node/test_randomuniform_philox_double/model.onnx create mode 100644 onnx/backend/test/data/node/test_randomuniform_philox_double/test_data_set_0/output_0.pb create mode 100644 onnx/backend/test/data/node/test_randomuniform_philox_float16/model.onnx create mode 100644 onnx/backend/test/data/node/test_randomuniform_philox_float16/test_data_set_0/output_0.pb create mode 100644 onnx/backend/test/data/node/test_randomuniform_philox_low_high/model.onnx create mode 100644 onnx/backend/test/data/node/test_randomuniform_philox_low_high/test_data_set_0/output_0.pb create mode 100644 onnx/version_converter/adapters/random_uniform_28_27.h diff --git a/docs/Changelog.md b/docs/Changelog.md index 198aa0b73db..efa54885ff7 100644 --- a/docs/Changelog.md +++ b/docs/Changelog.md @@ -33093,6 +33093,102 @@ This version of the operator has been available since version 28 of the default
Constrain input and output types to float tensors.
+### **RandomUniform-28** + + Generate a tensor with random values drawn from a uniform distribution. The shape + of the tensor is specified by the `shape` argument and the range by `low` and `high`. + + The data type is specified by the 'dtype' argument. The 'dtype' argument must + be one of the data types specified in the 'DataType' enum field in the + TensorProto message. + + The `generator` attribute selects the pseudo-random number generator algorithm. + With the default value "unspecified", the choice of generator is left to the + implementation and no determinism guarantee is given: results may differ across + implementations and even across runs of the same implementation, even when + `seed` is specified. An implementation may produce reproducible results in this + mode (for example for a fixed `seed`), but it is not required to. Setting + `generator` to "philox4x32_10" fully specifies the generated values: given + the same `seed`, every conforming implementation must produce bit-identical + results, which makes the operator deterministic and testable. More algorithms + may be added in future opset versions. + + When `generator` is "philox4x32_10", the `seed` attribute must be specified and + the output is computed with the Philox-4x32 counter-based generator with 10 + rounds (Salmon et al., "Parallel random numbers: as easy as 1, 2, 3", SC'11), + using the standard constants M0 = 0xD2511F53, M1 = 0xCD9E8D57, W0 = 0x9E3779B9, + W1 = 0xBB67AE85. All arithmetic on counter, key, and output words is unsigned + 32-bit modular arithmetic: + 1. The key is derived from `seed`, truncated toward zero and converted to an + unsigned 64-bit integer (modulo 2^64): `key0 = seed & 0xFFFFFFFF` and + `key1 = (seed >> 32) & 0xFFFFFFFF`. + 2. Counter block `b` (a 64-bit block index) is the 128-bit counter + `(c0, c1, c2, c3) = (b & 0xFFFFFFFF, (b >> 32) & 0xFFFFFFFF, 0, 0)`. It is + encrypted to four 32-bit output words `w0, w1, w2, w3` by applying the + Philox round function 10 times with round keys `(k0, k1)`, starting at + `(key0, key1)` and incremented by `(W0, W1)` before every round except the + first. One round maps `(c0, c1, c2, c3)` to + `(hi1 XOR c1 XOR k0, lo1, hi0 XOR c3 XOR k1, lo0)`, where `hi0` and `lo0` + are the high and low 32 bits of the 64-bit product `M0 * c0`, and `hi1` and + `lo1` are those of `M1 * c2`. + 3. Output element `i` (in row-major order) draws a value `r` in the interval + [0, 1) whose resolution matches the precision of `dtype`. Let `p` be the + number of significand bits of `dtype`, including the implicit bit (8 for + bfloat16, 11 for float16, 24 for float, 53 for double): + - If `dtype` is double, element `i` uses words `a = w(2 * (i mod 2))` and + `b = w(2 * (i mod 2) + 1)` of block `floor(i / 2)` and forms + `r = (floor(a / 2^5) * 2^26 + floor(b / 2^6)) / 2^53`. + - Otherwise, element `i` uses word `a = w(i mod 4)` of block `floor(i / 4)` + and forms `r = floor(a / 2^(32-p)) / 2^p`, which is exactly representable + in `dtype`. + 4. The element value is `low + r * (high - low)`, where `low` and `high` are + first converted to `dtype` and the subtraction, multiplication, and + addition are performed in `dtype` with IEEE 754 round-to-nearest-even + semantics. Note that due to this rounding, the result may equal `high` for + low-precision types. + + Because Philox is counter-based, each output element depends only on `seed` + and its position `i`: elements can be computed independently, in any order, + or in parallel. + +#### Version + +This version of the operator has been available since version 28 of the default ONNX operator set. + +#### Attributes + +
+
dtype : int (default is 1)
+
The data type for the elements of the output tensor. If not specified, default is TensorProto::FLOAT.
+
generator : string (default is unspecified)
+
(Optional) The pseudo-random number generator algorithm. "unspecified" leaves the choice of generator to the implementation and provides no determinism guarantee: results may differ across implementations and even across runs of the same implementation, even when `seed` is specified (an implementation may produce reproducible results, but is not required to). "philox4x32_10" selects the fully specified Philox-4x32-10 counter-based algorithm described in the operator documentation, making the output deterministic for a given `seed`. More algorithms may be added in future opset versions.
+
high : float (default is 1.0)
+
Upper boundary of the output values.
+
low : float (default is 0.0)
+
Lower boundary of the output values.
+
seed : float
+
(Optional) Seed to the random generator, if not specified we will auto generate one. Must be specified when `generator` is "philox4x32_10".
+
shape : list of ints (required)
+
The shape of the output tensor.
+
+ +#### Inputs + + +#### Outputs + +
+
output : T
+
Output tensor of random values drawn from uniform distribution
+
+ +#### Type Constraints + +
+
T : tensor(bfloat16), tensor(float16), tensor(float), tensor(double)
+
Constrain output types to float tensors.
+
+ # ai.onnx.preview ## Version 1 of the 'ai.onnx.preview' operator set ### **ai.onnx.preview.FlexAttention-1** diff --git a/docs/Operators.md b/docs/Operators.md index d7aa4ec05bc..0e04fe76814 100644 --- a/docs/Operators.md +++ b/docs/Operators.md @@ -113,7 +113,7 @@ For an operator input/output's differentiability, it can be differentiable, |RNN|22, 14, 7, 1| |RandomNormal|22, 1| |RandomNormalLike|22, 1| -|RandomUniform|22, 1| +|RandomUniform|28, 22, 1| |RandomUniformLike|22, 1| |Reciprocal|13, 6, 1| |ReduceMax|20, 18, 13, 12, 11, 1| @@ -27515,23 +27515,74 @@ Other versions of this operator: 1 be one of the data types specified in the 'DataType' enum field in the TensorProto message. + The `generator` attribute selects the pseudo-random number generator algorithm. + With the default value "unspecified", the choice of generator is left to the + implementation and no determinism guarantee is given: results may differ across + implementations and even across runs of the same implementation, even when + `seed` is specified. An implementation may produce reproducible results in this + mode (for example for a fixed `seed`), but it is not required to. Setting + `generator` to "philox4x32_10" fully specifies the generated values: given + the same `seed`, every conforming implementation must produce bit-identical + results, which makes the operator deterministic and testable. More algorithms + may be added in future opset versions. + + When `generator` is "philox4x32_10", the `seed` attribute must be specified and + the output is computed with the Philox-4x32 counter-based generator with 10 + rounds (Salmon et al., "Parallel random numbers: as easy as 1, 2, 3", SC'11), + using the standard constants M0 = 0xD2511F53, M1 = 0xCD9E8D57, W0 = 0x9E3779B9, + W1 = 0xBB67AE85. All arithmetic on counter, key, and output words is unsigned + 32-bit modular arithmetic: + 1. The key is derived from `seed`, truncated toward zero and converted to an + unsigned 64-bit integer (modulo 2^64): `key0 = seed & 0xFFFFFFFF` and + `key1 = (seed >> 32) & 0xFFFFFFFF`. + 2. Counter block `b` (a 64-bit block index) is the 128-bit counter + `(c0, c1, c2, c3) = (b & 0xFFFFFFFF, (b >> 32) & 0xFFFFFFFF, 0, 0)`. It is + encrypted to four 32-bit output words `w0, w1, w2, w3` by applying the + Philox round function 10 times with round keys `(k0, k1)`, starting at + `(key0, key1)` and incremented by `(W0, W1)` before every round except the + first. One round maps `(c0, c1, c2, c3)` to + `(hi1 XOR c1 XOR k0, lo1, hi0 XOR c3 XOR k1, lo0)`, where `hi0` and `lo0` + are the high and low 32 bits of the 64-bit product `M0 * c0`, and `hi1` and + `lo1` are those of `M1 * c2`. + 3. Output element `i` (in row-major order) draws a value `r` in the interval + [0, 1) whose resolution matches the precision of `dtype`. Let `p` be the + number of significand bits of `dtype`, including the implicit bit (8 for + bfloat16, 11 for float16, 24 for float, 53 for double): + - If `dtype` is double, element `i` uses words `a = w(2 * (i mod 2))` and + `b = w(2 * (i mod 2) + 1)` of block `floor(i / 2)` and forms + `r = (floor(a / 2^5) * 2^26 + floor(b / 2^6)) / 2^53`. + - Otherwise, element `i` uses word `a = w(i mod 4)` of block `floor(i / 4)` + and forms `r = floor(a / 2^(32-p)) / 2^p`, which is exactly representable + in `dtype`. + 4. The element value is `low + r * (high - low)`, where `low` and `high` are + first converted to `dtype` and the subtraction, multiplication, and + addition are performed in `dtype` with IEEE 754 round-to-nearest-even + semantics. Note that due to this rounding, the result may equal `high` for + low-precision types. + + Because Philox is counter-based, each output element depends only on `seed` + and its position `i`: elements can be computed independently, in any order, + or in parallel. + #### Version -This version of the operator has been available since version 22 of the default ONNX operator set. +This version of the operator has been available since version 28 of the default ONNX operator set. -Other versions of this operator: 1 +Other versions of this operator: 1, 22 #### Attributes
dtype : int (default is 1)
The data type for the elements of the output tensor. If not specified, default is TensorProto::FLOAT.
+
generator : string (default is unspecified)
+
(Optional) The pseudo-random number generator algorithm. "unspecified" leaves the choice of generator to the implementation and provides no determinism guarantee: results may differ across implementations and even across runs of the same implementation, even when `seed` is specified (an implementation may produce reproducible results, but is not required to). "philox4x32_10" selects the fully specified Philox-4x32-10 counter-based algorithm described in the operator documentation, making the output deterministic for a given `seed`. More algorithms may be added in future opset versions.
high : float (default is 1.0)
Upper boundary of the output values.
low : float (default is 0.0)
Lower boundary of the output values.
seed : float
-
(Optional) Seed to the random generator, if not specified we will auto generate one.
+
(Optional) Seed to the random generator, if not specified we will auto generate one. Must be specified when `generator` is "philox4x32_10".
shape : list of ints (required)
The shape of the output tensor.
@@ -27554,6 +27605,112 @@ Other versions of this operator: 1 +#### Examples + +
+randomuniform_philox + +```python +node = onnx.helper.make_node( + "RandomUniform", + inputs=[], + outputs=["y"], + shape=[3, 4], + seed=42.0, + generator="philox4x32_10", +) + +y = philox_uniform(42, (3, 4), np.float32) +expect( + node, + inputs=[], + outputs=[y], + name="test_randomuniform_philox", +) +``` + +
+ + +
+randomuniform_philox_double + +```python +node = onnx.helper.make_node( + "RandomUniform", + inputs=[], + outputs=["y"], + dtype=onnx.TensorProto.DOUBLE, + shape=[2, 4], + seed=123.0, + generator="philox4x32_10", +) + +y = philox_uniform(123, (2, 4), np.float64) +expect( + node, + inputs=[], + outputs=[y], + name="test_randomuniform_philox_double", +) +``` + +
+ + +
+randomuniform_philox_float16 + +```python +node = onnx.helper.make_node( + "RandomUniform", + inputs=[], + outputs=["y"], + dtype=onnx.TensorProto.FLOAT16, + shape=[10], + seed=7.0, + generator="philox4x32_10", +) + +y = philox_uniform(7, (10,), np.float16) +expect( + node, + inputs=[], + outputs=[y], + name="test_randomuniform_philox_float16", +) +``` + +
+ + +
+randomuniform_philox_low_high + +```python +node = onnx.helper.make_node( + "RandomUniform", + inputs=[], + outputs=["y"], + low=5.0, + high=10.0, + shape=[2, 3], + seed=0.0, + generator="philox4x32_10", +) + +y = philox_uniform(0, (2, 3), np.float32, low=5.0, high=10.0) +expect( + node, + inputs=[], + outputs=[y], + name="test_randomuniform_philox_low_high", +) +``` + +
+ + ### **RandomUniformLike** Generate a tensor with random values drawn from a uniform distribution. diff --git a/docs/TestCoverage.md b/docs/TestCoverage.md index 3073d6dea79..62a2f1dcd14 100644 --- a/docs/TestCoverage.md +++ b/docs/TestCoverage.md @@ -6,7 +6,7 @@ * [Overall Test Coverage](#overall-test-coverage) # Node Test Coverage ## Summary -Node tests have covered 189/201 (94.03%, 5 generators excluded) common operators. +Node tests have covered 190/202 (94.06%, 4 generators excluded) common operators. Node tests have covered 1/1 (100.00%, 0 generators excluded) experimental operators. @@ -19846,6 +19846,106 @@ expect( +### RandomUniform +There are 4 test cases, listed as following: +
+randomuniform_philox + +```python +node = onnx.helper.make_node( + "RandomUniform", + inputs=[], + outputs=["y"], + shape=[3, 4], + seed=42.0, + generator="philox4x32_10", +) + +y = philox_uniform(42, (3, 4), np.float32) +expect( + node, + inputs=[], + outputs=[y], + name="test_randomuniform_philox", +) +``` + +
+
+randomuniform_philox_double + +```python +node = onnx.helper.make_node( + "RandomUniform", + inputs=[], + outputs=["y"], + dtype=onnx.TensorProto.DOUBLE, + shape=[2, 4], + seed=123.0, + generator="philox4x32_10", +) + +y = philox_uniform(123, (2, 4), np.float64) +expect( + node, + inputs=[], + outputs=[y], + name="test_randomuniform_philox_double", +) +``` + +
+
+randomuniform_philox_float16 + +```python +node = onnx.helper.make_node( + "RandomUniform", + inputs=[], + outputs=["y"], + dtype=onnx.TensorProto.FLOAT16, + shape=[10], + seed=7.0, + generator="philox4x32_10", +) + +y = philox_uniform(7, (10,), np.float16) +expect( + node, + inputs=[], + outputs=[y], + name="test_randomuniform_philox_float16", +) +``` + +
+
+randomuniform_philox_low_high + +```python +node = onnx.helper.make_node( + "RandomUniform", + inputs=[], + outputs=["y"], + low=5.0, + high=10.0, + shape=[2, 3], + seed=0.0, + generator="philox4x32_10", +) + +y = philox_uniform(0, (2, 3), np.float32, low=5.0, high=10.0) +expect( + node, + inputs=[], + outputs=[y], + name="test_randomuniform_philox_low_high", +) +``` + +
+ + ### Range There are 4 test cases, listed as following:
@@ -30721,9 +30821,6 @@ expect(node, inputs=[x, y], outputs=[z], name="test_xor_bcast4v4d") ### RandomNormalLike (random generator operator) -### RandomUniform (random generator operator) - - ### RandomUniformLike (random generator operator) diff --git a/onnx/backend/test/case/node/randomuniform.py b/onnx/backend/test/case/node/randomuniform.py new file mode 100644 index 00000000000..3018c816896 --- /dev/null +++ b/onnx/backend/test/case/node/randomuniform.py @@ -0,0 +1,141 @@ +# Copyright (c) ONNX Project Contributors +# +# SPDX-License-Identifier: Apache-2.0 +from __future__ import annotations + +import numpy as np + +import onnx +from onnx.backend.test.case.base import Base +from onnx.backend.test.case.node import expect + + +def philox_uniform(seed, shape, dtype, low=0.0, high=1.0): + """Independent implementation of RandomUniform with generator="philox4x32_10". + + Follows the operator specification: Philox-4x32-10 keyed with the 64-bit + seed, counter block b = (lo32(b), hi32(b), 0, 0), per-element values in + [0, 1) with a resolution matching the precision of `dtype` (two output + words per element for double, one otherwise), and + ``low + r * (high - low)`` evaluated in `dtype`. Kept separate from + onnx.reference so the generated test data cross-checks the reference + implementation. + """ + m0, m1 = 0xD2511F53, 0xCD9E8D57 + w0, w1 = 0x9E3779B9, 0xBB67AE85 + seed = int(seed) & 0xFFFFFFFFFFFFFFFF + key0, key1 = seed & 0xFFFFFFFF, seed >> 32 + + def block(b): + c = [b & 0xFFFFFFFF, (b >> 32) & 0xFFFFFFFF, 0, 0] + k0, k1 = key0, key1 + for r in range(10): + if r > 0: + k0 = (k0 + w0) & 0xFFFFFFFF + k1 = (k1 + w1) & 0xFFFFFFFF + p0 = m0 * c[0] + p1 = m1 * c[2] + c = [ + (p1 >> 32) ^ c[1] ^ k0, + p1 & 0xFFFFFFFF, + (p0 >> 32) ^ c[3] ^ k1, + p0 & 0xFFFFFFFF, + ] + return c + + num = int(np.prod(shape)) + if np.dtype(dtype) == np.float64: + r = [] + for i in range(num): + w = block(i // 2) + a, b = w[2 * (i % 2)], w[2 * (i % 2) + 1] + r.append(((a >> 5) * 67108864.0 + (b >> 6)) / 9007199254740992.0) + else: + p = np.finfo(dtype).nmant + 1 + r = [(block(i // 4)[i % 4] >> (32 - p)) / (1 << p) for i in range(num)] + r = np.array(r, dtype=np.float64).reshape(shape).astype(dtype) + low = np.asarray(low, dtype=dtype) + high = np.asarray(high, dtype=dtype) + return r * (high - low) + low + + +class RandomUniform(Base): + @staticmethod + def export_randomuniform_philox() -> None: + node = onnx.helper.make_node( + "RandomUniform", + inputs=[], + outputs=["y"], + shape=[3, 4], + seed=42.0, + generator="philox4x32_10", + ) + + y = philox_uniform(42, (3, 4), np.float32) + expect( + node, + inputs=[], + outputs=[y], + name="test_randomuniform_philox", + ) + + @staticmethod + def export_randomuniform_philox_low_high() -> None: + node = onnx.helper.make_node( + "RandomUniform", + inputs=[], + outputs=["y"], + low=5.0, + high=10.0, + shape=[2, 3], + seed=0.0, + generator="philox4x32_10", + ) + + y = philox_uniform(0, (2, 3), np.float32, low=5.0, high=10.0) + expect( + node, + inputs=[], + outputs=[y], + name="test_randomuniform_philox_low_high", + ) + + @staticmethod + def export_randomuniform_philox_double() -> None: + node = onnx.helper.make_node( + "RandomUniform", + inputs=[], + outputs=["y"], + dtype=onnx.TensorProto.DOUBLE, + shape=[2, 4], + seed=123.0, + generator="philox4x32_10", + ) + + y = philox_uniform(123, (2, 4), np.float64) + expect( + node, + inputs=[], + outputs=[y], + name="test_randomuniform_philox_double", + ) + + @staticmethod + def export_randomuniform_philox_float16() -> None: + node = onnx.helper.make_node( + "RandomUniform", + inputs=[], + outputs=["y"], + dtype=onnx.TensorProto.FLOAT16, + shape=[10], + seed=7.0, + generator="philox4x32_10", + ) + + y = philox_uniform(7, (10,), np.float16) + expect( + node, + inputs=[], + outputs=[y], + name="test_randomuniform_philox_float16", + ) diff --git a/onnx/backend/test/data/node/test_randomuniform_philox/model.onnx b/onnx/backend/test/data/node/test_randomuniform_philox/model.onnx new file mode 100644 index 0000000000000000000000000000000000000000..3146c5877053cb5f221db698d5f004f906870b65 GIT binary patch literal 156 zcmd€ú•=f…®>!Û|?Øt§>Z? œè>lS?]‡(?mM?¥ F? \ No newline at end of file diff --git a/onnx/backend/test/data/node/test_randomuniform_philox_double/model.onnx b/onnx/backend/test/data/node/test_randomuniform_philox_double/model.onnx new file mode 100644 index 0000000000000000000000000000000000000000..27c02b37202f11138377c64fbdd3069e0f4854af GIT binary patch literal 177 zcmdJvA@2D6u5JNQt)~BQqzz!lc63DBjRu0VA^(9~Vn;YHEro1H(6`1&oY9 zK5KDCVnM0{lLN~FMs^_ukWKMLU<*ni7Q{mgiBHKdP0C4466Rv86yoFJ;ouhH;9}xn N0%8^?7A^(>834C8FRTCn literal 0 HcmV?d00001 diff --git a/onnx/backend/test/data/node/test_randomuniform_philox_double/test_data_set_0/output_0.pb b/onnx/backend/test/data/node/test_randomuniform_philox_double/test_data_set_0/output_0.pb new file mode 100644 index 0000000000000000000000000000000000000000..1b972d8f9a22e09cc99e0d904b2dae034789fda6 GIT binary patch literal 75 zcmV-R0JQ%I0tf^U3qk>TN|+f=RbM?^@##_ST;&<*{=08AR{=?K}zCRtHW{bcx>p$iQBd`Df literal 0 HcmV?d00001 diff --git a/onnx/backend/test/data/node/test_randomuniform_philox_float16/model.onnx b/onnx/backend/test/data/node/test_randomuniform_philox_float16/model.onnx new file mode 100644 index 0000000000000000000000000000000000000000..09c7284b778ebd82550f47d71b2ac72511b45a26 GIT binary patch literal 172 zcmdJvA@2D6u5JNQt)~BQqzz!lc63DBjRu0VA^(9~Vn;YHEro1H%J{1&oX! z&BYms1*r}|&Fn&oAbaAAz{ZzCjE{#H5ucWmpIBmOmc-A+SSiHC#lgWP#KOhI!R5rl I#ULO90L^wUFaQ7m literal 0 HcmV?d00001 diff --git a/onnx/backend/test/data/node/test_randomuniform_philox_float16/test_data_set_0/output_0.pb b/onnx/backend/test/data/node/test_randomuniform_philox_float16/test_data_set_0/output_0.pb new file mode 100644 index 0000000000000000000000000000000000000000..243846e3fb33cb131a824f72ad29eb8064a21162 GIT binary patch literal 29 kcmdU8*Z=?k literal 0 HcmV?d00001 diff --git a/onnx/backend/test/data/node/test_randomuniform_philox_low_high/model.onnx b/onnx/backend/test/data/node/test_randomuniform_philox_low_high/model.onnx new file mode 100644 index 0000000000000000000000000000000000000000..269ce5a1ea7b0310de69f7f81b068a1ad96a6f58 GIT binary patch literal 196 zcmXAiPYZ%D0L7g%TEj^!x)p>+A?V+!ll2jT&SU0MNjI5B`fYaaOE&ZH4!`%{{Scvk z!XGao(`_r7_WT97rl1eWjmuP3cUe4@uA()bWkL$gTctx%=S8WyQ+GVD{muXw1=!1r zEMGd#=3)Sd2!^HlSTyl~c5Z}7E!*bLlP2f2h}{?(Fs0$3X8Lb@k482+n7v`cb+W*q Trvdg6pa{FjwSiX;JM+~K%ZxH! literal 0 HcmV?d00001 diff --git a/onnx/backend/test/data/node/test_randomuniform_philox_low_high/test_data_set_0/output_0.pb b/onnx/backend/test/data/node/test_randomuniform_philox_low_high/test_data_set_0/output_0.pb new file mode 100644 index 0000000000000000000000000000000000000000..3cb00bf9bd3c012446d875f8d8550316225a232a GIT binary patch literal 35 rcmd;J;$RkFbYiUZlK6Pzz5`#Om}A;)E=QLF2FHfuvW^b#Z#n<~r)CRr literal 0 HcmV?d00001 diff --git a/onnx/defs/doc_strings.cc b/onnx/defs/doc_strings.cc index abab3015f08..49ace9ba9fb 100644 --- a/onnx/defs/doc_strings.cc +++ b/onnx/defs/doc_strings.cc @@ -154,6 +154,64 @@ be one of the data types specified in the 'DataType' enum field in the TensorProto message. )DOC"; +const char kDoc_RandomUniform_ver28[] = R"DOC( +Generate a tensor with random values drawn from a uniform distribution. The shape +of the tensor is specified by the `shape` argument and the range by `low` and `high`. + +The data type is specified by the 'dtype' argument. The 'dtype' argument must +be one of the data types specified in the 'DataType' enum field in the +TensorProto message. + +The `generator` attribute selects the pseudo-random number generator algorithm. +With the default value "unspecified", the choice of generator is left to the +implementation and no determinism guarantee is given: results may differ across +implementations and even across runs of the same implementation, even when +`seed` is specified. An implementation may produce reproducible results in this +mode (for example for a fixed `seed`), but it is not required to. Setting +`generator` to "philox4x32_10" fully specifies the generated values: given +the same `seed`, every conforming implementation must produce bit-identical +results, which makes the operator deterministic and testable. More algorithms +may be added in future opset versions. + +When `generator` is "philox4x32_10", the `seed` attribute must be specified and +the output is computed with the Philox-4x32 counter-based generator with 10 +rounds (Salmon et al., "Parallel random numbers: as easy as 1, 2, 3", SC'11), +using the standard constants M0 = 0xD2511F53, M1 = 0xCD9E8D57, W0 = 0x9E3779B9, +W1 = 0xBB67AE85. All arithmetic on counter, key, and output words is unsigned +32-bit modular arithmetic: +1. The key is derived from `seed`, truncated toward zero and converted to an + unsigned 64-bit integer (modulo 2^64): `key0 = seed & 0xFFFFFFFF` and + `key1 = (seed >> 32) & 0xFFFFFFFF`. +2. Counter block `b` (a 64-bit block index) is the 128-bit counter + `(c0, c1, c2, c3) = (b & 0xFFFFFFFF, (b >> 32) & 0xFFFFFFFF, 0, 0)`. It is + encrypted to four 32-bit output words `w0, w1, w2, w3` by applying the + Philox round function 10 times with round keys `(k0, k1)`, starting at + `(key0, key1)` and incremented by `(W0, W1)` before every round except the + first. One round maps `(c0, c1, c2, c3)` to + `(hi1 XOR c1 XOR k0, lo1, hi0 XOR c3 XOR k1, lo0)`, where `hi0` and `lo0` + are the high and low 32 bits of the 64-bit product `M0 * c0`, and `hi1` and + `lo1` are those of `M1 * c2`. +3. Output element `i` (in row-major order) draws a value `r` in the interval + [0, 1) whose resolution matches the precision of `dtype`. Let `p` be the + number of significand bits of `dtype`, including the implicit bit (8 for + bfloat16, 11 for float16, 24 for float, 53 for double): + - If `dtype` is double, element `i` uses words `a = w(2 * (i mod 2))` and + `b = w(2 * (i mod 2) + 1)` of block `floor(i / 2)` and forms + `r = (floor(a / 2^5) * 2^26 + floor(b / 2^6)) / 2^53`. + - Otherwise, element `i` uses word `a = w(i mod 4)` of block `floor(i / 4)` + and forms `r = floor(a / 2^(32-p)) / 2^p`, which is exactly representable + in `dtype`. +4. The element value is `low + r * (high - low)`, where `low` and `high` are + first converted to `dtype` and the subtraction, multiplication, and + addition are performed in `dtype` with IEEE 754 round-to-nearest-even + semantics. Note that due to this rounding, the result may equal `high` for + low-precision types. + +Because Philox is counter-based, each output element depends only on `seed` +and its position `i`: elements can be computed independently, in any order, +or in parallel. +)DOC"; + const char kDoc_DequantizeLinear_ver24[] = R"DOC( The linear dequantization operator. It consumes a quantized tensor, a scale, and a zero point to compute the full-precision tensor. The dequantization formula is `y = (x - x_zero_point) * x_scale`. `x_scale` and `x_zero_point` @@ -1318,6 +1376,7 @@ const char kDoc_Squeeze_ver24[] = ""; const char kDoc_MaxUnpool_ver11[] = ""; const char kDoc_Size_ver24[] = ""; const char kDoc_RandomUniform_ver1[] = ""; +const char kDoc_RandomUniform_ver28[] = ""; const char kDoc_Range_ver11[] = ""; const char kDoc_Range_ver27[] = ""; const char kDoc_DequantizeLinear_ver24[] = ""; diff --git a/onnx/defs/doc_strings.h b/onnx/defs/doc_strings.h index 5ebb7875924..ab3d5d7cc87 100644 --- a/onnx/defs/doc_strings.h +++ b/onnx/defs/doc_strings.h @@ -54,6 +54,7 @@ extern const char kDoc_PRelu_ver7[]; extern const char kDoc_RandomNormal_ver1[]; extern const char kDoc_RandomNormalLike_ver1[]; extern const char kDoc_RandomUniform_ver1[]; +extern const char kDoc_RandomUniform_ver28[]; extern const char kDoc_Range_ver11[]; extern const char kDoc_Range_ver27[]; extern const char kDoc_RandomUniformLike_ver1[]; diff --git a/onnx/defs/generator/defs.cc b/onnx/defs/generator/defs.cc index 4272b7b8f8c..cd02cda12c4 100644 --- a/onnx/defs/generator/defs.cc +++ b/onnx/defs/generator/defs.cc @@ -150,16 +150,28 @@ ONNX_OPERATOR_SET_SCHEMA( ONNX_OPERATOR_SET_SCHEMA( RandomUniform, - 22, + 28, OpSchema() - .SetDoc(kDoc_RandomUniform_ver1) + .SetDoc(kDoc_RandomUniform_ver28) .Attr("low", "Lower boundary of the output values.", AttributeProto::FLOAT, 0.0f) .Attr("high", "Upper boundary of the output values.", AttributeProto::FLOAT, 1.0f) .Attr( "seed", - "(Optional) Seed to the random generator, if not specified we will auto generate one.", + "(Optional) Seed to the random generator, if not specified we will auto generate one. " + "Must be specified when `generator` is \"philox4x32_10\".", AttributeProto::FLOAT, OPTIONAL_VALUE) + .Attr( + "generator", + "(Optional) The pseudo-random number generator algorithm. \"unspecified\" leaves the choice of " + "generator to the implementation and provides no determinism guarantee: results may differ " + "across implementations and even across runs of the same implementation, even when `seed` is " + "specified (an implementation may produce reproducible results, but is not required to). " + "\"philox4x32_10\" selects the fully specified Philox-4x32-10 counter-based algorithm described " + "in the operator documentation, making the output deterministic for a given `seed`. More " + "algorithms may be added in future opset versions.", + AttributeProto::STRING, + std::string("unspecified")) .Attr( "dtype", "The data type for the elements of the output tensor. If not specified, default is TensorProto::FLOAT.", @@ -170,6 +182,17 @@ ONNX_OPERATOR_SET_SCHEMA( .TypeConstraint("T", OpSchema::all_float_types_ir4(), "Constrain output types to float tensors.") .SetNodeDeterminism(OpSchema::NodeDeterminism::NonDeterministic) .TypeAndShapeInferenceFunction([](InferenceContext& ctx) { + const auto* generator_attr = ctx.getAttribute("generator"); + if (generator_attr != nullptr) { + const std::string& generator = generator_attr->s(); + if (generator != "unspecified" && generator != "philox4x32_10") { + fail_shape_inference( + "Attribute 'generator' must be one of 'unspecified' or 'philox4x32_10', got '", generator, "'."); + } + if (generator != "unspecified" && ctx.getAttribute("seed") == nullptr) { + fail_shape_inference("Attribute 'seed' must be specified when 'generator' is '", generator, "'."); + } + } propagateElemTypeFromAttributeToOutput(ctx, "dtype", 0, TensorProto::FLOAT); propagateShapeFromAttributeToOutput(ctx, "shape", 0); })); diff --git a/onnx/defs/generator/old.cc b/onnx/defs/generator/old.cc index 0d2c356425f..6e50f0d64e2 100644 --- a/onnx/defs/generator/old.cc +++ b/onnx/defs/generator/old.cc @@ -229,6 +229,32 @@ ONNX_OPERATOR_SET_SCHEMA( propagateShapeFromAttributeToOutput(ctx, "shape", 0); })); +ONNX_OPERATOR_SET_SCHEMA( + RandomUniform, + 22, + OpSchema() + .SetDoc(kDoc_RandomUniform_ver1) + .Attr("low", "Lower boundary of the output values.", AttributeProto::FLOAT, 0.0f) + .Attr("high", "Upper boundary of the output values.", AttributeProto::FLOAT, 1.0f) + .Attr( + "seed", + "(Optional) Seed to the random generator, if not specified we will auto generate one.", + AttributeProto::FLOAT, + OPTIONAL_VALUE) + .Attr( + "dtype", + "The data type for the elements of the output tensor. If not specified, default is TensorProto::FLOAT.", + AttributeProto::INT, + static_cast(TensorProto::FLOAT)) + .Attr("shape", "The shape of the output tensor.", AttributeProto::INTS) + .Output(0, "output", "Output tensor of random values drawn from uniform distribution", "T") + .TypeConstraint("T", OpSchema::all_float_types_ir4(), "Constrain output types to float tensors.") + .SetNodeDeterminism(OpSchema::NodeDeterminism::NonDeterministic) + .TypeAndShapeInferenceFunction([](InferenceContext& ctx) { + propagateElemTypeFromAttributeToOutput(ctx, "dtype", 0, TensorProto::FLOAT); + propagateShapeFromAttributeToOutput(ctx, "shape", 0); + })); + ONNX_OPERATOR_SET_SCHEMA( RandomUniform, 1, diff --git a/onnx/defs/operator_sets.h b/onnx/defs/operator_sets.h index de677584826..d429656b408 100644 --- a/onnx/defs/operator_sets.h +++ b/onnx/defs/operator_sets.h @@ -1471,12 +1471,14 @@ class OpSet_Onnx_ver27 { // Forward declarations for ai.onnx version 28 class ONNX_OPERATOR_SET_SCHEMA_CLASS_NAME(Onnx, 28, Celu); +class ONNX_OPERATOR_SET_SCHEMA_CLASS_NAME(Onnx, 28, RandomUniform); // Iterate over schema from ai.onnx version 28 class OpSet_Onnx_ver28 { public: static void ForEachSchema(const std::function& fn) { fn(GetOpSchema()); + fn(GetOpSchema()); } }; diff --git a/onnx/reference/ops/_op_common_random.py b/onnx/reference/ops/_op_common_random.py index c7b011e2929..e5b31900d6e 100644 --- a/onnx/reference/ops/_op_common_random.py +++ b/onnx/reference/ops/_op_common_random.py @@ -9,6 +9,106 @@ from onnx.reference.op_run import OpRun +class _Philox4x32: + """Philox-4x32-10 counter-based PRNG. + + Implements the Philox-4x32 generator with 10 rounds and the standard + constants from Salmon et al., "Parallel random numbers: as easy as + 1, 2, 3" (SC'11), as also distributed in the Random123 library. This is + the algorithm selected by the ``generator="philox4x32_10"`` attribute of + the random operators, which fully specifies their output for a given + seed. Being counter-based, every output word depends only on the key + (derived from the seed) and the block index, so elements can be computed + independently and in parallel. + """ + + _M0 = 0xD2511F53 + _M1 = 0xCD9E8D57 + _W0 = 0x9E3779B9 + _W1 = 0xBB67AE85 + + def __init__(self, seed: int): + seed &= 0xFFFFFFFFFFFFFFFF + self._key0 = seed & 0xFFFFFFFF + self._key1 = seed >> 32 + + @classmethod + def philox4x32_10(cls, c0, c1, c2, c3, key0: int, key1: int): + """Encrypt 128-bit counters (four uint32 arrays) with 10 Philox rounds. + + Returns the four 32-bit output words per counter. The round keys start + at ``(key0, key1)`` and are incremented by ``(W0, W1)`` before every + round except the first. + """ + mask = np.uint64(0xFFFFFFFF) + c0 = np.asarray(c0, dtype=np.uint64) + c1 = np.asarray(c1, dtype=np.uint64) + c2 = np.asarray(c2, dtype=np.uint64) + c3 = np.asarray(c3, dtype=np.uint64) + k0, k1 = key0, key1 + for r in range(10): + if r > 0: + k0 = (k0 + cls._W0) & 0xFFFFFFFF + k1 = (k1 + cls._W1) & 0xFFFFFFFF + p0 = np.uint64(cls._M0) * c0 + p1 = np.uint64(cls._M1) * c2 + c0, c1, c2, c3 = ( + (p1 >> np.uint64(32)) ^ c1 ^ np.uint64(k0), + p1 & mask, + (p0 >> np.uint64(32)) ^ c3 ^ np.uint64(k1), + p0 & mask, + ) + return ( + c0.astype(np.uint32), + c1.astype(np.uint32), + c2.astype(np.uint32), + c3.astype(np.uint32), + ) + + def _blocks(self, num_blocks: int): + """Output words of counter blocks 0 .. num_blocks-1. + + Block ``b`` uses the counter ``(lo32(b), hi32(b), 0, 0)``. + """ + b = np.arange(num_blocks, dtype=np.uint64) + zero = np.zeros(num_blocks, dtype=np.uint64) + return self.philox4x32_10( + b & np.uint64(0xFFFFFFFF), + b >> np.uint64(32), + zero, + zero, + self._key0, + self._key1, + ) + + def random_res53(self, num: int) -> np.ndarray: + """Draw `num` doubles in [0, 1) with 53-bit resolution. + + Element `i` combines words ``2*(i mod 2)`` and ``2*(i mod 2) + 1`` of + block ``i // 2`` as ``(floor(a / 2^5) * 2^26 + floor(b / 2^6)) / 2^53``. + """ + num_blocks = (num + 1) // 2 + w0, w1, w2, w3 = self._blocks(num_blocks) + a = np.stack([w0, w2], axis=1).reshape(-1)[:num] >> np.uint32(5) + b = np.stack([w1, w3], axis=1).reshape(-1)[:num] >> np.uint32(6) + return (a.astype(np.float64) * 67108864.0 + b.astype(np.float64)) * ( + 1.0 / 9007199254740992.0 + ) + + def random_res(self, num: int, precision: int) -> np.ndarray: + """Draw `num` values in [0, 1) with `precision` significand bits. + + Element `i` uses word ``i mod 4`` of block ``i // 4``: + ``(w >> (32 - p)) / 2^p``. The results are exactly representable in + any binary float type with at least `precision` significand bits. + """ + num_blocks = (num + 3) // 4 + w0, w1, w2, w3 = self._blocks(num_blocks) + words = np.stack([w0, w1, w2, w3], axis=1).reshape(-1)[:num] + scale = 1.0 / (1 << precision) + return (words >> np.uint32(32 - precision)).astype(np.float64) * scale + + class _CommonRandom(OpRun): def __init__(self, onnx_node, run_params): OpRun.__init__(self, onnx_node, run_params) @@ -53,3 +153,33 @@ def _get_state(seed): else: state = np.random.RandomState(seed=int(seed)) return state + + @staticmethod + def _deterministic_uniform(generator, seed, shape, dtype): + """Draw uniform values in [0, 1) with the fully specified generator. + + Unlike the "unspecified" generator, the result is bit-identical across + implementations for a given seed (see the operator specification). + The resolution of the values matches the precision of `dtype`: double + combines two 32-bit output words per element, all other float types + use one word per element, keeping every value exactly representable + in `dtype`. + """ + if generator != "philox4x32_10": + raise ValueError( + f"Unsupported value {generator!r} for attribute 'generator' " + f"(expected 'unspecified' or 'philox4x32_10')." + ) + if seed is None or np.isnan(seed): + raise ValueError( + "Attribute 'seed' must be specified when 'generator' is " + "'philox4x32_10'." + ) + state = _Philox4x32(int(seed)) + num = int(np.prod(shape)) + if np.dtype(dtype) == np.float64: + res = state.random_res53(num) + else: + precision = np.finfo(dtype).nmant + 1 + res = state.random_res(num, precision) + return res.reshape(shape).astype(dtype) diff --git a/onnx/reference/ops/op_random_uniform.py b/onnx/reference/ops/op_random_uniform.py index be6a74b3ac2..b5f9c170924 100644 --- a/onnx/reference/ops/op_random_uniform.py +++ b/onnx/reference/ops/op_random_uniform.py @@ -3,12 +3,22 @@ # SPDX-License-Identifier: Apache-2.0 from __future__ import annotations +import numpy as np + from onnx.reference.ops._op_common_random import _CommonRandom class RandomUniform(_CommonRandom): - def _run(self, dtype=None, high=None, low=None, seed=None, shape=None): + def _run( + self, dtype=None, generator=None, high=None, low=None, seed=None, shape=None + ): dtype = self._dtype(dtype=dtype) + if generator not in (None, "unspecified"): + res = self._deterministic_uniform(generator, seed, shape, dtype) + # low + r * (high - low), evaluated in the target data type + low_t = np.asarray(low, dtype=dtype) + high_t = np.asarray(high, dtype=dtype) + return (res * (high_t - low_t) + low_t,) state = self._get_state(seed) res = state.rand(*shape).astype(dtype) res *= high - low diff --git a/onnx/test/reference_evaluator_test.py b/onnx/test/reference_evaluator_test.py index c2cefbca4c9..309670edbcd 100644 --- a/onnx/test/reference_evaluator_test.py +++ b/onnx/test/reference_evaluator_test.py @@ -1477,6 +1477,111 @@ def test_onnxt_runtime_random_uniform(self): self.assertGreater(got.min(), 0) self.assertLess(got.max(), 1) + def test_onnxt_runtime_random_uniform_philox(self): + Y = make_tensor_value_info("Y", TensorProto.FLOAT, [None]) + node1 = make_node( + "RandomUniform", + [], + ["Y"], + seed=42.0, + shape=[2, 3], + generator="philox4x32_10", + ) + graph = make_graph([node1], "g", [], [Y]) + onnx_model = make_model(graph) + check_model(onnx_model) + sess = ReferenceEvaluator(onnx_model) + got = sess.run(None, {})[0] + # For float32, element i uses word (i mod 4) of Philox-4x32-10 block + # (i // 4) with key (42, 0): r = (w >> 8) / 2^24. Word stream produced + # by the canonical Random123 implementation. + expected = np.array( + [ + [0.61295986, 0.4685865, 0.0732317], + [0.3408615, 0.98771864, 0.32706332], + ], + dtype=np.float32, + ) + assert_allclose(got, expected, rtol=0, atol=0) + self.assertEqual(got.dtype, np.float32) + # A second run must produce bit-identical values. + assert_allclose(sess.run(None, {})[0], expected, rtol=0, atol=0) + + def test_onnxt_runtime_random_uniform_philox_low_high(self): + Y = make_tensor_value_info("Y", TensorProto.DOUBLE, [None]) + node1 = make_node( + "RandomUniform", + [], + ["Y"], + seed=42.0, + low=5.0, + high=10.0, + dtype=TensorProto.DOUBLE, + shape=[3], + generator="philox4x32_10", + ) + graph = make_graph([node1], "g", [], [Y]) + onnx_model = make_model(graph) + check_model(onnx_model) + sess = ReferenceEvaluator(onnx_model) + got = sess.run(None, {})[0] + # For double, element i combines words 2*(i mod 2) and 2*(i mod 2)+1 + # of Philox-4x32-10 block (i // 2) with key (42, 0) via + # r = ((a >> 5) * 2^26 + (b >> 6)) / 2^53. + expected = 5.0 + np.array( + [0.6129598801477738, 0.07323173687503892, 0.9877186516453577], + dtype=np.float64, + ) * (10.0 - 5.0) + assert_allclose(got, expected, rtol=0, atol=0) + self.assertEqual(got.dtype, np.float64) + + def test_onnxt_runtime_random_uniform_philox_no_seed_raises(self): + Y = make_tensor_value_info("Y", TensorProto.FLOAT, [None]) + node1 = make_node( + "RandomUniform", [], ["Y"], shape=[2, 3], generator="philox4x32_10" + ) + graph = make_graph([node1], "g", [], [Y]) + onnx_model = make_model(graph) + sess = ReferenceEvaluator(onnx_model) + with self.assertRaises(ValueError): + sess.run(None, {}) + + def test_philox4x32_10_known_answer_vectors(self): + # Known-answer vectors from the Random123 distribution + # (tests/kat_vectors, "philox4x32 10" entries): counter and key words + # followed by the expected four output words. + from onnx.reference.ops._op_common_random import ( # noqa: PLC0415 + _Philox4x32, + ) + + kat_vectors = [ + ( + (0x00000000, 0x00000000, 0x00000000, 0x00000000), + (0x00000000, 0x00000000), + (0x6627E8D5, 0xE169C58D, 0xBC57AC4C, 0x9B00DBD8), + ), + ( + (0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF), + (0xFFFFFFFF, 0xFFFFFFFF), + (0x408F276D, 0x41C83B0E, 0xA20BC7C6, 0x6D5451FD), + ), + ( + (0x243F6A88, 0x85A308D3, 0x13198A2E, 0x03707344), + (0xA4093822, 0x299F31D0), + (0xD16CFE09, 0x94FDCCEB, 0x5001E420, 0x24126EA1), + ), + ] + for counter, key, expected in kat_vectors: + out = _Philox4x32.philox4x32_10( + np.uint32([counter[0]]), + np.uint32([counter[1]]), + np.uint32([counter[2]]), + np.uint32([counter[3]]), + key[0], + key[1], + ) + self.assertEqual(tuple(int(w[0]) for w in out), expected) + def test_onnxt_runtime_random_uniform_like(self): X = make_tensor_value_info("X", TensorProto.FLOAT, [None]) Y = make_tensor_value_info("Y", TensorProto.FLOAT, [None]) diff --git a/onnx/test/schema_test.py b/onnx/test/schema_test.py index f135f56fa9b..62a593476f3 100644 --- a/onnx/test/schema_test.py +++ b/onnx/test/schema_test.py @@ -78,6 +78,18 @@ def allowed(schema): self.assertTrue(celu28.has_function) self.assertEqual(allowed(defs.get_schema("Celu", 12)), {"tensor(float)"}) + def test_randomuniform_generator_attribute(self) -> None: + schema28 = defs.get_schema("RandomUniform", 28) + self.assertIn("generator", schema28.attributes) + generator = schema28.attributes["generator"] + self.assertEqual(generator.type, defs.OpSchema.AttrType.STRING) + self.assertEqual(generator.default_value.s, b"unspecified") + self.assertFalse(generator.required) + # The operator stays non-deterministic at the schema level: with the + # "unspecified" generator the output is still implementation-defined. + self.assertTrue(schema28.non_deterministic) + self.assertNotIn("generator", defs.get_schema("RandomUniform", 22).attributes) + def test_range_supported_types(self) -> None: """Test Range operator supports all expected numeric types.""" range_schema = defs.get_schema("Range") diff --git a/onnx/test/shape_inference_test.py b/onnx/test/shape_inference_test.py index a2a500d8425..f57c8f88c8e 100644 --- a/onnx/test/shape_inference_test.py +++ b/onnx/test/shape_inference_test.py @@ -4404,6 +4404,59 @@ def test_random_normal(self) -> None: graph, [make_tensor_value_info("out", TensorProto.DOUBLE, (3, 4, 5))] ) + def test_random_uniform_philox(self) -> None: + graph = self._make_graph( + [], + [ + make_node( + "RandomUniform", + [], + ["out"], + dtype=TensorProto.DOUBLE, + shape=(3, 4), + seed=42.0, + generator="philox4x32_10", + ) + ], + [], + ) + self._assert_inferred( + graph, [make_tensor_value_info("out", TensorProto.DOUBLE, (3, 4))] + ) + + def test_random_uniform_unknown_generator_fails(self) -> None: + graph = self._make_graph( + [], + [ + make_node( + "RandomUniform", + [], + ["out"], + shape=(3, 4), + seed=0.0, + generator="xorshift", + ) + ], + [], + ) + self.assertRaises(onnx.shape_inference.InferenceError, self._inferred, graph) + + def test_random_uniform_philox_without_seed_fails(self) -> None: + graph = self._make_graph( + [], + [ + make_node( + "RandomUniform", + [], + ["out"], + shape=(3, 4), + generator="philox4x32_10", + ) + ], + [], + ) + self.assertRaises(onnx.shape_inference.InferenceError, self._inferred, graph) + def test_random_normal_like(self) -> None: graph = self._make_graph( [("X", TensorProto.FLOAT, (2, 3, 4))], diff --git a/onnx/test/version_converter_test.py b/onnx/test/version_converter_test.py index 2d667a91aa2..f3ae18208b3 100644 --- a/onnx/test/version_converter_test.py +++ b/onnx/test/version_converter_test.py @@ -2900,3 +2900,36 @@ def test_celu_float_27_28_and_28_27(self) -> None: ) def test_celu_28_27_unsupported_type_fails(self, _: str, dtype: int) -> None: self.assertRaises(RuntimeError, lambda: self._celu_converted(dtype, 28, 27)) + + def _randomuniform_converted(self, src: int, dst: int, **attrs) -> ModelProto: + node = helper.make_node("RandomUniform", [], ["Y"], shape=[2, 3], **attrs) + graph = helper.make_graph( + [node], + "randomuniform", + [], + [helper.make_tensor_value_info("Y", TensorProto.FLOAT, [2, 3])], + ) + return self._converted(graph, helper.make_operatorsetid("", src), dst) + + # RandomUniform 27 -> 28: CompatibleAdapter (generator attribute has a default) + def test_randomuniform_27_28(self) -> None: + converted = self._randomuniform_converted(27, 28, seed=0.0) + assert converted.opset_import[0].version == 28 + + # RandomUniform 28 -> 27: generator="unspecified" matches the old + # implementation-defined behavior, so the attribute is dropped on downgrade + def test_randomuniform_28_27_unspecified_generator_removed(self) -> None: + converted = self._randomuniform_converted(28, 27, generator="unspecified") + assert converted.opset_import[0].version == 27 + node = next(n for n in converted.graph.node if n.op_type == "RandomUniform") + assert not any(a.name == "generator" for a in node.attribute) + + # RandomUniform 28 -> 27: a deterministic generator cannot be expressed in + # older opsets and must be rejected + def test_randomuniform_28_27_philox_fails(self) -> None: + self.assertRaises( + RuntimeError, + lambda: self._randomuniform_converted( + 28, 27, generator="philox4x32_10", seed=42.0 + ), + ) diff --git a/onnx/version_converter/adapters/CMakeLists.txt b/onnx/version_converter/adapters/CMakeLists.txt index d4afa967b36..2be3e2c7035 100644 --- a/onnx/version_converter/adapters/CMakeLists.txt +++ b/onnx/version_converter/adapters/CMakeLists.txt @@ -24,6 +24,7 @@ target_sources(onnx PRIVATE no_previous_version.h pad_10_11.h q_dq_21_20.h + random_uniform_28_27.h remove_consumed_inputs.h reshape_4_5.h reshape_5_4.h diff --git a/onnx/version_converter/adapters/random_uniform_28_27.h b/onnx/version_converter/adapters/random_uniform_28_27.h new file mode 100644 index 00000000000..4957c652fc3 --- /dev/null +++ b/onnx/version_converter/adapters/random_uniform_28_27.h @@ -0,0 +1,42 @@ +// Copyright (c) ONNX Project Contributors +// +// SPDX-License-Identifier: Apache-2.0 + +// Adapter for RandomUniform in default domain from version 28 to 27 + +#pragma once + +#include +#include + +#include "onnx/version_converter/adapters/adapter.h" + +namespace ONNX_NAMESPACE { +namespace version_conversion { + +class RandomUniform_28_27 final : public Adapter { + public: + RandomUniform_28_27() : Adapter("RandomUniform", OpSetID(28), OpSetID(27)) {} + + Node* adapt(std::shared_ptr /*graph*/, Node* node) const override { + const Symbol generator("generator"); + if (node->hasAttribute(generator)) { + // "unspecified" matches the implementation-defined behavior of + // RandomUniform v22, so the attribute can simply be dropped. Any other + // generator selects fully specified deterministic output, which older + // versions cannot express. + ONNX_ASSERTM( + node->s(generator) == "unspecified", + "Attribute 'generator' of operator '", + name(), + "' must be 'unspecified' in Opset Version ", + static_cast(target_version().version()), + "."); + node->removeAttribute(generator); + } + return node; + } +}; + +} // namespace version_conversion +} // namespace ONNX_NAMESPACE diff --git a/onnx/version_converter/convert.h b/onnx/version_converter/convert.h index 6ad1e7d1d6c..f0ef91996e2 100644 --- a/onnx/version_converter/convert.h +++ b/onnx/version_converter/convert.h @@ -38,6 +38,7 @@ #include "onnx/version_converter/adapters/no_previous_version.h" #include "onnx/version_converter/adapters/pad_10_11.h" #include "onnx/version_converter/adapters/q_dq_21_20.h" +#include "onnx/version_converter/adapters/random_uniform_28_27.h" #include "onnx/version_converter/adapters/range_27_26.h" #include "onnx/version_converter/adapters/reshape_4_5.h" #include "onnx/version_converter/adapters/reshape_5_4.h" @@ -981,12 +982,15 @@ class DefaultVersionConverter : public BaseVersionConverter { /******** 27 -> 28 ********/ registerAdapter(std::make_unique("Celu", OpSetID(27), OpSetID(28))); + registerAdapter(std::make_unique("RandomUniform", OpSetID(27), OpSetID(28))); /******** 28 -> 27 ********/ // Celu v28 widened T to all_float_types_ir4(); Celu v12 (opset 27) supports only FLOAT. const std::vector celu_28_unallowed_types = { TensorProto_DataType_FLOAT16, TensorProto_DataType_BFLOAT16, TensorProto_DataType_DOUBLE}; registerAdapter(std::make_unique("Celu", OpSetID(28), OpSetID(27), celu_28_unallowed_types)); + // RandomUniform v28 added the generator attribute; only generator="unspecified" can be downgraded. + registerAdapter(std::make_unique()); } ModelProto convert_version(const ModelProto& mp_in, const OpSetID& initial_version, const OpSetID& target_version) From aa3317eb728e382be943a27a328f295f022beda2 Mon Sep 17 00:00:00 2001 From: Timo Stripf Date: Sun, 5 Jul 2026 12:58:34 +0000 Subject: [PATCH 2/8] Add bfloat16 coverage for RandomUniform philox4x32_10 The reference implementation used np.finfo to derive the significand precision, which rejects ml_dtypes.bfloat16 ('data type not inexact'); use ml_dtypes.finfo, which covers native and ml_dtypes float types alike. Adds a bfloat16 node test (expected outputs from the independent inline Philox implementation) and a reference-evaluator test with values hard-coded from the canonical Random123 implementation. The node test is excluded on NumPy < 2.0, matching the existing bfloat16 exclusions. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PVuibCDPDmYP2zoMawf9sp Signed-off-by: Timo Stripf --- docs/TestCoverage.md | 26 +++++++++++++++++- onnx/backend/test/case/node/randomuniform.py | 23 +++++++++++++++- .../model.onnx | Bin 0 -> 173 bytes .../test_data_set_0/output_0.pb | 2 ++ onnx/reference/ops/_op_common_random.py | 4 ++- onnx/test/reference_evaluator_test.py | 25 +++++++++++++++++ onnx/test/test_backend_reference.py | 2 ++ 7 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 onnx/backend/test/data/node/test_randomuniform_philox_bfloat16/model.onnx create mode 100644 onnx/backend/test/data/node/test_randomuniform_philox_bfloat16/test_data_set_0/output_0.pb diff --git a/docs/TestCoverage.md b/docs/TestCoverage.md index 62a2f1dcd14..fe4e6a492a6 100644 --- a/docs/TestCoverage.md +++ b/docs/TestCoverage.md @@ -19847,7 +19847,7 @@ expect( ### RandomUniform -There are 4 test cases, listed as following: +There are 5 test cases, listed as following:
randomuniform_philox @@ -19870,6 +19870,30 @@ expect( ) ``` +
+
+randomuniform_philox_bfloat16 + +```python +node = onnx.helper.make_node( + "RandomUniform", + inputs=[], + outputs=["y"], + dtype=onnx.TensorProto.BFLOAT16, + shape=[10], + seed=3.0, + generator="philox4x32_10", +) + +y = philox_uniform(3, (10,), ml_dtypes.bfloat16) +expect( + node, + inputs=[], + outputs=[y], + name="test_randomuniform_philox_bfloat16", +) +``` +
randomuniform_philox_double diff --git a/onnx/backend/test/case/node/randomuniform.py b/onnx/backend/test/case/node/randomuniform.py index 3018c816896..5b124c33968 100644 --- a/onnx/backend/test/case/node/randomuniform.py +++ b/onnx/backend/test/case/node/randomuniform.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: Apache-2.0 from __future__ import annotations +import ml_dtypes import numpy as np import onnx @@ -51,7 +52,7 @@ def block(b): a, b = w[2 * (i % 2)], w[2 * (i % 2) + 1] r.append(((a >> 5) * 67108864.0 + (b >> 6)) / 9007199254740992.0) else: - p = np.finfo(dtype).nmant + 1 + p = ml_dtypes.finfo(dtype).nmant + 1 r = [(block(i // 4)[i % 4] >> (32 - p)) / (1 << p) for i in range(num)] r = np.array(r, dtype=np.float64).reshape(shape).astype(dtype) low = np.asarray(low, dtype=dtype) @@ -120,6 +121,26 @@ def export_randomuniform_philox_double() -> None: name="test_randomuniform_philox_double", ) + @staticmethod + def export_randomuniform_philox_bfloat16() -> None: + node = onnx.helper.make_node( + "RandomUniform", + inputs=[], + outputs=["y"], + dtype=onnx.TensorProto.BFLOAT16, + shape=[10], + seed=3.0, + generator="philox4x32_10", + ) + + y = philox_uniform(3, (10,), ml_dtypes.bfloat16) + expect( + node, + inputs=[], + outputs=[y], + name="test_randomuniform_philox_bfloat16", + ) + @staticmethod def export_randomuniform_philox_float16() -> None: node = onnx.helper.make_node( diff --git a/onnx/backend/test/data/node/test_randomuniform_philox_bfloat16/model.onnx b/onnx/backend/test/data/node/test_randomuniform_philox_bfloat16/model.onnx new file mode 100644 index 0000000000000000000000000000000000000000..22ff6da9d95b0e7132cc8cf8963e93a694bc1425 GIT binary patch literal 173 zcmXBN!3u&f9Ds3WM(dB341x}I@hAjY(V>&{2tmgfv(1!rles|OqX*cdhWP;B@52G{ zGxmH7$#+Jy=JG}GAgGUm8Z|ZG>I4BwL diff --git a/onnx/backend/test/case/node/randomuniform.py b/onnx/backend/test/case/node/randomuniform.py index 5b124c33968..28f6c8ee16b 100644 --- a/onnx/backend/test/case/node/randomuniform.py +++ b/onnx/backend/test/case/node/randomuniform.py @@ -63,6 +63,10 @@ def block(b): class RandomUniform(Base): @staticmethod def export_randomuniform_philox() -> None: + """Intent: base case for the deterministic generator — default range + [0, 1), default dtype (float32), 12 elements spanning three full + Philox counter blocks. + """ node = onnx.helper.make_node( "RandomUniform", inputs=[], @@ -80,8 +84,65 @@ def export_randomuniform_philox() -> None: name="test_randomuniform_philox", ) + @staticmethod + def export_randomuniform_philox_multi_block() -> None: + """Intent: stress the counter-block logic — 35 elements span nine + Philox blocks, with the last block only partially consumed (35 = 8*4 + + 3), so incorrect block increments, word ordering, or padding + handling become visible. + """ + node = onnx.helper.make_node( + "RandomUniform", + inputs=[], + outputs=["y"], + shape=[5, 7], + seed=2024.0, + generator="philox4x32_10", + ) + + y = philox_uniform(2024, (5, 7), np.float32) + expect( + node, + inputs=[], + outputs=[y], + name="test_randomuniform_philox_multi_block", + ) + + @staticmethod + def export_randomuniform_philox_nd_shape() -> None: + """Intent: non-trivial output shape — a 4-D shape with a singleton + dimension and a negative `low` checks that the row-major element + ordering is independent of the tensor's rank and that sign handling + in low + r * (high - low) is correct. (A dynamic output shape is not + expressible for RandomUniform: `shape` is a required attribute and + the operator has no inputs; data-dependent shapes are the domain of + RandomUniformLike.) + """ + node = onnx.helper.make_node( + "RandomUniform", + inputs=[], + outputs=["y"], + low=-1.0, + high=1.0, + shape=[2, 3, 1, 5], + seed=11.0, + generator="philox4x32_10", + ) + + y = philox_uniform(11, (2, 3, 1, 5), np.float32, low=-1.0, high=1.0) + expect( + node, + inputs=[], + outputs=[y], + name="test_randomuniform_philox_nd_shape", + ) + @staticmethod def export_randomuniform_philox_low_high() -> None: + """Intent: non-default range — verifies that low + r * (high - low) + is evaluated in the target data type (float32) with the specified + rounding, not in double precision. + """ node = onnx.helper.make_node( "RandomUniform", inputs=[], @@ -103,6 +164,11 @@ def export_randomuniform_philox_low_high() -> None: @staticmethod def export_randomuniform_philox_double() -> None: + """Intent: the double path — each element combines two output words + of the same block via the res53 scheme (words 0/1 for even, 2/3 for + odd elements), unlike the one-word-per-element mapping of the other + types. + """ node = onnx.helper.make_node( "RandomUniform", inputs=[], @@ -123,6 +189,10 @@ def export_randomuniform_philox_double() -> None: @staticmethod def export_randomuniform_philox_bfloat16() -> None: + """Intent: lowest-precision type — r uses only the top 8 bits of an + output word (p=8) and every value must be exactly representable in + bfloat16. + """ node = onnx.helper.make_node( "RandomUniform", inputs=[], @@ -143,6 +213,10 @@ def export_randomuniform_philox_bfloat16() -> None: @staticmethod def export_randomuniform_philox_float16() -> None: + """Intent: reduced-precision type — r uses the top 11 bits of an + output word (p=11) and every value must be exactly representable in + float16. + """ node = onnx.helper.make_node( "RandomUniform", inputs=[], diff --git a/onnx/backend/test/data/node/test_randomuniform_philox_multi_block/model.onnx b/onnx/backend/test/data/node/test_randomuniform_philox_multi_block/model.onnx new file mode 100644 index 0000000000000000000000000000000000000000..01e767cdd9370a02574693999796c1dc39da5451 GIT binary patch literal 168 zcmdcHy2zJQTk zNEKvud=c2lQizf95JTc~OLIyx©|?`4«=(L±>ˆ-ß>ŠÜe?à‡í>ÎD?`åI=tŸT?ÈD•=  +*?ÀËö>lŠq>Hm€>À#™<‚Zâ>çä-?! Y?‚É?0Yý>rŽ>`„Ä<ûà? Ï>ŠÜ"?ôÓ?H†–>sÎS?ãF?æÞT?О=8‹â=–w? \ No newline at end of file diff --git a/onnx/backend/test/data/node/test_randomuniform_philox_nd_shape/model.onnx b/onnx/backend/test/data/node/test_randomuniform_philox_nd_shape/model.onnx new file mode 100644 index 0000000000000000000000000000000000000000..6af0cc65a8335a937c433a4615ec4204a977201f GIT binary patch literal 208 zcmdL0SE;U0YU+JN_fo1N Date: Sun, 5 Jul 2026 13:47:11 +0000 Subject: [PATCH 4/8] Add optional offset input and next_offset output for streaming A model run is a pure function of its inputs, so a hidden reset-vs-continue flag cannot exist in ONNX; following the explicit state pattern of CausalConvWithState (past_state/present_state) and Attention (KV cache), streaming becomes a wiring decision instead: - optional int64 scalar input 'offset' (default 0) occupies Philox counter words c2/c3 (two's complement bits read as unsigned), so the streams of different offsets never overlap, regardless of the output size. Element i is a pure function of (seed, offset, i). - optional int64 scalar output 'next_offset' = offset + 1 (wrapping on unsigned 64-bit overflow). Feeding it back as the next run's offset draws fresh, yet reproducible, values per run; feeding a constant (or omitting the input) reproduces the same values, which keeps the operator testable. The 28->27 downgrade adapter rejects nodes using the new input or output. Adds a node test with an input .pb for the offset and both outputs, an evaluator test chaining two runs through next_offset (disjoint streams, per-run replayability, default==offset 0), and shape inference and version converter tests. Docs and backend test data regenerated. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PVuibCDPDmYP2zoMawf9sp Signed-off-by: Timo Stripf --- docs/Changelog.md | 33 +++- docs/Operators.md | 178 +++++++++++++++++- docs/TestCoverage.md | 33 +++- onnx/backend/test/case/node/randomuniform.py | 38 +++- .../model.onnx | Bin 0 -> 225 bytes .../test_data_set_0/input_0.pb | Bin 0 -> 20 bytes .../test_data_set_0/output_0.pb | 1 + .../test_data_set_0/output_1.pb | Bin 0 -> 25 bytes onnx/defs/doc_strings.cc | 21 ++- onnx/defs/generator/defs.cc | 27 +++ onnx/reference/ops/_op_common_random.py | 38 ++-- onnx/reference/ops/op_random_uniform.py | 32 +++- onnx/test/reference_evaluator_test.py | 47 +++++ onnx/test/shape_inference_test.py | 23 +++ onnx/test/version_converter_test.py | 20 ++ .../adapters/random_uniform_28_27.h | 16 ++ 16 files changed, 465 insertions(+), 42 deletions(-) create mode 100644 onnx/backend/test/data/node/test_randomuniform_philox_offset/model.onnx create mode 100644 onnx/backend/test/data/node/test_randomuniform_philox_offset/test_data_set_0/input_0.pb create mode 100644 onnx/backend/test/data/node/test_randomuniform_philox_offset/test_data_set_0/output_0.pb create mode 100644 onnx/backend/test/data/node/test_randomuniform_philox_offset/test_data_set_0/output_1.pb diff --git a/docs/Changelog.md b/docs/Changelog.md index efa54885ff7..7689d1c7d5c 100644 --- a/docs/Changelog.md +++ b/docs/Changelog.md @@ -33123,7 +33123,10 @@ This version of the operator has been available since version 28 of the default unsigned 64-bit integer (modulo 2^64): `key0 = seed & 0xFFFFFFFF` and `key1 = (seed >> 32) & 0xFFFFFFFF`. 2. Counter block `b` (a 64-bit block index) is the 128-bit counter - `(c0, c1, c2, c3) = (b & 0xFFFFFFFF, (b >> 32) & 0xFFFFFFFF, 0, 0)`. It is + `(c0, c1, c2, c3) = (b & 0xFFFFFFFF, (b >> 32) & 0xFFFFFFFF, + offset & 0xFFFFFFFF, (offset >> 32) & 0xFFFFFFFF)`, where `offset` is the + value of the optional `offset` input (0 if not provided) with its two's + complement bits interpreted as an unsigned 64-bit integer. The counter is encrypted to four 32-bit output words `w0, w1, w2, w3` by applying the Philox round function 10 times with round keys `(k0, k1)`, starting at `(key0, key1)` and incremented by `(W0, W1)` before every round except the @@ -33147,9 +33150,19 @@ This version of the operator has been available since version 28 of the default semantics. Note that due to this rounding, the result may equal `high` for low-precision types. - Because Philox is counter-based, each output element depends only on `seed` - and its position `i`: elements can be computed independently, in any order, - or in parallel. + Because Philox is counter-based, each output element depends only on `seed`, + `offset`, and its position `i`: elements can be computed independently, in any + order, or in parallel. The block index occupies counter words `c0`/`c1` and the + offset occupies `c2`/`c3`, so the streams of different offsets never overlap, + regardless of the output size. + + The optional `next_offset` output returns `offset + 1` (wrapping around on + unsigned 64-bit overflow, independent of `generator`). A model run is a pure + function of its inputs: with a constant (or absent) `offset`, every run draws + the same values, which makes the operator testable. For streaming inference, + feed `next_offset` of one run as `offset` of the next run — each run then draws + a fresh, disjoint stream while remaining individually deterministic and + replayable. #### Version @@ -33172,14 +33185,20 @@ This version of the operator has been available since version 28 of the default
The shape of the output tensor.
-#### Inputs +#### Inputs (0 - 1) +
+
offset (optional) : T2
+
(Optional) Scalar 64-bit stream offset, 0 if not provided. Each offset value selects an independent random stream: with `generator` = "philox4x32_10" it is placed in the counter words `c2`/`c3` (its two's complement bits interpreted as unsigned), so the streams of different offsets never overlap, regardless of the output size. For streaming inference, feed `next_offset` of one run as `offset` of the next run to draw fresh, yet reproducible, values in every run; feed a constant (or omit the input) to draw the same values in every run. When `generator` is "unspecified", the effect of `offset` on the generated values is implementation-defined.
+
-#### Outputs +#### Outputs (1 - 2)
output : T
Output tensor of random values drawn from uniform distribution
+
next_offset (optional) : T2
+
(Optional) Scalar offset for a subsequent run: `offset + 1`, wrapping around on unsigned 64-bit overflow. Chaining runs through this value yields a disjoint random stream per run while each individual run remains deterministic and replayable.
#### Type Constraints @@ -33187,6 +33206,8 @@ This version of the operator has been available since version 28 of the default
T : tensor(bfloat16), tensor(float16), tensor(float), tensor(double)
Constrain output types to float tensors.
+
T2 : tensor(int64)
+
Constrain the stream offset to int64.
# ai.onnx.preview diff --git a/docs/Operators.md b/docs/Operators.md index 0e04fe76814..d1c969a4ae6 100644 --- a/docs/Operators.md +++ b/docs/Operators.md @@ -27536,7 +27536,10 @@ Other versions of this operator: 1 unsigned 64-bit integer (modulo 2^64): `key0 = seed & 0xFFFFFFFF` and `key1 = (seed >> 32) & 0xFFFFFFFF`. 2. Counter block `b` (a 64-bit block index) is the 128-bit counter - `(c0, c1, c2, c3) = (b & 0xFFFFFFFF, (b >> 32) & 0xFFFFFFFF, 0, 0)`. It is + `(c0, c1, c2, c3) = (b & 0xFFFFFFFF, (b >> 32) & 0xFFFFFFFF, + offset & 0xFFFFFFFF, (offset >> 32) & 0xFFFFFFFF)`, where `offset` is the + value of the optional `offset` input (0 if not provided) with its two's + complement bits interpreted as an unsigned 64-bit integer. The counter is encrypted to four 32-bit output words `w0, w1, w2, w3` by applying the Philox round function 10 times with round keys `(k0, k1)`, starting at `(key0, key1)` and incremented by `(W0, W1)` before every round except the @@ -27560,9 +27563,19 @@ Other versions of this operator: 1 semantics. Note that due to this rounding, the result may equal `high` for low-precision types. - Because Philox is counter-based, each output element depends only on `seed` - and its position `i`: elements can be computed independently, in any order, - or in parallel. + Because Philox is counter-based, each output element depends only on `seed`, + `offset`, and its position `i`: elements can be computed independently, in any + order, or in parallel. The block index occupies counter words `c0`/`c1` and the + offset occupies `c2`/`c3`, so the streams of different offsets never overlap, + regardless of the output size. + + The optional `next_offset` output returns `offset + 1` (wrapping around on + unsigned 64-bit overflow, independent of `generator`). A model run is a pure + function of its inputs: with a constant (or absent) `offset`, every run draws + the same values, which makes the operator testable. For streaming inference, + feed `next_offset` of one run as `offset` of the next run — each run then draws + a fresh, disjoint stream while remaining individually deterministic and + replayable. #### Version @@ -27587,14 +27600,20 @@ Other versions of this operator: 1, <
The shape of the output tensor.
-#### Inputs +#### Inputs (0 - 1) +
+
offset (optional) : T2
+
(Optional) Scalar 64-bit stream offset, 0 if not provided. Each offset value selects an independent random stream: with `generator` = "philox4x32_10" it is placed in the counter words `c2`/`c3` (its two's complement bits interpreted as unsigned), so the streams of different offsets never overlap, regardless of the output size. For streaming inference, feed `next_offset` of one run as `offset` of the next run to draw fresh, yet reproducible, values in every run; feed a constant (or omit the input) to draw the same values in every run. When `generator` is "unspecified", the effect of `offset` on the generated values is implementation-defined.
+
-#### Outputs +#### Outputs (1 - 2)
output : T
Output tensor of random values drawn from uniform distribution
+
next_offset (optional) : T2
+
(Optional) Scalar offset for a subsequent run: `offset + 1`, wrapping around on unsigned 64-bit overflow. Chaining runs through this value yields a disjoint random stream per run while each individual run remains deterministic and replayable.
#### Type Constraints @@ -27602,6 +27621,8 @@ Other versions of this operator: 1, <
T : tensor(bfloat16), tensor(float16), tensor(float), tensor(double)
Constrain output types to float tensors.
+
T2 : tensor(int64)
+
Constrain the stream offset to int64.
@@ -27611,6 +27632,10 @@ Other versions of this operator: 1, < randomuniform_philox ```python +"""Intent: base case for the deterministic generator — default range +[0, 1), default dtype (float32), 12 elements spanning three full +Philox counter blocks. +""" node = onnx.helper.make_node( "RandomUniform", inputs=[], @@ -27632,10 +27657,45 @@ expect(
+
+randomuniform_philox_bfloat16 + +```python +"""Intent: lowest-precision type — r uses only the top 8 bits of an +output word (p=8) and every value must be exactly representable in +bfloat16. +""" +node = onnx.helper.make_node( + "RandomUniform", + inputs=[], + outputs=["y"], + dtype=onnx.TensorProto.BFLOAT16, + shape=[10], + seed=3.0, + generator="philox4x32_10", +) + +y = philox_uniform(3, (10,), ml_dtypes.bfloat16) +expect( + node, + inputs=[], + outputs=[y], + name="test_randomuniform_philox_bfloat16", +) +``` + +
+ +
randomuniform_philox_double ```python +"""Intent: the double path — each element combines two output words +of the same block via the res53 scheme (words 0/1 for even, 2/3 for +odd elements), unlike the one-word-per-element mapping of the other +types. +""" node = onnx.helper.make_node( "RandomUniform", inputs=[], @@ -27662,6 +27722,10 @@ expect( randomuniform_philox_float16 ```python +"""Intent: reduced-precision type — r uses the top 11 bits of an +output word (p=11) and every value must be exactly representable in +float16. +""" node = onnx.helper.make_node( "RandomUniform", inputs=[], @@ -27688,6 +27752,10 @@ expect( randomuniform_philox_low_high ```python +"""Intent: non-default range — verifies that low + r * (high - low) +is evaluated in the target data type (float32) with the specified +rounding, not in double precision. +""" node = onnx.helper.make_node( "RandomUniform", inputs=[], @@ -27711,6 +27779,104 @@ expect(
+
+randomuniform_philox_multi_block + +```python +"""Intent: stress the counter-block logic — 35 elements span nine +Philox blocks, with the last block only partially consumed (35 = 8*4 ++ 3), so incorrect block increments, word ordering, or padding +handling become visible. +""" +node = onnx.helper.make_node( + "RandomUniform", + inputs=[], + outputs=["y"], + shape=[5, 7], + seed=2024.0, + generator="philox4x32_10", +) + +y = philox_uniform(2024, (5, 7), np.float32) +expect( + node, + inputs=[], + outputs=[y], + name="test_randomuniform_philox_multi_block", +) +``` + +
+ + +
+randomuniform_philox_nd_shape + +```python +"""Intent: non-trivial output shape — a 4-D shape with a singleton +dimension and a negative `low` checks that the row-major element +ordering is independent of the tensor's rank and that sign handling +in low + r * (high - low) is correct. (A dynamic output shape is not +expressible for RandomUniform: `shape` is a required attribute and +the operator has no inputs; data-dependent shapes are the domain of +RandomUniformLike.) +""" +node = onnx.helper.make_node( + "RandomUniform", + inputs=[], + outputs=["y"], + low=-1.0, + high=1.0, + shape=[2, 3, 1, 5], + seed=11.0, + generator="philox4x32_10", +) + +y = philox_uniform(11, (2, 3, 1, 5), np.float32, low=-1.0, high=1.0) +expect( + node, + inputs=[], + outputs=[y], + name="test_randomuniform_philox_nd_shape", +) +``` + +
+ + +
+randomuniform_philox_offset + +```python +"""Intent: streaming support — the offset input keys counter words +c2/c3, selecting a stream disjoint from offset 0, and next_offset +must return offset + 1 so consecutive runs can be chained (feeding +next_offset back as offset) to draw fresh, yet reproducible, values +per run. +""" +node = onnx.helper.make_node( + "RandomUniform", + inputs=["offset"], + outputs=["y", "next_offset"], + shape=[2, 3], + seed=42.0, + generator="philox4x32_10", +) + +offset = np.array(5, dtype=np.int64) +y = philox_uniform(42, (2, 3), np.float32, offset=5) +next_offset = np.array(6, dtype=np.int64) +expect( + node, + inputs=[offset], + outputs=[y, next_offset], + name="test_randomuniform_philox_offset", +) +``` + +
+ + ### **RandomUniformLike** Generate a tensor with random values drawn from a uniform distribution. diff --git a/docs/TestCoverage.md b/docs/TestCoverage.md index ac79c8a9ebd..101f21dfd08 100644 --- a/docs/TestCoverage.md +++ b/docs/TestCoverage.md @@ -19847,7 +19847,7 @@ expect( ### RandomUniform -There are 7 test cases, listed as following: +There are 8 test cases, listed as following:
randomuniform_philox @@ -20049,6 +20049,37 @@ expect( ) ``` +
+
+randomuniform_philox_offset + +```python +"""Intent: streaming support — the offset input keys counter words +c2/c3, selecting a stream disjoint from offset 0, and next_offset +must return offset + 1 so consecutive runs can be chained (feeding +next_offset back as offset) to draw fresh, yet reproducible, values +per run. +""" +node = onnx.helper.make_node( + "RandomUniform", + inputs=["offset"], + outputs=["y", "next_offset"], + shape=[2, 3], + seed=42.0, + generator="philox4x32_10", +) + +offset = np.array(5, dtype=np.int64) +y = philox_uniform(42, (2, 3), np.float32, offset=5) +next_offset = np.array(6, dtype=np.int64) +expect( + node, + inputs=[offset], + outputs=[y, next_offset], + name="test_randomuniform_philox_offset", +) +``` +
diff --git a/onnx/backend/test/case/node/randomuniform.py b/onnx/backend/test/case/node/randomuniform.py index 28f6c8ee16b..49f883046d0 100644 --- a/onnx/backend/test/case/node/randomuniform.py +++ b/onnx/backend/test/case/node/randomuniform.py @@ -11,13 +11,13 @@ from onnx.backend.test.case.node import expect -def philox_uniform(seed, shape, dtype, low=0.0, high=1.0): +def philox_uniform(seed, shape, dtype, low=0.0, high=1.0, offset=0): """Independent implementation of RandomUniform with generator="philox4x32_10". Follows the operator specification: Philox-4x32-10 keyed with the 64-bit - seed, counter block b = (lo32(b), hi32(b), 0, 0), per-element values in - [0, 1) with a resolution matching the precision of `dtype` (two output - words per element for double, one otherwise), and + seed, counter block b = (lo32(b), hi32(b), lo32(offset), hi32(offset)), + per-element values in [0, 1) with a resolution matching the precision of + `dtype` (two output words per element for double, one otherwise), and ``low + r * (high - low)`` evaluated in `dtype`. Kept separate from onnx.reference so the generated test data cross-checks the reference implementation. @@ -26,9 +26,10 @@ def philox_uniform(seed, shape, dtype, low=0.0, high=1.0): w0, w1 = 0x9E3779B9, 0xBB67AE85 seed = int(seed) & 0xFFFFFFFFFFFFFFFF key0, key1 = seed & 0xFFFFFFFF, seed >> 32 + offset = int(offset) & 0xFFFFFFFFFFFFFFFF def block(b): - c = [b & 0xFFFFFFFF, (b >> 32) & 0xFFFFFFFF, 0, 0] + c = [b & 0xFFFFFFFF, (b >> 32) & 0xFFFFFFFF, offset & 0xFFFFFFFF, offset >> 32] k0, k1 = key0, key1 for r in range(10): if r > 0: @@ -211,6 +212,33 @@ def export_randomuniform_philox_bfloat16() -> None: name="test_randomuniform_philox_bfloat16", ) + @staticmethod + def export_randomuniform_philox_offset() -> None: + """Intent: streaming support — the offset input keys counter words + c2/c3, selecting a stream disjoint from offset 0, and next_offset + must return offset + 1 so consecutive runs can be chained (feeding + next_offset back as offset) to draw fresh, yet reproducible, values + per run. + """ + node = onnx.helper.make_node( + "RandomUniform", + inputs=["offset"], + outputs=["y", "next_offset"], + shape=[2, 3], + seed=42.0, + generator="philox4x32_10", + ) + + offset = np.array(5, dtype=np.int64) + y = philox_uniform(42, (2, 3), np.float32, offset=5) + next_offset = np.array(6, dtype=np.int64) + expect( + node, + inputs=[offset], + outputs=[y, next_offset], + name="test_randomuniform_philox_offset", + ) + @staticmethod def export_randomuniform_philox_float16() -> None: """Intent: reduced-precision type — r uses the top 11 bits of an diff --git a/onnx/backend/test/data/node/test_randomuniform_philox_offset/model.onnx b/onnx/backend/test/data/node/test_randomuniform_philox_offset/model.onnx new file mode 100644 index 0000000000000000000000000000000000000000..b043dc348226c74d06e07bd1134030befd10cba3 GIT binary patch literal 225 zcmd8oÑ>PDö>ïZ_?+q?Á–F? \ No newline at end of file diff --git a/onnx/backend/test/data/node/test_randomuniform_philox_offset/test_data_set_0/output_1.pb b/onnx/backend/test/data/node/test_randomuniform_philox_offset/test_data_set_0/output_1.pb new file mode 100644 index 0000000000000000000000000000000000000000..6fa28270f522720f43a74274cf23722e89b29167 GIT binary patch literal 25 bcmWe&cjC@Vttg4lPfIIKE%D-DV}JkvR^tU? literal 0 HcmV?d00001 diff --git a/onnx/defs/doc_strings.cc b/onnx/defs/doc_strings.cc index 49ace9ba9fb..ec8198ddb34 100644 --- a/onnx/defs/doc_strings.cc +++ b/onnx/defs/doc_strings.cc @@ -183,7 +183,10 @@ W1 = 0xBB67AE85. All arithmetic on counter, key, and output words is unsigned unsigned 64-bit integer (modulo 2^64): `key0 = seed & 0xFFFFFFFF` and `key1 = (seed >> 32) & 0xFFFFFFFF`. 2. Counter block `b` (a 64-bit block index) is the 128-bit counter - `(c0, c1, c2, c3) = (b & 0xFFFFFFFF, (b >> 32) & 0xFFFFFFFF, 0, 0)`. It is + `(c0, c1, c2, c3) = (b & 0xFFFFFFFF, (b >> 32) & 0xFFFFFFFF, + offset & 0xFFFFFFFF, (offset >> 32) & 0xFFFFFFFF)`, where `offset` is the + value of the optional `offset` input (0 if not provided) with its two's + complement bits interpreted as an unsigned 64-bit integer. The counter is encrypted to four 32-bit output words `w0, w1, w2, w3` by applying the Philox round function 10 times with round keys `(k0, k1)`, starting at `(key0, key1)` and incremented by `(W0, W1)` before every round except the @@ -207,9 +210,19 @@ W1 = 0xBB67AE85. All arithmetic on counter, key, and output words is unsigned semantics. Note that due to this rounding, the result may equal `high` for low-precision types. -Because Philox is counter-based, each output element depends only on `seed` -and its position `i`: elements can be computed independently, in any order, -or in parallel. +Because Philox is counter-based, each output element depends only on `seed`, +`offset`, and its position `i`: elements can be computed independently, in any +order, or in parallel. The block index occupies counter words `c0`/`c1` and the +offset occupies `c2`/`c3`, so the streams of different offsets never overlap, +regardless of the output size. + +The optional `next_offset` output returns `offset + 1` (wrapping around on +unsigned 64-bit overflow, independent of `generator`). A model run is a pure +function of its inputs: with a constant (or absent) `offset`, every run draws +the same values, which makes the operator testable. For streaming inference, +feed `next_offset` of one run as `offset` of the next run — each run then draws +a fresh, disjoint stream while remaining individually deterministic and +replayable. )DOC"; const char kDoc_DequantizeLinear_ver24[] = R"DOC( diff --git a/onnx/defs/generator/defs.cc b/onnx/defs/generator/defs.cc index cd02cda12c4..71028b5f8fb 100644 --- a/onnx/defs/generator/defs.cc +++ b/onnx/defs/generator/defs.cc @@ -178,8 +178,30 @@ ONNX_OPERATOR_SET_SCHEMA( AttributeProto::INT, static_cast(TensorProto::FLOAT)) .Attr("shape", "The shape of the output tensor.", AttributeProto::INTS) + .Input( + 0, + "offset", + "(Optional) Scalar 64-bit stream offset, 0 if not provided. Each offset value selects an " + "independent random stream: with `generator` = \"philox4x32_10\" it is placed in the counter " + "words `c2`/`c3` (its two's complement bits interpreted as unsigned), so the streams of " + "different offsets never overlap, regardless of the output size. For streaming inference, " + "feed `next_offset` of one run as `offset` of the next run to draw fresh, yet reproducible, " + "values in every run; feed a constant (or omit the input) to draw the same values in every " + "run. When `generator` is \"unspecified\", the effect of `offset` on the generated values is " + "implementation-defined.", + "T2", + OpSchema::Optional) .Output(0, "output", "Output tensor of random values drawn from uniform distribution", "T") + .Output( + 1, + "next_offset", + "(Optional) Scalar offset for a subsequent run: `offset + 1`, wrapping around on unsigned " + "64-bit overflow. Chaining runs through this value yields a disjoint random stream per run " + "while each individual run remains deterministic and replayable.", + "T2", + OpSchema::Optional) .TypeConstraint("T", OpSchema::all_float_types_ir4(), "Constrain output types to float tensors.") + .TypeConstraint("T2", {types::Int64}, "Constrain the stream offset to int64.") .SetNodeDeterminism(OpSchema::NodeDeterminism::NonDeterministic) .TypeAndShapeInferenceFunction([](InferenceContext& ctx) { const auto* generator_attr = ctx.getAttribute("generator"); @@ -195,6 +217,11 @@ ONNX_OPERATOR_SET_SCHEMA( } propagateElemTypeFromAttributeToOutput(ctx, "dtype", 0, TensorProto::FLOAT); propagateShapeFromAttributeToOutput(ctx, "shape", 0); + if (ctx.getNumOutputs() > 1) { + updateOutputElemType(ctx, 1, TensorProto::INT64); + // next_offset is a scalar + ctx.getOutputType(1)->mutable_tensor_type()->mutable_shape(); + } })); ONNX_OPERATOR_SET_SCHEMA( diff --git a/onnx/reference/ops/_op_common_random.py b/onnx/reference/ops/_op_common_random.py index 959d6522bc2..08bd209cb22 100644 --- a/onnx/reference/ops/_op_common_random.py +++ b/onnx/reference/ops/_op_common_random.py @@ -28,10 +28,16 @@ class _Philox4x32: _W0 = 0x9E3779B9 _W1 = 0xBB67AE85 - def __init__(self, seed: int): + def __init__(self, seed: int, offset: int = 0): seed &= 0xFFFFFFFFFFFFFFFF self._key0 = seed & 0xFFFFFFFF self._key1 = seed >> 32 + # The stream offset occupies counter words c2/c3 (two's complement + # bits interpreted as unsigned), so different offsets select disjoint + # streams regardless of how many blocks are consumed. + offset &= 0xFFFFFFFFFFFFFFFF + self._offset0 = offset & 0xFFFFFFFF + self._offset1 = offset >> 32 @classmethod def philox4x32_10(cls, c0, c1, c2, c3, key0: int, key1: int): @@ -69,15 +75,15 @@ def philox4x32_10(cls, c0, c1, c2, c3, key0: int, key1: int): def _blocks(self, num_blocks: int): """Output words of counter blocks 0 .. num_blocks-1. - Block ``b`` uses the counter ``(lo32(b), hi32(b), 0, 0)``. + Block ``b`` uses the counter ``(lo32(b), hi32(b), lo32(offset), + hi32(offset))``. """ b = np.arange(num_blocks, dtype=np.uint64) - zero = np.zeros(num_blocks, dtype=np.uint64) return self.philox4x32_10( b & np.uint64(0xFFFFFFFF), b >> np.uint64(32), - zero, - zero, + np.full(num_blocks, self._offset0, dtype=np.uint64), + np.full(num_blocks, self._offset1, dtype=np.uint64), self._key0, self._key1, ) @@ -156,15 +162,15 @@ def _get_state(seed): return state @staticmethod - def _deterministic_uniform(generator, seed, shape, dtype): + def _deterministic_uniform(generator, seed, shape, dtype, offset=0): """Draw uniform values in [0, 1) with the fully specified generator. Unlike the "unspecified" generator, the result is bit-identical across - implementations for a given seed (see the operator specification). - The resolution of the values matches the precision of `dtype`: double - combines two 32-bit output words per element, all other float types - use one word per element, keeping every value exactly representable - in `dtype`. + implementations for a given seed and offset (see the operator + specification). The resolution of the values matches the precision of + `dtype`: double combines two 32-bit output words per element, all + other float types use one word per element, keeping every value + exactly representable in `dtype`. """ if generator != "philox4x32_10": raise ValueError( @@ -176,7 +182,7 @@ def _deterministic_uniform(generator, seed, shape, dtype): "Attribute 'seed' must be specified when 'generator' is " "'philox4x32_10'." ) - state = _Philox4x32(int(seed)) + state = _Philox4x32(int(seed), offset) num = int(np.prod(shape)) if np.dtype(dtype) == np.float64: res = state.random_res53(num) @@ -185,3 +191,11 @@ def _deterministic_uniform(generator, seed, shape, dtype): precision = ml_dtypes.finfo(dtype).nmant + 1 res = state.random_res(num, precision) return res.reshape(shape).astype(dtype) + + @staticmethod + def _next_offset(offset: int) -> np.ndarray: + """Scalar int64 ``offset + 1``, wrapping on unsigned 64-bit overflow.""" + nxt = (int(offset) + 1) & 0xFFFFFFFFFFFFFFFF + if nxt >= 0x8000000000000000: + nxt -= 0x10000000000000000 + return np.array(nxt, dtype=np.int64) diff --git a/onnx/reference/ops/op_random_uniform.py b/onnx/reference/ops/op_random_uniform.py index b5f9c170924..c96d1a192fc 100644 --- a/onnx/reference/ops/op_random_uniform.py +++ b/onnx/reference/ops/op_random_uniform.py @@ -10,17 +10,33 @@ class RandomUniform(_CommonRandom): def _run( - self, dtype=None, generator=None, high=None, low=None, seed=None, shape=None + self, + offset=None, + dtype=None, + generator=None, + high=None, + low=None, + seed=None, + shape=None, ): dtype = self._dtype(dtype=dtype) + offset_value = 0 if offset is None else int(np.asarray(offset).item()) if generator not in (None, "unspecified"): - res = self._deterministic_uniform(generator, seed, shape, dtype) + res = self._deterministic_uniform( + generator, seed, shape, dtype, offset_value + ) # low + r * (high - low), evaluated in the target data type low_t = np.asarray(low, dtype=dtype) high_t = np.asarray(high, dtype=dtype) - return (res * (high_t - low_t) + low_t,) - state = self._get_state(seed) - res = state.rand(*shape).astype(dtype) - res *= high - low - res += low - return (res.astype(dtype),) + res = res * (high_t - low_t) + low_t + else: + # The effect of offset on the values is implementation-defined + # for the "unspecified" generator; it is ignored here. + state = self._get_state(seed) + res = state.rand(*shape).astype(dtype) + res *= high - low + res += low + res = res.astype(dtype) + if len(self.onnx_node.output) > 1: + return (res, self._next_offset(offset_value)) + return (res,) diff --git a/onnx/test/reference_evaluator_test.py b/onnx/test/reference_evaluator_test.py index b0a3055b307..0b4c4e29e51 100644 --- a/onnx/test/reference_evaluator_test.py +++ b/onnx/test/reference_evaluator_test.py @@ -1590,6 +1590,53 @@ def run_philox(shape, seed): other_seed = run_philox([13, 2], 100.0) self.assertFalse(np.array_equal(large, other_seed)) + def test_onnxt_runtime_random_uniform_philox_offset_streaming(self): + # Intent: streaming — chaining runs through next_offset must yield a + # fresh, disjoint stream per run, while each run individually stays + # deterministic and replayable; omitting the offset input must equal + # offset = 0. + offset_in = make_tensor_value_info("offset", TensorProto.INT64, []) + Y = make_tensor_value_info("Y", TensorProto.FLOAT, [None]) + next_out = make_tensor_value_info("next_offset", TensorProto.INT64, []) + node1 = make_node( + "RandomUniform", + ["offset"], + ["Y", "next_offset"], + seed=42.0, + shape=[2, 3], + generator="philox4x32_10", + ) + graph = make_graph([node1], "g", [offset_in], [Y, next_out]) + onnx_model = make_model(graph) + check_model(onnx_model) + sess = ReferenceEvaluator(onnx_model) + + # Run 1 with offset 0, run 2 fed with next_offset of run 1. + y0, next0 = sess.run(None, {"offset": np.array(0, dtype=np.int64)}) + self.assertEqual(next0, np.array(1, dtype=np.int64)) + y1, next1 = sess.run(None, {"offset": next0}) + self.assertEqual(next1, np.array(2, dtype=np.int64)) + # Different offsets select disjoint streams: no value reappears. + self.assertFalse(np.intersect1d(y0, y1).size) + # Each run is individually replayable. + y0_again, _ = sess.run(None, {"offset": np.array(0, dtype=np.int64)}) + assert_allclose(y0_again, y0, rtol=0, atol=0) + + # A model without the offset input behaves like offset = 0. + node2 = make_node( + "RandomUniform", + [], + ["Y"], + seed=42.0, + shape=[2, 3], + generator="philox4x32_10", + ) + graph2 = make_graph([node2], "g", [], [Y]) + model2 = make_model(graph2) + check_model(model2) + y_default = ReferenceEvaluator(model2).run(None, {})[0] + assert_allclose(y_default, y0, rtol=0, atol=0) + def test_onnxt_runtime_random_uniform_philox_no_seed_raises(self): Y = make_tensor_value_info("Y", TensorProto.FLOAT, [None]) node1 = make_node( diff --git a/onnx/test/shape_inference_test.py b/onnx/test/shape_inference_test.py index f57c8f88c8e..afafab0bd6c 100644 --- a/onnx/test/shape_inference_test.py +++ b/onnx/test/shape_inference_test.py @@ -4424,6 +4424,29 @@ def test_random_uniform_philox(self) -> None: graph, [make_tensor_value_info("out", TensorProto.DOUBLE, (3, 4))] ) + def test_random_uniform_offset_next_offset(self) -> None: + graph = self._make_graph( + [("offset", TensorProto.INT64, ())], + [ + make_node( + "RandomUniform", + ["offset"], + ["out", "next_offset"], + shape=(3, 4), + seed=0.0, + generator="philox4x32_10", + ) + ], + [], + ) + self._assert_inferred( + graph, + [ + make_tensor_value_info("out", TensorProto.FLOAT, (3, 4)), + make_tensor_value_info("next_offset", TensorProto.INT64, ()), + ], + ) + def test_random_uniform_unknown_generator_fails(self) -> None: graph = self._make_graph( [], diff --git a/onnx/test/version_converter_test.py b/onnx/test/version_converter_test.py index f3ae18208b3..e386d318c06 100644 --- a/onnx/test/version_converter_test.py +++ b/onnx/test/version_converter_test.py @@ -2933,3 +2933,23 @@ def test_randomuniform_28_27_philox_fails(self) -> None: 28, 27, generator="philox4x32_10", seed=42.0 ), ) + + # RandomUniform 28 -> 27: the offset input / next_offset output cannot be + # expressed in older opsets and must be rejected + def test_randomuniform_28_27_offset_fails(self) -> None: + node = helper.make_node( + "RandomUniform", ["offset"], ["Y", "next_offset"], shape=[2, 3], seed=1.0 + ) + graph = helper.make_graph( + [node], + "randomuniform_offset", + [helper.make_tensor_value_info("offset", TensorProto.INT64, [])], + [ + helper.make_tensor_value_info("Y", TensorProto.FLOAT, [2, 3]), + helper.make_tensor_value_info("next_offset", TensorProto.INT64, []), + ], + ) + self.assertRaises( + RuntimeError, + lambda: self._converted(graph, helper.make_operatorsetid("", 28), 27), + ) diff --git a/onnx/version_converter/adapters/random_uniform_28_27.h b/onnx/version_converter/adapters/random_uniform_28_27.h index 4957c652fc3..83f58a84141 100644 --- a/onnx/version_converter/adapters/random_uniform_28_27.h +++ b/onnx/version_converter/adapters/random_uniform_28_27.h @@ -19,6 +19,22 @@ class RandomUniform_28_27 final : public Adapter { RandomUniform_28_27() : Adapter("RandomUniform", OpSetID(28), OpSetID(27)) {} Node* adapt(std::shared_ptr /*graph*/, Node* node) const override { + // The offset input and next_offset output do not exist in RandomUniform + // v22 and cannot be expressed in older opsets. + ONNX_ASSERTM( + node->inputs().empty(), + "Operator '", + name(), + "' with an 'offset' input is not supported in Opset Version ", + static_cast(target_version().version()), + "."); + ONNX_ASSERTM( + node->outputs().size() == 1, + "Operator '", + name(), + "' with a 'next_offset' output is not supported in Opset Version ", + static_cast(target_version().version()), + "."); const Symbol generator("generator"); if (node->hasAttribute(generator)) { // "unspecified" matches the implementation-defined behavior of From ff5a7109daa0198e6c28a7c568a90ccd1f63c8f9 Mon Sep 17 00:00:00 2001 From: Timo Stripf Date: Sun, 5 Jul 2026 16:09:26 +0000 Subject: [PATCH 5/8] Remove redundant next_offset output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit next_offset carried no information: offset + 1 is trivially computable by the host, by an Add node in the graph, or as a loop-carried update — unlike present_state/KV-cache outputs, which cannot be derived without recomputation. Dropping it also removes an over-specification: since every offset value selects an independent stream, the operator need not prescribe any particular increment protocol; any non-repeating scheme (step counter, batch number) works. The offset input and its counter mapping are unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PVuibCDPDmYP2zoMawf9sp Signed-off-by: Timo Stripf --- docs/Changelog.md | 21 ++++++----- docs/Operators.md | 33 ++++++++---------- docs/TestCoverage.md | 12 +++---- onnx/backend/test/case/node/randomuniform.py | 12 +++---- .../model.onnx | Bin 225 -> 189 bytes .../test_data_set_0/output_1.pb | Bin 25 -> 0 bytes onnx/defs/doc_strings.cc | 15 ++++---- onnx/defs/generator/defs.cc | 20 +++-------- onnx/reference/ops/_op_common_random.py | 8 ----- onnx/reference/ops/op_random_uniform.py | 20 +++++------ onnx/test/reference_evaluator_test.py | 23 ++++++------ onnx/test/shape_inference_test.py | 10 ++---- onnx/test/version_converter_test.py | 11 +++--- .../adapters/random_uniform_28_27.h | 11 ++---- 14 files changed, 74 insertions(+), 122 deletions(-) delete mode 100644 onnx/backend/test/data/node/test_randomuniform_philox_offset/test_data_set_0/output_1.pb diff --git a/docs/Changelog.md b/docs/Changelog.md index 7689d1c7d5c..619e556242f 100644 --- a/docs/Changelog.md +++ b/docs/Changelog.md @@ -33156,13 +33156,14 @@ This version of the operator has been available since version 28 of the default offset occupies `c2`/`c3`, so the streams of different offsets never overlap, regardless of the output size. - The optional `next_offset` output returns `offset + 1` (wrapping around on - unsigned 64-bit overflow, independent of `generator`). A model run is a pure - function of its inputs: with a constant (or absent) `offset`, every run draws - the same values, which makes the operator testable. For streaming inference, - feed `next_offset` of one run as `offset` of the next run — each run then draws - a fresh, disjoint stream while remaining individually deterministic and - replayable. + A model run is a pure function of its inputs: with a constant (or absent) + `offset`, every run draws the same values, which makes the operator testable. + For streaming inference, feed a different `offset` in every run — since every + offset value selects an independent stream, any non-repeating scheme works, + such as a step counter maintained by the host, stored as an initializer and + advanced at checkpoint time, or carried through a Loop and incremented in the + graph. Each run then draws a fresh, disjoint stream while remaining + individually deterministic and replayable. #### Version @@ -33189,16 +33190,14 @@ This version of the operator has been available since version 28 of the default
offset (optional) : T2
-
(Optional) Scalar 64-bit stream offset, 0 if not provided. Each offset value selects an independent random stream: with `generator` = "philox4x32_10" it is placed in the counter words `c2`/`c3` (its two's complement bits interpreted as unsigned), so the streams of different offsets never overlap, regardless of the output size. For streaming inference, feed `next_offset` of one run as `offset` of the next run to draw fresh, yet reproducible, values in every run; feed a constant (or omit the input) to draw the same values in every run. When `generator` is "unspecified", the effect of `offset` on the generated values is implementation-defined.
+
(Optional) Scalar 64-bit stream offset, 0 if not provided. Each offset value selects an independent random stream: with `generator` = "philox4x32_10" it is placed in the counter words `c2`/`c3` (its two's complement bits interpreted as unsigned), so the streams of different offsets never overlap, regardless of the output size. For streaming inference, feed a different offset in every run (any non-repeating scheme works, e.g. a step counter maintained by the host or computed in the graph) to draw fresh, yet reproducible, values per run; feed a constant (or omit the input) to draw the same values in every run. When `generator` is "unspecified", the effect of `offset` on the generated values is implementation-defined.
-#### Outputs (1 - 2) +#### Outputs
output : T
Output tensor of random values drawn from uniform distribution
-
next_offset (optional) : T2
-
(Optional) Scalar offset for a subsequent run: `offset + 1`, wrapping around on unsigned 64-bit overflow. Chaining runs through this value yields a disjoint random stream per run while each individual run remains deterministic and replayable.
#### Type Constraints diff --git a/docs/Operators.md b/docs/Operators.md index d1c969a4ae6..b0c2aa38dc2 100644 --- a/docs/Operators.md +++ b/docs/Operators.md @@ -27569,13 +27569,14 @@ Other versions of this operator: 1 offset occupies `c2`/`c3`, so the streams of different offsets never overlap, regardless of the output size. - The optional `next_offset` output returns `offset + 1` (wrapping around on - unsigned 64-bit overflow, independent of `generator`). A model run is a pure - function of its inputs: with a constant (or absent) `offset`, every run draws - the same values, which makes the operator testable. For streaming inference, - feed `next_offset` of one run as `offset` of the next run — each run then draws - a fresh, disjoint stream while remaining individually deterministic and - replayable. + A model run is a pure function of its inputs: with a constant (or absent) + `offset`, every run draws the same values, which makes the operator testable. + For streaming inference, feed a different `offset` in every run — since every + offset value selects an independent stream, any non-repeating scheme works, + such as a step counter maintained by the host, stored as an initializer and + advanced at checkpoint time, or carried through a Loop and incremented in the + graph. Each run then draws a fresh, disjoint stream while remaining + individually deterministic and replayable. #### Version @@ -27604,16 +27605,14 @@ Other versions of this operator: 1, <
offset (optional) : T2
-
(Optional) Scalar 64-bit stream offset, 0 if not provided. Each offset value selects an independent random stream: with `generator` = "philox4x32_10" it is placed in the counter words `c2`/`c3` (its two's complement bits interpreted as unsigned), so the streams of different offsets never overlap, regardless of the output size. For streaming inference, feed `next_offset` of one run as `offset` of the next run to draw fresh, yet reproducible, values in every run; feed a constant (or omit the input) to draw the same values in every run. When `generator` is "unspecified", the effect of `offset` on the generated values is implementation-defined.
+
(Optional) Scalar 64-bit stream offset, 0 if not provided. Each offset value selects an independent random stream: with `generator` = "philox4x32_10" it is placed in the counter words `c2`/`c3` (its two's complement bits interpreted as unsigned), so the streams of different offsets never overlap, regardless of the output size. For streaming inference, feed a different offset in every run (any non-repeating scheme works, e.g. a step counter maintained by the host or computed in the graph) to draw fresh, yet reproducible, values per run; feed a constant (or omit the input) to draw the same values in every run. When `generator` is "unspecified", the effect of `offset` on the generated values is implementation-defined.
-#### Outputs (1 - 2) +#### Outputs
output : T
Output tensor of random values drawn from uniform distribution
-
next_offset (optional) : T2
-
(Optional) Scalar offset for a subsequent run: `offset + 1`, wrapping around on unsigned 64-bit overflow. Chaining runs through this value yields a disjoint random stream per run while each individual run remains deterministic and replayable.
#### Type Constraints @@ -27849,15 +27848,14 @@ expect( ```python """Intent: streaming support — the offset input keys counter words -c2/c3, selecting a stream disjoint from offset 0, and next_offset -must return offset + 1 so consecutive runs can be chained (feeding -next_offset back as offset) to draw fresh, yet reproducible, values -per run. +c2/c3, selecting a stream disjoint from offset 0 (and from every +other offset value). Feeding a different offset per run (e.g. a step +counter) draws fresh, yet reproducible, values in every run. """ node = onnx.helper.make_node( "RandomUniform", inputs=["offset"], - outputs=["y", "next_offset"], + outputs=["y"], shape=[2, 3], seed=42.0, generator="philox4x32_10", @@ -27865,11 +27863,10 @@ node = onnx.helper.make_node( offset = np.array(5, dtype=np.int64) y = philox_uniform(42, (2, 3), np.float32, offset=5) -next_offset = np.array(6, dtype=np.int64) expect( node, inputs=[offset], - outputs=[y, next_offset], + outputs=[y], name="test_randomuniform_philox_offset", ) ``` diff --git a/docs/TestCoverage.md b/docs/TestCoverage.md index 101f21dfd08..6b72ed4cafd 100644 --- a/docs/TestCoverage.md +++ b/docs/TestCoverage.md @@ -20055,15 +20055,14 @@ expect( ```python """Intent: streaming support — the offset input keys counter words -c2/c3, selecting a stream disjoint from offset 0, and next_offset -must return offset + 1 so consecutive runs can be chained (feeding -next_offset back as offset) to draw fresh, yet reproducible, values -per run. +c2/c3, selecting a stream disjoint from offset 0 (and from every +other offset value). Feeding a different offset per run (e.g. a step +counter) draws fresh, yet reproducible, values in every run. """ node = onnx.helper.make_node( "RandomUniform", inputs=["offset"], - outputs=["y", "next_offset"], + outputs=["y"], shape=[2, 3], seed=42.0, generator="philox4x32_10", @@ -20071,11 +20070,10 @@ node = onnx.helper.make_node( offset = np.array(5, dtype=np.int64) y = philox_uniform(42, (2, 3), np.float32, offset=5) -next_offset = np.array(6, dtype=np.int64) expect( node, inputs=[offset], - outputs=[y, next_offset], + outputs=[y], name="test_randomuniform_philox_offset", ) ``` diff --git a/onnx/backend/test/case/node/randomuniform.py b/onnx/backend/test/case/node/randomuniform.py index 49f883046d0..57a8e2d2dc0 100644 --- a/onnx/backend/test/case/node/randomuniform.py +++ b/onnx/backend/test/case/node/randomuniform.py @@ -215,15 +215,14 @@ def export_randomuniform_philox_bfloat16() -> None: @staticmethod def export_randomuniform_philox_offset() -> None: """Intent: streaming support — the offset input keys counter words - c2/c3, selecting a stream disjoint from offset 0, and next_offset - must return offset + 1 so consecutive runs can be chained (feeding - next_offset back as offset) to draw fresh, yet reproducible, values - per run. + c2/c3, selecting a stream disjoint from offset 0 (and from every + other offset value). Feeding a different offset per run (e.g. a step + counter) draws fresh, yet reproducible, values in every run. """ node = onnx.helper.make_node( "RandomUniform", inputs=["offset"], - outputs=["y", "next_offset"], + outputs=["y"], shape=[2, 3], seed=42.0, generator="philox4x32_10", @@ -231,11 +230,10 @@ def export_randomuniform_philox_offset() -> None: offset = np.array(5, dtype=np.int64) y = philox_uniform(42, (2, 3), np.float32, offset=5) - next_offset = np.array(6, dtype=np.int64) expect( node, inputs=[offset], - outputs=[y, next_offset], + outputs=[y], name="test_randomuniform_philox_offset", ) diff --git a/onnx/backend/test/data/node/test_randomuniform_philox_offset/model.onnx b/onnx/backend/test/data/node/test_randomuniform_philox_offset/model.onnx index b043dc348226c74d06e07bd1134030befd10cba3..9870e06fec2237605652288865e41e5dda1008c5 100644 GIT binary patch delta 47 zcmaFJxR+6ZgI9fVTgI9I5TK8W&rBT3T^xi4bF@5O-c`MM*q_KT%=2 YY?3G!vXBrP7Yhfw5Q7s77lVKd05($>X8-^I diff --git a/onnx/backend/test/data/node/test_randomuniform_philox_offset/test_data_set_0/output_1.pb b/onnx/backend/test/data/node/test_randomuniform_philox_offset/test_data_set_0/output_1.pb deleted file mode 100644 index 6fa28270f522720f43a74274cf23722e89b29167..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25 bcmWe&cjC@Vttg4lPfIIKE%D-DV}JkvR^tU? diff --git a/onnx/defs/doc_strings.cc b/onnx/defs/doc_strings.cc index ec8198ddb34..d925ecff50b 100644 --- a/onnx/defs/doc_strings.cc +++ b/onnx/defs/doc_strings.cc @@ -216,13 +216,14 @@ order, or in parallel. The block index occupies counter words `c0`/`c1` and the offset occupies `c2`/`c3`, so the streams of different offsets never overlap, regardless of the output size. -The optional `next_offset` output returns `offset + 1` (wrapping around on -unsigned 64-bit overflow, independent of `generator`). A model run is a pure -function of its inputs: with a constant (or absent) `offset`, every run draws -the same values, which makes the operator testable. For streaming inference, -feed `next_offset` of one run as `offset` of the next run — each run then draws -a fresh, disjoint stream while remaining individually deterministic and -replayable. +A model run is a pure function of its inputs: with a constant (or absent) +`offset`, every run draws the same values, which makes the operator testable. +For streaming inference, feed a different `offset` in every run — since every +offset value selects an independent stream, any non-repeating scheme works, +such as a step counter maintained by the host, stored as an initializer and +advanced at checkpoint time, or carried through a Loop and incremented in the +graph. Each run then draws a fresh, disjoint stream while remaining +individually deterministic and replayable. )DOC"; const char kDoc_DequantizeLinear_ver24[] = R"DOC( diff --git a/onnx/defs/generator/defs.cc b/onnx/defs/generator/defs.cc index 71028b5f8fb..8d664a9072e 100644 --- a/onnx/defs/generator/defs.cc +++ b/onnx/defs/generator/defs.cc @@ -185,21 +185,14 @@ ONNX_OPERATOR_SET_SCHEMA( "independent random stream: with `generator` = \"philox4x32_10\" it is placed in the counter " "words `c2`/`c3` (its two's complement bits interpreted as unsigned), so the streams of " "different offsets never overlap, regardless of the output size. For streaming inference, " - "feed `next_offset` of one run as `offset` of the next run to draw fresh, yet reproducible, " - "values in every run; feed a constant (or omit the input) to draw the same values in every " - "run. When `generator` is \"unspecified\", the effect of `offset` on the generated values is " + "feed a different offset in every run (any non-repeating scheme works, e.g. a step counter " + "maintained by the host or computed in the graph) to draw fresh, yet reproducible, values per " + "run; feed a constant (or omit the input) to draw the same values in every run. When " + "`generator` is \"unspecified\", the effect of `offset` on the generated values is " "implementation-defined.", "T2", OpSchema::Optional) .Output(0, "output", "Output tensor of random values drawn from uniform distribution", "T") - .Output( - 1, - "next_offset", - "(Optional) Scalar offset for a subsequent run: `offset + 1`, wrapping around on unsigned " - "64-bit overflow. Chaining runs through this value yields a disjoint random stream per run " - "while each individual run remains deterministic and replayable.", - "T2", - OpSchema::Optional) .TypeConstraint("T", OpSchema::all_float_types_ir4(), "Constrain output types to float tensors.") .TypeConstraint("T2", {types::Int64}, "Constrain the stream offset to int64.") .SetNodeDeterminism(OpSchema::NodeDeterminism::NonDeterministic) @@ -217,11 +210,6 @@ ONNX_OPERATOR_SET_SCHEMA( } propagateElemTypeFromAttributeToOutput(ctx, "dtype", 0, TensorProto::FLOAT); propagateShapeFromAttributeToOutput(ctx, "shape", 0); - if (ctx.getNumOutputs() > 1) { - updateOutputElemType(ctx, 1, TensorProto::INT64); - // next_offset is a scalar - ctx.getOutputType(1)->mutable_tensor_type()->mutable_shape(); - } })); ONNX_OPERATOR_SET_SCHEMA( diff --git a/onnx/reference/ops/_op_common_random.py b/onnx/reference/ops/_op_common_random.py index 08bd209cb22..7c05252ab76 100644 --- a/onnx/reference/ops/_op_common_random.py +++ b/onnx/reference/ops/_op_common_random.py @@ -191,11 +191,3 @@ def _deterministic_uniform(generator, seed, shape, dtype, offset=0): precision = ml_dtypes.finfo(dtype).nmant + 1 res = state.random_res(num, precision) return res.reshape(shape).astype(dtype) - - @staticmethod - def _next_offset(offset: int) -> np.ndarray: - """Scalar int64 ``offset + 1``, wrapping on unsigned 64-bit overflow.""" - nxt = (int(offset) + 1) & 0xFFFFFFFFFFFFFFFF - if nxt >= 0x8000000000000000: - nxt -= 0x10000000000000000 - return np.array(nxt, dtype=np.int64) diff --git a/onnx/reference/ops/op_random_uniform.py b/onnx/reference/ops/op_random_uniform.py index c96d1a192fc..510652688f2 100644 --- a/onnx/reference/ops/op_random_uniform.py +++ b/onnx/reference/ops/op_random_uniform.py @@ -28,15 +28,11 @@ def _run( # low + r * (high - low), evaluated in the target data type low_t = np.asarray(low, dtype=dtype) high_t = np.asarray(high, dtype=dtype) - res = res * (high_t - low_t) + low_t - else: - # The effect of offset on the values is implementation-defined - # for the "unspecified" generator; it is ignored here. - state = self._get_state(seed) - res = state.rand(*shape).astype(dtype) - res *= high - low - res += low - res = res.astype(dtype) - if len(self.onnx_node.output) > 1: - return (res, self._next_offset(offset_value)) - return (res,) + return (res * (high_t - low_t) + low_t,) + # The effect of offset on the values is implementation-defined for + # the "unspecified" generator; it is ignored here. + state = self._get_state(seed) + res = state.rand(*shape).astype(dtype) + res *= high - low + res += low + return (res.astype(dtype),) diff --git a/onnx/test/reference_evaluator_test.py b/onnx/test/reference_evaluator_test.py index 0b4c4e29e51..e7d4ee1e3c3 100644 --- a/onnx/test/reference_evaluator_test.py +++ b/onnx/test/reference_evaluator_test.py @@ -1591,35 +1591,32 @@ def run_philox(shape, seed): self.assertFalse(np.array_equal(large, other_seed)) def test_onnxt_runtime_random_uniform_philox_offset_streaming(self): - # Intent: streaming — chaining runs through next_offset must yield a - # fresh, disjoint stream per run, while each run individually stays - # deterministic and replayable; omitting the offset input must equal - # offset = 0. + # Intent: streaming — feeding a different offset per run (e.g. a step + # counter maintained by the host) must yield a fresh, disjoint stream + # per run, while each run individually stays deterministic and + # replayable; omitting the offset input must equal offset = 0. offset_in = make_tensor_value_info("offset", TensorProto.INT64, []) Y = make_tensor_value_info("Y", TensorProto.FLOAT, [None]) - next_out = make_tensor_value_info("next_offset", TensorProto.INT64, []) node1 = make_node( "RandomUniform", ["offset"], - ["Y", "next_offset"], + ["Y"], seed=42.0, shape=[2, 3], generator="philox4x32_10", ) - graph = make_graph([node1], "g", [offset_in], [Y, next_out]) + graph = make_graph([node1], "g", [offset_in], [Y]) onnx_model = make_model(graph) check_model(onnx_model) sess = ReferenceEvaluator(onnx_model) - # Run 1 with offset 0, run 2 fed with next_offset of run 1. - y0, next0 = sess.run(None, {"offset": np.array(0, dtype=np.int64)}) - self.assertEqual(next0, np.array(1, dtype=np.int64)) - y1, next1 = sess.run(None, {"offset": next0}) - self.assertEqual(next1, np.array(2, dtype=np.int64)) + # The host advances the offset between runs (step counter). + y0 = sess.run(None, {"offset": np.array(0, dtype=np.int64)})[0] + y1 = sess.run(None, {"offset": np.array(1, dtype=np.int64)})[0] # Different offsets select disjoint streams: no value reappears. self.assertFalse(np.intersect1d(y0, y1).size) # Each run is individually replayable. - y0_again, _ = sess.run(None, {"offset": np.array(0, dtype=np.int64)}) + y0_again = sess.run(None, {"offset": np.array(0, dtype=np.int64)})[0] assert_allclose(y0_again, y0, rtol=0, atol=0) # A model without the offset input behaves like offset = 0. diff --git a/onnx/test/shape_inference_test.py b/onnx/test/shape_inference_test.py index afafab0bd6c..d929ef930e7 100644 --- a/onnx/test/shape_inference_test.py +++ b/onnx/test/shape_inference_test.py @@ -4424,14 +4424,14 @@ def test_random_uniform_philox(self) -> None: graph, [make_tensor_value_info("out", TensorProto.DOUBLE, (3, 4))] ) - def test_random_uniform_offset_next_offset(self) -> None: + def test_random_uniform_offset(self) -> None: graph = self._make_graph( [("offset", TensorProto.INT64, ())], [ make_node( "RandomUniform", ["offset"], - ["out", "next_offset"], + ["out"], shape=(3, 4), seed=0.0, generator="philox4x32_10", @@ -4440,11 +4440,7 @@ def test_random_uniform_offset_next_offset(self) -> None: [], ) self._assert_inferred( - graph, - [ - make_tensor_value_info("out", TensorProto.FLOAT, (3, 4)), - make_tensor_value_info("next_offset", TensorProto.INT64, ()), - ], + graph, [make_tensor_value_info("out", TensorProto.FLOAT, (3, 4))] ) def test_random_uniform_unknown_generator_fails(self) -> None: diff --git a/onnx/test/version_converter_test.py b/onnx/test/version_converter_test.py index e386d318c06..58ea2cddb61 100644 --- a/onnx/test/version_converter_test.py +++ b/onnx/test/version_converter_test.py @@ -2934,20 +2934,17 @@ def test_randomuniform_28_27_philox_fails(self) -> None: ), ) - # RandomUniform 28 -> 27: the offset input / next_offset output cannot be - # expressed in older opsets and must be rejected + # RandomUniform 28 -> 27: the offset input cannot be expressed in older + # opsets and must be rejected def test_randomuniform_28_27_offset_fails(self) -> None: node = helper.make_node( - "RandomUniform", ["offset"], ["Y", "next_offset"], shape=[2, 3], seed=1.0 + "RandomUniform", ["offset"], ["Y"], shape=[2, 3], seed=1.0 ) graph = helper.make_graph( [node], "randomuniform_offset", [helper.make_tensor_value_info("offset", TensorProto.INT64, [])], - [ - helper.make_tensor_value_info("Y", TensorProto.FLOAT, [2, 3]), - helper.make_tensor_value_info("next_offset", TensorProto.INT64, []), - ], + [helper.make_tensor_value_info("Y", TensorProto.FLOAT, [2, 3])], ) self.assertRaises( RuntimeError, diff --git a/onnx/version_converter/adapters/random_uniform_28_27.h b/onnx/version_converter/adapters/random_uniform_28_27.h index 83f58a84141..b9b1a5a2b6b 100644 --- a/onnx/version_converter/adapters/random_uniform_28_27.h +++ b/onnx/version_converter/adapters/random_uniform_28_27.h @@ -19,8 +19,8 @@ class RandomUniform_28_27 final : public Adapter { RandomUniform_28_27() : Adapter("RandomUniform", OpSetID(28), OpSetID(27)) {} Node* adapt(std::shared_ptr /*graph*/, Node* node) const override { - // The offset input and next_offset output do not exist in RandomUniform - // v22 and cannot be expressed in older opsets. + // The offset input does not exist in RandomUniform v22 and cannot be + // expressed in older opsets. ONNX_ASSERTM( node->inputs().empty(), "Operator '", @@ -28,13 +28,6 @@ class RandomUniform_28_27 final : public Adapter { "' with an 'offset' input is not supported in Opset Version ", static_cast(target_version().version()), "."); - ONNX_ASSERTM( - node->outputs().size() == 1, - "Operator '", - name(), - "' with a 'next_offset' output is not supported in Opset Version ", - static_cast(target_version().version()), - "."); const Symbol generator("generator"); if (node->hasAttribute(generator)) { // "unspecified" matches the implementation-defined behavior of From ca909f35ec73250660260144057ea8e33b2f74c3 Mon Sep 17 00:00:00 2001 From: Timo Stripf Date: Sun, 5 Jul 2026 16:27:13 +0000 Subject: [PATCH 6/8] Require dedicated int64 seed attribute seed_int64 for philox4x32_10 The legacy seed attribute is float32, whose 24-bit significand can only represent integers exactly up to 2^24 and cannot address the 64-bit Philox key space. Following the Constant value_int/value_float pattern (ONNX attributes have exactly one fixed type), a separate INT attribute seed_int64 now carries the key: its two's complement bits are the unsigned 64-bit Philox key (key0 = low word, key1 = high word). With generator="philox4x32_10", seed_int64 is required and the float seed must not be set (no fallback; enforced by shape inference). With generator="unspecified", both seeds remain implementation-defined as before. The 28->27 downgrade adapter rejects seed_int64. All philox tests and test data switched to seed_int64; expected values are unchanged since the numeric key values are the same. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PVuibCDPDmYP2zoMawf9sp Signed-off-by: Timo Stripf --- docs/Changelog.md | 33 ++++++------ docs/Operators.md | 49 ++++++++++-------- docs/TestCoverage.md | 16 +++--- onnx/backend/test/case/node/randomuniform.py | 16 +++--- .../node/test_randomuniform_philox/model.onnx | Bin 156 -> 159 bytes .../model.onnx | Bin 173 -> 176 bytes .../model.onnx | Bin 177 -> 180 bytes .../model.onnx | Bin 172 -> 175 bytes .../model.onnx | Bin 196 -> 199 bytes .../model.onnx | Bin 168 -> 172 bytes .../model.onnx | Bin 208 -> 211 bytes .../model.onnx | Bin 189 -> 192 bytes onnx/defs/doc_strings.cc | 27 +++++----- onnx/defs/generator/defs.cc | 27 ++++++++-- onnx/reference/ops/_op_common_random.py | 10 ++-- onnx/reference/ops/op_random_uniform.py | 3 +- onnx/test/reference_evaluator_test.py | 31 +++++------ onnx/test/schema_test.py | 9 +++- onnx/test/shape_inference_test.py | 22 +++++++- onnx/test/version_converter_test.py | 10 +++- .../adapters/random_uniform_28_27.h | 8 +++ 21 files changed, 164 insertions(+), 97 deletions(-) diff --git a/docs/Changelog.md b/docs/Changelog.md index 619e556242f..611fce5d090 100644 --- a/docs/Changelog.md +++ b/docs/Changelog.md @@ -33105,23 +33105,24 @@ This version of the operator has been available since version 28 of the default The `generator` attribute selects the pseudo-random number generator algorithm. With the default value "unspecified", the choice of generator is left to the implementation and no determinism guarantee is given: results may differ across - implementations and even across runs of the same implementation, even when - `seed` is specified. An implementation may produce reproducible results in this + implementations and even across runs of the same implementation, even when a + seed is specified. An implementation may produce reproducible results in this mode (for example for a fixed `seed`), but it is not required to. Setting `generator` to "philox4x32_10" fully specifies the generated values: given - the same `seed`, every conforming implementation must produce bit-identical + the same `seed_int64`, every conforming implementation must produce bit-identical results, which makes the operator deterministic and testable. More algorithms may be added in future opset versions. - When `generator` is "philox4x32_10", the `seed` attribute must be specified and - the output is computed with the Philox-4x32 counter-based generator with 10 - rounds (Salmon et al., "Parallel random numbers: as easy as 1, 2, 3", SC'11), - using the standard constants M0 = 0xD2511F53, M1 = 0xCD9E8D57, W0 = 0x9E3779B9, - W1 = 0xBB67AE85. All arithmetic on counter, key, and output words is unsigned - 32-bit modular arithmetic: - 1. The key is derived from `seed`, truncated toward zero and converted to an - unsigned 64-bit integer (modulo 2^64): `key0 = seed & 0xFFFFFFFF` and - `key1 = (seed >> 32) & 0xFFFFFFFF`. + When `generator` is "philox4x32_10", the `seed_int64` attribute must be specified + (the float `seed` attribute must not be used) and the output is computed with + the Philox-4x32 counter-based generator with 10 rounds (Salmon et al., + "Parallel random numbers: as easy as 1, 2, 3", SC'11), using the standard + constants M0 = 0xD2511F53, M1 = 0xCD9E8D57, W0 = 0x9E3779B9, W1 = 0xBB67AE85. + All arithmetic on counter, key, and output words is unsigned 32-bit modular + arithmetic: + 1. The key is the value of `seed_int64` with its two's complement bits interpreted + as an unsigned 64-bit integer: `key0 = seed_int64 & 0xFFFFFFFF` and + `key1 = (seed_int64 >> 32) & 0xFFFFFFFF`. 2. Counter block `b` (a 64-bit block index) is the 128-bit counter `(c0, c1, c2, c3) = (b & 0xFFFFFFFF, (b >> 32) & 0xFFFFFFFF, offset & 0xFFFFFFFF, (offset >> 32) & 0xFFFFFFFF)`, where `offset` is the @@ -33150,7 +33151,7 @@ This version of the operator has been available since version 28 of the default semantics. Note that due to this rounding, the result may equal `high` for low-precision types. - Because Philox is counter-based, each output element depends only on `seed`, + Because Philox is counter-based, each output element depends only on `seed_int64`, `offset`, and its position `i`: elements can be computed independently, in any order, or in parallel. The block index occupies counter words `c0`/`c1` and the offset occupies `c2`/`c3`, so the streams of different offsets never overlap, @@ -33175,13 +33176,15 @@ This version of the operator has been available since version 28 of the default
dtype : int (default is 1)
The data type for the elements of the output tensor. If not specified, default is TensorProto::FLOAT.
generator : string (default is unspecified)
-
(Optional) The pseudo-random number generator algorithm. "unspecified" leaves the choice of generator to the implementation and provides no determinism guarantee: results may differ across implementations and even across runs of the same implementation, even when `seed` is specified (an implementation may produce reproducible results, but is not required to). "philox4x32_10" selects the fully specified Philox-4x32-10 counter-based algorithm described in the operator documentation, making the output deterministic for a given `seed`. More algorithms may be added in future opset versions.
+
(Optional) The pseudo-random number generator algorithm. "unspecified" leaves the choice of generator to the implementation and provides no determinism guarantee: results may differ across implementations and even across runs of the same implementation, even when a seed is specified (an implementation may produce reproducible results, but is not required to). "philox4x32_10" selects the fully specified Philox-4x32-10 counter-based algorithm described in the operator documentation, making the output deterministic for a given `seed_int64`. More algorithms may be added in future opset versions.
high : float (default is 1.0)
Upper boundary of the output values.
low : float (default is 0.0)
Lower boundary of the output values.
seed : float
-
(Optional) Seed to the random generator, if not specified we will auto generate one. Must be specified when `generator` is "philox4x32_10".
+
(Optional) Seed to the random generator, if not specified we will auto generate one. Used only when `generator` is "unspecified" (with implementation-defined effect); must not be specified together with a deterministic generator, which uses `seed_int64` instead.
+
seed_int64 : int
+
(Optional) 64-bit seed for the fully specified generators; its two's complement bits are interpreted as an unsigned 64-bit integer. Must be specified when `generator` is "philox4x32_10" (the float `seed` attribute is not used in that case). When `generator` is "unspecified", the effect of `seed_int64` is implementation-defined.
shape : list of ints (required)
The shape of the output tensor.
diff --git a/docs/Operators.md b/docs/Operators.md index b0c2aa38dc2..0b41bf53e6d 100644 --- a/docs/Operators.md +++ b/docs/Operators.md @@ -27518,23 +27518,24 @@ Other versions of this operator: 1 The `generator` attribute selects the pseudo-random number generator algorithm. With the default value "unspecified", the choice of generator is left to the implementation and no determinism guarantee is given: results may differ across - implementations and even across runs of the same implementation, even when - `seed` is specified. An implementation may produce reproducible results in this + implementations and even across runs of the same implementation, even when a + seed is specified. An implementation may produce reproducible results in this mode (for example for a fixed `seed`), but it is not required to. Setting `generator` to "philox4x32_10" fully specifies the generated values: given - the same `seed`, every conforming implementation must produce bit-identical + the same `seed_int64`, every conforming implementation must produce bit-identical results, which makes the operator deterministic and testable. More algorithms may be added in future opset versions. - When `generator` is "philox4x32_10", the `seed` attribute must be specified and - the output is computed with the Philox-4x32 counter-based generator with 10 - rounds (Salmon et al., "Parallel random numbers: as easy as 1, 2, 3", SC'11), - using the standard constants M0 = 0xD2511F53, M1 = 0xCD9E8D57, W0 = 0x9E3779B9, - W1 = 0xBB67AE85. All arithmetic on counter, key, and output words is unsigned - 32-bit modular arithmetic: - 1. The key is derived from `seed`, truncated toward zero and converted to an - unsigned 64-bit integer (modulo 2^64): `key0 = seed & 0xFFFFFFFF` and - `key1 = (seed >> 32) & 0xFFFFFFFF`. + When `generator` is "philox4x32_10", the `seed_int64` attribute must be specified + (the float `seed` attribute must not be used) and the output is computed with + the Philox-4x32 counter-based generator with 10 rounds (Salmon et al., + "Parallel random numbers: as easy as 1, 2, 3", SC'11), using the standard + constants M0 = 0xD2511F53, M1 = 0xCD9E8D57, W0 = 0x9E3779B9, W1 = 0xBB67AE85. + All arithmetic on counter, key, and output words is unsigned 32-bit modular + arithmetic: + 1. The key is the value of `seed_int64` with its two's complement bits interpreted + as an unsigned 64-bit integer: `key0 = seed_int64 & 0xFFFFFFFF` and + `key1 = (seed_int64 >> 32) & 0xFFFFFFFF`. 2. Counter block `b` (a 64-bit block index) is the 128-bit counter `(c0, c1, c2, c3) = (b & 0xFFFFFFFF, (b >> 32) & 0xFFFFFFFF, offset & 0xFFFFFFFF, (offset >> 32) & 0xFFFFFFFF)`, where `offset` is the @@ -27563,7 +27564,7 @@ Other versions of this operator: 1 semantics. Note that due to this rounding, the result may equal `high` for low-precision types. - Because Philox is counter-based, each output element depends only on `seed`, + Because Philox is counter-based, each output element depends only on `seed_int64`, `offset`, and its position `i`: elements can be computed independently, in any order, or in parallel. The block index occupies counter words `c0`/`c1` and the offset occupies `c2`/`c3`, so the streams of different offsets never overlap, @@ -27590,13 +27591,15 @@ Other versions of this operator: 1, <
dtype : int (default is 1)
The data type for the elements of the output tensor. If not specified, default is TensorProto::FLOAT.
generator : string (default is unspecified)
-
(Optional) The pseudo-random number generator algorithm. "unspecified" leaves the choice of generator to the implementation and provides no determinism guarantee: results may differ across implementations and even across runs of the same implementation, even when `seed` is specified (an implementation may produce reproducible results, but is not required to). "philox4x32_10" selects the fully specified Philox-4x32-10 counter-based algorithm described in the operator documentation, making the output deterministic for a given `seed`. More algorithms may be added in future opset versions.
+
(Optional) The pseudo-random number generator algorithm. "unspecified" leaves the choice of generator to the implementation and provides no determinism guarantee: results may differ across implementations and even across runs of the same implementation, even when a seed is specified (an implementation may produce reproducible results, but is not required to). "philox4x32_10" selects the fully specified Philox-4x32-10 counter-based algorithm described in the operator documentation, making the output deterministic for a given `seed_int64`. More algorithms may be added in future opset versions.
high : float (default is 1.0)
Upper boundary of the output values.
low : float (default is 0.0)
Lower boundary of the output values.
seed : float
-
(Optional) Seed to the random generator, if not specified we will auto generate one. Must be specified when `generator` is "philox4x32_10".
+
(Optional) Seed to the random generator, if not specified we will auto generate one. Used only when `generator` is "unspecified" (with implementation-defined effect); must not be specified together with a deterministic generator, which uses `seed_int64` instead.
+
seed_int64 : int
+
(Optional) 64-bit seed for the fully specified generators; its two's complement bits are interpreted as an unsigned 64-bit integer. Must be specified when `generator` is "philox4x32_10" (the float `seed` attribute is not used in that case). When `generator` is "unspecified", the effect of `seed_int64` is implementation-defined.
shape : list of ints (required)
The shape of the output tensor.
@@ -27640,7 +27643,7 @@ node = onnx.helper.make_node( inputs=[], outputs=["y"], shape=[3, 4], - seed=42.0, + seed_int64=42, generator="philox4x32_10", ) @@ -27670,7 +27673,7 @@ node = onnx.helper.make_node( outputs=["y"], dtype=onnx.TensorProto.BFLOAT16, shape=[10], - seed=3.0, + seed_int64=3, generator="philox4x32_10", ) @@ -27701,7 +27704,7 @@ node = onnx.helper.make_node( outputs=["y"], dtype=onnx.TensorProto.DOUBLE, shape=[2, 4], - seed=123.0, + seed_int64=123, generator="philox4x32_10", ) @@ -27731,7 +27734,7 @@ node = onnx.helper.make_node( outputs=["y"], dtype=onnx.TensorProto.FLOAT16, shape=[10], - seed=7.0, + seed_int64=7, generator="philox4x32_10", ) @@ -27762,7 +27765,7 @@ node = onnx.helper.make_node( low=5.0, high=10.0, shape=[2, 3], - seed=0.0, + seed_int64=0, generator="philox4x32_10", ) @@ -27792,7 +27795,7 @@ node = onnx.helper.make_node( inputs=[], outputs=["y"], shape=[5, 7], - seed=2024.0, + seed_int64=2024, generator="philox4x32_10", ) @@ -27827,7 +27830,7 @@ node = onnx.helper.make_node( low=-1.0, high=1.0, shape=[2, 3, 1, 5], - seed=11.0, + seed_int64=11, generator="philox4x32_10", ) @@ -27857,7 +27860,7 @@ node = onnx.helper.make_node( inputs=["offset"], outputs=["y"], shape=[2, 3], - seed=42.0, + seed_int64=42, generator="philox4x32_10", ) diff --git a/docs/TestCoverage.md b/docs/TestCoverage.md index 6b72ed4cafd..66106f424c2 100644 --- a/docs/TestCoverage.md +++ b/docs/TestCoverage.md @@ -19861,7 +19861,7 @@ node = onnx.helper.make_node( inputs=[], outputs=["y"], shape=[3, 4], - seed=42.0, + seed_int64=42, generator="philox4x32_10", ) @@ -19889,7 +19889,7 @@ node = onnx.helper.make_node( outputs=["y"], dtype=onnx.TensorProto.BFLOAT16, shape=[10], - seed=3.0, + seed_int64=3, generator="philox4x32_10", ) @@ -19918,7 +19918,7 @@ node = onnx.helper.make_node( outputs=["y"], dtype=onnx.TensorProto.DOUBLE, shape=[2, 4], - seed=123.0, + seed_int64=123, generator="philox4x32_10", ) @@ -19946,7 +19946,7 @@ node = onnx.helper.make_node( outputs=["y"], dtype=onnx.TensorProto.FLOAT16, shape=[10], - seed=7.0, + seed_int64=7, generator="philox4x32_10", ) @@ -19975,7 +19975,7 @@ node = onnx.helper.make_node( low=5.0, high=10.0, shape=[2, 3], - seed=0.0, + seed_int64=0, generator="philox4x32_10", ) @@ -20003,7 +20003,7 @@ node = onnx.helper.make_node( inputs=[], outputs=["y"], shape=[5, 7], - seed=2024.0, + seed_int64=2024, generator="philox4x32_10", ) @@ -20036,7 +20036,7 @@ node = onnx.helper.make_node( low=-1.0, high=1.0, shape=[2, 3, 1, 5], - seed=11.0, + seed_int64=11, generator="philox4x32_10", ) @@ -20064,7 +20064,7 @@ node = onnx.helper.make_node( inputs=["offset"], outputs=["y"], shape=[2, 3], - seed=42.0, + seed_int64=42, generator="philox4x32_10", ) diff --git a/onnx/backend/test/case/node/randomuniform.py b/onnx/backend/test/case/node/randomuniform.py index 57a8e2d2dc0..ff11d2a051b 100644 --- a/onnx/backend/test/case/node/randomuniform.py +++ b/onnx/backend/test/case/node/randomuniform.py @@ -73,7 +73,7 @@ def export_randomuniform_philox() -> None: inputs=[], outputs=["y"], shape=[3, 4], - seed=42.0, + seed_int64=42, generator="philox4x32_10", ) @@ -97,7 +97,7 @@ def export_randomuniform_philox_multi_block() -> None: inputs=[], outputs=["y"], shape=[5, 7], - seed=2024.0, + seed_int64=2024, generator="philox4x32_10", ) @@ -126,7 +126,7 @@ def export_randomuniform_philox_nd_shape() -> None: low=-1.0, high=1.0, shape=[2, 3, 1, 5], - seed=11.0, + seed_int64=11, generator="philox4x32_10", ) @@ -151,7 +151,7 @@ def export_randomuniform_philox_low_high() -> None: low=5.0, high=10.0, shape=[2, 3], - seed=0.0, + seed_int64=0, generator="philox4x32_10", ) @@ -176,7 +176,7 @@ def export_randomuniform_philox_double() -> None: outputs=["y"], dtype=onnx.TensorProto.DOUBLE, shape=[2, 4], - seed=123.0, + seed_int64=123, generator="philox4x32_10", ) @@ -200,7 +200,7 @@ def export_randomuniform_philox_bfloat16() -> None: outputs=["y"], dtype=onnx.TensorProto.BFLOAT16, shape=[10], - seed=3.0, + seed_int64=3, generator="philox4x32_10", ) @@ -224,7 +224,7 @@ def export_randomuniform_philox_offset() -> None: inputs=["offset"], outputs=["y"], shape=[2, 3], - seed=42.0, + seed_int64=42, generator="philox4x32_10", ) @@ -249,7 +249,7 @@ def export_randomuniform_philox_float16() -> None: outputs=["y"], dtype=onnx.TensorProto.FLOAT16, shape=[10], - seed=7.0, + seed_int64=7, generator="philox4x32_10", ) diff --git a/onnx/backend/test/data/node/test_randomuniform_philox/model.onnx b/onnx/backend/test/data/node/test_randomuniform_philox/model.onnx index 3146c5877053cb5f221db698d5f004f906870b65..b76703e0dfe37e28e59a0523bf7490146dbe2777 100644 GIT binary patch delta 51 zcmbQkIG<6JgI9xPeiWgI9G)>_kxqem*Xi;?&d>Q3eJFhXss`6Vp8b DKgtZf diff --git a/onnx/backend/test/data/node/test_randomuniform_philox_double/model.onnx b/onnx/backend/test/data/node/test_randomuniform_philox_double/model.onnx index 27c02b37202f11138377c64fbdd3069e0f4854af..908266b8b20e2faf9c1ec691a828514613b53d63 100644 GIT binary patch delta 51 zcmdnUxP?)agI9b`J3X delta 48 zcmdnOxRFtmgI9Q3i%@P74?rC#L%X E07se)hX4Qo diff --git a/onnx/backend/test/data/node/test_randomuniform_philox_float16/model.onnx b/onnx/backend/test/data/node/test_randomuniform_philox_float16/model.onnx index 09c7284b778ebd82550f47d71b2ac72511b45a26..422daeb4f909d9b395c715f8140d84a8af767098 100644 GIT binary patch delta 51 zcmZ3(xSmmzgI9_kxqem*Xi;?&d>Q3i$w4ht9=C#HJ< E07Ov@SpWb4 diff --git a/onnx/backend/test/data/node/test_randomuniform_philox_low_high/model.onnx b/onnx/backend/test/data/node/test_randomuniform_philox_low_high/model.onnx index 269ce5a1ea7b0310de69f7f81b068a1ad96a6f58..e73d985d002b82dd36a9eb0eb56d83069774a15a 100644 GIT binary patch delta 51 zcmX@Yc$`s`gI9wOem*Xi;?&d>Q3fDbz{ohU!XE%i C0}Qc$ra@gI9Q3eJB#|4ax6Dy(t DQ&J6L diff --git a/onnx/backend/test/data/node/test_randomuniform_philox_offset/model.onnx b/onnx/backend/test/data/node/test_randomuniform_philox_offset/model.onnx index 9870e06fec2237605652288865e41e5dda1008c5..1d2ffcdab2ee304a7b5c3ee120baf3b89a24c64e 100644 GIT binary patch delta 51 zcmdnXcz{uqgI9Q3eJLrv;3R6BFYB DMa~S} diff --git a/onnx/defs/doc_strings.cc b/onnx/defs/doc_strings.cc index d925ecff50b..1adba6b4345 100644 --- a/onnx/defs/doc_strings.cc +++ b/onnx/defs/doc_strings.cc @@ -165,23 +165,24 @@ TensorProto message. The `generator` attribute selects the pseudo-random number generator algorithm. With the default value "unspecified", the choice of generator is left to the implementation and no determinism guarantee is given: results may differ across -implementations and even across runs of the same implementation, even when -`seed` is specified. An implementation may produce reproducible results in this +implementations and even across runs of the same implementation, even when a +seed is specified. An implementation may produce reproducible results in this mode (for example for a fixed `seed`), but it is not required to. Setting `generator` to "philox4x32_10" fully specifies the generated values: given -the same `seed`, every conforming implementation must produce bit-identical +the same `seed_int64`, every conforming implementation must produce bit-identical results, which makes the operator deterministic and testable. More algorithms may be added in future opset versions. -When `generator` is "philox4x32_10", the `seed` attribute must be specified and -the output is computed with the Philox-4x32 counter-based generator with 10 -rounds (Salmon et al., "Parallel random numbers: as easy as 1, 2, 3", SC'11), -using the standard constants M0 = 0xD2511F53, M1 = 0xCD9E8D57, W0 = 0x9E3779B9, -W1 = 0xBB67AE85. All arithmetic on counter, key, and output words is unsigned -32-bit modular arithmetic: -1. The key is derived from `seed`, truncated toward zero and converted to an - unsigned 64-bit integer (modulo 2^64): `key0 = seed & 0xFFFFFFFF` and - `key1 = (seed >> 32) & 0xFFFFFFFF`. +When `generator` is "philox4x32_10", the `seed_int64` attribute must be specified +(the float `seed` attribute must not be used) and the output is computed with +the Philox-4x32 counter-based generator with 10 rounds (Salmon et al., +"Parallel random numbers: as easy as 1, 2, 3", SC'11), using the standard +constants M0 = 0xD2511F53, M1 = 0xCD9E8D57, W0 = 0x9E3779B9, W1 = 0xBB67AE85. +All arithmetic on counter, key, and output words is unsigned 32-bit modular +arithmetic: +1. The key is the value of `seed_int64` with its two's complement bits interpreted + as an unsigned 64-bit integer: `key0 = seed_int64 & 0xFFFFFFFF` and + `key1 = (seed_int64 >> 32) & 0xFFFFFFFF`. 2. Counter block `b` (a 64-bit block index) is the 128-bit counter `(c0, c1, c2, c3) = (b & 0xFFFFFFFF, (b >> 32) & 0xFFFFFFFF, offset & 0xFFFFFFFF, (offset >> 32) & 0xFFFFFFFF)`, where `offset` is the @@ -210,7 +211,7 @@ W1 = 0xBB67AE85. All arithmetic on counter, key, and output words is unsigned semantics. Note that due to this rounding, the result may equal `high` for low-precision types. -Because Philox is counter-based, each output element depends only on `seed`, +Because Philox is counter-based, each output element depends only on `seed_int64`, `offset`, and its position `i`: elements can be computed independently, in any order, or in parallel. The block index occupies counter words `c0`/`c1` and the offset occupies `c2`/`c3`, so the streams of different offsets never overlap, diff --git a/onnx/defs/generator/defs.cc b/onnx/defs/generator/defs.cc index 8d664a9072e..149f536068d 100644 --- a/onnx/defs/generator/defs.cc +++ b/onnx/defs/generator/defs.cc @@ -158,17 +158,26 @@ ONNX_OPERATOR_SET_SCHEMA( .Attr( "seed", "(Optional) Seed to the random generator, if not specified we will auto generate one. " - "Must be specified when `generator` is \"philox4x32_10\".", + "Used only when `generator` is \"unspecified\" (with implementation-defined effect); must not " + "be specified together with a deterministic generator, which uses `seed_int64` instead.", AttributeProto::FLOAT, OPTIONAL_VALUE) + .Attr( + "seed_int64", + "(Optional) 64-bit seed for the fully specified generators; its two's complement bits are " + "interpreted as an unsigned 64-bit integer. Must be specified when `generator` is " + "\"philox4x32_10\" (the float `seed` attribute is not used in that case). When `generator` is " + "\"unspecified\", the effect of `seed_int64` is implementation-defined.", + AttributeProto::INT, + OPTIONAL_VALUE) .Attr( "generator", "(Optional) The pseudo-random number generator algorithm. \"unspecified\" leaves the choice of " "generator to the implementation and provides no determinism guarantee: results may differ " - "across implementations and even across runs of the same implementation, even when `seed` is " + "across implementations and even across runs of the same implementation, even when a seed is " "specified (an implementation may produce reproducible results, but is not required to). " "\"philox4x32_10\" selects the fully specified Philox-4x32-10 counter-based algorithm described " - "in the operator documentation, making the output deterministic for a given `seed`. More " + "in the operator documentation, making the output deterministic for a given `seed_int64`. More " "algorithms may be added in future opset versions.", AttributeProto::STRING, std::string("unspecified")) @@ -204,8 +213,16 @@ ONNX_OPERATOR_SET_SCHEMA( fail_shape_inference( "Attribute 'generator' must be one of 'unspecified' or 'philox4x32_10', got '", generator, "'."); } - if (generator != "unspecified" && ctx.getAttribute("seed") == nullptr) { - fail_shape_inference("Attribute 'seed' must be specified when 'generator' is '", generator, "'."); + if (generator != "unspecified") { + if (ctx.getAttribute("seed_int64") == nullptr) { + fail_shape_inference("Attribute 'seed_int64' must be specified when 'generator' is '", generator, "'."); + } + if (ctx.getAttribute("seed") != nullptr) { + fail_shape_inference( + "Attribute 'seed' must not be specified when 'generator' is '", + generator, + "'; use 'seed_int64' instead."); + } } } propagateElemTypeFromAttributeToOutput(ctx, "dtype", 0, TensorProto::FLOAT); diff --git a/onnx/reference/ops/_op_common_random.py b/onnx/reference/ops/_op_common_random.py index 7c05252ab76..b5e3251c93d 100644 --- a/onnx/reference/ops/_op_common_random.py +++ b/onnx/reference/ops/_op_common_random.py @@ -162,11 +162,11 @@ def _get_state(seed): return state @staticmethod - def _deterministic_uniform(generator, seed, shape, dtype, offset=0): + def _deterministic_uniform(generator, seed_int64, shape, dtype, offset=0): """Draw uniform values in [0, 1) with the fully specified generator. Unlike the "unspecified" generator, the result is bit-identical across - implementations for a given seed and offset (see the operator + implementations for a given seed_int64 and offset (see the operator specification). The resolution of the values matches the precision of `dtype`: double combines two 32-bit output words per element, all other float types use one word per element, keeping every value @@ -177,12 +177,12 @@ def _deterministic_uniform(generator, seed, shape, dtype, offset=0): f"Unsupported value {generator!r} for attribute 'generator' " f"(expected 'unspecified' or 'philox4x32_10')." ) - if seed is None or np.isnan(seed): + if seed_int64 is None: raise ValueError( - "Attribute 'seed' must be specified when 'generator' is " + "Attribute 'seed_int64' must be specified when 'generator' is " "'philox4x32_10'." ) - state = _Philox4x32(int(seed), offset) + state = _Philox4x32(int(seed_int64), offset) num = int(np.prod(shape)) if np.dtype(dtype) == np.float64: res = state.random_res53(num) diff --git a/onnx/reference/ops/op_random_uniform.py b/onnx/reference/ops/op_random_uniform.py index 510652688f2..a5db7fe7e35 100644 --- a/onnx/reference/ops/op_random_uniform.py +++ b/onnx/reference/ops/op_random_uniform.py @@ -17,13 +17,14 @@ def _run( high=None, low=None, seed=None, + seed_int64=None, shape=None, ): dtype = self._dtype(dtype=dtype) offset_value = 0 if offset is None else int(np.asarray(offset).item()) if generator not in (None, "unspecified"): res = self._deterministic_uniform( - generator, seed, shape, dtype, offset_value + generator, seed_int64, shape, dtype, offset_value ) # low + r * (high - low), evaluated in the target data type low_t = np.asarray(low, dtype=dtype) diff --git a/onnx/test/reference_evaluator_test.py b/onnx/test/reference_evaluator_test.py index e7d4ee1e3c3..5f511b11da1 100644 --- a/onnx/test/reference_evaluator_test.py +++ b/onnx/test/reference_evaluator_test.py @@ -1483,7 +1483,7 @@ def test_onnxt_runtime_random_uniform_philox(self): "RandomUniform", [], ["Y"], - seed=42.0, + seed_int64=42, shape=[2, 3], generator="philox4x32_10", ) @@ -1513,7 +1513,7 @@ def test_onnxt_runtime_random_uniform_philox_low_high(self): "RandomUniform", [], ["Y"], - seed=42.0, + seed_int64=42, low=5.0, high=10.0, dtype=TensorProto.DOUBLE, @@ -1541,7 +1541,7 @@ def test_onnxt_runtime_random_uniform_philox_bfloat16(self): "RandomUniform", [], ["Y"], - seed=3.0, + seed_int64=3, dtype=TensorProto.BFLOAT16, shape=[4], generator="philox4x32_10", @@ -1562,16 +1562,17 @@ def test_onnxt_runtime_random_uniform_philox_bfloat16(self): def test_onnxt_runtime_random_uniform_philox_element_independence(self): # Intent: Philox is counter-based, so element i depends only on - # (seed, i) — never on how many elements are generated. The row-major - # values of a smaller tensor must therefore be a prefix of any larger - # tensor with the same seed, across counter-block boundaries (4 words - # per block; 26 elements span 7 blocks, the last one partially). - def run_philox(shape, seed): + # (seed_int64, i) — never on how many elements are generated. The + # row-major values of a smaller tensor must therefore be a prefix of + # any larger tensor with the same seed, across counter-block + # boundaries (4 words per block; 26 elements span 7 blocks, the last + # one partially). + def run_philox(shape, seed_int64): node1 = make_node( "RandomUniform", [], ["Y"], - seed=seed, + seed_int64=seed_int64, shape=shape, generator="philox4x32_10", ) @@ -1581,13 +1582,13 @@ def run_philox(shape, seed): check_model(onnx_model) return ReferenceEvaluator(onnx_model).run(None, {})[0].ravel() - small = run_philox([3], 99.0) - medium = run_philox([2, 3], 99.0) - large = run_philox([13, 2], 99.0) + small = run_philox([3], 99) + medium = run_philox([2, 3], 99) + large = run_philox([13, 2], 99) assert_allclose(medium[:3], small, rtol=0, atol=0) assert_allclose(large[:6], medium, rtol=0, atol=0) # A different seed keys every block differently. - other_seed = run_philox([13, 2], 100.0) + other_seed = run_philox([13, 2], 100) self.assertFalse(np.array_equal(large, other_seed)) def test_onnxt_runtime_random_uniform_philox_offset_streaming(self): @@ -1601,7 +1602,7 @@ def test_onnxt_runtime_random_uniform_philox_offset_streaming(self): "RandomUniform", ["offset"], ["Y"], - seed=42.0, + seed_int64=42, shape=[2, 3], generator="philox4x32_10", ) @@ -1624,7 +1625,7 @@ def test_onnxt_runtime_random_uniform_philox_offset_streaming(self): "RandomUniform", [], ["Y"], - seed=42.0, + seed_int64=42, shape=[2, 3], generator="philox4x32_10", ) diff --git a/onnx/test/schema_test.py b/onnx/test/schema_test.py index 62a593476f3..cae87e7561a 100644 --- a/onnx/test/schema_test.py +++ b/onnx/test/schema_test.py @@ -85,10 +85,17 @@ def test_randomuniform_generator_attribute(self) -> None: self.assertEqual(generator.type, defs.OpSchema.AttrType.STRING) self.assertEqual(generator.default_value.s, b"unspecified") self.assertFalse(generator.required) + # The 64-bit seed for deterministic generators is a separate INT + # attribute; the legacy float seed only applies to "unspecified". + seed_int64 = schema28.attributes["seed_int64"] + self.assertEqual(seed_int64.type, defs.OpSchema.AttrType.INT) + self.assertFalse(seed_int64.required) # The operator stays non-deterministic at the schema level: with the # "unspecified" generator the output is still implementation-defined. self.assertTrue(schema28.non_deterministic) - self.assertNotIn("generator", defs.get_schema("RandomUniform", 22).attributes) + schema22 = defs.get_schema("RandomUniform", 22) + self.assertNotIn("generator", schema22.attributes) + self.assertNotIn("seed_int64", schema22.attributes) def test_range_supported_types(self) -> None: """Test Range operator supports all expected numeric types.""" diff --git a/onnx/test/shape_inference_test.py b/onnx/test/shape_inference_test.py index d929ef930e7..be7199a0a9c 100644 --- a/onnx/test/shape_inference_test.py +++ b/onnx/test/shape_inference_test.py @@ -4414,7 +4414,7 @@ def test_random_uniform_philox(self) -> None: ["out"], dtype=TensorProto.DOUBLE, shape=(3, 4), - seed=42.0, + seed_int64=42, generator="philox4x32_10", ) ], @@ -4424,6 +4424,24 @@ def test_random_uniform_philox(self) -> None: graph, [make_tensor_value_info("out", TensorProto.DOUBLE, (3, 4))] ) + def test_random_uniform_philox_with_float_seed_fails(self) -> None: + graph = self._make_graph( + [], + [ + make_node( + "RandomUniform", + [], + ["out"], + shape=(3, 4), + seed=42.0, + seed_int64=42, + generator="philox4x32_10", + ) + ], + [], + ) + self.assertRaises(onnx.shape_inference.InferenceError, self._inferred, graph) + def test_random_uniform_offset(self) -> None: graph = self._make_graph( [("offset", TensorProto.INT64, ())], @@ -4433,7 +4451,7 @@ def test_random_uniform_offset(self) -> None: ["offset"], ["out"], shape=(3, 4), - seed=0.0, + seed_int64=0, generator="philox4x32_10", ) ], diff --git a/onnx/test/version_converter_test.py b/onnx/test/version_converter_test.py index 58ea2cddb61..7faf4fd0719 100644 --- a/onnx/test/version_converter_test.py +++ b/onnx/test/version_converter_test.py @@ -2930,10 +2930,18 @@ def test_randomuniform_28_27_philox_fails(self) -> None: self.assertRaises( RuntimeError, lambda: self._randomuniform_converted( - 28, 27, generator="philox4x32_10", seed=42.0 + 28, 27, generator="philox4x32_10", seed_int64=42 ), ) + # RandomUniform 28 -> 27: the seed_int64 attribute cannot be expressed in + # older opsets and must be rejected + def test_randomuniform_28_27_seed_int64_fails(self) -> None: + self.assertRaises( + RuntimeError, + lambda: self._randomuniform_converted(28, 27, seed_int64=5), + ) + # RandomUniform 28 -> 27: the offset input cannot be expressed in older # opsets and must be rejected def test_randomuniform_28_27_offset_fails(self) -> None: diff --git a/onnx/version_converter/adapters/random_uniform_28_27.h b/onnx/version_converter/adapters/random_uniform_28_27.h index b9b1a5a2b6b..29a48074de7 100644 --- a/onnx/version_converter/adapters/random_uniform_28_27.h +++ b/onnx/version_converter/adapters/random_uniform_28_27.h @@ -28,6 +28,14 @@ class RandomUniform_28_27 final : public Adapter { "' with an 'offset' input is not supported in Opset Version ", static_cast(target_version().version()), "."); + // seed_int64 does not exist in RandomUniform v22. + ONNX_ASSERTM( + !node->hasAttribute(Symbol("seed_int64")), + "Attribute 'seed_int64' of operator '", + name(), + "' is not supported in Opset Version ", + static_cast(target_version().version()), + "."); const Symbol generator("generator"); if (node->hasAttribute(generator)) { // "unspecified" matches the implementation-defined behavior of From 017745ef21187d20da98b623ed2d7e326eea2ea0 Mon Sep 17 00:00:00 2001 From: Timo Stripf Date: Sun, 5 Jul 2026 17:31:41 +0000 Subject: [PATCH 7/8] Address review findings: adapter placeholder input, offset rank check, shared helpers Correctness: - The 28->27 adapter no longer rejects checker-valid models whose optional offset input is spelled as an empty string: the kUndefined placeholder the proto importer materializes is now detected and removed (precedent: axis_input_to_attribute.h), with a regression test. Real offset inputs are still rejected. - Shape inference now enforces that offset is a scalar via checkInputRank, as the spec promises; previously a [2,3] offset passed full_check and crashed the reference with a cryptic numpy error. - The reference implementation now enforces all three validation rules of the spec: a float seed alongside generator="philox4x32_10" raises instead of being silently ignored. Rollout preparation (the same mechanism is planned for five more ops): - generator/seed/seed_int64/offset attribute docs and the validation logic moved to shared constants and ValidateRandomGeneratorAttributes() in onnx/defs/generator/utils. - The downgrade adapter is parametrized (op name, number of legacy inputs) and renamed to RandomGenerator_28_27, so the Like-ops with a mandatory first input can reuse it. - _deterministic_uniform now owns validation, offset coercion, and the bit-exact affine step low + r * (high - low) in the target dtype, so future ops cannot diverge in the last ULP; misleading error text fixed. Performance (verified bit-identical, test data unchanged): - offset counter words broadcast as scalars instead of np.full - float32 intermediate for non-double dtypes - astype(dtype, copy=False) avoids a full copy in the double path - block-to-word-stream scaffolding deduplicated (_words helper) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PVuibCDPDmYP2zoMawf9sp Signed-off-by: Timo Stripf --- docs/Changelog.md | 4 +- docs/Operators.md | 4 +- onnx/defs/generator/defs.cc | 64 ++------------ onnx/defs/generator/utils.cc | 23 +++++ onnx/defs/generator/utils.h | 36 ++++++++ onnx/reference/ops/_op_common_random.py | 84 +++++++++++-------- onnx/reference/ops/op_random_uniform.py | 13 +-- onnx/test/reference_evaluator_test.py | 19 +++++ onnx/test/shape_inference_test.py | 17 ++++ onnx/test/version_converter_test.py | 15 ++++ .../version_converter/adapters/CMakeLists.txt | 2 +- .../adapters/random_generator_28_27.h | 74 ++++++++++++++++ .../adapters/random_uniform_28_27.h | 59 ------------- onnx/version_converter/convert.h | 7 +- 14 files changed, 252 insertions(+), 169 deletions(-) create mode 100644 onnx/version_converter/adapters/random_generator_28_27.h delete mode 100644 onnx/version_converter/adapters/random_uniform_28_27.h diff --git a/docs/Changelog.md b/docs/Changelog.md index 611fce5d090..a883b1654ae 100644 --- a/docs/Changelog.md +++ b/docs/Changelog.md @@ -33176,7 +33176,7 @@ This version of the operator has been available since version 28 of the default
dtype : int (default is 1)
The data type for the elements of the output tensor. If not specified, default is TensorProto::FLOAT.
generator : string (default is unspecified)
-
(Optional) The pseudo-random number generator algorithm. "unspecified" leaves the choice of generator to the implementation and provides no determinism guarantee: results may differ across implementations and even across runs of the same implementation, even when a seed is specified (an implementation may produce reproducible results, but is not required to). "philox4x32_10" selects the fully specified Philox-4x32-10 counter-based algorithm described in the operator documentation, making the output deterministic for a given `seed_int64`. More algorithms may be added in future opset versions.
+
(Optional) The pseudo-random number generator algorithm: "unspecified" leaves the choice of generator to the implementation and provides no determinism guarantee, even when a seed is specified; "philox4x32_10" selects the fully specified Philox-4x32-10 counter-based algorithm described in the operator documentation, making the output deterministic for a given `seed_int64`. More algorithms may be added in future opset versions.
high : float (default is 1.0)
Upper boundary of the output values.
low : float (default is 0.0)
@@ -33193,7 +33193,7 @@ This version of the operator has been available since version 28 of the default
offset (optional) : T2
-
(Optional) Scalar 64-bit stream offset, 0 if not provided. Each offset value selects an independent random stream: with `generator` = "philox4x32_10" it is placed in the counter words `c2`/`c3` (its two's complement bits interpreted as unsigned), so the streams of different offsets never overlap, regardless of the output size. For streaming inference, feed a different offset in every run (any non-repeating scheme works, e.g. a step counter maintained by the host or computed in the graph) to draw fresh, yet reproducible, values per run; feed a constant (or omit the input) to draw the same values in every run. When `generator` is "unspecified", the effect of `offset` on the generated values is implementation-defined.
+
(Optional) Scalar 64-bit stream offset, 0 if not provided. Each offset value selects an independent random stream (see the operator documentation for the exact semantics): feed a different offset in every run (any non-repeating scheme works, e.g. a step counter) to draw fresh, yet reproducible, values per run, or feed a constant (or omit the input) to draw the same values in every run. When `generator` is "unspecified", the effect of `offset` on the generated values is implementation-defined.
#### Outputs diff --git a/docs/Operators.md b/docs/Operators.md index 0b41bf53e6d..2870daacd1c 100644 --- a/docs/Operators.md +++ b/docs/Operators.md @@ -27591,7 +27591,7 @@ Other versions of this operator: 1, <
dtype : int (default is 1)
The data type for the elements of the output tensor. If not specified, default is TensorProto::FLOAT.
generator : string (default is unspecified)
-
(Optional) The pseudo-random number generator algorithm. "unspecified" leaves the choice of generator to the implementation and provides no determinism guarantee: results may differ across implementations and even across runs of the same implementation, even when a seed is specified (an implementation may produce reproducible results, but is not required to). "philox4x32_10" selects the fully specified Philox-4x32-10 counter-based algorithm described in the operator documentation, making the output deterministic for a given `seed_int64`. More algorithms may be added in future opset versions.
+
(Optional) The pseudo-random number generator algorithm: "unspecified" leaves the choice of generator to the implementation and provides no determinism guarantee, even when a seed is specified; "philox4x32_10" selects the fully specified Philox-4x32-10 counter-based algorithm described in the operator documentation, making the output deterministic for a given `seed_int64`. More algorithms may be added in future opset versions.
high : float (default is 1.0)
Upper boundary of the output values.
low : float (default is 0.0)
@@ -27608,7 +27608,7 @@ Other versions of this operator: 1, <
offset (optional) : T2
-
(Optional) Scalar 64-bit stream offset, 0 if not provided. Each offset value selects an independent random stream: with `generator` = "philox4x32_10" it is placed in the counter words `c2`/`c3` (its two's complement bits interpreted as unsigned), so the streams of different offsets never overlap, regardless of the output size. For streaming inference, feed a different offset in every run (any non-repeating scheme works, e.g. a step counter maintained by the host or computed in the graph) to draw fresh, yet reproducible, values per run; feed a constant (or omit the input) to draw the same values in every run. When `generator` is "unspecified", the effect of `offset` on the generated values is implementation-defined.
+
(Optional) Scalar 64-bit stream offset, 0 if not provided. Each offset value selects an independent random stream (see the operator documentation for the exact semantics): feed a different offset in every run (any non-repeating scheme works, e.g. a step counter) to draw fresh, yet reproducible, values per run, or feed a constant (or omit the input) to draw the same values in every run. When `generator` is "unspecified", the effect of `offset` on the generated values is implementation-defined.
#### Outputs diff --git a/onnx/defs/generator/defs.cc b/onnx/defs/generator/defs.cc index 149f536068d..3a38fa7ccd4 100644 --- a/onnx/defs/generator/defs.cc +++ b/onnx/defs/generator/defs.cc @@ -155,76 +155,22 @@ ONNX_OPERATOR_SET_SCHEMA( .SetDoc(kDoc_RandomUniform_ver28) .Attr("low", "Lower boundary of the output values.", AttributeProto::FLOAT, 0.0f) .Attr("high", "Upper boundary of the output values.", AttributeProto::FLOAT, 1.0f) - .Attr( - "seed", - "(Optional) Seed to the random generator, if not specified we will auto generate one. " - "Used only when `generator` is \"unspecified\" (with implementation-defined effect); must not " - "be specified together with a deterministic generator, which uses `seed_int64` instead.", - AttributeProto::FLOAT, - OPTIONAL_VALUE) - .Attr( - "seed_int64", - "(Optional) 64-bit seed for the fully specified generators; its two's complement bits are " - "interpreted as an unsigned 64-bit integer. Must be specified when `generator` is " - "\"philox4x32_10\" (the float `seed` attribute is not used in that case). When `generator` is " - "\"unspecified\", the effect of `seed_int64` is implementation-defined.", - AttributeProto::INT, - OPTIONAL_VALUE) - .Attr( - "generator", - "(Optional) The pseudo-random number generator algorithm. \"unspecified\" leaves the choice of " - "generator to the implementation and provides no determinism guarantee: results may differ " - "across implementations and even across runs of the same implementation, even when a seed is " - "specified (an implementation may produce reproducible results, but is not required to). " - "\"philox4x32_10\" selects the fully specified Philox-4x32-10 counter-based algorithm described " - "in the operator documentation, making the output deterministic for a given `seed_int64`. More " - "algorithms may be added in future opset versions.", - AttributeProto::STRING, - std::string("unspecified")) + .Attr("seed", kRandomGeneratorSeedAttrDoc, AttributeProto::FLOAT, OPTIONAL_VALUE) + .Attr("seed_int64", kRandomGeneratorSeedInt64AttrDoc, AttributeProto::INT, OPTIONAL_VALUE) + .Attr("generator", kRandomGeneratorAttrDoc, AttributeProto::STRING, std::string("unspecified")) .Attr( "dtype", "The data type for the elements of the output tensor. If not specified, default is TensorProto::FLOAT.", AttributeProto::INT, static_cast(TensorProto::FLOAT)) .Attr("shape", "The shape of the output tensor.", AttributeProto::INTS) - .Input( - 0, - "offset", - "(Optional) Scalar 64-bit stream offset, 0 if not provided. Each offset value selects an " - "independent random stream: with `generator` = \"philox4x32_10\" it is placed in the counter " - "words `c2`/`c3` (its two's complement bits interpreted as unsigned), so the streams of " - "different offsets never overlap, regardless of the output size. For streaming inference, " - "feed a different offset in every run (any non-repeating scheme works, e.g. a step counter " - "maintained by the host or computed in the graph) to draw fresh, yet reproducible, values per " - "run; feed a constant (or omit the input) to draw the same values in every run. When " - "`generator` is \"unspecified\", the effect of `offset` on the generated values is " - "implementation-defined.", - "T2", - OpSchema::Optional) + .Input(0, "offset", kRandomGeneratorOffsetInputDoc, "T2", OpSchema::Optional) .Output(0, "output", "Output tensor of random values drawn from uniform distribution", "T") .TypeConstraint("T", OpSchema::all_float_types_ir4(), "Constrain output types to float tensors.") .TypeConstraint("T2", {types::Int64}, "Constrain the stream offset to int64.") .SetNodeDeterminism(OpSchema::NodeDeterminism::NonDeterministic) .TypeAndShapeInferenceFunction([](InferenceContext& ctx) { - const auto* generator_attr = ctx.getAttribute("generator"); - if (generator_attr != nullptr) { - const std::string& generator = generator_attr->s(); - if (generator != "unspecified" && generator != "philox4x32_10") { - fail_shape_inference( - "Attribute 'generator' must be one of 'unspecified' or 'philox4x32_10', got '", generator, "'."); - } - if (generator != "unspecified") { - if (ctx.getAttribute("seed_int64") == nullptr) { - fail_shape_inference("Attribute 'seed_int64' must be specified when 'generator' is '", generator, "'."); - } - if (ctx.getAttribute("seed") != nullptr) { - fail_shape_inference( - "Attribute 'seed' must not be specified when 'generator' is '", - generator, - "'; use 'seed_int64' instead."); - } - } - } + ValidateRandomGeneratorAttributes(ctx, 0); propagateElemTypeFromAttributeToOutput(ctx, "dtype", 0, TensorProto::FLOAT); propagateShapeFromAttributeToOutput(ctx, "shape", 0); })); diff --git a/onnx/defs/generator/utils.cc b/onnx/defs/generator/utils.cc index 8eacc2dbdb3..da12c9efe2a 100644 --- a/onnx/defs/generator/utils.cc +++ b/onnx/defs/generator/utils.cc @@ -108,4 +108,27 @@ void ConstantOpInference(InferenceContext& ctx) { "this line should never be reached."); } +void ValidateRandomGeneratorAttributes(InferenceContext& ctx, int offset_input_index) { + const auto* generator_attr = ctx.getAttribute("generator"); + if (generator_attr != nullptr) { + const std::string& generator = generator_attr->s(); + if (generator != "unspecified" && generator != "philox4x32_10") { + fail_shape_inference( + "Attribute 'generator' must be one of 'unspecified' or 'philox4x32_10', got '", generator, "'."); + } + if (generator != "unspecified") { + if (ctx.getAttribute("seed_int64") == nullptr) { + fail_shape_inference("Attribute 'seed_int64' must be specified when 'generator' is '", generator, "'."); + } + if (ctx.getAttribute("seed") != nullptr) { + fail_shape_inference( + "Attribute 'seed' must not be specified when 'generator' is '", generator, "'; use 'seed_int64' instead."); + } + } + } + if (offset_input_index >= 0) { + checkInputRank(ctx, static_cast(offset_input_index), 0); + } +} + } // namespace ONNX_NAMESPACE diff --git a/onnx/defs/generator/utils.h b/onnx/defs/generator/utils.h index 899a02b682a..1e2cb392175 100644 --- a/onnx/defs/generator/utils.h +++ b/onnx/defs/generator/utils.h @@ -14,6 +14,42 @@ namespace ONNX_NAMESPACE { void ConstantOpInference(InferenceContext& ctx); +// Shared documentation for the deterministic random-generator mechanism +// (generator / seed / seed_int64 attributes and the offset input), introduced +// with RandomUniform-28 and intended to be reused verbatim when the other +// random operators adopt the same mechanism. +inline constexpr const char* kRandomGeneratorSeedAttrDoc = + "(Optional) Seed to the random generator, if not specified we will auto generate one. " + "Used only when `generator` is \"unspecified\" (with implementation-defined effect); must not " + "be specified together with a deterministic generator, which uses `seed_int64` instead."; + +inline constexpr const char* kRandomGeneratorSeedInt64AttrDoc = + "(Optional) 64-bit seed for the fully specified generators; its two's complement bits are " + "interpreted as an unsigned 64-bit integer. Must be specified when `generator` is " + "\"philox4x32_10\" (the float `seed` attribute is not used in that case). When `generator` is " + "\"unspecified\", the effect of `seed_int64` is implementation-defined."; + +inline constexpr const char* kRandomGeneratorAttrDoc = + "(Optional) The pseudo-random number generator algorithm: \"unspecified\" leaves the choice of " + "generator to the implementation and provides no determinism guarantee, even when a seed is " + "specified; \"philox4x32_10\" selects the fully specified Philox-4x32-10 counter-based algorithm " + "described in the operator documentation, making the output deterministic for a given " + "`seed_int64`. More algorithms may be added in future opset versions."; + +inline constexpr const char* kRandomGeneratorOffsetInputDoc = + "(Optional) Scalar 64-bit stream offset, 0 if not provided. Each offset value selects an " + "independent random stream (see the operator documentation for the exact semantics): feed a " + "different offset in every run (any non-repeating scheme works, e.g. a step counter) to draw " + "fresh, yet reproducible, values per run, or feed a constant (or omit the input) to draw the " + "same values in every run. When `generator` is \"unspecified\", the effect of `offset` on the " + "generated values is implementation-defined."; + +// Validates the deterministic random-generator attributes: `generator` must +// be a known algorithm, deterministic generators require `seed_int64` and +// forbid the float `seed`, and the optional offset input (identified by +// `offset_input_index`, or -1 if the operator has none) must be a scalar. +void ValidateRandomGeneratorAttributes(InferenceContext& ctx, int offset_input_index); + template int64_t compute_output_dim_for_range(const TensorProto* start, const TensorProto* limit, const TensorProto* delta) { if (!start->dims().empty() || !limit->dims().empty() || !delta->dims().empty()) { diff --git a/onnx/reference/ops/_op_common_random.py b/onnx/reference/ops/_op_common_random.py index b5e3251c93d..a4d877f5476 100644 --- a/onnx/reference/ops/_op_common_random.py +++ b/onnx/reference/ops/_op_common_random.py @@ -72,20 +72,25 @@ def philox4x32_10(cls, c0, c1, c2, c3, key0: int, key1: int): c3.astype(np.uint32), ) - def _blocks(self, num_blocks: int): - """Output words of counter blocks 0 .. num_blocks-1. + def _words(self, num: int, words_per_element: int) -> np.ndarray: + """Output words of enough counter blocks for `num` elements. Block ``b`` uses the counter ``(lo32(b), hi32(b), lo32(offset), - hi32(offset))``. + hi32(offset))``. Returns the words as an array of shape + ``(num_blocks, 4)`` in block order. """ + num_blocks = (num * words_per_element + 3) // 4 b = np.arange(num_blocks, dtype=np.uint64) - return self.philox4x32_10( - b & np.uint64(0xFFFFFFFF), - b >> np.uint64(32), - np.full(num_blocks, self._offset0, dtype=np.uint64), - np.full(num_blocks, self._offset1, dtype=np.uint64), - self._key0, - self._key1, + return np.stack( + self.philox4x32_10( + b & np.uint64(0xFFFFFFFF), + b >> np.uint64(32), + np.uint64(self._offset0), + np.uint64(self._offset1), + self._key0, + self._key1, + ), + axis=1, ) def random_res53(self, num: int) -> np.ndarray: @@ -94,10 +99,9 @@ def random_res53(self, num: int) -> np.ndarray: Element `i` combines words ``2*(i mod 2)`` and ``2*(i mod 2) + 1`` of block ``i // 2`` as ``(floor(a / 2^5) * 2^26 + floor(b / 2^6)) / 2^53``. """ - num_blocks = (num + 1) // 2 - w0, w1, w2, w3 = self._blocks(num_blocks) - a = np.stack([w0, w2], axis=1).reshape(-1)[:num] >> np.uint32(5) - b = np.stack([w1, w3], axis=1).reshape(-1)[:num] >> np.uint32(6) + w = self._words(num, 2) + a = w[:, [0, 2]].reshape(-1)[:num] >> np.uint32(5) + b = w[:, [1, 3]].reshape(-1)[:num] >> np.uint32(6) return (a.astype(np.float64) * 67108864.0 + b.astype(np.float64)) * ( 1.0 / 9007199254740992.0 ) @@ -106,14 +110,13 @@ def random_res(self, num: int, precision: int) -> np.ndarray: """Draw `num` values in [0, 1) with `precision` significand bits. Element `i` uses word ``i mod 4`` of block ``i // 4``: - ``(w >> (32 - p)) / 2^p``. The results are exactly representable in - any binary float type with at least `precision` significand bits. + ``(w >> (32 - p)) / 2^p``. Each value has at most 24 significand + bits, so the float32 result is exact and representable in any binary + float type with at least `precision` significand bits. """ - num_blocks = (num + 3) // 4 - w0, w1, w2, w3 = self._blocks(num_blocks) - words = np.stack([w0, w1, w2, w3], axis=1).reshape(-1)[:num] - scale = 1.0 / (1 << precision) - return (words >> np.uint32(32 - precision)).astype(np.float64) * scale + words = self._words(num, 1).reshape(-1)[:num] + scale = np.float32(1.0 / (1 << precision)) + return (words >> np.uint32(32 - precision)).astype(np.float32) * scale class _CommonRandom(OpRun): @@ -162,27 +165,36 @@ def _get_state(seed): return state @staticmethod - def _deterministic_uniform(generator, seed_int64, shape, dtype, offset=0): - """Draw uniform values in [0, 1) with the fully specified generator. - - Unlike the "unspecified" generator, the result is bit-identical across + def _deterministic_uniform( + generator, seed, seed_int64, shape, dtype, low, high, offset + ): + """Compute the fully specified deterministic uniform output. + + Validates the generator attributes, draws values in [0, 1) with a + resolution matching the precision of `dtype` (double combines two + 32-bit output words per element, all other float types use one word + per element, keeping every value exactly representable in `dtype`), + and evaluates ``low + r * (high - low)`` in `dtype`. Unlike the + "unspecified" generator, the result is bit-identical across implementations for a given seed_int64 and offset (see the operator - specification). The resolution of the values matches the precision of - `dtype`: double combines two 32-bit output words per element, all - other float types use one word per element, keeping every value - exactly representable in `dtype`. + specification). """ if generator != "philox4x32_10": raise ValueError( - f"Unsupported value {generator!r} for attribute 'generator' " - f"(expected 'unspecified' or 'philox4x32_10')." + f"Unsupported value {generator!r} for attribute 'generator'." ) if seed_int64 is None: raise ValueError( "Attribute 'seed_int64' must be specified when 'generator' is " - "'philox4x32_10'." + f"{generator!r}." + ) + if seed is not None: + raise ValueError( + "Attribute 'seed' must not be specified when 'generator' is " + f"{generator!r}; use 'seed_int64' instead." ) - state = _Philox4x32(int(seed_int64), offset) + offset_value = 0 if offset is None else int(np.asarray(offset).item()) + state = _Philox4x32(int(seed_int64), offset_value) num = int(np.prod(shape)) if np.dtype(dtype) == np.float64: res = state.random_res53(num) @@ -190,4 +202,8 @@ def _deterministic_uniform(generator, seed_int64, shape, dtype, offset=0): # ml_dtypes.finfo also covers non-native types such as bfloat16 precision = ml_dtypes.finfo(dtype).nmant + 1 res = state.random_res(num, precision) - return res.reshape(shape).astype(dtype) + res = res.reshape(shape).astype(dtype, copy=False) + # low + r * (high - low), evaluated in the target data type + low_t = np.asarray(low, dtype=dtype) + high_t = np.asarray(high, dtype=dtype) + return res * (high_t - low_t) + low_t diff --git a/onnx/reference/ops/op_random_uniform.py b/onnx/reference/ops/op_random_uniform.py index a5db7fe7e35..bdf026c0f02 100644 --- a/onnx/reference/ops/op_random_uniform.py +++ b/onnx/reference/ops/op_random_uniform.py @@ -3,8 +3,6 @@ # SPDX-License-Identifier: Apache-2.0 from __future__ import annotations -import numpy as np - from onnx.reference.ops._op_common_random import _CommonRandom @@ -21,15 +19,12 @@ def _run( shape=None, ): dtype = self._dtype(dtype=dtype) - offset_value = 0 if offset is None else int(np.asarray(offset).item()) if generator not in (None, "unspecified"): - res = self._deterministic_uniform( - generator, seed_int64, shape, dtype, offset_value + return ( + self._deterministic_uniform( + generator, seed, seed_int64, shape, dtype, low, high, offset + ), ) - # low + r * (high - low), evaluated in the target data type - low_t = np.asarray(low, dtype=dtype) - high_t = np.asarray(high, dtype=dtype) - return (res * (high_t - low_t) + low_t,) # The effect of offset on the values is implementation-defined for # the "unspecified" generator; it is ignored here. state = self._get_state(seed) diff --git a/onnx/test/reference_evaluator_test.py b/onnx/test/reference_evaluator_test.py index 5f511b11da1..58b92fe31a4 100644 --- a/onnx/test/reference_evaluator_test.py +++ b/onnx/test/reference_evaluator_test.py @@ -1646,6 +1646,25 @@ def test_onnxt_runtime_random_uniform_philox_no_seed_raises(self): with self.assertRaises(ValueError): sess.run(None, {}) + def test_onnxt_runtime_random_uniform_philox_float_seed_raises(self): + # The float seed attribute is forbidden alongside a deterministic + # generator; the reference must enforce this like shape inference. + Y = make_tensor_value_info("Y", TensorProto.FLOAT, [None]) + node1 = make_node( + "RandomUniform", + [], + ["Y"], + shape=[2, 3], + seed=0.0, + seed_int64=0, + generator="philox4x32_10", + ) + graph = make_graph([node1], "g", [], [Y]) + onnx_model = make_model(graph) + sess = ReferenceEvaluator(onnx_model) + with self.assertRaises(ValueError): + sess.run(None, {}) + def test_philox4x32_10_known_answer_vectors(self): # Known-answer vectors from the Random123 distribution # (tests/kat_vectors, "philox4x32 10" entries): counter and key words diff --git a/onnx/test/shape_inference_test.py b/onnx/test/shape_inference_test.py index be7199a0a9c..a9a4281abff 100644 --- a/onnx/test/shape_inference_test.py +++ b/onnx/test/shape_inference_test.py @@ -4461,6 +4461,23 @@ def test_random_uniform_offset(self) -> None: graph, [make_tensor_value_info("out", TensorProto.FLOAT, (3, 4))] ) + def test_random_uniform_offset_non_scalar_fails(self) -> None: + graph = self._make_graph( + [("offset", TensorProto.INT64, (2, 3))], + [ + make_node( + "RandomUniform", + ["offset"], + ["out"], + shape=(3, 4), + seed_int64=0, + generator="philox4x32_10", + ) + ], + [], + ) + self.assertRaises(onnx.shape_inference.InferenceError, self._inferred, graph) + def test_random_uniform_unknown_generator_fails(self) -> None: graph = self._make_graph( [], diff --git a/onnx/test/version_converter_test.py b/onnx/test/version_converter_test.py index 7faf4fd0719..60440fc5694 100644 --- a/onnx/test/version_converter_test.py +++ b/onnx/test/version_converter_test.py @@ -2942,6 +2942,21 @@ def test_randomuniform_28_27_seed_int64_fails(self) -> None: lambda: self._randomuniform_converted(28, 27, seed_int64=5), ) + # RandomUniform 28 -> 27: an omitted optional offset input, spelled as an + # empty string, must not block the downgrade (the placeholder is dropped) + def test_randomuniform_28_27_empty_offset_placeholder(self) -> None: + node = helper.make_node("RandomUniform", [""], ["Y"], shape=[2, 3], seed=1.0) + graph = helper.make_graph( + [node], + "randomuniform_empty_offset", + [], + [helper.make_tensor_value_info("Y", TensorProto.FLOAT, [2, 3])], + ) + converted = self._converted(graph, helper.make_operatorsetid("", 28), 27) + assert converted.opset_import[0].version == 27 + ru = next(n for n in converted.graph.node if n.op_type == "RandomUniform") + assert not [i for i in ru.input if i] + # RandomUniform 28 -> 27: the offset input cannot be expressed in older # opsets and must be rejected def test_randomuniform_28_27_offset_fails(self) -> None: diff --git a/onnx/version_converter/adapters/CMakeLists.txt b/onnx/version_converter/adapters/CMakeLists.txt index 2be3e2c7035..f8ae7f379ec 100644 --- a/onnx/version_converter/adapters/CMakeLists.txt +++ b/onnx/version_converter/adapters/CMakeLists.txt @@ -24,7 +24,7 @@ target_sources(onnx PRIVATE no_previous_version.h pad_10_11.h q_dq_21_20.h - random_uniform_28_27.h + random_generator_28_27.h remove_consumed_inputs.h reshape_4_5.h reshape_5_4.h diff --git a/onnx/version_converter/adapters/random_generator_28_27.h b/onnx/version_converter/adapters/random_generator_28_27.h new file mode 100644 index 00000000000..69fb98dda21 --- /dev/null +++ b/onnx/version_converter/adapters/random_generator_28_27.h @@ -0,0 +1,74 @@ +// Copyright (c) ONNX Project Contributors +// +// SPDX-License-Identifier: Apache-2.0 + +// Adapter for the random-generator operators in default domain from version +// 28 to 27 (currently RandomUniform; intended for the other random operators +// when they adopt the deterministic generator mechanism). + +#pragma once + +#include +#include +#include + +#include "onnx/version_converter/adapters/adapter.h" + +namespace ONNX_NAMESPACE { +namespace version_conversion { + +class RandomGenerator_28_27 final : public Adapter { + public: + RandomGenerator_28_27(std::string op_name, size_t num_legacy_inputs) + : Adapter(std::move(op_name), OpSetID(28), OpSetID(27)), num_legacy_inputs_(num_legacy_inputs) {} + + Node* adapt(std::shared_ptr /*graph*/, Node* node) const override { + // The optional offset input (the first input after the operator's legacy + // inputs) does not exist before version 28 and cannot be expressed in + // older opsets. An omitted optional input may still be present as an + // empty string, which the proto importer materializes as a kUndefined + // placeholder; drop the placeholder so the node satisfies the old + // schema's input arity. + if (node->inputs().size() > num_legacy_inputs_) { + ONNX_ASSERTM( + node->inputs().size() == num_legacy_inputs_ + 1 && + node->inputs()[num_legacy_inputs_]->node()->kind() == kUndefined, + "Operator '", + name(), + "' with an 'offset' input is not supported in Opset Version ", + static_cast(target_version().version()), + "."); + node->removeInput(num_legacy_inputs_); + } + // seed_int64 does not exist before version 28. + ONNX_ASSERTM( + !node->hasAttribute(Symbol("seed_int64")), + "Attribute 'seed_int64' of operator '", + name(), + "' is not supported in Opset Version ", + static_cast(target_version().version()), + "."); + const Symbol generator("generator"); + if (node->hasAttribute(generator)) { + // "unspecified" matches the implementation-defined behavior of the + // pre-28 operators, so the attribute can simply be dropped. Any other + // generator selects fully specified deterministic output, which older + // versions cannot express. + ONNX_ASSERTM( + node->s(generator) == "unspecified", + "Attribute 'generator' of operator '", + name(), + "' must be 'unspecified' in Opset Version ", + static_cast(target_version().version()), + "."); + node->removeAttribute(generator); + } + return node; + } + + private: + size_t num_legacy_inputs_; +}; + +} // namespace version_conversion +} // namespace ONNX_NAMESPACE diff --git a/onnx/version_converter/adapters/random_uniform_28_27.h b/onnx/version_converter/adapters/random_uniform_28_27.h deleted file mode 100644 index 29a48074de7..00000000000 --- a/onnx/version_converter/adapters/random_uniform_28_27.h +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright (c) ONNX Project Contributors -// -// SPDX-License-Identifier: Apache-2.0 - -// Adapter for RandomUniform in default domain from version 28 to 27 - -#pragma once - -#include -#include - -#include "onnx/version_converter/adapters/adapter.h" - -namespace ONNX_NAMESPACE { -namespace version_conversion { - -class RandomUniform_28_27 final : public Adapter { - public: - RandomUniform_28_27() : Adapter("RandomUniform", OpSetID(28), OpSetID(27)) {} - - Node* adapt(std::shared_ptr /*graph*/, Node* node) const override { - // The offset input does not exist in RandomUniform v22 and cannot be - // expressed in older opsets. - ONNX_ASSERTM( - node->inputs().empty(), - "Operator '", - name(), - "' with an 'offset' input is not supported in Opset Version ", - static_cast(target_version().version()), - "."); - // seed_int64 does not exist in RandomUniform v22. - ONNX_ASSERTM( - !node->hasAttribute(Symbol("seed_int64")), - "Attribute 'seed_int64' of operator '", - name(), - "' is not supported in Opset Version ", - static_cast(target_version().version()), - "."); - const Symbol generator("generator"); - if (node->hasAttribute(generator)) { - // "unspecified" matches the implementation-defined behavior of - // RandomUniform v22, so the attribute can simply be dropped. Any other - // generator selects fully specified deterministic output, which older - // versions cannot express. - ONNX_ASSERTM( - node->s(generator) == "unspecified", - "Attribute 'generator' of operator '", - name(), - "' must be 'unspecified' in Opset Version ", - static_cast(target_version().version()), - "."); - node->removeAttribute(generator); - } - return node; - } -}; - -} // namespace version_conversion -} // namespace ONNX_NAMESPACE diff --git a/onnx/version_converter/convert.h b/onnx/version_converter/convert.h index f0ef91996e2..40726eb34d6 100644 --- a/onnx/version_converter/convert.h +++ b/onnx/version_converter/convert.h @@ -38,7 +38,7 @@ #include "onnx/version_converter/adapters/no_previous_version.h" #include "onnx/version_converter/adapters/pad_10_11.h" #include "onnx/version_converter/adapters/q_dq_21_20.h" -#include "onnx/version_converter/adapters/random_uniform_28_27.h" +#include "onnx/version_converter/adapters/random_generator_28_27.h" #include "onnx/version_converter/adapters/range_27_26.h" #include "onnx/version_converter/adapters/reshape_4_5.h" #include "onnx/version_converter/adapters/reshape_5_4.h" @@ -989,8 +989,9 @@ class DefaultVersionConverter : public BaseVersionConverter { const std::vector celu_28_unallowed_types = { TensorProto_DataType_FLOAT16, TensorProto_DataType_BFLOAT16, TensorProto_DataType_DOUBLE}; registerAdapter(std::make_unique("Celu", OpSetID(28), OpSetID(27), celu_28_unallowed_types)); - // RandomUniform v28 added the generator attribute; only generator="unspecified" can be downgraded. - registerAdapter(std::make_unique()); + // RandomUniform v28 added the generator/seed_int64 attributes and the offset input; + // only generator="unspecified" without seed_int64 and offset can be downgraded. + registerAdapter(std::make_unique("RandomUniform", 0)); } ModelProto convert_version(const ModelProto& mp_in, const OpSetID& initial_version, const OpSetID& target_version) From a7fb2b4fdf72a3d337ed220c8f3ba1b47b2545f4 Mon Sep 17 00:00:00 2001 From: Timo Stripf Date: Sun, 5 Jul 2026 18:45:46 +0000 Subject: [PATCH 8/8] Address second review: scalar shape, docstring, constant-0 offset downgrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The reference implementation now supports the spec-defined scalar output (empty shape attribute) for deterministic generators; the empty-shape guard remains only for the legacy "unspecified" path, which cannot produce scalars. Evaluator test with the exact expected value added. - Corrected the nd_shape test docstring, which claimed the operator has no inputs — this PR itself added the optional offset input; the text is published verbatim in docs/Operators.md and docs/TestCoverage.md. - The 28->27 downgrade adapter now accepts a constant offset of 0 (Constant node or initializer, the documented pattern for storing the stream position in the model) and drops it together with the now-unused initializer, mirroring axis_input_to_attribute.h. Any other offset is still rejected. Regression tests for both cases. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PVuibCDPDmYP2zoMawf9sp Signed-off-by: Timo Stripf --- docs/Operators.md | 4 +- docs/TestCoverage.md | 4 +- onnx/backend/test/case/node/randomuniform.py | 4 +- onnx/reference/ops/_op_common_random.py | 11 +++- onnx/test/reference_evaluator_test.py | 19 ++++++ onnx/test/version_converter_test.py | 32 ++++++++++ .../adapters/random_generator_28_27.h | 58 +++++++++++++++---- 7 files changed, 114 insertions(+), 18 deletions(-) diff --git a/docs/Operators.md b/docs/Operators.md index 2870daacd1c..feaf21a31e6 100644 --- a/docs/Operators.md +++ b/docs/Operators.md @@ -27820,8 +27820,8 @@ dimension and a negative `low` checks that the row-major element ordering is independent of the tensor's rank and that sign handling in low + r * (high - low) is correct. (A dynamic output shape is not expressible for RandomUniform: `shape` is a required attribute and -the operator has no inputs; data-dependent shapes are the domain of -RandomUniformLike.) +the operator's only optional input is the stream offset, not a shape +tensor; data-dependent shapes are the domain of RandomUniformLike.) """ node = onnx.helper.make_node( "RandomUniform", diff --git a/docs/TestCoverage.md b/docs/TestCoverage.md index 66106f424c2..f084a57fec0 100644 --- a/docs/TestCoverage.md +++ b/docs/TestCoverage.md @@ -20026,8 +20026,8 @@ dimension and a negative `low` checks that the row-major element ordering is independent of the tensor's rank and that sign handling in low + r * (high - low) is correct. (A dynamic output shape is not expressible for RandomUniform: `shape` is a required attribute and -the operator has no inputs; data-dependent shapes are the domain of -RandomUniformLike.) +the operator's only optional input is the stream offset, not a shape +tensor; data-dependent shapes are the domain of RandomUniformLike.) """ node = onnx.helper.make_node( "RandomUniform", diff --git a/onnx/backend/test/case/node/randomuniform.py b/onnx/backend/test/case/node/randomuniform.py index ff11d2a051b..0e654644b6b 100644 --- a/onnx/backend/test/case/node/randomuniform.py +++ b/onnx/backend/test/case/node/randomuniform.py @@ -116,8 +116,8 @@ def export_randomuniform_philox_nd_shape() -> None: ordering is independent of the tensor's rank and that sign handling in low + r * (high - low) is correct. (A dynamic output shape is not expressible for RandomUniform: `shape` is a required attribute and - the operator has no inputs; data-dependent shapes are the domain of - RandomUniformLike.) + the operator's only optional input is the stream offset, not a shape + tensor; data-dependent shapes are the domain of RandomUniformLike.) """ node = onnx.helper.make_node( "RandomUniform", diff --git a/onnx/reference/ops/_op_common_random.py b/onnx/reference/ops/_op_common_random.py index a4d877f5476..6d1f7923999 100644 --- a/onnx/reference/ops/_op_common_random.py +++ b/onnx/reference/ops/_op_common_random.py @@ -122,8 +122,15 @@ def random_res(self, num: int, precision: int) -> np.ndarray: class _CommonRandom(OpRun): def __init__(self, onnx_node, run_params): OpRun.__init__(self, onnx_node, run_params) - if hasattr(self, "shape") and len(self.shape) == 0: - raise ValueError( # pragma: no cover + if ( + hasattr(self, "shape") + and len(self.shape) == 0 + # An empty shape (scalar output) is fully specified for the + # deterministic generators; only the legacy "unspecified" path + # of this implementation does not support it. + and getattr(self, "generator", None) in (None, "unspecified") + ): + raise ValueError( f"shape cannot be empty for operator {self.__class__.__name__}." ) diff --git a/onnx/test/reference_evaluator_test.py b/onnx/test/reference_evaluator_test.py index 58b92fe31a4..0a02f8060f7 100644 --- a/onnx/test/reference_evaluator_test.py +++ b/onnx/test/reference_evaluator_test.py @@ -1635,6 +1635,25 @@ def test_onnxt_runtime_random_uniform_philox_offset_streaming(self): y_default = ReferenceEvaluator(model2).run(None, {})[0] assert_allclose(y_default, y0, rtol=0, atol=0) + def test_onnxt_runtime_random_uniform_philox_scalar_shape(self): + # An empty shape attribute produces a scalar output, which is fully + # specified for the deterministic generator: the single element uses + # word 0 of block 0. + Y = make_tensor_value_info("Y", TensorProto.FLOAT, []) + node1 = make_node( + "RandomUniform", [], ["Y"], seed_int64=42, generator="philox4x32_10" + ) + node1.attribute.append( + onnx.helper.make_attribute("shape", [], attr_type=AttributeProto.INTS) + ) + graph = make_graph([node1], "g", [], [Y]) + onnx_model = make_model(graph) + check_model(onnx_model) + got = ReferenceEvaluator(onnx_model).run(None, {})[0] + self.assertEqual(got.shape, ()) + self.assertEqual(got.dtype, np.float32) + assert_allclose(got, np.float32(0.61295986), rtol=0, atol=0) + def test_onnxt_runtime_random_uniform_philox_no_seed_raises(self): Y = make_tensor_value_info("Y", TensorProto.FLOAT, [None]) node1 = make_node( diff --git a/onnx/test/version_converter_test.py b/onnx/test/version_converter_test.py index 60440fc5694..334bcd183d0 100644 --- a/onnx/test/version_converter_test.py +++ b/onnx/test/version_converter_test.py @@ -2973,3 +2973,35 @@ def test_randomuniform_28_27_offset_fails(self) -> None: RuntimeError, lambda: self._converted(graph, helper.make_operatorsetid("", 28), 27), ) + + def _randomuniform_offset_initializer(self, offset_value: int) -> ModelProto: + node = helper.make_node( + "RandomUniform", ["offset"], ["Y"], shape=[2, 3], seed=1.0 + ) + graph = helper.make_graph( + [node], + "randomuniform_offset_initializer", + [], + [helper.make_tensor_value_info("Y", TensorProto.FLOAT, [2, 3])], + initializer=[ + helper.make_tensor("offset", TensorProto.INT64, [], [offset_value]) + ], + ) + return self._converted(graph, helper.make_operatorsetid("", 28), 27) + + # RandomUniform 28 -> 27: a constant offset of 0 — the documented pattern + # for storing the stream position in the model — matches the default and + # is dropped together with its initializer + def test_randomuniform_28_27_constant_zero_offset_removed(self) -> None: + converted = self._randomuniform_offset_initializer(0) + assert converted.opset_import[0].version == 27 + ru = next(n for n in converted.graph.node if n.op_type == "RandomUniform") + assert not [i for i in ru.input if i] + assert not [i for i in converted.graph.initializer if i.name == "offset"] + + # RandomUniform 28 -> 27: a non-zero constant offset selects a stream that + # older opsets cannot express and must be rejected + def test_randomuniform_28_27_constant_nonzero_offset_fails(self) -> None: + self.assertRaises( + RuntimeError, lambda: self._randomuniform_offset_initializer(5) + ) diff --git a/onnx/version_converter/adapters/random_generator_28_27.h b/onnx/version_converter/adapters/random_generator_28_27.h index 69fb98dda21..54ca4ead2c5 100644 --- a/onnx/version_converter/adapters/random_generator_28_27.h +++ b/onnx/version_converter/adapters/random_generator_28_27.h @@ -11,8 +11,10 @@ #include #include #include +#include #include "onnx/version_converter/adapters/adapter.h" +#include "onnx/version_converter/helper.h" namespace ONNX_NAMESPACE { namespace version_conversion { @@ -22,23 +24,23 @@ class RandomGenerator_28_27 final : public Adapter { RandomGenerator_28_27(std::string op_name, size_t num_legacy_inputs) : Adapter(std::move(op_name), OpSetID(28), OpSetID(27)), num_legacy_inputs_(num_legacy_inputs) {} - Node* adapt(std::shared_ptr /*graph*/, Node* node) const override { + Node* adapt(std::shared_ptr graph, Node* node) const override { // The optional offset input (the first input after the operator's legacy - // inputs) does not exist before version 28 and cannot be expressed in - // older opsets. An omitted optional input may still be present as an - // empty string, which the proto importer materializes as a kUndefined - // placeholder; drop the placeholder so the node satisfies the old - // schema's input arity. + // inputs) does not exist before version 28. It can be removed without + // changing semantics when it is omitted — possibly spelled as an empty + // string, which the proto importer materializes as a kUndefined + // placeholder — or when it is a constant 0, the documented pattern for + // storing the stream position in the model. Any other offset selects a + // stream that older versions cannot express. if (node->inputs().size() > num_legacy_inputs_) { ONNX_ASSERTM( - node->inputs().size() == num_legacy_inputs_ + 1 && - node->inputs()[num_legacy_inputs_]->node()->kind() == kUndefined, + node->inputs().size() == num_legacy_inputs_ + 1 && IsRemovableOffset(graph, node), "Operator '", name(), "' with an 'offset' input is not supported in Opset Version ", static_cast(target_version().version()), - "."); - node->removeInput(num_legacy_inputs_); + " (only an omitted offset or a constant offset of 0 can be removed)."); + RemoveOffsetInput(graph, node); } // seed_int64 does not exist before version 28. ONNX_ASSERTM( @@ -68,6 +70,42 @@ class RandomGenerator_28_27 final : public Adapter { private: size_t num_legacy_inputs_; + + bool IsRemovableOffset(const std::shared_ptr& graph, Node* node) const { + const Value* offset_val = node->inputs()[num_legacy_inputs_]; + const Node* offset_node = offset_val->node(); + if (offset_node->kind() == kUndefined) { + return true; + } + if (offset_node->kind() == kConstant) { + const std::vector values = ReadInt64Tensor(offset_node->t(kvalue)); + return values.size() == 1 && values[0] == 0; + } + if (graph->is_constant_initializer(offset_val)) { + for (const auto& initializer : graph->initializers()) { + if (initializer.name() == offset_val->uniqueName()) { + const std::vector values = ReadInt64Tensor(initializer); + return values.size() == 1 && values[0] == 0; + } + } + } + return false; + } + + void RemoveOffsetInput(const std::shared_ptr& graph, Node* node) const { + Value* offset_val = node->inputs()[num_legacy_inputs_]; + Node* offset_node = offset_val->node(); + const std::string initializer_name = offset_val->uniqueName(); + const bool is_initializer = graph->is_constant_initializer(offset_val); + node->removeInput(num_legacy_inputs_); + if (offset_val->uses().empty()) { + if (is_initializer) { + graph->eraseInitializer(initializer_name); + } else if (offset_node->kind() == kConstant) { + offset_node->destroy(); + } + } + } }; } // namespace version_conversion