追踪 np.add 的底层实现
Tracing np.add, all the way down

原始链接: https://blog.veitheller.de/numpy.html

这篇文章探讨了 `np.add(a, b)` 背后的“运作机制”,追溯了从高级 Python 调用到 NumPy 2.5.2 中硬件级 SIMD 内核的执行过程。 尽管 `np.add` 无处不在,但它并非标准函数,而是一个复杂的对象。其执行遵循以下结构化路径: 1. **入口:** 调用进入 `ufunc_generic_fastcall`,该函数负责解析参数并检查 `__array_ufunc__` 重载(允许 Dask 或 CuPy 等库进行干预)。 2. **分发:** NumPy 为给定数据类型确定正确的 C 实现(即“内部循环”),并利用缓存避免重复解析。 3. **迭代:** 代码会根据情况选择路径:对于连续数组,采用“简单”快速路径;对于处理广播和复杂内存布局的情况,则使用 `NpyIter` 系统。 4. **执行:** 实际计算在生成的 C 循环中进行。NumPy 对连续数据使用 CPU 特定的优化(SIMD),而对于跨步、非连续的数组,则回退到标量 `for` 循环。 最终,作者强调了 NumPy 的架构是如何像“地质层”一样,由数十年前的遗留 C 代码与现代抽象层叠加而成的——这是一个为了支持自由线程 Python 等创新而不断完善的系统。

Hacker News 最新 | 过往 | 评论 | 提问 | 展示 | 招聘 | 提交 登录 追踪 np.add,探寻到底 (veitheller.de) 11 分,luu 发布于 1 小时前 | 隐藏 | 过往 | 收藏 | 1 条评论 tonnydourado 13 分钟前 [–] 这是一次很棒的探险 XD 回复 准则 | 常见问题 | 列表 | API | 安全 | 法律 | 申请 YC | 联系 搜索:
相关文章

原文

The notes for this blog post have been sitting in my drafts folder for half a year now. I’ve done a little work on NumPy itself in the past year. Nothing notable, but enough to have to find my way around the source. That gave me the idea to write this, but then, as other obligations overshadowed my NumPy contributions, it just started rotting quietly. One or two NumPy releases later I finally picked it up again, retraced my steps, and here we are.

Here’s the premise: np.add(a, b) might well be among the most executed lines of numerical Python in the world, and most of us have a working mental model that’s equivalent to “it adds the arrays, in C, quickly”. That model is correct, but there’s a lot of machinery between the Python call and the loop that does the adding, and I think it’s a fun machine to take apart. So today we’ll trace a single call, np.add(a, b) with two float64 arrays, from the Python entry point all the way down to the SIMD kernel, reading the actual NumPy source as we go. We’ll learn a lot, I hope!

Everything below is pinned to NumPy 2.5.2, the current release as I write this, and all links point into that tag. The internals move around between versions1, so if you’re spelunking along at home, check out the matching tag. I’ll assume you’re at least somewhat comfortable reading C, but no NumPy internals knowledge is required, that’s what we’re here for.

The map

Before we dive in, here’s the treasure map, so you always know where we are:

np.add(a, b)                      (Python)
   │
   ▼
ufunc_generic_fastcall            (C: parse arguments)
   │
   ▼
__array_ufunc__ override check    (may divert to other libraries)
   │
   ▼
promotion & dispatch              (find the float64 loop, cache it)
   │
   ▼
trivial loop  or  NpyIter         (iteration strategy)
   │
   ▼
DOUBLE_add                        (the actual inner loop, SIMD)

Each of these is a section below. Let’s start at the top.

np.add is an object

The first thing to know is that np.add is not a normal Python function. It’s an instance of numpy.ufunc, a C-defined type2:

ufunc_generic_vectorcall, which immediately forwards to the real workhorse, ufunc_generic_fastcall. That function is long, but it reads like a checklist, and it is the skeleton of the whole operation. Heavily abbreviated:

PyUFunc_CheckOverride walks all inputs and outputs and looks for a non-default __array_ufunc__ method, the protocol defined in NEP 133. If any argument has one, NumPy calls it and returns whatever it produces, and none of the machinery we’re going to talk about below ever runs.

This is the hook that makes np.add(dask_array, cupy_array) behave with third parties! Libraries like Dask and CuPy implement __array_ufunc__ and take over. We can play that tune ourselves in four lines:

dispatching.cpp, whose header comment is the best documentation of the process I’ve found anywhere, so let me just quote it, typos and all:

The process of dispatching and promotion can be summarized in the following
steps:

1. Override any `operand_DTypes` from `signature`.
2. Check if the new `operand_Dtypes` is cached (if it is, got to 4.)
3. Find the best matching "loop".  This is done using multiple dispatching
   on all `operand_DTypes` and loop `dtypes`.  A matching loop must be
   one whose DTypes are superclasses of the `operand_DTypes` (that are
   defined).  The best matching loop must be better than any other matching
   loop.  This result is cached.
4. If the found loop is a promoter: We call the promoter. It can modify
   the `operand_DTypes` currently.  Then go back to step 2.
5. The final `ArrayMethod` is found, its registered `dtypes` is copied
   into the `signature` so that it is available to the ufunc loop.

A few translations are in order. The signature is what you fix explicitly when you call np.add(a, b, dtype=...); in our call it’s empty. A “promoter” is a registered helper that handles cases where no loop matches directly by rewriting the requested types and letting dispatch run again. Confusingly, the everyday mixed case, np.add(int32_array, float64_array), doesn’t even use one: when dispatch comes up empty there, it falls back to the ufunc’s old type resolution machinery (PyUFunc_AdditionTypeResolver, in our case) to pick the common types, and then re-enters dispatch with those, landing on dd->d.

And the cache in step 2 matters a lot! The full resolution only happens the first time you call a ufunc with a given combination of types. For an ordinary cacheable case like ours, every later call with the same types is a single hash lookup on the DType classes. It’s pure machinery, but it’s useful.

To understand what we now have, we have to engage in some archaeology and word-slinging.

What promote_and_get_ufuncimpl returns is a PyArrayMethodObject, the modern (post-NEP 43) representation of “one concrete implementation of an operation for concrete DTypes”. For float64 addition, though, the ArrayMethod is a thin wrapper around something much older. When the actual loop is needed, get_wrapped_legacy_ufunc_loop calls PyUFunc_DefaultLegacyInnerLoopSelector, which does exactly what one would write the first time around! It walks the ufunc’s types table, entry by entry, until it finds dd->d, and returns ufunc->functions[i], a plain C function pointer. The wrapper that adapts it to the modern interface is adorable:

4.

We are deep in the cave now. It’s almost entirely dark.

To iterate or not to iterate

We have a loop, now we need to feed it. That’s PyUFunc_GenericFunctionInternal, and it makes one interesting decision:

try_trivial_single_output_loop calls the inner loop once over the entire data. No iterator is constructed at all. For the everyday np.add(a, b) of two well-behaved same-shape arrays, this is the path you’re on.

Everything else goes through execute_ufunc_loop and NpyIter, NumPy’s general array iterator. The construction flags are a compact summary of everything it takes care of for the inner loop:

loops_arithm_fp.dispatch.c.src, which use NumPy’s in-house templating language, because every big project grows a config format and a templating language:

conv_template.py) expands each repeat block for every value combination, so this one function body becomes FLOAT_add, DOUBLE_add, FLOAT_subtract, and so on, eight functions from one template. Note the signature: it’s the PyUFuncGenericFunction we met two sections ago. We’ve arrived. Don’t touch the cave walls, please.

Inside, the function is a cascade of specializations. There’s a branch for the reduction case, then SIMD-accelerated versions for the common stride patterns (both inputs contiguous, one input a scalar), written against NumPy’s “universal intrinsics”, a portable SIMD abstraction where npyv_add_f64 maps to whatever the target CPU calls adding a vector of doubles. And when no specialization fits, it falls through to the loop you would have written (though maybe you’re a bit less terse):

generate_umath.py. Here’s the extremely readable entry for add:

documentation in the build config shows the shape of the expansion with a made-up example (illustrative only, the features are per-loop and there is no AVX-512 variant of our add):

5. Somebody looked at one of the most-executed codepaths in numerical Python, decades in, battle-proven if anything ever was, and reworked it, because the world had changed. The oldest layer is still being tended to, even with all the deposits on top. It’s beautiful.

I hope you enjoyed the descent! If this post made you want to go further, building NumPy from source and putting a breakpoint in ufunc_generic_fastcall is a surprisingly pleasant afternoon. Just keep a beverage at hand. See you around!

Thanks to Nathan Goldbaum for reviewing this blog post!

1. I mean it! Part of the machinery described in this post has already been reworked on NumPy’s main branch, as we’ll see in the Fin. People still hack on np.add between releases.

2. Specifically, it lives in the C extension module numpy._core._multiarray_umath. The unwieldy name is, apparently, a historical artifact: the array machinery (multiarray) and the ufunc machinery (umath) were separate C modules for most of NumPy’s life, and were merged by NEP 15. If you’ve ever wondered why older NumPy guides reference modules that no longer exist, that’s why. The joys of software refactoring.

3. Not to be confused with __array_function__ from NEP 18, which is the analogous protocol for non-ufunc functions like np.concatenate. My old notes for this post confidently claimed both are checked on the ufunc path; they are not, ufuncs only consult __array_ufunc__. The notes came from an LLM conversation, which is a lesson in itself.

4. Nothing about the ArrayMethod layer requires wrapping old-style loops, it exists just so that new dtypes can register loops natively. The string dtype work in NumPy 2.0 is an example of modern loops: here is where StringDType registers its add loop, and it never appears in np.add.types, which only reflects the classic table. That mapping I mentioned at the top, the one Python never sees, is where such loops live. For the builtin numeric types, though, the “legacy” interface remains the real thing.

5. This came to me by way of NumPy core developer Nathan Goldbaum reviewing a draft of this post (thank you!). One example from the same family: a fix moving reduction-initial-value setup from call time to ufunc initialization, motivated by multithreaded reductions. The dispatch cache itself, a PyArrayIdentityHash, also recently got a concurrency-minded overhaul, described in Quansight’s “Scaling NumPy on free-threaded Python”.

联系我们 contact @ memedata.com