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

问题描述 投票:4回答:2

我最近创建了一个包,并希望回收我在新包中为它编写的许多“引擎盖下”函数。但是,在第一次尝试中,我发现在将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中编辑Imports和LinkingTo,添加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之间的差异很小或者很小,它们使用cpp接口从package1::col_sums调用package2

相反,我得到了(我也添加了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

这个6倍减速让我困惑,因为我没想到会有这样的差异。将“共享”函数的来源复制到新包中是否优先,为什么?我觉得只有一个col_sums源可以让我更容易地在两个包中传播更改。或者还有另外一个原因导致我的代码变慢了吗?

编辑:除了@ duckmayr的答案之外,我还更新了我的最小github包示例,以显示如何在package1中使用用户创建的函数,导出到其他包,导入到package2。代码可以在https://github.com/mkln/rcppeztest找到

r rcpp
2个回答
3
投票

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

在我进一步展示一个“更简单”的方法以缩短执行时间之前,我需要注意的是,虽然这对我有效(并且我已经使用过这种方法,我将在下面多次演示而没有问题),但是“更复杂” Rcpp::interfaces采用的方法部分用于与Section 5.4.3. of the Writing R Extensions manual中的陈述相符。 (具体来说,与R_GetCCallable有关的位你会在下面看到)。因此,使用我提供的代码来改善您的执行时间,这是您自己的危险.1,2

用于共享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创建的标题如下所示:

// 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中,我添加了包含上面“简单标题”代码的package3.h(我还在src/中添加了一个一次性的“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);
}

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

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

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

虽然更“复杂”的标题功能慢了6倍,但“更简单”的标题功能却没有。


1.然而,Rcpp::interfaces生成的自动代码确实包含了一些在R_GetCCallable问题旁边可能是多余的功能,尽管它们可能是可取的,并且在其他一些必要的上下文中。

2.注册函数始终是可移植的,并且通过Writing R Extensions手册指示包作者这样做,但是对于内部/组织/等。使用我相信如果涉及的所有包都是从源构建的,那么这里介绍的方法应该有用。有关讨论,请参阅this section of Hadley Wickham's R Packages以及上面链接的Writing R Extensions手册部分。


2
投票

我想到了三件事:

  1. rbenchmark做了“热身”周期吗?如果没有,那么package1::col_sums的第一次调用就是支付calling an R function的价格。这可能占系统时间的0.1秒。
  2. 该函数返回一个Armadillo对象。但是当通过R调用时,必须将其转换为R对象和back。我不确定这些转换的重量是多么轻,或者是否在(某些)情况下制作了数据副本。
  3. 功能可能很简单。每个函数调用的执行时间约为36μs。似乎有理由通过R做这件事会增加一些重要的开销。

总的来说,如果你想分享这样的短期运行函数,你应该将它们转换为“仅标题”并将它们放在inst/include/中,正如F. Privé在评论中所建议的那样。但是,您只会以这种方式共享源代码而不是目标代码,即当package2中的函数发生更改时,必须重新编译package1

我很想知道调用通过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μs。 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

对我来说,这看起来好像调用这么简单的函数间接地在这个相当慢的机器上增加了几十个开销。但是,我们已经看到分配的内存量翻了一番。如果我们查看返回更复杂结构的函数,我们得到:

  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倍!对我而言,这解释了性能下降。

© www.soinside.com 2019 - 2024. All rights reserved.