OMR 채점 성능 최적화
2026년 6월 21일, OMR 채점 성능 작업은 하나의 제약에 집중했습니다. 답을 바꾸지 않고 worker를 더 빠르게 만드는 것입니다.
최종 결과는 code-level OpenCV cleanup, native thread cap, 현실적인 Kubernetes CPU request를 결합했습니다. 100-image production command path에서 wall time은 기존 request 설정의 49.3초에서 request tuning, recognizer optimization, thread optimization 이후 21.5초로 줄었습니다.
배경
lumie-worker/services/grading는 OMR path를 실행합니다.
- MinIO에서 scanned sheet를 다운로드합니다.
- Backend에서 exam과 template context를 가져옵니다.
- OpenCV 기반 OMR grading을 실행합니다.
- Callback result를 publish합니다.
이전 OMR Grading Accuracy Validation은 guardrail을 만들었습니다. 706개의 deduplicated real scan과 golden output입니다. 덕분에 성능 변경이 grading answer를 바꾸면 기계적으로 거부할 수 있었습니다.
Runtime에는 숨은 concurrency multiplier가 있었습니다. RabbitMQ prefetch는 여러 grading job을 동시에 처리하게 했고, OpenCV는 각 job 안에서 native thread pool을 사용했습니다. Linux container 기본값인 OpenCV 10 thread 때문에 실제 runnable work는 눈에 보이는 queue concurrency보다 훨씬 커졌습니다.
영향
문제는 user-visible latency와 비효율적인 CPU 사용이었습니다.
- 큰 OMR batch가 느리게 느껴졌습니다.
- Pod CPU request가 실제 수요를 정직하게 표현하지 못했습니다.
- OpenCV native thread가 queue concurrency와 곱해졌습니다.
- Kubernetes CPU limit이 없어도 contention 아래에서 tail latency가 나빠졌습니다.
목표는 resource setting을 맹목적으로 낮추는 것이 아니었습니다. Sheet당 CPU work를 줄이고 native-thread oversubscription을 막으면서 real scan corpus의 output을 보존하는 것이었습니다.
진단
Stage timing은 병목이 MinIO, RabbitMQ, backend fetch가 아니라 CPU-heavy image recognition임을 보여줬습니다.
89-image production-like batch에서 원본 기록은 다음을 포착했습니다.
| 단계 | 관찰 결과 |
|---|---|
| batch wall time | 36.5s |
| whole usecase latency avg | 3.34s |
| whole usecase latency p95 | 7.09s |
omr_grade avg | 3.21s |
omr_grade p95 | 6.94s |
| image download avg | 102ms |
| exam fetch avg | 12ms |
| callback publish avg | 30ms |
Pod에는 Kubernetes CPU limit이 없었으므로 일반적인 CFS throttling이 직접 원인은 아니었습니다. 문제는 비효율적인 CPU work와 native-thread oversubscription이었습니다.
유지한 변경
유지한 변경은 output을 보존하면서 불필요한 OpenCV work를 줄였습니다.
| 영역 | 변경 | 이유 |
|---|---|---|
| image decode | grayscale로 직접 decode | OMR grading에는 intensity information만 필요합니다 |
| preprocessing | grayscale-first pipeline 유지 | 불필요한 channel conversion을 피합니다 |
| gamma correction | lookup table cache | image마다 같은 LUT를 다시 만들지 않습니다 |
| morphology | reusable kernel cache | 반복 kernel allocation을 줄입니다 |
| deskew | 상단 15% 영역에서 horizontal line detect | 관련 form line은 상단에 있습니다 |
| marker detection | 알려진 top, left, right marker 영역 검색 | registration marker는 알려진 영역에 있습니다 |
| density calculation | count_nonzero 사용 | binary mask에서 더 빠릅니다 |
| answer selection | 전체 sort 없이 top two density 추적 | 결정에는 best와 second-best만 필요합니다 |
| runtime | OPENCV_NUM_THREADS=4와 native numeric pool cap 설정 | throughput을 유지하면서 oversubscription을 줄입니다 |
거부한 실험도 중요했습니다.
| 실험 | 결과 | 결정 |
|---|---|---|
INTER_LINEAR resize | sample set에서 mismatch 발생 | 거부 |
INTER_AREA resize | 유의미한 speed improvement 없음 | 거부 |
| CLAHE 또는 gamma 제거 | mismatch 발생 | 거부 |
| unsharp processing 제거 | 작은 sample은 통과했지만 full 706-image run 실패 | 거부 |
| integral-image density | correctness는 통과했지만 더 느림 | revert |
| OpenCV thread 1 또는 2 | CPU는 낮지만 throughput 손실이 큼 | 선택하지 않음 |
벤치마크
Local old-vs-current benchmark
200 image local comparison은 code-level improvement를 보여줬습니다.
| 지표 | 이전 | 현재 | 변화 |
|---|---|---|---|
| avg per image | 144.27ms | 78.49ms | 45.6% 감소 |
| p50 | 128.66ms | 71.67ms | 44.3% 감소 |
| p95 | 259.38ms | 123.95ms | 52.2% 감소 |
| max | 697.20ms | 231.10ms | 66.9% 감소 |
이는 recognizer 변경의 유용한 증거였지만, thread setting에는 Linux container behavior가 더 중요했습니다.
Linux thread benchmark
706 image, grading concurrency 3의 Linux container benchmark는 OpenCV thread 4개를 선택했습니다.
| OpenCV threads | Worker concurrency | Wall time | Throughput | CPU time | Avg image | p95 image | Mismatches |
|---|---|---|---|---|---|---|---|
| 10 | 3 | 44.278s | 15.94 img/s | 262.634s | 187.63ms | 282.45ms | 0 |
| 4 | 3 | 40.785s | 17.31 img/s | 188.357s | 172.93ms | 266.37ms | 0 |
| 2 | 3 | 45.037s | 15.68 img/s | 162.814s | 190.91ms | 265.23ms | 0 |
| 4 | 2 | 47.158s | 14.97 img/s | 178.710s | 133.33ms | 186.76ms | 0 |
Concurrency 3에서 기본 10 thread와 비교하면 4 thread 설정은 CPU time을 약 28.3% 줄이면서 throughput을 약 8.6% 높였습니다.
Production command path
100-image production command-path benchmark는 결합 결과를 보여줬습니다.
| 상태 | Wall time | Usecase avg | omr_grade avg | omr_grade p95 |
|---|---|---|---|---|
| original 50m request | 49.3s | 3970ms | 3828ms | 9375ms |
| request raised to 250m | 38.3s | 3176ms | 2971ms | 5266ms |
| optimized recognizer + 250m + thread env | 21.5s | 1743ms | 1327ms | 2011ms |
결과는 이전 scaling work의 resource posture를 유지했습니다.
- CPU request는
250m입니다. - CPU limit은 없습니다.
OPENCV_NUM_THREADS=4입니다.OMP_NUM_THREADS=1,OPENBLAS_NUM_THREADS=1,MKL_NUM_THREADS=1,NUMEXPR_NUM_THREADS=1같은 native numeric pool cap을 둡니다.
검증
Correctness가 release gate였습니다.
| 점검 | 결과 |
|---|---|
lumie-worker/services/grading에서 uv run pytest | 원본 기록에서 통과 |
| deduplicated real OMR scan에 대 한 golden runner | 706 checked, 0 mismatches |
| local old-vs-current benchmark | average per-image latency 약 45.6% 감소 |
| Linux thread benchmark | OpenCV thread 4개에서 CPU time 감소와 throughput 증가 |
| production command-path benchmark | wall time 49.3s에서 21.5s로 감소 |
706-image golden corpus가 동일하게 유지됐기 때문에 최적화를 받아들일 수 있었습니다.
잘된 점
- Resource tuning 전에 stage timing으로
omr_grade를 격리했습니다. - Golden corpus가 안전하지 않은 speedup을 막았습니다.
- 거부한 실험도 기록했습니다.
- Local macOS behavior가 아니라 Linux container benchmark로 OpenCV thread를 선택했습니다.
아쉬웠던 점
- OpenCV native thread pool은 처음에는 RabbitMQ prefetch보다 덜 보였습니다.
- 측정 전 CPU request가 실제 workload에 비해 너무 낮았습니다.
- 일부 observability/logging은 성능 작업 중에 추가해야 했고 처음부터 있지 않았습니다.
- Production benchmark는 dedupe
SET이 read-only replica에 갈 수 있는 별도 Redis routing issue도 드러냈습니다.
액션 아이템
| 작업 | 소유 영역 | 상태 | 증거 |
|---|---|---|---|
| Recognizer 변경의 release gate로 golden OMR corpus를 유지합니다. | lumie-worker/services/grading | 향후 변경 필수 | 706-image gate가 안전하지 않은 실험을 잡았습니다. |
| OpenCV와 numeric thread cap을 runtime config에 명시적으로 유지합니다. | grading worker deployment values | 완료 / 유지 | OPENCV_NUM_THREADS=4는 container benchmark로 선택했습니다. |
| Dedupe write를 위한 Redis primary routing을 조사합니다. | worker/backend cache routing | 후속 작업 | Production benchmark가 read-only replica write를 드러냈습니다. |
교훈
- 비싼 stage를 격리하기 전에 Kubernetes resource를 조정하지 않습니다.
- Native thread pool은 queue concurrency와 곱해집니다.
- Recognition code의 성능 변경에는 먼저 accuracy gate가 필요합니다.
- 가장 큰 개선은 core recognition step을 제거한 것이 아니라 불필요한 work를 줄인 데서 나왔습니다.
- Local benchmark는 유용하지만 runtime thread setting은 Linux container benchmark로 결정해야 합니다.