做 R 的性能优化,有一个场景几乎人人踩过坑:在循环里不断 c(v, new) 追加数据。小数据时看不出来,一旦规模上来,速度像被抽走一样。

这篇文章想把话说清楚:yulab.utils 里的 chunked_array 不是“更快的向量”,它其实换了一套数据组织方式。它在“写入/拼接”上赢得很干脆,但在“随机访问”上要付成本。

我把两者在常见操作上做了系统 benchmark,结论放在前面:

  1. 如果你的核心操作是“反复追加”,chunked_array 会救你;
  2. 如果你需要频繁 x[i] 随机访问,那还是尽早 as.vector()

下面是细节。

先说人话:chunked_array 是怎么避免拷贝的?

普通 vector 做 c(a, b),本质是申请一块更大的连续内存,然后把 ab 全部拷过去。循环里反复干这件事,拷贝次数会爆炸,时间复杂度接近 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_len

length()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 = "内存占用对比"
)
ChunksTotal ElementsVector (bytes)Chunked Array (bytes)Overhead
101e+0440048414243.4%
1001e+054000484070881.8%
10001e+06400004840646881.6%

结尾:怎么选,一句话就够

如果你是下面这种写法:

  • 循环里不断追加结果(写多读少)

那就用 chunked_arrayc2() 追加),最后一次性 as.vector()

如果你需要大量随机访问、频繁切片:

  • 读多写少

那就别硬扛,早点转回普通 vector。