问
题提出
前几期的大猫课堂中大猫教了大家“10行代码搞定滚动回归”,在那一期的最后大猫说文章中给出的是目前大猫看到的最快的实现方法,“如果有发现更快方法的小伙伴一定要联系大猫”,emmmm……现在看来大猫不得不自己寻找更快的方法了,因为大猫前几天遇到了这样一个需求:需要处理大约2700个股票的120日滚动回归,每次滚动回归包含一个OLS以及一个GARCH拟合。按照平均每个股票7年历史,每年250个交易日来算,那就大约需要完成2700*7*250*2=940万次拟合!这个运算在大猫的i7 3.5G+32G+1T SSD的地球人上似乎要永远运行下去,于是大猫只得乖乖停止进程思考提高运算效率的办法。
问
题分析
原先的程序其实是非常memory efficient的,内存占用不多,关键问题是提高CPU的使用率,最直接的办法就是充分发挥多核CPU的性能。众所周知,绝大多数数据清洗任务都只能单线程运行,不论是R的data.table包还是SAS的data步都是如此,唯一存在提高空间的就是多次重复的回归拟合进行并行计算。经过一番搜索,大猫决定使用doParalle包。doParallel包分别针对Windows平台和Linux/Mac平台进行了多核优化,是目前使用最广泛的并行计算包之一。细心的同学可以在doParallel包的作者一栏中看到Microsoft的名字。既然都能得到微软的承认与支持,还有什么理由不尝试呢?
样
例数据集
我们使用与《10行代码搞定滚动回归》相同的样例数据集,创建过程如下。
# 设置随机数种子
set.seed(42)
# 生成样例数据集,一共有a,b,c,d,e五个group,每个group都有1000日的观测,共5000行观测
dt <- data.table(id = rep(letters[1:5], each = 1000), date = seq(as.Date("2001-01-01"), by = 'day', len = 1000 * 5), y = rnorm(1000 * 5), x = rnorm(1000 * 5)) %>% unique(by = c("id", "date"))
生成后的样例数据集长这个样子:
单
线程版本
《10行代码搞定滚动回归》中给出的非并行计算的代码如下。在下面的代码中,我们运行了一个 y ~ x的OLS回归,最终输出的是回归的系数。
# 设定滚动窗口期,这里为50天
n <- 50
# 计算滚动回归!
re <- dt[, {
l <- list()
for (t in (n + 1) : .N) {
l[[t]] <- as.list(c(coef(lm(y ~ x, data = .SD[(t - n) : t])), date = date[t]))
}
rbindlist(l)
},
keyby = .(id)]
输出数据集大概长这样:
载
入并设置doParalle
为了能够调用多核,我们需要首先根据CPU的核心数来进行设置,下面是大猫在自己4核8线程CPU上的设置代码。
# 载入包
library(doParallel)
# 指定调用的核心数, 即代码中的 “8”
# 注意,一味增加参数并不会提高效率。例如只有双核CPU,但是却设置调用4个核心数,其效率有可能还不如只设置调用2个核心
# 大猫在这里设置的参数是8,因为大猫的CPU有超线程,4核CPU可以模拟8核。如果你的CPU没有超线程,直接根据核心数设置即可
# 在大猫的机器上,核心数从4提高到8只带来小幅提高
cl <= makeCluster(8)
# 注册你的并行计算集群
# 过程中有可能弹出Windows防火墙,确认即可
registerDoParallel(cl)
在
分组回归中调用doParallel
先来看完成后的代码,红色字体即为doParallel独有的代码:
result <- dt[, {
n <- 50
foreach(t = (n + 1):.N, .final = rbindlist) %dopar% {
coef <- coef(lm(y[(t - n):t] ~ x[(t - n):t]))
list(alpha = coef[1], beta = coef[2], date = date[t])
}},
keyby = .(id)
]
语句大体上和非并行版本的地方很像,变动以及需要注意的地方有:
1)n <- 50用来指定滚动窗口。注意,不能把这行代码放到大括号外面!也就是说,n不能作为全局变量!这是因为doParalle不知怎么的无法搜索到全局变量。大猫为此蹭抓狂四十分钟才发现这个蛋疼的地方。
2)原有的for循环变成了foreach循环。foreach循环是doParallel的专有语法,作用和for很像
3)%dopar% 说明接下来的运算需要调动多核并行计算。如果改为 %do%,那么则使用单核,因而 %do% 适合用来作为评估多核性能的benchmark。
4. .final 参数。这个参数的值必须是一个函数,这个函数用来对最终foreach生成的list进行处理,在文中我们设置值为 rbindlist,也即我们要求doParallel将最终的输出的list合并成一个data.table。如果不加这个参数,最终输出的是原始list格式,不符合要求。
5. 在并行计算的版本中,我们省略了 l <- list()以及 l[[t]] <- 这两行。这是因为foreach函数默认情况下生成的就是一个list,不需要我们再手动生成。
性
性能比较
使用 %dopar% (并行)
> system.time({
+ result <- dt[, {
+ n <- 50
+ foreach(t = (n + 1):.N, .final = rbindlist) %dopar% {
+ coef <- coef(lm(y[(t - n):t] ~ x[(t - n):t]))
+ list(alpha = coef[1], beta = coef[2], date = date[t])
+ }},
+ keyby = .(id)
+ ]})
用户 系统 流逝
1.60 0.20 2.32
使用 %do% (单核)
> system.time({
+ result <- dt[, {
+ n <- 50
+ foreach(t = (n + 1):.N, .final = rbindlist) %do% {
+ coef <- coef(lm(y[(t - n):t] ~ x[(t - n):t]))
+ list(alpha = coef[1], beta = coef[2], date = date[t])
+ }},
+ keyby = .(id)
+ ]})
用户 系统 流逝
6.43 0.00 6.49
可以发现,并行计算的版本用时为原来的35%,约为原来的1/3。毕竟对于并行计算来说,无论是CPU多核还是AMD的Crossfire或者Nvidia的SLI,都不可能达到1+1=2的效果。doParalle在大猫的四核CPU上时间节约了2/3,大猫已经很开心啦。