Skip to content

perf(bootstrap): stop serializing the task list with every mirai batch - #27

Closed
seanthimons wants to merge 2 commits into
mainfrom
perf/mirai-dispatch
Closed

seanthimons wants to merge 2 commits into
mainfrom
perf/mirai-dispatch

Conversation

@seanthimons

Copy link
Copy Markdown
Owner

Problem

start_mirai_bootstrap() passed an anonymous worker to mirai::mirai_map(). That closure was created inside start_mirai_bootstrap(), so its environment was that function's frame, which holds every task (inputs) and batches. mirai serializes a function together with its environment, so each batch job carried the full task list. Dispatch cost therefore grew with the total replicate count (quadratic overall).

This blocks the calling R process. A Shiny app that calls bootstrap_dose_response_async() freezes during launch, and the default analyze_dose_response(bootstrap_times = 10000L) with the mirai backend is affected too.

Fix

  • The worker is built by an internal bootstrap_worker() whose environment is globalenv(). Its body uses only namespaced calls (pkgload::, dplyr::, singlehit:::), so it needs no free variables from the creating frame.
  • Source references are removed with utils::removeSource(). Under pkgload::load_all() the worker otherwise serialized the whole package source file (about 213 KB) with every batch. It now serializes to under 1 KB.
  • The dev_path branch is unchanged. The worker is not given the namespace as its environment, so dev-loaded workers still load the same checkout.
  • Replicate output is unchanged. The same seed gives identical results before and after.

Task generation (generate_bootstrap_tasks(), about 1 ms per replicate) is not changed here.

Measurements

bootstrap_dose_response_async(fit, times, seed = 42, workers = 2) on the Ward rotavirus data with a beta_poisson fit, using 2 local daemons. "Returns" is the wall time of the async call. Task generation is included in that time and dominates the after case. Collection time is refit compute and did not change beyond run-to-run noise.

replicates before: call returns (2 runs) after: call returns before: collect after: collect
1,000 1.30 s, 1.26 s 1.00 s 6.47 s, 6.12 s 6.13 s
4,000 7.33 s, 7.20 s 3.13 s 20.33 s, 19.45 s 20.24 s
10,000 34.89 s, 32.98 s 7.64 s 40.99 s, 39.54 s 44.83 s

Task generation at 10,000 replicates takes 7.2 to 7.5 s, so after the fix the call returns in roughly task-generation time plus about 0.4 s of dispatch. Before the fix, dispatch took about 25 s of the 33 to 35 s. Before the fix, the call took 33 to 35 s on this machine, not the 47.8 s seen earlier.

Replicate output was compared with identical() between the pre-fix and post-fix code for 1,000, 4,000 and 10,000 replicates at seed 42. All three matched.

Regression test

tests/testthat/test-bootstrap-dispatch.R, which needs no daemons and no timing:

  • The worker's environment is globalenv(), and its serialized size is under 10 KB.
  • The worker refits a batch exactly like run_sequential_bootstrap().
  • start_mirai_bootstrap() passes the detached worker to mirai_map(). This uses a mock of mirai_map and daemons_set. The test fails on the pre-fix code (2 failures) and passes with the fix.

Verification

  • testthat::test_local(): 297 passed, 0 failed, 3 skipped. Two of the skips are mirai tests that need an installed namespace.
  • R CMD build and R CMD check --no-manual: 305 passed, 0 failed, 1 skipped (the slow exact-model oracle), including the two mirai-gated tests. Status is 1 NOTE: singlehit:::bootstrap_refit is flagged as a ::: call to the package's own namespace. This is the same call that existed before this change.
  • The dev-loaded snippet from the "Development-loaded mirai bootstrap" CI job passes locally under pkgload::load_all().

Not in this PR

  • generate_bootstrap_tasks() could be made lazy as a follow-up. Task generation is now the dominant cost of the async call.
  • local_mocked_bindings() needs testthat 3.1.7 or later, but DESCRIPTION declares testthat (>= 3.0.0). I did not change DESCRIPTION.

The mirai worker was an anonymous function created inside
start_mirai_bootstrap(), so its environment was that frame. mirai
serializes a function together with its environment, so every batch
job carried the full task list and dispatch cost grew with the total
replicate count.

Build the worker in an internal bootstrap_worker() helper whose
environment is globalenv(). Its body uses only namespaced calls, so it
needs no free variables. Source references are stripped so that a
pkgload::load_all() worker does not ship the source file with each
batch. The dev_path load behavior is unchanged.

Replicate output for a given seed is unchanged.
Check that the worker's environment is globalenv() and that its
serialized payload stays small, that it refits a batch exactly like the
sequential path, and that start_mirai_bootstrap() passes the detached
worker to mirai_map().
@seanthimons

Copy link
Copy Markdown
Owner Author

Superseded by #28: the branch is renamed fix/mirai-dispatch because the branch-name lint only allows feature|feat|bugfix|fix|hotfix|release|chore|ci|docs|refactor|test prefixes (perf/ is not one of them). Same commits, same description.

@seanthimons
seanthimons deleted the perf/mirai-dispatch branch October 20, 2026 16:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant