博客 / 工程

chDB - 自行车上的火箭引擎

author avatar
@Auxten
9月 29, 2023 - 12 分钟阅读

这篇客座博客最初发布在 Auxten个人博客 上。它已更新了最近的进展。

引言

在正式开始 chDB 的旅程之前,我认为最好先简要介绍一下 ClickHouse。近年来,“向量化引擎”在 OLAP 数据库社区中尤其流行。主要原因是 CPU 中添加了越来越多的 SIMD 指令,这大大加速了 OLAP 场景中大量数据的聚合、排序和连接操作。ClickHouse 在“向量化”等多个领域进行了非常详细的优化,这可以从其 对 lz4 和 memcpy 的优化 中看出。

如果说 ClickHouse 是否是性能最佳的 OLAP 引擎还存在争议,但至少根据 基准测试,它属于顶尖行列。除了性能之外,ClickHouse 还拥有强大的功能,使其成为数据库领域的瑞士军刀

  1. 直接查询存储在 S3、GCS 和其他对象存储上的数据。
  2. 使用 ReplacingMergeTree 简化处理变化的数据。
  3. 完成跨数据库数据查询,甚至表连接,而无需依赖第三方工具。
  4. 甚至自动执行谓词下推。

开发和维护一个生产就绪且高效的 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 传递给它,并通过管道检索结果。

python_clickhouse.png

但是,这种方法带来了一些问题

  1. 为每个查询启动一个独立的进程会极大地影响性能,特别是当 clickhouse-local 二进制文件大约 500MB 大小时。
  2. SQL 查询结果的多份副本将不可避免,因为我们需要从管道中读取它,并复制到 Python 进程的缓冲区中。
  3. 与 Python 的集成受到限制,使得实现 Python UDF 和支持 Pandas DataFrame 上的 SQL 变得困难。
  4. 最重要的是,它缺乏优雅 ????

感谢 ClickHouse 结构良好的代码库,我能够在农历新年期间,一边吃饭一边破解 ClickHouse 的 90 万行代码,成功创建了一个原型。

ClickHouse 包含一系列名为 BufferBase 的实现,包括 ReadBufferWriteBuffer,它们大致对应于 C++ 的 istreamostream。为了有效地从文件读取和输出结果(例如,读取 CSV 或 JSONEachRow 并输出 SQL 执行结果),ClickHouse 的 Buffer 还支持对底层内存的随机访问。它甚至可以基于向量创建新的 Buffer,而无需复制内存。ClickHouse 内部使用 BufferBase 的派生类来读取/写入压缩文件以及远程文件(S3、HTTP)。

为了在 ClickHouse 级别实现 SQL 执行结果的零拷贝检索,我使用了内置的 WriteBufferFromVector 而不是 stdout 来接收数据。这确保了并行输出管道不会被阻塞,同时方便地获取 SQL 执行输出的原始内存块。

为了避免从 C++ 复制内存到 Python 对象,我利用了 Python 的 memoryview 进行直接内存映射。

clickhouse_local_py_bind.png

由于 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_arch.png

团队合作

最初,我开发 chDB 的唯一目的是创建一个可以在 Jupyter Notebook 中独立运行的 ClickHouse 引擎。这将使我能够轻松访问大量注释信息,而无需依赖缓慢的 Hive 集群,从而在使用 Python 训练 CV 模型时提高效率。令人惊讶的是,在大多数情况下,chDB 的独立版本实际上优于由数百台服务器组成的 Hive 集群。

chdb_fly_dev.png

在 chDB 发布后,来自 QXIP 的 Lorenzo 很快联系了我。他提出了一个 问题,建议删除对 AVX2 指令集的依赖,可以使 chDB 更方便地在 Lambda 服务上运行。我迅速实现了这个功能,之后 Lorenzo 为 fly.io 上的 chDB 创建了一个演示。老实说,我以前从未想象过这样的用法。

随后,Lorenzo 和他的团队开发了 Golang、NodeJS 和 Rust 中 chDB 的绑定。为了将所有这些项目整合在一起,我在 GitHub 上创建了 chdb.io 组织。

是的!我们还在 Linux 上为 Bun 提供了实验性的 chDB FFI 绑定。

chdb_bun.png

后来,@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),这部分由于以下几个原因而变得异常具有挑战性

  1. Python 运行时有自己的 libc。加载 chdb.so 后,许多本应链接到 ClickHouse 二进制文件中的 jemalloc 的内存分配和管理函数,将不可避免地通过 @plt 连接到 Python 的内置 libc。
  2. 为了解决上述问题,一种解决方案是修改 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 释放它。所以... 崩溃了!

chdb_jemalloc.png

理想情况下,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 分配的内存时,jemallocmallctl 可能会在断言时失败...

Lookup 导致断言失败?这显然不是理想的情况,所以我向 jemalloc 提交了一个补丁:#2424 使 arenas_lookup_ctl 可重试。官方仓库已经合并了这个 PR。因此,我现在已成为 jemalloc 的贡献者。

展示时刻

通过在 ClickHouse 和 jemalloc 上花费数周的努力,chDB 的内存使用量已显着降低了 50%。

chdb_memory.png

根据 ClickBench 上的数据,chDB 目前是 最快的无状态和无服务器数据库(不包括 ClickHouse Web)

clickbench_chdb.png

chDB 目前是 最快的 SQL on Parquet 实现(DuckDB 的实际性能是在“加载”过程之后实现的,该过程耗时 142~425 秒。

chdb_vs_duck.png

近期工作

随着 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_readbytes_readtime 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 团队密切合作

  1. 尽可能减小 chDB 安装包的整体大小(目前压缩到 100MB 左右,我们希望今年将其缩小到 80MB)
  2. chDB 的表函数和 UDAF(用户自定义聚合函数
  3. chDB 已经支持使用 Pandas Dataframe 作为输入和输出,我们将继续优化其在这方面的性能。

我们欢迎大家使用 chDB,也感谢您在 GitHub 上给我们一个 Star 来支持我们。

在这里,我要感谢 ClickHouse CTO @Alexey 和产品主管 @Tanya 的支持和鼓励。没有你们的帮助,就不会有今天的 chDB!

目前,chdb.io 拥有 10 个项目,每个人都是 ClickHouse 的忠实粉丝。我们是一群用爱发电的黑客!我们的目标是创建世界上最强大、最高性能的嵌入式数据库!

分享这篇文章

订阅我们的新闻邮件

随时了解功能发布、产品路线图、支持和云产品!
正在加载表单...
关注我们
X imageSlack imageGitHub image
Telegram imageMeetup imageRss image