Multi-threading

library(a5R)

a5R can parallelise vectorised operations across threads with rayon, a Rust library for data parallelism. By default a5R uses a single thread, so there is no overhead; you opt in to parallelism when you need it. Work is split across elements, so a single polygon part, covering or centre cell gains nothing.

Setting the thread count

a5_threads() reports the thread count and a5_threads(n) sets it, as options() and par() do:

a5_threads() # the default: 1
#> [1] 1

a5_threads(2)
a5_threads()
#> [1] 2

a5_info() also reports the count. To set it for a whole script or batch job, set an R option or environment variable before library(a5R); both are read when the package loads:

# In .Rprofile or at the top of a script
options(a5R.threads = 4)
A5R_NUM_THREADS=4 Rscript script.R

To use more threads for one block of code only, wrap it in with(), as with mirai::daemons(). The previous count is restored afterwards, even if the code fails:

set.seed(1)
cells <- a5_lonlat_to_cell(runif(1e4, -180, 180), runif(1e4, -80, 80), 10)
boundaries <- with(a5_threads(2), {
  a5_cell_to_boundary(cells)
})
a5_threads()
#> [1] 1

Setting a count returns the previous one, so old <- a5_threads(4) and later a5_threads(old) work too.

What gets parallelised

Threading applies to vectorised functions that process each element independently:

Function Per-element cost Benefit
a5_cell_to_boundary(), a5_cell_to_boundary_xy() Heavy (boundary + WKB/WKT) High
a5_polygon_to_cells() Heavy (per-part fill, hole subtraction) High
a5_line_string_to_cells() Heavy (per-feature tracing) High
a5_grid_disk(), a5_grid_disk_vertex(), a5_spherical_cap() Heavy (per-cell traversal) High
a5_cell_to_subcells() Heavy (per-cell search) High
a5_lonlat_to_cell(), a5_cell_to_lonlat() Moderate (projection) High
a5_cell_to_supercell() Moderate (projection) High
a5_cell_distance() Moderate (2 projections + distance) Medium
a5_union(x, y), a5_intersect(), a5_difference(), a5_contains(), a5_overlaps() Varies with covering size Medium
a5_covering_to_supercells() Search within each covering Medium
a5_cell_to_children(), a5_uncompact() Light (per-element expansion) Low
a5_cell_to_parent(), a5_get_resolution(), a5_migrate() Light (bit operations) Low

For the one-to-many functions (a5_grid_disk(), a5_spherical_cap(), a5_cell_to_children(), a5_uncompact()) the per-element work is parallelised; assembling the result, especially the simplify = FALSE list, is sequential, so the list form gains less than the flat form.

Scalar and bulk operations (a5_compact(), a5_cell_area(), a5_cell_child(), a5_cell_children_range()) are unaffected: they are already fast or do not split by element.

When is it worthwhile?

Each parallel call pays a small fixed cost to hand work to the threads. For small vectors this can outweigh the benefit. As a rule of thumb:

Here is a comparison on about 695,000 cells:

cells <- a5_uncompact(a5_polygon_to_cells(wk::rct(-10, 50, 10, 60), resolution = 12))
length(cells)
#> [1] 694889

a5_threads(1)
system.time(a5_cell_to_boundary(cells))
#>    user  system elapsed
#>   0.952   0.005   0.956

a5_threads(8)
system.time(a5_cell_to_boundary(cells))
#>    user  system elapsed
#>   1.206   0.009   0.248

user time (total CPU work across all threads) rises while elapsed (wall-clock) time falls: that is the parallelism at work.

Other parallel code

a5R uses its own rayon thread pool, separate from R’s parallelism. Combining the two multiplies the thread count, so keep the product within your cores:

Each a5_threads(n) call rebuilds the thread pool, which takes well under a millisecond, so scoping a count with with() around each heavy call costs nothing measurable.