Modelplane Modelplane docs

Collecting engine metrics

Reading an inference engine’s metrics, shown on the smallest serving shape: a 0.5B Qwen chat model on one NVIDIA L4. Modelplane collects from every engine it runs, so there is nothing to wire up: a collector on each workload cluster discovers serving pods by label, scrapes the engine port by name, and renames the engine’s metrics onto a common modelplane_* surface. The model is only the subject; the same applies to any engine, with the SGLang, leader/worker, and prefill/decode differences noted at the end.

This was run end to end on GKE. The InferenceClass and ModelDeployment are the exact manifests from that run, and the metric names below are the ones that run produced. Apply the platform side first, then the ML side.

Platform

inference-class.yaml
# InferenceClass for the L4 shape on GKE, validated serving Qwen2.5-0.5B.
#
# One NVIDIA L4 on a g2-standard-8. The GPU is declared as a DRA device: the
# scheduler matches a ModelDeployment's nodeSelector against this capacity, then
# DRA binds the physical GPU to the serving pod. A 0.5B model uses a sliver of
# this L4 - the shape is shared with the larger L4 examples, not sized for it.
apiVersion: modelplane.ai/v1alpha1
kind: InferenceClass
metadata:
  name: gke-l4-1x-g2
spec:
  description: "GKE g2-standard-8, 1x NVIDIA L4"
  provisioning:
    provider: GKE
    gke:
      machineType: g2-standard-8
      diskSizeGb: 100
      accelerator:
        type: nvidia-l4
        count: 1
  devices:
  - name: gpu
    claim: DRA
    driver: gpu.nvidia.com
    deviceClassName: gpu.nvidia.com
    count: 1
    attributes:
      architecture: { string: Ada Lovelace }
    capacity:
      # The L4's real usable VRAM as the NVIDIA DRA driver reports it, not the
      # nominal 24GB.
      memory: { value: "23034Mi" }
inference-cluster.yaml
# GKE InferenceCluster with one L4 node pool. Replace the project ID before
# applying. No clusterSelector targets it; the ModelDeployment matches on device
# capacity alone, so it lands here or on any other compatible cluster.
#
# Modelplane installs an in-cluster Prometheus on this cluster (the monitoring
# namespace) with open PodMonitor discovery - the metrics section of the example
# scrapes the engine through it.
apiVersion: modelplane.ai/v1alpha1
kind: InferenceCluster
metadata:
  name: gke-l4-single
  labels:
    modelplane.ai/cloud: gke
    modelplane.ai/region: us-central
spec:
  cluster:
    source: GKE
    gke:
      region: us-central1
  nodePools:
  - name: gpu-l4
    className: gke-l4-1x-g2
    nodeCount: 1
    zones:
    - us-central1-a
    minNodeCount: 0
    maxNodeCount: 4

Deployment

model-deployment.yaml
# Qwen2.5-0.5B-Instruct served on a single NVIDIA L4 by vLLM, validated end to
# end on GKE.
#
# A 0.5B dense model is the smallest useful serving shape: one Standalone vLLM
# pod, no ModelCache, weights pulled straight from Hugging Face. It barely touches
# the L4's VRAM, so the flags here are about behavior, not fit:
#
#   --max-model-len=16384   caps the context; the default 32K KV cache is wasteful
#                           for a model this size and a demo this small.
#   --served-model-name     the id clients pass as "model" in OpenAI requests.
#
# vLLM exposes Prometheus metrics at /metrics on its serving port (:8000) with no
# extra flag, which is what Modelplane's collector scrapes.
#
# No --port or --host: Modelplane's routing expects the engine on its default
# :8000 with a /health probe, and passes args through verbatim.
apiVersion: modelplane.ai/v1alpha1
kind: ModelDeployment
metadata:
  name: qwen2-5-0-5b
  namespace: ml-team
spec:
  # One replica, matched to any compatible InferenceCluster by device capacity.
  replicas: 1
  template:
    spec:
      engines:
      - name: qwen2-5-0-5b
        # Names the engine so its serving pods carry modelplane.ai/engine, which
        # is how the vLLM MetricMapping selects them. Without it the engine is
        # still collected from, under vLLM's own metric names.
        type: vllm
        members:
    # A single self-contained vLLM pod. The container named "engine" is the
    # inference server; its image and args pass through verbatim.
        - role: Standalone
          nodeSelector:
            devices:
            - name: gpu
              count: 1
              selectors:
          # The model needs almost no VRAM; >=20Gi simply pins it to the L4 pool
          # this example provisions (the L4 reports ~23Gi). DRA evaluates this CEL
          # against the InferenceClass device, then against the GPU's
          # ResourceSlice when it binds the claim.
              - cel: |
                  device.capacity["gpu.nvidia.com"].memory.compareTo(quantity("20Gi")) >= 0
          template:
            spec:
              containers:
              - name: engine
                image: vllm/vllm-openai:v0.23.0
                args:
                - "--model=Qwen/Qwen2.5-0.5B-Instruct"
                - "--served-model-name=qwen2.5-0.5b"
                - "--max-model-len=16384"
model-service.yaml
# Exposes the qwen2-5-0-5b deployment's endpoints as a single OpenAI-compatible
# URL. Modelplane labels each composed ModelEndpoint with the deployment name, so
# this selector reaches every replica. Read the public address from status.address:
#   kubectl get ms qwen2-5-0-5b -n ml-team -o jsonpath='{.status.address}'
apiVersion: modelplane.ai/v1alpha1
kind: ModelService
metadata:
  name: qwen2-5-0-5b
  namespace: ml-team
spec:
  endpoints:
  - selector:
      matchLabels:
        modelplane.ai/deployment: qwen2-5-0-5b

Reading the metrics

Nothing needs applying for collection. Every serving pod carries modelplane.ai/serving, and its engine container’s port is named http, which is what the collector’s scrape config matches — so an engine is collected from as soon as it serves.

The collector re-exposes what it collected on the workload cluster, so read it over a port-forward:

bash
kubectl -n monitoring port-forward svc/otel-collector 8889:8889   # workload cluster
curl -s localhost:8889/metrics | grep '^modelplane_'

For the deployment above that returns the normalized names, each labelled with the engine that produced it:

modelplane_requests_running{engine="vllm",model_name="qwen2.5-0.5b",...}
modelplane_requests_waiting{engine="vllm",model_name="qwen2.5-0.5b",...}
modelplane_time_to_first_token_sum{engine="vllm",model_name="qwen2.5-0.5b",...}
modelplane_request_latency_sum{engine="vllm",model_name="qwen2.5-0.5b",...}

Which names get renamed

A MetricMapping decides. Modelplane ships one per common engine, matched to serving pods by the modelplane.ai/engine label that engines[].type sets, so an engine that declares type: vllm gets the vLLM mapping and no detection is involved. A metric with no mapping entry is not dropped — it passes through under its own name.

Two things to know about the names that pass through. The collector’s exporter replaces : with _, so vLLM’s vllm:gpu_cache_usage_perc is published as vllm_gpu_cache_usage_perc. And an engine that declares no type matches no mapping, so all of its metrics pass through rather than being renamed by a guess.

To normalize a new or forked engine, apply another MetricMapping — no Modelplane release is needed:

yaml
apiVersion: modelplane.ai/v1alpha1
kind: MetricMapping
metadata:
  name: my-fork
  namespace: ml-team
spec:
  selector:
    matchLabels:
      modelplane.ai/engine: my-fork
  rename:
    myfork:queue_depth: modelplane_requests_waiting
  labels:
    add:
      engine: my-fork

Upgrading from a hand-written PodMonitor

Earlier versions of this example applied a PodMonitor to the workload cluster by hand. Delete it. Collection is composed now, and leaving it in place scrapes every engine twice.

Other engine shapes

The example above is a single-pod vLLM engine. Collection needs no changes for the other shapes, but what gets collected differs:

  • SGLang: exposes /metrics only when the engine runs with --enable-metrics; otherwise it is collected from the same way.
  • Leader/worker: only the leader serves the API and carries modelplane.ai/serving, so only the leader is collected from; the workers serve nothing and expose no metrics.
  • prefill/decode: two engines, labelled llm-d.ai/role: prefill and llm-d.ai/role: decode. Both are collected from without special casing, because the scrape matches the engine port by name rather than by number: the decode engine serves on 8001 since the routing sidecar takes 8000, and a config matching 8000 would report the sidecar’s metrics as the engine’s.