Skip to content

Add RandomUniform-28 with deterministic generator attribute (Philox4x32-10) - #3

Open
strimo378 wants to merge 8 commits into
mainfrom
claude/operator-determinism-philox-81n6xm
Open

Add RandomUniform-28 with deterministic generator attribute (Philox4x32-10)#3
strimo378 wants to merge 8 commits into
mainfrom
claude/operator-determinism-philox-81n6xm

Conversation

@strimo378

@strimo378 strimo378 commented Jul 5, 2026

Copy link
Copy Markdown

Adds an optional generator attribute, a 64-bit seed_int64 attribute, and an optional offset input to RandomUniform (new opset-28 schema), making the operator optionally deterministic — and therefore testable — while fully supporting streaming inference. RandomUniform was previously the only generator op with no backend node tests at all, because its output could not be verified.

Supersedes #2, which specified MT19937; this PR uses the counter-based Philox-4x32-10 instead (#2 stays open as backup).

  • generator="unspecified" (default value): the PRNG remains implementation-defined; fully backward compatible. This mode gives 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 here, but is not required to. The legacy float seed attribute applies only to this mode.
  • generator="philox4x32_10": fully specified Philox-4x32 generator with 10 rounds (Salmon et al., "Parallel random numbers: as easy as 1, 2, 3", SC'11; standard constants M0=0xD2511F53, M1=0xCD9E8D57, W0=0x9E3779B9, W1=0xBB67AE85):
    • Key: the required seed_int64 attribute (INT); its two's complement bits are the unsigned 64-bit Philox key — key0 = seed_int64 & 0xFFFFFFFF, key1 = (seed_int64 >> 32) & 0xFFFFFFFF. The legacy float seed is not used in this mode and must not be set (both enforced by shape inference, as is rejection of unknown generator names). Rationale: ONNX attributes have exactly one fixed type and seed is float32, whose 24-bit significand cannot address a 64-bit key space; a dedicated INT attribute follows the established Constant value_int/value_float pattern. (ONNX has no unsigned attribute type, so int64-bits-as-uint64 is the standard encoding, also used for the offset input.)
    • Counter mapping: block b uses the 128-bit counter (lo32(b), hi32(b), lo32(offset), hi32(offset)) and yields four 32-bit words w0..w3. For bfloat16/float16/float, element i uses word i mod 4 of block ⌊i/4⌋ and forms r = ⌊w / 2^(32-p)⌋ / 2^p with p significand bits (8/11/24) — exactly representable in the target type. For double, element i uses words 2*(i mod 2) and 2*(i mod 2)+1 of block ⌊i/2⌋ combined via the res53 scheme (r = (⌊a/2^5⌋·2^26 + ⌊b/2^6⌋) / 2^53).
    • The element value is low + r * (high - low), with low/high converted to dtype and all arithmetic performed in dtype under IEEE 754 round-to-nearest-even — bit-identical across conforming implementations. No double-precision arithmetic is needed unless dtype is double.
    • Order-independent by construction: each output element depends only on (seed_int64, offset, its position i), so elements can be computed independently, in any order, or in parallel — stated normatively in the operator doc.

Streaming: the offset input

A hidden "continue where the last run stopped" mode cannot exist in ONNX — a model run is a pure function of its inputs, and hidden state would be undefined under concurrent runs, session cloning, or replay. Instead, streaming is a wiring decision around one explicit input:

  • Optional input offset (int64 scalar, 0 if absent): keys Philox counter words c2/c3. Since the block index lives in c0/c1 and the offset in c2/c3, streams of different offsets never overlap, regardless of the output size.
  • Reset per run (testing, reproducibility): feed a constant offset or omit the input — every run draws identical values.
  • Streaming (fresh values per run): feed a different offset in every run. Because every offset value is an independent stream, any non-repeating scheme works — a host-side step counter, an offset stored as an initializer in the model file and advanced at checkpoint time, or a Loop-carried value incremented in the graph. Each run remains individually deterministic and replayable, unlike hidden-state RNGs.

The operator deliberately has no next_offset output: offset + 1 is trivially computable (host counter, Add node, loop-carried update), so such an output would carry no information — unlike present_state/KV-cache outputs, which cannot be derived without recomputation. It would also over-specify a chaining protocol where none is needed.

The 28→27 downgrade adapter rejects nodes that use the offset input or the seed_int64 attribute (not expressible in older opsets).

The attribute value set is deliberately extensible: the same pattern is intended to be rolled out to the other non-deterministic operators later (RandomNormal, RandomUniformLike, RandomNormalLike, Bernoulli, Multinomial), and future opset versions can add further generator algorithms. The Philox implementation already lives in the shared _CommonRandom base of the reference runtime in preparation for that.

Test cases and their intent

Every backend node test case documents its intent in a docstring that is published verbatim in docs/TestCoverage.md; the reference-evaluator tests carry the same information as comments. Summary:

Test Intent
test_randomuniform_philox Base case: default range/dtype, 12 elements = three full counter blocks.
test_randomuniform_philox_multi_block Counter-block stress: 35 elements span nine blocks with a partially consumed last block (35 = 8·4 + 3), exposing wrong block increments, word ordering, or padding handling.
test_randomuniform_philox_nd_shape Non-trivial 4-D shape with singleton dimension and negative low: row-major ordering must be rank-independent; sign handling in low + r*(high-low).
test_randomuniform_philox_offset Streaming: offset fed as an input .pb file (offset=5), expected output from the stream disjoint to offset 0.
test_randomuniform_philox_low_high Non-default range: the affine transform must be evaluated in the target dtype, not in double.
test_randomuniform_philox_double Double path: two words per element (res53), word pairing 0/1 vs. 2/3 within a block.
test_randomuniform_philox_float16 / _bfloat16 Reduced-precision paths (p=11 / p=8): top-bits extraction, exact representability. (bfloat16 excluded on NumPy < 2.0, matching existing bfloat16 exclusions.)
evaluator: element independence Row-major values of a smaller tensor must be a prefix of any larger tensor with the same seed, across block boundaries; a different seed changes the stream. Directly verifies the counter-based order-independence claim.
evaluator: offset streaming Runs with different offsets draw disjoint streams; each run is individually replayable; a model without the offset input equals offset 0.
evaluator: statelessness Running the same model twice yields bit-identical output — catches backends with hidden RNG state.
evaluator: Random123 KAT The three philox4x32 10 known-answer vectors from the Random123 distribution (tests/kat_vectors), hardcoded inline.

Multi-run .pb data: the backend test runner executes every test_data_set_N directory of a test case, so per-run input variation is expressible — the offset test uses exactly this mechanism (input_0.pb carries the offset). Dynamic output shapes remain out of scope by construction: shape is a required attribute; data-dependent shapes are the domain of RandomUniformLike, which will gain the same generator/offset mechanics in the planned follow-up.

Changes

  • defs.cc: RandomUniform-28 with generator and seed_int64 attributes and optional offset input (type constraint T2 = int64); shape inference validates the generator value, requires seed_int64 for deterministic generators, and rejects the float seed alongside them; old.cc: v22 schema preserved; operator_sets.h: registered under opset 28; new doc string with the complete, self-contained Philox-4x32-10 specification (round function, key schedule, counter mapping, offset/streaming semantics)
  • convert.h / adapters: 27→28 CompatibleAdapter; 28→27 custom adapter that drops generator="unspecified" and rejects deterministic generators, the seed_int64 attribute, and the offset input
  • Reference implementation: vectorized _Philox4x32 class in _op_common_random.py (seed_int64 + offset keyed), verified against the known-answer vectors from the Random123 distribution and against the word stream of the canonical Random123 implementation; uses ml_dtypes.finfo so non-native float types (bfloat16) resolve their precision correctly
  • Node tests: first-ever backend node tests for RandomUniform (8 cases, see table above) with exact expected outputs generated by an independent inline Philox implementation in the test case, verified bit-exactly by the reference backend runner
  • Unit tests: schema (incl. seed_int64 typing), shape inference (incl. error cases: missing seed_int64, float seed with philox, unknown generator), version converter 27↔28 (incl. offset and seed_int64 rejection), reference evaluator (see table above)
  • Regenerated docs/Operators.md, docs/Changelog.md, docs/TestCoverage.md, and backend test data

Motivation and Context

The random-number operators are non-deterministic per spec: even with seed set, results differ across implementations, so conformance tests cannot verify their output. As a consequence, the random operators are effectively invisible to conformance tracking such as the ONNX Backend Scoreboard: there are no node tests whose results a backend could be checked against. With an opt-in, fully specified generator (Philox-4x32-10 for now, extensible later), the output becomes reproducible and bit-exactly verifiable, so random operators can be covered by the standard backend test suite and their support becomes measurable on the scoreboard — while the default behavior, including its freedom to be non-deterministic, stays unchanged. Philox is counter-based, so the specification is order-independent and parallelizable — a natural fit for GPU and multi-threaded backends — and the explicit offset input supports streaming inference with fresh, reproducible values per run.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PVuibCDPDmYP2zoMawf9sp

strimo378 and others added 7 commits July 5, 2026 08:54
…bute

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PVuibCDPDmYP2zoMawf9sp
Signed-off-by: Timo Stripf <timo.stripf@emmtrix.com>
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PVuibCDPDmYP2zoMawf9sp
Signed-off-by: Timo Stripf <timo.stripf@emmtrix.com>
- test_randomuniform_philox_multi_block: 35 elements span nine counter
  blocks with a partially consumed last block, stressing block
  increments, word ordering, and padding handling.
- test_randomuniform_philox_nd_shape: 4-D shape with a singleton
  dimension and negative low, checking rank-independent row-major
  ordering and sign handling. (A dynamic output shape is not
  expressible for RandomUniform: shape is a required attribute and the
  op has no inputs; data-dependent shapes are the domain of
  RandomUniformLike.)
- Reference-evaluator element-independence test: the row-major values
  of a smaller tensor must be a prefix of any larger tensor with the
  same seed across block boundaries, and a different seed must change
  the stream.
- Every node test case now documents its intent in a docstring, which
  is published verbatim in docs/TestCoverage.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PVuibCDPDmYP2zoMawf9sp
Signed-off-by: Timo Stripf <timo.stripf@emmtrix.com>
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PVuibCDPDmYP2zoMawf9sp
Signed-off-by: Timo Stripf <timo.stripf@emmtrix.com>
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PVuibCDPDmYP2zoMawf9sp
Signed-off-by: Timo Stripf <timo.stripf@emmtrix.com>
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PVuibCDPDmYP2zoMawf9sp
Signed-off-by: Timo Stripf <timo.stripf@emmtrix.com>
…, 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PVuibCDPDmYP2zoMawf9sp
Signed-off-by: Timo Stripf <timo.stripf@emmtrix.com>
@strimo378
strimo378 force-pushed the claude/operator-determinism-philox-81n6xm branch from c62a1d1 to 017745e Compare July 5, 2026 18:09
…ngrade

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PVuibCDPDmYP2zoMawf9sp
Signed-off-by: Timo Stripf <timo.stripf@emmtrix.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant