From 9d5b79f47cbd60ad652a35f04d4c1c5e86ef12dd Mon Sep 17 00:00:00 2001 From: asteinh Date: Sun, 16 Aug 2026 09:32:34 +0200 Subject: [PATCH 1/4] feature: dedicated ConvTranspose 2D tile solve over output extent --- src/tigris/analysis/partition_spatial.py | 141 +++++++++++++++++++++++ tests/test_2d_tiling.py | 112 ++++++++++++++++++ 2 files changed, 253 insertions(+) diff --git a/src/tigris/analysis/partition_spatial.py b/src/tigris/analysis/partition_spatial.py index 5bab278..0e8eca7 100644 --- a/src/tigris/analysis/partition_spatial.py +++ b/src/tigris/analysis/partition_spatial.py @@ -268,6 +268,101 @@ def _stage_2d_eligible( return all(_op_supports_axis(op, TILE_AXIS_HW) for op in stage_ops) +def _stage_is_convtranspose_2d(stage_ops: list[OpNode]) -> bool: + """A stage qualifies for the dedicated ConvTranspose 2D solve iff exactly + one op is a ConvTranspose (group == 1, unit dilation) and every other op is + an audited unary pointwise wrapper. + + ConvTranspose is deliberately kept UNTILEABLE in _OP_CATEGORY, so it never + reaches the height (1D), HW-conv, or chain paths; this predicate gates the + isolated 2D-output-extent branch that replaces them for it. The group and + dilation checks are defense in depth: validate_operator_support already + rejects group != 1 or non-unit dilation, but a not-yet-rejected op must + never fall into the 2D solve. + + "Audited pointwise" reuses the same set _stage_2d_eligible admits: the + POINTWISE category minus _BINARY_OPS and Concat. Those take an independent + second operand that the shared input-halo rectangle load does not co-tile, + so they are excluded here for the same reason. This bounds Phase 1.3c to a + ConvTranspose plus optional unary pointwise. + """ + convtranspose = [op for op in stage_ops if op.op_type == "ConvTranspose"] + if len(convtranspose) != 1: + return False + ct = convtranspose[0] + if int(ct.attrs.get("group", 1)) != 1: + return False + if _get_dilation_h(ct) != 1 or _get_dilation_w(ct) != 1: + return False + for op in stage_ops: + if op is ct: + continue + if op.op_type in _BINARY_OPS or op.op_type == "Concat": + return False + if classify_op(op.op_type) != TileCategory.POINTWISE: + return False + return True + + +def _solve_convtranspose_2d( + ag: AnalyzedGraph, stage: Stage, stage_ops: list[OpNode], budget: int +) -> TilePlan: + """Emit a 2D (HW) tile plan for an over-budget ConvTranspose stage. + + ConvTranspose expands its spatial extent (stride upsampling), so the tile + grid and the proportional peak model must run over the OUTPUT tensor, not + the input. solve_2d_tile is reused with input_h/input_w set to the OUTPUT + extents and zero halo, so its int(peak * (th + 0) * (tw + 0) / + (out_h * out_w)) equals the expand-aware tiled peak. + + Fails closed (a non-tileable TilePlan) when the output extent cannot be + determined, or when no output tile - not even a 1x1 core - fits the budget. + """ + out_h = _find_output_extent(ag, stage) + out_w = _find_output_extent_width(ag, stage) + if out_h <= 0 or out_w <= 0: + return TilePlan( + tileable=False, + warnings=[ + f"Stage {stage.stage_id}: cannot determine ConvTranspose " + f"output extent" + ], + ) + + shape = solve_2d_tile( + budget=budget, + peak=stage.peak_bytes, + input_h=out_h, + input_w=out_w, + halo_h=0, + halo_w=0, + ) + if shape is None: + return TilePlan( + tileable=False, + warnings=[ + f"Stage {stage.stage_id} minimum 2D ConvTranspose tile still " + f"exceeds budget ({budget:,} bytes)" + ], + ) + + th, tw = shape + tiled_peak = int(stage.peak_bytes * th * tw / (out_h * out_w)) + return TilePlan( + tileable=True, + axis=TILE_AXIS_HW, + tile_height=th, + tile_width=tw, + num_tiles=math.ceil(out_h / th) * math.ceil(out_w / tw), + halo=0, + receptive_field=1, + original_height=out_h, + tiled_peak_bytes=tiled_peak, + overhead_bytes=0, + warnings=[], + ) + + def partition_spatial(ag: AnalyzedGraph) -> AnalyzedGraph: """Analyze each stage and attach a TilePlan where needed. @@ -284,6 +379,16 @@ def partition_spatial(ag: AnalyzedGraph) -> AnalyzedGraph: continue # fits, no tiling needed stage_ops = [ag.ops[i] for i in stage.op_indices] + + # ConvTranspose stays UNTILEABLE in _OP_CATEGORY on purpose (so it is + # auto-excluded from chains, the 1D height solve, and receptive-field + # composition). Its 2D tiling is handled here by a dedicated isolated + # branch that grids the expanded OUTPUT extent. Every other stage falls + # through to the existing byte-identical path below. + if _stage_is_convtranspose_2d(stage_ops): + stage.tile_plan = _solve_convtranspose_2d(ag, stage, stage_ops, budget) + continue + tile_axis = _stage_tile_axis(ag, stage, stage_ops) # Check if all ops are tileable @@ -506,6 +611,42 @@ def _find_source_dim_extent( return 0 +def _find_output_extent(ag: AnalyzedGraph, stage: Stage) -> int: + """Find the H extent of the stage OUTPUT (source NCHW/NCL dim 2).""" + return _find_output_dim_extent(ag, stage, dim=2, ranks={3, 4}) + + +def _find_output_extent_width(ag: AnalyzedGraph, stage: Stage) -> int: + """Find the W extent of the stage OUTPUT (source NCHW dim 3). + + Only rank-4 tensors carry a width dimension, so this mirrors the rank + restriction of _find_input_extent_width. + """ + return _find_output_dim_extent(ag, stage, dim=3, ranks={4}) + + +def _find_output_dim_extent( + ag: AnalyzedGraph, stage: Stage, dim: int, ranks: set[int] +) -> int: + """Find a stage's OUTPUT-shape extent at ``dim`` among candidate tensors. + + Mirrors _find_source_dim_extent but reads the stage's output tensors + (falling back to the last op's outputs), so ConvTranspose tiling grids over + the expanded output extent rather than the pre-upsample input. + """ + candidates = stage.output_tensors.copy() + if not candidates: + last_op = ag.ops[stage.op_indices[-1]] + candidates = [n for n in last_op.outputs if n in ag.tensors] + + for name in candidates: + info = ag.tensors.get(name) + if info and len(info.shape) in ranks: + return int(info.shape[dim]) + + return 0 + + def _estimate_halo_bytes(ag: AnalyzedGraph, stage, halo: int, input_h: int) -> int: """Estimate bytes for one halo region of the stage's input tensor.""" candidates = stage.input_tensors.copy() diff --git a/tests/test_2d_tiling.py b/tests/test_2d_tiling.py index f9b8f3c..5347ce8 100644 --- a/tests/test_2d_tiling.py +++ b/tests/test_2d_tiling.py @@ -1,5 +1,7 @@ """Tests for 2D (height + width) receptive field computation and tile solving.""" +import math + from onnx import TensorProto from tigris import TILE_AXIS_HEIGHT_OR_LENGTH, TILE_AXIS_HW @@ -343,3 +345,113 @@ def test_conv_plus_unary_stage_still_goes_hw(): ag = partition_spatial(ag) stage_plan = plan_for_single_stage(ag) assert stage_plan.axis == TILE_AXIS_HW + + +# ConvTranspose 2D tiling over the OUTPUT extent. +# +# ConvTranspose stays UNTILEABLE in _OP_CATEGORY (so it is auto-excluded from +# chains, the 1D height solve, and receptive-field composition). Its 2D tiling +# is handled by a dedicated isolated branch in partition_spatial that grids the +# OUTPUT extent (the expanded, post-upsample shape) with zero halo. These tests +# exercise that branch end to end. + + +def build_convtranspose_graph( + in_hw: tuple[int, int], + stride: int, + kernel: int, + in_ch: int, + out_ch: int, +) -> AnalyzedGraph: + """Single-stage AnalyzedGraph for one ConvTranspose on an int8 activation. + + group == 1 and unit dilation (the runtime-supported subset). The output + tensor shape is the ONNX-inferred expanded extent for pads == 0: + out = (in - 1) * stride + kernel + + peak_bytes is the whole OUTPUT activation working set (channels included), + matching the c*h*w convention of build_high_res_conv_graph above, so a + single output pixel costs exactly out_ch bytes and the proportional + tiled-peak model tiles the expanded output area. + """ + in_h, in_w = in_hw + out_h = (in_h - 1) * stride + kernel + out_w = (in_w - 1) * stride + kernel + op = OpNode( + name="conv_transpose", + op_type="ConvTranspose", + inputs=["input"], + outputs=["output"], + attrs={ + "kernel_shape": [kernel, kernel], + "strides": [stride, stride], + "pads": [0, 0, 0, 0], + "dilations": [1, 1], + "group": 1, + }, + ) + stage = Stage( + stage_id=0, + op_indices=[0], + input_tensors=["input"], + output_tensors=["output"], + peak_bytes=out_ch * out_h * out_w, + ) + return AnalyzedGraph( + ops=[op], + stages=[stage], + tensors={ + "input": TensorInfo("input", (1, in_ch, in_h, in_w), TensorProto.INT8), + "output": TensorInfo("output", (1, out_ch, out_h, out_w), TensorProto.INT8), + }, + budget=MemoryBudget(fast=0), + ) + + +def _ct_stage(ag): + """The single ConvTranspose stage.""" + return next( + s + for s in ag.stages + if any(ag.ops[i].op_type == "ConvTranspose" for i in s.op_indices) + ) + + +def test_convtranspose_over_budget_tiles_2d(): + ag = build_convtranspose_graph( + in_hw=(32, 32), stride=2, kernel=2, in_ch=8, out_ch=8 + ) # out 64x64 + stage = _ct_stage(ag) + ag.budget = MemoryBudget(fast=stage.peak_bytes // 4) # multiple tiles needed + ag = partition_spatial(ag) + tp = _ct_stage(ag).tile_plan + assert tp.tileable and tp.axis == TILE_AXIS_HW + assert tp.original_height == 64 # OUTPUT extent, not the 32-row input + assert tp.halo == 0 and tp.receptive_field == 1 + tiles_h = math.ceil(tp.original_height / tp.tile_height) + tiles_w = tp.num_tiles // tiles_h + assert tiles_h > 1 and tiles_w > 1 + + +def test_convtranspose_infeasible_budget_fails_closed(): + ag = build_convtranspose_graph( + in_hw=(32, 32), stride=2, kernel=2, in_ch=8, out_ch=8 + ) + # 4 bytes is smaller than even a single output pixel's working set (out_ch + # == 8 int8 bytes), so no output tile fits: the solve must fail closed. + # (The brief's illustrative literal 256 assumes a many-channel stage; at + # out_ch == 8 the honest per-pixel threshold is 8 bytes, matching the + # brief's own "smaller than a 1x1 output tile working set" comment.) + ag.budget = MemoryBudget(fast=4) + ag = partition_spatial(ag) + assert not _ct_stage(ag).tile_plan.tileable + + +def test_convtranspose_under_budget_untiled(): + ag = build_convtranspose_graph( + in_hw=(32, 32), stride=2, kernel=2, in_ch=8, out_ch=8 + ) + stage = _ct_stage(ag) + ag.budget = MemoryBudget(fast=stage.peak_bytes * 2) # fits whole, no tiling + ag = partition_spatial(ag) + assert _ct_stage(ag).tile_plan is None From 185916acfcc0120ec5014e76cea090f3d5ef0dcc Mon Sep 17 00:00:00 2001 From: asteinh Date: Sun, 16 Aug 2026 10:27:22 +0200 Subject: [PATCH 2/4] fix: size ConvTranspose 2D tiles to the runtime working-set model --- src/tigris/analysis/partition_spatial.py | 149 ++++++++++++++++++++--- tests/test_2d_tiling.py | 60 +++++++++ 2 files changed, 189 insertions(+), 20 deletions(-) diff --git a/src/tigris/analysis/partition_spatial.py b/src/tigris/analysis/partition_spatial.py index 0e8eca7..1de8b4d 100644 --- a/src/tigris/analysis/partition_spatial.py +++ b/src/tigris/analysis/partition_spatial.py @@ -75,6 +75,15 @@ class TileCategory(Enum): }) _RANK3_AXIS1_OPS = _RANK3_AXIS1_UNARY_OPS | _BINARY_OPS | {"Conv1D"} +# Conservative per-tile allocation alignment for the backend-agnostic tiled +# working-set model. The runtime rounds every fast-arena tile allocation up to +# its target's TIGRIS_TENSOR_ALIGN (Xtensa 8, aarch64 16, x86_64 32, default 4); +# 32 is the maximum of that standard set, so aligning the compiler's estimate to +# it never under-counts against any of them (align_up is monotonic in the +# alignment). Under-counting would let the solver emit a tile the runtime's +# stage_2d_fast_bytes check rejects, which is exactly the bug this guards. +_CONSERVATIVE_TENSOR_ALIGN = 32 + def classify_op(op_type: str) -> TileCategory: """Classify an op type into a tile category. Unknown ops are UNTILEABLE.""" @@ -304,20 +313,47 @@ def _stage_is_convtranspose_2d(stage_ops: list[OpNode]) -> bool: return True +def _stage_rank4_input_infos(ag: AnalyzedGraph, stage: Stage) -> list: + """The stage's external activation inputs that are rank-4 tensors. + + Mirrors the runtime's stage_inputs (tigris_stage_inputs): only the declared + external activation inputs, never an op's weight/bias operands. No fallback + to op inputs, which would wrongly pull in the ConvTranspose weight tensor. + """ + infos = [] + for name in stage.input_tensors: + info = ag.tensors.get(name) + if info and len(info.shape) == 4: + infos.append(info) + return infos + + def _solve_convtranspose_2d( ag: AnalyzedGraph, stage: Stage, stage_ops: list[OpNode], budget: int ) -> TilePlan: """Emit a 2D (HW) tile plan for an over-budget ConvTranspose stage. - ConvTranspose expands its spatial extent (stride upsampling), so the tile - grid and the proportional peak model must run over the OUTPUT tensor, not - the input. solve_2d_tile is reused with input_h/input_w set to the OUTPUT - extents and zero halo, so its int(peak * (th + 0) * (tw + 0) / - (out_h * out_w)) equals the expand-aware tiled peak. - - Fails closed (a non-tileable TilePlan) when the output extent cannot be - determined, or when no output tile - not even a 1x1 core - fits the budget. + The tile grid is normalized over the expanded OUTPUT extent, but the + per-tile working set is sized the SAME way the runtime does in + stage_2d_fast_bytes (tigris_executor.c): a resident packed INPUT tile plus + every op's packed output tile, all live at once. This matters because a + ConvTranspose's input tile does NOT shrink by the output-area ratio - it + inverts to a fixed-halo rectangle + ``in_tile = (out_tile + eff_k + stride - 1)//stride + 2`` (clamped to the + full input), which a proportional peak model under-counts. Under-counting + made the runtime reject every emitted tile (ERR_TILE); this models the real + working set so every emitted tile fits. + + Fails closed (a non-tileable TilePlan) when the output/input extent cannot + be determined, or when no output tile - not even a 1x1 core - fits budget. """ + ct = next((op for op in stage_ops if op.op_type == "ConvTranspose"), None) + if ct is None: # guarded by _stage_is_convtranspose_2d; defensive + return TilePlan( + tileable=False, + warnings=[f"Stage {stage.stage_id}: no ConvTranspose op in stage"], + ) + out_h = _find_output_extent(ag, stage) out_w = _find_output_extent_width(ag, stage) if out_h <= 0 or out_w <= 0: @@ -329,15 +365,69 @@ def _solve_convtranspose_2d( ], ) - shape = solve_2d_tile( - budget=budget, - peak=stage.peak_bytes, - input_h=out_h, - input_w=out_w, - halo_h=0, - halo_w=0, - ) - if shape is None: + in_infos = _stage_rank4_input_infos(ag, stage) + if not in_infos: + return TilePlan( + tileable=False, + warnings=[ + f"Stage {stage.stage_id}: cannot determine ConvTranspose " + f"input extent" + ], + ) + full_in_h = int(in_infos[0].shape[2]) # NCHW: H at dim 2 + full_in_w = int(in_infos[0].shape[3]) # NCHW: W at dim 3 + + # group == 1 and unit dilation are enforced by _stage_is_convtranspose_2d; + # compute eff_k with dilation folded in anyway to match the runtime exactly. + eff_kh = _get_dilation_h(ct) * (_get_kernel_h(ct) - 1) + 1 + eff_kw = _get_dilation_w(ct) * (_get_kernel_w(ct) - 1) + 1 + stride_h = _get_stride_h(ct) + stride_w = _get_stride_w(ct) + if full_in_h <= 0 or full_in_w <= 0 or stride_h <= 0 or stride_w <= 0: + return TilePlan( + tileable=False, + warnings=[ + f"Stage {stage.stage_id}: malformed ConvTranspose geometry" + ], + ) + + align = max(ag.tensor_alignment, _CONSERVATIVE_TENSOR_ALIGN) + + def working_set(th: int, tw: int) -> int: + """Runtime stage_2d_fast_bytes for one (th, tw) output tile.""" + # ConvTranspose input tile: inverts to a smaller fixed-halo rectangle, + # clamped to the full input, exactly as the runtime computes it. + in_tile_h = min((th + eff_kh + stride_h - 1) // stride_h + 2, full_in_h) + in_tile_w = min((tw + eff_kw + stride_w - 1) // stride_w + 2, full_in_w) + total = 0 + for info in in_infos: + total += _align_up( + int(info.shape[0]) * in_tile_h * in_tile_w + * int(info.shape[1]) * info.elem_size, + align, + ) + # Walk the op sequence tracking the running tile extent: it starts at + # the input tile, the single spatial op resizes it to the output tile, + # pointwise ops preserve it. Matches the runtime's cur_h/cur_w walk. + cur_h, cur_w = in_tile_h, in_tile_w + for op in stage_ops: + is_spatial = op is ct + ah = th if is_spatial else cur_h + aw = tw if is_spatial else cur_w + for name in op.outputs: + info = ag.tensors.get(name) + if info and len(info.shape) == 4: + total += _align_up( + int(info.shape[0]) * ah * aw + * int(info.shape[1]) * info.elem_size, + align, + ) + if is_spatial: + cur_h, cur_w = th, tw + return total + + # Fail closed if even a 1x1 output tile overflows the budget. + if working_set(1, 1) > budget: return TilePlan( tileable=False, warnings=[ @@ -346,8 +436,27 @@ def _solve_convtranspose_2d( ], ) - th, tw = shape - tiled_peak = int(stage.peak_bytes * th * tw / (out_h * out_w)) + # Largest output tile whose runtime working set fits, maximizing tile area + # (fewest tiles). working_set is monotonic non-decreasing in both th and tw, + # so per th the largest feasible tw is a binary search, and once th at tw==1 + # overflows no larger th can fit at any width. + best_th, best_tw, best_area = 1, 1, 1 + for th in range(1, out_h + 1): + if working_set(th, 1) > budget: + break + lo, hi, tw_for_th = 1, out_w, 1 + while lo <= hi: + mid = (lo + hi) // 2 + if working_set(th, mid) <= budget: + tw_for_th = mid + lo = mid + 1 + else: + hi = mid - 1 + area = th * tw_for_th + if area > best_area: + best_area, best_th, best_tw = area, th, tw_for_th + + th, tw = best_th, best_tw return TilePlan( tileable=True, axis=TILE_AXIS_HW, @@ -357,7 +466,7 @@ def _solve_convtranspose_2d( halo=0, receptive_field=1, original_height=out_h, - tiled_peak_bytes=tiled_peak, + tiled_peak_bytes=working_set(th, tw), overhead_bytes=0, warnings=[], ) diff --git a/tests/test_2d_tiling.py b/tests/test_2d_tiling.py index 5347ce8..972147a 100644 --- a/tests/test_2d_tiling.py +++ b/tests/test_2d_tiling.py @@ -455,3 +455,63 @@ def test_convtranspose_under_budget_untiled(): ag.budget = MemoryBudget(fast=stage.peak_bytes * 2) # fits whole, no tiling ag = partition_spatial(ag) assert _ct_stage(ag).tile_plan is None + + +def _runtime_ct_working_set( + th, tw, *, in_ch, out_ch, full_in_h, full_in_w, + eff_kh, eff_kw, stride_h, stride_w, in_elem, out_elem, align=32, +): + """Independent reimplementation of the runtime's stage_2d_fast_bytes + (tigris-runtime/src/tigris_executor.c) for a single-input, single-output + ConvTranspose: one resident input tile plus the output tile, each aligned. + + Kept deliberately separate from the compiler's own model so the test is a + real property check, not a tautology: a proportional peak model (the BUG B + cost model) emits a 32x32 tile here whose working set is 11104 bytes and + exceeds the 8192-byte budget, which this assertion catches. + """ + def align_up(n): + return (n + align - 1) & ~(align - 1) + + in_tile_h = min((th + eff_kh + stride_h - 1) // stride_h + 2, full_in_h) + in_tile_w = min((tw + eff_kw + stride_w - 1) // stride_w + 2, full_in_w) + total = align_up(1 * in_tile_h * in_tile_w * in_ch * in_elem) + total += align_up(1 * th * tw * out_ch * out_elem) + return total + + +def test_convtranspose_emitted_tile_fits_runtime_working_set(): + # Pins the BUG B fix: the tile the solver emits for the over-budget case + # must fit the runtime's real working-set model, not just a proportional + # activation-area estimate. Recompute the runtime formula independently and + # assert working_set <= budget (the invariant the proportional model broke). + ag = build_convtranspose_graph( + in_hw=(32, 32), stride=2, kernel=2, in_ch=8, out_ch=8 + ) + stage = _ct_stage(ag) + budget = stage.peak_bytes // 4 # 8192 + ag.budget = MemoryBudget(fast=budget) + ag = partition_spatial(ag) + tp = _ct_stage(ag).tile_plan + assert tp.tileable and tp.axis == TILE_AXIS_HW + + ws = _runtime_ct_working_set( + tp.tile_height, tp.tile_width, + in_ch=8, out_ch=8, full_in_h=32, full_in_w=32, + eff_kh=2, eff_kw=2, stride_h=2, stride_w=2, + in_elem=1, out_elem=1, + ) + assert ws <= budget, ( + f"emitted tile {tp.tile_height}x{tp.tile_width} needs {ws} bytes " + f"> budget {budget}" + ) + # The solver also reports that working set as the tiled peak. + assert tp.tiled_peak_bytes == ws + + # Sanity: the old proportional model would have emitted a 32x32 tile whose + # working set overflows the budget, proving this is a non-trivial check. + overflow = _runtime_ct_working_set( + 32, 32, in_ch=8, out_ch=8, full_in_h=32, full_in_w=32, + eff_kh=2, eff_kw=2, stride_h=2, stride_w=2, in_elem=1, out_elem=1, + ) + assert overflow > budget From 13ac5ff26890f292ab527830568ea41c062c31bf Mon Sep 17 00:00:00 2001 From: asteinh Date: Sun, 16 Aug 2026 10:30:13 +0200 Subject: [PATCH 3/4] test: cross-repo gate for 2D-tiled ConvTranspose bit-exact vs ORT --- scripts/crossrepo_contract.py | 231 ++++++++++++++++++++++++++++++++++ 1 file changed, 231 insertions(+) diff --git a/scripts/crossrepo_contract.py b/scripts/crossrepo_contract.py index 0ec0bda..0ce4909 100644 --- a/scripts/crossrepo_contract.py +++ b/scripts/crossrepo_contract.py @@ -1845,6 +1845,234 @@ def _2d_tiled_conv_sigmoid_case() -> ContractCase: ) +def _build_convtranspose_2d( + c_in: int, c_out: int, h_in: int, w_in: int, seed: int +) -> tuple[onnx.ModelProto, dict[str, Array]]: + """Build a stride-2 kernel-2 pad-0 float ConvTranspose upsampler. + + Mirrors _convtranspose_case's node and weight construction (ONNX weight + layout [C_in, C_out, kH, kW], transposed to OHWI by the compiler) but + parameterizes the geometry so a caller can drive the stage over a tight + budget. Output height and width follow stride * (in - 1) + kernel = 2 * in, + so the tensor doubles on each spatial axis; the compiler grids the 2D tile + over that expanded OUTPUT extent, not the pre-upsample input. + """ + out_h, out_w = 2 * h_in, 2 * w_in + model_input = helper.make_tensor_value_info( + "input", TensorProto.FLOAT, [1, c_in, h_in, w_in] + ) + model_output = helper.make_tensor_value_info( + "output", TensorProto.FLOAT, [1, c_out, out_h, out_w] + ) + rng = np.random.default_rng(seed) + weights = numpy_helper.from_array( + rng.normal(0.0, 0.05, size=(c_in, c_out, 2, 2)).astype(np.float32), + "weights", + ) + bias = numpy_helper.from_array( + rng.normal(0.0, 0.05, size=(c_out,)).astype(np.float32), "bias" + ) + model = _model( + "convtranspose2d", + [ + helper.make_node( + "ConvTranspose", + ["input", "weights", "bias"], + ["output"], + kernel_shape=[2, 2], + strides=[2, 2], + pads=[0, 0, 0, 0], + group=1, + ) + ], + [model_input], + [model_output], + [weights, bias], + ) + inputs = { + "input": rng.uniform(-1.0, 1.0, size=(1, c_in, h_in, w_in)).astype( + np.float32 + ) + } + return model, inputs + + +def _convtranspose_2d_tiled_case() -> ContractCase: + """A stride-2 ConvTranspose whose expanded output overflows an 8K budget, + forcing a 2D (HW) tile with full (evenly divided) core tiles. + + Input NCHW [1, 24, 16, 16] upsamples to output [1, 24, 32, 32]. The stage's + ~120 KB activation peak far exceeds the 8K fast budget, and ConvTranspose is + kept untileable on the 1D height and chain paths, so it routes only through + the compiler's dedicated output-extent 2D solve. That solve grids the 32x32 + output into a 4x4 grid of 8x8 tiles; 32 is a multiple of 8, so every tile is + a full core tile. An emitted axis == TILE_AXIS_HW plan is itself proof the + isolated ConvTranspose 2D branch fired, and the runtime must reproduce ORT's + float upsample bit-exact across all sixteen tiles. + """ + model, inputs = _build_convtranspose_2d( + c_in=24, c_out=24, h_in=16, w_in=16, seed=19 + ) + return ContractCase( + "float_convtranspose_2d", + model, + model, + inputs, + expected_operators=("ConvTranspose",), + mem_budget="8K", + expect_tiled=True, + expect_2d=True, + ) + + +def _qdq_convtranspose_2d_tiled_case() -> ContractCase: + """The int8 sibling of _convtranspose_2d_tiled_case, built with the same QDQ + pattern as _qdq_2d_tiled_conv_case but wrapping a stride-2 ConvTranspose. + + Input NCHW [1, 48, 16, 16] upsamples to [1, 48, 32, 32]. int8 activations + are a quarter the per-pixel footprint of the float case, so twice the + channels at a quarter the budget (4K) reproduce the same 4x4 grid of full + 8x8 output tiles. The runtime executes the s8 reference ConvTranspose kernel + under the 2D tile context and must match ORT's int8 QDQ reference to one LSB. + """ + c_in = c_out = 48 + h_in = w_in = 16 + out_h, out_w = 2 * h_in, 2 * w_in + model_input = helper.make_tensor_value_info( + "input", TensorProto.FLOAT, [1, c_in, h_in, w_in] + ) + model_output = helper.make_tensor_value_info( + "output", TensorProto.FLOAT, [1, c_out, out_h, out_w] + ) + int8_output = helper.make_tensor_value_info( + "output_q", TensorProto.INT8, [1, c_out, out_h, out_w] + ) + rng = np.random.default_rng(7) + input_scale = numpy_helper.from_array( + np.array([0.02], dtype=np.float32), "input_scale" + ) + input_zero_point = numpy_helper.from_array( + np.array([0], dtype=np.int8), "input_zero_point" + ) + output_scale = numpy_helper.from_array( + np.array([0.05], dtype=np.float32), "output_scale" + ) + output_zero_point = numpy_helper.from_array( + np.array([0], dtype=np.int8), "output_zero_point" + ) + weight = numpy_helper.from_array( + rng.normal(0.0, 0.05, size=(c_in, c_out, 2, 2)).astype(np.float32), + "weight", + ) + weight_scale = numpy_helper.from_array( + np.array([0.01], dtype=np.float32), "weight_scale" + ) + weight_zero_point = numpy_helper.from_array( + np.array([0], dtype=np.int8), "weight_zero_point" + ) + initializers = [ + input_scale, + input_zero_point, + output_scale, + output_zero_point, + weight, + weight_scale, + weight_zero_point, + ] + nodes = [ + helper.make_node( + "QuantizeLinear", + ["input", "input_scale", "input_zero_point"], + ["input_q"], + ), + helper.make_node( + "DequantizeLinear", + ["input_q", "input_scale", "input_zero_point"], + ["input_dq"], + ), + helper.make_node( + "QuantizeLinear", + ["weight", "weight_scale", "weight_zero_point"], + ["weight_q"], + ), + helper.make_node( + "DequantizeLinear", + ["weight_q", "weight_scale", "weight_zero_point"], + ["weight_dq"], + ), + helper.make_node( + "ConvTranspose", + ["input_dq", "weight_dq"], + ["raw"], + kernel_shape=[2, 2], + strides=[2, 2], + pads=[0, 0, 0, 0], + group=1, + ), + helper.make_node( + "QuantizeLinear", + ["raw", "output_scale", "output_zero_point"], + ["output_q"], + ), + helper.make_node( + "DequantizeLinear", + ["output_q", "output_scale", "output_zero_point"], + ["output"], + ), + ] + compile_model = _model( + "qdq_convtranspose_2d", + nodes, + [model_input], + [model_output], + initializers, + ) + reference_model = copy.deepcopy(compile_model) + del reference_model.graph.output[:] + reference_model.graph.output.extend([int8_output]) + onnx.checker.check_model(reference_model) + input_data = rng.uniform(-1.0, 1.0, size=(1, c_in, h_in, w_in)).astype( + np.float32 + ) + return ContractCase( + "int8_convtranspose_2d", + compile_model, + reference_model, + {"input": input_data}, + ("ConvTranspose",), + mem_budget="4K", + expect_tiled=True, + expect_2d=True, + ) + + +def _convtranspose_2d_partial_edge_case() -> ContractCase: + """A stride-2 ConvTranspose whose 2D tile does NOT evenly divide the output, + exercising the partial edge and corner tiles. + + Input NCHW [1, 32, 15, 15] upsamples to output [1, 24, 30, 30]. At a 24K + budget the ConvTranspose solve grids the 30x30 output into a 3x3 tile grid + with a non-square 13x14 core: 30 is a multiple of neither 13 nor 14, so the + last tile row is 4 rows high, the last tile column is 2 columns wide, and the + bottom-right corner tile is 4x2. The runtime must place every partial edge + and corner tile at the correct output offset and still match ORT bit-exact, + which is the geometry (inverted rect plus effective pads) that Task 3 added. + """ + model, inputs = _build_convtranspose_2d( + c_in=32, c_out=24, h_in=15, w_in=15, seed=23 + ) + return ContractCase( + "float_convtranspose_2d_partial", + model, + model, + inputs, + expected_operators=("ConvTranspose",), + mem_budget="24K", + expect_tiled=True, + expect_2d=True, + ) + + def _to_runtime_layout(value: Array) -> Array: if value.ndim == 4: return np.ascontiguousarray(value.transpose(0, 2, 3, 1)) @@ -2490,6 +2718,9 @@ def _run_gate(runtime: Path, work_dir: Path) -> None: _2d_tiled_conv_case(), _qdq_2d_tiled_conv_case(), _2d_tiled_conv_sigmoid_case(), + _convtranspose_2d_tiled_case(), + _qdq_convtranspose_2d_tiled_case(), + _convtranspose_2d_partial_edge_case(), ] covered_operators = { operator for case in cases for operator in case.expected_operators From 328b0efbb37d6b7fe21441b909c9398831eab32a Mon Sep 17 00:00:00 2001 From: asteinh Date: Sun, 16 Aug 2026 10:40:10 +0200 Subject: [PATCH 4/4] docs: correct 2D ConvTranspose gate-case docstrings to actual geometry --- scripts/crossrepo_contract.py | 46 ++++++++++++++++++++--------------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/scripts/crossrepo_contract.py b/scripts/crossrepo_contract.py index 0ce4909..0576f04 100644 --- a/scripts/crossrepo_contract.py +++ b/scripts/crossrepo_contract.py @@ -1899,16 +1899,18 @@ def _build_convtranspose_2d( def _convtranspose_2d_tiled_case() -> ContractCase: """A stride-2 ConvTranspose whose expanded output overflows an 8K budget, - forcing a 2D (HW) tile with full (evenly divided) core tiles. + forcing a 2D (HW) tile. Input NCHW [1, 24, 16, 16] upsamples to output [1, 24, 32, 32]. The stage's - ~120 KB activation peak far exceeds the 8K fast budget, and ConvTranspose is - kept untileable on the 1D height and chain paths, so it routes only through - the compiler's dedicated output-extent 2D solve. That solve grids the 32x32 - output into a 4x4 grid of 8x8 tiles; 32 is a multiple of 8, so every tile is - a full core tile. An emitted axis == TILE_AXIS_HW plan is itself proof the - isolated ConvTranspose 2D branch fired, and the runtime must reproduce ORT's - float upsample bit-exact across all sixteen tiles. + ~120 KB activation peak far exceeds the 8K fast budget - a single output row + does not fit on its own - and ConvTranspose is kept untileable on the 1D + height and chain paths, so it routes only through the compiler's dedicated + output-extent 2D solve, which splits both the height and width of the 32x32 + output. The solved core tile does not evenly divide the output, so the last + tile row, the last tile column, and the bottom-right corner tile are all + partial. An emitted axis == TILE_AXIS_HW plan is itself proof the isolated + ConvTranspose 2D branch fired, and the runtime must reproduce ORT's float + upsample bit-exact across every tile. """ model, inputs = _build_convtranspose_2d( c_in=24, c_out=24, h_in=16, w_in=16, seed=19 @@ -1930,10 +1932,13 @@ def _qdq_convtranspose_2d_tiled_case() -> ContractCase: pattern as _qdq_2d_tiled_conv_case but wrapping a stride-2 ConvTranspose. Input NCHW [1, 48, 16, 16] upsamples to [1, 48, 32, 32]. int8 activations - are a quarter the per-pixel footprint of the float case, so twice the - channels at a quarter the budget (4K) reproduce the same 4x4 grid of full - 8x8 output tiles. The runtime executes the s8 reference ConvTranspose kernel - under the 2D tile context and must match ORT's int8 QDQ reference to one LSB. + are half the per-pixel footprint of the float case (48 int8 channels vs 24 + float32), and the 4K budget is half the float case's 8K, so the compiler + splits both the height and width of the 32x32 output into a 2D tile grid. + The solved core tile does not evenly divide the output, so the last row, + column, and corner tiles are partial. The runtime executes the s8 reference + ConvTranspose kernel under the 2D tile context and must match ORT's int8 QDQ + reference to one LSB. """ c_in = c_out = 48 h_in = w_in = 16 @@ -2047,16 +2052,17 @@ def _qdq_convtranspose_2d_tiled_case() -> ContractCase: def _convtranspose_2d_partial_edge_case() -> ContractCase: - """A stride-2 ConvTranspose whose 2D tile does NOT evenly divide the output, - exercising the partial edge and corner tiles. + """A stride-2 ConvTranspose with a non-square input, whose 2D tile does NOT + evenly divide the output, exercising the partial edge and corner tiles. Input NCHW [1, 32, 15, 15] upsamples to output [1, 24, 30, 30]. At a 24K - budget the ConvTranspose solve grids the 30x30 output into a 3x3 tile grid - with a non-square 13x14 core: 30 is a multiple of neither 13 nor 14, so the - last tile row is 4 rows high, the last tile column is 2 columns wide, and the - bottom-right corner tile is 4x2. The runtime must place every partial edge - and corner tile at the correct output offset and still match ORT bit-exact, - which is the geometry (inverted rect plus effective pads) that Task 3 added. + budget the ConvTranspose solve splits both the height and width of the 30x30 + output into a non-square core tile that divides neither axis evenly, so the + last tile row, the last tile column, and the bottom-right corner tile are all + partial (and generally differently sized). The runtime must place every + partial edge and corner tile at the correct output offset and still match ORT + bit-exact, which is the geometry (inverted rect plus effective pads) that + Task 3 added. """ model, inputs = _build_convtranspose_2d( c_in=32, c_out=24, h_in=15, w_in=15, seed=23