Repository navigation
[#15178][fix] Account for reclaimable memory in V2 KV cache sizing on integrated GPUs #20951
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
3d34521
f95097d
c3c0eac
a49bc4d
297a683
15bd4cd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,8 +23,9 @@ | |
| import tensorrt_llm | ||
| import tensorrt_llm.bindings.executor as trtllm | ||
| from tensorrt_llm._utils import (confidential_compute_enabled, get_sm_version, | ||
| is_sm_100f, prefer_pinned, | ||
| str_dtype_to_binding, torch_dtype_to_str) | ||
| is_device_integrated, is_sm_100f, | ||
| prefer_pinned, str_dtype_to_binding, | ||
| torch_dtype_to_str) | ||
| from tensorrt_llm.inputs.multimodal import MultimodalParams | ||
|
|
||
| # isort: off | ||
|
|
@@ -73,6 +74,7 @@ | |
| MambaHybridCacheManagerV2, | ||
| MixedMambaHybridCacheManager, | ||
| use_py_mamba_cache_manager) | ||
| from .kv_cache.memory_utils import get_integrated_gpu_free_memory | ||
| from .llm_request import ExecutorResponse, LlmRequestState | ||
| from .model_engine import ModelEngine | ||
| from .py_executor import PyExecutor, PyExecutorResources | ||
|
|
@@ -1364,6 +1366,28 @@ def scaled(cost: CacheCost) -> CacheCost: | |
| include_request_cost=include_request_cost)) | ||
| return None | ||
|
|
||
| def _get_device_free_memory(self) -> tuple[int, int]: | ||
| """Return ``(free, total)`` device bytes for KV cache sizing. | ||
|
|
||
| Equivalent to ``torch.cuda.mem_get_info()`` except for the V2 KV cache | ||
| manager on integrated GPUs, where free memory also counts reclaimable | ||
| page cache (see ``get_integrated_gpu_free_memory``). The V2 manager | ||
| fails at construction when its quota cannot hold the live recurrent | ||
| states, so counting page cache as consumed device memory makes hybrid | ||
| models fail to start on unified-memory systems after a cold image pull. | ||
| Discrete GPUs and the V1 manager are unchanged. | ||
| """ | ||
| free, total = torch.cuda.mem_get_info() | ||
| if not (self._is_kv_cache_manager_v2 and is_device_integrated()): | ||
| return free, total | ||
| adjusted = get_integrated_gpu_free_memory(free, total) | ||
| if adjusted != free: | ||
| logger.info( | ||
| "Integrated GPU: sizing the V2 KV cache from " | ||
| f"{adjusted / GB:.2f} GiB of free memory including reclaimable " | ||
| f"page cache (CUDA reports {free / GB:.2f} GiB free).") | ||
| return adjusted, total | ||
|
|
||
| def _cal_max_memory(self, peak_memory, total_gpu_memory, fraction, | ||
| allocated_bytes: int) -> int: | ||
| """ | ||
|
|
@@ -1747,7 +1771,7 @@ def try_prepare_estimation(self) -> bool: | |
| ) if self._kv_cache_config.max_tokens is not None else estimate_max_tokens | ||
| self._kv_cache_config.avg_seq_len = self._max_seq_len | ||
| if self._is_kv_cache_manager_v2: | ||
| free_mem, _ = torch.cuda.mem_get_info() | ||
| free_mem, _ = self._get_device_free_memory() | ||
| max_gpu_total_bytes = int( | ||
| self._kv_cache_config.free_gpu_memory_fraction * free_mem) | ||
| if (self._max_gpu_total_bytes_in is not None | ||
|
|
@@ -1831,7 +1855,7 @@ def configure_kv_cache_capacity(self, | |
|
|
||
| torch.cuda.empty_cache() | ||
| torch.cuda.reset_peak_memory_stats() | ||
| end, total_gpu_memory = torch.cuda.mem_get_info() | ||
| end, total_gpu_memory = self._get_device_free_memory() | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Knock-on effect on the V2 auto host tier:
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Going GPU-only was not intended. Addressed in 9c615ca: automatic host-tier sizing now reads the same cgroup-bounded shared-memory estimate before either cache tier is allocated, then charges the pending device quota once. Explicit host sizes, the memlock cap and cross-rank synchronization remain unchanged. The regression for your example now gives 4.5 GiB of host cache from 90 GiB available minus an 81 GiB device quota, without the no-headroom warning. Additional cases cover a tighter cgroup, genuinely exhausted headroom, the CUDA fallback, memlock, and the unchanged discrete-device source. These function-body checks pass with mocked runtime imports; real GPU allocation and suspend/resume validation remain pending. — Codex (GPT-6) |
||
| total_used_bytes = total_gpu_memory - end | ||
| model_bytes = torch.cuda.memory_stats()["allocated_bytes.all.current"] | ||
| logger.info( | ||
|
|
@@ -1880,7 +1904,7 @@ def configure_kv_cache_capacity(self, | |
|
|
||
| # Clear the caching allocator before measuring the current memory usage | ||
| torch.cuda.empty_cache() | ||
| end, total_gpu_memory = torch.cuda.mem_get_info() | ||
| end, total_gpu_memory = self._get_device_free_memory() | ||
| torch_used_bytes = torch.cuda.memory_stats( | ||
| )["allocated_bytes.all.current"] | ||
| finally: | ||
|
|
@@ -4831,7 +4855,12 @@ def _try_infer_num_experts(model_config: ModelConfig) -> int: | |
| return num_experts | ||
|
|
||
|
|
||
| def _adjust_torch_mem_fraction(): | ||
| def _adjust_torch_mem_fraction(*, use_reclaimable_memory: bool = False) -> None: | ||
| """Cap PyTorch allocations, counting shared page cache for V2 integrated GPUs. | ||
|
|
||
| Args: | ||
| use_reclaimable_memory: Whether the executor uses V2 memory accounting. | ||
| """ | ||
| # If true, adjust PyTorch CUDA memory fraction to correspond to the | ||
| # total GPU memory minus the statically allocated engine memory. | ||
| # If false, set the PyTorch CUDA memory fraction to 1.0. | ||
|
|
@@ -4869,8 +4898,11 @@ def _adjust_torch_mem_fraction(): | |
| return | ||
| mem_reserved = torch.cuda.memory_reserved() | ||
| mem_free, mem_total = torch.cuda.mem_get_info() | ||
| if use_reclaimable_memory and is_device_integrated(): | ||
| mem_free = get_integrated_gpu_free_memory(mem_free, mem_total) | ||
| safety_margin = 32 * 1024**2 | ||
| mem_torch_max = mem_free + mem_reserved - safety_margin | ||
| mem_torch_max = min(mem_total, | ||
| max(0, mem_free + mem_reserved - safety_margin)) | ||
| mem_torch_fraction = mem_torch_max / mem_total | ||
| logger.info( | ||
| f"Setting PyTorch memory fraction to {mem_torch_fraction} ({mem_torch_max / 1024**3} GiB)" | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| # Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. | ||
| # | ||
| # Licensed under the Apache License, Version 2.0 (the "License"); | ||
| # you may not use this file except in compliance with the License. | ||
| # You may obtain a copy of the License at | ||
| # | ||
| # http://www.apache.org/licenses/LICENSE-2.0 | ||
| # | ||
| # Unless required by applicable law or agreed to in writing, software | ||
| # distributed under the License is distributed on an "AS IS" BASIS, | ||
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| # See the License for the specific language governing permissions and | ||
| # limitations under the License. | ||
|
|
||
| from pathlib import Path | ||
|
|
||
|
|
||
| def _read_meminfo_available_bytes(meminfo_path: str = "/proc/meminfo") -> int | None: | ||
| """Read the kernel's MemAvailable estimate in bytes from a procfs file.""" | ||
| try: | ||
| for line in Path(meminfo_path).read_text().splitlines(): | ||
| if line.startswith("MemAvailable:"): | ||
| return max(0, int(line.split()[1]) * 1024) | ||
| except (OSError, UnicodeError, ValueError, IndexError): | ||
| return None | ||
| return None | ||
|
|
||
|
|
||
| def _read_cgroup_headroom(cgroup: Path) -> int | None: | ||
| """Read one cgroup v2 limit, crediting only readily reclaimable file cache.""" | ||
| try: | ||
| limit_text = (cgroup / "memory.max").read_text().strip() | ||
| if limit_text == "max": | ||
| return None | ||
| limit = int(limit_text) | ||
| current = int((cgroup / "memory.current").read_text()) | ||
| except (OSError, UnicodeError, ValueError): | ||
| return None | ||
|
|
||
| reclaimable = 0 | ||
| try: | ||
| stats = dict(line.split() for line in (cgroup / "memory.stat").read_text().splitlines()) | ||
| # Exclude shmem, pages awaiting writeback, and unevictable pages. | ||
| # These counters can overlap, and unevictable includes anonymous | ||
| # pages, so subtracting all is deliberately conservative. Missing | ||
| # counters get no file-cache credit. | ||
| unavailable = sum( | ||
| max(0, int(stats[key])) | ||
| for key in ("shmem", "file_dirty", "file_writeback", "unevictable") | ||
| ) | ||
| reclaimable = max(0, int(stats["file"]) - unavailable) | ||
| except (OSError, UnicodeError, ValueError, KeyError): | ||
| pass | ||
| return max(0, limit - max(0, current - reclaimable)) | ||
|
|
||
|
|
||
| def _read_cgroup_memory_headroom_bytes( | ||
| cgroup_dir: str = "/sys/fs/cgroup", proc_cgroup_path: str = "/proc/self/cgroup" | ||
| ) -> int | None: | ||
| """Return the tightest finite headroom from the process cgroup and ancestors. | ||
|
|
||
| Args: | ||
| cgroup_dir: Root of the cgroup v2 mount. | ||
| proc_cgroup_path: Procfs file identifying the process's cgroup. | ||
| """ | ||
| root = Path(cgroup_dir) | ||
| leaf = root | ||
| try: | ||
| for line in Path(proc_cgroup_path).read_text().splitlines(): | ||
| hierarchy, controllers, relative = line.split(":", 2) | ||
| if hierarchy == "0" and not controllers: | ||
| parts = Path(relative.lstrip("/")).parts | ||
| if ".." not in parts: | ||
| candidate = root.joinpath(*parts) | ||
| if candidate.is_dir(): | ||
| leaf = candidate | ||
| break | ||
| except (OSError, UnicodeError, ValueError): | ||
| pass | ||
|
|
||
| # A private namespace or a subtree mount can expose the process cgroup at | ||
| # the mount root, even if the reported host-relative path is not visible. | ||
| headrooms = [] | ||
| while True: | ||
| headroom = _read_cgroup_headroom(leaf) | ||
| if headroom is not None: | ||
| headrooms.append(headroom) | ||
| if leaf == root: | ||
| break | ||
| leaf = leaf.parent | ||
| return min(headrooms) if headrooms else None | ||
|
|
||
|
|
||
| def get_integrated_gpu_free_memory(cuda_free: int, cuda_total: int) -> int: | ||
| """Estimate allocatable shared memory, including reclaimable host page cache. | ||
|
|
||
| MemAvailable can include file cache that CUDA does not report as free on | ||
| integrated GPUs. Finite cgroup headroom is a hard upper bound, including | ||
| when it is below CUDA free or MemAvailable cannot be read. | ||
|
|
||
| Args: | ||
| cuda_free: Free bytes reported by CUDA; used if MemAvailable is absent. | ||
| cuda_total: Device capacity in bytes, an upper bound on the estimate. | ||
| """ | ||
| available = _read_meminfo_available_bytes() | ||
| available = cuda_free if available is None else max(cuda_free, available) | ||
| available = min(available, cuda_total) | ||
| cgroup_headroom = _read_cgroup_memory_headroom_bytes() | ||
| if cgroup_headroom is not None: | ||
| available = min(available, cgroup_headroom) | ||
| return max(0, available) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The KV budget now counts reclaimable page cache, but
_adjust_torch_mem_fraction(L4906) still caps PyTorch at rawmem_get_info()free + reserved. Once the larger pool has taken the free pages and part of the cache, I'd expect CUDA free on Spark to be near zero, so the headroom this sizing leaves for later torch allocations (penalty workspace, multimodal encoder reserve, decode arena) sits in page cache the fraction cap does not count. Did the Spark runs hit a lazily allocated buffer, e.g. a request with penalties? If not, should_adjust_torch_mem_fractionuse the same integrated-GPU estimate?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The earlier Spark runs did not use penalties, so they did not establish coverage of that lazy allocation path. Addressed the accounting gap in 9c615ca: the final executor enables the same shared-memory estimate for
_adjust_torch_mem_fractionwhen the resolved manager is V2 on an integrated GPU. Reserved PyTorch bytes and the 32 MiB safety margin remain accounted for, and the fraction is bounded to [0, 1]. V1 and discrete devices keep the CUDA-free source.Added regression coverage for reclaimable headroom, restrictive cgroups, unavailable MemAvailable, device-total clamping, and the version/device gates. The selected function-body checks pass with mocked runtime imports; full-module and real Spark penalty-workspace validation remain pending. The previous GPU results should not be read as validation of this revision.
— Codex (GPT-6)