This vignette answers two practical questions that come up as soon as Tm profiling is scaled from a bacterial genome to the human genome (hg38):
tm_calculate() call not
make it faster (which is why the package no longer offers to)?Parts 1 and 2 were measured on a 6-core (12-thread) Intel MacBook Pro with 16 GB RAM, R 4.4, macOS, with the package’s compiled (Rcpp) nearest-neighbor core. The whole-genome sweep of Part 2 was then repeated unchanged on a cluster compute node (two 20-core Intel Xeon Gold 6230, 376 GB, of which the job requested six slots and 48 GB), which is what makes it possible to say which of the laptop’s results are properties of the software and which are properties of a 16 GB machine. Absolute numbers will differ on other hardware; the structure of the results, which parts scale and which do not, will not. The measurement chunks are not evaluated when the vignette is built; run them interactively to reproduce them. The sweep tables and figure are built from CSV files shipped with the package and are evaluated.
Three tools cover almost every need. Wall-clock time comes from
system.time() (read the elapsed column; for
parallel runs user and system only reflect the
manager process). The peak of R-managed memory over a code block comes
from the gc(reset = TRUE) idiom. The true resident size of
the current process — including C++ allocations that R’s GC does not see
— comes from ps::ps_memory_info().
gc(reset = TRUE) # reset the "max used" high-water mark
## ... run the code to be measured ...
gc() # read peak from the "max used" column
ps::ps_memory_info()[["rss"]] / 1e9 # current process resident set, GB(Earlier releases exported a gc() of their own, for the
GC content of a sequence, which masked R’s garbage collector
once the package was attached. That function is now
gc_content(), so no base:: prefix is
needed.)
For SnowParam() workers the manager cannot see worker
memory at all; record it inside the worker function and return it with
the result (this pattern is used in the whole-genome section below).
Before committing to chr1, verify your setup on chr21 — at 46.7 Mb it
is the smallest hg38 autosome and the whole pipeline finishes in about
ten seconds. It is also a deliberately instructive choice: chr21
begins with a ~5 Mb assembly gap of N bases, so it
exercises the N-trimming machinery that most chromosomes only touch
lightly.
chr_len21 <- GenomeInfoDb::seqlengths(genome)[["chr21"]]
t21 <- system.time({
bins21 <- make_genomiccoord(bsgenome = pkg, chromosomes = "chr21",
window = 200L, slide = 200L,
start = 1, end = chr_len21, strand = "+")
gr21 <- to_genomic_ranges_fast(list(pkg_name = pkg, seq = bins21),
method = "preload_chr")
tm21 <- tm_calculate(gr21, method = "tm_nn",
nn_table = "DNA_NN_SantaLucia_2004", Na = 50)
})
t21["elapsed"] # ~10-15 s cold, ~6 s warm, on the test machine
head(tm21$gr)Watch the make_genomiccoord() messages: N-trimming
reports an effective range starting at position 5,010,001 — the leading
5 Mb of Ns is detected in a single in-memory scan and
excluded before any windows are built, leaving 208,449 windows instead
of 233,549. The first run in a session pays one-time costs (reading the
genome from disk, loading the thermodynamic tables); subsequent runs are
roughly twice as fast. If this warm-up runs cleanly, your BSgenome
installation, compiled core and memory headroom are all in working
order, and the chr1 numbers below should reproduce within noise.
One benchmarking caveat that bites easily:
devtools::load_all() compiles the C++ core at
-O0 (a debug build, ~6x slower) and leaves a mixed session
state even after reinstalling. Always benchmark in a fresh session with
a plain library(TmCalculator); confirm with
"devtools_shims" %in% search() returning
FALSE.
chr1 is 248,956,422 bp. Non-overlapping 200 bp windows after N-trimming give 1,244,682 windows.
chr_len <- GenomeInfoDb::seqlengths(genome)[["chr1"]]
t_coord <- system.time({
bins <- make_genomiccoord(bsgenome = pkg, chromosomes = "chr1",
window = 200L, slide = 200L,
start = 1, end = chr_len, strand = "+")
})
t_extract <- system.time({
gr_batch <- to_genomic_ranges_fast(list(pkg_name = pkg, seq = bins),
method = "preload_chr")
})
base::gc(reset = TRUE)
t_tm <- system.time({
tm_chr1 <- tm_calculate(gr_batch, method = "tm_nn",
nn_table = "DNA_NN_SantaLucia_2004", Na = 50)
})
base::gc() # "max used" = peak R memory during the Tm step
rbind(t_coord, t_extract, t_tm)[, "elapsed"]
## t_coord t_extract t_tm
## 5.5 15.4 30.8 (freshly booted, otherwise idle machine)Measured on the test machine (R --vanilla, freshly
booted, no other applications):
| step | function | elapsed | notes |
|---|---|---|---|
| window coordinates | make_genomiccoord() |
~5.5 s | N-end scan loads the 249 Mb chromosome once |
| sequence extraction | to_genomic_ranges_fast(method = "preload_chr") |
~15 s | extractAt() itself is ~4 s; the rest is complement
generation, the GC column and GRanges assembly |
| Tm calculation | tm_calculate(method = "tm_nn") |
~31 s | compiled core, 1.24M windows |
| total | ~52 s |
On a machine with normal background load the same steps run 20-35% slower (we measured 7.3 / 19.3 / 41.5 s in an ordinary desktop session); comparisons within one section below were always measured in a single session.
Memory: base::gc() reported a peak of ~1.9 GB of
R-managed heap during the Tm step, and a dedicated worker process
running the whole chr1 pipeline reaches ~3.5-4 GB
resident (sequences, complements, a character copy handed to
the compiled core, GRanges metadata, plus loaded packages).
A useful planning figure: budget ~4 GB per chr1-sized
pipeline, tapering to well under 2 GB for small
chromosomes.
Two extraction details matter at this scale. Pass the input as
list(pkg_name = ..., seq = ...) so the genome package is
loaded once, and use method = "preload_chr" for dense
tiling of few chromosomes (getSeq()-based
"vectorized" is better for windows scattered across many
chromosomes).
Versions before 1.1.0 accepted a BPPARAM argument in
tm_calculate() that split the sequences into one chunk per
worker and ran the Tm loop on a BiocParallel backend. It
did exactly what it advertised, yet on chr1 it never helped, and the
argument has been removed. The measurement that decided it (taken with
the 1.0.x series; the call no longer runs):
system.time({
tm_serial <- tm_calculate(gr_batch, method = "tm_nn", Na = 50)
})
## elapsed ~ 30 s
## 1.0.x only: BPPARAM no longer exists
system.time({
tm_snow <- tm_calculate(gr_batch, method = "tm_nn", Na = 50,
BPPARAM = SnowParam(workers = 5))
})
## elapsed ~ 55 s on the idle machine (33-80 s across repeated sessions)
## -- NEVER faster than serialThe serial time is stable across sessions; the parallel time is not, because its added costs (spawning workers, serializing half a gigabyte of sequence, loading packages in each worker) are highly sensitive to machine state. The decomposition explains why even its best case cannot win. Of the ~30 s serial runtime, only the per-window Tm loop (~9 s with the compiled core) is divided among workers. The rest is inherently serial or is new cost created by parallelization:
| component | ~time | scales with workers? |
|---|---|---|
| serial preprocessing (N filtering, coercion, result assembly) | ~21 s | no — runs once in the manager |
| shipping ~0.5 GB of sequence to workers and results back | ~5 s | no — serialization is serial |
| worker startup + package loading | ~3+ s | fixed cost, added by parallelism |
| compiled Tm loop | ~9 s / n workers | yes |
This is Amdahl’s law with an unusually small parallel fraction
(~30%): even with infinitely many workers the floor is ~29 s —
essentially the serial runtime — and on a loaded machine the added costs
balloon well past it. With the pure-R core of previous releases the loop
dominated (minutes for an input of this size) and the same
SnowParam(5) call gave a 2.8x speedup; compiling the loop
moved the bottleneck into the serial part and made within-call
parallelism unprofitable. With the compiled core, a plain serial
call is the fastest way to run inputs up to roughly a million
windows, which is why tm_calculate() now runs
serially and leaves parallelism to the caller, at the level of regions
(Part 2).
A stronger warning applies to MulticoreParam() on large
inputs, and it carries over to the region-level parallelism of Part 2.
Forked workers share the manager’s memory copy-on-write, but R’s garbage
collector writes to every object header it marks, so each
worker’s GC forces the kernel to physically duplicate inherited pages.
On a chr1-scale input (measured with the pure-R core, where the effect
is easiest to see), MulticoreParam(5) ran slower than
serial while burning ~18 CPU-minutes of system time on
page duplication. When you parallelize, prefer
SnowParam().
The serial preprocessing that defeats within-call parallelism is
itself embarrassingly parallel across chromosomes: window
building, extraction, filtering and assembly for chr2 are independent of
chr1. So for genome-scale work, give each worker one whole chromosome
and run the entire pipeline — coordinates, extraction,
tm_calculate() with the serial default — inside the
worker:
chrs <- paste0("chr", c(1:22, "X", "Y"))
## Better: sort largest-first for load balance -- see "Choosing the
## worker count" below for the one-liner.
n_workers <- 5 # see "Choosing the worker count" below
runtime <- system.time({
res_list <- bplapply(chrs, function(chr, pkg) {
## SnowParam workers are fresh R processes: load packages HERE,
## not in the manager session.
suppressPackageStartupMessages(library(TmCalculator))
suppressPackageStartupMessages(library(pkg, character.only = TRUE))
genome <- get(pkg, envir = asNamespace(pkg))
chr_len <- GenomeInfoDb::seqlengths(genome)[[chr]]
bins <- make_genomiccoord(bsgenome = pkg, chromosomes = chr,
window = 200L, slide = 200L,
start = 1, end = chr_len, strand = "+",
verbose = FALSE)
gr <- to_genomic_ranges_fast(list(pkg_name = pkg, seq = bins),
method = "preload_chr")
out <- tm_calculate(gr, method = "tm_nn",
nn_table = "DNA_NN_SantaLucia_2004",
Na = 50)$gr # serial inside the worker
## Drop sequence columns before returning: Tm/GC are what we keep,
## and this cuts per-chromosome serialization from ~500 MB to a few MB.
out$sequence <- NULL
out$complement <- NULL
## Record this worker's peak resident memory (GB) for budgeting.
attr(out, "worker_rss_gb") <- ps::ps_memory_info()[["rss"]] / 1e9
out
}, pkg = pkg, BPPARAM = SnowParam(workers = n_workers))
tm_genome <- unlist(GenomicRanges::GRangesList(res_list))
})
runtime
sapply(res_list, attr, "worker_rss_gb") # per-worker memory check
length(tm_genome) # 14,687,330 windows
summary(tm_genome$Tm)
## Min. 47.2 1st Qu. 68.9 Median 71.5 Mean 72.0 3rd Qu. 74.8 Max. 101.1Unlike the old within-call BPPARAM, this scheme
parallelizes everything: window building, extraction, filtering
and assembly, not just the Tm loop. Per-task communication is a
chromosome name in and a slim GRanges out, so Amdahl’s law
has little serial residue to bite on.
The chromosome is not the only unit of work available, though, and the choice of unit turns out to matter more than the worker count. The next section defines three schemes and the one after it measures all three over the same worker range.
The three differ in how many tasks there are and in when a task is assigned to a worker.
Static dispatch is
SnowParam(workers = n) left alone. BiocParallel pre-splits
the task list into one contiguous chunk per worker before anything runs,
so a worker that draws a light chunk finishes early and then sits idle
for the rest of the run.
Dynamic dispatch is the same task list with
tasks = length(X). The manager hands out one element at a
time, so a worker that finishes immediately takes the next. Sorting the
chromosomes largest first makes this effective: the long tasks are
placed while there is still short work left to fill the ragged end of
the run.
Segment dispatch keeps dynamic assignment and changes the unit. Instead of 24 whole chromosomes the genome is cut into 50 Mb intervals, giving 73 tasks, and the longest single task falls from 55 s to 12 s. No schedule can finish sooner than its longest task, so this lowers the floor that the other two schemes are pressed against.
sl <- GenomeInfoDb::seqlengths(genome)[paste0("chr", c(1:22, "X", "Y"))]
chrs <- names(sort(sl, decreasing = TRUE)) # largest first
tasks_chrom <- lapply(chrs, function(ch)
list(chr = ch, start = 1L, end = as.integer(sl[[ch]])))
## Segment boundaries must be multiples of `slide`, or the window grid
## shifts between segments and the result stops matching a whole-
## chromosome run. 50 Mb = 250,000 x 200 bp.
seg_size <- 50e6
tasks_seg <- unlist(lapply(names(sl), function(ch) {
st <- seq(1, sl[[ch]], by = seg_size)
lapply(st, function(s)
list(chr = ch, start = as.integer(s),
end = as.integer(min(s + seg_size - 1, sl[[ch]]))))
}), recursive = FALSE)
length(tasks_chrom) # 24
length(tasks_seg) # 73
n <- 5
BPPARAM_static <- SnowParam(workers = n)
BPPARAM_dynamic <- SnowParam(workers = n, tasks = length(tasks_chrom))
BPPARAM_segment <- SnowParam(workers = n, tasks = length(tasks_seg))One correctness detail: a segment task must pass
trim_N = "none" to make_genomiccoord(), while
a whole-chromosome task should trim. Trimming inside a segment would
move the window grid relative to the chromosome and the two schemes
would no longer compute the same windows. Interior N
windows are dropped afterwards in both cases, so the totals agree
exactly.
All 24 chromosomes, 14,687,330 windows, 200 bp non-overlapping,
freshly booted, R --vanilla, three repetitions per
configuration in each environment. Wall time is elapsed time including
worker start-up, which is also reported on its own; workers start
concurrently, so start-up contributes its longest instance rather than
the sum. Speedup is the one-worker wall time of the same strategy and
environment divided by the wall time of the configuration, and
efficiency is that speedup divided by the worker count.
# The two sweeps ship with the package as the CSV files the benchmark
# scripts wrote, and everything below is derived from them. Nothing is
# transcribed, so the tables, the figure and the numbers quoted in the text
# cannot drift apart, and re-running a sweep means replacing a file.
cols <- c("env", "strategy", "n_workers", "n_tasks", "rep", "wall_s",
"startup_s", "wall_compute_s", "work_s", "hard_floor", "max_rss_gb")
read_sweep <- function(file, env) {
d <- utils::read.csv(system.file("extdata", file, package = "TmCalculator"),
stringsAsFactors = FALSE)
stopifnot(length(unique(d$n_windows)) == 1L) # identical windows throughout
d$env <- env
d[, cols]
}
raw <- rbind(read_sweep("bench_parallel_strategy.csv", "laptop"),
read_sweep("bench_parallel_cluster.csv", "node"))
# Median over repetitions; the observed range is kept for the wall clock,
# which is the quantity the range bars in the figure show.
sweep <- do.call(rbind, lapply(
split(raw, list(raw$env, raw$strategy, raw$n_workers), drop = TRUE),
function(d) data.frame(
env = d$env[1], strategy = d$strategy[1], workers = d$n_workers[1],
tasks = d$n_tasks[1], n_rep = nrow(d),
wall_s = median(d$wall_s), wall_lo = min(d$wall_s), wall_hi = max(d$wall_s),
startup_s = median(d$startup_s), compute_s = median(d$wall_compute_s),
work_s = median(d$work_s), longest_task_s = median(d$hard_floor),
rss_gb = median(d$max_rss_gb), stringsAsFactors = FALSE)))
# Derived rather than transcribed. Speedup is the one-worker wall clock of
# the SAME strategy in the SAME environment over the configuration's wall
# clock: segmenting changes how much total work there is, so a ratio formed
# against a configuration's own summed task times would credit a strategy
# for doing more work and the three could not be compared.
key <- paste(sweep$env, sweep$strategy)
ser <- sweep[sweep$workers == 1L, ]
serial <- stats::setNames(ser$wall_s, paste(ser$env, ser$strategy))
serial_work <- stats::setNames(ser$work_s, paste(ser$env, ser$strategy))
sweep$speedup <- serial[key] / sweep$wall_s
sweep$efficiency <- sweep$speedup / sweep$workers
sweep$work_ratio <- sweep$work_s / serial_work[key]
sweep$strategy <- factor(sweep$strategy, levels = c("static", "dynamic", "segment"))
sweep$env <- factor(sweep$env, levels = c("laptop", "node"))
sweep <- sweep[order(sweep$env, sweep$strategy, sweep$workers), ]
rownames(sweep) <- NULLsweep_table <- function(s, caption) {
tab <- data.frame(
Strategy = ifelse(duplicated(s$strategy), "", as.character(s$strategy)),
Workers = s$workers,
Tasks = s$tasks,
`Wall (s)` = sprintf("%.1f [%.1f-%.1f]", s$wall_s, s$wall_lo, s$wall_hi),
`Start-up (s)` = round(s$startup_s, 1),
`Total task time (s)` = round(s$work_s, 1),
`Work ratio` = round(s$work_ratio, 2),
`Longest task (s)` = round(s$longest_task_s, 1),
Speedup = round(s$speedup, 2),
Efficiency = round(s$efficiency, 2),
`Peak RSS per worker (GB)` = round(s$rss_gb, 2),
check.names = FALSE, stringsAsFactors = FALSE)
knitr::kable(tab, row.names = FALSE, caption = caption)
}
sweep_table(sweep[sweep$env == "laptop", ],
paste("Laptop (6-core Intel Core i7, 16 GB): task-partitioning strategy",
"and worker count. Wall time is the median of three repetitions with",
"the observed range in brackets."))| Strategy | Workers | Tasks | Wall (s) | Start-up (s) | Total task time (s) | Work ratio | Longest task (s) | Speedup | Efficiency | Peak RSS per worker (GB) |
|---|---|---|---|---|---|---|---|---|---|---|
| static | 1 | 24 | 590.5 [583.1-601.6] | 0.0 | 590.5 | 1.00 | 55.2 | 1.00 | 1.00 | 4.75 |
| 2 | 24 | 439.2 [431.2-441.1] | 7.1 | 613.9 | 1.04 | 57.1 | 1.34 | 0.67 | 4.06 | |
| 3 | 24 | 335.5 [331.5-338.3] | 7.9 | 638.2 | 1.08 | 60.8 | 1.76 | 0.59 | 4.06 | |
| 4 | 24 | 278.1 [276.7-279.3] | 8.1 | 656.5 | 1.11 | 61.9 | 2.12 | 0.53 | 3.85 | |
| 5 | 24 | 259.9 [259.7-261.7] | 9.0 | 730.0 | 1.24 | 70.7 | 2.27 | 0.45 | 3.70 | |
| 6 | 24 | 255.4 [253.4-270.0] | 9.7 | 906.3 | 1.53 | 92.4 | 2.31 | 0.39 | 3.35 | |
| dynamic | 1 | 24 | 589.1 [588.9-592.2] | 0.0 | 589.1 | 1.00 | 53.4 | 1.00 | 1.00 | 4.74 |
| 2 | 24 | 325.2 [322.7-332.5] | 7.4 | 620.7 | 1.05 | 59.4 | 1.81 | 0.91 | 3.86 | |
| 3 | 24 | 245.2 [239.3-254.4] | 7.2 | 683.9 | 1.16 | 63.6 | 2.40 | 0.80 | 3.38 | |
| 4 | 24 | 218.0 [210.7-231.3] | 8.2 | 795.8 | 1.35 | 69.7 | 2.70 | 0.68 | 2.90 | |
| 5 | 24 | 245.0 [240.8-255.0] | 8.7 | 1093.9 | 1.86 | 88.2 | 2.40 | 0.48 | 2.67 | |
| 6 | 24 | 272.0 [269.2-294.9] | 9.9 | 1481.9 | 2.52 | 127.5 | 2.17 | 0.36 | 2.15 | |
| segment | 1 | 73 | 578.5 [573.6-581.9] | 0.0 | 578.5 | 1.00 | 11.5 | 1.00 | 1.00 | 4.87 |
| 2 | 73 | 327.1 [320.4-328.0] | 6.9 | 629.1 | 1.09 | 13.8 | 1.77 | 0.88 | 2.58 | |
| 3 | 73 | 235.8 [227.0-238.2] | 7.8 | 665.3 | 1.15 | 15.6 | 2.45 | 0.82 | 2.58 | |
| 4 | 73 | 202.2 [192.3-227.1] | 8.3 | 736.9 | 1.27 | 15.7 | 2.86 | 0.72 | 2.34 | |
| 5 | 73 | 189.0 [176.0-210.9] | 9.0 | 842.6 | 1.46 | 17.5 | 3.06 | 0.61 | 1.86 | |
| 6 | 73 | 203.8 [201.1-222.0] | 9.8 | 1085.9 | 1.88 | 22.6 | 2.84 | 0.47 | 1.77 |
sweep_table(sweep[sweep$env == "node", ],
paste("Compute node (two 20-core Intel Xeon Gold 6230, six slots, 48 GB",
"limit, shared with other jobs): the same sweep."))| Strategy | Workers | Tasks | Wall (s) | Start-up (s) | Total task time (s) | Work ratio | Longest task (s) | Speedup | Efficiency | Peak RSS per worker (GB) |
|---|---|---|---|---|---|---|---|---|---|---|
| static | 1 | 24 | 597.7 [587.8-597.9] | 0.0 | 597.7 | 1.00 | 49.8 | 1.00 | 1.00 | 4.53 |
| 2 | 24 | 410.0 [400.1-432.5] | 7.6 | 573.4 | 0.96 | 55.5 | 1.46 | 0.73 | 4.29 | |
| 3 | 24 | 335.6 [314.4-358.1] | 7.6 | 638.0 | 1.07 | 62.2 | 1.78 | 0.59 | 4.14 | |
| 4 | 24 | 261.2 [259.7-262.8] | 8.1 | 619.8 | 1.04 | 59.7 | 2.29 | 0.57 | 4.15 | |
| 5 | 24 | 228.1 [223.0-229.5] | 8.2 | 631.0 | 1.06 | 60.5 | 2.62 | 0.52 | 3.79 | |
| 6 | 24 | 197.0 [196.8-198.2] | 8.3 | 650.4 | 1.09 | 60.6 | 3.03 | 0.51 | 3.57 | |
| dynamic | 1 | 24 | 573.1 [546.0-576.0] | 0.0 | 573.1 | 1.00 | 50.9 | 1.00 | 1.00 | 4.57 |
| 2 | 24 | 299.5 [297.4-328.1] | 8.5 | 567.0 | 0.99 | 55.8 | 1.91 | 0.96 | 4.18 | |
| 3 | 24 | 223.9 [212.4-240.1] | 8.0 | 626.3 | 1.09 | 60.5 | 2.56 | 0.85 | 4.03 | |
| 4 | 24 | 176.6 [176.0-182.0] | 8.2 | 653.6 | 1.14 | 61.9 | 3.24 | 0.81 | 3.55 | |
| 5 | 24 | 149.0 [146.6-151.2] | 8.2 | 661.2 | 1.15 | 61.1 | 3.85 | 0.77 | 3.87 | |
| 6 | 24 | 128.9 [126.2-129.2] | 8.4 | 680.2 | 1.19 | 62.6 | 4.44 | 0.74 | 3.83 | |
| segment | 1 | 73 | 585.4 [546.5-588.5] | 0.0 | 585.4 | 1.00 | 11.7 | 1.00 | 1.00 | 4.52 |
| 2 | 73 | 305.4 [302.8-335.4] | 7.5 | 583.7 | 1.00 | 13.8 | 1.92 | 0.96 | 2.54 | |
| 3 | 73 | 228.2 [211.3-240.4] | 8.6 | 629.2 | 1.07 | 14.6 | 2.57 | 0.86 | 2.56 | |
| 4 | 73 | 178.0 [176.9-179.7] | 8.1 | 655.5 | 1.12 | 15.0 | 3.29 | 0.82 | 2.46 | |
| 5 | 73 | 146.0 [144.3-147.1] | 8.2 | 661.4 | 1.13 | 15.1 | 4.01 | 0.80 | 2.52 | |
| 6 | 73 | 127.0 [124.4-127.2] | 8.4 | 675.4 | 1.15 | 15.4 | 4.61 | 0.77 | 2.40 |
# Three panels, one argument, now made twice. Panel A shows that on the
# laptop every strategy turns over before the cores run out, which invites
# the usual explanation of a ragged schedule. Panel B rules that out: the
# total task time grows with the worker count, so the later configurations
# are doing more work, not dividing a fixed amount badly. Panel C gives the
# reason and the remedy together. The node, overlaid, shows what happens to
# each of the three when memory stops being scarce.
pal <- c(static = "#1B5E9C", dynamic = "#C0392B", segment = "#5C6B73")
ltys <- c(laptop = "solid", node = "22") # "22": a short dash, legible in a key
pchs <- c(laptop = 16, node = 1)
lv <- levels(sweep$strategy)
ev <- levels(sweep$env)
wk <- sort(unique(sweep$workers))
op <- par(mfrow = c(1, 3), mar = c(4.4, 4.5, 2.2, 0.8), las = 1,
cex = 0.8, mgp = c(2.8, 0.7, 0))
series <- function(col, ylab, ref = NULL, ref_lab = NULL, ymax = NULL,
diagonal = FALSE) {
plot(NA, xlim = range(wk),
ylim = c(0, if (is.null(ymax)) max(sweep[[col]]) * 1.05 else ymax),
bty = "n", xaxt = "n", xlab = "Workers", ylab = ylab)
axis(1, at = wk)
if (diagonal) {
abline(a = 0, b = 1, lty = 2, col = "grey55")
text(max(wk), max(wk), "linear", adj = c(1.1, -0.4), cex = 0.75,
col = "grey40")
}
if (!is.null(ref)) {
abline(h = ref, lty = 2, col = "grey55")
text(max(wk), ref, ref_lab, adj = c(1.1, -0.5), cex = 0.75, col = "grey40")
}
for (e in ev) for (st in lv) {
d <- sweep[sweep$env == e & sweep$strategy == st, ]
lines(d$workers, d[[col]], col = pal[st], lwd = 2, lty = ltys[e])
points(d$workers, d[[col]], col = pal[st], pch = pchs[e], lwd = 1.6)
}
}
series("speedup", "Speedup", ymax = max(wk) * 1.02, diagonal = TRUE)
mtext("A", side = 3, adj = 0, font = 2, line = 0.7, cex = 1.05)
# One legend, two columns: environment keys on the left, strategy colours
# on the right. legend() fills column-major, so the shorter column is padded
# with a blank entry whose lty, pch and col are NA.
legend("topleft", bty = "n", cex = 0.8, ncol = 2, seg.len = 2.6,
legend = c(ev, "", lv),
lty = c(unname(ltys[ev]), NA, rep("solid", length(lv))),
pch = c(unname(pchs[ev]), NA, rep(NA, length(lv))),
col = c("grey25", "grey25", NA, unname(pal[lv])), lwd = 2)
series("work_s", "Total task time (s)",
ref = median(sweep$work_s[sweep$workers == 1]), ref_lab = "serial")
mtext("B", side = 3, adj = 0, font = 2, line = 0.7, cex = 1.05)
series("rss_gb", "Peak resident set size per worker (GB)")
mtext("C", side = 3, adj = 0, font = 2, line = 0.7, cex = 1.05)Three readings.
Dispatch order matters at every worker count. Static and dynamic run the identical 24 tasks; the only difference is that static commits them to workers up front. At four workers that costs 60 s, a fifth of the run.
Segmenting is worth more than dispatch order. On the laptop, segment dispatch is faster than dynamic at every worker count from three upwards, and its best time, 189 s, is 29 s better than dynamic’s best and 66 s better than static’s. The mechanism is in the “longest task” column: 55 s of chr1 cannot be divided by any scheduler, and at five workers that single task is nearly a third of the whole run.
Segmenting halves the memory. A worker holding a 50 Mb segment needs about 1.9 GB against 3.7 GB for one holding chr1, and peak memory is what decides how many workers a machine can run at all. On this 16 GB laptop segment dispatch is both the fastest scheme and the one that leaves the most headroom, which is an unusually convenient combination.
On the laptop, all three turn over well before the cores run out. Six physical cores are available, yet static peaks at 6, dynamic at 4 and segment at 5, and none reaches half of linear speedup. The next section explains why, the reason is not the one that first suggests itself, and the section after it shows the same sweep on a machine where the reason does not apply.
The obvious explanation for a curve that flattens is a ragged schedule: one long task holding up the end while other workers idle. The “total task time” column rules that out. It is the sum of the per-task times, measured inside the workers and excluding their package loading, so it is the amount of work the run actually performed. If the schedule were merely uneven, that column would stay flat and only the wall clock would suffer.
It does not stay flat. From one worker to six it grows by 53% under static dispatch, 88% under segment dispatch and 152% under dynamic dispatch. The later configurations are not dividing a fixed amount of work badly, they are doing more of it: the same chr1 task that takes 53 s alone takes 127 s with five siblings competing for memory bandwidth and last-level cache. Each worker is individually slower, and past a certain point adding one slows the others by more than it contributes.
This also explains the ordering. Dynamic dispatch inflates worst because its tasks are the largest resident objects, so the workers contend most for cache; segmenting reduces the resident footprint per worker and inflates less. The effect is a property of the machine rather than of the software, so a machine with more memory should show the turnover later, or not at all. That is a prediction, and the next section tests it. Rerun the sweep on your own hardware rather than transplanting these worker counts.
The work ratio column is the one to watch when you do.
As long as it stays near 1.0, adding workers is buying real parallelism;
once it passes about 1.3 the run is mostly paying for its own
contention.
The sweep was repeated with nothing changed but the machine: the same window grid, parameter set, worker counts and three repetitions, on a cluster node with two 20-core Intel Xeon Gold 6230 CPUs at 2.10 GHz, hyper-threading disabled, and 376 GB of memory, of which the job requested six slots and 48 GB. The node was shared with other jobs, so the run-to-run ranges are reported rather than suppressed; they are wide at one to three workers and narrow from four upwards.
Two things about that setup need saying. The job could not be given the laptop’s 16 GB. The scheduler enforces the sum of resident set size over every process in the job, and six socket workers plus their manager occupy about 23 GB, so a 16 GB request was killed after half an hour; 48 GB, three times the laptop and twice the measured peak, is what ran. And the workers share almost nothing with one another: summed proportional set size, which divides each shared page among its sharers, tracks summed RSS to within 0.2%, because each PSOCK worker attaches the packages and retrieves sequence for itself. The 23 GB is real, not an accounting artefact of shared mappings.
The result is the one the contention argument predicts, and it shows in every quantity that argument used:
| At six workers, static / dynamic / segment | laptop | node |
|---|---|---|
| efficiency | 0.39 / 0.36 / 0.47 | 0.51 / 0.74 / 0.77 |
| work ratio | 1.53 / 2.52 / 1.88 | 1.09 / 1.19 / 1.15 |
| longest dynamic task, one worker to six | 53.4 to 127.5 s | 50.9 to 62.6 s |
| dynamic dispatch at four / five / six workers | 218.0 / 245.0 / 272.0 s | 176.6 / 149.0 / 128.9 s |
The turnover is gone. Every curve on the node improves monotonically to six workers; dynamic dispatch reaches 128.9 s where on the laptop it was slowest at six of any configuration beyond three; and neither dynamic nor segment dispatch had saturated. The growth in total task time, which was the evidence that workers were slowing one another, falls to under a fifth. The longest task grows by a quarter instead of doubling. And the run-to-run spread, which on the laptop widened to 14 to 35 s at four to six workers for the two dynamic schemes, is 6 s or less on the node for the same configurations; timing that will not repeat is what paging looks like.
Segment dispatch is still the fastest, at 127.0 s, but its margin over dynamic dispatch falls from 29 s to 2 s. Bounding the working set is worth a great deal on a machine that is short of memory and almost nothing on one that is not. That is the practical form of the point made above: the ordering of the strategies is a property of the machine as much as of the partitioning.
One number runs the other way and is worth understanding rather than explaining away. Peak memory per worker is higher on the node, 3.83 GB against 2.15 GB for dynamic dispatch at six workers. R’s garbage collector reclaims on demand, so a process under no pressure keeps more allocated than one that is being squeezed; measured memory use is partly a function of memory available, and the per-worker figures in the tables are what the process settled at, not what it needed.
The summary table reports only the longest task, which hides which
task that was and whether the inflation is spread evenly. The benchmark
also writes a per-task file, *_tasks.csv, with one row per
task and repetition: chromosome, interval, window count, time, and the
worker’s resident memory. The laptop’s is shipped with the package,
compressed, and recovering the per-chromosome picture from it is a few
lines:
tk <- utils::read.csv(system.file("extdata", "bench_parallel_strategy_tasks.csv.gz",
package = "TmCalculator"))
tk <- tk[tk$strategy == "dynamic", ] # one task per chromosome
## Median over repetitions, one column per worker count.
w <- stats::reshape(
stats::aggregate(secs ~ chr + n_workers, data = tk, FUN = stats::median),
idvar = "chr", timevar = "n_workers", direction = "wide")
names(w) <- sub("^secs\\.", "w", names(w))
w$inflation <- w$w6 / w$w1 # 6 workers vs serial
w <- w[order(-w$w1), ]
knitr::kable(w, row.names = FALSE, digits = 1,
caption = "Laptop, dynamic dispatch: median seconds per chromosome at each worker count.")| chr | w1 | w2 | w3 | w4 | w5 | w6 | inflation |
|---|---|---|---|---|---|---|---|
| chr1 | 53.4 | 58.9 | 62.3 | 69.3 | 87.9 | 127.5 | 2.4 |
| chr2 | 47.0 | 59.4 | 63.6 | 69.7 | 88.2 | 119.8 | 2.5 |
| chr3 | 39.4 | 41.7 | 52.8 | 57.0 | 70.3 | 95.6 | 2.4 |
| chr4 | 38.5 | 38.9 | 41.7 | 53.7 | 68.5 | 93.3 | 2.4 |
| chr5 | 35.6 | 36.2 | 40.5 | 44.2 | 64.3 | 87.5 | 2.5 |
| chr6 | 33.8 | 32.9 | 38.0 | 42.7 | 65.5 | 85.2 | 2.5 |
| chr7 | 31.9 | 33.0 | 37.4 | 38.3 | 57.4 | 78.0 | 2.4 |
| chrX | 30.1 | 31.0 | 34.0 | 39.6 | 52.8 | 83.2 | 2.8 |
| chr8 | 28.7 | 29.7 | 31.5 | 39.1 | 48.1 | 72.5 | 2.5 |
| chr12 | 26.2 | 27.2 | 31.9 | 34.7 | 53.7 | 72.0 | 2.7 |
| chr11 | 26.0 | 28.4 | 29.0 | 34.7 | 64.4 | 73.6 | 2.8 |
| chr10 | 26.0 | 28.1 | 28.5 | 38.3 | 52.4 | 63.1 | 2.4 |
| chr9 | 25.6 | 27.4 | 28.7 | 36.6 | 55.3 | 59.0 | 2.3 |
| chr13 | 19.5 | 18.9 | 23.2 | 26.5 | 38.2 | 52.1 | 2.7 |
| chr14 | 17.2 | 18.8 | 21.7 | 25.3 | 30.8 | 49.1 | 2.9 |
| chr17 | 16.5 | 15.7 | 17.6 | 21.5 | 27.6 | 37.4 | 2.3 |
| chr16 | 16.4 | 16.1 | 19.6 | 23.9 | 37.9 | 43.4 | 2.7 |
| chr15 | 15.9 | 16.6 | 20.6 | 25.4 | 31.1 | 46.2 | 2.9 |
| chr18 | 15.9 | 15.6 | 19.5 | 21.8 | 26.5 | 38.0 | 2.4 |
| chr20 | 11.8 | 11.6 | 12.9 | 15.4 | 19.9 | 26.8 | 2.3 |
| chr19 | 10.5 | 11.6 | 12.8 | 13.7 | 17.0 | 26.4 | 2.5 |
| chrY | 8.6 | 8.2 | 9.9 | 8.9 | 15.5 | 20.4 | 2.4 |
| chr21 | 8.5 | 7.8 | 7.8 | 9.6 | 14.4 | 14.1 | 1.6 |
| chr22 | 8.2 | 7.7 | 7.9 | 9.0 | 14.3 | 18.8 | 2.3 |
Read it two ways. Down the w1 column is the serial cost
of each chromosome, which is what sets the floor no scheduler can go
below: the largest single value is the hard limit on any
chromosome-level schedule, and it is the number segmenting exists to
reduce. Across a row is how much slower that same task became as workers
were added, with inflation summarising it. A ratio near 1.0
means the task was indifferent to its neighbours; a large ratio means it
was competing with them for memory bandwidth and cache.
The distinction matters for choosing a strategy. If inflation were uniform across chromosomes it would be a fixed tax and segmenting would not help with it. If instead the large chromosomes inflate most, then the same property that makes them stragglers also makes them the main source of contention, and cutting them into segments addresses both at once.
Segment dispatch also answers the question Part 1 left open: how
do you parallelize a single chromosome? Ship coordinates, not
sequences. Each worker receives a (start, end) pair, a few
dozen bytes, and runs the entire pipeline on its own segment. Every row
of the Part 1 cost table becomes parallel except worker startup, because
the preprocessing happens inside the workers and the half-gigabyte
serialization never occurs.
chr_len <- GenomeInfoDb::seqlengths(genome)[["chr1"]]
## Segment length must be a multiple of `slide` so the window grid stays
## aligned across segment boundaries (50 Mb = 250,000 x 200).
seg_starts <- seq(1, chr_len, by = 50e6)
seg <- data.frame(start = seg_starts,
end = pmin(seg_starts + 50e6 - 1, chr_len))
t_seg <- system.time({
res_seg <- bplapply(seq_len(nrow(seg)), function(i, seg, pkg) {
suppressPackageStartupMessages(library(TmCalculator))
suppressPackageStartupMessages(library(pkg, character.only = TRUE))
bins <- make_genomiccoord(bsgenome = pkg, chromosomes = "chr1",
window = 200L, slide = 200L,
start = seg$start[i], end = seg$end[i],
strand = "+", trim_N = "none",
verbose = FALSE)
gr <- to_genomic_ranges_fast(list(pkg_name = pkg, seq = bins),
method = "preload_chr")
out <- tm_calculate(gr, method = "tm_nn",
nn_table = "DNA_NN_SantaLucia_2004", Na = 50)$gr
out$sequence <- NULL; out$complement <- NULL
out
}, seg = seg, pkg = pkg, BPPARAM = SnowParam(workers = 5))
tm_chr1_seg <- sort(unlist(GenomicRanges::GRangesList(res_seg)))
})
t_seg["elapsed"] # 38 s measured, vs ~52 s for the serial FULL pipeline
length(tm_chr1_seg) # 1,152,300Mind the comparison scope: 38 s covers the entire pipeline
(windows, extraction, Tm), so the serial reference is the ~52 s pipeline
total from Part 1 — a 1.35x speedup — not the 30 s
Tm-only figure. Modest, but it is the only single-chromosome
parallelization that wins at all (within-call SnowParam
lost to serial above), and the result is verifiably identical:
1,152,300 windows equals the serial run’s 1,244,682 generated minus its
92,382 N-gap windows, exactly. That equivalence is by construction —
trim_N = "none" keeps the window grid identical across
segments (the “Skipped … regions” warning from
tm_calculate() is expected; hg38 chr1 carries an ~18 Mb
interior centromeric gap), and the 50 Mb segment length is a multiple of
slide.
The gain is capped well below worker-count because each worker pays fixed costs the serial run pays once: loading packages and reading the same 249 Mb chromosome to extract its segment, with five workers contending for the same file. Per-worker memory stays moderate, about 1 to 2 GB, since only a 50 Mb segment is windowed at a time.
Take segment dispatch. It was fastest at every worker count from three upwards, it halves the per-worker memory, and the memory saving is what lets a machine run more workers in the first place. Chromosome-level dispatch is worth keeping only if the downstream analysis wants whole chromosomes back as single objects.
For the worker count, two constraints, take the minimum:
mem_gb <- 16 # your machine
cores <- parallel::detectCores(logical = FALSE) # physical cores
per_worker_gb <- 2 # 50 Mb segment task
n_workers <- min(cores - 1L, floor((mem_gb - 4) / per_worker_gb))
n_workers
## 16 GB / 6 cores -> 5 workers, which is what the sweep found fastest
## 32 GB / 8 cores -> 7 workers
## 64 GB / 10 cores -> 9 workersUse 2 GB per worker for segment tasks and 4 GB for whole-chromosome tasks; those are the measured peaks, not estimates. Do not count hyperthreads. The compiled core is CPU-bound and logical cores add little beyond scheduler overhead, and the laptop sweep shows the run is already contending for cache at five real cores. On the compute node the memory term is nowhere near binding, 22 workers at 48 GB, so the core term governs; with six slots the formula gives five, and the sweep was still improving at six. That is the case the formula is built for: it is a floor to start from, and the sweep is what sets the number.
Treat the memory bound as hard. When workers do collide the system swaps, and a swapping parallel run is slower than serial: we measured 19 GB of swap and a wedged machine in exactly this state, aggravated by leftover workers from interrupted runs.
Two smaller settings that the sweep shows are not optional. Sort the
tasks largest-first, because the scheduler deals them out in order and a
long task placed last has nothing left to overlap with. And pass
tasks = length(X) to SnowParam(); leaving it
at the default is what makes a run “static”, and that cost 60 s at four
workers here for no benefit at all.
The whole measurement is scripted, including the per-task instrumentation that separates worker start-up from compute and records each worker’s peak resident memory:
# laptop, or any single machine
Rscript inst/scripts/bench_parallel_strategy.R \
--workers 1,2,3,4,5,6 --reps 3 --outfile bench_parallel_strategy.csv
# cluster node under LSF. Edit the conda environment, queue and CPU model
# in the #BSUB header first; the script activates the environment itself,
# checks that the package and hg38 are installed before it starts, and
# writes results/ under the directory it was submitted from.
bsub < inst/scripts/bench_parallel_cluster.lsf
# the two-environment table and figure of the manuscript
Rscript inst/scripts/make_figure5_two_env.R \
--csv-hpc results/bench_parallel_cluster.csv \
--csv-mac bench_parallel_strategy.csvInclude one worker in the sweep. It is the baseline every speedup is divided by, and BiocParallel runs a single worker inside the manager rather than spawning one, so that point measures the serial cost with nothing to serialise. Without it the only available ratios are against each strategy’s own summed task times, which are not comparable across strategies because segmenting changes how much total work there is.
For a cluster, inst/scripts/bench_parallel_cluster.R
adds the three things a shared machine needs: it reads the allocated
core count from the scheduler rather than from
detectCores(), which on a shared node reports the whole
machine and oversubscribes badly; it stages the genome package on
node-local disk, because BSgenome reads the .2bit at random
offsets and several workers hitting a shared filesystem measure storage
latency instead of software; and it sweeps physical cores only. The
submission file pins one CPU model, because a cluster whose nodes span
several processor families would otherwise return a range that measures
the scheduler; requests six slots and 48 GB, for the reasons given
above; records the node’s load before and after the sweep; and samples
summed RSS and PSS every ten seconds so the shared-memory question can
be answered from the log rather than assumed.
Four operational notes. First, benchmark in a session that did
not restore a saved workspace
([Workspace loaded from ~/.RData] at startup means
gigabytes of old objects are already resident); disable “Restore .RData
at startup” in RStudio or start R --vanilla. Second,
SnowParam() workers load the installed package:
after devtools::load_all() development changes, run
devtools::install() before benchmarking, and note that
load_all() compiles C++ at -O0 — time only the
installed build. The same applies to
devtools::build_vignettes(), which evaluates vignette code
against a -O0 debug build (timings come out ~6x slow); set
options(pkg.build_extra_flags = FALSE) to disable the debug
flags, or rely on R CMD build /
devtools::check(), which install with normal optimization
before running vignettes. Changing the flag option does not
trigger recompilation — stale -O0 object files in
src/ are reused as long as sources are unchanged — so run
pkgbuild::clean_dll() after changing it. Third, if a
parallel run errors or is interrupted, workers can linger and hold
gigabytes; check for orphaned R processes (Activity Monitor
on macOS, ps aux | grep R elsewhere) before rerunning.
Fourth, worker message() output is buffered until each task
finishes — a silent console does not mean a hung run; watch the worker
processes’ CPU instead.
For hg38 with the compiled nearest-neighbor core, run single
chromosomes serially; tm_calculate() has no parallel option
because within-call parallelism cannot beat Amdahl’s law when the
parallel fraction is about 30%.
For the whole genome, parallelize outside the call, over 50 Mb
segments rather than whole chromosomes, with
SnowParam(workers = n, tasks = length(X)) and the tasks
sorted largest-first. On a 6-core, 16 GB laptop that runs 14.7 million
windows in 189 s at five workers, against 578 s serially, a speedup of
3.06 at 1.9 GB per worker. The same run organised by chromosome takes
218 s at best and needs 2.9 GB per worker. On a compute node with six
slots and 48 GB the segment run takes 127 s, a speedup of 4.61, and the
chromosome-organised run is within 2 s of it.
Set the worker count from
min(physical cores - 1, (memory - 4) / 2), then verify
rather than trust it. On the laptop every strategy turned over before
the cores ran out, and the reason was not scheduling: the total task
time grew with the worker count, by 53% to 152% from one worker to six,
as workers competed for memory bandwidth and cache. On the node, with
three times the memory, that growth fell to under a fifth and nothing
turned over. The turnover is a property of the machine, so the right
worker count on your hardware is the one the sweep finds, not the one
copied from these tables.
## R version 4.4.1 (2024-06-14)
## Platform: x86_64-apple-darwin20
## Running under: macOS Sonoma 14.6
##
## Matrix products: default
## BLAS: /Library/Frameworks/R.framework/Versions/4.4-x86_64/Resources/lib/libRblas.0.dylib
## LAPACK: /Library/Frameworks/R.framework/Versions/4.4-x86_64/Resources/lib/libRlapack.dylib; LAPACK version 3.12.0
##
## locale:
## [1] C/en_US.UTF-8/en_US.UTF-8/C/en_US.UTF-8/en_US.UTF-8
##
## time zone: America/New_York
## tzcode source: internal
##
## attached base packages:
## [1] stats4 stats graphics grDevices utils datasets methods
## [8] base
##
## other attached packages:
## [1] BSgenome.Ecoli.NCBI.ASM584v2_1.0.0 BSgenome_1.72.0
## [3] rtracklayer_1.64.0 BiocIO_1.14.0
## [5] Biostrings_2.72.1 XVector_0.44.0
## [7] GenomicRanges_1.56.2 GenomeInfoDb_1.40.1
## [9] IRanges_2.38.1 S4Vectors_0.42.1
## [11] BiocGenerics_0.50.0 TmCalculator_1.1.0
##
## loaded via a namespace (and not attached):
## [1] DBI_1.2.3 bitops_1.0-9
## [3] gridExtra_2.3 rlang_1.1.7
## [5] magrittr_2.0.4 biovizBase_1.52.0
## [7] otel_0.2.0 matrixStats_1.5.0
## [9] compiler_4.4.1 RSQLite_2.4.0
## [11] GenomicFeatures_1.56.0 png_0.1-8
## [13] vctrs_0.7.1 ProtGenerics_1.36.0
## [15] stringr_1.6.0 pkgconfig_2.0.3
## [17] crayon_1.5.3 fastmap_1.2.0
## [19] backports_1.5.0 Rsamtools_2.20.0
## [21] rmarkdown_2.30 UCSC.utils_1.0.0
## [23] bit_4.6.0 xfun_0.58
## [25] zlibbioc_1.50.0 cachem_1.1.0
## [27] jsonlite_2.0.0 blob_1.2.4
## [29] DelayedArray_0.30.1 BiocParallel_1.38.0
## [31] parallel_4.4.1 cluster_2.1.6
## [33] R6_2.6.1 VariantAnnotation_1.50.0
## [35] stringi_1.8.7 bslib_0.10.0
## [37] RColorBrewer_1.1-3 bezier_1.1.2
## [39] rpart_4.1.23 jquerylib_0.1.4
## [41] Rcpp_1.1.2 SummarizedExperiment_1.34.0
## [43] knitr_1.51 base64enc_0.1-6
## [45] Matrix_1.7-0 nnet_7.3-19
## [47] tidyselect_1.2.1 rstudioapi_0.18.0
## [49] dichromat_2.0-0.1 abind_1.4-8
## [51] yaml_2.3.12 codetools_0.2-20
## [53] curl_6.2.3 lattice_0.22-6
## [55] tibble_3.2.1 regioneR_1.36.0
## [57] Biobase_2.64.0 KEGGREST_1.44.1
## [59] evaluate_1.0.5 foreign_0.8-87
## [61] karyoploteR_1.30.0 pillar_1.10.2
## [63] MatrixGenerics_1.16.0 checkmate_2.3.2
## [65] generics_0.1.4 RCurl_1.98-1.17
## [67] ensembldb_2.28.1 ggplot2_3.5.2
## [69] scales_1.4.0 glue_1.8.0
## [71] lazyeval_0.2.2 Hmisc_5.2-3
## [73] tools_4.4.1 data.table_1.17.4
## [75] GenomicAlignments_1.40.0 XML_3.99-0.18
## [77] grid_4.4.1 colorspace_2.1-1
## [79] AnnotationDbi_1.66.0 GenomeInfoDbData_1.2.12
## [81] htmlTable_2.4.3 restfulr_0.0.15
## [83] Formula_1.2-5 cli_3.6.5
## [85] S4Arrays_1.4.1 dplyr_1.1.4
## [87] AnnotationFilter_1.28.0 gtable_0.3.6
## [89] sass_0.4.10 digest_0.6.39
## [91] SparseArray_1.4.8 rjson_0.2.23
## [93] htmlwidgets_1.6.4 farver_2.1.2
## [95] memoise_2.0.1 htmltools_0.5.9
## [97] lifecycle_1.0.5 httr_1.4.7
## [99] bit64_4.6.0-1 bamsignals_1.36.0