Clamp torch.randint output to high-1 to match half-open semantics (fixes #2568)#2708
Clamp torch.randint output to high-1 to match half-open semantics (fixes #2568)#2708LeSingh1 wants to merge 2 commits into
Conversation
torch.randint(low, high) is documented to sample from [low, high), so the maximum integer value is high - 1. The MIL random_uniform op is spec'd the same way, but the runtime can occasionally return values equal to high, which then cast to high and exceed the torch contract. Insert an mb.minimum clamp against high - 1 after the int32 cast so the converter matches torch semantics regardless of that quirk. Adds TestRandint::test_upper_bound_exclusive which verifies the clamp is present in the converted MIL program (via convert_to='milinternal' so the structural check runs without BlobWriter) and additionally runs an end-to-end max-value assertion when the native runtime is available. Fixes apple#2568
| # `minimum` op (the clamp against `high - 1`) downstream of the cast, | ||
| # so that any runtime quirk returning `high` is clipped back into range. | ||
| if frontend == TorchFrontend.EXECUTORCH: | ||
| pytest.skip("torch._ops.aten.randint.low is not Aten Canonical") |
There was a problem hiding this comment.
I don't think we should skip this. randint.low is added only as an alias in the conversion. Your test isn't using it.
| x = torch.zeros(1000, dtype=torch.int32) | ||
| torch_model = export_torch_model_to_frontend(model, (x,), frontend) | ||
|
|
||
| # Convert to `milinternal` (an MIL Program object) so the structural |
There was a problem hiding this comment.
Please fix your local environment. It doesn't make sense to convert it to milinternal.
This test should be much shorter. You should only need one call to ct.convert.
Per Toby's review on apple#2708: - Drop EXECUTORCH skip; the test does not route through randint.low. - Drop the milinternal structural check and the BlobWriter guard. - Single ct.convert call producing a real mlprogram, then 50 predict iterations asserting min/max bounds against [low, high).
|
Fair on all three points. Just pushed the rewrite (2742096). The test is now one A/B run with the local installed coremltools wheel, 50 iterations of So the new test passes on this branch and fails on main, exactly catching the regression #2568 describes. Let me know if you would rather see a different sample count or skip pattern. |
|
Closing as the issue was fixed by #2714. |
Summary
torch.randint(low, high, ...)samples integers from the half-open interval[low, high), so the maximum valid value ishigh - 1. The MILrandom_uniformop is documented the same way (lower inclusive, upper exclusive), but in practice the Core ML runtime can occasionally return float values equal tohigh. After thecast(int32)those samples come through ashighitself, exceeding the documented torch contract.Issue #2568 shows this concretely:
min=0, max=99(correct,[0, 100))min=0, max=100(off by one — includes100)Fix
After the existing
random_uniform → cast(int32)chain, insert anmb.minimumclamp againsthigh - 1so the output is always<= high - 1regardless of the runtime's exclusive-bound behavior:The clamp is a no-op in the common case (when
random_uniformis exclusive ofhighas spec'd) and only kicks in for the rare values that would otherwise overshoot. It costs onesub, onecast, and one elementwiseminimum.Why the clamp is safe
[low, high)cast to int32 produces{low, ..., high-1};minimum({low..high-1}, high-1) = {low..high-1}— no-op.[low, high](the buggy inclusive case) cast to int32 produces{low, ..., high};minimum({low..high}, high-1) = {low..high-1}— values equal tohighget clipped tohigh - 1.Probability of a
highsample is on the order of 1 / 2²⁴ per element in fp32, so the bias towardhigh - 1is statistically negligible for any practical batch size.Tests
Added
TestRandint::test_upper_bound_exclusive:torch.randint(0, 5, ...)model and asserts the converted MIL program contains bothrandom_uniformand the newminimumop (structural check viaconvert_to="milinternal"— runs withoutBlobWriter).BlobWriterand the native Core ML runtime are available, additionally runs prediction and assertsprediction.max() < highnumerically. Gated to skip cleanly otherwise.Both
TorchFrontend.TORCHSCRIPTandTorchFrontend.TORCHEXPORTparameterizations pass locally:The full converted MIL for a
randint(0, 5, (1000,))model now reads:Issue
Fixes #2568