Pointgroup:TrainingEvaluationOOM
훈련중 평가(Evaluation)를 실행하면 OOM이 발생하는 이슈
- PointGroup 학습 exit 137 진단과 수정
- 작성일: 2026-10-01
- 대상: mlops 플랫폼의 Pointcept 학습 런타임(PointGroup, pg-ptv3)과 Kubernetes 로그 수집
- 관련 문서:
docs/plans/54_training_pod_memory_and_log_follow_plan.md - 상태: 코드 수정 완료, 커밋·배포·런타임 이미지 재게시·재학습 실측은 아직 하지 않음
요약
PointGroup 인스턴스 분할 학습이 약 16시간 뒤 종료 코드 137 로 끝났다. 원인은 두 가지가 겹친 것이다.
- 평가 단계가 쓰는 메모리가 학습이 진행될수록 커진다. 평가기가 제안마다 원본 점 수 크기의 마스크를 만들어 모든 검증 씬이 끝날 때까지 붙들기 때문이다.
- 검증 데이터 로더가 평가 때마다 워커 프로세스를 새로 fork 한다. 이미 커진 메인 프로세스를 fork 하면 그 메모리가 사본으로 한 벌 더 생긴다.
둘의 합이 파드 메모리 상한 64Gi 를 넘어 커널이 파드를 죽였다. 진단 중에 별개의 결함도 드러났다. 플랫폼의 로그 수집이 파드 시작 4시간 뒤에 끊기고 다시 붙지 않아서, 실패 화면이 12시간 묵은 로그를 보여 주고 있었다.
GPU 통합 메모리, /dev/shm, 배치 크기는 원인이 아니었다.
증상
학습 화면에 다음 메시지가 표시되었다.
학습이 실패했습니다 (exit 137). 학습 단계에서 멈췄습니다
메모리 부족(OOM)으로 종료되었습니다. 배치 크기나 입력 크기를 줄이거나 /dev/shm 을 키우세요.
파드 메모리 상한(GEO_MLOPS_TRAINING_RUNTIME_MEMORY_LIMIT)에 걸렸을 수도 있습니다.
trainer exited with code 137:
[2026-09-30 08:47:35,902 INFO misc.py line 117 1] Train: [8/100][310/494] ... loss: 4.4757 seg_loss: 0.0000 ...
메시지만 보면 8 epoch 의 학습 스텝 도중에 죽은 것처럼 보인다. 실제로는 그렇지 않았다. 아래 타임라인 참고.
seg_loss: 0.0000 은 결함이 아니다. 클래스를 고르지 않은 실행은 단일 클래스 object 로 학습하고, 클래스가 하나면 교차 엔트로피가 상수 0 이다. 학습은 오프셋 손실이 이끈다.
진단
처음 세운 가설
| 가설 | 결과 |
| 파드 메모리 상한(전날 추가, 기본 64Gi)에 걸렸다 | 맞음 |
| GPU 통합 메모리가 파드 cgroup 에 과금되어 상한이 CPU 와 GPU 합산이 되었다 | 아님. cgroup 의 kernel 몫이 0.29GB 뿐 |
| | 아님. shmem 0.24GB |
| 호스트 메모리가 epoch 를 거치며 자란다 | 맞음. 다만 학습 루프가 아니라 평가 단계에서 |
노드에서 확인한 값
배포 노드에서 커널 로그를 읽었다.
sudo dmesg -T | grep -i -B2 -A6 "out of memory"
sudo dmesg -T | grep -B45 -A3 "oom-kill:constraint"
sudo dmesg -T | grep -A45 "Memory cgroup stats for"
핵심 줄은 다음과 같다.
oom-kill:constraint=CONSTRAINT_MEMCG, ... task=python,pid=442561
Memory cgroup out of memory: Killed process 442561 (python) total-vm:114837024kB, anon-rss:36670684kB, file-rss:95364kB, shmem-rss:231896kB
Tasks in ... are going to be killed due to memory.oom.group set
Memory cgroup out of memory: Killed process 443443 (pt_data_worker) total-vm:21504120kB, anon-rss:1190224kB
Memory cgroup out of memory: Killed process 1672569 (pt_data_worker) total-vm:108775600kB, anon-rss:31221212kB
Memory cgroup stats for ...:
anon 68195393536
file 237563904
kernel 286511104
shmem 237547520
inactive_anon 57443008512
active_anon 10989826048
| 항목 | 값 | 뜻 |
| 파드 상한 | 64Gi (67.1GB) | |
| cgroup anon | 68.2GB | 상한을 채운 것은 전부 프로세스 익명 메모리 |
| cgroup kernel | 0.29GB | GPU 드라이버 메모리는 cgroup 에 잡히지 않음 |
| cgroup shmem | 0.24GB | |
| 메인 python | 36.7GB | 평가가 쥔 메모리 |
| 늦게 fork 된 워커 (pid 1672569) | 31.2GB | 부모 메모리의 사본 |
| 시작 때 fork 된 워커 (pid 443443) | 1.2GB | 학습 로더 워커 |
숫자를 읽는 법
-
constraint=CONSTRAINT_MEMCG는 노드 전체가 아니라 파드 cgroup 상한에 걸렸다는 뜻이다. - fork 된 프로세스의 RSS 는 부모와 공유하는 페이지까지 포함한다. 그래서 워커의 31.2GB 가 공유분인지 자기 몫인지는 프로세스 표만으로는 알 수 없다.
- cgroup 은 페이지를 한 번만 센다. 세 프로세스의 익명 메모리 합(69.1GB)이 cgroup anon(68.2GB)과 거의 같으므로 워커의 31.2GB 는 공유분이 아니라 자기 소유의 사본이다.
- 이 사본이 없었다면 사용량은 38GB 쯤으로 상한에 한참 못 미쳤다.
- 워커가 둘뿐이고 pid 가 크게 다르다. 하나는 학습 시작 때, 다른 하나는 한참 뒤에 만들어졌다. 뒤의 것이 평가 때 새로 fork 된 검증 로더 워커다.
-
inactive_anon이 57GB 다. 대부분의 메모리가 최근에 쓰이지 않았다. 워커가 들고 있던 사본과 메인 프로세스가 붙들고만 있던 마스크가 여기에 해당한다.
원인
평가 메모리가 epoch 를 따라 자란다
PointGroup 은 추론 때 점들을 군집으로 묶어 제안을 만든다. 평가 경로는 다음과 같다.
- 모델이
[제안 수, 복셀 수]크기의 int32 조밀 텐서를 CPU 에 만든다. - Pointcept 의
InsSegEvaluator.eval이 이를pred_masks[:, idx]로 원본 점 수만큼 펼친다.[제안 수, 원본 점 수]int32 텐서가 된다. -
associate_instances가 제안마다 원본 점 수 크기의 bool 마스크를 만들어pred_inst["mask"]에 넣는다. 이 마스크는 모든 검증 씬이 끝나 집계할 때까지 살아 있다. - 집계(
evaluate_matches)는 교집합 수와 점 수만 읽는다. 마스크는 읽지 않는다.
제안 수는 모델이 덩어리를 잘 묶을수록 늘어난다. 단일 클래스 실행에서는 제안 점수가 늘 1.0 이라 걸러지는 제안도 없다. 그래서 평가 메모리가 epoch 를 따라 커진다. 초반 평가는 무사히 지나가고 뒤 epoch 에서야 상한에 닿는 이유다.
검증 워커 fork 가 메모리를 복제한다
Pointcept 은 학습 로더를 persistent_workers=True 로 만든다. 워커는 학습 시작 때 한 번만 Fork 된다. 그 시점의 메인 프로세스는 작다.
검증 로더에는 이 옵션이 없다. <span style="color: red">평가 때마다 워커를 새로 Fork 한다. Fork 된 워커는 그 시점 부모의 메모리를 통째로 물려받는다. 처음에는 공유 상태지만, 부모가 평가를 돌며 그 메모리를 다시 쓰면 copy-on-write 로 부모는 새 페이지를 받고 워커는 옛 페이지를 혼자 갖게 된다. 결과적으로 메모리가 두 벌이 된다.
로그 수집이 4시간에 끊긴다
서버의 follow_job_logs 는 파드 로그 스트림을 한 번만 열었다. 스트림이 끝나면 그대로 반환하고 다시 붙지 않았다. 스트림은 두 경우에 오류 없이 조용히 끝난다.
- kubelet 이 follow 연결을 스트리밍 제한 시간(기본 4시간)에 닫는다.
- HTTP 클라이언트가 한 시간 동안 데이터가 없으면 읽기 시간 초과로 끝낸다.
이 실행은 파드가 뜬 지 정확히 4시간 되는 순간에 로그가 멎었다.
타임라인
| 시각 (KST) | 일 |
| 09-30 13:47:35 | 학습 파드 시작 |
| 09-30 17:47:35 | 플랫폼이 수집한 마지막 로그 줄 (8 epoch 310번째 스텝). 파드 시작 뒤 정확히 4시간 |
| 09-30 17:47 부터 | 학습은 계속되었으나 로그는 수집되지 않음 |
| 10-01 05:45:15 | memcg OOM 으로 파드 종료. 실행 시간으로 역산하면 30 epoch 근처의 평가 도중 |
컨테이너 로그의 시각은 UTC 이고 노드의 dmesg -T 는 KST 다. 둘을 맞춰 보면 마지막 로그와 종료 사이에 12시간의 공백이 있다.
수정
네 가지를 Phase 로 나눠 구현했다.
Phase 1. 평가기가 마스크를 붙들지 않는다
- 파일:
src/geo_mlops/resources/training_runtime/pointcept_insseg.py -
associate_instances함수를 새로 두었다. 제안 하나의 마스크를 그때그때 만들어 점 수를 세고 버린다. 원본 점으로 옮길 때도 제안 한 줄씩만 옮긴다.[제안 수, 원본 점 수]텐서는 만들지 않는다. - 정답 인스턴스와의 교집합은 제안이 덮은 점의 인스턴스 ID 를 한 번 세어 얻는다. 원본처럼 정답 인스턴스마다 전체 점을 다시 훑지 않으므로 평가도 빨라진다.
-
GeoInsSegEvaluator가 Pointcept 의InsSegEvaluator를 물려받아eval만 바꾼다. AP 계산과 best 체크포인트 기준(AP50)은 그대로다. - 생성 설정이 부르는 평가기 이름이
GeoInsSegEvaluator로 바뀌었다.
Phase 2. 검증 로더는 워커 없이 읽는다
- 파일:
src/geo_mlops/resources/training_runtime/pointcept_semseg.py -
GeoTrainer.build_val_loader가 워커 수를VAL_LOADER_WORKERS = 0으로 두고 로더를 만든다. 평가 때 fork 가 일어나지 않는다. - 검증은 배치 1 이고 씬 수가 적어 워커가 벌어 주는 시간이 거의 없다.
- 시맨틱 분할과 인스턴스 분할 모두에 적용된다. 학습 로더의 워커 수(
num_workers)는 그대로다.
Phase 3. 로그 팔로워가 재접속한다
- 파일:
src/geo_mlops/integrations/kubernetes.py - 스트림이 끝나면 파드 상태를 묻는다.
- 실행 중이면 마지막으로 받은 줄의 시각부터 다시 붙는다.
- 끝났으면 꼬리를 줍기 위해 한 번 더 읽고 마친다.
- 파드가 사라졌으면 마친다.
- 클러스터를 읽을 수 없으면 실행 중으로 보고 다시 시도한다.
_LogCursor 가 재접속 때 다시 오는 줄을 버리고, 타임스탬프를 뗀 줄만 넘긴다. 같은 타임스탬프의 줄이 여럿이어도 잃지 않는다. Phase 4. epoch 마다 메모리를 기록한다
- 파일:
src/geo_mlops/resources/training_runtime/pointcept_semseg.py -
GeoProgress.after_epoch가 epoch 마다 한 줄을 찍는다.
-> memory epoch 30: rss 3.00GiB (peak 36.00GiB), pod 5.00GiB of 64.00GiB (peak 40.00GiB), cuda reserved 8.00GiB (peak allocated 6.00GiB)
| 항목 | 뜻 |
| rss | 메인 프로세스의 상주 메모리. 평가가 끝난 뒤의 값 |
| rss 의 peak | 지난 보고 뒤의 최고치. 평가가 epoch 끝에 돌므로 그 epoch 평가의 크기 |
| pod | 파드 cgroup 전체 사용량과 상한. 상한에 걸리는 값이 이것 |
| pod 의 peak | 파드 시작 뒤의 최고치 |
| cuda | PyTorch 가 예약한 GPU 메모리와 최대 할당량 |
exit 137 은 사후에 원인을 말해 주지 않는다. 이 줄이 있으면 어느 값이 언제부터 자랐는지를 노드에 들어가지 않고 읽을 수 있다.
손대지 않은 것
- 모델 안의
[제안 수, 복셀 수]int32 텐서는 Pointcept 포크의 코드라 그대로 두었다. 복셀 기준이라 원본 점 기준 텐서보다 훨씬 작다. Phase 4 의 기록에서 평가 최고치가 여전히 크면 그때 고친다. - 실패 메시지 문구와 메모리 상한 기본값(64Gi)은 바꾸지 않았다.
검증
| 대상 | 방법 | 결과 |
| 연관 계산 (Phase 1) | Pointcept 구현을 옮긴 기준 구현과 대조 (무작위 씬 5개, 무시 클래스 설정 2가지) | 일치 |
| 마스크 미보관 (Phase 1) | 반환값 어디에도 배열이 남지 않는지 확인 | 통과 |
| 검증 로더 (Phase 2) | 트레이너 소스에서 재정의와 원복 확인 | 통과 |
| 로그 재접속 (Phase 3) | 닫힌 스트림, 끊긴 스트림, 클러스터 조회 실패, 파드 삭제, 중복 줄 제거 | 통과 |
| 메모리 보고 (Phase 4) | 가짜 proc·cgroup 파일로 출력 확인, 파일이 없을 때도 실패하지 않음 | 통과 |
| 회귀 | | 889 통과, 1 실패 |
실패 1건은 test_flash_attention_stays_on 이다. 개발 환경에 flash-attn 이 없어 나는 실패이고 이번 변경과 무관하다.
검증하지 못한 것이 하나 있다. GeoInsSegEvaluator.eval 본문은 개발 환경에 torch 와 pointcept 가 없어 실행해 보지 못했다. 연관 계산은 대조했지만 평가 루프 자체는 재학습 실측에서 처음 돈다.
남은 일
- 변경을 커밋한다.
- 서버를 배포한다. Phase 3 은 서버 코드라 이것만으로 적용된다.
- Pointcept 학습 런타임 이미지를 재게시한다. Phase 1, 2, 4 는 이미지 안의 트레이너 코드다.
- 같은 데이터로 pg-ptv3 를 다시 학습한다. 확인할 것은 세 가지다.
- 첫 평가가 정상적으로 끝나고 AP 값이 나오는가
- 4시간 뒤에도 로그가 이어지는가
-
-> memory줄의 peak 가 epoch 를 따라 얼마나 자라는가
같은 증상을 다시 만났을 때
노드에서 먼저 커널 로그를 읽는다.
sudo dmesg -T | grep -A45 "Memory cgroup stats for"
sudo dmesg -T | grep -B10 -A6 "oom-kill:constraint"
그다음 아래 순서로 읽는다.
- 실패 메시지의 로그 꼬리가 가리키는 epoch 을 그대로 믿지 않는다. 로그의
-> memory줄과 MLflow 의 마지막 step 을 먼저 본다. -
constraint가CONSTRAINT_MEMCG인지 본다. 그렇다면 노드 전체가 아니라 파드 상한에 걸린 것이다. - cgroup 통계에서
anon,shmem,kernel중 무엇이 큰지 본다.-
anon이 크면 프로세스 힙이다. -
shmem이 크면/dev/shm이다. -
kernel이 크면 드라이버나 커널 쪽이다.
-
- 프로세스 표의 익명 메모리 합을 cgroup
anon과 견준다. 합이 훨씬 크면 fork 로 공유된 페이지가 이중으로 세어진 것이고, 비슷하면 각자 자기 몫을 들고 있는 것이다. - 추세가 필요하면 모니터링에서 파드의
container_memory_working_set_bytes를 본다.
See also
- Pointcept
- Pointgroup