为什么 `as.logical()` 比 `logical(0)` 快得多?

后端开发 2026-07-08

as.logical() returns a logical(0):

as.logical()
#> logical(0)

但我很惊讶地发现 logical(0)as.logical() 要慢得多。这是为什么呢?对于其他 as.*() 函数也是如此:

bench::mark(as.logical(), logical(0), iterations = 1e5)
#> # A tibble: 2 × 6
#>   expression        min   median `itr/sec` mem_alloc `gc/sec`
#>   <bch:expr>   <bch:tm> <bch:tm>     <dbl> <bch:byt>    <dbl>
#> 1 as.logical()   48.1ns     76ns  8467061.        0B      0  
#> 2 logical(0)      468ns    571ns  1541503.        0B     30.8
bench::mark(as.character(), character(0), iterations = 1e5)
#> # A tibble: 2 × 6
#>   expression          min   median `itr/sec` mem_alloc `gc/sec`
#>   <bch:expr>     <bch:tm> <bch:tm>     <dbl> <bch:byt>    <dbl>
#> 1 as.character()     65ns   94.1ns  9734069.        0B      0  
#> 2 character(0)      505ns    593ns  1550503.        0B     15.5
bench::mark(as.numeric(), numeric(0), iterations = 1e5)
#> # A tibble: 2 × 6
#>   expression        min   median `itr/sec` mem_alloc `gc/sec`
#>   <bch:expr>   <bch:tm> <bch:tm>     <dbl> <bch:byt>    <dbl>
#> 1 as.numeric()   58.1ns     91ns 10218252.        0B      0  
#> 2 numeric(0)      500ns    561ns  1622768.        0B     16.2
bench::mark(as.integer(), integer(0), iterations = 1e5)
#> # A tibble: 2 × 6
#>   expression        min   median `itr/sec` mem_alloc `gc/sec`
#>   <bch:expr>   <bch:tm> <bch:tm>     <dbl> <bch:byt>    <dbl>
#> 1 as.integer()     64ns     92ns  9863536.        0B      0  
#> 2 integer(0)      469ns    582ns  1435752.        0B     14.4

解决方案

.Internal() 函数的开销要比 .Primitive() 大。

logical() 的主体是:

.Internal(vector("logical", length))

as.logical() 是一个 原始类型,定义为:

.Primitive("as.logical")

请注意,.Internal() 本身也是一个原始函数,但它被一个标准的R 函数包装着——这是根本的区别:

is.primitive(logical)
# [1] FALSE
is.primitive(as.logical)
# [1] TRUE

在R Internals Manual的 [.Internal vs .Primitive] 部分,它写道:

使用 .Internal() 并封装在闭包中的函数通常更受偏好,因为这可以确保对带名参数和默认参数的标准处理……在使用被函数闭包包装的 .Internal 接口时,确实会有一些开销

开销是什么?

由于我们在比较一个 .Primitive 与一个 .Internal,有很多差异发生在C 层,因此我运行了带符号表的R 的 调试版本,并用 Valgrind 做了分析:

valgrind --tool=callgrind --trace-children=yes --callgrind-out-file=callgrind-logicalzero.out Rscript --vanilla -e "for(i in 1:1e7) logical(0)"
valgrind --tool=callgrind --trace-children=yes --callgrind-out-file=callgrind-as.logical.out Rscript --vanilla -e "for(i in 1:1e7) as.logical()"

随后我用 [callgrind] 获取了函数调用列表及其占总时间的百分比。以下是两者的前五行:

Output for as.logical()

2,225,301,557 (23.34%)  ./src/main/./src/main/eval.c:bcEval_loop [/usr/lib/R/lib/libR.so]
  931,749,167 ( 9.77%)  ./src/main/./src/main/memory.c:Rf_allocVector3 [/usr/lib/R/lib/libR.so]
  799,330,174 ( 8.38%)  ???:0x0000000000a16ce0 [/usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.32.so]
  720,038,510 ( 7.55%)  ./src/main/./src/main/coerce.c:do_asatomic [/usr/lib/R/lib/libR.so]
  490,428,238 ( 5.14%)  ./src/main/./src/main/coerce.c:Rf_coerceVector [/usr/lib/R/lib/libR.so]

Output for logical(0)

5,635,442,218 (17.74%)  ./src/main/./src/main/eval.c:bcEval_loop [/usr/lib/R/lib/libR.so]
2,278,455,493 ( 7.17%)  ./src/main/./src/main/match.c:Rf_matchArgs_NR [/usr/lib/R/lib/libR.so]
2,204,920,217 ( 6.94%)  /build/reproducible-path/glibc-2.42/string/../sysdeps/x86_64/multiarch/strcmp-avx2.S:__strcmp_avx2 [/usr/lib/x86_64-linux-gnu/libc.so.6]
1,734,012,789 ( 5.46%)  ./src/main/./src/main/eval.c:setup_bcframe_call [/usr/lib/R/lib/libR.so]
1,405,204,563 ( 4.42%)  ./src/main/./src/main/memory.c:CONS_NR [/usr/lib/R/lib/libR.so]
1,260,649,520 ( 3.97%)  ./src/main/./src/main/util.c:Rf_str2type [/usr/lib/R/lib/libR.so]

对输出的解析

完整的 logical(0) 运行大约执行了318亿条指令,而 as.logical() 大约为95亿条。值得关注的部分在于这些额外指令的去处。

第一个显著的差异是在循环之后(bcEval_loop),该循环分别占 logical(0) 运行的17.7% 和 as.logical() 运行的23.3%。在比较两次运行时,callgrind 的输出中的百分比略有误导,因为它们是基于截然不同的总量的比例。就绝对指令数而言,bcEval_looplogical(0) 的指令约为56.4亿条,对 as.logical() 的指令约为22.3亿条。

as.logical() 来说,接下来最耗时的任务其实是分配向量(Rf_allocVector3),而对于 logical(0) 来说,这甚至不在前五名。

绘制绝对指令计数可以让这一切更加清晰:

Output

正如你所怀疑的,Rf_allocVector3,负责分配结果向量,在两次运行中几乎占用完全相同数量的指令:

logical(0)    931,811,539
as.logical()  931,749,167

匹配参数并非免费

当你调用 logical(0) 时,你是在向函数提供一个参数。这一次调用(Rf_matchArgs_NR)是在循环之后最耗时的。

logical() 的函数体中还有字符串 "logical"

.Internal(vector("logical", length))

R通过在类型名表中搜索并用 Rf_str2type__strcmp_avx2 的调用逐个比较来将其转换为内部的 LGLSXP 类型代码,而这一步 as.logical() 不需要执行。

环境创建与销毁成本高

调用 logical(0) 需要对一个R 闭包进行求值。R必须匹配参数、建立字节码调用帧、创建调用环境并在到达内部向量构造函数之前对包装器进行求值。

这些额外的工作会直接在分析结果中体现。对于 logical(0),有些函数要么更加耗时,要么只有在特定场景才会显著出现,包括:

Rf_matchArgs_NR
setup_bcframe_call
CONS_NR
SETCAR
Rf_begincontext
Rf_NewEnvironment
make_applyClosure_env

as.logical(),另一方面,避免了调用R 闭包所需的环境创建与设置成本。

这解释了你在 bench::mark() 输出中的垃圾回收结果。logical(0) 在设置和求值闭包时会创建更多的临时对象,包括调用结构和环境,随后这些对象必须被回收。

补充:使用 valgrind 作为分析器

我在这里使用 valgrind,部分原因是想了解C 语言层和R 语言层在做什么,同时因为时间差如此微小(每秒约800万次迭代!),我对 [profvis] 能否处理它不太有信心。下面是我非常快速粗暴的做法——前提是你已经安装了 [Docker]。

# create a (self-destroying) container
docker run -it --rm --security-opt seccomp=unconfined --name r-callgrind rocker/r-base bash

# then inside the container install Valgrind and the debug symbol package
echo "deb http://deb.debian.org/debian-debug unstable-debug main" > /etc/apt/sources.list.d/debug.list
apt-get update
apt-get install -y valgrind r-base-core-dbgsym

# run the profiling
valgrind --tool=callgrind --trace-children=yes --callgrind-out-file=callgrind-logicalzero.out Rscript --vanilla -e "for(i in 1:1e7) logical(0)"
valgrind --tool=callgrind --trace-children=yes --callgrind-out-file=callgrind.as.logical.out Rscript --vanilla -e "for(i in 1:1e7) as.logical()"

# to get the results in a nice format with only the lines we want
callgrind_annotate  --auto=no  --inclusive=no callgrind-logicalzero.out | awk '/file:function/{flag=1; getline; next} flag' > logical-zero.txt
callgrind_annotate  --auto=no  --inclusive=no callgrind.as.logical.out | awk '/file:function/{flag=1; getline; next} flag' > as-logical.txt

然后在R 中将输出放入数据框(在容器仍在运行时执行):

# Extract the formatted files from the container
commands <- c("logical-zero", "as-logical")
sprintf("docker cp r-callgrind:/%s.txt .", commands)  |> 
    sapply(system) # you can also run this in bash rather than R

read_callgrind <- function(command) {
    path <- sprintf("%s.txt", command)
    # horrible regex to match lines that look like
    # 5,635,442,218 (17.74%)  ./src/main/./src/main/eval.c:bcEval_loop [/usr/lib/R/lib/libR.so]
    pattern <- "^\\s*([0-9,]+)\\s+\\(\\s*([0-9.]+)%\\)\\s+(.+?):([^[:space:]]+)"
    proto <- data.frame(
        instructions = character(),
        share = numeric(),
        source = character(),
        function_name = character()
    )

    strcapture(pattern, readLines(path), proto)  |> 
    dplyr::mutate(
        instructions = as.numeric(gsub(",", "", instructions)),
    )
}

results <- lapply(setNames(commands, commands), read_callgrind)  |> 
    dplyr::bind_rows(.id = "benchmark")

# This gives you something like:
#   benchmark    instructions share source                           function_name
#   <chr>               <dbl> <dbl> <chr>                            <chr>        
# 1 logical-zero   5635442218 17.7  ./src/main/./src/main/eval.c     bcEval_loop  
# 2 logical-zero   2278455493  7.17 ./src/main/./src/main/match.c    Rf_matchArgs…
# 3 logical-zero   2204920217  6.94 /build/reproducible-path/glibc-… __strcmp_avx2
# 4 logical-zero   1734012789  5.46 ./src/main/./src/main/eval.c     setup_bcfram…
# 5 logical-zero   1405204563  4.42 ./src/main/./src/main/memory.c   CONS_NR      
# 6 logical-zero   1260649520  3.97 ./src/main/./src/main/util.c     Rf_str2type

我想,对于对R 内部工作原理感兴趣并走到这一步的人,将会非常熟练地对数据进行拆分与分析。

站内所有文章版权归属LeftHeroAI导航站,无授权禁止任何主体转载、抄袭、复制内容,亦不得私自架设镜像站点。一经侵权,本站将通过法律途径追责。

相关文章