From a01d47f28fc9ce3868d6954b46d0d1b3ed9be8a1 Mon Sep 17 00:00:00 2001 From: rdbtCVS <87152661+rdbtCVS@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:01:53 +0100 Subject: [PATCH] perf: share decoded block facts during client startup --- benchmarks/shared-json-startup-20261017.json | 93 ++++++++++++++ .../examples/shared_json_startup_profile.rs | 34 +++++ crates/horizon-client/src/blocks.rs | 116 ++++++++++++++++-- crates/horizon-client/src/blocks/decode.rs | 9 +- .../horizon-client/src/blocks/decode_tests.rs | 6 +- crates/horizon-client/src/game.rs | 8 +- .../horizon-client/src/mod_content_tests.rs | 76 +++++++++++- crates/horizon-world/src/level/data.rs | 93 +++++++++----- crates/horizon-world/src/level/mod.rs | 28 +++++ tools/profile_client_startup.py | 67 ++++++++++ 10 files changed, 476 insertions(+), 54 deletions(-) create mode 100644 benchmarks/shared-json-startup-20261017.json create mode 100644 crates/horizon-client/examples/shared_json_startup_profile.rs create mode 100644 tools/profile_client_startup.py diff --git a/benchmarks/shared-json-startup-20261017.json b/benchmarks/shared-json-startup-20261017.json new file mode 100644 index 0000000000..92f557b33b --- /dev/null +++ b/benchmarks/shared-json-startup-20261017.json @@ -0,0 +1,93 @@ +{ + "scope": "Assets + block facts + behaviours loader only; excludes full startup", + "resources": ".local/horizon-client/26.3", + "repeats": 5, + "metrics": { + "baseline": { + "phase_ms": { + "median": 4730.402, + "samples": [ + 3258.541, + 6539.764, + 4773.735, + 3980.032, + 4730.402 + ] + }, + "total_ms": { + "median": 6325.026, + "samples": [ + 6596.141, + 7627.893, + 6296.443, + 5435.408, + 6325.026 + ] + }, + "cpu_ms": { + "median": 3199.722, + "samples": [ + 3322.801, + 3199.722, + 3227.438, + 3042.687, + 3144.658 + ] + }, + "peak_rss_kib": { + "median": 820000, + "samples": [ + 818540, + 820400, + 819352, + 820000, + 820128 + ] + } + }, + "shared": { + "phase_ms": { + "median": 1180.588, + "samples": [ + 869.228, + 842.213, + 1180.588, + 1560.087, + 1728.575 + ] + }, + "total_ms": { + "median": 2260.709, + "samples": [ + 1632.489, + 1553.753, + 2260.709, + 2879.728, + 3388.639 + ] + }, + "cpu_ms": { + "median": 1603.393, + "samples": [ + 1568.288, + 1432.106, + 1603.393, + 1604.423, + 1619.02 + ] + }, + "peak_rss_kib": { + "median": 351728, + "samples": [ + 353256, + 353664, + 351728, + 350724, + 351668 + ] + } + } + }, + "build": "cargo build --release -p horizon-client --example shared_json_startup_profile", + "limitations": "Loader-only wall-clock timings under concurrent machine load; not full startup or time to first frame. RSS includes Assets::load." +} diff --git a/crates/horizon-client/examples/shared_json_startup_profile.rs b/crates/horizon-client/examples/shared_json_startup_profile.rs new file mode 100644 index 0000000000..6c906ea83f --- /dev/null +++ b/crates/horizon-client/examples/shared_json_startup_profile.rs @@ -0,0 +1,34 @@ +//! Profile the fact and behaviour table phase of client startup. +//! Run in separate processes for baseline and shared JSON modes to capture peak RSS. +use horizon_client::{assets::Assets, blocks::BlockFacts}; +use horizon_world::level::Behaviours; +use std::{path::Path, time::Instant}; + +fn main() -> Result<(), Box> { + 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 root = Path::new(&root); + let total = Instant::now(); + let assets = Assets::load(root)?; + let started = Instant::now(); + let (facts, behaviours) = if mode == "baseline" { + ( + BlockFacts::load(root, assets.registry)?, + Behaviours::load(assets.registry, root)?, + ) + } else { + 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)? + }; + println!("phase_ms={:.3}", started.elapsed().as_secs_f64() * 1000.0); + println!("total_ms={:.3}", total.elapsed().as_secs_f64() * 1000.0); + println!("states={}", facts.states.len()); + std::hint::black_box((facts, behaviours)); + Ok(()) +} diff --git a/crates/horizon-client/src/blocks.rs b/crates/horizon-client/src/blocks.rs index 521d389bc1..63d451437c 100644 --- a/crates/horizon-client/src/blocks.rs +++ b/crates/horizon-client/src/blocks.rs @@ -2,7 +2,10 @@ //! (`gameplay-facts.json`), indexed by registry state ID. mod decode; use crate::Result; -use horizon_world::registry::{BlockRegistry, BlockStateId}; +use horizon_world::{ + level::{Behaviours, GameplayStateSource}, + registry::{BlockRegistry, BlockStateId}, +}; use serde_json::Value; use std::{ collections::HashMap, @@ -113,6 +116,39 @@ pub struct BlockFacts { } impl BlockFacts { pub fn load(root: &Path, registry: &BlockRegistry) -> Result { + let behaviours: Value = serde_json::from_reader(BufReader::new(File::open( + root.join("block-behaviours.json"), + )?))?; + Self::load_with_behaviour_json(root, registry, &behaviours) + } + + pub fn load_with_behaviour_json( + root: &Path, + registry: &BlockRegistry, + behaviours: &Value, + ) -> Result { + Ok(Self::load_with_callback(root, registry, behaviours, |_| Ok(()))?.0) + } + + /// Build both tables from the same behaviour document and gameplay decoder. + pub fn load_with_behaviours( + root: &Path, + registry: &BlockRegistry, + behaviours: &Value, + ) -> Result<(Self, Behaviours)> { + Self::load_with_callback(root, registry, behaviours, |entries| { + Ok(Behaviours::load_with_gameplay( + registry, root, behaviours, entries, + )?) + }) + } + + fn load_with_callback( + root: &Path, + registry: &BlockRegistry, + behaviours: &Value, + callback: impl FnOnce(&[decode::Entry<'_>]) -> Result, + ) -> Result<(Self, T)> { let bytes = fs::read(root.join("gameplay-facts.json"))?; serde_json::from_slice::(&bytes)?; let raw: decode::Gameplay<'_> = serde_json::from_slice(&bytes)?; @@ -125,9 +161,6 @@ impl BlockFacts { return Err("gameplay facts do not match the block registry".into()); } // Class chains and per-state behaviour flags exported from the actual block objects. - let behaviours: Value = serde_json::from_reader(BufReader::new(File::open( - root.join("block-behaviours.json"), - )?))?; let behaviour_states = behaviours["states"] .as_array() .filter(|s| s.len() == registry.len()) @@ -221,13 +254,13 @@ impl BlockFacts { outline, outline_edges, name: intern(&state.name), - emission: entry.emission as u8, - dampening: entry.dampening as u8, + emission: entry.emission.as_f64().unwrap_or(0.0) as u8, + dampening: entry.dampening.as_f64().unwrap_or(0.0) as u8, skylight_down: entry.skylight_down, shape_occlusion: entry.shape_occlusion, can_occlude: entry.can_occlude, solid_render: entry.solid_render, - hardness: entry.hardness as f32, + hardness: entry.hardness.as_f64().unwrap_or(0.0) as f32, requires_tool: entry.requires_tool, replaceable: entry.replaceable, random_ticks: entry.random_ticks, @@ -264,12 +297,16 @@ impl BlockFacts { .ok_or("missing block behaviour class chain")?, }); } - Ok(Self { - states, - air: registry - .default_state("minecraft:air") - .ok_or("missing air")?, - }) + let extra = callback(entries)?; + Ok(( + Self { + states, + air: registry + .default_state("minecraft:air") + .ok_or("missing air")?, + }, + extra, + )) } pub fn get(&self, id: BlockStateId) -> &StateFacts { &self.states[id.0 as usize] @@ -281,3 +318,56 @@ impl BlockFacts { ) } } + +impl GameplayStateSource for decode::Entry<'_> { + fn flag(&self, name: &str) -> bool { + match name { + "replaceable" => self.replaceable, + "random_ticks" => self.random_ticks, + "solid_render" => self.solid_render, + "liquid" => self.liquid, + "block_entity" => self.block_entity, + "ignited_by_lava" => self.ignited_by_lava, + "can_occlude" => self.can_occlude, + "full_collision" => self.full_collision, + "skylight_down" => self.skylight_down, + "requires_tool" => self.requires_tool, + "shape_occlusion" => self.shape_occlusion, + _ => false, + } + } + fn unsigned(&self, name: &str) -> u64 { + match name { + "emission" => self.emission.as_u64().unwrap_or(0), + "dampening" => self.dampening.as_u64().unwrap_or(0), + _ => 0, + } + } + fn number(&self, name: &str) -> f64 { + match name { + "hardness" => self.hardness.as_f64().unwrap_or(0.0), + _ => 0.0, + } + } + fn text(&self, name: &str) -> &str { + match name { + "push" => self.push.as_deref().unwrap_or(""), + _ => "", + } + } + fn sound_number(&self, name: &str) -> f64 { + match name { + "volume" => self.sound.volume, + "pitch" => self.sound.pitch, + _ => 1.0, + } + } + fn sound_id(&self, name: &str) -> &str { + match name { + "place" => self.sound.place.id.as_deref().unwrap_or(""), + "break" => self.sound.r#break.id.as_deref().unwrap_or(""), + "fall" => self.sound.fall.id.as_deref().unwrap_or(""), + _ => "", + } + } +} diff --git a/crates/horizon-client/src/blocks/decode.rs b/crates/horizon-client/src/blocks/decode.rs index 967955f9e3..7feb3ac41c 100644 --- a/crates/horizon-client/src/blocks/decode.rs +++ b/crates/horizon-client/src/blocks/decode.rs @@ -155,17 +155,20 @@ object!(Sound<'a> { }); object!(Entry<'a> { id: Option = None => value(unsigned), - emission: f64 = 0.0 => value(number), - dampening: f64 = 0.0 => value(number), + emission: Value = Value::Null => typed, + dampening: Value = Value::Null => typed, skylight_down: bool = false => value(flag), shape_occlusion: bool = false => value(flag), can_occlude: bool = false => value(flag), solid_render: bool = false => value(flag), - hardness: f64 = 0.0 => value(number), + hardness: Value = Value::Null => typed, requires_tool: bool = false => value(flag), replaceable: bool = false => value(flag), random_ticks: bool = false => value(flag), block_entity: bool = false => value(flag), + liquid: bool = false => value(flag), + ignited_by_lava: bool = false => value(flag), + push: Option = None => value(text), render_shape: Option = None => value(text), full_collision: bool = false => value(flag), friction: f64 = 0.0 => value(number), diff --git a/crates/horizon-client/src/blocks/decode_tests.rs b/crates/horizon-client/src/blocks/decode_tests.rs index d9d0e14fd1..8fdb93f4f7 100644 --- a/crates/horizon-client/src/blocks/decode_tests.rs +++ b/crates/horizon-client/src/blocks/decode_tests.rs @@ -89,9 +89,9 @@ fn typed_fields_keep_defaults_duplicates_and_wrong_type_behavior() -> crate::Res assert_eq!(entries[1].id, None); let e = &entries[2]; assert_eq!(e.id, Some(2)); - assert_eq!(e.emission, 4.5); - assert_eq!(e.dampening, 0.0); - assert_eq!(e.hardness.to_bits(), (-0.0f64).to_bits()); + assert_eq!(e.emission.as_f64(), Some(4.5)); + assert_eq!(e.dampening.as_f64().unwrap_or(0.0), 0.0); + assert_eq!(e.hardness.as_f64().unwrap().to_bits(), (-0.0f64).to_bits()); assert!(e.skylight_down && e.outline_offset.unwrap_or(e.offset)); assert_eq!(e.item, None); assert_eq!(e.render_shape, None); diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index e552eb118d..01c3adb1ad 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -10169,7 +10169,11 @@ pub fn run() -> Result<()> { let root = options.resources.clone(); let assets = Arc::new(assets); log::info!("load assets: {:.2}s", started.elapsed().as_secs_f32()); - let facts = BlockFacts::load(&root, assets.registry)?; + 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 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()); @@ -10236,7 +10240,7 @@ 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::load(registry_owner.registry, &root)?, + behaviours, mob_types: MobTypes::load(&root)? .types .into_iter() diff --git a/crates/horizon-client/src/mod_content_tests.rs b/crates/horizon-client/src/mod_content_tests.rs index 6dd92ecfd8..88df3b5fbd 100644 --- a/crates/horizon-client/src/mod_content_tests.rs +++ b/crates/horizon-client/src/mod_content_tests.rs @@ -49,8 +49,13 @@ fn tags_merge_without_repeats() { } fn prepared() -> Option { - let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3"); - root.join("gameplay-facts.json").exists().then_some(root) + let manifest = Path::new(env!("CARGO_MANIFEST_DIR")); + [ + manifest.join("../../.local/horizon-client/26.3"), + manifest.join("../../../../.local/horizon-client/26.3"), + ] + .into_iter() + .find(|root| root.join("gameplay-facts.json").exists()) } /// A content mod folder: a full block, a door and an item-less glass from @@ -137,6 +142,28 @@ fn content_root_loads_with_every_table() { let old_facts = BlockFacts::load(&base, old).unwrap(); let facts = BlockFacts::load(&root, new).unwrap(); + let shared_json: serde_json::Value = serde_json::from_reader(std::io::BufReader::new( + fs::File::open(root.join("block-behaviours.json")).unwrap(), + )) + .unwrap(); + 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(); + 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!(shared_facts.get(state).emission, facts.get(state).emission); + assert_eq!(shared_facts.get(state).classes, facts.get(state).classes); + assert_eq!( + shared_behaviours.facts(state).flags, + independent_behaviours.facts(state).flags, + ); + assert_eq!( + shared_behaviours.data(state).sounds.place, + independent_behaviours.data(state).sounds.place, + ); + } + let (a, b) = (old_facts.get(iron), facts.get(ruby)); assert_eq!((b.hardness, b.emission, b.friction), (4.0, 3, 0.8)); assert_eq!(b.explosion_resistance, a.explosion_resistance); @@ -216,3 +243,48 @@ fn no_content_keeps_the_base_root() { } use horizon_world::registry::BlockStateId; + +#[test] +fn shared_behaviour_document_matches_independent_loaders() { + let Some(root) = prepared() else { return }; + let assets = Assets::load(&root).expect("assets"); + let doc: serde_json::Value = serde_json::from_reader(std::io::BufReader::new( + std::fs::File::open(root.join("block-behaviours.json")).unwrap(), + )) + .unwrap(); + let original = BlockFacts::load(&root, assets.registry).unwrap(); + let (shared, b) = BlockFacts::load_with_behaviours(&root, assets.registry, &doc).unwrap(); + assert_eq!(original.states.len(), shared.states.len()); + for (a, b) in original.states.iter().zip(&shared.states) { + assert_eq!(a.classes, b.classes); + assert_eq!(a.legacy_solid, b.legacy_solid); + assert_eq!(a.sturdy_full, b.sturdy_full); + assert_eq!(a.emission, b.emission); + assert_eq!(a.dampening, b.dampening); + assert_eq!(a.hardness, b.hardness); + assert_eq!(a.sounds.volume, b.sounds.volume); + assert_eq!(a.sounds.pitch, b.sounds.pitch); + } + let a = horizon_world::level::Behaviours::load(assets.registry, &root).unwrap(); + 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)); + assert_eq!(a_state.flags, b_state.flags); + assert_eq!(a_state.sturdy, b_state.sturdy); + assert_eq!(a_state.weak, b_state.weak); + assert_eq!(a_state.strong, b_state.strong); + assert_eq!(a_state.fluid, b_state.fluid); + assert_eq!(a_state.emission, b_state.emission); + assert_eq!(a_state.dampening, b_state.dampening); + assert_eq!(a_state.hardness, b_state.hardness); + assert_eq!(a_state.push, b_state.push); + assert_eq!(a_state.instrument, b_state.instrument); + assert_eq!(a_state.shape, b_state.shape); + let (a_sound, b_sound) = (&a.data(state).sounds, &b.data(state).sounds); + assert_eq!(a_sound.volume, b_sound.volume); + assert_eq!(a_sound.pitch, b_sound.pitch); + assert_eq!(a_sound.place, b_sound.place); + assert_eq!(a_sound.break_, b_sound.break_); + assert_eq!(a_sound.fall, b_sound.fall); + } +} diff --git a/crates/horizon-world/src/level/data.rs b/crates/horizon-world/src/level/data.rs index 455a2ed57b..42f8f75ded 100644 --- a/crates/horizon-world/src/level/data.rs +++ b/crates/horizon-world/src/level/data.rs @@ -1,6 +1,6 @@ //! Behaviour tables built once from the pinned client resources. use super::{ - Behaviours, + Behaviours, GameplayStateSource, classes::{Class, ClassSet, Interface}, states::{BlockId, PropKey, StateTable}, }; @@ -653,20 +653,37 @@ pub fn load_registry(root: &Path) -> io::Result { } pub(super) fn load(registry: &BlockRegistry, root: &Path) -> io::Result { + let behaviour = read_json(&root.join("block-behaviours.json"))?; + load_with_behaviour_json(registry, root, &behaviour) +} + +pub(super) fn load_with_behaviour_json( + registry: &BlockRegistry, + root: &Path, + behaviour: &Value, +) -> io::Result { + let gameplay = read_json(&root.join("gameplay-facts.json"))?; + let gameplay_states = gameplay["states"] + .as_array() + .ok_or_else(|| fail("missing gameplay states"))?; + load_with_gameplay(registry, root, behaviour, gameplay_states) +} + +pub(super) fn load_with_gameplay( + registry: &BlockRegistry, + root: &Path, + behaviour: &Value, + gameplay_states: &[G], +) -> io::Result { let mut table = StateTable::new(registry)?; let props = Props::resolve(&mut table); let named = Named::resolve(&table)?; - let behaviour = read_json(&root.join("block-behaviours.json"))?; - let gameplay = read_json(&root.join("gameplay-facts.json"))?; let blocks_json = behaviour["blocks"] .as_array() .ok_or_else(|| fail("missing behaviour blocks"))?; let states_json = behaviour["states"] .as_array() .ok_or_else(|| fail("missing behaviour states"))?; - let gameplay_states = gameplay["states"] - .as_array() - .ok_or_else(|| fail("missing gameplay states"))?; if blocks_json.len() != table.block_count() || states_json.len() != table.state_count() || gameplay_states.len() != table.state_count() @@ -798,7 +815,7 @@ pub(super) fn load(registry: &BlockRegistry, root: &Path) -> io::Result io::Result PushReaction::Push, "POPPED" => PushReaction::Popped, "IMMOVEABLE" => PushReaction::Immoveable, @@ -951,23 +964,13 @@ pub(super) fn load(registry: &BlockRegistry, root: &Path) -> io::Result Direction::Up, }; let default = table.default_state(block); - let sound = &gameplay_states[default.0 as usize]["sound"]; - let sound_id = |key: &str, strings: &mut HashMap>| { - interned( - strings, - sound - .get(key) - .and_then(|s| s.get("id")) - .and_then(Value::as_str) - .unwrap_or(""), - ) - }; + let sound = &gameplay_states[default.0 as usize]; let sounds = Arc::new(Sounds { - volume: sound.get("volume").and_then(Value::as_f64).unwrap_or(1.0) as f32, - pitch: sound.get("pitch").and_then(Value::as_f64).unwrap_or(1.0) as f32, - place: sound_id("place", &mut strings), - break_: sound_id("break", &mut strings), - fall: sound_id("fall", &mut strings), + volume: sound.sound_number("volume") as f32, + pitch: sound.sound_number("pitch") as f32, + place: interned(&mut strings, sound.sound_id("place")), + break_: interned(&mut strings, sound.sound_id("break")), + fall: interned(&mut strings, sound.sound_id("fall")), }); blocks.push(BlockData { class, @@ -1145,3 +1148,31 @@ fn instrument_index(name: &str) -> u8 { .position(|n| n.eq_ignore_ascii_case(name)) .map_or(0, |i| i as u8) } + +impl GameplayStateSource for Value { + fn flag(&self, name: &str) -> bool { + self.get(name).and_then(Value::as_bool).unwrap_or(false) + } + fn unsigned(&self, name: &str) -> u64 { + self.get(name).and_then(Value::as_u64).unwrap_or(0) + } + fn number(&self, name: &str) -> f64 { + self.get(name).and_then(Value::as_f64).unwrap_or(0.0) + } + fn text(&self, name: &str) -> &str { + self.get(name).and_then(Value::as_str).unwrap_or("") + } + fn sound_number(&self, name: &str) -> f64 { + self.get("sound") + .and_then(|s| s.get(name)) + .and_then(Value::as_f64) + .unwrap_or(1.0) + } + fn sound_id(&self, name: &str) -> &str { + self.get("sound") + .and_then(|s| s.get(name)) + .and_then(|s| s.get("id")) + .and_then(Value::as_str) + .unwrap_or("") + } +} diff --git a/crates/horizon-world/src/level/mod.rs b/crates/horizon-world/src/level/mod.rs index 00dd115db1..dbd877fc5a 100644 --- a/crates/horizon-world/src/level/mod.rs +++ b/crates/horizon-world/src/level/mod.rs @@ -431,6 +431,17 @@ pub fn raw_brightness(level: &L, pos: BlockPos, sky_dar (level.sky_light(pos) - sky_darken).max(level.block_light(pos)) } +/// Access only the gameplay fields needed by level behaviour tables. +/// Both the JSON fallback and the client's typed decoder implement this. +pub trait GameplayStateSource { + fn flag(&self, name: &str) -> bool; + fn unsigned(&self, name: &str) -> u64; + fn number(&self, name: &str) -> f64; + fn text(&self, name: &str) -> &str; + fn sound_number(&self, name: &str) -> f64; + fn sound_id(&self, name: &str) -> &str; +} + /// Immutable vanilla block-behaviour tables. Build once with [`Behaviours::load`]. pub struct Behaviours { pub table: StateTable, @@ -456,6 +467,23 @@ impl Behaviours { pub fn load(registry: &BlockRegistry, resources_dir: &Path) -> io::Result { data::load(registry, resources_dir) } + /// Reuse the client's typed gameplay projection while it is borrowed from the JSON bytes. + pub fn load_with_gameplay( + registry: &BlockRegistry, + resources_dir: &Path, + behaviour: &serde_json::Value, + gameplay: &[G], + ) -> io::Result { + data::load_with_gameplay(registry, resources_dir, behaviour, gameplay) + } + + pub fn load_with_behaviour_json( + registry: &BlockRegistry, + resources_dir: &Path, + behaviour: &serde_json::Value, + ) -> io::Result { + data::load_with_behaviour_json(registry, resources_dir, behaviour) + } pub fn facts(&self, state: BlockStateId) -> &StateFacts { &self.facts[state.0 as usize] } diff --git a/tools/profile_client_startup.py b/tools/profile_client_startup.py new file mode 100644 index 0000000000..2549146c76 --- /dev/null +++ b/tools/profile_client_startup.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Compare baseline and shared JSON parsing 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. +""" +import argparse +import json +import os +import statistics +import subprocess +import tempfile +from pathlib import Path + + +def run(binary, resources, mode): + with tempfile.TemporaryFile(mode="w+t") as output: + proc = subprocess.Popen([str(binary), str(resources), mode], stdout=output, stderr=subprocess.PIPE) + _, status, usage = os.wait4(proc.pid, 0) + proc.returncode = os.waitstatus_to_exitcode(status) + _, stderr = proc.communicate() + if os.waitstatus_to_exitcode(status): + raise RuntimeError(f"{mode}: {stderr.decode(errors='replace')}") + output.seek(0) + fields = dict(line.rstrip().split("=", 1) for line in output if "=" in line) + return { + "phase_ms": float(fields["phase_ms"]), + "total_ms": float(fields["total_ms"]), + "cpu_ms": (usage.ru_utime + usage.ru_stime) * 1000.0, + "peak_rss_kib": usage.ru_maxrss, + } + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--binary", type=Path, default=Path("target/release/examples/shared_json_startup_profile")) + parser.add_argument("--resources", type=Path, default=Path(".local/horizon-client/26.3")) + parser.add_argument("--repeats", type=int, default=5) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + if args.repeats < 1: + parser.error("--repeats must be positive") + 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": []} + for i in range(args.repeats): + for mode in (("baseline", "shared") if i % 2 == 0 else ("shared", "baseline")): + samples[mode].append(run(binary, resources, mode)) + result = { + "scope": "Assets + block facts + behaviours loader only; excludes full startup", + "resources": str(resources), + "repeats": args.repeats, + "metrics": {mode: {key: {"median": round(statistics.median(row[key] for row in rows), 3), + "samples": [round(row[key], 3) for row in rows]} + for key in rows[0]} for mode, rows in samples.items()}, + } + data = json.dumps(result, indent=2) + print(data) + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(data + "\n") + + +if __name__ == "__main__": + main()