perf(streaming): skip unchanged exhausted generation scans

This commit is contained in:
rdbtCVS authored and Ludovic Marie committed 2026-10-15 07:04:23 +02:00
1 parent cfa6033535
commit 69925a57c5
2 files changed
+511 -58

No files matched your search

+459 -58
View File
@@ -134,6 +134,7 @@ pub struct Streaming {
threads: Vec<thread::JoinHandle<()>>,
order: u64,
generating: FxHashSet<(i32, i32)>,
generation_scan: GenerationScan,
/// Proto chunks: `None` while checked out to a decoration job.
protos: FxHashMap<(i32, i32), Option<Proto>>,
decorated: FxHashSet<(i32, i32)>,
@@ -191,6 +192,125 @@ pub struct Streaming {
/// of a world open to the network. They are generated and kept but not meshed.
pub extra: Vec<((i32, i32), i32)>,
}
/// Snapshot only after a scan found no missing candidates. Public range fields
/// and `World::chunks` can change without going through Streaming, so compare
/// their values and structural membership token rather than a frame timeout.
#[derive(Default)]
struct GenerationScan {
exhausted: bool,
center: (i32, i32),
radius: i32,
extra: Vec<((i32, i32), i32)>,
forced: HashSet<(i32, i32)>,
chunk_revision: u64,
}
impl GenerationScan {
fn unchanged(&self, ranges: &Ranges<'_>, world: &World) -> bool {
self.exhausted
&& self.center == ranges.center
&& self.radius == ranges.radius
&& self.extra == ranges.extra
&& self.forced == *ranges.forced
&& self.chunk_revision == world.chunks.membership_revision()
}
fn remember(&mut self, ranges: Ranges<'_>, world: &World) {
self.exhausted = true;
self.center = ranges.center;
self.radius = ranges.radius;
self.extra.clear();
self.extra.extend_from_slice(ranges.extra);
self.forced.clone_from(ranges.forced);
self.chunk_revision = world.chunks.membership_revision();
}
}
struct GenerationInputs<'a> {
ranges: Ranges<'a>,
world: &'a World,
generating: &'a FxHashSet<(i32, i32)>,
protos: &'a FxHashMap<(i32, i32), Option<Proto>>,
finishing: &'a FxHashSet<(i32, i32)>,
decorates: bool,
remote: bool,
}
impl GenerationScan {
/// None means the scan was skipped; Some preserves the original candidate
/// ordering and bounded batch, including an empty exhausted scan.
fn candidates(
&mut self,
input: GenerationInputs<'_>,
capacity: usize,
) -> Option<Vec<(i64, i32, i32)>> {
// Generation (load or proto), closest first, bounded in flight.
let margin = if input.decorates { 3 } else { 1 };
// Neighbours a finished chunk needs generated: decorated ones need theirs.
let reach = if input.decorates { 2 } else { 1 };
if input.remote
|| input.generating.len() >= capacity
|| self.unchanged(&input.ranges, input.world)
{
return None;
}
{
let mut wanted = Vec::new();
let areas: Vec<((i32, i32), i32)> =
std::iter::once((input.ranges.center, input.ranges.radius))
.chain(input.ranges.extra.iter().copied())
.collect();
for (center, radius) in areas {
let r = radius + margin.max(reach + 1);
for dz in -r..=r {
for dx in -r..=r {
let (x, z) = (center.0 + dx, center.1 + dz);
if !(input.ranges.in_range(x, z, margin)
|| input.ranges.near_finished(x, z, reach))
|| input.world.chunks.contains_key(&(x, z))
|| input.generating.contains(&(x, z))
|| input.protos.contains_key(&(x, z))
|| input.finishing.contains(&(x, z))
{
continue;
}
wanted.push((input.ranges.distance(x, z), x, z));
}
}
}
// Forced chunks and their generation neighbourhood.
let reach = margin - 1;
for &(fx, fz) in input.ranges.forced {
for dz in -reach..=reach {
for dx in -reach..=reach {
let (x, z) = (fx + dx, fz + dz);
if input.world.chunks.contains_key(&(x, z))
|| input.generating.contains(&(x, z))
|| input.protos.contains_key(&(x, z))
|| input.finishing.contains(&(x, z))
{
continue;
}
wanted.push((input.ranges.distance(x, z), x, z));
}
}
}
if wanted.is_empty() {
let ranges = Ranges {
center: input.ranges.center,
radius: input.ranges.radius,
extra: input.ranges.extra,
forced: input.ranges.forced,
};
self.remember(ranges, input.world);
} else {
// A bounded batch may leave candidates behind. Keep scanning
// as capacity opens, even if no range or membership changed.
self.exhausted = false;
}
wanted.sort_unstable();
wanted.dedup();
wanted.truncate(capacity - input.generating.len());
Some(wanted)
}
}
}
pub struct WorkerContext {
pub assets: Arc<crate::assets::Assets>,
pub tables: Arc<MeshTables>,
@@ -282,6 +402,7 @@ impl Streaming {
threads,
order: 0,
generating: Default::default(),
generation_scan: GenerationScan::default(),
protos: Default::default(),
decorated: Default::default(),
decorating: 0,
@@ -366,6 +487,7 @@ impl Streaming {
self.shared.ready.notify_one();
}
fn receive_decorated(&mut self, x: i32, z: i32, region: Box<[Proto; 9]>) {
self.generation_scan.exhausted = false;
self.decorating -= 1;
self.decorated.insert((x, z));
for (i, proto) in region.into_iter().enumerate() {
@@ -375,6 +497,7 @@ impl Streaming {
}
}
fn receive_generated(&mut self, mut chunk: Chunk, world: &mut World) {
self.generation_scan.exhausted = false;
let key = (chunk.x, chunk.z);
self.generating.remove(&key);
self.finishing.remove(&key);
@@ -427,14 +550,7 @@ impl Streaming {
}
}
fn distance(&self, x: i32, z: i32) -> i64 {
let d = |c: (i32, i32)| {
let (dx, dz) = (i64::from(x - c.0), i64::from(z - c.1));
dx * dx + dz * dz
};
self.extra
.iter()
.map(|(c, _)| d(*c))
.fold(d(self.center), i64::min)
self.ranges().distance(x, z)
}
fn region(world: &World, x: i32, z: i32) -> Option<Box<Region>> {
let mut region: Region = Default::default();
@@ -472,6 +588,7 @@ impl Streaming {
for done in deferred.into_iter().chain(received.collect::<Vec<_>>()) {
match done {
Done::Proto(x, z, proto, decorated) => {
self.generation_scan.exhausted = false;
self.generating.remove(&(x, z));
if self.in_range(x, z, 4) || self.near_finished(x, z, 2) {
self.protos.insert((x, z), Some(proto));
@@ -568,6 +685,9 @@ impl Streaming {
})
.map(|(k, _)| *k)
.collect();
if !far.is_empty() {
self.generation_scan.exhausted = false;
}
// Those decoration wrote to are saved (see `ChunkGenerator::save_protos`).
let written: Vec<Proto> = far
.iter()
@@ -717,56 +837,7 @@ impl Streaming {
self.submit(d * 4 + 1, Job::Decorate(x, z, region));
}
}
// Generation (load or proto), closest first, bounded in flight.
let margin = if self.decorates { 3 } else { 1 };
// Neighbours a finished chunk needs generated: decorated ones need theirs.
let reach = if self.decorates { 2 } else { 1 };
if !self.remote && self.generating.len() < workers * 3 {
let mut wanted = Vec::new();
let areas: Vec<((i32, i32), i32)> = std::iter::once((self.center, self.radius))
.chain(self.extra.iter().copied())
.collect();
for (center, radius) in areas {
let r = radius + margin.max(reach + 1);
for dz in -r..=r {
for dx in -r..=r {
let (x, z) = (center.0 + dx, center.1 + dz);
if !(self.in_range(x, z, margin) || self.near_finished(x, z, reach))
|| world.chunks.contains_key(&(x, z))
|| self.generating.contains(&(x, z))
|| self.protos.contains_key(&(x, z))
|| self.finishing.contains(&(x, z))
{
continue;
}
wanted.push((self.distance(x, z), x, z));
}
}
}
// Forced chunks and their generation neighbourhood.
let reach = margin - 1;
for &(fx, fz) in &self.forced {
for dz in -reach..=reach {
for dx in -reach..=reach {
let (x, z) = (fx + dx, fz + dz);
if world.chunks.contains_key(&(x, z))
|| self.generating.contains(&(x, z))
|| self.protos.contains_key(&(x, z))
|| self.finishing.contains(&(x, z))
{
continue;
}
wanted.push((self.distance(x, z), x, z));
}
}
}
wanted.sort_unstable();
wanted.dedup();
for (d, x, z) in wanted.into_iter().take(workers * 3 - self.generating.len()) {
self.generating.insert((x, z));
self.submit(d * 4, Job::Generate(x, z));
}
}
self.schedule_generation(world, workers);
// Lighting once all neighbours exist.
let light: Vec<_> = world.dirty_light.iter().copied().collect();
for (x, z) in light {
@@ -860,6 +931,28 @@ impl Streaming {
}
(uploads, removed)
}
fn schedule_generation(&mut self, world: &World, workers: usize) {
let input = GenerationInputs {
ranges: Ranges {
center: self.center,
radius: self.radius,
extra: &self.extra,
forced: &self.forced,
},
world,
generating: &self.generating,
protos: &self.protos,
finishing: &self.finishing,
decorates: self.decorates,
remote: self.remote,
};
if let Some(wanted) = self.generation_scan.candidates(input, workers * 3) {
for (d, x, z) in wanted {
self.generating.insert((x, z));
self.submit(d * 4, Job::Generate(x, z));
}
}
}
/// Relight block edits on lit chunks in place (vanilla's incremental light
/// engine) instead of recomputing the 3x3 chunks around each edit.
fn relight_edits(&mut self, world: &mut World) {
@@ -995,6 +1088,17 @@ struct Ranges<'a> {
forced: &'a HashSet<(i32, i32)>,
}
impl Ranges<'_> {
fn distance(&self, x: i32, z: i32) -> i64 {
let d = |c: (i32, i32)| {
let (dx, dz) = (i64::from(x - c.0), i64::from(z - c.1));
dx * dx + dz * dz
};
self.extra
.iter()
.map(|(c, _)| d(*c))
.fold(d(self.center), i64::min)
}
/// Whether a chunk finished here (`in_range(_, _, 1)`) lies within `reach`
/// chunks of (x, z) on both axes: its decoration needs its 3x3 neighbours
/// decorated, which need their own 3x3 neighbours generated. The ranges are
@@ -1042,6 +1146,303 @@ impl Drop for Streaming {
mod tests {
use super::*;
struct GenerationFixture {
world: World,
center: (i32, i32),
radius: i32,
extra: Vec<((i32, i32), i32)>,
forced: HashSet<(i32, i32)>,
generating: FxHashSet<(i32, i32)>,
protos: FxHashMap<(i32, i32), Option<Proto>>,
finishing: FxHashSet<(i32, i32)>,
decorates: bool,
remote: bool,
}
impl GenerationFixture {
fn new(radius: i32, decorates: bool) -> Self {
Self {
world: World::new(horizon_world::registry::BlockStateId(0), 0),
center: (0, 0),
radius,
extra: Vec::new(),
forced: HashSet::new(),
generating: Default::default(),
protos: Default::default(),
finishing: Default::default(),
decorates,
remote: false,
}
}
fn input(&self) -> GenerationInputs<'_> {
GenerationInputs {
ranges: Ranges {
center: self.center,
radius: self.radius,
extra: &self.extra,
forced: &self.forced,
},
world: &self.world,
generating: &self.generating,
protos: &self.protos,
finishing: &self.finishing,
decorates: self.decorates,
remote: self.remote,
}
}
fn fill(&mut self) {
// Scheduler only reads membership. Share one real empty column to
// keep a radius-32 fixture small; chunk content is never consulted.
let chunk = Arc::new(Chunk::empty(0, 0));
for (_, x, z) in baseline_candidates(self.input(), usize::MAX) {
self.world.chunks.insert((x, z), chunk.clone());
}
}
fn fill_steady(&mut self) {
let chunk = Arc::new(Chunk::empty(0, 0));
for (_, x, z) in baseline_candidates(self.input(), usize::MAX) {
if self.input().ranges.in_range(x, z, 1) {
self.world.chunks.insert((x, z), chunk.clone());
} else {
// Generation's outer neighbour margin remains proto;
// only columns in the finish range become resident.
self.protos.insert((x, z), Some(Box::new(())));
}
}
}
fn assert_same(&self, gate: &mut GenerationScan, capacity: usize) -> bool {
let expected = baseline_candidates(self.input(), capacity);
let result = gate.candidates(self.input(), capacity);
assert_eq!(result.as_deref().unwrap_or(&[]), expected);
result.is_some()
}
}
// Original scheduling scan, without snapshot bookkeeping, for differential
// correctness and timing. Keep this independent of the production gate.
fn baseline_candidates(input: GenerationInputs<'_>, capacity: usize) -> Vec<(i64, i32, i32)> {
if input.remote || input.generating.len() >= capacity {
return Vec::new();
}
let margin: i32 = if input.decorates { 3 } else { 1 };
let reach = if input.decorates { 2 } else { 1 };
let mut wanted = Vec::new();
let areas: Vec<_> = std::iter::once((input.ranges.center, input.ranges.radius))
.chain(input.ranges.extra.iter().copied())
.collect();
let missing = |x, z| {
!input.world.chunks.contains_key(&(x, z))
&& !input.generating.contains(&(x, z))
&& !input.protos.contains_key(&(x, z))
&& !input.finishing.contains(&(x, z))
};
for (center, radius) in areas {
let r = radius + margin.max(reach + 1);
for dz in -r..=r {
for dx in -r..=r {
let (x, z) = (center.0 + dx, center.1 + dz);
if (input.ranges.in_range(x, z, margin)
|| input.ranges.near_finished(x, z, reach))
&& missing(x, z)
{
wanted.push((input.ranges.distance(x, z), x, z));
}
}
}
}
for &(fx, fz) in input.ranges.forced {
for dz in -(margin - 1)..=(margin - 1) {
for dx in -(margin - 1)..=(margin - 1) {
let (x, z) = (fx + dx, fz + dz);
if missing(x, z) {
wanted.push((input.ranges.distance(x, z), x, z));
}
}
}
}
wanted.sort_unstable();
wanted.dedup();
wanted.truncate(capacity - input.generating.len());
wanted
}
#[test]
fn generation_gate_tracks_public_ranges_and_exact_membership() {
for decorates in [false, true] {
let mut f = GenerationFixture::new(2, decorates);
f.fill();
let mut gate = GenerationScan::default();
assert!(f.assert_same(&mut gate, 6));
assert!(!f.assert_same(&mut gate, 6));
// Light/mesh state and block content do not affect generation.
f.world.dirty_light.insert((0, 0));
f.world.dirty_sections.insert((0, 0, 0));
Arc::make_mut(f.world.chunks.get_mut(&(0, 0)).unwrap()).lit = true;
assert!(!f.assert_same(&mut gate, 6));
// Same-count removal/replacement must not leave a hole unnoticed.
let chunk = f.world.chunks.remove(&(0, 0)).unwrap();
f.world.chunks.insert((100, 100), chunk.clone());
assert!(f.assert_same(&mut gate, 6));
f.world.chunks.remove(&(100, 100));
f.world.chunks.insert((0, 0), chunk);
assert!(f.assert_same(&mut gate, 6));
assert!(!f.assert_same(&mut gate, 6));
f.center = (1, -1);
assert!(f.assert_same(&mut gate, 6));
f.fill();
f.assert_same(&mut gate, 6);
f.radius += 1;
assert!(f.assert_same(&mut gate, 6));
f.fill();
f.assert_same(&mut gate, 6);
f.extra.push(((20, -20), 2));
assert!(f.assert_same(&mut gate, 6));
f.fill();
f.assert_same(&mut gate, 6);
f.forced.insert((40, 40));
assert!(f.assert_same(&mut gate, 6));
f.fill();
f.assert_same(&mut gate, 6);
f.extra.clear();
f.forced.clear();
assert!(f.assert_same(&mut gate, 6));
f.remote = true;
assert!(!f.assert_same(&mut gate, 6));
f.world.chunks.clear();
f.remote = false;
assert!(f.assert_same(&mut gate, 6));
f.fill();
f.assert_same(&mut gate, 6);
f.world = World::new(horizon_world::registry::BlockStateId(0), 1);
assert!(f.assert_same(&mut gate, 6));
}
}
#[test]
fn generation_gate_preserves_batches_stage_transitions_and_retries() {
for decorates in [false, true] {
let mut f = GenerationFixture::new(2, decorates);
let mut gate = GenerationScan::default();
let mut scheduled = FxHashSet::default();
loop {
let expected = baseline_candidates(f.input(), 3);
let batch = gate.candidates(f.input(), 3).unwrap_or_default();
assert_eq!(batch, expected);
if batch.is_empty() {
break;
}
for (_, x, z) in batch {
assert!(scheduled.insert((x, z)), "duplicate scheduling");
f.generating.insert((x, z));
}
if f.generating.len() == 3 {
assert!(!f.assert_same(&mut gate, 3)); // capacity pressure
} else {
f.assert_same(&mut gate, 3);
}
for key in std::mem::take(&mut f.generating) {
// load miss -> proto -> checked out decoration -> finish.
f.protos.insert(key, None);
}
gate.exhausted = false; // Done::Proto/Decorated invalidation
}
assert!(!scheduled.is_empty());
assert!(!f.assert_same(&mut gate, 3));
let key = (0, 0);
f.protos.remove(&key);
f.finishing.insert(key);
assert!(!f.assert_same(&mut gate, 3)); // eligibility unchanged
f.finishing.remove(&key);
f.world.chunks.insert(key, Arc::new(Chunk::empty(0, 0)));
gate.exhausted = false; // Done::Generated
assert!(f.assert_same(&mut gate, 3));
assert!(!f.assert_same(&mut gate, 3));
// A discarded/pruned proto must be requested again, even without
// any range or loaded-membership change.
let key = scheduled.iter().copied().find(|k| *k != (0, 0)).unwrap();
f.protos.remove(&key);
gate.exhausted = false;
assert!(f.assert_same(&mut gate, 3));
assert_eq!(baseline_candidates(f.input(), 3).len(), 1);
}
}
#[test]
#[ignore = "reproducible actual generation scheduler benchmark; run with --ignored --nocapture"]
fn generation_scan_benchmark() {
use std::{hint::black_box, time::Instant};
let mut f = GenerationFixture::new(32, true);
f.fill_steady();
let mut gate = GenerationScan::default();
f.assert_same(&mut gate, 12);
assert!(!f.assert_same(&mut gate, 12));
const FRAMES: usize = 2000;
let measure = |optimized: bool, dynamic: bool| {
let mut samples = Vec::new();
let mut scans = 0;
let mut jobs = 0;
for repetition in 0..9 {
let mut fixture = GenerationFixture::new(32, true);
fixture.fill_steady();
let saved = fixture.world.chunks.get(&(0, 0)).unwrap().clone();
let mut gate = GenerationScan::default();
gate.candidates(fixture.input(), 12);
let start = Instant::now();
let mut count = 0;
let mut scheduled = 0;
for frame in 0..FRAMES {
// Remove a column, queue generation, then accept a saved
// load completion. Both schedulers see identical events.
if dynamic && frame % 32 == 0 {
fixture.world.chunks.remove(&(0, 0));
}
if dynamic && frame % 32 == 1 {
fixture.generating.remove(&(0, 0));
fixture.world.chunks.insert((0, 0), saved.clone());
gate.exhausted = false; // Done::Generated
}
let result = if optimized {
let result = gate.candidates(black_box(fixture.input()), 12);
count += usize::from(result.is_some());
result.unwrap_or_default()
} else {
count += 1;
baseline_candidates(black_box(fixture.input()), 12)
};
let expected_jobs = usize::from(dynamic && frame % 32 == 0);
assert_eq!(result.len(), expected_jobs);
for (_, x, z) in black_box(result) {
assert_eq!((x, z), (0, 0));
fixture.generating.insert((x, z));
scheduled += 1;
}
}
if repetition != 0 {
samples.push(start.elapsed().as_secs_f64() * 1e9 / FRAMES as f64);
}
assert!(fixture.generating.is_empty());
assert_eq!(fixture.world.chunks.len(), f.world.chunks.len());
scans = count;
jobs = scheduled;
}
samples.sort_by(f64::total_cmp);
(samples[samples.len() / 2], scans, jobs)
};
println!(
"radius=32 decorates=true positions/frame=5041 loaded_keys={} proto_keys={} frames/sample={FRAMES} warmup=1 measured=8",
f.world.chunks.len(),
f.protos.len()
);
for dynamic in [false, true] {
let (before, baseline_scans, before_jobs) = measure(false, dynamic);
let (after, optimized_scans, after_jobs) = measure(true, dynamic);
assert_eq!(before_jobs, after_jobs);
println!(
"dynamic={dynamic}: baseline={before:.1}ns/frame optimized={after:.1}ns/frame speedup={:.2}x scans={baseline_scans}/{optimized_scans} jobs={before_jobs}/{after_jobs}",
before / after
);
}
}
/// The ranges `Streaming::update` checks at each step, from generation to
/// light, must let every chunk the player spawns on finish and light: the
/// 3x3 columns around the player need their own 3x3 neighbours finished,
+52
View File
@@ -0,0 +1,52 @@
# Generation scan gate benchmark
`Streaming::update` now reuses an exhausted generation scan until its range,
loaded membership, or generation state changes. Pending candidates keep the
original per-frame bounded scheduling. Worker generation results and proto
pruning invalidate the gate; light/mesh results do not. Public extra/forced
ranges are compared exactly. `ChunkMap` tracks structural membership and map
replacement, while value edits and existing-key replacement keep its token.
The empty-extra/forced stationary gate is constant time; comparisons scale with
those auxiliary ranges when present.
Reproduce from the repository root:
```sh
CARGO_INCREMENTAL=0 CARGO_TARGET_DIR=/home/bob/Documents/horizon/target \
cargo test -p horizon-client --lib --profile fast generation_scan_benchmark \
-- --ignored --nocapture
```
The ignored test exercises the production candidate helper called by
`Streaming::update`, compared against an independent copy of the original
scan. It validates scheduled coordinates, job counts, and final membership
and in-flight state. It uses a radius-32 decorated world: 5,041 scanned square
positions, 3,409 finished resident keys and 552 outer-margin proto keys, capacity 12. Each sample runs 2,000
frames, with one warmup sample and eight measured samples; values below are
median nanoseconds per frame. The dynamic case removes the centre column every
32 frames, schedules regeneration, and accepts a saved-load completion on the
following frame.
Measured on Linux x86-64, AMD Ryzen 5 5600X, Rust 1.99.0
(`b940084d7`, 2026-09-28), optimized `fast` profile, 2026-10-14:
| Scenario | Original | Gated | Speedup | Scans per 2,000 frames | Jobs original/gated |
| --- | ---: | ---: | ---: | ---: | ---: |
| Stationary fully resident | 15,441.3 ns | 10.1 ns | 1,528.99x | 2,000 / 0 | 0 / 0 |
| Dynamic remove/load cycle | 14,794.3 ns | 1,004.8 ns | 14.72x | 2,000 / 126 | 63 / 63 |
This is a generation scheduling microbenchmark, not a full-update, FPS, worker
throughput, or startup benchmark. No prepared assets are needed: the fixture
shares an empty real `Chunk` under different resident keys and uses unit
protos because this path only reads membership. It excludes decoration execution, terrain generation, lighting,
meshing, uploads, and other per-frame scans. The stationary speedup reflects
replacing a completed scan with a small gate and should not be extrapolated to
overall game performance. Timing and assertion overhead are included in both
implementations. Memory cost is a u64 token per resident map plus a small gate
snapshot and copies of extra/forced range metadata; no resident-key cache.
Focused differential tests also cover movement, radius changes, extra and
forced areas, removal/replacement at unchanged count, entire world reset,
capacity pressure, staged generation membership, proto discard/retry, and
light/content edits that should leave the gate reusable. `ChunkMap` tests cover
structural tokens and value/light mutation separately.