perf: defer behaviour construction and test fresh resource loads

This commit is contained in:
rdbtCVS committed 2026-10-17 01:18:24 +01:00
1 parent a01d47f28f
commit cf837118e9
7 files changed
+487 -16

No files matched your search

@@ -0,0 +1,227 @@
{
"scope": "Assets + block facts + behaviours loader only; excludes full startup",
"resources": ".local/horizon-client/26.3",
"repeats": 5,
"metrics": {
"baseline": {
"phase_ms": {
"median": 1996.921,
"samples": [
2213.335,
1453.207,
1637.901,
1996.921,
2010.335
],
"range": [
1453.207,
2213.335
]
},
"total_ms": {
"median": 4728.136,
"samples": [
5628.975,
2147.329,
2162.696,
4728.136,
6404.524
],
"range": [
2147.329,
6404.524
]
},
"cpu_ms": {
"median": 2514.776,
"samples": [
2919.908,
2088.239,
2212.892,
2716.104,
2514.776
],
"range": [
2088.239,
2919.908
]
},
"peak_rss_kib": {
"median": 819824,
"samples": [
819824,
820872,
820744,
818980,
818816
]
}
},
"pr607": {
"phase_ms": {
"median": 586.707,
"samples": [
464.424,
739.67,
586.707,
1373.307,
509.266
],
"range": [
464.424,
1373.307
]
},
"total_ms": {
"median": 1128.6,
"samples": [
954.73,
1764.614,
1128.6,
4312.528,
1030.735
],
"range": [
954.73,
4312.528
]
},
"cpu_ms": {
"median": 1145.826,
"samples": [
981.058,
1465.79,
1145.826,
1736.807,
1056.992
],
"range": [
981.058,
1736.807
]
},
"peak_rss_kib": {
"median": 351900,
"samples": [
352164,
351816,
351900,
352348,
350492
]
}
},
"shared": {
"phase_ms": {
"median": 560.117,
"samples": [
522.284,
1255.75,
560.117,
2151.064,
479.329
],
"range": [
479.329,
2151.064
]
},
"total_ms": {
"median": 1101.305,
"samples": [
1011.715,
2565.449,
1101.305,
2960.186,
1006.817
],
"range": [
1006.817,
2960.186
]
},
"cpu_ms": {
"median": 1128.214,
"samples": [
1035.19,
1565.383,
1128.214,
1494.36,
1031.198
],
"range": [
1031.198,
1565.383
]
},
"peak_rss_kib": {
"median": 351532,
"samples": [
351624,
350540,
351532,
351716,
350076
]
}
},
"deferred": {
"phase_ms": {
"median": 564.046,
"samples": [
433.35,
665.149,
587.456,
564.046,
497.422
],
"range": [
433.35,
665.149
]
},
"total_ms": {
"median": 1143.129,
"samples": [
885.053,
1143.129,
1195.869,
1074.27,
1162.103
],
"range": [
885.053,
1195.869
]
},
"cpu_ms": {
"median": 1114.261,
"samples": [
910.946,
1128.96,
1194.638,
1114.261,
1073.823
],
"range": [
910.946,
1194.638
]
},
"peak_rss_kib": {
"median": 273776,
"samples": [
274320,
273860,
272548,
273776,
272520
]
}
}
},
"baseline_commit": "e422bd13",
"pr607_commit": "a01d47f2",
"measurement": "same optimized binary, isolated processes in alternating forward/reverse four-mode runs",
"limitations": "Includes Assets::load, BlockFacts, Behaviours; excludes complete client startup, pack/mod root resolution, graphics and first frame. Different modes emulate the original independent, PR #607 at a01d47f2, paired and deferred startup sequences in the same release build. Machine was not idle. Elapsed times show high variance; no statistically established gain between PR, paired and deferred modes."
}
@@ -1,5 +1,7 @@
//! Profile the fact and behaviour table phase of client startup.
//! Run in separate processes for baseline and shared JSON modes to capture peak RSS.
//! Run each mode in a separate process to capture CPU usage and peak RSS.
//! The deferred mode evaluates both phases contiguously for fair loader comparison;
//! actual client startup performs other initialization between these phases.
use horizon_client::{assets::Assets, blocks::BlockFacts};
use horizon_world::level::Behaviours;
use std::{path::Path, time::Instant};
@@ -7,9 +9,9 @@ use std::{path::Path, time::Instant};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut args = std::env::args().skip(1);
let root = args.next().ok_or("expected resource directory")?;
let mode = args.next().ok_or("expected baseline or shared")?;
if mode != "baseline" && mode != "shared" {
return Err("expected baseline or shared".into());
let mode = args.next().ok_or("expected baseline, pr607 or shared")?;
if !["baseline", "pr607", "shared", "deferred"].contains(&mode.as_str()) {
return Err("expected baseline, pr607 or shared".into());
}
let root = Path::new(&root);
let total = Instant::now();
@@ -20,11 +22,21 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
BlockFacts::load(root, assets.registry)?,
Behaviours::load(assets.registry, root)?,
)
} else {
} else if mode == "pr607" {
// Original PR #607 sequence at a01d47f2, retained as a same-build comparator.
let json: serde_json::Value = serde_json::from_reader(std::io::BufReader::new(
std::fs::File::open(root.join("block-behaviours.json"))?,
))?;
BlockFacts::load_with_behaviours(root, assets.registry, &json)?
} else if mode == "shared" {
BlockFacts::load_pair(root, assets.registry)?
} else {
let json: serde_json::Value = serde_json::from_reader(std::io::BufReader::new(
std::fs::File::open(root.join("block-behaviours.json"))?,
))?;
let (facts, gameplay) = BlockFacts::load_with_projection(root, assets.registry, &json)?;
let behaviours = Behaviours::load_with_gameplay(assets.registry, root, &json, &gameplay)?;
(facts, behaviours)
};
println!("phase_ms={:.3}", started.elapsed().as_secs_f64() * 1000.0);
println!("total_ms={:.3}", total.elapsed().as_secs_f64() * 1000.0);
+121
View File
@@ -110,6 +110,107 @@ impl StateFacts {
self.classes.iter().any(|c| &**c == name)
}
}
/// Compact, owned behaviour inputs retained until the normal behaviour startup stage.
/// No borrowed gameplay JSON or process-wide cache survives the fact loader.
pub struct GameplayProjection {
flags: u16,
emission: u64,
dampening: u64,
hardness: f64,
push: Box<str>,
volume: f64,
pitch: f64,
place: Box<str>,
break_sound: Box<str>,
fall: Box<str>,
}
impl GameplayProjection {
fn from_entry(entry: &decode::Entry<'_>) -> Self {
const FLAGS: [&str; 11] = [
"replaceable",
"random_ticks",
"solid_render",
"liquid",
"block_entity",
"ignited_by_lava",
"can_occlude",
"full_collision",
"skylight_down",
"requires_tool",
"shape_occlusion",
];
Self {
flags: FLAGS.iter().enumerate().fold(0, |flags, (i, name)| {
flags | ((u16::from(entry.flag(name))) << i)
}),
emission: entry.unsigned("emission"),
dampening: entry.unsigned("dampening"),
hardness: entry.number("hardness"),
push: entry.text("push").into(),
volume: entry.sound_number("volume"),
pitch: entry.sound_number("pitch"),
place: entry.sound_id("place").into(),
break_sound: entry.sound_id("break").into(),
fall: entry.sound_id("fall").into(),
}
}
}
impl GameplayStateSource for GameplayProjection {
fn flag(&self, name: &str) -> bool {
const FLAGS: [&str; 11] = [
"replaceable",
"random_ticks",
"solid_render",
"liquid",
"block_entity",
"ignited_by_lava",
"can_occlude",
"full_collision",
"skylight_down",
"requires_tool",
"shape_occlusion",
];
FLAGS
.iter()
.position(|field| *field == name)
.is_some_and(|index| self.flags & (1 << index) != 0)
}
fn unsigned(&self, name: &str) -> u64 {
match name {
"emission" => self.emission,
"dampening" => self.dampening,
_ => 0,
}
}
fn number(&self, name: &str) -> f64 {
if name == "hardness" {
self.hardness
} else {
0.0
}
}
fn text(&self, name: &str) -> &str {
if name == "push" { &self.push } else { "" }
}
fn sound_number(&self, name: &str) -> f64 {
match name {
"volume" => self.volume,
"pitch" => self.pitch,
_ => 1.0,
}
}
fn sound_id(&self, name: &str) -> &str {
match name {
"place" => &self.place,
"break" => &self.break_sound,
"fall" => &self.fall,
_ => "",
}
}
}
pub struct BlockFacts {
pub states: Vec<StateFacts>,
pub air: BlockStateId,
@@ -122,6 +223,26 @@ impl BlockFacts {
Self::load_with_behaviour_json(root, registry, &behaviours)
}
/// Load both tables from one resource root, sharing each JSON decode.
/// No data is cached across calls: changes to prepared packs or mods are read again.
pub fn load_pair(root: &Path, registry: &BlockRegistry) -> Result<(Self, Behaviours)> {
let behaviours: Value = serde_json::from_reader(BufReader::new(File::open(
root.join("block-behaviours.json"),
)?))?;
Self::load_with_behaviours(root, registry, &behaviours)
}
/// Decode state facts once, retaining only the small owned fields needed later.
pub fn load_with_projection(
root: &Path,
registry: &BlockRegistry,
behaviours: &Value,
) -> Result<(Self, Vec<GameplayProjection>)> {
Self::load_with_callback(root, registry, behaviours, |entries| {
Ok(entries.iter().map(GameplayProjection::from_entry).collect())
})
}
pub fn load_with_behaviour_json(
root: &Path,
registry: &BlockRegistry,
+8 -3
View File
@@ -10172,8 +10172,8 @@ pub fn run() -> Result<()> {
let behaviour_json: serde_json::Value = serde_json::from_reader(std::io::BufReader::new(
std::fs::File::open(root.join("block-behaviours.json"))?,
))?;
let (facts, behaviours) =
BlockFacts::load_with_behaviours(&root, assets.registry, &behaviour_json)?;
let (facts, gameplay_projection) =
BlockFacts::load_with_projection(&root, assets.registry, &behaviour_json)?;
let mob_data = horizon_client::mobs::MobData::load(&root, &facts);
horizon_client::mobs::trial_spawner::load_registry(&root);
log::info!("load facts: {:.2}s", started.elapsed().as_secs_f32());
@@ -10240,7 +10240,12 @@ pub fn run() -> Result<()> {
ambient: horizon_client::ambience::AmbientTables::load(&root),
nether_environment: Environment::load_dimension(&root, "the_nether", false)?,
end_environment: Environment::load_dimension(&root, "the_end", false)?,
behaviours,
behaviours: Behaviours::load_with_gameplay(
registry_owner.registry,
&root,
&behaviour_json,
&gameplay_projection,
)?,
mob_types: MobTypes::load(&root)?
.types
.into_iter()
@@ -149,9 +149,27 @@ fn content_root_loads_with_every_table() {
let (shared_facts, shared_behaviours) =
BlockFacts::load_with_behaviours(&root, new, &shared_json).unwrap();
let independent_behaviours = horizon_world::level::Behaviours::load(new, &root).unwrap();
let (deferred_facts, gameplay) =
BlockFacts::load_with_projection(&root, new, &shared_json).unwrap();
let deferred_behaviours =
horizon_world::level::Behaviours::load_with_gameplay(new, &root, &shared_json, &gameplay)
.unwrap();
assert_eq!(shared_facts.states.len(), facts.states.len());
for state in [ruby, ruby_door, BlockStateId(n + 65)] {
assert_eq!(shared_facts.get(state).hardness, facts.get(state).hardness);
assert_eq!(
deferred_facts.get(state).hardness,
facts.get(state).hardness
);
assert_eq!(deferred_facts.get(state).classes, facts.get(state).classes);
assert_eq!(
deferred_behaviours.facts(state).flags,
independent_behaviours.facts(state).flags
);
assert_eq!(
deferred_behaviours.data(state).sounds.place,
independent_behaviours.data(state).sounds.place
);
assert_eq!(shared_facts.get(state).emission, facts.get(state).emission);
assert_eq!(shared_facts.get(state).classes, facts.get(state).classes);
assert_eq!(
@@ -254,6 +272,16 @@ fn shared_behaviour_document_matches_independent_loaders() {
.unwrap();
let original = BlockFacts::load(&root, assets.registry).unwrap();
let (shared, b) = BlockFacts::load_with_behaviours(&root, assets.registry, &doc).unwrap();
let (deferred_facts, projection) =
BlockFacts::load_with_projection(&root, assets.registry, &doc).unwrap();
let deferred = horizon_world::level::Behaviours::load_with_gameplay(
assets.registry,
&root,
&doc,
&projection,
)
.unwrap();
assert_eq!(deferred_facts.states.len(), shared.states.len());
assert_eq!(original.states.len(), shared.states.len());
for (a, b) in original.states.iter().zip(&shared.states) {
assert_eq!(a.classes, b.classes);
@@ -269,6 +297,12 @@ fn shared_behaviour_document_matches_independent_loaders() {
for id in 0..assets.registry.len() {
let state = horizon_world::registry::BlockStateId(id as u32);
let (a_state, b_state) = (a.facts(state), b.facts(state));
let deferred_state = deferred.facts(state);
assert_eq!(b_state.flags, deferred_state.flags);
assert_eq!(b_state.emission, deferred_state.emission);
assert_eq!(b_state.dampening, deferred_state.dampening);
assert_eq!(b_state.hardness, deferred_state.hardness);
assert_eq!(b_state.push, deferred_state.push);
assert_eq!(a_state.flags, b_state.flags);
assert_eq!(a_state.sturdy, b_state.sturdy);
assert_eq!(a_state.weak, b_state.weak);
@@ -288,3 +322,73 @@ fn shared_behaviour_document_matches_independent_loaders() {
assert_eq!(a_sound.fall, b_sound.fall);
}
}
#[cfg(unix)]
#[test]
fn independent_reloads_read_changed_behaviour_json_and_report_invalid_files() {
let Some(base) = prepared() else { return };
let scratch = std::env::temp_dir().join(format!("horizon-json-reloads-{}", std::process::id()));
let _ = fs::remove_dir_all(&scratch);
fs::create_dir_all(&scratch).unwrap();
// Keep the remaining resource tree intact without copying a huge prepared client.
for entry in fs::read_dir(&base).unwrap() {
let entry = entry.unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(entry.path(), scratch.join(entry.file_name())).unwrap();
}
let assets = Assets::load(&base).unwrap();
let registry = assets.registry;
let original = fs::read(base.join("block-behaviours.json")).unwrap();
fs::remove_file(scratch.join("block-behaviours.json")).unwrap();
fs::write(scratch.join("block-behaviours.json"), &original).unwrap();
let (facts, _) = BlockFacts::load_pair(&scratch, registry).unwrap();
assert_eq!(facts.states.len(), registry.len());
assert_eq!(
BlockFacts::load(&scratch, registry).unwrap().states.len(),
registry.len()
);
assert!(horizon_world::level::Behaviours::load(registry, &scratch).is_ok());
fs::write(scratch.join("block-behaviours.json"), b"{").unwrap();
assert!(BlockFacts::load_pair(&scratch, registry).is_err());
assert!(BlockFacts::load(&scratch, registry).is_err());
assert!(horizon_world::level::Behaviours::load(registry, &scratch).is_err());
fs::write(scratch.join("block-behaviours.json"), b"{}").unwrap();
assert!(BlockFacts::load_pair(&scratch, registry).is_err());
assert!(BlockFacts::load(&scratch, registry).is_err());
assert!(horizon_world::level::Behaviours::load(registry, &scratch).is_err());
fs::write(scratch.join("block-behaviours.json"), original).unwrap();
assert_eq!(
BlockFacts::load_pair(&scratch, registry)
.unwrap()
.0
.states
.len(),
registry.len()
);
fs::remove_file(scratch.join("gameplay-facts.json")).unwrap();
fs::write(scratch.join("gameplay-facts.json"), b"{\"states\":[]}").unwrap();
assert!(BlockFacts::load_pair(&scratch, registry).is_err());
assert!(BlockFacts::load(&scratch, registry).is_err());
assert!(horizon_world::level::Behaviours::load(registry, &scratch).is_err());
fs::write(scratch.join("gameplay-facts.json"), b"{").unwrap();
assert!(BlockFacts::load_pair(&scratch, registry).is_err());
assert!(BlockFacts::load(&scratch, registry).is_err());
assert!(horizon_world::level::Behaviours::load(registry, &scratch).is_err());
fs::write(
scratch.join("gameplay-facts.json"),
fs::read(base.join("gameplay-facts.json")).unwrap(),
)
.unwrap();
assert_eq!(
BlockFacts::load_pair(&scratch, registry)
.unwrap()
.0
.states
.len(),
registry.len()
);
fs::remove_dir_all(&scratch).unwrap();
}
@@ -155,7 +155,7 @@ mod tick_parity {
position::{BlockPos, ChunkPos},
random::{LegacyRandom, RandomSource},
};
use horizon_world::level::{Behaviours, LevelAccess, LevelEvent, Ticks, data};
use horizon_world::level::{LevelAccess, LevelEvent, Ticks, data};
use std::{cell::RefCell, path::PathBuf};
#[derive(Clone)]
@@ -218,8 +218,7 @@ mod tick_parity {
return;
}
let registry = data::load_registry(&root).unwrap();
let mut facts = BlockFacts::load(&root, &registry).unwrap();
let behaviours = Behaviours::load(&registry, &root).unwrap();
let (mut facts, behaviours) = BlockFacts::load_pair(&root, &registry).unwrap();
let grass = registry.default_state("minecraft:grass_block").unwrap();
let chunk = |x| {
let mut c = Chunk::empty(x, 0);
+8 -5
View File
@@ -1,9 +1,12 @@
#!/usr/bin/env python3
"""Compare baseline and shared JSON parsing in the client startup table phase.
"""Compare four JSON loading sequences in the client startup table phase.
Build first with `cargo build --release -p horizon-client --example shared_json_startup_profile`.
This covers Assets::load plus BlockFacts::load and Behaviours::load, not full game
startup, rendering, resource pack setup, or first frame. Peak RSS includes Assets.
Modes: baseline uses independent public loaders, pr607 reproduces a01d47f2,
shared uses the paired wrapper, and deferred retains the projection until the
normal behaviour stage. All modes use one optimized executable. This covers
Assets::load plus BlockFacts and Behaviours loading; it excludes full startup,
rendering, resource-pack resolution and first frame. Peak RSS includes Assets.
"""
import argparse
import json
@@ -44,9 +47,9 @@ def main():
binary, resources = args.binary.resolve(), args.resources.resolve()
if not binary.is_file() or not resources.is_dir():
parser.error("binary or resources missing; build the example and prepare resources first")
samples = {"baseline": [], "shared": []}
samples = {"baseline": [], "pr607": [], "shared": [], "deferred": []}
for i in range(args.repeats):
for mode in (("baseline", "shared") if i % 2 == 0 else ("shared", "baseline")):
for mode in (("baseline", "pr607", "shared", "deferred") if i % 2 == 0 else ("deferred", "shared", "pr607", "baseline")):
samples[mode].append(run(binary, resources, mode))
result = {
"scope": "Assets + block facts + behaviours loader only; excludes full startup",