记录一次 StarRocks CSV Reader 性能优化过程

这篇文章复盘了一次 StarRocks CSV Reader 的性能优化过程,从用户反馈性能落后于 Trino、Doris 和 ClickHouse 开始,一步步定位热点并做针对性改造。正文先通过基准测试和火焰图发现列名映射、分区列追加和频繁对象分配带来的开销,再分别介绍预先建立列映射、批量追加分区列、复用 fields 容器和减少 Slice 拷贝等优化思路,以及对应 PR 的效果,最后结合 HDFS、OSS 和缓存场景下的对比测试总结出当前瓶颈已经更多转向外部存储访问。

这段时间,一位用户间断性的吐槽 StarRocks 有些 SQL 比 Trino、Apache Doris 慢了 n 个数量。起初我们没有太在意,觉得可能是某些 bad case 引起。后面感谢用户的定位,发现了这些查询的数据源大多为 Hive Text 格式(也就是 CSV 格式)。

天将降大任于斯人也,领导便把这个重任交付与我。其实问题一到手,我是非常疑惑的,这个 CSV Reader 在我入职前就有了,按道理也是被广泛使用,怎么还会有性能问题?而且一个行存的格式,能有啥特别的优化手段,不会是 BUG 把?

不过既然用户说比 Doris、Trino 慢了很多,那就搭一个测试环境呗。

初步测试

我选择了一台 4 core,32GB 内存的老爷机作为测试环境。分别安装上了 Apache Doris 2.0,StarRocks 3.1.2, Trino 424。然后我还额外加了一位重磅选手 ClickHouse(23.8.1.2785),大家同台竞技一把。

第一步当然就是造数据,我在 Hive 上面创建一张带分区的 text 格式表,这张表包含各种类型,有 BIGINT,有 STRING,还有 ARRAY<STRING> 等等。插入一行数据后,反复执行 INSERT INTO tbl SELECT * FROM tbl 来对表进行膨胀,最后搞了差不多有 5.1 G 的大小,够我们的老爷机 CPU 打满分析一会了。

SQL 语句我也不玩虚的,就来 3 条 SQL:

  1. 全表扫:SELECT * FROM table ORDER BY 1 LIMIT 1
  2. 扫一行,看结果会不会立刻返回:SELECT * FROM table LIMIT 1
  3. COUNT(*),看有没有做少读列:SELECT COUNT(*) FROM table

大家都跑 3 轮,我只取第 3 次结果。

Apache Doris

MySQL [(none)]> select * from csv_benchmark_hdfs order by 1 limit 1;
1 row in set (43.973 sec)

MySQL [(none)]> select * from csv_benchmark_hdfs limit 1;
1 row in set (0.323 sec)

MySQL [(none)]> select count(*) from csv_benchmark_hdfs;
1 row in set (43.866 sec)

ClickHouse

SELECT * FROM csv_benchmark_hdfs ORDER BY 1 ASC LIMIT 1
1 row in set. Elapsed: 42.858 sec.

SELECT * FROM csv_benchmark_hdfs LIMIT 1
1 row in set. Elapsed: 0.027 sec.

SELECT count(*) FROM csv_benchmark_hdfs;
1 row in set. Elapsed: 42.830 sec.

有一说一 ClickHouse 真省内存,CPU 利用率还低。

Trino

Trino 太慢了,不想测 3 轮了,随便选一轮吧。

select * from csv_benchmark_hdfs order by 1 limit 1;
4:24 [11M rows, 5.09GB] [41.5K rows/s, 19.7MB/s]

select * from csv_benchmark_hdfs limit 1;
7.46 [9.61K rows, 4.58MB] [1.29K rows/s, 628KB/s]

select count(*) from csv_benchmark_hdfs;
4:24 [11M rows, 5.09GB] [41.5K rows/s, 19.7MB/s]

StarRocks

select * from csv_benchmark_hdfs order by 1 limit 1;
1 row in set (1 min 47.561 sec)

select * from csv_benchmark_hdfs limit 1;
1 row in set (0.369 sec)

select count(*) from csv_benchmark_hdfs;
1 row in set (48.348 sec)

排除掉 Trino 不考虑,StarRocks 在 CSV 全表扫的场景下,那性能是相当的差,比 ClickHouse 和 Doris 慢了不是一个数量级。不过这也说明我们有很大的优化空间,那么该如何优化呢?

初步优化

遇事不决,直接火焰图。

这个图是拿 OSS 测的时候捕获的,AWS 的栈可以忽略不计。

优化前火焰图

我们主要看 parse_csv 这个方法,可以看到里面有 __dynamic_caststd::__detail::_Map_basestd::ctype<char>::do_tolowerstd::use_facet<std::ctype<char>> 这些看起来和解析 CSV 无关的代码,而且占了很大的比重。而且离谱的是,__dynamic_caststd::use_facet<std::ctype<char>> 在代码里面压根就不存在。

没办法,对于不存在的方法,那只能祭出 GDB,在 __dynamic_cast 上面打个断点:

(gdb) bt
#0  __cxxabiv1::__dynamic_cast (src_ptr=0xac26120 <(anonymous namespace)::ctype_c>, src_type=0x9e28f30 <typeinfo for std::locale::facet>, dst_type=0x9e2a3f8 <typeinfo for std::ctype<char>>,
    src2dst=src2dst@entry=0) at ../../.././libstdc++-v3/libsupc++/dyncast.cc:50
#1  0x00000000082f7b0b in std::use_facet<std::ctype<char> > (__loc=...) at /workspace/gcc/x86_64-pc-linux-gnu/libstdc++-v3/include/bits/locale_classes.tcc:139
#2  0x0000000005316bb9 in std::tolower<char> (__loc=..., __c=114 'r') at /opt/gcc/usr/include/c++/10.3.0/bits/locale_facets.h:2650
#3  boost::algorithm::detail::to_lowerF<char>::operator() (this=<synthetic pointer>, this=<synthetic pointer>, Ch=114 'r')
    at /var/local/thirdparty/installed/include/boost/algorithm/string/detail/case_conv.hpp:49
#4  boost::iterators::transform_iterator<boost::algorithm::detail::to_lowerF<char>, __gnu_cxx::__normal_iterator<char const*, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >, boost::use_default, boost::use_default>::dereference (this=<synthetic pointer>) at /var/local/thirdparty/installed/include/boost/iterator/transform_iterator.hpp:130
#5  boost::iterators::iterator_core_access::dereference<boost::iterators::transform_iterator<boost::algorithm::detail::to_lowerF<char>, __gnu_cxx::__normal_iterator<char const*, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >, boost::use_default, boost::use_default> > (f=<synthetic pointer>...) at /var/local/thirdparty/installed/include/boost/iterator/iterator_facade.hpp:550
#6  boost::iterators::detail::iterator_facade_base<boost::iterators::transform_iterator<boost::algorithm::detail::to_lowerF<char>, __gnu_cxx::__normal_iterator<char const*, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >, boost::use_default, boost::use_default>, char, boost::iterators::random_access_traversal_tag, char, long, false, false>::operator* (this=<synthetic pointer>)
    at /var/local/thirdparty/installed/include/boost/iterator/iterator_facade.hpp:656
#7  std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::_M_construct<boost::iterators::transform_iterator<boost::algorithm::detail::to_lowerF<char>, __gnu_cxx::__normal_iterator<char const*, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >, boost::use_default, boost::use_default> > (this=0x7ffe9d810b00, __beg=..., __end=..., __end=...)
    at /opt/gcc/usr/include/c++/10.3.0/bits/basic_string.tcc:172
#8  0x00000000053191b8 in std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::_M_construct_aux<boost::iterators::transform_iterator<boost::algorithm::detail::to_lowerF<char>, __gnu_cxx::__normal_iterator<char const*, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >, boost::use_default, boost::use_default> > (__end=..., __beg=...,
    this=0x7ffe9d810b00) at /opt/gcc/usr/include/c++/10.3.0/bits/basic_string.h:247
#9  std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::_M_construct<boost::iterators::transform_iterator<boost::algorithm::detail::to_lowerF<char>, __gnu_cxx::__normal_iterator<char const*, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >, boost::use_default, boost::use_default> > (__end=..., __beg=..., this=0x7ffe9d810b00)
    at /opt/gcc/usr/include/c++/10.3.0/bits/basic_string.h:266
#10 std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::basic_string<boost::iterators::transform_iterator<boost::algorithm::detail::to_lowerF<char>, __gnu_cxx::__normal_iterator<char const*, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >, boost::use_default, boost::use_default>, void> (__a=..., __end=..., __beg=..., this=0x7ffe9d810b00)
    at /opt/gcc/usr/include/c++/10.3.0/bits/basic_string.h:628
#11 boost::algorithm::detail::transform_range_copy<std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >, boost::algorithm::detail::to_lowerF<char> > (Functor=..., Input=...) at /var/local/thirdparty/installed/include/boost/algorithm/string/detail/case_conv.hpp:122
#12 boost::algorithm::to_lower_copy<std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > > (Loc=..., Input=...)
    at /var/local/thirdparty/installed/include/boost/algorithm/string/case_conv.hpp:79
#13 starrocks::HdfsTextScanner::parse_csv (this=0x7ffc29af2000, chunk_size=4096, chunk=0x7ffe9d810e10) at /root/starrocks/be/src/exec/hdfs_scanner_text.cpp:284

好家伙,原来都是小写转换里面来的,你可以注意到 std::use_facet 也在栈里面。所以罪魁祸首就是 boost::to_lower_copy() 这个方法。

好了,原因大概理清楚了,那我们看看咋改。

csv 列名转小写

外层的 for 循环是处理每一行文本,里面一个 for 循环是处理一行的每一列数据。然后这个 boost::to_lower_copy() 方法每一行的每一列都要执行一次。其实它 format 的都是列名,我寻思每一行的列名也不会变吧,何必每行每列都要执行一遍。

csv map 映射

前面之所以要 format 列名,就是为了从一个 map 里面找到该列对应的 csv_index,即这一列对应的是 csv 文件中中的哪一列。虽然 map 的 get() 是一个 $O(1)$ 的操作,但是为了一个简单的列映射,每一行每一列都要执行一次,那这代价也太大了吧。

不过解决办法也很简单,我在开始初始化 CSVReader 前,就先把这个映射建立好。然后不用 map,而是用 vector,结构差不多就是 vector<int> 这样,下标表示 table 的列顺序,值表示在 csv 文件中的第几列,这样就比 map 轻量多了。然后也能省去频繁执行 boost::to_lower_copy() 的开销。

代码往后拉发现添加分区列的操作也很迷,每扫一行,添加一行分区列。

添加分区列代码

我完全可以 csv 读完后,一次性 append n 行分区列,这样还可以触发 SIMD,肯定比一次添加一行更加高效。

经过修改,我提了一个 PR:https://github.com/StarRocks/starrocks/pull/30028,合入后,再测一把性能吧。

select * from csv_benchmark_hdfs order by 1 limit 1;
1 row in set (49.455 sec)

select * from csv_benchmark_hdfs limit 1;
1 row in set (0.122 sec)

select count(*) from csv_benchmark_hdfs;
1 row in set (48.348 sec)

乖乖,就这么一改,性能直接翻了一番?

进一步优化

现在成绩看起来和 Doris、ClickHouse 差不多了,但是显然还有进一步优化的空间,遂再一次采集火焰图。

老样子,忽略 AWS 的堆栈。

初步优化后的火焰图

可以看到 CSVReader::split_record 还是有点慢,而且整个 parse_csv 堆栈中,有好多的 new,free 的操作,这说明我们还有频繁的申请对象。

split_record 代码

可以看见我们每一行都会创建一个 fields 数组(其就是一个 vector<Slice> 的结构),然后把一行 csv 按分隔符切分成多个 field,emplace_back() 到 fields 里面。可想而知,这里会频繁的遇到 vector 的扩容。

那我为什么不能每一行共用一份 fields 呢?那就不用操心数组每一行都要扩容一次。

然后再看这一行方法,read_string,就是把 csv 的字符串根据表的类型,进行类型转换。

read_string() 代码
read_string() 具体实现

然后你可以注意到,这个 read_string() 每一次都会拷贝一次 Slice 对象,我的乖乖,想想每一行的每一列都要拷贝,这个开销还是很大的,而且这是完全不必要的拷贝,引用不就完事了?

所以我又提了一个 pr:https://github.com/StarRocks/starrocks/pull/30137,合入后,再一次测试性能。

select * from csv_benchmark_hdfs order by 1 limit 1;
1 row in set (47.252 sec)

select * from csv_benchmark_hdfs limit 1;
1 row in set (0.110 sec)

select count(*) from csv_benchmark_hdfs;
1 row in set (48.348 sec)

快了 2s,但是还是和 Doris 和 ClickHouse 有差距,咋回事呢?

试试 OSS 存储看看,因为 StarRocks 的 HDFS 是走 JNI 的,可能性能不如 C++ 写的 LIBHDFS3。而 OSS 大家都是走 S3 SDK,均是 C++ 写的。

因为 ClickHouse 的 Hive 不支持 OSS,跑不出来,所以就只能跑个 Doris 和 StarRocks 的。

Doris

select * from csv_benchmark order by 1 limit 1;
1 row in set (50.640 sec)

select * from csv_benchmark limit 1;
1 row in set (0.613 sec)

select count(*) from csv_benchmark;
1 row in set (45.242 sec)

Doris 走了 OSS 后,反而比 HDFS 更慢了,不晓得为啥,感觉有点不合常理。

StarRocks

select * from csv_benchmark order by 1 limit 1;
1 row in set (46.606 sec)

select * from csv_benchmark limit 1;
1 row in set (0.328 sec)

select count(*) from csv_benchmark_hdfs;
1 row in set (46.509 sec)

好吧,好像没啥参考意义,就只是在 HDFS 的基础上再快了 1s。

后续

因为 HDFS 大伙的实现不同,所以后面自己测了下 ClickHouse 和 StarRocks 开启 Cache 的场景,在排除了外部存储系统的干扰后,大家的查询时间几乎一致。这至少说明了在 csv 解析这块,我们已经做得差不多了,没有太大的优化空间了,也许后续应该好好的优化下 HDFS 的访问。

三家在查询性能相似的情况下,大家的峰值内存占用分别如下:670MB(ClickHouse) => 900MB(StarRocks) => 1.2G(Doris)

总结

在这一次 CSV 的优化上,第一次让我感受到了细微的代码优化,有时候能带来成倍的性能提升,毕竟两个 PR 加起来的代码也就 100 来行。

ClickHouse 还是全方位牛逼,就是湖的易用性几乎为 0,连 catalog 都没有,然后 Hive 表必须要有分区列,等等,一言难尽。

ClickHouse 的 limit 1 返回的真的很快,估计和实现有关。可能 ClickHouse 把 limit 1 下推到了 csv 解析这块,真就扫了一行。而 StarRocks 是至少也会扫描一个 chunk 4096 行,然后再取 1 行,就自然慢了。

原创文章,作者:Smith,如若转载,请注明出处:https://www.inlighting.org/archives/starrocks-csv-reader-performance-optimization

打赏 微信扫一扫 微信扫一扫
SmithSmith
上一篇 2023年7月6日 下午8:58
下一篇 2023年10月28日 下午3:28

相关推荐

  • StarRocks perfect IDE development setup (Support IDEA & Clion)

    This article provides an English guide for setting up a workable StarRocks development environment with both IDEA and Clion, aiming to let contributors write code on a local MacBook while compiling and debugging on a remote Ubuntu server. It walks through local and remote dependency setup, the first full build of StarRocks, FE development and remote debug in IDEA, and BE development in Clion with remote toolchain, deployment mapping, generated source handling, and GDB-based debugging, so that FE and BE can both be developed with normal code navigation and analysis support.

    2023年1月20日
    2.0K0
  • StarRocks Docker 开发环境搭建指南

    这篇文章介绍了如何借助 Docker 为 StarRocks 构建统一的开发环境,重点解决 BE 本地编译复杂、依赖沉重以及远程调试不便的问题。正文先分析这套方案在 thirdparty 编译、链接速度、SSH 远程开发、GDB 调试和端口隔离上的优势与限制,再说明镜像、目录映射和容器启动方式,随后分别比较 VS Code、Jetbrains Gateway 和代码同步式远程开发的使用体验,并给出 FE 与 BE 的远程 Debug 配置方法和一些实际开发中的注意事项。

    2022年7月30日
    7.0K34
  • How to deploy StarRocks with IAM enabled in AWS EKS?

    This article records a practical walkthrough for deploying StarRocks on AWS EKS with IAM enabled, using CloudShell, eksctl, Helm, and IAM service accounts. It starts from creating an EKS cluster and fixing pod scheduling issues by reducing FE and BE resource requests, then shows how to bind IAM roles to StarRocks pods so they can access services such as S3 and Glue, and finally covers applying the updated Helm values, connecting to the cluster, and cleaning up both the IAM service account and the EKS cluster after the test.

    2024年9月9日
    1.2K0

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注


评论列表(6条)

  • cc
    cc 2023年11月14日 下午6:53

    up又有绩效了

    • Smith
      Smith 2023年11月14日 下午7:10

      @cc只能说对绩效没啥卵用,😨

  • bigboss2063
    bigboss2063 2023年10月26日 下午9:05

    佬越来越强了😀

  • sss
    sss 2023年9月17日 下午2:38

    学习了

  • dirtysalt
    dirtysalt 2023年9月8日 上午9:05

    点赞,学习了。