본문으로 건너뛰기

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를 실행합니다.

  1. MinIO에서 scanned sheet를 다운로드합니다.
  2. Backend에서 exam과 template context를 가져옵니다.
  3. OpenCV 기반 OMR grading을 실행합니다.
  4. 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 time36.5s
whole usecase latency avg3.34s
whole usecase latency p957.09s
omr_grade avg3.21s
omr_grade p956.94s
image download avg102ms
exam fetch avg12ms
callback publish avg30ms

Pod에는 Kubernetes CPU limit이 없었으므로 일반적인 CFS throttling이 직접 원인은 아니었습니다. 문제는 비효율적인 CPU work와 native-thread oversubscription이었습니다.

유지한 변경

유지한 변경은 output을 보존하면서 불필요한 OpenCV work를 줄였습니다.

영역변경이유
image decodegrayscale로 직접 decodeOMR grading에는 intensity information만 필요합니다
preprocessinggrayscale-first pipeline 유지불필요한 channel conversion을 피합니다
gamma correctionlookup table cacheimage마다 같은 LUT를 다시 만들지 않습니다
morphologyreusable kernel cache반복 kernel allocation을 줄입니다
deskew상단 15% 영역에서 horizontal line detect관련 form line은 상단에 있습니다
marker detection알려진 top, left, right marker 영역 검색registration marker는 알려진 영역에 있습니다
density calculationcount_nonzero 사용binary mask에서 더 빠릅니다
answer selection전체 sort 없이 top two density 추적결정에는 best와 second-best만 필요합니다
runtimeOPENCV_NUM_THREADS=4와 native numeric pool cap 설정throughput을 유지하면서 oversubscription을 줄입니다

거부한 실험도 중요했습니다.

실험결과결정
INTER_LINEAR resizesample set에서 mismatch 발생거부
INTER_AREA resize유의미한 speed improvement 없음거부
CLAHE 또는 gamma 제거mismatch 발생거부
unsharp processing 제거작은 sample은 통과했지만 full 706-image run 실패거부
integral-image densitycorrectness는 통과했지만 더 느림revert
OpenCV thread 1 또는 2CPU는 낮지만 throughput 손실이 큼선택하지 않음

벤치마크

Local old-vs-current benchmark

200 image local comparison은 code-level improvement를 보여줬습니다.

지표이전현재변화
avg per image144.27ms78.49ms45.6% 감소
p50128.66ms71.67ms44.3% 감소
p95259.38ms123.95ms52.2% 감소
max697.20ms231.10ms66.9% 감소

이는 recognizer 변경의 유용한 증거였지만, thread setting에는 Linux container behavior가 더 중요했습니다.

Linux thread benchmark

706 image, grading concurrency 3의 Linux container benchmark는 OpenCV thread 4개를 선택했습니다.

OpenCV threadsWorker concurrencyWall timeThroughputCPU timeAvg imagep95 imageMismatches
10344.278s15.94 img/s262.634s187.63ms282.45ms0
4340.785s17.31 img/s188.357s172.93ms266.37ms0
2345.037s15.68 img/s162.814s190.91ms265.23ms0
4247.158s14.97 img/s178.710s133.33ms186.76ms0

Concurrency 3에서 기본 10 thread와 비교하면 4 thread 설정은 CPU time을 약 28.3% 줄이면서 throughput을 약 8.6% 높였습니다.

Production command path

100-image production command-path benchmark는 결합 결과를 보여줬습니다.

상태Wall timeUsecase avgomr_grade avgomr_grade p95
original 50m request49.3s3970ms3828ms9375ms
request raised to 250m38.3s3176ms2971ms5266ms
optimized recognizer + 250m + thread env21.5s1743ms1327ms2011ms

결과는 이전 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 runner706 checked, 0 mismatches
local old-vs-current benchmarkaverage per-image latency 약 45.6% 감소
Linux thread benchmarkOpenCV thread 4개에서 CPU time 감소와 throughput 증가
production command-path benchmarkwall 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로 결정해야 합니다.