"표는 하드웨어한테 하는 약속이야. 그 표가 미치는 범위나 표를 쥐고 있는 시간이 하드웨어가 실제로 하는 일이랑 어긋나는 순간, 아주 그럴싸한 장식 하나를 갖게 되는 거고."
문은 맞게 달았는데 두 번 샜어
앞 lesson 은 GPU 구간에 표 하나짜리 문을 둘러서 inference 둘이 겹치고 peak 메모리가 두 배 되는 일을 막았어. 원칙은 정확하게 적혀 있었어. 귀한 자원에 문을 달아라. 그런데 구현은 그 문을 adapter 객체에 달았어. 이 두 문장은 같은 문장이 아니야. 그리고 그 차이가 버그 두 개를 따로 만들어 냈어. 둘 다 같은 out-of-memory 크래시가 문이 못 보는 쪽 문으로 되돌아온 거였고.
첫 번째 샘: 객체 바깥에 생긴 두 번째 사용자
엔진에 upscaler 가 생겼어. 픽셀 공간 upscaler 경로 하나랑, 몇 분씩 걸리는 느린 화질 개선 모델 하나. 둘 다 같은 디바이스에서 돌아. 그런데 adapter 를 안 거치고 다른 route 에서 돌아. adapter 의 semaphore 는 adapter 인스턴스에 붙은 속성이었으니까 upscale 경로는 표를 달라고 한 적이 아예 없어. 생성 하나랑 몇 분짜리 upscale 이 디바이스에 같이 앉아 있을 수 있었던 거지. 그 문이 막으려고 존재한 바로 그 두 배 peak 메모리 말이야. 더 나쁜 건 upscale 둘끼리도 서로 줄을 안 섰다는 거야. 문 입장에서는 걔들이 존재하지도 않았거든.
고치는 건 작고 배울 건 커. semaphore 를 객체에서 꺼내서 프로세스로 올려. 디바이스를 건드리는 모든 입구가 표 하나를 같이 쓰게. adapter 는 거기 묶이고, upscale route 는 자기 스레드 넘기기 둘레에서 그 표를 받고, 앞으로 생길 디바이스 사용자는 기억해서가 아니라 구조 때문에 그 규율을 물려받아.
두 번째 샘: 기다리기를 관두는 게 일이 끝나는 건 아니야
두 번째 샘은 더 미묘하고 범위랑은 상관이 없어. GPU 작업은 event loop 가 계속 반응하도록 worker 스레드에서 돌아. async 쪽은 문 안에서 그 스레드를 기다리고. 그러다 사용자가 생성을 취소해. 흔한 흐름이야. 취소하고, 설정 하나 건드리고, 다시 생성. 그런데 task 를 취소하면 기다림이 그 자리에서 풀리고, 표를 쥐고 있던 블록도 그 자리에서 빠져나와. worker 스레드는 무슨 일이 있었는지 몰라. 아직 디바이스 위에서 한창 돌고 있어.
표는 비었는데 디바이스는 바빴던 거야. 그다음 재생성이 정당하게 표를 받아서, 이미 하나가 돌고 있는 디바이스 위에 두 번째 inference 를 올렸고. 똑같은 두 배 peak 경로인데 이번엔 문을 우회해서가 아니라 문을 지켜서 도착한 거지.
고침: 스레드가 실제로 빠져나올 때까지 쥐고 있기
수리는 이래. 스레드의 future 를 취소로부터 감싸 두고, sampler 의 단계별 콜백이 읽는 플래그를 세우고, 스레드가 알아채고 빠져나올 때까지 기다리고, 기다리는 동안 또 들어오는 취소는 흡수하고, 그러고 나서야 취소를 위로 올려보내. 표는 내가 관심을 끊는 시점이 아니라 디바이스가 비는 시점에 돌아가. 모델 로딩 경로에도 같은 처방을 했어. 클라이언트가 preload 중에 끊으면 똑같은 경합에 걸리거든.
프로세스 범위 singleton 이 물리는 세금
프로세스 범위로 올리면 옳음을 사고 테스트로 값을 치러. 늦게 만들어지는 동시성 도구는 처음 만들어질 때 돌고 있던 event loop 에 묶여. 그런데 테스트마다 새 loop 를 주는 스위트라면 두 번째 테스트가 첫 번째 테스트의 죽은 loop 에 묶인 문을 받게 돼. 답은 테스트마다 singleton 을 자동으로 초기화하는 거야. 이게 프로세스 범위가 됐다는 이유 하나로 존재하는 작고 의도적인 테스트 배선이지. 짚고 갈 만해. 많은 사람이 테스트를 통과시키려고 조용히 인스턴스 범위로 되돌리는 지점이 바로 여기거든.