Suya OCR API — vLLM-backed, OpenAI-compatible OCR service

FastAPI service wrapping the Surya-OCR-2 model (datalab-to) served through vLLM:
legacy /v1/api/ai/* endpoints, an OpenAI-compatible /v1/chat/completions endpoint,
a coalescing request batcher, a local OCR CLI, Docker packaging, multilingual
example outputs, and quantization/concurrency benchmarks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fu Dai
2026-06-17 10:20:02 +04:00
co-authored by Claude Opus 4.8
commit 1a585693be
147 changed files with 13827 additions and 0 deletions
+182
View File
@@ -0,0 +1,182 @@
# vLLM Backend Plan and Test Results
Date: 2026-06-09
## Plan
1. Keep the legacy OCR endpoint at `/v1/api/ai/suya_ocr/` for baseline comparison.
2. Add `/v1/api/ai/suya_ocr_vllm/`, backed by Surya2 `SuryaInferenceManager(method="vllm")`.
3. Use the Surya vLLM backend settings from the referenced Surya checkout:
- `SURYA_INFERENCE_BACKEND=vllm`
- `SURYA_INFERENCE_KEEP_ALIVE=true`
- T4 default: `VLLM_GPU_TYPE=t4`, `VLLM_DTYPE=float16`, `SURYA_INFERENCE_PARALLEL=16`
- optional tuning through `VLLM_GPUS`, `VLLM_GPU_MEMORY_UTILIZATION`, and `SURYA_INFERENCE_URL`
4. Add a concurrency benchmark that sends the same payload to the legacy and vLLM endpoints and writes latency/throughput JSON.
5. Verify syntax locally, then run the benchmark on a host with the Surya2 package, Docker, and NVIDIA GPU access.
## Implemented Files
- `vllm_tools.py`: vLLM-backed Surya2 predictor setup and OCR response formatting.
- `api.py`: new `/v1/api/ai/suya_ocr_vllm/` endpoint and health endpoint.
- `concurrency_test.py`: repeatable old-vs-new endpoint concurrency benchmark.
- `concurrency_test_results.json`: placeholder result file that the benchmark overwrites with measured results.
- `requirements.txt`: dependency alignment for Surya2/vLLM OpenAI client support.
- `Dockerfile`: T4 vLLM defaults. The referenced backend maps T4 16 GB VRAM to `max_num_seqs=16` and `max_num_batched_tokens=4096`; `float16` is required because T4 does not support `bfloat16`.
## Single-Container Kubernetes Deployment
The deployment now uses `vllm/vllm-openai:v0.20.1` as the base image. The container starts the vLLM OpenAI-compatible server in the background and runs `api.py` in the foreground through `scripts/start_single_container.sh`.
The API no longer depends on nested Docker. It attaches to:
```text
SURYA_INFERENCE_URL=http://127.0.0.1:8000/v1
```
The Surya source needed by the API is vendored into this repo under `surya/`, so the container does not depend on `/path/to/surya` at runtime.
Serving defaults for T4:
```text
SUYA_OCR_MODE=block
SUYA_MAX_BATCH_SIZE=8
SUYA_BATCH_WAIT_MS=25
SURYA_INFERENCE_PARALLEL=8
SURYA_INFERENCE_MAX_RETRIES=1
SURYA_MAX_BLOCKS_PER_PAGE=80
VLLM_DTYPE=float16
VLLM_MAX_NUM_SEQS=16
VLLM_MAX_BATCHED_TOKENS=4096
```
Speed-related changes:
- `/v1/api/ai/suya_ocr_vllm/` uses `vllm_batcher.py` to coalesce concurrent HTTP requests into batch Surya calls.
- `vllm_tools.py` batches layout and recognition across images.
- Default OCR mode is block mode, avoiding long full-page generations and hidden fallback bursts.
- Vendored Surya caps block OCR per page with `SURYA_MAX_BLOCKS_PER_PAGE`.
- Retry amplification is limited with `SURYA_INFERENCE_MAX_RETRIES=1`.
- The endpoint skips drawing annotated images because the API response only returns JSON/text.
- Base64 input is decoded once per request and the same PIL image object is reused for low/high-res OCR paths unless a caller explicitly asks for a copy.
- vLLM image payload encoding defaults to high-quality JPEG (`SUYA_VLLM_IMAGE_FORMAT=JPEG`, `SUYA_VLLM_JPEG_QUALITY=92`) to reduce CPU serialization and local vLLM transfer size versus PNG. Set `SUYA_VLLM_IMAGE_FORMAT=PNG` if lossless transport is required.
## Benchmark Command
```bash
python concurrency_test.py \
--image /path/to/surya/ttt.png \
--requests 20 \
--concurrency 8 \
--output concurrency_test_results.json
```
## Current Test Results
Import verification passed with `PYTHONPATH=/path/to/surya`.
Concurrency benchmark run:
```bash
/opt/conda/bin/python concurrency_test.py \
--image temp_image_c3c56948-282e-453b-8fac-6c482243d1e5.jpg \
--requests 16 \
--concurrency 16 \
--timeout 900 \
--output concurrency_test_results.json
```
Run started at `2026-06-11T14:35:17+0400`. Docker NVIDIA runtime and GPU checks passed. The local Python environment does not include the `vllm` package, so this validation attached the updated API to the already-warm local `vllm/vllm-openai:v0.20.1` server on port `43401` with `SURYA_INFERENCE_URL=http://127.0.0.1:43401/v1`. The API path still used the single-container attach mode (`SURYA_INFERENCE_AUTOSTART=false`) and did not start nested Docker.
| Endpoint | Requests | Concurrency | Success | Failed | Wall seconds | Throughput rps | Mean latency | P50 latency | P95 latency | Max latency |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| `/v1/api/ai/suya_ocr/` | 16 | 16 | 16 | 0 | 43.62 | 0.3668 | 40.81 | 41.84 | 43.39 | 43.62 |
| `/v1/api/ai/suya_ocr_vllm/` | 16 | 16 | 16 | 0 | 83.59 | 0.1914 | 59.08 | 60.41 | 83.52 | 83.54 |
Latency values are per request in seconds. Mean latency is the average request elapsed time, not the total benchmark wall time.
## Approach A Outcome (2026-06-12)
Diagnosis + work-reduction tuning (see `docs/diagnosis_baseline.md` and
`docs/tier2_results.md`). Measured on the A100 dev host against the warm
`surya-vllm-43401` server — same host/server as the table above, so comparable.
**Production is T4; the fix below is hardware-independent, the absolute numbers
are not.**
**Root cause:** the request batcher coalesced requests then dispatched them only
`SURYA_INFERENCE_PARALLEL=8` wide, so only 8 of vLLM's 16 sequence slots were
used (poller `peak_running=8`). The GPU sat half-idle, which is why the vLLM
endpoint was *slower* than the flooding legacy path.
**Fix (Tier 1):** scale the worker pool to the in-flight block count, capped by a
new `SURYA_INFERENCE_MAX_INFLIGHT` (default 16, = `VLLM_MAX_NUM_SEQS`). This fills
all 16 slots (`peak_running=16`).
| vLLM endpoint, 16/16 | mean | p50 | p95 | peak_running |
| --- | ---: | ---: | ---: | ---: |
| MI=8 (before) | 50.22 | 49.48 | 72.69 | 8 |
| MI=16 (fix) | 33.75 | 36.11 | 49.15 | 16 |
- Mean 33%, p95 32%; single request 6.16 s → ~5.3 s. CER divergence 0.0009
(lossless; gate ≤ 0.005). The vLLM endpoint now beats legacy on mean and median.
- MI=24 overcommits (worse). Batch-size / batch-wait are within noise.
**Tier 2 (token ceiling, recognition DPI, block cap):** no latency benefit on
this sparse page — decode dominates and the page is light — so all Tier-2 knobs
are left at defaults. They remain relevant for dense / pathological pages.
**Locked change:** `SURYA_INFERENCE_MAX_INFLIGHT=16` added to `Dockerfile` and
`scripts/start_single_container.sh`. Re-confirm absolute latencies on a T4 before
treating them as production SLAs; the regression fix itself applies unchanged.
## FP8 Experiment
I attempted to start the latest vLLM image with multimodal encoder FP8 enabled:
```bash
--dtype bfloat16 \
--mm-encoder-attn-dtype fp8
```
The first attempt on `cuda:0` failed before serving traffic because GPU 0 did not have enough free memory for the default `--gpu-memory-utilization 0.85` target.
The second attempt on `cuda:1` booted farther, but vLLM aborted during model initialization with:
```text
ValueError: mm_encoder_attn_dtype='fp8' requires the FlashInfer cuDNN backend with cuDNN >= 9.17.1 on a GPU with native FP8 support.
```
That means:
- no FP8 latency number was produced
- the current A100 hardware is not a valid target for this exact FP8 path
- the failure is architectural, not a tuning issue
To measure a real FP8 speed change, the next engineer should use a GPU with native FP8 support and a vLLM build that satisfies the FlashInfer cuDNN requirement for multimodal encoder FP8.
## NVIDIA Docker Fix
Docker currently has no registered `nvidia` runtime. The expected daemon config is:
```json
{
"data-root": "/data/docker_cache",
"runtimes": {
"nvidia": {
"args": [],
"path": "nvidia-container-runtime"
}
}
}
```
This user cannot write `/etc/docker/daemon.json` or restart Docker because `sudo` requires a password. The host does support Docker GPU passthrough through modern `--gpus all`, so the API now patches Surya's vLLM spawn path to use `scripts/docker_nvidia_runtime_compat.sh`. That wrapper strips the legacy `--runtime nvidia` pair and preserves `--gpus device=...`.
An admin can still register the named runtime permanently with:
```bash
sudo nvidia-ctk runtime configure --runtime=docker --config=/etc/docker/daemon.json
sudo systemctl restart docker
docker run --rm --runtime nvidia --gpus all nvidia/cuda:12.4.1-base-ubuntu22.04 nvidia-smi
```
The repo includes `scripts/configure_nvidia_docker.sh` with those commands.