1414#include " openvino/op/multiply.hpp"
1515#include " openvino/op/shape_of.hpp"
1616#include " openvino/op/squeeze.hpp"
17+ #include " openvino/op/unsqueeze.hpp"
1718#include " openvino/util/common_util.hpp"
1819#include " utils/reshape.hpp"
1920#include " utils/split.hpp"
@@ -38,10 +39,12 @@ enum class LSTMInput {
3839 LSTM_INPUT_P
3940};
4041
41- // Normalize tensor rank to target_rank by squeezing leading dimensions of size 1.
42- // This handles models where upstream Unsqueeze operations add extra leading dimensions.
43- // Static validation: if leading dims are statically known and != 1, emit clear error at conversion time.
44- // If extra dimensions are != 1 at runtime, Squeeze will fail with a clear error.
42+ // Normalize tensor rank to target_rank:
43+ // - If rank > target: squeeze leading dimensions of size 1 (handles upstream Unsqueeze ops).
44+ // Static validation rejects leading dims that are statically != 1.
45+ // - If rank < target by exactly 1: unsqueeze a leading dimension of size 1
46+ // (handles models that omit the num_directions=1 dimension for unidirectional LSTMs).
47+ // - If rank differs by more than the above, throw an error for malformed input.
4548ov::Output<ov::Node> normalize_tensor_rank (const ov::Output<ov::Node>& input,
4649 int64_t target_rank,
4750 const std::string& input_name) {
@@ -86,14 +89,23 @@ ov::Output<ov::Node> normalize_tensor_rank(const ov::Output<ov::Node>& input,
8689 return std::make_shared<v0::Squeeze>(input, axes_const);
8790 }
8891
89- // input_rank < target_rank: reject non-conformant models with missing num_directions dimension
90- OPENVINO_THROW (" LSTM input '" ,
91- input_name,
92- " ' has rank " ,
93- input_rank.get_length (),
94- " but expected " ,
95- target_rank,
96- " . Missing num_directions dimension cannot be automatically inferred." );
92+ // input_rank < target_rank: only allow exactly 1 missing dimension (the num_directions dim).
93+ // For unidirectional LSTM (forward/reverse), num_directions=1, so some models omit it.
94+ // A larger rank deficiency indicates a genuinely malformed model.
95+ const auto dims_to_unsqueeze = target_rank - input_rank.get_length ();
96+ if (dims_to_unsqueeze != 1 ) {
97+ OPENVINO_THROW (" LSTM input '" ,
98+ input_name,
99+ " ' has rank " ,
100+ input_rank.get_length (),
101+ " but expected " ,
102+ target_rank,
103+ " . Rank difference is " ,
104+ dims_to_unsqueeze,
105+ " but only 1 (missing num_directions) is supported." );
106+ }
107+ auto axes_const = v0::Constant::create (ov::element::i64 , Shape{1 }, std::vector<int64_t >{0 });
108+ return std::make_shared<v0::Unsqueeze>(input, axes_const);
97109}
98110
99111struct LSTMNgInputMap {
@@ -116,6 +128,24 @@ struct LSTMNgInputMap {
116128
117129 m_input_map[LSTMInput::LSTM_INPUT_X] = input_x;
118130
131+ // Detect if num_directions dimension is missing from W.
132+ // Some models omit the leading num_directions dimension when it equals 1.
133+ // normalize_tensor_rank will unsqueeze it, but we need to squeeze the
134+ // corresponding dimension from outputs to match the original model's expectations.
135+ const auto & w_rank = ng_inputs.at (1 ).get_partial_shape ().rank ();
136+ if (w_rank.is_static () && w_rank.get_length () < 3 ) {
137+ // Unsqueezing adds num_directions=1, which is only valid for forward/reverse.
138+ // For bidirectional LSTMs, num_directions=2 and cannot be inferred.
139+ const std::string direction =
140+ ov::util::to_lower (node.get_attribute_value <std::string>(" direction" , " forward" ));
141+ OPENVINO_ASSERT (direction != " bidirectional" ,
142+ " LSTM input 'W' has rank " ,
143+ w_rank.get_length (),
144+ " but expected 3. Cannot add num_directions dimension for bidirectional LSTM "
145+ " because num_directions=2 cannot be inferred from the data." );
146+ m_num_directions_unsqueezed = true ;
147+ }
148+
119149 // Weight tensor for the gates.
120150 // ONNX Shape: [num_directions, 4*hidden_size, input_size]
121151 auto input_w = normalize_tensor_rank (ng_inputs.at (1 ), 3 , " W" );
@@ -252,6 +282,9 @@ struct LSTMNgInputMap {
252282 return m_input_map.at (key);
253283 }
254284 std::map<LSTMInput, ov::Output<ov::Node>> m_input_map;
285+ // True when num_directions dimension was missing from inputs and was added via Unsqueeze.
286+ // In this case, outputs need the num_directions dimension squeezed to match the original model.
287+ bool m_num_directions_unsqueezed = false ;
255288};
256289
257290// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ATTRIBUTES PARSING ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -308,6 +341,21 @@ ov::OutputVector lstm(const ov::frontend::onnx::Node& node) {
308341 const auto Y_h = lstm_sequence->output (1 );
309342 const auto Y_c = lstm_sequence->output (2 );
310343
344+ if (input_map.m_num_directions_unsqueezed ) {
345+ // The num_directions dimension was added to inputs via Unsqueeze.
346+ // Squeeze it from outputs so downstream consumers see the original ranks.
347+ // Y: OV [batch_size, num_directions(1), seq_length, hidden_size] -> ONNX [seq_length, batch_size, hidden_size]
348+ // Y_h: OV [batch_size, num_directions(1), hidden_size] -> ONNX [batch_size, hidden_size]
349+ // Y_c: OV [batch_size, num_directions(1), hidden_size] -> ONNX [batch_size, hidden_size]
350+ auto num_dir_axis = v0::Constant::create (ov::element::i64 , Shape{1 }, {1 });
351+ auto Y_squeezed = std::make_shared<v0::Squeeze>(Y, num_dir_axis);
352+ auto Y_h_squeezed = std::make_shared<v0::Squeeze>(Y_h, num_dir_axis);
353+ auto Y_c_squeezed = std::make_shared<v0::Squeeze>(Y_c, num_dir_axis);
354+
355+ // Y: [batch_size, seq_length, hidden_size] -> [seq_length, batch_size, hidden_size]
356+ return {ov::op::util::reorder_axes (Y_squeezed, {1 , 0 , 2 }), Y_h_squeezed, Y_c_squeezed};
357+ }
358+
311359 return {ov::op::util::reorder_axes (Y, {2 , 1 , 0 , 3 }),
312360 ov::op::util::reorder_axes (Y_h, {1 , 0 , 2 }),
313361 ov::op::util::reorder_axes (Y_c, {1 , 0 , 2 })};
0 commit comments