greta#855 (tf-warmup-i547): call overhead in mcmc() and opt()

Runtime per call of mcmc() and opt() on greta#855 with and without its last two changes, which keep opt()’s traced loop and mcmc()’s chain state in TensorFlow between calls. Without them, a call to opt(), or to mcmc() with one_by_one = TRUE, takes several times as long.
Published

October 7, 2026

Setup
source(here::here("posts", "_helpers.R"))

post_path <- "posts/2026-10-07-call-overhead-i547"
file_link <- file_linker(post_path)
section_link <- section_linker(post_path)
# the models' sections of benchmark.R, shown below by their labels
knitr::read_chunk(here(post_path, "benchmark.R"))

sessions <- read_sessions(post_path)
n_sessions <- length(sessions)
# the second run, of mcmc() with verbose = FALSE alone
sessions_one_call <- read_sessions(post_path, "results-one-call")
one_call_settings <- sessions_one_call[[1]]$results[[1]]$one_call_settings
# the third run, of #855 as measured here and as it stands after the PR's
# review
sessions_review <- read_sessions(post_path, "results-review-head")
review_labels <- c(measured = "#855", reviewed = "#855 reviewed")
review_versions <- display_versions(
  sessions_review[[1]]$versions,
  labels = review_labels
)

# the names the results give the versions, and the names this page shows
version_labels <- c(CRAN = "CRAN", earlier = "#855 before", branch = "#855")
# pak installed earlier and branch from archives of their commits, which the
# results record as local paths; run.R names the same commits on GitHub
versions <- display_versions(
  sessions[[1]]$versions,
  labels = version_labels,
  on_github = c(
    earlier = "greta-dev/greta@11405935549722ecbf035a2712a007419e52af66",
    branch = "greta-dev/greta@f30ab1469ef9eafab8bb2ff48be10860cd3fd9ab"
  )
)
version_levels <- names(versions)
colours <- version_colours(version_levels)
bench_settings <- sessions[[1]]$results[[1]]$bench_settings

# how the text names each version: #855 links to the PR, and #855 before to
# its commit
version_text <- c(
  CRAN = "CRAN",
  `#855 before` = paste0(
    "[#855 before](",
    commit_url(versions[["#855 before"]]),
    ")"
  ),
  `#855` = paste0("[#855](", pr_url(855), ")")
)
version_ref <- function(version) I(version_text[[version]])

# one row per session and version, holding that version's results, with the
# versions named and ordered by `labels`
runs_of <- function(session_files, labels) {
  lapply(session_files, function(session_file) {
    tibble(
      session = session_file$session,
      version = factor(
        unname(labels[names(session_file$results)]),
        levels = unname(labels)
      ),
      results = unname(session_file$results)
    )
  }) |>
    bind_rows()
}
runs <- runs_of(sessions, version_labels)

model_levels <- names(runs$results[[1]]$mcmc)

# opt() was also timed at fewer iterations, which this page leaves out
opt_iterations <- max(bench_settings$opt_max_iterations)

# what each figure has a row of panels for: mcmc()'s three ways of running,
# named as benchmark.R names them, and opt()
case_labels <- c(
  one_by_one = "mcmc(), one_by_one = TRUE, no progress bar",
  progress = "mcmc(), progress bar shown (verbose = TRUE)",
  one_call = "mcmc(), no progress bar (verbose = FALSE)",
  opt = paste0("opt(), adam(), max_iterations = ", opt_iterations)
)
mcmc_cases <- c("one_by_one", "progress", "one_call")

# every timed call in `runs`, in every session, version, model and case
timed_calls_of <- function(runs) {
  lapply(seq_len(nrow(runs)), function(i) {
    lapply(model_levels, function(model_name) {
      mcmc_marks <- runs$results[[i]]$mcmc[[model_name]]
      mcmc_calls <- lapply(mcmc_cases, function(mode) {
        every_call(mcmc_marks[[mode]]) |> mutate(case = case_labels[[mode]])
      })
      opt_calls <- every_call(
        runs$results[[i]]$opt[[model_name]],
        max_iterations
      ) |>
        filter(max_iterations == opt_iterations) |>
        select(-max_iterations) |>
        mutate(case = case_labels[["opt"]])
      bind_rows(mcmc_calls, opt_calls) |>
        mutate(model = model_name)
    }) |>
      bind_rows() |>
      mutate(session = runs$session[i], version = runs$version[i])
  }) |>
    bind_rows() |>
    mutate(
      model = factor(model, levels = model_levels),
      case = factor(case, levels = case_labels)
    )
}
timed_calls <- timed_calls_of(runs)

# each version's median first call, and median call after the first, for each
# case and model, and how many times as long #855 before and CRAN take as #855
call_medians <- timed_calls |>
  group_by(calls, case, model, version) |>
  summarise(seconds = median(seconds), .groups = "drop") |>
  pivot_wider(names_from = version, values_from = seconds) |>
  mutate(
    before_over_pr = `#855 before` / `#855`,
    cran_over_pr = CRAN / `#855`
  )

# one column of call_medians, over the two models and the cases named
ratios_of <- function(column, cases, which_calls = "calls after the first") {
  rows <- call_medians$calls == which_calls &
    call_medians$case %in% case_labels[cases]
  call_medians[[column]][rows]
}
range_of <- function(column, cases, which_calls = "calls after the first") {
  format_range(ratios_of(column, cases, which_calls))
}

# how #855 before and CRAN compare with #855, naming the slower
before_vs_pr <- function(cases, which_calls = "calls after the first") {
  compare_times(
    ratios_of("before_over_pr", cases, which_calls),
    version_text[["#855 before"]],
    version_text[["#855"]]
  )
}
cran_vs_pr <- function(cases, which_calls = "calls after the first") {
  compare_times(
    ratios_of("cran_over_pr", cases, which_calls),
    version_text[["CRAN"]],
    version_text[["#855"]]
  )
}

# the sections of benchmark.R a reader runs to repeat the main run, in order
main_run_sections <- c(
  "benchmark-setup",
  "benchmark-settings",
  "model-linear",
  "model-hierarchical-linear",
  "benchmark-mcmc",
  "benchmark-opt",
  "bench-linear",
  "bench-hierarchical-linear"
)
main_run_returns <- paste(
  "list(mcmc = list(linear = linear_mcmc,",
  "hierarchical_linear = hierarchical_linear_mcmc),",
  "opt = list(linear = linear_opt, hierarchical_linear = hierarchical_linear_opt))"
)

# what each "Run it yourself" block shows: the main run, and the second run
# of mcmc() with verbose = FALSE, on #855 before and #855 only
main_run_code <- run_it_yourself(
  post_path,
  "benchmark.R",
  main_run_sections,
  versions,
  main_run_returns
)
one_call_code <- run_it_yourself(
  post_path,
  "benchmark-one-call.R",
  c("one-call-setup", "one-call-settings", "one-call-models", "one-call-mcmc"),
  versions[c("#855 before", "#855")],
  "list(linear = linear_one_call, hierarchical_linear = hierarchical_linear_one_call)"
)

greta#855’s last two changes keep opt()’s traced loop on the model, and keep mcmc()’s chain state as tensors between the calls of a phase. This post, a companion to the PR’s main post, times #855 against #855 before those changes and against greta 0.6.0 from CRAN, calling each method several times on each model. Once the first call has traced the model, the median runtime per call compares as:

The versions.

  • CRAN: greta 0.6.0. For the same warmup and n_samples it runs twice the sampler iterations the other two do: it runs thin + 1 iterations per draw, and greta#850, which #855 builds on, made that thin.
  • #855 before: greta-dev/greta@11405935. opt() traces its loop afresh on every call, and mcmc() turns the chain’s state, its tuning and the phase’s fixed inputs into tensors before every call to TensorFlow, and the state and tuning back into R after it.
  • #855: greta-dev/greta@f30ab146, the PR’s commit with the two changes. opt() keeps its traced loop on the model and starts it afresh from the initial values on every call, and the chain’s state and tuning stay as tensors from one call to the next, coming back to R when a phase ends, with the phase’s fixed inputs made into tensors once. Before this ran, every seeded draw, tuned parameter and opt() result was checked to be identical on the two.
  • #855 reviewed: greta-dev/greta@88e44dff, the PR’s head after its review, timed only in the last section. Each optimiser stores its function as a closure, and the traced functions are kept under rlang::hash() of their settings.

The models, two of greta’s examples. Normal and log-normal distributions are written with their mean and standard deviation, as greta’s normal() and lognormal() take them; \(\text{Cauchy}^{+}\) is a Cauchy truncated to positive values.

  • linear: each department’s rating in attitude regressed on its number of complaints.

    \[ \begin{aligned} \text{rating}_i &\sim \text{Normal}(\mu_i, \sigma), \quad i = 1, \dots, 30 \\ \mu_i &= \alpha + \beta \, \text{complaints}_i \\ \alpha, \beta &\sim \text{Normal}(0, 10) \\ \sigma &\sim \text{Cauchy}^{+}(0, 3) \end{aligned} \]

  • hierarchical_linear: sepal length on sepal width in iris, with an offset for each species, \(s[i]\) being flower \(i\)’s. Setosa’s offset is zero and the other two share a scale, \(\tau\).

    \[ \begin{aligned} \text{Sepal.Length}_i &\sim \text{Normal}(\mu_i, \sigma), \quad i = 1, \dots, 150 \\ \mu_i &= \alpha + \beta \, \text{Sepal.Width}_i + \gamma_{s[i]} \\ \gamma_1 &= 0, \qquad \gamma_2, \gamma_3 \sim \text{Normal}(0, \tau) \\ \tau &\sim \text{LogNormal}(0, 1) \\ \alpha, \beta &\sim \text{Normal}(0, 10) \\ \sigma &\sim \text{Cauchy}^{+}(0, 3) \end{aligned} \]

benchmark.R: model-linear
build_linear <- function() {
  int <- normal(0, 10)
  coef <- normal(0, 10)
  sd <- cauchy(0, 3, truncation = c(0, Inf))
  mu <- int + coef * attitude$complaints
  distribution(attitude$rating) <- normal(mu, sd)
  model(int, coef, sd)
}
benchmark.R: model-hierarchical-linear
build_hierarchical_linear <- function() {
  int <- normal(0, 10)
  coef <- normal(0, 10)
  sd <- cauchy(0, 3, truncation = c(0, Inf))
  species_sd <- lognormal(0, 1)
  species_offset <- normal(0, species_sd, dim = 2)
  species_effect <- rbind(0, species_offset)
  species_id <- as.numeric(iris$Species)
  mu <- int + coef * iris$Sepal.Width + species_effect[species_id]
  distribution(iris$Sepal.Length) <- normal(mu, sd)
  model(int, coef, sd, species_sd, species_offset)
}

What was timed. In each session, on each model, built afresh for each so that its first call traces:

  • mcmc() on 4 chains, 4 times each way: with one_by_one = TRUE and no progress bar, warmup of 200 and n_samples of 200; and with the progress bar shown (verbose = TRUE, the default) and with no progress bar (verbose = FALSE), both with warmup of 1000 and n_samples of 1000 (benchmark.R’s benchmark-mcmc section).
  • opt() with adam(), 6 times with max_iterations of 2000 (its benchmark-opt section). It was also timed with max_iterations of 20, which this page leaves out, as no one would run opt() for so few.
  • A second run timed mcmc() with verbose = FALSE alone, on #855 before and #855, 10 times on each model (benchmark-one-call.R’s one-call-mcmc section).
  • A third run, after the PR’s review, timed this benchmark.R again, unchanged, on #855 and #855 reviewed, and equivalence.R compared their seeded results.

The sessions. run.R installed each version as its run-versions section says, and ran benchmark.R for each in a fresh R session of its own, in 5 sessions, in a new random order each session (its run-sessions section). run-one-call.R ran the second run in 6 sessions, each version running first in every other one, and run-review-head.R the third in 4 sessions, the same way. Every version ran on TensorFlow 2.21.0, TensorFlow Probability 0.25.0 and reticulate 1.47.0, on Darwin 25.5.0 arm64 with 8 cores.

The scripts in full.

run.R
# Runs benchmark.R against CRAN and two commits of the tf-sampler-i547 branch
# of greta, in a fresh R session per version, in five sessions:
#
#   Rscript --quiet --vanilla posts/2026-10-07-call-overhead-i547/run.R
#
# Each session writes results/session-<n>.rds, and sessions already there are
# skipped, so a rerun after a crash carries on. Each `# ---- label ----` line
# starts a section that index.qmd shows by that label.

# ---- run-versions ----
# Pin every version to a commit: they move, and the post links each one.
# earlier is tf-sampler-i547 at 11405935, greta's sampler and opt() loops in
# TensorFlow. branch is its head at f30ab146, which also keeps opt()'s traced
# loop on the model, and the chain's state as tensors between the calls of a
# phase. Neither was on GitHub when this ran, so pak installed each from an
# archive of the commit, and the saved results record those local paths
versions <- c(
  CRAN = "greta@0.6.0",
  earlier = "greta-dev/greta@11405935549722ecbf035a2712a007419e52af66",
  branch = "greta-dev/greta@f30ab1469ef9eafab8bb2ff48be10860cd3fd9ab"
)
n_sessions <- 5
post_dir <- here::here("posts", "2026-10-07-call-overhead-i547")

# ---- run-sessions ----
dir.create(file.path(post_dir, "results"), showWarnings = FALSE)

for (session in seq_len(n_sessions)) {
  session_file <- file.path(
    post_dir,
    "results",
    sprintf("session-%d.rds", session)
  )
  if (file.exists(session_file)) {
    next
  }

  # a fresh order each session, so no version is always measured first or last
  order <- sample(names(versions))

  # cross::run_versions() installs each version into a library of its own and
  # evaluates the expression in a fresh R session against it. That session
  # cannot see this one's variables, so the script's path and the session
  # number reach it as environment variables: args_callr is passed on to
  # callr::r(), whose `env` sets variables for the child process (see ?callr::r
  # and ?callr::rcmd_safe_env, whose defaults are kept).
  measured <- cross::run_versions(
    source(Sys.getenv("GRETA_BENCH_SCRIPT"), local = TRUE)$value,
    pkgs = unname(versions[order]),
    args_callr = list(
      env = c(
        callr::rcmd_safe_env(),
        GRETA_BENCH_SCRIPT = file.path(post_dir, "benchmark.R"),
        GRETA_BENCH_SESSION = session
      )
    )
  )

  saveRDS(
    list(
      session = session,
      versions = versions,
      order = order,
      results = setNames(measured$result, order)
    ),
    session_file,
    compress = "xz"
  )
}
benchmark.R
# What each version of greta runs, in a fresh R session of its own: run.R
# sources it once per version per session, and keeps the list it ends with.
# Each `# ---- label ----` line starts a section that index.qmd shows by that
# label.
#
# Run it alone to try it against whichever greta is installed:
#
#   Rscript --quiet --vanilla posts/2026-10-07-call-overhead-i547/benchmark.R

# ---- benchmark-setup ----
library(greta)
library(bench)
session <- as.integer(Sys.getenv("GRETA_BENCH_SESSION", "1"))
session_seed <- 2026 - 10 - 7 + session

# ---- benchmark-settings ----
# warmup and n_samples are mcmc()'s arguments, so they count draws, and the
# one_by_one_* settings are those of the one_by_one calls; opt_max_iterations
# are the iteration limits opt() is timed at; the *_calls settings count timed
# calls
bench_settings <- list(
  chains = 4,
  warmup = 1000,
  n_samples = 1000,
  one_by_one_warmup = 200,
  one_by_one_n_samples = 200,
  mcmc_calls = 4,
  opt_max_iterations = c(20L, 2000L),
  opt_calls = 6
)

# ---- model-linear ----
build_linear <- function() {
  int <- normal(0, 10)
  coef <- normal(0, 10)
  sd <- cauchy(0, 3, truncation = c(0, Inf))
  mu <- int + coef * attitude$complaints
  distribution(attitude$rating) <- normal(mu, sd)
  model(int, coef, sd)
}

# ---- model-hierarchical-linear ----
build_hierarchical_linear <- function() {
  int <- normal(0, 10)
  coef <- normal(0, 10)
  sd <- cauchy(0, 3, truncation = c(0, Inf))
  species_sd <- lognormal(0, 1)
  species_offset <- normal(0, species_sd, dim = 2)
  species_effect <- rbind(0, species_offset)
  species_id <- as.numeric(iris$Species)
  mu <- int + coef * iris$Sepal.Width + species_effect[species_id]
  distribution(iris$Sepal.Length) <- normal(mu, sd)
  model(int, coef, sd, species_sd, species_offset)
}

# ---- benchmark-mcmc ----
# mcmc() on a model built afresh, mcmc_calls times: the first call traces its
# TensorFlow functions and later calls reuse them. Three ways of running it,
# which differ in how often the sampler returns to R:
#
# - one_by_one: once every iteration
# - progress: every pb_update iterations, to update the progress bar, as
#   mcmc() does by default
# - one call: once per phase, with verbose = FALSE
bench_mcmc <- function(build) {
  run_with <- function(...) {
    m <- build()
    set.seed(session_seed)
    mark(
      mcmc(m, chains = bench_settings$chains, ...),
      iterations = bench_settings$mcmc_calls,
      check = FALSE,
      memory = FALSE,
      filter_gc = FALSE
    )
  }
  list(
    one_by_one = run_with(
      warmup = bench_settings$one_by_one_warmup,
      n_samples = bench_settings$one_by_one_n_samples,
      one_by_one = TRUE,
      verbose = FALSE
    ),
    progress = run_with(
      warmup = bench_settings$warmup,
      n_samples = bench_settings$n_samples
    ),
    one_call = run_with(
      warmup = bench_settings$warmup,
      n_samples = bench_settings$n_samples,
      verbose = FALSE
    )
  )
}

# ---- benchmark-opt ----
# opt() with adam() on a model built afresh for each iteration limit,
# opt_calls times: the first call on a model traces its loop, and later calls
# may reuse it
bench_opt <- function(build) {
  press(
    max_iterations = bench_settings$opt_max_iterations,
    {
      m <- build()
      set.seed(session_seed)
      mark(
        opt(m, optimiser = adam(), max_iterations = max_iterations),
        iterations = bench_settings$opt_calls,
        check = FALSE,
        memory = FALSE,
        filter_gc = FALSE
      )
    }
  )
}

# ---- bench-linear ----
linear_mcmc <- bench_mcmc(build_linear)
linear_opt <- bench_opt(build_linear)

# ---- bench-hierarchical-linear ----
hierarchical_linear_mcmc <- bench_mcmc(build_hierarchical_linear)
hierarchical_linear_opt <- bench_opt(build_hierarchical_linear)

# ---- benchmark-provenance ----
session_info <- sessioninfo::session_info()
# greta_sitrep() reports through messages, so capture those
greta_sitrep_report <- utils::capture.output(greta_sitrep(), type = "message")
tf <- tensorflow::tf
tfp <- reticulate::import("tensorflow_probability")

provenance <- data.frame(
  greta_version = as.character(packageVersion("greta")),
  r_version = paste(R.version$major, R.version$minor, sep = "."),
  tensorflow_version = tf$version$VERSION,
  tfp_version = tfp$`__version__`,
  reticulate_version = as.character(packageVersion("reticulate")),
  machine = paste(
    Sys.info()[c("sysname", "release", "machine")],
    collapse = " "
  ),
  cores = parallel::detectCores()
)

# ---- benchmark-results ----
# the value source() returns to run.R: every bench_mark object as mark() and
# press() made it
list(
  session_seed = session_seed,
  bench_settings = bench_settings,
  provenance = provenance,
  session_info = session_info,
  greta_sitrep_report = greta_sitrep_report,
  mcmc = list(
    linear = linear_mcmc,
    hierarchical_linear = hierarchical_linear_mcmc
  ),
  opt = list(
    linear = linear_opt,
    hierarchical_linear = hierarchical_linear_opt
  )
)
run-one-call.R
# Runs benchmark-one-call.R against the two commits of the tf-sampler-i547
# branch that run.R compares, in a fresh R session per version, in six
# sessions:
#
#   Rscript --quiet --vanilla posts/2026-10-07-call-overhead-i547/run-one-call.R
#
# Each session writes results-one-call/session-<n>.rds, and sessions already
# there are skipped, so a rerun after a crash carries on. Each
# `# ---- label ----` line starts a section that index.qmd shows by that label.

# ---- one-call-versions ----
# The same two commits as run.R, which pak likewise installed from archives of
# the commits, so the saved results record local paths
versions <- c(
  earlier = "greta-dev/greta@11405935549722ecbf035a2712a007419e52af66",
  branch = "greta-dev/greta@f30ab1469ef9eafab8bb2ff48be10860cd3fd9ab"
)
n_sessions <- 6
post_dir <- here::here("posts", "2026-10-07-call-overhead-i547")

# ---- one-call-sessions ----
dir.create(file.path(post_dir, "results-one-call"), showWarnings = FALSE)

for (session in seq_len(n_sessions)) {
  session_file <- file.path(
    post_dir,
    "results-one-call",
    sprintf("session-%d.rds", session)
  )
  if (file.exists(session_file)) {
    next
  }

  # each version goes first in every other session, so neither is always
  # measured first: in run.R's random order, branch went first in four of
  # its five sessions
  order <- if (session %% 2 == 1) names(versions) else rev(names(versions))

  # as in run.R, the script's path and the session number reach the fresh
  # session through callr::r()'s `env`
  measured <- cross::run_versions(
    source(Sys.getenv("GRETA_BENCH_SCRIPT"), local = TRUE)$value,
    pkgs = unname(versions[order]),
    args_callr = list(
      env = c(
        callr::rcmd_safe_env(),
        GRETA_BENCH_SCRIPT = file.path(post_dir, "benchmark-one-call.R"),
        GRETA_BENCH_SESSION = session
      )
    )
  )

  saveRDS(
    list(
      session = session,
      versions = versions,
      order = order,
      results = setNames(measured$result, order)
    ),
    session_file,
    compress = "xz"
  )
}
benchmark-one-call.R
# A closer look at mcmc() with verbose = FALSE, in one call to TensorFlow per
# phase, which benchmark.R found 1% to 3% slower on the branch than on the
# earlier commit. What each version runs, in a fresh R session of its own:
# run-one-call.R sources it once per version per session, and keeps the list
# it ends with. Each `# ---- label ----` line starts a section that index.qmd
# shows by that label.
#
#   Rscript --quiet --vanilla posts/2026-10-07-call-overhead-i547/benchmark-one-call.R

# ---- one-call-setup ----
library(greta)
library(bench)
session <- as.integer(Sys.getenv("GRETA_BENCH_SESSION", "1"))
session_seed <- 2026 - 10 - 7 + session

# ---- one-call-settings ----
# warmup and n_samples are mcmc()'s arguments, so they count draws; mcmc_calls
# counts timed calls on each model, the first of which traces
one_call_settings <- list(
  chains = 4,
  warmup = 1000,
  n_samples = 1000,
  mcmc_calls = 10
)

# ---- one-call-models ----
build_linear <- function() {
  int <- normal(0, 10)
  coef <- normal(0, 10)
  sd <- cauchy(0, 3, truncation = c(0, Inf))
  mu <- int + coef * attitude$complaints
  distribution(attitude$rating) <- normal(mu, sd)
  model(int, coef, sd)
}

build_hierarchical_linear <- function() {
  int <- normal(0, 10)
  coef <- normal(0, 10)
  sd <- cauchy(0, 3, truncation = c(0, Inf))
  species_sd <- lognormal(0, 1)
  species_offset <- normal(0, species_sd, dim = 2)
  species_effect <- rbind(0, species_offset)
  species_id <- as.numeric(iris$Species)
  mu <- int + coef * iris$Sepal.Width + species_effect[species_id]
  distribution(iris$Sepal.Length) <- normal(mu, sd)
  model(int, coef, sd, species_sd, species_offset)
}

# ---- one-call-mcmc ----
bench_one_call <- function(build) {
  m <- build()
  set.seed(session_seed)
  mark(
    mcmc(
      m,
      warmup = one_call_settings$warmup,
      n_samples = one_call_settings$n_samples,
      chains = one_call_settings$chains,
      verbose = FALSE
    ),
    iterations = one_call_settings$mcmc_calls,
    check = FALSE,
    memory = FALSE,
    filter_gc = FALSE
  )
}

linear_one_call <- bench_one_call(build_linear)
hierarchical_linear_one_call <- bench_one_call(build_hierarchical_linear)

# ---- one-call-results ----
# the value source() returns to run-one-call.R
list(
  session_seed = session_seed,
  one_call_settings = one_call_settings,
  greta_version = as.character(packageVersion("greta")),
  mcmc = list(
    linear = linear_one_call,
    hierarchical_linear = hierarchical_linear_one_call
  )
)
run-review-head.R
# Runs benchmark.R, unchanged, against #855 as the main run measured it
# (f30ab146) and as it stands after the PR's review (88e44dff), in a fresh R
# session per version, in four sessions:
#
#   Rscript --quiet --vanilla posts/2026-10-07-call-overhead-i547/run-review-head.R
#
# Each session writes results-review-head/session-<n>.rds, and sessions already
# there are skipped, so a rerun after a crash carries on. Each
# `# ---- label ----` line starts a section that index.qmd shows by that label.

# ---- review-versions ----
# measured is the commit the main run measured; reviewed is the PR's head after
# its review, which stores each optimiser's method as a closure and keeps the
# traced functions under rlang::hash() of their settings
versions <- c(
  measured = "greta-dev/greta@f30ab1469ef9eafab8bb2ff48be10860cd3fd9ab",
  reviewed = "greta-dev/greta@88e44dff569f01f4dcaea1bb9b3bb4aceccc33f8"
)
n_sessions <- 4
post_dir <- here::here("posts", "2026-10-07-call-overhead-i547")

# ---- review-sessions ----
dir.create(file.path(post_dir, "results-review-head"), showWarnings = FALSE)

for (session in seq_len(n_sessions)) {
  session_file <- file.path(
    post_dir,
    "results-review-head",
    sprintf("session-%d.rds", session)
  )
  if (file.exists(session_file)) {
    next
  }

  # the order rotates, so each version runs first in every other session
  start <- (session - 1) %% length(versions)
  order <- names(versions)[
    (seq_along(versions) + start - 1) %%
      length(versions) +
      1
  ]

  # cross::run_versions() installs each version into a library of its own and
  # runs the expression in a fresh R session against it; the script's path
  # and the session number reach that session as environment variables
  measured <- cross::run_versions(
    source(Sys.getenv("GRETA_BENCH_SCRIPT"), local = TRUE)$value,
    pkgs = unname(versions[order]),
    args_callr = list(
      env = c(
        callr::rcmd_safe_env(),
        GRETA_BENCH_SCRIPT = file.path(post_dir, "benchmark.R"),
        GRETA_BENCH_SESSION = session
      )
    )
  )

  saveRDS(
    list(
      session = session,
      versions = versions,
      order = order,
      results = setNames(measured$result, order)
    ),
    session_file,
    compress = "xz"
  )
}
equivalence.R
# Seeded results from one installed greta, saved for comparison with another.
# results-equivalence/ holds f30ab146's (measured.rds) and 88e44dff's
# (reviewed.rds). To repeat it, install each version into a library of its
# own, run this against each, and compare the two files with identical():
#
#   Rscript -e 'pak::pkg_install("greta-dev/greta@<sha>", lib = "<library>")'
#   Rscript --quiet --vanilla posts/2026-10-07-call-overhead-i547/equivalence.R \
#     <library> <output.rds>

args <- commandArgs(trailingOnly = TRUE)
library(greta, lib.loc = args[1])

linear <- function() {
  int <- normal(0, 10)
  coef <- normal(0, 10)
  sd <- cauchy(0, 3, truncation = c(0, Inf))
  mu <- int + coef * attitude$complaints
  distribution(attitude$rating) <- normal(mu, sd)
  model(int, coef, sd)
}

draws_of <- function(sampler, ...) {
  m <- linear()
  set.seed(2026 - 10 - 9)
  draws <- mcmc(m, sampler = sampler, ...)
  lapply(draws, as.matrix)
}

runs <- list(
  hmc_defaults = draws_of(
    hmc(),
    warmup = 200,
    n_samples = 200,
    chains = 2,
    verbose = FALSE
  ),
  hmc_progress = draws_of(
    hmc(),
    warmup = 200,
    n_samples = 200,
    chains = 2,
    pb_update = 30
  ),
  hmc_one_by_one = draws_of(
    hmc(),
    warmup = 60,
    n_samples = 60,
    chains = 2,
    one_by_one = TRUE,
    verbose = FALSE
  ),
  hmc_thin = draws_of(
    hmc(),
    warmup = 100,
    n_samples = 150,
    thin = 3,
    chains = 4,
    verbose = FALSE
  ),
  rwmh = draws_of(
    rwmh(),
    warmup = 200,
    n_samples = 200,
    chains = 2,
    verbose = FALSE
  ),
  slice_one_chain = draws_of(
    slice(),
    warmup = 100,
    n_samples = 100,
    chains = 1,
    verbose = FALSE
  )
)

# extra_samples() carries on the same chains
m <- linear()
set.seed(2026 - 10 - 9)
draws <- mcmc(
  m,
  warmup = 100,
  n_samples = 100,
  chains = 2,
  thin = 2,
  verbose = FALSE
)
more <- extra_samples(draws, n_samples = 60, thin = 2, verbose = FALSE)
runs$extra_samples <- lapply(more, as.matrix)

# opt() with each kind of optimiser, twice on one model so the cache is used
optimisers <- list(
  adam = adam(learning_rate = 0.1),
  gradient_descent = gradient_descent(learning_rate = 0.01),
  rms_prop = rms_prop(),
  bfgs = bfgs(),
  nelder_mead = nelder_mead(),
  proximal_adagrad = proximal_adagrad()
)
# an optimiser that overflows on this model is compared by its error message
opt_or_error <- function(m, optimiser) {
  tryCatch(
    opt(m, optimiser = optimiser, max_iterations = 200),
    error = conditionMessage
  )
}
for (name in names(optimisers)) {
  m <- linear()
  first <- opt_or_error(m, optimisers[[name]])
  second <- opt_or_error(m, optimisers[[name]])
  runs[[paste0("opt_", name)]] <- list(first = first, second = second)
}

saveRDS(runs, args[2])
cat(
  "saved",
  length(runs),
  "runs from greta",
  as.character(packageVersion("greta", lib.loc = args[1])),
  "\n"
)

Runtime per call, once the model is traced

Each version called mcmc() 4 times on each model, each way, and opt() 6 times. The first call traces the model’s TensorFlow functions and the calls after it reuse them, so this section compares the calls after the first; the first call has its own section. Each row of panels is one of mcmc()’s three ways of running, or opt() with adam().

Code
timed_calls |>
  filter(calls == "calls after the first") |>
  ggplot(aes(x = seconds, y = version, colour = version)) +
  raincloud(colours) +
  scale_x_log10() +
  facet_wrap(vars(case, model), ncol = 2, scales = "free_x") +
  labs(x = "Runtime per call (seconds, log scale)", y = NULL)

Each dot is one call, from every session. Above the dots, the curve is the density of the calls and the box marks their median and quartiles.

Timed by benchmark.R’s benchmark-mcmc, benchmark-opt, bench-linear and bench-hierarchical-linear sections.

Run it yourself
# install.packages(c("pak", "bench"))
# pak::pak("DavisVaughan/cross")
results <- cross::run_versions(
  {
    # ---- benchmark-setup ----
    library(greta)
    library(bench)
    session <- as.integer(Sys.getenv("GRETA_BENCH_SESSION", "1"))
    session_seed <- 2026 - 10 - 7 + session
    # ---- benchmark-settings ----
    # warmup and n_samples are mcmc()'s arguments, so they count draws, and the
    # one_by_one_* settings are those of the one_by_one calls; opt_max_iterations
    # are the iteration limits opt() is timed at; the *_calls settings count timed
    # calls
    bench_settings <- list(
      chains = 4,
      warmup = 1000,
      n_samples = 1000,
      one_by_one_warmup = 200,
      one_by_one_n_samples = 200,
      mcmc_calls = 4,
      opt_max_iterations = c(20L, 2000L),
      opt_calls = 6
    )
    # ---- model-linear ----
    build_linear <- function() {
      int <- normal(0, 10)
      coef <- normal(0, 10)
      sd <- cauchy(0, 3, truncation = c(0, Inf))
      mu <- int + coef * attitude$complaints
      distribution(attitude$rating) <- normal(mu, sd)
      model(int, coef, sd)
    }
    # ---- model-hierarchical-linear ----
    build_hierarchical_linear <- function() {
      int <- normal(0, 10)
      coef <- normal(0, 10)
      sd <- cauchy(0, 3, truncation = c(0, Inf))
      species_sd <- lognormal(0, 1)
      species_offset <- normal(0, species_sd, dim = 2)
      species_effect <- rbind(0, species_offset)
      species_id <- as.numeric(iris$Species)
      mu <- int + coef * iris$Sepal.Width + species_effect[species_id]
      distribution(iris$Sepal.Length) <- normal(mu, sd)
      model(int, coef, sd, species_sd, species_offset)
    }
    # ---- benchmark-mcmc ----
    # mcmc() on a model built afresh, mcmc_calls times: the first call traces its
    # TensorFlow functions and later calls reuse them. Three ways of running it,
    # which differ in how often the sampler returns to R:
    #
    # - one_by_one: once every iteration
    # - progress: every pb_update iterations, to update the progress bar, as
    #   mcmc() does by default
    # - one call: once per phase, with verbose = FALSE
    bench_mcmc <- function(build) {
      run_with <- function(...) {
        m <- build()
        set.seed(session_seed)
        mark(
          mcmc(m, chains = bench_settings$chains, ...),
          iterations = bench_settings$mcmc_calls,
          check = FALSE,
          memory = FALSE,
          filter_gc = FALSE
        )
      }
      list(
        one_by_one = run_with(
          warmup = bench_settings$one_by_one_warmup,
          n_samples = bench_settings$one_by_one_n_samples,
          one_by_one = TRUE,
          verbose = FALSE
        ),
        progress = run_with(
          warmup = bench_settings$warmup,
          n_samples = bench_settings$n_samples
        ),
        one_call = run_with(
          warmup = bench_settings$warmup,
          n_samples = bench_settings$n_samples,
          verbose = FALSE
        )
      )
    }
    # ---- benchmark-opt ----
    # opt() with adam() on a model built afresh for each iteration limit,
    # opt_calls times: the first call on a model traces its loop, and later calls
    # may reuse it
    bench_opt <- function(build) {
      press(
        max_iterations = bench_settings$opt_max_iterations,
        {
          m <- build()
          set.seed(session_seed)
          mark(
            opt(m, optimiser = adam(), max_iterations = max_iterations),
            iterations = bench_settings$opt_calls,
            check = FALSE,
            memory = FALSE,
            filter_gc = FALSE
          )
        }
      )
    }
    # ---- bench-linear ----
    linear_mcmc <- bench_mcmc(build_linear)
    linear_opt <- bench_opt(build_linear)
    # ---- bench-hierarchical-linear ----
    hierarchical_linear_mcmc <- bench_mcmc(build_hierarchical_linear)
    hierarchical_linear_opt <- bench_opt(build_hierarchical_linear)
    # the timings to bring back
    list(mcmc = list(linear = linear_mcmc, hierarchical_linear = hierarchical_linear_mcmc), opt = list(linear = linear_opt, hierarchical_linear = hierarchical_linear_opt))
  },
  pkgs = c(
    "greta@0.6.0",
    "greta-dev/greta@11405935549722ecbf035a2712a007419e52af66",
    "greta-dev/greta@f30ab1469ef9eafab8bb2ff48be10860cd3fd9ab"
  )
)

Over the two models:

  • mcmc() with one_by_one = TRUE:
    • #855 before takes 3.6 to 3.71 times as long as #855
    • CRAN takes 3.22 to 3.62 times as long as #855
  • mcmc() with the progress bar shown (verbose = TRUE, the default):
    • #855 before takes 1.14 to 1.21 times as long as #855
    • CRAN takes 5.34 to 5.79 times as long as #855
  • mcmc() with no progress bar (verbose = FALSE):
  • opt() with adam(), where a call on #855 takes a median of 0.0315 to 0.0521 seconds:
    • #855 before takes 2.54 to 4.82 times as long as #855
    • CRAN takes 28.9 to 47.3 times as long as #855

With the same warmup and n_samples, CRAN runs twice the sampler iterations #855 does, so its mcmc() times include work the other two do not do.

Table: median runtime per call in seconds, once the model is traced
Code
call_medians |>
  filter(calls == "calls after the first") |>
  select(-calls) |>
  rename(`#855 before ÷ #855` = before_over_pr, `CRAN ÷ #855` = cran_over_pr) |>
  dt_table(digits = 3)

Runtime of the first call, which traces the model

The first call on a model traces its TensorFlow functions, so it takes longer than the calls after it; the panels are as above.

Code
timed_calls |>
  filter(calls == "first call") |>
  ggplot(aes(x = seconds, y = version, colour = version)) +
  raincloud(colours) +
  scale_x_log10() +
  facet_wrap(vars(case, model), ncol = 2, scales = "free_x") +
  labs(x = "Runtime per call (seconds, log scale)", y = NULL)

Each dot is one call, from every session. Above the dots, the curve is the density of the calls and the box marks their median and quartiles.

Timed by benchmark.R’s benchmark-mcmc, benchmark-opt, bench-linear and bench-hierarchical-linear sections.

Run it yourself
# install.packages(c("pak", "bench"))
# pak::pak("DavisVaughan/cross")
results <- cross::run_versions(
  {
    # ---- benchmark-setup ----
    library(greta)
    library(bench)
    session <- as.integer(Sys.getenv("GRETA_BENCH_SESSION", "1"))
    session_seed <- 2026 - 10 - 7 + session
    # ---- benchmark-settings ----
    # warmup and n_samples are mcmc()'s arguments, so they count draws, and the
    # one_by_one_* settings are those of the one_by_one calls; opt_max_iterations
    # are the iteration limits opt() is timed at; the *_calls settings count timed
    # calls
    bench_settings <- list(
      chains = 4,
      warmup = 1000,
      n_samples = 1000,
      one_by_one_warmup = 200,
      one_by_one_n_samples = 200,
      mcmc_calls = 4,
      opt_max_iterations = c(20L, 2000L),
      opt_calls = 6
    )
    # ---- model-linear ----
    build_linear <- function() {
      int <- normal(0, 10)
      coef <- normal(0, 10)
      sd <- cauchy(0, 3, truncation = c(0, Inf))
      mu <- int + coef * attitude$complaints
      distribution(attitude$rating) <- normal(mu, sd)
      model(int, coef, sd)
    }
    # ---- model-hierarchical-linear ----
    build_hierarchical_linear <- function() {
      int <- normal(0, 10)
      coef <- normal(0, 10)
      sd <- cauchy(0, 3, truncation = c(0, Inf))
      species_sd <- lognormal(0, 1)
      species_offset <- normal(0, species_sd, dim = 2)
      species_effect <- rbind(0, species_offset)
      species_id <- as.numeric(iris$Species)
      mu <- int + coef * iris$Sepal.Width + species_effect[species_id]
      distribution(iris$Sepal.Length) <- normal(mu, sd)
      model(int, coef, sd, species_sd, species_offset)
    }
    # ---- benchmark-mcmc ----
    # mcmc() on a model built afresh, mcmc_calls times: the first call traces its
    # TensorFlow functions and later calls reuse them. Three ways of running it,
    # which differ in how often the sampler returns to R:
    #
    # - one_by_one: once every iteration
    # - progress: every pb_update iterations, to update the progress bar, as
    #   mcmc() does by default
    # - one call: once per phase, with verbose = FALSE
    bench_mcmc <- function(build) {
      run_with <- function(...) {
        m <- build()
        set.seed(session_seed)
        mark(
          mcmc(m, chains = bench_settings$chains, ...),
          iterations = bench_settings$mcmc_calls,
          check = FALSE,
          memory = FALSE,
          filter_gc = FALSE
        )
      }
      list(
        one_by_one = run_with(
          warmup = bench_settings$one_by_one_warmup,
          n_samples = bench_settings$one_by_one_n_samples,
          one_by_one = TRUE,
          verbose = FALSE
        ),
        progress = run_with(
          warmup = bench_settings$warmup,
          n_samples = bench_settings$n_samples
        ),
        one_call = run_with(
          warmup = bench_settings$warmup,
          n_samples = bench_settings$n_samples,
          verbose = FALSE
        )
      )
    }
    # ---- benchmark-opt ----
    # opt() with adam() on a model built afresh for each iteration limit,
    # opt_calls times: the first call on a model traces its loop, and later calls
    # may reuse it
    bench_opt <- function(build) {
      press(
        max_iterations = bench_settings$opt_max_iterations,
        {
          m <- build()
          set.seed(session_seed)
          mark(
            opt(m, optimiser = adam(), max_iterations = max_iterations),
            iterations = bench_settings$opt_calls,
            check = FALSE,
            memory = FALSE,
            filter_gc = FALSE
          )
        }
      )
    }
    # ---- bench-linear ----
    linear_mcmc <- bench_mcmc(build_linear)
    linear_opt <- bench_opt(build_linear)
    # ---- bench-hierarchical-linear ----
    hierarchical_linear_mcmc <- bench_mcmc(build_hierarchical_linear)
    hierarchical_linear_opt <- bench_opt(build_hierarchical_linear)
    # the timings to bring back
    list(mcmc = list(linear = linear_mcmc, hierarchical_linear = hierarchical_linear_mcmc), opt = list(linear = linear_opt, hierarchical_linear = hierarchical_linear_opt))
  },
  pkgs = c(
    "greta@0.6.0",
    "greta-dev/greta@11405935549722ecbf035a2712a007419e52af66",
    "greta-dev/greta@f30ab1469ef9eafab8bb2ff48be10860cd3fd9ab"
  )
)

Over the two models:

  • mcmc() with one_by_one = TRUE, which returns to R every iteration from the first call on:
    • #855 before takes 1.82 to 2.01 times as long as #855
    • CRAN takes 1.52 to 1.72 times as long as #855
  • mcmc() with the progress bar shown (verbose = TRUE, the default):
  • mcmc() with no progress bar (verbose = FALSE):
    • #855 before takes 1.01 to 1.05 times as long as #855
    • CRAN takes 2.91 to 2.99 times as long as #855
  • opt() with adam():
    • #855 before takes 1.01 to 1.03 times as long as #855
    • CRAN takes 2.87 to 7.91 times as long as #855
Table: median runtime of the first call in seconds
Code
call_medians |>
  filter(calls == "first call") |>
  select(-calls) |>
  rename(`#855 before ÷ #855` = before_over_pr, `CRAN ÷ #855` = cran_over_pr) |>
  dt_table(digits = 3)

Does the order the versions ran in explain the gap with verbose = FALSE?

Code
# the main run's median call after the first, per session and model, with
# verbose = FALSE
one_call_main_run <- timed_calls |>
  filter(case == case_labels[["one_call"]], calls == "calls after the first") |>
  group_by(model, session, version) |>
  summarise(seconds = median(seconds), .groups = "drop") |>
  pivot_wider(names_from = version, values_from = seconds) |>
  mutate(before_over_pr = `#855 before` / `#855`)
pr_ran_first <- sum(vapply(
  sessions,
  \(session_file) version_labels[[session_file$order[1]]] == "#855",
  logical(1)
))

# every call after the first, in every session of the second run
one_call_calls <- lapply(sessions_one_call, function(session_file) {
  lapply(names(session_file$results), function(version) {
    marks <- session_file$results[[version]]$mcmc
    lapply(names(marks), function(model) {
      seconds <- as.numeric(marks[[model]]$time[[1]])
      tibble(
        session = session_file$session,
        went_first = paste(
          version_labels[[session_file$order[1]]],
          "ran first"
        ),
        version = factor(version_labels[[version]], levels = version_levels),
        model = factor(model, levels = model_levels),
        call = seq_along(seconds),
        seconds = seconds
      )
    }) |>
      bind_rows()
  }) |>
    bind_rows()
}) |>
  bind_rows() |>
  filter(call > 1) |>
  mutate(
    went_first = factor(
      went_first,
      levels = paste(version_labels[c("earlier", "branch")], "ran first")
    )
  )

# how many times as long #855 before takes as #855, per session and model
one_call_by_session <- one_call_calls |>
  group_by(model, went_first, session, version) |>
  summarise(seconds = median(seconds), .groups = "drop") |>
  pivot_wider(names_from = version, values_from = seconds) |>
  mutate(before_over_pr = `#855 before` / `#855`)
order_compared <- function(first) {
  compare_times(
    one_call_by_session$before_over_pr[one_call_by_session$went_first == first],
    version_text[["#855 before"]],
    version_text[["#855"]]
  )
}

# and over every session, to three decimal places, as the two are so close
one_call_overall <- one_call_calls |>
  group_by(model, version) |>
  summarise(seconds = median(seconds), .groups = "drop") |>
  pivot_wider(names_from = version, values_from = seconds) |>
  mutate(before_over_pr = `#855 before` / `#855`)
overall_ratio <- setNames(
  formatC(one_call_overall$before_over_pr, format = "f", digits = 3),
  one_call_overall$model
)

With verbose = FALSE, mcmc() calls TensorFlow once per phase, so #855 makes about as many conversions as #855 before, yet in the main run #855’s median runtime per call, once the model was traced, was the longer in 9 of 10 sessions and models. #855 had also run first in 4 of the 5 sessions, so this case was timed again on its own, with each version running first in every other session.

Code
ggplot(one_call_calls, aes(x = seconds, y = version, colour = version)) +
  raincloud(colours) +
  facet_wrap(vars(model, went_first), ncol = 2, scales = "free_x") +
  labs(x = "Runtime per call (seconds)", y = NULL)

Each dot is one call, from every session. Above the dots, the curve is the density of the calls and the box marks their median and quartiles.

Timed by benchmark-one-call.R’s one-call-mcmc section. The order is set by run-one-call.R’s one-call-sessions section.

Run it yourself
# install.packages(c("pak", "bench"))
# pak::pak("DavisVaughan/cross")
results <- cross::run_versions(
  {
    # ---- one-call-setup ----
    library(greta)
    library(bench)
    session <- as.integer(Sys.getenv("GRETA_BENCH_SESSION", "1"))
    session_seed <- 2026 - 10 - 7 + session
    # ---- one-call-settings ----
    # warmup and n_samples are mcmc()'s arguments, so they count draws; mcmc_calls
    # counts timed calls on each model, the first of which traces
    one_call_settings <- list(
      chains = 4,
      warmup = 1000,
      n_samples = 1000,
      mcmc_calls = 10
    )
    # ---- one-call-models ----
    build_linear <- function() {
      int <- normal(0, 10)
      coef <- normal(0, 10)
      sd <- cauchy(0, 3, truncation = c(0, Inf))
      mu <- int + coef * attitude$complaints
      distribution(attitude$rating) <- normal(mu, sd)
      model(int, coef, sd)
    }

    build_hierarchical_linear <- function() {
      int <- normal(0, 10)
      coef <- normal(0, 10)
      sd <- cauchy(0, 3, truncation = c(0, Inf))
      species_sd <- lognormal(0, 1)
      species_offset <- normal(0, species_sd, dim = 2)
      species_effect <- rbind(0, species_offset)
      species_id <- as.numeric(iris$Species)
      mu <- int + coef * iris$Sepal.Width + species_effect[species_id]
      distribution(iris$Sepal.Length) <- normal(mu, sd)
      model(int, coef, sd, species_sd, species_offset)
    }
    # ---- one-call-mcmc ----
    bench_one_call <- function(build) {
      m <- build()
      set.seed(session_seed)
      mark(
        mcmc(
          m,
          warmup = one_call_settings$warmup,
          n_samples = one_call_settings$n_samples,
          chains = one_call_settings$chains,
          verbose = FALSE
        ),
        iterations = one_call_settings$mcmc_calls,
        check = FALSE,
        memory = FALSE,
        filter_gc = FALSE
      )
    }

    linear_one_call <- bench_one_call(build_linear)
    hierarchical_linear_one_call <- bench_one_call(build_hierarchical_linear)
    # the timings to bring back
    list(linear = linear_one_call, hierarchical_linear = hierarchical_linear_one_call)
  },
  pkgs = c(
    "greta-dev/greta@11405935549722ecbf035a2712a007419e52af66",
    "greta-dev/greta@f30ab1469ef9eafab8bb2ff48be10860cd3fd9ab"
  )
)

The version that ran first tended to be the slower:

So the two take the same time, and the gap in the main run came from the order, which counted against #855 in its other comparisons too.

Table: median runtime per call in seconds once the model is traced, per session
Code
one_call_by_session |>
  rename(`#855 before ÷ #855` = before_over_pr) |>
  dt_table(digits = 3)

Runtime per call after the PR’s review

Code
# #855 keeps its colour from the figures above
review_colours <- c(`#855` = colours[["#855"]], `#855 reviewed` = "#0072B2")
reviewed_text <- paste0(
  "[#855 reviewed](",
  commit_url(review_versions[["#855 reviewed"]]),
  ")"
)

review_calls <- timed_calls_of(runs_of(sessions_review, review_labels))

# each version's median call, and how many times as long #855 reviewed takes
# as #855
review_medians <- review_calls |>
  group_by(calls, case, model, version) |>
  summarise(seconds = median(seconds), .groups = "drop") |>
  pivot_wider(names_from = version, values_from = seconds) |>
  mutate(reviewed_over_measured = `#855 reviewed` / `#855`)
reviewed_vs_measured <- function(cases, which_calls = "calls after the first") {
  rows <- review_medians$calls == which_calls &
    review_medians$case %in% case_labels[cases]
  compare_times(
    review_medians$reviewed_over_measured[rows],
    reviewed_text,
    version_text[["#855"]]
  )
}

# the largest gap for the calls after the first, and the middle half of that
# case's calls on each version, in milliseconds
largest_gap <- review_medians |>
  filter(calls == "calls after the first") |>
  slice_max(reviewed_over_measured, n = 1)
largest_gap_spread <- review_calls |>
  filter(
    calls == "calls after the first",
    case == largest_gap$case,
    model == largest_gap$model
  ) |>
  group_by(version) |>
  summarise(
    middle_half = paste(
      format_number(1000 * quantile(seconds, c(0.25, 0.75))),
      collapse = " to "
    )
  )
middle_half_of <- \(version) {
  largest_gap_spread$middle_half[largest_gap_spread$version == version]
}
# each case as the text names it
case_prose <- c(
  one_by_one = "`mcmc()` with `one_by_one = TRUE`",
  progress = "`mcmc()` with the progress bar shown",
  one_call = "`mcmc()` with no progress bar",
  opt = "`opt()` with `adam()`"
)
largest_gap_case <- I(case_prose[[names(case_labels)[
  match(largest_gap$case, case_labels)
]]])

# equivalence.R's seeded results on each version
seeded_measured <- readRDS(here(post_path, "results-equivalence", "measured.rds"))
seeded_reviewed <- readRDS(here(post_path, "results-equivalence", "reviewed.rds"))
seeded_identical <- identical(seeded_measured, seeded_reviewed)

review_run_code <- run_it_yourself(
  post_path,
  "benchmark.R",
  main_run_sections,
  review_versions,
  main_run_returns
)

The PR’s review changed its code after the main run, ending at #855 reviewed: each optimiser stores its function as a closure, and the traced functions are kept under rlang::hash() of their settings. run-review-head.R ran this post’s benchmark.R, unchanged, on #855 as measured above and on #855 reviewed, in 4 sessions, each version running first in half of them. Each row of panels is one of the cases above, for the calls after the first.

Code
review_calls |>
  filter(calls == "calls after the first") |>
  ggplot(aes(x = seconds, y = version, colour = version)) +
  raincloud(review_colours) +
  scale_x_log10() +
  facet_wrap(vars(case, model), ncol = 2, scales = "free_x") +
  labs(x = "Runtime per call (seconds, log scale)", y = NULL)

Each dot is one call, from every session. Above the dots, the curve is the density of the calls and the box marks their median and quartiles.

Timed by benchmark.R’s benchmark-mcmc, benchmark-opt, bench-linear and bench-hierarchical-linear sections. The order is set by run-review-head.R’s review-sessions section.

Run it yourself
# install.packages(c("pak", "bench"))
# pak::pak("DavisVaughan/cross")
results <- cross::run_versions(
  {
    # ---- benchmark-setup ----
    library(greta)
    library(bench)
    session <- as.integer(Sys.getenv("GRETA_BENCH_SESSION", "1"))
    session_seed <- 2026 - 10 - 7 + session
    # ---- benchmark-settings ----
    # warmup and n_samples are mcmc()'s arguments, so they count draws, and the
    # one_by_one_* settings are those of the one_by_one calls; opt_max_iterations
    # are the iteration limits opt() is timed at; the *_calls settings count timed
    # calls
    bench_settings <- list(
      chains = 4,
      warmup = 1000,
      n_samples = 1000,
      one_by_one_warmup = 200,
      one_by_one_n_samples = 200,
      mcmc_calls = 4,
      opt_max_iterations = c(20L, 2000L),
      opt_calls = 6
    )
    # ---- model-linear ----
    build_linear <- function() {
      int <- normal(0, 10)
      coef <- normal(0, 10)
      sd <- cauchy(0, 3, truncation = c(0, Inf))
      mu <- int + coef * attitude$complaints
      distribution(attitude$rating) <- normal(mu, sd)
      model(int, coef, sd)
    }
    # ---- model-hierarchical-linear ----
    build_hierarchical_linear <- function() {
      int <- normal(0, 10)
      coef <- normal(0, 10)
      sd <- cauchy(0, 3, truncation = c(0, Inf))
      species_sd <- lognormal(0, 1)
      species_offset <- normal(0, species_sd, dim = 2)
      species_effect <- rbind(0, species_offset)
      species_id <- as.numeric(iris$Species)
      mu <- int + coef * iris$Sepal.Width + species_effect[species_id]
      distribution(iris$Sepal.Length) <- normal(mu, sd)
      model(int, coef, sd, species_sd, species_offset)
    }
    # ---- benchmark-mcmc ----
    # mcmc() on a model built afresh, mcmc_calls times: the first call traces its
    # TensorFlow functions and later calls reuse them. Three ways of running it,
    # which differ in how often the sampler returns to R:
    #
    # - one_by_one: once every iteration
    # - progress: every pb_update iterations, to update the progress bar, as
    #   mcmc() does by default
    # - one call: once per phase, with verbose = FALSE
    bench_mcmc <- function(build) {
      run_with <- function(...) {
        m <- build()
        set.seed(session_seed)
        mark(
          mcmc(m, chains = bench_settings$chains, ...),
          iterations = bench_settings$mcmc_calls,
          check = FALSE,
          memory = FALSE,
          filter_gc = FALSE
        )
      }
      list(
        one_by_one = run_with(
          warmup = bench_settings$one_by_one_warmup,
          n_samples = bench_settings$one_by_one_n_samples,
          one_by_one = TRUE,
          verbose = FALSE
        ),
        progress = run_with(
          warmup = bench_settings$warmup,
          n_samples = bench_settings$n_samples
        ),
        one_call = run_with(
          warmup = bench_settings$warmup,
          n_samples = bench_settings$n_samples,
          verbose = FALSE
        )
      )
    }
    # ---- benchmark-opt ----
    # opt() with adam() on a model built afresh for each iteration limit,
    # opt_calls times: the first call on a model traces its loop, and later calls
    # may reuse it
    bench_opt <- function(build) {
      press(
        max_iterations = bench_settings$opt_max_iterations,
        {
          m <- build()
          set.seed(session_seed)
          mark(
            opt(m, optimiser = adam(), max_iterations = max_iterations),
            iterations = bench_settings$opt_calls,
            check = FALSE,
            memory = FALSE,
            filter_gc = FALSE
          )
        }
      )
    }
    # ---- bench-linear ----
    linear_mcmc <- bench_mcmc(build_linear)
    linear_opt <- bench_opt(build_linear)
    # ---- bench-hierarchical-linear ----
    hierarchical_linear_mcmc <- bench_mcmc(build_hierarchical_linear)
    hierarchical_linear_opt <- bench_opt(build_hierarchical_linear)
    # the timings to bring back
    list(mcmc = list(linear = linear_mcmc, hierarchical_linear = hierarchical_linear_mcmc), opt = list(linear = linear_opt, hierarchical_linear = hierarchical_linear_opt))
  },
  pkgs = c(
    "greta-dev/greta@f30ab1469ef9eafab8bb2ff48be10860cd3fd9ab",
    "greta-dev/greta@88e44dff569f01f4dcaea1bb9b3bb4aceccc33f8"
  )
)
  • The 13 seeded results of equivalence.R, hmc() four ways, rwmh(), slice(), extra_samples() and two opt() calls with each of six optimisers, are identical on the two.
  • For the calls after the first:
  • For the first call:

The largest gap, for opt() with adam() on hierarchical_linear, is 1.92 ms on a call of 31 ms. That is inside the middle half of that case’s calls: 28.6 to 33.8 ms on #855, and 29.4 to 33.7 ms on #855 reviewed. What the review added to each call is a hash of the settings and one lookup, so the PR’s speed is as the sections above report it.

Table: median runtime per call in seconds, before and after the review
Code
review_medians |>
  rename(`#855 reviewed ÷ #855` = reviewed_over_measured) |>
  dt_table(digits = 4)

Session details

What each version reported about itself in session 1, from benchmark.R’s benchmark-provenance section:

Code
runs |>
  filter(session == sessions[[1]]$session) |>
  mutate(provenance = lapply(results, \(result) result$provenance)) |>
  select(version, provenance) |>
  unnest(provenance) |>
  arrange(version) |>
  mutate(
    `installed from` = version_link(versions[as.character(version)]),
    version = version_text[as.character(version)],
    .after = version
  ) |>
  rename(
    greta = greta_version,
    R = r_version,
    TensorFlow = tensorflow_version,
    `TensorFlow Probability` = tfp_version,
    reticulate = reticulate_version
  ) |>
  knitr::kable()
version installed from greta R TensorFlow TensorFlow Probability reticulate machine cores
CRAN greta@0.6.0 0.6.0 4.6.1 2.15.1 0.23.0 1.47.0 Darwin 25.5.0 arm64 8
#855 before greta-dev/greta@11405935 0.6.0.9000 4.6.1 2.21.0 0.25.0 1.47.0 Darwin 25.5.0 arm64 8
#855 greta-dev/greta@f30ab146 0.6.0.9000 4.6.1 2.21.0 0.25.0 1.47.0 Darwin 25.5.0 arm64 8
Code
first_session <- runs |> filter(session == sessions[[1]]$session)
session_info_of <- function(version) {
  first_session$results[[match(version, first_session$version)]]$session_info
}
sessioninfo::session_info() for CRAN
Code
session_info_of("CRAN")
─ Session info ───────────────────────────────────────────────────────────────
 setting  value
 version  R version 4.6.1 (2026-06-24)
 os       macOS Tahoe 26.5.2
 system   aarch64, darwin23
 ui       X11
 language (EN)
 collate  en_AU.UTF-8
 ctype    en_AU.UTF-8
 tz       Australia/Hobart
 date     2026-10-07
 pandoc   3.10 @ /opt/homebrew/bin/pandoc
 quarto   1.9.36 @ /usr/local/bin/quarto

─ Packages ───────────────────────────────────────────────────────────────────
 package     * version  date (UTC) lib source
 abind         1.4-8    2024-09-12 [1] CRAN (R 4.6.0)
 backports     1.5.1    2026-04-03 [1] CRAN (R 4.6.0)
 base64enc     0.1-6    2026-02-02 [1] CRAN (R 4.6.0)
 bench       * 1.1.4    2025-01-16 [3] CRAN (R 4.6.0)
 callr         3.8.0    2026-06-05 [1] CRAN (R 4.6.0)
 cli           3.6.6    2026-04-09 [1] CRAN (R 4.6.0)
 coda          0.19-4.1 2024-01-31 [1] CRAN (R 4.6.0)
 codetools     0.2-20   2024-03-31 [3] CRAN (R 4.6.1)
 crayon        1.5.3    2024-06-20 [1] CRAN (R 4.6.0)
 digest        0.6.39   2025-11-19 [1] CRAN (R 4.6.0)
 future        1.76.0   2026-09-24 [1] CRAN (R 4.6.1)
 globals       0.19.1   2026-03-13 [1] CRAN (R 4.6.0)
 glue          1.8.1    2026-04-17 [1] CRAN (R 4.6.0)
 greta       * 0.6.0    2026-07-20 [1] CRAN (R 4.6.1)
 hms           1.1.4    2025-10-17 [1] CRAN (R 4.6.0)
 jsonlite      2.0.0    2025-03-27 [1] CRAN (R 4.6.0)
 lattice       0.23-1   2026-08-12 [3] CRAN (R 4.6.1)
 lifecycle     1.0.5    2026-01-08 [1] CRAN (R 4.6.0)
 listenv       1.1.0    2026-10-04 [1] CRAN (R 4.6.1)
 magrittr      2.0.5    2026-04-04 [1] CRAN (R 4.6.0)
 Matrix        1.7-6    2026-07-25 [3] CRAN (R 4.6.1)
 parallelly    1.48.0   2026-06-29 [1] CRAN (R 4.6.1)
 pillar        1.11.1   2025-09-17 [2] CRAN (R 4.6.0)
 pkgconfig     2.0.3    2019-09-22 [1] CRAN (R 4.6.0)
 png           0.1-9    2026-03-15 [1] CRAN (R 4.6.0)
 prettyunits   1.2.0    2023-09-24 [1] CRAN (R 4.6.0)
 processx      3.9.0    2026-04-22 [1] CRAN (R 4.6.0)
 progress      1.2.3    2023-12-06 [1] CRAN (R 4.6.0)
 R6            2.6.1    2025-02-15 [1] CRAN (R 4.6.0)
 Rcpp          1.1.2    2026-07-05 [1] CRAN (R 4.6.1)
 reticulate    1.47.0   2026-09-03 [1] CRAN (R 4.6.1)
 rlang         1.3.0    2026-07-05 [1] CRAN (R 4.6.1)
 sessioninfo   1.2.4    2026-06-04 [3] CRAN (R 4.6.0)
 tensorflow    2.20.0   2025-08-22 [1] CRAN (R 4.6.0)
 tfautograph   0.3.2    2021-09-17 [1] CRAN (R 4.6.0)
 tfruns        1.5.4    2025-08-20 [1] CRAN (R 4.6.0)
 tibble        3.3.1    2026-01-11 [2] CRAN (R 4.6.0)
 vctrs         0.7.3    2026-04-11 [1] CRAN (R 4.6.0)
 whisker       0.4.1    2022-12-05 [1] CRAN (R 4.6.0)
 withr         3.0.3    2026-06-19 [1] CRAN (R 4.6.0)

 [1] /private/var/folders/cz/hrp33k3s0jlcb_gg9snlqh580000gq/T/RtmpdRlPTa/filee659f36240a
 [2] /Users/nick_1/Library/R/arm64/4.6/library
 [3] /Library/Frameworks/R.framework/Versions/4.6/Resources/library
 * ── Packages attached to the search path.

─ Python configuration ───────────────────────────────────────────────────────
 /Users/nick_1/.cache/uv/archive-v0/ogasBA2nxVj6KqkBWNO5g/bin/python
 /Users/nick_1/.local/share/uv/python/cpython-3.11.15-macos-aarch64-none/lib/libpython3.11.dylib
 /Users/nick_1/.cache/uv/archive-v0/ogasBA2nxVj6KqkBWNO5g:/Users/nick_1/.cache/uv/archive-v0/ogasBA2nxVj6KqkBWNO5g
 /Users/nick_1/.local/share/uv/python/cpython-3.11.15-macos-aarch64-none/lib/python311.zip:/Users/nick_1/.local/share/uv/python/cpython-3.11.15-macos-aarch64-none/lib/python3.11:/Users/nick_1/.local/share/uv/python/cpython-3.11.15-macos-aarch64-none/lib/python3.11/lib-dynload:/Users/nick_1/.cache/uv/archive-v0/ogasBA2nxVj6KqkBWNO5g/lib/python3.11/site-packages
 /Users/nick_1/.cache/uv/archive-v0/ogasBA2nxVj6KqkBWNO5g
 /Users/nick_1/.cache/uv/archive-v0/ogasBA2nxVj6KqkBWNO5g
 /Users/nick_1/.local/share/uv/python/cpython-3.11.15-macos-aarch64-none
 /Users/nick_1/.cache/uv/archive-v0/ogasBA2nxVj6KqkBWNO5g
 /Users/nick_1/.cache/uv/archive-v0/ogasBA2nxVj6KqkBWNO5g/bin/activate_this.py
 /Users/nick_1/.cache/uv/archive-v0/ogasBA2nxVj6KqkBWNO5g/bin/python
 /Users/nick_1/.local/share/uv/python/cpython-3.11.15-macos-aarch64-none/bin/python3.11
 3.11.15 (main, Mar 20 2026, 00:41:54) [Clang 22.1.1 ]
 3, 11
 64bit
 FALSE
 False
 /Users/nick_1/.cache/uv/archive-v0/ogasBA2nxVj6KqkBWNO5g/lib/python3.11/site-packages/numpy, 1                                                                                          , 26                                                                                         , 4                                                                                          
 tensorflow
 /Users/nick_1/.cache/uv/archive-v0/ogasBA2nxVj6KqkBWNO5g/lib/python3.11/site-packages/tensorflow
 TRUE
 /Users/nick_1/.cache/uv/archive-v0/ogasBA2nxVj6KqkBWNO5g/bin/python
 py_require()
 TRUE

──────────────────────────────────────────────────────────────────────────────
sessioninfo::session_info() for #855 before
Code
session_info_of("#855 before")
─ Session info ───────────────────────────────────────────────────────────────
 setting  value
 version  R version 4.6.1 (2026-06-24)
 os       macOS Tahoe 26.5.2
 system   aarch64, darwin23
 ui       X11
 language (EN)
 collate  en_AU.UTF-8
 ctype    en_AU.UTF-8
 tz       Australia/Hobart
 date     2026-10-07
 pandoc   3.10 @ /opt/homebrew/bin/pandoc
 quarto   1.9.36 @ /usr/local/bin/quarto

─ Packages ───────────────────────────────────────────────────────────────────
 package     * version    date (UTC) lib source
 abind         1.4-8      2024-09-12 [1] CRAN (R 4.6.0)
 backports     1.5.1      2026-04-03 [1] CRAN (R 4.6.0)
 base64enc     0.1-6      2026-02-02 [1] CRAN (R 4.6.0)
 bench       * 1.1.4      2025-01-16 [3] CRAN (R 4.6.0)
 callr         3.8.0      2026-06-05 [1] CRAN (R 4.6.0)
 cli           3.6.6      2026-04-09 [1] CRAN (R 4.6.0)
 coda          0.19-4.1   2024-01-31 [1] CRAN (R 4.6.0)
 codetools     0.2-20     2024-03-31 [3] CRAN (R 4.6.1)
 crayon        1.5.3      2024-06-20 [1] CRAN (R 4.6.0)
 digest        0.6.39     2025-11-19 [1] CRAN (R 4.6.0)
 future        1.76.0     2026-09-24 [1] CRAN (R 4.6.1)
 globals       0.19.1     2026-03-13 [1] CRAN (R 4.6.0)
 glue          1.8.1      2026-04-17 [1] CRAN (R 4.6.0)
 greta       * 0.6.0.9000 2026-10-07 [1] local
 hms           1.1.4      2025-10-17 [1] CRAN (R 4.6.0)
 jsonlite      2.0.0      2025-03-27 [1] CRAN (R 4.6.0)
 lattice       0.23-1     2026-08-12 [3] CRAN (R 4.6.1)
 lifecycle     1.0.5      2026-01-08 [1] CRAN (R 4.6.0)
 listenv       1.1.0      2026-10-04 [1] CRAN (R 4.6.1)
 magrittr      2.0.5      2026-04-04 [1] CRAN (R 4.6.0)
 Matrix        1.7-6      2026-07-25 [3] CRAN (R 4.6.1)
 parallelly    1.48.0     2026-06-29 [1] CRAN (R 4.6.1)
 pillar        1.11.1     2025-09-17 [2] CRAN (R 4.6.0)
 pkgconfig     2.0.3      2019-09-22 [1] CRAN (R 4.6.0)
 png           0.1-9      2026-03-15 [1] CRAN (R 4.6.0)
 prettyunits   1.2.0      2023-09-24 [1] CRAN (R 4.6.0)
 processx      3.9.0      2026-04-22 [1] CRAN (R 4.6.0)
 progress      1.2.3      2023-12-06 [1] CRAN (R 4.6.0)
 R6            2.6.1      2025-02-15 [1] CRAN (R 4.6.0)
 Rcpp          1.1.2      2026-07-05 [1] CRAN (R 4.6.1)
 reticulate    1.47.0     2026-09-03 [1] CRAN (R 4.6.1)
 rlang         1.3.0      2026-07-05 [1] CRAN (R 4.6.1)
 sessioninfo   1.2.4      2026-06-04 [3] CRAN (R 4.6.0)
 tensorflow    2.20.0     2025-08-22 [1] CRAN (R 4.6.0)
 tfautograph   0.3.2      2021-09-17 [1] CRAN (R 4.6.0)
 tfruns        1.5.4      2025-08-20 [1] CRAN (R 4.6.0)
 tibble        3.3.1      2026-01-11 [2] CRAN (R 4.6.0)
 vctrs         0.7.3      2026-04-11 [1] CRAN (R 4.6.0)
 whisker       0.4.1      2022-12-05 [1] CRAN (R 4.6.0)
 withr         3.0.3      2026-06-19 [1] CRAN (R 4.6.0)

 [1] /private/var/folders/cz/hrp33k3s0jlcb_gg9snlqh580000gq/T/RtmpdRlPTa/filee6592b781453
 [2] /Users/nick_1/Library/R/arm64/4.6/library
 [3] /Library/Frameworks/R.framework/Versions/4.6/Resources/library
 * ── Packages attached to the search path.

─ Python configuration ───────────────────────────────────────────────────────
 /Users/nick_1/.cache/uv/archive-v0/L7vrsM8H3lbjoC0FIwGSF/bin/python
 /Users/nick_1/.local/share/uv/python/cpython-3.12.13-macos-aarch64-none/lib/lib/libpython3.12.dylib
 /Users/nick_1/.cache/uv/archive-v0/L7vrsM8H3lbjoC0FIwGSF:/Users/nick_1/.cache/uv/archive-v0/L7vrsM8H3lbjoC0FIwGSF
 /Users/nick_1/.local/share/uv/python/cpython-3.12.13-macos-aarch64-none/lib/python312.zip:/Users/nick_1/.local/share/uv/python/cpython-3.12.13-macos-aarch64-none/lib/python3.12:/Users/nick_1/.local/share/uv/python/cpython-3.12.13-macos-aarch64-none/lib/python3.12/lib-dynload:/Users/nick_1/.cache/uv/archive-v0/L7vrsM8H3lbjoC0FIwGSF/lib/python3.12/site-packages
 /Users/nick_1/.cache/uv/archive-v0/L7vrsM8H3lbjoC0FIwGSF
 /Users/nick_1/.cache/uv/archive-v0/L7vrsM8H3lbjoC0FIwGSF
 /Users/nick_1/.local/share/uv/python/cpython-3.12.13-macos-aarch64-none
 /Users/nick_1/.cache/uv/archive-v0/L7vrsM8H3lbjoC0FIwGSF
 /Users/nick_1/.cache/uv/archive-v0/L7vrsM8H3lbjoC0FIwGSF/bin/activate_this.py
 /Users/nick_1/.cache/uv/archive-v0/L7vrsM8H3lbjoC0FIwGSF/bin/python
 /Users/nick_1/.local/share/uv/python/cpython-3.12.13-macos-aarch64-none/bin/python3.12
 3.12.13 (main, Mar 20 2026, 00:20:20) [Clang 22.1.1 ]
 3, 12
 64bit
 FALSE
 False
 /Users/nick_1/.cache/uv/archive-v0/L7vrsM8H3lbjoC0FIwGSF/lib/python3.12/site-packages/numpy, 2                                                                                          , 5                                                                                          , 3                                                                                          
 tensorflow
 /Users/nick_1/.cache/uv/archive-v0/L7vrsM8H3lbjoC0FIwGSF/lib/python3.12/site-packages/tensorflow
 TRUE
 /Users/nick_1/.cache/uv/archive-v0/L7vrsM8H3lbjoC0FIwGSF/bin/python
 py_require()
 TRUE

──────────────────────────────────────────────────────────────────────────────
sessioninfo::session_info() for #855
Code
session_info_of("#855")
─ Session info ───────────────────────────────────────────────────────────────
 setting  value
 version  R version 4.6.1 (2026-06-24)
 os       macOS Tahoe 26.5.2
 system   aarch64, darwin23
 ui       X11
 language (EN)
 collate  en_AU.UTF-8
 ctype    en_AU.UTF-8
 tz       Australia/Hobart
 date     2026-10-07
 pandoc   3.10 @ /opt/homebrew/bin/pandoc
 quarto   1.9.36 @ /usr/local/bin/quarto

─ Packages ───────────────────────────────────────────────────────────────────
 package     * version    date (UTC) lib source
 abind         1.4-8      2024-09-12 [1] CRAN (R 4.6.0)
 backports     1.5.1      2026-04-03 [1] CRAN (R 4.6.0)
 base64enc     0.1-6      2026-02-02 [1] CRAN (R 4.6.0)
 bench       * 1.1.4      2025-01-16 [3] CRAN (R 4.6.0)
 callr         3.8.0      2026-06-05 [1] CRAN (R 4.6.0)
 cli           3.6.6      2026-04-09 [1] CRAN (R 4.6.0)
 coda          0.19-4.1   2024-01-31 [1] CRAN (R 4.6.0)
 codetools     0.2-20     2024-03-31 [3] CRAN (R 4.6.1)
 crayon        1.5.3      2024-06-20 [1] CRAN (R 4.6.0)
 digest        0.6.39     2025-11-19 [1] CRAN (R 4.6.0)
 future        1.76.0     2026-09-24 [1] CRAN (R 4.6.1)
 globals       0.19.1     2026-03-13 [1] CRAN (R 4.6.0)
 glue          1.8.1      2026-04-17 [1] CRAN (R 4.6.0)
 greta       * 0.6.0.9000 2026-10-07 [1] local
 hms           1.1.4      2025-10-17 [1] CRAN (R 4.6.0)
 jsonlite      2.0.0      2025-03-27 [1] CRAN (R 4.6.0)
 lattice       0.23-1     2026-08-12 [3] CRAN (R 4.6.1)
 lifecycle     1.0.5      2026-01-08 [1] CRAN (R 4.6.0)
 listenv       1.1.0      2026-10-04 [1] CRAN (R 4.6.1)
 magrittr      2.0.5      2026-04-04 [1] CRAN (R 4.6.0)
 Matrix        1.7-6      2026-07-25 [3] CRAN (R 4.6.1)
 parallelly    1.48.0     2026-06-29 [1] CRAN (R 4.6.1)
 pillar        1.11.1     2025-09-17 [2] CRAN (R 4.6.0)
 pkgconfig     2.0.3      2019-09-22 [1] CRAN (R 4.6.0)
 png           0.1-9      2026-03-15 [1] CRAN (R 4.6.0)
 prettyunits   1.2.0      2023-09-24 [1] CRAN (R 4.6.0)
 processx      3.9.0      2026-04-22 [1] CRAN (R 4.6.0)
 progress      1.2.3      2023-12-06 [1] CRAN (R 4.6.0)
 R6            2.6.1      2025-02-15 [1] CRAN (R 4.6.0)
 Rcpp          1.1.2      2026-07-05 [1] CRAN (R 4.6.1)
 reticulate    1.47.0     2026-09-03 [1] CRAN (R 4.6.1)
 rlang         1.3.0      2026-07-05 [1] CRAN (R 4.6.1)
 sessioninfo   1.2.4      2026-06-04 [3] CRAN (R 4.6.0)
 tensorflow    2.20.0     2025-08-22 [1] CRAN (R 4.6.0)
 tfautograph   0.3.2      2021-09-17 [1] CRAN (R 4.6.0)
 tfruns        1.5.4      2025-08-20 [1] CRAN (R 4.6.0)
 tibble        3.3.1      2026-01-11 [2] CRAN (R 4.6.0)
 vctrs         0.7.3      2026-04-11 [1] CRAN (R 4.6.0)
 whisker       0.4.1      2022-12-05 [1] CRAN (R 4.6.0)
 withr         3.0.3      2026-06-19 [1] CRAN (R 4.6.0)

 [1] /private/var/folders/cz/hrp33k3s0jlcb_gg9snlqh580000gq/T/RtmpdRlPTa/filee65930453378
 [2] /Users/nick_1/Library/R/arm64/4.6/library
 [3] /Library/Frameworks/R.framework/Versions/4.6/Resources/library
 * ── Packages attached to the search path.

─ Python configuration ───────────────────────────────────────────────────────
 /Users/nick_1/.cache/uv/archive-v0/L7vrsM8H3lbjoC0FIwGSF/bin/python
 /Users/nick_1/.local/share/uv/python/cpython-3.12.13-macos-aarch64-none/lib/lib/libpython3.12.dylib
 /Users/nick_1/.cache/uv/archive-v0/L7vrsM8H3lbjoC0FIwGSF:/Users/nick_1/.cache/uv/archive-v0/L7vrsM8H3lbjoC0FIwGSF
 /Users/nick_1/.local/share/uv/python/cpython-3.12.13-macos-aarch64-none/lib/python312.zip:/Users/nick_1/.local/share/uv/python/cpython-3.12.13-macos-aarch64-none/lib/python3.12:/Users/nick_1/.local/share/uv/python/cpython-3.12.13-macos-aarch64-none/lib/python3.12/lib-dynload:/Users/nick_1/.cache/uv/archive-v0/L7vrsM8H3lbjoC0FIwGSF/lib/python3.12/site-packages
 /Users/nick_1/.cache/uv/archive-v0/L7vrsM8H3lbjoC0FIwGSF
 /Users/nick_1/.cache/uv/archive-v0/L7vrsM8H3lbjoC0FIwGSF
 /Users/nick_1/.local/share/uv/python/cpython-3.12.13-macos-aarch64-none
 /Users/nick_1/.cache/uv/archive-v0/L7vrsM8H3lbjoC0FIwGSF
 /Users/nick_1/.cache/uv/archive-v0/L7vrsM8H3lbjoC0FIwGSF/bin/activate_this.py
 /Users/nick_1/.cache/uv/archive-v0/L7vrsM8H3lbjoC0FIwGSF/bin/python
 /Users/nick_1/.local/share/uv/python/cpython-3.12.13-macos-aarch64-none/bin/python3.12
 3.12.13 (main, Mar 20 2026, 00:20:20) [Clang 22.1.1 ]
 3, 12
 64bit
 FALSE
 False
 /Users/nick_1/.cache/uv/archive-v0/L7vrsM8H3lbjoC0FIwGSF/lib/python3.12/site-packages/numpy, 2                                                                                          , 5                                                                                          , 3                                                                                          
 tensorflow
 /Users/nick_1/.cache/uv/archive-v0/L7vrsM8H3lbjoC0FIwGSF/lib/python3.12/site-packages/tensorflow
 TRUE
 /Users/nick_1/.cache/uv/archive-v0/L7vrsM8H3lbjoC0FIwGSF/bin/python
 py_require()
 TRUE

──────────────────────────────────────────────────────────────────────────────