본문으로 건너뛰기

[sglang] Crash-dump 요청 히스토리를 pickle bytes로 보관해 cyclic GC 스캔에서 제외하기

PR 링크: sgl-project/sglang#48508 상태: Merged | 변경: +39 / -24

들어가며

SGLang 서버를 --crash-dump-folder 옵션과 함께 띄우면, TokenizerManager는 장애 발생 시 재현을 위해 최근 5분 동안 완료된 요청을 메모리에 들고 있습니다. 서버가 죽기 직전 dump_requests_before_crash가 이 히스토리를 파일로 기록합니다.

문제는 이 히스토리가 살아있는 Python 객체로 보관된다는 점이었습니다. 완료된 요청 하나마다 request 객체, 그 안의 dict/list, output dict, meta_info 등이 모두 cyclic GC의 추적 대상(tracked object)이 됩니다. 부하가 높으면 5분치 요청은 수십만~수백만 개의 tracked 객체가 되고, full collection(gen2)이 일어날 때마다 GC는 event loop 스레드에서 이 객체들을 전부 순회합니다. 그동안 요청 처리는 그대로 멈춥니다.

이전 PR #37297은 token list를 tuple로 바꿔 per-token 비용은 줄였지만, 나머지 구조는 여전히 tracked 상태였습니다. 이 PR은 아예 히스토리를 bytes로 바꿔 GC가 볼 필요 자체를 없앱니다.

코드 분석

tokenizer_manager.py — 기록 시점: pickle로 직렬화

Before

def record_request_for_crash_dump(self, state: ReqState, out_dict: dict):
    current_time = real_time()
    request = state.obj
    input_ids = request.input_ids
    if isinstance(input_ids, list):
        # Keep replay data intact without making full GC traverse every token.
        request = copy.copy(request)
        request.input_ids = (
            tuple(tuple(row) for row in input_ids)
            if input_ids and isinstance(input_ids[0], list)
            else tuple(input_ids)
        )
    self.crash_dump_request_list.append(
        (
            request,
            out_dict,
            convert_time_to_realtime(state.time_stats.created_time),
            current_time,
        )
    )

After

record = (
    state.obj,
    out_dict,
    convert_time_to_realtime(state.time_stats.created_time),
    current_time,
)
try:
    blob = pickle.dumps(record, protocol=pickle.HIGHEST_PROTOCOL)
except Exception as e:
    # The crash dump pickles the same tuple, so it could not hold this one.
    print_warning_once(
        "Leaving a finished request out of the crash dump history: "
        f"it does not pickle ({type(e).__name__})."
    )
    return
self.crash_dump_request_list.append((current_time, blob))

핵심 포인트:

  • bytes는 GC가 추적하지 않습니다. CPython에서 bytes, str, int, float 같은 원자적(atomic) 객체는 다른 객체를 참조할 수 없으므로 cyclic GC 대상이 아닙니다. 따라서 (float, bytes) 튜플은 내용물이 모두 untracked이고, CPython은 이런 튜플도 GC 과정에서 untrack 처리합니다. 결과적으로 5분치 히스토리가 GC 비용에 기여하는 바가 거의 0이 됩니다.
  • 기존의 copy.copy + tuple 변환 트릭이 사라졌습니다. pickle이 모든 하위 구조를 평탄한 바이트열로 만들어 주므로, 특정 필드만 골라 최적화할 필요가 없어졌습니다.
  • pickle 실패 시 graceful skip. dump 시점에도 같은 튜플을 pickle하므로, 지금 pickle되지 않는 레코드는 어차피 dump에 쓸 수 없습니다. 그래서 일찍 걸러내고 print_warning_once로 로그 폭주 없이 한 번만 경고합니다. 실패 처리를 '나중'에서 '지금'으로 앞당긴 셈입니다.

5분 trim 로직도 튜플 구조에 맞춰 바뀌었습니다.

# Before
and current_time - self.crash_dump_request_list[0][3] >= 300
# After
and current_time - self.crash_dump_request_list[0][0] >= 300

finish time을 바깥 튜플의 첫 슬롯에 따로 꺼내 두었기 때문에, trim할 때 bytes를 역직렬화할 필요가 없습니다. 작지만 중요한 설계입니다.

tokenizer_manager.py — dump 시점: GC를 멈추고 복원

Before

data_to_dump = []
if self.crash_dump_request_list:
    data_to_dump.extend(self.crash_dump_request_list)

After

gc_enabled = gc.isenabled()
gc.disable()
try:
    data_to_dump = [
        pickle.loads(blob) for _, blob in self.crash_dump_request_list
    ]
finally:
    if gc_enabled:
        gc.enable()

대량의 객체를 한꺼번에 생성하면 allocation 카운터가 계속 threshold를 넘으면서 GC가 반복 발동하고, 이미 만들어진 객체들을 매번 다시 훑게 됩니다(대량 생성 시 O(n²)에 가까운 패턴). 복원 구간 동안 gc.disable()로 이를 차단하고, finally에서 원래 상태가 enabled였을 때만 다시 켭니다. 호출자가 이미 GC를 꺼둔 상황을 망가뜨리지 않는 정석적인 패턴입니다.

복원된 결과는 이전과 동일한 (obj, out_dict, created, finished) 튜플이므로 dump 파일 포맷은 그대로 유지됩니다.

테스트 — fixture 형태 변경

crash_dump_request_list=deque([(finished[3], pickle.dumps(finished))]),

새 (finish time, bytes) 형태로 fixture를 맞추어, dump 결과가 기존과 같은지 그대로 검증합니다.

트레이드오프

PR 설명이 솔직하게 밝히듯 두 가지 변화가 있습니다.

  1. 요청 완료마다 pickle.dumps 1회 비용. 다만 이는 요청 수에 선형이고 분산된 비용이며, 반대로 gen2 GC의 stall은 히스토리 크기에 비례해 event loop 전체를 한 번에 멈춥니다. tail latency 관점에서 훨씬 유리한 교환입니다.
  2. output dict이 finish 시점의 snapshot이 됩니다. 이후 response path에서 추가되는 필드는 dump에 포함되지 않습니다. 반대로 말하면 이전에는 참조를 공유하고 있었기에 이후 변경이 히스토리에 섞여 들어갔는데, 이제는 시점이 명확한 불변 기록이 되었다고도 볼 수 있습니다.

왜 이게 좋은가

별도 벤치마크 수치는 제공되지 않았지만, 메커니즘은 명확합니다. gen2 collection 비용은 tracked 객체 수에 비례하고, crash-dump 히스토리는 요청률 × 300초 × 요청당 객체 수만큼 쌓입니다. 이 항을 사실상 0으로 만들었으므로 요청률이 높을수록 커지던 event loop stall이 사라집니다.

일반화할 수 있는 교훈:

  • 오래 살고 거의 읽지 않는 대용량 데이터는 직렬화된 형태로 보관하라. cold data를 live 객체 그래프로 두면 GC가 계속 세금을 걷습니다. bytes로 두면 메모리도 대개 더 compact합니다.
  • 인덱싱에 필요한 키만 밖으로 꺼내라. trim에 필요한 timestamp를 튜플 첫 슬롯에 두어 역직렬화 없이 관리합니다.
  • 실패는 가능한 일찍. dump 시점에 터질 pickle 오류를 기록 시점에 발견하면, 크래시 직전 같은 최악의 순간에 dump가 실패하는 일을 막을 수 있습니다.
  • 대량 객체 생성 구간은 gc.disable()로 감싸되 원래 상태를 복원하라.
  • 대안으로 gc.freeze()도 있지만, 히스토리는 계속 추가·삭제되는 구조라 freeze보다 직렬화가 더 적합합니다.

참고 자료

⚠️ 알림: 이 분석은 AI가 실제 코드 diff를 기반으로 작성했습니다.

댓글

관련 포스트

PR Analysis 의 다른글