Want to share your content on R-bloggers? click here if you have a blog, or here if you don't.
Introduction
When learning R, we are always told to vectorise, vectorise, vectorise. This is because R is an interpreted language.
Every time R runs a line of R code, it has to look up each name, match the arguments of each function call and set up
a new environment for each function it enters. A vectorised function such as *, cumsum() or match() does that
once and then loops over the elements in compiled code, where each step is very cheap. A for loop written in R does
it on every iteration.
set.seed(42)
x <- runif(1e5)
loop <- function(x) {
out <- numeric(length(x))
for (i in seq_along(x)) out[i] <- x[i] * 2
out
}
bench::mark(loop = loop(x), vectorised = x * 2)[, c("expression", "median", "mem_alloc")]
# # A tibble: 2 x 3
# expression median mem_alloc
# <bch:expr> <bch:tm> <bch:byt>
# 1 loop 2.22ms 819KB
# 2 vectorised 59.02us 781KB
The loop gives the same answer and uses the same memory, but it is nearly 40 times slower. Bear in mind that
lapply(), vapply() and friends are loops too. They tidy the loop away, but each iteration still calls an R
function, so what matters is how many times we go round and how much work we do each time.
Grouped operations make this advice tricky to follow. mutate(df, y = x - mean(x)) on grouped data means “evaluate
x - mean(x) for each group”, and since the expression can be any R code at all, there is no way around evaluating it
once per group. What we can do is make everything around that loop vectorised, and keep the work inside it to a
minimum.
{poorman} has not always done this. When I first wrote about grouped operations in
{poorman},
they used the classic split-apply-combine approach: split the data.frame into one data.frame per group, run the
verb on each piece and rbind() the results back together. It was simple, but it was slow once there were more than a
few hundred groups. I have since rewritten the grouped verbs, and in this post I want to share the tricks that made the
difference. All of them are plain base R, so you can use them in your own code too.
Split-Apply-Combine Versus Vectorised
To keep things concrete, let’s centre a column within groups, which is what mutate(x = x - mean(x)) does on grouped
data. The split-apply-combine version is how most of us would first write it.
centre_split <- function(df) {
df$row <- seq_len(nrow(df))
pieces <- split(df, df$g)
pieces <- lapply(pieces, function(piece) {
piece$centred <- piece$x - mean(piece$x)
piece
})
res <- do.call(rbind, pieces)
res$centred[order(res$row)]
}
And here is the version {poorman} has ended up with, stripped down to the bare bones.
centre_fast <- function(df) {
ids <- match(df$g, unique(df$g))
f <- structure(ids, levels = as.character(seq_len(max(ids))), class = "factor")
pieces <- split.default(df$x, f)
res <- unlist(lapply(pieces, function(x) x - mean(x)), use.names = FALSE)
rows <- unlist(split.default(seq_along(ids), f), use.names = FALSE)
back <- integer(length(rows))
back[rows] <- seq_along(rows)
res[back]
}
Both still have an lapply() over the groups. The difference is what else happens. centre_split() builds a
data.frame for every group, adds a column to each one and then binds them all back together. centre_fast() only
splits the one column it needs, does nothing in the loop except the calculation itself, and combines the results with
a single unlist().
To time them, I use bench::press(), which runs bench::mark() once for each value of n_groups, on 100,000 rows.
bench::mark() also checks that both functions return the same result.
res <- bench::press(
n_groups = c(10, 1e3, 1e4),
{
set.seed(42)
df <- data.frame(g = sample(n_groups, 1e5, replace = TRUE), x = runif(1e5))
bench::mark(split = centre_split(df), fast = centre_fast(df), iterations = 10L)
},
.quiet = TRUE
)
res[, c("expression", "n_groups", "median", "mem_alloc")]
# # A tibble: 6 x 4
# expression n_groups median mem_alloc
# <bch:expr> <dbl> <bch:tm> <bch:byt>
# 1 split 10 29.62ms 26.1MB
# 2 fast 10 3.76ms 7.57MB
# 3 split 1000 86.71ms 61.97MB
# 4 fast 1000 6.23ms 7.67MB
# 5 split 10000 522.85ms 55.61MB
# 6 fast 10000 26.27ms 6.18MB
centre_fast() is about 8 times faster with 10 groups, 14 times faster with 1,000 groups and 20 times faster with
10,000 groups, and it uses far less memory. Let’s go through the tricks one at a time. For the rest of the post, I’ll
use this data for the timings, with 1,000 groups:
set.seed(42) n <- 1e5 df <- data.frame(g = sample(1e3, n, replace = TRUE), x = runif(n)) g <- df$g
and this small dataset to show what each trick does.
sales <- data.frame(
shop = c("b", "a", "b", "c", "a", "b"),
day = c(1L, 1L, 2L, 1L, 1L, 2L),
amount = c(2, 4, 6, 8, 10, 12)
)
sales
# shop day amount
# 1 b 1 2
# 2 a 1 4
# 3 b 2 6
# 4 c 1 8
# 5 a 1 10
# 6 b 2 12
Tip 1: Number the Groups With match()
The first job is to work out which rows belong to which group. The obvious way is to compare every row with every group.
lapply(unique(sales$shop), function(shop) which(sales$shop == shop)) # [[1]] # [1] 1 3 6 # # [[2]] # [1] 2 5 # # [[3]] # [1] 4
That is n * G comparisons for n rows and G groups, and each comparison allocates a new logical vector the length
of the data. It is much faster to give each row a group number in one go with match().
match(sales$shop, unique(sales$shop)) # [1] 1 2 1 3 2 1
Each value gets the position where it first appears in unique(sales$shop), so the groups are numbered in the order
they appear. Both unique() and match() hand their work to C code inside R itself (you can see the call to
.Internal() if you print match), and that code uses a hash table. A hash table is a lookup table where each value
is turned into a number, its hash, which says where in the table the value is stored. Looking a value up then means
working out its hash and checking that one place, rather than searching the whole table, so it takes about the same
time however many values are stored. unique() goes through the vector once and adds each value it hasn’t seen
before to a table; match() puts the values of unique(sales$shop) in a table and looks up each element of
sales$shop in it. Either way, each row is looked at once, whatever the number of groups.
bench::mark(
compare = lapply(unique(g), function(value) which(g == value)),
match = match(g, unique(g)),
check = FALSE
)[, c("expression", "median", "mem_alloc")]
# # A tibble: 2 x 3
# expression median mem_alloc
# <bch:expr> <bch:tm> <bch:byt>
# 1 compare 162ms 764.87MB
# 2 match 653us 2.16MB
(check = FALSE is needed here because the two give their answers in different forms; Tip 2 turns the group numbers
into the rows of each group.)
With more than one grouping column, we need a number for each combination of values. The original version of
{poorman} used base R’s interaction() for this, which builds a label for each row by pasting the values together
with a ".". Unfortunately, pasting can give two different groups the same label.
interaction(c("a.b", "a"), c("c", "b.c"))
# [1] a.b.c a.b.c
# Levels: a.b.c a.b.b.c a.c
("a.b", "c") and ("a", "b.c") are different groups, but both become a.b.c. interaction() also makes a level
for every possible combination of values, whether or not it appears in the data, which can use a lot of memory with
several columns. Instead, I number each column with match() and combine the numbers with a little arithmetic, a bit
like turning two digits into one number.
shop_id <- match(sales$shop, unique(sales$shop)) day_id <- match(sales$day, unique(sales$day)) combined <- (shop_id - 1) * max(day_id) + day_id combined # [1] 1 3 2 5 3 2 match(combined, unique(combined)) # [1] 1 2 3 4 2 3
Each (shop, day) pair gets its own number, so no two groups can collide. Calling match() again on the combined
number brings the ids back down to 1, 2, ..., G, so they stay small when there are more columns to add. The - 1 is
a double, so the arithmetic is done in doubles and can’t overflow R’s integers.
Tip 2: Build Factors by Hand
Given the group ids, split() gives us the rows of each group. But split() wants a factor, and calling factor(ids)
would sort the unique values and match() every value against them again: work we have already done. The ids are
already the integer codes 1:G, so I attach the levels and class attributes directly with structure() and have a
valid factor at no cost.
ids <- match(combined, unique(combined)) f <- structure(ids, levels = as.character(seq_len(max(ids))), class = "factor") f # [1] 1 2 3 4 2 3 # Levels: 1 2 3 4 split.default(seq_along(ids), f) # $`1` # [1] 1 # # $`2` # [1] 2 5 # # $`3` # [1] 3 6 # # $`4` # [1] 4
On 100,000 rows with 1,000 groups, this is 4 times faster than going through factor().
ids <- match(g, unique(g))
bench::mark(
factor = split(seq_along(ids), factor(ids)),
structure = split(seq_along(ids), structure(ids, levels = as.character(seq_len(max(ids))), class = "factor"))
)[, c("expression", "median", "mem_alloc")]
# # A tibble: 2 x 3
# expression median mem_alloc
# <bch:expr> <bch:tm> <bch:byt>
# 1 factor 5.63ms 4.2MB
# 2 structure 1.28ms 1.2MB
This only works because the ids are whole numbers from 1 to G with no gaps, which match() guarantees.
Tip 3: Split Columns, Not data.frames
split() on a data.frame calls [.data.frame once for each group. [.data.frame is a long R function that has to
handle every way of indexing a data.frame and checks the row names every time. Splitting each column on its own
avoids all of that.
f <- structure(ids, levels = as.character(seq_len(max(ids))), class = "factor")
bench::mark(
data_frame = split(df, f),
columns = lapply(df, split.default, f),
check = FALSE
)[, c("expression", "median", "mem_alloc")]
# # A tibble: 2 x 3
# expression median mem_alloc
# <bch:expr> <bch:tm> <bch:byt>
# 1 data_frame 13.01ms 3.9MB
# 2 columns 2.04ms 2.02MB
There is a second choice to make here. If we already have the rows of each group, we can also split a column by
indexing it once per group, lapply(rows, function(i) x[i]). Which is faster depends on the size of the groups.
res <- bench::press(
n_groups = c(100, 1e3, 3e3, 1e4),
{
set.seed(42)
x <- runif(1e5)
ids <- match(sample(n_groups, 1e5, replace = TRUE), seq_len(n_groups))
f <- structure(ids, levels = as.character(seq_len(n_groups)), class = "factor")
rows <- split.default(seq_along(x), f)
bench::mark(index = lapply(rows, function(i) x[i]), split = split.default(x, f), check = FALSE)
},
.quiet = TRUE
)
res[, c("expression", "n_groups", "median", "mem_alloc")]
# # A tibble: 8 x 4
# expression n_groups median mem_alloc
# <bch:expr> <dbl> <bch:tm> <bch:byt>
# 1 index 100 232.35us 786.77KB
# 2 split 100 964.07us 1.15MB
# 3 index 1000 526.28us 835.98KB
# 4 split 1000 1.02ms 1.2MB
# 5 index 3000 1.1ms 944.86KB
# 6 split 3000 1.1ms 1.32MB
# 7 index 10000 2.9ms 131.44KB
# 8 split 10000 1.18ms 561.22KB
split.default() costs about 1 ms whatever the number of groups, because it always goes through the whole column.
Indexing costs one R function call per group, so it is cheap with a few large groups and expensive with many small
ones. The two meet at about 3,000 groups, which is about 33 rows per group, so {poorman} indexes when the groups have
32 rows or more on average and uses split.default() otherwise.
Tip 4: Combine With One unlist()
do.call(rbind, pieces) has the same problem as splitting a data.frame, but in reverse. rbind.data.frame() has to
cope with columns in a different order, factors with different levels, duplicated row names and more, and it does
those checks for every piece. If each group gives a plain vector, unlist() combines them in one call.
pieces_df <- lapply(split(df, f), function(piece) data.frame(m = mean(piece$x)))
pieces_vec <- lapply(split.default(df$x, f), mean)
bench::mark(
rbind = do.call(rbind, pieces_df)$m,
unlist = unlist(pieces_vec, use.names = FALSE)
)[, c("expression", "median", "mem_alloc")]
# # A tibble: 2 x 3
# expression median mem_alloc
# <bch:expr> <bch:tm> <bch:byt>
# 1 rbind 7.11ms 267.74KB
# 2 unlist 7.38us 7.86KB
With 1,000 one row pieces, unlist() is about 1,000 times faster. Of course unlist() drops attributes, so factors
and dates need a little extra care, but for plain numbers and characters this is all it takes.
Tip 5: Put the Rows Back With One Permutation
After the combine, the results are in group order: all of group 1, then all of group 2 and so on. mutate() has to
return them in the original row order. unlist(rows) tells us which row each result belongs to, and writing
seq_along(rows) into those positions turns that around, giving the position of each row’s result.
rows <- unlist(split.default(seq_along(shop_id), shop_id), use.names = FALSE) rows # [1] 1 3 6 2 5 4 back <- integer(length(rows)) back[rows] <- seq_along(rows) back # [1] 1 4 2 6 5 3
match() numbered shop b first, so group order is the three b rows, then the two a rows, then c. Row 1 is
shop b, so its result is 1st in group order; row 2 is shop a, so its result is 4th, after the three results for
b; and so on. Now any result can be put back with a single subset.
totals <- unlist(lapply(split(sales$amount, shop_id), sum), use.names = FALSE) by_group <- rep(totals, tabulate(shop_id)) by_group[back] # [1] 20 14 20 8 14 20
back only has to be worked out once, and since it is used with [, it works for every new column whatever its type.
Tip 6: Remove the Loop When You Can
Some grouped calculations do not need a loop over the groups at all. If you know the function in advance, base R often
has a grouped version already. The mean from our example can be done with rowsum(), which sums each group in C.
centre_rowsum <- function(df) {
ids <- match(df$g, unique(df$g))
df$x - (rowsum(df$x, ids, reorder = FALSE) / tabulate(ids))[ids]
}
res <- bench::press(
n_groups = c(10, 1e3, 1e4),
{
set.seed(42)
df <- data.frame(g = sample(n_groups, 1e5, replace = TRUE), x = runif(1e5))
bench::mark(fast = centre_fast(df), rowsum = centre_rowsum(df), iterations = 10L)
},
.quiet = TRUE
)
res[, c("expression", "n_groups", "median", "mem_alloc")]
# # A tibble: 6 x 4
# expression n_groups median mem_alloc
# <bch:expr> <dbl> <bch:tm> <bch:byt>
# 1 fast 10 3.81ms 7.49MB
# 2 rowsum 10 1.64ms 4.68MB
# 3 fast 1000 5.55ms 7.67MB
# 4 rowsum 1000 1.64ms 4.71MB
# 5 fast 10000 22.65ms 6.18MB
# 6 rowsum 10000 6.59ms 5.15MB
{poorman} can’t do this for mutate(), because it doesn’t know what the expression will be, but it can for verbs that
don’t take an expression to evaluate for each group.
My favourite example is filling missing values, as in fill(). Filling down means replacing each NA with the last
value before it that isn’t missing. Take the position of each element, set it to 0 where the value is missing, and
take the running maximum with cummax(). That gives the position of the last value that isn’t missing.
x <- c(NA, 3, NA, NA, 5, NA) seq_along(x) * !is.na(x) # [1] 0 2 0 0 5 0 cummax(seq_along(x) * !is.na(x)) # [1] 0 2 2 2 5 5
The first element has no value before it, so it gets 0. To do this within groups, put the rows in group order and always count the first element of each group, missing or not. That fixes the 0, and it also stops a value leaking into the next group.
first <- c(TRUE, FALSE, FALSE, TRUE, FALSE, FALSE) # two groups: positions 1-3 and 4-6 from <- cummax(seq_along(x) * (!is.na(x) | first)) from # [1] 1 2 2 4 5 5 x[from] # [1] NA 3 3 NA 5 5
Position 4 stays NA rather than taking the 3 from the first group. Here is a version that splits the data and uses
the trick inside each group, and a version that uses it once on the whole column.
fill_one <- function(x) {
pos <- seq_along(x)
x[cummax(pos * (!is.na(x) | pos == 1L))]
}
fill_split <- function(x, ids) {
f <- structure(ids, levels = as.character(seq_len(max(ids))), class = "factor")
unsplit(lapply(split(x, f), fill_one), f)
}
fill_whole <- function(x, ids) {
idx <- order(ids)
sorted <- ids[idx]
first <- c(TRUE, sorted[-1L] != sorted[-length(sorted)])
y <- x[idx]
out <- x
out[idx] <- y[cummax(seq_along(y) * (!is.na(y) | first))]
out
}
res <- bench::press(
n_groups = c(10, 1e3, 1e4),
{
set.seed(42)
ids <- match(sample(n_groups, 1e5, replace = TRUE), seq_len(n_groups))
z <- ifelse(runif(1e5) < 0.3, NA, runif(1e5))
bench::mark(split = fill_split(z, ids), whole = fill_whole(z, ids), iterations = 10L)
},
.quiet = TRUE
)
res[, c("expression", "n_groups", "median", "mem_alloc")]
# # A tibble: 6 x 4
# expression n_groups median mem_alloc
# <bch:expr> <dbl> <bch:tm> <bch:byt>
# 1 split 10 3.64ms 7.69MB
# 2 whole 10 1.96ms 9.16MB
# 3 split 1000 4.91ms 8.13MB
# 4 whole 1000 2.07ms 9.16MB
# 5 split 10000 11.53ms 3.45MB
# 6 whole 10000 2.09ms 9.16MB
Both functions use the same cummax() trick, so the only difference is the loop over the groups. With 10 groups
that loop costs little, but with 10,000 groups fill_whole() is 5 times faster. It uses a little more memory, for the
reordered copy of the column.
How {poorman} Puts It Together
So far these have been small base R functions. Here is how {poorman} uses the same tricks in its verbs.
Grouped data
group_by() works out the groups once and stores them on the data as an attribute. group_data() shows them: one row
per group, with the values of the grouping columns and a list column, .rows, of the rows in each group.
library(poorman) gs <- group_by(sales, shop) group_data(gs) # shop .rows # 1 a 2, 5 # 2 b 1, 3, 6 # 3 c 4
To build this, group_by() numbers the rows with match() as in Tip 1 and splits the row numbers with a hand built
factor as in Tip 2. The groups are then sorted by their values, which is why a comes first here. Every grouped verb
reads .rows rather than finding the groups again.
Evaluating an expression for each group
Take mutate(gs, share = amount / sum(amount)). For each group, R has to evaluate amount / sum(amount) with
amount meaning the amounts of that group only. The base R tool for this is an environment: eval(expr, env)
evaluates expr and looks up its variables in env first. So for each group, {poorman} makes a small environment
holding that group’s piece of each column, and evaluates the expression in it. Here is that process written out by
hand for our example.
expr <- quote(amount / sum(amount)) rows <- group_data(gs)$.rows pieces <- lapply(rows, function(i) sales$amount[i]) envs <- lapply(pieces, function(amount) list2env(list(amount = amount), envir = new.env(parent = globalenv()))) results <- lapply(envs, function(env) eval(expr, env)) results # [[1]] # [1] 0.2857143 0.7142857 # # [[2]] # [1] 0.1 0.3 0.6 # # [[3]] # [1] 1
The parent of each environment is the environment the verb was called from, here the global environment. A variable
that isn’t a column, such as a total defined in your own function, isn’t found in the group’s environment, so R
looks for it in the parent, just as it would outside of {poorman}.
The results are then combined with one unlist() (Tip 4) and put back in row order with one permutation (Tip 5).
idx <- unlist(rows) back <- integer(length(idx)) back[idx] <- seq_along(idx) unlist(results)[back] # [1] 0.1000000 0.2857143 0.3000000 1.0000000 0.7142857 0.6000000 mutate(gs, share = amount / sum(amount))$share # [1] 0.1000000 0.2857143 0.3000000 1.0000000 0.7142857 0.6000000
That is mutate() in miniature. The real thing splits every column, not just amount, using whichever of the two
methods in Tip 3 suits the size of the groups, and it checks that each result has the right size and type, but the
steps are the same. summarise(), mutate(), transmute(), filter() and slice() all share one internal
function that does this. Making one environment per group and evaluating the expressions in it is almost all of the
work it does for each group; everything else is done once for all groups.
Building only what is used
Like {dplyr}, {poorman} lets you refer to the columns of the current group through .data. This is useful when the
name of a column is stored in a variable.
var <- "amount" mutate(gs, double = .data[[var]] * 2) # shop day amount double # 1 b 1 2 4 # 2 a 1 4 8 # 3 b 2 6 12 # 4 c 1 8 16 # 5 a 1 10 20 # 6 b 2 12 24
For this to work, every group’s environment needs a variable called .data holding that group’s columns as a
data.frame. Building a data.frame for every group is exactly the cost that we have been trying to avoid, and most
expressions never use .data. So {poorman} only builds it when an expression reads it, using an active binding.
makeActiveBinding() creates a variable that holds a function rather than a value, and runs that function each time
the variable is read. Here is an environment for one group, where .data prints a message each time it is built.
env <- list2env(list(amount = c(4, 10)), envir = new.env())
makeActiveBinding(".data", function() {
cat("Building .data\n")
data.frame(amount = env$amount)
}, env)
eval(quote(sum(amount)), env)
# [1] 14
eval(quote(sum(.data[["amount"]])), env)
# Building .data
# [1] 14
The first expression never reads .data, so the data.frame is never built. The second reads it, and only then is it
built.
Subsetting once in filter()
filter() evaluates its condition in each group’s environment in the same way, which gives a logical vector for each
group. These are combined and put back in row order exactly like the results of mutate(), which gives one logical
vector, keep, for all of the rows. The data is then subset once with it. The old approach subset each group’s
data.frame and bound the pieces back together.
The result is still grouped, and its groups don’t need to be found again. Filtering doesn’t change the values of the
grouping columns, it only removes rows, so only the row numbers in .rows change. The new number of each row that is
kept is cumsum(keep) at that row. Take the condition amount > 4.
keep <- sales$amount > 4 keep # [1] FALSE FALSE TRUE TRUE TRUE TRUE cumsum(keep) # [1] 0 0 1 2 3 4 group_data(filter(gs, amount > 4)) # shop .rows # 1 a 3 # 2 b 1, 4 # 3 c 2
Shop b had rows 1, 3 and 6. Row 1 is dropped, and rows 3 and 6 become cumsum(keep)[c(3, 6)], that is rows 1 and 4
of the result. {poorman} does this for all of the groups with one subset of cumsum(keep).
Verbs that do less per group
fill() fills missing values within each group. It doesn’t evaluate an expression per group, so it doesn’t use the
environments at all. It puts the rows in group order with unlist(group_data(gs)$.rows), marks the first row of each
group, and applies the cummax() trick from Tip 6 to the whole column, just as fill_whole() does.
distinct() evaluates its arguments like mutate(), so that distinct(gs, d = day + 1) works, but it finds the
distinct rows without a loop over groups. The distinct values of day within each shop are just the distinct
combinations of shop and day, so it numbers those combinations as in Tip 1 and keeps the rows where
duplicated() is FALSE. With the ids for shop and day from Tip 2:
which(!duplicated(ids)) # [1] 1 2 3 4 distinct(gs, day) # shop day # 1 b 1 # 2 a 1 # 3 b 2 # 4 c 1
slice_head() still loops over the groups, but only to work out the positions of the first n rows from .rows.
That loop only handles short integer vectors, never the data or an environment, and the data is then subset once.
unlist(lapply(group_data(gs)$.rows, head, 2)) # [1] 2 5 1 3 4 slice_head(gs, n = 2) # shop day amount # 2 a 1 4 # 5 a 1 10 # 1 b 1 2 # 3 b 2 6 # 4 c 1 8
The Results
Here is the code I used to compare {poorman} 0.3.0, the version with these changes, with the current CRAN release,
0.2.8. I ran it twice, in separate R sessions, once with each version installed (install.packages("poorman") for
0.2.8 and remotes::install_github("nathaneastwood/poorman") for 0.3.0). group_by() times making the
grouped data; every other line times a grouped operation on gdf, the data grouped by g.
library(poorman)
res <- bench::press(
n_groups = c(10, 1e3, 1e4),
{
set.seed(42)
n <- 1e5
df <- data.frame(
g = sample(n_groups, n, replace = TRUE),
x = runif(n),
y = sample(5L, n, replace = TRUE)
)
df$z <- ifelse(runif(n) < 0.3, NA, df$x)
gdf <- group_by(df, g)
bench::mark(
group_by = group_by(df, g),
summarise = summarise(gdf, m = mean(x)),
mutate = mutate(gdf, m = x - mean(x)),
filter = filter(gdf, x > mean(x)),
slice_head = slice_head(gdf, n = 2),
distinct = distinct(gdf, y),
fill = fill(gdf, z),
iterations = 3L,
check = FALSE
)
},
.quiet = TRUE
)
res[, c("expression", "n_groups", "median", "mem_alloc")]
| Verb | Groups | Time, 0.2.8 | Time, 0.3.0 | Memory, 0.2.8 | Memory, 0.3.0 |
|---|---|---|---|---|---|
group_by() |
10 | 15.2 ms | 2.66 ms | 25.27 MB | 5.82 MB |
| 1,000 | 1.53 s | 2.87 ms | 1.87 GB | 5.94 MB | |
| 10,000 | 15.2 s | 5.35 ms | 18.64 GB | 5.83 MB | |
summarise() |
10 | 7.55 ms | 2.08 ms | 13.71 MB | 5 MB |
| 1,000 | 174.84 ms | 11.18 ms | 33.49 MB | 4.98 MB | |
| 10,000 | 2.86 s | 133.88 ms | 1.88 GB | 9.41 MB | |
mutate() |
10 | 50.8 ms | 2.85 ms | 64.62 MB | 8.12 MB |
| 1,000 | 1.3 s | 13.83 ms | 1.95 GB | 8.41 MB | |
| 10,000 | 11.8 s | 115.23 ms | 18.72 GB | 11.63 MB | |
filter() |
10 | 46.54 ms | 5.91 ms | 57.57 MB | 13.86 MB |
| 1,000 | 1.45 s | 19.11 ms | 1.91 GB | 14.25 MB | |
| 10,000 | 13.89 s | 196.36 ms | 18.66 GB | 17.89 MB | |
slice_head() |
10 | 6.27 ms | 1.1 ms | 10.35 MB | 2.74 MB |
| 1,000 | 110.49 ms | 2.54 ms | 50.24 MB | 2.84 MB | |
| 10,000 | 3.16 s | 17.1 ms | 3.75 GB | 4.49 MB | |
distinct() |
10 | 44.22 ms | 7.39 ms | 22.86 MB | 17.79 MB |
| 1,000 | 465.29 ms | 18.68 ms | 120.36 MB | 19.6 MB | |
| 10,000 | 8.2 s | 135.01 ms | 8.08 GB | 27.79 MB | |
fill() |
10 | 54.85 ms | 2.45 ms | 72.58 MB | 7.76 MB |
| 1,000 | 1.28 s | 2.63 ms | 1.95 GB | 7.68 MB | |
| 10,000 | 12.38 s | 3.25 ms | 18.7 GB | 8.16 MB |
0.3.0 is faster for every verb and every number of groups: between 4 and 22 times faster with 10
groups, and between 21 and about 3,800 times faster with 10,000 groups. fill(), which no longer loops over groups,
went from 12.4 seconds to 3.3 ms. Most of the old time and memory went on comparing every row with every group, as in
Tip 1: 0.2.8 did this in group_by(), and every grouped verb did it again to find the groups of its result. That is
why the old times grow so quickly with the number of groups, and why mutate() with 10,000 groups allocated 18.7 GB.
It now allocates 11.6 MB.
None of this needed anything outside of base R. The loop over the groups is still there where it has to be, but it is now the only part of the code that runs once per group.
{poorman} 0.3.0 will be on CRAN soon, and then you will be able to install it with
install.packages("poorman")
Until then, you can install it from GitHub with
remotes::install_github("nathaneastwood/poorman")
As always, please report any bugs or differences from {dplyr} on GitHub.
R-bloggers.com offers daily e-mail updates about R news and tutorials about learning R and many other topics. Click here if you're looking to post or find an R/data-science job.
Want to share your content on R-bloggers? click here if you have a blog, or here if you don't.
