mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
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:
1 parent
02c8607ee8
commit
caa34071cd
8 files changed
+601
-19
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
|
||||
@@ -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 { .. }));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user