欢迎阅读我们的 2024 年首篇版本发布博文,尽管实际上是关于 2023 年底发布的版本!ClickHouse 23.12 版本包含 21 项新功能、18 项性能优化和 37 个错误修复。
在这篇博文中,我们将介绍一小部分新功能,但此版本还包括以下功能:ORDER BY ALL 的能力、从数字生成短唯一标识符 (SQID)、使用新的基于傅里叶变换的 seriesPeriodDetectFFT 函数查找信号频率、支持 SHA-512/256、ALIAS 列上的索引、通过 APPLY DELETED MASK 在轻量级删除操作后清理已删除的记录、降低哈希连接的内存使用量以及加快 Merge 表的计数速度。
在集成方面,我们还改进了 ClickHouse 的 PowerBI、Metabase、dbt、Apache Beam 和 Kafka 连接器。
新贡献者
一如既往,我们特别欢迎 23.12 版本的所有新贡献者!ClickHouse 的普及很大程度上归功于社区的贡献。看到社区不断壮大总是令人感到荣幸。
以下是新贡献者的姓名
安德烈·费多托夫、陈立祥、加甘·戈埃尔、詹姆斯·诺克、娜塔莉亚·奇佐恩科娃、瑞安·雅各布斯、谢尔盖·苏沃洛夫、沙尼·埃尔哈拉尔、邱卓、andrewzolotukhin、hdhoang 和 skyoct。
如果您在这里看到了您的名字,请与我们联系...但我们也会在 Twitter 等平台上找到您。
您还可以查看演示文稿的幻灯片。
可刷新物化视图
由迈克尔·科卢帕耶夫、迈克尔·古佐夫贡献
ClickHouse 的新用户经常发现自己在探索物化视图,以解决各种数据和查询问题,从加速聚合查询到插入时的数据转换任务。此时,这些用户也经常遇到一个常见的困惑点——他们期望 ClickHouse 中的物化视图与他们在其他数据库中使用的物化视图类似,但实际上它们只是在新插入行时执行的查询触发器!更准确地说,当行作为块(通常至少包含 1000 行)插入到 ClickHouse 时,为物化视图定义的查询将在该块上执行,结果存储在不同的目标表中。我们的同事 Mark 在最近的一个视频中简洁地描述了这个过程
此功能非常强大,并且像 ClickHouse 中的大多数功能一样,它是专门为扩展性而设计的,视图会随着新数据的插入而增量更新。但是,在某些用例中,不需要或不适用这种增量过程。有些问题要么与增量方法不兼容,要么不需要实时更新,定期重建更合适。例如,您可能希望定期对完整数据集上的视图执行完全重新计算,因为它使用了复杂的连接,这与增量方法不兼容。
在 23.12 版本中,我们很高兴地宣布添加可刷新物化视图作为实验性功能,以解决这些用例!除了允许视图由定期执行的查询组成,并将结果设置为目标表之外,此功能还可用于在 ClickHouse 中执行 cron 任务,例如,定期从外部数据源导出或导入数据。
这个重要功能值得单独写一篇博文(敬请期待!),特别是考虑到它可能解决的问题数量。
例如,为了介绍语法,让我们考虑一个问题,这个问题可能很难通过传统的增量物化视图甚至经典视图来解决。
考虑一下我们在与 dbt 集成时使用的示例。这包含一个小的 IMDB 数据集,其关系模式如下。此数据集源自 关系数据集存储库。
假设您已在 ClickHouse 中创建并填充了这些表,如我们的文档中所述,则可以使用以下查询来计算每个演员的摘要,并按电影出场次数最多的顺序排序。
SELECT
id,
any(actor_name) AS name,
uniqExact(movie_id) AS num_movies,
avg(rank) AS avg_rank,
uniqExact(genre) AS unique_genres,
uniqExact(director_name) AS uniq_directors,
max(created_at) AS updated_at
FROM
(
SELECT
imdb.actors.id AS id,
concat(imdb.actors.first_name, ' ', imdb.actors.last_name) AS actor_name,
imdb.movies.id AS movie_id,
imdb.movies.rank AS rank,
genre,
concat(imdb.directors.first_name, ' ', imdb.directors.last_name) AS director_name,
created_at
FROM imdb.actors
INNER JOIN imdb.roles ON imdb.roles.actor_id = imdb.actors.id
LEFT JOIN imdb.movies ON imdb.movies.id = imdb.roles.movie_id
LEFT JOIN imdb.genres ON imdb.genres.movie_id = imdb.movies.id
LEFT JOIN imdb.movie_directors ON imdb.movie_directors.movie_id = imdb.movies.id
LEFT JOIN imdb.directors ON imdb.directors.id = imdb.movie_directors.director_id
)
GROUP BY id
ORDER BY num_movies DESC
LIMIT 5
┌─────id─┬─name─────────┬─num_movies─┬───────────avg_rank─┬─unique_genres─┬─uniq_directors─┬──────────updated_at─┐
│ 45332 │ Mel Blanc │ 909 │ 5.7884792542982515 │ 19 │ 148 │ 2024-01-08 15:44:31 │
│ 621468 │ Bess Flowers │ 672 │ 5.540605094212635 │ 20 │ 301 │ 2024-01-08 15:44:31 │
│ 283127 │ Tom London │ 549 │ 2.8057034230202023 │ 18 │ 208 │ 2024-01-08 15:44:31 │
│ 41669 │ Adoor Bhasi │ 544 │ 0 │ 4 │ 121 │ 2024-01-08 15:44:31 │
│ 89951 │ Edmund Cobb │ 544 │ 2.72430730046193 │ 17 │ 203 │ 2024-01-08 15:44:31 │
└────────┴──────────────┴────────────┴────────────────────┴───────────────┴────────────────┴─────────────────────┘
5 rows in set. Elapsed: 1.207 sec. Processed 5.49 million rows, 88.27 MB (4.55 million rows/s., 73.10 MB/s.)
Peak memory usage: 1.44 GiB.
诚然,这不是最慢的查询,但让我们假设用户需要它更快,并且计算成本更低才能用于应用程序。假设此数据集也受到持续更新的影响——电影不断上映,新的演员和导演也不断涌现。
正常的视图在这里没有帮助,并且将此转换为增量物化视图将具有挑战性:只有连接左侧表的更改才会反映出来,这需要多个链接视图和相当大的复杂性。
在 23.12 版本中,我们可以创建一个可刷新物化视图,该视图将定期运行上述查询,并以原子方式替换目标表中的结果。虽然这不会像增量视图那样实时更新,但对于不太可能频繁更新的数据集来说,这可能就足够了。
首先,让我们为结果创建目标表
CREATE TABLE imdb.actor_summary
(
`id` UInt32,
`name` String,
`num_movies` UInt16,
`avg_rank` Float32,
`unique_genres` UInt16,
`uniq_directors` UInt16,
`updated_at` DateTime
)
ENGINE = MergeTree
ORDER BY num_movies
创建可刷新物化视图使用与增量物化视图相同的语法,除了我们引入了一个 REFRESH
子句,用于指定应执行查询的周期。请注意,我们删除了查询存储完整结果的限制。此视图类型对 SELECT
子句没有限制。
//enable experimental feature
SET allow_experimental_refreshable_materialized_view = 1
CREATE MATERIALIZED VIEW imdb.actor_summary_mv
REFRESH EVERY 1 MINUTE TO imdb.actor_summary AS
SELECT
id,
any(actor_name) AS name,
uniqExact(movie_id) AS num_movies,
avg(rank) AS avg_rank,
uniqExact(genre) AS unique_genres,
uniqExact(director_name) AS uniq_directors,
max(created_at) AS updated_at
FROM
(
SELECT
imdb.actors.id AS id,
concat(imdb.actors.first_name, ' ', imdb.actors.last_name) AS actor_name,
imdb.movies.id AS movie_id,
imdb.movies.rank AS rank,
genre,
concat(imdb.directors.first_name, ' ', imdb.directors.last_name) AS director_name,
created_at
FROM imdb.actors
INNER JOIN imdb.roles ON imdb.roles.actor_id = imdb.actors.id
LEFT JOIN imdb.movies ON imdb.movies.id = imdb.roles.movie_id
LEFT JOIN imdb.genres ON imdb.genres.movie_id = imdb.movies.id
LEFT JOIN imdb.movie_directors ON imdb.movie_directors.movie_id = imdb.movies.id
LEFT JOIN imdb.directors ON imdb.directors.id = imdb.movie_directors.director_id
)
GROUP BY id
ORDER BY num_movies DESC
该视图将立即执行,并在之后每分钟按配置执行一次,以确保源表的更新得到反映。我们之前获取演员摘要的查询在语法上变得更简单,并且速度明显加快!
SELECT *
FROM imdb.actor_summary
ORDER BY num_movies DESC
LIMIT 5
┌─────id─┬─name─────────┬─num_movies─┬──avg_rank─┬─unique_genres─┬─uniq_directors─┬──────────updated_at─┐
│ 45332 │ Mel Blanc │ 909 │ 5.7884793 │ 19 │ 148 │ 2024-01-09 10:12:57 │
│ 621468 │ Bess Flowers │ 672 │ 5.540605 │ 20 │ 301 │ 2024-01-09 10:12:57 │
│ 283127 │ Tom London │ 549 │ 2.8057034 │ 18 │ 208 │ 2024-01-09 10:12:57 │
│ 356804 │ Bud Osborne │ 544 │ 1.9575342 │ 16 │ 157 │ 2024-01-09 10:12:57 │
│ 41669 │ Adoor Bhasi │ 544 │ 0 │ 4 │ 121 │ 2024-01-09 10:12:57 │
└────────┴──────────────┴────────────┴───────────┴───────────────┴────────────────┴─────────────────────┘
5 rows in set. Elapsed: 0.003 sec. Processed 6.71 thousand rows, 275.62 KB (2.30 million rows/s., 94.35 MB/s.)
Peak memory usage: 1.19 MiB.
假设我们在源数据中添加了一个新演员“Clicky McClickHouse”,他碰巧出演了很多电影!
INSERT INTO imdb.actors VALUES (845466, 'Clicky', 'McClickHouse', 'M');
INSERT INTO imdb.roles SELECT
845466 AS actor_id,
id AS movie_id,
'Himself' AS role,
now() AS created_at
FROM imdb.movies
LIMIT 10000, 910
0 rows in set. Elapsed: 0.006 sec. Processed 10.91 thousand rows, 43.64 KB (1.84 million rows/s., 7.36 MB/s.)
Peak memory usage: 231.79 KiB.
不到 60 秒后,我们的目标表就会更新,以反映 Clicky 演艺生涯的辉煌成就
SELECT *
FROM imdb.actor_summary
ORDER BY num_movies DESC
LIMIT 5
┌─────id─┬─name────────────────┬─num_movies─┬──avg_rank─┬unique_genres─┬─uniq_directors─┬──────────updated_at─┐
│ 845466 │ Clicky McClickHouse │ 910 │ 1.4687939 │ 21 │ 662 │ 2024-01-09 10:45:04 │
│ 45332 │ Mel Blanc │ 909 │ 5.7884793 │ 19 │ 148 │ 2024-01-09 10:12:57 │
│ 621468 │ Bess Flowers │ 672 │ 5.540605 │ 20 │ 301 │ 2024-01-09 10:12:57 │
│ 283127 │ Tom London │ 549 │ 2.8057034 │ 18 │ 208 │ 2024-01-09 10:12:57 │
│ 356804 │ Bud Osborne │ 544 │ 1.9575342 │ 16 │ 157 │ 2024-01-09 10:12:57 │
└────────┴─────────────────────┴────────────┴───────────┴──────────────┴────────────────┴─────────────────────┘
5 rows in set. Elapsed: 0.003 sec. Processed 6.71 thousand rows, 275.66 KB (2.20 million rows/s., 90.31 MB/s.)
Peak memory usage: 1.19 MiB.
此示例代表了可刷新物化视图的简单应用。此功能可能具有更广泛的应用。查询执行的周期性意味着它可能用于定期导入或导出到外部数据源。此外,这些视图可以使用 DEPENDS
子句链接在一起,以创建视图之间的依赖关系,从而允许构建复杂的工作流程。有关更多详细信息,请参阅 CREATE VIEW 文档。
我们很想知道您是如何利用此功能以及它如何帮助您更高效地解决问题的!
FINAL 的优化
由马克西姆·基塔贡献
自动增量后台数据转换是 ClickHouse 中的一个重要概念,它允许在高数据摄取速率下保持规模,同时在后台合并数据部分时,持续应用 特定于表引擎的数据修改。例如,ReplacingMergeTree 引擎在合并部分时,仅根据行的排序键列值及其包含的数据部分的创建时间戳,保留行的最新插入版本。AggregatingMergeTree 引擎在部分合并期间,将具有相同排序键值的行折叠为聚合行。
只要表存在多个部分,表数据就只处于中间状态,即 ReplacingMergeTree 表可能存在过时行,并且 AggregatingMergeTree 表可能尚未聚合所有行。在连续数据摄取的场景中(例如,实时流式传输场景),几乎总是存在表的多个部分。幸运的是,ClickHouse 为您提供了保障:ClickHouse 提供了 FINAL 作为 FROM 子句的修饰符(例如,SELECT ... FROM table FINAL
),它在查询时动态应用缺失的数据转换。虽然这很方便,并且将查询结果与后台合并的进度分离,但 FINAL 也可能减慢查询速度并增加内存消耗。
在 ClickHouse 20.5 版本之前,带有 FINAL 的 SELECT 以单线程方式执行:选定的数据由单个线程按物理顺序(基于表的排序键)从部分读取,同时进行合并和转换。
ClickHouse 20.5 引入了带有 FINAL 的 SELECT 的并行处理:所有选定的数据都分为多组,每组具有不同的排序键范围,并由多个线程并发处理(读取、合并和转换)。
ClickHouse 23.12 更进一步,根据排序键值将与查询的 WHERE 子句匹配的表数据划分为非相交和相交范围。所有非相交数据范围都并行处理,就像查询中未使用 FINAL 修饰符一样。这仅留下相交数据范围,对于这些范围,表引擎的合并逻辑与 ClickHouse 20.5 引入的并行处理方法一起应用。
此外,对于 FINAL 查询,如果表的分区键是表排序键的前缀,ClickHouse 不再尝试跨不同分区合并数据。
下图概述了带有 FINAL 的 SELECT 查询的这种新处理逻辑
为了并行化数据处理,查询被转换为查询管道——查询的物理运算符计划,该计划由多个独立的执行通道组成,这些通道并发地流式传输、过滤、聚合和排序选定表数据的不相交范围。独立执行通道的数量取决于 max_threads 设置,默认情况下,该设置设置为可用 CPU 核心的数量。在上面的示例中,运行查询的 ClickHouse 服务器有 8 个 CPU 核心。
由于查询使用了 FINAL 修饰符,ClickHouse 在规划时(创建物理运算符计划时)使用表数据部分的主索引。
首先,识别与查询的 WHERE 子句匹配的部分中的所有数据范围,并根据表的排序键将其拆分为非相交和相交范围。非相交范围是仅存在于单个部分中且不需要转换的数据区域。相反,相交范围中的行可能(基于排序键值)存在于多个部分中,并且需要特殊处理。此外,在上面的示例中,查询计划器可以将选定的相交范围拆分为两组(在图中标记为蓝色和绿色),每组具有不同的排序键范围。使用创建的查询管道,所有匹配的非相交数据范围(在图中标记为黄色)都像往常一样并发处理(就像查询根本没有 FINAL 子句一样),方法是将它们的处理均匀地分布在一些可用的执行通道中。来自选定的相交数据范围的数据(每组)按顺序流式传输,并在数据像往常一样处理之前应用特定于表引擎的合并逻辑。
请注意,当具有相同排序键列值的行数较少时,查询性能将与未使用 FINAL 时大致相同。我们用一个具体的例子来说明这一点。为此,我们稍微修改了英国房价样本数据集中的表,并假设该表存储有关当前房产报价的数据,而不是以前售出的房产。我们正在使用 ReplacingMergeTree 表引擎,这使我们能够通过简单地插入具有相同排序键值的新行来更新报价房产的价格和其他特征
CREATE TABLE uk_property_offers
(
postcode1 LowCardinality(String),
postcode2 LowCardinality(String),
street LowCardinality(String),
addr1 String,
addr2 String,
price UInt32,
…
)
ENGINE = ReplacingMergeTree
ORDER BY (postcode1, postcode2, street, addr1, addr2);
接下来,我们将大约 1500 万行插入到表中。
我们在 ClickHouse 23.11 版本上运行一个典型的分析查询,而不使用 FINAL 修饰符,选择三个最昂贵的主要邮政编码
SELECT
postcode1,
formatReadableQuantity(avg(price))
FROM uk_property_offers
GROUP BY postcode1
ORDER BY avg(price) DESC
LIMIT 3
┌─postcode1─┬─formatReadableQuantity(avg(price))─┐
│ W1A │ 163.58 million │
│ NG90 │ 68.59 million │
│ CF99 │ 47.00 million │
└───────────┴────────────────────────────────────┘
3 rows in set. Elapsed: 0.037 sec. Processed 15.52 million rows, 91.36 MB (418.58 million rows/s., 2.46 GB/s.)
Peak memory usage: 881.08 KiB.
我们在 ClickHouse 23.11 版本上运行相同的查询,但使用 FINAL 修饰符
SELECT
postcode1,
formatReadableQuantity(avg(price))
FROM uk_property_offers FINAL
GROUP BY postcode1
ORDER BY avg(price) DESC
LIMIT 3;
┌─postcode1─┬─formatReadableQuantity(avg(price))─┐
│ W1A │ 163.58 million │
│ NG90 │ 68.59 million │
│ CF99 │ 47.00 million │
└───────────┴────────────────────────────────────┘
3 rows in set. Elapsed: 0.299 sec. Processed 15.59 million rows, 506.68 MB (57.19 million rows/s., 1.86 GB/s.)
Peak memory usage: 120.81 MiB.
请注意,使用 FINAL 的查询运行速度慢了大约 10 倍,并且使用了明显更多的内存。
我们在 ClickHouse 23.12 版本上运行带有 FINAL 修饰符的查询
SELECT
postcode1,
formatReadableQuantity(avg(price))
FROM uk_property_offers FINAL
GROUP BY postcode1
ORDER BY avg(price) DESC
LIMIT 3;
┌─postcode1─┬─formatReadableQuantity(avg(price))─┐
│ W1A │ 163.58 million │
│ NG90 │ 68.59 million │
│ CF99 │ 47.00 million │
└───────────┴────────────────────────────────────┘
3 rows in set. Elapsed: 0.036 sec. Processed 15.52 million rows, 91.36 MB (434.42 million rows/s., 2.56 GB/s.)
Peak memory usage: 1.62 MiB.
对于我们在 23.12 版本上的示例数据,无论是否使用 FINAL 修饰符,查询运行时间和内存使用量都大致相同!:)
矢量化改进
在 23.12 版本中,由于使用 SIMD 指令增加了矢量化,几个常见查询得到了显着改进。
更快的 min/max
由劳尔·马林贡献
由于允许使用 SIMD 指令对 min 和 max 函数进行矢量化的更改,这两个函数的速度更快了。当查询受 CPU 限制而不是受 I/O 或内存带宽限制时,这些更改应该可以提高查询性能。虽然这些情况可能很少见,但改进可能很显着。考虑以下相当人为的示例,我们在其中计算 10 亿个整数中的最大数。以下操作在支持 Intel AVX 指令的 Intel(R) Xeon(R) Platinum 8259CL CPU @ 2.50GHz 上执行。
在 23.11 版本中
SELECT max(number)
FROM
(
SELECT *
FROM system.numbers
LIMIT 1000000000
)
┌─max(number)─┐
│ 999999999 │
└─────────────┘
1 row in set. Elapsed: 1.102 sec. Processed 1.00 billion rows, 8.00 GB (907.50 million rows/s., 7.26 GB/s.)
Peak memory usage: 65.55 KiB.
现在是 23.12 版本
┌─max(number)─┐
│ 999999999 │
└─────────────┘
1 row in set. Elapsed: 0.482 sec. Processed 1.00 billion rows, 8.00 GB (2.07 billion rows/s., 16.59 GB/s.)
Peak memory usage: 62.59 KiB.
对于更真实的示例,请考虑以下 NOAA 天气数据集,其中包含超过 10 亿行。下面我们计算有史以来记录的最高温度。
在 23.11 版本中
SELECT max(tempMax) / 10
FROM noaa
┌─divide(max(tempMax), 10)─┐
│ 56.7 │
└──────────────────────────┘
1 row in set. Elapsed: 0.428 sec. Processed 1.08 billion rows, 3.96 GB (2.52 billion rows/s., 9.26 GB/s.)
Peak memory usage: 873.76 KiB.
虽然 23.12 版本的改进不如我们之前的示例那么显着,但我们仍然获得了 25% 的速度提升!
┌─divide(max(tempMax), 10)─┐
│ 56.7 │
└──────────────────────────┘
1 row in set. Elapsed: 0.347 sec. Processed 1.08 billion rows, 3.96 GB (3.11 billion rows/s., 11.42 GB/s.)
Peak memory usage: 847.91 KiB.
更快的聚合
由安东·波波夫贡献
由于针对跨越块的相同键的情况进行了优化,聚合速度也更快了。ClickHouse 以块为单位处理数据。在聚合处理期间,ClickHouse 使用哈希表来存储新聚合值或更新已处理的行块中每行的分组键值的现有聚合值。分组键值用于确定哈希表中聚合值的位置。当已处理块中的所有行都具有相同的唯一分组键时,ClickHouse 只需要确定聚合值的位置一次,然后在该位置进行一批值更新,这可以很好地矢量化。
让我们在 Apple M2 Max 上试一下,看看效果如何。
SELECT number DIV 100000 AS k,
avg(number) AS avg,
max(number) as max,
min(number) as min
FROM numbers_mt(1000000000)
GROUP BY k
ORDER BY k
LIMIT 10;
在 23.11 版本中
┌─k─┬──────avg─┬────max─┬────min─┐
│ 0 │ 49999.5 │ 99999 │ 0 │
│ 1 │ 149999.5 │ 199999 │ 100000 │
│ 2 │ 249999.5 │ 299999 │ 200000 │
│ 3 │ 349999.5 │ 399999 │ 300000 │
│ 4 │ 449999.5 │ 499999 │ 400000 │
│ 5 │ 549999.5 │ 599999 │ 500000 │
│ 6 │ 649999.5 │ 699999 │ 600000 │
│ 7 │ 749999.5 │ 799999 │ 700000 │
│ 8 │ 849999.5 │ 899999 │ 800000 │
│ 9 │ 949999.5 │ 999999 │ 900000 │
└───┴──────────┴────────┴────────┘
10 rows in set. Elapsed: 1.050 sec. Processed 908.92 million rows, 7.27 GB (865.66 million rows/s., 6.93 GB/s.)
在 23.12 版本中
┌─k─┬──────avg─┬────max─┬────min─┐
│ 0 │ 49999.5 │ 99999 │ 0 │
│ 1 │ 149999.5 │ 199999 │ 100000 │
│ 2 │ 249999.5 │ 299999 │ 200000 │
│ 3 │ 349999.5 │ 399999 │ 300000 │
│ 4 │ 449999.5 │ 499999 │ 400000 │
│ 5 │ 549999.5 │ 599999 │ 500000 │
│ 6 │ 649999.5 │ 699999 │ 600000 │
│ 7 │ 749999.5 │ 799999 │ 700000 │
│ 8 │ 849999.5 │ 899999 │ 800000 │
│ 9 │ 949999.5 │ 999999 │ 900000 │
└───┴──────────┴────────┴────────┘
10 rows in set. Elapsed: 0.649 sec. Processed 966.48 million rows, 7.73 GB (1.49 billion rows/s., 11.91 GB/s.)
PASTE JOIN
由亚里克·布留霍韦茨基贡献
PASTE JOIN 适用于连接多个数据集,其中每个数据集中的等效行都引用同一项。即,第一个数据集中的第 n 行应与第二个数据集中的第 n 行连接。然后,我们可以按行号连接数据集,而不是指定连接键。
让我们尝试使用 Hugging Face 上 GLUE 基准测试中的 Quora Question Pairs2 数据集。我们将训练 Parquet 文件拆分为两个
questions.parquet,其中包含 question1、question2 和 idx;labels.parquet,其中包含 label 和 idx
然后,我们可以使用 PASTE JOIN 将列重新连接在一起。
INSERT INTO FUNCTION file('/tmp/qn_labels.parquet') SELECT *
FROM
(
SELECT *
FROM `questions.parquet`
ORDER BY idx ASC
) AS qn
PASTE JOIN
(
SELECT *
FROM `labels.parquet`
ORDER BY idx ASC
) AS lab
Ok.
0 rows in set. Elapsed: 0.221 sec. Processed 727.69 thousand rows, 34.89 MB (3.30 million rows/s., 158.15 MB/s.)
Peak memory usage: 140.47 MiB.