Caché y freeze: render sin recalcular todo

quarto
reproducibilidad
Cómo no esperar 10 minutos cada vez que cambias una coma. Cache de chunks de knitr, freeze a nivel de proyecto, cuándo invalidar y los patrones para iterar rápido sobre documentos pesados.

El problema

Tienes un documento Quarto con un chunk que tarda 3 minutos en ejecutarse, un DESeq() sobre 30.000 genes, un modelo bayesiano, una llamada a una API lenta. Cada vez que cambias una palabra del texto, el render entero recalcula ese chunk. 30 cambios = 90 minutos esperando.

Quarto tiene dos mecanismos para esto:

  • cache: a nivel de chunk individual (heredado de knitr).
  • freeze: a nivel de proyecto (más reciente y de uso global en websites y libros).

Los dos se pueden combinar. Aprender los dos paga 100× en productividad sobre proyectos grandes.

cache: true por chunk

El mecanismo clásico de knitr. Por chunk:

`​`​`{r}
#| label: modelo
#| cache: true

dds <- DESeq(dds)              # toma minutos
res <- results(dds)
`​`​`

La primera vez: ejecuta el chunk y guarda el resultado en _cache/ (carpeta junto al .qmd). Renders sucesivos: si el código y dependencias no cambian, carga el resultado guardado. Casi instantáneo.

Cuándo se invalida la caché

knitr considera el chunk distinto (e invalida la caché) si:

  • El código del chunk cambia.
  • Las opciones del chunk cambian.
  • Una dependencia declarada cambia (dependson: ...).

knitr no detecta cambios en:

  • Datos externos (un CSV que carga el chunk).
  • Variables de chunks previos (a menos que las declares como dependencia).
  • Funciones definidas en archivos .R fuente.

Esto es la causa #1 de “la caché está mintiendo”. Si tu chunk carga read.csv("datos.csv") y datos.csv cambia, el chunk no se recalcula automáticamente. Tienes que invalidar a mano.

Dependencias explícitas

`​`​`{r}
#| label: cargar-datos
#| cache: true

datos <- read.csv("datos.csv")
`​`​`

`​`​`{r}
#| label: modelo
#| cache: true
#| dependson: ["cargar-datos"]

modelo <- lm(y ~ x, data = datos)
`​`​`

dependson: ["cargar-datos"] le dice a knitr: “si cambia el chunk cargar-datos, invalida este también”.

Para invalidar también con cambios de un archivo externo:

`​`​`{r}
#| label: cargar-datos
#| cache: true
#| cache.extra: !expr file.info("datos.csv")$mtime

datos <- read.csv("datos.csv")
`​`​`

cache.extra con la fecha de modificación del fichero: si cambia, la caché se invalida. Patrón estándar para chunks que cargan datos.

Invalidar caché a mano

Cuando estás seguro de que la caché está stale:

quarto render documento.qmd --cache-refresh

O bórrala manualmente:

rm -rf _cache/

--cache-refresh regenera. rm la elimina (siguiente render la reconstruye).

Setup global de cache

Defaults para todo el documento:

---
execute:
  cache: true
---

Y excepciones en chunks individuales:

`​`​`{r}
#| cache: false      # NO cachear este

resultado_rapido <- 1 + 1
`​`​`

Para chunks rápidos (cargar datos, sumar columnas), cachear es overhead innecesario. Para chunks lentos, el beneficio es enorme.

freeze: el sistema moderno para proyectos

freeze es el mecanismo de Quarto para proyectos enteros (websites, libros). Diferencia clave: cache opera por chunk, freeze opera por documento completo.

En _quarto.yml:

project:
  type: website
execute:
  freeze: auto

Tres modos:

  • auto: Quarto computa el documento la primera vez. En renders posteriores, no recomputa salvo que el .qmd haya cambiado.
  • true: Nunca recomputa, ni siquiera si cambias el .qmd. Tienes que invalidar a mano. Útil para CI con outputs ya generados localmente.
  • false: Default. Siempre recomputa.

El output del compute se guarda en _freeze/. Esto se versiona en Git: cuando publicas el sitio en CI, el server no necesita ejecutar R/Python, sirve el output ya computado.

Cuándo cada uno

Situación Mecanismo
Documento solo, un chunk muy lento cache: true en ese chunk
Documento solo, varios chunks moderados execute: cache: true global
Website / book con muchas páginas freeze: auto en _quarto.yml
Deploy a Netlify / GitHub Pages sin R en CI freeze: auto + commit de _freeze/

Para proyectos serios, freeze: auto es casi obligatorio. Sin él, render de un libro de 30 capítulos = recalcular todos los chunks de los 30 capítulos cada vez.

_freeze/: qué versionar

_freeze/ contiene resultados pre-computados. Sí se versiona (entra en Git). Razones:

  • Permite renderizar el sitio en CI sin R/Python instalado.
  • Garantiza que todos los colaboradores ven el mismo output.
  • Hace el deploy mucho más rápido (no se ejecuta nada en el servidor).

_cache/ (de knitr) suele estar en .gitignore, es output local, no se necesita compartir.

.gitignore típico de un proyecto Quarto:

/.quarto/
/_site/
/_cache/
*.rmarkdown
*_files/

Y _freeze/ no está en gitignore, se commitea.

El flujo idiomático con freeze

# Localmente: editas un .qmd
quarto preview                # renderiza solo lo que cambió

# Antes de pushear:
git add _freeze/
git commit -m "actualiza analisis-regional"
git push

# En CI (Netlify, GitHub Actions, etc.):
quarto render               # usa los _freeze/, no ejecuta R/Python

quarto preview durante desarrollo recomputa solo el documento que editas. El resto sirve desde _freeze/.

Casos extremos: análisis muy pesados

Para análisis donde la ejecución toma horas (ML training, RNA-seq con miles de genes, simulaciones), patrón sugerido:

  1. Análisis en script .R o .py separado, que guarda resultados a .rds / .parquet.
  2. .qmd carga los resultados ya computados.
# analisis.R (script separado)
modelo <- entrenar_random_forest(...)
saveRDS(modelo, "modelos/rf-2024.rds")
`​`​`{r}
# en el .qmd, instantáneo
modelo <- readRDS("modelos/rf-2024.rds")
`​`​`

Esto separa el cómputo de la narrativa. El render del documento es siempre rápido. Cuando los datos cambian, ejecutas el script una vez y commits el .rds actualizado.

Es la solución más limpia para análisis serios, cache y freeze lo hacen automático, pero a veces tener el control explícito vale más.

Diferencias frente a Rmd

En R Markdown clásico, solo existía cache. Quarto añade freeze para resolver el caso de sitios y libros, donde knitr cache resultaba insuficiente.

Para proyectos pequeños (un solo .qmd), cache por chunk sigue siendo lo más cómodo. Para sitios y libros, freeze global.

Trampas habituales

  • Caché stale silenciosa. Cambias un CSV, el chunk no se recalcula. Usa cache.extra con file.info() o invalida a mano.
  • cache: true en chunks rápidos. Overhead de E/S. Reserva para chunks de > 5 segundos.
  • Versionar _cache/ pero no _freeze/. Es al revés: _cache/ es local, _freeze/ se versiona para CI.
  • freeze: true accidental. Significa “nunca recomputar”. Si tu documento parece “atascado” en una versión, comprueba el modo.
  • Cambio de versión de paquete + caché viva. Si actualizas DESeq2 y la caché es de antes, los resultados se cargan de la versión vieja. En cambios grandes de paquetes, borra _cache/ y _freeze/ y regenera.

En la siguiente entrega

Has cerrado el bloque editorial. Lo que viene son sitios y libros Quarto: cómo se configura un _quarto.yml para website, los listings de blog, la navbar, y los detalles de configuración. Lo siguiente.