unsloth/studio/backend/utils/model_memory_settings.py
Michael Han 1b3c31e718
Add Model memory settings to keep a loaded model in VRAM (#8002)
* Add Model memory settings to keep a loaded model in VRAM

Two independent opt-ins under Settings -> System, both off by default:

Keep model in GPU memory: vetoes the idle auto-unload TTL and passes
--mlock, so the weights are not handed back to system RAM between prompts
and re-uploaded on the next one.

Don't reserve system RAM for the model: drops --mlock and --no-mmap so
llama.cpp keeps its default mmap path instead of holding a full host copy
of the weights.

--mlock is itself a full-model RAM reservation, so no-reserve wins on that
flag and the UI says so. With both off nothing is stripped, so a hand-typed
--mlock or --no-mmap still applies exactly as before.

Also adds a hint prop to SettingsRow that moves a long description behind a
hover tooltip, and uses it on the two new rows and on Models folder.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Don't let the memory policy rebind the caller's extra_args

load_model reads extra_args after the command is built, where the commit
block treats None as "inherit previous extras" and [] as "clear them".
Rebinding through the policy turned None into [], which cleared saved
extras on an inheriting reload. Keep the stripped list launch-only, so
extra_args keeps its None-vs-[] meaning and a user's saved --mlock or
--no-mmap comes back when the toggle is turned off.

Also scope the SettingsRow label to flex only when a hint is present, so
rows without one render exactly as before.

* Use --load-mode instead of the deprecated --mlock where available

llama.cpp deprecated --mlock, --mmap/--no-mmap and --direct-io in favour of
a single --load-mode enum. Probe for it like the other version-dependent
flags and emit "--load-mode mmap+mlock", which is what --mlock meant
alongside the default mmap. Older or user-supplied binaries without it keep
getting --mlock, which is deprecated but still accepted.

Also strip --load-mode / -lm from pass-through extras under either toggle:
it is the modern spelling of both flags, so a user value would last-wins
override the managed one, and "--load-mode mlock" is a RAM reservation that
no-reserve has to be able to veto. It carries a value, so it is stripped
with its argument rather than as a boolean.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Warn when RLIMIT_MEMLOCK is too low for residency to work

Simulated the toggle against the real llama-server: with RLIMIT_MEMLOCK
below the model size, llama.cpp logs "failed to mlock ...: Resource
temporarily unavailable" and carries on. The load is never broken, which is
the right behaviour, but residency then looks enabled while doing nothing.

Linux commonly ships an 8 MB (older, 64 KB) default, so this would have hit
a lot of hosts silently. Report the soft limit when it is finite and say so
in the section, with the ulimit -l fix. None on macOS (unlimited) and on
Windows (no RLIMIT_MEMLOCK), so nothing is shown there.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Let the memory settings own env vars and the reload hint

Three fixes from review feedback, each verified against the real binary or a
test that fails without them.

llama.cpp reads LLAMA_ARG_MLOCK, LLAMA_ARG_MMAP, LLAMA_ARG_LOAD_MODE and
LLAMA_ARG_DIO before argv, so stripping the equivalent tokens left an
inherited value in force: with LLAMA_ARG_MLOCK=1 exported, turning on "don't
reserve system RAM" still produced an mlocked child. Measured that, and that
argv overrides env. Scrub the group when either toggle is on, like the spec
and placement env groups already do. Untouched with both off.

reload_required only compared the managed --mlock state, so a process
launched with a user --mlock or --no-mmap looked compliant when it was not,
and a process the user had already pinned asked for a pointless reload. Track
the state the child actually launched with (env defaults, argv last-wins) and
compare that against what the settings would produce.

The frontend cached the whole response indefinitely, including reload_required
and memlock_limit_bytes, which describe the loaded process and go stale as
soon as a model is loaded or swapped. Always refetch; concurrent callers still
share one request.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Stop the managed memory flag being reset, skipped or crashed past

Three more from review, each reproduced against the real binary before fixing.

Measured that any trailing mmap-family flag resets the WHOLE load mode:
"--load-mode mmap+mlock --no-mmap" leaves the child unlocked, and so does
"--mmap", and so does "--mlock --no-mmap" on the legacy path. A saved
--no-mmap preset therefore silently cancelled residency. Strip the mmap
toggles from the emitted argv whenever a managed flag goes out, leaving the
stored request alone. Verified all eight preset/binary combinations now pin.

Toggling a setting changes only the launch flags, so the load intent is
unchanged and the already-loaded fast path reused the process and never
applied the setting. Both dedupe entry points funnel through
_runtime_matches_intent, so compare the launched memory state there and force
a real relaunch when it no longer satisfies the settings. The settings route
now shares that predicate, so the reload hint and the reload path cannot
disagree.

supports_load_mode was only assigned inside the probe's try block but read
when building the capability map, so a timed-out or broken --help probe
raised UnboundLocalError instead of falling back and would have blocked the
load. Reproduced it, then initialised it with the other capability flags.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Remove the managed flag on disable, and strip the dio aliases too

Two more from review, both reproduced first.

--direct-io, -dio, --no-direct-io and -ndio are deprecated selectors for the
same load-mode enum, and measured against the binary every one of them resets
the mode and drops the mlock, on both the modern and legacy paths, in both
polarities. A saved DirectIO preset therefore cancelled residency while
_memory_state still claimed the model was pinned. They join the alias group
that is stripped whenever a managed flag goes out, and the state resolver now
understands them, so the recorded state matches the process. Verified across
all sixteen preset-by-binary combinations.

DirectIO streams the weights rather than buffering them, so it is no longer
counted as a full-RAM reservation. Only the modes that skip mmap (none,
mlock) are.

Turning both switches off left the process pinned by the flag this policy
emitted, with no reload prompt and the duplicate-load comparator reusing it,
so residency never actually turned off. Track whether the live flag is ours
and require a relaunch to remove it, while still leaving a purely
user-supplied --mlock alone.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Scope the memory predicate and track suppressed placement too

Three more from review, all reproduced against the current code first.

The predicate ran against every load, but a diffusion GGUF goes through the
same backend and never touches the llama-server memory policy, so its state
stayed at the reset value. With residency on, that made the duplicate-load
comparator refuse every identical /load, tearing the model down and reloading
it each time, and the settings endpoint reported a reload that no reload could
ever satisfy. The state is now None for a process this policy does not govern,
and None always matches.

Only an emitted flag counted as the policy having acted, so a suppressed one
did not. With no-reserve on, a user's own --mlock was stripped and the launch
recorded as untouched; turning the toggle back off then reported nothing to do
and the comparator reused the process, so their flag never came back. Track
that the launch differed from an unmanaged one at all, whether the policy
emitted a flag, suppressed a requested one, or scrubbed an inherited env var.

Residency vetoes the idle-unload TTL, so it changes idle_unload_active on the
auto-switch endpoint, whose client cached its response indefinitely. The Hub
reads that to decide whether to preserve or clear the selected checkpoint on
an empty /status, so a stale copy cleared it. Saving model-memory settings now
invalidates that cache.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Clear the mlock when resolving --no-mmap

--no-mmap is the deprecated selector for the whole "none" load mode, so it
drops the mlock as well, but the resolver only set the reservation bit. A
process launched with extras ending "--mlock --no-mmap" was therefore recorded
as pinned when it was not, and enabling residency afterwards reported it as
already compliant, suppressing both the reload hint and the relaunch.

Measured both orderings against the binary: "--mlock --no-mmap" leaves the
child unlocked, "--no-mmap --mlock" locks it. Then checked the resolver's mlock
prediction against the real process across all 64 ordered combinations of the
memory flags, which now agree everywhere.

Bare --mlock is deliberately left setting only the mlock bit. The enum has
both "mlock" and "mmap+mlock" and which one the deprecated flag maps to is not
observable from outside, but it changes no decision here: mlock alone already
counts as a reservation for no-reserve.

* Stop reads that predate an invalidation refilling either cache

Both caches cleared on write but left an already-running read free to store
what it had fetched before the write. Reproduced both deterministically before
changing anything.

Backend: a reader whose SELECT finished just before a model-memory PUT
committed would repopulate the memo with the old value, so the toggle appeared
to revert for the rest of the 2s TTL and a load in that window could launch
flags contradicting the saved setting. The first two attempts at a repro did
not reproduce it, because the harness stalled the reader before its SELECT and
then because the stall outlived the TTL and aged the stale entry out. With the
stall placed after the SELECT and kept under the TTL it fails reliably.

Frontend: an /openai-auto-switch GET already in flight when the model-memory
PUT invalidated would run its .then and refill the cache with the pre-toggle
idleUnloadActive, which the Hub reads to decide whether to keep or clear the
selected checkpoint.

Both now carry a generation counter, bumped on invalidation and checked before
the fill, so an obsolete read returns its value to its own caller without
poisoning the cache for anyone else. Uncontended reads still cache and
concurrent readers still share one request.

* Only page-lock when the weights are in host RAM

mlock pins a whole mapping in system RAM. For a model fully offloaded to
a discrete GPU that reserves a second full copy of the weights in RAM and
does nothing for VRAM residency, which is the opposite of what the toggle
promises. Emit it only for unified memory or a partial offload; elsewhere
the idle-unload veto keeps the model resident on its own.

Also retry a settings read that was invalidated mid-flight. Dropping the
stale cache fill was not enough: the racing reader still returned the
pre-write value to its caller, so a load could launch with flags
contradicting the setting that had just been saved.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Derive full offload for manual mode, and stop demanding a skipped mlock

fully_gpu_offloaded is only set in the automatic selected-GPU branch, so
manual GPU memory at the picker's maximum, and a user -ngl in Auto, still
looked host-resident and were page-locked on a discrete GPU. Derive both
from the effective layer and MoE placement. Every unknown answers "host
resident", which is the pre-existing behaviour.

A launch that skips mlock on purpose recorded (False, False), which the
comparator read as contradicting residency: the reload hint never cleared
and every duplicate load relaunched a process that was already correct.
Track that mlock was not applicable and accept it.

Also key the reload hint on is_active rather than is_loaded. A save that
lands while a load is still passing its health check reported no reload,
even though the child was already committed to the pre-save flags.

Retry auto-switch reads invalidated in flight, matching the settings
cache: the pending promise was still handed to post-write callers, who
put it straight into idleUnloadArmed.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Resolve the memory env vars the way llama.cpp actually applies them

Two things the resolver got wrong about what the child is really running with,
both measured against the shipped binary before changing anything.

The negative DirectIO spellings are a RAM reservation. -ndio and
--no-direct-io were grouped with --direct-io as non-reserving, but upstream
maps them to LLAMA_LOAD_MODE_NONE, the same enum value as --no-mmap, which
reads the weights into a full host buffer, and --no-direct-io produces the same
RssFile shape as --no-mmap rather than the mapped shape. So "Don't reserve
system RAM" left a hand-typed --no-direct-io in the argv and then reported the
process compliant, which is the one thing that toggle exists to prevent. They
now resolve to (mlock=False, reserves_ram=True) and join --no-mmap in the strip
set. The affirmative spellings are unchanged: DirectIO streams and holds no
full copy.

Every LLAMA_ARG_* memory var assigns the whole mode. Each runs the same handler
as its flag, so a later one overwrites an earlier one, in llama.cpp's
option-registration order. LLAMA_ARG_MMAP was treated as a reserves_ram bit
that left an inherited mlock standing, and LLAMA_ARG_DIO was scrubbed but never
read at all. Measured: LLAMA_ARG_MLOCK=1 with either LLAMA_ARG_MMAP=on or
LLAMA_ARG_DIO=0 gives VmLck 0, while the resolver claimed a lock. Turning
residency on against such a process saw an already-compliant launch and
suppressed both the reload hint and the relaunch, so it never actually locked.

Drops _MEMORY_PLACEMENT_FLAGS, which was defined and never referenced.

* Stop the locked-memory warning firing where no lock is requested

mlock_active is reported from the toggle pair alone, and the frontend uses it
to decide whether to show the locked-memory cap warning. On a discrete GPU the
host-residency gate means no page-lock is ever passed, so enabling residency on
a box with the common 8 MB ulimit -l told the user to raise a system limit that
nothing would consult, and named a model that was never going to be pinned.
Measured under ulimit -l 8192: the endpoint answered mlock_active true and
memlock_limit_bytes 8388608 for a fully offloaded load whose own log line said
it was skipping the page-lock.

Report it against the running process instead, reusing the applicability bit
the launch already records, and suppress memlock_limit_bytes with it. With
nothing running there is no launch to read, so the toggles' intent is still
what gets reported.

* Make the memory rows findable, and cover the caches they changed

Three loose ends on the frontend side.

Settings search could not find this feature. The index lists the three new
rows, but search matches label text, and mlock, vram, ulimit, memlock and pin
are not substrings of "Model memory", "Keep model in GPU memory" or "Don't
reserve system RAM for the model". Added a keywords key for the rows, in all
twelve locales, following modelsFolderKeywords. It is never rendered.

The section carried its own byte formatter. It used binary divisors with
decimal labels, so a limit read as GB when it meant GiB, and it was the second
formatter in the tree. Uses the shared formatBytes from features/hub/lib.

The new frontend logic had no tests, in a directory with 68 of them. Covers
what model-memory.ts promises: concurrent reads share one request, a later read
is never served from a cache because the response carries runtime state, a
failed read does not wedge the in-flight slot, the two switches save
independently, and saving invalidates the auto-switch cache that residency
makes stale. Also covers the in-flight retry, which has to hand back the
post-write value rather than the response that predates it. The auth barrel
re-exports a .tsx file that node --experimental-strip-types cannot parse, so
the settings API modules get the same stub treatment export-api already has.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Tighten the comments on the Model Memory fixes for PR #8002

Comment and docstring only, no behaviour change: 18 lines of prose removed
across the flag policy, the settings route and their tests, keeping the
measured facts (upstream maps -ndio to mode none, each LLAMA_ARG_* var assigns
the whole mode) and dropping the restatement around them. Verified with an AST
diff that the code is byte-identical.

* Close the remaining gaps in the page-lock gate

Seven fixes, all in the Model Memory path.

The CPU placement guard now applies to the automatic offload branch too.
It sat behind an `or`, so an auto fit that offloaded every layer skipped
it, and an extra like --n-cpu-moe or --override-tensor still left weights
in RAM unpinned. The whole gate moves into _weights_in_host_memory so it
is testable rather than inline, and it is only asked when page-locking is
actually on the table.

Vulkan integrated GPUs count as host resident. The probe already reports
is_igpu and the fit already treats that VRAM as shared system RAM, so a
full offload onto one is still pageable.

LLAMA_ARG_NO_MMAP is scrubbed and resolved. It disables mmap by presence
alone, whatever the value; measured against the shipped binary, which
emits the same deprecation warning as --no-mmap even when it is "0".

No-reserve strips only load modes that lock or reserve. It stripped every
--load-mode, so a DirectIO preset silently became mmap even though dio
holds no full host copy.

The --fit on fallback re-applies the lock. It fires exactly when the
full-offload prediction that suppressed the lock proves wrong, so the
retry could be left with host-resident weights and no mlock, recorded as
intentionally exempt. Appending wins by last-wins, measured.

The reload hint covers the pre-spawn window, where the placement is
already decided but _process is still None.

mlock_active now describes the lock actually taken once something is
running, so a diffusion runner or a skipped lock no longer tells the user
to raise ulimit -l for a lock nobody took. With nothing loaded it still
reports the intent. The veto note keys on the toggles, which is the
reason that note actually gives.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Apply ruff-format kwarg spacing to the merged llama_cpp.py

The merge left a logger.warning split across two string literals that the
repo's ruff-format hook joins, which is what pre-commit.ci flagged. String
content and the AST are unchanged.

* Let the --fit on retry read the page-lock gate it re-arms

The re-arm added in 7cfa9c013 runs inside _spawn_and_wait but assigns
_mem_host_resident, which is a load_model local. That assignment makes the name
local to _spawn_and_wait, so the read a line above it is an UnboundLocalError:
the fallback raises instead of retrying with --fit on, exactly on the path
where the fit estimate was optimistic and the load was already in trouble.
Caught by ruff F823.

Declares it nonlocal alongside _last_spawn_cmd, which is what the write-back
was for. Pinned with an AST test asserting no nested writer of the gate lacks
the declaration, since the existing retry tests build the argv themselves and
never enter the closure.

* Consult inherited env for CPU placement and PUT staleness

Inherited LLAMA_ARG_OVERRIDE_TENSOR / _CPU_MOE / _N_CPU_MOE keep weights
in host RAM and survive any token stripping, but the page-lock gate only
read the argv. _pipeline_parallel_disabled_by_args already recognised
them, so both now share one predicate rather than two copies that can
drift.

The auto-switch PUT labelled its reply with the generation read after the
await, so a residency write landing mid-flight pinned a stale
idleUnloadActive. Capture the generation before the request and refresh
instead of caching a reply that predates it.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Parse CPU-MoE counts, reconcile the gate env, keep non-reserving env

Three ways the page-lock gate said "host resident" when it is not.

--n-cpu-moe 0 places nothing, so presence alone was wrong; the count is
parsed now, matching the rule the env side already applied. That logic
already existed inline in _pipeline_parallel_disabled_by_args, so both go
through one helper rather than two copies.

Manual mode strips its placement vars from the child env, so the gate was
pinning for a CPU-MoE setting the child never receives. It now reads the
same reconciled env the launch builds, via the same helper, so the two
cannot drift. LLAMA_ARG_OVERRIDE_TENSOR is deliberately not in that list
and still counts.

The env scrub dropped every load-mode variable, including the ones that
hold no full host copy. It now mirrors the argv rule and keeps a DirectIO
or mmap choice, whether it arrives as LLAMA_ARG_DIO, LLAMA_ARG_MMAP or
LLAMA_ARG_LOAD_MODE, and leaves an unrecognised value alone.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Don't let residency purge the user's saved KV resume

get_auto_unload_idle_seconds() returns 0 while keep_resident is on, and the
auto-switch PUT reused that to decide whether to delete an already-saved KV
resume. Residency is not the user turning idle unload off, so saving anything
unrelated threw away chat context that survived before this branch.

Adds idle_unload_is_configured(), which is the same reader minus the veto, and
points the purge at it. The displayed idle_unload_active is unchanged.

* Read llama_cpp.py as utf-8 in the gate AST test

The repo's source-read guard fails a checked-in read without an explicit
encoding, since it breaks on Windows the moment the file gains a non-ASCII
byte.

* Tighten the KV-purge comments for PR #8002

Comment-only: fold the two purge comments into one and cut the docstring to
the two lines that carry the reason. AST-verified identical.

* Don't let a stale prediction decide page-locking or a cached read

Two live issues from the review backlog.

_weights_in_host_memory took fully_gpu_offloaded as proof, but that predicts
our own -ngl -1 --fit off and auto mode appends the user's extras after it.
llama.cpp is last-wins, so a pass-through -ngl 0 runs the model in host RAM
while the gate reported no host weights: no page-lock, and the launch recorded
as deliberately unpinnable. Applies the same guard the launch path already uses
for full_offload_tuning_active.

loadOpenAIAutoSwitchSettings tagged nothing on the in-flight request, so a
caller arriving after an invalidation adopted a GET issued before it and
returned the pre-write idleUnloadActive. The hub poll feeds that straight into
idleUnloadArmed, where disarmed clears the selected checkpoint. The request now
carries the generation it was issued at.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Treat a mixed Vulkan selection as host-resident

Requiring every selected device to be integrated skipped the page-lock when an
iGPU and a discrete card were selected together, but the split still places
weights on the iGPU, whose reported VRAM is shared system RAM. Those pages are
as evictable as when it is the only device, so any selected iGPU counts.

* Record page-lock applicability on every launch, not just locked ones

_memory_mlock_applicable is what a later keep-resident save is compared against,
but the gate only ran when a lock was already on the table, so a default launch
recorded the placeholder True. On a discrete GPU with a full offload that made
enabling Keep resident demand a reload and reject the duplicate-load fast path,
tearing down a healthy server to relaunch byte-identical argv -- the opposite of
what the toggle promises.

The gate now runs every launch. Only the Vulkan probe stays behind should_mlock,
since it spawns a subprocess and skipping it keeps the conservative answer, so
the default path still spawns nothing.

* Drop both memory settings from the cache in one acquisition

The write commits the pair in one transaction, but the invalidation ran key by
key, so a load landing between them could read a fresh keep_resident against a
cached stale no_ram_reserve and emit --mlock for a combination that was never
stored, exactly what the user had just switched off.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Decide the launch policy from one settings snapshot

apply_model_memory_policy read no_ram_reserve, then should_mlock, which reads
both keys again. A save landing between them yields a pair that was never
stored: no strip for the newly committed no-reserve, and no lock either, so a
saved --mlock survives and the child reserves host RAM anyway.

get_model_memory_settings now returns a coherent pair, re-reading when either
generation moves, and the policy derives both decisions from it. The paired
invalidation makes one bumped generation enough to spot the write.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Stop the residency gate pinning a fully offloaded model

Two ways it over-fired, both ending in the redundant host copy the gate exists
to avoid.

_offloads_every_layer proved CPU placement from flag presence while
_args_place_tensors_on_cpu parses the count, so -ngl -1 --n-cpu-moe 0 answered
host-resident for an all-GPU launch. It now uses the parsed predicate, so the
two agree.

Under Vulkan, gpu_indices are Vulkan ordinals, but the ROCm APU helper reads
them as physical ids and could answer for a different device, pinning an
all-discrete offload. The Vulkan probe owns device type there.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Only a fit that is switched on voids the full-offload prediction

My guard in 6b7776929 keyed on the whole layer-offload family, so a pass-through
--fit off cleared fully_gpu_offloaded even though it restates what Studio
already passes. _offloads_every_layer cannot infer a full offload from a fit
flag alone, so the gate answered host-resident and pinned a full host copy for a
discrete full offload.

Upstream requires a value and only a truthy one enables the fitter, so a
disabled fitter cannot move weights to the CPU. Adds fit_is_enabled_in, a
last-wins reader beside the other extras parsers.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Let residency stop unloads without also blocking the reload

The reload-capability checks read the effective TTL, which residency
zeroes, so a model the idle loop had already freed could not come back:
with a standalone UNSLOTH_MODEL_IDLE_TTL and auto-switch off, turning on
Keep resident made the next request fail instead of reloading the model
and then keeping it resident.

They ask a configuration question, so they now read
idle_unload_is_configured, which is the same reader minus the veto and
keeps the identical auto-switch gating. The idle loop still reads the
vetoed value, and so does the idle_unload_active the settings UI shows,
because those are about scheduling.

The auto-switch tests stub the TTL reader to mean "idle unload is on", so
those stubs are paired with the configured reader to keep the two in step.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Treat an explicit CPU device pin as host-resident for PR #8002

* Classify placement from the sanitized extras and env for PR #8002

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Reclassify page-locking after the fit-off retry for PR #8002

* Recompute policy activity after the fit-off retry drops the lock for PR #8002

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Honor an active fitter before declaring full offload for PR #8002

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Unsloth <michaelhan@Michaels-MacBook-Pro.local>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-08-07 18:18:26 -07:00

180 lines
6.5 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Persisted model-memory residency controls.
``keep_resident`` -- weights never go back to system RAM while loaded: no idle
auto-unload, and ``--mlock`` so the OS cannot page them out and re-fault them in.
``no_ram_reserve`` -- no full host-RAM copy: keeps llama.cpp's default mmap path
and drops ``--no-mmap`` / ``--mlock``.
Both on means "live in VRAM, keep no RAM copy, never idle-unload". ``--mlock`` is
itself a full-model RAM reservation, so ``no_ram_reserve`` wins on that flag.
"""
from __future__ import annotations
import threading
import time
from typing import Any, Optional
KEEP_RESIDENT_SETTING_KEY = "model_memory_keep_resident"
NO_RAM_RESERVE_SETTING_KEY = "model_memory_no_ram_reserve"
DEFAULT_KEEP_RESIDENT = False
DEFAULT_NO_RAM_RESERVE = False
# Read on the load path and every idle poll, so memo briefly to spare SQLite.
# Matches openai_auto_switch_settings.
_CACHE_TTL_S = 2.0
_cache_lock = threading.Lock()
_cache: dict[str, tuple[float, Any]] = {}
# Bumped on every write. A read that began before a write must not fill the
# cache with the value it already fetched, or the new setting would appear to
# revert for the rest of the TTL and a load could launch contradicting it.
_generation: dict[str, int] = {}
def _coerce_bool(value: Any) -> Optional[bool]:
if isinstance(value, bool):
return value
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off", ""}:
return False
return None
# A write racing a read is rare, so a couple of retries always converges. The
# bound only exists so a pathological write storm cannot spin here forever.
_MAX_REREADS = 3
def _cached_setting(key: str) -> Any:
for _attempt in range(_MAX_REREADS):
with _cache_lock:
hit = _cache.get(key)
if hit is not None and time.monotonic() - hit[0] < _CACHE_TTL_S:
return hit[1]
generation = _generation.get(key, 0)
try:
from storage.studio_db import get_app_setting
stored = get_app_setting(key, None)
except Exception:
# An unreadable DB must not fail a load; fall back to the default.
return None
with _cache_lock:
if _generation.get(key, 0) == generation:
_cache[key] = (time.monotonic(), stored)
return stored
# A write committed while this read was in flight, so `stored` predates
# it. Returning it would let a load launch with flags contradicting the
# setting that was just saved, so read again against the new generation.
return stored
def _invalidate(*keys: str) -> None:
"""Drop these keys in ONE acquisition. The write commits the pair in one
transaction, so invalidating them separately would let a load in between read
a new keep_resident against a cached old no_ram_reserve and emit --mlock for
a combination that was never stored."""
with _cache_lock:
for key in keys:
_cache.pop(key, None)
_generation[key] = _generation.get(key, 0) + 1
def get_keep_resident() -> bool:
"""True when the loaded model must stay in GPU memory while it is loaded."""
parsed = _coerce_bool(_cached_setting(KEEP_RESIDENT_SETTING_KEY))
return parsed if parsed is not None else DEFAULT_KEEP_RESIDENT
def get_no_ram_reserve() -> bool:
"""True when no full host-RAM copy of the weights may be held."""
parsed = _coerce_bool(_cached_setting(NO_RAM_RESERVE_SETTING_KEY))
return parsed if parsed is not None else DEFAULT_NO_RAM_RESERVE
def should_mlock() -> bool:
"""Whether to pass ``--mlock``.
mlock pins the whole model in host RAM, so it is emitted only when residency
is on and no-reserve is off. The two conflict, and no-reserve wins.
"""
keep_resident, no_ram_reserve = get_model_memory_settings()
return keep_resident and not no_ram_reserve
def _pair_generations() -> tuple[int, int]:
with _cache_lock:
return (
_generation.get(KEEP_RESIDENT_SETTING_KEY, 0),
_generation.get(NO_RAM_RESERVE_SETTING_KEY, 0),
)
def get_model_memory_settings() -> tuple[bool, bool]:
"""``(keep_resident, no_ram_reserve)`` from ONE coherent snapshot.
Read one after the other, a save landing in between returns a pair that was
never stored, and the launch then strips for one setting while locking for
the other. The write drops both keys in a single acquisition, so a bumped
generation on either side is enough to spot it and read again.
"""
pair = (get_keep_resident(), get_no_ram_reserve())
for _attempt in range(_MAX_REREADS):
before = _pair_generations()
pair = (get_keep_resident(), get_no_ram_reserve())
if _pair_generations() == before:
return pair
return pair
def set_model_memory_settings(
keep_resident: Any = None, no_ram_reserve: Any = None
) -> tuple[bool, bool]:
"""One-transaction write; ``None`` leaves a stored value untouched."""
updates: dict[str, bool] = {}
if keep_resident is not None:
parsed = _coerce_bool(keep_resident)
if parsed is None:
raise ValueError("Keep model in GPU memory must be true or false.")
updates[KEEP_RESIDENT_SETTING_KEY] = parsed
if no_ram_reserve is not None:
parsed = _coerce_bool(no_ram_reserve)
if parsed is None:
raise ValueError("Do not reserve system RAM must be true or false.")
updates[NO_RAM_RESERVE_SETTING_KEY] = parsed
if updates:
from storage.studio_db import upsert_app_settings
upsert_app_settings(updates)
_invalidate(*updates)
return get_keep_resident(), get_no_ram_reserve()
def memlock_limit_bytes() -> Optional[int]:
"""Soft RLIMIT_MEMLOCK, or None when unlimited or unavailable.
mlock cannot exceed this. Linux commonly defaults to 8 MB, where llama.cpp
logs "failed to mlock" and carries on, so residency would silently do
nothing. None on Windows (no RLIMIT_MEMLOCK) and on macOS (unlimited).
"""
try:
import resource
except ImportError:
return None
try:
soft, _hard = resource.getrlimit(resource.RLIMIT_MEMLOCK)
except (AttributeError, ValueError, OSError):
return None
if soft < 0 or soft == resource.RLIM_INFINITY:
return None
return int(soft)