[MOC] Implement LSTMCell fusion for Keras LSTM layers (#24873)

**Details:** Implement LSTMCell fusion for Keras LSTM layers. Such
sub-graph with split weights appears for bi-directional LSTM layer.
For custom model, we managed to fuse 6 LSTMCell sub-graphs and obtained
19% performance gain on CPU.

**Ticket:** 142924

---------

Signed-off-by: Kazantsev, Roman <roman.kazantsev@intel.com>
This commit is contained in:
Roman Kazantsev 2024-06-10 12:28:01 +04:00 committed by GitHub
parent 3f47ab6d73
commit 5291ecc11c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 523 additions and 15 deletions

View File

@ -1,4 +1,4 @@
// Copyright (C) 2023 Intel Corporation
// Copyright (C) 2023-2024 Intel Corporation
// SPDX-License-Identifier: Apache-2.0
//
@ -11,17 +11,43 @@ namespace ov {
namespace pass {
class TRANSFORMATIONS_API LSTMCellFusion;
class TRANSFORMATIONS_API LSTMCellFusionWithJointWeights;
class TRANSFORMATIONS_API LSTMCellFusionWithSplitWeights;
} // namespace pass
} // namespace ov
/**
* @ingroup ov_transformation_common_api
* @brief LSTMCellFusion transformation replaces a sequence of
* operations with LSTMCell op.
* @brief LSTMCellFusionWithJointWeights transformation converts
* a sequence of operations with merged weights into LSTMCell op.
*/
class ov::pass::LSTMCellFusion : public ov::pass::MatcherPass {
class ov::pass::LSTMCellFusionWithJointWeights : public ov::pass::MatcherPass {
public:
OPENVINO_RTTI("LSTMCellFusionWithJointWeights", "0");
LSTMCellFusionWithJointWeights();
};
/**
* @ingroup ov_transformation_common_api
* @brief LSTMCellFusionWithSplitWeights transformation converts
* a sequence of operations with split weights into LSTMCell op.
*/
class ov::pass::LSTMCellFusionWithSplitWeights : public ov::pass::MatcherPass {
public:
OPENVINO_RTTI("LSTMCellFusionWithSplitWeights", "0");
LSTMCellFusionWithSplitWeights();
};
/**
* @ingroup ov_transformation_common_api
* @brief LSTMCellFusion transformation replaces various sub-graphs with a LSTMCell op.
*/
class ov::pass::LSTMCellFusion : public ov::pass::GraphRewrite {
public:
OPENVINO_RTTI("LSTMCellFusion", "0");
LSTMCellFusion();
LSTMCellFusion() {
add_matcher<ov::pass::LSTMCellFusionWithJointWeights>();
add_matcher<ov::pass::LSTMCellFusionWithSplitWeights>();
}
};

View File

@ -1,4 +1,4 @@
// Copyright (C) 2023 Intel Corporation
// Copyright (C) 2023-2024 Intel Corporation
// SPDX-License-Identifier: Apache-2.0
//
@ -23,6 +23,107 @@
#include "openvino/pass/pattern/op/wrap_type.hpp"
#include "transformations/utils/utils.hpp"
using namespace ov;
using namespace ov::pass;
namespace {
static std::string get_activation_name(const std::shared_ptr<ov::Node>& node) {
std::string name = node->get_type_name();
name[0] = std::tolower(name[0]);
return name;
}
void generate_gate_pattern(const std::shared_ptr<ov::Node>& x,
const std::shared_ptr<ov::Node>& h,
std::shared_ptr<ov::Node>& it,
std::shared_ptr<ov::Node>& wi,
std::shared_ptr<ov::Node>& ri,
std::shared_ptr<ov::Node>& bi,
std::shared_ptr<ov::Node>& x_by_wi,
std::shared_ptr<ov::Node>& h_by_ri) {
wi = pattern::any_input([](const Output<Node>& output) {
return pattern::has_static_shape()(output) && pattern::rank_equals(2)(output);
});
ri = pattern::any_input([](const Output<Node>& output) {
return pattern::has_static_shape()(output) && pattern::rank_equals(2)(output);
});
bi = pattern::any_input([](const Output<Node>& output) {
return pattern::has_static_shape()(output) && pattern::rank_equals(1)(output);
});
x_by_wi = pattern::wrap_type<op::v0::MatMul>({x, wi});
auto x_by_wi_biased = pattern::wrap_type<op::v1::Add>({x_by_wi, bi});
h_by_ri = pattern::wrap_type<op::v0::MatMul>({h, ri});
it = pattern::wrap_type<op::v1::Add>({x_by_wi_biased, h_by_ri});
it = pattern::wrap_type<op::v0::Relu, op::v0::Sigmoid, op::v0::Tanh>({it});
}
bool check_weights_format(const ov::Output<ov::Node>& w,
const ov::Output<ov::Node>& r,
const ov::Output<ov::Node>& b,
size_t input_size,
size_t hidden_size,
const ov::pass::pattern::PatternValueMap& pattern_map,
const std::shared_ptr<Node>& x_by_w_label,
const std::shared_ptr<Node>& h_by_r_label) {
// w must be of a shape [input_size, hidden_size]
// r must be of a shape [hidden_size, hidden_size]
// b must be of a shape [hidden_size]
if (w.get_shape() != ov::Shape{input_size, hidden_size}) {
return false;
}
if (r.get_shape() != ov::Shape{hidden_size, hidden_size}) {
return false;
}
if (b.get_shape() != ov::Shape{hidden_size}) {
return false;
}
// check transpose attributes for MatMul operations
if (const auto& matmul = ov::as_type_ptr<op::v0::MatMul>(pattern_map.at(x_by_w_label).get_node_shared_ptr())) {
if (matmul->get_transpose_a() || matmul->get_transpose_b()) {
return false;
}
} else {
return false;
}
if (const auto& matmul = ov::as_type_ptr<op::v0::MatMul>(pattern_map.at(h_by_r_label).get_node_shared_ptr())) {
if (matmul->get_transpose_a() || matmul->get_transpose_b()) {
return false;
}
} else {
return false;
}
return true;
}
ov::Output<ov::Node> prepare_weight_fico(const ov::Output<ov::Node>& f,
const ov::Output<ov::Node>& i,
const ov::Output<ov::Node>& c,
const ov::Output<ov::Node>& o,
NodeRegistry& rg) {
// at this point input weights are of shape [input_size, hidden_size]
// before concatenation it needs to transpose them
// to get a shape equal to [hidden_size, input_size]
auto transpose_order = rg.make<ov::op::v0::Constant>(element::i32, ov::Shape{2}, std::vector<int32_t>{1, 0});
auto f_tr = rg.make<ov::op::v1::Transpose>(f, transpose_order);
auto i_tr = rg.make<ov::op::v1::Transpose>(i, transpose_order);
auto c_tr = rg.make<ov::op::v1::Transpose>(c, transpose_order);
auto o_tr = rg.make<ov::op::v1::Transpose>(o, transpose_order);
ov::Output<ov::Node> w = rg.make<ov::op::v0::Concat>(ov::OutputVector{f_tr, i_tr, c_tr, o_tr}, 0);
if (const auto& constant = ov::util::constantfold_subgraph(w)) {
w = constant;
}
return w;
}
} // namespace
/*
The following graph is fused to LSTMCell
@ -107,15 +208,8 @@
+----------+
*/
static std::string get_activation_name(const std::shared_ptr<ov::Node>& node) {
std::string name = node->get_type_name();
name[0] = std::tolower(name[0]);
return name;
}
ov::pass::LSTMCellFusion::LSTMCellFusion() {
MATCHER_SCOPE(LSTMCellFusion);
ov::pass::LSTMCellFusionWithJointWeights::LSTMCellFusionWithJointWeights() {
MATCHER_SCOPE(LSTMCellFusionWithJointWeights);
auto x_label = pattern::any_input(pattern::rank_equals(2));
auto h_label = pattern::any_input(pattern::rank_equals(2));
@ -352,3 +446,205 @@ ov::pass::LSTMCellFusion::LSTMCellFusion() {
auto m = std::make_shared<pattern::Matcher>(Ho_label, matcher_name);
this->register_matcher(m, callback);
}
/*
The following graph is fused to LSTMCell
+----+ +----+ +----+ +----+
| It | | Ct | | Ft | | Ot |
+----+ +----+ +----+ +----+
| | | |
v | | v
+------+-----+ | | +------+-----+
| Activation | | | | Activation |
| (i_t) | | | | (o_t) |
+------+-----+ | | +------+-----+
| v | |
| +------+-----+ | |
| | Activation | | |
| | (c_t) | | |
| +------+-----+ | |
| | | |
| | v |
+---+ +---+ +------+-----+ |
| | | Activation | +-----+ |
v v | (f_t) | | C | |
+--+---+---+ +------------+ +-----+ |
| Multiply | | | |
+----+-----+ | +--------+ |
| | | |
| v v |
| +---+---+--+ |
| | Multiply | |
| +----+-----+ |
| | |
| | |
+---------+ +--------+ |
| | |
v v |
+-+-----+-+ |
| Add | |
| (C out) | |
+----+----+ |
| |
v |
+-----+------+ |
| Activation | |
+-----+------+ |
| |
| |
+----------+ +-------------------+
| |
v v
+--+----+--+
| Multiply |
| (H out) |
+----------+
where each of It, Ct, Ft, and Ot represents a separate graph:
+-----+ +-----+
| X | | W |
+--+--+ +--+--+
| |
+---+ +---+
| |
v v
+--+--+--+ +------+ +-----+ +----+
| MatMul | | Bias | | H | | R |
+----+---+ +---+--+ +-----+ +----+
| | | |
| +--------+ +---+ +---+
| | | |
v v v v
+--+--+--+ +--------+
| Add | | MatMul |
+----+---+ +--+-----+
| |
| +-----------------------------+
| |
v v
+--+---+--+
| Add |
+----+----+
|
*/
ov::pass::LSTMCellFusionWithSplitWeights::LSTMCellFusionWithSplitWeights() {
MATCHER_SCOPE(LSTMCellFusionWithSplitWeights);
auto x_label = pattern::any_input(pattern::rank_equals(2));
auto h_label = pattern::any_input(pattern::rank_equals(2));
auto c_label = pattern::any_input(pattern::rank_equals(2));
// it expects a pattern with split weights (input, recurrent and bias) for each gate
std::shared_ptr<Node> it_label, wi_label, ri_label, bi_label, x_by_wi_label, h_by_ri_label;
generate_gate_pattern(x_label, h_label, it_label, wi_label, ri_label, bi_label, x_by_wi_label, h_by_ri_label);
std::shared_ptr<Node> ft_label, wf_label, rf_label, bf_label, x_by_wf_label, h_by_rf_label;
generate_gate_pattern(x_label, h_label, ft_label, wf_label, rf_label, bf_label, x_by_wf_label, h_by_rf_label);
std::shared_ptr<Node> ot_label, wo_label, ro_label, bo_label, x_by_wo_label, h_by_ro_label;
generate_gate_pattern(x_label, h_label, ot_label, wo_label, ro_label, bo_label, x_by_wo_label, h_by_ro_label);
std::shared_ptr<Node> c1t_label, wc_label, rc_label, bc_label, x_by_wc_label, h_by_rc_label;
generate_gate_pattern(x_label, h_label, c1t_label, wc_label, rc_label, bc_label, x_by_wc_label, h_by_rc_label);
auto it_mul_c1t_label = pattern::wrap_type<op::v1::Multiply>({it_label, c1t_label});
auto ft_mul_c_label = pattern::wrap_type<op::v1::Multiply>({ft_label, c_label});
auto ct_label = pattern::wrap_type<op::v1::Add>({ft_mul_c_label, it_mul_c1t_label});
auto ct_activated_label = pattern::wrap_type<op::v0::Relu, op::v0::Sigmoid, op::v0::Tanh>({ct_label});
auto ht_label = pattern::wrap_type<op::v1::Multiply>({ct_activated_label, ot_label});
matcher_pass_callback callback = [=](pattern::Matcher& m) {
NodeRegistry rg;
const auto& pattern_map = m.get_pattern_value_map();
const auto& x = pattern_map.at(x_label);
const auto& h = pattern_map.at(h_label);
const auto& c = pattern_map.at(c_label);
const auto& wi = pattern_map.at(wi_label);
const auto& ri = pattern_map.at(ri_label);
const auto& bi = pattern_map.at(bi_label);
const auto& wf = pattern_map.at(wf_label);
const auto& rf = pattern_map.at(rf_label);
const auto& bf = pattern_map.at(bf_label);
const auto& wo = pattern_map.at(wo_label);
const auto& ro = pattern_map.at(ro_label);
const auto& bo = pattern_map.at(bo_label);
const auto& wc = pattern_map.at(wc_label);
const auto& rc = pattern_map.at(rc_label);
const auto& bc = pattern_map.at(bc_label);
const auto& it = pattern_map.at(it_label).get_node_shared_ptr();
const auto& ft = pattern_map.at(ft_label).get_node_shared_ptr();
const auto& ot = pattern_map.at(ot_label).get_node_shared_ptr();
const auto& c1t = pattern_map.at(c1t_label).get_node_shared_ptr();
const auto& ct_activated = pattern_map.at(ct_activated_label).get_node_shared_ptr();
auto ct = pattern_map.at(ct_label);
auto ht = pattern_map.at(ht_label);
// check weights format
auto input_size = static_cast<size_t>(wi.get_shape()[0]);
auto hidden_size = static_cast<size_t>(wi.get_shape()[1]);
bool weights_format_ok =
check_weights_format(wi, ri, bi, input_size, hidden_size, pattern_map, x_by_wi_label, h_by_ri_label);
weights_format_ok &=
check_weights_format(wf, rf, bf, input_size, hidden_size, pattern_map, x_by_wf_label, h_by_rf_label);
weights_format_ok &=
check_weights_format(wo, ro, bo, input_size, hidden_size, pattern_map, x_by_wo_label, h_by_ro_label);
weights_format_ok &=
check_weights_format(wc, rc, bc, input_size, hidden_size, pattern_map, x_by_wc_label, h_by_rc_label);
if (!weights_format_ok) {
return false;
}
// check activation functions
std::string f_activation_name = it->get_type_name();
if (f_activation_name != ft->get_type_name() || f_activation_name != ot->get_type_name()) {
return false;
}
f_activation_name[0] = std::tolower(f_activation_name[0]);
std::string g_activation_name = get_activation_name(c1t);
std::string h_activation_name = get_activation_name(ct_activated);
// prepare weights in a format required by LSTMCell
// W weights must be in fico format
ov::Output<ov::Node> W = prepare_weight_fico(wf, wi, wc, wo, rg);
ov::Output<ov::Node> R = prepare_weight_fico(rf, ri, rc, ro, rg);
ov::Output<ov::Node> B = rg.make<ov::op::v0::Concat>(ov::OutputVector{bf, bi, bc, bo}, 0);
if (const auto& constant = ov::util::constantfold_subgraph(B))
B = constant;
auto lstm_cell = rg.make<op::v4::LSTMCell>(
x,
h,
c,
W,
R,
B,
static_cast<size_t>(hidden_size),
std::vector<std::string>{f_activation_name, g_activation_name, h_activation_name});
if (transformation_callback(lstm_cell)) {
return false;
}
lstm_cell->set_friendly_name(m.get_match_root()->get_friendly_name());
copy_runtime_info(m.get_matched_nodes(), rg.get());
ht.replace(lstm_cell->output(0));
ct.replace(lstm_cell->output(1));
return true;
};
auto m = std::make_shared<pattern::Matcher>(ht_label, matcher_name);
this->register_matcher(m, callback);
}

View File

@ -4,7 +4,10 @@
#include "transformations/common_optimizations/lstm_cell_fusion.hpp"
#include <random>
#include "common_test_utils/ov_test_utils.hpp"
#include "openvino/core/validation_util.hpp"
#include "openvino/op/abs.hpp"
#include "openvino/op/add.hpp"
#include "openvino/op/concat.hpp"
@ -12,10 +15,13 @@
#include "openvino/op/lstm_cell.hpp"
#include "openvino/op/matmul.hpp"
#include "openvino/op/multiply.hpp"
#include "openvino/op/negative.hpp"
#include "openvino/op/parameter.hpp"
#include "openvino/op/relu.hpp"
#include "openvino/op/sigmoid.hpp"
#include "openvino/op/split.hpp"
#include "openvino/op/tanh.hpp"
#include "openvino/op/transpose.hpp"
using namespace ov;
@ -108,3 +114,183 @@ TEST_P(LSTMCellFusionTestSuite, SubgraphFusedToLSTMCell) {
INSTANTIATE_TEST_SUITE_P(LSTMCellFusion,
LSTMCellFusionTestSuite,
testing::Combine(testing::Values(false, true), testing::Values(1, 2), testing::Values(1, -1)));
using LSTMCellTFKerasFusionParam = std::tuple<std::string, // f activation function
std::string, // g activation function
std::string, // h activation function
size_t, // input size
size_t>; // hidden size
class LSTMCellFusionWithSplitWeights : public testing::WithParamInterface<LSTMCellTFKerasFusionParam>,
public TransformationTestsF {};
namespace {
void generate_weights_value(std::vector<float>& weights_value, const Shape& weights_shape) {
weights_value.resize(shape_size(weights_shape));
std::mt19937 rng(9812);
std::uniform_real_distribution<float> distribution(-300, 300);
for (size_t i = 0; i < weights_value.size(); ++i) {
weights_value[i] = distribution(rng);
}
}
void generate_weights(std::vector<float>& w,
std::vector<float>& r,
std::vector<float>& b,
size_t input_size,
size_t hidden_size) {
Shape w_shape({hidden_size, input_size});
Shape r_shape({hidden_size, hidden_size});
Shape b_shape({hidden_size});
generate_weights_value(w, w_shape);
generate_weights_value(r, r_shape);
generate_weights_value(b, b_shape);
}
ov::Output<ov::Node> get_activation_function(const std::string& activation_name, const ov::Output<ov::Node>& input) {
if (activation_name == "relu") {
return std::make_shared<op::v0::Relu>(input);
} else if (activation_name == "tanh") {
return std::make_shared<op::v0::Tanh>(input);
} else if (activation_name == "sigmoid") {
return std::make_shared<op::v0::Sigmoid>(input);
}
throw "unsupported activation function";
}
ov::Output<ov::Node> generate_gate_subgraph(const std::shared_ptr<ov::Node>& x,
const std::shared_ptr<ov::Node>& h,
const std::vector<float>& w,
const std::vector<float>& r,
const std::vector<float>& b,
size_t input_size,
size_t hidden_size,
const std::string& f_activation) {
// w must be of a shape [input_size, hidden_size]
// r must be of a shape [hidden_size, hidden_size]
// b must be of a shape [hidden_size]
Shape w_shape({input_size, hidden_size});
Shape r_shape({hidden_size, hidden_size});
Shape b_shape({hidden_size});
auto w_const = op::v0::Constant::create(element::f32, w_shape, w);
auto r_const = op::v0::Constant::create(element::f32, r_shape, r);
auto b_const = op::v0::Constant::create(element::f32, b_shape, b);
auto x_by_wi = std::make_shared<op::v0::MatMul>(x, w_const);
auto x_by_wi_biased = std::make_shared<op::v1::Add>(x_by_wi, b_const);
auto h_by_ri = std::make_shared<op::v0::MatMul>(h, r_const);
auto it = std::make_shared<op::v1::Add>(x_by_wi_biased, h_by_ri);
return get_activation_function(f_activation, it);
}
ov::Output<ov::Node> prepare_weight_fico(const std::vector<float>& f_val,
const std::vector<float>& i_val,
const std::vector<float>& c_val,
const std::vector<float>& o_val,
Shape w_shape) {
auto f = std::make_shared<ov::op::v0::Constant>(element::f32, w_shape, f_val);
auto i = std::make_shared<ov::op::v0::Constant>(element::f32, w_shape, i_val);
auto c = std::make_shared<ov::op::v0::Constant>(element::f32, w_shape, c_val);
auto o = std::make_shared<ov::op::v0::Constant>(element::f32, w_shape, o_val);
auto tr_order = std::make_shared<ov::op::v0::Constant>(element::i32, ov::Shape{2}, std::vector<int32_t>{1, 0});
auto f_tr = std::make_shared<ov::op::v1::Transpose>(f, tr_order);
auto i_tr = std::make_shared<ov::op::v1::Transpose>(i, tr_order);
auto c_tr = std::make_shared<ov::op::v1::Transpose>(c, tr_order);
auto o_tr = std::make_shared<ov::op::v1::Transpose>(o, tr_order);
ov::Output<ov::Node> w = std::make_shared<ov::op::v0::Concat>(ov::OutputVector{f_tr, i_tr, c_tr, o_tr}, 0);
if (const auto& constant = ov::util::constantfold_subgraph(w)) {
w = constant;
}
return w;
}
} // namespace
TEST_P(LSTMCellFusionWithSplitWeights, SubgraphFusedToLSTMCell) {
const auto& param = GetParam();
const std::string& f_activation = std::get<0>(param);
const std::string& g_activation = std::get<1>(param);
const std::string& h_activation = std::get<2>(param);
size_t input_size = std::get<3>(param);
size_t hidden_size = std::get<4>(param);
size_t batch_size = 2;
// generate weights values
std::vector<float> wi, ri, bi, wf, rf, bf, wo, ro, bo, wc, rc, bc;
generate_weights(wi, ri, bi, input_size, hidden_size);
generate_weights(wf, rf, bf, input_size, hidden_size);
generate_weights(wo, ro, bo, input_size, hidden_size);
generate_weights(wc, rc, bc, input_size, hidden_size);
{
auto x = std::make_shared<op::v0::Parameter>(element::f32, Shape{batch_size, input_size});
auto h = std::make_shared<op::v0::Parameter>(element::f32, Shape{batch_size, hidden_size});
auto c = std::make_shared<op::v0::Parameter>(element::f32, Shape{batch_size, hidden_size});
auto it = generate_gate_subgraph(x, h, wi, ri, bi, input_size, hidden_size, f_activation);
auto ft = generate_gate_subgraph(x, h, wf, rf, bf, input_size, hidden_size, f_activation);
auto ot = generate_gate_subgraph(x, h, wo, ro, bo, input_size, hidden_size, f_activation);
auto c1t = generate_gate_subgraph(x, h, wc, rc, bc, input_size, hidden_size, g_activation);
auto it_mul_c1t = std::make_shared<op::v1::Multiply>(it, c1t);
auto ft_mul_c = std::make_shared<op::v1::Multiply>(ft, c);
auto ct = std::make_shared<op::v1::Add>(ft_mul_c, it_mul_c1t);
auto ct_activated = get_activation_function(h_activation, ct);
auto ht = std::make_shared<op::v1::Multiply>(ct_activated, ot);
auto c_neg = std::make_shared<op::v0::Negative>(ct);
auto h_abs = std::make_shared<op::v0::Abs>(ht);
model = std::make_shared<Model>(NodeVector{h_abs, c_neg}, ParameterVector{x, h, c});
manager.register_pass<ov::pass::LSTMCellFusion>();
}
{
auto x = std::make_shared<op::v0::Parameter>(element::f32, Shape{batch_size, input_size});
auto h = std::make_shared<op::v0::Parameter>(element::f32, Shape{batch_size, hidden_size});
auto c = std::make_shared<op::v0::Parameter>(element::f32, Shape{batch_size, hidden_size});
// concatenate weights in fico format
auto w = prepare_weight_fico(wf, wi, wc, wo, ov::Shape{input_size, hidden_size});
auto r = prepare_weight_fico(rf, ri, rc, ro, ov::Shape{hidden_size, hidden_size});
std::vector<float> b_value = bf;
b_value.insert(b_value.end(), bi.begin(), bi.end());
b_value.insert(b_value.end(), bc.begin(), bc.end());
b_value.insert(b_value.end(), bo.begin(), bo.end());
auto b = op::v0::Constant::create(element::f32, Shape{4 * hidden_size}, b_value);
auto lstm_cell =
std::make_shared<op::v4::LSTMCell>(x,
h,
c,
w,
r,
b,
hidden_size,
std::vector<std::string>{f_activation, g_activation, h_activation});
auto c_neg = std::make_shared<op::v0::Negative>(lstm_cell->output(1));
auto h_abs = std::make_shared<op::v0::Abs>(lstm_cell->output(0));
model_ref = std::make_shared<Model>(NodeVector{h_abs, c_neg}, ParameterVector{x, h, c});
}
comparator.enable(FunctionsComparator::CmpValues::CONST_VALUES);
comparator.enable(FunctionsComparator::CmpValues::ATTRIBUTES);
comparator.enable(FunctionsComparator::CmpValues::ACCURACY);
}
INSTANTIATE_TEST_SUITE_P(LSTMCellFusion,
LSTMCellFusionWithSplitWeights,
testing::Combine(testing::Values("sigmoid", "tanh", "relu"),
testing::Values("sigmoid", "relu"),
testing::Values("tanh", "relu"),
testing::Values(2, 3),
testing::Values(3, 4)));