338 lines
12 KiB
Python
338 lines
12 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# Standard
|
|
import argparse
|
|
import random
|
|
import time
|
|
|
|
# Third Party
|
|
from tqdm import tqdm
|
|
import numpy as np
|
|
import torch
|
|
|
|
# First Party
|
|
from lmcache.logging import init_logger
|
|
from lmcache.utils import mock_up_broadcast_fn, mock_up_broadcast_object_fn
|
|
from lmcache.v1.cache_engine import LMCacheEngineBuilder
|
|
from lmcache.v1.config import LMCacheEngineConfig
|
|
from lmcache.v1.gpu_connector.gpu_connectors import VLLMPagedMemGPUConnectorV2
|
|
from lmcache.v1.metadata import LMCacheMetadata
|
|
|
|
logger = init_logger(__name__)
|
|
|
|
|
|
def generate_test_tokens(num_chunks: int, chunk_size: int) -> torch.Tensor:
|
|
"""Generate test tokens for testing.
|
|
The sequence is [0, 1, 2, ..., num_chunks * chunk_size - 1]
|
|
"""
|
|
# Create sequential tokens for testing
|
|
return torch.arange(0, num_chunks * chunk_size, dtype=torch.long, device="cuda")
|
|
|
|
|
|
def generate_kv_cache_paged_list_tensors(
|
|
num_blocks, device, block_size=16, dtype=torch.bfloat16
|
|
):
|
|
"""
|
|
Instead of Tuple[Tuple[Tensor, Tensor]], return List[Tensor]
|
|
where KV are in the same tensor
|
|
"""
|
|
ret = []
|
|
num_layers = 32
|
|
num_heads = 8
|
|
head_size = 128
|
|
shape = [2, num_blocks, block_size, num_heads, head_size]
|
|
|
|
for i in range(num_layers):
|
|
# Use a fixed seed for reproducibility between sender and receiver
|
|
torch.manual_seed(42 + i)
|
|
kv = torch.rand(shape, dtype=dtype, device=device)
|
|
ret.append(kv)
|
|
|
|
return ret
|
|
|
|
|
|
def fill_kv_cache_with_pattern(kv_cache, slot_mapping, pattern_value=0.99):
|
|
"""Fill the KV cache at the specified slot mappings with
|
|
a recognizable pattern
|
|
"""
|
|
print(slot_mapping.shape)
|
|
for layer_idx, layer_tensor in tqdm(enumerate(kv_cache), total=len(kv_cache)):
|
|
# Fill both K and V with the pattern value
|
|
num_blocks = layer_tensor.shape[1]
|
|
block_size = layer_tensor.shape[2]
|
|
new_shape = (2, num_blocks * block_size, 8, 128)
|
|
layer_tensor.reshape(new_shape)[:, slot_mapping, :, :] = pattern_value
|
|
|
|
return kv_cache
|
|
|
|
|
|
def verify_kv_cache_pattern(kv_cache, slot_mapping, pattern_value=0.99, tolerance=0.01):
|
|
"""Verify that the KV cache contains the expected pattern at the
|
|
specified slot mappings
|
|
"""
|
|
logger.info(f"Verifying KV cache pattern {pattern_value}")
|
|
all_correct = True
|
|
for layer_idx, layer_tensor in tqdm(enumerate(kv_cache), total=len(kv_cache)):
|
|
num_blocks = layer_tensor.shape[1]
|
|
block_size = layer_tensor.shape[2]
|
|
new_shape = (2, num_blocks * block_size, 8, 128)
|
|
actual_values = layer_tensor.reshape(new_shape)[:, slot_mapping, :, :]
|
|
# Check if the mean is close to the pattern value
|
|
mean_value = actual_values.mean().item()
|
|
if abs(mean_value - pattern_value) > tolerance:
|
|
logger.error(
|
|
f"Pattern mismatch at layer {layer_idx}: "
|
|
f"expected mean ~{pattern_value}, got {mean_value}"
|
|
)
|
|
all_correct = False
|
|
|
|
return all_correct
|
|
|
|
|
|
def calculate_throughput(total_bytes: int, elapsed_time: float) -> float:
|
|
"""Calculate throughput in GB/s"""
|
|
if elapsed_time == 0:
|
|
return float("inf")
|
|
gb = total_bytes / (1024 * 1024 * 1024)
|
|
return gb / elapsed_time
|
|
|
|
|
|
def create_config(role: str, host: str, port: int) -> LMCacheEngineConfig:
|
|
"""Create a configuration for the LMCacheEngine with Nixl backend."""
|
|
config = LMCacheEngineConfig.from_defaults(
|
|
chunk_size=256,
|
|
local_cpu=False, # Nixl requires local_cpu=False
|
|
max_local_cpu_size=0, # Nixl requires max_local_cpu_size=0
|
|
local_disk=None, # Nixl requires local_disk=None
|
|
max_local_disk_size=0, # Nixl requires max_local_disk_size=0
|
|
remote_url=None, # Nixl requires remote_url=None
|
|
remote_serde=None, # Nixl requires remote_serde=None
|
|
save_decode_cache=False, # Nixl requires save_decode_cache=False
|
|
enable_p2p=False, # Nixl requires enable_p2p=False
|
|
enable_nixl=True, # Enable Nixl
|
|
nixl_role=role, # 'sender' or 'receiver'
|
|
nixl_receiver_host=host,
|
|
nixl_receiver_port=port,
|
|
nixl_buffer_size=2**30, # 1GB
|
|
nixl_buffer_device="cuda",
|
|
)
|
|
return config
|
|
|
|
|
|
def create_metadata() -> LMCacheMetadata:
|
|
"""Create metadata for the LMCacheEngine."""
|
|
# Define KV shape: (num_layers, 2, chunk_size, num_heads, head_dim)
|
|
chunk_size = 256
|
|
num_layers = 32
|
|
num_heads = 32
|
|
head_dim = 128
|
|
kv_shape = (num_layers, 2, chunk_size, num_heads, head_dim)
|
|
|
|
return LMCacheMetadata(
|
|
model_name="test_model",
|
|
world_size=1,
|
|
local_world_size=1,
|
|
worker_id=0,
|
|
local_worker_id=0,
|
|
kv_dtype=torch.bfloat16,
|
|
kv_shape=kv_shape,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
parser = argparse.ArgumentParser(description="Test LMCacheEngine with Nixl backend")
|
|
parser.add_argument(
|
|
"--role",
|
|
type=str,
|
|
required=True,
|
|
choices=["sender", "receiver"],
|
|
help="Role of this instance (sender or receiver)",
|
|
)
|
|
parser.add_argument(
|
|
"--host",
|
|
type=str,
|
|
default="localhost",
|
|
help="Host name/IP for connection",
|
|
)
|
|
parser.add_argument(
|
|
"--port", type=int, default=5555, help="Port number for connection"
|
|
)
|
|
parser.add_argument(
|
|
"--num-chunks", type=int, default=10, help="Number of chunks to send"
|
|
)
|
|
parser.add_argument(
|
|
"--num-rounds",
|
|
type=int,
|
|
default=1,
|
|
help="Number of times to run the experiment",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
# Set fixed random seed for reproducibility
|
|
random.seed(42)
|
|
torch.manual_seed(42)
|
|
np.random.seed(42)
|
|
|
|
# Create configuration and metadata
|
|
config = create_config(args.role, args.host, args.port)
|
|
metadata = create_metadata()
|
|
|
|
# Parameters for paged KV cache
|
|
num_blocks = 10000
|
|
block_size = 16
|
|
dtype = torch.bfloat16
|
|
device = "cuda"
|
|
|
|
max_chunks = num_blocks * block_size // config.chunk_size
|
|
assert args.num_chunks <= max_chunks, f"Number of chunks must be <= {max_chunks}"
|
|
|
|
# Create the VLLMPagedMemGPUConnectorV2
|
|
hidden_dim = 1024
|
|
num_layers = 32
|
|
gpu_connector = VLLMPagedMemGPUConnectorV2(hidden_dim, num_layers)
|
|
|
|
# Calculate the expected total size of data
|
|
kv_shape = gpu_connector.get_shape(config.chunk_size)
|
|
element_size = torch.tensor([], dtype=metadata.kv_dtype).element_size()
|
|
chunk_size_bytes = torch.prod(torch.tensor(kv_shape)).item() * element_size
|
|
total_size = chunk_size_bytes * args.num_chunks
|
|
|
|
# Create the LMCacheEngine (will be reused across rounds)
|
|
engine = LMCacheEngineBuilder.get_or_create(
|
|
"test_engine",
|
|
config,
|
|
metadata,
|
|
gpu_connector,
|
|
mock_up_broadcast_fn,
|
|
mock_up_broadcast_object_fn,
|
|
)
|
|
|
|
# Generate or create buffers that will be reused across rounds
|
|
tokens = generate_test_tokens(args.num_chunks, config.chunk_size)
|
|
slot_indices = list(range(0, num_blocks * block_size))
|
|
random.shuffle(slot_indices)
|
|
slot_mapping = torch.tensor(slot_indices[: len(tokens)], device=device)
|
|
|
|
if args.role == "sender":
|
|
# Generate KV cache once and reuse
|
|
kv_cache = generate_kv_cache_paged_list_tensors(
|
|
num_blocks, device, block_size, dtype
|
|
)
|
|
pattern_value = 0.99
|
|
kv_cache = fill_kv_cache_with_pattern(kv_cache, slot_mapping, pattern_value)
|
|
else: # receiver
|
|
# Create retrieval buffer once and reuse
|
|
retrieved_cache = generate_kv_cache_paged_list_tensors(
|
|
num_blocks, device, block_size, dtype
|
|
)
|
|
|
|
# Track statistics across rounds
|
|
throughputs = []
|
|
|
|
for round_num in range(args.num_rounds):
|
|
logger.info(f"\nStarting round {round_num + 1}/{args.num_rounds}")
|
|
|
|
if args.role == "sender":
|
|
# Wait a bit for the receiver to set up
|
|
time.sleep(2)
|
|
|
|
logger.info(f"Storing {len(tokens)} tokens ({args.num_chunks} chunks)...")
|
|
start_time = time.time()
|
|
engine.store(tokens, kvcaches=kv_cache, slot_mapping=slot_mapping)
|
|
|
|
end_time = time.time()
|
|
elapsed_time = end_time - start_time
|
|
logger.info(f"Stored {len(tokens)} tokens in {elapsed_time:.6f} seconds")
|
|
throughput = calculate_throughput(total_size, elapsed_time)
|
|
logger.info(f"Throughput: {throughput:.2f} GB/s")
|
|
throughputs.append(throughput)
|
|
|
|
else: # receiver
|
|
# Wait for data to be received
|
|
logger.info("Waiting to receive data...")
|
|
|
|
# Poll until we receive all chunks or timeout
|
|
received_count = 0
|
|
start_time = time.time()
|
|
timeout = 60 # seconds
|
|
|
|
while received_count < args.num_chunks:
|
|
# Check how many chunks we've received by looking up tokens
|
|
received_count = engine.lookup(tokens) // config.chunk_size
|
|
|
|
if received_count == args.num_chunks:
|
|
break
|
|
|
|
# Check for timeout
|
|
if time.time() - start_time > timeout:
|
|
logger.error(
|
|
"Timed out waiting for data. Received only "
|
|
f"{received_count}/{args.num_chunks} chunks."
|
|
)
|
|
break
|
|
|
|
time.sleep(0.1) # Small sleep to avoid busy waiting
|
|
|
|
if received_count == args.num_chunks:
|
|
logger.info(f"Received all {args.num_chunks} chunks")
|
|
|
|
# Retrieve and verify the data
|
|
logger.info("Retrieving and verifying data...")
|
|
start_time = time.time()
|
|
|
|
# Retrieve tokens from the cache engine
|
|
ret_mask = engine.retrieve(
|
|
tokens, kvcaches=retrieved_cache, slot_mapping=slot_mapping
|
|
)
|
|
|
|
end_time = time.time()
|
|
elapsed_time = end_time - start_time
|
|
|
|
# Check if all tokens were retrieved
|
|
retrieved_tokens = torch.sum(ret_mask).item()
|
|
if retrieved_tokens == len(tokens):
|
|
logger.info(
|
|
f"Successfully retrieved all {retrieved_tokens} tokens "
|
|
f"in {elapsed_time:.6f} seconds"
|
|
)
|
|
throughput = calculate_throughput(total_size, elapsed_time)
|
|
logger.info(f"Retrieval throughput: {throughput:.2f} GB/s")
|
|
|
|
# Verify the data by checking if the retrieved KV cache
|
|
# has the expected pattern
|
|
pattern_value = 0.99 # Same value used by sender
|
|
if verify_kv_cache_pattern(
|
|
retrieved_cache, slot_mapping, pattern_value
|
|
):
|
|
logger.info(
|
|
"✅ Data verification successful - pattern matches!"
|
|
)
|
|
else:
|
|
logger.error(
|
|
"❌ Data verification failed - pattern doesn't match!"
|
|
)
|
|
else:
|
|
logger.error(
|
|
"Failed to retrieve all tokens. Retrieved "
|
|
f"{retrieved_tokens}/{len(tokens)} tokens."
|
|
)
|
|
else:
|
|
logger.error(f"Only received {received_count}/{args.num_chunks} chunks")
|
|
|
|
# Wait between rounds
|
|
time.sleep(2)
|
|
|
|
# Print summary statistics
|
|
if throughputs:
|
|
mean_throughput = sum(throughputs) / len(throughputs)
|
|
std_throughput = np.std(throughputs) if len(throughputs) > 1 else 0
|
|
logger.info("\nSummary Statistics:")
|
|
logger.info(
|
|
f"Mean throughput: {mean_throughput:.2f} ± {std_throughput:.2f} GB/s"
|
|
)
|
|
logger.info(f"Min throughput: {min(throughputs):.2f} GB/s")
|
|
logger.info(f"Max throughput: {max(throughputs):.2f} GB/s")
|
|
|
|
# Cleanup at the very end
|
|
LMCacheEngineBuilder.destroy("test_engine")
|
|
logger.info("All rounds completed")
|