引言
在正式开始 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 引擎。这将使我能够轻松访问大量注释信息,而无需在使用 Python 训练 CV 模型时依赖缓慢的 Hive 集群。令人惊讶的是,chDB 的独立版本在大多数情况下实际上都优于由数百台服务器组成的 Hive 集群。
chDB 发布后,来自 QXIP 的 Lorenzo 很快联系了我。他提出了一个 问题,建议删除对 AVX2 指令集的依赖可以使 chDB 更方便地在 Lambda 服务上运行。我立即实现了此功能,之后 Lorenzo 创建了一个 chDB 在 fly.io 上的演示。老实说,我以前从未想过这种用法。
随后,Lorenzo 及其团队开发了 chDB 在 Golang、NodeJS 和 Rust 中的绑定。为了将所有这些项目整合在一起,我在 GitHub 上创建了 chdb.io 组织。
是的!我们还有一个用于 Bun 的实验性 chDB FFI 绑定(在 Linux 上)。
后来,@laodouya 为 chDB 贡献了一个 Python DB API 2.0 接口的实现。@nmreadelf 为 chDB 添加了对 Dataframe 输出格式的支持。诸如 @dchimeno、@Berry、@Dan Goodman、@Sebastian Gale、@Mimoune、@schaal 和 @alanpaulkwan 等朋友也为 chDB 提出了许多宝贵的意见。
Jemalloc 在 so 中
chDB 进行了大量的性能优化,包括将 jemalloc 移植到 chdb 的共享库这一极其困难的任务。
在仔细分析chDB的性能并在Clickbench上进行测试,发现chDB和clickhouse-local在Q23查询上存在明显的性能差距。我们认为这种差异是由于chDB在实现Q23时,为了简化流程去掉了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
可能会在断言处失败...
查找导致断言失败?这显然不是理想的,所以我向jemalloc提交了一个补丁:#2424 Make arenas_lookup_ctl triable。官方仓库已经合并了这个PR。因此,我现在成为了jemalloc的贡献者。
成果展示
通过几个星期对ClickHouse和jemalloc的努力,chDB的内存使用量显著降低了50%。
根据ClickBench上的数据,chDB目前是最快的无状态和无服务器数据库(不包括ClickHouse Web)。
chDB目前是Parquet上SQL查询最快实现(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上为我们点赞以示支持。
在此,我要感谢ClickHouse的CTO @Alexey和产品负责人@Tanya的支持和鼓励。没有你们的帮助,就不会有今天的chDB!
目前,chdb.io拥有10个项目,每个人都是ClickHouse的忠实粉丝。我们是一群“用爱发电”的黑客!我们的目标是打造世界上最强大、性能最高的嵌入式数据库!