[perf] overlap page preprocessing, pack the vit, enable prefill CUDA graph for paddle-ocr (#35318)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Mick
2026-08-19 08:20:55 +08:00
committed by GitHub
co-authored by Claude Opus 5
parent 64e404263e
commit 77fc5c128e
13 changed files with 1278 additions and 265 deletions
@@ -0,0 +1,69 @@
"""Guard the PaddleOCR-VL serving defaults that a refactor could silently drop.
Both settings here live in allowlists keyed by model type / architecture, so
nothing in PaddleOCR-VL's own code path breaks if an entry disappears — the
model just quietly serves slower.
A document page costs tens of milliseconds to resize + normalize + patchify, so
a single synchronous processor worker caps request throughput at
1 / preprocess_time no matter how much GPU is idle. Measured on an H200 with
1080p pages, opting into concurrent workers moved 32-way concurrent throughput
from 6.6 to 8.9 req/s and made single-stream TTFT stable (the single-worker
path alternated between ~282 ms and ~790 ms).
The opt-in lives on the class, and `QwenVLImageProcessor` grants it only to an
explicit `model_type` allowlist that PaddleOCR-VL is not on — so it is exactly
the kind of setting a refactor can silently drop.
"""
import pytest
from sglang.srt.configs.model_config import (
multimodal_breakable_cuda_graph_supported_model_archs,
)
from sglang.srt.multimodal.processors.base_processor import BaseMultimodalProcessor
from sglang.srt.multimodal.processors.paddleocr_vlm import PaddleOCRVLImageProcessor
from sglang.test.ci.ci_register import register_cpu_ci
register_cpu_ci(est_time=2, suite="base-a-test-cpu")
def test_processor_opts_into_concurrency():
assert PaddleOCRVLImageProcessor.supports_mm_processor_concurrency is True
assert PaddleOCRVLImageProcessor.auto_mm_processor_worker_num > 1
assert PaddleOCRVLImageProcessor.auto_mm_io_worker_num > 1
def test_worker_count_stays_at_the_measured_optimum():
"""Two beat both one and four at 32-way concurrency on an H200, on document
pages and on small images with long outputs alike. Past two, spreading
request arrivals fragments GPU prefill batches faster than the extra overlap
pays for itself."""
assert PaddleOCRVLImageProcessor.auto_mm_processor_worker_num == 2
def test_concurrency_opt_in_is_not_inherited_by_accident():
"""The base class must stay conservative; this model opts in explicitly."""
assert BaseMultimodalProcessor.supports_mm_processor_concurrency is False
assert BaseMultimodalProcessor.auto_mm_processor_worker_num == 1
assert (
PaddleOCRVLImageProcessor.__dict__["supports_mm_processor_concurrency"] is True
), "the opt-in must be declared on PaddleOCRVLImageProcessor itself"
def test_prefill_breakable_cuda_graph_is_allowlisted():
"""Breakable CG is the CUDA default but is switched off for every multimodal
arch; PaddleOCR-VL opts back in so its text-only prefill keeps the graph.
Measured on an H200 (2704-token text prompts): single-stream TTFT 16.1 ms
without the graph, 11.5 ms with it. Image-carrying batches are rejected at
replay and run eager either way, so this is a text/mixed-traffic win only.
"""
assert (
"PaddleOCRVLForConditionalGeneration"
in multimodal_breakable_cuda_graph_supported_model_archs
)
if __name__ == "__main__":
raise SystemExit(pytest.main([__file__, "-v"]))
@@ -0,0 +1,268 @@
"""CPU coverage for the PaddleOCR-VL packed vision-tower fast paths.
The tower encodes a whole (possibly cross-request) batch as one packed
``[total_patches, dim]`` tensor. These tests pin the packed results to the
straightforward per-image reference so the packing stays a pure optimization.
"""
import pytest
import torch
import torch.nn as nn
from einops import rearrange
from sglang.srt.models.paddleocr_vl import (
Projector,
SiglipVisionEmbeddings,
build_packed_2d_position_ids,
merge_patch_neighbourhoods,
)
from sglang.test.ci.ci_register import register_cpu_ci
register_cpu_ci(est_time=20, suite="base-a-test-cpu")
# Mixes repeated grids (LFU cache hits), an odd aspect ratio, and t > 1.
GRIDS = [(1, 4, 6), (1, 8, 10), (2, 2, 4), (1, 8, 10)]
class _VisionConfig:
"""Minimal stand-in for PaddleOCR-VL's `vision_config`."""
hidden_size = 32
image_size = 56
patch_size = 14
num_channels = 3
class _TextConfig:
hidden_size = 48
def _grid_offsets():
"""Start row of each image inside the packed batch."""
offset = 0
for t, h, w in GRIDS:
yield offset
offset += t * h * w
def _packed_features(dtype=torch.float64) -> torch.Tensor:
total = sum(t * h * w for t, h, w in GRIDS)
return torch.randn(total, _VisionConfig.hidden_size, dtype=dtype)
def _reference_projector_output(
projector: Projector, packed: torch.Tensor
) -> torch.Tensor:
"""Per-image merge + projection, i.e. the pre-packing formulation."""
m1, m2 = projector.merge_kernel_size
outputs = []
offset = 0
for t, h, w in GRIDS:
num_patches = t * h * w
feature = projector.pre_norm(packed[offset : offset + num_patches])
feature = rearrange(
feature,
"(t h p1 w p2) d -> (t h w) (p1 p2 d)",
t=t,
h=h // m1,
p1=m1,
w=w // m2,
p2=m2,
)
outputs.append(projector.linear_2(projector.act(projector.linear_1(feature))))
offset += num_patches
return torch.cat(outputs, dim=0)
def _build_projector() -> Projector:
torch.manual_seed(0)
projector = Projector(_TextConfig(), _VisionConfig()).to(torch.float64)
return projector
# The merge is pure data movement, so it must be bit-exact. The projections that
# follow are not: batching N per-image GEMMs into one changes the blocking, and
# with it the summation order, so the results differ in the last bits (observed
# up to 3e-14 relative in fp64, and it is BLAS-implementation dependent -- equal
# on Apple silicon, unequal on x86). A permutation bug would move values by
# order 1, so this tolerance still catches one decisively.
_GEMM_REORDER_RTOL = 1e-12
_GEMM_REORDER_ATOL = 1e-12
def test_projector_merge_permutation_is_exact():
"""The 2x2 regroup moves data without arithmetic, so it must be bit-exact."""
torch.manual_seed(1)
projector = _build_projector()
packed = _packed_features()
normed = projector.pre_norm(packed)
actual = merge_patch_neighbourhoods(normed, GRIDS, projector.merge_kernel_size)
m1, m2 = projector.merge_kernel_size
expected = torch.cat(
[
rearrange(
normed[offset : offset + t * h * w],
"(t h p1 w p2) d -> (t h w) (p1 p2 d)",
t=t,
h=h // m1,
p1=m1,
w=w // m2,
p2=m2,
)
for offset, (t, h, w) in zip(_grid_offsets(), GRIDS)
],
dim=0,
)
assert actual.shape == expected.shape
assert torch.equal(actual, expected)
def test_projector_packed_merge_matches_per_image_reference():
torch.manual_seed(1)
projector = _build_projector()
packed = _packed_features()
actual = projector(packed, GRIDS)
expected = _reference_projector_output(projector, packed)
assert actual.shape == expected.shape
assert actual.shape[0] == sum(t * h * w for t, h, w in GRIDS) // 4
assert actual.shape[1] == _TextConfig.hidden_size
torch.testing.assert_close(
actual, expected, rtol=_GEMM_REORDER_RTOL, atol=_GEMM_REORDER_ATOL
)
def test_projector_is_batch_invariant():
"""Encoding images together must equal encoding them one at a time."""
torch.manual_seed(2)
projector = _build_projector()
packed = _packed_features()
together = projector(packed, GRIDS)
apart = []
offset = 0
for grid in GRIDS:
num_patches = grid[0] * grid[1] * grid[2]
apart.append(projector(packed[offset : offset + num_patches], [grid]))
offset += num_patches
apart = torch.cat(apart, dim=0)
torch.testing.assert_close(
together, apart, rtol=_GEMM_REORDER_RTOL, atol=_GEMM_REORDER_ATOL
)
def _build_embeddings() -> SiglipVisionEmbeddings:
torch.manual_seed(3)
embeddings = SiglipVisionEmbeddings(_VisionConfig()).to(torch.float64)
nn.init.normal_(embeddings.position_embedding.weight)
return embeddings
def _reference_position_embedding_add(
embeddings: SiglipVisionEmbeddings, patch_embeds: torch.Tensor
) -> torch.Tensor:
"""Uncached interpolation per image, concatenated — the pre-cache formulation."""
outputs = []
offset = 0
for t, h, w in GRIDS:
num_patches = t * h * w
image = patch_embeds[offset : offset + num_patches]
position = embeddings.interpolate_pos_encoding(h, w).squeeze(0).repeat(t, 1)
outputs.append(image + position)
offset += num_patches
return torch.cat(outputs, dim=0)
def test_position_embedding_cache_matches_uncached_interpolation():
embeddings = _build_embeddings()
torch.manual_seed(4)
patch_embeds = torch.randn(
sum(t * h * w for t, h, w in GRIDS),
_VisionConfig.hidden_size,
dtype=torch.float64,
)
expected = _reference_position_embedding_add(embeddings, patch_embeds)
actual = patch_embeds.clone()
offset = 0
for t, h, w in GRIDS:
num_patches = t * h * w
actual[offset : offset + num_patches].view(
t, h * w, _VisionConfig.hidden_size
).add_(embeddings.fetch_position_embedding_lfu_cache(h, w))
offset += num_patches
torch.testing.assert_close(actual, expected, rtol=0, atol=0)
# (8, 10) appears twice in GRIDS, so it must have been served from the cache.
assert embeddings.cache_position_count[(8, 10)] == 2
assert len(embeddings.cache_position_embedding) == 3
def test_position_embedding_cache_evicts_least_frequently_used():
embeddings = _build_embeddings()
embeddings.fetch_position_embedding_lfu_cache(4, 4, max_cache=2)
embeddings.fetch_position_embedding_lfu_cache(4, 4, max_cache=2)
embeddings.fetch_position_embedding_lfu_cache(6, 6, max_cache=2)
embeddings.fetch_position_embedding_lfu_cache(8, 8, max_cache=2)
assert set(embeddings.cache_position_embedding) == {(4, 4), (8, 8)}
def test_patch_embedding_takes_the_matmul_path():
"""kernel == stride and zero padding, so the conv must lower to a matmul."""
embeddings = _build_embeddings()
assert embeddings.patch_embedding.enable_linear
torch.manual_seed(5)
patch_size = _VisionConfig.patch_size
pixel_values = torch.randn(7, 3, patch_size, patch_size, dtype=torch.float64)
actual = embeddings.patch_embedding(pixel_values)
expected = nn.functional.conv2d(
pixel_values,
embeddings.patch_embedding.weight,
embeddings.patch_embedding.bias,
stride=(patch_size, patch_size),
)
torch.testing.assert_close(actual, expected, rtol=0, atol=1e-12)
# The tower adds position embeddings in place on this view.
assert actual.flatten(-2).squeeze(-1).is_contiguous()
def test_packed_2d_position_ids_match_per_image_reference():
pids, max_grid_size = build_packed_2d_position_ids(GRIDS, torch.device("cpu"))
expected_hids = []
expected_wids = []
for t, h, w in GRIDS:
image_pids = torch.arange(t * h * w) % (h * w)
expected_hids.append(image_pids // w)
expected_wids.append(image_pids % w)
expected = torch.stack([torch.cat(expected_hids), torch.cat(expected_wids)], dim=-1)
assert torch.equal(pids, expected)
# Must match the device-side `pids.max() + 1` it replaces.
assert max_grid_size == int(expected.max()) + 1
def test_packed_2d_position_ids_single_image_avoids_cat():
grid = (1, 3, 5)
pids, max_grid_size = build_packed_2d_position_ids([grid], torch.device("cpu"))
image_pids = torch.arange(15)
expected = torch.stack([image_pids // 5, image_pids % 5], dim=-1)
assert torch.equal(pids, expected)
assert max_grid_size == 5
if __name__ == "__main__":
raise SystemExit(pytest.main([__file__, "-v"]))
@@ -0,0 +1,117 @@
"""End-to-end OpenAI-API coverage for PaddleOCR-VL.
The vision tower encodes a whole batch as one packed tensor, so the test drives
a single-image request plus several concurrent requests with differently sized
images — the shape that makes the scheduler hand several images to one ViT
forward. Bit-exactness of the packing itself is pinned on CPU by
`test/registered/unit/models/test_paddleocr_vl_vision.py`.
"""
import base64
import io
import unittest
from concurrent.futures import ThreadPoolExecutor
import openai
from PIL import Image, ImageDraw, ImageFont
from sglang.test.ci.ci_register import register_cuda_ci
from sglang.test.vlm_utils import TestOpenAIMLLMServerBase
register_cuda_ci(est_time=240, stage="base-b", runner_config="1-gpu-large")
class TestPaddleOCRVLServer(TestOpenAIMLLMServerBase):
model = "PaddlePaddle/PaddleOCR-VL"
extra_args = [
"--context-length=8192",
"--mem-fraction-static=0.7",
"--cuda-graph-max-bs-decode=4",
]
@staticmethod
def _font(size: int):
for path in (
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf",
):
try:
return ImageFont.truetype(path, size=size)
except OSError:
pass
return ImageFont.load_default()
@classmethod
def _make_ocr_image_url(cls, text: str, size=(640, 360)) -> str:
width, height = size
img = Image.new("RGB", size, "white")
draw = ImageDraw.Draw(img)
draw.rectangle((16, 16, width - 16, height - 16), outline="black", width=4)
font_size = height // 6
font = cls._font(font_size)
text_width = draw.textbbox((0, 0), text, font=font)[2]
if text_width > width - 96:
font = cls._font((width - 96) * font_size // text_width)
draw.text((48, height // 3), text, fill="black", font=font)
buffer = io.BytesIO()
img.save(buffer, format="PNG")
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
return f"data:image/png;base64,{encoded}"
def _ocr(self, client, image_url: str, max_tokens: int = 64) -> str:
response = client.chat.completions.create(
model="default",
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": "OCR:"},
{"type": "image_url", "image_url": {"url": image_url}},
],
},
],
temperature=0,
max_tokens=max_tokens,
)
self.assertEqual(response.choices[0].message.role, "assistant")
self.assertGreater(response.usage.prompt_tokens, 0)
self.assertGreater(response.usage.completion_tokens, 0)
return response.choices[0].message.content
def test_single_image_ocr(self):
client = openai.Client(api_key=self.api_key, base_url=self.base_url)
text = self._ocr(client, self._make_ocr_image_url("SGLANG 12345"))
self.assertIsInstance(text, str)
self.assertIn("12345", text)
self.assertIn("sglang", text.lower())
def test_concurrent_requests_batch_the_vision_tower(self):
"""Different image sizes in flight at once must not bleed across images."""
client = openai.Client(api_key=self.api_key, base_url=self.base_url)
cases = [
("ALPHA 111", (640, 360)),
("BRAVO 222", (800, 320)),
("CHARLIE 333", (512, 512)),
("DELTA 444", (960, 288)),
]
urls = [self._make_ocr_image_url(text, size) for text, size in cases]
with ThreadPoolExecutor(max_workers=len(urls)) as pool:
results = list(pool.map(lambda url: self._ocr(client, url), urls))
for (expected_text, _), actual in zip(cases, results):
word, digits = expected_text.split()
self.assertIn(digits, actual, f"{expected_text!r} -> {actual!r}")
self.assertIn(
word.lower(), actual.lower(), f"{expected_text!r} -> {actual!r}"
)
del TestOpenAIMLLMServerBase
if __name__ == "__main__":
unittest.main()