forked from 1b-agent/opencadstudio-upstream
perf: account for the device memory the renderer holds
Stage 0 of the roadmap asks for a memory baseline before any more
optimisation, and explicitly not from RSS: the allocator and the driver
both retain high-water memory that RSS cannot tell apart from live
resources.
`gpu-live` counts from the sizes this renderer allocated, in the
categories large enough to decide where the next fix goes:
slots total shadow targets text_atlas wire_arena block_geometry
It is partial by construction and says so, rather than presenting itself
as a total. Hatches, images, meshes and Face3D are not counted yet.
Three of the numbers needed a size that was not reachable before: the
block-geometry chunk records its bytes at build because in storage mode
the buffer belongs to the bind group, the arena exposes its allocation
rather than its occupied prefix — headroom is what a budget has to answer
for — and the text atlas reports its `R8` texel count.
Emitted only when a number moves. `prepare` runs thousands of times in a
session and these change a handful of times, so a per-frame line would
bury the log it is meant to explain.
This answers the question the previous fix left open: whether the arena's
52 MiB per slot, the 16 MiB shadow map every slot allocates whether or not
shadows are on, or the render targets dominate once a paper layout has
four or five viewports on screen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a9a2c82478
commit
d81a858040
5 files changed
+142
No files matched your search
@@ -4727,6 +4727,80 @@ fn aabb_below_pixel(
|
|||||||
(max_px - min_px).max(max_py - min_py) < threshold_px
|
(max_px - min_px).max(max_py - min_py) < threshold_px
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Device bytes a renderer is holding, counted from the sizes it allocated
|
||||||
|
/// rather than from process RSS — the allocator and the driver both keep
|
||||||
|
/// high-water memory that RSS cannot distinguish from live resources.
|
||||||
|
///
|
||||||
|
/// Partial by construction: it covers the categories large enough to decide
|
||||||
|
/// where the next fix goes, and says so rather than pretending to be a total.
|
||||||
|
#[derive(Default, Clone, Copy, PartialEq)]
|
||||||
|
pub(crate) struct GpuLiveBytes {
|
||||||
|
pub slots: usize,
|
||||||
|
pub shadow: u64,
|
||||||
|
pub render_targets: u64,
|
||||||
|
pub text_atlas: u64,
|
||||||
|
pub wire_arena: u64,
|
||||||
|
pub block_geometry: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GpuLiveBytes {
|
||||||
|
pub(crate) fn total(&self) -> u64 {
|
||||||
|
self.shadow + self.render_targets + self.text_atlas + self.wire_arena + self.block_geometry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Pipeline {
|
||||||
|
/// This slot's share. `alloc_size` is the real allocation, which is rounded
|
||||||
|
/// up from the requested size and grows only.
|
||||||
|
fn gpu_live_bytes(&self) -> GpuLiveBytes {
|
||||||
|
// 4x MSAA colour (4 B/sample) + 4x MSAA depth-stencil (4 B/sample) +
|
||||||
|
// one single-sample resolve target.
|
||||||
|
const BYTES_PER_PIXEL: u64 = (MSAA_SAMPLES as u64) * 4 + (MSAA_SAMPLES as u64) * 4 + 4;
|
||||||
|
let pixels = u64::from(self.alloc_size.width) * u64::from(self.alloc_size.height);
|
||||||
|
GpuLiveBytes {
|
||||||
|
slots: 1,
|
||||||
|
// Allocated by every slot whether or not shadows are enabled.
|
||||||
|
shadow: u64::from(SHADOW_MAP_SIZE) * u64::from(SHADOW_MAP_SIZE) * 4,
|
||||||
|
render_targets: pixels * BYTES_PER_PIXEL,
|
||||||
|
text_atlas: self
|
||||||
|
.text_atlas_gpu
|
||||||
|
.as_ref()
|
||||||
|
.map(text_gpu::TextAtlasGpu::gpu_bytes)
|
||||||
|
.unwrap_or(0),
|
||||||
|
wire_arena: self
|
||||||
|
.wire_arena
|
||||||
|
.as_ref()
|
||||||
|
.map(wire_arena::PersistentWireArena::gpu_bytes)
|
||||||
|
.unwrap_or(0)
|
||||||
|
+ self
|
||||||
|
.wire_arena_mesh
|
||||||
|
.as_ref()
|
||||||
|
.map(wire_arena::PersistentWireArena::gpu_bytes)
|
||||||
|
.unwrap_or(0),
|
||||||
|
block_geometry: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MultiPipeline {
|
||||||
|
/// Sum across slots, plus the caches held once for all of them.
|
||||||
|
pub(crate) fn gpu_live_bytes(&self) -> GpuLiveBytes {
|
||||||
|
let mut total = GpuLiveBytes {
|
||||||
|
block_geometry: wire_gpu::block_geometry_bytes(&self.block_geometry),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
for inner in &self.inners {
|
||||||
|
let slot = inner.gpu_live_bytes();
|
||||||
|
total.slots += slot.slots;
|
||||||
|
total.shadow += slot.shadow;
|
||||||
|
total.render_targets += slot.render_targets;
|
||||||
|
total.text_atlas += slot.text_atlas;
|
||||||
|
total.wire_arena += slot.wire_arena;
|
||||||
|
}
|
||||||
|
total
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Bind one block-wire batch and draw it.
|
/// Bind one block-wire batch and draw it.
|
||||||
///
|
///
|
||||||
/// The two pipeline modes lay the vertex slots out differently — packed puts
|
/// The two pipeline modes lay the vertex slots out differently — packed puts
|
||||||
|
|||||||
@@ -221,6 +221,11 @@ pub struct TextAtlasGpu {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl TextAtlasGpu {
|
impl TextAtlasGpu {
|
||||||
|
/// Device bytes this atlas holds. `R8`, so one byte per texel.
|
||||||
|
pub fn gpu_bytes(&self) -> u64 {
|
||||||
|
u64::from(self._texture.width()) * u64::from(self._texture.height())
|
||||||
|
}
|
||||||
|
|
||||||
/// Bind-group layout for group 1: `{ R8 atlas texture, linear sampler }`.
|
/// Bind-group layout for group 1: `{ R8 atlas texture, linear sampler }`.
|
||||||
pub fn bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
|
pub fn bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
|
||||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||||
|
|||||||
@@ -1369,6 +1369,18 @@ impl PersistentWireArena {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Device bytes this arena holds. The instance buffer is reserved with
|
||||||
|
/// headroom, so this is the allocation, not the occupied prefix — which is
|
||||||
|
/// what a memory budget has to answer for.
|
||||||
|
pub fn gpu_bytes(&self) -> u64 {
|
||||||
|
match &self.inner {
|
||||||
|
PersistentWireArenaKind::Indexed(arena) => {
|
||||||
|
arena.inst_buf.size() + arena.const_buf.size()
|
||||||
|
}
|
||||||
|
PersistentWireArenaKind::Packed(arena) => arena.inst_buf.size(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn wire_gpus_visible(
|
pub fn wire_gpus_visible(
|
||||||
&self,
|
&self,
|
||||||
view_rot: glam::Mat4,
|
view_rot: glam::Mat4,
|
||||||
|
|||||||
@@ -703,6 +703,15 @@ pub struct BlockWireTarget<'a> {
|
|||||||
pub mode: WirePipelineMode,
|
pub mode: WirePipelineMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Device bytes held by a whole block-geometry cache.
|
||||||
|
pub fn block_geometry_bytes(cache: &BlockGeometryCache) -> u64 {
|
||||||
|
cache
|
||||||
|
.values()
|
||||||
|
.flat_map(|chunks| chunks.iter())
|
||||||
|
.map(|chunk| chunk.gpu_bytes)
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
/// `(source_id, mesh_edge, colour bits, base translation bits)`.
|
/// `(source_id, mesh_edge, colour bits, base translation bits)`.
|
||||||
pub type BlockGeometryKey = (u64, bool, [u32; 4], [u64; 3]);
|
pub type BlockGeometryKey = (u64, bool, [u32; 4], [u64; 3]);
|
||||||
|
|
||||||
@@ -722,6 +731,9 @@ pub struct BlockGeometryChunk {
|
|||||||
pub vertex_buffer: Option<wgpu::Buffer>,
|
pub vertex_buffer: Option<wgpu::Buffer>,
|
||||||
pub vertex_count: u32,
|
pub vertex_count: u32,
|
||||||
pub bind_group: std::sync::Arc<wgpu::BindGroup>,
|
pub bind_group: std::sync::Arc<wgpu::BindGroup>,
|
||||||
|
/// Device bytes this chunk holds. Recorded at build because in storage mode
|
||||||
|
/// the buffer is owned by `bind_group` and cannot be measured from here.
|
||||||
|
pub gpu_bytes: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BlockWireGpu {
|
impl BlockWireGpu {
|
||||||
@@ -856,6 +868,7 @@ impl BlockWireGpu {
|
|||||||
BlockGeometryChunk {
|
BlockGeometryChunk {
|
||||||
vertex_buffer: None,
|
vertex_buffer: None,
|
||||||
vertex_count: chunk.len() as u32 * 6,
|
vertex_count: chunk.len() as u32 * 6,
|
||||||
|
gpu_bytes: std::mem::size_of_val(chunk) as u64,
|
||||||
bind_group: std::sync::Arc::new(device.create_bind_group(
|
bind_group: std::sync::Arc::new(device.create_bind_group(
|
||||||
&wgpu::BindGroupDescriptor {
|
&wgpu::BindGroupDescriptor {
|
||||||
label: Some("block_wire.const.bg"),
|
label: Some("block_wire.const.bg"),
|
||||||
@@ -897,6 +910,7 @@ impl BlockWireGpu {
|
|||||||
vertices
|
vertices
|
||||||
.chunks(max_verts)
|
.chunks(max_verts)
|
||||||
.map(|chunk| BlockGeometryChunk {
|
.map(|chunk| BlockGeometryChunk {
|
||||||
|
gpu_bytes: std::mem::size_of_val(chunk) as u64,
|
||||||
vertex_buffer: Some(super::gpu_upload::upload_buffer(
|
vertex_buffer: Some(super::gpu_upload::upload_buffer(
|
||||||
device,
|
device,
|
||||||
queue,
|
queue,
|
||||||
|
|||||||
@@ -1217,6 +1217,7 @@ impl shader::Primitive for Primitive {
|
|||||||
self.viewports.len(),
|
self.viewports.len(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
report_gpu_live(pipeline);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render(
|
fn render(
|
||||||
@@ -1568,6 +1569,42 @@ fn solar_direction(
|
|||||||
|
|
||||||
// ── Render-style helpers (impl Scene) ────────────────────────────────────
|
// ── Render-style helpers (impl Scene) ────────────────────────────────────
|
||||||
|
|
||||||
|
/// Report what the renderer is holding on the device, when it changes.
|
||||||
|
///
|
||||||
|
/// Only on change: `prepare` runs thousands of times a session and these
|
||||||
|
/// numbers move a handful of times. RSS is deliberately not used — the
|
||||||
|
/// allocator and the driver both retain high-water memory that RSS cannot tell
|
||||||
|
/// apart from live resources.
|
||||||
|
fn report_gpu_live(pipeline: &crate::scene::pipeline::MultiPipeline) {
|
||||||
|
use std::cell::Cell;
|
||||||
|
if !crate::perf::enabled() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
thread_local! {
|
||||||
|
static LAST: Cell<Option<crate::scene::pipeline::GpuLiveBytes>> = const { Cell::new(None) };
|
||||||
|
}
|
||||||
|
let now = pipeline.gpu_live_bytes();
|
||||||
|
LAST.with(|last| {
|
||||||
|
if last.get() == Some(now) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
last.set(Some(now));
|
||||||
|
const MIB: f64 = 1024.0 * 1024.0;
|
||||||
|
let mib = |bytes: u64| bytes as f64 / MIB;
|
||||||
|
crate::perf_record!(
|
||||||
|
"[perf] gpu-live slots={} total={:.1}MiB shadow={:.1} targets={:.1} \
|
||||||
|
text_atlas={:.1} wire_arena={:.1} block_geometry={:.1}",
|
||||||
|
now.slots,
|
||||||
|
mib(now.total()),
|
||||||
|
mib(now.shadow),
|
||||||
|
mib(now.render_targets),
|
||||||
|
mib(now.text_atlas),
|
||||||
|
mib(now.wire_arena),
|
||||||
|
mib(now.block_geometry),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
impl Scene {
|
impl Scene {
|
||||||
fn model_tile_vport(&self, index: usize) -> Option<&acadrust::tables::VPort> {
|
fn model_tile_vport(&self, index: usize) -> Option<&acadrust::tables::VPort> {
|
||||||
let rect = self.model_tiles.borrow().get(index)?.rect;
|
let rect = self.model_tiles.borrow().get(index)?.rect;
|
||||||
|
|||||||
Reference in new issue
Block a user