运行 当 运行 从 OpenMP 并行块中的 Python 接收到函数时,时间与线程数成比例

Running time scales with the number of threads when running a function received from Python inside OpenMP parallel block

这是测试文件。

# CMakeLists.txt

cmake_minimum_required(VERSION 3.16)
project(CALLBACK_TEST)

set(CMAKE_CXX_STANDARD 17)
add_compile_options(-O3 -fopenmp -fPIC)
add_link_options(-fopenmp)

add_subdirectory(pybind11)
pybind11_add_module(callback callback.cpp)

add_custom_command(TARGET callback POST_BUILD
    COMMAND ${CMAKE_COMMAND} -E create_symlink $<TARGET_FILE:callback> ${CMAKE_CURRENT_SOURCE_DIR}/callback.so
)
// callback.cpp

#include <cmath>
#include <functional>
#include <vector>

#include <pybind11/pybind11.h>
#include <pybind11/functional.h>

namespace py = pybind11;

class C
{
public:
  C(std::function<float(float)> f, size_t s) : f_(f), v_(s, 1) {}
  void apply()
  {
#pragma omp parallel for
    for (size_t i = 0; i < v_.size(); i++)
      v_[i] = f_(v_[i]);
  }
  void apply_direct()
  {
#pragma omp parallel for
    for (size_t i = 0; i < v_.size(); i++)
      v_[i] = log(1 + v_[i]);
  }

private:
  std::vector<float> v_;
  std::function<float(float)> f_;
};

PYBIND11_MODULE(callback, m)
{
  py::class_<C>(m, "C")
      .def(py::init<std::function<float(float)>, size_t>())
      .def("apply", &C::apply, py::call_guard<py::gil_scoped_release>())
      .def("apply_direct", &C::apply_direct);
  m.def("log1p", [](float x) -> float
        { return log(1 + x); });
}
# callback.py

import math
import time

from callback import C, log1p


def run(n, func):
    start = time.time()
    if func:
        for _ in range(n):
            c = C(func, 1000)
            c.apply()
    else:
        for _ in range(n):
            c = C(func, 1000)
            c.apply_direct()
    end = time.time()
    print(end - start)


if __name__ == "__main__":
    n = 1000
    one = 1
    print("Python")
    run(n, lambda x: math.log(x + 1))
    print("C++")
    run(n, log1p)
    print("Direct")
    run(n, None)

我 运行 在具有 48 CPU 个核心的服务器上的 Python 脚本。这是运行宁时间。它显示 1. 当 OMP_NUM_THREADS 增加时,运行ning 时间增加,尤其是当接受来自 Python 的 Python/C++ 回调时,以及 2. 将所有内容保留在 C++ 中要快得多,这似乎与 documentation.

中的“无开销”声明相矛盾
$ python callback.py
Python
19.612852573394775
C++
19.268250226974487
Direct
0.04382634162902832
$ OMP_NUM_THREADS=4 python callback.py
Python
6.042902708053589
C++
5.48648738861084
Direct
0.03322458267211914
$ OMP_NUM_THREADS=1 python callback.py
Python
0.5964927673339844
C++
0.38849639892578125
Direct
0.020793914794921875

并且当 OpenMP 关闭时:

$ python callback.py
Python
0.8492450714111328
C++
0.26660943031311035
Direct
0.010872125625610352

那么这里出了什么问题?

您的代码中存在几个问题。

首先,OpenMP 并行区域在这里应该有很大的开销,因为它需要在 48 个线程之间共享工作。 work-sharing 在某些平台上的调度策略可能非常昂贵。您需要使用 schedule(static) 来最小化此开销。在最坏的情况下,一个运行时可以创建 48 个线程并每次都加入它们,这非常昂贵。 Creating/Joining 48*1000 个线程会非常昂贵(至少需要几秒钟)。线程数越高,程序越慢。话虽如此,大多数运行时都试图保持一个活跃的线程池。尽管如此,这并不总是可能的(这是一种优化,规范不需要)。请注意,大多数 OpenMP 运行时会检测 OMP_NUM_THREADS 设置为 1 的情况,因此在这种情况下开销非常低。一般的经验法则是避免对非常短的操作使用多线程,比如少于 1 毫秒的操作。

此外,并行for循环受到错误共享的影响。实际上,1000 个浮点项的向量将占用 4000 字节的内存,并且在主流平台上将分布在 63 个 64 字节的缓存行中。对于 48 个线程,几乎所有缓存行都必须在内核之间移动,这与完成的计算相比成本很高。当工作在相邻缓存行上的两个线程交错执行时,一个缓存行可以在几次迭代中反弹多次。在 NUMA 架构上,这甚至更加昂贵,因为缓存行必须在 NUMA 节点之间移动。这样做 1000 次非常昂贵。

此外,AFAIK 从并行上下文调用 python 函数要么不安全,要么不受 speed-up 约束,因为 全局解释器锁 (吉尔)。不安全,我的意思是 CPython 解释器数据结构可能被破坏导致 non-deterministic 崩溃。这就是 GIL 存在的原因。只要 GIL 未发布,它就会阻止所有代码在多线程上扩展。释放 GIL 的时间太短也会导致缓存行跳动,这对性能有害(比使用顺序代码更重要)。

最后,“C++”和 Python 的开销比“直接”方法大得多,因为它们调用的 dynamically-defined 函数不能被内联或矢量化编译器。 Python 函数特别慢,因为 CPython 解释器 。如果你想做一个公平的基准测试,你需要将 PyBind 解决方案与使用 std::function 的解决方案进行比较(尽管要小心聪明的编译器优化)。