# Copyright (c) 2019  PaddlePaddle Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# Reference Code from https://github.com/microsoft/onnxruntime,  Licensed under the MIT License.

import argparse
import numpy as np
import onnx
import sys
from onnx import helper, numpy_helper, shape_inference
import sympy

from packaging import version


def get_attribute(node, attr_name, default_value=None):
    found = [attr for attr in node.attribute if attr.name == attr_name]
    if found:
        return helper.get_attribute_value(found[0])
    return default_value


def get_dim_from_type_proto(dim):
    return getattr(dim, dim.WhichOneof('value')) if type(
        dim.WhichOneof('value')) == str else None


def get_shape_from_type_proto(type_proto):
    return [
        get_dim_from_type_proto(d) for d in type_proto.tensor_type.shape.dim
    ]


def get_shape_from_sympy_shape(sympy_shape):
    return [
        None if i is None else (int(i) if is_literal(i) else str(i))
        for i in sympy_shape
    ]


def is_literal(dim):
    return type(dim) in [int, np.int64, np.int32, sympy.Integer] or (hasattr(
        dim, 'is_number') and dim.is_number)


def handle_negative_axis(axis, rank):
    assert axis < rank and axis >= -rank
    return axis if axis >= 0 else rank + axis


def get_opset(mp, domain=['', 'onnx', 'ai.onnx']):
    if type(domain) != list:
        domain = [domain]
    for opset in mp.opset_import:
        if opset.domain in domain:
            return opset.version
    return None


def as_scalar(x):
    if type(x) == list:
        assert len(x) == 1
        return x[0]
    elif type(x) == np.ndarray:
        return np.asscalar(x)
    else:
        return x


def as_list(x, keep_none):
    if type(x) == list:
        return x
    elif type(x) == np.ndarray:
        return list(x)
    elif keep_none and x is None:
        return None
    else:
        return [x]


def sympy_reduce_product(x):
    if type(x) == list:
        value = sympy.Integer(1)
        for v in x:
            value = value * v
    else:
        value = x
    return value


class SymbolicShapeInference:
    def __init__(self, int_max, auto_merge, guess_output_rank, verbose):
        self.dispatcher_ = {
            'Add': self._infer_symbolic_compute_ops,
            'ArrayFeatureExtractor': self._infer_ArrayFeatureExtractor,
            'AveragePool': self._infer_Pool,
            'Cast': self._infer_Cast,
            'CategoryMapper': self._infer_CategoryMapper,
            'Compress': self._infer_Compress,
            'Concat': self._infer_Concat,
            'ConstantOfShape': self._infer_ConstantOfShape,
            'Conv': self._infer_Conv,
            'CumSum': self._pass_on_shape_and_type,
            'Div': self._infer_symbolic_compute_ops,
            'Expand': self._infer_Expand,
            'Equal': self._infer_symbolic_compute_ops,
            'Floor': self._infer_symbolic_compute_ops,
            'Gather': self._infer_Gather,
            'GatherElements': self._infer_GatherElements,
            'GatherND': self._infer_GatherND,
            'If': self._infer_If,
            'Loop': self._infer_Loop,
            'MatMul': self._infer_MatMul,
            'MatMulInteger16': self._infer_MatMulInteger,
            'MaxPool': self._infer_Pool,
            'Max': self._infer_symbolic_compute_ops,
            'Min': self._infer_symbolic_compute_ops,
            'Mul': self._infer_symbolic_compute_ops,
            'NonMaxSuppression': self._infer_NonMaxSuppression,
            'NonZero': self._infer_NonZero,
            'OneHot': self._infer_OneHot,
            'Pad': self._infer_Pad,
            'Range': self._infer_Range,
            'ReduceProd': self._infer_ReduceProd,
            'Reshape': self._infer_Reshape,
            'Resize': self._infer_Resize,
            'Round': self._pass_on_shape_and_type,
            'Scan': self._infer_Scan,
            'ScatterElements': self._infer_ScatterElements,
            'Shape': self._infer_Shape,
            'Size': self._infer_Size,
            'Slice': self._infer_Slice,
            'Split': self._infer_Split,
            'SplitToSequence': self._infer_SplitToSequence,
            'Squeeze': self._infer_Squeeze,
            'Sub': self._infer_symbolic_compute_ops,
            'Tile': self._infer_Tile,
            'TopK': self._infer_TopK,
            'Unsqueeze': self._infer_Unsqueeze,
            'Where': self._infer_symbolic_compute_ops,
            'ZipMap': self._infer_ZipMap
        }
        self.run_ = True
        self.suggested_merge_ = {}
        self.symbolic_dims_ = {}
        self.input_symbols_ = {}
        self.auto_merge_ = auto_merge
        self.guess_output_rank_ = guess_output_rank
        self.verbose_ = verbose
        self.int_max_ = int_max

    def _add_suggested_merge(self, symbols, apply=False):
        assert all([(type(s) == str and s in self.symbolic_dims_) or
                    is_literal(s) for s in symbols])
        symbols = set(symbols)
        for k, v in self.suggested_merge_.items():
            if k in symbols:
                symbols.remove(k)
                symbols.add(v)
        map_to = None
        # if there is literal, map to it first
        for s in symbols:
            if is_literal(s):
                map_to = s
                break
        # when no literals, map to input symbolic dims, then existing symbolic dims
        if map_to is None:
            for s in symbols:
                if s in self.input_symbols_:
                    map_to = s
                    break
        if map_to is None:
            for s in symbols:
                if type(self.symbolic_dims_[s]) == sympy.Symbol:
                    map_to = s
                    break
        # when nothing to map to, use the shorter one
        if map_to is None:
            if self.verbose_ > 0:
                print(
                    'Potential unsafe merge between symbolic expressions: ({})'.
                    format(','.join(symbols)))
            symbols_list = list(symbols)
            lens = [len(s) for s in symbols_list]
            map_to = symbols_list[lens.index(min(lens))]
            symbols.remove(map_to)

        for s in symbols:
            if s == map_to:
                continue
            if is_literal(map_to) and is_literal(s):
                assert int(map_to) == int(s)
            self.suggested_merge_[s] = int(map_to) if is_literal(
                map_to) else map_to
            for k, v in self.suggested_merge_.items():
                if v == s:
                    self.suggested_merge_[k] = map_to
        if apply and self.auto_merge_:
            self._apply_suggested_merge()

    def _apply_suggested_merge(self, graph_input_only=False):
        if not self.suggested_merge_:
            return
        for i in list(self.out_mp_.graph.input) + (
            [] if graph_input_only else list(self.out_mp_.graph.value_info)):
            for d in i.type.tensor_type.shape.dim:
                if d.dim_param in self.suggested_merge_:
                    v = self.suggested_merge_[d.dim_param]
                    if is_literal(v):
                        d.dim_value = int(v)
                    else:
                        d.dim_param = v

    def _preprocess(self, in_mp, input_shapes=None):
        out_mp = onnx.ModelProto()
        out_mp.CopyFrom(in_mp)
        out_mp.graph.ClearField('node')
        self.out_mp_ = out_mp

        defined = set([
            i.name
            for i in list(in_mp.graph.input) + list(in_mp.graph.initializer)
        ])
        pending_nodes = []

        # returns True if no more ready nodes
        def _insert_ready_nodes():
            ready_nodes = [
                pn for pn in pending_nodes
                if all([i in defined for i in pn.input if i])
            ]
            for rn in ready_nodes:
                self.out_mp_.graph.node.add().CopyFrom(rn)
                for o in rn.output:
                    defined.add(o)
                pending_nodes.remove(rn)
            return not ready_nodes

        # constant op -> initializer, topological sort
        for in_n in in_mp.graph.node:
            if in_n.op_type == 'Constant':
                t = get_attribute(in_n, 'value')
                t.name = in_n.output[0]
                self.out_mp_.graph.initializer.add().CopyFrom(t)
                defined.add(t.name)
            else:
                pending_nodes.append(in_n)
            _insert_ready_nodes()

        while pending_nodes:
            if _insert_ready_nodes():
                break

        if pending_nodes and self.verbose_ > 0:
            print('SymbolicShapeInference: orphaned nodes discarded: ')
            print(
                *[n.op_type + ': ' + n.output[0] for n in pending_nodes],
                sep='\n')

        if input_shapes is not None:
            for input_name, shape in input_shapes.items():
                for idx in range(len(self.out_mp_.graph.input)):
                    if self.out_mp_.graph.input[idx].name == input_name:
                        value_info = self.out_mp_.graph.input[idx]
                        del self.out_mp_.graph.input[idx]
                        self.out_mp_.graph.input.append(
                            helper.make_tensor_value_info(
                                value_info.name,
                                value_info.type.tensor_type.elem_type, shape))

        self.initializers_ = dict(
            [(i.name, i) for i in self.out_mp_.graph.initializer])
        self.known_vi_ = dict(
            [(i.name, i) for i in list(self.out_mp_.graph.input)])
        self.known_vi_.update(
            dict([(i.name, helper.make_tensor_value_info(i.name, i.data_type,
                                                         list(i.dims)))
                  for i in self.out_mp_.graph.initializer]))

    def _merge_symbols(self, dims):
        if not all([type(d) == str for d in dims]):
            if self.auto_merge_:
                assert len(
                    dims
                ) == 2  # only allow symbol->int merge in binary ops for now
                is_int = [is_literal(d) for d in dims]
                if sum(is_int) == 1:
                    int_dim = is_int.index(1)
                    if self.verbose_ > 0:
                        print('dim {} has been merged with value {}'.format(
                            dims[1 - int_dim], dims[int_dim]))
                    self._check_merged_dims(dims, allow_broadcast=False)
                    return dims[int_dim]
                else:
                    if self.verbose_ > 0:
                        print('dim {} has been mergd with dim {}'.format(dims[
                            0], dims[1]))
                    return dims[0]
            else:
                return None
        if all([d == dims[0] for d in dims]):
            return dims[0]
        merged = [
            self.suggested_merge_[d] if d in self.suggested_merge_ else d
            for d in dims
        ]
        if all([d == merged[0] for d in merged]):
            assert merged[0] in self.symbolic_dims_
            return merged[0]
        else:
            return None

    # broadcast from right to left, and merge symbolic dims if needed
    def _broadcast_shapes(self, shape1, shape2):
        new_shape = []
        rank1 = len(shape1)
        rank2 = len(shape2)
        new_rank = max(rank1, rank2)
        for i in range(new_rank):
            dim1 = shape1[rank1 - 1 - i] if i < rank1 else 1
            dim2 = shape2[rank2 - 1 - i] if i < rank2 else 1
            if dim1 == 1 or dim1 == dim2:
                new_dim = dim2
            elif dim2 == 1:
                new_dim = dim1
            else:
                new_dim = self._merge_symbols([dim1, dim2])
                if not new_dim:
                    # warning about unsupported broadcast when not auto merge
                    # note that auto merge has the risk of incorrectly merge symbols while one of them being 1
                    # for example, 'a' = 1, 'b' = 5 at runtime is valid broadcasting, but with auto merge 'a' == 'b'
                    if self.auto_merge_:
                        self._add_suggested_merge([dim1, dim2], apply=True)
                    else:
                        print('unsupported broadcast between ' + str(dim1) + ' '
                              + str(dim2))
            new_shape = [new_dim] + new_shape
        return new_shape

    def _get_shape(self, node, idx):
        name = node.input[idx]
        if name in self.known_vi_:
            return get_shape_from_type_proto(self.known_vi_[name].type)
        else:
            assert name in self.initializers_
            return list(self.initializers_[name].dims)

    def _get_shape_rank(self, node, idx):
        return len(self._get_shape(node, idx))

    def _get_sympy_shape(self, node, idx):
        sympy_shape = []
        for d in self._get_shape(node, idx):
            if type(d) == str:
                sympy_shape.append(self.symbolic_dims_[d] if d in
                                   self.symbolic_dims_ else sympy.Symbol(
                                       d, integer=True))
            else:
                assert None != d
                sympy_shape.append(d)
        return sympy_shape

    def _get_value(self, node, idx):
        name = node.input[idx]
        assert name in self.sympy_data_ or name in self.initializers_
        return self.sympy_data_[
            name] if name in self.sympy_data_ else numpy_helper.to_array(
                self.initializers_[name])

    def _try_get_value(self, node, idx):
        if idx >= len(node.input):
            return None
        name = node.input[idx]
        if name in self.sympy_data_ or name in self.initializers_:
            return self._get_value(node, idx)
        return None

    def _update_computed_dims(self, new_sympy_shape):
        for i, new_dim in enumerate(new_sympy_shape):
            if not is_literal(new_dim) and not type(new_dim) == str:
                str_dim = str(new_dim)
                if str_dim in self.suggested_merge_:
                    new_sympy_shape[i] = self.symbolic_dims_[
                        self.suggested_merge_[str_dim]]
                else:
                    # add new_dim if it's a computational expression
                    if not str(new_dim) in self.symbolic_dims_:
                        self.symbolic_dims_[str(new_dim)] = new_dim

    def _onnx_infer_single_node(self, node):
        # skip onnx shape inference for Scan/Loop
        skip_infer = node.op_type in ['Scan', 'Loop']
        if not skip_infer:
            # run single node inference with self.known_vi_ shapes
            # note that inference rely on initializer values is not handled
            # as we don't copy initializer weights to tmp_graph for inference speed purpose
            if node.op_type == 'SplitToSequence':
                make_value_info_func = helper.make_sequence_value_info
            else:
                make_value_info_func = helper.make_tensor_value_info
            tmp_graph = helper.make_graph(
                [node], 'tmp', [self.known_vi_[i] for i in node.input if i], [
                    make_value_info_func(i, onnx.TensorProto.UNDEFINED, None)
                    for i in node.output
                ])
            self.tmp_mp_.graph.CopyFrom(tmp_graph)
            self.tmp_mp_ = shape_inference.infer_shapes(self.tmp_mp_)
        for i_o in range(len(node.output)):
            o = node.output[i_o]
            vi = self.out_mp_.graph.value_info.add()
            if not skip_infer:
                vi.CopyFrom(self.tmp_mp_.graph.output[i_o])
            self.known_vi_[o] = vi

    def _onnx_infer_subgraph(self, node, subgraph, use_node_input=True):
        if self.verbose_ > 2:
            print('Inferencing subgraph of node {} with output({}...): {}'.
                  format(node.name, node.output[0], node.op_type))
        # node inputs are not passed directly to the subgraph
        # it's up to the node dispatcher to prepare subgraph input
        # for example, with Scan/Loop, subgraph input shape would be trimmed from node input shape
        # besides, inputs in subgraph could shadow implicit inputs
        subgraph_inputs = set([
            i.name for i in list(subgraph.initializer) + list(subgraph.input)
        ])
        subgraph_implicit_input = set([
            name for name in self.known_vi_.keys()
            if not name in subgraph_inputs
        ])
        tmp_graph = helper.make_graph(
            list(subgraph.node), 'tmp',
            list(subgraph.input) +
            [self.known_vi_[i] for i in subgraph_implicit_input], [
                helper.make_tensor_value_info(i.name,
                                              onnx.TensorProto.UNDEFINED, None)
                for i in subgraph.output
            ])
        tmp_graph.initializer.extend([
            i for i in self.out_mp_.graph.initializer
            if i.name in subgraph_implicit_input
        ])
        tmp_graph.initializer.extend(subgraph.initializer)
        self.tmp_mp_.graph.CopyFrom(tmp_graph)

        symbolic_shape_inference = SymbolicShapeInference(
            self.int_max_, self.auto_merge_, self.guess_output_rank_,
            self.verbose_)
        all_shapes_inferred = False
        symbolic_shape_inference._preprocess(self.tmp_mp_)
        # note that after _preprocess, Constant node will be converted to initializer and should be appended to subgraph.initializer
        subgraph.initializer.extend([
            i for i in symbolic_shape_inference.out_mp_.graph.initializer
            if i.name not in subgraph_implicit_input and i.name not in
            subgraph_inputs
        ])
        symbolic_shape_inference.suggested_merge_ = self.suggested_merge_.copy()
        while symbolic_shape_inference.run_:
            all_shapes_inferred = symbolic_shape_inference._infer_impl(
                self.tmp_mp_, self.sympy_data_.copy())
        symbolic_shape_inference._update_output_from_vi()
        if use_node_input:
            # if subgraph uses node input, it needs to update to merged dims
            subgraph.ClearField('input')
            subgraph.input.extend(
                symbolic_shape_inference.out_mp_.graph.input[:len(node.input)])
        subgraph.ClearField('output')
        subgraph.output.extend(symbolic_shape_inference.out_mp_.graph.output)
        subgraph.ClearField('value_info')
        subgraph.value_info.extend(
            symbolic_shape_inference.out_mp_.graph.value_info)
        subgraph.ClearField('node')
        subgraph.node.extend(symbolic_shape_inference.out_mp_.graph.node)
        # for new symbolic dims from subgraph output, add to main graph symbolic dims
        subgraph_shapes = [
            get_shape_from_type_proto(o.type)
            for o in symbolic_shape_inference.out_mp_.graph.output
        ]
        subgraph_new_symbolic_dims = set([
            d for s in subgraph_shapes
            if s for d in s if type(d) == str and not d in self.symbolic_dims_
        ])
        new_dims = {}
        for d in subgraph_new_symbolic_dims:
            assert d in symbolic_shape_inference.symbolic_dims_
            new_dims[d] = symbolic_shape_inference.symbolic_dims_[d]
        self.symbolic_dims_.update(new_dims)
        return symbolic_shape_inference

    def _get_int_values(self, node, broadcast=False):
        values = [self._try_get_value(node, i) for i in range(len(node.input))]
        if all([v is not None for v in values]):
            # some shape compute is in floating point, cast to int for sympy
            for i, v in enumerate(values):
                if type(v) != np.ndarray:
                    continue
                if len(v.shape) > 1:
                    new_v = None  # ignore value for rank > 1
                elif len(v.shape) == 0:
                    new_v = int(np.asscalar(v))
                else:
                    assert len(v.shape) == 1
                    new_v = [int(vv) for vv in v]
                values[i] = new_v
        values_len = [len(v) if type(v) == list else 0 for v in values]
        max_len = max(values_len)
        if max_len >= 1 and broadcast:
            # broadcast
            for i, v in enumerate(values):
                if v is None:
                    continue  # don't broadcast if value is unknown
                if type(v) == list:
                    if len(v) < max_len:
                        values[i] = v * max_len
                    else:
                        assert len(v) == max_len
                else:
                    values[i] = [v] * max_len
        return values

    def _compute_on_sympy_data(self, node, op_func):
        assert len(node.output) == 1
        values = self._get_int_values(node, broadcast=True)
        if all([v is not None for v in values]):
            is_list = [type(v) == list for v in values]
            as_list = any(is_list)
            if as_list:
                self.sympy_data_[node.output[
                    0]] = [op_func(vs) for vs in zip(*values)]
            else:
                self.sympy_data_[node.output[0]] = op_func(values)

    def _pass_on_sympy_data(self, node):
        assert len(node.input) == 1 or node.op_type == 'Reshape'
        self._compute_on_sympy_data(node, lambda x: x[0])

    def _pass_on_shape_and_type(self, node):
        vi = self.known_vi_[node.output[0]]
        vi.CopyFrom(
            helper.make_tensor_value_info(node.output[0], self.known_vi_[
                node.input[0]].type.tensor_type.elem_type,
                                          self._get_shape(node, 0)))

    def _new_symbolic_dim(self, prefix, dim):
        new_dim = '{}_d{}'.format(prefix, dim)
        if new_dim in self.suggested_merge_:
            v = self.suggested_merge_[new_dim]
            new_dim = sympy.Integer(int(v)) if is_literal(v) else v
        else:
            self.symbolic_dims_[new_dim] = sympy.Symbol(new_dim, integer=True)
        return new_dim

    def _new_symbolic_dim_from_output(self, node, out_idx=0, dim=0):
        return self._new_symbolic_dim('{}{}_o{}_'.format(
            node.op_type, list(self.out_mp_.graph.node).index(node), out_idx),
                                      dim)

    def _new_symbolic_shape(self, rank, node, out_idx=0):
        return [
            self._new_symbolic_dim_from_output(node, out_idx, i)
            for i in range(rank)
        ]

    def _compute_conv_pool_shape(self, node):
        sympy_shape = self._get_sympy_shape(node, 0)
        if len(node.input) > 1:
            W_shape = self._get_sympy_shape(node, 1)
            rank = len(W_shape) - 2  # number of spatial axes
            kernel_shape = W_shape[-rank:]
            sympy_shape[1] = W_shape[0]
        else:
            W_shape = None
            kernel_shape = get_attribute(node, 'kernel_shape')
            rank = len(kernel_shape)

        assert len(sympy_shape) == rank + 2

        # only need to symbolic shape inference if input has symbolic dims in spatial axes
        is_symbolic_dims = [not is_literal(i) for i in sympy_shape[-rank:]]

        if not any(is_symbolic_dims):
            shape = get_shape_from_type_proto(self.known_vi_[node.output[0]]
                                              .type)
            if len(shape) > 0:
                assert len(sympy_shape) == len(shape)
                sympy_shape[-rank:] = [sympy.Integer(d) for d in shape[-rank:]]
                return sympy_shape

        dilations = get_attribute(node, 'dilations', [1] * rank)
        strides = get_attribute(node, 'strides', [1] * rank)
        effective_kernel_shape = [(k - 1) * d + 1
                                  for k, d in zip(kernel_shape, dilations)]
        pads = get_attribute(node, 'pads')
        if pads is None:
            pads = [0] * (2 * rank)
            auto_pad = get_attribute(node, 'auto_pad',
                                     b'NOTSET').decode('utf-8')
            if auto_pad != 'VALID' and auto_pad != 'NOTSET':
                try:
                    residual = [
                        sympy.Mod(d, s)
                        for d, s in zip(sympy_shape[-rank:], strides)
                    ]
                    total_pads = [
                        max(0, (k - s) if r == 0 else (k - r))
                        for k, s, r in zip(effective_kernel_shape, strides,
                                           residual)
                    ]
                except TypeError:  # sympy may throw TypeError: cannot determine truth value of Relational
                    total_pads = [
                        max(0, (k - s))
                        for k, s in zip(effective_kernel_shape, strides)
                    ]  # assuming no residual if sympy throws error
            elif auto_pad == 'VALID':
                total_pads = []
            else:
                total_pads = [0] * rank
        else:
            assert len(pads) == 2 * rank
            total_pads = [p1 + p2 for p1, p2 in zip(pads[:rank], pads[rank:])]

        ceil_mode = get_attribute(node, 'ceil_mode', 0)
        for i in range(rank):
            effective_input_size = sympy_shape[-rank + i]
            if len(total_pads) > 0:
                effective_input_size = effective_input_size + total_pads[i]
            if ceil_mode:
                strided_kernel_positions = sympy.ceiling(
                    (effective_input_size - effective_kernel_shape[i]) /
                    strides[i])
            else:
                strided_kernel_positions = (
                    effective_input_size - effective_kernel_shape[i]
                ) // strides[i]
            sympy_shape[-rank + i] = strided_kernel_positions + 1
        return sympy_shape

    def _check_merged_dims(self, dims, allow_broadcast=True):
        if allow_broadcast:
            dims = [d for d in dims if not (is_literal(d) and int(d) <= 1)]
        if not all([d == dims[0] for d in dims]):
            self._add_suggested_merge(dims, apply=True)

    def _compute_matmul_shape(self, node, output_dtype=None):
        lhs_shape = self._get_shape(node, 0)
        rhs_shape = self._get_shape(node, 1)
        lhs_rank = len(lhs_shape)
        rhs_rank = len(rhs_shape)
        lhs_reduce_dim = 0
        rhs_reduce_dim = 0
        assert lhs_rank > 0 and rhs_rank > 0
        if lhs_rank == 1 and rhs_rank == 1:
            new_shape = []
        elif lhs_rank == 1:
            rhs_reduce_dim = -2
            new_shape = rhs_shape[:rhs_reduce_dim] + [rhs_shape[-1]]
        elif rhs_rank == 1:
            lhs_reduce_dim = -1
            new_shape = lhs_shape[:lhs_reduce_dim]
        else:
            lhs_reduce_dim = -1
            rhs_reduce_dim = -2
            new_shape = self._broadcast_shapes(
                lhs_shape[:-2],
                rhs_shape[:-2]) + [lhs_shape[-2]] + [rhs_shape[-1]]
        # merge reduce dim
        self._check_merged_dims(
            [lhs_shape[lhs_reduce_dim], rhs_shape[rhs_reduce_dim]],
            allow_broadcast=False)
        if output_dtype is None:
            # infer output_dtype from input type when not specified
            output_dtype = self.known_vi_[node.input[
                0]].type.tensor_type.elem_type
        vi = self.known_vi_[node.output[0]]
        vi.CopyFrom(
            helper.make_tensor_value_info(node.output[0], output_dtype,
                                          new_shape))

    def _infer_ArrayFeatureExtractor(self, node):
        data_shape = self._get_shape(node, 0)
        indices_shape = self._get_shape(node, 1)
        vi = self.known_vi_[node.output[0]]
        vi.CopyFrom(
            helper.make_tensor_value_info(node.output[0], self.known_vi_[
                node.input[0]].type.tensor_type.elem_type, data_shape[:-1] +
                                          indices_shape))

    def _infer_symbolic_compute_ops(self, node):
        funcs = {
            'Add': lambda l: l[0] + l[1],
            'Div': lambda l: l[0] // l[1],  # integer div in sympy
            'Equal': lambda l: l[0] == l[1],
            'Floor': lambda l: sympy.floor(l[0]),
            'Max':
            lambda l: l[1] if is_literal(l[0]) and int(l[0]) < -self.int_max_ else (l[0] if is_literal(l[1]) and int(l[1]) < -self.int_max_ else sympy.Max(l[0], l[1])),
            'Min':
            lambda l: l[1] if is_literal(l[0]) and int(l[0]) > self.int_max_ else (l[0] if is_literal(l[1]) and int(l[1]) > self.int_max_ else sympy.Min(l[0], l[1])),
            'Mul': lambda l: l[0] * l[1],
            'Sub': lambda l: l[0] - l[1],
            'Where': lambda l: l[1] if l[0] else l[2]
        }
        assert node.op_type in funcs
        self._compute_on_sympy_data(node, funcs[node.op_type])

    def _infer_Cast(self, node):
        self._pass_on_sympy_data(node)

    def _infer_CategoryMapper(self, node):
        input_type = self.known_vi_[node.input[0]].type.tensor_type.elem_type
        if input_type == onnx.TensorProto.STRING:
            output_type = onnx.TensorProto.INT64
        else:
            output_type = onnx.TensorProto.STRING
        vi = self.known_vi_[node.output[0]]
        vi.CopyFrom(
            helper.make_tensor_value_info(node.output[0], output_type,
                                          self._get_shape(node, 0)))

    def _infer_Compress(self, node):
        input_shape = self._get_shape(node, 0)
        # create a new symbolic dimension for Compress output
        compress_len = self._new_symbolic_dim_from_output(node)
        axis = get_attribute(node, 'axis')
        if axis == None:
            # when axis is not specified, input is flattened before compress so output is 1D
            output_shape = [compress_len]
        else:
            output_shape = input_shape
            output_shape[handle_negative_axis(axis, len(
                input_shape))] = compress_len
        vi = self.known_vi_[node.output[0]]
        vi.CopyFrom(
            helper.make_tensor_value_info(node.output[0], self.known_vi_[
                node.input[0]].type.tensor_type.elem_type, output_shape))

    def _infer_Concat(self, node):
        if any([i in self.sympy_data_ for i in node.input]):
            values = self._get_int_values(node)
            if all([v is not None for v in values]):
                assert 0 == get_attribute(node, 'axis')
                self.sympy_data_[node.output[0]] = []
                for i in range(len(node.input)):
                    value = values[i]
                    if type(value) == list:
                        self.sympy_data_[node.output[0]].extend(value)
                    else:
                        self.sympy_data_[node.output[0]].append(value)

        sympy_shape = self._get_sympy_shape(node, 0)
        axis = handle_negative_axis(
            get_attribute(node, 'axis'), len(sympy_shape))
        for i_idx in range(1, len(node.input)):
            input_shape = self._get_sympy_shape(node, i_idx)
            if input_shape:
                sympy_shape[axis] = sympy_shape[axis] + input_shape[axis]
        self._update_computed_dims(sympy_shape)
        # merge symbolic dims for non-concat axes
        for d in range(len(sympy_shape)):
            if d == axis:
                continue
            dims = [
                self._get_shape(node, i_idx)[d]
                for i_idx in range(len(node.input))
                if self._get_shape(node, i_idx)
            ]
            if all([d == dims[0] for d in dims]):
                continue
            merged = self._merge_symbols(dims)
            if type(merged) == str:
                sympy_shape[d] = self.symbolic_dims_[merged] if merged else None
            else:
                sympy_shape[d] = merged
        vi = self.known_vi_[node.output[0]]
        vi.CopyFrom(
            helper.make_tensor_value_info(
                node.output[0], self.known_vi_[node.input[0]].type.tensor_type.
                elem_type, get_shape_from_sympy_shape(sympy_shape)))

    def _infer_Conv(self, node):
        sympy_shape = self._compute_conv_pool_shape(node)
        self._update_computed_dims(sympy_shape)
        vi = self.known_vi_[node.output[0]]
        vi.CopyFrom(
            helper.make_tensor_value_info(
                node.output[0], vi.type.tensor_type.elem_type,
                get_shape_from_sympy_shape(sympy_shape)))

    def _infer_ConstantOfShape(self, node):
        sympy_shape = self._get_int_values(node)[0]
        vi = self.known_vi_[node.output[0]]
        if sympy_shape is not None:
            if type(sympy_shape) != list:
                sympy_shape = [sympy_shape]
            self._update_computed_dims(sympy_shape)
            # update sympy data if output type is int, and shape is known
            if vi.type.tensor_type.elem_type == onnx.TensorProto.INT64 and all(
                [is_literal(x) for x in sympy_shape]):
                self.sympy_data_[node.output[0]] = np.ones(
                    [int(x) for x in sympy_shape],
                    dtype=np.int64) * numpy_helper.to_array(
                        get_attribute(node, 'value', 0))
        else:
            # create new dynamic shape
            sympy_shape = self._new_symbolic_shape(
                self._get_shape_rank(node, 0), node)

        vi.CopyFrom(
            helper.make_tensor_value_info(
                node.output[0], vi.type.tensor_type.elem_type,
                get_shape_from_sympy_shape(sympy_shape)))

    def _infer_Expand(self, node):
        expand_to_shape = self._try_get_value(node, 1)
        if expand_to_shape is not None:
            # new_shape's dim can come from shape value
            self._update_computed_dims(expand_to_shape)
            shape = self._get_shape(node, 0)
            new_shape = self._broadcast_shapes(
                shape, get_shape_from_sympy_shape(expand_to_shape))
            vi = self.known_vi_[node.output[0]]
            vi.CopyFrom(
                helper.make_tensor_value_info(node.output[0], self.known_vi_[
                    node.input[0]].type.tensor_type.elem_type, new_shape))

    def _infer_Gather(self, node):
        data_shape = self._get_shape(node, 0)
        axis = handle_negative_axis(
            get_attribute(node, 'axis', 0), len(data_shape))
        indices_shape = self._get_shape(node, 1)
        vi = self.known_vi_[node.output[0]]
        vi.CopyFrom(
            helper.make_tensor_value_info(
                node.output[0], vi.type.tensor_type.elem_type, data_shape[:axis]
                + indices_shape + data_shape[axis + 1:]))
        if node.input[0] in self.sympy_data_:
            assert 0 == get_attribute(node, 'axis',
                                      0)  # only handle 1D sympy compute
            idx = self._get_value(node, 1)
            data = self.sympy_data_[node.input[0]]
            if type(data) == list:
                if type(idx) == np.ndarray and len(idx.shape) == 1:
                    self.sympy_data_[node.output[
                        0]] = [data[int(i)] for i in idx]
                else:
                    self.sympy_data_[node.output[0]] = data[int(idx)]
            else:
                assert idx == 0
                self.sympy_data_[node.output[0]] = data

    def _infer_GatherElements(self, node):
        indices_shape = self._get_shape(node, 1)
        vi = self.known_vi_[node.output[0]]
        vi.CopyFrom(
            helper.make_tensor_value_info(node.output[0], self.known_vi_[
                node.input[0]].type.tensor_type.elem_type, indices_shape))

    def _infer_GatherND(self, node):
        data_shape = self._get_shape(node, 0)
        data_rank = len(data_shape)
        indices_shape = self._get_shape(node, 1)
        indices_rank = len(indices_shape)
        last_index_dimension = indices_shape[-1]
        assert is_literal(
            last_index_dimension) and last_index_dimension <= data_rank
        new_shape = indices_shape[:-1] + data_shape[last_index_dimension:]
        vi = self.known_vi_[node.output[0]]
        vi.CopyFrom(
            helper.make_tensor_value_info(node.output[0], self.known_vi_[
                node.input[0]].type.tensor_type.elem_type, new_shape))

    def _infer_If(self, node):
        # special case for constant condition, in case there are mismatching shape from the non-executed branch
        subgraphs = [
            get_attribute(node, 'then_branch'), get_attribute(node,
                                                              'else_branch')
        ]
        cond = self._try_get_value(node, 0)
        if cond is not None:
            if cond > 0:
                subgraphs[1].CopyFrom(subgraphs[0])
            else:
                subgraphs[0].CopyFrom(subgraphs[1])

        for i_sub, subgraph in enumerate(subgraphs):
            subgraph_infer = self._onnx_infer_subgraph(
                node, subgraph, use_node_input=False)
            for i_out in range(len(node.output)):
                vi = self.known_vi_[node.output[i_out]]
                if i_sub == 0:
                    vi.CopyFrom(subgraph.output[i_out])
                    vi.name = node.output[i_out]
                else:
                    assert all([
                        d1 == d2
                        for d1, d2 in zip(vi.type.tensor_type.shape.dim,
                                          subgraph.output[
                                              i_out].type.tensor_type.shape.dim)
                    ])
                # pass on sympy data from subgraph, if cond is constant
                if cond is not None and i_sub == (0 if cond > 0 else 1):
                    if subgraph.output[
                            i_out].name in subgraph_infer.sympy_data_:
                        self.sympy_data_[vi.name] = subgraph_infer.sympy_data_[
                            subgraph.output[i_out].name]

    def _infer_Loop(self, node):
        subgraph = get_attribute(node, 'body')
        assert len(subgraph.input) == len(node.input)
        for i, si in enumerate(subgraph.input):
            subgraph_name = si.name
            si.CopyFrom(self.known_vi_[node.input[i]])
            si.name = subgraph_name
        self._onnx_infer_subgraph(node, subgraph)
        # create a new symbolic dimension for iteration dependent dimension
        loop_iter_dim = self._new_symbolic_dim_from_output(node)
        num_loop_carried = len(node.input) - 2
        for i in range(len(node.output)):
            vi = self.known_vi_[node.output[i]]
            vi.CopyFrom(
                subgraph.output[i + 1]
            )  # first subgraph output is condition, not in node output
            if i >= num_loop_carried:
                subgraph_vi_dim = subgraph.output[i +
                                                  1].type.tensor_type.shape.dim
                vi.type.tensor_type.shape.ClearField('dim')
                vi_dim = vi.type.tensor_type.shape.dim
                vi_dim.add().dim_param = loop_iter_dim
                vi_dim.extend(list(subgraph_vi_dim))
            vi.name = node.output[i]

    def _infer_MatMul(self, node):
        self._compute_matmul_shape(node)

    def _infer_MatMulInteger(self, node):
        self._compute_matmul_shape(node, onnx.TensorProto.INT32)

    def _infer_NonMaxSuppression(self, node):
        selected = self._new_symbolic_dim_from_output(node)
        vi = self.known_vi_[node.output[0]]
        vi.CopyFrom(
            helper.make_tensor_value_info(node.output[
                0], onnx.TensorProto.INT64, [selected, 3]))

    def _infer_NonZero(self, node):
        input_rank = self._get_shape_rank(node, 0)
        # create a new symbolic dimension for NonZero output
        nz_len = self._new_symbolic_dim_from_output(node, 0, 1)
        vi = self.known_vi_[node.output[0]]
        vi.CopyFrom(
            helper.make_tensor_value_info(node.output[
                0], vi.type.tensor_type.elem_type, [input_rank, nz_len]))

    def _infer_OneHot(self, node):
        sympy_shape = self._get_sympy_shape(node, 0)
        depth = self._try_get_value(node, 1)
        axis = get_attribute(node, 'axis', -1)
        axis = handle_negative_axis(axis, len(sympy_shape) + 1)
        new_shape = get_shape_from_sympy_shape(sympy_shape[:axis] + [
            self._new_symbolic_dim_from_output(node)
            if not is_literal(depth) else depth
        ] + sympy_shape[axis:])
        vi = self.known_vi_[node.output[0]]
        vi.CopyFrom(
            helper.make_tensor_value_info(node.output[0], self.known_vi_[
                node.input[2]].type.tensor_type.elem_type, new_shape))

    def _infer_Pad(self, node):
        if get_opset(self.out_mp_) <= 10:
            pads = get_attribute(node, 'pads')
        else:
            pads = self._try_get_value(node, 1)

        vi = self.known_vi_[node.output[0]]
        output_shape = get_shape_from_type_proto(vi.type)
        if len(output_shape) == 0 or None in output_shape:
            sympy_shape = self._get_sympy_shape(node, 0)
            rank = len(sympy_shape)
            if pads is not None:
                assert len(pads) == 2 * rank
                new_sympy_shape = [
                    d + pad_up + pad_down
                    for d, pad_up, pad_down in zip(sympy_shape, pads[:rank],
                                                   pads[rank:])
                ]
                self._update_computed_dims(new_sympy_shape)
            else:
                # dynamic pads, create new symbolic dimensions
                new_sympy_shape = self._new_symbolic_shape(rank, node)
            output_tp = self.known_vi_[node.input[0]].type.tensor_type.elem_type
            vi.CopyFrom(
                helper.make_tensor_value_info(node.output[
                    0], output_tp, get_shape_from_sympy_shape(new_sympy_shape)))

    def _infer_Pool(self, node):
        sympy_shape = self._compute_conv_pool_shape(node)
        self._update_computed_dims(sympy_shape)
        for o in node.output:
            if not o:
                continue
            vi = self.known_vi_[o]
            vi.CopyFrom(
                helper.make_tensor_value_info(o, vi.type.tensor_type.elem_type,
                                              get_shape_from_sympy_shape(
                                                  sympy_shape)))

    def _infer_Range(self, node):
        vi = self.known_vi_[node.output[0]]
        input_data = self._get_int_values(node)
        if all([i is not None for i in input_data]):
            start = as_scalar(input_data[0])
            limit = as_scalar(input_data[1])
            delta = as_scalar(input_data[2])
            new_sympy_shape = [
                sympy.Max(sympy.ceiling((limit - start) / delta), 0)
            ]
        else:
            new_dim = self._new_symbolic_dim_from_output(node)
            new_sympy_shape = [self.symbolic_dims_[new_dim]]
        self._update_computed_dims(new_sympy_shape)
        vi.CopyFrom(
            helper.make_tensor_value_info(
                node.output[0], self.known_vi_[node.input[0]].type.tensor_type.
                elem_type, get_shape_from_sympy_shape(new_sympy_shape)))

    def _infer_ReduceProd(self, node):
        axes = get_attribute(node, 'axes')
        keep_dims = get_attribute(node, 'keepdims')
        if keep_dims == 0 and axes == [0]:
            data = self._get_int_values(node)[0]
            if data is not None:
                self.sympy_data_[node.output[0]] = sympy_reduce_product(data)

    def _infer_Reshape(self, node):
        shape_value = self._try_get_value(node, 1)
        vi = self.known_vi_[node.output[0]]
        if shape_value is None:
            shape_shape = self._get_shape(node, 1)
            assert len(shape_shape) == 1
            shape_rank = shape_shape[0]
            assert is_literal(shape_rank)
            vi.CopyFrom(
                helper.make_tensor_value_info(
                    node.output[0], vi.type.tensor_type.elem_type,
                    get_shape_from_sympy_shape(
                        self._new_symbolic_shape(shape_rank, node))))
        else:
            input_shape = self._get_shape(node, 0)
            input_sympy_shape = self._get_sympy_shape(node, 0)
            total = int(1)
            for d in input_sympy_shape:
                total = total * d
            new_sympy_shape = []
            deferred_dim_idx = -1
            non_deferred_size = int(1)
            for i, d in enumerate(shape_value):
                if type(d) == sympy.Symbol:
                    new_sympy_shape.append(d)
                elif d == 0:
                    new_sympy_shape.append(input_sympy_shape[i])
                    non_deferred_size = non_deferred_size * input_sympy_shape[i]
                else:
                    new_sympy_shape.append(d)
                if d == -1:
                    deferred_dim_idx = i
                elif d != 0:
                    non_deferred_size = non_deferred_size * d

            assert new_sympy_shape.count(-1) < 2
            if -1 in new_sympy_shape:
                new_dim = total // non_deferred_size
                new_sympy_shape[deferred_dim_idx] = new_dim
                self._update_computed_dims(new_sympy_shape)

            vi.CopyFrom(
                helper.make_tensor_value_info(
                    node.output[0], vi.type.tensor_type.elem_type,
                    get_shape_from_sympy_shape(new_sympy_shape)))

        self._pass_on_sympy_data(node)

    def _infer_Resize(self, node):
        vi = self.known_vi_[node.output[0]]
        input_sympy_shape = self._get_sympy_shape(node, 0)
        if get_opset(self.out_mp_) <= 10:
            scales = self._try_get_value(node, 1)
            if scales is not None:
                new_sympy_shape = [
                    sympy.simplify(sympy.floor(d * s))
                    for d, s in zip(input_sympy_shape, scales)
                ]
                self._update_computed_dims(new_sympy_shape)
                vi.CopyFrom(
                    helper.make_tensor_value_info(
                        node.output[0], self.known_vi_[node.input[
                            0]].type.tensor_type.elem_type,
                        get_shape_from_sympy_shape(new_sympy_shape)))
        else:
            roi = self._try_get_value(node, 1)
            scales = self._try_get_value(node, 2)
            sizes = self._try_get_value(node, 3)
            if sizes is not None:
                new_sympy_shape = [
                    sympy.simplify(sympy.floor(s)) for s in sizes
                ]
                self._update_computed_dims(new_sympy_shape)
            elif scales is not None:
                rank = len(scales)
                if get_attribute(node, 'coordinate_transformation_mode'
                                 ) == 'tf_crop_and_resize':
                    assert len(roi) == 2 * rank
                    roi_start = list(roi)[:rank]
                    roi_end = list(roi)[rank:]
                else:
                    roi_start = [0] * rank
                    roi_end = [1] * rank
                scales = list(scales)
                new_sympy_shape = [
                    sympy.simplify(sympy.floor(d * (end - start) * scale))
                    for d, start, end, scale in zip(input_sympy_shape,
                                                    roi_start, roi_end, scales)
                ]
                self._update_computed_dims(new_sympy_shape)
            else:
                new_sympy_shape = self._new_symbolic_shape(
                    self._get_shape_rank(node, 0), node)

            vi.CopyFrom(
                helper.make_tensor_value_info(node.output[0], self.known_vi_[
                    node.input[0]].type.tensor_type.elem_type,
                                              get_shape_from_sympy_shape(
                                                  new_sympy_shape)))

    def _infer_Scan(self, node):
        subgraph = get_attribute(node, 'body')
        num_scan_inputs = get_attribute(node, 'num_scan_inputs')
        scan_input_axes = get_attribute(node, 'scan_input_axes',
                                        [0] * num_scan_inputs)
        num_scan_states = len(node.input) - num_scan_inputs
        scan_input_axes = [
            handle_negative_axis(
                ax, self._get_shape_rank(node, i + num_scan_states))
            for i, ax in enumerate(scan_input_axes)
        ]
        # We may have cases where the subgraph has optionial inputs that appear in both subgraph's input and initializer,
        # but not in the node's input. In such cases, the input model might be invalid, but let's skip those optional inputs.
        assert len(subgraph.input) >= len(node.input)
        subgraph_inputs = subgraph.input[:len(node.input)]
        for i, si in enumerate(subgraph_inputs):
            subgraph_name = si.name
            si.CopyFrom(self.known_vi_[node.input[i]])
            if i >= num_scan_states:
                scan_input_dim = si.type.tensor_type.shape.dim
                scan_input_dim.remove(scan_input_dim[scan_input_axes[
                    i - num_scan_states]])
            si.name = subgraph_name
        self._onnx_infer_subgraph(node, subgraph)
        num_scan_outputs = len(node.output) - num_scan_states
        scan_output_axes = get_attribute(node, 'scan_output_axes',
                                         [0] * num_scan_outputs)
        scan_input_dim = get_shape_from_type_proto(self.known_vi_[node.input[
            -1]].type)[scan_input_axes[-1]]
        for i, o in enumerate(node.output):
            vi = self.known_vi_[o]
            if i >= num_scan_states:
                shape = get_shape_from_type_proto(subgraph.output[i].type)
                new_dim = handle_negative_axis(
                    scan_output_axes[i - num_scan_states], len(shape) + 1)
                shape = shape[:new_dim] + [scan_input_dim] + shape[new_dim:]
                vi.CopyFrom(
                    helper.make_tensor_value_info(o, subgraph.output[
                        i].type.tensor_type.elem_type, shape))
            else:
                vi.CopyFrom(subgraph.output[i])
            vi.name = o

    def _infer_ScatterElements(self, node):
        data_shape = self._get_shape(node, 0)
        vi = self.known_vi_[node.output[0]]
        vi.CopyFrom(
            helper.make_tensor_value_info(node.output[0], self.known_vi_[
                node.input[0]].type.tensor_type.elem_type, data_shape))

    def _infer_Shape(self, node):
        self.sympy_data_[node.output[0]] = self._get_sympy_shape(node, 0)

    def _infer_Size(self, node):
        sympy_shape = self._get_sympy_shape(node, 0)
        self.sympy_data_[node.output[0]] = sympy_reduce_product(sympy_shape)
        self.known_vi_[node.output[0]].CopyFrom(
            helper.make_tensor_value_info(node.output[0],
                                          onnx.TensorProto.INT64, []))

    def _infer_Slice(self, node):
        if get_opset(self.out_mp_) <= 9:
            axes = get_attribute(node, 'axes')
            starts = get_attribute(node, 'starts')
            ends = get_attribute(node, 'ends')
            steps = [1] * len(axes)
        else:
            starts = as_list(self._try_get_value(node, 1), keep_none=True)
            ends = as_list(self._try_get_value(node, 2), keep_none=True)
            axes = self._try_get_value(node, 3)
            steps = self._try_get_value(node, 4)
            if axes is None and not (starts is None and ends is None):
                axes = list(
                    range(0, len(starts if starts is not None else ends)))
            if steps is None and not (starts is None and ends is None):
                steps = [1] * len(starts if starts is not None else ends)
            axes = as_list(axes, keep_none=True)
            steps = as_list(steps, keep_none=True)

        new_sympy_shape = self._get_sympy_shape(node, 0)
        if starts is None or ends is None:
            if axes is None:
                for i in range(len(new_sympy_shape)):
                    new_sympy_shape[i] = self._new_symbolic_dim_from_output(
                        node, 0, i)
            else:
                new_sympy_shape = get_shape_from_sympy_shape(new_sympy_shape)
                for i in axes:
                    new_sympy_shape[i] = self._new_symbolic_dim_from_output(
                        node, 0, i)
        else:
            for i, s, e, t in zip(axes, starts, ends, steps):
                idx = handle_negative_axis(i, len(new_sympy_shape))
                if is_literal(e):
                    if e >= self.int_max_:
                        e = new_sympy_shape[i]
                    elif e <= -self.int_max_:
                        e = 0 if s > 0 else -1
                    elif is_literal(new_sympy_shape[i]):
                        if e < 0:
                            e = e + new_sympy_shape[i]
                        e = min(e, new_sympy_shape[i])
                    else:
                        if e > 0:
                            e = sympy.Min(
                                e, new_sympy_shape[i]
                            ) if e > 1 else e  #special case for slicing first to make computation easier
                        else:
                            e = new_sympy_shape[i] + e
                else:
                    if is_literal(new_sympy_shape[i]):
                        e = sympy.Min(e, new_sympy_shape[i])
                    else:
                        try:
                            if e >= new_sympy_shape[i]:
                                e = new_sympy_shape[i]
                        except Exception:
                            print(
                                'Unable to determine if {} <= {}, treat as equal'.
                                format(e, new_sympy_shape[i]))
                            e = new_sympy_shape[i]

                if is_literal(s) and int(s) < 0:
                    s = new_sympy_shape[i] + s

                new_sympy_shape[idx] = (e - s + t + (-1 if t > 0 else 1)) // t

            self._update_computed_dims(new_sympy_shape)

        vi = self.known_vi_[node.output[0]]
        vi.CopyFrom(
            helper.make_tensor_value_info(
                node.output[0], vi.type.tensor_type.elem_type,
                get_shape_from_sympy_shape(new_sympy_shape)))

        # handle sympy_data if needed, for slice in shape computation
        if node.input[0] in self.sympy_data_:
            assert [0] == axes
            assert len(starts) == 1
            assert len(ends) == 1
            self.sympy_data_[node.output[0]] = self.sympy_data_[node.input[0]][
                starts[0]:ends[0]]

    def _infer_Split_Common(self, node, make_value_info_func):
        input_sympy_shape = self._get_sympy_shape(node, 0)
        axis = handle_negative_axis(
            get_attribute(node, 'axis', 0), len(input_sympy_shape))
        split = get_attribute(node, 'split')
        if not split:
            num_outputs = len(node.output)
            split = [input_sympy_shape[axis] /
                     sympy.Integer(num_outputs)] * num_outputs
            self._update_computed_dims(split)
        else:
            split = [sympy.Integer(s) for s in split]

        for i_o in range(len(split)):
            vi = self.known_vi_[node.output[i_o]]
            vi.CopyFrom(
                make_value_info_func(node.output[i_o], self.known_vi_[
                    node.input[0]].type.tensor_type.elem_type,
                                     get_shape_from_sympy_shape(
                                         input_sympy_shape[:axis] + [
                                             split[i_o]
                                         ] + input_sympy_shape[axis + 1:])))
            self.known_vi_[vi.name] = vi

    def _infer_Split(self, node):
        self._infer_Split_Common(node, helper.make_tensor_value_info)

    def _infer_SplitToSequence(self, node):
        self._infer_Split_Common(node, helper.make_sequence_value_info)

    def _infer_Squeeze(self, node):
        self._pass_on_sympy_data(node)

    def _infer_Tile(self, node):
        repeats_value = self._get_value(node, 1)
        input_sympy_shape = self._get_sympy_shape(node, 0)
        new_sympy_shape = []
        for i, d in enumerate(input_sympy_shape):
            new_dim = d * repeats_value[i]
            new_sympy_shape.append(new_dim)
        self._update_computed_dims(new_sympy_shape)
        vi = self.known_vi_[node.output[0]]
        vi.CopyFrom(
            helper.make_tensor_value_info(
                node.output[0], vi.type.tensor_type.elem_type,
                get_shape_from_sympy_shape(new_sympy_shape)))

    def _infer_TopK(self, node):
        rank = self._get_shape_rank(node, 0)
        axis = handle_negative_axis(get_attribute(node, 'axis', -1), rank)
        new_shape = self._get_shape(node, 0)

        if get_opset(self.out_mp_) <= 9:
            k = get_attribute(node, 'k')
        else:
            k = self._get_int_values(node)[1]

        if k == None:
            k = self._new_symbolic_dim_from_output(node)
        else:
            k = as_scalar(k)

        if type(k) in [int, str]:
            new_shape[axis] = k
        else:
            new_sympy_shape = self._get_sympy_shape(node, 0)
            new_sympy_shape[axis] = k
            self._update_computed_dims(
                new_sympy_shape
            )  # note that TopK dim could be computed in sympy_data, so need to update computed_dims when it enters shape
            new_shape = get_shape_from_sympy_shape(new_sympy_shape)

        for i_o in range(len(node.output)):
            vi = self.known_vi_[node.output[i_o]]
            vi.CopyFrom(
                helper.make_tensor_value_info(node.output[
                    i_o], vi.type.tensor_type.elem_type, new_shape))

    def _infer_Unsqueeze(self, node):
        self._pass_on_sympy_data(node)

    def _infer_ZipMap(self, node):
        map_key_type = None
        if get_attribute(node, 'classlabels_int64s') is not None:
            map_key_type = onnx.TensorProto.INT64
        elif get_attribute(node, 'classlabels_strings') is not None:
            map_key_type = onnx.TensorProto.STRING

        assert map_key_type is not None
        new_vi = onnx.ValueInfoProto()
        new_vi.name = node.output[0]
        new_vi.type.sequence_type.elem_type.map_type.value_type.tensor_type.elem_type = onnx.TensorProto.FLOAT
        new_vi.type.sequence_type.elem_type.map_type.key_type = map_key_type
        vi = self.known_vi_[node.output[0]]
        vi.CopyFrom(new_vi)

    def _infer_impl(self, in_mp, start_sympy_data={}):
        self.sympy_data_ = start_sympy_data
        self.out_mp_.graph.ClearField('value_info')
        self._apply_suggested_merge(graph_input_only=True)
        self.input_symbols_ = set()
        for i in self.out_mp_.graph.input:
            input_dims = i.type.tensor_type.shape.dim
            for i_dim in range(len(input_dims)):
                if get_dim_from_type_proto(input_dims[i_dim]) is None:
                    # some models use None for symbolic dim in input, replace it with a string
                    input_dims[i_dim].dim_param = self._new_symbolic_dim(i.name,
                                                                         i_dim)
            self.input_symbols_.update([
                d for d in get_shape_from_type_proto(i.type) if type(d) == str
            ])

        for s in self.input_symbols_:
            if s in self.suggested_merge_:
                s_merge = self.suggested_merge_[s]
                assert s_merge in self.symbolic_dims_
                self.symbolic_dims_[s] = self.symbolic_dims_[s_merge]
            else:
                self.symbolic_dims_[s] = sympy.Symbol(s, integer=True)

        # create a temporary ModelProto for single node inference
        # note that we remove initializer to have faster inference
        # for tensor ops like Reshape/Tile/Expand that read initializer, we need to do sympy computation based inference anyways
        self.tmp_mp_ = onnx.ModelProto()
        self.tmp_mp_.CopyFrom(self.out_mp_)
        self.tmp_mp_.graph.ClearField('initializer')

        for node in self.out_mp_.graph.node:
            assert all([i in self.known_vi_ for i in node.input if i])
            self._onnx_infer_single_node(node)
            if node.op_type in self.dispatcher_:
                self.dispatcher_[node.op_type](node)
            elif node.op_type in ['ConvTranspose']:
                # onnx shape inference ops like ConvTranspose may have empty shape for symbolic input
                # before adding symbolic compute for them
                # mark the output type as UNDEFINED to allow guessing of rank
                vi = self.known_vi_[node.output[0]]
                if len(vi.type.tensor_type.shape.dim) == 0:
                    vi.type.tensor_type.elem_type = onnx.TensorProto.UNDEFINED

            if self.verbose_ > 2:
                print(node.op_type + ': ' + node.name)
                for i, name in enumerate(node.input):
                    print('  Input {}: {} {}'.format(
                        i, name, 'initializer'
                        if name in self.initializers_ else ''))

            # onnx automatically merge dims with value, i.e. Mul(['aaa', 'bbb'], [1000, 1]) -> [1000, 'bbb']
            # symbolic shape inference needs to apply merge of 'aaa' -> 1000 in this case
            if node.op_type in [
                    'Add', 'Sub', 'Mul', 'Div', 'MatMul', 'MatMulInteger',
                    'MatMulInteger16', 'Where', 'Sum'
            ]:
                vi = self.known_vi_[node.output[0]]
                out_rank = len(get_shape_from_type_proto(vi.type))
                in_shapes = [
                    self._get_shape(node, i) for i in range(len(node.input))
                ]
                for d in range(out_rank - (2 if node.op_type in [
                        'MatMul', 'MatMulInteger', 'MatMulInteger16'
                ] else 0)):
                    in_dims = [
                        s[len(s) - out_rank + d] for s in in_shapes
                        if len(s) + d >= out_rank
                    ]
                    if len(in_dims) > 1:
                        self._check_merged_dims(in_dims, allow_broadcast=True)

            for i_o in range(len(node.output)):
                vi = self.known_vi_[node.output[i_o]]
                out_type = vi.type
                out_type_kind = out_type.WhichOneof('value')
                # only TensorProto and SparseTensorProto have shape
                if out_type_kind != 'tensor_type' and out_type_kind != 'sparse_tensor_type':
                    continue
                out_shape = get_shape_from_type_proto(vi.type)
                out_type_undefined = out_type.tensor_type.elem_type == onnx.TensorProto.UNDEFINED
                if self.verbose_ > 2:
                    print('  {}: {} {}'.format(node.output[
                        i_o], str(out_shape), vi.type.tensor_type.elem_type))
                    if node.output[i_o] in self.sympy_data_:
                        print('  Sympy Data: ' + str(self.sympy_data_[
                            node.output[i_o]]))

                if None in out_shape or out_type_undefined:
                    if self.auto_merge_:
                        if node.op_type in [
                                'Add', 'Sub', 'Mul', 'Div', 'MatMul',
                                'MatMulInteger', 'MatMulInteger16', 'Concat',
                                'Where', 'Sum'
                        ]:
                            shapes = [
                                self._get_shape(node, i)
                                for i in range(len(node.input))
                            ]
                            if node.op_type in [
                                    'MatMul', 'MatMulInteger', 'MatMulInteger16'
                            ]:
                                if None in out_shape:
                                    idx = out_shape.index(None)
                                    dim_idx = [
                                        len(s) - len(out_shape) + idx
                                        for s in shapes
                                    ]
                                    # only support auto merge for MatMul for dim < rank-2 when rank > 2
                                    assert len(shapes[0]) > 2 and dim_idx[
                                        0] < len(shapes[0]) - 2
                                    assert len(shapes[1]) > 2 and dim_idx[
                                        1] < len(shapes[1]) - 2
                        elif node.op_type == 'Expand':
                            # auto merge for cases like Expand([min(batch, 1), min(seq, 512)], [batch, seq])
                            shapes = [
                                self._get_shape(node, 0), self._get_value(node,
                                                                          1)
                            ]
                        else:
                            shapes = []

                        if shapes:
                            for idx in range(len(out_shape)):
                                if out_shape[idx] is not None:
                                    continue
                                dim_idx = [
                                    len(s) - len(out_shape) + idx
                                    for s in shapes
                                ]
                                assert all([d >= 0 for d in dim_idx])
                                self._add_suggested_merge([
                                    s[i] if is_literal(s[i]) else str(s[i])
                                    for s, i in zip(shapes, dim_idx)
                                ])
                            self.run_ = True
                        else:
                            self.run_ = False
                    else:
                        self.run_ = False

                    # create new dynamic dims for ops not handled by symbolic shape inference
                    if self.run_ == False and not node.op_type in self.dispatcher_:
                        is_unknown_op = (out_type_undefined and
                                         len(out_shape) == 0)
                        if is_unknown_op:
                            # unknown op to ONNX, maybe from higher opset or other domain
                            # only guess the output rank from input 0 when using guess_output_rank option
                            out_rank = self._get_shape_rank(
                                node, 0) if self.guess_output_rank_ else -1
                        else:
                            # valid ONNX op, but not handled by symbolic shape inference, just assign dynamic shape
                            out_rank = len(out_shape)

                        if out_rank >= 0:
                            new_shape = self._new_symbolic_shape(out_rank, node,
                                                                 i_o)
                            vi.CopyFrom(
                                helper.make_tensor_value_info(
                                    vi.name, self.known_vi_[node.input[
                                        0]].type.tensor_type.elem_type,
                                    get_shape_from_sympy_shape(new_shape)))

                            if self.verbose_ > 0:
                                if is_unknown_op:
                                    print(
                                        "Possible unknown op: {} node: {}, guessing {} shape".
                                        format(node.op_type, node.name,
                                               vi.name))
                                if self.verbose_ > 2:
                                    print('  {}: {} {}'.format(
                                        node.output[i_o],
                                        str(new_shape),
                                        vi.type.tensor_type.elem_type))

                            self.run_ = True
                            continue  # continue the inference after guess, no need to stop as no merge is needed

                    if self.verbose_ > 0 or not self.auto_merge_ or out_type_undefined:
                        print('Stopping at incomplete shape inference at ' +
                              node.op_type + ': ' + node.name)
                        print('node inputs:')
                        for i in node.input:
                            print(self.known_vi_[i])
                        print('node outputs:')
                        for o in node.output:
                            print(self.known_vi_[o])
                        if self.auto_merge_ and not out_type_undefined:
                            print('Merging: ' + str(self.suggested_merge_))
                    return False

        self.run_ = False
        return True

    def _update_output_from_vi(self):
        for output in self.out_mp_.graph.output:
            if output.name in self.known_vi_:
                output.CopyFrom(self.known_vi_[output.name])

    @staticmethod
    def infer_shapes(in_mp,
                     fixed_input_shape=None,
                     int_max=2**31 - 1,
                     auto_merge=False,
                     guess_output_rank=False,
                     verbose=0):
        assert version.parse(onnx.__version__) >= version.parse("1.5.0")
        onnx_opset = get_opset(in_mp)
        if not onnx_opset or onnx_opset < 7:
            print('[WARNING] Symbolic shape inference only support models of onnx opset 7 and above.')
            return
        symbolic_shape_inference = SymbolicShapeInference(
            int_max, auto_merge, guess_output_rank, verbose)
        all_shapes_inferred = False
        symbolic_shape_inference._preprocess(
            in_mp, input_shapes=fixed_input_shape)
        try:
            while symbolic_shape_inference.run_:
                all_shapes_inferred = symbolic_shape_inference._infer_impl(
                    in_mp)
            symbolic_shape_inference._update_output_from_vi()
            if not all_shapes_inferred:
                symbolic_shape_inference.out_mp_ = shape_inference.infer_shapes(
                    symbolic_shape_inference.out_mp_)
            print('[INFO] Complete symbolic shape inference.')
        except:
            print('[WARNING] Incomplete symbolic shape inference.')
            symbolic_shape_inference.out_mp_ = shape_inference.infer_shapes(
                symbolic_shape_inference.out_mp_)
        return symbolic_shape_inference.out_mp_.graph
