Skip to main content

OMR Grading Performance Optimization

On June 21, 2026, OMR grading performance work focused on one constraint: make the worker faster without changing answers.

The final result combined code-level OpenCV cleanup, native thread caps, and an honest Kubernetes CPU request. In the 100-image production command path, wall time dropped from 49.3 seconds in the original request setting to 21.5 seconds after request tuning plus recognizer and thread optimization.

Context

lumie-worker/services/grading runs the OMR path:

  1. download the scanned sheet from MinIO;
  2. fetch exam and template context from the backend;
  3. run OpenCV-based OMR grading;
  4. publish the callback result.

The previous accuracy validation produced the guardrail: 706 deduplicated real scans with golden output. That meant performance changes could be rejected mechanically if they changed grading answers.

The runtime had a hidden concurrency multiplier. RabbitMQ prefetch allowed multiple in-flight grading jobs, while OpenCV used a native thread pool inside each job. With the Linux container defaulting to 10 OpenCV threads, effective runnable work became much larger than the visible queue concurrency.

Impact

The problem was user-visible latency and inefficient CPU use:

  • large OMR batches felt slow;
  • the pod CPU request did not honestly represent actual demand;
  • OpenCV native threads multiplied with queue concurrency;
  • tail latency worsened under contention even without a Kubernetes CPU limit.

The goal was not to lower resource settings blindly. The goal was to reduce CPU work per sheet and prevent native-thread oversubscription while preserving output on the real scan corpus.

Diagnosis

Stage timing showed that the bottleneck was CPU-heavy image recognition, not MinIO, RabbitMQ, or backend fetches.

For an 89-image production-like batch, the source record captured:

StageObserved result
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

There was no Kubernetes CPU limit on the pod, so classic CFS throttling was not the direct root cause. The issue was inefficient CPU work plus native-thread oversubscription.

Changes Kept

The retained changes reduced unnecessary OpenCV work while preserving output:

AreaChangeReason
image decodedecode directly as grayscaleOMR grading only needs intensity information
preprocessingkeep the pipeline grayscale-firstavoid redundant channel conversions
gamma correctioncache the lookup tableavoid rebuilding the same LUT per image
morphologycache the reusable kernelavoid repeated kernel allocation
deskewdetect horizontal lines from the top 15% regionrelevant form lines are near the top
marker detectionsearch known top, left, and right marker regionsregistration markers live in known regions
density calculationuse count_nonzerofaster for binary masks
answer selectiontrack top two densities without full sortingthe decision only needs best and second-best
runtimeset OPENCV_NUM_THREADS=4 and cap native numeric poolsreduce oversubscription while preserving throughput

Rejected experiments were as important as accepted ones:

ExperimentResultDecision
resize with INTER_LINEARcaused mismatches on the sample setrejected
resize with INTER_AREAdid not provide useful speed improvementrejected
remove CLAHE or gammacaused mismatchesrejected
remove unsharp processingpassed a small sample but failed the full 706-image runrejected
integral-image densitypassed correctness but was slowerreverted
OpenCV threads 1 or 2lower CPU, but too much throughput lossnot chosen

Benchmarks

Local old-vs-current benchmark

The local comparison over 200 images showed code-level improvement:

MetricPreviousCurrentChange
avg per image144.27ms78.49ms45.6% lower
p50128.66ms71.67ms44.3% lower
p95259.38ms123.95ms52.2% lower
max697.20ms231.10ms66.9% lower

This was useful evidence for the recognizer changes, but Linux container behavior mattered more for thread settings.

Linux thread benchmark

The Linux container benchmark over 706 images with grading concurrency 3 selected four OpenCV threads:

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

Compared with the 10-thread default at concurrency 3, the four-thread setting used about 28.3% less CPU time while improving throughput by about 8.6%.

Production command path

The 100-image production command-path benchmark showed the combined result:

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

The result preserved the resource posture from the earlier scaling work:

  • CPU request set to 250m;
  • no CPU limit;
  • OPENCV_NUM_THREADS=4;
  • native numeric pool caps such as OMP_NUM_THREADS=1, OPENBLAS_NUM_THREADS=1, MKL_NUM_THREADS=1, and NUMEXPR_NUM_THREADS=1.

Verification

Correctness owned the release gate:

CheckResult
uv run pytest in lumie-worker/services/gradingpassed in the source record
golden runner over deduplicated real OMR scans706 checked, 0 mismatches
local old-vs-current benchmarkabout 45.6% lower average per-image latency
Linux thread benchmarklower CPU time and higher throughput at four OpenCV threads
production command-path benchmarkwall time reduced from 49.3s to 21.5s

The optimization was acceptable because the 706-image golden corpus stayed identical.

What Went Well

  • Stage timing isolated omr_grade before resource tuning started.
  • The golden corpus prevented unsafe speedups from shipping.
  • Rejected experiments were recorded, not hidden.
  • Linux container benchmarks, not local macOS behavior, drove OpenCV thread selection.

What Went Poorly

  • OpenCV's native thread pool was initially invisible compared with RabbitMQ prefetch.
  • CPU request was too low for the real workload before measurement.
  • Some observability/logging had to be added during the performance work rather than already existing.
  • The production benchmark also surfaced a separate Redis routing issue where dedupe SET calls could hit a read-only replica.

Action Items

ActionOwner / surfaceStatusEvidence
Keep the golden OMR corpus as a release gate for recognizer changes.lumie-worker/services/gradingRequired for future changes706-image gate caught unsafe experiments.
Keep OpenCV and numeric thread caps explicit in runtime config.grading worker deployment valuesDone / maintainOPENCV_NUM_THREADS=4 selected by container benchmark.
Investigate Redis primary routing for dedupe writes.worker/backend cache routingFollow-upProduction benchmark surfaced read-only replica writes.

Lessons

  • Do not tune Kubernetes resources before isolating the expensive stage.
  • Native thread pools multiply with queue concurrency.
  • Performance changes in recognition code need an accuracy gate first.
  • The best wins came from reducing unnecessary work, not deleting core recognition steps.
  • Local benchmarks are useful, but Linux container benchmarks should decide runtime thread settings.