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

895 lines
31 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPDX-License-Identifier: Apache-2.0
"""
Managing objects and memory for L1 cache
"""
# Standard
from dataclasses import dataclass
from typing import Literal
import threading
# First Party
from lmcache.logging import init_logger
from lmcache.native_storage_ops import TTLLock
from lmcache.v1.distributed.api import MemoryLayoutDesc, ObjectKey
from lmcache.v1.distributed.config import L1ManagerConfig
from lmcache.v1.distributed.error import L1Error
from lmcache.v1.distributed.internal_api import L1ManagerListener
from lmcache.v1.distributed.memory_manager import (
GDSL1MemoryManager,
L1ManagerProtocol,
L1MemoryManager,
)
from lmcache.v1.distributed.memory_manager.devdax_l1_memory_manager import (
DevDaxL1MemoryManager,
)
from lmcache.v1.memory_management import MemoryObj
from lmcache.v1.mp_observability.event import Event, EventType
from lmcache.v1.mp_observability.event_bus import get_event_bus
from lmcache.v1.mp_observability.otel_init import register_gauge
logger = init_logger(__name__)
# Internal classes and helper functions
@dataclass
class L1ObjectState:
"""
The internal state of an object in L1 cache
"""
memory_obj: MemoryObj
""" The memory object stored in L1 cache. """
write_lock: TTLLock
""" Whether the object is write-locked. """
read_lock: TTLLock
""" The read lock with TTL for the object. """
is_temporary: bool
""" Whether the object is temporary (need to be deleted after read). """
def available_for_read(self) -> bool:
"""Check if the object is available for read.
Returns:
True if the object is not write-locked, False otherwise.
"""
return not self.write_lock.is_locked()
def available_for_write(self) -> bool:
"""Check if the object is available for write.
Returns:
True if the object is not write-locked and has no read locks
and is not a temporary object, False otherwise.
"""
return (
not self.write_lock.is_locked()
and not self.read_lock.is_locked()
and not self.is_temporary
)
def l1_mgr_synchronized(func):
"""
Decorator to mark L1Manager methods as thread-safe
"""
def wrapper(self: "L1Manager", *args, **kwargs):
with self._lock:
return func(self, *args, **kwargs)
return wrapper
L1OperationResult = tuple[L1Error, MemoryObj | None]
# Upper bound for the count parameter in reserve_read / finish_read
# to prevent a single call from holding the global lock for too long.
MAX_READ_LOCK_COUNT = 128
def _validate_extra_count(extra_count: int) -> int:
"""Validate and clamp extra_count.
Args:
extra_count: Extra lock count on top of the
default 1 lock.
Returns:
Clamped value in [0, MAX_READ_LOCK_COUNT - 1].
"""
if extra_count < 0:
logger.warning(
"L1Manager: extra_count=%d is invalid, clamping to 0",
extra_count,
)
return 0
upper = MAX_READ_LOCK_COUNT - 1
if extra_count > upper:
logger.warning(
"L1Manager: extra_count=%d exceeds limit=%d, clamping",
extra_count,
upper,
)
return upper
return extra_count
def _l1_usage_ratio_or_zero(target: "L1Manager | None") -> float:
"""Return ``target.get_memory_usage()`` as a 0.0-1.0 ratio.
Returns 0.0 when ``target`` is None or ``total_bytes`` is zero so the
observable-gauge callback never raises during scrape.
"""
if target is None:
return 0.0
used, total = target.get_memory_usage()
if total <= 0:
return 0.0
return used / total
# Main classes
class L1Manager:
"""
Object lifecycle state machine for L1 cache
+--------+
| None | <---------------------------------------+
+--------+ |
| ^ |
| | (write lock expired) | delete()
| | |
reserve | +----------------------+ |
write() | | |
v | |
+--------------+ +-----------+ |
| write_locked | | |---------------+
| |---------->| ready |
| | finish_ | |---------------+
+--------------+ write() +-----------+ |
^ | |
| | reserve_read() | finish_read()
+--------------------------+ | (if count becomes 0)
reserve_write() | |
v |
+-----------------+ |
| read_locked |-----------+
| (count = 1) |
+-----------------+
| ^
reserve_read() | | finish_read()
v |
+-----------------+
| read_locked |
| (count = 2) |
+-----------------+
| ^
reserve_read() | | finish_read()
v |
(...) (...)
(Higher Counts)
For every operation on list of keys, the operation is atomic
"""
# Singleton dispatch for ``lmcache_mp.l1_memory_usage_bytes``: tests may
# construct multiple L1Managers but the OTel SDK only honors the first
# gauge registration, so the callback reads from the most recently built
# instance via ``_gauge_target``.
_gauge_registered: bool = False
_gauge_target: "L1Manager | None" = None
def __init__(self, config: L1ManagerConfig):
self._lock = threading.Lock()
self._objects: dict[ObjectKey, L1ObjectState] = {}
# GDS, Device-DAX, and CPU L1 are mutually exclusive tiers. Each tier
# owns its backing allocator instead of branching inside the CPU path.
self._memory_manager: L1ManagerProtocol
if config.gds_l1_config is not None:
self._memory_manager = GDSL1MemoryManager(config.gds_l1_config)
logger.info("L1Manager: GDS L1 tier enabled; CPU pinned-DRAM L1 disabled")
elif config.memory_config.devdax_path:
self._memory_manager = DevDaxL1MemoryManager(config.memory_config)
logger.info("L1Manager: Device-DAX L1 tier enabled; CPU-only L1 disabled")
else:
self._memory_manager = L1MemoryManager(config.memory_config)
self._write_ttl_seconds = config.write_ttl_seconds
self._read_ttl_seconds = config.read_ttl_seconds
self._registered_listeners: list[L1ManagerListener] = []
self._event_bus = get_event_bus()
L1Manager._gauge_target = self
if not L1Manager._gauge_registered:
L1Manager._gauge_registered = True
register_gauge(
"lmcache.l1_manager",
"lmcache_mp.l1_memory_usage_bytes",
"Bytes currently held in L1 cache",
lambda: (
L1Manager._gauge_target.get_memory_usage()[0]
if L1Manager._gauge_target is not None
else 0
),
)
register_gauge(
"lmcache.l1_manager",
"lmcache_mp.l1_usage_ratio",
"L1 used/total ratio (0.01.0)",
lambda: _l1_usage_ratio_or_zero(L1Manager._gauge_target),
)
def register_listener(self, listener: L1ManagerListener) -> None:
"""Register a listener for L1Manager events.
Args:
listener: The listener to register.
"""
with self._lock:
self._registered_listeners.append(listener)
@l1_mgr_synchronized
def reserve_read(
self,
keys: list[ObjectKey],
extra_count: int = 0,
) -> dict[ObjectKey, L1OperationResult]:
"""Reserve read access for the given keys.
Args:
keys: The list of object keys to reserve
read access for.
extra_count: Extra read locks on top of the
default 1 lock. Total locks acquired per
key = 1 + extra_count. Useful when multiple
workers each consume one read lock for the
same key (e.g. MLA models with TP > 1).
Returns:
A dictionary mapping each object key to a tuple
of (L1Error, Optional[MemoryObj]).
Errors:
KEY_NOT_EXIST: The key does not exist.
KEY_NOT_READABLE: The key exists but is not
readable.
"""
extra_count = _validate_extra_count(extra_count)
total = 1 + extra_count
ret: dict[ObjectKey, L1OperationResult] = {}
successful_keys: list[ObjectKey] = []
for key in keys:
entry = self._objects.get(key, None)
if entry is None:
ret[key] = (L1Error.KEY_NOT_EXIST, None)
continue
if not entry.available_for_read():
ret[key] = (L1Error.KEY_NOT_READABLE, None)
continue
# TODO(perf): support a count argument in
# TTLLock.lock() to avoid Python for-loop
# overhead (TTLLock is C++ std::atomic).
for _ in range(total):
entry.read_lock.lock()
ret[key] = (L1Error.SUCCESS, entry.memory_obj)
successful_keys.append(key)
for listener in self._registered_listeners:
listener.on_l1_keys_reserved_read(successful_keys)
self._event_bus.publish(
Event(
event_type=EventType.L1_READ_RESERVED,
metadata={"keys": successful_keys},
)
)
return ret
@l1_mgr_synchronized
def unsafe_read(
self,
keys: list[ObjectKey],
) -> dict[ObjectKey, L1OperationResult]:
"""Unsafe read the read-locked objects without adding new read locks.
This method does not acquire read locks. Therefore, the caller need
to make sure the `unsafe_read` is called between `reserve_read` and
`finish_read` calls.
Args:
keys: The list of object keys to read.
Returns:
A dictionary mapping each object key to a tuple of
(L1Error, Optional[MemoryObj]).
Errors:
KEY_NOT_EXIST: The key does not exist.
KEY_NOT_READABLE: The key is not readable (in this case, not read-locked).
"""
ret: dict[ObjectKey, L1OperationResult] = {}
for key in keys:
entry = self._objects.get(key, None)
if entry is None:
ret[key] = (L1Error.KEY_NOT_EXIST, None)
continue
if not entry.read_lock.is_locked():
ret[key] = (L1Error.KEY_NOT_READABLE, None)
continue
ret[key] = (L1Error.SUCCESS, entry.memory_obj)
return ret
@l1_mgr_synchronized
def finish_read(
self,
keys: list[ObjectKey],
extra_count: int = 0,
) -> dict[ObjectKey, L1Error]:
"""Finish read access for the given keys.
Will delete the object if it is temporary and read
count reaches zero.
Args:
keys: The list of object keys to finish read
access for.
extra_count: Extra read locks to release on top
of the default 1. Must match the
``extra_count`` used in the corresponding
``reserve_read`` call.
Returns:
A dictionary mapping each object key to an
L1Error.
Errors:
KEY_NOT_EXIST: The key does not exist.
KEY_IN_WRONG_STATE: The key is write-locked or
non-read-locked, which means the reader may
read inconsistent data.
"""
extra_count = _validate_extra_count(extra_count)
total = 1 + extra_count
need_to_free: list[MemoryObj] = []
need_to_free_keys: list[ObjectKey] = []
ret: dict[ObjectKey, L1Error] = {}
successful_keys: list[ObjectKey] = []
for key in keys:
entry = self._objects.get(key, None)
if entry is None:
logger.warning(
"L1Manager: finish read on non-existing key %s, "
"potential inconsistent data might be read",
key,
)
ret[key] = L1Error.KEY_NOT_EXIST
continue
if entry.write_lock.is_locked():
logger.warning(
"L1Manager: finish read on write-locked key %s, "
"potential inconsistent data might be read",
key,
)
ret[key] = L1Error.KEY_IN_WRONG_STATE
continue
if not entry.read_lock.is_locked():
logger.warning(
"L1Manager: finish read on non-read-locked key %s, "
"potential inconsistent data might be read",
key,
)
ret[key] = L1Error.KEY_IN_WRONG_STATE
continue
# TODO(perf): support a count argument in
# TTLLock.unlock() to avoid Python for-loop
# overhead (TTLLock is C++ std::atomic).
for _ in range(total):
entry.read_lock.unlock()
if entry.is_temporary and not entry.read_lock.is_locked():
# NOTE: temporary objects shouldn't have write-locks
need_to_free.append(entry.memory_obj)
need_to_free_keys.append(key)
del self._objects[key]
ret[key] = L1Error.SUCCESS
successful_keys.append(key)
self._memory_manager.free(need_to_free)
for listener in self._registered_listeners:
listener.on_l1_keys_read_finished(successful_keys)
listener.on_l1_keys_deleted_by_manager(need_to_free_keys)
self._event_bus.publish(
Event(
event_type=EventType.L1_READ_FINISHED,
metadata={"keys": successful_keys},
)
)
self._event_bus.publish(
Event(
event_type=EventType.L1_KEYS_EVICTED,
metadata={"keys": need_to_free_keys},
)
)
return ret
@l1_mgr_synchronized
def reserve_write(
self,
keys: list[ObjectKey],
is_temporary: list[bool],
layout_desc: MemoryLayoutDesc,
mode: Literal["new", "update", "all"] = "all",
) -> dict[ObjectKey, L1OperationResult]:
"""Reserve write access for the given keys.
Args:
keys: The list of object keys to reserve write access for.
is_temporary: The list of booleans indicating whether each key is
temporary.
shape_spec: The memory layout description for the objects to be
allocated.
mode (Literal["new", "update", "all"]): Reservation mode.
- "new": Reserve only new objects that do not exist.
- "update": Reserve only existing objects for update.
- "all": Reserve all writable objects regardless of existence.
Returns:
A dictionary mapping each object key to a tuple of
(L1Error, Optional[MemoryObj]).
Errors:
KEY_NOT_WRITABLE: The key exists but is not writable.
OUT_OF_MEMORY: Not enough memory to allocate for the object.
"""
need_to_allocate: list[tuple[ObjectKey, bool]] = []
ret: dict[ObjectKey, L1OperationResult] = {}
successful_keys: list[ObjectKey] = []
for key, is_temp in zip(keys, is_temporary, strict=False):
entry = self._objects.get(key, None)
if entry is None:
need_to_allocate.append((key, is_temp))
continue
if mode == "new":
ret[key] = (L1Error.KEY_NOT_WRITABLE, None)
continue
if not entry.available_for_write():
ret[key] = (L1Error.KEY_NOT_WRITABLE, None)
continue
entry.write_lock.lock()
ret[key] = (L1Error.SUCCESS, entry.memory_obj)
successful_keys.append(key)
# Early return if no allocation is needed
if len(need_to_allocate) == 0:
return ret
# Don't allow allocation in "update" mode
if mode == "update":
for key, _ in need_to_allocate:
ret[key] = (L1Error.KEY_NOT_WRITABLE, None)
return ret
err, allocated_objs = self._memory_manager.allocate(
layout_desc, len(need_to_allocate)
)
if err != L1Error.SUCCESS:
for key, _ in need_to_allocate:
ret[key] = (L1Error.OUT_OF_MEMORY, None)
# Free the memory if partial allocation succeeded
if allocated_objs:
self._memory_manager.free(allocated_objs)
else:
for (key, is_temp), mem_obj in zip(
need_to_allocate, allocated_objs, strict=False
):
self._objects[key] = L1ObjectState(
memory_obj=mem_obj,
write_lock=TTLLock(self._write_ttl_seconds),
read_lock=TTLLock(self._read_ttl_seconds),
is_temporary=is_temp,
)
self._objects[key].write_lock.lock()
ret[key] = (L1Error.SUCCESS, mem_obj)
successful_keys.append(key)
for listener in self._registered_listeners:
listener.on_l1_keys_reserved_write(successful_keys)
self._event_bus.publish(
Event(
event_type=EventType.L1_WRITE_RESERVED,
metadata={"keys": successful_keys},
)
)
return ret
@l1_mgr_synchronized
def finish_write(
self,
keys: list[ObjectKey],
) -> dict[ObjectKey, L1Error]:
"""Finish write access for the given keys.
Args:
keys: The list of object keys to finish write access for.
Returns:
A dictionary mapping each object key to an L1Error.
Errors:
KEY_NOT_EXIST: The key does not exist.
KEY_IN_WRONG_STATE: The key is not write-locked, or it's read-locked,
which means the writer may have caused inconsistent data.
"""
ret: dict[ObjectKey, L1Error] = {}
successful_keys: list[ObjectKey] = []
for key in keys:
entry = self._objects.get(key, None)
if entry is None:
ret[key] = L1Error.KEY_NOT_EXIST
continue
if not entry.write_lock.is_locked():
logger.warning(
"L1Manager: finish write on non-write-locked key %s, "
"potential inconsistent data might be written",
key,
)
ret[key] = L1Error.KEY_IN_WRONG_STATE
continue
if entry.read_lock.is_locked():
logger.warning(
"L1Manager: finish write on read-locked key %s, "
"potential inconsistent data might be written",
key,
)
ret[key] = L1Error.KEY_IN_WRONG_STATE
continue
entry.write_lock.unlock()
ret[key] = L1Error.SUCCESS
successful_keys.append(key)
for listener in self._registered_listeners:
listener.on_l1_keys_write_finished(successful_keys)
self._event_bus.publish(
Event(
event_type=EventType.L1_WRITE_FINISHED,
metadata={"keys": successful_keys},
)
)
return ret
@l1_mgr_synchronized
def finish_write_and_reserve_read(
self,
keys: list[ObjectKey],
extra_count: int = 0,
) -> dict[ObjectKey, L1OperationResult]:
"""Atomically finish write and acquire read lock for the given keys.
This is used by the prefetch controller after successfully loading
data from L2 into write-reserved L1 buffers. It transitions the
object from write-locked to read-locked in a single atomic step,
preventing a race window where eviction could interfere.
Args:
keys: Keys to transition from write-locked to read-locked.
extra_count: Extra read locks on top of the default 1 lock.
Total locks acquired per key = 1 + extra_count. Useful
when multiple TP workers each consume one read lock for
the same key (e.g. MLA models with TP > 1).
Returns:
A dictionary mapping each object key to a tuple of
(L1Error, Optional[MemoryObj]).
Errors:
KEY_NOT_EXIST: The key does not exist.
KEY_IN_WRONG_STATE: The key is not write-locked, or it already
has read locks.
"""
extra_count = _validate_extra_count(extra_count)
total = 1 + extra_count
ret: dict[ObjectKey, L1OperationResult] = {}
successful_keys: list[ObjectKey] = []
for key in keys:
entry = self._objects.get(key, None)
if entry is None:
ret[key] = (L1Error.KEY_NOT_EXIST, None)
continue
if not entry.write_lock.is_locked():
logger.warning(
"L1Manager: finish_write_and_reserve_read on "
"non-write-locked key %s",
key,
)
ret[key] = (L1Error.KEY_IN_WRONG_STATE, None)
continue
if entry.read_lock.is_locked():
logger.warning(
"L1Manager: finish_write_and_reserve_read on read-locked key %s",
key,
)
ret[key] = (L1Error.KEY_IN_WRONG_STATE, None)
continue
entry.write_lock.unlock()
for _ in range(total):
entry.read_lock.lock()
ret[key] = (L1Error.SUCCESS, entry.memory_obj)
successful_keys.append(key)
for listener in self._registered_listeners:
listener.on_l1_keys_finish_write_and_reserve_read(successful_keys)
self._event_bus.publish(
Event(
event_type=EventType.L1_WRITE_FINISHED_AND_READ_RESERVED,
metadata={"keys": successful_keys},
)
)
return ret
@l1_mgr_synchronized
def delete(self, keys: list[ObjectKey]) -> dict[ObjectKey, L1Error]:
"""Delete the given keys from L1 cache.
Args:
keys: The list of object keys to delete.
Returns:
A dictionary mapping each object key to an L1Error.
Errors:
KEY_NOT_EXIST: The key does not exist.
KEY_IS_LOCKED: The key is locked (either write-locked or read-locked
and cannot be deleted).
"""
need_to_free: list[MemoryObj] = []
ret: dict[ObjectKey, L1Error] = {}
successful_keys: list[ObjectKey] = []
for key in keys:
entry = self._objects.get(key, None)
if entry is None:
ret[key] = L1Error.KEY_NOT_EXIST
continue
if entry.read_lock.is_locked() or entry.write_lock.is_locked():
ret[key] = L1Error.KEY_IS_LOCKED
continue
need_to_free.append(entry.memory_obj)
del self._objects[key]
ret[key] = L1Error.SUCCESS
successful_keys.append(key)
self._memory_manager.free(need_to_free)
for listener in self._registered_listeners:
listener.on_l1_keys_deleted_by_manager(successful_keys)
self._event_bus.publish(
Event(
event_type=EventType.L1_KEYS_EVICTED,
metadata={"keys": successful_keys},
)
)
return ret
def touch_keys(self, keys: list[ObjectKey]):
"""Touch the given keys, marking the keys as accessed(retrieved or stored).
Args:
keys: The list of object keys to touch.
"""
for listener in self._registered_listeners:
listener.on_l1_keys_accessed(keys)
@l1_mgr_synchronized
def clear(self, force: bool = False) -> None:
"""Clear objects from L1 cache.
Args:
force: If True, clear ALL objects including locked ones.
This may corrupt in-flight store/prefetch operations.
If False (default), only clear unlocked objects, keeping
write-locked and read-locked objects intact.
"""
if force:
logger.warning(
"L1Manager: force-clearing all %d objects "
"(including locked ones). This may corrupt in-flight "
"store/prefetch operations — use with caution.",
len(self._objects),
)
all_keys = list(self._objects.keys())
all_memory_objs = [entry.memory_obj for entry in self._objects.values()]
self._memory_manager.free(all_memory_objs)
self._objects.clear()
for listener in self._registered_listeners:
listener.on_l1_keys_deleted_by_manager(all_keys)
self._event_bus.publish(
Event(
event_type=EventType.L1_KEYS_EVICTED,
metadata={"keys": all_keys},
)
)
logger.info(
"L1Manager: cleared %d objects, 0 remaining.",
len(all_keys),
)
return
keys_to_clear: list[ObjectKey] = []
objs_to_free: list[MemoryObj] = []
locked_count = 0
for key, entry in list(self._objects.items()):
if entry.write_lock.is_locked() or entry.read_lock.is_locked():
locked_count += 1
continue
keys_to_clear.append(key)
objs_to_free.append(entry.memory_obj)
for key in keys_to_clear:
del self._objects[key]
self._memory_manager.free(objs_to_free)
if keys_to_clear:
for listener in self._registered_listeners:
listener.on_l1_keys_deleted_by_manager(keys_to_clear)
self._event_bus.publish(
Event(
event_type=EventType.L1_KEYS_EVICTED,
metadata={"keys": keys_to_clear},
)
)
logger.info(
"L1Manager: cleared %d objects, %d locked objects remaining.",
len(keys_to_clear),
locked_count,
)
def is_key_evictable(self, key: ObjectKey) -> bool:
"""Check if a key is eligible for eviction (not locked).
This method does NOT acquire the global L1Manager lock.
L1Manager.delete() will check again and safely reject a key
that became locked between the check and the actual deletion.
Args:
key: The object key to check.
Returns:
True if the key exists and is not locked (neither read-locked
nor write-locked), False otherwise.
"""
entry = self._objects.get(key, None)
if entry is None:
return False
return not entry.read_lock.is_locked() and not entry.write_lock.is_locked()
def get_memory_usage(self) -> tuple[int, int]:
"""Get the current memory usage of L1 cache.
Returns:
A tuple of (used_memory_bytes, total_memory_bytes).
Note:
In the future, we many want to make a "callback" based mechanism
via "L1ManagerListener" to notify the memory usage changes.
"""
return self._memory_manager.get_memory_usage()
def get_l1_memory_desc(self):
"""Return an L1MemoryDesc describing the underlying L1 memory buffer."""
return self._memory_manager.get_l1_memory_desc()
def close(self) -> None:
"""Close the L1Manager and free all resources."""
with self._lock:
all_memory_objs = [entry.memory_obj for entry in self._objects.values()]
self._memory_manager.free(all_memory_objs)
self._objects.clear()
self._memory_manager.close()
# Status reporting
@l1_mgr_synchronized
def report_status(self) -> dict:
"""Return a status dict describing L1 cache state."""
write_locked = 0
read_locked = 0
temporary = 0
for entry in self._objects.values():
if entry.write_lock.is_locked():
write_locked += 1
if entry.read_lock.is_locked():
read_locked += 1
if entry.is_temporary:
temporary += 1
used, total = self._memory_manager.get_memory_usage()
return {
"is_healthy": self._memory_manager.memcheck(),
"total_object_count": len(self._objects),
"write_locked_count": write_locked,
"read_locked_count": read_locked,
"temporary_count": temporary,
"memory_used_bytes": used,
"memory_total_bytes": total,
"memory_usage_ratio": used / total if total > 0 else 0.0,
"write_ttl_seconds": self._write_ttl_seconds,
"read_ttl_seconds": self._read_ttl_seconds,
}
# Debugging APIs
@l1_mgr_synchronized
def get_object_state(self, key: ObjectKey) -> L1ObjectState | None:
"""Get the internal state of the object with the given key.
Args:
key: The object key.
Returns:
The L1ObjectState if the object exists, None otherwise.
"""
return self._objects.get(key, None)
@l1_mgr_synchronized
def memcheck(self) -> bool:
"""Perform memory check for L1 cache."""
mem_check_result = self._memory_manager.memcheck()
# Log the locked objects for debugging
num_write_locked = 0
num_read_locked = 0
for key, entry in self._objects.items():
if entry.write_lock.is_locked():
num_write_locked += 1
if entry.read_lock.is_locked():
num_read_locked += 1
logger.info(
"L1Manager memcheck: total objects = %d, write-locked = %d, "
"read-locked = %d",
len(self._objects),
num_write_locked,
num_read_locked,
)
return mem_check_result