perf(client): share parsed block and item models

This commit is contained in:
rdbtCVS committed 2026-10-15 13:20:49 +01:00
1 parent 89b58991bb
commit 7b8189d19c
12 files changed
+563 -75

No files matched your search

+36 -18
View File
@@ -50,29 +50,47 @@ impl ModelLibrary {
/// A library that also knows `builtin/generated`, the parent of every
/// flat item model; the native client registers it as a special model.
pub fn with_item_generator(mut models: BTreeMap<String, CuboidModel>) -> Result<Self> {
models.insert(
GENERATED_MODEL.to_owned(),
CuboidModel {
parent: None,
ambient_occlusion: None,
gui_light: Some(GuiLight::Front),
textures: BTreeMap::from([(
"particle".to_owned(),
TextureSlot::Reference("layer0".to_owned()),
)]),
elements: None,
display: None,
},
);
models.insert(GENERATED_MODEL.to_owned(), Self::item_generator_model());
Self::new(models)
}
/// Register the item built-in while reusing already decoded resources.
pub fn with_shared_item_generator(
mut models: BTreeMap<String, Arc<CuboidModel>>,
) -> Result<Self> {
models.insert(
GENERATED_MODEL.to_owned(),
Arc::new(Self::item_generator_model()),
);
Self::from_shared(models)
}
fn item_generator_model() -> CuboidModel {
CuboidModel {
parent: None,
ambient_occlusion: None,
gui_light: Some(GuiLight::Front),
textures: BTreeMap::from([(
"particle".to_owned(),
TextureSlot::Reference("layer0".to_owned()),
)]),
elements: None,
display: None,
}
}
pub fn new(models: BTreeMap<String, CuboidModel>) -> Result<Self> {
Self::from_models(
models
.into_iter()
.map(|(name, model)| (name, Arc::new(model))),
)
}
/// Build an immutable resolution view without copying model geometry.
pub fn from_shared(models: BTreeMap<String, Arc<CuboidModel>>) -> Result<Self> {
Self::from_models(models)
}
fn from_models(models: impl IntoIterator<Item = (String, Arc<CuboidModel>)>) -> Result<Self> {
let mut decoded = BTreeMap::new();
for (name, model) in models {
if decoded
.insert(identifier(&name)?, Arc::new(model))
.is_some()
{
if decoded.insert(identifier(&name)?, model).is_some() {
return Err(error("duplicate normalized model identifier"));
}
}
@@ -0,0 +1,129 @@
//! Full block/assets + item startup. Compile with HORIZON_BENCH_COUNT_ALLOCATIONS=1
//! for live requested heap bytes; leave it unset for latency measurements.
//! cargo run --profile fast -p horizon-client --example model_library_bench -- ROOT [--parity]
//! Run fresh processes, warm up twice, then compare multiple alternating baseline/candidate runs.
use horizon_client::{assets::Assets, item_render::ItemRenderer};
use std::{
alloc::{GlobalAlloc, Layout, System},
hash::{Hash, Hasher},
path::Path,
sync::atomic::{AtomicUsize, Ordering::Relaxed},
time::Instant,
};
struct CountingAllocator;
const TRACK: bool = option_env!("HORIZON_BENCH_COUNT_ALLOCATIONS").is_some();
static LIVE: AtomicUsize = AtomicUsize::new(0);
// The allocator delegates every operation and layout unchanged to System.
unsafe impl GlobalAlloc for CountingAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
let ptr = unsafe { System.alloc(layout) };
if TRACK && !ptr.is_null() {
LIVE.fetch_add(layout.size(), Relaxed);
}
ptr
}
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
let ptr = unsafe { System.alloc_zeroed(layout) };
if TRACK && !ptr.is_null() {
LIVE.fetch_add(layout.size(), Relaxed);
}
ptr
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
if TRACK {
LIVE.fetch_sub(layout.size(), Relaxed);
}
unsafe { System.dealloc(ptr, layout) };
}
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, size: usize) -> *mut u8 {
let out = unsafe { System.realloc(ptr, layout, size) };
if TRACK && !out.is_null() {
LIVE.fetch_sub(layout.size(), Relaxed);
LIVE.fetch_add(size, Relaxed);
}
out
}
}
#[global_allocator]
static ALLOCATOR: CountingAllocator = CountingAllocator;
fn main() -> horizon_client::Result<()> {
let root = std::env::args()
.nth(1)
.ok_or("usage: model_library_bench ROOT [--parity]")?;
let root = Path::new(&root);
let initial = LIVE.load(Relaxed);
let start = Instant::now();
let assets = Assets::load(root)?;
let block_ms = start.elapsed().as_secs_f64() * 1000.;
let block_retained = LIVE.load(Relaxed) - initial;
let start_items = Instant::now();
let items = ItemRenderer::load_with_library(root, assets.model_library.clone())?;
let item_ms = start_items.elapsed().as_secs_f64() * 1000.;
let total_ms = start.elapsed().as_secs_f64() * 1000.;
let retained = LIVE.load(Relaxed) - initial;
println!(
"block_ms={block_ms:.3} item_ms={item_ms:.3} total_ms={total_ms:.3} retained_bytes={retained} block_retained_bytes={block_retained}"
);
if std::env::args().any(|a| a == "--parity") {
let mut hash = std::collections::hash_map::DefaultHasher::new();
for id in 0..assets.registry.len() {
let id = horizon_world::registry::BlockStateId(id as u32);
for pos in [
horizon_core::position::BlockPos::new(0, 0, 0),
horizon_core::position::BlockPos::new(-17, 65, 31),
] {
if let Ok(selected) = assets.model_catalog.collect(id, pos) {
for (variant, model) in selected {
format!("{variant:?}").hash(&mut hash);
model.ambient_occlusion.hash(&mut hash);
for face in &model.faces {
format!("{:?}", face.quad).hash(&mut hash);
face.layer.hash(&mut hash);
face.opaque.hash(&mut hash);
face.translucent.hash(&mut hash);
}
}
}
}
}
let mut names = Vec::new();
for ns in std::fs::read_dir(root.join("assets"))? {
let ns = ns?;
let base = ns.path().join("models");
let mut stack = vec![base.clone()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(dir) else {
continue;
};
for entry in entries {
let path = entry?.path();
if path.is_dir() {
stack.push(path);
} else if path.extension().is_some_and(|e| e == "json") {
names.push(format!(
"{}:{}",
ns.file_name().to_string_lossy(),
path.strip_prefix(&base)?
.with_extension("")
.to_string_lossy()
.replace('\\', "/")
));
}
}
}
}
names.sort();
for name in &names {
format!("{:?}", items.bake(name)).hash(&mut hash);
}
println!(
"parity_models={} fingerprint={:016x}",
names.len(),
hash.finish()
);
}
std::hint::black_box((&assets, &items));
Ok(())
}
+6 -1
View File
@@ -59,6 +59,8 @@ pub struct Assets {
pub pixels: Vec<u8>,
pub texture_layers: u32,
pub model_catalog: crate::models::StateModels,
/// Shared parsed resources; item-only validation errors surface at item preparation.
pub model_library: std::result::Result<Arc<horizon_assets::resolve::ModelLibrary>, String>,
pub model_placements: Vec<(horizon_core::position::BlockPos, BlockStateId)>,
pub model_gallery: Vec<Value>,
pub animations: Vec<crate::sprites::SpriteAnimation>,
@@ -219,7 +221,9 @@ impl Assets {
for name in MATERIALS {
sprites.insert(root, &format!("minecraft:block/{name}"))?;
}
let models = crate::models::prepare(root, registry, &mut sprites)?;
let parsed = crate::model_library::ParsedModels::load(root);
let models = crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?;
let model_library = parsed.into_item_library();
// Fluids are drawn by the fluid renderer, not block models; breaking
// progress uses the destroy-stage overlays.
for name in [
@@ -244,6 +248,7 @@ impl Assets {
texture_layers: u32::try_from(sprites.layers.len())?,
pixels: sprites.pixels,
model_catalog: models.catalog,
model_library,
model_placements: models.placements,
model_gallery: models.states,
animations: sprites.animations,
+7 -1
View File
@@ -9143,6 +9143,12 @@ pub fn run() -> Result<()> {
let animate =
horizon_client::animate_tick::AnimateTables::load(&root, registry_owner.registry, &biomes)?;
let fog = horizon_client::fog::FogRenderer::load(&root, &biomes);
let item_renderer = Arc::new(
horizon_client::item_render::ItemRenderer::load_with_library(
&root,
assets.model_library.clone(),
)?,
);
let mut resources = Resources {
stations,
assets,
@@ -9154,7 +9160,7 @@ pub fn run() -> Result<()> {
environment,
atlas,
panorama,
item_renderer: Arc::new(horizon_client::item_render::ItemRenderer::load(&root)?),
item_renderer,
layer_names,
providers: Providers::load(&root),
block_entity_render: horizon_client::block_entity_render::BlockEntityRenderer::load(
+4 -1
View File
@@ -115,7 +115,10 @@ impl Game<'_> {
let panorama = horizon_client::gfx::panorama::Faces::load(root);
atlas.font.uniform = self.settings.force_unicode;
atlas.font.japanese = self.settings.japanese_glyphs;
let item_renderer = horizon_client::item_render::ItemRenderer::load(root)?;
let item_renderer = horizon_client::item_render::ItemRenderer::load_with_library(
root,
assets.model_library.clone(),
)?;
let visibility = horizon_client::models::load_visibility(root, assets.registry)?;
let tables = std::sync::Arc::new(horizon_client::mesher::MeshTables::new(
&assets,
+14 -35
View File
@@ -22,7 +22,7 @@ use horizon_assets::{
ClientItem, Components, Context, DisplayContext, FoilType, ItemEnv, ItemModels,
LayerContent, LevelState, OwnerState, SpecialModel, StackView,
},
model::{CuboidModel, Direction},
model::Direction,
palette::PalettedPermutations,
resolve::{MISSING_MODEL, ModelLibrary},
};
@@ -370,7 +370,7 @@ impl FileSprites {
/// The baked item models and everything resolution needs.
pub struct ItemRenderer {
pub models: ItemModels,
library: ModelLibrary,
library: Arc<ModelLibrary>,
sprites: FileSprites,
baked: Mutex<HashMap<String, Arc<BakedItemModel>>>,
extents: Mutex<HashMap<String, Arc<Vec<Vec3>>>>,
@@ -382,37 +382,6 @@ pub struct ItemRenderer {
item_ids: HashMap<String, i32>,
}
/// Every `models/**.json` of a namespace directory, by identifier.
fn load_models(root: &Path) -> BTreeMap<String, CuboidModel> {
let mut out = BTreeMap::new();
let Ok(namespaces) = fs::read_dir(root.join("assets")) else {
return out;
};
for ns in namespaces.flatten() {
let namespace = ns.file_name().to_string_lossy().into_owned();
let base = ns.path().join("models");
let mut stack = vec![base.clone()];
while let Some(dir) = stack.pop() {
let Ok(entries) = fs::read_dir(&dir) else {
continue;
};
for e in entries.flatten() {
let path = e.path();
if path.is_dir() {
stack.push(path);
} else if path.extension().is_some_and(|x| x == "json")
&& let Ok(rel) = path.strip_prefix(&base)
&& let Ok(text) = fs::read_to_string(&path)
&& let Ok(model) = CuboidModel::from_json(&text)
{
let rel = rel.with_extension("").to_string_lossy().replace('\\', "/");
out.insert(format!("{namespace}:{rel}"), model);
}
}
}
}
out
}
/// Every `items/*.json` client item definition, by identifier.
fn load_definitions(root: &Path) -> BTreeMap<String, ClientItem> {
let mut out = BTreeMap::new();
@@ -445,14 +414,24 @@ impl ItemRenderer {
/// Load item definitions, item prototypes (`items.json`), every model,
/// the special renderers' entity models and the grass colormap.
pub fn load(root: &Path) -> crate::Result<Self> {
Self::load_with_library(
root,
crate::model_library::ParsedModels::load(root).into_item_library(),
)
}
/// Reuse the immutable models prepared with the block resources at `root`.
/// Defer item-only validation failures until after item definitions are loaded.
pub fn load_with_library(
root: &Path,
library: std::result::Result<Arc<ModelLibrary>, String>,
) -> crate::Result<Self> {
let prototypes: BTreeMap<String, Components> =
serde_json::from_slice(&fs::read(root.join("items.json"))?)?;
let models = ItemModels {
definitions: load_definitions(root),
prototypes,
};
let library = ModelLibrary::with_item_generator(load_models(root))
.map_err(|e| format!("item models: {e:?}"))?;
let library = library?;
let permutations = PalettedPermutations::from_atlas(
&fs::read_to_string(root.join("assets/minecraft/atlases/items.json"))
.unwrap_or_default(),
+1
View File
@@ -93,6 +93,7 @@ pub mod mesher;
pub mod minimap;
pub mod mipmap;
pub mod mobs;
mod model_library;
pub mod models;
pub mod motion;
pub mod mouse;
+200
View File
@@ -0,0 +1,200 @@
//! Parsed resource models for one startup or resource-pack reload.
use crate::Result;
use horizon_assets::{model::CuboidModel, resolve::ModelLibrary};
use std::{collections::BTreeMap, fs, path::Path, sync::Arc};
#[derive(Default)]
pub(crate) struct ParsedModels {
models: BTreeMap<String, Arc<CuboidModel>>,
}
impl ParsedModels {
/// Every `models/**.json` of a namespace directory, by identifier.
pub(crate) fn load(root: &Path) -> Self {
let mut out = BTreeMap::new();
let Ok(namespaces) = fs::read_dir(root.join("assets")) else {
return Self { models: out };
};
for ns in namespaces.flatten() {
let namespace = ns.file_name().to_string_lossy().into_owned();
let base = ns.path().join("models");
let mut stack = vec![base.clone()];
while let Some(dir) = stack.pop() {
let Ok(entries) = fs::read_dir(&dir) else {
continue;
};
for e in entries.flatten() {
let path = e.path();
if path.is_dir() {
stack.push(path);
} else if path.extension().is_some_and(|x| x == "json")
&& let Ok(rel) = path.strip_prefix(&base)
&& let Ok(text) = fs::read_to_string(&path)
&& let Ok(model) = CuboidModel::from_json(&text)
{
let rel = rel.with_extension("").to_string_lossy().replace('\\', "/");
out.insert(format!("{namespace}:{rel}"), Arc::new(model));
}
}
}
}
Self { models: out }
}
/// Blocks require every referenced resource, including parents. Items instead
/// skip broken resources and use the resolver's missing-model fallback.
pub(crate) fn block_library(
&self,
root: &Path,
mut pending: Vec<String>,
) -> Result<ModelLibrary> {
let mut models = BTreeMap::new();
while let Some(name) = pending.pop() {
if models.contains_key(&name) {
continue;
}
let model = match self.models.get(&name) {
Some(model) => Arc::clone(model),
None => {
let (namespace, path) =
name.split_once(':').ok_or("invalid model identifier")?;
let text = fs::read_to_string(
root.join(format!("assets/{namespace}/models/{path}.json")),
)?;
Arc::new(CuboidModel::from_json(&text)?)
}
};
if let Some(parent) = &model.parent {
pending.push(parent.clone());
}
models.insert(name, model);
}
Ok(ModelLibrary::from_shared(models)?)
}
pub(crate) fn into_item_library(self) -> std::result::Result<Arc<ModelLibrary>, String> {
ModelLibrary::with_shared_item_generator(self.models)
.map(Arc::new)
.map_err(|e| format!("item models: {e:?}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_assets::resolve::{GENERATED_MODEL, MISSING_MODEL};
struct Fixture(std::path::PathBuf);
impl Fixture {
fn write(&self, name: &str, json: &str) {
let (namespace, path) = name.split_once(':').unwrap();
let path = self
.0
.join(format!("assets/{namespace}/models/{path}.json"));
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, json).unwrap();
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn shared_views_preserve_resolution_errors_builtins_and_reload() -> Result<()> {
let fixture = Fixture(
std::env::temp_dir().join(format!("horizon-shared-models-{}", std::process::id())),
);
fixture.write("horizon:parent", r##"{"textures":{"side":"#all","all":"block/stone"},"elements":[{"from":[0,0,0],"to":[16,8,16],"faces":{"up":{"texture":"#side"}}}]}"##);
fixture.write("horizon:nested/child", r#"{"parent":"horizon:parent","textures":{"all":"block/glass"},"display":{"gui":{"rotation":[30,45,0]}}}"#);
fixture.write("horizon:broken", "{");
fixture.write("horizon:dangling", r#"{"parent":"horizon:absent"}"#);
fixture.write("horizon:a", r#"{"parent":"horizon:b"}"#);
fixture.write("horizon:b", r#"{"parent":"horizon:a"}"#);
fixture.write(
GENERATED_MODEL,
r#"{"elements":[{"from":[0,0,0],"to":[1,1,1],"faces":{"up":{"texture":"particle"}}}],"gui_light":"side"}"#,
);
fixture.write(MISSING_MODEL, r#"{"elements":[]}"#);
let parsed = ParsedModels::load(&fixture.0);
let direct = ParsedModels::default();
let names = vec![
"horizon:nested/child".into(),
GENERATED_MODEL.into(),
MISSING_MODEL.into(),
"horizon:a".into(),
];
let old = direct.block_library(&fixture.0, names.clone())?;
let shared = parsed.block_library(&fixture.0, names)?;
for name in ["horizon:nested/child", GENERATED_MODEL, MISSING_MODEL] {
assert_eq!(
format!("{:?}", old.resolve(name)?),
format!("{:?}", shared.resolve(name)?)
);
}
assert!(shared.resolve("horizon:a").is_err());
for name in ["horizon:broken", "horizon:absent", "horizon:dangling"] {
let load = |models: &ParsedModels| {
models
.block_library(&fixture.0, vec![name.into()])
.err()
.unwrap()
.to_string()
};
assert_eq!(load(&direct), load(&parsed));
}
let items = parsed.into_item_library()?;
let block = shared.resolve("horizon:nested/child")?;
let item = items.resolve("horizon:nested/child")?;
assert!(std::ptr::eq(
block.elements().as_ptr(),
item.elements().as_ptr()
));
assert_eq!(
item.material("side")?.unwrap().sprite,
"minecraft:block/glass"
);
assert_eq!(shared.resolve(GENERATED_MODEL)?.elements().len(), 1);
assert!(items.resolve(GENERATED_MODEL)?.elements().is_empty());
assert!(items.resolve(GENERATED_MODEL)?.uses_item_generator());
for name in [
"horizon:broken",
"horizon:absent",
"horizon:dangling",
MISSING_MODEL,
] {
assert_eq!(items.resolve(name)?.elements().len(), 1);
}
assert!(items.resolve("horizon:a").is_err());
fixture.write("horizon:parent", r#"{"elements":[]}"#);
let reloaded = ParsedModels::load(&fixture.0).into_item_library()?;
assert!(
reloaded
.resolve("horizon:nested/child")?
.elements()
.is_empty()
);
assert_eq!(items.resolve("horizon:nested/child")?.elements().len(), 1);
// An unrelated bad item identifier must not make block preparation fail
// (which would trigger resource-pack rollback at a different stage).
fixture.write("horizon:INVALID", "{}");
let parsed = ParsedModels::load(&fixture.0);
assert!(
parsed
.block_library(&fixture.0, vec!["horizon:nested/child".into()])
.is_ok()
);
let library = parsed.into_item_library();
assert!(library.is_err());
fs::write(fixture.0.join("items.json"), "{")?;
let load = || {
crate::item_render::ItemRenderer::load_with_library(&fixture.0, library.clone())
.err()
.unwrap()
.to_string()
};
assert!(!load().starts_with("item models:"));
fs::write(fixture.0.join("items.json"), "{}")?;
assert!(load().starts_with("item models:"));
Ok(())
}
}
+14 -1
View File
@@ -54,6 +54,19 @@ pub fn prepare(
root: &Path,
registry: &BlockRegistry,
sprites: &mut Sprites,
) -> Result<PreparedModels> {
prepare_with_models(
root,
registry,
sprites,
&crate::model_library::ParsedModels::default(),
)
}
pub(crate) fn prepare_with_models(
root: &Path,
registry: &BlockRegistry,
sprites: &mut Sprites,
models: &crate::model_library::ParsedModels,
) -> Result<PreparedModels> {
let facts: serde_json::Map<String, Value> =
serde_json::from_slice(&std::fs::read(root.join("model-states.json"))?)?;
@@ -62,7 +75,7 @@ pub fn prepare(
.filter(|name| registry.default_state(name).is_some())
.filter_map(|name| name.strip_prefix("minecraft:"))
.collect();
let catalog = StateModels::load(root, registry, &blocks, sprites)?;
let catalog = StateModels::load_with_models(root, registry, &blocks, sprites, models)?;
let mut placements = Vec::new();
let mut states = Vec::new();
for (index, (name, properties)) in GALLERY.iter().enumerate() {
+19 -18
View File
@@ -9,8 +9,6 @@ use horizon_assets::{
bind::{Properties, PropertySchema, PropertyValues, SelectedModels},
choice::CompiledChoices,
},
model::CuboidModel,
resolve::ModelLibrary,
};
use horizon_core::{
position::BlockPos,
@@ -54,6 +52,21 @@ impl StateModels {
registry: &BlockRegistry,
blocks: &[&str],
sprites: &mut Sprites,
) -> Result<Self> {
Self::load_with_models(
root,
registry,
blocks,
sprites,
&crate::model_library::ParsedModels::default(),
)
}
pub(crate) fn load_with_models(
root: &Path,
registry: &BlockRegistry,
blocks: &[&str],
sprites: &mut Sprites,
models: &crate::model_library::ParsedModels,
) -> Result<Self> {
let facts: BTreeMap<String, Value> =
serde_json::from_slice(&fs::read(root.join("model-states.json"))?)?;
@@ -156,22 +169,10 @@ impl StateModels {
}
choices.push(CompiledChoices::new(&definition)?);
}
let mut models = BTreeMap::new();
let mut pending: Vec<_> = variants.iter().map(|(name, _, _)| name.clone()).collect();
while let Some(name) = pending.pop() {
if models.contains_key(&name) {
continue;
}
let (namespace, path) = name.split_once(':').ok_or("invalid model identifier")?;
let text =
fs::read_to_string(root.join(format!("assets/{namespace}/models/{path}.json")))?;
let model = CuboidModel::from_json(&text)?;
if let Some(parent) = &model.parent {
pending.push(parent.clone());
}
models.insert(name, model);
}
let library = ModelLibrary::new(models)?;
let library = models.block_library(
root,
variants.iter().map(|(name, _, _)| name.clone()).collect(),
)?;
let mut baked = BTreeMap::new();
for (name, turns, lock) in variants {
let model = library.resolve(&name)?;
+81
View File
@@ -0,0 +1,81 @@
# Shared model library benchmark
`model_library_bench` measures `Assets::load` followed by item preparation using
that asset snapshot. It includes filesystem traversal, JSON decoding, blockstate
binding, block model resolution/baking, sprite preparation, item definitions,
entity parts and item registry setup. Timing stops before the optional parity
probe. The Python driver's `wait4` resource accounting reports process peak RSS
on Linux. For latency runs, allocation tracking is compiled out. For separate
retained-memory runs, compile with `HORIZON_BENCH_COUNT_ALLOCATIONS=1`; the counting
system allocator reports live requested bytes after both loads. A zero
`retained_bytes` with tracking disabled is not a heap measurement.
Use real prepared resources; no game files belong in Git. Build the baseline
at `89b58991bbc02eb79b6965fc4ca56357fe2297d9` with the benchmark example copied in
and `ItemRenderer::load(root)` in its item stage. Save that binary, then build
this branch with `ItemRenderer::load_with_library(root, assets.model_library.clone())`.
Both builds use `cargo build --locked --profile fast -p horizon-client --example
model_library_bench` with `HORIZON_BENCH_COUNT_ALLOCATIONS` unset (`opt-level=3`, LTO disabled, 16 codegen units). Set
`CARGO_BUILD_JOBS=2`. On the optimization machine, hold
`/tmp/horizon-optimizations-20261015-heavy.lock` for each entire build or paired
measurement session. Reuse a shared Cargo target only under that lock.
```sh
flock /tmp/horizon-optimizations-20261015-heavy.lock \
python3 tools/model_library_bench.py BASELINE_BINARY CANDIDATE_BINARY RESOURCE_ROOT
BASELINE_BINARY RESOURCE_ROOT --parity
CANDIDATE_BINARY RESOURCE_ROOT --parity
```
Run the parity probes under the same lock. The driver runs two warmups for each
binary, followed by ten samples per binary in alternating order. Every sample
uses a fresh process, including setup and allocation costs. Filesystem caches
are warm; these measurements do not represent cold disk, GPU initialization or
entire time to title. The library is built during asset preparation, so compare
the combined time rather than either stage alone. Block-only callers of
`Assets::load` now prepare and retain item models too; standalone
`ItemRenderer::load` remains available for callers without an asset snapshot.
The optional parity probe hashes every bound registry state's selected variants
and baked block faces at two fixed positions, then every resource model's baked
item geometry, lighting, transforms and particle material. It complements the
synthetic test for malformed/missing parents, cycles, child overrides, built-in
models, shared geometry and reload isolation. It is a baseline comparison, not
a replacement for vanilla differential harnesses.
## Results
Prepared 26.3 resources contain 4,192 model JSON files. Both binaries use the
optimized `fast` profile and the same resource root on this Linux machine
(12 logical CPUs, 31 GiB RAM; rustc 1.99.0, b940084d7). Tracking is compiled out
for these ten samples.
| Median | Baseline | Shared |
| --- | ---: | ---: |
| Assets (ms) | 450.1310 | 477.4105 |
| Items (ms) | 83.1180 | 36.2980 |
| Combined (ms) | 532.6055 | 514.1075 |
| Peak RSS (KiB) | 275440.0000 | 277018.0000 |
Combined startup preparation improves by 18.4980 ms (3.47%). The
candidate is faster in all ten pairs. Stage medians do not necessarily add to
the median combined time. Peak RSS increases by 1578 KiB (0.57%).
Combined sample times in milliseconds, in paired order:
```text
baseline: 531.090, 528.995, 523.807, 534.564, 538.876, 529.148, 531.804, 533.407, 541.323, 539.934
candidate: 514.767, 513.659, 514.896, 506.912, 516.291, 514.556, 510.443, 513.191, 510.017, 519.038
```
Separate builds with allocation counting enabled report the same retained
requested heap in every sample: 129,921,272 versus 129,921,320 bytes (+48 bytes)
after both loaders. After assets alone, the values are
115,873,670 versus 122,249,411 bytes (+6.08 MiB). The shared models
are prepared earlier, so block-only asset consumers pay this retained-memory
and loading-time cost even when no item renderer follows.
The baseline and current candidate resource probes both produce
`86a447cfd3f535c2` over all 4,192 item models and the block selections/faces at
two fixed positions. The focused test additionally preserves item-only error
timing, so invalid item identifiers do not change resource-pack rollback.
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""Compare saved model_library_bench baseline/candidate binaries on prepared resources.
Build both with the same profile, then run under the shared heavy-work lock:
python3 tools/model_library_bench.py BASELINE CANDIDATE RESOURCE_ROOT
Fresh processes include all asset/item setup. Two warmups per binary precede
10 alternating samples. Page caches are warm; this does not simulate cold disk.
"""
import argparse
import json
import os
from pathlib import Path
import re
import statistics
import subprocess
import tempfile
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("baseline", type=Path)
parser.add_argument("candidate", type=Path)
parser.add_argument("resources", type=Path)
args = parser.parse_args()
binaries = {name: str(getattr(args, name).resolve()) for name in ("baseline", "candidate")}
samples = {name: [] for name in binaries}
for iteration in range(12):
order = list(binaries) if iteration % 2 == 0 else list(reversed(binaries))
for label in order:
with tempfile.TemporaryFile(mode="w+t") as output:
command = [binaries[label], str(args.resources)]
with subprocess.Popen(command, stdout=output, stderr=subprocess.STDOUT) as process:
_, status, usage = os.wait4(process.pid, 0)
process.returncode = os.waitstatus_to_exitcode(status)
output.seek(0)
text = output.read()
if process.returncode:
raise subprocess.CalledProcessError(process.returncode, command, text)
row = {key: float(value) for key, value in re.findall(r"(\w+)=([0-9.]+)", text)}
row["peak_rss_kib"] = usage.ru_maxrss
print(json.dumps({"iteration": iteration, "label": label, **row}), flush=True)
if iteration >= 2:
samples[label].append(row)
for label, rows in samples.items():
print(label, json.dumps({key: {
"median": statistics.median(row[key] for row in rows),
"samples": [row[key] for row in rows],
} for key in rows[0]}), flush=True)
if __name__ == "__main__":
main()