memory: a RAM limit that holds the process, not one that pages

HORIZON_MEMORY_LIMIT_MB caps the working set, and Windows meets it by moving pages out: at 512 MB the game still committed 2.2 GB here. HORIZON_MEMORY_HARD_MB (and /ramlimit hard) is a budget of the process's commit instead. The governor sheds far terrain and render distance against it, the allocator refuses what would pass it, and a game that cannot fit ends with a crash report or a message naming the limit rather than living in the page file.
This commit is contained in:
CocofireHD committed 2026-11-02 18:47:40 +01:00
1 parent 02c8607ee8
commit caa34071cd
8 files changed
+601 -19

No files matched your search

+6
View File
@@ -7,6 +7,7 @@ use super::Game;
use horizon_client::chunk::ColumnCensus;
use horizon_client::fast_hash::FxHashSet;
use horizon_client::world::World;
use horizon_threads::capped;
use serde_json::{Value, json};
use std::sync::Arc;
@@ -131,6 +132,11 @@ impl Game<'_> {
"resident_mb": horizon_threads::memory_limit::resident_bytes().map(mb),
"allocator_commit_mb": mb(commit),
"allocator_peak_commit_mb": mb(peak_commit),
// Bytes the game asked for, against the hard ceiling if one is set.
"held_mb": mb(capped::live_bytes()),
"held_peak_mb": mb(capped::peak_bytes()),
"hard_limit_mb": capped::limit().map(mb),
"refused_allocations": capped::refused(),
},
"render_distance": self.streaming.radius,
"client_columns": census_json(&client),
@@ -3,8 +3,11 @@
use super::Game;
use horizon_client::ram_limit::{self, Step};
use horizon_client::text::Text;
use horizon_threads::capped;
use std::time::Instant;
const MIB: u64 = 1024 * 1024;
impl Game<'_> {
/// The render distance in use: the player's, unless memory holds it lower.
pub(super) fn render_distance_in_use(&self) -> i32 {
@@ -25,6 +28,17 @@ impl Game<'_> {
};
let has_far = self.settings.horizon.lod_distance > 0;
let pressure = ram_limit::pressure(limit, self.fps);
// Every ten seconds, what the limit sees: a report can show the trace.
if self.ticks.is_multiple_of(200) {
let (commit, _) = horizon_threads::allocator_commit();
log::info!(
"Horizon: memory: allocated {} MB of {} MB, commit {} MB, distance {}, {pressure:?}",
capped::live_bytes() / MIB,
limit / MIB,
commit as u64 / MIB,
self.render_distance_in_use()
);
}
let step = self
.ram_limit
.step(Instant::now(), pressure, chosen, has_far);
+1 -1
View File
@@ -3,7 +3,7 @@
#[cfg(not(any(feature = "system-allocator", feature = "alloc-census")))]
#[global_allocator]
static ALLOCATOR: horizon_threads::Allocator = horizon_threads::Allocator;
static ALLOCATOR: horizon_threads::Allocator = horizon_threads::ALLOCATOR;
#[cfg(all(feature = "alloc-census", not(feature = "system-allocator")))]
#[global_allocator]
static ALLOCATOR: horizon_threads::CensusAllocator = horizon_threads::CensusAllocator;
+87 -14
View File
@@ -4,6 +4,7 @@
//! Under pressure, [`Governor`] turns far terrain off and lowers the render distance in use, never
//! the option the player saved, and gives them back when there is room. Only a game that can't run
//! under the limit at all ends with a crash report.
use horizon_threads::capped;
use horizon_threads::memory_limit::{self, Pressure};
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
@@ -12,6 +13,8 @@ use std::time::{Duration, Instant};
/// The command's name, which the chat box lists with the server's.
pub const COMMAND: &str = "ramlimit";
const ENV: &str = "HORIZON_MEMORY_LIMIT_MB";
/// A ceiling at the allocator: past it the game ends instead of paging.
const HARD_ENV: &str = "HORIZON_MEMORY_HARD_MB";
const MIB: u64 = 1024 * 1024;
/// The lowest render distance the game runs at (`Options.renderDistance`'s lowest).
pub const LOWEST_DISTANCE: i32 = 2;
@@ -135,6 +138,17 @@ const SLOW_FPS: f32 = 30.;
/// See [`memory_limit::pressure`]. Under a cap the system enforces the game is already under the
/// limit, so nothing the player sees goes unless the game is slow as well.
pub fn pressure(limit: u64, fps: f32) -> Pressure {
if capped::limit().is_some() {
// A hard ceiling is a budget of its own: what the process commits
// decides, and a smooth frame does not excuse holding more. Shedding
// down to it is the point, so frames suffering is not the trigger.
return horizon_threads::allocator_commit()
.0
.try_into()
.map_or(Pressure::Steady, |commit| {
memory_limit::pressure_of_commit(commit, limit)
});
}
let pressure = memory_limit::pressure(limit);
needed(pressure, memory_limit::system_enforced(), fps)
}
@@ -163,12 +177,30 @@ pub fn start(home: &Path, side: &'static str) {
Err(_) => log::warn!("{ENV}={value} is not a number of MiB: no limit set"),
}
}
if let Ok(value) = std::env::var(HARD_ENV) {
match value.trim().parse::<u64>() {
Ok(mb) => set_hard(Some(mb)),
Err(_) => log::warn!("{HARD_ENV}={value} is not a number of MiB: no hard limit set"),
}
}
}
fn set(mb: Option<u64>) {
memory_limit::set(mb.map(|mb| mb.saturating_mul(MIB)), exceeded);
}
/// Sets the ceiling at the allocator, and with no working set limit yet, gives
/// the governor the same budget: alone, a hard ceiling would end the game
/// before anything stepped down.
fn set_hard(mb: Option<u64>) {
capped::set_limit(mb.map(|mb| mb.saturating_mul(MIB)));
if let Some(mb) = mb
&& memory_limit::get().is_none()
{
set(Some(mb));
}
}
fn exceeded(used: u64, limit: u64) {
let message = format!(
"The game can't run under its RAM limit: it holds {} MiB and the limit is {} MiB. Raise \
@@ -188,6 +220,7 @@ fn exceeded(used: u64, limit: u64) {
enum Request {
Show,
Set(Option<u64>),
SetHard(Option<u64>),
Usage,
}
@@ -197,28 +230,44 @@ fn parse(command: &str) -> Option<Request> {
if words.next() != Some(COMMAND) {
return None;
}
Some(match (words.next(), words.next()) {
(None, _) => Request::Show,
(Some("off"), None) => Request::Set(None),
(Some(mb), None) => mb
Some(match (words.next(), words.next(), words.next()) {
(None, _, _) => Request::Show,
(Some("off"), None, _) => Request::Set(None),
(Some("hard"), Some("off"), None) => Request::SetHard(None),
(Some("hard"), Some(mb), None) => mb
.parse()
.map_or(Request::Usage, |mb| Request::SetHard(Some(mb))),
(Some(mb), None, _) => mb
.parse()
.map_or(Request::Usage, |mb| Request::Set(Some(mb))),
_ => Request::Usage,
})
}
fn describe(limit: Option<u64>, used: Option<u64>) -> String {
fn describe(limit: Option<u64>, hard: Option<u64>, used: Option<u64>) -> String {
let limit = limit.map_or("off".to_owned(), |limit| format!("{} MB", limit / MIB));
match used {
Some(used) => format!("RAM limit: {limit}. The game holds {} MB.", used / MIB),
None => format!("RAM limit: {limit}."),
}
let hard_off = hard.is_none();
let hard = hard.map_or("off".to_owned(), |hard| format!("{} MB", hard / MIB));
let holds = used.map_or(String::new(), |used| {
// With a hard limit, the number that counts is the process's commit,
// since that is what the budget is spent on.
let what = if hard_off { "game" } else { "process" };
format!(" The {what} holds {} MB.", used / MIB)
});
format!("RAM limit: {limit}. Hard limit: {hard}.{holds}")
}
/// Runs `/ramlimit` and returns what to say in chat, or None when `command` is another command.
pub fn run(command: &str) -> Option<String> {
Some(match parse(command)? {
Request::Show => describe(memory_limit::get(), memory_limit::resident_bytes()),
Request::Show => describe(
memory_limit::get(),
capped::limit(),
match capped::limit() {
Some(_) => Some(horizon_threads::allocator_commit().0 as u64),
None => memory_limit::resident_bytes(),
},
),
Request::Set(None) => {
set(None);
"RAM limit off until the game closes.".to_owned()
@@ -230,7 +279,22 @@ pub fn run(command: &str) -> Option<String> {
slower if it's very low."
)
}
Request::Usage => "Usage: /ramlimit, /ramlimit <MB> or /ramlimit off".to_owned(),
Request::SetHard(None) => {
set_hard(None);
"Hard memory limit off until the game closes.".to_owned()
}
Request::SetHard(Some(mb)) => {
set_hard(Some(mb));
format!(
"Hard memory limit set to {mb} MB until the game closes: past it the game ends \
instead of paging."
)
}
Request::Usage => {
"Usage: /ramlimit, /ramlimit <MB>, /ramlimit off, /ramlimit hard <MB> or /ramlimit \
hard off"
.to_owned()
}
})
}
@@ -333,6 +397,12 @@ mod tests {
assert_eq!(parse("ramlimit off"), Some(Request::Set(None)));
assert_eq!(parse("ramlimit 1"), Some(Request::Set(Some(1))));
assert_eq!(parse("ramlimit 8192"), Some(Request::Set(Some(8192))));
assert_eq!(
parse("ramlimit hard 512"),
Some(Request::SetHard(Some(512)))
);
assert_eq!(parse("ramlimit hard off"), Some(Request::SetHard(None)));
assert_eq!(parse("ramlimit hard lots"), Some(Request::Usage));
}
#[test]
@@ -347,9 +417,12 @@ mod tests {
#[test]
fn the_answer_shows_the_limit_and_what_is_held() {
assert_eq!(
describe(Some(2048 * MIB), Some(512 * MIB)),
"RAM limit: 2048 MB. The game holds 512 MB."
describe(Some(2048 * MIB), Some(1024 * MIB), Some(512 * MIB)),
"RAM limit: 2048 MB. Hard limit: 1024 MB. The process holds 512 MB."
);
assert_eq!(
describe(None, None, None),
"RAM limit: off. Hard limit: off."
);
assert_eq!(describe(None, None), "RAM limit: off.");
}
}
+411
View File
@@ -0,0 +1,411 @@
//! A global allocator with a hard ceiling on what the game may hold.
//!
//! [`crate::memory_limit`] caps the working set, which Windows enforces by moving
//! pages out of RAM: the game keeps its allocations and runs slower. This is the
//! other contract, the one `-Xmx` gives a Java game: past the ceiling an
//! allocation fails, so nothing runs the game past it. Freed memory comes back
//! to the budget at once, and a ceiling set from the launcher is in force before
//! the game allocates anything.
//!
//! What it counts is the bytes the game asked for. The allocator's own pages,
//! per-thread heaps and rounding sit on top, so the process stays a little
//! above the ceiling; a ceiling far below what the game needs ends it with a
//! message instead of paging, which is what `-Xmx` does too.
use std::alloc::{GlobalAlloc, Layout};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
/// No ceiling: every allocation passes and only accounting runs.
const NONE: u64 = u64::MAX;
static LIMIT: AtomicU64 = AtomicU64::new(NONE);
/// Bytes the game holds now, as it asked for them.
static LIVE: AtomicU64 = AtomicU64::new(0);
static PEAK: AtomicU64 = AtomicU64::new(0);
static REFUSED: AtomicU64 = AtomicU64::new(0);
/// The message is written once, by the allocation that first hits the ceiling.
static REPORTED: AtomicBool = AtomicBool::new(false);
/// What the allocator holds, for `/ramlimit` and the crash report.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Usage {
pub live: u64,
pub peak: u64,
pub limit: Option<u64>,
pub refused: u64,
}
/// Sets the ceiling in bytes, or removes it.
///
/// Counting costs a relaxed load per allocation and nothing else while no
/// ceiling is set, so a game that never asks for one pays no atomic.
/// Turning one on mid-run seeds the counter with what the process already
/// committed, so the ceiling starts from what the game holds rather than from
/// zero; the allocator's own pages are part of that seed, which makes the
/// ceiling a little stricter than the bytes the game asked for.
pub fn set_limit(bytes: Option<u64>) {
apply(bytes, crate::allocator_commit().0 as u64);
}
/// [`set_limit`], with the counter seeded by `held` instead of the process's
/// commit: the tests count from zero, a game turning it on counts from what it
/// already holds.
fn apply(bytes: Option<u64>, held: u64) {
match bytes {
Some(bytes) => {
LIVE.store(held, Ordering::Relaxed);
PEAK.store(held, Ordering::Relaxed);
REFUSED.store(0, Ordering::Relaxed);
// Last: no allocation is counted before the seed is in place.
LIMIT.store(bytes, Ordering::Relaxed);
}
None => {
LIMIT.store(NONE, Ordering::Relaxed);
LIVE.store(0, Ordering::Relaxed);
PEAK.store(0, Ordering::Relaxed);
}
}
REPORTED.store(false, Ordering::Relaxed);
}
/// The ceiling in bytes, if one is set.
pub fn limit() -> Option<u64> {
Some(LIMIT.load(Ordering::Relaxed)).filter(|limit| *limit != NONE)
}
/// The bytes the game holds now.
pub fn live_bytes() -> u64 {
LIVE.load(Ordering::Relaxed)
}
/// The most the game has held at once.
pub fn peak_bytes() -> u64 {
PEAK.load(Ordering::Relaxed)
}
/// How many allocations the ceiling turned away.
pub fn refused() -> u64 {
REFUSED.load(Ordering::Relaxed)
}
/// All three at once, without reading an atomic three times.
pub fn usage() -> Usage {
Usage {
live: live_bytes(),
peak: peak_bytes(),
limit: limit(),
refused: refused(),
}
}
/// An allocator behind a hard ceiling: [`Allocator`] is this over mimalloc.
pub struct Capped<A>(pub A);
impl<A> Capped<A> {
pub const fn new(inner: A) -> Self {
Capped(inner)
}
}
/// The game's allocator, over mimalloc (`#[global_allocator]` in its `main.rs`).
pub type Allocator = Capped<mimalloc::MiMalloc>;
/// A const instance, so `main.rs` can name it in a `static`.
pub const ALLOCATOR: Allocator = Capped(mimalloc::MiMalloc);
// SAFETY: every method forwards to the wrapped allocator with the same layout
// and pointer, and only decides whether to pass the request on at all.
unsafe impl<A: GlobalAlloc> GlobalAlloc for Capped<A> {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
if !reserve(layout.size()) {
return std::ptr::null_mut();
}
// SAFETY: the caller's layout, and the result is handed back to it.
let ptr = unsafe { self.0.alloc(layout) };
if ptr.is_null() {
release(layout.size());
}
ptr
}
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
if !reserve(layout.size()) {
return std::ptr::null_mut();
}
// SAFETY: the caller's layout, and the result is handed back to it.
let ptr = unsafe { self.0.alloc_zeroed(layout) };
if ptr.is_null() {
release(layout.size());
}
ptr
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
// SAFETY: the caller's pointer and layout, passed on unchanged.
unsafe { self.0.dealloc(ptr, layout) };
release(layout.size());
}
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
let grew = new_size > layout.size();
if grew && !reserve(new_size - layout.size()) {
return std::ptr::null_mut();
}
// SAFETY: the caller's pointer, layout and new size, passed on unchanged.
let out = unsafe { self.0.realloc(ptr, layout, new_size) };
if out.is_null() {
// The old block is still the game's: give back only what was reserved.
if grew {
release(new_size - layout.size());
}
} else if !grew {
release(layout.size() - new_size);
}
out
}
}
/// Takes `bytes` from the budget; false when that would pass the ceiling.
#[inline]
fn reserve(bytes: usize) -> bool {
let bytes = bytes as u64;
if bytes == 0 {
return true;
}
// One relaxed load while no ceiling is set: no shared line touched, so a
// game without one allocates exactly as it did before this module existed.
let limit = LIMIT.load(Ordering::Relaxed);
if limit == NONE {
return true;
}
let mut live = LIVE.load(Ordering::Relaxed);
loop {
let next = live.saturating_add(bytes);
if next > limit {
REFUSED.fetch_add(1, Ordering::Relaxed);
report(limit, live, bytes);
return false;
}
match LIVE.compare_exchange_weak(live, next, Ordering::Relaxed, Ordering::Relaxed) {
Ok(_) => {
bump_peak(next);
return true;
}
Err(actual) => live = actual,
}
}
}
/// Gives `bytes` back to the budget.
#[inline]
fn release(bytes: usize) {
let bytes = bytes as u64;
if bytes == 0 || LIMIT.load(Ordering::Relaxed) == NONE {
return;
}
// A free without its allocation would wrap the counter and refuse every
// later allocation; that can only come from a broken allocator contract.
if LIVE.fetch_sub(bytes, Ordering::Relaxed) < bytes {
LIVE.store(0, Ordering::Relaxed);
}
}
#[inline]
fn bump_peak(live: u64) {
if live > PEAK.load(Ordering::Relaxed) {
PEAK.fetch_max(live, Ordering::Relaxed);
}
}
/// Says, once, which ceiling ended the game. Built in a stack buffer and
/// written with no allocation, since this runs when allocating failed.
fn report(limit: u64, live: u64, wanted: u64) {
// A test refuses on purpose; its output is not a game ending.
if cfg!(test) || REPORTED.swap(true, Ordering::Relaxed) {
return;
}
let mut buf = [0u8; 192];
let mut at = 0;
at += put(&mut buf[at..], b"Horizon: the hard memory limit of ");
at += number(&mut buf[at..], limit >> 20);
at += put(&mut buf[at..], b" MiB is reached (holding ");
at += number(&mut buf[at..], live >> 20);
at += put(&mut buf[at..], b" MiB, asked for ");
at += number(&mut buf[at..], wanted);
at += put(
&mut buf[at..],
b" more bytes). Raise HORIZON_MEMORY_HARD_MB.\n",
);
let _ = std::io::Write::write_all(&mut std::io::stderr(), &buf[..at]);
}
fn put(out: &mut [u8], text: &[u8]) -> usize {
let len = text.len().min(out.len());
out[..len].copy_from_slice(&text[..len]);
len
}
fn number(out: &mut [u8], mut n: u64) -> usize {
let mut digits = [0u8; 20];
let mut at = digits.len();
if n == 0 {
at -= 1;
digits[at] = b'0';
}
while n > 0 {
at -= 1;
digits[at] = b'0' + (n % 10) as u8;
n /= 10;
}
put(out, &digits[at..])
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, MutexGuard};
/// The budget is one process's: these tests take turns.
static BUDGET: Mutex<()> = Mutex::new(());
fn alone() -> MutexGuard<'static, ()> {
let guard = BUDGET.lock().unwrap_or_else(|e| e.into_inner());
LIVE.store(0, Ordering::Relaxed);
PEAK.store(0, Ordering::Relaxed);
REFUSED.store(0, Ordering::Relaxed);
apply(None, 0);
guard
}
#[test]
fn a_ceiling_counts_up_and_down_with_the_allocations() {
let _alone = alone();
apply(Some(100), 0);
assert!(reserve(40));
assert!(reserve(60));
assert_eq!(live_bytes(), 100);
assert_eq!(peak_bytes(), 100);
assert!(!reserve(1), "the budget is exactly used up");
assert_eq!(refused(), 1);
release(60);
assert_eq!(live_bytes(), 40);
assert!(reserve(60), "a free made room again");
assert_eq!(live_bytes(), 100);
assert_eq!(peak_bytes(), 100);
}
#[test]
fn a_peak_stays_at_its_highest_after_the_budget_shrinks() {
let _alone = alone();
apply(Some(1 << 20), 0);
assert!(reserve(900));
assert_eq!(release_bytes(400), 500);
assert!(reserve(200));
assert_eq!(live_bytes(), 700);
assert_eq!(peak_bytes(), 900);
}
/// `release` returns nothing; the test reads the counter after it.
fn release_bytes(bytes: usize) -> u64 {
release(bytes);
live_bytes()
}
#[test]
fn no_ceiling_counts_nothing_and_refuses_nothing() {
let _alone = alone();
assert!(reserve(1 << 30));
assert_eq!(live_bytes(), 0, "a game without a ceiling pays no counter");
assert!(reserve(1 << 30));
assert_eq!(refused(), 0);
release(1 << 31);
assert_eq!(live_bytes(), 0);
}
#[test]
fn a_free_without_its_allocation_does_not_wrap_the_budget() {
let _alone = alone();
apply(Some(1000), 0);
reserve(10);
release(500);
assert_eq!(live_bytes(), 0, "clamped instead of wrapped");
assert!(reserve(1000), "the whole budget is usable again");
}
#[test]
fn the_allocator_itself_refuses_past_the_ceiling_and_frees_below_it() {
let _alone = alone();
apply(Some(4096), 0);
let small = Layout::from_size_align(1024, 8).unwrap();
// SAFETY: the layout is valid and the pointer is freed once, with the same layout.
unsafe {
let first = ALLOCATOR.alloc(small);
assert!(!first.is_null());
assert_eq!(live_bytes(), 1024);
let second = ALLOCATOR.alloc(small);
assert!(!second.is_null());
let over = ALLOCATOR.alloc(Layout::from_size_align(4096, 8).unwrap());
assert!(over.is_null(), "past the ceiling the request fails");
assert_eq!(refused(), 1);
assert_eq!(live_bytes(), 2048, "a refused request takes no budget");
ALLOCATOR.dealloc(second, small);
ALLOCATOR.dealloc(first, small);
assert_eq!(live_bytes(), 0);
}
}
#[test]
fn growing_an_allocation_past_the_ceiling_keeps_the_old_one() {
let _alone = alone();
apply(Some(4096), 0);
let layout = Layout::from_size_align(1024, 8).unwrap();
// SAFETY: the layout is valid, and the pointer is freed once with the
// layout it came back with.
unsafe {
let ptr = ALLOCATOR.alloc(layout);
assert!(!ptr.is_null());
let grown = ALLOCATOR.realloc(ptr, layout, 8192);
assert!(grown.is_null(), "the growth would pass the ceiling");
assert_eq!(live_bytes(), 1024, "and the old allocation still counts");
let same = ALLOCATOR.realloc(ptr, layout, 512);
assert!(!same.is_null());
assert_eq!(live_bytes(), 512, "a shrink frees the difference");
ALLOCATOR.dealloc(same, Layout::from_size_align(512, 8).unwrap());
assert_eq!(live_bytes(), 0);
}
}
#[test]
fn the_ceiling_can_be_lifted_again() {
let _alone = alone();
apply(Some(64), 0);
assert!(reserve(64));
assert!(!reserve(1));
apply(None, 0);
assert!(reserve(1 << 20), "no ceiling: anything passes");
assert_eq!(limit(), None);
assert_eq!(live_bytes(), 0, "and nothing is counted again");
}
#[test]
fn a_ceiling_turned_on_mid_run_counts_from_what_is_already_held() {
let _alone = alone();
apply(Some(1000), 900);
assert_eq!(live_bytes(), 900);
assert!(reserve(100), "the last tenth is there");
assert!(!reserve(1), "and no more");
release(400);
assert_eq!(live_bytes(), 600);
assert!(reserve(400));
}
#[test]
fn the_message_names_the_ceiling_and_fits_its_buffer() {
let mut buf = [0u8; 192];
let mut at = 0;
at += put(&mut buf[at..], b"limit ");
at += number(&mut buf[at..], 4096);
at += put(&mut buf[at..], b" MiB");
assert_eq!(&buf[..at], b"limit 4096 MiB");
assert_eq!(number(&mut buf, 0), 1);
assert_eq!(&buf[..1], b"0");
}
}
+5 -1
View File
@@ -4,6 +4,7 @@
/// The game window's boot screen, before wgpu's first frame paints over it.
#[cfg(target_os = "windows")]
pub mod boot_paint;
pub mod capped;
pub mod heap_census;
pub mod jobs;
pub mod memory_limit;
@@ -231,7 +232,10 @@ pub fn catch_stop_signals() {}
/// World generation, meshing and the tick allocate and free small blocks on
/// every worker at once; the system heaps serialise them (Windows' heap was
/// 16% of a chunk's generation time), mimalloc keeps a heap per thread.
pub use mimalloc::MiMalloc as Allocator;
///
/// It sits behind [`capped`]'s hard ceiling: with none set, every request is
/// passed on unchanged and only the byte counter moves.
pub use capped::{ALLOCATOR, Allocator};
/// The global allocator of a build with the `alloc-census` feature: live
/// heap bytes by startup step and thread, for `horizon-ctl memory`.
+60 -3
View File
@@ -130,6 +130,21 @@ fn pressure_of_faults(per_second: f64) -> Pressure {
}
}
/// Against a hard ceiling, what the process commits is the budget: the working set sits at its
/// cap whatever the game needs, and page faults only say how hard it pages, not how much it
/// holds. A ceiling counts every page the process committed, the renderer's own included.
pub fn pressure_of_commit(commit: u64, limit: u64) -> Pressure {
if commit > limit / 10 * 9 {
Pressure::Over {
keep: limit as f64 * 0.8 / commit as f64,
}
} else if commit < limit / 10 * 7 {
Pressure::Room
} else {
Pressure::Steady
}
}
/// How tight memory is under `limit`. Reads a difference since the last call: call it once a
/// second, from one place.
pub fn pressure(limit: u64) -> Pressure {
@@ -183,10 +198,20 @@ fn frozen(no_frame_for: Duration, faults_per_second: f64) -> bool {
}
fn check(state: &mut Over, faults: Option<f64>, limit: u64, exceeded: fn(u64, u64)) {
let Some(mut used) = resident_bytes() else {
// A hard ceiling counts what the process committed, the renderer's own
// pages included, since that is the number the player set.
let hard = crate::capped::limit().is_some();
let measured = if hard {
Some(crate::allocator_commit().0 as u64)
} else {
resident_bytes()
};
let Some(mut used) = measured else {
return;
};
if system_enforced() {
// A cap the system enforces can't be gone over, so there the sign is a game
// frozen by it. A hard ceiling can be gone over, and says so.
if system_enforced() && !hard {
let no_frame_for = since_start().saturating_sub(LAST_FRAME.load(Ordering::Relaxed));
if frozen(Duration::from_millis(no_frame_for), faults.unwrap_or(0.)) {
// The report has to be written, which a frozen game can't do under the cap.
@@ -197,7 +222,11 @@ fn check(state: &mut Over, faults: Option<f64>, limit: u64, exceeded: fn(u64, u6
}
if used > limit {
crate::release_free_memory();
used = resident_bytes().unwrap_or(used);
used = if hard {
crate::allocator_commit().0 as u64
} else {
resident_bytes().unwrap_or(used)
};
}
let can_shrink = since_start() < SHRINKS_UNTIL.load(Ordering::Relaxed);
if state.gave_up(Instant::now(), used > limit, can_shrink) {
@@ -446,4 +475,32 @@ mod tests {
can_shrink();
assert!(since_start() < SHRINKS_UNTIL.load(Ordering::Relaxed));
}
#[test]
fn a_commit_budget_says_over_near_it_and_room_far_below() {
assert_eq!(
pressure_of_commit(950, 1000),
Pressure::Over {
keep: 0.8 * 1000. / 950.
}
);
assert_eq!(pressure_of_commit(800, 1000), Pressure::Steady);
assert_eq!(pressure_of_commit(600, 1000), Pressure::Room);
}
#[test]
fn a_commit_budget_aims_for_less_the_further_over_it_is() {
let a = pressure_of_commit(1000, 1000);
let b = pressure_of_commit(2000, 1000);
let (Pressure::Over { keep: a }, Pressure::Over { keep: b }) = (a, b) else {
panic!("both are over");
};
assert!(a > b, "twice the budget asks for half as much");
assert!((b - 0.4).abs() < 1e-9);
}
#[test]
fn a_budget_of_zero_never_says_room() {
assert!(matches!(pressure_of_commit(1, 0), Pressure::Over { .. }));
}
}
+17
View File
@@ -254,6 +254,23 @@ there is room. Linux and macOS have no such limit: there the game steps down bef
The game ends with a crash report only when it can't run under the limit: on Windows no frame for a minute
while faulting hard, elsewhere 20 seconds over the limit with nothing left to give up.
**`HORIZON_MEMORY_HARD_MB` / `/ramlimit hard <MB>`** is the other contract, the one `-Xmx` gives
Java: a budget for the memory the process commits, not only for the part that stays in RAM. Nothing is
paged out to meet it. Once a second the game compares what the process committed with the budget and,
over nine tenths of it, gives up far terrain and then render distance the way it does under a low RAM
limit, until it fits; when there is room again, both come back. Below a budget the game can reach even
at its lowest distance, it ends after twenty seconds with a crash report naming what it held and the
budget, instead of sitting in the page file.
The allocator holds the same line: it counts the bytes the game asks for, a free returns them at once,
and a request past the ceiling fails rather than being served, so a sudden burst cannot pass the budget
in the second before the next look. That failure ends the game with a message naming the ceiling.
Set it at or below the RAM limit; alone it also becomes the RAM limit. What it counts is the whole
process: on a clean launch this build commits 1.1 to 1.2 GB before a world is open (the renderer and
the game's resources), so a budget under about 1.4 GB leaves too little to run, exactly as `-Xmx` too
low throws `OutOfMemoryError`.
### Discord Rich Presence
While Discord runs on the same computer, the player's Discord profile shows