【问题标题】:Fast pnorm() computation on really long vector (of length ~1e+7 to ~1e+8)对非常长的向量(长度 ~1e+7 到 ~1e+8)进行快速 pnorm() 计算
【发布时间】:2016-06-24 08:17:17
【问题描述】:

有没有办法优化pnorm?我的代码遇到了一些瓶颈,经过大量优化和基准测试后,我意识到它来自于在非常大的向量上调用 pnorm

使用microbenchmarking 我在我的机器上发现如果length(u) ~ 5e+7 然后pnorm(u) 需要11 秒。

这里有没有办法使用Rcpp,还是内置的pnorm已经优化了?

欢迎任何想法。

我在 SO:Use pnorm from Rmath.h with RcppHow can I use qnorm on Rcpp? 上找到了这些帖子。但据我了解,他们的目的是将 R 函数用于 Cpp 代码。

【问题讨论】:

  • 根据您的用例和您对准确性的需求,您可能能够 (1) 使用足够但更快的近似值 pnorm (2) “memoise”,即将一些结果存储在查找中桌子。例如pnorm 的老式近似值是 plogis,具有适当匹配的方差 ...
  • 例如this 看起来很有用...
  • 我猜近似值是个好主意,尤其是在@ZheyuanLi 回复之后。
  • @ZheyuanLi 如果我先使用round(u, digits=3),然后使用pnorm(unique(u)),那么重新计算原始sum(pnorm(u)) 的有效方法是什么?
  • @ZheyuanLi 我想到了与rle(sort(... 相同的技巧,显然更快,但是准备数据的计算开销最终使其更长。在您的示例中,微基准测试提供的 x20 不仅仅是 pnorm

标签: c++ r random


【解决方案1】:

我有点惊讶in this answer 显示的线性内插如此缓慢。一个解决方案是改用this package(由 Yixuan Qiu 创建并由我更新)来进行插值。可以通过调用来安装:

remotes::install_github("boennecd/fastncdf")

旧答案的更新版本

以下是我的旧答案以及使用 fastncdf 包的新近似值。

这里有没有办法使用Rcpp,还是内置的pnorm已经优化了?

您可以使用in this answer 之类的近似值。但是,至少在我的机器上,pnorm 似乎确实受益于并行计算。这是一个使用 OpenMP 的示例:

#include <Rcpp.h>
#include <Rmath.h>
#include <cmath>

// [[Rcpp::plugins(openmp)]]
#ifdef _OPENMP
#include <omp.h>
#endif

/**
 * evaluates the standard normal CDF after avoiding some checks in the
 * original version. Use with care!
 */
inline double pnorm_std(double const x, int lower, int is_log) {
  if(std::isinf(x) || std::isnan(x))
    return NAN;

  double p, cp;
  p = x;
  Rf_pnorm_both(x, &p, &cp, lower ? 0 : 1, is_log);
  return lower ? p : cp;
}

/** calls pnorm_std potentially in parallel. */
// [[Rcpp::export(rng = false)]]
Rcpp::NumericVector pnorm_std(Rcpp::NumericVector x,
                              unsigned const n_threads = 1){
  R_len_t const n = x.size();
  Rcpp::NumericVector out(n);
  double const * const xb = &x[0];
  double * const ob = &out[0];

#ifdef _OPENMP
#pragma omp parallel for num_threads(n_threads) schedule(static)
#endif
  for(R_len_t i = 0; i < n; ++i)
    *(ob + i) = pnorm_std(*(xb + i), 1L, 0L);

  return out;
}

使用上面的Rcpp::sourceCpp,我们现在可以比较计算时间和精度/我们得到的结果是一样的:

# simulate data
set.seed(1)
u <- rnorm(1e7)

# assign function to compare with from other answer
approx_pnorm <- function(u, h = 0.2) {
  x <- seq(from = -4, to = 4, by = h)
  approx(x, pnorm(x), yleft = 0, yright = 1, xout = u)$y
}


# check times and results. First using the new interpolation method
library(fastncdf)
system.time(lin_itr <- fastpnorm(u))
#R>    user  system elapsed 
#R>   0.068   0.016   0.084 

# w/ pre-allocated vector
dum <- rep(0., length(u))
system.time(fastpnorm_preallocated(u, p = dum))
all.equal(lin_itr, dum)
#R>    user  system elapsed 
#R>   0.058   0.000   0.058 

# then as in the original answer
system.time(truth <- pnorm(u))
#R>    user  system elapsed 
#R>   0.368   0.008   0.376 
system.time(mini_one <- pnorm_std(u, 1L))
#R>    user  system elapsed 
#R>   0.265   0.016   0.281
system.time(mini_six <- pnorm_std(u, 4L))
#R>    user  system elapsed 
#R>   0.265   0.024   0.092 
system.time(other_ans <- approx_pnorm(u, h = 0.2))
#R>    user  system elapsed 
#R>   0.272   0.004   0.277 

# are the results identical?
all.equal(mini_one, truth)
#R> [1] TRUE
all.equal(other_ans, truth)
#R> [1] "Mean relative difference: 0.001062221"
all.equal(lin_itr, truth)
#R> [1] "Mean relative difference: 8.765925e-08"

# what about the times?
bench::mark(`R                ` = pnorm       (u),
            `C++ (1 thread)   ` = pnorm_std   (u, 1L),
            `C++ (2 threads)  ` =  pnorm_std  (u, 2L),
            `C++ (4 threads)  ` =  pnorm_std  (u, 4L),
            `C++ (6 threads)  ` =  pnorm_std  (u, 6L),
            `Other answer     ` = approx_pnorm(u, h = 0.2),
            `C++ interpolation` = fastpnorm   (u),
            min_time = 10, relative = TRUE, check = FALSE)
#R> # A tibble: 7 x 13
#R>   expression          min median `itr/sec` mem_alloc `gc/sec` n_itr  n_gc total_time result memory                  time           gc                
#R>   <bch:expr>        <dbl>  <dbl>     <dbl>     <dbl>    <dbl> <int> <dbl>   <bch:tm> <list> <list>                  <list>         <list>            
#R> 1 R                  5.23   5.01      1         1        1.33    21     6      7.84s <NULL> <Rprofmem[,3] [1 × 3]>  <bch:tm [27]>  <tibble [27 × 3]> 
#R> 2 C++ (1 thread)     3.96   3.82      1.31      1        1.53    28     7      7.95s <NULL> <Rprofmem[,3] [1 × 3]>  <bch:tm [35]>  <tibble [35 × 3]> 
#R> 3 C++ (2 threads)    2.18   2.10      2.39      1        2.06    54    10      8.43s <NULL> <Rprofmem[,3] [1 × 3]>  <bch:tm [64]>  <tibble [64 × 3]> 
#R> 4 C++ (4 threads)    1.29   1.26      3.98      1        2.62    92    13      8.63s <NULL> <Rprofmem[,3] [1 × 3]>  <bch:tm [105]> <tibble [105 × 3]>
#R> 5 C++ (6 threads)    1      1         5.01      1        3.48   114    17      8.49s <NULL> <Rprofmem[,3] [1 × 3]>  <bch:tm [131]> <tibble [131 × 3]>
#R> 6 Other answer       3.86   3.76      1.33      1.00     1       31     5      8.68s <NULL> <Rprofmem[,3] [11 × 3]> <bch:tm [36]>  <tibble [36 × 3]> 
#R> 7 C++ interpolation  1.13   1.08      4.61      1        3.07   105    15      8.49s <NULL> <Rprofmem[,3] [1 × 3]>  <bch:tm [120]> <tibble [120 × 3]>

在我的六核机器上使用六个线程将计算时间减少了五倍。但很明显,它不是线性扩展的。此外,我之前引用的答案并没有减少 7000 万个变量,也不是那么精确(我希望我没有犯错误)。来自 fastncdf 的新 C++ 版本几乎与使用 Rcpp 解决方案使用六个线程一样快。

【讨论】:

  • fastncdfpnorm 慢两倍。
  • Github 页面上的示例显示该软件包的速度几乎快了 10 倍。你能举一个不正确的例子吗?
【解决方案2】:

在本次会议中,我将演示对pnorm() 的快速而准确的近似。

在开始之前,我们需要明确:我们想通过使用近似来实现什么?效率/速度/性能,对吧?但这样的效率从何而来?

如上所述,pnorm() 计算受内存限制;即使我们进行近似计算,它仍然受内存限制(因此我们不考虑进一步并行化)。内存绑定问题有

number of floating point operations : memory access = O(1)

换句话说,这个比率是一个常数C。所以我们的目标应该是减少这个常数,即我们要减少浮点运算。

浮点运算的次数通常报告为浮点加法和乘法的次数。其他类型的浮点运算被“转换”为这种度量。现在,我们来比较几种常见的浮点运算的成本。

u <- sample(1:10/10, 5e+7, replace = TRUE)

system.time(u + u)
#  user  system elapsed 
# 0.468   0.168   0.639 
system.time(u * u)
#  user  system elapsed 
# 0.424   0.212   0.638 
system.time(u / u)
#  user  system elapsed 
# 0.504   0.204   0.710 
system.time(u ^ 1.1)
#  user  system elapsed 
# 7.240   0.212   7.458 
system.time(sqrt(u))
#  user  system elapsed 
# 2.044   0.176   2.224 
system.time(exp(u))
#  user  system elapsed 
# 4.336   0.208   4.550 
system.time(log(u))
#  user  system elapsed 
# 2.748   0.172   2.925 
system.time(round(u))
#  user  system elapsed 
# 6.836   0.188   7.034 

请注意,加法和乘法很便宜,根和对数更昂贵,而一些运算非常昂贵,包括幂、指数和舍入。

现在让我们回到pnorm(),甚至dnorm() 等,我们需要计算一个指数项。鉴于:

system.time(pnorm(u))
#   user  system elapsed 
# 11.016   0.160  11.193 
system.time(dnorm(u))
#  user  system elapsed 
# 8.844   0.164   9.022 

我们看到计算pnorm()dnorm() 的大部分时间都归因于计算指数。 pnorm()dnorm() 需要更长的时间,因为它还有一个积分!

现在,我们的目标相当明确:我们想用非常便宜的东西替换昂贵的pnorm() 评估,理想情况下只涉及加法/乘法。我们可以吗??

历史上有很多近似方法。 @Ben 提到了逻辑近似。 R 函数plogis() 执行此操作。但仔细阅读?plogis 会发现它仍然是基于指数的。

现在,我们可以使用非参数逼近来代替那些参数逼近吗?但我们不应该在这里做回归;相反,我们想使用一些高分辨率精确数据的插值函数来预测pnorm()。好吧,线性插值是最好的选择,因为它非常有效(由于稀疏性:线性预测矩阵是三对角矩阵)。在 R 中,approx 执行此操作。我将不熟悉的读者推荐给?approx,我将继续进行。

OP 说他只需要标准正态分布,所以我们专注于此。考虑以下近似函数(我没有使用approxfun,因为我想要自定义h):

approx.pnorm <- function(u, h = 0.2) {
  x <- seq(from = -4, to = 4, by = h)
  approx(x, pnorm(x), yleft = 0, yright = 1, xout = u)$y
  }

准确的数据是在[-4, 4] 之间的分辨率为h 的网格上获取的。 -4 以下的预测为 0,4 以上的预测为 1。这满足了 CDF 的要求。给定新值u,我们根据已知准确数据通过线性插值近似pnorm(u)

显然,分辨率h 控制着准确性。考虑以下函数来计算 RMSE 并显示近似曲线:

RMSEh <- function(h) {
  x <- sort(rnorm(1000))
  y <- pnorm(x)
  y1 <- approx.pnorm(x, h)
  plot(x, y, type = "l", lwd = 2); lines(x, y1, col = 2, lwd = 2)
  mean((y - y1) ^ 2)^0.5
  }

par(mfrow = c(1, 3))
RMSEh(1)  # 0.01570339
RMSEh(0.5)  # 0.003968882
RMSEh(0.2)  # 0.000639888

其实h = 0.2的时候,逼近已经相当不错了。所以下面我们将使用h = 0.2


基准测试

这应该是最激动人心的部分。在上面我们已经看到pnorm(u) 的准确计算需要 11 秒。现在

system.time(approx.pnorm(u, h = 0.2))
#  user  system elapsed 
# 2.656   0.172   2.833 

哇,我们快了将近 4 倍!!

【讨论】:

  • 方法很有趣,但使用microbenchmark 两者的数量级相同,即 3.≈ 秒:&gt; microbenchmark::microbenchmark(pnorm(u), approx.pnorm(u)) Unit: seconds expr min lq mean median uq max neval pnorm(u) 2.966394 3.149768 3.571884 3.326649 3.696174 6.855846 100 approx.pnorm(u) 2.730123 2.890056 3.225221 3.012693 3.197080 6.546609 100
  • 确实和你的有很大不同:&gt; system.time(u + u) 0.178 0.787 1.290 &gt; system.time(u * u) 0.140 0.148 0.312 &gt; system.time(u / u) 0.466 0.139 0.611 &gt; system.time(u ^ 1.1) 2.303 0.145 2.460 &gt; system.time(sqrt(u)) 0.448 0.157 0.767 &gt; system.time(exp(u)) 0.709 0.163 0.918 &gt; system.time(log(u)) 0.849 0.146 1.006 &gt; system.time(round(u)) 1.015 0.140 1.160
  • 无论如何,我想这种方法对于我可能遇到的其他问题肯定要牢记
  • 终于在另一台电脑上实现了这个解决方案,谢谢!
  • 用于与您的性能相同的计算机上;我正在与伙伴分享我的代码
【解决方案3】:

我不是来让你失望的,但是pnorm 已经优化了。如果您在 R 控制台中键入“pnorm”,您会看到它是如何编写的:

function (q, mean = 0, sd = 1, lower.tail = TRUE, log.p = FALSE) 
.Call(C_pnorm, q, mean, sd, lower.tail, log.p)
<bytecode: 0x98712e0>
<environment: namespace:stats>

它已经用 C 语言编写(参见 Rmath.h)。

然后有些人可能会建议您进行并行计算。例如,R 级并行性可以使用 parallel 包中的 mclapply / parLapply / parSapply 函数。但是你是否应该这样做取决于你有什么硬件。

在简单的多核机器上并行化pnorm() 是个坏主意,因为它是内存绑定。 CPU 计算和内存引用之间的比率是O(1)(使用大的O 表示法)。此外,R 级并行不是线程级并行,而是通过设置独立的 R 进程来实现的。这意味着并行开销更大,不易摊销。

如果你有一个集群,你可以在不同的节点上对非常大的问题进行并行计算。您将获得良好的并行可扩展性。


进一步说明并行处理

假设u 是一个长向量:u[1], u[2], ....,我们的目标是计算pnorm(u)。每个元素u[i] 仅从 RAM 带到 CPU 一次,无需再次使用。因此,pnorm() 的计算需要读取常量数据。

现在考虑一台具有 4 个物理 CPU 的多核机器(即,每个都有非共享的执行单元,如寄存器、ALU、FPU、L1 缓存等)。我们设置了 4 个线程或进程,希望在 4 个不同的 u 块上运行 4 个并行的 pnorm() 计算。在计算过程中,每个 CPU 都“需要数据”,并要求不断的数据流。但是,只有一个公共汽车。如果一个 CPU 占用总线,其余三个 CPU 的数据流将被切断,因此它们无事可做。也就是说,这 4 个 CPU 几乎不可能同时工作,而且它们并不比单 CPU 计算好。

现在我们转移到集群上的 4 个节点。初始数据拆分到 4 个不同的节点后,每个节点将工作在单 CPU 模式下。 4个节点之间既没有共享的执行资源,也没有内存资源。它们可以完全并行工作。最后,来自 4 个节点的结果被伪造在一起。这样,对于非常大的问题,可以保证良好/合理的可扩展性。

多核机器上的并行计算只对CPU-bound任务有用(在某种程度上,在总线饱和之前)。具体来说,我们应该使用块算法进行 L1 缓存。缓存实现了可观的数据重用。例如,对于块大小nb 的块矩阵乘法,CPU 工作与内存读取之间的比率为O(nb)。这样,CPU 将一个数据块读入其专有的 L1 缓存后,在较长的一段时间内(以 CPU 周期为单位),它不需要访问 RAM,因此总线变得空闲。然后其他核心可以利用这种间隙/中断来读取他们需要的数据。由于只使用了有限数量的 CPU,它们可以以交错的方式工作而不会相互干扰。

【讨论】:

  • 不错的答案。你怎么知道pnorm() 是内存绑定的……?
  • 确实很有趣。我确实使用了foreach 包,但无法想象doMC 无法帮助而doMPI 这样做
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2021-02-24
  • 2016-04-30
  • 2012-11-14
  • 2013-05-26
  • 2013-05-20
  • 2013-11-16
  • 1970-01-01
相关资源
最近更新 更多