Benchmark: a 1080p set next to 720p

perf_bench ran every game in a 1280x720 window only. It now runs the
standard set at 1280x720 and 1920x1080 (--sizes picks others), each size
its own reports: <label>-rd<N>.json at 720p as before, <label>-1080p-rd<N>.json
at 1080p.

Horizon takes vanilla's --width and --height for its window's starting size
(1280x720 by default); perf_bench passes them to every game, and to Horizon
only off 720p, so builds before this one still run the 720p set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ludovic Marie
2026-10-23 07:58:53 +02:00
parent 5bb492ca54
commit 5c9f11b954
4 changed files with 62 additions and 19 deletions
+15 -3
View File
@@ -267,6 +267,9 @@ struct Options {
/// Render into a texture instead of the window (`--offscreen`), so a
/// GPU the display cannot present from can still be benchmarked.
offscreen: bool,
/// `--width` and `--height` (vanilla's own arguments): the window's
/// starting size, 1280x720 by default.
window_size: [u32; 2],
/// Multiplayer: `--server HOST[:PORT]` joins at start; `--name` is the offline player name.
server: Option<String>,
/// `--name`, `HORIZON_PLAYER_NAME` or the signed-in account's name; else
@@ -326,6 +329,7 @@ impl Options {
no_pause: false,
control: None,
offscreen: false,
window_size: [1280, 720],
spikes: std::env::var("HORIZON_SPIKES")
.ok()
.and_then(|v| v.parse().ok()),
@@ -348,7 +352,7 @@ impl Options {
match arg.as_str() {
"--help" | "-h" => {
println!(
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n --no-server-thread Run single player's world in the frame, not on its server thread\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n --no-server-thread Run single player's world in the frame, not on its server thread\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
horizon_core::VANILLA_VERSION
);
return Ok(None);
@@ -397,6 +401,8 @@ impl Options {
"--timeout-seconds" => o.timeout = Duration::from_secs(value.parse()?),
"--smoke-report" => o.report = Some(value.into()),
"--screenshot-after" => o.screenshot_after = Some(value.parse()?),
"--width" => o.window_size[0] = value.parse::<u32>()?.max(1),
"--height" => o.window_size[1] = value.parse::<u32>()?.max(1),
"--world" => o.world = Some(value),
"--dev-mod" => o.dev_mods.push(std::path::absolute(&value)?),
"--benchmark" => {
@@ -10574,6 +10580,7 @@ impl ApplicationHandler for Game<'_> {
}
// The Window constructor: created fullscreen when options.txt says so.
let fullscreen = self.settings.fullscreen;
let size = self.options.window_size.map(f64::from);
self.fullscreen = horizon_client::window::FullscreenState::new(fullscreen);
let attributes = Window::default_attributes()
.with_title("Horizon")
@@ -10585,7 +10592,7 @@ impl ApplicationHandler for Game<'_> {
);
let window = match event_loop.create_window(
attributes
.with_inner_size(winit::dpi::LogicalSize::new(1280.0, 720.0))
.with_inner_size(winit::dpi::LogicalSize::new(size[0], size[1]))
.with_fullscreen(fullscreen.then_some(winit::window::Fullscreen::Borderless(None))),
) {
Ok(w) => Arc::new(w),
@@ -12678,6 +12685,11 @@ impl Loading {
}
// The Window constructor: created fullscreen when options.txt says so.
let fullscreen = self.settings.fullscreen;
let size = self
.options
.as_ref()
.map_or([1280, 720], |o| o.window_size)
.map(f64::from);
self.fullscreen = horizon_client::window::FullscreenState::new(fullscreen);
let attributes = Window::default_attributes()
.with_title("Horizon")
@@ -12689,7 +12701,7 @@ impl Loading {
);
let window = match event_loop.create_window(
attributes
.with_inner_size(winit::dpi::LogicalSize::new(1280.0, 720.0))
.with_inner_size(winit::dpi::LogicalSize::new(size[0], size[1]))
.with_fullscreen(fullscreen.then_some(winit::window::Fullscreen::Borderless(None))),
) {
Ok(w) => Arc::new(w),
+3 -3
View File
@@ -4,7 +4,7 @@
## What is measured
One game is started per render distance (8, 16 and 32 chunks by default), each from a fresh world, so nothing generated by an earlier run is reused. In each game:
One game is started per window size (1280x720 and 1920x1080 by default) and render distance (8, 16 and 32 chunks by default), each from a fresh world, so nothing generated by an earlier run is reused. In each game:
1. **Start to the title screen.** Wall-clock time from starting the process to the first frame of the title screen with no loading overlay.
2. **Create the world.** A creative world named `PerfBench`, seed 1234, default world type and difficulty, created the way the game's own Create World does. **Start to in-world** is the time from starting the process until the player has spawned and every chunk around the spawn is loaded and drawn.
@@ -16,7 +16,7 @@ One game is started per render distance (8, 16 and 32 chunks by default), each f
- **Same machine, same run conditions.** Games run one at a time; the load average before and after each run and the machine (CPU, RAM, OS) are stored with the results. Runs on a machine doing other work are marked as such and not published.
- **Same settings, one file.** Every game gets the same `options.txt`, written by the driver: render distance, simulation distance 12, unlimited frame rate (`maxFps:260`), VSync off, FOV 70, automatic GUI scale, windowed, no pause on lost focus, and `inactivityFpsLimit:minimized` (both games otherwise drop to 30 FPS after a minute without input). Everything else stays at vanilla's defaults: Horizon reads vanilla's `options.txt` keys and its defaults match vanilla's (the options harness checks it), and Horizon's own settings (`horizon-graphics.txt`) are left at their defaults, which render as vanilla does.
- **Same resolution.** Both windows are 1280x720 (Horizon's default window; vanilla gets `--width 1280 --height 720`). The size each game actually renders at is stored with the results.
- **Same resolution.** Both games get the same window size, `--width` and `--height` (vanilla's own launch arguments, which Horizon takes too): 1280x720, the size results were first published at, then 1920x1080, the commonest screen; `--sizes` picks others. The size each game actually renders at is stored with the results: a compositor or screen smaller than the window can shrink it.
- **Same world and path.** Same seed and world type; same paths, yaw and pitch, same flight speed; the same steps in the same order. Vanilla is driven by a small dev-only Fabric mod (`mods/horizon/src/bench`), which only calls the game's own code: it creates the world with `WorldOpenFlows.createFreshLevel` as the Create World screen does, teleports with `/tp`, sets the time with `/time set`, and places the player on the path each frame as Horizon's `horizon-ctl fly` does.
- **Warm-up excluded.** Each scene's flight starts only after its chunks are loaded and drawn and 2 seconds have passed; the load itself is reported separately. JIT warm-up of the JVM during the first scene is part of vanilla's real behaviour and is not removed.
- **The same statistics code.** Horizon returns its raw frame times (`horizon-ctl fly ... raw`) and the vanilla mod writes its own; `stats()` in the driver computes every figure for both.
@@ -55,7 +55,7 @@ python3 tools/perf_bench.py --game vanilla-mods --label mods-mine
python3 tools/perf_bench.py --table horizon-mine vanilla-mine mods-mine
```
Options: `--distances 8,16`, `--scenes village,ocean`, `--seconds 20`, `--repeats 5`, `--heap 4G` (vanilla), `--offscreen` (Horizon, for locked or headless sessions). Results go to `benchmarks/perf/<label>-rd<N>.json`, one [benchmark report](benchmark-report.md) per render distance (the same format the game's `--benchmark` and the launcher write), with the load average before each game; each game's log, and the vanilla driver's event log, stay in `.local/perf-bench/`. Keep the machine otherwise idle, plugged in, and the window on a screen.
Options: `--distances 8,16`, `--sizes 1920x1080`, `--scenes village,ocean`, `--seconds 20`, `--repeats 5`, `--heap 4G` (vanilla), `--offscreen` (Horizon, for locked or headless sessions). Results go to `benchmarks/perf/<label>-rd<N>.json` at 1280x720 and `<label>-1080p-rd<N>.json` at 1920x1080 (other sizes `<label>-<W>x<H>-rd<N>.json`; `--table` takes those labels), one [benchmark report](benchmark-report.md) per window size and render distance (the same format the game's `--benchmark` and the launcher write), with the load average before each game; each game's log, and the vanilla driver's event log, stay in `.local/perf-bench/`. Keep the machine otherwise idle, plugged in, and the window on a screen.
## Results
+8
View File
@@ -38,6 +38,14 @@ class PerfBenchStatsTests(unittest.TestCase):
self.assertIsNone(frames("x", 0, []))
json.dumps(s)
def test_window_sizes_name_their_reports(self):
from tools.perf_bench import SIZES, parse_sizes, size_label
self.assertEqual(parse_sizes("1280x720,1920X1080"), [(1280, 720), (1920, 1080)])
self.assertEqual(SIZES, [(1280, 720), (1920, 1080)])
self.assertEqual(size_label("horizon-m4", 1280, 720), "horizon-m4")
self.assertEqual(size_label("horizon-m4", 1920, 1080), "horizon-m4-1080p")
self.assertEqual(size_label("horizon-m4", 2560, 1080), "horizon-m4-2560x1080")
def test_one_options_file_sets_the_render_distance(self):
self.assertEqual(OPTIONS["renderDistance"].format(distance=16), "16")
self.assertEqual(OPTIONS["graphicsPreset"], '"custom"')
+36 -13
View File
@@ -256,6 +256,23 @@ def write_options(game_dir, distance, version=None):
return text
# Window sizes of the standard set (every game gets --width/--height): 720p,
# the size results were first published at, and 1080p, the commonest screen.
SIZES = [(1280, 720), (1920, 1080)]
def parse_sizes(text):
""""1280x720,1920x1080" as [(1280, 720), (1920, 1080)]."""
return [tuple(int(v) for v in size.lower().split("x")) for size in text.split(",") if size]
def size_label(label, width, height):
"""Reports at 1280x720 keep the plain label; other sizes add theirs: horizon-m4-1080p, or -2560x1080."""
if (width, height) == SIZES[0]:
return label
return f"{label}-{height}p" if width * 9 == height * 16 else f"{label}-{width}x{height}"
def scene_list(args):
picked = args.scenes.split(",") if args.scenes else [s[0] for s in SCENES]
return [s for s in SCENES if s[0] in picked]
@@ -375,6 +392,9 @@ def run_horizon(args, distance):
client = Path(args.build).resolve() / "horizon-client"
command = [str(client), "--resources", str(Path(args.resources).resolve()),
"--control-socket", str(sock), "--no-pause", "--weather", "clear", "--time", "6000"]
if (args.width, args.height) != SIZES[0]:
# 1280x720 is Horizon's own window; builds before --width open only that one.
command += ["--width", str(args.width), "--height", str(args.height)]
if args.offscreen:
command.append("--offscreen")
for carry in dev_mods(args):
@@ -796,8 +816,8 @@ def main():
p.add_argument("--seconds", type=float, default=12.0, help="flight duration per scene")
p.add_argument("--settle", type=float, default=2.0, help="seconds held at the start before each flight")
p.add_argument("--timeout", type=float, default=300.0, help="seconds allowed for each load")
p.add_argument("--width", type=int, default=1280, help="vanilla window width (Horizon's is 1280x720)")
p.add_argument("--height", type=int, default=720)
p.add_argument("--sizes", default=",".join(f"{w}x{h}" for w, h in SIZES),
help="window sizes, each a set of reports (default 1280x720 and 1920x1080)")
p.add_argument("--heap", default="2G", help="vanilla -Xmx (the official launcher's default is 2G)")
p.add_argument("--offscreen", action="store_true",
help="Horizon renders into a texture and does not wait for the window (locked macOS sessions)")
@@ -817,17 +837,20 @@ def main():
args.label = args.label or f"{args.game}-{platform.node().split('.')[0]}"
WORK.mkdir(parents=True, exist_ok=True)
run = {"horizon": run_horizon, "neoforge": run_neoforge}.get(args.game, run_vanilla)
for d in [int(x) for x in args.distances.split(",")]:
runs = []
for _ in range(args.repeats):
load_before = os.getloadavg()
result = run(args, d)
result["load_before"], result["load_after"] = load_before, os.getloadavg()
runs.append(result)
out = ROOT / "benchmarks/perf" / f"{args.label}-rd{d}.json"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(report(args, d, runs, app(args)), indent=1) + "\n")
print(out)
label = args.label
for args.width, args.height in parse_sizes(args.sizes):
args.label = size_label(label, args.width, args.height)
for d in [int(x) for x in args.distances.split(",")]:
runs = []
for _ in range(args.repeats):
load_before = os.getloadavg()
result = run(args, d)
result["load_before"], result["load_after"] = load_before, os.getloadavg()
runs.append(result)
out = ROOT / "benchmarks/perf" / f"{args.label}-rd{d}.json"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(report(args, d, runs, app(args)), indent=1) + "\n")
print(out)
def app(args):