
做 R 的性能优化,有一个场景几乎人人踩过坑:在循环里不断 c(v, new) 追加数据。小数据时看不出来,一旦规模上来,速度像被抽走一样。
这篇文章想把话说清楚:yulab.utils 里的 chunked_array 不是“更快的向量”,它其实换了一套数据组织方式。它在“写入/拼接”上赢得很干脆,但在“随机访问”上要付成本。
我把两者在常见操作上做了系统 benchmark,结论放在前面:
- 如果你的核心操作是“反复追加”,
chunked_array会救你; - 如果你需要频繁
x[i]随机访问,那还是尽早as.vector()。
下面是细节。
先说人话:chunked_array 是怎么避免拷贝的?
普通 vector 做 c(a, b),本质是申请一块更大的连续内存,然后把 a 和 b 全部拷过去。循环里反复干这件事,拷贝次数会爆炸,时间复杂度接近 O(n²)。
chunked_array 的做法更像“分块记账”:把每次追加的内容当作一个 chunk,放进一个列表里。追加时只需要把新 chunk 挂到末尾,基本不需要搬运历史数据。
代价也很直白:当你要 x[i] 时,它先得判断 i 落在哪个 chunk,再去那个 chunk 里取值。这个“定位”的开销在大量随机访问时会很显眼。
扒开源码:c2() 到底干了什么?
既然说它不是魔法,那咱们就直接扒开 concat.r 的源码看看。你如果自己去翻,会发现它的设计其实极其克制,甚至可以说是有点“偷懒”:
1. 数据结构:只是个戴着面具的 list
当你调用 as_chunked_array(x) 时,它压根没干什么高深的事:就是把你的向量往一个 list 里一塞,然后顺手记下它的起始偏移量(idx = 0)。
structure(
list(
vector_list = list(x),
idx = 0
),
class = "chunked_array"
)2. 拼接魔法:追加列表,绝不碰数据
当你执行 c2(x, y) 的时候,真正巧妙的地方来了:它根本不去碰底层的向量数据。它只是把两个 list 拼在了一起,然后更新一下索引的标记。
list(
vector_list = c(X$vector_list, Y$vector_list),
idx = c(X$idx, Y$idx + length(X))
)懂 R 底层的人都知道,合并两个 list 只是在复制一堆指针,开销便宜得令人发指。这就是为什么在前面的 benchmark 里,c2() 能把追加耗时和内存分配(mem_alloc)直接降维打击到接近 0。它不是算得快,它是压根没在算。
3. 出来混迟早要还:切片索引的痛
既然数据被我们人为地拆成了一块块(vector_list),那当你手欠去执行 ca[i] 取数据时,代码就必须先算出来 i 到底落在哪一块里。
去看看 [.chunked_array 的源码,你会发现我用了 findInterval 来做定位,然后再算出相对位置去提数据:
# 找到 i 属于哪个 chunk
chunk_indices <- findInterval(valid_i - 1, x$idx)
# 计算在 chunk 内的相对位置
relative_pos <- valid_i - x$idx[chunk_indices]这就是前文一直强调的“定位开销”。普通向量在 C 语言底层直接算个内存偏移量就拿到了,而 chunked_array 必须在 R 层面跑一趟 findInterval 外加分组提取。慢就慢在这儿,这债没法躲。
4. 终极归宿:as.vector()
当你把循环跑完,想把它变回正常的向量时,源码极其简单粗暴:
unlist(x$vector_list)一个底层的 unlist 调过去,直接把所有 chunk 连续拷贝成一个大向量。这其实就是把“在循环里做无数次中等拷贝”的惨剧,强行转化为了“最后只做一次大拷贝”。所以,千万别忘了这最后一步。
复现实验:用到的包
library(yulab.utils)
library(bench)
library(ggplot2)1)拼接:chunked_array 的主场
1.1 两两拼接:c() vs c2()
这里比较的是把两个等长对象拼起来。
sizes <- c(1e3, 1e4, 1e5, 1e6)
bench_concat <- bench::press(
n = sizes,
{
a <- seq_len(n)
b <- seq_len(n)
ca <- as_chunked_array(a)
cb <- as_chunked_array(b)
bench::mark(
vector = c(a, b),
chunked = c2(ca, cb),
check = FALSE,
min_iterations = 50
)
}
)
bench_concat这组结果最醒目的点不是“谁快一点”,而是“规模越大差距越大”。比如 n=1e6 时,向量拼接需要毫秒级(还会分配十几 MB),而 c2() 基本保持在几十微秒级,且 mem_alloc 接近 0。
autoplot(bench_concat) +
labs(
title = "两两拼接: c() vs c2()",
subtitle = "chunked_array 通过分块追加避免大规模拷贝"
) +
theme_minimal(base_size = 13)
1.2 循环追加:别再在 for 里反复 c()
这个场景更贴近日常:一边跑循环一边攒结果。
n_iters <- c(100, 500, 1000, 2000)
chunk_size <- 100
bench_iter <- bench::press(
n = n_iters,
{
chunks <- lapply(seq_len(n), function(i) seq_len(chunk_size))
bench::mark(
vector = {
v <- integer(0)
for (ch in chunks) v <- c(v, ch)
v
},
chunked = {
ca <- as_chunked_array(integer(0))
for (ch in chunks) ca <- c2(ca, ch)
ca
},
check = FALSE,
min_iterations = 5
)
}
)
bench_iter从表里能看到一个很现实的变化:当迭代次数上来,vector 的时间和内存分配都会快速上升;而 chunked_array 的增长更温和。n=2000 时,vector 的 mem_alloc 已经是 700+MB 的量级,这就是很多人“跑着跑着 R 直接卡死”的根源。
autoplot(bench_iter) +
labs(
title = "迭代拼接: 循环 c() vs 循环 c2()",
subtitle = "vector 的重复拷贝 vs chunked_array 的分块追加"
) +
theme_minimal(base_size = 13)
2)索引:chunked_array 的代价在哪
为了把这个代价说清楚,我构造了一个 1e6 长度的对象,并把它拆成 1000 个 chunk(每个 1000 元素)。
2.1 单元素随机访问
N <- 1e6
vec <- seq_len(N)
ca <- Reduce(c2, lapply(split(vec, ceiling(seq_along(vec) / 1000)), as_chunked_array))
set.seed(42)
random_idx <- sample(N, 1000)
bench_single <- bench::mark(
vector = { for (i in random_idx) vec[i] },
chunked = { for (i in random_idx) ca[i] },
check = FALSE,
min_iterations = 10
)
bench_single这组数据会让你立刻明白“定位 chunk”不是免费的:vector 的量级是微秒/毫秒,而 chunked_array 会到几十毫秒。
如果你的代码里有大量 for (i in idx) x[i] 这种操作,chunked_array 不会让它变快。
2.2 批量切片访问
slice_sizes <- c(10, 100, 1000, 10000)
bench_slice <- bench::press(
k = slice_sizes,
{
idx <- sample(N, k)
bench::mark(
vector = vec[idx],
chunked = ca[idx],
check = FALSE,
min_iterations = 50
)
}
)
bench_slice同样的结论:k 越大,chunked_array 的定位开销越明显。
autoplot(bench_slice) +
labs(
title = "批量索引: vector[i] vs chunked_array[i]",
subtitle = "chunked_array 需要额外定位 chunk"
) +
theme_minimal(base_size = 13)
3)最后一步:一次性还原成普通向量
绝大多数时候,chunked_array 的使用方式应该是:
“写入阶段用它攒数据,读/分析阶段一次性 as.vector() 变回普通向量。”
sizes_conv <- c(1e4, 1e5, 1e6)
bench_conv <- bench::press(
n = sizes_conv,
{
v <- seq_len(n)
ca <- Reduce(c2, lapply(split(v, ceiling(seq_along(v) / 1000)), as_chunked_array))
bench::mark(
as.vector = as.vector(ca),
check = FALSE,
min_iterations = 20
)
}
)
bench_conv还原需要做一次线性拷贝,这是正常的。关键在于:你把“无数次小拷贝”变成了“最后一次大拷贝”。
4)length() 与内存开销:小账也要算
4.1 length()
bench_len <- bench::mark(
vector = length(vec),
chunked = length(ca),
check = FALSE,
min_iterations = 1000
)
bench_lenlength() 在 chunked_array 上会慢一些,但通常不构成瓶颈。
4.2 内存占用
chunked_array 用 list 存 chunk,肯定会有结构性开销。我的测量结果是:chunk 越多,开销越明显,但总体在可接受范围内。
n_chunks <- c(10, 100, 1000)
chunk_sz <- 1000
mem_results <- lapply(n_chunks, function(nc) {
v <- seq_len(nc * chunk_sz)
ca <- Reduce(c2, lapply(split(v, ceiling(seq_along(v) / chunk_sz)), as_chunked_array))
data.frame(
n_chunks = nc,
total_elems = nc * chunk_sz,
vec_bytes = as.numeric(object.size(v)),
ca_bytes = as.numeric(object.size(ca)),
overhead = sprintf("%.1f%%", (as.numeric(object.size(ca)) / as.numeric(object.size(v)) - 1) * 100)
)
})
mem_df <- do.call(rbind, mem_results)
knitr::kable(
mem_df,
col.names = c("Chunks", "Total Elements", "Vector (bytes)", "Chunked Array (bytes)", "Overhead"),
caption = "内存占用对比"
)| Chunks | Total Elements | Vector (bytes) | Chunked Array (bytes) | Overhead |
|---|---|---|---|---|
| 10 | 1e+04 | 40048 | 41424 | 3.4% |
| 100 | 1e+05 | 400048 | 407088 | 1.8% |
| 1000 | 1e+06 | 4000048 | 4064688 | 1.6% |
结尾:怎么选,一句话就够
如果你是下面这种写法:
- 循环里不断追加结果(写多读少)
那就用 chunked_array(c2() 追加),最后一次性 as.vector()。
如果你需要大量随机访问、频繁切片:
- 读多写少
那就别硬扛,早点转回普通 vector。