diff --git a/CLAUDE.md b/CLAUDE.md index 24f72a3..50d36d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -299,7 +299,8 @@ Native torch modules will load weights directly from HuggingFace safetensors for model <- load_from_hf("stabilityai/stable-diffusion-xl-base-1.0") ``` -See cornyverse CLAUDE.md for safetensors package setup (use cornball-ai fork until PR merged). +See cornyverse CLAUDE.md for safetensors package setup. CRAN safetensors +0.3.0 contains the required fixes; keep downstream gates capability-probed. ## Roadmap diff --git a/DESCRIPTION b/DESCRIPTION index d85c958..c047ea6 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: diffuseR Title: Functional Interface to Diffusion Models in R -Version: 0.2.2.6 +Version: 0.2.2.7 Authors@R: c( person("Troy", "Hernandez", email = "troy@cornball.ai", role = c("aut", "cre"), comment = c(ORCID = "0009-0005-4248-604X")), diff --git a/NEWS.md b/NEWS.md index a7e85df..04a8ae9 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,66 @@ +# diffuseR 0.2.2.7 + +* `recommend()` diagnosed the wrong safetensors capability for bf16. The + tier gate consults `.st_can_read()`, but the note it produced cited + mlverse/safetensors#11, which is the bfloat16 *write* fix. bfloat16 + read worked on CRAN 0.2.1, so a reader that lacks it is not waiting on + #11, and the message sent users to an unrelated issue. `.st_update_note()` + now takes `mode`, and both call sites pass the capability they actually + gated on. float8 is unchanged: 0.2.1 had neither read nor write, so #13 + is correct from both sides. + + The suite had been holding this in place. Its read-mode assertion + matched `"safetensors#11"`, so the wrong reference was pinned by the + test rather than caught by it. The remedy-only guards added earlier in + this release could not catch it either: the message named the right + package to install while blaming the wrong missing feature. + +* safetensors 0.3.0 reached CRAN on 2026-08-21 with all four fixes + diffuseR had been routing users around: float8 support + (mlverse/safetensors#13), bfloat16 write (#11), the >2 GB offset + overflow (#14), and empty tensor names (#10). Every message that told + users to install a development build from GitHub now tells them to run + `install.packages("safetensors")`. That covers `recommend()`'s tier + note, the graceful fp8/bf16 fallback, both `flux_quantize()` errors, and + the >2 GB read breadcrumb. + + The same sweep reached the documentation, which is where most of the + stale advice actually was: the `shard_bytes` help for `flux_quantize()`, + `ltx23_quantize_nf4()`, `ltx23_quantize_fp8()` and + `gemma3_quantize_nf4()` described the 1.9e9 default as what "stock CRAN + safetensors" can read and pointed at a fork for anything larger. + `README.md`, `vignette("performance-levers")`, `reshard_safetensors()` + and the `unet_safetensors`, `download_prebuilt` and + `convert_sd21_pt_to_diffusers` help pages carried variants of the same. + + All of it now names the capability rather than a version: "needs the + overflow fix (mlverse/safetensors#14, which reached CRAN in 0.3.0)" + rather than "requires safetensors 0.3.0 or newer". That distinction is + the package's whole stance here, documented below: a version number + never separated a capable build from an incapable one, so prose making + 0.3.0 the requirement contradicts the probes it sits beside. The >2 GB + read breadcrumb and the bf16 resident-dtype message use the same + vocabulary. + + The capability probes are unchanged, and deliberately so. They were + written as runtime probes rather than a version floor precisely so this + day would need no code change, and they still cover what a version test + cannot: the fixes existed for three weeks in builds that reported 0.2.1. + No version floor has been added to `Suggests` for the same reason: nf4 + works on older safetensors, so a stale install costs a tier rather than + the model. + + `recommend()`'s returned `fork_suggested` field keeps its name, which is + now historical: it means the installed safetensors cannot read a tier + the card could otherwise run. Renaming it would break the returned + contract for a cosmetic gain. + +* `reshard_safetensors()` is no longer required to make a large artifact + readable, since 0.3.0 fixed the overflow it worked around. It stays + useful for publishing: the shards it writes load on every safetensors + including the older ones, which is what makes a hosted artifact safe to + redistribute. + # diffuseR 0.2.2.6 * Fixed an allocator pre-warm accumulation introduced in 0.2.2.4. diff --git a/R/convert_sd_pt.R b/R/convert_sd_pt.R index 181f058..5b614d4 100644 --- a/R/convert_sd_pt.R +++ b/R/convert_sd_pt.R @@ -13,7 +13,7 @@ #' CreativeML OpenRAIL++-M (redistributable), and cornball already hosts #' these weights as \code{.pt}. At \code{float16} the components are all #' sub-2 GB single files (unet ~1.7 GB, text_encoder ~0.65 GB, vae -#' ~0.16 GB), so they load on stock CRAN safetensors. +#' ~0.16 GB), so they also load on safetensors older than 0.3.0. #' #' @param pt_dir Directory holding \code{unet-cpu.pt}, #' \code{decoder-cpu.pt}, \code{text_encoder-cpu.pt} (default: the diff --git a/R/download_prebuilt.R b/R/download_prebuilt.R index 0bf8d09..a4e5d8f 100644 --- a/R/download_prebuilt.R +++ b/R/download_prebuilt.R @@ -3,8 +3,8 @@ #' diffuseR hosts prebuilt NF4 artifacts for the two models whose #' licenses permit redistribution: FLUX.2-klein-4B and Z-Image-Turbo #' (both Apache-2.0, ungated). The artifacts are the exact output of -#' \code{\link{flux_quantize}} (sub-2 GB shards, bf16 residents), so -#' stock CRAN safetensors reads them. Nothing else in the catalog is +#' \code{\link{flux_quantize}} (sub-2 GB shards, bf16 residents), so even +#' safetensors older than 0.3.0 reads them. Nothing else in the catalog is #' hosted: FLUX.1-schnell sits behind a HuggingFace license gate and #' LTX-2.3's license does not permit redistribution, so both still #' download their sources and quantize locally. diff --git a/R/fp8_ltx23.R b/R/fp8_ltx23.R index 71a5fdb..4a7fece 100644 --- a/R/fp8_ltx23.R +++ b/R/fp8_ltx23.R @@ -113,9 +113,11 @@ ltx23_fp8_linear <- torch::nn_module( #' @param output_dir Output directory for shards + manifest; NULL (the #' default) resolves under tools::R_user_dir("diffuseR", "data"). #' @param shard_bytes Numeric. Target shard size in bytes. The default -#' 1.9e9 keeps every shard under the 2^31-byte (~2.15 GB) ceiling that -#' stock CRAN safetensors can read. Pass a larger value (e.g. 4e9) only -#' for local builds you will read back with a fork-patched safetensors. +#' 1.9e9 keeps every shard under the 2^31-byte (~2.15 GB) offset +#' ceiling, so the artifact reads on a safetensors that lacks the +#' overflow fix (mlverse/safetensors#14, which reached CRAN in 0.3.0). +#' Larger values (e.g. 4e9) need that fix present, so keep them for +#' local artifacts and use the default for anything redistributed. #' @param force Logical. Re-quantize even if a valid manifest exists. #' @param verbose Logical. #' diff --git a/R/nf4_ltx23.R b/R/nf4_ltx23.R index 5d1e25f..ff47354 100644 --- a/R/nf4_ltx23.R +++ b/R/nf4_ltx23.R @@ -259,9 +259,11 @@ ltx23_nf4_linear <- torch::nn_module( #' @param output_dir Output directory for shards + manifest; NULL (the #' default) resolves under tools::R_user_dir("diffuseR", "data"). #' @param shard_bytes Numeric. Target shard size in bytes. The default -#' 1.9e9 keeps every shard under the 2^31-byte (~2.15 GB) ceiling that -#' stock CRAN safetensors can read. Pass a larger value (e.g. 4e9) only -#' for local builds you will read back with a fork-patched safetensors. +#' 1.9e9 keeps every shard under the 2^31-byte (~2.15 GB) offset +#' ceiling, so the artifact reads on a safetensors that lacks the +#' overflow fix (mlverse/safetensors#14, which reached CRAN in 0.3.0). +#' Larger values (e.g. 4e9) need that fix present, so keep them for +#' local artifacts and use the default for anything redistributed. #' @param force Logical. Re-quantize even if a valid manifest exists. #' @param verbose Logical. #' diff --git a/R/quantize_flux.R b/R/quantize_flux.R index 7f36d64..5c8cc83 100644 --- a/R/quantize_flux.R +++ b/R/quantize_flux.R @@ -17,8 +17,8 @@ NULL } # Capability probe: can the installed safetensors round-trip this dtype? -# CRAN safetensors (<= 0.2.1) can read bfloat16 but not write it, and has -# no float8 support; the fixes are upstream PRs. Cached per session; +# The CRAN safetensors 0.2.1 release can read bfloat16 but not write it, +# and has no float8 support; 0.3.0 includes both fixes. Cached per session; # options(diffuseR.st_caps = list(bfloat16 = FALSE, ...)) overrides for # tests. .st_caps <- new.env(parent = emptyenv()) @@ -49,7 +49,7 @@ NULL # Resolve precision = "auto": prefer an existing quantized artifact # (fp8 first), else pick by float8 write capability. An fp8 artifact is # only chosen if the installed safetensors can actually read float8 - -# otherwise a fork-built fp8 artifact on a CRAN-safetensors machine +# otherwise an fp8 artifact on a reader without float8 support # would be selected and then fail at read time. Write capability is a # sound proxy for read capability (nothing writes fp8 but cannot read # it). @@ -310,10 +310,11 @@ NULL #' the per-format location under \code{tools::R_user_dir}). #' @param format "nf4" or "fp8". #' @param shard_bytes Numeric. Target shard size in bytes. The default -#' 1.9e9 keeps every shard under the 2^31-byte (~2.15 GB) ceiling that -#' stock CRAN safetensors can read, so the artifact loads fork-free. -#' Pass a larger value (e.g. 4e9) only for local builds you will read -#' back with a fork-patched safetensors. +#' 1.9e9 keeps every shard under the 2^31-byte (~2.15 GB) offset +#' ceiling, so the artifact reads on a safetensors that lacks the +#' overflow fix (mlverse/safetensors#14, which reached CRAN in 0.3.0). +#' Larger values (e.g. 4e9) need that fix present, so keep them for +#' local artifacts and use the default for anything redistributed. #' @param force Logical. Re-quantize even if a valid manifest exists. #' @param verbose Logical. #' @@ -327,14 +328,15 @@ flux_quantize <- function(transformer_dir, output_dir = NULL, if (format == "fp8" && !.st_can_write("float8_e4m3fn")) { stop("The installed safetensors package cannot write float8 ", "tensors (needs the float8 support from ", - "mlverse/safetensors#13, which is merged upstream but not ", - "yet on CRAN; install the development version of ", - "safetensors from GitHub, or use format = \"nf4\").", - call. = FALSE) + "mlverse/safetensors#13, released in safetensors 0.3.0). ", + "Run install.packages(\"safetensors\") to update, or use ", + "format = \"nf4\".", call. = FALSE) } # Residents load into the compute dtype either way; bf16 halves the - # artifact but the CRAN build of safetensors 0.2.1 cannot write it - # (the fix is merged upstream; the probe decides, not the version) + # artifact but a safetensors without the bfloat16 write fix + # (mlverse/safetensors#11, which reached CRAN in 0.3.0) cannot write + # it. The probe decides, not the version: the fix existed for three + # weeks in builds still reporting 0.2.1. resident_dtype <- if (.st_can_write("bfloat16")) { torch::torch_bfloat16() } else { @@ -491,10 +493,9 @@ flux_load_transformer <- function(ckpt, device = "cuda", dtype = "bfloat16", format <- ckpt$format %||% "full" if (identical(format, "fp8") && !.st_can_write("float8_e4m3fn")) { stop("This fp8 artifact needs float8 support the installed ", - "safetensors lacks (mlverse/safetensors#13, merged upstream ", - "but not yet on CRAN). Install the development version of ", - "safetensors from GitHub, or rebuild the artifact as nf4.", - call. = FALSE) + "safetensors lacks (mlverse/safetensors#13, released in ", + "safetensors 0.3.0). Run install.packages(\"safetensors\") ", + "to update, or rebuild the artifact as nf4.", call. = FALSE) } hooks <- .flux_family_hooks(ckpt$config) diff --git a/R/quantize_gemma3.R b/R/quantize_gemma3.R index 25f6f98..8c30c5e 100644 --- a/R/quantize_gemma3.R +++ b/R/quantize_gemma3.R @@ -18,7 +18,7 @@ #' @param output_dir Output directory for shards + manifest (default: #' \code{gemma3-nf4} under \code{tools::R_user_dir}). #' @param shard_bytes Numeric. Target shard size in bytes; the 1.9e9 -#' default keeps shards readable by stock CRAN safetensors. +#' default keeps shards readable by safetensors older than 0.3.0. #' @param force Logical. Re-quantize even if a valid manifest exists. #' @param verbose Logical. #' diff --git a/R/recommend.R b/R/recommend.R index e91a051..5005067 100644 --- a/R/recommend.R +++ b/R/recommend.R @@ -13,7 +13,7 @@ #' bf16) AND the installed safetensors can \emph{read} that dtype #' (\code{\link{.st_can_read}}), that tier is recommended instead. #' \item When the card has room but safetensors cannot read the tier, -#' nf4 is recommended and the fork suggestion is surfaced in +#' nf4 is recommended and the upgrade suggestion is surfaced in #' \code{note} (never an error). #' } #' @@ -53,8 +53,12 @@ #' logical), \code{max_pixels}, \code{text_device}, \code{attn_chunk}, #' \code{vram_gb}, \code{pin} (page-lock the phase-swapped host #' copies), \code{pinned_set_gb} (estimated pinned bytes), -#' \code{host_ram_gb}, \code{fork_suggested} (logical), and -#' \code{note} (the fork suggestion string, or NULL). +#' \code{host_ram_gb}, \code{fork_suggested} (logical: the installed +#' safetensors cannot read the tier the card could otherwise run), and +#' \code{note} (the suggestion string, or NULL). The field name is +#' historical -- the remedy used to be the cornball-ai fork and is now +#' \code{install.packages("safetensors")}, since the fixes reached CRAN +#' in 0.3.0 -- and is kept because it is part of the returned contract. #' #' @export #' @@ -65,7 +69,7 @@ #' st_caps = list(bfloat16 = TRUE, float8_e4m3fn = FALSE)) #' r$precision # "nf4": fp8 fits the card, but cannot be read #' r$fork_suggested # TRUE -#' cat(r$note) # the fork-or-nf4 message +#' cat(r$note) # the update-or-nf4 message #' #' # Same card, once safetensors can read float8 #' recommend("flux1", vram_gb = 16, @@ -137,7 +141,8 @@ recommend <- function(model = c("sd21", "sdxl", "flux1", "flux2", "zimage", host_ram_gb = host_ram_gb, fork_suggested = fork, note = if (fork) { - .st_fork_note(want$precision) + # The tier was blocked by .st_can_read, so diagnose read. + .st_update_note(want$precision, mode = "read") } else { .bf16_note(model, chosen$precision) } diff --git a/R/reshard.R b/R/reshard.R index c28afc5..7d32a00 100644 --- a/R/reshard.R +++ b/R/reshard.R @@ -2,13 +2,18 @@ #' #' Splits a single \code{.safetensors} file into diffusers-style shards #' (\code{-00001-of-000NN.safetensors} plus a -#' \code{.safetensors.index.json} weight map) so it loads on stock -#' CRAN safetensors, which overflows a 32-bit offset on any file at or -#' above 2^31 bytes. Reading the oversize source requires a -#' fork-patched safetensors (a build-machine step); the shards it writes -#' are fork-free to read. Used to host large fp16 diffusers weights +#' \code{.safetensors.index.json} weight map) so it loads on a +#' safetensors without the overflow fix, which trips a 32-bit offset on +#' any file at or above 2^31 bytes. Used to host large fp16 diffusers weights #' (e.g. the 5 GB SDXL UNet) unchanged, without quantization. #' +#' safetensors 0.3.0 fixed the overflow (mlverse/safetensors#14), so +#' reading an oversize source no longer needs a patched build and this is +#' no longer required to produce a readable artifact. It stays useful for +#' two reasons: the shards it writes load on every safetensors including +#' the older ones, which is what makes a hosted artifact safe to publish, +#' and sharded weights stream rather than materialising one multi-GB file. +#' #' @param input Path to the source \code{.safetensors} file, or a #' directory containing \code{.safetensors}. #' @param output_dir Output directory for the shards + index. diff --git a/R/resident.R b/R/resident.R index 29e5eb2..7a61b38 100644 --- a/R/resident.R +++ b/R/resident.R @@ -386,7 +386,7 @@ resident_load <- function(model = c("flux2", "flux1", "zimage", "ltx", held <- tryCatch({ s <- torch::cuda_memory_stats(device = .cuda_index(device)) as.numeric(s$reserved_bytes$all$current) - - as.numeric(s$allocated_bytes$all$current) + as.numeric(s$allocated_bytes$all$current) }, error = function(e) 0) } if (!isTRUE(is.finite(held)) || held < 0) { diff --git a/R/st_caps.R b/R/st_caps.R index f546b8b..e5558a6 100644 --- a/R/st_caps.R +++ b/R/st_caps.R @@ -1,11 +1,17 @@ -#' safetensors read-capability probes and fork messaging +#' safetensors read-capability probes and upgrade messaging #' -#' The CRAN build of safetensors 0.2.1 reads bfloat16 but cannot write -#' it, and has no float8 support at all. Both fixes merged upstream on -#' 2026-07-31 (mlverse/safetensors#11 for bfloat16 write, #13 for -#' float8) without a version bump, so the installed version number -#' cannot tell you which build you have. That is why every gate here is -#' a runtime probe: write a tiny tensor, read it back, cache the answer. +#' The CRAN safetensors 0.2.1 release reads bfloat16 but cannot write it, +#' and has no float8 support at all. Both fixes (mlverse/safetensors#11 +#' for bfloat16 write, #13 for float8) merged upstream on 2026-07-31 and +#' reached CRAN in safetensors 0.3.0 on 2026-08-21, along with #14 for the +#' >2 GB offset overflow and #10 for empty tensor names. +#' +#' The gates here stayed runtime probes rather than a version floor, and +#' that decision is what let the fork requirement retire itself the day +#' the release landed: nothing had to change for a 0.3.0 user to get the +#' higher tiers. It also still covers the case a version test cannot -- +#' the fixes existed for three weeks in builds that reported 0.2.1, so +#' the version number never distinguished them. Probe, do not pin. #' Two capabilities matter and they differ: #' #' \itemize{ @@ -15,26 +21,27 @@ #' \item \emph{read} (\code{.st_can_read}, here): needed to LOAD a #' hosted artifact in that dtype. This is the capability that gates #' user-facing recommendations. It is strictly weaker than write: -#' CRAN safetensors reads bfloat16 it cannot write, so the write -#' probe is the wrong signal for whether a hosted bf16 artifact will -#' load. +#' the CRAN 0.2.1 release reads bfloat16 it cannot write, so the +#' write probe is the wrong signal for whether a hosted bf16 artifact +#' will load. #' } #' -#' Both are capability-probed, never version-pinned, so the fork -#' requirement self-heals the day the fixes reach CRAN. +#' Both are capability-probed, never version-pinned. That is why the fork +#' requirement healed itself when 0.3.0 shipped, and why it will do the +#' same for whatever lands next. #' #' @name st_caps NULL # Read-probe cache, keyed by dtype. Separate from quantize_flux.R's # `.st_caps` write cache: the same dtype can be readable but not -# writable (bfloat16 on CRAN), so the two must not share entries. +# writable (bfloat16 on CRAN 0.2.1), so the two must not share entries. .st_read_caps <- new.env(parent = emptyenv()) # Write a minimal 2-element safetensors file by hand: a u64 # little-endian header length, the JSON header, then the raw tensor # bytes. Deliberately does NOT go through safetensors::safe_save_file - -# that is the whole point, since a CRAN safetensors cannot WRITE +# that is the whole point, since CRAN safetensors 0.2.1 cannot WRITE # bfloat16 yet can READ it. Lets `.st_can_read` test read capability in # isolation from write capability. .st_write_min <- function(path, dtype_name, payload) { @@ -95,34 +102,56 @@ NULL ok } -# The standard "install the fork, or press on with nf4" message. Shared -# by the recommender (read side, fit = TRUE: "best fit for your card") -# and the download graceful-fallback path (write side, fit = FALSE, since -# the user asked for it outright) so the wording stays identical -# everywhere. No em dashes (house style). -.st_fork_note <- function(precision, fit = TRUE) { +# The standard "update safetensors, or press on with nf4" message. Shared +# by the recommender (fit = TRUE: "best fit for your card") and the +# download graceful-fallback path (fit = FALSE, since the user asked for +# it outright) so the wording stays identical everywhere. No em dashes +# (house style). +# +# `mode` names the capability actually being diagnosed, and it is not +# cosmetic. bfloat16 READ worked on CRAN 0.2.1, so a build that cannot +# read bf16 is not waiting on mlverse/safetensors#11: that is the WRITE +# fix, and citing it on the read path sends the user to an issue that +# has nothing to do with their failure. The recommender gates on read +# (.st_can_read), so it was doing exactly that. float8 is unaffected +# either way, since 0.2.1 had neither read nor write for it. +.st_update_note <- function(precision, fit = TRUE, mode = c("read", "write")) { + mode <- match.arg(mode) precision <- as.character(precision) + lead <- if (fit) { + sprintf("%s is the best fit for your card but needs", precision) + } else { + sprintf("%s needs", precision) + } + is_bf16 <- precision %in% c("bf16", "bfloat16") + # No release "added" bfloat16 read, so there is no fix to point at + # and no version that makes it appear: a reader lacking it predates + # the capability rather than trailing a patch. + if (is_bf16 && mode == "read") { + return(sprintf(paste0("%s a safetensors that can read bfloat16, ", + "which the installed one cannot. Run ", + "install.packages(\"safetensors\") to ", + "update, or press on with nf4: same weights, ", + "slightly lower precision, and it just ", + "works."), + lead)) + } detail <- switch(precision, fp8 = "float8 support (mlverse/safetensors#13)", float8_e4m3fn = "float8 support (mlverse/safetensors#13)", bf16 = "bfloat16 write support (mlverse/safetensors#11)", bfloat16 = "bfloat16 write support (mlverse/safetensors#11)", paste0(precision, " support (mlverse/safetensors)")) - lead <- if (fit) { - sprintf("%s is the best fit for your card but needs", precision) - } else { - sprintf("%s needs", precision) - } - sprintf(paste0("%s a safetensors newer than the one on CRAN: %s is ", - "merged upstream but not yet released. Install the ", - "development version from the mlverse/safetensors ", - "repository on GitHub, or press on with nf4: same ", - "weights, slightly lower precision, and it just works."), + sprintf(paste0("%s a newer safetensors than the one installed: %s ", + "reached CRAN in safetensors 0.3.0. Run ", + "install.packages(\"safetensors\") to update, or press ", + "on with nf4: same weights, slightly lower precision, ", + "and it just works."), lead, detail) } # When a user explicitly asks for fp8/bf16 but the needed safetensors -# capability is missing, print the fork suggestion and fall back to nf4 +# capability is missing, print the upgrade suggestion and fall back to nf4 # instead of letting a downstream builder or loader fail. nf4, fp16, # fp32 and anything unrecognized pass through untouched. `mode` selects # the capability that matters: "write" when about to BUILD an artifact, @@ -144,7 +173,7 @@ NULL return(precision) } if (verbose) { - message(.st_fork_note(precision, fit = FALSE), + message(.st_update_note(precision, fit = FALSE, mode = mode), "\nFalling back to nf4 for now.") } "nf4" @@ -154,23 +183,23 @@ NULL # .st_read_or_breadcrumb so it can be unit-tested without a real 2 GB # file. .st_overflow_message <- function(file_path, size_bytes, underlying) { - sprintf(paste0("Could not read %s (%.1f GB). Stock CRAN safetensors ", - "overflows a 32-bit offset on files at or above 2^31 ", - "bytes (~2.15 GB). Rebuild the artifact with smaller ", - "shards (the quantizers now default to ", - "shard_bytes = 1.9e9), or install the development ", - "version of safetensors from the mlverse/safetensors ", - "repository on GitHub, where the fix is merged. ", + sprintf(paste0("Could not read %s (%.1f GB). A safetensors without ", + "the >2 GB offset fix (mlverse/safetensors#14, which ", + "reached CRAN in 0.3.0) overflows a 32-bit offset on ", + "files at or above 2^31 bytes (~2.15 GB). Run ", + "install.packages(\"safetensors\") to update, or ", + "rebuild the artifact with smaller shards (the ", + "quantizers default to shard_bytes = 1.9e9). ", "Underlying error: %s"), basename(file_path), size_bytes / 1e9, underlying) } # Run a safetensors read; if it fails AND the backing shard is at/above # the 2^31-byte ceiling, translate the cryptic overflow into the -# fork-or-smaller-shards breadcrumb. A read that succeeds (fork, or a -# sub-2 GB shard) is untouched; a failure on a small shard rethrows -# verbatim. Reactive by design, so it never false-alarms on a machine -# that can read large files. +# update-or-smaller-shards breadcrumb. A read that succeeds (a reader +# with the overflow fix, or a sub-2 GB shard) is untouched; a failure on +# a small shard rethrows verbatim. Reactive by design, so it never +# false-alarms on a machine that can read large files. .st_read_or_breadcrumb <- function(read_fn, file_path = NULL) { tryCatch(read_fn(), error = function(e) { sz <- if (!is.null(file_path)) { diff --git a/R/unet_safetensors.R b/R/unet_safetensors.R index be8a76d..7fa18ed 100644 --- a/R/unet_safetensors.R +++ b/R/unet_safetensors.R @@ -11,8 +11,9 @@ #' parameter is filled and no key or shape is left unmatched. #' #' Reads route through the shared sharded opener, so an oversize (>2 GB) -#' single-file checkpoint on stock CRAN safetensors surfaces the -#' actionable "rebuild with smaller shards or install the fork" message +#' single-file checkpoint read by a safetensors without the overflow fix +#' (mlverse/safetensors#14, which reached CRAN in 0.3.0) surfaces the +#' actionable "update safetensors or rebuild with smaller shards" message #' rather than a raw 32-bit overflow. #' #' @name unet_safetensors diff --git a/README.md b/README.md index 7b2b4e7..4b6566b 100644 --- a/README.md +++ b/README.md @@ -224,17 +224,19 @@ txt2img("a lighthouse at dusk", model_name = "flux2") ### Precision and safetensors The quantized transformers ship as **nf4** by default (packed uint8 + -float32 blocks). nf4 loads on **stock CRAN safetensors** because the -artifacts are written in sub-2 GB shards; R's safetensors overflows a -32-bit offset on any single file at or above 2^31 bytes (~2.15 GB), so -shard size, not the dtype, is what gates readability. +float32 blocks). nf4 loads on every safetensors release because the +artifacts are written in sub-2 GB shards. Releases before 0.3.0 overflow +a 32-bit offset on any single file at or above 2^31 bytes (~2.15 GB), so +the default shard size preserves backward compatibility; 0.3.0 and newer +can also read larger shards. Higher-quality tiers behave as follows: - **bf16** (24 GB+ cards) is also CRAN-readable. -- **fp8** (the 12-16 GB sweet spot) needs the - [`cornball-ai/safetensors`](https://github.com/cornball-ai/safetensors) - fork until float8 support lands on CRAN (mlverse/safetensors#13). +- **fp8** (the 12-16 GB sweet spot) needs float8 support + (mlverse/safetensors#13), which reached CRAN in safetensors 0.3.0. A + safetensors without it still reads nf4, so a stale install costs you a + tier rather than the model. `recommend(model)` picks the right tier for your VRAM and the safetensors you have installed, and asking for a tier your safetensors @@ -242,7 +244,7 @@ cannot read falls back to nf4 with a note rather than erroring: ```r recommend("flux2") # e.g. list(precision = "fp8", ...) on a 16 GB card -recommend("flux1")$note # the fork suggestion, when fp8/bf16 would fit but can't load +recommend("flux1")$note # the upgrade suggestion when fp8/bf16 would fit but cannot load ``` ## Supported Models diff --git a/inst/tinytest/test_recommend.R b/inst/tinytest/test_recommend.R index 41c2881..1aa5eba 100644 --- a/inst/tinytest/test_recommend.R +++ b/inst/tinytest/test_recommend.R @@ -137,25 +137,75 @@ expect_equal(rr, "nf4") options(diffuseR.st_caps = list(float8_e4m3fn = TRUE)) expect_equal(grc("fp8", "write"), "fp8") # present -> passes through options(diffuseR.st_caps = NULL) -# bf16 gate goes through the READ probe in read mode +# bf16 gate goes through the READ probe in read mode. The marker is the +# read wording, not "safetensors#11": this assertion used to match the +# write fix and so held the wrong diagnosis in place. options(diffuseR.st_read_caps = list(bfloat16 = FALSE)) -expect_message(rr2 <- grc("bf16", "read"), pattern = "safetensors#11") +expect_message(rr2 <- grc("bf16", "read"), pattern = "read bfloat16") expect_equal(rr2, "nf4") options(diffuseR.st_read_caps = NULL) -# --- fork note fit parameter ------------------------------------------------------ +# --- upgrade note fit parameter --------------------------------------------------- -fn <- diffuseR:::.st_fork_note +fn <- diffuseR:::.st_update_note expect_true(grepl("best fit for your card", fn("fp8", fit = TRUE))) expect_false(grepl("best fit for your card", fn("fp8", fit = FALSE))) expect_false(grepl("—", fn("fp8"))) # no em dash, either variant +# The REMEDY, not just the phrasing. Nothing here asserted what the +# message tells a user to DO, which is how it went on recommending a +# GitHub development build for three days after safetensors 0.3.0 shipped +# the fix to CRAN. A message can be well-formed and still wrong. +for (v in list(fn("fp8"), fn("bf16", fit = FALSE))) { + expect_true(grepl('install.packages("safetensors")', v, fixed = TRUE)) + # Regression guard: if this ever points back at a development build, + # the advice has gone stale again. + expect_false(grepl("development version", v, fixed = TRUE)) + expect_false(grepl("GitHub", v, fixed = TRUE)) +} + +# --- the note must diagnose the capability that actually failed ------------------- + +# Asserting the remedy is not enough: a message can name the right fix to +# install and still blame the wrong missing feature. bfloat16 READ worked +# on CRAN 0.2.1, so mlverse/safetensors#11 (the WRITE fix) cannot be what +# a failed read gate is waiting on. recommend() gates on .st_can_read and +# was citing #11 anyway, which is a correct-looking sentence pointing at +# an unrelated issue. +expect_true(grepl("safetensors#11", fn("bf16", mode = "write"), fixed = TRUE)) +expect_false(grepl("safetensors#11", fn("bf16", mode = "read"), fixed = TRUE)) +expect_true(grepl("read bfloat16", fn("bf16", mode = "read"), fixed = TRUE)) + +# float8 is unaffected: 0.2.1 had neither read nor write, so #13 is the +# right reference from both sides. +expect_true(grepl("safetensors#13", fn("fp8", mode = "read"), fixed = TRUE)) +expect_true(grepl("safetensors#13", fn("fp8", mode = "write"), fixed = TRUE)) + +# Both modes still carry the remedy and the house style. +for (v in list(fn("bf16", mode = "read"), fn("bf16", mode = "write"))) { + expect_true(grepl('install.packages("safetensors")', v, fixed = TRUE)) + expect_false(grepl("GitHub", v, fixed = TRUE)) + expect_false(grepl("—", v)) +} + +# End to end: the recommender's read-gated bf16 note must not send the +# user to the write fix. +nob_note <- recommend("flux1", 24, + list(bfloat16 = FALSE, float8_e4m3fn = FALSE))$note +expect_false(grepl("safetensors#11", nob_note, fixed = TRUE)) +expect_true(grepl("read bfloat16", nob_note, fixed = TRUE)) + # --- multi-GB read breadcrumb ----------------------------------------------------- msg <- diffuseR:::.st_overflow_message("shard-00001.safetensors", 3.4e9, "boom") expect_true(grepl("3.4 GB", msg)) expect_true(grepl("2\\^31", msg)) expect_true(grepl("shard-00001", msg)) +# Same guard as above: the overflow breadcrumb carried the same stale +# GitHub advice, and nothing caught it. +expect_true(grepl('install.packages("safetensors")', msg, fixed = TRUE)) +expect_false(grepl("development version", msg, fixed = TRUE)) +expect_false(grepl("GitHub", msg, fixed = TRUE)) brc <- diffuseR:::.st_read_or_breadcrumb # a read that succeeds is returned untouched diff --git a/inst/tinytest/test_st_caps.R b/inst/tinytest/test_st_caps.R index ea3f7f9..987fab5 100644 --- a/inst/tinytest/test_st_caps.R +++ b/inst/tinytest/test_st_caps.R @@ -1,6 +1,6 @@ # safetensors capability gating: dtype write probes, precision "auto" -# resolution, and the quantizer's resident-dtype fallback for CRAN -# safetensors (no bfloat16 write, no float8). +# resolution, and the quantizer's resident-dtype fallback for the CRAN +# safetensors 0.2.1 release (no bfloat16 write, no float8). if (!requireNamespace("torch", quietly = TRUE) || !torch::torch_is_installed()) { exit_file("torch not fully installed") @@ -15,6 +15,12 @@ can_write <- diffuseR:::.st_can_write resolve_precision <- diffuseR:::.flux_resolve_precision # --- capability probe ------------------------------------------------------------- +expect_cran_remedy <- function(msg) { + expect_true(grepl('install.packages("safetensors")', msg, fixed = TRUE)) + expect_false(grepl("development version", msg, fixed = TRUE)) + expect_false(grepl("GitHub", msg, fixed = TRUE)) + invisible(msg) +} expect_true(is.logical(can_write("bfloat16"))) expect_true(is.logical(can_write("float8_e4m3fn"))) @@ -34,7 +40,7 @@ expect_equal(resolve_precision("nf4"), "nf4") expect_equal(resolve_precision("fp8"), "fp8") # An existing artifact wins, fp8 preferred. Pin the fp8 capability so -# the assertion is hermetic: on stock CRAN safetensors the ambient probe +# the assertion is hermetic: on CRAN safetensors 0.2.1 the ambient probe # is FALSE and resolve_precision correctly refuses the fp8 artifact. options(diffuseR.st_caps = list(float8_e4m3fn = TRUE)) prefix <- file.path(tempdir(), "stcaps-test-") @@ -56,8 +62,8 @@ options(diffuseR.st_caps = list(float8_e4m3fn = TRUE)) expect_equal(resolve_precision("auto", prefix), "fp8") options(diffuseR.st_caps = NULL) -# An fp8 artifact present but unreadable (CRAN safetensors) is NOT -# selected - it would fail at read time. Only nf4 (or a build) is safe. +# An fp8 artifact present but unreadable (CRAN safetensors 0.2.1) is NOT +# selected - it would fail at read time. Only nf4 is safe there. dir.create(fp8_dir, showWarnings = FALSE) writeLines("{}", file.path(fp8_dir, "manifest.json")) options(diffuseR.st_caps = list(float8_e4m3fn = FALSE)) @@ -68,7 +74,7 @@ expect_equal(resolve_precision("auto", prefix), "fp8") options(diffuseR.st_caps = NULL) unlink(fp8_dir, recursive = TRUE) -# --- quantizer gates (tiny checkpoint, CRAN-safetensors emulation) ------------------ +# --- quantizer gates (tiny checkpoint, CRAN 0.2.1 emulation) ------------------------- ckpt_dir <- system.file("tinytest", "fixtures", "zimage_tiny_ckpt", package = "diffuseR") @@ -78,11 +84,28 @@ if (!dir.exists(ckpt_dir)) exit_file("zimage tiny checkpoint missing") options(diffuseR.st_caps = list(bfloat16 = FALSE, float8_e4m3fn = FALSE)) # fp8 quantization is refused with an actionable error -expect_error( +fp8_err <- tryCatch( flux_quantize(ckpt_dir, file.path(tempdir(), "stcaps-fp8"), format = "fp8", verbose = FALSE), - pattern = "float8" + error = function(e) conditionMessage(e) +) +expect_true(is.character(fp8_err)) +expect_true(grepl("float8", fp8_err, fixed = TRUE)) +expect_cran_remedy(fp8_err) + +# The load-path gate has its own hand-written error and must carry the +# same remedy even when this installation cannot build a real fp8 artifact. +# The fixture must carry the class: flux_load_transformer() checks +# inherits(ckpt, "ltx23_checkpoint") BEFORE the fp8 gate, so a bare list +# errors on the stopifnot and never reaches the message under test. +load_ckpt <- structure(list(format = "fp8"), class = "ltx23_checkpoint") +load_err <- tryCatch( + flux_load_transformer(load_ckpt, device = "cpu", verbose = FALSE), + error = function(e) conditionMessage(e) ) +expect_true(is.character(load_err)) +expect_true(grepl("float8", load_err, fixed = TRUE)) +expect_cran_remedy(load_err) # NF4 quantization falls back to float32 residents nf4_out <- file.path(tempdir(), "stcaps-nf4") diff --git a/man/convert_sd21_pt_to_diffusers.Rd b/man/convert_sd21_pt_to_diffusers.Rd index 49606b2..fe9faa2 100644 --- a/man/convert_sd21_pt_to_diffusers.Rd +++ b/man/convert_sd21_pt_to_diffusers.Rd @@ -41,6 +41,6 @@ provenance-clean way to build the hosted artifact: the upstream CreativeML OpenRAIL++-M (redistributable), and cornball already hosts these weights as \code{.pt}. At \code{float16} the components are all sub-2 GB single files (unet ~1.7 GB, text_encoder ~0.65 GB, vae -~0.16 GB), so they load on stock CRAN safetensors. +~0.16 GB), so they also load on safetensors older than 0.3.0. } diff --git a/man/download_prebuilt.Rd b/man/download_prebuilt.Rd index aa5e105..b0f772f 100644 --- a/man/download_prebuilt.Rd +++ b/man/download_prebuilt.Rd @@ -6,8 +6,8 @@ diffuseR hosts prebuilt NF4 artifacts for the two models whose licenses permit redistribution: FLUX.2-klein-4B and Z-Image-Turbo (both Apache-2.0, ungated). The artifacts are the exact output of -\code{\link{flux_quantize}} (sub-2 GB shards, bf16 residents), so -stock CRAN safetensors reads them. Nothing else in the catalog is +\code{\link{flux_quantize}} (sub-2 GB shards, bf16 residents), so even +safetensors older than 0.3.0 reads them. Nothing else in the catalog is hosted: FLUX.1-schnell sits behind a HuggingFace license gate and LTX-2.3's license does not permit redistribution, so both still download their sources and quantize locally. diff --git a/man/flux_quantize.Rd b/man/flux_quantize.Rd index cfa3aac..0065d9f 100644 --- a/man/flux_quantize.Rd +++ b/man/flux_quantize.Rd @@ -21,10 +21,11 @@ the per-format location under \code{tools::R_user_dir}).} \item{format}{"nf4" or "fp8".} \item{shard_bytes}{Numeric. Target shard size in bytes. The default -1.9e9 keeps every shard under the 2^31-byte (~2.15 GB) ceiling that -stock CRAN safetensors can read, so the artifact loads fork-free. -Pass a larger value (e.g. 4e9) only for local builds you will read -back with a fork-patched safetensors.} +1.9e9 keeps every shard under the 2^31-byte (~2.15 GB) offset +ceiling, so the artifact reads on a safetensors that lacks the +overflow fix (mlverse/safetensors#14, which reached CRAN in 0.3.0). +Larger values (e.g. 4e9) need that fix present, so keep them for +local artifacts and use the default for anything redistributed.} \item{force}{Logical. Re-quantize even if a valid manifest exists.} diff --git a/man/gemma3_quantize_nf4.Rd b/man/gemma3_quantize_nf4.Rd index 0195f38..934ef62 100644 --- a/man/gemma3_quantize_nf4.Rd +++ b/man/gemma3_quantize_nf4.Rd @@ -19,7 +19,7 @@ model-*.safetensors).} \code{gemma3-nf4} under \code{tools::R_user_dir}).} \item{shard_bytes}{Numeric. Target shard size in bytes; the 1.9e9 -default keeps shards readable by stock CRAN safetensors.} +default keeps shards readable by safetensors older than 0.3.0.} \item{force}{Logical. Re-quantize even if a valid manifest exists.} diff --git a/man/ltx23_quantize_fp8.Rd b/man/ltx23_quantize_fp8.Rd index 21a2ad9..20cef8a 100644 --- a/man/ltx23_quantize_fp8.Rd +++ b/man/ltx23_quantize_fp8.Rd @@ -18,9 +18,11 @@ ltx23_quantize_fp8( default) resolves under tools::R_user_dir("diffuseR", "data").} \item{shard_bytes}{Numeric. Target shard size in bytes. The default -1.9e9 keeps every shard under the 2^31-byte (~2.15 GB) ceiling that -stock CRAN safetensors can read. Pass a larger value (e.g. 4e9) only -for local builds you will read back with a fork-patched safetensors.} +1.9e9 keeps every shard under the 2^31-byte (~2.15 GB) offset +ceiling, so the artifact reads on a safetensors that lacks the +overflow fix (mlverse/safetensors#14, which reached CRAN in 0.3.0). +Larger values (e.g. 4e9) need that fix present, so keep them for +local artifacts and use the default for anything redistributed.} \item{force}{Logical. Re-quantize even if a valid manifest exists.} diff --git a/man/ltx23_quantize_nf4.Rd b/man/ltx23_quantize_nf4.Rd index 2406f22..9b1566d 100644 --- a/man/ltx23_quantize_nf4.Rd +++ b/man/ltx23_quantize_nf4.Rd @@ -18,9 +18,11 @@ ltx23_quantize_nf4( default) resolves under tools::R_user_dir("diffuseR", "data").} \item{shard_bytes}{Numeric. Target shard size in bytes. The default -1.9e9 keeps every shard under the 2^31-byte (~2.15 GB) ceiling that -stock CRAN safetensors can read. Pass a larger value (e.g. 4e9) only -for local builds you will read back with a fork-patched safetensors.} +1.9e9 keeps every shard under the 2^31-byte (~2.15 GB) offset +ceiling, so the artifact reads on a safetensors that lacks the +overflow fix (mlverse/safetensors#14, which reached CRAN in 0.3.0). +Larger values (e.g. 4e9) need that fix present, so keep them for +local artifacts and use the default for anything redistributed.} \item{force}{Logical. Re-quantize even if a valid manifest exists.} diff --git a/man/recommend.Rd b/man/recommend.Rd index b00f9b1..95d08b4 100644 --- a/man/recommend.Rd +++ b/man/recommend.Rd @@ -30,8 +30,12 @@ A list with \code{model}, \code{precision}, \code{devices} logical), \code{max_pixels}, \code{text_device}, \code{attn_chunk}, \code{vram_gb}, \code{pin} (page-lock the phase-swapped host copies), \code{pinned_set_gb} (estimated pinned bytes), - \code{host_ram_gb}, \code{fork_suggested} (logical), and - \code{note} (the fork suggestion string, or NULL). + \code{host_ram_gb}, \code{fork_suggested} (logical: the installed + safetensors cannot read the tier the card could otherwise run), and + \code{note} (the suggestion string, or NULL). The field name is + historical -- the remedy used to be the cornball-ai fork and is now + \code{install.packages("safetensors")}, since the fixes reached CRAN + in 0.3.0 -- and is kept because it is part of the returned contract. } \description{ One VRAM-and-capability-aware recommendation for every diffuseR @@ -48,7 +52,7 @@ floor is fp16 with placement varying by VRAM. bf16) AND the installed safetensors can \emph{read} that dtype (\code{\link{.st_can_read}}), that tier is recommended instead. \item When the card has room but safetensors cannot read the tier, -nf4 is recommended and the fork suggestion is surfaced in +nf4 is recommended and the upgrade suggestion is surfaced in \code{note} (never an error). } @@ -81,7 +85,7 @@ r <- recommend("flux1", vram_gb = 16, st_caps = list(bfloat16 = TRUE, float8_e4m3fn = FALSE)) r$precision # "nf4": fp8 fits the card, but cannot be read r$fork_suggested # TRUE -cat(r$note) # the fork-or-nf4 message +cat(r$note) # the update-or-nf4 message # Same card, once safetensors can read float8 recommend("flux1", vram_gb = 16, diff --git a/man/reshard_safetensors.Rd b/man/reshard_safetensors.Rd index ef470d1..c758fd3 100644 --- a/man/reshard_safetensors.Rd +++ b/man/reshard_safetensors.Rd @@ -31,10 +31,17 @@ Invisibly, the path to the written index.json. \description{ Splits a single \code{.safetensors} file into diffusers-style shards (\code{-00001-of-000NN.safetensors} plus a -\code{.safetensors.index.json} weight map) so it loads on stock -CRAN safetensors, which overflows a 32-bit offset on any file at or -above 2^31 bytes. Reading the oversize source requires a -fork-patched safetensors (a build-machine step); the shards it writes -are fork-free to read. Used to host large fp16 diffusers weights +\code{.safetensors.index.json} weight map) so it loads on a +safetensors without the overflow fix, which trips a 32-bit offset on +any file at or above 2^31 bytes. Used to host large fp16 diffusers weights (e.g. the 5 GB SDXL UNet) unchanged, without quantization. } +\details{ +safetensors 0.3.0 fixed the overflow (mlverse/safetensors#14), so +reading an oversize source no longer needs a patched build and this is +no longer required to produce a readable artifact. It stays useful for +two reasons: the shards it writes load on every safetensors including +the older ones, which is what makes a hosted artifact safe to publish, +and sharded weights stream rather than materialising one multi-GB file. + +} diff --git a/man/st_caps.Rd b/man/st_caps.Rd index d79ec34..b559141 100644 --- a/man/st_caps.Rd +++ b/man/st_caps.Rd @@ -1,17 +1,23 @@ % tinyrox says don't edit this manually, but it can't stop you! \name{st_caps} \alias{st_caps} -\title{safetensors read-capability probes and fork messaging} +\title{safetensors read-capability probes and upgrade messaging} \description{ -The CRAN build of safetensors 0.2.1 reads bfloat16 but cannot write -it, and has no float8 support at all. Both fixes merged upstream on -2026-07-31 (mlverse/safetensors#11 for bfloat16 write, #13 for -float8) without a version bump, so the installed version number -cannot tell you which build you have. That is why every gate here is -a runtime probe: write a tiny tensor, read it back, cache the answer. -Two capabilities matter and they differ: +The CRAN safetensors 0.2.1 release reads bfloat16 but cannot write it, +and has no float8 support at all. Both fixes (mlverse/safetensors#11 +for bfloat16 write, #13 for float8) merged upstream on 2026-07-31 and +reached CRAN in safetensors 0.3.0 on 2026-08-21, along with #14 for the +>2 GB offset overflow and #10 for empty tensor names. } \details{ +The gates here stayed runtime probes rather than a version floor, and +that decision is what let the fork requirement retire itself the day +the release landed: nothing had to change for a 0.3.0 user to get the +higher tiers. It also still covers the case a version test cannot -- +the fixes existed for three weeks in builds that reported 0.2.1, so +the version number never distinguished them. Probe, do not pin. +Two capabilities matter and they differ: + \itemize{ \item \emph{write} (\code{\link{flux_quantize}}'s internal \code{.st_can_write}, in quantize_flux.R): needed to BUILD a @@ -19,12 +25,13 @@ quantized artifact in that dtype. \item \emph{read} (\code{.st_can_read}, here): needed to LOAD a hosted artifact in that dtype. This is the capability that gates user-facing recommendations. It is strictly weaker than write: -CRAN safetensors reads bfloat16 it cannot write, so the write -probe is the wrong signal for whether a hosted bf16 artifact will -load. +the CRAN 0.2.1 release reads bfloat16 it cannot write, so the +write probe is the wrong signal for whether a hosted bf16 artifact +will load. } -Both are capability-probed, never version-pinned, so the fork -requirement self-heals the day the fixes reach CRAN. +Both are capability-probed, never version-pinned. That is why the fork +requirement healed itself when 0.3.0 shipped, and why it will do the +same for whatever lands next. } diff --git a/man/unet_safetensors.Rd b/man/unet_safetensors.Rd index 649ec21..ad07a1d 100644 --- a/man/unet_safetensors.Rd +++ b/man/unet_safetensors.Rd @@ -15,8 +15,9 @@ parameter is filled and no key or shape is left unmatched. } \details{ Reads route through the shared sharded opener, so an oversize (>2 GB) -single-file checkpoint on stock CRAN safetensors surfaces the -actionable "rebuild with smaller shards or install the fork" message +single-file checkpoint read by a safetensors without the overflow fix +(mlverse/safetensors#14, which reached CRAN in 0.3.0) surfaces the +actionable "update safetensors or rebuild with smaller shards" message rather than a raw 32-bit overflow. } diff --git a/vignettes/performance-levers.md b/vignettes/performance-levers.md index f9b1c8b..a9eac35 100644 --- a/vignettes/performance-levers.md +++ b/vignettes/performance-levers.md @@ -122,7 +122,7 @@ r <- recommend("ltx") # or "flux1", "flux2", "zimage", "sdxl", "sd21" r$precision # tier the card + safetensors support r$devices # per-component placement r$pin # page-lock the phase-swapped host copies? -r$note # fork suggestion when fp8 wanted but unreadable +r$note # upgrade suggestion when fp8 wanted but unreadable ``` Treat the result as the machine's advice: pass its fields to the