diff --git a/backends/samsung/_passes/__init__.py b/backends/samsung/_passes/__init__.py index 46b9ca74502..9ab486c5662 100644 --- a/backends/samsung/_passes/__init__.py +++ b/backends/samsung/_passes/__init__.py @@ -13,6 +13,7 @@ from .decompose_einsum import DecomposeEinsum from .decompose_glu import DecomposeGlu from .decompose_linalg_vector_norm import DecomposeLinalgVectorNorm +from .decompose_remainder import DecomposeRemainder from .decompose_roll import DecomposeRoll from .fold_qdq import FoldQDQPass from .fuse_activation import FuseActivationPass @@ -31,6 +32,7 @@ "DecomposeEinsum", "DecomposeGlu", "DecomposeLinalgVectorNorm", + "DecomposeRemainder", "DecomposeRoll", "FoldQDQPass", "FuseActivationPass", diff --git a/backends/samsung/_passes/annotate_qparams.py b/backends/samsung/_passes/annotate_qparams.py index 001f2cbdf66..7565add7f3e 100644 --- a/backends/samsung/_passes/annotate_qparams.py +++ b/backends/samsung/_passes/annotate_qparams.py @@ -42,6 +42,8 @@ class AnnotateQparamsPass(ExportPass): exir_ops.edge.aten.cat.default, exir_ops.edge.aten.expand_copy.default, exir_ops.edge.aten.split_with_sizes_copy.default, + exir_ops.edge.aten.clone.default, + exir_ops.edge.aten.contiguous.default, } def __init__(self, edge_program: ExportedProgram): @@ -84,40 +86,154 @@ def _impl(node: Node, res_list: List[Node]): _impl(user, res_list) return res_list + def _walk_qdq_chain_to_terminals(self, cur: Node) -> List[Node]: + r"""Walk forward from a Q/DQ node `cur` through the Q-DQ chain, + returning every terminal node (last Q/DQ node before a non-Q/DQ + consumer). Handles fan-out: the SAME quantized tensor is commonly + dequantized into multiple branches (e.g. one Q feeding two DQs for + two independent consumers) -- each branch is walked and its own + terminal is collected, instead of the chain silently stopping at + `cur` when it has more than one Q/DQ child. + + Mirrors the DFS shape of `_get_last_dqs`, applied starting from a + single Q/DQ node rather than its non-Q/DQ source. + """ + next_nodes = [ + u + for u in cur.users + if u.target in QuantConstants.QUANT_OPS_KEY_MAP + or u.target in QuantConstants.DEQUANT_OPS_KEY_MAP + ] + terminals: List[Node] = [] + """ `cur` may feed a non-Q/DQ consumer *and* further Q/DQ branches. It + still terminates the chain for that direct consumer, so collect it + as well as walking the branches.""" + if not next_nodes or len(next_nodes) < len(cur.users): + terminals.append(cur) + for nxt in next_nodes: + terminals.extend(self._walk_qdq_chain_to_terminals(nxt)) + return terminals + + def _collect_dq_nodes(self, node: Node) -> List[Node]: + """For each user of `node`, resolve it to a propagate candidate: a + non-Q user is used directly, a Q user is walked through its Q-DQ + chain (including fan-out) to find every terminal node.""" + dq_nodes: List[Node] = [] + for user in node.users: + if user.target not in QuantConstants.QUANT_OPS_KEY_MAP: + # If user is a direct propagate node (not Q), collect it too + dq_nodes.append(user) + continue + # user is a Q node: walk through the Q-DQ chain (including any + # fan-out branches) to find every terminal node. + dq_nodes.extend(self._walk_qdq_chain_to_terminals(user)) + return dq_nodes + + def _is_propagatable(self, candidate: Node) -> bool: + """True if `candidate` is a SharedQuant propagate node that is safe + to annotate with quantize_attrs and recurse into: it must be a + propagate node, and if it has exactly one user, that user must not + be a Q/DQ (a Q/DQ boundary already carries its own quant params).""" + if candidate.target not in self.propagate_nodes: + return False + if len(candidate.users) == 1: + only_user = next(iter(candidate.users)) + if ( + only_user.target in QuantConstants.QUANT_OPS_KEY_MAP + or only_user.target in QuantConstants.DEQUANT_OPS_KEY_MAP + ): + return False + return True + + def _propagate_into_dequant_users(self, dq_node: Node, user_attrs) -> None: + """dq_node is a DQ: propagate to each of its users that is itself an + eligible (non-Q/DQ) propagate node.""" + for op_user in dq_node.users: + if ( + op_user.target in QuantConstants.QUANT_OPS_KEY_MAP + or op_user.target in QuantConstants.DEQUANT_OPS_KEY_MAP + ): + continue + if not self._is_propagatable(op_user): + continue + op_user.meta["quantize_attrs"] = user_attrs + self._propagate_quant_params(op_user) + def _propagate_quant_params(self, node: Node): assert ( quantize_attrs := node.meta.get("quantize_attrs") ), "Must be annotated node." requantize_map: Dict[Node, Node] = node.meta.get("requantize", {}) - while node.users: - if len(node.users) != 1: - break - user = list(node.users.keys())[0] - if ( - user.target not in QuantConstants.QUANT_OPS_KEY_MAP - and user.target not in QuantConstants.DEQUANT_OPS_KEY_MAP - ): - break - node = user - # Case1: ...-q-dq(cur)-propagate_node-node(not d-dq) - # Case2: propagate_node(propagateed)-propagate_node-node(not q-dq) - for idx, user in enumerate(node.users.keys()): + # Walk through Q-DQ chains, handling multiple Q-DQ branches. + # For node->Q->DQ->op1 and node->Q->DQ->op3, we collect all last DQ nodes. + dq_nodes = self._collect_dq_nodes(node) + # Case1: ...-q-dq(cur)-propagate_node-node(not q-dq) + # Case2: propagate_node(propagated)-propagate_node-node(not q-dq) + for idx, dq_node in enumerate(dq_nodes): # For the branch who need to be requantized, we propagate the requantize params user_attrs = requantize_map.get(idx, quantize_attrs) - if user.target not in self.propagate_nodes: + if dq_node.target in QuantConstants.DEQUANT_OPS_KEY_MAP: + self._propagate_into_dequant_users(dq_node, user_attrs) + elif self._is_propagatable(dq_node): + # dq_node is not a DQ but a propagate node directly connected to source + dq_node.meta["quantize_attrs"] = user_attrs + self._propagate_quant_params(dq_node) + + def _backward_propagate(self, node: Node): + """Walk backward from `node`, copying its quantize_attrs into unannotated + single-input upstream ops. + + Handles patterns like `DQ → SiLU → chunk → Q` where forward propagation + cannot populate SiLU's quantize_attrs because SiLU is not in + `propagate_nodes` (its input/output scales differ in general). But when + the downstream is a SharedQuant op (chunk/split/view/permute/...) whose + input scale must equal its output scale, the intermediate op's output + scale is fully determined by the downstream shared scale, so backward + propagation is safe. + + Stops at: + - already-annotated upstream (respect forward pass results) + - Q/DQ boundaries (scale is defined by the Q/DQ params themselves) + - non-call_function nodes (placeholders, get_attr, output) + - multi-input upstream ops (ambiguous which input to follow) + """ + quant_attrs = node.meta.get("quantize_attrs") + if not quant_attrs: + return + inputs = node.all_input_nodes + if len(inputs) != 1: + return + upstream = inputs[0] + if upstream.meta.get("quantize_attrs"): + return + if upstream.target in QuantConstants.QUANT_OPS_KEY_MAP: + return + if upstream.target in QuantConstants.DEQUANT_OPS_KEY_MAP: + return + if upstream.op != "call_function": + return + upstream.meta["quantize_attrs"] = quant_attrs + self._backward_propagate(upstream) + + def _propagate_quant_params_backward_all(self, graph_module: GraphModule): + """For every SharedQuant propagate node with annotated quantize_attrs, + walk backward and fill in unannotated single-input upstream ops. + + Multi-input propagate ops (cat/concat) are excluded because their + upstream is ambiguous — different input branches may legitimately have + different scales, and picking one to backward-propagate would corrupt + the others. + """ + single_input_shared = self.propagate_nodes - { + exir_ops.edge.aten.concat.default, + exir_ops.edge.aten.cat.default, + } + for node in graph_module.graph.nodes: + if node.target not in single_input_shared: continue - if len(user.users) == 1: - # Possibily no need for checking len(users)>1 - user_of_user = list(user.users)[0] - # node-q-dq-propagate-q-dq not need for propagatey - if ( - user_of_user.target in QuantConstants.QUANT_OPS_KEY_MAP - or user_of_user.target in QuantConstants.DEQUANT_OPS_KEY_MAP - ): - continue - # propagate quant for node-q-dq-propagate_node-node(not qdq) - user.meta["quantize_attrs"] = user_attrs - self._propagate_quant_params(user) + if not node.meta.get("quantize_attrs"): + continue + self._backward_propagate(node) def _annotate_requantize(self, node: Node): assert ( @@ -185,12 +301,13 @@ def _annotate(self, graph_module: GraphModule): ): # Currently, don't add quant info for d_qd node here. continue - elif source_node.target == operator.getitem: - source_node.meta["quantize_attrs"] = quant_attrs - source_node = source_node.args[0] - source_node.meta["quantize_attrs"] = quant_attrs - self._annotate_requantize(source_node) + if source_node.target == operator.getitem: + source_node.args[0].meta["quantize_attrs"] = quant_attrs + self._annotate_requantize(source_node.args[0]) + else: + self._annotate_requantize(source_node) + self._propagate_quant_params(source_node) def _annotate_in_quantize_attrs(self, graph_module: GraphModule): @@ -244,6 +361,7 @@ def _annotate_decomposed_mm(self, graph_module: GraphModule): def call(self, graph_module: GraphModule): self._annotate(graph_module) + self._propagate_quant_params_backward_all(graph_module) self._annotate_decomposed_mm(graph_module) self._annotate_in_quantize_attrs(graph_module) graph_module.recompile() diff --git a/backends/samsung/_passes/decompose_remainder.py b/backends/samsung/_passes/decompose_remainder.py new file mode 100644 index 00000000000..b8df77088e7 --- /dev/null +++ b/backends/samsung/_passes/decompose_remainder.py @@ -0,0 +1,126 @@ +# Copyright (c) Qualcomm Innovation Center, Inc. +# Copyright (c) Samsung Electronics Co. LTD +# All rights reserved +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +import torch +from executorch.exir.dialects._ops import ops as exir_ops +from executorch.exir.dialects.edge._ops import EdgeOpOverload +from executorch.exir.pass_base import ExportPass, PassResult +from torchao.quantization.pt2e.utils import get_new_attr_name_with_prefix + +from .utils import copy_meta, create_const_node + + +class DecomposeRemainder(ExportPass): + """ + Decompose remainder.Scalar and remainder.Tensor using the identity: + remainder(x, y) = x - floor(x / y) * y + """ + + def __init__(self): + super(DecomposeRemainder, self).__init__() + self.remainder_targets = { + torch.ops.aten.remainder.Scalar, + torch.ops.aten.remainder.Scalar_Tensor, + torch.ops.aten.remainder.Tensor, + exir_ops.edge.aten.remainder.Scalar, + exir_ops.edge.aten.remainder.Scalar_Tensor, + exir_ops.edge.aten.remainder.Tensor, + } + + def call(self, graph_module: torch.fx.GraphModule): + graph = graph_module.graph + # Cache scalar:node mappings to avoid duplicate buffer registrations if the same scalar divisor appears in multiple remainder ops + const_cache = {} + for node in list(graph.nodes): + if node.op == "call_function" and node.target in self.remainder_targets: + x_arg = node.args[0] + y_arg = node.args[1] + is_edge = isinstance(node.target, EdgeOpOverload) + meta = node.meta + + val = meta.get("val", None) + if val is None or val.dtype.is_floating_point: + continue + + floor_div_op = ( + exir_ops.edge.aten.floor_divide.default + if is_edge + else torch.ops.aten.floor_divide.default + ) + mul_op = ( + exir_ops.edge.aten.mul.Tensor + if is_edge + else torch.ops.aten.mul.Tensor + ) + sub_op = ( + exir_ops.edge.aten.sub.Tensor + if is_edge + else torch.ops.aten.sub.Tensor + ) + + is_x_scalar = not isinstance(x_arg, torch.fx.Node) + if is_x_scalar and is_edge: + if x_arg not in const_cache: + attr_name = get_new_attr_name_with_prefix("_remainder_const_")( + graph_module + ) + const_cache[x_arg] = create_const_node( + graph, graph_module, attr_name, x_arg, node + ) + x_node = const_cache[x_arg] + else: + x_node = x_arg + + is_y_scalar = not isinstance(y_arg, torch.fx.Node) + if is_y_scalar and is_edge: + if y_arg not in const_cache: + attr_name = get_new_attr_name_with_prefix("_remainder_const_")( + graph_module + ) + const_cache[y_arg] = create_const_node( + graph, graph_module, attr_name, y_arg, node + ) + y_node = const_cache[y_arg] + else: + y_node = y_arg + + with graph.inserting_before(node): + floor_div_node = graph.create_node( + "call_function", floor_div_op, (x_node, y_node) + ) + floor_div_node.meta = copy_meta(meta) + + mul_node = graph.create_node( + "call_function", mul_op, (floor_div_node, y_node) + ) + mul_node.meta = copy_meta(meta) + + sub_node = graph.create_node( + "call_function", sub_op, (x_node, mul_node) + ) + sub_node.meta = copy_meta(meta) + + # Cast back to the original integer dtype, which the + # division may have promoted away from. + to_copy_op = ( + exir_ops.edge.aten._to_copy.default + if is_edge + else torch.ops.aten._to_copy.default + ) + cast_node = graph.create_node( + "call_function", + to_copy_op, + (sub_node,), + {"dtype": val.dtype}, + ) + cast_node.meta = copy_meta(meta) + for user in node.users.copy(): + user.replace_input_with(node, cast_node) + + graph.eliminate_dead_code() + graph_module.recompile() + return PassResult(graph_module, True) diff --git a/backends/samsung/_passes/enn_pass_manager.py b/backends/samsung/_passes/enn_pass_manager.py index 9c143f716b8..874aa7115c9 100644 --- a/backends/samsung/_passes/enn_pass_manager.py +++ b/backends/samsung/_passes/enn_pass_manager.py @@ -13,6 +13,7 @@ DecomposeEinsum, DecomposeGlu, DecomposeLinalgVectorNorm, + DecomposeRemainder, DecomposeRoll, FoldQDQPass, FuseActivationPass, @@ -59,6 +60,7 @@ def transform_for_annotation_pass(self, graph_module: GraphModule): def transform_for_export_pass(self, exported_program: ExportedProgram): self.add_pass(ComputeConstAttrs()) self.add_pass(DecomposeRoll()) + self.add_pass(DecomposeRemainder()) self._transform(exported_program.graph_module) return exported_program diff --git a/backends/samsung/_passes/utils.py b/backends/samsung/_passes/utils.py index 3fc1affc6e5..ebdda3c793c 100644 --- a/backends/samsung/_passes/utils.py +++ b/backends/samsung/_passes/utils.py @@ -63,3 +63,34 @@ def default_output_process(node): decomposed_node, arg_transform=lambda x, remap=remap: remap[x], ) + + +def copy_meta(meta: Dict, callback=None): + copied = {} + for k, v in meta.items(): + copied[k] = v + if callback: + copied = callback(copied) + return copied + + +def create_const_node( + graph: torch.fx.Graph, + graph_module: torch.fx.GraphModule, + attr_name: str, + value, + source_node: torch.fx.Node, +) -> torch.fx.Node: + """ + Register a scalar constant as a named buffer on the graph module and return a get_attr node referencing it. + Used in edge dialect op decomposition passes where raw scalar arguments are not accepted by QNN op builders which need the inputs to be graph nodes. + """ + dtype = source_node.meta["val"].dtype + tensor = torch.tensor(value, dtype=dtype) + graph_module.register_buffer(attr_name, tensor) + + fake_mode = source_node.meta["val"].fake_mode + with graph.inserting_before(next(iter(graph.nodes))): + const_node = graph.get_attr(attr_name) + const_node.meta["val"] = fake_mode.from_tensor(tensor) + return const_node diff --git a/backends/samsung/builders/__init__.py b/backends/samsung/builders/__init__.py index 780ed787231..57ebc8f4401 100644 --- a/backends/samsung/builders/__init__.py +++ b/backends/samsung/builders/__init__.py @@ -7,6 +7,7 @@ from . import ( node_visitor, op_add, + op_amax, op_avg_pool2d, op_batch_norm, op_bmm, @@ -21,6 +22,8 @@ op_exp, op_expand_copy, op_flip, + op_floor_divide, + op_gather, op_gelu, op_getitem, op_group_norm, @@ -33,6 +36,7 @@ op_linear, op_log, op_log_softmax, + op_max_dim, op_max_pool2d, op_maximum, op_mean_dim, @@ -73,6 +77,7 @@ __all__ = [ "node_visitor", "op_add", + "op_amax", "op_avg_pool2d", "op_batch_norm", "op_bmm", @@ -87,6 +92,8 @@ "op_exp", "op_expand_copy", "op_flip", + "op_floor_divide", + "op_gather", "op_gelu", "op_getitem", "op_group_norm", @@ -99,6 +106,7 @@ "op_linear", "op_log_softmax", "op_log", + "op_max_dim", "op_max_pool2d", "op_maximum", "op_mean_dim", diff --git a/backends/samsung/builders/op_amax.py b/backends/samsung/builders/op_amax.py new file mode 100644 index 00000000000..b7dd455fc29 --- /dev/null +++ b/backends/samsung/builders/op_amax.py @@ -0,0 +1,49 @@ +# Copyright (c) 2026 Samsung Electronics Co. LTD +# All rights reserved +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +from typing import Dict + +import torch +from executorch.backends.samsung.builders.node_visitor import ( + NodeVisitor, + register_node_visitor, +) +from executorch.backends.samsung.serialization.enn_graph_schema import EnnGraph +from executorch.backends.transforms import get_shape + + +@register_node_visitor +class AMaxVisitor(NodeVisitor): + target = ["aten.amax.default"] + + def __init__(self, *args) -> None: + super().__init__(*args) + + def define_node( + self, + node: torch.fx.Node, + enn_graph: EnnGraph, + vals_to_ids: Dict[torch.Tensor, int], + ) -> bool: + input = node.args[0] + input_id = self.define_tensor(input, enn_graph, vals_to_ids) + + output_id = self.define_tensor(node, enn_graph, vals_to_ids) + + in_shape = get_shape(input) + dim_arg = node.args[1] if len(node.args) >= 2 else None + if dim_arg is None: + reduce_axes = list(range(len(in_shape))) + elif isinstance(dim_arg, int): + reduce_axes = [dim_arg % len(in_shape)] + else: + reduce_axes = [d % len(in_shape) for d in dim_arg] + keep_dim = node.args[2] if len(node.args) >= 3 else False + params = {"keep_dims": keep_dim, "axes": reduce_axes} + self._update_params_qdtype(node, params) + enn_graph.define_op(node.name, "ReduceMax", [input_id], [output_id], params) + + return True diff --git a/backends/samsung/builders/op_floor_divide.py b/backends/samsung/builders/op_floor_divide.py new file mode 100644 index 00000000000..846f2f1efd3 --- /dev/null +++ b/backends/samsung/builders/op_floor_divide.py @@ -0,0 +1,45 @@ +# Copyright (c) 2026 Samsung Electronics Co. LTD +# All rights reserved +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +from typing import Dict + +import torch +from executorch.backends.samsung.builders.node_visitor import ( + NodeVisitor, + register_node_visitor, +) +from executorch.backends.samsung.serialization.enn_graph_schema import EnnGraph + + +@register_node_visitor +class DivVisitor(NodeVisitor): + target = ["aten.floor_divide.default"] + + def __init__(self, *args) -> None: + super().__init__(*args) + + def define_node( + self, + node: torch.fx.Node, + enn_graph: EnnGraph, + vals_to_ids: Dict[torch.Tensor, int], + ) -> bool: + input1 = node.args[0] + input_id_1 = self.define_tensor(input1, enn_graph, vals_to_ids) + + input2 = node.args[1] + input_id_2 = self.define_tensor(input2, enn_graph, vals_to_ids) + + params = {} + self._update_params_qdtype(node, params) + # output + output_id = self.define_tensor(node, enn_graph, vals_to_ids) + + enn_graph.define_op( + node.name, "FloorDiv", [input_id_1, input_id_2], [output_id], params + ) + + return True diff --git a/backends/samsung/builders/op_gather.py b/backends/samsung/builders/op_gather.py new file mode 100644 index 00000000000..728f3a66080 --- /dev/null +++ b/backends/samsung/builders/op_gather.py @@ -0,0 +1,46 @@ +# Copyright (c) 2026 Samsung Electronics Co. LTD +# All rights reserved +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +from typing import cast, Dict + +import torch +from executorch.backends.samsung.builders.node_visitor import ( + NodeVisitor, + register_node_visitor, +) +from executorch.backends.samsung.serialization.enn_graph_schema import EnnGraph +from executorch.backends.transforms import get_shape + + +@register_node_visitor +class GatherVisitor(NodeVisitor): + target = "aten.gather.default" + + def define_node( + self, + node: torch.fx.Node, + enn_graph: EnnGraph, + vals_to_ids: Dict[torch.Tensor, int], + ) -> bool: + input = node.args[0] + input_id = self.define_tensor(input, enn_graph, vals_to_ids) + + in_shape = get_shape(input) + axis = cast(int, node.args[1]) % len(in_shape) + target_indices_node = node.args[2] + + indices_id = self.define_tensor(target_indices_node, enn_graph, vals_to_ids) + + output_id = self.define_tensor(node, enn_graph, vals_to_ids) + + params = {"axis": axis} + self._update_params_qdtype(node, params) + + enn_graph.define_op( + node.name, "GATHER", [input_id, indices_id], [output_id], params + ) + + return True diff --git a/backends/samsung/builders/op_index.py b/backends/samsung/builders/op_index.py index 0145616de56..f6e6050de66 100644 --- a/backends/samsung/builders/op_index.py +++ b/backends/samsung/builders/op_index.py @@ -27,17 +27,13 @@ def define_node( input = node.args[0] input_id = self.define_tensor(input, enn_graph, vals_to_ids) - axis = 0 - valid_indices_node_count = 0 - target_indices_node = None - for indices_node in node.args[1]: - if indices_node is not None: - target_indices_node = indices_node - valid_indices_node_count += 1 - if valid_indices_node_count > 1: - raise NotImplementedError("Not support multi indices node.") - if target_indices_node is None: - axis += 1 + indices = node.args[1] + indexed_axes = [axis for axis, index in enumerate(indices) if index is not None] + if len(indexed_axes) != 1: + return False + + axis = indexed_axes[0] + target_indices_node = indices[axis] indices_id = self.define_tensor(target_indices_node, enn_graph, vals_to_ids) diff --git a/backends/samsung/builders/op_max_dim.py b/backends/samsung/builders/op_max_dim.py new file mode 100644 index 00000000000..bbcbc4ff77d --- /dev/null +++ b/backends/samsung/builders/op_max_dim.py @@ -0,0 +1,56 @@ +# Copyright (c) 2026 Samsung Electronics Co. LTD +# All rights reserved +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +from typing import cast, Dict + +import torch +from executorch.backends.samsung.builders.node_visitor import ( + NodeVisitor, + register_node_visitor, +) +from executorch.backends.samsung.serialization.enn_graph_schema import EnnGraph +from executorch.backends.transforms import get_shape + + +@register_node_visitor +class MaxDimVisitor(NodeVisitor): + target = ["aten.max.dim"] + + def __init__(self, *args) -> None: + super().__init__(*args) + + def define_node( + self, + node: torch.fx.Node, + enn_graph: EnnGraph, + vals_to_ids: Dict[torch.Tensor, int], + ) -> bool: + input = node.args[0] + input_id = self.define_tensor(input, enn_graph, vals_to_ids) + + users = list(node.users.keys()) + if ( + len(users) != 1 + or users[0].op != "call_function" + or users[0].target.__name__ != "getitem" + or users[0].args[1] != 0 + ): + # ENN's ReduceMax has no indices output, so reject rather than + # silently drop max.dim's second output when it is consumed. + return False + + output_id = self.define_tensor(node, enn_graph, vals_to_ids, output_idx=0) + vals_to_ids[users[0]] = output_id + + in_shape = get_shape(input) + axis = cast(int, node.args[1]) % len(in_shape) + reduce_axes = [axis] + keep_dim = node.args[2] if len(node.args) >= 3 else False + params = {"keep_dims": keep_dim, "axes": reduce_axes} + self._update_params_qdtype(node, params) + enn_graph.define_op(node.name, "ReduceMax", [input_id], [output_id], params) + + return True diff --git a/backends/samsung/builders/op_split_with_sizes_copy.py b/backends/samsung/builders/op_split_with_sizes_copy.py index 48612ba9a6d..02f3cacbbcd 100644 --- a/backends/samsung/builders/op_split_with_sizes_copy.py +++ b/backends/samsung/builders/op_split_with_sizes_copy.py @@ -12,6 +12,7 @@ register_node_visitor, ) from executorch.backends.samsung.serialization.enn_graph_schema import EnnGraph +from executorch.backends.transforms import get_shape @register_node_visitor @@ -30,25 +31,48 @@ def define_node( # output all_output_tensors = [] + copied_indices = [] for output_idx in range(len(node.args[1])): - output_id = self.define_tensor( - node, - enn_graph, - vals_to_ids, - output_idx=output_idx, - ) - all_output_tensors.append(output_id) - - for user in node.users.keys(): - if user.target.__name__ == "getitem" and len(user.args) > 1: - vals_to_ids[user] = all_output_tensors[user.args[1]] + for user in node.users.keys(): + if user.target.__name__ == "getitem" and len(user.args) > 1: + copied_idx = user.args[1] + if copied_idx == output_idx: + copied_indices.append(copied_idx) + output_id = self.define_tensor(user, enn_graph, vals_to_ids) + all_output_tensors.append(output_id) + in_shape = get_shape(input) + points = node.args[1] axis = node.args[2] if len(node.args) > 2 else 0 + axis = axis % len(in_shape) - params = {} - params["axis"] = axis - params["point"] = node.args[1] + if len(all_output_tensors) < len(node.args[1]): + for idx, output_tensor_id in enumerate(all_output_tensors): + begin = [0] * len(in_shape) + end = in_shape + point_idx = copied_indices[idx] + begin[axis] = sum(points[:point_idx]) + end[axis] = begin[axis] + points[point_idx] + strides = [1] * len(in_shape) + params = { + "begin": begin, + "end": end, + "strides": strides, + "shrink_axis_mask": 0, + } + self._update_params_qdtype(node, params) + enn_graph.define_op( + node.name, "STRIDEDSLICE", [input_id], [output_tensor_id], params + ) + else: + params = { + "axis": axis, + "point": points, + } + self._update_params_qdtype(node, params) - enn_graph.define_op(node.name, "SPLIT", [input_id], all_output_tensors, params) + enn_graph.define_op( + node.name, "SPLIT", [input_id], all_output_tensors, params + ) return True diff --git a/backends/samsung/builders/op_topk.py b/backends/samsung/builders/op_topk.py index 6a4de9ccc91..921e176cb4e 100644 --- a/backends/samsung/builders/op_topk.py +++ b/backends/samsung/builders/op_topk.py @@ -4,7 +4,7 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. -from typing import cast, Dict +from typing import cast, Dict, List, Tuple import torch from executorch.backends.samsung.builders.node_visitor import ( @@ -19,58 +19,78 @@ class TopKVisitor(NodeVisitor): target = "aten.topk.default" - def define_node( - self, - node: torch.fx.Node, - enn_graph: EnnGraph, - vals_to_ids: Dict[torch.Tensor, int], - ) -> bool: - input = node.args[0] - input_id = self.define_tensor(input, enn_graph, vals_to_ids) + @staticmethod + def _resolve_dim(node: torch.fx.Node) -> int: + """Resolve and validate the `dim` argument. - k = cast(int, node.args[1]) - params = {"k_dims": k} - in_shape_len = len(get_shape(input)) + Defaults to the last dimension; normalises negatives; requires + the last dimension. + """ + in_shape_len = len(get_shape(node.args[0])) dim = cast(int, node.args[2]) if len(node.args) > 2 else in_shape_len - 1 if dim < 0: - dim = dim + in_shape_len + dim += in_shape_len if dim != in_shape_len - 1: raise AssertionError("Not supported dim not being last dimension!") + return dim - all_output_tensors = [] - users = list(node.users.keys()) - output_val_idx = 0 - output_val_id = self.define_tensor( - node, - enn_graph, - vals_to_ids, - output_idx=output_val_idx, - ) - if len(users) > 0 and users[0].target.__name__ == "getitem": - vals_to_ids[users[0]] = output_val_id - all_output_tensors.append(output_val_id) + @staticmethod + def _validate_flags(node: torch.fx.Node) -> None: + """Validate the optional ``largest`` and ``sorted`` arguments.""" + if len(node.args) > 3 and not cast(bool, node.args[3]): + raise AssertionError("Not supported largest = False.") + if len(node.args) > 4 and not cast(bool, node.args[4]): + raise AssertionError("Not supported sorted = False.") - output_indices_idx = 1 - output_indices_id = self.define_tensor( - node, - enn_graph, - vals_to_ids, - output_idx=output_indices_idx, - ) - if len(users) > 1 and users[1].target.__name__ == "getitem": - vals_to_ids[users[1]] = output_indices_id - all_output_tensors.append(output_indices_id) + def _process_getitem_users( + self, + node: torch.fx.Node, + enn_graph: EnnGraph, + vals_to_ids: Dict[torch.Tensor, int], + ) -> Tuple[str, List[int]]: + """Inspect getitem users of the TopK node to determine outputs. + + Returns ``(output_type, tensor_ids)`` where *output_type* is one + of ``"value"``, ``"index"``, or ``"both"``. + """ + output_ids: Dict[int, int] = {} + output_type = "both" + num_users = len(node.users) + + for user in node.users.keys(): + if user.target.__name__ != "getitem" or len(user.args) <= 1: + continue + idx = user.args[1] + if idx not in (0, 1): + continue + output_ids[idx] = self.define_tensor(user, enn_graph, vals_to_ids) + vals_to_ids[user] = output_ids[idx] + if num_users == 1: + output_type = "value" if idx == 0 else "index" - if len(node.args) > 3: - largest = cast(bool, node.args[3]) - if not largest: - raise AssertionError("Not supported largest = False.") + # Order by getitem index; node.users iteration order is unspecified. + return output_type, [output_ids[idx] for idx in sorted(output_ids)] - if len(node.args) > 4: - is_sorted = cast(bool, node.args[4]) - if not is_sorted: - raise AssertionError("Not supported sorted = False.") + def define_node( + self, + node: torch.fx.Node, + enn_graph: EnnGraph, + vals_to_ids: Dict[torch.Tensor, int], + ) -> bool: + input_id = self.define_tensor(node.args[0], enn_graph, vals_to_ids) + + dim = self._resolve_dim(node) + self._validate_flags(node) + output_type, all_output_tensors = self._process_getitem_users( + node, enn_graph, vals_to_ids + ) + params = { + "k_dims": cast(int, node.args[1]), + "output": output_type, + "axis": dim, + } + self._update_params_qdtype(node, params) enn_graph.define_op(node.name, "TopK", [input_id], all_output_tensors, params) return True diff --git a/backends/samsung/builders/op_upsample_bilinear2d.py b/backends/samsung/builders/op_upsample_bilinear2d.py index 7374e687101..3f26d5980e3 100644 --- a/backends/samsung/builders/op_upsample_bilinear2d.py +++ b/backends/samsung/builders/op_upsample_bilinear2d.py @@ -8,6 +8,7 @@ from typing import cast, Dict, List import torch + from executorch.backends.samsung.builders.node_visitor import ( NodeVisitor, register_node_visitor, diff --git a/backends/samsung/builders/op_upsample_nearest2d.py b/backends/samsung/builders/op_upsample_nearest2d.py index 6af5402d56c..28fa97b28d9 100644 --- a/backends/samsung/builders/op_upsample_nearest2d.py +++ b/backends/samsung/builders/op_upsample_nearest2d.py @@ -4,7 +4,6 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. -import logging from typing import cast, Dict, List import torch @@ -32,13 +31,10 @@ def define_node( input = node.args[0] input_id = self.define_tensor(input, enn_graph, vals_to_ids) in_shape = get_shape(input) - output_size = cast(List[int], node.args[1]) - if output_size is None: - logging.warning("output is None for this case.") - return False + out_shape = get_shape(node) scale_factor = [ - output_size[0] * 1.0 / in_shape[-2], - output_size[1] * 1.0 / in_shape[-1], + out_shape[-2] * 1.0 / in_shape[-2], + out_shape[-1] * 1.0 / in_shape[-1], ] if len(node.args) > 2 and node.args[2]: diff --git a/backends/samsung/partition/enn_partitioner.py b/backends/samsung/partition/enn_partitioner.py index a61187ae2ea..6541f03aad9 100644 --- a/backends/samsung/partition/enn_partitioner.py +++ b/backends/samsung/partition/enn_partitioner.py @@ -201,5 +201,6 @@ def ops_to_not_decompose( torch.ops.aten.hardsigmoid.default, torch.ops.aten.silu.default, torch.ops.aten.pad.default, + torch.ops.aten.floor_divide.default, ] return (ops_not_to_decompose, None) diff --git a/backends/samsung/quantizer/annotator.py b/backends/samsung/quantizer/annotator.py index b943653d4b3..5239555c685 100644 --- a/backends/samsung/quantizer/annotator.py +++ b/backends/samsung/quantizer/annotator.py @@ -223,12 +223,22 @@ def annotate_2in1out(node: Node, quant_config: QuantizationConfig) -> None: quant_config.output_activation if _is_float_tensor(node) else None ) + def _input_qspec_for(inp): + # If the input already carries a quantization_annotation (e.g. a shape-only + # op such as transpose/permute/flatten that used SharedQuantizationSpec on + # its output), share with it. Otherwise assign a fresh input_activation. + # Without this, a fresh observer here would break the shared-scale + # invariant across shape-only ops that feed matmul/bmm/div/sum. + if isinstance(inp, Node) and "quantization_annotation" in inp.meta: + return SharedQuantizationSpec(inp) + return input_act_qspec + input_qspec_map = {} if _is_float_tensor(input_act0): - input_qspec_map[input_act0] = input_act_qspec + input_qspec_map[input_act0] = _input_qspec_for(input_act0) if _is_float_tensor(input_act1): - input_qspec_map[input_act1] = input_act_qspec + input_qspec_map[input_act1] = _input_qspec_for(input_act1) node.meta["quantization_annotation"] = QuantizationAnnotation( input_qspec_map=input_qspec_map, @@ -478,6 +488,7 @@ def annotate_1in1out(node: Node, quant_config: QuantizationConfig) -> None: torch.ops.aten.chunk.default, torch.ops.aten.view_copy.default, torch.ops.aten.flip.default, + torch.ops.aten.contiguous.default, ] ) def annotate_1in1out_with_SharedQuant( diff --git a/backends/samsung/test/models/test_yolo26.py b/backends/samsung/test/models/test_yolo26.py new file mode 100644 index 00000000000..2502d4d1a29 --- /dev/null +++ b/backends/samsung/test/models/test_yolo26.py @@ -0,0 +1,27 @@ +# Copyright (c) Samsung Electronics Co. LTD +# All rights reserved +# +# Licensed under the BSD License (the "License"); you may not use this file +# except in compliance with the License. See the license file in the root +# directory of this source tree for more details. + +import unittest + +import torch +from executorch.backends.samsung.serialization.compile_options import ( + gen_samsung_backend_compile_spec, +) +from executorch.backends.samsung.test.tester import SamsungTester +from executorch.backends.samsung.test.utils.utils import TestConfig +from executorch.examples.models.yolo26 import YOLO26Model + + +class TestMilestoneYolo26(unittest.TestCase): + def test_yolo26_fp16(self): + torch.manual_seed(8) + model = YOLO26Model().get_eager_model() + example_input = YOLO26Model().get_example_inputs() + tester = SamsungTester( + model, example_input, [gen_samsung_backend_compile_spec(TestConfig.chipset)] + ) + (tester.export().to_edge_transform_and_lower().to_executorch()) diff --git a/backends/samsung/test/ops/test_add.py b/backends/samsung/test/ops/test_add.py index f0e51888ca5..f049df503cb 100644 --- a/backends/samsung/test/ops/test_add.py +++ b/backends/samsung/test/ops/test_add.py @@ -48,7 +48,7 @@ def _test(self, module: torch.nn.Module, inputs): .check_not(["executorch_exir_dialects_edge__ops_aten_add_Tensor"]) .check_count({"torch.ops.higher_order.executorch_call_delegate": 1}) .to_executorch() - .run_method_and_compare_outputs(inputs=inputs) + .run_method_and_compare_outputs(inputs=inputs, atol=0.003) ) def _test_a8w8(self, module: torch.nn.Module, inputs): diff --git a/backends/samsung/test/ops/test_topk.py b/backends/samsung/test/ops/test_topk.py index 4df439d1a2d..900bc4cdd89 100644 --- a/backends/samsung/test/ops/test_topk.py +++ b/backends/samsung/test/ops/test_topk.py @@ -40,13 +40,17 @@ def _test(self, module: torch.nn.Module, inputs): .check_not(["executorch_exir_dialects_edge__ops_aten_topk_default"]) .check_count({"torch.ops.higher_order.executorch_call_delegate": 1}) .to_executorch() - .run_method_and_compare_outputs() + .run_method_and_compare_outputs(inputs=inputs) ) def test_fp32_topk_dim3(self): - inputs = (torch.randn(1, 16, 8, 8),) + # Random permutation of 1..1024 gives well-separated values, + # so TopK k-boundary ordering is unambiguous. + x = torch.randperm(16 * 8 * 8, dtype=torch.float32).view(1, 16, 8, 8) + inputs = (x,) self._test(TopK(k=5, dim=3), inputs) def test_fp32_topk_dim_negative1(self): - inputs = (torch.randn(1, 16, 8, 8),) + x = torch.randperm(16 * 8 * 8, dtype=torch.float32).view(1, 16, 8, 8) + inputs = (x,) self._test(TopK(k=5, dim=-1), inputs) diff --git a/backends/samsung/utils/export_utils.py b/backends/samsung/utils/export_utils.py index 55dfcfd7d11..91c3c991342 100644 --- a/backends/samsung/utils/export_utils.py +++ b/backends/samsung/utils/export_utils.py @@ -9,11 +9,9 @@ import executorch.exir as exir import torch +from executorch.backends.samsung._passes.enn_pass_manager import EnnPassManager from executorch.backends.samsung.partition.enn_partitioner import EnnPartitioner from executorch.backends.samsung.quantizer.quantizer import EnnQuantizer, Precision -from executorch.backends.transforms.decompose_sdpa import ( - DecomposeScaledDotProductAttention, -) from executorch.exir import EdgeCompileConfig from executorch.exir.backend.backend_details import CompileSpec from executorch.exir.dialects._ops import ops as exir_ops @@ -59,7 +57,6 @@ def quantize_module( quantizer.setup_quant_params(precision, is_per_channel, is_qat) logging.info("Export nn module for quantization...") exported_module = torch.export.export(module, inputs).module() - DecomposeScaledDotProductAttention()(exported_module) logging.info("Quantizing the module...") annotated_module = prepare_pt2e(exported_module, quantizer) for data in calibration_dataset: @@ -77,6 +74,7 @@ def to_edge_transform_and_lower_to_enn( ) -> exir.ExecutorchProgramManager: assert compile_specs is not None, "For now, we must deliver complile specs" prog = torch.export.export(module, inputs) + EnnPassManager().transform_for_export_pass(prog) return to_edge_transform_and_lower( prog, partitioner={"forward": [EnnPartitioner(compile_specs)]}, diff --git a/examples/samsung/aot_compiler.py b/examples/samsung/aot_compiler.py index abc003a2bae..b780f9fc5ac 100644 --- a/examples/samsung/aot_compiler.py +++ b/examples/samsung/aot_compiler.py @@ -35,6 +35,7 @@ "dl3", "vit", "w2l", + "yolo26", ] diff --git a/examples/samsung/scripts/yolo26_validate.py b/examples/samsung/scripts/yolo26_validate.py new file mode 100644 index 00000000000..714e538995b --- /dev/null +++ b/examples/samsung/scripts/yolo26_validate.py @@ -0,0 +1,379 @@ +# Copyright (c) Intel Corporation +# Copyright (c) 2026 Samsung Electronics Co. LTD +# All rights reserved +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +# mypy: disable-error-code="import-untyped,import-not-found" + +""" +Samsung device test script for YOLO26 model. + +Combines model export and device-based validation in a single script call, +following the pattern of run_method_and_compare_outputs from +backends/test/harness/tester.py. + +The script: +1. Loads YOLO26 model and preprocesses inputs +2. Exports to .pte for Samsung ENN backend +3. Executes inference on device via RuntimeExecutor (ADB + enn_executor_runner) +4. Validates using the validate_yolo pipeline with device outputs + +Usage: + export EXYNOS_AI_LITECORE_ROOT=/path/to/litecore + export LD_LIBRARY_PATH=${EXYNOS_AI_LITECORE_ROOT}/lib/x86_64-linux + + # Export and validate on device: + python yolo26_validate.py -c E9965 -m yolo26s -d /path/to/images --validate coco128.yaml + + # Quantized model with device validation: + python yolo26_validate.py -c E9965 -m yolo26s -d /path/to/images -p A8W8 --validate coco128.yaml + + A list of available datasets and instructions on how to use a custom dataset can be found at: + https://docs.ultralytics.com/datasets/detect + Validation only supports the default --input_dims; please do not specify this parameter when using the + --validate flag. +""" + +import argparse +import glob +import os +from itertools import islice +from typing import Dict, List, Tuple + +import cv2 +import numpy as np +import torch + +from executorch.backends.samsung.quantizer import Precision +from executorch.backends.samsung.serialization.compile_options import ( + gen_samsung_backend_compile_spec, + PerformanceMode, +) +from executorch.backends.samsung.test.utils.runtime_executor import RuntimeExecutor +from executorch.backends.samsung.test.utils.utils import TestConfig +from executorch.backends.samsung.utils.export_utils import ( + quantize_module, + to_edge_transform_and_lower_to_enn, +) +from executorch.examples.samsung.utils import save_tensors +from executorch.exir import ExecutorchBackendConfig +from executorch.extension.export_util.utils import save_pte_program +from ultralytics import YOLO +from ultralytics.data.utils import check_det_dataset +from ultralytics.engine.validator import BaseValidator as Validator +from ultralytics.utils.torch_utils import unwrap_model + + +def get_calibration_data_from_folder( + dataset_path: str, + transform_fn, + subset_size: int, +) -> List[Tuple[torch.Tensor, ...]]: + """Load calibration images from a folder and preprocess them.""" + image_paths = sorted(glob.glob(os.path.join(dataset_path, "*"))) + if not image_paths: + raise RuntimeError(f"No images found in {dataset_path}") + + calibration_data = [] + for img_path in islice(image_paths, subset_size): + frame = cv2.imread(img_path) + if frame is None: + continue + input_tensor = transform_fn(frame) + calibration_data.append((input_tensor,)) + print(f"Loaded {len(calibration_data)} calibration images from {dataset_path}") + return calibration_data + + +def _prepare_validation( + model: YOLO, dataset_yaml_path: str +) -> Tuple[Validator, torch.utils.data.DataLoader]: + """Prepare the validation pipeline using the ultralytics validator. + + Mirrors the same logic from export_and_validate.py. + """ + custom = {"rect": False, "batch": 1} + args = { + **model.overrides, + **custom, + "mode": "val", + } + + validator = model._smart_load("validator")(args=args, _callbacks=model.callbacks) + stride = 32 + validator.stride = stride + validator.data = check_det_dataset(dataset_yaml_path) + validator.init_metrics(unwrap_model(model)) + validator.device = torch.device("cpu") + validator.end2end = False + + data_loader = validator.get_dataloader( + validator.data.get(validator.args.split), validator.args.batch + ) + return validator, data_loader + + +def validate_yolo_on_device( + model: YOLO, + exec_prog, + dataset_yaml_path: str, + pt_model: torch.nn.Module, + dump: bool = False, + artifact_dir: str = "./", +) -> Dict[str, float]: + """Run validation by executing each batch on device and validating with the ultralytics pipeline. + + Mirrors the run_method_and_compare_outputs pattern from the Tester base class: + 1. For each validation batch, preprocess the input + 2. Execute on device via RuntimeExecutor (pushes .pte + input, runs on device, pulls output) + 3. Optionally dump the preprocessed input, CPU reference output, and device output + each into a per-image directory named after the image file (e.g. 000000000009/) + 4. Postprocess device output using ultralytics validator + 5. Update validation metrics + + Args: + model: The YOLO model instance. + exec_prog: The ExecuTorch program manager containing the compiled model. + dataset_yaml_path: Path to the validation dataset YAML file. + pt_model: The PyTorch model for computing CPU reference outputs. + dump: Whether to dump per-batch preprocessed inputs and CPU reference outputs. + artifact_dir: Directory to save dumped tensors. + + Returns: + Dictionary of validation statistics computed over the dataset. + """ + validator, data_loader = _prepare_validation(model, dataset_yaml_path) + + print(f"Start device validation on {dataset_yaml_path} dataset ...") + + batch_idx = 0 + for batch in data_loader: + img_name = os.path.splitext(os.path.basename(batch["im_file"][0]))[0] + batch = validator.preprocess(batch) + input_tensor = batch["img"] + + # Execute on device via RuntimeExecutor + runtime = RuntimeExecutor(exec_prog, input_tensor) + device_output = runtime.run_on_device() + + # Optionally dump preprocessed input, CPU reference output, and device output + if dump: + batch_dir = os.path.join(artifact_dir, img_name) + os.makedirs(batch_dir, exist_ok=True) + save_tensors((input_tensor,), "input", batch_dir) + with torch.no_grad(): + ref_output = pt_model(input_tensor) + save_tensors(ref_output, "ref_output", batch_dir) + save_tensors(device_output, "device_output", batch_dir) + print( + f" Dumped input + ref_output + device_output for {img_name} to {batch_dir}" + ) + + # Postprocess device output and update validation metrics + device_output_list = ( + list(device_output) if isinstance(device_output, tuple) else [device_output] + ) + preds = validator.postprocess(device_output_list) + validator.update_metrics(preds, batch) + + batch_idx += 1 + if batch_idx % 10 == 0: + print(f" Processed {batch_idx} batches ...") + + stats = validator.get_stats() + return stats + + +def main(args): + # Load the YOLO model + print(f"Loading YOLO model: {args.model_name}") + model = YOLO(args.model_name) + + # Setup preprocessing with target input dimensions + input_h, input_w = args.input_dims + np_dummy = np.ones((input_h, input_w, 3), dtype=np.uint8) + model.predict(np_dummy, imgsz=(input_h, input_w), device="cpu", verbose=False) + + pt_model = model.model.to(torch.device("cpu")).eval() + float_pt_model = pt_model + + def transform_fn(frame): + """Preprocess a single frame using the YOLO predictor.""" + return model.predictor.preprocess([frame]) + + # Build example input for model export + example_input = transform_fn(np_dummy) + example_args = (example_input,) + + # Collect calibration data from image folder + calibration_number = args.calibration_number + if args.dataset: + calibration_data = get_calibration_data_from_folder( + args.dataset, transform_fn, calibration_number + ) + else: + # Use repeated dummy inputs for calibration + calibration_data = [example_args for _ in range(calibration_number)] + print( + f"No dataset provided, using {calibration_number} dummy inputs for calibration." + ) + + # Select test input (first calibration sample) + test_in = calibration_data[0] + print(f"Test input shape: {test_in[0].shape}") + + # Compile specs for Samsung ENN backend + compile_specs = [ + gen_samsung_backend_compile_spec(args.chipset, PerformanceMode.DEFAULT) + ] + + # Optionally quantize the model + if args.precision: + print(f"Quantizing model with precision: {args.precision}") + pt_model = quantize_module( + pt_model, + example_args, + calibration_data, + getattr(Precision, args.precision), + ) + print("Quantization finished.") + + # Lower to Samsung ENN backend and export .pte + print(f"Lowering model to ENN backend (chipset={args.chipset}) ...") + edge_prog = to_edge_transform_and_lower_to_enn( + pt_model, example_args, compile_specs=compile_specs + ) + + exec_prog = edge_prog.to_executorch( + config=ExecutorchBackendConfig(extract_delegate_segments=True) + ) + + # Save .pte file + os.makedirs(args.artifact, exist_ok=True) + pte_filename = f"yolo26_{'int8' if args.precision else 'fp32'}_{args.chipset}" + save_pte_program(exec_prog, pte_filename, args.artifact) + pte_path = os.path.join(args.artifact, f"{pte_filename}.pte") + print(f"Model saved to {pte_path}") + + # Optionally run full validation on device + if args.validate: + if args.input_dims != [640, 640]: + raise NotImplementedError( + f"Validation with the custom input shape {args.input_dims} is not implemented. " + "Please use the default --input_dims [640,640] for validation." + ) + + # Configure device connection + if args.host: + TestConfig.host_ip = args.host + if args.device: + TestConfig.device_id = args.device + TestConfig.chipset = args.chipset + + print("\nRunning full device validation ...") + print(f" Device: {args.device or 'auto-detect'}") + print(f" Host: {TestConfig.host_ip or 'localhost'}") + print(f" Chipset: {TestConfig.chipset}") + stats = validate_yolo_on_device( + model, + exec_prog, + args.validate, + float_pt_model, + args.dump, + args.artifact, + ) + print("Validation results:") + for stat, value in stats.items(): + print(f" {stat}: {value}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Test YOLO26 model on Samsung ENN backend with device validation." + ) + + parser.add_argument( + "-c", + "--chipset", + default="E9955", + help="Samsung chipset, i.e. E9945, E9955, E9965, etc.", + type=str, + ) + parser.add_argument( + "-m", + "--model_name", + default="yolo26n", + help="Ultralytics YOLO26 model name or path to a .pt file. Default: yolo26n", + type=str, + ) + parser.add_argument( + "--input_dims", + type=int, + nargs=2, + default=[640, 640], + help="Input model dimensions as height width. Default: 640 640", + ) + parser.add_argument( + "-d", + "--dataset", + default=None, + help=( + "Path to a folder containing calibration images (e.g. COCO images). " + "Used for calibration." + ), + type=str, + ) + parser.add_argument( + "-p", + "--precision", + default=None, + choices=[None, "A8W8"], + help="Quantization precision. If not set, model stays FP32.", + type=str, + ) + parser.add_argument( + "-cn", + "--calibration_number", + default=100, + help="Number of samples for calibrating quantization params. Default: 100.", + type=int, + ) + parser.add_argument( + "--dump", + action="store_true", + help="Whether to dump input/output tensors. Default: False.", + ) + parser.add_argument( + "-a", + "--artifact", + default="./yolo26", + help="Path for storing generated artifacts. Default: ./yolo26", + type=str, + ) + parser.add_argument( + "--validate", + nargs="?", + const="coco128.yaml", + help=( + "Run full validation on device using the Ultralytics validation pipeline. " + "Provide a path to the dataset YAML file (default: coco128.yaml)." + ), + type=str, + ) + parser.add_argument( + "--host", + default=None, + help="Host IP address with device connecting", + type=str, + ) + parser.add_argument( + "--device", + default=None, + help="Device ID to test", + type=str, + ) + + args = parser.parse_args() + main(args) diff --git a/examples/samsung/utils.py b/examples/samsung/utils.py index 60473536fdd..6f92f09650d 100644 --- a/examples/samsung/utils.py +++ b/examples/samsung/utils.py @@ -1,22 +1,33 @@ -import collections import logging import os import torch -def save_tensors(tensors, prefix, artifact_dir): - if isinstance(tensors, tuple): - for index, output in enumerate(tensors): - save_path = prefix + "_" + str(index) + ".bin" - output.cpu().detach().numpy().tofile(os.path.join(artifact_dir, save_path)) - elif isinstance(tensors, torch.Tensor): - tensors.cpu().detach().numpy().tofile( - os.path.join(artifact_dir, prefix + ".bin") - ) - elif isinstance(tensors, collections.OrderedDict): - for index, output in enumerate(tensors.values()): - save_path = prefix + "_" + str(index) + ".bin" - output.cpu().detach().numpy().tofile(os.path.join(artifact_dir, save_path)) - else: - logging.warning("Unsupported type (", type(tensors), ") skip saving tensor. ") +def _save_tensor(tensor: torch.Tensor, path: str) -> None: + """Serialize a single tensor to a .bin file.""" + tensor.cpu().detach().numpy().tofile(path) + + +def save_tensors(tensors, prefix: str, artifact_dir: str) -> None: + """Recursively save all tensors from arbitrary nested structures. + + Accepts tensor, list/tuple of tensors, dict of tensors, or any combination + of the above at arbitrary depth. Each leaf tensor is written as a .bin file + under *artifact_dir* with a name derived from *prefix* and the navigation path. + """ + + def _collect(obj, path_parts): + if isinstance(obj, torch.Tensor): + name = "_".join([prefix, *path_parts]) + ".bin" + _save_tensor(obj, os.path.join(artifact_dir, name)) + elif isinstance(obj, dict): + for i, value in enumerate(obj.values()): + _collect(value, path_parts + [str(i)]) + elif isinstance(obj, (list, tuple)): + for i, value in enumerate(obj): + _collect(value, path_parts + [str(i)]) + else: + logging.warning("Skipping unsupported type: %s", type(obj)) + + _collect(tensors, [])