Files
vllm-omni/vllm_omni/edge/memory.py
T

98 lines
3.6 KiB
Python
Raw Normal View History

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Give freed weight-loading memory back to the OS.
Loading a quantized checkpoint churns far more memory than the weights
occupy. The GPTQ path is the extreme case: `_process_gptq_weights_w4a8`
unpacks 4-bit codes to **int32 -- four bytes per weight** -- permutes, repacks,
and drops the intermediates. For a 1.7 B model that is gigabytes of
short-lived allocation. It is correctly freed, but a caching allocator keeps
the arenas, so it stays resident for the life of the process.
Measured on Spark-X2.5-1.7B (Xeon 8480C, anonymous RSS, which repeats to
within 0.5 MB where VmRSS swings 1-2 GB):
path after load after release freed
W4A8 (GPTQ) 7338 MB 3510 MB 3828 MB
W4A16 (tinygemm) 5023 MB 3411 MB 1612 MB
Two things worth reading off that table. The release is worth **52%** of
resident memory on the path we actually want to deploy; and afterwards both
paths land in the same place, so W4A8's apparent 2.3 GB penalty over W4A16 was
never a real footprint difference -- it was the repack, held by the allocator.
This is only useful on CPU. On GPU the weights are in device memory and the
host-side churn is not what constrains anything.
"""
import ctypes
from vllm.logger import init_logger
logger = init_logger(__name__)
# glibc's trim, then tcmalloc's equivalent: vLLM's CPU docs tell you to
# LD_PRELOAD tcmalloc, so on a correctly configured host the second one is the
# one that does the work.
_RELEASERS = (
("libc.so.6", "malloc_trim", (0,)),
("libtcmalloc_minimal.so.4", "MallocExtension_ReleaseFreeMemory", ()),
("libtcmalloc.so.4", "MallocExtension_ReleaseFreeMemory", ()),
)
def anon_rss_bytes() -> int | None:
"""Resident anonymous memory: allocations, excluding mapped files.
The right metric for a memory budget. ``VmRSS`` also counts the mmap'd
checkpoint's pages, which come and go with page-cache pressure and moved
1-2 GB between identical runs on this host.
"""
try:
with open("/proc/self/status") as f:
for line in f:
if line.startswith("RssAnon:"):
return int(line.split()[1]) * 1024
except OSError:
pass
return None
def release_free_memory() -> list[str]:
"""Ask every allocator we can reach to return free arenas to the OS.
Returns the names of the calls that succeeded. Safe to call repeatedly and
safe when none are available -- it simply does nothing.
"""
released: list[str] = []
for lib, symbol, args in _RELEASERS:
try:
fn = getattr(ctypes.CDLL(lib), symbol)
except (OSError, AttributeError):
continue
try:
fn(*args)
released.append(f"{lib}:{symbol}")
except Exception as exc: # pragma: no cover - defensive
logger.debug("%s:%s failed: %s", lib, symbol, exc)
return released
def release_after_load(context: str = "weight load") -> None:
"""Release, and say how much came back, at INFO."""
before = anon_rss_bytes()
released = release_free_memory()
if not released:
return
after = anon_rss_bytes()
if before is None or after is None:
logger.info("Released allocator arenas after %s via %s", context, released)
return
logger.info(
"Released %.0f MiB after %s (anonymous RSS %.0f -> %.0f MiB) via %s",
(before - after) / 2**20,
context,
before / 2**20,
after / 2**20,
released,
)