Files
lightseekorg--tokenspeed/python/tokenspeed/runtime/pd/epd/encode_loop.py
T
wehub-resource-sync 59a0a3844c
PR Test AMD / cancel-on-close (push) Has been skipped
PR Test NVIDIA ARM / scan (push) Has been skipped
PR Test NVIDIA / cancel-on-close (push) Has been skipped
PR Test AMD / scan (push) Has been skipped
PR Test NVIDIA ARM / cancel-on-close (push) Has been skipped
PR Test NVIDIA / scan (push) Has been skipped
Release Docker Images / build (cu129-torch-2.11.0) (push) Has been skipped
Release Docker Images / build (cu130-torch-2.11.0) (push) Has been skipped
Release PyPI / publish (push) Has been skipped
Scheduler Python Test / test (push) Successful in 27m19s
Docs / build (push) Successful in 28m8s
Scheduler C++ Test / test (push) Successful in 28m19s
Scheduler C++ Test / test-flat (push) Successful in 28m18s
Docs / deploy (push) Has been cancelled
PR Test AMD / finish (push) Has been cancelled
PR Test NVIDIA / finish (push) Has been cancelled
PR Test NVIDIA ARM / finish (push) Has been cancelled
PR Test NVIDIA ARM / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
PR Test AMD / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
PR Test NVIDIA / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:32:31 +08:00

316 lines
14 KiB
Python

# Copyright (c) 2026 LightSeek Foundation
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
"""EPD encode-worker loop: a lightweight, LM-free scheduler subprocess.
The encode role runs the vision tower and ships image embeddings to prefill
workers over Mooncake; it owns no KV cache and never runs the language model, so
it does NOT use the full :class:`EventLoop`/C++ scheduler (that machinery -- paged
KV, chunked prefill, retract, token budget -- is an impedance mismatch for a ViT).
Instead ``run_event_loop`` branches here when ``disaggregation_mode == "encode"``.
The assembly is: load the model, stand up the Mooncake encode manager + bootstrap
server, the DisaggEncodeExecutor, and the EncodeWorker, reusing the SAME request
IPC the LM scheduler uses: a ZMQ PULL
on ``port_args.scheduler_input_ipc_name``. The smg grpc_servicer's TokenSpeedEncoder
handler sends an :class:`EncodeRequest` (pickled) over that channel; the loop
drains them into ``EncodeWorker.submit`` and runs ``step`` to encode + transfer.
TP: the vision tower is TP-sharded, so all encode ranks run each batch in lockstep
-- rank 0 owns the gateway ZMQ and broadcasts every batch to the TP group. The
embedding transport is a 1->N broadcast: prefill_tp must be a multiple of
encode_tp. Data-parallel encode workers inside one server are not supported;
horizontal scale comes from multiple independent encode servers selected by the
gateway.
"""
from __future__ import annotations
import time
import zmq
from tokenspeed.runtime.cache.embedding_cache import (
EmbeddingCache,
TieredEmbeddingCache,
)
from tokenspeed.runtime.pd.epd.encode_scheduler import EncodeScheduler
from tokenspeed.runtime.pd.epd.encode_worker import EncodeWorker
from tokenspeed.runtime.utils import get_colorful_logger, get_zmq_socket
from tokenspeed.runtime.utils.env import envs
logger = get_colorful_logger(__name__)
# Vision-embedding cache capacity. L1 lives in GPU VRAM; the optional L2 lives in
# host DRAM and catches L1 evictions so duplicate images skip the tower even past
# the VRAM working set. Both are whole-MiB env overrides. L2 defaults to 0
# (disabled): the host tier is opt-in. NOTE both knobs are PER ENCODE PROCESS (per
# TP rank): at encode TP>1, every co-located rank allocates its own L1+L2, so
# budget host DRAM as tp_size * EMBED_CACHE_DRAM_MB.
def _embedding_cache_bytes(env_field) -> int:
"""Whole-MiB env field -> bytes.
EnvField handles parsing and defaults; negative capacities are rejected here
with an env-named error.
"""
mb = env_field.get()
if mb < 0:
raise ValueError(f"{env_field.name} must be >= 0 MiB, got {mb}")
return mb * 1024 * 1024
def _make_embedding_cache(l1_bytes: int, l2_bytes: int, device: str):
"""Select the encode embedding cache: a plain single-tier VRAM
:class:`EmbeddingCache` by default, or a two-tier :class:`TieredEmbeddingCache`
(VRAM L1 + host-DRAM L2) when the L2 capacity is enabled (``l2_bytes > 0``)."""
if l2_bytes > 0:
logger.info(
"EPD encode embedding cache: L1(VRAM)=%d MiB, L2(host DRAM)=%d MiB",
l1_bytes >> 20,
l2_bytes >> 20,
)
return TieredEmbeddingCache(l1_bytes, l2_bytes, device=device)
logger.info(
"EPD encode embedding cache: L1(VRAM)=%d MiB (host-DRAM L2 disabled)",
l1_bytes >> 20,
)
return EmbeddingCache(l1_bytes)
def _build_manager_args(server_args, mapping):
"""EmbeddingManagerArgs for the encode Mooncake endpoint.
EPD embedding transfer currently supports TP only. Horizontal scale is via
separate encode servers selected by the gateway; attention DP inside one
encode server is rejected so it cannot be accidentally advertised as TP.
"""
from tokenspeed.runtime.pd.epd.entities import EmbeddingManagerArgs
if mapping.attn.dp_size != 1:
raise ValueError(
"disaggregation_mode=encode currently supports encode "
f"data_parallel_size == 1, got {mapping.attn.dp_size}"
)
bootstrap_host = None
if server_args.dist_init_addr:
bootstrap_host = server_args.dist_init_addr.split(":", 1)[0]
return EmbeddingManagerArgs(
bootstrap_port=server_args.disaggregation_bootstrap_port,
tp_size=mapping.attn.tp_size,
bootstrap_host=bootstrap_host,
)
def _maybe_install_encoder_cudagraph(model, server_args) -> bool:
"""Install the vision-encoder CUDA-graph wrapper as ``model.image_encoder``,
mirroring the aggregated path's hook in ``execution/model_executor.py``.
The encode loop never builds a ModelExecutor, so this is where the encode
worker opts into capture/replay of the tower instead of running it eager. Same
gate as the aggregated install: the model exposes the builder, multimodal is
active, the env flag is on, and the attention backend is graph-capturable. The
wrapper IS the model's ``image_encoder`` seam (lazy capture on first encode);
the executor's IMAGE path dispatches through ``model.image_encoder`` and falls
back to eager ``get_image_feature`` (the default) when this returns False.
Returns whether the wrapper was installed.
"""
if not (
hasattr(model, "make_encoder_cudagraph_wrapper")
and getattr(model, "is_multimodal_active", True)
and envs.TOKENSPEED_MM_ENABLE_ENCODER_CUDA_GRAPH.get()
and server_args.mm_attention_backend != "flashinfer_cudnn"
):
return False
model.image_encoder = model.make_encoder_cudagraph_wrapper(model.mapping)
logger.info("EPD encode worker: vision-encoder CUDA graph installed")
return True
def _build_encode_worker(server_args, port_args, gpu_id, global_rank):
"""Assemble the encode worker: model + Mooncake manager + bootstrap server +
executor + scheduler + cache, driven from the real ServerArgs."""
from tokenspeed.runtime.configs.model_config import ModelConfig
from tokenspeed.runtime.execution.distributed_initializer import (
DistributedConfig,
DistributedInitializer,
)
from tokenspeed.runtime.execution.factory import create_model_runner
from tokenspeed.runtime.pd.epd.conn import MooncakeEmbeddingBootstrapServer
from tokenspeed.runtime.pd.epd.embedding_transfer import (
MooncakeEmbeddingManagerEncode,
)
from tokenspeed.runtime.pd.epd.encode_executor import (
DisaggEncodeExecutor,
)
from tokenspeed.runtime.pd.epd.entities import EmbeddingArgs
mapping = server_args.mapping
attn_tp_rank = mapping.attn.tp_rank
device = f"cuda:{gpu_id}"
model_config = ModelConfig(
server_args.model,
trust_remote_code=server_args.trust_remote_code,
revision=server_args.revision,
context_length=server_args.max_model_len,
model_override_args=server_args.hf_overrides,
dtype=server_args.dtype,
quantization=server_args.quantization,
server_args=server_args,
)
DistributedInitializer.initialize(
DistributedConfig.from_server_args(
server_args=server_args,
port_args=port_args,
gpu_id=gpu_id,
global_rank=global_rank,
hidden_size=model_config.hidden_size,
max_num_tokens=server_args.chunked_prefill_size or 8192,
)
)
# The encode worker only needs the vision tower. The model is built
# vision-only (LM construction + LM weight load skipped) via the
# encoder_only gate derived from disaggregation_mode=="encode". The tower
# is used via DisaggEncodeExecutor. No KV/mamba pool is allocated: the encode
# loop never builds a ModelExecutor.
model = create_model_runner(server_args, model_config, None, gpu_id, global_rank)[
0
].model
# Opt into vision-encoder CUDA-graph capture (mirrors the aggregated path,
# which the encode worker bypasses by never building a ModelExecutor).
_maybe_install_encoder_cudagraph(model, server_args)
manager_args = _build_manager_args(server_args, mapping)
embedding_args = EmbeddingArgs(
engine_rank=global_rank,
gpu_id=gpu_id,
ib_device=server_args.disaggregation_ib_device,
embedding_data_ptr=0,
embedding_data_len=0,
)
# The encode worker is the Mooncake data source: it hosts its own bootstrap
# server (prefill workers discover it via the handshake the gateway injects).
# At TP>1 only rank 0 binds the port; every rank still registers its own
# rank_ip/rank_port to the rank-0 bootstrap host, so each prefill rank can
# look up the encode rank it pairs with (contiguous blocks of prefill ranks
# share one encode rank; encode_tp=1 -> all prefill ranks pair encode rank 0).
if attn_tp_rank == 0:
MooncakeEmbeddingBootstrapServer(server_args.disaggregation_bootstrap_port)
manager = MooncakeEmbeddingManagerEncode(manager_args, embedding_args)
executor = DisaggEncodeExecutor(manager, model, device=device)
l1_bytes = _embedding_cache_bytes(envs.TOKENSPEED_EPD_ENCODE_EMBED_CACHE_MB)
l2_bytes = _embedding_cache_bytes(envs.TOKENSPEED_EPD_ENCODE_EMBED_CACHE_DRAM_MB)
cache = _make_embedding_cache(l1_bytes, l2_bytes, device)
scheduler = EncodeScheduler(
max_tokens_per_batch=server_args.chunked_prefill_size or 8192,
max_items_per_batch=server_args.max_num_seqs,
)
return EncodeWorker(executor, scheduler, cache), model_config
def run_encode_loop(server_args, port_args, pipe_writer, gpu_id, global_rank):
"""Run the encode-worker loop until the parent process exits.
Drains :class:`EncodeRequest`s off the shared scheduler-input ZMQ channel into
EncodeWorker.submit, then runs EncodeWorker.step to encode + ship each pending
item over Mooncake. Synchronous; no KV, no LM forward.
"""
worker, model_config = _build_encode_worker(
server_args, port_args, gpu_id, global_rank
)
# TP coordination: the vision tower is TP-sharded (collective ops), so every
# encode rank must run each batch in lockstep. Only rank 0 owns the gateway
# ZMQ; it broadcasts each drained batch to the TP group so all ranks submit
# the same requests and step together. (TP=1 -> rank 0 only, no broadcast.)
attn_tp_rank = server_args.mapping.attn.tp_rank
attn_tp_size = server_args.attn_tp_size or server_args.mapping.attn.tp_size
tp_cpu_group = None
broadcast_pyobj = None
attn_tp_src_rank = server_args.mapping.attn.tp_group[0]
if attn_tp_size > 1:
from tokenspeed.runtime.distributed.process_group_manager import (
process_group_manager as pg_manager,
)
from tokenspeed.runtime.utils.common import broadcast_pyobj as _bcast
broadcast_pyobj = _bcast
tp_cpu_group = pg_manager.get_process_group(
"gloo", server_args.mapping.attn.tp_group
)
context = zmq.Context(2)
recv_from_gateway = None
if attn_tp_rank == 0:
recv_from_gateway = get_zmq_socket(
context, zmq.PULL, port_args.scheduler_input_ipc_name, False
)
# Unblock the launcher. The encode role has no KV pool, so the token/seq
# fields are nominal (kept for envelope compatibility with the LM ready msg).
pipe_writer.send(
{
"status": "ready",
"max_total_num_tokens": 0,
"max_req_input_len": model_config.context_len,
"max_num_seqs": server_args.max_num_seqs,
"chunked_prefill_size": server_args.chunked_prefill_size,
"max_model_len": model_config.context_len,
}
)
while True:
# Rank 0 drains the gateway ZMQ without blocking; other ranks get the
# same batch by broadcast below.
new_reqs = []
if attn_tp_rank == 0:
while True:
try:
request = recv_from_gateway.recv_pyobj(flags=zmq.NOBLOCK)
except zmq.Again:
break
new_reqs.append(request)
if attn_tp_size > 1:
# Unconditional every iteration: this is the TP rendezvous that keeps
# all ranks stepping the (collective) vision tower in lockstep.
new_reqs = broadcast_pyobj(
new_reqs, attn_tp_rank, tp_cpu_group, src=attn_tp_src_rank
)
for request in new_reqs:
worker.submit(request)
drained = len(new_reqs) > 0
# Encode + ship one scheduler batch. step() returns 0 when idle. All ranks
# hold the same pending set, so they make the same step decision together.
did = worker.step()
# Yield the GIL when there is no fresh ZMQ work, OR when sends are deferred
# on a full ring. The daemon transfer-workers that free ring slots are
# GIL-bound, so spinning here would starve them and wedge the ring; a brief
# yield lets them free slots so the next drain_deferred() can ship.
if (
not drained and did == 0 and not worker.has_pending()
) or worker.has_deferred():
time.sleep(0.0005)