使用clang++/ctypes/rbc从numba调用C++函数的有效方法

543 阅读4分钟

本文的目的是探索一种从Numba 编译的函数中调用C++库函数的方法------用numba.jit(nopython=True)

虽然存在将C++代码包裹到Python中的方法,但从Numba编译的函数中调用这些包裹器往往不像人们希望的那样直接和高效。这种低效率的根本问题在于,Python/C++包装器被设计为在Python对象上调用。例如,考虑到这篇文章的目的,对 Numba 编译函数的调用将涉及到将 Python 对象转换为低级别的对象,例如,转换为一些 C/C++ 相当的内在类型或结构,然后再将其转换为 Python 对象并传递给 Python/C++ 封装函数。然后这个包装函数将Python对象(再次)转换为一个等效的C/C++类型的对象,最后可以传递给底层的C++库函数。C++函数的返回值也将被多次转换,只是与函数的调用序列方向相反。总的来说,所有这些对象的转换可能会在从 Python 调用原本高效的 C++ 库函数时产生相当大的开销。

从 Numba 调用 C++ 库函数的另一个困难来自于 C++ 编译器对函数名的处理,以支持函数/方法重载以及其它相关的 C++ 语言特性。原则上,如果知道C++编译器如何在内部转换函数名,就可以从任何Numba编译的函数中直接调用这样的C++库函数(使用numba.cfunc功能)。然而,名字的变换算法是依赖于C++编译器厂商的,在实践中,很难以可移植的方式预测变换后的名字。

cxx2py.py工具

在这篇文章中,我们将介绍一种直接有效的方法,从Numba编译的函数中调用C++库函数,以规避名称杂乱的问题。这个方法的基础是在运行时确定C++库函数的地址,然后与函数签名一起用来建立一个高效的调用序列。这种方法需要创建一个小型的C/C++包装库,其中包含export "C"-归属的函数,这些函数返回C++库函数的地址,并且可以使用各种技术从Python中轻松调用,这里我们使用ctypes

我们提供了一个Python脚本cxx2py.py,它可以从用户提供的C++头文件和源文件中自动生成C/C++包装库以及Pythonctypes包装模块。Python模块包含ctypes C++库函数的定义,Numba编译的函数能够直接调用,而不需要上面提到的昂贵和多余的对象转换。在Python包装模块中使用ctypes 的一个替代方法是使用Numba包装地址协议--WAP。如果ctypes的 "C++表现力 "被证明是不够的,应该考虑使用它。

目前,cxx2py.py 工具中支持的功能包括:

  • 包装具有标量输入和返回值的C++库函数。
  • 支持可以在C++命名空间内定义的C++函数。
  • 支持可能是静态类成员函数的C++函数。

cxx2py.py 工具可以被扩展以支持其他的C++特性,例如:

  • 从 Python 创建 C++ 类/结构实例。
  • 在不同的语言之间来回传递C++类/结构实例。
  • 调用C++类/结构实例的方法。
  • 支持指针类型作为函数输入和返回值。
  • 等等。

cxx2py.py 工具使用clang++ 来解析C++头文件并建立C/C++包装共享库。它还使用RBC--远程后端编译器来解析C++函数的签名,以方便使用。

例子

作为先决条件,让我们创建一个Conda环境,如下所示

$ conda create -n cxx2py-demo -c conda-forge numba rbc cxx-compiler clangdev
$ conda activate cxx2py-demo

我们假设cxx2py.py脚本被复制到了当前工作目录中,并且是有效的。

$ python cxx2py.py --help
usage: cxx2py.py [-h] [-m MODULENAME] [--clang-exe CLANG_EXE]
                 [--clang-ast-dump-flags CLANG_AST_DUMP_FLAGS]
                 [--clang-build-flags CLANG_BUILD_FLAGS]
                 [--clang-extra-flags CLANG_EXTRA_FLAGS]
                 [--build] [--verbose]
                 file [file ...]

Generate ctypes wrappers to C++ library functions

positional arguments:
  file                  C++ header/source file

optional arguments:
  -h, --help            show this help message and exit
  -m MODULENAME, --modulename MODULENAME
                        Python module name of ctypes wrappers (default: untitled)
  --clang-exe CLANG_EXE
                        Path to clang compiler (default: clang++)
  --clang-ast-dump-flags CLANG_AST_DUMP_FLAGS
                        Override flags to clang ast dump command (default:
                        '-Xclang -ast-dump -fsyntax-only -fno-diagnostics-color')
  --clang-build-flags CLANG_BUILD_FLAGS
                        Override flags to clang build shared library command
                        (default: '-shared -fPIC')
  --clang-extra-flags CLANG_EXTRA_FLAGS
                        Extra flags to clang command (default: '')
  --build               Build shared library (default: False)
  --verbose             Be verbose (default: False)

接下来,让我们考虑下面这个C++头文件和源文件,我们将把它作为一个C++库的模型。

/* File: foo.hpp */
#include <iostream>
int foo(int a);

namespace ns {
  namespace ns2 {
    double bar(double x);
    class BarCls {
    public:
      BarCls(double a): a_(a) {}
      double get_a() { return a_; }
      static int fun() { return 54321; }
    private:
      double a_;
    };
  }
}

/* File: foo.cpp */
int foo(int a) {
  std::cout << "in foo(" << std::to_string(a) << ")" << std::endl;
  return a + 123;
}

namespace ns { namespace ns2 {
    double bar(double a) {
      std::cout << "in ns::ns2::bar(" << std::to_string(a) << ")" << std::endl;
      return a + 12.3;
    }
}}

我们建立一个 Python ctypes 封装库,使用

$ python cxx2py.py -m libfoo foo.hpp foo.cpp --build
DONE

As a quick test, try running:

  LD_LIBRARY_PATH=. python -c "import untitled as m; print(m.__all__)"

将在当前目录下创建三个文件。

$ ls *libfoo*
cxx2py_libfoo.cpp  libcxx2py_libfoo.so  libfoo.py

注意,生成的cxx2py_libfoo.cpp文件包含了用于返回 C++ 函数地址的轻量级 C 函数。

#include <memory>
#include <cstdint>
#include "foo.hpp"

extern "C" intptr_t get_foo_address() {
  /* int (int) */
  return reinterpret_cast<intptr_t>(std::addressof(foo));
}


extern "C" intptr_t get_ns__ns2__bar_address() {
  /* double (double) */
  return reinterpret_cast<intptr_t>(std::addressof(ns::ns2::bar));
}


extern "C" intptr_t get_ns__BarCls__fun_address() {
  /* int () */
  return reinterpret_cast<intptr_t>(std::addressof(ns::BarCls::fun));
}

当使用--build 标志时,cxx2py_libfoo.cpp 文件被内置到共享库libcxx2py_libfoo.so

让我们在 Python 中测试包装模块libfoo

$ export LD_LIBRARY_PATH=.  # this makes sure that ctypes is able to find the shared library
$ python
>>> import libfoo
>>> libfoo.__all__
['foo', 'ns__ns2__bar', 'ns__BarCls__fun']
>>> libfoo.foo(5)
in foo(5)
128
>>> libfoo.ns__ns2__bar(1.2)
in ns::ns2::bar(1.200000)
13.5
>>> libfoo.ns__BarCls__fun()
54321

也就是说,由于使用了ctypes!,C++ 库函数可以直接从 Python 中调用。

此外,C++库的函数可以从Numba编译的函数中调用。比如说。

>>> import numba
>>> @numba.njit
... def fun(x):
...     return libfoo.foo(x + 2)
... 
>>> fun(5)
in foo(7)
130

摘要

在这篇文章中,我们概述了一种从Python中调用C++库函数的方法,重点是以最小的开销从Numba编译的函数中使用它们。虽然所提供的工具cxx2py.py目前只支持包装带有标量输入和返回值的 C++ 函数,但它也可以很容易地扩展到支持其他 C++ 特性。