Browse / github.com/storytold/vectorcraft / patch
Undo: the history keeps steps within a quarter of installed memory on every desktop OS
Undo: the history keeps steps within a quarter of installed memory on every desktop OS Builds on #4239. ## What and why Each document's undo history drops its oldest steps once they keep more memory than a budget (#1244). `default_history_budget` in `crates/engine/src/lib.rs` read the installed memory only on Linux, so the budget was 1 GiB on Windows, macOS and FreeBSD, and on Linux it was an eighth of the memory, at most 1 GiB. `vectorcraft-render` reads the installed memory on every desktop OS for the decoded-image cache since #4261; this change sizes the undo history from the same reader. The reference app's performance, preferences and requirements pages do not say how mu
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- dotCooCoo (imported from pull request #4268, open)
- Base
e2a8280134a7c3c9b4e117ee668b9b3e7eb3e315- Size
- +59 −28 in 5 files
- Review
- pending
- Shared
- 2026-10-28
- Id
f9ceeb9dba078628c0767377
How it went
No other operator has reported using it yet.
Diff
raw · crates/engine/src/lib.rs crates/engine/src/tests_history_mem.rs crates/render/src/images.rs crates/render/src/lib.rs docs/target-app-parity.md
@@ -225,25 +225,21 @@ impl History { } } -/// The default [`History::byte_budget`]: an eighth of the machine's memory where it is known-/// (Linux), at most 1 GiB (the figure elsewhere); 256 MiB on the web, whose memory is small.+/// The default [`History::byte_budget`]: a quarter of the installed memory+/// ([`vectorcraft_render::physical_memory`], see [`history_budget_for`]); 1 GiB where the installed+/// memory can't be read, and 256 MiB on the web. pub fn default_history_budget() -> usize {- const MAX: usize = 1 << 30; if cfg!(target_arch = "wasm32") { return 256 << 20; } static BUDGET: std::sync::OnceLock<usize> = std::sync::OnceLock::new();- *BUDGET.get_or_init(|| physical_memory().map_or(MAX, |m| (m / 8).clamp(64 << 20, MAX)))+ *BUDGET.get_or_init(|| vectorcraft_render::physical_memory().filter(|&m| m > 0).map_or(1 << 30, history_budget_for)) } -/// The machine's memory, where cheap to learn without platform calls.-fn physical_memory() -> Option<usize> {- if !cfg!(target_os = "linux") {- return None;- }- let info = std::fs::read_to_string("/proc/meminfo").ok()?;- let kb: usize = info.lines().find_map(|l| l.strip_prefix("MemTotal:"))?.trim().trim_end_matches("kB").trim().parse().ok()?;- kb.checked_mul(1024)+/// The default [`History::byte_budget`] on a machine with `memory` bytes installed: a quarter of it,+/// at least 64 MiB, and at most a quarter of the address space (on 32-bit builds).+fn history_budget_for(memory: u64) -> usize {+ usize::try_from(memory / 4).unwrap_or(usize::MAX).clamp(64 << 20, usize::MAX / 4) } /// An in-progress drag: the snapshot the previews are applied to.@@ -75,6 +75,24 @@ fn within_the_budget_the_step_limit_still_counts() { assert!(st.history.bytes() < st.history.byte_budget); } +#[test]+#[cfg(target_pointer_width = "64")]+fn the_default_budget_is_a_quarter_of_installed_memory() {+ assert_eq!(history_budget_for(16 << 30), 4 << 30);+ assert_eq!(history_budget_for(64 << 30), 16 << 30, "no fixed ceiling");+ assert_eq!(history_budget_for(2 << 30), 512 << 20);+ assert_eq!(history_budget_for(128 << 20), 64 << 20, "at least 64 MiB");+}++#[test]+#[cfg(any(target_os = "linux", windows, target_os = "macos", target_os = "freebsd"))]+fn the_default_budget_follows_the_installed_memory_read() {+ let memory = vectorcraft_render::physical_memory();+ assert!(memory.is_some_and(|m| m >= 256 << 20), "{memory:?}");+ assert_eq!(default_history_budget(), memory.map_or(0, history_budget_for));+ assert_eq!(History::default().byte_budget, default_history_budget());+}+ #[test] fn undo_steps_keep_no_lookup_tables() { let mut s = session_with_rects(200);@@ -19,8 +19,9 @@ use vello_cpu::color::PremulRgba8; use crate::ink::Ink; -/// Largest default budget of the shared image store, in bytes; less on machines with under 8 GiB-/// of memory (an eighth of it). [`set_image_cache_budget`] changes it.+/// Default budget of the shared image store, in bytes, where the installed memory can't be read.+/// Elsewhere the default is a quarter of the installed memory, and on the web it is 256 MiB.+/// [`set_image_cache_budget`] changes it. pub const IMAGE_CACHE_MAX: usize = 1 << 30; /// The default budget on the web (a tab's memory is tight).@@ -31,19 +32,19 @@ fn default_budget() -> usize { #[cfg(not(target_arch = "wasm32"))] fn default_budget() -> usize {- physical_memory().map_or(IMAGE_CACHE_MAX, budget_for)+ physical_memory().filter(|&m| m > 0).map_or(IMAGE_CACHE_MAX, budget_for) } -/// The default budget on a machine with `memory` bytes installed: an eighth of it, at most-/// [`IMAGE_CACHE_MAX`].+/// The default budget on a machine with `memory` bytes installed: a quarter of it, and at most a+/// quarter of the address space (on 32-bit builds). #[cfg(not(target_arch = "wasm32"))] fn budget_for(memory: u64) -> usize {- usize::try_from(memory / 8).unwrap_or(usize::MAX).min(IMAGE_CACHE_MAX)+ usize::try_from(memory / 4).unwrap_or(usize::MAX).min(usize::MAX / 4) } /// Installed memory in bytes (`MemTotal` in `/proc/meminfo`). #[cfg(target_os = "linux")]-fn physical_memory() -> Option<u64> {+pub fn physical_memory() -> Option<u64> { let info = std::fs::read_to_string("/proc/meminfo").ok()?; let line = info.lines().find(|l| l.starts_with("MemTotal:"))?; let kib: u64 = line.split_whitespace().nth(1)?.parse().ok()?;@@ -52,14 +53,14 @@ fn physical_memory() -> Option<u64> { /// Installed memory in bytes (`GlobalMemoryStatusEx`). #[cfg(windows)]-fn physical_memory() -> Option<u64> {+pub fn physical_memory() -> Option<u64> { winsafe::GlobalMemoryStatusEx().ok().map(|m| m.ullTotalPhys) } /// Installed memory in bytes, as `sysctl` prints it (`hw.memsize` on macOS, `hw.physmem` on /// FreeBSD). #[cfg(any(target_os = "macos", target_os = "freebsd"))]-fn physical_memory() -> Option<u64> {+pub fn physical_memory() -> Option<u64> { let (sysctl, name) = if cfg!(target_os = "macos") { ("/usr/sbin/sysctl", "hw.memsize") } else { ("/sbin/sysctl", "hw.physmem") }; let out = std::process::Command::new(sysctl).args(["-n", name]).output().ok().filter(|o| o.status.success())?; sysctl_bytes(&out.stdout)@@ -71,8 +72,9 @@ fn sysctl_bytes(out: &[u8]) -> Option<u64> { std::str::from_utf8(out).ok()?.trim().parse().ok() } -#[cfg(not(any(target_os = "linux", windows, target_os = "macos", target_os = "freebsd", target_arch = "wasm32")))]-fn physical_memory() -> Option<u64> {+/// Installed memory in bytes: not read on other targets, the web among them.+#[cfg(not(any(target_os = "linux", windows, target_os = "macos", target_os = "freebsd")))]+pub fn physical_memory() -> Option<u64> { None } @@ -359,11 +361,11 @@ mod tests { } #[test]- #[cfg(not(target_arch = "wasm32"))]- fn default_budget_is_an_eighth_of_installed_memory_at_most_1_gib() {- assert_eq!(budget_for(4 << 30), 512 << 20);- assert_eq!(budget_for(8 << 30), IMAGE_CACHE_MAX);- assert_eq!(budget_for(64 << 30), IMAGE_CACHE_MAX);+ #[cfg(target_pointer_width = "64")]+ fn default_budget_is_a_quarter_of_installed_memory() {+ assert_eq!(budget_for(16 << 30), 4 << 30);+ assert_eq!(budget_for(64 << 30), 16 << 30, "no fixed ceiling");+ assert_eq!(budget_for(2 << 30), 512 << 20); } #[test]@@ -372,6 +374,18 @@ mod tests { assert!(physical_memory().is_some_and(|m| m >= 256 << 20), "{:?}", physical_memory()); } + #[test]+ #[cfg(not(target_arch = "wasm32"))]+ fn two_images_of_a_tenth_of_memory_each_stay_cached() {+ // 16 MiB installed (16 GiB scaled down): two images of 1,600 KiB (640 × 640 pixels) take+ // about a fifth of it.+ let mut s = ImageStore::new(budget_for(16 << 20));+ drop(s.insert(key("a"), Arc::new(Pixmap::new(640, 640))));+ drop(s.insert(key("b"), Arc::new(Pixmap::new(640, 640))));+ assert!(s.peek(&key("a")).is_some() && s.peek(&key("b")).is_some(), "neither image is decoded again");+ assert_eq!(s.bytes(), 2 * 640 * 640 * 4);+ }+ #[test] fn sysctl_prints_the_memory_size_in_bytes() { assert_eq!(sysctl_bytes(b"17179869184\n"), Some(16 << 30));@@ -14,6 +14,7 @@ mod freeform; mod fx; mod group; mod images;+pub use images::physical_memory; mod ink; mod live; mod lru;@@ -991,6 +991,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped. Print-size images (more than 512 MB decoded, up to 2 GB) draw in the canvas and in raster exports and can be trimmed with Crop Image; an image with a side past 65,535 px is shrunk to fit, a raster export says when an image could not be decoded, and Crop Image's trim leaves CMYK images alone (#1517). On Windows `vectorcraft-cli perf` reports the cores busy and, with `--deep`, its working set, and warns on a busy machine as it does on macOS and Linux (#1502). An image whose pixels can't be decoded, such as a PNG cut short or an image over the 2 GB decoding allowance as RGBA that can't be drawn at reduced size, is tried once: the renderer notes it and does not try to decode it again on later frames. An image refused only because the images being drawn leave no room for it is tried again on the next render. On the canvas such an image draws as a light gray box crossed from corner to corner, and the status bar says once for each image that it couldn't be decoded; exports leave it out and report it as before. A PNG, JPEG, GIF or WebP image that takes more than 2 GiB as RGBA (512 MiB in the web app) draws in the canvas and in raster exports at the largest size within that allowance, instead of as that crossed box on the canvas and not at all in exports. Each pixel of the result is the mean of the image's pixels it covers, and the document keeps the whole file. A PNG that isn't interlaced is read a row at a time, and other images are read whole when their decoded samples fit in the allowance. A 16-bit PNG that isn't interlaced, whose decoded samples take more than the allowance and whose RGBA fits in it, draws at its own size, read a row at a time. An image of more pixels than the allowance has bytes (on the desktop, 46,341 × 46,341 is more) still draws as the crossed box, and so does one whose decoded samples take more than the allowance, other than a PNG that isn't interlaced. Open, Place and the Links panel say "an image of W × H pixels is shown at reduced size", and say "too large to draw" of an image that is not drawn at reduced size.+ The renderers keep the decoded images they share within a quarter of the installed memory on Windows, macOS, Linux and FreeBSD, within 1 GiB where the installed memory can't be read, and within 256 MiB in the web build; while the decoded images add up to less than that budget, they are not decoded again when the view changes.+ Each document's undo history keeps its steps within a quarter of the installed memory on the same systems (at least 64 MiB), within 1 GiB where the installed memory can't be read, and within 256 MiB in the web build; past that budget the oldest steps are dropped, but the last eight undo steps and the next eight redo steps stay. With `--deep`, each memory row of `vectorcraft-cli perf` also gives the peak so far: the peak working set on Windows and the peak resident memory (`VmHWM`) on Linux. A new last row gives the memory after the SVG export and the save → open → save at the end of the run. The renderer composes the two ink planes of a CMYK document that shows transparency into screen colors on up to as many threads as it rasterizes with, instead of on one. At 2880 × 1800 on 8 threads, a page of 300 shapes at 20% to 90% opacity takes 30 ms a frame instead of 55 ms, and 50,000 paths with one at 99% opacity take 258 ms instead of 361 ms (`vectorcraft-cli bench`), with the same pixels. With Overprint Preview or Separations Preview on, the renderer keeps the art it prepared for the preview from one frame to the next. It prepares again the objects that changed, and all of the art when Discard White Overprint changes or, with Separations Preview, when the plates shown, the proof setup, the swatches, the Spot Colors option or the color settings change or a profile is loaded. It draws the other objects from the art, geometry and stroke outlines it kept for them.