Portal scans: keep per-chunk section masks across ticks
The nether-portal section precheck from #661 answered "does this section's palette hold a portal?" per section, per tick. Keep the answer per resident chunk in `portal_sections`, keyed by a weak handle: an edit or a chunk replacement rebuilds it, and it is dropped when a chunk unloads and cleared when the block facts change, so a portal block always forces the sweep. The entity sweep now finds portal blocks without the per-tick map. This is a scan microbenchmark, not a whole-game claim: steady improves 2.4x to 17x over the fixtures and the run parity-checks the cached and per-tick answers. Rescanning every chunk every tick (cold, reload, edit_all) is slower because a miss reads all 24 sections of a chunk; see docs/portal-scan-benchmark.md.
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
//! The `entity_portals` section scan only: `may_hold_portal` over synthetic
|
||||
//! entity reaches, with the per-tick palette map against the resident cache.
|
||||
//! Run with prepared client resources:
|
||||
//! cargo run --profile fast -p horizon-client --example portal_scan_bench -- RESOURCES
|
||||
use horizon_client::{
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, MIN_Y, SECTIONS, Section},
|
||||
fast_hash::FxHashMap,
|
||||
portal_sections::PortalSections,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
position::BlockPos,
|
||||
};
|
||||
use horizon_world::{level::Behaviours, registry::BlockStateId};
|
||||
use std::{hint::black_box, path::Path, sync::Arc, time::Instant};
|
||||
|
||||
/// `Game::swept_portal`'s reach (`Entity.checkInsideBlocks`'s swept box).
|
||||
fn reach(from: Vec3, to: Vec3, width: f32, height: f32) -> Aabb {
|
||||
let half = f64::from(width) / 2.0;
|
||||
let bounds = Aabb::new(
|
||||
Vec3::new(to.x - half, to.y, to.z - half),
|
||||
Vec3::new(to.x + half, to.y + f64::from(height), to.z + half),
|
||||
)
|
||||
.deflate(Vec3::new(1.0e-5, 1.0e-5, 1.0e-5));
|
||||
bounds
|
||||
.union_bounds(bounds.move_by(from.subtract(to)))
|
||||
.inflate(Vec3::new(1.0, 1.0, 1.0))
|
||||
}
|
||||
|
||||
fn sections(reach: Aabb) -> (i64, i32, i32, i32, i32, i32, i32) {
|
||||
let (min, max) = (reach.min_position(), reach.max_position());
|
||||
let (min, max) = (BlockPos::containing(min), BlockPos::containing(max));
|
||||
let top = SECTIONS as i32 - 1;
|
||||
let (x0, x1) = (min.x >> 4, max.x >> 4);
|
||||
let (z0, z1) = (min.z >> 4, max.z >> 4);
|
||||
let (y0, y1) = (
|
||||
((min.y - MIN_Y) >> 4).clamp(0, top),
|
||||
((max.y - MIN_Y) >> 4).clamp(0, top),
|
||||
);
|
||||
let count = (i64::from(x1) - i64::from(x0) + 1)
|
||||
* (i64::from(y1) - i64::from(y0) + 1)
|
||||
* (i64::from(z1) - i64::from(z0) + 1);
|
||||
(count, x0, x1, y0, y1, z0, z1)
|
||||
}
|
||||
|
||||
/// The original `Game::may_hold_portal`: per-tick `seen`, palette per section.
|
||||
fn may_hold_portal_baseline(
|
||||
reach: Aabb,
|
||||
world: &FxHashMap<(i32, i32), Arc<Chunk>>,
|
||||
seen: &mut FxHashMap<(i32, i32, i32), bool>,
|
||||
behaviours: &Behaviours,
|
||||
) -> bool {
|
||||
let (count, x0, x1, y0, y1, z0, z1) = sections(reach);
|
||||
if count > 64 {
|
||||
return true;
|
||||
}
|
||||
(x0..=x1).any(|cx| {
|
||||
(z0..=z1).any(|cz| {
|
||||
(y0..=y1).any(|sy| {
|
||||
*seen.entry((cx, sy, cz)).or_insert_with(|| {
|
||||
world
|
||||
.get(&(cx, cz))
|
||||
.and_then(|c| c.sections.get(sy as usize))
|
||||
.is_some_and(|s| {
|
||||
s.blocks
|
||||
.maybe_has(|v| behaviours.portal_axis(BlockStateId(v)).is_some())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The cached `Game::may_hold_portal`: one mask per resident chunk.
|
||||
fn may_hold_portal_cached(
|
||||
reach: Aabb,
|
||||
world: &FxHashMap<(i32, i32), Arc<Chunk>>,
|
||||
cache: &mut PortalSections,
|
||||
behaviours: &Behaviours,
|
||||
) -> bool {
|
||||
let (count, x0, x1, y0, y1, z0, z1) = sections(reach);
|
||||
if count > 64 {
|
||||
return true;
|
||||
}
|
||||
let mut y_mask = 0u32;
|
||||
for sy in y0..=y1 {
|
||||
y_mask |= 1 << sy;
|
||||
}
|
||||
(x0..=x1).any(|cx| {
|
||||
(z0..=z1).any(|cz| {
|
||||
world.get(&(cx, cz)).is_some_and(|chunk| {
|
||||
cache.get((cx, cz), chunk, |v| {
|
||||
behaviours.portal_axis(BlockStateId(v)).is_some()
|
||||
}) & y_mask
|
||||
!= 0
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn main() -> horizon_client::Result<()> {
|
||||
let root = std::env::args()
|
||||
.nth(1)
|
||||
.ok_or("expected prepared client resource directory")?;
|
||||
let start = Instant::now();
|
||||
let registry = horizon_world::level::data::load_registry(Path::new(&root))?;
|
||||
let (facts, behaviours) = BlockFacts::load_pair(Path::new(&root), ®istry)?;
|
||||
println!(
|
||||
"resource_load_ms={:.3}",
|
||||
start.elapsed().as_secs_f64() * 1000.0
|
||||
);
|
||||
let len = registry.len() as u32;
|
||||
let portal: Vec<u32> = (0..len)
|
||||
.filter(|&id| behaviours.portal_axis(BlockStateId(id)).is_some())
|
||||
.collect();
|
||||
let inactive_states: Vec<u32> = (0..len)
|
||||
.filter(|&id| behaviours.portal_axis(BlockStateId(id)).is_none())
|
||||
.collect();
|
||||
let _ = &facts;
|
||||
let portal_state = *portal
|
||||
.first()
|
||||
.ok_or("no nether portal block state in the registry")?;
|
||||
println!("portal_states={}", portal.len());
|
||||
|
||||
// 500 entities spread over a 17x17 chunk world. Their reaches touch 1-4
|
||||
// sections, as an item or a mob standing in or stepping into a portal does.
|
||||
let grid = 17;
|
||||
let base = -(grid / 2) * 16;
|
||||
let entities: Vec<(Vec3, Vec3, f32, f32)> = (0..500)
|
||||
.map(|i| {
|
||||
let x = base + (i % grid) * 16 + (i % 5);
|
||||
let z = base + (i / grid % grid) * 16 + (i % 7);
|
||||
let y = 64 + (i % 3) * 16;
|
||||
let from = Vec3::new(f64::from(x), f64::from(y), f64::from(z));
|
||||
let to = from.add_vector(Vec3::new(0.1, 0.0, 0.07));
|
||||
(from, to, 0.25, 0.25)
|
||||
})
|
||||
.collect();
|
||||
let reaches: Vec<Aabb> = entities
|
||||
.iter()
|
||||
.map(|&(from, to, w, h)| reach(from, to, w, h))
|
||||
.collect();
|
||||
|
||||
for (name, palette, portals_every) in [
|
||||
("sparse", 1usize, 0usize),
|
||||
("dense", 128, 0),
|
||||
("global", 300, 0),
|
||||
("dense_portal", 128, 5),
|
||||
] {
|
||||
let start = Instant::now();
|
||||
let mut world: FxHashMap<(i32, i32), Arc<Chunk>> = FxHashMap::default();
|
||||
for czi in 0..grid {
|
||||
for cxi in 0..grid {
|
||||
let (cx, cz) = (-(grid / 2) + cxi, -(grid / 2) + czi);
|
||||
let mut chunk = Chunk::empty(cx, cz);
|
||||
for (i, section) in chunk.sections.iter_mut().enumerate() {
|
||||
let values: [u32; 4096] = std::array::from_fn(|j| {
|
||||
if portals_every > 0 && i % portals_every == 0 && j == 0 {
|
||||
portal_state
|
||||
} else {
|
||||
inactive_states[(j * 7 + i) % palette.min(inactive_states.len())]
|
||||
}
|
||||
});
|
||||
Arc::make_mut(section).blocks = Section::from_blocks(&values);
|
||||
}
|
||||
world.insert((cx, cz), Arc::new(chunk));
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"{name} fixture_ms={:.3}",
|
||||
start.elapsed().as_secs_f64() * 1000.0
|
||||
);
|
||||
let keys: Vec<_> = world.keys().copied().collect();
|
||||
let portal_chunks: Vec<(i32, i32)> = keys
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&(cx, cz)| {
|
||||
world[&(cx, cz)]
|
||||
.sections
|
||||
.iter()
|
||||
.any(|s| s.blocks.maybe_has(|v| v == portal_state))
|
||||
})
|
||||
.collect();
|
||||
println!("{name} portal_chunks={}", portal_chunks.len());
|
||||
// The global palette's maybe_has walks the whole ID range, so rescanning
|
||||
// every chunk each tick (cold/reload/edit_all) is intractable; those rows
|
||||
// are only meaningful for the palettes whose scans are bounded.
|
||||
let workloads: &[(&str, usize)] = if name == "global" {
|
||||
&[("steady", 0), ("edit_1pct", 3)]
|
||||
} else {
|
||||
&[
|
||||
("steady", 0),
|
||||
("cold", 0),
|
||||
("reload", 0),
|
||||
("edit_1pct", 3),
|
||||
("edit_all", 289),
|
||||
]
|
||||
};
|
||||
for &(workload, changed) in workloads {
|
||||
let mut baseline = Vec::new();
|
||||
let mut candidate = Vec::new();
|
||||
let mut cache = PortalSections::default();
|
||||
let (mut base_hits, mut cand_hits) = (0u64, 0u64);
|
||||
for sample in 0..8 {
|
||||
for cached in if sample % 2 == 0 {
|
||||
[false, true]
|
||||
} else {
|
||||
[true, false]
|
||||
} {
|
||||
let start = Instant::now();
|
||||
let mut result = 0u64;
|
||||
for tick in 0..300 {
|
||||
if cached && workload == "cold" {
|
||||
cache = PortalSections::default();
|
||||
}
|
||||
if cached && workload == "reload" {
|
||||
cache.clear();
|
||||
}
|
||||
for key in keys.iter().take(changed) {
|
||||
Arc::make_mut(world.get_mut(key).unwrap()).set(
|
||||
0,
|
||||
MIN_Y,
|
||||
0,
|
||||
inactive_states[tick % inactive_states.len()],
|
||||
);
|
||||
}
|
||||
let mut seen = FxHashMap::default();
|
||||
for reach in &reaches {
|
||||
let hit = if cached {
|
||||
may_hold_portal_cached(*reach, &world, &mut cache, &behaviours)
|
||||
} else {
|
||||
may_hold_portal_baseline(*reach, &world, &mut seen, &behaviours)
|
||||
};
|
||||
result += hit as u64;
|
||||
}
|
||||
}
|
||||
if cached {
|
||||
cache = PortalSections::default();
|
||||
cand_hits += black_box(result);
|
||||
} else {
|
||||
base_hits += black_box(result);
|
||||
}
|
||||
if sample > 0 {
|
||||
let us = start.elapsed().as_secs_f64() * 1e6 / 300.0;
|
||||
if cached {
|
||||
candidate.push(us);
|
||||
} else {
|
||||
baseline.push(us);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
base_hits, cand_hits,
|
||||
"{name} {workload}: cached may_hold_portal diverged"
|
||||
);
|
||||
println!("{name} {workload} baseline_us={baseline:.3?} candidate_us={candidate:.3?}");
|
||||
}
|
||||
}
|
||||
println!("chunk_wrapper_bytes={}", std::mem::size_of::<Chunk>());
|
||||
Ok(())
|
||||
}
|
||||
@@ -1815,6 +1815,7 @@ impl Game<'_> {
|
||||
}
|
||||
for &key in self.tick_chunks.removed() {
|
||||
self.random_tick_sections.remove(&key);
|
||||
self.portals.sections.remove(&key);
|
||||
self.block_ticks
|
||||
.remove_chunk(horizon_core::position::ChunkPos::new(key.0, key.1));
|
||||
}
|
||||
|
||||
@@ -65,6 +65,8 @@ pub(super) struct Portals {
|
||||
arrivals: Vec<Arrival>,
|
||||
loaded: bool,
|
||||
dirty: bool,
|
||||
/// Per-chunk portal palette masks, kept across ticks (portal_sections).
|
||||
pub(super) sections: horizon_client::portal_sections::PortalSections,
|
||||
}
|
||||
|
||||
fn rotate_y(v: Vec3, degrees: f32) -> Vec3 {
|
||||
@@ -486,13 +488,14 @@ impl Game<'_> {
|
||||
.filter(|m| self.mob_can_use_portal(m))
|
||||
.map(|m| (m.id, m.position, m.width(), m.height()))
|
||||
.collect();
|
||||
let mut portal_sections = horizon_client::fast_hash::FxHashMap::default();
|
||||
let mut portal_sections = std::mem::take(&mut self.portals.sections);
|
||||
for (id, to, width, height) in items.into_iter().chain(mobs) {
|
||||
let from = self.portals.before.get(&id).copied().unwrap_or(to);
|
||||
if let Some(pos) = self.swept_portal(from, to, width, height, &mut portal_sections) {
|
||||
inside.push((id, pos));
|
||||
}
|
||||
}
|
||||
self.portals.sections = portal_sections;
|
||||
for (id, pos) in inside {
|
||||
self.portals
|
||||
.entities
|
||||
@@ -509,7 +512,7 @@ impl Game<'_> {
|
||||
to: Vec3,
|
||||
width: f32,
|
||||
height: f32,
|
||||
portal_sections: &mut horizon_client::fast_hash::FxHashMap<(i32, i32, i32), bool>,
|
||||
portal_sections: &mut horizon_client::portal_sections::PortalSections,
|
||||
) -> Option<BlockPos> {
|
||||
let half = f64::from(width) / 2.0;
|
||||
let bounds = horizon_core::geometry::Aabb::new(
|
||||
@@ -550,12 +553,13 @@ impl Game<'_> {
|
||||
found
|
||||
}
|
||||
/// Whether a section `reach` touches has a nether portal state in its
|
||||
/// palette: without one, no block there is a portal. Answers are kept
|
||||
/// per section for the tick; a box over many sections is not checked.
|
||||
/// palette: without one, no block there is a portal. Palette answers are
|
||||
/// kept per chunk across ticks (`portal_sections`); a box over many
|
||||
/// sections is not checked.
|
||||
fn may_hold_portal(
|
||||
&self,
|
||||
reach: horizon_core::geometry::Aabb,
|
||||
seen: &mut horizon_client::fast_hash::FxHashMap<(i32, i32, i32), bool>,
|
||||
sections: &mut horizon_client::portal_sections::PortalSections,
|
||||
) -> bool {
|
||||
use horizon_client::chunk::{MIN_Y, SECTIONS};
|
||||
let (min, max) = (reach.min_position(), reach.max_position());
|
||||
@@ -576,23 +580,21 @@ impl Game<'_> {
|
||||
if count > 64 {
|
||||
return true;
|
||||
}
|
||||
let mut y_mask = 0u32;
|
||||
for sy in y0..=y1 {
|
||||
y_mask |= 1 << sy;
|
||||
}
|
||||
let behaviours = &self.res.behaviours;
|
||||
let chunks = &self.world.chunks;
|
||||
(x0..=x1).any(|cx| {
|
||||
(z0..=z1).any(|cz| {
|
||||
(y0..=y1).any(|sy| {
|
||||
*seen.entry((cx, sy, cz)).or_insert_with(|| {
|
||||
chunks
|
||||
.get(&(cx, cz))
|
||||
.and_then(|c| c.sections.get(sy as usize))
|
||||
.is_some_and(|s| {
|
||||
s.blocks.maybe_has(|v| {
|
||||
behaviours
|
||||
.portal_axis(horizon_world::registry::BlockStateId(v))
|
||||
.is_some()
|
||||
})
|
||||
})
|
||||
})
|
||||
chunks.get(&(cx, cz)).is_some_and(|chunk| {
|
||||
sections.get((cx, cz), chunk, |v| {
|
||||
behaviours
|
||||
.portal_axis(horizon_world::registry::BlockStateId(v))
|
||||
.is_some()
|
||||
}) & y_mask
|
||||
!= 0
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -154,6 +154,7 @@ impl Game<'_> {
|
||||
};
|
||||
// Everything loaded: switch over.
|
||||
self.random_tick_sections.clear();
|
||||
self.portals.sections.clear();
|
||||
self.options.resources = root.to_path_buf();
|
||||
let context = pack.context.clone();
|
||||
self.pack = pack;
|
||||
|
||||
@@ -133,6 +133,7 @@ pub mod patrol;
|
||||
pub mod perf_overlay;
|
||||
pub mod pick_item;
|
||||
pub mod poi_sections;
|
||||
pub mod portal_sections;
|
||||
pub mod pose;
|
||||
pub mod post;
|
||||
pub mod projectiles;
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Memoized nether-portal palette eligibility per resident chunk.
|
||||
//!
|
||||
//! `Game::find_entity_portals` asks the same sections every tick whether their
|
||||
//! palette may hold a portal state. The answer only changes when a chunk's
|
||||
//! blocks change, so it is kept per chunk until the weak handle stops matching.
|
||||
use crate::{chunk::Chunk, fast_hash::FxHashMap};
|
||||
use std::sync::{Arc, Weak};
|
||||
|
||||
struct Cached {
|
||||
chunk: Weak<Chunk>,
|
||||
/// A bit per section, set when that section's palette may hold a portal.
|
||||
sections: u32,
|
||||
}
|
||||
|
||||
/// Resident chunks are immutable behind `Arc`; edits use `Arc::make_mut`.
|
||||
/// A weak reference detects both edits and replacement, without retaining block
|
||||
/// data or forcing a deep copy on the next edit. It also prevents address reuse.
|
||||
/// Clear this cache whenever the gameplay facts change (including resource reload).
|
||||
#[derive(Default)]
|
||||
pub struct PortalSections {
|
||||
chunks: FxHashMap<(i32, i32), Cached>,
|
||||
}
|
||||
|
||||
impl PortalSections {
|
||||
pub fn clear(&mut self) {
|
||||
self.chunks.clear();
|
||||
}
|
||||
pub fn remove(&mut self, key: &(i32, i32)) {
|
||||
self.chunks.remove(key);
|
||||
}
|
||||
/// The sections of `chunk` whose palette may hold a portal, as a bitmask in
|
||||
/// ascending section order (`MIN_Y` at bit 0). `portal` is only called while
|
||||
/// (re)building an entry, so an unchanged chunk scans its palette once.
|
||||
pub fn get(
|
||||
&mut self,
|
||||
key: (i32, i32),
|
||||
chunk: &Arc<Chunk>,
|
||||
mut portal: impl FnMut(u32) -> bool,
|
||||
) -> u32 {
|
||||
const {
|
||||
assert!(crate::chunk::SECTIONS <= u32::BITS as usize);
|
||||
}
|
||||
let cached = self.chunks.entry(key).or_insert_with(|| Cached {
|
||||
chunk: Weak::new(),
|
||||
sections: 0,
|
||||
});
|
||||
if cached.chunk.as_ptr() != Arc::as_ptr(chunk) {
|
||||
let mut mask = 0;
|
||||
for (i, section) in chunk.sections.iter().enumerate() {
|
||||
// Preserve maybe_has, including unused entries and global palettes.
|
||||
if section.blocks.maybe_has(&mut portal) {
|
||||
mask |= 1 << i;
|
||||
}
|
||||
}
|
||||
cached.sections = mask;
|
||||
cached.chunk = Arc::downgrade(chunk);
|
||||
}
|
||||
cached.sections
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::chunk::{MIN_Y, SECTIONS, Section};
|
||||
|
||||
#[test]
|
||||
fn edits_replacements_compaction_and_reload() {
|
||||
let mut cache = PortalSections::default();
|
||||
let mut chunk = Arc::new(Chunk::empty(0, 0));
|
||||
assert_eq!(cache.get((0, 0), &chunk, |id| id == 7), 0);
|
||||
cache.get((0, 0), &chunk, |_| panic!("unchanged chunk rescanned"));
|
||||
let worker = chunk.clone();
|
||||
Arc::make_mut(&mut chunk).set(0, MIN_Y, 0, 7);
|
||||
assert_eq!(cache.get((0, 0), &chunk, |id| id == 7), 1);
|
||||
assert_eq!(worker.get(0, MIN_Y, 0), 0);
|
||||
// Even after the last portal block is removed, its unused palette entry counts.
|
||||
Arc::make_mut(&mut chunk).set(0, MIN_Y, 0, 0);
|
||||
assert_eq!(cache.get((0, 0), &chunk, |id| id == 7), 1);
|
||||
Arc::make_mut(&mut Arc::make_mut(&mut chunk).sections[0]).compact();
|
||||
assert_eq!(cache.get((0, 0), &chunk, |id| id == 7), 0);
|
||||
Arc::make_mut(&mut chunk).sections[SECTIONS - 1] = Arc::new(Section::default());
|
||||
cache.clear(); // resource reload, with changed facts for the same state IDs
|
||||
assert_eq!(
|
||||
cache.get((0, 0), &chunk, |id| id == 0),
|
||||
(1 << SECTIONS) - 1,
|
||||
"a fresh palette is rescanned for every section"
|
||||
);
|
||||
cache.remove(&(0, 0));
|
||||
assert!(cache.chunks.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacement_at_same_coordinates_and_early_snapshot() {
|
||||
let mut cache = PortalSections::default();
|
||||
let mut chunk = Arc::new(Chunk::empty(-2, 3));
|
||||
let before = cache.get((-2, 3), &chunk, |id| id == 1);
|
||||
Arc::make_mut(&mut chunk).set(0, MIN_Y + 16, 0, 1);
|
||||
assert_eq!(before, 0);
|
||||
assert_eq!(cache.get((-2, 3), &chunk, |id| id == 1), 1 << 1);
|
||||
let weak = Arc::downgrade(&chunk);
|
||||
drop(chunk);
|
||||
assert!(weak.upgrade().is_none()); // cache does not retain unloaded block data
|
||||
let replacement = Arc::new(Chunk::empty(-2, 3));
|
||||
assert_eq!(cache.get((-2, 3), &replacement, |id| id == 1), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_palette_covers_the_whole_id_range() {
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
for i in [0, 9, SECTIONS - 1] {
|
||||
Arc::make_mut(&mut chunk.sections[i]).blocks =
|
||||
Section::from_blocks(&std::array::from_fn(|j| (j % 300) as u32));
|
||||
}
|
||||
let chunk = Arc::new(chunk);
|
||||
let mut cache = PortalSections::default();
|
||||
// Global maybe_has considers the entire ID range, including absent states.
|
||||
assert_eq!(
|
||||
cache.get((0, 0), &chunk, |id| id == 40000),
|
||||
(1 << 0) | (1 << 9) | (1 << (SECTIONS - 1))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
# Nether-portal section-scan benchmark
|
||||
|
||||
Run with the pinned prepared client resources:
|
||||
|
||||
```sh
|
||||
CARGO_BUILD_JOBS=6 cargo run --profile fast -p horizon-client --example portal_scan_bench -- \
|
||||
.local/horizon-client/26.3
|
||||
```
|
||||
|
||||
The example keeps the original `Game::may_hold_portal` alongside the resident
|
||||
cache (`portal_sections`). Both run in the same optimized binary, alternating
|
||||
measurement order. Each workload has one discarded warmup pair and seven pairs
|
||||
of 300 ticks. It reports resource loading and fixture construction separately.
|
||||
This measures the section scan only, not the whole `entity_portals` span, the
|
||||
sweep that follows a positive answer, or the rest of the tick.
|
||||
|
||||
The fixture is a 17x17 chunk world (289 chunks), each with 24 sections, and 500
|
||||
entity reaches spread over it (an item or mob box 0.25 wide and the small move
|
||||
that produces the swept box). A reach touches 1-4 sections. Four palettes:
|
||||
sparse (one non-portal state), dense (128 distinct), global (300 distinct, a
|
||||
global palette whose `maybe_has` walks the whole state range) and dense_portal
|
||||
(the dense palette with a portal in every fifth section). These are controlled
|
||||
fixtures, not a claim about typical generated terrain.
|
||||
|
||||
Steady includes the initial cache fill amortized over 300 ticks. Cold recreates
|
||||
the cache each tick, including allocation and destruction; reload clears it each
|
||||
tick while retaining capacity. Edit workloads alternate a block value in three
|
||||
or all 289 chunks before every scan. Timing includes the `Arc::make_mut` move of
|
||||
the 176-byte chunk wrapper caused by weak cache handles; the baseline holds no
|
||||
such handles. Both paths keep the original outer reach list.
|
||||
|
||||
The scan is parity-checked in the same run: the cached and per-tick answers must
|
||||
agree for every reach over every sample, or the example panics.
|
||||
|
||||
Measured on an AMD Ryzen 5 5600X (12 logical CPUs), Linux x86-64, rustc 1.99.0
|
||||
(b940084d7), using `fast` (release optimization, LTO off, 16 codegen units).
|
||||
Resource loading took 705 ms; fixture construction took 66 ms sparse, 360 ms
|
||||
dense, 392 ms global and 385 ms dense_portal, shared by both implementations.
|
||||
|
||||
Median microseconds per tick (seven paired samples):
|
||||
|
||||
| Palette | Workload | Original | Cached | Speedup |
|
||||
| --- | --- | ---: | ---: | ---: |
|
||||
| sparse | steady | 40.058 | 16.836 | 2.38x |
|
||||
| sparse | cold | 40.203 | 48.619 | 0.83x |
|
||||
| sparse | reload | 40.828 | 44.552 | 0.92x |
|
||||
| sparse | edit_1pct | 41.282 | 52.200 | 0.79x |
|
||||
| sparse | edit_all | 50.119 | 4192.396 | 0.01x |
|
||||
| dense | steady | 244.610 | 21.869 | 11.19x |
|
||||
| dense | cold | 292.533 | 1505.691 | 0.19x |
|
||||
| dense | reload | 250.397 | 1301.608 | 0.19x |
|
||||
| dense | edit_1pct | 256.522 | 69.132 | 3.71x |
|
||||
| dense | edit_all | 263.829 | 5904.581 | 0.04x |
|
||||
| global | steady | 6179.070 | 360.082 | 17.16x |
|
||||
| global | edit_1pct | 6251.530 | 1565.249 | 3.99x |
|
||||
| dense_portal | steady | 256.854 | 21.078 | 12.19x |
|
||||
| dense_portal | cold | 240.498 | 1526.251 | 0.16x |
|
||||
| dense_portal | reload | 347.879 | 2037.123 | 0.17x |
|
||||
| dense_portal | edit_1pct | 368.684 | 118.108 | 3.12x |
|
||||
| dense_portal | edit_all | 282.876 | 7194.112 | 0.04x |
|
||||
|
||||
The global palette's `maybe_has` walks the whole ID range, so rescanning every
|
||||
chunk each tick (cold, reload, edit_all) is intractable; those rows are not
|
||||
measured for it.
|
||||
|
||||
Keep: the steady scan improves for every palette (2.4x to 17x), which is the
|
||||
case that repeats each tick in real play. Under constant edits the cache also
|
||||
improves the dense and global palettes (edit_1pct 3-4x) because the original
|
||||
rescans through the per-tick map. Rescanning every chunk every tick (cold,
|
||||
reload, edit_all) is slower than the original because a miss scans all 24
|
||||
sections of a chunk, where the original only touched the sections a reach
|
||||
overlaps. No whole-game speedup is inferred from this microbenchmark; real
|
||||
terrain's palette sizes and block-write frequency determine the gain. Lighting
|
||||
and other chunk changes conservatively invalidate a chunk's mask.
|
||||
|
||||
The cache adds a weak handle and mask per visited resident chunk, retains no
|
||||
unloaded block data, and is pruned when a chunk unloads (`sync_tick_chunks`) and
|
||||
cleared on resource reload, like `random_tick_sections`. Editing a cached chunk
|
||||
can move its 176-byte wrapper (plus Arc bookkeeping) on the first edit after it
|
||||
is cached; it does not force a deep copy of the block data. The original outer
|
||||
reach-list allocation remains.
|
||||
Reference in New Issue
Block a user