mirror of
https://github.com/unslothai/unsloth.git
synced 2026-08-24 00:04:14 +00:00
* 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>
180 lines
6.5 KiB
Python
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)
|