From 29d915d65da33d7e3cf3043b27a7c52189ca0f4c Mon Sep 17 00:00:00 2001 From: tangchengxiang <2064027004@qq.com> Date: Wed, 16 Sep 2026 04:31:16 +0000 Subject: [PATCH 1/9] feat(engine): integrate prefix eviction and chunked parallel execution --- csrc/engine/compiler/paged_compiler.cpp | 125 +++ csrc/engine/compiler/paged_compiler.hpp | 7 + csrc/engine/infer_engine.cpp | 10 +- csrc/engine/rank_worker.cpp | 22 +- csrc/engine/rank_worker.hpp | 3 + .../causal_lm_templates/text_causal_lm.hpp | 2 +- csrc/models/infinilm_model.hpp | 2 + csrc/pybind11/engine/engine.hpp | 16 +- docs/cache-chunk-validation.md | 72 ++ docs/chunk-graphs.md | 30 + docs/chunked-prefill.md | 134 +++ docs/validation/cache-chunk-graphs.json | 278 ++++++ examples/bench.py | 20 +- examples/bench_videonsa.py | 4 + examples/llama.py | 18 +- examples/test_infer.py | 9 + python/infinilm/base_config.py | 41 + python/infinilm/config/engine_config.py | 44 + python/infinilm/infer_engine.py | 55 +- python/infinilm/llm/cache_manager.py | 79 +- python/infinilm/llm/llm.py | 46 + .../infinilm/llm/model_runner/model_runner.py | 10 + python/infinilm/llm/scheduler.py | 138 ++- .../processors/basic_llm_processor.py | 17 +- python/infinilm/server/inference_server.py | 15 + python/infinilm/server/pipeline_worker.py | 4 + test/llm/README.md | 94 ++ test/llm/README.slru.md | 98 ++ test/llm/benchmark_chunk_prefill.py | 298 ++++++ test/llm/benchmark_prefix_cache.py | 928 ++++++++++++++++++ test/llm/cache_test_support.py | 84 ++ test/llm/check_chunk_output.py | 78 ++ test/llm/check_chunk_tp.py | 412 ++++++++ test/llm/chunk_test_support.py | 3 + test/llm/graph_counter.cc | 21 + test/llm/test_benchmark_prefix_cache.py | 230 +++++ test/llm/test_cache_manager.py | 255 +++++ test/llm/test_cache_manager_slru.py | 149 +++ test/llm/test_cache_policy_config.py | 273 ++++++ test/llm/test_chunk_config.py | 377 +++++++ test/llm/test_chunk_execution.py | 218 ++++ test/llm/test_chunk_output.py | 115 +++ test/llm/test_chunk_scheduler.py | 222 +++++ test/llm/test_scheduler_cache_lifecycle.py | 350 +++++++ 44 files changed, 5329 insertions(+), 77 deletions(-) create mode 100644 docs/cache-chunk-validation.md create mode 100644 docs/chunk-graphs.md create mode 100644 docs/chunked-prefill.md create mode 100644 docs/validation/cache-chunk-graphs.json create mode 100644 test/llm/README.md create mode 100644 test/llm/README.slru.md create mode 100644 test/llm/benchmark_chunk_prefill.py create mode 100644 test/llm/benchmark_prefix_cache.py create mode 100644 test/llm/cache_test_support.py create mode 100644 test/llm/check_chunk_output.py create mode 100644 test/llm/check_chunk_tp.py create mode 100644 test/llm/chunk_test_support.py create mode 100644 test/llm/graph_counter.cc create mode 100644 test/llm/test_benchmark_prefix_cache.py create mode 100644 test/llm/test_cache_manager.py create mode 100644 test/llm/test_cache_manager_slru.py create mode 100644 test/llm/test_cache_policy_config.py create mode 100644 test/llm/test_chunk_config.py create mode 100644 test/llm/test_chunk_execution.py create mode 100644 test/llm/test_chunk_output.py create mode 100644 test/llm/test_chunk_scheduler.py create mode 100644 test/llm/test_scheduler_cache_lifecycle.py diff --git a/csrc/engine/compiler/paged_compiler.cpp b/csrc/engine/compiler/paged_compiler.cpp index dee3123c9..e1906df9c 100644 --- a/csrc/engine/compiler/paged_compiler.cpp +++ b/csrc/engine/compiler/paged_compiler.cpp @@ -4,6 +4,8 @@ #include #include +#include +#include #include #include @@ -177,9 +179,132 @@ void PagedCompiler::compile() { compiled_map_decode_[b] = CompiledResult{std::move(input), std::make_tuple(graph, shared_output)}; } } + compile_prefill(); +} + +void PagedCompiler::compile_prefill() { + compiled_map_prefill_.clear(); + prefill_chunk_size_ = 0; + const char *size_env = std::getenv("INFINILM_PREFILL_GRAPH_CHUNK_SIZE"); + if (size_env == nullptr) { + return; + } + // This opt-in experiment deliberately has a smaller support scope than + // the existing Decode compiler. Do not silently capture unsupported paths. + char *end = nullptr; + const auto chunk = std::strtoul(size_env, &end, 10); + const auto *cache = dynamic_cast(model_->get_cache_config()); + const auto &config = infinilm::global_state::get_infinilm_config(); + auto &ctx = infinilm::global_state::get_forward_context(); + if (end == size_env || *end != '\0' || chunk < 2 || chunk > 4096 + || cache == nullptr || chunk > cache->num_blocks() * cache->block_size() + || config.attention_backend != infinilm::backends::AttentionBackend::FLASH_ATTN + || config.use_mla || has_mamba_cache(ctx) + || infinilm::global_state::get_tensor_model_parallel_world_size() != 1 + || infinicore::context::getDevice().getType() != infinicore::Device::Type::METAX) { + throw std::invalid_argument("Experimental Prefill graphs require C500/MetaX, TP1, flash-attn, ordinary paged KV and chunk size 2..4096 within cache capacity"); + } + prefill_chunk_size_ = chunk; + // Flash Attention's scalar maximum is fixed during capture. The actual + // KV length remains a device tensor and can grow across continuation chunks. + prefill_max_sequence_length_ = cache->num_blocks() * cache->block_size(); + auto make_tensor = [](const std::vector &shape, infinicore::DataType dtype) { + return infinicore::Tensor::empty(shape, dtype, infinicore::context::getDevice()); + }; + for (bool intermediate : {true, false}) { + InfinilmModel::Input input; + input.prefill_only = intermediate; + input.input_ids = make_tensor({1, chunk}, infinicore::DataType::I64); + input.position_ids = make_tensor({chunk}, infinicore::DataType::I64); + input.total_sequence_lengths = make_tensor({1}, infinicore::DataType::I32); + input.input_offsets = make_tensor({2}, infinicore::DataType::I32); + input.cu_seqlens = make_tensor({2}, infinicore::DataType::I32); + input.block_tables = make_tensor({1, cache->num_blocks()}, infinicore::DataType::I32); + input.slot_mapping = make_tensor({chunk}, infinicore::DataType::I64); + set_zeros(input.input_ids.value()); + std::vector positions(chunk); + std::iota(positions.begin(), positions.end(), 0); + std::vector pages(cache->num_blocks()); + std::iota(pages.begin(), pages.end(), 0); + const std::vector offsets{0, static_cast(chunk)}; + auto upload = [](infinicore::Tensor dst, const auto &values) { + infinicore::context::memcpyH2D(dst->data(), values.data(), values.size() * sizeof(values[0]), false); + }; + upload(input.position_ids.value(), positions); + upload(input.slot_mapping.value(), positions); + upload(input.block_tables.value(), pages); + upload(input.input_offsets.value(), offsets); + upload(input.cu_seqlens.value(), offsets); + upload(input.total_sequence_lengths.value(), std::vector{static_cast(chunk)}); + ctx.attn_metadata = {input.past_sequence_lengths, input.total_sequence_lengths, + input.input_offsets, input.cu_seqlens, input.block_tables, + input.slot_mapping, chunk, prefill_max_sequence_length_}; + (void)model_->forward(input); + infinicore::context::syncStream(); + model_->reset_runtime_state(); + infinicore::context::syncStream(); + infinicore::context::startGraphRecording(); + auto output = model_->forward(input); + auto graph = infinicore::context::stopGraphRecording(); + auto saved = std::make_shared(); + if (output.logits) { + saved->logits = infinicore::graph::GraphTensor(output.logits); + } + if (output.hidden_states) { + saved->hidden_states = infinicore::graph::GraphTensor(output.hidden_states); + } + compiled_map_prefill_[intermediate] = CompiledResult{std::move(input), {graph, saved}}; + } +} + +PagedCompiler::Compiled PagedCompiler::get_compiled_prefill(const InfinilmModel::Input &input) { + if (prefill_chunk_size_ == 0 || input.input_ids.value()->numel() != prefill_chunk_size_ + || input.block_tables.value()->size(0) != 1 || input.sample_all_positions + || input.mamba_init_state_indices.has_value() || input.pixel_values.has_value()) { + return {nullptr, nullptr}; + } + auto &entry = compiled_map_prefill_.at(input.prefill_only); + auto &target = entry.input; + const auto &lengths = input.total_sequence_lengths.value(); + if (lengths->device().getType() != infinicore::Device::Type::CPU + || lengths->dtype() != infinicore::DataType::I32 || lengths->numel() != 1) { + throw std::invalid_argument("Prefill graph replay requires CPU int32 sequence lengths"); + } + const auto length = *reinterpret_cast(lengths->data()); + const size_t width = input.block_tables.value()->size(1); + if (length < static_cast(prefill_chunk_size_) + || static_cast(length) > prefill_max_sequence_length_ + || width > target.block_tables.value()->size(1) + || input.position_ids.value()->shape() != target.position_ids.value()->shape()) { + return {nullptr, nullptr}; + } + target.input_ids.value()->copy_from(input.input_ids.value()); + target.position_ids.value()->copy_from(input.position_ids.value()); + target.total_sequence_lengths.value()->copy_from(lengths); + target.input_offsets.value()->copy_from(input.input_offsets.value()); + target.cu_seqlens.value()->copy_from(input.cu_seqlens.value()); + target.slot_mapping.value()->copy_from(input.slot_mapping.value()); + set_minus_one_device_async(target.block_tables.value()); + target.block_tables.value()->narrow({{1, 0, width}})->copy_from(input.block_tables.value()); + model_->reset_runtime_state(); + auto saved = std::get<1>(entry.compiled); + auto output = std::make_shared(); + if (saved->logits) { + output->logits = saved->logits->resume_from_blob_(); + } + return {std::get<0>(entry.compiled), output}; } PagedCompiler::Compiled PagedCompiler::get_compiled(const InfinilmModel::Input &input) { + if (prefill_chunk_size_ != 0) { + auto result = get_compiled_prefill(input); + if (std::get<0>(result)) { + return result; + } + } + if (input.prefill_only) { + return {nullptr, nullptr}; + } if (model_->get_cache_config() != nullptr && dynamic_cast(model_->get_cache_config())) { size_t batch_size = input.block_tables.value()->size(0); size_t block_per_req = input.block_tables.value()->size(1); diff --git a/csrc/engine/compiler/paged_compiler.hpp b/csrc/engine/compiler/paged_compiler.hpp index a1125864d..3b94c6d8c 100644 --- a/csrc/engine/compiler/paged_compiler.hpp +++ b/csrc/engine/compiler/paged_compiler.hpp @@ -15,6 +15,12 @@ class PagedCompiler : public GraphCompiler { private: std::vector decode_batch_sizes_; + // Experimental fixed-size, single-request Prefill graphs. Unmatched tails + // keep the ordinary eager path; intermediate graphs do not run the LM head. + size_t prefill_chunk_size_{0}; + size_t prefill_max_sequence_length_{0}; + void compile_prefill(); + Compiled get_compiled_prefill(const InfinilmModel::Input &input); infinicore::Tensor block_tables_holder_; @@ -27,5 +33,6 @@ class PagedCompiler : public GraphCompiler { size_t, // num_requests CompiledResult> compiled_map_decode_; + std::unordered_map compiled_map_prefill_; }; } // namespace infinilm::engine diff --git a/csrc/engine/infer_engine.cpp b/csrc/engine/infer_engine.cpp index 422b5df73..947be6e2e 100644 --- a/csrc/engine/infer_engine.cpp +++ b/csrc/engine/infer_engine.cpp @@ -240,7 +240,8 @@ InferEngine::Input::to_model_input(infinicore::Device device) const { image_req_ids, visual_token_ranges, to_device(target_hidden_states), - sample_all_positions}; + sample_all_positions, + prefill_only}; if (serialize_host_copy) { infinicore::context::syncStream(); @@ -269,6 +270,13 @@ InferEngine::Input::to_model_input(infinicore::Device device) const { } InferEngine::Output InferEngine::forward(const InferEngine::Input &input) { + // Every PP stage receives prefill_only and skips the sampled-ID exchange. + if (input.prefill_only && input.sample_all_positions) { + throw std::invalid_argument("prefill_only requires sample_all_positions=false"); + } + if (input.prefill_only && (!input.input_offsets.has_value() || input.input_offsets.value()->numel() < 2)) { + throw std::invalid_argument("prefill_only requires request input_offsets"); + } // Trigger each worker to run inference for (auto &worker : workers_) { worker->run(input); diff --git a/csrc/engine/rank_worker.cpp b/csrc/engine/rank_worker.cpp index 0fa0a84cf..196f00469 100644 --- a/csrc/engine/rank_worker.cpp +++ b/csrc/engine/rank_worker.cpp @@ -418,6 +418,7 @@ void RankWorker::thread_loop() { infinicore::Tensor logits; infinicore::Tensor hidden_states; + bool graph_executed = false; // All-position speculative/MTP runs need eager mode because // hidden states are not part of compiled graph outputs. if (!local_args.sample_all_positions && compiler_ != nullptr && rank_info_.pp_size == 1) { @@ -425,16 +426,35 @@ void RankWorker::thread_loop() { if (graph != nullptr && output != nullptr) { graph->run(); logits = output->logits; + graph_executed = true; } } // Fall back to eager mode - if (!logits) { + if (!graph_executed) { auto model_args = local_args.to_model_input(rank_info_.device); auto model_output = model_->forward(model_args); logits = model_output.logits; hidden_states = model_output.hidden_states; } + if (local_args.prefill_only) { + if (rank_info_.tp_rank == 0) { + // Preserve the old sampler's RNG advancement, but + // avoid LM output sampling and the token D2H copy. + const auto n_req = local_args.input_offsets.value()->numel() - 1; + for (size_t i = 0; i < n_req; ++i) { + (void)std::uniform_real_distribution(0, 1)(rng_); + } + // Keep the ordinary forward completion contract. + // Publication/cancellation follows this return. + infinicore::context::syncStream(); + } + output_ = Output{}; + job_done_ = true; + cv_.notify_all(); + continue; + } + if (rank_info_.pp_size > 1 && rank_info_.pp_stage + 1 != rank_info_.pp_size) { infinicore::Tensor output_ids; if (rank_info_.pp_stage == 0 && rank_info_.tp_rank == 0) { diff --git a/csrc/engine/rank_worker.hpp b/csrc/engine/rank_worker.hpp index d396ef6f1..10028cf20 100644 --- a/csrc/engine/rank_worker.hpp +++ b/csrc/engine/rank_worker.hpp @@ -79,6 +79,9 @@ class RankWorker { float top_p{1}; + /// Compute KV for an intermediate prefill without returning model output. + bool prefill_only{false}; + infinilm::InfinilmModel::Input to_model_input(infinicore::Device device) const; }; diff --git a/csrc/layers/causal_lm_templates/text_causal_lm.hpp b/csrc/layers/causal_lm_templates/text_causal_lm.hpp index e39f27d9e..8dd307421 100644 --- a/csrc/layers/causal_lm_templates/text_causal_lm.hpp +++ b/csrc/layers/causal_lm_templates/text_causal_lm.hpp @@ -52,7 +52,7 @@ class TextCausalLM : public InfinilmModel { */ Output forward(const Input &input) const override { auto hidden_states = model_->forward(input); - if (!is_last_pp_stage()) { + if (!is_last_pp_stage() || input.prefill_only) { return {infinicore::Tensor(), hidden_states}; } diff --git a/csrc/models/infinilm_model.hpp b/csrc/models/infinilm_model.hpp index 02677318d..c729cf4f7 100644 --- a/csrc/models/infinilm_model.hpp +++ b/csrc/models/infinilm_model.hpp @@ -57,6 +57,8 @@ class InfinilmModel : public infinicore::nn::Module { std::optional target_hidden_states; /// Preserve logits for every packed position for speculative/MTP callers. bool sample_all_positions{false}; + /// Intermediate prefill does not need the language-model head. + bool prefill_only{false}; }; struct Output { diff --git a/csrc/pybind11/engine/engine.hpp b/csrc/pybind11/engine/engine.hpp index c5e85577c..025abc1f5 100644 --- a/csrc/pybind11/engine/engine.hpp +++ b/csrc/pybind11/engine/engine.hpp @@ -122,18 +122,14 @@ inline void bind_infer_engine(py::module &m) { return state_dict_tp_all; }) .def("process_weights_after_loading", &InferEngine::process_weights_after_loading, "Process the weights after loading on all workers (e.g., for quantization)") - .def( - "forward", [](InferEngine &self, const InferEngine::Input &input) -> InferEngine::Output { + .def("forward", [](InferEngine &self, const InferEngine::Input &input) -> InferEngine::Output { // IMPORTANT: Release the GIL before calling forward() to allow other Python threads // to run concurrently during inference (which may block for a long time). // Do NOT remove this — without it, the GIL is held throughout inference and will // deadlock or stall any other Python thread (e.g., request handling, scheduling). py::gil_scoped_release release; - return self.forward(input); - }, - "Run inference on all ranks with arbitrary arguments") - .def( - "reset_cache", [](InferEngine &self, std::shared_ptr cfg) { self.reset_cache(cfg ? cfg.get() : nullptr); }, py::arg("cache_config") = py::none()) + return self.forward(input); }, "Run inference on all ranks with arbitrary arguments") + .def("reset_cache", [](InferEngine &self, std::shared_ptr cfg) { self.reset_cache(cfg ? cfg.get() : nullptr); }, py::arg("cache_config") = py::none()) .def("get_kv_cache", &InferEngine::get_kv_cache, "Get per-rank kv cache list") .def("get_cache_config", [](const InferEngine &self) -> std::shared_ptr { auto cfg = self.get_cache_config(); @@ -190,6 +186,7 @@ inline void bind_infer_engine(py::module &m) { // Allowed keyword arguments static const std::unordered_set allowed_kwargs = { + "prefill_only", "temperature", "top_p", "top_k", @@ -203,7 +200,9 @@ inline void bind_infer_engine(py::module &m) { "InferEngine.Input got an unexpected keyword argument '" + key + "'"); } - if (key == "temperature") { + if (key == "prefill_only") { + input.prefill_only = py::cast(item.second); + } else if (key == "temperature") { input.temperature = py::cast(item.second); } else if (key == "top_p") { input.top_p = py::cast(item.second); @@ -249,6 +248,7 @@ inline void bind_infer_engine(py::module &m) { .def_readwrite("image_req_ids", &InferEngine::Input::image_req_ids) .def_readwrite("visual_token_ranges", &InferEngine::Input::visual_token_ranges) .def_readwrite("target_hidden_states", &InferEngine::Input::target_hidden_states) + .def_readwrite("prefill_only", &InferEngine::Input::prefill_only) .def_readwrite("sample_all_positions", &InferEngine::Input::sample_all_positions) .def_readwrite("temperature", &InferEngine::Input::temperature) .def_readwrite("top_k", &InferEngine::Input::top_k) diff --git a/docs/cache-chunk-validation.md b/docs/cache-chunk-validation.md new file mode 100644 index 000000000..b765d7bf0 --- /dev/null +++ b/docs/cache-chunk-validation.md @@ -0,0 +1,72 @@ +# Cache, chunking and graph integration validation + +The default is LRU eviction with chunking disabled. SLRU and chunking are +optional. Prefix cache hits are promoted only after successful admission in +both scheduling paths. Intermediate chunks execute all Transformer layers; +the LM head and sampling are omitted until the final chunk. + +## Tested configurations (2026-09-16) + +| Hardware / model | Execution | Coverage | +|---|---|---| +| A6000, Qwen2.5-1.5B FP16 | TP1 and TP2, eager or eager Prefill + Decode graphs | LRU, SLRU, non-page-aligned chunk300, prefix reuse, shared ownership, cancellation, prefix caching off | +| Two A6000s, same model | TP1/PP2 eager | Unchunked baseline, chunk300 + LRU/SLRU, prefix caching off; both stages inspected | +| C500 slice, Qwen3-0.6B BF16 | TP1, Decode graph with eager or graph Prefill | LRU/SLRU, chunk512, prefix reuse, cancellation, prefix caching off | +| C500 slice, Qwen3-4B BF16 | TP1, Prefill and Decode graphs | SLRU, chunk512, shared/repeated prefix and cancellation | + +All 13 A6000 configurations matched one completed-request token reference. +The six C500 lifecycle configurations passed; 0.6B outputs matched across +policies/modes and each model's shared/repeated requests matched. First/last +local KV layers were checked by poisoning scheduled slots, requiring finite +writes there and unchanged values elsewhere. Every case returned all 16 +blocks to a usable state with zero references. Actual device-graph launches +were counted; TP2 Decode replay launched a graph on each rank. + +The matching InfiniCore MetaX varlen ABI fix also passed six Prefill and six +Decode FP16/BF16 operator cases against FP32 reference attention. Its build +selects the signature of the installed wheel; older signatures have fixture +coverage, not execution on older hardware/software installations. + +Structured results: [cache-chunk-graphs.json](validation/cache-chunk-graphs.json). +The CPU suite has 108 passing tests, including successful-admission-only SLRU +promotion in chunk mode and PP worker configuration forwarding: + +```sh +python -m unittest discover -s test/llm -p 'test_*.py' +``` + +Native reproduction commands and prerequisites are in +[chunked-prefill.md](chunked-prefill.md) and [chunk-graphs.md](chunk-graphs.md). +`check_chunk_tp.py` accepts `--tp`, `--pp`, `--stage`, `--port`, `--graph`, +`--policy`, `--cache-off`, `--chunk-size` and `--output`. Use separate processes +and visible devices for PP stages. The counter is required only with `--graph`. + +## Performance boundaries + +A final TP2 mixed-request smoke comparison used chunk512, a 2048-token long +prompt, two 128-token short prompts, 160 output tokens, and prefix reuse off. +One window per mode measured 161.71 token/s eager and 161.02 token/s with +Decode graphs; all outputs matched. This does not demonstrate a throughput +improvement. KV-poison checks are correctness instrumentation and are not +used as performance measurements. + +The earlier C500 ablation showed clearer Decode ITL benefits and only a small +increment from fixed-size Prefill graphs; see [chunk-graphs.md](chunk-graphs.md). +Chunking improves opportunities for other requests to progress, while it can +increase long-request TTFT and reduce throughput. No universal speedup is +claimed. + +## Limits + +PP graphs, TP2/PP2, remote-KV integration, MoE, quantization, multimodal models, +Ascend and Moore devices were not tested. This does not extend those support +claims. PP graph/chunk combinations are rejected; fixed-size Prefill graphs +are restricted to MetaX TP1. Full graph workspace memory overhead and sustained +service throughput were not measured. Near-tied FP16 tokens can differ across +prefix execution shapes, as documented in the earlier chunking checks. + +Builds reused existing objects where possible. A clean build and the upstream +hardware CI matrix remain external validation steps. The native runners test +the real scheduler/model/cache path directly; they do not replace an HTTP +load test. The direct-native examples bypass the scheduler and deliberately +reject the chunking option. diff --git a/docs/chunk-graphs.md b/docs/chunk-graphs.md new file mode 100644 index 000000000..49f4b0266 --- /dev/null +++ b/docs/chunk-graphs.md @@ -0,0 +1,30 @@ +# Experimental graphs with chunked Prefill + +Supported chunk/graph combinations are TP1/PP1 and TP2/PP1 on CUDA-compatible devices. Tested backends are NVIDIA A6000 paged attention (Qwen2.5-1.5B FP16) and MetaX C500 Flash Attention (Qwen3 BF16, TP1). PP2 chunking uses eager execution. + +Set `prefill_chunk_size=512`, `enable_graph=True` and `device="cuda"` to combine eager Prefill with Decode graphs. Use `attn_backend="paged-attn"` on the tested A6000 build and `"flash-attn"` on C500. InfiniCore must be built with `--graph=y`; TP2 also requires working collectives. The `cuda` device alias maps to MACA in the C500 build. + +To additionally test fixed-size Prefill graphs, set +`INFINILM_PREFILL_GRAPH_CHUNK_SIZE=512` **before creating the engine**. This +experimental switch requires the MetaX backend, TP1, ordinary paged KV and +Flash Attention. It captures one request of exactly the chosen size; other +sizes retain the existing execution path. Single-token final tails may use +the Decode graph because they have the same one-query attention semantics. + +The compiler captures separate intermediate and final graphs. Intermediate +chunks execute every Transformer layer but omit the LM head, sampling and +token output, matching the eager implementation. A graph with no logits is +still a completed execution and must not trigger a duplicate eager forward. + +Token IDs, positions, KV lengths, offsets, page tables and slot mappings are +updated in persistent buffers before each replay. The scalar maximum KV +length is fixed to the cache pool capacity so that later chunks and reused +prefixes do not inherit the first chunk's length. Unsupported shapes fall +back to the existing execution path. This implements fixed shapes, without +padding or dynamic Prefill batch buckets. + +The integrated cache manager supports LRU and optional SLRU with both graph modes. NVIDIA TP2 Decode graphs and PP2 eager have lifecycle validation; fixed-size Prefill graphs remain MetaX TP1 only. Remote KV, PP graphs, speculative decoding and hybrid/MoE/multimodal models are outside this scope. + +A C500 short ablation (Qwen3-4B, 2048 input/16 output, chunk512) measured single-request token intervals of 12.96 ms for chunk eager and 9.37 ms with Decode graphs. Adding Prefill graphs reduced TTFT from 247.97 to 241.37 ms. These samples precede the cache-policy integration and do not establish sustained serving throughput. Graphs add initialization cost and retained workspace; full additional memory usage was not measured. + +Integrated lifecycle results and limits: [cache-chunk-validation.md](cache-chunk-validation.md). diff --git a/docs/chunked-prefill.md b/docs/chunked-prefill.md new file mode 100644 index 000000000..8e6e6cfea --- /dev/null +++ b/docs/chunked-prefill.md @@ -0,0 +1,134 @@ +# Bounded chunked prefill + +`prefill_chunk_size=0` (default) preserves the existing scheduling path. Set a positive value through `EngineConfig`, `LLM`, `AsyncLLMEngine`, or `--prefill-chunk-size` on the inference server / `examples/test_infer.py` to bound each prefill dispatch. For example: + +```python +from infinilm.llm.llm import AsyncLLMEngine + +engine = AsyncLLMEngine( + model_path="/path/to/dense-text-model", + tensor_parallel_size=2, + cache_type="paged", + enable_graph=False, + prefill_chunk_size=512, +) +``` + +The effective prefill segment is at most `min(prefill_chunk_size, max_num_batched_tokens)` tokens. The supported parallel configurations are TP/PP=1/1, 2/1 and 1/2. PP=2 uses eager execution; TP=1/2 with PP=1 can combine eager Prefill and Decode graphs on CUDA-compatible devices. See [graph configuration](chunk-graphs.md). CLI parsing rejects other parallel sizes before starting a pipeline worker. Static cache, MLA, draft models, remote KV connectors, Mamba, MoE and multimodal inputs are excluded from this opt-in mode. TP=2 requires a working CUDA collective runtime (InfiniCCL built with CUDA/NCCL support); a CUDA model build alone does not establish that collectives are enabled. The direct-native examples `bench.py`, `llama.py` and `bench_videonsa.py` reject this option because they bypass the scheduler. Native validation used Qwen2.5-1.5B FP16 with paged attention on an A6000; this is not validation of every dense architecture/backend. + +## Scheduling and ownership + +Successful dispatches rotate among decode, prefill continuation, and new admission; empty or capacity-blocked phases are skipped. Each continuously eligible phase receives an opportunity within three successful dispatches. This is a bound on dispatch opportunities, not milliseconds or per-request admission latency. Decode batches retain `max_batch_size`; prefill dispatches contain one request. Continuations rotate FIFO. A capacity-deferred admission returns to the waiting queue. + +Admission still reserves the full prompt's KV pages and accounts for the future decode capacity of partial requests. This change does not reduce prompt KV reservation. Existing prefix lookup pins only published full blocks; failed admission releases its temporary references. After successful model execution, only the completed span can be published. Cancellation returns all owned references, leaving computed full pages reusable. Intermediate chunks advance the computed boundary without emitting or appending sampled tokens; only the final chunk enters normal generation/EOS handling. Intermediate chunks use an internal `prefill_only` forward: the text causal-LM template skips the LM head, the worker skips sampling and token transfer, and the runner returns no sampled IDs. Final chunks and decode retain normal output handling. + +This work follows the input-slicing/continuation concepts in [#371](https://github.com/InfiniTensor/InfiniLM/pull/371), adapted to the target branch's per-request KV publication. At audited revision `02419ee3`, #371's executable phase order favors waiting, then continuation, then decode (with periodic forced continuation), so sustained admissions can defer decode. The new mode adds explicit phase rotation, per-request publication and cancellation handling. [#571](https://github.com/InfiniTensor/InfiniLM/pull/571), audited at `6683db7e`, orders waiting admissions with priority/aging and addresses a separate concern. This is not a claim of first implementing chunked prefill, nor an integration of those two PRs. + +## Validation and tradeoffs + +Run native-free regressions with Python 3.10+ and `janus`, `xxhash` installed: + +```sh +python -m unittest discover -s test/llm -p 'test_chunk_*.py' +``` + +The chunk tests cover configuration forwarding/exclusions, disabled/static paths, offsets including non-page-aligned chunks, shared prefixes, publication boundaries, EOS/final output, queued and in-flight cancellation, generation hashing, capacity/pin rollback and phase progress. + +One short A6000 run used eager Qwen2.5-1.5B FP16, 256-token pages, 128 pages, batch limit 2, prefix caching off, and three requests: an active 128-token prompt generating 128 tokens, an arriving 8192-token prompt generating 16, and a late 128-token prompt generating 16. With 512-token chunks: + +| Measurement | Disabled | Chunk 512 | +|---|---:|---:| +| Active request maximum output gap | 1804.46 ms | 227.92 ms | +| Active request p95 output gap | 25.64 ms | 136.68 ms | +| Late short request first token | 1781.56 ms | 77.08 ms | +| Long request first token | 1790.27 ms | 2098.04 ms | +| All three requests finish | 2.928 s | 3.015 s | + +All 160 generated token IDs matched. These are single-run delivery timestamps, not stable throughput or universal speedup estimates. Breaking one long stall into several smaller stalls improves the maximum gap while worsening p95 in this trace. The late request starts earlier but also encounters gaps while the long prefill continues. + +A separate native lifecycle check used 300-token chunks across 256-token pages, a 1027-token prompt, cancellation after the first chunk, reuse of its 256-token published prefix, and repeat reuse of a 1024-token prefix. Ownership, publication bounds, no intermediate output and completion checks passed. **Strict greedy sequence equality failed** between the partial-prefix/chunk path and full-prefix reuse at generated index 6. At identical prompt/prior tokens, the competing FP16 logits were tied at 16.015625 in the first path, versus 16.03125 and 16.015625 on reuse. Full-prefix repeats were stable, and a normal-forward rerun matched the diagnostic outputs. This is evidence of sensitivity to small numerical differences across execution shapes, not a proof of general numerical equivalence. Preserve this failed strict comparison when evaluating the feature; no model-quality benchmark is claimed. + + +## TP=2 execution contract + +The scheduler owns one request state and one logical page table. `InferEngine::forward` fans out the same input to both rank workers; each worker copies positions, sequence lengths, offsets, block tables and slots to its own device and sets its thread-local attention metadata. Dense tensor parallelism partitions attention heads while retaining the token coordinates. The existing segmented processor inputs therefore express the same prefill span on both ranks; the TP=2 extension changes the configuration guards and adds native validation, without a new native slicing implementation. + +Engine-owned KV writes, reads and subsequent reuse are ordered on persistent device streams. Worker `wait()` is not an all-device completion barrier: rank zero synchronizes after forward (after sampling on the ordinary output path), while another rank can report completion after submission. The supported serialized engine path preserves stream ordering for later reuse. Cross-stream KV transfer and overlap are outside this contract. No additional global device synchronization is inserted in the production path. + +Run explicit native checks with an FP16 dense model and a working CUDA/NCCL runtime: + +```sh +CUDA_VISIBLE_DEVICES=0,1 python test/llm/check_chunk_tp.py --model /path/to/model --tp 2 --chunk-size 300 --output /tmp/chunk-tp2.json +CUDA_VISIBLE_DEVICES=0,1 python test/llm/benchmark_chunk_prefill.py --model /path/to/model --tp 2 --chunk-size 0 --output /tmp/mixed-off.json +CUDA_VISIBLE_DEVICES=0,1 python test/llm/benchmark_chunk_prefill.py --model /path/to/model --tp 2 --chunk-size 512 --output /tmp/mixed-512.json +``` + +The correctness script watches the first and last local attention layers on each rank/stage. Before every prefill it fills scheduled K/V slots with NaNs, then verifies all scheduled values are finite and values outside the span are unchanged. These probes synchronize/copy KV only in this script; the benchmark does not use them. TP=2 chunk 300, TP=1 chunk 300, TP=2 chunk disabled and TP=2 chunk 300 with prefix caching disabled all passed. They cover non-page-aligned spans, partial and full prefix reuse, shared references, cancellation and full capacity recovery. The same completed prompts generated identical tokens across these four fixtures. Controlled cancellation after forward covers a final segment with caching enabled and an intermediate segment with caching disabled; it is not a network cancellation race test. + +## TP=2 short mixed-request measurements + +Two A6000s, Qwen2.5-1.5B FP16, eager paged attention, the same three-request workload above, prefix caching disabled. Each configuration ran three times; entries are medians of per-run metrics. Disabled/512 runs alternated order; 1024 ran subsequently, so shared-machine drift is not fully controlled. + +| Measurement | Disabled | Chunk 512 | Chunk 1024 | +|---|---:|---:|---:| +| Active maximum output gap | 1022.94 ms | 142.74 ms | 239.21 ms | +| Active p95 output gap | 24.88 ms | 92.68 ms | 63.62 ms | +| Late short request first token | 1001.54 ms | 68.16 ms | 139.44 ms | +| Long request first token | 1012.17 ms | 1435.76 ms | 1210.33 ms | +| Finite-window output throughput | 75.27 token/s | 62.83 token/s | 68.06 token/s | + +Chunk 512 reduced maximum output gap by 86.0% and late TTFT by 93.2%, with 41.8% longer long-request TTFT and 16.5% lower output throughput. Chunk 1024 reduced those gaps by 76.6% and 86.1%, with 19.6% longer long TTFT and 9.6% lower throughput. Both worsened active p95: one long stall becomes multiple shorter stalls. Throughput is 160 delivered output tokens divided by elapsed time from earliest request submission to last token; loading and warmup are excluded. These short shared-machine measurements do not establish sustained serving throughput or an optimal chunk size. + +All nine TP=2 runs matched the same 160 baseline token IDs. Traces confirm 16 or 8 long-prefill segments, active decode progress between segments and late first-token delivery before the long prefill finished. Prefer 512 when reducing the largest pauses matters most; 1024 is a candidate for a smaller throughput penalty. The default remains zero. Prefill/decode are separate dispatches, not a fused mixed batch, and the original measurements in this table predate the output-suppression optimization below. + +The prior strict numerical failure was also reproduced under TP=2 and remains **strict_mismatch**. At generated index 6 with identical prior tokens, the partial-prefix path selected token 220 with logit 16.03125 versus token 13 at 16.015625; full-prefix reuse tied both at 16.015625 and selected 13. Two full-prefix repeats agreed. This supports a narrow FP16 execution-shape sensitivity diagnosis; it does not prove universal output equivalence or model-quality preservation. Review this opt-in extension with that limitation visible. + + +## Avoid unused intermediate outputs + +The internal `prefill_only` flag is enabled automatically only for non-final chunks. It defaults to false in native inputs and is propagated to every TP rank. The shared text causal-LM template computes the model/KV state and omits last-token selection and the vocabulary projection. The rank worker omits GPU sampling, token D2H transfer and retained output tensors. The Python forward returns `None`; the runner returns an empty sampled-ID list, which the existing intermediate-chunk lifecycle handles before normal output-count validation. Other model implementations can still compute logits internally; only the shared text template's LM-head bypass was implemented here. + +Rank zero still synchronizes its device stream before returning. The optimization does not relax publication/cancellation completion or introduce cross-stream overlap. To preserve the previous stochastic sampling progression, the worker still makes one identical `uniform_real_distribution` RNG draw per request and discards it. This is a source-level equivalence argument; greedy tests do not establish stochastic output equality. Native guards reject all-position sampling and missing request offsets. For PP, all stages receive the same intermediate flag, finish their activation transfers, and skip the sampled-token exchange. The coordinator waits for each stage before publishing or reclaiming KV pages. Intermediate chunks run eagerly unless the opt-in fixed-size MetaX Prefill graph matches. + +**Rebuild the InfiniLM native extension when using this change.** InfiniCore does not need a source change. Additional checks: + +```sh +python -m unittest discover -s test/llm -p 'test_chunk_*.py' +CUDA_VISIBLE_DEVICES=0,1 python test/llm/check_chunk_output.py --model /path/to/model --tp 2 --chunk-size 300 --output /tmp/chunk-output.json +CUDA_VISIBLE_DEVICES=0,1 python test/llm/benchmark_chunk_prefill.py --model /path/to/model --tp 2 --chunk-size 512 --legacy-chunk-output --output /tmp/legacy-output.json +CUDA_VISIBLE_DEVICES=0,1 python test/llm/benchmark_chunk_prefill.py --model /path/to/model --tp 2 --chunk-size 512 --output /tmp/skip-output.json +``` + +`--legacy-chunk-output` is a benchmark control: it restores intermediate logits, sampling, transfer and conversion using the same compiled binary and unchanged schedule. It is not an engine policy option. The CPU tests include intermediate/final/decode forwarding and cancellation with an empty output. Native TP1/TP2 and prefix-off checks confirm absent intermediate raw outputs, valid final outputs and unchanged KV lifecycle; invalid native calls are rejected without preventing subsequent valid inference. + +Three alternating TP2 chunk512 comparisons on the same short workload gave these per-run medians: + +| Measurement | Legacy output work | Skip intermediate output | +|---|---:|---:| +| Total long-prefill dispatch execution | 1202.99 ms | 1175.69 ms | +| Intermediate long-prefill dispatch execution | 1077.84 ms | 1051.54 ms | +| Long request TTFT | 1437.11 ms | 1426.41 ms | +| Active maximum output gap | 141.38 ms | 140.92 ms | +| Active p95 output gap | 93.97 ms | 92.33 ms | +| Late short request TTFT | 75.52 ms | 75.26 ms | +| Finite-window output throughput | 64.19 token/s | 62.87 token/s | + +Measured long-prefill execution decreased 2.3%, while overall throughput did **not** improve in the median (-2.0%). Per-run throughput ranged 61.05–68.14 versus 61.17–67.19 token/s, with changes in both directions between paired runs. These measurements support removing redundant work, not a stable end-to-end throughput gain or recovery of the earlier chunking penalty. All six runs matched the same 160 baseline tokens, retained 16 long-prefill segments and let active decode and late admission progress. The preserved near-tied FP16 fixture still reports `strict_mismatch`; this optimization does not resolve that numerical limitation. + + +A separate same-input CUDA/NVTX replay confirmed that across 15 intermediate chunks on two ranks, the optimization removed 30 LM-head GEMVs, 30 last-token selections, 45 sampling-related kernels and 15 token D2H copies. Paged-prefill attention and NCCL invocation counts stayed at 840 and 1680. They accounted for approximately 73% and 12% of summed kernel duration across both GPUs in the legacy replay; those sums are not wall-clock shares. The trace supports targeting paged-prefill attention efficiency and batch scheduling for further throughput work, rather than expecting the small sampling cost to explain the entire chunking penalty. Replay/instrumentation timings are excluded from the serving comparison. + +## Integrated cache and parallel validation + +`prefix_cache_policy="lru"` is the default; `"slru"` enables bounded probation/protected queues. Chunk admission records prefix hits only after allocation succeeds, so capacity-deferred requests cannot promote cache entries. The same page manager handles eager and graph execution. + +On two A6000s, Qwen2.5-1.5B FP16 passed 13 combinations covering single-device/TP2 eager and Decode graphs, PP2 eager, LRU/SLRU and prefix caching disabled. Completed shared/repeated requests matched one baseline token sequence, first/last local KV layers passed poisoned-slot checks, and all references returned to zero. PP worker checks separately cover the second stage. This finite set does not remove the FP16 numerical limitation documented above. + +Build InfiniCore with `--graph=y` for device graphs and CUDA/NCCL collective support for TP/PP. A build without device graphs can replay recorded operators on the host; an enabled LM flag alone does not demonstrate CUDA graph execution. The native test can check actual launches with an optional counter: + +```sh +g++ -std=c++17 -shared -fPIC -I"$INFINI_ROOT/include" test/llm/graph_counter.cc -ldl -o /tmp/infini-graph-counter.so +LD_PRELOAD=/tmp/infini-graph-counter.so CUDA_VISIBLE_DEVICES=0,1 python test/llm/check_chunk_tp.py --model /path/to/fp16-model --tp 2 --chunk-size 300 --graph --policy slru --output /tmp/tp2-graph.json +``` + +For PP, start the same checker twice using separate `CUDA_VISIBLE_DEVICES` values, `--tp 1 --pp 2`, a shared `--port`, and `--stage 1` on the worker. Both processes need their own `--output` file. PP graphs and combined TP2/PP2 are excluded. diff --git a/docs/validation/cache-chunk-graphs.json b/docs/validation/cache-chunk-graphs.json new file mode 100644 index 000000000..c9caafc4d --- /dev/null +++ b/docs/validation/cache-chunk-graphs.json @@ -0,0 +1,278 @@ +{ + "date": "2026-09-16", + "cpu_tests": 108, + "nvidia": { + "model": "Qwen2.5-1.5B", + "dtype": "FP16", + "hardware": "2 x A6000 (shared, not exclusive)", + "cases": [ + { + "case": "tp1-pp1-c0-g0-lru-off0", + "passed": true, + "tp": 1, + "pp": 1, + "chunk": 0, + "graph": false, + "policy": "lru", + "prefix_enabled": true, + "kv_span_checks": 6, + "device_graph_launches": 0, + "usable_blocks": 16, + "baseline_tokens_equal": true + }, + { + "case": "tp1-pp1-c300-g0-lru-off0", + "passed": true, + "tp": 1, + "pp": 1, + "chunk": 300, + "graph": false, + "policy": "lru", + "prefix_enabled": true, + "kv_span_checks": 12, + "device_graph_launches": 0, + "usable_blocks": 16, + "baseline_tokens_equal": true + }, + { + "case": "tp1-pp1-c300-g1-lru-off0", + "passed": true, + "tp": 1, + "pp": 1, + "chunk": 300, + "graph": true, + "policy": "lru", + "prefix_enabled": true, + "kv_span_checks": 12, + "device_graph_launches": 14, + "usable_blocks": 16, + "baseline_tokens_equal": true + }, + { + "case": "tp1-pp1-c300-g1-slru-off0", + "passed": true, + "tp": 1, + "pp": 1, + "chunk": 300, + "graph": true, + "policy": "slru", + "prefix_enabled": true, + "kv_span_checks": 12, + "device_graph_launches": 14, + "usable_blocks": 16, + "baseline_tokens_equal": true + }, + { + "case": "tp1-pp2-c0-g0-lru-off0", + "passed": true, + "tp": 1, + "pp": 2, + "chunk": 0, + "graph": false, + "policy": "lru", + "prefix_enabled": true, + "kv_span_checks": 54, + "device_graph_launches": 0, + "usable_blocks": 16, + "baseline_tokens_equal": true + }, + { + "case": "tp1-pp2-c300-g0-lru-off0", + "passed": true, + "tp": 1, + "pp": 2, + "chunk": 300, + "graph": false, + "policy": "lru", + "prefix_enabled": true, + "kv_span_checks": 52, + "device_graph_launches": 0, + "usable_blocks": 16, + "baseline_tokens_equal": true + }, + { + "case": "tp1-pp2-c300-g0-slru-off0", + "passed": true, + "tp": 1, + "pp": 2, + "chunk": 300, + "graph": false, + "policy": "slru", + "prefix_enabled": true, + "kv_span_checks": 52, + "device_graph_launches": 0, + "usable_blocks": 16, + "baseline_tokens_equal": true + }, + { + "case": "tp1-pp2-c300-g0-slru-off1", + "passed": true, + "tp": 1, + "pp": 2, + "chunk": 300, + "graph": false, + "policy": "slru", + "prefix_enabled": false, + "kv_span_checks": 72, + "device_graph_launches": 0, + "usable_blocks": 16, + "baseline_tokens_equal": true + }, + { + "case": "tp2-pp1-c0-g0-lru-off0", + "passed": true, + "tp": 2, + "pp": 1, + "chunk": 0, + "graph": false, + "policy": "lru", + "prefix_enabled": true, + "kv_span_checks": 12, + "device_graph_launches": 0, + "usable_blocks": 16, + "baseline_tokens_equal": true + }, + { + "case": "tp2-pp1-c300-g0-lru-off0", + "passed": true, + "tp": 2, + "pp": 1, + "chunk": 300, + "graph": false, + "policy": "lru", + "prefix_enabled": true, + "kv_span_checks": 24, + "device_graph_launches": 0, + "usable_blocks": 16, + "baseline_tokens_equal": true + }, + { + "case": "tp2-pp1-c300-g1-lru-off0", + "passed": true, + "tp": 2, + "pp": 1, + "chunk": 300, + "graph": true, + "policy": "lru", + "prefix_enabled": true, + "kv_span_checks": 24, + "device_graph_launches": 28, + "usable_blocks": 16, + "baseline_tokens_equal": true + }, + { + "case": "tp2-pp1-c300-g1-slru-off0", + "passed": true, + "tp": 2, + "pp": 1, + "chunk": 300, + "graph": true, + "policy": "slru", + "prefix_enabled": true, + "kv_span_checks": 24, + "device_graph_launches": 28, + "usable_blocks": 16, + "baseline_tokens_equal": true + }, + { + "case": "tp2-pp1-c300-g1-slru-off1", + "passed": true, + "tp": 2, + "pp": 1, + "chunk": 300, + "graph": true, + "policy": "slru", + "prefix_enabled": false, + "kv_span_checks": 44, + "device_graph_launches": 28, + "usable_blocks": 16, + "baseline_tokens_equal": true + } + ] + }, + "metax": { + "models": [ + "Qwen3-0.6B", + "Qwen3-4B" + ], + "dtype": "BF16", + "hardware": "C500 50% compute slice, 32000 MiB", + "cases": [ + { + "case": "c500-Qwen3-0.6B-lru-p0-off0", + "passed": true, + "policy": "lru", + "kv_span_checks": 10, + "device_graph_launches": 14, + "usable_blocks": 16, + "repeat_tokens_equal": true + }, + { + "case": "c500-Qwen3-0.6B-lru-p512-off0", + "passed": true, + "policy": "lru", + "kv_span_checks": 10, + "device_graph_launches": 16, + "usable_blocks": 16, + "repeat_tokens_equal": true + }, + { + "case": "c500-Qwen3-0.6B-slru-p0-off0", + "passed": true, + "policy": "slru", + "kv_span_checks": 10, + "device_graph_launches": 14, + "usable_blocks": 16, + "repeat_tokens_equal": true + }, + { + "case": "c500-Qwen3-0.6B-slru-p512-off0", + "passed": true, + "policy": "slru", + "kv_span_checks": 10, + "device_graph_launches": 16, + "usable_blocks": 16, + "repeat_tokens_equal": true + }, + { + "case": "c500-Qwen3-0.6B-slru-p512-off1", + "passed": true, + "policy": "slru", + "kv_span_checks": 18, + "device_graph_launches": 21, + "usable_blocks": 16, + "repeat_tokens_equal": true + }, + { + "case": "c500-Qwen3-4B-slru-p512-off0", + "passed": true, + "policy": "slru", + "kv_span_checks": 10, + "device_graph_launches": 16, + "usable_blocks": 16, + "repeat_tokens_equal": true + } + ] + }, + "core_adapter_cases": 12, + "tp2_mixed_short_check": [ + { + "graph": false, + "active_itl_median_ms": 6.079213693737984, + "long_ttft_ms": 175.9560825303197, + "window_tokens_s": 161.71175184476675 + }, + { + "graph": true, + "active_itl_median_ms": 5.886984057724476, + "long_ttft_ms": 188.25936783105135, + "window_tokens_s": 161.0190469587565 + } + ], + "limitations": [ + "13+6 lifecycle configurations are not a full model/shape/platform matrix.", + "PP graphs, TP2/PP2, distributed remote KV, MoE and quantization are excluded.", + "Historical near-tied FP16 prefix-shape token mismatch remains documented.", + "One mixed window per mode; no sustained-throughput or significance claim." + ] +} diff --git a/examples/bench.py b/examples/bench.py index 17bfe1a6d..6d95db09c 100644 --- a/examples/bench.py +++ b/examples/bench.py @@ -753,6 +753,10 @@ def run( if __name__ == "__main__": cfg = BaseConfig() + if cfg.prefill_chunk_size: + raise ValueError( + "Chunked prefill requires the LLM engine; use test_infer.py or the server." + ) logging.basicConfig( level=getattr(logging, cfg.log_level.upper(), logging.INFO), format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", @@ -903,9 +907,11 @@ def run( ) cfg.max_cache_len = max( max_benchmark_cache_len, - next(iter(cases_dict.values()))["input_len"] + _WARMUP_DECODE_LEN - if cfg.warmup - else 0, + ( + next(iter(cases_dict.values()))["input_len"] + _WARMUP_DECODE_LEN + if cfg.warmup + else 0 + ), ) cfg.attn = attn_backend if enable_paged_attn: @@ -945,9 +951,11 @@ def run( block_size=cfg.block_size, max_cache_len=max( max_benchmark_cache_len, - next(iter(cases_dict.values()))["input_len"] + _WARMUP_DECODE_LEN - if cfg.warmup - else 0, + ( + next(iter(cases_dict.values()))["input_len"] + _WARMUP_DECODE_LEN + if cfg.warmup + else 0 + ), ), temperature=cfg.temperature, top_p=cfg.top_p, diff --git a/examples/bench_videonsa.py b/examples/bench_videonsa.py index 8c0338a83..6a2770e6e 100644 --- a/examples/bench_videonsa.py +++ b/examples/bench_videonsa.py @@ -179,6 +179,10 @@ def run_case(model, tokenizer, cfg, video_payload, batch_size, input_len, output def main(): cfg = BaseConfig() + if cfg.prefill_chunk_size: + raise ValueError( + "Chunked prefill requires the LLM engine; use test_infer.py or the server." + ) cfg.enable_prefix_caching = False normalize_bench_defaults(cfg) diff --git a/examples/llama.py b/examples/llama.py index a3f0f11f8..57ff7781e 100644 --- a/examples/llama.py +++ b/examples/llama.py @@ -1,13 +1,13 @@ -import infinicore -from transformers import AutoTokenizer -from tokenizers import decoders as _dec -from infinilm.modeling_utils import get_model_state_dict -import infinilm -import argparse +import os import sys import time -import os + +import infinicore +import infinilm from infinilm.base_config import BaseConfig +from infinilm.modeling_utils import get_model_state_dict +from tokenizers import decoders as _dec +from transformers import AutoTokenizer sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../python")) @@ -103,6 +103,10 @@ def test( if __name__ == "__main__": cfg = BaseConfig() + if cfg.prefill_chunk_size: + raise ValueError( + "Chunked prefill requires the LLM engine; use test_infer.py or the server." + ) device_str = cfg.get_device_str(cfg.device) diff --git a/examples/test_infer.py b/examples/test_infer.py index f17a8baa0..da6e2956e 100644 --- a/examples/test_infer.py +++ b/examples/test_infer.py @@ -41,6 +41,9 @@ def test( use_legacy_moe=False, enable_prefix_caching=True, pre_transpose=False, + prefix_cache_policy="lru", + prefix_cache_protected_ratio=0.8, + prefill_chunk_size=0, ): model_path = os.path.expanduser(model_path) # ---------------------------------------------------------------------------- # @@ -77,6 +80,9 @@ def test( use_legacy_moe=use_legacy_moe, enable_prefix_caching=enable_prefix_caching, pre_transpose=pre_transpose, + prefix_cache_policy=prefix_cache_policy, + prefix_cache_protected_ratio=prefix_cache_protected_ratio, + prefill_chunk_size=prefill_chunk_size, ) conversations = [ @@ -185,4 +191,7 @@ def test( use_legacy_moe=cfg.use_legacy_moe, enable_prefix_caching=cfg.enable_prefix_caching, pre_transpose=cfg.pre_transpose, + prefix_cache_policy=cfg.prefix_cache_policy, + prefix_cache_protected_ratio=cfg.prefix_cache_protected_ratio, + prefill_chunk_size=cfg.prefill_chunk_size, ) diff --git a/python/infinilm/base_config.py b/python/infinilm/base_config.py index 4ae7665c0..2f46c3927 100644 --- a/python/infinilm/base_config.py +++ b/python/infinilm/base_config.py @@ -8,6 +8,17 @@ from infinilm.moe_config import MOE_EP_BACKEND_HELP +def parse_nonnegative_int(value: str) -> int: + """Parse an integer option that uses zero to disable its feature.""" + try: + result = int(value) + except ValueError: + raise argparse.ArgumentTypeError("value must be a nonnegative integer") + if result < 0: + raise argparse.ArgumentTypeError("value must be a nonnegative integer") + return result + + def parse_list(value: str): """Parse parse_list argument: can be a single int or a list of ints. @@ -76,6 +87,18 @@ def __init__(self): self.enable_graph = self.args.enable_graph self.enable_paged_attn = self.args.enable_paged_attn self.enable_prefix_caching = self.args.enable_prefix_caching + self.prefix_cache_policy = self.args.prefix_cache_policy + self.prefix_cache_protected_ratio = self.args.prefix_cache_protected_ratio + if not 0 < self.prefix_cache_protected_ratio < 1: + self.parser.error("--prefix-cache-protected-ratio must be between 0 and 1") + self.prefill_chunk_size = self.args.prefill_chunk_size + # Worker entrypoints branch before constructing the host LLM engine. + if self.prefill_chunk_size and ( + (self.tp, self.pp) not in {(1, 1), (2, 1), (1, 2)} + ): + self.parser.error("--prefill-chunk-size requires TP/PP=1/1, 2/1 or 1/2") + if self.prefill_chunk_size and self.draft_model: + self.parser.error("--prefill-chunk-size does not support --draft-model") self.use_mla = self.args.use_mla self.pre_transpose = self.args.pre_transpose self.num_blocks = self.args.num_blocks @@ -274,6 +297,24 @@ def _add_common_args(self): default=True, help="disable KV prefix cache reuse", ) + self.parser.add_argument( + "--prefix-cache-policy", + choices=["lru", "slru"], + default="lru", + help="paged prefix-cache eviction policy", + ) + self.parser.add_argument( + "--prefix-cache-protected-ratio", + type=float, + default=0.8, + help="fraction of paged blocks protected by SLRU (strictly between 0 and 1)", + ) + self.parser.add_argument( + "--prefill-chunk-size", + type=parse_nonnegative_int, + default=0, + help="maximum prompt tokens per prefill step (0 disables; paged TP/PP=1/1, 2/1 or 1/2; graphs require PP=1)", + ) self.parser.add_argument( "--num-blocks", type=int, default=512, help="number of KV cache blocks" ) diff --git a/python/infinilm/config/engine_config.py b/python/infinilm/config/engine_config.py index bccb7e758..cdab542c6 100644 --- a/python/infinilm/config/engine_config.py +++ b/python/infinilm/config/engine_config.py @@ -28,6 +28,9 @@ class EngineConfig: block_size: Size of each KV cache block (only for paged cache). max_cache_len: Maximum sequence length (only for static cache). enable_prefix_caching: Whether to reuse KV cache across requests. + prefix_cache_policy: Paged prefix-cache eviction policy ('lru' or 'slru'). + prefix_cache_protected_ratio: Fraction of paged blocks protected by SLRU. + prefill_chunk_size: Maximum prompt tokens per prefill step; 0 disables chunking. temperature: Default sampling temperature. top_p: Default top-p sampling parameter. top_k: Default top-k sampling parameter. @@ -69,8 +72,49 @@ class EngineConfig: use_legacy_moe: bool = False kv_transfer_config: Optional[KVTransferConfig] = None enable_prefix_caching: bool = True + prefix_cache_policy: str = "lru" + prefix_cache_protected_ratio: float = 0.8 + + prefill_chunk_size: int = 0 def __post_init__(self) -> None: + if self.prefix_cache_policy not in {"lru", "slru"}: + raise ValueError("prefix_cache_policy must be either 'lru' or 'slru'") + if not 0 < self.prefix_cache_protected_ratio < 1: + raise ValueError("prefix_cache_protected_ratio must be between 0 and 1") + if self.prefix_cache_policy == "slru" and self.cache_type != "paged": + raise ValueError("prefix_cache_policy='slru' requires cache_type='paged'") + + if ( + isinstance(self.prefill_chunk_size, bool) + or not isinstance(self.prefill_chunk_size, int) + or self.prefill_chunk_size < 0 + ): + raise ValueError("prefill_chunk_size must be a nonnegative integer") + if self.prefill_chunk_size: + if self.cache_type != "paged": + raise ValueError("prefill_chunk_size requires cache_type='paged'") + if (self.tensor_parallel_size, self.pipeline_parallel_size) not in { + (1, 1), + (2, 1), + (1, 2), + }: + raise ValueError("prefill_chunk_size requires TP/PP=1/1, 2/1 or 1/2") + if self.enable_graph and ( + self.pipeline_parallel_size != 1 + or self.device != "cuda" + or self.attn_backend not in {"default", "paged-attn", "flash-attn"} + ): + raise ValueError( + "prefill_chunk_size with enable_graph requires PP=1, " + "device='cuda' and a supported paged attention backend" + ) + if self.use_mla: + raise ValueError("prefill_chunk_size does not support MLA") + if self.draft_model_path: + raise ValueError("prefill_chunk_size does not support draft models") + if self.kv_transfer_config and self.kv_transfer_config.kv_connector: + raise ValueError("prefill_chunk_size does not support KV transfer") if self.num_draft_tokens < 1: raise ValueError("num_draft_tokens must be >= 1") if self.pipeline_parallel_size < 1: diff --git a/python/infinilm/infer_engine.py b/python/infinilm/infer_engine.py index 117d82f9a..c672c9a5c 100644 --- a/python/infinilm/infer_engine.py +++ b/python/infinilm/infer_engine.py @@ -275,6 +275,7 @@ def _build_input( visual_token_ranges=None, target_hidden_states=None, sample_all_positions=False, + prefill_only=False, temperature=None, top_k=None, top_p=None, @@ -333,6 +334,7 @@ def convert_tensor_list(tensor_list_): visual_token_ranges=visual_token_ranges, target_hidden_states=target_hidden_states, sample_all_positions=sample_all_positions, + prefill_only=prefill_only, temperature=temperature, top_k=top_k, top_p=top_p, @@ -358,6 +360,7 @@ def forward( image_req_ids=None, visual_token_ranges=None, target_hidden_states=None, + prefill_only=False, temperature=None, top_k=None, top_p=None, @@ -409,34 +412,34 @@ def convert_tensor_list(tensor_list_): tgt_sizes = convert_tensor_list(tgt_sizes) image_grid_thw = convert_tensor_list(image_grid_thw) - return infinicore.Tensor( - super() - .forward( - self._build_input( - input_ids, - position_ids=position_ids, - past_kv_lengths=past_kv_lengths, - total_kv_lengths=total_kv_lengths, - input_offsets=input_offsets, - cu_seqlens=cu_seqlens, - block_tables=block_tables, - slot_mapping=slot_mapping, - mamba_init_state_indices=mamba_init_state_indices, - mamba_final_state_indices=mamba_final_state_indices, - pixel_values=pixel_values, - image_bound=image_bound, - tgt_sizes=tgt_sizes, - image_grid_thw=image_grid_thw, - image_req_ids=image_req_ids, - visual_token_ranges=visual_token_ranges, - target_hidden_states=target_hidden_states, - temperature=temperature, - top_k=top_k, - top_p=top_p, - ) + output = super().forward( + self._build_input( + input_ids, + position_ids=position_ids, + past_kv_lengths=past_kv_lengths, + total_kv_lengths=total_kv_lengths, + input_offsets=input_offsets, + cu_seqlens=cu_seqlens, + block_tables=block_tables, + slot_mapping=slot_mapping, + mamba_init_state_indices=mamba_init_state_indices, + mamba_final_state_indices=mamba_final_state_indices, + pixel_values=pixel_values, + image_bound=image_bound, + tgt_sizes=tgt_sizes, + image_grid_thw=image_grid_thw, + image_req_ids=image_req_ids, + visual_token_ranges=visual_token_ranges, + target_hidden_states=target_hidden_states, + prefill_only=prefill_only, + temperature=temperature, + top_k=top_k, + top_p=top_p, ) - .output_ids ) + if prefill_only: + return None + return infinicore.Tensor(output.output_ids) except BaseException as e: handle_oom_and_exit(e) raise diff --git a/python/infinilm/llm/cache_manager.py b/python/infinilm/llm/cache_manager.py index 6c045e4ee..f58a33afb 100644 --- a/python/infinilm/llm/cache_manager.py +++ b/python/infinilm/llm/cache_manager.py @@ -1,6 +1,6 @@ """Paged KV cache allocation and source-agnostic prefix lookup.""" -from collections import deque +from collections import OrderedDict, deque from collections.abc import Sequence from typing import Dict, List, Set @@ -71,16 +71,32 @@ def get_num_free_blocks(self) -> int: class BlockManager: """Manage physical paged-cache blocks and published prefix hashes.""" - def __init__(self, num_blocks: int, block_size: int): + def __init__( + self, + num_blocks: int, + block_size: int, + prefix_cache_policy: str = "lru", + prefix_cache_protected_ratio: float = 0.8, + ): if num_blocks <= 0 or block_size <= 0: raise ValueError("num_blocks and block_size must be positive") + if prefix_cache_policy not in {"lru", "slru"}: + raise ValueError("`prefix_cache_policy` must be 'lru' or 'slru'.") + if not 0 < prefix_cache_protected_ratio < 1: + raise ValueError("`prefix_cache_protected_ratio` must be between 0 and 1.") self.num_blocks = num_blocks self.block_size = block_size + self.prefix_cache_policy = prefix_cache_policy + self._protected_capacity = int(num_blocks * prefix_cache_protected_ratio) self.blocks: List[Block] = [Block(i) for i in range(num_blocks)] self.hash_to_block_ids: Dict[BlockHash, Set[int]] = {} self.free_block_ids: deque[int] = deque(range(num_blocks)) self.used_block_ids: Set[int] = set() + self._evictable_blocks: OrderedDict[int, None] = OrderedDict() + self._protected_evictable_blocks: OrderedDict[int, None] = OrderedDict() + # Membership survives pinning so active requests cannot bypass the cap. + self._protected_blocks: OrderedDict[int, None] = OrderedDict() def __repr__(self) -> str: return ( @@ -115,6 +131,9 @@ def _deallocate_block(self, block_id: int) -> None: f"Block {block_id} ref_count not zero, cannot deallocate" ) self._remove_block_hash(block) + self._evictable_blocks.pop(block_id, None) + self._protected_evictable_blocks.pop(block_id, None) + self._protected_blocks.pop(block_id, None) block.free() self.used_block_ids.remove(block_id) self.free_block_ids.append(block_id) @@ -126,12 +145,11 @@ def get_num_free_blocks(self) -> int: return len(self.free_block_ids) def get_total_usable_blocks(self) -> int: - freeable_used_blocks = sum( - 1 - for block_id in self.used_block_ids - if self.blocks[block_id].ref_count == 0 + return ( + len(self.free_block_ids) + + len(self._evictable_blocks) + + len(self._protected_evictable_blocks) ) - return len(self.free_block_ids) + freeable_used_blocks def get_computed_blocks( self, @@ -151,10 +169,30 @@ def get_computed_blocks( block_id = next(iter(block_ids)) block = self.blocks[block_id] assert block.hash == block_hash and block_id in self.used_block_ids + if block.ref_count == 0: + if block_id in self._protected_blocks: + del self._protected_evictable_blocks[block_id] + else: + del self._evictable_blocks[block_id] block.ref_count += 1 cached_block_table.append(block_id) return cached_block_table, len(cached_block_table) * self.block_size + def record_cache_hit(self, block_table: Sequence[int]) -> None: + """Promote locally matched blocks only after request admission succeeds.""" + if self.prefix_cache_policy != "slru": + return + for block_id in reversed(block_table): + block = self.blocks[block_id] + assert block.ref_count > 0 and block.hash != EMPTY_BLOCK_HASH + self._protected_blocks[block_id] = None + self._protected_blocks.move_to_end(block_id) + while len(self._protected_blocks) > self._protected_capacity: + block_id, _ = self._protected_blocks.popitem(last=False) + if self.blocks[block_id].ref_count == 0: + del self._protected_evictable_blocks[block_id] + self._evictable_blocks[block_id] = None + def allocate_slots( self, num_new_tokens: int, @@ -328,23 +366,28 @@ def append_slot( return block_table, last_block_id * self.block_size + offset def free_blocks(self, block_table: Sequence[int]) -> None: - """Release request references while retaining computed blocks for reuse.""" + """Release references and retain only reusable computed blocks.""" for block_id in reversed(block_table): block = self.blocks[block_id] - assert block.ref_count > 0, "block ref_count must be greater than 0" + assert block.ref_count > 0, "Block reference count must be positive." block.ref_count -= 1 + if block.ref_count == 0: + if block.hash == EMPTY_BLOCK_HASH: + self._deallocate_block(block_id) + elif block_id in self._protected_blocks: + self._protected_blocks.move_to_end(block_id) + self._protected_evictable_blocks[block_id] = None + else: + self._evictable_blocks[block_id] = None def try_free_blocks(self, num_required: int) -> bool: - """Evict unreferenced blocks until the requested capacity is available.""" - to_free = [ - block_id - for block_id in self.used_block_ids - if self.blocks[block_id].ref_count == 0 - ] - for block_id in to_free: + """Reclaim probationary blocks before protected blocks, oldest first.""" + while not self.can_allocate(num_required): + candidates = self._evictable_blocks or self._protected_evictable_blocks + if not candidates: + break + block_id = next(iter(candidates)) self._deallocate_block(block_id) - if self.can_allocate(num_required): - return True return self.can_allocate(num_required) def update_blocks_slot( diff --git a/python/infinilm/llm/llm.py b/python/infinilm/llm/llm.py index 59d5a1eca..946c6aa28 100644 --- a/python/infinilm/llm/llm.py +++ b/python/infinilm/llm/llm.py @@ -42,6 +42,18 @@ def __init__(self, config: EngineConfig): self.config = config hf_config = read_hf_config(config.model_path) has_mamba_cache = model_uses_mamba_cache(hf_config) + if config.prefill_chunk_size: + text_config = hf_config.get("text_config", hf_config) + if ( + has_mamba_cache + or any( + text_config.get(key) + for key in ("num_experts", "num_local_experts", "n_routed_experts") + ) + or "vision_config" in hf_config + or "audio_config" in hf_config + ): + raise ValueError("Chunked prefill supports dense text models only.") if has_mamba_cache and config.enable_prefix_caching: model_type = hf_config["model_type"] raise RuntimeError( @@ -110,6 +122,9 @@ def __init__(self, config: EngineConfig): has_mamba_cache=has_mamba_cache, num_mamba_cache_blocks=num_mamba_cache_blocks, enable_prefix_caching=config.enable_prefix_caching, + prefix_cache_policy=config.prefix_cache_policy, + prefix_cache_protected_ratio=config.prefix_cache_protected_ratio, + prefill_chunk_size=config.prefill_chunk_size, ) logger.info(f"Using Paged KV Cache with num_blocks={config.num_blocks}") if has_mamba_cache: @@ -157,6 +172,19 @@ def step(self) -> tuple[bool, list[tuple]]: runner_output = self.model_runner.execute_model(scheduler_output) sampled_token_ids = runner_output.sampled_token_ids self.scheduler.update_from_output(runner_output) + end = getattr(scheduler_output, "prefill_end", None) + if end is not None: + req = scheduler_output.scheduled_requests[0] + req.num_computed_tokens = end + if end < req.get_prompt_length(): + self.scheduler.commit_computed_tokens(req, end) + if req.is_aborted() or req.is_finished(): + if not req.is_finished(): + req.mark_canceled() + self.scheduler.complete_requests([req]) + else: + self.scheduler.requeue_prefill(req) + return True, [] pending = self._update_requests( scheduler_output.scheduled_requests, sampled_token_ids, @@ -366,6 +394,9 @@ def __init__( skip_load: bool = False, use_legacy_moe: bool = False, enable_prefix_caching: bool = True, + prefix_cache_policy: str = "lru", + prefix_cache_protected_ratio: float = 0.8, + prefill_chunk_size: int = 0, ): """Initialize LLM. @@ -379,7 +410,10 @@ def __init__( max_tokens: Default maximum tokens to generate. num_blocks: Number of KV cache blocks (only for paged cache). block_size: Size of each KV cache block (only for paged cache). + prefill_chunk_size: Prompt tokens per prefill step; 0 disables chunking. max_cache_len: Maximum sequence length (only for static cache). + prefix_cache_policy: Paged prefix-cache eviction policy ('lru' or 'slru'). + prefix_cache_protected_ratio: Fraction of paged blocks protected by SLRU. temperature: Default sampling temperature. top_p: Default top-p sampling parameter. top_k: Default top-k sampling parameter. @@ -418,6 +452,9 @@ def __init__( skip_load=skip_load, use_legacy_moe=use_legacy_moe, enable_prefix_caching=enable_prefix_caching, + prefix_cache_policy=prefix_cache_policy, + prefix_cache_protected_ratio=prefix_cache_protected_ratio, + prefill_chunk_size=prefill_chunk_size, ) self.engine = LLMEngine(config) self.config = config @@ -594,6 +631,9 @@ def __init__( weight_load_mode: str = "async", use_legacy_moe: bool = False, enable_prefix_caching: bool = True, + prefix_cache_policy: str = "lru", + prefix_cache_protected_ratio: float = 0.8, + prefill_chunk_size: int = 0, ): """Initialize AsyncLLMEngine. @@ -607,7 +647,10 @@ def __init__( max_tokens: Default maximum tokens to generate. num_blocks: Number of KV cache blocks (only for paged cache). block_size: Size of each KV cache block (only for paged cache). + prefill_chunk_size: Prompt tokens per prefill step; 0 disables chunking. max_cache_len: Maximum sequence length (only for static cache). + prefix_cache_policy: Paged prefix-cache eviction policy ('lru' or 'slru'). + prefix_cache_protected_ratio: Fraction of paged blocks protected by SLRU. temperature: Default sampling temperature. top_p: Default top-p sampling parameter. top_k: Default top-k sampling parameter. @@ -651,6 +694,9 @@ def __init__( weight_load_mode=weight_load_mode, use_legacy_moe=use_legacy_moe, enable_prefix_caching=enable_prefix_caching, + prefix_cache_policy=prefix_cache_policy, + prefix_cache_protected_ratio=prefix_cache_protected_ratio, + prefill_chunk_size=prefill_chunk_size, ) self.engine = LLMEngine(config) self.config = config diff --git a/python/infinilm/llm/model_runner/model_runner.py b/python/infinilm/llm/model_runner/model_runner.py index a1696f848..499b108c0 100644 --- a/python/infinilm/llm/model_runner/model_runner.py +++ b/python/infinilm/llm/model_runner/model_runner.py @@ -221,6 +221,14 @@ def _model_forward(self, scheduler_output): if self.speculative_runner is not None: return self._model_forward_with_speculative(scheduler_output, model_input) + end = getattr(scheduler_output, "prefill_end", None) + prefill_only = ( + end is not None + and end < scheduler_output.scheduled_requests[0].get_prompt_length() + ) + if prefill_only: + model_input["prefill_only"] = True + # Wake every stage before stage 0 enters forward. Each worker receives # the same metadata and then blocks in its model on the activation from # the preceding stage. Stage 0 waits for all acknowledgements afterward. @@ -237,6 +245,8 @@ def _model_forward(self, scheduler_output): raise if self.pipeline_control is not None: self.pipeline_control.wait_forward() + if prefill_only: + return [] sampled_tokens_list = sampled_tokens.to_numpy().tolist() return sampled_tokens_list diff --git a/python/infinilm/llm/scheduler.py b/python/infinilm/llm/scheduler.py index c10b55f2f..dc178c0d1 100644 --- a/python/infinilm/llm/scheduler.py +++ b/python/infinilm/llm/scheduler.py @@ -4,6 +4,7 @@ import logging import queue +from collections import deque from typing import List, Optional import janus @@ -44,12 +45,14 @@ def __init__( scheduled_requests: List[InferenceRequest], is_prefill: bool = False, speculative_cache_ops: Optional[SpeculativeCacheOps] = None, + prefill_end: int | None = None, ): self.scheduled_requests = scheduled_requests self.num_requests = len(scheduled_requests) self.is_prefill = is_prefill self.speculative_cache_ops = speculative_cache_ops self.kv_connector_metadata = None + self.prefill_end = prefill_end class Scheduler: @@ -71,7 +74,16 @@ def __init__( has_mamba_cache: bool = False, num_mamba_cache_blocks: int | None = None, enable_prefix_caching: bool = True, + prefix_cache_policy: str = "lru", + prefix_cache_protected_ratio: float = 0.8, + prefill_chunk_size: int = 0, ): + if type(prefill_chunk_size) is not int or prefill_chunk_size < 0: + raise ValueError("`prefill_chunk_size` must be a nonnegative integer.") + if prefill_chunk_size and (connector is not None or has_mamba_cache): + raise ValueError("Chunked prefill does not support remote KV or Mamba.") + if prefill_chunk_size and max_num_batched_tokens <= 0: + raise ValueError("Chunked prefill requires a positive token budget.") self.waiting_queue = janus.Queue() self.running_queue = janus.Queue() self.max_batch_size = max_batch_size @@ -82,7 +94,12 @@ def __init__( self.pending_kv_decode_blocks: int = 0 self.remote_kv_requests: dict[str, InferenceRequest] = {} - self.cache_manager = BlockManager(num_blocks=num_blocks, block_size=block_size) + self.cache_manager = BlockManager( + num_blocks=num_blocks, + block_size=block_size, + prefix_cache_policy=prefix_cache_policy, + prefix_cache_protected_ratio=prefix_cache_protected_ratio, + ) self.has_mamba_cache = has_mamba_cache self.mamba_cache_manager = ( MambaCacheManager(num_mamba_cache_blocks or max(2, num_blocks // 4)) @@ -94,9 +111,16 @@ def __init__( self.max_num_batched_tokens = max_num_batched_tokens self.connector = connector self.enable_prefix_caching = enable_prefix_caching + self.prefill_chunk_size = prefill_chunk_size + self.chunking_queue: deque[InferenceRequest] = deque() + self._next_chunk_phase = 0 def add_request(self, request: InferenceRequest): if request is not None: + if self.prefill_chunk_size and request.has_multimodal_inputs: + raise ValueError( + "Chunked prefill does not support multimodal requests." + ) # TODO: Remove the multimodal exclusion once media-aware prefix # hashing and model-side cache-boundary handling are supported. request.initialize_block_hashes( @@ -128,6 +152,8 @@ def _exceeds_token_budget( def schedule(self) -> Optional[SchedulerOutput]: """Schedule and return batch of requests to execute.""" + if self.prefill_chunk_size: + return self._schedule_chunked() deferred_requests = [] scheduled_requests = [] is_prefill = False @@ -258,6 +284,7 @@ def schedule(self) -> Optional[SchedulerOutput]: num_external_computed_tokens, self.block_size, ) + self.cache_manager.record_cache_hit(cached_block_table) else: load_kv_async = False num_tokens_this_step = ( @@ -376,6 +403,115 @@ def schedule(self) -> Optional[SchedulerOutput]: return None + def _schedule_chunked(self) -> Optional[SchedulerOutput]: + """Rotate dispatch opportunities across decode, continuation, and admission.""" + phases = ( + self._schedule_chunk_decode, + self._schedule_continuation, + self._admit_chunk_request, + ) + for offset in range(len(phases)): + phase = (self._next_chunk_phase + offset) % len(phases) + output = phases[phase]() + if output is not None: + self._next_chunk_phase = (phase + 1) % len(phases) + return output + return None + + def _schedule_chunk_decode(self) -> Optional[SchedulerOutput]: + requests = [] + while len(requests) < self.max_batch_size: + try: + req = self.running_queue.sync_q.get_nowait() + except queue.Empty: + break + if req.is_finished(): + self.complete_requests([req]) + continue + req.block_table, slot = self.cache_manager.append_slot( + req.block_table, req.get_total_length() + ) + req.slot_mapping = [slot] + req.num_blocks = len(req.block_table) + req.num_local_cached_tokens = req.get_total_length() - 1 + requests.append(req) + if requests: + return SchedulerOutput( + requests, speculative_cache_ops=self.speculative_cache_ops + ) + return None + + def _schedule_continuation(self) -> Optional[SchedulerOutput]: + while self.chunking_queue: + req = self.chunking_queue.popleft() + if req.is_finished(): + self.complete_requests([req]) + continue + return self._prefill_chunk(req) + return None + + def _admit_chunk_request(self) -> Optional[SchedulerOutput]: + while True: + try: + req = self.waiting_queue.sync_q.get_nowait() + except queue.Empty: + return None + if req.is_finished(): + self.complete_requests([req]) + continue + cached_table, cached_tokens = ( + self.cache_manager.get_computed_blocks( + req.block_hashes, req.get_prompt_length() - 1 + ) + if self.enable_prefix_caching + else ([], 0) + ) + # Partial prompts own their prompt pages but still need decode headroom. + chunk_headroom = sum( + self._get_prefill_extra_blocks(other) + for other in self.chunking_queue + if not other.is_finished() + ) + allocation = None + if self.can_accept_request(req, cached_tokens, chunk_headroom): + allocation = self.cache_manager.allocate_slots( + req.get_prompt_length() - cached_tokens, + num_computed_tokens=cached_tokens, + cached_block_table=cached_table, + ) + if allocation is None: + self.cache_manager.free_blocks(cached_table) + self.waiting_queue.sync_q.put(req) + return None + self.cache_manager.record_cache_hit(cached_table) + req.block_table, _ = allocation + req.num_blocks = len(req.block_table) + req.num_cache_indexed_blocks = len(cached_table) + req.num_computed_tokens = cached_tokens + req.status = RequestStatus.RUNNING + return self._prefill_chunk(req) + + def _prefill_chunk(self, req: InferenceRequest) -> SchedulerOutput: + start = req.num_computed_tokens + end = min( + req.get_prompt_length(), + start + min(self.prefill_chunk_size, self.max_num_batched_tokens), + ) + req.num_local_cached_tokens = start + req.slot_mapping = self.cache_manager.update_blocks_slot( + req.block_table, start, end + ) + return SchedulerOutput( + [req], + is_prefill=True, + speculative_cache_ops=self.speculative_cache_ops, + prefill_end=end, + ) + + def requeue_prefill(self, request: InferenceRequest) -> None: + """Retain ownership while waiting for the next prefill segment.""" + self.chunking_queue.append(request) + def update_waiting_for_remote_kv(self, request: InferenceRequest): self.remote_kv_requests.pop(request.request_id, None) self.pending_kv_decode_blocks -= ( diff --git a/python/infinilm/processors/basic_llm_processor.py b/python/infinilm/processors/basic_llm_processor.py index a6fbc33ac..63fc1fb81 100644 --- a/python/infinilm/processors/basic_llm_processor.py +++ b/python/infinilm/processors/basic_llm_processor.py @@ -46,13 +46,13 @@ def apply_chat_template( normalized_conversation = [] for message in conversation: if isinstance(message["content"], list): - assert len(message["content"]) == 1, ( - "Only one content item supported in list" - ) + assert ( + len(message["content"]) == 1 + ), "Only one content item supported in list" content_item = message["content"][0] - assert "type" in content_item and "text" in content_item, ( - "Content dict must have 'type' and 'text' keys" - ) + assert ( + "type" in content_item and "text" in content_item + ), "Content dict must have 'type' and 'text' keys" normalized_conversation.append( {"role": message["role"], "content": content_item["text"]} ) @@ -207,11 +207,12 @@ def _build_model_input_from_batch_scheduler_output( if scheduler_output.is_prefill: # Prefill phase req_tokens = req.get_input_tokens() - tokens_to_compute = req_tokens[num_cached:] + prefill_end = scheduler_output.prefill_end + seq_len = len(req_tokens) if prefill_end is None else prefill_end + tokens_to_compute = req_tokens[num_cached:seq_len] tokens.extend(tokens_to_compute) compute_len = len(tokens_to_compute) - seq_len = len(req_tokens) seq_lens.append(seq_len) current_offset += compute_len diff --git a/python/infinilm/server/inference_server.py b/python/infinilm/server/inference_server.py index 462c31084..9651d1881 100644 --- a/python/infinilm/server/inference_server.py +++ b/python/infinilm/server/inference_server.py @@ -125,6 +125,9 @@ def __init__( kv_transfer_config: Optional[KVTransferConfig] = None, enable_prefix_caching: bool = True, pre_transpose: bool = False, + prefix_cache_policy: str = "lru", + prefix_cache_protected_ratio: float = 0.8, + prefill_chunk_size: int = 0, ): """Initialize inference server. @@ -142,6 +145,8 @@ def __init__( num_blocks: Number of KV cache blocks (only for paged cache). block_size: Size of each KV cache block (only for paged cache). max_cache_len: Maximum sequence length (only for static cache). + prefix_cache_policy: Paged prefix-cache eviction policy ('lru' or 'slru'). + prefix_cache_protected_ratio: Fraction of paged blocks protected by SLRU. temperature: Default sampling temperature. top_p: Default top-p sampling parameter. top_k: Default top-k sampling parameter. @@ -154,6 +159,7 @@ def __init__( weight_load_mode: Weight loading mode across tensor-parallel workers. ignore_eos: Whether to ignore EOS tokens during generation. kv_transfer_config: Optional configuration for the KV transfer mechanism. + prefill_chunk_size: Maximum prompt tokens per prefill step; 0 disables chunking. """ self.model_path = model_path # vLLM-like served model id: directory name of model_path @@ -187,7 +193,10 @@ def __init__( self.ignore_eos = ignore_eos self.kv_transfer_config = kv_transfer_config self.enable_prefix_caching = enable_prefix_caching + self.prefix_cache_policy = prefix_cache_policy + self.prefix_cache_protected_ratio = prefix_cache_protected_ratio self.pre_transpose = pre_transpose + self.prefill_chunk_size = prefill_chunk_size self.engine: AsyncLLMEngine = None @@ -231,7 +240,10 @@ async def lifespan(app: FastAPI): weight_load_mode=self.weight_load_mode, kv_transfer_config=self.kv_transfer_config, enable_prefix_caching=self.enable_prefix_caching, + prefix_cache_policy=self.prefix_cache_policy, + prefix_cache_protected_ratio=self.prefix_cache_protected_ratio, pre_transpose=self.pre_transpose, + prefill_chunk_size=self.prefill_chunk_size, ) self.engine.start() logger.info(f"Engine initialized with model at {self.model_path}") @@ -666,7 +678,10 @@ def main(): ignore_eos=cfg.ignore_eos, kv_transfer_config=kv_transfer_config, enable_prefix_caching=cfg.enable_prefix_caching, + prefix_cache_policy=cfg.prefix_cache_policy, + prefix_cache_protected_ratio=cfg.prefix_cache_protected_ratio, pre_transpose=cfg.pre_transpose, + prefill_chunk_size=cfg.prefill_chunk_size, ) server.start() diff --git a/python/infinilm/server/pipeline_worker.py b/python/infinilm/server/pipeline_worker.py index 9e4bb8e6b..d38c05cde 100644 --- a/python/infinilm/server/pipeline_worker.py +++ b/python/infinilm/server/pipeline_worker.py @@ -38,6 +38,10 @@ def run_worker(cfg: BaseConfig) -> None: weight_load_mode=cfg.weight_load_mode, skip_load=cfg.skip_load, use_legacy_moe=cfg.use_legacy_moe, + prefill_chunk_size=cfg.prefill_chunk_size, + enable_prefix_caching=cfg.enable_prefix_caching, + prefix_cache_policy=cfg.prefix_cache_policy, + prefix_cache_protected_ratio=cfg.prefix_cache_protected_ratio, ) runner = ModelRunner(config, initialize_processor=False) diff --git a/test/llm/README.md b/test/llm/README.md new file mode 100644 index 000000000..297a71fcb --- /dev/null +++ b/test/llm/README.md @@ -0,0 +1,94 @@ +# Prefix-cache benchmark + +`benchmark_prefix_cache.py` is the common baseline/candidate harness for the +paged prefix-cache LRU experiment. It has two deliberately separate modes. +`metadata` loads only the Python cache metadata modules and never imports the +native InfiniLM package. `model` uses the normal `AsyncLLMEngine` API and actual +token IDs on CUDA. + +## CPU metadata mode + +Run the same copied harness in each checkout. Pool setup, hash preparation, and +JSON serialization are outside the timed calls. The JSON reports the median, +p95, and valid sample count for usable-capacity queries and reclaiming 1, 8, or +64 blocks at 0%, 50%, and 90% pinned occupancy. It separately records the +actual reclaimed count when the requested reclaim is impossible. `tracemalloc` +reports Python peak/current increments after constructing and filling the pool; +these values do not include GPU memory. + +```bash +experiment_dir=/path/to/pr1-lru-results +for blocks in 512 4096 65536; do + conda run -n infinilm python test/llm/benchmark_prefix_cache.py \ + --mode metadata --num-blocks "$blocks" --repeat 5 --seed 0 \ + --output "${experiment_dir}/candidate-metadata-${blocks}.json" +done +``` + +Use `baseline-metadata-${blocks}.json` for the detached baseline checkout. The +policy examples are CPU control-plane observations. They demonstrate victim and +subsequent hash-hit differences; they are not GPU speed measurements. +Metadata setup assigns distinct synthetic 16-byte block hashes rather than +timing real `xxhash` calculation. This isolates lookup/reclaim metadata cost; +the synthetic hashes are prepared before every timed call. + +## CUDA model mode + +Model mode fixes TP=1, FP16, paged attention, eager execution, block size 256, +32 generated tokens, and maximum batch size 4. It accepts only the documented +scenarios and concurrency values. The first 16 requests warm the cache and are +retained in the trace but excluded from the performance denominator. Each +repeat runs in a fresh child process so native model state cannot leak between +repeats. A timeout, child failure, missing token stream, or output other than 32 +tokens makes the experiment fail and writes a failure JSON. + +```bash +model_dir=/path/to/Qwen2.5-1.5B-pr1-fp16 +experiment_dir=/path/to/pr1-lru-results +export INFINILM_MODEL_PROVENANCE="${experiment_dir}/model-experiments.json" +export INFINILM_BUILD_PROVENANCE="${experiment_dir}/build-provenance.json" +python test/llm/benchmark_prefix_cache.py \ + --mode model \ + --model "$model_dir" \ + --prefix-cache on --scenario hot-cold --concurrency 1 \ + --num-blocks 64 --repeat 3 --repeat-timeout-seconds 3600 --seed 0 \ + --output "${experiment_dir}/candidate-1.5b-hot-cold-cache-on-blocks-64.json" +``` + +Set `INFINILM_MODEL_PROVENANCE` to the controller's JSON manifest containing +the model ID, immutable revision, and FP16 overlay hashes. The harness embeds +the matching manifest entry without assuming a machine-specific research path. +Set `INFINILM_BUILD_PROVENANCE` to the native build manifest. The harness +embeds and hashes it, hashes both imported extension binaries, and requires the +manifest artifact hashes to match those binaries. + +Repeat with cache `on` and `off`, pools 64 and 512, and scenarios `hot-cold`, +`hot-shift`, `no-reuse`, `over-capacity`, `mixed-length`, and `shared`. +`shared` should use concurrency 4; other scenarios support 1 or 4. The +controller is responsible for the complete model matrix and isolated native +library environment. + +The result preserves the complete prompt-token trace and SHA256, every output +token ID and delivery timestamp-derived metric, admitted local-cache and +prefill token counts, grouped TTFT, aggregate throughput, both repository SHAs, +actual imported source paths, GPU/driver/CUDA details, and FP16 overlay +provenance. Throughput is total delivered measurement tokens divided by the +shared measurement wall time. Delivery intervals include engine threads and +queues and must not be described as GPU kernel time. + +For an exactly 256-aligned prompt, the scheduler intentionally retains the last +token rule at block granularity: a 1024-token prompt can reuse only 768 tokens. +The benchmark workloads add a 32-token unique tail to a 1024-token prefix, so an +admitted full-prefix hit reports 1024 cached tokens and 32 prefill tokens. + +## Result artifacts + +Final quiet-window measurements are a Task 4 deliverable. Populate the table +with the controller's accepted artifacts; do not copy functional-smoke timings +into performance claims. + +| Measurement | Baseline artifact | Candidate artifact | Result | +|---|---|---|---| +| Metadata, 512/4096/65536 blocks | pending Task 4 | pending Task 4 | pending | +| Qwen2.5-1.5B correctness smoke | pending Task 4 | pending Task 4 | pending | +| Qwen2.5-7B scenario matrix | pending Task 4 | pending Task 4 | pending | diff --git a/test/llm/README.slru.md b/test/llm/README.slru.md new file mode 100644 index 000000000..c28e51c45 --- /dev/null +++ b/test/llm/README.slru.md @@ -0,0 +1,98 @@ +# Optional segmented LRU + +SLRU protects prefixes reused by admitted requests against one-off traffic. +The default policy remains `lru`. Enable SLRU for a paged-cache engine: + +```python +from infinilm.llm import AsyncLLMEngine + +engine = AsyncLLMEngine( + model_path="/path/to/model", + cache_type="paged", + prefix_cache_policy="slru", + prefix_cache_protected_ratio=0.8, +) +``` + +The server exposes the same options: + +```bash +python python/infinilm/server/inference_server.py --model /path/to/model \ + --enable-paged-attn --prefix-cache-policy slru \ + --prefix-cache-protected-ratio 0.8 +``` + +`examples/test_infer.py` forwards the same options when using +`--enable-paged-attn`. The offline `examples/bench.py` workflow explicitly disables +prefix caching and does not measure these eviction policies. + +The ratio must be strictly between zero and one. SLRU with static cache is +rejected. Disabling prefix caching prevents both reuse and promotion. + +## Policy semantics + +- Newly published blocks become probationary when their final reference is + released. Unpublished blocks immediately return to the free pool. +- A locally matched prefix is promoted after successful request admission. + Lookup alone, token-budget deferral, rejected admission, failed allocation, + and publishing newly computed tokens do not promote blocks. A request + canceled after admission still counts as admitted reuse. +- Only zero-reference blocks enter either reclaim queue. Protected membership + survives pinning, including references held by remote KV transfers. +- Protected membership is limited to `floor(num_blocks * ratio)`, including + pinned blocks. This is a cap, not reserved GPU memory. A one-block pool has + no protected capacity. +- Successful reuse and final release refresh protected ordering. Exceeding the + cap demotes the oldest protected member to probationary without releasing + references. A demoted pinned block becomes evictable only on final release. +- Reclamation consumes probationary blocks first, then protected blocks if + required. Protection never prevents an otherwise possible allocation. +- Hit promotion and release process a request's block table tail first. This + favors retaining its earlier prefix pages, but does not implement a global + radix-tree leaf constraint. + +The implementation adds ordered protected membership and a protected reclaim +queue. Candidate selection and individual queue updates use expected O(1) +metadata operations; promoting a prefix costs O(number of matched blocks), +including at most that many demotions. No history of evicted hashes is retained. + +## Framework precedent and limits + +[SGLang's SLRU documentation at revision 4b186cf](https://github.com/sgl-project/sglang/blob/4b186cfea59371cc8ec38597f750a5597f7147a0/docs/docs/advanced_features/radix_eviction_policy.mdx) +describes probationary/protected priorities based on hit counts. Its eligible +victims are unlocked radix-tree leaves. This implementation instead uses +physical pages, admitted-request reuse, and an explicit protected capacity cap; +it is not a port or a claim of equivalent behavior. + +SLRU is workload dependent. A large stale protected set can slow adaptation to +new hotspots. Pure cyclic scans larger than the cache can still miss on every +request because no resident page receives a second hit. Large requests may +require evicting protected blocks too. Choose the ratio from representative +traffic rather than assuming the default is optimal. + +## Verification + +```bash +python -m unittest discover -s test/llm -p 'test_*.py' +``` + +Coverage includes hotspot retention versus LRU, admission-only promotion, +bounded protection, pinned demotion, tail preference, physical-ID reuse, +mixed shared lifetimes, and scheduler rejection/cancellation/deferred transfer +paths. Configuration tests exercise the CLI, server startup configuration, +engine constructors, and paged scheduler forwarding without loading a model. + +A small real-scheduler metadata trace with an eight-block pool and ratio 0.5 +kept both two-page hotspots after six one-off prefixes. Across ten measurement +requests, cached tokens rose from 64 to 128 and prefill fell from 266 to 202 +(16-token blocks). No-reuse and cyclic traces produced zero hits for both +policies. These are metadata observations, not GPU throughput measurements. + +A bounded RTX A6000 / Qwen2.5-1.5B FP16 check used 256-token blocks, ratio 0.5, +four warmup requests, and ten measurement requests with eight generated tokens +each. At eight blocks, LRU recorded 1,024 cached tokens and 4,416 prefill tokens; +SLRU recorded 2,048 and 3,392 respectively (23.19% less prefill). All 14 output +token sequences and the separate normal-EOS check matched across LRU/8 blocks, +SLRU/8 blocks, LRU/32 blocks, and SLRU/8 blocks with prefix caching disabled. +These short single-run checks establish bounded correctness and retention +behavior, not a stable throughput gain or general workload coverage. diff --git a/test/llm/benchmark_chunk_prefill.py b/test/llm/benchmark_chunk_prefill.py new file mode 100644 index 000000000..ebb2e948c --- /dev/null +++ b/test/llm/benchmark_chunk_prefill.py @@ -0,0 +1,298 @@ +"""Short native long-prefill/active-decode experiment with recorded step boundaries.""" + +import argparse +import asyncio +import hashlib +import json +import os +import random +import statistics +import subprocess +import time +from pathlib import Path + +SOURCE_TEXTS = ( + "You are a careful technical assistant. Follow the system instructions and " + "answer with precise, verifiable details. ", + "Paged key value caches store completed attention states in fixed sized blocks. " + "Prefix reuse avoids repeated prefill computation when requests share input. ", + "Explain how deterministic experiments separate control plane overhead from " + "model execution and why complete traces are needed for reproducibility. ", +) + + +def provenance(engine, model_path): + import infinicore + import infinilm.llm.llm as llm_source + from infinicore.lib import _infinicore as core_native + from infinilm.lib import _infinilm as lm_native + + tree = Path(llm_source.__file__).resolve().parents[3] + model_engine = engine.engine.model_runner.model_engine + assert model_engine.dtype == infinicore.float16 + ranks = model_engine.get_kv_cache() + assert len(ranks) == engine.config.tensor_parallel_size + assert {t.device.index for rank in ranks for t in rank} == set(range(len(ranks))) + assert all(t.dtype == infinicore.float16 for rank in ranks for t in rank) + record = dict( + imported_llm_source=llm_source.__file__, + infinilm_sha=subprocess.check_output( + ["git", "rev-parse", "HEAD"], cwd=tree, text=True + ).strip(), + git_status=subprocess.check_output( + ["git", "status", "--short"], cwd=tree, text=True + ), + model_path=str(model_path), + model_config=json.loads((model_path / "config.json").read_text()), + model_config_sha256=hashlib.sha256( + (model_path / "config.json").read_bytes() + ).hexdigest(), + cuda_visible_devices=os.getenv("CUDA_VISIBLE_DEVICES"), + rank_devices=[[str(t.device) for t in rank] for rank in ranks], + native_binaries={ + p: hashlib.sha256(Path(p).read_bytes()).hexdigest() + for p in (lm_native.__file__, core_native.__file__) + }, + ) + maps = Path("/proc/self/maps") + if maps.exists(): + paths = sorted( + { + line.split()[-1] + for line in maps.read_text().splitlines() + if any(name in line for name in ("libinfiniccl.so", "libnccl.so")) + } + ) + record["communication_libraries"] = { + name: hashlib.sha256(Path(name).read_bytes()).hexdigest() for name in paths + } + for name in ("INFINILM_BUILD_PROVENANCE", "INFINILM_MODEL_PROVENANCE"): + path = os.getenv(name) + if path: + record[name] = dict( + path=path, + sha256=hashlib.sha256(Path(path).read_bytes()).hexdigest(), + manifest=json.loads(Path(path).read_text()), + ) + return record + + +async def run(args): + from infinilm.llm.llm import AsyncLLMEngine + from infinilm.llm.sampling_params import SamplingParams + + config = dict( + model_path=args.model, + device="cuda", + dtype="float16", + cache_type="paged", + enable_graph=args.graph, + prefix_cache_policy=args.policy, + attn_backend="paged-attn", + tensor_parallel_size=args.tp, + block_size=256, + num_blocks=128, + max_batch_size=2, + max_tokens=128, + enable_prefix_caching=False, + ) + if args.chunk_size: + config["prefill_chunk_size"] = args.chunk_size + engine = AsyncLLMEngine(**config) + try: + evidence = provenance(engine, Path(config["model_path"]).resolve()) + tree = Path(evidence["imported_llm_source"]).resolve().parents[3] + hashes = { + str(p): hashlib.sha256(p.read_bytes()).hexdigest() + for p in [ + Path(__file__), + *[ + tree / name + for name in ( + "csrc/engine/infer_engine.cpp", + "csrc/engine/rank_worker.cpp", + "csrc/engine/rank_worker.hpp", + "csrc/models/infinilm_model.hpp", + "csrc/pybind11/engine/engine.hpp", + "csrc/layers/causal_lm_templates/text_causal_lm.hpp", + ) + ], + *[ + tree / "python/infinilm" / name + for name in ( + "infer_engine.py", + "llm/model_runner/model_runner.py", + "llm/scheduler.py", + "llm/request.py", + "llm/llm.py", + "processors/basic_llm_processor.py", + "config/engine_config.py", + "base_config.py", + ) + ], + ] + } + if args.legacy_chunk_output: + # Experimental control: use the same binary and schedule, restoring + # logits/sampling/token transfer for intermediate chunks only. + forward = engine.engine.model_runner.model_engine.forward + + def legacy_forward(**kwargs): + intermediate = kwargs.get("prefill_only", False) + if intermediate: + kwargs["prefill_only"] = False + result = forward(**kwargs) + if intermediate: + result.to_numpy().tolist() + return result + + engine.engine.model_runner.model_engine.forward = legacy_forward + + corpus = engine.engine.tokenizer.encode( + " ".join(SOURCE_TEXTS), add_special_tokens=False + ) + corpus = [x for x in corpus if x not in engine.engine.tokenizer.all_special_ids] + prompts = { + name: random.Random(seed).choices(corpus, k=length) + for name, seed, length in [ + ("active", 51, 128), + ("long", 52, args.long_tokens), + ("late", 53, 128), + ] + } + prompts["warmup"] = corpus[:128] + steps = [] + original = engine.engine.model_runner.execute_model + + def execute(output): + row = dict( + start=time.perf_counter(), + prefill=output.is_prefill, + requests=[ + dict( + id=r.request_id, + slots=len(r.slot_mapping), + cached=r.num_local_cached_tokens, + computed=r.num_computed_tokens, + generated=len(r.generated_token_ids), + ) + for r in output.scheduled_requests + ], + ) + result = original(output) + row["end"] = time.perf_counter() + steps.append(row) + return result + + engine.engine.model_runner.execute_model = execute + results = {} + active_ready = asyncio.Event() + + async def collect(name, count): + started = time.perf_counter() + request = engine.add_request( + messages=None, + prompt_token_ids=prompts[name], + request_id=name, + sampling_params=SamplingParams( + top_k=1, max_tokens=count, ignore_eos=True + ), + ) + times = [] + ids = [] + async for output in engine.stream_request(request): + if output.token_id >= 0: + times.append(time.perf_counter()) + ids.append(output.token_id) + if name == "active" and len(ids) == 8: + active_ready.set() + assert len(ids) == count, (name, len(ids), count) + gaps = [b - a for a, b in zip(times, times[1:])] + results[name] = dict( + start=started, + times=times, + token_ids=ids, + ttft=times[0] - started, + itl_median=statistics.median(gaps), + itl_max=max(gaps), + itl_p95=sorted(gaps)[int(0.95 * (len(gaps) - 1))], + status=str(request.status), + ) + + engine.start() + tasks = [] + try: + # A separate warmup does not populate the test prefix cache. + await asyncio.wait_for(collect("warmup", 8), 30) + results.pop("warmup") + prompts.pop("warmup") + steps.clear() + tasks.append(asyncio.create_task(collect("active", 128))) + await asyncio.wait_for(active_ready.wait(), 30) + tasks.append(asyncio.create_task(collect("long", 16))) + await asyncio.sleep(0.02) + tasks.append(asyncio.create_task(collect("late", 16))) + await asyncio.wait_for(asyncio.gather(*tasks), 90) + finally: + for task in tasks: + if not task.done(): + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + if len(results) == 3: + assert all( + b.ref_count == 0 + for b in engine.engine.scheduler.cache_manager.blocks + ) + return dict( + status="success", + chunk_size=args.chunk_size, + legacy_chunk_output=args.legacy_chunk_output, + config=config, + provenance=evidence, + source_hashes=hashes, + prompts=prompts, + results=results, + steps=steps, + ) + finally: + if engine._running: + engine.stop() + else: + # stop() skips close after a failed worker or before start(). + engine.engine.close() + if engine._step_thread is not None: + assert not engine._step_thread.is_alive() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--model", required=True) + parser.add_argument("--chunk-size", type=int, default=0) + parser.add_argument("--tp", type=int, default=2) + parser.add_argument("--long-tokens", type=int, default=8192) + parser.add_argument("--graph", action="store_true") + parser.add_argument("--policy", choices=("lru", "slru"), default="lru") + parser.add_argument( + "--legacy-chunk-output", + action="store_true", + help="benchmark control: compute and discard intermediate outputs", + ) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + try: + payload = asyncio.run(run(args)) + except Exception as error: + args.output.write_text( + json.dumps(dict(status="failure", error=repr(error)), indent=2) + "\n" + ) + raise + args.output.write_text(json.dumps(payload, indent=2) + "\n") + print( + json.dumps( + { + k: {m: v for m, v in d.items() if m not in ("times", "token_ids")} + for k, d in payload["results"].items() + }, + indent=2, + ) + ) diff --git a/test/llm/benchmark_prefix_cache.py b/test/llm/benchmark_prefix_cache.py new file mode 100644 index 000000000..11f44f6cb --- /dev/null +++ b/test/llm/benchmark_prefix_cache.py @@ -0,0 +1,928 @@ +#!/usr/bin/env python3 +"""Reproducible control-plane and model prefix-cache benchmark.""" + +import argparse +import asyncio +import hashlib +import importlib.util +import json +import os +import platform +import random +import signal +import statistics +import subprocess +import sys +import tempfile +import time +import tracemalloc +from pathlib import Path +from types import ModuleType + +BLOCK_SIZE = 256 +OUTPUT_TOKENS = 32 +REQUEST_COUNT = 128 +WARMUP_REQUESTS = 16 +SOURCE_TEXTS = ( + "You are a careful technical assistant. Follow the system instructions and " + "answer with precise, verifiable details. ", + "Paged key value caches store completed attention states in fixed sized blocks. " + "Prefix reuse avoids repeated prefill computation when requests share input. ", + "Explain how deterministic experiments separate control plane overhead from " + "model execution and why complete traces are needed for reproducibility. ", +) + + +def _percentile(values, percentile): + ordered = sorted(values) + if not ordered: + return None + rank = (len(ordered) - 1) * percentile + low = int(rank) + high = min(low + 1, len(ordered) - 1) + return ordered[low] + (ordered[high] - ordered[low]) * (rank - low) + + +def _distribution(values): + return { + "median_ns": statistics.median(values) if values else None, + "p95_ns": _percentile(values, 0.95), + "valid_samples": len(values), + } + + +def _file_sha256(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def build_request_timing(request_id, prompt_tokens, started, token_ids, token_times): + if not token_ids or len(token_ids) != len(token_times): + raise RuntimeError( + "The request produced no token IDs or mismatched timestamps." + ) + return { + "request_id": request_id, + "prompt_tokens": prompt_tokens, + "output_token_ids": list(token_ids), + "ttft_seconds": token_times[0] - started, + "delivery_intervals_seconds": [ + later - earlier for earlier, later in zip(token_times, token_times[1:]) + ], + "latency_seconds": token_times[-1] - started, + } + + +def summarize_requests(requests, wall_seconds): + generated = sum(len(item["output_token_ids"]) for item in requests) + if wall_seconds <= 0: + raise ValueError("wall_seconds must be positive") + return { + "request_count": len(requests), + "generated_tokens": generated, + "wall_seconds": wall_seconds, + "throughput_tokens_per_second": generated / wall_seconds, + "ttft_median_seconds": statistics.median( + item["ttft_seconds"] for item in requests + ), + } + + +class SchedulerAccounting: + """Record cache work only for requests returned as scheduled prefill work.""" + + def __init__(self, scheduler): + self.scheduler = scheduler + self.original = scheduler.schedule + self.by_request = {} + self.admission_counts = {} + self.max_observed_block_ref_count = 0 + + def install(self): + def measured_schedule(): + output = self.original() + if output is not None and output.is_prefill: + for request in output.scheduled_requests: + self.admission_counts[request.request_id] = ( + self.admission_counts.get(request.request_id, 0) + 1 + ) + self.by_request[request.request_id] = { + "local_cached_tokens": request.num_local_cached_tokens, + "prefill_tokens": len(request.slot_mapping), + } + manager = getattr(self.scheduler, "cache_manager", None) + for block_id in getattr(request, "block_table", ()): + self.max_observed_block_ref_count = max( + self.max_observed_block_ref_count, + manager.blocks[block_id].ref_count if manager else 0, + ) + return output + + self.scheduler.schedule = measured_schedule + + def restore(self): + self.scheduler.schedule = self.original + + def require_exactly_once(self, request_ids): + missing = [ + request_id + for request_id in request_ids + if self.admission_counts.get(request_id, 0) == 0 + ] + duplicates = [ + request_id + for request_id in request_ids + if self.admission_counts.get(request_id, 0) > 1 + ] + if missing or duplicates: + raise RuntimeError( + "Invalid admitted-prefill accounting: " + f"missing={missing}, duplicate={duplicates}" + ) + + +def _expanded_tokens(token_sources, minimum=4096): + tokens = [token for source in token_sources for token in source] + if not tokens: + raise ValueError("Tokenizer produced no ordinary token IDs") + return (tokens * (minimum // len(tokens) + 2))[:minimum] + + +def _family(corpus, seed, family_index, length): + # A family-local PRNG composes only real corpus IDs while avoiding periodic + # rotations when the source text contains fewer than 128 distinct positions. + rng = random.Random(f"prefix-family-{seed}-{family_index}") + return [corpus[rng.randrange(len(corpus))] for _ in range(length)] + + +def build_trace(scenario, token_sources, seed, request_count=REQUEST_COUNT): + rng = random.Random(seed) + corpus = _expanded_tokens(token_sources) + + def tail(index): + start = (2048 + index * 37) % len(corpus) + return _family(corpus, seed, f"tail-{start}", 32) + + requests = [] + for index in range(request_count): + phase = "measurement" + group = None + if scenario == "hot-cold": + family_index = 0 if index % 4 < 3 else index + 1 + prefix_length = 1024 + group = "hot" if family_index == 0 else "cold" + elif scenario == "hot-shift": + family_index = 0 if index < 64 else 1 + prefix_length = 1024 + if index >= 64: + group = "post_shift_first_16" if index < 80 else "post_shift_stable" + else: + group = "prefix_a" + elif scenario == "no-reuse": + family_index = index + rng.randrange(1, 1 << 30) * request_count + prefix_length = 1024 + elif scenario == "over-capacity": + family_index = index % 32 + prefix_length = 1024 + elif scenario == "mixed-length": + prefix_length = (256, 1024, 2048)[index % 3] + family_index = index % 4 + group = str(prefix_length) + elif scenario == "shared": + family_index = 0 + prefix_length = 1024 + group = f"batch-{index // 4}" + else: + raise ValueError(f"Unknown scenario: {scenario}") + prompt = _family(corpus, seed, family_index, prefix_length) + tail(index) + if index < WARMUP_REQUESTS: + phase = "warmup" + requests.append( + { + "request_id": f"request-{index:03d}", + "phase": phase, + "group": group, + "prompt_token_ids": prompt, + } + ) + return requests + + +def _load_block_manager(): + """Load metadata modules without importing infinilm or native extensions.""" + source = Path(__file__).resolve().parents[2] / "python/infinilm/llm" + saved = dict(sys.modules) + try: + infinilm = ModuleType("infinilm") + llm = ModuleType("infinilm.llm") + infinilm.__path__ = [] + llm.__path__ = [] + sys.modules["infinilm"] = infinilm + sys.modules["infinilm.llm"] = llm + for name in ("prefix_cache", "cache_manager"): + fullname = f"infinilm.llm.{name}" + spec = importlib.util.spec_from_file_location( + fullname, source / f"{name}.py" + ) + module = importlib.util.module_from_spec(spec) + sys.modules[fullname] = module + spec.loader.exec_module(module) + return sys.modules["infinilm.llm.cache_manager"].BlockManager + finally: + loaded = sys.modules.get("infinilm.llm.cache_manager") + sys.modules.clear() + sys.modules.update(saved) + if loaded is not None: + sys.modules["_benchmark_cache_manager"] = loaded + + +def _filled_manager(block_manager, num_blocks, pinned_ratio): + manager = block_manager(num_blocks, BLOCK_SIZE) + table, _ = manager.allocate_slots(num_blocks * BLOCK_SIZE) + hashes = [index.to_bytes(16, "little") for index in range(1, num_blocks + 1)] + manager.publish_computed_blocks(table, hashes, 0, num_blocks * BLOCK_SIZE) + manager.free_blocks(table) + pinned = [] + for block_hash in hashes[: int(num_blocks * pinned_ratio)]: + blocks, _ = manager.get_computed_blocks([block_hash], BLOCK_SIZE) + pinned.extend(blocks) + return manager, hashes, pinned + + +def _policy_examples(block_manager): + manager = block_manager(3, BLOCK_SIZE) + hashes = [] + for value in (11, 22, 33): + table, _ = manager.allocate_slots(BLOCK_SIZE) + block_hash = bytes([value]) * 16 + manager.publish_computed_blocks(table, [block_hash], 0, BLOCK_SIZE) + manager.free_blocks(table) + hashes.append(block_hash) + touched, _ = manager.get_computed_blocks([hashes[0]], BLOCK_SIZE) + manager.free_blocks(touched) + manager.allocate_slots(BLOCK_SIZE) + survivor_hits = [] + for block_hash in hashes: + pinned, hit = manager.get_computed_blocks([block_hash], BLOCK_SIZE) + survivor_hits.append(hit) + if pinned: + manager.free_blocks(pinned) + + manager = block_manager(2, BLOCK_SIZE) + table, _ = manager.allocate_slots(2 * BLOCK_SIZE) + tail_hashes = [b"prefix".ljust(16, b"0"), b"tail".ljust(16, b"0")] + manager.publish_computed_blocks(table, tail_hashes, 0, 2 * BLOCK_SIZE) + manager.free_blocks(table) + manager.allocate_slots(BLOCK_SIZE) + _, prefix_hit = manager.get_computed_blocks(tail_hashes, 2 * BLOCK_SIZE) + return { + "recent_reuse": {"hit_tokens_after_pressure": survivor_hits}, + "tail_first": {"prefix_hit_tokens_after_pressure": prefix_hit}, + } + + +def run_metadata(args): + block_manager = _load_block_manager() + cases = [] + for pinned_ratio in (0.0, 0.5, 0.9): + manager, _, _ = _filled_manager(block_manager, args.num_blocks, pinned_ratio) + usable_times = [] + for _ in range(args.repeat): + started = time.perf_counter_ns() + usable = manager.get_total_usable_blocks() + usable_times.append(time.perf_counter_ns() - started) + reclaim = {} + for count in (1, 8, 64): + elapsed = [] + outcomes = [] + reclaimed_counts = [] + for _ in range(args.repeat): + sample, _, _ = _filled_manager( + block_manager, args.num_blocks, pinned_ratio + ) + free_before = sample.get_num_free_blocks() + started = time.perf_counter_ns() + outcome = sample.try_free_blocks(count) + ended = time.perf_counter_ns() + outcomes.append(outcome) + elapsed.append(ended - started) + reclaimed_counts.append(sample.get_num_free_blocks() - free_before) + reclaim[str(count)] = { + **_distribution(elapsed), + "successful_samples": sum(outcomes), + "requested_blocks": count, + "actual_reclaimed_blocks": reclaimed_counts, + "capacity_failure": any(actual < count for actual in reclaimed_counts), + } + cases.append( + { + "pinned_ratio": pinned_ratio, + "usable_blocks": usable, + "usable_query": _distribution(usable_times), + "reclaim": reclaim, + } + ) + + tracemalloc.start() + before_current, _ = tracemalloc.get_traced_memory() + memory_manager, _, _ = _filled_manager(block_manager, args.num_blocks, 0.0) + current, peak = tracemalloc.get_traced_memory() + tracemalloc.stop() + del memory_manager + return { + "schema_version": 1, + "mode": "metadata", + "status": "success", + "python_version": platform.python_version(), + "num_blocks": args.num_blocks, + "block_size": BLOCK_SIZE, + "repeat": args.repeat, + "seed": args.seed, + "harness_sha256": _file_sha256(__file__), + "cache_manager_source_sha256": _file_sha256( + Path(__file__).resolve().parents[2] / "python/infinilm/llm/cache_manager.py" + ), + "cache_manager_source": str( + Path(__file__).resolve().parents[2] / "python/infinilm/llm/cache_manager.py" + ), + "timing_scope": "method call only; pool construction, hashing, and JSON excluded", + "memory_scope": "Python tracemalloc peak increment; GPU memory excluded", + "pool_peak_increment_bytes": peak - before_current, + "pool_current_increment_bytes": current - before_current, + "policy_examples": _policy_examples(block_manager), + "cases": cases, + } + + +def _git_sha(path): + try: + return subprocess.check_output( + ["git", "-C", str(path), "rev-parse", "HEAD"], text=True + ).strip() + except (OSError, subprocess.CalledProcessError): + return None + + +def _git_root_for(path): + candidate = Path(path).resolve() + if candidate.is_file(): + candidate = candidate.parent + for parent in (candidate, *candidate.parents): + if (parent / ".git").exists(): + return parent + return None + + +def _dtype_name(value): + if value is None: + return None + return getattr(value, "name", None) or str(value) + + +def _load_json_provenance(environment_name, description): + value = os.environ.get(environment_name) + if not value: + raise RuntimeError(f"{environment_name} must point to {description}") + path = Path(value).resolve() + manifest = json.loads(path.read_text()) + return { + "source": str(path), + "sha256": _file_sha256(path), + "manifest": manifest, + } + + +def load_build_provenance(): + provenance = _load_json_provenance( + "INFINILM_BUILD_PROVENANCE", "the native build provenance manifest" + ) + manifest = provenance["manifest"] + for project in ("infinicore", "infinilm"): + if not isinstance(manifest.get(project), dict) or not manifest[project].get( + "git_sha" + ): + raise RuntimeError(f"Build provenance is missing {project}.git_sha") + return provenance + + +def _validate_native_artifact(build_provenance, binary_path): + binary_path = Path(binary_path).resolve() + actual_sha = _file_sha256(binary_path) + matching = [ + artifact + for artifact in build_provenance["manifest"].get("artifacts", []) + if Path(artifact.get("path", "")).name == binary_path.name + ] + if len(matching) != 1 or matching[0].get("sha256") != actual_sha: + raise RuntimeError( + f"Build manifest does not uniquely match imported binary {binary_path}" + ) + return { + "path": str(binary_path), + "sha256": actual_sha, + "manifest_artifact": matching[0], + } + + +def _provenance(engine, model_path, build_provenance): + import infinicore + import infinicore.lib._infinicore as core_native + from infinilm.lib import _infinilm as lm_native + + model_engine = engine.engine.model_runner.model_engine + if model_engine.dtype != infinicore.float16: + raise RuntimeError( + f"Effective model dtype is {_dtype_name(model_engine.dtype)}, not float16" + ) + nested_cache = model_engine.get_kv_cache() + cache_tensors = [tensor for layer in nested_cache for tensor in layer] + if not cache_tensors: + raise RuntimeError("Native model engine returned no KV cache tensors") + invalid_cache_dtypes = { + _dtype_name(tensor.dtype) + for tensor in cache_tensors + if tensor.dtype != infinicore.float16 + } + if invalid_cache_dtypes: + raise RuntimeError( + f"Effective KV cache tensor dtypes are not float16: {invalid_cache_dtypes}" + ) + effective_dtype = _dtype_name(model_engine.dtype) + model_provenance = _load_json_provenance( + "INFINILM_MODEL_PROVENANCE", "the model revision/overlay manifest" + ) + overlays = model_provenance["manifest"] + overlay = next( + (item for item in overlays if item.get("experiment_path") == str(model_path)), + None, + ) + if overlay is None or not overlay.get("repo_id") or not overlay.get("revision"): + raise RuntimeError( + "INFINILM_MODEL_PROVENANCE must name a manifest containing this " + "experiment_path, repo_id, and immutable revision" + ) + try: + gpu = ( + subprocess.check_output( + [ + "nvidia-smi", + "--query-gpu=name,driver_version", + "--format=csv,noheader", + ], + text=True, + ) + .strip() + .splitlines() + ) + except (OSError, subprocess.CalledProcessError): + gpu = [] + core_source = Path(infinicore.__file__).resolve() + core_root = _git_root_for(core_source) + core_binary = _validate_native_artifact(build_provenance, core_native.__file__) + lm_binary = _validate_native_artifact(build_provenance, lm_native.__file__) + try: + cuda_toolkit = subprocess.check_output(["nvcc", "--version"], text=True).strip() + except (OSError, subprocess.CalledProcessError): + cuda_toolkit = None + return { + "infinilm_sha": _git_sha(Path(__file__).resolve().parents[2]), + "infinicore_sha": os.environ.get("INFINICORE_GIT_SHA") + or (_git_sha(core_root) if core_root else None), + "imported_infinicore_source": str(core_source), + "infinicore_native_binary": core_binary, + "infinilm_native_binary": lm_binary, + "imported_llm_source": sys.modules["infinilm.llm.llm"].__file__, + "harness_sha256": _file_sha256(__file__), + "imported_llm_source_sha256": _file_sha256( + sys.modules["infinilm.llm.llm"].__file__ + ), + "model_path": str(model_path), + "model_id": overlay.get("repo_id") if overlay else None, + "model_revision": overlay.get("revision") if overlay else None, + "fp16_overlay": overlay, + "model_provenance": model_provenance, + "build_provenance": build_provenance, + "effective_model_dtype": effective_dtype, + "effective_kv_cache_dtype": _dtype_name(cache_tensors[0].dtype), + "effective_kv_cache_tensor_count": len(cache_tensors), + "gpu_and_driver": gpu, + "cuda_toolkit": cuda_toolkit, + "cuda_visible_devices": os.environ.get("CUDA_VISIBLE_DEVICES"), + "build_environment": { + key: value + for key, value in os.environ.items() + if key.startswith(("INFINI", "CUDA", "LD_LIBRARY_PATH")) + }, + } + + +async def _collect(engine, item, sampling_params): + started = time.perf_counter() + request = engine.add_request( + messages=None, + prompt_token_ids=item["prompt_token_ids"], + sampling_params=sampling_params, + request_id=item["request_id"], + ) + times = [] + token_ids = [] + async for output in engine.stream_request(request): + if output.token_id >= 0: + times.append(time.perf_counter()) + token_ids.append(output.token_id) + result = build_request_timing( + item["request_id"], len(item["prompt_token_ids"]), started, token_ids, times + ) + result.update({"phase": item["phase"], "group": item["group"]}) + return result + + +async def _run_model_async(args): + from infinilm.llm.llm import AsyncLLMEngine + from infinilm.llm.sampling_params import SamplingParams + + build_provenance = load_build_provenance() + engine = None + accounting = None + trace = None + trace_sha256 = None + provenance = None + results = [] + measured_started = None + engine_started = False + try: + engine = AsyncLLMEngine( + model_path=args.model, + device="cuda", + dtype="float16", + cache_type="paged", + enable_graph=False, + attn_backend="paged-attn", + tensor_parallel_size=1, + block_size=BLOCK_SIZE, + num_blocks=args.num_blocks, + max_batch_size=4, + max_tokens=OUTPUT_TOKENS, + enable_prefix_caching=args.prefix_cache == "on", + ) + provenance = _provenance(engine, Path(args.model), build_provenance) + accounting = SchedulerAccounting(engine.engine.scheduler) + accounting.install() + tokenizer = engine.engine.tokenizer + token_sources = [] + for text in SOURCE_TEXTS: + try: + ids = tokenizer.encode(text, add_special_tokens=False) + except TypeError: + ids = tokenizer.encode(text) + special_ids = set(getattr(tokenizer, "all_special_ids", ())) + token_sources.append( + [token_id for token_id in ids if token_id not in special_ids] + ) + trace = build_trace(args.scenario, token_sources, args.seed) + trace_bytes = json.dumps(trace, sort_keys=True, separators=(",", ":")).encode() + trace_sha256 = hashlib.sha256(trace_bytes).hexdigest() + _write_json( + args.output, + { + "schema_version": 1, + "mode": "model", + "status": "running", + "repeat": args.child_repeat, + "config": _model_config(args), + "trace_sha256": trace_sha256, + "trace": trace, + "requests": [], + "provenance": provenance, + }, + ) + sampling = SamplingParams(top_k=1, max_tokens=OUTPUT_TOKENS, ignore_eos=True) + engine.start() + engine_started = True + for offset in range(0, len(trace), args.concurrency): + if offset == WARMUP_REQUESTS: + for result in results: + result.update(accounting.by_request[result["request_id"]]) + _write_json( + args.output, + { + "schema_version": 1, + "mode": "model", + "status": "running", + "repeat": args.child_repeat, + "config": _model_config(args), + "checkpoint": "warmup_complete", + "trace_sha256": trace_sha256, + "trace": trace, + "requests": results, + "provenance": provenance, + }, + ) + measured_started = time.perf_counter() + batch = trace[offset : offset + args.concurrency] + results.extend( + await asyncio.gather( + *(_collect(engine, item, sampling) for item in batch) + ) + ) + wall_seconds = time.perf_counter() - measured_started + accounting.require_exactly_once([item["request_id"] for item in trace]) + # A separate normal-EOS request verifies that the smoke path does not rely on + # ignore_eos semantics. Its output is excluded from performance accounting. + eos_item = { + "request_id": "correctness-eos", + "prompt_token_ids": trace[-1]["prompt_token_ids"], + "phase": "correctness", + "group": None, + } + eos_result = await _collect( + engine, + eos_item, + SamplingParams(top_k=1, max_tokens=OUTPUT_TOKENS, ignore_eos=False), + ) + for result in results: + result.update(accounting.by_request[result["request_id"]]) + if len(result["output_token_ids"]) != OUTPUT_TOKENS: + raise RuntimeError( + f"{result['request_id']} delivered " + f"{len(result['output_token_ids'])} tokens; expected {OUTPUT_TOKENS}" + ) + measured = [item for item in results if item["phase"] == "measurement"] + aggregate = summarize_requests(measured, wall_seconds) + aggregate["local_cached_tokens"] = sum( + item["local_cached_tokens"] for item in measured + ) + aggregate["prefill_tokens"] = sum(item["prefill_tokens"] for item in measured) + aggregate["max_observed_block_ref_count"] = ( + accounting.max_observed_block_ref_count + ) + if ( + args.scenario == "shared" + and args.prefix_cache == "on" + and accounting.max_observed_block_ref_count < 2 + ): + raise RuntimeError( + "Shared scenario did not observe concurrent cached-block owners" + ) + groups = {} + for group in sorted({item["group"] for item in measured if item["group"]}): + selected = [item for item in measured if item["group"] == group] + groups[group] = { + "request_count": len(selected), + "ttft_median_seconds": statistics.median( + item["ttft_seconds"] for item in selected + ), + } + return { + "schema_version": 1, + "mode": "model", + "status": "success", + **_model_config(args), + "trace_sha256": trace_sha256, + "trace": trace, + "requests": results, + "correctness_eos_request": eos_result, + "aggregate": aggregate, + "groups": groups, + "provenance": provenance, + } + except Exception as error: + for result in results: + if accounting and result["request_id"] in accounting.by_request: + result.update(accounting.by_request[result["request_id"]]) + return { + "schema_version": 1, + "mode": "model", + "status": "failure", + **_model_config(args), + "error_type": type(error).__name__, + "error": str(error), + "trace_sha256": trace_sha256, + "trace": trace, + "requests": results, + "provenance": provenance, + "build_provenance": build_provenance, + } + finally: + if accounting is not None: + accounting.restore() + if engine_started: + engine.stop() + + +def _model_config(args): + return { + "model": args.model, + "prefix_cache": args.prefix_cache, + "scenario": args.scenario, + "concurrency": args.concurrency, + "num_blocks": args.num_blocks, + "block_size": BLOCK_SIZE, + "seed": args.seed, + "repeat": args.repeat, + "repeat_timeout_seconds": args.repeat_timeout_seconds, + "warmup_requests": WARMUP_REQUESTS, + "attention_backend": "paged-attn", + "engine_config": { + "device": "cuda", + "requested_dtype": "float16", + "cache_type": "paged", + "enable_graph": False, + "tensor_parallel_size": 1, + "max_batch_size": 4, + "max_tokens": OUTPUT_TOKENS, + }, + } + + +def _write_json(path, payload): + Path(path).write_text(json.dumps(payload, indent=2) + "\n") + + +def assemble_repeat_artifact(records, config, build_provenance=None): + successful = sum(record["status"] == "success" for record in records) + failed = len(records) - successful + return { + "schema_version": 1, + "mode": "model", + "status": "failure" if failed else "success", + "config": config, + "build_provenance": build_provenance, + "successful_repeats": successful, + "failed_repeats": failed, + "repeats": records, + } + + +def _read_child_payload(path): + try: + return json.loads(Path(path).read_text()) + except (OSError, json.JSONDecodeError): + return None + + +def _run_child(command, timeout_seconds): + process = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + start_new_session=True, + ) + try: + stdout, stderr = process.communicate(timeout=timeout_seconds) + return process.returncode, stdout, stderr, False + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + stdout, stderr = process.communicate() + return None, stdout, stderr, True + + +def _run_model_repeats(args): + if args.child_repeat is not None: + return asyncio.run(_run_model_async(args)) + try: + build_provenance = load_build_provenance() + except Exception as error: + records = [ + { + "repeat": index, + "status": "failure", + "error_type": type(error).__name__, + "error": str(error), + } + for index in range(args.repeat) + ] + return assemble_repeat_artifact(records, _model_config(args)) + records = [] + with tempfile.TemporaryDirectory(prefix="infinilm-prefix-benchmark-") as directory: + for index in range(args.repeat): + child_output = Path(directory) / f"repeat-{index}.json" + command = [ + sys.executable, + str(Path(__file__).resolve()), + "--mode", + "model", + "--output", + str(child_output), + "--seed", + str(args.seed), + "--repeat", + "1", + "--num-blocks", + str(args.num_blocks), + "--model", + args.model, + "--prefix-cache", + args.prefix_cache, + "--scenario", + args.scenario, + "--concurrency", + str(args.concurrency), + "--repeat-timeout-seconds", + str(args.repeat_timeout_seconds), + "--child-repeat", + str(index), + ] + try: + returncode, stdout, stderr, timed_out = _run_child( + command, args.repeat_timeout_seconds + ) + except OSError as error: + records.append( + { + "repeat": index, + "status": "failure", + "error_type": type(error).__name__, + "error": str(error), + } + ) + continue + payload = _read_child_payload(child_output) + if timed_out: + record = { + "repeat": index, + "status": "timeout", + "timeout_seconds": args.repeat_timeout_seconds, + "stdout": stdout, + "stderr": stderr, + } + if payload is not None: + record["payload"] = payload + elif returncode or payload is None or payload.get("status") != "success": + record = { + "repeat": index, + "status": "failure", + "returncode": returncode, + "stdout": stdout, + "stderr": stderr, + } + if payload is not None: + record["payload"] = payload + else: + record = {"repeat": index, "status": "success", "payload": payload} + records.append(record) + return assemble_repeat_artifact( + records, _model_config(args), build_provenance=build_provenance + ) + + +def parse_args(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--mode", choices=("metadata", "model"), required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--seed", type=int, required=True) + parser.add_argument("--repeat", type=int, required=True) + parser.add_argument("--num-blocks", type=int, required=True) + parser.add_argument("--model") + parser.add_argument("--prefix-cache", choices=("on", "off")) + parser.add_argument( + "--scenario", + choices=( + "hot-cold", + "hot-shift", + "no-reuse", + "over-capacity", + "mixed-length", + "shared", + ), + ) + parser.add_argument("--concurrency", type=int, choices=(1, 4)) + parser.add_argument("--repeat-timeout-seconds", type=float, default=3600.0) + parser.add_argument("--child-repeat", type=int, help=argparse.SUPPRESS) + args = parser.parse_args(argv) + if args.repeat <= 0 or args.num_blocks <= 0 or args.repeat_timeout_seconds <= 0: + parser.error( + "--repeat, --num-blocks, and --repeat-timeout-seconds must be positive" + ) + if args.mode == "model" and not all( + (args.model, args.prefix_cache, args.scenario, args.concurrency) + ): + parser.error( + "model mode requires --model, --prefix-cache, --scenario, and --concurrency" + ) + return args + + +def main(argv=None): + args = parse_args(argv) + args.output.parent.mkdir(parents=True, exist_ok=True) + try: + result = ( + run_metadata(args) if args.mode == "metadata" else _run_model_repeats(args) + ) + except Exception as error: + result = { + "schema_version": 1, + "mode": args.mode, + "status": "failure", + "error_type": type(error).__name__, + "error": str(error), + } + _write_json(args.output, result) + raise + _write_json(args.output, result) + return 0 if result.get("status") == "success" else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/llm/cache_test_support.py b/test/llm/cache_test_support.py new file mode 100644 index 000000000..96777dc8c --- /dev/null +++ b/test/llm/cache_test_support.py @@ -0,0 +1,84 @@ +import importlib.util +import sys +from pathlib import Path +from unittest.mock import patch + +# Keep native NumPy modules alive when restoring the isolated package imports. +import numpy as np # noqa: F401 + + +def load_modules(): + source = Path(__file__).resolve().parents[2] / "python/infinilm/llm" + modules = {} + with patch.dict(sys.modules): + for name in ( + "prefix_cache", + "sampling_params", + "request", + "cache_manager", + "scheduler", + ): + fullname = f"infinilm.llm.{name}" + spec = importlib.util.spec_from_file_location( + fullname, source / f"{name}.py" + ) + module = importlib.util.module_from_spec(spec) + sys.modules[fullname] = module + spec.loader.exec_module(module) + modules[name] = module + return modules + + +MODULES = load_modules() +BlockManager = MODULES["cache_manager"].BlockManager + + +def chain_hashes(tokens, block_size=16): + hashes = [] + parent = MODULES["prefix_cache"].EMPTY_BLOCK_HASH + for start in range(0, len(tokens) - block_size + 1, block_size): + parent = MODULES["prefix_cache"].hash_block_tokens( + tokens[start : start + block_size], parent + ) + hashes.append(parent) + return hashes + + +def publish(manager, tokens): + table, _ = manager.allocate_slots(len(tokens)) + hashes = chain_hashes(tokens, manager.block_size) + manager.publish_computed_blocks(table, hashes, 0, len(tokens)) + manager.free_blocks(table) + return table, hashes + + +def assert_state(case, manager): + free = list(manager.free_block_ids) + used = set(manager.used_block_ids) + case.assertEqual(len(free), len(set(free))) + case.assertFalse(set(free) & used) + case.assertEqual(set(free) | used, set(range(manager.num_blocks))) + evictable = set() + index = {} + for block in manager.blocks: + case.assertGreaterEqual(block.ref_count, 0) + if block.block_id in free: + case.assertEqual((block.ref_count, block.hash), (0, b"")) + elif block.ref_count == 0: + case.assertNotEqual(block.hash, b"") + evictable.add(block.block_id) + if block.hash: + case.assertIn(block.block_id, used) + index.setdefault(block.hash, set()).add(block.block_id) + probationary = set(manager._evictable_blocks) + protected = set(manager._protected_evictable_blocks) + case.assertFalse(probationary & protected) + case.assertEqual(probationary | protected, evictable) + case.assertTrue(protected <= set(manager._protected_blocks) <= used) + case.assertFalse(probationary & set(manager._protected_blocks)) + case.assertLessEqual(len(manager._protected_blocks), manager._protected_capacity) + for block_id in manager._protected_blocks: + case.assertTrue(manager.blocks[block_id].hash) + case.assertEqual(manager.blocks[block_id].ref_count == 0, block_id in protected) + case.assertEqual(manager.hash_to_block_ids, index) + case.assertEqual(manager.get_total_usable_blocks(), len(free) + len(evictable)) diff --git a/test/llm/check_chunk_output.py b/test/llm/check_chunk_output.py new file mode 100644 index 000000000..d757e7a5f --- /dev/null +++ b/test/llm/check_chunk_output.py @@ -0,0 +1,78 @@ +"""Native output suppression plus the existing per-rank KV lifecycle checks.""" + +import argparse +import json +from pathlib import Path +from unittest.mock import patch + +from check_chunk_tp import run + + +def check(args): + from infinilm.lib import _infinilm + + # Fail before loading a model if the native extension has not been rebuilt. + assert _infinilm.InferEngine.Input(prefill_only=True).prefill_only + native = _infinilm.InferEngine.forward + calls = [] + rejected = [] + + def forward(engine, inputs): + if not calls: + # Rejection must happen before any worker job is submitted; the + # subsequent normal lifecycle must still be able to use this engine. + for kwargs, message in ( + ( + {"prefill_only": True, "sample_all_positions": True}, + "sample_all_positions=false", + ), + ({"prefill_only": True}, "input_offsets"), + ): + try: + native(engine, _infinilm.InferEngine.Input(**kwargs)) + except ValueError as error: + assert message in str(error) + rejected.append(message) + else: + raise AssertionError("invalid outputless forward was accepted") + output = native(engine, inputs) + if inputs.prefill_only: + assert not output.output_ids + assert not output.logits + assert not output.hidden_states + else: + assert output.output_ids + assert output.logits + calls.append(dict(prefill_only=inputs.prefill_only)) + return output + + with patch.object(_infinilm.InferEngine, "forward", forward): + result = run(args) + skipped = sum(c["prefill_only"] for c in calls) + assert skipped > 0 + result["native_rejections"] = rejected + assert len(rejected) == 2 + result["native_outputs"] = dict( + calls=len(calls), prefill_only=skipped, checked=True + ) + return result + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--model", required=True) + parser.add_argument("--tp", type=int, default=2) + parser.add_argument("--chunk-size", type=int, default=300) + parser.add_argument("--cache-off", action="store_true") + parser.add_argument("--output", type=Path, required=True) + parser.set_defaults(graph=False, pp=1, stage=0, port=29761, policy="lru") + args = parser.parse_args() + try: + payload = check(args) + except Exception as error: + args.output.write_text( + json.dumps(dict(status="failure", error=repr(error)), indent=2) + "\n" + ) + raise + args.output.write_text(json.dumps(payload, indent=2) + "\n") + print(json.dumps(payload["native_outputs"])) diff --git a/test/llm/check_chunk_tp.py b/test/llm/check_chunk_tp.py new file mode 100644 index 000000000..6ee992758 --- /dev/null +++ b/test/llm/check_chunk_tp.py @@ -0,0 +1,412 @@ +"""Opt-in real-model chunk/KV check; run explicitly with --model and --output.""" + +import argparse +import ctypes +import hashlib +import json +import random +import time +from pathlib import Path + + +def host_fp16(tensor, core): + """Export FP16 without relying on the native to_numpy dtype support.""" + import numpy as np + + assert tensor.dtype == core.float16 + core.set_device(tensor.device) + core.sync_device() + cpu = tensor.to(core.device("cpu", 0)).contiguous() + raw = (ctypes.c_ubyte * (cpu.numel() * 2)).from_address(cpu.data_ptr()) + result = np.frombuffer(raw, dtype=np.float16).reshape(cpu.shape).copy() + core.set_device(core.device("cuda", 0)) + return result + + +def run(args): + import infinicore as core + import numpy as np + from infinilm.config.engine_config import EngineConfig + from infinilm.llm.llm import LLMEngine + from infinilm.llm.request import InferenceRequest + from infinilm.llm.sampling_params import SamplingParams + + config = EngineConfig( + model_path=args.model, + device="cuda", + dtype="float16", + tensor_parallel_size=args.tp, + cache_type="paged", + enable_graph=args.graph, + pipeline_parallel_size=args.pp, + pipeline_parallel_stage=args.stage, + master_port=args.port, + prefix_cache_policy=args.policy, + attn_backend="paged-attn", + block_size=256, + num_blocks=16, + max_batch_size=2, + prefill_chunk_size=args.chunk_size, + enable_prefix_caching=not args.cache_off, + ) + + def local_caches(model): + from infinilm.lib import _infinilm + + return [ + [core.Tensor(t) for t in rank if t] + for rank in _infinilm.InferEngine.get_kv_cache(model) + ] + + if args.stage: + from infinilm.distributed.pipeline_transport import PipelineWorkerClient + from infinilm.llm.model_runner.model_runner import ModelRunner + + runner = ModelRunner(config, initialize_processor=False) + observed = [ + t for rank in local_caches(runner.model_engine) for t in (rank[0], rank[-1]) + ] + checks = [] + forward = runner.model_engine.forward + + def checked_forward(**inputs): + slots = inputs["slot_mapping"].to_numpy().reshape(-1).tolist() + allowed = np.zeros(16 * 256, dtype=bool) + allowed[slots] = True + before = [] + for tensor in observed: + raw = host_fp16(tensor, core) + for slot in slots: + raw[:, slot // 256, :, slot % 256, :] = np.nan + core.set_device(tensor.device) + tensor.copy_(core.from_numpy(raw, device=tensor.device)) + core.sync_device() + before.append(raw.transpose(1, 3, 0, 2, 4).reshape(16 * 256, -1)) + result = forward(**inputs) + for index, tensor in enumerate(observed): + after = ( + host_fp16(tensor, core) + .transpose(1, 3, 0, 2, 4) + .reshape(16 * 256, -1) + ) + assert np.isfinite(after[allowed]).all() + assert np.array_equal( + before[index][~allowed], after[~allowed], equal_nan=True + ) + checks.append( + dict(prefill_only=inputs.get("prefill_only", False), slots=len(slots)) + ) + return result + + runner.model_engine.forward = checked_forward + try: + PipelineWorkerClient( + runner, config.master_addr, config.master_port, args.stage + ).serve_forever() + return dict(status="worker_success", stage=args.stage, checks=checks) + finally: + runner.close() + counter = ctypes.CDLL(None).graph_launch_count if args.graph else lambda: 0 + if args.graph: + counter.restype = ctypes.c_ulonglong + engine = LLMEngine(config) + try: + manager = engine.scheduler.cache_manager + ranks = local_caches(engine.model_runner.model_engine) + assert len(ranks) == args.tp + assert {t.device.index for rank in ranks for t in rank} == set(range(args.tp)) + # Inspect the first attention layer on every TP rank. Other layers run normally. + observed = [rank[i] for rank in ranks for i in (0, len(rank) - 1)] + sentinel = np.float16(np.nan) + for tensor in observed: + assert ( + len(tensor.shape) == 5 + and tensor.shape[0] == 2 + and tensor.shape[3] == 256 + ) + core.set_device(tensor.device) + tensor.copy_( + core.from_numpy( + np.full(tensor.shape, sentinel, dtype=np.float16), + device=tensor.device, + ) + ) + core.sync_device() + core.set_device(core.device("cuda", 0)) + corpus = engine.tokenizer.encode( + "The engine processes a long document while serving short requests. " + "Pages store key and value vectors, and attention reuses computed prefixes. " + * 8, + add_special_tokens=False, + ) + corpus = [t for t in corpus if t not in engine.tokenizer.all_special_ids] + prompt = random.Random(82).choices(corpus, k=1027) + steps = [] + publication_checks = [] + span_checks = [] + requests = {} + original = engine.model_runner.execute_model + + def execute(output): + row = dict( + prefill=output.is_prefill, + end=getattr(output, "prefill_end", None), + requests=[ + dict( + id=r.request_id, + cached=r.num_local_cached_tokens, + computed=r.num_computed_tokens, + generated=len(r.generated_token_ids), + slots=list(r.slot_mapping), + ) + for r in output.scheduled_requests + ], + ) + before = [] + if output.is_prefill: + # Poison every scheduled slot, including reused physical pages, so a + # missing K/V write cannot pass merely because stale values are finite. + for tensor in observed: + raw = host_fp16(tensor, core) + for request in output.scheduled_requests: + for slot in request.slot_mapping: + raw[:, slot // 256, :, slot % 256, :] = sentinel + core.set_device(tensor.device) + tensor.copy_(core.from_numpy(raw, device=tensor.device)) + core.sync_device() + before.append(raw.transpose(1, 3, 0, 2, 4).reshape(16 * 256, -1)) + core.set_device(core.device("cuda", 0)) + before_launches = counter() + started = time.perf_counter() + result = original(output) + row["elapsed_ms"] = (time.perf_counter() - started) * 1000 + row["graph_launches"] = counter() - before_launches + if args.graph: + assert row["graph_launches"] == ( + 0 if output.is_prefill else args.tp + ), row + if output.is_prefill: + allowed = np.zeros(16 * 256, dtype=bool) + for r in output.scheduled_requests: + allowed[r.slot_mapping] = True + for rank, tensor in enumerate(observed): + after = ( + host_fp16(tensor, core) + .transpose(1, 3, 0, 2, 4) + .reshape(16 * 256, -1) + ) + assert np.array_equal( + before[rank][~allowed], after[~allowed], equal_nan=True + ), ( + rank, + "KV write outside scheduled slots", + ) + assert np.isfinite(after[allowed]).all() + span_checks.append( + dict( + rank=rank, + request=row["requests"][0]["id"], + end=row["end"], + written_span=int(allowed.sum()), + outside_unchanged=True, + ) + ) + steps.append(row) + return result + + engine.model_runner.execute_model = execute + + def new(name, tokens=prompt, count=8): + r = InferenceRequest( + name, + prompt_token_ids=tokens, + sampling_params=SamplingParams( + top_k=1, max_tokens=count, ignore_eos=True + ), + ) + engine.add_request(r) + requests[name] = r + return r + + def state(idle=False): + assert all(b.ref_count >= 0 for b in manager.blocks) + if idle: + assert all(b.ref_count == 0 for b in manager.blocks) + assert manager.get_total_usable_blocks() == 16 + + def tick(): + worked, pending = engine.step() + assert worked and not pending + state() + + def drain(active): + for _ in range(100): + if all(r.is_finished() for r in active): + break + tick() + assert all( + r.is_finished() and len(r.generated_token_ids) == 8 for r in active + ) + state(idle=True) + + first = new("first") + # Both ranks must write exactly [0, chunk_size), preserving untouched future slots. + tick() + if args.chunk_size: + assert ( + first.num_computed_tokens == args.chunk_size + and not first.generated_token_ids + ) + expected = np.zeros(16 * 256, dtype=bool) + for i in range(args.chunk_size): + expected[first.block_table[i // 256] * 256 + i % 256] = True + for rank, tensor in enumerate(observed): + values = ( + host_fp16(tensor, core) + .transpose(1, 3, 0, 2, 4) + .reshape(16 * 256, -1) + ) + changed = np.any(~np.isnan(values), axis=1) + assert np.array_equal(changed, expected), ( + rank, + np.flatnonzero(changed != expected).tolist(), + ) + assert np.isfinite(values[expected]).all() + publication_checks.append( + dict( + rank=rank, + device=str(tensor.device), + changed_slots=int(changed.sum()), + expected_slots=int(expected.sum()), + indexed_blocks=first.num_cache_indexed_blocks, + ) + ) + assert first.num_cache_indexed_blocks == ( + 0 if args.cache_off else args.chunk_size // 256 + ) + # A new request may reuse only the fully computed first page. Both hold shared ownership. + second = new("shared", prompt + corpus[:2]) + shared_dispatch = None + for _ in range(5): + tick() + row = steps[-1] + if row["requests"][0]["id"] == "shared": + shared_dispatch = row + break + assert shared_dispatch is not None + hit = shared_dispatch["requests"][0]["cached"] + if not args.cache_off: + assert hit > 0 and hit % 256 == 0 + assert all( + manager.blocks[b].ref_count == 2 + for b in second.block_table[: hit // 256] + ) + else: + assert hit == 0 + first.mark_canceled() + drain([second]) + assert not first.generated_token_ids + state(idle=True) + # Repeat now matches the completed prefix; the uncached suffix is shorter than a chunk. + repeat = new("repeat", prompt + corpus[:2]) + drain([repeat]) + if not args.cache_off: + assert any( + r["id"] == "repeat" and r["cached"] == 1024 + for s in steps + if s["prefill"] + for r in s["requests"] + ) + # Abort after forward: final cached suffix when caching is on; + # an intermediate segment when caching is off. Both must release ownership. + canceled = new("abort-forward", prompt + corpus[:2]) + run_forward = engine.model_runner.execute_model + + def abort_after_forward(output): + result = run_forward(output) + canceled.abort() + return result + + engine.model_runner.execute_model = abort_after_forward + tick() + engine.model_runner.execute_model = run_forward + assert canceled.is_finished() and not canceled.generated_token_ids + state(idle=True) + else: + drain([first]) + drain([new("shared", prompt + corpus[:2])]) + drain([new("repeat", prompt + corpus[:2])]) + result = dict( + status="success", + tp=args.tp, + pp=args.pp, + policy=args.policy, + graph=args.graph, + chunk_size=args.chunk_size, + cache_off=args.cache_off, + rank_devices=[[str(t.device) for t in rank] for rank in ranks], + checks=publication_checks, + span_checks=span_checks, + steps=steps, + requests={ + name: dict( + prompt=list(r.prompt_token_ids), + tokens=list(r.generated_token_ids), + status=str(r.status), + ) + for name, r in requests.items() + }, + final_refs=[b.ref_count for b in manager.blocks], + final_usable=manager.get_total_usable_blocks(), + ) + import infinilm.llm.llm as source + from infinilm.lib import _infinilm as native + + result["source"] = str(Path(source.__file__).resolve()) + result["native_sha256"] = hashlib.sha256( + Path(native.__file__).read_bytes() + ).hexdigest() + result["same_prompt_greedy_equal"] = ( + result["requests"]["shared"]["tokens"] + == result["requests"]["repeat"]["tokens"] + ) + assert result["same_prompt_greedy_equal"], "shared and repeated tokens differ" + result["script_sha256"] = hashlib.sha256( + Path(__file__).read_bytes() + ).hexdigest() + return result + finally: + engine.close() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--model", required=True) + parser.add_argument("--tp", type=int, choices=(1, 2), default=2) + parser.add_argument("--chunk-size", type=int, choices=(0, 300, 512), default=300) + parser.add_argument("--cache-off", action="store_true") + parser.add_argument("--graph", action="store_true") + parser.add_argument("--pp", type=int, choices=(1, 2), default=1) + parser.add_argument("--stage", type=int, choices=(0, 1), default=0) + parser.add_argument("--port", type=int, default=29761) + parser.add_argument("--policy", choices=("lru", "slru"), default="lru") + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + try: + result = run(args) + except Exception as error: + args.output.write_text( + json.dumps(dict(status="failure", error=repr(error)), indent=2) + "\n" + ) + raise + args.output.write_text(json.dumps(result, indent=2) + "\n") + print( + json.dumps( + { + k: result[k] + for k in ("status", "tp", "chunk_size", "checks", "final_usable") + if k in result + }, + indent=2, + ) + ) diff --git a/test/llm/chunk_test_support.py b/test/llm/chunk_test_support.py new file mode 100644 index 000000000..03ad01865 --- /dev/null +++ b/test/llm/chunk_test_support.py @@ -0,0 +1,3 @@ +"""Share the isolated scheduler modules with the cache lifecycle suite.""" + +from cache_test_support import MODULES, BlockManager # noqa: F401 diff --git a/test/llm/graph_counter.cc b/test/llm/graph_counter.cc new file mode 100644 index 000000000..42c36a9fe --- /dev/null +++ b/test/llm/graph_counter.cc @@ -0,0 +1,21 @@ +#include +#include +#include +#include + +static std::atomic launches{0}; +extern "C" unsigned long long graph_launch_count() { return launches.load(); } +extern "C" infiniStatus_t infinirtGraphLuanch(infinirtGraphExec_t graph, infinirtStream_t stream) { + using Fn = infiniStatus_t (*)(infinirtGraphExec_t, infinirtStream_t); + // Python loads extensions RTLD_LOCAL, so RTLD_NEXT may not see InfiniRT. + static auto real = reinterpret_cast(dlsym( + dlopen("libinfinirt.so", RTLD_NOW | RTLD_LOCAL), "infinirtGraphLuanch")); + if (!real) { + std::abort(); + } + auto status = real(graph, stream); + if (status == INFINI_STATUS_SUCCESS) { + ++launches; + } + return status; +} diff --git a/test/llm/test_benchmark_prefix_cache.py b/test/llm/test_benchmark_prefix_cache.py new file mode 100644 index 000000000..128c825fb --- /dev/null +++ b/test/llm/test_benchmark_prefix_cache.py @@ -0,0 +1,230 @@ +import importlib.util +import sys +import unittest +from pathlib import Path + +from cache_test_support import MODULES, publish + +SCRIPT = Path(__file__).with_name("benchmark_prefix_cache.py") +SPEC = importlib.util.spec_from_file_location("benchmark_prefix_cache", SCRIPT) +BENCHMARK = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(BENCHMARK) +Scheduler = MODULES["scheduler"].Scheduler +InferenceRequest = MODULES["request"].InferenceRequest +SamplingParams = MODULES["sampling_params"].SamplingParams + + +class ResultAccountingTests(unittest.TestCase): + def test_throughput_uses_total_tokens_over_shared_wall_clock(self): + requests = [ + {"output_token_ids": list(range(32)), "ttft_seconds": 0.1}, + {"output_token_ids": list(range(32)), "ttft_seconds": 0.2}, + ] + + summary = BENCHMARK.summarize_requests(requests, wall_seconds=2.0) + + self.assertEqual(summary["generated_tokens"], 64) + self.assertEqual(summary["throughput_tokens_per_second"], 32.0) + + def test_delivery_interval_is_the_gap_between_actual_token_times(self): + clock = iter((10.0, 10.1, 10.15)) + + result = BENCHMARK.build_request_timing( + request_id="request-0", + prompt_tokens=1056, + started=10.0, + token_ids=[41, 42], + token_times=[next(clock), next(clock), next(clock)][1:], + ) + + self.assertAlmostEqual(result["ttft_seconds"], 0.1) + self.assertAlmostEqual(result["delivery_intervals_seconds"][0], 0.05) + self.assertEqual(result["output_token_ids"], [41, 42]) + + +class SchedulerAccountingTests(unittest.TestCase): + def make_scheduler(self, **kwargs): + scheduler = Scheduler(block_size=256, **kwargs) + self.addCleanup(scheduler.waiting_queue.close) + self.addCleanup(scheduler.running_queue.close) + return scheduler + + @staticmethod + def request(request_id, tokens, max_tokens=1): + return InferenceRequest( + request_id, + prompt_token_ids=tokens, + sampling_params=SamplingParams(max_tokens=max_tokens), + ) + + def test_aligned_and_one_token_tail_report_scheduler_work(self): + for prompt_length, expected in ((1024, (768, 256)), (1025, (1024, 1))): + with self.subTest(prompt_length=prompt_length): + scheduler = self.make_scheduler(num_blocks=16) + publish(scheduler.cache_manager, [11] * 1024) + request = self.request("reuse", [11] * prompt_length) + scheduler.add_request(request) + accounting = BENCHMARK.SchedulerAccounting(scheduler) + accounting.install() + + scheduler.schedule() + accounting.require_exactly_once([request.request_id]) + + self.assertEqual( + accounting.by_request[request.request_id], + { + "local_cached_tokens": expected[0], + "prefill_tokens": expected[1], + }, + ) + + def test_no_reuse_reports_full_prefill(self): + scheduler = self.make_scheduler(num_blocks=16) + publish(scheduler.cache_manager, [11] * 1024) + request = self.request("new", [22] * 1025) + scheduler.add_request(request) + accounting = BENCHMARK.SchedulerAccounting(scheduler) + accounting.install() + + scheduler.schedule() + accounting.require_exactly_once([request.request_id]) + + self.assertEqual( + accounting.by_request[request.request_id], + {"local_cached_tokens": 0, "prefill_tokens": 1025}, + ) + + def test_budget_rejected_probe_is_excluded(self): + scheduler = self.make_scheduler( + num_blocks=16, max_batch_size=2, max_num_batched_tokens=1024 + ) + publish(scheduler.cache_manager, [11] * 1024) + first = self.request("first", [11] * 1025) + rejected = self.request("budget-rejected", [11] * 1024 + [22] * 1024) + scheduler.add_request(first) + scheduler.add_request(rejected) + accounting = BENCHMARK.SchedulerAccounting(scheduler) + accounting.install() + + scheduler.schedule() + + self.assertEqual(set(accounting.by_request), {"first"}) + self.assertNotIn(rejected.request_id, accounting.by_request) + + def test_capacity_rejected_probe_is_excluded(self): + scheduler = self.make_scheduler(num_blocks=2) + publish(scheduler.cache_manager, [11] * 256) + rejected = self.request("capacity-rejected", [11] * 256 + [22], max_tokens=512) + scheduler.add_request(rejected) + accounting = BENCHMARK.SchedulerAccounting(scheduler) + accounting.install() + + self.assertIsNone(scheduler.schedule()) + + self.assertEqual(accounting.by_request, {}) + + def test_missing_or_duplicate_admission_accounting_fails(self): + scheduler = self.make_scheduler(num_blocks=16) + request = self.request("duplicate", [11] * 257) + scheduler.add_request(request) + accounting = BENCHMARK.SchedulerAccounting(scheduler) + accounting.install() + scheduler.schedule() + + with self.assertRaisesRegex(RuntimeError, "missing"): + accounting.require_exactly_once(["unknown"]) + accounting.admission_counts[request.request_id] = 2 + with self.assertRaisesRegex(RuntimeError, "duplicate"): + accounting.require_exactly_once([request.request_id]) + + +class RepeatArtifactTests(unittest.TestCase): + def test_mixed_repeat_outcomes_preserve_every_payload(self): + records = [ + {"repeat": 0, "status": "success", "payload": {"trace_sha256": "abc"}}, + { + "repeat": 1, + "status": "timeout", + "timeout_seconds": 15, + "stdout": "partial output", + "stderr": "", + }, + ] + + result = BENCHMARK.assemble_repeat_artifact( + records, {"scenario": "hot-cold", "repeat_timeout_seconds": 15} + ) + + self.assertEqual(result["status"], "failure") + self.assertEqual(result["repeats"], records) + self.assertEqual(result["successful_repeats"], 1) + self.assertEqual(result["failed_repeats"], 1) + + def test_child_deadline_reports_timeout(self): + returncode, stdout, stderr, timed_out = BENCHMARK._run_child( + [sys.executable, "-c", "import time; time.sleep(10)"], 0.05 + ) + + self.assertTrue(timed_out) + self.assertIsNone(returncode) + self.assertEqual((stdout, stderr), ("", "")) + + +class TraceTests(unittest.TestCase): + def test_no_reuse_requests_have_distinct_first_blocks(self): + token_sources = [list(range(10, 410)), list(range(510, 910))] + + trace = BENCHMARK.build_trace( + "no-reuse", token_sources, seed=7, request_count=128 + ) + + first_blocks = {tuple(item["prompt_token_ids"][:256]) for item in trace} + self.assertEqual(len(first_blocks), 128) + self.assertTrue(all(len(item["prompt_token_ids"]) == 1056 for item in trace)) + + def test_shared_requests_reuse_one_prefix_but_keep_unique_tails(self): + token_sources = [list(range(10, 410)), list(range(510, 910))] + + trace = BENCHMARK.build_trace("shared", token_sources, seed=7, request_count=8) + + self.assertEqual( + len({tuple(item["prompt_token_ids"][:1024]) for item in trace}), 1 + ) + self.assertEqual( + len({tuple(item["prompt_token_ids"][1024:]) for item in trace}), 8 + ) + + def test_seed_is_repeatable_and_changes_every_scenario_trace(self): + token_sources = [list(range(10, 410)), list(range(510, 910))] + scenarios = ( + "hot-cold", + "hot-shift", + "no-reuse", + "over-capacity", + "mixed-length", + "shared", + ) + for scenario in scenarios: + with self.subTest(scenario=scenario): + first = BENCHMARK.build_trace(scenario, token_sources, seed=7) + repeated = BENCHMARK.build_trace(scenario, token_sources, seed=7) + changed = BENCHMARK.build_trace(scenario, token_sources, seed=8) + self.assertEqual(first, repeated) + self.assertNotEqual( + [item["prompt_token_ids"] for item in first], + [item["prompt_token_ids"] for item in changed], + ) + self.assertEqual( + [ + (item["phase"], item["group"], len(item["prompt_token_ids"])) + for item in first + ], + [ + (item["phase"], item["group"], len(item["prompt_token_ids"])) + for item in changed + ], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/llm/test_cache_manager.py b/test/llm/test_cache_manager.py new file mode 100644 index 000000000..bb497c7cf --- /dev/null +++ b/test/llm/test_cache_manager.py @@ -0,0 +1,255 @@ +import random +import unittest + +from cache_test_support import ( + BlockManager, + assert_state, + chain_hashes, + publish, +) + + +class CachePolicyTests(unittest.TestCase): + def test_recently_reused_prefix_survives_pressure(self): + manager = BlockManager(3, 16) + _, a = publish(manager, [11] * 16) + _, b = publish(manager, [22] * 16) + _, c = publish(manager, [33] * 16) + touched, hit = manager.get_computed_blocks(a, 16) + self.assertEqual(hit, 16) + manager.free_blocks(touched) + self.assertIsNotNone(manager.allocate_slots(16)) + self.assertIn(a[0], manager.hash_to_block_ids) + self.assertNotIn(b[0], manager.hash_to_block_ids) + self.assertIn(c[0], manager.hash_to_block_ids) + assert_state(self, manager) + + def test_request_tail_is_evicted_before_its_prefix(self): + manager = BlockManager(2, 16) + _, hashes = publish(manager, [11] * 16 + [22] * 16) + self.assertIsNotNone(manager.allocate_slots(16)) + pinned, hit = manager.get_computed_blocks(hashes, 32) + self.assertEqual(hit, 16) + self.assertNotIn(hashes[1], manager.hash_to_block_ids) + manager.free_blocks(pinned) + assert_state(self, manager) + + +class CacheStateTests(unittest.TestCase): + def test_shared_pin_survives_until_last_release(self): + manager = BlockManager(2, 16) + _, hashes = publish(manager, [11] * 16) + first, _ = manager.get_computed_blocks(hashes, 16) + second, _ = manager.get_computed_blocks(hashes, 16) + pressure, _ = manager.allocate_slots(16) + + manager.free_blocks(first) + self.assertFalse(manager.try_free_blocks(1)) + self.assertEqual(manager.blocks[second[0]].ref_count, 1) + + manager.free_blocks(second) + self.assertTrue(manager.try_free_blocks(1)) + self.assertNotIn(hashes[0], manager.hash_to_block_ids) + manager.free_blocks(pressure) + assert_state(self, manager) + + def test_duplicate_hash_keeps_other_block_indexed(self): + manager = BlockManager(2, 16) + first, _ = manager.allocate_slots(16) + second, _ = manager.allocate_slots(16) + block_hash = chain_hashes([7] * 16) + manager.publish_computed_blocks(first, block_hash, 0, 16) + manager.publish_computed_blocks(second, block_hash, 0, 16) + + manager.free_blocks(first) + self.assertTrue(manager.try_free_blocks(1)) + self.assertEqual(manager.hash_to_block_ids[block_hash[0]], {second[0]}) + manager.free_blocks(second) + assert_state(self, manager) + + def test_unpublished_release_returns_free_capacity(self): + manager = BlockManager(2, 16) + table, _ = manager.allocate_slots(17) + manager.free_blocks(table) + + self.assertEqual(manager.get_num_free_blocks(), 2) + self.assertEqual(manager.used_block_ids, set()) + self.assertEqual(manager.hash_to_block_ids, {}) + self.assertEqual(manager._evictable_blocks, {}) + assert_state(self, manager) + + def test_partial_publish_retains_only_full_block(self): + manager = BlockManager(2, 16) + table, _ = manager.allocate_slots(17) + hashes = chain_hashes([11] * 16 + [22] * 16) + manager.publish_computed_blocks(table, hashes, 0, 17) + manager.free_blocks(table) + + self.assertEqual(manager.get_num_free_blocks(), 1) + missed, missed_tokens = manager.get_computed_blocks(hashes, 15) + self.assertEqual((missed, missed_tokens), ([], 0)) + pinned, hit_tokens = manager.get_computed_blocks(hashes, 17) + self.assertEqual(hit_tokens, 16) + manager.free_blocks(pinned) + assert_state(self, manager) + + def test_insufficient_capacity_preserves_pins(self): + manager = BlockManager(2, 16) + _, hashes = publish(manager, [11] * 16) + private, _ = manager.allocate_slots(16) + private_id = private[0] + + self.assertFalse(manager.try_free_blocks(2)) + self.assertNotIn(hashes[0], manager.hash_to_block_ids) + self.assertEqual(manager.blocks[private_id].ref_count, 1) + self.assertEqual(manager.blocks[private_id].block_id, private_id) + self.assertEqual(manager.get_num_free_blocks(), 1) + manager.free_blocks(private) + assert_state(self, manager) + + def test_sufficient_capacity_does_not_evict(self): + manager = BlockManager(2, 16) + _, hashes = publish(manager, [11] * 16) + + self.assertTrue(manager.try_free_blocks(1)) + self.assertTrue(manager.try_free_blocks(0)) + self.assertIn(hashes[0], manager.hash_to_block_ids) + assert_state(self, manager) + + def test_speculative_truncate_releases_private_tail(self): + manager = BlockManager(3, 16) + table, _ = manager.allocate_slots(17) + table, _ = manager.append_slots(table, 18, 16) + self.assertEqual(len(table), 3) + + retained = manager.truncate_blocks(table, 17) + self.assertEqual(len(retained), 2) + self.assertEqual(manager.get_num_free_blocks(), 1) + self.assertEqual(manager._evictable_blocks, {}) + manager.free_blocks(retained) + assert_state(self, manager) + + def test_invalid_truncate_is_atomic(self): + for invalid_kind in ( + "discarded_published", + "discarded_shared", + "retained_published", + "retained_shared", + ): + with self.subTest(invalid_kind=invalid_kind): + manager = BlockManager(4, 16) + table, _ = manager.allocate_slots(48) + hashes = chain_hashes([11] * 16 + [22] * 16 + [33] * 16) + second_owner = [] + if invalid_kind == "discarded_published": + manager.publish_computed_blocks(table, hashes, 0, 48) + keep_tokens = 16 + elif invalid_kind == "discarded_shared": + second_owner = [table[-1]] + manager.blocks[second_owner[0]].ref_count += 1 + keep_tokens = 16 + elif invalid_kind == "retained_published": + manager.publish_computed_blocks(table, hashes, 0, 32) + keep_tokens = 17 + else: + second_owner = [table[1]] + manager.blocks[second_owner[0]].ref_count += 1 + keep_tokens = 17 + before = self._snapshot(manager, table) + + with self.assertRaises(RuntimeError): + manager.truncate_blocks(table, keep_tokens) + + self.assertEqual(self._snapshot(manager, table), before) + manager.free_blocks(table) + if second_owner: + manager.free_blocks(second_owner) + assert_state(self, manager) + + def test_append_slot_uses_lru_at_boundary(self): + manager = BlockManager(2, 16) + old_table, old_hashes = publish(manager, [11] * 16) + table, _ = manager.allocate_slots(16) + + table, slot = manager.append_slot(table, 17) + self.assertEqual(slot, old_table[0] * 16) + self.assertNotIn(old_hashes[0], manager.hash_to_block_ids) + self.assertEqual(len(table), 2) + manager.free_blocks(table) + assert_state(self, manager) + + def test_bounded_legal_operation_sequences_preserve_invariants(self): + for seed in range(20): + rng = random.Random(seed) + manager = BlockManager(8, 16) + owners = [] + published_sequences = [] + next_token = 1 + history = [] + for step in range(500): + operation = rng.choice( + ("allocate", "publish", "pin", "release", "free") + ) + try: + if operation == "allocate": + blocks = rng.randint(1, 3) + allocation = manager.allocate_slots(blocks * 16) + if allocation is not None: + owners.append(allocation[0]) + elif operation == "publish": + candidates = [ + table + for table in owners + if all( + manager.blocks[block_id].ref_count == 1 + and not manager.blocks[block_id].hash + for block_id in table + ) + ] + if candidates: + table = rng.choice(candidates) + tokens = list( + range(next_token, next_token + len(table) * 16) + ) + next_token += len(tokens) + hashes = chain_hashes(tokens) + manager.publish_computed_blocks( + table, hashes, 0, len(tokens) + ) + published_sequences.append(hashes) + elif operation == "pin" and published_sequences: + hashes = rng.choice(published_sequences) + table, _ = manager.get_computed_blocks(hashes, len(hashes) * 16) + if table: + owners.append(table) + elif operation == "release" and owners: + owner = owners.pop(rng.randrange(len(owners))) + manager.free_blocks(owner) + elif operation == "free": + manager.try_free_blocks(rng.randint(0, 9)) + history.append(operation) + assert_state(self, manager) + except Exception as error: + self.fail( + f"seed={seed} step={step} operation={operation} " + f"history={history[-30:]} error={error!r}" + ) + for owner in owners: + manager.free_blocks(owner) + self.assertEqual(manager.get_total_usable_blocks(), 8, f"seed={seed}") + assert_state(self, manager) + + @staticmethod + def _snapshot(manager, table): + return ( + list(table), + list(manager.free_block_ids), + set(manager.used_block_ids), + list(manager._evictable_blocks), + {key: set(value) for key, value in manager.hash_to_block_ids.items()}, + [(block.ref_count, block.hash) for block in manager.blocks], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/llm/test_cache_manager_slru.py b/test/llm/test_cache_manager_slru.py new file mode 100644 index 000000000..c419b2b77 --- /dev/null +++ b/test/llm/test_cache_manager_slru.py @@ -0,0 +1,149 @@ +import random +import unittest + +from cache_test_support import BlockManager, assert_state, publish + + +class SegmentedCacheTests(unittest.TestCase): + def make_manager(self, blocks=4, ratio=0.5): + return BlockManager( + blocks, + 16, + prefix_cache_policy="slru", + prefix_cache_protected_ratio=ratio, + ) + + def reuse(self, manager, hashes): + table, _ = manager.get_computed_blocks(hashes, len(hashes) * 16) + manager.record_cache_hit(table) + manager.free_blocks(table) + + def test_admitted_hot_prefix_survives_one_off_scan(self): + for policy, retained in (("lru", False), ("slru", True)): + with self.subTest(policy=policy): + manager = BlockManager(4, 16, prefix_cache_policy=policy) + _, hot = publish(manager, [11] * 16) + self.reuse(manager, hot) + for token in range(20, 30): + publish(manager, [token] * 16) + assert_state(self, manager) + self.assertEqual(hot[0] in manager.hash_to_block_ids, retained) + + def test_probe_does_not_promote(self): + manager = self.make_manager() + table, hashes = publish(manager, [11] * 16) + probe, _ = manager.get_computed_blocks(hashes, 16) + manager.free_blocks(probe) + self.assertNotIn(table[0], manager._protected_blocks) + for token in range(20, 25): + publish(manager, [token] * 16) + self.assertNotIn(hashes[0], manager.hash_to_block_ids) + + def test_protected_capacity_demotes_old_hotspot(self): + manager = self.make_manager(ratio=0.25) + old_table, old = publish(manager, [11] * 16) + self.reuse(manager, old) + new_table, new = publish(manager, [22] * 16) + self.reuse(manager, new) + self.assertEqual(list(manager._protected_blocks), new_table) + self.assertIn(old_table[0], manager._evictable_blocks) + for token in range(30, 35): + publish(manager, [token] * 16) + self.assertNotIn(old[0], manager.hash_to_block_ids) + self.assertIn(new[0], manager.hash_to_block_ids) + assert_state(self, manager) + + def test_pinned_demotion_never_releases_shared_owner(self): + manager = self.make_manager(ratio=0.25) + table, hashes = publish(manager, [11] * 16) + first, _ = manager.get_computed_blocks(hashes, 16) + second, _ = manager.get_computed_blocks(hashes, 16) + manager.record_cache_hit(first) + _, other = publish(manager, [22] * 16) + self.reuse(manager, other) + self.assertNotIn(table[0], manager._protected_blocks) + self.assertEqual(manager.blocks[table[0]].ref_count, 2) + self.assertFalse(manager.try_free_blocks(4)) + manager.free_blocks(first) + self.assertFalse(manager.try_free_blocks(4)) + manager.free_blocks(second) + self.assertTrue(manager.try_free_blocks(4)) + assert_state(self, manager) + + def test_tail_loses_protection_before_prefix_when_cap_is_small(self): + manager = self.make_manager(ratio=0.25) + table, hashes = publish(manager, [11] * 16 + [22] * 16) + self.reuse(manager, hashes) + self.assertEqual(list(manager._protected_blocks), table[:1]) + self.assertTrue(manager.try_free_blocks(3)) + self.assertIn(hashes[0], manager.hash_to_block_ids) + self.assertNotIn(hashes[1], manager.hash_to_block_ids) + + def test_protected_tail_evicted_before_prefix_when_no_probation_remains(self): + manager = self.make_manager(ratio=0.75) + _, hashes = publish(manager, [11] * 16 + [22] * 16) + self.reuse(manager, hashes) + self.assertTrue(manager.try_free_blocks(3)) + self.assertIn(hashes[0], manager.hash_to_block_ids) + self.assertNotIn(hashes[1], manager.hash_to_block_ids) + + def test_eviction_clears_protection_before_block_id_reuse(self): + manager = self.make_manager() + _, hot = publish(manager, [11] * 16) + self.reuse(manager, hot) + self.assertTrue(manager.try_free_blocks(4)) + self.assertFalse(manager._protected_blocks) + self.assertFalse(manager._protected_evictable_blocks) + for token in range(20, 24): + publish(manager, [token] * 16) + self.assertFalse(manager._protected_blocks) + assert_state(self, manager) + + def test_one_block_pool_has_no_permanent_protection(self): + manager = self.make_manager(blocks=1) + _, hashes = publish(manager, [11] * 16) + self.reuse(manager, hashes) + self.assertFalse(manager._protected_blocks) + self.assertTrue(manager.try_free_blocks(1)) + assert_state(self, manager) + + def test_invalid_policy_configuration(self): + for policy, ratio in ( + ("unknown", 0.5), + ("slru", 0), + ("slru", 1), + ("slru", float("nan")), + ): + with self.subTest(policy=policy, ratio=ratio): + with self.assertRaises(ValueError): + BlockManager( + 4, + 16, + prefix_cache_policy=policy, + prefix_cache_protected_ratio=ratio, + ) + + def test_randomized_shared_lifetimes_preserve_capacity(self): + rng = random.Random(71) + manager = self.make_manager(blocks=12) + held = [] + for _ in range(400): + action = rng.randrange(4) + if action == 0 and manager.hash_to_block_ids: + key = rng.choice(list(manager.hash_to_block_ids)) + table, _ = manager.get_computed_blocks([key], 16) + if rng.choice((True, False)): + manager.record_cache_hit(table) + held.append(table) + elif action == 1 and held: + manager.free_blocks(held.pop(rng.randrange(len(held)))) + elif action == 2 and manager.get_total_usable_blocks(): + publish(manager, [rng.randrange(10, 1000)] * 16) + else: + manager.try_free_blocks(rng.randrange(1, 13)) + assert_state(self, manager) + self.assertLessEqual(len(manager._protected_blocks), 6) + for table in held: + manager.free_blocks(table) + self.assertTrue(manager.try_free_blocks(12)) + assert_state(self, manager) diff --git a/test/llm/test_cache_policy_config.py b/test/llm/test_cache_policy_config.py new file mode 100644 index 000000000..ab321292c --- /dev/null +++ b/test/llm/test_cache_policy_config.py @@ -0,0 +1,273 @@ +"""CPU configuration coverage; native model construction is replaced at its boundary.""" + +import asyncio +import importlib.util +import io +import os +import runpy +import sys +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +from types import ModuleType, SimpleNamespace +from unittest.mock import patch + +from cache_test_support import MODULES + +SOURCE = Path(__file__).resolve().parents[2] / "python/infinilm" + + +def load_module(name, relative_path): + spec = importlib.util.spec_from_file_location(name, SOURCE / relative_path) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +def load_config_modules(): + with patch.dict(sys.modules): + # Keep package __init__ files from importing the native model extension. + for name in ("infinilm", "infinilm.config", "infinilm.llm"): + sys.modules[name] = ModuleType(name) + for name, module in MODULES.items(): + sys.modules[f"infinilm.llm.{name}"] = module + kv = load_module("infinilm.config.kv_transfer", "config/kv_transfer.py") + sys.modules["infinilm.config"].KVTransferConfig = kv.KVTransferConfig + engine_config = load_module( + "infinilm.config.engine_config", "config/engine_config.py" + ) + load_module("infinilm.moe_config", "moe_config.py") + base = load_module("infinilm.base_config", "base_config.py") + load_module("infinilm.llm.static_scheduler", "llm/static_scheduler.py") + sys.modules["infinilm.infer_engine"] = SimpleNamespace( + read_hf_config=lambda path: {}, model_uses_mamba_cache=lambda config: False + ) + sys.modules["infinilm.kv_connector"] = SimpleNamespace( + KVConnectorFactory=object, KVConnectorRole=object + ) + sys.modules["infinilm.llm.model_runner.model_runner"] = SimpleNamespace( + ModelRunner=object + ) + sys.modules["infinilm.multimodal.multimodal"] = SimpleNamespace( + resolve_multimodal_inputs=object + ) + llm = load_module("infinilm.llm.llm", "llm/llm.py") + for name in ("AsyncLLMEngine", "FinishReason", "SamplingParams"): + setattr(sys.modules["infinilm.llm"], name, getattr(llm, name)) + server = load_module( + "infinilm.server.inference_server", "server/inference_server.py" + ) + return engine_config.EngineConfig, base.BaseConfig, llm, server + + +EngineConfig, BaseConfig, LLM_MODULE, SERVER_MODULE = load_config_modules() + + +class CachePolicyConfigTests(unittest.TestCase): + def test_engine_defaults_preserve_lru_for_both_cache_types(self): + for cache_type in ("paged", "static"): + config = EngineConfig("unused", cache_type=cache_type) + self.assertEqual(config.prefix_cache_policy, "lru") + self.assertEqual(config.prefix_cache_protected_ratio, 0.8) + + def test_engine_rejects_unknown_policy(self): + with self.assertRaisesRegex(ValueError, "prefix_cache_policy"): + EngineConfig("unused", prefix_cache_policy="fifo") + + def test_engine_rejects_nonfinite_and_boundary_ratios(self): + for ratio in (-0.1, 0, 1, 1.1, float("nan"), float("inf"), -float("inf")): + with self.subTest(ratio=ratio): + with self.assertRaisesRegex(ValueError, "prefix_cache_protected_ratio"): + EngineConfig("unused", prefix_cache_protected_ratio=ratio) + + def test_static_cache_rejects_slru_even_when_prefix_caching_disabled(self): + for enabled in (True, False): + with self.assertRaisesRegex(ValueError, "paged"): + EngineConfig( + "unused", + cache_type="static", + prefix_cache_policy="slru", + enable_prefix_caching=enabled, + ) + + def parse_cli(self, *args): + with patch.object( + sys, "argv", ["server", "--model", "unused", "--device", "cpu", *args] + ): + with patch.dict(os.environ): + return BaseConfig() + + def test_cli_defaults_and_explicit_slru(self): + default = self.parse_cli() + self.assertEqual(default.prefix_cache_policy, "lru") + self.assertEqual(default.prefix_cache_protected_ratio, 0.8) + custom = self.parse_cli( + "--enable-paged-attn", + "--prefix-cache-policy", + "slru", + "--prefix-cache-protected-ratio", + "0.6", + ) + self.assertEqual(custom.prefix_cache_policy, "slru") + self.assertEqual(custom.prefix_cache_protected_ratio, 0.6) + + def test_cli_rejects_unknown_policy_and_invalid_ratio(self): + invalid = [("--prefix-cache-policy", "fifo")] + invalid.extend( + ("--prefix-cache-protected-ratio", value) + for value in ("0", "1", "nan", "inf", "-inf") + ) + for args in invalid: + with self.subTest(args=args), redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit): + self.parse_cli(*args) + + def test_convenience_constructors_forward_policy_to_engine_config(self): + # Engine construction normally loads model weights; retain real config validation. + with patch.object( + LLM_MODULE, "LLMEngine", lambda config: SimpleNamespace(config=config) + ): + for constructor in (LLM_MODULE.LLM, LLM_MODULE.AsyncLLMEngine): + default = constructor("unused") + custom = constructor( + "unused", + prefix_cache_policy="slru", + prefix_cache_protected_ratio=0.6, + ) + self.assertEqual(default.engine.config.prefix_cache_policy, "lru") + self.assertEqual(custom.engine.config.prefix_cache_policy, "slru") + self.assertEqual(custom.engine.config.prefix_cache_protected_ratio, 0.6) + with self.assertRaisesRegex(ValueError, "paged"): + constructor( + "unused", cache_type="static", prefix_cache_policy="slru" + ) + + def test_paged_engine_forwards_policy_to_scheduler(self): + runner = SimpleNamespace( + device="cpu", + dtype="float16", + eos_token_id=[], + processor=SimpleNamespace(get_tokenizer=lambda: None), + model_engine=SimpleNamespace(hf_config={"max_position_embeddings": 4096}), + ) + config = EngineConfig( + "unused", prefix_cache_policy="slru", prefix_cache_protected_ratio=0.6 + ) + with patch.object(LLM_MODULE, "ModelRunner", lambda config: runner): + with patch.object(LLM_MODULE, "Scheduler") as scheduler: + LLM_MODULE.LLMEngine(config) + self.assertEqual(scheduler.call_args.kwargs["prefix_cache_policy"], "slru") + self.assertEqual( + scheduler.call_args.kwargs["prefix_cache_protected_ratio"], 0.6 + ) + + def run_offline_example(self, cli=False, **kwargs): + configs = [] + + def chat(model, messages): + configs.append(model.config) + return [] + + modules = { + "infinilm.base_config": SimpleNamespace(BaseConfig=BaseConfig), + "infinilm.llm.llm": LLM_MODULE, + "infinilm.moe_config": SimpleNamespace( + configure_moe_ep_backend=SERVER_MODULE.configure_moe_ep_backend + ), + "infinilm.processors.videonsa_processor": SimpleNamespace( + decode_video_frames=object + ), + } + argv = [ + "test_infer", + "--model", + "unused", + "--device", + "cpu", + "--enable-paged-attn", + "--prefix-cache-policy", + "slru", + "--prefix-cache-protected-ratio", + "0.6", + ] + with ( + patch.dict(sys.modules, modules), + patch.dict(os.environ), + patch.object(sys, "argv", argv), + patch.object( + LLM_MODULE, + "LLMEngine", + lambda config: SimpleNamespace(close=lambda: None), + ), + patch.object(LLM_MODULE.LLM, "chat", chat), + patch.object(SERVER_MODULE.logging, "basicConfig"), + redirect_stdout(io.StringIO()), + ): + example = runpy.run_path( + str(SOURCE.parents[1] / "examples/test_infer.py"), + run_name="__main__" if cli else "offline_example", + ) + if not cli: + example["test"](["hello"], "unused", enable_paged_attn=True, **kwargs) + return configs + + def test_offline_example_forwards_policy_to_llm_config(self): + default = self.run_offline_example() + custom = self.run_offline_example( + prefix_cache_policy="slru", prefix_cache_protected_ratio=0.6 + ) + self.assertEqual(default[0].prefix_cache_policy, "lru") + self.assertEqual(default[0].prefix_cache_protected_ratio, 0.8) + self.assertEqual(custom[0].prefix_cache_policy, "slru") + self.assertEqual(custom[0].prefix_cache_protected_ratio, 0.6) + + def test_offline_cli_reaches_llm_config(self): + configs = self.run_offline_example(cli=True) + self.assertEqual(len(configs), 1) + self.assertEqual(configs[0].prefix_cache_policy, "slru") + self.assertEqual(configs[0].prefix_cache_protected_ratio, 0.6) + + def test_cli_reaches_server_lifespan_and_async_engine_config(self): + configs = [] + + async def start_without_listener(server): + app = server._create_app() + async with app.router.lifespan_context(app): + configs.append(server.engine.config) + + argv = [ + "server", + "--model", + "unused", + "--device", + "cpu", + "--enable-paged-attn", + "--prefix-cache-policy", + "slru", + "--prefix-cache-protected-ratio", + "0.6", + ] + with ( + patch.object( + LLM_MODULE, "LLMEngine", lambda config: SimpleNamespace(config=config) + ), + patch.object(LLM_MODULE.AsyncLLMEngine, "start"), + patch.object(LLM_MODULE.AsyncLLMEngine, "stop"), + patch.object( + SERVER_MODULE.InferenceServer, + "start", + lambda server: asyncio.run(start_without_listener(server)), + ), + patch.object(SERVER_MODULE, "setup_logging"), + patch.object(sys, "argv", argv), + patch.dict(os.environ), + ): + SERVER_MODULE.main() + self.assertEqual(len(configs), 1) + self.assertEqual(configs[0].prefix_cache_policy, "slru") + self.assertEqual(configs[0].prefix_cache_protected_ratio, 0.6) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/llm/test_chunk_config.py b/test/llm/test_chunk_config.py new file mode 100644 index 000000000..aa3b121ad --- /dev/null +++ b/test/llm/test_chunk_config.py @@ -0,0 +1,377 @@ +"""CPU configuration coverage; native model construction is replaced at its boundary.""" + +import asyncio +import importlib.util +import io +import os +import runpy +import sys +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +from types import ModuleType, SimpleNamespace +from unittest.mock import patch + +from chunk_test_support import MODULES + +SOURCE = Path(__file__).resolve().parents[2] / "python/infinilm" + + +def load_module(name, relative_path): + spec = importlib.util.spec_from_file_location(name, SOURCE / relative_path) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +def load_config_modules(): + with patch.dict(sys.modules): + # Keep package __init__ files from importing the native model extension. + for name in ("infinilm", "infinilm.config", "infinilm.llm"): + sys.modules[name] = ModuleType(name) + for name, module in MODULES.items(): + sys.modules[f"infinilm.llm.{name}"] = module + kv = load_module("infinilm.config.kv_transfer", "config/kv_transfer.py") + sys.modules["infinilm.config"].KVTransferConfig = kv.KVTransferConfig + engine_config = load_module( + "infinilm.config.engine_config", "config/engine_config.py" + ) + load_module("infinilm.moe_config", "moe_config.py") + base = load_module("infinilm.base_config", "base_config.py") + load_module("infinilm.llm.static_scheduler", "llm/static_scheduler.py") + sys.modules["infinilm.infer_engine"] = SimpleNamespace( + read_hf_config=lambda path: {}, model_uses_mamba_cache=lambda config: False + ) + sys.modules["infinilm.kv_connector"] = SimpleNamespace( + KVConnectorFactory=object, KVConnectorRole=object + ) + sys.modules["infinilm.llm.model_runner.model_runner"] = SimpleNamespace( + ModelRunner=object + ) + sys.modules["infinilm.multimodal.multimodal"] = SimpleNamespace( + resolve_multimodal_inputs=object + ) + llm = load_module("infinilm.llm.llm", "llm/llm.py") + for name in ("AsyncLLMEngine", "FinishReason", "SamplingParams"): + setattr(sys.modules["infinilm.llm"], name, getattr(llm, name)) + server = load_module( + "infinilm.server.inference_server", "server/inference_server.py" + ) + return engine_config.EngineConfig, base.BaseConfig, llm, server + + +EngineConfig, BaseConfig, LLM_MODULE, SERVER_MODULE = load_config_modules() + + +class ChunkConfigTests(unittest.TestCase): + def test_default_preserves_unbounded_prefill_for_both_caches(self): + for cache_type in ("paged", "static"): + self.assertEqual( + EngineConfig("unused", cache_type=cache_type).prefill_chunk_size, 0 + ) + + def test_positive_chunk_size_and_inactive_transfer_are_supported(self): + kv_config = SERVER_MODULE.KVTransferConfig() + config = EngineConfig( + "unused", prefill_chunk_size=128, kv_transfer_config=kv_config + ) + self.assertEqual(config.prefill_chunk_size, 128) + + def test_tp2_chunking_is_supported_by_config_and_convenience_apis(self): + config = EngineConfig("unused", prefill_chunk_size=128, tensor_parallel_size=2) + self.assertEqual(config.tensor_parallel_size, 2) + with patch.object( + LLM_MODULE, "LLMEngine", lambda config: SimpleNamespace(config=config) + ): + for constructor in (LLM_MODULE.LLM, LLM_MODULE.AsyncLLMEngine): + engine = constructor( + "unused", prefill_chunk_size=128, tensor_parallel_size=2 + ) + self.assertEqual(engine.config.tensor_parallel_size, 2) + self.assertEqual(engine.config.prefill_chunk_size, 128) + cli = self.parse_cli("--tp", "2", "--prefill-chunk-size", "128") + self.assertEqual((cli.tp, cli.prefill_chunk_size), (2, 128)) + + def test_pp2_eager_chunking_and_worker_cli(self): + for stage in (0, 1): + EngineConfig( + "unused", + prefill_chunk_size=300, + pipeline_parallel_size=2, + pipeline_parallel_stage=stage, + prefix_cache_policy="slru", + ) + cfg = self.parse_cli( + "--pp", "2", "--node-rank", str(stage), "--prefill-chunk-size", "300" + ) + self.assertEqual(cfg.pp, 2) + with self.assertRaisesRegex(ValueError, "TP/PP"): + EngineConfig( + "unused", + prefill_chunk_size=300, + tensor_parallel_size=2, + pipeline_parallel_size=2, + ) + + def test_pipeline_worker_forwards_chunk_and_cache_configuration(self): + cfg = self.parse_cli( + "--pp", + "2", + "--node-rank", + "1", + "--enable-paged-attn", + "--prefill-chunk-size", + "300", + "--prefix-cache-policy", + "slru", + ) + captured = [] + closed = [] + + def make_runner(config, initialize_processor): + self.assertFalse(initialize_processor) + captured.append(config) + return SimpleNamespace(close=lambda: closed.append(True)) + + replacements = { + "infinilm.base_config": SimpleNamespace(BaseConfig=BaseConfig), + "infinilm.config.engine_config": SimpleNamespace(EngineConfig=EngineConfig), + "infinilm.distributed.pipeline_transport": SimpleNamespace( + PipelineWorkerClient=lambda *args, **kwargs: SimpleNamespace( + serve_forever=lambda: None + ) + ), + "infinilm.llm.model_runner.model_runner": SimpleNamespace( + ModelRunner=make_runner + ), + } + with patch.dict(sys.modules, replacements): + worker = load_module( + "infinilm.server.pipeline_worker", "server/pipeline_worker.py" + ) + worker.run_worker(cfg) + self.assertEqual(len(captured), 1) + self.assertEqual(captured[0].prefill_chunk_size, 300) + self.assertEqual(captured[0].prefix_cache_policy, "slru") + self.assertEqual(captured[0].pipeline_parallel_stage, 1) + self.assertEqual(closed, [True]) + + def test_chunk_size_rejects_negative_noninteger_and_boolean(self): + for value in (-1, 1.5, "128", None, True, False): + with self.subTest(value=value): + with self.assertRaisesRegex(ValueError, "prefill_chunk_size"): + EngineConfig("unused", prefill_chunk_size=value) + + def test_chunking_allows_tp_decode_graphs(self): + config = EngineConfig( + "unused", + prefill_chunk_size=512, + enable_graph=True, + attn_backend="flash-attn", + device="cuda", + tensor_parallel_size=1, + ) + self.assertTrue(config.enable_graph) + self.assertEqual(config.prefill_chunk_size, 512) + for tp in (1, 2): + for backend in ("default", "paged-attn", "flash-attn"): + EngineConfig( + "unused", + prefill_chunk_size=512, + enable_graph=True, + tensor_parallel_size=tp, + attn_backend=backend, + ) + for overrides in ( + {"pipeline_parallel_size": 2}, + {"device": "cpu"}, + {"attn_backend": "unsupported"}, + ): + values = dict( + prefill_chunk_size=512, + enable_graph=True, + attn_backend="flash-attn", + device="cuda", + ) + values.update(overrides) + with ( + self.subTest(overrides=overrides), + self.assertRaisesRegex(ValueError, "requires PP=1"), + ): + EngineConfig("unused", **values) + + def test_enabled_chunking_rejects_unsupported_execution_modes(self): + active_transfer = SERVER_MODULE.KVTransferConfig( + kv_connector="MooncakeConnector", kv_role="kv_producer" + ) + for overrides in ( + {"cache_type": "static"}, + {"enable_graph": True, "pipeline_parallel_size": 2}, + {"tensor_parallel_size": 4}, + {"pipeline_parallel_size": 3}, + {"use_mla": True}, + {"draft_model_path": "draft"}, + {"kv_transfer_config": active_transfer}, + ): + with self.subTest(overrides=overrides): + with self.assertRaisesRegex(ValueError, "prefill_chunk_size"): + EngineConfig("unused", prefill_chunk_size=128, **overrides) + EngineConfig("unused", prefill_chunk_size=0, **overrides) + + def parse_cli(self, *args): + with ( + patch.object( + sys, "argv", ["server", "--model", "unused", "--device", "cpu", *args] + ), + patch.dict(os.environ), + ): + return BaseConfig() + + def test_cli_default_and_explicit_nonnegative_chunk_size(self): + self.assertEqual(self.parse_cli().prefill_chunk_size, 0) + for value in ("0", "1", "128"): + self.assertEqual( + self.parse_cli("--prefill-chunk-size", value).prefill_chunk_size, + int(value), + ) + + def test_cli_rejects_chunked_parallelism_before_worker_dispatch(self): + for parallel in ( + ("--tp", "4"), + ("--pp", "3"), + ("--pp", "3", "--node-rank", "1"), + ): + with self.subTest(parallel=parallel): + with ( + redirect_stderr(io.StringIO()), + self.assertRaises(SystemExit) as error, + ): + self.parse_cli("--prefill-chunk-size", "128", *parallel) + self.assertEqual(error.exception.code, 2) + self.assertEqual(self.parse_cli(*parallel).prefill_chunk_size, 0) + + def test_cli_rejects_negative_and_noninteger_chunk_sizes(self): + for value in ("-1", "1.5", "True"): + with self.subTest(value=value), redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit): + self.parse_cli("--prefill-chunk-size", value) + + def test_cli_rejects_draft_model_with_chunking(self): + with redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit): + self.parse_cli("--prefill-chunk-size", "128", "--draft-model", "draft") + self.assertEqual(self.parse_cli("--draft-model", "draft").prefill_chunk_size, 0) + + def test_convenience_constructors_validate_chunking_before_model_load(self): + # Only native model construction is replaced; EngineConfig stays real. + with patch.object( + LLM_MODULE, "LLMEngine", lambda config: SimpleNamespace(config=config) + ): + for constructor in (LLM_MODULE.LLM, LLM_MODULE.AsyncLLMEngine): + self.assertEqual( + constructor("unused").engine.config.prefill_chunk_size, 0 + ) + config = constructor("unused", prefill_chunk_size=128).engine.config + self.assertEqual(config.prefill_chunk_size, 128) + with self.assertRaisesRegex(ValueError, "prefill_chunk_size"): + constructor("unused", cache_type="static", prefill_chunk_size=128) + + def test_cli_reaches_server_lifespan_and_async_engine_config(self): + configs = [] + + async def start_without_listener(server): + app = server._create_app() + async with app.router.lifespan_context(app): + configs.append(server.engine.config) + + with ( + patch.object( + LLM_MODULE, "LLMEngine", lambda config: SimpleNamespace(config=config) + ), + patch.object(LLM_MODULE.AsyncLLMEngine, "start"), + patch.object(LLM_MODULE.AsyncLLMEngine, "stop"), + patch.object( + SERVER_MODULE.InferenceServer, + "start", + lambda server: asyncio.run(start_without_listener(server)), + ), + patch.object(SERVER_MODULE, "setup_logging"), + patch.object( + sys, + "argv", + [ + "server", + "--model", + "unused", + "--device", + "cpu", + "--enable-paged-attn", + "--tp", + "2", + "--prefill-chunk-size", + "128", + ], + ), + patch.dict(os.environ), + ): + SERVER_MODULE.main() + self.assertEqual(len(configs), 1) + self.assertEqual(configs[0].prefill_chunk_size, 128) + self.assertEqual(configs[0].tensor_parallel_size, 2) + + def test_offline_cli_reaches_llm_config(self): + configs = [] + + def chat(model, messages): + configs.append(model.config) + return [] + + modules = { + "infinilm.base_config": SimpleNamespace(BaseConfig=BaseConfig), + "infinilm.llm.llm": LLM_MODULE, + "infinilm.moe_config": SimpleNamespace( + configure_moe_ep_backend=SERVER_MODULE.configure_moe_ep_backend + ), + "infinilm.processors.videonsa_processor": SimpleNamespace( + decode_video_frames=object + ), + } + with ( + patch.dict(sys.modules, modules), + patch.dict(os.environ), + patch.object( + sys, + "argv", + [ + "test_infer", + "--model", + "unused", + "--device", + "cpu", + "--enable-paged-attn", + "--tp", + "2", + "--prefill-chunk-size", + "128", + ], + ), + patch.object( + LLM_MODULE, + "LLMEngine", + lambda config: SimpleNamespace(close=lambda: None), + ), + patch.object(LLM_MODULE.LLM, "chat", chat), + patch.object(SERVER_MODULE.logging, "basicConfig"), + redirect_stdout(io.StringIO()), + ): + runpy.run_path( + str(SOURCE.parents[1] / "examples/test_infer.py"), run_name="__main__" + ) + self.assertEqual(len(configs), 1) + self.assertEqual(configs[0].prefill_chunk_size, 128) + self.assertEqual(configs[0].tensor_parallel_size, 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/llm/test_chunk_execution.py b/test/llm/test_chunk_execution.py new file mode 100644 index 000000000..ba8d48e06 --- /dev/null +++ b/test/llm/test_chunk_execution.py @@ -0,0 +1,218 @@ +import sys +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +from chunk_test_support import MODULES +from test_chunk_config import LLM_MODULE, load_module + + +def load_processor(): + with patch.dict(sys.modules): + for name, module in MODULES.items(): + sys.modules[f"infinilm.llm.{name}"] = module + sys.modules["transformers"] = SimpleNamespace(AutoTokenizer=object) + static = load_module("infinilm.llm.static_scheduler", "llm/static_scheduler.py") + load_module("infinilm.processors.processor", "processors/processor.py") + return ( + load_module( + "infinilm.processors.basic_llm_processor", + "processors/basic_llm_processor.py", + ).BasicLLMProcessor, + static, + ) + + +Processor, STATIC_MODULE = load_processor() + + +class ChunkExecutionTests(unittest.TestCase): + def test_legacy_static_output_does_not_require_chunk_metadata(self): + engine, req = self.setup_engine(length=1) + engine.scheduler = STATIC_MODULE.StaticScheduler() + self.addCleanup(engine.scheduler.waiting_queue.close) + engine.scheduler.add_request(req) + engine.step() + self.assertEqual(list(req.generated_token_ids), [77]) + + def test_static_processor_still_uses_its_prefix_metadata(self): + _, req = self.setup_engine(length=17) + output = STATIC_MODULE.StaticSchedulerOutput( + [req], is_prefill=True, prefix_hit_len=16 + ) + processor = Processor.__new__(Processor) + backend = SimpleNamespace( + from_list=lambda values, **kwargs: values, int64="int64", int32="int32" + ) + with patch.dict(sys.modules, {"infinicore": backend}): + inputs = processor.build_model_inputs(output) + self.assertEqual([list(row) for row in inputs["input_ids"]], [[26]]) + self.assertEqual(inputs["position_ids"], [[16]]) + self.assertEqual(inputs["past_kv_lengths"], [16]) + self.assertEqual(inputs["total_kv_lengths"], [17]) + self.assertIsNone(inputs["slot_mapping"]) + + def test_unsupported_models_rejected_before_native_initialization(self): + from test_chunk_config import EngineConfig + + for hf in ({"num_experts": 8}, {"vision_config": {}}, {"audio_config": {}}): + with ( + self.subTest(hf=hf), + patch.object(LLM_MODULE, "read_hf_config", return_value=hf), + ): + with self.assertRaisesRegex(ValueError, "dense text"): + LLM_MODULE.LLMEngine(EngineConfig("unused", prefill_chunk_size=16)) + with patch.object(LLM_MODULE, "model_uses_mamba_cache", return_value=True): + with self.assertRaisesRegex(ValueError, "dense text"): + LLM_MODULE.LLMEngine(EngineConfig("unused", prefill_chunk_size=16)) + + def setup_engine(self, length=35, prefix=True): + scheduler = MODULES["scheduler"].Scheduler( + num_blocks=16, + block_size=16, + prefill_chunk_size=16, + enable_prefix_caching=prefix, + ) + self.addCleanup(scheduler.waiting_queue.close) + self.addCleanup(scheduler.running_queue.close) + req = MODULES["request"].InferenceRequest( + "long", + prompt_token_ids=list(range(10, 10 + length)), + sampling_params=MODULES["sampling_params"].SamplingParams( + max_tokens=1, ignore_eos=True + ), + ) + scheduler.add_request(req) + engine = LLM_MODULE.LLMEngine.__new__(LLM_MODULE.LLMEngine) + engine.scheduler = scheduler + engine.tokenizer = SimpleNamespace(decode=lambda tokens: "output") + engine.model_runner = SimpleNamespace( + execute_model=lambda output: SimpleNamespace( + sampled_token_ids=[77] * len(output.scheduled_requests), + kv_connector_output=None, + ) + ) + return engine, req + + def test_intermediate_steps_publish_only_computed_pages_and_emit_no_tokens(self): + engine, req = self.setup_engine() + for end, pages in ((16, 1), (32, 2)): + worked, pending = engine.step() + self.assertTrue(worked) + self.assertEqual(pending, []) + self.assertEqual(req.get_num_generated_tokens(), 0) + self.assertEqual(req.num_computed_tokens, end) + self.assertEqual(req.num_cache_indexed_blocks, pages) + self.assertEqual( + len(engine.scheduler.cache_manager.hash_to_block_ids), pages + ) + engine.step() + self.assertEqual(list(req.generated_token_ids), [77]) + self.assertEqual(req.status, MODULES["request"].RequestStatus.FINISHED) + self.assertEqual(engine.scheduler.cache_manager.get_total_usable_blocks(), 16) + + def test_abort_during_intermediate_forward_releases_ownership(self): + engine, req = self.setup_engine() + + def execute(output): + req._aborted = True + return SimpleNamespace(sampled_token_ids=[77], kv_connector_output=None) + + engine.model_runner.execute_model = execute + engine.step() + self.assertEqual(req.status, MODULES["request"].RequestStatus.CANCELED) + self.assertFalse(engine.scheduler.chunking_queue) + self.assertEqual(engine.scheduler.running_queue.sync_q.qsize(), 0) + self.assertEqual(req.get_num_generated_tokens(), 0) + self.assertEqual(req.num_cache_indexed_blocks, 1) + self.assertEqual(engine.scheduler.cache_manager.get_total_usable_blocks(), 16) + + def test_other_request_completion_never_publishes_future_long_pages(self): + engine, req = self.setup_engine(length=64) + engine.step() + short = MODULES["request"].InferenceRequest( + "short", + prompt_token_ids=[8], + sampling_params=MODULES["sampling_params"].SamplingParams( + max_tokens=1, ignore_eos=True + ), + ) + engine.scheduler.add_request(short) + engine.step() + engine.step() + self.assertEqual(short.get_num_generated_tokens(), 1) + self.assertEqual(req.num_computed_tokens, 32) + self.assertNotIn( + req.block_hashes[2], engine.scheduler.cache_manager.hash_to_block_ids + ) + self.assertNotIn( + req.block_hashes[3], engine.scheduler.cache_manager.hash_to_block_ids + ) + + def test_disabled_prefix_cache_still_completes_in_three_steps(self): + engine, req = self.setup_engine(prefix=False) + for _ in range(3): + engine.step() + self.assertEqual(list(req.generated_token_ids), [77]) + self.assertFalse(engine.scheduler.cache_manager.hash_to_block_ids) + + def test_abort_during_final_chunk_emits_nothing_and_releases(self): + engine, req = self.setup_engine(length=17) + engine.step() + + def execute(output): + req.abort() + return SimpleNamespace(sampled_token_ids=[77], kv_connector_output=None) + + engine.model_runner.execute_model = execute + self.assertEqual(engine.step(), (True, [])) + self.assertEqual(req.get_num_generated_tokens(), 0) + self.assertEqual(req.status, MODULES["request"].RequestStatus.CANCELED) + self.assertTrue( + all(b.ref_count == 0 for b in engine.scheduler.cache_manager.blocks) + ) + + def test_intermediate_eos_is_ignored_and_final_eos_finishes(self): + engine, req = self.setup_engine(length=17) + req.sampling_params.ignore_eos = False + req.sampling_params.max_tokens = 8 + engine.eos_token_ids = [77] + engine.step() + self.assertFalse(req.is_finished()) + self.assertEqual(req.get_num_generated_tokens(), 0) + engine.step() + self.assertTrue(req.is_finished()) + self.assertEqual(list(req.generated_token_ids), [77]) + self.assertEqual(req.finish_reason, MODULES["request"].FinishReason.EOS_TOKEN) + + def test_decode_extends_hashes_and_only_publishes_computed_tokens(self): + engine, req = self.setup_engine(length=31) + req.sampling_params.max_tokens = 3 + engine.step() + engine.step() + self.assertEqual(len(req.block_hashes), 2) + self.assertEqual(req.num_cache_indexed_blocks, 1) + engine.step() + self.assertEqual(req.num_cache_indexed_blocks, 2) + engine.step() + self.assertTrue(req.is_finished()) + self.assertTrue( + all(b.ref_count == 0 for b in engine.scheduler.cache_manager.blocks) + ) + + def test_processor_slices_partial_prefill_positions_and_lengths(self): + engine, req = self.setup_engine(length=35) + engine.step() + step = engine.scheduler.schedule() + processor = Processor.__new__(Processor) + backend = SimpleNamespace( + from_list=lambda values, **kwargs: values, int64="int64", int32="int32" + ) + with patch.dict(sys.modules, {"infinicore": backend}): + inputs = processor.build_model_inputs(step) + self.assertEqual(inputs["input_ids"], [list(range(26, 42))]) + self.assertEqual(inputs["position_ids"], list(range(16, 32))) + self.assertEqual(inputs["past_kv_lengths"], [16]) + self.assertEqual(inputs["total_kv_lengths"], [32]) + self.assertEqual(inputs["input_offsets"], [0, 16]) + self.assertEqual(len(inputs["slot_mapping"]), 16) diff --git a/test/llm/test_chunk_output.py b/test/llm/test_chunk_output.py new file mode 100644 index 000000000..a0b5b1891 --- /dev/null +++ b/test/llm/test_chunk_output.py @@ -0,0 +1,115 @@ +"""Exercise the real runner boundary without loading a native model.""" + +import sys +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +import test_chunk_execution +from test_chunk_config import EngineConfig, load_module + + +def load_runner(): + replacements = { + "infinicore": SimpleNamespace(), + "infinilm.cache.cache": SimpleNamespace( + PagedKVCacheConfig=object, StaticKVCacheConfig=object + ), + "infinilm.config.engine_config": SimpleNamespace(EngineConfig=EngineConfig), + "infinilm.distributed": SimpleNamespace(DistConfig=object), + "infinilm.distributed.pipeline_transport": SimpleNamespace( + PipelineControlServer=object + ), + "infinilm.infer_engine": SimpleNamespace(InferEngine=object), + "infinilm.kv_connector": SimpleNamespace( + KVConnectorFactory=object, KVConnectorRole=object + ), + "infinilm.llm.model_runner.speculative_runner": SimpleNamespace( + SpeculativeRunner=object + ), + "infinilm.modeling_utils": SimpleNamespace( + load_model_state_dict_by_file=object + ), + "infinilm.processors": SimpleNamespace(AutoInfinilmProcessor=object), + } + with patch.dict(sys.modules, replacements): + return load_module( + "infinilm.llm.model_runner.model_runner", "llm/model_runner/model_runner.py" + ).ModelRunner + + +Runner = load_runner() + + +class ChunkOutputTests(unittest.TestCase): + def setup_runner(self): + engine, req = test_chunk_execution.ChunkExecutionTests.setup_engine(self) + runner = Runner.__new__(Runner) + runner.config = EngineConfig("unused", prefill_chunk_size=16) + runner.processor = SimpleNamespace(build_model_inputs=lambda *a: {}) + runner.speculative_runner = None + runner.pipeline_control = None + runner.kv_connector = None + calls = [] + + def forward(**kwargs): + calls.append(kwargs.get("prefill_only", False)) + if kwargs.get("prefill_only"): + return None + return SimpleNamespace( + to_numpy=lambda: SimpleNamespace(tolist=lambda: [77]) + ) + + runner.model_engine = SimpleNamespace(forward=forward) + engine.model_runner = runner + return engine, req, calls + + def test_only_intermediate_chunks_skip_native_output(self): + engine, req, calls = self.setup_runner() + for _ in range(2): + self.assertEqual(engine.step(), (True, [])) + self.assertEqual(list(req.generated_token_ids), []) + self.assertEqual(calls, [True, True]) + engine.step() + self.assertEqual(calls, [True, True, False]) + self.assertEqual(list(req.generated_token_ids), [77]) + self.assertTrue( + all(b.ref_count == 0 for b in engine.scheduler.cache_manager.blocks) + ) + + def test_decode_keeps_sampling(self): + engine, req, calls = self.setup_runner() + req.sampling_params.max_tokens = 2 + for _ in range(4): + engine.step() + self.assertEqual(calls, [True, True, False, False]) + self.assertEqual(list(req.generated_token_ids), [77, 77]) + + def test_legacy_output_without_chunk_metadata_keeps_sampling(self): + engine, req, calls = self.setup_runner() + output = SimpleNamespace( + scheduled_requests=[req], num_requests=1, is_prefill=True + ) + result = engine.model_runner.execute_model(output) + self.assertEqual(calls, [False]) + self.assertEqual(result.sampled_token_ids, [77]) + + def test_empty_native_output_still_finishes_cancelled_chunk(self): + engine, req, calls = self.setup_runner() + forward = engine.model_runner.model_engine.forward + + def abort(**kwargs): + req.abort() + return forward(**kwargs) + + engine.model_runner.model_engine.forward = abort + self.assertEqual(engine.step(), (True, [])) + self.assertEqual(calls, [True]) + self.assertEqual(list(req.generated_token_ids), []) + self.assertTrue( + all(b.ref_count == 0 for b in engine.scheduler.cache_manager.blocks) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/llm/test_chunk_scheduler.py b/test/llm/test_chunk_scheduler.py new file mode 100644 index 000000000..faf20d471 --- /dev/null +++ b/test/llm/test_chunk_scheduler.py @@ -0,0 +1,222 @@ +import unittest + +from chunk_test_support import MODULES + +Scheduler = MODULES["scheduler"].Scheduler +Request = MODULES["request"].InferenceRequest +Status = MODULES["request"].RequestStatus +Sampling = MODULES["sampling_params"].SamplingParams + + +class ChunkSchedulerTests(unittest.TestCase): + def scheduler(self, **kwargs): + config = dict(num_blocks=64, block_size=16, prefill_chunk_size=16) + config.update(kwargs) + scheduler = Scheduler(**config) + self.addCleanup(scheduler.waiting_queue.close) + self.addCleanup(scheduler.running_queue.close) + return scheduler + + @staticmethod + def request(name, length, output=4): + return Request( + name, + prompt_token_ids=[11] * length, + sampling_params=Sampling(max_tokens=output), + ) + + def finish_step(self, scheduler, output): + for req in output.scheduled_requests: + if output.prefill_end is not None: + req.num_computed_tokens = output.prefill_end + scheduler.commit_computed_tokens(req, output.prefill_end) + if output.prefill_end < req.prompt_length: + scheduler.requeue_prefill(req) + continue + req.append_generated_token_id(12) + scheduler.complete_requests([req]) + + def test_slru_chunk_admission_promotes_hits_only_after_allocation(self): + from cache_test_support import assert_state, publish + + for rejected in (False, True): + with self.subTest(rejected=rejected): + scheduler = self.scheduler(num_blocks=4, prefix_cache_policy="slru") + manager = scheduler.cache_manager + table, _ = publish(manager, [11] * 16) + req = self.request("hit", 1000 if rejected else 33, output=1) + scheduler.add_request(req) + step = scheduler.schedule() + if rejected: + self.assertIsNone(step) + self.assertFalse(manager._protected_blocks) + self.assertEqual(manager.blocks[table[0]].ref_count, 0) + else: + self.assertEqual(step.scheduled_requests, [req]) + self.assertIn(table[0], manager._protected_blocks) + req.status = Status.CANCELED + scheduler.complete_requests([req]) + assert_state(self, manager) + + def test_chunk_boundaries_and_last_partial_segment(self): + scheduler = self.scheduler() + req = self.request("long", 35) + scheduler.add_request(req) + for start, end in ((0, 16), (16, 32), (32, 35)): + step = scheduler.schedule() + self.assertTrue(step.is_prefill) + self.assertEqual(step.prefill_end, end) + self.assertEqual(req.num_local_cached_tokens, start) + self.assertEqual(len(req.slot_mapping), end - start) + self.assertEqual(req.get_num_generated_tokens(), 0) + self.finish_step(scheduler, step) + self.assertEqual(req.get_num_generated_tokens(), 1) + self.assertFalse(scheduler.schedule().is_prefill) + + def test_chunk_respects_smaller_token_budget(self): + scheduler = self.scheduler(max_num_batched_tokens=7) + req = self.request("limited", 20) + scheduler.add_request(req) + self.assertEqual(scheduler.schedule().prefill_end, 7) + + def test_disabled_mode_retains_whole_prompt_dispatch(self): + scheduler = self.scheduler(prefill_chunk_size=0, max_num_batched_tokens=16) + req = self.request("legacy", 35) + scheduler.add_request(req) + step = scheduler.schedule() + self.assertTrue(step.is_prefill) + self.assertIsNone(step.prefill_end) + self.assertEqual(len(req.slot_mapping), 35) + self.assertFalse(scheduler.chunking_queue) + + def test_nonaligned_chunks_reconstruct_physical_slots(self): + scheduler = self.scheduler(prefill_chunk_size=11) + req = self.request("unaligned", 35) + scheduler.add_request(req) + for start, end in ((0, 11), (11, 22), (22, 33), (33, 35)): + step = scheduler.schedule() + expected = [ + req.block_table[i // 16] * 16 + i % 16 for i in range(start, end) + ] + self.assertEqual(req.slot_mapping, expected) + self.finish_step(scheduler, step) + + def test_published_partial_prefix_starts_at_hit_boundary(self): + scheduler = self.scheduler() + first = self.request("first", 49) + scheduler.add_request(first) + self.finish_step(scheduler, scheduler.schedule()) + first.status = Status.CANCELED + second = self.request("second", 49) + scheduler.add_request(second) + seen = False + for _ in range(4): + step = scheduler.schedule() + if step.scheduled_requests == [second]: + self.assertEqual(second.num_local_cached_tokens, 16) + self.assertEqual(step.prefill_end, 32) + seen = True + break + self.finish_step(scheduler, step) + self.assertTrue(seen) + + def test_decode_continuation_and_admission_each_get_dispatch_opportunities(self): + scheduler = self.scheduler(max_batch_size=1) + active = self.request("active", 1) + scheduler.add_request(active) + self.finish_step(scheduler, scheduler.schedule()) + long = self.request("long", 200) + scheduler.add_request(long) + kinds = [] + seen = {"active"} + for i in range(12): + scheduler.add_request(self.request(f"new-{i}", 100)) + step = scheduler.schedule() + req = step.scheduled_requests[0] + kinds.append( + "decode" + if not step.is_prefill + else "continue" if req.request_id in seen else "admit" + ) + seen.add(req.request_id) + self.finish_step(scheduler, step) + for start in (3, 6, 9): + self.assertEqual( + set(kinds[start : start + 3]), {"decode", "continue", "admit"} + ) + + def test_cancelled_middle_chunk_is_released_without_requeue(self): + scheduler = self.scheduler() + req = self.request("canceled", 64) + scheduler.add_request(req) + self.finish_step(scheduler, scheduler.schedule()) + req.status = Status.CANCELED + self.assertIsNone(scheduler.schedule()) + self.assertFalse(scheduler.chunking_queue) + self.assertEqual(scheduler.cache_manager.get_total_usable_blocks(), 64) + + def test_prefix_disabled_still_advances(self): + scheduler = self.scheduler(enable_prefix_caching=False) + req = self.request("no-cache", 33) + scheduler.add_request(req) + for end in (16, 32, 33): + step = scheduler.schedule() + self.assertEqual(step.prefill_end, end) + self.finish_step(scheduler, step) + self.assertFalse(scheduler.cache_manager.hash_to_block_ids) + + def test_partial_requests_reserve_future_decode_capacity(self): + scheduler = self.scheduler(num_blocks=6) + long = self.request("long", 48, output=32) + scheduler.add_request(long) + self.finish_step(scheduler, scheduler.schedule()) + other = self.request("other", 32, output=16) + scheduler.add_request(other) + for _ in range(2): + step = scheduler.schedule() + self.assertNotIn(other, step.scheduled_requests) + self.finish_step(scheduler, step) + self.assertEqual(other.status, Status.WAITING) + + def test_rejected_admission_returns_temporary_prefix_reference(self): + scheduler = self.scheduler(num_blocks=5) + req = self.request("long", 48, output=16) + scheduler.add_request(req) + self.finish_step(scheduler, scheduler.schedule()) + other = self.request("too-large", 48, output=128) + scheduler.add_request(other) + before = [b.ref_count for b in scheduler.cache_manager.blocks] + step = scheduler.schedule() + self.assertNotIn(other, step.scheduled_requests) + self.assertEqual(before, [b.ref_count for b in scheduler.cache_manager.blocks]) + + def test_shared_prefix_stays_pinned_until_both_owners_release(self): + scheduler = self.scheduler() + first = self.request("first", 64) + second = self.request("second", 64) + scheduler.add_request(first) + self.finish_step(scheduler, scheduler.schedule()) + scheduler.add_request(second) + for _ in range(3): + step = scheduler.schedule() + self.finish_step(scheduler, step) + if second.status == Status.RUNNING: + break + shared = first.block_table[0] + self.assertEqual(second.block_table[0], shared) + self.assertEqual(scheduler.cache_manager.blocks[shared].ref_count, 2) + first.mark_canceled() + scheduler.schedule() + self.assertEqual(scheduler.cache_manager.blocks[shared].ref_count, 1) + # The previous dispatch may have removed the second request for execution. + second.mark_canceled() + scheduler.complete_requests([second]) + self.assertTrue(all(b.ref_count == 0 for b in scheduler.cache_manager.blocks)) + + def test_multimodal_requests_fail_before_ownership_is_acquired(self): + scheduler = self.scheduler() + req = self.request("image", 16) + req.has_multimodal_inputs = True + with self.assertRaisesRegex(ValueError, "multimodal"): + scheduler.add_request(req) + self.assertEqual(scheduler.waiting_queue.sync_q.qsize(), 0) diff --git a/test/llm/test_scheduler_cache_lifecycle.py b/test/llm/test_scheduler_cache_lifecycle.py new file mode 100644 index 000000000..c13f0787c --- /dev/null +++ b/test/llm/test_scheduler_cache_lifecycle.py @@ -0,0 +1,350 @@ +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +from cache_test_support import MODULES, assert_state, chain_hashes, publish + +Scheduler = MODULES["scheduler"].Scheduler +InferenceRequest = MODULES["request"].InferenceRequest +RequestStatus = MODULES["request"].RequestStatus +SamplingParams = MODULES["sampling_params"].SamplingParams +MambaCacheManager = MODULES["cache_manager"].MambaCacheManager + + +class DelayedConnector: + def request_finished(self, request, block_table, block_size): + return True, None + + +class SchedulerCacheTests(unittest.TestCase): + def make_scheduler(self, **kwargs): + scheduler = Scheduler(**kwargs) + self.addCleanup(scheduler.waiting_queue.close) + self.addCleanup(scheduler.running_queue.close) + return scheduler + + @staticmethod + def output(**connector_output): + return SimpleNamespace(kv_connector_output=SimpleNamespace(**connector_output)) + + def test_send_completion_releases_exactly_once(self): + scheduler = self.make_scheduler( + num_blocks=1, block_size=16, connector=DelayedConnector() + ) + manager = scheduler.cache_manager + request = InferenceRequest("delayed", prompt_token_ids=[11] * 16) + request.block_table, _ = manager.allocate_slots(16) + manager.publish_computed_blocks( + request.block_table, chain_hashes([11] * 16), 0, 16 + ) + block_id = request.block_table[0] + request.status = RequestStatus.CANCELED + + scheduler.complete_requests([request]) + + self.assertFalse(manager.try_free_blocks(1)) + self.assertEqual(manager.blocks[block_id].ref_count, 1) + event = self.output(finished_sending={"delayed"}) + scheduler.update_from_output(event) + scheduler.update_from_output(event) + self.assertEqual(scheduler.pending_free_blocks, {}) + self.assertEqual(manager.get_total_usable_blocks(), 1) + assert_state(self, manager) + + def test_terminal_status_releases_tail_and_preserves_shared_prefix(self): + for status in ( + RequestStatus.FINISHED, + RequestStatus.CANCELED, + RequestStatus.FAILED, + RequestStatus.TIMEOUT, + ): + with self.subTest(status=status): + scheduler = self.make_scheduler(num_blocks=2, block_size=16) + manager = scheduler.cache_manager + request = InferenceRequest(status.value, prompt_token_ids=[11] * 17) + request.block_table, _ = manager.allocate_slots(17) + hashes = chain_hashes([11] * 16) + manager.publish_computed_blocks(request.block_table, hashes, 0, 16) + shared, hit = manager.get_computed_blocks(hashes, 16) + prefix_id, tail_id = request.block_table + request.status = status + + scheduler.complete_requests([request]) + + self.assertEqual(hit, 16) + self.assertEqual(manager.blocks[prefix_id].ref_count, 1) + self.assertIn(prefix_id, manager.used_block_ids) + self.assertIn(tail_id, manager.free_block_ids) + self.assertNotIn(prefix_id, manager._evictable_blocks) + manager.free_blocks(shared) + self.assertIn(prefix_id, manager._evictable_blocks) + assert_state(self, manager) + + def test_admission_failure_returns_temporary_prefix_pin(self): + scheduler = self.make_scheduler(num_blocks=2, block_size=16) + manager = scheduler.cache_manager + table, hashes = publish(manager, [11] * 16) + request = InferenceRequest( + "too-large", + prompt_token_ids=[11] * 16 + [12], + sampling_params=SamplingParams(max_tokens=64), + ) + scheduler.add_request(request) + + self.assertIsNone(scheduler.schedule()) + + self.assertEqual(request.status, RequestStatus.WAITING) + self.assertEqual(scheduler.waiting_queue.sync_q.qsize(), 1) + self.assertEqual(manager.blocks[table[0]].ref_count, 0) + self.assertEqual(list(manager._evictable_blocks), table) + self.assertEqual(manager.get_total_usable_blocks(), 2) + assert_state(self, manager) + + def test_failed_admission_refreshes_prefix_release_recency(self): + scheduler = self.make_scheduler(num_blocks=3, block_size=16) + manager = scheduler.cache_manager + a_table, _ = publish(manager, [11] * 16) + b_table, b_hashes = publish(manager, [22] * 16) + c_table, _ = publish(manager, [33] * 16) + request = InferenceRequest( + "touch-a", + prompt_token_ids=[11] * 16 + [12], + sampling_params=SamplingParams(max_tokens=64), + ) + scheduler.add_request(request) + + self.assertIsNone(scheduler.schedule()) + allocation = manager.allocate_slots(16) + + self.assertIsNotNone(allocation) + self.assertEqual(list(manager._evictable_blocks), [c_table[0], a_table[0]]) + self.assertNotIn(b_hashes[0], manager.hash_to_block_ids) + self.assertEqual(allocation[0], b_table) + manager.free_blocks(allocation[0]) + assert_state(self, manager) + + def test_token_budget_defer_returns_second_requests_temporary_pin(self): + scheduler = self.make_scheduler( + max_batch_size=2, + max_num_batched_tokens=16, + num_blocks=8, + block_size=16, + ) + manager = scheduler.cache_manager + prefix_table, _ = publish(manager, [11] * 16) + first = InferenceRequest( + "first", + prompt_token_ids=[11] * 16 + [21], + sampling_params=SamplingParams(max_tokens=1), + ) + second = InferenceRequest( + "second", + prompt_token_ids=[11] * 16 + [31] * 32, + sampling_params=SamplingParams(max_tokens=1), + ) + scheduler.add_request(first) + scheduler.add_request(second) + + batch = scheduler.schedule() + + self.assertEqual(batch.scheduled_requests, [first]) + self.assertEqual(first.status, RequestStatus.RUNNING) + self.assertEqual(second.status, RequestStatus.WAITING) + self.assertEqual(scheduler.waiting_queue.sync_q.qsize(), 1) + self.assertEqual(manager.blocks[prefix_table[0]].ref_count, 1) + self.assertNotIn(prefix_table[0], manager._evictable_blocks) + first.status = RequestStatus.CANCELED + scheduler.complete_requests([first]) + assert_state(self, manager) + + def test_prefix_disabled_releases_all_pages_as_free(self): + scheduler = self.make_scheduler( + num_blocks=3, block_size=16, enable_prefix_caching=False + ) + request = InferenceRequest( + "no-prefix", + prompt_token_ids=[11] * 17, + sampling_params=SamplingParams(max_tokens=1), + ) + scheduler.add_request(request) + batch = scheduler.schedule() + request.status = RequestStatus.CANCELED + + scheduler.complete_requests([request]) + + self.assertEqual(batch.scheduled_requests, [request]) + self.assertEqual(list(request.block_hashes), []) + self.assertEqual(scheduler.cache_manager.get_num_free_blocks(), 3) + self.assertEqual(scheduler.cache_manager.hash_to_block_ids, {}) + assert_state(self, scheduler.cache_manager) + + def test_receive_completion_releases_canceled_remote_request_once(self): + scheduler = self.make_scheduler( + num_blocks=1, block_size=16, connector=DelayedConnector() + ) + manager = scheduler.cache_manager + request = InferenceRequest( + "remote", + prompt_token_ids=[11] * 16, + sampling_params=SamplingParams(max_tokens=1), + ) + request.block_table, _ = manager.allocate_slots(16) + manager.publish_computed_blocks( + request.block_table, chain_hashes([11] * 16), 0, 16 + ) + block_id = request.block_table[0] + scheduler.remote_kv_requests[request.request_id] = request + scheduler.pending_kv_decode_blocks = 1 + request.status = RequestStatus.CANCELED + + scheduler.complete_requests([request]) + + self.assertEqual(scheduler.pending_kv_decode_blocks, 0) + self.assertFalse(manager.try_free_blocks(1)) + self.assertEqual(manager.blocks[block_id].ref_count, 1) + event = self.output(finished_recving={"remote", "unknown"}) + scheduler.update_from_output(event) + scheduler.update_from_output(event) + self.assertEqual(scheduler.pending_free_blocks, {}) + self.assertEqual(manager.get_total_usable_blocks(), 1) + assert_state(self, manager) + + def test_mamba_manager_and_scheduler_release_owned_rows_and_pages(self): + mamba = MambaCacheManager(3) + first = mamba.allocate() + second = mamba.allocate() + self.assertEqual((first, second), (1, 2)) + self.assertIsNone(mamba.allocate()) + mamba.free(first) + self.assertEqual(mamba.allocate(), 1) + + scheduler = self.make_scheduler( + num_blocks=3, + block_size=16, + has_mamba_cache=True, + num_mamba_cache_blocks=2, + ) + request = InferenceRequest( + "mamba", + prompt_token_ids=[11] * 17, + sampling_params=SamplingParams(max_tokens=1), + ) + scheduler.add_request(request) + batch = scheduler.schedule() + owned_row = request.mamba_cache_index + request.status = RequestStatus.CANCELED + + scheduler.complete_requests([request]) + + self.assertEqual(batch.scheduled_requests, [request]) + self.assertEqual(list(request.block_hashes), []) + self.assertIsNotNone(owned_row) + self.assertIsNone(request.mamba_cache_index) + self.assertEqual(scheduler.mamba_cache_manager.get_num_free_blocks(), 1) + self.assertEqual(scheduler.cache_manager.get_num_free_blocks(), 3) + assert_state(self, scheduler.cache_manager) + + +class SegmentedSchedulerCacheTests(SchedulerCacheTests): + def make_scheduler(self, **kwargs): + kwargs["prefix_cache_policy"] = "slru" + kwargs["prefix_cache_protected_ratio"] = 0.5 + return super().make_scheduler(**kwargs) + + def test_only_successful_admission_promotes_local_prefix(self): + scheduler = self.make_scheduler(num_blocks=4, block_size=16) + manager = scheduler.cache_manager + table, hashes = publish(manager, [11] * 16) + rejected = InferenceRequest( + "rejected", + prompt_token_ids=[11] * 16 + [12], + sampling_params=SamplingParams(max_tokens=128), + ) + scheduler.add_request(rejected) + self.assertIsNone(scheduler.schedule()) + self.assertFalse(manager._protected_blocks) + rejected.status = RequestStatus.CANCELED + admitted = InferenceRequest( + "admitted", + prompt_token_ids=[11] * 16 + [13], + sampling_params=SamplingParams(max_tokens=1), + ) + scheduler.add_request(admitted) + self.assertEqual(scheduler.schedule().scheduled_requests, [admitted]) + self.assertEqual(list(manager._protected_blocks), table) + self.assertFalse(manager._protected_evictable_blocks) + admitted.status = RequestStatus.CANCELED + scheduler.complete_requests([admitted]) + self.assertEqual(list(manager._protected_evictable_blocks), table) + for token in range(20, 28): + publish(manager, [token] * 16) + self.assertIn(hashes[0], manager.hash_to_block_ids) + assert_state(self, manager) + + def test_token_budget_probe_does_not_promote_unrelated_prefix(self): + scheduler = self.make_scheduler( + num_blocks=8, block_size=16, max_batch_size=2, max_num_batched_tokens=16 + ) + manager = scheduler.cache_manager + table, _ = publish(manager, [22] * 16) + first = InferenceRequest( + "first", + prompt_token_ids=[11] * 8, + sampling_params=SamplingParams(max_tokens=1), + ) + second = InferenceRequest( + "deferred", + prompt_token_ids=[22] * 16 + [33] * 16, + sampling_params=SamplingParams(max_tokens=1), + ) + scheduler.add_request(first) + scheduler.add_request(second) + self.assertEqual(scheduler.schedule().scheduled_requests, [first]) + self.assertEqual(second.status, RequestStatus.WAITING) + self.assertNotIn(table[0], manager._protected_blocks) + first.status = RequestStatus.CANCELED + scheduler.complete_requests([first]) + assert_state(self, manager) + + def test_allocation_failure_does_not_promote(self): + scheduler = self.make_scheduler(num_blocks=4, block_size=16) + manager = scheduler.cache_manager + table, _ = publish(manager, [11] * 16) + request = InferenceRequest( + "allocation-failed", + prompt_token_ids=[11] * 16 + [12], + sampling_params=SamplingParams(max_tokens=1), + ) + scheduler.add_request(request) + with patch.object(manager, "allocate_slots", return_value=None): + self.assertIsNone(scheduler.schedule()) + self.assertEqual(request.status, RequestStatus.WAITING) + self.assertFalse(manager._protected_blocks) + self.assertEqual(list(manager._evictable_blocks), table) + assert_state(self, manager) + + def test_protected_remote_owner_is_not_evictable_until_transfer_finishes(self): + scheduler = self.make_scheduler( + num_blocks=4, block_size=16, connector=DelayedConnector() + ) + manager = scheduler.cache_manager + _, hashes = publish(manager, [11] * 16) + table, _ = manager.get_computed_blocks(hashes, 16) + manager.record_cache_hit(table) + request = InferenceRequest("protected-send", prompt_token_ids=[11] * 16) + request.block_table = table + request.status = RequestStatus.CANCELED + scheduler.complete_requests([request]) + self.assertEqual(list(manager._protected_blocks), table) + self.assertFalse(manager.try_free_blocks(4)) + self.assertEqual(manager.blocks[table[0]].ref_count, 1) + event = self.output(finished_sending={request.request_id}) + scheduler.update_from_output(event) + scheduler.update_from_output(event) + self.assertEqual(list(manager._protected_evictable_blocks), table) + self.assertTrue(manager.try_free_blocks(4)) + assert_state(self, manager) + + +if __name__ == "__main__": + unittest.main() From 32d00aaeac03f8dcd12474e3f2b5ad3cc6f9f9c9 Mon Sep 17 00:00:00 2001 From: tangchengxiang <2064027004@qq.com> Date: Wed, 16 Sep 2026 06:05:48 +0000 Subject: [PATCH 2/9] style: align Python formatting with CI Ruff version --- python/infinilm/processors/basic_llm_processor.py | 12 ++++++------ test/llm/check_chunk_tp.py | 6 +++--- test/llm/test_chunk_scheduler.py | 4 +++- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/python/infinilm/processors/basic_llm_processor.py b/python/infinilm/processors/basic_llm_processor.py index 63fc1fb81..0076ab34e 100644 --- a/python/infinilm/processors/basic_llm_processor.py +++ b/python/infinilm/processors/basic_llm_processor.py @@ -46,13 +46,13 @@ def apply_chat_template( normalized_conversation = [] for message in conversation: if isinstance(message["content"], list): - assert ( - len(message["content"]) == 1 - ), "Only one content item supported in list" + assert len(message["content"]) == 1, ( + "Only one content item supported in list" + ) content_item = message["content"][0] - assert ( - "type" in content_item and "text" in content_item - ), "Content dict must have 'type' and 'text' keys" + assert "type" in content_item and "text" in content_item, ( + "Content dict must have 'type' and 'text' keys" + ) normalized_conversation.append( {"role": message["role"], "content": content_item["text"]} ) diff --git a/test/llm/check_chunk_tp.py b/test/llm/check_chunk_tp.py index 6ee992758..87267b3ae 100644 --- a/test/llm/check_chunk_tp.py +++ b/test/llm/check_chunk_tp.py @@ -182,9 +182,9 @@ def execute(output): row["elapsed_ms"] = (time.perf_counter() - started) * 1000 row["graph_launches"] = counter() - before_launches if args.graph: - assert row["graph_launches"] == ( - 0 if output.is_prefill else args.tp - ), row + assert row["graph_launches"] == (0 if output.is_prefill else args.tp), ( + row + ) if output.is_prefill: allowed = np.zeros(16 * 256, dtype=bool) for r in output.scheduled_requests: diff --git a/test/llm/test_chunk_scheduler.py b/test/llm/test_chunk_scheduler.py index faf20d471..b8dcbf6fe 100644 --- a/test/llm/test_chunk_scheduler.py +++ b/test/llm/test_chunk_scheduler.py @@ -136,7 +136,9 @@ def test_decode_continuation_and_admission_each_get_dispatch_opportunities(self) kinds.append( "decode" if not step.is_prefill - else "continue" if req.request_id in seen else "admit" + else "continue" + if req.request_id in seen + else "admit" ) seen.add(req.request_id) self.finish_step(scheduler, step) From c7599cde8ae2d653752c740964e93df1017becc8 Mon Sep 17 00:00:00 2001 From: tangchengxiang <2064027004@qq.com> Date: Wed, 16 Sep 2026 09:01:58 +0000 Subject: [PATCH 3/9] docs: quantify cache chunking and graph tradeoffs --- docs/cache-chunk-validation.md | 7 + docs/performance-ablation.md | 193 +++++ docs/validation/performance-ablation.json | 827 ++++++++++++++++++++++ docs/validation/test-results.png | Bin 0 -> 232605 bytes 4 files changed, 1027 insertions(+) create mode 100644 docs/performance-ablation.md create mode 100644 docs/validation/performance-ablation.json create mode 100644 docs/validation/test-results.png diff --git a/docs/cache-chunk-validation.md b/docs/cache-chunk-validation.md index b765d7bf0..eb3203ff1 100644 --- a/docs/cache-chunk-validation.md +++ b/docs/cache-chunk-validation.md @@ -43,6 +43,13 @@ and visible devices for PP stages. The counter is required only with `--graph`. ## Performance boundaries +The [component performance report](performance-ablation.md) provides baseline +versus candidate numbers, hardware, shapes, dtype, sample counts and costs for +LRU, SLRU, TP2 chunking, intermediate-output omission and C500 graph modes. +Its [measurement export](validation/performance-ablation.json) contains +per-run evidence. Those component experiments precede final integration; +they must not be presented as a full rerun of this revision. + A final TP2 mixed-request smoke comparison used chunk512, a 2048-token long prompt, two 128-token short prompts, 160 output tokens, and prefix reuse off. One window per mode measured 161.71 token/s eager and 161.02 token/s with diff --git a/docs/performance-ablation.md b/docs/performance-ablation.md new file mode 100644 index 000000000..9385de88c --- /dev/null +++ b/docs/performance-ablation.md @@ -0,0 +1,193 @@ +# Cache, chunking and graph performance evidence + +These results explain the individual mechanisms integrated by this PR. They +are archived component experiments, **not a complete performance rerun of the +final integrated revision**. Final integration correctness is reported in +[cache-chunk-validation.md](cache-chunk-validation.md). Do not multiply the +gains from different experiments or interpret them as a universal speedup. + +[Sanitized measurements](validation/performance-ablation.json) include +per-run values, original artifact names and SHA256 hashes. The values below +were recalculated from request accounting, output timestamps and recorded +execution steps. The export omits machine paths, credentials and prompts. +Historical source commits identify development snapshots; they are not the +published squash commit. Worktree/binary hashes in the original experiments +were used where a commit alone did not describe the tested build. + +[Results screenshot](validation/test-results.png): browser capture of the +audited saved-results report, not a new GPU run or upstream CI result. + +## Definitions and common conditions + +- TTFT: request submission to its first generated token. ITL: interval between + consecutive generated tokens. An active request's maximum ITL measures its + worst observed pause, not its typical token latency. +- Cached tokens are actual admitted reused tokens; Prefill tokens are prompt + tokens that still require computation. Warmup requests are excluded below. +- Percentage change is `100 * (new / old - 1)`. Negative latency or work + changes are reductions. Each table names its baseline and aggregation. +- All model experiments use dense models, greedy decoding and paged KV with + 256-token pages. Fixed output budgets ignore EOS for timing; normal-EOS + and lifecycle checks are separate. No sustained HTTP load, confidence + intervals or statistical significance claim is provided. +- NVIDIA experiments use RTX A6000 48 GB GPUs, CUDA 12.4 and driver 580.105.08; + Qwen2.5 model/KV tensors are FP16. GPU(s) were checked for availability, + but the host was shared. TP2 uses NCCL on the two local A6000s. + +## LRU versus the former set-based reclamation + +One A6000, Qwen2.5-1.5B (model revision +`8faed761d45a263340a0528343f099c05c9a4323`), TP1, eager paged attention, +64 cache blocks, concurrency 1. Each repeat has 128 requests, of which the +first 16 warm the cache; each prompt has 1,024 prefix tokens plus a unique +32-token tail, and produces 32 tokens. Two independent engine repeats use +the same hotspot/cold trace. The baseline is `270feb3e`; the LRU component +is `49410568`, both using Core `1ab85ef1`. + +| Metric | Set-based baseline | LRU | Interpretation | +|---|---:|---:|---| +| Cached tokens per repeat | 61,440 | 86,016 | +40.00% reused work | +| Prefill tokens per repeat | 56,832 | 32,256 | **43.24% less prompt computation** | +| Mean TTFT, repeats 0 / 1 (ms) | 38.37 / 38.46 | 27.59 / 27.54 | Median paired reduction **28.24%** | +| Output token/s, repeats 0 / 1 | 120.94 / 124.60 | 121.98 / 124.99 | +0.87% / +0.32%; no stable throughput gain | + +The policy retains recently reused prefixes instead of depending on set +iteration order. It is not universally better: Qwen2.5-7B with a 128-page +cyclic working set and only 64 blocks had 33,792 -> 0 cached tokens and +34.96 -> 33.16 output token/s (**5.14% lower**, one run). LRU thrashed on +that trace while the old policy happened to keep a subset. + +Some historical FP16 cache-on/off outputs differ with execution shape. +Matching-retention controls and logit replays were investigated separately; +these latency results do not establish universal token-level equivalence. +The final integrated correctness checks also retain this limitation. + +CPU-only method-call microbenchmark: Python 3.11.15, 65,536 blocks, 90% pinned, +five fresh pressure pools. Reclaiming one block took a median **2,058.758 us +-> 18.217 us** (113x lower call time). Pool construction and hashing were +excluded. Retained Python metadata increased by **5,767,328 bytes** at that +pool size. This is not GPU time or an end-to-end model speedup. + +## Optional SLRU versus LRU under a one-use scan + +One A6000, the same 1.5B FP16 model, TP1, eager, concurrency 1, eight blocks, +protected ratio **0.5** (the API default is 0.8). Four warmup requests establish +hot prefixes, followed by ten measured 544-token requests, eight output +tokens each. One run per configuration; the trace includes six one-use +prefixes. All 14 output arrays and normal-EOS checks match across LRU8, +SLRU8, LRU32 and cache-off controls. + +| Metric | LRU, 8 blocks | SLRU, 8 blocks | +|---|---:|---:| +| Cached tokens | 1,024 | 2,048 | +| Prefill tokens | 4,416 | 3,392 (**23.19% less**) | +| Observed mean TTFT (ms) | 28.26 | 23.91 | + +SLRU8 matches the retained-token count of LRU32 in this trace. It preserves +established hot prefixes during a scan; it does not increase physical KV +capacity. No stable throughput claim follows from this one short run. +Scheduler-only no-reuse and overcapacity-cycle traces have zero hits with +both policies; the tested hotspot-shift trace has no total-work improvement. + +## TP2 chunked Prefill versus unchunked eager + +Two A6000s, Qwen2.5-1.5B FP16, TP2/PP1, eager paged attention, 128 cache blocks, +prefix reuse off, max batch size 2. A 128-token prompt generates 128 tokens; +after its eighth token an 8,192-token prompt generating 16 tokens arrives, +then a second 128-token prompt generating 16 tokens arrives 20 ms later. +A separate warmup precedes measurement. Values are medians of three short +runs per setting; all 160 output IDs match across the nine runs. Component +snapshot: `18beeeec` plus the recorded working-tree implementation. + +| Metric | Unchunked | Chunk512 | Change | Chunk1024 | +|---|---:|---:|---:|---:| +| Active request maximum ITL (ms) | 1,022.94 | 142.74 | **-86.05%** | 239.21 | +| Late short request TTFT (ms) | 1,001.54 | 68.16 | **-93.19%** | 139.44 | +| Active request p95 ITL (ms) | 24.88 | 92.68 | +272.47% | 63.62 | +| Long request TTFT (ms) | 1,012.17 | 1,435.76 | +41.85% | 1,210.33 | +| Finite-window output token/s | 75.27 | 62.83 | -16.53% | 68.06 | + +Chunk512 allowed 15 active Decode steps during the long Prefill, and the late +short request produced its first token before the long Prefill finished. +The gain is bounded worst-case blocking and request progress. More frequent +short pauses replace one long pause, so p95 ITL can worsen while maximum ITL +improves. Chunk1024 reduces maximum pause by 76.61% with a 9.58% output-rate +cost. Chunk size is an application latency/throughput choice. + +Skipping unused intermediate LM-head/sampling/output work was compared +separately on this chunk512 workload, with three alternating runs per mode. +Median cumulative long-Prefill dispatch time changed **1,202.99 -> 1,175.69 ms +(-2.27%)**, while output rate changed **64.19 -> 62.87 token/s (-2.05%)**. +This does not establish a serving-throughput gain. Every Transformer layer, +including Attention, MLP and required collectives, remains fully executed. + +## C500 graphs: same-binary five-mode ablation + +MetaX C500 **50% compute / 32,000 MiB slice**, CPU quota six cores; MACA +3.5.3.20, driver 3.8.30, torch 2.8.0+metax3.5.3.9, Flash Attention +2.6.3+metax3.5.3.9torch2.8. Qwen3-0.6B/4B BF16, TP1/PP1, vendor Flash +Attention, 32 KV blocks, prefix reuse off. All five modes use the same LM +binary built from the pre-integration graph prototype based on `ac89c5c3`. +This isolates execution mode without mixing native/vendor backends. + +Single requests: 2,048 input / 16 output tokens, three measurements after +warmup; TTFT is their mean, ITL is the mean of per-request median intervals. +Mixed windows: a 128-input / 48-output request receives a 2,048-input / +16-output competitor after its eighth output token; two windows per mode. +Window rate is 56 remaining output tokens divided by time from long-request +admission until both finish. It is not sustained serving throughput. + +| Model | Mode | Single TTFT ms | Single ITL ms | Mixed short max ITL ms | Mixed window token/s | +|---|---|---:|---:|---:|---:| +| 0.6B | Unchunked eager | 77.79 | 6.86 | 85.60 | 154.26 | +| 0.6B | Unchunked + Decode graph | 77.34 | 4.26 | 82.53 | 226.68 | +| 0.6B | Chunk512 eager | 97.22 | 6.82 | 34.58 | 145.96 | +| 0.6B | Chunk512 + Decode graph | 96.83 | 4.26 | 31.56 | 209.73 | +| 0.6B | Chunk512 + Prefill/Decode graphs | 91.01 | 4.37 | 30.21 | 212.86 | +| 4B | Unchunked eager | 211.94 | 12.99 | 227.82 | 72.79 | +| 4B | Unchunked + Decode graph | 211.38 | 9.39 | 222.93 | 93.35 | +| 4B | Chunk512 eager | 248.52 | 12.96 | 82.73 | 69.80 | +| 4B | Chunk512 + Decode graph | 247.97 | 9.37 | 77.40 | 88.06 | +| 4B | Chunk512 + Prefill/Decode graphs | 241.37 | 9.37 | 75.52 | 88.93 | + +At chunk512, Decode graphs reduce 4B single-request ITL by **27.64%** and +increase the mixed-window output rate by **26.16%** versus chunk eager. +Adding experimental Prefill graphs then reduces TTFT by **2.66%** for 4B +and **6.01%** for 0.6B. That small increment is not a significance claim. +The 4B full combination versus unchunked eager reduces the competing short +request's maximum pause by **66.85%**, with **22.17%** higher window output +rate; single-request TTFT is still higher (211.94 -> 241.37 ms). + +All 30 single-request outputs match the HF eager reference; all 20 mixed +windows match their long-request reference and short-request eager control. +Actual device-graph launches were counted, not inferred from an enable flag. +Existing Decode graph infrastructure is reused; this PR's contribution is +its safe combination with chunking and experimental fixed-size Prefill graphs. + +Costs: 4B initialization, including weight loading and graph preparation, +was 5.08 s unchunked eager, 6.30 s chunk + Decode graph and 7.05 s with both +graphs. This is not isolated graph capture time. Extra retained graph memory +was not measured. The shared slice, small samples and same-family models +limit generalization. + +## Final integrated performance check and reproduction + +The final A6000 TP2 check used a shorter 2,048-token long prompt, two 128-token +short prompts, chunk512, prefix cache off and 160 total outputs. One window +per mode measured **161.71 token/s eager vs 161.02 token/s Decode graph** +(-0.43%); outputs matched. No throughput improvement is established by this +pair. Do not compare its rates directly with the 8,192-token component test. +PP2 eager has correctness/lifecycle coverage, not a performance comparison. + +The committed [prefix benchmark instructions](../test/llm/README.md), +[SLRU checks](../test/llm/README.slru.md), +[chunk benchmark](chunked-prefill.md) and [graph configuration](chunk-graphs.md) +describe runnable checks and prerequisites. Match model, dtype, pool, +prefix setting, prompt lengths, warmup, output budget and repetition count +before comparing runs. The JSON export allows independent aggregation of +the archived measurements; it is not a portable archive of every historical +build or experiment driver. No GPU experiments were rerun for this report. + +Remaining coverage limits: sustained service load, full graph memory cost, +PP graphs, TP2 Prefill graphs, four-GPU TP2/PP2, remote-KV/chunk combinations, +MoE, quantization, multimodal, and unconnected Ascend/Moore hardware. diff --git a/docs/validation/performance-ablation.json b/docs/validation/performance-ablation.json new file mode 100644 index 000000000..8d6adf933 --- /dev/null +++ b/docs/validation/performance-ablation.json @@ -0,0 +1,827 @@ +{ + "date": "2026-09-16", + "scope": "Archived component experiments, not a rerun of the integrated PR. Raw source files are identified by relative artifact name and SHA256; exported measurements exclude paths, host identifiers and prompts.", + "change_formula": "100 * (candidate / baseline - 1); negative latency/work changes are reductions", + "lru_hotspot": [ + { + "variant": "baseline", + "repeat": 0, + "source": { + "artifact": "pr1-lru/qwen15b-small/baseline-on-hot-cold-c1-b64-r0.json", + "sha256": "2b25e91460486d32019eda8917c151e61e3915ce206657635a77e2121a42c0dd" + }, + "recorded_lm_commit": "270feb3e14976ca14c3b012df1d1740f939a941e", + "trace_sha256": "6d6ee5f4820bee581714b292c5e5341e44baa4ee461ba01865b906b36b0e3e5f", + "measured_requests": 112, + "cached_tokens": 61440, + "prefill_tokens": 56832, + "mean_ttft_ms": 38.374022564052474, + "output_tokens_s": 120.93562602161356 + }, + { + "variant": "candidate", + "repeat": 0, + "source": { + "artifact": "pr1-lru/qwen15b-small/candidate-on-hot-cold-c1-b64-r0.json", + "sha256": "97767df1f1712d58c25bf0ea75ba03f18b87e151801db58f0f244d017dc3fb75" + }, + "recorded_lm_commit": "4941056847a42c6030a50680d9c22160bc2845de", + "trace_sha256": "6d6ee5f4820bee581714b292c5e5341e44baa4ee461ba01865b906b36b0e3e5f", + "measured_requests": 112, + "cached_tokens": 86016, + "prefill_tokens": 32256, + "mean_ttft_ms": 27.591595234948077, + "output_tokens_s": 121.9828588428591 + }, + { + "variant": "baseline", + "repeat": 1, + "source": { + "artifact": "pr1-lru/qwen15b-small/baseline-on-hot-cold-c1-b64-r1.json", + "sha256": "71e372ff93e72f1ca84668bccdeffdec4512b588b62099d00503cd404d9e9277" + }, + "recorded_lm_commit": "270feb3e14976ca14c3b012df1d1740f939a941e", + "trace_sha256": "6d6ee5f4820bee581714b292c5e5341e44baa4ee461ba01865b906b36b0e3e5f", + "measured_requests": 112, + "cached_tokens": 61440, + "prefill_tokens": 56832, + "mean_ttft_ms": 38.462521450128406, + "output_tokens_s": 124.59826730665682 + }, + { + "variant": "candidate", + "repeat": 1, + "source": { + "artifact": "pr1-lru/qwen15b-small/candidate-on-hot-cold-c1-b64-r1.json", + "sha256": "0d838f4da45cc47d13f7b5fabc537b16e4020291c3cd41835b6cda407598c0ac" + }, + "recorded_lm_commit": "4941056847a42c6030a50680d9c22160bc2845de", + "trace_sha256": "6d6ee5f4820bee581714b292c5e5341e44baa4ee461ba01865b906b36b0e3e5f", + "measured_requests": 112, + "cached_tokens": 86016, + "prefill_tokens": 32256, + "mean_ttft_ms": 27.543096029798367, + "output_tokens_s": 124.99234817379885 + } + ], + "slru_hot_scan": [ + { + "variant": "lru-b8", + "source": { + "artifact": "pr2-slru/model-lru-b8.json", + "sha256": "0a4477e5405c7047e7ea6468614f12255f7980384f9ecf750daa62001636d486" + }, + "recorded_lm_commit": "4941056847a42c6030a50680d9c22160bc2845de", + "trace_sha256": "54196efd5b1d5651237476bb0eec8f4b5f08748892bc2ba9dae4d5573751672f", + "measured_requests": 10, + "cached_tokens": 1024, + "prefill_tokens": 4416, + "mean_ttft_ms": 28.25833586975932, + "output_tokens_s": 101.2304649877099 + }, + { + "variant": "slru-b8", + "source": { + "artifact": "pr2-slru/model-slru-b8.json", + "sha256": "d3e6ae49bbfd28fb8856799982d5191ba7dec37ae048c84566c2979b40ef9b12" + }, + "recorded_lm_commit": "4941056847a42c6030a50680d9c22160bc2845de", + "trace_sha256": "54196efd5b1d5651237476bb0eec8f4b5f08748892bc2ba9dae4d5573751672f", + "measured_requests": 10, + "cached_tokens": 2048, + "prefill_tokens": 3392, + "mean_ttft_ms": 23.908149264752865, + "output_tokens_s": 112.29277362197301 + }, + { + "variant": "lru-b32", + "source": { + "artifact": "pr2-slru/model-lru-b32.json", + "sha256": "f54fb6b4516aadbc7209e5f44b50a4db7474f8ac198f76bde5bf79e3d5d4bda3" + }, + "recorded_lm_commit": "4941056847a42c6030a50680d9c22160bc2845de", + "trace_sha256": "54196efd5b1d5651237476bb0eec8f4b5f08748892bc2ba9dae4d5573751672f", + "measured_requests": 10, + "cached_tokens": 2048, + "prefill_tokens": 3392, + "mean_ttft_ms": 23.94797643646598, + "output_tokens_s": 110.73545655281454 + }, + { + "variant": "slru-b8-off", + "source": { + "artifact": "pr2-slru/model-slru-b8-off.json", + "sha256": "71e2f231f5de80be392803dae78585217b1948db47b3034605a9509d958e40c2" + }, + "recorded_lm_commit": "4941056847a42c6030a50680d9c22160bc2845de", + "trace_sha256": "54196efd5b1d5651237476bb0eec8f4b5f08748892bc2ba9dae4d5573751672f", + "measured_requests": 10, + "cached_tokens": 0, + "prefill_tokens": 5440, + "mean_ttft_ms": 33.01804466173053, + "output_tokens_s": 87.52988821479991 + } + ], + "lru_negative_cycle": [ + { + "variant": "baseline", + "source": { + "artifact": "pr1-lru/qwen7b-full/baseline-on-over-capacity-c1-b64-r0.json", + "sha256": "3d68c0a7cfaf62ce60c7b885e48646552acc3f8f4da63a576b4a3ca233bbbb42" + }, + "recorded_lm_commit": "270feb3e14976ca14c3b012df1d1740f939a941e", + "trace_sha256": "1941d52163a04386b5dd95d4dfe0aa6cf0badde929d128bed5c2d5dc7814d21b", + "measured_requests": 112, + "cached_tokens": 33792, + "prefill_tokens": 84480, + "mean_ttft_ms": 158.39023908899566, + "output_tokens_s": 34.95745680898737 + }, + { + "variant": "candidate", + "source": { + "artifact": "pr1-lru/qwen7b-full/candidate-on-over-capacity-c1-b64-r0.json", + "sha256": "9ecd2e46150d096af7a4e9cbdc81d31849088cd55e4a58825e84da8c2e08b2ad" + }, + "recorded_lm_commit": "4941056847a42c6030a50680d9c22160bc2845de", + "trace_sha256": "1941d52163a04386b5dd95d4dfe0aa6cf0badde929d128bed5c2d5dc7814d21b", + "measured_requests": 112, + "cached_tokens": 0, + "prefill_tokens": 118272, + "mean_ttft_ms": 211.100796786403, + "output_tokens_s": 33.160089972732806 + } + ], + "cpu_reclaim": [ + { + "variant": "baseline", + "source": { + "artifact": "pr1-lru/baseline-metadata-65536.json", + "sha256": "1fe64372cae43db251992002ba4caf455635821ca11f2f80f99db8fc67d627dd" + }, + "python": "3.11.15", + "pool": 65536, + "pinned_ratio": 0.9, + "median_ns": 2058758, + "p95_ns": 2541288.6, + "valid_samples": 5, + "successful_samples": 5, + "requested_blocks": 1, + "actual_reclaimed_blocks": [ + 1, + 1, + 1, + 1, + 1 + ], + "capacity_failure": false, + "retained_python_bytes": 33127432 + }, + { + "variant": "candidate", + "source": { + "artifact": "pr1-lru/candidate-metadata-65536.json", + "sha256": "e2ce9f7ed4f09220b3a784401b9125350be68fa52d65c87d1758af3b0f432880" + }, + "python": "3.11.15", + "pool": 65536, + "pinned_ratio": 0.9, + "median_ns": 18217, + "p95_ns": 92578.19999999998, + "valid_samples": 5, + "successful_samples": 5, + "requested_blocks": 1, + "actual_reclaimed_blocks": [ + 1, + 1, + 1, + 1, + 1 + ], + "capacity_failure": false, + "retained_python_bytes": 38894760 + } + ], + "tp2_chunk": [ + { + "source": { + "artifact": "pr4-tp2-chunk/mixed-tp2-c0-r0.json", + "sha256": "864b1bc39e01c8d77220fd799897ce3608ba7aa64af1ecf50ea76965c8eae0ff" + }, + "recorded_lm_commit": "18beeeecc7e136b31b31600972008f22ab359f84", + "chunk": 0, + "repeat": 0, + "active_max_itl_ms": 1044.0825372934341, + "active_p95_itl_ms": 25.46475175768137, + "long_ttft_ms": 1027.7268989011645, + "late_ttft_ms": 1018.7378702685237, + "output_tokens_s": 69.65358550815387 + }, + { + "source": { + "artifact": "pr4-tp2-chunk/mixed-tp2-c0-r1.json", + "sha256": "f511108e6bc522a31896b0e9be5856c079029b9e5579f9d249c69535440bf053" + }, + "recorded_lm_commit": "18beeeecc7e136b31b31600972008f22ab359f84", + "chunk": 0, + "repeat": 1, + "active_max_itl_ms": 1022.2546840086579, + "active_p95_itl_ms": 24.196366779506207, + "long_ttft_ms": 1011.832257732749, + "late_ttft_ms": 1001.3264119625092, + "output_tokens_s": 76.47256581163282 + }, + { + "source": { + "artifact": "pr4-tp2-chunk/mixed-tp2-c0-r2.json", + "sha256": "3304092b57daab180ac23f337459fe619e3832328024551fb026ce1d5d72de56" + }, + "recorded_lm_commit": "18beeeecc7e136b31b31600972008f22ab359f84", + "chunk": 0, + "repeat": 2, + "active_max_itl_ms": 1022.9357611387968, + "active_p95_itl_ms": 24.883517064154148, + "long_ttft_ms": 1012.1732363477349, + "late_ttft_ms": 1001.5435870736837, + "output_tokens_s": 75.27313935874217 + }, + { + "source": { + "artifact": "pr4-tp2-chunk/mixed-tp2-c512-r0.json", + "sha256": "d93a49443d07ecac4a54ec801edc0dd3190b529105919838d3a6733bac524b5e" + }, + "recorded_lm_commit": "18beeeecc7e136b31b31600972008f22ab359f84", + "chunk": 512, + "repeat": 0, + "active_max_itl_ms": 142.74343475699425, + "active_p95_itl_ms": 92.36581716686487, + "long_ttft_ms": 1426.2459091842175, + "late_ttft_ms": 68.15813109278679, + "output_tokens_s": 62.1401633599568 + }, + { + "source": { + "artifact": "pr4-tp2-chunk/mixed-tp2-c512-r1.json", + "sha256": "501a6e930e74f1e8351598ef47f51ff96ee8d33c408e6741522c0532c999a856" + }, + "recorded_lm_commit": "18beeeecc7e136b31b31600972008f22ab359f84", + "chunk": 512, + "repeat": 1, + "active_max_itl_ms": 142.27045513689518, + "active_p95_itl_ms": 95.56909650564194, + "long_ttft_ms": 1483.4352610632777, + "late_ttft_ms": 84.46694444864988, + "output_tokens_s": 62.83360726513769 + }, + { + "source": { + "artifact": "pr4-tp2-chunk/mixed-tp2-c512-r2.json", + "sha256": "0f07207b283579ab311b35c4d16b812608dd1307e554b9888e6e9666f9a8cd94" + }, + "recorded_lm_commit": "18beeeecc7e136b31b31600972008f22ab359f84", + "chunk": 512, + "repeat": 2, + "active_max_itl_ms": 143.38157419115305, + "active_p95_itl_ms": 92.68482308834791, + "long_ttft_ms": 1435.7550907880068, + "late_ttft_ms": 66.94784760475159, + "output_tokens_s": 63.186604867391715 + }, + { + "source": { + "artifact": "pr4-tp2-chunk/mixed-tp2-c1024-r0.json", + "sha256": "d498ca2576dae06e1da298515fe7c1821f1a2aedc1eb52128267b93e839cc83a" + }, + "recorded_lm_commit": "18beeeecc7e136b31b31600972008f22ab359f84", + "chunk": 1024, + "repeat": 0, + "active_max_itl_ms": 239.21358492225409, + "active_p95_itl_ms": 66.04252103716135, + "long_ttft_ms": 1214.295919984579, + "late_ttft_ms": 141.81798696517944, + "output_tokens_s": 66.38686075560109 + }, + { + "source": { + "artifact": "pr4-tp2-chunk/mixed-tp2-c1024-r1.json", + "sha256": "30627eb79a4291d4c5abea2a048e8c22632c79678fd6542dfc49336571a5bc6c" + }, + "recorded_lm_commit": "18beeeecc7e136b31b31600972008f22ab359f84", + "chunk": 1024, + "repeat": 1, + "active_max_itl_ms": 238.32283914089203, + "active_p95_itl_ms": 63.6207340285182, + "long_ttft_ms": 1210.3307088837028, + "late_ttft_ms": 139.43834137171507, + "output_tokens_s": 68.06129671777143 + }, + { + "source": { + "artifact": "pr4-tp2-chunk/mixed-tp2-c1024-r2.json", + "sha256": "51bf30d05c42e47a84a0ae27060e92c4340bacdd45144b73eea2aa16c2e36178" + }, + "recorded_lm_commit": "18beeeecc7e136b31b31600972008f22ab359f84", + "chunk": 1024, + "repeat": 2, + "active_max_itl_ms": 239.33370877057314, + "active_p95_itl_ms": 60.559434816241264, + "long_ttft_ms": 1202.8568340465426, + "late_ttft_ms": 134.3604512512684, + "output_tokens_s": 72.05348208166319 + } + ], + "intermediate_output": [ + { + "source": { + "artifact": "pr5-chunk-output/mixed-legacy-r0.json", + "sha256": "21bfead47178247f478abf1daa4429d4c4912486d85282be45ae647a8a03b2f5" + }, + "variant": "legacy", + "repeat": 0, + "long_prefill_execution_ms": 1208.6142068728805, + "output_tokens_s": 61.04694102935545 + }, + { + "source": { + "artifact": "pr5-chunk-output/mixed-legacy-r1.json", + "sha256": "23a3361e4dd4f0be3393f278353832a69d170d05b61fb137c60e4a1aee361d3f" + }, + "variant": "legacy", + "repeat": 1, + "long_prefill_execution_ms": 1182.2610516101122, + "output_tokens_s": 68.13992559990778 + }, + { + "source": { + "artifact": "pr5-chunk-output/mixed-legacy-r2.json", + "sha256": "55841d060421d68b2323d9782401d307aad86741e568e7d5bd079d6acc1fe563" + }, + "variant": "legacy", + "repeat": 2, + "long_prefill_execution_ms": 1202.985405921936, + "output_tokens_s": 64.1864439253074 + }, + { + "source": { + "artifact": "pr5-chunk-output/mixed-optimized-r0.json", + "sha256": "1dd159cddc3a9ec1b779ca9645709dacdc4ecf23c043bdc3b6992bcc4e556b9f" + }, + "variant": "optimized", + "repeat": 0, + "long_prefill_execution_ms": 1192.3475973308086, + "output_tokens_s": 61.16729167321039 + }, + { + "source": { + "artifact": "pr5-chunk-output/mixed-optimized-r1.json", + "sha256": "d33e337c90cb64acf2b6a205b3ad470a65c98b4dd07b57737ccc090057fbe67c" + }, + "variant": "optimized", + "repeat": 1, + "long_prefill_execution_ms": 1174.908585846424, + "output_tokens_s": 62.87148479791548 + }, + { + "source": { + "artifact": "pr5-chunk-output/mixed-optimized-r2.json", + "sha256": "c8a8c9213855f7fcc5b1badae8171280a29c4c4a21c34b831b0bb186c5eb6b7e" + }, + "variant": "optimized", + "repeat": 2, + "long_prefill_execution_ms": 1175.6912898272276, + "output_tokens_s": 67.19424223783167 + } + ], + "c500_graphs": [ + { + "model": "Qwen3-0.6B", + "mode": "c0-g0", + "source": { + "artifact": "c500-graph-ablation/Qwen3-0.6B-c0-g0.json", + "sha256": "9d73adf9bcde012ba432824c909144142756b56ce2096fb3e3dd41c59740bc08" + }, + "lm_binary_sha256": "02d3417926b887bdfdf403f1e68caeefd7ac9f83ece1725d4e31f05e9611f83a", + "single_requests": [ + { + "ttft_ms": 79.8242810415104, + "median_itl_ms": 6.821062066592276, + "finite_request_output_tokens_s": 87.87185273940202 + }, + { + "ttft_ms": 76.80631999392062, + "median_itl_ms": 6.865010946057737, + "finite_request_output_tokens_s": 88.9601489479711 + }, + { + "ttft_ms": 76.74454106017947, + "median_itl_ms": 6.9005509139969945, + "finite_request_output_tokens_s": 88.73658090674604 + } + ], + "mixed_windows": [ + { + "short_max_itl_ms": 86.75120607949793, + "long_ttft_ms": 76.87031396199018, + "window_seconds": 0.36403864703606814, + "window_output_tokens": 56, + "finite_window_output_tokens_s": 153.82982124546695 + }, + { + "short_max_itl_ms": 84.44142004009336, + "long_ttft_ms": 76.91469497513026, + "window_seconds": 0.36203506402671337, + "window_output_tokens": 56, + "finite_window_output_tokens_s": 154.68114987853207 + } + ], + "engine_load_seconds": 1.5478712120093405 + }, + { + "model": "Qwen3-0.6B", + "mode": "c0-g1", + "source": { + "artifact": "c500-graph-ablation/Qwen3-0.6B-c0-g1.json", + "sha256": "aec41b1dc9f7533c278bd68231a168f3c890c554eeebf9323fe771086914747d" + }, + "lm_binary_sha256": "02d3417926b887bdfdf403f1e68caeefd7ac9f83ece1725d4e31f05e9611f83a", + "single_requests": [ + { + "ttft_ms": 79.10229603294283, + "median_itl_ms": 4.263582988642156, + "finite_request_output_tokens_s": 111.50987510949798 + }, + { + "ttft_ms": 76.50900701992214, + "median_itl_ms": 4.26289695315063, + "finite_request_output_tokens_s": 113.61004451774552 + }, + { + "ttft_ms": 76.42301602754742, + "median_itl_ms": 4.261289956048131, + "finite_request_output_tokens_s": 113.86783403692492 + } + ], + "mixed_windows": [ + { + "short_max_itl_ms": 83.66876107174903, + "long_ttft_ms": 76.96511095855385, + "window_seconds": 0.24804238602519035, + "window_output_tokens": 56, + "finite_window_output_tokens_s": 225.76786531279708 + }, + { + "short_max_itl_ms": 81.39872900210321, + "long_ttft_ms": 76.5976799884811, + "window_seconds": 0.24604712694417685, + "window_output_tokens": 56, + "finite_window_output_tokens_s": 227.5986746746499 + } + ], + "engine_load_seconds": 2.518482425948605 + }, + { + "model": "Qwen3-0.6B", + "mode": "c512-g0", + "source": { + "artifact": "c500-graph-ablation/Qwen3-0.6B-c512-g0.json", + "sha256": "07faf60d41f7ab82fb99bdf965e663df41388f03dad5e9ab0d957a5781eed8bf" + }, + "lm_binary_sha256": "02d3417926b887bdfdf403f1e68caeefd7ac9f83ece1725d4e31f05e9611f83a", + "single_requests": [ + { + "ttft_ms": 98.0809589382261, + "median_itl_ms": 6.7741479724645615, + "finite_request_output_tokens_s": 80.02744861771578 + }, + { + "ttft_ms": 96.7681430047378, + "median_itl_ms": 6.838207016699016, + "finite_request_output_tokens_s": 80.27431336036632 + }, + { + "ttft_ms": 96.8068620422855, + "median_itl_ms": 6.833213032223284, + "finite_request_output_tokens_s": 80.15923549949929 + } + ], + "mixed_windows": [ + { + "short_max_itl_ms": 35.50305194221437, + "long_ttft_ms": 117.88052599877119, + "window_seconds": 0.383247634046711, + "window_output_tokens": 56, + "finite_window_output_tokens_s": 146.11962351520899 + }, + { + "short_max_itl_ms": 33.659768057987094, + "long_ttft_ms": 118.15998400561512, + "window_seconds": 0.38410016405396163, + "window_output_tokens": 56, + "finite_window_output_tokens_s": 145.79530351914312 + } + ], + "engine_load_seconds": 1.4546657629543915 + }, + { + "model": "Qwen3-0.6B", + "mode": "c512-g1", + "source": { + "artifact": "c500-graph-ablation/Qwen3-0.6B-c512-g1.json", + "sha256": "ca7d341e809913f983a829164ba68b0a10ba86fe212762cf0dc4820e03376ea3" + }, + "lm_binary_sha256": "02d3417926b887bdfdf403f1e68caeefd7ac9f83ece1725d4e31f05e9611f83a", + "single_requests": [ + { + "ttft_ms": 97.38001390360296, + "median_itl_ms": 4.262696020305157, + "finite_request_output_tokens_s": 99.01659502888025 + }, + { + "ttft_ms": 96.51387494523078, + "median_itl_ms": 4.261964000761509, + "finite_request_output_tokens_s": 99.57132434230608 + }, + { + "ttft_ms": 96.5981719782576, + "median_itl_ms": 4.260887042619288, + "finite_request_output_tokens_s": 99.59523372278204 + } + ], + "mixed_windows": [ + { + "short_max_itl_ms": 32.371642999351025, + "long_ttft_ms": 108.80499798804522, + "window_seconds": 0.2676137809175998, + "window_output_tokens": 56, + "finite_window_output_tokens_s": 209.25678718033882 + }, + { + "short_max_itl_ms": 30.749029014259577, + "long_ttft_ms": 109.01637095957994, + "window_seconds": 0.2664180740248412, + "window_output_tokens": 56, + "finite_window_output_tokens_s": 210.19594937383445 + } + ], + "engine_load_seconds": 2.519157030968927 + }, + { + "model": "Qwen3-0.6B", + "mode": "c512-g1-p512", + "source": { + "artifact": "c500-graph-ablation/Qwen3-0.6B-c512-g1-p512.json", + "sha256": "fd43d08532470e6bb6fc845eb27a895f3e05cff8bb25ad11c3362fc8ad4b33a6" + }, + "lm_binary_sha256": "02d3417926b887bdfdf403f1e68caeefd7ac9f83ece1725d4e31f05e9611f83a", + "single_requests": [ + { + "ttft_ms": 91.29042597487569, + "median_itl_ms": 4.3628859566524625, + "finite_request_output_tokens_s": 101.62564964149574 + }, + { + "ttft_ms": 90.84178297780454, + "median_itl_ms": 4.317894927226007, + "finite_request_output_tokens_s": 102.70470335324092 + }, + { + "ttft_ms": 90.89809097349644, + "median_itl_ms": 4.428689950145781, + "finite_request_output_tokens_s": 101.7881771755744 + } + ], + "mixed_windows": [ + { + "short_max_itl_ms": 31.406677095219493, + "long_ttft_ms": 104.01316499337554, + "window_seconds": 0.2646652889670804, + "window_output_tokens": 56, + "finite_window_output_tokens_s": 211.58800316638948 + }, + { + "short_max_itl_ms": 29.01442104484886, + "long_ttft_ms": 102.9531330568716, + "window_seconds": 0.26151709409896284, + "window_output_tokens": 56, + "finite_window_output_tokens_s": 214.13514169291196 + } + ], + "engine_load_seconds": 2.7417224689852446 + }, + { + "model": "Qwen3-4B", + "mode": "c0-g0", + "source": { + "artifact": "c500-graph-ablation/Qwen3-4B-c0-g0.json", + "sha256": "0ed165dbf1b64a0bd93bb952375551dc8dc3a566a9cb6fe6b7ec46b55ee9e5d1" + }, + "lm_binary_sha256": "02d3417926b887bdfdf403f1e68caeefd7ac9f83ece1725d4e31f05e9611f83a", + "single_requests": [ + { + "ttft_ms": 213.06744602043182, + "median_itl_ms": 13.09685199521482, + "finite_request_output_tokens_s": 38.7228335294421 + }, + { + "ttft_ms": 211.432253010571, + "median_itl_ms": 12.94599601533264, + "finite_request_output_tokens_s": 39.0370000559599 + }, + { + "ttft_ms": 211.31556096952409, + "median_itl_ms": 12.935793958604336, + "finite_request_output_tokens_s": 39.39240863743308 + } + ], + "mixed_windows": [ + { + "short_max_itl_ms": 228.96672098431736, + "long_ttft_ms": 211.594982072711, + "window_seconds": 0.7683330880245194, + "window_output_tokens": 56, + "finite_window_output_tokens_s": 72.8850558082602 + }, + { + "short_max_itl_ms": 226.67458106298, + "long_ttft_ms": 211.30752097815275, + "window_seconds": 0.7702595330774784, + "window_output_tokens": 56, + "finite_window_output_tokens_s": 72.70276782717482 + } + ], + "engine_load_seconds": 5.076910855947062 + }, + { + "model": "Qwen3-4B", + "mode": "c0-g1", + "source": { + "artifact": "c500-graph-ablation/Qwen3-4B-c0-g1.json", + "sha256": "de7230bf983a13d721f46f56ef63818e4b1580f1bf0820dba2ccd9f10277f386" + }, + "lm_binary_sha256": "02d3417926b887bdfdf403f1e68caeefd7ac9f83ece1725d4e31f05e9611f83a", + "single_requests": [ + { + "ttft_ms": 211.78095799405128, + "median_itl_ms": 9.393629035912454, + "finite_request_output_tokens_s": 45.267016171884556 + }, + { + "ttft_ms": 211.01937198545784, + "median_itl_ms": 9.376900037750602, + "finite_request_output_tokens_s": 45.432711288765645 + }, + { + "ttft_ms": 211.33165108039975, + "median_itl_ms": 9.386084973812103, + "finite_request_output_tokens_s": 45.36420405624591 + } + ], + "mixed_windows": [ + { + "short_max_itl_ms": 223.71347295120358, + "long_ttft_ms": 210.9993400517851, + "window_seconds": 0.5988905160920694, + "window_output_tokens": 56, + "finite_window_output_tokens_s": 93.50623944659516 + }, + { + "short_max_itl_ms": 222.14943391736597, + "long_ttft_ms": 211.15404297597706, + "window_seconds": 0.6009402009658515, + "window_output_tokens": 56, + "finite_window_output_tokens_s": 93.18730867063793 + } + ], + "engine_load_seconds": 6.295128494966775 + }, + { + "model": "Qwen3-4B", + "mode": "c512-g0", + "source": { + "artifact": "c500-graph-ablation/Qwen3-4B-c512-g0.json", + "sha256": "12310d3015478ce906281e56aa056ec5f8755bfd287c788992c08931ab8c41a1" + }, + "lm_binary_sha256": "02d3417926b887bdfdf403f1e68caeefd7ac9f83ece1725d4e31f05e9611f83a", + "single_requests": [ + { + "ttft_ms": 249.27168106660247, + "median_itl_ms": 12.888173921965063, + "finite_request_output_tokens_s": 36.1055871054971 + }, + { + "ttft_ms": 247.82653898000717, + "median_itl_ms": 12.960067950189114, + "finite_request_output_tokens_s": 36.18155769693437 + }, + { + "ttft_ms": 248.46953700762242, + "median_itl_ms": 13.018788886256516, + "finite_request_output_tokens_s": 35.88350552210521 + } + ], + "mixed_windows": [ + { + "short_max_itl_ms": 84.426648914814, + "long_ttft_ms": 287.96457406133413, + "window_seconds": 0.8041914129862562, + "window_output_tokens": 56, + "finite_window_output_tokens_s": 69.63516284270129 + }, + { + "short_max_itl_ms": 81.02831908036023, + "long_ttft_ms": 287.3378910589963, + "window_seconds": 0.8004108859458938, + "window_output_tokens": 56, + "finite_window_output_tokens_s": 69.96406593573677 + } + ], + "engine_load_seconds": 4.833688564947806 + }, + { + "model": "Qwen3-4B", + "mode": "c512-g1", + "source": { + "artifact": "c500-graph-ablation/Qwen3-4B-c512-g1.json", + "sha256": "f82b6838a99ffd95559a2280bb743041472d392c8c842589ff580a20d68f0284" + }, + "lm_binary_sha256": "02d3417926b887bdfdf403f1e68caeefd7ac9f83ece1725d4e31f05e9611f83a", + "single_requests": [ + { + "ttft_ms": 249.00059693027288, + "median_itl_ms": 9.373518056236207, + "finite_request_output_tokens_s": 41.01255560429826 + }, + { + "ttft_ms": 247.4075510399416, + "median_itl_ms": 9.372230037115514, + "finite_request_output_tokens_s": 41.16757828316191 + }, + { + "ttft_ms": 247.5125939818099, + "median_itl_ms": 9.376770001836121, + "finite_request_output_tokens_s": 41.14866721466945 + } + ], + "mixed_windows": [ + { + "short_max_itl_ms": 78.30318505875766, + "long_ttft_ms": 275.2043070504442, + "window_seconds": 0.6360797800589353, + "window_output_tokens": 56, + "finite_window_output_tokens_s": 88.03927078897459 + }, + { + "short_max_itl_ms": 76.49294100701809, + "long_ttft_ms": 275.9519129758701, + "window_seconds": 0.6357877160189673, + "window_output_tokens": 56, + "finite_window_output_tokens_s": 88.07971369854111 + } + ], + "engine_load_seconds": 6.3010460620280355 + }, + { + "model": "Qwen3-4B", + "mode": "c512-g1-p512", + "source": { + "artifact": "c500-graph-ablation/Qwen3-4B-c512-g1-p512.json", + "sha256": "376d1b0d09837c85b6f6a67f9bec3b9df921c978b84f01d4fa1ddb220bd9abcf" + }, + "lm_binary_sha256": "02d3417926b887bdfdf403f1e68caeefd7ac9f83ece1725d4e31f05e9611f83a", + "single_requests": [ + { + "ttft_ms": 241.4711710298434, + "median_itl_ms": 9.368281927891076, + "finite_request_output_tokens_s": 41.87622314282987 + }, + { + "ttft_ms": 241.46708799526095, + "median_itl_ms": 9.369399980641901, + "finite_request_output_tokens_s": 41.87662351918585 + }, + { + "ttft_ms": 241.18255090434104, + "median_itl_ms": 9.37285099644214, + "finite_request_output_tokens_s": 41.891006545143796 + } + ], + "mixed_windows": [ + { + "short_max_itl_ms": 76.42750407103449, + "long_ttft_ms": 268.4811579529196, + "window_seconds": 0.6281527009559795, + "window_output_tokens": 56, + "finite_window_output_tokens_s": 89.15029723628369 + }, + { + "short_max_itl_ms": 74.60299599915743, + "long_ttft_ms": 268.79685290623456, + "window_seconds": 0.6312453869031742, + "window_output_tokens": 56, + "finite_window_output_tokens_s": 88.7135195945436 + } + ], + "engine_load_seconds": 7.04994626599364 + } + ] +} diff --git a/docs/validation/test-results.png b/docs/validation/test-results.png new file mode 100644 index 0000000000000000000000000000000000000000..b9cc7f627fc179521c553c7faf2d2707cd12442f GIT binary patch literal 232605 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
  • hbZB8$*w1i&)MHWsp!8n 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

    g+p8^vRnvtJ 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[(@iE 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 Fe*tCVr9A)u literal 0 HcmV?d00001 From a7345fa22f779274457d192f6ae883b3cccf1e75 Mon Sep 17 00:00:00 2001 From: tangchengxiang <2064027004@qq.com> Date: Wed, 16 Sep 2026 09:48:08 +0000 Subject: [PATCH 4/9] refactor(scheduler): reuse decode scheduling and test setup --- docs/cache-chunk-validation.md | 19 ++++- python/infinilm/llm/scheduler.py | 84 +++++++--------------- test/llm/chunk_test_support.py | 3 - test/llm/config_test_support.py | 58 +++++++++++++++ test/llm/test_cache_policy_config.py | 61 +++------------- test/llm/test_chunk_config.py | 62 +++------------- test/llm/test_chunk_execution.py | 6 +- test/llm/test_chunk_output.py | 2 +- test/llm/test_chunk_scheduler.py | 39 +++++++++- test/llm/test_scheduler_cache_lifecycle.py | 33 +++++++++ 10 files changed, 192 insertions(+), 175 deletions(-) delete mode 100644 test/llm/chunk_test_support.py create mode 100644 test/llm/config_test_support.py diff --git a/docs/cache-chunk-validation.md b/docs/cache-chunk-validation.md index eb3203ff1..8a9201f41 100644 --- a/docs/cache-chunk-validation.md +++ b/docs/cache-chunk-validation.md @@ -28,8 +28,9 @@ selects the signature of the installed wheel; older signatures have fixture coverage, not execution on older hardware/software installations. Structured results: [cache-chunk-graphs.json](validation/cache-chunk-graphs.json). -The CPU suite has 108 passing tests, including successful-admission-only SLRU -promotion in chunk mode and PP worker configuration forwarding: +The CPU suite now has 111 passing tests, including successful-admission-only +SLRU promotion, PP worker configuration forwarding, shared Decode scheduling +across page boundaries and connector metadata on idle dispatch: ```sh python -m unittest discover -s test/llm -p 'test_*.py' @@ -41,6 +42,20 @@ Native reproduction commands and prerequisites are in `--policy`, `--cache-off`, `--chunk-size` and `--output`. Use separate processes and visible devices for PP stages. The counter is required only with `--graph`. +## Scheduler reuse follow-up + +Both Prefill policies now reuse the original Decode scheduling path; output +construction also shares speculative operations and connector metadata setup. +The configuration tests share one isolated module loader. The follow-up passed +111 CPU tests (108 existing and three additional cases) and one A6000 smoke +run: Qwen2.5-1.5B FP16, TP1/PP1, SLRU, page256/pool16, chunk300, eager Prefill +and Decode graphs. It observed 14 device-graph launches, matched the archived +same-configuration output tokens, and returned all 16 pages with zero references +after shared/repeated requests and cancellation. It used the refactored Python +source with the unchanged native binary from the integration checks. This was +a correctness check; the full GPU matrix and archived performance measurements +were not repeated for this Python refactor. + ## Performance boundaries The [component performance report](performance-ablation.md) provides baseline diff --git a/python/infinilm/llm/scheduler.py b/python/infinilm/llm/scheduler.py index dc178c0d1..3c1c91a52 100644 --- a/python/infinilm/llm/scheduler.py +++ b/python/infinilm/llm/scheduler.py @@ -156,7 +156,6 @@ def schedule(self) -> Optional[SchedulerOutput]: return self._schedule_chunked() deferred_requests = [] scheduled_requests = [] - is_prefill = False current_num_batched_tokens = 0 current_prefill_extra_blocks = 0 @@ -321,17 +320,13 @@ def schedule(self) -> Optional[SchedulerOutput]: # Return prefill batch if any waiting requests were scheduled if scheduled_requests: - is_prefill = True - scheduler_output = SchedulerOutput( - scheduled_requests=scheduled_requests, - is_prefill=is_prefill, - speculative_cache_ops=self.speculative_cache_ops, - ) - if self.connector is not None: - meta = self.connector.build_connector_meta() - scheduler_output.kv_connector_metadata = meta - return scheduler_output + return self._make_output(scheduled_requests, is_prefill=True) + + return self._schedule_decode() + def _schedule_decode(self) -> Optional[SchedulerOutput]: + """Schedule Decode and remote-KV progress for either Prefill policy.""" + scheduled_requests = [] # Process Running queue (decode phase) while len(scheduled_requests) < self.max_batch_size: try: @@ -378,35 +373,30 @@ def schedule(self) -> Optional[SchedulerOutput]: else: break # Defer promotion to next schedule() if batch is full - # Return decode batch if any running requests were scheduled - if scheduled_requests: - is_prefill = False - scheduler_output = SchedulerOutput( - scheduled_requests=scheduled_requests, - is_prefill=is_prefill, - speculative_cache_ops=self.speculative_cache_ops, - ) - - if self.connector is not None: - meta = self.connector.build_connector_meta() - scheduler_output.kv_connector_metadata = meta - return scheduler_output + if scheduled_requests or self.connector is not None: + return self._make_output(scheduled_requests) + return None + def _make_output( + self, + requests: List[InferenceRequest], + is_prefill: bool = False, + prefill_end: int | None = None, + ) -> SchedulerOutput: + output = SchedulerOutput( + requests, + is_prefill=is_prefill, + speculative_cache_ops=self.speculative_cache_ops, + prefill_end=prefill_end, + ) if self.connector is not None: - scheduler_output = SchedulerOutput( - scheduled_requests=[], - speculative_cache_ops=self.speculative_cache_ops, - ) - meta = self.connector.build_connector_meta() - scheduler_output.kv_connector_metadata = meta - return scheduler_output - - return None + output.kv_connector_metadata = self.connector.build_connector_meta() + return output def _schedule_chunked(self) -> Optional[SchedulerOutput]: """Rotate dispatch opportunities across decode, continuation, and admission.""" phases = ( - self._schedule_chunk_decode, + self._schedule_decode, self._schedule_continuation, self._admit_chunk_request, ) @@ -418,29 +408,6 @@ def _schedule_chunked(self) -> Optional[SchedulerOutput]: return output return None - def _schedule_chunk_decode(self) -> Optional[SchedulerOutput]: - requests = [] - while len(requests) < self.max_batch_size: - try: - req = self.running_queue.sync_q.get_nowait() - except queue.Empty: - break - if req.is_finished(): - self.complete_requests([req]) - continue - req.block_table, slot = self.cache_manager.append_slot( - req.block_table, req.get_total_length() - ) - req.slot_mapping = [slot] - req.num_blocks = len(req.block_table) - req.num_local_cached_tokens = req.get_total_length() - 1 - requests.append(req) - if requests: - return SchedulerOutput( - requests, speculative_cache_ops=self.speculative_cache_ops - ) - return None - def _schedule_continuation(self) -> Optional[SchedulerOutput]: while self.chunking_queue: req = self.chunking_queue.popleft() @@ -501,10 +468,9 @@ def _prefill_chunk(self, req: InferenceRequest) -> SchedulerOutput: req.slot_mapping = self.cache_manager.update_blocks_slot( req.block_table, start, end ) - return SchedulerOutput( + return self._make_output( [req], is_prefill=True, - speculative_cache_ops=self.speculative_cache_ops, prefill_end=end, ) diff --git a/test/llm/chunk_test_support.py b/test/llm/chunk_test_support.py deleted file mode 100644 index 03ad01865..000000000 --- a/test/llm/chunk_test_support.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Share the isolated scheduler modules with the cache lifecycle suite.""" - -from cache_test_support import MODULES, BlockManager # noqa: F401 diff --git a/test/llm/config_test_support.py b/test/llm/config_test_support.py new file mode 100644 index 000000000..3696e29c5 --- /dev/null +++ b/test/llm/config_test_support.py @@ -0,0 +1,58 @@ +"""Load configuration entrypoints without constructing native model extensions.""" + +import importlib.util +import sys +from pathlib import Path +from types import ModuleType, SimpleNamespace +from unittest.mock import patch + +from cache_test_support import MODULES + +SOURCE = Path(__file__).resolve().parents[2] / "python/infinilm" + + +def load_module(name, relative_path): + spec = importlib.util.spec_from_file_location(name, SOURCE / relative_path) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +def load_config_modules(): + with patch.dict(sys.modules): + # Keep package __init__ files from importing the native model extension. + for name in ("infinilm", "infinilm.config", "infinilm.llm"): + sys.modules[name] = ModuleType(name) + for name, module in MODULES.items(): + sys.modules[f"infinilm.llm.{name}"] = module + kv = load_module("infinilm.config.kv_transfer", "config/kv_transfer.py") + sys.modules["infinilm.config"].KVTransferConfig = kv.KVTransferConfig + engine_config = load_module( + "infinilm.config.engine_config", "config/engine_config.py" + ) + load_module("infinilm.moe_config", "moe_config.py") + base = load_module("infinilm.base_config", "base_config.py") + load_module("infinilm.llm.static_scheduler", "llm/static_scheduler.py") + sys.modules["infinilm.infer_engine"] = SimpleNamespace( + read_hf_config=lambda path: {}, model_uses_mamba_cache=lambda config: False + ) + sys.modules["infinilm.kv_connector"] = SimpleNamespace( + KVConnectorFactory=object, KVConnectorRole=object + ) + sys.modules["infinilm.llm.model_runner.model_runner"] = SimpleNamespace( + ModelRunner=object + ) + sys.modules["infinilm.multimodal.multimodal"] = SimpleNamespace( + resolve_multimodal_inputs=object + ) + llm = load_module("infinilm.llm.llm", "llm/llm.py") + for name in ("AsyncLLMEngine", "FinishReason", "SamplingParams"): + setattr(sys.modules["infinilm.llm"], name, getattr(llm, name)) + server = load_module( + "infinilm.server.inference_server", "server/inference_server.py" + ) + return engine_config.EngineConfig, base.BaseConfig, llm, server + + +EngineConfig, BaseConfig, LLM_MODULE, SERVER_MODULE = load_config_modules() diff --git a/test/llm/test_cache_policy_config.py b/test/llm/test_cache_policy_config.py index ab321292c..8e95217c0 100644 --- a/test/llm/test_cache_policy_config.py +++ b/test/llm/test_cache_policy_config.py @@ -1,67 +1,22 @@ """CPU configuration coverage; native model construction is replaced at its boundary.""" import asyncio -import importlib.util import io import os import runpy import sys import unittest from contextlib import redirect_stderr, redirect_stdout -from pathlib import Path -from types import ModuleType, SimpleNamespace +from types import SimpleNamespace from unittest.mock import patch -from cache_test_support import MODULES - -SOURCE = Path(__file__).resolve().parents[2] / "python/infinilm" - - -def load_module(name, relative_path): - spec = importlib.util.spec_from_file_location(name, SOURCE / relative_path) - module = importlib.util.module_from_spec(spec) - sys.modules[name] = module - spec.loader.exec_module(module) - return module - - -def load_config_modules(): - with patch.dict(sys.modules): - # Keep package __init__ files from importing the native model extension. - for name in ("infinilm", "infinilm.config", "infinilm.llm"): - sys.modules[name] = ModuleType(name) - for name, module in MODULES.items(): - sys.modules[f"infinilm.llm.{name}"] = module - kv = load_module("infinilm.config.kv_transfer", "config/kv_transfer.py") - sys.modules["infinilm.config"].KVTransferConfig = kv.KVTransferConfig - engine_config = load_module( - "infinilm.config.engine_config", "config/engine_config.py" - ) - load_module("infinilm.moe_config", "moe_config.py") - base = load_module("infinilm.base_config", "base_config.py") - load_module("infinilm.llm.static_scheduler", "llm/static_scheduler.py") - sys.modules["infinilm.infer_engine"] = SimpleNamespace( - read_hf_config=lambda path: {}, model_uses_mamba_cache=lambda config: False - ) - sys.modules["infinilm.kv_connector"] = SimpleNamespace( - KVConnectorFactory=object, KVConnectorRole=object - ) - sys.modules["infinilm.llm.model_runner.model_runner"] = SimpleNamespace( - ModelRunner=object - ) - sys.modules["infinilm.multimodal.multimodal"] = SimpleNamespace( - resolve_multimodal_inputs=object - ) - llm = load_module("infinilm.llm.llm", "llm/llm.py") - for name in ("AsyncLLMEngine", "FinishReason", "SamplingParams"): - setattr(sys.modules["infinilm.llm"], name, getattr(llm, name)) - server = load_module( - "infinilm.server.inference_server", "server/inference_server.py" - ) - return engine_config.EngineConfig, base.BaseConfig, llm, server - - -EngineConfig, BaseConfig, LLM_MODULE, SERVER_MODULE = load_config_modules() +from config_test_support import ( + LLM_MODULE, + SERVER_MODULE, + SOURCE, + BaseConfig, + EngineConfig, +) class CachePolicyConfigTests(unittest.TestCase): diff --git a/test/llm/test_chunk_config.py b/test/llm/test_chunk_config.py index aa3b121ad..285326acc 100644 --- a/test/llm/test_chunk_config.py +++ b/test/llm/test_chunk_config.py @@ -1,67 +1,23 @@ """CPU configuration coverage; native model construction is replaced at its boundary.""" import asyncio -import importlib.util import io import os import runpy import sys import unittest from contextlib import redirect_stderr, redirect_stdout -from pathlib import Path -from types import ModuleType, SimpleNamespace +from types import SimpleNamespace from unittest.mock import patch -from chunk_test_support import MODULES - -SOURCE = Path(__file__).resolve().parents[2] / "python/infinilm" - - -def load_module(name, relative_path): - spec = importlib.util.spec_from_file_location(name, SOURCE / relative_path) - module = importlib.util.module_from_spec(spec) - sys.modules[name] = module - spec.loader.exec_module(module) - return module - - -def load_config_modules(): - with patch.dict(sys.modules): - # Keep package __init__ files from importing the native model extension. - for name in ("infinilm", "infinilm.config", "infinilm.llm"): - sys.modules[name] = ModuleType(name) - for name, module in MODULES.items(): - sys.modules[f"infinilm.llm.{name}"] = module - kv = load_module("infinilm.config.kv_transfer", "config/kv_transfer.py") - sys.modules["infinilm.config"].KVTransferConfig = kv.KVTransferConfig - engine_config = load_module( - "infinilm.config.engine_config", "config/engine_config.py" - ) - load_module("infinilm.moe_config", "moe_config.py") - base = load_module("infinilm.base_config", "base_config.py") - load_module("infinilm.llm.static_scheduler", "llm/static_scheduler.py") - sys.modules["infinilm.infer_engine"] = SimpleNamespace( - read_hf_config=lambda path: {}, model_uses_mamba_cache=lambda config: False - ) - sys.modules["infinilm.kv_connector"] = SimpleNamespace( - KVConnectorFactory=object, KVConnectorRole=object - ) - sys.modules["infinilm.llm.model_runner.model_runner"] = SimpleNamespace( - ModelRunner=object - ) - sys.modules["infinilm.multimodal.multimodal"] = SimpleNamespace( - resolve_multimodal_inputs=object - ) - llm = load_module("infinilm.llm.llm", "llm/llm.py") - for name in ("AsyncLLMEngine", "FinishReason", "SamplingParams"): - setattr(sys.modules["infinilm.llm"], name, getattr(llm, name)) - server = load_module( - "infinilm.server.inference_server", "server/inference_server.py" - ) - return engine_config.EngineConfig, base.BaseConfig, llm, server - - -EngineConfig, BaseConfig, LLM_MODULE, SERVER_MODULE = load_config_modules() +from config_test_support import ( + LLM_MODULE, + SERVER_MODULE, + SOURCE, + BaseConfig, + EngineConfig, + load_module, +) class ChunkConfigTests(unittest.TestCase): diff --git a/test/llm/test_chunk_execution.py b/test/llm/test_chunk_execution.py index ba8d48e06..97886751d 100644 --- a/test/llm/test_chunk_execution.py +++ b/test/llm/test_chunk_execution.py @@ -3,8 +3,8 @@ from types import SimpleNamespace from unittest.mock import patch -from chunk_test_support import MODULES -from test_chunk_config import LLM_MODULE, load_module +from cache_test_support import MODULES +from config_test_support import LLM_MODULE, load_module def load_processor(): @@ -53,7 +53,7 @@ def test_static_processor_still_uses_its_prefix_metadata(self): self.assertIsNone(inputs["slot_mapping"]) def test_unsupported_models_rejected_before_native_initialization(self): - from test_chunk_config import EngineConfig + from config_test_support import EngineConfig for hf in ({"num_experts": 8}, {"vision_config": {}}, {"audio_config": {}}): with ( diff --git a/test/llm/test_chunk_output.py b/test/llm/test_chunk_output.py index a0b5b1891..cb6ecfb29 100644 --- a/test/llm/test_chunk_output.py +++ b/test/llm/test_chunk_output.py @@ -6,7 +6,7 @@ from unittest.mock import patch import test_chunk_execution -from test_chunk_config import EngineConfig, load_module +from config_test_support import EngineConfig, load_module def load_runner(): diff --git a/test/llm/test_chunk_scheduler.py b/test/llm/test_chunk_scheduler.py index b8dcbf6fe..50280cafe 100644 --- a/test/llm/test_chunk_scheduler.py +++ b/test/llm/test_chunk_scheduler.py @@ -1,6 +1,6 @@ import unittest -from chunk_test_support import MODULES +from cache_test_support import MODULES Scheduler = MODULES["scheduler"].Scheduler Request = MODULES["request"].InferenceRequest @@ -89,6 +89,43 @@ def test_disabled_mode_retains_whole_prompt_dispatch(self): self.assertEqual(len(req.slot_mapping), 35) self.assertFalse(scheduler.chunking_queue) + def test_decode_page_boundary_and_cancellation_match_without_chunking(self): + from cache_test_support import assert_state + + slots = [] + for chunk_size in (0, 16): + with self.subTest(chunk_size=chunk_size): + scheduler = self.scheduler( + num_blocks=8, max_batch_size=2, prefill_chunk_size=chunk_size + ) + requests = [self.request(name, 16) for name in ("cancel", "a", "b")] + for req in requests: + req.block_table, _ = scheduler.cache_manager.allocate_slots(16) + req.num_blocks = 1 + req.num_computed_tokens = 16 + req.status = Status.RUNNING + req.append_generated_token_id(12) + scheduler.running_queue.sync_q.put(req) + requests[0].mark_canceled() + + step = scheduler.schedule() + + self.assertFalse(step.is_prefill) + self.assertIsNone(step.prefill_end) + self.assertEqual(step.scheduled_requests, requests[1:]) + for req in step.scheduled_requests: + self.assertEqual(req.num_local_cached_tokens, 16) + self.assertEqual(req.num_blocks, 2) + self.assertEqual(req.slot_mapping, [req.block_table[1] * 16]) + slots.append([req.slot_mapping for req in step.scheduled_requests]) + self.assertEqual(scheduler.cache_manager.get_total_usable_blocks(), 4) + for req in step.scheduled_requests: + req.mark_canceled() + scheduler.complete_requests(step.scheduled_requests) + self.assertEqual(scheduler.cache_manager.get_total_usable_blocks(), 8) + assert_state(self, scheduler.cache_manager) + self.assertEqual(slots[0], slots[1]) + def test_nonaligned_chunks_reconstruct_physical_slots(self): scheduler = self.scheduler(prefill_chunk_size=11) req = self.request("unaligned", 35) diff --git a/test/llm/test_scheduler_cache_lifecycle.py b/test/llm/test_scheduler_cache_lifecycle.py index c13f0787c..6d18efbbf 100644 --- a/test/llm/test_scheduler_cache_lifecycle.py +++ b/test/llm/test_scheduler_cache_lifecycle.py @@ -27,6 +27,39 @@ def make_scheduler(self, **kwargs): def output(**connector_output): return SimpleNamespace(kv_connector_output=SimpleNamespace(**connector_output)) + def test_connector_metadata_survives_prefill_decode_and_idle_dispatch(self): + metadata = object() + connector = SimpleNamespace( + build_connector_meta=lambda: metadata, + get_num_new_matched_tokens=lambda req, cached: (0, False), + update_state_after_alloc=lambda *args: None, + request_finished=lambda *args: (False, None), + ) + scheduler = self.make_scheduler( + num_blocks=4, block_size=16, connector=connector + ) + req = InferenceRequest( + "metadata", + prompt_token_ids=[11], + sampling_params=SamplingParams(max_tokens=2), + ) + scheduler.add_request(req) + prefill = scheduler.schedule() + self.assertTrue(prefill.is_prefill) + self.assertIs(prefill.kv_connector_metadata, metadata) + req.append_generated_token_id(12) + scheduler.complete_requests([req]) + decode = scheduler.schedule() + self.assertFalse(decode.is_prefill) + self.assertEqual(decode.scheduled_requests, [req]) + self.assertIs(decode.kv_connector_metadata, metadata) + req.mark_canceled() + scheduler.complete_requests([req]) + idle = scheduler.schedule() + self.assertEqual(idle.scheduled_requests, []) + self.assertIs(idle.kv_connector_metadata, metadata) + assert_state(self, scheduler.cache_manager) + def test_send_completion_releases_exactly_once(self): scheduler = self.make_scheduler( num_blocks=1, block_size=16, connector=DelayedConnector() From f6eaf6cc3945dca13f9134e80b3675aa31947fcc Mon Sep 17 00:00:00 2001 From: tangchengxiang <2064027004@qq.com> Date: Wed, 16 Sep 2026 12:17:05 +0000 Subject: [PATCH 5/9] chore: move experiment artifacts out of the source diff --- docs/cache-and-chunking.md | 100 +++ docs/cache-chunk-validation.md | 94 --- docs/chunk-graphs.md | 30 - docs/chunked-prefill.md | 134 ---- docs/performance-ablation.md | 193 ----- docs/validation/cache-chunk-graphs.json | 278 ------- docs/validation/performance-ablation.json | 827 ------------------- docs/validation/test-results.png | Bin 232605 -> 0 bytes test/llm/README.md | 121 +-- test/llm/README.slru.md | 98 --- test/llm/benchmark_chunk_prefill.py | 298 ------- test/llm/benchmark_prefix_cache.py | 928 ---------------------- test/llm/test_benchmark_prefix_cache.py | 230 ------ 13 files changed, 137 insertions(+), 3194 deletions(-) create mode 100644 docs/cache-and-chunking.md delete mode 100644 docs/cache-chunk-validation.md delete mode 100644 docs/chunk-graphs.md delete mode 100644 docs/chunked-prefill.md delete mode 100644 docs/performance-ablation.md delete mode 100644 docs/validation/cache-chunk-graphs.json delete mode 100644 docs/validation/performance-ablation.json delete mode 100644 docs/validation/test-results.png delete mode 100644 test/llm/README.slru.md delete mode 100644 test/llm/benchmark_chunk_prefill.py delete mode 100644 test/llm/benchmark_prefix_cache.py delete mode 100644 test/llm/test_benchmark_prefix_cache.py diff --git a/docs/cache-and-chunking.md b/docs/cache-and-chunking.md new file mode 100644 index 000000000..c436185ad --- /dev/null +++ b/docs/cache-and-chunking.md @@ -0,0 +1,100 @@ +# Paged prefix caching and chunked Prefill + +Paged prefix caching uses LRU by default. Chunking is disabled by default. +For a dense text model, optional SLRU and bounded Prefill can be configured +through `EngineConfig`, `LLM`, or `AsyncLLMEngine`: + +```python +from infinilm.llm import AsyncLLMEngine + +engine = AsyncLLMEngine( + model_path="/path/to/model", + cache_type="paged", + prefix_cache_policy="slru", + prefix_cache_protected_ratio=0.8, + prefill_chunk_size=512, + tensor_parallel_size=1, + enable_graph=False, +) +``` + +The inference server and `examples/test_infer.py` expose the same settings: + +```sh +python python/infinilm/server/inference_server.py --model /path/to/model \ + --enable-paged-attn --prefix-cache-policy slru \ + --prefix-cache-protected-ratio 0.8 --prefill-chunk-size 512 +``` + +## Cache policies + +Only zero-reference pages can be reclaimed. Final release processes a request's +pages tail first, favoring its earlier prefix. Shared or remote-transfer owners +keep their pages pinned until the last reference is released. + +SLRU reclaims probationary pages before protected pages. A resident prefix is +promoted only after successful admission; lookup, rejected admission and newly +computed KV do not promote it. Protected membership includes pinned pages and +is capped at `floor(num_blocks * prefix_cache_protected_ratio)`. The oldest +protected member is demoted when this cap is exceeded. The ratio must be +strictly between zero and one; this cap does not reserve GPU memory. + +SLRU requires paged cache. Disabling prefix caching disables reuse and promotion. +It retains no history of evicted hashes. It can preserve established hotspots +through one-use traffic, but stale protected pages can delay adaptation to new +hotspots, and overcapacity cyclic traffic can still miss on every request. + +## Scheduling and parallel execution + +A Prefill dispatch computes at most +`min(prefill_chunk_size, max_num_batched_tokens)` tokens from one request. +Successful dispatches rotate among Decode, Prefill continuation and admission; +empty or blocked phases are skipped. Decode reuses the ordinary scheduler and +its batch limit. This bounds dispatch opportunities, not elapsed latency. + +Admission still reserves all prompt pages and future Decode headroom. Prefix +lookup pins only published full pages; failed admission releases temporary pins. +After successful execution, only completed full pages are published. Cancellation +releases request ownership while preserving reusable completed pages. +Intermediate chunks execute every Transformer layer but omit the LM head, +sampling and output tokens. The final chunk enters normal generation handling. + +Supported chunk configurations are TP/PP = 1/1, 2/1 and 1/2. TP2 requires working +CUDA collectives; PP2 uses eager execution and publishes only after both stages +complete. Static cache, MLA, draft models, remote KV connectors, Mamba, MoE and +multimodal inputs are excluded from chunking. The direct-native `bench.py`, +`llama.py` and `bench_videonsa.py` examples reject chunking because they bypass +the scheduler; `bench.py` also disables prefix caching. + +## Device graphs + +With `device="cuda"` and `enable_graph=True`, TP1/TP2 with PP1 can combine eager +Prefill and Decode graphs. InfiniCore must be built with `--graph=y`. Tested +attention backends are `paged-attn` on NVIDIA A6000 and `flash-attn` on MetaX +C500; `cuda` maps to MACA on the MetaX build. Other devices have not been +validated for these combinations. + +Experimental fixed-size Prefill graphs additionally require MetaX, TP1, ordinary +paged KV and Flash Attention. Set `INFINILM_PREFILL_GRAPH_CHUNK_SIZE=512` before +creating the engine. Only a single request of exactly the selected size uses +this Prefill graph; other shapes retain the existing path. Single-token tails +may use Decode graphs. Separate intermediate/final graphs retain persistent +buffers for IDs, positions, KV lengths, offsets, page tables and slot mappings. +An intermediate graph with no logits has still executed and must not run again +in eager mode. PP graphs and TP2 Prefill graphs are excluded. + +## Validation and tradeoffs + +See [test instructions](../test/llm/README.md) for CPU regressions and opt-in +native lifecycle checks. Hardware validation covered A6000 Qwen2.5-1.5B FP16 +and C500 Qwen3-0.6B/4B BF16. This does not establish support for every dense +architecture or backend. + +Chunking can reduce long output pauses and short-request waiting while reducing +throughput and increasing long-request TTFT. Graphs add initialization time and +retained workspace; full memory overhead and sustained HTTP throughput were +not measured. A historical FP16 near-tied-token mismatch across prefix execution +shapes remains documented; universal bitwise equivalence is not promised. + +[PR #573](https://github.com/InfiniTensor/InfiniLM/pull/573) contains the measured +benefits, costs, test conditions and links to archived reports and experiments. diff --git a/docs/cache-chunk-validation.md b/docs/cache-chunk-validation.md deleted file mode 100644 index 8a9201f41..000000000 --- a/docs/cache-chunk-validation.md +++ /dev/null @@ -1,94 +0,0 @@ -# Cache, chunking and graph integration validation - -The default is LRU eviction with chunking disabled. SLRU and chunking are -optional. Prefix cache hits are promoted only after successful admission in -both scheduling paths. Intermediate chunks execute all Transformer layers; -the LM head and sampling are omitted until the final chunk. - -## Tested configurations (2026-09-16) - -| Hardware / model | Execution | Coverage | -|---|---|---| -| A6000, Qwen2.5-1.5B FP16 | TP1 and TP2, eager or eager Prefill + Decode graphs | LRU, SLRU, non-page-aligned chunk300, prefix reuse, shared ownership, cancellation, prefix caching off | -| Two A6000s, same model | TP1/PP2 eager | Unchunked baseline, chunk300 + LRU/SLRU, prefix caching off; both stages inspected | -| C500 slice, Qwen3-0.6B BF16 | TP1, Decode graph with eager or graph Prefill | LRU/SLRU, chunk512, prefix reuse, cancellation, prefix caching off | -| C500 slice, Qwen3-4B BF16 | TP1, Prefill and Decode graphs | SLRU, chunk512, shared/repeated prefix and cancellation | - -All 13 A6000 configurations matched one completed-request token reference. -The six C500 lifecycle configurations passed; 0.6B outputs matched across -policies/modes and each model's shared/repeated requests matched. First/last -local KV layers were checked by poisoning scheduled slots, requiring finite -writes there and unchanged values elsewhere. Every case returned all 16 -blocks to a usable state with zero references. Actual device-graph launches -were counted; TP2 Decode replay launched a graph on each rank. - -The matching InfiniCore MetaX varlen ABI fix also passed six Prefill and six -Decode FP16/BF16 operator cases against FP32 reference attention. Its build -selects the signature of the installed wheel; older signatures have fixture -coverage, not execution on older hardware/software installations. - -Structured results: [cache-chunk-graphs.json](validation/cache-chunk-graphs.json). -The CPU suite now has 111 passing tests, including successful-admission-only -SLRU promotion, PP worker configuration forwarding, shared Decode scheduling -across page boundaries and connector metadata on idle dispatch: - -```sh -python -m unittest discover -s test/llm -p 'test_*.py' -``` - -Native reproduction commands and prerequisites are in -[chunked-prefill.md](chunked-prefill.md) and [chunk-graphs.md](chunk-graphs.md). -`check_chunk_tp.py` accepts `--tp`, `--pp`, `--stage`, `--port`, `--graph`, -`--policy`, `--cache-off`, `--chunk-size` and `--output`. Use separate processes -and visible devices for PP stages. The counter is required only with `--graph`. - -## Scheduler reuse follow-up - -Both Prefill policies now reuse the original Decode scheduling path; output -construction also shares speculative operations and connector metadata setup. -The configuration tests share one isolated module loader. The follow-up passed -111 CPU tests (108 existing and three additional cases) and one A6000 smoke -run: Qwen2.5-1.5B FP16, TP1/PP1, SLRU, page256/pool16, chunk300, eager Prefill -and Decode graphs. It observed 14 device-graph launches, matched the archived -same-configuration output tokens, and returned all 16 pages with zero references -after shared/repeated requests and cancellation. It used the refactored Python -source with the unchanged native binary from the integration checks. This was -a correctness check; the full GPU matrix and archived performance measurements -were not repeated for this Python refactor. - -## Performance boundaries - -The [component performance report](performance-ablation.md) provides baseline -versus candidate numbers, hardware, shapes, dtype, sample counts and costs for -LRU, SLRU, TP2 chunking, intermediate-output omission and C500 graph modes. -Its [measurement export](validation/performance-ablation.json) contains -per-run evidence. Those component experiments precede final integration; -they must not be presented as a full rerun of this revision. - -A final TP2 mixed-request smoke comparison used chunk512, a 2048-token long -prompt, two 128-token short prompts, 160 output tokens, and prefix reuse off. -One window per mode measured 161.71 token/s eager and 161.02 token/s with -Decode graphs; all outputs matched. This does not demonstrate a throughput -improvement. KV-poison checks are correctness instrumentation and are not -used as performance measurements. - -The earlier C500 ablation showed clearer Decode ITL benefits and only a small -increment from fixed-size Prefill graphs; see [chunk-graphs.md](chunk-graphs.md). -Chunking improves opportunities for other requests to progress, while it can -increase long-request TTFT and reduce throughput. No universal speedup is -claimed. - -## Limits - -PP graphs, TP2/PP2, remote-KV integration, MoE, quantization, multimodal models, -Ascend and Moore devices were not tested. This does not extend those support -claims. PP graph/chunk combinations are rejected; fixed-size Prefill graphs -are restricted to MetaX TP1. Full graph workspace memory overhead and sustained -service throughput were not measured. Near-tied FP16 tokens can differ across -prefix execution shapes, as documented in the earlier chunking checks. - -Builds reused existing objects where possible. A clean build and the upstream -hardware CI matrix remain external validation steps. The native runners test -the real scheduler/model/cache path directly; they do not replace an HTTP -load test. The direct-native examples bypass the scheduler and deliberately -reject the chunking option. diff --git a/docs/chunk-graphs.md b/docs/chunk-graphs.md deleted file mode 100644 index 49f4b0266..000000000 --- a/docs/chunk-graphs.md +++ /dev/null @@ -1,30 +0,0 @@ -# Experimental graphs with chunked Prefill - -Supported chunk/graph combinations are TP1/PP1 and TP2/PP1 on CUDA-compatible devices. Tested backends are NVIDIA A6000 paged attention (Qwen2.5-1.5B FP16) and MetaX C500 Flash Attention (Qwen3 BF16, TP1). PP2 chunking uses eager execution. - -Set `prefill_chunk_size=512`, `enable_graph=True` and `device="cuda"` to combine eager Prefill with Decode graphs. Use `attn_backend="paged-attn"` on the tested A6000 build and `"flash-attn"` on C500. InfiniCore must be built with `--graph=y`; TP2 also requires working collectives. The `cuda` device alias maps to MACA in the C500 build. - -To additionally test fixed-size Prefill graphs, set -`INFINILM_PREFILL_GRAPH_CHUNK_SIZE=512` **before creating the engine**. This -experimental switch requires the MetaX backend, TP1, ordinary paged KV and -Flash Attention. It captures one request of exactly the chosen size; other -sizes retain the existing execution path. Single-token final tails may use -the Decode graph because they have the same one-query attention semantics. - -The compiler captures separate intermediate and final graphs. Intermediate -chunks execute every Transformer layer but omit the LM head, sampling and -token output, matching the eager implementation. A graph with no logits is -still a completed execution and must not trigger a duplicate eager forward. - -Token IDs, positions, KV lengths, offsets, page tables and slot mappings are -updated in persistent buffers before each replay. The scalar maximum KV -length is fixed to the cache pool capacity so that later chunks and reused -prefixes do not inherit the first chunk's length. Unsupported shapes fall -back to the existing execution path. This implements fixed shapes, without -padding or dynamic Prefill batch buckets. - -The integrated cache manager supports LRU and optional SLRU with both graph modes. NVIDIA TP2 Decode graphs and PP2 eager have lifecycle validation; fixed-size Prefill graphs remain MetaX TP1 only. Remote KV, PP graphs, speculative decoding and hybrid/MoE/multimodal models are outside this scope. - -A C500 short ablation (Qwen3-4B, 2048 input/16 output, chunk512) measured single-request token intervals of 12.96 ms for chunk eager and 9.37 ms with Decode graphs. Adding Prefill graphs reduced TTFT from 247.97 to 241.37 ms. These samples precede the cache-policy integration and do not establish sustained serving throughput. Graphs add initialization cost and retained workspace; full additional memory usage was not measured. - -Integrated lifecycle results and limits: [cache-chunk-validation.md](cache-chunk-validation.md). diff --git a/docs/chunked-prefill.md b/docs/chunked-prefill.md deleted file mode 100644 index 8e6e6cfea..000000000 --- a/docs/chunked-prefill.md +++ /dev/null @@ -1,134 +0,0 @@ -# Bounded chunked prefill - -`prefill_chunk_size=0` (default) preserves the existing scheduling path. Set a positive value through `EngineConfig`, `LLM`, `AsyncLLMEngine`, or `--prefill-chunk-size` on the inference server / `examples/test_infer.py` to bound each prefill dispatch. For example: - -```python -from infinilm.llm.llm import AsyncLLMEngine - -engine = AsyncLLMEngine( - model_path="/path/to/dense-text-model", - tensor_parallel_size=2, - cache_type="paged", - enable_graph=False, - prefill_chunk_size=512, -) -``` - -The effective prefill segment is at most `min(prefill_chunk_size, max_num_batched_tokens)` tokens. The supported parallel configurations are TP/PP=1/1, 2/1 and 1/2. PP=2 uses eager execution; TP=1/2 with PP=1 can combine eager Prefill and Decode graphs on CUDA-compatible devices. See [graph configuration](chunk-graphs.md). CLI parsing rejects other parallel sizes before starting a pipeline worker. Static cache, MLA, draft models, remote KV connectors, Mamba, MoE and multimodal inputs are excluded from this opt-in mode. TP=2 requires a working CUDA collective runtime (InfiniCCL built with CUDA/NCCL support); a CUDA model build alone does not establish that collectives are enabled. The direct-native examples `bench.py`, `llama.py` and `bench_videonsa.py` reject this option because they bypass the scheduler. Native validation used Qwen2.5-1.5B FP16 with paged attention on an A6000; this is not validation of every dense architecture/backend. - -## Scheduling and ownership - -Successful dispatches rotate among decode, prefill continuation, and new admission; empty or capacity-blocked phases are skipped. Each continuously eligible phase receives an opportunity within three successful dispatches. This is a bound on dispatch opportunities, not milliseconds or per-request admission latency. Decode batches retain `max_batch_size`; prefill dispatches contain one request. Continuations rotate FIFO. A capacity-deferred admission returns to the waiting queue. - -Admission still reserves the full prompt's KV pages and accounts for the future decode capacity of partial requests. This change does not reduce prompt KV reservation. Existing prefix lookup pins only published full blocks; failed admission releases its temporary references. After successful model execution, only the completed span can be published. Cancellation returns all owned references, leaving computed full pages reusable. Intermediate chunks advance the computed boundary without emitting or appending sampled tokens; only the final chunk enters normal generation/EOS handling. Intermediate chunks use an internal `prefill_only` forward: the text causal-LM template skips the LM head, the worker skips sampling and token transfer, and the runner returns no sampled IDs. Final chunks and decode retain normal output handling. - -This work follows the input-slicing/continuation concepts in [#371](https://github.com/InfiniTensor/InfiniLM/pull/371), adapted to the target branch's per-request KV publication. At audited revision `02419ee3`, #371's executable phase order favors waiting, then continuation, then decode (with periodic forced continuation), so sustained admissions can defer decode. The new mode adds explicit phase rotation, per-request publication and cancellation handling. [#571](https://github.com/InfiniTensor/InfiniLM/pull/571), audited at `6683db7e`, orders waiting admissions with priority/aging and addresses a separate concern. This is not a claim of first implementing chunked prefill, nor an integration of those two PRs. - -## Validation and tradeoffs - -Run native-free regressions with Python 3.10+ and `janus`, `xxhash` installed: - -```sh -python -m unittest discover -s test/llm -p 'test_chunk_*.py' -``` - -The chunk tests cover configuration forwarding/exclusions, disabled/static paths, offsets including non-page-aligned chunks, shared prefixes, publication boundaries, EOS/final output, queued and in-flight cancellation, generation hashing, capacity/pin rollback and phase progress. - -One short A6000 run used eager Qwen2.5-1.5B FP16, 256-token pages, 128 pages, batch limit 2, prefix caching off, and three requests: an active 128-token prompt generating 128 tokens, an arriving 8192-token prompt generating 16, and a late 128-token prompt generating 16. With 512-token chunks: - -| Measurement | Disabled | Chunk 512 | -|---|---:|---:| -| Active request maximum output gap | 1804.46 ms | 227.92 ms | -| Active request p95 output gap | 25.64 ms | 136.68 ms | -| Late short request first token | 1781.56 ms | 77.08 ms | -| Long request first token | 1790.27 ms | 2098.04 ms | -| All three requests finish | 2.928 s | 3.015 s | - -All 160 generated token IDs matched. These are single-run delivery timestamps, not stable throughput or universal speedup estimates. Breaking one long stall into several smaller stalls improves the maximum gap while worsening p95 in this trace. The late request starts earlier but also encounters gaps while the long prefill continues. - -A separate native lifecycle check used 300-token chunks across 256-token pages, a 1027-token prompt, cancellation after the first chunk, reuse of its 256-token published prefix, and repeat reuse of a 1024-token prefix. Ownership, publication bounds, no intermediate output and completion checks passed. **Strict greedy sequence equality failed** between the partial-prefix/chunk path and full-prefix reuse at generated index 6. At identical prompt/prior tokens, the competing FP16 logits were tied at 16.015625 in the first path, versus 16.03125 and 16.015625 on reuse. Full-prefix repeats were stable, and a normal-forward rerun matched the diagnostic outputs. This is evidence of sensitivity to small numerical differences across execution shapes, not a proof of general numerical equivalence. Preserve this failed strict comparison when evaluating the feature; no model-quality benchmark is claimed. - - -## TP=2 execution contract - -The scheduler owns one request state and one logical page table. `InferEngine::forward` fans out the same input to both rank workers; each worker copies positions, sequence lengths, offsets, block tables and slots to its own device and sets its thread-local attention metadata. Dense tensor parallelism partitions attention heads while retaining the token coordinates. The existing segmented processor inputs therefore express the same prefill span on both ranks; the TP=2 extension changes the configuration guards and adds native validation, without a new native slicing implementation. - -Engine-owned KV writes, reads and subsequent reuse are ordered on persistent device streams. Worker `wait()` is not an all-device completion barrier: rank zero synchronizes after forward (after sampling on the ordinary output path), while another rank can report completion after submission. The supported serialized engine path preserves stream ordering for later reuse. Cross-stream KV transfer and overlap are outside this contract. No additional global device synchronization is inserted in the production path. - -Run explicit native checks with an FP16 dense model and a working CUDA/NCCL runtime: - -```sh -CUDA_VISIBLE_DEVICES=0,1 python test/llm/check_chunk_tp.py --model /path/to/model --tp 2 --chunk-size 300 --output /tmp/chunk-tp2.json -CUDA_VISIBLE_DEVICES=0,1 python test/llm/benchmark_chunk_prefill.py --model /path/to/model --tp 2 --chunk-size 0 --output /tmp/mixed-off.json -CUDA_VISIBLE_DEVICES=0,1 python test/llm/benchmark_chunk_prefill.py --model /path/to/model --tp 2 --chunk-size 512 --output /tmp/mixed-512.json -``` - -The correctness script watches the first and last local attention layers on each rank/stage. Before every prefill it fills scheduled K/V slots with NaNs, then verifies all scheduled values are finite and values outside the span are unchanged. These probes synchronize/copy KV only in this script; the benchmark does not use them. TP=2 chunk 300, TP=1 chunk 300, TP=2 chunk disabled and TP=2 chunk 300 with prefix caching disabled all passed. They cover non-page-aligned spans, partial and full prefix reuse, shared references, cancellation and full capacity recovery. The same completed prompts generated identical tokens across these four fixtures. Controlled cancellation after forward covers a final segment with caching enabled and an intermediate segment with caching disabled; it is not a network cancellation race test. - -## TP=2 short mixed-request measurements - -Two A6000s, Qwen2.5-1.5B FP16, eager paged attention, the same three-request workload above, prefix caching disabled. Each configuration ran three times; entries are medians of per-run metrics. Disabled/512 runs alternated order; 1024 ran subsequently, so shared-machine drift is not fully controlled. - -| Measurement | Disabled | Chunk 512 | Chunk 1024 | -|---|---:|---:|---:| -| Active maximum output gap | 1022.94 ms | 142.74 ms | 239.21 ms | -| Active p95 output gap | 24.88 ms | 92.68 ms | 63.62 ms | -| Late short request first token | 1001.54 ms | 68.16 ms | 139.44 ms | -| Long request first token | 1012.17 ms | 1435.76 ms | 1210.33 ms | -| Finite-window output throughput | 75.27 token/s | 62.83 token/s | 68.06 token/s | - -Chunk 512 reduced maximum output gap by 86.0% and late TTFT by 93.2%, with 41.8% longer long-request TTFT and 16.5% lower output throughput. Chunk 1024 reduced those gaps by 76.6% and 86.1%, with 19.6% longer long TTFT and 9.6% lower throughput. Both worsened active p95: one long stall becomes multiple shorter stalls. Throughput is 160 delivered output tokens divided by elapsed time from earliest request submission to last token; loading and warmup are excluded. These short shared-machine measurements do not establish sustained serving throughput or an optimal chunk size. - -All nine TP=2 runs matched the same 160 baseline token IDs. Traces confirm 16 or 8 long-prefill segments, active decode progress between segments and late first-token delivery before the long prefill finished. Prefer 512 when reducing the largest pauses matters most; 1024 is a candidate for a smaller throughput penalty. The default remains zero. Prefill/decode are separate dispatches, not a fused mixed batch, and the original measurements in this table predate the output-suppression optimization below. - -The prior strict numerical failure was also reproduced under TP=2 and remains **strict_mismatch**. At generated index 6 with identical prior tokens, the partial-prefix path selected token 220 with logit 16.03125 versus token 13 at 16.015625; full-prefix reuse tied both at 16.015625 and selected 13. Two full-prefix repeats agreed. This supports a narrow FP16 execution-shape sensitivity diagnosis; it does not prove universal output equivalence or model-quality preservation. Review this opt-in extension with that limitation visible. - - -## Avoid unused intermediate outputs - -The internal `prefill_only` flag is enabled automatically only for non-final chunks. It defaults to false in native inputs and is propagated to every TP rank. The shared text causal-LM template computes the model/KV state and omits last-token selection and the vocabulary projection. The rank worker omits GPU sampling, token D2H transfer and retained output tensors. The Python forward returns `None`; the runner returns an empty sampled-ID list, which the existing intermediate-chunk lifecycle handles before normal output-count validation. Other model implementations can still compute logits internally; only the shared text template's LM-head bypass was implemented here. - -Rank zero still synchronizes its device stream before returning. The optimization does not relax publication/cancellation completion or introduce cross-stream overlap. To preserve the previous stochastic sampling progression, the worker still makes one identical `uniform_real_distribution` RNG draw per request and discards it. This is a source-level equivalence argument; greedy tests do not establish stochastic output equality. Native guards reject all-position sampling and missing request offsets. For PP, all stages receive the same intermediate flag, finish their activation transfers, and skip the sampled-token exchange. The coordinator waits for each stage before publishing or reclaiming KV pages. Intermediate chunks run eagerly unless the opt-in fixed-size MetaX Prefill graph matches. - -**Rebuild the InfiniLM native extension when using this change.** InfiniCore does not need a source change. Additional checks: - -```sh -python -m unittest discover -s test/llm -p 'test_chunk_*.py' -CUDA_VISIBLE_DEVICES=0,1 python test/llm/check_chunk_output.py --model /path/to/model --tp 2 --chunk-size 300 --output /tmp/chunk-output.json -CUDA_VISIBLE_DEVICES=0,1 python test/llm/benchmark_chunk_prefill.py --model /path/to/model --tp 2 --chunk-size 512 --legacy-chunk-output --output /tmp/legacy-output.json -CUDA_VISIBLE_DEVICES=0,1 python test/llm/benchmark_chunk_prefill.py --model /path/to/model --tp 2 --chunk-size 512 --output /tmp/skip-output.json -``` - -`--legacy-chunk-output` is a benchmark control: it restores intermediate logits, sampling, transfer and conversion using the same compiled binary and unchanged schedule. It is not an engine policy option. The CPU tests include intermediate/final/decode forwarding and cancellation with an empty output. Native TP1/TP2 and prefix-off checks confirm absent intermediate raw outputs, valid final outputs and unchanged KV lifecycle; invalid native calls are rejected without preventing subsequent valid inference. - -Three alternating TP2 chunk512 comparisons on the same short workload gave these per-run medians: - -| Measurement | Legacy output work | Skip intermediate output | -|---|---:|---:| -| Total long-prefill dispatch execution | 1202.99 ms | 1175.69 ms | -| Intermediate long-prefill dispatch execution | 1077.84 ms | 1051.54 ms | -| Long request TTFT | 1437.11 ms | 1426.41 ms | -| Active maximum output gap | 141.38 ms | 140.92 ms | -| Active p95 output gap | 93.97 ms | 92.33 ms | -| Late short request TTFT | 75.52 ms | 75.26 ms | -| Finite-window output throughput | 64.19 token/s | 62.87 token/s | - -Measured long-prefill execution decreased 2.3%, while overall throughput did **not** improve in the median (-2.0%). Per-run throughput ranged 61.05–68.14 versus 61.17–67.19 token/s, with changes in both directions between paired runs. These measurements support removing redundant work, not a stable end-to-end throughput gain or recovery of the earlier chunking penalty. All six runs matched the same 160 baseline tokens, retained 16 long-prefill segments and let active decode and late admission progress. The preserved near-tied FP16 fixture still reports `strict_mismatch`; this optimization does not resolve that numerical limitation. - - -A separate same-input CUDA/NVTX replay confirmed that across 15 intermediate chunks on two ranks, the optimization removed 30 LM-head GEMVs, 30 last-token selections, 45 sampling-related kernels and 15 token D2H copies. Paged-prefill attention and NCCL invocation counts stayed at 840 and 1680. They accounted for approximately 73% and 12% of summed kernel duration across both GPUs in the legacy replay; those sums are not wall-clock shares. The trace supports targeting paged-prefill attention efficiency and batch scheduling for further throughput work, rather than expecting the small sampling cost to explain the entire chunking penalty. Replay/instrumentation timings are excluded from the serving comparison. - -## Integrated cache and parallel validation - -`prefix_cache_policy="lru"` is the default; `"slru"` enables bounded probation/protected queues. Chunk admission records prefix hits only after allocation succeeds, so capacity-deferred requests cannot promote cache entries. The same page manager handles eager and graph execution. - -On two A6000s, Qwen2.5-1.5B FP16 passed 13 combinations covering single-device/TP2 eager and Decode graphs, PP2 eager, LRU/SLRU and prefix caching disabled. Completed shared/repeated requests matched one baseline token sequence, first/last local KV layers passed poisoned-slot checks, and all references returned to zero. PP worker checks separately cover the second stage. This finite set does not remove the FP16 numerical limitation documented above. - -Build InfiniCore with `--graph=y` for device graphs and CUDA/NCCL collective support for TP/PP. A build without device graphs can replay recorded operators on the host; an enabled LM flag alone does not demonstrate CUDA graph execution. The native test can check actual launches with an optional counter: - -```sh -g++ -std=c++17 -shared -fPIC -I"$INFINI_ROOT/include" test/llm/graph_counter.cc -ldl -o /tmp/infini-graph-counter.so -LD_PRELOAD=/tmp/infini-graph-counter.so CUDA_VISIBLE_DEVICES=0,1 python test/llm/check_chunk_tp.py --model /path/to/fp16-model --tp 2 --chunk-size 300 --graph --policy slru --output /tmp/tp2-graph.json -``` - -For PP, start the same checker twice using separate `CUDA_VISIBLE_DEVICES` values, `--tp 1 --pp 2`, a shared `--port`, and `--stage 1` on the worker. Both processes need their own `--output` file. PP graphs and combined TP2/PP2 are excluded. diff --git a/docs/performance-ablation.md b/docs/performance-ablation.md deleted file mode 100644 index 9385de88c..000000000 --- a/docs/performance-ablation.md +++ /dev/null @@ -1,193 +0,0 @@ -# Cache, chunking and graph performance evidence - -These results explain the individual mechanisms integrated by this PR. They -are archived component experiments, **not a complete performance rerun of the -final integrated revision**. Final integration correctness is reported in -[cache-chunk-validation.md](cache-chunk-validation.md). Do not multiply the -gains from different experiments or interpret them as a universal speedup. - -[Sanitized measurements](validation/performance-ablation.json) include -per-run values, original artifact names and SHA256 hashes. The values below -were recalculated from request accounting, output timestamps and recorded -execution steps. The export omits machine paths, credentials and prompts. -Historical source commits identify development snapshots; they are not the -published squash commit. Worktree/binary hashes in the original experiments -were used where a commit alone did not describe the tested build. - -[Results screenshot](validation/test-results.png): browser capture of the -audited saved-results report, not a new GPU run or upstream CI result. - -## Definitions and common conditions - -- TTFT: request submission to its first generated token. ITL: interval between - consecutive generated tokens. An active request's maximum ITL measures its - worst observed pause, not its typical token latency. -- Cached tokens are actual admitted reused tokens; Prefill tokens are prompt - tokens that still require computation. Warmup requests are excluded below. -- Percentage change is `100 * (new / old - 1)`. Negative latency or work - changes are reductions. Each table names its baseline and aggregation. -- All model experiments use dense models, greedy decoding and paged KV with - 256-token pages. Fixed output budgets ignore EOS for timing; normal-EOS - and lifecycle checks are separate. No sustained HTTP load, confidence - intervals or statistical significance claim is provided. -- NVIDIA experiments use RTX A6000 48 GB GPUs, CUDA 12.4 and driver 580.105.08; - Qwen2.5 model/KV tensors are FP16. GPU(s) were checked for availability, - but the host was shared. TP2 uses NCCL on the two local A6000s. - -## LRU versus the former set-based reclamation - -One A6000, Qwen2.5-1.5B (model revision -`8faed761d45a263340a0528343f099c05c9a4323`), TP1, eager paged attention, -64 cache blocks, concurrency 1. Each repeat has 128 requests, of which the -first 16 warm the cache; each prompt has 1,024 prefix tokens plus a unique -32-token tail, and produces 32 tokens. Two independent engine repeats use -the same hotspot/cold trace. The baseline is `270feb3e`; the LRU component -is `49410568`, both using Core `1ab85ef1`. - -| Metric | Set-based baseline | LRU | Interpretation | -|---|---:|---:|---| -| Cached tokens per repeat | 61,440 | 86,016 | +40.00% reused work | -| Prefill tokens per repeat | 56,832 | 32,256 | **43.24% less prompt computation** | -| Mean TTFT, repeats 0 / 1 (ms) | 38.37 / 38.46 | 27.59 / 27.54 | Median paired reduction **28.24%** | -| Output token/s, repeats 0 / 1 | 120.94 / 124.60 | 121.98 / 124.99 | +0.87% / +0.32%; no stable throughput gain | - -The policy retains recently reused prefixes instead of depending on set -iteration order. It is not universally better: Qwen2.5-7B with a 128-page -cyclic working set and only 64 blocks had 33,792 -> 0 cached tokens and -34.96 -> 33.16 output token/s (**5.14% lower**, one run). LRU thrashed on -that trace while the old policy happened to keep a subset. - -Some historical FP16 cache-on/off outputs differ with execution shape. -Matching-retention controls and logit replays were investigated separately; -these latency results do not establish universal token-level equivalence. -The final integrated correctness checks also retain this limitation. - -CPU-only method-call microbenchmark: Python 3.11.15, 65,536 blocks, 90% pinned, -five fresh pressure pools. Reclaiming one block took a median **2,058.758 us --> 18.217 us** (113x lower call time). Pool construction and hashing were -excluded. Retained Python metadata increased by **5,767,328 bytes** at that -pool size. This is not GPU time or an end-to-end model speedup. - -## Optional SLRU versus LRU under a one-use scan - -One A6000, the same 1.5B FP16 model, TP1, eager, concurrency 1, eight blocks, -protected ratio **0.5** (the API default is 0.8). Four warmup requests establish -hot prefixes, followed by ten measured 544-token requests, eight output -tokens each. One run per configuration; the trace includes six one-use -prefixes. All 14 output arrays and normal-EOS checks match across LRU8, -SLRU8, LRU32 and cache-off controls. - -| Metric | LRU, 8 blocks | SLRU, 8 blocks | -|---|---:|---:| -| Cached tokens | 1,024 | 2,048 | -| Prefill tokens | 4,416 | 3,392 (**23.19% less**) | -| Observed mean TTFT (ms) | 28.26 | 23.91 | - -SLRU8 matches the retained-token count of LRU32 in this trace. It preserves -established hot prefixes during a scan; it does not increase physical KV -capacity. No stable throughput claim follows from this one short run. -Scheduler-only no-reuse and overcapacity-cycle traces have zero hits with -both policies; the tested hotspot-shift trace has no total-work improvement. - -## TP2 chunked Prefill versus unchunked eager - -Two A6000s, Qwen2.5-1.5B FP16, TP2/PP1, eager paged attention, 128 cache blocks, -prefix reuse off, max batch size 2. A 128-token prompt generates 128 tokens; -after its eighth token an 8,192-token prompt generating 16 tokens arrives, -then a second 128-token prompt generating 16 tokens arrives 20 ms later. -A separate warmup precedes measurement. Values are medians of three short -runs per setting; all 160 output IDs match across the nine runs. Component -snapshot: `18beeeec` plus the recorded working-tree implementation. - -| Metric | Unchunked | Chunk512 | Change | Chunk1024 | -|---|---:|---:|---:|---:| -| Active request maximum ITL (ms) | 1,022.94 | 142.74 | **-86.05%** | 239.21 | -| Late short request TTFT (ms) | 1,001.54 | 68.16 | **-93.19%** | 139.44 | -| Active request p95 ITL (ms) | 24.88 | 92.68 | +272.47% | 63.62 | -| Long request TTFT (ms) | 1,012.17 | 1,435.76 | +41.85% | 1,210.33 | -| Finite-window output token/s | 75.27 | 62.83 | -16.53% | 68.06 | - -Chunk512 allowed 15 active Decode steps during the long Prefill, and the late -short request produced its first token before the long Prefill finished. -The gain is bounded worst-case blocking and request progress. More frequent -short pauses replace one long pause, so p95 ITL can worsen while maximum ITL -improves. Chunk1024 reduces maximum pause by 76.61% with a 9.58% output-rate -cost. Chunk size is an application latency/throughput choice. - -Skipping unused intermediate LM-head/sampling/output work was compared -separately on this chunk512 workload, with three alternating runs per mode. -Median cumulative long-Prefill dispatch time changed **1,202.99 -> 1,175.69 ms -(-2.27%)**, while output rate changed **64.19 -> 62.87 token/s (-2.05%)**. -This does not establish a serving-throughput gain. Every Transformer layer, -including Attention, MLP and required collectives, remains fully executed. - -## C500 graphs: same-binary five-mode ablation - -MetaX C500 **50% compute / 32,000 MiB slice**, CPU quota six cores; MACA -3.5.3.20, driver 3.8.30, torch 2.8.0+metax3.5.3.9, Flash Attention -2.6.3+metax3.5.3.9torch2.8. Qwen3-0.6B/4B BF16, TP1/PP1, vendor Flash -Attention, 32 KV blocks, prefix reuse off. All five modes use the same LM -binary built from the pre-integration graph prototype based on `ac89c5c3`. -This isolates execution mode without mixing native/vendor backends. - -Single requests: 2,048 input / 16 output tokens, three measurements after -warmup; TTFT is their mean, ITL is the mean of per-request median intervals. -Mixed windows: a 128-input / 48-output request receives a 2,048-input / -16-output competitor after its eighth output token; two windows per mode. -Window rate is 56 remaining output tokens divided by time from long-request -admission until both finish. It is not sustained serving throughput. - -| Model | Mode | Single TTFT ms | Single ITL ms | Mixed short max ITL ms | Mixed window token/s | -|---|---|---:|---:|---:|---:| -| 0.6B | Unchunked eager | 77.79 | 6.86 | 85.60 | 154.26 | -| 0.6B | Unchunked + Decode graph | 77.34 | 4.26 | 82.53 | 226.68 | -| 0.6B | Chunk512 eager | 97.22 | 6.82 | 34.58 | 145.96 | -| 0.6B | Chunk512 + Decode graph | 96.83 | 4.26 | 31.56 | 209.73 | -| 0.6B | Chunk512 + Prefill/Decode graphs | 91.01 | 4.37 | 30.21 | 212.86 | -| 4B | Unchunked eager | 211.94 | 12.99 | 227.82 | 72.79 | -| 4B | Unchunked + Decode graph | 211.38 | 9.39 | 222.93 | 93.35 | -| 4B | Chunk512 eager | 248.52 | 12.96 | 82.73 | 69.80 | -| 4B | Chunk512 + Decode graph | 247.97 | 9.37 | 77.40 | 88.06 | -| 4B | Chunk512 + Prefill/Decode graphs | 241.37 | 9.37 | 75.52 | 88.93 | - -At chunk512, Decode graphs reduce 4B single-request ITL by **27.64%** and -increase the mixed-window output rate by **26.16%** versus chunk eager. -Adding experimental Prefill graphs then reduces TTFT by **2.66%** for 4B -and **6.01%** for 0.6B. That small increment is not a significance claim. -The 4B full combination versus unchunked eager reduces the competing short -request's maximum pause by **66.85%**, with **22.17%** higher window output -rate; single-request TTFT is still higher (211.94 -> 241.37 ms). - -All 30 single-request outputs match the HF eager reference; all 20 mixed -windows match their long-request reference and short-request eager control. -Actual device-graph launches were counted, not inferred from an enable flag. -Existing Decode graph infrastructure is reused; this PR's contribution is -its safe combination with chunking and experimental fixed-size Prefill graphs. - -Costs: 4B initialization, including weight loading and graph preparation, -was 5.08 s unchunked eager, 6.30 s chunk + Decode graph and 7.05 s with both -graphs. This is not isolated graph capture time. Extra retained graph memory -was not measured. The shared slice, small samples and same-family models -limit generalization. - -## Final integrated performance check and reproduction - -The final A6000 TP2 check used a shorter 2,048-token long prompt, two 128-token -short prompts, chunk512, prefix cache off and 160 total outputs. One window -per mode measured **161.71 token/s eager vs 161.02 token/s Decode graph** -(-0.43%); outputs matched. No throughput improvement is established by this -pair. Do not compare its rates directly with the 8,192-token component test. -PP2 eager has correctness/lifecycle coverage, not a performance comparison. - -The committed [prefix benchmark instructions](../test/llm/README.md), -[SLRU checks](../test/llm/README.slru.md), -[chunk benchmark](chunked-prefill.md) and [graph configuration](chunk-graphs.md) -describe runnable checks and prerequisites. Match model, dtype, pool, -prefix setting, prompt lengths, warmup, output budget and repetition count -before comparing runs. The JSON export allows independent aggregation of -the archived measurements; it is not a portable archive of every historical -build or experiment driver. No GPU experiments were rerun for this report. - -Remaining coverage limits: sustained service load, full graph memory cost, -PP graphs, TP2 Prefill graphs, four-GPU TP2/PP2, remote-KV/chunk combinations, -MoE, quantization, multimodal, and unconnected Ascend/Moore hardware. diff --git a/docs/validation/cache-chunk-graphs.json b/docs/validation/cache-chunk-graphs.json deleted file mode 100644 index c9caafc4d..000000000 --- a/docs/validation/cache-chunk-graphs.json +++ /dev/null @@ -1,278 +0,0 @@ -{ - "date": "2026-09-16", - "cpu_tests": 108, - "nvidia": { - "model": "Qwen2.5-1.5B", - "dtype": "FP16", - "hardware": "2 x A6000 (shared, not exclusive)", - "cases": [ - { - "case": "tp1-pp1-c0-g0-lru-off0", - "passed": true, - "tp": 1, - "pp": 1, - "chunk": 0, - "graph": false, - "policy": "lru", - "prefix_enabled": true, - "kv_span_checks": 6, - "device_graph_launches": 0, - "usable_blocks": 16, - "baseline_tokens_equal": true - }, - { - "case": "tp1-pp1-c300-g0-lru-off0", - "passed": true, - "tp": 1, - "pp": 1, - "chunk": 300, - "graph": false, - "policy": "lru", - "prefix_enabled": true, - "kv_span_checks": 12, - "device_graph_launches": 0, - "usable_blocks": 16, - "baseline_tokens_equal": true - }, - { - "case": "tp1-pp1-c300-g1-lru-off0", - "passed": true, - "tp": 1, - "pp": 1, - "chunk": 300, - "graph": true, - "policy": "lru", - "prefix_enabled": true, - "kv_span_checks": 12, - "device_graph_launches": 14, - "usable_blocks": 16, - "baseline_tokens_equal": true - }, - { - "case": "tp1-pp1-c300-g1-slru-off0", - "passed": true, - "tp": 1, - "pp": 1, - "chunk": 300, - "graph": true, - "policy": "slru", - "prefix_enabled": true, - "kv_span_checks": 12, - "device_graph_launches": 14, - "usable_blocks": 16, - "baseline_tokens_equal": true - }, - { - "case": "tp1-pp2-c0-g0-lru-off0", - "passed": true, - "tp": 1, - "pp": 2, - "chunk": 0, - "graph": false, - "policy": "lru", - "prefix_enabled": true, - "kv_span_checks": 54, - "device_graph_launches": 0, - "usable_blocks": 16, - "baseline_tokens_equal": true - }, - { - "case": "tp1-pp2-c300-g0-lru-off0", - "passed": true, - "tp": 1, - "pp": 2, - "chunk": 300, - "graph": false, - "policy": "lru", - "prefix_enabled": true, - "kv_span_checks": 52, - "device_graph_launches": 0, - "usable_blocks": 16, - "baseline_tokens_equal": true - }, - { - "case": "tp1-pp2-c300-g0-slru-off0", - "passed": true, - "tp": 1, - "pp": 2, - "chunk": 300, - "graph": false, - "policy": "slru", - "prefix_enabled": true, - "kv_span_checks": 52, - "device_graph_launches": 0, - "usable_blocks": 16, - "baseline_tokens_equal": true - }, - { - "case": "tp1-pp2-c300-g0-slru-off1", - "passed": true, - "tp": 1, - "pp": 2, - "chunk": 300, - "graph": false, - "policy": "slru", - "prefix_enabled": false, - "kv_span_checks": 72, - "device_graph_launches": 0, - "usable_blocks": 16, - "baseline_tokens_equal": true - }, - { - "case": "tp2-pp1-c0-g0-lru-off0", - "passed": true, - "tp": 2, - "pp": 1, - "chunk": 0, - "graph": false, - "policy": "lru", - "prefix_enabled": true, - "kv_span_checks": 12, - "device_graph_launches": 0, - "usable_blocks": 16, - "baseline_tokens_equal": true - }, - { - "case": "tp2-pp1-c300-g0-lru-off0", - "passed": true, - "tp": 2, - "pp": 1, - "chunk": 300, - "graph": false, - "policy": "lru", - "prefix_enabled": true, - "kv_span_checks": 24, - "device_graph_launches": 0, - "usable_blocks": 16, - "baseline_tokens_equal": true - }, - { - "case": "tp2-pp1-c300-g1-lru-off0", - "passed": true, - "tp": 2, - "pp": 1, - "chunk": 300, - "graph": true, - "policy": "lru", - "prefix_enabled": true, - "kv_span_checks": 24, - "device_graph_launches": 28, - "usable_blocks": 16, - "baseline_tokens_equal": true - }, - { - "case": "tp2-pp1-c300-g1-slru-off0", - "passed": true, - "tp": 2, - "pp": 1, - "chunk": 300, - "graph": true, - "policy": "slru", - "prefix_enabled": true, - "kv_span_checks": 24, - "device_graph_launches": 28, - "usable_blocks": 16, - "baseline_tokens_equal": true - }, - { - "case": "tp2-pp1-c300-g1-slru-off1", - "passed": true, - "tp": 2, - "pp": 1, - "chunk": 300, - "graph": true, - "policy": "slru", - "prefix_enabled": false, - "kv_span_checks": 44, - "device_graph_launches": 28, - "usable_blocks": 16, - "baseline_tokens_equal": true - } - ] - }, - "metax": { - "models": [ - "Qwen3-0.6B", - "Qwen3-4B" - ], - "dtype": "BF16", - "hardware": "C500 50% compute slice, 32000 MiB", - "cases": [ - { - "case": "c500-Qwen3-0.6B-lru-p0-off0", - "passed": true, - "policy": "lru", - "kv_span_checks": 10, - "device_graph_launches": 14, - "usable_blocks": 16, - "repeat_tokens_equal": true - }, - { - "case": "c500-Qwen3-0.6B-lru-p512-off0", - "passed": true, - "policy": "lru", - "kv_span_checks": 10, - "device_graph_launches": 16, - "usable_blocks": 16, - "repeat_tokens_equal": true - }, - { - "case": "c500-Qwen3-0.6B-slru-p0-off0", - "passed": true, - "policy": "slru", - "kv_span_checks": 10, - "device_graph_launches": 14, - "usable_blocks": 16, - "repeat_tokens_equal": true - }, - { - "case": "c500-Qwen3-0.6B-slru-p512-off0", - "passed": true, - "policy": "slru", - "kv_span_checks": 10, - "device_graph_launches": 16, - "usable_blocks": 16, - "repeat_tokens_equal": true - }, - { - "case": "c500-Qwen3-0.6B-slru-p512-off1", - "passed": true, - "policy": "slru", - "kv_span_checks": 18, - "device_graph_launches": 21, - "usable_blocks": 16, - "repeat_tokens_equal": true - }, - { - "case": "c500-Qwen3-4B-slru-p512-off0", - "passed": true, - "policy": "slru", - "kv_span_checks": 10, - "device_graph_launches": 16, - "usable_blocks": 16, - "repeat_tokens_equal": true - } - ] - }, - "core_adapter_cases": 12, - "tp2_mixed_short_check": [ - { - "graph": false, - "active_itl_median_ms": 6.079213693737984, - "long_ttft_ms": 175.9560825303197, - "window_tokens_s": 161.71175184476675 - }, - { - "graph": true, - "active_itl_median_ms": 5.886984057724476, - "long_ttft_ms": 188.25936783105135, - "window_tokens_s": 161.0190469587565 - } - ], - "limitations": [ - "13+6 lifecycle configurations are not a full model/shape/platform matrix.", - "PP graphs, TP2/PP2, distributed remote KV, MoE and quantization are excluded.", - "Historical near-tied FP16 prefix-shape token mismatch remains documented.", - "One mixed window per mode; no sustained-throughput or significance claim." - ] -} diff --git a/docs/validation/performance-ablation.json b/docs/validation/performance-ablation.json deleted file mode 100644 index 8d6adf933..000000000 --- a/docs/validation/performance-ablation.json +++ /dev/null @@ -1,827 +0,0 @@ -{ - "date": "2026-09-16", - "scope": "Archived component experiments, not a rerun of the integrated PR. Raw source files are identified by relative artifact name and SHA256; exported measurements exclude paths, host identifiers and prompts.", - "change_formula": "100 * (candidate / baseline - 1); negative latency/work changes are reductions", - "lru_hotspot": [ - { - "variant": "baseline", - "repeat": 0, - "source": { - "artifact": "pr1-lru/qwen15b-small/baseline-on-hot-cold-c1-b64-r0.json", - "sha256": "2b25e91460486d32019eda8917c151e61e3915ce206657635a77e2121a42c0dd" - }, - "recorded_lm_commit": "270feb3e14976ca14c3b012df1d1740f939a941e", - "trace_sha256": "6d6ee5f4820bee581714b292c5e5341e44baa4ee461ba01865b906b36b0e3e5f", - "measured_requests": 112, - "cached_tokens": 61440, - "prefill_tokens": 56832, - "mean_ttft_ms": 38.374022564052474, - "output_tokens_s": 120.93562602161356 - }, - { - "variant": "candidate", - "repeat": 0, - "source": { - "artifact": "pr1-lru/qwen15b-small/candidate-on-hot-cold-c1-b64-r0.json", - "sha256": "97767df1f1712d58c25bf0ea75ba03f18b87e151801db58f0f244d017dc3fb75" - }, - "recorded_lm_commit": "4941056847a42c6030a50680d9c22160bc2845de", - "trace_sha256": "6d6ee5f4820bee581714b292c5e5341e44baa4ee461ba01865b906b36b0e3e5f", - "measured_requests": 112, - "cached_tokens": 86016, - "prefill_tokens": 32256, - "mean_ttft_ms": 27.591595234948077, - "output_tokens_s": 121.9828588428591 - }, - { - "variant": "baseline", - "repeat": 1, - "source": { - "artifact": "pr1-lru/qwen15b-small/baseline-on-hot-cold-c1-b64-r1.json", - "sha256": "71e372ff93e72f1ca84668bccdeffdec4512b588b62099d00503cd404d9e9277" - }, - "recorded_lm_commit": "270feb3e14976ca14c3b012df1d1740f939a941e", - "trace_sha256": "6d6ee5f4820bee581714b292c5e5341e44baa4ee461ba01865b906b36b0e3e5f", - "measured_requests": 112, - "cached_tokens": 61440, - "prefill_tokens": 56832, - "mean_ttft_ms": 38.462521450128406, - "output_tokens_s": 124.59826730665682 - }, - { - "variant": "candidate", - "repeat": 1, - "source": { - "artifact": "pr1-lru/qwen15b-small/candidate-on-hot-cold-c1-b64-r1.json", - "sha256": "0d838f4da45cc47d13f7b5fabc537b16e4020291c3cd41835b6cda407598c0ac" - }, - "recorded_lm_commit": "4941056847a42c6030a50680d9c22160bc2845de", - "trace_sha256": "6d6ee5f4820bee581714b292c5e5341e44baa4ee461ba01865b906b36b0e3e5f", - "measured_requests": 112, - "cached_tokens": 86016, - "prefill_tokens": 32256, - "mean_ttft_ms": 27.543096029798367, - "output_tokens_s": 124.99234817379885 - } - ], - "slru_hot_scan": [ - { - "variant": "lru-b8", - "source": { - "artifact": "pr2-slru/model-lru-b8.json", - "sha256": "0a4477e5405c7047e7ea6468614f12255f7980384f9ecf750daa62001636d486" - }, - "recorded_lm_commit": "4941056847a42c6030a50680d9c22160bc2845de", - "trace_sha256": "54196efd5b1d5651237476bb0eec8f4b5f08748892bc2ba9dae4d5573751672f", - "measured_requests": 10, - "cached_tokens": 1024, - "prefill_tokens": 4416, - "mean_ttft_ms": 28.25833586975932, - "output_tokens_s": 101.2304649877099 - }, - { - "variant": "slru-b8", - "source": { - "artifact": "pr2-slru/model-slru-b8.json", - "sha256": "d3e6ae49bbfd28fb8856799982d5191ba7dec37ae048c84566c2979b40ef9b12" - }, - "recorded_lm_commit": "4941056847a42c6030a50680d9c22160bc2845de", - "trace_sha256": "54196efd5b1d5651237476bb0eec8f4b5f08748892bc2ba9dae4d5573751672f", - "measured_requests": 10, - "cached_tokens": 2048, - "prefill_tokens": 3392, - "mean_ttft_ms": 23.908149264752865, - "output_tokens_s": 112.29277362197301 - }, - { - "variant": "lru-b32", - "source": { - "artifact": "pr2-slru/model-lru-b32.json", - "sha256": "f54fb6b4516aadbc7209e5f44b50a4db7474f8ac198f76bde5bf79e3d5d4bda3" - }, - "recorded_lm_commit": "4941056847a42c6030a50680d9c22160bc2845de", - "trace_sha256": "54196efd5b1d5651237476bb0eec8f4b5f08748892bc2ba9dae4d5573751672f", - "measured_requests": 10, - "cached_tokens": 2048, - "prefill_tokens": 3392, - "mean_ttft_ms": 23.94797643646598, - "output_tokens_s": 110.73545655281454 - }, - { - "variant": "slru-b8-off", - "source": { - "artifact": "pr2-slru/model-slru-b8-off.json", - "sha256": "71e2f231f5de80be392803dae78585217b1948db47b3034605a9509d958e40c2" - }, - "recorded_lm_commit": "4941056847a42c6030a50680d9c22160bc2845de", - "trace_sha256": "54196efd5b1d5651237476bb0eec8f4b5f08748892bc2ba9dae4d5573751672f", - "measured_requests": 10, - "cached_tokens": 0, - "prefill_tokens": 5440, - "mean_ttft_ms": 33.01804466173053, - "output_tokens_s": 87.52988821479991 - } - ], - "lru_negative_cycle": [ - { - "variant": "baseline", - "source": { - "artifact": "pr1-lru/qwen7b-full/baseline-on-over-capacity-c1-b64-r0.json", - "sha256": "3d68c0a7cfaf62ce60c7b885e48646552acc3f8f4da63a576b4a3ca233bbbb42" - }, - "recorded_lm_commit": "270feb3e14976ca14c3b012df1d1740f939a941e", - "trace_sha256": "1941d52163a04386b5dd95d4dfe0aa6cf0badde929d128bed5c2d5dc7814d21b", - "measured_requests": 112, - "cached_tokens": 33792, - "prefill_tokens": 84480, - "mean_ttft_ms": 158.39023908899566, - "output_tokens_s": 34.95745680898737 - }, - { - "variant": "candidate", - "source": { - "artifact": "pr1-lru/qwen7b-full/candidate-on-over-capacity-c1-b64-r0.json", - "sha256": "9ecd2e46150d096af7a4e9cbdc81d31849088cd55e4a58825e84da8c2e08b2ad" - }, - "recorded_lm_commit": "4941056847a42c6030a50680d9c22160bc2845de", - "trace_sha256": "1941d52163a04386b5dd95d4dfe0aa6cf0badde929d128bed5c2d5dc7814d21b", - "measured_requests": 112, - "cached_tokens": 0, - "prefill_tokens": 118272, - "mean_ttft_ms": 211.100796786403, - "output_tokens_s": 33.160089972732806 - } - ], - "cpu_reclaim": [ - { - "variant": "baseline", - "source": { - "artifact": "pr1-lru/baseline-metadata-65536.json", - "sha256": "1fe64372cae43db251992002ba4caf455635821ca11f2f80f99db8fc67d627dd" - }, - "python": "3.11.15", - "pool": 65536, - "pinned_ratio": 0.9, - "median_ns": 2058758, - "p95_ns": 2541288.6, - "valid_samples": 5, - "successful_samples": 5, - "requested_blocks": 1, - "actual_reclaimed_blocks": [ - 1, - 1, - 1, - 1, - 1 - ], - "capacity_failure": false, - "retained_python_bytes": 33127432 - }, - { - "variant": "candidate", - "source": { - "artifact": "pr1-lru/candidate-metadata-65536.json", - "sha256": "e2ce9f7ed4f09220b3a784401b9125350be68fa52d65c87d1758af3b0f432880" - }, - "python": "3.11.15", - "pool": 65536, - "pinned_ratio": 0.9, - "median_ns": 18217, - "p95_ns": 92578.19999999998, - "valid_samples": 5, - "successful_samples": 5, - "requested_blocks": 1, - "actual_reclaimed_blocks": [ - 1, - 1, - 1, - 1, - 1 - ], - "capacity_failure": false, - "retained_python_bytes": 38894760 - } - ], - "tp2_chunk": [ - { - "source": { - "artifact": "pr4-tp2-chunk/mixed-tp2-c0-r0.json", - "sha256": "864b1bc39e01c8d77220fd799897ce3608ba7aa64af1ecf50ea76965c8eae0ff" - }, - "recorded_lm_commit": "18beeeecc7e136b31b31600972008f22ab359f84", - "chunk": 0, - "repeat": 0, - "active_max_itl_ms": 1044.0825372934341, - "active_p95_itl_ms": 25.46475175768137, - "long_ttft_ms": 1027.7268989011645, - "late_ttft_ms": 1018.7378702685237, - "output_tokens_s": 69.65358550815387 - }, - { - "source": { - "artifact": "pr4-tp2-chunk/mixed-tp2-c0-r1.json", - "sha256": "f511108e6bc522a31896b0e9be5856c079029b9e5579f9d249c69535440bf053" - }, - "recorded_lm_commit": "18beeeecc7e136b31b31600972008f22ab359f84", - "chunk": 0, - "repeat": 1, - "active_max_itl_ms": 1022.2546840086579, - "active_p95_itl_ms": 24.196366779506207, - "long_ttft_ms": 1011.832257732749, - "late_ttft_ms": 1001.3264119625092, - "output_tokens_s": 76.47256581163282 - }, - { - "source": { - "artifact": "pr4-tp2-chunk/mixed-tp2-c0-r2.json", - "sha256": "3304092b57daab180ac23f337459fe619e3832328024551fb026ce1d5d72de56" - }, - "recorded_lm_commit": "18beeeecc7e136b31b31600972008f22ab359f84", - "chunk": 0, - "repeat": 2, - "active_max_itl_ms": 1022.9357611387968, - "active_p95_itl_ms": 24.883517064154148, - "long_ttft_ms": 1012.1732363477349, - "late_ttft_ms": 1001.5435870736837, - "output_tokens_s": 75.27313935874217 - }, - { - "source": { - "artifact": "pr4-tp2-chunk/mixed-tp2-c512-r0.json", - "sha256": "d93a49443d07ecac4a54ec801edc0dd3190b529105919838d3a6733bac524b5e" - }, - "recorded_lm_commit": "18beeeecc7e136b31b31600972008f22ab359f84", - "chunk": 512, - "repeat": 0, - "active_max_itl_ms": 142.74343475699425, - "active_p95_itl_ms": 92.36581716686487, - "long_ttft_ms": 1426.2459091842175, - "late_ttft_ms": 68.15813109278679, - "output_tokens_s": 62.1401633599568 - }, - { - "source": { - "artifact": "pr4-tp2-chunk/mixed-tp2-c512-r1.json", - "sha256": "501a6e930e74f1e8351598ef47f51ff96ee8d33c408e6741522c0532c999a856" - }, - "recorded_lm_commit": "18beeeecc7e136b31b31600972008f22ab359f84", - "chunk": 512, - "repeat": 1, - "active_max_itl_ms": 142.27045513689518, - "active_p95_itl_ms": 95.56909650564194, - "long_ttft_ms": 1483.4352610632777, - "late_ttft_ms": 84.46694444864988, - "output_tokens_s": 62.83360726513769 - }, - { - "source": { - "artifact": "pr4-tp2-chunk/mixed-tp2-c512-r2.json", - "sha256": "0f07207b283579ab311b35c4d16b812608dd1307e554b9888e6e9666f9a8cd94" - }, - "recorded_lm_commit": "18beeeecc7e136b31b31600972008f22ab359f84", - "chunk": 512, - "repeat": 2, - "active_max_itl_ms": 143.38157419115305, - "active_p95_itl_ms": 92.68482308834791, - "long_ttft_ms": 1435.7550907880068, - "late_ttft_ms": 66.94784760475159, - "output_tokens_s": 63.186604867391715 - }, - { - "source": { - "artifact": "pr4-tp2-chunk/mixed-tp2-c1024-r0.json", - "sha256": "d498ca2576dae06e1da298515fe7c1821f1a2aedc1eb52128267b93e839cc83a" - }, - "recorded_lm_commit": "18beeeecc7e136b31b31600972008f22ab359f84", - "chunk": 1024, - "repeat": 0, - "active_max_itl_ms": 239.21358492225409, - "active_p95_itl_ms": 66.04252103716135, - "long_ttft_ms": 1214.295919984579, - "late_ttft_ms": 141.81798696517944, - "output_tokens_s": 66.38686075560109 - }, - { - "source": { - "artifact": "pr4-tp2-chunk/mixed-tp2-c1024-r1.json", - "sha256": "30627eb79a4291d4c5abea2a048e8c22632c79678fd6542dfc49336571a5bc6c" - }, - "recorded_lm_commit": "18beeeecc7e136b31b31600972008f22ab359f84", - "chunk": 1024, - "repeat": 1, - "active_max_itl_ms": 238.32283914089203, - "active_p95_itl_ms": 63.6207340285182, - "long_ttft_ms": 1210.3307088837028, - "late_ttft_ms": 139.43834137171507, - "output_tokens_s": 68.06129671777143 - }, - { - "source": { - "artifact": "pr4-tp2-chunk/mixed-tp2-c1024-r2.json", - "sha256": "51bf30d05c42e47a84a0ae27060e92c4340bacdd45144b73eea2aa16c2e36178" - }, - "recorded_lm_commit": "18beeeecc7e136b31b31600972008f22ab359f84", - "chunk": 1024, - "repeat": 2, - "active_max_itl_ms": 239.33370877057314, - "active_p95_itl_ms": 60.559434816241264, - "long_ttft_ms": 1202.8568340465426, - "late_ttft_ms": 134.3604512512684, - "output_tokens_s": 72.05348208166319 - } - ], - "intermediate_output": [ - { - "source": { - "artifact": "pr5-chunk-output/mixed-legacy-r0.json", - "sha256": "21bfead47178247f478abf1daa4429d4c4912486d85282be45ae647a8a03b2f5" - }, - "variant": "legacy", - "repeat": 0, - "long_prefill_execution_ms": 1208.6142068728805, - "output_tokens_s": 61.04694102935545 - }, - { - "source": { - "artifact": "pr5-chunk-output/mixed-legacy-r1.json", - "sha256": "23a3361e4dd4f0be3393f278353832a69d170d05b61fb137c60e4a1aee361d3f" - }, - "variant": "legacy", - "repeat": 1, - "long_prefill_execution_ms": 1182.2610516101122, - "output_tokens_s": 68.13992559990778 - }, - { - "source": { - "artifact": "pr5-chunk-output/mixed-legacy-r2.json", - "sha256": "55841d060421d68b2323d9782401d307aad86741e568e7d5bd079d6acc1fe563" - }, - "variant": "legacy", - "repeat": 2, - "long_prefill_execution_ms": 1202.985405921936, - "output_tokens_s": 64.1864439253074 - }, - { - "source": { - "artifact": "pr5-chunk-output/mixed-optimized-r0.json", - "sha256": "1dd159cddc3a9ec1b779ca9645709dacdc4ecf23c043bdc3b6992bcc4e556b9f" - }, - "variant": "optimized", - "repeat": 0, - "long_prefill_execution_ms": 1192.3475973308086, - "output_tokens_s": 61.16729167321039 - }, - { - "source": { - "artifact": "pr5-chunk-output/mixed-optimized-r1.json", - "sha256": "d33e337c90cb64acf2b6a205b3ad470a65c98b4dd07b57737ccc090057fbe67c" - }, - "variant": "optimized", - "repeat": 1, - "long_prefill_execution_ms": 1174.908585846424, - "output_tokens_s": 62.87148479791548 - }, - { - "source": { - "artifact": "pr5-chunk-output/mixed-optimized-r2.json", - "sha256": "c8a8c9213855f7fcc5b1badae8171280a29c4c4a21c34b831b0bb186c5eb6b7e" - }, - "variant": "optimized", - "repeat": 2, - "long_prefill_execution_ms": 1175.6912898272276, - "output_tokens_s": 67.19424223783167 - } - ], - "c500_graphs": [ - { - "model": "Qwen3-0.6B", - "mode": "c0-g0", - "source": { - "artifact": "c500-graph-ablation/Qwen3-0.6B-c0-g0.json", - "sha256": "9d73adf9bcde012ba432824c909144142756b56ce2096fb3e3dd41c59740bc08" - }, - "lm_binary_sha256": "02d3417926b887bdfdf403f1e68caeefd7ac9f83ece1725d4e31f05e9611f83a", - "single_requests": [ - { - "ttft_ms": 79.8242810415104, - "median_itl_ms": 6.821062066592276, - "finite_request_output_tokens_s": 87.87185273940202 - }, - { - "ttft_ms": 76.80631999392062, - "median_itl_ms": 6.865010946057737, - "finite_request_output_tokens_s": 88.9601489479711 - }, - { - "ttft_ms": 76.74454106017947, - "median_itl_ms": 6.9005509139969945, - "finite_request_output_tokens_s": 88.73658090674604 - } - ], - "mixed_windows": [ - { - "short_max_itl_ms": 86.75120607949793, - "long_ttft_ms": 76.87031396199018, - "window_seconds": 0.36403864703606814, - "window_output_tokens": 56, - "finite_window_output_tokens_s": 153.82982124546695 - }, - { - "short_max_itl_ms": 84.44142004009336, - "long_ttft_ms": 76.91469497513026, - "window_seconds": 0.36203506402671337, - "window_output_tokens": 56, - "finite_window_output_tokens_s": 154.68114987853207 - } - ], - "engine_load_seconds": 1.5478712120093405 - }, - { - "model": "Qwen3-0.6B", - "mode": "c0-g1", - "source": { - "artifact": "c500-graph-ablation/Qwen3-0.6B-c0-g1.json", - "sha256": "aec41b1dc9f7533c278bd68231a168f3c890c554eeebf9323fe771086914747d" - }, - "lm_binary_sha256": "02d3417926b887bdfdf403f1e68caeefd7ac9f83ece1725d4e31f05e9611f83a", - "single_requests": [ - { - "ttft_ms": 79.10229603294283, - "median_itl_ms": 4.263582988642156, - "finite_request_output_tokens_s": 111.50987510949798 - }, - { - "ttft_ms": 76.50900701992214, - "median_itl_ms": 4.26289695315063, - "finite_request_output_tokens_s": 113.61004451774552 - }, - { - "ttft_ms": 76.42301602754742, - "median_itl_ms": 4.261289956048131, - "finite_request_output_tokens_s": 113.86783403692492 - } - ], - "mixed_windows": [ - { - "short_max_itl_ms": 83.66876107174903, - "long_ttft_ms": 76.96511095855385, - "window_seconds": 0.24804238602519035, - "window_output_tokens": 56, - "finite_window_output_tokens_s": 225.76786531279708 - }, - { - "short_max_itl_ms": 81.39872900210321, - "long_ttft_ms": 76.5976799884811, - "window_seconds": 0.24604712694417685, - "window_output_tokens": 56, - "finite_window_output_tokens_s": 227.5986746746499 - } - ], - "engine_load_seconds": 2.518482425948605 - }, - { - "model": "Qwen3-0.6B", - "mode": "c512-g0", - "source": { - "artifact": "c500-graph-ablation/Qwen3-0.6B-c512-g0.json", - "sha256": "07faf60d41f7ab82fb99bdf965e663df41388f03dad5e9ab0d957a5781eed8bf" - }, - "lm_binary_sha256": "02d3417926b887bdfdf403f1e68caeefd7ac9f83ece1725d4e31f05e9611f83a", - "single_requests": [ - { - "ttft_ms": 98.0809589382261, - "median_itl_ms": 6.7741479724645615, - "finite_request_output_tokens_s": 80.02744861771578 - }, - { - "ttft_ms": 96.7681430047378, - "median_itl_ms": 6.838207016699016, - "finite_request_output_tokens_s": 80.27431336036632 - }, - { - "ttft_ms": 96.8068620422855, - "median_itl_ms": 6.833213032223284, - "finite_request_output_tokens_s": 80.15923549949929 - } - ], - "mixed_windows": [ - { - "short_max_itl_ms": 35.50305194221437, - "long_ttft_ms": 117.88052599877119, - "window_seconds": 0.383247634046711, - "window_output_tokens": 56, - "finite_window_output_tokens_s": 146.11962351520899 - }, - { - "short_max_itl_ms": 33.659768057987094, - "long_ttft_ms": 118.15998400561512, - "window_seconds": 0.38410016405396163, - "window_output_tokens": 56, - "finite_window_output_tokens_s": 145.79530351914312 - } - ], - "engine_load_seconds": 1.4546657629543915 - }, - { - "model": "Qwen3-0.6B", - "mode": "c512-g1", - "source": { - "artifact": "c500-graph-ablation/Qwen3-0.6B-c512-g1.json", - "sha256": "ca7d341e809913f983a829164ba68b0a10ba86fe212762cf0dc4820e03376ea3" - }, - "lm_binary_sha256": "02d3417926b887bdfdf403f1e68caeefd7ac9f83ece1725d4e31f05e9611f83a", - "single_requests": [ - { - "ttft_ms": 97.38001390360296, - "median_itl_ms": 4.262696020305157, - "finite_request_output_tokens_s": 99.01659502888025 - }, - { - "ttft_ms": 96.51387494523078, - "median_itl_ms": 4.261964000761509, - "finite_request_output_tokens_s": 99.57132434230608 - }, - { - "ttft_ms": 96.5981719782576, - "median_itl_ms": 4.260887042619288, - "finite_request_output_tokens_s": 99.59523372278204 - } - ], - "mixed_windows": [ - { - "short_max_itl_ms": 32.371642999351025, - "long_ttft_ms": 108.80499798804522, - "window_seconds": 0.2676137809175998, - "window_output_tokens": 56, - "finite_window_output_tokens_s": 209.25678718033882 - }, - { - "short_max_itl_ms": 30.749029014259577, - "long_ttft_ms": 109.01637095957994, - "window_seconds": 0.2664180740248412, - "window_output_tokens": 56, - "finite_window_output_tokens_s": 210.19594937383445 - } - ], - "engine_load_seconds": 2.519157030968927 - }, - { - "model": "Qwen3-0.6B", - "mode": "c512-g1-p512", - "source": { - "artifact": "c500-graph-ablation/Qwen3-0.6B-c512-g1-p512.json", - "sha256": "fd43d08532470e6bb6fc845eb27a895f3e05cff8bb25ad11c3362fc8ad4b33a6" - }, - "lm_binary_sha256": "02d3417926b887bdfdf403f1e68caeefd7ac9f83ece1725d4e31f05e9611f83a", - "single_requests": [ - { - "ttft_ms": 91.29042597487569, - "median_itl_ms": 4.3628859566524625, - "finite_request_output_tokens_s": 101.62564964149574 - }, - { - "ttft_ms": 90.84178297780454, - "median_itl_ms": 4.317894927226007, - "finite_request_output_tokens_s": 102.70470335324092 - }, - { - "ttft_ms": 90.89809097349644, - "median_itl_ms": 4.428689950145781, - "finite_request_output_tokens_s": 101.7881771755744 - } - ], - "mixed_windows": [ - { - "short_max_itl_ms": 31.406677095219493, - "long_ttft_ms": 104.01316499337554, - "window_seconds": 0.2646652889670804, - "window_output_tokens": 56, - "finite_window_output_tokens_s": 211.58800316638948 - }, - { - "short_max_itl_ms": 29.01442104484886, - "long_ttft_ms": 102.9531330568716, - "window_seconds": 0.26151709409896284, - "window_output_tokens": 56, - "finite_window_output_tokens_s": 214.13514169291196 - } - ], - "engine_load_seconds": 2.7417224689852446 - }, - { - "model": "Qwen3-4B", - "mode": "c0-g0", - "source": { - "artifact": "c500-graph-ablation/Qwen3-4B-c0-g0.json", - "sha256": "0ed165dbf1b64a0bd93bb952375551dc8dc3a566a9cb6fe6b7ec46b55ee9e5d1" - }, - "lm_binary_sha256": "02d3417926b887bdfdf403f1e68caeefd7ac9f83ece1725d4e31f05e9611f83a", - "single_requests": [ - { - "ttft_ms": 213.06744602043182, - "median_itl_ms": 13.09685199521482, - "finite_request_output_tokens_s": 38.7228335294421 - }, - { - "ttft_ms": 211.432253010571, - "median_itl_ms": 12.94599601533264, - "finite_request_output_tokens_s": 39.0370000559599 - }, - { - "ttft_ms": 211.31556096952409, - "median_itl_ms": 12.935793958604336, - "finite_request_output_tokens_s": 39.39240863743308 - } - ], - "mixed_windows": [ - { - "short_max_itl_ms": 228.96672098431736, - "long_ttft_ms": 211.594982072711, - "window_seconds": 0.7683330880245194, - "window_output_tokens": 56, - "finite_window_output_tokens_s": 72.8850558082602 - }, - { - "short_max_itl_ms": 226.67458106298, - "long_ttft_ms": 211.30752097815275, - "window_seconds": 0.7702595330774784, - "window_output_tokens": 56, - "finite_window_output_tokens_s": 72.70276782717482 - } - ], - "engine_load_seconds": 5.076910855947062 - }, - { - "model": "Qwen3-4B", - "mode": "c0-g1", - "source": { - "artifact": "c500-graph-ablation/Qwen3-4B-c0-g1.json", - "sha256": "de7230bf983a13d721f46f56ef63818e4b1580f1bf0820dba2ccd9f10277f386" - }, - "lm_binary_sha256": "02d3417926b887bdfdf403f1e68caeefd7ac9f83ece1725d4e31f05e9611f83a", - "single_requests": [ - { - "ttft_ms": 211.78095799405128, - "median_itl_ms": 9.393629035912454, - "finite_request_output_tokens_s": 45.267016171884556 - }, - { - "ttft_ms": 211.01937198545784, - "median_itl_ms": 9.376900037750602, - "finite_request_output_tokens_s": 45.432711288765645 - }, - { - "ttft_ms": 211.33165108039975, - "median_itl_ms": 9.386084973812103, - "finite_request_output_tokens_s": 45.36420405624591 - } - ], - "mixed_windows": [ - { - "short_max_itl_ms": 223.71347295120358, - "long_ttft_ms": 210.9993400517851, - "window_seconds": 0.5988905160920694, - "window_output_tokens": 56, - "finite_window_output_tokens_s": 93.50623944659516 - }, - { - "short_max_itl_ms": 222.14943391736597, - "long_ttft_ms": 211.15404297597706, - "window_seconds": 0.6009402009658515, - "window_output_tokens": 56, - "finite_window_output_tokens_s": 93.18730867063793 - } - ], - "engine_load_seconds": 6.295128494966775 - }, - { - "model": "Qwen3-4B", - "mode": "c512-g0", - "source": { - "artifact": "c500-graph-ablation/Qwen3-4B-c512-g0.json", - "sha256": "12310d3015478ce906281e56aa056ec5f8755bfd287c788992c08931ab8c41a1" - }, - "lm_binary_sha256": "02d3417926b887bdfdf403f1e68caeefd7ac9f83ece1725d4e31f05e9611f83a", - "single_requests": [ - { - "ttft_ms": 249.27168106660247, - "median_itl_ms": 12.888173921965063, - "finite_request_output_tokens_s": 36.1055871054971 - }, - { - "ttft_ms": 247.82653898000717, - "median_itl_ms": 12.960067950189114, - "finite_request_output_tokens_s": 36.18155769693437 - }, - { - "ttft_ms": 248.46953700762242, - "median_itl_ms": 13.018788886256516, - "finite_request_output_tokens_s": 35.88350552210521 - } - ], - "mixed_windows": [ - { - "short_max_itl_ms": 84.426648914814, - "long_ttft_ms": 287.96457406133413, - "window_seconds": 0.8041914129862562, - "window_output_tokens": 56, - "finite_window_output_tokens_s": 69.63516284270129 - }, - { - "short_max_itl_ms": 81.02831908036023, - "long_ttft_ms": 287.3378910589963, - "window_seconds": 0.8004108859458938, - "window_output_tokens": 56, - "finite_window_output_tokens_s": 69.96406593573677 - } - ], - "engine_load_seconds": 4.833688564947806 - }, - { - "model": "Qwen3-4B", - "mode": "c512-g1", - "source": { - "artifact": "c500-graph-ablation/Qwen3-4B-c512-g1.json", - "sha256": "f82b6838a99ffd95559a2280bb743041472d392c8c842589ff580a20d68f0284" - }, - "lm_binary_sha256": "02d3417926b887bdfdf403f1e68caeefd7ac9f83ece1725d4e31f05e9611f83a", - "single_requests": [ - { - "ttft_ms": 249.00059693027288, - "median_itl_ms": 9.373518056236207, - "finite_request_output_tokens_s": 41.01255560429826 - }, - { - "ttft_ms": 247.4075510399416, - "median_itl_ms": 9.372230037115514, - "finite_request_output_tokens_s": 41.16757828316191 - }, - { - "ttft_ms": 247.5125939818099, - "median_itl_ms": 9.376770001836121, - "finite_request_output_tokens_s": 41.14866721466945 - } - ], - "mixed_windows": [ - { - "short_max_itl_ms": 78.30318505875766, - "long_ttft_ms": 275.2043070504442, - "window_seconds": 0.6360797800589353, - "window_output_tokens": 56, - "finite_window_output_tokens_s": 88.03927078897459 - }, - { - "short_max_itl_ms": 76.49294100701809, - "long_ttft_ms": 275.9519129758701, - "window_seconds": 0.6357877160189673, - "window_output_tokens": 56, - "finite_window_output_tokens_s": 88.07971369854111 - } - ], - "engine_load_seconds": 6.3010460620280355 - }, - { - "model": "Qwen3-4B", - "mode": "c512-g1-p512", - "source": { - "artifact": "c500-graph-ablation/Qwen3-4B-c512-g1-p512.json", - "sha256": "376d1b0d09837c85b6f6a67f9bec3b9df921c978b84f01d4fa1ddb220bd9abcf" - }, - "lm_binary_sha256": "02d3417926b887bdfdf403f1e68caeefd7ac9f83ece1725d4e31f05e9611f83a", - "single_requests": [ - { - "ttft_ms": 241.4711710298434, - "median_itl_ms": 9.368281927891076, - "finite_request_output_tokens_s": 41.87622314282987 - }, - { - "ttft_ms": 241.46708799526095, - "median_itl_ms": 9.369399980641901, - "finite_request_output_tokens_s": 41.87662351918585 - }, - { - "ttft_ms": 241.18255090434104, - "median_itl_ms": 9.37285099644214, - "finite_request_output_tokens_s": 41.891006545143796 - } - ], - "mixed_windows": [ - { - "short_max_itl_ms": 76.42750407103449, - "long_ttft_ms": 268.4811579529196, - "window_seconds": 0.6281527009559795, - "window_output_tokens": 56, - "finite_window_output_tokens_s": 89.15029723628369 - }, - { - "short_max_itl_ms": 74.60299599915743, - "long_ttft_ms": 268.79685290623456, - "window_seconds": 0.6312453869031742, - "window_output_tokens": 56, - "finite_window_output_tokens_s": 88.7135195945436 - } - ], - "engine_load_seconds": 7.04994626599364 - } - ] -} diff --git a/docs/validation/test-results.png b/docs/validation/test-results.png deleted file mode 100644 index b9cc7f627fc179521c553c7faf2d2707cd12442f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 232605 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

  • hbZB8$*w1i&)MHWsp!8n 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

    g+p8^vRnvtJ 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[(@iE ziUm1~mquG;9kzXCtYOZH)NbN6F(51bQ)>c&8_eWj} z`Yhu5jn;f6(t#i2lV0TvS8`!0pD8&j+!BYJ6Khf`B@T{HF+{yw>};ss=*s*Fi(nh> ziExf2bb@FzOX#y_t!v z5gpd7-R=$6x)DuTcuIkeoEq$OwqmCztH&=}ZW0#2xTm&Y*yeE{eu?qKYS@zpVJa`6 z4ppx|{Ua}Dw!}g7L-?Bomrtd(1y?nGc>dpxPjFL1`hT}6eG>2bD@EIZBx~Ren0;!o z2+S+dfjMN+K5WP0HdCvS7tFZRq@wXd_wrMRX~m4DZF@=NPOkuD*^=%AmCinn!-VlL zHGurjQ&}^>4o7J*t=KxW84$w0B$;=ACG9+TT59XJT|r2Rt(VD?oztcGdK@H2z+154 z`K)7}9l(zItth!ugU6W#mPY0i-D66vyYkTvvq%W+BV&)L>?&UjtkuBP%Ot)$rP8Je z@v$=t31hK*(N5WVDWDSdsT8>L><{wZr>2CV$~6bH69n(kY<1NANY)uQoAEZC+enrDb z=nob9o7EH_fS8<>0N*Cx1u^bAiUYp zk?cmnn_JW?2lYvy+=FUQve`DC&7bfyri%FpZbF$+}CKATsWOn@(Vv2{%n_itnGRXuco(%z5@z`nM)_+(O z0cuVEs1N)16=?XQnyQPGwTqEr6||xZ^T=}x)e@&<3-F+_WQbH2cCp(GAl@W6HwRfa zi(Z)liB4vVJ{$Y(Y$I(pJ}SN-9Ry1mTMZb2U^wo4ULvOcpV0!>bjn{LsPsG4=Eh)F z+IX^^rK|{OpAq!Bs)7w__aC{Fa_CKiV7{YFj8WivRDP{X+F$GN@H2_d-I|F5jUo?( z1SJbhE$Y;ZLp506EjW@o_42M}dk9D*Z0Rh!FE}fu>K^RD_$>f<4bYsdUR{xnrji`9 zEMVX*{_tAtlBv4s%mSxwQ8FWJ$hT0h;I5JJwv(=AyiZ#%)sZ&UEU2*cSt1k7j`m{Y z)51n8J!vuA4oj!3$#YopeqWlVEVR|RHVOyUhJb5fz{|p7`ck`g_`Ab>HOy`7CTi!YJX2!&_W;YfkNF`%)17uh!BxmQmyDu_2qECC)`EbCA%v4Esx z6W)W>d{L|}G?s<7Na(9L)|@0O9n9O*1x(&(#7QU;i5lPNV!GL21vS|LF)5IUXICuX z7BjkJKCJFt#Q#uA1H`iN^;(GhmWMHy2SCt%-KAwSV zz~q8|^)5`Tr$OqFz@kLZF;DKhc^^Pl-L<=^X_Y>jFE9{b6rF0^V`;Z}a@nny3HO&$ zU#hh}J$8&dvxPx0-z54e>l~$+MiVV}$#wP^rzX=4SCQ?a93$ti}(FTx7p^8jdbRN%a+*qMgH#GU!;>Bjj zznL@0cbtW{uoVx#QHsz%O8dzi0v?d*=?053D(kLDfctG4Kk0N(^qkqRK7rmF zZJ5>c2i@K-M>AJT(|S&&YhMT8MK3;G9kF5g$&9Lk&60>~(koX_!@0XWx zC>Me>HXJ=N4N>D8T=^jJHSJ*nqudlGNK6BQ|CEa(EXA=rC;xOxJs#X_cQ0E_) z1CZGaFxH=b7RCs_O_km}DgUC)liBV*pSblyVouq?*_^4qaoCvw;A1{vUO&L8uQ)$_ zZ7dJn@Nmie84bF0OXqlh#3c&*qMz=jm%{Eod2$q;t0(M(MRkDOz3FS2_q(d93)Y_v z`DEJp*6eC%4}AgC=(uSZcJnI_`hVqWzmpYXh8|A`W;G#7>w2Ce^`XK@9=+i=pfm^ft<5Q z926#Z>IkjdT>hJ2Ikp$ zy@Lk8@`xD>BkUp-U;#RgDZP1D!@K=8o53oF0sTzi{o^Z$Qo;q@*$zp&h&hVVl3*@E z2-QOA?q^=jW|xz+)@O%jjheK9p8h_$%Z zsz@?^oC2ku6yJ=$%qm@)kG7xPNjt%Yz-UOj+(%zS=owRsD) z7&sZ$pqpyuAb)OOmt#7=t1SOx;eq zFPStvU0^127$>a??}P|o1y&)(j;jqUW{=-yh$U4iz1dXIC?ibus{(0I5zGvz1>KY_ zKi9h7B!gus{O+{E&r+IO3COyVyrO+Z;0?2ydK-$5^;42;|L%ehGBiE3CAD zF&;@&cwkt;5eHEi4g9m5Zy^0qLehjRjrB8+!IY%bj%zKi8%T~oy1BUPRjo~V06{8d z8ECV@U8}V%hIvg#H~*%%4);$a{#VcFCDhzPB)a_%7XU zhxz2Wcc#v0KH@;x+A8`1fytq(+Qd9NI`gnf4UX;)fY|0=Aj;?u#Il7>{=ui-#!OBd zV)S0~=}bKd z;D_7$@aud+L%aUq^jbR?lS6H6xmEej**O%8+HFDMIMK=W+e?9xQjlccZ!oil_#tl?rPEA; z_VV__t|ycRs-$}J{TFF`Mq+8k%C{kCwh%FYL^ME}G(j0IFg#va|DGxeoM7k!Zq4t% zd;1>#zia2Kj($hxXc3Zmcfhs(_wN1cbbNR3lOl`WMEI|{@UKy%iDj!OENkr_eXKdIlnq2!Y$J^z7NZL9F2mzUL|k0X zE<>-15KweUWG45AE5Q@X6Jrhzds#Inwcgy^K0kIo-7bSZny=f#x!){9{z>Ix=v zlZQ*HelM#CNox(8g25^iL&KxB@s@9U$1umz^nH%iqTt9Wcoh)f;1tpYKerUhl$;gb zGW-VfHz#+y4#~qsZx)h2{_^>T*tef~lyecfqV-!Nh#V^Jej0Cu zFOBDU_qQ|QlrW}UEO1)YJ$1r;y$7Y@&fO$dW`AH*Y|Lk?$PXG#oa5a8tc>x-#X05) zk{_QzBJv-w$@RFLTP}rTkL<9sDZb^PXDrqQ$S{~b!WCAwfk>Rytvt_>ya5)EZ_jq; z*X5d!ug-4xt4%tB*_^5lWSw6n(;A&ta>B8`%O%5h4qhg&K0Lu4Ez!yPHc|cp*`F}1 ztm?~&t@h1W53I8202=}IiE^1-fll1eFYf}n56^cGw%24*Q?xkpC?ccVqhHbFL7kU( zr;AjEYULShtjY@xGn)l+#b1^g^N|KKxxLx!kDntv2OHd<%F{WRv?Ic1c)snQboOnQ zjI&&9?0T0%#G?#E5@ps&#_)o?A}u$FNU6nR$UE}BoiSJWz1kUtxX%?uBb24r*(M^4 zVGTA;M&NOkH2G8Dlk#kslZkc)U4qO~9&oi;trOrSOGLN%Fa^|G-@4BB)e|JnG4#Uy ziSnTjnWpe`lTtfe^aJZC_8FHMmX&D~5g{`>J=ol@NKY2+KdQ$=h0%uMMW54&CDU}S zt0Jh~TjPJG5lP>}9`KNpsRWxqnVwm^IGJQAMD~6U&4JSXR`+)kiJ25>sFl_0VY|01 zU;X-UOr^wz##VTlcDR>m41Mu}^YWT)-tqF*9pQ_6T`|$IWKsPJtUKmon|Wx;@tM4f z1=p1G?OH3sm-JRv(LU@)1Z-C9#h+vW(EEPx#udLi@fl;W2LOeamnuhf{rY$|kLd&A zpcG|g1k&eA5Od*y1Ka@2EDu;wA@N05c8!5Mp)S|K0jtsYZKy6ct2j6MxEw(eFqBI zrpP>V`Rgl#sehZp8N156{1KK76v+QPP!K|Lvn&1V7e}1{33z^XjmvxDYFyVtu9L$| zTU+YIC(=pu7Ue_VuKQ;S)E7)9ma#xc2i%8&`ER9915-f1CXO`FlbCdgD z1BU8Y;NbL#0<`Mr_~S!lj1C!_uS+MvbqC(+;=0@|C-SuT-t6Nxe05-Mh>!Ib%ocYSS(2VM}7D8rIorE7Ze1A%g@<03I zD|~>8Y1oI^aj=EHJ!s(#uh*EcrDwjC(4ZP`=|{1dEG8KQ?j>B&h8>+=cKEcI zb`2Z@`<}D;>lTLH>*(8Oc~C>Iplgm_>|*P}1#8J>k}E53J7!xBAv)yG#NtWKYB=LK z>?CA3q!^|E!I?4)(3qLQc+wOzI;cz)wLn-(vf&Gy=mL#&ID=0Qm-jyW9*scofsnbA zKn=o6CZnoG)%bP>CCmA*Q+gWnWF>;YFgH=*%B}iX22C{pL6+SM)r2A)Z-2sJ-S6bbz?7^?T(7tL9`pVdQ74q*kE!ri2ug>1yz9}?IV5fP}YRpc(d)8GB zhucf8W~v1Rwqouy1Tzgl6y%%I{XmTr$gx3gM|rL&clWSG3wC`wc*Cbe^jn!TSy}Of zl6buy{c>=rrhQSZC&Y4SB!RI#k2$q`{L(f(cVZoe0j7aoXC~8Z(qVBe`ulFR5r1be zIAC`u+A@Yr)a7HE(RSD|LL@DTUTvWg6;Qeja|omcG{YNS&A$dLYle8hMPVeN*&Ot# z3J`Uu_#@!Dkr#{@k_(YRxax95p*)fDyCghs$npt@71Y1z3wSwPZ-C+Oq%>^YfUR zWgwv$xpHlywU4JDv@#u>sX{Fk_Cfw>OLQIoa2pNS3Sg+pa`8FS?)b_B{c3M*9bI*a zuN?->h~w6UB%kmAlJUODk&VpYwqCc^d(dT!B@StHx~F5s+CuNT)VZ9?stDhf{)|qY$ek^mmxr=cAIj!A8K{f|LK#^2dEX{jf0ujw%z5fK{fI!iA}r{ zZj7IvE>!BL{qtPS_&jFkpHRb)J>dmn#=ppH*YT+jfzoPfm13fqaPq11KpDRGXSQ?o zPHpC&_+$L14fs!Yr;SMMYGEg{$dFa$I={Q}v)5A{EY!Y3$_G00KZ(e~Hs*)yS!k55 zJ*t-WzkW9B-g`RD^6wrsU{G8k%p)LPxywmc4KKq&X&J~#XAT7vqGDUhsVD=`^&CxX z^{jGuO3PTagwL{mtFyH{4tt$^;V3D~&#{jx+9gR$uj>EHRu81^N9+MtDTSlff?DC*TNDZeFRkxWb_y$oF zSV#3Xllnqi1`ERFA4?Hng+@||W;edFp=5VA2u*XFh8+gbMXZ+3QehZm1*=>4^CM_# zRc1jK!7$f~2DNhXhksyL*ngG(GPa1^0k6?rft^NHrvF?z<1w`!Bn{zYd$L&F-5%rv z*BaJQZY3IBO}=X)vY+;x&hA`pnfsGb*l~0(3fc%MsTa5|kXV0f{mk)xh2Lsr(mAf4 z%!<$z2d{|j&3SIzaPO^Bx*9c8ks7ozi_7znGXx~0_04s;Mgi{{E4_fMGdh>AD-`KW z`HRF@3c+21Q0|m^j=&LvL12^Fnp|C}a4uZRCMR4K6P&IG?#z5iBjv#vO4=$AmcqQJNM9_x=bIa3sWi_g;O5WOaJ zV?5hL2IT#%4`AQVVp7}8G^}Ds2@b&Fn_*4mE7rYapsnk2g=JaJnB1P8+ex4eGP!Ha zhke=5%kKT#Y1?wD6pVB(OBQwK0!kZSoQf7pbo19CrFdIo9k^1hXiPQEu#XXh=I%Hm z0>9UK4?=)a71}*O=W_u#d6xsE*OOjE*7vYL0^B~)Bc1=lDA_eg25qt8)6U)BN zGeU;4NR`IZ!g~9zuGL>(L#hZbj(<0@bFoQoULkP27D9Q^$%uT>l{Z;7@A1j~{Clb2 z+?g&YjVIf=u1NOw+Ypp_Y2(E)Zt|>H=VwgY$n=glF8P$dNCFKt;M|_z7;vOrrHwB7 zCeo&}F$8&{!hv#LftdPH`Bn+K0_@ z*qQnAE$v^WpYfYKTOo_v^5yBcl1dsr8d&B^KMQz<6sis0(Qq8qb+Kp*H0z9v=g6ZI zCC_O~3Y7BoTz@)8Rr1sFNpU4MLxC&D?akD;M)R%aeJiA0&=e}pSt5C}8`nTvrCqHu z{{2u8LyB&Q8=H)f$qjPAPb5?&jJ_(8%^6ec0Cx*~Jt-R=>#^C2T0tkP<>OoI#H{=t z=OpV-o5|zNR|!7IkhUi^FD|ajn~Oe`ZMk$j+bJ&~y9N0+(UT`vsz(nh zu*#a$OwMUmUnu>0xKXeEFnt-W9^y1_Qpb0bTKGAkfFLZAcBWERZEuA|F@%^fo#gZm z+R;Rp775fvLW<`HVLIq35_;{g2 z>CG%ZOyF0cgoNJOJk;EtHWB$Ap>)G7Yn6C(U!J*O-99Z}_Y{2}mIdkwg=upjUj?h|~4iyi<7Tka5HLRRETo$z(y#xO{^O-YrD= zOH()emwNd%eH044%=rWM;_U-j6q{rl$`fh;}Sc`m~+1l7V zy`G?Afl}+*7#}t5&QJEG`P7HrcG`i5OCjo$)d#%6P#pKV*xab?k$f0Vw`aY?MjnHW znMUHdoT}eO3+{NnAorCbn#-bXv+!#n9g?_QI-2`bXS|a9O*vaM*~%ntM3DFlg6K8! znN8p5Au9B**y8d}1dgc>xK^8aDajQ|*NaaV)?^ScEJh+bDs8c?Gt9~uomb9dYn~|jy~2oUf@L}Ogf_z$`KXl3)~KX6wY5_j@d5V zXBd?m10+|Bd~Hjlp!Fky?#c4K3lFStD(A59;+QOqTNnrGAjU`u4cic7ah? z%N9`K`+EQwjX}tM*iT8}%^%BiIS7G(sxS;liRO^fvyr>U+Kd2*;n{qva}@tU%y^MV^XUUbjcDnqXWL)q(PYxX}iV_nS%n&St2G;1GI5$Ie_;OlBnZ3D$lj{VIXF=!vbfE0#gp z9kz|isV#PFNrI|Y2U!Uid;@jGbR*iWcf8MVMMCKV6X^5hbyL|b(_LRO+heE%Ih+|* z(45~#s9&9_6nQmznp9U!1m)3Ng^m{^o_)2r>rn0?x%v{Hnh8aIHXhtKyXm_FicvIu1i`xCjb3F~?0du;L&AM+W z{v@Wjt7hmPp0?hiyZ_48Iavfoqft3$@W*3vL$M-h+kCMyFv?$r)-2gGo5e*n4rp?o zfGD*1-xtjZ-a>BjO;bR4YlLLn51KTbfE%L<&E_U@xmaSUN6+eEqK6 z_9)KfS;|V%8QT7~5Avx!#ZWrN-3L7G086PS`rke?6NW}cK%sA%kSefDk>l4&!m4VOV0;GCbfpBg`kVgm{zj#$7es?%pwj9;`4awR_!2|wI@oG3&6Phn6QBG zkLgQQv2>_l0cVIUl@__Q(e^}u<(FUQ6fnOYGgtxAP-z%h$?Cer<}P9~kb<-l{(K?t zp=i1P^j0GF=kk}ujo*QzV~RFxT_d$GTedDqy2?G;1FpqeKxdzPzzu_*P5q%uAp4q` zqc=Tuxi!q_0A2-!ndWUGHl*FLKmj|Fjj{vD*glo=^|F-DUcLcopPWi*2)eqO+zjiH zx(zp4N7>GG0{pwwdr&023BrsaXaXi5QQ@ft(LfX)LUql(p@y){4fWfYI~ z`r>Aj#l{~dNp|-F;IyQzy)*R>f1F1cM(9Z}8;ck+K2+3_L&xGD#a)EqF7Lp3OOq822vL?RRHG=^>XWpKJ6fT(i4<)Zqot9mvuu!%-> zr`mmVN7gKs94Ga+QJ@79%0XFjvw?khh!DM|XgcAyl&+%U^NS5{8Sfq{$3x0FXvhAc zJ$33Iz6MwKshh;1^R!qJ;bF|Mt@sm{7u`W@ z6>AMcT~}xQr2tB1cXpDqU+QwM>8g)>t$q753~pywrXcq4gs?tPB-+tw6f^UydRVkR ze_ivhr&`tW#t-pUk^CYRcBU+K6>h^n-NP?ED!<&x8M-m9fG^NySqCCcL?qd z0fM``1$TFMx8S|9&)NIy^L15M-*xv*Zm3Gt^VX6%$NY`4x)!{TpAL7z6l5p^<@CmT z;Al@@YiA&J++MORfF_gDwiHuA$~yCH^^qgT$Fjv5@J`0n z3|&Mhe{w+K9C_HvRgiRl&B>zL$dQttB?A^ObCKE-c ze@D+SL(Ld%wg7dTJDKj6dK6;yPwK@lj0$90X4ZYlM0`z&sJt0=NOTJR(ZY9gbwMM}R8<+(NCs#ZgS+bycU-9#M@gJX!zMynT zl3oemb2^d7aJ#>VM~Bu2^-xkMUiLsL8}>lz4~l9ApFJ1pCUOq+OKW%K!~N?FJ%69E zaY|(#mE5LxatpCJWB8?{6_j&y4HzTfX++ZZ_D0MYaY9rg5A_OJ9JrirMq}B?Ne_b` zglaOY7zz9k*ax$?NFFL57q8HoOutnLTu82(N>Q2D@Wv)mqIA)UrQB`}AoY7+9HJdG zmEGT@t;v3A_b*?l>GrLp{jxo0y;e~BcrfQ#@an6$++bwM@fAXyjcCwy`Rx<=*wSv1 za+63|JOd=Tbl1|GvDZ$=wK+F|aYAh-HsUGjSea{6vrD|lm0W|3@gD@LU3X_ZMng#? zQpZ2m3L=rkSt1nL90Iuyw+hC9lJkS>78;&em2TctUv`?_=5wdL^x4;cYXOr%s%2~} zVK~^MT1`bp>oNq(`h#lFH5J`x&3k>3hE`5-sL3+bQ%gKX!zM?&A7e>ZGajQv+mTN~ z#nWg%Zfi6`1m8M$Qi`=ZO@j;H^YIqqotzdwV(6U%oXi8hM3sJ~c01iwS~F{iXF!PK z#oC%5p@WXZ!^254*9(}vojgdp75_;x+-PAxxf)4DkYmlp381G-;zS!wH5{_i-U)kN zZF_@$#kY+7PLm;HrD?D5>3}s;-#DAPW9LPz{D@+OJGY?p#|y@J|g2FbMZ---5{qB!c zTJ!DH9t}P>7F~=L>*Fndz|2RnX0ony<_K)vR9ptj{AKIC!31h?NcTHQclgy1phv*I znk!K1(PKxb{kVQS)6r{Oy&89-X=jfQOTAYhBcLB>BP`h7>Lwl4RSLOIYLUHU+cBDbWZ2q?UzWB3l2xw$@VA1(UGqCYq3hbkM`CBNEwGCx-cVN_|zKT zeD!;S@ztSZ<^~U;(I{guTuNmm?J}?Kuh8~6T(SSO z>;*4?b2i7ZTdZ;u1$$`rBm|G8?h;O@q@sPB@;B^^eYX}It_)fz%I{tGO}>K8DIoXz zXO64UmP#fNd09Q*DnLa+4y!cfi(eg@Y5$~!YK{|S%#k7aDb$;@qBiSvNZ({zUpaE5 zB$7tEdJ54`qtV#vG%90HfHcN@#)dnl)$Hg!u&5~$j3bWHp%dPvQSIoRq9Cf95)h5@ zOEE>E&X{JU%U@Me8eW)o{tMb<)N?Uv*=;zo`ku7ON(H^c$A%F4JjsQU$A;CD!_LA= zw;?YVi$1n`#Sic-(pn9;LP0=E=L@+&XyE*1{h>D1y{1gQy!&?(cwd(=%m8`7evMAW z(m3jb+Vm}!Q+agtLcW-cpJ5f8+dEE##dohWm6PO0yN^<(=uMtVjzjrgCp|GFSZYb+qcn-Pf;;xfn2lQUktCt!=yI z%RY8~2(HKBiePSycMcyB0U=j2udf`If!tL0%P^Ck+~_Uf1XyRtkY~d4vwwehv0nAu z?u<3=y)>TtyWeoRy%GxSf>3WL%q+Uv&(2LD5ahrde3}`>cWR)~zFW%gr=(hil*HKh zjuh@J;-p-qt@mgBykPbqbhq|rVJSjUvtlAfGhTny{!0I&G$z(U&Bml;=>P(P*moKY zm6qi+m>)|7#ph1TnG07qsZhEqMAU+#SPGTQz&sT!nUW(Fpc!M~ zsv{^+%j-;yc4tFVP5*0`n)@vtv)Y&)5U}+YF!JR|KR(d9Dx1?a*q&5@Tl<*JMQ=pu z?5E>k2)sGG8mh+IW`v9%4(^-#!yfJ*(;Mz&jFSpw2(0TQOp%@+*!prG8Y3$EXDlrHY4vBx5+9vt$Nd}H z{cgu~K4JEa#01!fr?*}19M#hYBgQhOR8S@vcc{Qa5`+t zB@N5OXSTnCPi^>2IwA{A6{>cAK5#{&?JHlv*!pY#GNO6#xfBcGr8-?#fVihZXjhAjL=eako)Pv~v zRpfvhC6YpCX@SGu5#L_%Ja>uOs2*?EXRht2z56uR8_^Wb3zaxa?KouFg{7hmfk@`#Hg43^wDUgrZ^o5;_;!X3OXgh|1 z%)K5iyMV1sxfsmnw{RWjOV`+R=6#cr<*O}Y$2u&AE~K$*UdTGf!mnXHc$XYl97f?8 zmPa+Va?ctc+!Q~r{~buh_-Lr&fcg^QLLaI+(UQyi{=Awn_MZ22$2#@B@~9==H@BK83Il<67OTH>4KFCmthCvU&i#93 z;5QaTQdr*{asik@^mPSWLYX$g=e$1{7X|sx9Uc##pKwLON7{?czQt<0r%(07w6Q$qCUh_0xrfnXF1}Db5MXQvm+Di5+NZ2;f1g}|?BhXc z&A>l@FKr`>SEbm!@*tnxk!FPRXDRHGhXu_OwOJEC%)e#lzs?AKRw3j-Tm)p&7#1&` zC9Vh4mPO|gZ($#@39H4$j&V5ne(wB6nra0K?Rl;y21>mnXY3wMyoZ7PHmY9dPQ&d4 z$BjeF;9?K2rPbU&u?{r&B$F@S0*CL1>H*E+l`k>gFfq+%K>Q? zo2Xf0Vp&stnsL?S+vL)ny^*N<`98+ZTa3G+QNo{;>eanis^VZ4GW#xkD8@AjulAon zqo<(mwe1=EM5_&HY|>yfEKM?OHpwFeT6^_(%gmkz$_{_-e>puou@!pF#GGI$`(#qG z&TB9P)mwjiNlPB~@FZ#3AJ4-ixvC)S=zBMX%NXi#J5avnN^3Dmk8U!V|8Ylr4Kj&_ zL8ek^6diiXGHeyO%CI7614&85C1aX-cU+2xQSmA_FtowywBil*2elhpdl5_ZT8(T1 z*D58eU5l4hmIOPkGy8PFLUg*iY?-WAz!E&oM$9tKPn;y)uCwm`5)g5bslFM{%38M+ z?-zN6deSyb_>?p9EG&J+=9I?WWUkqdG@03D*!R`MXYM1F{ArnV+%+bfdvoI24um+- zW#$$S;VUR!ahZBo>>I^hTwz}s(pcT|0cr*f?*GSr<{DdLcS*4?$>EK{t**%^#%1S;W;lI+394UyzkiFCh zVi8|b$fH?(YhP?|Vmo+EZNAIAM(3V-6#4G4o3)mVPL~scHV%DXu&%7|mjR{N?GBRm zDxh|Cd$hd1w#67H`Py!Dr!1miL>Zi8!Qf7*#vW8Q%#9rVBf8C2jDcK4JjS+gxv zFJ7F;6^@6#?_Y=go6Fe~+IUAc4s=2LMC;C`?8*SBdxnn8Mv%Bvf+08NxVGp#yeysB zHjK44G~5~rb05Z2HDtD`kwaBKbH%T9 zQ;#YIo$}Hd&AZFJMb6Dl;77v!KjFOh={5+FJa7NsB46)8*yy1%wql#C_JQ?PYK$yc zTfau2M6-$4yKZ6}-4}O@obO(cw~DWnEXsjzBJ$GmF(Ua0&t@UV`2CnxOBX!JD=w#( zk*v(d(Aj9i7cw%F&E;Cf6og@|GRfwbSgEwuoR){DoufC@S09wm4rNM;^cjrr;N9Cn z0BQh6B1Z9#Ixx!+Y4-s&&g*~~L3eR{MPR_tym|HDM92+{D#n3NdPKCFL}XYRcDIWXpQ0 zEjQiufFp2C`-kEOh5Th6402DHCHUyx%z=iydaIgK4%_>{)|=vl z6vi6<<0LvvCtT0@*R+PvMvQRzBQ6BC8A>isgG3^jtvehpXE4wr@q2i52S&ZapG4&q!1MbV%Y6(;x!?kKSrD$Tq#R7H9BCkqL2)Mu`^@2Jicw0i@MWe zS00Du1Z2n$?aD$Haedn@k_%%PK-e@VYHan3cR7uuC`=s-4EuipxbId?v(jC_ckG0Kf^_<|oYSZ{0>Hd`G zBQ}?1z@8nNXuXI*KPc4aMlsY%zBxTPP|AP0ja(RFxjRzfk;@Z-;U>MlVqLf?pd_gfFCoI* z7=oq;;Dvs?!nG5c)8gtvQ>3wkU3+(Xa}2!(p@>1_x5E@`bONRBXahMh!F0SIu)VDF zU@aGG2;wdD?Jkx+o)7PpjF;Kmly#F(gU5t(Jy4_$4ymAwDVEs950t=~RwxVTPOCPg zQI?R$NoCv0l1-#@xgA-$t8+bMIyu#&Cr+9r`0h+Vd~s-m(3O9NYy?Nr_8- z52aMPDP^M(9qj_KiwY0}8NmUlPU;+@v6fFl$&W*<1B&x)w9f(}Ld z{jwu|p4)*2xzPcy1-c0AZxvIhrUX)}jd$pg=}(I6`4p?4^`hLTwX83=Y`G;j`hYO; znFkMw+j?rQ^xMp+OGTRe8lc99oTZXlF-h@PlM)lGr&$05cmgHS=6DzF0kJp@kdvMs zRI7cE(D`q(xxHI3z0a$rGzy?jUe4@(R1`T($R-uZQG0^4jBIs=Cs@C zTQTD5!!Ot+(gzLSHoN`+D%ce+r#z_mzIkt~zCrJ!jx$-VY=vcgx;bJw;~$Q`M2Y`o zE&y>Y9$atuEGBjYDe{RX@{w^5Be!X1ELk>3It;)bq_3) zb{`+ry%|Ev@d)E(jz`oMQ0lQWIo%CI`l;iXd)l;uq6b;BxjkQe6?oM?*;T5or1w+( zIlqYYKrvJ=9+BC`>^=gZG_7OYbZ1r+^ZTOi;N`sHA;2n(ga}d=@Yp?Z_L*#h|4r&i zqt2?Hej5erZj-3b*-u&F%MzFH@K=T054ZxS<0ZqxiEu_;Oi;P8j61s3P$ zDb-F)HS{2hP|VG;Vz2Qy5Gu27vNzo#(x_OPEZseA+p9X9-5x2%zFlT_XZg!EPN7+I zI^KRQJ7r&|Su0OKieo+%crd11eP;-VpY?OQE(@_-E#W-{i|fYr%@RV7^fW79U?|xK zDj~*3-{Ep4Ss5dxP=mnbU-6Ajr`tbly=JRM$gT*Onl?re@VM`xG=vZ!C6P)+i^k0g zFqBNgKU|szLcsek2@bwI6^=Yyuh1Do}YJ;d|_UhPlHt{9w`6ma z`u?o2gkG*d$kXNwR$_8-Q~fU#Qm5g(5b#9L=ZoJVABGCxf&o@$OHZ4yeo-ZOC4sYg zKN#UIK6k=yf3Z8;n@cEMHz5T%ogSFpzdaPKD^QO5r_tb~>;Xp@5ch=w*=hKY6xD`z zh=q?}>KHu&g=rv_skZe?yQ7z0(2npi+eHZi&bpZSWU0Q7>XyZWxn*?~2Ry4Jw{)6$XdrjPJ}6 zl!M*jfy~~*&>Y|;Y!B%l=4Vw0Vu*e)PDJ)TmH3Dt65_F>tF?!8O|%mP34BV$y#DQvPgh=#r?~+7>xn{MDl$5#ajHvo#=|(QW7T_CR2!?W zLLavOUpQeEQh?obkp^dv!8aJIDQAKwN3Nx5*Vm0L&1;gd z?%2!y*;pWwBBM=-$Tu8)&y+K=s~L3B?$|xpmx$CCm3tf<9>&KJu)nP1?Q_jOLl!Z~ z>iDAOzBiy5IIKe7n2BvvpUqHZEU~q`aPSnbE{|&Aa{o{|@UNZ!Ij#=ue31-`1JY5= z=FTbOZ+mCE)9dT&mRfD`L!X1q8D~w<(wzd-i1f1)JZhmL+!;Q+n-OAJ1h zG#@!#Z|y3MHtU{A@#k49^+nj3bkbfA#7{7lM^d?3Uz3Nflr8#g8ogF+M0tWhZOS;< z0w6XKP9yV-$H*dLTxHz^GuieW`3U+Z#MeLfppF7e8b!_92$h_D;cX0Y=6tDShrx3x z*Q)%~I+rc#v#pUsr5_M$r#p$1qEA3xb-hnh5L#?V9l9^BcdIW>q*vXH;P70$$z`@ z12z$DE`;u8Ywe6Bok72co=OcwNd1NRPiun$Fmp$=V0s|!$uS*)zraCE7%C zb;Vm72QAs>y4*FGe>H|V^?dS(kPa!y?|E;Pg39Hl5Frv#UDBe)**@xRb39E9ZgbmD zHZKo*W7;-0r<0``3zMP`i14qyDFRo;il5_>;|Y@S>&*B`_y9#BFFlkB<5kWX^L_D3yG|gg%Cb=ZI)?JhRIn{-rN(>fM$@GB;^poE!gFl!MP$C4rl8UKE){^xc^&l8;fco9r z?R$g($Hj^_(Yi~AvpTC#I#I}Gc?O$Gc<#&~TA=rU<*8cZRv@D1%CfGEaL-r|%$9D4 zQqxX+!>~p~w!=gZw~QGgAFx7Dxl6LzF#Z#5Myfx29iMbv2J$Sz0~CkQzMf4xcUd6l zu8)6I`bU^++OzOuddl1K~wOLZ>z zPzw>J@fb=a`{P&4joW~7t-XO)gh&d2#yIV^MaV6XMe%htKJVsLxZQWb|4QuR{cl*W z{v5!1V6gZ}SsZn6qEyim8(3oY4kI4b?;cVl>{$mx40%mqt8IOB;ub2V3cL#fc~X{C zS*^_#DUCFec0Z@9{pXOFxW~FSN`U<(nHTK2IM>hH(ol&xM+qT(k$>d;qaS$1Y&pHV zoO8bg#N3bHIO4G*pT3EEavS51g2)7L1fU%s+HLA( zE_Gj*$_b8*EYGc9VO&mKB5pb>1_rRki_CV%Ty~?i>IgryPZ-zQ?pU>!=X2AskCZ6_ zisQx6T&}Nssja=QWo2*~)%=+gP}2oZ;y&?Fc0Fjxh9LDqrl0*x587dhP^AaygUT$k zQ3u2&NL?jg4mdRa>|}7xwL+g?&MbXUwqi>l&$>qHSn<3#wA4W2p>z2?3vZbfPEZq? ze>q^Uuy2-hFaVLEMh|Qk4*$j3!5l#b|6(!FnSVUrh;^z!=l&FDGtH4e#abo(fqb|H z_^Lz~Jqb!) zw4Mfw!G9`OV9x&=m9zgBD#wky6X=a1wjCQqm-~20tWqK`=+xo~u?^3<*&6`^BFDN5 z+Kj1;9BNpvB$dr7O^io~Y`%y2Z4Wr0{`gDwQKZTNvQN4052wG=YPLn^5%o`%nAN>N z(|MpmbrVoe0c_-yO6$gJ>O|#Iy*vfZVps(W)P_jELxfVg1*Irq2eEaPP(^^a1&Sdb zXN9Nb$}t2K*}9|<9Eo9a_FC9lk-t^=E)Tj8Z~XO7gQq(H%rVgIC@Nr&{z-51*Jte` zZiNwTQ>*%MsKs}&)h9$eYPW{(SBd%dz_0Ai%TxP7&l z)^7LV6^!3L@!%^D+6;?|(g~gf-?u6g+BoS^|~6sgy7p3`+tuiOI* zHC5@Xj6!J2a$x@O`(Bh4uh6^@S^@)+N6%7WF;AXbh1Xj06UKtdYC(q2v+!?HHu|MA zAuyCmSSob#Y^#2v`!$x{nd>C4)6 z0wxd}!?9egml6ceVN`c`2ivMGt*eH=YZ%b1!w;AE8rz;%y9<2Av=(>i7<0*Z7K%Ur z2}X3-9bMN+kIUe6zF7#HtE!_b5oQ2ps0{=DX&Zi&(bPt#olTg{BG~pf|Fxty?Kvl- z;yW&GMs6RIMVc6R*Z5}#Ij-vh%sKC{Ic-6YuDT~=j3kx(e7_j&M34KPTRL{|HqjWl z@Rlbkv+Z}>sFW+@*P_I+JAE*W6w5onoFD=20$>~NN|Ut0Np;3KUFQ^R>tr_djm@w} zseGb@wU;X#_VWGH6zcVKoIk)Q9EvW=F|t8GiVBHRMN9GF-PICrSs3#vC)_h`fLZKi%s@VX`E3f!_MYhkO0geRP2pOFG+4-a9zxx*{}3pZ z?8~AYZN(8=e@hLFr_pL=aC(59W5ac=HYQ7=kRK5f>^1+mSP6~P`-)-rl7=B)Al5Xs zq%DD?Tf;YNXCP(%$7S6<58mj@R!22K@96Nn5O3-+pED?k1qg;s zx65l{0y!gmOEdK$xf~ZRWPW_1vVI{BIaRR<4^_1Cg?rX>wzqd&uGs(y#pChTs^_n# z!w3&yIpe0(swgsWtqshPQ`ljKGo+Teuh3+AGPUOJy;)-z zpT;-cBu%SKMU8B6I%?~mg#b4*<|cS`w*QnWOgNeAgas#zP4xp4rfVjx@yIZ7>V|4B zBc)>~(GP!F`IWWj3Tf521#2PI>U&4>av}OQzI25J*%|cZ5?e(M;h(fDTEw78djj^; z&Rkh455-GE10j*n{06eKwHP2Mb&G(XudWPOnc3xLDnjN(xROsbH1hC*7z`f z{7YwHG-0a82z`&vi5$rSt+jvD>%y$(a^HvsSBSa&d|UlqvZX?6;B{mf#-o;Cmbv8idI0%N z;mp8r@_8V2=G&2&ai*S*ayrHC{=f%dcUP}9_1;5e%A?hhn&rc&GFo;17YNsnorrsh zvEK=Kmo!{5&nIna{t4!zCcTcfJLrHfhmm`I-$Ur|x?db7f|Q;_$^qH=2}`T`UiXDS zF282aR{}`R{3=q^M4}nxuwgO$2QIdnJsOAGPVm^UczbL`4Pti|CE9LNBr$CftIPE; z@ImSjeri@gM@|?7*Y!KlG21@`og*pCFRr$~CQ<2QR@&yi`4!9-oEkWBBB?5xW=%}~*1R#r~}{N^ycptHe8F=D8J?>;o9n}6{8iY0+} zPj~tpq2XzLpc)ic_}X+gv-QZvWe=H+;YN`Ibq22ZLHhY7iLR_U>AUHxFTn7j&f=_c zZL+pDce`xe&|ENps%v`U@6G}8!Wk;txLBpqTNle-iFVN5_0CbOw%lhue}QZVwhW7f zlgEwju_y)CN62q$a;f8_H zi3`B$8K~;D1BMvMd%H8xW+3mCJzsD2sxf7m=cvH$yu8RJq0y{eqcdM6ZF+9KZKTgW z&+Zi0?M^ER_;}I+EVM$6{`309^=XhN!z7Y9@|`>R?E3*=et>*v0I0Y(%Do>y#%OKb zv6p-uZon`kcK%R*#x3Wiu0|-lA+n(7DuyxugV1t0mGRbL=m%GX@rdZgzcIDWw!l%D zhjjm@2y|pvU7^#-#f7pv&YG1WX%F*0BHbd5D?@Owl|N_kTBBqAL3Yg77sF8kOXi`Gkg-Isce_YeJm|3Wc{b0B;bxTMb)zgN=MTdYhX0?xVQ$%Re` zpGD#4LG$#uIO-hx;jGmi2FIl+GP&H;e7evo_?twg-as1MrH{Xj1t30SdBH5@*r^g2 z^uuLI6(iIJt>m#{W3bLS2*CqJ`a5e%OVa-Oyuj4hh;n5z-_^157qDOQ{VuxJrzs97=IUkPR%0oF zL&3gE0IpExBknTiX1Amt<*QX|k0Kqcdv&{<%@bEL1-w&65eG`**;)G6VxMT7p774f zQj_821@o)_T$?G+{dodpN8Q=gQnMMKP>@0ldppe*xNnB!8~MW?P=DNX7e7LcVxc69MXanzO-oP7F_Lre@kvdo^4sjdG zNr(W5+V+<$iJ6FXzsqAr!e@2wY~26FwgCo3_*Z1e>y&VQzz%5y>E(&l#clEah%V2~xp^#(BZj!P9X>MbiyTHX4(JgskB zGMF&%^8GA%oH$qaX3}OI0lU4B_eRbck2AU54C;}wfyAwGK1xRau zjEL%X1Yj-QKIF{PA!s)_ImcJMkA2!j!OpfK?DOYKlFe|$7H=3;_48Yd4=Y+hO&A2T zlA6@Uz-@CLlkwDm#H=nSnJSw|z;v{EJfo82O`(+$rlL;;+~fgapOAzGi@`G>LNX5! z&*dvlU-SfKl>Z@Enup75fs-<=MEMQ;z#ft5&a>Wa${eUnW(s6gZbvVP<*V$@RtDe= z_V@N*wzI9o$6Xf-#6r6|;k0X5!}KJgDG{k%A2SBFbrP5NtL5>14L%RXy6*Sn(+chk zZUQWP9EjM0i$p7{Cq>yxrOU_foBu&#u$nd75f^)ny7?~hAS{bpBk^)m`ykfkJ#O^=11C!KT%xapzj+G z@e!>V;_O*Yj&e$=HmnH>r3Zu=qqHXen8r+@P%@b2a^2m>(_|>;3YyGC<_|zurYC@a zr&olMR3Lf>2LP>-5I|laf(F4>bGdLR>31|?eyz9`qjsGrKYI_;7O`Rj9+weCJVN0L*s zr-WNT_zNzV<$JH)!9y^N@EpOT(MiRy2uGhVYQ8R-a$ zzF>{tG%9c5EH<;W;*rQ$&@bo4uvYDRN`U*S)$NMA+HWOpBYhjEt~T@eR+8mQ$Sb`J zjb5r+bRkZh0X}Xu6lpkKSAqiA!ka2ibvmK+{rUX>ESa-d@qV4C2GVv}Ducv}N{4G} zw!vZ!Jmi3QIzdsT4+II;u#vLGty=xodK;5f(v~YV7RS8hMcn%%?N-l362+_At=} z;$|!br$;F;nch}`K$5!CtvcHm=!oH*Dp~~9>KQDd8T5(VkB5tYp8KPccHetH8|k~! zukQz{+8_QkWc}G2-TjARX$8tB$pbQV(F$Vaa|}Gr=RO%Cqo`bY3Bc5>>SRw4@Ri_%#r*eO9DII4b8=OkyF>$#y_Hmqgx>?}gR#F^ z%dv4ep{K{)Dm1!!y5oG`fi&=7n_LOI+io6|ko;8BMm`!2ka0H6`JC*ABg-TC2yi5Ug^aR637wQIG+?I2UvM zF}Dgfq2eO~9z$EfTm?+*9(C-n5EV9q>p5$Mo}ZKTYFljA@Ce79`$r7zI;$fJ7Ax^r zK}Lh3y9yZjU8=-klJHuRDbI>9!O6=YpN{%{W|0T3lEsEx+kzK47v#v!m)YY~KmFW~ z&9`_7e7)^7YB@Aq_8YKjQ^n_}8%&!hj&7I3?_*m#d9-BbHeA=SB#RB=WFu8Jhttg6MQBO+u z+6lsp9WPkzAbj&{hE|Z1J6@yfotKQv56@qpRTS`k->9r9G)+e->FOU zY1f%cq*JEJ^JQ!gm-B(=V|zG;E_~y=KIxc&SSC<9ljM>X_8ad`n(x#TMuIU2n*_qo zm-k{>gcFEDkVJILn36|JF$JR@US}SDXKqM`DxvMsp_OT{u&P$uEm&D%iobq6nEEM1 z5s5+w842Jc4$o7$-bq1XW>Lbp0Qn+`$}U%XF`_4#uMiv5?qc}$PF8?>-`qKBVXG3; z-94|Ahw_M`NoFSrutW+$zfd=f`E;qhV~xqG)2vxNP@OM-a(a>?6o$DI{Ce=7Vvfh- z(!rpe8hj(j)^xo&l)N3r$fNy1DvU~AQ?gdaA>YKP_XI7@x;|fI&sJ>|(*; z%J9Yl5xfkl)MZ}C1!?p-e-WI2^D%kA@*OIx5;m(<*G0=7MOYswrxiUEv#A|oIbkEqF_3Eff&EfPy*x72kw0>fhWNcGo^?}hS z&STVY0=eg2g48B6>k+-NNLZ#Zjn)bGe7<+$Y;|H;kyfPxMS&Xl-i%6{of)k9?8Vtq zwQRKjxrW!HIqTD-`cpQW?SZL1AQG0IsFaxn#KgR;gp#B`L>;cxn)ob39$a&KSX>-R zf{;*g{lpxx#+b`{z&`Al*Pn7ZFrF{iFA;?xl2?79KDoM3uk>2-6ZScnTCBCw5Ox^h zX!&@$=#9P`ZV-aJGni21`g~}+)zf!BwMV`CJ7z;%MrP(`x?7F8QlkVNVsOD+Hu+Ve zwFV~@NWQaAa82WOKNx)u65-!sSByYb{V7$9-^w0Gt+qb0XI*e@|E?J&H&r& zYNwb50S^k2+g+B(yNtRtjX*Gz=-?Tp!g8hcl?;p?iD7pHM}+Do26XQqEIH~-DqQ!t z1?~>6_J-!HHnv~7`*#~YiYKsUi|VS>ISqw;zdD$l&n^){*mt@(gl~^E4n-N>@;=0l zyxJR_{b{>m`e9ji4%h@w7usC=9By3>5313wmgh8E+@9~l=Gdz8tmkUD#ZdMhBW?Io z*4n&Z&t=zJgQB7h4MzG%V$=e13XQ3?BHn#b{CMj`mRW)^CHj+A_0pGNkA?>5#V@)2 zMKPpD6J_KE96iSPE@TuMhs-9FL~r;g=2B85%%)S2YQ8)wkh|4c-yZi4v59k0Yc;-H z-lS4uO!1Ngh=6AuB68%BY&I?zshUSMn*uRul1POgjc$7M@M`-f)WAlMQi5>(7R!s6 zOsPd=H`ucCW{;+O3;>;8?>_L(kU7;pS`r8@R_LESSh{Pqc*JwK8$nCtyt}Y@xow5o z7p2y0Hr3bn`?**ux#fScm7EC|+v)Z4lpiDS-q?-k_|+>#KIt6K3759XzBn+_P#m3T zYd8H7*_fu$c28@R664EK;nb;RY66wkcvhv(Pu(gHna0+j=181*&w)e9PVM$hxE?vw zV~~D&gUh_JS(^&8*=wn=B->L)Lgg)20~d@(Q#Mc(w|I^p@a`*g?)SOu-14=Sp;$uu zM5PFjuo8U5Vzrjq=rR+3RR0A_l|Jue_GbvIb&*IWr+xMqbUl1nL5>eN^!X>953Wcf zl9<;tL7%RHf>n34R75-(`1ASjW~phW4Z#T=S=nknF!D2w^{m7}ni+fOM;UmBbrLAd zG~rd}Qx1QdUkNlA#A0!l&8>-2coan+ums4`J%52)hY;RuGiHVPSL43?vyMQtc zLO2r7|HyqYN#=9p8#B*<#VyROlJH8YoY~ky_{2WT+3hUKB6D)gx7M767-SH3sqvJ` zkP-wR*mkdWr$3Z6ji}LEmQ?ebHM0}&}xz6c&j%#W(xpgzEZt`+1Hg6R@B21JsmnxUa9q3_F zVcvX{EV8)Z3wn~`+m-^Sv2~-_=)D=gq15OyxrrO?Y6S4b-w4uQa z=$I67U%y5|MWuS}t@_bHG?%As!3utQ*vuuBX5vHsdiG{*>uNc0&W`f&cI8nE2-z~K zbnbi<1&h^*C96$KSS%&;74S`Og=nH&k&osISv@yk9WGe$fZd72u{d8$m({Q7^?cyB zs6Fi7&<>q9QWtMMnYP+t<@;Y4pQCR+6fhR4JD%DhT==s01y;)M=`O3W8NU_k@ggME zYIj6?CK1|Hi`HNYP+%sI05DLbrga1Uj3o6Mz?^FML*(a5x;5T7)Qs?i`n#q|qm_1T|T;M>nh( zdI|SvjuHZlNA|y1@&7>&M0~-X%jWhXrQ_b<6e`a~HkvNcfOPd?+WUPmW$$gljkG{A znUMZSM-P9rVtV^08$E>XYN6f>(c;#u>$}eFne^dvQ}X~!+m2|Oh@GqqmN2@LI1K9bS>0%F7i4N>;G;O9VL_(CyFMC5$S=t zMynRK*VW*i)4Toju!2ZH3Tc+>BGbAulp)r}pmk(Hlp5Y(vowFM15>x&7M334>_250 zM}fsteQ-T-Dz5GSIt4{hOP9|#XUr3Mx#osbRdV$X=;U{~UGl0qe<}K_O_2Yk5|ak( zWf6ydM&T@1KQD+YZ+i+&#IhN;~H z^&T1J^P_)u>z*{WY>rJEuYQ5oJrYq@P;y_TMhKJr%DDeTHm`NUbbc>UJAci~KNVPR zrySGe)mB;&MA?Z8UXR-D^>EtdrbcDLamys!n7%28Df_6iXT+v4)VJzZQ@L>QOu%> zBdW#V9FQV11S}gEO{&fOssa;o0ta}hu;YC`!BlEVr8mD@T0q~k0%^^w?!VDyE46S^ z%GFu(^{)am9z8%3UMJVVWS&qd22Jib*{52sJ_88lFtoRj+EPB8Y($07Ki~Levcd|yVC_R060>IC;e6bID`U&!@n^bi44M(X zkP9b;<@+xPj>x>#oPeMH6R>Z?8NHvbO=qKI2e!$6X4jEVpl=dCi}%+SC(JoM#t%xQ zpG688k5(rP<{4;#0$N!Biryft%v+t^^7y4C?pKMS}(-_=!#*4^&=@g3u5o;GI)x59F6&(a)^TaAgxw=@+xIz9zz zb&a-~fvIAneEJHpo=^-3?liskmlkWq;`k4p%M0{dQUVbZ_3|G`;X0%<+dGH_Fd2j# zdVJ}ub)3_YaS3;lKPcN74ux;Q5{IR++P^XT6sg_r2{mLV*xCrSYdj2veJsCmkswvO zzS~7;M!fDhkGCb90Qd1?tM_2B$uMpo-R;Tv>n;-vA!PF-WiT&0w}l=kn^qNOBfj z=TlS&#-*=7jfsr;(rV^RXiC?B;c%lqCRes}(`nh{qgx{-!GUJ4&7n!7)J3I*suyVF%_)fj;!)P%1w+DVmJ>p6%pLAYkW zwD)Fnu!kiA)hZT~C?lYe-m!HBMd3ZdIa9^42v@t!MBueJ+>jiFC@Y4fb?nm{d(4ZVp6&T! zh{Y@B?*?OX4FXT?3HO_xy?gd_`PFcu0E!!e_6*t4jMy#c!gf4&GsD|~iHvff1c%o^ zG~m+3g4M8w8yZ3Y515nH`(dO}`notx8>NVE?^8It_4&k4jpqt=3n7F3(|tOoW?53r z%n*Us3hfO#^%6QS6xF-ebBjG(S>8Qx<<|L(uO`R(bu#bQN4%YJgYs_4k4I+<{E-wl zBER(^%w!Gv#FSHfzlt8(pyrQ%PqSOKIqLdKx82jH^6+|pxA9zgm!4t(b+0{u`Mb&2 z$_hFby@*R`j^U&WosTvGKZxkat@ktvm9sS1r3VuQL=M`oR}%|M1@oD$mUG`c?{kVx zN}Q5~*w-t|_(pQ238BxEUWu4tqSmZzrTzVD=&RB3c@JXiA5GL#r6q~Wz12;FFYs2f zfiw`&ei@DFBY1j8{-U0+nmz7F|%2DeKmg3`daWR01(|MwMPD zPZ$*nki#EL$_xp9y_lPcr!s4xd)eU?D|SiHaxRc|VzsW*4FJ}b zvayhlXSxJ#q8rafC{q*^1x*iUKanVO$zgA>8djE5Ow1QhrJT~6FE&;8%3+|(0g+PN z$0EUrc&&lpiiNd4QxWWUcD1jOFw%75yvjpRyr%|Ro!;dK+|<1QcHekH#u`6t$23q_ z(=%sF4n7j9JM!gJc#E~+KnZs4DHej(zX}<Iw|S=>T)3EGn1@4<^2N?O5$6d0r%PVdvx5 z%8Gr`Hd3#!9w_WEt4J@@x!rME;@w|+T`umRy^@>H7)hqp{8WiFi*OMy1r_Z*kUatV z+s&UAV`tWXjd7i<53uLRPJPd&s!Sx9d|W7X6Sn!xU{aG!u>RWJI;iPJjxY=zjwQd6 zEN;uChj=d%F6E*)E* z>t-#5O}=WGHB^(2)PB6?K9Qg#)2I5z!Kb0|I^c0V#T*l1IY&}-o!qk<+;J!xngfss zI<;H*w%&}fiDbx$hC6o?2PN1oQ+ER>0KslPqkjT=0p3G)!_jgLa;bR+y52MY;;{Uf z;}?Ca6b5xJp@AI&+N9hh{eI%U!$|ePo7=Tpm#&uY9fwmPG{sKOy>4E5W@db5ls|3d znI-p_mQ3aK-F9N7W}LV0yl{?lo*^$%F{xoDsA9~Z<}5N?D4)+QFD|k&a#5RXn$s}v zpjMi}VmAW^$sWJWQ@c(Vj*dx`1pivj(KL&0qeLcmg)bjrQM$n;XFN7IuQ`^1u+7#; zy5NBX@dFFUmH0_w!qI(lK-jbz&9B}#`qW^# zt7sxb8BV;7O=f?pJ~wt$4i%Yd_~i@JNLAZ=I8X3{4d+!Zwwn5BloKok2GJfSuJk-M z(Q&d2c9I&A7XK!t14AB*8ABZKbi)-#>yjv*al$)WH6;}7ws^ym2Q zRxz(dw?RTF;~_dK+2A=Cwa;yHL*jQo3~1{Iyss}24a&*}zymQ%YZq0)IFfjU};qb9-m&`MA0j~slt!K?^@jY#qWIpCFhM<|Bh;l`#};Q zAex0Er*Mnnxu$AcvaG@SE1Q~!jtlv$1vn~gzW2tRg^6qAa|CY~VnS|VBJDRXhk4zc zl@tL8fT3!X;v#7HbTeYeY(SBtErJLVqN`9hyydF)cCaqpE{~~5dN0UmbEL0Phe5ZG z+!1PHX(I!4^h%DD+qosKZ*iSN@3QfeXy}M9F(90SQfbp{sg=5I>)~wtH>z3g>3p<& z;d8$tta>UTV=UcTz648hKiXD`MfNhVb(-Jf61ZZ@MtSI|`7VC*dAY;D{vuKfGgP>Yr2Nm(^d}yEQ9C#VejxT%x~x-L+1=v=Vg}S3+2@TUDc2W+lV(>Kcp09!!9 z7tE*Ozfp7AxB|j9N{Z*D8XZl=t=JkyHIne(yI03&ddHVS2wKd+FfLphin}nlO7pto z?Eu-(gVAoYs6gj&?U%NcP-|Aa$LL+9sO~K))5qZY-+k}bbMHpZwsx~&i+f&bBali0 zH;kJtH(R9$1Jp^{~ocwIP^ttVH->92}RP~8M-+%>|V_-?!l@D9P1go ziFJ^?b@lJfL;GJ73Cc1lfhMq~^zMiUCaZ5{*}{T`!mH&6-Utsx?h|S-&e}L{M%Kx* z$L#`~_pJ>12*jz*?Gwz|%h1ig?@#`)6*jNa_=@j^T-(W2g2b4T@#No3-B9-)S6Z5- z+RbT--13D|p|k4KcxBx8XeBs9HoDP;oQF*4FnLtPlwfX+>X+l^BRk!E{EP0J{Rx6A zLQ+SU!&++GLn$o6eP4u>uzdscbIgPOJ5Mi~wH$7|Zu3xVdBqM`jHoKnzakRcv52_) ztgRkVhMB7C6$)Zb$v|NBI9Z>BujJ@(jv5bc-W^7$A}8N|rR>!>Tg4kN)3_}`tjl`( zZ(P|&;hTuuz))lXHE`Vdc|!W$m+xDh1ZM0acV)9+);9Hy$re0@PBLM8Q;L@kz(B)km%Ug+LyBfz;&k^+` z@P`lvK{PK}&)6xb37KQXOvi_?anI9GR+V_IfWJhHF(HCWzZL@p1x>#`gu{UQ4}e_( zA`8aZ6~pKQd4vrXpR*{%YJ!sg!*fg4%~qEufGN~DGLY*c`f8z?`ojd3ruJn8Vlhgab&AOgfwMg0kdMoZLdoJ7q!ci++$K8k8_ z@9$auS`xXJKKWgLGq^bH9A-T_p*ZW1?=%&yvL8^-`;H5o3{xdeL8cb*4wiHC9HzzJ zU^8$Kfjr-*SnbF0BaLn6kr4o|9k=g# zjFYo7g?eE1CQZz!N#f#5t>w(4e(FROd@jx%WQ*KcF$?9S<#T)W*9ck>hb7lXZ?qsI zp(hkw^rU?N$-4%vz0hkIS_WbG@nw!+gao{Csr#pCje4J-F`Np-?9A_??jk4bF$XLr zbt+5P*nzy#zY$BR8=cVstjr3XG|;*twJ#r0q31iLw;}Z(?&DGONGp9hqV0nkH?_O` z;+tyVlEGq`!jvk=fy{L%_w9NfD`74$C>X}a#f_|c4<9`#o*NeCBLgEDGHzJB4;g=I zPo;i}Pr72UD4s7C>G8d!K06YM!pFyL@i&hlgPzPCCIMivQu#xx6f`q-d%{Q*YJ2o- zYVT~pb7|H-rMr=rMRFi#>+gAGAMmZ?Am)|*kG3tOKB^-bwyEFqn!~cQcy$F=p6P8) z=I!c4VtzO8MB|p;siX9uPwNU!XG0ym`vVF>+R-dka!4z`I2tOmmxQueObi6@kXG6) zmKt_FJmMa)RVyGI<*(Ey2^|R`*&UPpsG9lJSXnktzQr5y_*M&4QK9}aknB#w=w<35 z^mskkClQx5yF58O8VikZMQp7UD)$bf#WH^$Y_tNV zk?pC#DS!;_XjVPGS**T;X9E++oPS`So@AGq_y| z&?=~PpiS621Uxd>TM#(5sBC_ZS@_xw0HG{7bVL^IXRZ;n_k--Fk~&!U-z0U^g6>^jFQAi24)eQW1?WO3oL# z792WfCPTJXalffO_D>*f_EPPfzlnTsX%bqW{5gUe7+xveBXtGkrikdd>>Zy&wP4Eb&4& zpwUXAxmOsz{B4^08?|VxwQiNJniIE0`{#l6p$zMZfjbkeU!?_E<7NE2Q2fa#yY$Hd zm3kb_%}8F0t~4o4>qYCXw+6?9KdMc1iM2kh503SCc+q6E`*AR#b5rCJ5gAS{u5W9y zIL1qQyFU9fOKvsKSLzECLIfNfXcM-fs3n$D;IQPP2)@89@W zVe-KKNfMMC^Xqy%$jJq1={A}3`0W-4QfVBzeC4Pk=ksAgpKDKTR;WdVU9-b|@C~3~ zw4)om)x%~^O$j0?HQRpnCuGNAu~f__B_K-=v^`(QB^F5#NZtt(w1rt}lj24^xaq_a67KpzCbu zSsf8G=q7}Qgy=_{L7vlM8uRe1-wRW7R*|qG>;Y!Iuam z2U`*@&_A?)5JMoeQoC5}DCvj*ttl)lzWttrL~_7z3$)+C$<|3+XR@77SZ(nJu?;#l zGK?S3A*NEG4IMh%@%VU)=N^-|A`oY3D~ux#^yO9Rshb58FzzVr-aqvY%}CjOb$f>_ zO&+dHZ}#`c-;QFIQzlM?ylnzaQivg@FgOzttNGQUKA6A>PXbV(<-;ytIl`rKC~F Fe*tCVr9A)u diff --git a/test/llm/README.md b/test/llm/README.md index 297a71fcb..d4b3d8b24 100644 --- a/test/llm/README.md +++ b/test/llm/README.md @@ -1,94 +1,47 @@ -# Prefix-cache benchmark +# Cache and chunking regression tests -`benchmark_prefix_cache.py` is the common baseline/candidate harness for the -paged prefix-cache LRU experiment. It has two deliberately separate modes. -`metadata` loads only the Python cache metadata modules and never imports the -native InfiniLM package. `model` uses the normal `AsyncLLMEngine` API and actual -token IDs on CUDA. +Run the CPU regressions with the project Python dependencies installed: -## CPU metadata mode - -Run the same copied harness in each checkout. Pool setup, hash preparation, and -JSON serialization are outside the timed calls. The JSON reports the median, -p95, and valid sample count for usable-capacity queries and reclaiming 1, 8, or -64 blocks at 0%, 50%, and 90% pinned occupancy. It separately records the -actual reclaimed count when the requested reclaim is impossible. `tracemalloc` -reports Python peak/current increments after constructing and filling the pool; -these values do not include GPU memory. - -```bash -experiment_dir=/path/to/pr1-lru-results -for blocks in 512 4096 65536; do - conda run -n infinilm python test/llm/benchmark_prefix_cache.py \ - --mode metadata --num-blocks "$blocks" --repeat 5 --seed 0 \ - --output "${experiment_dir}/candidate-metadata-${blocks}.json" -done +```sh +python -m unittest discover -s test/llm -p 'test_*.py' ``` -Use `baseline-metadata-${blocks}.json` for the detached baseline checkout. The -policy examples are CPU control-plane observations. They demonstrate victim and -subsequent hash-hit differences; they are not GPU speed measurements. -Metadata setup assigns distinct synthetic 16-byte block hashes rather than -timing real `xxhash` calculation. This isolates lookup/reclaim metadata cost; -the synthetic hashes are prepared before every timed call. +These tests load isolated Python modules without constructing a native model. +They cover cache ownership/capacity, LRU/SLRU eviction, admission rollback, +remote-KV delayed release, configuration forwarding, chunk boundaries, +phase progress, cancellation and final-only output. -## CUDA model mode +With matching built InfiniLM/InfiniCore extensions and a local dense FP16 model, +run the opt-in A6000 lifecycle check (use `--tp 1` for a single GPU): -Model mode fixes TP=1, FP16, paged attention, eager execution, block size 256, -32 generated tokens, and maximum batch size 4. It accepts only the documented -scenarios and concurrency values. The first 16 requests warm the cache and are -retained in the trace but excluded from the performance denominator. Each -repeat runs in a fresh child process so native model state cannot leak between -repeats. A timeout, child failure, missing token stream, or output other than 32 -tokens makes the experiment fail and writes a failure JSON. - -```bash -model_dir=/path/to/Qwen2.5-1.5B-pr1-fp16 -experiment_dir=/path/to/pr1-lru-results -export INFINILM_MODEL_PROVENANCE="${experiment_dir}/model-experiments.json" -export INFINILM_BUILD_PROVENANCE="${experiment_dir}/build-provenance.json" -python test/llm/benchmark_prefix_cache.py \ - --mode model \ - --model "$model_dir" \ - --prefix-cache on --scenario hot-cold --concurrency 1 \ - --num-blocks 64 --repeat 3 --repeat-timeout-seconds 3600 --seed 0 \ - --output "${experiment_dir}/candidate-1.5b-hot-cold-cache-on-blocks-64.json" +```sh +CUDA_VISIBLE_DEVICES=0,1 python test/llm/check_chunk_tp.py \ + --model /path/to/model --tp 2 --chunk-size 300 --policy slru \ + --output /tmp/chunk-tp2.json ``` -Set `INFINILM_MODEL_PROVENANCE` to the controller's JSON manifest containing -the model ID, immutable revision, and FP16 overlay hashes. The harness embeds -the matching manifest entry without assuming a machine-specific research path. -Set `INFINILM_BUILD_PROVENANCE` to the native build manifest. The harness -embeds and hashes it, hashes both imported extension binaries, and requires the -manifest artifact hashes to match those binaries. - -Repeat with cache `on` and `off`, pools 64 and 512, and scenarios `hot-cold`, -`hot-shift`, `no-reuse`, `over-capacity`, `mixed-length`, and `shared`. -`shared` should use concurrency 4; other scenarios support 1 or 4. The -controller is responsible for the complete model matrix and isolated native -library environment. - -The result preserves the complete prompt-token trace and SHA256, every output -token ID and delivery timestamp-derived metric, admitted local-cache and -prefill token counts, grouped TTFT, aggregate throughput, both repository SHAs, -actual imported source paths, GPU/driver/CUDA details, and FP16 overlay -provenance. Throughput is total delivered measurement tokens divided by the -shared measurement wall time. Delivery intervals include engine threads and -queues and must not be described as GPU kernel time. - -For an exactly 256-aligned prompt, the scheduler intentionally retains the last -token rule at block granularity: a 1024-token prompt can reuse only 768 tokens. -The benchmark workloads add a 32-token unique tail to a 1024-token prefix, so an -admitted full-prefix hit reports 1024 cached tokens and 32 prefill tokens. - -## Result artifacts +The check poisons scheduled KV slots, verifies writes on each rank, exercises +shared/repeated prefixes and cancellation, and requires zero final references. +It uses page256/pool16 and compares repeated greedy outputs. `--cache-off` +disables prefix reuse. For PP2 eager, run separate processes with `--tp 1 +--pp 2 --stage 0` and `--tp 1 --pp 2 --stage 1`, one visible GPU per process, +matching `--port` values and different output paths. + +For actual Decode-graph launch checks, build InfiniCore with `--graph=y` and +preload the counter: + +```sh +g++ -std=c++17 -shared -fPIC -I"$INFINI_ROOT/include" \ + test/llm/graph_counter.cc -ldl -o /tmp/infini-graph-counter.so +LD_PRELOAD=/tmp/infini-graph-counter.so CUDA_VISIBLE_DEVICES=0,1 \ + python test/llm/check_chunk_tp.py --model /path/to/model --tp 2 \ + --chunk-size 300 --policy slru --graph --output /tmp/chunk-graph.json +``` -Final quiet-window measurements are a Task 4 deliverable. Populate the table -with the controller's accepted artifacts; do not copy functional-smoke timings -into performance claims. +`check_chunk_output.py` accepts `--model`, `--tp`, `--chunk-size` and `--output` +to additionally check native output suppression and invalid-input rejection. +KV poisoning is correctness instrumentation, not a performance measurement. -| Measurement | Baseline artifact | Candidate artifact | Result | -|---|---|---|---| -| Metadata, 512/4096/65536 blocks | pending Task 4 | pending Task 4 | pending | -| Qwen2.5-1.5B correctness smoke | pending Task 4 | pending Task 4 | pending | -| Qwen2.5-7B scenario matrix | pending Task 4 | pending Task 4 | pending | +Configuration and limits: [cache and chunking](../../docs/cache-and-chunking.md). +Historical benchmark scripts and measurements are linked from +[PR #573](https://github.com/InfiniTensor/InfiniLM/pull/573). diff --git a/test/llm/README.slru.md b/test/llm/README.slru.md deleted file mode 100644 index c28e51c45..000000000 --- a/test/llm/README.slru.md +++ /dev/null @@ -1,98 +0,0 @@ -# Optional segmented LRU - -SLRU protects prefixes reused by admitted requests against one-off traffic. -The default policy remains `lru`. Enable SLRU for a paged-cache engine: - -```python -from infinilm.llm import AsyncLLMEngine - -engine = AsyncLLMEngine( - model_path="/path/to/model", - cache_type="paged", - prefix_cache_policy="slru", - prefix_cache_protected_ratio=0.8, -) -``` - -The server exposes the same options: - -```bash -python python/infinilm/server/inference_server.py --model /path/to/model \ - --enable-paged-attn --prefix-cache-policy slru \ - --prefix-cache-protected-ratio 0.8 -``` - -`examples/test_infer.py` forwards the same options when using -`--enable-paged-attn`. The offline `examples/bench.py` workflow explicitly disables -prefix caching and does not measure these eviction policies. - -The ratio must be strictly between zero and one. SLRU with static cache is -rejected. Disabling prefix caching prevents both reuse and promotion. - -## Policy semantics - -- Newly published blocks become probationary when their final reference is - released. Unpublished blocks immediately return to the free pool. -- A locally matched prefix is promoted after successful request admission. - Lookup alone, token-budget deferral, rejected admission, failed allocation, - and publishing newly computed tokens do not promote blocks. A request - canceled after admission still counts as admitted reuse. -- Only zero-reference blocks enter either reclaim queue. Protected membership - survives pinning, including references held by remote KV transfers. -- Protected membership is limited to `floor(num_blocks * ratio)`, including - pinned blocks. This is a cap, not reserved GPU memory. A one-block pool has - no protected capacity. -- Successful reuse and final release refresh protected ordering. Exceeding the - cap demotes the oldest protected member to probationary without releasing - references. A demoted pinned block becomes evictable only on final release. -- Reclamation consumes probationary blocks first, then protected blocks if - required. Protection never prevents an otherwise possible allocation. -- Hit promotion and release process a request's block table tail first. This - favors retaining its earlier prefix pages, but does not implement a global - radix-tree leaf constraint. - -The implementation adds ordered protected membership and a protected reclaim -queue. Candidate selection and individual queue updates use expected O(1) -metadata operations; promoting a prefix costs O(number of matched blocks), -including at most that many demotions. No history of evicted hashes is retained. - -## Framework precedent and limits - -[SGLang's SLRU documentation at revision 4b186cf](https://github.com/sgl-project/sglang/blob/4b186cfea59371cc8ec38597f750a5597f7147a0/docs/docs/advanced_features/radix_eviction_policy.mdx) -describes probationary/protected priorities based on hit counts. Its eligible -victims are unlocked radix-tree leaves. This implementation instead uses -physical pages, admitted-request reuse, and an explicit protected capacity cap; -it is not a port or a claim of equivalent behavior. - -SLRU is workload dependent. A large stale protected set can slow adaptation to -new hotspots. Pure cyclic scans larger than the cache can still miss on every -request because no resident page receives a second hit. Large requests may -require evicting protected blocks too. Choose the ratio from representative -traffic rather than assuming the default is optimal. - -## Verification - -```bash -python -m unittest discover -s test/llm -p 'test_*.py' -``` - -Coverage includes hotspot retention versus LRU, admission-only promotion, -bounded protection, pinned demotion, tail preference, physical-ID reuse, -mixed shared lifetimes, and scheduler rejection/cancellation/deferred transfer -paths. Configuration tests exercise the CLI, server startup configuration, -engine constructors, and paged scheduler forwarding without loading a model. - -A small real-scheduler metadata trace with an eight-block pool and ratio 0.5 -kept both two-page hotspots after six one-off prefixes. Across ten measurement -requests, cached tokens rose from 64 to 128 and prefill fell from 266 to 202 -(16-token blocks). No-reuse and cyclic traces produced zero hits for both -policies. These are metadata observations, not GPU throughput measurements. - -A bounded RTX A6000 / Qwen2.5-1.5B FP16 check used 256-token blocks, ratio 0.5, -four warmup requests, and ten measurement requests with eight generated tokens -each. At eight blocks, LRU recorded 1,024 cached tokens and 4,416 prefill tokens; -SLRU recorded 2,048 and 3,392 respectively (23.19% less prefill). All 14 output -token sequences and the separate normal-EOS check matched across LRU/8 blocks, -SLRU/8 blocks, LRU/32 blocks, and SLRU/8 blocks with prefix caching disabled. -These short single-run checks establish bounded correctness and retention -behavior, not a stable throughput gain or general workload coverage. diff --git a/test/llm/benchmark_chunk_prefill.py b/test/llm/benchmark_chunk_prefill.py deleted file mode 100644 index ebb2e948c..000000000 --- a/test/llm/benchmark_chunk_prefill.py +++ /dev/null @@ -1,298 +0,0 @@ -"""Short native long-prefill/active-decode experiment with recorded step boundaries.""" - -import argparse -import asyncio -import hashlib -import json -import os -import random -import statistics -import subprocess -import time -from pathlib import Path - -SOURCE_TEXTS = ( - "You are a careful technical assistant. Follow the system instructions and " - "answer with precise, verifiable details. ", - "Paged key value caches store completed attention states in fixed sized blocks. " - "Prefix reuse avoids repeated prefill computation when requests share input. ", - "Explain how deterministic experiments separate control plane overhead from " - "model execution and why complete traces are needed for reproducibility. ", -) - - -def provenance(engine, model_path): - import infinicore - import infinilm.llm.llm as llm_source - from infinicore.lib import _infinicore as core_native - from infinilm.lib import _infinilm as lm_native - - tree = Path(llm_source.__file__).resolve().parents[3] - model_engine = engine.engine.model_runner.model_engine - assert model_engine.dtype == infinicore.float16 - ranks = model_engine.get_kv_cache() - assert len(ranks) == engine.config.tensor_parallel_size - assert {t.device.index for rank in ranks for t in rank} == set(range(len(ranks))) - assert all(t.dtype == infinicore.float16 for rank in ranks for t in rank) - record = dict( - imported_llm_source=llm_source.__file__, - infinilm_sha=subprocess.check_output( - ["git", "rev-parse", "HEAD"], cwd=tree, text=True - ).strip(), - git_status=subprocess.check_output( - ["git", "status", "--short"], cwd=tree, text=True - ), - model_path=str(model_path), - model_config=json.loads((model_path / "config.json").read_text()), - model_config_sha256=hashlib.sha256( - (model_path / "config.json").read_bytes() - ).hexdigest(), - cuda_visible_devices=os.getenv("CUDA_VISIBLE_DEVICES"), - rank_devices=[[str(t.device) for t in rank] for rank in ranks], - native_binaries={ - p: hashlib.sha256(Path(p).read_bytes()).hexdigest() - for p in (lm_native.__file__, core_native.__file__) - }, - ) - maps = Path("/proc/self/maps") - if maps.exists(): - paths = sorted( - { - line.split()[-1] - for line in maps.read_text().splitlines() - if any(name in line for name in ("libinfiniccl.so", "libnccl.so")) - } - ) - record["communication_libraries"] = { - name: hashlib.sha256(Path(name).read_bytes()).hexdigest() for name in paths - } - for name in ("INFINILM_BUILD_PROVENANCE", "INFINILM_MODEL_PROVENANCE"): - path = os.getenv(name) - if path: - record[name] = dict( - path=path, - sha256=hashlib.sha256(Path(path).read_bytes()).hexdigest(), - manifest=json.loads(Path(path).read_text()), - ) - return record - - -async def run(args): - from infinilm.llm.llm import AsyncLLMEngine - from infinilm.llm.sampling_params import SamplingParams - - config = dict( - model_path=args.model, - device="cuda", - dtype="float16", - cache_type="paged", - enable_graph=args.graph, - prefix_cache_policy=args.policy, - attn_backend="paged-attn", - tensor_parallel_size=args.tp, - block_size=256, - num_blocks=128, - max_batch_size=2, - max_tokens=128, - enable_prefix_caching=False, - ) - if args.chunk_size: - config["prefill_chunk_size"] = args.chunk_size - engine = AsyncLLMEngine(**config) - try: - evidence = provenance(engine, Path(config["model_path"]).resolve()) - tree = Path(evidence["imported_llm_source"]).resolve().parents[3] - hashes = { - str(p): hashlib.sha256(p.read_bytes()).hexdigest() - for p in [ - Path(__file__), - *[ - tree / name - for name in ( - "csrc/engine/infer_engine.cpp", - "csrc/engine/rank_worker.cpp", - "csrc/engine/rank_worker.hpp", - "csrc/models/infinilm_model.hpp", - "csrc/pybind11/engine/engine.hpp", - "csrc/layers/causal_lm_templates/text_causal_lm.hpp", - ) - ], - *[ - tree / "python/infinilm" / name - for name in ( - "infer_engine.py", - "llm/model_runner/model_runner.py", - "llm/scheduler.py", - "llm/request.py", - "llm/llm.py", - "processors/basic_llm_processor.py", - "config/engine_config.py", - "base_config.py", - ) - ], - ] - } - if args.legacy_chunk_output: - # Experimental control: use the same binary and schedule, restoring - # logits/sampling/token transfer for intermediate chunks only. - forward = engine.engine.model_runner.model_engine.forward - - def legacy_forward(**kwargs): - intermediate = kwargs.get("prefill_only", False) - if intermediate: - kwargs["prefill_only"] = False - result = forward(**kwargs) - if intermediate: - result.to_numpy().tolist() - return result - - engine.engine.model_runner.model_engine.forward = legacy_forward - - corpus = engine.engine.tokenizer.encode( - " ".join(SOURCE_TEXTS), add_special_tokens=False - ) - corpus = [x for x in corpus if x not in engine.engine.tokenizer.all_special_ids] - prompts = { - name: random.Random(seed).choices(corpus, k=length) - for name, seed, length in [ - ("active", 51, 128), - ("long", 52, args.long_tokens), - ("late", 53, 128), - ] - } - prompts["warmup"] = corpus[:128] - steps = [] - original = engine.engine.model_runner.execute_model - - def execute(output): - row = dict( - start=time.perf_counter(), - prefill=output.is_prefill, - requests=[ - dict( - id=r.request_id, - slots=len(r.slot_mapping), - cached=r.num_local_cached_tokens, - computed=r.num_computed_tokens, - generated=len(r.generated_token_ids), - ) - for r in output.scheduled_requests - ], - ) - result = original(output) - row["end"] = time.perf_counter() - steps.append(row) - return result - - engine.engine.model_runner.execute_model = execute - results = {} - active_ready = asyncio.Event() - - async def collect(name, count): - started = time.perf_counter() - request = engine.add_request( - messages=None, - prompt_token_ids=prompts[name], - request_id=name, - sampling_params=SamplingParams( - top_k=1, max_tokens=count, ignore_eos=True - ), - ) - times = [] - ids = [] - async for output in engine.stream_request(request): - if output.token_id >= 0: - times.append(time.perf_counter()) - ids.append(output.token_id) - if name == "active" and len(ids) == 8: - active_ready.set() - assert len(ids) == count, (name, len(ids), count) - gaps = [b - a for a, b in zip(times, times[1:])] - results[name] = dict( - start=started, - times=times, - token_ids=ids, - ttft=times[0] - started, - itl_median=statistics.median(gaps), - itl_max=max(gaps), - itl_p95=sorted(gaps)[int(0.95 * (len(gaps) - 1))], - status=str(request.status), - ) - - engine.start() - tasks = [] - try: - # A separate warmup does not populate the test prefix cache. - await asyncio.wait_for(collect("warmup", 8), 30) - results.pop("warmup") - prompts.pop("warmup") - steps.clear() - tasks.append(asyncio.create_task(collect("active", 128))) - await asyncio.wait_for(active_ready.wait(), 30) - tasks.append(asyncio.create_task(collect("long", 16))) - await asyncio.sleep(0.02) - tasks.append(asyncio.create_task(collect("late", 16))) - await asyncio.wait_for(asyncio.gather(*tasks), 90) - finally: - for task in tasks: - if not task.done(): - task.cancel() - await asyncio.gather(*tasks, return_exceptions=True) - if len(results) == 3: - assert all( - b.ref_count == 0 - for b in engine.engine.scheduler.cache_manager.blocks - ) - return dict( - status="success", - chunk_size=args.chunk_size, - legacy_chunk_output=args.legacy_chunk_output, - config=config, - provenance=evidence, - source_hashes=hashes, - prompts=prompts, - results=results, - steps=steps, - ) - finally: - if engine._running: - engine.stop() - else: - # stop() skips close after a failed worker or before start(). - engine.engine.close() - if engine._step_thread is not None: - assert not engine._step_thread.is_alive() - - -if __name__ == "__main__": - parser = argparse.ArgumentParser() - parser.add_argument("--model", required=True) - parser.add_argument("--chunk-size", type=int, default=0) - parser.add_argument("--tp", type=int, default=2) - parser.add_argument("--long-tokens", type=int, default=8192) - parser.add_argument("--graph", action="store_true") - parser.add_argument("--policy", choices=("lru", "slru"), default="lru") - parser.add_argument( - "--legacy-chunk-output", - action="store_true", - help="benchmark control: compute and discard intermediate outputs", - ) - parser.add_argument("--output", type=Path, required=True) - args = parser.parse_args() - try: - payload = asyncio.run(run(args)) - except Exception as error: - args.output.write_text( - json.dumps(dict(status="failure", error=repr(error)), indent=2) + "\n" - ) - raise - args.output.write_text(json.dumps(payload, indent=2) + "\n") - print( - json.dumps( - { - k: {m: v for m, v in d.items() if m not in ("times", "token_ids")} - for k, d in payload["results"].items() - }, - indent=2, - ) - ) diff --git a/test/llm/benchmark_prefix_cache.py b/test/llm/benchmark_prefix_cache.py deleted file mode 100644 index 11f44f6cb..000000000 --- a/test/llm/benchmark_prefix_cache.py +++ /dev/null @@ -1,928 +0,0 @@ -#!/usr/bin/env python3 -"""Reproducible control-plane and model prefix-cache benchmark.""" - -import argparse -import asyncio -import hashlib -import importlib.util -import json -import os -import platform -import random -import signal -import statistics -import subprocess -import sys -import tempfile -import time -import tracemalloc -from pathlib import Path -from types import ModuleType - -BLOCK_SIZE = 256 -OUTPUT_TOKENS = 32 -REQUEST_COUNT = 128 -WARMUP_REQUESTS = 16 -SOURCE_TEXTS = ( - "You are a careful technical assistant. Follow the system instructions and " - "answer with precise, verifiable details. ", - "Paged key value caches store completed attention states in fixed sized blocks. " - "Prefix reuse avoids repeated prefill computation when requests share input. ", - "Explain how deterministic experiments separate control plane overhead from " - "model execution and why complete traces are needed for reproducibility. ", -) - - -def _percentile(values, percentile): - ordered = sorted(values) - if not ordered: - return None - rank = (len(ordered) - 1) * percentile - low = int(rank) - high = min(low + 1, len(ordered) - 1) - return ordered[low] + (ordered[high] - ordered[low]) * (rank - low) - - -def _distribution(values): - return { - "median_ns": statistics.median(values) if values else None, - "p95_ns": _percentile(values, 0.95), - "valid_samples": len(values), - } - - -def _file_sha256(path): - return hashlib.sha256(Path(path).read_bytes()).hexdigest() - - -def build_request_timing(request_id, prompt_tokens, started, token_ids, token_times): - if not token_ids or len(token_ids) != len(token_times): - raise RuntimeError( - "The request produced no token IDs or mismatched timestamps." - ) - return { - "request_id": request_id, - "prompt_tokens": prompt_tokens, - "output_token_ids": list(token_ids), - "ttft_seconds": token_times[0] - started, - "delivery_intervals_seconds": [ - later - earlier for earlier, later in zip(token_times, token_times[1:]) - ], - "latency_seconds": token_times[-1] - started, - } - - -def summarize_requests(requests, wall_seconds): - generated = sum(len(item["output_token_ids"]) for item in requests) - if wall_seconds <= 0: - raise ValueError("wall_seconds must be positive") - return { - "request_count": len(requests), - "generated_tokens": generated, - "wall_seconds": wall_seconds, - "throughput_tokens_per_second": generated / wall_seconds, - "ttft_median_seconds": statistics.median( - item["ttft_seconds"] for item in requests - ), - } - - -class SchedulerAccounting: - """Record cache work only for requests returned as scheduled prefill work.""" - - def __init__(self, scheduler): - self.scheduler = scheduler - self.original = scheduler.schedule - self.by_request = {} - self.admission_counts = {} - self.max_observed_block_ref_count = 0 - - def install(self): - def measured_schedule(): - output = self.original() - if output is not None and output.is_prefill: - for request in output.scheduled_requests: - self.admission_counts[request.request_id] = ( - self.admission_counts.get(request.request_id, 0) + 1 - ) - self.by_request[request.request_id] = { - "local_cached_tokens": request.num_local_cached_tokens, - "prefill_tokens": len(request.slot_mapping), - } - manager = getattr(self.scheduler, "cache_manager", None) - for block_id in getattr(request, "block_table", ()): - self.max_observed_block_ref_count = max( - self.max_observed_block_ref_count, - manager.blocks[block_id].ref_count if manager else 0, - ) - return output - - self.scheduler.schedule = measured_schedule - - def restore(self): - self.scheduler.schedule = self.original - - def require_exactly_once(self, request_ids): - missing = [ - request_id - for request_id in request_ids - if self.admission_counts.get(request_id, 0) == 0 - ] - duplicates = [ - request_id - for request_id in request_ids - if self.admission_counts.get(request_id, 0) > 1 - ] - if missing or duplicates: - raise RuntimeError( - "Invalid admitted-prefill accounting: " - f"missing={missing}, duplicate={duplicates}" - ) - - -def _expanded_tokens(token_sources, minimum=4096): - tokens = [token for source in token_sources for token in source] - if not tokens: - raise ValueError("Tokenizer produced no ordinary token IDs") - return (tokens * (minimum // len(tokens) + 2))[:minimum] - - -def _family(corpus, seed, family_index, length): - # A family-local PRNG composes only real corpus IDs while avoiding periodic - # rotations when the source text contains fewer than 128 distinct positions. - rng = random.Random(f"prefix-family-{seed}-{family_index}") - return [corpus[rng.randrange(len(corpus))] for _ in range(length)] - - -def build_trace(scenario, token_sources, seed, request_count=REQUEST_COUNT): - rng = random.Random(seed) - corpus = _expanded_tokens(token_sources) - - def tail(index): - start = (2048 + index * 37) % len(corpus) - return _family(corpus, seed, f"tail-{start}", 32) - - requests = [] - for index in range(request_count): - phase = "measurement" - group = None - if scenario == "hot-cold": - family_index = 0 if index % 4 < 3 else index + 1 - prefix_length = 1024 - group = "hot" if family_index == 0 else "cold" - elif scenario == "hot-shift": - family_index = 0 if index < 64 else 1 - prefix_length = 1024 - if index >= 64: - group = "post_shift_first_16" if index < 80 else "post_shift_stable" - else: - group = "prefix_a" - elif scenario == "no-reuse": - family_index = index + rng.randrange(1, 1 << 30) * request_count - prefix_length = 1024 - elif scenario == "over-capacity": - family_index = index % 32 - prefix_length = 1024 - elif scenario == "mixed-length": - prefix_length = (256, 1024, 2048)[index % 3] - family_index = index % 4 - group = str(prefix_length) - elif scenario == "shared": - family_index = 0 - prefix_length = 1024 - group = f"batch-{index // 4}" - else: - raise ValueError(f"Unknown scenario: {scenario}") - prompt = _family(corpus, seed, family_index, prefix_length) + tail(index) - if index < WARMUP_REQUESTS: - phase = "warmup" - requests.append( - { - "request_id": f"request-{index:03d}", - "phase": phase, - "group": group, - "prompt_token_ids": prompt, - } - ) - return requests - - -def _load_block_manager(): - """Load metadata modules without importing infinilm or native extensions.""" - source = Path(__file__).resolve().parents[2] / "python/infinilm/llm" - saved = dict(sys.modules) - try: - infinilm = ModuleType("infinilm") - llm = ModuleType("infinilm.llm") - infinilm.__path__ = [] - llm.__path__ = [] - sys.modules["infinilm"] = infinilm - sys.modules["infinilm.llm"] = llm - for name in ("prefix_cache", "cache_manager"): - fullname = f"infinilm.llm.{name}" - spec = importlib.util.spec_from_file_location( - fullname, source / f"{name}.py" - ) - module = importlib.util.module_from_spec(spec) - sys.modules[fullname] = module - spec.loader.exec_module(module) - return sys.modules["infinilm.llm.cache_manager"].BlockManager - finally: - loaded = sys.modules.get("infinilm.llm.cache_manager") - sys.modules.clear() - sys.modules.update(saved) - if loaded is not None: - sys.modules["_benchmark_cache_manager"] = loaded - - -def _filled_manager(block_manager, num_blocks, pinned_ratio): - manager = block_manager(num_blocks, BLOCK_SIZE) - table, _ = manager.allocate_slots(num_blocks * BLOCK_SIZE) - hashes = [index.to_bytes(16, "little") for index in range(1, num_blocks + 1)] - manager.publish_computed_blocks(table, hashes, 0, num_blocks * BLOCK_SIZE) - manager.free_blocks(table) - pinned = [] - for block_hash in hashes[: int(num_blocks * pinned_ratio)]: - blocks, _ = manager.get_computed_blocks([block_hash], BLOCK_SIZE) - pinned.extend(blocks) - return manager, hashes, pinned - - -def _policy_examples(block_manager): - manager = block_manager(3, BLOCK_SIZE) - hashes = [] - for value in (11, 22, 33): - table, _ = manager.allocate_slots(BLOCK_SIZE) - block_hash = bytes([value]) * 16 - manager.publish_computed_blocks(table, [block_hash], 0, BLOCK_SIZE) - manager.free_blocks(table) - hashes.append(block_hash) - touched, _ = manager.get_computed_blocks([hashes[0]], BLOCK_SIZE) - manager.free_blocks(touched) - manager.allocate_slots(BLOCK_SIZE) - survivor_hits = [] - for block_hash in hashes: - pinned, hit = manager.get_computed_blocks([block_hash], BLOCK_SIZE) - survivor_hits.append(hit) - if pinned: - manager.free_blocks(pinned) - - manager = block_manager(2, BLOCK_SIZE) - table, _ = manager.allocate_slots(2 * BLOCK_SIZE) - tail_hashes = [b"prefix".ljust(16, b"0"), b"tail".ljust(16, b"0")] - manager.publish_computed_blocks(table, tail_hashes, 0, 2 * BLOCK_SIZE) - manager.free_blocks(table) - manager.allocate_slots(BLOCK_SIZE) - _, prefix_hit = manager.get_computed_blocks(tail_hashes, 2 * BLOCK_SIZE) - return { - "recent_reuse": {"hit_tokens_after_pressure": survivor_hits}, - "tail_first": {"prefix_hit_tokens_after_pressure": prefix_hit}, - } - - -def run_metadata(args): - block_manager = _load_block_manager() - cases = [] - for pinned_ratio in (0.0, 0.5, 0.9): - manager, _, _ = _filled_manager(block_manager, args.num_blocks, pinned_ratio) - usable_times = [] - for _ in range(args.repeat): - started = time.perf_counter_ns() - usable = manager.get_total_usable_blocks() - usable_times.append(time.perf_counter_ns() - started) - reclaim = {} - for count in (1, 8, 64): - elapsed = [] - outcomes = [] - reclaimed_counts = [] - for _ in range(args.repeat): - sample, _, _ = _filled_manager( - block_manager, args.num_blocks, pinned_ratio - ) - free_before = sample.get_num_free_blocks() - started = time.perf_counter_ns() - outcome = sample.try_free_blocks(count) - ended = time.perf_counter_ns() - outcomes.append(outcome) - elapsed.append(ended - started) - reclaimed_counts.append(sample.get_num_free_blocks() - free_before) - reclaim[str(count)] = { - **_distribution(elapsed), - "successful_samples": sum(outcomes), - "requested_blocks": count, - "actual_reclaimed_blocks": reclaimed_counts, - "capacity_failure": any(actual < count for actual in reclaimed_counts), - } - cases.append( - { - "pinned_ratio": pinned_ratio, - "usable_blocks": usable, - "usable_query": _distribution(usable_times), - "reclaim": reclaim, - } - ) - - tracemalloc.start() - before_current, _ = tracemalloc.get_traced_memory() - memory_manager, _, _ = _filled_manager(block_manager, args.num_blocks, 0.0) - current, peak = tracemalloc.get_traced_memory() - tracemalloc.stop() - del memory_manager - return { - "schema_version": 1, - "mode": "metadata", - "status": "success", - "python_version": platform.python_version(), - "num_blocks": args.num_blocks, - "block_size": BLOCK_SIZE, - "repeat": args.repeat, - "seed": args.seed, - "harness_sha256": _file_sha256(__file__), - "cache_manager_source_sha256": _file_sha256( - Path(__file__).resolve().parents[2] / "python/infinilm/llm/cache_manager.py" - ), - "cache_manager_source": str( - Path(__file__).resolve().parents[2] / "python/infinilm/llm/cache_manager.py" - ), - "timing_scope": "method call only; pool construction, hashing, and JSON excluded", - "memory_scope": "Python tracemalloc peak increment; GPU memory excluded", - "pool_peak_increment_bytes": peak - before_current, - "pool_current_increment_bytes": current - before_current, - "policy_examples": _policy_examples(block_manager), - "cases": cases, - } - - -def _git_sha(path): - try: - return subprocess.check_output( - ["git", "-C", str(path), "rev-parse", "HEAD"], text=True - ).strip() - except (OSError, subprocess.CalledProcessError): - return None - - -def _git_root_for(path): - candidate = Path(path).resolve() - if candidate.is_file(): - candidate = candidate.parent - for parent in (candidate, *candidate.parents): - if (parent / ".git").exists(): - return parent - return None - - -def _dtype_name(value): - if value is None: - return None - return getattr(value, "name", None) or str(value) - - -def _load_json_provenance(environment_name, description): - value = os.environ.get(environment_name) - if not value: - raise RuntimeError(f"{environment_name} must point to {description}") - path = Path(value).resolve() - manifest = json.loads(path.read_text()) - return { - "source": str(path), - "sha256": _file_sha256(path), - "manifest": manifest, - } - - -def load_build_provenance(): - provenance = _load_json_provenance( - "INFINILM_BUILD_PROVENANCE", "the native build provenance manifest" - ) - manifest = provenance["manifest"] - for project in ("infinicore", "infinilm"): - if not isinstance(manifest.get(project), dict) or not manifest[project].get( - "git_sha" - ): - raise RuntimeError(f"Build provenance is missing {project}.git_sha") - return provenance - - -def _validate_native_artifact(build_provenance, binary_path): - binary_path = Path(binary_path).resolve() - actual_sha = _file_sha256(binary_path) - matching = [ - artifact - for artifact in build_provenance["manifest"].get("artifacts", []) - if Path(artifact.get("path", "")).name == binary_path.name - ] - if len(matching) != 1 or matching[0].get("sha256") != actual_sha: - raise RuntimeError( - f"Build manifest does not uniquely match imported binary {binary_path}" - ) - return { - "path": str(binary_path), - "sha256": actual_sha, - "manifest_artifact": matching[0], - } - - -def _provenance(engine, model_path, build_provenance): - import infinicore - import infinicore.lib._infinicore as core_native - from infinilm.lib import _infinilm as lm_native - - model_engine = engine.engine.model_runner.model_engine - if model_engine.dtype != infinicore.float16: - raise RuntimeError( - f"Effective model dtype is {_dtype_name(model_engine.dtype)}, not float16" - ) - nested_cache = model_engine.get_kv_cache() - cache_tensors = [tensor for layer in nested_cache for tensor in layer] - if not cache_tensors: - raise RuntimeError("Native model engine returned no KV cache tensors") - invalid_cache_dtypes = { - _dtype_name(tensor.dtype) - for tensor in cache_tensors - if tensor.dtype != infinicore.float16 - } - if invalid_cache_dtypes: - raise RuntimeError( - f"Effective KV cache tensor dtypes are not float16: {invalid_cache_dtypes}" - ) - effective_dtype = _dtype_name(model_engine.dtype) - model_provenance = _load_json_provenance( - "INFINILM_MODEL_PROVENANCE", "the model revision/overlay manifest" - ) - overlays = model_provenance["manifest"] - overlay = next( - (item for item in overlays if item.get("experiment_path") == str(model_path)), - None, - ) - if overlay is None or not overlay.get("repo_id") or not overlay.get("revision"): - raise RuntimeError( - "INFINILM_MODEL_PROVENANCE must name a manifest containing this " - "experiment_path, repo_id, and immutable revision" - ) - try: - gpu = ( - subprocess.check_output( - [ - "nvidia-smi", - "--query-gpu=name,driver_version", - "--format=csv,noheader", - ], - text=True, - ) - .strip() - .splitlines() - ) - except (OSError, subprocess.CalledProcessError): - gpu = [] - core_source = Path(infinicore.__file__).resolve() - core_root = _git_root_for(core_source) - core_binary = _validate_native_artifact(build_provenance, core_native.__file__) - lm_binary = _validate_native_artifact(build_provenance, lm_native.__file__) - try: - cuda_toolkit = subprocess.check_output(["nvcc", "--version"], text=True).strip() - except (OSError, subprocess.CalledProcessError): - cuda_toolkit = None - return { - "infinilm_sha": _git_sha(Path(__file__).resolve().parents[2]), - "infinicore_sha": os.environ.get("INFINICORE_GIT_SHA") - or (_git_sha(core_root) if core_root else None), - "imported_infinicore_source": str(core_source), - "infinicore_native_binary": core_binary, - "infinilm_native_binary": lm_binary, - "imported_llm_source": sys.modules["infinilm.llm.llm"].__file__, - "harness_sha256": _file_sha256(__file__), - "imported_llm_source_sha256": _file_sha256( - sys.modules["infinilm.llm.llm"].__file__ - ), - "model_path": str(model_path), - "model_id": overlay.get("repo_id") if overlay else None, - "model_revision": overlay.get("revision") if overlay else None, - "fp16_overlay": overlay, - "model_provenance": model_provenance, - "build_provenance": build_provenance, - "effective_model_dtype": effective_dtype, - "effective_kv_cache_dtype": _dtype_name(cache_tensors[0].dtype), - "effective_kv_cache_tensor_count": len(cache_tensors), - "gpu_and_driver": gpu, - "cuda_toolkit": cuda_toolkit, - "cuda_visible_devices": os.environ.get("CUDA_VISIBLE_DEVICES"), - "build_environment": { - key: value - for key, value in os.environ.items() - if key.startswith(("INFINI", "CUDA", "LD_LIBRARY_PATH")) - }, - } - - -async def _collect(engine, item, sampling_params): - started = time.perf_counter() - request = engine.add_request( - messages=None, - prompt_token_ids=item["prompt_token_ids"], - sampling_params=sampling_params, - request_id=item["request_id"], - ) - times = [] - token_ids = [] - async for output in engine.stream_request(request): - if output.token_id >= 0: - times.append(time.perf_counter()) - token_ids.append(output.token_id) - result = build_request_timing( - item["request_id"], len(item["prompt_token_ids"]), started, token_ids, times - ) - result.update({"phase": item["phase"], "group": item["group"]}) - return result - - -async def _run_model_async(args): - from infinilm.llm.llm import AsyncLLMEngine - from infinilm.llm.sampling_params import SamplingParams - - build_provenance = load_build_provenance() - engine = None - accounting = None - trace = None - trace_sha256 = None - provenance = None - results = [] - measured_started = None - engine_started = False - try: - engine = AsyncLLMEngine( - model_path=args.model, - device="cuda", - dtype="float16", - cache_type="paged", - enable_graph=False, - attn_backend="paged-attn", - tensor_parallel_size=1, - block_size=BLOCK_SIZE, - num_blocks=args.num_blocks, - max_batch_size=4, - max_tokens=OUTPUT_TOKENS, - enable_prefix_caching=args.prefix_cache == "on", - ) - provenance = _provenance(engine, Path(args.model), build_provenance) - accounting = SchedulerAccounting(engine.engine.scheduler) - accounting.install() - tokenizer = engine.engine.tokenizer - token_sources = [] - for text in SOURCE_TEXTS: - try: - ids = tokenizer.encode(text, add_special_tokens=False) - except TypeError: - ids = tokenizer.encode(text) - special_ids = set(getattr(tokenizer, "all_special_ids", ())) - token_sources.append( - [token_id for token_id in ids if token_id not in special_ids] - ) - trace = build_trace(args.scenario, token_sources, args.seed) - trace_bytes = json.dumps(trace, sort_keys=True, separators=(",", ":")).encode() - trace_sha256 = hashlib.sha256(trace_bytes).hexdigest() - _write_json( - args.output, - { - "schema_version": 1, - "mode": "model", - "status": "running", - "repeat": args.child_repeat, - "config": _model_config(args), - "trace_sha256": trace_sha256, - "trace": trace, - "requests": [], - "provenance": provenance, - }, - ) - sampling = SamplingParams(top_k=1, max_tokens=OUTPUT_TOKENS, ignore_eos=True) - engine.start() - engine_started = True - for offset in range(0, len(trace), args.concurrency): - if offset == WARMUP_REQUESTS: - for result in results: - result.update(accounting.by_request[result["request_id"]]) - _write_json( - args.output, - { - "schema_version": 1, - "mode": "model", - "status": "running", - "repeat": args.child_repeat, - "config": _model_config(args), - "checkpoint": "warmup_complete", - "trace_sha256": trace_sha256, - "trace": trace, - "requests": results, - "provenance": provenance, - }, - ) - measured_started = time.perf_counter() - batch = trace[offset : offset + args.concurrency] - results.extend( - await asyncio.gather( - *(_collect(engine, item, sampling) for item in batch) - ) - ) - wall_seconds = time.perf_counter() - measured_started - accounting.require_exactly_once([item["request_id"] for item in trace]) - # A separate normal-EOS request verifies that the smoke path does not rely on - # ignore_eos semantics. Its output is excluded from performance accounting. - eos_item = { - "request_id": "correctness-eos", - "prompt_token_ids": trace[-1]["prompt_token_ids"], - "phase": "correctness", - "group": None, - } - eos_result = await _collect( - engine, - eos_item, - SamplingParams(top_k=1, max_tokens=OUTPUT_TOKENS, ignore_eos=False), - ) - for result in results: - result.update(accounting.by_request[result["request_id"]]) - if len(result["output_token_ids"]) != OUTPUT_TOKENS: - raise RuntimeError( - f"{result['request_id']} delivered " - f"{len(result['output_token_ids'])} tokens; expected {OUTPUT_TOKENS}" - ) - measured = [item for item in results if item["phase"] == "measurement"] - aggregate = summarize_requests(measured, wall_seconds) - aggregate["local_cached_tokens"] = sum( - item["local_cached_tokens"] for item in measured - ) - aggregate["prefill_tokens"] = sum(item["prefill_tokens"] for item in measured) - aggregate["max_observed_block_ref_count"] = ( - accounting.max_observed_block_ref_count - ) - if ( - args.scenario == "shared" - and args.prefix_cache == "on" - and accounting.max_observed_block_ref_count < 2 - ): - raise RuntimeError( - "Shared scenario did not observe concurrent cached-block owners" - ) - groups = {} - for group in sorted({item["group"] for item in measured if item["group"]}): - selected = [item for item in measured if item["group"] == group] - groups[group] = { - "request_count": len(selected), - "ttft_median_seconds": statistics.median( - item["ttft_seconds"] for item in selected - ), - } - return { - "schema_version": 1, - "mode": "model", - "status": "success", - **_model_config(args), - "trace_sha256": trace_sha256, - "trace": trace, - "requests": results, - "correctness_eos_request": eos_result, - "aggregate": aggregate, - "groups": groups, - "provenance": provenance, - } - except Exception as error: - for result in results: - if accounting and result["request_id"] in accounting.by_request: - result.update(accounting.by_request[result["request_id"]]) - return { - "schema_version": 1, - "mode": "model", - "status": "failure", - **_model_config(args), - "error_type": type(error).__name__, - "error": str(error), - "trace_sha256": trace_sha256, - "trace": trace, - "requests": results, - "provenance": provenance, - "build_provenance": build_provenance, - } - finally: - if accounting is not None: - accounting.restore() - if engine_started: - engine.stop() - - -def _model_config(args): - return { - "model": args.model, - "prefix_cache": args.prefix_cache, - "scenario": args.scenario, - "concurrency": args.concurrency, - "num_blocks": args.num_blocks, - "block_size": BLOCK_SIZE, - "seed": args.seed, - "repeat": args.repeat, - "repeat_timeout_seconds": args.repeat_timeout_seconds, - "warmup_requests": WARMUP_REQUESTS, - "attention_backend": "paged-attn", - "engine_config": { - "device": "cuda", - "requested_dtype": "float16", - "cache_type": "paged", - "enable_graph": False, - "tensor_parallel_size": 1, - "max_batch_size": 4, - "max_tokens": OUTPUT_TOKENS, - }, - } - - -def _write_json(path, payload): - Path(path).write_text(json.dumps(payload, indent=2) + "\n") - - -def assemble_repeat_artifact(records, config, build_provenance=None): - successful = sum(record["status"] == "success" for record in records) - failed = len(records) - successful - return { - "schema_version": 1, - "mode": "model", - "status": "failure" if failed else "success", - "config": config, - "build_provenance": build_provenance, - "successful_repeats": successful, - "failed_repeats": failed, - "repeats": records, - } - - -def _read_child_payload(path): - try: - return json.loads(Path(path).read_text()) - except (OSError, json.JSONDecodeError): - return None - - -def _run_child(command, timeout_seconds): - process = subprocess.Popen( - command, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - start_new_session=True, - ) - try: - stdout, stderr = process.communicate(timeout=timeout_seconds) - return process.returncode, stdout, stderr, False - except subprocess.TimeoutExpired: - os.killpg(process.pid, signal.SIGKILL) - stdout, stderr = process.communicate() - return None, stdout, stderr, True - - -def _run_model_repeats(args): - if args.child_repeat is not None: - return asyncio.run(_run_model_async(args)) - try: - build_provenance = load_build_provenance() - except Exception as error: - records = [ - { - "repeat": index, - "status": "failure", - "error_type": type(error).__name__, - "error": str(error), - } - for index in range(args.repeat) - ] - return assemble_repeat_artifact(records, _model_config(args)) - records = [] - with tempfile.TemporaryDirectory(prefix="infinilm-prefix-benchmark-") as directory: - for index in range(args.repeat): - child_output = Path(directory) / f"repeat-{index}.json" - command = [ - sys.executable, - str(Path(__file__).resolve()), - "--mode", - "model", - "--output", - str(child_output), - "--seed", - str(args.seed), - "--repeat", - "1", - "--num-blocks", - str(args.num_blocks), - "--model", - args.model, - "--prefix-cache", - args.prefix_cache, - "--scenario", - args.scenario, - "--concurrency", - str(args.concurrency), - "--repeat-timeout-seconds", - str(args.repeat_timeout_seconds), - "--child-repeat", - str(index), - ] - try: - returncode, stdout, stderr, timed_out = _run_child( - command, args.repeat_timeout_seconds - ) - except OSError as error: - records.append( - { - "repeat": index, - "status": "failure", - "error_type": type(error).__name__, - "error": str(error), - } - ) - continue - payload = _read_child_payload(child_output) - if timed_out: - record = { - "repeat": index, - "status": "timeout", - "timeout_seconds": args.repeat_timeout_seconds, - "stdout": stdout, - "stderr": stderr, - } - if payload is not None: - record["payload"] = payload - elif returncode or payload is None or payload.get("status") != "success": - record = { - "repeat": index, - "status": "failure", - "returncode": returncode, - "stdout": stdout, - "stderr": stderr, - } - if payload is not None: - record["payload"] = payload - else: - record = {"repeat": index, "status": "success", "payload": payload} - records.append(record) - return assemble_repeat_artifact( - records, _model_config(args), build_provenance=build_provenance - ) - - -def parse_args(argv=None): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--mode", choices=("metadata", "model"), required=True) - parser.add_argument("--output", type=Path, required=True) - parser.add_argument("--seed", type=int, required=True) - parser.add_argument("--repeat", type=int, required=True) - parser.add_argument("--num-blocks", type=int, required=True) - parser.add_argument("--model") - parser.add_argument("--prefix-cache", choices=("on", "off")) - parser.add_argument( - "--scenario", - choices=( - "hot-cold", - "hot-shift", - "no-reuse", - "over-capacity", - "mixed-length", - "shared", - ), - ) - parser.add_argument("--concurrency", type=int, choices=(1, 4)) - parser.add_argument("--repeat-timeout-seconds", type=float, default=3600.0) - parser.add_argument("--child-repeat", type=int, help=argparse.SUPPRESS) - args = parser.parse_args(argv) - if args.repeat <= 0 or args.num_blocks <= 0 or args.repeat_timeout_seconds <= 0: - parser.error( - "--repeat, --num-blocks, and --repeat-timeout-seconds must be positive" - ) - if args.mode == "model" and not all( - (args.model, args.prefix_cache, args.scenario, args.concurrency) - ): - parser.error( - "model mode requires --model, --prefix-cache, --scenario, and --concurrency" - ) - return args - - -def main(argv=None): - args = parse_args(argv) - args.output.parent.mkdir(parents=True, exist_ok=True) - try: - result = ( - run_metadata(args) if args.mode == "metadata" else _run_model_repeats(args) - ) - except Exception as error: - result = { - "schema_version": 1, - "mode": args.mode, - "status": "failure", - "error_type": type(error).__name__, - "error": str(error), - } - _write_json(args.output, result) - raise - _write_json(args.output, result) - return 0 if result.get("status") == "success" else 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/test/llm/test_benchmark_prefix_cache.py b/test/llm/test_benchmark_prefix_cache.py deleted file mode 100644 index 128c825fb..000000000 --- a/test/llm/test_benchmark_prefix_cache.py +++ /dev/null @@ -1,230 +0,0 @@ -import importlib.util -import sys -import unittest -from pathlib import Path - -from cache_test_support import MODULES, publish - -SCRIPT = Path(__file__).with_name("benchmark_prefix_cache.py") -SPEC = importlib.util.spec_from_file_location("benchmark_prefix_cache", SCRIPT) -BENCHMARK = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(BENCHMARK) -Scheduler = MODULES["scheduler"].Scheduler -InferenceRequest = MODULES["request"].InferenceRequest -SamplingParams = MODULES["sampling_params"].SamplingParams - - -class ResultAccountingTests(unittest.TestCase): - def test_throughput_uses_total_tokens_over_shared_wall_clock(self): - requests = [ - {"output_token_ids": list(range(32)), "ttft_seconds": 0.1}, - {"output_token_ids": list(range(32)), "ttft_seconds": 0.2}, - ] - - summary = BENCHMARK.summarize_requests(requests, wall_seconds=2.0) - - self.assertEqual(summary["generated_tokens"], 64) - self.assertEqual(summary["throughput_tokens_per_second"], 32.0) - - def test_delivery_interval_is_the_gap_between_actual_token_times(self): - clock = iter((10.0, 10.1, 10.15)) - - result = BENCHMARK.build_request_timing( - request_id="request-0", - prompt_tokens=1056, - started=10.0, - token_ids=[41, 42], - token_times=[next(clock), next(clock), next(clock)][1:], - ) - - self.assertAlmostEqual(result["ttft_seconds"], 0.1) - self.assertAlmostEqual(result["delivery_intervals_seconds"][0], 0.05) - self.assertEqual(result["output_token_ids"], [41, 42]) - - -class SchedulerAccountingTests(unittest.TestCase): - def make_scheduler(self, **kwargs): - scheduler = Scheduler(block_size=256, **kwargs) - self.addCleanup(scheduler.waiting_queue.close) - self.addCleanup(scheduler.running_queue.close) - return scheduler - - @staticmethod - def request(request_id, tokens, max_tokens=1): - return InferenceRequest( - request_id, - prompt_token_ids=tokens, - sampling_params=SamplingParams(max_tokens=max_tokens), - ) - - def test_aligned_and_one_token_tail_report_scheduler_work(self): - for prompt_length, expected in ((1024, (768, 256)), (1025, (1024, 1))): - with self.subTest(prompt_length=prompt_length): - scheduler = self.make_scheduler(num_blocks=16) - publish(scheduler.cache_manager, [11] * 1024) - request = self.request("reuse", [11] * prompt_length) - scheduler.add_request(request) - accounting = BENCHMARK.SchedulerAccounting(scheduler) - accounting.install() - - scheduler.schedule() - accounting.require_exactly_once([request.request_id]) - - self.assertEqual( - accounting.by_request[request.request_id], - { - "local_cached_tokens": expected[0], - "prefill_tokens": expected[1], - }, - ) - - def test_no_reuse_reports_full_prefill(self): - scheduler = self.make_scheduler(num_blocks=16) - publish(scheduler.cache_manager, [11] * 1024) - request = self.request("new", [22] * 1025) - scheduler.add_request(request) - accounting = BENCHMARK.SchedulerAccounting(scheduler) - accounting.install() - - scheduler.schedule() - accounting.require_exactly_once([request.request_id]) - - self.assertEqual( - accounting.by_request[request.request_id], - {"local_cached_tokens": 0, "prefill_tokens": 1025}, - ) - - def test_budget_rejected_probe_is_excluded(self): - scheduler = self.make_scheduler( - num_blocks=16, max_batch_size=2, max_num_batched_tokens=1024 - ) - publish(scheduler.cache_manager, [11] * 1024) - first = self.request("first", [11] * 1025) - rejected = self.request("budget-rejected", [11] * 1024 + [22] * 1024) - scheduler.add_request(first) - scheduler.add_request(rejected) - accounting = BENCHMARK.SchedulerAccounting(scheduler) - accounting.install() - - scheduler.schedule() - - self.assertEqual(set(accounting.by_request), {"first"}) - self.assertNotIn(rejected.request_id, accounting.by_request) - - def test_capacity_rejected_probe_is_excluded(self): - scheduler = self.make_scheduler(num_blocks=2) - publish(scheduler.cache_manager, [11] * 256) - rejected = self.request("capacity-rejected", [11] * 256 + [22], max_tokens=512) - scheduler.add_request(rejected) - accounting = BENCHMARK.SchedulerAccounting(scheduler) - accounting.install() - - self.assertIsNone(scheduler.schedule()) - - self.assertEqual(accounting.by_request, {}) - - def test_missing_or_duplicate_admission_accounting_fails(self): - scheduler = self.make_scheduler(num_blocks=16) - request = self.request("duplicate", [11] * 257) - scheduler.add_request(request) - accounting = BENCHMARK.SchedulerAccounting(scheduler) - accounting.install() - scheduler.schedule() - - with self.assertRaisesRegex(RuntimeError, "missing"): - accounting.require_exactly_once(["unknown"]) - accounting.admission_counts[request.request_id] = 2 - with self.assertRaisesRegex(RuntimeError, "duplicate"): - accounting.require_exactly_once([request.request_id]) - - -class RepeatArtifactTests(unittest.TestCase): - def test_mixed_repeat_outcomes_preserve_every_payload(self): - records = [ - {"repeat": 0, "status": "success", "payload": {"trace_sha256": "abc"}}, - { - "repeat": 1, - "status": "timeout", - "timeout_seconds": 15, - "stdout": "partial output", - "stderr": "", - }, - ] - - result = BENCHMARK.assemble_repeat_artifact( - records, {"scenario": "hot-cold", "repeat_timeout_seconds": 15} - ) - - self.assertEqual(result["status"], "failure") - self.assertEqual(result["repeats"], records) - self.assertEqual(result["successful_repeats"], 1) - self.assertEqual(result["failed_repeats"], 1) - - def test_child_deadline_reports_timeout(self): - returncode, stdout, stderr, timed_out = BENCHMARK._run_child( - [sys.executable, "-c", "import time; time.sleep(10)"], 0.05 - ) - - self.assertTrue(timed_out) - self.assertIsNone(returncode) - self.assertEqual((stdout, stderr), ("", "")) - - -class TraceTests(unittest.TestCase): - def test_no_reuse_requests_have_distinct_first_blocks(self): - token_sources = [list(range(10, 410)), list(range(510, 910))] - - trace = BENCHMARK.build_trace( - "no-reuse", token_sources, seed=7, request_count=128 - ) - - first_blocks = {tuple(item["prompt_token_ids"][:256]) for item in trace} - self.assertEqual(len(first_blocks), 128) - self.assertTrue(all(len(item["prompt_token_ids"]) == 1056 for item in trace)) - - def test_shared_requests_reuse_one_prefix_but_keep_unique_tails(self): - token_sources = [list(range(10, 410)), list(range(510, 910))] - - trace = BENCHMARK.build_trace("shared", token_sources, seed=7, request_count=8) - - self.assertEqual( - len({tuple(item["prompt_token_ids"][:1024]) for item in trace}), 1 - ) - self.assertEqual( - len({tuple(item["prompt_token_ids"][1024:]) for item in trace}), 8 - ) - - def test_seed_is_repeatable_and_changes_every_scenario_trace(self): - token_sources = [list(range(10, 410)), list(range(510, 910))] - scenarios = ( - "hot-cold", - "hot-shift", - "no-reuse", - "over-capacity", - "mixed-length", - "shared", - ) - for scenario in scenarios: - with self.subTest(scenario=scenario): - first = BENCHMARK.build_trace(scenario, token_sources, seed=7) - repeated = BENCHMARK.build_trace(scenario, token_sources, seed=7) - changed = BENCHMARK.build_trace(scenario, token_sources, seed=8) - self.assertEqual(first, repeated) - self.assertNotEqual( - [item["prompt_token_ids"] for item in first], - [item["prompt_token_ids"] for item in changed], - ) - self.assertEqual( - [ - (item["phase"], item["group"], len(item["prompt_token_ids"])) - for item in first - ], - [ - (item["phase"], item["group"], len(item["prompt_token_ids"])) - for item in changed - ], - ) - - -if __name__ == "__main__": - unittest.main() From b509415860be9843388989cecc887e35721116d5 Mon Sep 17 00:00:00 2001 From: tangchengxiang <2064027004@qq.com> Date: Wed, 16 Sep 2026 12:36:33 +0000 Subject: [PATCH 6/9] refactor(engine): reuse native input conversion --- docs/cache-and-chunking.md | 3 +- python/infinilm/infer_engine.py | 46 ----------- test/llm/test_infer_engine_input.py | 113 ++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 47 deletions(-) create mode 100644 test/llm/test_infer_engine_input.py diff --git a/docs/cache-and-chunking.md b/docs/cache-and-chunking.md index c436185ad..99e57486b 100644 --- a/docs/cache-and-chunking.md +++ b/docs/cache-and-chunking.md @@ -88,7 +88,8 @@ in eager mode. PP graphs and TP2 Prefill graphs are excluded. See [test instructions](../test/llm/README.md) for CPU regressions and opt-in native lifecycle checks. Hardware validation covered A6000 Qwen2.5-1.5B FP16 and C500 Qwen3-0.6B/4B BF16. This does not establish support for every dense -architecture or backend. +architecture or backend. Quantized-model chunking has not been validated; +the dense-model check does not reject quantization metadata. Chunking can reduce long output pauses and short-request waiting while reducing throughput and increasing long-request TTFT. Graphs add initialization time and diff --git a/python/infinilm/infer_engine.py b/python/infinilm/infer_engine.py index c672c9a5c..e012bcb91 100644 --- a/python/infinilm/infer_engine.py +++ b/python/infinilm/infer_engine.py @@ -366,52 +366,6 @@ def forward( top_p=None, ): try: - # TODO: Remove `_underlying` and simplify the corresponding code. - input_ids = input_ids._underlying if input_ids is not None else None - position_ids = ( - position_ids._underlying if position_ids is not None else None - ) - past_kv_lengths = ( - past_kv_lengths._underlying if past_kv_lengths is not None else None - ) - total_kv_lengths = ( - total_kv_lengths._underlying if total_kv_lengths is not None else None - ) - input_offsets = ( - input_offsets._underlying if input_offsets is not None else None - ) - block_tables = ( - block_tables._underlying if block_tables is not None else None - ) - cu_seqlens = cu_seqlens._underlying if cu_seqlens is not None else None - slot_mapping = ( - slot_mapping._underlying if slot_mapping is not None else None - ) - mamba_init_state_indices = ( - mamba_init_state_indices._underlying - if mamba_init_state_indices is not None - else None - ) - mamba_final_state_indices = ( - mamba_final_state_indices._underlying - if mamba_final_state_indices is not None - else None - ) - - def convert_tensor_list(tensor_list_): - if tensor_list_ is None: - return None - if not isinstance(tensor_list_, list): - tensor_list_ = [tensor_list_] - if len(tensor_list_) == 0: - return None - return [tensor._underlying for tensor in tensor_list_] - - pixel_values = convert_tensor_list(pixel_values) - image_bound = convert_tensor_list(image_bound) - tgt_sizes = convert_tensor_list(tgt_sizes) - image_grid_thw = convert_tensor_list(image_grid_thw) - output = super().forward( self._build_input( input_ids, diff --git a/test/llm/test_infer_engine_input.py b/test/llm/test_infer_engine_input.py new file mode 100644 index 000000000..252dd627d --- /dev/null +++ b/test/llm/test_infer_engine_input.py @@ -0,0 +1,113 @@ +"""Check the Python/native input boundary without constructing a model.""" + +import sys +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +from config_test_support import load_module + + +class InputConversionTests(unittest.TestCase): + def setUp(self): + self.calls = [] + calls = self.calls + + class NativeEngine: + @staticmethod + def Input(input_ids, **kwargs): + return SimpleNamespace(input_ids=input_ids, **kwargs) + + def forward(self, inputs): + calls.append(inputs) + return SimpleNamespace(output_ids=77, logits=88, hidden_states=99) + + replacements = { + "infinicore": SimpleNamespace(Tensor=lambda value: value), + "infinilm.cache": SimpleNamespace(PagedKVCacheConfig=object), + "infinilm.distributed": SimpleNamespace(DistConfig=object), + "infinilm.lib": SimpleNamespace( + _infinilm=SimpleNamespace(InferEngine=NativeEngine) + ), + "infinilm.exception_utils": SimpleNamespace( + handle_oom_and_exit=lambda e: None + ), + "infinilm.modeling_utils": SimpleNamespace(parse_dtype=object), + } + with patch.dict(sys.modules, replacements): + cls = load_module("infinilm.infer_engine", "infer_engine.py").InferEngine + self.engine = cls.__new__(cls) + + def test_forward_preserves_tensor_metadata_and_output_modes(self): + tensor = SimpleNamespace(_underlying=object()) + for prefill_only in (False, True): + with self.subTest(prefill_only=prefill_only): + output = self.engine.forward( + tensor, + position_ids=tensor, + past_kv_lengths=tensor, + total_kv_lengths=tensor, + input_offsets=tensor, + cu_seqlens=tensor, + block_tables=tensor, + slot_mapping=tensor, + mamba_init_state_indices=tensor, + mamba_final_state_indices=tensor, + target_hidden_states=tensor, + pixel_values=[tensor], + image_bound=tensor, + tgt_sizes=[], + image_grid_thw=None, + image_req_ids=[0], + visual_token_ranges=[(0, 1)], + temperature=0.0, + top_k=3, + top_p=0.9, + prefill_only=prefill_only, + ) + call = self.calls[-1] + for name in ( + "input_ids", + "position_ids", + "past_sequence_lengths", + "total_sequence_lengths", + "input_offsets", + "cu_seqlens", + "block_tables", + "slot_mapping", + "mamba_init_state_indices", + "mamba_final_state_indices", + "target_hidden_states", + ): + self.assertIs(getattr(call, name), tensor._underlying) + self.assertEqual(call.pixel_values, [tensor._underlying]) + self.assertEqual(call.image_bound, [tensor._underlying]) + self.assertIsNone(call.tgt_sizes) + self.assertIsNone(call.image_grid_thw) + self.assertEqual( + (call.image_req_ids, call.visual_token_ranges), ([0], [(0, 1)]) + ) + self.assertEqual( + (call.temperature, call.top_k, call.top_p), (0.0, 3, 0.9) + ) + self.assertEqual(call.prefill_only, prefill_only) + self.assertFalse(call.sample_all_positions) + self.assertEqual(output, None if prefill_only else 77) + + def test_forward_and_raw_keep_default_sampling_and_output_contracts(self): + tensor = SimpleNamespace(_underlying=object()) + self.assertEqual(self.engine.forward(tensor), 77) + self.assertEqual( + self.engine.forward_raw(tensor), + {"output_ids": 77, "logits": 88, "hidden_states": 99}, + ) + for call in self.calls: + self.assertIs(call.input_ids, tensor._underlying) + self.assertEqual((call.temperature, call.top_k, call.top_p), (1.0, 1, 1.0)) + self.assertFalse(call.prefill_only) + self.assertFalse(self.calls[0].sample_all_positions) + self.assertTrue(self.calls[1].sample_all_positions) + + +if __name__ == "__main__": + unittest.main() From 04022fad590ad5da32625e29684babf66cb04639 Mon Sep 17 00:00:00 2001 From: tangchengxiang <2064027004@qq.com> Date: Wed, 16 Sep 2026 12:54:47 +0000 Subject: [PATCH 7/9] refactor(engine): defer experimental prefill graphs --- csrc/engine/compiler/paged_compiler.cpp | 122 ------------------------ csrc/engine/compiler/paged_compiler.hpp | 7 -- csrc/engine/rank_worker.cpp | 4 +- docs/cache-and-chunking.md | 12 +-- 4 files changed, 5 insertions(+), 140 deletions(-) diff --git a/csrc/engine/compiler/paged_compiler.cpp b/csrc/engine/compiler/paged_compiler.cpp index e1906df9c..9700642df 100644 --- a/csrc/engine/compiler/paged_compiler.cpp +++ b/csrc/engine/compiler/paged_compiler.cpp @@ -4,8 +4,6 @@ #include #include -#include -#include #include #include @@ -179,129 +177,9 @@ void PagedCompiler::compile() { compiled_map_decode_[b] = CompiledResult{std::move(input), std::make_tuple(graph, shared_output)}; } } - compile_prefill(); -} - -void PagedCompiler::compile_prefill() { - compiled_map_prefill_.clear(); - prefill_chunk_size_ = 0; - const char *size_env = std::getenv("INFINILM_PREFILL_GRAPH_CHUNK_SIZE"); - if (size_env == nullptr) { - return; - } - // This opt-in experiment deliberately has a smaller support scope than - // the existing Decode compiler. Do not silently capture unsupported paths. - char *end = nullptr; - const auto chunk = std::strtoul(size_env, &end, 10); - const auto *cache = dynamic_cast(model_->get_cache_config()); - const auto &config = infinilm::global_state::get_infinilm_config(); - auto &ctx = infinilm::global_state::get_forward_context(); - if (end == size_env || *end != '\0' || chunk < 2 || chunk > 4096 - || cache == nullptr || chunk > cache->num_blocks() * cache->block_size() - || config.attention_backend != infinilm::backends::AttentionBackend::FLASH_ATTN - || config.use_mla || has_mamba_cache(ctx) - || infinilm::global_state::get_tensor_model_parallel_world_size() != 1 - || infinicore::context::getDevice().getType() != infinicore::Device::Type::METAX) { - throw std::invalid_argument("Experimental Prefill graphs require C500/MetaX, TP1, flash-attn, ordinary paged KV and chunk size 2..4096 within cache capacity"); - } - prefill_chunk_size_ = chunk; - // Flash Attention's scalar maximum is fixed during capture. The actual - // KV length remains a device tensor and can grow across continuation chunks. - prefill_max_sequence_length_ = cache->num_blocks() * cache->block_size(); - auto make_tensor = [](const std::vector &shape, infinicore::DataType dtype) { - return infinicore::Tensor::empty(shape, dtype, infinicore::context::getDevice()); - }; - for (bool intermediate : {true, false}) { - InfinilmModel::Input input; - input.prefill_only = intermediate; - input.input_ids = make_tensor({1, chunk}, infinicore::DataType::I64); - input.position_ids = make_tensor({chunk}, infinicore::DataType::I64); - input.total_sequence_lengths = make_tensor({1}, infinicore::DataType::I32); - input.input_offsets = make_tensor({2}, infinicore::DataType::I32); - input.cu_seqlens = make_tensor({2}, infinicore::DataType::I32); - input.block_tables = make_tensor({1, cache->num_blocks()}, infinicore::DataType::I32); - input.slot_mapping = make_tensor({chunk}, infinicore::DataType::I64); - set_zeros(input.input_ids.value()); - std::vector positions(chunk); - std::iota(positions.begin(), positions.end(), 0); - std::vector pages(cache->num_blocks()); - std::iota(pages.begin(), pages.end(), 0); - const std::vector offsets{0, static_cast(chunk)}; - auto upload = [](infinicore::Tensor dst, const auto &values) { - infinicore::context::memcpyH2D(dst->data(), values.data(), values.size() * sizeof(values[0]), false); - }; - upload(input.position_ids.value(), positions); - upload(input.slot_mapping.value(), positions); - upload(input.block_tables.value(), pages); - upload(input.input_offsets.value(), offsets); - upload(input.cu_seqlens.value(), offsets); - upload(input.total_sequence_lengths.value(), std::vector{static_cast(chunk)}); - ctx.attn_metadata = {input.past_sequence_lengths, input.total_sequence_lengths, - input.input_offsets, input.cu_seqlens, input.block_tables, - input.slot_mapping, chunk, prefill_max_sequence_length_}; - (void)model_->forward(input); - infinicore::context::syncStream(); - model_->reset_runtime_state(); - infinicore::context::syncStream(); - infinicore::context::startGraphRecording(); - auto output = model_->forward(input); - auto graph = infinicore::context::stopGraphRecording(); - auto saved = std::make_shared(); - if (output.logits) { - saved->logits = infinicore::graph::GraphTensor(output.logits); - } - if (output.hidden_states) { - saved->hidden_states = infinicore::graph::GraphTensor(output.hidden_states); - } - compiled_map_prefill_[intermediate] = CompiledResult{std::move(input), {graph, saved}}; - } -} - -PagedCompiler::Compiled PagedCompiler::get_compiled_prefill(const InfinilmModel::Input &input) { - if (prefill_chunk_size_ == 0 || input.input_ids.value()->numel() != prefill_chunk_size_ - || input.block_tables.value()->size(0) != 1 || input.sample_all_positions - || input.mamba_init_state_indices.has_value() || input.pixel_values.has_value()) { - return {nullptr, nullptr}; - } - auto &entry = compiled_map_prefill_.at(input.prefill_only); - auto &target = entry.input; - const auto &lengths = input.total_sequence_lengths.value(); - if (lengths->device().getType() != infinicore::Device::Type::CPU - || lengths->dtype() != infinicore::DataType::I32 || lengths->numel() != 1) { - throw std::invalid_argument("Prefill graph replay requires CPU int32 sequence lengths"); - } - const auto length = *reinterpret_cast(lengths->data()); - const size_t width = input.block_tables.value()->size(1); - if (length < static_cast(prefill_chunk_size_) - || static_cast(length) > prefill_max_sequence_length_ - || width > target.block_tables.value()->size(1) - || input.position_ids.value()->shape() != target.position_ids.value()->shape()) { - return {nullptr, nullptr}; - } - target.input_ids.value()->copy_from(input.input_ids.value()); - target.position_ids.value()->copy_from(input.position_ids.value()); - target.total_sequence_lengths.value()->copy_from(lengths); - target.input_offsets.value()->copy_from(input.input_offsets.value()); - target.cu_seqlens.value()->copy_from(input.cu_seqlens.value()); - target.slot_mapping.value()->copy_from(input.slot_mapping.value()); - set_minus_one_device_async(target.block_tables.value()); - target.block_tables.value()->narrow({{1, 0, width}})->copy_from(input.block_tables.value()); - model_->reset_runtime_state(); - auto saved = std::get<1>(entry.compiled); - auto output = std::make_shared(); - if (saved->logits) { - output->logits = saved->logits->resume_from_blob_(); - } - return {std::get<0>(entry.compiled), output}; } PagedCompiler::Compiled PagedCompiler::get_compiled(const InfinilmModel::Input &input) { - if (prefill_chunk_size_ != 0) { - auto result = get_compiled_prefill(input); - if (std::get<0>(result)) { - return result; - } - } if (input.prefill_only) { return {nullptr, nullptr}; } diff --git a/csrc/engine/compiler/paged_compiler.hpp b/csrc/engine/compiler/paged_compiler.hpp index 3b94c6d8c..a1125864d 100644 --- a/csrc/engine/compiler/paged_compiler.hpp +++ b/csrc/engine/compiler/paged_compiler.hpp @@ -15,12 +15,6 @@ class PagedCompiler : public GraphCompiler { private: std::vector decode_batch_sizes_; - // Experimental fixed-size, single-request Prefill graphs. Unmatched tails - // keep the ordinary eager path; intermediate graphs do not run the LM head. - size_t prefill_chunk_size_{0}; - size_t prefill_max_sequence_length_{0}; - void compile_prefill(); - Compiled get_compiled_prefill(const InfinilmModel::Input &input); infinicore::Tensor block_tables_holder_; @@ -33,6 +27,5 @@ class PagedCompiler : public GraphCompiler { size_t, // num_requests CompiledResult> compiled_map_decode_; - std::unordered_map compiled_map_prefill_; }; } // namespace infinilm::engine diff --git a/csrc/engine/rank_worker.cpp b/csrc/engine/rank_worker.cpp index 196f00469..2b23b8b18 100644 --- a/csrc/engine/rank_worker.cpp +++ b/csrc/engine/rank_worker.cpp @@ -418,7 +418,6 @@ void RankWorker::thread_loop() { infinicore::Tensor logits; infinicore::Tensor hidden_states; - bool graph_executed = false; // All-position speculative/MTP runs need eager mode because // hidden states are not part of compiled graph outputs. if (!local_args.sample_all_positions && compiler_ != nullptr && rank_info_.pp_size == 1) { @@ -426,11 +425,10 @@ void RankWorker::thread_loop() { if (graph != nullptr && output != nullptr) { graph->run(); logits = output->logits; - graph_executed = true; } } // Fall back to eager mode - if (!graph_executed) { + if (!logits) { auto model_args = local_args.to_model_input(rank_info_.device); auto model_output = model_->forward(model_args); logits = model_output.logits; diff --git a/docs/cache-and-chunking.md b/docs/cache-and-chunking.md index 99e57486b..8bfdd8e11 100644 --- a/docs/cache-and-chunking.md +++ b/docs/cache-and-chunking.md @@ -74,14 +74,10 @@ attention backends are `paged-attn` on NVIDIA A6000 and `flash-attn` on MetaX C500; `cuda` maps to MACA on the MetaX build. Other devices have not been validated for these combinations. -Experimental fixed-size Prefill graphs additionally require MetaX, TP1, ordinary -paged KV and Flash Attention. Set `INFINILM_PREFILL_GRAPH_CHUNK_SIZE=512` before -creating the engine. Only a single request of exactly the selected size uses -this Prefill graph; other shapes retain the existing path. Single-token tails -may use Decode graphs. Separate intermediate/final graphs retain persistent -buffers for IDs, positions, KV lengths, offsets, page tables and slot mappings. -An intermediate graph with no logits has still executed and must not run again -in eager mode. PP graphs and TP2 Prefill graphs are excluded. +Intermediate Prefill chunks use eager execution. A single-token final tail +may use the existing Decode graph because it has the same one-query attention +semantics. This change adds no Prefill graph capture or configuration switch. +PP2 chunking remains eager. ## Validation and tradeoffs From cb243d9a8599c9b7ce8ce41c9f3f6a395040f4fe Mon Sep 17 00:00:00 2001 From: tangchengxiang <2064027004@qq.com> Date: Thu, 17 Sep 2026 08:38:27 +0000 Subject: [PATCH 8/9] test(engine): consolidate cache and chunk configuration coverage Exercise shared CLI, service and example forwarding once with both policies enabled. Retain capability rejection, PP worker forwarding, cache lifetimes and native output checks while removing duplicated setup. --- test/llm/test_cache_policy_config.py | 228 ------------------ test/llm/test_chunk_config.py | 333 -------------------------- test/llm/test_chunk_output.py | 12 +- test/llm/test_engine_config.py | 335 +++++++++++++++++++++++++++ 4 files changed, 339 insertions(+), 569 deletions(-) delete mode 100644 test/llm/test_cache_policy_config.py delete mode 100644 test/llm/test_chunk_config.py create mode 100644 test/llm/test_engine_config.py diff --git a/test/llm/test_cache_policy_config.py b/test/llm/test_cache_policy_config.py deleted file mode 100644 index 8e95217c0..000000000 --- a/test/llm/test_cache_policy_config.py +++ /dev/null @@ -1,228 +0,0 @@ -"""CPU configuration coverage; native model construction is replaced at its boundary.""" - -import asyncio -import io -import os -import runpy -import sys -import unittest -from contextlib import redirect_stderr, redirect_stdout -from types import SimpleNamespace -from unittest.mock import patch - -from config_test_support import ( - LLM_MODULE, - SERVER_MODULE, - SOURCE, - BaseConfig, - EngineConfig, -) - - -class CachePolicyConfigTests(unittest.TestCase): - def test_engine_defaults_preserve_lru_for_both_cache_types(self): - for cache_type in ("paged", "static"): - config = EngineConfig("unused", cache_type=cache_type) - self.assertEqual(config.prefix_cache_policy, "lru") - self.assertEqual(config.prefix_cache_protected_ratio, 0.8) - - def test_engine_rejects_unknown_policy(self): - with self.assertRaisesRegex(ValueError, "prefix_cache_policy"): - EngineConfig("unused", prefix_cache_policy="fifo") - - def test_engine_rejects_nonfinite_and_boundary_ratios(self): - for ratio in (-0.1, 0, 1, 1.1, float("nan"), float("inf"), -float("inf")): - with self.subTest(ratio=ratio): - with self.assertRaisesRegex(ValueError, "prefix_cache_protected_ratio"): - EngineConfig("unused", prefix_cache_protected_ratio=ratio) - - def test_static_cache_rejects_slru_even_when_prefix_caching_disabled(self): - for enabled in (True, False): - with self.assertRaisesRegex(ValueError, "paged"): - EngineConfig( - "unused", - cache_type="static", - prefix_cache_policy="slru", - enable_prefix_caching=enabled, - ) - - def parse_cli(self, *args): - with patch.object( - sys, "argv", ["server", "--model", "unused", "--device", "cpu", *args] - ): - with patch.dict(os.environ): - return BaseConfig() - - def test_cli_defaults_and_explicit_slru(self): - default = self.parse_cli() - self.assertEqual(default.prefix_cache_policy, "lru") - self.assertEqual(default.prefix_cache_protected_ratio, 0.8) - custom = self.parse_cli( - "--enable-paged-attn", - "--prefix-cache-policy", - "slru", - "--prefix-cache-protected-ratio", - "0.6", - ) - self.assertEqual(custom.prefix_cache_policy, "slru") - self.assertEqual(custom.prefix_cache_protected_ratio, 0.6) - - def test_cli_rejects_unknown_policy_and_invalid_ratio(self): - invalid = [("--prefix-cache-policy", "fifo")] - invalid.extend( - ("--prefix-cache-protected-ratio", value) - for value in ("0", "1", "nan", "inf", "-inf") - ) - for args in invalid: - with self.subTest(args=args), redirect_stderr(io.StringIO()): - with self.assertRaises(SystemExit): - self.parse_cli(*args) - - def test_convenience_constructors_forward_policy_to_engine_config(self): - # Engine construction normally loads model weights; retain real config validation. - with patch.object( - LLM_MODULE, "LLMEngine", lambda config: SimpleNamespace(config=config) - ): - for constructor in (LLM_MODULE.LLM, LLM_MODULE.AsyncLLMEngine): - default = constructor("unused") - custom = constructor( - "unused", - prefix_cache_policy="slru", - prefix_cache_protected_ratio=0.6, - ) - self.assertEqual(default.engine.config.prefix_cache_policy, "lru") - self.assertEqual(custom.engine.config.prefix_cache_policy, "slru") - self.assertEqual(custom.engine.config.prefix_cache_protected_ratio, 0.6) - with self.assertRaisesRegex(ValueError, "paged"): - constructor( - "unused", cache_type="static", prefix_cache_policy="slru" - ) - - def test_paged_engine_forwards_policy_to_scheduler(self): - runner = SimpleNamespace( - device="cpu", - dtype="float16", - eos_token_id=[], - processor=SimpleNamespace(get_tokenizer=lambda: None), - model_engine=SimpleNamespace(hf_config={"max_position_embeddings": 4096}), - ) - config = EngineConfig( - "unused", prefix_cache_policy="slru", prefix_cache_protected_ratio=0.6 - ) - with patch.object(LLM_MODULE, "ModelRunner", lambda config: runner): - with patch.object(LLM_MODULE, "Scheduler") as scheduler: - LLM_MODULE.LLMEngine(config) - self.assertEqual(scheduler.call_args.kwargs["prefix_cache_policy"], "slru") - self.assertEqual( - scheduler.call_args.kwargs["prefix_cache_protected_ratio"], 0.6 - ) - - def run_offline_example(self, cli=False, **kwargs): - configs = [] - - def chat(model, messages): - configs.append(model.config) - return [] - - modules = { - "infinilm.base_config": SimpleNamespace(BaseConfig=BaseConfig), - "infinilm.llm.llm": LLM_MODULE, - "infinilm.moe_config": SimpleNamespace( - configure_moe_ep_backend=SERVER_MODULE.configure_moe_ep_backend - ), - "infinilm.processors.videonsa_processor": SimpleNamespace( - decode_video_frames=object - ), - } - argv = [ - "test_infer", - "--model", - "unused", - "--device", - "cpu", - "--enable-paged-attn", - "--prefix-cache-policy", - "slru", - "--prefix-cache-protected-ratio", - "0.6", - ] - with ( - patch.dict(sys.modules, modules), - patch.dict(os.environ), - patch.object(sys, "argv", argv), - patch.object( - LLM_MODULE, - "LLMEngine", - lambda config: SimpleNamespace(close=lambda: None), - ), - patch.object(LLM_MODULE.LLM, "chat", chat), - patch.object(SERVER_MODULE.logging, "basicConfig"), - redirect_stdout(io.StringIO()), - ): - example = runpy.run_path( - str(SOURCE.parents[1] / "examples/test_infer.py"), - run_name="__main__" if cli else "offline_example", - ) - if not cli: - example["test"](["hello"], "unused", enable_paged_attn=True, **kwargs) - return configs - - def test_offline_example_forwards_policy_to_llm_config(self): - default = self.run_offline_example() - custom = self.run_offline_example( - prefix_cache_policy="slru", prefix_cache_protected_ratio=0.6 - ) - self.assertEqual(default[0].prefix_cache_policy, "lru") - self.assertEqual(default[0].prefix_cache_protected_ratio, 0.8) - self.assertEqual(custom[0].prefix_cache_policy, "slru") - self.assertEqual(custom[0].prefix_cache_protected_ratio, 0.6) - - def test_offline_cli_reaches_llm_config(self): - configs = self.run_offline_example(cli=True) - self.assertEqual(len(configs), 1) - self.assertEqual(configs[0].prefix_cache_policy, "slru") - self.assertEqual(configs[0].prefix_cache_protected_ratio, 0.6) - - def test_cli_reaches_server_lifespan_and_async_engine_config(self): - configs = [] - - async def start_without_listener(server): - app = server._create_app() - async with app.router.lifespan_context(app): - configs.append(server.engine.config) - - argv = [ - "server", - "--model", - "unused", - "--device", - "cpu", - "--enable-paged-attn", - "--prefix-cache-policy", - "slru", - "--prefix-cache-protected-ratio", - "0.6", - ] - with ( - patch.object( - LLM_MODULE, "LLMEngine", lambda config: SimpleNamespace(config=config) - ), - patch.object(LLM_MODULE.AsyncLLMEngine, "start"), - patch.object(LLM_MODULE.AsyncLLMEngine, "stop"), - patch.object( - SERVER_MODULE.InferenceServer, - "start", - lambda server: asyncio.run(start_without_listener(server)), - ), - patch.object(SERVER_MODULE, "setup_logging"), - patch.object(sys, "argv", argv), - patch.dict(os.environ), - ): - SERVER_MODULE.main() - self.assertEqual(len(configs), 1) - self.assertEqual(configs[0].prefix_cache_policy, "slru") - self.assertEqual(configs[0].prefix_cache_protected_ratio, 0.6) - - -if __name__ == "__main__": - unittest.main() diff --git a/test/llm/test_chunk_config.py b/test/llm/test_chunk_config.py deleted file mode 100644 index 285326acc..000000000 --- a/test/llm/test_chunk_config.py +++ /dev/null @@ -1,333 +0,0 @@ -"""CPU configuration coverage; native model construction is replaced at its boundary.""" - -import asyncio -import io -import os -import runpy -import sys -import unittest -from contextlib import redirect_stderr, redirect_stdout -from types import SimpleNamespace -from unittest.mock import patch - -from config_test_support import ( - LLM_MODULE, - SERVER_MODULE, - SOURCE, - BaseConfig, - EngineConfig, - load_module, -) - - -class ChunkConfigTests(unittest.TestCase): - def test_default_preserves_unbounded_prefill_for_both_caches(self): - for cache_type in ("paged", "static"): - self.assertEqual( - EngineConfig("unused", cache_type=cache_type).prefill_chunk_size, 0 - ) - - def test_positive_chunk_size_and_inactive_transfer_are_supported(self): - kv_config = SERVER_MODULE.KVTransferConfig() - config = EngineConfig( - "unused", prefill_chunk_size=128, kv_transfer_config=kv_config - ) - self.assertEqual(config.prefill_chunk_size, 128) - - def test_tp2_chunking_is_supported_by_config_and_convenience_apis(self): - config = EngineConfig("unused", prefill_chunk_size=128, tensor_parallel_size=2) - self.assertEqual(config.tensor_parallel_size, 2) - with patch.object( - LLM_MODULE, "LLMEngine", lambda config: SimpleNamespace(config=config) - ): - for constructor in (LLM_MODULE.LLM, LLM_MODULE.AsyncLLMEngine): - engine = constructor( - "unused", prefill_chunk_size=128, tensor_parallel_size=2 - ) - self.assertEqual(engine.config.tensor_parallel_size, 2) - self.assertEqual(engine.config.prefill_chunk_size, 128) - cli = self.parse_cli("--tp", "2", "--prefill-chunk-size", "128") - self.assertEqual((cli.tp, cli.prefill_chunk_size), (2, 128)) - - def test_pp2_eager_chunking_and_worker_cli(self): - for stage in (0, 1): - EngineConfig( - "unused", - prefill_chunk_size=300, - pipeline_parallel_size=2, - pipeline_parallel_stage=stage, - prefix_cache_policy="slru", - ) - cfg = self.parse_cli( - "--pp", "2", "--node-rank", str(stage), "--prefill-chunk-size", "300" - ) - self.assertEqual(cfg.pp, 2) - with self.assertRaisesRegex(ValueError, "TP/PP"): - EngineConfig( - "unused", - prefill_chunk_size=300, - tensor_parallel_size=2, - pipeline_parallel_size=2, - ) - - def test_pipeline_worker_forwards_chunk_and_cache_configuration(self): - cfg = self.parse_cli( - "--pp", - "2", - "--node-rank", - "1", - "--enable-paged-attn", - "--prefill-chunk-size", - "300", - "--prefix-cache-policy", - "slru", - ) - captured = [] - closed = [] - - def make_runner(config, initialize_processor): - self.assertFalse(initialize_processor) - captured.append(config) - return SimpleNamespace(close=lambda: closed.append(True)) - - replacements = { - "infinilm.base_config": SimpleNamespace(BaseConfig=BaseConfig), - "infinilm.config.engine_config": SimpleNamespace(EngineConfig=EngineConfig), - "infinilm.distributed.pipeline_transport": SimpleNamespace( - PipelineWorkerClient=lambda *args, **kwargs: SimpleNamespace( - serve_forever=lambda: None - ) - ), - "infinilm.llm.model_runner.model_runner": SimpleNamespace( - ModelRunner=make_runner - ), - } - with patch.dict(sys.modules, replacements): - worker = load_module( - "infinilm.server.pipeline_worker", "server/pipeline_worker.py" - ) - worker.run_worker(cfg) - self.assertEqual(len(captured), 1) - self.assertEqual(captured[0].prefill_chunk_size, 300) - self.assertEqual(captured[0].prefix_cache_policy, "slru") - self.assertEqual(captured[0].pipeline_parallel_stage, 1) - self.assertEqual(closed, [True]) - - def test_chunk_size_rejects_negative_noninteger_and_boolean(self): - for value in (-1, 1.5, "128", None, True, False): - with self.subTest(value=value): - with self.assertRaisesRegex(ValueError, "prefill_chunk_size"): - EngineConfig("unused", prefill_chunk_size=value) - - def test_chunking_allows_tp_decode_graphs(self): - config = EngineConfig( - "unused", - prefill_chunk_size=512, - enable_graph=True, - attn_backend="flash-attn", - device="cuda", - tensor_parallel_size=1, - ) - self.assertTrue(config.enable_graph) - self.assertEqual(config.prefill_chunk_size, 512) - for tp in (1, 2): - for backend in ("default", "paged-attn", "flash-attn"): - EngineConfig( - "unused", - prefill_chunk_size=512, - enable_graph=True, - tensor_parallel_size=tp, - attn_backend=backend, - ) - for overrides in ( - {"pipeline_parallel_size": 2}, - {"device": "cpu"}, - {"attn_backend": "unsupported"}, - ): - values = dict( - prefill_chunk_size=512, - enable_graph=True, - attn_backend="flash-attn", - device="cuda", - ) - values.update(overrides) - with ( - self.subTest(overrides=overrides), - self.assertRaisesRegex(ValueError, "requires PP=1"), - ): - EngineConfig("unused", **values) - - def test_enabled_chunking_rejects_unsupported_execution_modes(self): - active_transfer = SERVER_MODULE.KVTransferConfig( - kv_connector="MooncakeConnector", kv_role="kv_producer" - ) - for overrides in ( - {"cache_type": "static"}, - {"enable_graph": True, "pipeline_parallel_size": 2}, - {"tensor_parallel_size": 4}, - {"pipeline_parallel_size": 3}, - {"use_mla": True}, - {"draft_model_path": "draft"}, - {"kv_transfer_config": active_transfer}, - ): - with self.subTest(overrides=overrides): - with self.assertRaisesRegex(ValueError, "prefill_chunk_size"): - EngineConfig("unused", prefill_chunk_size=128, **overrides) - EngineConfig("unused", prefill_chunk_size=0, **overrides) - - def parse_cli(self, *args): - with ( - patch.object( - sys, "argv", ["server", "--model", "unused", "--device", "cpu", *args] - ), - patch.dict(os.environ), - ): - return BaseConfig() - - def test_cli_default_and_explicit_nonnegative_chunk_size(self): - self.assertEqual(self.parse_cli().prefill_chunk_size, 0) - for value in ("0", "1", "128"): - self.assertEqual( - self.parse_cli("--prefill-chunk-size", value).prefill_chunk_size, - int(value), - ) - - def test_cli_rejects_chunked_parallelism_before_worker_dispatch(self): - for parallel in ( - ("--tp", "4"), - ("--pp", "3"), - ("--pp", "3", "--node-rank", "1"), - ): - with self.subTest(parallel=parallel): - with ( - redirect_stderr(io.StringIO()), - self.assertRaises(SystemExit) as error, - ): - self.parse_cli("--prefill-chunk-size", "128", *parallel) - self.assertEqual(error.exception.code, 2) - self.assertEqual(self.parse_cli(*parallel).prefill_chunk_size, 0) - - def test_cli_rejects_negative_and_noninteger_chunk_sizes(self): - for value in ("-1", "1.5", "True"): - with self.subTest(value=value), redirect_stderr(io.StringIO()): - with self.assertRaises(SystemExit): - self.parse_cli("--prefill-chunk-size", value) - - def test_cli_rejects_draft_model_with_chunking(self): - with redirect_stderr(io.StringIO()): - with self.assertRaises(SystemExit): - self.parse_cli("--prefill-chunk-size", "128", "--draft-model", "draft") - self.assertEqual(self.parse_cli("--draft-model", "draft").prefill_chunk_size, 0) - - def test_convenience_constructors_validate_chunking_before_model_load(self): - # Only native model construction is replaced; EngineConfig stays real. - with patch.object( - LLM_MODULE, "LLMEngine", lambda config: SimpleNamespace(config=config) - ): - for constructor in (LLM_MODULE.LLM, LLM_MODULE.AsyncLLMEngine): - self.assertEqual( - constructor("unused").engine.config.prefill_chunk_size, 0 - ) - config = constructor("unused", prefill_chunk_size=128).engine.config - self.assertEqual(config.prefill_chunk_size, 128) - with self.assertRaisesRegex(ValueError, "prefill_chunk_size"): - constructor("unused", cache_type="static", prefill_chunk_size=128) - - def test_cli_reaches_server_lifespan_and_async_engine_config(self): - configs = [] - - async def start_without_listener(server): - app = server._create_app() - async with app.router.lifespan_context(app): - configs.append(server.engine.config) - - with ( - patch.object( - LLM_MODULE, "LLMEngine", lambda config: SimpleNamespace(config=config) - ), - patch.object(LLM_MODULE.AsyncLLMEngine, "start"), - patch.object(LLM_MODULE.AsyncLLMEngine, "stop"), - patch.object( - SERVER_MODULE.InferenceServer, - "start", - lambda server: asyncio.run(start_without_listener(server)), - ), - patch.object(SERVER_MODULE, "setup_logging"), - patch.object( - sys, - "argv", - [ - "server", - "--model", - "unused", - "--device", - "cpu", - "--enable-paged-attn", - "--tp", - "2", - "--prefill-chunk-size", - "128", - ], - ), - patch.dict(os.environ), - ): - SERVER_MODULE.main() - self.assertEqual(len(configs), 1) - self.assertEqual(configs[0].prefill_chunk_size, 128) - self.assertEqual(configs[0].tensor_parallel_size, 2) - - def test_offline_cli_reaches_llm_config(self): - configs = [] - - def chat(model, messages): - configs.append(model.config) - return [] - - modules = { - "infinilm.base_config": SimpleNamespace(BaseConfig=BaseConfig), - "infinilm.llm.llm": LLM_MODULE, - "infinilm.moe_config": SimpleNamespace( - configure_moe_ep_backend=SERVER_MODULE.configure_moe_ep_backend - ), - "infinilm.processors.videonsa_processor": SimpleNamespace( - decode_video_frames=object - ), - } - with ( - patch.dict(sys.modules, modules), - patch.dict(os.environ), - patch.object( - sys, - "argv", - [ - "test_infer", - "--model", - "unused", - "--device", - "cpu", - "--enable-paged-attn", - "--tp", - "2", - "--prefill-chunk-size", - "128", - ], - ), - patch.object( - LLM_MODULE, - "LLMEngine", - lambda config: SimpleNamespace(close=lambda: None), - ), - patch.object(LLM_MODULE.LLM, "chat", chat), - patch.object(SERVER_MODULE.logging, "basicConfig"), - redirect_stdout(io.StringIO()), - ): - runpy.run_path( - str(SOURCE.parents[1] / "examples/test_infer.py"), run_name="__main__" - ) - self.assertEqual(len(configs), 1) - self.assertEqual(configs[0].prefill_chunk_size, 128) - self.assertEqual(configs[0].tensor_parallel_size, 2) - - -if __name__ == "__main__": - unittest.main() diff --git a/test/llm/test_chunk_output.py b/test/llm/test_chunk_output.py index cb6ecfb29..ae5f45e6c 100644 --- a/test/llm/test_chunk_output.py +++ b/test/llm/test_chunk_output.py @@ -66,6 +66,7 @@ def forward(**kwargs): def test_only_intermediate_chunks_skip_native_output(self): engine, req, calls = self.setup_runner() + req.sampling_params.max_tokens = 2 for _ in range(2): self.assertEqual(engine.step(), (True, [])) self.assertEqual(list(req.generated_token_ids), []) @@ -73,18 +74,13 @@ def test_only_intermediate_chunks_skip_native_output(self): engine.step() self.assertEqual(calls, [True, True, False]) self.assertEqual(list(req.generated_token_ids), [77]) + engine.step() + self.assertEqual(calls, [True, True, False, False]) + self.assertEqual(list(req.generated_token_ids), [77, 77]) self.assertTrue( all(b.ref_count == 0 for b in engine.scheduler.cache_manager.blocks) ) - def test_decode_keeps_sampling(self): - engine, req, calls = self.setup_runner() - req.sampling_params.max_tokens = 2 - for _ in range(4): - engine.step() - self.assertEqual(calls, [True, True, False, False]) - self.assertEqual(list(req.generated_token_ids), [77, 77]) - def test_legacy_output_without_chunk_metadata_keeps_sampling(self): engine, req, calls = self.setup_runner() output = SimpleNamespace( diff --git a/test/llm/test_engine_config.py b/test/llm/test_engine_config.py new file mode 100644 index 000000000..d53c3341c --- /dev/null +++ b/test/llm/test_engine_config.py @@ -0,0 +1,335 @@ +"""Validate cache/chunk configuration once across public entrypoints.""" + +import asyncio +import io +import os +import runpy +import sys +import unittest +from contextlib import redirect_stderr, redirect_stdout +from types import SimpleNamespace +from unittest.mock import patch + +from config_test_support import ( + LLM_MODULE, + SERVER_MODULE, + SOURCE, + BaseConfig, + EngineConfig, + load_module, +) + +OPTIONS = dict( + prefix_cache_policy="slru", + prefix_cache_protected_ratio=0.6, + prefill_chunk_size=128, + tensor_parallel_size=2, +) +CLI = [ + "--enable-paged-attn", + "--prefix-cache-policy", + "slru", + "--prefix-cache-protected-ratio", + "0.6", + "--prefill-chunk-size", + "128", + "--tp", + "2", +] + + +class EngineConfigTests(unittest.TestCase): + def parse_cli(self, *args): + with ( + patch.object( + sys, "argv", ["server", "--model", "unused", "--device", "cpu", *args] + ), + patch.dict(os.environ), + ): + return BaseConfig() + + def assert_options(self, config): + for key, value in OPTIONS.items(): + self.assertEqual(getattr(config, key), value) + + def test_defaults_preserve_existing_behavior(self): + for cache_type in ("paged", "static"): + config = EngineConfig("unused", cache_type=cache_type) + self.assertEqual( + ( + config.prefix_cache_policy, + config.prefix_cache_protected_ratio, + config.prefill_chunk_size, + ), + ("lru", 0.8, 0), + ) + cli = self.parse_cli() + self.assertEqual( + ( + cli.prefix_cache_policy, + cli.prefix_cache_protected_ratio, + cli.prefill_chunk_size, + ), + ("lru", 0.8, 0), + ) + + def test_invalid_policy_ratio_and_chunk_values(self): + invalid = [("prefix_cache_policy", "fifo")] + invalid += [ + ("prefix_cache_protected_ratio", r) + for r in (-0.1, 0, 1, 1.1, float("nan"), float("inf"), -float("inf")) + ] + invalid += [ + ("prefill_chunk_size", n) for n in (-1, 1.5, "128", None, True, False) + ] + for key, value in invalid: + with ( + self.subTest(key=key, value=value), + self.assertRaisesRegex(ValueError, key), + ): + EngineConfig("unused", **{key: value}) + for enabled in (True, False): + with self.assertRaisesRegex(ValueError, "paged"): + EngineConfig( + "unused", + cache_type="static", + prefix_cache_policy="slru", + enable_prefix_caching=enabled, + ) + + def test_chunk_execution_capabilities(self): + inactive = SERVER_MODULE.KVTransferConfig() + EngineConfig("unused", prefill_chunk_size=128, kv_transfer_config=inactive) + for tp in (1, 2): + for backend in ("default", "paged-attn", "flash-attn"): + EngineConfig( + "unused", + prefill_chunk_size=128, + tensor_parallel_size=tp, + enable_graph=True, + attn_backend=backend, + ) + for stage in (0, 1): + EngineConfig( + "unused", + prefill_chunk_size=300, + pipeline_parallel_size=2, + pipeline_parallel_stage=stage, + prefix_cache_policy="slru", + ) + active = SERVER_MODULE.KVTransferConfig( + kv_connector="MooncakeConnector", kv_role="kv_producer" + ) + for overrides in ( + {"cache_type": "static"}, + {"tensor_parallel_size": 4}, + {"pipeline_parallel_size": 3}, + {"use_mla": True}, + {"draft_model_path": "draft"}, + {"kv_transfer_config": active}, + {"tensor_parallel_size": 2, "pipeline_parallel_size": 2}, + ): + with self.subTest(overrides=overrides), self.assertRaises(ValueError): + EngineConfig("unused", prefill_chunk_size=128, **overrides) + EngineConfig("unused", prefill_chunk_size=0, **overrides) + for overrides in ( + {"pipeline_parallel_size": 2}, + {"device": "cpu"}, + {"attn_backend": "unsupported"}, + ): + with ( + self.subTest(overrides=overrides), + self.assertRaisesRegex(ValueError, "requires PP=1"), + ): + EngineConfig( + "unused", prefill_chunk_size=128, enable_graph=True, **overrides + ) + + def test_cli_validation_before_dispatch(self): + config = self.parse_cli(*CLI) + self.assertEqual( + ( + config.prefix_cache_policy, + config.prefix_cache_protected_ratio, + config.prefill_chunk_size, + config.tp, + ), + ("slru", 0.6, 128, 2), + ) + invalid = [("--prefix-cache-policy", "fifo")] + invalid += [ + ("--prefix-cache-protected-ratio", v) + for v in ("0", "1", "nan", "inf", "-inf") + ] + invalid += [("--prefill-chunk-size", v) for v in ("-1", "1.5", "True")] + invalid += [ + ("--prefill-chunk-size", "128", *extra) + for extra in ( + ("--tp", "4"), + ("--pp", "3"), + ("--pp", "3", "--node-rank", "1"), + ("--draft-model", "draft"), + ) + ] + for args in invalid: + with ( + self.subTest(args=args), + redirect_stderr(io.StringIO()), + self.assertRaises(SystemExit) as error, + ): + self.parse_cli(*args) + self.assertEqual(error.exception.code, 2) + for value in ("0", "1", "128"): + self.assertEqual( + self.parse_cli("--prefill-chunk-size", value).prefill_chunk_size, + int(value), + ) + + def test_convenience_apis_forward_and_validate_options(self): + with patch.object( + LLM_MODULE, "LLMEngine", lambda config: SimpleNamespace(config=config) + ): + for constructor in (LLM_MODULE.LLM, LLM_MODULE.AsyncLLMEngine): + self.assert_options(constructor("unused", **OPTIONS).engine.config) + default = constructor("unused").engine.config + self.assertEqual( + (default.prefix_cache_policy, default.prefill_chunk_size), + ("lru", 0), + ) + with self.assertRaisesRegex(ValueError, "paged"): + constructor( + "unused", cache_type="static", prefix_cache_policy="slru" + ) + with self.assertRaisesRegex(ValueError, "prefill_chunk_size"): + constructor("unused", cache_type="static", prefill_chunk_size=128) + + def test_engine_forwards_options_to_scheduler(self): + runner = SimpleNamespace( + device="cpu", + dtype="float16", + eos_token_id=[], + processor=SimpleNamespace(get_tokenizer=lambda: None), + model_engine=SimpleNamespace(hf_config={"max_position_embeddings": 4096}), + ) + with ( + patch.object(LLM_MODULE, "ModelRunner", lambda config: runner), + patch.object(LLM_MODULE, "Scheduler") as scheduler, + ): + LLM_MODULE.LLMEngine(EngineConfig("unused", **OPTIONS)) + for key in ( + "prefix_cache_policy", + "prefix_cache_protected_ratio", + "prefill_chunk_size", + ): + self.assertEqual(scheduler.call_args.kwargs[key], OPTIONS[key]) + + def test_pipeline_worker_forwards_options(self): + config = self.parse_cli(*CLI[:-2], "--pp", "2", "--node-rank", "1") + captured, closed = [], [] + + def make_runner(config, initialize_processor): + self.assertFalse(initialize_processor) + captured.append(config) + return SimpleNamespace(close=lambda: closed.append(True)) + + replacements = { + "infinilm.base_config": SimpleNamespace(BaseConfig=BaseConfig), + "infinilm.config.engine_config": SimpleNamespace(EngineConfig=EngineConfig), + "infinilm.distributed.pipeline_transport": SimpleNamespace( + PipelineWorkerClient=lambda *a, **kw: SimpleNamespace( + serve_forever=lambda: None + ) + ), + "infinilm.llm.model_runner.model_runner": SimpleNamespace( + ModelRunner=make_runner + ), + } + with patch.dict(sys.modules, replacements): + worker = load_module( + "infinilm.server.pipeline_worker", "server/pipeline_worker.py" + ) + worker.run_worker(config) + self.assertEqual(len(captured), 1) + self.assertEqual( + ( + captured[0].prefill_chunk_size, + captured[0].prefix_cache_policy, + captured[0].prefix_cache_protected_ratio, + captured[0].pipeline_parallel_stage, + ), + (128, "slru", 0.6, 1), + ) + self.assertEqual(closed, [True]) + + def test_cli_reaches_server_lifespan(self): + configs = [] + + async def start_without_listener(server): + app = server._create_app() + async with app.router.lifespan_context(app): + configs.append(server.engine.config) + + with ( + patch.object( + LLM_MODULE, "LLMEngine", lambda config: SimpleNamespace(config=config) + ), + patch.object(LLM_MODULE.AsyncLLMEngine, "start"), + patch.object(LLM_MODULE.AsyncLLMEngine, "stop"), + patch.object( + SERVER_MODULE.InferenceServer, + "start", + lambda server: asyncio.run(start_without_listener(server)), + ), + patch.object(SERVER_MODULE, "setup_logging"), + patch.dict(os.environ), + patch.object( + sys, "argv", ["server", "--model", "unused", "--device", "cpu", *CLI] + ), + ): + SERVER_MODULE.main() + self.assertEqual(len(configs), 1) + self.assert_options(configs[0]) + + def test_offline_cli_reaches_llm(self): + configs = [] + modules = { + "infinilm.base_config": SimpleNamespace(BaseConfig=BaseConfig), + "infinilm.llm.llm": LLM_MODULE, + "infinilm.moe_config": SimpleNamespace( + configure_moe_ep_backend=SERVER_MODULE.configure_moe_ep_backend + ), + "infinilm.processors.videonsa_processor": SimpleNamespace( + decode_video_frames=object + ), + } + + def chat(model, messages): + configs.append(model.config) + return [] + + with ( + patch.dict(sys.modules, modules), + patch.dict(os.environ), + patch.object( + sys, + "argv", + ["test_infer", "--model", "unused", "--device", "cpu", *CLI], + ), + patch.object( + LLM_MODULE, + "LLMEngine", + lambda config: SimpleNamespace(close=lambda: None), + ), + patch.object(LLM_MODULE.LLM, "chat", chat), + patch.object(SERVER_MODULE.logging, "basicConfig"), + redirect_stdout(io.StringIO()), + ): + runpy.run_path( + str(SOURCE.parents[1] / "examples/test_infer.py"), run_name="__main__" + ) + self.assertEqual(len(configs), 1) + self.assert_options(configs[0]) + + +if __name__ == "__main__": + unittest.main() From 6ab1130ddc2ce497788f2bde37bc957cb1a4a861 Mon Sep 17 00:00:00 2001 From: tangchengxiang <2064027004@qq.com> Date: Mon, 21 Sep 2026 08:01:43 +0000 Subject: [PATCH 9/9] test(engine): trim chunk validation scaffolding --- test/llm/README.md | 37 +-- test/llm/check_chunk_output.py | 157 ++++++++---- test/llm/check_chunk_tp.py | 412 ------------------------------- test/llm/graph_counter.cc | 21 -- test/llm/test_chunk_execution.py | 100 +++++++- test/llm/test_chunk_output.py | 111 --------- test/llm/test_engine_config.py | 98 ++++---- 7 files changed, 257 insertions(+), 679 deletions(-) delete mode 100644 test/llm/check_chunk_tp.py delete mode 100644 test/llm/graph_counter.cc delete mode 100644 test/llm/test_chunk_output.py diff --git a/test/llm/README.md b/test/llm/README.md index d4b3d8b24..a8bb05364 100644 --- a/test/llm/README.md +++ b/test/llm/README.md @@ -11,36 +11,23 @@ They cover cache ownership/capacity, LRU/SLRU eviction, admission rollback, remote-KV delayed release, configuration forwarding, chunk boundaries, phase progress, cancellation and final-only output. -With matching built InfiniLM/InfiniCore extensions and a local dense FP16 model, -run the opt-in A6000 lifecycle check (use `--tp 1` for a single GPU): +With matching built InfiniLM/InfiniCore extensions and a dense FP16 model, +run the short native regression (use `--tp 1` for a single GPU): ```sh -CUDA_VISIBLE_DEVICES=0,1 python test/llm/check_chunk_tp.py \ - --model /path/to/model --tp 2 --chunk-size 300 --policy slru \ - --output /tmp/chunk-tp2.json +CUDA_VISIBLE_DEVICES=0,1 python test/llm/check_chunk_output.py \ + --model /path/to/model --tp 2 --chunk-size 17 ``` -The check poisons scheduled KV slots, verifies writes on each rank, exercises -shared/repeated prefixes and cancellation, and requires zero final references. -It uses page256/pool16 and compares repeated greedy outputs. `--cache-off` -disables prefix reuse. For PP2 eager, run separate processes with `--tp 1 ---pp 2 --stage 0` and `--tp 1 --pp 2 --stage 1`, one visible GPU per process, -matching `--port` values and different output paths. +The check compares chunked and ordinary greedy output, verifies native +intermediate-output suppression and invalid-input rejection, and exercises +prefix reuse, cancellation and complete page-reference reclamation. Add `--graph` +to run ordinary Decode with graphs; compile InfiniCore with `--graph=y` first. +The model must accept token IDs 1–67 and meet the scheduler’s minimum +`max_position_embeddings` of 1024. -For actual Decode-graph launch checks, build InfiniCore with `--graph=y` and -preload the counter: - -```sh -g++ -std=c++17 -shared -fPIC -I"$INFINI_ROOT/include" \ - test/llm/graph_counter.cc -ldl -o /tmp/infini-graph-counter.so -LD_PRELOAD=/tmp/infini-graph-counter.so CUDA_VISIBLE_DEVICES=0,1 \ - python test/llm/check_chunk_tp.py --model /path/to/model --tp 2 \ - --chunk-size 300 --policy slru --graph --output /tmp/chunk-graph.json -``` - -`check_chunk_output.py` accepts `--model`, `--tp`, `--chunk-size` and `--output` -to additionally check native output suppression and invalid-input rejection. -KV poisoning is correctness instrumentation, not a performance measurement. +Longer TP/PP experiments, KV poisoning and graph-launch interception are archived +in the [validation tools](https://github.com/big-hip/InfiniCore/tree/b635f35f359d2f536b9ba5ca82686b6b2b988cb7/docs/validation/cache-chunk-tools-20260921). Configuration and limits: [cache and chunking](../../docs/cache-and-chunking.md). Historical benchmark scripts and measurements are linked from diff --git a/test/llm/check_chunk_output.py b/test/llm/check_chunk_output.py index d757e7a5f..28139a1d3 100644 --- a/test/llm/check_chunk_output.py +++ b/test/llm/check_chunk_output.py @@ -1,27 +1,90 @@ -"""Native output suppression plus the existing per-rank KV lifecycle checks.""" +"""Opt-in native chunk/output/cancellation regression for a dense FP16 model.""" import argparse -import json -from pathlib import Path from unittest.mock import patch -from check_chunk_tp import run - -def check(args): +def check(model, tp=1, chunk_size=17, graph=False): + import infinicore + from infinilm.config.engine_config import EngineConfig from infinilm.lib import _infinilm + from infinilm.llm.llm import LLMEngine + from infinilm.llm.request import InferenceRequest + from infinilm.llm.sampling_params import SamplingParams + + outputs = [] + for chunk in (0, chunk_size): + engine = LLMEngine( + EngineConfig( + model, + device="cuda", + dtype="float16", + tensor_parallel_size=tp, + enable_graph=graph, + attn_backend="paged-attn", + num_blocks=16, + block_size=64, + max_batch_size=1, + prefill_chunk_size=chunk, + enable_prefix_caching=True, + prefix_cache_policy="slru", + ) + ) + raw = engine.model_runner.model_engine + native = _infinilm.InferEngine.forward + calls = [] - # Fail before loading a model if the native extension has not been rebuilt. - assert _infinilm.InferEngine.Input(prefill_only=True).prefill_only - native = _infinilm.InferEngine.forward - calls = [] - rejected = [] + def forward(instance, inputs): + result = native(instance, inputs) + calls.append(inputs.prefill_only) + if inputs.prefill_only: + assert ( + not result.output_ids + and not result.logits + and not result.hidden_states + ) + else: + assert result.output_ids and result.logits + return result - def forward(engine, inputs): - if not calls: - # Rejection must happen before any worker job is submitted; the - # subsequent normal lifecycle must still be able to use this engine. - for kwargs, message in ( + def generate(name, tokens, cancel=False, reused=False): + request = InferenceRequest( + name, + prompt_token_ids=tokens, + sampling_params=SamplingParams(max_tokens=4, ignore_eos=True, top_k=1), + ) + engine.add_request(request) + for step in range(100): + if request.is_finished(): + break + assert engine.step()[0] + if step == 0 and reused: + assert request.num_local_cached_tokens == 64 + if cancel and step == 0: + assert not request.generated_token_ids + request.abort() + assert request.is_finished() + cache = engine.scheduler.cache_manager + assert all(block.ref_count == 0 for block in cache.blocks) + assert cache.get_total_usable_blocks() == cache.num_blocks + if cancel: + assert ( + request.status.name == "CANCELED" + and not request.generated_token_ids + ) + else: + assert len(request.generated_token_ids) == 4 + return list(request.generated_token_ids) + + try: + caches = _infinilm.InferEngine.get_kv_cache(raw) + assert len(caches) == tp + for rank, tensors in enumerate(caches): + assert {infinicore.Tensor(t).device.index for t in tensors if t} == { + rank + } + # Invalid output suppression must fail before dispatching worker jobs. + for arguments, message in ( ( {"prefill_only": True, "sample_all_positions": True}, "sample_all_positions=false", @@ -29,50 +92,36 @@ def forward(engine, inputs): ({"prefill_only": True}, "input_offsets"), ): try: - native(engine, _infinilm.InferEngine.Input(**kwargs)) + native(raw, _infinilm.InferEngine.Input(**arguments)) except ValueError as error: assert message in str(error) - rejected.append(message) else: - raise AssertionError("invalid outputless forward was accepted") - output = native(engine, inputs) - if inputs.prefill_only: - assert not output.output_ids - assert not output.logits - assert not output.hidden_states - else: - assert output.output_ids - assert output.logits - calls.append(dict(prefill_only=inputs.prefill_only)) - return output - - with patch.object(_infinilm.InferEngine, "forward", forward): - result = run(args) - skipped = sum(c["prefill_only"] for c in calls) - assert skipped > 0 - result["native_rejections"] = rejected - assert len(rejected) == 2 - result["native_outputs"] = dict( - calls=len(calls), prefill_only=skipped, checked=True - ) - return result + raise AssertionError("Invalid outputless forward was accepted.") + with patch.object(_infinilm.InferEngine, "forward", forward): + tokens = list(range(1, 68)) + result = generate("first", tokens) + assert generate("prefix-reuse", tokens, reused=True) == result + if chunk: + assert any(calls) and not all(calls) + generate("cancel", [7] * len(tokens), cancel=True) + else: + assert not any(calls) + outputs.append(result) + finally: + engine.close() + assert outputs[0] == outputs[1], "Chunked and ordinary greedy tokens differ." if __name__ == "__main__": - parser = argparse.ArgumentParser() + parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--model", required=True) - parser.add_argument("--tp", type=int, default=2) - parser.add_argument("--chunk-size", type=int, default=300) - parser.add_argument("--cache-off", action="store_true") - parser.add_argument("--output", type=Path, required=True) - parser.set_defaults(graph=False, pp=1, stage=0, port=29761, policy="lru") + parser.add_argument("--tp", type=int, default=1) + parser.add_argument("--chunk-size", type=int, default=17) + parser.add_argument("--graph", action="store_true") args = parser.parse_args() - try: - payload = check(args) - except Exception as error: - args.output.write_text( - json.dumps(dict(status="failure", error=repr(error)), indent=2) + "\n" + if not 0 < args.chunk_size < 67: + parser.error( + "--chunk-size must be between 1 and 66 to exercise intermediate chunks" ) - raise - args.output.write_text(json.dumps(payload, indent=2) + "\n") - print(json.dumps(payload["native_outputs"])) + check(args.model, args.tp, args.chunk_size, args.graph) + print("Native chunk output, prefix reuse, cancellation and reclamation passed.") diff --git a/test/llm/check_chunk_tp.py b/test/llm/check_chunk_tp.py deleted file mode 100644 index 87267b3ae..000000000 --- a/test/llm/check_chunk_tp.py +++ /dev/null @@ -1,412 +0,0 @@ -"""Opt-in real-model chunk/KV check; run explicitly with --model and --output.""" - -import argparse -import ctypes -import hashlib -import json -import random -import time -from pathlib import Path - - -def host_fp16(tensor, core): - """Export FP16 without relying on the native to_numpy dtype support.""" - import numpy as np - - assert tensor.dtype == core.float16 - core.set_device(tensor.device) - core.sync_device() - cpu = tensor.to(core.device("cpu", 0)).contiguous() - raw = (ctypes.c_ubyte * (cpu.numel() * 2)).from_address(cpu.data_ptr()) - result = np.frombuffer(raw, dtype=np.float16).reshape(cpu.shape).copy() - core.set_device(core.device("cuda", 0)) - return result - - -def run(args): - import infinicore as core - import numpy as np - from infinilm.config.engine_config import EngineConfig - from infinilm.llm.llm import LLMEngine - from infinilm.llm.request import InferenceRequest - from infinilm.llm.sampling_params import SamplingParams - - config = EngineConfig( - model_path=args.model, - device="cuda", - dtype="float16", - tensor_parallel_size=args.tp, - cache_type="paged", - enable_graph=args.graph, - pipeline_parallel_size=args.pp, - pipeline_parallel_stage=args.stage, - master_port=args.port, - prefix_cache_policy=args.policy, - attn_backend="paged-attn", - block_size=256, - num_blocks=16, - max_batch_size=2, - prefill_chunk_size=args.chunk_size, - enable_prefix_caching=not args.cache_off, - ) - - def local_caches(model): - from infinilm.lib import _infinilm - - return [ - [core.Tensor(t) for t in rank if t] - for rank in _infinilm.InferEngine.get_kv_cache(model) - ] - - if args.stage: - from infinilm.distributed.pipeline_transport import PipelineWorkerClient - from infinilm.llm.model_runner.model_runner import ModelRunner - - runner = ModelRunner(config, initialize_processor=False) - observed = [ - t for rank in local_caches(runner.model_engine) for t in (rank[0], rank[-1]) - ] - checks = [] - forward = runner.model_engine.forward - - def checked_forward(**inputs): - slots = inputs["slot_mapping"].to_numpy().reshape(-1).tolist() - allowed = np.zeros(16 * 256, dtype=bool) - allowed[slots] = True - before = [] - for tensor in observed: - raw = host_fp16(tensor, core) - for slot in slots: - raw[:, slot // 256, :, slot % 256, :] = np.nan - core.set_device(tensor.device) - tensor.copy_(core.from_numpy(raw, device=tensor.device)) - core.sync_device() - before.append(raw.transpose(1, 3, 0, 2, 4).reshape(16 * 256, -1)) - result = forward(**inputs) - for index, tensor in enumerate(observed): - after = ( - host_fp16(tensor, core) - .transpose(1, 3, 0, 2, 4) - .reshape(16 * 256, -1) - ) - assert np.isfinite(after[allowed]).all() - assert np.array_equal( - before[index][~allowed], after[~allowed], equal_nan=True - ) - checks.append( - dict(prefill_only=inputs.get("prefill_only", False), slots=len(slots)) - ) - return result - - runner.model_engine.forward = checked_forward - try: - PipelineWorkerClient( - runner, config.master_addr, config.master_port, args.stage - ).serve_forever() - return dict(status="worker_success", stage=args.stage, checks=checks) - finally: - runner.close() - counter = ctypes.CDLL(None).graph_launch_count if args.graph else lambda: 0 - if args.graph: - counter.restype = ctypes.c_ulonglong - engine = LLMEngine(config) - try: - manager = engine.scheduler.cache_manager - ranks = local_caches(engine.model_runner.model_engine) - assert len(ranks) == args.tp - assert {t.device.index for rank in ranks for t in rank} == set(range(args.tp)) - # Inspect the first attention layer on every TP rank. Other layers run normally. - observed = [rank[i] for rank in ranks for i in (0, len(rank) - 1)] - sentinel = np.float16(np.nan) - for tensor in observed: - assert ( - len(tensor.shape) == 5 - and tensor.shape[0] == 2 - and tensor.shape[3] == 256 - ) - core.set_device(tensor.device) - tensor.copy_( - core.from_numpy( - np.full(tensor.shape, sentinel, dtype=np.float16), - device=tensor.device, - ) - ) - core.sync_device() - core.set_device(core.device("cuda", 0)) - corpus = engine.tokenizer.encode( - "The engine processes a long document while serving short requests. " - "Pages store key and value vectors, and attention reuses computed prefixes. " - * 8, - add_special_tokens=False, - ) - corpus = [t for t in corpus if t not in engine.tokenizer.all_special_ids] - prompt = random.Random(82).choices(corpus, k=1027) - steps = [] - publication_checks = [] - span_checks = [] - requests = {} - original = engine.model_runner.execute_model - - def execute(output): - row = dict( - prefill=output.is_prefill, - end=getattr(output, "prefill_end", None), - requests=[ - dict( - id=r.request_id, - cached=r.num_local_cached_tokens, - computed=r.num_computed_tokens, - generated=len(r.generated_token_ids), - slots=list(r.slot_mapping), - ) - for r in output.scheduled_requests - ], - ) - before = [] - if output.is_prefill: - # Poison every scheduled slot, including reused physical pages, so a - # missing K/V write cannot pass merely because stale values are finite. - for tensor in observed: - raw = host_fp16(tensor, core) - for request in output.scheduled_requests: - for slot in request.slot_mapping: - raw[:, slot // 256, :, slot % 256, :] = sentinel - core.set_device(tensor.device) - tensor.copy_(core.from_numpy(raw, device=tensor.device)) - core.sync_device() - before.append(raw.transpose(1, 3, 0, 2, 4).reshape(16 * 256, -1)) - core.set_device(core.device("cuda", 0)) - before_launches = counter() - started = time.perf_counter() - result = original(output) - row["elapsed_ms"] = (time.perf_counter() - started) * 1000 - row["graph_launches"] = counter() - before_launches - if args.graph: - assert row["graph_launches"] == (0 if output.is_prefill else args.tp), ( - row - ) - if output.is_prefill: - allowed = np.zeros(16 * 256, dtype=bool) - for r in output.scheduled_requests: - allowed[r.slot_mapping] = True - for rank, tensor in enumerate(observed): - after = ( - host_fp16(tensor, core) - .transpose(1, 3, 0, 2, 4) - .reshape(16 * 256, -1) - ) - assert np.array_equal( - before[rank][~allowed], after[~allowed], equal_nan=True - ), ( - rank, - "KV write outside scheduled slots", - ) - assert np.isfinite(after[allowed]).all() - span_checks.append( - dict( - rank=rank, - request=row["requests"][0]["id"], - end=row["end"], - written_span=int(allowed.sum()), - outside_unchanged=True, - ) - ) - steps.append(row) - return result - - engine.model_runner.execute_model = execute - - def new(name, tokens=prompt, count=8): - r = InferenceRequest( - name, - prompt_token_ids=tokens, - sampling_params=SamplingParams( - top_k=1, max_tokens=count, ignore_eos=True - ), - ) - engine.add_request(r) - requests[name] = r - return r - - def state(idle=False): - assert all(b.ref_count >= 0 for b in manager.blocks) - if idle: - assert all(b.ref_count == 0 for b in manager.blocks) - assert manager.get_total_usable_blocks() == 16 - - def tick(): - worked, pending = engine.step() - assert worked and not pending - state() - - def drain(active): - for _ in range(100): - if all(r.is_finished() for r in active): - break - tick() - assert all( - r.is_finished() and len(r.generated_token_ids) == 8 for r in active - ) - state(idle=True) - - first = new("first") - # Both ranks must write exactly [0, chunk_size), preserving untouched future slots. - tick() - if args.chunk_size: - assert ( - first.num_computed_tokens == args.chunk_size - and not first.generated_token_ids - ) - expected = np.zeros(16 * 256, dtype=bool) - for i in range(args.chunk_size): - expected[first.block_table[i // 256] * 256 + i % 256] = True - for rank, tensor in enumerate(observed): - values = ( - host_fp16(tensor, core) - .transpose(1, 3, 0, 2, 4) - .reshape(16 * 256, -1) - ) - changed = np.any(~np.isnan(values), axis=1) - assert np.array_equal(changed, expected), ( - rank, - np.flatnonzero(changed != expected).tolist(), - ) - assert np.isfinite(values[expected]).all() - publication_checks.append( - dict( - rank=rank, - device=str(tensor.device), - changed_slots=int(changed.sum()), - expected_slots=int(expected.sum()), - indexed_blocks=first.num_cache_indexed_blocks, - ) - ) - assert first.num_cache_indexed_blocks == ( - 0 if args.cache_off else args.chunk_size // 256 - ) - # A new request may reuse only the fully computed first page. Both hold shared ownership. - second = new("shared", prompt + corpus[:2]) - shared_dispatch = None - for _ in range(5): - tick() - row = steps[-1] - if row["requests"][0]["id"] == "shared": - shared_dispatch = row - break - assert shared_dispatch is not None - hit = shared_dispatch["requests"][0]["cached"] - if not args.cache_off: - assert hit > 0 and hit % 256 == 0 - assert all( - manager.blocks[b].ref_count == 2 - for b in second.block_table[: hit // 256] - ) - else: - assert hit == 0 - first.mark_canceled() - drain([second]) - assert not first.generated_token_ids - state(idle=True) - # Repeat now matches the completed prefix; the uncached suffix is shorter than a chunk. - repeat = new("repeat", prompt + corpus[:2]) - drain([repeat]) - if not args.cache_off: - assert any( - r["id"] == "repeat" and r["cached"] == 1024 - for s in steps - if s["prefill"] - for r in s["requests"] - ) - # Abort after forward: final cached suffix when caching is on; - # an intermediate segment when caching is off. Both must release ownership. - canceled = new("abort-forward", prompt + corpus[:2]) - run_forward = engine.model_runner.execute_model - - def abort_after_forward(output): - result = run_forward(output) - canceled.abort() - return result - - engine.model_runner.execute_model = abort_after_forward - tick() - engine.model_runner.execute_model = run_forward - assert canceled.is_finished() and not canceled.generated_token_ids - state(idle=True) - else: - drain([first]) - drain([new("shared", prompt + corpus[:2])]) - drain([new("repeat", prompt + corpus[:2])]) - result = dict( - status="success", - tp=args.tp, - pp=args.pp, - policy=args.policy, - graph=args.graph, - chunk_size=args.chunk_size, - cache_off=args.cache_off, - rank_devices=[[str(t.device) for t in rank] for rank in ranks], - checks=publication_checks, - span_checks=span_checks, - steps=steps, - requests={ - name: dict( - prompt=list(r.prompt_token_ids), - tokens=list(r.generated_token_ids), - status=str(r.status), - ) - for name, r in requests.items() - }, - final_refs=[b.ref_count for b in manager.blocks], - final_usable=manager.get_total_usable_blocks(), - ) - import infinilm.llm.llm as source - from infinilm.lib import _infinilm as native - - result["source"] = str(Path(source.__file__).resolve()) - result["native_sha256"] = hashlib.sha256( - Path(native.__file__).read_bytes() - ).hexdigest() - result["same_prompt_greedy_equal"] = ( - result["requests"]["shared"]["tokens"] - == result["requests"]["repeat"]["tokens"] - ) - assert result["same_prompt_greedy_equal"], "shared and repeated tokens differ" - result["script_sha256"] = hashlib.sha256( - Path(__file__).read_bytes() - ).hexdigest() - return result - finally: - engine.close() - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--model", required=True) - parser.add_argument("--tp", type=int, choices=(1, 2), default=2) - parser.add_argument("--chunk-size", type=int, choices=(0, 300, 512), default=300) - parser.add_argument("--cache-off", action="store_true") - parser.add_argument("--graph", action="store_true") - parser.add_argument("--pp", type=int, choices=(1, 2), default=1) - parser.add_argument("--stage", type=int, choices=(0, 1), default=0) - parser.add_argument("--port", type=int, default=29761) - parser.add_argument("--policy", choices=("lru", "slru"), default="lru") - parser.add_argument("--output", type=Path, required=True) - args = parser.parse_args() - try: - result = run(args) - except Exception as error: - args.output.write_text( - json.dumps(dict(status="failure", error=repr(error)), indent=2) + "\n" - ) - raise - args.output.write_text(json.dumps(result, indent=2) + "\n") - print( - json.dumps( - { - k: result[k] - for k in ("status", "tp", "chunk_size", "checks", "final_usable") - if k in result - }, - indent=2, - ) - ) diff --git a/test/llm/graph_counter.cc b/test/llm/graph_counter.cc deleted file mode 100644 index 42c36a9fe..000000000 --- a/test/llm/graph_counter.cc +++ /dev/null @@ -1,21 +0,0 @@ -#include -#include -#include -#include - -static std::atomic launches{0}; -extern "C" unsigned long long graph_launch_count() { return launches.load(); } -extern "C" infiniStatus_t infinirtGraphLuanch(infinirtGraphExec_t graph, infinirtStream_t stream) { - using Fn = infiniStatus_t (*)(infinirtGraphExec_t, infinirtStream_t); - // Python loads extensions RTLD_LOCAL, so RTLD_NEXT may not see InfiniRT. - static auto real = reinterpret_cast(dlsym( - dlopen("libinfinirt.so", RTLD_NOW | RTLD_LOCAL), "infinirtGraphLuanch")); - if (!real) { - std::abort(); - } - auto status = real(graph, stream); - if (status == INFINI_STATUS_SUCCESS) { - ++launches; - } - return status; -} diff --git a/test/llm/test_chunk_execution.py b/test/llm/test_chunk_execution.py index 97886751d..4b36b19b2 100644 --- a/test/llm/test_chunk_execution.py +++ b/test/llm/test_chunk_execution.py @@ -4,7 +4,7 @@ from unittest.mock import patch from cache_test_support import MODULES -from config_test_support import LLM_MODULE, load_module +from config_test_support import LLM_MODULE, EngineConfig, load_module def load_processor(): @@ -216,3 +216,101 @@ def test_processor_slices_partial_prefill_positions_and_lengths(self): self.assertEqual(inputs["total_kv_lengths"], [32]) self.assertEqual(inputs["input_offsets"], [0, 16]) self.assertEqual(len(inputs["slot_mapping"]), 16) + + +def load_runner(): + replacements = { + "infinicore": SimpleNamespace(), + "infinilm.cache.cache": SimpleNamespace( + PagedKVCacheConfig=object, StaticKVCacheConfig=object + ), + "infinilm.config.engine_config": SimpleNamespace(EngineConfig=EngineConfig), + "infinilm.distributed": SimpleNamespace(DistConfig=object), + "infinilm.distributed.pipeline_transport": SimpleNamespace( + PipelineControlServer=object + ), + "infinilm.infer_engine": SimpleNamespace(InferEngine=object), + "infinilm.kv_connector": SimpleNamespace( + KVConnectorFactory=object, KVConnectorRole=object + ), + "infinilm.llm.model_runner.speculative_runner": SimpleNamespace( + SpeculativeRunner=object + ), + "infinilm.modeling_utils": SimpleNamespace( + load_model_state_dict_by_file=object + ), + "infinilm.processors": SimpleNamespace(AutoInfinilmProcessor=object), + } + with patch.dict(sys.modules, replacements): + return load_module( + "infinilm.llm.model_runner.model_runner", "llm/model_runner/model_runner.py" + ).ModelRunner + + +Runner = load_runner() + + +class ChunkOutputTests(unittest.TestCase): + def setup_runner(self): + engine, req = ChunkExecutionTests.setup_engine(self) + runner = Runner.__new__(Runner) + runner.config = EngineConfig("unused", prefill_chunk_size=16) + runner.processor = SimpleNamespace(build_model_inputs=lambda *a: {}) + runner.speculative_runner = None + runner.pipeline_control = None + runner.kv_connector = None + calls = [] + + def forward(**kwargs): + calls.append(kwargs.get("prefill_only", False)) + if kwargs.get("prefill_only"): + return None + return SimpleNamespace( + to_numpy=lambda: SimpleNamespace(tolist=lambda: [77]) + ) + + runner.model_engine = SimpleNamespace(forward=forward) + engine.model_runner = runner + return engine, req, calls + + def test_only_intermediate_chunks_skip_native_output(self): + engine, req, calls = self.setup_runner() + req.sampling_params.max_tokens = 2 + for _ in range(2): + self.assertEqual(engine.step(), (True, [])) + self.assertEqual(list(req.generated_token_ids), []) + self.assertEqual(calls, [True, True]) + engine.step() + self.assertEqual(calls, [True, True, False]) + self.assertEqual(list(req.generated_token_ids), [77]) + engine.step() + self.assertEqual(calls, [True, True, False, False]) + self.assertEqual(list(req.generated_token_ids), [77, 77]) + self.assertTrue( + all(b.ref_count == 0 for b in engine.scheduler.cache_manager.blocks) + ) + + def test_legacy_output_without_chunk_metadata_keeps_sampling(self): + engine, req, calls = self.setup_runner() + output = SimpleNamespace( + scheduled_requests=[req], num_requests=1, is_prefill=True + ) + result = engine.model_runner.execute_model(output) + self.assertEqual(calls, [False]) + self.assertEqual(result.sampled_token_ids, [77]) + + def test_empty_native_output_still_finishes_cancelled_chunk(self): + engine, req, calls = self.setup_runner() + forward = engine.model_runner.model_engine.forward + + def abort(**kwargs): + req.abort() + return forward(**kwargs) + + engine.model_runner.model_engine.forward = abort + self.assertEqual(engine.step(), (True, [])) + self.assertEqual(calls, [True]) + self.assertEqual(list(req.generated_token_ids), []) + self.assertTrue( + all(b.ref_count == 0 for b in engine.scheduler.cache_manager.blocks) + ) diff --git a/test/llm/test_chunk_output.py b/test/llm/test_chunk_output.py deleted file mode 100644 index ae5f45e6c..000000000 --- a/test/llm/test_chunk_output.py +++ /dev/null @@ -1,111 +0,0 @@ -"""Exercise the real runner boundary without loading a native model.""" - -import sys -import unittest -from types import SimpleNamespace -from unittest.mock import patch - -import test_chunk_execution -from config_test_support import EngineConfig, load_module - - -def load_runner(): - replacements = { - "infinicore": SimpleNamespace(), - "infinilm.cache.cache": SimpleNamespace( - PagedKVCacheConfig=object, StaticKVCacheConfig=object - ), - "infinilm.config.engine_config": SimpleNamespace(EngineConfig=EngineConfig), - "infinilm.distributed": SimpleNamespace(DistConfig=object), - "infinilm.distributed.pipeline_transport": SimpleNamespace( - PipelineControlServer=object - ), - "infinilm.infer_engine": SimpleNamespace(InferEngine=object), - "infinilm.kv_connector": SimpleNamespace( - KVConnectorFactory=object, KVConnectorRole=object - ), - "infinilm.llm.model_runner.speculative_runner": SimpleNamespace( - SpeculativeRunner=object - ), - "infinilm.modeling_utils": SimpleNamespace( - load_model_state_dict_by_file=object - ), - "infinilm.processors": SimpleNamespace(AutoInfinilmProcessor=object), - } - with patch.dict(sys.modules, replacements): - return load_module( - "infinilm.llm.model_runner.model_runner", "llm/model_runner/model_runner.py" - ).ModelRunner - - -Runner = load_runner() - - -class ChunkOutputTests(unittest.TestCase): - def setup_runner(self): - engine, req = test_chunk_execution.ChunkExecutionTests.setup_engine(self) - runner = Runner.__new__(Runner) - runner.config = EngineConfig("unused", prefill_chunk_size=16) - runner.processor = SimpleNamespace(build_model_inputs=lambda *a: {}) - runner.speculative_runner = None - runner.pipeline_control = None - runner.kv_connector = None - calls = [] - - def forward(**kwargs): - calls.append(kwargs.get("prefill_only", False)) - if kwargs.get("prefill_only"): - return None - return SimpleNamespace( - to_numpy=lambda: SimpleNamespace(tolist=lambda: [77]) - ) - - runner.model_engine = SimpleNamespace(forward=forward) - engine.model_runner = runner - return engine, req, calls - - def test_only_intermediate_chunks_skip_native_output(self): - engine, req, calls = self.setup_runner() - req.sampling_params.max_tokens = 2 - for _ in range(2): - self.assertEqual(engine.step(), (True, [])) - self.assertEqual(list(req.generated_token_ids), []) - self.assertEqual(calls, [True, True]) - engine.step() - self.assertEqual(calls, [True, True, False]) - self.assertEqual(list(req.generated_token_ids), [77]) - engine.step() - self.assertEqual(calls, [True, True, False, False]) - self.assertEqual(list(req.generated_token_ids), [77, 77]) - self.assertTrue( - all(b.ref_count == 0 for b in engine.scheduler.cache_manager.blocks) - ) - - def test_legacy_output_without_chunk_metadata_keeps_sampling(self): - engine, req, calls = self.setup_runner() - output = SimpleNamespace( - scheduled_requests=[req], num_requests=1, is_prefill=True - ) - result = engine.model_runner.execute_model(output) - self.assertEqual(calls, [False]) - self.assertEqual(result.sampled_token_ids, [77]) - - def test_empty_native_output_still_finishes_cancelled_chunk(self): - engine, req, calls = self.setup_runner() - forward = engine.model_runner.model_engine.forward - - def abort(**kwargs): - req.abort() - return forward(**kwargs) - - engine.model_runner.model_engine.forward = abort - self.assertEqual(engine.step(), (True, [])) - self.assertEqual(calls, [True]) - self.assertEqual(list(req.generated_token_ids), []) - self.assertTrue( - all(b.ref_count == 0 for b in engine.scheduler.cache_manager.blocks) - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/test/llm/test_engine_config.py b/test/llm/test_engine_config.py index d53c3341c..3da00be82 100644 --- a/test/llm/test_engine_config.py +++ b/test/llm/test_engine_config.py @@ -6,7 +6,7 @@ import runpy import sys import unittest -from contextlib import redirect_stderr, redirect_stdout +from contextlib import contextmanager, redirect_stderr, redirect_stdout from types import SimpleNamespace from unittest.mock import patch @@ -39,39 +39,38 @@ class EngineConfigTests(unittest.TestCase): - def parse_cli(self, *args): + @contextmanager + def cli(self, *args): with ( patch.object( sys, "argv", ["server", "--model", "unused", "--device", "cpu", *args] ), patch.dict(os.environ), ): + yield + + def parse_cli(self, *args): + with self.cli(*args): return BaseConfig() - def assert_options(self, config): - for key, value in OPTIONS.items(): + def assert_options(self, config, expected=OPTIONS): + for key, value in expected.items(): self.assertEqual(getattr(config, key), value) def test_defaults_preserve_existing_behavior(self): - for cache_type in ("paged", "static"): - config = EngineConfig("unused", cache_type=cache_type) - self.assertEqual( - ( - config.prefix_cache_policy, - config.prefix_cache_protected_ratio, - config.prefill_chunk_size, + for config in ( + EngineConfig("unused", cache_type="paged"), + EngineConfig("unused", cache_type="static"), + self.parse_cli(), + ): + self.assert_options( + config, + dict( + prefix_cache_policy="lru", + prefix_cache_protected_ratio=0.8, + prefill_chunk_size=0, ), - ("lru", 0.8, 0), ) - cli = self.parse_cli() - self.assertEqual( - ( - cli.prefix_cache_policy, - cli.prefix_cache_protected_ratio, - cli.prefill_chunk_size, - ), - ("lru", 0.8, 0), - ) def test_invalid_policy_ratio_and_chunk_values(self): invalid = [("prefix_cache_policy", "fifo")] @@ -261,18 +260,29 @@ def make_runner(config, initialize_processor): ) self.assertEqual(closed, [True]) - def test_cli_reaches_server_lifespan(self): + def check_entrypoint(self, main): configs = [] + def make_engine(config): + configs.append(config) + return SimpleNamespace(config=config, close=lambda: None) + + with ( + self.cli(*CLI), + patch.object(LLM_MODULE, "LLMEngine", make_engine), + redirect_stdout(io.StringIO()), + ): + main() + self.assertEqual(len(configs), 1) + self.assert_options(configs[0]) + + def test_cli_reaches_server_lifespan(self): async def start_without_listener(server): app = server._create_app() async with app.router.lifespan_context(app): - configs.append(server.engine.config) + self.assert_options(server.engine.config) with ( - patch.object( - LLM_MODULE, "LLMEngine", lambda config: SimpleNamespace(config=config) - ), patch.object(LLM_MODULE.AsyncLLMEngine, "start"), patch.object(LLM_MODULE.AsyncLLMEngine, "stop"), patch.object( @@ -281,17 +291,10 @@ async def start_without_listener(server): lambda server: asyncio.run(start_without_listener(server)), ), patch.object(SERVER_MODULE, "setup_logging"), - patch.dict(os.environ), - patch.object( - sys, "argv", ["server", "--model", "unused", "--device", "cpu", *CLI] - ), ): - SERVER_MODULE.main() - self.assertEqual(len(configs), 1) - self.assert_options(configs[0]) + self.check_entrypoint(SERVER_MODULE.main) def test_offline_cli_reaches_llm(self): - configs = [] modules = { "infinilm.base_config": SimpleNamespace(BaseConfig=BaseConfig), "infinilm.llm.llm": LLM_MODULE, @@ -303,32 +306,17 @@ def test_offline_cli_reaches_llm(self): ), } - def chat(model, messages): - configs.append(model.config) - return [] - with ( patch.dict(sys.modules, modules), - patch.dict(os.environ), - patch.object( - sys, - "argv", - ["test_infer", "--model", "unused", "--device", "cpu", *CLI], - ), - patch.object( - LLM_MODULE, - "LLMEngine", - lambda config: SimpleNamespace(close=lambda: None), - ), - patch.object(LLM_MODULE.LLM, "chat", chat), + patch.object(LLM_MODULE.LLM, "chat", return_value=[]), patch.object(SERVER_MODULE.logging, "basicConfig"), - redirect_stdout(io.StringIO()), ): - runpy.run_path( - str(SOURCE.parents[1] / "examples/test_infer.py"), run_name="__main__" + self.check_entrypoint( + lambda: runpy.run_path( + str(SOURCE.parents[1] / "examples/test_infer.py"), + run_name="__main__", + ) ) - self.assertEqual(len(configs), 1) - self.assert_options(configs[0]) if __name__ == "__main__":