Skip to content

Flush denormal floats to zero for ONNX inference and compute threads - #1619

Merged
Mysticial merged 2 commits into
mainfrom
flush-denormals-to-zero
Oct 20, 2026
Merged

Mysticial merged 2 commits into
mainfrom
flush-denormals-to-zero

Conversation

@Gin890

@Gin890 Gin890 commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

Why

The Chinese and Japanese OCR tests were 15–25× slower on the Mac Intel CI runner than on any other runner. That made its unit test step take 463 s, against about 100 s elsewhere. English and Korean OCR tests were just as fast there as on other runners.

Test, Mac Intel Before After Windows (AMD), before
DonutBerriesReader 20260102_02_chiSim_5.jpg 185.0 s 0.7 s 8.0 s
DonutBerriesReader 20260102_01_chiSim_7.jpg 166.4 s 4.2 s 8.7 s
FlavorPowerScreenDetector jojoaman_01_chiTra.jpg 75.6 s 0.0 s 3.9 s
StatsReader page 1 deoxys_1_jpn.png 63.1 s 1.0 s 2.6 s

The cause is denormal (subnormal) floats, values below about 1.2e-38:

  • The model: Chinese and Japanese share the large PaddleOCR server model (84 MB, against 8–13 MB for the others), and it produces denormals.
  • Intel CPUs: they handle many operations on denormals in microcode. The Mac Intel runner is an Intel i7-8700B.
  • The other runners: the Linux and Windows runners are AMD EPYC (7763 / 9V74), and the Mac runner is Apple M1. Those pay much less for denormals, which is why only Mac Intel was slow.
  • ONNX Runtime leaves denormals on unless asked.
  • Ruled out: the runner has AVX2 and FMA, so missing SIMD support wasn't the cause.

Changes

Turn on flush-to-zero, so the CPU treats denormal inputs and results as zero, but only while ONNX Runtime runs a model:

  • Common/Cpp/Hardware/FloatingPointMode.h (new): the RAII class FlushDenormalsToZeroScope, plus the functions it uses. On x86-64 it sets MXCSR's FTZ and DAZ bits; on ARM64 (GCC and Clang), FPCR's FZ bit. Elsewhere it does nothing.
  • Every ONNX Runtime Run() call runs inside FlushDenormalsToZeroScope: PaddleOCR recognition and detection, YOLOv5, and Segment Anything. The scope restores the calling thread's previous mode when the call returns.
  • ONNX Runtime's global thread pool uses SetGlobalDenormalAsZero(). ONNX Runtime only supports this on x86.

All other code keeps the default floating-point behavior. An earlier version of this PR also turned flush-to-zero on for every GlobalThreadPools thread. Review asked to limit it, and the measurements below show that part gave no measurable gain.

Is SetGlobalDenormalAsZero() alone enough?

No. ONNX Runtime runs part of every inference on the thread that calls Run(), which its thread-pool option doesn't cover. On a 4-core runner the intra-op pool is that caller plus one worker.

To check, three variants ran on Mac Intel at the same time, all passing 24,349 tests:

Variant Chinese/Japanese OCR test classes* All tests Test step
Before (main) 1,113 s 1,688 s 463 s
Pool only (SetGlobalDenormalAsZero()) 837 s 1,132 s 326 s
This PR (pool + Run() scope) 189 s 535 s 155 s
Every pool thread (earlier version) 258 s 770 s 227 s

*Sum of FlavorPowerScreenDetector, DonutBerriesReader and StatsReader test times. "Before" is from PR #1555's run. The "before" column comes from an earlier run; the other three ran at the same time.

Mac Intel runner speed varies a lot between runs: the every-thread version took 117 s, 141 s and 227 s in three runs. So compare variants from the same time.

Effect on users

Production builds run OCR on the CPU by default: ONNX_OPTIONS.USE_GPU defaults to off and is hidden. So Windows and Mac users with Intel CPUs who read Chinese or Japanese text should get a similar speedup. AMD and Apple Silicon users should see little change.

The only behavior change: inside an ONNX inference, a float below about 1.2e-38 becomes exactly 0. All unit tests give the same results.

Results

This PR's own CI on the current commit:

Runner CPU Test step before After Unit tests
Mac Intel Intel i7-8700B 463 s 223 s (155 s in the run above) 24,349 passed, 0 failed
Mac Apple M1 (Virtual) 128 s 151 s passed
Windows Default AMD EPYC 7763 113 s 80 s passed
Windows Clang AMD EPYC 7763 105 s 100 s passed
Ubuntu clang AMD EPYC 84 s 84 s passed on re-run*
Ubuntu Default AMD EPYC 84–108 s 85 s passed

*The first attempt segfaulted inside the Kernels::AudioChannelMixer x64_AVX512 tests. Those only run on runners with AVX-512, and that code isn't touched here. It looks like a separate bug in that kernel and needs its own fix.

Per-test times include waiting for a language's PaddleOCR model, which loads on first use, so single test classes can move a lot between runs.

Testing

  • I checked FloatingPointMode.h with a small program on arm64 and x86-64 (under Rosetta): a denormal product is 0 inside the scope, and the previous mode is restored afterwards.
  • The CI runs above build and pass on MSVC, clang-cl, GCC, Clang and AppleClang.

🤖 Generated with Claude Code

The Chinese and Japanese OCR tests ran 15-25x slower on the Mac Intel CI
runner than anywhere else: up to 185 s per test, against about 8 s on
the AMD Windows runners. English and Korean OCR was just as fast there
as elsewhere. The large PaddleOCR server model that Chinese and Japanese
share produces denormal (subnormal) floats, and Intel CPUs (the runner
is an i7-8700B) handle many operations on them in microcode. AMD and
Apple CPUs pay much less, which is why only Mac Intel was slow. ONNX
Runtime leaves denormals on unless asked.

Turn on flush-to-zero, so the CPU treats denormal inputs and results as
zero:
- Common/Cpp/Hardware/FloatingPointMode.h: enable_flush_denormals_to_zero()
  and FlushDenormalsToZeroScope, for x86-64 (MXCSR FTZ and DAZ) and
  ARM64 (FPCR.FZ).
- Every ONNX Runtime Run() call runs inside FlushDenormalsToZeroScope,
  because ONNX runs part of each inference on the calling thread. The
  scope restores the thread's previous mode afterwards.
- ONNX Runtime's global thread pool uses SetGlobalDenormalAsZero().
- Every GlobalThreadPools thread turns flush-to-zero on when it starts.
  This also sped up image matching, e.g. PokemonLA::MMOSpriteMatcher.

On the Mac Intel runner, the unit test step went from 463 s to 117 s.
All unit tests pass on all six CI platforms. Production builds run OCR
on the CPU by default (ONNX USE_GPU is off), so Intel users reading
Chinese or Japanese text should get the same speedup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Gin890

Gin890 commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator Author

Leave this PR open to check tests. I will still take a look at Claude's vibe code to double check its implementation

@Mysticial

Copy link
Copy Markdown
Collaborator

The "blast radius" for this is huge. Is it possible you restrict it to only the ONNX threads?

Comment thread SerialPrograms/Source/ML/Models/ML_OrtEnv.cpp
Review feedback: turning flush-to-zero on for every GlobalThreadPools
thread changes floating-point behavior for far more code than needs it.
Drop that, so it's only on:
- on ONNX Runtime's own thread pool (SetGlobalDenormalAsZero()), and
- on the thread calling Run(), only for the length of the call
  (FlushDenormalsToZeroScope restores the previous mode afterwards).

The calling-thread scope is still needed: ONNX Runtime runs part of
each inference on the calling thread, which its thread pool option
doesn't cover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Mysticial
Mysticial merged commit ed29956 into main Oct 20, 2026
8 of 9 checks passed
@Mysticial
Mysticial deleted the flush-denormals-to-zero branch October 20, 2026 07:44
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.

2 participants