跳转至

Python MPI

全局解释器锁(GIL,Global Interpreter Lock)

Python代码的执行由Python虚拟机(解释器)来控制。

对Python虚拟机的访问由全局解释器锁(GIL)来控制,正是这个锁能保证同时只有一个线程在运行。所以就会出现尽管你设置了多线程的任务,但是只能跑一个的情况。

但是I/O密集的程序(爬虫)相对好一点,因为I/O操作会调用内建的操作系统C代码,所以这时会释放GIL锁,达到部分多线程的效果。

通常我们用的解释器是官方实现的CPython,要真正利用多核,除非重写一个不带GIL的解释器。

多线程(鸡肋)

from threading import Thread

多进程(正常实现)

通过多进程实现多核任务。多个Python进程有各自独立的GIL锁,互不影响。

from multiprocessing import Process

子进程调用实例

def TIMEOUT_COMMAND(command, timeout=10):
    """call shell-command and either return its output or kill it
    if it doesn't normally exit within timeout seconds and return None"""
    import subprocess, datetime, os, time, signal
    cmd = command.split(" ")
    start = datetime.datetime.now()
    process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,encoding="utf-8",preexec_fn=os.setsid) #让 Popen 成立自己的进程组
    # https://www.cnblogs.com/gracefulb/p/6893166.html
    # 因此利用这个特性,就可以通过 preexec_fn 参数让 Popen 成立自己的进程组, 然后再向进程组发送 SIGTERM 或 SIGKILL,中止 subprocess.Popen 所启动进程的子子孙孙。当然,前提是这些子子孙孙中没有进程再调用 setsid 分裂自立门户。
    ic("BHive-noUseOSACA-before",process.pid,process.poll())
    while process.poll() != 208: # poll()(好像BHive208是正常结束)返回0 正常结束, 1 sleep, 2 子进程不存在,-15 kill,None 在运行
        ic("BHive-noUseOSACA-During",process.pid,process.poll())
        time.sleep(0.2)
        now = datetime.datetime.now()
        if (now - start).seconds> timeout:
            # BHive有子进程,需要杀死进程组。但是需要新生成进程组,不然会把自己kill掉
            os.killpg(os.getpgid(process.pid), signal.SIGKILL)
            # os.killpg(process.pid, signal.SIGTERM) SIGTERM不一定会kill,可能会被忽略,要看代码实现
            # https://blog.csdn.net/zhupenghui176/article/details/109097737
            # os.waitpid(-1, os.WNOHANG)
            (killPid,killSig) = os.waitpid(process.pid, 0)
            if killPid != process.pid or killSig!=9:
                errorPrint("TIMEOUT_COMMAND kill failed! killPid %d process.pid %d killSig %d" % (killPid, process.pid, killSig))
            ic("Killed",process.pid,process.poll())
            return None
    ic("BHive-noUseOSACA-Finished",process.pid,process.poll())
    return process.stdout.readlines()

使用Queue或者Pipe通讯

参考

调用C语言的链接库

把一些计算密集型任务用C语言编写,然后把.so链接库内容加载到Python中,因为执行C代码,GIL锁会释放,这样一来,就可以做到每个核都跑一个线程的目的!

C++代码不会受到GIL锁的影响,但是C++里调用python api,也就是执行python字节码需要上GIL锁

类似PTA的代码中

PyObject* THNPModule_attachOutOfMemoryObserver(PyObject* _unused, PyObject* observer)
{
    HANDLE_TH_ERRORS
    Py_XINCREF(observer);
    auto obs = [observer](int64_t device, int64_t alloc, int64_t device_allocated, int64_t device_free) {
        py::gil_scoped_acquire g;
        PyObject* result = PyObject_CallFunction(observer, "LLLL", device, alloc, device_allocated, device_free);
        if (!result) {
            throw py::error_already_set();
        }
        Py_XDECREF(result);
    };
    torch_npu::utils::npu_lazy_init();
    c10_npu::NPUCachingAllocator::attachOutOfMemoryObserver(std::move(obs));
    Py_RETURN_NONE;
    END_HANDLE_TH_ERRORS
}

进程池

from mpi4py import MPI

pybind11 / GIL in C++

Pybind11 中的 GIL 锁

Pybind11 是一个 C++ 库,用于方便地创建 Python 的 C++ 扩展模块。通过 Pybind11,你可以将 C++ 的函数和类暴露给 Python,使得 Python 能直接调用这些 C++ 的功能。它的设计目标是简化 Python 和现代 C++(C++11 及更高版本)之间的绑定工作,同时提供一个高效、直观的接口。


Pybind11 的特点

  1. 轻量级:不需要复杂的配置,头文件即用(header-only)。
  2. 现代化:支持 C++11/14/17 的特性,比如智能指针、模板和 lambda。
  3. 易用性:极简的语法,几乎不需要写样板代码。
  4. 高性能:生成的模块接近直接调用 C++ 的性能。

如何使用 Pybind11
  1. 安装 Pybind11

如果尚未安装,可以通过 pip 直接安装:

pip install pybind11

或者通过包管理器安装(例如 Ubuntu 上):

sudo apt-get install pybind11-dev

  1. 创建一个简单的绑定

假设你有一个 C++ 文件 example.cpp,它提供了一个简单的 C++ 函数:

#include <pybind11/pybind11.h>

// 命名空间简化
namespace py = pybind11;

// 一个简单的 C++ 函数
int add(int a, int b) {
    return a + b;
}

// 定义模块
PYBIND11_MODULE(example, m) {
    m.doc() = "Pybind11 example module";  // 模块文档
    m.def("add", &add, "A function that adds two numbers");
}
  1. 编译绑定代码

使用 Pybind11 提供的 pybind11-config 脚本获取编译选项:

c++ -O3 -Wall -shared -std=c++11 -fPIC `python3 -m pybind11 --includes` example.cpp -o example`python3-config --extension-suffix`

这里生成了 example.cpython-<version>-<platform>.so 文件,这是一个可以在 Python 中直接导入的模块。

  1. 在 Python 中使用

运行 Python,导入刚刚生成的模块并调用函数:

import example

print(example.add(3, 5))  # 输出: 8

自动获取和释放 GIL

默认情况下,Pybind11 在调用 Python API 时会自动管理 GIL。如果你调用 Python 代码或与 Python 对象交互,Pybind11 会自动获取 GIL 并在调用结束后释放它。

#include <pybind11/pybind11.h>

namespace py = pybind11;

void example_function() {
    py::print("Hello from C++!");
    // GIL 被自动获取
}

PYBIND11_MODULE(example, m) {
    m.def("example_function", &example_function);
}

在这个简单的示例中,py::print 调用会自动获取 GIL,并在返回时释放它。这保证了多线程环境下的线程安全。

手动获取和释放 GIL

如果你的 C++ 代码中有与 Python 无关的操作,或者你想在执行耗时的 C++ 计算时释放 GIL,从而让其他 Python 线程可以执行,你可以手动控制 GIL。

Pybind11 提供了 py::gil_scoped_acquirepy::gil_scoped_release 来显式地获取和释放 GIL。

  • 获取 GIL

如果你需要调用 Python 代码(例如操作 Python 对象),你必须先获取 GIL。

void some_function() {
    py::gil_scoped_acquire acquire;  // 获取 GIL
    py::print("This requires GIL");
}
  • 释放 GIL

如果你在 C++ 代码中执行计算密集型操作,可以释放 GIL,从而允许其他 Python 线程执行。在 py::gil_scoped_release 的作用域内,GIL 会被释放,允许其他线程在这个时间段执行 Python 代码。C++ 代码执行完后,GIL 会重新自动获取。

void long_running_task() {
    {
        py::gil_scoped_release release;  // 释放 GIL
        // 执行耗时的计算,避免阻塞其他 Python 线程
        for (int i = 0; i < 100000000; ++i) {
            // 长时间的计算任务
        }
    }
    // 在此作用域内 GIL 被释放, 在作用域结束时自动恢复 GIL。
}

快捷语法:定义函数时释放 GIL

py::call_guard<py::gil_scoped_release>() 确保了在调用 broadcast_coalesced 时,C++ 层的计算不会占用 GIL,从而允许其他 Python 线程继续执行。

m.def(
        "_broadcast_coalesced",
        [](std::vector<at::Tensor>& tensors,
            std::vector<int64_t> devices,
            size_t buffer_size) {
            return torch_npu::data_parallel::broadcast_coalesced(tensors, devices, buffer_size);
        },
        py::arg("tensors"),
        py::arg("devices"),
        py::arg("buffer_size"),
        py::call_guard<py::gil_scoped_release>())

释放 GIL后还是调用python代码

如果你在 C++ 代码中使用了 py::call_guard<py::gil_scoped_release>() 来释放 GIL (Global Interpreter Lock),并且在释放 GIL 后又调用了 Python 代码(比如 py::print("GIL is re-acquired.")),那么 会导致未定义行为,并且在大多数情况下会导致 崩溃(通常是 segmentation fault 或其他类似错误)。原因如下:

  1. GIL 被释放:当你使用 py::call_guard<py::gil_scoped_release>() 时,GIL 会被释放,允许其他 Python 线程执行。这意味着 Python 解释器的锁被释放,其他线程可以访问 Python 对象和执行 Python 代码。

  2. GIL 被释放后调用 Python 代码:如果在释放 GIL 后,你再尝试调用 Python 代码(例如 py::print),就会出现问题,因为没有 GIL 的保护,Python 解释器的线程安全机制会被破坏。Python 的 C API 并不是线程安全的,必须在有 GIL 的情况下才能调用 Python 代码,否则可能会出现以下几种问题:

  3. Segmentation Fault:由于没有 GIL,Python 解释器内部的内存管理可能会出错,导致访问无效的内存位置,进而触发 segmentation fault
  4. 未定义行为:因为没有 GIL 保护,Python 内部的对象和数据结构可能在多线程环境下发生竞争条件,导致不可预期的错误。

  5. 正确的做法:如果你需要在释放 GIL 后执行 Python 代码,你应该重新获取 GIL。你可以通过 py::gil_scoped_acquire 来手动获取 GIL,然后安全地执行 Python 代码。这样做能够确保 Python 代码在执行时能安全地访问 Python 的内部数据结构。

示例

以下是一个错误的示例(会导致崩溃或未定义行为):

void bad_example() {
    {
        py::gil_scoped_release release;  // 释放 GIL
        // 执行一些计算密集型的 C++ 代码

        py::print("This will crash!");  // 错误:在释放 GIL 后调用 Python 代码
    }  // GIL 会被恢复,但这个时候 GIL 已经被释放过了
}

正确的做法: 如果你必须在释放 GIL 后调用 Python 代码,你应该重新获取 GIL。例如:

void correct_example() {
    {
        py::gil_scoped_release release;  // 释放 GIL
        // 执行一些计算密集型的 C++ 代码
    }  // GIL 已经被恢复

    {
        py::gil_scoped_acquire acquire;  // 重新获取 GIL
        py::print("This is safe.");  // 在正确的线程保护下调用 Python 代码
    }
}

常见报错信息

pybind11::handle::inc_ref() is being called while the GIL is either not held or invalid.

这时gdb的信息会显示python的cpython栈。

PTA在DEBUG模式下,GIL锁已释放,但是对象的引用计数还在增加,说明在没有正确持有 GIL 的情况下,尝试操作 Python 对象,导致引用计数操作失败。可能会触发segfault。

但是通过编译选项-DNDEBUG掩盖报错信息, 但是问题还是实际存在的,导致难以定位。

实例

python的子程序实现有问题,运行中,会有bhive-reg遗留下来(多达20个,需要按照下面手动kill,这也是核数建议为总核数的1/3的原因
  1. check process create time
ps -eo pid,lstart,cmd |grep bhive
date
  1. kill all process by name
 sudo ps -ef | grep 'bhive-re' | grep -v grep | awk '{print $2}' | sudo xargs -r kill -9
  1. 以为的原因

subProcess.pool 返回程序状态的时候,除了运行和结束状态,还有休眠等其他状态。也就是程序在发射之后并不是直接进入运行状态的。判断程序是否超时不能通过判断是否运行,因为一开始while循环进不去

while process.poll() is None:

而应该是判断是否正常结束(208是BHive结束返回值,不同程序不同)

while process.poll() != 208:
  1. 继续分析

实际debug还是有

在debug输出里没有这些pid

check了,输出的个数是符合的。

不懂了,我都没调用,这僵尸进程哪里来的?除非是BHive产生的。

  1. 实际原因

调用的Bhive会产生子进程,原本的python实现不能杀死子进程的子进程。需要改用杀死进程组的实现

杀死进程组

可能设定是timeout是20秒,但是htop程序运行了2分钟也没有kill。这是正常的,因为主程序挤占资源导致挂起了,导致无法及时判断和kill

参考文献