引言
在正式开始 chDB 的旅程之前,我认为最好先简要介绍一下 ClickHouse。近年来,“向量化引擎”在 OLAP 数据库社区中尤其流行。主要原因是 CPU 中添加了越来越多的 SIMD 指令,这大大加速了 OLAP 场景中大量数据的聚合、排序和连接操作。ClickHouse 在“向量化”等多个领域进行了非常详细的优化,这可以从其 对 lz4 和 memcpy 的优化 中看出。
如果说 ClickHouse 是否是性能最佳的 OLAP 引擎还存在争议,但至少根据 基准测试,它属于顶尖行列。除了性能之外,ClickHouse 还拥有强大的功能,使其成为数据库领域的瑞士军刀
- 直接查询存储在 S3、GCS 和其他对象存储上的数据。
- 使用 ReplacingMergeTree 简化处理变化的数据。
- 完成跨数据库数据查询,甚至表连接,而无需依赖第三方工具。
- 甚至自动执行谓词下推。
开发和维护一个生产就绪且高效的 SQL 引擎需要人才和时间。作为领先的 OLAP 引擎之一,Alexey Milovidov 及其团队已投入 14 年时间进行 ClickHouse 开发。既然 ClickHouse 已经在 SQL 引擎方面做了这么多工作,为什么不考虑将其引擎提取到 Python 模块中呢?这感觉就像在自行车上安装火箭引擎!
2023 年 2 月,我开始开发 chDB,主要目标是使强大的 ClickHouse 引擎可以作为“开箱即用”的 Python 模块使用。ClickHouse 已经有一个名为 clickhouse-local
的独立版本,可以从命令行独立运行;这使得 chDB 更加可行。
破解 ClickHouse
将 ClickHouse 嵌入 Python 模块的问题有一个非常简单直接的实现方法:直接将 clickhouse-local
二进制文件包含在 Python 包中,然后通过类似 popen
的方法将 SQL 传递给它,并通过管道检索结果。
但是,这种方法带来了一些问题
- 为每个查询启动一个独立的进程会极大地影响性能,特别是当
clickhouse-local
二进制文件大约 500MB 大小时。 - SQL 查询结果的多份副本将不可避免,因为我们需要从管道中读取它,并复制到 Python 进程的缓冲区中。
- 与 Python 的集成受到限制,使得实现 Python UDF 和支持 Pandas DataFrame 上的 SQL 变得困难。
- 最重要的是,它缺乏优雅 ????
感谢 ClickHouse 结构良好的代码库,我能够在农历新年期间,一边吃饭一边破解 ClickHouse 的 90 万行代码,成功创建了一个原型。
ClickHouse 包含一系列名为 BufferBase
的实现,包括 ReadBuffer
和 WriteBuffer
,它们大致对应于 C++ 的 istream
和 ostream
。为了有效地从文件读取和输出结果(例如,读取 CSV 或 JSONEachRow 并输出 SQL 执行结果),ClickHouse 的 Buffer 还支持对底层内存的随机访问。它甚至可以基于向量创建新的 Buffer,而无需复制内存。ClickHouse 内部使用 BufferBase
的派生类来读取/写入压缩文件以及远程文件(S3、HTTP)。
为了在 ClickHouse 级别实现 SQL 执行结果的零拷贝检索,我使用了内置的 WriteBufferFromVector
而不是 stdout 来接收数据。这确保了并行输出管道不会被阻塞,同时方便地获取 SQL 执行输出的原始内存块。
为了避免从 C++ 复制内存到 Python 对象,我利用了 Python 的 memoryview
进行直接内存映射。
由于 Pybind11 的成熟,现在可以轻松地将 C++ 类的构造和析构与 Python 对象的生命周期绑定在一起。所有这些都可以通过一个简单的类模板定义来实现
class __attribute__((visibility("default"))) query_result {
public:
query_result(local_result * result) : result(result);
~query_result();
}
py::class_<query_result>(m, "query_result")
通过这种方式,chDB 快速启动并运行,我非常兴奋地发布了它。chDB 的架构大致如下图所示
团队合作
最初,我开发 chDB 的唯一目的是创建一个可以在 Jupyter Notebook 中独立运行的 ClickHouse 引擎。这将使我能够轻松访问大量注释信息,而无需依赖缓慢的 Hive 集群,从而在使用 Python 训练 CV 模型时提高效率。令人惊讶的是,在大多数情况下,chDB 的独立版本实际上优于由数百台服务器组成的 Hive 集群。
在 chDB 发布后,来自 QXIP 的 Lorenzo 很快联系了我。他提出了一个 问题,建议删除对 AVX2 指令集的依赖,可以使 chDB 更方便地在 Lambda 服务上运行。我迅速实现了这个功能,之后 Lorenzo 为 fly.io 上的 chDB 创建了一个演示。老实说,我以前从未想象过这样的用法。
随后,Lorenzo 和他的团队开发了 Golang、NodeJS 和 Rust 中 chDB 的绑定。为了将所有这些项目整合在一起,我在 GitHub 上创建了 chdb.io 组织。
是的!我们还在 Linux 上为 Bun 提供了实验性的 chDB FFI 绑定。
后来,@laodouya 为 chDB 贡献了 Python DB API 2.0 接口的实现。@nmreadelf 在 chDB 中添加了 Dataframe 输出格式的支持。@dchimeno, @Berry, @Dan Goodman, @Sebastian Gale, @Mimoune, @schaal, 和 @alanpaulkwan 等朋友也为 chDB 提出了许多有价值的问题。
so 中的 Jemalloc
chDB 进行了许多性能优化,包括将 jemalloc 移植到 chdb 共享库的极其困难的任务。
在仔细 分析 chDB 在 Clickbench 中的性能 后,发现 chDB 和 clickhouse-local 在 Q23 中存在显着的性能差距。人们认为这种差异是由于在实现 Q23 时,chDB 通过删除 jemalloc 简化了流程。那么我们是如何解决这个问题的呢?
ClickHouse 引擎包含数百个子模块,包括 Boost 和 LLVM 等重量级库。为了确保良好的兼容性并实现 JIT 执行引擎,ClickHouse 静态链接了它自己的 LLVM 版本的 libc 和 libc++。这样,ClickHouse 的二进制文件可以轻松保证整体链接安全性。但是,对于 chDB 而言,作为共享对象 (so),这部分由于以下几个原因而变得异常具有挑战性
- Python 运行时有自己的 libc。加载 chdb.so 后,许多本应链接到 ClickHouse 二进制文件中的 jemalloc 的内存分配和管理函数,将不可避免地通过 @plt 连接到 Python 的内置 libc。
- 为了解决上述问题,一种解决方案是修改 ClickHouse 源代码,以便所有相关函数都显式地使用
je_
前缀调用,例如je_malloc
,je_free
。但这种方法带来了两个新问题;其中一个可以很容易地解决。修改第三方库的 malloc 调用代码将是一个巨大的项目。相反,我在使用 clang++ 链接时使用了一个技巧:-Wl,-wrap,malloc
。例如,在链接阶段,所有对 malloc 符号的调用都会重定向到__wrap_malloc
。你可以参考 chDB 中的这段代码:mallocAdapt.c
似乎问题已得到解决,但真正的噩梦出现了。chDB 在某些 je_free
调用上仍然偶尔崩溃。经过不懈的调查,最终发现这是一个古老的 libc 遗留问题
在编写 C 代码时,malloc/calloc 通常与 free 配对使用。我们将尽力避免从函数内部返回由 malloc
在堆上分配的内存。这是因为这很容易导致调用者忘记调用 free
,从而导致内存泄漏。
但是,由于历史遗留问题,GNU libc 中的某些函数(例如 getcwd() 和 get_current_dir_name())在内部调用 malloc
来分配自己的内存并返回它。
这些函数在 STL 和 Boost 等库中被广泛使用,用于实现路径相关的功能。因此,我们遇到了这样一种情况:getcwd 返回由 glibc 版本的 malloc 分配的内存,但我们尝试使用 je_free
释放它。所以... 崩溃了!
理想情况下,jemalloc 会提供一个接口来查询指针指向的内存是否由 jemalloc 分配。我们只需要在调用 je_free 之前检查它,如下所示。
void __wrap_free(void * ptr)
{
int arena_ind;
if (unlikely(ptr == NULL))
{
return;bun
}
// in some glibc functions, the returned buffer is allocated by glibc malloc
// so we need to free it by glibc free.
// eg. getcwd, see: https://man7.org/linux/man-pages/man3/getcwd.3.html
// so we need to check if the buffer is allocated by jemalloc
// if not, we need to free it by glibc free
arena_ind = je_mallctl("arenas.lookup", NULL, NULL, &ptr, sizeof(ptr));
if (unlikely(arena_ind != 0)) {
__real_free(ptr);
return;
}
je_free(ptr);
}
但不幸的是,当使用 arenas.lookup
查询不是由 jemalloc
分配的内存时,jemalloc
的 mallctl
可能会在断言时失败...
Lookup 导致断言失败?这显然不是理想的情况,所以我向 jemalloc 提交了一个补丁:#2424 使 arenas_lookup_ctl 可重试。官方仓库已经合并了这个 PR。因此,我现在已成为 jemalloc 的贡献者。
展示时刻
通过在 ClickHouse 和 jemalloc 上花费数周的努力,chDB 的内存使用量已显着降低了 50%。
根据 ClickBench 上的数据,chDB 目前是 最快的无状态和无服务器数据库(不包括 ClickHouse Web)
chDB 目前是 最快的 SQL on Parquet 实现(DuckDB 的实际性能是在“加载”过程之后实现的,该过程耗时 142~425 秒。
近期工作
随着 chDB v0.14 的发布,让我们回顾一下最近发生的事情
-
v0.12 - 查询多个 Pandas DataFrame。你甚至可以将 Parquet 与 DataFrame 连接起来!
df1 = pd.DataFrame({'a': [1, 2, 3], 'b': ["one", "two", "three"]}) df2 = pd.DataFrame({'c': [1, 2, 3], 'd': ["ONE", "TWO", "THREE"]}) # Save df2 to Parquet file df2.to_parquet('df2.parquet') print("\n# Join DataFrame and Parquet:") print(cdf.query(sql="select * from __tbl1__ t1 join __tbl2__ t2 on t1.a = t2.c", tbl1=df1, tbl2=cdf.Table(parquet_path='df2.parquet')))
-
v0.13 - 获取 查询统计信息,例如
rows_read
、bytes_read
、time elapsed
。# Query read_rows, read_bytes, elapsed time data = "file('hits_0.parquet', Parquet)" sql = f"""SELECT RegionID, SUM(AdvEngineID), COUNT(*) AS c, AVG(ResolutionWidth), COUNT(DISTINCT UserID) FROM {data} GROUP BY RegionID ORDER BY c DESC""" res = chdb.query(sql) print(f"\nSQL read {res.rows_read()} rows, {res.bytes_read()} bytes, elapsed {res.elapsed()} seconds")
-
v0.14 - Python UDF(用户自定义函数)
from chdb.udf import chdb_udf from chdb import query @chdb_udf() def sum_udf(lhs, rhs): return int(lhs) + int(rhs) print(query("select sum_udf(12,22)"))
展望未来
chDB 在 v0.11 上升级到 ClickHouse 23.6,并且在 Parquet 上运行 SQL 的性能得到了显着提升。但是等等,还有更多!就在几天前,我们兴奋地发现 ClickHouse 23.8 通过“Parquet 过滤器下推”进一步优化了 Parquet 性能。因此,带有 ClickHouse 23.8 的 chDB 正在路上!
我们还在以下领域与 ClickHouse 团队密切合作
- 尽可能减小 chDB 安装包的整体大小(目前压缩到 100MB 左右,我们希望今年将其缩小到 80MB)
- chDB 的表函数和 UDAF(用户自定义聚合函数)
- chDB 已经支持使用 Pandas Dataframe 作为输入和输出,我们将继续优化其在这方面的性能。
我们欢迎大家使用 chDB,也感谢您在 GitHub 上给我们一个 Star 来支持我们。
在这里,我要感谢 ClickHouse CTO @Alexey 和产品主管 @Tanya 的支持和鼓励。没有你们的帮助,就不会有今天的 chDB!
目前,chdb.io 拥有 10 个项目,每个人都是 ClickHouse 的忠实粉丝。我们是一群用爱发电的黑客!我们的目标是创建世界上最强大、最高性能的嵌入式数据库!