tm_calculate() turns a genome, or any set of regions in
it, into a melting temperature profile: it tiles the regions into
windows, fetches each window’s sequence, computes a Tm, and returns the
lot as one object. The same call takes sequences you already have, so
there is one function to learn; on a mammalian genome it spreads the
work across processes.
This vignette answers three questions, in order:
Before you start. You need a BSgenome package for
the genome you want to profile; this vignette uses
BSgenome.Hsapiens.UCSC.hg38. BiocParallel
comes with TmCalculator, so there is nothing to install for the parallel
part; attach it when you want to name a backend.
The hg38 chunks below are not evaluated when the vignette is built, since they take minutes; the timing table and the figure are evaluated, and are built from measurements that ship with the package.
The smallest genome-scale call needs a genome and a window width:
library(TmCalculator)
hg38 <- "BSgenome.Hsapiens.UCSC.hg38"
tm <- tm_calculate(hg38, window = 200, slide = 200)$gr
tm
## GRanges object with 14687412 ranges and 2 metadata columns:
## seqnames ranges strand | Tm GCtm_calculate() returns a TmCalculator
object: $gr is the profile and $options
records the model and tiling it was produced with.
That tiles every standard chromosome into non-overlapping 200 bp windows and computes a nearest-neighbour Tm for each, in a single process. The window width has no default at genome scale, and is not meant to: it is the resolution of the profile, so it belongs in the call rather than in a default that quietly decides how many rows come back. Adding workers is one more argument:
tm <- tm_calculate(hg38, window = 200, slide = 200,
BPPARAM = BiocParallel::SnowParam(workers = 5))$grSame windows, same Tm values, about a third of the time. How the genome is divided changes the runtime, not the result. See the sweep below for how far adding workers gets you.
Everything else is a refinement of those two calls: which regions, which thermodynamic model, and how many workers.
The first argument says where sequence comes from. It is given by name, not as a loaded object, because each worker opens the source for itself.
tm_calculate(hg38, window = 200) # an installed BSgenome package
tm_calculate("contigs.fa.gz", window = 200) # a FASTA file, gzip is fine
tm_calculate(oligos) # a character vector of sequencesA vector of sequences is staged as a temporary FASTA under
tmpdir and deleted on exit, so that the workers read it
rather than receive it. That is worth doing for a large set, because it
moves window construction and result assembly into the workers as well;
for a handful of sequences the staging and the worker start-up cost more
than the calculation, and tm_calculate() is the right call.
On a cluster, set tmpdir to node-local scratch: the default
tempdir() is often a small partition.
regions says what to take from the source, in whichever
form is at hand.
## Chromosomes or records, by name or by number. On a BSgenome the chr
## prefix is added or removed to match the genome, so the same code works on
## UCSC and on Ensembl; FASTA record names are matched exactly.
tm_calculate(hg38, regions = 1:22, window = 200) # autosomes
tm_calculate(hg38, regions = paste0("chr", c(1:22, "X", "Y")), # no chrM
window = 200)
tm_calculate("contigs.fa.gz", regions = c("contig_7", "contig_9"),
window = 200)
## Coordinate intervals. Commas and scientific notation are accepted, so a
## number pasted out of a genome browser works as it stands.
tm_calculate(hg38, regions = c("chr1:1-10e6", "chrX:5,000,000-6,000,000"),
window = 200)
## A mixture, when some chromosomes are wanted whole and others in part.
tm_calculate(hg38, regions = c(1:20, "chr21:1-10e6", "X", "Y"), window = 200)
## A GRanges, when the regions come from an annotation.
prom <- promoters(genes(TxDb.Hsapiens.UCSC.hg38.knownGene),
upstream = 1000, downstream = 500)
tm_calculate(hg38, regions = prom, window = 50, slide = 25)
## A GRanges carrying its own sequences is a source rather than a query, and
## then regions selects by overlap: a seqname takes every range on it, an
## interval takes the ranges it meets. Whole ranges come back, not clipped
## pieces of them, since their sequences are already fixed.
tm_calculate(probes_gr, regions = "chr7")
tm_calculate(probes_gr, regions = "chr7:1-1e6")
## window = NULL, the default, gives one window per region, which is what
## short records call for: a FASTA of array probes, primers or synthetic
## oligos returns one Tm per record. It is refused for a region over 1 Mb,
## where a single Tm would mean nothing.
tm_calculate("probes.fa", window = NULL)
tm_calculate(oligos, BPPARAM = BiocParallel::SnowParam(5))Three things are worth knowing before you trust the output.
The default includes chrM. With no regions, a
BSgenome source covers GenomeInfoDb::standardChromosomes(),
which for GRCh38 is the 24 assembled chromosomes and
the mitochondrion. Name the chromosomes explicitly if that does not
belong in your profile. The sweep later in this vignette uses
paste0("chr", c(1:22, "X", "Y")) for exactly that reason,
and its 14,687,330 windows are the 24 without chrM.
Whole chromosomes are trimmed, named regions are not. A region that names a whole chromosome has its leading and trailing assembly gaps trimmed, since a telomeric run of N carries no windows. A region given by coordinate is tiled from the start you asked for. Windows containing N are dropped either way.
Overlapping regions are not merged. They produce windows
that appear twice in the result, so tm_calculate() warns
rather than double-counting them quietly.
Array manifests are not read directly, since their layout is vendor
and version specific. Take the probe sequences out with the package that
already understands the manifest, illuminaio,
minfi or sesame for Infinium arrays, then pass
them as a FASTA file or straight to tm_calculate().
A task is the unit one worker owns from start to
finish: it opens the source, builds its own windows, fetches its own
sequence and computes its own Tm. Only a name and a coordinate pair
cross between processes, so no sequence is ever serialized.
unit decides how tasks are cut.
tm_calculate(hg38, window = 200, unit = "segment", # the default: 73 tasks
segment_size = 50e6, BPPARAM = BiocParallel::SnowParam(5))
tm_calculate(hg38, window = 200, unit = "region", # 24 tasks
BPPARAM = BiocParallel::SnowParam(5))The two calls return the same profile, row for row.
regions and window decide what is computed;
unit and segment_size decide only how that
work is handed out, and segment boundaries are held to multiples of
slide so the window grid cannot shift when the segmenting
changes.
Segments are the better default, for two reasons. Chromosome 1 is an indivisible task of 249 Mb, so with one task per chromosome the run cannot finish before chromosome 1 does, however many workers are available. And a worker holding a 50 Mb segment needs less memory than one holding a whole chromosome.
Prefer SnowParam() to
MulticoreParam(). 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 collection forces the
kernel to duplicate the inherited pages. On a genome-scale input that
has been measured running slower than a single process.
The sweep below ships with the package: hg38 at 200 bp non-overlapping windows, 14,687,330 windows, one to six workers, three repetitions per configuration, on a six-core 16 GB laptop and on a cluster compute node given six slots and the same 16 GB, so that the two differ in their processors and not in their quota.
# Read the shipped summaries rather than transcribing numbers, so the table
# cannot drift from the measurements it describes.
read_sweep <- function(file, env) {
d <- utils::read.csv(system.file("extdata", file, package = "TmCalculator"),
stringsAsFactors = FALSE)
d$Environment <- env
d
}
sweep <- rbind(read_sweep("bench_hg38_laptop.csv", "Laptop"),
read_sweep("bench_hg38_cluster.csv", "Compute node"))
sweep <- sweep[order(sweep$Environment != "Laptop", sweep$n_workers), ]knitr::kable(
data.frame(
Environment = sweep$Environment,
Workers = sweep$n_workers,
`Wall time (s)` = sprintf("%.1f [%.1f-%.1f]", sweep$wall_s, sweep$lo, sweep$hi),
Speedup = sprintf("%.2f", sweep$speedup),
`Peak RSS per worker (GB)` = sprintf("%.2f", sweep$peak_worker_gb),
check.names = FALSE),
row.names = FALSE,
caption = paste("Median of three repetitions, observed range in brackets.",
"Wall time includes worker start-up."))| Environment | Workers | Wall time (s) | Speedup | Peak RSS per worker (GB) |
|---|---|---|---|---|
| Laptop | 1 | 590.3 [586.1-590.7] | 1.00 | 3.47 |
| Laptop | 2 | 336.8 [335.4-337.6] | 1.75 | 2.50 |
| Laptop | 3 | 239.6 [233.1-243.9] | 2.46 | 2.63 |
| Laptop | 4 | 197.4 [196.4-199.8] | 2.99 | 2.18 |
| Laptop | 5 | 179.7 [178.0-184.0] | 3.28 | 2.00 |
| Laptop | 6 | 179.8 [176.4-214.9] | 3.28 | 2.81 |
| Compute node | 1 | 494.5 [485.8-529.0] | 1.00 | 3.55 |
| Compute node | 2 | 277.4 [276.8-278.1] | 1.78 | 3.53 |
| Compute node | 3 | 197.0 [196.7-197.3] | 2.51 | 2.88 |
| Compute node | 4 | 154.3 [154.1-156.3] | 3.20 | 2.95 |
| Compute node | 5 | 129.9 [128.8-130.3] | 3.81 | 3.10 |
| Compute node | 6 | 114.4 [114.0-116.0] | 4.32 | 3.59 |
op <- par(mfrow = c(1, 2), mar = c(4.2, 4.4, 2.2, 0.8), mgp = c(2.6, 0.7, 0))
for (what in c("wall", "rss")) {
ys <- if (what == "wall") sweep$wall_s else sweep$peak_worker_gb
plot(range(sweep$n_workers), range(0, ys * 1.05), type = "n",
xlab = "Workers", xaxt = "n", adj = 0,
ylab = if (what == "wall") "Wall clock (s)" else "Peak resident size per worker (GB)",
main = if (what == "wall") "A" else "B")
axis(1, at = sort(unique(sweep$n_workers)))
for (e in unique(sweep$Environment)) {
d <- sweep[sweep$Environment == e, ]
y <- if (what == "wall") d$wall_s else d$peak_worker_gb
solid <- e == "Laptop"
if (what == "wall")
arrows(d$n_workers, d$lo, d$n_workers, d$hi, angle = 90, code = 3,
length = 0.03, col = "grey40")
lines(d$n_workers, y, lty = if (solid) 1 else 2, col = "grey20")
points(d$n_workers, y, pch = if (solid) 19 else 1, col = "grey20")
}
if (what == "wall")
legend("topright", c("Laptop, 6 cores", "Compute node, 6 slots"),
lty = c(1, 2), pch = c(19, 1), bty = "n", cex = 0.85, col = "grey20")
}Two things in that table decide a worker count.
More workers stop helping, and where depends on cores. The laptop is fastest at five workers, 179.7 s against 590.3 s in one process, and a sixth worker returns the same time with a far wider spread across repetitions (176.4 to 214.9 s, against 10.9 s or less at every smaller count). Five workers and the process that dispatches them already occupy the laptop’s six cores, so the sixth has none left. The node, whose six slots sit on a forty-core machine, improves all the way to six workers and 114.4 s, its repetitions differing by 2.2 s or less throughout.
Memory was not the limit on either machine. Peak resident size per worker reached 3.5 GB in the one-worker run and stayed between 2.0 and 3.6 GB thereafter, because no worker holds more than one 50 Mb task at a time. Both sweeps ran well inside 16 GB.
Fitting the wall times to Amdahl’s law gives
T(n) = 79.4 + 509.2/n on the laptop and
T(n) = 41.6 + 456.4/n on the node, both with R-squared of
at least 0.997. The divisible part is nearly the same on the two
machines, as it should be for the same work; what differs is the
constant, by a factor of 1.9, and the number of cores available to
divide among.
A rule that works on both machines:
workers = physical cores - 1
Confirm it on your own machine with a short sweep over one chromosome rather than the whole genome. Note that worker start-up, roughly nine seconds per call, is included in every timing, so a run that takes less than a minute will understate the speedup:
for (n in 1:6) {
t <- system.time(
tm_calculate(hg38, regions = "chr21", window = 200, slide = 200,
method = "tm_nn", segment_size = 10e6,
BPPARAM = BiocParallel::SnowParam(n), verbose = FALSE))
cat(sprintf("%d workers: %.1f s\n", n, t[["elapsed"]]))
}The sweeps above were produced by bench_tm_calculate.R,
driven by bench_tm_calculate_local.sh on the laptop and by
bench_tm_calculate.lsf on the compute node. All three are
in system.file("scripts", package = "TmCalculator"), and
their summaries are the two CSV files read here,
bench_hg38_laptop.csv and
bench_hg38_cluster.csv in extdata, so
re-running one on your own machine produces a file this vignette can
read.
bench_tm_calculate.R in the same directory times
tm_calculate() itself over a range of worker counts, with
bench_tm_calculate.lsf to submit it, and is the one to
reach for when sizing a new machine.
A GRanges with Tm and GC
metadata columns in genomic order, ready for
integrate_granges(), compare_groups() and the
plotting functions, and for anything else that takes genomic
intervals.
tm_annot <- integrate_granges(gr_tm = tm, gr_features = atac_peaks,
strategy = "overlap", weight = "overlap")
compare_groups(tm_annot, value_cols = "Tm", group_col = "class")The sequence and complement columns are dropped by default, since
they run to roughly 500 MB per large chromosome; pass
keep_sequence = TRUE if you need them.
tm_calculate() starts either from a source it can open,
as above, or from sequences you already hold:
Given sequences and nothing else it does exactly what it has always
done, one Tm per sequence, by the shortest path through the function.
Add regions, window or BPPARAM
and the profiling machinery engages.
What it will not do is divide the sequences of one region among workers. With the compiled core the per-window loop is a minority of a call’s cost; window construction, sequence retrieval and result assembly run once, and sending the sequences to workers costs more than the loop it divides. Measured on chromosome 1, splitting one call across five workers was never faster than not splitting it. Parallelism therefore divides by region, and sequences handed in directly are staged to a temporary file so that the workers read them rather than receive them.
## 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.1
##
## 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