Source code for apache_beam.ml.inference.vllm_inference

#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements.  See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License.  You may obtain a copy of the License at
#
#    http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

# pytype: skip-file

import asyncio
import logging
import os
import shutil
import subprocess
import sys
import threading
import time
import urllib.request
import uuid
from collections.abc import Callable
from collections.abc import Iterable
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Any
from typing import Optional

from openai import AsyncOpenAI
from openai import OpenAI

from apache_beam.io.filesystems import FileSystems
from apache_beam.ml.inference.base import ModelHandler
from apache_beam.ml.inference.base import PredictionResult
from apache_beam.utils import subprocess_server

try:
  # VLLM logging config breaks beam logging.
  os.environ["VLLM_CONFIGURE_LOGGING"] = "0"
  import vllm  # pylint: disable=unused-import
  logging.info('vllm module successfully imported.')
  os.environ["VLLM_CONFIGURE_LOGGING"] = "1"
except ModuleNotFoundError:
  msg = 'vllm module was not found. This is ok as long as the specified ' \
    'runner has vllm dependencies installed.'
  logging.warning(msg)

__all__ = [
    'OpenAIChatMessage',
    'VLLMCompletionsModelHandler',
    'VLLMChatModelHandler',
]


[docs] @dataclass(frozen=True) class OpenAIChatMessage(): """" Dataclass containing previous chat messages in conversation. Role is the entity that sent the message (either 'user' or 'system'). Content is the contents of the message. """ role: str content: str
def start_process(cmd) -> tuple[subprocess.Popen, int]: port, = subprocess_server.pick_port(None) cmd = [arg.replace('{{PORT}}', str(port)) for arg in cmd] # pylint: disable=not-an-iterable logging.info("Starting service with %s", str(cmd).replace("',", "'")) process = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) # Emit the output of this command as info level logging. def log_stdout(): line = process.stdout.readline() while line: # The log obtained from stdout is bytes, decode it into string. # Remove newline via rstrip() to not print an empty line. logging.info(line.decode(errors='backslashreplace').rstrip()) line = process.stdout.readline() t = threading.Thread(target=log_stdout) t.daemon = True t.start() return process, port def getVLLMClient(port) -> OpenAI: openai_api_key = "EMPTY" openai_api_base = f"http://localhost:{port}/v1" return OpenAI( api_key=openai_api_key, base_url=openai_api_base, ) def getAsyncVLLMClient(port) -> AsyncOpenAI: openai_api_key = "EMPTY" openai_api_base = f"http://localhost:{port}/v1" return AsyncOpenAI( api_key=openai_api_key, base_url=openai_api_base, ) # Embedded Dynamo runtime defaults proven on the smoke test: etcd discovery, # TCP request plane, ZMQ event plane, KV events disabled. KV-aware routing, # disaggregated prefill/decode, and the Planner are not active in this mode. _DYNAMO_FRONTEND_DEFAULT_KWARGS: dict[str, Optional[str]] = { 'discovery-backend': 'etcd', 'request-plane': 'tcp', 'event-plane': 'zmq', 'router-mode': 'round-robin', 'no-router-kv-events': None, } _DYNAMO_ENGINE_DEFAULT_KWARGS: dict[str, Optional[str]] = { 'discovery-backend': 'etcd', 'request-plane': 'tcp', 'event-plane': 'zmq', 'kv-events-config': '{"enable_kv_cache_events": false}', } def _append_kwargs(cmd: list[str], kwargs: dict[str, Optional[str]]) -> None: for k, v in kwargs.items(): cmd.append(f'--{k}') # Only add values for commands with value part. if v is not None: cmd.append(v) def _uses_etcd_discovery(kwargs: dict[str, Optional[str]]) -> bool: return kwargs.get('discovery-backend') == 'etcd' class _VLLMModelServer(): def __init__( self, model_name: str, vllm_server_kwargs: dict[str, Optional[str]], dynamo_frontend_kwargs: Optional[dict[str, Optional[str]]] = None, use_dynamo: bool = False): self._model_name = model_name self._vllm_server_kwargs = vllm_server_kwargs self._dynamo_frontend_kwargs = dynamo_frontend_kwargs or {} self._server_started = False self._server_process = None self._dynamo_process = None self._etcd_process = None self._etcd_data_dir: Optional[str] = None self._managed_etcd_endpoint = None self._server_port: int = -1 self._server_process_lock = threading.RLock() self._use_dynamo = use_dynamo self.start_server() @staticmethod def _stop_process(process: Optional[subprocess.Popen]) -> None: if process is None or process.poll() is not None: return # A process may exit between poll() and terminate() / kill(), in which # case the OS raises ProcessLookupError (or another OSError). Treat that # as already-stopped so we don't bail out of the broader cleanup. try: process.terminate() try: process.wait(timeout=10) except subprocess.TimeoutExpired: process.kill() process.wait() except OSError: pass def _stop_processes(self) -> None: self._stop_process(self._dynamo_process) self._stop_process(self._server_process) self._stop_process(self._etcd_process) if (self._managed_etcd_endpoint is not None and os.environ.get('ETCD_ENDPOINTS') == self._managed_etcd_endpoint): os.environ.pop('ETCD_ENDPOINTS', None) if self._etcd_data_dir is not None: shutil.rmtree(self._etcd_data_dir, ignore_errors=True) self._etcd_data_dir = None self._dynamo_process = None self._server_process = None self._etcd_process = None self._managed_etcd_endpoint = None self._server_started = False self._server_port = -1 def _process_status(self) -> str: process_status = [] if self._server_process is not None: process_status.append( 'frontend/server exit code: %s' % self._server_process.poll()) if self._dynamo_process is not None: process_status.append( 'dynamo worker exit code: %s' % self._dynamo_process.poll()) if self._etcd_process is not None: process_status.append('etcd exit code: %s' % self._etcd_process.poll()) return ', '.join(process_status) or 'no process status available' def __del__(self): # __del__ may run during interpreter shutdown when module globals can # already be torn down; swallow any cleanup failures so we don't print # a noisy traceback. try: self._stop_processes() except Exception: # pylint: disable=broad-except pass def _uses_embedded_etcd(self) -> bool: return ( self._use_dynamo and _uses_etcd_discovery(self._dynamo_frontend_kwargs) and _uses_etcd_discovery(self._vllm_server_kwargs) and 'ETCD_ENDPOINTS' not in os.environ) def _wait_for_etcd(self, endpoint: str, timeout_secs=30) -> None: deadline = time.time() + timeout_secs health_url = endpoint.rstrip('/') + '/health' while time.time() < deadline and self._etcd_process.poll() is None: try: with urllib.request.urlopen(health_url, timeout=2) as response: if response.status < 500: return except Exception: # pylint: disable=broad-except time.sleep(1) process_status = self._process_status() self._stop_processes() raise RuntimeError( "Failed to start embedded etcd for Dynamo. Process status: " f"{process_status}. Install etcd in the worker container or set " "ETCD_ENDPOINTS to an external etcd service.") def _ensure_etcd(self) -> None: if not self._uses_embedded_etcd(): return if shutil.which('etcd') is None: raise RuntimeError( "Embedded Dynamo mode requires etcd when ETCD_ENDPOINTS is not " "set. Install etcd in the worker container or set ETCD_ENDPOINTS " "to an external etcd service.") etcd_name = f'beam-dynamo-etcd-{uuid.uuid4().hex}' self._etcd_data_dir = f'/tmp/{etcd_name}' peer_port, = subprocess_server.pick_port(None) etcd_cmd = [ 'etcd', '--name', etcd_name, '--listen-client-urls', 'http://127.0.0.1:{{PORT}}', '--advertise-client-urls', 'http://127.0.0.1:{{PORT}}', '--listen-peer-urls', f'http://127.0.0.1:{peer_port}', '--initial-advertise-peer-urls', f'http://127.0.0.1:{peer_port}', '--initial-cluster', f'{etcd_name}=http://127.0.0.1:{peer_port}', '--data-dir', self._etcd_data_dir, '--log-level', 'warn', ] self._etcd_process, etcd_port = start_process(etcd_cmd) endpoint = f'http://127.0.0.1:{etcd_port}' os.environ['ETCD_ENDPOINTS'] = endpoint self._managed_etcd_endpoint = endpoint self._wait_for_etcd(endpoint) def start_server(self, retries=3): with self._server_process_lock: if not self._server_started: self._stop_processes() self._ensure_etcd() if self._use_dynamo: # Dynamo embedded mode uses the frontend as its OpenAI-compatible # local endpoint and a separate vLLM worker process. server_cmd = [ sys.executable, '-m', 'dynamo.frontend', '--http-port', '{{PORT}}', ] _append_kwargs(server_cmd, self._dynamo_frontend_kwargs) else: server_cmd = [ sys.executable, '-m', 'vllm.entrypoints.openai.api_server', '--model', self._model_name, '--port', '{{PORT}}', ] _append_kwargs(server_cmd, self._vllm_server_kwargs) self._server_process, self._server_port = start_process(server_cmd) if self._use_dynamo: server_cmd = [ sys.executable, '-m', 'dynamo.vllm', '--model', self._model_name, ] _append_kwargs(server_cmd, self._vllm_server_kwargs) self._dynamo_process, _ = start_process(server_cmd) self.check_connectivity(retries) def get_server_port(self) -> int: if not self._server_started: self.start_server() return self._server_port def check_connectivity(self, retries=3, timeout_secs=600): start_time = time.time() with getVLLMClient(self._server_port) as client: while (time.time() - start_time < timeout_secs and self._server_process.poll() is None and (self._dynamo_process is None or self._dynamo_process.poll() is None) and (self._etcd_process is None or self._etcd_process.poll() is None)): try: models = client.models.list().data logging.info('models: %s' % models) if len(models) > 0: self._server_started = True return except: # pylint: disable=bare-except pass # Sleep while bringing up the process time.sleep(5) process_status = self._process_status() self._stop_processes() if retries == 0: raise Exception( "Failed to start vLLM server. Process status: " f"{process_status}. Next time a request is tried, the server " "will be restarted") else: self.start_server(retries - 1)
[docs] class VLLMCompletionsModelHandler(ModelHandler[str, PredictionResult, _VLLMModelServer]): def __init__( self, model_name: str, vllm_server_kwargs: Optional[dict[str, Optional[str]]] = None, *, use_dynamo: bool = False, dynamo_frontend_kwargs: Optional[dict[str, Optional[str]]] = None, min_batch_size: Optional[int] = None, max_batch_size: Optional[int] = None, max_batch_duration_secs: Optional[int] = None, max_batch_weight: Optional[int] = None, element_size_fn: Optional[Callable[[Any], int]] = None, batch_length_fn: Optional[Callable[[Any], int]] = None, batch_bucket_boundaries: Optional[list[int]] = None): """Implementation of the ModelHandler interface for vLLM using text as input. Example Usage:: pcoll | RunInference(VLLMModelHandler(model_name='facebook/opt-125m')) Args: model_name: The vLLM model. See https://docs.vllm.ai/en/latest/models/supported_models.html for supported models. vllm_server_kwargs: Any additional kwargs to be passed into your vllm server when it is being created. When ``use_dynamo`` is disabled, this is invoked using ``python -m vllm.entrypoints.openai.api_server <beam provided args> <vllm_server_kwargs>``. When ``use_dynamo`` is enabled, these kwargs are passed to the ``dynamo.vllm`` worker process. For example, you could pass ``{'echo': 'true'}`` to prepend new messages with the previous message. On ~16GB GPUs, pass lower ``max-num-seqs`` and ``gpu-memory-utilization`` values (see ``apache_beam.examples.inference.vllm_text_completion``). For a list of possible kwargs, see https://docs.vllm.ai/en/latest/serving/openai_compatible_server.html#extra-parameters-for-completions-api use_dynamo: Whether to use NVIDIA Dynamo as the underlying vLLM engine. Requires installing Dynamo in your runtime environment (``pip install ai-dynamo[vllm]``). This is an opt-in single-worker embedded mode; KV-aware routing, disaggregated prefill/decode, KVBM offload across nodes, the Planner, and Grove are not active in embedded mode. Dynamo also requires an etcd-style discovery service: when ``ETCD_ENDPOINTS`` is unset, Beam starts a local etcd, which requires the ``etcd`` binary in the worker environment. dynamo_frontend_kwargs: Additional kwargs to be passed to the ``dynamo.frontend`` process when ``use_dynamo`` is enabled. By default, embedded Dynamo uses etcd discovery, TCP request plane, ZMQ event plane, round-robin routing, and disables router KV events. min_batch_size: optional. the minimum batch size to use when batching inputs. max_batch_size: optional. the maximum batch size to use when batching inputs. max_batch_duration_secs: optional. the maximum amount of time to buffer a batch before emitting; used in streaming contexts. max_batch_weight: optional. the maximum total weight of a batch. element_size_fn: optional. a function that returns the size (weight) of an element. batch_length_fn: optional. a callable that returns the length of an element for length-aware batching. batch_bucket_boundaries: optional. a sorted list of positive boundary values for length-aware batching buckets. """ super().__init__( min_batch_size=min_batch_size, max_batch_size=max_batch_size, max_batch_duration_secs=max_batch_duration_secs, max_batch_weight=max_batch_weight, element_size_fn=element_size_fn, batch_length_fn=batch_length_fn, batch_bucket_boundaries=batch_bucket_boundaries) self._model_name = model_name self._vllm_server_kwargs: dict[str, Optional[str]] = ({ **_DYNAMO_ENGINE_DEFAULT_KWARGS, **(vllm_server_kwargs or {}) } if use_dynamo else vllm_server_kwargs or {}) self._dynamo_frontend_kwargs: dict[str, Optional[str]] = { **_DYNAMO_FRONTEND_DEFAULT_KWARGS, **(dynamo_frontend_kwargs or {}) } self._use_dynamo = use_dynamo
[docs] def load_model(self) -> _VLLMModelServer: return _VLLMModelServer( self._model_name, self._vllm_server_kwargs, self._dynamo_frontend_kwargs, self._use_dynamo)
async def _async_run_inference( self, batch: Sequence[str], model: _VLLMModelServer, inference_args: Optional[dict[str, Any]] = None ) -> Iterable[PredictionResult]: inference_args = inference_args or {} async with getAsyncVLLMClient(model.get_server_port()) as client: try: async_predictions = [ client.completions.create( model=self._model_name, prompt=prompt, **inference_args) for prompt in batch ] responses = await asyncio.gather(*async_predictions) except Exception as e: model.check_connectivity() raise e return [PredictionResult(x, y) for x, y in zip(batch, responses)]
[docs] def run_inference( self, batch: Sequence[str], model: _VLLMModelServer, inference_args: Optional[dict[str, Any]] = None ) -> Iterable[PredictionResult]: """Runs inferences on a batch of text strings. Args: batch: A sequence of examples as text strings. model: A _VLLMModelServer containing info for connecting to the server. inference_args: Any additional arguments for an inference. Returns: An Iterable of type PredictionResult. """ return asyncio.run(self._async_run_inference(batch, model, inference_args))
[docs] def validate_inference_args(self, inference_args: Optional[dict[str, Any]]): # Override the base validator so OpenAI-compatible request kwargs such as # ``max_tokens`` can be passed through ``RunInference`` to the vLLM / # Dynamo server. pass
[docs] def share_model_across_processes(self) -> bool: return True
[docs] class VLLMChatModelHandler(ModelHandler[Sequence[OpenAIChatMessage], PredictionResult, _VLLMModelServer]): def __init__( self, model_name: str, chat_template_path: Optional[str] = None, vllm_server_kwargs: Optional[dict[str, Optional[str]]] = None, *, use_dynamo: bool = False, dynamo_frontend_kwargs: Optional[dict[str, Optional[str]]] = None, min_batch_size: Optional[int] = None, max_batch_size: Optional[int] = None, max_batch_duration_secs: Optional[int] = None, max_batch_weight: Optional[int] = None, element_size_fn: Optional[Callable[[Any], int]] = None, batch_length_fn: Optional[Callable[[Any], int]] = None, batch_bucket_boundaries: Optional[list[int]] = None): """ Implementation of the ModelHandler interface for vLLM using previous messages as input. Example Usage:: pcoll | RunInference(VLLMModelHandler(model_name='facebook/opt-125m')) Args: model_name: The vLLM model. See https://docs.vllm.ai/en/latest/models/supported_models.html for supported models. chat_template_path: Path to a chat template. This file must be accessible from your runner's execution environment, so it is recommended to use a cloud based file storage system (e.g. Google Cloud Storage). For info on chat templates, see: https://docs.vllm.ai/en/latest/serving/openai_compatible_server.html#chat-template vllm_server_kwargs: Any additional kwargs to be passed into your vllm server when it is being created. When ``use_dynamo`` is disabled, this is invoked using ``python -m vllm.entrypoints.openai.api_server <beam provided args> <vllm_server_kwargs>``. When ``use_dynamo`` is enabled, these kwargs are passed to the ``dynamo.vllm`` worker process. For example, you could pass ``{'echo': 'true'}`` to prepend new messages with the previous message. For a list of possible kwargs, see https://docs.vllm.ai/en/latest/serving/openai_compatible_server.html#extra-parameters-for-chat-api use_dynamo: Whether to use NVIDIA Dynamo as the underlying vLLM engine. Requires installing Dynamo in your runtime environment (``pip install ai-dynamo[vllm]``). This is an opt-in single-worker embedded mode; KV-aware routing, disaggregated prefill/decode, KVBM offload across nodes, the Planner, and Grove are not active in embedded mode. Dynamo also requires an etcd-style discovery service: when ``ETCD_ENDPOINTS`` is unset, Beam starts a local etcd, which requires the ``etcd`` binary in the worker environment. dynamo_frontend_kwargs: Additional kwargs to be passed to the ``dynamo.frontend`` process when ``use_dynamo`` is enabled. By default, embedded Dynamo uses etcd discovery, TCP request plane, ZMQ event plane, round-robin routing, and disables router KV events. min_batch_size: optional. the minimum batch size to use when batching inputs. max_batch_size: optional. the maximum batch size to use when batching inputs. max_batch_duration_secs: optional. the maximum amount of time to buffer a batch before emitting; used in streaming contexts. max_batch_weight: optional. the maximum total weight of a batch. element_size_fn: optional. a function that returns the size (weight) of an element. batch_length_fn: optional. a callable that returns the length of an element for length-aware batching. batch_bucket_boundaries: optional. a sorted list of positive boundary values for length-aware batching buckets. """ super().__init__( min_batch_size=min_batch_size, max_batch_size=max_batch_size, max_batch_duration_secs=max_batch_duration_secs, max_batch_weight=max_batch_weight, element_size_fn=element_size_fn, batch_length_fn=batch_length_fn, batch_bucket_boundaries=batch_bucket_boundaries) self._model_name = model_name self._vllm_server_kwargs: dict[str, Optional[str]] = ({ **_DYNAMO_ENGINE_DEFAULT_KWARGS, **(vllm_server_kwargs or {}) } if use_dynamo else vllm_server_kwargs or {}) self._dynamo_frontend_kwargs: dict[str, Optional[str]] = { **_DYNAMO_FRONTEND_DEFAULT_KWARGS, **(dynamo_frontend_kwargs or {}) } self._chat_template_path = chat_template_path self._chat_file = f'template-{uuid.uuid4().hex}.jinja' self._use_dynamo = use_dynamo
[docs] def load_model(self) -> _VLLMModelServer: chat_template_contents = '' if self._chat_template_path is not None: local_chat_template_path = os.path.join(os.getcwd(), self._chat_file) if not os.path.exists(local_chat_template_path): with FileSystems.open(self._chat_template_path) as fin: chat_template_contents = fin.read().decode() with open(local_chat_template_path, 'a') as f: f.write(chat_template_contents) self._vllm_server_kwargs['chat_template'] = local_chat_template_path return _VLLMModelServer( self._model_name, self._vllm_server_kwargs, self._dynamo_frontend_kwargs, self._use_dynamo)
async def _async_run_inference( self, batch: Sequence[Sequence[OpenAIChatMessage]], model: _VLLMModelServer, inference_args: Optional[dict[str, Any]] = None ) -> Iterable[PredictionResult]: inference_args = inference_args or {} async with getAsyncVLLMClient(model.get_server_port()) as client: try: async_predictions = [ client.chat.completions.create( model=self._model_name, messages=[{ "role": message.role, "content": message.content } for message in messages], **inference_args) for messages in batch ] predictions = await asyncio.gather(*async_predictions) except Exception as e: model.check_connectivity() raise e return [PredictionResult(x, y) for x, y in zip(batch, predictions)]
[docs] def run_inference( self, batch: Sequence[Sequence[OpenAIChatMessage]], model: _VLLMModelServer, inference_args: Optional[dict[str, Any]] = None ) -> Iterable[PredictionResult]: """Runs inferences on a batch of text strings. Args: batch: A sequence of examples as OpenAI messages. model: A _VLLMModelServer for connecting to the spun up server. inference_args: Any additional arguments for an inference. Returns: An Iterable of type PredictionResult. """ return asyncio.run(self._async_run_inference(batch, model, inference_args))
[docs] def validate_inference_args(self, inference_args: Optional[dict[str, Any]]): # Override the base validator so OpenAI-compatible request kwargs such as # ``max_tokens`` can be passed through ``RunInference`` to the vLLM / # Dynamo server. pass
[docs] def share_model_across_processes(self) -> bool: return True