Compare commits

...
Author SHA1 Message Date
rdbt 90f7852079 Merge branch 'main' into mem/block-size 2026-11-13 02:51:33 +00:00
rdbt 9f42064c11 Merge pull request #15867 from rdbtCVS/fix/pet-owner-teleport-stroll
pet_owner_teleport: a wolf that came may stroll off; count 16 blocks as near
2026-11-13 02:39:12 +00:00
rdbtCVSandClaude Opus 5.5 a13a21c522 pet_owner_teleport: count a wolf within 16 blocks as come to its owner
A tame wolf that teleported to Alice keeps strolling (WaterAvoidingRandomStrollGoal,
up to 10 blocks) and only walks back from 10 blocks, so a few seconds later it can
be 6 to 12 blocks from her, in vanilla as in Horizon. The 6-block check failed on
that at random (#15483; parity-main 43332620951 bisected it to unrelated merges).
Wolves left behind stay 28 blocks or more away.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-13 02:35:07 +00:00
rdbtCVSandClaude Sonnet 5.5 300d44d52b Video memory: shared blocks of 4-16 MiB, and horizon-ctl memory lists the blocks
A loaded world at render distance 12 reserved ~540 MB of video memory for ~395 MB
allocated. Buffers larger than a block get one of their own size; the rest share
blocks whose size grew from 8 to 64 MiB as more were opened, and the few small
buffers kept all session (section tests, clouds, staging belt) held whole 32 and
64 MiB blocks. Blocks are now 4-16 MiB.

`horizon-ctl memory` gains renderer.gpu.blocks: each block's size, bytes used
and largest allocations.

Fixes #15723

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 17:36:00 +00:00
5 changed files with 129 additions and 23 deletions

No files matched your search

+93 -7
View File
@@ -8,6 +8,22 @@ use super::{
Renderer, SectionGpu, SectionKey, TerrainVertex, arena, flat, lod, texture_bytes, visbuffer,
};
/// What the backend's allocator holds (`Renderer::gpu_memory_by_label`).
pub struct GpuMemory {
pub allocated: u64,
pub reserved: u64,
/// Bytes by label of what holds them, largest first.
pub labels: Vec<(String, u64)>,
pub blocks: Vec<GpuBlock>,
}
/// One block of device memory: its size, the bytes allocated in it and its largest allocations.
pub struct GpuBlock {
pub size: u64,
pub used: u64,
pub names: Vec<String>,
}
impl Renderer {
/// Sections with GPU buffers, for soak tests watching residency.
pub fn resident_sections(&self) -> usize {
@@ -117,19 +133,45 @@ impl Renderer {
/// Device memory the backend's allocator holds, by the label of what holds
/// it, largest first, with its allocated and reserved totals (Vulkan and
/// DirectX 12; none on Metal and OpenGL) (`horizon-ctl memory`).
pub fn gpu_memory_by_label(&self) -> Option<(u64, u64, Vec<(String, u64)>)> {
pub fn gpu_memory_by_label(&self) -> Option<GpuMemory> {
let report = self.device.generate_allocator_report()?;
let mut by_label: HashMap<String, u64> = HashMap::new();
for a in report.allocations {
*by_label.entry(a.name).or_default() += a.size;
for a in &report.allocations {
*by_label.entry(a.name.clone()).or_default() += a.size;
}
let mut labels: Vec<_> = by_label.into_iter().collect();
labels.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
Some((
report.total_allocated_bytes,
report.total_reserved_bytes,
// Each block with what is used in it, to see where reserved memory sits empty.
let blocks = report
.blocks
.iter()
.map(|block| {
let inside = report.allocations.get(block.allocations.clone());
let used: u64 = inside.iter().flat_map(|a| a.iter()).map(|a| a.size).sum();
let mut names: Vec<(&str, u64)> = inside
.iter()
.flat_map(|a| a.iter())
.map(|a| (a.name.as_str(), a.size))
.collect();
names.sort_by_key(|a| std::cmp::Reverse(a.1));
let names = names
.iter()
.take(3)
.map(|(n, s)| format!("{n} {s}"))
.collect();
GpuBlock {
size: block.size,
used,
names,
}
})
.collect();
Some(GpuMemory {
allocated: report.total_allocated_bytes,
reserved: report.total_reserved_bytes,
labels,
))
blocks,
})
}
/// Heap bytes of the renderer's per-section bookkeeping (`horizon-ctl memory`):
/// resident sections, far tiles, block entities and light sources by section.
@@ -162,3 +204,47 @@ impl Renderer {
]
}
}
#[cfg(test)]
mod tests {
/// Shared blocks stay at or under 16 MiB, so a block a few long-lived buffers hold is
/// small, and a larger buffer gets a block of its own size (#15723).
#[test]
fn larger_buffers_get_their_own_block_and_shared_blocks_stay_small() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, _queue)) =
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
memory_hints: super::super::surface_setup::MEMORY_HINTS,
..Default::default()
}))
else {
return;
};
let buffer = |mb: u64| {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("census test"),
size: mb << 20,
usage: wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
};
// Six-MiB buffers: enough of them to open the largest shared block size.
let buffers: Vec<_> = (0..12).map(|_| buffer(6)).chain([buffer(40)]).collect();
let Some(report) = device.generate_allocator_report() else {
return; // Metal and OpenGL have none.
};
for block in &report.blocks {
let inside = &report.allocations[block.allocations.clone()];
assert!(
block.size <= 16 << 20 || inside.len() == 1,
"a {} MiB block shared by {} allocations",
block.size >> 20,
inside.len()
);
}
drop(buffers);
}
}
@@ -11,6 +11,16 @@ pub(super) mod ray_query;
use std::sync::Arc;
use winit::window::Window;
/// Video memory the allocator shares out in blocks of 4-16 MiB (Vulkan and DirectX 12); a
/// buffer or texture larger than a block gets a block of exactly its size, which is freed
/// with it. With blocks of 8-64 MiB (`MemoryHints::MemoryUsage`) the size grew with each
/// block opened, and the few small buffers that live all session (section tests, clouds,
/// the staging belt) each kept a 32 or 64 MiB block from being freed: some 140 of 540 MB
/// reserved were empty in a loaded world at render distance 12 (#15723).
pub(super) const MEMORY_HINTS: wgpu::MemoryHints = wgpu::MemoryHints::Manual {
suballocated_device_memory_block_size: 4 << 20..16 << 20,
};
/// Sets the window surface up on `device`: the swapchain format (`surface_format`), the
/// copies screenshots and frame generation make, and Mailbox when the surface offers it
/// (`keep_mode`, a replaced renderer's mode, first). `caps` are the surface's capabilities
@@ -167,10 +177,7 @@ pub(super) async fn open_adapter(
label: Some("Horizon device"),
required_features: features,
required_limits: limits,
// Video memory in blocks of 8-64 MiB instead of 128-256 MiB (Vulkan and DirectX 12):
// with the larger blocks a few small buffers kept whole blocks the arena's pages had
// left, and over half the video memory reserved was unused (1,344 MiB for 500).
memory_hints: wgpu::MemoryHints::MemoryUsage,
memory_hints: MEMORY_HINTS,
experimental_features: ray_query::experimental(features),
..Default::default()
};
+16 -7
View File
@@ -245,6 +245,8 @@ struct Gpu {
allocated_mb: f64,
reserved_mb: f64,
by_label_mb: Vec<(String, f64)>,
/// Blocks of device memory, largest first: size, bytes used, largest allocations.
blocks: Vec<(f64, f64, Vec<String>)>,
}
#[derive(Serialize)]
@@ -571,17 +573,24 @@ impl Game<'_> {
let [arenas, vertices] = r.section_vertex_bytes();
let cpu = r.cpu_census();
let cpu_bytes: usize = cpu.iter().map(|(_, v)| *v).sum();
let gpu = r
.gpu_memory_by_label()
.map(|(allocated, reserved, labels)| Gpu {
allocated_mb: mb(allocated),
reserved_mb: mb(reserved),
by_label_mb: labels
let gpu = r.gpu_memory_by_label().map(|m| {
let mut blocks: Vec<_> = m.blocks.iter().collect();
blocks.sort_by_key(|b| std::cmp::Reverse(b.size));
Gpu {
allocated_mb: mb(m.allocated),
reserved_mb: mb(m.reserved),
by_label_mb: m
.labels
.iter()
.take(40)
.map(|(label, bytes)| (label.to_string(), mb(*bytes)))
.collect(),
});
blocks: blocks
.iter()
.map(|b| (mb(b.size), mb(b.used), b.names.clone()))
.collect(),
}
});
let renderer = Renderer {
section_arenas_mb: mb(arenas),
section_vertices_mb: mb(vertices),
+1 -1
View File
@@ -31,7 +31,7 @@ The reply is built in `crates/horizon-client/src/game/memory.rs`. Sizes are MiB
| `far_tiles` | Far terrain tiles: `active` and `cached`, and the tiles `kept` for a return with their `kept_mb` |
| `renderer.section_arenas_mb`, `section_vertices_mb` | The GPU buffers of the section vertex arenas, and the vertices that resident sections and far tiles hold in them |
| `renderer.cpu` | The renderer's CPU-side bookkeeping: `resident_sections_mb`, `far_tiles_mb`, `block_entities_mb`, `light_sources_mb` |
| `renderer.gpu` | The GPU allocator's `allocated_mb` and `reserved_mb`, and the 40 largest resources as `["label", mb]` rows in `by_label_mb`; `null` without Vulkan or DirectX 12 |
| `renderer.gpu` | The GPU allocator's `allocated_mb` and `reserved_mb`, and the 40 largest resources as `["label", mb]` rows in `by_label_mb`, and `blocks`, each block of device memory as `[size_mb, used_mb, its largest allocations]` (reserved memory sitting empty shows as `used_mb` below `size_mb`); `null` without Vulkan or DirectX 12 |
| `named_mb`, `unattributed_mb` | `named_mb` adds up the columns (a section both the window and its server thread hold once), `server_generation`, `entities`, `players`, `chunk_send`, the two queues, `renderer.cpu` and the far tiles' `kept_mb`. `unattributed_mb` is `process.resident_mb` less that; it can be negative where a census overlaps another, and `null` where the resident set is not read |
| `heap_by_tag_mb` | Only in an `alloc-census` build, otherwise `null`: see [the heap census](#who-allocated-it-the-heap-census) |
+8 -4
View File
@@ -6,8 +6,10 @@ vanilla generated first, with Alice an operator. Three standing wolves she owns
held by a pig that never moves (NoAI), one sitting in a boat. She then teleports 40 blocks away.
`TamableAnimal.shouldTryTeleportToOwner` (12 blocks or more) sends a pet to its owner unless `unableToMoveToOwner`:
ordered to sit, `isPassenger`, `mayBeLeashed` or an owner who is a spectator. So only the free wolf comes to her.
Whether each wolf is within 6 blocks of Alice afterwards is compared (where exactly a teleported wolf lands is a random
draw, not under test).
Whether each wolf is within 16 blocks of Alice afterwards is compared. Where exactly a teleported wolf lands is a random
draw, not under test, and so is what it does next: a tame wolf still strolls (`WaterAvoidingRandomStrollGoal`, up to 10
blocks) and only follows its owner back from 10 blocks (`FollowOwnerGoal`), teleporting again from 12. A wolf that came
can be 6 to 12 blocks from her a few seconds later, in vanilla as in Horizon; a wolf that stayed behind is 28 or more.
"""
import re
import sys
@@ -35,10 +37,12 @@ SCRIPT = [
]
# Where `away` puts Alice.
AWAY = (0.5, -60.0, 40.5)
# A wolf this close came to her: past the 12 blocks a pet teleports from, short of the 28 a wolf left behind keeps.
NEAR = 16.0
def shown(lines):
"""Whether each wolf ended within 6 blocks of Alice, from its `Pos`."""
"""Whether each wolf ended within `NEAR` blocks of Alice, from its `Pos`."""
out = []
for line in lines:
parts = line.split(' ', 3)
@@ -49,7 +53,7 @@ def shown(lines):
position = [float(v) for v in re.findall(r'"text":"(-?[0-9.]+)","color":"gold"', line)]
if len(position) != 3:
continue
near = sum((a - b) ** 2 for a, b in zip(position, AWAY)) <= 36.0
near = sum((a - b) ** 2 for a, b in zip(position, AWAY)) <= NEAR * NEAR
out.append(f'{parts[0]} {parts[1]} {"near" if near else "behind"}')
return out