Unverified Commit 742b4b82 authored by kahmed10's avatar kahmed10 Committed by GitHub
Browse files

Nd deconv read support (#554)



* initial progress

* formatting

* check existing tests

* formatting

* change for loop to transform

* formatting

* add tests

* formatting

* remove comment

* add more tests

* remove extra slice axes
Co-authored-by: default avatarmvermeulen <5479696+mvermeulen@users.noreply.github.com>
parent 158bf57c
...@@ -19,9 +19,9 @@ namespace op { ...@@ -19,9 +19,9 @@ namespace op {
struct deconvolution struct deconvolution
{ {
std::array<std::size_t, 2> padding = {{0, 0}}; std::vector<std::size_t> padding = {0, 0};
std::array<std::size_t, 2> stride = {{1, 1}}; std::vector<std::size_t> stride = {1, 1};
std::array<std::size_t, 2> dilation = {{1, 1}}; std::vector<std::size_t> dilation = {1, 1};
padding_mode_t padding_mode = default_; padding_mode_t padding_mode = default_;
int group = 1; int group = 1;
...@@ -39,25 +39,27 @@ struct deconvolution ...@@ -39,25 +39,27 @@ struct deconvolution
std::string name() const { return "deconvolution"; } std::string name() const { return "deconvolution"; }
shape compute_shape(std::vector<shape> inputs) const shape compute_shape(std::vector<shape> inputs) const
{ {
check_shapes{inputs, *this}.has(2).same_type().same_ndims().only_dims(4); check_shapes{inputs, *this}.has(2).same_type().same_ndims().min_ndims(3);
if(not(padding.size() == stride.size() and padding.size() == dilation.size()))
{
MIGRAPHX_THROW("deconvolution: inconsistent attribute sizes");
}
const shape& input = inputs.at(0); const shape& input = inputs.at(0);
const shape& weights = inputs.at(1); const shape& weights = inputs.at(1);
auto t = input.type(); auto t = input.type();
size_t kdims = input.lens().size() - 2;
return {t, std::vector<size_t> output_lens{input.lens()[0], weights.lens()[1]};
{
input.lens()[0], for(size_t i = 0; i < kdims; i++)
weights.lens()[1], {
std::size_t(std::max<std::ptrdiff_t>( output_lens.push_back(std::size_t(std::max<std::ptrdiff_t>(
1, 1,
stride[0] * (input.lens()[2] - 1) + stride[i] * (input.lens()[i + 2] - 1) +
((weights.lens()[2] - 1) * dilation[0] + 1) - 2 * padding[0])), ((weights.lens()[i + 2] - 1) * dilation[i] + 1) - 2 * padding[i])));
std::size_t(std::max<std::ptrdiff_t>( }
1, return {t, output_lens};
stride[1] * (input.lens()[3] - 1) +
((weights.lens()[3] - 1) * dilation[1] + 1) - 2 * padding[1])),
}};
} }
}; };
......
...@@ -320,29 +320,32 @@ struct onnx_parser ...@@ -320,29 +320,32 @@ struct onnx_parser
return curr_ins; return curr_ins;
} }
template <class Op> bool is_asym_padding(const std::vector<int64_t>& padding)
void check_asym_padding(instruction_ref& ins,
const std::vector<int64_t>& padding,
Op& op,
float pad_val = 0)
{ {
bool asym_padding = false;
assert(padding.size() % 2 == 0); assert(padding.size() % 2 == 0);
size_t pad_ndims = padding.size() / 2; size_t pad_ndims = padding.size() / 2;
auto left_pad_it = padding.begin();
auto right_pad_it = left_pad_it + pad_ndims;
for(size_t i = 0; i < pad_ndims; i++) for(size_t i = 0; i < pad_ndims; i++)
{ {
if(padding[i] != padding[i + pad_ndims]) if(padding[i] != padding[i + pad_ndims])
{ {
asym_padding = true; return true;
break;
} }
} }
return false;
}
if(asym_padding) template <class Op>
void check_asym_padding(instruction_ref& ins,
const std::vector<int64_t>& padding,
Op& op,
float pad_val = 0)
{
size_t pad_ndims = padding.size() / 2;
auto left_pad_it = padding.begin();
auto right_pad_it = left_pad_it + pad_ndims;
if(is_asym_padding(padding))
{ {
std::vector<int64_t> asym_pads{0, 0, 0, 0}; // don't pad N and C std::vector<int64_t> asym_pads{0, 0, 0, 0}; // don't pad N and C
// add left pads // add left pads
...@@ -654,7 +657,11 @@ struct onnx_parser ...@@ -654,7 +657,11 @@ struct onnx_parser
op::deconvolution op; op::deconvolution op;
auto l0 = args[0]; auto l0 = args[0];
std::vector<std::int64_t> padding; std::vector<std::int64_t> padding;
bool asymm_padding = false; bool asym_padding = false;
auto in_lens = l0->get_shape().lens();
assert(in_lens.size() > 2);
auto kdims = in_lens.size() - 2;
if(contains(info.attributes, "pads")) if(contains(info.attributes, "pads"))
{ {
if(contains(info.attributes, "auto_pad")) if(contains(info.attributes, "auto_pad"))
...@@ -662,38 +669,45 @@ struct onnx_parser ...@@ -662,38 +669,45 @@ struct onnx_parser
auto s = info.attributes["auto_pad"].s(); auto s = info.attributes["auto_pad"].s();
if(contains(info.attributes, "pads") and to_upper(s) != "NOTSET") if(contains(info.attributes, "pads") and to_upper(s) != "NOTSET")
{ {
MIGRAPHX_THROW("auto_pad and padding cannot be specified simultaneously"); MIGRAPHX_THROW("PARSE_CONV_TRANSPOSE: auto_pad and padding cannot be specified "
"simultaneously");
} }
} }
copy(info.attributes["pads"].ints(), std::back_inserter(padding)); copy(info.attributes["pads"].ints(), std::back_inserter(padding));
if(padding.size() != 4)
{ asym_padding = is_asym_padding(padding);
MIGRAPHX_THROW("padding should have 4 values");
} if(not asym_padding)
if(padding[0] != padding[2] || padding[1] != padding[3])
{
asymm_padding = true;
}
else
{ {
op.padding[0] = padding[0]; size_t pad_ndims = padding.size() / 2;
op.padding[1] = padding[1]; check_attr_sizes(kdims, pad_ndims, "PARSE_CONV_TRANSPOSE: inconsistent paddings");
op.padding.clear();
std::transform(padding.begin(),
padding.begin() + pad_ndims,
std::back_inserter(op.padding),
[](auto pad_val) { return pad_val; });
} }
} }
if(contains(info.attributes, "strides")) if(contains(info.attributes, "strides"))
{ {
copy(info.attributes["strides"].ints(), op.stride.begin()); op.stride.clear();
copy(info.attributes["strides"].ints(), std::back_inserter(op.stride));
check_attr_sizes(kdims, op.stride.size(), "PARSE_CONV_TRANSPOSE: inconsistent strides");
} }
if(contains(info.attributes, "dilations")) if(contains(info.attributes, "dilations"))
{ {
copy(info.attributes["dilations"].ints(), op.dilation.begin()); op.dilation.clear();
copy(info.attributes["dilations"].ints(), std::back_inserter(op.dilation));
check_attr_sizes(
kdims, op.dilation.size(), "PARSE_CONV_TRANSPOSE: inconsistent dilations");
} }
if(contains(info.attributes, "auto_pad")) if(contains(info.attributes, "auto_pad"))
{ {
auto s = info.attributes["auto_pad"].s(); auto s = info.attributes["auto_pad"].s();
if(contains(info.attributes, "pads") and to_upper(s) != "NOTSET") if(contains(info.attributes, "pads") and to_upper(s) != "NOTSET")
{ {
MIGRAPHX_THROW("auto_pad and padding cannot be specified simultaneously"); MIGRAPHX_THROW("PARSE_CONV_TRANSPOSE: auto_pad and padding cannot be specified "
"simultaneously");
} }
if(s.find("SAME") != std::string::npos) if(s.find("SAME") != std::string::npos)
...@@ -707,44 +721,56 @@ struct onnx_parser ...@@ -707,44 +721,56 @@ struct onnx_parser
op.group = parse_value(info.attributes.at("group")).at<int>(); op.group = parse_value(info.attributes.at("group")).at<int>();
} }
recalc_conv_attributes(op, kdims);
auto l1 = prog.add_instruction(op, l0, args[1]); auto l1 = prog.add_instruction(op, l0, args[1]);
std::vector<int64_t> dims = to_int64_vector(l1->get_shape().lens()); std::vector<int64_t> dims = to_int64_vector(l1->get_shape().lens());
std::vector<int64_t> curr_shape{dims[2], dims[3]}; std::vector<int64_t> curr_shape(dims.begin() + 2, dims.end());
if(asymm_padding) if(asym_padding)
{ {
op::slice slice_op; std::vector<int64_t> axes(kdims);
slice_op.axes = {0, 1, 2, 3}; std::iota(axes.begin(), axes.end(), 2); // ignore first 2 dims
slice_op.starts = {0, 0, 0 + padding[0], 0 + padding[1]};
slice_op.ends = { auto pad_kdim_start = padding.begin() + kdims;
dims[0], dims[1], curr_shape[0] - padding[2], curr_shape[1] - padding[3]}; std::vector<int64_t> starts(padding.begin(), pad_kdim_start);
std::vector<int64_t> ends{};
std::transform(curr_shape.begin(),
curr_shape.end(),
pad_kdim_start,
std::back_inserter(ends),
[](auto curr_dim, auto pad_dim) { return curr_dim - pad_dim; });
l1 = prog.add_instruction(slice_op, l1); l1 = prog.add_instruction(op::slice{axes, starts, ends}, l1);
} }
if(contains(info.attributes, "output_padding")) if(contains(info.attributes, "output_padding"))
{ {
std::vector<int64_t> output_padding; size_t non_kdims = dims.size() * 2 - kdims;
std::vector<int64_t> output_padding(non_kdims, 0);
copy(info.attributes["output_padding"].ints(), std::back_inserter(output_padding)); copy(info.attributes["output_padding"].ints(), std::back_inserter(output_padding));
output_padding = {0, 0, 0, 0, 0, 0, output_padding[0], output_padding[1]}; check_attr_sizes(kdims,
l1 = prog.add_instruction(op::pad{output_padding}, l1); output_padding.size() - non_kdims,
"PARSE_CONV_TRANSPOSE: inconsistent output padding");
l1 = prog.add_instruction(op::pad{output_padding}, l1);
} }
if(contains(info.attributes, "output_shape")) if(contains(info.attributes, "output_shape"))
{ {
std::vector<int64_t> output_shape; std::vector<int64_t> output_shape;
copy(info.attributes["output_shape"].ints(), std::back_inserter(output_shape)); copy(info.attributes["output_shape"].ints(), std::back_inserter(output_shape));
dims = to_int64_vector(l1->get_shape().lens()); check_attr_sizes(
curr_shape = {dims[2], dims[3]}; kdims, output_shape.size(), "PARSE_CONV_TRANSPOSE: inconsistent output shape");
dims = to_int64_vector(l1->get_shape().lens());
copy(dims.begin() + 2, dims.end(), curr_shape.begin());
if(curr_shape != output_shape) if(curr_shape != output_shape)
{ {
std::vector<int64_t> target_padding = {0, std::vector<int64_t> target_padding(dims.size() * 2 - kdims, 0);
0, std::transform(output_shape.begin(),
0, output_shape.end(),
0, curr_shape.begin(),
0, std::back_inserter(target_padding),
0, [](auto out_dim, auto curr_dim) { return out_dim - curr_dim; });
output_shape[0] - curr_shape[0],
output_shape[1] - curr_shape[1]};
l1 = prog.add_instruction(op::pad{target_padding}, l1); l1 = prog.add_instruction(op::pad{target_padding}, l1);
} }
} }
......
deconv_output_padding_3d_test:
G
x
wy" ConvTranspose*
output_padding@@@*
strides@@@deconv_output_padding_3d_testZ
x





Z
w





b
y





B
\ No newline at end of file
deconv_output_shape_3d_test:
E
x
wy" ConvTranspose*
output_shape@
@@*
strides@@@deconv_output_shape_3d_testZ
x





Z
w





b
y





B
\ No newline at end of file
...@@ -871,7 +871,23 @@ def deconv_input_pads_asymm_test(): ...@@ -871,7 +871,23 @@ def deconv_input_pads_asymm_test():
@onnx_test @onnx_test
def deconv_output_shape_test(): def deconv_input_pads_asymm_1d_test():
x = helper.make_tensor_value_info('x', TensorProto.FLOAT, [1, 1, 3])
w = helper.make_tensor_value_info('w', TensorProto.FLOAT, [1, 2, 3])
y = helper.make_tensor_value_info('y', TensorProto.FLOAT, [1, 2, 6])
node = onnx.helper.make_node('ConvTranspose',
inputs=['x', 'w'],
outputs=['y'],
strides=[2],
pads=[0, 1],
dilations=[1])
return ([node], [x, w], [y])
@onnx_test
def deconv_output_padding_test():
x = helper.make_tensor_value_info('x', TensorProto.FLOAT, [1, 1, 3, 3]) x = helper.make_tensor_value_info('x', TensorProto.FLOAT, [1, 1, 3, 3])
w = helper.make_tensor_value_info('w', TensorProto.FLOAT, [1, 2, 3, 3]) w = helper.make_tensor_value_info('w', TensorProto.FLOAT, [1, 2, 3, 3])
y = helper.make_tensor_value_info('y', TensorProto.FLOAT, [1, 2, 10, 8]) y = helper.make_tensor_value_info('y', TensorProto.FLOAT, [1, 2, 10, 8])
...@@ -880,13 +896,28 @@ def deconv_output_shape_test(): ...@@ -880,13 +896,28 @@ def deconv_output_shape_test():
inputs=['x', 'w'], inputs=['x', 'w'],
outputs=['y'], outputs=['y'],
strides=[3, 2], strides=[3, 2],
output_shape=[10, 8]) output_padding=[1, 1])
return ([node], [x, w], [y]) return ([node], [x, w], [y])
@onnx_test @onnx_test
def deconv_output_padding_test(): def deconv_output_padding_3d_test():
x = helper.make_tensor_value_info('x', TensorProto.FLOAT, [1, 1, 3, 3, 3])
w = helper.make_tensor_value_info('w', TensorProto.FLOAT, [1, 2, 3, 3, 3])
y = helper.make_tensor_value_info('y', TensorProto.FLOAT, [1, 2, 10, 8, 8])
node = onnx.helper.make_node('ConvTranspose',
inputs=['x', 'w'],
outputs=['y'],
strides=[3, 2, 2],
output_padding=[1, 1, 1])
return ([node], [x, w], [y])
@onnx_test
def deconv_output_shape_test():
x = helper.make_tensor_value_info('x', TensorProto.FLOAT, [1, 1, 3, 3]) x = helper.make_tensor_value_info('x', TensorProto.FLOAT, [1, 1, 3, 3])
w = helper.make_tensor_value_info('w', TensorProto.FLOAT, [1, 2, 3, 3]) w = helper.make_tensor_value_info('w', TensorProto.FLOAT, [1, 2, 3, 3])
y = helper.make_tensor_value_info('y', TensorProto.FLOAT, [1, 2, 10, 8]) y = helper.make_tensor_value_info('y', TensorProto.FLOAT, [1, 2, 10, 8])
...@@ -895,7 +926,22 @@ def deconv_output_padding_test(): ...@@ -895,7 +926,22 @@ def deconv_output_padding_test():
inputs=['x', 'w'], inputs=['x', 'w'],
outputs=['y'], outputs=['y'],
strides=[3, 2], strides=[3, 2],
output_padding=[1, 1]) output_shape=[10, 8])
return ([node], [x, w], [y])
@onnx_test
def deconv_output_shape_3d_test():
x = helper.make_tensor_value_info('x', TensorProto.FLOAT, [1, 1, 3, 3, 3])
w = helper.make_tensor_value_info('w', TensorProto.FLOAT, [1, 2, 3, 3, 3])
y = helper.make_tensor_value_info('y', TensorProto.FLOAT, [1, 2, 10, 8, 8])
node = onnx.helper.make_node('ConvTranspose',
inputs=['x', 'w'],
outputs=['y'],
strides=[3, 2, 2],
output_shape=[10, 8, 8])
return ([node], [x, w], [y]) return ([node], [x, w], [y])
......
...@@ -633,13 +633,25 @@ TEST_CASE(deconv_input_pads_asymm_test) ...@@ -633,13 +633,25 @@ TEST_CASE(deconv_input_pads_asymm_test)
auto l0 = p.add_parameter("x", {migraphx::shape::float_type, {1, 1, 3, 3}}); auto l0 = p.add_parameter("x", {migraphx::shape::float_type, {1, 1, 3, 3}});
auto l1 = p.add_parameter("w", {migraphx::shape::float_type, {1, 2, 3, 3}}); auto l1 = p.add_parameter("w", {migraphx::shape::float_type, {1, 2, 3, 3}});
auto l2 = p.add_instruction(migraphx::op::deconvolution{{0, 0}, {3, 2}}, l0, l1); auto l2 = p.add_instruction(migraphx::op::deconvolution{{0, 0}, {3, 2}}, l0, l1);
p.add_instruction(migraphx::op::slice{{0, 1, 2, 3}, {0, 0, 0, 0}, {1, 2, 8, 6}}, l2); p.add_instruction(migraphx::op::slice{{2, 3}, {0, 0}, {8, 6}}, l2);
auto prog = optimize_onnx("deconv_input_pads_asymm_test.onnx"); auto prog = optimize_onnx("deconv_input_pads_asymm_test.onnx");
EXPECT(p == prog); EXPECT(p == prog);
} }
TEST_CASE(deconv_output_shape_test) TEST_CASE(deconv_input_pads_asymm_1d_test)
{
migraphx::program p;
auto l0 = p.add_parameter("x", {migraphx::shape::float_type, {1, 1, 3}});
auto l1 = p.add_parameter("w", {migraphx::shape::float_type, {1, 2, 3}});
auto l2 = p.add_instruction(migraphx::op::deconvolution{{0}, {2}, {1}}, l0, l1);
p.add_instruction(migraphx::op::slice{{2}, {0}, {6}}, l2);
auto prog = optimize_onnx("deconv_input_pads_asymm_1d_test.onnx");
EXPECT(p == prog);
}
TEST_CASE(deconv_output_padding_test)
{ {
migraphx::program p; migraphx::program p;
auto l0 = p.add_parameter("x", {migraphx::shape::float_type, {1, 1, 3, 3}}); auto l0 = p.add_parameter("x", {migraphx::shape::float_type, {1, 1, 3, 3}});
...@@ -647,11 +659,24 @@ TEST_CASE(deconv_output_shape_test) ...@@ -647,11 +659,24 @@ TEST_CASE(deconv_output_shape_test)
auto l2 = p.add_instruction(migraphx::op::deconvolution{{0, 0}, {3, 2}}, l0, l1); auto l2 = p.add_instruction(migraphx::op::deconvolution{{0, 0}, {3, 2}}, l0, l1);
p.add_instruction(migraphx::op::pad{{0, 0, 0, 0, 0, 0, 1, 1}}, l2); p.add_instruction(migraphx::op::pad{{0, 0, 0, 0, 0, 0, 1, 1}}, l2);
auto prog = optimize_onnx("deconv_output_shape_test.onnx"); auto prog = optimize_onnx("deconv_output_padding_test.onnx");
EXPECT(p == prog); EXPECT(p == prog);
} }
TEST_CASE(deconv_output_padding_test) TEST_CASE(deconv_output_padding_3d_test)
{
migraphx::program p;
auto l0 = p.add_parameter("x", {migraphx::shape::float_type, {1, 1, 3, 3, 3}});
auto l1 = p.add_parameter("w", {migraphx::shape::float_type, {1, 2, 3, 3, 3}});
auto l2 =
p.add_instruction(migraphx::op::deconvolution{{0, 0, 0}, {3, 2, 2}, {1, 1, 1}}, l0, l1);
p.add_instruction(migraphx::op::pad{{0, 0, 0, 0, 0, 0, 0, 1, 1, 1}}, l2);
auto prog = optimize_onnx("deconv_output_padding_3d_test.onnx");
EXPECT(p == prog);
}
TEST_CASE(deconv_output_shape_test)
{ {
migraphx::program p; migraphx::program p;
auto l0 = p.add_parameter("x", {migraphx::shape::float_type, {1, 1, 3, 3}}); auto l0 = p.add_parameter("x", {migraphx::shape::float_type, {1, 1, 3, 3}});
...@@ -659,7 +684,20 @@ TEST_CASE(deconv_output_padding_test) ...@@ -659,7 +684,20 @@ TEST_CASE(deconv_output_padding_test)
auto l2 = p.add_instruction(migraphx::op::deconvolution{{0, 0}, {3, 2}}, l0, l1); auto l2 = p.add_instruction(migraphx::op::deconvolution{{0, 0}, {3, 2}}, l0, l1);
p.add_instruction(migraphx::op::pad{{0, 0, 0, 0, 0, 0, 1, 1}}, l2); p.add_instruction(migraphx::op::pad{{0, 0, 0, 0, 0, 0, 1, 1}}, l2);
auto prog = optimize_onnx("deconv_output_padding_test.onnx"); auto prog = optimize_onnx("deconv_output_shape_test.onnx");
EXPECT(p == prog);
}
TEST_CASE(deconv_output_shape_3d_test)
{
migraphx::program p;
auto l0 = p.add_parameter("x", {migraphx::shape::float_type, {1, 1, 3, 3, 3}});
auto l1 = p.add_parameter("w", {migraphx::shape::float_type, {1, 2, 3, 3, 3}});
auto l2 =
p.add_instruction(migraphx::op::deconvolution{{0, 0, 0}, {3, 2, 2}, {1, 1, 1}}, l0, l1);
p.add_instruction(migraphx::op::pad{{0, 0, 0, 0, 0, 0, 0, 1, 1, 1}}, l2);
auto prog = optimize_onnx("deconv_output_shape_3d_test.onnx");
EXPECT(p == prog); EXPECT(p == prog);
} }
......
...@@ -89,6 +89,28 @@ TEST_CASE(convolution_shape) ...@@ -89,6 +89,28 @@ TEST_CASE(convolution_shape)
weights_3d); weights_3d);
} }
TEST_CASE(deconvolution_shape)
{
migraphx::shape input{migraphx::shape::float_type, {4, 4, 1, 1}};
migraphx::shape output{migraphx::shape::float_type, {4, 3, 3, 3}};
migraphx::shape weights{migraphx::shape::float_type, {4, 3, 3, 3}};
expect_shape(output, migraphx::op::deconvolution{}, input, weights);
throws_shape(migraphx::op::deconvolution{}, input);
migraphx::shape input_1d{migraphx::shape::float_type, {4, 4, 1}};
migraphx::shape output_1d{migraphx::shape::float_type, {4, 3, 3}};
migraphx::shape weights_1d{migraphx::shape::float_type, {4, 3, 3}};
expect_shape(output_1d, migraphx::op::deconvolution{{0}, {1}, {1}}, input_1d, weights_1d);
migraphx::shape input_3d{migraphx::shape::float_type, {4, 4, 1, 1, 1}};
migraphx::shape output_3d{migraphx::shape::float_type, {4, 3, 3, 3, 3}};
migraphx::shape weights_3d{migraphx::shape::float_type, {4, 3, 3, 3, 3}};
expect_shape(output_3d,
migraphx::op::deconvolution{{0, 0, 0}, {1, 1, 1}, {1, 1, 1}},
input_3d,
weights_3d);
}
TEST_CASE(quant_convolution_shape) TEST_CASE(quant_convolution_shape)
{ {
migraphx::shape output{migraphx::shape::int32_type, {4, 4, 1, 1}}; migraphx::shape output{migraphx::shape::int32_type, {4, 4, 1, 1}};
...@@ -115,6 +137,7 @@ TEST_CASE(inconsistent_attr_shape) ...@@ -115,6 +137,7 @@ TEST_CASE(inconsistent_attr_shape)
migraphx::shape input{migraphx::shape::float_type, {4, 3, 3, 3}}; migraphx::shape input{migraphx::shape::float_type, {4, 3, 3, 3}};
migraphx::shape weights{migraphx::shape::float_type, {4, 3, 3, 3}}; migraphx::shape weights{migraphx::shape::float_type, {4, 3, 3, 3}};
throws_shape(migraphx::op::convolution{{1, 1}, {2}, {3, 3, 3}}, input, weights); throws_shape(migraphx::op::convolution{{1, 1}, {2}, {3, 3, 3}}, input, weights);
throws_shape(migraphx::op::deconvolution{{1, 1}, {2}, {3, 3, 3}}, input, weights);
throws_shape(migraphx::op::pooling{"max", {1}, {0}, {1, 1}}, input); throws_shape(migraphx::op::pooling{"max", {1}, {0}, {1, 1}}, input);
} }
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment