data.table 分组操作的性能

ric*_*lam 6 r data.table

我将首先描述我正在执行的任务。我必须重复计算分组总和,通常为 5 到 10 次。我正在执行分组总和的列中的值随着每次迭代而变化,但我分组所依据的列则不会。下面是一个示例表,其中 w、x 和 y 一起构成分组,z 是将其值求和的列。

DT <- data.table(w = sample(1:10, size  = 1000000, replace = TRUE), 
                 x = sample(1:100, size  = 1000000, replace = TRUE), 
                 y = sample(1:1000, size  = 1000000, replace = TRUE),
                 z = runif(n = 1000000, min = 0, max = 1))

setkey(DT, w, x, y)
Run Code Online (Sandbox Code Playgroud)

我认为我最初的解决方案是最明显的:

DT[, Group_Total := sum(z), keyby = list(w, x, y)]

microbenchmark(DT[, Group_Total := sum(z), keyby = list(w, x, y)])
Unit: milliseconds
min       lq        mean      median    uq        max  neval
447.8674  467.3366  481.1989  479.3057  490.5499  691  100
Run Code Online (Sandbox Code Playgroud)

我发现这个解决方案并没有我希望的那么快,特别是因为我必须多次执行此计算。然而,据我所知,data.table 中没有一种方法可以提供比这更好的性能。最近,我开始尝试 Rccp,并编写了以下函数来执行分组求和:

cppFunction('NumericVector Group_Total(NumericVector Grouping, NumericVector x) {
                          
                          int n = Grouping.size();
                          NumericVector out(n);
                          
                          int curr_start_index = 0;
                          double grp_total = x[0];
                          
                          for(int i = 1; i < (n-1); ++i) {
                          
                            if(Grouping[i] == 1) {
                            
                              for(int j = curr_start_index; j < i; ++j) {
                              
                                out[j] = grp_total;  
                              
                              }
                              
                              curr_start_index = i;
                              grp_total = x[i];
                              
                            } else {
                              
                                grp_total = grp_total + x[i];
                              
                              } 
                              
                            }
                            
                          if(Grouping[(n-1)] == 1) {
                              
                              for(int j = curr_start_index; j < (n-1); ++j) {
                              
                                out[j] = grp_total;  
                              
                              }
                              
                              out[(n-1)] = x[(n-1)];
                                
                            } else {
                              
                                grp_total = grp_total + x[(n-1)];
                                
                                for(int j = curr_start_index; j < n; ++j) {
                                  
                                  out[j] = grp_total;  
                              
                                }
                              
                            }
                              
                        return out;
              }')
Run Code Online (Sandbox Code Playgroud)

为了使用此函数,我在数据排序后计算组内索引列:

DT[, Within_Group_Index := 1][, Within_Group_Index := cumsum(Within_Group_Index), keyby = list(w, x, y)]
Run Code Online (Sandbox Code Playgroud)

由于我只需要计算该列一次,因此增加的时间并不重要。创建此列后,可以通过以下方式计算分组总和:

DT[, Group_Total := Group_Total(Within_Group_Index, z)]

microbenchmark(DT[, Group_Total := Group_Total(Within_Group_Index, z)])
Unit: milliseconds
min     lq      mean      median  uq      max       neval
5.3286  6.9879  9.078267  7.4814  8.0171  161.6406  100
Run Code Online (Sandbox Code Playgroud)

这明显快了很多,现在我们来回答我的问题:为什么?我的理解是分组操作在data.table中非常高效。我可以做些什么来加速 data.table 分组操作,或者这是基本速度与灵活性权衡的一个例子,即在考虑特定用途时,data.table 分组操作的灵活性是以速度为代价的案例?

Ale*_*xis 4

我认为你的怀疑是正确的。\n至于原因,我们可以做出有根据的猜测。

\n

编辑:下面的信息忽略了data.tableGForce 优化的作用,\nGForce 支持的函数可能会避免类似于 C++ 代码的数据副本,但是,\n Frank 提到,\n:=在 1.14.3 之前没有尝试检测 GForce 优化表达式,\naddrs下面的肯定没有经过 GForce 优化。

\n

您编写的 C++ 代码不会修改其输入数据中的任何内容,\n只需要分配out。\n正如您正确所说,\n 的data.table目标是更加灵活,\n并且必须支持用户提供的任何有效的 R 代码。\n让我认为,\n对于分组操作,\n必须为每个子集分配新的 R 向量z根据组索引为每个子集分配新的 R 向量,\n有效地多次复制某些输入。

\n

我想尝试验证我的假设,因此我编写了一些 C++ 代码来查看内存地址:

\n
set.seed(31L)\nDT <- data.table(w = sample(1:3, size  = 20, replace = TRUE), \n                 x = sample(1:3, size  = 20, replace = TRUE), \n                 y = sample(1:3, size  = 20, replace = TRUE),\n                 z = runif(n = 20, min = 0, max = 1))\n\nsetkey(DT, w, x, y)\n\ncppFunction(plugins = "cpp11", includes = "#include <sstream>", '\n    StringVector addrs(NumericVector z, IntegerVector indices, IntegerVector group_ids) {\n        StringVector ans(indices.size());\n        for (auto i = 0; i < indices.size(); ++i) {\n            std::ostringstream oss;\n            oss << "Group" << group_ids[i] << " " << &z[indices[i]];\n            ans[i] = oss.str();\n        }\n        return ans;\n    }\n')\n\nidx <- DT[, .(indices = .I[1L] - 1L, group_ids = .GRP), keyby = list(w, x, y)]\n\nDT[, list(addrs(z, idx$indices, idx$group_ids))]\n#                         V1\n#  1:  Group1 0x55b7733361f8\n#  2:  Group2 0x55b773336200\n#  3:  Group3 0x55b773336210\n#  4:  Group4 0x55b773336220\n#  5:  Group5 0x55b773336228\n#  6:  Group6 0x55b773336230\n#  7:  Group7 0x55b773336238\n#  8:  Group8 0x55b773336248\n#  9:  Group9 0x55b773336250\n# 10: Group10 0x55b773336258\n# 11: Group11 0x55b773336260\n# 12: Group12 0x55b773336270\n# 13: Group13 0x55b773336280\n# 14: Group14 0x55b773336288\n# 15: Group15 0x55b773336290\n
Run Code Online (Sandbox Code Playgroud)\n

正如这里所预期的那样,\n如果我们从整体上看z,\n没有发生任何复制,并且不同元素的地址彼此接近,\n例如0x55b773336200 = 0x55b7733361f8 + 0x8。\n您可以多次执行前面代码中的最后一行,并且它将始终显示相同的地址。

\n

我部分没想到的是:

\n
DT[, list(addrs(z, 0L, .GRP)), keyby = list(w, x, y)]\n#     w x y                     V1\n#  1: 1 1 2  Group1 0x55b771b03440\n#  2: 1 1 3  Group2 0x55b771b03440\n#  3: 1 2 1  Group3 0x55b771b03440\n#  4: 1 2 2  Group4 0x55b771b03440\n#  5: 1 3 2  Group5 0x55b771b03440\n#  6: 1 3 3  Group6 0x55b771b03440\n#  7: 2 1 1  Group7 0x55b771b03440\n#  8: 2 2 1  Group8 0x55b771b03440\n#  9: 2 2 2  Group9 0x55b771b03440\n# 10: 2 2 3 Group10 0x55b771b03440\n# 11: 2 3 1 Group11 0x55b771b03440\n# 12: 3 2 1 Group12 0x55b771b03440\n# 13: 3 2 2 Group13 0x55b771b03440\n# 14: 3 2 3 Group14 0x55b771b03440\n# 15: 3 3 3 Group15 0x55b771b03440\n
Run Code Online (Sandbox Code Playgroud)\n

一方面,\n内存中的地址确实发生了变化,\n因此复制了一些内容,\n如果多次运行此命令,每次都会看到不同的地址。\n但是,似乎以某种方式重用了具有data.table相同地址的缓冲区, \n也许分配一个具有最大组子集长度的数组并将不同的组值复制到其中?\n我想知道他们如何管理这一点。\n或者也许我的代码是错误的\xc2\xaf\\_(\xe3\x83 \x84)_/\xc2\xaf

\n

编辑:我添加了以下内容作为 \n 的第一行addrs(由于 R 解析而进行了双重转义)

\n
Rcout << "input length = " << z.size() << "\\\\n";\n
Run Code Online (Sandbox Code Playgroud)\n

如果我运行DT[...]上面的最后一个代码,即使地址相同,\nit 也会打印不同的长度。

\n

  • 也许常见问题解答 3.1 有助于解释?“仅为最大的组进行一次内存分配,然后该内存将重新用于其他组。需要收集的垃圾非常少。” (5认同)