Files
wehub-resource-sync 770d92cb1f
Lint / lint (push) Waiting to run
Windows CI / Windows (push) Waiting to run
Build Docs / Deploy Docs (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:23:58 +08:00

491 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Asynchronous embedding inference engine for encoder and decoder models."""
import asyncio
import concurrent.futures
import json
import os
from typing import List, Literal, Optional, Tuple, Union # noqa: UP035
import numpy as np
import tvm
from tvm import relax
from tvm.runtime import Device
from tvm_ffi import Shape
from mlc_llm.serve import engine_utils
from mlc_llm.support.auto_device import detect_device
from mlc_llm.tokenizers import Tokenizer
class AsyncEmbeddingEngine:
"""Asynchronous embedding inference engine.
Supports both encoder models (BERT-style) and decoder-only embedding models
(e.g. Qwen3-Embeddings). Uses a ThreadPoolExecutor for background inference
so that the asyncio event loop is not blocked.
Parameters
----------
model : str
Path to the model weight directory.
model_lib : str
Path to the compiled model library (.so/.dylib file).
device : Union[str, Device]
Device string, e.g. "auto", "cuda:0", "metal".
pooling_strategy : Optional[str]
Pooling strategy: "cls" (first token), "mean" (masked average),
or "last" (last token). If None, auto-detected based on model type:
encoder -> "cls", decoder -> "last".
"""
def __init__(
self,
model: str,
model_lib: str,
device: Union[str, Device] = "auto",
*,
pooling_strategy: Optional[str] = None,
) -> None:
# Reuse existing utility: device detection
self.device = detect_device(device) if isinstance(device, str) else device
# Reuse existing utility: tokenizer
self.tokenizer = Tokenizer(model)
# Load TVM module, metadata, and params via engine_utils helpers
ex = tvm.runtime.load_module(model_lib)
vm = relax.VirtualMachine(ex, device=self.device)
self._mod = vm.module
self._metadata = json.loads(self._mod["_metadata"]())
self._params = engine_utils.load_embedding_params(model, self.device, self._metadata)
# Detect model type and set pooling strategy
self.embedding_metadata = engine_utils.get_embedding_metadata(self._metadata)
if self.embedding_metadata:
self.model_type = self.embedding_metadata["model_type"]
self.pooling_strategy = self.embedding_metadata["pooling_strategy"]
self.normalize = self.embedding_metadata["normalize"]
else:
self.model_type = engine_utils.detect_embedding_model_type(self._mod)
self.pooling_strategy = "cls" if self.model_type == "encoder" else "last"
self.normalize = True
# Allow caller to override pooling strategy
if pooling_strategy:
self.pooling_strategy = pooling_strategy
# Initialize model-type-specific functions
if self.model_type == "encoder":
self._init_encoder(model)
else:
self._init_decoder(model)
# Background thread pool (1 worker = serialized GPU inference)
self._executor = concurrent.futures.ThreadPoolExecutor(
max_workers=1, thread_name_prefix="embedding"
)
self._terminated = False
def _init_encoder(self, model: str) -> None:
"""Initialize encoder (BERT-style) model functions and special tokens."""
self._prefill_func = self._mod["prefill"]
self._cls_token_id: Optional[int] = None
self._sep_token_id: Optional[int] = None
tok_config_path = os.path.join(model, "tokenizer_config.json")
if os.path.exists(tok_config_path):
with open(tok_config_path, encoding="utf-8") as f:
tok_config = json.load(f)
# Try added_tokens_decoder first (newer HF format)
added = tok_config.get("added_tokens_decoder", {})
for tid, info in added.items():
if info.get("content") == tok_config.get("cls_token"):
self._cls_token_id = int(tid)
if info.get("content") == tok_config.get("sep_token"):
self._sep_token_id = int(tid)
# Fallback: encode the special token strings via tokenizer
if self._cls_token_id is None and tok_config.get("cls_token"):
ids = list(self.tokenizer.encode(tok_config["cls_token"]))
if len(ids) == 1:
self._cls_token_id = ids[0]
if self._sep_token_id is None and tok_config.get("sep_token"):
ids = list(self.tokenizer.encode(tok_config["sep_token"]))
if len(ids) == 1:
self._sep_token_id = ids[0]
def _init_decoder(self, model: str) -> None:
"""Initialize decoder (Qwen3-Embeddings style) model functions."""
# Prefer tokenizer post-processing (HF-style) for terminal/pooling token handling.
# Only fall back to manual EOS append when tokenizer does not define a post-processor
# that actually appends a token at the end of the sequence.
self._decoder_tokenizer_appends_eos = False
tokenizer_json_path = os.path.join(model, "tokenizer.json")
if os.path.exists(tokenizer_json_path):
with open(tokenizer_json_path, encoding="utf-8") as f:
tokenizer_json = json.load(f)
post_proc = tokenizer_json.get("post_processor")
if post_proc is not None:
# Check if the post-processor actually appends a special token at the end
# (e.g. TemplateProcessing with "$A <|endoftext|>"). We verify by encoding
# a test string and checking if the last token is a known special token.
test_tokens = list(self.tokenizer.encode("test"))
if len(test_tokens) > 0:
vocab = tokenizer_json.get("added_tokens", [])
special_ids = {t["id"] for t in vocab if t.get("special", False)}
if test_tokens[-1] in special_ids:
self._decoder_tokenizer_appends_eos = True
# Read EOS token from config — fallback only when tokenizer does not auto-append.
self._decoder_eos_token_id: Optional[int] = None
config_path = os.path.join(model, "mlc-chat-config.json")
if os.path.exists(config_path):
with open(config_path, encoding="utf-8") as f:
chat_config = json.load(f)
eos = chat_config.get("eos_token_id")
if isinstance(eos, list):
self._decoder_eos_token_id = eos[0]
elif isinstance(eos, int):
self._decoder_eos_token_id = eos
self._embed_func = self._mod["embed"]
self._prefill_to_hidden_func = self._mod["prefill_to_last_hidden_states"]
self._batch_prefill_to_hidden_func = self._mod["batch_prefill_to_last_hidden_states"]
if self._mod.implements_function("create_tir_paged_kv_cache"):
self._create_kv_cache_func = self._mod["create_tir_paged_kv_cache"]
elif self._mod.implements_function("create_flashinfer_paged_kv_cache"):
self._create_kv_cache_func = self._mod["create_flashinfer_paged_kv_cache"]
else:
raise RuntimeError("Cannot find KV cache creation function in model library.")
self._kv_state_add_sequence = tvm.get_global_func("vm.builtin.kv_state_add_sequence")
self._kv_state_remove_sequence = tvm.get_global_func("vm.builtin.kv_state_remove_sequence")
self._kv_state_begin_forward = tvm.get_global_func("vm.builtin.kv_state_begin_forward")
self._kv_state_end_forward = tvm.get_global_func("vm.builtin.kv_state_end_forward")
self._nd_reshape = tvm.get_global_func("vm.builtin.reshape")
def embed(self, inputs: List[str]) -> Tuple[List[List[float]], int]: # noqa: UP006
"""Compute embeddings for a list of input strings (synchronous).
Parameters
----------
inputs : List[str]
The input strings to embed.
Returns
-------
embeddings : List[List[float]]
The L2-normalized embedding vectors.
total_tokens : int
Total number of tokens processed.
"""
if self.model_type == "encoder":
return self._embed_encoder(inputs)
return self._embed_decoder(inputs)
async def async_embed(self, inputs: List[str]) -> Tuple[List[List[float]], int]: # noqa: UP006
"""Compute embeddings asynchronously in a background thread.
This method does not block the asyncio event loop.
Parameters
----------
inputs : List[str]
The input strings to embed.
Returns
-------
embeddings : List[List[float]]
The L2-normalized embedding vectors.
total_tokens : int
Total number of tokens processed.
"""
loop = asyncio.get_running_loop()
return await loop.run_in_executor(self._executor, self.embed, inputs)
def _embed_encoder(
self,
inputs: List[str], # noqa: UP006
) -> Tuple[List[List[float]], int]: # noqa: UP006
"""Encoder model embedding (BERT-style).
Processes each input individually to avoid batch padding artifacts.
Encoder uses bidirectional attention, so chunked prefill is NOT possible
(each token must attend to all other tokens in the full sequence).
Inputs exceeding prefill_chunk_size are truncated.
(Additional Strategy)
TODO: For better long-text support, implement sliding window + mean pooling:
1. Split text into overlapping windows of prefill_chunk_size (stride=chunk/2)
2. Encode each window independently
3. Mean-pool all window embeddings → final embedding → L2 normalize
This preserves information from the full text at the cost of N× compute.
""" # noqa: RUF002
embeddings: List[List[float]] = [] # noqa: UP006
total_tokens = 0
prefill_chunk = self._metadata.get("prefill_chunk_size", 512)
for text in inputs:
tokens = list(self.tokenizer.encode(text))
# Add [CLS] and [SEP] if needed
if self._cls_token_id is not None and (
len(tokens) == 0 or tokens[0] != self._cls_token_id
):
tokens = [self._cls_token_id, *tokens]
if self._sep_token_id is not None and (
len(tokens) == 0 or tokens[-1] != self._sep_token_id
):
tokens = [*tokens, self._sep_token_id]
# Truncate to compiled buffer limit (keep [CLS] at start, [SEP] at end)
if len(tokens) > prefill_chunk:
tokens = tokens[:prefill_chunk]
if self._sep_token_id is not None:
tokens[-1] = self._sep_token_id
seq_len = len(tokens)
total_tokens += seq_len
token_ids = np.array([tokens], dtype=np.int32) # [1, seq_len]
attention_mask: np.ndarray = np.ones((1, seq_len), dtype=np.int32) # [1, seq_len]
tokens_tvm = tvm.runtime.tensor(token_ids, device=self.device)
mask_tvm = tvm.runtime.tensor(attention_mask, device=self.device)
output = self._prefill_func(tokens_tvm, mask_tvm, self._params)
# .numpy() copies to CPU, escaping TVM workspace buffer reuse across calls.
output_np = output.numpy() # [1, seq_len, hidden_size]
# Pooling
if self.pooling_strategy == "cls":
pooled = output_np[0, 0, :]
elif self.pooling_strategy == "mean":
pooled = output_np[0].mean(axis=0)
else: # "last"
pooled = output_np[0, -1, :]
# L2 normalize
pooled = pooled.astype(np.float32)
if self.normalize:
norm = np.linalg.norm(pooled)
if norm > 1e-12:
pooled = pooled / norm
embeddings.append(pooled.tolist())
return embeddings, total_tokens
def _embed_decoder(self, inputs: List[str]) -> Tuple[List[List[float]], int]: # noqa: UP006
"""Decoder model embedding with batch prefill optimization.
When total tokens fit within prefill_chunk_size, all inputs are processed
in a single batch forward pass using shared KV cache. Otherwise, falls back
to sequential chunked prefill per input.
"""
# Read KV cache config from metadata
prefill_chunk = self._metadata.get("prefill_chunk_size", 2048)
max_seq_len = self._metadata.get("context_window_size", 32768)
if max_seq_len == -1:
max_seq_len = self._metadata.get("sliding_window_size", -1)
assert max_seq_len > 0, f"max_seq_len must be positive, got {max_seq_len}"
support_sliding = int(self._metadata.get("sliding_window_size", -1) != -1)
# Tokenize all inputs. Prefer tokenizer post-processor output. If absent (older models),
# fall back to appending eos_token_id when missing.
token_lists: List[List[int]] = [] # noqa: UP006
for text in inputs:
tokens = list(self.tokenizer.encode(text))
if (
not self._decoder_tokenizer_appends_eos
and self._decoder_eos_token_id is not None
and (len(tokens) == 0 or tokens[-1] != self._decoder_eos_token_id)
):
tokens.append(self._decoder_eos_token_id)
if len(tokens) > max_seq_len:
tokens = tokens[:max_seq_len]
token_lists.append(tokens)
total_tokens = sum(len(t) for t in token_lists)
# Fast path: all tokens fit in one prefill chunk → batch forward
if total_tokens <= prefill_chunk and all(len(t) > 0 for t in token_lists):
return self._batch_embed_decoder(
token_lists, total_tokens, max_seq_len, prefill_chunk, support_sliding
)
# Greedy sub-batching: pack texts into sub-batches that fit within
# prefill_chunk, preserving input order. Oversize texts (single text
# exceeding prefill_chunk) fall back to sequential chunked prefill.
sub_batches = self._build_sub_batches(token_lists, prefill_chunk)
all_embeddings: List[List[float]] = [] # noqa: UP006
for batch_type, batch, batch_total in sub_batches:
if batch_type == "batch":
embs, _ = self._batch_embed_decoder(
batch, batch_total, max_seq_len, prefill_chunk, support_sliding
)
else:
embs, _ = self._sequential_embed_decoder(
batch, batch_total, max_seq_len, prefill_chunk, support_sliding
)
all_embeddings.extend(embs)
return all_embeddings, total_tokens
@staticmethod
def _build_sub_batches(
token_lists: List[List[int]], # noqa: UP006
prefill_chunk: int,
) -> List[Tuple[Literal["batch", "sequential"], List[List[int]], int]]: # noqa: UP006
"""Partition token lists into sub-batches that fit within prefill_chunk.
Each sub-batch is a tuple of (mode, token_lists, total_token_count).
Empty token lists are skipped to avoid invalid batch processing.
"""
sub_batches: List[Tuple[Literal["batch", "sequential"], List[List[int]], int]] = [] # noqa: UP006
current_batch: List[List[int]] = [] # noqa: UP006
current_tokens = 0
for tokens in token_lists:
if not tokens:
continue
token_len = len(tokens)
is_oversized = token_len > prefill_chunk
if current_batch and (is_oversized or current_tokens + token_len > prefill_chunk):
sub_batches.append(("batch", current_batch, current_tokens))
current_batch, current_tokens = [], 0
if is_oversized:
sub_batches.append(("sequential", [tokens], token_len))
else:
current_batch.append(tokens)
current_tokens += token_len
if current_batch:
sub_batches.append(("batch", current_batch, current_tokens))
return sub_batches
def _batch_embed_decoder(
self,
token_lists: List[List[int]], # noqa: UP006
total_tokens: int,
max_seq_len: int,
prefill_chunk: int,
support_sliding: int,
) -> Tuple[List[List[float]], int]: # noqa: UP006
"""Batch prefill: process all inputs in a single forward pass."""
batch_size = len(token_lists)
# Create KV cache for the entire batch
kv_cache = self._create_kv_cache_func(
Shape([batch_size]),
Shape([max_seq_len]),
Shape([prefill_chunk]),
Shape([16]),
Shape([support_sliding]),
)
# Register all sequences
seq_ids = list(range(batch_size))
seq_lens = [len(t) for t in token_lists]
for sid in seq_ids:
self._kv_state_add_sequence(kv_cache, sid)
# Begin forward with all sequences at once
self._kv_state_begin_forward(kv_cache, Shape(seq_ids), Shape(seq_lens))
# Concatenate all tokens → embed → batch prefill
all_tokens = []
for tokens in token_lists:
all_tokens.extend(tokens)
token_ids = tvm.runtime.tensor(np.array(all_tokens, dtype=np.int32), device=self.device)
all_embed = self._embed_func(token_ids, self._params)
all_embed = self._nd_reshape(all_embed, Shape([1, total_tokens, all_embed.shape[-1]]))
hidden_states, _ = self._batch_prefill_to_hidden_func(all_embed, kv_cache, self._params)
# .numpy() copies to CPU, escaping TVM workspace buffer reuse across calls.
# (torch.from_dlpack is zero-copy and hits aliasing bugs on 2nd+ invocation.)
hidden_np = hidden_states.numpy()
self._kv_state_end_forward(kv_cache)
for sid in seq_ids:
self._kv_state_remove_sequence(kv_cache, sid)
# Extract last token hidden state per sequence
embeddings: List[List[float]] = [] # noqa: UP006
offset = 0
for tokens in token_lists:
last_pos = offset + len(tokens) - 1
pooled = hidden_np[0, last_pos, :].astype(np.float32)
if self.normalize:
norm = np.linalg.norm(pooled)
if norm > 1e-12:
pooled = pooled / norm
embeddings.append(pooled.tolist())
offset += len(tokens)
return embeddings, total_tokens
def _sequential_embed_decoder(
self,
token_lists: List[List[int]], # noqa: UP006
total_tokens: int,
max_seq_len: int,
prefill_chunk: int,
support_sliding: int,
) -> Tuple[List[List[float]], int]: # noqa: UP006
"""Sequential chunked prefill: process each input independently."""
embeddings: List[List[float]] = [] # noqa: UP006
for tokens in token_lists:
if len(tokens) == 0:
continue
# Create KV cache for this single sequence
kv_cache = self._create_kv_cache_func(
Shape([1]),
Shape([max_seq_len]),
Shape([prefill_chunk]),
Shape([16]),
Shape([support_sliding]),
)
self._kv_state_add_sequence(kv_cache, 0)
# Process tokens in chunks
hidden = None
for chunk_start in range(0, len(tokens), prefill_chunk):
chunk_end = min(chunk_start + prefill_chunk, len(tokens))
chunk_tokens = tokens[chunk_start:chunk_end]
chunk_len = len(chunk_tokens)
token_ids = tvm.runtime.tensor(
np.array(chunk_tokens, dtype=np.int32), device=self.device
)
chunk_embed = self._embed_func(token_ids, self._params)
chunk_embed = self._nd_reshape(
chunk_embed, Shape([1, chunk_len, chunk_embed.shape[-1]])
)
self._kv_state_begin_forward(kv_cache, Shape([0]), Shape([chunk_len]))
hidden, kv_cache = self._prefill_to_hidden_func(chunk_embed, kv_cache, self._params)
# .numpy() copies to CPU, escaping TVM buffer aliasing.
hidden_np = hidden.numpy()
self._kv_state_end_forward(kv_cache)
self._kv_state_remove_sequence(kv_cache, 0)
pooled = hidden_np[0, -1, :] if hidden_np.ndim == 3 else hidden_np[-1, :]
pooled = pooled.astype(np.float32)
if self.normalize:
norm = np.linalg.norm(pooled)
if norm > 1e-12:
pooled = pooled / norm
embeddings.append(pooled.tolist())
return embeddings, total_tokens
def terminate(self) -> None:
"""Terminate the engine and clean up the thread pool."""
if getattr(self, "_terminated", True):
return
self._terminated = True
self._executor.shutdown(wait=False)
def __del__(self):
self.terminate()