Skip to content

Perlin volumes skip the Ys a lerp does not read, and batch Ys across cells - #10141

Open
ilguerro wants to merge 1 commit into
horizon-rust:mainfrom
ilguerro:perf/9996-perlin
Open

ilguerro wants to merge 1 commit into
horizon-rust:mainfrom
ilguerro:perf/9996-perlin

Conversation

@ilguerro

@ilguerro ilguerro commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

🤖 Opened by ilguerro's AI agent (Claude Code, Claude Opus 5.5), not by ilguerro personally.

What and why

Part of #9996.

PerlinNoise::add_volume and the scalar PerlinNoise::sample are the largest share of terrain generation in the issue's flamegraphs. Profiling chunkgen_bench (overworld, seed 12345, 1 thread) agrees: 10.6% of the proto phase in add_volume, 3.8% in sample and an out-of-line array::map. About half of add_volume's time is the terrain noise, old_blended_noise: a lerp between two 16-octave smeared Perlin stacks (min_limit, max_limit) by an 8-octave alpha, whose high octaves change Y cell at every Y of a column.

Three changes, all in horizon-core, each value computed by the same operations in the same order:

  • Lerp branches only where they are read. LerpSampler's volume path reads first only where alpha is not 1 and second only where it is not 0 (NaN reads both). In the overworld 37% of the terrain noise's coarse points have alpha exactly 1 and 50% exactly 0. The lerp now gives each sampled branch a bit per Y of each X/Z column it reads (DensitySampler::sample_needed, Noise::add_to_needed), and a Perlin volume skips the other Ys and wholly unread columns. Samplers without the method sample everything, as before, so caches and other nodes are untouched. Vanilla's LerpFunction.Sampler.sampleVolume samples both branches everywhere; the skipped values are never read.
  • Four Ys in four cells, vectorized. When a column's Y cell changes at every Y (the tabulated case), four Ys now go through the same lane-by-lane add_four as four Ys in one cell, each lane taking its own cell's corner terms from CornerTerms. Before, each such Y went one at a time.
  • Gradient lookups inline. gradients_at used array::map, whose closure LLVM had left out of line (1.1% of the profile on its own); a loop now. sample is inlined into get3 with it.

Instruction counts (callgrind, generate_proto only, 64 overworld chunks): add_volume (with CornerTerms::new, now out of line) 842 M → 684 M (−19%), the scalar path (sample + map + get3) 273 M → 188 M (−31%), the whole proto phase 6.50 G → 6.26 G (−3.6%).

Kind

  • parity: Horizon now does what vanilla 26.3 does
  • bug: a crash or a breakage
  • perf: faster, or less memory
  • feature: something new for players (listed under New in the release notes)
  • extra: Horizon-only, off by default
  • tooling: tools, CI, tests, harnesses
  • docs

Benchmark

Method of docs/surface-generation-benchmark.md: cases declared before editing, one discarded pair, then 5 alternating baseline/candidate pairs, identical --profile fast builds of crates/horizon-world/examples/chunkgen_bench.rs (baseline origin/main 624c4f6). Common arguments: --seed 12345 --size 16 --center 0 0 --warmup --json --no-map (256 proto chunks, 196 decorated). Proto ms/chunk = wall_s × 1000 / 256 (not the thread-multiplied display).

Proto ms/chunk, baseline → candidate:

Laptop (AMD Ryzen 7 7840HS, 8 cores / 16 threads, Linux, Rust 1.99.0; governor performance, platform profile performance, boost on, on AC). Every run taskset -c 8-15 (cores 4–7, both threads of each), sequential, nothing else heavy running. A laptop's boost and heat vary; each pair runs the baseline first, so heat building up within a pair would favour the baseline.

Case discarded pair pair 1 pair 2 pair 3 pair 4 pair 5 median baseline median candidate gain
Overworld, 1 thread 5.794 → 5.234 5.447 → 5.291 5.463 → 5.442 5.601 → 5.339 5.668 → 5.344 5.645 → 5.379 5.601 5.344 4.6%
Overworld, 4 threads 2.011 → 1.978 2.036 → 1.981 2.052 → 1.963 2.007 → 1.932 1.997 → 1.918 2.065 → 1.922 2.036 1.932 5.1%
Nether, 1 thread 2.112 → 2.078 2.163 → 2.090 2.145 → 2.076 2.157 → 2.036 2.148 → 2.046 2.171 → 2.030 2.157 2.046 5.1%

Under full load, the way the game's worker pool runs (same laptop, no taskset, --size 32: 1024 proto chunks, same seed and method, the baseline first in each pair):

Case discarded pair pair 1 pair 2 pair 3 pair 4 pair 5 median baseline median candidate gain
Overworld, 8 threads 0.980 → 0.939 0.989 → 0.936 0.989 → 0.947 0.992 → 0.943 0.986 → 0.940 0.980 → 0.947 0.989 0.943 4.6%
Overworld, 16 threads 0.884 → 0.844 0.874 → 0.847 0.881 → 0.842 0.887 → 0.849 0.887 → 0.857 0.896 → 0.853 0.887 0.849 4.3%

Every pair is faster with the change (0.4% to 7.0%). Decoration, ms per decorated chunk (median of the 5 pairs; this change does not target it): 1.922 → 1.856, 0.585 → 0.567, 0.336 → 0.351.

A remote build VM, for the record (Rust 1.99.1; every run taskset -c 40-55, builds and other jobs on 0–39). Its speed flips between two levels about 2× apart for seconds to minutes at a time, even with the guest idle, so single pairs swing by ±30% and its medians say nothing at this size. The second round's binaries in both trees also carried an identical, unused local timing hook.

Case round 1 medians (gain) round 1 pairs, candidate vs baseline round 2 medians (gain) round 2 pairs, candidate vs baseline
Overworld, 1 thread 14.276 → 13.345 (gain 6.5%) −31.7% to +22.4% 13.541 → 17.128 (gain −26.5%) −6.4% to +34.9%
Overworld, 4 threads 6.495 → 6.562 (gain −1.0%) −6.1% to +9.5% 6.318 → 6.114 (gain 3.2%) −11.2% to −0.4%
Nether, 1 thread 7.157 → 7.171 (gain −0.2%) −4.0% to +1.8% 7.301 → 7.668 (gain −5.0%) −10.8% to +24.2%

Instruction counts on that VM do not depend on its speed (callgrind, generate_proto only, --size 8, no warm-up): overworld 6.50 G → 6.26 G (−3.6%), nether 2.97 G → 2.88 G (−3.3%).

Exactness

  • --snapshot (terrain and decorated, --threads 4) byte for byte against the baseline: overworld seed 12345 at (0, 0), overworld seed −1 at (32, −32), the nether and the end: all 8 files identical.
  • batched_volume_is_bit_identical now also runs the batched loop with masks of needed Ys (none, all, two random sets) against the one-at-a-time loop: needed Ys bit-identical, the others either the same value or untouched.
  • New lerp_samples_its_branches_only_where_alpha_reads_them: the overworld's compiled terrain noise over four volumes (with and without masks, 49, 64 and 65 Ys) against the same lerp whose branches hide sample_needed, bit for bit.

Parity harnesses, on a remote build VM, all 10 pass in 17.5 min: noise density terrain worldgen worldgen_random chunk_generation biome_generation material_surface structure_density flat_generation.

cargo test -p horizon-core -p horizon-world --lib: 127 and 359 passed. cargo clippy -p horizon-core -p horizon-world --all-targets -- -D warnings: clean.

Other projects' code

none

Self-check

pr_ready (tools/pr_ready.py --quick, against origin/main at 624c4f6, 2 min 9 s)

Check Result
Scope ✅ 5 files, +327 −34
Cargo.lock ✅ unchanged
Public tree ✅
Python tests ✅ 686 passed (44 s)
rustfmt ✅ (8 s)
clippy ✅ (1 min 46 s)
tests: horizon-core ✅ 135 passed (10 s)
doc tests ✅ 0 passed
content root tests ➖ content root untouched
tokens.css up to date ➖ theme untouched
launcher rustfmt ➖ launcher untouched
launcher clippy ➖ launcher untouched
launcher tests ➖ launcher untouched
Parity ➖ 425 affected, to run in CI (--quick): active_effect_tick, active_effects, allay_cooldown, allay_interaction_state, ambient_sound, anchor, anvil_turtle_egg, armor_stand_frost, armor_stand_honey, armor_stand_turtle_egg, arrow, arrow_wind_charge and 413 more

The touched files are unchanged on main since 624c4f6 (now ff4b4b1); the branch merges cleanly.

https://claude.ai/code/session_01WZzq8LUjYucp4kaN6UfSg7

🤖 Generated with Claude Code

…cells

Part of horizon-rust#9996.

The terrain noise (old_blended_noise) is a lerp between two 16-octave
smeared Perlin stacks. Lerp reads its first branch only where alpha is
not 1 and its second only where alpha is not 0, and in the overworld
about 37% of the coarse points have alpha 1 and 50% alpha 0. The lerp
now passes each branch a bit per Y of each column it reads, and a noise
volume skips the others (DensitySampler::sample_needed,
Noise::add_to_needed; samplers without it still sample everything).

When a column's Y cells change at every Y, as in the stacks'
high-frequency octaves, four Ys in four cells now go through the same
vectorized lane code as four Ys in one cell, each lane with its own
cell's corner terms. Gradient lookups are a loop rather than
array::map, which LLVM had left out of line.

Every value is computed by the same operations in the same order:
snapshots match byte for byte and the tests compare against the
unbatched, unmasked loops.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fekd3xkgubE8VfXrALh2td

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

perf Kind: faster, or less memory

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant