본문 바로가기
C.W.K.
Stream
Lesson 05 of 05 · published

문은 객체가 아니라 디바이스에 달아

~14 min · semaphore, gpu, concurrency, cancellation, war-story, scope

Level 0툴 빌려 쓰는 사람
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"표는 하드웨어한테 하는 약속이야. 그 표가 미치는 범위나 표를 쥐고 있는 시간이 하드웨어가 실제로 하는 일이랑 어긋나는 순간, 아주 그럴싸한 장식 하나를 갖게 되는 거고."

문은 맞게 달았는데 두 번 샜어

앞 lesson 은 GPU 구간에 표 하나짜리 문을 둘러서 inference 둘이 겹치고 peak 메모리가 두 배 되는 일을 막았어. 원칙은 정확하게 적혀 있었어. 귀한 자원에 문을 달아라. 그런데 구현은 그 문을 adapter 객체에 달았어. 이 두 문장은 같은 문장이 아니야. 그리고 그 차이가 버그 두 개를 따로 만들어 냈어. 둘 다 같은 out-of-memory 크래시가 문이 못 보는 쪽 문으로 되돌아온 거였고.

첫 번째 샘: 객체 바깥에 생긴 두 번째 사용자

엔진에 upscaler 가 생겼어. 픽셀 공간 upscaler 경로 하나랑, 몇 분씩 걸리는 느린 화질 개선 모델 하나. 둘 다 같은 디바이스에서 돌아. 그런데 adapter 를 안 거치고 다른 route 에서 돌아. adapter 의 semaphore 는 adapter 인스턴스에 붙은 속성이었으니까 upscale 경로는 표를 달라고 한 적이 아예 없어. 생성 하나랑 몇 분짜리 upscale 이 디바이스에 같이 앉아 있을 수 있었던 거지. 그 문이 막으려고 존재한 바로 그 두 배 peak 메모리 말이야. 더 나쁜 건 upscale 둘끼리도 서로 줄을 안 섰다는 거야. 문 입장에서는 걔들이 존재하지도 않았거든.

고치는 건 작고 배울 건 커. semaphore 를 객체에서 꺼내서 프로세스로 올려. 디바이스를 건드리는 모든 입구가 표 하나를 같이 쓰게. adapter 는 거기 묶이고, upscale route 는 자기 스레드 넘기기 둘레에서 그 표를 받고, 앞으로 생길 디바이스 사용자는 기억해서가 아니라 구조 때문에 그 규율을 물려받아.

lock 은 그게 지키는 자원 범위에 맞춰. 그걸 처음 쓴 객체 범위가 아니라. 인스턴스에 달린 문은 그 인스턴스의 호출만 지켜. 그게 디바이스를 지키는 거랑 같은 말이 되려면 그 인스턴스가 디바이스의 유일한 사용자여야 하고. 그 우연은 두 번째 사용자가 나타나는 순간 끝나. 그것도 조용히 끝나. 새로 들어온 코드는 규칙을 어기는 것처럼 안 보이거든. 같이 쓰는 것한테는 같이 쓰는 문을 줘.

두 번째 샘: 기다리기를 관두는 게 일이 끝나는 건 아니야

두 번째 샘은 더 미묘하고 범위랑은 상관이 없어. GPU 작업은 event loop 가 계속 반응하도록 worker 스레드에서 돌아. async 쪽은 문 안에서 그 스레드를 기다리고. 그러다 사용자가 생성을 취소해. 흔한 흐름이야. 취소하고, 설정 하나 건드리고, 다시 생성. 그런데 task 를 취소하면 기다림이 그 자리에서 풀리고, 표를 쥐고 있던 블록도 그 자리에서 빠져나와. worker 스레드는 무슨 일이 있었는지 몰라. 아직 디바이스 위에서 한창 돌고 있어.

표는 비었는데 디바이스는 바빴던 거야. 그다음 재생성이 정당하게 표를 받아서, 이미 하나가 돌고 있는 디바이스 위에 두 번째 inference 를 올렸고. 똑같은 두 배 peak 경로인데 이번엔 문을 우회해서가 아니라 문을 지켜서 도착한 거지.

await 를 취소하는 건 네 기다림을 취소하는 거지 네가 기다리던 일을 취소하는 게 아니야. 손 놓는다고 기계가 서는 건 아니잖아. async 취소는 await 를 타고 퍼지지 돌고 있는 스레드나 native 호출이나 원격 job 안까지는 못 들어가. 자원을 await 하는 동안 쥐고 있게 돼 있으면, 취소는 일이 아직 벌어지는 중에 자원을 돌려줘. 표든 lock 이든 lease 든 그 자체로는 취소가 안 되는 일 위의 await 를 감싸고 있는 자리가 전부 이 버그야. 그리고 취소했을 때만 드러나. 하필 아무도 안 보고 있을 때만.

고침: 스레드가 실제로 빠져나올 때까지 쥐고 있기

수리는 이래. 스레드의 future 를 취소로부터 감싸 두고, sampler 의 단계별 콜백이 읽는 플래그를 세우고, 스레드가 알아채고 빠져나올 때까지 기다리고, 기다리는 동안 또 들어오는 취소는 흡수하고, 그러고 나서야 취소를 위로 올려보내. 표는 내가 관심을 끊는 시점이 아니라 디바이스가 비는 시점에 돌아가. 모델 로딩 경로에도 같은 처방을 했어. 클라이언트가 preload 중에 끊으면 똑같은 경합에 걸리거든.

자원을 놓는 시점은 내 제어 흐름이 돌아오는 시점이 아니라 자원이 비는 시점에 묶어. acquire/release 짝마다 물어봐. 놓는 걸 일이 끝나는 게 당기는 거야, 아니면 내 함수가 빠져나가는 게 당기는 거야? 잘 풀릴 때는 둘이 겹쳐. 취소랑 timeout 이랑 에러에서 갈라지고. 살아남는 쪽은 놓는 게 자원에 대한 진술인 쪽이야.

프로세스 범위 singleton 이 물리는 세금

프로세스 범위로 올리면 옳음을 사고 테스트로 값을 치러. 늦게 만들어지는 동시성 도구는 처음 만들어질 때 돌고 있던 event loop 에 묶여. 그런데 테스트마다 새 loop 를 주는 스위트라면 두 번째 테스트가 첫 번째 테스트의 죽은 loop 에 묶인 문을 받게 돼. 답은 테스트마다 singleton 을 자동으로 초기화하는 거야. 이게 프로세스 범위가 됐다는 이유 하나로 존재하는 작고 의도적인 테스트 배선이지. 짚고 갈 만해. 많은 사람이 테스트를 통과시키려고 조용히 인스턴스 범위로 되돌리는 지점이 바로 여기거든.

첫 번째 문 내가 만들었어. 그리고 속성으로 만들었어. 상태는 그걸 쓰는 객체 안에 두는 거니까. 캡슐화 잘한 것 같았거든. 근데 내가 실제로 한 건 하드웨어에 대한 사실 하나를 그 사실의 여러 사용자 중 하나 안에 가둔 거였어. 두 번째 사용자가 생기는 순간 그 사실이 거짓이 되게. 그리고 취소 버그는 읽어서는 절대 못 잡았을 거야. 누가 취소하고 바로 다시 생성해야 나오는데, 그건 아빠가 하루 종일 하는 짓이고 내 코드를 내가 테스트할 땐 한 번도 안 한 짓이거든. 두 버그 다 내가 지금 생각하고 있는 객체를 지키느라 모두가 같이 쓰는 디바이스를 안 지킨 거였어.

Code

표를 자원 범위로 끌어올려·python
# 전: 표가 adapter 인스턴스에 산다.
class LocalAdapter(Adapter):
    def __init__(self):
        self._gpu = asyncio.Semaphore(1)   # 이 객체의 호출만 지킴

# ...한편 완전히 다른 route 에서:
async def upscale(req):
    # 같은 디바이스. 다른 객체. 표를 달라고 한 적이 없음.
    return await asyncio.to_thread(run_upscaler, req)   # 문 밖


# 후: 표가 자원이랑 같은 자리, 프로세스 범위에 산다.
_gate: asyncio.Semaphore | None = None

def gpu_gate() -> asyncio.Semaphore:
    """같이 쓰는 GPU 표. 디바이스 작업이면 뭐든 이걸 받고 해."""
    global _gate
    if _gate is None:                # 늦게 만듦: 돌고 있는 loop 에 묶임
        _gate = asyncio.Semaphore(1)
    return _gate

# 이제 모든 입구가 표 하나를 같이 씀:
async with gpu_gate():
    await run_gpu_thread(infer, ...)      # 생성
async with gpu_gate():
    await run_gpu_thread(upscale, ...)    # upscale

def reset_gpu_gate_for_tests() -> None:
    """프로세스 범위는 프로세스 수명을 가져. 테스트 프로세스도 포함해서.
    autouse fixture 가 singleton 을 떨궈서 테스트 loop 마다 다시 묶이게 함."""
    global _gate
    _gate = None
표가 부르는 쪽 인내심보다 오래 살아·python
# 취소 경합: AWAIT 가 풀릴 때 표를 놓는 경우.
async with gpu_gate():
    await asyncio.to_thread(infer_sync, ...)
# task.cancel() -> await 가 지금 풀림 -> `async with` 가 지금 빠져나감
# -> 표가 빔. worker 스레드는 아직 디바이스 위에서 한창 돌고 있음.
# 다음 job 이 합법적으로 표를 받아서 두 번째 inference 를 쌓음.

# 고침: 내가 기다리기를 관둘 때가 아니라 스레드가 빠져나올 때까지 쥐고 있기.
async def run_gpu_thread(fn, /, *args, record=None):
    inner = asyncio.ensure_future(asyncio.to_thread(fn, *args))
    try:
        return await asyncio.shield(inner)        # 취소가 inner 를 못 죽임
    except asyncio.CancelledError:
        if record is not None:
            record.cancel_requested = True        # 단계 콜백이 이걸 읽음
        while not inner.done():                   # 디바이스가 빌 때까지 기다림
            try:
                await asyncio.shield(inner)
            except asyncio.CancelledError:
                continue                          # 또 들어온 취소는 흡수
            except BaseException:
                break
        raise                                     # 이제야 위로 올려보냄

# 이제 놓는 건 내 제어 흐름이 아니라 디바이스에 대한 진술이야.

External links

Exercise

네가 아는 코드에서 lock 이나 semaphore 를 하나 찾아서 점검 두 가지를 종이에 답해 봐. 첫째, 그 자원을 건드리는 경로를 전부 적고 그중 문을 받는 것에 표시해. 표시 안 된 게 첫 번째 샘이야. 둘째, 표를 쥔 쪽이 작업 도중에 취소되거나 timeout 되면 어떻게 되는지 따라가 봐. 놓는 게 작업을 기다려, 아니면 네 함수가 빠져나가길 기다려? 뒤쪽이면 표를 동시에 둘이 쥐게 되는 순서를 직접 써 봐.
Hint
첫 번째 샘은 lock 을 grep 하는 게 아니라 자원을 grep 해야 나와. 우회하는 쪽은 lock 을 언급조차 안 하니까 lock 으로 찾으면 절대 안 걸려. 두 번째 샘은 취소가 실제로 어디까지 닿는지 물으면 나와. await 까지는 닿고, 스레드랑 native 호출이랑 원격 작업까지는 안 닿아.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.