"""Rewrites a constant-folded BiRefNet graph so every WebGPU shader fits in 8 storage buffers and nothing falls back to the CPU.""" import math import sys from collections import Counter, defaultdict import numpy as np import onnx from onnx import TensorProto, helper, numpy_helper, shape_inference FAN = 6 source, target = sys.argv[1], sys.argv[2] model = shape_inference.infer_shapes(onnx.load(source)) graph = model.graph shapes = {} for value in list(graph.value_info) + list(graph.input) + list(graph.output): t = value.type.tensor_type shapes[value.name] = ([d.dim_value for d in t.shape.dim], t.elem_type) inits = {i.name: i for i in graph.initializer} for name, init in inits.items(): shapes[name] = (list(init.dims), init.data_type) counter = Counter() def fresh(base): counter[base] += 1 return f"wonfy/{base}/{counter[base]}" new_inits = [] def const(base, array): name = fresh(base) new_inits.append(numpy_helper.from_array(np.asarray(array), name)) return name def attr(node, name, default=None): for a in node.attribute: if a.name == name: return helper.get_attribute_value(a) return default consumers = defaultdict(list) for n in graph.node: for x in n.input: consumers[x].append(n) producers = {out: n for n in graph.node for out in n.output} # The int64 index chain of each deformable-conv sample, done in float instead (the values are whole numbers). cast_nodes = [n for n in graph.node if n.op_type == "Cast" and attr(n, "to") == TensorProto.INT64] region = set() frontier = [n.output[0] for n in cast_nodes] while frontier: name = frontier.pop() if name in region: continue region.add(name) for c in consumers[name]: if c.op_type in ("Slice", "Add", "Clip", "Reshape"): frontier.append(c.output[0]) elif c.op_type == "Concat": region.add(c.output[0]) elif c.op_type != "GatherND": raise SystemExit(f"unexpected consumer {c.op_type} of int64 tensor {name}") cast_source = {n.output[0]: n.input[0] for n in cast_nodes} drop = set(id(n) for n in cast_nodes) renames = {} for n in graph.node: if n.op_type in ("Add", "Clip") and n.output[0] in region: for i, x in enumerate(n.input): if x in inits and inits[x].data_type == TensorProto.INT64: n.input[i] = const("float_const", numpy_helper.to_array(inits[x]).astype(np.float32)) for n in graph.node: for i, x in enumerate(n.input): if x in cast_source: n.input[i] = cast_source[x] out_nodes = [] stats = Counter() def emit(op, inputs, outputs, **attrs): out_nodes.append(helper.make_node(op, inputs, outputs, name=fresh(op.lower()), **attrs)) def concat_tree(inputs, output, axis): while len(inputs) > FAN: grouped = [] for start in range(0, len(inputs), FAN): group = inputs[start:start + FAN] if len(group) == 1: grouped.append(group[0]) continue name = fresh("concat") emit("Concat", group, [name], axis=axis) grouped.append(name) inputs = grouped emit("Concat", inputs, [output], axis=axis) def split_tree(source_name, sizes, outputs, axis): if len(outputs) <= FAN: emit("Split", [source_name, const("split_sizes", np.array(sizes, dtype=np.int64))], outputs, axis=axis) return size = math.ceil(len(outputs) / FAN) parts, part_sizes = [], [] for start in range(0, len(outputs), size): group_outputs, group_sizes = outputs[start:start + size], sizes[start:start + size] name = group_outputs[0] if len(group_outputs) == 1 else fresh("split") parts.append((name, group_sizes, group_outputs)) part_sizes.append(sum(group_sizes)) split_tree(source_name, part_sizes, [p[0] for p in parts], axis) for name, group_sizes, group_outputs in parts: if len(group_outputs) > 1: split_tree(name, group_sizes, group_outputs, axis) concat_inputs = {n.output[0]: list(n.input) for n in graph.node if n.op_type == "Concat" and n.output[0] in region} for n in graph.node: if id(n) in drop: continue if n.op_type == "Concat" and n.output[0] in region: stats["index concat removed"] += 1 continue if n.op_type == "Concat" and len(n.input) > FAN: concat_tree(list(n.input), n.output[0], attr(n, "axis")) stats["concat"] += 1 continue if n.op_type == "Split" and len(n.output) > FAN: sizes = numpy_helper.to_array(inits[n.input[1]]).tolist() axis = attr(n, "axis", 0) split_tree(n.input[0], sizes, list(n.output), axis) stats["split"] += 1 continue if n.op_type == "Sum": total = n.input[0] for i, x in enumerate(n.input[1:], start=1): name = n.output[0] if i == len(n.input) - 1 else fresh("sum") emit("Add", [total, x], [name]) total = name stats["sum"] += 1 continue if n.op_type == "GatherND": assert attr(n, "batch_dims") == 2, "only batch_dims 2 is handled" data, index = n.input data_shape = shapes[data][0] assert data_shape[:2] == [1, 1], data_shape _, _, height, width, channels = data_shape y, x = concat_inputs[index] count = shapes[n.output[0]][0][2] y32, x32, row, flat, flat1, table, picked = (fresh(k) for k in ("y32", "x32", "row", "flat", "flat1", "table", "picked")) emit("Cast", [y], [y32], to=TensorProto.INT32) emit("Cast", [x], [x32], to=TensorProto.INT32) emit("Mul", [y32, const("width", np.array(width, dtype=np.int32))], [row]) emit("Add", [row, x32], [flat]) emit("Reshape", [flat, const("shape", np.array([count], dtype=np.int64))], [flat1]) emit("Reshape", [data, const("shape", np.array([height * width, channels], dtype=np.int64))], [table]) emit("Gather", [table, flat1], [picked], axis=0) emit("Reshape", [picked, const("shape", np.array([1, 1, count, channels], dtype=np.int64))], [n.output[0]]) stats["gathernd"] += 1 continue out_nodes.append(n) del graph.node[:] graph.node.extend(out_nodes) graph.initializer.extend(new_inits) del graph.value_info[:] used = {x for n in graph.node for x in n.input} kept = [i for i in graph.initializer if i.name in used] del graph.initializer[:] graph.initializer.extend(kept) del model.opset_import[:] model.opset_import.extend([helper.make_opsetid("", 17)]) model = shape_inference.infer_shapes(model, strict_mode=True) onnx.checker.check_model(model) onnx.save(model, target) print("rewrote", dict(stats)) # Audit: buffers per node, and anything WebGPU would run on the CPU. graph = model.graph types = {v.name: v.type.tensor_type.elem_type for v in list(graph.value_info) + list(graph.output) + list(graph.input)} cpu_inputs = {("Reshape", 1), ("Split", 1), ("Slice", 1), ("Slice", 2), ("Slice", 3), ("Slice", 4), ("Clip", 1), ("Clip", 2), ("Pad", 1), ("Pad", 2), ("Resize", 1), ("Resize", 2), ("Resize", 3)} worst = Counter() for n in graph.node: buffers = sum(1 for i, x in enumerate(n.input) if x and (n.op_type, i) not in cpu_inputs) + len(n.output) worst[n.op_type] = max(worst[n.op_type], buffers) print("most buffers per op:", dict(sorted(worst.items(), key=lambda kv: -kv[1]))) int64 = Counter(n.op_type for n in graph.node if any(types.get(o) == TensorProto.INT64 for o in n.output)) print("ops still making int64:", dict(int64) or "none") print("ops:", dict(Counter(n.op_type for n in graph.node).most_common()))