perf: group block entities once per save batch

This commit is contained in:
rdbtCVS committed 2026-10-15 01:07:56 +01:00
1 parent e96e1571e9
commit 34628c1471
5 files changed
+503 -34

No files matched your search

@@ -0,0 +1,260 @@
//! Synthetic village/storage save batches, using the production snapshot and
//! full chunk NBT encoder. Run with `cargo run --profile fast -p horizon-client
//! --example save_batch_bench`. Add `-- --nbt` to also time serialization.
//! Exact NBT equality is always validated. No GPU, compression or disk I/O timed.
use horizon_client::{
biome::Biomes,
chunk::{BLOCK_STRATEGY, Chunk},
containers::BlockEntity,
items::ItemStack,
save::{self, ChunkExtras},
};
use horizon_core::paletted::PalettedContainer;
use horizon_world::{
DATA_VERSION,
nbt::{self, Limits, Tag},
registry::BlockRegistry,
};
use std::{collections::HashMap, hint::black_box, sync::Arc, time::Instant};
type Entities = HashMap<(i32, i32, i32), BlockEntity>;
type Names = HashMap<(i32, i32, i32), String>;
const BLOCKS: [&str; 5] = [
"minecraft:air",
"minecraft:stone",
"minecraft:chest",
"minecraft:furnace",
"minecraft:oak_sign",
];
// The pre-change with_entities path, with the same Chunk clone and outputs.
fn baseline(chunks: &[Arc<Chunk>], entities: &Entities, names: &Names) -> Vec<Chunk> {
chunks
.iter()
.map(|chunk| {
let mut c = (**chunk).clone();
c.block_entities = entities
.iter()
.filter(|(p, _)| p.0 >> 4 == c.x && p.2 >> 4 == c.z)
.map(|(p, e)| (*p, e.clone()))
.collect();
c.block_names = names
.iter()
.filter(|(p, _)| p.0 >> 4 == c.x && p.2 >> 4 == c.z)
.map(|(p, n)| (*p, n.clone()))
.collect();
c
})
.collect()
}
fn fixture(per_chunk: usize) -> (Vec<Arc<Chunk>>, Entities, Names) {
let mut chunks = Vec::new();
let mut entities = Entities::new();
let mut names = Names::new();
let stack = ItemStack {
item: "minecraft:stone".into(),
count: 64,
damage: 0,
extra: horizon_client::item_data::decode_components(Some(&Tag::compound([(
"minecraft:custom_data",
Tag::compound([("fixture", Tag::String("save batch".into()))]),
)])))
.1,
};
let mut template = Chunk::empty(0, 0);
for s in &mut template.sections[..8] {
Arc::make_mut(s).blocks = PalettedContainer::new(BLOCK_STRATEGY, 1);
}
template.heights = Arc::new([64; 256]);
for i in 0..256 {
let x = i % 16 - 8;
let z = i / 16 - 8;
let mut c = template.clone();
c.x = x;
c.z = z;
let mut extras = ChunkExtras::default();
extras.root.insert("InhabitedTime".into(), Tag::Long(12000));
for j in 0..per_chunk {
let p = (
x * 16 + (j % 16) as i32,
64 + (j / 256) as i32,
z * 16 + ((j / 16) % 16) as i32,
);
let (block, mut e) = match j % 4 {
0 => (4, BlockEntity::for_block(BLOCKS[4]).unwrap()),
1 => (3, BlockEntity::for_block(BLOCKS[3]).unwrap()),
_ => (2, BlockEntity::Chest(vec![Some(stack.clone()); 27])),
};
if let BlockEntity::Furnace(f) = &mut e {
f.items[0] = Some(stack.clone());
f.lit_remaining = 120;
f.cooking_timer = 45;
}
if let BlockEntity::Sign(s) = &mut e {
s.front.lines[0] = "Village storage".into();
}
c.set(p.0 & 15, p.1, p.2 & 15, block);
if j % 4 == 2 {
names.insert(p, format!("Storage {x},{z}/{j}"));
}
// Exercise the existing NBT overlay and saved list order as well.
if j % 8 == 2 {
let mut raw = save::encode_block_entity(p, &e, BLOCKS[block as usize]);
if let Tag::Compound(map) = &mut raw {
map.insert(
"LootTable".into(),
Tag::String("minecraft:chests/simple_dungeon".into()),
);
}
extras.block_entity_sources.push((p, raw));
}
extras.block_entity_order.push(p);
entities.insert(p, e);
}
// An unmodelled block entity, retained from the original chunk.
extras.block_entities.push(Tag::compound([
("id", Tag::String("minecraft:end_gateway".into())),
("x", Tag::Int(x * 16)),
("y", Tag::Int(80)),
("z", Tag::Int(z * 16)),
("Age", Tag::Long(200)),
]));
c.saved = Some(Arc::new(extras));
chunks.push(Arc::new(c));
}
(chunks, entities, names)
}
fn encoding_fixture() -> horizon_client::Result<(BlockRegistry, Biomes)> {
let registry = BlockRegistry::from_nbt(&Tag::compound([
("DataVersion", Tag::Int(DATA_VERSION)),
(
"states",
Tag::List(BLOCKS.iter().map(|n| Tag::String((*n).into())).collect()),
),
(
"defaults",
Tag::compound(
BLOCKS
.iter()
.enumerate()
.map(|(i, n)| (*n, Tag::Int(i as i32))),
),
),
]))?;
let temp = std::env::temp_dir().join(format!("horizon-save-batch-{}", std::process::id()));
let result = (|| {
let textures = temp.join("assets/minecraft/textures/colormap");
let biomes = temp.join("data/minecraft/worldgen/biome");
std::fs::create_dir_all(&textures)?;
std::fs::create_dir_all(&biomes)?;
for name in ["grass", "foliage", "dry_foliage"] {
let mut encoder = png::Encoder::new(
std::fs::File::create(textures.join(format!("{name}.png")))?,
1,
1,
);
encoder.set_color(png::ColorType::Rgb);
encoder.write_header()?.write_image_data(&[0, 128, 0])?;
}
std::fs::write(biomes.join("plains.json"), b"{}")?;
Biomes::load(&temp)
})();
std::fs::remove_dir_all(&temp)?;
Ok((registry, result?))
}
fn serialize(chunks: &[Chunk], registry: &BlockRegistry, biomes: &Biomes) -> Vec<Vec<u8>> {
chunks
.iter()
.map(|c| nbt::write_named(&save::encode(c, registry, biomes), Limits::default()).unwrap())
.collect()
}
fn measure(mut f: impl FnMut(), repetitions: usize) -> f64 {
let start = Instant::now();
for _ in 0..repetitions {
f();
}
start.elapsed().as_secs_f64() * 1e6 / repetitions as f64
}
fn compare(mut before: impl FnMut(), mut after: impl FnMut()) -> (f64, f64, usize) {
for _ in 0..5 {
before();
after();
}
let estimate = measure(&mut before, 1).max(measure(&mut after, 1));
let repetitions = (10_000.0 / estimate.max(0.01)).ceil().clamp(1.0, 10_000.0) as usize;
let mut old = Vec::new();
let mut new = Vec::new();
for i in 0..21 {
if i % 2 == 0 {
old.push(measure(&mut before, repetitions));
new.push(measure(&mut after, repetitions));
} else {
new.push(measure(&mut after, repetitions));
old.push(measure(&mut before, repetitions));
}
}
old.sort_by(f64::total_cmp);
new.sort_by(f64::total_cmp);
(old[10], new[10], repetitions)
}
fn main() -> horizon_client::Result<()> {
let include_nbt = std::env::args().any(|arg| arg == "--nbt");
let (registry, biomes) = encoding_fixture()?;
println!(
"stage,fixture,chunks,world_entities,world_names,saved_entities,bytes,before_us,after_us,speedup,samples,reps"
);
for (name, density) in [("village", 12), ("storage", 48)] {
let (chunks, entities, names) = fixture(density);
for count in [0, 1, 16, 64, 256] {
let batch = &chunks[..count];
let before = baseline(batch, &entities, &names);
let after = save::chunks_with_block_entities(batch, &entities, &names);
for (a, b) in before.iter().zip(&after) {
assert_eq!(a.block_names, b.block_names);
}
let encoded = serialize(&before, &registry, &biomes);
assert_eq!(
encoded,
serialize(&after, &registry, &biomes),
"exact NBT bytes: {name}/{count}"
);
let bytes: usize = encoded.iter().map(Vec::len).sum();
for encode in [false, true] {
if encode && !include_nbt {
continue;
}
let run = |optimized| {
let snapshots = if optimized {
save::chunks_with_block_entities(
black_box(batch),
black_box(&entities),
black_box(&names),
)
} else {
baseline(black_box(batch), black_box(&entities), black_box(&names))
};
if encode {
black_box(serialize(black_box(&snapshots), &registry, &biomes));
}
black_box(snapshots);
};
let (old, new, reps) = compare(|| run(false), || run(true));
println!(
"{},{name},{count},{},{},{},{bytes},{old:.3},{new:.3},{:.3},21,{reps}",
if encode { "snapshot+nbt" } else { "snapshot" },
entities.len(),
names.len(),
count * density,
old / new
);
}
}
}
Ok(())
}
+18 -34
View File
@@ -5472,28 +5472,22 @@ impl Game<'_> {
}
Ok(())
}
/// A copy of a chunk carrying its block entities, for saving.
fn with_entities(
/// Copies of a save batch carrying its live block entities and save data.
fn with_entities<'a>(
&self,
chunk: &Arc<horizon_client::chunk::Chunk>,
) -> Arc<horizon_client::chunk::Chunk> {
let mut c = (**chunk).clone();
c.block_entities = self
.world
.block_entities
.iter()
.filter(|(p, _)| p.0 >> 4 == c.x && p.2 >> 4 == c.z)
.map(|(p, e)| (*p, e.clone()))
.collect();
c.block_names = self
.world
.block_names
.iter()
.filter(|(p, _)| p.0 >> 4 == c.x && p.2 >> 4 == c.z)
.map(|(p, n)| (*p, n.clone()))
.collect();
self.attach_save_data(&mut c);
Arc::new(c)
chunks: impl IntoIterator<Item = &'a Arc<horizon_client::chunk::Chunk>>,
) -> Vec<Arc<horizon_client::chunk::Chunk>> {
horizon_client::save::chunks_with_block_entities(
chunks,
&self.world.block_entities,
&self.world.block_names,
)
.into_iter()
.map(|mut c| {
self.attach_save_data(&mut c);
Arc::new(c)
})
.collect()
}
fn tick_mobs(&mut self) -> Result<()> {
self.tick_creatures()?;
@@ -5974,11 +5968,7 @@ impl Game<'_> {
};
// Save the departing dimension's changed chunks.
let keys: Vec<_> = self.world.modified.drain().collect();
let chunks: Vec<_> = keys
.iter()
.filter_map(|k| self.world.chunks.get(k))
.map(|c| self.with_entities(c))
.collect();
let chunks = self.with_entities(keys.iter().filter_map(|k| self.world.chunks.get(k)));
self.save
.save(&chunks, self.res.assets.registry, &self.res.biomes)?;
self.save_dimension_entities()?;
@@ -7792,11 +7782,7 @@ impl Game<'_> {
return Ok(());
}
let keys: Vec<_> = self.world.modified.drain().collect();
let chunks: Vec<_> = keys
.iter()
.filter_map(|k| self.world.chunks.get(k))
.map(|c| self.with_entities(c))
.collect();
let chunks = self.with_entities(keys.iter().filter_map(|k| self.world.chunks.get(k)));
self.save
.save(&chunks, self.res.assets.registry, &self.res.biomes)?;
self.save_entities()?;
@@ -8296,9 +8282,7 @@ impl Game<'_> {
.filter(|c| self.world.modified.remove(&(c.x, c.z)))
.cloned()
.collect();
let modified: Vec<_> = horizon_client::spans::time("with_entities", || {
dirty.iter().map(|c| self.with_entities(c)).collect()
});
let modified = horizon_client::spans::time("with_entities", || self.with_entities(&dirty));
horizon_client::spans::time("unload_entities", || self.unload_entities(&unloaded));
if !unloaded.is_empty() {
let gone: horizon_client::fast_hash::FxHashSet<(i32, i32)> =
+124
View File
@@ -47,6 +47,62 @@ use std::{
pub const VERSION_NAME: &str = "26.3";
/// Snapshot live block entities and custom names for a save batch without
/// mutating the world. Each map is visited once, retaining its iteration order
/// within each chunk (including repeated chunk coordinates in the input).
/// An empty batch skips both maps; one chunk uses the direct filter path.
pub fn chunks_with_block_entities<'a>(
chunks: impl IntoIterator<Item = &'a Arc<Chunk>>,
entities: &HashMap<(i32, i32, i32), crate::containers::BlockEntity>,
names: &HashMap<(i32, i32, i32), String>,
) -> Vec<Chunk> {
let chunks: Vec<_> = chunks.into_iter().collect();
if chunks.is_empty() {
return Vec::new();
}
if chunks.len() == 1 {
let mut c = (**chunks[0]).clone();
c.block_entities = entities
.iter()
.filter(|(p, _)| p.0 >> 4 == c.x && p.2 >> 4 == c.z)
.map(|(p, e)| (*p, e.clone()))
.collect();
c.block_names = names
.iter()
.filter(|(p, _)| p.0 >> 4 == c.x && p.2 >> 4 == c.z)
.map(|(p, n)| (*p, n.clone()))
.collect();
return vec![c];
}
// Index only requested chunks, borrowing their entries so unsaved entities
// are never cloned. Clone one chunk at a time for inventory cache locality.
let mut grouped = crate::fast_hash::FxHashMap::<_, (Vec<_>, Vec<_>)>::default();
grouped.reserve(chunks.len());
for c in &chunks {
grouped.entry((c.x, c.z)).or_default();
}
for (p, e) in entities {
if let Some((entries, _)) = grouped.get_mut(&(p.0 >> 4, p.2 >> 4)) {
entries.push((p, e));
}
}
for (p, n) in names {
if let Some((_, entries)) = grouped.get_mut(&(p.0 >> 4, p.2 >> 4)) {
entries.push((p, n));
}
}
chunks
.into_iter()
.map(|chunk| {
let mut c = (**chunk).clone();
let (entities, names) = &grouped[&(c.x, c.z)];
c.block_entities = entities.iter().map(|(p, e)| (**p, (*e).clone())).collect();
c.block_names = names.iter().map(|(p, n)| (**p, (*n).clone())).collect();
c
})
.collect()
}
fn string(s: &str) -> Tag {
Tag::String(s.into())
}
@@ -2498,6 +2554,74 @@ impl<G: ChunkGenerator> ChunkGenerator for Persistent<G> {
#[cfg(test)]
mod tests {
#[test]
fn save_batch_preserves_order_coordinates_and_ownership() {
use crate::containers::BlockEntity;
let mut entities = HashMap::new();
let mut names = HashMap::new();
for x in [i32::MIN, -17, -16, -1, 0, 15, 16, i32::MAX] {
for z in [-17, -16, -1, 0, 15, 16] {
let p = (x, 64, z);
entities.insert(p, BlockEntity::Chest(vec![None; 27]));
names.insert(p, format!("container {x},{z}"));
}
}
// Names without a modelled entity must also survive the snapshot.
names.insert((-2, 80, -3), "orphan name".into());
let mut source = Chunk::empty(-1, -1);
source
.block_entities
.push(((99, 0, 99), BlockEntity::Chest(vec![])));
source.block_names.push(((99, 0, 99), "stale".into()));
source.saved = Some(Arc::new(ChunkExtras::default()));
let source = Arc::new(source);
let batch = vec![
source.clone(),
Arc::new(Chunk::empty(1, 1)),
Arc::new(Chunk::empty(-2, 0)),
source.clone(), // Preserve input order and repeated coordinates.
Arc::new(Chunk::empty(i32::MIN >> 4, -2)),
Arc::new(Chunk::empty(i32::MAX >> 4, 1)),
Arc::new(Chunk::empty(100, 100)),
];
let encode = |entries: &[((i32, i32, i32), BlockEntity)]| {
entries
.iter()
.map(|(p, e)| encode_block_entity(*p, e, "minecraft:chest"))
.collect::<Vec<_>>()
};
for input in [&batch[..0], &batch[..1], &batch[..]] {
let actual = chunks_with_block_entities(input, &entities, &names);
assert_eq!(actual.len(), input.len());
for (c, original) in actual.iter().zip(input) {
let expected: Vec<_> = entities
.iter()
.filter(|(p, _)| (p.0 >> 4, p.2 >> 4) == (c.x, c.z))
.map(|(p, e)| (*p, e.clone()))
.collect();
let expected_names: Vec<_> = names
.iter()
.filter(|(p, _)| (p.0 >> 4, p.2 >> 4) == (c.x, c.z))
.map(|(p, n)| (*p, n.clone()))
.collect();
assert_eq!((c.x, c.z), (original.x, original.z));
assert_eq!(encode(&c.block_entities), encode(&expected));
assert_eq!(c.block_names, expected_names);
assert!(Arc::ptr_eq(&c.sections[0], &original.sections[0]));
if let Some(saved) = &original.saved {
assert!(Arc::ptr_eq(c.saved.as_ref().unwrap(), saved));
}
}
}
let snapshot = chunks_with_block_entities(&batch, &entities, &names);
entities.clear();
names.clear();
assert!(!snapshot[0].block_entities.is_empty());
assert!(!snapshot[0].block_names.is_empty());
assert_eq!(source.block_names[0].1, "stale");
assert_eq!(source.block_entities[0].0, (99, 0, 99));
}
#[test]
fn older_worlds_are_upgraded_on_opening() {
let dir = std::env::temp_dir().join(format!("horizon-dv-{}", std::process::id()));
+11
View File
@@ -0,0 +1,11 @@
stage,fixture,chunks,world_entities,world_names,saved_entities,bytes,before_us,after_us,speedup,samples,reps
snapshot,village,0,3072,768,0,0,0.010,0.008,1.311,21,10000
snapshot,village,1,3072,768,12,24213,43.732,43.813,0.998,21,208
snapshot,village,16,3072,768,192,387384,695.763,578.064,1.204,21,15
snapshot,village,64,3072,768,768,1549536,8185.510,7467.929,1.096,21,2
snapshot,village,256,3072,768,3072,6197760,38434.320,36447.195,1.055,21,1
snapshot,storage,0,12288,3072,0,0,0.010,0.008,1.314,21,10000
snapshot,storage,1,12288,3072,48,81786,172.842,170.303,1.015,21,59
snapshot,storage,16,12288,3072,768,1308480,3688.055,3242.919,1.137,21,2
snapshot,storage,64,12288,3072,3072,5233920,35799.641,33446.834,1.070,21,1
snapshot,storage,256,12288,3072,12288,20934144,155262.657,147501.719,1.053,21,1
1 stage fixture chunks world_entities world_names saved_entities bytes before_us after_us speedup samples reps
2 snapshot village 0 3072 768 0 0 0.010 0.008 1.311 21 10000
3 snapshot village 1 3072 768 12 24213 43.732 43.813 0.998 21 208
4 snapshot village 16 3072 768 192 387384 695.763 578.064 1.204 21 15
5 snapshot village 64 3072 768 768 1549536 8185.510 7467.929 1.096 21 2
6 snapshot village 256 3072 768 3072 6197760 38434.320 36447.195 1.055 21 1
7 snapshot storage 0 12288 3072 0 0 0.010 0.008 1.314 21 10000
8 snapshot storage 1 12288 3072 48 81786 172.842 170.303 1.015 21 59
9 snapshot storage 16 12288 3072 768 1308480 3688.055 3242.919 1.137 21 2
10 snapshot storage 64 12288 3072 3072 5233920 35799.641 33446.834 1.070 21 1
11 snapshot storage 256 12288 3072 12288 20934144 155262.657 147501.719 1.053 21 1
+90
View File
@@ -0,0 +1,90 @@
# Block entity save batches
`Game::with_entities` prepares each dimension-change, full-save and dirty-unload
batch through `save::chunks_with_block_entities`. For multiple chunks, it visits
the live block-entity and custom-name maps once, groups borrowed entries only for
requested coordinates, then clones their contents one chunk at a time. The
single-chunk path keeps the original filters; an empty batch visits neither map.
Chunk input order, map iteration order within each chunk, duplicate inputs and
arithmetic `>> 4` mapping for negative coordinates are preserved. World maps and
original `Arc<Chunk>` values remain untouched. Attaching tick/extras data, draining
modified flags, unload cleanup, synchronous save errors and asynchronous save
queue errors retain their existing order and handling.
## Reproduce
From this worktree, with no other benchmark running:
```sh
export CARGO_INCREMENTAL=0
export CARGO_TARGET_DIR=/home/bob/Documents/horizon/target
cargo build --profile fast -p horizon-client --example save_batch_bench
taskset -c 2 "$CARGO_TARGET_DIR/fast/examples/save_batch_bench"
cargo test --profile fast -p horizon-client --lib save::tests
cargo clippy --profile fast -p horizon-client --all-targets -- -D warnings
cargo fmt --all -- --check
```
Change the target directory and CPU affinity for other machines. The example
constructs all fixtures itself and removes its temporary biome/colormap inputs.
It compares a copy of the previous per-chunk scans against the actual production
helper on the same maps and chunks, validates exact full chunk NBT bytes (not
just entity counts), warms both paths five times, and reports medians of 21
alternating samples. Each sample repeats enough batches for approximately 10 ms
of the slower path, capped at 10,000 repetitions; large batches run once.
Inputs and outputs pass through `black_box`. Destruction of outputs is timed.
## Scope and memory
The fixtures cover a 16 by 16 loaded area spanning negative and positive chunks:
3,072 block entities / 768 names for a village and 12,288 / 3,072 for storage.
Each chunk has 12 or 48 mixed chests, furnaces and signs, with populated
inventories, custom item components, names, saved NBT overlays, saved entity
order, and an unmodelled end gateway. Batch sizes are 0, 1, 16, 64 and 256 chunks.
Terrain uses eight uniform stone sections plus sections containing the entities;
the minimal registry has five states and one biome. Prepared production assets
were not found in the relevant project/Horizon cache/data locations, so these
are synthetic workloads rather than captured saves.
`snapshot` measures the production grouping/clone helper. `snapshot+nbt` also
is optional (`--nbt`) and calls the production `save::encode` and
`nbt::write_named` paths, with no
precomputed save tables. Both exclude `Game::attach_save_data`, writer queueing,
compression, region I/O, rendering and startup. They do not establish an overall
save-latency, startup or FPS improvement. Serialization and populated inventory
clones can dominate this CPU stage.
The multi-chunk path adds an O(chunk-count) index and O(selected-entity +
selected-name) borrowed pairs (16 bytes per pair on this 64-bit machine, plus
vector spare capacity). It also temporarily stores input chunk references and
cloned `Chunk` values before the game wraps them in `Arc`. Unsaved entity/name
contents are not cloned, and the owned snapshots remain independent of unload
cleanup. No full-world cache or persistent index is added.
## Recorded results
Recorded on 2026-10-15: AMD Ryzen 5 5600X, Linux 7.2.8-zen1-2-zen,
`rustc 1.99.0 (b940084d7 2026-09-28)`, `fast` profile (optimized, LTO off,
16 codegen units), `CARGO_INCREMENTAL=0`, pinned to logical CPU 2. The retained
run followed completion of other Rust builds; a contended trial was discarded.
Full raw medians, repetitions, entity counts and NBT byte counts are in
[save-batch-entities.csv](save-batch-entities.csv).
| Fixture | Saved chunks | Before (ms) | After (ms) | Speedup |
| --- | ---: | ---: | ---: | ---: |
| village | 1 | 0.043732 | 0.043813 | 0.998x |
| village | 16 | 0.695763 | 0.578064 | 1.204x |
| village | 64 | 8.185510 | 7.467929 | 1.096x |
| village | 256 | 38.434320 | 36.447195 | 1.055x |
| storage | 1 | 0.172842 | 0.170303 | 1.015x |
| storage | 16 | 3.688055 | 3.242919 | 1.137x |
| storage | 64 | 35.799641 | 33.446834 | 1.070x |
| storage | 256 | 155.262657 | 147.501719 | 1.053x |
Multi-chunk snapshot speedups were 1.053–1.204x (5.0–16.9% less time).
Single-chunk village time changed by +0.19%, and storage by -1.47%; the original
scan strategy remains in place for both. Empty batches skip the maps, and their
nanosecond-scale ratios in the CSV should not be treated as a throughput claim.
All ten cases passed exact NBT byte equality, including names and block entities.
Optional combined serialization timings were not retained in this result set.