forked from 1b-agent/opencadstudio-upstream
fix: one VRAM-aware buffer budget for every GPU upload path
max_buffer_size is an API validation limit, not physical VRAM. NVIDIA reports several GB of it on a 2 GB card, so sizing a chunk from it produces buffers larger than the whole device. That is the same bug as #203 (mesh_gpu), #358 (face3d_gpu) and the block-wire vertex buffer: rediscovered three times because each upload path spelled the budget out for itself. mesh_gpu.rs was the only site that clamped the result. Move that clamp into src/scene/pipeline/gpu_budget.rs and route every allocating path through it, so a new upload path cannot reintroduce the mistake by copying one of the unclamped expressions. Five sites had no clamp at all: text_gpu (block-text instances), wire_gpu (block-wire instances), and face3d_gpu (vertices, instances, and upload_chunks). Three behaviour changes worth noting: * Silhouette chunks were capped at a private 64 MiB constant and now use the shared 32 MiB budget. * The storage-hatch chunk limit was the minimum of two device limits, both of them validation figures; it is now also capped by the budget. The `*_fits` predicates next to it are genuine "would wgpu reject this" checks and are left alone. * mesh_gpu's instanced path used the unclamped ceiling to decide whether a mesh fits in a single buffer, which would still admit a multi-GB allocation. It now uses the clamped budget, so a few more meshes take the chunked path. OCS_GPU_CHUNK_MIB overrides the cap. Raising it above the real VRAM is the cheapest way to force an allocation failure and check that the renderer degrades instead of aborting. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
1 parent
8552450aa8
commit
771a5b2c5c
7 files changed
+84
-35
No files matched your search
@@ -376,11 +376,9 @@ fn upload_block_chunks(
|
||||
if vertices.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let budget = (device.limits().max_buffer_size as usize / 10) * 9;
|
||||
let max_vertices = ((budget / std::mem::size_of::<Face3DVertex>()).max(3) / 3) * 3;
|
||||
let max_instances = ((device.limits().max_buffer_size as usize / 10) * 9
|
||||
/ std::mem::size_of::<Face3DInstance>())
|
||||
.max(1);
|
||||
// Triangle lists: chunks must stay a multiple of 3.
|
||||
let max_vertices = super::gpu_budget::max_elements_grouped::<Face3DVertex>(device, 3);
|
||||
let max_instances = super::gpu_budget::max_elements::<Face3DInstance>(device);
|
||||
let instances: Vec<Face3DInstance> = group
|
||||
.iter()
|
||||
.filter_map(|wire| {
|
||||
@@ -459,10 +457,8 @@ fn upload_chunks(
|
||||
verts: &[Face3DVertex],
|
||||
label: &'static str,
|
||||
) -> Vec<Face3DChunk> {
|
||||
let budget = (device.limits().max_buffer_size as usize / 10) * 9; // 10% headroom
|
||||
let vsize = std::mem::size_of::<Face3DVertex>();
|
||||
// Round down to a multiple of 3 so triangles never straddle chunks.
|
||||
let max_verts = ((budget / vsize).max(3) / 3) * 3;
|
||||
let max_verts = super::gpu_budget::max_elements_grouped::<Face3DVertex>(device, 3);
|
||||
verts
|
||||
.chunks(max_verts)
|
||||
.map(|c| Face3DChunk {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
//! One VRAM-aware budget for every GPU buffer this renderer allocates.
|
||||
//!
|
||||
//! `wgpu::Limits::max_buffer_size` is an **API validation limit**, not a
|
||||
//! statement about physical memory: NVIDIA reports several GB of it on a 2 GB
|
||||
//! card. Sizing a chunk from that limit therefore produces buffers larger than
|
||||
//! the whole device, the allocation fails, and the frame dies — issue #203
|
||||
//! (`mesh_gpu`), #358 (`face3d_gpu`) and the block-wire path were all the same
|
||||
//! bug rediscovered.
|
||||
//!
|
||||
//! `mesh_gpu.rs` was the only site that clamped the result. This module makes
|
||||
//! that clamp the single shared rule so a new upload path cannot reintroduce
|
||||
//! the mistake by copying one of the unclamped expressions.
|
||||
|
||||
use iced::wgpu;
|
||||
|
||||
/// Upper bound on any single buffer, regardless of what the device claims it
|
||||
/// would validate. Chosen to match the clamp `mesh_gpu` has used since #203:
|
||||
/// large enough that chunk overhead stays negligible, small enough that a
|
||||
/// failed allocation is recoverable on a low-VRAM card.
|
||||
const HARD_CAP_BYTES: usize = 32 * 1024 * 1024;
|
||||
|
||||
/// 10% headroom below the device's validation limit, matching the `/ 10 * 9`
|
||||
/// the individual sites used before they shared this helper.
|
||||
fn device_ceiling(device: &wgpu::Device) -> usize {
|
||||
(device.limits().max_buffer_size as usize / 10) * 9
|
||||
}
|
||||
|
||||
/// Test/debug override, in MiB. Raising it above the real VRAM is the
|
||||
/// cheapest way to force an out-of-memory path and check that the renderer
|
||||
/// degrades the frame instead of aborting.
|
||||
fn cap_bytes() -> usize {
|
||||
static CAP: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
|
||||
*CAP.get_or_init(|| {
|
||||
std::env::var("OCS_GPU_CHUNK_MIB")
|
||||
.ok()
|
||||
.and_then(|value| value.trim().parse::<usize>().ok())
|
||||
.filter(|mib| *mib > 0)
|
||||
.map(|mib| mib * 1024 * 1024)
|
||||
.unwrap_or(HARD_CAP_BYTES)
|
||||
})
|
||||
}
|
||||
|
||||
/// Largest byte size a single buffer built by this renderer may take.
|
||||
pub fn buffer_budget(device: &wgpu::Device) -> usize {
|
||||
device_ceiling(device).min(cap_bytes())
|
||||
}
|
||||
|
||||
/// How many `T` fit in one buffer. Never returns 0, so callers can pass the
|
||||
/// result straight to `slice::chunks`, which panics on a zero chunk size.
|
||||
pub fn max_elements<T>(device: &wgpu::Device) -> usize {
|
||||
(buffer_budget(device) / std::mem::size_of::<T>().max(1)).max(1)
|
||||
}
|
||||
|
||||
/// `max_elements` rounded down to a whole number of `group` elements, for
|
||||
/// vertex data whose primitives must not straddle a chunk boundary: 3 for a
|
||||
/// triangle list, 6 for the quad expansion the wire shaders use.
|
||||
///
|
||||
/// Always returns at least one whole group.
|
||||
pub fn max_elements_grouped<T>(device: &wgpu::Device, group: usize) -> usize {
|
||||
let group = group.max(1);
|
||||
(max_elements::<T>(device) / group).max(1) * group
|
||||
}
|
||||
@@ -196,9 +196,13 @@ impl StorageHatchBatch {
|
||||
}
|
||||
|
||||
let limits = device.limits();
|
||||
// Both device figures are API validation limits, not physical VRAM, so
|
||||
// the shared budget caps them (`storage.rs` chunks are allocations, not
|
||||
// just size checks — the `*_fits` predicates below are the checks).
|
||||
let chunk_limit = limits
|
||||
.max_buffer_size
|
||||
.min(limits.max_storage_buffer_binding_size as u64);
|
||||
.min(limits.max_storage_buffer_binding_size as u64)
|
||||
.min(crate::scene::pipeline::gpu_budget::buffer_budget(device) as u64);
|
||||
let mut ranges = Vec::new();
|
||||
let mut start = 0;
|
||||
let mut used = [0u64; 7];
|
||||
|
||||
@@ -1580,10 +1580,8 @@ pub fn build_mesh_batch_filtered(
|
||||
// Derive the caps from the real device limit and vertex size. The previous
|
||||
// fixed 6 M-vertex cap assumed 40 B/vertex, but `position_low` (RTE) grew
|
||||
// MeshVertex to 52 B, so 6 M × 52 B = 312 MB blew past the 256 MB cap.
|
||||
let hard_budget = (device.limits().max_buffer_size as usize / 10) * 9;
|
||||
let budget = hard_budget.min(32 * 1024 * 1024);
|
||||
let vsize = std::mem::size_of::<MeshVertex>();
|
||||
let max_verts = (budget / vsize).max(3);
|
||||
let budget = super::gpu_budget::buffer_budget(device);
|
||||
let max_verts = super::gpu_budget::max_elements::<MeshVertex>(device).max(3);
|
||||
// A triangle contributes three line segments (six standalone edge
|
||||
// vertices). Bound chunks by the largest buffer produced for wire meshes.
|
||||
let max_tris =
|
||||
@@ -1800,9 +1798,9 @@ pub fn build_mesh_batch_filtered(
|
||||
.verts
|
||||
.len()
|
||||
.saturating_mul(std::mem::size_of::<MeshVertex>())
|
||||
<= hard_budget
|
||||
<= budget
|
||||
&& part.indices.len().saturating_mul(std::mem::size_of::<u32>())
|
||||
<= hard_budget
|
||||
<= budget
|
||||
&& part
|
||||
.set
|
||||
.instance_source
|
||||
@@ -1812,7 +1810,7 @@ pub fn build_mesh_batch_filtered(
|
||||
.edge_verts
|
||||
.len()
|
||||
.saturating_mul(std::mem::size_of::<MeshEdgeVertex>())
|
||||
<= hard_budget;
|
||||
<= budget;
|
||||
let triangle_edges_fit = !part.include_edges
|
||||
|| !source.edge_verts.is_empty()
|
||||
|| part
|
||||
@@ -1820,7 +1818,7 @@ pub fn build_mesh_batch_filtered(
|
||||
.len()
|
||||
.saturating_mul(2)
|
||||
.saturating_mul(std::mem::size_of::<MeshEdgeVertex>())
|
||||
<= hard_budget;
|
||||
<= budget;
|
||||
feature_edges_fit && triangle_edges_fit
|
||||
})
|
||||
&& part
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
mod device_capabilities;
|
||||
pub mod face3d_gpu;
|
||||
pub mod gpu_budget;
|
||||
pub mod hatch_gpu;
|
||||
pub mod wipeout_gpu;
|
||||
pub mod image_gpu;
|
||||
@@ -2728,11 +2729,7 @@ impl Pipeline {
|
||||
.normalize_or(glam::DVec3::NEG_Z);
|
||||
use crate::scene::pipeline::mesh_gpu::{SilhouetteInstance, SilhouetteVertex};
|
||||
use wgpu::util::DeviceExt;
|
||||
const SILHOUETTE_CHUNK_BYTES: u64 = 64 * 1024 * 1024;
|
||||
let chunk_bytes = device
|
||||
.limits()
|
||||
.max_buffer_size
|
||||
.min(SILHOUETTE_CHUNK_BYTES) as usize;
|
||||
let chunk_bytes = gpu_budget::buffer_budget(device);
|
||||
let max_vertices = chunk_bytes / std::mem::size_of::<SilhouetteVertex>() / 2 * 2;
|
||||
if max_vertices < 2 {
|
||||
self.silhouette_chunks.clear();
|
||||
|
||||
@@ -541,9 +541,8 @@ pub fn upload_block_vertex_refs(
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let max_instances = ((device.limits().max_buffer_size as usize / 10) * 9
|
||||
/ std::mem::size_of::<BlockTextInstance>())
|
||||
.max(1);
|
||||
let max_instances =
|
||||
super::gpu_budget::max_elements::<BlockTextInstance>(device);
|
||||
for chunk in instances.chunks(max_instances) {
|
||||
let instance_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("block_text.instances"),
|
||||
|
||||
@@ -745,15 +745,10 @@ impl BlockWireGpu {
|
||||
// `mesh_gpu.rs` (#203) and the fill chunking in `face3d_gpu.rs`
|
||||
// (#358); the block-wire path was never converted, so a
|
||||
// block-heavy drawing produced one multi-hundred-MB buffer.
|
||||
// `max_buffer_size` is an API validation limit, not physical VRAM
|
||||
// (NVIDIA reports several GB on a 2 GB card), so clamp it the way
|
||||
// `mesh_gpu.rs` already does.
|
||||
let vertex_budget = ((device.limits().max_buffer_size as usize / 10) * 9)
|
||||
.min(32 * 1024 * 1024);
|
||||
let vsize = std::mem::size_of::<BlockWireVertex>();
|
||||
// Round down to a multiple of 6: each segment expands to six
|
||||
// vertices and must not straddle two chunks.
|
||||
let max_verts = ((vertex_budget / vsize).max(6) / 6) * 6;
|
||||
let max_verts =
|
||||
super::gpu_budget::max_elements_grouped::<BlockWireVertex>(device, 6);
|
||||
let vertex_chunks: Vec<(wgpu::Buffer, u32)> = vertices
|
||||
.chunks(max_verts)
|
||||
.map(|chunk| {
|
||||
@@ -810,9 +805,7 @@ impl BlockWireGpu {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let max_instances = ((device.limits().max_buffer_size as usize / 10) * 9
|
||||
/ std::mem::size_of::<BlockWireInstance>())
|
||||
.max(1);
|
||||
let max_instances = super::gpu_budget::max_elements::<BlockWireInstance>(device);
|
||||
// Build each instance buffer once and reuse it across vertex
|
||||
// chunks, rather than re-uploading it per chunk.
|
||||
let instance_chunks: Vec<(wgpu::Buffer, u32)> = instances
|
||||
|
||||
Reference in new issue
Block a user