DoubleCloud 即将停止服务。迁移到 ClickHouse,享受限时免费迁移服务。立即联系我们 ->->

博客 / 工程

chDB - 自行车上的火箭引擎

author avatar
@Auxten
2023 年 9 月 29 日

这篇客座博客最初发布在 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 引擎。这将使我能够轻松访问大量注释信息,而无需在使用 Python 训练 CV 模型时依赖缓慢的 Hive 集群。令人惊讶的是,chDB 的独立版本在大多数情况下实际上都优于由数百台服务器组成的 Hive 集群。

chdb_fly_dev.png

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

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

是的!我们还有一个用于 Bun 的实验性 chDB FFI 绑定(在 Linux 上)。

chdb_bun.png

后来,@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),这部分变得异常具有挑战性,原因有很多。

  1. Python运行时环境有自己的libc。加载chdb.so后,许多原本应该链接到ClickHouse二进制文件中的jemalloc的内存分配和管理函数,将不可避免地通过@plt连接到Python内置的libc。
  2. 为了解决上述问题,一种解决方案是修改ClickHouse源代码,使所有相关的函数都显式地使用je_前缀调用,例如je_mallocje_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可能会在断言处失败...

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

成果展示

通过几个星期对ClickHouse和jemalloc的努力,chDB的内存使用量显著降低了50%。

chdb_memory.png

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

clickbench_chdb.png

chDB目前是Parquet上SQL查询最快实现(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上为我们点赞以示支持。

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

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

分享此文章

订阅我们的新闻

及时了解功能发布、产品路线图、支持和云服务信息!
加载表单...
关注我们
Twitter imageSlack imageGitHub image
Telegram imageMeetup imageRss image