File size: 8,512 Bytes
a15d07c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 | """Export PaddlePaddle/PP-DocLayoutV3_safetensors to ONNX.
The HF module returns a large structured output (intermediate decoder states,
per-layer masks, reference points...). Only four tensors are needed at serving
time, so we wrap the model and return exactly those:
logits (B, 300, num_classes)
pred_boxes (B, 300, 4) cxcywh, normalised
order_logits (B, 300, 300) reading-order pointer matrix
out_masks (B, 300, 200, 200) mask logits @ stride 4 (optional)
`out_masks` is by far the biggest tensor (300*200*200*4B = 48 MB per image in
fp32). Export with --no-masks if you only need boxes + reading order; polygons
then degrade to axis-aligned rectangles.
Usage:
python export_pp_doclayout_v3.py --output pp_doclayoutv3.onnx
python export_pp_doclayout_v3.py --output pp_doclayoutv3_nomask.onnx --no-masks
python export_pp_doclayout_v3.py --output m.onnx --fp16 # extra fp16 copy
"""
from __future__ import annotations
import argparse
from contextlib import contextmanager
from pathlib import Path
import numpy as np
import torch
from torch import nn
from transformers import AutoModelForObjectDetection
DEFAULT_MODEL = "PaddlePaddle/PP-DocLayoutV3_safetensors"
INPUT_SIZE = 800 # preprocessor_config.json: {"height": 800, "width": 800}
@contextmanager
def fp32_position_embedding():
"""Force the 2D sin/cos position embedding to float32 during tracing.
Upstream computes the frequency grid in float64, which bakes Sin/Cos(double)
nodes into the graph. ONNX Runtime's CPU EP has no double kernel for those, so
the session fails to load with:
NOT_IMPLEMENTED : Could not find an implementation for Cos(7)
The embedding is cast to float32 anyway; the difference is ~1e-6.
"""
from transformers.models.pp_doclayout_v3 import modeling_pp_doclayout_v3 as M
original = M.build_2d_sinusoidal_position_embedding
def patched(height, width, embed_dim=256, temperature=10000.0,
cls_token=False, device=None, dtype=torch.float32):
pos_dim = embed_dim // 4
omega = torch.arange(pos_dim, dtype=torch.float32, device=device) / pos_dim
omega = 1.0 / temperature**omega
grid_h = torch.arange(height, dtype=torch.float32, device=device)
grid_w = torch.arange(width, dtype=torch.float32, device=device)
grid_h, grid_w = torch.meshgrid(grid_h, grid_w, indexing="ij")
emb_h = grid_h.flatten().outer(omega)
emb_w = grid_w.flatten().outer(omega)
pos_embed = torch.cat([emb_h.sin(), emb_h.cos(), emb_w.sin(), emb_w.cos()], dim=1)
if cls_token:
zeros = torch.zeros(1, embed_dim, dtype=torch.float32, device=device)
pos_embed = torch.cat([zeros, pos_embed], dim=0)
return pos_embed.to(dtype)
M.build_2d_sinusoidal_position_embedding = patched
try:
yield
finally:
M.build_2d_sinusoidal_position_embedding = original
class PPDocLayoutV3ExportWrapper(nn.Module):
"""Flattens the HF output struct down to the tensors post-processing needs."""
def __init__(self, model: nn.Module, with_masks: bool = True) -> None:
super().__init__()
self.model = model
self.with_masks = with_masks
def forward(self, pixel_values: torch.Tensor):
out = self.model(pixel_values=pixel_values)
order_logits = out.order_logits
# Doc types it as a tuple; take the final decoder layer if so.
if isinstance(order_logits, (tuple, list)):
order_logits = order_logits[-1]
if order_logits.dim() == 4: # (B, layers, Q, Q)
order_logits = order_logits[:, -1]
if not self.with_masks:
return out.logits, out.pred_boxes, order_logits
masks = out.out_masks
if masks.dim() == 5: # (B, layers, Q, H, W) -> last layer only
masks = masks[:, -1]
return out.logits, out.pred_boxes, order_logits, masks
def export(
model_path: str,
output: Path,
with_masks: bool = True,
opset: int = 17,
dynamo: bool = False,
dynamic_batch: bool = True,
) -> tuple[nn.Module, list[str]]:
model = AutoModelForObjectDetection.from_pretrained(model_path, dtype=torch.float32).eval()
# Pure-PyTorch deformable attention (grid_sample) instead of the custom CUDA
# kernel — the custom op has no ONNX symbolic. The checkpoint config already
# sets this, but be explicit in case someone overrides it.
model.config.disable_custom_kernels = True
wrapper = PPDocLayoutV3ExportWrapper(model, with_masks=with_masks).eval()
dummy = torch.randn(1, 3, INPUT_SIZE, INPUT_SIZE)
output_names = ["logits", "pred_boxes", "order_logits"]
if with_masks:
output_names.append("out_masks")
# Height/width stay static: the image processor always resizes to 800x800,
# and static spatial dims let ORT constant-fold the anchor generation.
dynamic_axes = None
if dynamic_batch:
dynamic_axes = {name: {0: "batch"} for name in ["pixel_values"] + output_names}
output.parent.mkdir(parents=True, exist_ok=True)
with torch.inference_mode(), fp32_position_embedding():
torch.onnx.export(
wrapper,
(dummy,),
str(output),
input_names=["pixel_values"],
output_names=output_names,
dynamic_axes=dynamic_axes,
opset_version=opset,
do_constant_folding=True,
dynamo=dynamo,
)
print(f"Exported -> {output} ({output.stat().st_size / 1e6:.1f} MB)")
return wrapper, output_names
def check_parity(
wrapper: nn.Module, onnx_path: Path, output_names: list[str], batch: int = 2, atol: float = 1e-3
) -> None:
"""Compare ONNX Runtime against PyTorch on random input.
Note: this is only meaningful with the real pretrained weights. With randomly
initialised weights the encoder emits thousands of identical proposal scores,
so TopK query selection is arbitrary and torch/ORT legitimately disagree.
"""
import onnxruntime as ort
x = torch.randn(batch, 3, INPUT_SIZE, INPUT_SIZE)
with torch.inference_mode():
torch_out = [t.numpy() for t in wrapper(x)]
sess = ort.InferenceSession(str(onnx_path), providers=["CPUExecutionProvider"])
onnx_out = sess.run(None, {"pixel_values": x.numpy()})
ok = True
for name, t, o in zip(output_names, torch_out, onnx_out):
diff = np.abs(t - o).max()
status = "OK " if diff < atol else "FAIL"
ok &= diff < atol
print(f" {status} {name:14s} shape={tuple(o.shape)} max|diff|={diff:.3e}")
print("Parity check passed\n" if ok else "Parity check FAILED — do not ship this graph\n")
def to_fp16(onnx_path: Path) -> Path:
"""Half-precision copy. Keep normalisation/mask ops in fp32 to avoid overflow."""
import onnx
from onnxconverter_common import float16
model = onnx.load(str(onnx_path))
fp16_model = float16.convert_float_to_float16(
model, keep_io_types=True, op_block_list=["GridSample", "ReduceMean", "Pow", "Sqrt", "Div"]
)
out = onnx_path.with_name(onnx_path.stem + "_fp16.onnx")
onnx.save(fp16_model, str(out))
print(f"fp16 -> {out} ({out.stat().st_size / 1e6:.1f} MB)")
return out
def main() -> int:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--model", default=DEFAULT_MODEL)
p.add_argument("--output", type=Path, default=Path("pp_doclayoutv3.onnx"))
p.add_argument("--opset", type=int, default=17, help=">=16 required for GridSample")
p.add_argument("--no-masks", dest="with_masks", action="store_false")
p.add_argument("--static-batch", dest="dynamic_batch", action="store_false")
p.add_argument("--dynamo", action="store_true", help="use the TorchDynamo exporter")
p.add_argument("--fp16", action="store_true", help="also emit an fp16 copy")
p.add_argument("--skip-check", action="store_true")
args = p.parse_args()
wrapper, names = export(
args.model,
args.output,
with_masks=args.with_masks,
opset=args.opset,
dynamo=args.dynamo,
dynamic_batch=args.dynamic_batch,
)
if not args.skip_check:
check_parity(wrapper, args.output, names, batch=2 if args.dynamic_batch else 1)
if args.fp16:
to_fp16(args.output)
return 0
if __name__ == "__main__":
raise SystemExit(main())
|