본문으로 건너뛰기

[vLLM] Observability: 추적, 프로파일링, 메트릭

들어가며

프로덕션 LLM 서빙에서 관측 가능성(Observability)은 필수다. vLLM은 vllm/tracing/에서 분산 추적을, vllm/profiler/에서 성능 프로파일링을, vllm/usage/에서 사용량 수집을 구현하고 있다. 이 글에서는 OpenTelemetry 기반 추적 시스템을 중심으로 분석한다.

공식 문서

vLLM 공식 문서: Metrics

핵심 구조/코드 분석

OpenTelemetry 트레이서 초기화

def init_otel_tracer(
    instrumenting_module_name: str,
    otlp_traces_endpoint: str,
    extra_attributes: dict[str, str] | None = None,
) -> Tracer:
    os.environ["OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"] = otlp_traces_endpoint

    resource_attrs = {}
    resource_attrs["vllm.instrumenting_module_name"] = instrumenting_module_name
    resource_attrs["vllm.process_id"] = str(os.getpid())
    if extra_attributes:
        resource_attrs.update(extra_attributes)
    resource = Resource.create(resource_attrs)

    trace_provider = TracerProvider(resource=resource)
    span_exporter = get_span_exporter(otlp_traces_endpoint)
    trace_provider.add_span_processor(BatchSpanProcessor(span_exporter))
    set_tracer_provider(trace_provider)
    atexit.register(trace_provider.shutdown)
    return trace_provider.get_tracer(instrumenting_module_name)

OTLP 엔드포인트를 환경 변수로 전파하여, 자식 프로세스(워커)에서도 동일한 엔드포인트를 사용할 수 있다. gRPC와 HTTP/protobuf 두 가지 프로토콜을 지원한다:

def get_span_exporter(endpoint):
    protocol = os.environ.get(OTEL_EXPORTER_OTLP_TRACES_PROTOCOL, "grpc")
    if protocol == "grpc":
        return OTLPGrpcExporter(endpoint=endpoint, insecure=True)
    elif protocol == "http/protobuf":
        return OTLPHttpExporter(endpoint=endpoint)

워커 프로세스 트레이서

def init_otel_worker_tracer(instrumenting_module_name, process_kind, process_name):
    otlp_endpoint = os.environ.get("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT")
    if not otlp_endpoint:
        return None
    extra_attrs = {
        "vllm.process_kind": process_kind,
        "vllm.process_name": process_name,
    }
    return init_otel_tracer(instrumenting_module_name, otlp_endpoint, extra_attrs)

각 워커 프로세스가 자체 트레이서를 초기화한다. process_kind(예: "gpu_worker")와 process_name으로 스팬을 구분할 수 있다.

프로세스 간 트레이스 전파

@contextmanager
def propagate_trace_to_env():
    original_state = {k: os.environ.get(k) for k in TRACE_HEADERS}
    try:
        inject(os.environ)  # traceparent, tracestate를 환경변수에 주입
        yield
    finally:
        for key, original_value in original_state.items():
            if original_value is None:
                os.environ.pop(key, None)
            else:
                os.environ[key] = original_value

메인 프로세스에서 워커를 spawn할 때, 현재 트레이스 컨텍스트를 os.environ에 주입한다. 워커는 시작 시 이를 읽어 동일한 트레이스의 자식 스팬을 생성한다. 컨텍스트 매니저로 환경 변수를 원래 상태로 복원한다.

스마트 컨텍스트 감지

def _get_smart_context() -> Context | None:
    current_span = trace.get_current_span()
    if current_span.get_span_context().is_valid:
        return None  # 이미 활성 스팬이 있으면 그걸 사용

    carrier = {}
    if tp := os.environ.get("traceparent", os.environ.get("TRACEPARENT")):
        carrier["traceparent"] = tp
    if ts := os.environ.get("tracestate", os.environ.get("TRACESTATE")):
        carrier["tracestate"] = ts

    if not carrier:
        carrier = dict(os.environ)
    return TraceContextTextMapPropagator().extract(carrier)

활성 스팬이 있으면 그대로 사용하고, 없으면 환경 변수에서 traceparent를 추출한다. 대소문자 모두 시도하는 방어적 코딩이 눈에 띈다.

함수 자동 계측

def instrument_otel(func, span_name, attributes, record_exception):
    code_attrs = {
        LoadingSpanAttributes.CODE_FUNCTION: func.__qualname__,
        LoadingSpanAttributes.CODE_NAMESPACE: func.__module__,
        LoadingSpanAttributes.CODE_FILEPATH: func.__code__.co_filename,
        LoadingSpanAttributes.CODE_LINENO: str(func.__code__.co_firstlineno),
    }

    @functools.wraps(func)
    async def async_wrapper(*args, **kwargs):
        tracer = trace.get_tracer(module_name)
        ctx = _get_smart_context()
        with tracer.start_as_current_span(final_span_name, context=ctx, attributes=code_attrs):
            return await func(*args, **kwargs)

sync/async 함수 모두에 대해 자동으로 스팬을 생성하는 데코레이터다. 코드 위치 정보(함수명, 모듈, 파일, 라인 번호)를 정적으로 한 번만 계산하여 스팬 속성에 포함한다.

수동 스팬 생성

def manual_instrument_otel(span_name, start_time, end_time=None, attributes=None, context=None, kind=None):
    tracer = trace.get_tracer(__name__)
    ctx = context if context is not None else _get_smart_context()
    span = tracer.start_span(name=span_name, context=ctx, start_time=start_time)
    if attributes:
        span.set_attributes(attributes)
    span.end(end_time=end_time)

사후 보고용으로, 이미 완료된 연산의 시작/종료 시간을 명시하여 스팬을 생성할 수 있다. CUDA 비동기 연산의 실제 실행 시간을 보고할 때 유용하다.

왜 이 설계인가

  1. 환경 변수 기반 전파: vLLM은 멀티프로세스 아키텍처(API 서버, 엔진 코어, GPU 워커)를 사용한다. HTTP 헤더가 아닌 환경 변수로 트레이스 컨텍스트를 전파하는 것은, 프로세스 spawn 시점에 자연스럽게 컨텍스트를 상속받기 위한 선택이다.

  2. Graceful degradation: OpenTelemetry 패키지가 설치되지 않으면 _IS_OTEL_AVAILABLE = False로 설정하고, 모든 계측 함수가 no-op이 된다. 프로덕션에서 관측 도구 없이도 vLLM이 정상 동작한다.

  3. BatchSpanProcessor: 스팬을 즉시 전송하지 않고 배치로 모아서 전송한다. LLM 추론은 밀리초 단위의 고빈도 연산이므로, 스팬 전송 오버헤드를 최소화하는 것이 중요하다.

참고 자료

댓글

관련 포스트

vLLM 의 다른글