perf: share decoded block facts during client startup

This commit is contained in:
rdbtCVS committed 2026-10-17 01:02:09 +01:00
1 parent e422bd138a
commit a01d47f28f
10 files changed
+476 -54

No files matched your search

@@ -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."
}
@@ -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<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 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(())
}
+103 -13
View File
@@ -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<Self> {
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<Self> {
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<T>(
root: &Path,
registry: &BlockRegistry,
behaviours: &Value,
callback: impl FnOnce(&[decode::Entry<'_>]) -> Result<T>,
) -> Result<(Self, T)> {
let bytes = fs::read(root.join("gameplay-facts.json"))?;
serde_json::from_slice::<decode::Validate>(&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(""),
_ => "",
}
}
}
+6 -3
View File
@@ -155,17 +155,20 @@ object!(Sound<'a> {
});
object!(Entry<'a> {
id: Option<u64> = 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<String> = None => value(text),
render_shape: Option<String> = None => value(text),
full_collision: bool = false => value(flag),
friction: f64 = 0.0 => value(number),
@@ -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);
+6 -2
View File
@@ -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()
+74 -2
View File
@@ -49,8 +49,13 @@ fn tags_merge_without_repeats() {
}
fn prepared() -> Option<PathBuf> {
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);
}
}
+62 -31
View File
@@ -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<BlockRegistry> {
}
pub(super) fn load(registry: &BlockRegistry, root: &Path) -> io::Result<Behaviours> {
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<Behaviours> {
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<G: GameplayStateSource>(
registry: &BlockRegistry,
root: &Path,
behaviour: &Value,
gameplay_states: &[G],
) -> io::Result<Behaviours> {
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<Behaviou
.as_array()
.ok_or_else(|| fail("invalid behaviour state row"))?;
let int = |i: usize| row.get(i).and_then(Value::as_i64).unwrap_or(0);
let gb = |key: &str| game.get(key).and_then(Value::as_bool).unwrap_or(false);
let gb = |key: &str| game.flag(key);
let mut flags = int(1) as u32 & 0x7f;
for (key, bit) in [
("replaceable", state_flags::REPLACEABLE),
@@ -832,14 +849,10 @@ pub(super) fn load(registry: &BlockRegistry, root: &Path) -> io::Result<Behaviou
weak: int(5) as u32,
strong: int(6) as u32,
fluid,
emission: game.get("emission").and_then(Value::as_u64).unwrap_or(0) as u8,
dampening: game.get("dampening").and_then(Value::as_u64).unwrap_or(0) as u8,
hardness: game.get("hardness").and_then(Value::as_f64).unwrap_or(0.0) as f32,
push: match game
.get("push")
.and_then(Value::as_str)
.unwrap_or("PUSH_PULL")
{
emission: game.unsigned("emission") as u8,
dampening: game.unsigned("dampening") as u8,
hardness: game.number("hardness") as f32,
push: match game.text("push") {
"PUSH" => PushReaction::Push,
"POPPED" => PushReaction::Popped,
"IMMOVEABLE" => PushReaction::Immoveable,
@@ -951,23 +964,13 @@ pub(super) fn load(registry: &BlockRegistry, root: &Path) -> io::Result<Behaviou
_ => 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<String, Arc<str>>| {
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("")
}
}
+28
View File
@@ -431,6 +431,17 @@ pub fn raw_brightness<L: LevelAccess + ?Sized>(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<Self> {
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<G: GameplayStateSource>(
registry: &BlockRegistry,
resources_dir: &Path,
behaviour: &serde_json::Value,
gameplay: &[G],
) -> io::Result<Self> {
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<Self> {
data::load_with_behaviour_json(registry, resources_dir, behaviour)
}
pub fn facts(&self, state: BlockStateId) -> &StateFacts {
&self.facts[state.0 as usize]
}
+67
View File
@@ -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()