fix: free superseded wire buffers before allocating their replacement

The renderer shares one set of wire GPU buffers per wire_content_id
across every pane and viewport slot. Every geometry edit mints a new
content id, so every edit builds a new set -- and the old one stayed in
wire_buffer_cache until the map exceeded sixteen entries.

Sixteen entries is a sane bound for small drawings and fatal for large
ones. The policy counted entries, not bytes. On the reproducer a single
set is most of the card: the VRAM trace shows 572 MiB baseline rising to
1871 MiB on a 2048 MiB GT 1030. The second set has nowhere to go, so the
first edit after opening exhausts VRAM. The perf log shows exactly that
-- two `shared-fullupload` lines, an edit, then the process dies.

Evict on every miss instead of at a count threshold, and do it before
building the replacement rather than after, so the old allocation is
released while the new one is still being sized. Dropping this slot's own
handles first is what lets the retain see the superseded entry as
garbage; a pane still drawing that content keeps its own clone and its
strong count stays above one, so shared geometry is never pulled out
from under a live viewport.

The trade is that returning to a previous content id -- undo, mostly --
now rebuilds instead of hitting a warm entry. On a card where the
alternative is dying, that is the right way round.

Adds a PERF line reporting evicted and retained entry counts, so the
policy can be watched rather than assumed.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
PanosandClaude Opus 5 committed 2026-09-04 16:21:08 +03:00
1 parent d0ef752532
commit 0d11421dea
23 files changed
+3736 -12

No files matched your search

+1
View File
@@ -20,6 +20,7 @@ language =
.polish = Polski
.chinese-traditional = 繁體中文
.bulgarian = Български
.greek = Ελληνικά
action =
.close = إغلاق
+1
View File
@@ -20,6 +20,7 @@ language =
.polish = Polski
.chinese-traditional = 繁體中文
.bulgarian = Български
.greek = Ελληνικά
action =
.close = Затвори
+1
View File
@@ -20,6 +20,7 @@ language =
.polish = Polski
.chinese-traditional = 繁體中文
.bulgarian = Български
.greek = Ελληνικά
action =
.close = Zavřít
+1
View File
@@ -20,6 +20,7 @@ language =
.polish = Polski
.chinese-traditional = 繁體中文
.bulgarian = Български
.greek = Ελληνικά
action =
.close = Schließen
File diff suppressed because it is too large. Load diff
+1
View File
@@ -20,6 +20,7 @@ language =
.polish = Polski
.chinese-traditional = 繁體中文
.bulgarian = Български
.greek = Ελληνικά
action =
.close = Close
+1
View File
@@ -20,6 +20,7 @@ language =
.polish = Polski
.chinese-traditional = 繁體中文
.bulgarian = Български
.greek = Ελληνικά
action =
.close = Cerrar
+1
View File
@@ -20,6 +20,7 @@ language =
.polish = Polski
.chinese-traditional = 繁體中文
.bulgarian = Български
.greek = Ελληνικά
action =
.close = Sulje
+1
View File
@@ -20,6 +20,7 @@ language =
.polish = Polski
.chinese-traditional = 繁體中文
.bulgarian = Български
.greek = Ελληνικά
action =
.close = Fermer
+1
View File
@@ -20,6 +20,7 @@ language =
.polish = Polski
.chinese-traditional = 繁體中文
.bulgarian = Български
.greek = Ελληνικά
action =
.close = बंद करें
+1
View File
@@ -20,6 +20,7 @@ language =
.polish = Polski
.chinese-traditional = 繁體中文
.bulgarian = Български
.greek = Ελληνικά
action =
.close = Bezárás
+1
View File
@@ -20,6 +20,7 @@ language =
.polish = Polski
.chinese-traditional = 繁體中文
.bulgarian = Български
.greek = Ελληνικά
action =
.close = Chiudi
+1
View File
@@ -20,6 +20,7 @@ language =
.polish = Polski
.chinese-traditional = 繁體中文
.bulgarian = Български
.greek = Ελληνικά
action =
.close = 閉じる
+1
View File
@@ -20,6 +20,7 @@ language =
.polish = Polski
.chinese-traditional = 繁體中文
.bulgarian = Български
.greek = Ελληνικά
action =
.close = 닫기
+1
View File
@@ -20,6 +20,7 @@ language =
.polish = Polski
.chinese-traditional = 繁體中文
.bulgarian = Български
.greek = Ελληνικά
action =
.close = Sluiten
+1
View File
@@ -20,6 +20,7 @@ language =
.polish = Polski
.chinese-traditional = 繁體中文
.bulgarian = Български
.greek = Ελληνικά
action =
.close = Zamknij
+1
View File
@@ -20,6 +20,7 @@ language =
.polish = Polski
.chinese-traditional = 繁體中文
.bulgarian = Български
.greek = Ελληνικά
action =
.close = Fechar
+1
View File
@@ -20,6 +20,7 @@ language =
.polish = Polski
.chinese-traditional = 繁體中文
.bulgarian = Български
.greek = Ελληνικά
action =
.close = Замкнуть
+1
View File
@@ -20,6 +20,7 @@ language =
.polish = Polski
.chinese-traditional = 繁體中文
.bulgarian = Български
.greek = Ελληνικά
action =
.close = Kapat
+1
View File
@@ -20,6 +20,7 @@ language =
.polish = Polski
.chinese-traditional = 繁體中文
.bulgarian = Български
.greek = Ελληνικά
action =
.close = 关闭
+1
View File
@@ -20,6 +20,7 @@ language =
.polish = Polski
.chinese-traditional = 繁體中文
.bulgarian = Български
.greek = Ελληνικά
action =
.close = 關閉
+6 -1
View File
@@ -65,10 +65,12 @@ pub enum Language {
PlPl,
#[serde(rename = "zh-TW")]
ZhTw,
#[serde(rename = "el-GR")]
ElGr,
}
impl Language {
pub const ALL: [Language; 21] = [
pub const ALL: [Language; 22] = [
Language::System,
Language::EnUs,
Language::BgBg,
@@ -78,6 +80,7 @@ impl Language {
Language::FrFr,
Language::FiFi,
Language::DeDe,
Language::ElGr,
Language::HuHu,
Language::ItIt,
Language::JaJp,
@@ -115,6 +118,7 @@ impl Language {
Language::HuHu => vec!["hu-HU".parse().expect("valid locale")],
Language::PlPl => vec!["pl-PL".parse().expect("valid locale")],
Language::ZhTw => vec!["zh-TW".parse().expect("valid locale")],
Language::ElGr => vec!["el-GR".parse().expect("valid locale")],
}
}
@@ -141,6 +145,7 @@ impl Language {
Language::HuHu => crate::tr!("language", "hungarian"),
Language::PlPl => crate::tr!("language", "polish"),
Language::ZhTw => crate::tr!("language", "chinese-traditional"),
Language::ElGr => crate::tr!("language", "greek"),
}
}
}
+29 -11
View File
@@ -920,22 +920,40 @@ impl shader::Primitive for Primitive {
let built = match cached {
Some(entry) => entry,
None => {
// Free the superseded content BEFORE allocating its
// replacement. Every geometry edit mints a new content
// id, so a miss here means this slot's current buffers
// are already dead weight — and on a large drawing one
// set is most of the card (measured: 1.3 GB of a 2 GB
// GT 1030). Holding the old set while the new one is
// allocated is what exhausts VRAM after the first edit.
//
// Dropping this slot's handles first is what makes the
// retain below able to see the old entry as garbage; a
// pane still drawing that content keeps its own clone,
// so shared geometry is never pulled out from under it.
inner.gpu_wires = std::sync::Arc::new(Vec::new());
inner.gpu_block_wires = std::sync::Arc::new(Vec::new());
// Entries no slot still holds: only the cache references
// them. An entry drawn by any pane keeps a strong count
// ≥ 2, so this never drops live geometry.
let held_before = pipeline.wire_buffer_cache.len();
pipeline.wire_buffer_cache.retain(|_, (w, b, _)| {
std::sync::Arc::strong_count(w) > 1
|| std::sync::Arc::strong_count(b) > 1
});
if _perf {
crate::perf_record!(
"[perf] wire-cache evicted={} held={}",
held_before - pipeline.wire_buffer_cache.len(),
pipeline.wire_buffer_cache.len(),
);
}
let entry =
inner.build_wire_buffers(device, queue, &vp_wires[..], &draw_depths);
pipeline
.wire_buffer_cache
.insert(vp.wire_content_id, entry.clone());
// Evict entries no slot still holds (only the cache
// references them). An entry drawn by any pane keeps a
// strong count ≥ 2, so this never drops live geometry.
if pipeline.wire_buffer_cache.len() > 16 {
pipeline
.wire_buffer_cache
.retain(|_, (w, b, _)| {
std::sync::Arc::strong_count(w) > 1
|| std::sync::Arc::strong_count(b) > 1
});
}
entry
}
};