作者:Xuanwo
Databend Labs 成员,数据库研发工程师
我即将分享一个冗长的故事,从 OpenDAL 的 op.read()
开始,以一个意想不到的转折结束。这个过程对我来说非常有启发性,我希望你也能感受到。我会尽力重现这个经历,并附上我一路学到的教训。让我们开始吧!
所有的代码片段和脚本都可以在 Xuanwo/when-i-find-rust-is-slow 中找到。
OpenDAL Python 绑定比 Python 慢?
OpenDAL 是一个数据访问层,允许用户以统一的方式从各种存储服务中轻松高效地获取数据。我们通过 pyo3 为 OpenDAL 提供了 python 绑定。
有一天,@beldathas 在 discord 向我报告了一个案例,即 OpenDAL 的 python 绑定比 python 慢:
import pathlib
import timeit
import opendal
root = pathlib.Path(__file__).parent
op = opendal.Operator("fs", root=str(root))
filename = "lorem_ipsum_150mb.txt"
def read_file_with_opendal() -> bytes:
with op.open(filename, "rb") as fp:
result = fp.read()
return result
def read_file_with_normal() -> bytes:
with open(root / filename, "rb") as fp:
result = fp.read()
return result
if __name__ == "__main__":
print("normal: ", timeit.timeit(read_file_with_normal, number=100))
print("opendal: ", timeit.timeit(read_file_with_opendal, number=100))
结果显示
(venv) $ python benchmark.py
normal: 4.470868484000675
opendal: 8.993250704006641
Emmm,我对这些结果有点尴尬。以下是一些快速的假设:
-
Python 是否有内部缓存可以重复使用相同的内存?
-
Python 是否拥有加速文件读取的一些技巧?
-
PyO3 是否引入了额外的开销?
我将代码重构如下:
with open("/tmp/file", "rb") as fp:
result = fp.read()
assert len(result) == 64 * 1024 * 1024
import opendal
op = opendal.Operator("fs", root=str("/tmp"))
result = op.read("file")
assert len(result) == 64 * 1024 * 1024
结果显示,Python 比 OpenDAL 快得多:
Benchmark 1: python-fs-read/test.py
Time (mean ± σ): 15.9 ms ± 0.7 ms [User: 5.6 ms, System: 10.1 ms]
Range (min … max): 14.9 ms … 21.6 ms 180 runs
Benchmark 2: python-opendal-read/test.py
Time (mean ± σ): 32.9 ms ± 1.3 ms [User: 6.1 ms, System: 26.6 ms]
Range (min … max): 31.4 ms … 42.6 ms 85 runs
Summary
python-fs-read/test.py ran
2.07 ± 0.12 times faster than python-opendal-read/test.py
OpenDAL 的 Python 绑定似乎比 Python 本身运行得更慢,这并不是个好消息。让我们来探究其背后的原因。
OpenDAL Fs 服务比 Python 慢?
这个谜题涉及到许多元素,如 rust、opendal、python、pyo3 等。让我们集中精力尝试找出根本原因。
我在 rust 中通过 opendal fs 服务实现了相同的逻辑:
use std::io::Read;
use opendal::services::Fs;
use opendal::Operator;
fn main() {
let mut cfg = Fs::default();
cfg.root("/tmp");
let op = Operator::new(cfg).unwrap().finish().blocking();
let mut bs = vec![0; 64 * 1024 * 1024];
let mut f = op.reader("file").unwrap();
let mut ts = 0;
loop {
let buf = &mut bs[ts..];
let n = f.read(buf).unwrap();
let n = n as usize;
if n == 0 {
break
}
ts += n;
}
assert_eq!(ts, 64 * 1024 * 1024);
}
然而,结果显示即使 opendal 是用 rust 实现的,它的速度仍然比 python 慢:
Benchmark 1: rust-opendal-fs-read/target/release/test
Time (mean ± σ): 23.8 ms ± 2.0 ms [User: 0.4 ms, System: 23.4 ms]
Range (min … max): 21.8 ms … 34.6 ms 121 runs
Benchmark 2: python-fs-read/test.py
Time (mean ± σ): 15.6 ms ± 0.8 ms [User: 5.5 ms, System: 10.0 ms]
Range (min … max): 14.4 ms … 20.8 ms 166 runs
Summary
python-fs-read/test.py ran
1.52 ± 0.15 times faster than rust-opendal-fs-read/target/release/test
虽然 rust-opendal-fs-read 的表现略优于 python-opendal-read,这暗示了在绑定和 pyo3 中有改进的空间,但这些并非核心问题。我们需要进一步深入探究。
啊,opendal fs 服务比 python 慢。
Rust std fs 比 Python 慢?
OpenDAL 通过 std::fs 实现文件系统服务。OpenDAL 本身会产生额外的开销吗?
我使用 std::fs
在 Rust 中实现了相同逻辑:
use std::io::Read;
use std::fs::OpenOptions;
fn main() {
let mut bs = vec![0; 64 * 1024 * 1024];
let mut f = OpenOptions::new().read(true).open("/tmp/file").unwrap();
let mut ts = 0;
loop {
let buf = &mut bs[ts..];
let n = f.read(buf).unwrap();
let n = n as usize;
if n == 0 {
break
}
ts += n;
}
assert_eq!(ts, 64 * 1024 * 1024);
}
但是:
Benchmark 1: rust-std-fs-read/target/release/test
Time (mean ± σ): 23.1 ms ± 2.5 ms [User: 0.3 ms, System: 22.8 ms]
Range (min … max): 21.0 ms … 37.6 ms 124 runs
Benchmark 2: python-fs-read/test.py
Time (mean ± σ): 15.2 ms ± 1.1 ms [User: 5.4 ms, System: 9.7 ms]
Range (min … max): 14.3 ms … 21.4 ms 178 runs
Summary
python-fs-read/test.py ran
1.52 ± 0.20 times faster than rust-std-fs-read/target/release/test
哇,Rust 的 std fs 比 Python 还慢?这怎么可能呢?无意冒犯,但是这怎么可能呢?
Rust std fs 比 Python 还慢?真的吗!?
我无法相信这个结果:Rust std fs 的速度竟然比 Python 还要慢。
我尝试学会了如何使用 strace
进行系统调用分析。strace
是一个 Linux 系统调用追踪器,它让我们能够监控系统调用并理解其过程。
strace 将包含程序发出的所有系统调用。我们应该关注与/tmp/file
相关的方面。每一行 strace 输出都以系统调用名称开始,后跟输入参数和输出。
比如:
openat(AT_FDCWD, "/tmp/file", O_RDONLY|O_CLOEXEC) = 3
这意味着我们使用参数 AT_FDCWD
,"/tmp/file"
和 O_RDONLY|O_CLOEXEC
调用 openat
系统调用。这将返回输出 3
,这是在后续的系统调用中引用的文件描述符。
好了,我们已经掌握了 strace
。让我们开始使用它吧!
rust-std-fs-read
的 strace:
> strace ./rust-std-fs-read/target/release/test
...
mmap(NULL, 67112960, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f290dd40000
openat(AT_FDCWD, "/tmp/file", O_RDONLY|O_CLOEXEC) = 3
read(3, "\tP\201A\225\366>\260\270R\365\313\220{E\372\274\6\35\"\353\204\220s\2|7C\205\265\6\263"..., 67108864) = 67108864
read(3, "", 0) = 0
close(3) = 0
munmap(0x7f290dd40000, 67112960) = 0
...
python-fs-read
的 strace:
> strace ./python-fs-read/test.py
...
openat(AT_FDCWD, "/tmp/file", O_RDONLY|O_CLOEXEC) = 3
newfstatat(3, "", {st_mode=S_IFREG|0644, st_size=67108864, ...}, AT_EMPTY_PATH) = 0
ioctl(3, TCGETS, 0x7ffe9f844ac0) = -1 ENOTTY (Inappropriate ioctl for device)
lseek(3, 0, SEEK_CUR) = 0
lseek(3, 0, SEEK_CUR) = 0
newfstatat(3, "", {st_mode=S_IFREG|0644, st_size=67108864, ...}, AT_EMPTY_PATH) = 0
mmap(NULL, 67112960, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f13277ff000
read(3, "\tP\201A\225\366>\260\270R\365\313\220{E\372\274\6\35\"\353\204\220s\2|7C\205\265\6\263"..., 67108865) = 67108864
read(3, "", 1) = 0
close(3) = 0
rt_sigaction(SIGINT, {sa_handler=SIG_DFL, sa_mask=[], sa_flags=SA_RESTORER|SA_ONSTACK, sa_restorer=0x7f132be5c710}, {sa_handler=0x7f132c17ac36, sa_mask=[], sa_flags=SA_RESTORER|SA_ONSTACK, sa_restorer=0x7f132be5c710}, 8) = 0
munmap(0x7f13277ff000, 67112960) = 0
...
从分析strace来看,很明显 python-fs-read
的系统调用比 rust-std-fs-read
多,两者都利用了mmap
。那为什么 Python 要比 Rust 更快呢?