ggplot2 icon indicating copy to clipboard operation
ggplot2 copied to clipboard

Warning: Graphics device changes when using `ggplotGrob` inside `future::future_lapply` with multisession plan

Open elgabbas opened this issue 8 months ago • 11 comments

When using ggplot2::ggplotGrob() (and indirectly ggExtra::ggMarginal()) inside a future::future_lapply() call with plan(multisession), warnings appear related to unexpected graphics device changes. This breaks the expected isolation of child processes: child workers should not open or close graphics devices that persist outside their scope or cause side effects detectable by the parent.

Specifically, the warnings mention that a new device (usually pdf) is opened without being closed properly, or the device state changes unexpectedly during the future evaluation.

This happens even when dev.list() returns NULL before execution, and attempts to pre-open and explicitly close devices inside the future expression (e.g., using grDevices::pdf() and dev.off()) do not fully suppress these warnings.

This makes it difficult to safely use ggplot2 in parallel workflows using multisession futures, where device state consistency is critical.

Reprex:

library(ggplot2)
library(grid)
library(future.apply)

plan(multisession, workers = 2)

plot_function <- function(x) {
  p <- ggplot(data.frame(x = rnorm(100), y = rnorm(100)), aes(x, y)) +
    geom_point()
  grob <- ggplotGrob(p)
  return(grob)
}

results <- future_lapply(1:5, plot_function, future.seed = TRUE)
# Warning messages:
# 1: MultisessionFuture (‘future_lapply-1’) added, removed, or modified devices. A future expression must close
any opened devices and must not close devices it did not open. Details: 1 devices differ: index=2, before=‘NA’, after=‘pdf’ 
# 2: MultisessionFuture (‘future_lapply-2’) added, removed, or modified devices. A future expression must close
any opened devices and must not close devices it did not open. Details: 1 devices differ: index=2, before=‘NA’, after=‘pdf’ 

Opening a temporary pdf device explicitly before plotting and closing it afterwards did not fully solve the issue:

# new R session
library(ggplot2)
library(grid)
library(future.apply)

plan(multisession, workers = 2)

plot_function <- function(x) {
  # Open a temporary graphics device to absorb side effects
  tmp <- tempfile(fileext = ".pdf")
  grDevices::pdf(tmp)
  on.exit(grDevices::dev.off(), add = TRUE)
  
  # Create plot and convert to grob
  p <- ggplot(data.frame(x = rnorm(100), y = rnorm(100)), aes(x, y)) +
    geom_point()
  grob <- ggplotGrob(p)
  return(grob)
}

results <- future_lapply(1:5, plot_function, future.seed = TRUE)

# Warning messages:
# 1: MultisessionFuture (‘future_lapply-1’) added, removed, or modified devices. A future expression must close 
any opened devices and must not close devices it did not open. Details: 1 devices differ: index=2, before=‘NA’, after=‘’ 
# 2: MultisessionFuture (‘future_lapply-2’) added, removed, or modified devices. A future expression must close 
any opened devices and must not close devices it did not open. Details: 1 devices differ: index=2, before=‘NA’, after=‘’ 

What internal calls in ggplot2::ggplotGrob() or related grid functions might open a graphics device implicitly, especially in headless or parallel contexts? Is there a recommended way to preemptively open and close devices, or to prevent implicit device openings, when using ggplot2 grobs in parallel processes?


> sessionInfo()
R version 4.5.0 (2025-04-11 ucrt)
Platform: x86_64-w64-mingw32/x64
Running under: Windows 10 x64 (build 19045)

Matrix products: default
LAPACK version 3.12.1

locale:
  [1] LC_COLLATE=English_United States.utf8  LC_CTYPE=English_United States.utf8   
[3] LC_MONETARY=English_United States.utf8 LC_NUMERIC=C                          
[5] LC_TIME=English_United States.utf8    

time zone: Europe/Berlin
tzcode source: internal

attached base packages:
  [1] grid      stats     graphics  grDevices utils     datasets  methods   base     

other attached packages:
  [1] future.apply_1.11.3 future_1.49.0       ggplot2_3.5.2      

loaded via a namespace (and not attached):
  [1] vctrs_0.6.5        cli_3.6.5          rlang_1.1.6        generics_0.1.4    
[5] glue_1.8.0         listenv_0.9.1      scales_1.4.0       tibble_3.2.1      
[9] lifecycle_1.0.4    compiler_4.5.0     dplyr_1.1.4        codetools_0.2-20  
[13] RColorBrewer_1.1-3 pkgconfig_2.0.3    rstudioapi_0.17.1  farver_2.1.2      
[17] digest_0.6.37      R6_2.6.1           dichromat_2.0-0.1  tidyselect_1.2.1  
[21] pillar_1.10.2      parallelly_1.44.0  parallel_4.5.0     magrittr_2.0.3    
[25] tools_4.5.0        withr_3.0.2        gtable_0.3.6       globals_0.18.0    

See also: https://github.com/futureverse/future/discussions/788

elgabbas avatar May 20 '25 00:05 elgabbas

I have fought with these types of issues a lot in my cowplot package. The brief answer is converting a plot into a grob should ideally only be done when actually rendering the plot into an opened graphics device. Otherwise you will run into all sorts of issues. However, you could try the cowplot::as_grob() function to see if raises similar warnings.

Here is the relevant source code, which is quite cumbersome and lists some of the problems that can arise in different settings: https://github.com/wilkelab/cowplot/blob/34819eb709ed590d90a74ac7c39132e832bb94fc/R/as_grob.R#L82

The reason why a graphics device needs to be open is that we need font metrics to generate the grob and we don't have access to font metrics without having a graphics device open. An additional problem is that the font metrics will (subtly) change for different graphics devices and settings (for example, for raster devices, they can depend on the size of the output image), so we're almost guaranteed to have incorrect font metrics if we convert to a grob outside of final rendering. Again, this is where we go back to the point that ideally we wouldn't do this in the first place.

clauswilke avatar May 20 '25 04:05 clauswilke

Thanks @clauswilke. I tried a couple of examples using cowplot::as_grob() and I get either warnings or errors

plot_function <- function(x) {
  p <- ggplot(data.frame(x = rnorm(100), y = rnorm(100)), aes(x, y)) +
    geom_point()
  
  # warnings
  grob <- cowplot::as_grob(p)
  # grob <- cowplot::as_grob(p, device = pdf)
  # grob <- cowplot::as_grob(p, device = png)
  
  # Error in device(width = 6, height = 6) : could not find function "device"
  # grob <- cowplot::as_grob(p, device = "pdf")
  # grob <- cowplot::as_grob(p, device = "cairo")
  # grob <- cowplot::as_grob(p, device = "png")
  # grob <- cowplot::as_grob(p, device = "agg")
  
  # Error in ...future.FUN(...future.X_jj, ...) : object 'cairo' not found
  # grob <- cowplot::as_grob(p, device = cairo)
  # Error in ...future.FUN(...future.X_jj, ...) : object 'agg' not found
  # grob <- cowplot::as_grob(p, device = agg)
  
  return(grob)
}

results <- future_lapply(1:5, plot_function, future.seed = TRUE)

As it is necessary to open a device to get font metrics, how device is opened in ggplot and is it explicitly closed afterwards? My understanding is that this opens device (which is probably fine) without closing it.

elgabbas avatar May 20 '25 09:05 elgabbas

how device is opened in ggplot and is it explicitly closed afterwards?

AFAIK ggplot2 doesn't open graphics devices explicitly except in ggsave(). It might be that opening graphics devices is a side-effect from functions such as grid::convertWidth().

teunbrand avatar May 20 '25 09:05 teunbrand

I think I found a temporary solution that disables the DeviceMisuseFutureWarning warnings.

library(ggplot2)
library(grid)
library(ggExtra)
library(future.apply)

plan(multisession, workers = 2)

plot_function1 <- function(x) {
  warning("warning 1", call. = FALSE)
  data <- data.frame(x = rnorm(100), y = rnorm(100))
  Plot <- ggplot(data, aes(x, y)) + geom_point(color = "steelblue4")
  warning("warning 2", call. = FALSE)
  
  Plot_Marginal <- ggExtra::ggMarginal(
    p = Plot, type = "histogram", margins = "y", size = 6,
    color = "steelblue4", fill = "steelblue4", bins = 50
  )
  warning("warning 3", call. = FALSE)
  
  Plot_Marginal
}

plot_function2 <- function(x) {
  warning("warning 1", call. = FALSE)
  p <- ggplot(data.frame(x = rnorm(100), y = rnorm(100)), aes(x, y)) +
    geom_point()
  warning("warning 2", call. = FALSE)
  grob <- ggplotGrob(p)
  warning("warning 3", call. = FALSE)
  return(grob)
}

suppress_device_warning <- function(expr) {
  withCallingHandlers(
    expr,
    warning = function(w) {
      msg <- conditionMessage(w)
      if (inherits(w, "DeviceMisuseFutureWarning") ||
          grepl("added, removed, or modified devices", msg)) {
        invokeRestart("muffleWarning")
      }
    }
  )
}

# no warnings
suppress_device_warning({
  outputs1 <- future_lapply(1:5, plot_function1, future.seed = TRUE)
})

plot(outputs1[[1]])

# no warnings
suppress_device_warning({
  outputs2 <- future_lapply(1:5, plot_function2, future.seed = TRUE)
})

plot(outputs2[[1]])

elgabbas avatar May 20 '25 10:05 elgabbas

@teunbrand Yes, this is exactly what happens. If you request font metrics and no graphics device is open then R just opens one.

clauswilke avatar May 20 '25 14:05 clauswilke

Right, but that isn't really behaviour ggplot2 has any sensible control over so there isn't much to adapt in ggplot2's code, I think.

teunbrand avatar May 28 '25 10:05 teunbrand

It is possible to avoid it in ggplot2 at the cost of performance.

Text rendering is clearly moving towards an A and B tier device support, so it is likely that e.g. the string dimensions we get from one device cannot sensibly be used with another device.

It might make sense to investigate wether the performance benefit that prompted the upfront-unit conversion still exist today

thomasp85 avatar May 28 '25 10:05 thomasp85

It is possible to avoid it in ggplot2 at the cost of performance.

Is this: just check in ggplot_gtable() whether a device is open, and if not, open and close a temporary one? Because if we only have to do this once per plot, it wouldn't be so terrible, I imagine.

teunbrand avatar Jun 05 '25 12:06 teunbrand

@teunbrand No, temporary graphics devices cause a bunch of issues and are best avoided. If only because you don't typically get the correct font metrics as the temporary device will in general not exactly match the final device used for rendering. I'm not sure what @thomasp85 is thinking of but in general it is best to defer all requests for font metrics etc. until the final rendering time, so the correct graphics device is available and open. The problem is there are many extension packages out there that violate this rule. My own are among the worst offenders. I'm not sure off the top of my head whether ggplot2 itself has this problem anywhere in its code base. I think not.

clauswilke avatar Jun 05 '25 14:06 clauswilke

In ggplot2 we convert grob dimensions to absolute units in eg titleGrob. We do this because it is more performant to calculate up front but we could choose not to do it and keep it in grobWidth and grobHeight dimensions

thomasp85 avatar Jun 05 '25 14:06 thomasp85

it is best to defer all requests for font metrics etc. until the final rendering time

Yes I agree, but if nobody has opened a GD, then grid will open one for us (and never close it). So the problems with font metrics are already happening, they just aren't extremely noticible.

teunbrand avatar Jun 05 '25 14:06 teunbrand

I should have checked this before I started puzzling, but I don't think this is a ggplot2 issue. Even if you very reasonably open and close a graphics device, future_lapply() will complain.

library(future.apply)
#> Loading required package: future

plan(multisession, workers = 2)

plot_function <- function(x) {
  dev.new()
  cur <- dev.cur()
  dev.off(cur)
  return(1)
}

results <- future_lapply(1:5, plot_function, future.seed = TRUE)
#> Warning: MultisessionFuture ('future_lapply-1') opened the default graphics device (1: c("do.call(function(...) {", "    \"# future::getGlobalsAndPackages(): FUN() uses '...' internally \"", "    \"# without having an '...' argument. This means '...' is treated\"", "    \"# as a global variable. This may happen when FUN() is an       \"", "    \"# anonymous function.                                          \"", "    \"#                                                              \"", "    \"# If an anonymous function, we will make sure to restore the   \"", "    \"# function environment of FUN() to the calling environment.    \"", 
#> "    \"# We assume FUN() an anonymous function if it lives in the     \"", "    \"# global environment, which is where globals are written.      \"", "    penv <- env <- environment(...future.FUN)", "    repeat {", "        if (identical(env, globalenv()) || identical(env, emptyenv())) ", "            break", "        penv <- env", "        env <- parent.env(env)", "    }", "    if (identical(penv, globalenv())) {", "        environment(...future.FUN) <- environment()", "    }", "    else if (!identical(penv, emptyenv()) && !is.null(penv) && ", 
#> "        !isNamespace(penv)) {", "        parent.env(penv) <- environment()", "    }", "    rm(list = c(\"env\", \"penv\"), inherits = 
# [truncated for brevity purposes]

Created on 2025-09-29 with reprex v2.1.1

In this case, it doesn't matter if ggplot2 is well-behaved with regards to graphics devices: you'll get these warnings anyway. Unless there is another compelling reason for ggplot2 to close graphics devices that grid opens, I propose to close this issue.

teunbrand avatar Sep 29 '25 09:09 teunbrand

Closing this issue for reasons mentioned above. We can re-open if the above code works as expected.

teunbrand avatar Oct 07 '25 09:10 teunbrand