204 lines
6.8 KiB
Python
204 lines
6.8 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""Continuous usage reporting: periodic interval counters and histograms."""
|
|
|
|
# Future
|
|
from __future__ import annotations
|
|
|
|
# Standard
|
|
from typing import TYPE_CHECKING
|
|
import os
|
|
import time
|
|
|
|
# Third Party
|
|
import numpy as np
|
|
|
|
# First Party
|
|
from lmcache.logging import init_logger
|
|
from lmcache.usage_telemetry.guard import swallow_telemetry_errors
|
|
from lmcache.usage_telemetry.identity import (
|
|
get_usage_identity,
|
|
is_usage_tracking_enabled,
|
|
)
|
|
from lmcache.usage_telemetry.messages import (
|
|
CacheLifespanMessage,
|
|
ContinuousContextMessage,
|
|
DeploymentMode,
|
|
)
|
|
from lmcache.usage_telemetry.transport import (
|
|
DEFAULT_SENDER,
|
|
UsageMessageSender,
|
|
build_usage_payload,
|
|
usage_server_url,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
# First Party
|
|
from lmcache.observability import LMCacheStats
|
|
from lmcache.v1.metadata import LMCacheMetadata
|
|
|
|
logger = init_logger(__name__)
|
|
|
|
|
|
class ContinuousUsageContext:
|
|
"""Periodic reporter of interval cache-usage counters.
|
|
|
|
Accumulates hit/stored token counts and cache lifespans via
|
|
:meth:`incr_or_send_stats` and flushes them to the stats server every
|
|
``LMCACHE_USAGE_TRACK_INTERVAL`` seconds (default 600). Interval data
|
|
is dropped, not retried, when a send fails; gaps in
|
|
``sequence_number`` mark lost intervals on the backend.
|
|
"""
|
|
|
|
_instance: ContinuousUsageContext | None = None
|
|
|
|
def __init__(
|
|
self,
|
|
metadata: LMCacheMetadata,
|
|
sender: UsageMessageSender | None = None,
|
|
mode: DeploymentMode = DeploymentMode.SINGLE_PROCESS,
|
|
) -> None:
|
|
"""Initialize the reporter.
|
|
|
|
Args:
|
|
metadata: The engine metadata; its kv shape/dtype size the
|
|
stored-bytes estimate.
|
|
sender: Message transport; ``None`` selects the default HTTP
|
|
sender.
|
|
mode: Deployment mode stamped on every payload this reporter
|
|
sends.
|
|
"""
|
|
self.cache_lifespan_buckets: list[float] = [
|
|
0,
|
|
1,
|
|
5,
|
|
10,
|
|
20,
|
|
40,
|
|
60,
|
|
80,
|
|
100,
|
|
250,
|
|
500,
|
|
750,
|
|
1000,
|
|
2500,
|
|
5000,
|
|
]
|
|
self.metadata: LMCacheMetadata = metadata
|
|
self.cache_usage_url: str = usage_server_url(ContinuousContextMessage.ENDPOINT)
|
|
self.cache_lifespan_url: str = usage_server_url(CacheLifespanMessage.ENDPOINT)
|
|
self.min_logging_interval: int = int(
|
|
os.getenv("LMCACHE_USAGE_TRACK_INTERVAL", "600")
|
|
)
|
|
# send the first message immediately after init
|
|
self.last_logged_ts: float = -1
|
|
|
|
self.interval_num_hit_tokens: int = 0
|
|
self.interval_num_stored_tokens: int = 0
|
|
try:
|
|
self.kv_sz_per_token_bytes: int = int(
|
|
np.prod(self.metadata.kv_shape)
|
|
* self.metadata.kv_dtype.itemsize
|
|
/ self.metadata.kv_shape[2]
|
|
)
|
|
except Exception:
|
|
# 0 marks the stored-bytes estimate as unavailable.
|
|
logger.debug("Cannot derive kv bytes per token", exc_info=True)
|
|
self.kv_sz_per_token_bytes = 0
|
|
self.cache_lifespan_data: list[float] = []
|
|
self._sender = sender if sender is not None else DEFAULT_SENDER
|
|
self._mode = mode
|
|
self._sequence_number: int = 0
|
|
|
|
@staticmethod
|
|
def GetOrCreate(metadata: LMCacheMetadata) -> ContinuousUsageContext:
|
|
"""Return the process-wide instance, creating it on first call.
|
|
|
|
Args:
|
|
metadata: The engine metadata used on first creation.
|
|
|
|
Returns:
|
|
The singleton continuous usage context.
|
|
"""
|
|
if ContinuousUsageContext._instance is None:
|
|
ContinuousUsageContext._instance = ContinuousUsageContext(metadata)
|
|
if ContinuousUsageContext._instance.metadata != metadata:
|
|
logger.error(
|
|
"ContinuousUsageContext instance already created with"
|
|
"different metadata. This should not happen except "
|
|
"in test."
|
|
)
|
|
return ContinuousUsageContext._instance
|
|
|
|
def send_caching_message(self) -> None:
|
|
"""Flush the interval counters and lifespan histogram, then reset."""
|
|
self._sequence_number += 1
|
|
identity = get_usage_identity()
|
|
|
|
usage_message = ContinuousContextMessage(
|
|
interval_stored_kv_size=int(
|
|
self.kv_sz_per_token_bytes * self.interval_num_stored_tokens
|
|
),
|
|
interval_num_hit_tokens=int(self.interval_num_hit_tokens),
|
|
interval_num_stored_tokens=int(self.interval_num_stored_tokens),
|
|
sequence_number=self._sequence_number,
|
|
)
|
|
self._sender.send(
|
|
self.cache_usage_url,
|
|
build_usage_payload(usage_message, identity, self._mode),
|
|
)
|
|
self.interval_num_hit_tokens = 0
|
|
self.interval_num_stored_tokens = 0
|
|
|
|
lifespan_message = CacheLifespanMessage(
|
|
cache_lifespan_histogram=self.list_to_histogram(
|
|
self.cache_lifespan_data, self.cache_lifespan_buckets
|
|
),
|
|
sequence_number=self._sequence_number,
|
|
)
|
|
self._sender.send(
|
|
self.cache_lifespan_url,
|
|
build_usage_payload(lifespan_message, identity, self._mode),
|
|
)
|
|
self.cache_lifespan_data = []
|
|
|
|
def list_to_histogram(
|
|
self, data: list[float], buckets: list[float]
|
|
) -> dict[float, int]:
|
|
"""Bucket *data* into a ``{bucket_lower_bound: count}`` histogram.
|
|
|
|
Args:
|
|
data: The raw samples.
|
|
buckets: Ascending bucket boundaries.
|
|
|
|
Returns:
|
|
Mapping from each bucket boundary to the sample count in it.
|
|
"""
|
|
histogram, _ = np.histogram(data, bins=buckets)
|
|
counts = list(histogram)
|
|
counts.insert(0, 0)
|
|
return {
|
|
bucket: int(count) for bucket, count in zip(buckets, counts, strict=False)
|
|
}
|
|
|
|
@swallow_telemetry_errors
|
|
def incr_or_send_stats(self, stats: LMCacheStats) -> None:
|
|
"""Accumulate interval stats and flush when the interval elapsed.
|
|
|
|
No-op when usage tracking is disabled; never raises into the
|
|
stats-logger loop.
|
|
|
|
Args:
|
|
stats: The stats snapshot of the elapsed logging tick.
|
|
"""
|
|
if not is_usage_tracking_enabled():
|
|
return
|
|
self.cache_lifespan_data.extend(stats.interval_request_cache_lifespan)
|
|
self.interval_num_hit_tokens += stats.interval_hit_tokens
|
|
self.interval_num_stored_tokens += stats.interval_stored_tokens
|
|
|
|
cur_ts: float = time.monotonic()
|
|
if cur_ts - self.last_logged_ts >= self.min_logging_interval:
|
|
self.send_caching_message()
|
|
self.last_logged_ts = cur_ts
|