DragonArray 文档

LoongArch / LSX 优化日志

这是 loongson-experimental 分支上优化工作的时间顺序记录。实测吞吐量见 BENCHMARKS.zh-CN.md

待办事项

统一记录在这里,避免跨 session 遗失。完成的项目会移出此列表,并成为下方按日期 排列的独立章节。

2026-05-17 — NPYV f32 exp / log

文件: numpy/_core/src/umath/loops_exponent_log.dispatch.c.src

改动

loops_exponent_log.dispatch.c.src 中加入 NPYV 风格的 simd_exp_FLOAT_npyv / simd_log_FLOAT_npyv。原文件中的 float32 exp/log 使用 raw x86 intrinsic(AVX2/AVX-512),因此在 LoongArch 上会回退到 scalar expf / logf。新 block 的 gate 为:

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

因此它覆盖所有尚无 AVX 路径的 NPYV target,目前包括 LSX、NEON 和 VSX。 dispatcher entry 新增 #elif defined(NPYV_IMPL_F32_EXP_LOG) 分支:contiguous input 使用新 kernel,strided input 回退到 scalar libm。

实现方式

算法与同一文件中的 AVX2 实现一致:

coefficient 原样复用 numpy/_core/src/umath/npy_simd_data.h 中的数据。

决策

开发期间修复的正确性问题

  1. NaN / inf input 触发不应出现的 FP exception。 ordered LSX compare (vfcmp.clt.s 等)遇到 NaN 会触发 INVALID。两个 kernel 现在都会在任何 signaling compare 之前,用 non-signaling npyv_notnan_f32 把 NaN mask 到 safe value(exp 用 0,log 用 1)。polynomial path 同样接收 masked input,避免 形成 inf · finite 等无效中间值。
  2. log mantissa range 错误。 最初将 IEEE exponent field 设为 127,得到 m ∈ [1, 2),随后却与 1/√2 ≈ 0.707 比较,因此条件永远不成立。修复为 exponent field 126(m ∈ [0.5, 1)),并相应调整 integer exponent,与 AVX 路径一致。修复前 test_log_float32 报告 3985 ULP error;修复后 774/774 个 exp/log test 全部通过。

测试状态

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

后续工作


2026-05-18 — 启用 Highway LSX target

文件: numpy/_core/meson.build(两处修改)

改动

移除 loongarch64 上已经过时的全局 -DHWY_COMPILE_ONLY_SCALAR flag,并把 LSX 加入 highway_qsort.dispatch.hhighway_qsort_16bit.dispatch.h 的 dispatch list。Highway 现在会编译 LSX backend(compiler 支持时也包括 LASX),NumPy 的 sort dispatcher 也会真正实例化 LSX variant。

为什么现在可行

Google Highway 在 v1.2 到 v1.3 之间加入 LSX/LASX target(首个 commit 7e01a07e,2024-11-25;实际实现 7bfb8e8b,2025-04-17;runtime dispatch 10d7ab41,2025-09-09)。bundled submodule 位于 1.3.0-159-gee36c83,同时 包含 hwy/ops/loongarch_lsx-inl.h(5,954 行)和 hwy/ops/loongarch_lasx-inl.h(4,681 行)。

NumPy 的 flag 添加于 2024-11-05(commit 7c35c37a1d),比 Highway upstream 首次加入 LSX stub 还早三周。它会强制所有由 Highway 支持的 op 回退到 scalar, 如今已经过时一年多。

影响

影响范围远大于 exp/log,因为 Highway 在 NumPy 中不仅用于 sort:

3A6000 上的实测收益(表格见 BENCHMARKS.zh-CN.md):

少数 op 未受影响,因为它们目前并不实际经过 Highway(tanarcsinarctansinhcosh)。

决策

测试状态

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

后续工作


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

文件:

改动

将 LASX(LoongArch 256-bit SIMD)端到端接入 NumPy CPU dispatch 系统,使 NumPy 编译的所有 Highway-backed code 都能在 3A6000 上于 runtime 使用 LASX target。

detection 仍使用 getauxval(AT_HWCAP) & HWCAP_LOONGARCH_LASX,遵循 HWCAP 是 事实来源而不是 raw cpucfg 的既有规则。3A6000 上的 np.show_runtime() 现在报告: 'simd_extensions': {'baseline': ['LSX'], 'found': ['LASX'], 'not_found': []}

这只是 LASX dispatch wiring。此时还没有 numpy/_core/src/common/simd/lasx/ NPYV backend,那是另一项更大的工作。当前 LASX 收益完全来自 Highway 自己的 LASX target;NPYV 风格代码(包括五月编写的 f32 exp/log)仍以 LSX 运行。

决策

影响(3A6000,n = 1M)

相对上一节的 LSX-only 状态,LASX 进一步带来:

tanh 与 logical op 在 LSX 下已经饱和(即便 NumPy 层 dispatch target 是 LSX, Highway 也会在内部采用这些路径)。tanarcsinarctansinhcosh 仍无变化,它们使用其他 code path。

相对最初 scalar baseline,累计收益为:

测试状态

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

后续工作


2026-05-20 — 原生 LASX NPYV backend

新增文件:

总计 2108 行,与 LSX backend 的文件结构逐一对应。

修改文件:

改动

为 LoongArch LASX 编写完整的 NPYV(256-bit)backend。从 simd/lsx/ 转换的工作 大部分是 mechanical:

2026-05-17 编写的 f32 exp/log kernel 已采用 NPYV 风格,因此将 LASX 加入其 dispatch list 后,它自动获得 256-bit lane,无需修改 source。

决策与注意事项

测试状态

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

影响(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 上都相对 LSX 获得接近 2× 的提升;n 足够大、scalar tail 的开销被 摊薄后,8-lane scaling 如预期实现。

后续工作


2026-05-20 — f64 NPYV exp / log

文件: numpy/_core/src/umath/loops_exponent_log.dispatch.c.src

改动

加入 double-precision exp/log 的 NPYV 风格 kernel,gate 为 #if NPY_SIMD_F64 && !defined(SIMD_AVX2_FMA3) && !defined(SIMD_AVX512F) && !defined(SIMD_AVX512F_NOCLANG_BUG),并加入 NPYV_IMPL_F64_EXP_LOG sentinel。DOUBLE_@func@ dispatcher 在 AVX-512 path 与 scalar fallback 之间新增 #if defined(NPYV_IMPL_F64_EXP_LOG) 分支。

算法

f64 需要约 15 位十进制精度,因此不能沿用过短的 f32 P5/Q2 polynomial。这里采用 两种成熟算法:

同一文件中新增两个 arch-specific helper:

#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))

并提供对应 LSX entry。NPYV 尚未 portable 地暴露 int64↔float64 cast;TODO 记录了后续提升计划。

测试状态

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

在 200,000 个 random sample 上相对 math.exp/math.log 的 ULP:

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

结果优于 f32 path;后者在 edge case 上的 max 约为 6 ULP。

影响(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×

同一 kernel 也会生成 2-wide LSX 与 4-wide LASX f64;显著数字采用 3A6000 runtime dispatch 的 LASX。bench script 没有单独记录 LSX-only f64。

后续工作


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

文件:

改动

把 LoongArch acceleration 扩展到六个原先位于 loops_umath_fp.dispatch.c.src 的 transcendental。此前它们只有 X86_V4/SVML 路径,在包括 LoongArch、ARM、PowerPC 在内的其他架构上都使用 scalar libm。 六项都以现有 exp/log NPYV kernel 上的 thin wrapper 实现:

op 使用的 identity
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) x ≥ 1 时为 log(x + √(x² − 1))

kernel naming 遵循现有 dispatcher template 使用的 libm convention (asinh/acosh/atanh,而非 NumPy 的 arcsinh 形式),因此 dispatcher 中同一 #elif defined(NPYV_HAVE_@intrin@_@sfx@) pattern 可以自动接入,无需 per-op #ifdef

架构重构

exp/log kernel 原先 inline 在 loops_exponent_log.dispatch.c.src 中。它们被移至 新的 numpy/_core/src/umath/npyv_exp_log.h,让 loops_umath_fp 可以在其上组合。 两个文件都 include 该 header;kernel 为 NPY_FINLINE,所以每个 translation unit 获得自己的 inline copy,不产生 link conflict。NPYV_IMPL_F32_EXP_LOG / NPYV_IMPL_F64_EXP_LOG marker 现在在 header 内设置(前提是对应 int↔float cast macro 可用);原文件 guard 仅检查 marker,不再重复 condition。

决策

测试状态

umath suite:4699 passed,0 failed。

影响(3A6000,n = 1M)

在同一 build 中对比 contiguous array(NPYV path)与同长度 strided array。strided dispatch 无法通过 steps[i] == sizeof(dtype) 检查,因而回退到 scalar libm; 编译代码相同,仅 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 提升较小,因为 scalar libm acoshf 已经可以 vectorize,或 LoongArch libm 本身已有优化。其他 op 此前确实受 scalar path 限制。

后续工作


2026-05-23 — Tier 2 NPYV transcendental(除 power 外的所有项)

文件:

改动

此 pass 补齐 loops_umath_fp.dispatch.c.src 中的大部分缺口。该文件拥有的 17 个 ufunc 中,15 个现在已有 NPYV 实现;只剩 power(x, y)。它涉及 negative base 配 integer exponent、0^0、任一 operand 为 ±inf 等 corner case,明显更复杂。

kernel 可分为几种 pattern:

决策与注意事项

测试状态

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

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

数字在不同 run 间有噪声,特别是 absolute speed 较低、overhead 占主导时;下列 ratio 已 round。

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 最突出:3A6000 上 scalar libm tan 约为 7 M elem/s,而 NPYV 一次处理 8 lane。arcsin / arccos 提升最小,因为它们调用本身已经承担 polynomial cost 的 arctan;收益主要来自 SIMD width。

后续工作


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

文件: numpy/_core/src/umath/loops_umath_fp.dispatch.c.src

改动

实现 npyv_pow_FLOAT_kernelnpyv_pow_DOUBLE_kernel,补齐 loops_umath_fp.dispatch.c.src 最后一处缺口。该文件每个 op 现在都在 LoongArch (LSX 与 LASX)上拥有 NPYV path。

算法

通用公式为 |x|^y = exp(y · log(|x|))。外围用一系列 mask-based override 处理 IEEE 754 / C99 pow() special case:

input pattern result
pow(x, 0) 1(即便 x 为 NaN),覆盖其他规则
pow(1, y) 1(即便 y 为 NaN),覆盖其他规则
pow(x<0, integer y) ±|x|^y(sign 由 y parity 决定)
pow(x<0, non-integer y) NaN
pow(x, ±inf) 根据 |x| 与 1 的关系返回 0、1 或 inf
pow(0, y>0) / pow(0, y<0) 0 / +inf(后者触发 DIV_BY_ZERO)
NaN propagation 其他情况

避免无关 FP flag

决策

测试状态

4699 个 umath test 全部通过,无 regression。

影响(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×

提升不算巨大,因为 LoongArch libm pow 内部已经采用 exp(y·log(x)) 组合;但 f64 收益仍有意义,也意味着 loops_umath_fp 的全部 17 个 op 现已在 LoongArch 上经过 NPYV routing。

LoongArch 上 loops_umath_fp.dispatch.c.src 的最终状态

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(通过 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(本次 pass) NPYV(本次 pass)

17 个 unary + 2 个 binary = 19 个 ufunc,全部使用 NPYV。

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

文件: numpy/_core/src/umath/loops_umath_fp.dispatch.c.src

改动

Tier-2 pass 让 f64 arcsin / arccos 在 3A6000 上反而慢于 scalar libm:

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×

组合方式为什么失败

原 kernel 通过已有 Tier-2 arctan 计算 arcsin(x) = arctan(x/√(1-x²))。每个 element 需要:

4-lane LASX f64 的 SIMD parallelism 无法弥补约为 scalar libm direct asin 两倍的 floating-point 工作量。

修复

使用 FDLIBM two-region rational 重写 npyv_asin_DOUBLE_kernel,coefficient 来自 glibc sysdeps/ieee754/dbl-64/e_asin.c

两个 branch 共用 P(degree 5)/ Q(degree 4),所以 polynomial 无条件计算。 large-path identity 会把 argument 移回同一 polynomial 精确的范围。总成本为一次 sqrt、一次 div、一个 degree-5 Horner 与一个 degree-4 Horner,不含 transcendental。

npyv_acos_DOUBLE_kernel 仍组合为 π/2 - asin(x),因而自动获得提升。

结果

决策:不同时重写 f32 arcsin/arccos

f32 kernel 仍在 arctan 上组合,相对 main benchmark 为 2.07× / 2.27×,已有收益且 没有精度问题。f64 fix 不代表必须修改 f32,因此保持不变。

2026-05-23 — 通过精度 gate 重新启用 SIMD f64 sin/cos

文件: numpy/_core/src/umath/npyv_sincos.h(新增)、 numpy/_core/src/umath/loops_trigonometric.dispatch.cpp

背景:上游为什么禁用此路径

upstream loops_trigonometric.dispatch.cpp 215–217 行完全禁用了 SIMD f64 sin/cos:

/* 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*/

讨论涉及一个曾发布的 SVML kernel:当 |x| ≥ 2^20 时会损失精度。三段 π/2 split 的 Cody-Waite reduction 在约 2^20 之后失效,而 kernel 没有 Payne-Hanek fallback。 因此 DOUBLE_sin / DOUBLE_cos 在所有平台都 route 到 scalar libm;在 3A6000 上这会放弃约 3× LASX speedup。

改动

只在 Cody-Waite 可证明精确的范围内重新启用 SIMD f64 sin/cos。per-block gate 在 block 含任何 out-of-range lane 时回退到 scalar libm;pattern 与同文件 Highway f32 sin/cos 完全一致。

精度审计(3A6000,每个范围 100k sample)

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

SIMD range 的 max 为 1 ULP,达到或优于 FDLIBM 约 2 ULP 的目标。gate 之外的 libm fallback bit-exact,因为 strided 与 contiguous path 都调用同一 npy_sin/npy_cos。kernel 内部先把 NaN mask 到 0 再 rint,最后恢复 NaN。

结果

决策:hybrid gate,而非完整 SIMD reduction

选择 per-block |x| < 2^20 libm fallback,而未立即实现 SIMD Payne-Hanek:

后续:SIMD Payne-Hanek

TODO:实现 SIMD Payne-Hanek range reduction,让 SIMD f64 sin/cos 覆盖完整 f64 range,不再回退到 libm。这样可以:

参考:Payne 与 Hanek 的 1983 年论文 “Radian Reduction for Trigonometric Functions”;FDLIBM k_rem_pio2.c scalar reference;以及 simd_sincos_f32 顶部标记同一 f32 follow-up 的 Highway TODO。

决策:非 LoongArch 保持 upstream behavior

新 macro body 由 #if NPY_SIMD && defined(__loongarch__) && defined(NPYV_IMPL_F64_EXP_LOG) guard, 因此非 LoongArch build 得到原 upstream UNARY_LOOP { *op1 = npy_##func(*ip1); }。这是有意为之:在没有 benchmark 的 x86 上重新启用 SIMD f64 sin/cos 会绕过 numpy-discussion 的决定。LoongArch 是 opt-in;若要扩展到其他架构,只需调整一行 guard。

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

文件: numpy/_core/src/umath/loops_umath_fp.dispatch.c.src

背景

sin/cos 工作完成后,f64 cbrt 是 speedup 最低的 op,相对 main 1.42×(受噪声影响 有时为 1.29×)。此前 kernel 在 NPYV exp/log 上组合:

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

每个 element 要为 log_kernel 付出约 30 op,再为 exp_kernel 付出约 30 op,加上 乘以 1/3 与重建 sign,合计约 60 op。libm 会绕过两个 transcendental。

算法(FDLIBM s_cbrt.c

  1. 通过 bit hack 得到 initial estimate(约 5-bit accuracy)。取得 |x| 的 top 32 bit,除以 3,加 magic constant B1 = 715094163 ≈ (682 - 0.033) · 2²⁰。对 biased exponent field 的 arithmetic 隐式把 binary exponent 除以 3,B1 把结果调整到正确 cube-root exponent。再 shift 回 high 32 bit,low bit 清零,reinterpret 为 double:

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

  1. Rational refinement(约 5 → 23 bit)。使用 FDLIBM 对 r = t³/x 的 standard rational:

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

C、D、E、F、G 分别为 19/35、-864/1225、99/70、45/28、5/14。

  1. Newton iteration 达到 53 bit。 先清零 t 的 low 32 bit,使 exact:

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

SIMD 细节

精度审计(3A6000,每个范围 50k sample,相对 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] 3.00 0.51

完整 f64 normal range max 为 3 ULP,略宽于 FDLIBM 2 ULP 目标。额外 ULP 来自 FP-based initial divide-by-3;FDLIBM 对 32-bit hx 使用 integer divide,而这里的 floor(hx · (1/3)) 引入 sub-ULP 偏移,随后被 rational refinement 放大。可以通过 给 NPYV 加 integer-divide-by-3 helper 收紧,但 3 ULP 仍在 SIMD transcendental 预算内。

special value 已验证:cbrt(±0) = ±0cbrt(±inf) = ±infcbrt(NaN) = NaNtest_umath.py 中 86 个 cbrt test 全部通过。

结果

决策:不同时重写 f32 cbrt

f32 cbrt 相对 main 已有 3.57×;现有 exp(log(|x|)/3) 在 8-lane LASX f32 path 上表现良好。重写的收益更少且需要独立 ULP audit,因此保持现状。

后续:~~subnormal handling~~ — 已完成

最初 cbrt 实现将 subnormal route 到原 x,结果错误;后续章节补上 FDLIBM rescale branch。

2026-05-23 — cbrt f64:subnormal correctness

文件: numpy/_core/src/umath/loops_umath_fp.dispatch.c.src

问题

cbrt 的 bit-magic initial estimate 假设 input 为 normal magnitude;biased exponent field 必须落在 normal double range。若 exponent field 为零(subnormal), divide-by-3-plus-magic arithmetic 会失效。首次实现把 subnormal lane route 到 x 自身,这是错误的:cbrt(2⁻¹⁰⁷⁴) ≈ 1.7e-108,不是 2⁻¹⁰⁷⁴

修复

按照 FDLIBM s_cbrt.c

  1. 先把 subnormal lane 乘以 2⁵⁴,推入 normal range。
  2. 这些 lane 使用 magic constant B2 = 696219795,而不是 B1 = 715094163。 B2 = B1 − 18·2²⁰;initial estimate exponent field 中的 −18 会精确抵消 2⁵⁴ scaling,因为 cbrt(2⁵⁴) = 2¹⁸
  3. rational refinement 与 Newton step 使用 unscaled |x|,因为它们针对原始 cube-root constraint refinement t³ ≈ |x|

SIMD 在 bit-magic 前按 lane select:subnormal 选择 bit_input = abs_x × 2⁵⁴magic = B2,其他 lane 选择原 abs_x 与 B1。 多出两个 select 与一个 multiply,在 4-lane LASX path 上几乎无成本;throughput 仍在 137 M/s 噪声范围内。

精度审计(完整 subnormal range 的 156 个 sample)

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

最小 subnormal cbrt(2⁻¹⁰⁷⁴) = 1.703184e-108 与 scalar libm 完全一致。 subnormal 反而达到 1 ULP(normal 为 3 ULP),因为 scaled input 给 bit-magic 更干净的起点,FP divide-by-3 也有更多 headroom。

已验证 edge case

input output 与 libm 一致?
0.0 0.0
-0.0 -0.0
+inf +inf
-inf -inf
NaN NaN
2⁻¹⁰²²(最小 normal) 2.81264e-103
2⁻¹⁰⁷⁴(最小 subnormal) 1.70318e-108

4699 个 umath test 仍全部通过。相对 main throughput 保持约 3.2×。

2026-05-24 — log1p f64:按 block dispatch 的直接 FDLIBM Padé

文件: numpy/_core/src/umath/npyv_exp_log.h

背景

log1p f64 相对 main 为 1.95–2.17×——当 |x| 超过非常严格的 2⁻⁷ Taylor threshold 时,kernel 的 slow path 会经过 log_kernel(1+x)(约 30 个操作)。 由于 7 项 Taylor branch 和 log_kernel 总是一起计算再 blend,每次调用都要承担完整的 log_kernel 成本。

算法——两个 kernel,按 block dispatch

s = x/(2+x); z = s²; hfsq = x²/2 R = z · poly(z) [Horner, 7 coefficients] log1p(x) = x - (hfsq - s·(hfsq + R))

成本约 15 个操作,在 x ∈ (1-√2/2, √2-1) ≈ (-0.293, 0.414) 内有效。超出该区间时, 公式会损失约 14 bit。

踩坑记录

第一次尝试使用了对称 threshold |x| < 0.4。这会让 x = -0.39 走 Padé, 但此处违反了公式的底层假设(u = 1+x 应落在 [√2/2, √2) 内)。结果是在 测试覆盖的区域出现约 8700 ULP error。把 threshold 修正为 FDLIBM 使用的非对称 (-0.29, 0.41) 区间后,整个 f64 normal range 的 max ULP 回到 1。

full path 还需要 inf/NaN masking——correction 中的 inf − inf 会触发虚假的 INVALID(被 NumPy 的 test_unary_spurious_fpexception 捕获)。修复方法是在 subtraction 之前把 non-finite lane mask 为 0,然后让 log_kernel(1+x) 自己的 special-case path 产生正确的 inf/NaN。还加入了 u == 0 guard,防止 log1p(-1) 在 correction 中计算 0/0(这一问题在 arctanh(-1) 开始返回 NaN 而不是 -∞ 时被发现)。

精度审计(每个 range 50k 个 sample,与 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)(完整 normal range) 1.00 0.05
(1e-300, 1e300)(极端 exponent) 0.00 0.00

special-value 检查:log1p(0) = 0log1p(-1) = -∞log1p(-2) = NaNlog1p(∞) = ∞log1p(NaN) = NaN——全部正确,且没有虚假 flag。

结果

2026-05-24 — arctanh f64:以 x² 为变量的直接 Cephes Padé,按 block dispatch

文件: numpy/_core/src/umath/loops_umath_fp.dispatch.c.src

背景

arctanh f64 相对 main 为 2.54×。kernel 使用 0.5 · log1p(2x/(1-x))——即便有了上面更快的 log1p,仍要付出一次 divide 和完整 log1p path 的成本。

算法

|x| ≤ 0.5 时,函数使用以 t = x² 为变量的 Cephes Padé 近似:

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

其中 P 为 4 次、Q 为 5 次(最高项 1 隐含)。coefficient 来自 Cephes atanh.c,peak relative error 为 1.4e-17,成本约 13 个操作。

|x| > 0.5 时,polynomial 不收敛,因此使用原来的 log1p 公式。 per-block dispatch 根据 max(|lane|) ≤ 0.5 选择 path。

经验

Cephes 存储 polynomial coefficient 时,coef[0] 对应的是最高次幂, 而不是最低次幂。第一次尝试以错误顺序执行 Horner,产生了离谱的结果 (约 10¹⁸ ULP error)。已在 kernel comment 中记录,以免移植其他 Cephes routine 时重犯。

精度审计(每个 range 50k 个 sample)

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,接近 singularity) 21.00 0.54

slow-path 边界处的 21 ULP 来自 x = 1 附近 (1-x) 丢失 ULP 时的渐近 放大;libm 本身在这里也只略好一些。

special value:arctanh(±0) = ±0arctanh(±1) = ±∞arctanh(|x| > 1) = NaNarctanh(NaN/±∞) = NaN——全部正确。

结果

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

文件: numpy/_core/src/umath/payne_hanek_f64.h(新增)、 numpy/_core/src/umath/npyv_sincos.hnumpy/_core/src/umath/loops_trigonometric.dispatch.cpp

背景

2026-05-23 合入的 sin/cos f64 实现只在 max(|lane|) < 2^20 时使用 SIMD, 只要 block 中含有更大的 input 就 fallback 到 scalar libm。2^20 限制来自 Cody–Waite range reduction:y = round(x · 2/π) 必须能在 53-bit mantissa 中精确表示;对更大的 |x|,x · inv_pi_2 的舍入误差会破坏 y。超过这一范围后, libm 使用 Payne–Hanek——一种 table-based reduction:存储 2/π 的许多 bit, 并按 x 的 exponent 建立索引,从而处理任意 magnitude。

这次改动用我们自己的 Payne–Hanek reduction 替代 libm fallback。LoongArch sin/cos f64 path 对 libm 的依赖降为零,并以 libm 等效精度覆盖完整 f64 normal range。

实现

新增两个文件;slow-path kernel 位于二者之间:

  1. 缩放 |x|,使其 biased exponent 变为 1046(unbiased 23),得到 [2^23, 2^24) 内的值。剥离三个 24-bit integer chunk tx[0..2]
  2. 索引预计算的 2/π bit table(66 个 chunk × 24 bit = 1584 bit,足以 覆盖 |x| < 2^1024 并留有余量)。加载 jx+jk+1 个 chunk,其中 jx = nx-1jk = 4(FDLIBM 为 f64 精度采用的值)。
  3. tx[] 与加载的 chunk 相乘,得到 partial product q[0..jk]。 每次 f64 multiplication 都是精确的(24-bit × 24-bit 为 48 bit, 不超过 53 bit)。
  4. 从高位向下把 q[] 分解成 24-bit integer piece iq[],取出 x · 2/π 的 integer part,供 quadrant 使用。
  5. 若剩余 fraction 大于 0.5,则 quadrant 加一,并对 iq[] 取 complement。 若 fraction 恰好缩为零则迭代(罕见;goto recompute)。
  6. 把 fraction 乘回按 24-bit 对齐的 π/2 chunk,恢复 reduced argument。

精度审计(每个 range 10k 个 sample,与 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

具体检查:sin(2⁵⁰⁰) = 0.42925739234240... 与 libm 完全一致。 在 1e151e301e1001e1501e300 的 spot check 都与 libm bit-for-bit 一致。4699 个 umath test 全部通过。

Throughput(3A6000,n = 1M)

input range 所走 path speed 相对 libm 等效实现
[-3, 3] fast Cody–Waite SIMD 240 M/s 比 scalar libm 快 3.56×
[-10¹⁰, 10¹⁰] slow Payne–Hanek SIMD 7.3 M/s scalar libm 为 9.4 M/s,故为 0.78×
[-10¹⁰⁰, 10¹⁰⁰] slow Payne–Hanek SIMD 7.0 M/s 相近

slow path 目前比 scalar libm 慢约 25%。polynomial half 已 vectorize,因此 一旦 per-lane reduction overhead 能被摊薄,差距应会缩小。下一项 follow-up 是 vectorize reduction(用 vector FP arithmetic 实现真正的 SIMD Payne–Hanek);这需要对 per-lane table chunk load 进行 SIMD gather,而在 LASX 上需加入 xvinsgr2vr_d plumbing。

46 个 op×dtype pair 的 overall bench geomean(绝大多数为 fast-path input) 保持 4.94× 不变。

决策:scalar reduction + vector polynomial,而不是全 SIMD reduction

完全 vectorized 的 Payne–Hanek 需要逐 lane 从 66-entry table gather 三到四个 24-bit chunk。LASX 没有原生 gather instruction;替代方案是在四次 scalar load 后使用 xvinsgr2vr_d,与当前 scalar 方法相比并不真正节省时间。reduction 后的 polynomial 才是容易 vectorize 的部分,而这部分已经完成。

尝试 SIMD Payne–Hanek(2026-05-24)及失败原因

我做过一个 vectorized PH prototype:逐 lane gather 四个 2/π chunk,并用全 vector FP arithmetic 完成 multiplication 和 quadrant/fraction split。 它在 |x| ≤ ~10⁶ 时正确(1 ULP),但在 |x| ≳ 10¹⁰ 时出现灾难性错误 (3000+ ULP)。

根因是 chunk accumulation 中的精度损失。每个 chunk 的 contribution m · chunk_k · 2^(e − 52 − 24(k+1)) 跨越 77-bit range,但 f64 只能保留 53 bit。用普通 f64 arithmetic 将不同 bit scale 的 chunk 相加,会丢掉每次 sum 的低位:

求和后,fractional part 只剩约 20 bit,远不足以满足 sin/cos 所需的 53 ULP 精度。

修复方法都不简单:

  1. Double-Double accumulation。ddadd2(使用 FMA correction 的 Dekker-style two-sum)替换 partial product 之间的普通 +。每一步 accumulator 都保留结果的 high 与 low part。每次 add 每个 element 约多 6 个操作 × 4 次 add = 24 个操作,multiplication 也需要 DD 形式。
  2. Multi-precision integer accumulation。 把 running sum 保持为多个独立的 24-bit-piece vector(FDLIBM 的 iq[0..jz] 风格),并在 vector side propagate carry。实质上就是把 FDLIBM 的 __kernel_rem_pio2 连同 per-lane state 直接移植到 SIMD。
  3. 预对齐 table(SLEEF 方法)。生成 table 时就把每个 entry 的四个 double 按直接 multiplication 所需 scale 对齐。需要用 mpmath 生成 table;constant table 会从 66 个 int 增长到数千个 double。

(1)最清晰;(3)是 SLEEF 实际采用的方法。二者都要增加 100–300 行谨慎编写的 代码。当前 scalar PH 工作正常,能以 7–9 M/s 在完整 f64 range 内输出正确结果, 而且 bench 不受影响(bench input 全部在 fast path)。因此 SIMD-reduction follow-up 继续保留,但不阻塞发布。

决策:不为 arcsinh f64 增加直接 Padé

arcsinh 现有的 log1p(|x| + x²/(1+√(1+x²))) 会在 bench range |x| < 2 内产生通常大于 0.5 的 log1p argument(例如 arcsinh(0.5) → log1p(0.618),arcsinh(1) → log1p(1.414))。因此, log1p 中 threshold 为 0.5 的 per-block dispatch 在 arcsinh call path 中很少 触发。arcsinh 的直接 polynomial 需要自己的 reduction(FDLIBM 按 |x| 分为四个 range),工程投入较大,而在这颗 CPU 上与 bench 有关的收益有限。如果真实 workload 需要,再作为后续工作处理。

2026-05-24 — 从 libm 收回 tanh / cbrt-f32 / power

文件: numpy/_core/src/umath/loops_umath_fp.dispatch.c.srcnumpy/_core/meson.build

背景

在 Intel 上与 stock NumPy 进行的 cross-CPU bench 表明,有三个 transcendental 落后幅度远大于 f64/f32 SIMD-width gap 所能解释:tanh 为 0.06×、cbrt f32 为 0.06×,arcsinh/power 在 0.08–0.11×。诊断如下:

arcsinh 是唯一的 outlier——见上一节。log1p 形式对其精度至关重要,如果不使用 自定义推导的 Padé coefficient,很难缩短 path。

实现

meson.buildloops_hyperbolic arch list 中移除 LASX, LSX,使该 source 不再为 LoongArch build 生成 dispatch function,避免 duplicate-symbol collision。loops_umath_fp 中新的 DOUBLE_tanh / FLOAT_tanh 定义受 NPYV_HAVE_tanh_@sfx@ 控制(仅当 NPYV exp/log path 编译时为 true,也就是 LSX/LASX),因此其他 target 仍以 loops_hyperbolic 的 SVML port 为准。

权衡

已在 Intel 验证

四个 op 都能正确编译。在 Intel 上,tanh 仍走 loops_hyperbolic SVML port (只从其 arch list 移除了 LASX/LSX);f32 cbrtpow 不会使用我们的 NPYV path(那里未定义 NPYV_IMPL_F32_EXP_LOG)。对 libm 的验证等待 Loongson rebuild。

Build 陷阱:baseline duplicate symbol

第一次 Loongson rebuild 因 loops_umath_fp baseline build 与 loops_hyperbolic baseline build 都定义 DOUBLE_tanh 而失败。Loongson meson_cpu config 把 LSX 放在 baseline,因此即使对每个 dispatch source 做 baseline build,也会定义 NPY_HAVE_LSX。结果是两个 source 的 baseline 中 NPYV_HAVE_tanh_* 都被定义,双方都生成了该 symbol。

修复方法是用 #if !defined(NPY_HAVE_LSX) && !defined(NPY_HAVE_LASX) guard loops_hyperbolic 的 tanh 定义。loops_umath_fp 一侧已正确受 NPYV_HAVE_tanh_@sfx@ 控制,因此 collision 现在只剩单一来源:LoongArch 上 symbol 来自 loops_umath_fp;其他平台来自 loops_hyperbolic。

Bench 意外:tanh f32——Highway port 更快,继续保留

rebuild 后第一次 end-to-end bench 显示,tanh f32 从 307 → 166 M/s,反而出现 regression。Highway-based SVML port 原本就在 LASX 上正常运行(与我先前的假设 不同);我们的 NPYV expm1-based 形式在 f32 上更慢,因为 small-x polynomial path 的 per-block dispatch 会在典型 bench input distribution [-3, 3] 上 误触发:每个 lane 约有 8% 的 input 小于 0.25,导致约 50% 的 8-lane LASX block 进入 “mixed” branch,同时支付两条 path 的成本。

修复方法是完全不编译 f32 NPYV kernel。从 loops_umath_fp.dispatch.c.src 移除 #define NPYV_HAVE_tanh_f32 1,因此其中的 dispatch wrapper 只为 f64 生成。loops_hyperbolic source 的 tanh #if !@loong_skip@ gate 现在使用 per-iteration loong_skip = 0, 1 repeat variable,只在 LoongArch 上跳过 DOUBLE。LASX/LSX 的 FLOAT_tanh 继续来自 Highway SVML port。

Loongson 的 DOUBLE_tanh 确实改用 NPYV:Highway path 在那里对 f64 fallback 到 scalar libm(没有 LASX f64 Highway gather),改动前为 54 M/s,使用我们的 NPYV 形式后为 81 M/s,f64 净提升 1.49×。

最终 Loongson 3A6000 数字

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

验证:各 100k 个 random input 中,tanh f32 ≤1 ULP、tanh f64 ≤3 ULP、 cbrt f32 ≤3 ULP。power 在 result 约为 1e-18 的 extreme value(例如 pow(99, -8.6))处会达到 ≤65 ULP——这是 f64 中 exp(y · log(x)) 不可避免的 ULP 放大:约 1 ULP 的 log error × |y| ≈ 8.6,会在 y·log(x) 中放大为约 9 ULP;随后 exp 把 absolute precision 指数化为 tiny value 的 relative precision,换算后约为 65 ULP。接近 1.0 的 pow result 保持 ≤8 ULP。 已记录为已知限制;若要修复,需要在整个 log+exp pair 上使用 double-double arithmetic。

2026-05-25 — Release 1:日常基础操作启用 LASX,以及两项权衡

为此前 transcendental 工作仍留在 LSX 的日常 element-wise dispatch unit 启用 LASX(256-bit):loops_arithm_fp(float add/sub/mul/divide + complex)、 loops_unaryloops_unary_fp(sqrt、reciprocal、square、abs、 rint/floor/ceil/trunc)、loops_unary_complexloops_autovec。这几乎不需 额外实现:LASX NPYV backend(NumPy 自己的 upstream/community work)已经实现这些 文件需要的全部 intrinsic,只是 meson.build 中的开关尚未打开。

有两个 dispatch unit 刻意保留在 LSX,因为对 store-bound、byte-output op, LASX 的 wide→byte pack 需要跨 128-bit lane 的 xvpermi_d fixup,整体反而变慢:

保留 LASX 的已知权衡:f32 add/sub/mul

loops_arithm_fp 是单个 dispatch unit,只能全开或全关。在 3A6000 上实测: f32 divide +92%,全部 f64 arithmetic +9–14%,但 f32 add/sub/mul −19%。为保留 divide/f64 的收益,仍采用 LASX。尚未 profile f32 regression 的原因——可能受 memory bandwidth 限制,但这只是 hypothesis,并非测量结论; cache-resident 与更高 bandwidth 部件上的表现仍未测试。详见 BENCHMARKS.md。

Headline(Release 1)

完整 rebench(含 sort):dragon 2.5.0.dev0+dragon.unofficial.1 对 stock NumPy 2.5.0.dev0;在空闲 3A6000 上 back-to-back 运行,--iters 30,共 135 个 op × dtype pair:geomean 3.31×(f16 10.7×、bool 11.4×、f32 4.5×、 f64 3.6×、int 1.8×)。

Bench 方法说明

只比较在空闲机器上 back-to-back 获取的 run。曾经一次用旧 stock run 对比刚完成的 dragon run,产生了“修复反而变慢”的假象;max/multiply control op(本应稳定) 是识别 contaminated run 的信号。