Skip to content

Performance tuning

This page explains the levers, then gives the measurements from the project’s memory and benchmark documentation, so the numbers match the documents they come from. They come from developer machines (a 12-thread Ryzen 5 7600X shared with other builds), so read them as orders of magnitude and ratios, not as guarantees.

Lever Where Effect
Memory profile memory-profile=low|throughput low (default): at most 3 chunk workers, nothing pooled, caches trimmed when idle. throughput: hardware threads - 2 chunk workers, pooled buffers
Memory limit memory.limit_mb in ramjet.toml Memory the process must stay under (0 = the container or job limit). As usage nears it, the memory governor holds back chunk work beyond the chunks nearest the players; at its highest level it also drops rebuildable caches
View distance view-distance chunks sent to each player in each direction: the main driver of per-player memory, bandwidth and generation load
Simulation distance simulation-distance chunks around each player that are ticked (mobs, redstone, random ticks): the main driver of tick time
Chunk workers worker-threads, --workers, worldgen.workers threads for generation, lighting and saving; overrides the profile’s cap
Join and teleport burst worldgen.burst_workers chunk workers that may generate at once while a player joins or teleports
Entity tracking range entity-broadcast-range-percentage scales how far entities are tracked and broadcast (10-1000)
Compression network-compression-threshold bytes from which packets are compressed; -1 disables
  • low for a home server, a small community, a dev server: roughly 1-10 players. Keeps resident memory small and idle CPU near zero.
  • throughput once more than about 10 players explore new terrain at the same time. With many players spread over fresh chunks, generation throughput matters: in the measurements below 50 bots spread over 400 blocks ran at 4.9 ms/tick average (p99 13 ms) in low against 3.2 ms (p99 9.2 ms) in throughput.

Keep simulation distance lower than view distance: players see far, but the server only ticks what is near. Dropping simulation distance from 10 to 6 is the cheapest way to cut tick time and memory (compare the two tables below: one bot ticks at 1.44 ms average with simulation distance 10 and 0.96 ms with 6; a 50-bot spread at 4.90 ms and 3.54 ms). Lowering view distance cuts memory per player (fewer loaded chunks) and bandwidth.

Working set (WS) is what Task Manager shows at a random moment of the 20 s trim cycle; private = committed bytes (does not follow the trim). Before = the first measurement of this branch (default settings of that day, 10 workers, no trim). Tick time from the memory command over the last 60 s.

simulation distance 10:

state before WS / private WS private tick avg / p99 / max (ms)
idle 98 / 92 4 33 0.13 / 0.31 / 2.1
1 bot at spawn 219 / 212 38 93 1.44 / 6.7 / 16.0
2 bots at spawn 237 / 231 38 93 1.55 / 6.4 / 15.3
1 bot 200 chunks out and back 268 (peak 292) / 269 32 (peak 121) 105 1.94 / 4.6 / 16.0
50 bots spread over 400 blocks 282 / 276 148 156 4.90 / 13.1 / 24.3
1 bot, throughput 174 167 1.41 / 4.9 / 18.4
50 bots, throughput 197 192 3.16 / 9.2 / 25.1

simulation distance 6:

state WS private tick avg / p99 / max (ms)
1 bot 26 80 0.96 / 3.7 / 9.8
2 bots 27 82 0.95 / 4.0 / 7.7
1 bot exploring 25 94 1.18 / 2.8 / 14.1
50 bots spread 115 123 3.54 / 9.1 / 19.3

The tick-thread stall of the trim is gone (it ran on the tick thread and walked the whole heap before: 93-119 ms every 20 s); the windows above contain three trims and the maximum is a normal tick. Right after a trim the working set is below 1 MB and regrows with what the next 20 s touch, so the sampled WS is the honest number; private bytes are the floor the working set cannot go below.

Botswarm S2, 200 bots clustered at spawn, view 6, quick, one repeat, --memory-profile pinned: both profiles TPS 20.00, RTT p50 / p99 34.8 / 55.3 ms (throughput) and 38.9 / 63.5 ms (low), RSS 145 and 142 MB. worlddiff radius 6 against the cached vanilla world: 104/169 identical, as on main (data-only changes). tools/protocol_oracle/run_replay.sh over a recorded 30 s join (4160 packets, 249 chunks): fail=0.

Per unit (low, 1 bot, view 10 / sim 10): 33 MB baseline, 93 MB committed with 1 player (about 60 MB of loaded chunks: ~1100 holders), roughly 0.8 MB per extra player spread in fresh terrain.

low is the default of ramjet; libraries, tests and benchmarks that do not set it run throughput (the previous behaviour). bench.py pins --memory-profile throughput explicitly. ctest runs world, game and worldgen a second time as *_low (MC_MEMORY_PROFILE=low).

throughput low
chunk workers hardware threads - 2 min(that, 3) (an explicit worker-threads / --workers wins)
density buffer pools (3.6 MB per concurrent generation) kept (up to the hardware threads) none, the blocks go straight back
generator / light-solver scratch kept freed after 100 idle ticks
structure templates block lists cached only size + jigsaws + the packed file kept; block lists rebuilt per placement from a 6-entry shared LRU
loot tables compiled at startup, kept compiled at startup (so a broken datapack warns at boot), then dropped; compiled again on first use from zlib-packed documents
heap trim after a burst (trim_after_burst: scratch back + HeapCompact / malloc_trim on a worker) off at the end of every chunk burst and of every burst of >= 64 saves, at most every 10 s
idle trim (memory.trim_idle_seconds, default 30 s without chunk work; once per idle period) heap only heap + EmptyWorkingSet, the latter skipped above trim_max_players players
transient work budget (chunks.inflight_memory_mb, see “Burst memory”) 256 MB 24 MB

Not profile dependent: packet shrink_to_fit, lazy recipe-book displays, biomes / wg of FULL holders dropped, the segment-heap manifest, the raw recipe / advancement JSON freed at the end of startup.

Effect of the 3-worker cap: generation throughput. --pregen 24 (2401 chunks, fresh seed, tiles order) takes 7.9 s with 3 workers and 4.9 s with 10 (300 vs 490 chunks/s): a player flying or sprinting into new terrain asks for far fewer than that, so exploration with 1-2 players does not notice, but a crowd spread over new terrain does: 50 bots spread over 400 blocks run at 4.9 ms/tick average (p99 13) in low against 3.2 ms (p99 9.2) in throughput. Use throughput for a server with more than ~10 players.

item before after how
cached chunk packets (capacity of the buffers) 27.3 5.9 BufferWriter::shrink_to_fit in make_frame
worldgen density buffer pools (idle) 36 0 no pooling in low; scratch trimmed when idle
structure templates 10 ~1 block lists dropped, rebuilt per placement
recipes 12.3 3.3 recipe-book displays built on first use; JSON freed
loot 4.3 0.6 packed documents, compiled forms dropped after the boot-time validation
free heap kept after generation peaks 25 0 segment heap manifest (MC_SEGMENT_HEAP, Windows 10 2004+)
threads 22 19 3 workers

The segment heap alone took 1 bot at sim 6 from 89 to 72 MB private and from 65 to 42 MB sampled working set: the NT heap kept ~25 MB of free blocks interleaved with long-lived chunk data (HeapCompact does not return them).

The project’s benchmark compares Ramjet with vanilla 26.3 using one protocol and one script: a headless bot client joins the server, and the same scenarios run against both servers. The scenarios and the pass criteria for “supported players” are:

Mix (botswarm --behaviour) World Notes
S1 idle:100 flat clustered at spawn
S2 walk:70,idle:20,circle:10 flat clustered at spawn
S3 as S2 flat spread over a 1000-block radius after the ramp (chunk/entity-tracking load per player instead of overlap)
S4 explore:100 default terrain sprinting into new chunks: worldgen load. Reported separately, not part of the “supported players” headline
S2D as S2 default terrain diagnostic only (ground probes on, walls and ledges): used to measure correction rates, not a protocol scenario
S5 chat:50,spam:50 flat chat every 5 s; spam = rotation every tick and a swing every 4 ticks (broadcast fan-out)

Counts 50, 100, 200, 500, 1000, 2000, 4000, 8000, per scenario, in order, until the first failing or invalid step. Supported players = the largest count that passes all of (medians):

  • time-weighted TPS >= 19.5 (server_tps_estimate.time_weighted: all game ticks / all wall time between set_time packets)
  • stats_rtt p99 < 100 ms and 0 unanswered probes (a probe with no reply after 10 s counts; replies of any delay are measured)
  • 0 kicks (any disconnect reason except the final swarm_stopped), 0 connect failures
  • join p99 < 2 s (whole run, including the ramp)
  • corrections per bot per second <= 2x the reference rate, where the reference is the rate of the first (lowest) ladder step of the same scenario, floored at 0.05/s/bot. (The task statement says “within 2x of idle”; an idle reference is ~0 and meaningless as a ratio, so the uncontended low-count rate of the same scenario is used. It detects the rubber-band spiral described in botswarm.md.)

A step is invalid (and the ladder stops without counting it as the server’s failure) if botswarm was the bottleneck: client.tick_late_p99_ms > 20 or client.avg_cores > 50% of its io threads. Re-run with more swarm CPUs (--swarm-cpus) or a second machine.

Ryzen 5 7600X (12 threads, shared with other agents’ builds and a JVM: noisy), vanilla 26.3, JDK 25.0.4.1 -Xms8G -Xmx8G, flat pre-generated world (seed 1234, radius 400), view = simulation distance 6, bots --ground-probe 0. S1/S2 at 50 and S1 at 200 ran with server CPUs 0-8 / botswarm 9-11; S2 at 200 was re-run with the final split (0-9 / 10-11, above-normal priority) because the first attempt was INVALID (botswarm tick_late p99 27.6 ms, SMT sibling shared with the server). Raw results in %TEMP%/ramjet-bench/results-quick, r-s2-200.

scenario bots result TPS (time-weighted) stats RTT p50/p99 (ms) unanswered join p99 (ms) corr/s/bot kicks server CPU s (cores) RSS MB GC n / total / max ms in/out B/s/bot tickServer-only MSPT avg (p99 max)
S1 idle 50 PASS 20.00 27.6/50.0 0 1016 0.00 0 29 (0.49) 4838 0 / 0 / 0 4007/120 8.4 (15)
S1 idle 200 PASS 20.00 23.6/43.0 0 950 0.00 0 127 (2.12) 5791 3 / 182 / 62 10259/120 28.1 (85)
S2 walk 50 PASS 20.00 23.6/43.0 0 950 0.01 0 88 (1.52) 5660 3 / 134 / 45 21126/542 19.6 (61)
S2 walk 200 FAIL no set_time sample in the 60 s window 2490/2941 0 4981 0.15 0 218 (3.55) 5764 5 / 308 / 64 24018/541 no reply to /tick query

Supported players (quick, flat, vanilla): S1 >= 200 (ladder not continued), S2 = 50 (the 100 step was not run). At 200 moving bots the main loop is in the “moved too quickly” correction spiral (1718 such lines in the last 2000 log lines of the run): stats RTT 2.5 s, no TPS sample at all, although the server still uses 3.5 cores. This reproduces the 200-bot collapse of the early baseline on a flat world with the quiet bot profile, so it is not an artefact of the bots’ ground probes.

Correction rates (fairness check, 20 bots each, quick, vanilla): S2 mix on default terrain with ground probes 1.34/s/bot (per walking or circling bot about 1.7/s), on flat with --ground-probe 100 0.17/s/bot, on flat with --ground-probe 0 0.006/s/bot (the S2 profile). The earlier “about 0.8/s” claim in botswarm.md was wrong and has been replaced.

Same command for both (bench.py --scenarios S4 --counts 10,50 --quick --no-pregen, fresh world, seed 1234, view/sim distance 6, --ground-probe 100). Ours was run with --verbose (profile line chunks:), vanilla with -Xms8G. “Chunks stored/s” = chunks present in the region files after the graceful stop (every saved status, ring chunks included) divided by the bots’ wall time (about 92 s); it is the server-wide worldgen throughput proxy for both servers.

server bots stats RTT p50/p99 (ms) join p99 (ms) corr/s/bot server cores in B/s/bot chunks recv/s/bot (B/chunk) chunks stored/s
ours 10 25.6/47.1 475 2.48 0.03 21 984 3.6 (5676) 7.6
ours 50 25.6/51.2 475 2.99 0.14 35 756 4.7 (5563) 7.6
vanilla 10 21.5/43.0 3529 1.61 0.64 62 801 9.3 (4968) 25.7
vanilla 50 19.5/55.3 6554 1.71 3.02 102 490 8.9 (4986) 24.4

(Vanilla’s join p99 is its first-join JIT/worldgen; its in also carries mob/entity traffic: ~985 pkt/s/bot vs ~120 for ours.) Before the spawn fix ours delivered 13 / 24 KB/s/bot at 0.05 / 0.09 cores.

Findings (S4 discrepancy).

  1. Root cause: our new worlds spawned at 0,0 (NoiseBasedChunkGenerator.getOrigin / NoiseSpawnFinder was not ported). For seed 1234 that is deep ocean: the bots fell 24 blocks through water onto the sea floor (y 39, then 13..11 in caves) and ground along it, so there was little new terrain, small ocean chunk packets (3.7 KB vs 5 KB) and almost no worldgen CPU. Fixed: worldgen::find_noise_spawn; the world spawn is now (-528, 69, 64), identical to vanilla’s level.dat.
  2. Vanilla scatters joining players over +-10 blocks (PlayerSpawnFinder.findSpawn, respawn radius); ours stacked everyone on one block. Ported (find_player_spawn).
  3. Checked and ruled out: movement validation (the port matches handlePlayerPositionChange line by line; on a flat world ours gives 0.20 corrections/s/bot with ground probes vs vanilla’s 0.17), chunk pacing (PlayerChunkSender is the vanilla algorithm), the bots (they never depend on chunk data), chunk packet size (5.6 KB vs 5.0 KB per chunk on the same kind of terrain).
  4. Still open: on the default terrain our explorers cover about a third of vanilla’s ground (7.6 vs ~25 chunks stored/s, 0.1 vs 3 cores) and are rejected more often (2.5-3.0 vs 1.6-1.9 corrections/s/bot). Their rejections are genuine collisions: they get trapped on the bumpy sea floor around the spawn (1-block steps; the bots cannot jump), where the generated chunks stop being requested after ~20 s. The bots’ world-less random walk makes S4 sensitive to terrain details; whether our terrain differs from vanilla’s around (-575, 56, 49) (1-block dirt/water pits in a seagrass floor) has not been checked block by block. Next step: compare those chunks against the vanilla region files in %TEMP%/ramjet-bench/run/vanilla/world, and/or give the explorer a stuck detector (walk back along its path) so S4 measures worldgen load instead of terrain luck.