Files
2026-07-13 12:24:33 +08:00

623 lines
21 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""
Configuration for distributed storage manager
"""
# Standard
from dataclasses import dataclass, field
from typing import Any, Literal, cast
import argparse
import os
# First Party
from lmcache.logging import init_logger
from lmcache.v1.distributed.l2_adapters.config import (
L2AdapterConfigBase,
L2AdaptersConfig,
add_l2_adapters_args,
get_type_name_for_config,
parse_args_to_l2_adapters_config,
)
from lmcache.v1.platform import current_device_spec
logger = init_logger(__name__)
_HYBRID_L1_SINGLE_REGION_L2_ADAPTERS = {
"nixl_store",
"nixl_store_dynamic",
}
def _requires_single_l1_memory_region(
adapter_config: L2AdapterConfigBase,
) -> str | None:
type_name = get_type_name_for_config(adapter_config)
if type_name in _HYBRID_L1_SINGLE_REGION_L2_ADAPTERS:
return type_name
if (
type_name == "mooncake_store"
and getattr(adapter_config, "setup_config", {}).get("protocol") == "rdma"
):
return type_name
return None
def _infer_l1_devdax_overflow_from_dax_adapter(
memory_config: "L1MemoryManagerConfig",
l2_adapter_config: L2AdaptersConfig,
) -> None:
if not memory_config.devdax_path or memory_config.devdax_size_in_bytes:
return
l1_devdax_path = memory_config.devdax_path
remaining_adapters: list[L2AdapterConfigBase] = []
matched_dax_device: Any | None = None
for adapter_config in l2_adapter_config.adapters:
if get_type_name_for_config(adapter_config) != "dax":
remaining_adapters.append(adapter_config)
continue
dax_adapter = cast(Any, adapter_config)
devices = list(getattr(dax_adapter, "devices", []))
matched_devices = [
device
for device in devices
if getattr(device, "device_path", None) == l1_devdax_path
]
if not matched_devices:
remaining_adapters.append(adapter_config)
continue
if matched_dax_device is not None or len(matched_devices) > 1:
raise ValueError(
"Only one DAX device can match l1-devdax-path for hybrid L1"
)
matched_dax_device = matched_devices[0]
remaining_devices = [
device
for device in devices
if getattr(device, "device_path", None) != l1_devdax_path
]
if remaining_devices:
remaining_dax_adapter = type(dax_adapter)(
devices=remaining_devices,
hotplug_enabled=dax_adapter.hotplug_enabled,
slot_bytes=dax_adapter.slot_bytes,
num_store_workers=dax_adapter.num_store_workers,
num_lookup_workers=dax_adapter.num_lookup_workers,
num_load_workers=dax_adapter.num_load_workers,
)
remaining_dax_adapter.eviction_config = dax_adapter.eviction_config
remaining_dax_adapter.persist_config = dax_adapter.persist_config
remaining_dax_adapter.serde_config = dax_adapter.serde_config
remaining_adapters.append(remaining_dax_adapter)
if matched_dax_device is None:
return
max_dax_size_gb = matched_dax_device.max_dax_size_gb
memory_config.devdax_size_in_bytes = int(max_dax_size_gb * (1 << 30))
l2_adapter_config.adapters = remaining_adapters
@dataclass
class L1MemoryManagerConfig:
"""
The configuration for L1 memory manager.
"""
size_in_bytes: int
""" The size of L1 memory in bytes. """
use_lazy: bool
""" Whether to use lazy initialization for L1 memory. """
init_size_in_bytes: int = field(default=20 << 30)
""" The initial size when using lazy allocation. Default is 20GB. """
align_bytes: int = field(default=0x1000)
""" The alignment size in bytes. Default is 4KB. """
shm_name: str = field(default_factory=lambda: f"lmcache_l1_pool_{os.getpid()}")
""" POSIX shared-memory segment name for L1 pool. Empty disables SHM. """
devdax_path: str | None = None
""" Optional Device-DAX path to use as the L1 backing arena. """
devdax_size_in_bytes: int = 0
""" Optional Device-DAX overflow size for hybrid DRAM + DAX L1. """
def __post_init__(self):
self.init_size_in_bytes = min(self.init_size_in_bytes, self.size_in_bytes)
if self.devdax_path is not None:
self.devdax_path = self.devdax_path.strip()
if self.devdax_size_in_bytes < 0:
raise ValueError("devdax_size_in_bytes must be >= 0")
if self.devdax_size_in_bytes and not self.devdax_path:
raise ValueError("devdax_size_in_bytes requires devdax_path")
if self.devdax_path and self.use_lazy:
raise ValueError(
"l1-devdax-path requires lazy allocation to be disabled. "
"Please set --no-l1-use-lazy."
)
if self.devdax_path and self.shm_name:
raise ValueError(
'l1-devdax-path requires SHM to be disabled. Please set --shm-name "".'
)
# LazyMemoryAllocator requires pinned memory support.
# Auto-disable on platforms that don't support it to avoid a RuntimeError.
if self.use_lazy and not current_device_spec.is_pin_supported:
logger.warning(
"LazyMemoryAllocator requires memory pinning which is not "
"supported on the current backend. Disabling l1-use-lazy."
)
self.use_lazy = False
@dataclass
class GdsL1Config:
"""Configuration for the GDS slab-file L1 tier.
When present on :class:`L1ManagerConfig`, the L1 medium becomes an NVMe
slab file accessed via GPUDirect Storage DMA (cuFile on NVIDIA, hipFile on
AMD ROCm) instead of pinned DRAM (mutually exclusive with the pinned-DRAM
tier in ``memory_config``). Carries the slab location, capacity, and DMA
mode.
"""
file_location: str
"""Directory for the slab file (one shared slab per process, used by all
GPU instances)."""
size_in_bytes: int
"""Slab capacity in bytes (from ``--l1-size-gb``). Sizes both the
preallocated slab file and the GDS tier's address space."""
use_direct_io: bool = True
"""Open the slab with ``O_DIRECT`` (required for the GDS DMA fast path)."""
align_bytes: int = 4096
"""Allocation alignment; cuFile/hipFile and O_DIRECT require 4 KiB."""
@dataclass
class L1ManagerConfig:
"""
Special config for the L1 Object/Key manager
"""
memory_config: L1MemoryManagerConfig
""" The memory manager configuration for L1 cache. """
gds_l1_config: "GdsL1Config | None" = None
""" Optional GDS L1 tier. When set, the GDS slab is the L1 medium
(mutually exclusive with the pinned-DRAM tier in ``memory_config``). """
write_ttl_seconds: int = field(default=600)
""" Time to live for each object's write lock. Default is 600s (10 minutes). """
read_ttl_seconds: int = field(default=300)
""" Time to live for each object's read lock. Default is 300s (5 minutes). """
@dataclass
class EvictionConfig:
"""
The configuration for eviction policies (L1 and optionally L2).
"""
eviction_policy: Literal["LRU", "IsolatedLRU", "noop"]
""" The eviction policy to use. """
trigger_watermark: float = field(default=0.8)
""" The memory usage watermark to trigger eviction (0.0 to 1.0). """
eviction_ratio: float = field(default=0.2)
""" The fraction of *allocated* memory to evict when triggered (0.0 to 1.0). """
@dataclass
class StorageManagerConfig:
"""
The configuration for the distributed storage manager.
"""
l1_manager_config: L1ManagerConfig
""" The configuration for the L1 manager. """
eviction_config: EvictionConfig
""" The configuration for eviction policies. """
l2_adapter_config: L2AdaptersConfig = field(
default_factory=lambda: L2AdaptersConfig([])
)
""" The configuration for L2 adapters. """
store_policy: str = "default"
""" The L2 store policy name. """
prefetch_policy: str = "default"
""" The L2 prefetch policy name. """
prefetch_max_in_flight: int = 8
""" Maximum number of concurrent prefetch requests. """
periodic_notifier_interval_ms: int = 5
""" Interval (ms) for the periodic event notifier heartbeat. """
def __post_init__(self) -> None:
normalize_storage_manager_config(self)
validate_storage_manager_config(self)
def normalize_storage_manager_config(config: StorageManagerConfig) -> None:
"""Normalize storage manager configuration.
This consumes a matching DAX adapter as hybrid L1 Device-DAX overflow
capacity.
Args:
config: Storage manager configuration to normalize in place.
Returns:
None.
Raises:
ValueError: If more than one DAX device matches ``l1-devdax-path``.
"""
memory_config = config.l1_manager_config.memory_config
_infer_l1_devdax_overflow_from_dax_adapter(memory_config, config.l2_adapter_config)
def validate_storage_manager_config(config: StorageManagerConfig) -> None:
"""Validate storage manager configuration.
This rejects L2 adapters that require a single contiguous L1 memory
descriptor when hybrid L1 Device-DAX overflow is enabled.
Args:
config: Storage manager configuration to validate.
Returns:
None.
Raises:
ValueError: If mutually exclusive L1 tiers are both configured, or
hybrid L1 is paired with incompatible L2 adapters.
"""
if (
config.l1_manager_config.gds_l1_config is not None
and config.l1_manager_config.memory_config.devdax_path
):
raise ValueError("gds-l1-path cannot be used with l1-devdax-path")
memory_config = config.l1_manager_config.memory_config
if not (memory_config.devdax_path and memory_config.devdax_size_in_bytes):
return
incompatible_adapters = [
adapter_name
for adapter_config in config.l2_adapter_config.adapters
if (adapter_name := _requires_single_l1_memory_region(adapter_config))
is not None
]
if incompatible_adapters:
raise ValueError(
"Hybrid DRAM + Device-DAX L1 cannot be used with L2 adapters "
"that register a single L1 memory region: "
f"{', '.join(incompatible_adapters)}"
)
def l1_exposes_single_memory_region(config: StorageManagerConfig) -> bool:
"""Whether L1 is a single memory region a transfer channel can register.
Args:
config: Storage manager configuration to inspect.
Returns:
``True`` if L1 is a single registerable memory region, ``False`` for
GDS L1 or Device-DAX L1.
"""
l1_config = config.l1_manager_config
if l1_config.gds_l1_config is not None:
return False
if l1_config.memory_config.devdax_path:
return False
return True
def add_storage_manager_args(
parser: argparse.ArgumentParser,
) -> argparse.ArgumentParser:
"""
Add storage manager configuration arguments to an existing parser.
This function allows other modules to integrate storage manager arguments
into their own argument parsers. Arguments are organized into groups to
avoid naming conflicts with other modules.
Args:
parser: The argument parser to add arguments to.
Returns:
argparse.ArgumentParser: The same parser with storage manager
arguments added.
Example:
>>> # In another module that needs its own arguments
>>> parser = argparse.ArgumentParser(description="My Application")
>>> parser.add_argument("--my-arg", type=str)
>>> add_storage_manager_args(parser)
>>> args = parser.parse_args()
>>> config = parse_args_to_config(args)
"""
# L1 Memory Manager Config
memory_group = parser.add_argument_group(
"L1 Memory Manager", "Configuration for L1 memory manager"
)
memory_group.add_argument(
"--l1-size-gb",
type=float,
required=True,
help="The size of L1 memory in GB.",
)
memory_group.add_argument(
"--l1-use-lazy",
action=argparse.BooleanOptionalAction,
default=True,
help="Whether to use lazy loading for L1 memory. (Default is True)",
)
memory_group.add_argument(
"--l1-init-size-gb",
type=int,
default=20,
help="The initial size (GB) when using lazy allocation. Default is 20.",
)
memory_group.add_argument(
"--l1-align-bytes",
type=int,
default=4096,
help="The alignment size in bytes. Default is 4KB (4096 bytes).",
)
memory_group.add_argument(
"--l1-devdax-path",
type=str,
default=None,
help=(
"Optional /dev/dax device or mmap-able file to use as the L1 "
"backing arena. When set, L1 lazy allocation and SHM transfer "
"advertising must be disabled because the L1 bytes live in the DAX "
'map. Set --no-l1-use-lazy and --shm-name "". '
"If a DAX L2 adapter with the same device_path is registered, "
"that adapter's max_dax_size_gb is used as L1 overflow size."
),
)
# GDS L1 tier (optional, opt-in via --gds-l1-path)
gds_group = parser.add_argument_group(
"GDS L1 tier",
"Configuration for the GDS slab-file L1 tier. Setting --gds-l1-path "
"makes the L1 medium an NVMe slab accessed via GPUDirect Storage DMA "
"(cuFile on NVIDIA, hipFile on AMD ROCm) instead of pinned DRAM; "
"--l1-size-gb then sizes the slab. Disable byte-array L2 adapters when "
"this is on.",
)
gds_group.add_argument(
"--gds-l1-path",
type=str,
default=None,
help="NVMe directory path for the GDS L1 slab. Setting this enables GDS L1.",
)
gds_group.add_argument(
"--gds-l1-use-direct-io",
action=argparse.BooleanOptionalAction,
default=True,
help="Open the slab file with O_DIRECT (required for the GDS DMA fast "
"path on ext4). Default True.",
)
# L1 Manager Config (TTL settings)
ttl_group = parser.add_argument_group(
"L1 Manager TTL", "TTL configuration for L1 manager locks"
)
ttl_group.add_argument(
"--l1-write-ttl-seconds",
type=int,
default=600,
help="Time to live for each object's write lock. Default is 600s.",
)
ttl_group.add_argument(
"--l1-read-ttl-seconds",
type=int,
default=300,
help="Time to live for each object's read lock. Default is 300s.",
)
# Eviction Config
eviction_group = parser.add_argument_group(
"Eviction Policy", "Configuration for eviction policies"
)
eviction_group.add_argument(
"--eviction-policy",
type=str,
choices=["LRU", "IsolatedLRU", "noop"],
required=True,
help="The eviction policy to use ('LRU', 'IsolatedLRU', or 'noop'). "
"'IsolatedLRU' maintains one LRU list per cache_salt and requires "
"quotas keyed by cache_salt to be configured via the HTTP API.",
)
eviction_group.add_argument(
"--eviction-trigger-watermark",
type=float,
default=0.8,
help="The memory usage watermark to trigger eviction (0.0 to 1.0). "
"Default is 0.8.",
)
eviction_group.add_argument(
"--eviction-ratio",
type=float,
default=0.2,
help="The fraction of memory to evict when triggered (0.0 to 1.0). "
"Default is 0.2.",
)
# L2 Policies
# Import here to break circular dependency:
# config.py <-> storage_controllers (via eviction_controller)
# Safe because config.py is fully initialized by the time this
# function is called.
# First Party
from lmcache.v1.distributed.storage_controllers.prefetch_policy import (
get_registered_prefetch_policies,
)
from lmcache.v1.distributed.storage_controllers.store_policy import (
get_registered_store_policies,
)
import lmcache.v1.distributed.storage_controllers # noqa: F401
policy_group = parser.add_argument_group(
"L2 Policies", "Store and prefetch policy selection for L2 adapters"
)
policy_group.add_argument(
"--l2-store-policy",
type=str,
choices=get_registered_store_policies(),
default="default",
help="L2 store policy. Determines which adapters receive each key "
"and whether keys are deleted from L1 after L2 store. "
"Default is 'default' (store all keys to all adapters, keep L1).",
)
policy_group.add_argument(
"--l2-prefetch-policy",
type=str,
choices=get_registered_prefetch_policies(),
default="default",
help="L2 prefetch policy. Determines which adapter loads each key "
"when multiple adapters have it. "
"Default is 'default' (pick the first adapter by index).",
)
policy_group.add_argument(
"--l2-prefetch-max-in-flight",
type=int,
default=8,
help="Maximum number of concurrent prefetch requests. Default is 8.",
)
policy_group.add_argument(
"--periodic-notifier-interval-ms",
type=int,
default=5,
help="Interval in ms for the periodic event notifier heartbeat. Default is 5.",
)
# Adapter config
add_l2_adapters_args(parser)
return parser
def get_arg_parser() -> argparse.ArgumentParser:
"""
Get a standalone argument parser for storage manager configuration.
This creates a new parser with only storage manager arguments.
For integrating with other modules' parsers, use add_storage_manager_args()
instead.
Returns:
argparse.ArgumentParser: The argument parser with all storage manager
configuration options.
"""
parser = argparse.ArgumentParser(
description="Distributed Storage Manager Configuration"
)
return add_storage_manager_args(parser)
def parse_args_to_config(
args: argparse.Namespace,
) -> StorageManagerConfig:
"""
Convert parsed command line arguments to a StorageManagerConfig.
Args:
args: Parsed arguments from the argument parser.
Returns:
StorageManagerConfig: The configuration object.
"""
shm_name = getattr(args, "shm_name", None)
if shm_name is None:
memory_config = L1MemoryManagerConfig(
size_in_bytes=int(args.l1_size_gb * (1 << 30)),
use_lazy=args.l1_use_lazy,
init_size_in_bytes=int(args.l1_init_size_gb * (1 << 30)),
align_bytes=args.l1_align_bytes,
devdax_path=args.l1_devdax_path,
)
else:
memory_config = L1MemoryManagerConfig(
size_in_bytes=int(args.l1_size_gb * (1 << 30)),
use_lazy=args.l1_use_lazy,
init_size_in_bytes=int(args.l1_init_size_gb * (1 << 30)),
align_bytes=args.l1_align_bytes,
shm_name=shm_name,
devdax_path=args.l1_devdax_path,
)
gds_l1_config: GdsL1Config | None = None
if getattr(args, "gds_l1_path", None):
# --l1-size-gb is the single L1 size flag; under GDS it sizes the slab.
gds_l1_config = GdsL1Config(
file_location=args.gds_l1_path,
size_in_bytes=int(args.l1_size_gb * (1 << 30)),
use_direct_io=args.gds_l1_use_direct_io,
)
l1_manager_config = L1ManagerConfig(
memory_config=memory_config,
gds_l1_config=gds_l1_config,
write_ttl_seconds=args.l1_write_ttl_seconds,
read_ttl_seconds=args.l1_read_ttl_seconds,
)
eviction_config = EvictionConfig(
eviction_policy=args.eviction_policy,
trigger_watermark=args.eviction_trigger_watermark,
eviction_ratio=args.eviction_ratio,
)
l2_adapter_config = parse_args_to_l2_adapters_config(args)
config = StorageManagerConfig(
l1_manager_config=l1_manager_config,
eviction_config=eviction_config,
l2_adapter_config=l2_adapter_config,
store_policy=args.l2_store_policy,
prefetch_policy=args.l2_prefetch_policy,
prefetch_max_in_flight=args.l2_prefetch_max_in_flight,
periodic_notifier_interval_ms=args.periodic_notifier_interval_ms,
)
return config
def parse_args(args: list[str] | None = None) -> StorageManagerConfig:
"""
Parse command line arguments and return a StorageManagerConfig.
This is a convenience function that combines get_arg_parser() and
parse_args_to_config().
Args:
args: Optional list of arguments to parse. If None, uses sys.argv.
Returns:
StorageManagerConfig: The configuration object.
"""
parser = get_arg_parser()
parsed_args = parser.parse_args(args)
return parse_args_to_config(parsed_args)