Question Details

No question body available.

Tags

r

Answers (1)

June 7, 2026 Score: 4 Rep: 23,379 Quality: Medium Completeness: 80%

.Internal() functions have more overhead than .Primitive() ones

logical() is defined as:

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

While as.logical() is a primitive, defined as:

.Primitive("as.logical")

Note that .Internal() is itself a primitive, but it's wrapped by a standard R function - that's the fundamental difference:

is.primitive(logical)

[1] FALSE

is.primitive(as.logical)

[1] TRUE

In the .Internal vs .Primitive section of the R Internals Manual, it says:

Functions using .Internal() wrapped in a closure are in general preferred as this ensures standard handling of named and default arguments... there is some overhead in using the .Internal interface wrapped in a function closure

What is the overhead?

As we're comparing a .Primitive to a .Internal, many differences happen at C level, so I ran the debug version of R, which contains symbol tables, and profiled using 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()"

I then got the list of function calls and percentage of total time using callgrind. Here are the first five lines of both:

Output for as.logical()

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

Output for logical(0)

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

Parsing the output

The complete logical(0) run executed about 31.8 billion instructions, compared with about 9.5 billion for as.logical(). The interesting part is where those additional instructions are spent.

The first notable difference is that, after the loop (bcEvalloop) which accounts for 17.7% of the logical(0) run and 23.3% of the as.logical() run, the next most expensive task for as.logical() is actually allocating the vector (RfallocVector3), whereas for logical(0) that's not even in the top five.

The percentages in the callgrind output are slightly misleading when comparing the two runs because they're proportions of very different totals. In absolute terms, however, about 5.64 billion instructions are executed for logical(0), compared with 2.23 billion for as.logical().

Plotting the absolute instruction counts makes the difference clearer:

Output

As you suspect, RfallocVector3, which is responsible for allocating the resulting vector, accounts for almost exactly the same number of instructions in both runs:

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

Matching arguments isn't free

When you call logical(0), you're providing an argument to the function. This (Rf
matchArgsNR) is the most expensive call, after the loop. There's also the string "logical" in the function body of logical():

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

R converts this to its internal LGLSXP type code by searching a table of type names and comparing each name using the calls to Rf
str2type and
strcmpavx2, which as.logical() doesn't need to do.

Environment creation and destruction is expensive

Calling logical(0) involves evaluating an R closure. R has to match the argument, set up a byte-code call frame, create the call environment and evaluate the wrapper before it reaches the internal vector constructor.

That additional work appears directly in the profile. Functions which are much more expensive, or only appear meaningfully, for logical(0) include:

RfmatchArgsNR
setupbcframecall
CONSNR
SETCAR
Rfbegincontext
RfNewEnvironment
makeapplyClosureenv

as.logical(), on the other hand, avoids the environment creation setup costs needed to call an R closure.

This explains the garbage collection result in your bench::mark() output. logical(0) creates more temporary objects while setting up and evaluating the closure, including call structures and environments, and those objects later have to be collected.

Addendum: using valgrind as a profiler

I used valgrind here partly to get a sense of what was going on in C land as well as R land, and also because these time differences are so tiny I wasn't very confident profvis would be able to handle it. Here's the very quick and dirty way that I did this - it assumes you have Docker installed.

# create a (self-destructing) 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 (reasonably) nice format

callgrindannotate --auto=no --inclusive=no callgrind-logicalzero.out callgrindannotate --auto=no --inclusive=no callgrind.as.logical.out

to copy files out of container before you destroy it do e.g.

docker cp r-callgrind:/callgrind-logicalzero.out . docker cp r-callgrind:/callgrind.as.logical.out .