在 Cython 项目中使用 abseil-cpp

上一篇文章介绍了使用 CMake 构建 Python C/C++ 扩展的两个方案,也提到了之前在工作中做这个的主要目的是为了能够使用 abseil-cpp 的 Swiss Tables 优化性能,那么这篇文章就来简单介绍一下如何在 Cython 项目中使用 abseil-cpp.

项目目录结构

$ tree -L 2 .
.
├── CMakeLists.txt        # 项目主 CMake 配置
├── absl.pxd              # abseil-cpp 接口的部分 Cython 定义文件
├── hello                 # hello Python 库
│   ├── CMakeLists.txt    # Python C++ 扩展 CMake 配置
│   ├── __init__.py
│   └── _hello.pyx        # Cython 文件
├── pyproject.toml
├── setup.py
├── src
│   ├── CMakeLists.txt    # 项目 C/C++ 代码 CMake 配置
│   ├── absl.cc
│   └── absl.h
├── test_hello_cython.py  # 测试代码
├── vendor                # C/C++ 第三方库放置路径
│   └── abseil-cpp        # abseil-cpp Git submodule
└── venv                  # Python virtualenv

该示例项目的代码托管在 GitHub: https://github.com/messense/cython-abseil-sample

接入 abseil-cpp

可以直接把 abseil-cpp 作为一个 Git submodule 导入到自己的项目中

git submodule add --depth 50 https://github.com/abseil/abseil-cpp.git vendor/abseil-cpp

这里我把它放到了 vendor/abseil-cpp 目录中,然后可以在项目主 CMakeLists.txt 文件中配置依赖,官方文档 上有更详细的说明。

cmake_minimum_required(VERSION 3.5.0)
project(hello)

set (CMAKE_POSITION_INDEPENDENT_CODE ON)

# Abseil requires C++11
set(CMAKE_CXX_STANDARD 11)

# Process Abseil's CMake build system
add_subdirectory(vendor/abseil-cpp)
include_directories(
  /usr/local/include
  ${CMAKE_CURRENT_SOURCE_DIR}/vendor/abseil-cpp
  ${CMAKE_CURRENT_SOURCE_DIR}/src)

add_subdirectory(src)
add_subdirectory(hello)

上面的配置把 src/hello/ 目录也作为 CMake 子目录加入了配置,下面分别介绍一下。

src 目录 - C/C++ 模块

src 目录主要存放了该项目点 C/C++ 代码,这部分代码和是 Python 无关的。

CMakeLists.txt

set(SOURCES absl.cc)
add_library(hello_absl STATIC ${SOURCES})
target_link_libraries(hello_absl absl::flat_hash_map)

这里的构建配置非常简单,只是构建了一个静态的 hello_absl target 并链接 absl::flat_hash_map 库,这个 target 暴露的 public API 只有一个返回值类型为 absl::flat_hash_map 的函数 fn_return_flat_hash_map,定义在 src/absl.h 文件中:

#include <string>
#include "absl/container/flat_hash_map.h"

absl::flat_hash_map<int, std::string> fn_return_flat_hash_map(void);

其实现也非常简单 absl.cc:

#include "absl.h"

absl::flat_hash_map<int, std::string> fn_return_flat_hash_map(void) {
    absl::flat_hash_map<int, std::string> numbers =
        {{1, "one"}, {2, "two"}, {3, "three"}};
    return numbers;
}

hello 目录 - Cython 模块

hello 目录存放的是 Python 模块代码,包含一个 _hello.pyx 的 Cython 模块。

CMakeLists.txt

if(SKBUILD)
  message(STATUS "The project is built using scikit-build")
  find_package(PythonExtensions REQUIRED)
  find_package(Cython REQUIRED)

  include_directories(
    /usr/local/include
    ${PYTHON_INCLUDE_DIRS}
    ${CMAKE_CURRENT_SOURCE_DIR}/vendor/abseil-cpp)

  add_cython_target(_hello CXX)
  add_library(_hello MODULE ${_hello})
  target_link_libraries(_hello hello_absl)
  python_extension_module(_hello)

  install(TARGETS _hello LIBRARY DESTINATION hello)
endif()
  1. 增加 SKBUILD 变量的判断,只有在使用 scikit-build 构建的时候才会去构建 Python 扩展,这样如果你的项目中的 C/C++ 部分不依赖 Python 也可以运行、测试或者 benchmark 的话,会比较方便
  2. 增加了 find_package(Cython REQUIRED)add_cython_target(_hello CXX) 配置 Cython 模块支持

_hello.pyx

from libcpp.string cimport string

from absl cimport flat_hash_map

cdef extern from "absl.h":
    cdef flat_hash_map[int, string] fn_return_flat_hash_map() nogil

cpdef dict hello_absl():
    cdef flat_hash_map[int, string] a_map
    with nogil:
      a_map = fn_return_flat_hash_map()
    return {it.first: it.second.decode() for it in a_map}
  1. 从 Cython 自带的 libcpp 标准库导入了 std::string
  2. 从我们定义的 absl.pxd 导入了 flat_hash_mapabsl.pxd 详情下面再解释
  3. 使用 cdef extern 定义了上述 src 目录中 C++ 模块定义的 fn_return_flat_hash_map 函数原型
  4. cpdef dict hello_absl 函数中调用 fn_return_flat_hash_map C++ 函数并转换成 Python dict 返回

这里有两个问题需要解答:

  1. from absl cimport flat_hash_map 如何工作的?
  2. flat_hash_map 转换成 Python dict 为什么不能像 libcppunordered_map 一样直接赋值给 dict 类型的变量自动转换而需要迭代手动生成一个 dict?

问题 1 我们需要了解一下 Cython 是如何调用 C/C++ 函数的,官方文档中关于 pxd 文件的文档有如下解释

Cython uses .pxd files which work like C header files – they contain Cython declarations (and sometimes code sections) which are only meant for inclusion by Cython modules. A pxd file is imported into a pyx module by using the cimport keyword.

为什么 from libcpp.string import string 不需要我们提供 pxd 文件呢?这是因为 Cython 已经默认包含了 libcpp 的接口定义,但是 abseil-cpp 的接口定义 Cython 默认是没有的,所以需要提供我们,因为我们这里只用到了 abseil-cpp 的 flat_hash_map 类,故 absl.pxd 中目前只有它的定义,如果以后用到其他的类,也可以在里面增加定义。

absl.pxd

absl.pxd 的内容是从 Cython 自带的 libcpp 代码中的 unordered_map.pxd 代码修改的,diff 一下这两个文件

--- unordered_map.pxd2020-05-10 16:29:28.000000000 +0800
+++ absl.pxd2020-05-10 14:54:27.000000000 +0800
@@ -1,7 +1,7 @@
-from .utility cimport pair
+from libcpp.utility cimport pair

-cdef extern from "<unordered_map>" namespace "std" nogil:
-    cdef cppclass unordered_map[T, U]:
+cdef extern from "absl/container/flat_hash_map.h" namespace "absl" nogil:
+    cdef cppclass flat_hash_map[T, U]:
         ctypedef T key_type
         ctypedef U mapped_type
         ctypedef pair[const T, U] value_type
@@ -21,17 +21,17 @@
             pass
         cppclass const_reverse_iterator(reverse_iterator):
             pass
-        unordered_map() except +
-        unordered_map(unordered_map&) except +
-        #unordered_map(key_compare&)
+        flat_hash_map() except +
+        flat_hash_map(flat_hash_map&) except +
+        #flat_hash_map(key_compare&)
         U& operator[](T&)
-        #unordered_map& operator=(unordered_map&)
-        bint operator==(unordered_map&, unordered_map&)
-        bint operator!=(unordered_map&, unordered_map&)
-        bint operator<(unordered_map&, unordered_map&)
-        bint operator>(unordered_map&, unordered_map&)
-        bint operator<=(unordered_map&, unordered_map&)
-        bint operator>=(unordered_map&, unordered_map&)
+        #flat_hash_map& operator=(flat_hash_map&)
+        bint operator==(flat_hash_map&, flat_hash_map&)
+        bint operator!=(flat_hash_map&, flat_hash_map&)
+        bint operator<(flat_hash_map&, flat_hash_map&)
+        bint operator>(flat_hash_map&, flat_hash_map&)
+        bint operator<=(flat_hash_map&, flat_hash_map&)
+        bint operator>=(flat_hash_map&, flat_hash_map&)
         U& at(const T&)
         const U& const_at "at"(const T&)
         iterator begin()
@@ -43,8 +43,8 @@
         const_iterator const_end "end"()
         pair[iterator, iterator] equal_range(T&)
         pair[const_iterator, const_iterator] const_equal_range "equal_range"(const T&)
-        iterator erase(iterator)
-        iterator erase(iterator, iterator)
+        void erase(iterator)
+        void erase(iterator, iterator)
         size_t erase(T&)
         iterator find(T&)
         const_iterator const_find "find"(T&)
@@ -60,7 +60,7 @@
         reverse_iterator rend()
         const_reverse_iterator const_rend "rend"()
         size_t size()
-        void swap(unordered_map&)
+        void swap(flat_hash_map&)
         iterator upper_bound(T&)
         const_iterator const_upper_bound "upper_bound"(T&)
         #value_compare value_comp()

可以发现,除了将 unordered_map 名字替换成 flat_hash_map 和修改 extern from 路径外,只有几个 erase 方法的返回值类型不一样,unordered_map.erase 会返回一个新的 iteratorflat_hash_map.erase 返回值类型为 void,这是因为 abseil-cpp 的 flat_hash_map 为了优化 erase 方法的性能和 C++ STL 的 unordered_map erase 方法行为有所不同导致的:

Guarantees an `O(1)`` erase method by returning void instead of an iterator.

问题 2 则是因为 Cython 编译器对 libcpp 有特殊支持会生成很多转换函数,而我们自行提供的 .pxd 文件则没有这种特殊待遇导致的。有兴趣的可以看一下 Cython.Compiler.PyrexTypes 模块中的 CTypedefType.create_to_py_utility_code 方法代码。

本文只给出了一个 C++ 代码返回值类型为 abseil-cpp 类型的例子,实际上也可以扩展为函数参数类型为 abseil-cpp 或者其他任何 C/C++ 第三方库的类型,这里不再赘述。

测试

test_hello_cython.py 文件中编写了一个 test_hello_absl 测试用例:

import hello


def test_hello_absl():
    ret = hello.hello_absl()
    assert ret == {1: "one", 2: "two", 3: "three"}

运行 pytest -v 可以确认测试正常:

$ pytest -v
test_hello_cython.py::test_hello_absl PASSED
Some rights reserved
Except where otherwise noted, content on this page is licensed under a Creative Commons Attribution 4.0 International license