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())