Skip to content

Exp, Log, Pow: fewer instructions, bit-identical results - #17

Open
Auburn wants to merge 3 commits into
masterfrom
perf/exp-log-pow
Open

Auburn wants to merge 3 commits into
masterfrom
perf/exp-log-pow

Conversation

@Auburn

@Auburn Auburn commented Oct 17, 2026 •

Copy link
Copy Markdown
Owner

Problem

The generic FS::Exp, FS::Log and FS::Pow (ToolSet/Generic/Functions.h) do work that either never changes the result or can be done more cheaply. Every feature set uses this code, except relaxed SCALAR, which calls std::.

  • Exp follows Floor( fx ) with MaskedDecrement( flr > fx, flr ). A floor is never above its input, so this never fires. It is left over from the truncation-based floor in sse_mathfun.
  • Log gets the [0.5, 1) mantissa with an and/or, compares it with sqrt(0.5), doubles it with a masked add, and fixes the exponent with a convert, a +1 and a float masked decrement.
  • Exp builds 2^n with a float to int convert and an int add.
  • Pow zeroes value <= 0 lanes, and the Log inside it does the same again.
  • Without SSE4.1, FS::Floor needs an int round trip plus a select for inputs outside int range. Exp never gives it such inputs.

FastNoise2 calls Pow for its Minkowski distance and its PowFloat node.

Change

Three commits, each giving the same bits as before:

  1. Drop redundant ops. Remove Exp's floor fix-up. Log computes the exponent in the int domain and converts once. Pow calls impl::LogUnmasked, the same log without the a > 0 mask, because its own mask already covers those lanes.
  2. Cheaper exponent/mantissa handling.
    • Log subtracts the bits of sqrt(0.5) (0x3f3504f3) from the input's bits. An arithmetic shift of that gives the exponent, and the low 23 bits plus 0x3f3504f3 give the mantissa in [sqrt(0.5), sqrt(2)). These are the same e and m as the old compare-and-double path.
    • Exp: after the clamp and floor, fx is an integer in [-127, 128]. So fx + 2^23 + 127 holds fx + 127 in its low mantissa bits, and shifting its bits left by 23 gives 2^n.
  3. Cheaper floor on SSE2. Exp rounds through int and fixes up with a masked decrement, without FS::Floor's range select. This only applies to SSE2 without SSE4.1.

With clang-cl 23, FastNoise2's 2D PowFloat function goes from 254 to 220 instructions on SSE2, 228 to 210 on SSE4.1, 215 to 197 on AVX2 and 193 to 179 on AVX512. Each one runs Pow on two native registers.

Effect

I built FastNoise2 master (8c2d080) against this branch and against FastSIMD master, with the same compiler, and timed both on a Ryzen 9 7950X. Both builds are loaded in one process and timed interleaved on one pinned core. Each figure is the median of 3 processes with alternating order. The Apple M1 runs are unpinned.

The Pow cases are the four Minkowski distance nodes (DistanceToPoint and CellularValue/Distance/Lookup, with a fixed and a node-driven P where available) plus PowFloat, in 2D, 3D and 4D. The controls are the Euclidean CellularValue/Distance/Lookup cases, which compile into the same functions but never call Pow, plus PowInt. Numbers are new/old time, geomean per feature set:

Compiler SSE2 SSE4.1 AVX2 AVX512 Controls
clang-cl 23 0.864 0.939 0.916 0.920 1.000
MSVC 19.51 (VS 2026) 0.826 0.847 0.839 0.856 1.003
MSVC 19.44 (VS 2022) 0.804 0.841 0.857 0.851 1.006
GCC 16 (Linux) 0.845 0.862 0.847 0.862 0.991

On the Apple M1 with Homebrew clang 23, NEON is 0.928 and AARCH64 0.930, with controls at 0.995.

  • Biggest cases: Minkowski DistanceToPoint is 0.86-0.95 with clang-cl, and 0.71-0.89 with MSVC. The cellular Minkowski cases are 0.74-0.90 with MSVC and 0.85-0.96 with clang-cl. PowFloat is 0.89-1.00 on every compiler.
  • Euclidean cellular cases: these share the changed functions, so individual cases move with register allocation:
    • With MSVC they range from 0.86 to 1.20, geomean 1.00-1.01.
    • With clang-cl, CellularValue AVX512 3D/4D is 1.05/1.08 and 2D is 0.88.
  • Default benchmark set (no Pow), all feature sets: clang-cl 1.005 and MSVC 19.51 0.999.
  • Code placement: with clang-cl, cases whose code doesn't change at all still move in this build. Simplex SSE2 4D is 1.56, FBm Simplex SSE2 4D 1.50 and DomainWarp SuperSimplex SSE4.1 3D 1.27. The SSE2 4D PowFloat/PowInt cases use Simplex 4D as their input, so I left those two cases out of the table above.

Earlier measurements on a FastNoise2 development branch, on a busier machine, show the same pattern per commit. With clang-cl 23:

  • Commit 1: SSE2 0.93, SSE4.1 0.98, AVX2 0.96, AVX512 0.98.
  • Commit 2: another 0.96, 0.96, 0.98 and 0.95.
  • Commit 3: SSE2 0.975.

With MSVC 19.51, all three together measured 0.88-0.89 on every feature set.

Testing

  • FastSIMD tests, full count (4,194,304 inputs), seeds 24301, 1 and 2:
    • clang-cl 23, MSVC 19.51, GCC 16, Emscripten 6.0.12 (node) and M1 Homebrew clang 23 each give the same list as master: 628 passed and 0 failed.
    • M1 Apple clang 21 ran seed 24301 only, with the same result.
    • The covering tests are f32 exp, f32 log, f32 pow and f32 pow zero base. In relaxed mode they check against std:: within 8/8/256 ULP. The non-relaxed f32 exp/log/pow full range tests compare every feature set with SCALAR bit for bit. That is what checks the SSE2-only floor path against FS::Floor.
    • As a check, I removed the SSE2 fix-up. f32 exp and f32 exp full range then fail on SSE2 at every width.
  • Bit-identity: I built a test that compiles the old and new functions into one translation unit and compares their output bits:
    • It covers every float input for Exp, Log and Pow( |x|, 2 ), plus 2^28 random Pow pairs, at native and double width.
    • With strict float settings (/fp:precise or -ffp-contract=off) there are no differences on SCALAR, SSE2, SSE4.1, AVX2 and AVX512 with clang-cl 23, MSVC 19.51 and GCC 16. There are also none on NEON/AARCH64 with M1 clang 23.
    • With fast-math, the same check also finds no differences, except MSVC AVX2. There, 16,721 Log inputs and 27,454 Pow( |x|, 2 ) inputs differ by 1 ulp because MSVC fuses the multiply-adds differently. In the cases I looked at, the old and new results sat about half an ulp either side of the exact value.
  • FastNoise2 output:
    • With clang-cl 23 it is bit-identical in all 1212 node, feature set and dimension cases.
    • FastNoise2 builds with /fp:fast or -ffast-math, and there some cases round differently, all below 1e-3 relative:
      • GCC 16: 6 PowFloat cases, about 1 ulp.
      • M1 Homebrew clang 23: 4 Minkowski cases. M1 Apple clang 21: 24 cases.
      • MSVC 19.44: 10 Minkowski cases.
      • MSVC 19.51: 41 cases, including Euclidean cellular cases on SSE2/SSE4.1. MSVC keeps FastNoise2's CalcDistance switch out of line, so changing the Minkowski branch changes how it compiles the other branches.
    • When I rebuilt both sides with -fno-associative-math (GCC 16, M1 Homebrew clang 23) or /fp:precise (MSVC 19.51), FastNoise2's output was bit-identical again. So the differences come from fast-math reordering of the code around these functions, not from the functions themselves.
    • With GCC and M1 clang, only the Log half of commit 2 triggers them. Without it, those compilers give bit-identical FastNoise2 output, but the Pow cases run 3-6% slower than with it.
  • FastNoise2 also builds for WASM (Emscripten 6.0.12) and for ARM NEON/AARCH64 (clang 23 cross compile, and on the M1).

Other PRs

- Exp: Floor( fx ) is never above fx, so the MaskedDecrement fix-up after it
  was a no-op (left over from a truncation-based floor).
- Log: compute the exponent in the integer domain (i - 126, int masked
  decrement) and convert once, instead of convert, +1 and a float masked
  decrement. The values are small integers, so the result is identical.
- Pow: Log's own a > 0 mask is redundant under Pow's mask, use an unmasked
  log internally.
- Log: split x into 2^e * m with m in [sqrt(0.5), sqrt(2)) by subtracting
  the bits of sqrt(0.5) (arithmetic shift for e, low bits + sqrt(0.5)'s bits
  for m). This gives exactly the e and m of the [0.5, 1) mantissa doubled
  below sqrt(0.5), without the compare, masked add and masked decrement.
- Exp: fx is an integer in [-127, 128], so fx + 2^23 + 127 holds fx + 127 in
  its low mantissa bits; shift that instead of converting and adding 127.
Without SSE4.1's round instruction FS::Floor needs an int round trip plus
overflow handling and a fix-up. The clamped exponent here is within
[-127, 128], so round through int and fix up without the overflow select.
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