Skip to content

Fix exponential_ underflowing to exactly 0 for float16 - #195077

Open
qflen wants to merge 1 commit into
pytorch:mainfrom
qflen:fix/exponential-fp16-zero-192812
Open

qflen wants to merge 1 commit into
pytorch:mainfrom
qflen:fix/exponential-fp16-zero-192812

Conversation

@qflen

@qflen qflen commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #192812

Root cause

exponential_ samples in a wider type and narrows with static_cast<scalar_t>. Anything below float16's smallest subnormal becomes an exact 0.

On CPU the kernel samples in double, so a sample under 2^-25 rounds to zero about 3 times per 1e8 draws at lambda=1. On CUDA the eps/2 floor inside transformation::exponential is applied in accscalar_t (float), which bounds the sample at 2^-24/lambda; that sits at or below half's round-to-even tie point once lambda >= 2, so it narrows to zero as well.

multinomial without replacement divides p by the sample (Gumbel trick), so a zero turns a zero-probability category into 0/0 = NaN, and NaN wins argmax. Only float16 is affected; bfloat16 and float32 subnormals are far below anything either kernel emits.

Fix

c10::cast_no_underflow<To>(value) narrows, and returns denorm_min with the input's sign when the cast would otherwise flush a nonzero value to zero. Both kernels use it in place of their static_cast.

It lives in c10/util/safe_numerics.h rather than TypeCast.h because the template itself needs only <limits> (the destination's numeric_limits is instantiated at the call site), so the header stays cheap enough to include from CUDA kernels.

The random stream is untouched and an exact 0 stays 0, so exponential_(inf) still returns 0 (asserted by test_exponential) and every other output value is bit-identical.

Flooring before the cast does not work, since eps/2 of a double still rounds to float16 0. Sampling on CPU in float with the CUDA floor would change the random stream for fp16/bf16/fp32 and still emit zeros for lambda >= 2, so it would need this floor anyway.

Verification

test_exponential_no_zero is now parametrized over lambda and also runs on CPU. At lambda=1024 roughly 3e-5 of the samples fall under the rounding threshold, so 5e7 samples produce about 1500 zeros before the fix against 1 at lambda=1; that is what makes it a real regression detector on both backends. test_exponential_half_underflow pins a deterministic CPU case (seed 20, index 5833).

python test/test_torch.py -k exponential
python test/test_torch.py -k multinomial
python test/test_torch.py -k kstest

Zeros in 1e8 float16 CPU samples (seed 0, Apple M5):

lambda before after
1 3 0
2 3 0
8 24 0

Perf is within noise: float16 exponential_ on 1e7 samples goes 80.4 ms to 81.3 ms (minimum over 15 interleaved runs of the two builds); float64 is unchanged, the compiler folds the check away there.

I have no GPU on hand, so the CUDA change is unverified outside CI.

cc @jgong5 @mingfeima @XiaobingSuper @sanchitintel @ashokei @jingxu10 @aditew01

@pytorch-bot pytorch-bot Bot added the module: cpu CPU specific problem (e.g., perf, algorithm) label Aug 28, 2026
@pytorch-bot

pytorch-bot Bot commented Aug 28, 2026 •

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/195077

Note: Links to docs will display an error until the docs builds have been completed.

✅ No Failures

As of commit b84d28e with merge base 7fe5288 (image):
💚 Looks good so far! There are no failures yet. 💚

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@pytorch-bot

pytorch-bot Bot commented Aug 28, 2026

Copy link
Copy Markdown

This PR needs a release notes: label

If your changes are user facing and intended to be a part of release notes, please use a label starting with release notes:.

If not, please add the topic: not user facing label.

To add a label, you can comment to pytorchbot, for example
@pytorchbot label "topic: not user facing"

For more information, see
https://github.com/pytorch/pytorch/wiki/PyTorch-AutoLabel-Bot#why-categorize-for-release-notes-and-how-does-it-work.

return static_cast<scalar_t>(exponential(generator));
auto sample = exponential(generator);
auto value = static_cast<scalar_t>(sample);
// Floor if the cast underflowed to 0; an exact 0 (lambda == inf) stays 0

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feels like this should be a generic c10::safe_numerics utility maybe?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added as c10::cast_no_underflow in safe_numerics.h.

It picked up a second caller on the way, since the CUDA kernel's eps/2 floor is applied in accscalar_t and so still narrows to 0 for half once lambda >= 2.

@qflen
qflen force-pushed the fix/exponential-fp16-zero-192812 branch from 3d0bc50 to 3d2ddb5 Compare August 29, 2026 01:52
@qflen
qflen requested a review from Skylion007 August 29, 2026 01:53
@qflen qflen changed the title Fix CPU exponential_ underflowing to exactly 0 for float16 Fix exponential_ underflowing to exactly 0 for float16 Aug 29, 2026
Comment thread c10/util/safe_numerics.h Resolved
Comment thread c10/util/safe_numerics.h Resolved
exponential_ samples in a wider type and narrows with
static_cast<scalar_t>, and anything below float16's smallest subnormal
becomes an exact 0. On CPU the kernel samples in double, so a sample
under 2^-25 rounds to zero about 2.98e-8 of the time at lambda=1,
growing with lambda. On CUDA the eps/2 floor inside
transformation::exponential is applied in accscalar_t (float), which
bounds the sample at 2^-24/lambda; that sits at or below half's
round-to-even tie point once lambda >= 2, so it narrows to zero too.

multinomial without replacement divides p by that sample in its
Gumbel-trick path, 0/0 = NaN, and NaN wins argmax, so zero-probability
categories get sampled. Only float16 is affected: bfloat16 and float32
subnormals are far below anything either kernel emits.

c10::cast_no_underflow narrows and returns denorm_min with the input's
sign when the cast would otherwise flush a nonzero value to zero, and
both kernels use it in place of their static_cast. It lives in
c10/util/safe_numerics.h rather than TypeCast.h because the template
needs only <limits>, with the destination's numeric_limits instantiated
at the call site, so the header stays cheap enough to include from CUDA
kernels. An exact 0 stays 0, so exponential_(inf) still returns 0, which
test_exponential asserts; the random stream is untouched and every other
output value is bit-identical.

Flooring before the cast cannot work: T is double in the CPU branch of
transformation::exponential, and eps/2 there is about 1.1e-16, which
still rounds to float16 0. Sampling on CPU in opmath float with the CUDA
floor would change the random stream for fp16/bf16/fp32 and still emit
zeros for lambda >= 2, so it would need this floor anyway.

Fixes pytorch#192812

Test Plan:

test_exponential_no_zero is now parametrized over lambda and also runs
on CPU. At lambda=1024 about 3e-5 of the samples fall under the rounding
threshold, so 5e7 samples give roughly 1500 zeros before the fix against
1 at lambda=1, which is what makes it a real regression detector on both
backends. test_exponential_half_underflow pins a deterministic CPU case
(seed 20 draws 4.5e-9 at index 5833), and test_multinomial_zero_prob_half
is the issue repro batched as 200 rows over 151936 float16 categories.
Both are red on f5f7ac4 and green with the fix.

```
python test/test_torch.py -k exponential
python test/test_torch.py -k multinomial
python test/test_torch.py -k kstest
python test/test_mps.py -k exponential
python test/test_mps.py -k multinomial
```

Exact zeros in 1e8 float16 CPU samples at lambda 1/2/8: 3/3/24 before,
0/0/0 after (seed 0, Apple M5). Throughput cost is within noise: float16
exponential_ on 1e7 samples 80.4 ms before, 81.3 ms after (minimum over
15 interleaved runs of the two builds); float64 unchanged since the
compiler folds the check away there. No GPU on hand, so the CUDA change
is unverified outside CI.
@qflen
qflen force-pushed the fix/exponential-fp16-zero-192812 branch from 1531a9b to b84d28e Compare September 26, 2026 10:28

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

module: cpu CPU specific problem (e.g., perf, algorithm) open source triaged This issue has been looked at a team member, and triaged and prioritized into an appropriate module

Projects

None yet

Development

Successfully merging this pull request may close these issues.

torch.multinomial selects zero-probability indices on CPU for float16, so exponential_ underflows to exactly 0 in the double to half cast

4 participants