Numba 函数的编组对象代码

Marshaling object code for a Numba function

我有一个可以通过 Numba 解决的问题:为查询服务器创建 Numpy ufuncs 以 (a) 将简单操作合并为单次数据传递,减少我的 #1 热点(内存带宽),以及 (b ) 将第三方 C 函数封装为动态的 ufunc,为查询系统的用户提供更多功能。

我有一个累加器节点,它拆分查询并收集结果和计算节点,实际上 运行 Numpy(网络中的不同计算机)。如果 Numba 编译发生在计算节点上,这将是重复的工作,因为它们针对相同的查询在不同的数据分区上工作——相同的查询意味着相同的 Numba 编译。此外,即使是最简单的 Numba 编译也需要 96 毫秒——只要 运行 在数百万个点上进行查询计算,这在计算节点上提供的时间更好。

所以我想在累积节点上一次进行Numba编译,然后将其发送到计算节点,这样它们就可以运行它。我可以保证两者具有相同的硬件,因此目标代码是兼容的。

我一直在 Numba API 中搜索此功能,但没有找到它(除了没有文档的 numba.serialize 模块;我不确定它的用途是什么) .解决方案可能不是 Numba 包的 "feature",而是一种利用某人对 Numba and/or LLVM 的内部知识的技术。有谁知道如何获取目标代码、编组并重构它?如果有帮助,我可以在两台机器上都安装 Numba,我只是不能在目标机器上做任何太昂贵的事情。

好的,这是可能的,解决方案大量使用了 Numba 下的 llvmlite 库。

获取序列化函数

首先我们用 Numba 定义一些函数。

import numba

@numba.jit("f8(f8)", nopython=True)
def example(x):
  return x + 1.1

我们可以通过

访问目标代码
cres = example.overloads.values()[0]  # 0: first and only type signature
elfbytes = cres.library._compiled_object

如果你打印出 elfbytes,你会看到它是一个 ELF-encoded 字节数组(bytes 对象,而不是 str 如果你在 Python 3).如果您要编译共享库或可执行文件,这就是文件中的内容,因此它可以移植到具有相同体系结构、相同库等的任何机器上。

这个bundle里面有几个函数,你可以通过转储LLVM IR看到:

print(cres.library.get_llvm_str())

我们想要的名字是__main__.example.float64,我们可以在LLVM IR中看到它的类型签名:

define i32 @"__main__.example.float64"(double* noalias nocapture %retptr, { i8*, i32 }** noalias nocapture readnone %excinfo, i8* noalias nocapture readnone %env, double %arg.x) #0 {
entry:
  %.14 = fadd double %arg.x, 1.100000e+00
  store double %.14, double* %retptr, align 8
  ret i32 0
}

记下以备将来参考:第一个参数是指向 double 的指针,它会被结果覆盖,第二个和第三个参数是永远不会被使用的指针,最后一个参数是输入double.

(另请注意,我们可以使用 [x.name for x in cres.library._final_module.functions] 以编程方式获取函数名称。Numba 实际使用的入口点是 cres.fndesc.mangled_name。)

我们将这个 ELF 和函数签名从进行所有编译的机器传输到进行所有计算的机器。

读回

现在在计算机上,我们将使用完全没有 Numba 的 llvmlite(在 this page 之后)。初始化它:

import llvmlite.binding as llvm

llvm.initialize()
llvm.initialize_native_target()
llvm.initialize_native_asmprinter()  # yes, even this one

创建 LLVM 执行引擎:

target = llvm.Target.from_default_triple()
target_machine = target.create_target_machine()
backing_mod = llvm.parse_assembly("")
engine = llvm.create_mcjit_compiler(backing_mod, target_machine)

现在劫持它的缓存机制让它加载我们的 ELF,命名为 elfbytes:

def object_compiled_hook(ll_module, buf):
    pass

def object_getbuffer_hook(ll_module):
    return elfbytes

engine.set_object_cache(object_compiled_hook, object_getbuffer_hook)

像我们刚刚编译 IR 一样完成引擎,但实际上我们跳过了这一步。引擎将加载我们的 ELF,认为它来自其 disk-based 缓存。

engine.finalize_object()

我们现在应该在这个引擎的 space 中找到我们的功能。如果下面的returns 0L,有问题。它应该是一个函数指针。

func_ptr = engine.get_function_address("__main__.example.float64")

现在我们需要将 func_ptr 解释为我们可以调用的 ctypes 函数。我们必须手动设置签名。

import ctypes
pdouble = ctypes.c_double * 1
out = pdouble()

pointerType = ctypes.POINTER(None)
dummy1 = pointerType()
dummy2 = pointerType()

#                        restype first   then argtypes...
cfunc = ctypes.CFUNCTYPE(ctypes.c_int32, pdouble, pointerType, pointerType, ctypes.c_double)(func_ptr)

现在我们可以调用它了:

cfunc(out, dummy1, dummy2, ctypes.c_double(3.14))
print(out[0])
# 4.24, which is 3.14 + 1.1. Yay!

更多并发症

如果 JITed 函数有数组输入(毕竟,您想对编译代码中的许多值进行紧密循环,而不是 Python),Numba 会生成识别 Numpy 数组的代码。这个调用约定非常复杂,包括异常对象的 pointers-to-pointers 和 Numpy 数组附带的所有元数据作为单独的参数。它不会生成一个入口点,您可以使用 Numpy 的 ctypes 接口。

但是,它确实提供了一个非常 high-level 的入口点,它以 Python *args, **kwds 作为参数并在内部解析它们。下面是你如何使用它。

首先找到名字以"cpython."开头的函数:

name = [x.name for x in cres.library._final_module.functions if x.name.startswith("cpython.")][0]

其中应该只有一个。然后,经过序列化和反序列化后,使用上面描述的方法得到它的函数指针:

func_ptr = engine.get_function_address(name)

并使用三个 PyObject* 参数和一个 PyObject* return 值进行转换。 (LLVM 认为这些是 i8*。)

class PyTypeObject(ctypes.Structure):
    _fields_ = ("ob_refcnt", ctypes.c_int), ("ob_type", ctypes.c_void_p), ("ob_size", ctypes.c_int), ("tp_name", ctypes.c_char_p)

class PyObject(ctypes.Structure):
    _fields_ = ("ob_refcnt", ctypes.c_int), ("ob_type", ctypes.POINTER(PyTypeObject))

PyObjectPtr = ctypes.POINTER(PyObject)

cpythonfcn = ctypes.CFUNCTYPE(PyObjectPtr, PyObjectPtr, PyObjectPtr, PyObjectPtr)(fcnptr)

这三个参数中的第一个是闭包(函数访问的全局变量),我假设我们不需要它。使用显式参数而不是闭包。我们可以利用 CPython 的 id() 实现 return 指针值这一事实来制作 PyObject 指针。

    def wrapped(*args, **kwds):
        closure = ()
        return cpythonfcn(ctypes.cast(id(closure), PyObjectPtr), ctypes.cast(id(args), PyObjectPtr), ctypes.cast(id(kwds), PyObjectPtr))

现在函数可以调用为

wrapped(whatever_numpy_arguments, ...)

就像原来的 Numba 调度程序功能一样。

底线

毕竟,这值得吗?使用 Numba 进行 end-to-end 编译——最简单的方法——这个简单的函数需要 50 毫秒。要求 -O3 而不是默认的 -O2,我可以使它慢 40%。

然而,拼接 pre-compiled ELF 文件需要 0.5 毫秒:快了 100 倍。此外,编译时间会随着更复杂的函数而增加,但 splicing-in 过程对于任何函数都应始终花费 0.5 毫秒。

对于我的应用来说,这绝对是值得的。这意味着我一次可以在 10 MB 上执行计算,并且大部分时间都花在计算上(做实际工作),而不是编译(准备工作)。将其放大 100 倍,我将不得不一次对 1 GB 执行计算。由于一台机器被限制为 order-of 100 GB,并且必须在 order-of 100 个进程之间共享,我将面临更大的资源限制、负载平衡问题等危险,因为问题会太细化。

但对于其他应用来说,50 ms 不算什么。这完全取决于您的应用程序。