mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 02:56:17 -10:00
perf: group block entities once per save batch
This commit is contained in:
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, ®istry, &biomes);
|
||||
assert_eq!(
|
||||
encoded,
|
||||
serialize(&after, ®istry, &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), ®istry, &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(())
|
||||
}
|
||||
@@ -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)> =
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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
|
||||
|
@@ -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.
|
||||
Reference in new issue
Block a user