commit f213ec89765936f8d36a71674c92a605834810fc Author: wehub-resource-sync Date: Mon Jul 13 12:24:33 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.buildkite/README.md b/.buildkite/README.md new file mode 100644 index 0000000..73ccfde --- /dev/null +++ b/.buildkite/README.md @@ -0,0 +1,15 @@ +# Comprehensive Tests + +An end-to-end integration suite for LMCache & vLLM latest branch. + +## Layout + +- **Scripts**: `scripts/vllm-integration-tests.sh` +- **Configs**: `configs/` +- **Pipeline**: `pipelines/comprehensive-tests.yml` + +## Prepare + +1. Add your YAMLs to `configs/` (e.g. `local_cpu.yaml`, `local_disk.yaml`). + +2. Add the filenames to `cases/comprehensive-cases.txt`. diff --git a/.buildkite/cases/comprehensive-cases.txt b/.buildkite/cases/comprehensive-cases.txt new file mode 100644 index 0000000..8ca9964 --- /dev/null +++ b/.buildkite/cases/comprehensive-cases.txt @@ -0,0 +1,11 @@ +local_cpu.yaml +local_cpu_mla.yaml +local_disk.yaml +pd.yaml +multi_device.yaml +async.yaml +p2p.yaml +layerwise.yaml +local_cpu_with_v3.yaml +local_disk_with_v3.yaml +p2p_with_v3.yaml \ No newline at end of file diff --git a/.buildkite/cases/integration-cases.txt b/.buildkite/cases/integration-cases.txt new file mode 100644 index 0000000..1cc79cb --- /dev/null +++ b/.buildkite/cases/integration-cases.txt @@ -0,0 +1 @@ +dummy.yaml diff --git a/.buildkite/configs/async.yaml b/.buildkite/configs/async.yaml new file mode 100644 index 0000000..df244dd --- /dev/null +++ b/.buildkite/configs/async.yaml @@ -0,0 +1,32 @@ +workload: + type: long_doc_qa + max-inflight-requests: 20 + sleep-time-after-warmup: 20 + num-documents: 20 + repeat-count: 1 + hit-miss-ratio: 2:2 + +docker: + env: + - "LMCACHE_CHUNK_SIZE=256" + - "LMCACHE_LOCAL_CPU=False" + - "LMCACHE_MAX_LOCAL_CPU_SIZE=70" + - "LMCACHE_MAX_LOCAL_DISK_SIZE=70" + - "LMCACHE_LOCAL_DISK=\"file:///local/end-to-end-tests/local/\"" + - "LMCACHE_ENABLE_ASYNC_LOADING=True" + - "LMCACHE_EXTRA_CONFIG={\"lookup_backoff_time\": 0.01, \"use_odirect\": True}" + - "LMCACHE_SAVE_UNFULL_CHUNK=False" + - "LMCACHE_INTERNAL_API_SERVER_ENABLED=True" + - "LMCACHE_INTERNAL_API_SERVER_SOCKET_PATH_PREFIX=/tmp/lmcache_internal_api_server/socket" + +vllm: + model: "meta-llama/Llama-3.1-8B-Instruct" + args: + - "--load-format" + - "dummy" + - "--no-enable-prefix-caching" + - "--kv-transfer-config" + - "{\"kv_connector\":\"LMCacheConnectorV1\",\"kv_role\":\"kv_both\"}" + +checking-fields: + - query_round_time_per_prompt diff --git a/.buildkite/configs/dummy.yaml b/.buildkite/configs/dummy.yaml new file mode 100644 index 0000000..c266453 --- /dev/null +++ b/.buildkite/configs/dummy.yaml @@ -0,0 +1,22 @@ +workload: + type: dummy + +docker: + env: + - "LMCACHE_CHUNK_SIZE=256" + - "LMCACHE_LOCAL_CPU=True" + - "LMCACHE_MAX_LOCAL_CPU_SIZE=5" + - "LMCACHE_INTERNAL_API_SERVER_ENABLED=True" + - "LMCACHE_INTERNAL_API_SERVER_SOCKET_PATH_PREFIX=/tmp/lmcache_internal_api_server/socket" + +vllm: + model: "meta-llama/Llama-3.2-1B-Instruct" + args: + - "--max-model-len" + - "1024" + - "--gpu-memory-utilization" + - "0.35" + - "--load-format" + - "dummy" + - "--kv-transfer-config" + - "{\"kv_connector\":\"LMCacheConnectorV1\",\"kv_role\":\"kv_both\"}" \ No newline at end of file diff --git a/.buildkite/configs/layerwise.yaml b/.buildkite/configs/layerwise.yaml new file mode 100644 index 0000000..ed41144 --- /dev/null +++ b/.buildkite/configs/layerwise.yaml @@ -0,0 +1,26 @@ +workload: + type: long_doc_qa + max-inflight-requests: 20 + +docker: + env: + - "LMCACHE_CHUNK_SIZE=256" + - "LMCACHE_LOCAL_CPU=True" + - "LMCACHE_MAX_LOCAL_CPU_SIZE=5" + - "LMCACHE_USE_LAYERWISE=true" + - "LMCACHE_INTERNAL_API_SERVER_ENABLED=True" + - "LMCACHE_INTERNAL_API_SERVER_SOCKET_PATH_PREFIX=/tmp/lmcache_internal_api_server/socket" + +vllm: + model: "meta-llama/Llama-3.2-1B-Instruct" + args: + - "--load-format" + - "dummy" + - "--no-enable-prefix-caching" + - "--kv-transfer-config" + - "{\"kv_connector\":\"LMCacheConnectorV1\",\"kv_role\":\"kv_both\"}" + - "--compilation-config" + - "{\"cudagraph_mode\":\"PIECEWISE\"}" + +checking-fields: + - query_round_time_per_prompt diff --git a/.buildkite/configs/lmcache_configs/local_cpu_mla.yaml b/.buildkite/configs/lmcache_configs/local_cpu_mla.yaml new file mode 100644 index 0000000..8acd238 --- /dev/null +++ b/.buildkite/configs/lmcache_configs/local_cpu_mla.yaml @@ -0,0 +1,15 @@ +# LMCache Configuration File +# This replaces the individual environment variables for cleaner configuration + +# Basic configurations +chunk_size: 256 +local_cpu: true +max_local_cpu_size: 5 + +internal_api_server_enabled: true +internal_api_server_socket_path_prefix: "/tmp/lmcache_internal_api_server/socket" +internal_api_server_include_index_list: [1] + +extra_config: + save_only_first_rank: true + first_rank_max_local_cpu_size: 5 diff --git a/.buildkite/configs/local_cpu.yaml b/.buildkite/configs/local_cpu.yaml new file mode 100644 index 0000000..6727480 --- /dev/null +++ b/.buildkite/configs/local_cpu.yaml @@ -0,0 +1,23 @@ +workload: + type: long_doc_qa + max-inflight-requests: 20 + +docker: + env: + - "LMCACHE_CHUNK_SIZE=256" + - "LMCACHE_LOCAL_CPU=True" + - "LMCACHE_MAX_LOCAL_CPU_SIZE=5" + - "LMCACHE_INTERNAL_API_SERVER_ENABLED=True" + - "LMCACHE_INTERNAL_API_SERVER_SOCKET_PATH_PREFIX=/tmp/lmcache_internal_api_server/socket" + +vllm: + model: "meta-llama/Llama-3.2-1B-Instruct" + args: + - "--load-format" + - "dummy" + - "--no-enable-prefix-caching" + - "--kv-transfer-config" + - "{\"kv_connector\":\"LMCacheConnectorV1\",\"kv_role\":\"kv_both\"}" + +checking-fields: + - query_round_time_per_prompt diff --git a/.buildkite/configs/local_cpu_mla.yaml b/.buildkite/configs/local_cpu_mla.yaml new file mode 100644 index 0000000..a48f287 --- /dev/null +++ b/.buildkite/configs/local_cpu_mla.yaml @@ -0,0 +1,30 @@ +workload: + type: long_doc_qa + max-inflight-requests: 20 + +docker: + env: + - "LMCACHE_CONFIG_FILE=/etc/lmcache/local_cpu_mla.yaml" + - "LMCACHE_INTERNAL_API_SERVER_ENABLED=True" + - "LMCACHE_INTERNAL_API_SERVER_SOCKET_PATH_PREFIX=/tmp/lmcache_internal_api_server/socket" + gpu_count: 2 + +vllm: + model: "deepseek-ai/DeepSeek-V2-Lite-Chat" + args: + - "--load-format" + - "dummy" + - "--no-enable-prefix-caching" + - "--kv-transfer-config" + - '{"kv_connector":"LMCacheConnectorV1Dynamic","kv_role":"kv_both","kv_connector_module_path":"lmcache.integration.vllm.lmcache_connector_v1","kv_connector_extra_config":{"discard_partial_chunks":false}}' + - "--tensor-parallel-size" + - "2" + - "--block-size" + - "64" + - "--kv-cache-dtype" + - "fp8_e4m3" + - "--quantization" + - "fp8" + +checking-fields: + - query_round_time_per_prompt diff --git a/.buildkite/configs/local_cpu_with_v3.yaml b/.buildkite/configs/local_cpu_with_v3.yaml new file mode 100644 index 0000000..d5592ee --- /dev/null +++ b/.buildkite/configs/local_cpu_with_v3.yaml @@ -0,0 +1,25 @@ +workload: + type: long_doc_qa + max-inflight-requests: 20 + expected-latency-gain: true + +docker: + env: + - "LMCACHE_CHUNK_SIZE=256" + - "LMCACHE_LOCAL_CPU=True" + - "LMCACHE_MAX_LOCAL_CPU_SIZE=5" + - "LMCACHE_USE_GPU_CONNECTOR_V3=True" + - "LMCACHE_INTERNAL_API_SERVER_ENABLED=True" + - "LMCACHE_INTERNAL_API_SERVER_SOCKET_PATH_PREFIX=/tmp/lmcache_internal_api_server/socket" + +vllm: + model: "meta-llama/Llama-3.2-1B-Instruct" + args: + - "--load-format" + - "dummy" + - "--no-enable-prefix-caching" + - "--kv-transfer-config" + - "{\"kv_connector\":\"LMCacheConnectorV1\",\"kv_role\":\"kv_both\"}" + +checking-fields: + - query_round_time_per_prompt diff --git a/.buildkite/configs/local_disk.yaml b/.buildkite/configs/local_disk.yaml new file mode 100644 index 0000000..0e5162c --- /dev/null +++ b/.buildkite/configs/local_disk.yaml @@ -0,0 +1,26 @@ +workload: + type: long_doc_qa + max-inflight-requests: 20 + sleep-time-after-warmup: 20 + +docker: + env: + - "LMCACHE_CHUNK_SIZE=256" + - "LMCACHE_LOCAL_CPU=False" + - "LMCACHE_MAX_LOCAL_CPU_SIZE=10" + - "LMCACHE_MAX_LOCAL_DISK_SIZE=10" + - "LMCACHE_LOCAL_DISK=\"file:///local/end-to-end-tests/local/\"" + - "LMCACHE_INTERNAL_API_SERVER_ENABLED=True" + - "LMCACHE_INTERNAL_API_SERVER_SOCKET_PATH_PREFIX=/tmp/lmcache_internal_api_server/socket" + +vllm: + model: "meta-llama/Llama-3.2-1B-Instruct" + args: + - "--load-format" + - "dummy" + - "--no-enable-prefix-caching" + - "--kv-transfer-config" + - "{\"kv_connector\":\"LMCacheConnectorV1\",\"kv_role\":\"kv_both\"}" + +checking-fields: + - query_round_time_per_prompt diff --git a/.buildkite/configs/local_disk_with_v3.yaml b/.buildkite/configs/local_disk_with_v3.yaml new file mode 100644 index 0000000..0bc380a --- /dev/null +++ b/.buildkite/configs/local_disk_with_v3.yaml @@ -0,0 +1,27 @@ +workload: + type: long_doc_qa + max-inflight-requests: 20 + sleep-time-after-warmup: 20 + +docker: + env: + - "LMCACHE_CHUNK_SIZE=256" + - "LMCACHE_LOCAL_CPU=False" + - "LMCACHE_MAX_LOCAL_CPU_SIZE=10" + - "LMCACHE_MAX_LOCAL_DISK_SIZE=10" + - "LMCACHE_LOCAL_DISK=\"file:///local/end-to-end-tests/local/\"" + - "LMCACHE_USE_GPU_CONNECTOR_V3=True" + - "LMCACHE_INTERNAL_API_SERVER_ENABLED=True" + - "LMCACHE_INTERNAL_API_SERVER_SOCKET_PATH_PREFIX=/tmp/lmcache_internal_api_server/socket" + +vllm: + model: "meta-llama/Llama-3.2-1B-Instruct" + args: + - "--load-format" + - "dummy" + - "--no-enable-prefix-caching" + - "--kv-transfer-config" + - "{\"kv_connector\":\"LMCacheConnectorV1\",\"kv_role\":\"kv_both\"}" + +checking-fields: + - query_round_time_per_prompt diff --git a/.buildkite/configs/multi_device.yaml b/.buildkite/configs/multi_device.yaml new file mode 100644 index 0000000..9bb5bf9 --- /dev/null +++ b/.buildkite/configs/multi_device.yaml @@ -0,0 +1,26 @@ +workload: + type: long_doc_qa + max-inflight-requests: 20 + +docker: + env: + - "LMCACHE_CHUNK_SIZE=256" + - "LMCACHE_LOCAL_CPU=True" + - "LMCACHE_MAX_LOCAL_CPU_SIZE=5" + - "LMCACHE_GDS_PATH=/tmp/" + - "LMCACHE_GDS_BUFFER_SIZE=4096" + - "LMCACHE_EXTRA_CONFIG={\"use_direct_io\": true}" + - "LMCACHE_INTERNAL_API_SERVER_ENABLED=True" + - "LMCACHE_INTERNAL_API_SERVER_SOCKET_PATH_PREFIX=/tmp/lmcache_internal_api_server/socket" + +vllm: + model: "meta-llama/Llama-3.2-1B-Instruct" + args: + - "--load-format" + - "dummy" + - "--no-enable-prefix-caching" + - "--kv-transfer-config" + - "{\"kv_connector\":\"LMCacheConnectorV1\",\"kv_role\":\"kv_both\"}" + +checking-fields: + - query_round_time_per_prompt diff --git a/.buildkite/configs/p2p.yaml b/.buildkite/configs/p2p.yaml new file mode 100644 index 0000000..f66010f --- /dev/null +++ b/.buildkite/configs/p2p.yaml @@ -0,0 +1,69 @@ +workload: + type: long_doc_qa + num-documents: 20 + max-inflight-requests: 1 + repeat-count: 1 + +feature: + type: p2p + +docker1: + env: + - "LMCACHE_MAX_LOCAL_CPU_SIZE=60" + - "LMCACHE_ENABLE_ASYNC_LOADING=True" + - "LMCACHE_ENABLE_P2P=True" + - "LMCACHE_P2P_HOST=localhost" + - "LMCACHE_P2P_INIT_PORTS=8200" + - "LMCACHE_P2P_LOOKUP_PORTS=8201" + - "LMCACHE_TRANSFER_CHANNEL=nixl" + - "LMCACHE_ENABLE_CONTROLLER=True" + - "LMCACHE_LMCACHE_INSTANCE_ID=lmcache_instance_1" + - "LMCACHE_LMCACHE_WORKER_PORTS=8500" + - "LMCACHE_EXTRA_CONFIG={\"lookup_backoff_time\": 0.001}" + - "LMCACHE_SAVE_UNFULL_CHUNK=False" + - "PYTHONHASHSEED=123" + - "LMCACHE_INTERNAL_API_SERVER_ENABLED=True" + - "LMCACHE_INTERNAL_API_SERVER_SOCKET_PATH_PREFIX=/tmp/lmcache_internal_api_server/socket" + pull-port: 8300 + reply-port: 8400 + +docker2: + env: + - "LMCACHE_MAX_LOCAL_CPU_SIZE=60" + - "LMCACHE_ENABLE_ASYNC_LOADING=True" + - "LMCACHE_ENABLE_P2P=True" + - "LMCACHE_P2P_HOST=localhost" + - "LMCACHE_P2P_INIT_PORTS=8202" + - "LMCACHE_P2P_LOOKUP_PORTS=8203" + - "LMCACHE_TRANSFER_CHANNEL=nixl" + - "LMCACHE_ENABLE_CONTROLLER=True" + - "LMCACHE_LMCACHE_INSTANCE_ID=lmcache_instance_2" + - "LMCACHE_LMCACHE_WORKER_PORTS=8501" + - "LMCACHE_EXTRA_CONFIG={\"lookup_backoff_time\": 0.001}" + - "LMCACHE_SAVE_UNFULL_CHUNK=False" + - "PYTHONHASHSEED=123" + - "LMCACHE_INTERNAL_API_SERVER_ENABLED=True" + - "LMCACHE_INTERNAL_API_SERVER_SOCKET_PATH_PREFIX=/tmp/lmcache_internal_api_server/socket" + pull-port: 8300 + reply-port: 8400 + +vllm1: + model: "meta-llama/Llama-3.1-8B-Instruct" + args: + - "--load-format" + - "dummy" + - "--no-enable-prefix-caching" + - "--kv-transfer-config" + - "{\"kv_connector\":\"LMCacheConnectorV1\",\"kv_role\":\"kv_both\"}" + +vllm2: + model: "meta-llama/Llama-3.1-8B-Instruct" + args: + - "--load-format" + - "dummy" + - "--no-enable-prefix-caching" + - "--kv-transfer-config" + - "{\"kv_connector\":\"LMCacheConnectorV1\",\"kv_role\":\"kv_both\"}" + +checking-fields: + - warmup_round_time_per_prompt diff --git a/.buildkite/configs/p2p_with_v3.yaml b/.buildkite/configs/p2p_with_v3.yaml new file mode 100644 index 0000000..708e2ad --- /dev/null +++ b/.buildkite/configs/p2p_with_v3.yaml @@ -0,0 +1,71 @@ +workload: + type: long_doc_qa + num-documents: 20 + max-inflight-requests: 1 + repeat-count: 1 + +feature: + type: p2p + +docker1: + env: + - "LMCACHE_MAX_LOCAL_CPU_SIZE=60" + - "LMCACHE_ENABLE_ASYNC_LOADING=True" + - "LMCACHE_ENABLE_P2P=True" + - "LMCACHE_P2P_HOST=localhost" + - "LMCACHE_P2P_INIT_PORTS=8200" + - "LMCACHE_P2P_LOOKUP_PORTS=8201" + - "LMCACHE_TRANSFER_CHANNEL=nixl" + - "LMCACHE_ENABLE_CONTROLLER=True" + - "LMCACHE_LMCACHE_INSTANCE_ID=lmcache_instance_1" + - "LMCACHE_LMCACHE_WORKER_PORTS=8500" + - "LMCACHE_EXTRA_CONFIG={\"lookup_backoff_time\": 0.001}" + - "LMCACHE_SAVE_UNFULL_CHUNK=False" + - "PYTHONHASHSEED=123" + - "LMCACHE_USE_GPU_CONNECTOR_V3=True" + - "LMCACHE_INTERNAL_API_SERVER_ENABLED=True" + - "LMCACHE_INTERNAL_API_SERVER_SOCKET_PATH_PREFIX=/tmp/lmcache_internal_api_server/socket" + pull-port: 8300 + reply-port: 8400 + +docker2: + env: + - "LMCACHE_MAX_LOCAL_CPU_SIZE=60" + - "LMCACHE_ENABLE_ASYNC_LOADING=True" + - "LMCACHE_ENABLE_P2P=True" + - "LMCACHE_P2P_HOST=localhost" + - "LMCACHE_P2P_INIT_PORTS=8202" + - "LMCACHE_P2P_LOOKUP_PORTS=8203" + - "LMCACHE_TRANSFER_CHANNEL=nixl" + - "LMCACHE_ENABLE_CONTROLLER=True" + - "LMCACHE_LMCACHE_INSTANCE_ID=lmcache_instance_2" + - "LMCACHE_LMCACHE_WORKER_PORTS=8501" + - "LMCACHE_EXTRA_CONFIG={\"lookup_backoff_time\": 0.001}" + - "LMCACHE_SAVE_UNFULL_CHUNK=False" + - "PYTHONHASHSEED=123" + - "LMCACHE_USE_GPU_CONNECTOR_V3=True" + - "LMCACHE_INTERNAL_API_SERVER_ENABLED=True" + - "LMCACHE_INTERNAL_API_SERVER_SOCKET_PATH_PREFIX=/tmp/lmcache_internal_api_server/socket" + pull-port: 8300 + reply-port: 8400 + +vllm1: + model: "meta-llama/Llama-3.1-8B-Instruct" + args: + - "--load-format" + - "dummy" + - "--no-enable-prefix-caching" + - "--kv-transfer-config" + - "{\"kv_connector\":\"LMCacheConnectorV1\",\"kv_role\":\"kv_both\"}" + +vllm2: + model: "meta-llama/Llama-3.1-8B-Instruct" + args: + - "--load-format" + - "dummy" + - "--no-enable-prefix-caching" + - "--kv-transfer-config" + - "{\"kv_connector\":\"LMCacheConnectorV1\",\"kv_role\":\"kv_both\"}" + +checking-fields: + - warmup_round_time_per_prompt diff --git a/.buildkite/configs/pd.yaml b/.buildkite/configs/pd.yaml new file mode 100644 index 0000000..31e2244 --- /dev/null +++ b/.buildkite/configs/pd.yaml @@ -0,0 +1,74 @@ +workload: + type: long_doc_qa + max-inflight-requests: 1 + repeat-count: 1 + completion: true + num-documents: 20 + document-length: 16382 + +feature: + type: pd + +docker-prefiller: + env: + # Force vLLM Model Runner V1: MRV2 (enabled by default for Llama/Mistral + # dense models in vLLM nightly, see vllm-project/vllm#43458) is incompatible + # with KV cache connectors and hangs the PD path. Remove once MRV2 supports + # connectors / auto-falls back to MRV1. + - "VLLM_USE_V2_MODEL_RUNNER=0" + - "LMCACHE_LOCAL_CPU=False" + - "LMCACHE_MAX_LOCAL_CPU_SIZE=5" + - "LMCACHE_ENABLE_PD=True" + - "LMCACHE_TRANSFER_CHANNEL=nixl" + - "LMCACHE_PD_ROLE=sender" + - "LMCACHE_PD_PROXY_HOST=localhost" + - "LMCACHE_PD_BUFFER_SIZE=1073741824" # 1GB + - "LMCACHE_PD_BUFFER_DEVICE=cuda" + - "LMCACHE_SAVE_UNFULL_CHUNK=True" + - "PYTHONHASHSEED=0" + - "LMCACHE_INTERNAL_API_SERVER_ENABLED=True" + - "LMCACHE_INTERNAL_API_SERVER_SOCKET_PATH_PREFIX=/tmp/lmcache_internal_api_server/socket" + proxy-port: 7500 + +docker-decoder: + env: + # Force vLLM Model Runner V1: MRV2 (enabled by default for Llama/Mistral + # dense models in vLLM nightly, see vllm-project/vllm#43458) is incompatible + # with KV cache connectors and hangs the PD path. Remove once MRV2 supports + # connectors / auto-falls back to MRV1. + - "VLLM_USE_V2_MODEL_RUNNER=0" + - "LMCACHE_LOCAL_CPU=False" + - "LMCACHE_MAX_LOCAL_CPU_SIZE=0" + - "LMCACHE_ENABLE_PD=True" + - "LMCACHE_TRANSFER_CHANNEL=nixl" + - "LMCACHE_PD_ROLE=receiver" + - "LMCACHE_PD_PEER_HOST=localhost" + - "LMCACHE_PD_BUFFER_SIZE=2147483648" # 2GB + - "LMCACHE_PD_BUFFER_DEVICE=cuda" + - "LMCACHE_SAVE_UNFULL_CHUNK=True" + - "PYTHONHASHSEED=0" + - "LMCACHE_INTERNAL_API_SERVER_ENABLED=True" + - "LMCACHE_INTERNAL_API_SERVER_SOCKET_PATH_PREFIX=/tmp/lmcache_internal_api_server/socket" + init-port: 7300 + alloc-port: 7400 + +vllm-prefiller: + model: "meta-llama/Llama-3.2-1B-Instruct" + args: + - "--load-format" + - "dummy" + - "--no-enable-prefix-caching" + - "--kv-transfer-config" + - "{\"kv_connector\":\"LMCacheConnectorV1\",\"kv_role\":\"kv_producer\",\"kv_connector_extra_config\": {\"discard_partial_chunks\": false, \"lmcache_rpc_port\": \"producer1\"}}" + +vllm-decoder: + model: "meta-llama/Llama-3.2-1B-Instruct" + args: + - "--load-format" + - "dummy" + - "--no-enable-prefix-caching" + - "--kv-transfer-config" + - "{\"kv_connector\":\"LMCacheConnectorV1\",\"kv_role\":\"kv_consumer\",\"kv_connector_extra_config\": {\"discard_partial_chunks\": false, \"lmcache_rpc_port\": \"consumer1\", \"skip_last_n_tokens\": 1}}" + +checking-fields: + - query_round_time_per_prompt diff --git a/.buildkite/correctness/README.md b/.buildkite/correctness/README.md new file mode 100644 index 0000000..dfa14ac --- /dev/null +++ b/.buildkite/correctness/README.md @@ -0,0 +1,52 @@ +# Correctness Test + +This is a E2E testing suite for whether having a KV pass through LMCache from vllm (storing and loading) will degrade the accuracy of a transformer. + +Unit tests do not suffice in going from request to response and passing through LMCache. We use offline serving +for this testing suite because the API Server of vllm is assumed to be unproblematic. + +## Setup + +We use the [Measuring Massive Multitask Language Understanding](https://arxiv.org/abs/2009.03300) multiple choice dataset downloaded as a compressed tarball in `data.tar.xz` + +The following 3 options are viable: +A. Disable prefix caching on vllm to only use lmcache +B. Use lmcache centralized remote server +C. Use lmcache p2p sharing + +We choose option A to keep the CI server lightweight (only 2x L4s needed). + +We test one small model for each Attention Architecture. Currently dense/standard attention will use `meta-llama/Llama-3.1-8B-Instruct` and +MLA (multi-head latent attention) will use `deepseek-ai/DeepSeek-V2-Lite` with tensor parallel 2. We do not care about the objective accuracy on the MMLU benchmark but only on any differential that appears from using LMCache. + +## CI Agent Pre Set-up + +To ensure the speed of the MMLU Correctness tests, please conduct the following set up on your runner beforehand (only need to do once and `setup.sh` will renew your environment afterwards). + +1. Create a virtual environment at `~/correctness_venv` + +```bash +pip install pandas +``` + +2. Please run the following commands (these will be run by `setup.sh` every time): +```bash +cd ~ +mkdir correctness_repositories +cd correctness_repositories +git clone https://github.com/LMCache/LMCache.git +git clone https://github.com/vllm-project/vllm.git +cd LMCache +pip install -e . +cd ../vllm +pip install -e . +``` + +Explanation: "pull" upstream code in this CI suite by pulling the latest upstream default branch +and let the editable virtual environment update the latest code. This allows a little bit of pre-setup to greatly optimize every run afterwards. + +## Directory Structure +`single-mmlu-test.py` + +The MMLU question dataset will be sent to this single endpoint and accuracy will be evaluated (correct answers are included +in the dataset). When LMCache is used, queries are sent twice. \ No newline at end of file diff --git a/.buildkite/correctness/async_request.py b/.buildkite/correctness/async_request.py new file mode 100755 index 0000000..e1bfa1c --- /dev/null +++ b/.buildkite/correctness/async_request.py @@ -0,0 +1,166 @@ +# SPDX-License-Identifier: Apache-2.0 +# Standard +import argparse +import asyncio +import copy +import json +import logging +import os +import time +import traceback + +# Third Party +from tqdm import tqdm +import aiohttp + +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO) + + +async def make_request(session, payload: tuple, args): + endpoint = args.endpoint + output_file = args.output_file + obj_name, payload = payload + headers = { + "Authorization": f"Bearer {os.getenv('API_KEY', '')}", + "Content-Type": "application/json", + } + try: + async with session.post( + endpoint, json=payload, headers=headers, ssl=False + ) as response: + json_data = await response.json() + content = ( + json_data.get("choices", [{}])[0].get("message", {}).get("content", "") + ) + logger.info(content) + if json_data.get("id") is not None: + with open(output_file, "a") as f: + f.write(f"{json_data.get('id')}:\n{content}\n") + if json_data.get("choices", [{}])[0].get("finish_reason", "") != "stop": + with open(output_file.replace(".txt", "_length.txt"), "a") as f: + f.write(f"{json_data.get('id')}:\n{content}\n") + return True, json_data + except Exception: + logger.error(f"Request failed (Object name: {obj_name})") + traceback.print_exc() + return False, None + + +async def process_requests(payloads: dict, args): + semaphore = asyncio.Semaphore(args.max_concurrency) + + async def sem_task(session, payload: tuple, args): + async with semaphore: + return await make_request(session, payload, args) + + timeout = aiohttp.ClientTimeout(total=6000) + connector = aiohttp.TCPConnector(limit=0) + async with aiohttp.ClientSession(timeout=timeout, connector=connector) as session: + tasks = [sem_task(session, payload, args) for payload in payloads.items()] + + generated_token = [] + for coro in tqdm( + asyncio.as_completed(tasks), total=len(tasks), desc="Requests Progress" + ): + success, content = await coro + if success: + generated_token.append( + content.get("usage", {}).get("completion_tokens", 0) + ) + else: + logger.error(f"Error: {content}") + logger.info(f"Total generated tokens: {sum(generated_token)}") + + +def request_concurrency(requests: dict, args): + same_req = args.same_req + rid = args.rid + counts = args.counts + model = args.model + payloads = dict() + if same_req: + for index, request in requests.items(): + if index == rid: + request.update({"model": model}) + request.update({"stream": False}) + request.update({"temperature": 0}) + request.update({"request_id": index}) + payloads[index] = request + for i in range(counts): + payloads[i] = copy.deepcopy(payloads[index]) + payloads[i]["request_id"] = str(i) + break + + else: + for index, request in requests.items(): + request.update({"model": model}) + request.update({"stream": False}) + request.update({"temperature": 0}) + request["request_id"] = index + payloads[index] = request + + start = time.time() + asyncio.run(process_requests(payloads, args)) + end = time.time() + logger.info(f"Total time: {end - start:.2f} seconds") + + +def main(): + parser = argparse.ArgumentParser(description="Concurrent request tester") + + parser.add_argument( + "--model", type=str, default="Qwen3/Qwen3-32B", help="Model name" + ) + parser.add_argument( + "--endpoint", + type=str, + required=True, + help="API endpoint, e.g. http://host:port/v1/chat/completions", + ) + parser.add_argument( + "--request-number", + type=int, + default=500, + help="Number of requests to read from dataset", + ) + parser.add_argument( + "--max-concurrency", type=int, default=128, help="Max concurrency" + ) + parser.add_argument( + "--dataset_file", type=str, default="data.json", help="Dataset json file" + ) + parser.add_argument( + "--output-file", + type=str, + default="result_output.txt", + help="Output result file prefix", + ) + parser.add_argument( + "--same-req", + action="store_true", + help="Send the same rid request multiple times", + ) + parser.add_argument( + "--rid", type=str, default="", help="The rid to repeat if same_req=True" + ) + parser.add_argument( + "--counts", type=int, default=100, help="How many times to send the same rid" + ) + + args = parser.parse_args() + + # --- load dataset --- + with open(args.dataset_file, "r", encoding="utf-8") as f: + data = json.load(f) + + data = dict(list(data.items())[: args.request_number]) + + print(f"Loaded {len(data)} requests.") + + # --- call your function --- + request_concurrency(data, args) + + +if __name__ == "__main__": + main() diff --git a/.buildkite/correctness/compare_files.py b/.buildkite/correctness/compare_files.py new file mode 100644 index 0000000..2c8635a --- /dev/null +++ b/.buildkite/correctness/compare_files.py @@ -0,0 +1,86 @@ +# SPDX-License-Identifier: Apache-2.0 +# Standard +import argparse +import re + + +def load_file(path): + data = {} + current_id = None + buffer = [] + + id_pattern = re.compile(r"chatcmpl-[a-zA-Z0-9\-_]+") + + with open(path, "r", encoding="utf-8") as f: + for line in f: + line_strip = line.strip() + if id_pattern.search(line_strip): + if current_id is not None: + data[current_id] = "\n".join(buffer).strip() + current_id = id_pattern.search(line_strip).group(0) + buffer = [] + else: + buffer.append(line.rstrip()) + + if current_id is not None: + data[current_id] = "\n".join(buffer).strip() + + return data + + +def compare_files(file1, file2): + d1 = load_file(file1) + d2 = load_file(file2) + + ids1 = set(d1.keys()) + ids2 = set(d2.keys()) + + common = ids1 & ids2 + only1 = ids1 - ids2 + only2 = ids2 - ids1 + + same = [] + diff = [] + + for cid in common: + if d1[cid] == d2[cid]: + same.append(cid) + else: + diff.append(cid) + print("====== Statistics ======") + print(f"Common IDs: {len(common)}") + print(f"Identical IDs: {len(same)}") + print(f"Different IDs: {len(diff)}") + print() + print("—— Identical IDs ——") + for cid in same: + print(cid) + print() + print("—— Different IDs ——") + for cid in diff: + print(cid) + print() + print("—— Only in File 1 ——") + for cid in only1: + print(cid) + print() + print("—— Only in File 2 ——") + for cid in only2: + print(cid) + + +def main(): + parser = argparse.ArgumentParser(description="Compare two result files.") + parser.add_argument("--file1", type=str, required=True, help="Path to first file.") + parser.add_argument("--file2", type=str, required=True, help="Path to second file.") + + args = parser.parse_args() + + file1 = args.file1 + file2 = args.file2 + + compare_files(file1, file2) + + +if __name__ == "__main__": + main() diff --git a/.buildkite/correctness/mmlu-test.py b/.buildkite/correctness/mmlu-test.py new file mode 100644 index 0000000..fb4e939 --- /dev/null +++ b/.buildkite/correctness/mmlu-test.py @@ -0,0 +1,189 @@ +# SPDX-License-Identifier: Apache-2.0 +# Standard +import argparse +import json +import os +import time + +# Third Party +from tqdm import tqdm +from transformers import AutoTokenizer, set_seed + +# vLLM +from vllm import LLM, SamplingParams +from vllm.config import KVTransferConfig +import numpy as np +import pandas as pd + +# setting PYTHONHASHSEED derandomizes token chunking +os.environ["PYTHONHASHSEED"] = "0" + +global tokenizer +choices = ["A", "B", "C", "D"] + + +# grab the idx'th row of the df and generate a prompt string +# format of the MMLU csvs: +# question,option_A,option_B,option_C,option_D,answer +def prompt_string(df, idx, include_answer=True): + prompt = df.iloc[idx, 0] + k = df.shape[1] - 2 # number of columns - 2 (question and answer) + for i in range(k): + prompt += f"\n{choices[i]}. {df.iloc[idx, i + 1]}" + prompt += "\nAnswer:" + if include_answer: + prompt += f" {df.iloc[idx, k]}\n\n" + return prompt + + +def evaluate(args, llm, subject, dev_df, test_df): + prompts, labels = [], [] + + shared_multi_shot_prefix = [ + f'The following are multiple choice questions (with answers) \ + about {subject}. \ + You must respond with a single letter only. \ + Either "A", "B", "C", or "D". \ + Do not include any other text in your response. \n\n' + ] + shared_multi_shot_prefix_length = 0 + for i in range(dev_df.shape[0]): + # the multi-shot examples should contain answers + shared_multi_shot_prefix.append(prompt_string(dev_df, i)) + + # Use plain list of token IDs, no torch tensors + token_ids = tokenizer(shared_multi_shot_prefix[-1], add_special_tokens=True)[ + "input_ids" + ] + shared_multi_shot_prefix_length += len(token_ids) + + if shared_multi_shot_prefix_length > 4000: + break + + # all already have double newlines at the end + shared_multi_shot_prefix = "".join(shared_multi_shot_prefix) + + for i in range(test_df.shape[0]): + # do NOT include the answer for the actual question + # we want the LLM to answer + query_prompt = prompt_string(test_df, i, include_answer=False) + prompt = f"{shared_multi_shot_prefix}\n\n{query_prompt}" + prompts.append(prompt) + label = test_df.iloc[i, test_df.shape[1] - 1] + labels.append(label) + + # Create sampling params with deterministic settings + # (temperature=0, seed=42) + sampling_params = SamplingParams( + temperature=0, + max_tokens=2, + seed=42, + n=1, + stop=None, + ) + + # even though offline serving can batch all the prompts, we do them one at a time + # to keep the vllm scheduler deterministic + outputs = [] + for prompt in prompts: + # if we use lmcache, we need to first populate the kv cache + if args.use_lmcache: + llm.generate(prompt, sampling_params) + time.sleep(0.5) + outputs.append(llm.generate(prompt, sampling_params)) + time.sleep(0.5) + + predictions = [] + for output in outputs: + prediction = output.outputs[0].text + prediction_stripped = prediction.strip() + if prediction_stripped and prediction_stripped[0] in choices: + predictions.append(prediction_stripped[0]) + else: + # Fallback: look for any A, B, C, D in the response + for char in prediction_stripped: + if char in ["A", "B", "C", "D"]: + predictions.append(char) + break + else: + predictions.append("A") # Default fallback + + accuracy = np.mean(np.array(predictions) == np.array(labels)) + return accuracy + + +def main(args): + global tokenizer + tokenizer = AutoTokenizer.from_pretrained(args.model) + + ktc = None + if args.use_lmcache: + ktc = KVTransferConfig( + kv_connector="LMCacheConnectorV1", + kv_role="kv_both", + ) + llm = LLM( + model=args.model, + max_model_len=8000, + gpu_memory_utilization=0.9, + kv_transfer_config=ktc, + tensor_parallel_size=2, + enable_prefix_caching=False, + enforce_eager=True, + trust_remote_code=True, + ) + + mmlu_files = os.listdir("data/test") + test_files = [f for f in mmlu_files if f.endswith("_test.csv")] + subjects = sorted([f.split("_test.csv")[0] for f in test_files]) + + accuracies = [] + num_questions = [] + output_dict = {} + + for subject_raw in tqdm( + subjects[: args.number_of_subjects], + desc="Processing subjects", + ): + subject = " ".join(subject_raw.split("_")) + dev_df = pd.read_csv( + os.path.join("data/dev", subject_raw + "_dev.csv"), + header=None, + ) + test_df = pd.read_csv( + os.path.join("data/test", subject_raw + "_test.csv"), + header=None, + ) + accuracy = evaluate(args, llm, subject, dev_df, test_df) + accuracies.append(accuracy) + num_questions.append(len(test_df)) + output_dict[subject_raw] = { + "accuracy": accuracy, + "num_questions": len(test_df), + } + + total_accuracy = np.mean(accuracies) + total_num_questions = sum(num_questions) + output_dict["total"] = { + "accuracy": total_accuracy, + "num_questions": total_num_questions, + } + + with open(args.result_file, "w") as f: + # output will be a jsonl file + for subject, value in output_dict.items(): + f.write(json.dumps({subject: value}) + "\n") + + +if __name__ == "__main__": + set_seed(42) # some tokenizers may have randomness + parser = argparse.ArgumentParser() + parser.add_argument("--model", type=str, required=True) + parser.add_argument("--number-of-subjects", type=int, default=25) + parser.add_argument("--use-lmcache", action="store_true", default=False) + args = parser.parse_args() + if args.use_lmcache: + args.result_file = args.model.split("/")[-1] + "-lmcache.jsonl" + else: + args.result_file = args.model.split("/")[-1] + "-baseline.jsonl" + main(args) diff --git a/.buildkite/correctness/pipeline.correctness.yml b/.buildkite/correctness/pipeline.correctness.yml new file mode 100644 index 0000000..24af1cb --- /dev/null +++ b/.buildkite/correctness/pipeline.correctness.yml @@ -0,0 +1,16 @@ +steps: + - label: ":rocket: vLLM correctness" + key: "correctness" + timeout_in_minutes: 60 + commands: + - | + set -euo pipefail + + echo "[INFO] HOME: $HOME" + echo "[INFO] PWD: $PWD" + + chmod +x .buildkite/scripts/vllm-correctness.sh + BUILD_ID="${BUILDKITE_BUILD_ID}" .buildkite/scripts/vllm-correctness.sh + + artifact_paths: + - "build_${BUILDKITE_BUILD_ID}.log" \ No newline at end of file diff --git a/.buildkite/correctness/pipeline.mmlu.yml b/.buildkite/correctness/pipeline.mmlu.yml new file mode 100644 index 0000000..392f296 --- /dev/null +++ b/.buildkite/correctness/pipeline.mmlu.yml @@ -0,0 +1,36 @@ +agents: + queue: "shaoting-gcp" + +steps: + - label: "Run MMLU KV Transfer Test" + command: | + set -euo pipefail + echo "Starting MMLU test" + echo "Running setup.sh" + bash setup.sh + + # buildkite agents start at the root of the Git checkout + cd .buildkite/correctness + + echo "Starting Dense KV Transfer Test with meta-llama/Llama-3.1-8B-Instruct" + # produces Llama-3.1-8B-Instruct-baseline.jsonl + python mmlu-test.py --model meta-llama/Llama-3.1-8B-Instruct --number-of-subjects 25 + # produces Llama-3.1-8B-Instruct-lmcache.jsonl + python mmlu-test.py --model meta-llama/Llama-3.1-8B-Instruct --number-of-subjects 25 --use-lmcache + + echo "Starting MLA KV Transfer Test with deepseek-ai/DeepSeek-V2-Lite" + # produces DeepSeek-V2-Lite-baseline.jsonl + python mmlu-test.py --model deepseek-ai/DeepSeek-V2-Lite --number-of-subjects 25 + # produces DeepSeek-V2-Lite-lmcache.jsonl + python mmlu-test.py --model deepseek-ai/DeepSeek-V2-Lite --number-of-subjects 25 --use-lmcache + + echo "Summarizing Results" + # the summarize-results.py will look for jsonl files with the same techniqiue as the mmlu-test.py script + # which is with args.model.split("/")[-1] + python summarize-results.py meta-llama/Llama-3.1-8B-Instruct deepseek-ai/DeepSeek-V2-Lite + + # this produces correctness-summary.txt + buildkite-agent artifact upload correctness-summary.txt + + artifact_paths: + - correctness-summary.txt diff --git a/.buildkite/correctness/setup.sh b/.buildkite/correctness/setup.sh new file mode 100644 index 0000000..c328e98 --- /dev/null +++ b/.buildkite/correctness/setup.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +# buildkite agents start at the root of the Git checkout + +cd .buildkite/correctness + +unxz data.tar.xz +tar xf data.tar + +# see README.md for pre-configuring your CI runner +source ~/correctness_venv/bin/activate +cd ~/correctness_repositories/LMCache +git pull origin dev +cd ~/correctness_repositories/vllm +git pull origin main diff --git a/.buildkite/correctness/sharegpt2openai.py b/.buildkite/correctness/sharegpt2openai.py new file mode 100644 index 0000000..90a498a --- /dev/null +++ b/.buildkite/correctness/sharegpt2openai.py @@ -0,0 +1,125 @@ +# SPDX-License-Identifier: Apache-2.0 +# Standard +import argparse +import json + +VLLM_TEMPLATE = { + "model": "Qwen3/Qwen3-32B", + "frequency_penalty": 0.0, + "logit_bias": None, + "logprobs": False, + "top_logprobs": 0, + "max_tokens": 2048, + "max_completion_tokens": 2048, + "n": 1, + "presence_penalty": 0.0, + "response_format": None, + "seed": None, + "stop": [], + "stream": True, + "stream_options": None, + "temperature": 0.01, + "top_p": 1.0, + "tools": None, + "tool_choice": "none", + "reasoning_effort": None, + "include_reasoning": True, + "parallel_tool_calls": False, + "user": None, + "best_of": None, + "use_beam_search": False, + "top_k": -1, + "min_p": None, + "repetition_penalty": 1.0, + "length_penalty": 1.0, + "stop_token_ids": [], + "include_stop_str_in_output": False, + "ignore_eos": False, + "min_tokens": 0, + "skip_special_tokens": True, + "spaces_between_special_tokens": True, + "priority": 0, + "truncate_prompt_tokens": None, + "prompt_logprobs": None, + "allowed_token_ids": None, + "bad_words": [], + "order": "routine", + "lora_type": None, + "reasoning": False, + "echo": False, + "add_generation_prompt": True, + "continue_final_message": False, + "add_special_tokens": False, + "documents": None, + "chat_template": None, + "chat_template_kwargs": {"enable_thinking": False}, + "mm_processor_kwargs": None, + "guided_json": None, + "guided_regex": None, + "guided_choice": None, + "guided_grammar": None, + "structural_tag": None, + "guided_decoding_backend": None, + "guided_whitespace_pattern": None, + "kv_transfer_params": None, + "vllm_xargs": None, +} + + +def convert_sharegpt_to_vllm(input_path, output_path): + with open(input_path, "r", encoding="utf-8") as f: + data = json.load(f) + + output = {} + + for item in data: + conv = item["conversations"] + + # # Delete the gpt's response at the end + if len(conv) > 0 and conv[-1]["from"] == "gpt": + conv = conv[:-1] + + messages = [] + for c in conv: + role = "user" if c["from"] == "human" else "assistant" + content = c["value"] + messages.append({"role": role, "content": content}) + + file_key = f"{item['id']}" + + out_entry = VLLM_TEMPLATE.copy() + out_entry["messages"] = messages + + output[file_key] = out_entry + + with open(output_path, "w", encoding="utf-8") as f: + json.dump(output, f, ensure_ascii=False, indent=2) + + +def main(): + parser = argparse.ArgumentParser( + description="Convert ShareGPT JSON dataset to vLLM format." + ) + parser.add_argument( + "--input", + "-i", + type=str, + required=True, + help="Path to the input ShareGPT JSON file.", + ) + parser.add_argument( + "--output", + "-o", + type=str, + required=True, + help="Path to the output vLLM JSON file.", + ) + + args = parser.parse_args() + + convert_sharegpt_to_vllm(args.input, args.output) + print(f"Converted {args.input} -> {args.output}") + + +if __name__ == "__main__": + main() diff --git a/.buildkite/correctness/summarize-results.py b/.buildkite/correctness/summarize-results.py new file mode 100644 index 0000000..46539e5 --- /dev/null +++ b/.buildkite/correctness/summarize-results.py @@ -0,0 +1,216 @@ +# SPDX-License-Identifier: Apache-2.0 +# Standard +from typing import Dict, List, Tuple +import json +import os +import sys + + +def load_jsonl_file(filepath: str) -> Dict[str, Dict[str, float]]: + """Load a JSONL file and return a dictionary of results.""" + results: dict[str, dict[str, float]] = {} + + if not os.path.exists(filepath): + print(f"Warning: File {filepath} not found") + return results + + with open(filepath, "r") as f: + for line in f: + line = line.strip() + if line: + try: + data = json.loads(line) + results.update(data) + except json.JSONDecodeError: + print(f"Error parsing line in {filepath}: {line}") + continue + + return results + + +def compare_results( + baseline_results: Dict, lmcache_results: Dict +) -> Tuple[List[Tuple], float, float]: + """Compare baseline and lmcache results, return comparison data and totals.""" + comparisons = [] + baseline_total_correct = 0 + baseline_total_questions = 0 + lmcache_total_correct = 0 + lmcache_total_questions = 0 + + # Get all subjects (excluding 'total' for now) + all_subjects = set(baseline_results.keys()) | set(lmcache_results.keys()) + all_subjects.discard("total") # Handle total separately + + # Sort subjects alphabetically + for subject in sorted(all_subjects): + baseline_data = baseline_results.get( + subject, {"accuracy": 0.0, "num_questions": 0} + ) + lmcache_data = lmcache_results.get( + subject, {"accuracy": 0.0, "num_questions": 0} + ) + + baseline_acc = baseline_data["accuracy"] + lmcache_acc = lmcache_data["accuracy"] + difference = lmcache_acc - baseline_acc + + # Use the number of questions from whichever dataset has the subject + num_questions = max( + baseline_data["num_questions"], lmcache_data["num_questions"] + ) + + comparisons.append( + (subject, baseline_acc, lmcache_acc, difference, num_questions) + ) + + # Accumulate totals + baseline_total_correct += baseline_acc * num_questions + baseline_total_questions += num_questions + lmcache_total_correct += lmcache_acc * num_questions + lmcache_total_questions += num_questions + + # Calculate overall accuracies + baseline_total_acc = ( + baseline_total_correct / baseline_total_questions + if baseline_total_questions > 0 + else 0 + ) + lmcache_total_acc = ( + lmcache_total_correct / lmcache_total_questions + if lmcache_total_questions > 0 + else 0 + ) + + return comparisons, baseline_total_acc, lmcache_total_acc + + +def format_model_report( + model_name: str, + model_short_name: str, + comparisons: List[Tuple], + baseline_total: float, + lmcache_total: float, +) -> str: + """Format a single model's comparison report.""" + report = [] + report.append("=" * 80) + report.append(f"MODEL: {model_name}") + report.append("=" * 80) + report.append("") + + # Header + report.append( + f"{'Subject':<35} \ + {'Baseline':<12} \ + {'LMCache':<12} \ + {'Difference':<12} \ + {'Questions':<10}" + ) + report.append("-" * 80) + + # Subject comparisons + for subject, baseline_acc, lmcache_acc, difference, num_questions in comparisons: + report.append( + f"{subject:<35} \ + {baseline_acc:<12.4f} \ + {lmcache_acc:<12.4f} \ + {difference:<12.4f} \ + {num_questions:<10}" + ) + + # Totals + total_difference = lmcache_total - baseline_total + report.append("-" * 80) + report.append( + f"{'TOTAL':<35} \ + {baseline_total:<12.4f} \ + {lmcache_total:<12.4f} \ + {total_difference:<12.4f}" + ) + report.append("") + + # Summary + report.append("SUMMARY:") + report.append(f" Baseline Total Accuracy: {baseline_total:.4f}") + report.append(f" LMCache Total Accuracy: {lmcache_total:.4f}") + report.append(f" Net Accuracy Difference: {total_difference:.4f}") + if total_difference >= 0: + report.append(f" Result: LMCache performs BETTER by {total_difference:.4f}") + else: + report.append( + f" Result: LMCache performs WORSE by {abs(total_difference):.4f}" + ) + report.append("") + + return "\n".join(report) + + +def main(): + if len(sys.argv) < 2: + print("Usage: python summarize-results.py ...") + print( + "Example: python summarize-results.py \ + meta-llama/Llama-3.1-8B-Instruct \ + deepseek-ai/DeepSeek-V2-Lite" + ) + sys.exit(1) + + model_names = sys.argv[1:] + all_reports = [] + + print(f"Processing {len(model_names)} models...") + + for model_name in model_names: + print(f"Processing model: {model_name}") + + # Extract short name using the same logic as mmlu-test.py + model_short_name = model_name.split("/")[-1] + + # Define file paths + baseline_file = f"{model_short_name}-baseline.jsonl" + lmcache_file = f"{model_short_name}-lmcache.jsonl" + + print(f" Looking for files: {baseline_file}, {lmcache_file}") + + # Load results + baseline_results = load_jsonl_file(baseline_file) + lmcache_results = load_jsonl_file(lmcache_file) + + if not baseline_results and not lmcache_results: + print(f" Warning: No data found for {model_name}") + continue + + # Compare results + comparisons, baseline_total, lmcache_total = compare_results( + baseline_results, lmcache_results + ) + + # Generate report for this model + model_report = format_model_report( + model_name, + model_short_name, + comparisons, + baseline_total, + lmcache_total, + ) + all_reports.append(model_report) + + print(f" Processed {len(comparisons)} subjects") + + # Write combined report to file + output_file = "correctness-summary.txt" + with open(output_file, "w") as f: + f.write("MMLU Correctness Comparison Report\n") + f.write("=" * 80 + "\n") + f.write(f"Generated for {len(model_names)} models\n\n") + + for report in all_reports: + f.write(report) + f.write("\n") + + print(f"\nReport written to {output_file}") + + +if __name__ == "__main__": + main() diff --git a/.buildkite/k3_harness/ARCHITECTURE.md b/.buildkite/k3_harness/ARCHITECTURE.md new file mode 100644 index 0000000..73807f4 --- /dev/null +++ b/.buildkite/k3_harness/ARCHITECTURE.md @@ -0,0 +1,89 @@ +# K3s CI Harness Architecture + +This document outlines the main motivations, design decisions, and architecture of the K3s-based Buildkite CI Harness for LMCache. + +## Motivations + +1. **Ease of Setup**: Bring up a fully functional CI node on any raw Linux GPU machine with a single script (`setup-cluster.sh`), avoiding manual configuration of dependencies or persistent Buildkite agents. +2. **Machine Agnostic**: Remove reliance on machine-specific scripts (e.g., custom `pick-free-gpu.sh` scripts). The CI runs identically on any machine with an NVIDIA GPU and Docker. +3. **Clean Environments**: Eliminate state bleeding between test runs (no shared pip/uv caches or leftover files). Every job gets a pristine environment. +4. **Automated Resource Management**: Leverage standard Kubernetes primitives for GPU allocation, volume mounting, and cleanup. + +## Core Design Decisions + +### 1. K3s + GPU Operator +We use **K3s** as a lightweight, single-node Kubernetes cluster. The **NVIDIA GPU Operator** automatically configures the container runtime and exposes GPUs to K8s. This abstracts away the underlying hardware and handles GPU discovery automatically. + +### 2. Ephemeral Pod-Based Execution (`agent-stack-k8s`) +Instead of running persistent `buildkite-agent` binaries, we use Buildkite's `agent-stack-k8s`. A controller pod polls the Buildkite queue and dynamically provisions an ephemeral K8s Pod to run each job. When the job finishes, the pod is destroyed. + +### 3. Declarative GPU Allocation & Parallelizability +Each pipeline step explicitly requests the number of GPUs it needs in its Kubernetes pod specification: + +```yaml +resources: + limits: + nvidia.com/gpu: "2" # Requests 2 GPUs +``` + +Because Kubernetes handles atomic resource scheduling, **jobs automatically run in parallel on multi-GPU nodes**. If a node has 4 GPUs and three jobs arrive (requesting 1, 2, and 1 GPU respectively), K8s schedules them concurrently. If another job requests 2 GPUs, it waits in the queue until resources free up. This completely eliminates the need for manual GPU locking mechanisms. + +### 4. Local Base Image & Ephemeral Setup +To avoid Docker registry dependencies, `setup-cluster.sh` automatically detects the host GPU's compute capability, builds the `lmcache/ci-base:latest` image locally, and imports it directly into K3s. Each job pod uses this base image and dynamically installs vLLM and LMCache (`setup-env.sh`) at runtime. + +### 5. Shared Host Volumes +Large, read-heavy directories (like HuggingFace models and datasets) are mounted into pods via `hostPath` to speed up tests without duplicating downloads, while keeping the job environment itself stateless. + +--- + +## Architecture Flow + +### Node Initialization (One-Time Setup) + +```text +[ Raw Linux Host + NVIDIA GPU ] + | + | (run setup-cluster.sh) + v ++-----------------------------------------------------+ +| K3s Cluster | +| | +| 1. Install K3s | +| 2. Install Helm -> Deploy NVIDIA GPU Operator | +| 3. Build CI Base Image (lmcache/ci-base:latest) | +| 4. Import Base Image to K3s containerd | ++-----------------------------------------------------+ + | + | (run install-agent-stack.sh) + v +[ agent-stack-k8s Controller Pod (Watches queue) ] +``` + +### CI Execution Flow + +```text +Buildkite Web UI + | + | 1. Job pushed to 'k8s' queue + v +agent-stack-k8s Controller (in K3s) + | + | 2. Reads job, creates ephemeral Pod requesting GPUs + v ++-------------------------------------------------------------+ +| Job Pod (e.g., limits: nvidia.com/gpu: "1") | +| | +| - Mounts /data/huggingface from host | +| - Runs setup-env.sh (Installs vLLM, LMCache) | +| - Executes test script (e.g., pytest) | ++-------------------------------------------------------------+ + | + | 3. Test finishes (Pass/Fail) + v +agent-stack-k8s Controller + | + | 4. Reports result to Buildkite + | 5. Destroys the Pod (wipes environment) + v +Buildkite Web UI +``` \ No newline at end of file diff --git a/.buildkite/k3_harness/README.md b/.buildkite/k3_harness/README.md new file mode 100644 index 0000000..9cf7fb2 --- /dev/null +++ b/.buildkite/k3_harness/README.md @@ -0,0 +1,143 @@ +# K3s CI Harness + +Single-node K3s + NVIDIA GPU Operator for running LMCache CI on any GPU machine. + +## Prerequisites + +- Linux host with NVIDIA GPU(s) and driver installed +- Docker (for building the CI base image) +- Root access +- A GitHub token (PAT or fine-grained) with read/write access to the repo + +## Setup (one-time) + +```bash +# 1. Install K3s + GPU Operator + build CI base image +.buildkite/k3_harness/setup-cluster.sh + +# 2. Verify GPUs work in pods +.buildkite/k3_harness/smoke-test.sh + +# 3. Connect to Buildkite (needs agent token + GitHub token for HTTPS checkout) +.buildkite/k3_harness/install-agent-stack.sh +``` + +`setup-cluster.sh` does everything: installs K3s, Helm, GPU Operator, builds the CI base image from `ci-base.Dockerfile`, imports it into K3s containerd locally, and creates host volume directories. Safe to re-run. + +## How Buildkite integration works + +This is different from the bare-metal agent setup where you install a Buildkite agent binary on your own machine, register it with a queue created on the Web UI, and manage it as a systemd service. With agent-stack-k8s, there are **no persistent agents on the machine**. + +Instead, agent-stack-k8s runs a controller pod in K8s that polls Buildkite for jobs matching the configured queue. When a job appears, it creates a K8s pod to run it. When the job finishes, the pod is deleted. + +**What you need from the Buildkite web UI**: + +1. **Create a queue** — Go to **Organization Settings → Default cluster → Queues → New Queue** and create a queue named `k8s` (or your chosen name). The queue needs no configuration — just a name. You do **not** register any agents on it. +2. **Get an agent token** — From the cluster settings page, copy the agent token (or create a new one). +3. **Get a GitHub token** — Create a PAT (or fine-grained token) with read/write access to the repo. This is used for HTTPS checkout and for pushing baselines to `benchmarks-main`. +4. **Run `install-agent-stack.sh`** with the agent token and GitHub token. The script creates a K8s secret and installs agent-stack-k8s. + +To use a queue name other than `k8s`, set `BUILDKITE_QUEUE` when running install: +```bash +BUILDKITE_QUEUE=my-queue .buildkite/k3_harness/install-agent-stack.sh +``` + +Pipeline steps target the queue with: +```yaml +agents: + queue: "k8s" # must match the queue name +``` + +## Per-job environment + +Every CI job sources `setup-env.sh` to install vLLM + LMCache: + +```yaml +command: | + source .buildkite/k3_harness/setup-env.sh + bash .buildkite/scripts/my-test.sh +``` + +This installs: +1. **vLLM** from nightly wheel +2. **LMCache** from PR source + +Each pod has its own ephemeral filesystem that is cleanly erased after being shut down — no shared pip/uv cache, no cross-pod contention. + +## Shared volumes + +Mounted into every pod via `hostPath`: + +| Host | Container | What | +|------|-----------|------| +| `/data/huggingface` | `/root/.cache/huggingface` | Model weights (read-heavy, write-once) | +| `/data/datasets` | `/root/correctness` | Test datasets (read-only after download) | + +## GPU allocation + +Request GPUs per pipeline step: + +```yaml +plugins: + - kubernetes: + podSpec: + containers: + - resources: + limits: + nvidia.com/gpu: "2" # 1 or 2 +``` + +K8s device plugin handles atomic allocation. No `pick-free-gpu.sh`. + +## CI base image + +`ci-base.Dockerfile` builds an image with CUDA + Python 3.12 + uv + build deps (no vLLM/LMCache). `setup-cluster.sh` builds it automatically, auto-detects your GPU's compute capability for `TORCH_CUDA_ARCH_LIST`, and imports it into K3s containerd — no registry needed. + +To rebuild after changing `requirements/*.txt`: +```bash +REBUILD_IMAGE=1 .buildkite/k3_harness/setup-cluster.sh +``` + +## Files + +``` +k3_harness/ +├── ci-base.Dockerfile # CI base image definition +├── setup-cluster.sh # One-time: K3s + GPU Operator + base image +├── install-agent-stack.sh # One-time: Buildkite agent (needs token + GitHub token) +├── values.yaml # Reference Helm values (documentation only) +├── setup-env.sh # Per-job: install vLLM + LMCache (includes GPU health check) +├── smoke-test.sh # Verify: GPU pod runs in K3s +├── gpu-monitor.sh # Host-level: detect stale non-K8s GPU processes +├── setup-gpu-monitor.sh # Install gpu-monitor.sh as a cron job +├── teardown.sh # Remove everything (preserves /data/*) +└── README.md +``` + +## GPU monitoring + +CI jobs automatically check GPU health at startup (via `check_gpu_health` in `setup-env.sh`). If a GPU assigned to a pod has less than 80% free memory — usually due to a stale host process — the job fails immediately with a clear message instead of wasting time on setup and then getting a cryptic CUDA OOM. + +For **host-level monitoring**, install the GPU monitor cron job: + +```bash +# Install cron job that checks every 10 minutes for stale non-K8s GPU processes +sudo bash .buildkite/k3_harness/setup-gpu-monitor.sh +``` + +This runs `gpu-monitor.sh` every 10 minutes. It distinguishes K8s pod processes (in `kubepods` cgroups) from stale host processes and logs warnings to `/var/log/gpu-monitor.log` with PID, command, GPU, memory usage, and process age. + +```bash +# View monitor logs +tail -f /var/log/gpu-monitor.log + +# Remove the cron job +crontab -l 2>/dev/null | grep -v gpu-monitor.sh | crontab - +``` + +## Teardown + +```bash +.buildkite/k3_harness/teardown.sh +# Removes K3s, GPU Operator, agent-stack. Preserves /data/* +``` diff --git a/.buildkite/k3_harness/ci-base.Dockerfile b/.buildkite/k3_harness/ci-base.Dockerfile new file mode 100644 index 0000000..47f1ac5 --- /dev/null +++ b/.buildkite/k3_harness/ci-base.Dockerfile @@ -0,0 +1,40 @@ +# CI base image: CUDA + Python + uv + build deps. +# No vLLM or LMCache — those are installed per-job by setup-env.sh. +# +# Built automatically by setup-cluster.sh and imported into K3s containerd. +# Rebuild when requirements/*.txt changes. + +FROM nvidia/cuda:13.0.2-devel-ubuntu24.04 + +ENV DEBIAN_FRONTEND=noninteractive +ENV PATH="/opt/venv/bin:${PATH}" + +RUN echo 'tzdata tzdata/Areas select America' | debconf-set-selections \ + && echo 'tzdata tzdata/Zones/America select Los_Angeles' | debconf-set-selections \ + && apt-get update -y \ + && apt-get install -y --no-install-recommends \ + ccache software-properties-common git curl sudo jq lsof \ + python3 python3-dev python3-venv python3-pip tzdata libxcb1-dev \ + libcudart12 \ + && ldconfig \ + && curl -LsSf https://astral.sh/uv/install.sh | sh \ + && mv ~/.local/bin/uv /usr/local/bin/ \ + && mv ~/.local/bin/uvx /usr/local/bin/ \ + && uv venv /opt/venv \ + && . /opt/venv/bin/activate \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /workspace + +# Pre-install requirements that rarely change +COPY requirements/common.txt requirements/build.txt requirements/cuda.txt \ + requirements/cuda13_core.txt requirements/nixl.txt /tmp/reqs/ +RUN . /opt/venv/bin/activate && \ + uv pip install -r /tmp/reqs/cuda.txt && \ + uv pip install -r /tmp/reqs/build.txt && \ + rm -rf /tmp/reqs + +# Set at build time to match the CI machine's GPU. +# Query with: nvidia-smi --query-gpu=compute_cap --format=csv,noheader +ARG TORCH_CUDA_ARCH_LIST +ENV TORCH_CUDA_ARCH_LIST=${TORCH_CUDA_ARCH_LIST} diff --git a/.buildkite/k3_harness/gpu-monitor.sh b/.buildkite/k3_harness/gpu-monitor.sh new file mode 100755 index 0000000..f4cd757 --- /dev/null +++ b/.buildkite/k3_harness/gpu-monitor.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# Host-level GPU monitoring script. +# Detects non-K8s processes holding GPU memory and logs warnings. +# +# Install as a cron job on the CI machine: +# sudo crontab -e +# */10 * * * * /path/to/gpu-monitor.sh >> /var/log/gpu-monitor.log 2>&1 +# +# Or install with setup-gpu-monitor.sh for automatic setup. + +set -euo pipefail + +LOG_PREFIX="[gpu-monitor $(date '+%Y-%m-%d %H:%M:%S')]" +K8S_PIDS_FILE=$(mktemp) +trap 'rm -f "$K8S_PIDS_FILE"' EXIT + +if ! command -v nvidia-smi &>/dev/null; then + echo "$LOG_PREFIX ERROR: nvidia-smi not found" + exit 1 +fi + +# Collect all PIDs using GPU compute +mapfile -t GPU_PIDS < <( + nvidia-smi --query-compute-apps=pid --format=csv,noheader,nounits 2>/dev/null \ + | tr -d ' ' | sort -u | grep -v '^$' +) + +if [[ ${#GPU_PIDS[@]} -eq 0 ]]; then + # No GPU processes -- all clean + exit 0 +fi + +# Collect K8s container PIDs (all processes running inside any K8s pod). +# K3s uses containerd; enumerate all container cgroups. +{ + shopt -s nullglob + for cgroup_dir in /sys/fs/cgroup/*/kubepods* /sys/fs/cgroup/kubepods*; do + if [[ -d "$cgroup_dir" ]]; then + find "$cgroup_dir" -name "cgroup.procs" -exec cat {} \; 2>/dev/null + fi + done + shopt -u nullglob +} | sort -u > "$K8S_PIDS_FILE" 2>/dev/null || true + +# Also check via crictl if available (more reliable) +if command -v crictl &>/dev/null; then + for container_id in $(crictl ps -q 2>/dev/null); do + pid=$(crictl inspect --output go-template --template '{{.info.pid}}' "$container_id" 2>/dev/null || true) + if [[ -n "$pid" && "$pid" != "0" ]]; then + # Include the container PID and all its descendants + echo "$pid" >> "$K8S_PIDS_FILE" + pgrep -P "$pid" --ns "$pid" 2>/dev/null >> "$K8S_PIDS_FILE" || true + fi + done +fi + +# Check each GPU process +STALE_FOUND=false +for pid in "${GPU_PIDS[@]}"; do + [[ -z "$pid" ]] && continue + + # Is this PID a K8s process? + if grep -qw "$pid" "$K8S_PIDS_FILE" 2>/dev/null; then + continue + fi + + # Check if this PID is inside any cgroup containing "kubepods" + if [[ -f "/proc/$pid/cgroup" ]] && grep -q "kubepods" "/proc/$pid/cgroup" 2>/dev/null; then + continue + fi + + # This is a non-K8s process using a GPU + STALE_FOUND=true + local_cmdline=$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null | head -c 200 || echo "") + + echo "$LOG_PREFIX STALE GPU PROCESS: PID=$pid cmd='$local_cmdline'" + nvidia-smi --query-compute-apps=pid,gpu_bus_id,used_memory --format=csv,noheader 2>/dev/null \ + | grep "^${pid}," | while IFS=, read -r _ bus mem; do + echo "$LOG_PREFIX GPU bus=$bus memory=$mem" + done + + # Check process age + if [[ -f "/proc/$pid/stat" ]]; then + local_start=$(awk '{print $22}' "/proc/$pid/stat" 2>/dev/null || echo "0") + local_uptime=$(awk '{print int($1)}' /proc/uptime) + local_clk_tck=$(getconf CLK_TCK) + if [[ "$local_start" -gt 0 && "$local_clk_tck" -gt 0 ]]; then + local_age_secs=$(( local_uptime - local_start / local_clk_tck )) + local_age_hours=$(( local_age_secs / 3600 )) + echo "$LOG_PREFIX Age: ~${local_age_hours}h (${local_age_secs}s)" + fi + fi +done + +if [[ "$STALE_FOUND" == "true" ]]; then + echo "$LOG_PREFIX" + echo "$LOG_PREFIX === GPU SUMMARY ===" + nvidia-smi --query-gpu=index,memory.used,memory.total,memory.free --format=csv,noheader \ + | while IFS=, read -r idx used total free; do + echo "$LOG_PREFIX GPU $idx: used=$(echo $used | xargs), total=$(echo $total | xargs), free=$(echo $free | xargs)" + done + echo "$LOG_PREFIX WARNING: Stale non-K8s processes are holding GPU memory. CI tests may fail." + echo "$LOG_PREFIX To fix: kill the stale PIDs listed above." + exit 1 +fi diff --git a/.buildkite/k3_harness/install-agent-stack.sh b/.buildkite/k3_harness/install-agent-stack.sh new file mode 100755 index 0000000..dbdb949 --- /dev/null +++ b/.buildkite/k3_harness/install-agent-stack.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Install Buildkite agent-stack-k8s with GitHub token for HTTPS repo access. +# +# Usage: +# install-agent-stack.sh +# +# Arguments: +# BUILDKITE_AGENT_TOKEN — from Buildkite cluster settings +# GITHUB_TOKEN — GitHub PAT or fine-grained token with repo read/write +# +# The queue name defaults to "k8s". Override with BUILDKITE_QUEUE env var. +set -euo pipefail + +export KUBECONFIG=/etc/rancher/k3s/k3s.yaml + +TOKEN="${1:?Usage: $0 }" +GH_TOKEN="${2:?Usage: $0 }" +QUEUE="${BUILDKITE_QUEUE:-k8s}" + +# Create (or update) the K8s secret with GitHub credentials. +# Two keys: +# .git-credentials — used by agent-stack-k8s checkout container (HTTPS clone). +# The leading dot matches the file git-credential-store expects. +# GITHUB_TOKEN — injected into job containers for git push (e.g. baselines) +kubectl create secret generic buildkite-git-creds \ + --from-literal=.git-credentials="https://x-access-token:${GH_TOKEN}@github.com" \ + --from-literal=GITHUB_TOKEN="${GH_TOKEN}" \ + -n buildkite --dry-run=client -o yaml | kubectl apply -f - + +# Install or upgrade agent-stack-k8s +# - default-checkout-params.gitCredentialsSecret: checkout container uses HTTPS +# instead of SSH. Expects a Secret with a `.git-credentials` key. +# - GITHUB_TOKEN is injected per-step in pipeline.yml (not via global pod-spec-patch) +helm upgrade --install agent-stack-k8s oci://ghcr.io/buildkite/helm/agent-stack-k8s \ + --version 0.38.0 \ + --namespace buildkite --create-namespace \ + --set agentToken="${TOKEN}" \ + --set config.queue="${QUEUE}" \ + --set-json 'config.default-checkout-params={"gitCredentialsSecret":{"secretName":"buildkite-git-creds"}}' \ + --wait --timeout 3m + +echo "agent-stack-k8s installed (queue=${QUEUE})" +kubectl get pods -n buildkite diff --git a/.buildkite/k3_harness/resolve-pinned-vllm.sh b/.buildkite/k3_harness/resolve-pinned-vllm.sh new file mode 100644 index 0000000..b48289b --- /dev/null +++ b/.buildkite/k3_harness/resolve-pinned-vllm.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# Resolve the vLLM nightly version that this build should install. +# +# Resolution order (first non-empty wins): +# 1. PINNED_VLLM_VERSION env var -- explicit per-build override. +# 2. latest_tested_vllm.txt fetched from +# https://raw.githubusercontent.com/LMCache/LMCache/buildkite_latest_tested_vllm/latest_tested_vllm.txt +# -- the most recent vLLM nightly that the canary build verified. +# 3. Empty string -- caller falls back to "latest nightly". +# +# The pin file's first non-blank, non-comment line is the bare version +# (kept this way so older `head -n1` consumers still work). Subsequent +# key=value lines carry pre-resolved metadata so consumers can install +# the wheel without making any extra API calls: +# short_sha= +# full_sha=<40-char-commit-sha> +# archive_index_url=https://wheels.vllm.ai//cu130 +# +# Toggles: +# USE_PINNED_VLLM=false -- skip step 2 (always probe the latest nightly). +# Used by the canary build itself, since pinning +# to its own previous result would defeat the +# purpose of a freshness check. +# +# Usage: +# source .buildkite/k3_harness/resolve-pinned-vllm.sh +# echo "Resolved: ${PINNED_VLLM_VERSION:-}" +# echo "Archive : ${PINNED_VLLM_ARCHIVE_INDEX_URL:-}" +# +# After sourcing the following are set (possibly empty) and exported: +# PINNED_VLLM_VERSION -- e.g. 0.23.1rc1.dev508+gc6dd32a81 +# PINNED_VLLM_FULL_SHA -- 40-char commit SHA, if recorded in pin +# PINNED_VLLM_ARCHIVE_INDEX_URL +# -- permanent PEP 503 simple index, if recorded +# +# The script never fails the build: a missing/unreachable pin file just +# falls through to the unpinned path, mirroring the previous behaviour. + +# Allow re-sourcing without "unbound variable" complaints under set -u. +PINNED_VLLM_VERSION="${PINNED_VLLM_VERSION:-}" +PINNED_VLLM_ARCHIVE_INDEX_URL="${PINNED_VLLM_ARCHIVE_INDEX_URL:-}" +PINNED_VLLM_FULL_SHA="${PINNED_VLLM_FULL_SHA:-}" +USE_PINNED_VLLM="${USE_PINNED_VLLM:-true}" + +# Override URL if you mirror the pin file elsewhere (e.g. an internal +# raw-file proxy for offline CI). +LMCACHE_VLLM_PIN_URL="${LMCACHE_VLLM_PIN_URL:-https://raw.githubusercontent.com/LMCache/LMCache/buildkite_latest_tested_vllm/latest_tested_vllm.txt}" + +if [[ -z "${PINNED_VLLM_VERSION}" && "${USE_PINNED_VLLM}" == "true" ]]; then + if command -v curl >/dev/null 2>&1; then + # 5s connect, 10s total -- pin lookup must never dominate setup time. + fetched="$(curl -fsSL --connect-timeout 5 --max-time 10 \ + "${LMCACHE_VLLM_PIN_URL}" 2>/dev/null || true)" + # The pin file's first non-blank, non-comment line is the bare + # version (back-compat for older readers); subsequent key=value + # lines carry resolved metadata so consumers can skip the live + # GitHub API lookup. A single awk pass extracts everything. + # Empty / missing keys are tolerated -- we'll just fall back to + # resolving them on demand later in the pipeline. + eval "$(printf '%s\n' "${fetched}" | awk ' + BEGIN { ver=""; idx=""; sha="" } + /^[[:space:]]*(#|$)/ { next } + ver == "" { + line=$0 + sub(/[[:space:]]+$/, "", line) + ver=line + next + } + /^archive_index_url=/ { + v=$0; sub(/^archive_index_url=/, "", v); idx=v; next + } + /^full_sha=/ { + v=$0; sub(/^full_sha=/, "", v); sha=v; next + } + END { + printf("PINNED_VLLM_VERSION=%s\n", ver) + printf("PINNED_VLLM_ARCHIVE_INDEX_URL=%s\n", idx) + printf("PINNED_VLLM_FULL_SHA=%s\n", sha) + } + ')" + fi +fi + +export PINNED_VLLM_VERSION +export PINNED_VLLM_ARCHIVE_INDEX_URL +export PINNED_VLLM_FULL_SHA + +if [[ -n "${PINNED_VLLM_VERSION}" ]]; then + echo "[resolve-pinned-vllm] Pinned vLLM version: ${PINNED_VLLM_VERSION}" >&2 + if [[ -n "${PINNED_VLLM_ARCHIVE_INDEX_URL}" ]]; then + echo "[resolve-pinned-vllm] Archive index:" \ + "${PINNED_VLLM_ARCHIVE_INDEX_URL}" >&2 + fi +else + echo "[resolve-pinned-vllm] No pinned vLLM; will install latest nightly" >&2 +fi diff --git a/.buildkite/k3_harness/setup-blend-env.sh b/.buildkite/k3_harness/setup-blend-env.sh new file mode 100755 index 0000000..df07689 --- /dev/null +++ b/.buildkite/k3_harness/setup-blend-env.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Per-job environment setup for CacheBlend-plugin compatibility tests. + +set -euo pipefail + +trap 'echo "ERROR: setup-blend-env.sh failed at line $LINENO (exit code $?)" >&2' ERR + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +# 1. Full env: vLLM nightly + LMCache(PR) from source (+ c_ops for this GPU). +# setup-env.sh already runs the GPU health pre-check. +source "${REPO_ROOT}/.buildkite/k3_harness/setup-env.sh" + +# setup-env.sh installed its own ERR trap; restore ours for accurate attribution. +trap 'echo "ERROR: setup-blend-env.sh failed at line $LINENO (exit code $?)" >&2' ERR + +# 2. Plugin-harness dep not in the LMCache/vLLM dependency closure. +echo "--- :python: Installing cacheblend-plugin harness deps" +uv pip install httpx + +# 3. Pull the cacheblend-plugin. Coordinates come from the Buildkite pipeline +# env; CB_PLUGIN_REPO is required (set it in the pipeline env so the repo +# owner/name is not hardcoded here). REF/DIR have sensible fallbacks. +CB_PLUGIN_REPO="${CB_PLUGIN_REPO:?CB_PLUGIN_REPO not set — set the plugin repo (owner/name) in the Buildkite pipeline env}" +CB_PLUGIN_REF="${CB_PLUGIN_REF:-main}" +CB_PLUGIN_DIR="${CB_PLUGIN_DIR:-/tmp/cb-plugin}" + +echo "--- :arrow_down: Pulling ${CB_PLUGIN_REPO}@${CB_PLUGIN_REF}" +_cb_tok="${GH_TOKEN:-${GITHUB_TOKEN:-}}" +echo " GH_TOKEN present: $([ -n "${_cb_tok}" ] && echo yes || echo NO)" +if [ -z "${_cb_tok}" ]; then + echo "ERROR: GH_TOKEN/GITHUB_TOKEN not set. ${CB_PLUGIN_REPO} is private — set a" >&2 + echo " fine-grained PAT (resource owner = the repo's org, Contents: read)" >&2 + echo " as GH_TOKEN in the Buildkite pipeline env." >&2 + exit 1 +fi +_cb_url="https://x-access-token:${_cb_tok}@github.com/${CB_PLUGIN_REPO}.git" +# Isolate from the Buildkite agent's git config: its checkout credential is a +# GitHub App token scoped to LMCache (injected via the GLOBAL git config — +# insteadOf / extraheader / credential.helper) and 403s on the plugin repo. +# Ignoring global+system config forces git to use ONLY our GH_TOKEN from the URL. +_cb_git=(env GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null + git -c credential.helper=) +rm -rf "${CB_PLUGIN_DIR}" +# --branch handles a branch/tag; a commit SHA needs a full clone + checkout. +if ! "${_cb_git[@]}" clone --depth 1 --branch "${CB_PLUGIN_REF}" "${_cb_url}" "${CB_PLUGIN_DIR}" 2>/dev/null; then + "${_cb_git[@]}" clone "${_cb_url}" "${CB_PLUGIN_DIR}" + git -C "${CB_PLUGIN_DIR}" checkout "${CB_PLUGIN_REF}" +fi +echo " cacheblend-plugin @ $(git -C "${CB_PLUGIN_DIR}" rev-parse --short HEAD) (ref=${CB_PLUGIN_REF})" + +# 4. Install the plugin editable; --no-deps keeps the env's torch/vllm/lmcache, +# --no-build-isolation because the plugin is pure-Python (no compile step). +echo "--- :python: Installing cacheblend-plugin (editable)" +git config --global --add safe.directory "${CB_PLUGIN_DIR}" 2>/dev/null || true +uv pip install -e "${CB_PLUGIN_DIR}" --no-deps --no-build-isolation + +# 5. Import/registration smoke so a broken install fails here, not 180s into a +# server-boot timeout inside the harness. +python -c "import lmcache_cacheblend; print('cacheblend-plugin imported OK')" + +export CB_PLUGIN_DIR CB_PLUGIN_REF +echo "--- :white_check_mark: CacheBlend env ready (plugin @ ${CB_PLUGIN_REF}, dir=${CB_PLUGIN_DIR})" diff --git a/.buildkite/k3_harness/setup-cluster.sh b/.buildkite/k3_harness/setup-cluster.sh new file mode 100755 index 0000000..969e20b --- /dev/null +++ b/.buildkite/k3_harness/setup-cluster.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# Idempotent K3s + GPU Operator + CI base image setup. +# Safe to re-run — skips components already installed. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +export KUBECONFIG=/etc/rancher/k3s/k3s.yaml + +CI_BASE_IMAGE="lmcache/ci-base:latest" +DATA_DIR="/data" + +# ── K3s ────────────────────────────────────────────────────── +if command -v k3s &>/dev/null && systemctl is-active --quiet k3s; then + echo "✓ K3s already running" +else + echo "→ Installing K3s..." + curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="server \ + --disable=traefik \ + --write-kubeconfig-mode=644" sh - + echo "✓ K3s installed" +fi + +# Persist KUBECONFIG for interactive shells +grep -q 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' ~/.bashrc 2>/dev/null || \ + echo 'export KUBECONFIG=/etc/rancher/k3s/k3s.yaml' >> ~/.bashrc + +kubectl get nodes +echo "" + +# ── Helm ───────────────────────────────────────────────────── +if ! command -v helm &>/dev/null; then + echo "→ Installing Helm..." + curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash +fi +echo "✓ Helm $(helm version --short)" + +# ── NVIDIA GPU Operator ────────────────────────────────────── +if helm status gpu-operator -n gpu-operator &>/dev/null; then + echo "✓ GPU Operator already installed" +else + echo "→ Installing GPU Operator..." + helm repo add nvidia https://helm.ngc.nvidia.com/nvidia + helm repo update nvidia + helm install gpu-operator nvidia/gpu-operator \ + --namespace gpu-operator --create-namespace \ + --set driver.enabled=false \ + --set toolkit.enabled=true \ + --wait --timeout 5m + echo "✓ GPU Operator installed" +fi + +echo "→ Waiting for device plugin..." +kubectl wait --for=condition=ready pod \ + -l app=nvidia-device-plugin-daemonset \ + -n gpu-operator --timeout=120s + +GPU_COUNT=$(kubectl get node -o jsonpath='{.items[0].status.allocatable.nvidia\.com/gpu}') +echo "✓ GPUs available: ${GPU_COUNT}" + +# ── CI base image ──────────────────────────────────────────── +# Auto-detect GPU compute capability for TORCH_CUDA_ARCH_LIST +COMPUTE_CAP=$(nvidia-smi --query-gpu=compute_cap --format=csv,noheader | head -1) +ARCH_LIST="${COMPUTE_CAP}+PTX" + +# Check if image already exists in K3s containerd +if k3s ctr images check | grep -q "${CI_BASE_IMAGE}"; then + echo "✓ CI base image already imported (${CI_BASE_IMAGE})" + echo " To rebuild: run with REBUILD_IMAGE=1" + if [[ "${REBUILD_IMAGE:-}" != "1" ]]; then + SKIP_IMAGE=1 + fi +fi + +if [[ "${SKIP_IMAGE:-}" != "1" ]]; then + echo "→ Building CI base image (TORCH_CUDA_ARCH_LIST=${ARCH_LIST})..." + docker build \ + -f "${SCRIPT_DIR}/ci-base.Dockerfile" \ + --build-arg TORCH_CUDA_ARCH_LIST="${ARCH_LIST}" \ + -t "${CI_BASE_IMAGE}" \ + "${REPO_ROOT}" + + echo "→ Importing into K3s containerd..." + docker save "${CI_BASE_IMAGE}" | k3s ctr images import - + echo "✓ CI base image ready" +fi + +# ── Shared host volumes ────────────────────────────────────── +mkdir -p "${DATA_DIR}/huggingface" "${DATA_DIR}/datasets" +echo "✓ Host volumes: ${DATA_DIR}/{huggingface,datasets}" + +echo "" +echo "=== Cluster ready ===" +echo " K3s: $(k3s --version | head -1)" +echo " GPU Operator: $(helm list -n gpu-operator -o json | python3 -c 'import sys,json;print(json.load(sys.stdin)[0]["app_version"])' 2>/dev/null || echo 'unknown')" +echo " GPUs: ${GPU_COUNT}x $(nvidia-smi --query-gpu=name --format=csv,noheader | head -1)" +echo " Arch list: ${ARCH_LIST}" +echo " Base image: ${CI_BASE_IMAGE}" +echo " Data dir: ${DATA_DIR}" +echo "" +echo "Next: run ./install-agent-stack.sh " diff --git a/.buildkite/k3_harness/setup-env.sh b/.buildkite/k3_harness/setup-env.sh new file mode 100755 index 0000000..a5c5642 --- /dev/null +++ b/.buildkite/k3_harness/setup-env.sh @@ -0,0 +1,298 @@ +#!/usr/bin/env bash +# Per-job environment setup: installs vLLM nightly + LMCache from source. +# Called at the start of every CI job. +set -euo pipefail + +# Print the failing command and line number on any error. +trap 'echo "ERROR: setup-env.sh failed at line $LINENO (exit code $?)" >&2' ERR + +# ── GPU health pre-check ──────────────────────────────────── +# Fail fast if GPUs are occupied by stale host processes. +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "${REPO_ROOT}/.buildkite/k3_tests/common_scripts/helpers.sh" +check_gpu_health 80 + +# Resolve which vLLM nightly to install. Sets PINNED_VLLM_VERSION (empty +# string means "use latest nightly", any other value means +# "install vllm=="). See script header for the full +# resolution order and override knobs. +source "${REPO_ROOT}/.buildkite/k3_harness/resolve-pinned-vllm.sh" + +echo "--- :broom: Pre-install bytecode/cache eviction" +# The CI base image pre-installs packages from requirements/*.txt at image +# build time. We've observed k3 jobs (integration + correctness) fail with +# ImportError: cannot import name 'GenerationConfig' from 'transformers' +# after `uv pip install -U vllm ...` upgrades transformers, even though the +# installed .py files clearly expose GenerationConfig (verified both 5.5.0 +# and 5.5.4). The same install recipe replayed in a fresh venv outside CI +# always succeeds, so the failure is tied to base-image filesystem state +# (stale __pycache__, partial upgrades via overlayfs, etc.). Evict all +# bytecode caches and uv's download cache up front so later steps operate +# on clean ground; this is cheap (few seconds) and idempotent. +find /opt/venv/lib/python3.12/site-packages -type d -name __pycache__ \ + -exec rm -rf {} + 2>/dev/null || true +uv cache clean 2>/dev/null || true + +echo "--- :python: Installing vLLM nightly (pinned to cu130 index)" +# The base image is nvidia/cuda:13.0.2-devel-ubuntu24.04 (system nvcc 13). +# vLLM's generic nightly index (wheels.vllm.ai/nightly/vllm/) non-deterministically +# resolves to either a cu128 or a cu130 torch wheel depending on which wheel +# vLLM's nightly CI happened to publish that day. When the resolver picks a +# cu128 torch, torch.utils.cpp_extension._check_cuda_version aborts the +# LMCache editable install with: +# RuntimeError: The detected CUDA version (13.0) mismatches the version +# that was used to compile PyTorch (12.8). +# +# Pin to the cu130 sub-index so torch.version.cuda is always "13.0" and +# matches the base image. This also lets us drop the HTML-scraping + apt +# cuda-compiler alignment dance that lived here before. +# (See https://docs.vllm.ai/ install tips → Nightly → CUDA 13.0.) +# +# --reinstall-package for transformers / tokenizers / huggingface-hub / +# safetensors / vllm forces uv to uninstall-and-reinstall those packages +# even when it thinks the existing install is up to date. That is the +# minimum set to put the full `vllm serve` import chain on a freshly +# extracted wheel, which bypasses whatever filesystem-level mismatch in +# the base image was causing the GenerationConfig ImportError. +# +# When PINNED_VLLM_VERSION is non-empty (resolved by resolve-pinned-vllm.sh +# from the `buildkite_latest_tested_vllm` branch), pin to that exact wheel +# so every CI job matches the version most recently verified by the +# canary build. Empty falls back to "latest nightly". +# +# vLLM's nightly index (wheels.vllm.ai/nightly//) only keeps the +# *latest* wheel; older versions get rolled off within a day or two and +# pinning them there fails with "no version of vllm==X". Historical +# wheels are still served at wheels.vllm.ai///, +# which is a PEP 503 simple index. The canary records that archive URL +# directly in the pin file, so the common path needs zero extra API +# calls. As a fallback (e.g. an old-format pin file written before the +# canary started recording metadata, or a manual override via +# PINNED_VLLM_VERSION), we still expand the short SHA via the public +# GitHub commits API; GITHUB_TOKEN is honoured (5000 req/h) but optional. +PINNED_VLLM_INDEX_ARGS=() +if [[ -n "${PINNED_VLLM_VERSION:-}" ]]; then + VLLM_INSTALL_SPEC="vllm[runai,tensorizer,flashinfer]==${PINNED_VLLM_VERSION}" + echo "Installing vLLM pinned: ${VLLM_INSTALL_SPEC}" + archive_url="${PINNED_VLLM_ARCHIVE_INDEX_URL:-}" + if [[ -z "${archive_url}" ]]; then + # Old-format pin file or per-build override: resolve on demand. + short_sha="${PINNED_VLLM_VERSION##*+g}" + if [[ "${short_sha}" != "${PINNED_VLLM_VERSION}" \ + && "${short_sha}" =~ ^[0-9a-f]+$ ]]; then + gh_auth_args=() + if [[ -n "${GITHUB_TOKEN:-}" ]]; then + gh_auth_args=(-H "Authorization: Bearer ${GITHUB_TOKEN}") + fi + full_sha="" + for attempt in 1 2 3; do + full_sha="$(curl -fsSL --connect-timeout 5 --max-time 10 \ + -H "Accept: application/vnd.github+json" \ + "${gh_auth_args[@]+"${gh_auth_args[@]}"}" \ + "https://api.github.com/repos/vllm-project/vllm/commits/${short_sha}" \ + 2>/dev/null \ + | awk -F'"' '/"sha":/ {print $4; exit}')" || true + if [[ "${full_sha}" =~ ^[0-9a-f]{40}$ ]]; then + break + fi + echo "[INFO] GitHub commit lookup attempt ${attempt} for" \ + "${short_sha} returned no SHA; retrying..." >&2 + sleep 2 + done + if [[ "${full_sha}" =~ ^[0-9a-f]{40}$ ]]; then + archive_url="https://wheels.vllm.ai/${full_sha}/cu130" + else + echo "[WARN] could not resolve full SHA for ${short_sha}; pip" \ + "may fail if the wheel has rolled off the nightly index" >&2 + fi + fi + fi + if [[ -n "${archive_url}" ]]; then + echo "Adding commit-archived index: ${archive_url}" + PINNED_VLLM_INDEX_ARGS+=(--extra-index-url "${archive_url}") + fi +else + VLLM_INSTALL_SPEC="vllm[runai,tensorizer,flashinfer]" + echo "Installing latest vLLM nightly (no pin)" +fi +uv pip install -U "${VLLM_INSTALL_SPEC}" --pre \ + --reinstall-package transformers \ + --reinstall-package tokenizers \ + --reinstall-package huggingface-hub \ + --reinstall-package safetensors \ + --reinstall-package vllm \ + "${PINNED_VLLM_INDEX_ARGS[@]+"${PINNED_VLLM_INDEX_ARGS[@]}"}" \ + --extra-index-url https://wheels.vllm.ai/nightly/cu130 \ + --extra-index-url https://download.pytorch.org/whl/cu130 \ + --index-strategy unsafe-best-match + +# Pre-import transformers on the main thread via sitecustomize.py so +# vllm's BG-thread preload can't race ahead of _LazyModule init. +cat > /opt/venv/lib/python3.12/site-packages/sitecustomize.py <<'PY' +try: + import transformers # noqa: F401 +except Exception: + pass +PY + +# Probe the vLLM CLI by invoking `vllm --help` as a subprocess. This is the +# only probe that exercises the full import chain that `vllm serve` runs: +# vllm.entrypoints.cli.main.main() triggers `import vllm.entrypoints.cli. +# benchmark.main` *inside* the function body, which in turn loads +# vllm.config.model -> vllm.transformers_utils.config -> `from transformers +# import GenerationConfig, PretrainedConfig`. A plain `from vllm.entrypoints. +# cli.main import main` only resolves the `main` symbol; it never executes +# the function, so it silently passes even when the CLI is broken. Shared +# between the pre-install auto-heal loop below and the post-install hard +# probe at the end of this script. +probe_vllm_cli() { + vllm --help 2>&1 >/dev/null +} + +# vLLM nightlies periodically add eager imports of packages that aren't in +# their declared deps (e.g. `pandas` from vllm/_aiter_ops.py). Auto-install +# any ModuleNotFoundError modules so the job keeps going. Capped to avoid +# infinite loops; every auto-install is logged so the drift is visible in +# the build output. ImportError with a missing top-level name (e.g. a +# transformers/vLLM API break) bails immediately since reinstalling the +# package wouldn't recover. +dump_transformers_state() { + # Called whenever a CLI probe fails with anything other than a clean + # ModuleNotFoundError. This failure mode has been reproducible only on + # the K3s pods, never on a fresh local venv with identical versions, + # so we need a direct view of the running pod's filesystem + Python + # state to make progress. + echo "=================== DIAGNOSTIC DUMP ===================" >&2 + echo "--- uv pip list (relevant packages) ---" >&2 + uv pip list 2>/dev/null | grep -iE "^(transformers|tokenizers|huggingface|safetensors|vllm|torch) " >&2 || true + local tf_dir=/opt/venv/lib/python3.12/site-packages/transformers + echo "--- transformers directory listing ---" >&2 + ls -la "${tf_dir}/__init__.py" "${tf_dir}/__pycache__/__init__.cpython-312.pyc" 2>&1 >&2 || true + echo "--- dist-info METADATA version ---" >&2 + grep -m1 "^Version:" /opt/venv/lib/python3.12/site-packages/transformers-*.dist-info/METADATA 2>/dev/null >&2 || true + echo "--- transformers/__init__.py: __version__ line ---" >&2 + grep -n "^__version__" "${tf_dir}/__init__.py" 2>/dev/null >&2 || true + echo "--- transformers/__init__.py: 'generation' key in _import_structure ---" >&2 + awk '/"generation":/,/\]/' "${tf_dir}/__init__.py" 2>/dev/null | head -20 >&2 || true + echo "--- Python sees: ---" >&2 + python - <<'PY' >&2 2>&1 || true +import sys, importlib +print(f"sys.executable = {sys.executable}") +print(f"sys.path = {sys.path}") +try: + import transformers + print(f"transformers.__file__ = {transformers.__file__}") + print(f"transformers.__version__ = {transformers.__version__}") + print(f"type(transformers) = {type(transformers).__name__}") + print(f"'GenerationConfig' in dir(transformers) = {'GenerationConfig' in dir(transformers)}") + cs2m = getattr(transformers, "_class_to_module", None) + print(f"has _class_to_module = {cs2m is not None}") + if cs2m is not None: + print(f"GenerationConfig in _class_to_module = {'GenerationConfig' in cs2m}") + print(f"first 5 keys = {list(cs2m.keys())[:5]}") + import_struct = getattr(transformers, "_import_structure", None) + print(f"has _import_structure = {import_struct is not None}") + if import_struct is not None: + print(f"'generation' in _import_structure = {'generation' in import_struct}") + print(f"_import_structure['generation'] = {import_struct.get('generation')}") + try: + from transformers import GenerationConfig + print("DIRECT IMPORT of GenerationConfig WORKED") + except Exception as e: + print(f"DIRECT IMPORT failed: {type(e).__name__}: {e}") +except Exception as e: + import traceback + traceback.print_exc() +PY + echo "=================== END DIAGNOSTIC DUMP ===================" >&2 +} + +MAX_AUTO_INSTALL=5 +for i in $(seq 1 "$MAX_AUTO_INSTALL"); do + if err=$(probe_vllm_cli); then + break + fi + mod=$(printf '%s\n' "$err" | sed -n "s/.*No module named '\([^']*\)'.*/\1/p" | head -1) + if [[ -z "$mod" ]]; then + echo "vLLM import failed with a non-ModuleNotFoundError:" >&2 + echo "$err" >&2 + dump_transformers_state + exit 1 + fi + if [[ "$i" == "$MAX_AUTO_INSTALL" ]]; then + echo "Hit $MAX_AUTO_INSTALL auto-install retries; last missing module: $mod" >&2 + echo "$err" >&2 + exit 1 + fi + echo "Auto-installing missing vLLM runtime dep: $mod" + uv pip install "$mod" +done + +echo "--- :mag: Verifying torch CUDA matches system nvcc" +# Sanity check: fail fast with a clear message if the cu130 pin above +# somehow didn't produce a cu13x torch. Previously this mismatch surfaced +# deep inside ninja as a cryptic `cusparse.h: No such file or directory`; +# catching it here makes the failure mode obvious. +python - <<'PY' +import subprocess, sys, torch +tc = torch.version.cuda or "" +try: + nv = subprocess.check_output(["nvcc", "--version"], text=True) + sys_major = next( + (line.split("release ")[1].split(",")[0].split(".")[0] + for line in nv.splitlines() if "release " in line), + "", + ) +except Exception: + sys_major = "" +torch_major = tc.split(".")[0] if tc else "" +print(f"torch.version.cuda={tc!r}; system nvcc major={sys_major!r}") +if torch_major and sys_major and torch_major != sys_major: + sys.exit( + f"CUDA major mismatch: torch={torch_major} vs nvcc={sys_major}. " + "Check the vLLM nightly cu130 index pin in setup-env.sh." + ) +PY + +echo "--- :python: Installing LMCache from source" +# Skip setuptools_scm git describe; the repo carries non-PEP-440 tags +# (nightly, nightly-cu13) that crash the newer vcs_versioning backend. +export SETUPTOOLS_SCM_PRETEND_VERSION_FOR_LMCACHE="${SETUPTOOLS_SCM_PRETEND_VERSION_FOR_LMCACHE:-0.0.0+ci}" +uv pip freeze | sort > /tmp/env-before-lmcache.txt +uv pip install -e . --no-build-isolation +uv pip freeze | sort > /tmp/env-after-lmcache.txt +if ! diff -q /tmp/env-before-lmcache.txt /tmp/env-after-lmcache.txt >/dev/null; then + echo "--- :warning: Packages changed during LMCache install" + diff /tmp/env-before-lmcache.txt /tmp/env-after-lmcache.txt || true +fi + +echo "--- :broom: Post-install bytecode eviction" +# Belt-and-suspenders companion to the pre-install eviction above: clear +# __pycache__ again after the LMCache editable install, which may have +# triggered fresh imports (setuptools_scm, pyproject build backend) and +# deposited new .pyc files that reference now-downgraded packages. +find /opt/venv/lib/python3.12/site-packages -type d -name __pycache__ \ + -exec rm -rf {} + 2>/dev/null || true + +echo "--- :mag: Post-install CLI chain probe" +# The LMCache editable install can downgrade transitive deps to honor the +# caps in requirements/common.txt. If that leaves the env in a state where +# `vllm --help` cannot complete its import chain (vllm.entrypoints.cli. +# main.main() -> vllm.entrypoints.cli.benchmark.main -> vllm.config -> +# vllm.transformers_utils.config -> `from transformers import ...`), the +# only other signal is a 180s `wait_for_server` timeout inside each test +# harness. Re-probe the full chain here so broken envs fail fast with the +# actual traceback instead of a generic timeout. +if err=$(probe_vllm_cli); then + echo "vLLM CLI import chain OK post-install." +else + echo "FATAL: vLLM CLI import chain broken after LMCache install." >&2 + echo "--- Traceback ---" >&2 + echo "$err" >&2 + echo "--- Installed packages ---" >&2 + uv pip freeze >&2 || true + exit 1 +fi + +echo "--- :white_check_mark: Environment ready" +python -c "import vllm; import lmcache; print(f'vLLM={vllm.__version__}, LMCache installed from source with no build isolation')" diff --git a/.buildkite/k3_harness/setup-gpu-monitor.sh b/.buildkite/k3_harness/setup-gpu-monitor.sh new file mode 100755 index 0000000..7cdae33 --- /dev/null +++ b/.buildkite/k3_harness/setup-gpu-monitor.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Install the GPU monitor as a cron job on the CI host machine. +# Run this once during initial cluster setup. +# +# Usage: sudo bash setup-gpu-monitor.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MONITOR_SCRIPT="${SCRIPT_DIR}/gpu-monitor.sh" +LOG_FILE="/var/log/gpu-monitor.log" +CRON_INTERVAL="*/10" # Every 10 minutes + +if [[ ! -f "$MONITOR_SCRIPT" ]]; then + echo "ERROR: gpu-monitor.sh not found at $MONITOR_SCRIPT" + exit 1 +fi + +chmod +x "$MONITOR_SCRIPT" + +# Create log file with rotation +touch "$LOG_FILE" + +# Set up logrotate +cat > /etc/logrotate.d/gpu-monitor </dev/null | grep -v "gpu-monitor.sh" || true; echo "$CRON_CMD") | crontab - + +echo "GPU monitor installed:" +echo " Script: $MONITOR_SCRIPT" +echo " Log: $LOG_FILE" +echo " Cron: $CRON_CMD" +echo "" +echo "View logs: tail -f $LOG_FILE" +echo "Remove cron: crontab -l | grep -v gpu-monitor.sh | crontab -" diff --git a/.buildkite/k3_harness/setup-lmcache-only-env.sh b/.buildkite/k3_harness/setup-lmcache-only-env.sh new file mode 100755 index 0000000..1758ebf --- /dev/null +++ b/.buildkite/k3_harness/setup-lmcache-only-env.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Per-job environment setup for jobs that DON'T need vLLM (e.g. unit tests). +# Installs LMCache from source on top of the ci-base image, which already +# has torch + requirements/cuda.txt + build.txt baked in. Much faster than +# setup-env.sh since it skips the vLLM nightly install entirely. +set -euo pipefail + +trap 'echo "ERROR: setup-lmcache-only-env.sh failed at line $LINENO (exit code $?)" >&2' ERR + +# ── GPU health pre-check ──────────────────────────────────── +# Fail fast if GPUs are occupied by stale host processes. +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "${REPO_ROOT}/.buildkite/k3_tests/common_scripts/helpers.sh" +check_gpu_health 80 + +echo "--- :python: Installing LMCache from source (no vLLM)" +# Skip setuptools_scm git describe; the repo carries non-PEP-440 tags +# (nightly, nightly-cu13) that crash the newer vcs_versioning backend. +export SETUPTOOLS_SCM_PRETEND_VERSION_FOR_LMCACHE="${SETUPTOOLS_SCM_PRETEND_VERSION_FOR_LMCACHE:-0.0.0+ci}" +uv pip install -e . --no-build-isolation + +echo "--- :white_check_mark: Environment ready (LMCache only, no vLLM)" +python -c "import lmcache; print('LMCache installed from source')" diff --git a/.buildkite/k3_harness/setup-sglang-env.sh b/.buildkite/k3_harness/setup-sglang-env.sh new file mode 100755 index 0000000..a3cffcd --- /dev/null +++ b/.buildkite/k3_harness/setup-sglang-env.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Per-job env setup for the SGLang + LMCache MP integration tests. +# Drop the fork install once https://github.com/sgl-project/sglang/pull/24089 +# lands. +set -euo pipefail +trap 'echo "ERROR: setup-sglang-env.sh failed at line $LINENO (exit code $?)" >&2' ERR + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "${REPO_ROOT}/.buildkite/k3_tests/common_scripts/helpers.sh" +check_gpu_health 80 + +echo "--- :wrench: System tools (rustup, protoc, libnuma1)" +# rustup: sglang-grpc needs Rust 1.85+ (apt's rustc is too old). +# protoc: sglang-grpc's prost-build shells out to it. +# libnuma1: sgl_kernel's sm100 .so dynamically links to libnuma.so.1. +if ! command -v rustc >/dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- \ + -y --default-toolchain stable --profile minimal --no-modify-path +fi +# shellcheck disable=SC1091 +. "${HOME}/.cargo/env" +rustc --version + +APT_NEEDED=() +command -v protoc >/dev/null 2>&1 || APT_NEEDED+=("protobuf-compiler") +[[ -e /usr/lib/x86_64-linux-gnu/libnuma.so.1 || -e /lib/x86_64-linux-gnu/libnuma.so.1 ]] || APT_NEEDED+=("libnuma1") +if [[ ${#APT_NEEDED[@]} -gt 0 ]]; then + apt-get update + apt-get install -y --no-install-recommends "${APT_NEEDED[@]}" +fi +protoc --version + +echo "--- :package: SGLang + LMCache install" +SGLANG_URL="git+https://github.com/sgl-project/sglang.git@main#subdirectory=python" +uv pip install "${SGLANG_URL}" +export SETUPTOOLS_SCM_PRETEND_VERSION_FOR_LMCACHE="${SETUPTOOLS_SCM_PRETEND_VERSION_FOR_LMCACHE:-0.0.0+ci}" + +uv pip uninstall cupy-cuda12x 2>/dev/null || true +uv pip install -e . --no-build-isolation + +python -c "import lmcache, sglang; print(f'sglang={sglang.__version__}; lmcache OK')" +python -c "import cupy; print(f'cupy={cupy.__version__}')" diff --git a/.buildkite/k3_harness/smoke-test.sh b/.buildkite/k3_harness/smoke-test.sh new file mode 100755 index 0000000..7e7971d --- /dev/null +++ b/.buildkite/k3_harness/smoke-test.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Verify K3s + GPU Operator works: schedule a pod that runs nvidia-smi. +set -euo pipefail + +export KUBECONFIG=/etc/rancher/k3s/k3s.yaml + +echo "=== Smoke Test ===" + +# 1. Check cluster +echo "→ Cluster" +kubectl get nodes -o wide +echo "" + +# 2. Check GPU resources +GPU_COUNT=$(kubectl get node -o jsonpath='{.items[0].status.allocatable.nvidia\.com/gpu}') +echo "→ GPUs allocatable: ${GPU_COUNT}" +if [[ "${GPU_COUNT}" -lt 1 ]]; then + echo "✗ No GPUs found on node" + exit 1 +fi + +# 3. Run GPU pod +echo "→ Launching GPU test pod..." +kubectl delete pod gpu-smoke-test --ignore-not-found --wait=false &>/dev/null + +cat <<'EOF' | kubectl apply -f - +apiVersion: v1 +kind: Pod +metadata: + name: gpu-smoke-test +spec: + restartPolicy: Never + containers: + - name: test + image: nvidia/cuda:12.8.0-base-ubuntu24.04 + command: ["nvidia-smi"] + resources: + limits: + nvidia.com/gpu: "1" +EOF + +kubectl wait --for=jsonpath='{.status.phase}'=Succeeded pod/gpu-smoke-test --timeout=120s +echo "" +echo "→ Pod logs:" +kubectl logs gpu-smoke-test +kubectl delete pod gpu-smoke-test + +echo "" +echo "=== Smoke test passed ===" diff --git a/.buildkite/k3_harness/teardown.sh b/.buildkite/k3_harness/teardown.sh new file mode 100755 index 0000000..165b773 --- /dev/null +++ b/.buildkite/k3_harness/teardown.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Tear down K3s stack. Removes: agent-stack-k8s, GPU Operator, K3s. +# Host data volumes (/data/*) are NOT deleted. +set -euo pipefail + +export KUBECONFIG=/etc/rancher/k3s/k3s.yaml + +echo "=== Teardown ===" + +# agent-stack-k8s +if helm status agent-stack-k8s -n buildkite &>/dev/null; then + echo "→ Removing agent-stack-k8s..." + helm uninstall agent-stack-k8s -n buildkite --wait +fi + +# GPU Operator +if helm status gpu-operator -n gpu-operator &>/dev/null; then + echo "→ Removing GPU Operator..." + helm uninstall gpu-operator -n gpu-operator --wait + kubectl delete namespace gpu-operator --ignore-not-found +fi + +# K3s +if command -v k3s-uninstall.sh &>/dev/null; then + echo "→ Removing K3s..." + /usr/local/bin/k3s-uninstall.sh +fi + +echo "✓ Teardown complete" +echo " Note: /data/{huggingface,datasets} preserved" diff --git a/.buildkite/k3_harness/values.yaml b/.buildkite/k3_harness/values.yaml new file mode 100644 index 0000000..04fa55e --- /dev/null +++ b/.buildkite/k3_harness/values.yaml @@ -0,0 +1,25 @@ +# Reference Helm values for agent-stack-k8s. +# install-agent-stack.sh sets these automatically. +# This file is for documentation only. +# Docs: https://github.com/buildkite/agent-stack-k8s + +agentToken: "" + +config: + queue: "k8s" # override with BUILDKITE_QUEUE env var + + # HTTPS checkout using GitHub token (replaces SSH key) + git-credentials-secret: + name: buildkite-git-creds # created by install-agent-stack.sh + key: git-credentials # contains https://x-access-token:@github.com + + # Inject GITHUB_TOKEN into job containers for git push operations + pod-spec-patch: + containers: + - name: container-0 + env: + - name: GITHUB_TOKEN + valueFrom: + secretKeyRef: + name: buildkite-git-creds + key: GITHUB_TOKEN diff --git a/.buildkite/k3_tests/README.md b/.buildkite/k3_tests/README.md new file mode 100644 index 0000000..dd7016b --- /dev/null +++ b/.buildkite/k3_tests/README.md @@ -0,0 +1,154 @@ +# K8s Test Pipelines + +Each subdirectory under `k3_tests/` is a self-contained test with these files: + +| File | Purpose | +|------|---------| +| `run.sh` | Test script (sources `k3_harness/setup-env.sh`, then runs tests) | +| `pipeline.yml` | K8s pod spec — GPU count, volumes, timeouts | +| `buildkite-pipeline.yml` | What to paste into the Buildkite UI "Steps" editor (runs path filter, then uploads `pipeline.yml`) | +| `BK_WEB_SETUP.md` | Full Buildkite UI setup instructions: env vars, trigger filters, recommendations | + +## Buildkite Web UI Setup + +### Prerequisites + +Before creating pipelines, make sure a queue named `k8s` exists in your Buildkite cluster. Go to **Organization Settings → Default cluster → Queues → New Queue** and create it. The queue needs no configuration and no agents — agent-stack-k8s creates ephemeral pod-based agents automatically when jobs arrive. + +### Per-pipeline setup + +For each test directory, create a pipeline in the Buildkite UI. +Each directory has a `BK_WEB_SETUP.md` with the exact settings — env vars, GitHub trigger filters, and recommendations for that test. The short version: + +1. Go to your org → **New Pipeline** +2. In the **Steps** editor, paste the contents of that test's `buildkite-pipeline.yml`: + ```yaml + agents: + queue: "k8s" + + env: + HF_TOKEN: "" + + steps: + - label: ":pipeline: Upload pipeline" + command: buildkite-agent pipeline upload .buildkite/k3_tests//pipeline.yml + ``` + The `agents.queue` must match the queue you created above. This routes the upload step to agent-stack-k8s, which checks out the repo, runs the path filter, and (if the build isn't skipped) uploads the real `pipeline.yml`. Each subsequent step also targets the same queue. +3. `HF_TOKEN` is needed for gated model access (e.g., Llama, Qwen). Set it in the `env` block as shown above, or under **Pipeline Settings → Environment Variables** in the UI — both work +4. Under **GitHub Settings**, configure trigger filters per the test's `BK_WEB_SETUP.md` +5. Save — jobs will run on the K8s queue automatically + +### Path-based skip (auto-pass on docs-only changes) + +The upload step in `buildkite-pipeline.yml` runs `common_scripts/upload-pipeline.sh` +instead of `buildkite-agent pipeline upload` directly. The wrapper +(`common_scripts/path-filter.sh`) inspects the changed files in the build and: + +- **Skips** the build (exits 0 without uploading `pipeline.yml` → build is + green with just the upload step) when *all* changed files match a "trivial" + pattern: `*.md`, `LICENSE*`, `NOTICE*`, `.gitignore`, `.gitattributes`, + `.editorconfig`, `.mailmap`, `CODEOWNERS`, or anything under `docs/` or + `.github/`. (`.github/` is trivial here because k3 tests run on Buildkite, + not GitHub Actions, so workflow / CODEOWNERS / template changes do not + affect them.) +- **Force-runs** the build when any changed file lives under `.buildkite/` — + those PRs are usually fixing the k3 CI itself, so we want them tested on + the PR rather than after merge. +- **Runs** the build whenever there is at least one non-trivial file by + uploading `pipeline.yml`, which contains the real test steps. + +Detection: +- PR builds diff against `origin/${BUILDKITE_PULL_REQUEST_BASE_BRANCH}` + (default `main`) using the merge-base. +- Push builds diff `HEAD~1..HEAD`. +- Scheduled builds (`BUILDKITE_SOURCE=schedule`) are never skipped. +- If the script can't determine the changed files (shallow clone with no + parent, missing base branch, etc.) it falls back to "do not skip". + +To bypass the skip and force a full run, add the **`force-ci`** label to the +PR on GitHub. Buildkite picks up PR labels automatically; when the filter +sees `force-ci` it runs the full pipeline regardless of which files changed. + +### Trigger strategy + +Not all tests should run on every push. The general pattern: + +| Test weight | When to trigger | Example filter condition | +|-------------|----------------|------------------------| +| Lightweight (1 GPU, <20 min) | Every push / every PR | *(no filter)* | +| Heavy (multi-GPU, >30 min) | PR label or main branch only | `build.pull_request.labels includes "full" \|\| build.branch == 'dev'` | + +Set **"Rebuild on PR label change"** to `Yes` for label-triggered pipelines so adding a label to an existing PR kicks off the build. + +## Adding a New Test + +1. Create a directory: `.buildkite/k3_tests//` + +2. Write a `run.sh` that sources the shared environment setup: + ```bash + #!/usr/bin/env bash + set -euo pipefail + cd "$(cd "$(dirname "$0")/../../.." && pwd)" + source .buildkite/k3_harness/setup-env.sh + # ... your test commands ... + ``` + +3. Write a `pipeline.yml`. Set the GPU limit to what your test needs: + ```yaml + steps: + - label: ":test_tube: My Test" + command: .buildkite/k3_tests//run.sh + timeout_in_minutes: 30 + agents: { queue: "k8s" } + plugins: + - kubernetes: + podSpec: + containers: + - name: container-0 + image: lmcache/ci-base:latest + imagePullPolicy: Never # local image, imported into K3s containerd + resources: + limits: + nvidia.com/gpu: "1" + volumeMounts: + - { name: hf-cache, mountPath: /root/.cache/huggingface } + volumes: + - { name: hf-cache, hostPath: { path: /data/huggingface, type: DirectoryOrCreate } } + ``` + +4. Write a `buildkite-pipeline.yml` (the snippet pasted into the Buildkite UI's Steps + editor). Use `common_scripts/upload-pipeline.sh` so the test gets path-based skip: + ```yaml + agents: + queue: "k8s" + + steps: + - label: ":pipeline: Upload pipeline" + command: bash .buildkite/k3_tests/common_scripts/upload-pipeline.sh .buildkite/k3_tests//pipeline.yml + ``` + +5. Write a `BK_WEB_SETUP.md` documenting the Buildkite UI settings for this test (env vars, trigger filters, recommendations). Use an existing test's `BK_WEB_SETUP.md` as a template. + +6. `chmod +x` your `run.sh` and create the pipeline in the Buildkite UI. + +### Optional: datasets volume + +If your test needs pre-downloaded data (e.g., ShareGPT), add the datasets volume: +```yaml +volumeMounts: + - { name: datasets, mountPath: /root/correctness } +volumes: + - { name: datasets, hostPath: { path: /data/datasets, type: DirectoryOrCreate } } +``` + +### Optional: Docker-in-Docker + +If your test runs Docker containers inside the pod: +```yaml +securityContext: + privileged: true +volumeMounts: + - { name: docker-sock, mountPath: /var/run/docker.sock } +volumes: + - { name: docker-sock, hostPath: { path: /var/run/docker.sock } } +``` diff --git a/.buildkite/k3_tests/blend/buildkite-pipeline.yml b/.buildkite/k3_tests/blend/buildkite-pipeline.yml new file mode 100644 index 0000000..327d74e --- /dev/null +++ b/.buildkite/k3_tests/blend/buildkite-pipeline.yml @@ -0,0 +1,20 @@ +# Paste this into the Buildkite pipeline's "Steps" editor. +# It uploads the real pipeline definition from the repo. +# +# Fill in HF_TOKEN (gated model access), GH_TOKEN (read access to the private +# plugin repo), and CB_PLUGIN_REPO (owner/name of the plugin repo). CB_PLUGIN_REF +# selects which plugin commit to test the PR against; CB_PLUGIN_DIR is the clone +# destination in the pod. +agents: + queue: "k8s" + +env: + HF_TOKEN: "" # HuggingFace token — gated model access (e.g. Llama) + GH_TOKEN: "" # GitHub token — read access to the private plugin repo + CB_PLUGIN_REPO: "" # owner/name of the plugin repo (required; set here or in the UI) + CB_PLUGIN_REF: "main" # plugin git ref to test the PR against + CB_PLUGIN_DIR: "/tmp/cb-plugin" # clone destination in the pod + +steps: + - label: ":pipeline: Upload pipeline" + command: bash .buildkite/k3_tests/common_scripts/upload-pipeline.sh .buildkite/k3_tests/blend/pipeline.yml diff --git a/.buildkite/k3_tests/blend/pipeline.yml b/.buildkite/k3_tests/blend/pipeline.yml new file mode 100644 index 0000000..9665dd0 --- /dev/null +++ b/.buildkite/k3_tests/blend/pipeline.yml @@ -0,0 +1,34 @@ +# CacheBlend <-> LMCache compatibility test. +# +# Builds the PR's LMCache from source (compiling c_ops for the pod's GPU arch), +# pulls the external cacheblend-plugin from GitHub, installs it editable, and +# runs the plugin's vllm_compat_check (--skip-correctness): one blend server + +# vLLM stack, five V3 contract checks ending in a non-prefix blend that must +# engage. Catches LMCache changes that break the external plugin *before* merge +# — the recurring rebase/version-skew class (RPC key renames, handler-signature +# drift, KV-layout changes, etc.). +# +# Needs one GPU. Set HF_TOKEN (gated model) and GH_TOKEN (private plugin repo) +# in the Buildkite UI — see BK_WEB_SETUP.md. + +steps: + - label: ":jigsaw: CacheBlend plugin compat (Llama-8B)" + command: .buildkite/k3_tests/blend/run.sh + timeout_in_minutes: 30 + agents: { queue: "k8s" } + plugins: + - kubernetes: + podSpec: + containers: + - name: container-0 + image: lmcache/ci-base:latest + imagePullPolicy: Never # local image, imported into K3s containerd + resources: + limits: + nvidia.com/gpu: "1" + volumeMounts: + - { name: hf-cache, mountPath: /root/.cache/huggingface } + volumes: + - { name: hf-cache, hostPath: { path: /data/huggingface, type: DirectoryOrCreate } } + artifact_paths: + - "cb_compat_logs/**/*" diff --git a/.buildkite/k3_tests/blend/run.sh b/.buildkite/k3_tests/blend/run.sh new file mode 100755 index 0000000..44626eb --- /dev/null +++ b/.buildkite/k3_tests/blend/run.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" + +cd "${REPO_ROOT}" + +# ── Environment setup ──────────────────────────────────────── +source .buildkite/k3_harness/setup-blend-env.sh + +# ── Ensure all scripts are executable ──────────────────────── +chmod +x "${SCRIPT_DIR}"/scripts/*.sh + +# ── Run the actual test logic ──────────────────────────────── +exec bash "${SCRIPT_DIR}/scripts/run-compat.sh" "$@" diff --git a/.buildkite/k3_tests/blend/scripts/run-compat.sh b/.buildkite/k3_tests/blend/scripts/run-compat.sh new file mode 100755 index 0000000..2ec8799 --- /dev/null +++ b/.buildkite/k3_tests/blend/scripts/run-compat.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# CacheBlend <-> LMCache compatibility check. +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../../.." && pwd)" + +cd "${REPO_ROOT}" +source .buildkite/k3_tests/common_scripts/helpers.sh + +# Fail fast if the GPUs are occupied by stale host processes. +check_gpu_health 80 + +: "${CB_PLUGIN_DIR:?CB_PLUGIN_DIR not set — source k3_harness/setup-blend-env.sh first}" + +MODEL="${CB_MODEL:-meta-llama/Llama-3.1-8B-Instruct}" +MAX_LEN="${CB_MAX_MODEL_LEN:-16384}" +LOG_DIR="${REPO_ROOT}/cb_compat_logs" # workspace-relative so artifact_paths can collect it +mkdir -p "${LOG_DIR}" + +# Resolve the venv that owns vllm/lmcache. In the CI pod this is the image's +# /opt/venv (on PATH via setup-env.sh); CB_VENV overrides for local runs. +if [ -n "${CB_VENV:-}" ]; then + VENV="${CB_VENV}" +elif [ -n "${VIRTUAL_ENV:-}" ]; then + VENV="${VIRTUAL_ENV}" +elif command -v vllm >/dev/null 2>&1; then + VENV="$(cd "$(dirname "$(command -v vllm)")/.." && pwd)" +else + VENV="/opt/venv" +fi + +echo "+++ :jigsaw: vllm_compat_check model=${MODEL} venv=${VENV} plugin=${CB_PLUGIN_DIR}" +python "${CB_PLUGIN_DIR}/.buildkite/harness/vllm_compat_check.py" \ + --model "${MODEL}" \ + --gpu-sync 0 \ + --skip-correctness \ + --venv "${VENV}" \ + --max-model-len "${MAX_LEN}" \ + --log-dir "${LOG_DIR}" diff --git a/.buildkite/k3_tests/common_scripts/helpers.sh b/.buildkite/k3_tests/common_scripts/helpers.sh new file mode 100755 index 0000000..770c424 --- /dev/null +++ b/.buildkite/k3_tests/common_scripts/helpers.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# Shared helper functions for K3s test scripts. +# Source this file from every scripts/*.sh. + +# Track background PIDs for cleanup +TRACKED_PIDS=() + +# Check that all visible GPUs have sufficient free memory. +# Fails fast with a clear message if a host-level process is hogging GPU memory. +# Usage: check_gpu_health [min_free_percent] +# min_free_percent: minimum percentage of GPU memory that must be free (default: 80) +check_gpu_health() { + local min_free_pct="${1:-80}" + echo "--- :mag: GPU health check (require ${min_free_pct}% free memory)" + + if ! command -v nvidia-smi &>/dev/null; then + echo " nvidia-smi not found, skipping GPU health check" + return 0 + fi + + # Parse GPU info into a temp file to avoid subshell variable scoping issues + local gpu_info + gpu_info=$(nvidia-smi --query-gpu=index,memory.total,memory.used,memory.free --format=csv,noheader,nounits 2>/dev/null | sed 's/ //g') + + if [[ -z "$gpu_info" ]]; then + echo " No GPUs detected, skipping" + return 0 + fi + + local has_problem=false + while IFS=, read -r idx total used free; do + + if [[ "$total" -eq 0 ]]; then + continue + fi + + local free_pct=$((free * 100 / total)) + + if [[ "$free_pct" -lt "$min_free_pct" ]]; then + echo " WARNING: GPU $idx has only ${free_pct}% free (${free} MiB free / ${total} MiB total, ${used} MiB used)" + # Show which processes are using this GPU + nvidia-smi --query-compute-apps=pid,gpu_uuid,used_memory --format=csv,noheader -i "$idx" 2>/dev/null | while IFS= read -r proc; do + echo " Process: $proc" + done + has_problem=true + else + echo " GPU $idx: OK (${free_pct}% free, ${free} MiB / ${total} MiB)" + fi + done <<< "$gpu_info" + + if [[ "$has_problem" == "true" ]]; then + echo "" + echo "FATAL: One or more GPUs have insufficient free memory." + echo "This usually means a stale process on the host is consuming GPU memory." + echo "Check the host with: nvidia-smi" + echo "To fix: kill the offending host processes, then re-run the CI job." + return 1 + fi + + echo " All GPUs healthy." + return 0 +} + +# Find an available TCP port starting from a given port number. +# Usage: find_free_port [start_port] +find_free_port() { + local port="${1:-8000}" + while [ "$port" -lt 65536 ]; do + if ! lsof -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1 && + ! timeout 1 bash -c "/dev/null; then + echo "$port" + return 0 + fi + ((port++)) + done + echo "ERROR: No available port found starting from ${1:-8000}" >&2 + return 1 +} + +# Wait for a vLLM server to become ready by polling /v1/models. +# Usage: wait_for_server [timeout_secs] [log_file] +# If log_file is provided, its tail is dumped to stderr on timeout so the +# real failure (e.g. an ImportError during startup) is visible inline in the +# job output instead of requiring a trip through build artifacts. +wait_for_server() { + local port="$1" + local timeout="${2:-180}" + local log_file="${3:-}" + echo "Waiting for vLLM on port $port (timeout=${timeout}s)..." + for ((i = 0; i < timeout; i++)); do + if curl -sf "http://localhost:${port}/v1/models" >/dev/null 2>&1; then + echo "vLLM ready on port $port (${i}s)" + return 0 + fi + sleep 1 + done + echo "vLLM failed to start on port $port within ${timeout}s" >&2 + if [[ -n "$log_file" && -f "$log_file" ]]; then + echo "--- :page_facing_up: Last 200 lines of ${log_file}" >&2 + tail -n 200 "$log_file" >&2 + fi + return 1 +} + +# Kill all tracked background PIDs and wait for them. +# Call this in a trap handler. +cleanup_pids() { + echo "--- Cleaning up background processes..." + for pid in "${TRACKED_PIDS[@]}"; do + if kill -0 "$pid" 2>/dev/null; then + echo " Killing PID $pid" + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + fi + done + TRACKED_PIDS=() + # Give GPU memory a moment to release + sleep 2 +} diff --git a/.buildkite/k3_tests/common_scripts/path-filter.sh b/.buildkite/k3_tests/common_scripts/path-filter.sh new file mode 100755 index 0000000..7b99f2b --- /dev/null +++ b/.buildkite/k3_tests/common_scripts/path-filter.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +# Path filter: decide whether a CI build can be skipped based on which files +# changed since the base commit. +# +# Usage: +# source path-filter.sh +# if should_skip_ci; then +# # all changed files are trivial (docs, etc.) +# fi +# +# Rules: +# - If ANY changed file lives under .buildkite/, the build runs. +# (Those PRs are usually fixing the k3 CI itself, so we want to test on +# the PR instead of waiting for it to land on main.) +# - Otherwise, if EVERY changed file matches a "trivial" pattern (markdown, +# LICENSE, anything under docs/ or .github/, etc.), the build can be +# skipped. .github/ is trivial for k3 because k3 tests run on Buildkite, +# not GitHub Actions, so workflow/CODEOWNERS/template changes do not +# affect what the k3 tests do. +# - Anything else → build runs. +# +# Opt-out: add a "force-ci" label to the PR on GitHub. Buildkite exposes +# PR labels via BUILDKITE_PULL_REQUEST_LABELS; if "force-ci" is present +# the filter is bypassed and the full pipeline runs. +# +# Detection of "changed files": +# - PR builds → diff against the merge-base with BUILDKITE_PULL_REQUEST_BASE_BRANCH. +# - Push builds → diff HEAD~1..HEAD. +# - Anything we can't figure out → fall back to "do not skip". + +set -uo pipefail + +# ── Pattern lists ───────────────────────────────────────────── +# bash `case` patterns: `*` matches any string including `/`, so `docs/*` +# matches `docs/foo/bar.png` as well as `docs/foo`. + +_path_filter_is_always_trigger() { + case "$1" in + .buildkite/*) return 0 ;; + esac + return 1 +} + +_path_filter_is_trivial() { + case "$1" in + *.md) return 0 ;; + LICENSE|LICENSE.*) return 0 ;; + NOTICE|NOTICE.*) return 0 ;; + .gitignore|.gitattributes|.editorconfig|.mailmap) return 0 ;; + CODEOWNERS) return 0 ;; + docs/*) return 0 ;; + .github/*) return 0 ;; + esac + return 1 +} + +# ── Changed-files detection ─────────────────────────────────── + +_path_filter_get_changed_files() { + local base_branch base merge_base + + # Ephemeral pods may not have GitHub's SSH host key yet. + # Accept new keys automatically so git-fetch doesn't hang on a prompt. + export GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=accept-new -o LogLevel=ERROR" + + if [[ -n "${BUILDKITE_PULL_REQUEST:-}" && "${BUILDKITE_PULL_REQUEST:-}" != "false" ]]; then + base_branch="${BUILDKITE_PULL_REQUEST_BASE_BRANCH:-main}" + # Buildkite checks out shallow; fetch enough history to find the merge-base. + git fetch --no-tags --depth=200 origin "$base_branch" 2>/dev/null || \ + git fetch --no-tags origin "$base_branch" 2>/dev/null || true + + if base=$(git rev-parse --verify "origin/${base_branch}" 2>/dev/null); then + if merge_base=$(git merge-base HEAD "$base" 2>/dev/null); then + git diff --name-only "$merge_base" HEAD + return 0 + fi + # No merge-base (history not deep enough): diff directly. + git diff --name-only "$base" HEAD + return 0 + fi + echo "path-filter: could not resolve origin/${base_branch}" >&2 + return 1 + fi + + # Push build (or unknown context): diff against the previous commit. + if git rev-parse --verify HEAD~1 >/dev/null 2>&1; then + git diff --name-only HEAD~1 HEAD + return 0 + fi + + echo "path-filter: no parent commit available" >&2 + return 1 +} + +# ── Public entry point ──────────────────────────────────────── + +# Returns 0 if the build can be safely skipped, non-zero otherwise. +# Prints a classification of every changed file to stderr for the build log. +should_skip_ci() { + # PR label opt-out: adding "force-ci" on GitHub forces a full run. + if [[ ",${BUILDKITE_PULL_REQUEST_LABELS:-}," == *",force-ci,"* ]]; then + echo "path-filter: PR has 'force-ci' label → not skipping" >&2 + return 1 + fi + + # Never skip scheduled builds (e.g. nightly baselines with NEED_UPLOAD=true). + if [[ "${BUILDKITE_SOURCE:-}" == "schedule" ]]; then + echo "path-filter: scheduled build (BUILDKITE_SOURCE=schedule) → not skipping" >&2 + return 1 + fi + + local changed_files + if ! changed_files=$(_path_filter_get_changed_files); then + echo "path-filter: could not determine changed files → not skipping" >&2 + return 1 + fi + + if [[ -z "$changed_files" ]]; then + echo "path-filter: no changed files reported → not skipping (safer default)" >&2 + return 1 + fi + + local has_always_trigger=0 + local has_non_trivial=0 + local trivial_count=0 + local total=0 + + echo "path-filter: classifying changed files:" >&2 + while IFS= read -r f; do + [[ -z "$f" ]] && continue + total=$((total + 1)) + if _path_filter_is_always_trigger "$f"; then + has_always_trigger=1 + echo " [force-trigger] $f" >&2 + elif _path_filter_is_trivial "$f"; then + trivial_count=$((trivial_count + 1)) + echo " [trivial] $f" >&2 + else + has_non_trivial=1 + echo " [non-trivial] $f" >&2 + fi + done <<< "$changed_files" + + echo "path-filter: ${total} files changed (${trivial_count} trivial)" >&2 + + if [[ "$has_always_trigger" -eq 1 ]]; then + echo "path-filter: at least one file under .buildkite/ → not skipping" >&2 + return 1 + fi + + if [[ "$has_non_trivial" -eq 1 ]]; then + echo "path-filter: non-trivial files changed → not skipping" >&2 + return 1 + fi + + echo "path-filter: all changed files are trivial → SKIP" >&2 + return 0 +} diff --git a/.buildkite/k3_tests/common_scripts/upload-pipeline.sh b/.buildkite/k3_tests/common_scripts/upload-pipeline.sh new file mode 100755 index 0000000..361808e --- /dev/null +++ b/.buildkite/k3_tests/common_scripts/upload-pipeline.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Wraps `buildkite-agent pipeline upload` with a path-based skip check. +# +# Usage (called from each test's buildkite-pipeline.yml upload step): +# command: bash .buildkite/k3_tests/common_scripts/upload-pipeline.sh \ +# .buildkite/k3_tests//pipeline.yml +# +# If every changed file in this build is trivial (markdown, LICENSE, .github, +# etc.) and none touch .buildkite/, this script: +# - Annotates the build with a "skipped" note +# - Exits 0 without uploading any further steps → the build is green +# Otherwise it execs `buildkite-agent pipeline upload `, adding +# the real test steps to the build. +# +# Add a "force-ci" label to the PR on GitHub to bypass the check. + +set -euo pipefail + +PIPELINE_FILE="${1:?Usage: upload-pipeline.sh }" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# shellcheck source=path-filter.sh +source "${SCRIPT_DIR}/path-filter.sh" + +if should_skip_ci; then + echo "+++ :fast_forward: Skipping CI — only trivial files changed" + if command -v buildkite-agent >/dev/null 2>&1; then + buildkite-agent annotate \ + --style success \ + --context "path-filter-skip" \ + "Skipped: only trivial files (docs, license, etc.) changed. Add a \`force-ci\` label to the PR to force a full run." \ + || true + fi + exit 0 +fi + +echo "--- :pipeline: Uploading ${PIPELINE_FILE}" +exec buildkite-agent pipeline upload "${PIPELINE_FILE}" diff --git a/.buildkite/k3_tests/comprehensive/BK_WEB_SETUP.md b/.buildkite/k3_tests/comprehensive/BK_WEB_SETUP.md new file mode 100644 index 0000000..a37b8dd --- /dev/null +++ b/.buildkite/k3_tests/comprehensive/BK_WEB_SETUP.md @@ -0,0 +1,27 @@ +# Buildkite Web UI Setup: Comprehensive Tests + +**Steps editor**: paste contents of `buildkite-pipeline.yml` (fill in `HF_TOKEN`). + +**GitHub trigger settings**: +- Filter: `build.pull_request.labels includes "full" || build.branch == 'dev'` +- Rebuild on PR label change: Yes +- Skip queued / cancel running branch builds: Yes + +Heavy test (10 parallel GPU steps) — run on `"full"` label or dev push, not every PR. + +> Builds whose only changes are docs/`*.md`/`LICENSE`/`.github/**` auto-pass +> via the [path filter](../README.md#path-based-skip-auto-pass-on-docs-only-changes). +> Changes under `.buildkite/` always run. Add `force-ci` label to the PR to +> bypass. + +## Nightly Scheduled Build (rolling baselines) + +Create a **Scheduled Build** on this same pipeline to upload performance baselines: + +- **Schedule**: daily (e.g. `0 2 * * *` — 2am UTC) +- **Branch**: `dev` +- **Extra Environment Variables**: `NEED_UPLOAD=true` + +Each config step writes a date-stamped baseline (`-YYYYMMDD.json`) as a Buildkite artifact. The finalize step (`scripts/upload-baselines.sh`) collects them all, prunes files older than 5 days, and pushes a single commit to the `benchmarks-main` branch. + +PR builds automatically compare against the rolling 5-day worst-case (max latency) baseline. diff --git a/.buildkite/k3_tests/comprehensive/buildkite-pipeline.yml b/.buildkite/k3_tests/comprehensive/buildkite-pipeline.yml new file mode 100644 index 0000000..559fa29 --- /dev/null +++ b/.buildkite/k3_tests/comprehensive/buildkite-pipeline.yml @@ -0,0 +1,11 @@ +# Paste this into the Buildkite pipeline's "Steps" editor. +# It uploads the real pipeline definition from the repo. +agents: + queue: "k8s" + +env: + HF_TOKEN: "" # set your HuggingFace token here (needed for gated model access) + +steps: + - label: ":pipeline: Upload pipeline" + command: bash .buildkite/k3_tests/common_scripts/upload-pipeline.sh .buildkite/k3_tests/comprehensive/pipeline.yml diff --git a/.buildkite/k3_tests/comprehensive/pipeline.yml b/.buildkite/k3_tests/comprehensive/pipeline.yml new file mode 100644 index 0000000..8d80ca5 --- /dev/null +++ b/.buildkite/k3_tests/comprehensive/pipeline.yml @@ -0,0 +1,143 @@ +# Comprehensive integration tests — one step per config, run in parallel. +# Each step gets its own K8s pod with the exact GPU count it needs. +# 2-GPU group is listed first so these heavier jobs get scheduled before 1-GPU jobs. + +x-perf-retry: &perf-retry + automatic: + - exit_status: -1 + limit: 2 + - exit_status: 1 + limit: 2 + +steps: + - group: ":test_tube: Comprehensive (2-GPU)" + steps: + - label: ":test_tube: pd" + command: .buildkite/k3_tests/comprehensive/run.sh pd.yaml + timeout_in_minutes: 30 + retry: *perf-retry + agents: { queue: "k8s" } + plugins: + - kubernetes: + podSpec: &pod-2gpu + containers: + - name: container-0 + image: lmcache/ci-base:latest + imagePullPolicy: Never + resources: { limits: { "nvidia.com/gpu": "2" } } + volumeMounts: &vol-mounts + - { name: hf-cache, mountPath: /root/.cache/huggingface } + - { name: datasets, mountPath: /root/correctness } + volumes: &vols + - { name: hf-cache, hostPath: { path: /data/huggingface, type: DirectoryOrCreate } } + - { name: datasets, hostPath: { path: /data/datasets, type: DirectoryOrCreate } } + artifact_paths: ["*.log", "benchmarks/long_doc_qa/*.json"] + + - label: ":test_tube: p2p" + command: .buildkite/k3_tests/comprehensive/run.sh p2p.yaml + timeout_in_minutes: 30 + retry: *perf-retry + agents: { queue: "k8s" } + plugins: [{ kubernetes: { podSpec: *pod-2gpu } }] + artifact_paths: ["*.log", "benchmarks/long_doc_qa/*.json"] + + - label: ":test_tube: p2p_with_v3" + command: .buildkite/k3_tests/comprehensive/run.sh p2p_with_v3.yaml + timeout_in_minutes: 30 + retry: *perf-retry + agents: { queue: "k8s" } + plugins: [{ kubernetes: { podSpec: *pod-2gpu } }] + artifact_paths: ["*.log", "benchmarks/long_doc_qa/*.json"] + + - group: ":test_tube: Comprehensive (1-GPU)" + steps: + - label: ":test_tube: local_cpu" + command: .buildkite/k3_tests/comprehensive/run.sh local_cpu.yaml + timeout_in_minutes: 30 + retry: *perf-retry + agents: { queue: "k8s" } + plugins: + - kubernetes: + podSpec: &pod-1gpu + containers: + - name: container-0 + image: lmcache/ci-base:latest + imagePullPolicy: Never + resources: { limits: { "nvidia.com/gpu": "1" } } + volumeMounts: *vol-mounts + volumes: *vols + artifact_paths: ["*.log", "benchmarks/long_doc_qa/*.json"] + + - label: ":test_tube: local_disk" + command: .buildkite/k3_tests/comprehensive/run.sh local_disk.yaml + timeout_in_minutes: 30 + retry: *perf-retry + agents: { queue: "k8s" } + plugins: [{ kubernetes: { podSpec: *pod-1gpu } }] + artifact_paths: ["*.log", "benchmarks/long_doc_qa/*.json"] + + - label: ":test_tube: async" + command: .buildkite/k3_tests/comprehensive/run.sh async.yaml + timeout_in_minutes: 30 + retry: *perf-retry + agents: { queue: "k8s" } + plugins: [{ kubernetes: { podSpec: *pod-1gpu } }] + artifact_paths: ["*.log", "benchmarks/long_doc_qa/*.json"] + + - label: ":test_tube: local_cpu_with_v3" + command: .buildkite/k3_tests/comprehensive/run.sh local_cpu_with_v3.yaml + timeout_in_minutes: 30 + retry: *perf-retry + agents: { queue: "k8s" } + plugins: [{ kubernetes: { podSpec: *pod-1gpu } }] + artifact_paths: ["*.log", "benchmarks/long_doc_qa/*.json"] + + - label: ":test_tube: local_disk_with_v3" + command: .buildkite/k3_tests/comprehensive/run.sh local_disk_with_v3.yaml + timeout_in_minutes: 30 + retry: *perf-retry + agents: { queue: "k8s" } + plugins: [{ kubernetes: { podSpec: *pod-1gpu } }] + artifact_paths: ["*.log", "benchmarks/long_doc_qa/*.json"] + + - label: ":test_tube: multi_device" + command: .buildkite/k3_tests/comprehensive/run.sh multi_device.yaml + timeout_in_minutes: 30 + retry: *perf-retry + agents: { queue: "k8s" } + plugins: [{ kubernetes: { podSpec: *pod-1gpu } }] + artifact_paths: ["*.log", "benchmarks/long_doc_qa/*.json"] + + - label: ":test_tube: layerwise" + command: .buildkite/k3_tests/comprehensive/run.sh layerwise.yaml + timeout_in_minutes: 30 + retry: *perf-retry + agents: { queue: "k8s" } + plugins: [{ kubernetes: { podSpec: *pod-1gpu } }] + artifact_paths: ["*.log", "benchmarks/long_doc_qa/*.json"] + + # Nightly baseline upload: only runs when NEED_UPLOAD=true (scheduled build). + # Collects date-stamped baseline artifacts from all config steps, + # prunes files older than 5 days, and pushes a single commit to benchmarks-main. + - wait: ~ + if: build.env("NEED_UPLOAD") == "true" + + - label: ":arrow_up: Upload rolling baselines" + if: build.env("NEED_UPLOAD") == "true" + command: .buildkite/k3_tests/comprehensive/scripts/upload-baselines.sh + timeout_in_minutes: 5 + agents: { queue: "k8s" } + plugins: + - kubernetes: + podSpec: + containers: + - name: container-0 + image: lmcache/ci-base:latest + imagePullPolicy: Never + resources: { limits: { cpu: "1", memory: "2Gi" } } + env: + - name: GITHUB_TOKEN + valueFrom: + secretKeyRef: + name: buildkite-git-creds + key: GITHUB_TOKEN diff --git a/.buildkite/k3_tests/comprehensive/run.sh b/.buildkite/k3_tests/comprehensive/run.sh new file mode 100755 index 0000000..1b1186d --- /dev/null +++ b/.buildkite/k3_tests/comprehensive/run.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Comprehensive integration test entrypoint for K8s pods. +# Usage: run.sh +# Thin wrapper: sets up environment, then delegates to scripts/. +set -euo pipefail + +CFG_NAME="${1:?Usage: $0 }" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" + +cd "${REPO_ROOT}" + +# ── Environment setup ──────────────────────────────────────── +source .buildkite/k3_harness/setup-env.sh + +# Install test utilities (yq for YAML parsing, jq for JSON, openai/pandas/matplotlib for benchmarks) +uv pip install yq jq openai pandas matplotlib 2>/dev/null || true + +# ── Ensure all scripts are executable ──────────────────────── +chmod +x "${SCRIPT_DIR}"/scripts/*.sh + +# ── Run the actual test logic ──────────────────────────────── +exec bash "${SCRIPT_DIR}/scripts/run-single-config.sh" "$CFG_NAME" diff --git a/.buildkite/k3_tests/comprehensive/scripts/run-single-config.sh b/.buildkite/k3_tests/comprehensive/scripts/run-single-config.sh new file mode 100755 index 0000000..43d6790 --- /dev/null +++ b/.buildkite/k3_tests/comprehensive/scripts/run-single-config.sh @@ -0,0 +1,531 @@ +#!/usr/bin/env bash +# Comprehensive integration test: run a single config natively in a K8s pod. +# Replaces the old Docker-based vllm-integration-tests.sh. +# +# Usage: run-single-config.sh +set -euo pipefail + +CFG_NAME="${1:?Usage: $0 }" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../../.." && pwd)" + +cd "${REPO_ROOT}" +source .buildkite/k3_tests/common_scripts/helpers.sh + +CONFIG_DIR="${REPO_ROOT}/.buildkite/configs" +LOGFILE="${REPO_ROOT}/${CFG_NAME%.yaml}.log" +BUILD_ID="${BUILDKITE_BUILD_ID:-local_$$}" +NEED_UPLOAD="${NEED_UPLOAD:-false}" +SERVER_WAIT_TIMEOUT="${SERVER_WAIT_TIMEOUT:-240}" + +# Tee all output so Buildkite can collect it as an artifact +exec > >(tee -a "$LOGFILE") 2>&1 + +# ── Validate config ────────────────────────────────────────── +cfg_file="${CONFIG_DIR}/${CFG_NAME}" +if [[ ! -f "$cfg_file" ]]; then + echo "Config not found: ${cfg_file}" + exit 1 +fi + +feature_type=$(yq -r '.feature.type // ""' "$cfg_file") +echo -e "\033[1;33m===== Testing LMCache with ${CFG_NAME} (type=${feature_type:-standard}) =====\033[0m" + +# Prevent git from hanging on prompts +export GIT_TERMINAL_PROMPT=0 + +# PID tracking for cleanup +PIDS=() +on_exit() { + local rc=$? + echo "--- Cleaning up (exit code: $rc)..." + for p in "${PIDS[@]}"; do + kill "$p" 2>/dev/null || true + wait "$p" 2>/dev/null || true + done + # Copy vLLM server logs to repo root so Buildkite can collect them as artifacts + cp /tmp/build_${BUILD_ID}_${CFG_NAME%.yaml}*.log "${REPO_ROOT}/" 2>/dev/null || true + sleep 5 +} +trap on_exit EXIT INT TERM + +############### +# UTILITIES # +############### + +# Start a vLLM process natively (no Docker). +# Reads env vars from a yq-extracted docker section and vllm args from a vllm section. +start_single_server() { + local docker_section="$1" + local vllm_section="$2" + local port="$3" + local gpu="${4:-}" + local logfile="${5:-/tmp/build_${BUILD_ID}_${CFG_NAME%.yaml}.log}" + + # Collect env vars from docker.env[] + local -a env_cmd=() + while IFS= read -r e; do + [[ -n "$e" ]] && env_cmd+=("$e") + done < <(yq -r '.env[]?' <<<"$docker_section") + + # Standard env vars + env_cmd+=("VLLM_USE_FLASHINFER_SAMPLER=0") + [[ -n "${HF_TOKEN:-}" ]] && env_cmd+=("HF_TOKEN=${HF_TOKEN}") + [[ -n "$gpu" ]] && env_cmd+=("CUDA_VISIBLE_DEVICES=${gpu}") + + # Override socket prefix so each server gets its own socket directory. + # In the old Docker setup, a volume mount mapped /tmp/lmcache_internal_api_server + # inside the container to /tmp/lmcache_internal_api_server/${port} on the host. + # Without Docker, we replicate this by setting the prefix to include the port. + mkdir -p "/tmp/lmcache_internal_api_server/${port}" + env_cmd+=("LMCACHE_INTERNAL_API_SERVER_SOCKET_PATH_PREFIX=/tmp/lmcache_internal_api_server/${port}/socket") + + # Parse vllm model and args + local vllm_model + vllm_model="$(yq -r '.model' <<<"$vllm_section")" + local -a vllm_cli_args=() + mapfile -t vllm_cli_args < <(yq -r '.args // [] | .[]' <<<"$vllm_section") + + echo "Starting vLLM: model=$vllm_model port=$port gpu=${gpu:-all}" + env "${env_cmd[@]}" \ + vllm serve "$vllm_model" "${vllm_cli_args[@]}" --port "$port" \ + >"$logfile" 2>&1 & + local pid=$! + PIDS+=("$pid") + echo " PID=$pid log=$logfile" + + # Wait for readiness + if ! wait_for_server "$port" "$SERVER_WAIT_TIMEOUT"; then + echo "Server failed to start. Last 50 lines of log:" + tail -50 "$logfile" || true + return 1 + fi +} + +############### +# LAUNCH # +############### + +PORT=$(find_free_port 8000) +PORT1="" +PORT2="" +model="" + +if [[ "$feature_type" == "pd" ]]; then + # ── Prefiller-Decoder mode (2 GPUs) ───────────────────── + PORT1=$(find_free_port 8100) + PORT2=$(find_free_port $((PORT1 + 100))) + + prefiller_docker="$(yq '.["docker-prefiller"]' "$cfg_file")" + prefiller_vllm="$(yq '.["vllm-prefiller"]' "$cfg_file")" + decoder_docker="$(yq '.["docker-decoder"]' "$cfg_file")" + decoder_vllm="$(yq '.["vllm-decoder"]' "$cfg_file")" + model="$(yq -r '.["vllm-prefiller"].model' "$cfg_file")" + + # Extra PD env vars + proxy=$(yq -er '.["docker-prefiller"]["proxy-port"]' "$cfg_file" 2>/dev/null || echo "7500") + init=$(yq -er '.["docker-decoder"]["init-port"]' "$cfg_file" 2>/dev/null || echo "7300") + alloc=$(yq -er '.["docker-decoder"]["alloc-port"]' "$cfg_file" 2>/dev/null || echo "7400") + + # Inject PD-specific env vars into docker sections + prefiller_docker=$(echo "$prefiller_docker" | yq -y --arg proxy "$proxy" '. + {"env": (.env + ["LMCACHE_PD_PROXY_PORT=" + $proxy])}') + decoder_docker=$(echo "$decoder_docker" | yq -y --arg init "$init" --arg alloc "$alloc" '. + {"env": (.env + ["LMCACHE_PD_PEER_INIT_PORT=" + $init, "LMCACHE_PD_PEER_ALLOC_PORT=" + $alloc])}') + + echo "--- Starting prefiller on port $PORT1 (GPU 0)" + # Prefiller needs UCX_TLS for NIXL transport + prefiller_docker=$(echo "$prefiller_docker" | yq -y ". + {\"env\": (.env + [\"UCX_TLS=cuda_ipc,cuda_copy,tcp\"])}") + start_single_server "$prefiller_docker" "$prefiller_vllm" "$PORT1" "0" \ + "${REPO_ROOT}/${CFG_NAME%.yaml}-prefiller.log" + + echo "--- Starting decoder on port $PORT2 (GPU 1)" + decoder_docker=$(echo "$decoder_docker" | yq -y ". + {\"env\": (.env + [\"UCX_TLS=cuda_ipc,cuda_copy,tcp\"])}") + start_single_server "$decoder_docker" "$decoder_vllm" "$PORT2" "1" \ + "${REPO_ROOT}/${CFG_NAME%.yaml}-decoder.log" + + # Start disagg proxy + echo "--- Starting PD proxy on port $PORT" + python3 "${REPO_ROOT}/examples/disagg_prefill/disagg_proxy_server.py" \ + --port "$PORT" \ + --prefiller-port "$PORT1" \ + --decoder-port "$PORT2" \ + --decoder-init-port "$init" \ + --decoder-alloc-port "$alloc" \ + --proxy-port "$proxy" \ + > "${REPO_ROOT}/${CFG_NAME%.yaml}-proxy.log" 2>&1 & + PIDS+=($!) + sleep 10 + +elif [[ "$feature_type" == "p2p" ]]; then + # ── Peer-to-Peer mode (2 GPUs) ────────────────────────── + PORT1=$(find_free_port 8177) + PORT2=$(find_free_port $((PORT1 + 100))) + + docker1="$(yq '.docker1' "$cfg_file")" + vllm1="$(yq '.vllm1' "$cfg_file")" + docker2="$(yq '.docker2' "$cfg_file")" + vllm2="$(yq '.vllm2' "$cfg_file")" + model="$(yq -r '.vllm1.model' "$cfg_file")" + + # Controller ports + pull=$(yq -er '.docker1["pull-port"]' "$cfg_file" 2>/dev/null || echo "8300") + reply=$(yq -er '.docker1["reply-port"]' "$cfg_file" 2>/dev/null || echo "8400") + + # Inject controller URLs + docker1=$(echo "$docker1" | yq -y --arg pull "$pull" --arg reply "$reply" '. + {"env": (.env + ["LMCACHE_CONTROLLER_PULL_URL=localhost:" + $pull, "LMCACHE_CONTROLLER_REPLY_URL=localhost:" + $reply, "UCX_TLS=tcp"])}') + + pull2=$(yq -er '.docker2["pull-port"]' "$cfg_file" 2>/dev/null || echo "$pull") + reply2=$(yq -er '.docker2["reply-port"]' "$cfg_file" 2>/dev/null || echo "$reply") + docker2=$(echo "$docker2" | yq -y --arg pull2 "$pull2" --arg reply2 "$reply2" '. + {"env": (.env + ["LMCACHE_CONTROLLER_PULL_URL=localhost:" + $pull2, "LMCACHE_CONTROLLER_REPLY_URL=localhost:" + $reply2, "UCX_TLS=tcp"])}') + + # Start controller + echo "--- Starting P2P controller on port $PORT" + PYTHONHASHSEED=123 lmcache_controller \ + --host localhost \ + --port "$PORT" \ + --monitor-ports "{\"pull\": ${pull}, \"reply\": ${reply}}" \ + > "/tmp/build_${BUILD_ID}_${CFG_NAME%.yaml}_controller.log" 2>&1 & + PIDS+=($!) + sleep 10 + + echo "--- Starting instance 1 on port $PORT1 (GPU 0)" + start_single_server "$docker1" "$vllm1" "$PORT1" "0" \ + "/tmp/build_${BUILD_ID}_${CFG_NAME%.yaml}1.log" + + echo "--- Starting instance 2 on port $PORT2 (GPU 1)" + start_single_server "$docker2" "$vllm2" "$PORT2" "1" \ + "/tmp/build_${BUILD_ID}_${CFG_NAME%.yaml}2.log" + +else + # ── Single server mode (1 GPU) ────────────────────────── + docker_section="$(yq '.docker' "$cfg_file")" + vllm_section="$(yq '.vllm' "$cfg_file")" + model="$(yq -r '.vllm.model' "$cfg_file")" + + echo "--- Starting single server on port $PORT" + start_single_server "$docker_section" "$vllm_section" "$PORT" "" \ + "/tmp/build_${BUILD_ID}_${CFG_NAME%.yaml}.log" +fi + +############### +# WORKLOAD # +############### + +test_mode="$(yq -r '.workload.type' "$cfg_file")" + +if [[ "$test_mode" == "dummy" ]]; then + echo "--- Running dummy test (simple HTTP request)" + http_status_code=$( + curl --max-time 60 "http://localhost:${PORT}/v1/completions" \ + -w "%{http_code}" -o response-file.txt \ + -H "Content-Type: application/json" \ + -d '{ + "model": "'"$model"'", + "prompt": "<|begin_of_text|><|system|>\nYou are a helpful AI assistant.\n<|user|>\nWhat is the capital of France?\n<|assistant|>", + "max_tokens": 100, + "temperature": 0.7 + }' + ) + if [[ "$http_status_code" -ne 200 ]]; then + echo "FAIL: Dummy test HTTP status ${http_status_code}" + cat response-file.txt + exit 1 + fi + echo "PASS: Dummy test succeeded" + cat response-file.txt + +elif [[ "$test_mode" == "long_doc_qa" ]]; then + echo "--- Running long_doc_qa workload" + + # Build workload JSON (merge workload section with model, strip non-CLI fields) + # Fields like expected-latency-gain are used by the checking logic, not long_doc_qa.py. + # "completion" -> "completions" rename to match the argparse flag. + workload_yaml="$(yq --arg model "$model" '(.workload * {"model": $model}) | del(.type) | del(.["expected-latency-gain"]) | if .completion then .completions = .completion | del(.completion) else . end' "$cfg_file")" + cfg_json="$(yq '.' "$cfg_file")" + + # Determine which checking fields are requested + check_warmup_round_time_per_prompt=$( + jq -e '(.["checking-fields"] // []) | index("warmup_round_time_per_prompt") != null' \ + <<< "$cfg_json" >/dev/null && echo true || echo false + ) + check_query_round_time_per_prompt=$( + jq -e '(.["checking-fields"] // []) | index("query_round_time_per_prompt") != null' \ + <<< "$cfg_json" >/dev/null && echo true || echo false + ) + check_query_ttft_per_prompt=$( + jq -e '(.["checking-fields"] // []) | index("warmup_ttft_per_prompt") != null' \ + <<< "$cfg_json" >/dev/null && echo true || echo false + ) + + run_long_doc_qa_workload() { + local workload_config="$1" + local port="$2" + local check_warmup="${3:-false}" + local check_ttft="${4:-false}" + local check_round="${5:-false}" + local feat_type="${6:-dummy}" + local need_upload="${7:-false}" + + echo "Running long_doc_qa on port $port" + + # Build args array from JSON config + local -a workload_args=() + mapfile -d '' -t workload_args < <( + jq -j ' + to_entries[] + | select(.value != null and (.value|tostring) != "") + | ( + if (.value|type) == "boolean" then + if .value then ["--\(.key)", "\u0000"] else [] end + elif (.value|type) == "array" then + .value[] + | ["--\(.key)", "\u0000", + (if (type=="string") then . else tostring end), + "\u0000"] + else + ["--\(.key)", "\u0000", + (if ((.value|type)=="string") then .value else (.value|tostring) end), + "\u0000"] + end + ) + | .[] + ' <<<"$workload_config" + ) + + local json + json=$( + python3 "${REPO_ROOT}/benchmarks/long_doc_qa/long_doc_qa.py" \ + "${workload_args[@]}" \ + --port="$port" \ + --output="response.txt" \ + --json-output \ + 2>>response.txt | tail -n 1 + ) + local query_ttft_per_prompt query_round_time_per_prompt warmup_round_time_per_prompt + query_ttft_per_prompt=$(echo "$json" | jq -r '.query_ttft_per_prompt') + query_round_time_per_prompt=$(echo "$json" | jq -r '.query_round_time_per_prompt') + warmup_round_time_per_prompt=$(echo "$json" | jq -r '.warmup_round_time_per_prompt') + + if [[ "$need_upload" == "true" ]]; then + # Nightly: write date-stamped file and upload as Buildkite artifact. + # A finalize step (upload-baselines.sh) will collect all artifacts, + # prune old files, and make a single commit to benchmarks-main. + local datestamp + datestamp="$(date +%Y%m%d)" + local artifact_name="${feat_type}-${datestamp}.json" + local artifact_path="${REPO_ROOT}/benchmarks/long_doc_qa/${artifact_name}" + + echo "$json" + mkdir -p "$(dirname "$artifact_path")" + printf '%s\n' "$json" > "$artifact_path" + + echo "[INFO] Uploading baseline artifact: ${artifact_name}" + buildkite-agent artifact upload "$artifact_path" 2>/dev/null || { + echo "[WARN] buildkite-agent not available; wrote $artifact_path locally" + } + return 0 + fi + + # ── PR comparison: rolling 5-day worst-case baseline ────── + # Fetch all date-stamped baselines for this feature from benchmarks-main. + # Use max() of each metric (= worst/slowest) as the threshold baseline. + # This makes the gate more tolerant of nightly hardware noise. + timeout 30 git fetch origin benchmarks-main >/dev/null 2>&1 || true + + # List all baseline files for this feature (date-stamped and legacy). + # Note: git ls-tree does NOT expand shell globs in pathspecs, so we + # list the directory and filter with grep instead. + local -a baseline_files=() + mapfile -t baseline_files < <( + git ls-tree --name-only origin/benchmarks-main -- \ + benchmarks/long_doc_qa/ 2>/dev/null \ + | grep -E "^benchmarks/long_doc_qa/${feat_type}(-[0-9]{8})?\.json$" \ + || true + ) + + if [[ ${#baseline_files[@]} -eq 0 ]]; then + if [[ "$feat_type" != "dummy" ]]; then + echo "No baselines found for ${feat_type} -- skipping performance comparisons" + echo "Current metrics: TTFT=$query_ttft_per_prompt, Latency=$query_round_time_per_prompt, Warmup=$warmup_round_time_per_prompt" + fi + return 0 + fi + + echo "[INFO] Found ${#baseline_files[@]} baseline file(s) for ${feat_type}:" + printf " %s\n" "${baseline_files[@]}" + + # Compute rolling max (worst-case) across all baselines + local expected_query_ttft=0 expected_query_round=0 expected_warmup=0 + for bf in "${baseline_files[@]}"; do + local bj + bj=$(git show "origin/benchmarks-main:${bf}" 2>/dev/null || echo "") + if [[ -z "$bj" ]] || ! echo "$bj" | jq -e . >/dev/null 2>&1; then + echo " Skipping invalid baseline: $bf" + continue + fi + # Take max of each metric (higher time = worse perf = more permissive gate) + expected_query_ttft=$(awk "BEGIN { a=$expected_query_ttft; b=$(echo "$bj" | jq -r '.query_ttft_per_prompt // 0'); print (a>b?a:b) }") + expected_query_round=$(awk "BEGIN { a=$expected_query_round; b=$(echo "$bj" | jq -r '.query_round_time_per_prompt // 0'); print (a>b?a:b) }") + expected_warmup=$(awk "BEGIN { a=$expected_warmup; b=$(echo "$bj" | jq -r '.warmup_round_time_per_prompt // 0'); print (a>b?a:b) }") + done + + echo "[INFO] Rolling worst-case baseline: TTFT=$expected_query_ttft, Latency=$expected_query_round, Warmup=$expected_warmup" + + # Sanity check: if all baselines were invalid, skip comparison + if awk "BEGIN { exit ($expected_query_ttft == 0 && $expected_query_round == 0 && $expected_warmup == 0) ? 0 : 1 }"; then + echo "All baselines were invalid or empty -- skipping performance comparisons" + return 0 + fi + + # Adaptive threshold: with fewer baselines the worst-case max is less + # representative, so we allow more headroom. + # 5+ baselines → 1.2x (20% tolerance, full confidence) + # 4 baselines → 1.3x + # 3 baselines → 1.4x + # 2 baselines → 1.5x + # 1 baseline → 1.6x (60% tolerance, low confidence) + local num_baselines=${#baseline_files[@]} + local threshold + threshold=$(awk -v n="$num_baselines" 'BEGIN { + gap = (5 - n); if (gap < 0) gap = 0 + printf "%.1f", 1.2 + gap * 0.1 + }') + local pct + pct=$(awk -v t="$threshold" 'BEGIN { printf "%d", (t - 1) * 100 }') + echo "[INFO] Baselines: ${num_baselines}/5 — using ${pct}% tolerance (threshold=${threshold}x)" + + if [[ "$check_ttft" == "true" ]]; then + echo "Worst-case baseline query ttft per prompt: $expected_query_ttft" + echo "Actual query ttft per prompt: $query_ttft_per_prompt" + awk -v expected="$expected_query_ttft" -v actual="$query_ttft_per_prompt" -v t="$threshold" -v pct="$pct" 'BEGIN { + if (actual > expected * t) { printf "FAIL: Query ttft per prompt >%d%% worse than rolling baseline\n", pct; exit 1 } + else { print "PASS: Query ttft per prompt within threshold" } + }' + fi + + if [[ "$check_round" == "true" ]]; then + echo "Worst-case baseline query round time per prompt: $expected_query_round" + echo "Actual query round time per prompt: $query_round_time_per_prompt" + awk -v expected="$expected_query_round" -v actual="$query_round_time_per_prompt" -v t="$threshold" -v pct="$pct" 'BEGIN { + if (actual > expected * t) { printf "FAIL: Query round time per prompt >%d%% worse than rolling baseline\n", pct; exit 1 } + else { print "PASS: Query round time per prompt within threshold" } + }' + fi + + if [[ "$check_warmup" == "true" ]]; then + echo "Worst-case baseline warmup round time per prompt: $expected_warmup" + echo "Actual warmup round time per prompt: $warmup_round_time_per_prompt" + awk -v expected="$expected_warmup" -v actual="$warmup_round_time_per_prompt" -v t="$threshold" -v pct="$pct" 'BEGIN { + if (actual > expected * t) { printf "FAIL: Warmup round time per prompt >%d%% worse than rolling baseline\n", pct; exit 1 } + else { print "PASS: Warmup round time per prompt within threshold" } + }' + fi + } + + if [[ "$feature_type" == "p2p" ]]; then + run_long_doc_qa_workload "$workload_yaml" "$PORT1" + run_long_doc_qa_workload "$workload_yaml" "$PORT2" \ + "$check_warmup_round_time_per_prompt" "$check_query_ttft_per_prompt" \ + "$check_query_round_time_per_prompt" "${CFG_NAME%.yaml}" "$NEED_UPLOAD" + else + run_long_doc_qa_workload "$workload_yaml" "$PORT" \ + "$check_warmup_round_time_per_prompt" "$check_query_ttft_per_prompt" \ + "$check_query_round_time_per_prompt" "${CFG_NAME%.yaml}" "$NEED_UPLOAD" + fi +fi + +############### +# MEMORY LEAK # +############### + +check_memory_leak() { + local port="$1" + local socket_path="/tmp/lmcache_internal_api_server/${port}/socket_7000" + + local use_hot + use_hot=$(curl -s --unix-socket "$socket_path" "http://localhost/conf" 2>/dev/null | jq -r '.local_cpu // false') + if [[ -z "$use_hot" || "$use_hot" == "null" ]]; then + use_hot="false" + fi + + echo "Checking memory leak on socket_path $socket_path (use_hot=$use_hot)..." + + local metrics + metrics=$(curl -s --unix-socket "$socket_path" "http://localhost/metrics" 2>/dev/null) + if [[ -z "$metrics" ]]; then + echo "ERROR: Failed to fetch metrics from $socket_path" + return 1 + fi + + local local_cpu_hot_cache_count active_memory_objs_count pinned_memory_objs_count pin_monitor_pinned_objects_count + local_cpu_hot_cache_count=$(echo "$metrics" | grep -E '^lmcache:local_cpu_hot_cache_count\b' | awk '{print $2}' | head -n 1) + active_memory_objs_count=$(echo "$metrics" | grep -E '^lmcache:active_memory_objs_count\b' | awk '{print $2}' | head -n 1) + pinned_memory_objs_count=$(echo "$metrics" | grep -E '^lmcache:pinned_memory_objs_count\b' | awk '{print $2}' | head -n 1) + pin_monitor_pinned_objects_count=$(echo "$metrics" | grep -E '^lmcache:pin_monitor_pinned_objects_count\b' | awk '{print $2}' | head -n 1) + + local_cpu_hot_cache_count=$(printf "%.0f" "${local_cpu_hot_cache_count:-0}") + active_memory_objs_count=$(printf "%.0f" "${active_memory_objs_count:-0}") + pinned_memory_objs_count=$(printf "%.0f" "${pinned_memory_objs_count:-0}") + pin_monitor_pinned_objects_count=$(printf "%.0f" "${pin_monitor_pinned_objects_count:-0}") + + echo " local_cpu_hot_cache_count: $local_cpu_hot_cache_count" + echo " active_memory_objs_count: $active_memory_objs_count" + echo " pinned_memory_objs_count: $pinned_memory_objs_count" + echo " pin_monitor_pinned_objects_count: $pin_monitor_pinned_objects_count" + + local has_leak=false + + if [[ "$pinned_memory_objs_count" -ne 0 ]]; then + echo "ERROR: Memory leak detected - pinned_memory_objs_count ($pinned_memory_objs_count) should be 0" + has_leak=true + fi + + if [[ "$use_hot" == "false" || "$use_hot" == "False" ]]; then + if [[ "$local_cpu_hot_cache_count" -ne 0 ]]; then + echo "ERROR: Memory leak - local_cpu_hot_cache_count ($local_cpu_hot_cache_count) should be 0 when use_hot=false" + has_leak=true + fi + if [[ "$active_memory_objs_count" -ne 0 ]]; then + echo "ERROR: Memory leak - active_memory_objs_count ($active_memory_objs_count) should be 0 when use_hot=false" + has_leak=true + fi + else + if [[ "$active_memory_objs_count" -ne "$local_cpu_hot_cache_count" ]]; then + echo "ERROR: Memory leak - active_memory_objs_count ($active_memory_objs_count) should equal local_cpu_hot_cache_count ($local_cpu_hot_cache_count) when use_hot=true" + has_leak=true + fi + fi + + if [[ "$has_leak" == "true" ]]; then + echo "$metrics" + return 1 + fi + + echo " Memory leak check passed!" + return 0 +} + +# Wait for metrics to settle +sleep 30 +echo "--- Checking for memory leaks..." + +if [[ "$feature_type" == "p2p" ]]; then + echo "Skipping memory leak check for p2p (known issue with pinned_memory_objs not clearing)." +elif [[ "$feature_type" == "pd" ]]; then + if ! check_memory_leak "$PORT1"; then + echo "Memory leak check failed for instance 1 (port $PORT1)" + exit 1 + fi + if ! check_memory_leak "$PORT2"; then + echo "Memory leak check failed for instance 2 (port $PORT2)" + exit 1 + fi +elif [[ "$CFG_NAME" == "multi_device.yaml" || "$CFG_NAME" == "layerwise.yaml" ]]; then + echo "Skipping memory leak check for $CFG_NAME case as it's a flaky test while run check_memory_leak check." +else + if ! check_memory_leak "$PORT"; then + echo "Memory leak check failed for $CFG_NAME" + exit 1 + fi +fi + +echo "===== PASS: ${CFG_NAME} =====" diff --git a/.buildkite/k3_tests/comprehensive/scripts/upload-baselines.sh b/.buildkite/k3_tests/comprehensive/scripts/upload-baselines.sh new file mode 100755 index 0000000..1080145 --- /dev/null +++ b/.buildkite/k3_tests/comprehensive/scripts/upload-baselines.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# Finalize nightly baseline upload. +# Runs AFTER all comprehensive config steps complete (via `wait`). +# +# 1. Downloads all *-YYYYMMDD.json artifacts from this build +# 2. Checks out benchmarks-main +# 3. Copies new date-stamped files, prunes entries older than 5 days +# 4. Makes a single commit and pushes to benchmarks-main +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../../.." && pwd)" +BASELINE_DIR="${REPO_ROOT}/benchmarks/long_doc_qa" +KEEP_DAYS=5 + +cd "${REPO_ROOT}" + +# Prevent git from hanging on prompts +export GIT_TERMINAL_PROMPT=0 + +############### +# DOWNLOAD # +############### + +ARTIFACT_DIR="/tmp/baseline_artifacts" +mkdir -p "$ARTIFACT_DIR" + +echo "--- Downloading baseline artifacts from this build" +buildkite-agent artifact download "benchmarks/long_doc_qa/*-*.json" "$ARTIFACT_DIR" 2>/dev/null || { + echo "No baseline artifacts found. This is expected if no long_doc_qa configs ran." + exit 0 +} + +NEW_FILES=("$ARTIFACT_DIR"/benchmarks/long_doc_qa/*-*.json) +if [[ ! -e "${NEW_FILES[0]}" ]]; then + echo "No date-stamped baseline files found in artifacts." + exit 0 +fi + +echo "Downloaded ${#NEW_FILES[@]} baseline file(s):" +printf " %s\n" "${NEW_FILES[@]}" + +############### +# CHECKOUT # +############### + +# Work in a detached worktree to avoid touching the main checkout +WORK_DIR="/tmp/baselines_push_$$" +trap 'rm -rf "$WORK_DIR"' EXIT + +# Push baselines to a dedicated CI repo instead of the main LMCache repo. +CI_REPO="LMCache/LMCache" +CI_BRANCH="benchmarks-main" + +if [[ -n "${GITHUB_TOKEN:-}" ]]; then + CI_REPO_URL="https://x-access-token:${GITHUB_TOKEN}@github.com/${CI_REPO}.git" +else + echo "[WARN] GITHUB_TOKEN not set — push may fail without credentials" + CI_REPO_URL="https://github.com/${CI_REPO}.git" +fi + +echo "--- Preparing ${CI_BRANCH} branch from ${CI_REPO}" +git clone --depth=1 --branch "${CI_BRANCH}" "${CI_REPO_URL}" "$WORK_DIR" 2>/dev/null || { + # Branch doesn't exist yet — create an orphan + mkdir -p "$WORK_DIR" + git -C "$WORK_DIR" init + git -C "$WORK_DIR" remote add origin "${CI_REPO_URL}" + git -C "$WORK_DIR" checkout --orphan "${CI_BRANCH}" +} + +############### +# COPY + PRUNE# +############### + +mkdir -p "$WORK_DIR/benchmarks/long_doc_qa" + +# Copy new files +for f in "${NEW_FILES[@]}"; do + cp "$f" "$WORK_DIR/benchmarks/long_doc_qa/" +done + +# Prune date-stamped files older than KEEP_DAYS +echo "--- Pruning baselines older than ${KEEP_DAYS} days" +CUTOFF_DATE="$(date -d "${KEEP_DAYS} days ago" +%Y%m%d 2>/dev/null || date -v-${KEEP_DAYS}d +%Y%m%d)" + +for f in "$WORK_DIR"/benchmarks/long_doc_qa/*-*.json; do + [[ ! -e "$f" ]] && continue + fname="$(basename "$f")" + # Extract YYYYMMDD from filename like "local_cpu-20260301.json" + file_date="${fname##*-}" + file_date="${file_date%.json}" + if [[ "$file_date" =~ ^[0-9]{8}$ ]] && [[ "$file_date" < "$CUTOFF_DATE" ]]; then + echo " Removing old baseline: $fname" + rm "$f" + fi +done + +# Also remove legacy single-point files (feature.json without date) since we now use rolling +# Keep them if they exist for backward compat during transition, but don't create new ones. + +############### +# COMMIT+PUSH # +############### + +cd "$WORK_DIR" +git add benchmarks/long_doc_qa/ + +if git diff --cached --quiet 2>/dev/null; then + echo "No changes to commit." + exit 0 +fi + +TODAY="$(date +%Y-%m-%d)" +git -c user.email="ci@lmcache.ai" -c user.name="LMCache CI" \ + commit -m "Update rolling baselines: ${TODAY}" || true + +echo "--- Pushing to ${CI_REPO} ${CI_BRANCH}" +# Try normal push first; fall back to force-push if history diverged +if ! git push origin "HEAD:${CI_BRANCH}" 2>/dev/null; then + echo "[WARN] Normal push failed, force-pushing..." + git push origin "+HEAD:${CI_BRANCH}" 2>/dev/null || { + echo "[ERROR] Failed to push baselines to ${CI_REPO} ${CI_BRANCH}" + exit 1 + } +fi + +echo "--- Baselines uploaded successfully" +git log --oneline -1 +ls -la benchmarks/long_doc_qa/ diff --git a/.buildkite/k3_tests/correctness/BK_WEB_SETUP.md b/.buildkite/k3_tests/correctness/BK_WEB_SETUP.md new file mode 100644 index 0000000..77bbc2d --- /dev/null +++ b/.buildkite/k3_tests/correctness/BK_WEB_SETUP.md @@ -0,0 +1,14 @@ +# Buildkite Web UI Setup: Correctness Tests + +**Steps editor**: paste contents of `buildkite-pipeline.yml` (fill in `HF_TOKEN`). + +**GitHub trigger settings**: +- Filter: *(none — runs on every push/PR)* +- Skip queued / cancel running branch builds: Yes + +Lightweight (1 GPU) — good candidate for a required GitHub status check. + +> Builds whose only changes are docs/`*.md`/`LICENSE`/`.github/**` auto-pass +> via the [path filter](../README.md#path-based-skip-auto-pass-on-docs-only-changes). +> Changes under `.buildkite/` always run. Add `force-ci` label to the PR to +> bypass. diff --git a/.buildkite/k3_tests/correctness/buildkite-pipeline.yml b/.buildkite/k3_tests/correctness/buildkite-pipeline.yml new file mode 100644 index 0000000..ed9fb33 --- /dev/null +++ b/.buildkite/k3_tests/correctness/buildkite-pipeline.yml @@ -0,0 +1,11 @@ +# Paste this into the Buildkite pipeline's "Steps" editor. +# It uploads the real pipeline definition from the repo. +agents: + queue: "k8s" + +env: + HF_TOKEN: "" # set your HuggingFace token here (needed for gated model access) + +steps: + - label: ":pipeline: Upload pipeline" + command: bash .buildkite/k3_tests/common_scripts/upload-pipeline.sh .buildkite/k3_tests/correctness/pipeline.yml diff --git a/.buildkite/k3_tests/correctness/pipeline.yml b/.buildkite/k3_tests/correctness/pipeline.yml new file mode 100644 index 0000000..935c377 --- /dev/null +++ b/.buildkite/k3_tests/correctness/pipeline.yml @@ -0,0 +1,27 @@ +# Correctness test — verifies LMCache produces identical output to base vLLM. +# Runs: base vLLM → LMCache vLLM → man bash prefix test → ShareGPT diff. +# Requires pre-downloaded model weights and ShareGPT dataset. + +steps: + - label: ":white_check_mark: Correctness" + command: .buildkite/k3_tests/correctness/run.sh + timeout_in_minutes: 30 + agents: { queue: "k8s" } + plugins: + - kubernetes: + podSpec: + containers: + - name: container-0 + image: lmcache/ci-base:latest + imagePullPolicy: Never + resources: + limits: + nvidia.com/gpu: "1" + volumeMounts: + - { name: hf-cache, mountPath: /root/.cache/huggingface } + - { name: datasets, mountPath: /root/correctness } + volumes: + - { name: hf-cache, hostPath: { path: /data/huggingface, type: DirectoryOrCreate } } + - { name: datasets, hostPath: { path: /data/datasets, type: DirectoryOrCreate } } + artifact_paths: + - "build_*.log" diff --git a/.buildkite/k3_tests/correctness/run.sh b/.buildkite/k3_tests/correctness/run.sh new file mode 100755 index 0000000..eed3b5a --- /dev/null +++ b/.buildkite/k3_tests/correctness/run.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Correctness test entrypoint for K8s pods. +# Thin wrapper: sets up environment, then delegates to scripts/. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" + +cd "${REPO_ROOT}" + +# ── Environment setup ──────────────────────────────────────── +source .buildkite/k3_harness/setup-env.sh +uv pip install aiohttp tqdm pandas huggingface_hub + +# ── Ensure all scripts are executable ──────────────────────── +chmod +x "${SCRIPT_DIR}"/scripts/*.sh + +# ── Run the actual test logic ──────────────────────────────── +# Retry up to 3 times. vLLM's `VLLM_BATCH_INVARIANT=1` guarantee +# currently does not hold across process restarts in the vllm nightlies +# we pin to (reproduced locally: two back-to-back vanilla vllm-only runs +# produce ~55 different outputs out of 100). Phase 4's bitwise +# comparison between the base-vllm run and the vllm+LMCache run is +# bimodal (0 diff or ~50 diff) depending on which cuBLAS/kernel plans +# the two separate processes happen to pick. Retrying lets a lucky +# scheduling pass; a real LMCache regression will persistently fail. +MAX_ATTEMPTS="${CORRECTNESS_MAX_ATTEMPTS:-3}" +for attempt in $(seq 1 "${MAX_ATTEMPTS}"); do + echo "=== Correctness attempt ${attempt}/${MAX_ATTEMPTS} ===" + if bash "${SCRIPT_DIR}/scripts/run-correctness.sh"; then + exit 0 + fi + echo "[INFO] Attempt ${attempt} failed." +done +echo "[FAIL] Correctness persistently failed after ${MAX_ATTEMPTS} attempts." >&2 +exit 1 diff --git a/.buildkite/k3_tests/correctness/scripts/run-correctness.sh b/.buildkite/k3_tests/correctness/scripts/run-correctness.sh new file mode 100755 index 0000000..3f3d8c1 --- /dev/null +++ b/.buildkite/k3_tests/correctness/scripts/run-correctness.sh @@ -0,0 +1,221 @@ +#!/usr/bin/env bash +# Self-contained correctness test for K8s pods. +# Replaces the old vllm-correctness.sh which assumed a pre-existing venv +# and pick-free-gpu.sh. Here everything runs natively in the pod. +# +# Tests that LMCache produces identical output to base vLLM. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../../.." && pwd)" + +cd "${REPO_ROOT}" +source .buildkite/k3_tests/common_scripts/helpers.sh + +############### +# CONFIG # +############### + +BUILD_ID="${BUILDKITE_BUILD_ID:-local_$$}" +MODEL="Qwen/Qwen2.5-14B-Instruct" +WORK_LOG="/tmp/build_${BUILD_ID}_correctness.log" +VLLM_LOG="/tmp/build_${BUILD_ID}_vllm.log" +ARTIFACT="build_${BUILD_ID}.log" +SERVER_WAIT_TIMEOUT=180 +CORRECTNESS_DIR=".buildkite/correctness" +REQUEST_NUMBER=100 +MAX_CONCURRENCY=40 + +# K8s assigns GPUs via device plugin +export CUDA_VISIBLE_DEVICES="${CUDA_VISIBLE_DEVICES:-0}" + +############### +# DATA SETUP # +############### + +SHAREGPT_PATH="/root/correctness/.ShareGPT_V3_unfiltered_cleaned_split.json" +if [[ ! -f "$SHAREGPT_PATH" ]]; then + echo "[INFO] ShareGPT dataset not found, downloading..." + mkdir -p /root/correctness + wget -q \ + "https://huggingface.co/datasets/anon8231489123/ShareGPT_Vicuna_unfiltered/resolve/main/ShareGPT_V3_unfiltered_cleaned_split.json" \ + -O "$SHAREGPT_PATH" +fi + +if [[ ! -s "$SHAREGPT_PATH" ]]; then + echo "[ERROR] ShareGPT dataset file is empty: $SHAREGPT_PATH" + exit 1 +fi + +############### +# HELPERS # +############### + +VLLM_PID="" + +CI_CACHE_DIR="$PWD/.vllm_cache_${BUILD_ID}" +mkdir -p "$CI_CACHE_DIR" +export FLASHINFER_WORKSPACE_DIR="$CI_CACHE_DIR/flashinfer" + +collect_artifact() { + echo "[INFO] Collecting logs into ${ARTIFACT}" + cat "${WORK_LOG}" "${VLLM_LOG}" > "${ARTIFACT}" 2>/dev/null || true +} + +stop_vllm() { + if [[ -n "${VLLM_PID:-}" ]]; then + echo "[INFO] Stopping vLLM process (PID: ${VLLM_PID})" + kill "${VLLM_PID}" >/dev/null 2>&1 || true + wait "${VLLM_PID}" 2>/dev/null || true + VLLM_PID="" + sleep 5 + fi +} + +trap 'rc=$?; stop_vllm; collect_artifact; exit $rc' EXIT INT TERM + +exec > >(tee -a "${WORK_LOG}") 2>&1 + +echo "=== DIAGNOSTICS: GPU STATE before CI ===" +nvidia-smi +echo "[INFO] Using GPU(s): ${CUDA_VISIBLE_DEVICES}" + +echo "[INFO] Converting ShareGPT dataset to OpenAI format..." +python "${CORRECTNESS_DIR}/sharegpt2openai.py" -i "$SHAREGPT_PATH" -o "./shareGPT_dataset.json" + +############### +# PHASE 1 # +############### +# Base Server (Baseline) -- vLLM WITHOUT LMCache + +PORT=$(find_free_port 8000) +echo "[INFO] Starting BASE vLLM server on port ${PORT}..." + +VLLM_SERVER_DEV_MODE=1 \ +VLLM_BATCH_INVARIANT=1 \ +vllm serve "${MODEL}" \ + --port "${PORT}" \ + --trust-remote-code \ + --enforce-eager \ + --attention-backend FLASH_ATTN \ + --gpu-memory-utilization 0.8 \ + >"${VLLM_LOG}" 2>&1 & +VLLM_PID=$! + +echo "[INFO] Waiting for Base server readiness..." +if ! wait_for_server "$PORT" "$SERVER_WAIT_TIMEOUT"; then + echo "[ERROR] Base vLLM failed to start" + exit 1 +fi + +echo "[TEST] Running ShareGPT Batch (Base)..." +python "${CORRECTNESS_DIR}/async_request.py" \ + --model "${MODEL}" \ + --endpoint "http://localhost:${PORT}/v1/chat/completions" \ + --output-file "sharegpt_base.txt" \ + --dataset_file "./shareGPT_dataset.json" \ + --request-number "${REQUEST_NUMBER}" \ + --max-concurrency "${MAX_CONCURRENCY}" + +stop_vllm + +############### +# PHASE 2 # +############### +# LMCache Server -- vLLM WITH LMCache + +echo "[INFO] Preparing LMCache config (cpu.yaml)..." +cat < cpu.yaml +chunk_size: 256 +local_cpu: true +max_local_cpu_size: 50 +EOF + +PORT=$(find_free_port 8000) +echo "[INFO] Starting LMCACHE vLLM server on port ${PORT}..." + +LMCACHE_CONFIG_FILE=cpu.yaml \ +VLLM_SERVER_DEV_MODE=1 \ +VLLM_BATCH_INVARIANT=1 \ +vllm serve "${MODEL}" \ + --port "${PORT}" \ + --trust-remote-code \ + --enforce-eager \ + --attention-backend FLASH_ATTN \ + --gpu-memory-utilization 0.8 \ + --kv-transfer-config '{"kv_connector":"LMCacheConnectorV1","kv_role":"kv_both"}' \ + >>"${VLLM_LOG}" 2>&1 & +VLLM_PID=$! + +echo "[INFO] Waiting for LMCache server readiness..." +if ! wait_for_server "$PORT" "$SERVER_WAIT_TIMEOUT"; then + echo "[ERROR] LMCache vLLM failed to start" + exit 1 +fi + +echo "[TEST] Running ShareGPT Batch (LMCache)..." +python "${CORRECTNESS_DIR}/async_request.py" \ + --model "${MODEL}" \ + --endpoint "http://localhost:${PORT}/v1/chat/completions" \ + --output-file "sharegpt_lmcache.txt" \ + --dataset_file "./shareGPT_dataset.json" \ + --request-number "${REQUEST_NUMBER}" \ + --max-concurrency "${MAX_CONCURRENCY}" + +############### +# PHASE 3 # +############### +# Man bash correctness test (on the LMCache server) + +echo "[TEST] Running technical man bash correctness test..." +CONTEXT="$(man bash | sed 's/.\x08//g' | tr -s '[:space:]' ' ' | awk '{for(i=1;i<=NF;i++){printf "%s ",$i; if(++c==5000) exit}}')" +HALF_CONTEXT="$(man bash | sed 's/.\x08//g' | tr -s '[:space:]' ' ' | awk '{for(i=1;i<=NF;i++){printf "%s ",$i; if(++c==2500) exit}}')" + +send_completion() { + curl -s "http://localhost:${PORT}/v1/chat/completions" \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg model "${MODEL}" --arg content "$1" '{model: $model, temperature: 0, max_tokens: 100, messages: [{role:"user",content:$content}]}')" | + jq -r '.choices[0].message.content' +} + +echo "[STEP 1] Full context (LMCache)" +OUT1="$(send_completion "${CONTEXT}")" + +echo "[STEP 2] Reset prefix cache" +curl -s -X POST "http://localhost:${PORT}/reset_prefix_cache" >/dev/null + +echo "[STEP 3] Half context" +send_completion "${HALF_CONTEXT}" >/dev/null + +echo "[STEP 4] Full context again" +OUT2="$(send_completion "${CONTEXT}")" + +if [[ "${OUT1}" != "${OUT2}" ]]; then + echo "[FAIL] man bash output mismatch!" + exit 1 +fi + +############### +# PHASE 4 # +############### +# ShareGPT File Comparison + +echo "[INFO] Comparing ShareGPT results..." +COMPARE_OUT=$(python "${CORRECTNESS_DIR}/compare_files.py" --file1 "sharegpt_base.txt" --file2 "sharegpt_lmcache.txt") + +echo "--- COMPARISON RESULTS ---" +echo "${COMPARE_OUT}" +echo "--------------------------" + +DIFFERENT_COUNT=$(echo "${COMPARE_OUT}" | grep "^Different IDs:" | grep -oE '[0-9]+' | tail -1 || echo "0") +ONLY_FILE1_COUNT=$(echo "${COMPARE_OUT}" | awk '/^—— Only in File 1 ——$/,/^—— Only in File 2 ——$/ {print}' | grep -cE "^chatcmpl-" || echo "0") +ONLY_FILE2_COUNT=$(echo "${COMPARE_OUT}" | awk '/^—— Only in File 2 ——$/,/^$/ {print}' | grep -cE "^chatcmpl-" || echo "0") + +echo "[INFO] Analysis: Different=${DIFFERENT_COUNT}, Only in Base=${ONLY_FILE1_COUNT}, Only in LMCache=${ONLY_FILE2_COUNT}" + +if [[ "${DIFFERENT_COUNT}" -gt 0 ]] || [[ "${ONLY_FILE1_COUNT}" -gt 0 ]] || [[ "${ONLY_FILE2_COUNT}" -gt 0 ]]; then + echo "[FAIL] Inconsistency detected between Base and LMCache outputs." + exit 1 +fi + +echo "[PASS] All correctness tests passed -- identical output verified." diff --git a/.buildkite/k3_tests/integration/BK_WEB_SETUP.md b/.buildkite/k3_tests/integration/BK_WEB_SETUP.md new file mode 100644 index 0000000..8d2fcea --- /dev/null +++ b/.buildkite/k3_tests/integration/BK_WEB_SETUP.md @@ -0,0 +1,14 @@ +# Buildkite Web UI Setup: Integration Tests + +**Steps editor**: paste contents of `buildkite-pipeline.yml` (fill in `HF_TOKEN`). + +**GitHub trigger settings**: +- Filter: *(none — runs on every push/PR)* +- Skip queued / cancel running branch builds: Yes + +Lightweight (1 GPU) — good candidate for a required GitHub status check. + +> Builds whose only changes are docs/`*.md`/`LICENSE`/`.github/**` auto-pass +> via the [path filter](../README.md#path-based-skip-auto-pass-on-docs-only-changes). +> Changes under `.buildkite/` always run. Add `force-ci` label to the PR to +> bypass. \ No newline at end of file diff --git a/.buildkite/k3_tests/integration/buildkite-pipeline.yml b/.buildkite/k3_tests/integration/buildkite-pipeline.yml new file mode 100644 index 0000000..ffed4f5 --- /dev/null +++ b/.buildkite/k3_tests/integration/buildkite-pipeline.yml @@ -0,0 +1,11 @@ +# Paste this into the Buildkite pipeline's "Steps" editor. +# It uploads the real pipeline definition from the repo. +agents: + queue: "k8s" + +env: + HF_TOKEN: "" # set your HuggingFace token here (needed for gated model access) + +steps: + - label: ":pipeline: Upload pipeline" + command: bash .buildkite/k3_tests/common_scripts/upload-pipeline.sh .buildkite/k3_tests/integration/pipeline.yml diff --git a/.buildkite/k3_tests/integration/pipeline.yml b/.buildkite/k3_tests/integration/pipeline.yml new file mode 100644 index 0000000..9d6c114 --- /dev/null +++ b/.buildkite/k3_tests/integration/pipeline.yml @@ -0,0 +1,49 @@ +# Integration tests — starts vLLM with LMCache directly (no Docker), +# tests CPU and disk backends via the OpenAI API. + +steps: + - label: ":test_tube: Integration — CPU backend" + command: .buildkite/k3_tests/integration/run.sh cpu + timeout_in_minutes: 20 + agents: { queue: "k8s" } + plugins: + - kubernetes: + podSpec: + containers: + - name: container-0 + image: lmcache/ci-base:latest + imagePullPolicy: Never + resources: + limits: + nvidia.com/gpu: "1" + volumeMounts: + - { name: hf-cache, mountPath: /root/.cache/huggingface } + volumes: + - { name: hf-cache, hostPath: { path: /data/huggingface, type: DirectoryOrCreate } } + artifact_paths: + - "*.csv" + - "*.pdf" + - "*.log" + + - label: ":test_tube: Integration — Disk backend" + command: .buildkite/k3_tests/integration/run.sh disk + timeout_in_minutes: 20 + agents: { queue: "k8s" } + plugins: + - kubernetes: + podSpec: + containers: + - name: container-0 + image: lmcache/ci-base:latest + imagePullPolicy: Never + resources: + limits: + nvidia.com/gpu: "1" + volumeMounts: + - { name: hf-cache, mountPath: /root/.cache/huggingface } + volumes: + - { name: hf-cache, hostPath: { path: /data/huggingface, type: DirectoryOrCreate } } + artifact_paths: + - "*.csv" + - "*.pdf" + - "*.log" diff --git a/.buildkite/k3_tests/integration/run.sh b/.buildkite/k3_tests/integration/run.sh new file mode 100755 index 0000000..c2d23d6 --- /dev/null +++ b/.buildkite/k3_tests/integration/run.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Integration test entrypoint for K8s pods. +# Thin wrapper: sets up environment, then delegates to scripts/. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" + +cd "${REPO_ROOT}" + +# ── Environment setup ──────────────────────────────────────── +source .buildkite/k3_harness/setup-env.sh +uv pip install aiohttp + +# ── Ensure all scripts are executable ──────────────────────── +chmod +x "${SCRIPT_DIR}"/scripts/*.sh + +# ── Run the actual test logic ──────────────────────────────── +exec bash "${SCRIPT_DIR}/scripts/run-integration.sh" "$@" diff --git a/.buildkite/k3_tests/integration/scripts/run-integration.sh b/.buildkite/k3_tests/integration/scripts/run-integration.sh new file mode 100755 index 0000000..3d805a2 --- /dev/null +++ b/.buildkite/k3_tests/integration/scripts/run-integration.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# Integration test logic: starts vLLM with LMCache (CPU and disk backends), +# sends requests via the OpenAI API, and verifies responses. +# Runs directly in the K8s pod -- no Docker. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../../.." && pwd)" + +cd "${REPO_ROOT}" +source .buildkite/k3_tests/common_scripts/helpers.sh + +TEST_NAME="${1:-all}" # "cpu", "disk", or "all" + +MODEL="meta-llama/Llama-3.2-1B-Instruct" +PORT=8000 +VLLM_PID="" + +cleanup() { + if [[ -n "$VLLM_PID" ]]; then + echo "--- Shutting down vLLM (PID=$VLLM_PID)" + kill "$VLLM_PID" 2>/dev/null || true + wait "$VLLM_PID" 2>/dev/null || true + fi +} +trap cleanup EXIT + +send_request() { + local port=$1 prompt=$2 + curl -sf "http://localhost:${port}/v1/completions" \ + -H "Content-Type: application/json" \ + -d "{ + \"model\": \"${MODEL}\", + \"prompt\": \"${prompt}\", + \"max_tokens\": 64, + \"temperature\": 0 + }" +} + +run_test() { + local test_name=$1 + shift + local env_vars=("$@") + + echo "--- :test_tube: ${test_name}" + + # Start vLLM with LMCache + env "${env_vars[@]}" \ + vllm serve "$MODEL" \ + --port "$PORT" \ + --load-format dummy \ + --enforce-eager \ + --no-enable-prefix-caching \ + --gpu-memory-utilization 0.8 \ + --kv-transfer-config '{"kv_connector":"LMCacheConnectorV1","kv_role":"kv_both"}' \ + > "${test_name}-vllm.log" 2>&1 & + VLLM_PID=$! + + wait_for_server "$PORT" 300 "${test_name}-vllm.log" + + # Send requests -- same prompt twice to exercise the cache (second hit should be cached) + local prompt="The quick brown fox jumps over the lazy dog. Explain this sentence in detail." + + echo "Sending request 1 (cold)..." + local resp1 + resp1=$(send_request "$PORT" "$prompt") + echo "Response 1: $(echo "$resp1" | python3 -c 'import sys,json; r=json.load(sys.stdin); print(r["choices"][0]["text"][:100])' 2>/dev/null || echo "PARSE_ERROR")" + + echo "Sending request 2 (should hit cache)..." + local resp2 + resp2=$(send_request "$PORT" "$prompt") + echo "Response 2: $(echo "$resp2" | python3 -c 'import sys,json; r=json.load(sys.stdin); print(r["choices"][0]["text"][:100])' 2>/dev/null || echo "PARSE_ERROR")" + + # Verify we got valid responses + for resp_name in resp1 resp2; do + local resp_val="${!resp_name}" + if ! echo "$resp_val" | python3 -c 'import sys,json; r=json.load(sys.stdin); assert r["choices"][0]["text"]' 2>/dev/null; then + echo "FAIL: ${test_name} -- invalid response in ${resp_name}" + echo "$resp_val" + kill "$VLLM_PID" 2>/dev/null; wait "$VLLM_PID" 2>/dev/null || true; VLLM_PID="" + return 1 + fi + done + + echo "PASS: ${test_name}" + + # Shut down for next test + kill "$VLLM_PID" 2>/dev/null; wait "$VLLM_PID" 2>/dev/null || true; VLLM_PID="" + sleep 2 +} + +# ── Test 1: CPU backend ─────────────────────────────────────── +if [[ "$TEST_NAME" == "cpu" || "$TEST_NAME" == "all" ]]; then + run_test "local_cpu" \ + "LMCACHE_CHUNK_SIZE=256" \ + "LMCACHE_LOCAL_CPU=True" \ + "LMCACHE_MAX_LOCAL_CPU_SIZE=5" +fi + +# ── Test 2: Disk backend ───────────────────────────────────── +if [[ "$TEST_NAME" == "disk" || "$TEST_NAME" == "all" ]]; then + DISK_DIR=$(mktemp -d) + trap "cleanup; rm -rf $DISK_DIR" EXIT + + run_test "local_disk" \ + "LMCACHE_CHUNK_SIZE=256" \ + "LMCACHE_LOCAL_CPU=True" \ + "LMCACHE_MAX_LOCAL_CPU_SIZE=5" \ + "LMCACHE_LOCAL_DISK=file://${DISK_DIR}" +fi + +echo "--- :white_check_mark: Integration tests passed (${TEST_NAME})" diff --git a/.buildkite/k3_tests/multiprocess/BK_WEB_SETUP.md b/.buildkite/k3_tests/multiprocess/BK_WEB_SETUP.md new file mode 100644 index 0000000..eb0244d --- /dev/null +++ b/.buildkite/k3_tests/multiprocess/BK_WEB_SETUP.md @@ -0,0 +1,15 @@ +# Buildkite Web UI Setup: Multiprocess Tests + +**Steps editor**: paste contents of `buildkite-pipeline.yml` (fill in `HF_TOKEN`). + +**GitHub trigger settings**: +- Filter: `build.pull_request.labels includes "mp" || build.pull_request.labels includes "full" || build.branch == 'dev'` +- Rebuild on PR label change: Yes +- Skip queued / cancel running branch builds: Yes + +Heavy test (2 GPUs, Docker-in-Docker, ~45 min) — run on `"mp"`/`"full"` label or dev push, not every PR. + +> Builds whose only changes are docs/`*.md`/`LICENSE`/`.github/**` auto-pass +> via the [path filter](../README.md#path-based-skip-auto-pass-on-docs-only-changes). +> Changes under `.buildkite/` always run. Add `force-ci` label to the PR to +> bypass. diff --git a/.buildkite/k3_tests/multiprocess/buildkite-pipeline.yml b/.buildkite/k3_tests/multiprocess/buildkite-pipeline.yml new file mode 100644 index 0000000..0178e84 --- /dev/null +++ b/.buildkite/k3_tests/multiprocess/buildkite-pipeline.yml @@ -0,0 +1,11 @@ +# Paste this into the Buildkite pipeline's "Steps" editor. +# It uploads the real pipeline definition from the repo. +agents: + queue: "k8s" + +env: + HF_TOKEN: "" # set your HuggingFace token here (needed for gated model access) + +steps: + - label: ":pipeline: Upload pipeline" + command: bash .buildkite/k3_tests/common_scripts/upload-pipeline.sh .buildkite/k3_tests/multiprocess/pipeline.yml diff --git a/.buildkite/k3_tests/multiprocess/pipeline.yml b/.buildkite/k3_tests/multiprocess/pipeline.yml new file mode 100644 index 0000000..bf9936b --- /dev/null +++ b/.buildkite/k3_tests/multiprocess/pipeline.yml @@ -0,0 +1,279 @@ +# Multiprocess tests — one step per test, run in parallel. +# Each step gets its own K8s pod with the exact GPU count it needs. +# Tests are grouped by GPU requirement; the 2-GPU group is listed first so +# these heavier jobs get scheduled before 1-GPU jobs. +# 2-GPU: LMCache+vLLM on GPU 0, baseline on GPU 1 (or TP=2 across both). +# 1-GPU: LMCache+vLLM on GPU 0 only (no baseline server). + +# Retry policy for known-flaky long-running multiprocess steps. +# Note: exit_status "*" matches 1-255 only; -1 (agent lost) needs a separate rule. +x-flaky-retry: &flaky-retry + automatic: + - exit_status: -1 + limit: 2 + - exit_status: "*" + limit: 2 + +steps: + - group: ":compression: Multiprocess (2-GPU)" + if: build.env("VERIFY_AND_PIN_VLLM") !~ /true/ + steps: + - label: ":compression: vllm_bench" + command: .buildkite/k3_tests/multiprocess/run.sh vllm_bench + timeout_in_minutes: 30 + agents: { queue: "k8s" } + plugins: + - kubernetes: + podSpec: &pod-2gpu + containers: + - name: container-0 + image: lmcache/ci-base:latest + imagePullPolicy: Never + resources: { limits: { "nvidia.com/gpu": "2" } } + volumeMounts: &vol-mounts + - { name: hf-cache, mountPath: /root/.cache/huggingface } + volumes: &vols + - { name: hf-cache, hostPath: { path: /data/huggingface, type: DirectoryOrCreate } } + artifact_paths: ["*.log"] + + - label: ":compression: long_doc_qa" + command: .buildkite/k3_tests/multiprocess/run.sh long_doc_qa + timeout_in_minutes: 30 + retry: *flaky-retry + agents: { queue: "k8s" } + plugins: [{ kubernetes: { podSpec: *pod-2gpu } }] + artifact_paths: ["*.log"] + + - label: ":compression: long_doc_qa_l2" + command: .buildkite/k3_tests/multiprocess/run.sh long_doc_qa_l2 + timeout_in_minutes: 30 + retry: *flaky-retry + agents: { queue: "k8s" } + plugins: [{ kubernetes: { podSpec: *pod-2gpu } }] + artifact_paths: ["*.log"] + + - label: ":compression: deadlock" + command: .buildkite/k3_tests/multiprocess/run.sh deadlock + timeout_in_minutes: 30 + agents: { queue: "k8s" } + plugins: [{ kubernetes: { podSpec: *pod-2gpu } }] + artifact_paths: ["*.log"] + + # Two LMCache MP instances + two vLLM instances + one coordinator on a + # single 2-GPU pod (localhost). Verifies engine B serves a long prompt's + # KV from engine A over P2P. + - label: ":compression: p2p" + command: .buildkite/k3_tests/multiprocess/run.sh p2p + timeout_in_minutes: 30 + agents: { queue: "k8s" } + plugins: [{ kubernetes: { podSpec: *pod-2gpu } }] + artifact_paths: ["*.log"] + + - group: ":compression: Multiprocess (1-GPU)" + if: build.env("VERIFY_AND_PIN_VLLM") !~ /true/ + steps: + - label: ":compression: lm_eval" + command: .buildkite/k3_tests/multiprocess/run.sh lm_eval + timeout_in_minutes: 30 + retry: *flaky-retry + agents: { queue: "k8s" } + plugins: + - kubernetes: + podSpec: &pod-1gpu + containers: + - name: container-0 + image: lmcache/ci-base:latest + imagePullPolicy: Never + resources: { limits: { "nvidia.com/gpu": "1" } } + volumeMounts: *vol-mounts + volumes: *vols + artifact_paths: ["*.log"] + + # Engine-driven transfer mode correctness on GPU (SHM transport). + # Default CUDA dispatch is lmcache_driven (server-side IPC handle); + # this step forces engine_driven (worker-side gather/scatter via SHM) + # and verifies gsm8k outputs are still bit-exact across cache-population + # and cache-hit runs. + # + # To enable SHM transport: + # - L1_USE_LAZY=false disables lazy allocation so L1 is backed by a + # POSIX SHM segment that the worker can mmap. + # - CPU_BUFFER_SIZE=20 caps L1 at 20GB (needs /dev/shm >= 20GB). + # - dshm volume provides a 24Gi tmpfs mount at /dev/shm. + - label: ":compression: lm_eval_engine_driven" + command: .buildkite/k3_tests/multiprocess/run.sh lm_eval + timeout_in_minutes: 30 + env: + LMCACHE_MP_TRANSFER_MODE: "engine_driven" + L1_USE_LAZY: "false" + CPU_BUFFER_SIZE: "80" + agents: { queue: "k8s" } + plugins: + - kubernetes: + podSpec: + containers: + - name: container-0 + image: lmcache/ci-base:latest + imagePullPolicy: Never + resources: { limits: { "nvidia.com/gpu": "1" } } + volumeMounts: + - { name: hf-cache, mountPath: /root/.cache/huggingface } + - { name: dshm, mountPath: /dev/shm } + volumes: + - { name: hf-cache, hostPath: { path: /data/huggingface, type: DirectoryOrCreate } } + - { name: dshm, emptyDir: { medium: Memory, sizeLimit: 128Gi } } + artifact_paths: ["*.log"] + + # HMA correctness check on google/gemma-4-31B-it (a hybrid model whose KV + # cache groups get different block sizes). Runs gsm8k, resets vLLM's prefix + # cache (LMCache preserved), reruns served by LMCache, and asserts the two + # runs' scores match. Single GPU, no baseline. + - label: ":compression: hma_lm_eval_gemma4" + command: .buildkite/k3_tests/multiprocess/run.sh hma_lm_eval_gemma4 + timeout_in_minutes: 60 + retry: *flaky-retry + env: + MODEL: "google/gemma-4-31B-it" + # Allow a small score difference between the two runs. + SCORE_TOLERANCE: "0.03" + ATTENTION_BACKEND: "auto" + GPU_MEMORY_UTILIZATION: "0.85" + # 31B load + CUDA-graph capture is slow; raise the readiness timeout + # above the 300s default. + MAX_WAIT_SECONDS: "600" + # Cap samples and enlarge the CPU pool so the retrieve run stays + # cache-served (31B's per-token KV is large). + LIMIT: "100" + CPU_BUFFER_SIZE: "200" + agents: { queue: "k8s" } + plugins: [{ kubernetes: { podSpec: *pod-1gpu } }] + artifact_paths: ["*.log"] + + - label: ":compression: fault_tolerance" + command: .buildkite/k3_tests/multiprocess/run.sh fault_tolerance + timeout_in_minutes: 30 + agents: { queue: "k8s" } + plugins: [{ kubernetes: { podSpec: *pod-1gpu } }] + artifact_paths: ["*.log"] + + - label: ":compression: restart_recovery" + command: .buildkite/k3_tests/multiprocess/run.sh restart_recovery + timeout_in_minutes: 30 + agents: { queue: "k8s" } + plugins: [{ kubernetes: { podSpec: *pod-1gpu } }] + artifact_paths: ["*.log"] + + - label: ":compression: cache_stats" + command: .buildkite/k3_tests/multiprocess/run.sh cache_stats + timeout_in_minutes: 30 + agents: { queue: "k8s" } + plugins: [{ kubernetes: { podSpec: *pod-1gpu } }] + artifact_paths: ["*.log"] + + - label: ":compression: http_api" + command: .buildkite/k3_tests/multiprocess/run.sh http_api + timeout_in_minutes: 30 + agents: { queue: "k8s" } + plugins: [{ kubernetes: { podSpec: *pod-1gpu } }] + artifact_paths: ["*.log"] + + - label: ":compression: gds_smoke_test" + command: .buildkite/k3_tests/multiprocess/run.sh gds_smoke_test + timeout_in_minutes: 30 + agents: { queue: "k8s" } + plugins: + - kubernetes: + podSpec: + containers: + - name: container-0 + image: lmcache/ci-base:latest + imagePullPolicy: Never + resources: { limits: { "nvidia.com/gpu": "1" } } + volumeMounts: + - { name: hf-cache, mountPath: /root/.cache/huggingface } + - { name: scratch, mountPath: /scratch } + - { name: udev, mountPath: /run/udev, readOnly: true } + volumes: + - { name: hf-cache, hostPath: { path: /data/huggingface, type: DirectoryOrCreate } } + - { name: scratch, hostPath: { path: /data/gds-scratch, type: DirectoryOrCreate } } + - { name: udev, hostPath: { path: /run/udev, type: Directory } } + artifact_paths: ["*.log"] + + - group: ":compression: Multiprocess (CPU-only)" + if: build.env("VERIFY_AND_PIN_VLLM") !~ /true/ + steps: + # Note: LMCACHE_SHM_NAME uses `${VAR-default}` (not `:-`) in the + # runner script, so an empty string ("") is meaningful and differs + # from unset. `__default__` is the sentinel meaning "shm mode". + - label: ":compression: cpu_e2e_validation ({{matrix.mode}})" + # matrix.adjustments does not support `env:` in Buildkite. Instead + # we branch on {{matrix.mode}} inside the command to set the + # per-mode env before invoking the shared runner script. + command: | + case "{{matrix.mode}}" in + shm) ;; # rely on script defaults (engine-driven + shm transport) + pickle) export LMCACHE_SHM_NAME="" ;; + server_copy) export LMCACHE_MP_TRANSFER_MODE="lmcache_driven" ;; + esac + bash .github/scripts/run-cpu-e2e-validation.sh + matrix: + setup: + mode: + - "shm" + - "pickle" + - "server_copy" + timeout_in_minutes: 30 + agents: { queue: "k8s" } + plugins: + - kubernetes: + podSpec: + containers: + - name: container-0 + image: lmcache/ci-base:latest + imagePullPolicy: Never + resources: + requests: + cpu: "8" + memory: "256Gi" + limits: + cpu: "8" + memory: "256Gi" + volumeMounts: + - { name: hf-cache, mountPath: /root/.cache/huggingface } + - { name: dshm, mountPath: /dev/shm } + volumes: + - { name: hf-cache, hostPath: { path: /data/huggingface, type: DirectoryOrCreate } } + - { name: dshm, emptyDir: { medium: Memory, sizeLimit: 4Gi } } + + # Canary path for VERIFY_AND_PIN_VLLM=true: skips every real test group + # above (each group's `if` evaluates to false) and instead runs a single + # vllm_bench step that proves the freshly-installed vLLM nightly works + # with LMCache. On success, the verified vllm wheel version is pushed to + # the `buildkite_latest_tested_vllm` branch so downstream builds can pin + # to a known-good nightly instead of resolving "latest" again. + - label: ":pushpin: Verify & pin vLLM nightly" + if: build.env("VERIFY_AND_PIN_VLLM") =~ /true/ + command: | + .buildkite/k3_tests/multiprocess/run.sh vllm_bench + bash .buildkite/k3_tests/multiprocess/scripts/pin-tested-vllm.sh + timeout_in_minutes: 30 + agents: { queue: "k8s" } + plugins: + - kubernetes: + podSpec: + containers: + - name: container-0 + image: lmcache/ci-base:latest + imagePullPolicy: Never + resources: { limits: { "nvidia.com/gpu": "2" } } + env: + - name: GITHUB_TOKEN + valueFrom: + secretKeyRef: + name: buildkite-git-creds + key: GITHUB_TOKEN + volumeMounts: + - { name: hf-cache, mountPath: /root/.cache/huggingface } + volumes: + - { name: hf-cache, hostPath: { path: /data/huggingface, type: DirectoryOrCreate } } + artifact_paths: ["*.log"] diff --git a/.buildkite/k3_tests/multiprocess/run.sh b/.buildkite/k3_tests/multiprocess/run.sh new file mode 100755 index 0000000..88df1cd --- /dev/null +++ b/.buildkite/k3_tests/multiprocess/run.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Multiprocess test entrypoint for K8s pods. +# Usage: run.sh +# test_name: lm_eval | hma_lm_eval_gemma4 | vllm_bench | long_doc_qa +# | long_doc_qa_l2 | fault_tolerance | deadlock | restart_recovery +# | gds_smoke_test | p2p +# Thin wrapper: sets up environment, then delegates to scripts/. +# No Docker -- all processes run natively in the pod. +set -euo pipefail + +TEST_NAME="${1:?Usage: $0 (lm_eval|hma_lm_eval_gemma4|vllm_bench|long_doc_qa|long_doc_qa_l2|fault_tolerance|deadlock|restart_recovery|cache_stats|http_api|p2p)}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" + +cd "${REPO_ROOT}" + +# ── Environment setup ──────────────────────────────────────── +source .buildkite/k3_harness/setup-env.sh + +# Install test extras (lm-eval for eval workload, openai/pandas/matplotlib for benchmarks) +uv pip install 'lm-eval[api]' openai pandas matplotlib + +# ── Ensure all scripts are executable ──────────────────────── +chmod +x "${SCRIPT_DIR}"/scripts/*.sh + +# ── Run the actual test logic ──────────────────────────────── +exec bash "${SCRIPT_DIR}/scripts/run-single-test.sh" "$TEST_NAME" diff --git a/.buildkite/k3_tests/multiprocess/scripts/cleanup.sh b/.buildkite/k3_tests/multiprocess/scripts/cleanup.sh new file mode 100755 index 0000000..2a61601 --- /dev/null +++ b/.buildkite/k3_tests/multiprocess/scripts/cleanup.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Cleanup background processes launched for multiprocessing tests. +# This script should always be called, even on failure. + +BUILD_ID="${BUILD_ID:-local_$$}" +PID_FILE="/tmp/lmcache_mp_pids_${BUILD_ID}" + +echo "=== Cleaning up background processes ===" + +if [[ -f "$PID_FILE" ]]; then + while IFS= read -r pid; do + if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then + echo "Killing PID $pid" + # Capture a snippet of the process log before killing + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + echo "PID $pid stopped" + fi + done < "$PID_FILE" + rm -f "$PID_FILE" +else + echo "No PID file found at $PID_FILE" +fi + +# Also kill any stray vllm/lmcache processes from this build +# (safety net in case PIDs weren't recorded) +for port in "${VLLM_PORT:-8000}" "${VLLM_BASELINE_PORT:-9000}" "${LMCACHE_PORT:-6555}"; do + fuser -k "${port}/tcp" 2>/dev/null || true +done + +# Remove the GDS slab scratch dir (only set for gds_* tests). It lives on the +# /scratch hostPath (host-local NVMe), so it persists past the pod and the +# preallocated slab is large -- drop it now that the server is stopped. +if [[ -n "${GDS_L1_PATH:-}" ]]; then + echo "Removing GDS slab dir: $GDS_L1_PATH" + rm -rf "${GDS_L1_PATH}" 2>/dev/null || true +fi + +echo "=== Cleanup complete ===" + +# Copy server logs to the workspace so Buildkite can collect them as artifacts +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../../.." && pwd)" +cp /tmp/build_${BUILD_ID}_*.log "${REPO_ROOT}/" 2>/dev/null || true + +# Wait for GPU memory to be fully released +echo "Waiting 5 seconds for GPU memory to be released..." +sleep 5 diff --git a/.buildkite/k3_tests/multiprocess/scripts/launch-processes.sh b/.buildkite/k3_tests/multiprocess/scripts/launch-processes.sh new file mode 100755 index 0000000..4e40052 --- /dev/null +++ b/.buildkite/k3_tests/multiprocess/scripts/launch-processes.sh @@ -0,0 +1,199 @@ +#!/usr/bin/env bash +# Launch LMCache MP server, vLLM with LMCache, and vLLM baseline +# as native background processes (no Docker). +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../../.." && pwd)" + +source "${REPO_ROOT}/.buildkite/k3_tests/common_scripts/helpers.sh" + +# Configuration (inherited from run-mp-test.sh) +LMCACHE_PORT="${LMCACHE_PORT:-6555}" +vllm_port="${VLLM_PORT:-8000}" +vllm_baseline_port="${VLLM_BASELINE_PORT:-9000}" +CPU_BUFFER_SIZE="${CPU_BUFFER_SIZE:-80}" +MAX_WORKERS="${MAX_WORKERS:-4}" +MODEL="${MODEL:-Qwen/Qwen3-14B}" +BUILD_ID="${BUILD_ID:-local_$$}" + +# K8s assigns exactly 2 GPUs as devices 0 and 1 (overridable for local runs). +GPU_FOR_VLLM="${GPU_FOR_VLLM:-0}" +GPU_FOR_BASELINE="${GPU_FOR_BASELINE:-1}" +echo "Using GPU $GPU_FOR_VLLM for vLLM with LMCache" +echo "Using GPU $GPU_FOR_BASELINE for vLLM baseline" + +# Check GPU memory and set gpu-memory-utilization for very large GPUs. +# Without this, vLLM allocates so much KV cache that APC covers all prefixes +# and LMCache's cache path is never exercised, making the test pass vacuously. +GPU_MEMORY_UTIL_ARG="" +GPU_MEMORY_MB=$(nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits -i "${GPU_FOR_VLLM}" | tr -d ' ') +GPU_MEMORY_GB=$((GPU_MEMORY_MB / 1024)) +echo "Detected GPU memory: ${GPU_MEMORY_GB}GB (${GPU_MEMORY_MB}MB)" + +if [ -n "${GPU_MEMORY_UTILIZATION:-}" ]; then + # Explicit override (e.g. large models like gemma-4-31B whose ~63GB of + # weights alone exceed the default 0.5 fraction and would fail to load). + echo "Using configured --gpu-memory-utilization ${GPU_MEMORY_UTILIZATION}" + GPU_MEMORY_UTIL_ARG="--gpu-memory-utilization ${GPU_MEMORY_UTILIZATION}" +elif [ "$GPU_MEMORY_GB" -gt 90 ]; then + echo "GPU memory > 90GB, adding --gpu-memory-utilization 0.5" + GPU_MEMORY_UTIL_ARG="--gpu-memory-utilization 0.5" +fi + +# Attention backend for both vLLM servers. Defaults to FLASH_ATTN (what the +# batch-invariant lm_eval needs). Models with heterogeneous head dimensions +# (e.g. gemma-4) must NOT pin FLASH_ATTN -- set ATTENTION_BACKEND=auto so vLLM +# selects the backend itself (gemma-4 auto-forces TRITON_ATTN). +ATTENTION_BACKEND="${ATTENTION_BACKEND:-FLASH_ATTN}" +ATTENTION_BACKEND_ARG="" +if [ -n "$ATTENTION_BACKEND" ] && [ "$ATTENTION_BACKEND" != "auto" ]; then + ATTENTION_BACKEND_ARG="--attention-backend $ATTENTION_BACKEND" +fi + +# Optionally run vLLM in eager mode (skip CUDA graph capture) for both servers. +# Off by default: verified to break the bit-exact run1 == run2 check in the +# determinism tests (lm_eval) -- eager changes the kernel path enough to diverge +# across the cold/warm batch difference even under VLLM_BATCH_INVARIANT. Enable +# (ENFORCE_EAGER=1) only for large models whose CUDA-graph capture would +# otherwise time out at launch (those tests use a tolerance, not bit-exactness). +ENFORCE_EAGER_ARG="" +if [ "${ENFORCE_EAGER:-0}" = "1" ] || [ "${ENFORCE_EAGER:-0}" = "true" ]; then + ENFORCE_EAGER_ARG="--enforce-eager" +fi + +# Pin max model length for both servers; defaults to "auto" (vLLM derives the +# largest length that fits KV memory). Verified not to affect the bit-exact +# determinism tests. Override via MAX_MODEL_LEN if a model needs a fixed length. +MAX_MODEL_LEN="${MAX_MODEL_LEN:-auto}" +MAX_MODEL_LEN_ARG="--max-model-len ${MAX_MODEL_LEN}" + +# LMCache server chunk size in tokens. Empty -> server default. +CHUNK_SIZE_ARG="" +if [ -n "${CHUNK_SIZE:-}" ]; then + CHUNK_SIZE_ARG="--chunk-size ${CHUNK_SIZE}" +fi + +# vLLM batch-invariant mode. On by default; GDN/Mamba backends do not support it. +BATCH_INVARIANT="${BATCH_INVARIANT:-1}" + +# Mamba KV cache mode + prefix caching, set only for hybrid Mamba models. +MAMBA_ARGS="" +if [ -n "${MAMBA_CACHE_MODE:-}" ]; then + MAMBA_ARGS="--mamba-cache-mode ${MAMBA_CACHE_MODE} --enable-prefix-caching" +fi + +# Max tokens per scheduler step. Empty -> vLLM default. +MAX_NUM_BATCHED_TOKENS_ARG="" +if [ -n "${MAX_NUM_BATCHED_TOKENS:-}" ]; then + MAX_NUM_BATCHED_TOKENS_ARG="--max-num-batched-tokens ${MAX_NUM_BATCHED_TOKENS}" +fi + +# L1 lazy allocation mode. Default is lazy (--l1-use-lazy). Set L1_USE_LAZY=false +# to disable lazy allocation, which enables POSIX SHM-backed L1 pool for the +# engine_driven SHM transfer path. When lazy is enabled (default), the SHM pool +# is disabled and engine_driven falls back to pickle transport. +L1_LAZY_ARG="" +if [ "${L1_USE_LAZY:-true}" = "false" ]; then + L1_LAZY_ARG="--no-l1-use-lazy" + echo "L1 lazy allocation disabled (SHM transport enabled)" +fi + +# Store PIDs in a file so cleanup.sh can find them +PID_FILE="/tmp/lmcache_mp_pids_${BUILD_ID}" +> "$PID_FILE" + +# ── 1. LMCache Multiprocess Server ────────────────────────── +echo "=== Launching LMCache MP server ===" +echo "Port: $LMCACHE_PORT" + +# Optional GDS L1 slab tier (gds_* tests). When GDS_L1_PATH is set, the L1 +# medium becomes an NVMe slab accessed via cuFile DMA instead of pinned DRAM; +# --l1-size-gb then sizes the slab. The path must be on a GDS-capable +# filesystem (local NVMe), provided by the /scratch hostPath mount. +GDS_L1_ARG="" +if [ -n "${GDS_L1_PATH:-}" ]; then + echo "GDS L1 tier enabled; slab directory: $GDS_L1_PATH" + GDS_L1_ARG="--gds-l1-path ${GDS_L1_PATH}" +fi + +CUDA_VISIBLE_DEVICES="${GPU_FOR_VLLM}" \ +lmcache server \ + --l1-size-gb "$CPU_BUFFER_SIZE" \ + --eviction-policy LRU \ + --max-workers "$MAX_WORKERS" \ + $CHUNK_SIZE_ARG \ + --port "$LMCACHE_PORT" \ + ${GDS_L1_ARG} \ + ${L1_LAZY_ARG} \ + > "/tmp/build_${BUILD_ID}_lmcache.log" 2>&1 & + +LMCACHE_PID=$! +echo "$LMCACHE_PID" >> "$PID_FILE" +echo "LMCache MP server started (PID=$LMCACHE_PID)" + +# Wait for LMCache to initialize +echo "Waiting for LMCache to initialize..." +sleep 10 + +# Unset VLLM_PORT so vLLM's internal get_open_port() picks a random +# ephemeral port for torch.distributed instead of trying serving_port+1. +# Without this, both instances fight over the same internal port. +unset VLLM_PORT + +# ── 2. vLLM with LMCache ──────────────────────────────────── +echo "=== Launching vLLM with LMCache ===" +echo "Model: $MODEL" +echo "Port: $vllm_port" + +CUDA_VISIBLE_DEVICES="${GPU_FOR_VLLM}" \ +VLLM_ENABLE_V1_MULTIPROCESSING=0 \ +VLLM_SERVER_DEV_MODE=1 \ +VLLM_BATCH_INVARIANT=${BATCH_INVARIANT} \ +PYTHONHASHSEED=0 \ +vllm serve "$MODEL" \ + --kv-transfer-config "{\"kv_connector\":\"LMCacheMPConnector\", \"kv_role\":\"kv_both\", \"kv_load_failure_policy\": \"recompute\", \"kv_connector_extra_config\": {\"lmcache.mp.port\": $LMCACHE_PORT, \"lmcache.mp.mq_timeout\": 10}}" \ + $ATTENTION_BACKEND_ARG \ + --port "$vllm_port" \ + --no-async-scheduling \ + $MAX_MODEL_LEN_ARG \ + $ENFORCE_EAGER_ARG \ + $GPU_MEMORY_UTIL_ARG \ + $MAMBA_ARGS \ + $MAX_NUM_BATCHED_TOKENS_ARG \ + > "/tmp/build_${BUILD_ID}_vllm.log" 2>&1 & + +VLLM_PID=$! +echo "$VLLM_PID" >> "$PID_FILE" +echo "vLLM with LMCache started (PID=$VLLM_PID)" + +# ── 3. vLLM Baseline (without LMCache) ────────────────────── +# Only launched for tests that compare against a baseline (2-GPU pods). +# Single-GPU tests set LAUNCH_BASELINE=false and skip this entirely. +if [[ "${LAUNCH_BASELINE:-true}" == "true" ]]; then + echo "=== Launching vLLM baseline ===" + echo "Port: $vllm_baseline_port" + + CUDA_VISIBLE_DEVICES="${GPU_FOR_BASELINE}" \ + VLLM_ENABLE_V1_MULTIPROCESSING=0 \ + VLLM_SERVER_DEV_MODE=1 \ + VLLM_BATCH_INVARIANT=1 \ + PYTHONHASHSEED=0 \ + vllm serve "$MODEL" \ + $ATTENTION_BACKEND_ARG \ + --port "$vllm_baseline_port" \ + --no-async-scheduling \ + $MAX_MODEL_LEN_ARG \ + $ENFORCE_EAGER_ARG \ + $GPU_MEMORY_UTIL_ARG \ + > "/tmp/build_${BUILD_ID}_vllm_baseline.log" 2>&1 & + + VLLM_BASELINE_PID=$! + echo "$VLLM_BASELINE_PID" >> "$PID_FILE" + echo "vLLM baseline started (PID=$VLLM_BASELINE_PID)" +else + echo "=== Skipping vLLM baseline (LAUNCH_BASELINE=false, 1-GPU test) ===" +fi + +echo "=== All processes launched ===" +echo "PIDs: LMCache=$LMCACHE_PID, vLLM=$VLLM_PID, Baseline=${VLLM_BASELINE_PID:-skipped}" diff --git a/.buildkite/k3_tests/multiprocess/scripts/pin-tested-vllm.sh b/.buildkite/k3_tests/multiprocess/scripts/pin-tested-vllm.sh new file mode 100644 index 0000000..9a5c3c8 --- /dev/null +++ b/.buildkite/k3_tests/multiprocess/scripts/pin-tested-vllm.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env bash +# Pin the currently-installed vLLM nightly to the +# `buildkite_latest_tested_vllm` branch. +# +# Runs ONLY when the canary vllm_bench step has succeeded and +# VERIFY_AND_PIN_VLLM=true. Records the just-verified vllm wheel version so +# downstream builds can install the same version deterministically instead +# of resolving "latest nightly" again. +# +# Two files are maintained on the dedicated branch: +# tested_vllm_versions.csv -- JSON Lines, append-only history. Each line +# is one self-contained record. +# latest_tested_vllm.txt -- Plain text, single line: the most recent +# verified version. Overwritten every run. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)" +cd "${REPO_ROOT}" + +export GIT_TERMINAL_PROMPT=0 + +# ── Resolve the version that's actually installed in this pod ─────────── +VLLM_VERSION="$(python -c 'import vllm; print(vllm.__version__)')" +if [[ -z "${VLLM_VERSION}" ]]; then + echo "[ERROR] could not read vllm.__version__ from the live env" >&2 + exit 1 +fi +echo "Verified vLLM version: ${VLLM_VERSION}" + +# ── Resolve commit SHAs so consumers don't need to call any external API ─ +# The PEP 440 local version after `+g` is the short commit SHA, e.g. +# 0.23.1rc1.dev508+gc6dd32a81 -> c6dd32a81. Expand it to the full 40-char +# SHA via the public GitHub commits API; we already have GITHUB_TOKEN in +# the env (5000 req/h). The full SHA gives us the permanent +# wheels.vllm.ai/// archive URL, which keeps working even +# after the rolling nightly index has dropped the wheel. +VLLM_SHORT_SHA="${VLLM_VERSION##*+g}" +if [[ "${VLLM_SHORT_SHA}" == "${VLLM_VERSION}" \ + || ! "${VLLM_SHORT_SHA}" =~ ^[0-9a-f]+$ ]]; then + VLLM_SHORT_SHA="" +fi + +VLLM_FULL_SHA="" +if [[ -n "${VLLM_SHORT_SHA}" ]]; then + gh_auth_args=() + if [[ -n "${GITHUB_TOKEN:-}" ]]; then + gh_auth_args=(-H "Authorization: Bearer ${GITHUB_TOKEN}") + fi + for attempt in 1 2 3; do + VLLM_FULL_SHA="$(curl -fsSL --connect-timeout 5 --max-time 10 \ + -H "Accept: application/vnd.github+json" \ + "${gh_auth_args[@]+"${gh_auth_args[@]}"}" \ + "https://api.github.com/repos/vllm-project/vllm/commits/${VLLM_SHORT_SHA}" \ + 2>/dev/null \ + | jq -r '.sha // empty')" || true + if [[ "${VLLM_FULL_SHA}" =~ ^[0-9a-f]{40}$ ]]; then + break + fi + VLLM_FULL_SHA="" + echo "[INFO] GitHub commit lookup attempt ${attempt} for" \ + "${VLLM_SHORT_SHA} returned no SHA; retrying..." >&2 + sleep 2 + done +fi + +if [[ -n "${VLLM_FULL_SHA}" ]]; then + VLLM_ARCHIVE_INDEX="https://wheels.vllm.ai/${VLLM_FULL_SHA}/cu130" + echo "Resolved full SHA: ${VLLM_FULL_SHA}" + echo "Archive index: ${VLLM_ARCHIVE_INDEX}" +else + VLLM_ARCHIVE_INDEX="" + echo "[WARN] could not resolve full SHA for short SHA" \ + "'${VLLM_SHORT_SHA:-}'; archive_index_url will be empty" \ + "and consumers will fall back to live API lookup" >&2 +fi + +CI_REPO="LMCache/LMCache" +CI_BRANCH="buildkite_latest_tested_vllm" + +if [[ -n "${GITHUB_TOKEN:-}" ]]; then + CI_REPO_URL="https://x-access-token:${GITHUB_TOKEN}@github.com/${CI_REPO}.git" +else + echo "[WARN] GITHUB_TOKEN not set — push will likely fail" >&2 + CI_REPO_URL="https://github.com/${CI_REPO}.git" +fi + +WORK_DIR="/tmp/pin_vllm_$$" +trap 'rm -rf "${WORK_DIR}"' EXIT + +echo "--- Preparing ${CI_BRANCH} branch from ${CI_REPO}" +if ! git clone --depth=1 --branch "${CI_BRANCH}" "${CI_REPO_URL}" \ + "${WORK_DIR}" 2>/dev/null; then + # Branch does not exist yet -- create an orphan with no parent history. + rm -rf "${WORK_DIR}" + mkdir -p "${WORK_DIR}" + git -C "${WORK_DIR}" init -q + git -C "${WORK_DIR}" remote add origin "${CI_REPO_URL}" + git -C "${WORK_DIR}" checkout --orphan "${CI_BRANCH}" + # Drop anything that the orphan checkout might have staged from HEAD. + git -C "${WORK_DIR}" rm -rf --cached . >/dev/null 2>&1 || true + find "${WORK_DIR}" -mindepth 1 -maxdepth 1 ! -name ".git" \ + -exec rm -rf {} + +fi + +# ── Update files ──────────────────────────────────────────────────────── +HISTORY_FILE="${WORK_DIR}/tested_vllm_versions.csv" +LATEST_FILE="${WORK_DIR}/latest_tested_vllm.txt" + +TIMESTAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +BUILD_URL="${BUILDKITE_BUILD_URL:-}" +BUILD_NUMBER="${BUILDKITE_BUILD_NUMBER:-}" +COMMIT_SHA="${BUILDKITE_COMMIT:-}" + +# Append-only history (JSON Lines). Built via python so quoting is safe. +# Use a quoted heredoc (<<'PY') to disable Bash expansion inside the script, +# and pass variables via the environment to avoid syntax errors from special +# characters in values like BUILD_URL. +TIMESTAMP="${TIMESTAMP}" \ +VLLM_VERSION="${VLLM_VERSION}" \ +VLLM_SHORT_SHA="${VLLM_SHORT_SHA}" \ +VLLM_FULL_SHA="${VLLM_FULL_SHA}" \ +VLLM_ARCHIVE_INDEX="${VLLM_ARCHIVE_INDEX}" \ +BUILD_NUMBER="${BUILD_NUMBER}" \ +BUILD_URL="${BUILD_URL}" \ +COMMIT_SHA="${COMMIT_SHA}" \ +python - "$HISTORY_FILE" <<'PY' +import json, os, sys +path = sys.argv[1] +record = { + "timestamp": os.environ.get("TIMESTAMP", ""), + "vllm_version": os.environ.get("VLLM_VERSION", ""), + "vllm_short_sha": os.environ.get("VLLM_SHORT_SHA", ""), + "vllm_full_sha": os.environ.get("VLLM_FULL_SHA", ""), + "archive_index_url": os.environ.get("VLLM_ARCHIVE_INDEX", ""), + "build_number": os.environ.get("BUILD_NUMBER", ""), + "build_url": os.environ.get("BUILD_URL", ""), + "commit": os.environ.get("COMMIT_SHA", ""), +} +with open(path, "a", encoding="utf-8") as f: + f.write(json.dumps(record) + "\n") +PY + +# Latest pointer. The first line is the bare version so older consumers +# that just `head -n1` keep working; the trailing key=value lines let new +# consumers skip the live GitHub API lookup entirely. +{ + printf '%s\n' "${VLLM_VERSION}" + printf 'short_sha=%s\n' "${VLLM_SHORT_SHA}" + printf 'full_sha=%s\n' "${VLLM_FULL_SHA}" + printf 'archive_index_url=%s\n' "${VLLM_ARCHIVE_INDEX}" +} > "${LATEST_FILE}" + +# ── Commit + push ─────────────────────────────────────────────────────── +cd "${WORK_DIR}" +git add tested_vllm_versions.csv latest_tested_vllm.txt + +if git diff --cached --quiet 2>/dev/null; then + echo "No changes to commit (version unchanged?)." + exit 0 +fi + +git -c user.email="ci@lmcache.ai" -c user.name="LMCache CI" \ + commit -m "Pin verified vLLM nightly: ${VLLM_VERSION}" + +echo "--- Pushing to ${CI_REPO} ${CI_BRANCH}" +if ! git push origin "HEAD:${CI_BRANCH}" 2>/dev/null; then + echo "[WARN] Normal push failed, force-pushing..." >&2 + git push origin "+HEAD:${CI_BRANCH}" 2>/dev/null || { + echo "[ERROR] Failed to push to ${CI_REPO} ${CI_BRANCH}" >&2 + exit 1 + } +fi + +echo "--- Pinned vLLM ${VLLM_VERSION} successfully" +git log --oneline -1 diff --git a/.buildkite/k3_tests/multiprocess/scripts/run-cache-stats.sh b/.buildkite/k3_tests/multiprocess/scripts/run-cache-stats.sh new file mode 100755 index 0000000..5129767 --- /dev/null +++ b/.buildkite/k3_tests/multiprocess/scripts/run-cache-stats.sh @@ -0,0 +1,214 @@ +#!/usr/bin/env bash +# Test that kv_transfer_params / cached_token_stats flows end-to-end +# through the OpenAI-compatible API when LMCache MP mode is active. +# +# Flow: +# 1. Send a long prompt (cold — populates LMCache, no cache hit) +# 2. Send the same prompt again (warm — should hit LMCache) +# 3. Verify the response contains cached_token_stats with expected values +set -e +set -o pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../../.." && pwd)" + +source "${REPO_ROOT}/.buildkite/k3_tests/common_scripts/helpers.sh" + +# Configuration (inherited from run-single-test.sh) +VLLM_PORT="${VLLM_PORT:-8000}" +MODEL="${MODEL:-Qwen/Qwen3-14B}" +BUILD_ID="${BUILD_ID:-local_$$}" +RESULTS_DIR="${RESULTS_DIR:-/tmp/lmcache_ci_results_${BUILD_ID}}" + +STATS_DIR="$RESULTS_DIR/cache_stats" +mkdir -p "$STATS_DIR" + +echo "=== Cache Stats Reporting Test ===" +echo "Model: $MODEL" +echo "vLLM Port: $VLLM_PORT" +echo "Results dir: $STATS_DIR" +echo "" + +# Build a prompt long enough to span multiple LMCache chunks (default +# chunk_size=256 tokens). Repeating a sentence gives us ~600+ tokens. +LONG_CONTENT="Explain the history of computer science in great detail. $(printf 'The Turing machine is a fundamental concept in theoretical computer science that defines an abstract machine capable of manipulating symbols on a strip of tape according to a table of rules. %.0s' {1..20})" + +send_request() { + local label="$1" + local output_file="$2" + + echo "--- Sending request: $label ---" + local http_code + http_code=$(curl -s -o "$output_file" -w "%{http_code}" \ + -X POST "http://localhost:${VLLM_PORT}/v1/chat/completions" \ + -H "Content-Type: application/json" \ + -d "{ + \"model\": \"${MODEL}\", + \"messages\": [{\"role\": \"user\", \"content\": $(python3 -c "import json; print(json.dumps('$LONG_CONTENT'))")}], + \"max_tokens\": 1, + \"kv_transfer_params\": {\"cached_token_stats\": true} + }") + + if [ "$http_code" -ne 200 ]; then + echo "FAIL: $label returned HTTP $http_code" + cat "$output_file" + return 1 + fi + echo "$label: HTTP 200 OK" +} + +validate_stats_present() { + local label="$1" + local response_file="$2" + + python3 -c " +import json, sys + +with open('$response_file') as f: + data = json.load(f) + +kv_params = data.get('kv_transfer_params') +if kv_params is None: + print('FAIL: $label — kv_transfer_params is missing from response') + sys.exit(1) + +stats = kv_params.get('cached_token_stats') +if stats is None: + print('FAIL: $label — cached_token_stats is missing from kv_transfer_params') + print(f' kv_transfer_params = {kv_params}') + sys.exit(1) + +required_keys = [ + 'num_vllm_cached_tokens', + 'num_lmcache_cached_tokens', + 'num_lmcache_extra_cached_tokens', +] +missing = [k for k in required_keys if k not in stats] +if missing: + print(f'FAIL: $label — missing keys in cached_token_stats: {missing}') + print(f' cached_token_stats = {stats}') + sys.exit(1) + +for k in required_keys: + v = stats[k] + if not isinstance(v, int) or v < 0: + print(f'FAIL: $label — {k} should be a non-negative integer, got {v!r}') + sys.exit(1) + +print(f'PASS: $label — cached_token_stats present with all required keys') +print(f' num_vllm_cached_tokens: {stats[\"num_vllm_cached_tokens\"]}') +print(f' num_lmcache_cached_tokens: {stats[\"num_lmcache_cached_tokens\"]}') +print(f' num_lmcache_extra_cached_tokens: {stats[\"num_lmcache_extra_cached_tokens\"]}') +" +} + +validate_warm_hit() { + local cold_file="$1" + local warm_file="$2" + + python3 -c " +import json, sys + +with open('$cold_file') as f: + cold = json.load(f) +with open('$warm_file') as f: + warm = json.load(f) + +cold_stats = cold['kv_transfer_params']['cached_token_stats'] +warm_stats = warm['kv_transfer_params']['cached_token_stats'] + +cold_lmcache = cold_stats['num_lmcache_cached_tokens'] +warm_lmcache = warm_stats['num_lmcache_cached_tokens'] + +print(f'Cold request — num_lmcache_cached_tokens: {cold_lmcache}') +print(f'Warm request — num_lmcache_cached_tokens: {warm_lmcache}') + +if warm_lmcache <= cold_lmcache: + print(f'FAIL: warm request should have more LMCache hits than cold request') + print(f' cold={cold_lmcache}, warm={warm_lmcache}') + sys.exit(1) + +if warm_lmcache == 0: + print('FAIL: warm request has 0 LMCache cached tokens (cache not populated?)') + sys.exit(1) + +print(f'PASS: warm request has more LMCache hits ({warm_lmcache} > {cold_lmcache})') +" +} + +# ── Step 1: Cold request (populates LMCache) ────────────────── +echo "============================================" +echo "=== Step 1: Cold request ===" +echo "============================================" +if ! send_request "Cold" "$STATS_DIR/cold_response.json"; then + exit 1 +fi +if ! validate_stats_present "Cold" "$STATS_DIR/cold_response.json"; then + exit 1 +fi +echo "" + +# Small delay to let the store operation complete in LMCache +sleep 2 + +# ── Step 2: Warm request (same prompt, should hit cache) ────── +echo "============================================" +echo "=== Step 2: Warm request ===" +echo "============================================" +if ! send_request "Warm" "$STATS_DIR/warm_response.json"; then + exit 1 +fi +if ! validate_stats_present "Warm" "$STATS_DIR/warm_response.json"; then + exit 1 +fi +echo "" + +# ── Step 3: Validate cache hit improvement ──────────────────── +echo "============================================" +echo "=== Step 3: Validate cache hit ===" +echo "============================================" +if ! validate_warm_hit "$STATS_DIR/cold_response.json" "$STATS_DIR/warm_response.json"; then + exit 1 +fi +echo "" + +# ── Step 4: Verify opt-in behavior ──────────────────────────── +# Request WITHOUT kv_transfer_params should NOT have stats in response. +echo "============================================" +echo "=== Step 4: Verify opt-in (no stats without opt-in) ===" +echo "============================================" + +echo "--- Sending request without kv_transfer_params ---" +http_code=$(curl -s -o "$STATS_DIR/no_opt_in_response.json" -w "%{http_code}" \ + -X POST "http://localhost:${VLLM_PORT}/v1/chat/completions" \ + -H "Content-Type: application/json" \ + -d "{ + \"model\": \"${MODEL}\", + \"messages\": [{\"role\": \"user\", \"content\": \"Hello, how are you?\"}], + \"max_tokens\": 1 + }") + +if [ "$http_code" -ne 200 ]; then + echo "FAIL: no-opt-in request returned HTTP $http_code" + exit 1 +fi + +python3 -c " +import json, sys + +with open('$STATS_DIR/no_opt_in_response.json') as f: + data = json.load(f) + +kv_params = data.get('kv_transfer_params') +if kv_params is not None: + print(f'FAIL: kv_transfer_params should be absent without opt-in, got {kv_params}') + sys.exit(1) + +print('PASS: kv_transfer_params correctly absent when not opted in') +" +echo "" + +# ── Summary ─────────────────────────────────────────────────── +echo "============================================" +echo "=== Cache Stats Reporting Test PASSED ===" +echo "============================================" diff --git a/.buildkite/k3_tests/multiprocess/scripts/run-deadlock.sh b/.buildkite/k3_tests/multiprocess/scripts/run-deadlock.sh new file mode 100644 index 0000000..77a84e2 --- /dev/null +++ b/.buildkite/k3_tests/multiprocess/scripts/run-deadlock.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# Self-contained deadlock regression test. +# +# Launches DeepSeek-V2-Lite-Chat with TP=2 (both GPUs) + LMCache server, +# sends 50 requests with ~30K token prefixes, and verifies they all +# complete within 3 minutes. A CUDA-driver/GIL deadlock would cause +# requests to hang indefinitely, failing the timeout. +# +# This test is self-contained: it handles its own server lifecycle +# instead of using the standard launch-processes.sh / wait-for-servers.sh. +set -o pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../../.." && pwd)" + +source "${REPO_ROOT}/.buildkite/k3_tests/common_scripts/helpers.sh" + +# ── Configuration ─────────────────────────────────────────── +MODEL="deepseek-ai/DeepSeek-V2-Lite-Chat" +LMCACHE_PORT="${LMCACHE_PORT:-15554}" +VLLM_PORT="${VLLM_PORT:-8000}" +BUILD_ID="${BUILD_ID:-local_$$}" +PID_FILE="/tmp/lmcache_mp_pids_${BUILD_ID}" +TIMEOUT_SECONDS=180 # 3 minutes + +# ── Install py-spy for deadlock diagnosis ────────────────── +echo "=== Installing py-spy ===" +uv pip install py-spy +PY_SPY="$(which py-spy)" +echo "py-spy installed at: $PY_SPY" + +PYSPY_LOG="/tmp/build_${BUILD_ID}_pyspy.log" + +# ── Helper: dump stacks of server processes via py-spy ───── +dump_stacks() { + echo "" | tee -a "$PYSPY_LOG" + echo "=== py-spy stack dump (native + Python) ===" | tee -a "$PYSPY_LOG" + + if kill -0 "$LMCACHE_PID" 2>/dev/null; then + echo "" | tee -a "$PYSPY_LOG" + echo "--- LMCache server (PID=$LMCACHE_PID) ---" | tee -a "$PYSPY_LOG" + sudo "$PY_SPY" dump --pid "$LMCACHE_PID" --native 2>&1 | tee -a "$PYSPY_LOG" || true + fi + + # Copy to repo root so cleanup.sh collects it as a Buildkite artifact + cp "$PYSPY_LOG" "${REPO_ROOT}/build_${BUILD_ID}_pyspy.log" 2>/dev/null || true +} + +# ── 1. Launch LMCache server ─────────────────────────────── +echo "=== Launching LMCache server ===" +echo "Port: $LMCACHE_PORT" + +lmcache server \ + --host localhost \ + --port "$LMCACHE_PORT" \ + --chunk-size 256 \ + --l1-size-gb 50 \ + --eviction-policy LRU \ + --max-workers 2 \ + > "/tmp/build_${BUILD_ID}_lmcache.log" 2>&1 & + +LMCACHE_PID=$! +echo "$LMCACHE_PID" >> "$PID_FILE" +echo "LMCache server started (PID=$LMCACHE_PID)" +sleep 10 + +# ── 2. Launch vLLM with DeepSeek TP=2 ───────────────────── +echo "=== Launching vLLM (DeepSeek TP=2) ===" +echo "Model: $MODEL" +echo "Port: $VLLM_PORT" + +# Save VLLM_PORT before unsetting — vLLM's internal get_open_port() +# would otherwise collide with the serving port for torch.distributed. +SAVED_VLLM_PORT="$VLLM_PORT" +unset VLLM_PORT + +FLASHINFER_DISABLE_VERSION_CHECK=1 \ +VLLM_SERVER_DEV_MODE=1 \ +vllm serve "$MODEL" \ + --tensor-parallel-size 2 \ + --distributed-executor-backend mp \ + --block-size 64 \ + --trust-remote-code \ + --load-format dummy \ + --enable-prefix-caching \ + --enable-chunked-prefill \ + --gpu-memory-utilization 0.8 \ + --max-model-len 65536 \ + --hf-overrides '{"max_position_embeddings":65536}' \ + --max-num-seqs 32 \ + --max-num-batched-tokens 16000 \ + --scheduling-policy fcfs \ + --port "$SAVED_VLLM_PORT" \ + --enforce-eager \ + --kv-transfer-config "{\"kv_connector\":\"LMCacheMPConnector\", \"kv_role\":\"kv_both\", \"kv_load_failure_policy\": \"recompute\", \"kv_connector_extra_config\": {\"lmcache.mp.port\": $LMCACHE_PORT, \"lmcache.mp.mq_timeout\": 60}}" \ + > "/tmp/build_${BUILD_ID}_vllm.log" 2>&1 & + +VLLM_PID=$! +echo "$VLLM_PID" >> "$PID_FILE" +echo "vLLM started (PID=$VLLM_PID)" + +VLLM_PORT="$SAVED_VLLM_PORT" + +# ── 3. Wait for vLLM to be ready ────────────────────────── +echo "=== Waiting for vLLM to be ready ===" +if ! wait_for_server "$VLLM_PORT" 600; then + echo "vLLM failed to start. Last 100 lines of log:" + tail -100 "/tmp/build_${BUILD_ID}_vllm.log" 2>/dev/null || true + exit 1 +fi + +# ── 4. Run benchmark with timeout ───────────────────────── +echo "=== Running lmcache bench engine (random-prefill, 50 reqs, ~30K tokens) ===" +echo "Timeout: ${TIMEOUT_SECONDS}s" + +if ! timeout "$TIMEOUT_SECONDS" lmcache bench engine \ + --engine-url "http://localhost:${VLLM_PORT}" \ + --workload random-prefill \ + --tokens-per-gb-kvcache 6000 \ + --rp-request-length 30000 \ + --rp-num-requests 50 \ + --no-interactive \ + --no-csv \ + -q; then + echo "FAIL: Benchmark failed or timed out (possible deadlock)" + echo "" + echo "=== LMCache log (last 50 lines) ===" + tail -50 "/tmp/build_${BUILD_ID}_lmcache.log" 2>/dev/null || true + echo "" + echo "=== vLLM log (last 50 lines) ===" + tail -50 "/tmp/build_${BUILD_ID}_vllm.log" 2>/dev/null || true + exit 1 +fi + +echo "" +echo "=== Benchmark completed within ${TIMEOUT_SECONDS}s ===" +echo "PASS: No deadlock detected" diff --git a/.buildkite/k3_tests/multiprocess/scripts/run-fault-tolerance.sh b/.buildkite/k3_tests/multiprocess/scripts/run-fault-tolerance.sh new file mode 100755 index 0000000..2bbfbae --- /dev/null +++ b/.buildkite/k3_tests/multiprocess/scripts/run-fault-tolerance.sh @@ -0,0 +1,253 @@ +#!/usr/bin/env bash +# Test LMCache fault tolerance: verify vLLM requests complete after +# the LMCache MP server is killed mid-flight. +# +# Flow: +# 1. Run a warmup bench (measures baseline timing) +# 2. Run bench again, killing LMCache server mid-flight +# 3. Run bench fully without LMCache server +# 4. Verify all prompts completed in every phase +set -o pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../../.." && pwd)" + +source "${REPO_ROOT}/.buildkite/k3_tests/common_scripts/helpers.sh" + +# Configuration (inherited from run-mp-test.sh) +VLLM_PORT="${VLLM_PORT:-8000}" +MODEL="${MODEL:-Qwen/Qwen3-14B}" +BUILD_ID="${BUILD_ID:-local_$$}" +RESULTS_DIR="${RESULTS_DIR:-/tmp/lmcache_ci_results_${BUILD_ID}}" +LMCACHE_PORT="${LMCACHE_PORT:-6555}" + +# Bench parameters +NUM_PROMPTS="${NUM_PROMPTS:-50}" +RANDOM_INPUT_LEN="${RANDOM_INPUT_LEN:-10000}" +RANDOM_OUTPUT_LEN="${RANDOM_OUTPUT_LEN:-1}" +RANDOM_SEED="${RANDOM_SEED:-42}" + +CPU_BUFFER_SIZE="${CPU_BUFFER_SIZE:-80}" +MAX_WORKERS="${MAX_WORKERS:-4}" + +# Output directory +FT_DIR="$RESULTS_DIR/fault_tolerance" +mkdir -p "$FT_DIR" + +echo "=== Fault Tolerance Test ===" +echo "Model: $MODEL" +echo "vLLM Port: $VLLM_PORT" +echo "LMCache Port: $LMCACHE_PORT" +echo "Bench: $NUM_PROMPTS prompts, input_len=$RANDOM_INPUT_LEN, output_len=$RANDOM_OUTPUT_LEN" +echo "Results dir: $FT_DIR" +echo "" + +# ── Step 0: Restart LMCache + vLLM with fresh state ───────── +# Previous steps may have left processes in an unknown state. +# Restart both so vLLM registers its GPU context with the new server. +echo "============================================" +echo "=== Restarting LMCache + vLLM ===" +echo "============================================" + +PID_FILE="/tmp/lmcache_mp_pids_${BUILD_ID}" +GPU_DEVICE="${GPU_FOR_VLLM:-0}" + +# Kill existing LMCache + vLLM (keep baseline on line 3) +if [ -f "$PID_FILE" ]; then + OLD_LMCACHE_PID=$(sed -n '1p' "$PID_FILE") + OLD_VLLM_PID=$(sed -n '2p' "$PID_FILE") + for pid in $OLD_LMCACHE_PID $OLD_VLLM_PID; do + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + echo "Killing PID $pid" + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + fi + done + sleep 2 +fi + +# Launch LMCache with L1 config +CUDA_VISIBLE_DEVICES="${GPU_DEVICE}" \ +lmcache server \ + --l1-size-gb "$CPU_BUFFER_SIZE" \ + --eviction-policy LRU \ + --max-workers "$MAX_WORKERS" \ + --port "$LMCACHE_PORT" \ + > "/tmp/build_${BUILD_ID}_lmcache_ft.log" 2>&1 & + +NEW_LMCACHE_PID=$! +echo "LMCache server started (PID=$NEW_LMCACHE_PID)" +sleep 10 + +# Launch vLLM with LMCache +GPU_MEMORY_UTIL_ARG="" +GPU_MEMORY_MB=$(nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits -i "${GPU_DEVICE}" | tr -d ' ') +GPU_MEMORY_GB=$((GPU_MEMORY_MB / 1024)) +if [ "$GPU_MEMORY_GB" -gt 90 ]; then + GPU_MEMORY_UTIL_ARG="--gpu-memory-utilization 0.5" +fi + +env -u VLLM_PORT \ + CUDA_VISIBLE_DEVICES="${GPU_DEVICE}" \ + VLLM_ENABLE_V1_MULTIPROCESSING=0 \ + VLLM_SERVER_DEV_MODE=1 \ + VLLM_BATCH_INVARIANT=1 \ + PYTHONHASHSEED=0 \ +vllm serve "$MODEL" \ + --kv-transfer-config "{\"kv_connector\":\"LMCacheMPConnector\", \"kv_role\":\"kv_both\", \"kv_load_failure_policy\": \"recompute\", \"kv_connector_extra_config\": {\"lmcache.mp.port\": $LMCACHE_PORT, \"lmcache.mp.mq_timeout\": 10}}" \ + --attention-backend FLASH_ATTN \ + --port "$VLLM_PORT" \ + --no-async-scheduling \ + $GPU_MEMORY_UTIL_ARG \ + > "/tmp/build_${BUILD_ID}_vllm_ft.log" 2>&1 & + +NEW_VLLM_PID=$! +echo "vLLM started (PID=$NEW_VLLM_PID)" + +# Update PID file +if [ -f "$PID_FILE" ]; then + sed -i "1s/.*/$NEW_LMCACHE_PID/" "$PID_FILE" + sed -i "2s/.*/$NEW_VLLM_PID/" "$PID_FILE" +else + echo "$NEW_LMCACHE_PID" > "$PID_FILE" + echo "$NEW_VLLM_PID" >> "$PID_FILE" +fi + +if ! wait_for_server "$VLLM_PORT" 300; then + echo "vLLM failed to start" + tail -50 "/tmp/build_${BUILD_ID}_lmcache_ft.log" || true + tail -50 "/tmp/build_${BUILD_ID}_vllm_ft.log" || true + exit 1 +fi +echo "" + +# ── Helpers ────────────────────────────────────────────────── + +run_bench() { + local description="$1" + local result_file="$2" + + echo "" + echo "--- $description ---" + + vllm bench serve \ + --seed "$RANDOM_SEED" \ + --port "$VLLM_PORT" \ + --model "$MODEL" \ + --dataset-name random \ + --random-input-len "$RANDOM_INPUT_LEN" \ + --random-output-len "$RANDOM_OUTPUT_LEN" \ + --num-prompts "$NUM_PROMPTS" \ + --ignore-eos \ + --backend openai-chat \ + --endpoint /v1/chat/completions \ + --result-dir "$FT_DIR" \ + --result-filename "$result_file" \ + --save-result + + local completed + completed=$(python3 -c " +import json +with open('$FT_DIR/$result_file') as f: + data = json.load(f) +print(data.get('completed', 0)) +") + + echo "$description: $completed / $NUM_PROMPTS completed" + + if [ "$completed" -ne "$NUM_PROMPTS" ]; then + echo "FAIL: Expected $NUM_PROMPTS completed, got $completed" + return 1 + fi + + echo "PASS: All $NUM_PROMPTS prompts completed" + return 0 +} + +get_lmcache_pid() { + local pid_file="/tmp/lmcache_mp_pids_${BUILD_ID}" + if [[ -f "$pid_file" ]]; then + head -1 "$pid_file" + fi +} + +# ── Step 1: Warmup bench ───────────────────────────────────── +echo "============================================" +echo "=== Fault Tolerance Step 1: Warmup bench ===" +echo "============================================" + +if ! run_bench "Warmup (with LMCache)" "ft_warmup.json"; then + echo "FAIL: Warmup bench failed" + exit 1 +fi + +# Extract duration to calibrate kill timing +WARMUP_DURATION=$(python3 -c "import json; print(json.load(open('$FT_DIR/ft_warmup.json'))['duration'])") +KILL_DELAY=$(python3 -c "print(max(3, int($WARMUP_DURATION * 0.4)))") +echo "Warmup took ${WARMUP_DURATION}s. Will kill LMCache after ${KILL_DELAY}s in next run." + +# ── Step 2: Bench with mid-flight LMCache kill ─────────────── +echo "" +echo "============================================" +echo "=== Fault Tolerance Step 2: Mid-flight kill ===" +echo "============================================" + +LMCACHE_PID=$(get_lmcache_pid) +if [ -z "$LMCACHE_PID" ] || ! kill -0 "$LMCACHE_PID" 2>/dev/null; then + echo "FAIL: LMCache server not running (PID=$LMCACHE_PID)" + exit 1 +fi + +echo "LMCache server PID: $LMCACHE_PID" +echo "Will kill after ${KILL_DELAY}s into bench." + +# Start bench in background +run_bench "Mid-flight kill" "ft_midflight.json" & +BENCH_PID=$! + +# Wait, then kill LMCache +sleep "$KILL_DELAY" +echo "Killing LMCache server (PID: $LMCACHE_PID)..." +kill "$LMCACHE_PID" 2>/dev/null +wait "$LMCACHE_PID" 2>/dev/null || true +echo "LMCache server killed at +${KILL_DELAY}s." + +# Wait for bench to finish +echo "Waiting for bench to complete..." +if ! wait "$BENCH_PID"; then + echo "FAIL: Bench did not complete after mid-flight LMCache kill." + echo "--- vLLM log (last 50 lines) ---" + tail -50 "/tmp/build_${BUILD_ID}_vllm.log" 2>/dev/null || true + exit 1 +fi + +# ── Step 3: Bench fully without LMCache server ─────────────── +echo "" +echo "============================================" +echo "=== Fault Tolerance Step 3: Without LMCache ===" +echo "============================================" + +if ! run_bench "Without LMCache" "ft_without_lmcache.json"; then + echo "FAIL: Bench failed without LMCache server." + echo "--- vLLM log (last 50 lines) ---" + tail -50 "/tmp/build_${BUILD_ID}_vllm.log" 2>/dev/null || true + exit 1 +fi + +# ── Summary ────────────────────────────────────────────────── +echo "" +echo "============================================" +echo "=== Fault Tolerance Test PASSED ===" +echo "============================================" + +warmup_completed=$(python3 -c "import json; print(json.load(open('$FT_DIR/ft_warmup.json'))['completed'])") +warmup_duration=$(python3 -c "import json; print(f\"{json.load(open('$FT_DIR/ft_warmup.json'))['duration']:.1f}\")") +midflight_completed=$(python3 -c "import json; print(json.load(open('$FT_DIR/ft_midflight.json'))['completed'])") +midflight_duration=$(python3 -c "import json; print(f\"{json.load(open('$FT_DIR/ft_midflight.json'))['duration']:.1f}\")") +without_completed=$(python3 -c "import json; print(json.load(open('$FT_DIR/ft_without_lmcache.json'))['completed'])") +without_duration=$(python3 -c "import json; print(f\"{json.load(open('$FT_DIR/ft_without_lmcache.json'))['duration']:.1f}\")") + +echo " Warmup (with LMCache): $warmup_completed/$NUM_PROMPTS in ${warmup_duration}s" +echo " Mid-flight kill: $midflight_completed/$NUM_PROMPTS in ${midflight_duration}s (killed at +${KILL_DELAY}s)" +echo " Without LMCache: $without_completed/$NUM_PROMPTS in ${without_duration}s" +echo "" diff --git a/.buildkite/k3_tests/multiprocess/scripts/run-gds-smoke.sh b/.buildkite/k3_tests/multiprocess/scripts/run-gds-smoke.sh new file mode 100755 index 0000000..c33e5c6 --- /dev/null +++ b/.buildkite/k3_tests/multiprocess/scripts/run-gds-smoke.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# GDS L1 smoke test. Sends a few completions (cold) to store KV to the slab, +# resets vLLM's prefix cache, then re-sends them (warm) to read the KV back from +# LMCache/GDS. Passes if every request returns HTTP 200, a real LMCache retrieve +# happened, and the warm (GDS-retrieved) outputs match the cold (recomputed) +# ones -- i.e. the GDS store/retrieve path works and is correct. +# +# Expects the GDS-enabled LMCache server + vLLM to already be running, with +# VLLM_SERVER_DEV_MODE=1 (for /reset_prefix_cache). +set -e +set -o pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../../.." && pwd)" +source "${REPO_ROOT}/.buildkite/k3_tests/common_scripts/helpers.sh" + +VLLM_PORT="${VLLM_PORT:-8000}" +MODEL="${MODEL:-Qwen/Qwen3-14B}" +BUILD_ID="${BUILD_ID:-local_$$}" +LMCACHE_LOG="/tmp/build_${BUILD_ID}_lmcache.log" +N_PROMPTS="${GDS_SMOKE_PROMPTS:-4}" +OUT_DIR="$(mktemp -d)" +trap 'rm -rf "$OUT_DIR"' EXIT + +# A long-ish prompt so each request stores at least one LMCache chunk. +build_prompt() { # $1 = unique id + local filler="The key-value cache stores attention keys and values across transformer layers. " + local body="" i + for i in $(seq 1 80); do body="${body}${filler}"; done + printf 'Document %s. %s' "$1" "$body" +} + +# Send N_PROMPTS completions; capture each generated text to +# $OUT_DIR/