在 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()

增加 SKBUILD 变量的判断，只有在使用 scikit-build 构建的时候才会去构建 Python 扩展，这样如果你的项目中的 C/C++ 部分不依赖 Python 也可以运行、测试或者 benchmark 的话，会比较方便

增加了 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}

从 Cython 自带的 libcpp 标准库导入了 std::string

从我们定义的 absl.pxd 导入了 flat_hash_map，absl.pxd 详情下面再解释

使用 cdef extern 定义了上述 src 目录中 C++ 模块定义的 fn_return_flat_hash_map 函数原型

在 cpdef dict hello_absl 函数中调用 fn_return_flat_hash_map C++ 函数并转换成 Python dict 返回

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

from absl cimport flat_hash_map 如何工作的?

flat_hash_map 转换成 Python dict 为什么不能像 libcpp 的 unordered_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 会返回一个新的 iterator 而 flat_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

爱发电上赞助