본문으로 건너뛰기

[SGLang] Engine: 멀티프로세스 오케스트레이터의 설계와 구현

들어가며

LLM inference 서버를 구축할 때, 단일 프로세스로 tokenization, GPU 연산, detokenization을 모두 처리하면 어떻게 될까? tokenizer가 문자열을 처리하는 동안 GPU는 놀고, GPU가 forward pass를 수행하는 동안 다음 요청의 tokenization은 대기한다. 즉, CPU-bound 작업과 GPU-bound 작업이 서로를 blocking한다.

SGLang의 Engine 클래스는 이 문제를 멀티프로세스 파이프라인으로 해결한다. 각 단계를 독립 프로세스로 분리하고, ZMQ IPC로 연결하여 파이프라인 병렬성을 달성한다. 이 글에서는 python/sglang/srt/entrypoints/engine.py를 중심으로 Engine의 설계를 분석한다.

Engine 프로세스 아키텍처

Engine이 생성하는 멀티프로세스 파이프라인의 전체 구조는 다음과 같다.

┌─────────────────────────────────────────────────────────────┐
│                      Main Process                           │
│  ┌──────────┐    ┌───────────────────┐                      │
│  │  Engine   │───▶│ TokenizerManager  │                      │
│  │ (API)     │    │ (tokenize)        │                      │
│  └──────────┘    └────────┬──────────┘                      │
│                           │ ZMQ IPC                         │
└───────────────────────────┼─────────────────────────────────┘
                            ▼
┌───────────────────────────────────────────────────────────┐
│                 Scheduler Process(es)                      │
│  ┌─────────────────────────────────────────────────────┐  │
│  │  Scheduler                                           │  │
│  │  - batch scheduling                                  │  │
│  │  - forward pass (TP Workers: GPU 0, GPU 1, ...)      │  │
│  └──────────────────────┬──────────────────────────────┘  │
│                          │ ZMQ IPC                         │
└──────────────────────────┼────────────────────────────────┘
                           ▼
┌──────────────────────────────────────────────────────────┐
│              Detokenizer Process                          │
│  ┌────────────────────────────────────────────────────┐  │
│  │  DetokenizerManager                                 │  │
│  │  - output tokens → text                             │  │
│  │  - 결과를 TokenizerManager로 반환 (ZMQ IPC)          │  │
│  └────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────┘

핵심 설계 원칙은 명확하다. TokenizerManager는 메인 프로세스에서, Scheduler와 DetokenizerManager는 별도 subprocess에서 실행한다. 프로세스 간 통신은 모두 ZMQ IPC 소켓을 통해 이루어진다.

핵심 코드 분석

Engine 클래스 구조

EngineEngineBase(추상 클래스)와 EngineScoreMixin을 상속한다. EngineBasegenerate, flush_cache, shutdown 등의 인터페이스를 정의하고, Engine이 이를 멀티프로세스 방식으로 구현한다.

class Engine(EngineScoreMixin, EngineBase):
    """
    The entry point to the inference engine.

    - The engine consists of three components:
        1. TokenizerManager: Tokenizes the requests and sends them to the scheduler.
        2. Scheduler (subprocess): Receives requests from the Tokenizer Manager, schedules batches, forwards them, and sends the output tokens to the Detokenizer Manager.
        3. DetokenizerManager (subprocess): Detokenizes the output tokens and sends the result back to the Tokenizer Manager.

    Note:
    1. The HTTP server, Engine, and TokenizerManager all run in the main process.
    2. Inter-process communication is done through IPC (each process uses a different port) via the ZMQ library.
    """

    server_args_class: ServerArgs = ServerArgs
    init_tokenizer_manager_func: Callable = staticmethod(init_tokenizer_manager)
    run_scheduler_process_func: Callable = staticmethod(run_scheduler_process)
    run_detokenizer_process_func: Callable = staticmethod(run_detokenizer_process)

클래스 변수로 run_scheduler_process_func, run_detokenizer_process_func를 노출한 점이 인상적이다. 이 설계 덕분에 서브클래스(예: RayEngine)에서 프로세스 생성 방식을 쉽게 교체할 수 있다.

Engine 초기화 흐름

__init__에서 가장 중요한 호출은 _launch_subprocesses다. 이 메서드 하나가 전체 멀티프로세스 파이프라인을 구성한다.

def __init__(self, **kwargs):
    # ...server_args 파싱 생략...
    self.tokenizer_manager = None
    atexit.register(self.shutdown)

    (
        tokenizer_manager,
        template_manager,
        port_args,
        scheduler_init_result,
        subprocess_watchdog,
    ) = self._launch_subprocesses(
        server_args=server_args,
        init_tokenizer_manager_func=self.init_tokenizer_manager_func,
        run_scheduler_process_func=self.run_scheduler_process_func,
        run_detokenizer_process_func=self.run_detokenizer_process_func,
    )
    self.tokenizer_manager = tokenizer_manager
    # ...

    # Initialize ZMQ sockets
    context = zmq.Context(2)
    if self.server_args.node_rank == 0:
        self.send_to_rpc = get_zmq_socket(
            context, zmq.DEALER, self.port_args.rpc_ipc_name, True
        )

atexit.register(self.shutdown)로 프로세스 종료 시 자식 프로세스를 정리하고, tokenizer_manager를 미리 None으로 설정하여 shutdown 시 AttributeError를 방지한다. 이런 방어적 코드가 프로덕션 수준의 안정성을 만든다.

ZMQ IPC 통신 채널

프로세스 간 통신 경로는 PortArgs에 정의된다. 각 IPC 채널의 역할은 다음과 같다.

@dataclasses.dataclass
class PortArgs:
    tokenizer_ipc_name: str         # Detokenizer → TokenizerManager
    scheduler_input_ipc_name: str   # TokenizerManager → Scheduler (rank 0)
    detokenizer_ipc_name: str       # Scheduler → DetokenizerManager
    rpc_ipc_name: str               # Engine ↔ Scheduler (RPC 호출)
    metrics_ipc_name: str           # Scheduler → Metrics
    nccl_port: int                  # NCCL 초기화용 (torch.distributed)

ZMQ의 IPC transport(ipc://)를 사용하기 때문에 네트워크 오버헤드 없이 Unix domain socket 수준의 저지연 통신이 가능하다. TCP 소켓 대비 직렬화/역직렬화 비용만 발생하며, 동일 머신 내에서는 사실상 메모리 복사에 가까운 성능을 보인다.

프로세스 생성과 Scheduler 초기화

_launch_subprocesses는 다음 순서로 프로세스를 생성한다.

1단계: Scheduler 프로세스 생성. dp_size == 1이면 TP(Tensor Parallel) Scheduler를 직접 생성하고, dp_size > 1이면 DataParallelController를 통해 관리한다.

for pp_rank in pp_rank_range:
    for tp_rank in tp_rank_range:
        reader, writer = mp.Pipe(duplex=False)
        gpu_id = (
            server_args.base_gpu_id
            + ((pp_rank % pp_size_per_node) * tp_size_per_node)
            + (tp_rank % tp_size_per_node) * server_args.gpu_id_step
        )
        proc = mp.Process(
            target=run_scheduler_process_func,
            args=(server_args, port_args, gpu_id, tp_rank, ...),
        )
        proc.start()
        scheduler_procs.append(proc)

각 Scheduler 프로세스는 mp.Pipe를 통해 초기화 완료 신호를 보낸다. 부모 프로세스는 _wait_for_scheduler_ready에서 이 파이프를 polling하며, 5초 타임아웃으로 자식 프로세스의 비정상 종료(OOM SIGKILL 등)를 감지한다.

2단계: Detokenizer 프로세스 생성.

detoken_proc = mp.Process(
    target=run_detokenizer_process_func,
    args=(server_args, port_args),
)
detoken_proc.start()

3단계: TokenizerManager 초기화 (메인 프로세스).

tokenizer_manager, template_manager = init_tokenizer_manager_func(
    server_args, port_args
)

4단계: Scheduler 준비 대기 후 SubprocessWatchdog 시작.

scheduler_init_result.wait_for_ready()

subprocess_watchdog = SubprocessWatchdog(
    processes=processes, process_names=names
)
subprocess_watchdog.start()

SubprocessWatchdog는 모든 자식 프로세스의 생존 여부를 지속적으로 모니터링한다. 한 프로세스가 crash하면 전체 프로세스 트리를 정리하여 좀비 프로세스나 GPU 메모리 누수를 방지한다.

요청 처리 흐름

Engine의 generate 메서드는 놀라울 정도로 단순하다. 요청을 GenerateReqInput으로 포장하여 TokenizerManager에 위임할 뿐이다.

def generate(self, prompt=None, sampling_params=None, stream=False, ...):
    obj = GenerateReqInput(text=prompt, sampling_params=sampling_params, ...)
    generator = self.tokenizer_manager.generate_request(obj, None)

    if stream:
        def generator_wrapper():
            while True:
                try:
                    chunk = self.loop.run_until_complete(generator.__anext__())
                    yield chunk
                except StopAsyncIteration:
                    break
        return generator_wrapper()
    else:
        return self.loop.run_until_complete(generator.__anext__())

이후의 처리 흐름은 다음과 같다: TokenizerManager가 텍스트를 tokenize하여 ZMQ로 Scheduler에 전송 → Scheduler가 배치를 구성하고 GPU forward pass 실행 → 출력 토큰을 ZMQ로 DetokenizerManager에 전송 → DetokenizerManager가 텍스트로 변환하여 ZMQ로 TokenizerManager에 반환. Engine은 이 파이프라인의 진입점일 뿐, 실제 데이터 흐름은 세 Manager가 ZMQ를 통해 자율적으로 처리한다.

Collective RPC

Engine은 ZMQ DEALER 소켓을 통해 Scheduler에 직접 RPC 호출도 수행할 수 있다.

def collective_rpc(self, method: str, **kwargs):
    obj = RpcReqInput(method=method, parameters=kwargs)
    self.send_to_rpc.send_pyobj(obj)
    recv_req = self.send_to_rpc.recv_pyobj(zmq.BLOCKY)
    assert isinstance(recv_req, RpcReqOutput)
    assert recv_req.success, recv_req.message

이 채널은 추론 파이프라인과 별도로 작동하며, 모델 저장(save_remote_model), weight 업데이트 등 관리 작업에 사용된다.

왜 이 설계인가

단일 프로세스 접근 방식과 비교하면 멀티프로세스 파이프라인의 이점이 명확해진다.

GIL 회피. Python의 GIL은 멀티스레딩으로는 CPU-bound 작업의 진정한 병렬 실행이 불가능하게 만든다. Tokenization과 detokenization은 CPU-intensive한 작업이므로, 별도 프로세스에서 실행해야 GPU 작업과 겹칠 수 있다.

장애 격리. Scheduler 프로세스가 GPU OOM으로 crash해도 TokenizerManager는 살아 있다. SubprocessWatchdog가 이를 감지하고 전체 프로세스 트리를 깔끔하게 정리한다. 단일 프로세스에서는 하나의 segfault가 서버 전체를 즉사시킨다.

확장성. Tensor Parallelism은 GPU당 하나의 Scheduler 프로세스를 생성한다. Data Parallelism은 DataParallelController를 통해 여러 Scheduler 그룹을 관리한다. 멀티노드 환경에서는 node_rank >= 1인 노드가 TokenizerManager/DetokenizerManager 없이 Scheduler만 실행한다. 이런 유연한 조합은 멀티프로세스 아키텍처에서만 가능하다.

Override 용이성. run_scheduler_process_func 등을 클래스 변수로 노출하여 RayEngine 같은 서브클래스가 프로세스 생성 방식을 교체할 수 있다. 핵심 로직을 건드리지 않고도 분산 백엔드를 전환할 수 있는 것이다.

관련 포스트

  • HTTP 서버: Engine 위에서 동작하는 FastAPI 기반 HTTP 인터페이스
  • Scheduler: Scheduler 프로세스의 배치 스케줄링과 continuous batching 구현
  • TokenizerManager: 메인 프로세스에서 요청을 관리하는 TokenizerManager의 내부 구조

참고

댓글

관련 포스트

SGLang 의 다른글