包含从其他 Rcpp 包导出的代码时性能下降

Performance drop when including code exported from other Rcpp package

我最近创建了一个包,想在新包中回收我为它编写的许多 "under-the-hood" 函数。但是,在第一次尝试中,我发现将 cpp 代码导入新包时性能会显着下降。我会在下面澄清。

我有 package1,是通过 RcppArmadillo::RcppArmadillo.package.skeleton() 创建的。该包的唯一源文件是 package1/src/shared.cpp,其中包含一个使用 RcppArmadillo 计算矩阵列总和的函数。因此 shared.cpp 的源代码是:

//[[Rcpp::depends(RcppArmadillo)]]
//[[Rcpp::interfaces(r, cpp)]]

#include "RcppArmadillo.h"

// [[Rcpp::export]]
arma::vec col_sums(const arma::mat& matty){
  return arma::sum(matty, 0).t();
}

现在假设我想在另一个名为 package2 的包中回收此功能。为此,我在 DESCRIPTION 中编辑 ImportsLinkingTo,添加 package1。然后,这个新包的唯一源文件是 package2/src/testimport.cpp

//[[Rcpp::depends(RcppArmadillo, package1)]]

#include "RcppArmadillo.h"
#include "package1.h"

//[[Rcpp::export]]
arma::vec col_sums(const arma::mat& test){
  return arma::sum(test,0).t();
}

//[[Rcpp::export]]
arma::vec col_sums_imported(const arma::mat& test){
  return package1::col_sums(test);
}

现在,如果我编译这两个包,并对 3+1 函数进行基准测试,我得到

library(magrittr)
library(rbenchmark)

nr <- 100
p <- 800

testmat <- rnorm(nr * p) %>% matrix(ncol=p)

benchmark(package2::col_sums(testmat),
          package2::col_sums_imported(testmat), 
          colSums(testmat),
          package1::col_sums(testmat),
          replications=1000)

我希望 package1::col_sumspackage2::col_sums 之间没有任何区别,但两者与 package2::col_sums_imported 之间的区别很小或很小,这称为 [=16] =] 来自 package2 使用 cpp 接口。

相反,我得到了(我还添加了 R 的 colSums 进行比较)

                                  test replications elapsed relative user.self sys.self user.child sys.child
3                     colSums(testmat)         1000   0.050    1.429     0.052    0.000          0         0
4          package1::col_sums(testmat)         1000   0.035    1.000     0.036    0.000          0         0
1        package2::col_sums(testmat)         1000   0.038    1.086     0.036    0.000          0         0
2 package2::col_sums_imported(testmat)         1000   0.214    6.114     0.100    0.108          0         0

这个6x慢下来让我很困惑,因为我没想到会有这么大的差异。将 "shared" 函数的源代码复制到新包是否更可取,为什么?我觉得 col_sums 只有一个来源可以让我更轻松地在两个包之间传播更改。还是有其他原因导致我的代码速度如此之慢?

编辑: 除了下面@duckmayr 的回答之外,我还更新了我的最小 github 包示例,以展示如何在 package1,导出到其他包,导入到package2。代码可以在 https://github.com/mkln/rcppeztest

找到

我想到三件事:

  1. rbenchmark 做一个 "warm up" 循环吗?如果不是,那么 package1::col_sums 的第一次调用就要付出 calling an R function 的代价。这可能占系统时间的 0.1 秒。

  2. 函数returns一个犰狳对象。但是当通过 R 调用时,必须将其转换为 R 对象和 back。我不确定这些转换的轻量级,或者在(某些)情况下是否制作了数据副本。

  3. 功能可能太简单了。每个函数调用的执行时间约为 36 µs。通过 R 执行此操作似乎会增加一些显着的开销。

总的来说,如果你想分享这么短的 运行 函数,你应该将它们转换成 "header only" 并放在 inst/include/ 中,如 F. Privé 中的建议评论。但是,您将只共享源代码而不共享目标代码,即当 package1 中的函数更改时,必须重新编译 package2

我很好奇调用通过 R 导出的函数的效率如何。因此我在示例包中添加了一个简单的测试函数:

//[[Rcpp::interfaces(r, cpp)]]
#include <thread>
#include <chrono>

#include <Rcpp.h>

// [[Rcpp::export]]
int mysleep(int msec) {
  std::this_thread::sleep_for (std::chrono::microseconds(msec));
  return msec;
}

然后我比较了直接或间接调用此函数作为导出函数的睡眠时间 50、500 和 5000 微秒。 bench::mark:

报告的中位执行时间
            50µs  500µs     5ms   mem_alloc
direct     153µs  688µs  5.37ms      2.47KB 
indirect   163µs  705µs  5.39ms      4.95KB

对我来说,这看起来好像间接调用这样一个简单的函数只会在这台相当慢的机器上增加几 10µs 的开销。但是,我们已经看到分配的内存量增加了一倍。如果我们看一下你的函数 returns 一个更复杂的结构,我们得到:

  expression   min  mean median      max `itr/sec` mem_alloc  n_gc n_itr
  <chr>      <bch> <bch> <bch:> <bch:tm>     <dbl> <bch:byt> <dbl> <int>
1 direct     141µs 148µs  145µs 830.14µs     6737.    10.4KB     0  3342
2 imported   344µs 703µs  832µs   1.17ms     1423.   644.2KB     7   628 

间接调用分配的内存量增加了60多倍!对我来说,这解释了性能下降。

如其他人所述,允许其他包从 C++ 调用您的 C++ 代码需要使用 inst/include/ 中的 header 个文件。 Rcpp::interfaces 允许您自动创建此类文件。但是,正如我在下面演示的那样,手动创建您自己的 header 可以缩短执行时间。我相信这是因为依靠 Rcpp::interfaces 为您创建 header 可能会导致更复杂的 header 代码。

在我进一步演示可以缩短执行时间的 "simpler" 方法之前,我需要注意,虽然这对我有用(而且我已经使用了我将在下面演示的方法多次,没有问题),Rcpp::interfaces 采取的 "complex" 方法部分用于与 Section 5.4.3. of the Writing R Extensions manual 中的语句一致。 (具体来说,与 R_GetCCallable 有关的位您将在下面看到)。因此,使用我在下面提供的代码来缩短执行时间,后果自负。1,2

一个简单的 header 共享 col_sums 的代码可能如下所示:

#ifndef RCPP_package3
#define RCPP_package3

#include <RcppArmadillo.h>

namespace package3 {
    inline arma::vec col_sums(const arma::mat& test){
      return arma::sum(test,0).t();
    }
}

#endif

然而,Rcpp::interfaces 创建的 header 看起来像这样:

// Generated by using Rcpp::compileAttributes() -> do not edit by hand
// Generator token: 10BE3573-1514-4C36-9D1C-5A225CD40393

#ifndef RCPP_package1_RCPPEXPORTS_H_GEN_
#define RCPP_package1_RCPPEXPORTS_H_GEN_

#include <RcppArmadillo.h>
#include <Rcpp.h>

namespace package1 {

    using namespace Rcpp;

    namespace {
        void validateSignature(const char* sig) {
            Rcpp::Function require = Rcpp::Environment::base_env()["require"];
            require("package1", Rcpp::Named("quietly") = true);
            typedef int(*Ptr_validate)(const char*);
            static Ptr_validate p_validate = (Ptr_validate)
                R_GetCCallable("package1", "_package1_RcppExport_validate");
            if (!p_validate(sig)) {
                throw Rcpp::function_not_exported(
                    "C++ function with signature '" + std::string(sig) + "' not found in package1");
            }
        }
    }

    inline arma::vec col_sums(const arma::mat& matty) {
        typedef SEXP(*Ptr_col_sums)(SEXP);
        static Ptr_col_sums p_col_sums = NULL;
        if (p_col_sums == NULL) {
            validateSignature("arma::vec(*col_sums)(const arma::mat&)");
            p_col_sums = (Ptr_col_sums)R_GetCCallable("package1", "_package1_col_sums");
        }
        RObject rcpp_result_gen;
        {
            RNGScope RCPP_rngScope_gen;
            rcpp_result_gen = p_col_sums(Shield<SEXP>(Rcpp::wrap(matty)));
        }
        if (rcpp_result_gen.inherits("interrupted-error"))
            throw Rcpp::internal::InterruptedException();
        if (Rcpp::internal::isLongjumpSentinel(rcpp_result_gen))
            throw Rcpp::LongjumpException(rcpp_result_gen);
        if (rcpp_result_gen.inherits("try-error"))
            throw Rcpp::exception(Rcpp::as<std::string>(rcpp_result_gen).c_str());
        return Rcpp::as<arma::vec >(rcpp_result_gen);
    }

}

#endif // RCPP_package1_RCPPEXPORTS_H_GEN_

所以,我通过

创建了两个额外的包
library(RcppArmadillo)
RcppArmadillo.package.skeleton(name = "package3", example_code = FALSE)
RcppArmadillo.package.skeleton(name = "package4", example_code = FALSE)

然后在package3/inst/include中,我添加了包含上面"simple header"代码的package3.h(我还在src/中添加了一个throwaway "Hello World" cpp文件)。在 package4/src/ 中,我添加了以下内容:

#include <package3.h>

// [[Rcpp::export]]
arma::vec col_sums(const arma::mat& test){
  return arma::sum(test,0).t();
}

// [[Rcpp::export]]
arma::vec simple_header_import(const arma::mat& test){
  return package3::col_sums(test);
}

以及在 DESCRIPTION 文件中将 package3 添加到 LinkingTo

然后,在安装新软件包后,我对所有功能进行了基准测试:

library(rbenchmark)

set.seed(1)
nr <- 100
p <- 800
testmat <- matrix(rnorm(nr * p), ncol = p)

benchmark(original = package1::col_sums(testmat),
          first_copy = package2::col_sums(testmat),
          complicated_import = package2::col_sums_imported(testmat),
          second_copy = package4::col_sums(testmat),
          simple_import = package4::simple_header_import(testmat),
          replications = 1e3,
          columns = c("test", "relative", "elapsed", "user.self", "sys.self"),
          order = "relative")


                test relative elapsed user.self sys.self
2         first_copy    1.000   0.174     0.174    0.000
4        second_copy    1.000   0.174     0.173    0.000
5      simple_import    1.000   0.174     0.174    0.000
1           original    1.126   0.196     0.197    0.000
3 complicated_import    6.690   1.164     0.544    0.613

虽然 more "complicated" header 函数慢了 6 倍,但 "simpler" 却没有。


1.但是,Rcpp::interfaces 生成的自动代码确实包含一些除了 R_GetCCallable 问题之外对您来说可能多余的功能,尽管它们可能是您想要的并且在某些其他情况下是必要的。

2。注册函数始终是可移植的,编写 R 扩展手册指示包作者这样做,但 internal/organizational/etc。使用 我相信如果涉及的所有包都是从源代码构建的,这里介绍的方法应该有效。有关讨论以及上面链接的编写 R 扩展手册的部分,请参阅 this section of Hadley Wickham's R Packages