모델을 쪼개면 생기는 문제
검출 모델 두 개를 한 프레임에 돌리고 싶다. 하나는 일반 객체 검출기, 하나는 소수샷 검출기라고 하자. 두 모델을 한 프로세스에 넣으면 간단하지만, GPU 배분·재기동·모델 교체가 전부 묶여버린다. 그래서 프로세스를 나누고 결과를 메시지로 발행하는 구성으로 간다.
그러면 융합 단계에서 문제가 생긴다. 융합 함수는 “한 프레임의 모든 모델 검출”을 한꺼번에 요구하는데, 메시지는 모델마다 따로 온다. 그 간극을 메우는 것이 프레임 상관(correlation)이다.
정확 일치로 묶으면 아무것도 안 묶인다
가장 먼저 시도하는 것은 타임스탬프로 묶는 것이다.
1
key = (unit, camera, timestamp)
이건 동작하지 않는다. 검출기를 프로세스별로 쪼개면 각자 RTSP를 따로 디코딩하므로 같은 순간을 봐도 촬영 시각이 밀리초 단위로 다르다. 그리고 실패 방식이 나쁘다. 오류도 경고도 없이 융합이 그냥 안 일어나고, 검출기별 결과가 따로 나간다. 하류에서는 그게 정상 동작처럼 보인다.
그래서 시각을 버킷으로 양자화한다.
1
2
3
4
def quantize_timestamp(ts_ms: int, quantum_ms: int) -> str:
return f"q{quantum_ms}:{ts_ms // quantum_ms}"
key = (unit, camera, quantize_timestamp(ts_ms, 25))
반올림이 아니라 내림이어야 한다
여기서 //(내림)를 쓴 것은 의도적이다. 반올림을 쓰면 경계 근처에서 두 검출기가 서로 다른 버킷으로 갈릴 확률이 폭의 절반 구간에 걸쳐 생긴다. 내림은 그 구간이 폭 전체에 딱 한 번만 생긴다. 경계 사고가 절반으로 줄고, 무엇보다 어디서 생기는지 예측 가능하다.
시간대가 없으면 UTC로 본다
타임스탬프 문자열에 오프셋이 없을 때 로컬 시각으로 해석하면 장비의 TZ 설정에 따라 버킷이 통째로 흔들린다. 없으면 UTC로 고정한다.
파싱에 실패한 시각은 예외를 던지지 말고 원문을 그대로 키에 쓴다. 여기서 예외를 올리면 형식이 조금 다른 검출기의 결과를 통째로 잃고, 그 검출기는 자기 원문끼리만 묶여 영원히 융합에서 빠진다.
버킷 폭에는 대가가 있다
폭이 프레임 간격보다 넓으면 서로 다른 두 프레임이 같은 버킷에 들어온다. 실측에서 24ms 차이 나는 두 프레임이 25ms 버킷에 함께 들어왔고, 전체 발행량의 0.14%가 이 경우였다. 같은 버킷의 두 번째 프레임은 검출이 통째로 버려진다(첫 것을 유지하는 정책이라).
폭을 줄이면 이 손실은 줄지만 두 검출기가 다른 버킷으로 갈릴 위험이 늘어난다. 버킷 폭은 도착 시각차와 함께 봐야 정할 수 있는 값이다.
그리고 “0이 맞는 구성”도 있다. 한 프로세스가 모든 모델을 돌려 타임스탬프를 공유하면 버킷은 서로 다른 프레임을 섞을 위험만 더한다.
언제 버킷을 닫을 것인가
버킷을 닫고 융합으로 넘기는 경로는 셋이다.
| 경로 | 조건 | 성격 |
|---|---|---|
| 정족(quorum) | 기대하는 모델이 전부 도착 | 지연 없는 정상 경로 |
| 타임아웃 | 첫 메시지 후 timeout_ms 경과 | 이것도 정상 경로다 |
| 축출(evict) | 미완 버킷이 상한 초과 | 가장 오래된 것 강제 플러시 |
타임아웃이 예외가 아니라 정상 경로인 이유가 있다. 검출기가 검출 결과가 있을 때만 발행하는 구성이면, 한쪽 모델만 무언가를 찾은 프레임은 정족이 구조적으로 절대 안 찬다. 즉 timeout_ms는 예외 상황의 안전장치가 아니라 파이프라인 지연을 직접 결정하는 값이다.
축출도 손실이 아니다. 축출된 프레임도 그대로 발행한다. 그래서 처리량을 셀 때는 플러시 = 정족 + 타임아웃 + 축출로 센다.
조용히 멈추는 설정을 기동 시에 막는다
버킷에는 최대 나이(max_age_ms)도 둔다. 너무 늦은 프레임은 하류에 밀어 넣는 것보다 버리는 것이 맞기 때문이다. 그런데 이 값이 타임아웃보다 작거나 같으면 타임아웃으로 닫힌 모든 프레임이 곧바로 stale 판정을 받아 아무것도 발행되지 않는다.
서비스가 조용히 멈추는 경로라서, 설정 검증 단계에서 아예 막아둔다.
1
2
if max_age_ms is not None and max_age_ms <= timeout_ms:
raise ValueError("max_age_ms must be greater than timeout_ms")
시간을 주입받는다
상관기는 벽시계를 직접 읽지 않고 now_ms를 인자로 받는다.
1
2
def tick(self, now_ms: int) -> list[Frame]:
...
이유는 두 가지다. 테스트에서 sleep 없이 타임아웃 경계를 정확히 검증할 수 있고, 운영에서는 time.monotonic()을 넘기면 된다. 벽시계는 NTP 보정으로 뒤로 갈 수 있는데, 상관에 필요한 것은 경과 시간뿐이다.
재는 것은 촬영 시각차가 아니라 도착 시각차다
timeout_ms를 정할 때 가장 먼저 혼동하는 지점이다. 어떤 실측에서 촬영 시각차는 18ms인데 도착 시각차는 241ms였다. 타임아웃이 재는 것은 후자다.
그리고 여기서 지표가 거짓말을 한 사건이 있었다. 서비스가 이렇게 보고하고 있었다.
1
2
arrival_skew_p50_ms: 72
arrival_skew_max_ms: 300 ← 그때의 timeout_ms도 300
최댓값이 타임아웃과 정확히 같다는 것이 이상 신호였다. 편차 표본을 플러시된 프레임에서만 모으고 있었기 때문이다. 편차가 타임아웃보다 큰 프레임은 상대가 오기 전에 닫히고, 그 메시지는 “늦게 도착함”으로 폐기되어 표본에서 사라진다.
즉 이 지표는 원리적으로 타임아웃을 넘을 수 없었고, 무엇을 재든 “편차는 타임아웃 안에 있다”는 결론만 영원히 재생산했다. 독립 프로브로 잰 참값은 403ms였다.
p50이 72ms로 낮았던 것은 별개의 두 번째 이유였다. 정족 플러시가 초기 몇천 건 이후로 멈추면서 링버퍼가 갱신되지 않아 기동 직후 값이 그대로 굳어 있었다.
고친 방식
- 늦게 온 메시지의 편차도 표본에 넣는다. 그쪽이 정확히 “타임아웃을 얼마나 늘렸어야 했는가”를 말해주는 표본이다
- 모델 하나만 온 프레임은 편차를 정의하지 않는다. 0으로 집계하면 p50이 낮게 왜곡되어 타임아웃을 너무 짧게 잡는 근거가 된다
- p99를 추가하고, 모델별로 “누가 늦는지”를 가른다
값이 세 번 뒤집힌 기록
| 환경 | 도착 시각차 | 그때의 정답 |
|---|---|---|
| 융합기 로컬, 검출기 원격 | p50 241ms | 300~450 |
| 융합기를 검출기 호스트로 이동, 채널 6개 | p50 403ms | 500~700 또는 융합 포기 |
| 브로커·상류까지 로컬로 이동 | p50 1ms, 최대 32ms | 66/200 확정 |
두 번째 줄이 반직관적이다. 호스트를 붙였는데 시각차가 늘었다. 시각차의 정체가 네트워크가 아니라 한쪽 검출기의 파이프라인 지연 자체였기 때문이다. 405ms − 2ms = 403ms로 소수점까지 맞았다.
그때 쓰고 있던 300ms는 두 손해를 다 보고 있었다. 융합을 사지도 못했으면서(시각차가 그보다 컸다) 모든 출력에 302ms를 얹고 있었다. 근본 해결은 상류의 405ms였다.
판정 순서 하나가 단서를 지운다
메시지가 들어왔을 때 검사 순서가 중요하다.
1
2
3
4
5
if model not in self._expected: # ← 이게 먼저여야 한다
self._count_unexpected(model)
...
if key in self._flushed: # ← 늦은 도착 판정
return []
순서를 반대로 하면 이 경로가 정작 필요한 상황에서만 침묵한다. 기대 목록에 없는 검출기는 조금이라도 느리면 항상 이미 닫힌 버킷에 도착한다. 그러면 “늦은 도착”으로 먼저 반환되어 “기대 밖 모델” 카운터가 0으로 남고, 남는 단서는 모델 이름이 없는 폐기 카운터뿐이다.
실제로 이 순서 때문에 오래 헤맸다. 수신 53만 건 중 50만 건(94%)이 기대 밖 모델이었고 정족 플러시는 0건이었는데, 기대 모델 이름 세 개가 전부 옛 이름이었다. 순서를 고치고 나서야 그게 드러났다.
다만 이름을 고쳐도 융합 문제 자체는 안 풀린다. 기대 모델 목록은 조기 플러시(빠른 경로)만 결정하고, 융합 여부는 “버킷이 닫힐 때 누가 들어와 있었는가”로만 정해진다.
중복 두 종류는 대응이 정반대다
| 카운터 | 판별 | 손댈 곳 |
|---|---|---|
| 다른 프레임 중복 | 원문 촬영 시각이 다름 | 융합기 — 버킷 폭이 넓다 |
| 같은 프레임 중복 | 원문 촬영 시각이 같음 | 상류 — 재발행 또는 QoS 재전송 |
총계 하나만 세면 어디를 고쳐야 할지 정해지지 않는다. 상관 키는 버킷이라 키가 같아도 같은 프레임이 아닐 수 있으므로, 원문 시각이 유일한 판별자다. 시각을 못 읽으면 후자로 센다. 없는 근거로 자기 설정을 의심하지 않기 위해서다.
경고는 묶어서 낸다
이런 시스템에서 경고는 건별로 내면 안 된다. 어긋나기 시작하면 매 프레임 어긋난다. 40fps에서 건별 경고는 초당 40줄이고, 정작 원인이 스크롤 밖으로 밀려난다. 실제로 그 상태에서 다른 경고를 놓쳐 원인 판단이 불가능해진 적이 있다.
| 경고 | 묶는 단위 |
|---|---|
| 기대 밖 모델 | 모델 이름당 1회 |
| 메타데이터 불일치 | 필드당 1회 |
| 토픽↔페이로드 불일치 | 토픽당 1회 |
| 축출 | 5초 간격 건수 묶음 |
| 발행 실패 | 첫 건과 1·10·100…번째 |
지속 여부는 카운터가 계속 보여주므로 로그를 줄여도 정보를 잃지 않는다.
처리량은 대체로 문제가 아니다
구조를 바꾸자는 제안이 나올 때마다 근거로 드는 것이 성능인데, 실제 코드 경로를 벤치마크해보면 대개 여유가 크다.
| 조건 | 처리량 | 메시지당 |
|---|---|---|
| 검출기 1대, 2채널 | 15,391 msg/s | 65µs |
| 검출기 3대, 정족 성립 | 31,620 msg/s | 32µs |
| 빈 tick 1회 | 0.5µs | 1ms 주기에서 CPU 0.05% |
현장 유입은 108~177 msg/s였다. 필요 처리량의 1% 미만이다. 채널당 약 29 msg/s이므로 한 프로세스로 수백 채널까지 여유가 있다.
성능을 이유로 구조를 바꾸자는 제안은 이 표를 먼저 반박해야 한다. 이 표가 없으면 구조 논쟁이 감각으로 흘러간다.