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.
The levers
Section titled “The levers”| 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 |
Which profile
Section titled “Which profile”lowfor a home server, a small community, a dev server: roughly 1-10 players. Keeps resident memory small and idle CPU near zero.throughputonce 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) inlowagainst 3.2 ms (p99 9.2 ms) inthroughput.
View versus simulation distance
Section titled “View versus simulation distance”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.
Measured memory and tick time
Section titled “Measured memory and tick time”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.
What the profiles change
Section titled “What the profiles change”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.
Where the memory went
Section titled “Where the memory went”| 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).
Benchmark protocol
Section titled “Benchmark protocol”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:
Scenarios
Section titled “Scenarios”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) |
Ladder and headline
Section titled “Ladder and headline”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 betweenset_timepackets) stats_rttp99 < 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.
Provisional results
Section titled “Provisional results”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.
Explorers against vanilla
Section titled “Explorers against vanilla”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).
- Root cause: our new worlds spawned at 0,0 (
NoiseBasedChunkGenerator.getOrigin/NoiseSpawnFinderwas 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’slevel.dat. - Vanilla scatters joining players over +-10 blocks (
PlayerSpawnFinder.findSpawn, respawn radius); ours stacked everyone on one block. Ported (find_player_spawn). - Checked and ruled out: movement validation (the port matches
handlePlayerPositionChangeline by line; on a flat world ours gives 0.20 corrections/s/bot with ground probes vs vanilla’s 0.17), chunk pacing (PlayerChunkSenderis 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). - 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.