tests/testthat/test-search-threading.R

.search_thread_env <- c(
  "OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS",
  "VECLIB_MAXIMUM_THREADS", "NUMEXPR_NUM_THREADS"
)

test_that("thread limits depend on fitting workers independently of progress", {
  withr::local_envvar(c(
    OMP_NUM_THREADS = "4", OPENBLAS_NUM_THREADS = "3",
    MKL_NUM_THREADS = "2", VECLIB_MAXIMUM_THREADS = NA,
    NUMEXPR_NUM_THREADS = NA
  ))
  caller_threads <- c("4", "3", "2", NA_character_, NA_character_)
  caller_pid <- Sys.getpid()
  probe <- function(i) list(
    pid = Sys.getpid(),
    threads = unname(Sys.getenv(.search_thread_env, unset = NA_character_))
  )

  backends <- if (parallelly::supportsMulticore()) c(TRUE, FALSE) else TRUE
  for (is_rstudio in backends) {
    for (progress in c(FALSE, TRUE)) {
      heartbeat <- if (progress) function() invisible(NULL) else NULL
      for (jobs in 1:2) {
        # One job needs one fitting worker even when two cores are requested.
        expected <- if (jobs == 1L) caller_threads else rep("1", 5L)
        for (cores in jobs:2) {
          result <- .bifrost_search_lapply(
            seq_len(jobs), probe, num_cores = cores, is_rstudio = is_rstudio,
            heartbeat = heartbeat
          )
          for (worker in result) expect_identical(worker$threads, expected)
          pids <- vapply(result, `[[`, integer(1), "pid")
          expect_length(unique(pids), jobs)
          if (progress || jobs > 1L) expect_false(any(pids == caller_pid))
          expect_identical(
            unname(Sys.getenv(.search_thread_env, unset = NA_character_)),
            caller_threads
          )
        }
      }
    }
  }
})

test_that("worker thread limits never change the calling process", {
  # Native thread counts cannot be queried portably. Catch either setter at
  # the library boundary without changing the test runner's thread settings.
  testthat::local_mocked_bindings(
    blas_set_num_threads = function(...) {
      stop("Attempted to change the caller's BLAS threads")
    },
    omp_set_num_threads = function(...) {
      stop("Attempted to change the caller's OpenMP threads")
    },
    .package = "RhpcBLASctl"
  )

  expect_invisible(.bifrost_search_limit_worker_threads(Sys.getpid()))
})

test_that("legacy Future plans are reinitialized after stage cleanup", {
  old_plan <- future::plan("list")
  on.exit(future::plan(old_plan), add = TRUE)
  future::plan(future::sequential)
  initialization_probe <- future::future(NA)
  active <- FALSE
  legacy <- function(..., workers = 1L) {
    active <<- TRUE
    initialization_probe
  }
  class(legacy) <- c("legacy", "future", "function")
  attr(legacy, "init") <- TRUE
  attr(legacy, "cleanup") <- function() active <<- FALSE
  future::plan(legacy)
  expect_true(active)
  expect_null(attr(future::plan("next"), "backend", exact = TRUE))
  expect_identical(attr(future::plan("next"), "init", exact = TRUE), "done")

  for (fail in c(FALSE, TRUE)) {
    work <- function() {
      if (fail) stop("synthetic fitting failure")
      42L
    }
    run <- function() .bifrost_search_with_future_plan(1L, TRUE, work)
    if (fail) expect_error(run(), "synthetic fitting failure")
    else expect_identical(run(), 42L)
    expect_true(active)
    expect_s3_class(future::plan("next"), "legacy")
  }
})

test_that("an existing multisession pool cannot bypass the thread policy", {
  withr::local_envvar(c(OMP_NUM_THREADS = "4", OPENBLAS_NUM_THREADS = "3"))
  old_plan <- future::plan("list")
  on.exit(future::plan(old_plan), add = TRUE)
  future::plan(future::sequential)
  connections_before <- nrow(showConnections())
  probe <- function(i) Sys.getenv("OPENBLAS_NUM_THREADS")
  for (initial_workers in c(1L, 2L)) {
    workers <- if (initial_workers == 1L) I(1L) else initial_workers
    future::plan(future::multisession, workers = workers)
    expect_identical(future::value(future::future(probe(1))), "3")
    for (progress in c(FALSE, TRUE)) {
      result <- .bifrost_search_lapply(
        1:2, probe, num_cores = 2L, is_rstudio = TRUE,
        heartbeat = if (progress) function() invisible(NULL) else NULL
      )
      expect_identical(result, list("1", "1"))
      expect_equal(future::nbrOfWorkers(), initial_workers)
      expect_identical(future::value(future::future(probe(1))), "3")
    }
    future::plan(future::sequential)
    expect_warning(gc(), NA)
    expect_equal(nrow(showConnections()), connections_before)
  }
})

test_that("disabled forking still runs fitting work outside the caller", {
  withr::local_options(future.fork.enable = FALSE)
  caller_pid <- Sys.getpid()
  for (cores in 1:2) {
    result <- .bifrost_search_lapply(
      seq_len(cores), function(i) Sys.getpid(), num_cores = cores,
      is_rstudio = FALSE, heartbeat = function() invisible(NULL)
    )
    expect_false(any(unlist(result) == caller_pid))
    expect_length(unique(unlist(result)), cores)
  }
})

test_that("worker counts are validated before being limited by job count", {
  for (jobs in 0:1) {
    for (cores in list(c(1, 2), numeric(), Inf, NA_real_, "2")) {
      expect_error(
        .bifrost_search_lapply(seq_len(jobs), identity, cores, is_rstudio = TRUE),
        "`num_cores` must be a single finite number", fixed = TRUE
      )
    }
  }
  expect_identical(
    .bifrost_search_lapply(integer(), identity, 2L, is_rstudio = TRUE), list()
  )
  withr::local_envvar(OMP_NUM_THREADS = "4")
  result <- .bifrost_search_lapply(
    1:2, function(i) Sys.getenv("OMP_NUM_THREADS"),
    num_cores = 1.5, is_rstudio = TRUE
  )
  expect_identical(result, list("4", "4"))
})

test_that("one-worker progress restores the caller plan after a failed fit", {
  withr::local_envvar(c(OMP_NUM_THREADS = "4", OPENBLAS_NUM_THREADS = NA))
  old_plan <- future::plan("list")
  on.exit(future::plan(old_plan), add = TRUE)
  future::plan(future::sequential)
  expect_error(
    .bifrost_search_lapply(
      1L, function(i) stop("synthetic serial failure"),
      num_cores = 1L, is_rstudio = TRUE,
      heartbeat = function() invisible(NULL)
    ),
    "synthetic serial failure"
  )
  expect_s3_class(future::plan("next"), "sequential")
  expect_identical(Sys.getenv("OMP_NUM_THREADS"), "4")
  expect_true(is.na(Sys.getenv("OPENBLAS_NUM_THREADS", unset = NA_character_)))
})

test_that("greedy search and serial weights preserve numerical threading", {
  withr::local_envvar(c(OMP_NUM_THREADS = "4", OPENBLAS_NUM_THREADS = "3"))
  set.seed(46)
  tree <- ape::rtree(10)
  baseline <- phytools::paintSubTree(tree, node = 11L, state = 0)
  candidate <- generatePaintedTrees(baseline, min_tips = 3)[2]
  caller_pid <- Sys.getpid()
  fit <- function(...) {
    # Observe settings at the fitting boundary, including in a real worker.
    if (!identical(Sys.getenv("OMP_NUM_THREADS"), "4") ||
        !identical(Sys.getenv("OPENBLAS_NUM_THREADS"), "3")) {
      stop("Numerical thread settings were changed during a serial fit")
    }
    # A fast fit can finish before the first heartbeat poll. Check its process
    # directly; deterministic polling coverage lives in test-search-spinner.R.
    if (!identical(Sys.getpid() != caller_pid, progress)) {
      stop("Fit ran in an unexpected process for the progress setting")
    }
    list(GIC = list(GIC = 90))
  }
  backends <- if (parallelly::supportsMulticore()) c(TRUE, FALSE) else TRUE
  for (is_rstudio in backends) {
    for (progress in c(FALSE, TRUE)) {
      heartbeat <- if (progress) function() invisible(NULL) else NULL
      result <- .bifrost_search_forward(
        sorted_candidates = candidate, current_best_tree = baseline,
        current_best_ic = 100, shift_id = 0L, IC = "GIC",
        formula = trait_data ~ 1, trait_data = matrix(0, 10, 1),
        shift_acceptance_threshold = 5, store_model_fit_history = FALSE,
        sub_dir = NULL, plot = FALSE, verbose_log = function(...) NULL,
        heartbeat = heartbeat, is_rstudio = is_rstudio, fit = fit
      )
      expect_length(result$shift_vec, 1L)
      weights <- .bifrost_search_calculate_ic_weights(
        uncertaintyweights = TRUE, uncertaintyweights_par = FALSE,
        shift_vec = result$shift_vec,
        best_tree_no_uncertainty = result$best_tree_no_uncertainty,
        model_with_shift_no_uncertainty = result$model_with_shift_no_uncertainty,
        IC = "GIC", formula = trait_data ~ 1, trait_data = matrix(0, 10, 1),
        args_list = list(), num_cores = 1L, is_rstudio = is_rstudio,
        verbose_log = function(...) NULL, heartbeat = heartbeat, fit = fit
      )
      expect_equal(nrow(weights), 1L)
    }
  }
})

Try the bifrost package in your browser

Any scripts or data that you put into this service are public.

bifrost documentation built on Nov. 5, 2026, 9:07 a.m.