DragonArray docs

LoongArch / LSX Optimizations

Chronological log of optimization work on the loongson-experimental branch. See BENCHMARKS.md for measured throughput.

Open follow-ups

Tracked here so they don't get lost between sessions. Items move out of this list as they land (each one becomes a dated section below).

2026-05-17 — NPYV f32 exp / log

Files: numpy/_core/src/umath/loops_exponent_log.dispatch.c.src

What

Added an NPYV-style simd_exp_FLOAT_npyv / simd_log_FLOAT_npyv to loops_exponent_log.dispatch.c.src. The existing float32 exp/log in that file are written in raw x86 intrinsics (AVX2/AVX-512), so on LoongArch they fell through to scalar expf / logf. The new block is gated by

#if NPY_SIMD_F32 && !defined(SIMD_AVX2_FMA3) && !defined(SIMD_AVX512F)

so it covers any NPYV target without an existing AVX path — currently LSX, NEON, VSX. The dispatcher entry got a new #elif defined(NPYV_IMPL_F32_EXP_LOG) branch that calls it for contiguous inputs and falls back to scalar libm for strided ones.

How

Algorithm mirrors the AVX2 implementation in the same file:

Coefficients reused unchanged from numpy/_core/src/umath/npy_simd_data.h.

Decisions

Correctness fixes during development

  1. Spurious FP exceptions for NaN / inf inputs. Ordered LSX compares (vfcmp.clt.s etc.) raise INVALID for NaN. Both kernels now mask NaN to a safe value (0 for exp, 1 for log) via the non-signaling npyv_notnan_f32 before any signaling compare. The polynomial path also receives the masked input so inf · finite etc. doesn't materialize.
  2. Wrong log mantissa range. Initial extraction set the IEEE exponent field to 127, giving m ∈ [1, 2), then compared to 1/√2 ≈ 0.707 — a condition never true. Fixed by setting the exponent field to 126 (m ∈ [0.5, 1)) and adjusting the integer exponent accordingly. Matches the AVX path. Before fix, test_log_float32 reported 3985 ULP error; after, 774/774 exp/log tests pass.

Test status

spin test -- numpy/_core/tests/test_umath.py: 4699 passed, 0 failed.

Follow-ups


2026-05-18 — Enable Highway LSX target

Files: numpy/_core/meson.build (two hunks)

What

Removed the stale -DHWY_COMPILE_ONLY_SCALAR global flag for loongarch64 and added LSX to the dispatch lists of both highway_qsort.dispatch.h and highway_qsort_16bit.dispatch.h. Highway is now compiled with its LSX (and LASX, where the compiler supports it) backends, and NumPy's sort dispatcher actually instantiates the LSX variant.

Why it works now

Google Highway gained LSX/LASX targets between v1.2 and v1.3 (first commit 7e01a07e on 2024-11-25, real impl 7bfb8e8b on 2025-04-17, runtime dispatch 10d7ab41 on 2025-09-09). The bundled submodule is at 1.3.0-159-gee36c83 and includes both hwy/ops/loongarch_lsx-inl.h (5,954 lines) and hwy/ops/loongarch_lasx-inl.h (4,681 lines).

NumPy's flag was added 2024-11-05 (commit 7c35c37a1d) — three weeks before Highway upstream had even stubbed LSX. The flag forced every Highway-backed op to a scalar fallback. It has been stale for over a year.

Impact

This is a much bigger blast radius than the exp/log change because Highway powers more than sort in NumPy:

Measured wins on the 3A6000 (see BENCHMARKS.md for the table):

A handful of ops were not affected because they don't actually route through Highway today (tan, arcsin, arctan, sinh, cosh).

Decisions

Test status

spin test -- numpy/_core/tests/test_multiarray.py -k sort numpy/_core/tests/test_umath.py: 7920 passed, 0 failed.

Follow-ups


2026-05-19 — Define LASX feature; dispatch sort/trig/log/hyper

Files: - meson_cpu/loongarch64/meson.build (added LASX feature, implies LSX) - numpy/_core/src/_simd/checks/cpu_lasx.c (new probe) - numpy/_core/src/common/npy_cpu_features.h (NPY_CPU_FEATURE_LASX = 501) - numpy/_core/src/common/npy_cpu_features.c (HWCAP probe + name registration) - numpy/_core/meson.build (LASX added to highway_qsort, loops_hyperbolic, loops_logical, loops_trigonometric dispatch lists)

What

Wired LASX (LoongArch 256-bit SIMD) end-to-end into NumPy's CPU dispatch system, so any Highway-backed code compiled by NumPy can now use the LASX target at runtime on the 3A6000.

Detection still uses getauxval(AT_HWCAP) & HWCAP_LOONGARCH_LASX — matches the standing rule that HWCAP is the source of truth, not raw cpucfg. np.show_runtime() now reports 'simd_extensions': {'baseline': ['LSX'], 'found': ['LASX'], 'not_found': []} on the 3A6000.

This is the LASX dispatch wiring only. There is no numpy/_core/src/common/simd/lasx/ NPYV backend yet — that's a separate, much larger piece of work. The LASX gains we see today come entirely from Highway's internal LASX target; NPYV-style code (including the f32 exp/log we wrote in May) still runs as LSX.

Decisions

Impact (3A6000, n = 1M)

Over the LSX-only state (see prior section), LASX adds:

tanh and the logical ops were already saturated by LSX (Highway already picks them up internally even without -mlasx at numpy's level when LSX is the dispatch target). tan, arcsin, arctan, sinh, cosh still flat — different code paths, separate follow-up.

Stacking the wins so far against the original scalar baseline:

Test status

spin test -- numpy/_core/tests/test_multiarray.py -k sort numpy/_core/tests/test_umath.py: 7920 passed, 0 failed.

Follow-ups


2026-05-20 — Native LASX NPYV backend

Files added: - numpy/_core/src/common/simd/lasx/lasx.h (umbrella, types, lane counts) - numpy/_core/src/common/simd/lasx/misc.h - numpy/_core/src/common/simd/lasx/memory.h - numpy/_core/src/common/simd/lasx/reorder.h - numpy/_core/src/common/simd/lasx/operators.h - numpy/_core/src/common/simd/lasx/conversion.h - numpy/_core/src/common/simd/lasx/arithmetic.h - numpy/_core/src/common/simd/lasx/math.h

Total: 2108 lines, mirroring the LSX backend's structure file-for-file.

Files modified: - numpy/_core/src/common/simd/simd.h — LASX dispatch entry above LSX - numpy/_core/src/umath/loops_exponent_log.dispatch.c.src — extend the npyv__cvt_f32_s32 helper to use __lasx_xvffint_s_w on LASX targets - numpy/_core/meson.build — list LASX in the loops_exponent_log dispatch (so the file is compiled for LASX as well as LSX)

What

Wrote a full NPYV (256-bit) backend for LoongArch LASX. The translation from simd/lsx/ was largely mechanical:

The f32 exp/log kernel we wrote on 2026-05-17 is NPYV-style, so once LASX was listed in its dispatch list it picked up 256-bit lanes automatically with no source changes to loops_exponent_log.dispatch.c.src.

Decisions / Gotchas

Test status

spin test -- numpy/_core/tests/test_umath.py: 4699 passed, 0 failed.

Impact (3A6000, n = 1M, float32)

op scalar LSX (4-wide) LASX (8-wide) LASX/scalar
np.exp 236.5 M/s 412.7 823.4 3.48×
np.log 208.2 M/s 378.4 770.1 3.70×

LASX gives a clean 2× over LSX on both — the headline 8-lane scaling materializes when n is large enough to amortize the scalar tail.

Follow-ups


2026-05-20 — f64 NPYV exp / log

File: numpy/_core/src/umath/loops_exponent_log.dispatch.c.src

What

Added NPYV-style kernels for double-precision exp/log, gated by #if NPY_SIMD_F64 && !defined(SIMD_AVX2_FMA3) && !defined(SIMD_AVX512F) && !defined(SIMD_AVX512F_NOCLANG_BUG), plus a NPYV_IMPL_F64_EXP_LOG sentinel. The DOUBLE_@func@ dispatcher got a new #if defined(NPYV_IMPL_F64_EXP_LOG) branch between the AVX-512 paths and the scalar fallback.

Algorithm

Different from the f32 kernels — f64 needs ~15 decimal digits of precision, so the f32 P5/Q2 polynomial is too short. Used two well-known algorithms:

Two new arch-specific helpers in the same file:

#define npyv__cvt_f64_s64(X)  ((npyv_f64)__lasx_xvffint_d_l(X))
#define npyv__cvt_s64_f64(X)  ((npyv_s64)__lasx_xvftintrne_l_d(X))

(with parallel LSX entries). NPYV doesn't expose int64↔float64 casts portably yet; TODO notes mark these for eventual lift.

Test status

spin test -- numpy/_core/tests/test_umath.py: 4699 passed, 0 failed.

ULP measurement against math.exp/math.log on 200,000 random samples:

op dtype max ULP p99
exp float64 2 1
log float64 1 0

(Better than the f32 path, which had max ~6 ULP on edge cases.)

Impact (3A6000, n = 1M, float64)

op scalar (M/s) LSX (M/s) LASX (M/s) LASX/scalar
np.exp 71.2 311.5 4.38×
np.log 54.6 219.9 4.03×

(LSX f64 numbers are also produced by the same kernel — 2-wide vs 4-wide — but the prominent number is LASX since that's the runtime dispatch on the 3A6000. Bench script didn't capture LSX-only for f64 separately.)

Follow-ups


2026-05-23 — Tier 1 NPYV transcendentals (sinh/cosh/exp2/log2/log10/arccosh)

Files: - numpy/_core/src/umath/npyv_exp_log.h (new) — exp/log kernels moved here from loops_exponent_log.dispatch.c.src so they can be shared - numpy/_core/src/umath/loops_exponent_log.dispatch.c.src — now #includes the header, no more inline kernel definitions - numpy/_core/src/umath/loops_umath_fp.dispatch.c.src — 12 new NPYV wrapper kernels (6 ops × 2 precisions) on top of exp/log, with an NPYV branch in the unary dispatcher template - numpy/_core/src/umath/loops.h.src — re-include loops_umath_fp.dispatch.h before declaring power/arctan2 (otherwise their dispatch list inherits from loops_half, missing LASX/LSX, breaking the LASX build) - numpy/_core/meson.build[X86_V4][X86_V4, LASX, LSX] in loops_umath_fp.dispatch.h

What

Extended the LoongArch acceleration to six transcendentals that previously sat in loops_umath_fp.dispatch.c.src (X86_V4/SVML-only — meaning scalar libm on every other arch including LoongArch, ARM, PowerPC). All six are implemented as thin wrappers over the existing exp/log NPYV kernels:

op identity used
sinh(x) (eˣ − e⁻ˣ) / 2
cosh(x) (eˣ + e⁻ˣ) / 2
exp2(x) e^(x·ln2)
log2(x) log(x) · log₂(e)
log10(x) log(x) · log₁₀(e)
arccosh(x) log(x + √(x² − 1)) for x ≥ 1

The kernel naming follows the libm convention used by the existing dispatcher template (asinh/acosh/atanh, not the numpy arcsinh form), so the same #elif defined(NPYV_HAVE_@intrin@_@sfx@) pattern in the dispatcher picks them up automatically without per-op #ifdefs.

Architecture refactor

The exp/log kernels were previously inlined into loops_exponent_log.dispatch.c.src. Moved them into a new numpy/_core/src/umath/npyv_exp_log.h so loops_umath_fp can build on top. Both files now include that header; the kernels are NPY_FINLINE so each translation unit gets its own inlined copy with no link conflicts. The NPYV_IMPL_F32_EXP_LOG / NPYV_IMPL_F64_EXP_LOG markers are now set inside the header (when the appropriate int↔float cast macro is available), and the original guards in loops_exponent_log.dispatch.c.src just check those markers instead of replicating the conditions.

Decisions

Test status

4699 passed, 0 failed across the umath suite.

Impact (3A6000, n = 1M)

Contiguous arrays (NPYV path) vs strided arrays of the same length on the same build (the strided dispatch fails the steps[i] == sizeof(dtype) check and falls back to scalar libm — same compiled code, different code path).

op dtype scalar libm (M/s) NPYV (M/s) speedup
np.sinh float32 26.6 368.0 13.83×
np.cosh float32 86.5 362.3 4.19×
np.exp2 float32 247.3 691.3 2.80×
np.log2 float32 205.3 674.2 3.28×
np.log10 float32 86.0 674.2 7.84×
np.arccosh float32 230.5 238.9 1.04×
np.exp2 float64 20.1 292.5 14.55×
np.log2 float64 52.1 224.8 4.31×
np.log10 float64 37.4 222.7 5.95×
np.sinh float64 24.5 155.9 6.36×
np.cosh float64 47.6 155.9 3.27×
np.arccosh float64 74.3 105.6 1.42×

arccosh shows only a modest speedup because the scalar libm acoshf is already vectorizable / fast — the LoongArch libm seems to have a tuned implementation. Other ops were genuinely scalar-bound.

Follow-ups


2026-05-23 — Tier 2 NPYV transcendentals (everything except power)

Files: - numpy/_core/src/umath/npyv_exp_log.h — added log1p and expm1 kernels for f32 and f64. Both use Taylor for small |x| (avoids catastrophic cancellation in 1+x or e^x - 1) and the existing log_kernel(1+x) / exp_kernel(x) - 1 for moderate |x|. 7-term Taylor for f64; 5-term for f32. - numpy/_core/src/umath/loops_umath_fp.dispatch.c.src — added 8 NPYV kernels: arctan (FDLIBM polynomial with 3-way range reduction), arcsin, arccos (composed on arctan), arctan2 (binary, with quadrant logic), tan (Cody-Waite range reduction by π/2 + FDLIBM polynomial), cbrt (sign(x) · exp(log(|x|)/3)). Rewrote arcsinh and arctanh to use log1p so the small-|x| precision problem is fixed and they can be re-enabled in NPYV.

What

This pass closes most of the gap left in loops_umath_fp.dispatch.c.src. Of the 17 ufuncs that file owns, 15 now have NPYV implementations; only power(x, y) is left as a follow-up (its corner cases — negative base with integer exponent, 0^0, ±inf in either operand, etc. — make a clean implementation substantially more involved than the others).

The kernels themselves break into a few patterns: - Direct polynomial after range reduction: arctan (3-way reduction by tan(π/8) and tan(3π/8), then FDLIBM minimax of degree 5 for f32 / degree 21 for f64), tan (Cody-Waite reduction by π/2, FDLIBM minimax of degree 11 for f32 / degree 25 for f64). - Compose on exp/log/log1p: arcsinh = log1p(|x| + x²/(1+sqrt(1+x²))) (note rationalized form, otherwise the 1+x²→1 cancellation breaks small-|x|); arctanh = ½·log1p(2x/(1-x)); cbrt = exp(log(|x|)/3); exp2 = exp(x·ln2); log2 = log·log₂(e); log10 = log·log₁₀(e); sinh = (eˣ−e⁻ˣ)/2; cosh = (eˣ+e⁻ˣ)/2. - Compose on arctan: arcsin = arctan(x/sqrt(1-x²)); arccos = π/2 - arcsin; arctan2(y, x) = arctan(y/x) plus quadrant adjustment.

Decisions / gotchas

Test status

spin test -- numpy/_core/tests/test_umath.py: 4699 passed, 0 failed.

Impact (3A6000, contiguous NPYV vs strided scalar libm, n=1M)

Numbers are noisy run-to-run (especially small absolute speeds where overhead dominates), so the ratios below are rounded.

op f32 speedup f64 speedup
np.tan ~25× ~34×
np.arctan2 ~22× ~6×
np.arcsinh ~17× ~2×
np.expm1 ~9× ~4×
np.log1p ~8× ~3×
np.arctanh ~7× ~3×
np.cbrt ~4× ~8×
np.arccos ~4× ~1×
np.arctan ~5× ~5×
np.arcsin ~1.3× ~1.1×

tan is the standout: scalar libm tan is ~7 M elem/s on the 3A6000 (libm is doing the same range reduction we are, but element-by-element with substantial per-call overhead), while our NPYV does it 8 lanes at a time. arcsin / arccos get the smallest gains because they call into our arctan which already pays the polynomial cost — the win is mostly just the SIMD width.

Follow-ups


2026-05-23 — power(x, y) NPYV

File: numpy/_core/src/umath/loops_umath_fp.dispatch.c.src

What

Implemented npyv_pow_FLOAT_kernel and npyv_pow_DOUBLE_kernel. Closes the last gap in loops_umath_fp.dispatch.c.src — every op in that file now has an NPYV path on LoongArch (LSX and LASX).

Algorithm

General formula: |x|^y = exp(y · log(|x|)). Around that, a chain of mask-based special-case overrides per IEEE 754 / C99 pow():

input pattern result
pow(x, 0) 1 (even for NaN x) — overrides everything
pow(1, y) 1 (even for NaN y) — overrides everything
pow(x<0, integer y) ±|x|^y (sign from parity of y)
pow(x<0, non-integer y) NaN
pow(x, ±inf) {0, 1, inf} depending on |x| vs 1
pow(0, y>0) / pow(0, y<0) 0 / +inf (DIV_BY_ZERO for second)
NaN propagation otherwise

Spurious-flag mitigations

The recurring "every SIMD branch always executes" issue, applied here for several traps the formula would set on lanes we mask away:

Decisions

Test status

4699 umath tests pass, no regressions.

Impact (3A6000, n = 1M)

op dtype scalar libm NPYV speedup
np.power float32 80 M/s 131 M/s 1.64×
np.power float64 17 M/s 47 M/s 2.71×

Modest because the LoongArch libm pow is already an exp(y·log(x)) composition under the hood — but the f64 win is meaningful and the implementation establishes that all 17 ops in loops_umath_fp are now NPYV-routed on LoongArch.

Final state of loops_umath_fp.dispatch.c.src on LoongArch

op f32 f64
exp2, log2, log10 NPYV (Tier 1) NPYV (Tier 1)
sinh, cosh NPYV (Tier 1) NPYV (Tier 1)
arccosh NPYV (Tier 1) NPYV (Tier 1)
arcsinh, arctanh NPYV (re-enabled via log1p, Tier 2) NPYV (Tier 2)
log1p, expm1 NPYV (Tier 2) NPYV (Tier 2)
arctan, arcsin, arccos, arctan2 NPYV (Tier 2) NPYV (Tier 2)
tan NPYV (Tier 2) NPYV (Tier 2)
cbrt NPYV (Tier 2) NPYV (Tier 2)
power NPYV (this pass) NPYV (this pass)

17 unary + 2 binary = 19 ufuncs all on NPYV.

2026-05-23 — Regression cleanup: arcsin / arccos f64 direct kernel

File: numpy/_core/src/umath/loops_umath_fp.dispatch.c.src

What

The Tier-2 pass made arcsin / arccos f64 slower than scalar libm on the 3A6000:

op dtype main NPYV head NPYV (pre-fix) head/main
arcsin f64 67.9 M/s 34.3 M/s 0.51×
arccos f64 64.8 M/s 52.0 M/s 0.80×

Why the composition lost

The original kernel computed arcsin(x) = arctan(x/√(1-x²)), riding on the existing Tier-2 arctan. That means per element:

With 4-lane LASX f64 the SIMD parallelism doesn't make up for doing ~2× the floating-point work of scalar libm's direct asin.

Fix

Rewrote npyv_asin_DOUBLE_kernel using FDLIBM's two-region rational (coefficients from glibc sysdeps/ieee754/dbl-64/e_asin.c):

Both branches share P (degree 5) / Q (degree 4) so the polynomial is evaluated unconditionally. The large-path identity moves the argument back into a regime where the same poly is accurate. Total cost: one sqrt + one div + one degree-5 Horner + one degree-4 Horner. No transcendentals.

npyv_acos_DOUBLE_kernel continues to compose as π/2 - asin(x) — now gets the win for free.

Result

Decision: don't also rewrite f32 arcsin/arccos

The f32 kernels still compose on arctan and benchmark at 2.07× / 2.27× vs main — already winning, no precision concerns. Leave them alone; the f64 fix doesn't dictate f32 changes.

2026-05-23 — Re-enable SIMD f64 sin/cos with precision gate

Files: numpy/_core/src/umath/npyv_sincos.h (new), numpy/_core/src/umath/loops_trigonometric.dispatch.cpp

Context — why upstream disabled this

Upstream loops_trigonometric.dispatch.cpp lines 215-217 disabled the SIMD f64 sin/cos path entirely:

/* Disable SIMD code sin/cos f64 and revert to libm: see
 * https://mail.python.org/archives/list/[email protected]/
 *   thread/C6EYZZSR4EWGVKHAZXLE7IBILRMNVK7L/
 * for detailed discussion on this*/

The numpy-discussion thread concerned a previously-shipped SVML kernel that lost precision for |x| ≥ 2^20 (Cody-Waite range reduction breaks down past about 2^20 with a three-part π/2 split, and the kernel had no Payne-Hanek fallback). Result: DOUBLE_sin / DOUBLE_cos were routed to scalar libm on every platform — which on the 3A6000 leaves ~3× of LASX speedup on the floor.

What

Reintroduced a SIMD f64 sin/cos only for the range where Cody-Waite is provably accurate, with a per-block gate that falls back to scalar libm for blocks containing any out-of-range lane. The gate matches the exact pattern Highway uses for f32 sin/cos in the same file.

Implementation:

Precision audit (3A6000, 100k samples per range)

range sin max ULP sin mean ULP cos max ULP cos mean ULP
|x| ≤ 1 1.00 0.08 1.00 0.13
|x| ≤ 100 1.00 0.16 1.00 0.17
|x| ≤ 10⁶ 1.00 0.17 1.00 0.17
|x| > 2²⁰ (libm fallback) 0.00 0.00 0.00 0.00

Max 1 ULP across the SIMD regime — matches or beats FDLIBM's own ~2 ULP target. Beyond the gate the libm fallback is bit-exact since both the strided and contiguous paths call the same npy_sin/npy_cos.

NaN is handled inside the kernel (masked to 0 before rint, restored in the result).

Result

Decision: hybrid gate, not full SIMD reduction

We chose the per-block |x| < 2^20 libm fallback over implementing Payne-Hanek in SIMD. Reasoning:

Follow-up: SIMD Payne-Hanek

TODO: write a SIMD Payne-Hanek range reduction so the SIMD f64 sin/cos path covers the full f64 range without falling back to libm. This would:

References: - Payne, M. & Hanek, R., "Radian Reduction for Trigonometric Functions", SIGNUM Newsletter, 1983. - FDLIBM k_rem_pio2.c is a scalar reference. - The Highway TODO at the top of simd_sincos_f32 flags the same follow-up for the f32 path.

Decision: keep upstream behavior on non-LoongArch

The #if NPY_SIMD && defined(__loongarch__) && defined(NPYV_IMPL_F64_EXP_LOG) guard around the new macro body means non-LoongArch builds get the original upstream UNARY_LOOP { *op1 = npy_##func(*ip1); } exactly. This was deliberate — re-enabling SIMD f64 sin/cos on x86 would override the numpy-discussion decision for a platform we have no benchmark data on. LoongArch is opt-in; if someone wants to extend the hybrid to other arches the guard is one line.

2026-05-23 — cbrt f64: FDLIBM bit-magic + Newton

File: numpy/_core/src/umath/loops_umath_fp.dispatch.c.src

Context

cbrt f64 was the lowest-speedup op after the sin/cos work — 1.42× vs main (sometimes measured at 1.29×, depending on noise). The previous kernel composed on the existing NPYV exp/log:

cbrt(x) = sign(x) · exp(log(|x|) / 3)

That pays ~30 ops for log_kernel and another ~30 for exp_kernel, plus the multiply by 1/3 and the sign reconstruction — roughly 60 ops per element. Libm skips both transcendentals.

Algorithm (FDLIBM s_cbrt.c)

  1. Initial estimate via bit hack (~5 bits of accuracy). Take the top 32 bits of |x|, divide by 3, add a magic constant B1 = 715094163 ≈ (682 - 0.033) · 2²⁰. The arithmetic on the biased exponent field implicitly divides the binary exponent by 3, and B1 nudges the result toward the correct cube-root exponent. Shift back into the high 32 bits with zero low bits, reinterpret as a double:

    c t = bits_to_double(((|x|_bits >> 32) / 3 + B1) << 32)

  2. Rational refinement (~5 → ~23 bits). Standard FDLIBM rational on r = t³/x:

    c r = t·t/x; s = C + r·t; // C = 19/35 t *= G + F/(s + E + D/s);

    C, D, E, F, G are the FDLIBM constants (19/35, -864/1225, 99/70, 45/28, 5/14).

  3. Newton iteration to 53 bits. Critically, zero the low 32 bits of t first so is exact (no rounding error in the multiplication). Then:

    c s = t·t; r = x/s; w = t+t; r = (r-t)/(w+r); t = t + t·r;

SIMD specifics

Precision audit (3A6000, 50k samples per range, vs scalar libm)

range max ULP mean ULP
[0.001, 1] 3.00 0.64
[1, 1000] 3.00 0.50
[-1000, -0.001] 3.00 0.49
[1e-200, 1e200] (huge exponent range) 3.00 0.51

Max 3 ULP across the full f64 normal range — slightly looser than FDLIBM's stated 2 ULP target. The extra ULP comes from the FP-based divide-by-3 in the initial estimate (FDLIBM does integer divide of the 32-bit hx; we do floor(hx · (1/3)) which introduces a sub-ULP nudge that's amplified by the rational refinement). Could be tightened by adding an integer-divide-by-3 helper to NPYV, but 3 ULP is well within the budget for SIMD transcendentals.

Special values verified: cbrt(±0) = ±0, cbrt(±inf) = ±inf, cbrt(NaN) = NaN. All 86 cbrt tests in test_umath.py pass.

Result

Decision: don't also rewrite f32 cbrt

f32 cbrt was already at 3.57× vs main (the existing exp(log(|x|)/3) composition runs well on the 8-lane LASX f32 path — SIMD parallelism wins). Rewriting it would gain less and would need its own ULP audit; current implementation is fine.

Follow-up: ~~subnormal handling~~ — done

Initial cbrt landing routed subnormals to x (wrong) since FDLIBM's rescaling branch wasn't yet vectorized. Added below.

2026-05-23 — cbrt f64: subnormal correctness

File: numpy/_core/src/umath/loops_umath_fp.dispatch.c.src

What

The bit-magic initial estimate for cbrt assumes a normal-magnitude input — the biased exponent field needs to be in the range a normal double occupies, and the divide-by-3-plus-magic arithmetic breaks if the exponent field is zero (subnormal). The first landing routed subnormal lanes to x itself, which is wrong: cbrt(2⁻¹⁰⁷⁴) ≈ 1.7e-108, not 2⁻¹⁰⁷⁴.

Fix

Per FDLIBM s_cbrt.c:

  1. Pre-scale subnormal lanes by 2⁵⁴ to push them into the normal range (any subnormal × 2⁵⁴ is normal).
  2. Use magic constant B2 = 696219795 instead of B1 = 715094163 for those lanes. B2 = B1 − 18·2²⁰ — the −18 in the exponent field of the initial estimate exactly undoes the 2⁵⁴ scale-up (since cbrt(2⁵⁴) = 2¹⁸).
  3. The rational refinement and Newton step use the unscaled |x|, since they refine t³ ≈ |x| against the original cube-root constraint. (For x = 2⁻¹⁰³⁰, t ≈ 2⁻³⁴³, and t²/x ≈ 2³⁴⁴ — both normal, so the divide-by-subnormal step yields a finite result even though x is subnormal.)

In SIMD this is a per-lane select before the bit-magic: pick bit_input = abs_x × 2⁵⁴ and magic = B2 for subnormal lanes, else bit_input = abs_x and magic = B1. Two extra selects and one extra multiply — costs essentially nothing on the 4-lane LASX path (throughput stayed within noise at 137 M/s after the change).

Precision audit (156 subnormal samples across the full subnormal range)

range max ULP mean ULP
subnormal (2⁻¹⁰⁷⁴ .. 2⁻¹⁰²²) 1.00 0.34

Smallest subnormal cbrt(2⁻¹⁰⁷⁴) = 1.703184e-108 matches scalar libm exactly. Interesting: subnormals come out to 1 ULP (vs 3 ULP for normals) because the scaled-up input gives the bit-magic a cleaner starting point — the FP divide-by-3 has more headroom.

Edge cases verified

input output match libm?
0.0 0.0
-0.0 -0.0
+inf +inf
-inf -inf
NaN NaN
2⁻¹⁰²² (smallest normal) 2.81264e-103
2⁻¹⁰⁷⁴ (smallest subnormal) 1.70318e-108

All 4699 umath tests still pass. Throughput vs main unchanged at ~3.2×.

2026-05-24 — log1p f64: direct FDLIBM Padé with per-block dispatch

File: numpy/_core/src/umath/npyv_exp_log.h

Context

log1p f64 sat at 1.95-2.17× vs main — the kernel's slow path went through log_kernel(1+x) (~30 ops) for any |x| above the very tight 2⁻⁷ Taylor threshold. With both the 7-term Taylor branch and log_kernel always computed-and-blended, every call paid the full log_kernel cost.

Algorithm — two kernels, per-block dispatch

Lessons from getting this wrong

The first attempt used a symmetric threshold |x| < 0.4. That ran the Padé for x = -0.39, where the formula's underlying assumption (that u = 1+x lands in [√2/2, √2)) is violated. Result: ~8700 ULP error in a region the test caught. Threshold corrected to the asymmetric (-0.29, 0.41) band that FDLIBM uses; max ULP dropped back to 1 across the whole f64 normal range.

The full path also needed inf/NaN masking — inf − inf in the correction triggered spurious INVALID (numpy's test_unary_spurious_fpexception caught this). Fix: mask non-finite lanes to 0 before the subtractions, then let log_kernel(1+x) produce the right inf/NaN on its own special-case paths. Also added a u == 0 guard so log1p(-1) doesn't compute 0/0 in the correction (caught when arctanh(-1) started returning NaN instead of -∞).

Precision audit (50k samples per range, vs scalar libm)

range max ULP mean ULP
(-0.29, 0.41) (Padé sweet spot) 1.00 0.00
(-0.5, 0.5) (bench range, mixed) 1.00 0.07
(-0.99, 5) (full normal range) 1.00 0.05
(1e-300, 1e300) (extreme exponents) 0.00 0.00

Special-value check: log1p(0) = 0, log1p(-1) = -∞, log1p(-2) = NaN, log1p(∞) = ∞, log1p(NaN) = NaN — all correct, no spurious flags.

Result

2026-05-24 — arctanh f64: direct Cephes Padé in x² with per-block dispatch

File: numpy/_core/src/umath/loops_umath_fp.dispatch.c.src

Context

arctanh f64 was at 2.54× vs main. The kernel did 0.5 · log1p(2x/(1-x)) — even with the faster log1p above, this still pays a divide and the full log1p path.

Algorithm

For |x| ≤ 0.5, the function is approximated by a Cephes Padé in t = x²:

arctanh(x) = x + x · t · P(t) / Q(t)

with P degree 4, Q degree 5 (leading 1 implicit). Coefficients from Cephes atanh.c, peak relative error 1.4e-17. Cost ~13 ops.

For |x| > 0.5 the polynomial doesn't converge — use the original log1p formula. Per-block dispatch picks the path based on max(|lane|) ≤ 0.5.

Lessons

Cephes stores polynomial coefficients with coef[0] as the highest power, not the lowest. The first attempt evaluated Horner in the wrong order and produced wildly wrong values (~10¹⁸ ULP errors). Documented this in the kernel comment so we don't repeat when porting other Cephes routines.

Precision audit (50k samples per range)

range max ULP mean ULP
(-0.4, 0.4) (Padé sweet spot) 2.00 0.24
(-0.5, 0.5) (bench range) 2.00 0.25
(-0.99, 0.99) (slow path, near singularity) 21.00 0.54

The 21 ULP at the slow-path boundary is the asymptotic blow-up where (1-x) underflows ULPs near x = 1; libm itself only does slightly better here.

Special values: arctanh(±0) = ±0, arctanh(±1) = ±∞, arctanh(|x| > 1) = NaN, arctanh(NaN/±∞) = NaN — all correct.

Result

2026-05-24 — Payne-Hanek reduction for f64 sin/cos slow path

Files: numpy/_core/src/umath/payne_hanek_f64.h (new), numpy/_core/src/umath/npyv_sincos.h, numpy/_core/src/umath/loops_trigonometric.dispatch.cpp

Context

The 2026-05-23 sin/cos f64 landing gated the SIMD path on max(|lane|) < 2^20 and fell back to scalar libm for blocks containing any larger input. The 2^20 limit comes from Cody-Waite range reduction: y = round(x · 2/π) needs to be exactly representable in the 53-bit mantissa, and rounding errors in x · inv_pi_2 corrupt y for larger |x|. Beyond that, libm uses Payne-Hanek — a table-based reduction that handles arbitrary magnitudes by storing many bits of 2/π and indexing them based on x's exponent.

This pass replaces the libm fallback with our own Payne-Hanek reduction. Reduces the libm dependency to zero on the LoongArch sin/cos f64 path; covers the full f64 normal range with libm-equivalent precision.

Implementation

Two new files; the slow-path kernel sits in between them:

Precision audit (10k samples per range, vs scalar libm)

range sin max ULP cos max ULP
[-1, 1] (fast path) 1.00 1.00
[-100, 100] (fast path) 1.00 1.00
[-10⁶, 10⁶] (fast path) 1.00 1.00
[-2²⁵, 2²⁵] (slow path) 1.00 1.00
[-2⁴⁰, 2⁴⁰] (slow path) 1.00 1.00
[-2¹⁰⁰, 2¹⁰⁰] (slow path) 1.00 1.00
[-2⁵⁰⁰, 2⁵⁰⁰] (slow path) 1.00 1.00

Specific check: sin(2⁵⁰⁰) = 0.42925739234240... matches libm exactly. Spot checks at 1e15, 1e30, 1e100, 1e150, 1e300 all match libm bit-for-bit. All 4699 umath tests pass.

Throughput (3A6000, n = 1M)

input range which path speed vs libm equiv
[-3, 3] fast Cody-Waite SIMD 240 M/s 3.56× over scalar libm
[-10¹⁰, 10¹⁰] slow Payne-Hanek SIMD 7.3 M/s 0.78× vs scalar libm 9.4 M/s
[-10¹⁰⁰, 10¹⁰⁰] slow Payne-Hanek SIMD 7.0 M/s similar

The slow path is currently ~25% slower than scalar libm. The polynomial half is vectorized, so once the per-lane reduction overhead is amortized, this gap should close. Vectorizing the reduction (true SIMD Payne-Hanek with vectorized FP arithmetic across the chunks) is the next follow-up — it would need a SIMD gather for the per-lane table-chunk loads, which on LASX requires xvinsgr2vr_d plumbing.

Overall bench geomean across the 46 op×dtype pairs (mostly fast-path inputs) is unchanged at 4.94×.

Decision: scalar reduction + vector polynomial, not full SIMD reduction

A fully-vectorized Payne-Hanek would need per-lane gather of three or four 24-bit chunks from a 66-entry table. LASX has no native gather instruction; the workaround is xvinsgr2vr_d after four scalar loads, which doesn't really save time over the scalar approach we have. The polynomial after the reduction is the easy part to vectorize and already is.

Attempted SIMD Payne-Hanek (2026-05-24), and why it failed

I prototyped a vectorized PH that did per-lane gather of 4 chunks of 2/π plus all-vector FP arithmetic for the multiplications and the quadrant/fraction split. It was correct for |x| ≤ ~10⁶ (1 ULP) but catastrophically wrong (3000+ ULP) for |x| ≳ 10¹⁰.

The root cause is precision loss during chunk accumulation. Each chunk's contribution m · chunk_k · 2^(e − 52 − 24(k+1)) covers a 77-bit range, but f64 only holds 53 bits. Summing chunks at different bit-scales in plain f64 arithmetic drops the lower-order bits of each sum:

After the sum, only ~20 bits of the fractional part survive, which is nowhere near the 53 ULP-precision we need for sin/cos.

The fixes, all substantial:

  1. Double-Double accumulation. Replace the plain + between partial products with ddadd2 (Dekker-style two-sum with FMA correction). Each accumulator step preserves both the high and low parts of the result. ~6 extra ops per add × 4 adds = 24 ops per element, plus the multiplications also need DD form.
  2. Multi-precision integer accumulation. Keep the running sum as separate 24-bit-piece vectors (FDLIBM's iq[0..jz] style), propagate carries vector-side. Essentially porting FDLIBM's __kernel_rem_pio2 directly to SIMD with per-lane state.
  3. Pre-aligned table (SLEEF approach). Generate the table so each entry's 4 doubles are already at the right scale for direct multiplication, with the alignment baked in. Requires mpmath to generate the table; constant-table size grows from 66 ints to a few thousand doubles.

(1) is the cleanest; (3) is what SLEEF actually does. Either would add 100-300 lines of careful code. The current scalar PH works, ships correct results across the full f64 range at 7-9 M/s, and the bench is unaffected (the bench inputs are all fast-path). So the SIMD-reduction follow-up stays open but is not blocking.

Decision: don't add direct Padé for arcsinh f64

arcsinh's existing log1p(|x| + x²/(1+√(1+x²))) produces a log1p argument that's generally > 0.5 for the bench range |x| < 2 (e.g. arcsinh(0.5) → log1p(0.618), arcsinh(1) → log1p(1.414)). So per-block dispatch with threshold 0.5 in log1p would rarely fire through the arcsinh call path. Direct polynomial for arcsinh would need its own reduction (FDLIBM splits into 4 ranges based on |x|), which is engineering effort with limited bench-relevant payoff on this CPU. Filed for later if a real workload demands it.

2026-05-24 — Reclaim tanh / cbrt-f32 / power from libm

Files: numpy/_core/src/umath/loops_umath_fp.dispatch.c.src, numpy/_core/meson.build

Context

The cross-CPU bench against stock NumPy on Intel showed three transcendentals lagging more than the f64/f32 SIMD-width gap could account for: tanh at 0.06×, cbrt f32 at 0.06×, arcsinh/power in the 0.08–0.11× range. Diagnosis:

arcsinh is the one outlier — see prior section. The log1p form is intrinsic to its accuracy and the path is hard to shortcut without custom-derived Padé coefficients.

Implementation

Tradeoffs

Verified on Intel

The four ops compile cleanly. tanh on Intel still routes through the loops_hyperbolic SVML port (we only pulled LASX/LSX from its arch list); f32 cbrt and pow aren't exercised by our NPYV path on Intel (NPYV_IMPL_F32_EXP_LOG is undefined there). Validation against libm pending the Loongson rebuild.

Build trap: baseline duplicate symbol

The first Loongson rebuild failed with multiple definition of DOUBLE_tanh between the loops_umath_fp baseline build and the loops_hyperbolic baseline build. The Loongson meson_cpu config puts LSX in the baseline, so NPY_HAVE_LSX is defined even for baseline-build of every dispatch source. That meant NPYV_HAVE_tanh_* was defined for both sources' baseline, so both emitted the symbol.

Fixed by guarding the loops_hyperbolic tanh definition with #if !defined(NPY_HAVE_LSX) && !defined(NPY_HAVE_LASX). The loops_umath_fp side already gates correctly on NPYV_HAVE_tanh_@sfx@, so the collision is now one-sided: on LoongArch the symbol comes from loops_umath_fp; everywhere else, from loops_hyperbolic.

Bench surprise: tanh f32 — Highway port wins, keep it

The first end-to-end bench after the rebuild showed tanh f32 had regressed from 307 → 166 M/s. The Highway-based SVML port was already running fine on LASX (despite my earlier hypothesis); our NPYV expm1-based form is slower on f32 because the per-block dispatch for the small-x polynomial path mis-fires on the typical bench input distribution [-3, 3]: with ~8% of inputs below 0.25 per lane, ~50% of 8-lane LASX blocks hit the "mixed" branch and pay both paths.

Fix: keep the f32 NPYV kernel out of the build entirely. The #define NPYV_HAVE_tanh_f32 1 was removed from loops_umath_fp.dispatch.c.src, so the dispatch wrapper there only emits for f64. The loops_hyperbolic source's tanh #if !@loong_skip@ gate now uses a per-iteration loong_skip = 0, 1 repeat variable, gating only DOUBLE on LoongArch. FLOAT_tanh on LASX/LSX continues to come from the Highway SVML port.

DOUBLE_tanh on Loongson does get NPYV: the Highway path falls back to scalar libm for f64 there (no LASX f64 Highway gather), so 54 M/s before vs 81 M/s with our NPYV form. Net 1.49× for f64.

Final Loongson 3A6000 numbers

Op dtype Before After Gain
tanh f64 54.3 81.0 1.49×
tanh f32 307.1 338.9 1.10× (same Highway path; noise)
cbrt f32 174.3 537.3 3.08×
power f64 52.9 75.7 1.43×
power f32 140.2 183.4 1.31×

Validation: tanh f32 ≤1 ULP, tanh f64 ≤3 ULP, cbrt f32 ≤3 ULP across 100k random inputs each. power shows ≤65 ULP at extreme values where the result is ~1e-18 (e.g. pow(99, -8.6)) — that ULP magnification is the unavoidable consequence of exp(y · log(x)) in f64: a ~1 ULP log error × |y| ≈ 8.6 magnifies to ~9 ULP in y·log(x), then exp exponentiates absolute precision into relative-precision-at-tiny-value which converts to ~65 ULP. Closer-to-1.0 pow results stay ≤8 ULP. Documented for future-me as a known limitation; fixing requires double-double arithmetic across the log+exp pair.

2026-05-25 — Release 1: LASX for the basic ops, and two tradeoffs

Enabled LASX (256-bit) for the everyday element-wise dispatch units that the earlier transcendental work had left at LSX: loops_arithm_fp (float add/sub/mul/divide + complex), loops_unary, loops_unary_fp (sqrt, reciprocal, square, abs, rint/floor/ceil/trunc), loops_unary_complex, and loops_autovec. Almost free: the LASX NPYV backend (NumPy's own upstream/community work) already implements every intrinsic these files need — the switches just hadn't been flipped in meson.build.

Two dispatch units are deliberately kept on LSX because LASX is a net loss on store-bound, byte-output ops where the wide→byte pack needs cross-128-bit-lane xvpermi_d fixups:

Known tradeoff kept on LASX: f32 add/sub/mul

loops_arithm_fp is one dispatch unit, so it's all-or-nothing. Measured on the 3A6000: divide f32 +92%, all f64 arith +9–14%, but f32 add/sub/mul −19%. Kept on LASX for the divide/f64 wins. Cause of the f32 regression not profiled — likely memory-bandwidth-bound, but that's a hypothesis, not measured; cache-resident and higher-bandwidth-part behavior is untested. In BENCHMARKS.md.

Headline (release 1)

Full rebench incl. sort, dragon 2.5.0.dev0+dragon.unofficial.1 vs stock NumPy 2.5.0.dev0, back-to-back on an idle 3A6000, --iters 30, 135 op × dtype pairs: geomean 3.31× (f16 10.7×, bool 11.4×, f32 4.5×, f64 3.6×, int 1.8×).

Bench methodology note

Only compare runs taken back-to-back on an idle box. A stale-stock-vs-fresh-dragon comparison once produced a fake "the fix made it worse"; the max/multiply control ops (which should be stable) are the tell for a contaminated run.