高级调试工具#

如果您阅读到此处,说明您想深入了解或使用更高级的工具。对于初次贡献者和大多数日常开发而言,这通常不是必需的。这些工具的使用频率较低,例如在新 NumPy 版本发布临近时,或者当进行了一项大型或特别复杂的更改时。

其中一些工具在 NumPy 的持续集成测试中使用。如果您看到一个仅在调试工具下才会出现的测试失败,希望这些说明能帮助您在本地重现该测试失败。

由于并非所有这些工具都定期使用,并且仅在某些系统上可用,因此请预料可能存在的差异、问题或怪癖;如果您遇到困难,我们将乐于提供帮助,并且非常欢迎您对这些工作流程提出任何改进或建议。

使用附加工具查找 C 语言错误#

大多数开发工作不需要比 调试 中所示的典型调试工具链更多的工具。但例如,内存泄漏可能特别微妙或难以缩小范围。

我们不期望大多数贡献者会运行这些工具中的任何一个。但是,您可以确保我们能够更轻松地追踪这些问题。

这将有助于我们在您的更改发布之前发现任何疏忽,并且意味着您不必担心发生引用计数错误,这可能会令人望而生畏。

Python 调试构建#

Python 的调试构建在 Linux 系统上通常可以通过系统包管理器轻松获得,在其他平台上也可用,但可能格式不太方便。如果您无法从系统包管理器轻松安装 Python 的调试构建,可以使用 pyenv 自己构建一个。例如,要安装并全局激活 Python 3.13.3 的调试构建,可以执行以下操作:

pyenv install -g 3.13.3
pyenv global 3.13.3

请注意,pyenv install 会从源代码构建 Python,因此您必须确保在构建之前已安装 Python 的依赖项,有关特定于平台的安装说明,请参阅 pyenv 文档。您可以使用 pip 安装调试会话可能需要的 Python 依赖项。如果 pypi 上没有可用的调试 wheel,您将需要从源代码构建依赖项,并确保您的依赖项也已作为调试构建进行编译。

通常,Python 的调试构建会将其 Python 可执行文件命名为 pythond 而不是 python。要检查您是否安装了 Python 的调试构建,可以运行例如 pythond -m sysconfig 来获取 Python 可执行文件的构建配置。调试构建将使用 CFLAGS 中的调试编译器选项(例如 -g -Og)进行构建。

运行 NumPy 测试或交互式终端通常很容易,就像这样:

python3.8d runtests.py
# or
python3.8d runtests.py --ipython

并且已在 调试 中提到过。

Python 调试构建将有助于:

  • 查找可能导致随机行为的错误。例如,当对象在被删除后仍在使用时。

  • Python 调试构建允许检查正确的引用计数。这可以使用附加命令来完成:

    sys.gettotalrefcount()
    sys.getallocatedblocks()
    
  • Python 调试构建允许使用 gdb 和其他 C 语言调试器进行更轻松的调试。

pytest 结合使用#

仅使用调试 Python 构建运行测试套件本身并不能发现许多错误。Python 调试构建的另一个优点是它可以检测内存泄漏。

一个使此过程更轻松的工具是 pytest-leaks,可以使用 pip 安装。不幸的是,pytest 本身可能会泄漏内存,但通常(目前)通过删除以下内容可以获得良好的结果:

@pytest.fixture(autouse=True)
def add_np(doctest_namespace):
    doctest_namespace['np'] = numpy

@pytest.fixture(autouse=True)
def env_setup(monkeypatch):
    monkeypatch.setenv('PYTHONHASHSEED', '0')

numpy/conftest.py 中移除(这可能会随着新 pytest-leaks 版本或 pytest 更新而改变)。

这使得可以方便地运行测试套件或其中的一部分:

python3.8d runtests.py -t numpy/_core/tests/test_multiarray.py -- -R2:3 -s

其中 -R2:3pytest-leaks 命令(参见其文档),-s 会打印输出,并且可能是必需的(在某些版本中,捕获的输出被检测为泄漏)。

请注意,有些测试已知(甚至设计为)会泄漏引用,我们会尝试标记它们,但可能会有一些误报。

valgrind#

Valgrind 是一个强大的工具,用于查找某些内存访问问题,应在复杂的 C 语言代码上运行。 valgrind 的基本用法通常只需要:

PYTHONMALLOC=malloc valgrind python runtests.py

其中 PYTHONMALLOC=malloc 是为了避免 Python 本身产生的误报而必需的。根据系统和 valgrind 版本,您可能会看到更多误报。 valgrind 支持“抑制”来忽略其中一些,Python 也有一个抑制文件(甚至一个编译时选项),如果您发现有必要,它可能会有所帮助。

Valgrind 有助于:

  • 查找未初始化变量/内存的使用。

  • 检测内存访问违规(读取或写入已分配内存之外的区域)。

  • 查找大量内存泄漏。请注意,对于大多数泄漏,Python 调试构建方法(和 pytest-leaks)更敏感。原因是 valgrind 只能检测内存是否已明确丢失。如果

    dtype = np.dtype(np.int64)
    arr.astype(dtype=dtype)
    

    对于 dtype 存在错误的引用计数,这是一个 bug,但 valgrind 看不到,因为 np.dtype(np.int64) 总是返回同一个对象。但是,并非所有 dtype 都是单例,因此这可能因输入不同而泄漏内存。在极少数情况下,NumPy 使用 malloc 而不是 Python 内存分配器,后者对 Python 调试构建是不可见的。通常应避免使用 malloc,但有一些例外(例如,PyArray_Dims 结构是公共 API,不能使用 Python 分配器)。

尽管使用 valgrind 进行内存泄漏检测很慢且不那么敏感,但它也很方便:您可以无需修改即可在 valgrind 下运行大多数程序。

需要注意的事项

  • Valgrind 不支持 NumPy 的 longdouble,这意味着测试将失败或被标记为错误,而实际上它们是完全正常的。

  • 在运行 NumPy 代码之前和之后,请预料一些错误。

  • 缓存可能导致错误(特别是内存泄漏)未被检测到,或者仅在稍后不相关的某个时间才被检测到。

valgrind 的一个主要优点是它除了 valgrind 本身之外没有其他要求(尽管您可能想使用调试构建以获得更好的回溯)。

pytest 结合使用#

您可以运行 valgrind 测试套件,当您只对少量测试感兴趣时,这可能就足够了:

PYTHONMALLOC=malloc valgrind python runtests.py \
 -t numpy/_core/tests/test_multiarray.py -- --continue-on-collection-errors

注意 --continue-on-collection-errors,由于缺少 longdouble 支持导致失败,目前这是必需的(如果您不运行完整的测试套件,这通常不是必需的)。

如果您想检测内存泄漏,还需要 --show-leak-kinds=definite 以及可能的其他 valgrind 选项。与 pytest-leaks 一样,某些测试已知会导致 valgrind 中的错误,并且可能或可能未被标记。

我们开发了 pytest-valgrind,它:

  • 单独报告每个测试的错误。

  • 将内存泄漏缩小到单个测试(默认情况下,valgrind 仅在程序停止后检查内存泄漏,这非常麻烦)。

有关更多信息,请参阅其 README(它包含 NumPy 的示例命令)。

C 语言调试器#

每当 NumPy 崩溃或在处理 NumPy 底层的 C 或 C++ 代码的更改时,在 C 语言调试器下运行 Python 以获取更多信息通常很方便。调试器可以帮助理解解释器崩溃(例如,由于段错误)的情况,提供崩溃点的 C 语言调用堆栈。调用堆栈通常提供有价值的上下文来理解崩溃的性质。C 语言调试器在开发过程中也很有用,允许在 NumPy 的 C 语言实现中进行交互式调试。

NumPy 开发者经常同时使用 gdblldb 来调试 NumPy。经验法则通常是,gdb 在 Linux 上通常更容易使用,而 lldb 在 Mac 环境下更容易使用。它们具有不同的用户界面,因此您需要学习如何使用其中一个。 gdblldb命令映射 是一个方便的参考,用于如何在两个调试器中完成常见操作。

构建带调试符号#

spin 开发工作流工具。内置支持通过 spin gdbspin lldb 命令与 gdblldb 进行交互。

注意

使用 -Dbuildtype=debug 构建有几个重要的影响需要注意:

  • 断言已启用:此构建类型未定义 NDEBUG 宏,这意味着 C 语言级别的任何断言都将生效。这对于调试非常有用,因为它可以帮助确定意外情况发生的位置。

  • 编译器标志可能需要覆盖:某些编译器工具链,尤其是来自 conda-forge 的工具链,可能默认设置了 -O2 等优化标志。这些可能会覆盖 debug 构建类型。为了确保在这些环境中真正进行调试构建,您可能需要手动取消设置或覆盖此标志。

有关这两点的更多详细信息,请参阅 meson-python 关于调试构建的指南

对于这两种调试器,建议在 debugdebugoptimized meson 构建配置文件中构建 NumPy。要使用 debug,可以通过 spin build 传递该选项:

spin build -- -Dbuildtype=debug

要使用 debugoptimized,请将 -Dbuildtype=debugoptimized 传递给它。

您可以使用与 spin build 相同的 positional 参数语法,将其他参数传递给 meson setup,除了 buildtype

运行测试脚本#

假设您有一个名为 test.py 的测试脚本,它位于 NumPy 源代码检出目录同级目录的 test 文件夹中。您可以使用 NumPy 的 spin 构建执行该测试脚本,方法如下:

spin gdb ../test/test.py

这将启动 gdb。如果您只关心崩溃的调用堆栈,请键入“r”并按 Enter。您的测试脚本将运行,如果发生崩溃,请键入“bt”以获取回溯。对于 lldb,说明类似,只需将 spin gdb 替换为 spin lldb

您还可以设置断点并使用其他更高级的技术。有关更多详细信息,请参阅您调试器的文档。

NumPy 中断点的一个常见问题是,在导入 numpy 模块期间,某些代码路径会被反复命中。这使得查找 NumPy 导入完成后、numpy 模块完全初始化后的第一个“真实”调用变得困难或乏味。

一个变通方法是使用类似这样的脚本:

import os
import signal

import numpy as np

PID = os.getpid()

def do_nothing(*args):
    pass

signal.signal(signal.SIGUSR1, do_nothing)

os.kill(PID, signal.SIGUSR1)

# the code to run under a debugger follows

此示例为 SIGUSR1 信号安装了一个什么也不做的信号处理程序,然后使用 SIGUSR1 信号在 Python 进程上调用 os.kill。这会触发信号处理程序,并且最重要的是,它还会导致 gdblldbkill 系统调用中暂停执行。

如果您运行 lldb,您应该会看到类似以下的输出:

Process 67365 stopped
 * thread #1, queue = 'com.apple.main-thread', stop reason = signal SIGUSR1
     frame #0: 0x000000019c4b9da4 libsystem_kernel.dylib`__kill + 8
 libsystem_kernel.dylib`__kill:
 ->  0x19c4b9da4 <+8>:  b.lo   0x19c4b9dc4    ; <+40>
     0x19c4b9da8 <+12>: pacibsp
     0x19c4b9dac <+16>: stp    x29, x30, [sp, #-0x10]!
     0x19c4b9db0 <+20>: mov    x29, sp
 Target 0: (python3.13) stopped.
 (lldb) bt
 * thread #1, queue = 'com.apple.main-thread', stop reason = signal SIGUSR1
   * frame #0: 0x000000019c4b9da4 libsystem_kernel.dylib`__kill + 8
     frame #1: 0x000000010087f5c4 libpython3.13.dylib`os_kill + 104
     frame #2: 0x000000010071374c libpython3.13.dylib`cfunction_vectorcall_FASTCALL + 276
     frame #3: 0x00000001006c1e3c libpython3.13.dylib`PyObject_Vectorcall + 88
     frame #4: 0x00000001007edd1c libpython3.13.dylib`_PyEval_EvalFrameDefault + 23608
     frame #5: 0x00000001007e7e6c libpython3.13.dylib`PyEval_EvalCode + 252
     frame #6: 0x0000000100852944 libpython3.13.dylib`run_eval_code_obj + 180
     frame #7: 0x0000000100852610 libpython3.13.dylib`run_mod + 220
     frame #8: 0x000000010084fa4c libpython3.13.dylib`_PyRun_SimpleFileObject + 868
     frame #9: 0x000000010084f400 libpython3.13.dylib`_PyRun_AnyFileObject + 160
     frame #10: 0x0000000100874ab8 libpython3.13.dylib`pymain_run_file + 336
     frame #11: 0x0000000100874324 libpython3.13.dylib`Py_RunMain + 1516
     frame #12: 0x000000010087459c libpython3.13.dylib`pymain_main + 324
     frame #13: 0x000000010087463c libpython3.13.dylib`Py_BytesMain + 40
     frame #14: 0x000000019c152b98 dyld`start + 6076
(lldb)

如您所见,C 语言堆栈跟踪位于 kill 系统调用中,并且 lldb 提示符处于活动状态,允许交互式设置断点。由于 os.kill 调用发生在 numpy 模块已经完全初始化之后,这意味着在 kill 中设置的任何断点都将在 numpy 完成初始化之后发生。

pytest 结合使用#

您也可以在调试器下运行 pytest 测试。这需要以稍微手动的方式使用调试器,因为 spin 尚未自动化此过程。首先,运行 spin build 以确保有一个由 spin 管理的完全构建的 NumPy。然后,要使用 lldb 在调试器下运行测试,可以执行类似以下操作:

spin lldb $(which python) $(which pytest) build-install/usr/lib/python3.13/site-packages/numpy/_core/tests/test_multiarray.py

这将在键入“r”并按 Enter 后,在 lldb 下执行 test_multiarray.py 中的测试。请注意,此命令来自使用 Python 3.13 在 Mac 上进行的会话。如果您使用的是不同的 Python 版本或操作系统,build-install 中的目录布局可能略有不同。

您可以设置如上所述的断点。关于在 NumPy 导入期间经常命中断点的问题同样适用——考虑将您的测试工作流程重构为测试脚本,以便您可以使用上面描述的 os.kill 方法来解决问题。

请注意使用 $(which python) 来确保调试器接收到 Python 可执行文件的路径。如果您使用的是 pyenv,您可能需要将 which python 替换为 pyenv which python,因为 pyenv 依赖于 which 不知道的 shim 脚本。

编译器 Sanitizers#

GCC 和 LLVM 提供的 编译器 Sanitizer 套件提供了一种在运行时检测许多常见编程错误的方法。Sanitizers 通过在构建时对应用程序代码进行插装来实现,从而触发额外的运行时检查。通常,Sanitizers 在常规测试过程中运行,如果 Sanitizer 检查失败,将导致测试失败或崩溃,并附带关于失败性质的报告。

虽然可以使用“普通”CPython 构建来使用 Sanitizers,但最好能够建立一个基于从源代码构建的 Python 环境,该环境经过 Sanitizer 插装,然后使用插装后的 Python 来构建 NumPy 并运行测试。如果整个 Python 堆栈都使用相同的 Sanitizer 运行时进行了插装,就可以识别发生在整个 Python 堆栈中的问题。例如,这使得可以检测 NumPy 中由于误用 CPython 分配的内存而导致的内存泄漏。

使用 Sanitizer 插装构建 Python#

有关从源代码构建 Python 的更多信息,请参阅 Python 开发者指南 中的相关章节。要启用地址 Sanitizer,您需要在构建 Python 时向 configure 脚本调用传递 --with-address-sanitizer

您还可以使用 pyenv 来自动化构建 Python 的过程,并使用类似于虚拟环境的命令行界面快速激活或停用 Python 安装。使用 pyenv,您可以像这样安装 Python 3.13 的 ASAN 插装构建:

CONFIGURE_OPTS="--with-address-sanitizer" pyenv install 3.13

如果您对线程 Sanitizer 感兴趣,cpython_sanity docker 镜像 也可能是一个更快的选择,可以避免从源代码构建 Python,尽管在 docker 镜像中进行调试工作可能会很麻烦。

spin 结合使用#

无论您如何构建 Python,一旦有了插装后的 Python 构建,就可以安装 NumPy 的开发和测试依赖项,并使用地址 Sanitizer 插装来构建 NumPy。例如,要使用 debug 配置文件和地址 Sanitizer 构建 NumPy,您需要向 meson 传递附加的构建选项,如下所示:

spin build -- -Dbuildtype=debug -Db_sanitize=address

构建完成后,您可以像使用任何其他 Python 构建一样,使用 spin testspin gdb 等其他 spin 命令。

特殊注意事项#

某些 NumPy 测试会故意导致 malloc 返回 NULL。在其默认配置中,某些编译器 Sanitizer 会将此标记为错误。您可以通过将 allocator_may_return_null=1 作为选项传递给 Sanitizer 来禁用该检查。例如,使用地址 Sanitizer:

ASAN_OPTIONS=allocator_may_return_null=1 spin test

您可能会看到来自 Python 解释器的内存泄漏,尤其是在 MacOS 上。如果内存泄漏报告无用,您可以将 detect_leaks=0 传递给 ASAN_OPTIONS 来禁用泄漏检测。您可以使用冒号分隔的列表传递多个选项,如下所示:

ASAN_OPTIONS=allocator_may_return_null=1:halt_on_error=1:detect_leaks=1 spin test

halt_on_error 选项可能特别有用——当它检测到错误时,它会硬性崩溃 Python 可执行文件,并附带一个包含堆栈跟踪的错误报告。

您还可以查看 compiler_sanitizers.yml GitHub Actions 工作流配置。它描述了在 NumPy 测试过程中使用的几个不同的 CI 作业,使用了线程、地址和未定义行为 Sanitizer。