已合并
[bugfix]cpu/disk命中根据parent_hash + token_hash查找 #601
ganglv创建于 27 天前
[bugfix]cpu/disk命中根据parent_hash + token_hash查找 #601
已合并
共 31 个文件变更+2669-1104
| @@ -127,3 +127,6 @@ motor/kv_conductor/curl.sh | |||
| 127 | Dockerfile.motor-e2e-kv | 127 | Dockerfile.motor-e2e-kv |
| 128 | motor/kv_conductor/.cargo/ | 128 | motor/kv_conductor/.cargo/ |
| 129 | motor/kv_conductor/mock/ | 129 | motor/kv_conductor/mock/ |
| 130 | + | ||
| 131 | +# gitleaks | ||
| 132 | +gitleaks | ||
| @@ -9,7 +9,7 @@ | |||
| 9 | # See the Mulan PSL v2 for more details. | 9 | # See the Mulan PSL v2 for more details. |
| 10 | 10 | ||
| 11 | # Copyright Huawei Technologies Co., Ltd. 2026. All rights reserved. | 11 | # Copyright Huawei Technologies Co., Ltd. 2026. All rights reserved. |
| 12 | -from datetime import datetime, timezone | 12 | +from datetime import datetime, timedelta, timezone |
| 13 | import configparser | 13 | import configparser |
| 14 | import logging | 14 | import logging |
| 15 | import logging.handlers | 15 | import logging.handlers |
| @@ -227,9 +227,24 @@ class LogMonitor: | |||
| 227 | log_e(f"shell_get_pod Exception: {e}") | 227 | log_e(f"shell_get_pod Exception: {e}") |
| 228 | return None | 228 | return None |
| 229 | 229 | ||
| 230 | - def shell_pull_log(self, pod_name: str, file_path: str, interval: float = 0.2) -> bool: | 230 | + @staticmethod |
| 231 | + def _kubectl_since_time(when: datetime | None = None, lookback_seconds: float = 2.0) -> str: | ||
| 232 | + """RFC3339 UTC timestamp for ``kubectl logs --since-time`` (small lookback avoids gaps).""" | ||
| 233 | + ts = (when or datetime.now(timezone.utc)) - timedelta(seconds=lookback_seconds) | ||
| 234 | + return ts.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") | ||
| 235 | + | ||
| 236 | + def shell_pull_log( | ||
| 237 | + self, | ||
| 238 | + pod_name: str, | ||
| 239 | + file_path: str, | ||
| 240 | + interval: float = 0.2, | ||
| 241 | + since_time: str | None = None, | ||
| 242 | + ) -> bool: | ||
| 231 | """ | 243 | """ |
| 232 | Execute the kubectl command to obtain the log. | 244 | Execute the kubectl command to obtain the log. |
| 245 | + | ||
| 246 | + :param since_time: If set, pass ``--since-time`` so reconnects only fetch | ||
| 247 | + new lines (avoids re-dumping the full container history). | ||
| 233 | """ | 248 | """ |
| 234 | b_write_flag = False | 249 | b_write_flag = False |
| 235 | abs_path = os.path.abspath(os.path.normpath(file_path)) | 250 | abs_path = os.path.abspath(os.path.normpath(file_path)) |
| @@ -240,10 +255,21 @@ class LogMonitor: | |||
| 240 | 255 | ||
| 241 | process = None | 256 | process = None |
| 242 | try: | 257 | try: |
| 258 | + kubectl_args = [ | ||
| 259 | + self.cmd_kubectl, | ||
| 260 | + 'logs', | ||
| 261 | + '-f', | ||
| 262 | + '-n', | ||
| 263 | + g_name_space, | ||
| 264 | + pod_name, | ||
| 265 | + ] | ||
| 266 | + if since_time: | ||
| 267 | + kubectl_args.extend(['--since-time', since_time]) | ||
| 268 | + | ||
| 243 | # Long-running kubectl logs -f; with-statement is unsuitable | 269 | # Long-running kubectl logs -f; with-statement is unsuitable |
| 244 | # because the process is terminated in the finally block. | 270 | # because the process is terminated in the finally block. |
| 245 | process = subprocess.Popen( # pylint: disable=consider-using-with | 271 | process = subprocess.Popen( # pylint: disable=consider-using-with |
| 246 | - [self.cmd_kubectl, 'logs', '-f', '-n', g_name_space, pod_name], | 272 | + kubectl_args, |
| 247 | stdout=subprocess.PIPE, | 273 | stdout=subprocess.PIPE, |
| 248 | stderr=subprocess.PIPE, | 274 | stderr=subprocess.PIPE, |
| 249 | text=True, | 275 | text=True, |
| @@ -255,6 +281,8 @@ class LogMonitor: | |||
| 255 | if abs_path not in self._logged_save_paths: | 281 | if abs_path not in self._logged_save_paths: |
| 256 | self._logged_save_paths.add(abs_path) | 282 | self._logged_save_paths.add(abs_path) |
| 257 | log_i(f"{pod_name}: logs save to: {abs_path} (max {size:.1f}MB, keep {g_backup_count} backups)") | 283 | log_i(f"{pod_name}: logs save to: {abs_path} (max {size:.1f}MB, keep {g_backup_count} backups)") |
| 284 | + elif since_time: | ||
| 285 | + log_i(f"{pod_name}: reconnecting logs with --since-time={since_time}") | ||
| 258 | 286 | ||
| 259 | # Reads the output in real time and writes it to the log file. | 287 | # Reads the output in real time and writes it to the log file. |
| 260 | while not self.exit_flag.is_set(): | 288 | while not self.exit_flag.is_set(): |
| @@ -276,7 +304,10 @@ class LogMonitor: | |||
| 276 | # Ensure the child process is terminated | 304 | # Ensure the child process is terminated |
| 277 | if process and process.poll() is None: | 305 | if process and process.poll() is None: |
| 278 | process.terminate() | 306 | process.terminate() |
| 279 | - log_i(f"{pod_name} :The thread has exited.") | 307 | + for handler in list(logger.handlers): |
| 308 | + handler.close() | ||
| 309 | + logger.removeHandler(handler) | ||
| 310 | + log_i(f"{pod_name}: log stream session ended.") | ||
| 280 | return b_write_flag | 311 | return b_write_flag |
| 281 | 312 | ||
| 282 | def pull_log_and_save(self, pod_name: str, interval: float = 3) -> None: | 313 | def pull_log_and_save(self, pod_name: str, interval: float = 3) -> None: |
| @@ -288,6 +319,13 @@ class LogMonitor: | |||
| 288 | next_slot = self._pod_log_next_slot.get(pod_name, 0) | 319 | next_slot = self._pod_log_next_slot.get(pod_name, 0) |
| 289 | index = max(generation_floor, next_slot) | 320 | index = max(generation_floor, next_slot) |
| 290 | node_name = self.shell_get_pod_node(pod_name) | 321 | node_name = self.shell_get_pod_node(pod_name) |
| 322 | + # Reuse one rotating log file across kubectl stream reconnects; only | ||
| 323 | + # allocate a new _{n}.log when the collector starts fresh or the pod | ||
| 324 | + # moves to another node. Otherwise each ~4h apiserver disconnect would | ||
| 325 | + # re-dump full history into _1/_2/... (and duplicate .log.1 rotations). | ||
| 326 | + file_path: str | None = None | ||
| 327 | + allocated_log_index: int | None = None | ||
| 328 | + since_time: str | None = None | ||
| 291 | try: | 329 | try: |
| 292 | while not self.exit_flag.is_set(): | 330 | while not self.exit_flag.is_set(): |
| 293 | pod_running_state = self.check_pod_is_running(pod_name) | 331 | pod_running_state = self.check_pod_is_running(pod_name) |
| @@ -301,26 +339,37 @@ class LogMonitor: | |||
| 301 | fetched_node_name = self.shell_get_pod_node(pod_name) | 339 | fetched_node_name = self.shell_get_pod_node(pod_name) |
| 302 | if fetched_node_name != node_name: | 340 | if fetched_node_name != node_name: |
| 303 | log_i(f"{pod_name}: node_name refresh {node_name!r} -> {fetched_node_name!r}") | 341 | log_i(f"{pod_name}: node_name refresh {node_name!r} -> {fetched_node_name!r}") |
| 304 | - node_name = fetched_node_name | 342 | + node_name = fetched_node_name |
| 305 | - file_path, allocated_log_index = self._allocate_unique_log_path(pod_name, node_name, index) | 343 | + # New node => new file name; fetch full history for this placement. |
| 306 | - if allocated_log_index != index: | 344 | + if allocated_log_index is not None: |
| 307 | - log_i( | 345 | + index = max(index, allocated_log_index + 1) |
| 308 | - f"{pod_name}: log file slot bumped {index} -> {allocated_log_index} " | 346 | + file_path = None |
| 309 | - "(target path already exists)." | 347 | + allocated_log_index = None |
| 310 | - ) | 348 | + since_time = None |
| 311 | - if self.shell_pull_log(pod_name, file_path): | 349 | + if file_path is None: |
| 312 | - next_after = allocated_log_index + 1 | 350 | + file_path, allocated_log_index = self._allocate_unique_log_path(pod_name, node_name, index) |
| 351 | + if allocated_log_index != index: | ||
| 352 | + log_i( | ||
| 353 | + f"{pod_name}: log file slot bumped {index} -> {allocated_log_index} " | ||
| 354 | + "(target path already exists)." | ||
| 355 | + ) | ||
| 356 | + index = allocated_log_index | ||
| 357 | + if self.shell_pull_log(pod_name, file_path, since_time=since_time): | ||
| 358 | + # Reserve next slot for a future collector generation, but keep | ||
| 359 | + # appending to the same file for this thread's reconnects. | ||
| 313 | self._pod_log_next_slot[pod_name] = max( | 360 | self._pod_log_next_slot[pod_name] = max( |
| 314 | self._pod_log_next_slot.get(pod_name, 0), | 361 | self._pod_log_next_slot.get(pod_name, 0), |
| 315 | - next_after, | 362 | + allocated_log_index + 1, |
| 316 | ) | 363 | ) |
| 317 | - index = self._pod_log_next_slot[pod_name] | 364 | + # Capture before sleep so logs during the pause are still fetched. |
| 318 | - # Pod restart — apply exponential backoff to avoid log | 365 | + since_time = self._kubectl_since_time() |
| 319 | - # duplication from frequent restarts (e.g. crash-loop). | 366 | + # Stream reconnect (not a pod crash-loop) — keep a short pause. |
| 320 | - delay = self._backoff_sleep(pod_name) | 367 | + # Backoff is reserved for cases that start a brand-new file. |
| 368 | + delay = interval | ||
| 321 | log_i( | 369 | log_i( |
| 322 | f"{pod_name}: Log stream ended; pausing {delay:.0f}s before " | 370 | f"{pod_name}: Log stream ended; pausing {delay:.0f}s before " |
| 323 | - "re-checking pod and reopening logs if still Running." | 371 | + f"re-checking pod and reopening logs " |
| 372 | + f"(same file, --since-time={since_time})." | ||
| 324 | ) | 373 | ) |
| 325 | time.sleep(delay) | 374 | time.sleep(delay) |
| 326 | else: | 375 | else: |
| @@ -329,8 +378,13 @@ class LogMonitor: | |||
| 329 | os.remove(file_path) | 378 | os.remove(file_path) |
| 330 | except OSError: | 379 | except OSError: |
| 331 | pass | 380 | pass |
| 332 | - log_w(f"{pod_name}: Failed to pull logs; pausing {interval}s before retry.") | 381 | + file_path = None |
| 333 | - time.sleep(interval) | 382 | + allocated_log_index = None |
| 383 | + since_time = None | ||
| 384 | + # Failed / crash-loop pulls — exponential backoff before retry. | ||
| 385 | + delay = self._backoff_sleep(pod_name) | ||
| 386 | + log_w(f"{pod_name}: Failed to pull logs; pausing {delay:.0f}s before retry.") | ||
| 387 | + time.sleep(delay) | ||
| 334 | except Exception as e: | 388 | except Exception as e: |
| 335 | log_e(f"{pod_name} :Exception: {e}") | 389 | log_e(f"{pod_name} :Exception: {e}") |
| 336 | finally: | 390 | finally: |
| @@ -264,7 +264,7 @@ | |||
| 264 | "block_size": 128, | 264 | "block_size": 128, |
| 265 | "engine_type": "vLLM", | 265 | "engine_type": "vLLM", |
| 266 | "pool_endpoint": "", | 266 | "pool_endpoint": "", |
| 267 | - "xpu_endpoint": "", | 267 | + "npu_endpoint": "", |
| 268 | "cpu_endpoint": "", | 268 | "cpu_endpoint": "", |
| 269 | "disk_endpoint": "", | 269 | "disk_endpoint": "", |
| 270 | "endpoint": "", | 270 | "endpoint": "", |
| @@ -280,21 +280,30 @@ def _update_prefill_kv_event_config(updated_config: dict[str, Any], user_config_ | |||
| 280 | 280 | ||
| 281 | 281 | ||
| 282 | def _redirect_prefill_kv_event_config(updated_config: dict[str, Any], user_config_data: dict[str, Any]) -> None: | 282 | def _redirect_prefill_kv_event_config(updated_config: dict[str, Any], user_config_data: dict[str, Any]) -> None: |
| 283 | - """Redirect legacy prefill_kv_event_config into the unified kv_conductor_config. | 283 | + """Redirect legacy / top-level kv conductor settings into scheduler_config. |
| 284 | 284 | ||
| 285 | - 1. If the user config still has a ``prefill_kv_event_config`` section, merge its | 285 | + 1. Merge top-level ``user_config.kv_conductor_config`` into |
| 286 | + ``scheduler_config.kv_conductor_config`` (deployer convention). | ||
| 287 | + 2. If the user config still has a ``prefill_kv_event_config`` section, merge its | ||
| 286 | fields into ``kv_conductor_config`` (backward compat). | 288 | fields into ``kv_conductor_config`` (backward compat). |
| 287 | - 2. Auto-derive connection info from engine sections and kv_conductor_config. | 289 | + 3. Auto-derive connection info from engine sections when fields are unset. |
| 288 | """ | 290 | """ |
| 289 | try: | 291 | try: |
| 290 | reg = updated_config.setdefault("scheduler_config", {}).setdefault(KV_CONDUCTOR_CONFIG, {}) | 292 | reg = updated_config.setdefault("scheduler_config", {}).setdefault(KV_CONDUCTOR_CONFIG, {}) |
| 291 | 293 | ||
| 294 | + # ── Top-level user_config.kv_conductor_config (deployer) ────── | ||
| 295 | + top_level = user_config_data.get(KV_CONDUCTOR_CONFIG) | ||
| 296 | + if isinstance(top_level, dict): | ||
| 297 | + for key, value in top_level.items(): | ||
| 298 | + if value not in (None, "") and not reg.get(key): | ||
| 299 | + reg[key] = value | ||
| 300 | + | ||
| 292 | # ── Backward compat: migrate old prefill_kv_event_config ────── | 301 | # ── Backward compat: migrate old prefill_kv_event_config ────── |
| 293 | old_config = updated_config.pop(PREFILL_KV_EVENT_CONFIG, None) | 302 | old_config = updated_config.pop(PREFILL_KV_EVENT_CONFIG, None) |
| 294 | if isinstance(old_config, dict): | 303 | if isinstance(old_config, dict): |
| 295 | logger.warning( | 304 | logger.warning( |
| 296 | "prefill_kv_event_config is deprecated and will be removed in a future version. " | 305 | "prefill_kv_event_config is deprecated and will be removed in a future version. " |
| 297 | - "Please migrate to kv_conductor_config under scheduler_config. " | 306 | + "Please migrate to top-level kv_conductor_config (or scheduler_config.kv_conductor_config). " |
| 298 | "See docs/zh/user_guide/features/kvcache_affinity.md for details." | 307 | "See docs/zh/user_guide/features/kvcache_affinity.md for details." |
| 299 | ) | 308 | ) |
| 300 | for key in ( | 309 | for key in ( |
| @@ -157,8 +157,8 @@ class KvConductorConfig: | |||
| 157 | the kv-conductor. Behaviour varies by ``store_backend``: | 157 | the kv-conductor. Behaviour varies by ``store_backend``: |
| 158 | 158 | ||
| 159 | - Mooncake / Memcache: register the pool once (``pool_endpoint``) + | 159 | - Mooncake / Memcache: register the pool once (``pool_endpoint``) + |
| 160 | - per-DP HBM via ``xpu_endpoint``. | 160 | + per-DP HBM via ``npu_endpoint``. |
| 161 | - - YuanRong: per-DP multi-port via ``xpu/cpu/disk_endpoint`` patterns. | 161 | + - YuanRong: per-DP multi-port via ``npu/cpu/disk_endpoint`` patterns. |
| 162 | 162 | ||
| 163 | Endpoint patterns use ``*`` as IP placeholder and add ``dp_rank`` | 163 | Endpoint patterns use ``*`` as IP placeholder and add ``dp_rank`` |
| 164 | to the port, e.g. ``"tcp://*:15557"`` resolves to | 164 | to the port, e.g. ``"tcp://*:15557"`` resolves to |
| @@ -195,9 +195,12 @@ class KvConductorConfig: | |||
| 195 | pool_endpoint: str = "" | 195 | pool_endpoint: str = "" |
| 196 | """Pool service endpoint for centralized backends, e.g. "tcp://kvp-master:5557".""" | 196 | """Pool service endpoint for centralized backends, e.g. "tcp://kvp-master:5557".""" |
| 197 | 197 | ||
| 198 | - xpu_endpoint: str = "" | 198 | + npu_endpoint: str = "" |
| 199 | """Per-DP HBM ZMQ PUB endpoint pattern, e.g. "tcp://*:50090".""" | 199 | """Per-DP HBM ZMQ PUB endpoint pattern, e.g. "tcp://*:50090".""" |
| 200 | 200 | ||
| 201 | + xpu_endpoint: str = "" | ||
| 202 | + """Deprecated alias of ``npu_endpoint``; used when ``npu_endpoint`` is empty.""" | ||
| 203 | + | ||
| 201 | cpu_endpoint: str = "" | 204 | cpu_endpoint: str = "" |
| 202 | """Per-DP CPU/DDR ZMQ PUB endpoint pattern, e.g. "tcp://*:15558".""" | 205 | """Per-DP CPU/DDR ZMQ PUB endpoint pattern, e.g. "tcp://*:15558".""" |
| 203 | 206 | ||
| @@ -129,7 +129,11 @@ class ConductorApiClient: | |||
| 129 | def _register_hbm_dp(cls, reg, store_backend: str, instance: "Instance", endpoint: "Endpoint") -> None: | 129 | def _register_hbm_dp(cls, reg, store_backend: str, instance: "Instance", endpoint: "Endpoint") -> None: |
| 130 | """Register a single DP's HBM endpoint for pool-backend auto-attach.""" | 130 | """Register a single DP's HBM endpoint for pool-backend auto-attach.""" |
| 131 | instance_id = conductor_instance_id(instance) | 131 | instance_id = conductor_instance_id(instance) |
| 132 | - xpu_url = cls._resolve_endpoint_url(reg.xpu_endpoint or reg.endpoint, endpoint.ip, endpoint.id) | 132 | + npu_url = cls._resolve_endpoint_url( |
| 133 | + reg.npu_endpoint or reg.xpu_endpoint or reg.endpoint, | ||
| 134 | + endpoint.ip, | ||
| 135 | + endpoint.id, | ||
| 136 | + ) | ||
| 133 | 137 | ||
| 134 | replay_url = cls._resolve_endpoint_url(reg.replay_endpoint, endpoint.ip, endpoint.id) | 138 | replay_url = cls._resolve_endpoint_url(reg.replay_endpoint, endpoint.ip, endpoint.id) |
| 135 | register_data: dict = { | 139 | register_data: dict = { |
| @@ -140,8 +144,8 @@ class ConductorApiClient: | |||
| 140 | "block_size": reg.block_size, | 144 | "block_size": reg.block_size, |
| 141 | "dp_rank": endpoint.id, | 145 | "dp_rank": endpoint.id, |
| 142 | } | 146 | } |
| 143 | - if xpu_url: | 147 | + if npu_url: |
| 144 | - register_data["medium_endpoints"] = {"xpu": xpu_url} | 148 | + register_data["medium_endpoints"] = {"npu": npu_url} |
| 145 | if TENANT_ID != "default": | 149 | if TENANT_ID != "default": |
| 146 | register_data["tenant_id"] = TENANT_ID | 150 | register_data["tenant_id"] = TENANT_ID |
| 147 | if replay_url: | 151 | if replay_url: |
| @@ -151,7 +155,7 @@ class ConductorApiClient: | |||
| 151 | try: | 155 | try: |
| 152 | with SafeHTTPSClient(timeout=15, **client_args) as client: | 156 | with SafeHTTPSClient(timeout=15, **client_args) as client: |
| 153 | client.post("/register", register_data) | 157 | client.post("/register", register_data) |
| 154 | - mode = "ZMQ+HTTP" if xpu_url else "HTTP-only" | 158 | + mode = "ZMQ+HTTP" if npu_url else "HTTP-only" |
| 155 | logger.info( | 159 | logger.info( |
| 156 | "HBM DP registered (%s): instance=%s dp=%d replay=%s", | 160 | "HBM DP registered (%s): instance=%s dp=%d replay=%s", |
| 157 | mode, | 161 | mode, |
| @@ -218,12 +222,12 @@ class ConductorApiClient: | |||
| 218 | 222 | ||
| 219 | def _build_medium_endpoints(cls, config, ip: str, dp_rank: int) -> dict[str, str]: | 223 | def _build_medium_endpoints(cls, config, ip: str, dp_rank: int) -> dict[str, str]: |
| 220 | """Build the medium_endpoints map from per-medium endpoint patterns.""" | 224 | """Build the medium_endpoints map from per-medium endpoint patterns.""" |
| 221 | - xpu_url = cls._resolve_endpoint_url(config.xpu_endpoint, ip, dp_rank) | 225 | + npu_url = cls._resolve_endpoint_url(config.npu_endpoint or config.xpu_endpoint, ip, dp_rank) |
| 222 | cpu_url = cls._resolve_endpoint_url(config.cpu_endpoint, ip, dp_rank) | 226 | cpu_url = cls._resolve_endpoint_url(config.cpu_endpoint, ip, dp_rank) |
| 223 | disk_url = cls._resolve_endpoint_url(config.disk_endpoint, ip, dp_rank) | 227 | disk_url = cls._resolve_endpoint_url(config.disk_endpoint, ip, dp_rank) |
| 224 | fallback = cls._resolve_endpoint_url(config.endpoint, ip, dp_rank) | 228 | fallback = cls._resolve_endpoint_url(config.endpoint, ip, dp_rank) |
| 225 | return { | 229 | return { |
| 226 | - "xpu": xpu_url or fallback or "", | 230 | + "npu": npu_url or fallback or "", |
| 227 | "cpu": cpu_url or fallback or "", | 231 | "cpu": cpu_url or fallback or "", |
| 228 | "disk": disk_url or fallback or "", | 232 | "disk": disk_url or fallback or "", |
| 229 | } | 233 | } |
| @@ -309,7 +313,7 @@ class ConductorApiClient: | |||
| 309 | 313 | ||
| 310 | 314 | ||
| 311 | def query_conductor(cls, instances: list[Instance], encoded_ids: list[int]) -> dict[str, Any]: | 315 | def query_conductor(cls, instances: list[Instance], encoded_ids: list[int]) -> dict[str, Any]: |
| 312 | - """Query KV conductor for prefix cache overlap scores. | 316 | + """Query KV conductor for prefix cache matched blocks. |
| 313 | 317 | ||
| 314 | Circuit breaker: after ``_QUERY_CB_THRESHOLD`` consecutive failures, | 318 | Circuit breaker: after ``_QUERY_CB_THRESHOLD`` consecutive failures, |
| 315 | skip queries for ``_QUERY_CB_COOLDOWN`` seconds. | 319 | skip queries for ``_QUERY_CB_COOLDOWN`` seconds. |
| @@ -345,7 +349,7 @@ class ConductorApiClient: | |||
| 345 | try: | 349 | try: |
| 346 | with SafeHTTPSClient(timeout=3, **client_args) as client: | 350 | with SafeHTTPSClient(timeout=3, **client_args) as client: |
| 347 | response = client.post("/query", query_data) | 351 | response = client.post("/query", query_data) |
| 348 | - cls._log_hit_summary(response, reg.block_size) | 352 | + logger.info("conductor query response: %s", response) |
| 349 | cls._query_failures = 0 # reset on success | 353 | cls._query_failures = 0 # reset on success |
| 350 | return response | 354 | return response |
| 351 | except Exception as e: | 355 | except Exception as e: |
| @@ -366,69 +370,6 @@ class ConductorApiClient: | |||
| 366 | ) | 370 | ) |
| 367 | return {} | 371 | return {} |
| 368 | 372 | ||
| 369 | - | ||
| 370 | - def _log_hit_summary(cls, response: dict[str, Any], block_size: int = 128) -> None: | ||
| 371 | - """Log a concise per-instance hit summary from the query response.""" | ||
| 372 | - if not isinstance(response, dict): | ||
| 373 | - return | ||
| 374 | - for tenant_id, instances in response.items(): | ||
| 375 | - if not isinstance(instances, dict): | ||
| 376 | - continue | ||
| 377 | - for inst_id, imd in instances.items(): | ||
| 378 | - if not isinstance(imd, dict): | ||
| 379 | - continue | ||
| 380 | - longest = imd.get("longest_matched", 0) # tokens (blocks × block_size) | ||
| 381 | - dp = imd.get("DP", {}) | ||
| 382 | - total_score = imd.get("total_score", 0) | ||
| 383 | - | ||
| 384 | - # Aggregate per-DP hit info for the log line. | ||
| 385 | - any_hit = False | ||
| 386 | - dp_parts = [] | ||
| 387 | - media_parts = [] | ||
| 388 | - if isinstance(dp, dict): | ||
| 389 | - for rank, v in sorted(dp.items(), key=lambda x: int(x[0])): | ||
| 390 | - if isinstance(v, dict): | ||
| 391 | - mt = v.get("matched_tokens", 0) | ||
| 392 | - s = v.get("total", 0) | ||
| 393 | - xpu_blk = v.get("XPU_blk", 0) | ||
| 394 | - cpu_blk = v.get("CPU_blk", 0) | ||
| 395 | - disk_blk = v.get("DISK_blk", 0) | ||
| 396 | - dp_parts.append(f"{rank}:{mt}t/{s}pts") | ||
| 397 | - if xpu_blk or cpu_blk or disk_blk: | ||
| 398 | - any_hit = True | ||
| 399 | - media_fmt = cls._fmt_medium(xpu_blk, cpu_blk, disk_blk, block_size) | ||
| 400 | - if media_fmt: | ||
| 401 | - media_parts.append(f" conductor media: {tenant_id}/{inst_id} dp={rank} {media_fmt}") | ||
| 402 | - else: | ||
| 403 | - dp_parts.append(f"{rank}:{v}t") | ||
| 404 | - | ||
| 405 | - parts = [ | ||
| 406 | - f"matched={longest}t", | ||
| 407 | - f"score={total_score}", | ||
| 408 | - f"DP={{{','.join(dp_parts)}}}", | ||
| 409 | - ] | ||
| 410 | - logger.info( | ||
| 411 | - "conductor hit: %s/%s %s %s", | ||
| 412 | - tenant_id, | ||
| 413 | - inst_id, | ||
| 414 | - "HIT" if any_hit else "MISS", | ||
| 415 | - " ".join(parts), | ||
| 416 | - ) | ||
| 417 | - for mp in media_parts: | ||
| 418 | - logger.info(mp) | ||
| 419 | - | ||
| 420 | - | ||
| 421 | - def _fmt_medium(xpu_blk: int, cpu_blk: int, disk_blk: int, block_size: int) -> str: | ||
| 422 | - """Format per-medium hit as e.g. ``XPU=768t(6blk) CPU=0t DISK=0t``.""" | ||
| 423 | - parts = [] | ||
| 424 | - for label, blk in [("XPU", xpu_blk), ("CPU", cpu_blk), ("DISK", disk_blk)]: | ||
| 425 | - if blk: | ||
| 426 | - tok = blk * block_size | ||
| 427 | - parts.append(f"{label}={tok}t({blk}blk)") | ||
| 428 | - else: | ||
| 429 | - parts.append(f"{label}=0t") | ||
| 430 | - return " ".join(parts) | ||
| 431 | - | ||
| 432 | 373 | ||
| 433 | def _build_register_payload(cls, instance: Instance, endpoint: Endpoint) -> dict[str, Any]: | 374 | def _build_register_payload(cls, instance: Instance, endpoint: Endpoint) -> dict[str, Any]: |
| 434 | """Build registration payload using the unified kv_conductor_config config. | 375 | """Build registration payload using the unified kv_conductor_config config. |
| @@ -259,11 +259,10 @@ class KvCacheAffinityPolicy(WorkloadLedgerMixin, BaseSchedulingPolicy): | |||
| 259 | # already excludes headless endpoints / respects enable_multi_endpoints. | 259 | # already excludes headless endpoints / respects enable_multi_endpoints. |
| 260 | for ep in instance.get_all_endpoints(): | 260 | for ep in instance.get_all_endpoints(): |
| 261 | matched_raw = dp_map.get(f"{ep.id}", 0) | 261 | matched_raw = dp_map.get(f"{ep.id}", 0) |
| 262 | - # Conductor reports per-DP match data. Since the multi-medium scoring | 262 | + # Conductor reports per-DP match data. Since the multi-medium blocks |
| 263 | - # revision (DpScoring struct), the value is a dict with a "matched_tokens" | 263 | + # revision (DpBlocks struct), the value is a dict with a "matched_tokens" |
| 264 | - # key (plus "XPU"/"CPU"/"DISK" per-medium breakdown for future | 264 | + # key (plus "npu_blocks"/"cpu_blocks"/"disk_blocks" per-medium block counts); |
| 265 | - # medium-aware scheduling); older conductors returned a plain int. | 265 | + # older conductors returned a plain int. Handle both. |
| 266 | - # Handle both. | ||
| 267 | if isinstance(matched_raw, dict): | 266 | if isinstance(matched_raw, dict): |
| 268 | matched = matched_raw.get("matched_tokens", 0) | 267 | matched = matched_raw.get("matched_tokens", 0) |
| 269 | else: | 268 | else: |
| @@ -467,7 +466,7 @@ class TokenizerManager(ThreadSafeSingleton): | |||
| 467 | is_vllm_engine = engine_type == "vllm" | 466 | is_vllm_engine = engine_type == "vllm" |
| 468 | 467 | ||
| 469 | if is_vllm_engine and self._is_deepseek_v4_model(model_path): | 468 | if is_vllm_engine and self._is_deepseek_v4_model(model_path): |
| 470 | - from vllm.tokenizers.deepseek_v4 import DeepseekV4Tokenizer | 469 | + from vllm.tokenizers.deepseek_v4 import DeepseekV4Tokenizer # pylint: disable=no-name-in-module |
| 471 | 470 | ||
| 472 | self.tokenizer = DeepseekV4Tokenizer.from_pretrained(model_path, trust_remote_code=True) | 471 | self.tokenizer = DeepseekV4Tokenizer.from_pretrained(model_path, trust_remote_code=True) |
| 473 | self._is_dsv4 = True | 472 | self._is_dsv4 = True |
| @@ -14,7 +14,9 @@ description = "KV Conductor - Radix tree based KV cache indexer for MindIE-PyMot | |||
| 14 | version = "0.1.0" | 14 | version = "0.1.0" |
| 15 | edition = "2021" | 15 | edition = "2021" |
| 16 | authors = ["MindIE Team"] | 16 | authors = ["MindIE Team"] |
| 17 | -license = "Mulan PSL v2" | 17 | +# Package is Mulan PSL v2; several sources are Apache-2.0 Derivative Works of |
| 18 | +# NVIDIA Dynamo kv-router — see THIRD_PARTY_NOTICES.md and licenses/Apache-2.0.txt. | ||
| 19 | +license = "MulanPSL-2.0" | ||
| 18 | 20 | ||
| 19 | [dependencies] | 21 | [dependencies] |
| 20 | axum = "0.7" | 22 | axum = "0.7" |
| @@ -74,9 +74,6 @@ def is_available() -> bool: | |||
| 74 | def start( | 74 | def start( |
| 75 | host: str = "::", | 75 | host: str = "::", |
| 76 | port: int = 13333, | 76 | port: int = 13333, |
| 77 | - hbm_weight: int = 3, | ||
| 78 | - cpu_weight: int = 2, | ||
| 79 | - disk_weight: int = 1, | ||
| 80 | extra_args: list[str] | None = None, | 77 | extra_args: list[str] | None = None, |
| 81 | ) -> subprocess.Popen: | 78 | ) -> subprocess.Popen: |
| 82 | """Launch kv-conductor as a subprocess. | 79 | """Launch kv-conductor as a subprocess. |
| @@ -87,12 +84,6 @@ def start( | |||
| 87 | Bind address (default ``::`` — dual-stack IPv4/IPv6). | 84 | Bind address (default ``::`` — dual-stack IPv4/IPv6). |
| 88 | port: | 85 | port: |
| 89 | HTTP listen port. | 86 | HTTP listen port. |
| 90 | - hbm_weight: | ||
| 91 | - Score per matched HBM/XPU block (scoring config). | ||
| 92 | - cpu_weight: | ||
| 93 | - Score per matched CPU block. | ||
| 94 | - disk_weight: | ||
| 95 | - Score per matched disk block. | ||
| 96 | extra_args: | 87 | extra_args: |
| 97 | Additional CLI arguments forwarded to the binary. | 88 | Additional CLI arguments forwarded to the binary. |
| 98 | 89 | ||
| @@ -118,12 +109,6 @@ def start( | |||
| 118 | host, | 109 | host, |
| 119 | "--port", | 110 | "--port", |
| 120 | str(port), | 111 | str(port), |
| 121 | - "--hbm-weight", | ||
| 122 | - str(hbm_weight), | ||
| 123 | - "--cpu-weight", | ||
| 124 | - str(cpu_weight), | ||
| 125 | - "--disk-weight", | ||
| 126 | - str(disk_weight), | ||
| 127 | ] | 112 | ] |
| 128 | if extra_args: | 113 | if extra_args: |
| 129 | cmd.extend(extra_args) | 114 | cmd.extend(extra_args) |
| @@ -94,7 +94,7 @@ pub enum MatchMode { | |||
| 94 | /// No auto-attach — the subscriber is tied to a fixed dp_rank (YuanRong). | 94 | /// No auto-attach — the subscriber is tied to a fixed dp_rank (YuanRong). |
| 95 | None, | 95 | None, |
| 96 | /// Match by IP only. An event with `backend_id=<ip>` is applied to | 96 | /// Match by IP only. An event with `backend_id=<ip>` is applied to |
| 97 | - /// **every** HBM-registered DP whose XPU endpoint IP matches. | 97 | + /// **every** HBM-registered DP whose NPU endpoint IP matches. |
| 98 | /// (Mooncake: one master per cluster, backend_id=node IP). | 98 | /// (Mooncake: one master per cluster, backend_id=node IP). |
| 99 | IpOnly, | 99 | IpOnly, |
| 100 | /// Match by IP **and** dp_rank. An event with `backend_id=<ip>` and | 100 | /// Match by IP **and** dp_rank. An event with `backend_id=<ip>` and |
| @@ -284,7 +284,7 @@ mod tests { | |||
| 284 | 284 | ||
| 285 | fn test_resolve_workers_empty_ip_index() { | 285 | fn test_resolve_workers_empty_ip_index() { |
| 286 | let index = make_ip_index(vec![]); | 286 | let index = make_ip_index(vec![]); |
| 287 | - let media = &[StorageMedium::Xpu]; | 287 | + let media = &[StorageMedium::Npu]; |
| 288 | let workers = MatchMode::IpOnly.resolve_workers(Some(&index), "10.0.0.1", 0, media); | 288 | let workers = MatchMode::IpOnly.resolve_workers(Some(&index), "10.0.0.1", 0, media); |
| 289 | assert!(workers.is_empty()); | 289 | assert!(workers.is_empty()); |
| 290 | } | 290 | } |
| @@ -292,7 +292,7 @@ mod tests { | |||
| 292 | 292 | ||
| 293 | fn test_resolve_workers_multiple_media() { | 293 | fn test_resolve_workers_multiple_media() { |
| 294 | let index = make_ip_index(vec![("10.0.0.1", vec![("prefill-0", 0)])]); | 294 | let index = make_ip_index(vec![("10.0.0.1", vec![("prefill-0", 0)])]); |
| 295 | - let media = &[StorageMedium::Xpu, StorageMedium::Cpu, StorageMedium::Disk]; | 295 | + let media = &[StorageMedium::Npu, StorageMedium::Cpu, StorageMedium::Disk]; |
| 296 | 296 | ||
| 297 | let workers = MatchMode::IpOnly.resolve_workers(Some(&index), "10.0.0.1", 0, media); | 297 | let workers = MatchMode::IpOnly.resolve_workers(Some(&index), "10.0.0.1", 0, media); |
| 298 | assert_eq!(workers.len(), 3); // 1 DP × 3 media | 298 | assert_eq!(workers.len(), 3); // 1 DP × 3 media |
| @@ -1,15 +1,30 @@ | |||
| 1 | -// Copyright (c) Huawei Technologies Co., Ltd. 2025-2026. All rights reserved. | 1 | +// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. |
| 2 | -// MindIE is licensed under Mulan PSL v2. | 2 | +// SPDX-FileCopyrightText: Copyright (c) Huawei Technologies Co., Ltd. 2025-2026. All rights reserved. |
| 3 | -// You can use this software according to the terms and conditions of the Mulan PSL v2. | 3 | +// SPDX-License-Identifier: Apache-2.0 |
| 4 | -// You may obtain a copy of Mulan PSL v2 at: | 4 | +// |
| 5 | -// http://license.coscl.org.cn/MulanPSL2 | 5 | +// This file is a Derivative Work of NVIDIA Dynamo kv-router |
| 6 | -// THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, | 6 | +// (https://github.com/ai-dynamo/dynamo), originally licensed under the |
| 7 | -// EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, | 7 | +// Apache License, Version 2.0. Upstream source path: |
| 8 | -// MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. | 8 | +// lib/kv-router/src/indexer/concurrent_radix_tree.rs |
| 9 | -// See the Mulan PSL v2 for more details. | 9 | +// |
| 10 | +// You may obtain a copy of the Apache License at: | ||
| 11 | +// http://www.apache.org/licenses/LICENSE-2.0 | ||
| 12 | +// Local copy: licenses/Apache-2.0.txt | ||
| 13 | +// Attribution: THIRD_PARTY_NOTICES.md | ||
| 14 | +// | ||
| 15 | +// Modified by Huawei Technologies Co., Ltd. for MindIE-PyMotor KV Conductor | ||
| 16 | +// (WorkerKey + Arc<FxHashSet> COW workers; lookup owned by Indexer; | ||
| 17 | +// find_matches_detailed / PrefixMatch; sweep_stale_nodes; no CleanupState / | ||
| 18 | +// metrics / early_exit path). Huawei modifications are also available under | ||
| 19 | +// Mulan PSL v2 (http://license.coscl.org.cn/MulanPSL2). Redistribution of | ||
| 20 | +// this file must still comply with Apache License 2.0. | ||
| 10 | 21 | ||
| 11 | //! Thread-safe Concurrent Radix Tree for KV cache block indexing. | 22 | //! Thread-safe Concurrent Radix Tree for KV cache block indexing. |
| 12 | //! | 23 | //! |
| 24 | +//! Derived from NVIDIA Dynamo kv-router `ConcurrentRadixTree` | ||
| 25 | +//! (`lib/kv-router/src/indexer/concurrent_radix_tree.rs`, Apache-2.0). See | ||
| 26 | +//! `THIRD_PARTY_NOTICES.md`. | ||
| 27 | +//! | ||
| 13 | //! Uses `Arc<parking_lot::RwLock<Block>>` per node, enabling: | 28 | //! Uses `Arc<parking_lot::RwLock<Block>>` per node, enabling: |
| 14 | //! - Multiple concurrent `find_matches` (read locks only) | 29 | //! - Multiple concurrent `find_matches` (read locks only) |
| 15 | //! - Exclusive `apply_event` (write locks with hand-over-hand ordering) | 30 | //! - Exclusive `apply_event` (write locks with hand-over-hand ordering) |
| @@ -93,6 +108,16 @@ impl Default for ConcurrentRadixTree { | |||
| 93 | } | 108 | } |
| 94 | } | 109 | } |
| 95 | 110 | ||
| 111 | +/// Per-worker HBM prefix match: depth + last engine sequence hash. | ||
| 112 | +/// | ||
| 113 | +/// ``last_seq_hash`` is the breakpoint used to continue into the CPU | ||
| 114 | +/// continuation-edge index. | ||
| 115 | + | ||
| 116 | +pub struct PrefixMatch { | ||
| 117 | + pub depth: u32, | ||
| 118 | + pub last_seq_hash: Option<SequenceBlockHash>, | ||
| 119 | +} | ||
| 120 | + | ||
| 96 | impl ConcurrentRadixTree { | 121 | impl ConcurrentRadixTree { |
| 97 | /// Create a new empty concurrent radix tree. | 122 | /// Create a new empty concurrent radix tree. |
| 98 | pub fn new() -> Self { | 123 | pub fn new() -> Self { |
| @@ -107,14 +132,28 @@ impl ConcurrentRadixTree { | |||
| 107 | 132 | ||
| 108 | /// Find matches for a sequence of `LocalBlockHash` values. | 133 | /// Find matches for a sequence of `LocalBlockHash` values. |
| 109 | /// | 134 | /// |
| 110 | - /// Returns per-worker overlap scores indicating the depth of the longest | 135 | + /// Returns per-worker matched block counts indicating the depth of the |
| 111 | - /// matching prefix for each worker. | 136 | + /// longest matching prefix for each worker. |
| 112 | - pub fn find_matches(&self, sequence: &[LocalBlockHash]) -> OverlapScores { | 137 | + pub fn find_matches(&self, sequence: &[LocalBlockHash]) -> OverlapBlocks { |
| 138 | + let detailed = self.find_matches_detailed(sequence); | ||
| 139 | + let mut overlap = OverlapBlocks::default(); | ||
| 140 | + for (worker, m) in detailed { | ||
| 141 | + overlap.update_blocks(worker, m.depth); | ||
| 142 | + } | ||
| 143 | + overlap | ||
| 144 | + } | ||
| 145 | + | ||
| 146 | + /// Like [`find_matches`], but also returns the last matched sequence hash | ||
| 147 | + /// so callers can continue into lower-tier indexes. | ||
| 148 | + pub fn find_matches_detailed( | ||
| 149 | + &self, | ||
| 150 | + sequence: &[LocalBlockHash], | ||
| 151 | + ) -> FxHashMap<WorkerKey, PrefixMatch> { | ||
| 113 | let t0 = std::time::Instant::now(); | 152 | let t0 = std::time::Instant::now(); |
| 114 | - let mut scores = OverlapScores::default(); | 153 | + let mut results: FxHashMap<WorkerKey, PrefixMatch> = FxHashMap::default(); |
| 115 | 154 | ||
| 116 | if sequence.is_empty() { | 155 | if sequence.is_empty() { |
| 117 | - return scores; | 156 | + return results; |
| 118 | } | 157 | } |
| 119 | 158 | ||
| 120 | // Get first child from root (read lock) | 159 | // Get first child from root (read lock) |
| @@ -125,18 +164,17 @@ impl ConcurrentRadixTree { | |||
| 125 | 164 | ||
| 126 | let Some(first_child) = first_child else { | 165 | let Some(first_child) = first_child else { |
| 127 | tracing::trace!(seq_len = sequence.len(), "tree miss at root"); | 166 | tracing::trace!(seq_len = sequence.len(), "tree miss at root"); |
| 128 | - return scores; | 167 | + return results; |
| 129 | }; | 168 | }; |
| 130 | 169 | ||
| 131 | // Initialize active workers from first child. | 170 | // Initialize active workers from first child. |
| 132 | - // Arc clone is O(1) refcount bump instead of O(n) set clone. | ||
| 133 | let mut active: FxHashSet<WorkerKey> = { | 171 | let mut active: FxHashSet<WorkerKey> = { |
| 134 | let guard = first_child.read(); | 172 | let guard = first_child.read(); |
| 135 | (*guard.workers).clone() | 173 | (*guard.workers).clone() |
| 136 | }; | 174 | }; |
| 137 | 175 | ||
| 138 | if active.is_empty() { | 176 | if active.is_empty() { |
| 139 | - return scores; | 177 | + return results; |
| 140 | } | 178 | } |
| 141 | 179 | ||
| 142 | let mut current = first_child; | 180 | let mut current = first_child; |
| @@ -153,6 +191,8 @@ impl ConcurrentRadixTree { | |||
| 153 | break; | 191 | break; |
| 154 | }; | 192 | }; |
| 155 | 193 | ||
| 194 | + let last_seq = { current.read().block_hash }; | ||
| 195 | + | ||
| 156 | // Short-circuit: if only 1 worker remains, just check whether | 196 | // Short-circuit: if only 1 worker remains, just check whether |
| 157 | // this single worker is in the child's set. | 197 | // this single worker is in the child's set. |
| 158 | if active.len() == 1 { | 198 | if active.len() == 1 { |
| @@ -166,20 +206,31 @@ impl ConcurrentRadixTree { | |||
| 166 | matched_depth += 1; | 206 | matched_depth += 1; |
| 167 | continue; | 207 | continue; |
| 168 | } else { | 208 | } else { |
| 169 | - scores.update_score(w, matched_depth); | 209 | + results.insert( |
| 210 | + w, | ||
| 211 | + PrefixMatch { | ||
| 212 | + depth: matched_depth, | ||
| 213 | + last_seq_hash: last_seq, | ||
| 214 | + }, | ||
| 215 | + ); | ||
| 170 | active.clear(); | 216 | active.clear(); |
| 171 | break; | 217 | break; |
| 172 | } | 218 | } |
| 173 | } | 219 | } |
| 174 | 220 | ||
| 175 | // Reconcile: remove workers that don't have this child block. | 221 | // Reconcile: remove workers that don't have this child block. |
| 176 | - // Use retain to avoid a second Vec allocation. | ||
| 177 | let guard = block.read(); | 222 | let guard = block.read(); |
| 178 | active.retain(|w| { | 223 | active.retain(|w| { |
| 179 | if guard.workers.contains(w) { | 224 | if guard.workers.contains(w) { |
| 180 | true | 225 | true |
| 181 | } else { | 226 | } else { |
| 182 | - scores.update_score(w.clone(), matched_depth); | 227 | + results.insert( |
| 228 | + w.clone(), | ||
| 229 | + PrefixMatch { | ||
| 230 | + depth: matched_depth, | ||
| 231 | + last_seq_hash: last_seq, | ||
| 232 | + }, | ||
| 233 | + ); | ||
| 183 | false | 234 | false |
| 184 | } | 235 | } |
| 185 | }); | 236 | }); |
| @@ -193,19 +244,25 @@ impl ConcurrentRadixTree { | |||
| 193 | matched_depth += 1; | 244 | matched_depth += 1; |
| 194 | } | 245 | } |
| 195 | 246 | ||
| 196 | - // Drain surviving workers into scores (avoid clone) | 247 | + let last_seq = { current.read().block_hash }; |
| 197 | for worker in active.drain() { | 248 | for worker in active.drain() { |
| 198 | - scores.update_score(worker, matched_depth); | 249 | + results.insert( |
| 250 | + worker, | ||
| 251 | + PrefixMatch { | ||
| 252 | + depth: matched_depth, | ||
| 253 | + last_seq_hash: last_seq, | ||
| 254 | + }, | ||
| 255 | + ); | ||
| 199 | } | 256 | } |
| 200 | 257 | ||
| 201 | tracing::debug!( | 258 | tracing::debug!( |
| 202 | seq_len = sequence.len(), | 259 | seq_len = sequence.len(), |
| 203 | depth = matched_depth, | 260 | depth = matched_depth, |
| 204 | - active_workers = scores.scores.len(), | 261 | + active_workers = results.len(), |
| 205 | elapsed_us = t0.elapsed().as_micros(), | 262 | elapsed_us = t0.elapsed().as_micros(), |
| 206 | "find_matches" | 263 | "find_matches" |
| 207 | ); | 264 | ); |
| 208 | - scores | 265 | + results |
| 209 | } | 266 | } |
| 210 | 267 | ||
| 211 | // ----------------------------------------------------------------------- | 268 | // ----------------------------------------------------------------------- |
| @@ -355,7 +412,7 @@ impl ConcurrentRadixTree { | |||
| 355 | 412 | ||
| 356 | /// Remove a worker entirely, cleaning up all tree references. | 413 | /// Remove a worker entirely, cleaning up all tree references. |
| 357 | pub fn remove_worker(&self, worker: &WorkerKey, lookup: &mut WorkerLookup) { | 414 | pub fn remove_worker(&self, worker: &WorkerKey, lookup: &mut WorkerLookup) { |
| 358 | - for (_, block) in lookup.iter() { | 415 | + for block in lookup.values() { |
| 359 | block.write().drop_worker(worker); | 416 | block.write().drop_worker(worker); |
| 360 | } | 417 | } |
| 361 | lookup.clear(); | 418 | lookup.clear(); |
| @@ -411,7 +468,7 @@ mod tests { | |||
| 411 | instance_id: instance_id.to_string(), | 468 | instance_id: instance_id.to_string(), |
| 412 | backend_id: instance_id.to_string(), | 469 | backend_id: instance_id.to_string(), |
| 413 | dp_rank, | 470 | dp_rank, |
| 414 | - medium: StorageMedium::Xpu, | 471 | + medium: StorageMedium::Npu, |
| 415 | } | 472 | } |
| 416 | } | 473 | } |
| 417 | 474 | ||
| @@ -432,8 +489,8 @@ mod tests { | |||
| 432 | 489 | ||
| 433 | fn test_concurrent_find_matches_empty() { | 490 | fn test_concurrent_find_matches_empty() { |
| 434 | let tree = ConcurrentRadixTree::new(); | 491 | let tree = ConcurrentRadixTree::new(); |
| 435 | - let scores = tree.find_matches(&[LocalBlockHash(1)]); | 492 | + let overlap = tree.find_matches(&[LocalBlockHash(1)]); |
| 436 | - assert!(scores.is_empty()); | 493 | + assert!(overlap.is_empty()); |
| 437 | } | 494 | } |
| 438 | 495 | ||
| 439 | 496 | ||
| @@ -445,8 +502,8 @@ mod tests { | |||
| 445 | tree.apply_store(&w1, &mut lookup, &make_store(None, vec![(100, 1)])) | 502 | tree.apply_store(&w1, &mut lookup, &make_store(None, vec![(100, 1)])) |
| 446 | .unwrap(); | 503 | .unwrap(); |
| 447 | 504 | ||
| 448 | - let scores = tree.find_matches(&[LocalBlockHash(1)]); | 505 | + let overlap = tree.find_matches(&[LocalBlockHash(1)]); |
| 449 | - assert_eq!(scores.scores.get(&w1).copied(), Some(1)); | 506 | + assert_eq!(overlap.blocks.get(&w1).copied(), Some(1)); |
| 450 | } | 507 | } |
| 451 | 508 | ||
| 452 | 509 | ||
| @@ -462,8 +519,8 @@ mod tests { | |||
| 462 | tree.apply_store(&w1, &mut lookup, &make_store(Some(200), vec![(300, 3)])) | 519 | tree.apply_store(&w1, &mut lookup, &make_store(Some(200), vec![(300, 3)])) |
| 463 | .unwrap(); | 520 | .unwrap(); |
| 464 | 521 | ||
| 465 | - let scores = tree.find_matches(&[LocalBlockHash(1), LocalBlockHash(2), LocalBlockHash(3)]); | 522 | + let overlap = tree.find_matches(&[LocalBlockHash(1), LocalBlockHash(2), LocalBlockHash(3)]); |
| 466 | - assert_eq!(scores.scores.get(&w1).copied(), Some(3)); | 523 | + assert_eq!(overlap.blocks.get(&w1).copied(), Some(3)); |
| 467 | } | 524 | } |
| 468 | 525 | ||
| 469 | 526 | ||
| @@ -485,9 +542,9 @@ mod tests { | |||
| 485 | 542 | ||
| 486 | assert_eq!(lookup.len(), 1); | 543 | assert_eq!(lookup.len(), 1); |
| 487 | 544 | ||
| 488 | - let scores = tree.find_matches(&[LocalBlockHash(1), LocalBlockHash(2)]); | 545 | + let overlap = tree.find_matches(&[LocalBlockHash(1), LocalBlockHash(2)]); |
| 489 | // Only first block matches since second was removed | 546 | // Only first block matches since second was removed |
| 490 | - assert_eq!(scores.scores.get(&w1).copied(), Some(1)); | 547 | + assert_eq!(overlap.blocks.get(&w1).copied(), Some(1)); |
| 491 | } | 548 | } |
| 492 | 549 | ||
| 493 | 550 | ||
| @@ -506,8 +563,8 @@ mod tests { | |||
| 506 | tree.remove_worker(&w1, &mut lookup); | 563 | tree.remove_worker(&w1, &mut lookup); |
| 507 | 564 | ||
| 508 | assert!(lookup.is_empty()); | 565 | assert!(lookup.is_empty()); |
| 509 | - let scores = tree.find_matches(&[LocalBlockHash(1)]); | 566 | + let overlap = tree.find_matches(&[LocalBlockHash(1)]); |
| 510 | - assert!(scores.is_empty()); | 567 | + assert!(overlap.is_empty()); |
| 511 | } | 568 | } |
| 512 | 569 | ||
| 513 | 570 | ||
| @@ -531,13 +588,13 @@ mod tests { | |||
| 531 | .unwrap(); | 588 | .unwrap(); |
| 532 | 589 | ||
| 533 | // Both match first block | 590 | // Both match first block |
| 534 | - let scores = tree.find_matches(&[LocalBlockHash(1)]); | 591 | + let overlap = tree.find_matches(&[LocalBlockHash(1)]); |
| 535 | - assert_eq!(scores.scores.get(&w1).copied(), Some(1)); | 592 | + assert_eq!(overlap.blocks.get(&w1).copied(), Some(1)); |
| 536 | - assert_eq!(scores.scores.get(&w2).copied(), Some(1)); | 593 | + assert_eq!(overlap.blocks.get(&w2).copied(), Some(1)); |
| 537 | 594 | ||
| 538 | // Path (1, 2) matches W1 deeper | 595 | // Path (1, 2) matches W1 deeper |
| 539 | - let scores = tree.find_matches(&[LocalBlockHash(1), LocalBlockHash(2)]); | 596 | + let overlap = tree.find_matches(&[LocalBlockHash(1), LocalBlockHash(2)]); |
| 540 | - assert_eq!(scores.scores.get(&w1).copied(), Some(2)); | 597 | + assert_eq!(overlap.blocks.get(&w1).copied(), Some(2)); |
| 541 | - assert_eq!(scores.scores.get(&w2).copied(), Some(1)); | 598 | + assert_eq!(overlap.blocks.get(&w2).copied(), Some(1)); |
| 542 | } | 599 | } |
| 543 | } | 600 | } |
| @@ -11,7 +11,7 @@ | |||
| 11 | //! `FlexHash` — polymorphic u64 deserialization for msgpack values. | 11 | //! `FlexHash` — polymorphic u64 deserialization for msgpack values. |
| 12 | //! | 12 | //! |
| 13 | //! Supports multiple representations: | 13 | //! Supports multiple representations: |
| 14 | -//! - integer (u64, i64, u32, …) | 14 | +//! - non-negative integer (u64, i64, u32, …; negative i64 is rejected) |
| 15 | //! - decimal string "12345678901234567890" | 15 | //! - decimal string "12345678901234567890" |
| 16 | //! - hex string "0xABCD1234…" or "ABCD1234…" | 16 | //! - hex string "0xABCD1234…" or "ABCD1234…" |
| 17 | //! - binary bytes up to 8 bytes, big-endian (vLLM BlockHash compat) | 17 | //! - binary bytes up to 8 bytes, big-endian (vLLM BlockHash compat) |
| @@ -14,21 +14,28 @@ | |||
| 14 | //! | 14 | //! |
| 15 | //! 1. **Pool backend** (Mooncake / Memcache / YuanRong): `PoolEvent` with | 15 | //! 1. **Pool backend** (Mooncake / Memcache / YuanRong): `PoolEvent` with |
| 16 | //! `seq_hashes`/`block_hashes`, processed by `apply_pool_event`. No | 16 | //! `seq_hashes`/`block_hashes`, processed by `apply_pool_event`. No |
| 17 | -//! `token_ids` — `tokens_hash` is set equal to `block_hash`. | 17 | +//! `token_ids` on the wire — `tokens_hash` comes from the engine offload |
| 18 | +//! cache, retained content (TTL-bounded), | ||
| 19 | +//! or a prior lower-tier insert via the two-phase match (CPU→Disk | ||
| 20 | +//! promotion). | ||
| 18 | //! | 21 | //! |
| 19 | //! 2. **vLLM engine** (native): `VllmEventMap` with `token_ids` + `block_size`, | 22 | //! 2. **vLLM engine** (native): `VllmEventMap` with `token_ids` + `block_size`, |
| 20 | //! processed by `apply_vllm_event`. `tokens_hash` is re-computed from | 23 | //! processed by `apply_vllm_event`. `tokens_hash` is re-computed from |
| 21 | -//! `token_ids` via `compute_block_hash_for_seq` (XXH3, seed 1337), while | 24 | +//! `token_ids` via `compute_block_hash_for_seq` (XXH3, seed 1337); the |
| 22 | -//! the engine's chained `block_hashes` are kept as | 25 | +//! engine's `block_hashes` are matched against `pending_pool` and cached |
| 23 | -//! `ExternalSequenceBlockHash` for reverse-lookup on `BlockRemoved`. | 26 | +//! as `SequenceBlockHash` until the pool confirms (reverse-lookup on |
| 27 | +//! `BlockRemoved` goes through `evict_pending_blocks`). | ||
| 24 | //! | 28 | //! |
| 25 | //! ## Event filtering | 29 | //! ## Event filtering |
| 26 | //! | 30 | //! |
| 27 | -//! Following Dynamo kv-router's approach, events from non-main attention | 31 | +//! Events from non-main attention groups (SWA, Mamba, ChunkedLocal, etc.) |
| 28 | -//! groups (SWA, Mamba, ChunkedLocal, etc.) are filtered out. Only | 32 | +//! are filtered out. Only `FullAttention`, `MlaAttention`, and |
| 29 | -//! `FullAttention`, `MlaAttention`, and `SinkFullAttention` events are | 33 | +//! `SinkFullAttention` events are processed (PascalCase or snake_case wire |
| 30 | -//! processed. This ensures all ingested events share the same `block_size`, | 34 | +//! forms such as `mla_attention`); kinds like `sliding_window_mla` are |
| 31 | -//! avoiding the multi-group hash granularity mismatch problem. | 35 | +//! denied. The allow/deny kind set is derived from NVIDIA Dynamo |
| 36 | +//! kv-router (Apache-2.0); see `THIRD_PARTY_NOTICES.md`. This ensures all | ||
| 37 | +//! ingested events share the same `block_size`, avoiding multi-group hash | ||
| 38 | +//! granularity mismatch. | ||
| 32 | 39 | ||
| 33 | pub(crate) mod flex_hash; | 40 | pub(crate) mod flex_hash; |
| 34 | mod helpers; | 41 | mod helpers; |
| @@ -54,8 +61,8 @@ pub(crate) use crate::error::KvConductorError; | |||
| 54 | pub(crate) use crate::hashing::compute_block_hash_for_seq; | 61 | pub(crate) use crate::hashing::compute_block_hash_for_seq; |
| 55 | 62 | ||
| 56 | pub(crate) use crate::protocols::{ | 63 | pub(crate) use crate::protocols::{ |
| 57 | - HbmIpIndex, KvCacheEventData, KvCacheStoreData, KvCacheStoredBlockData, ScoringConfig, | 64 | + HbmIpIndex, KvCacheEventData, KvCacheStoreData, KvCacheStoredBlockData, SequenceBlockHash, |
| 58 | - SequenceBlockHash, StorageMedium, WorkerKey, | 65 | + StorageMedium, WorkerKey, |
| 59 | }; | 66 | }; |
| 60 | 67 | ||
| 61 | 68 | ||
| @@ -12,7 +12,9 @@ | |||
| 12 | //! | 12 | //! |
| 13 | //! Used by all pool backends (Mooncake, Memcache, YuanRong) — the pool | 13 | //! Used by all pool backends (Mooncake, Memcache, YuanRong) — the pool |
| 14 | //! daemon broadcasts KV cache events that this module deserializes and | 14 | //! daemon broadcasts KV cache events that this module deserializes and |
| 15 | -//! matches against offload cache entries. | 15 | +//! resolves against the offload cache, retained content, or an |
| 16 | +//! already-indexed lower tier (two-phase match, pool-first arrivals wait | ||
| 17 | +//! in `pending_pool`). | ||
| 16 | 18 | ||
| 17 | use serde::Deserialize; | 19 | use serde::Deserialize; |
| 18 | 20 | ||
| @@ -104,7 +106,7 @@ pub(crate) fn apply_pool_event( | |||
| 104 | // For MatchMode::None (YuanRong), use the subscriber's backend_id which IS | 106 | // For MatchMode::None (YuanRong), use the subscriber's backend_id which IS |
| 105 | // the engine instance_id from registration. The event's backend_id may be | 107 | // the engine instance_id from registration. The event's backend_id may be |
| 106 | // the pool daemon's IP:port — using it directly would create a different | 108 | // the pool daemon's IP:port — using it directly would create a different |
| 107 | - // instance_id than the HBM blocks, breaking cross-media score aggregation. | 109 | + // instance_id than the HBM blocks, breaking cross-media block aggregation. |
| 108 | // For pool backends (Mooncake/Memcache), the event's backend_id is the node | 110 | // For pool backends (Mooncake/Memcache), the event's backend_id is the node |
| 109 | // IP, needed by resolve_workers for hbm_ip_index lookup. | 111 | // IP, needed by resolve_workers for hbm_ip_index lookup. |
| 110 | let event_be_id = pool_event.backend_id.as_deref().unwrap_or(backend_id); | 112 | let event_be_id = pool_event.backend_id.as_deref().unwrap_or(backend_id); |
| @@ -165,14 +167,21 @@ pub(crate) fn apply_pool_event( | |||
| 165 | medium = %worker.medium.as_str(), | 167 | medium = %worker.medium.as_str(), |
| 166 | ?preview, | 168 | ?preview, |
| 167 | worker = %worker.instance_id, | 169 | worker = %worker.instance_id, |
| 168 | - "pool confirm: matched cached offload blocks, inserting into tree" | 170 | + "pool confirm: resolved mapping, inserting into tree" |
| 169 | ); | 171 | ); |
| 170 | - let store_data = KvCacheStoreData { | 172 | + // Apply one `Stored` event per block, each with its own |
每个 block 单独 apply_event 会加锁多次,对大批量确认可能性能差,考虑批量带 parent_hash。 ![]() ![]() | |||
| 171 | - parent_hash: None, | 173 | + // `parent_hash` (resolved from the offload cache, retained |
| 172 | - start_position: None, | 174 | + // content, or a lower-tier lookup), instead of batching them |
| 173 | - blocks, | 175 | + // into a single event with `parent_hash: None` — batching |
| 174 | - }; | 176 | + // would silently drop continuation-edge chaining. |
| 175 | - entry.apply_event(worker, &KvCacheEventData::Stored(store_data))?; | 177 | + for (parent_hash, block) in blocks { |
| 178 | + let store_data = KvCacheStoreData { | ||
| 179 | + parent_hash, | ||
| 180 | + start_position: None, | ||
| 181 | + blocks: vec![block], | ||
| 182 | + }; | ||
| 183 | + entry.apply_event(worker, &KvCacheEventData::Stored(store_data))?; | ||
| 184 | + } | ||
| 176 | } | 185 | } |
| 177 | } else if is_removed { | 186 | } else if is_removed { |
| 178 | let block_hashes: Vec<u64> = seq_hashes.iter().map(|h| h.0).collect(); | 187 | let block_hashes: Vec<u64> = seq_hashes.iter().map(|h| h.0).collect(); |
| @@ -381,7 +381,7 @@ fn test_apply_vllm_block_stored_computes_tokens_hash() { | |||
| 381 | use crate::hashing::compute_block_hash_for_seq; | 381 | use crate::hashing::compute_block_hash_for_seq; |
| 382 | use crate::indexer::Indexer; | 382 | use crate::indexer::Indexer; |
| 383 | 383 | ||
| 384 | - let indexer = Indexer::new(ScoringConfig::default()); | 384 | + let indexer = Indexer::new(); |
| 385 | let token_ids = vec![1i64, 2, 3, 4, 5, 6, 7, 8]; | 385 | let token_ids = vec![1i64, 2, 3, 4, 5, 6, 7, 8]; |
| 386 | let block_size = 4u32; | 386 | let block_size = 4u32; |
| 387 | 387 | ||
| @@ -404,7 +404,7 @@ fn test_apply_vllm_block_stored_computes_tokens_hash() { | |||
| 404 | "test-tenant", | 404 | "test-tenant", |
| 405 | "test-backend", | 405 | "test-backend", |
| 406 | 0, | 406 | 0, |
| 407 | - &[StorageMedium::Xpu], | 407 | + &[StorageMedium::Npu], |
| 408 | MatchMode::None, | 408 | MatchMode::None, |
| 409 | &None, | 409 | &None, |
| 410 | block_size, | 410 | block_size, |
| @@ -420,7 +420,7 @@ fn test_apply_vllm_block_stored_computes_tokens_hash() { | |||
| 420 | instance_id: "test-backend".into(), | 420 | instance_id: "test-backend".into(), |
| 421 | backend_id: "test-backend".into(), | 421 | backend_id: "test-backend".into(), |
| 422 | dp_rank: 0, | 422 | dp_rank: 0, |
| 423 | - medium: StorageMedium::Xpu, | 423 | + medium: StorageMedium::Npu, |
| 424 | }; | 424 | }; |
| 425 | let lookup = lookups.get(&wk).expect("worker should exist"); | 425 | let lookup = lookups.get(&wk).expect("worker should exist"); |
| 426 | // 2 SHA256 hashes → 2 lookup entries | 426 | // 2 SHA256 hashes → 2 lookup entries |
| @@ -434,9 +434,9 @@ fn test_apply_vllm_block_stored_computes_tokens_hash() { | |||
| 434 | } | 434 | } |
| 435 | 435 | ||
| 436 | // Query via find_matches should match (tokens_hash == query hash) | 436 | // Query via find_matches should match (tokens_hash == query hash) |
| 437 | - let scores = entry.find_matches(&token_ids, block_size, &ScoringConfig::default()); | 437 | + let scores = entry.find_matches(&token_ids, block_size); |
| 438 | assert!( | 438 | assert!( |
| 439 | - !scores.scores.is_empty(), | 439 | + !scores.blocks.is_empty(), |
| 440 | "query should match stored blocks" | 440 | "query should match stored blocks" |
| 441 | ); | 441 | ); |
| 442 | } | 442 | } |
| @@ -449,7 +449,7 @@ fn test_apply_vllm_block_stored_computes_tokens_hash() { | |||
| 449 | fn test_non_hbm_event_cached_not_in_tree() { | 449 | fn test_non_hbm_event_cached_not_in_tree() { |
| 450 | use crate::indexer::Indexer; | 450 | use crate::indexer::Indexer; |
| 451 | 451 | ||
| 452 | - let indexer = Indexer::new(ScoringConfig::default()); | 452 | + let indexer = Indexer::new(); |
| 453 | let token_ids = vec![1i64, 2, 3, 4]; | 453 | let token_ids = vec![1i64, 2, 3, 4]; |
| 454 | let block_size = 4u32; | 454 | let block_size = 4u32; |
| 455 | 455 | ||
| @@ -485,9 +485,9 @@ fn test_non_hbm_event_cached_not_in_tree() { | |||
| 485 | } | 485 | } |
| 486 | 486 | ||
| 487 | // Tree should NOT have the block (not inserted for non-HBM). | 487 | // Tree should NOT have the block (not inserted for non-HBM). |
| 488 | - let scores = entry.find_matches(&token_ids, block_size, &ScoringConfig::default()); | 488 | + let scores = entry.find_matches(&token_ids, block_size); |
| 489 | assert!( | 489 | assert!( |
| 490 | - scores.scores.is_empty(), | 490 | + scores.blocks.is_empty(), |
| 491 | "non-HBM events should not be inserted into tree" | 491 | "non-HBM events should not be inserted into tree" |
| 492 | ); | 492 | ); |
| 493 | } | 493 | } |
| @@ -496,7 +496,7 @@ fn test_non_hbm_event_cached_not_in_tree() { | |||
| 496 | fn test_pool_backend_store_matches_cached_block() { | 496 | fn test_pool_backend_store_matches_cached_block() { |
| 497 | use crate::indexer::Indexer; | 497 | use crate::indexer::Indexer; |
| 498 | 498 | ||
| 499 | - let indexer = Indexer::new(ScoringConfig::default()); | 499 | + let indexer = Indexer::new(); |
| 500 | let token_ids = vec![1i64, 2, 3, 4]; | 500 | let token_ids = vec![1i64, 2, 3, 4]; |
| 501 | let block_size = 4u32; | 501 | let block_size = 4u32; |
| 502 | 502 | ||
| @@ -555,7 +555,7 @@ fn test_pool_backend_store_matches_cached_block() { | |||
| 555 | 555 | ||
| 556 | // Tree should now have the block at the pool backend's worker key. | 556 | // Tree should now have the block at the pool backend's worker key. |
| 557 | let entry = indexer.get_or_create("test-model", "test-tenant"); | 557 | let entry = indexer.get_or_create("test-model", "test-tenant"); |
| 558 | - let scores = entry.find_matches(&token_ids, block_size, &ScoringConfig::default()); | 558 | + let scores = entry.find_matches(&token_ids, block_size); |
| 559 | // The pool backend worker ("test-pool") should have a match. | 559 | // The pool backend worker ("test-pool") should have a match. |
| 560 | let pool_worker = WorkerKey { | 560 | let pool_worker = WorkerKey { |
| 561 | instance_id: "test-pool".into(), | 561 | instance_id: "test-pool".into(), |
| @@ -564,7 +564,7 @@ fn test_pool_backend_store_matches_cached_block() { | |||
| 564 | medium: StorageMedium::Cpu, | 564 | medium: StorageMedium::Cpu, |
| 565 | }; | 565 | }; |
| 566 | assert!( | 566 | assert!( |
| 567 | - scores.scores.contains_key(&pool_worker), | 567 | + scores.blocks.contains_key(&pool_worker), |
| 568 | "pool backend store should insert cached block into tree at pool worker" | 568 | "pool backend store should insert cached block into tree at pool worker" |
| 569 | ); | 569 | ); |
| 570 | } | 570 | } |
| @@ -573,7 +573,7 @@ fn test_pool_backend_store_matches_cached_block() { | |||
| 573 | fn test_pool_backend_store_ignores_unknown_hash() { | 573 | fn test_pool_backend_store_ignores_unknown_hash() { |
| 574 | use crate::indexer::Indexer; | 574 | use crate::indexer::Indexer; |
| 575 | 575 | ||
| 576 | - let indexer = Indexer::new(ScoringConfig::default()); | 576 | + let indexer = Indexer::new(); |
| 577 | let entry = indexer.get_or_create("test-model", "test-tenant"); | 577 | let entry = indexer.get_or_create("test-model", "test-tenant"); |
| 578 | 578 | ||
| 579 | // Pool backend stores a block we never cached — now queued in | 579 | // Pool backend stores a block we never cached — now queued in |
| @@ -628,7 +628,7 @@ fn test_pool_backend_store_ignores_unknown_hash() { | |||
| 628 | fn test_pool_backend_remove_evicts_cache() { | 628 | fn test_pool_backend_remove_evicts_cache() { |
| 629 | use crate::indexer::Indexer; | 629 | use crate::indexer::Indexer; |
| 630 | 630 | ||
| 631 | - let indexer = Indexer::new(ScoringConfig::default()); | 631 | + let indexer = Indexer::new(); |
| 632 | let token_ids = vec![1i64, 2, 3, 4, 5, 6, 7, 8]; | 632 | let token_ids = vec![1i64, 2, 3, 4, 5, 6, 7, 8]; |
| 633 | let block_size = 4u32; | 633 | let block_size = 4u32; |
| 634 | 634 | ||
| @@ -727,16 +727,25 @@ fn test_pool_backend_remove_evicts_cache() { | |||
| 727 | assert!(state.pending_pool.is_empty()); | 727 | assert!(state.pending_pool.is_empty()); |
| 728 | } | 728 | } |
| 729 | 729 | ||
| 730 | - // Tree should still have the pool worker's block for 0xBBB (only 0xAAA was removed). | 730 | + // After removing 0xAAA, 0xBBB remains in the CPU tier. Contiguous prefix |
| 731 | - let scores = entry.find_matches(&token_ids, block_size, &ScoringConfig::default()); | 731 | + // lookup cannot walk past the hole, so find_matches on the full sequence |
| 732 | + // reports no hit — verify tier membership directly instead. | ||
| 732 | let pool_worker = WorkerKey { | 733 | let pool_worker = WorkerKey { |
| 733 | instance_id: "test-pool".into(), | 734 | instance_id: "test-pool".into(), |
| 734 | backend_id: "test-pool".into(), | 735 | backend_id: "test-pool".into(), |
| 735 | dp_rank: 0, | 736 | dp_rank: 0, |
| 736 | medium: StorageMedium::Cpu, | 737 | medium: StorageMedium::Cpu, |
| 737 | }; | 738 | }; |
| 738 | - // After removing 0xAAA, only the 0xBBB block remains → still 1 match. | 739 | + assert!(!entry.cpu_tiers.contains_block(0xAAA)); |
| 739 | - assert!(scores.scores.contains_key(&pool_worker)); | 740 | + assert!( |
| 741 | + entry.cpu_tiers.contains_block(0xBBB), | ||
| 742 | + "removing 0xAAA must not evict the remaining block 0xBBB" | ||
| 743 | + ); | ||
| 744 | + assert_eq!(entry.cpu_tiers.worker_block_count(&pool_worker), 1); | ||
| 745 | + assert!( | ||
| 746 | + entry.find_matches(&token_ids, block_size).blocks.is_empty(), | ||
| 747 | + "broken prefix chain should not produce a contiguous match" | ||
| 748 | + ); | ||
| 740 | } | 749 | } |
| 741 | 750 | ||
| 742 | // ----------------------------------------------------------------------- | 751 | // ----------------------------------------------------------------------- |
| @@ -750,7 +759,7 @@ fn test_pool_backend_remove_evicts_cache() { | |||
| 750 | fn test_pool_arrives_before_offload() { | 759 | fn test_pool_arrives_before_offload() { |
| 751 | use crate::indexer::Indexer; | 760 | use crate::indexer::Indexer; |
| 752 | 761 | ||
| 753 | - let indexer = Indexer::new(ScoringConfig::default()); | 762 | + let indexer = Indexer::new(); |
| 754 | let token_ids = vec![1i64, 2, 3, 4]; | 763 | let token_ids = vec![1i64, 2, 3, 4]; |
| 755 | let block_size = 4u32; | 764 | let block_size = 4u32; |
| 756 | 765 | ||
| @@ -793,8 +802,8 @@ fn test_pool_arrives_before_offload() { | |||
| 793 | } | 802 | } |
| 794 | 803 | ||
| 795 | // Tree still empty — no offload has arrived yet. | 804 | // Tree still empty — no offload has arrived yet. |
| 796 | - let scores = entry.find_matches(&token_ids, block_size, &ScoringConfig::default()); | 805 | + let scores = entry.find_matches(&token_ids, block_size); |
| 797 | - assert!(scores.scores.is_empty()); | 806 | + assert!(scores.blocks.is_empty()); |
| 798 | 807 | ||
| 799 | // Phase 2: engine offloads to CPU (arrives SECOND). | 808 | // Phase 2: engine offloads to CPU (arrives SECOND). |
| 800 | let engine_event = VllmEvent::BlockStored { | 809 | let engine_event = VllmEvent::BlockStored { |
| @@ -827,7 +836,7 @@ fn test_pool_arrives_before_offload() { | |||
| 827 | } | 836 | } |
| 828 | 837 | ||
| 829 | // Tree should now have the block at the pool backend's worker key. | 838 | // Tree should now have the block at the pool backend's worker key. |
| 830 | - let scores = entry.find_matches(&token_ids, block_size, &ScoringConfig::default()); | 839 | + let scores = entry.find_matches(&token_ids, block_size); |
| 831 | let pool_worker = WorkerKey { | 840 | let pool_worker = WorkerKey { |
| 832 | instance_id: "test-pool".into(), | 841 | instance_id: "test-pool".into(), |
| 833 | backend_id: "test-pool".into(), | 842 | backend_id: "test-pool".into(), |
| @@ -835,7 +844,7 @@ fn test_pool_arrives_before_offload() { | |||
| 835 | medium: StorageMedium::Cpu, | 844 | medium: StorageMedium::Cpu, |
| 836 | }; | 845 | }; |
| 837 | assert!( | 846 | assert!( |
| 838 | - scores.scores.contains_key(&pool_worker), | 847 | + scores.blocks.contains_key(&pool_worker), |
| 839 | "pool-first ordering: block should be inserted into tree after offload arrives" | 848 | "pool-first ordering: block should be inserted into tree after offload arrives" |
| 840 | ); | 849 | ); |
| 841 | } | 850 | } |
| @@ -846,7 +855,7 @@ fn test_pool_arrives_before_offload() { | |||
| 846 | fn test_pool_arrives_before_offload_multi_worker() { | 855 | fn test_pool_arrives_before_offload_multi_worker() { |
| 847 | use crate::indexer::Indexer; | 856 | use crate::indexer::Indexer; |
| 848 | 857 | ||
| 849 | - let indexer = Indexer::new(ScoringConfig::default()); | 858 | + let indexer = Indexer::new(); |
| 850 | let token_ids = vec![10i64, 20, 30, 40]; | 859 | let token_ids = vec![10i64, 20, 30, 40]; |
| 851 | let block_size = 4u32; | 860 | let block_size = 4u32; |
| 852 | 861 | ||
| @@ -919,7 +928,7 @@ fn test_pool_arrives_before_offload_multi_worker() { | |||
| 919 | } | 928 | } |
| 920 | 929 | ||
| 921 | // Both workers have the block in tree. | 930 | // Both workers have the block in tree. |
| 922 | - let scores = entry.find_matches(&token_ids, block_size, &ScoringConfig::default()); | 931 | + let scores = entry.find_matches(&token_ids, block_size); |
| 923 | let w0 = WorkerKey { | 932 | let w0 = WorkerKey { |
| 924 | instance_id: "pool".into(), | 933 | instance_id: "pool".into(), |
| 925 | backend_id: "pool".into(), | 934 | backend_id: "pool".into(), |
| @@ -932,8 +941,8 @@ fn test_pool_arrives_before_offload_multi_worker() { | |||
| 932 | dp_rank: 1, | 941 | dp_rank: 1, |
| 933 | medium: StorageMedium::Cpu, | 942 | medium: StorageMedium::Cpu, |
| 934 | }; | 943 | }; |
| 935 | - assert!(scores.scores.contains_key(&w0)); | 944 | + assert!(scores.blocks.contains_key(&w0)); |
| 936 | - assert!(scores.scores.contains_key(&w1)); | 945 | + assert!(scores.blocks.contains_key(&w1)); |
| 937 | } | 946 | } |
| 938 | 947 | ||
| 939 | /// Pool event is queued, then a pool removal arrives — pending entry | 948 | /// Pool event is queued, then a pool removal arrives — pending entry |
| @@ -942,7 +951,7 @@ fn test_pool_arrives_before_offload_multi_worker() { | |||
| 942 | fn test_pool_removal_cleans_pending() { | 951 | fn test_pool_removal_cleans_pending() { |
| 943 | use crate::indexer::Indexer; | 952 | use crate::indexer::Indexer; |
| 944 | 953 | ||
| 945 | - let indexer = Indexer::new(ScoringConfig::default()); | 954 | + let indexer = Indexer::new(); |
| 946 | let entry = indexer.get_or_create("m", "t"); | 955 | let entry = indexer.get_or_create("m", "t"); |
| 947 | 956 | ||
| 948 | // Pool stored arrives first → queued. | 957 | // Pool stored arrives first → queued. |
| @@ -1022,7 +1031,7 @@ fn test_pool_removal_cleans_pending() { | |||
| 1022 | fn test_offload_then_vllm_removal() { | 1031 | fn test_offload_then_vllm_removal() { |
| 1023 | use crate::indexer::Indexer; | 1032 | use crate::indexer::Indexer; |
| 1024 | 1033 | ||
| 1025 | - let indexer = Indexer::new(ScoringConfig::default()); | 1034 | + let indexer = Indexer::new(); |
| 1026 | let entry = indexer.get_or_create("m", "t"); | 1035 | let entry = indexer.get_or_create("m", "t"); |
| 1027 | let block_size = 4u32; | 1036 | let block_size = 4u32; |
| 1028 | 1037 | ||
| @@ -1092,7 +1101,7 @@ fn test_offload_then_vllm_removal() { | |||
| 1092 | fn test_removal_after_both_matched() { | 1101 | fn test_removal_after_both_matched() { |
| 1093 | use crate::indexer::Indexer; | 1102 | use crate::indexer::Indexer; |
| 1094 | 1103 | ||
| 1095 | - let indexer = Indexer::new(ScoringConfig::default()); | 1104 | + let indexer = Indexer::new(); |
| 1096 | let token_ids = vec![1i64, 2, 3, 4]; | 1105 | let token_ids = vec![1i64, 2, 3, 4]; |
| 1097 | let block_size = 4u32; | 1106 | let block_size = 4u32; |
| 1098 | 1107 | ||
| @@ -1163,9 +1172,9 @@ fn test_removal_after_both_matched() { | |||
| 1163 | medium: StorageMedium::Cpu, | 1172 | medium: StorageMedium::Cpu, |
| 1164 | }; | 1173 | }; |
| 1165 | { | 1174 | { |
| 1166 | - let scores = entry.find_matches(&token_ids, block_size, &ScoringConfig::default()); | 1175 | + let scores = entry.find_matches(&token_ids, block_size); |
| 1167 | assert!( | 1176 | assert!( |
| 1168 | - scores.scores.contains_key(&pool_worker), | 1177 | + scores.blocks.contains_key(&pool_worker), |
| 1169 | "after match: block should be in tree" | 1178 | "after match: block should be in tree" |
| 1170 | ); | 1179 | ); |
| 1171 | } | 1180 | } |
| @@ -1199,9 +1208,9 @@ fn test_removal_after_both_matched() { | |||
| 1199 | 1208 | ||
| 1200 | // Tree should be empty now (removal succeeded). | 1209 | // Tree should be empty now (removal succeeded). |
| 1201 | // Verify via find_matches — no workers should have the block. | 1210 | // Verify via find_matches — no workers should have the block. |
| 1202 | - let scores_after = entry.find_matches(&token_ids, block_size, &ScoringConfig::default()); | 1211 | + let scores_after = entry.find_matches(&token_ids, block_size); |
| 1203 | assert!( | 1212 | assert!( |
| 1204 | - !scores_after.scores.contains_key(&pool_worker), | 1213 | + !scores_after.blocks.contains_key(&pool_worker), |
| 1205 | "removal after match: block should be removed from tree" | 1214 | "removal after match: block should be removed from tree" |
| 1206 | ); | 1215 | ); |
| 1207 | } | 1216 | } |
| @@ -1212,7 +1221,7 @@ fn test_removal_after_both_matched() { | |||
| 1212 | fn test_vllm_removal_after_pool_queued() { | 1221 | fn test_vllm_removal_after_pool_queued() { |
| 1213 | use crate::indexer::Indexer; | 1222 | use crate::indexer::Indexer; |
| 1214 | 1223 | ||
| 1215 | - let indexer = Indexer::new(ScoringConfig::default()); | 1224 | + let indexer = Indexer::new(); |
| 1216 | let entry = indexer.get_or_create("m", "t"); | 1225 | let entry = indexer.get_or_create("m", "t"); |
| 1217 | let block_size = 4u32; | 1226 | let block_size = 4u32; |
| 1218 | 1227 | ||
| @@ -1284,7 +1293,7 @@ fn test_vllm_removal_after_pool_queued() { | |||
| 1284 | fn test_duplicate_pool_stored_idempotent() { | 1293 | fn test_duplicate_pool_stored_idempotent() { |
| 1285 | use crate::indexer::Indexer; | 1294 | use crate::indexer::Indexer; |
| 1286 | 1295 | ||
| 1287 | - let indexer = Indexer::new(ScoringConfig::default()); | 1296 | + let indexer = Indexer::new(); |
| 1288 | let entry = indexer.get_or_create("m", "t"); | 1297 | let entry = indexer.get_or_create("m", "t"); |
| 1289 | 1298 | ||
| 1290 | let zmq_event = PoolEvent { | 1299 | let zmq_event = PoolEvent { |
| @@ -1340,7 +1349,7 @@ fn test_duplicate_pool_stored_idempotent() { | |||
| 1340 | fn test_pending_worker_cleanup() { | 1349 | fn test_pending_worker_cleanup() { |
| 1341 | use crate::indexer::Indexer; | 1350 | use crate::indexer::Indexer; |
| 1342 | 1351 | ||
| 1343 | - let indexer = Indexer::new(ScoringConfig::default()); | 1352 | + let indexer = Indexer::new(); |
| 1344 | let entry = indexer.get_or_create("m", "t"); | 1353 | let entry = indexer.get_or_create("m", "t"); |
| 1345 | 1354 | ||
| 1346 | // Queue pool events for two different workers. | 1355 | // Queue pool events for two different workers. |
| @@ -1394,7 +1403,7 @@ fn test_pending_worker_cleanup() { | |||
| 1394 | fn test_cleared_cleans_pending() { | 1403 | fn test_cleared_cleans_pending() { |
| 1395 | use crate::indexer::Indexer; | 1404 | use crate::indexer::Indexer; |
| 1396 | 1405 | ||
| 1397 | - let indexer = Indexer::new(ScoringConfig::default()); | 1406 | + let indexer = Indexer::new(); |
| 1398 | let entry = indexer.get_or_create("m", "t"); | 1407 | let entry = indexer.get_or_create("m", "t"); |
| 1399 | 1408 | ||
| 1400 | // Queue a pool event. | 1409 | // Queue a pool event. |
| @@ -1465,7 +1474,7 @@ fn test_cleared_cleans_pending() { | |||
| 1465 | fn test_sweep_stale_pending() { | 1474 | fn test_sweep_stale_pending() { |
| 1466 | use crate::indexer::Indexer; | 1475 | use crate::indexer::Indexer; |
| 1467 | 1476 | ||
| 1468 | - let indexer = Indexer::new(ScoringConfig::default()); | 1477 | + let indexer = Indexer::new(); |
| 1469 | let entry = indexer.get_or_create("m", "t"); | 1478 | let entry = indexer.get_or_create("m", "t"); |
| 1470 | 1479 | ||
| 1471 | // Queue a pool event. | 1480 | // Queue a pool event. |
| @@ -1498,7 +1507,7 @@ fn test_sweep_stale_pending() { | |||
| 1498 | assert_eq!(entry.pending_count(), 1); | 1507 | assert_eq!(entry.pending_count(), 1); |
| 1499 | 1508 | ||
| 1500 | // Sweep with zero TTL → removes everything. | 1509 | // Sweep with zero TTL → removes everything. |
| 1501 | - let pruned = entry.sweep_stale_pending(std::time::Duration::ZERO); | 1510 | + let pruned = entry.sweep_stale_pending(std::time::Duration::ZERO, std::time::Duration::ZERO); |
| 1502 | assert!(pruned > 0, "zero-TTL sweep should remove pending entries"); | 1511 | assert!(pruned > 0, "zero-TTL sweep should remove pending entries"); |
| 1503 | assert_eq!(entry.pending_count(), 0); | 1512 | assert_eq!(entry.pending_count(), 0); |
| 1504 | } | 1513 | } |
| @@ -1514,7 +1523,7 @@ fn test_vllm_parent_hash_root_level() { | |||
| 1514 | use crate::hashing::compute_block_hash_for_seq; | 1523 | use crate::hashing::compute_block_hash_for_seq; |
| 1515 | use crate::indexer::Indexer; | 1524 | use crate::indexer::Indexer; |
| 1516 | 1525 | ||
| 1517 | - let indexer = Indexer::new(ScoringConfig::default()); | 1526 | + let indexer = Indexer::new(); |
| 1518 | let block_size = 4u32; | 1527 | let block_size = 4u32; |
| 1519 | let tokens: Vec<i64> = (0..8).collect(); | 1528 | let tokens: Vec<i64> = (0..8).collect(); |
| 1520 | let hashes = compute_block_hash_for_seq(&tokens, block_size); | 1529 | let hashes = compute_block_hash_for_seq(&tokens, block_size); |
| @@ -1524,9 +1533,9 @@ fn test_vllm_parent_hash_root_level() { | |||
| 1524 | instance_id: "be".into(), | 1533 | instance_id: "be".into(), |
| 1525 | backend_id: "be".into(), | 1534 | backend_id: "be".into(), |
| 1526 | dp_rank: 0, | 1535 | dp_rank: 0, |
| 1527 | - medium: StorageMedium::Xpu, | 1536 | + medium: StorageMedium::Npu, |
| 1528 | }; | 1537 | }; |
| 1529 | - let media = &[StorageMedium::Xpu]; | 1538 | + let media = &[StorageMedium::Npu]; |
| 1530 | 1539 | ||
| 1531 | // 2-block event, no parent — these form a root chain internally. | 1540 | // 2-block event, no parent — these form a root chain internally. |
| 1532 | apply_vllm_event( | 1541 | apply_vllm_event( |
| @@ -1551,9 +1560,9 @@ fn test_vllm_parent_hash_root_level() { | |||
| 1551 | .unwrap(); | 1560 | .unwrap(); |
| 1552 | 1561 | ||
| 1553 | let entry = indexer.get_or_create("m", "t"); | 1562 | let entry = indexer.get_or_create("m", "t"); |
| 1554 | - let scores = entry.find_matches(&tokens, block_size, &ScoringConfig::default()); | 1563 | + let scores = entry.find_matches(&tokens, block_size); |
| 1555 | assert!( | 1564 | assert!( |
| 1556 | - scores.scores.contains_key(&wk), | 1565 | + scores.blocks.contains_key(&wk), |
| 1557 | "should match at least 1 block" | 1566 | "should match at least 1 block" |
| 1558 | ); | 1567 | ); |
| 1559 | 1568 | ||
| @@ -1570,7 +1579,7 @@ fn test_vllm_parent_hash_cross_event_chain() { | |||
| 1570 | use crate::hashing::compute_block_hash_for_seq; | 1579 | use crate::hashing::compute_block_hash_for_seq; |
| 1571 | use crate::indexer::Indexer; | 1580 | use crate::indexer::Indexer; |
| 1572 | 1581 | ||
| 1573 | - let indexer = Indexer::new(ScoringConfig::default()); | 1582 | + let indexer = Indexer::new(); |
| 1574 | let block_size = 4u32; | 1583 | let block_size = 4u32; |
| 1575 | let tokens: Vec<i64> = (0..16).collect(); | 1584 | let tokens: Vec<i64> = (0..16).collect(); |
| 1576 | let hashes = compute_block_hash_for_seq(&tokens, block_size); | 1585 | let hashes = compute_block_hash_for_seq(&tokens, block_size); |
| @@ -1580,9 +1589,9 @@ fn test_vllm_parent_hash_cross_event_chain() { | |||
| 1580 | instance_id: "be".into(), | 1589 | instance_id: "be".into(), |
| 1581 | backend_id: "be".into(), | 1590 | backend_id: "be".into(), |
| 1582 | dp_rank: 0, | 1591 | dp_rank: 0, |
| 1583 | - medium: StorageMedium::Xpu, | 1592 | + medium: StorageMedium::Npu, |
| 1584 | }; | 1593 | }; |
| 1585 | - let media = &[StorageMedium::Xpu]; | 1594 | + let media = &[StorageMedium::Npu]; |
| 1586 | 1595 | ||
| 1587 | // Event 0: blocks 0x100, 0x200, tokens[0..8], no parent. | 1596 | // Event 0: blocks 0x100, 0x200, tokens[0..8], no parent. |
| 1588 | apply_vllm_event( | 1597 | apply_vllm_event( |
| @@ -1629,10 +1638,13 @@ fn test_vllm_parent_hash_cross_event_chain() { | |||
| 1629 | .unwrap(); | 1638 | .unwrap(); |
| 1630 | 1639 | ||
| 1631 | let entry = indexer.get_or_create("m", "t"); | 1640 | let entry = indexer.get_or_create("m", "t"); |
| 1632 | - let scores = entry.find_matches(&tokens, block_size, &ScoringConfig::default()); | 1641 | + let scores = entry.find_matches(&tokens, block_size); |
| 1633 | - let score = scores.scores.get(&wk).expect("should match HBM chain"); | 1642 | + let matched = scores.blocks.get(&wk).expect("should match HBM chain"); |
| 1634 | - // 4-block chain → depth=4 × hbm_weight=3 = 12 | 1643 | + // Per-medium overlap is matched block count (no weighted scoring). |
| 1635 | - assert_eq!(*score, 12, "depth=4 × 3 = 12, got {score}"); | 1644 | + assert_eq!( |
| 1645 | + *matched, 4, | ||
| 1646 | + "4-block HBM chain should match depth=4, got {matched}" | ||
| 1647 | + ); | ||
| 1636 | 1648 | ||
| 1637 | // Verify parent-not-found error. | 1649 | // Verify parent-not-found error. |
| 1638 | let result = apply_vllm_event( | 1650 | let result = apply_vllm_event( |
| @@ -7,11 +7,20 @@ | |||
| 7 | // EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, | 7 | // EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, |
| 8 | // MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. | 8 | // MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. |
| 9 | // See the Mulan PSL v2 for more details. | 9 | // See the Mulan PSL v2 for more details. |
| 10 | +// | ||
| 11 | +// Portions: the main-attention allow/deny kind set used by | ||
| 12 | +// `is_main_attention_kind` is derived from NVIDIA Dynamo kv-router | ||
| 13 | +// `lib/kv-router/src/zmq_wire/filter.rs` (Apache-2.0). | ||
| 14 | +// Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| 15 | +// See ../THIRD_PARTY_NOTICES.md and ../licenses/Apache-2.0.txt. | ||
| 16 | +// The msgspec/JSON visitor and event-application logic in this file are | ||
| 17 | +// Huawei original work under Mulan PSL v2. | ||
| 10 | 18 | ||
| 11 | //! vLLM-native event types, parsing, and application logic. | 19 | //! vLLM-native event types, parsing, and application logic. |
| 12 | //! | 20 | //! |
| 13 | //! Handles the msgspec ``array_like`` wire format with tag-based dispatch, | 21 | //! Handles the msgspec ``array_like`` wire format with tag-based dispatch, |
| 14 | //! attention-group filtering, and two-phase offload/pool insertion. | 22 | //! attention-group filtering, and two-phase offload/pool insertion. |
| 23 | +//! Attention-kind filtering policy: see `THIRD_PARTY_NOTICES.md`. | ||
| 15 | 24 | ||
| 16 | use serde::Deserialize; | 25 | use serde::Deserialize; |
| 17 | 26 | ||
| @@ -363,18 +372,37 @@ fn parse_block_removed_values( | |||
| 363 | // --------------------------------------------------------------------------- | 372 | // --------------------------------------------------------------------------- |
| 364 | 373 | ||
| 365 | /// Returns `true` if `kind` is a main attention type whose events should be | 374 | /// Returns `true` if `kind` is a main attention type whose events should be |
| 366 | -/// ingested. Following Dynamo kv-router, only `FullAttention`, | 375 | +/// ingested. Allow/deny kind names follow NVIDIA Dynamo kv-router |
| 367 | -/// `MlaAttention`, and `SinkFullAttention` qualify. Events with no | 376 | +/// `zmq_wire/filter.rs` (Apache-2.0; see `THIRD_PARTY_NOTICES.md`): only |
| 368 | -/// `kv_cache_spec_kind` (older vLLM versions) are kept for backward compat. | 377 | +/// `FullAttention`, `MlaAttention`, and `SinkFullAttention` qualify. |
| 378 | +/// Events with no `kv_cache_spec_kind` (older vLLM versions) are kept for | ||
| 379 | +/// backward compat. | ||
| 380 | +/// | ||
| 381 | +/// Matching is case-insensitive and ignores underscores so both PascalCase | ||
| 382 | +/// (`MlaAttention`) and vLLM wire snake_case (`mla_attention`) are matched; | ||
| 383 | +/// denied kinds (e.g. `sliding_window_mla`) are filtered out. | ||
| 369 | pub(crate) fn is_main_attention_kind(kind: Option<&str>) -> bool { | 384 | pub(crate) fn is_main_attention_kind(kind: Option<&str>) -> bool { |
| 370 | - match kind { | 385 | + let Some(kind) = kind else { |
| 371 | - None => true, | 386 | + return true; |
| 372 | - Some("FullAttention") | Some("MlaAttention") | Some("SinkFullAttention") => true, | 387 | + }; |
| 373 | - Some("SlidingWindow") | 388 | + // Normalize: lowercase + strip underscores → "MlaAttention"/"mla_attention" |
| 374 | - | Some("Mamba") | 389 | + // both become "mlaattention". |
| 375 | - | Some("ChunkedLocalAttention") | 390 | + let normalized: String = kind |
| 376 | - | Some("EncoderOnlyAttention") | 391 | + .chars() |
| 377 | - | Some("CrossAttention") => false, | 392 | + .filter(|c| *c != '_') |
| 393 | + .flat_map(|c| c.to_lowercase()) | ||
| 394 | + .collect(); | ||
| 395 | + match normalized.as_str() { | ||
| 396 | + "fullattention" | "mlaattention" | "sinkfullattention" => true, | ||
| 397 | + // Non-main groups (SWA / Mamba / local / encoder / cross). | ||
| 398 | + // `slidingwindowmla` covers wire form `sliding_window_mla`. | ||
| 399 | + "slidingwindow" | ||
| 400 | + | "slidingwindowmla" | ||
| 401 | + | "mamba" | ||
| 402 | + | "chunkedlocalattention" | ||
| 403 | + | "encoderonlyattention" | ||
| 404 | + | "crossattention" => false, | ||
| 405 | + // Unknown future kinds — forward compat (same as before). | ||
| 378 | _ => true, | 406 | _ => true, |
| 379 | } | 407 | } |
| 380 | } | 408 | } |
| @@ -525,7 +553,7 @@ pub(crate) fn parse_vllm_batch(payload: &[u8]) -> Option<(Vec<VllmEvent>, u32)> | |||
| 525 | /// us to re-compute `tokens_hash` (XXH3 content hash). The behaviour | 553 | /// us to re-compute `tokens_hash` (XXH3 content hash). The behaviour |
| 526 | /// depends on the storage medium: | 554 | /// depends on the storage medium: |
| 527 | /// | 555 | /// |
| 528 | -/// - **HBM** (XPU/GPU): insert directly into the radix tree. | 556 | +/// - **HBM** (NPU): insert directly into the radix tree. |
| 529 | /// - **Non-HBM** (CPU/DISK): bidirectional matching — cache the | 557 | /// - **Non-HBM** (CPU/DISK): bidirectional matching — cache the |
| 530 | /// `block_hash → tokens_hash` mapping and check for pending pool events | 558 | /// `block_hash → tokens_hash` mapping and check for pending pool events |
| 531 | /// that arrived earlier. If a match is found the block enters the tree | 559 | /// that arrived earlier. If a match is found the block enters the tree |
| @@ -535,6 +563,10 @@ pub(crate) fn parse_vllm_batch(payload: &[u8]) -> Option<(Vec<VllmEvent>, u32)> | |||
| 535 | /// the block on a different node than the engine that offloaded it — the | 563 | /// the block on a different node than the engine that offloaded it — the |
| 536 | /// engine's offloading event tells us *what* was offloaded, and the pool | 564 | /// engine's offloading event tells us *what* was offloaded, and the pool |
| 537 | /// backend's event tells us *where* it was placed. | 565 | /// backend's event tells us *where* it was placed. |
| 566 | +/// | ||
| 567 | +/// After the first pool confirmation, `(tokens_hash, parent_hash)` is | ||
| 568 | +/// retained so a later pool medium (e.g. Disk SSD offload) can reuse | ||
| 569 | +/// content without another engine event. | ||
| 538 | 570 | ||
| 539 | pub(crate) fn apply_vllm_event( | 571 | pub(crate) fn apply_vllm_event( |
| 540 | indexer: &Indexer, | 572 | indexer: &Indexer, |
| @@ -570,9 +602,8 @@ pub(crate) fn apply_vllm_event( | |||
| 570 | return Ok(()); | 602 | return Ok(()); |
| 571 | } | 603 | } |
| 572 | 604 | ||
| 573 | - let event_medium = medium.as_deref().unwrap_or("xpu"); | 605 | + let event_medium = medium.as_deref().unwrap_or("npu"); |
| 574 | - let is_non_hbm = !event_medium.eq_ignore_ascii_case("xpu") | 606 | + let is_non_hbm = !StorageMedium::is_hbm_key(event_medium); |
| 575 | - && !event_medium.eq_ignore_ascii_case("gpu"); | ||
| 576 | 607 | ||
| 577 | let computed_hashes: Vec<u64> = if token_ids.is_empty() || *block_size == 0 { | 608 | let computed_hashes: Vec<u64> = if token_ids.is_empty() || *block_size == 0 { |
| 578 | block_hashes.to_vec() | 609 | block_hashes.to_vec() |
| @@ -590,20 +621,29 @@ pub(crate) fn apply_vllm_event( | |||
| 590 | let entry = indexer.get_or_create(model_name, tenant_id); | 621 | let entry = indexer.get_or_create(model_name, tenant_id); |
| 591 | 622 | ||
| 592 | if is_non_hbm { | 623 | if is_non_hbm { |
| 593 | - let pairs: Vec<(u64, u64)> = (0..num) | 624 | + // Walk the offload chain so each block carries its own |
| 594 | - .map(|i| (block_hashes[i], computed_hashes[i])) | 625 | + // `parent_hash`: the first block's parent is the event's |
| 595 | - .collect(); | 626 | + // `parent_block_hash`, and each subsequent block's parent is |
| 627 | + // the immediately preceding block in this same chain. This | ||
| 628 | + // preserves continuation-edge semantics across the two-phase | ||
| 629 | + // offload/pool confirmation protocol. | ||
| 630 | + let mut parent = *parent_block_hash; | ||
| 631 | + let mut triples: Vec<(u64, u64, Option<u64>)> = Vec::with_capacity(num); | ||
| 632 | + for i in 0..num { | ||
| 633 | + triples.push((block_hashes[i], computed_hashes[i], parent)); | ||
| 634 | + parent = Some(block_hashes[i]); | ||
| 635 | + } | ||
| 596 | 636 | ||
| 597 | - let preview_hashes: Vec<u64> = pairs.iter().take(4).map(|p| p.0).collect(); | 637 | + let preview_hashes: Vec<u64> = triples.iter().take(4).map(|p| p.0).collect(); |
| 598 | tracing::trace!( | 638 | tracing::trace!( |
| 599 | model = %model_name, tenant = %tenant_id, | 639 | model = %model_name, tenant = %tenant_id, |
| 600 | - num = pairs.len(), | 640 | + num = triples.len(), |
| 601 | ?preview_hashes, | 641 | ?preview_hashes, |
| 602 | medium = %event_medium, | 642 | medium = %event_medium, |
| 603 | "vLLM non-HBM: ingesting offload blocks" | 643 | "vLLM non-HBM: ingesting offload blocks" |
| 604 | ); | 644 | ); |
| 605 | 645 | ||
| 606 | - let matched = entry.ingest_offload_blocks(&pairs); | 646 | + let matched = entry.ingest_offload_blocks(&triples); |
| 607 | let total_matched: usize = matched.values().map(|v| v.len()).sum(); | 647 | let total_matched: usize = matched.values().map(|v| v.len()).sum(); |
| 608 | 648 | ||
| 609 | if !matched.is_empty() { | 649 | if !matched.is_empty() { |
| @@ -614,13 +654,19 @@ pub(crate) fn apply_vllm_event( | |||
| 614 | medium = %event_medium, | 654 | medium = %event_medium, |
| 615 | "vLLM non-HBM: matched pending pool events, applying to tree" | 655 | "vLLM non-HBM: matched pending pool events, applying to tree" |
| 616 | ); | 656 | ); |
| 657 | + // Apply one `Stored` event per block, each with its own | ||
| 658 | + // `parent_hash`, instead of batching them under | ||
| 659 | + // `parent_hash: None` — batching would silently drop | ||
| 660 | + // continuation-edge chaining between blocks. | ||
| 617 | for (worker, blocks) in matched { | 661 | for (worker, blocks) in matched { |
| 618 | - let store_data = KvCacheStoreData { | 662 | + for (parent_hash, block) in blocks { |
| 619 | - parent_hash: None, | 663 | + let store_data = KvCacheStoreData { |
| 620 | - start_position: None, | 664 | + parent_hash, |
| 621 | - blocks, | 665 | + start_position: None, |
| 622 | - }; | 666 | + blocks: vec![block], |
| 623 | - entry.apply_event(&worker, &KvCacheEventData::Stored(store_data))?; | 667 | + }; |
| 668 | + entry.apply_event(&worker, &KvCacheEventData::Stored(store_data))?; | ||
| 669 | + } | ||
| 624 | } | 670 | } |
| 625 | } | 671 | } |
| 626 | 672 | ||
| @@ -1,22 +1,36 @@ | |||
| 1 | -// Copyright (c) Huawei Technologies Co., Ltd. 2025-2026. All rights reserved. | 1 | +// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. |
| 2 | -// MindIE is licensed under Mulan PSL v2. | 2 | +// SPDX-FileCopyrightText: Copyright (c) Huawei Technologies Co., Ltd. 2025-2026. All rights reserved. |
| 3 | -// You can use this software according to the terms and conditions of the Mulan PSL v2. | 3 | +// SPDX-License-Identifier: Apache-2.0 |
| 4 | -// You may obtain a copy of Mulan PSL v2 at: | 4 | +// |
| 5 | -// http://license.coscl.org.cn/MulanPSL2 | 5 | +// This file is a Derivative Work of NVIDIA Dynamo kv-router hashing helpers |
| 6 | -// THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, | 6 | +// in lib/kv-router/src/protocols.rs (XXH3_SEED, compute_block_hash, |
| 7 | -// EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, | 7 | +// little-endian xxh3 block hashing), originally licensed under the Apache |
| 8 | -// MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. | 8 | +// License, Version 2.0. Upstream project: https://github.com/ai-dynamo/dynamo |
| 9 | -// See the Mulan PSL v2 for more details. | 9 | +// |
| 10 | +// You may obtain a copy of the Apache License at: | ||
| 11 | +// http://www.apache.org/licenses/LICENSE-2.0 | ||
| 12 | +// Local copy: licenses/Apache-2.0.txt | ||
| 13 | +// Attribution: THIRD_PARTY_NOTICES.md | ||
| 14 | +// | ||
| 15 | +// Modified by Huawei Technologies Co., Ltd. for MindIE-PyMotor KV Conductor | ||
| 16 | +// (i64 token input with per-chunk conversion, rayon parallel batching, | ||
| 17 | +// include partial trailing block via div_ceil). Huawei modifications are | ||
| 18 | +// also available under Mulan PSL v2 (http://license.coscl.org.cn/MulanPSL2). | ||
| 19 | +// Redistribution of this file must still comply with Apache License 2.0. | ||
| 10 | 20 | ||
| 11 | //! XXH3-based token block hashing. | 21 | //! XXH3-based token block hashing. |
| 12 | //! | 22 | //! |
| 23 | +//! Derived from NVIDIA Dynamo kv-router hashing helpers in | ||
| 24 | +//! `lib/kv-router/src/protocols.rs` (Apache-2.0). See `THIRD_PARTY_NOTICES.md`. | ||
| 25 | +//! | ||
| 13 | //! Computes `LocalBlockHash` values from token sequences using a sliding-window | 26 | //! Computes `LocalBlockHash` values from token sequences using a sliding-window |
| 14 | 27 | ||
| 15 | use xxhash_rust::xxh3; | 28 | use xxhash_rust::xxh3; |
| 16 | 29 | ||
| 17 | use crate::protocols::LocalBlockHash; | 30 | use crate::protocols::LocalBlockHash; |
| 18 | 31 | ||
| 19 | -/// Seed for XXH3 hashing, consistent with Dynamo kv-router. | 32 | +/// Seed for XXH3 hashing (same value as NVIDIA Dynamo kv-router for wire |
| 33 | +/// interoperability). | ||
| 20 | pub const XXH3_SEED: u64 = 1337; | 34 | pub const XXH3_SEED: u64 = 1337; |
| 21 | 35 | ||
| 22 | /// Compute the hash of arbitrary data. | 36 | /// Compute the hash of arbitrary data. |
| @@ -8,19 +8,23 @@ | |||
| 8 | // MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. | 8 | // MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. |
| 9 | // See the Mulan PSL v2 for more details. | 9 | // See the Mulan PSL v2 for more details. |
| 10 | 10 | ||
| 11 | -//! Per-(model, tenant) radix tree indexer with per-medium scoring. | 11 | +//! Per-(model, tenant) radix tree indexer with per-medium block matching. |
| 12 | //! | 12 | //! |
| 13 | //! Each `IndexerEntry` manages: | 13 | //! Each `IndexerEntry` manages: |
| 14 | //! | 14 | //! |
| 15 | -//! - **HBM tree** (`hbm_tree`) — prefix-chain radix tree for XPU/GPU blocks | 15 | +//! - **HBM tree** (`hbm_tree`) — prefix-chain radix tree for NPU blocks. |
| 16 | -//! (weight ×3). Only HBM blocks need prefix-chain matching. | 16 | +//! - **CPU / Disk continuation indexes** (`cpu_tiers` / `disk_tiers`) — |
| 17 | -//! - **CPU flat map** (`cpu_blocks`) — ``tokens_hash → {workers}`` (weight ×2). | 17 | +//! ``(parent_seq_hash, tokens_hash) → child`` edges (see `lower_tier` |
| 18 | -//! - **Disk flat map** (`disk_blocks`) — ``tokens_hash → {workers}`` (weight ×1). | 18 | +//! and `THIRD_PARTY_NOTICES.md`). CPU continues from the HBM |
| 19 | -//! - **non_hbm_cache** — engine offload ``block_hash → tokens_hash`` for | 19 | +//! breakpoint; Disk continues from ``max(HBM, CPU)`` (CPU preferred |
| 20 | -//! two-phase pool confirmation. | 20 | +//! when it extends further). Root chains are walked unconditionally so |
| 21 | +//! longer lower-tier replicas are never hidden by shorter upstream hits. | ||
| 22 | +//! - **offload_pool_state** — bidirectional offload/pool event matching | ||
| 23 | +//! (see [`OffloadPoolState`]). The `offload` side now also carries the | ||
| 24 | +//! originating `parent_hash` so that lower-tier continuation edges are | ||
| 25 | +//! correctly chained once the pool backend confirms placement. | ||
| 21 | //! | 26 | //! |
| 22 | -//! Scoring: each matched HBM block = 3 pts, CPU block = 2 pts, disk block = 1 pt. | 27 | +//! Query results report matched block counts per medium (no weighted scoring). |
| 23 | -//! A DP's total score is the sum across all its media. | ||
| 24 | 28 | ||
| 25 | use std::collections::HashMap; | 29 | use std::collections::HashMap; |
| 26 | use std::sync::atomic::{AtomicU64, Ordering}; | 30 | use std::sync::atomic::{AtomicU64, Ordering}; |
| @@ -30,33 +34,70 @@ use dashmap::DashMap; | |||
| 30 | use parking_lot::RwLock; | 34 | use parking_lot::RwLock; |
| 31 | use rustc_hash::FxHashMap; | 35 | use rustc_hash::FxHashMap; |
| 32 | use rustc_hash::FxHashSet; | 36 | use rustc_hash::FxHashSet; |
| 37 | +use serde::Serialize; | ||
| 33 | 38 | ||
| 34 | -use crate::concurrent_tree::{ConcurrentRadixTree, WorkerLookup}; | 39 | +use crate::concurrent_tree::{ConcurrentRadixTree, PrefixMatch, WorkerLookup}; |
| 35 | use crate::error::KvConductorError; | 40 | use crate::error::KvConductorError; |
| 36 | use crate::hashing::compute_block_hash_for_seq; | 41 | use crate::hashing::compute_block_hash_for_seq; |
| 42 | +use crate::lower_tier::{ContiguousHit, LowerTierContinuation, LowerTierIndexer}; | ||
| 37 | use crate::protocols::*; | 43 | use crate::protocols::*; |
| 38 | 44 | ||
| 39 | /// Number of HBM removals after which `sweep_stale_nodes` is triggered | 45 | /// Number of HBM removals after which `sweep_stale_nodes` is triggered |
| 40 | /// to reclaim orphan tree nodes. | 46 | /// to reclaim orphan tree nodes. |
| 41 | const HBM_SWEEP_THRESHOLD: u64 = 1000; | 47 | const HBM_SWEEP_THRESHOLD: u64 = 1000; |
| 42 | 48 | ||
| 43 | -/// Minimum number of block hashes to trigger parallel CPU/Disk flat lookup. | ||
| 44 | -const FLAT_PAR_THRESHOLD: usize = 4096; | ||
| 45 | - | ||
| 46 | /// Number of ingest operations after which `sweep_stale_pending` is triggered | 49 | /// Number of ingest operations after which `sweep_stale_pending` is triggered |
| 47 | -/// to evict expired entries from the pending pool cache. | 50 | +/// to evict expired `pending_pool` / `content` entries. |
| 48 | const PENDING_SWEEP_THRESHOLD: u64 = 100; | 51 | const PENDING_SWEEP_THRESHOLD: u64 = 100; |
| 49 | /// TTL for stale pending pool entries (60 seconds). | 52 | /// TTL for stale pending pool entries (60 seconds). |
| 50 | const PENDING_TTL: std::time::Duration = std::time::Duration::from_secs(60); | 53 | const PENDING_TTL: std::time::Duration = std::time::Duration::from_secs(60); |
| 54 | +/// TTL for retained `content` entries. Content must survive the CPU→Disk | ||
| 55 | +/// migration window (CPU tier eviction before the Disk store event arrives), | ||
| 56 | +/// so it is deliberately longer than [`PENDING_TTL`]; entries are cleared | ||
| 57 | +/// once the window closes. | ||
| 58 | +const CONTENT_TTL: std::time::Duration = std::time::Duration::from_secs(300); | ||
| 51 | 59 | ||
| 52 | -/// Per-worker reverse-lookup for non-HBM flat stores. | 60 | +/// Upstream-tier match breakpoint used to continue into the next lower tier. |
| 53 | -/// Maps ``SequenceBlockHash → LocalBlockHash(XXH3 tokens_hash)``. | 61 | +#[derive(Debug, Clone)] |
| 54 | -type FlatLookup = FxHashMap<u64, u64>; | 62 | +struct TierBreakpoint { |
| 63 | + instance_id: String, | ||
| 64 | + dp_rank: DpRank, | ||
| 65 | + /// Absolute index in the query hash sequence where the next tier starts. | ||
| 66 | + end_pos: usize, | ||
| 67 | + /// Sequence hash of the last matched upstream block. | ||
| 68 | + last_seq: SequenceBlockHash, | ||
| 69 | +} | ||
| 55 | 70 | ||
| 56 | // --------------------------------------------------------------------------- | 71 | // --------------------------------------------------------------------------- |
| 57 | // Two-phase offload/pool matching protocol | 72 | // Two-phase offload/pool matching protocol |
| 58 | // --------------------------------------------------------------------------- | 73 | // --------------------------------------------------------------------------- |
| 59 | 74 | ||
| 75 | +/// Cached Phase-1 offload mapping: `tokens_hash` plus the optional | ||
| 76 | +/// `parent_hash` (the engine's immediately preceding block in the offload | ||
| 77 | +/// chain, or the chain's original `parent_block_hash` for the first block). | ||
| 78 | +/// | ||
| 79 | +/// Carrying `parent_hash` alongside `tokens_hash` allows Phase-2 confirmation | ||
| 80 | +/// to insert this block as a continuation edge from the correct predecessor, | ||
| 81 | +/// rather than always chaining from root. | ||
| 82 | + | ||
| 83 | +pub(crate) struct OffloadCacheEntry { | ||
| 84 | + pub(crate) tokens_hash: u64, | ||
| 85 | + pub(crate) parent_hash: Option<u64>, | ||
| 86 | +} | ||
| 87 | + | ||
| 88 | +/// Confirmed content kept for a possible later pool medium (Disk promotion), | ||
| 89 | +/// with insert time for TTL eviction ([`CONTENT_TTL`]). | ||
| 90 | +/// | ||
| 91 | +/// Unlike the unconfirmed `offload` entries (which have no TTL — bounded by | ||
| 92 | +/// the number of outstanding offloaded blocks), content survives lower-tier | ||
| 93 | +/// removal so a Disk store event arriving after CPU eviction can still resolve | ||
| 94 | +/// the mapping; the TTL bounds how long it lingers. | ||
| 95 | + | ||
| 96 | +pub(crate) struct ContentEntry { | ||
| 97 | + pub(crate) entry: OffloadCacheEntry, | ||
| 98 | + pub(crate) inserted_at: std::time::Instant, | ||
| 99 | +} | ||
| 100 | + | ||
| 60 | /// A pool backend event waiting for its corresponding offload event. | 101 | /// A pool backend event waiting for its corresponding offload event. |
| 61 | /// | 102 | /// |
| 62 | /// Equality and hashing consider **only** the `worker` field — `inserted_at` | 103 | /// Equality and hashing consider **only** the `worker` field — `inserted_at` |
| @@ -84,16 +125,33 @@ impl std::hash::Hash for PendingPoolEvent { | |||
| 84 | 125 | ||
| 85 | /// Combined state for the two-phase offload/pool matching protocol. | 126 | /// Combined state for the two-phase offload/pool matching protocol. |
| 86 | /// | 127 | /// |
| 87 | -/// **Invariant**: a `block_hash` exists in **at most one** of the two maps | 128 | +/// **Invariant**: a `block_hash` is never in both `offload` and `content`. |
| 88 | -/// at any time. Once both sides have arrived, the entry is removed from | 129 | +/// After the first pool confirmation, the entry moves from `offload` into |
| 89 | -/// both maps and inserted into the radix tree (or flat store). | 130 | +/// `content` — always retained for a possible later Disk promotion (the |
| 131 | +/// mapping is a copy of the tier data plus a bounded migration-window | ||
| 132 | +/// residue; `content` is TTL-swept so memory stays bounded). | ||
| 133 | +/// | ||
| 134 | +/// Lifecycle: | ||
| 135 | +/// - `offload`: unconfirmed engine offloads. **No TTL** — pool confirmation | ||
| 136 | +/// may arrive arbitrarily late, so entries are bounded only by the number | ||
| 137 | +/// of outstanding offloaded blocks (original two-phase design) and cleared | ||
| 138 | +/// on match or explicit removal. | ||
| 139 | +/// - `content`: confirmed `(tokens_hash, parent_hash)` kept for a later pool | ||
| 140 | +/// medium. **TTL-evicted** ([`CONTENT_TTL`]) — it must survive lower-tier | ||
| 141 | +/// removal so a Disk store arriving after CPU eviction still resolves, and | ||
| 142 | +/// the TTL bounds how long a block that never reaches Disk lingers. | ||
| 143 | +/// - `pending_pool`: pool-first arrivals, TTL [`PENDING_TTL`]. | ||
| 90 | /// | 144 | /// |
| 91 | /// Uses a single `RwLock` so that cross-cache operations are atomic without | 145 | /// Uses a single `RwLock` so that cross-cache operations are atomic without |
| 92 | /// lock-ordering deadlock risk. | 146 | /// lock-ordering deadlock risk. |
| 93 | 147 | ||
| 94 | pub(crate) struct OffloadPoolState { | 148 | pub(crate) struct OffloadPoolState { |
| 95 | - /// `block_hash → tokens_hash`: offload events waiting for pool confirmation. | 149 | + /// `block_hash → OffloadCacheEntry`: offload events waiting for the |
| 96 | - pub(crate) offload: FxHashMap<u64, u64>, | 150 | + /// **first** pool confirmation. Not TTL-swept (see struct docs). |
| 151 | + pub(crate) offload: FxHashMap<u64, OffloadCacheEntry>, | ||
| 152 | + /// `block_hash → ContentEntry`: retained after the first lower-tier | ||
| 153 | + /// insert (always, see struct docs). Swept with [`CONTENT_TTL`]. | ||
| 154 | + pub(crate) content: FxHashMap<u64, ContentEntry>, | ||
| 97 | /// `block_hash → workers`: pool events waiting for offload `tokens_hash`. | 155 | /// `block_hash → workers`: pool events waiting for offload `tokens_hash`. |
| 98 | /// Values are `FxHashSet` to deduplicate repeated deliveries. | 156 | /// Values are `FxHashSet` to deduplicate repeated deliveries. |
| 99 | pub(crate) pending_pool: FxHashMap<u64, FxHashSet<PendingPoolEvent>>, | 157 | pub(crate) pending_pool: FxHashMap<u64, FxHashSet<PendingPoolEvent>>, |
| @@ -108,20 +166,15 @@ pub struct IndexerKey { | |||
| 108 | 166 | ||
| 109 | /// An indexer entry for one (model, tenant) pair. | 167 | /// An indexer entry for one (model, tenant) pair. |
| 110 | pub struct IndexerEntry { | 168 | pub struct IndexerEntry { |
| 111 | - /// HBM prefix-chain radix tree (XPU workers, weight ×3). | 169 | + /// HBM prefix-chain radix tree (NPU workers). |
| 112 | pub hbm_tree: Arc<ConcurrentRadixTree>, | 170 | pub hbm_tree: Arc<ConcurrentRadixTree>, |
| 113 | /// HBM per-worker reverse lookups: WorkerKey → WorkerLookup. | 171 | /// HBM per-worker reverse lookups: WorkerKey → WorkerLookup. |
| 114 | pub lookups: Arc<RwLock<FxHashMap<WorkerKey, WorkerLookup>>>, | 172 | pub lookups: Arc<RwLock<FxHashMap<WorkerKey, WorkerLookup>>>, |
| 115 | 173 | ||
| 116 | - /// CPU flat blocks: tokens_hash → set of workers with this block cached. | 174 | + /// CPU continuation-edge index. |
| 117 | - pub cpu_blocks: Arc<RwLock<FxHashMap<LocalBlockHash, FxHashSet<WorkerKey>>>>, | 175 | + pub cpu_tiers: Arc<LowerTierIndexer>, |
| 118 | - /// CPU per-worker reverse lookups: WorkerKey → (seq_hash → tokens_hash). | 176 | + /// Disk continuation-edge index. |
| 119 | - cpu_lookups: Arc<RwLock<FxHashMap<WorkerKey, FlatLookup>>>, | 177 | + pub disk_tiers: Arc<LowerTierIndexer>, |
| 120 | - | ||
| 121 | - /// Disk flat blocks: tokens_hash → set of workers with this block cached. | ||
| 122 | - pub disk_blocks: Arc<RwLock<FxHashMap<LocalBlockHash, FxHashSet<WorkerKey>>>>, | ||
| 123 | - /// Disk per-worker reverse lookups. | ||
| 124 | - disk_lookups: Arc<RwLock<FxHashMap<WorkerKey, FlatLookup>>>, | ||
| 125 | 178 | ||
| 126 | /// Bidirectional offload/pool event matching state. | 179 | /// Bidirectional offload/pool event matching state. |
| 127 | /// See [`OffloadPoolState`] for the invariant. | 180 | /// See [`OffloadPoolState`] for the invariant. |
| @@ -137,150 +190,233 @@ pub struct IndexerEntry { | |||
| 137 | 190 | ||
| 138 | impl Default for IndexerEntry { | 191 | impl Default for IndexerEntry { |
| 139 | fn default() -> Self { | 192 | fn default() -> Self { |
| 140 | - Self { | 193 | + Self::new() |
| 141 | - hbm_tree: Arc::new(ConcurrentRadixTree::new()), | ||
| 142 | - lookups: Arc::new(RwLock::new(FxHashMap::default())), | ||
| 143 | - cpu_blocks: Arc::new(RwLock::new(FxHashMap::default())), | ||
| 144 | - cpu_lookups: Arc::new(RwLock::new(FxHashMap::default())), | ||
| 145 | - disk_blocks: Arc::new(RwLock::new(FxHashMap::default())), | ||
| 146 | - disk_lookups: Arc::new(RwLock::new(FxHashMap::default())), | ||
| 147 | - offload_pool_state: Arc::new(RwLock::new(OffloadPoolState::default())), | ||
| 148 | - hbm_removal_count: AtomicU64::new(0), | ||
| 149 | - pending_ingest_count: AtomicU64::new(0), | ||
| 150 | - } | ||
| 151 | } | 194 | } |
| 152 | } | 195 | } |
| 153 | 196 | ||
| 154 | impl IndexerEntry { | 197 | impl IndexerEntry { |
| 155 | pub fn new() -> Self { | 198 | pub fn new() -> Self { |
| 156 | - Self::default() | 199 | + Self { |
| 200 | + hbm_tree: Arc::new(ConcurrentRadixTree::new()), | ||
| 201 | + lookups: Arc::new(RwLock::new(FxHashMap::default())), | ||
| 202 | + cpu_tiers: Arc::new(LowerTierIndexer::new()), | ||
| 203 | + disk_tiers: Arc::new(LowerTierIndexer::new()), | ||
| 204 | + offload_pool_state: Arc::new(RwLock::new(OffloadPoolState::default())), | ||
| 205 | + hbm_removal_count: AtomicU64::new(0), | ||
| 206 | + pending_ingest_count: AtomicU64::new(0), | ||
| 207 | + } | ||
| 157 | } | 208 | } |
| 158 | 209 | ||
| 159 | // ----------------------------------------------------------------------- | 210 | // ----------------------------------------------------------------------- |
| 160 | // Query | 211 | // Query |
| 161 | // ----------------------------------------------------------------------- | 212 | // ----------------------------------------------------------------------- |
| 162 | 213 | ||
| 163 | - pub fn find_matches( | 214 | + pub fn find_matches(&self, token_ids: &[i64], block_size: u32) -> OverlapBlocks { |
| 164 | - &self, | ||
| 165 | - token_ids: &[i64], | ||
| 166 | - block_size: u32, | ||
| 167 | - cfg: &ScoringConfig, | ||
| 168 | - ) -> OverlapScores { | ||
| 169 | let t_hash = std::time::Instant::now(); | 215 | let t_hash = std::time::Instant::now(); |
| 170 | let block_hashes = compute_block_hash_for_seq(token_ids, block_size); | 216 | let block_hashes = compute_block_hash_for_seq(token_ids, block_size); |
| 171 | let hash_us = t_hash.elapsed().as_micros(); | 217 | let hash_us = t_hash.elapsed().as_micros(); |
| 172 | 218 | ||
| 173 | - let scores = self.find_matches_by_hash(&block_hashes, cfg); | 219 | + let overlap = self.find_matches_by_hash(&block_hashes); |
| 174 | 220 | ||
| 175 | tracing::debug!( | 221 | tracing::debug!( |
| 176 | num_tokens = token_ids.len(), | 222 | num_tokens = token_ids.len(), |
| 177 | block_size, | 223 | block_size, |
| 178 | num_hashes = block_hashes.len(), | 224 | num_hashes = block_hashes.len(), |
| 179 | hash_us, | 225 | hash_us, |
| 180 | - scores = scores.scores.len(), | 226 | + matched = overlap.blocks.len(), |
| 181 | "hash_computed" | 227 | "hash_computed" |
| 182 | ); | 228 | ); |
| 183 | - scores | 229 | + overlap |
| 184 | } | 230 | } |
| 185 | 231 | ||
| 186 | - pub fn find_matches_by_hash( | 232 | + pub fn find_matches_by_hash(&self, block_hashes: &[LocalBlockHash]) -> OverlapBlocks { |
| 187 | - &self, | 233 | + self.find_matches_with_coverage(block_hashes).0 |
| 188 | - block_hashes: &[LocalBlockHash], | ||
| 189 | - cfg: &ScoringConfig, | ||
| 190 | - ) -> OverlapScores { | ||
| 191 | - let mut scores = OverlapScores::default(); | ||
| 192 | - | ||
| 193 | - let hbm_scores = self.hbm_tree.find_matches(block_hashes); | ||
| 194 | - for (worker, depth) in hbm_scores.scores { | ||
| 195 | - scores.add_score(worker, depth * cfg.hbm_weight); | ||
| 196 | - } | ||
| 197 | - | ||
| 198 | - if block_hashes.len() > FLAT_PAR_THRESHOLD { | ||
| 199 | - self.flat_lookup_parallel(block_hashes, cfg, &mut scores); | ||
| 200 | - } else { | ||
| 201 | - self.flat_lookup_sequential(block_hashes, cfg, &mut scores); | ||
| 202 | - } | ||
| 203 | - | ||
| 204 | - scores | ||
| 205 | } | 234 | } |
| 206 | 235 | ||
| 207 | - /// Sequential CPU + Disk flat lookup (for small hash sets). | 236 | + /// Query with per-DP absolute coverage end (in blocks). |
| 208 | - fn flat_lookup_sequential( | 237 | + /// |
| 238 | + /// `npu_blocks` / `cpu_blocks` / `disk_blocks` remain per-medium segment | ||
| 239 | + /// lengths. `coverage_end` is the farthest absolute prefix end across | ||
| 240 | + /// media for each `(instance_id, dp_rank)` — used for `matched_tokens` | ||
| 241 | + /// so overlapping replicas (same prefix on NPU+CPU+Disk) are not summed. | ||
| 242 | + fn find_matches_with_coverage( | ||
| 209 | &self, | 243 | &self, |
| 210 | block_hashes: &[LocalBlockHash], | 244 | block_hashes: &[LocalBlockHash], |
| 211 | - cfg: &ScoringConfig, | 245 | + ) -> (OverlapBlocks, FxHashMap<(String, DpRank), u32>) { |
| 212 | - scores: &mut OverlapScores, | 246 | + let mut overlap = OverlapBlocks::default(); |
| 213 | - ) { | 247 | + let mut coverage_end: FxHashMap<(String, DpRank), u32> = FxHashMap::default(); |
| 214 | - let cpu = self.cpu_blocks.read(); | 248 | + |
| 215 | - for hash in block_hashes { | 249 | + // 1) HBM prefix match. |
| 216 | - if let Some(workers) = cpu.get(hash) { | 250 | + let hbm: FxHashMap<WorkerKey, PrefixMatch> = |
| 217 | - for w in workers { | 251 | + self.hbm_tree.find_matches_detailed(block_hashes); |
| 218 | - scores.add_score(w.clone(), cfg.cpu_weight); | 252 | + for (worker, m) in &hbm { |
| 219 | - } | 253 | + if m.depth == 0 { |
| 254 | + continue; | ||
| 220 | } | 255 | } |
| 256 | + overlap.add_blocks(worker.clone(), m.depth); | ||
| 257 | + Self::note_coverage( | ||
| 258 | + &mut coverage_end, | ||
| 259 | + &worker.instance_id, | ||
| 260 | + worker.dp_rank, | ||
| 261 | + m.depth, | ||
| 262 | + ); | ||
| 221 | } | 263 | } |
| 222 | - drop(cpu); | ||
| 223 | 264 | ||
| 224 | - let disk = self.disk_blocks.read(); | 265 | + // Breakpoints need last_seq_hash for continuation. |
| 225 | - for hash in block_hashes { | 266 | + let hbm_breaks: Vec<TierBreakpoint> = hbm |
| 226 | - if let Some(workers) = disk.get(hash) { | 267 | + .iter() |
| 227 | - for w in workers { | 268 | + .filter(|(_, m)| m.depth > 0) |
| 228 | - scores.add_score(w.clone(), cfg.disk_weight); | 269 | + .filter_map(|(w, m)| { |
| 229 | - } | 270 | + Some(TierBreakpoint { |
| 230 | - } | 271 | + instance_id: w.instance_id.clone(), |
| 231 | - } | 272 | + dp_rank: w.dp_rank, |
| 232 | - } | 273 | + end_pos: m.depth as usize, |
| 274 | + last_seq: m.last_seq_hash?, | ||
| 275 | + }) | ||
| 276 | + }) | ||
| 277 | + .collect(); | ||
| 233 | 278 | ||
| 234 | - /// Parallel CPU + Disk flat lookup using rayon (for large hash sets, | 279 | + // 2) CPU: continue from HBM breakpoints; root walk runs for every |
| 235 | - /// e.g. DeepSeek V4 with 32K+ block hashes). | 280 | + // worker owning the first edge (replicas of an already-covered |
| 236 | - fn flat_lookup_parallel( | 281 | + // prefix are reported as real segment lengths — coverage is a |
| 237 | - &self, | 282 | + // max, so they cannot inflate matched_tokens). |
| 238 | - block_hashes: &[LocalBlockHash], | 283 | + let cpu_hits = self.lower_tier_lookup( |
| 239 | - cfg: &ScoringConfig, | 284 | + block_hashes, |
| 240 | - scores: &mut OverlapScores, | 285 | + &hbm_breaks, |
| 241 | - ) { | 286 | + &self.cpu_tiers, |
| 242 | - use rayon::prelude::*; | 287 | + &mut overlap, |
| 243 | - | 288 | + &mut coverage_end, |
| 244 | - let cpu = self.cpu_blocks.read(); | ||
| 245 | - let disk = self.disk_blocks.read(); | ||
| 246 | - | ||
| 247 | - let (cpu_scores, disk_scores): (OverlapScores, OverlapScores) = rayon::join( | ||
| 248 | - || { | ||
| 249 | - block_hashes | ||
| 250 | - .par_iter() | ||
| 251 | - .fold(OverlapScores::default, |mut acc, hash| { | ||
| 252 | - if let Some(workers) = cpu.get(hash) { | ||
| 253 | - for w in workers { | ||
| 254 | - acc.add_score(w.clone(), cfg.cpu_weight); | ||
| 255 | - } | ||
| 256 | - } | ||
| 257 | - acc | ||
| 258 | - }) | ||
| 259 | - .reduce(OverlapScores::default, |mut a, b| { | ||
| 260 | - a.merge(b); | ||
| 261 | - a | ||
| 262 | - }) | ||
| 263 | - }, | ||
| 264 | - || { | ||
| 265 | - block_hashes | ||
| 266 | - .par_iter() | ||
| 267 | - .fold(OverlapScores::default, |mut acc, hash| { | ||
| 268 | - if let Some(workers) = disk.get(hash) { | ||
| 269 | - for w in workers { | ||
| 270 | - acc.add_score(w.clone(), cfg.disk_weight); | ||
| 271 | - } | ||
| 272 | - } | ||
| 273 | - acc | ||
| 274 | - }) | ||
| 275 | - .reduce(OverlapScores::default, |mut a, b| { | ||
| 276 | - a.merge(b); | ||
| 277 | - a | ||
| 278 | - }) | ||
| 279 | - }, | ||
| 280 | ); | 289 | ); |
| 281 | 290 | ||
| 282 | - scores.merge(cpu_scores); | 291 | + let cpu_breaks: Vec<TierBreakpoint> = cpu_hits |
| 283 | - scores.merge(disk_scores); | 292 | + .iter() |
| 293 | + .filter(|(_, h)| h.count > 0) | ||
| 294 | + .filter_map(|(w, h)| { | ||
| 295 | + Some(TierBreakpoint { | ||
| 296 | + instance_id: w.instance_id.clone(), | ||
| 297 | + dp_rank: w.dp_rank, | ||
| 298 | + end_pos: h.end_pos(), | ||
| 299 | + last_seq: h.last_matched_hash?, | ||
| 300 | + }) | ||
| 301 | + }) | ||
| 302 | + .collect(); | ||
| 303 | + | ||
| 304 | + // 3) Disk: continue from max(HBM, CPU) per DP (CPU wins when it | ||
| 305 | + // extends further — matches vLLM lookup: CPU then Disk after NPU). | ||
| 306 | + let disk_breaks = Self::merge_tier_breakpoints(&hbm_breaks, &cpu_breaks); | ||
| 307 | + self.lower_tier_lookup( | ||
| 308 | + block_hashes, | ||
| 309 | + &disk_breaks, | ||
| 310 | + &self.disk_tiers, | ||
| 311 | + &mut overlap, | ||
| 312 | + &mut coverage_end, | ||
| 313 | + ); | ||
| 314 | + | ||
| 315 | + (overlap, coverage_end) | ||
| 316 | + } | ||
| 317 | + | ||
| 318 | + | ||
| 319 | + fn note_coverage( | ||
| 320 | + coverage_end: &mut FxHashMap<(String, DpRank), u32>, | ||
| 321 | + instance_id: &str, | ||
| 322 | + dp_rank: DpRank, | ||
| 323 | + end: u32, | ||
| 324 | + ) { | ||
| 325 | + coverage_end | ||
| 326 | + .entry((instance_id.to_string(), dp_rank)) | ||
| 327 | + .and_modify(|e| *e = (*e).max(end)) | ||
| 328 | + .or_insert(end); | ||
| 329 | + } | ||
| 330 | + | ||
| 331 | + /// Per `(instance_id, dp_rank)`, keep the farther breakpoint. | ||
| 332 | + /// | ||
| 333 | + /// `preferred` (CPU) overwrites `fallback` (HBM) when ``end_pos`` is | ||
| 334 | + /// greater or equal — so Disk resumes after the longest upstream prefix. | ||
| 335 | + fn merge_tier_breakpoints( | ||
| 336 | + fallback: &[TierBreakpoint], | ||
| 337 | + preferred: &[TierBreakpoint], | ||
| 338 | + ) -> Vec<TierBreakpoint> { | ||
| 339 | + let mut best: HashMap<(String, DpRank), TierBreakpoint> = HashMap::new(); | ||
| 340 | + for b in fallback { | ||
| 341 | + best.insert((b.instance_id.clone(), b.dp_rank), b.clone()); | ||
| 342 | + } | ||
| 343 | + for b in preferred { | ||
| 344 | + let key = (b.instance_id.clone(), b.dp_rank); | ||
| 345 | + match best.get(&key) { | ||
| 346 | + Some(existing) if b.end_pos < existing.end_pos => {} | ||
| 347 | + _ => { | ||
| 348 | + best.insert(key, b.clone()); | ||
| 349 | + } | ||
| 350 | + } | ||
| 351 | + } | ||
| 352 | + best.into_values().collect() | ||
| 353 | + } | ||
| 354 | + | ||
| 355 | + /// Build continuations and count contiguous lower-tier hits. | ||
| 356 | + /// | ||
| 357 | + /// - Root walks run for **every** worker owning the first edge — replicas | ||
| 358 | + /// of a prefix already covered upstream are reported as real segment | ||
| 359 | + /// lengths. This cannot inflate `matched_tokens` (coverage is a max | ||
| 360 | + /// over absolute ends, each bounded by the query length). | ||
| 361 | + /// - Continuation starts from each upstream ``TierBreakpoint``; a worker | ||
| 362 | + /// may hold several candidates (root + breakpoints), and the one with | ||
| 363 | + /// the farthest absolute end wins inside | ||
| 364 | + /// [`LowerTierIndexer::query_contiguous_hits`]. | ||
| 365 | + fn lower_tier_lookup( | ||
| 366 | + &self, | ||
| 367 | + block_hashes: &[LocalBlockHash], | ||
| 368 | + upstream_breaks: &[TierBreakpoint], | ||
| 369 | + tiers: &LowerTierIndexer, | ||
| 370 | + overlap: &mut OverlapBlocks, | ||
| 371 | + coverage_end: &mut FxHashMap<(String, DpRank), u32>, | ||
| 372 | + ) -> FxHashMap<WorkerKey, ContiguousHit> { | ||
| 373 | + if block_hashes.is_empty() { | ||
| 374 | + return FxHashMap::default(); | ||
| 375 | + } | ||
| 376 | + | ||
| 377 | + let mut continuations: FxHashMap<WorkerKey, Vec<LowerTierContinuation>> = | ||
| 378 | + FxHashMap::default(); | ||
| 379 | + | ||
| 380 | + // Root walk: unconditional, so a longer replica on this tier is never | ||
| 381 | + // hidden by an upstream (possibly shorter) hit. | ||
| 382 | + for w in tiers.root_workers(block_hashes[0]) { | ||
| 383 | + continuations | ||
| 384 | + .entry(w) | ||
| 385 | + .or_default() | ||
| 386 | + .push(LowerTierContinuation::from_root(0)); | ||
| 387 | + } | ||
| 388 | + | ||
| 389 | + // Continue from each upstream breakpoint (candidate list — the walk | ||
| 390 | + // keeps the farthest end per worker). | ||
| 391 | + for b in upstream_breaks { | ||
| 392 | + if b.end_pos >= block_hashes.len() { | ||
| 393 | + continue; | ||
| 394 | + } | ||
| 395 | + for w in tiers.edge_owners(Some(b.last_seq), block_hashes[b.end_pos]) { | ||
| 396 | + continuations | ||
| 397 | + .entry(w) | ||
| 398 | + .or_default() | ||
| 399 | + .push(LowerTierContinuation::new(b.end_pos, b.last_seq)); | ||
| 400 | + } | ||
| 401 | + } | ||
| 402 | + | ||
| 403 | + if continuations.is_empty() { | ||
| 404 | + return FxHashMap::default(); | ||
| 405 | + } | ||
| 406 | + | ||
| 407 | + let hits = tiers.query_contiguous_hits(block_hashes, &continuations); | ||
| 408 | + for (worker, hit) in &hits { | ||
| 409 | + if hit.count > 0 { | ||
| 410 | + overlap.add_blocks(worker.clone(), hit.count as u32); | ||
| 411 | + Self::note_coverage( | ||
| 412 | + coverage_end, | ||
| 413 | + &worker.instance_id, | ||
| 414 | + worker.dp_rank, | ||
| 415 | + hit.end_pos() as u32, | ||
| 416 | + ); | ||
| 417 | + } | ||
| 418 | + } | ||
| 419 | + hits | ||
| 284 | } | 420 | } |
| 285 | 421 | ||
| 286 | // ----------------------------------------------------------------------- | 422 | // ----------------------------------------------------------------------- |
| @@ -289,32 +425,54 @@ impl IndexerEntry { | |||
| 289 | 425 | ||
| 290 | /// Ingest offload blocks from vLLM non-HBM events. | 426 | /// Ingest offload blocks from vLLM non-HBM events. |
| 291 | /// | 427 | /// |
| 292 | - /// For each `(block_hash, tokens_hash)` pair, checks whether there are | 428 | + /// Each triple is `(block_hash, tokens_hash, parent_hash)` where |
| 293 | - /// pending pool backend events waiting for this block. Matched entries | 429 | + /// `parent_hash` is the immediately preceding engine `block_hash` in |
| 294 | - /// are removed from `pending_pool` and returned (grouped by worker) so | 430 | + /// this offload chain (or the chain's original `parent_block_hash` for |
| 295 | - /// the caller can insert them into the radix tree / flat store. | 431 | + /// the first block). Checks whether there are pending pool backend |
| 296 | - /// Unmatched entries are cached in `offload` for later pool confirmation. | 432 | + /// events waiting for each block. Matched entries are removed from |
| 433 | + /// `pending_pool` and returned (grouped by worker). Content is retained | ||
| 434 | + /// (always retained; TTL-swept via [`CONTENT_TTL`]). Unmatched | ||
| 435 | + /// entries are cached in `offload`. | ||
| 297 | pub fn ingest_offload_blocks( | 436 | pub fn ingest_offload_blocks( |
| 298 | &self, | 437 | &self, |
| 299 | - pairs: &[(u64, u64)], | 438 | + triples: &[(u64, u64, Option<u64>)], |
| 300 | - ) -> HashMap<WorkerKey, Vec<KvCacheStoredBlockData>> { | 439 | + ) -> HashMap<WorkerKey, Vec<(Option<u64>, KvCacheStoredBlockData)>> { |
| 301 | let matched = { | 440 | let matched = { |
| 302 | let mut state = self.offload_pool_state.write(); | 441 | let mut state = self.offload_pool_state.write(); |
| 303 | - let mut matched: HashMap<WorkerKey, Vec<KvCacheStoredBlockData>> = HashMap::new(); | 442 | + let mut matched: HashMap<WorkerKey, Vec<(Option<u64>, KvCacheStoredBlockData)>> = |
| 443 | + HashMap::new(); | ||
| 304 | 444 | ||
| 305 | - for &(block_hash, tokens_hash) in pairs { | 445 | + for &(block_hash, tokens_hash, parent_hash) in triples { |
| 446 | + let cache_entry = OffloadCacheEntry { | ||
| 447 | + tokens_hash, | ||
| 448 | + parent_hash, | ||
| 449 | + }; | ||
| 306 | if let Some(pending) = state.pending_pool.remove(&block_hash) { | 450 | if let Some(pending) = state.pending_pool.remove(&block_hash) { |
| 307 | - for entry in pending { | 451 | + state.content.insert( |
| 308 | - matched | 452 | + block_hash, |
| 309 | - .entry(entry.worker) | 453 | + ContentEntry { |
| 310 | - .or_default() | 454 | + entry: cache_entry, |
| 311 | - .push(KvCacheStoredBlockData { | 455 | + inserted_at: std::time::Instant::now(), |
| 456 | + }, | ||
| 457 | + ); | ||
| 458 | + state.offload.remove(&block_hash); | ||
| 459 | + for pending_entry in pending { | ||
| 460 | + matched.entry(pending_entry.worker).or_default().push(( | ||
| 461 | + parent_hash, | ||
| 462 | + KvCacheStoredBlockData { | ||
| 312 | block_hash, | 463 | block_hash, |
| 313 | tokens_hash, | 464 | tokens_hash, |
| 314 | - }); | 465 | + }, |
| 466 | + )); | ||
| 315 | } | 467 | } |
| 316 | } else { | 468 | } else { |
| 317 | - state.offload.insert(block_hash, tokens_hash); | 469 | + // Unconfirmed offload: no TTL (pool confirmation may be |
| 470 | + // arbitrarily late). If this hash was previously confirmed | ||
| 471 | + // and re-offloaded, drop the stale content mapping to keep | ||
| 472 | + // the offload/content invariant — the tier still carries | ||
| 473 | + // the mapping for later pool events. | ||
| 474 | + state.content.remove(&block_hash); | ||
| 475 | + state.offload.insert(block_hash, cache_entry); | ||
| 318 | } | 476 | } |
| 319 | } | 477 | } |
| 320 | matched | 478 | matched |
| @@ -323,73 +481,138 @@ impl IndexerEntry { | |||
| 323 | matched | 481 | matched |
| 324 | } | 482 | } |
| 325 | 483 | ||
| 326 | - /// Ingest pool backend blocks from Mooncake stored events. | 484 | + /// Resolve `(parent_hash, tokens_hash)` from offload / retained content. |
| 327 | /// | 485 | /// |
| 328 | - /// For each `block_hash`, checks the `offload` cache for a pre-computed | 486 | + /// When taking from `offload`, the mapping is moved into `content` (with a |
| 329 | - /// `tokens_hash`. Matched entries are removed from `offload` and returned | 487 | + /// fresh [`CONTENT_TTL`] window) so a later pool medium can reuse it. Tier |
| 330 | - /// as `KvCacheStoredBlockData` blocks for radix tree insertion. | 488 | + /// lookups happen outside this lock in [`Self::ingest_pool_blocks`]. |
| 331 | - /// Unmatched entries are queued in `pending_pool` so that a future | 489 | + fn resolve_pool_content( |
| 332 | - /// offload event can complete the match. | 490 | + &self, |
| 491 | + state: &mut OffloadPoolState, | ||
| 492 | + block_hash: u64, | ||
| 493 | + ) -> Option<(Option<u64>, u64)> { | ||
| 494 | + if let Some(cached) = state.offload.remove(&block_hash) { | ||
| 495 | + state.content.insert( | ||
| 496 | + block_hash, | ||
| 497 | + ContentEntry { | ||
| 498 | + entry: cached, | ||
| 499 | + inserted_at: std::time::Instant::now(), | ||
| 500 | + }, | ||
| 501 | + ); | ||
| 502 | + return Some((cached.parent_hash, cached.tokens_hash)); | ||
| 503 | + } | ||
| 504 | + if let Some(cached) = state.content.get(&block_hash) { | ||
| 505 | + return Some((cached.entry.parent_hash, cached.entry.tokens_hash)); | ||
| 506 | + } | ||
| 507 | + None | ||
| 508 | + } | ||
| 509 | + | ||
| 510 | + /// Ingest pool backend blocks from Mooncake / YuanRong stored events. | ||
| 511 | + /// | ||
| 512 | + /// For each `block_hash`, resolves `(tokens_hash, parent_hash)` from the | ||
| 513 | + /// offload cache, retained content map, or an already-indexed lower tier. | ||
| 514 | + /// Unmatched entries are queued in `pending_pool`. | ||
| 333 | pub fn ingest_pool_blocks( | 515 | pub fn ingest_pool_blocks( |
| 334 | &self, | 516 | &self, |
| 335 | block_hashes: &[u64], | 517 | block_hashes: &[u64], |
| 336 | worker: &WorkerKey, | 518 | worker: &WorkerKey, |
| 337 | - ) -> Vec<KvCacheStoredBlockData> { | 519 | + ) -> Vec<(Option<u64>, KvCacheStoredBlockData)> { |
| 338 | - let matched = { | 520 | + let mut matched = Vec::with_capacity(block_hashes.len()); |
| 339 | - let mut state = self.offload_pool_state.write(); | 521 | + let mut need_tier_lookup = Vec::new(); |
| 340 | - let mut matched = Vec::with_capacity(block_hashes.len()); | ||
| 341 | 522 | ||
| 523 | + { | ||
| 524 | + let mut state = self.offload_pool_state.write(); | ||
| 342 | for &bh in block_hashes { | 525 | for &bh in block_hashes { |
| 343 | - if let Some(tokens_hash) = state.offload.remove(&bh) { | 526 | + if let Some((parent_hash, tokens_hash)) = self.resolve_pool_content(&mut state, bh) |
| 344 | - matched.push(KvCacheStoredBlockData { | 527 | + { |
| 345 | - block_hash: bh, | 528 | + matched.push(( |
| 346 | - tokens_hash, | 529 | + parent_hash, |
| 347 | - }); | 530 | + KvCacheStoredBlockData { |
| 531 | + block_hash: bh, | ||
| 532 | + tokens_hash, | ||
| 533 | + }, | ||
| 534 | + )); | ||
| 348 | } else { | 535 | } else { |
| 349 | - state | 536 | + need_tier_lookup.push(bh); |
| 350 | - .pending_pool | ||
| 351 | - .entry(bh) | ||
| 352 | - .or_default() | ||
| 353 | - .insert(PendingPoolEvent { | ||
| 354 | - worker: worker.clone(), | ||
| 355 | - inserted_at: std::time::Instant::now(), | ||
| 356 | - }); | ||
| 357 | } | 537 | } |
| 358 | } | 538 | } |
| 359 | - matched | 539 | + } // release offload_pool_state before touching tier locks |
| 360 | - }; // lock released before sweep | 540 | + |
| 541 | + for &bh in &need_tier_lookup { | ||
| 542 | + if let Some((parent, tokens)) = self | ||
| 543 | + .cpu_tiers | ||
| 544 | + .lookup_block(bh) | ||
| 545 | + .or_else(|| self.disk_tiers.lookup_block(bh)) | ||
| 546 | + { | ||
| 547 | + let mut state = self.offload_pool_state.write(); | ||
| 548 | + state.content.insert( | ||
| 549 | + bh, | ||
| 550 | + ContentEntry { | ||
| 551 | + entry: OffloadCacheEntry { | ||
| 552 | + tokens_hash: tokens, | ||
| 553 | + parent_hash: parent, | ||
| 554 | + }, | ||
| 555 | + inserted_at: std::time::Instant::now(), | ||
| 556 | + }, | ||
| 557 | + ); | ||
| 558 | + matched.push(( | ||
| 559 | + parent, | ||
| 560 | + KvCacheStoredBlockData { | ||
| 561 | + block_hash: bh, | ||
| 562 | + tokens_hash: tokens, | ||
| 563 | + }, | ||
| 564 | + )); | ||
| 565 | + } else { | ||
| 566 | + let mut state = self.offload_pool_state.write(); | ||
| 567 | + state | ||
| 568 | + .pending_pool | ||
| 569 | + .entry(bh) | ||
| 570 | + .or_default() | ||
| 571 | + .insert(PendingPoolEvent { | ||
| 572 | + worker: worker.clone(), | ||
| 573 | + inserted_at: std::time::Instant::now(), | ||
| 574 | + }); | ||
| 575 | + } | ||
| 576 | + } | ||
| 577 | + | ||
| 361 | self.maybe_sweep_pending(); | 578 | self.maybe_sweep_pending(); |
| 362 | matched | 579 | matched |
| 363 | } | 580 | } |
| 364 | 581 | ||
| 365 | - /// Evict blocks from both caches (for removal events). | 582 | + /// Evict blocks from pending caches (for removal events). |
| 366 | /// | 583 | /// |
| 367 | - /// Returns `block_hashes` that were in **neither** cache — these were | 584 | + /// Returns `block_hashes` that need lower-tier / tree removal — already |
| 368 | - /// already matched and inserted into the tree, so the caller must apply | 585 | + /// confirmed into a tier, or with no matching pending state for this |
| 369 | - /// tree removal for them. | 586 | + /// worker (tier removal is a no-op if the hash never entered a tier). |
| 370 | - /// | 587 | + /// Unconfirmed `offload` / `pending_pool`-only entries are dropped |
| 371 | - /// Blocks found in either cache were still pending and are simply evicted | 588 | + /// without tier removal. Retained `content` is left for the migration |
| 372 | - /// (they never entered the tree). This also cleans up `pending_pool` | 589 | + /// window (bounded by [`CONTENT_TTL`]) and may resolve a later Disk store. |
| 373 | - /// entries for the specific `worker`. | ||
| 374 | pub fn evict_pending_blocks(&self, block_hashes: &[u64], worker: &WorkerKey) -> Vec<u64> { | 590 | pub fn evict_pending_blocks(&self, block_hashes: &[u64], worker: &WorkerKey) -> Vec<u64> { |
| 375 | let mut state = self.offload_pool_state.write(); | 591 | let mut state = self.offload_pool_state.write(); |
| 376 | let mut need_tree_removal = Vec::new(); | 592 | let mut need_tree_removal = Vec::new(); |
| 377 | 593 | ||
| 378 | for &bh in block_hashes { | 594 | for &bh in block_hashes { |
| 379 | - // Try offload cache first | 595 | + // Unconfirmed offload — never entered a tier. |
| 380 | if state.offload.remove(&bh).is_some() { | 596 | if state.offload.remove(&bh).is_some() { |
| 381 | - // Was waiting for pool event — now cancelled | 597 | + state.content.remove(&bh); |
| 382 | continue; | 598 | continue; |
| 383 | } | 599 | } |
| 384 | - // Try pending pool events | 600 | + // Pool-first pending for this worker — may or may not be in a tier. |
| 385 | if let Some(entries) = state.pending_pool.get_mut(&bh) { | 601 | if let Some(entries) = state.pending_pool.get_mut(&bh) { |
| 602 | + let before = entries.len(); | ||
| 386 | entries.retain(|e| e.worker != *worker); | 603 | entries.retain(|e| e.worker != *worker); |
| 387 | - if entries.is_empty() { | 604 | + if entries.len() != before { |
| 388 | - state.pending_pool.remove(&bh); | 605 | + if entries.is_empty() { |
| 606 | + state.pending_pool.remove(&bh); | ||
| 607 | + } | ||
| 608 | + // Content means another medium already confirmed this hash. | ||
| 609 | + if state.content.contains_key(&bh) { | ||
| 610 | + need_tree_removal.push(bh); | ||
| 611 | + } | ||
| 612 | + continue; | ||
| 389 | } | 613 | } |
| 390 | - continue; | ||
| 391 | } | 614 | } |
| 392 | - // Not in either cache — must already be in the tree | 615 | + // Already confirmed (or unknown) — remove from the tier. |
| 393 | need_tree_removal.push(bh); | 616 | need_tree_removal.push(bh); |
| 394 | } | 617 | } |
| 395 | need_tree_removal | 618 | need_tree_removal |
| @@ -398,11 +621,14 @@ impl IndexerEntry { | |||
| 398 | /// Remove all pending entries for a worker from `pending_pool`. | 621 | /// Remove all pending entries for a worker from `pending_pool`. |
| 399 | /// | 622 | /// |
| 400 | /// Called on worker disconnect / Cleared events. Returns the number of | 623 | /// Called on worker disconnect / Cleared events. Returns the number of |
| 401 | - /// block hashes that were affected. | 624 | + /// block hashes whose `pending_pool` entries were fully cleared. |
| 402 | /// | 625 | /// |
| 403 | - /// Note: the `offload` map has no per-worker association, so offload | 626 | + /// Note: `offload` / `content` have no per-worker association. Content is |
| 404 | - /// entries are not purged here — they are bounded by the number of | 627 | + /// TTL-evicted via [`Self::sweep_stale_pending`] ([`CONTENT_TTL`]); it is |
| 405 | - /// outstanding offloaded blocks and evicted on match or TTL sweep. | 628 | + /// kept across tier clears so a later Disk store can still promote the |
| 629 | + /// block. Unconfirmed `offload` entries are not TTL'd — they are bounded | ||
| 630 | + /// by the number of outstanding offloaded blocks and cleared on match, | ||
| 631 | + /// removal, or the pool confirmation eventually arriving. | ||
| 406 | pub fn remove_pending_worker(&self, worker: &WorkerKey) -> usize { | 632 | pub fn remove_pending_worker(&self, worker: &WorkerKey) -> usize { |
| 407 | let mut state = self.offload_pool_state.write(); | 633 | let mut state = self.offload_pool_state.write(); |
| 408 | let mut removed = 0usize; | 634 | let mut removed = 0usize; |
| @@ -419,27 +645,38 @@ impl IndexerEntry { | |||
| 419 | removed | 645 | removed |
| 420 | } | 646 | } |
| 421 | 647 | ||
| 422 | - /// Total number of pending entries across both caches (for diagnostics). | 648 | + /// Total pending + retained content entries (for diagnostics). |
| 423 | pub fn pending_count(&self) -> usize { | 649 | pub fn pending_count(&self) -> usize { |
| 424 | let state = self.offload_pool_state.read(); | 650 | let state = self.offload_pool_state.read(); |
| 425 | - state.offload.len() + state.pending_pool.len() | 651 | + state.offload.len() + state.pending_pool.len() + state.content.len() |
| 426 | } | 652 | } |
| 427 | 653 | ||
| 428 | - /// Sweep stale `pending_pool` entries that exceed `ttl`, returning the | 654 | + /// Sweep stale `pending_pool` and retained `content` entries that exceed |
| 429 | - /// number evicted. | 655 | + /// their TTLs, returning the total number of entries evicted. |
| 430 | /// | 656 | /// |
| 431 | - /// Only `pending_pool` entries are swept (they carry per-entry timestamps). | 657 | + /// Unconfirmed `offload` entries are **not** swept: pool confirmation may |
| 432 | - /// `offload` entries have no timestamp and are kept until matched by a | 658 | + /// arrive arbitrarily late, so they are bounded only by the number of |
| 433 | - /// pool event or explicitly evicted by a removal event — they are bounded | 659 | + /// outstanding offloaded blocks (original two-phase design). `content` |
| 434 | - /// by the number of outstanding offloaded blocks. | 660 | + /// must survive lower-tier removal for the CPU→Disk migration window, |
| 435 | - pub fn sweep_stale_pending(&self, ttl: std::time::Duration) -> usize { | 661 | + /// so it is bounded by [`CONTENT_TTL`] instead. |
| 662 | + pub fn sweep_stale_pending( | ||
| 663 | + &self, | ||
| 664 | + pending_ttl: std::time::Duration, | ||
| 665 | + content_ttl: std::time::Duration, | ||
| 666 | + ) -> usize { | ||
| 436 | let mut state = self.offload_pool_state.write(); | 667 | let mut state = self.offload_pool_state.write(); |
| 437 | let mut pruned = 0usize; | 668 | let mut pruned = 0usize; |
| 438 | let now = std::time::Instant::now(); | 669 | let now = std::time::Instant::now(); |
| 439 | 670 | ||
| 671 | + let before_content = state.content.len(); | ||
| 672 | + state | ||
| 673 | + .content | ||
| 674 | + .retain(|_bh, e| now.duration_since(e.inserted_at) < content_ttl); | ||
| 675 | + pruned += before_content - state.content.len(); | ||
| 676 | + | ||
| 440 | state.pending_pool.retain(|_bh, entries| { | 677 | state.pending_pool.retain(|_bh, entries| { |
| 441 | entries.retain(|e| { | 678 | entries.retain(|e| { |
| 442 | - let keep = now.duration_since(e.inserted_at) < ttl; | 679 | + let keep = now.duration_since(e.inserted_at) < pending_ttl; |
| 443 | if !keep { | 680 | if !keep { |
| 444 | pruned += 1; | 681 | pruned += 1; |
| 445 | } | 682 | } |
| @@ -452,8 +689,9 @@ impl IndexerEntry { | |||
| 452 | tracing::debug!( | 689 | tracing::debug!( |
| 453 | pruned, | 690 | pruned, |
| 454 | remaining_offload = state.offload.len(), | 691 | remaining_offload = state.offload.len(), |
| 692 | + remaining_content = state.content.len(), | ||
| 455 | remaining_pending_keys = state.pending_pool.len(), | 693 | remaining_pending_keys = state.pending_pool.len(), |
| 456 | - "swept stale pending pool entries" | 694 | + "swept stale content/pending pool entries" |
| 457 | ); | 695 | ); |
| 458 | } | 696 | } |
| 459 | 697 | ||
| @@ -461,109 +699,19 @@ impl IndexerEntry { | |||
| 461 | } | 699 | } |
| 462 | 700 | ||
| 463 | // ----------------------------------------------------------------------- | 701 | // ----------------------------------------------------------------------- |
| 464 | - // Flat (CPU/Disk) store / remove helpers | 702 | + // Event application |
| 465 | // ----------------------------------------------------------------------- | 703 | // ----------------------------------------------------------------------- |
| 466 | 704 | ||
| 467 | - /// Insert a block into the CPU or Disk flat store. | 705 | + /// Periodically sweep stale pending pool / content entries on every |
| 468 | - fn flat_store( | 706 | + /// `PENDING_SWEEP_THRESHOLD`-th ingest. Called after each `ingest_*` |
| 469 | - blocks: &RwLock<FxHashMap<LocalBlockHash, FxHashSet<WorkerKey>>>, | 707 | + /// operation. |
| 470 | - lookups: &RwLock<FxHashMap<WorkerKey, FlatLookup>>, | ||
| 471 | - worker: &WorkerKey, | ||
| 472 | - tokens_hash: u64, | ||
| 473 | - seq_hash: u64, | ||
| 474 | - ) { | ||
| 475 | - // Update flat block set: tokens_hash → {workers} | ||
| 476 | - { | ||
| 477 | - let mut map = blocks.write(); | ||
| 478 | - map.entry(LocalBlockHash(tokens_hash)) | ||
| 479 | - .or_default() | ||
| 480 | - .insert(worker.clone()); | ||
| 481 | - } | ||
| 482 | - // Update per-worker reverse lookup for removal | ||
| 483 | - { | ||
| 484 | - let mut lu = lookups.write(); | ||
| 485 | - lu.entry(worker.clone()) | ||
| 486 | - .or_default() | ||
| 487 | - .insert(seq_hash, tokens_hash); | ||
| 488 | - } | ||
| 489 | - tracing::trace!( | ||
| 490 | - instance_id = %worker.instance_id, | ||
| 491 | - dp_rank = worker.dp_rank, | ||
| 492 | - medium = %worker.medium.as_str(), | ||
| 493 | - ?seq_hash, | ||
| 494 | - tokens_hash, | ||
| 495 | - "flat store" | ||
| 496 | - ); | ||
| 497 | - } | ||
| 498 | - | ||
| 499 | - /// Remove a block from the CPU or Disk flat store. | ||
| 500 | - fn flat_remove( | ||
| 501 | - blocks: &RwLock<FxHashMap<LocalBlockHash, FxHashSet<WorkerKey>>>, | ||
| 502 | - lookups: &RwLock<FxHashMap<WorkerKey, FlatLookup>>, | ||
| 503 | - worker: &WorkerKey, | ||
| 504 | - seq_hash: u64, | ||
| 505 | - ) { | ||
| 506 | - // Find tokens_hash from per-worker reverse lookup | ||
| 507 | - let tokens_hash = { | ||
| 508 | - let lu = lookups.read(); | ||
| 509 | - lu.get(worker).and_then(|m| m.get(&seq_hash).copied()) | ||
| 510 | - }; | ||
| 511 | - | ||
| 512 | - if let Some(th) = tokens_hash { | ||
| 513 | - // Remove worker from the flat set | ||
| 514 | - let mut map = blocks.write(); | ||
| 515 | - if let Some(set) = map.get_mut(&LocalBlockHash(th)) { | ||
| 516 | - set.remove(worker); | ||
| 517 | - if set.is_empty() { | ||
| 518 | - map.remove(&LocalBlockHash(th)); | ||
| 519 | - } | ||
| 520 | - } | ||
| 521 | - // Clean up reverse lookup | ||
| 522 | - let mut lu = lookups.write(); | ||
| 523 | - if let Some(m) = lu.get_mut(worker) { | ||
| 524 | - m.remove(&seq_hash); | ||
| 525 | - } | ||
| 526 | - } | ||
| 527 | - } | ||
| 528 | - | ||
| 529 | - /// Clear all CPU/Disk flat blocks for a worker. | ||
| 530 | - fn flat_clear( | ||
| 531 | - blocks: &RwLock<FxHashMap<LocalBlockHash, FxHashSet<WorkerKey>>>, | ||
| 532 | - lookups: &RwLock<FxHashMap<WorkerKey, FlatLookup>>, | ||
| 533 | - worker: &WorkerKey, | ||
| 534 | - ) { | ||
| 535 | - // Collect tokens_hashes from reverse lookup | ||
| 536 | - let tokens_hashes: Vec<LocalBlockHash> = { | ||
| 537 | - let lu = lookups.read(); | ||
| 538 | - lu.get(worker) | ||
| 539 | - .map(|m| m.values().map(|&th| LocalBlockHash(th)).collect()) | ||
| 540 | - .unwrap_or_default() | ||
| 541 | - }; | ||
| 542 | - // Remove worker from each block set | ||
| 543 | - if !tokens_hashes.is_empty() { | ||
| 544 | - let mut map = blocks.write(); | ||
| 545 | - for th in &tokens_hashes { | ||
| 546 | - if let Some(set) = map.get_mut(th) { | ||
| 547 | - set.remove(worker); | ||
| 548 | - if set.is_empty() { | ||
| 549 | - map.remove(th); | ||
| 550 | - } | ||
| 551 | - } | ||
| 552 | - } | ||
| 553 | - } | ||
| 554 | - // Clear reverse lookup | ||
| 555 | - lookups.write().remove(worker); | ||
| 556 | - } | ||
| 557 | - | ||
| 558 | - /// Periodically sweep stale pending pool entries when the ingest counter | ||
| 559 | - /// exceeds the threshold. Called after each `ingest_*` operation. | ||
| 560 | fn maybe_sweep_pending(&self) { | 708 | fn maybe_sweep_pending(&self) { |
| 561 | let count = self | 709 | let count = self |
| 562 | .pending_ingest_count | 710 | .pending_ingest_count |
| 563 | .fetch_add(1, std::sync::atomic::Ordering::Relaxed) | 711 | .fetch_add(1, std::sync::atomic::Ordering::Relaxed) |
| 564 | + 1; | 712 | + 1; |
| 565 | if count.is_multiple_of(PENDING_SWEEP_THRESHOLD) { | 713 | if count.is_multiple_of(PENDING_SWEEP_THRESHOLD) { |
| 566 | - let pruned = self.sweep_stale_pending(PENDING_TTL); | 714 | + let pruned = self.sweep_stale_pending(PENDING_TTL, CONTENT_TTL); |
| 567 | if pruned > 0 { | 715 | if pruned > 0 { |
| 568 | tracing::debug!( | 716 | tracing::debug!( |
| 569 | pruned, | 717 | pruned, |
| @@ -596,74 +744,58 @@ impl IndexerEntry { | |||
| 596 | match event { | 744 | match event { |
| 597 | KvCacheEventData::Stored(store_data) => { | 745 | KvCacheEventData::Stored(store_data) => { |
| 598 | match worker.medium { | 746 | match worker.medium { |
| 599 | - StorageMedium::Xpu | StorageMedium::Unknown => { | 747 | + StorageMedium::Npu | StorageMedium::Unknown => { |
| 600 | // HBM: prefix-chain tree insert | 748 | // HBM: prefix-chain tree insert |
| 601 | let mut lookups = self.lookups.write(); | 749 | let mut lookups = self.lookups.write(); |
| 602 | let lookup = lookups.entry(worker.clone()).or_default(); | 750 | let lookup = lookups.entry(worker.clone()).or_default(); |
| 603 | self.hbm_tree.apply_store(worker, lookup, store_data) | 751 | self.hbm_tree.apply_store(worker, lookup, store_data) |
| 604 | } | 752 | } |
| 605 | StorageMedium::Cpu => { | 753 | StorageMedium::Cpu => { |
| 606 | - // CPU: flat insert | 754 | + self.cpu_tiers.store_blocks(worker, store_data); |
| 607 | - for block in &store_data.blocks { | ||
| 608 | - Self::flat_store( | ||
| 609 | - &self.cpu_blocks, | ||
| 610 | - &self.cpu_lookups, | ||
| 611 | - worker, | ||
| 612 | - block.tokens_hash, | ||
| 613 | - block.block_hash, | ||
| 614 | - ); | ||
| 615 | - } | ||
| 616 | Ok(()) | 755 | Ok(()) |
| 617 | } | 756 | } |
| 618 | StorageMedium::Disk => { | 757 | StorageMedium::Disk => { |
| 619 | - // Disk: flat insert | 758 | + self.disk_tiers.store_blocks(worker, store_data); |
| 620 | - for block in &store_data.blocks { | ||
| 621 | - Self::flat_store( | ||
| 622 | - &self.disk_blocks, | ||
| 623 | - &self.disk_lookups, | ||
| 624 | - worker, | ||
| 625 | - block.tokens_hash, | ||
| 626 | - block.block_hash, | ||
| 627 | - ); | ||
| 628 | - } | ||
| 629 | Ok(()) | 759 | Ok(()) |
| 630 | } | 760 | } |
| 631 | } | 761 | } |
| 632 | } | 762 | } |
| 633 | KvCacheEventData::Removed { block_hashes } => match worker.medium { | 763 | KvCacheEventData::Removed { block_hashes } => match worker.medium { |
| 634 | - StorageMedium::Xpu | StorageMedium::Unknown => { | 764 | + StorageMedium::Npu | StorageMedium::Unknown => { |
| 635 | let mut lookups = self.lookups.write(); | 765 | let mut lookups = self.lookups.write(); |
| 636 | let lookup = lookups.entry(worker.clone()).or_default(); | 766 | let lookup = lookups.entry(worker.clone()).or_default(); |
| 637 | let result = self.hbm_tree.apply_remove(worker, lookup, block_hashes); | 767 | let result = self.hbm_tree.apply_remove(worker, lookup, block_hashes); |
| 638 | self.maybe_sweep_hbm(); | 768 | self.maybe_sweep_hbm(); |
| 639 | result | 769 | result |
| 640 | } | 770 | } |
| 771 | + // Retained content is deliberately NOT pruned here: a CPU | ||
| 772 | + // eviction may be the pool migrating the block to Disk, and | ||
| 773 | + // the Disk store event must still resolve via `content` | ||
| 774 | + // (bounded by `CONTENT_TTL` sweep instead). | ||
| 641 | StorageMedium::Cpu => { | 775 | StorageMedium::Cpu => { |
| 642 | - for &h in block_hashes { | 776 | + self.cpu_tiers.remove_blocks(worker, block_hashes); |
| 643 | - Self::flat_remove(&self.cpu_blocks, &self.cpu_lookups, worker, h); | ||
| 644 | - } | ||
| 645 | Ok(()) | 777 | Ok(()) |
| 646 | } | 778 | } |
| 647 | StorageMedium::Disk => { | 779 | StorageMedium::Disk => { |
| 648 | - for &h in block_hashes { | 780 | + self.disk_tiers.remove_blocks(worker, block_hashes); |
| 649 | - Self::flat_remove(&self.disk_blocks, &self.disk_lookups, worker, h); | ||
| 650 | - } | ||
| 651 | Ok(()) | 781 | Ok(()) |
| 652 | } | 782 | } |
| 653 | }, | 783 | }, |
| 654 | KvCacheEventData::Cleared => { | 784 | KvCacheEventData::Cleared => { |
| 655 | match worker.medium { | 785 | match worker.medium { |
| 656 | - StorageMedium::Xpu | StorageMedium::Unknown => { | 786 | + StorageMedium::Npu | StorageMedium::Unknown => { |
| 657 | let mut lookups = self.lookups.write(); | 787 | let mut lookups = self.lookups.write(); |
| 658 | let lookup = lookups.entry(worker.clone()).or_default(); | 788 | let lookup = lookups.entry(worker.clone()).or_default(); |
| 659 | self.hbm_tree.remove_worker(worker, lookup); | 789 | self.hbm_tree.remove_worker(worker, lookup); |
| 660 | self.maybe_sweep_hbm(); | 790 | self.maybe_sweep_hbm(); |
| 661 | } | 791 | } |
| 792 | + // Same as Removed: retained content survives the clear so | ||
| 793 | + // a later Disk store can still promote these blocks. | ||
| 662 | StorageMedium::Cpu => { | 794 | StorageMedium::Cpu => { |
| 663 | - Self::flat_clear(&self.cpu_blocks, &self.cpu_lookups, worker); | 795 | + self.cpu_tiers.clear_worker(worker); |
| 664 | } | 796 | } |
| 665 | StorageMedium::Disk => { | 797 | StorageMedium::Disk => { |
| 666 | - Self::flat_clear(&self.disk_blocks, &self.disk_lookups, worker); | 798 | + self.disk_tiers.clear_worker(worker); |
| 667 | } | 799 | } |
| 668 | } | 800 | } |
| 669 | Ok(()) | 801 | Ok(()) |
| @@ -689,39 +821,21 @@ impl IndexerEntry { | |||
| 689 | lookups.remove(wk); | 821 | lookups.remove(wk); |
| 690 | } | 822 | } |
| 691 | } | 823 | } |
| 692 | - // CPU flat — collect keys first to avoid holding read lock | 824 | + // CPU / Disk continuation-edge indexes |
| 693 | - // across the write in flat_clear. | 825 | + for wk in self.cpu_tiers.worker_keys() { |
| 694 | - { | 826 | + if wk.instance_id == instance_id && wk.dp_rank == dp_rank { |
| 695 | - let cpu_matches: Vec<WorkerKey> = { | 827 | + self.cpu_tiers.clear_worker(&wk); |
| 696 | - let cpu_lu = self.cpu_lookups.read(); | ||
| 697 | - cpu_lu | ||
| 698 | - .keys() | ||
| 699 | - .filter(|wk| wk.instance_id == instance_id && wk.dp_rank == dp_rank) | ||
| 700 | - .cloned() | ||
| 701 | - .collect() | ||
| 702 | - }; | ||
| 703 | - for wk in &cpu_matches { | ||
| 704 | - Self::flat_clear(&self.cpu_blocks, &self.cpu_lookups, wk); | ||
| 705 | } | 828 | } |
| 706 | } | 829 | } |
| 707 | - // Disk flat — same pattern | 830 | + for wk in self.disk_tiers.worker_keys() { |
| 708 | - { | 831 | + if wk.instance_id == instance_id && wk.dp_rank == dp_rank { |
| 709 | - let disk_matches: Vec<WorkerKey> = { | 832 | + self.disk_tiers.clear_worker(&wk); |
| 710 | - let disk_lu = self.disk_lookups.read(); | ||
| 711 | - disk_lu | ||
| 712 | - .keys() | ||
| 713 | - .filter(|wk| wk.instance_id == instance_id && wk.dp_rank == dp_rank) | ||
| 714 | - .cloned() | ||
| 715 | - .collect() | ||
| 716 | - }; | ||
| 717 | - for wk in &disk_matches { | ||
| 718 | - Self::flat_clear(&self.disk_blocks, &self.disk_lookups, wk); | ||
| 719 | } | 833 | } |
| 720 | } | 834 | } |
| 721 | // Offload/pool pending state — clean up pool entries waiting for | 835 | // Offload/pool pending state — clean up pool entries waiting for |
| 722 | - // this worker. Offload entries have no per-worker association so | 836 | + // this worker. Unconfirmed offload / retained content entries are |
| 723 | - // we cannot selectively purge them here; they are bounded by the | 837 | + // not per-worker: offload is bounded by outstanding offloaded blocks, |
| 724 | - // number of outstanding offloaded blocks. | 838 | + // and content is TTL-evicted (`CONTENT_TTL`), so both self-clean. |
| 725 | { | 839 | { |
| 726 | let mut state = self.offload_pool_state.write(); | 840 | let mut state = self.offload_pool_state.write(); |
| 727 | state.pending_pool.retain(|_, entries| { | 841 | state.pending_pool.retain(|_, entries| { |
| @@ -735,26 +849,14 @@ impl IndexerEntry { | |||
| 735 | /// Get the total number of cached blocks across all workers and media. | 849 | /// Get the total number of cached blocks across all workers and media. |
| 736 | pub fn total_blocks(&self) -> usize { | 850 | pub fn total_blocks(&self) -> usize { |
| 737 | let hbm = self.lookups.read().values().map(|l| l.len()).sum::<usize>(); | 851 | let hbm = self.lookups.read().values().map(|l| l.len()).sum::<usize>(); |
| 738 | - let cpu = self | 852 | + hbm + self.cpu_tiers.total_blocks() + self.disk_tiers.total_blocks() |
| 739 | - .cpu_lookups | ||
| 740 | - .read() | ||
| 741 | - .values() | ||
| 742 | - .map(|l| l.len()) | ||
| 743 | - .sum::<usize>(); | ||
| 744 | - let disk = self | ||
| 745 | - .disk_lookups | ||
| 746 | - .read() | ||
| 747 | - .values() | ||
| 748 | - .map(|l| l.len()) | ||
| 749 | - .sum::<usize>(); | ||
| 750 | - hbm + cpu + disk | ||
| 751 | } | 853 | } |
| 752 | 854 | ||
| 753 | /// Get all registered worker keys. | 855 | /// Get all registered worker keys. |
| 754 | pub fn worker_keys(&self) -> Vec<WorkerKey> { | 856 | pub fn worker_keys(&self) -> Vec<WorkerKey> { |
| 755 | let mut keys: Vec<WorkerKey> = self.lookups.read().keys().cloned().collect(); | 857 | let mut keys: Vec<WorkerKey> = self.lookups.read().keys().cloned().collect(); |
| 756 | - keys.extend(self.cpu_lookups.read().keys().cloned()); | 858 | + keys.extend(self.cpu_tiers.worker_keys()); |
| 757 | - keys.extend(self.disk_lookups.read().keys().cloned()); | 859 | + keys.extend(self.disk_tiers.worker_keys()); |
| 758 | keys | 860 | keys |
| 759 | } | 861 | } |
| 760 | } | 862 | } |
| @@ -762,14 +864,13 @@ impl IndexerEntry { | |||
| 762 | /// Top-level indexer managing multiple (model, tenant) trees. | 864 | /// Top-level indexer managing multiple (model, tenant) trees. |
| 763 | pub struct Indexer { | 865 | pub struct Indexer { |
| 764 | entries: DashMap<IndexerKey, Arc<IndexerEntry>>, | 866 | entries: DashMap<IndexerKey, Arc<IndexerEntry>>, |
| 765 | - scoring: ScoringConfig, | ||
| 766 | } | 867 | } |
| 767 | 868 | ||
| 768 | impl Indexer { | 869 | impl Indexer { |
| 769 | - pub fn new(scoring: ScoringConfig) -> Self { | 870 | + /// Create an indexer. |
| 871 | + pub fn new() -> Self { | ||
| 770 | Self { | 872 | Self { |
| 771 | entries: DashMap::new(), | 873 | entries: DashMap::new(), |
| 772 | - scoring, | ||
| 773 | } | 874 | } |
| 774 | } | 875 | } |
| 775 | 876 | ||
| @@ -795,7 +896,8 @@ impl Indexer { | |||
| 795 | self.entries.get(&key).map(|e| e.value().clone()) | 896 | self.entries.get(&key).map(|e| e.value().clone()) |
| 796 | } | 897 | } |
| 797 | 898 | ||
| 798 | - /// Remove an indexer entry if it has no more workers across any medium. | 899 | + /// Remove an indexer entry if it has no more workers across any medium |
| 900 | + /// and no pending offload / content / pool state remains. | ||
| 799 | pub fn remove_if_empty(&self, model_name: &str, tenant_id: &str) { | 901 | pub fn remove_if_empty(&self, model_name: &str, tenant_id: &str) { |
| 800 | let key = IndexerKey { | 902 | let key = IndexerKey { |
| 801 | model_name: model_name.to_string(), | 903 | model_name: model_name.to_string(), |
| @@ -804,8 +906,8 @@ impl Indexer { | |||
| 804 | let should_remove = self.entries.get(&key).is_some_and(|e| { | 906 | let should_remove = self.entries.get(&key).is_some_and(|e| { |
| 805 | let entry = e.value(); | 907 | let entry = e.value(); |
| 806 | entry.lookups.read().is_empty() | 908 | entry.lookups.read().is_empty() |
| 807 | - && entry.cpu_lookups.read().is_empty() | 909 | + && entry.cpu_tiers.is_empty() |
| 808 | - && entry.disk_lookups.read().is_empty() | 910 | + && entry.disk_tiers.is_empty() |
| 809 | && entry.pending_count() == 0 | 911 | && entry.pending_count() == 0 |
| 810 | }); | 912 | }); |
| 811 | if should_remove { | 913 | if should_remove { |
| @@ -813,7 +915,7 @@ impl Indexer { | |||
| 813 | } | 915 | } |
| 814 | } | 916 | } |
| 815 | 917 | ||
| 816 | - /// Query overlap scores for a token sequence against a specific model/tenant. | 918 | + /// Query matched block counts for a token sequence against a specific model/tenant. |
| 817 | /// | 919 | /// |
| 818 | /// `block_size` determines the token-to-hash granularity — it must match | 920 | /// `block_size` determines the token-to-hash granularity — it must match |
| 819 | /// the size used by the engine when publishing events. | 921 | /// the size used by the engine when publishing events. |
| @@ -833,10 +935,21 @@ impl Indexer { | |||
| 833 | tenant_id: tenant_id.to_string(), | 935 | tenant_id: tenant_id.to_string(), |
| 834 | })?; | 936 | })?; |
| 835 | 937 | ||
| 836 | - let overlap = entry.find_matches(token_ids, block_size, &self.scoring); | 938 | + let t_hash = std::time::Instant::now(); |
| 939 | + let block_hashes = compute_block_hash_for_seq(token_ids, block_size); | ||
| 940 | + let hash_us = t_hash.elapsed().as_micros(); | ||
| 941 | + let (overlap, coverage_end) = entry.find_matches_with_coverage(&block_hashes); | ||
| 942 | + tracing::debug!( | ||
| 943 | + num_tokens = token_ids.len(), | ||
| 944 | + block_size, | ||
| 945 | + num_hashes = block_hashes.len(), | ||
| 946 | + hash_us, | ||
| 947 | + matched = overlap.blocks.len(), | ||
| 948 | + "hash_computed" | ||
| 949 | + ); | ||
| 837 | let t_tree = t0.elapsed(); | 950 | let t_tree = t0.elapsed(); |
| 838 | 951 | ||
| 839 | - let resp = self.build_response(overlap, model_name, tenant_id, block_size); | 952 | + let resp = Self::build_response(overlap, &coverage_end, model_name, tenant_id, block_size); |
| 840 | let total = t0.elapsed(); | 953 | let total = t0.elapsed(); |
| 841 | 954 | ||
| 842 | tracing::debug!( | 955 | tracing::debug!( |
| @@ -849,7 +962,7 @@ impl Indexer { | |||
| 849 | resp | 962 | resp |
| 850 | } | 963 | } |
| 851 | 964 | ||
| 852 | - /// Query overlap scores using pre-computed `LocalBlockHash` values. | 965 | + /// Query matched block counts using pre-computed `LocalBlockHash` values. |
| 853 | pub fn query_by_hash( | 966 | pub fn query_by_hash( |
| 854 | &self, | 967 | &self, |
| 855 | model_name: &str, | 968 | model_name: &str, |
| @@ -863,16 +976,23 @@ impl Indexer { | |||
| 863 | tenant_id: tenant_id.to_string(), | 976 | tenant_id: tenant_id.to_string(), |
| 864 | })?; | 977 | })?; |
| 865 | 978 | ||
| 866 | - let overlap = entry.find_matches_by_hash(block_hashes, &self.scoring); | 979 | + let (overlap, coverage_end) = entry.find_matches_with_coverage(block_hashes); |
| 867 | // Default to 1 token per hash (no scaling) since we don't know the | 980 | // Default to 1 token per hash (no scaling) since we don't know the |
| 868 | // original block_size from the hash alone. | 981 | // original block_size from the hash alone. |
| 869 | - self.build_response(overlap, model_name, tenant_id, 1) | 982 | + Self::build_response(overlap, &coverage_end, model_name, tenant_id, 1) |
| 870 | } | 983 | } |
| 871 | 984 | ||
| 872 | - /// Build a `QueryResponse` from weighted overlap scores. | 985 | + /// Build a `QueryResponse` from per-worker matched block counts. |
| 986 | + /// | ||
| 987 | + /// Per-medium fields are segment lengths. `matched_tokens` uses the | ||
| 988 | + /// farthest absolute coverage end across media for that DP (never the | ||
| 989 | + /// sum of segment lengths — every hit records a coverage end, so the | ||
| 990 | + /// defensive sum fallback below is unreachable), so NPU+CPU+Disk | ||
| 991 | + /// replicas of the same prefix do not inflate the cached prefix beyond | ||
| 992 | + /// the input. | ||
| 873 | fn build_response( | 993 | fn build_response( |
| 874 | - &self, | 994 | + overlap: OverlapBlocks, |
| 875 | - overlap: OverlapScores, | 995 | + coverage_end: &FxHashMap<(String, DpRank), u32>, |
| 876 | model_name: &str, | 996 | model_name: &str, |
| 877 | tenant_id: &str, | 997 | tenant_id: &str, |
| 878 | block_size: u32, | 998 | block_size: u32, |
| @@ -886,43 +1006,40 @@ impl Indexer { | |||
| 886 | 1006 | ||
| 887 | let mut instance_data: HashMap<String, InstanceMatchData> = HashMap::new(); | 1007 | let mut instance_data: HashMap<String, InstanceMatchData> = HashMap::new(); |
| 888 | 1008 | ||
| 889 | - for (worker, &score) in &overlap.scores { | 1009 | + for (worker, &matched_blocks) in &overlap.blocks { |
| 890 | let dp_rank_str = worker.dp_rank.to_string(); | 1010 | let dp_rank_str = worker.dp_rank.to_string(); |
| 891 | - | ||
| 892 | - // Derive per-medium block count from the score and weight. | ||
| 893 | - let matched_blocks = match worker.medium { | ||
| 894 | - StorageMedium::Xpu | StorageMedium::Unknown => score / self.scoring.hbm_weight, | ||
| 895 | - StorageMedium::Cpu => score / self.scoring.cpu_weight, | ||
| 896 | - StorageMedium::Disk => score / self.scoring.disk_weight, | ||
| 897 | - }; | ||
| 898 | - let matched_tokens = matched_blocks * block_size; | ||
| 899 | - | ||
| 900 | let imd = instance_data.entry(worker.instance_id.clone()).or_default(); | 1011 | let imd = instance_data.entry(worker.instance_id.clone()).or_default(); |
| 901 | - | 1012 | + let dp_match = imd.dp.entry(dp_rank_str).or_default(); |
| 902 | - imd.longest_matched = imd.longest_matched.max(matched_tokens); | ||
| 903 | - | ||
| 904 | - let dp_score = imd.dp.entry(dp_rank_str).or_default(); | ||
| 905 | match worker.medium { | 1013 | match worker.medium { |
| 906 | - StorageMedium::Xpu | StorageMedium::Unknown => { | 1014 | + StorageMedium::Npu | StorageMedium::Unknown => { |
| 907 | - dp_score.xpu_score = dp_score.xpu_score.max(score); | 1015 | + dp_match.npu_blocks = dp_match.npu_blocks.max(matched_blocks); |
| 908 | - dp_score.xpu_blocks = dp_score.xpu_blocks.max(matched_blocks); | ||
| 909 | } | 1016 | } |
| 910 | StorageMedium::Cpu => { | 1017 | StorageMedium::Cpu => { |
| 911 | - dp_score.cpu_score = dp_score.cpu_score.max(score); | 1018 | + dp_match.cpu_blocks = dp_match.cpu_blocks.max(matched_blocks); |
| 912 | - dp_score.cpu_blocks = dp_score.cpu_blocks.max(matched_blocks); | ||
| 913 | } | 1019 | } |
| 914 | StorageMedium::Disk => { | 1020 | StorageMedium::Disk => { |
| 915 | - dp_score.disk_score = dp_score.disk_score.max(score); | 1021 | + dp_match.disk_blocks = dp_match.disk_blocks.max(matched_blocks); |
| 916 | - dp_score.disk_blocks = dp_score.disk_blocks.max(matched_blocks); | ||
| 917 | } | 1022 | } |
| 918 | } | 1023 | } |
| 919 | - dp_score.matched_tokens = dp_score.matched_tokens.max(matched_tokens); | ||
| 920 | - dp_score.total = dp_score.xpu_score + dp_score.cpu_score + dp_score.disk_score; | ||
| 921 | } | 1024 | } |
| 922 | 1025 | ||
| 923 | - // Compute total_score once per instance after all DPs have been populated. | 1026 | + for (instance_id, imd) in instance_data.iter_mut() { |
| 924 | - for imd in instance_data.values_mut() { | 1027 | + let mut longest = 0u32; |
| 925 | - imd.total_score = imd.dp.values().map(|s| s.total).sum(); | 1028 | + for (dp_rank_str, dp_match) in imd.dp.iter_mut() { |
| 1029 | + let dp_rank: DpRank = dp_rank_str.parse().unwrap_or(0); | ||
| 1030 | + let covered = coverage_end | ||
| 1031 | + .get(&(instance_id.clone(), dp_rank)) | ||
| 1032 | + .copied() | ||
| 1033 | + .unwrap_or_else(|| { | ||
| 1034 | + dp_match | ||
| 1035 | + .npu_blocks | ||
| 1036 | + .saturating_add(dp_match.cpu_blocks) | ||
| 1037 | + .saturating_add(dp_match.disk_blocks) | ||
| 1038 | + }); | ||
| 1039 | + dp_match.matched_tokens = covered.saturating_mul(block_size); | ||
| 1040 | + longest = longest.max(dp_match.matched_tokens); | ||
| 1041 | + } | ||
| 1042 | + imd.longest_matched = longest; | ||
| 926 | } | 1043 | } |
| 927 | 1044 | ||
| 928 | let mut response = QueryResponse::default(); | 1045 | let mut response = QueryResponse::default(); |
| @@ -953,7 +1070,7 @@ impl Indexer { | |||
| 953 | 1070 | ||
| 954 | impl Default for Indexer { | 1071 | impl Default for Indexer { |
| 955 | fn default() -> Self { | 1072 | fn default() -> Self { |
| 956 | - Self::new(ScoringConfig::default()) | 1073 | + Self::new() |
| 957 | } | 1074 | } |
| 958 | } | 1075 | } |
| 959 | 1076 | ||
| @@ -965,139 +1082,5 @@ pub struct IndexerSummary { | |||
| 965 | pub total_blocks: usize, | 1082 | pub total_blocks: usize, |
| 966 | } | 1083 | } |
| 967 | 1084 | ||
| 968 | -use serde::Serialize; | ||
| 969 | - | ||
| 970 | 1085 | ||
| 971 | -mod tests { | 1086 | +mod tests; |
| 972 | - use super::*; | ||
| 973 | - | ||
| 974 | - | ||
| 975 | - fn test_indexer_get_or_create_and_query() { | ||
| 976 | - let indexer = Indexer::new(ScoringConfig::default()); | ||
| 977 | - let entry = indexer.get_or_create("model-a", "tenant-1"); | ||
| 978 | - | ||
| 979 | - // Compute the actual hash for the test token sequence | ||
| 980 | - let tokens: Vec<i64> = vec![10, 20, 30, 40]; | ||
| 981 | - let hashes = compute_block_hash_for_seq(&tokens, 4); | ||
| 982 | - assert!(!hashes.is_empty()); | ||
| 983 | - let tokens_hash = hashes[0]; | ||
| 984 | - | ||
| 985 | - // Insert a worker with XPU blocks using the real hash | ||
| 986 | - let wk_xpu = WorkerKey { | ||
| 987 | - instance_id: "inst-1".into(), | ||
| 988 | - backend_id: "inst-1".into(), | ||
| 989 | - dp_rank: 0, | ||
| 990 | - medium: StorageMedium::Xpu, | ||
| 991 | - }; | ||
| 992 | - | ||
| 993 | - let store = KvCacheEventData::Stored(KvCacheStoreData { | ||
| 994 | - parent_hash: None, | ||
| 995 | - start_position: None, | ||
| 996 | - blocks: vec![KvCacheStoredBlockData { | ||
| 997 | - block_hash: 100, | ||
| 998 | - tokens_hash: tokens_hash.0, | ||
| 999 | - }], | ||
| 1000 | - }); | ||
| 1001 | - entry.apply_event(&wk_xpu, &store).unwrap(); | ||
| 1002 | - | ||
| 1003 | - // Query with the same tokens | ||
| 1004 | - let resp = indexer.query("model-a", "tenant-1", &tokens, 4).unwrap(); | ||
| 1005 | - let tenant = &resp.tenants["tenant-1"]; | ||
| 1006 | - let imd = &tenant["inst-1"]; | ||
| 1007 | - let dp0 = &imd.dp["0"]; | ||
| 1008 | - assert!(dp0.xpu_blocks > 0, "should have XPU match"); | ||
| 1009 | - assert_eq!(dp0.cpu_blocks, 0); | ||
| 1010 | - assert_eq!(dp0.disk_blocks, 0); | ||
| 1011 | - assert!(dp0.matched_tokens > 0); | ||
| 1012 | - assert_eq!(imd.longest_matched, dp0.matched_tokens); | ||
| 1013 | - } | ||
| 1014 | - | ||
| 1015 | - | ||
| 1016 | - fn test_per_tier_aggregation() { | ||
| 1017 | - let indexer = Indexer::new(ScoringConfig::default()); | ||
| 1018 | - let entry = indexer.get_or_create("model-b", "t1"); | ||
| 1019 | - | ||
| 1020 | - // Two different token sequences → different block hashes | ||
| 1021 | - let tokens_a: Vec<i64> = vec![10, 20, 30, 40]; | ||
| 1022 | - let tokens_b: Vec<i64> = vec![50, 60, 70, 80]; | ||
| 1023 | - let hash_a = compute_block_hash_for_seq(&tokens_a, 4)[0]; | ||
| 1024 | - let hash_b = compute_block_hash_for_seq(&tokens_b, 4)[0]; | ||
| 1025 | - | ||
| 1026 | - // Worker 1: XPU blocks | ||
| 1027 | - let wk1 = WorkerKey { | ||
| 1028 | - instance_id: "inst-1".into(), | ||
| 1029 | - backend_id: "inst-1".into(), | ||
| 1030 | - dp_rank: 0, | ||
| 1031 | - medium: StorageMedium::Xpu, | ||
| 1032 | - }; | ||
| 1033 | - entry | ||
| 1034 | - .apply_event( | ||
| 1035 | - &wk1, | ||
| 1036 | - &KvCacheEventData::Stored(KvCacheStoreData { | ||
| 1037 | - parent_hash: None, | ||
| 1038 | - start_position: None, | ||
| 1039 | - blocks: vec![KvCacheStoredBlockData { | ||
| 1040 | - block_hash: 100, | ||
| 1041 | - tokens_hash: hash_a.0, | ||
| 1042 | - }], | ||
| 1043 | - }), | ||
| 1044 | - ) | ||
| 1045 | - .unwrap(); | ||
| 1046 | - | ||
| 1047 | - // Worker 2: CPU blocks (different instance, different tokens) | ||
| 1048 | - let wk2 = WorkerKey { | ||
| 1049 | - instance_id: "inst-2".into(), | ||
| 1050 | - backend_id: "mooncake-1".into(), | ||
| 1051 | - dp_rank: 0, | ||
| 1052 | - medium: StorageMedium::Cpu, | ||
| 1053 | - }; | ||
| 1054 | - entry | ||
| 1055 | - .apply_event( | ||
| 1056 | - &wk2, | ||
| 1057 | - &KvCacheEventData::Stored(KvCacheStoreData { | ||
| 1058 | - parent_hash: None, | ||
| 1059 | - start_position: None, | ||
| 1060 | - blocks: vec![KvCacheStoredBlockData { | ||
| 1061 | - block_hash: 200, | ||
| 1062 | - tokens_hash: hash_b.0, | ||
| 1063 | - }], | ||
| 1064 | - }), | ||
| 1065 | - ) | ||
| 1066 | - .unwrap(); | ||
| 1067 | - | ||
| 1068 | - // Query with tokens_a — should match inst-1 (XPU) only | ||
| 1069 | - let resp = indexer.query("model-b", "t1", &tokens_a, 4).unwrap(); | ||
| 1070 | - let tenant = &resp.tenants["t1"]; | ||
| 1071 | - | ||
| 1072 | - let imd1 = &tenant["inst-1"]; | ||
| 1073 | - let dp0 = &imd1.dp["0"]; | ||
| 1074 | - assert!( | ||
| 1075 | - dp0.xpu_blocks > 0, | ||
| 1076 | - "inst-1 should have XPU match for tokens_a" | ||
| 1077 | - ); | ||
| 1078 | - assert_eq!(dp0.cpu_blocks, 0, "inst-1 should have no CPU match"); | ||
| 1079 | - | ||
| 1080 | - // Query with tokens_b — should match inst-2 (CPU) only | ||
| 1081 | - let resp = indexer.query("model-b", "t1", &tokens_b, 4).unwrap(); | ||
| 1082 | - let tenant = &resp.tenants["t1"]; | ||
| 1083 | - | ||
| 1084 | - let imd2 = &tenant["inst-2"]; | ||
| 1085 | - let dp2 = &imd2.dp["0"]; | ||
| 1086 | - assert_eq!(dp2.xpu_blocks, 0, "inst-2 should have no XPU match"); | ||
| 1087 | - assert!( | ||
| 1088 | - dp2.cpu_blocks > 0, | ||
| 1089 | - "inst-2 should have CPU match for tokens_b" | ||
| 1090 | - ); | ||
| 1091 | - } | ||
| 1092 | - | ||
| 1093 | - | ||
| 1094 | - fn test_no_indexer_error() { | ||
| 1095 | - let indexer = Indexer::new(ScoringConfig::default()); | ||
| 1096 | - let err = indexer.query("no-such-model", "default", &[1, 2, 3, 4], 4); | ||
| 1097 | - assert!(err.is_err()); | ||
| 1098 | - assert!(matches!( | ||
| 1099 | - err.unwrap_err(), | ||
| 1100 | - KvConductorError::NoIndexer { .. } | ||
| 1101 | - )); | ||
| 1102 | - } | ||
| 1103 | -} | ||
| @@ -0,0 +1,852 @@ | |||
| 1 | +// Copyright (c) Huawei Technologies Co., Ltd. 2025-2026. All rights reserved. | ||
| 2 | +// MindIE is licensed under Mulan PSL v2. | ||
| 3 | +// You can use this software according to the terms and conditions of the Mulan PSL v2. | ||
| 4 | +// You may obtain a copy of Mulan PSL v2 at: | ||
| 5 | +// http://license.coscl.org.cn/MulanPSL2 | ||
| 6 | +// THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, | ||
| 7 | +// EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, | ||
| 8 | +// MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. | ||
| 9 | +// See the Mulan PSL v2 for more details. | ||
| 10 | + | ||
| 11 | +use super::*; | ||
| 12 | + | ||
| 13 | + | ||
| 14 | +fn test_indexer_get_or_create_and_query() { | ||
| 15 | + let indexer = Indexer::new(); | ||
| 16 | + let entry = indexer.get_or_create("model-a", "tenant-1"); | ||
| 17 | + | ||
| 18 | + // Compute the actual hash for the test token sequence | ||
| 19 | + let tokens: Vec<i64> = vec![10, 20, 30, 40]; | ||
| 20 | + let hashes = compute_block_hash_for_seq(&tokens, 4); | ||
| 21 | + assert!(!hashes.is_empty()); | ||
| 22 | + let tokens_hash = hashes[0]; | ||
| 23 | + | ||
| 24 | + // Insert a worker with NPU blocks using the real hash | ||
| 25 | + let wk_npu = WorkerKey { | ||
| 26 | + instance_id: "inst-1".into(), | ||
| 27 | + backend_id: "inst-1".into(), | ||
| 28 | + dp_rank: 0, | ||
| 29 | + medium: StorageMedium::Npu, | ||
| 30 | + }; | ||
| 31 | + | ||
| 32 | + let store = KvCacheEventData::Stored(KvCacheStoreData { | ||
| 33 | + parent_hash: None, | ||
| 34 | + start_position: None, | ||
| 35 | + blocks: vec![KvCacheStoredBlockData { | ||
| 36 | + block_hash: 100, | ||
| 37 | + tokens_hash: tokens_hash.0, | ||
| 38 | + }], | ||
| 39 | + }); | ||
| 40 | + entry.apply_event(&wk_npu, &store).unwrap(); | ||
| 41 | + | ||
| 42 | + // Query with the same tokens | ||
| 43 | + let resp = indexer.query("model-a", "tenant-1", &tokens, 4).unwrap(); | ||
| 44 | + let tenant = &resp.tenants["tenant-1"]; | ||
| 45 | + let imd = &tenant["inst-1"]; | ||
| 46 | + let dp0 = &imd.dp["0"]; | ||
| 47 | + assert!(dp0.npu_blocks > 0, "should have NPU match"); | ||
| 48 | + assert_eq!(dp0.cpu_blocks, 0); | ||
| 49 | + assert_eq!(dp0.disk_blocks, 0); | ||
| 50 | + assert_eq!( | ||
| 51 | + dp0.matched_tokens, | ||
| 52 | + (dp0.npu_blocks + dp0.cpu_blocks + dp0.disk_blocks) * 4 | ||
| 53 | + ); | ||
| 54 | + assert_eq!(imd.longest_matched, dp0.matched_tokens); | ||
| 55 | +} | ||
| 56 | + | ||
| 57 | + | ||
| 58 | +fn test_per_tier_aggregation() { | ||
| 59 | + let indexer = Indexer::new(); | ||
| 60 | + let entry = indexer.get_or_create("model-b", "t1"); | ||
| 61 | + | ||
| 62 | + // Two different token sequences → different block hashes | ||
| 63 | + let tokens_a: Vec<i64> = vec![10, 20, 30, 40]; | ||
| 64 | + let tokens_b: Vec<i64> = vec![50, 60, 70, 80]; | ||
| 65 | + let hash_a = compute_block_hash_for_seq(&tokens_a, 4)[0]; | ||
| 66 | + let hash_b = compute_block_hash_for_seq(&tokens_b, 4)[0]; | ||
| 67 | + | ||
| 68 | + // Worker 1: NPU blocks | ||
| 69 | + let wk1 = WorkerKey { | ||
| 70 | + instance_id: "inst-1".into(), | ||
| 71 | + backend_id: "inst-1".into(), | ||
| 72 | + dp_rank: 0, | ||
| 73 | + medium: StorageMedium::Npu, | ||
| 74 | + }; | ||
| 75 | + entry | ||
| 76 | + .apply_event( | ||
| 77 | + &wk1, | ||
| 78 | + &KvCacheEventData::Stored(KvCacheStoreData { | ||
| 79 | + parent_hash: None, | ||
| 80 | + start_position: None, | ||
| 81 | + blocks: vec![KvCacheStoredBlockData { | ||
| 82 | + block_hash: 100, | ||
| 83 | + tokens_hash: hash_a.0, | ||
| 84 | + }], | ||
| 85 | + }), | ||
| 86 | + ) | ||
| 87 | + .unwrap(); | ||
| 88 | + | ||
| 89 | + // Worker 2: CPU blocks (different instance, different tokens) | ||
| 90 | + let wk2 = WorkerKey { | ||
| 91 | + instance_id: "inst-2".into(), | ||
| 92 | + backend_id: "mooncake-1".into(), | ||
| 93 | + dp_rank: 0, | ||
| 94 | + medium: StorageMedium::Cpu, | ||
| 95 | + }; | ||
| 96 | + entry | ||
| 97 | + .apply_event( | ||
| 98 | + &wk2, | ||
| 99 | + &KvCacheEventData::Stored(KvCacheStoreData { | ||
| 100 | + parent_hash: None, | ||
| 101 | + start_position: None, | ||
| 102 | + blocks: vec![KvCacheStoredBlockData { | ||
| 103 | + block_hash: 200, | ||
| 104 | + tokens_hash: hash_b.0, | ||
| 105 | + }], | ||
| 106 | + }), | ||
| 107 | + ) | ||
| 108 | + .unwrap(); | ||
| 109 | + | ||
| 110 | + // Query with tokens_a — should match inst-1 (NPU) only | ||
| 111 | + let resp = indexer.query("model-b", "t1", &tokens_a, 4).unwrap(); | ||
| 112 | + let tenant = &resp.tenants["t1"]; | ||
| 113 | + | ||
| 114 | + let imd1 = &tenant["inst-1"]; | ||
| 115 | + let dp0 = &imd1.dp["0"]; | ||
| 116 | + assert!( | ||
| 117 | + dp0.npu_blocks > 0, | ||
| 118 | + "inst-1 should have NPU match for tokens_a" | ||
| 119 | + ); | ||
| 120 | + assert_eq!(dp0.cpu_blocks, 0, "inst-1 should have no CPU match"); | ||
| 121 | + | ||
| 122 | + // Query with tokens_b — should match inst-2 (CPU) only | ||
| 123 | + let resp = indexer.query("model-b", "t1", &tokens_b, 4).unwrap(); | ||
| 124 | + let tenant = &resp.tenants["t1"]; | ||
| 125 | + | ||
| 126 | + let imd2 = &tenant["inst-2"]; | ||
| 127 | + let dp2 = &imd2.dp["0"]; | ||
| 128 | + assert_eq!(dp2.npu_blocks, 0, "inst-2 should have no NPU match"); | ||
| 129 | + assert!( | ||
| 130 | + dp2.cpu_blocks > 0, | ||
| 131 | + "inst-2 should have CPU match for tokens_b" | ||
| 132 | + ); | ||
| 133 | +} | ||
| 134 | + | ||
| 135 | + | ||
| 136 | +fn test_cpu_continuation_from_hbm_breakpoint() { | ||
| 137 | + let indexer = Indexer::new(); | ||
| 138 | + let entry = indexer.get_or_create("model-c", "t1"); | ||
| 139 | + | ||
| 140 | + let tokens: Vec<i64> = (0..12).collect(); | ||
| 141 | + let hashes = compute_block_hash_for_seq(&tokens, 4); | ||
| 142 | + assert_eq!(hashes.len(), 3); | ||
| 143 | + | ||
| 144 | + // HBM holds first two blocks. | ||
| 145 | + let wk_npu = WorkerKey { | ||
| 146 | + instance_id: "inst-1".into(), | ||
| 147 | + backend_id: "inst-1".into(), | ||
| 148 | + dp_rank: 0, | ||
| 149 | + medium: StorageMedium::Npu, | ||
| 150 | + }; | ||
| 151 | + entry | ||
| 152 | + .apply_event( | ||
| 153 | + &wk_npu, | ||
| 154 | + &KvCacheEventData::Stored(KvCacheStoreData { | ||
| 155 | + parent_hash: None, | ||
| 156 | + start_position: None, | ||
| 157 | + blocks: vec![ | ||
| 158 | + KvCacheStoredBlockData { | ||
| 159 | + block_hash: 100, | ||
| 160 | + tokens_hash: hashes[0].0, | ||
| 161 | + }, | ||
| 162 | + KvCacheStoredBlockData { | ||
| 163 | + block_hash: 200, | ||
| 164 | + tokens_hash: hashes[1].0, | ||
| 165 | + }, | ||
| 166 | + ], | ||
| 167 | + }), | ||
| 168 | + ) | ||
| 169 | + .unwrap(); | ||
| 170 | + | ||
| 171 | + // CPU holds the tail continuing from HBM's last seq_hash=200. | ||
| 172 | + let wk_cpu = WorkerKey { | ||
| 173 | + instance_id: "inst-1".into(), | ||
| 174 | + backend_id: "pool-1".into(), | ||
| 175 | + dp_rank: 0, | ||
| 176 | + medium: StorageMedium::Cpu, | ||
| 177 | + }; | ||
| 178 | + entry | ||
| 179 | + .apply_event( | ||
| 180 | + &wk_cpu, | ||
| 181 | + &KvCacheEventData::Stored(KvCacheStoreData { | ||
| 182 | + parent_hash: Some(200), | ||
| 183 | + start_position: None, | ||
| 184 | + blocks: vec![KvCacheStoredBlockData { | ||
| 185 | + block_hash: 300, | ||
| 186 | + tokens_hash: hashes[2].0, | ||
| 187 | + }], | ||
| 188 | + }), | ||
| 189 | + ) | ||
| 190 | + .unwrap(); | ||
| 191 | + | ||
| 192 | + let overlap = entry.find_matches(&tokens, 4); | ||
| 193 | + assert_eq!( | ||
| 194 | + overlap.blocks.get(&wk_npu).copied().unwrap_or(0), | ||
| 195 | + 2, | ||
| 196 | + "HBM should match first 2 blocks" | ||
| 197 | + ); | ||
| 198 | + assert_eq!( | ||
| 199 | + overlap.blocks.get(&wk_cpu).copied().unwrap_or(0), | ||
| 200 | + 1, | ||
| 201 | + "CPU should continue 1 block from HBM breakpoint" | ||
| 202 | + ); | ||
| 203 | + | ||
| 204 | + // matched_tokens = coverage_end × block_size (HBM 2 + CPU tail 1 → end 3) | ||
| 205 | + let resp = indexer.query("model-c", "t1", &tokens, 4).unwrap(); | ||
| 206 | + let dp0 = &resp.tenants["t1"]["inst-1"].dp["0"]; | ||
| 207 | + assert_eq!(dp0.npu_blocks, 2); | ||
| 208 | + assert_eq!(dp0.cpu_blocks, 1); | ||
| 209 | + assert_eq!(dp0.disk_blocks, 0); | ||
| 210 | + assert_eq!(dp0.matched_tokens, 3 * 4); | ||
| 211 | + assert_eq!(resp.tenants["t1"]["inst-1"].longest_matched, 12); | ||
| 212 | +} | ||
| 213 | + | ||
| 214 | + | ||
| 215 | +fn test_cpu_replica_reported_when_hbm_hits_same_dp() { | ||
| 216 | + let indexer = Indexer::new(); | ||
| 217 | + let entry = indexer.get_or_create("model-d", "t1"); | ||
| 218 | + | ||
| 219 | + let tokens: Vec<i64> = vec![1, 2, 3, 4]; | ||
| 220 | + let hashes = compute_block_hash_for_seq(&tokens, 4); | ||
| 221 | + assert_eq!(hashes.len(), 1); | ||
| 222 | + | ||
| 223 | + let wk_npu = WorkerKey { | ||
| 224 | + instance_id: "inst-1".into(), | ||
| 225 | + backend_id: "inst-1".into(), | ||
| 226 | + dp_rank: 0, | ||
| 227 | + medium: StorageMedium::Npu, | ||
| 228 | + }; | ||
| 229 | + entry | ||
| 230 | + .apply_event( | ||
| 231 | + &wk_npu, | ||
| 232 | + &KvCacheEventData::Stored(KvCacheStoreData { | ||
| 233 | + parent_hash: None, | ||
| 234 | + start_position: None, | ||
| 235 | + blocks: vec![KvCacheStoredBlockData { | ||
| 236 | + block_hash: 100, | ||
| 237 | + tokens_hash: hashes[0].0, | ||
| 238 | + }], | ||
| 239 | + }), | ||
| 240 | + ) | ||
| 241 | + .unwrap(); | ||
| 242 | + | ||
| 243 | + // Same DP also has a full CPU root chain for the same prefix. | ||
| 244 | + let wk_cpu = WorkerKey { | ||
| 245 | + instance_id: "inst-1".into(), | ||
| 246 | + backend_id: "pool-1".into(), | ||
| 247 | + dp_rank: 0, | ||
| 248 | + medium: StorageMedium::Cpu, | ||
| 249 | + }; | ||
| 250 | + entry | ||
| 251 | + .apply_event( | ||
| 252 | + &wk_cpu, | ||
| 253 | + &KvCacheEventData::Stored(KvCacheStoreData { | ||
| 254 | + parent_hash: None, | ||
| 255 | + start_position: None, | ||
| 256 | + blocks: vec![KvCacheStoredBlockData { | ||
| 257 | + block_hash: 200, | ||
| 258 | + tokens_hash: hashes[0].0, | ||
| 259 | + }], | ||
| 260 | + }), | ||
| 261 | + ) | ||
| 262 | + .unwrap(); | ||
| 263 | + | ||
| 264 | + let resp = indexer.query("model-d", "t1", &tokens, 4).unwrap(); | ||
| 265 | + let dp0 = &resp.tenants["t1"]["inst-1"].dp["0"]; | ||
| 266 | + assert_eq!(dp0.npu_blocks, 1); | ||
| 267 | + // CPU replica of the same prefix is reported as a real segment; the | ||
| 268 | + // coverage max keeps matched_tokens at the true end (1 block). | ||
| 269 | + assert_eq!(dp0.cpu_blocks, 1); | ||
| 270 | + assert_eq!(dp0.matched_tokens, 4); | ||
| 271 | +} | ||
| 272 | + | ||
| 273 | + | ||
| 274 | +fn test_cpu_root_used_when_no_hbm_on_dp() { | ||
| 275 | + let indexer = Indexer::new(); | ||
| 276 | + let entry = indexer.get_or_create("model-e", "t1"); | ||
| 277 | + | ||
| 278 | + let tokens: Vec<i64> = vec![9, 8, 7, 6]; | ||
| 279 | + let hashes = compute_block_hash_for_seq(&tokens, 4); | ||
| 280 | + | ||
| 281 | + let wk_cpu = WorkerKey { | ||
| 282 | + instance_id: "inst-cpu-only".into(), | ||
| 283 | + backend_id: "pool-1".into(), | ||
| 284 | + dp_rank: 0, | ||
| 285 | + medium: StorageMedium::Cpu, | ||
| 286 | + }; | ||
| 287 | + entry | ||
| 288 | + .apply_event( | ||
| 289 | + &wk_cpu, | ||
| 290 | + &KvCacheEventData::Stored(KvCacheStoreData { | ||
| 291 | + parent_hash: None, | ||
| 292 | + start_position: None, | ||
| 293 | + blocks: vec![KvCacheStoredBlockData { | ||
| 294 | + block_hash: 300, | ||
| 295 | + tokens_hash: hashes[0].0, | ||
| 296 | + }], | ||
| 297 | + }), | ||
| 298 | + ) | ||
| 299 | + .unwrap(); | ||
| 300 | + | ||
| 301 | + let resp = indexer.query("model-e", "t1", &tokens, 4).unwrap(); | ||
| 302 | + let dp0 = &resp.tenants["t1"]["inst-cpu-only"].dp["0"]; | ||
| 303 | + assert_eq!(dp0.npu_blocks, 0); | ||
| 304 | + assert_eq!(dp0.cpu_blocks, 1); | ||
| 305 | + assert_eq!(dp0.matched_tokens, 4); | ||
| 306 | +} | ||
| 307 | + | ||
| 308 | + | ||
| 309 | +fn test_disk_continuation_from_cpu_breakpoint() { | ||
| 310 | + let indexer = Indexer::new(); | ||
| 311 | + let entry = indexer.get_or_create("model-f", "t1"); | ||
| 312 | + | ||
| 313 | + let tokens: Vec<i64> = (0..12).collect(); | ||
| 314 | + let hashes = compute_block_hash_for_seq(&tokens, 4); | ||
| 315 | + assert_eq!(hashes.len(), 3); | ||
| 316 | + | ||
| 317 | + // HBM: first block | ||
| 318 | + let wk_npu = WorkerKey { | ||
| 319 | + instance_id: "inst-1".into(), | ||
| 320 | + backend_id: "inst-1".into(), | ||
| 321 | + dp_rank: 0, | ||
| 322 | + medium: StorageMedium::Npu, | ||
| 323 | + }; | ||
| 324 | + entry | ||
| 325 | + .apply_event( | ||
| 326 | + &wk_npu, | ||
| 327 | + &KvCacheEventData::Stored(KvCacheStoreData { | ||
| 328 | + parent_hash: None, | ||
| 329 | + start_position: None, | ||
| 330 | + blocks: vec![KvCacheStoredBlockData { | ||
| 331 | + block_hash: 100, | ||
| 332 | + tokens_hash: hashes[0].0, | ||
| 333 | + }], | ||
| 334 | + }), | ||
| 335 | + ) | ||
| 336 | + .unwrap(); | ||
| 337 | + | ||
| 338 | + // CPU: second block, continuing from HBM seq=100 | ||
| 339 | + let wk_cpu = WorkerKey { | ||
| 340 | + instance_id: "inst-1".into(), | ||
| 341 | + backend_id: "pool-cpu".into(), | ||
| 342 | + dp_rank: 0, | ||
| 343 | + medium: StorageMedium::Cpu, | ||
| 344 | + }; | ||
| 345 | + entry | ||
| 346 | + .apply_event( | ||
| 347 | + &wk_cpu, | ||
| 348 | + &KvCacheEventData::Stored(KvCacheStoreData { | ||
| 349 | + parent_hash: Some(100), | ||
| 350 | + start_position: None, | ||
| 351 | + blocks: vec![KvCacheStoredBlockData { | ||
| 352 | + block_hash: 200, | ||
| 353 | + tokens_hash: hashes[1].0, | ||
| 354 | + }], | ||
| 355 | + }), | ||
| 356 | + ) | ||
| 357 | + .unwrap(); | ||
| 358 | + | ||
| 359 | + // Disk: third block, continuing from CPU seq=200 | ||
| 360 | + let wk_disk = WorkerKey { | ||
| 361 | + instance_id: "inst-1".into(), | ||
| 362 | + backend_id: "pool-disk".into(), | ||
| 363 | + dp_rank: 0, | ||
| 364 | + medium: StorageMedium::Disk, | ||
| 365 | + }; | ||
| 366 | + entry | ||
| 367 | + .apply_event( | ||
| 368 | + &wk_disk, | ||
| 369 | + &KvCacheEventData::Stored(KvCacheStoreData { | ||
| 370 | + parent_hash: Some(200), | ||
| 371 | + start_position: None, | ||
| 372 | + blocks: vec![KvCacheStoredBlockData { | ||
| 373 | + block_hash: 300, | ||
| 374 | + tokens_hash: hashes[2].0, | ||
| 375 | + }], | ||
| 376 | + }), | ||
| 377 | + ) | ||
| 378 | + .unwrap(); | ||
| 379 | + | ||
| 380 | + let resp = indexer.query("model-f", "t1", &tokens, 4).unwrap(); | ||
| 381 | + let dp0 = &resp.tenants["t1"]["inst-1"].dp["0"]; | ||
| 382 | + assert_eq!(dp0.npu_blocks, 1); | ||
| 383 | + assert_eq!(dp0.cpu_blocks, 1); | ||
| 384 | + assert_eq!(dp0.disk_blocks, 1); | ||
| 385 | + // Coverage end = 3 (consecutive segments), not a double-counted sum. | ||
| 386 | + assert_eq!(dp0.matched_tokens, 3 * 4); | ||
| 387 | +} | ||
| 388 | + | ||
| 389 | + | ||
| 390 | +fn test_overlapping_npu_cpu_disk_replicas_do_not_inflate_matched_tokens() { | ||
| 391 | + // Log pattern: same prefix present on NPU + CPU + Disk. Replicas are | ||
| 392 | + // reported as real segment lengths on every tier, but matched_tokens | ||
| 393 | + // must stay within the input prefix (coverage end), never NPU+CPU+Disk. | ||
| 394 | + let indexer = Indexer::new(); | ||
| 395 | + let entry = indexer.get_or_create("model-overlap", "t1"); | ||
| 396 | + | ||
| 397 | + let tokens: Vec<i64> = (0..8).collect(); | ||
| 398 | + let hashes = compute_block_hash_for_seq(&tokens, 4); | ||
| 399 | + assert_eq!(hashes.len(), 2); | ||
| 400 | + | ||
| 401 | + let wk_npu = WorkerKey { | ||
| 402 | + instance_id: "inst-1".into(), | ||
| 403 | + backend_id: "inst-1".into(), | ||
| 404 | + dp_rank: 0, | ||
| 405 | + medium: StorageMedium::Npu, | ||
| 406 | + }; | ||
| 407 | + entry | ||
| 408 | + .apply_event( | ||
| 409 | + &wk_npu, | ||
| 410 | + &KvCacheEventData::Stored(KvCacheStoreData { | ||
| 411 | + parent_hash: None, | ||
| 412 | + start_position: None, | ||
| 413 | + blocks: vec![ | ||
| 414 | + KvCacheStoredBlockData { | ||
| 415 | + block_hash: 100, | ||
| 416 | + tokens_hash: hashes[0].0, | ||
| 417 | + }, | ||
| 418 | + KvCacheStoredBlockData { | ||
| 419 | + block_hash: 200, | ||
| 420 | + tokens_hash: hashes[1].0, | ||
| 421 | + }, | ||
| 422 | + ], | ||
| 423 | + }), | ||
| 424 | + ) | ||
| 425 | + .unwrap(); | ||
| 426 | + | ||
| 427 | + let wk_cpu = WorkerKey { | ||
| 428 | + instance_id: "inst-1".into(), | ||
| 429 | + backend_id: "pool-cpu".into(), | ||
| 430 | + dp_rank: 0, | ||
| 431 | + medium: StorageMedium::Cpu, | ||
| 432 | + }; | ||
| 433 | + entry | ||
| 434 | + .apply_event( | ||
| 435 | + &wk_cpu, | ||
| 436 | + &KvCacheEventData::Stored(KvCacheStoreData { | ||
| 437 | + parent_hash: None, | ||
| 438 | + start_position: None, | ||
| 439 | + blocks: vec![ | ||
| 440 | + KvCacheStoredBlockData { | ||
| 441 | + block_hash: 100, | ||
| 442 | + tokens_hash: hashes[0].0, | ||
| 443 | + }, | ||
| 444 | + KvCacheStoredBlockData { | ||
| 445 | + block_hash: 200, | ||
| 446 | + tokens_hash: hashes[1].0, | ||
| 447 | + }, | ||
| 448 | + ], | ||
| 449 | + }), | ||
| 450 | + ) | ||
| 451 | + .unwrap(); | ||
| 452 | + | ||
| 453 | + let wk_disk = WorkerKey { | ||
| 454 | + instance_id: "inst-1".into(), | ||
| 455 | + backend_id: "pool-disk".into(), | ||
| 456 | + dp_rank: 0, | ||
| 457 | + medium: StorageMedium::Disk, | ||
| 458 | + }; | ||
| 459 | + entry | ||
| 460 | + .apply_event( | ||
| 461 | + &wk_disk, | ||
| 462 | + &KvCacheEventData::Stored(KvCacheStoreData { | ||
| 463 | + parent_hash: None, | ||
| 464 | + start_position: None, | ||
| 465 | + blocks: vec![ | ||
| 466 | + KvCacheStoredBlockData { | ||
| 467 | + block_hash: 100, | ||
| 468 | + tokens_hash: hashes[0].0, | ||
| 469 | + }, | ||
| 470 | + KvCacheStoredBlockData { | ||
| 471 | + block_hash: 200, | ||
| 472 | + tokens_hash: hashes[1].0, | ||
| 473 | + }, | ||
| 474 | + ], | ||
| 475 | + }), | ||
| 476 | + ) | ||
| 477 | + .unwrap(); | ||
| 478 | + | ||
| 479 | + let resp = indexer.query("model-overlap", "t1", &tokens, 4).unwrap(); | ||
| 480 | + let dp0 = &resp.tenants["t1"]["inst-1"].dp["0"]; | ||
| 481 | + assert_eq!(dp0.npu_blocks, 2); | ||
| 482 | + // Replicas on CPU/Disk are reported as real segment lengths (no root | ||
| 483 | + // skip), but matched_tokens stays at the coverage end — never summed. | ||
| 484 | + assert_eq!(dp0.cpu_blocks, 2); | ||
| 485 | + assert_eq!(dp0.disk_blocks, 2); | ||
| 486 | + assert_eq!(dp0.matched_tokens, 2 * 4); | ||
| 487 | + assert!( | ||
| 488 | + dp0.matched_tokens <= tokens.len() as u32, | ||
| 489 | + "matched_tokens {} exceeds input {}", | ||
| 490 | + dp0.matched_tokens, | ||
| 491 | + tokens.len() | ||
| 492 | + ); | ||
| 493 | +} | ||
| 494 | + | ||
| 495 | + | ||
| 496 | +fn test_shorter_hbm_breakpoint_does_not_overcount_cpu_overlap() { | ||
| 497 | + // Two NPU workers on the same DP with different depths; CPU holds the | ||
| 498 | + // tail after the shorter breakpoint. Coverage must stay at the true | ||
| 499 | + // prefix end (2), not npu_max + cpu_segment. | ||
| 500 | + let indexer = Indexer::new(); | ||
| 501 | + let entry = indexer.get_or_create("model-short-break", "t1"); | ||
| 502 | + | ||
| 503 | + let tokens: Vec<i64> = (0..8).collect(); | ||
| 504 | + let hashes = compute_block_hash_for_seq(&tokens, 4); | ||
| 505 | + assert_eq!(hashes.len(), 2); | ||
| 506 | + | ||
| 507 | + let wk_npu_short = WorkerKey { | ||
| 508 | + instance_id: "inst-1".into(), | ||
| 509 | + backend_id: "npu-short".into(), | ||
| 510 | + dp_rank: 0, | ||
| 511 | + medium: StorageMedium::Npu, | ||
| 512 | + }; | ||
| 513 | + entry | ||
| 514 | + .apply_event( | ||
| 515 | + &wk_npu_short, | ||
| 516 | + &KvCacheEventData::Stored(KvCacheStoreData { | ||
| 517 | + parent_hash: None, | ||
| 518 | + start_position: None, | ||
| 519 | + blocks: vec![KvCacheStoredBlockData { | ||
| 520 | + block_hash: 100, | ||
| 521 | + tokens_hash: hashes[0].0, | ||
| 522 | + }], | ||
| 523 | + }), | ||
| 524 | + ) | ||
| 525 | + .unwrap(); | ||
| 526 | + | ||
| 527 | + let wk_npu_long = WorkerKey { | ||
| 528 | + instance_id: "inst-1".into(), | ||
| 529 | + backend_id: "npu-long".into(), | ||
| 530 | + dp_rank: 0, | ||
| 531 | + medium: StorageMedium::Npu, | ||
| 532 | + }; | ||
| 533 | + entry | ||
| 534 | + .apply_event( | ||
| 535 | + &wk_npu_long, | ||
| 536 | + &KvCacheEventData::Stored(KvCacheStoreData { | ||
| 537 | + parent_hash: None, | ||
| 538 | + start_position: None, | ||
| 539 | + blocks: vec![ | ||
| 540 | + KvCacheStoredBlockData { | ||
| 541 | + block_hash: 100, | ||
| 542 | + tokens_hash: hashes[0].0, | ||
| 543 | + }, | ||
| 544 | + KvCacheStoredBlockData { | ||
| 545 | + block_hash: 200, | ||
| 546 | + tokens_hash: hashes[1].0, | ||
| 547 | + }, | ||
| 548 | + ], | ||
| 549 | + }), | ||
| 550 | + ) | ||
| 551 | + .unwrap(); | ||
| 552 | + | ||
| 553 | + let wk_cpu = WorkerKey { | ||
| 554 | + instance_id: "inst-1".into(), | ||
| 555 | + backend_id: "pool-cpu".into(), | ||
| 556 | + dp_rank: 0, | ||
| 557 | + medium: StorageMedium::Cpu, | ||
| 558 | + }; | ||
| 559 | + entry | ||
| 560 | + .apply_event( | ||
| 561 | + &wk_cpu, | ||
| 562 | + &KvCacheEventData::Stored(KvCacheStoreData { | ||
| 563 | + parent_hash: Some(100), | ||
| 564 | + start_position: None, | ||
| 565 | + blocks: vec![KvCacheStoredBlockData { | ||
| 566 | + block_hash: 200, | ||
| 567 | + tokens_hash: hashes[1].0, | ||
| 568 | + }], | ||
| 569 | + }), | ||
| 570 | + ) | ||
| 571 | + .unwrap(); | ||
| 572 | + | ||
| 573 | + let resp = indexer | ||
| 574 | + .query("model-short-break", "t1", &tokens, 4) | ||
| 575 | + .unwrap(); | ||
| 576 | + let dp0 = &resp.tenants["t1"]["inst-1"].dp["0"]; | ||
| 577 | + assert_eq!(dp0.npu_blocks, 2); | ||
| 578 | + // The CPU worker genuinely owns the chained block (100→200 = position 1), | ||
| 579 | + // so it scores a 1-block continuation segment. The farthest HBM | ||
| 580 | + // breakpoint (end_pos=2) sits at the sequence end and cannot continue — | ||
| 581 | + // but coverage end is still 2, so matched_tokens stays within input. | ||
| 582 | + assert_eq!(dp0.cpu_blocks, 1); | ||
| 583 | + assert_eq!(dp0.matched_tokens, 2 * 4); | ||
| 584 | +} | ||
| 585 | + | ||
| 586 | + | ||
| 587 | +fn test_disk_continuation_from_hbm_when_cpu_miss() { | ||
| 588 | + // vLLM lookup: after NPU hit, Disk can hit even if CPU miss (then promote). | ||
| 589 | + let indexer = Indexer::new(); | ||
| 590 | + let entry = indexer.get_or_create("model-h", "t1"); | ||
| 591 | + | ||
| 592 | + let tokens: Vec<i64> = (0..8).collect(); | ||
| 593 | + let hashes = compute_block_hash_for_seq(&tokens, 4); | ||
| 594 | + assert_eq!(hashes.len(), 2); | ||
| 595 | + | ||
| 596 | + let wk_npu = WorkerKey { | ||
| 597 | + instance_id: "inst-1".into(), | ||
| 598 | + backend_id: "inst-1".into(), | ||
| 599 | + dp_rank: 0, | ||
| 600 | + medium: StorageMedium::Npu, | ||
| 601 | + }; | ||
| 602 | + entry | ||
| 603 | + .apply_event( | ||
| 604 | + &wk_npu, | ||
| 605 | + &KvCacheEventData::Stored(KvCacheStoreData { | ||
| 606 | + parent_hash: None, | ||
| 607 | + start_position: None, | ||
| 608 | + blocks: vec![KvCacheStoredBlockData { | ||
| 609 | + block_hash: 100, | ||
| 610 | + tokens_hash: hashes[0].0, | ||
| 611 | + }], | ||
| 612 | + }), | ||
| 613 | + ) | ||
| 614 | + .unwrap(); | ||
| 615 | + | ||
| 616 | + // Disk tail continues from HBM; CPU has nothing. | ||
| 617 | + let wk_disk = WorkerKey { | ||
| 618 | + instance_id: "inst-1".into(), | ||
| 619 | + backend_id: "pool-disk".into(), | ||
| 620 | + dp_rank: 0, | ||
| 621 | + medium: StorageMedium::Disk, | ||
| 622 | + }; | ||
| 623 | + entry | ||
| 624 | + .apply_event( | ||
| 625 | + &wk_disk, | ||
| 626 | + &KvCacheEventData::Stored(KvCacheStoreData { | ||
| 627 | + parent_hash: Some(100), | ||
| 628 | + start_position: None, | ||
| 629 | + blocks: vec![KvCacheStoredBlockData { | ||
| 630 | + block_hash: 300, | ||
| 631 | + tokens_hash: hashes[1].0, | ||
| 632 | + }], | ||
| 633 | + }), | ||
| 634 | + ) | ||
| 635 | + .unwrap(); | ||
| 636 | + | ||
| 637 | + let resp = indexer.query("model-h", "t1", &tokens, 4).unwrap(); | ||
| 638 | + let dp0 = &resp.tenants["t1"]["inst-1"].dp["0"]; | ||
| 639 | + assert_eq!(dp0.npu_blocks, 1); | ||
| 640 | + assert_eq!(dp0.cpu_blocks, 0); | ||
| 641 | + assert_eq!(dp0.disk_blocks, 1); | ||
| 642 | + assert_eq!(dp0.matched_tokens, 2 * 4); // coverage end after NPU + disk tail | ||
| 643 | +} | ||
| 644 | + | ||
| 645 | + | ||
| 646 | +fn test_disk_replica_reported_when_cpu_hits_same_dp() { | ||
| 647 | + let indexer = Indexer::new(); | ||
| 648 | + let entry = indexer.get_or_create("model-g", "t1"); | ||
| 649 | + | ||
| 650 | + let tokens: Vec<i64> = vec![1, 2, 3, 4]; | ||
| 651 | + let hashes = compute_block_hash_for_seq(&tokens, 4); | ||
| 652 | + | ||
| 653 | + let wk_cpu = WorkerKey { | ||
| 654 | + instance_id: "inst-1".into(), | ||
| 655 | + backend_id: "pool-cpu".into(), | ||
| 656 | + dp_rank: 0, | ||
| 657 | + medium: StorageMedium::Cpu, | ||
| 658 | + }; | ||
| 659 | + entry | ||
| 660 | + .apply_event( | ||
| 661 | + &wk_cpu, | ||
| 662 | + &KvCacheEventData::Stored(KvCacheStoreData { | ||
| 663 | + parent_hash: None, | ||
| 664 | + start_position: None, | ||
| 665 | + blocks: vec![KvCacheStoredBlockData { | ||
| 666 | + block_hash: 100, | ||
| 667 | + tokens_hash: hashes[0].0, | ||
| 668 | + }], | ||
| 669 | + }), | ||
| 670 | + ) | ||
| 671 | + .unwrap(); | ||
| 672 | + | ||
| 673 | + // Same DP also has a Disk root chain — reported as a real segment. | ||
| 674 | + let wk_disk = WorkerKey { | ||
| 675 | + instance_id: "inst-1".into(), | ||
| 676 | + backend_id: "pool-disk".into(), | ||
| 677 | + dp_rank: 0, | ||
| 678 | + medium: StorageMedium::Disk, | ||
| 679 | + }; | ||
| 680 | + entry | ||
| 681 | + .apply_event( | ||
| 682 | + &wk_disk, | ||
| 683 | + &KvCacheEventData::Stored(KvCacheStoreData { | ||
| 684 | + parent_hash: None, | ||
| 685 | + start_position: None, | ||
| 686 | + blocks: vec![KvCacheStoredBlockData { | ||
| 687 | + block_hash: 200, | ||
| 688 | + tokens_hash: hashes[0].0, | ||
| 689 | + }], | ||
| 690 | + }), | ||
| 691 | + ) | ||
| 692 | + .unwrap(); | ||
| 693 | + | ||
| 694 | + let resp = indexer.query("model-g", "t1", &tokens, 4).unwrap(); | ||
| 695 | + let dp0 = &resp.tenants["t1"]["inst-1"].dp["0"]; | ||
| 696 | + assert_eq!(dp0.cpu_blocks, 1); | ||
| 697 | + // Disk replica is reported as a real segment; coverage max keeps | ||
| 698 | + // matched_tokens at the true end (1 block). | ||
| 699 | + assert_eq!(dp0.disk_blocks, 1); | ||
| 700 | + assert_eq!(dp0.matched_tokens, 4); | ||
| 701 | +} | ||
| 702 | + | ||
| 703 | + | ||
| 704 | +fn test_disk_replica_reported_when_hbm_hits_same_dp() { | ||
| 705 | + let indexer = Indexer::new(); | ||
| 706 | + let entry = indexer.get_or_create("model-i", "t1"); | ||
| 707 | + | ||
| 708 | + let tokens: Vec<i64> = vec![1, 2, 3, 4]; | ||
| 709 | + let hashes = compute_block_hash_for_seq(&tokens, 4); | ||
| 710 | + | ||
| 711 | + let wk_npu = WorkerKey { | ||
| 712 | + instance_id: "inst-1".into(), | ||
| 713 | + backend_id: "inst-1".into(), | ||
| 714 | + dp_rank: 0, | ||
| 715 | + medium: StorageMedium::Npu, | ||
| 716 | + }; | ||
| 717 | + entry | ||
| 718 | + .apply_event( | ||
| 719 | + &wk_npu, | ||
| 720 | + &KvCacheEventData::Stored(KvCacheStoreData { | ||
| 721 | + parent_hash: None, | ||
| 722 | + start_position: None, | ||
| 723 | + blocks: vec![KvCacheStoredBlockData { | ||
| 724 | + block_hash: 100, | ||
| 725 | + tokens_hash: hashes[0].0, | ||
| 726 | + }], | ||
| 727 | + }), | ||
| 728 | + ) | ||
| 729 | + .unwrap(); | ||
| 730 | + | ||
| 731 | + let wk_disk = WorkerKey { | ||
| 732 | + instance_id: "inst-1".into(), | ||
| 733 | + backend_id: "pool-disk".into(), | ||
| 734 | + dp_rank: 0, | ||
| 735 | + medium: StorageMedium::Disk, | ||
| 736 | + }; | ||
| 737 | + entry | ||
| 738 | + .apply_event( | ||
| 739 | + &wk_disk, | ||
| 740 | + &KvCacheEventData::Stored(KvCacheStoreData { | ||
| 741 | + parent_hash: None, | ||
| 742 | + start_position: None, | ||
| 743 | + blocks: vec![KvCacheStoredBlockData { | ||
| 744 | + block_hash: 200, | ||
| 745 | + tokens_hash: hashes[0].0, | ||
| 746 | + }], | ||
| 747 | + }), | ||
| 748 | + ) | ||
| 749 | + .unwrap(); | ||
| 750 | + | ||
| 751 | + let resp = indexer.query("model-i", "t1", &tokens, 4).unwrap(); | ||
| 752 | + let dp0 = &resp.tenants["t1"]["inst-1"].dp["0"]; | ||
| 753 | + assert_eq!(dp0.npu_blocks, 1); | ||
| 754 | + // Disk replica of the same prefix is reported as a real segment; | ||
| 755 | + // coverage max keeps matched_tokens at the true end (1 block). | ||
| 756 | + assert_eq!(dp0.disk_blocks, 1); | ||
| 757 | + assert_eq!(dp0.matched_tokens, 4); | ||
| 758 | +} | ||
| 759 | + | ||
| 760 | +/// The documented "longer lower-tier replica" case: NPU holds only the | ||
| 761 | +/// first block while Disk holds the full prefix. The old root-skip made | ||
| 762 | +/// this silently under-report (disk_blocks=0, matched=1 block); the | ||
| 763 | +/// unconditional root walk must surface the Disk copy and extend the | ||
| 764 | +/// coverage end to the true prefix length. | ||
| 765 | + | ||
| 766 | +fn test_lower_tier_longer_replica_extends_coverage() { | ||
| 767 | + let indexer = Indexer::new(); | ||
| 768 | + let entry = indexer.get_or_create("model-long-replica", "t1"); | ||
| 769 | + | ||
| 770 | + let tokens: Vec<i64> = (0..12).collect(); | ||
| 771 | + let hashes = compute_block_hash_for_seq(&tokens, 4); | ||
| 772 | + assert_eq!(hashes.len(), 3); | ||
| 773 | + | ||
| 774 | + // NPU: only the first block. | ||
| 775 | + let wk_npu = WorkerKey { | ||
| 776 | + instance_id: "inst-1".into(), | ||
| 777 | + backend_id: "inst-1".into(), | ||
| 778 | + dp_rank: 0, | ||
| 779 | + medium: StorageMedium::Npu, | ||
| 780 | + }; | ||
| 781 | + entry | ||
| 782 | + .apply_event( | ||
| 783 | + &wk_npu, | ||
| 784 | + &KvCacheEventData::Stored(KvCacheStoreData { | ||
| 785 | + parent_hash: None, | ||
| 786 | + start_position: None, | ||
| 787 | + blocks: vec![KvCacheStoredBlockData { | ||
| 788 | + block_hash: 100, | ||
| 789 | + tokens_hash: hashes[0].0, | ||
| 790 | + }], | ||
| 791 | + }), | ||
| 792 | + ) | ||
| 793 | + .unwrap(); | ||
| 794 | + | ||
| 795 | + // Disk: full 3-block root chain. | ||
| 796 | + let wk_disk = WorkerKey { | ||
| 797 | + instance_id: "inst-1".into(), | ||
| 798 | + backend_id: "pool-disk".into(), | ||
| 799 | + dp_rank: 0, | ||
| 800 | + medium: StorageMedium::Disk, | ||
| 801 | + }; | ||
| 802 | + entry | ||
| 803 | + .apply_event( | ||
| 804 | + &wk_disk, | ||
| 805 | + &KvCacheEventData::Stored(KvCacheStoreData { | ||
| 806 | + parent_hash: None, | ||
| 807 | + start_position: None, | ||
| 808 | + blocks: vec![ | ||
| 809 | + KvCacheStoredBlockData { | ||
| 810 | + block_hash: 200, | ||
| 811 | + tokens_hash: hashes[0].0, | ||
| 812 | + }, | ||
| 813 | + KvCacheStoredBlockData { | ||
| 814 | + block_hash: 201, | ||
| 815 | + tokens_hash: hashes[1].0, | ||
| 816 | + }, | ||
| 817 | + KvCacheStoredBlockData { | ||
| 818 | + block_hash: 202, | ||
| 819 | + tokens_hash: hashes[2].0, | ||
| 820 | + }, | ||
| 821 | + ], | ||
| 822 | + }), | ||
| 823 | + ) | ||
| 824 | + .unwrap(); | ||
| 825 | + | ||
| 826 | + let resp = indexer | ||
| 827 | + .query("model-long-replica", "t1", &tokens, 4) | ||
| 828 | + .unwrap(); | ||
| 829 | + let dp0 = &resp.tenants["t1"]["inst-1"].dp["0"]; | ||
| 830 | + assert_eq!(dp0.npu_blocks, 1); | ||
| 831 | + assert_eq!( | ||
| 832 | + dp0.disk_blocks, 3, | ||
| 833 | + "Disk replica of the full prefix must be reported" | ||
| 834 | + ); | ||
| 835 | + assert_eq!( | ||
| 836 | + dp0.matched_tokens, | ||
| 837 | + 3 * 4, | ||
| 838 | + "coverage must extend to the longer replica, not the NPU's 1 block" | ||
| 839 | + ); | ||
| 840 | + assert!(dp0.matched_tokens <= tokens.len() as u32); | ||
| 841 | +} | ||
| 842 | + | ||
| 843 | + | ||
| 844 | +fn test_no_indexer_error() { | ||
| 845 | + let indexer = Indexer::new(); | ||
| 846 | + let err = indexer.query("no-such-model", "default", &[1, 2, 3, 4], 4); | ||
| 847 | + assert!(err.is_err()); | ||
| 848 | + assert!(matches!( | ||
| 849 | + err.unwrap_err(), | ||
| 850 | + KvConductorError::NoIndexer { .. } | ||
| 851 | + )); | ||
| 852 | +} | ||
| @@ -21,6 +21,7 @@ pub mod error; | |||
| 21 | pub mod events; | 21 | pub mod events; |
| 22 | pub mod hashing; | 22 | pub mod hashing; |
| 23 | pub mod indexer; | 23 | pub mod indexer; |
| 24 | +pub mod lower_tier; | ||
| 24 | pub mod protocols; | 25 | pub mod protocols; |
| 25 | pub mod registry; | 26 | pub mod registry; |
| 26 | pub mod server; | 27 | pub mod server; |
| @@ -36,7 +37,7 @@ pub use indexer::Indexer; | |||
| 36 | pub use protocols::{ | 37 | pub use protocols::{ |
| 37 | DpRank, HbmIpIndex, InstanceId, InstanceMatchData, KvCacheEvent, KvCacheEventData, | 38 | DpRank, HbmIpIndex, InstanceId, InstanceMatchData, KvCacheEvent, KvCacheEventData, |
| 38 | KvCacheStoreData, KvCacheStoredBlockData, KvEventBatch, KvEventWirePayload, LocalBlockHash, | 39 | KvCacheStoreData, KvCacheStoredBlockData, KvEventBatch, KvEventWirePayload, LocalBlockHash, |
| 39 | - OverlapScores, QueryByHashRequest, QueryRequest, QueryResponse, RegisterRequest, | 40 | + OverlapBlocks, QueryByHashRequest, QueryRequest, QueryResponse, RegisterRequest, |
| 40 | SequenceBlockHash, StorageMedium, UnregisterRequest, WorkerKey, | 41 | SequenceBlockHash, StorageMedium, UnregisterRequest, WorkerKey, |
| 41 | }; | 42 | }; |
| 42 | pub use registry::WorkerRegistry; | 43 | pub use registry::WorkerRegistry; |
| @@ -0,0 +1,515 @@ | |||
| 1 | +// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| 2 | +// SPDX-FileCopyrightText: Copyright (c) Huawei Technologies Co., Ltd. 2025-2026. All rights reserved. | ||
| 3 | +// SPDX-License-Identifier: Apache-2.0 | ||
| 4 | +// | ||
| 5 | +// This file is a Derivative Work of NVIDIA Dynamo kv-router | ||
| 6 | +// (https://github.com/ai-dynamo/dynamo), originally licensed under the | ||
| 7 | +// Apache License, Version 2.0. Upstream source path: | ||
| 8 | +// lib/kv-router/src/indexer/lower_tier.rs | ||
| 9 | +// | ||
| 10 | +// You may obtain a copy of the Apache License at: | ||
| 11 | +// http://www.apache.org/licenses/LICENSE-2.0 | ||
| 12 | +// Local copy: licenses/Apache-2.0.txt | ||
| 13 | +// Attribution: THIRD_PARTY_NOTICES.md | ||
| 14 | +// | ||
| 15 | +// Modified by Huawei Technologies Co., Ltd. for MindIE-PyMotor KV Conductor | ||
| 16 | +// (RwLock + per-worker reverse index, ContiguousHit API, WorkerKey/medium | ||
| 17 | +// integration). Huawei modifications are also available under Mulan PSL v2 | ||
| 18 | +// (http://license.coscl.org.cn/MulanPSL2). Redistribution of this file must | ||
| 19 | +// still comply with Apache License 2.0. | ||
| 20 | + | ||
| 21 | +//! Lower-tier (CPU / Disk) continuation-edge index. | ||
| 22 | +//! | ||
| 23 | +//! Derived from NVIDIA Dynamo kv-router `LowerTierIndexer` | ||
| 24 | +//! (`lib/kv-router/src/indexer/lower_tier.rs`, Apache-2.0). See | ||
| 25 | +//! `THIRD_PARTY_NOTICES.md`. | ||
| 26 | +//! | ||
| 27 | +//! Stores worker ownership over shared continuation edges: | ||
| 28 | +//! ``(parent_sequence_hash, local_hash) -> child_sequence_hash``. | ||
| 29 | +//! | ||
| 30 | +//! Unlike the HBM radix tree, this index does **not** score from root by | ||
| 31 | +//! default. Queries continue from caller-provided per-worker continuation | ||
| 32 | +//! points (HBM → CPU; max(HBM, CPU) → Disk) and count how many | ||
| 33 | +//! **consecutive** lower-tier blocks are present. The caller also issues an | ||
| 34 | +//! unconditional root-walk candidate per worker owning the first edge, so a | ||
| 35 | +//! longer full replica on this tier is never hidden by a shorter upstream | ||
| 36 | +//! hit; the walk keeps the candidate with the farthest absolute end. | ||
| 37 | + | ||
| 38 | +use parking_lot::RwLock; | ||
| 39 | +use rustc_hash::{FxHashMap, FxHashSet}; | ||
| 40 | + | ||
| 41 | +use crate::protocols::{KvCacheStoreData, LocalBlockHash, SequenceBlockHash, WorkerKey}; | ||
| 42 | + | ||
| 43 | +type WorkerSet = FxHashSet<WorkerKey>; | ||
| 44 | + | ||
| 45 | +/// Edge key in the continuation graph. | ||
| 46 | + | ||
| 47 | +struct TransitionKey { | ||
| 48 | + parent_hash: Option<SequenceBlockHash>, | ||
| 49 | + local_hash: LocalBlockHash, | ||
| 50 | +} | ||
| 51 | + | ||
| 52 | + | ||
| 53 | +enum EdgeOwnersEntry { | ||
| 54 | + Single { | ||
| 55 | + child_hash: SequenceBlockHash, | ||
| 56 | + owner: WorkerKey, | ||
| 57 | + }, | ||
| 58 | + Multi { | ||
| 59 | + child_hash: SequenceBlockHash, | ||
| 60 | + owners: WorkerSet, | ||
| 61 | + }, | ||
| 62 | +} | ||
| 63 | + | ||
| 64 | +impl EdgeOwnersEntry { | ||
| 65 | + fn new(child_hash: SequenceBlockHash, owner: WorkerKey) -> Self { | ||
| 66 | + Self::Single { child_hash, owner } | ||
| 67 | + } | ||
| 68 | + | ||
| 69 | + fn child_hash(&self) -> SequenceBlockHash { | ||
| 70 | + match self { | ||
| 71 | + Self::Single { child_hash, .. } | Self::Multi { child_hash, .. } => *child_hash, | ||
| 72 | + } | ||
| 73 | + } | ||
| 74 | + | ||
| 75 | + /// Insert `owner` for this edge. Returns `false` if `child_hash` conflicts | ||
| 76 | + /// with the existing mapping (first-writer wins). | ||
| 77 | + fn insert(&mut self, child_hash: SequenceBlockHash, owner: WorkerKey) -> bool { | ||
| 78 | + match self { | ||
| 79 | + Self::Single { | ||
| 80 | + child_hash: existing_hash, | ||
| 81 | + owner: existing_owner, | ||
| 82 | + } => { | ||
| 83 | + if *existing_hash != child_hash { | ||
| 84 | + return false; | ||
| 85 | + } | ||
| 86 | + if *existing_owner == owner { | ||
| 87 | + return true; | ||
| 88 | + } | ||
| 89 | + let mut owners = WorkerSet::default(); | ||
| 90 | + owners.insert(existing_owner.clone()); | ||
| 91 | + owners.insert(owner); | ||
| 92 | + *self = Self::Multi { child_hash, owners }; | ||
| 93 | + true | ||
| 94 | + } | ||
| 95 | + Self::Multi { | ||
| 96 | + child_hash: existing_hash, | ||
| 97 | + owners, | ||
| 98 | + } => { | ||
| 99 | + if *existing_hash != child_hash { | ||
| 100 | + return false; | ||
| 101 | + } | ||
| 102 | + owners.insert(owner); | ||
| 103 | + true | ||
| 104 | + } | ||
| 105 | + } | ||
| 106 | + } | ||
| 107 | + | ||
| 108 | + /// Remove `owner`. Returns `true` if the edge should be deleted. | ||
| 109 | + fn remove(&mut self, owner: &WorkerKey) -> bool { | ||
| 110 | + match self { | ||
| 111 | + Self::Single { | ||
| 112 | + owner: existing_owner, | ||
| 113 | + .. | ||
| 114 | + } => existing_owner == owner, | ||
| 115 | + Self::Multi { child_hash, owners } => { | ||
| 116 | + if !owners.remove(owner) { | ||
| 117 | + return false; | ||
| 118 | + } | ||
| 119 | + if owners.is_empty() { | ||
| 120 | + return true; | ||
| 121 | + } | ||
| 122 | + if owners.len() == 1 { | ||
| 123 | + let remaining = owners.iter().next().cloned().unwrap(); | ||
| 124 | + *self = Self::Single { | ||
| 125 | + child_hash: *child_hash, | ||
| 126 | + owner: remaining, | ||
| 127 | + }; | ||
| 128 | + } | ||
| 129 | + false | ||
| 130 | + } | ||
| 131 | + } | ||
| 132 | + } | ||
| 133 | + | ||
| 134 | + fn contains(&self, owner: &WorkerKey) -> bool { | ||
| 135 | + match self { | ||
| 136 | + Self::Single { | ||
| 137 | + owner: existing_owner, | ||
| 138 | + .. | ||
| 139 | + } => existing_owner == owner, | ||
| 140 | + Self::Multi { owners, .. } => owners.contains(owner), | ||
| 141 | + } | ||
| 142 | + } | ||
| 143 | + | ||
| 144 | + fn collect_workers(&self) -> Vec<WorkerKey> { | ||
| 145 | + match self { | ||
| 146 | + Self::Single { owner, .. } => vec![owner.clone()], | ||
| 147 | + Self::Multi { owners, .. } => owners.iter().cloned().collect(), | ||
| 148 | + } | ||
| 149 | + } | ||
| 150 | +} | ||
| 151 | + | ||
| 152 | +/// Where a lower-tier walk should resume for one worker. | ||
| 153 | + | ||
| 154 | +pub struct LowerTierContinuation { | ||
| 155 | + pub start_pos: usize, | ||
| 156 | + pub last_matched_hash: Option<SequenceBlockHash>, | ||
| 157 | +} | ||
| 158 | + | ||
| 159 | +impl LowerTierContinuation { | ||
| 160 | + pub fn new(start_pos: usize, last_matched_hash: SequenceBlockHash) -> Self { | ||
| 161 | + Self { | ||
| 162 | + start_pos, | ||
| 163 | + last_matched_hash: Some(last_matched_hash), | ||
| 164 | + } | ||
| 165 | + } | ||
| 166 | + | ||
| 167 | + pub fn from_root(start_pos: usize) -> Self { | ||
| 168 | + Self { | ||
| 169 | + start_pos, | ||
| 170 | + last_matched_hash: None, | ||
| 171 | + } | ||
| 172 | + } | ||
| 173 | +} | ||
| 174 | + | ||
| 175 | +/// Result of a contiguous lower-tier walk for one worker. | ||
| 176 | + | ||
| 177 | +pub struct ContiguousHit { | ||
| 178 | + /// Number of blocks matched from ``start_pos``. | ||
| 179 | + pub count: usize, | ||
| 180 | + /// Absolute start index in the query hash sequence. | ||
| 181 | + pub start_pos: usize, | ||
| 182 | + /// Sequence hash of the last matched block (for the next tier). | ||
| 183 | + pub last_matched_hash: Option<SequenceBlockHash>, | ||
| 184 | +} | ||
| 185 | + | ||
| 186 | +impl ContiguousHit { | ||
| 187 | + /// Absolute end index (exclusive) — next tier continues here. | ||
| 188 | + pub fn end_pos(&self) -> usize { | ||
| 189 | + self.start_pos.saturating_add(self.count) | ||
| 190 | + } | ||
| 191 | +} | ||
| 192 | + | ||
| 193 | +/// Continuation-edge index for one lower-tier medium (CPU or Disk). | ||
| 194 | + | ||
| 195 | +pub struct LowerTierIndexer { | ||
| 196 | + edges: RwLock<FxHashMap<TransitionKey, EdgeOwnersEntry>>, | ||
| 197 | + /// Per-worker reverse lookup: ``block_hash ->TransitionKey`` for O(1) remove. | ||
| 198 | + worker_blocks: RwLock<FxHashMap<WorkerKey, FxHashMap<SequenceBlockHash, TransitionKey>>>, | ||
| 199 | +} | ||
| 200 | + | ||
| 201 | +impl LowerTierIndexer { | ||
| 202 | + pub fn new() -> Self { | ||
| 203 | + Self::default() | ||
| 204 | + } | ||
| 205 | + | ||
| 206 | + /// Workers owning the root edge for ``local_hash`` (parent = None). | ||
| 207 | + pub fn root_workers(&self, local_hash: LocalBlockHash) -> Vec<WorkerKey> { | ||
| 208 | + self.edge_owners(None, local_hash) | ||
| 209 | + } | ||
| 210 | + | ||
| 211 | + /// Workers owning edge ``(parent_hash, local_hash)``. | ||
| 212 | + pub fn edge_owners( | ||
| 213 | + &self, | ||
| 214 | + parent_hash: Option<SequenceBlockHash>, | ||
| 215 | + local_hash: LocalBlockHash, | ||
| 216 | + ) -> Vec<WorkerKey> { | ||
| 217 | + let key = TransitionKey { | ||
| 218 | + parent_hash, | ||
| 219 | + local_hash, | ||
| 220 | + }; | ||
| 221 | + self.edges | ||
| 222 | + .read() | ||
| 223 | + .get(&key) | ||
| 224 | + .map(|e| e.collect_workers()) | ||
| 225 | + .unwrap_or_default() | ||
| 226 | + } | ||
| 227 | + | ||
| 228 | + /// Insert a stored chain as continuation edges. | ||
| 229 | + pub fn store_blocks(&self, worker: &WorkerKey, store_data: &KvCacheStoreData) { | ||
| 230 | + let mut parent_hash = store_data.parent_hash.map(SequenceBlockHash); | ||
| 231 | + let mut worker_blocks = self.worker_blocks.write(); | ||
| 232 | + let worker_map = worker_blocks.entry(worker.clone()).or_default(); | ||
| 233 | + let mut edges = self.edges.write(); | ||
| 234 | + | ||
| 235 | + for block in &store_data.blocks { | ||
| 236 | + let child = SequenceBlockHash(block.block_hash); | ||
| 237 | + let key = TransitionKey { | ||
| 238 | + parent_hash, | ||
| 239 | + local_hash: LocalBlockHash(block.tokens_hash), | ||
| 240 | + }; | ||
| 241 | + | ||
| 242 | + // Conflicting reverse mapping for the same block_hash ->stop chain. | ||
| 243 | + if worker_map | ||
| 244 | + .get(&child) | ||
| 245 | + .is_some_and(|existing| *existing != key) | ||
| 246 | + { | ||
| 247 | + break; | ||
| 248 | + } | ||
| 249 | + | ||
| 250 | + let inserted = match edges.get_mut(&key) { | ||
| 251 | + Some(edge) => edge.insert(child, worker.clone()), | ||
| 252 | + None => { | ||
| 253 | + edges.insert(key, EdgeOwnersEntry::new(child, worker.clone())); | ||
| 254 | + true | ||
| 255 | + } | ||
| 256 | + }; | ||
| 257 | + | ||
| 258 | + if !inserted { | ||
| 259 | + break; | ||
| 260 | + } | ||
| 261 | + | ||
| 262 | + worker_map.insert(child, key); | ||
| 263 | + parent_hash = Some(child); | ||
| 264 | + } | ||
| 265 | + } | ||
| 266 | + | ||
| 267 | + /// Remove blocks by engine sequence hash. | ||
| 268 | + pub fn remove_blocks(&self, worker: &WorkerKey, block_hashes: &[u64]) { | ||
| 269 | + let mut worker_blocks = self.worker_blocks.write(); | ||
| 270 | + let Some(worker_map) = worker_blocks.get_mut(worker) else { | ||
| 271 | + return; | ||
| 272 | + }; | ||
| 273 | + let mut edges = self.edges.write(); | ||
| 274 | + | ||
| 275 | + for &h in block_hashes { | ||
| 276 | + let seq = SequenceBlockHash(h); | ||
| 277 | + let Some(key) = worker_map.remove(&seq) else { | ||
| 278 | + continue; | ||
| 279 | + }; | ||
| 280 | + if let Some(edge) = edges.get_mut(&key) { | ||
| 281 | + if edge.remove(worker) { | ||
| 282 | + edges.remove(&key); | ||
| 283 | + } | ||
| 284 | + } | ||
| 285 | + } | ||
| 286 | + | ||
| 287 | + if worker_map.is_empty() { | ||
| 288 | + worker_blocks.remove(worker); | ||
| 289 | + } | ||
| 290 | + } | ||
| 291 | + | ||
| 292 | + /// Drop all edges owned by ``worker``. | ||
| 293 | + pub fn clear_worker(&self, worker: &WorkerKey) { | ||
| 294 | + let mut worker_blocks = self.worker_blocks.write(); | ||
| 295 | + let Some(worker_map) = worker_blocks.remove(worker) else { | ||
| 296 | + return; | ||
| 297 | + }; | ||
| 298 | + let mut edges = self.edges.write(); | ||
| 299 | + for (_, key) in worker_map { | ||
| 300 | + if let Some(edge) = edges.get_mut(&key) { | ||
| 301 | + if edge.remove(worker) { | ||
| 302 | + edges.remove(&key); | ||
| 303 | + } | ||
| 304 | + } | ||
| 305 | + } | ||
| 306 | + } | ||
| 307 | + | ||
| 308 | + /// Look up `(parent_hash, tokens_hash)` for an engine `block_hash` owned | ||
| 309 | + /// by any worker in this tier. | ||
| 310 | + /// | ||
| 311 | + /// Used when a later pool medium (e.g. Disk) confirms a block that was | ||
| 312 | + /// already indexed on another lower tier (e.g. CPU): reuse the content | ||
| 313 | + /// mapping without requiring a fresh engine offload event. | ||
| 314 | + pub fn lookup_block(&self, block_hash: u64) -> Option<(Option<u64>, u64)> { | ||
| 315 | + let seq = SequenceBlockHash(block_hash); | ||
| 316 | + let worker_blocks = self.worker_blocks.read(); | ||
| 317 | + for worker_map in worker_blocks.values() { | ||
| 318 | + if let Some(key) = worker_map.get(&seq) { | ||
| 319 | + return Some((key.parent_hash.map(|h| h.0), key.local_hash.0)); | ||
| 320 | + } | ||
| 321 | + } | ||
| 322 | + None | ||
| 323 | + } | ||
| 324 | + | ||
| 325 | + /// Whether any worker currently owns ``block_hash``. | ||
| 326 | + pub fn contains_block(&self, block_hash: u64) -> bool { | ||
| 327 | + let seq = SequenceBlockHash(block_hash); | ||
| 328 | + self.worker_blocks | ||
| 329 | + .read() | ||
| 330 | + .values() | ||
| 331 | + .any(|m| m.contains_key(&seq)) | ||
| 332 | + } | ||
| 333 | + | ||
| 334 | + /// Number of blocks tracked for ``worker``. | ||
| 335 | + pub fn worker_block_count(&self, worker: &WorkerKey) -> usize { | ||
| 336 | + self.worker_blocks | ||
| 337 | + .read() | ||
| 338 | + .get(worker) | ||
| 339 | + .map(|m| m.len()) | ||
| 340 | + .unwrap_or(0) | ||
| 341 | + } | ||
| 342 | + | ||
| 343 | + /// Total blocks across all workers (sum of reverse-lookup sizes). | ||
| 344 | + pub fn total_blocks(&self) -> usize { | ||
| 345 | + self.worker_blocks.read().values().map(|m| m.len()).sum() | ||
| 346 | + } | ||
| 347 | + | ||
| 348 | + /// All workers that currently own at least one edge. | ||
| 349 | + pub fn worker_keys(&self) -> Vec<WorkerKey> { | ||
| 350 | + self.worker_blocks.read().keys().cloned().collect() | ||
| 351 | + } | ||
| 352 | + | ||
| 353 | + pub fn is_empty(&self) -> bool { | ||
| 354 | + self.worker_blocks.read().is_empty() | ||
| 355 | + } | ||
| 356 | + | ||
| 357 | + /// For each worker, walk contiguous lower-tier hits from its continuations. | ||
| 358 | + /// | ||
| 359 | + /// A worker may have several candidate continuations (a root walk plus one | ||
| 360 | + /// or more upstream-breakpoint continuations). Each candidate is walked | ||
| 361 | + /// independently and the one with the **farthest absolute end** wins — | ||
| 362 | + /// coverage semantics stay correct no matter which candidate is longer. | ||
| 363 | + pub fn query_contiguous_hits( | ||
| 364 | + &self, | ||
| 365 | + local_hashes: &[LocalBlockHash], | ||
| 366 | + continuations: &FxHashMap<WorkerKey, Vec<LowerTierContinuation>>, | ||
| 367 | + ) -> FxHashMap<WorkerKey, ContiguousHit> { | ||
| 368 | + let mut hits = FxHashMap::default(); | ||
| 369 | + let edges = self.edges.read(); | ||
| 370 | + | ||
| 371 | + for (worker, conts) in continuations { | ||
| 372 | + let mut best: Option<ContiguousHit> = None; | ||
| 373 | + for cont in conts { | ||
| 374 | + let mut cur_pos = cont.start_pos; | ||
| 375 | + let mut cur_hash = cont.last_matched_hash; | ||
| 376 | + let start = cur_pos; | ||
| 377 | + | ||
| 378 | + while cur_pos < local_hashes.len() { | ||
| 379 | + let key = TransitionKey { | ||
| 380 | + parent_hash: cur_hash, | ||
| 381 | + local_hash: local_hashes[cur_pos], | ||
| 382 | + }; | ||
| 383 | + let Some(edge) = edges.get(&key) else { | ||
| 384 | + break; | ||
| 385 | + }; | ||
| 386 | + if !edge.contains(worker) { | ||
| 387 | + break; | ||
| 388 | + } | ||
| 389 | + cur_hash = Some(edge.child_hash()); | ||
| 390 | + cur_pos += 1; | ||
| 391 | + } | ||
| 392 | + | ||
| 393 | + let hit = ContiguousHit { | ||
| 394 | + count: cur_pos.saturating_sub(start), | ||
| 395 | + start_pos: start, | ||
| 396 | + last_matched_hash: if cur_pos > start { cur_hash } else { None }, | ||
| 397 | + }; | ||
| 398 | + // Keep the candidate with the farthest absolute end (ties: | ||
| 399 | + // later candidate wins — same end implies the same last hash). | ||
| 400 | + best = match best { | ||
| 401 | + Some(b) if hit.end_pos() >= b.end_pos() => Some(hit), | ||
| 402 | + Some(b) => Some(b), | ||
| 403 | + None => Some(hit), | ||
| 404 | + }; | ||
| 405 | + } | ||
| 406 | + if let Some(b) = best { | ||
| 407 | + hits.insert(worker.clone(), b); | ||
| 408 | + } | ||
| 409 | + } | ||
| 410 | + | ||
| 411 | + hits | ||
| 412 | + } | ||
| 413 | +} | ||
| 414 | + | ||
| 415 | + | ||
| 416 | +mod tests { | ||
| 417 | + use super::*; | ||
| 418 | + use crate::protocols::{KvCacheStoredBlockData, StorageMedium}; | ||
| 419 | + | ||
| 420 | + fn worker(id: &str) -> WorkerKey { | ||
| 421 | + WorkerKey { | ||
| 422 | + instance_id: id.into(), | ||
| 423 | + backend_id: id.into(), | ||
| 424 | + dp_rank: 0, | ||
| 425 | + medium: StorageMedium::Cpu, | ||
| 426 | + } | ||
| 427 | + } | ||
| 428 | + | ||
| 429 | + fn store(parent: Option<u64>, blocks: &[(u64, u64)]) -> KvCacheStoreData { | ||
| 430 | + KvCacheStoreData { | ||
| 431 | + parent_hash: parent, | ||
| 432 | + start_position: None, | ||
| 433 | + blocks: blocks | ||
| 434 | + .iter() | ||
| 435 | + .map(|&(bh, th)| KvCacheStoredBlockData { | ||
| 436 | + block_hash: bh, | ||
| 437 | + tokens_hash: th, | ||
| 438 | + }) | ||
| 439 | + .collect(), | ||
| 440 | + } | ||
| 441 | + } | ||
| 442 | + | ||
| 443 | + | ||
| 444 | + fn root_chain_full_match() { | ||
| 445 | + let idx = LowerTierIndexer::new(); | ||
| 446 | + let w = worker("w1"); | ||
| 447 | + idx.store_blocks(&w, &store(None, &[(101, 11), (102, 12)])); | ||
| 448 | + | ||
| 449 | + let mut conts = FxHashMap::default(); | ||
| 450 | + conts.insert(w.clone(), vec![LowerTierContinuation::from_root(0)]); | ||
| 451 | + let hits = idx.query_contiguous_hits(&[LocalBlockHash(11), LocalBlockHash(12)], &conts); | ||
| 452 | + assert_eq!(hits.get(&w).map(|h| h.count), Some(2)); | ||
| 453 | + } | ||
| 454 | + | ||
| 455 | + | ||
| 456 | + fn mid_chain_continuation_from_parent() { | ||
| 457 | + let idx = LowerTierIndexer::new(); | ||
| 458 | + let w = worker("w1"); | ||
| 459 | + // Tail only: parent=999, then local 21,22 | ||
| 460 | + idx.store_blocks(&w, &store(Some(999), &[(201, 21), (202, 22)])); | ||
| 461 | + | ||
| 462 | + let mut conts = FxHashMap::default(); | ||
| 463 | + conts.insert( | ||
| 464 | + w.clone(), | ||
| 465 | + vec![LowerTierContinuation::new(2, SequenceBlockHash(999))], | ||
| 466 | + ); | ||
| 467 | + let query = [ | ||
| 468 | + LocalBlockHash(1), | ||
| 469 | + LocalBlockHash(2), | ||
| 470 | + LocalBlockHash(21), | ||
| 471 | + LocalBlockHash(22), | ||
| 472 | + ]; | ||
| 473 | + let hits = idx.query_contiguous_hits(&query, &conts); | ||
| 474 | + assert_eq!(hits.get(&w).map(|h| h.count), Some(2)); | ||
| 475 | + assert_eq!( | ||
| 476 | + hits.get(&w).and_then(|h| h.last_matched_hash), | ||
| 477 | + Some(SequenceBlockHash(202)) | ||
| 478 | + ); | ||
| 479 | + assert_eq!(hits.get(&w).map(|h| h.end_pos()), Some(4)); | ||
| 480 | + } | ||
| 481 | + | ||
| 482 | + | ||
| 483 | + fn remove_breaks_contiguous_walk() { | ||
| 484 | + let idx = LowerTierIndexer::new(); | ||
| 485 | + let w = worker("w1"); | ||
| 486 | + idx.store_blocks(&w, &store(None, &[(101, 11), (102, 12), (103, 13)])); | ||
| 487 | + idx.remove_blocks(&w, &[102]); | ||
| 488 | + | ||
| 489 | + let mut conts = FxHashMap::default(); | ||
| 490 | + conts.insert(w.clone(), vec![LowerTierContinuation::from_root(0)]); | ||
| 491 | + let hits = idx.query_contiguous_hits( | ||
| 492 | + &[LocalBlockHash(11), LocalBlockHash(12), LocalBlockHash(13)], | ||
| 493 | + &conts, | ||
| 494 | + ); | ||
| 495 | + // First edge remains; walk stops at missing middle edge. | ||
| 496 | + assert_eq!(hits.get(&w).map(|h| h.count), Some(1)); | ||
| 497 | + } | ||
| 498 | + | ||
| 499 | + | ||
| 500 | + fn shared_edge_remove_preserves_other_owner() { | ||
| 501 | + let idx = LowerTierIndexer::new(); | ||
| 502 | + let a = worker("a"); | ||
| 503 | + let b = worker("b"); | ||
| 504 | + idx.store_blocks(&a, &store(None, &[(101, 11), (102, 12)])); | ||
| 505 | + idx.store_blocks(&b, &store(None, &[(101, 11), (102, 12)])); | ||
| 506 | + idx.remove_blocks(&a, &[101, 102]); | ||
| 507 | + | ||
| 508 | + let mut conts = FxHashMap::default(); | ||
| 509 | + conts.insert(a.clone(), vec![LowerTierContinuation::from_root(0)]); | ||
| 510 | + conts.insert(b.clone(), vec![LowerTierContinuation::from_root(0)]); | ||
| 511 | + let hits = idx.query_contiguous_hits(&[LocalBlockHash(11), LocalBlockHash(12)], &conts); | ||
| 512 | + assert_eq!(hits.get(&a).map(|h| h.count), Some(0)); | ||
| 513 | + assert_eq!(hits.get(&b).map(|h| h.count), Some(2)); | ||
| 514 | + } | ||
| 515 | +} | ||
根起步仅 worker 拥第一条边时能继续,若根边不存在但后续边存在,查询会漏,请确认是否预期。 ![]() ![]() | |||
| @@ -21,7 +21,6 @@ use clap::Parser; | |||
| 21 | use tracing_subscriber::fmt::time::OffsetTime; | 21 | use tracing_subscriber::fmt::time::OffsetTime; |
| 22 | use tracing_subscriber::EnvFilter; | 22 | use tracing_subscriber::EnvFilter; |
| 23 | 23 | ||
| 24 | -use kv_conductor::protocols::ScoringConfig; | ||
| 25 | use kv_conductor::registry::WorkerRegistry; | 24 | use kv_conductor::registry::WorkerRegistry; |
| 26 | use kv_conductor::server::{create_router, AppState}; | 25 | use kv_conductor::server::{create_router, AppState}; |
| 27 | 26 | ||
| @@ -37,18 +36,6 @@ struct Cli { | |||
| 37 | /// Port to listen on | 36 | /// Port to listen on |
| 38 | 37 | ||
| 39 | port: u16, | 38 | port: u16, |
| 40 | - | ||
| 41 | - /// Score per matched HBM/XPU block | ||
| 42 | - | ||
| 43 | - hbm_weight: u32, | ||
| 44 | - | ||
| 45 | - /// Score per matched CPU block | ||
| 46 | - | ||
| 47 | - cpu_weight: u32, | ||
| 48 | - | ||
| 49 | - /// Score per matched disk block | ||
| 50 | - | ||
| 51 | - disk_weight: u32, | ||
| 52 | } | 39 | } |
| 53 | 40 | ||
| 54 | 41 | ||
| @@ -70,20 +57,8 @@ async fn main() { | |||
| 70 | let host: IpAddr = cli.host.parse().expect("invalid host address"); | 57 | let host: IpAddr = cli.host.parse().expect("invalid host address"); |
| 71 | let addr = SocketAddr::new(host, cli.port); | 58 | let addr = SocketAddr::new(host, cli.port); |
| 72 | 59 | ||
| 73 | - let scoring = ScoringConfig { | 60 | + let registry = Arc::new(WorkerRegistry::new()); |
| 74 | - hbm_weight: cli.hbm_weight, | 61 | + let state = AppState { registry }; |
| 75 | - cpu_weight: cli.cpu_weight, | ||
| 76 | - disk_weight: cli.disk_weight, | ||
| 77 | - }; | ||
| 78 | - tracing::info!( | ||
| 79 | - hbm_weight = scoring.hbm_weight, | ||
| 80 | - cpu_weight = scoring.cpu_weight, | ||
| 81 | - disk_weight = scoring.disk_weight, | ||
| 82 | - "scoring config" | ||
| 83 | - ); | ||
| 84 | - | ||
| 85 | - let registry = Arc::new(WorkerRegistry::new(scoring.clone())); | ||
| 86 | - let state = AppState { registry, scoring }; | ||
| 87 | let router = create_router(state); | 62 | let router = create_router(state); |
| 88 | 63 | ||
| 89 | tracing::info!("KV conductor starting on {}", addr); | 64 | tracing::info!("KV conductor starting on {}", addr); |
| @@ -1,16 +1,28 @@ | |||
| 1 | -// Copyright (c) Huawei Technologies Co., Ltd. 2025-2026. All rights reserved. | 1 | +// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. |
| 2 | -// MindIE is licensed under Mulan PSL v2. | 2 | +// SPDX-FileCopyrightText: Copyright (c) Huawei Technologies Co., Ltd. 2025-2026. All rights reserved. |
| 3 | -// You can use this software according to the terms and conditions of the Mulan PSL v2. | 3 | +// |
| 4 | -// You may obtain a copy of Mulan PSL v2 at: | 4 | +// Portions of this file (hash newtypes, KV cache event / store payloads, and |
| 5 | -// http://license.coscl.org.cn/MulanPSL2 | 5 | +// overlap-match result types) are a Derivative Work of NVIDIA Dynamo |
| 6 | -// THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, | 6 | +// kv-router lib/kv-router/src/protocols.rs, licensed under Apache-2.0. |
| 7 | -// EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, | 7 | +// Upstream project: https://github.com/ai-dynamo/dynamo |
| 8 | -// MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. | 8 | +// SPDX-License-Identifier: Apache-2.0 |
| 9 | -// See the Mulan PSL v2 for more details. | 9 | +// |
| 10 | +// You may obtain a copy of the Apache License at: | ||
| 11 | +// http://www.apache.org/licenses/LICENSE-2.0 | ||
| 12 | +// Local copy: licenses/Apache-2.0.txt | ||
| 13 | +// Attribution: THIRD_PARTY_NOTICES.md | ||
| 14 | +// | ||
| 15 | +// MindIE HTTP API types (register / query / health, WorkerKey, StorageMedium | ||
| 16 | +// parsing, HbmIpIndex, etc.) and other Huawei modifications are also | ||
| 17 | +// available under Mulan PSL v2 (http://license.coscl.org.cn/MulanPSL2). | ||
| 18 | +// Redistribution of the Dynamo-derived portions must still comply with | ||
| 19 | +// Apache License 2.0. | ||
| 10 | 20 | ||
| 11 | //! Protocol types for the KV conductor service. | 21 | //! Protocol types for the KV conductor service. |
| 12 | //! | 22 | //! |
| 13 | -//! These types define the HTTP API contract, compatible with the Python | 23 | +//! Hash / KV-event payload types are derived from NVIDIA Dynamo kv-router |
| 24 | +//! `lib/kv-router/src/protocols.rs` (Apache-2.0); see `THIRD_PARTY_NOTICES.md`. | ||
| 25 | +//! HTTP API types are the MindIE conductor contract used by Python | ||
| 14 | //! `ConductorApiClient` in `motor/coordinator/api_client/`. | 26 | //! `ConductorApiClient` in `motor/coordinator/api_client/`. |
| 15 | 27 | ||
| 16 | use std::collections::HashMap; | 28 | use std::collections::HashMap; |
| @@ -21,6 +33,8 @@ use rustc_hash::FxHashMap; | |||
| 21 | use serde::{Deserialize, Serialize}; | 33 | use serde::{Deserialize, Serialize}; |
| 22 | use tracing; | 34 | use tracing; |
| 23 | 35 | ||
| 36 | +use crate::hashing::compute_block_hash_for_seq; | ||
| 37 | + | ||
| 24 | // --------------------------------------------------------------------------- | 38 | // --------------------------------------------------------------------------- |
| 25 | // Shared types | 39 | // Shared types |
| 26 | // --------------------------------------------------------------------------- | 40 | // --------------------------------------------------------------------------- |
| @@ -73,9 +87,10 @@ impl<'de> Deserialize<'de> for LocalBlockHash { | |||
| 73 | )] | 87 | )] |
| 74 | 88 | ||
| 75 | pub enum StorageMedium { | 89 | pub enum StorageMedium { |
| 76 | - /// GPU/NPU HBM — from inference engine workers. | 90 | + /// NPU HBM — from inference engine workers. |
| 91 | + /// Wire aliases `gpu` / `xpu` / `hbm` / `device` still parse to this variant. | ||
| 77 | 92 | ||
| 78 | - Xpu, | 93 | + Npu, |
| 79 | /// Host DDR / CPU pinned memory — from Mooncake master (MEMORY replica). | 94 | /// Host DDR / CPU pinned memory — from Mooncake master (MEMORY replica). |
| 80 | Cpu, | 95 | Cpu, |
| 81 | /// SSD / DFS / NVMe disk — from Mooncake master (DISK replica). | 96 | /// SSD / DFS / NVMe disk — from Mooncake master (DISK replica). |
| @@ -87,7 +102,9 @@ pub enum StorageMedium { | |||
| 87 | impl StorageMedium { | 102 | impl StorageMedium { |
| 88 | pub fn parse(s: &str) -> Self { | 103 | pub fn parse(s: &str) -> Self { |
| 89 | match s { | 104 | match s { |
| 90 | - "xpu" | "XPU" | "hbm" | "HBM" | "device" | "DEVICE" => Self::Xpu, | 105 | + "npu" | "NPU" | "gpu" | "GPU" | "xpu" | "XPU" | "hbm" | "HBM" | "device" | "DEVICE" => { |
| 106 | + Self::Npu | ||
| 107 | + } | ||
| 91 | "cpu" | "CPU" | "cpu_pinned" | "CPU_PINNED" | "host" | "HOST" | "memory" | "MEMORY" => { | 108 | "cpu" | "CPU" | "cpu_pinned" | "CPU_PINNED" | "host" | "HOST" | "memory" | "MEMORY" => { |
| 92 | Self::Cpu | 109 | Self::Cpu |
| 93 | } | 110 | } |
| @@ -100,36 +117,16 @@ impl StorageMedium { | |||
| 100 | 117 | ||
| 101 | pub fn as_str(&self) -> &'static str { | 118 | pub fn as_str(&self) -> &'static str { |
| 102 | match self { | 119 | match self { |
| 103 | - Self::Xpu => "XPU", | 120 | + Self::Npu => "NPU", |
| 104 | Self::Cpu => "CPU", | 121 | Self::Cpu => "CPU", |
| 105 | Self::Disk => "DISK", | 122 | Self::Disk => "DISK", |
| 106 | Self::Unknown => "UNKNOWN", | 123 | Self::Unknown => "UNKNOWN", |
| 107 | } | 124 | } |
| 108 | } | 125 | } |
| 109 | -} | ||
| 110 | 126 | ||
| 111 | -// --------------------------------------------------------------------------- | 127 | + /// Whether `s` names the device-HBM tier (npu / gpu / xpu / hbm / device). |
| 112 | -// Scoring configuration | 128 | + pub fn is_hbm_key(s: &str) -> bool { |
| 113 | -// --------------------------------------------------------------------------- | 129 | + matches!(Self::parse(s), Self::Npu) |
| 114 | - | ||
| 115 | -/// Per-medium block match weights, configurable at startup. | ||
| 116 | - | ||
| 117 | -pub struct ScoringConfig { | ||
| 118 | - /// Weight for each HBM (XPU) block matched. | ||
| 119 | - pub hbm_weight: u32, | ||
| 120 | - /// Weight for each CPU block matched. | ||
| 121 | - pub cpu_weight: u32, | ||
| 122 | - /// Weight for each disk block matched. | ||
| 123 | - pub disk_weight: u32, | ||
| 124 | -} | ||
| 125 | - | ||
| 126 | -impl Default for ScoringConfig { | ||
| 127 | - fn default() -> Self { | ||
| 128 | - Self { | ||
| 129 | - hbm_weight: 3, | ||
| 130 | - cpu_weight: 2, | ||
| 131 | - disk_weight: 1, | ||
| 132 | - } | ||
| 133 | } | 130 | } |
| 134 | } | 131 | } |
| 135 | 132 | ||
| @@ -150,7 +147,7 @@ pub struct WorkerKey { | |||
| 150 | /// RFC #1527: backend that owns the KV blocks (engine worker, Mooncake daemon, etc.). | 147 | /// RFC #1527: backend that owns the KV blocks (engine worker, Mooncake daemon, etc.). |
| 151 | pub backend_id: String, | 148 | pub backend_id: String, |
| 152 | pub dp_rank: DpRank, | 149 | pub dp_rank: DpRank, |
| 153 | - /// RFC #1527: cache medium (xpu, cpu, disk). | 150 | + /// RFC #1527: cache medium (npu, cpu, disk). |
| 154 | pub medium: StorageMedium, | 151 | pub medium: StorageMedium, |
| 155 | } | 152 | } |
| 156 | 153 | ||
| @@ -163,7 +160,7 @@ pub struct WorkerKey { | |||
| 163 | pub struct RegisterRequest { | 160 | pub struct RegisterRequest { |
| 164 | pub instance_id: InstanceId, | 161 | pub instance_id: InstanceId, |
| 165 | /// Per-medium ZMQ PUB endpoints (new protocol). | 162 | /// Per-medium ZMQ PUB endpoints (new protocol). |
| 166 | - /// e.g. {"xpu": "tcp://...:5557", "cpu": "tcp://...:5558"}. | 163 | + /// e.g. {"npu": "tcp://...:5557", "cpu": "tcp://...:5558"}. |
| 167 | /// Multiple media may share the same endpoint URL; the conductor deduplicates. | 164 | /// Multiple media may share the same endpoint URL; the conductor deduplicates. |
| 168 | /// When empty, falls back to the legacy `endpoint` field. | 165 | /// When empty, falls back to the legacy `endpoint` field. |
| 169 | 166 | ||
| @@ -236,34 +233,27 @@ pub struct QueryByHashRequest { | |||
| 236 | // Query response types (matching Python client expectations) | 233 | // Query response types (matching Python client expectations) |
| 237 | // | 234 | // |
| 238 | // Python reads: | 235 | // Python reads: |
| 239 | -// rsp[tenant_id][instance_id]["longest_matched"] (in tokens) | 236 | +// rsp[tenant_id][instance_id]["longest_matched"] (in tokens) |
| 240 | -// rsp[tenant_id][instance_id]["DP"][dp_rank_str] (in tokens) | 237 | +// rsp[tenant_id][instance_id]["DP"][dp_rank_str] (DpBlocks obj: |
| 238 | +// matched_tokens / npu_blocks / cpu_blocks / disk_blocks) | ||
| 241 | // --------------------------------------------------------------------------- | 239 | // --------------------------------------------------------------------------- |
| 242 | 240 | ||
| 243 | -/// Per-DP weighted score breakdown across storage media. | 241 | +/// Per-DP matched block counts across storage media. |
| 244 | 242 | ||
| 245 | -pub struct DpScoring { | 243 | +pub struct DpBlocks { |
| 246 | - /// HBM matched blocks × 3 | 244 | + /// Cached prefix length for this DP, in tokens: |
| 247 | - #[serde(rename = "XPU")] | 245 | + /// farthest absolute coverage end across media × `block_size`. |
| 248 | - pub xpu_score: u32, | 246 | + /// |
| 249 | - /// CPU matched blocks × 2 | 247 | + /// Per-medium `*_blocks` are segment lengths (HBM from root; CPU/Disk |
| 250 | - #[serde(rename = "CPU")] | 248 | + /// from their continuation **or** an unconditional root chain — replicas |
| 251 | - pub cpu_score: u32, | 249 | + /// are reported on every tier). `matched_tokens` must not sum |
| 252 | - /// Disk matched blocks × 1 | 250 | + /// overlapping replicas of the same prefix. |
| 253 | - | ||
| 254 | - pub disk_score: u32, | ||
| 255 | - /// Total weighted score (xpu_score + cpu_score + disk_score) | ||
| 256 | - pub total: u32, | ||
| 257 | - /// Cached prefix length for this DP, in tokens (blocks × block_size). | ||
| 258 | pub matched_tokens: u32, | 251 | pub matched_tokens: u32, |
| 259 | - /// HBM raw block count. | 252 | + /// HBM (NPU) matched block count. |
| 260 | - #[serde(rename = "XPU_blk")] | 253 | + pub npu_blocks: u32, |
| 261 | - pub xpu_blocks: u32, | 254 | + /// CPU matched block count. |
| 262 | - /// CPU raw block count. | ||
| 263 | - | ||
| 264 | pub cpu_blocks: u32, | 255 | pub cpu_blocks: u32, |
| 265 | - /// Disk raw block count. | 256 | + /// Disk matched block count. |
| 266 | - | ||
| 267 | pub disk_blocks: u32, | 257 | pub disk_blocks: u32, |
| 268 | } | 258 | } |
| 269 | 259 | ||
| @@ -272,11 +262,9 @@ pub struct DpScoring { | |||
| 272 | pub struct InstanceMatchData { | 262 | pub struct InstanceMatchData { |
| 273 | /// Longest continuous prefix match across all DP ranks, in tokens. | 263 | /// Longest continuous prefix match across all DP ranks, in tokens. |
| 274 | pub longest_matched: u32, | 264 | pub longest_matched: u32, |
| 275 | - /// Per-DP-rank scoring breakdown across media. | 265 | + /// Per-DP-rank matched block counts across media. |
| 276 | 266 | ||
| 277 | - pub dp: HashMap<String, DpScoring>, | 267 | + pub dp: HashMap<String, DpBlocks>, |
| 278 | - /// Sum of all DP total scores for this instance. | ||
| 279 | - pub total_score: u32, | ||
| 280 | } | 268 | } |
| 281 | 269 | ||
| 282 | /// Full query response: { tenant_id: { instance_id: InstanceMatchData } } | 270 | /// Full query response: { tenant_id: { instance_id: InstanceMatchData } } |
| @@ -292,12 +280,12 @@ pub struct QueryResponse { | |||
| 292 | 280 | ||
| 293 | /// Batch of KV cache events from workers. | 281 | /// Batch of KV cache events from workers. |
| 294 | /// | 282 | /// |
| 295 | -/// Routing context (`instance_id`, `model_name`, `tenant_id`, `block_size`) | 283 | +/// Routing context (`instance_id`, `model_name`, `tenant_id`) identifies the |
| 296 | -/// identifies the originating worker and the model/tenant scope for indexer | 284 | +/// originating worker and the model/tenant scope for indexer lookup. When |
| 297 | -/// lookup. When `model_name` / `tenant_id` are omitted and the instance is | 285 | +/// `model_name` / `tenant_id` are omitted and the instance is already |
| 298 | -/// already registered, the registered values are used as a fallback. | 286 | +/// registered, the registered values are used as a fallback. `block_size` |
| 299 | -/// `block_size` defaults to 128 when neither the batch nor a prior | 287 | +/// is carried for wire compatibility; the HTTP events path does not consume |
| 300 | -/// registration provides it. | 288 | +/// it (query uses the registered value). |
| 301 | 289 | ||
| 302 | pub struct KvEventBatch { | 290 | pub struct KvEventBatch { |
| 303 | /// The worker instance these events originate from. | 291 | /// The worker instance these events originate from. |
| @@ -308,7 +296,9 @@ pub struct KvEventBatch { | |||
| 308 | /// Tenant id for indexer routing (falls back to registered value if omitted). | 296 | /// Tenant id for indexer routing (falls back to registered value if omitted). |
| 309 | 297 | ||
| 310 | pub tenant_id: Option<String>, | 298 | pub tenant_id: Option<String>, |
| 311 | - /// KV block size in tokens (falls back to registered value, then 128). | 299 | + /// KV block size in tokens. The HTTP events path currently does not |
| 300 | + /// consume this field (query uses the registered `block_size`); the | ||
| 301 | + /// serde default of 128 keeps the wire type stable. | ||
| 312 | 302 | ||
| 313 | pub block_size: u32, | 303 | pub block_size: u32, |
| 314 | 304 | ||
| @@ -355,14 +345,24 @@ pub struct KvEventWirePayload { | |||
| 355 | /// Engine-style: blocks with block_hash + tokens_hash. | 345 | /// Engine-style: blocks with block_hash + tokens_hash. |
| 356 | pub blocks: Vec<KvCacheStoredBlockData>, | 346 | pub blocks: Vec<KvCacheStoredBlockData>, |
| 357 | /// Engine-style: parent sequence hash. | 347 | /// Engine-style: parent sequence hash. |
| 348 | + /// Accepts both the RFC #1527 field name `parent_hash` and the vLLM | ||
| 349 | + /// engine field name `parent_block_hash`. | ||
| 350 | + | ||
| 358 | pub parent_hash: Option<i64>, | 351 | pub parent_hash: Option<i64>, |
| 352 | + /// Engine-style: raw token ids of the stored chain, used to recompute | ||
| 353 | + /// `tokens_hash` (XXH3) when the event carries no pre-computed blocks. | ||
| 354 | + | ||
| 355 | + pub token_ids: Vec<i64>, | ||
| 356 | + /// Engine-style: block size in tokens, must pair with `token_ids`. | ||
| 357 | + | ||
| 358 | + pub block_size: Option<u32>, | ||
| 359 | /// RFC #1527: rolling sequence hashes. | 359 | /// RFC #1527: rolling sequence hashes. |
| 360 | pub seq_hashes: Vec<u64>, | 360 | pub seq_hashes: Vec<u64>, |
| 361 | - /// RFC #1527: cache medium (xpu, cpu, disk). | 361 | + /// RFC #1527: cache medium (npu, cpu, disk). |
| 362 | pub medium: Option<String>, | 362 | pub medium: Option<String>, |
| 363 | /// RFC #1527: backend that owns the blocks. | 363 | /// RFC #1527: backend that owns the blocks. |
| 364 | pub backend_id: Option<String>, | 364 | pub backend_id: Option<String>, |
| 365 | - /// Legacy compat: block_hashes (vLLM/Dynamo alias for seq_hashes). | 365 | + /// Legacy compat: block_hashes (vLLM / engine alias for seq_hashes). |
| 366 | 366 | ||
| 367 | pub block_hashes: Vec<u64>, | 367 | pub block_hashes: Vec<u64>, |
| 368 | /// Legacy compat: old event type string (e.g. "BlockStored"). | 368 | /// Legacy compat: old event type string (e.g. "BlockStored"). |
| @@ -387,6 +387,18 @@ impl KvEventWirePayload { | |||
| 387 | "stored" => { | 387 | "stored" => { |
| 388 | let blocks: Vec<KvCacheStoredBlockData> = if !self.blocks.is_empty() { | 388 | let blocks: Vec<KvCacheStoredBlockData> = if !self.blocks.is_empty() { |
| 389 | self.blocks.clone() | 389 | self.blocks.clone() |
| 390 | + } else if !self.token_ids.is_empty() && self.block_size.is_some_and(|bs| bs > 0) { | ||
| 391 | + // Engine-style event (vLLM map/JSON): recompute the XXH3 | ||
| 392 | + // content hash from token_ids, same as the ZMQ path. | ||
| 393 | + let bs = self.block_size.unwrap_or(0); | ||
| 394 | + let computed = compute_block_hash_for_seq(&self.token_ids, bs); | ||
| 395 | + let num = computed.len().min(self.block_hashes.len()); | ||
| 396 | + (0..num) | ||
| 397 | + .map(|i| KvCacheStoredBlockData { | ||
| 398 | + block_hash: self.block_hashes[i], | ||
| 399 | + tokens_hash: computed[i].0, | ||
| 400 | + }) | ||
| 401 | + .collect() | ||
| 390 | } else { | 402 | } else { |
| 391 | // Build engine-style blocks from seq_hashes (Mooncake path: no tokens_hash) | 403 | // Build engine-style blocks from seq_hashes (Mooncake path: no tokens_hash) |
| 392 | seq_hashes | 404 | seq_hashes |
| @@ -397,13 +409,10 @@ impl KvEventWirePayload { | |||
| 397 | }) | 409 | }) |
| 398 | .collect() | 410 | .collect() |
| 399 | }; | 411 | }; |
| 400 | - let parent_hash = if !self.blocks.is_empty() { | ||
| 401 | - self.parent_hash | ||
| 402 | - } else { | ||
| 403 | - None // Mooncake events have no parent | ||
| 404 | - }; | ||
| 405 | KvCacheEventData::Stored(KvCacheStoreData { | 412 | KvCacheEventData::Stored(KvCacheStoreData { |
| 406 | - parent_hash: parent_hash.map(|h| h as u64), | 413 | + // Engine events carry `parent_block_hash` (aliased into |
| 414 | + // `parent_hash`); RFC #1527 pool events have no parent. | ||
| 415 | + parent_hash: self.parent_hash.map(|h| h as u64), | ||
| 407 | start_position: None, | 416 | start_position: None, |
| 408 | blocks, | 417 | blocks, |
| 409 | }) | 418 | }) |
| @@ -442,15 +451,31 @@ impl KvEventWirePayload { | |||
| 442 | (data, self.medium.clone(), self.backend_id.clone()) | 451 | (data, self.medium.clone(), self.backend_id.clone()) |
| 443 | } | 452 | } |
| 444 | 453 | ||
| 454 | + /// Resolve the event type to one of `stored` / `removed` / `cleared`. | ||
| 455 | + /// | ||
| 456 | + /// Both the canonical wire names ("stored", "removed", "cleared") and the | ||
| 457 | + /// engine class names ("BlockStored", "BlockRemoved", "AllBlocksCleared") | ||
| 458 | + /// are recognized via keyword matching, so a vLLM-style event pushed over | ||
| 459 | + /// HTTP /events is never misclassified (a `BlockStored` must not fall into | ||
| 460 | + /// the `Removed` fallback branch). | ||
| 445 | fn resolve_event_type(&self) -> &str { | 461 | fn resolve_event_type(&self) -> &str { |
| 446 | - if !self.event_type.is_empty() { | 462 | + let raw = if !self.event_type.is_empty() { |
| 447 | - return &self.event_type; | 463 | + self.event_type.as_str() |
| 448 | - } | 464 | + } else { |
| 449 | - match &self.legacy_type { | 465 | + match &self.legacy_type { |
| 450 | - Some(t) if t.contains("Removed") || t.contains("removed") => "removed", | 466 | + Some(t) => t.as_str(), |
| 451 | - Some(t) if t.contains("Stored") || t.contains("stored") => "stored", | 467 | + None => return "unknown", |
| 452 | - Some(t) if t.contains("Cleared") || t.contains("cleared") => "cleared", | 468 | + } |
| 453 | - _ => "unknown", | 469 | + }; |
| 470 | + let lower = raw.to_ascii_lowercase(); | ||
| 471 | + if lower.contains("removed") { | ||
| 472 | + "removed" | ||
| 473 | + } else if lower.contains("stored") { | ||
| 474 | + "stored" | ||
| 475 | + } else if lower.contains("cleared") { | ||
| 476 | + "cleared" | ||
| 477 | + } else { | ||
| 478 | + "unknown" | ||
| 454 | } | 479 | } |
| 455 | } | 480 | } |
| 456 | 481 | ||
| @@ -503,42 +528,42 @@ pub struct KvCacheStoredBlockData { | |||
| 503 | // Internal types | 528 | // Internal types |
| 504 | // --------------------------------------------------------------------------- | 529 | // --------------------------------------------------------------------------- |
| 505 | 530 | ||
| 506 | -/// Overlap scores result from a radix-tree lookup. | 531 | +/// Overlap match result from a radix-tree lookup. |
| 507 | 532 | ||
| 508 | -pub struct OverlapScores { | 533 | +pub struct OverlapBlocks { |
| 509 | /// worker -> matched block count | 534 | /// worker -> matched block count |
| 510 | - pub scores: FxHashMap<WorkerKey, u32>, | 535 | + pub blocks: FxHashMap<WorkerKey, u32>, |
| 511 | } | 536 | } |
| 512 | 537 | ||
| 513 | -impl OverlapScores { | 538 | +impl OverlapBlocks { |
| 514 | pub fn is_empty(&self) -> bool { | 539 | pub fn is_empty(&self) -> bool { |
| 515 | - self.scores.is_empty() | 540 | + self.blocks.is_empty() |
| 516 | } | 541 | } |
| 517 | 542 | ||
| 518 | - /// Add points for a worker (HBM depth × 3, CPU block × 2, disk block × 1). | 543 | + /// Add matched block count for a worker. |
| 519 | 544 | ||
| 520 | - pub fn add_score(&mut self, worker: WorkerKey, points: u32) { | 545 | + pub fn add_blocks(&mut self, worker: WorkerKey, n: u32) { |
| 521 | - self.scores | 546 | + self.blocks |
| 522 | .entry(worker) | 547 | .entry(worker) |
| 523 | - .and_modify(|s| *s += points) | 548 | + .and_modify(|s| *s += n) |
| 524 | - .or_insert(points); | 549 | + .or_insert(n); |
| 525 | } | 550 | } |
| 526 | 551 | ||
| 527 | - /// Legacy max-based update, used internally by HBM tree traversal. | 552 | + /// Max-based update, used internally by HBM tree traversal. |
| 528 | 553 | ||
| 529 | - pub fn update_score(&mut self, worker: WorkerKey, depth: u32) { | 554 | + pub fn update_blocks(&mut self, worker: WorkerKey, depth: u32) { |
| 530 | - self.scores | 555 | + self.blocks |
| 531 | .entry(worker) | 556 | .entry(worker) |
| 532 | .and_modify(|s| *s = (*s).max(depth)) | 557 | .and_modify(|s| *s = (*s).max(depth)) |
| 533 | .or_insert(depth); | 558 | .or_insert(depth); |
| 534 | } | 559 | } |
| 535 | 560 | ||
| 536 | - /// Merge scores from another `OverlapScores` into this one. | 561 | + /// Merge matched blocks from another `OverlapBlocks` into this one, |
| 537 | - /// Used by parallel flat lookup to combine per-thread results. | 562 | + /// keeping the maximum per worker. |
| 538 | 563 | ||
| 539 | - pub fn merge(&mut self, other: OverlapScores) { | 564 | + pub fn merge(&mut self, other: OverlapBlocks) { |
| 540 | - for (worker, score) in other.scores { | 565 | + for (worker, n) in other.blocks { |
| 541 | - self.add_score(worker, score); | 566 | + self.add_blocks(worker, n); |
| 542 | } | 567 | } |
| 543 | } | 568 | } |
| 544 | } | 569 | } |
| @@ -553,11 +578,18 @@ mod tests { | |||
| 553 | // ── StorageMedium ───────────────────────────────────────────────── | 578 | // ── StorageMedium ───────────────────────────────────────────────── |
| 554 | 579 | ||
| 555 | 580 | ||
| 556 | - fn test_storage_medium_from_str_xpu() { | 581 | + fn test_storage_medium_from_str_npu() { |
| 557 | - assert_eq!(StorageMedium::parse("xpu"), StorageMedium::Xpu); | 582 | + assert_eq!(StorageMedium::parse("npu"), StorageMedium::Npu); |
| 558 | - assert_eq!(StorageMedium::parse("XPU"), StorageMedium::Xpu); | 583 | + assert_eq!(StorageMedium::parse("NPU"), StorageMedium::Npu); |
| 559 | - assert_eq!(StorageMedium::parse("hbm"), StorageMedium::Xpu); | 584 | + assert_eq!(StorageMedium::parse("gpu"), StorageMedium::Npu); |
| 560 | - assert_eq!(StorageMedium::parse("device"), StorageMedium::Xpu); | 585 | + assert_eq!(StorageMedium::parse("GPU"), StorageMedium::Npu); |
| 586 | + assert_eq!(StorageMedium::parse("xpu"), StorageMedium::Npu); | ||
| 587 | + assert_eq!(StorageMedium::parse("XPU"), StorageMedium::Npu); | ||
| 588 | + assert_eq!(StorageMedium::parse("hbm"), StorageMedium::Npu); | ||
| 589 | + assert_eq!(StorageMedium::parse("device"), StorageMedium::Npu); | ||
| 590 | + assert!(StorageMedium::is_hbm_key("npu")); | ||
| 591 | + assert!(StorageMedium::is_hbm_key("gpu")); | ||
| 592 | + assert!(!StorageMedium::is_hbm_key("cpu")); | ||
| 561 | } | 593 | } |
| 562 | 594 | ||
| 563 | 595 | ||
| @@ -573,13 +605,13 @@ mod tests { | |||
| 573 | } | 605 | } |
| 574 | 606 | ||
| 575 | 607 | ||
| 576 | - fn test_storage_medium_default_is_xpu() { | 608 | + fn test_storage_medium_default_is_npu() { |
| 577 | - assert_eq!(StorageMedium::default(), StorageMedium::Xpu); | 609 | + assert_eq!(StorageMedium::default(), StorageMedium::Npu); |
| 578 | } | 610 | } |
| 579 | 611 | ||
| 580 | 612 | ||
| 581 | fn test_storage_medium_as_str() { | 613 | fn test_storage_medium_as_str() { |
| 582 | - assert_eq!(StorageMedium::Xpu.as_str(), "XPU"); | 614 | + assert_eq!(StorageMedium::Npu.as_str(), "NPU"); |
| 583 | assert_eq!(StorageMedium::Cpu.as_str(), "CPU"); | 615 | assert_eq!(StorageMedium::Cpu.as_str(), "CPU"); |
| 584 | assert_eq!(StorageMedium::Disk.as_str(), "DISK"); | 616 | assert_eq!(StorageMedium::Disk.as_str(), "DISK"); |
| 585 | assert_eq!(StorageMedium::Unknown.as_str(), "UNKNOWN"); | 617 | assert_eq!(StorageMedium::Unknown.as_str(), "UNKNOWN"); |
| @@ -593,12 +625,12 @@ mod tests { | |||
| 593 | instance_id: "inst-1".into(), | 625 | instance_id: "inst-1".into(), |
| 594 | backend_id: "backend-a".into(), | 626 | backend_id: "backend-a".into(), |
| 595 | dp_rank: 2, | 627 | dp_rank: 2, |
| 596 | - medium: StorageMedium::Xpu, | 628 | + medium: StorageMedium::Npu, |
| 597 | }; | 629 | }; |
| 598 | assert_eq!(wk.instance_id, "inst-1"); | 630 | assert_eq!(wk.instance_id, "inst-1"); |
| 599 | assert_eq!(wk.backend_id, "backend-a"); | 631 | assert_eq!(wk.backend_id, "backend-a"); |
| 600 | assert_eq!(wk.dp_rank, 2); | 632 | assert_eq!(wk.dp_rank, 2); |
| 601 | - assert_eq!(wk.medium, StorageMedium::Xpu); | 633 | + assert_eq!(wk.medium, StorageMedium::Npu); |
| 602 | } | 634 | } |
| 603 | 635 | ||
| 604 | 636 | ||
| @@ -624,7 +656,7 @@ mod tests { | |||
| 624 | instance_id: "i1".into(), | 656 | instance_id: "i1".into(), |
| 625 | backend_id: "b1".into(), | 657 | backend_id: "b1".into(), |
| 626 | dp_rank: 0, | 658 | dp_rank: 0, |
| 627 | - medium: StorageMedium::Xpu, | 659 | + medium: StorageMedium::Npu, |
| 628 | }; | 660 | }; |
| 629 | let b = WorkerKey { | 661 | let b = WorkerKey { |
| 630 | instance_id: "i1".into(), | 662 | instance_id: "i1".into(), |
| @@ -639,50 +671,41 @@ mod tests { | |||
| 639 | 671 | ||
| 640 | 672 | ||
| 641 | fn test_instance_match_data_serialization() { | 673 | fn test_instance_match_data_serialization() { |
| 642 | - let mut imd = InstanceMatchData::default(); | 674 | + let mut imd = InstanceMatchData { |
| 643 | - imd.longest_matched = 256; | 675 | + longest_matched: 256, |
| 676 | + ..Default::default() | ||
| 677 | + }; | ||
| 644 | imd.dp.insert( | 678 | imd.dp.insert( |
| 645 | "0".into(), | 679 | "0".into(), |
| 646 | - DpScoring { | 680 | + DpBlocks { |
| 647 | - xpu_score: 6, | ||
| 648 | - cpu_score: 0, | ||
| 649 | - disk_score: 0, | ||
| 650 | - total: 6, | ||
| 651 | matched_tokens: 768, | 681 | matched_tokens: 768, |
| 652 | - xpu_blocks: 6, | 682 | + npu_blocks: 6, |
| 653 | cpu_blocks: 0, | 683 | cpu_blocks: 0, |
| 654 | disk_blocks: 0, | 684 | disk_blocks: 0, |
| 655 | }, | 685 | }, |
| 656 | ); | 686 | ); |
| 657 | imd.dp.insert( | 687 | imd.dp.insert( |
| 658 | "1".into(), | 688 | "1".into(), |
| 659 | - DpScoring { | 689 | + DpBlocks { |
| 660 | - xpu_score: 0, | ||
| 661 | - cpu_score: 8, | ||
| 662 | - disk_score: 0, | ||
| 663 | - total: 8, | ||
| 664 | matched_tokens: 1024, | 690 | matched_tokens: 1024, |
| 665 | - xpu_blocks: 0, | 691 | + npu_blocks: 0, |
| 666 | cpu_blocks: 4, | 692 | cpu_blocks: 4, |
| 667 | disk_blocks: 0, | 693 | disk_blocks: 0, |
| 668 | }, | 694 | }, |
| 669 | ); | 695 | ); |
| 670 | - imd.total_score = 14; | ||
| 671 | 696 | ||
| 672 | let json = serde_json::to_string(&imd).unwrap(); | 697 | let json = serde_json::to_string(&imd).unwrap(); |
| 673 | let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); | 698 | let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); |
| 674 | 699 | ||
| 675 | assert_eq!(parsed["longest_matched"], 256); | 700 | assert_eq!(parsed["longest_matched"], 256); |
| 676 | - assert_eq!(parsed["total_score"], 14); | 701 | + assert!(parsed.get("total_score").is_none()); |
| 677 | - assert_eq!(parsed["DP"]["0"]["XPU"], 6); | 702 | + assert!(parsed["DP"]["0"].get("XPU").is_none()); |
| 678 | - assert_eq!(parsed["DP"]["0"]["total"], 6); | 703 | + assert!(parsed["DP"]["0"].get("total").is_none()); |
| 679 | assert_eq!(parsed["DP"]["0"]["matched_tokens"], 768); | 704 | assert_eq!(parsed["DP"]["0"]["matched_tokens"], 768); |
| 680 | - assert_eq!(parsed["DP"]["0"]["XPU_blk"], 6); | 705 | + assert_eq!(parsed["DP"]["0"]["npu_blocks"], 6); |
| 681 | - assert!(parsed["DP"]["0"]["CPU_blk"].as_u64().unwrap() == 0); | 706 | + assert_eq!(parsed["DP"]["0"]["cpu_blocks"], 0); |
| 682 | - assert_eq!(parsed["DP"]["1"]["CPU"], 8); | ||
| 683 | - assert_eq!(parsed["DP"]["1"]["total"], 8); | ||
| 684 | assert_eq!(parsed["DP"]["1"]["matched_tokens"], 1024); | 707 | assert_eq!(parsed["DP"]["1"]["matched_tokens"], 1024); |
| 685 | - assert_eq!(parsed["DP"]["1"]["CPU_blk"], 4); | 708 | + assert_eq!(parsed["DP"]["1"]["cpu_blocks"], 4); |
| 686 | } | 709 | } |
| 687 | 710 | ||
| 688 | // ── KvEventWirePayload normalization ──────────────────────────────── | 711 | // ── KvEventWirePayload normalization ──────────────────────────────── |
| @@ -758,4 +781,72 @@ mod tests { | |||
| 758 | let (data, _, _) = payload.normalize(); | 781 | let (data, _, _) = payload.normalize(); |
| 759 | assert!(matches!(data, KvCacheEventData::Removed { .. })); | 782 | assert!(matches!(data, KvCacheEventData::Removed { .. })); |
| 760 | } | 783 | } |
| 784 | + | ||
| 785 | + // ── Engine class-name type resolution (vLLM map/JSON events) ──────── | ||
| 786 | + | ||
| 787 | + | ||
| 788 | + fn test_normalize_engine_block_stored_is_stored_not_removed() { | ||
| 789 | + // vLLM engine events pushed via HTTP /events carry `type: "BlockStored"`. | ||
| 790 | + // They must normalize to Stored — the historical fallback classified | ||
| 791 | + // them as Removed, which would delete index entries. | ||
| 792 | + let payload = KvEventWirePayload { | ||
| 793 | + event_type: "BlockStored".into(), | ||
| 794 | + block_hashes: vec![100, 200], | ||
| 795 | + parent_hash: Some(50), | ||
| 796 | + token_ids: vec![1, 2, 3, 4, 5, 6, 7, 8], | ||
| 797 | + block_size: Some(4), | ||
| 798 | + ..Default::default() | ||
| 799 | + }; | ||
| 800 | + let (data, _, _) = payload.normalize(); | ||
| 801 | + let KvCacheEventData::Stored(store) = &data else { | ||
| 802 | + panic!("BlockStored must normalize to Stored, got {data:?}"); | ||
| 803 | + }; | ||
| 804 | + assert_eq!(store.parent_hash, Some(50)); | ||
| 805 | + assert_eq!(store.blocks.len(), 2); | ||
| 806 | + // tokens_hash is the XXH3 content hash recomputed from token_ids, | ||
| 807 | + // never the sequence hash. | ||
| 808 | + let computed = compute_block_hash_for_seq(&[1, 2, 3, 4, 5, 6, 7, 8], 4); | ||
| 809 | + assert_eq!(store.blocks[0].tokens_hash, computed[0].0); | ||
| 810 | + assert_eq!(store.blocks[1].tokens_hash, computed[1].0); | ||
| 811 | + assert_ne!(store.blocks[0].tokens_hash, 100); | ||
| 812 | + } | ||
| 813 | + | ||
| 814 | + | ||
| 815 | + fn test_normalize_engine_block_removed() { | ||
| 816 | + let payload = KvEventWirePayload { | ||
| 817 | + event_type: "BlockRemoved".into(), | ||
| 818 | + block_hashes: vec![111], | ||
| 819 | + ..Default::default() | ||
| 820 | + }; | ||
| 821 | + let (data, _, _) = payload.normalize(); | ||
| 822 | + assert!(matches!(data, KvCacheEventData::Removed { .. })); | ||
| 823 | + } | ||
| 824 | + | ||
| 825 | + | ||
| 826 | + fn test_normalize_engine_all_blocks_cleared() { | ||
| 827 | + let payload = KvEventWirePayload { | ||
| 828 | + event_type: "AllBlocksCleared".into(), | ||
| 829 | + ..Default::default() | ||
| 830 | + }; | ||
| 831 | + let (data, _, _) = payload.normalize(); | ||
| 832 | + assert!(matches!(data, KvCacheEventData::Cleared)); | ||
| 833 | + } | ||
| 834 | + | ||
| 835 | + | ||
| 836 | + fn test_normalize_engine_parent_block_hash_alias() { | ||
| 837 | + // The vLLM field name `parent_block_hash` maps onto `parent_hash`. | ||
| 838 | + let payload = KvEventWirePayload { | ||
| 839 | + event_type: "BlockStored".into(), | ||
| 840 | + block_hashes: vec![100], | ||
| 841 | + parent_hash: Some(999), | ||
| 842 | + token_ids: vec![1, 2, 3, 4], | ||
| 843 | + block_size: Some(4), | ||
| 844 | + ..Default::default() | ||
| 845 | + }; | ||
| 846 | + let (data, _, _) = payload.normalize(); | ||
| 847 | + let KvCacheEventData::Stored(store) = &data else { | ||
| 848 | + panic!("expected Stored, got {data:?}"); | ||
| 849 | + }; | ||
| 850 | + assert_eq!(store.parent_hash, Some(999)); | ||
| 851 | + } | ||
| 761 | } | 852 | } |
| @@ -38,7 +38,7 @@ fn extract_ip_from_endpoint(endpoint: &str) -> Option<String> { | |||
| 38 | Some(without_prefix[..colon_pos].to_string()) | 38 | Some(without_prefix[..colon_pos].to_string()) |
| 39 | } | 39 | } |
| 40 | 40 | ||
| 41 | -/// Add `(instance_id, dp_rank)` entries to `hbm_ip_index` for each XPU endpoint. | 41 | +/// Add `(instance_id, dp_rank)` entries to `hbm_ip_index` for each NPU endpoint. |
| 42 | fn add_hbm_ip_index_entries( | 42 | fn add_hbm_ip_index_entries( |
| 43 | ip_index: &HbmIpIndex, | 43 | ip_index: &HbmIpIndex, |
| 44 | medium_endpoints: &HashMap<String, String>, | 44 | medium_endpoints: &HashMap<String, String>, |
| @@ -46,7 +46,7 @@ fn add_hbm_ip_index_entries( | |||
| 46 | dp_rank: u32, | 46 | dp_rank: u32, |
| 47 | ) { | 47 | ) { |
| 48 | for (medium_str, ep_url) in medium_endpoints { | 48 | for (medium_str, ep_url) in medium_endpoints { |
| 49 | - if !medium_str.eq_ignore_ascii_case("xpu") { | 49 | + if !StorageMedium::is_hbm_key(medium_str) { |
| 50 | continue; | 50 | continue; |
| 51 | } | 51 | } |
| 52 | let Some(ref ip) = extract_ip_from_endpoint(ep_url) else { | 52 | let Some(ref ip) = extract_ip_from_endpoint(ep_url) else { |
| @@ -61,7 +61,7 @@ fn add_hbm_ip_index_entries( | |||
| 61 | } | 61 | } |
| 62 | } | 62 | } |
| 63 | 63 | ||
| 64 | -/// For each XPU endpoint in `medium_endpoints`, remove the (instance_id, dp_rank) | 64 | +/// For each NPU endpoint in `medium_endpoints`, remove the (instance_id, dp_rank) |
| 65 | /// entry from `hbm_ip_index`. If a given IP has no remaining DPs, the IP key is | 65 | /// entry from `hbm_ip_index`. If a given IP has no remaining DPs, the IP key is |
| 66 | /// dropped entirely. | 66 | /// dropped entirely. |
| 67 | fn remove_hbm_ip_index_entries( | 67 | fn remove_hbm_ip_index_entries( |
| @@ -71,7 +71,7 @@ fn remove_hbm_ip_index_entries( | |||
| 71 | dp_rank: u32, | 71 | dp_rank: u32, |
| 72 | ) { | 72 | ) { |
| 73 | for (medium_str, ep_url) in medium_endpoints { | 73 | for (medium_str, ep_url) in medium_endpoints { |
| 74 | - if !medium_str.eq_ignore_ascii_case("xpu") { | 74 | + if !StorageMedium::is_hbm_key(medium_str) { |
| 75 | continue; | 75 | continue; |
| 76 | } | 76 | } |
| 77 | let Some(ref ip) = extract_ip_from_endpoint(ep_url) else { | 77 | let Some(ref ip) = extract_ip_from_endpoint(ep_url) else { |
| @@ -91,7 +91,7 @@ fn remove_hbm_ip_index_entries( | |||
| 91 | /// Information about a registered endpoint for a worker. | 91 | /// Information about a registered endpoint for a worker. |
| 92 | 92 | ||
| 93 | pub struct EndpointInfo { | 93 | pub struct EndpointInfo { |
| 94 | - /// Per-medium ZMQ PUB endpoints, e.g. {"xpu": "tcp://...:5557", "cpu": "tcp://...:5558"}. | 94 | + /// Per-medium ZMQ PUB endpoints, e.g. {"npu": "tcp://...:5557", "cpu": "tcp://...:5558"}. |
| 95 | pub medium_endpoints: HashMap<String, String>, | 95 | pub medium_endpoints: HashMap<String, String>, |
| 96 | pub engine_type: String, | 96 | pub engine_type: String, |
| 97 | pub dp_rank: DpRank, | 97 | pub dp_rank: DpRank, |
| @@ -119,8 +119,8 @@ pub struct WorkerRegistry { | |||
| 119 | instances: tokio::sync::RwLock<HashMap<InstanceId, WorkerEntry>>, | 119 | instances: tokio::sync::RwLock<HashMap<InstanceId, WorkerEntry>>, |
| 120 | /// The shared indexer for all models/tenants | 120 | /// The shared indexer for all models/tenants |
| 121 | indexer: Arc<Indexer>, | 121 | indexer: Arc<Indexer>, |
| 122 | - /// Count of active replay sessions. Queries are rejected while > 0 | 122 | + /// Count of active replay sessions. Tracks how many replays are running |
| 123 | - /// to avoid returning incomplete results during prefix-tree rebuild. | 123 | + /// (for diagnostics); replay itself is offloaded to `spawn_blocking`. |
| 124 | /// Wrapped in Arc so it can be shared with spawn_blocking tasks. | 124 | /// Wrapped in Arc so it can be shared with spawn_blocking tasks. |
| 125 | replay_in_progress: Arc<std::sync::atomic::AtomicU64>, | 125 | replay_in_progress: Arc<std::sync::atomic::AtomicU64>, |
| 126 | /// Active ZMQ subscribers, keyed by (instance_id, dp_rank, endpoint_url). | 126 | /// Active ZMQ subscribers, keyed by (instance_id, dp_rank, endpoint_url). |
| @@ -164,12 +164,12 @@ impl WorkerRegistry { | |||
| 164 | )); | 164 | )); |
| 165 | } | 165 | } |
| 166 | let mut map: HashMap<String, Vec<StorageMedium>> = HashMap::new(); | 166 | let mut map: HashMap<String, Vec<StorageMedium>> = HashMap::new(); |
| 167 | - // Mooncake master publishes CPU and DISK events (never XPU) on one | 167 | + // Mooncake master publishes CPU and DISK events (never NPU) on one |
| 168 | - // port. We include XPU here so the indexer is ready for HBM events | 168 | + // port. We include NPU here so the indexer is ready for HBM events |
| 169 | // from engine-level publishers that may share the same endpoint. | 169 | // from engine-level publishers that may share the same endpoint. |
| 170 | map.insert( | 170 | map.insert( |
| 171 | ep.clone(), | 171 | ep.clone(), |
| 172 | - vec![StorageMedium::Xpu, StorageMedium::Cpu, StorageMedium::Disk], | 172 | + vec![StorageMedium::Npu, StorageMedium::Cpu, StorageMedium::Disk], |
| 173 | ); | 173 | ); |
| 174 | return Ok(map); | 174 | return Ok(map); |
| 175 | } | 175 | } |
| @@ -179,10 +179,10 @@ impl WorkerRegistry { | |||
| 179 | Ok(HashMap::new()) | 179 | Ok(HashMap::new()) |
| 180 | } | 180 | } |
| 181 | 181 | ||
| 182 | - pub fn new(scoring: ScoringConfig) -> Self { | 182 | + pub fn new() -> Self { |
| 183 | Self { | 183 | Self { |
| 184 | instances: tokio::sync::RwLock::new(HashMap::new()), | 184 | instances: tokio::sync::RwLock::new(HashMap::new()), |
| 185 | - indexer: Arc::new(Indexer::new(scoring)), | 185 | + indexer: Arc::new(Indexer::new()), |
| 186 | replay_in_progress: Arc::new(std::sync::atomic::AtomicU64::new(0)), | 186 | replay_in_progress: Arc::new(std::sync::atomic::AtomicU64::new(0)), |
| 187 | zmq_subscribers: tokio::sync::RwLock::new(HashMap::new()), | 187 | zmq_subscribers: tokio::sync::RwLock::new(HashMap::new()), |
| 188 | hbm_ip_index: Arc::new(ParkingRwLock::new(HashMap::new())), | 188 | hbm_ip_index: Arc::new(ParkingRwLock::new(HashMap::new())), |
| @@ -449,7 +449,7 @@ impl WorkerRegistry { | |||
| 449 | 449 | ||
| 450 | entry.endpoints.remove(&req.dp_rank); | 450 | entry.endpoints.remove(&req.dp_rank); |
| 451 | 451 | ||
| 452 | - // Remove from indexer tree across all storage media (XPU/CPU/DISK) | 452 | + // Remove from indexer tree across all storage media (NPU/CPU/DISK) |
| 453 | // Use the *registered* model/tenant (not the request's) to ensure | 453 | // Use the *registered* model/tenant (not the request's) to ensure |
| 454 | // cleanup targets the correct indexer entry. | 454 | // cleanup targets the correct indexer entry. |
| 455 | let indexer_entry = self.indexer.get(&entry.model_name, &entry.tenant_id); | 455 | let indexer_entry = self.indexer.get(&entry.model_name, &entry.tenant_id); |
| @@ -548,7 +548,7 @@ impl WorkerRegistry { | |||
| 548 | let medium = medium_str | 548 | let medium = medium_str |
| 549 | .as_deref() | 549 | .as_deref() |
| 550 | .map(StorageMedium::parse) | 550 | .map(StorageMedium::parse) |
| 551 | - .unwrap_or(StorageMedium::Xpu); | 551 | + .unwrap_or(StorageMedium::Npu); |
| 552 | 552 | ||
| 553 | let backend_id = backend_id_opt.as_deref().unwrap_or(instance_id).to_string(); | 553 | let backend_id = backend_id_opt.as_deref().unwrap_or(instance_id).to_string(); |
| 554 | 554 | ||
| @@ -594,7 +594,7 @@ impl WorkerRegistry { | |||
| 594 | 594 | ||
| 595 | impl Default for WorkerRegistry { | 595 | impl Default for WorkerRegistry { |
| 596 | fn default() -> Self { | 596 | fn default() -> Self { |
| 597 | - Self::new(ScoringConfig::default()) | 597 | + Self::new() |
| 598 | } | 598 | } |
| 599 | } | 599 | } |
| 600 | 600 | ||
| @@ -13,8 +13,8 @@ | |||
| 13 | //! Provides the following endpoints: | 13 | //! Provides the following endpoints: |
| 14 | //! - `POST /register` — Register a worker instance | 14 | //! - `POST /register` — Register a worker instance |
| 15 | //! - `POST /unregister` — Unregister a worker instance | 15 | //! - `POST /unregister` — Unregister a worker instance |
| 16 | -//! - `POST /query` — Query KV cache overlap scores by token IDs | 16 | +//! - `POST /query` — Query KV cache matched blocks by token IDs |
| 17 | -//! - `POST /query_by_hash` — Query KV cache overlap scores by pre-computed hashes | 17 | +//! - `POST /query_by_hash` — Query KV cache matched blocks by pre-computed hashes |
| 18 | //! - `POST /events` — Ingest KV cache events from workers | 18 | //! - `POST /events` — Ingest KV cache events from workers |
| 19 | //! - `GET /health` — Health check | 19 | //! - `GET /health` — Health check |
| 20 | //! - `GET /workers` — List registered workers (debug) | 20 | //! - `GET /workers` — List registered workers (debug) |
| @@ -30,7 +30,7 @@ use axum::{ | |||
| 30 | use tower_http::cors::{Any, CorsLayer}; | 30 | use tower_http::cors::{Any, CorsLayer}; |
| 31 | use tower_http::trace::TraceLayer; | 31 | use tower_http::trace::TraceLayer; |
| 32 | 32 | ||
| 33 | -/// Maximum request body size (16 MB). Large queries (402400+ token IDs) | 33 | +/// Maximum request body size (64 MB). Large queries (402400+ token IDs) |
| 34 | /// exceed axum's default 2 MB limit. | 34 | /// exceed axum's default 2 MB limit. |
| 35 | const MAX_BODY_BYTES: usize = 64 * 1024 * 1024; | 35 | const MAX_BODY_BYTES: usize = 64 * 1024 * 1024; |
| 36 | 36 | ||
| @@ -42,8 +42,6 @@ use crate::registry::WorkerRegistry; | |||
| 42 | 42 | ||
| 43 | pub struct AppState { | 43 | pub struct AppState { |
| 44 | pub registry: Arc<WorkerRegistry>, | 44 | pub registry: Arc<WorkerRegistry>, |
| 45 | - /// Per-medium block scoring weights. | ||
| 46 | - pub scoring: ScoringConfig, | ||
| 47 | } | 45 | } |
| 48 | 46 | ||
| 49 | /// Create the axum Router with all endpoints. | 47 | /// Create the axum Router with all endpoints. |
| @@ -62,8 +60,8 @@ pub fn create_router(state: AppState) -> Router { | |||
| 62 | .route("/health", get(health_handler)) | 60 | .route("/health", get(health_handler)) |
| 63 | .route("/workers", get(workers_handler)); | 61 | .route("/workers", get(workers_handler)); |
| 64 | 62 | ||
| 65 | - // Raise body limit for query/events endpoints — DeepSeek V4 queries | 63 | + // Raise the global body limit — DeepSeek V4 queries carry 400K+ token |
| 66 | - // carry 400K+ token IDs (~2.4 MB JSON body). | 64 | + // IDs (~2.4 MB JSON body), beyond axum's 2 MB default. |
| 67 | router = router.layer(axum::extract::DefaultBodyLimit::max(MAX_BODY_BYTES)); | 65 | router = router.layer(axum::extract::DefaultBodyLimit::max(MAX_BODY_BYTES)); |
| 68 | 66 | ||
| 69 | router | 67 | router |
| @@ -137,7 +135,9 @@ async fn unregister_handler( | |||
| 137 | /// | 135 | /// |
| 138 | /// Request body: `{ "model": "...", "block_size": 128, "token_ids": [...], "tenant_id": "default" }` | 136 | /// Request body: `{ "model": "...", "block_size": 128, "token_ids": [...], "tenant_id": "default" }` |
| 139 | /// | 137 | /// |
| 140 | -/// Response: `{ "<tenant_id>": { "<instance_id>": { "longest_matched": N, "DP": { "<rank>": N } } } }` | 138 | +/// Response: `{ "<tenant_id>": { "<instance_id>": { "longest_matched": N, |
| 139 | +/// "DP": { "<rank>": { "matched_tokens": N, "npu_blocks": N, | ||
| 140 | +/// "cpu_blocks": N, "disk_blocks": N } } } } }` | ||
| 141 | async fn query_handler( | 141 | async fn query_handler( |
| 142 | State(state): State<AppState>, | 142 | State(state): State<AppState>, |
| 143 | Json(req): Json<QueryRequest>, | 143 | Json(req): Json<QueryRequest>, |
| @@ -286,7 +286,8 @@ async fn events_handler( | |||
| 286 | i = j; | 286 | i = j; |
| 287 | } | 287 | } |
| 288 | 288 | ||
| 289 | - // Handle shutdown flag: unregister the instance if it's shutting down | 289 | + // Handle shutdown flag: the instance reports it is shutting down. Full |
| 290 | + // cleanup is done by an explicit /unregister call; here we just log. | ||
| 290 | if batch.shutdown { | 291 | if batch.shutdown { |
| 291 | tracing::info!( | 292 | tracing::info!( |
| 292 | instance_id = %batch.instance_id, | 293 | instance_id = %batch.instance_id, |
| @@ -419,8 +419,8 @@ fn zmq_errno_reasonable(e: &zmq::Error) -> bool { | |||
| 419 | /// 3. End-of-stream: seq == 0xFFFFFFFFFFFFFFFF (-1 as signed i64) | 419 | /// 3. End-of-stream: seq == 0xFFFFFFFFFFFFFFFF (-1 as signed i64) |
| 420 | /// | 420 | /// |
| 421 | /// Called during `/register` when the registration payload includes | 421 | /// Called during `/register` when the registration payload includes |
| 422 | -/// a `replay_endpoint` field. Runs synchronously in the registration | 422 | +/// a `replay_endpoint` field. Invoked via `spawn_blocking` from the |
| 423 | -/// handler (blocking). | 423 | +/// registration handler (offloaded to a dedicated thread). |
| 424 | 424 | ||
| 425 | pub fn replay_events( | 425 | pub fn replay_events( |
| 426 | replay_endpoint: &str, | 426 | replay_endpoint: &str, |
| @@ -16,15 +16,13 @@ use reqwest::Client; | |||
| 16 | use serde_json::{json, Value}; | 16 | use serde_json::{json, Value}; |
| 17 | use tokio::net::TcpListener; | 17 | use tokio::net::TcpListener; |
| 18 | 18 | ||
| 19 | -use kv_conductor::protocols::ScoringConfig; | ||
| 20 | use kv_conductor::registry::WorkerRegistry; | 19 | use kv_conductor::registry::WorkerRegistry; |
| 21 | use kv_conductor::server::{create_router, AppState}; | 20 | use kv_conductor::server::{create_router, AppState}; |
| 22 | 21 | ||
| 23 | /// Start a test server on a random port, returning the base URL. | 22 | /// Start a test server on a random port, returning the base URL. |
| 24 | async fn start_test_server() -> (String, tokio::task::JoinHandle<()>) { | 23 | async fn start_test_server() -> (String, tokio::task::JoinHandle<()>) { |
| 25 | - let scoring = ScoringConfig::default(); | 24 | + let registry = Arc::new(WorkerRegistry::new()); |
| 26 | - let registry = Arc::new(WorkerRegistry::new(scoring.clone())); | 25 | + let state = AppState { registry }; |
| 27 | - let state = AppState { registry, scoring }; | ||
| 28 | let router = create_router(state); | 26 | let router = create_router(state); |
| 29 | 27 | ||
| 30 | let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); | 28 | let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); |
| @@ -65,7 +63,7 @@ async fn test_register_and_query() { | |||
| 65 | let register_data = json!({ | 63 | let register_data = json!({ |
| 66 | "instance_id": "vllm-prefill-42", | 64 | "instance_id": "vllm-prefill-42", |
| 67 | "medium_endpoints": { | 65 | "medium_endpoints": { |
| 68 | - "xpu": "tcp://10.0.0.1:50090", | 66 | + "npu": "tcp://10.0.0.1:50090", |
| 69 | "cpu": "tcp://10.0.0.1:50090", | 67 | "cpu": "tcp://10.0.0.1:50090", |
| 70 | "disk": "tcp://10.0.0.1:50090" | 68 | "disk": "tcp://10.0.0.1:50090" |
| 71 | }, | 69 | }, |
| @@ -120,7 +118,7 @@ async fn test_query_after_kv_events() { | |||
| 120 | .json(&json!({ | 118 | .json(&json!({ |
| 121 | "instance_id": format!("vllm-prefill-{}", i), | 119 | "instance_id": format!("vllm-prefill-{}", i), |
| 122 | "medium_endpoints": { | 120 | "medium_endpoints": { |
| 123 | - "xpu": ep, | 121 | + "npu": ep, |
| 124 | "cpu": ep, | 122 | "cpu": ep, |
| 125 | "disk": ep | 123 | "disk": ep |
| 126 | }, | 124 | }, |
| @@ -200,7 +198,7 @@ async fn test_unregister() { | |||
| 200 | .json(&json!({ | 198 | .json(&json!({ |
| 201 | "instance_id": "vllm-prefill-99", | 199 | "instance_id": "vllm-prefill-99", |
| 202 | "medium_endpoints": { | 200 | "medium_endpoints": { |
| 203 | - "xpu": "tcp://10.0.0.1:50090", | 201 | + "npu": "tcp://10.0.0.1:50090", |
| 204 | "cpu": "tcp://10.0.0.1:50090", | 202 | "cpu": "tcp://10.0.0.1:50090", |
| 205 | "disk": "tcp://10.0.0.1:50090" | 203 | "disk": "tcp://10.0.0.1:50090" |
| 206 | }, | 204 | }, |
| @@ -260,7 +258,7 @@ async fn test_duplicate_registration() { | |||
| 260 | let reg = json!({ | 258 | let reg = json!({ |
| 261 | "instance_id": "dup-test", | 259 | "instance_id": "dup-test", |
| 262 | "medium_endpoints": { | 260 | "medium_endpoints": { |
| 263 | - "xpu": "tcp://10.0.0.1:50090", | 261 | + "npu": "tcp://10.0.0.1:50090", |
| 264 | "cpu": "tcp://10.0.0.1:50090", | 262 | "cpu": "tcp://10.0.0.1:50090", |
| 265 | "disk": "tcp://10.0.0.1:50090" | 263 | "disk": "tcp://10.0.0.1:50090" |
| 266 | }, | 264 | }, |
| @@ -332,12 +330,12 @@ async fn test_mooncake_hbm_plus_pool_registration() { | |||
| 332 | let (base_url, _handle) = start_test_server().await; | 330 | let (base_url, _handle) = start_test_server().await; |
| 333 | let client = Client::new(); | 331 | let client = Client::new(); |
| 334 | 332 | ||
| 335 | - // Register HBM endpoint (XPU only, store_backend=Mooncake) | 333 | + // Register HBM endpoint (NPU only, store_backend=Mooncake) |
| 336 | let resp = client | 334 | let resp = client |
| 337 | .post(format!("{}/register", base_url)) | 335 | .post(format!("{}/register", base_url)) |
| 338 | .json(&json!({ | 336 | .json(&json!({ |
| 339 | "instance_id": "mooncake-prefill-0", | 337 | "instance_id": "mooncake-prefill-0", |
| 340 | - "medium_endpoints": {"xpu": "tcp://10.0.0.1:50090"}, | 338 | + "medium_endpoints": {"npu": "tcp://10.0.0.1:50090"}, |
| 341 | "type": "vLLM", | 339 | "type": "vLLM", |
| 342 | "store_backend": "Mooncake", | 340 | "store_backend": "Mooncake", |
| 343 | "modelname": "mooncake-model", | 341 | "modelname": "mooncake-model", |
| @@ -395,7 +393,7 @@ async fn test_memcache_hbm_plus_pool_registration() { | |||
| 395 | .post(format!("{}/register", base_url)) | 393 | .post(format!("{}/register", base_url)) |
| 396 | .json(&json!({ | 394 | .json(&json!({ |
| 397 | "instance_id": "memcache-prefill-0", | 395 | "instance_id": "memcache-prefill-0", |
| 398 | - "medium_endpoints": {"xpu": "tcp://10.0.1.1:50090"}, | 396 | + "medium_endpoints": {"npu": "tcp://10.0.1.1:50090"}, |
| 399 | "type": "vLLM", | 397 | "type": "vLLM", |
| 400 | "store_backend": "Memcache", | 398 | "store_backend": "Memcache", |
| 401 | "modelname": "memcache-model", | 399 | "modelname": "memcache-model", |
| @@ -440,13 +438,13 @@ async fn test_yuanrong_multi_port_registration() { | |||
| 440 | let (base_url, _handle) = start_test_server().await; | 438 | let (base_url, _handle) = start_test_server().await; |
| 441 | let client = Client::new(); | 439 | let client = Client::new(); |
| 442 | 440 | ||
| 443 | - // YuanRong: cpu + disk share one port, xpu on another | 441 | + // YuanRong: cpu + disk share one port, npu on another |
| 444 | let resp = client | 442 | let resp = client |
| 445 | .post(format!("{}/register", base_url)) | 443 | .post(format!("{}/register", base_url)) |
| 446 | .json(&json!({ | 444 | .json(&json!({ |
| 447 | "instance_id": "yr-node-0", | 445 | "instance_id": "yr-node-0", |
| 448 | "medium_endpoints": { | 446 | "medium_endpoints": { |
| 449 | - "xpu": "tcp://10.0.2.1:15557", | 447 | + "npu": "tcp://10.0.2.1:15557", |
| 450 | "cpu": "tcp://10.0.2.1:15558", | 448 | "cpu": "tcp://10.0.2.1:15558", |
| 451 | "disk": "tcp://10.0.2.1:15558" | 449 | "disk": "tcp://10.0.2.1:15558" |
| 452 | }, | 450 | }, |
| @@ -470,7 +468,7 @@ async fn test_yuanrong_multi_port_registration() { | |||
| 470 | let body: Value = resp.json().await.unwrap(); | 468 | let body: Value = resp.json().await.unwrap(); |
| 471 | let w = &body["workers"].as_array().unwrap()[0]; | 469 | let w = &body["workers"].as_array().unwrap()[0]; |
| 472 | let meps = &w["endpoints"]["0"]["medium_endpoints"]; | 470 | let meps = &w["endpoints"]["0"]["medium_endpoints"]; |
| 473 | - assert_eq!(meps["xpu"], "tcp://10.0.2.1:15557"); | 471 | + assert_eq!(meps["npu"], "tcp://10.0.2.1:15557"); |
| 474 | assert_eq!(meps["cpu"], "tcp://10.0.2.1:15558"); | 472 | assert_eq!(meps["cpu"], "tcp://10.0.2.1:15558"); |
| 475 | assert_eq!(meps["disk"], "tcp://10.0.2.1:15558"); | 473 | assert_eq!(meps["disk"], "tcp://10.0.2.1:15558"); |
| 476 | } | 474 | } |
| @@ -484,7 +482,7 @@ async fn test_mooncake_duplicate_hbm_registration() { | |||
| 484 | 482 | ||
| 485 | let reg = json!({ | 483 | let reg = json!({ |
| 486 | "instance_id": "mooncake-dup", | 484 | "instance_id": "mooncake-dup", |
| 487 | - "medium_endpoints": {"xpu": "tcp://10.0.3.1:50090"}, | 485 | + "medium_endpoints": {"npu": "tcp://10.0.3.1:50090"}, |
| 488 | "type": "vLLM", | 486 | "type": "vLLM", |
| 489 | "store_backend": "Mooncake", | 487 | "store_backend": "Mooncake", |
| 490 | "modelname": "dup-model", | 488 | "modelname": "dup-model", |
| @@ -557,7 +555,7 @@ async fn test_unregister_mooncake_hbm_removes_worker() { | |||
| 557 | .post(format!("{}/register", base_url)) | 555 | .post(format!("{}/register", base_url)) |
| 558 | .json(&json!({ | 556 | .json(&json!({ |
| 559 | "instance_id": "to-remove", | 557 | "instance_id": "to-remove", |
| 560 | - "medium_endpoints": {"xpu": "tcp://10.0.5.1:50090"}, | 558 | + "medium_endpoints": {"npu": "tcp://10.0.5.1:50090"}, |
| 561 | "type": "vLLM", | 559 | "type": "vLLM", |
| 562 | "store_backend": "Mooncake", | 560 | "store_backend": "Mooncake", |
| 563 | "modelname": "rm-model", | 561 | "modelname": "rm-model", |
| @@ -615,7 +613,7 @@ async fn test_unknown_backend_falls_back_to_yuanrong() { | |||
| 615 | .json(&json!({ | 613 | .json(&json!({ |
| 616 | "instance_id": "unknown-backend", | 614 | "instance_id": "unknown-backend", |
| 617 | "medium_endpoints": { | 615 | "medium_endpoints": { |
| 618 | - "xpu": "tcp://10.0.6.1:15557", | 616 | + "npu": "tcp://10.0.6.1:15557", |
| 619 | "cpu": "tcp://10.0.6.1:15558", | 617 | "cpu": "tcp://10.0.6.1:15558", |
| 620 | "disk": "tcp://10.0.6.1:15558" | 618 | "disk": "tcp://10.0.6.1:15558" |
| 621 | }, | 619 | }, |
| @@ -683,7 +681,7 @@ async fn test_reregister_same_backend_preserves_tree() { | |||
| 683 | let reg = json!({ | 681 | let reg = json!({ |
| 684 | "instance_id": "rereg-same", | 682 | "instance_id": "rereg-same", |
| 685 | "medium_endpoints": { | 683 | "medium_endpoints": { |
| 686 | - "xpu": "tcp://10.0.10.1:50090", | 684 | + "npu": "tcp://10.0.10.1:50090", |
| 687 | "cpu": "tcp://10.0.10.1:50090", | 685 | "cpu": "tcp://10.0.10.1:50090", |
| 688 | "disk": "tcp://10.0.10.1:50090" | 686 | "disk": "tcp://10.0.10.1:50090" |
| 689 | }, | 687 | }, |
| @@ -727,7 +725,7 @@ async fn test_reregister_same_backend_preserves_tree() { | |||
| 727 | // Re-register with same backend (different endpoint) | 725 | // Re-register with same backend (different endpoint) |
| 728 | let mut reg2 = reg.clone(); | 726 | let mut reg2 = reg.clone(); |
| 729 | reg2["medium_endpoints"] = json!({ | 727 | reg2["medium_endpoints"] = json!({ |
| 730 | - "xpu": "tcp://10.0.10.2:50090", | 728 | + "npu": "tcp://10.0.10.2:50090", |
| 731 | "cpu": "tcp://10.0.10.2:50090", | 729 | "cpu": "tcp://10.0.10.2:50090", |
| 732 | "disk": "tcp://10.0.10.2:50090" | 730 | "disk": "tcp://10.0.10.2:50090" |
| 733 | }); | 731 | }); |
| @@ -775,7 +773,7 @@ async fn test_reregister_different_backend_drops_tree() { | |||
| 775 | let reg = json!({ | 773 | let reg = json!({ |
| 776 | "instance_id": "rereg-diff", | 774 | "instance_id": "rereg-diff", |
| 777 | "medium_endpoints": { | 775 | "medium_endpoints": { |
| 778 | - "xpu": "tcp://10.0.11.1:50090", | 776 | + "npu": "tcp://10.0.11.1:50090", |
| 779 | "cpu": "tcp://10.0.11.1:50090", | 777 | "cpu": "tcp://10.0.11.1:50090", |
| 780 | "disk": "tcp://10.0.11.1:50090" | 778 | "disk": "tcp://10.0.11.1:50090" |
| 781 | }, | 779 | }, |
| @@ -1925,17 +1925,23 @@ def test_pre_separate_l6_inactive_instances_triggers_scale_p2d( | |||
| 1925 | with patch(_FAULT_MGR_IM) as mock_fm_im_class: | 1925 | with patch(_FAULT_MGR_IM) as mock_fm_im_class: |
| 1926 | mock_fm_im = MagicMock() | 1926 | mock_fm_im = MagicMock() |
| 1927 | mock_fm_im_class.return_value = mock_fm_im | 1927 | mock_fm_im_class.return_value = mock_fm_im |
| 1928 | + # Prevent async ScaleP2D.execute from racing: without a scale_p2d IM | ||
| 1929 | + # patch, execute aborts immediately, marks finished, and | ||
| 1930 | + # _process_instance_strategy clears strategy before assertions. | ||
| 1931 | + with patch.object(manager.executor, "submit") as mock_submit: | ||
| 1932 | + # First refresh fault level → L6 (included because node has instances) | ||
| 1933 | + manager._refresh_instance_fault_level(1) | ||
| 1934 | + # Then process strategy → should trigger ScaleP2D | ||
| 1935 | + manager._process_instance_strategy(1) | ||
| 1928 | 1936 | ||
| 1929 | - # First refresh fault level → L6 (included because node has instances) | 1937 | + ins_meta = manager.instances[1] |
| 1930 | - manager._refresh_instance_fault_level(1) | 1938 | + assert ins_meta.fault_level == FaultLevel.L6, ( |
| 1931 | - # Then process strategy → should trigger ScaleP2D | 1939 | + "PreSeparateNPU L6 with instances on node should set instance fault to L6" |
| 1932 | - manager._process_instance_strategy(1) | 1940 | + ) |
| 1933 | - | 1941 | + assert ins_meta.strategy is not None, ( |
| 1934 | - ins_meta = manager.instances[1] | 1942 | + "PreSeparateNPU L6 with inactive instances should trigger ScaleP2D" |
| 1935 | - assert ins_meta.fault_level == FaultLevel.L6, ( | 1943 | + ) |
| 1936 | - "PreSeparateNPU L6 with instances on node should set instance fault to L6" | 1944 | + mock_submit.assert_called_once() |
| 1937 | - ) | ||
| 1938 | - assert ins_meta.strategy is not None, "PreSeparateNPU L6 with inactive instances should trigger ScaleP2D" | ||
| 1939 | 1945 | ||
| 1940 | 1946 | ||
| 1941 | # ============================================================================= | 1947 | # ============================================================================= |
| @@ -66,7 +66,7 @@ def _mock_config(**overrides) -> Mock: | |||
| 66 | 66 | ||
| 67 | reg = KvConductorConfig( | 67 | reg = KvConductorConfig( |
| 68 | store_backend=overrides.get("store_backend", "Mooncake"), | 68 | store_backend=overrides.get("store_backend", "Mooncake"), |
| 69 | - xpu_endpoint=overrides.get("xpu_endpoint", "tcp://*:5557"), | 69 | + npu_endpoint=overrides.get("npu_endpoint", "tcp://*:5557"), |
| 70 | endpoint=overrides.get("endpoint", "tcp://*:5557"), | 70 | endpoint=overrides.get("endpoint", "tcp://*:5557"), |
| 71 | replay_endpoint=overrides.get("replay_endpoint", ""), | 71 | replay_endpoint=overrides.get("replay_endpoint", ""), |
| 72 | engine_type=overrides.get("engine_type", "vLLM"), | 72 | engine_type=overrides.get("engine_type", "vLLM"), |
| @@ -122,7 +122,7 @@ class TestBuildRegisterPayload: | |||
| 122 | 122 | ||
| 123 | def test_returns_empty_dict_when_no_endpoints_configured(self): | 123 | def test_returns_empty_dict_when_no_endpoints_configured(self): |
| 124 | """No endpoint patterns configured → empty dict.""" | 124 | """No endpoint patterns configured → empty dict.""" |
| 125 | - cfg = _mock_config(xpu_endpoint="", endpoint="", replay_endpoint="") | 125 | + cfg = _mock_config(npu_endpoint="", endpoint="", replay_endpoint="") |
| 126 | inst = _make_instance(inst_id=1, role=PDRole.ROLE_P) | 126 | inst = _make_instance(inst_id=1, role=PDRole.ROLE_P) |
| 127 | ep = _make_endpoint(ep_id=0, ip="10.0.0.1") | 127 | ep = _make_endpoint(ep_id=0, ip="10.0.0.1") |
| 128 | 128 | ||
| @@ -131,9 +131,9 @@ class TestBuildRegisterPayload: | |||
| 131 | 131 | ||
| 132 | assert payload == {} | 132 | assert payload == {} |
| 133 | 133 | ||
| 134 | - def test_basic_payload_with_xpu_endpoint(self): | 134 | + def test_basic_payload_with_npu_endpoint(self): |
| 135 | - """Standard payload with medium_endpoints via xpu_endpoint (no fallback).""" | 135 | + """Standard payload with medium_endpoints via npu_endpoint (no fallback).""" |
| 136 | - cfg = _mock_config(xpu_endpoint="tcp://*:5557", endpoint="") | 136 | + cfg = _mock_config(npu_endpoint="tcp://*:5557", endpoint="") |
| 137 | inst = _make_instance(inst_id=1, role=PDRole.ROLE_P, model_name="qwen") | 137 | inst = _make_instance(inst_id=1, role=PDRole.ROLE_P, model_name="qwen") |
| 138 | ep = _make_endpoint(ep_id=0, ip="10.0.0.1") | 138 | ep = _make_endpoint(ep_id=0, ip="10.0.0.1") |
| 139 | 139 | ||
| @@ -142,7 +142,7 @@ class TestBuildRegisterPayload: | |||
| 142 | 142 | ||
| 143 | assert payload["instance_id"] == "vllm-prefill-1" | 143 | assert payload["instance_id"] == "vllm-prefill-1" |
| 144 | assert payload["dp_rank"] == 0 | 144 | assert payload["dp_rank"] == 0 |
| 145 | - assert payload["medium_endpoints"] == {"xpu": "tcp://10.0.0.1:5557"} | 145 | + assert payload["medium_endpoints"] == {"npu": "tcp://10.0.0.1:5557"} |
| 146 | assert payload["type"] == "vLLM" | 146 | assert payload["type"] == "vLLM" |
| 147 | assert payload["modelname"] == "qwen" | 147 | assert payload["modelname"] == "qwen" |
| 148 | assert payload["block_size"] == 128 | 148 | assert payload["block_size"] == 128 |
| @@ -150,7 +150,7 @@ class TestBuildRegisterPayload: | |||
| 150 | def test_payload_with_replay_endpoint(self): | 150 | def test_payload_with_replay_endpoint(self): |
| 151 | """Payload includes replay_endpoint when configured.""" | 151 | """Payload includes replay_endpoint when configured.""" |
| 152 | cfg = _mock_config( | 152 | cfg = _mock_config( |
| 153 | - xpu_endpoint="tcp://*:5557", | 153 | + npu_endpoint="tcp://*:5557", |
| 154 | replay_endpoint="tcp://*:6667", | 154 | replay_endpoint="tcp://*:6667", |
| 155 | ) | 155 | ) |
| 156 | inst = _make_instance(inst_id=2, role=PDRole.ROLE_U, model_name="qwen") | 156 | inst = _make_instance(inst_id=2, role=PDRole.ROLE_U, model_name="qwen") |
| @@ -165,7 +165,7 @@ class TestBuildRegisterPayload: | |||
| 165 | 165 | ||
| 166 | def test_payload_dp_rank_uses_endpoint_id(self): | 166 | def test_payload_dp_rank_uses_endpoint_id(self): |
| 167 | """dp_rank is taken from endpoint.id.""" | 167 | """dp_rank is taken from endpoint.id.""" |
| 168 | - cfg = _mock_config(xpu_endpoint="tcp://*:5557") | 168 | + cfg = _mock_config(npu_endpoint="tcp://*:5557") |
| 169 | inst = _make_instance(inst_id=3, role=PDRole.ROLE_P) | 169 | inst = _make_instance(inst_id=3, role=PDRole.ROLE_P) |
| 170 | ep = _make_endpoint(ep_id=5, ip="10.0.0.3") | 170 | ep = _make_endpoint(ep_id=5, ip="10.0.0.3") |
| 171 | 171 | ||
| @@ -173,25 +173,25 @@ class TestBuildRegisterPayload: | |||
| 173 | payload = ConductorApiClient._build_register_payload(inst, ep) | 173 | payload = ConductorApiClient._build_register_payload(inst, ep) |
| 174 | 174 | ||
| 175 | assert payload["dp_rank"] == 5 | 175 | assert payload["dp_rank"] == 5 |
| 176 | - assert payload["medium_endpoints"]["xpu"] == "tcp://10.0.0.3:5562" | 176 | + assert payload["medium_endpoints"]["npu"] == "tcp://10.0.0.3:5562" |
| 177 | 177 | ||
| 178 | def test_payload_with_fallback_endpoint(self): | 178 | def test_payload_with_fallback_endpoint(self): |
| 179 | - """Legacy 'endpoint' fallback pattern used when xpu_endpoint empty.""" | 179 | + """Legacy 'endpoint' fallback pattern used when npu_endpoint empty.""" |
| 180 | - cfg = _mock_config(xpu_endpoint="", endpoint="tcp://*:15557") | 180 | + cfg = _mock_config(npu_endpoint="", endpoint="tcp://*:15557") |
| 181 | inst = _make_instance(inst_id=4, role=PDRole.ROLE_P) | 181 | inst = _make_instance(inst_id=4, role=PDRole.ROLE_P) |
| 182 | ep = _make_endpoint(ep_id=0, ip="10.0.0.4") | 182 | ep = _make_endpoint(ep_id=0, ip="10.0.0.4") |
| 183 | 183 | ||
| 184 | with patch.object(ConductorApiClient, "coordinator_config", cfg): | 184 | with patch.object(ConductorApiClient, "coordinator_config", cfg): |
| 185 | payload = ConductorApiClient._build_register_payload(inst, ep) | 185 | payload = ConductorApiClient._build_register_payload(inst, ep) |
| 186 | 186 | ||
| 187 | - # Fallback endpoint fills xpu, cpu, disk | 187 | + # Fallback endpoint fills gpu, cpu, disk |
| 188 | meps = payload["medium_endpoints"] | 188 | meps = payload["medium_endpoints"] |
| 189 | - assert "xpu" in meps | 189 | + assert "npu" in meps |
| 190 | 190 | ||
| 191 | def test_replay_endpoint_malformed_skipped(self): | 191 | def test_replay_endpoint_malformed_skipped(self): |
| 192 | """replay_endpoint without '*:' → replay_endpoint absent in payload.""" | 192 | """replay_endpoint without '*:' → replay_endpoint absent in payload.""" |
| 193 | cfg = _mock_config( | 193 | cfg = _mock_config( |
| 194 | - xpu_endpoint="tcp://*:5557", | 194 | + npu_endpoint="tcp://*:5557", |
| 195 | replay_endpoint="tcp://127.0.0.1:6667", | 195 | replay_endpoint="tcp://127.0.0.1:6667", |
| 196 | ) | 196 | ) |
| 197 | inst = _make_instance(inst_id=5, role=PDRole.ROLE_P) | 197 | inst = _make_instance(inst_id=5, role=PDRole.ROLE_P) |
| @@ -219,7 +219,7 @@ class TestNormalizeServiceKey: | |||
| 219 | "instance_id": "vllm-prefill-1", | 219 | "instance_id": "vllm-prefill-1", |
| 220 | "endpoints": { | 220 | "endpoints": { |
| 221 | "0": { | 221 | "0": { |
| 222 | - "medium_endpoints": {"xpu": "tcp://10.0.0.1:5557"}, | 222 | + "medium_endpoints": {"npu": "tcp://10.0.0.1:5557"}, |
| 223 | "dp_rank": 0, | 223 | "dp_rank": 0, |
| 224 | } | 224 | } |
| 225 | }, | 225 | }, |
| @@ -232,8 +232,8 @@ class TestNormalizeServiceKey: | |||
| 232 | worker = { | 232 | worker = { |
| 233 | "instance_id": "vllm-union-2", | 233 | "instance_id": "vllm-union-2", |
| 234 | "endpoints": { | 234 | "endpoints": { |
| 235 | - "0": {"medium_endpoints": {"xpu": "tcp://10.0.0.1:5557"}}, | 235 | + "0": {"medium_endpoints": {"npu": "tcp://10.0.0.1:5557"}}, |
| 236 | - "1": {"medium_endpoints": {"xpu": "tcp://10.0.0.1:5558"}}, | 236 | + "1": {"medium_endpoints": {"npu": "tcp://10.0.0.1:5558"}}, |
| 237 | }, | 237 | }, |
| 238 | } | 238 | } |
| 239 | keys = ConductorApiClient._normalize_service_key(worker) | 239 | keys = ConductorApiClient._normalize_service_key(worker) |
| @@ -250,8 +250,8 @@ class TestNormalizeServiceKey: | |||
| 250 | worker = { | 250 | worker = { |
| 251 | "instance_id": "vllm-prefill-1", | 251 | "instance_id": "vllm-prefill-1", |
| 252 | "endpoints": { | 252 | "endpoints": { |
| 253 | - "abc": {"medium_endpoints": {"xpu": "tcp://x:1"}}, | 253 | + "abc": {"medium_endpoints": {"npu": "tcp://x:1"}}, |
| 254 | - "0": {"medium_endpoints": {"xpu": "tcp://x:2"}}, | 254 | + "0": {"medium_endpoints": {"npu": "tcp://x:2"}}, |
| 255 | }, | 255 | }, |
| 256 | } | 256 | } |
| 257 | keys = ConductorApiClient._normalize_service_key(worker) | 257 | keys = ConductorApiClient._normalize_service_key(worker) |
| @@ -419,7 +419,7 @@ class TestReRegisterKvInstances: | |||
| 419 | def test_skip_when_no_endpoints_configured(self): | 419 | def test_skip_when_no_endpoints_configured(self): |
| 420 | """No endpoint patterns → _build_register_payload returns {} → skip.""" | 420 | """No endpoint patterns → _build_register_payload returns {} → skip.""" |
| 421 | inst = _make_instance(inst_id=1, role=PDRole.ROLE_P) | 421 | inst = _make_instance(inst_id=1, role=PDRole.ROLE_P) |
| 422 | - cfg = _mock_config(xpu_endpoint="", endpoint="") | 422 | + cfg = _mock_config(npu_endpoint="", endpoint="") |
| 423 | 423 | ||
| 424 | with ( | 424 | with ( |
| 425 | patch.object(ConductorApiClient, "coordinator_config", cfg), | 425 | patch.object(ConductorApiClient, "coordinator_config", cfg), |
| @@ -436,7 +436,7 @@ class TestReRegisterKvInstances: | |||
| 436 | endpoints = {"pod-0": {0: ep}} | 436 | endpoints = {"pod-0": {0: ep}} |
| 437 | inst = Instance(id=1, role=PDRole.ROLE_P, model_name="qwen", job_name="test-job", endpoints=endpoints) | 437 | inst = Instance(id=1, role=PDRole.ROLE_P, model_name="qwen", job_name="test-job", endpoints=endpoints) |
| 438 | 438 | ||
| 439 | - cfg = _mock_config(xpu_endpoint="tcp://*:5557") | 439 | + cfg = _mock_config(npu_endpoint="tcp://*:5557") |
| 440 | 440 | ||
| 441 | # Conductor has a DIFFERENT instance registered | 441 | # Conductor has a DIFFERENT instance registered |
| 442 | registered = [{"instance_id": "vllm-prefill-99", "endpoints": {"0": {}}}] | 442 | registered = [{"instance_id": "vllm-prefill-99", "endpoints": {"0": {}}}] |
| @@ -456,11 +456,11 @@ class TestReRegisterKvInstances: | |||
| 456 | endpoints = {"pod-0": {0: ep}} | 456 | endpoints = {"pod-0": {0: ep}} |
| 457 | inst = Instance(id=1, role=PDRole.ROLE_P, model_name="qwen", job_name="test-job", endpoints=endpoints) | 457 | inst = Instance(id=1, role=PDRole.ROLE_P, model_name="qwen", job_name="test-job", endpoints=endpoints) |
| 458 | 458 | ||
| 459 | - cfg = _mock_config(xpu_endpoint="tcp://*:5557") | 459 | + cfg = _mock_config(npu_endpoint="tcp://*:5557") |
| 460 | 460 | ||
| 461 | # Same (instance_id, dp_rank) already registered | 461 | # Same (instance_id, dp_rank) already registered |
| 462 | registered = [ | 462 | registered = [ |
| 463 | - {"instance_id": "vllm-prefill-1", "endpoints": {"0": {"medium_endpoints": {"xpu": "tcp://10.0.0.1:5557"}}}} | 463 | + {"instance_id": "vllm-prefill-1", "endpoints": {"0": {"medium_endpoints": {"npu": "tcp://10.0.0.1:5557"}}}} |
| 464 | ] | 464 | ] |
| 465 | 465 | ||
| 466 | with ( | 466 | with ( |
| @@ -479,11 +479,11 @@ class TestReRegisterKvInstances: | |||
| 479 | endpoints = {"pod-0": {0: ep0, 1: ep1}} | 479 | endpoints = {"pod-0": {0: ep0, 1: ep1}} |
| 480 | inst = Instance(id=1, role=PDRole.ROLE_P, model_name="qwen", job_name="test-job", endpoints=endpoints) | 480 | inst = Instance(id=1, role=PDRole.ROLE_P, model_name="qwen", job_name="test-job", endpoints=endpoints) |
| 481 | 481 | ||
| 482 | - cfg = _mock_config(xpu_endpoint="tcp://*:5557") | 482 | + cfg = _mock_config(npu_endpoint="tcp://*:5557") |
| 483 | 483 | ||
| 484 | # ep0 (dp_rank=0) already registered; ep1 (dp_rank=1) missing | 484 | # ep0 (dp_rank=0) already registered; ep1 (dp_rank=1) missing |
| 485 | registered = [ | 485 | registered = [ |
| 486 | - {"instance_id": "vllm-prefill-1", "endpoints": {"0": {"medium_endpoints": {"xpu": "tcp://10.0.0.1:5557"}}}} | 486 | + {"instance_id": "vllm-prefill-1", "endpoints": {"0": {"medium_endpoints": {"npu": "tcp://10.0.0.1:5557"}}}} |
| 487 | ] | 487 | ] |
| 488 | 488 | ||
| 489 | with ( | 489 | with ( |
| @@ -504,7 +504,7 @@ class TestReRegisterKvInstances: | |||
| 504 | endpoints = {"pod-0": {0: ep}} | 504 | endpoints = {"pod-0": {0: ep}} |
| 505 | inst = Instance(id=1, role=PDRole.ROLE_P, model_name="qwen", job_name="test-job", endpoints=endpoints) | 505 | inst = Instance(id=1, role=PDRole.ROLE_P, model_name="qwen", job_name="test-job", endpoints=endpoints) |
| 506 | 506 | ||
| 507 | - cfg = _mock_config(xpu_endpoint="tcp://*:5557") | 507 | + cfg = _mock_config(npu_endpoint="tcp://*:5557") |
| 508 | 508 | ||
| 509 | # Mooncake Master format: InstanceID + DPRank | 509 | # Mooncake Master format: InstanceID + DPRank |
| 510 | registered = [{"InstanceID": "vllm-prefill-1", "DPRank": 0, "Endpoint": "tcp://10.0.0.1:5557"}] | 510 | registered = [{"InstanceID": "vllm-prefill-1", "DPRank": 0, "Endpoint": "tcp://10.0.0.1:5557"}] |
| @@ -524,7 +524,7 @@ class TestReRegisterKvInstances: | |||
| 524 | endpoints = {"pod-0": {0: ep}} | 524 | endpoints = {"pod-0": {0: ep}} |
| 525 | inst = Instance(id=1, role=PDRole.ROLE_P, model_name="qwen", job_name="test-job", endpoints=endpoints) | 525 | inst = Instance(id=1, role=PDRole.ROLE_P, model_name="qwen", job_name="test-job", endpoints=endpoints) |
| 526 | 526 | ||
| 527 | - cfg = _mock_config(xpu_endpoint="tcp://*:5557") | 527 | + cfg = _mock_config(npu_endpoint="tcp://*:5557") |
| 528 | 528 | ||
| 529 | # Different instance registered | 529 | # Different instance registered |
| 530 | registered = [{"InstanceID": "vllm-prefill-99", "DPRank": 0, "Endpoint": "tcp://10.0.0.99:5557"}] | 530 | registered = [{"InstanceID": "vllm-prefill-99", "DPRank": 0, "Endpoint": "tcp://10.0.0.99:5557"}] |
| @@ -593,7 +593,7 @@ def test_return_value_on_failure(mock_http): | |||
| 593 | 593 | ||
| 594 | 594 | ||
| 595 | def _setup_reg_config( | 595 | def _setup_reg_config( |
| 596 | - store_backend, pool_endpoint="", xpu_endpoint="", cpu_endpoint="", disk_endpoint="", replay_endpoint="" | 596 | + store_backend, pool_endpoint="", npu_endpoint="", cpu_endpoint="", disk_endpoint="", replay_endpoint="" |
| 597 | ): | 597 | ): |
| 598 | """Patch ConductorApiClient's config for registration testing.""" | 598 | """Patch ConductorApiClient's config for registration testing.""" |
| 599 | from motor.config.coordinator import KvConductorConfig, SchedulerConfig | 599 | from motor.config.coordinator import KvConductorConfig, SchedulerConfig |
| @@ -601,7 +601,7 @@ def _setup_reg_config( | |||
| 601 | reg = KvConductorConfig( | 601 | reg = KvConductorConfig( |
| 602 | store_backend=store_backend, | 602 | store_backend=store_backend, |
| 603 | pool_endpoint=pool_endpoint, | 603 | pool_endpoint=pool_endpoint, |
| 604 | - xpu_endpoint=xpu_endpoint, | 604 | + npu_endpoint=npu_endpoint, |
| 605 | cpu_endpoint=cpu_endpoint, | 605 | cpu_endpoint=cpu_endpoint, |
| 606 | disk_endpoint=disk_endpoint, | 606 | disk_endpoint=disk_endpoint, |
| 607 | replay_endpoint=replay_endpoint, | 607 | replay_endpoint=replay_endpoint, |
| @@ -634,7 +634,7 @@ def test_yuanrong_registration_dispatches_per_dp(mock_http): | |||
| 634 | ConductorApiClient._pool_registered = False | 634 | ConductorApiClient._pool_registered = False |
| 635 | 635 | ||
| 636 | with _setup_reg_config( | 636 | with _setup_reg_config( |
| 637 | - "YuanRong", xpu_endpoint="tcp://*:15557", cpu_endpoint="tcp://*:15558", disk_endpoint="tcp://*:15558" | 637 | + "YuanRong", npu_endpoint="tcp://*:15557", cpu_endpoint="tcp://*:15558", disk_endpoint="tcp://*:15558" |
| 638 | ): | 638 | ): |
| 639 | ConductorApiClient.register_kv_instance([instance]) | 639 | ConductorApiClient.register_kv_instance([instance]) |
| 640 | 640 | ||
| @@ -643,7 +643,7 @@ def test_yuanrong_registration_dispatches_per_dp(mock_http): | |||
| 643 | payload = calls[0][0][1] | 643 | payload = calls[0][0][1] |
| 644 | assert "medium_endpoints" in payload | 644 | assert "medium_endpoints" in payload |
| 645 | assert payload["store_backend"] == "YuanRong" | 645 | assert payload["store_backend"] == "YuanRong" |
| 646 | - assert "xpu" in str(payload["medium_endpoints"]) | 646 | + assert "npu" in str(payload["medium_endpoints"]) |
| 647 | assert "cpu" in str(payload["medium_endpoints"]) | 647 | assert "cpu" in str(payload["medium_endpoints"]) |
| 648 | assert "disk" in str(payload["medium_endpoints"]) | 648 | assert "disk" in str(payload["medium_endpoints"]) |
| 649 | 649 | ||
| @@ -658,7 +658,7 @@ def test_mooncake_registration_includes_pool_plus_hbm(mock_http): | |||
| 658 | instance = _make_mock_instance(1) | 658 | instance = _make_mock_instance(1) |
| 659 | ConductorApiClient._pool_registered = False | 659 | ConductorApiClient._pool_registered = False |
| 660 | 660 | ||
| 661 | - with _setup_reg_config("Mooncake", pool_endpoint="tcp://kvp-master:5557", xpu_endpoint="tcp://*:50090"): | 661 | + with _setup_reg_config("Mooncake", pool_endpoint="tcp://kvp-master:5557", npu_endpoint="tcp://*:50090"): |
| 662 | ConductorApiClient.register_kv_instance([instance]) | 662 | ConductorApiClient.register_kv_instance([instance]) |
| 663 | 663 | ||
| 664 | calls = mock_client.post.call_args_list | 664 | calls = mock_client.post.call_args_list |
| @@ -673,7 +673,7 @@ def test_mooncake_registration_includes_pool_plus_hbm(mock_http): | |||
| 673 | # Second call: HBM DP | 673 | # Second call: HBM DP |
| 674 | hbm_payload = calls[1][0][1] | 674 | hbm_payload = calls[1][0][1] |
| 675 | assert "medium_endpoints" in hbm_payload | 675 | assert "medium_endpoints" in hbm_payload |
| 676 | - assert "xpu" in str(hbm_payload["medium_endpoints"]) | 676 | + assert "npu" in str(hbm_payload["medium_endpoints"]) |
| 677 | 677 | ||
| 678 | 678 | ||
| 679 | 679 | ||
| @@ -686,7 +686,7 @@ def test_mooncake_pool_only_registered_once(mock_http): | |||
| 686 | instance = _make_mock_instance(1) | 686 | instance = _make_mock_instance(1) |
| 687 | ConductorApiClient._pool_registered = False | 687 | ConductorApiClient._pool_registered = False |
| 688 | 688 | ||
| 689 | - with _setup_reg_config("Mooncake", pool_endpoint="tcp://kvp-master:5557", xpu_endpoint="tcp://*:50090"): | 689 | + with _setup_reg_config("Mooncake", pool_endpoint="tcp://kvp-master:5557", npu_endpoint="tcp://*:50090"): |
| 690 | ConductorApiClient.register_kv_instance([instance]) | 690 | ConductorApiClient.register_kv_instance([instance]) |
| 691 | ConductorApiClient.register_kv_instance([instance]) | 691 | ConductorApiClient.register_kv_instance([instance]) |
| 692 | 692 | ||
| @@ -707,7 +707,7 @@ def test_memcache_registration_same_as_mooncake_different_store_backend(mock_htt | |||
| 707 | instance = _make_mock_instance(1) | 707 | instance = _make_mock_instance(1) |
| 708 | ConductorApiClient._pool_registered = False | 708 | ConductorApiClient._pool_registered = False |
| 709 | 709 | ||
| 710 | - with _setup_reg_config("Memcache", pool_endpoint="tcp://kvp-master:5557", xpu_endpoint="tcp://*:50090"): | 710 | + with _setup_reg_config("Memcache", pool_endpoint="tcp://kvp-master:5557", npu_endpoint="tcp://*:50090"): |
| 711 | ConductorApiClient.register_kv_instance([instance]) | 711 | ConductorApiClient.register_kv_instance([instance]) |
| 712 | 712 | ||
| 713 | calls = mock_client.post.call_args_list | 713 | calls = mock_client.post.call_args_list |
| @@ -728,7 +728,7 @@ def test_replay_endpoint_included_in_registration(mock_http): | |||
| 728 | 728 | ||
| 729 | with _setup_reg_config( | 729 | with _setup_reg_config( |
| 730 | "YuanRong", | 730 | "YuanRong", |
| 731 | - xpu_endpoint="tcp://*:15557", | 731 | + npu_endpoint="tcp://*:15557", |
| 732 | cpu_endpoint="tcp://*:15558", | 732 | cpu_endpoint="tcp://*:15558", |
| 733 | disk_endpoint="tcp://*:15558", | 733 | disk_endpoint="tcp://*:15558", |
| 734 | replay_endpoint="tcp://*:6667", | 734 | replay_endpoint="tcp://*:6667", |
| @@ -752,13 +752,13 @@ def test_endpoint_url_resolves_ip_and_dp_rank(mock_http): | |||
| 752 | instance.endpoints["pod-0"][0].id = 2 # dp_rank=2 | 752 | instance.endpoints["pod-0"][0].id = 2 # dp_rank=2 |
| 753 | 753 | ||
| 754 | with _setup_reg_config( | 754 | with _setup_reg_config( |
| 755 | - "YuanRong", xpu_endpoint="tcp://*:15557", cpu_endpoint="tcp://*:15558", disk_endpoint="tcp://*:15558" | 755 | + "YuanRong", npu_endpoint="tcp://*:15557", cpu_endpoint="tcp://*:15558", disk_endpoint="tcp://*:15558" |
| 756 | ): | 756 | ): |
| 757 | ConductorApiClient.register_kv_instance([instance]) | 757 | ConductorApiClient.register_kv_instance([instance]) |
| 758 | 758 | ||
| 759 | payload = mock_client.post.call_args_list[0][0][1] | 759 | payload = mock_client.post.call_args_list[0][0][1] |
| 760 | meps = payload["medium_endpoints"] | 760 | meps = payload["medium_endpoints"] |
| 761 | - assert meps["xpu"] == "tcp://10.0.0.1:15559" # 15557 + 2 | 761 | + assert meps["npu"] == "tcp://10.0.0.1:15559" # 15557 + 2 |
| 762 | assert meps["cpu"] == "tcp://10.0.0.1:15560" # 15558 + 2 | 762 | assert meps["cpu"] == "tcp://10.0.0.1:15560" # 15558 + 2 |
| 763 | assert payload["dp_rank"] == 2 | 763 | assert payload["dp_rank"] == 2 |
| 764 | 764 | ||
| @@ -772,7 +772,7 @@ def test_unknown_backend_falls_back_to_per_dp(mock_http): | |||
| 772 | 772 | ||
| 773 | instance = _make_mock_instance(1) | 773 | instance = _make_mock_instance(1) |
| 774 | 774 | ||
| 775 | - with _setup_reg_config("SomeUnknownBackend", xpu_endpoint="tcp://*:15557"): | 775 | + with _setup_reg_config("SomeUnknownBackend", npu_endpoint="tcp://*:15557"): |
| 776 | ConductorApiClient.register_kv_instance([instance]) | 776 | ConductorApiClient.register_kv_instance([instance]) |
| 777 | 777 | ||
| 778 | assert len(mock_client.post.call_args_list) >= 1 | 778 | assert len(mock_client.post.call_args_list) >= 1 |
| @@ -268,7 +268,7 @@ class TestKvCacheAffinityPolicy(unittest.TestCase): | |||
| 268 | 268 | ||
| 269 | 269 | ||
| 270 | def test_select_endpoint_dpscoring_dict_format(self, mock_tokenizer_manager, mock_query_conductor): | 270 | def test_select_endpoint_dpscoring_dict_format(self, mock_tokenizer_manager, mock_query_conductor): |
| 271 | - """New DpScoring format: DP values are dicts with matched_tokens, not plain ints.""" | 271 | + """New DpBlocks format: DP values are dicts with matched_tokens, not plain ints.""" |
| 272 | ep_a = _make_endpoint(0, active_tokens=50.0) | 272 | ep_a = _make_endpoint(0, active_tokens=50.0) |
| 273 | ep_b = _make_endpoint(1, active_tokens=50.0) | 273 | ep_b = _make_endpoint(1, active_tokens=50.0) |
| 274 | mock_instance = Mock() | 274 | mock_instance = Mock() |
| @@ -284,13 +284,13 @@ class TestKvCacheAffinityPolicy(unittest.TestCase): | |||
| 284 | mock_tokenizer.encode.return_value = list(range(1000)) | 284 | mock_tokenizer.encode.return_value = list(range(1000)) |
| 285 | mock_tokenizer_manager.return_value = mock_tokenizer | 285 | mock_tokenizer_manager.return_value = mock_tokenizer |
| 286 | 286 | ||
| 287 | - # New DpScoring format: DP values are dicts with matched_tokens | 287 | + # New DpBlocks format: DP values are dicts with matched_tokens |
| 288 | mock_query_conductor.return_value = { | 288 | mock_query_conductor.return_value = { |
| 289 | TENANT_ID: { | 289 | TENANT_ID: { |
| 290 | "vllm-prefill-inst": { | 290 | "vllm-prefill-inst": { |
| 291 | "DP": { | 291 | "DP": { |
| 292 | - "0": {"XPU": 2400, "CPU": 0, "DISK": 0, "total": 2400, "matched_tokens": 800}, | 292 | + "0": {"npu_blocks": 6, "cpu_blocks": 0, "disk_blocks": 0, "matched_tokens": 800}, |
| 293 | - "1": {"XPU": 0, "CPU": 200, "DISK": 0, "total": 200, "matched_tokens": 100}, | 293 | + "1": {"npu_blocks": 0, "cpu_blocks": 1, "disk_blocks": 0, "matched_tokens": 100}, |
| 294 | } | 294 | } |
| 295 | } | 295 | } |
| 296 | } | 296 | } |
| @@ -68,6 +68,7 @@ def test_register_post_uses_union_conductor_id_for_role_u() -> None: | |||
| 68 | mock_config.scheduler_config.kv_conductor_config.conductor_service = "kv-conductor" | 68 | mock_config.scheduler_config.kv_conductor_config.conductor_service = "kv-conductor" |
| 69 | mock_config.scheduler_config.kv_conductor_config.http_server_port = 13333 | 69 | mock_config.scheduler_config.kv_conductor_config.http_server_port = 13333 |
| 70 | mock_config.scheduler_config.kv_conductor_config.endpoint = "tcp://*:5557" | 70 | mock_config.scheduler_config.kv_conductor_config.endpoint = "tcp://*:5557" |
| 71 | + mock_config.scheduler_config.kv_conductor_config.npu_endpoint = "" | ||
| 71 | mock_config.scheduler_config.kv_conductor_config.xpu_endpoint = "" | 72 | mock_config.scheduler_config.kv_conductor_config.xpu_endpoint = "" |
| 72 | mock_config.scheduler_config.kv_conductor_config.cpu_endpoint = "" | 73 | mock_config.scheduler_config.kv_conductor_config.cpu_endpoint = "" |
| 73 | mock_config.scheduler_config.kv_conductor_config.disk_endpoint = "" | 74 | mock_config.scheduler_config.kv_conductor_config.disk_endpoint = "" |
| @@ -101,6 +102,7 @@ def test_register_post_formats_ipv6_endpoint_and_conductor_address() -> None: | |||
| 101 | mock_config.scheduler_config.kv_conductor_config.model_path = "" | 102 | mock_config.scheduler_config.kv_conductor_config.model_path = "" |
| 102 | mock_config.scheduler_config.kv_conductor_config.conductor_service = "2001:db8::10" | 103 | mock_config.scheduler_config.kv_conductor_config.conductor_service = "2001:db8::10" |
| 103 | mock_config.scheduler_config.kv_conductor_config.http_server_port = 13333 | 104 | mock_config.scheduler_config.kv_conductor_config.http_server_port = 13333 |
| 105 | + mock_config.scheduler_config.kv_conductor_config.npu_endpoint = "" | ||
| 104 | mock_config.scheduler_config.kv_conductor_config.xpu_endpoint = "" | 106 | mock_config.scheduler_config.kv_conductor_config.xpu_endpoint = "" |
| 105 | mock_config.scheduler_config.kv_conductor_config.cpu_endpoint = "" | 107 | mock_config.scheduler_config.kv_conductor_config.cpu_endpoint = "" |
| 106 | mock_config.scheduler_config.kv_conductor_config.disk_endpoint = "" | 108 | mock_config.scheduler_config.kv_conductor_config.disk_endpoint = "" |
| @@ -118,9 +120,9 @@ def test_register_post_formats_ipv6_endpoint_and_conductor_address() -> None: | |||
| 118 | mock_http_client.assert_called_once() | 120 | mock_http_client.assert_called_once() |
| 119 | assert mock_http_client.call_args.kwargs["address"] == "[2001:db8::10]:13333" | 121 | assert mock_http_client.call_args.kwargs["address"] == "[2001:db8::10]:13333" |
| 120 | register_payload = mock_http_client.return_value.__enter__.return_value.post.call_args[0][1] | 122 | register_payload = mock_http_client.return_value.__enter__.return_value.post.call_args[0][1] |
| 121 | - # Endpoints are now wrapped in medium_endpoints dict (xpu/cpu/disk). | 123 | + # Endpoints are now wrapped in medium_endpoints dict (npu/cpu/disk). |
| 122 | # When per-medium fields are empty, all fall back to the legacy "endpoint". | 124 | # When per-medium fields are empty, all fall back to the legacy "endpoint". |
| 123 | - assert register_payload["medium_endpoints"]["xpu"] == "tcp://[2001:db8::1]:5559" | 125 | + assert register_payload["medium_endpoints"]["npu"] == "tcp://[2001:db8::1]:5559" |
| 124 | assert register_payload["replay_endpoint"] == "tcp://[2001:db8::1]:6669" | 126 | assert register_payload["replay_endpoint"] == "tcp://[2001:db8::1]:6669" |
| 125 | 127 | ||
| 126 | 128 | ||


单 worker 短路分支里 last_seq 取当前 block_hash,但 matched_depth 未包含该块,续接时可能用错 breakpoint。