onnx_import.cpp
1 /*
2  * This file is part of CasADi.
3  *
4  * CasADi -- A symbolic framework for dynamic optimization.
5  * Copyright (C) 2010-2023 Joel Andersson, Joris Gillis, Moritz Diehl,
6  * KU Leuven. All rights reserved.
7  * Copyright (C) 2011-2014 Greg Horn
8  *
9  * CasADi is free software; you can redistribute it and/or
10  * modify it under the terms of the GNU Lesser General Public
11  * License as published by the Free Software Foundation; either
12  * version 3 of the License, or (at your option) any later version.
13  *
14  * CasADi is distributed in the hope that it will be useful,
15  * but WITHOUT ANY WARRANTY; without even the implied warranty of
16  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
17  * Lesser General Public License for more details.
18  *
19  * You should have received a copy of the GNU Lesser General Public
20  * License along with CasADi; if not, write to the Free Software
21  * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
22  *
23  */
24 
25 
26 #include "onnx_model.hpp"
27 
29 namespace casadi {
30 
31  // ONNX initializers are pre-loaded constants
32  void Onnx::process_graph_initializers(
33  const onnx::GraphProto& graph,
34  std::map<std::string, MX>& value_map,
35  bool verbose) const {
36 
37  for (int i = 0; i < graph.initializer_size(); ++i) {
38  const onnx::TensorProto& tensor = graph.initializer(i);
39  std::string tensor_name = tensor.name();
40 
41  if (verbose) {
42  uout() << " Processing initializer: " << tensor_name << std::endl;
43  }
44 
45  value_map[tensor_name] = MX(tensor_to_dm(tensor));
46  }
47  }
48 
49  // Create MX symbols for graph inputs
50  void Onnx::process_graph_inputs(
51  const onnx::GraphProto& graph,
52  std::map<std::string, MX>& value_map,
53  std::vector<MX>& func_inputs,
54  std::vector<std::string>& input_names,
55  bool verbose) const {
56 
57  for (int i = 0; i < graph.input_size(); ++i) {
58  const onnx::ValueInfoProto& input = graph.input(i);
59  std::string input_name = input.name();
60 
61  // Skip if already in value_map (it's an initializer, not a variable)
62  if (value_map.count(input_name)) {
63  if (verbose) {
64  uout() << " Skipping input '" << input_name
65  << "' (it's an initializer)" << std::endl;
66  }
67  continue;
68  }
69 
70  // Extract shape
71  const onnx::TensorShapeProto& shape =
72  input.type().tensor_type().shape();
73  casadi_int rows = get_dimension(shape, 0);
74  casadi_int cols = get_dimension(shape, 1);
75 
76  if (verbose) {
77  uout() << " Creating input: " << input_name
78  << " [" << rows << ", " << cols << "]" << std::endl;
79  }
80 
81  // Sparsity overlay: if a sparse_initializer shares this input's name it carries a non-dense
82  // input pattern -- make a SPARSE input symbol and feed it AS-IS, so CasADi propagates the
83  // true sparsity through the body (no densification on entry; densify only where an ONNX op
84  // requires
85  // the dense layout, e.g. Reshape). Transpose-rep: the ONNX input is declared (c,r).
86  Sparsity ov = input_pattern(graph, input_name);
87  MX mx_input = ov.is_null() ? MX::sym(input_name, cols, rows) : MX::sym(input_name, ov);
88  value_map[input_name] = mx_input;
89  func_inputs.push_back(mx_input);
90  input_names.push_back(input_name);
91  }
92  }
93 
94  // Process all nodes in the graph
95  void Onnx::process_graph_nodes(
96  const onnx::GraphProto& graph,
97  std::map<std::string, MX>& value_map,
98  bool verbose) {
99 
100  // Per-graph capture of kron operands, keyed by the exporter's node-name tag "kron<k>".
101  std::map<std::string, MX> kron_operand_a_, kron_operand_b_;
102 
103  for (int i = 0; i < graph.node_size(); ++i) {
104  const onnx::NodeProto& node = graph.node(i);
105  std::string op_type = node.op_type();
106 
107  if (verbose) {
108  uout() << " Processing node " << i << ": " << op_type << std::endl;
109  }
110 
111  // ========== Special Handling: Kronecker product group ==========
112  // The exporter tags its kron node group with node name "kron<k>" (see OP_KRON in
113  // onnx_operations.cpp). The 4-D intermediate tensors cannot be MX (MX is 2-D), so we do NOT
114  // process the group generically (the helper tensors kron<k>_A4/_B4/_P are never added to the
115  // value_map). We capture the two logical operands at the leading Reshapes (whose single data
116  // input is the imported, transposed-back A / B) and reconstruct kron(A_imp, B_imp) at the
117  // final Reshape. CasADi kron propagates sparsity natively, so the pattern round-trips for
118  // free
119  // (kron commutes with transpose). This is handled BEFORE the generic input gathering because
120  // the helper tensors are intentionally absent from value_map.
121  if (node.name().rfind("kron", 0) == 0) {
122  const std::string& nm = node.name(); // "kron<k>_<role>"
123  std::size_t us = nm.rfind('_');
124  std::string tag = nm.substr(0, us), role = nm.substr(us + 1);
125  if (role == "A4") {
126  kron_operand_a_[tag] = value_map.at(node.input(0)); // data input = imported A
127  continue;
128  }
129  if (role == "B4") {
130  kron_operand_b_[tag] = value_map.at(node.input(0)); // data input = imported B
131  continue;
132  }
133  if (role == "P") continue; // intermediate broadcast product; nothing to record
134  if (role == "R") {
135  // Final Reshape: emit kron of the captured operands and skip generic 4-D handling.
136  casadi_assert(kron_operand_a_.count(tag) && kron_operand_b_.count(tag),
137  "ONNX import: incomplete kron node group '" + tag + "'");
138  value_map[node.output(0)] = MX::kron(kron_operand_a_.at(tag), kron_operand_b_.at(tag));
139  continue;
140  }
141  }
142 
143  // Gather input tensors
144  std::vector<MX> node_inputs;
145  for (int j = 0; j < node.input_size(); ++j) {
146  std::string input_name = node.input(j);
147 
148  // Empty string means optional input not provided
149  if (input_name.empty()) {
150  node_inputs.push_back(MX());
151  continue;
152  }
153 
154  casadi_assert(value_map.count(input_name),
155  "Unknown input tensor '" + input_name +
156  "' required by node " + std::to_string(i) +
157  " (op_type: " + op_type + ")");
158  node_inputs.push_back(value_map[input_name]);
159  }
160 
161  // Compute output based on operation type
162  MX output;
163 
164  // ========== Special Handling: Multi-output operations ==========
165  if (op_type == "Split") {
166  casadi_assert(node_inputs.size() >= 1, "Split requires 1 input");
167  casadi_int axis = get_int_attribute(node, "axis", 0);
168  casadi_assert(axis == 0 || axis == 1, "Split: only axis 0 and 1 supported");
169 
170  // Split sizes: 2nd input (opset 13+), else 'split' attribute (opset <13), else equal
171  std::vector<casadi_int> split_sizes;
172  if (node_inputs.size() >= 2) {
173  DM split_dm = DM(node_inputs[1]);
174  for (casadi_int k = 0; k < split_dm.nnz(); ++k) {
175  split_sizes.push_back(static_cast<casadi_int>(static_cast<double>(split_dm.nz(k))));
176  }
177  } else {
178  for (int a = 0; a < node.attribute_size(); ++a) {
179  if (node.attribute(a).name() == "split") {
180  for (int k = 0; k < node.attribute(a).ints_size(); ++k) {
181  split_sizes.push_back(node.attribute(a).ints(k));
182  }
183  break;
184  }
185  }
186  }
187  if (split_sizes.empty()) { // equal split across the outputs
188  // Transpose-rep: ONNX axis 0 is CasADi columns, axis 1 is CasADi rows
189  casadi_int total = (axis == 0) ? node_inputs[0].size2() : node_inputs[0].size1();
190  split_sizes.assign(node.output_size(), total / node.output_size());
191  }
192 
193  // Offsets [0, s0, s0+s1, ...]; ONNX axis 0 -> CasADi horzsplit, axis 1 -> vertsplit
194  std::vector<casadi_int> offset = {0};
195  for (casadi_int sz : split_sizes) offset.push_back(offset.back() + sz);
196  std::vector<MX> outputs = (axis == 0) ? horzsplit(node_inputs[0], offset)
197  : vertsplit(node_inputs[0], offset);
198 
199  for (casadi_int j = 0; j < outputs.size(); ++j) value_map[node.output(j)] = outputs[j];
200  continue; // Don't use standard output handling
201 
202  // ========== Scan: iterate a body over columns -> CasADi Map ==========
203  } else if (op_type == "Scan") {
204  const onnx::GraphProto* body = get_graph_attribute(node, "body");
205  casadi_assert(body != nullptr, "Scan node requires a 'body' subgraph");
206  casadi_int num_scan_inputs = get_int_attribute(node, "num_scan_inputs", node.input_size());
207  casadi_int M = node.input_size() - num_scan_inputs; // state variables (reduce_out count)
208 
209  // A body that references outer-scope names (captures) signals reduce_in
210  std::set<std::string> body_in_names, defined;
211  for (int b = 0; b < body->input_size(); ++b) {
212  body_in_names.insert(body->input(b).name());
213  defined.insert(body->input(b).name());
214  }
215  for (int nn = 0; nn < body->node_size(); ++nn)
216  for (int oo = 0; oo < body->node(nn).output_size(); ++oo)
217  defined.insert(body->node(nn).output(oo));
218  bool has_capture = false;
219  for (int nn = 0; nn < body->node_size() && !has_capture; ++nn)
220  for (int ii = 0; ii < body->node(nn).input_size(); ++ii) {
221  const std::string& in = body->node(nn).input(ii);
222  if (!in.empty() && !defined.count(in)) { has_capture = true; break; }
223  }
224 
225  if (M == 0 && !has_capture) {
226  // Plain map: the 3-D lift reshapes pass through on import (CasADi is 2-D), so
227  // node_inputs are the original (rows, n*c) map inputs. Rebuild base.map(n).
228  Function base = function_from_graph(*body, op_type + "_body");
229  casadi_int n = node_inputs[0].size2() / base.size2_in(0);
230  std::vector<MX> outputs =
231  base.map(n)(std::vector<MX>(node_inputs.begin(), node_inputs.end()));
232  for (int j = 0; j < node.output_size(); ++j) value_map[node.output(j)] = outputs[j];
233  continue;
234  }
235 
236  // Reduce-map: the body wraps a single base call; recover base + masks and rebuild
237  // base.map(n, reduce_in, reduce_out).
238  const onnx::NodeProto* call = nullptr;
239  for (int nn = 0; nn < body->node_size(); ++nn)
240  if (!body->node(nn).domain().empty()) { call = &body->node(nn); break; }
241  casadi_assert(call, "reduce-map Scan body must contain a base function call");
242  casadi_int nin = call->input_size(), nout = call->output_size();
243 
244  // reduce_in: base arg is a capture; reduce_out: base result feeds an accumulator Add
245  std::vector<bool> reduce_in(nin), reduce_out(nout, false);
246  for (casadi_int j = 0; j < nin; ++j) reduce_in[j] = !body_in_names.count(call->input(j));
247  for (int nn = 0; nn < body->node_size(); ++nn) {
248  if (body->node(nn).op_type() != "Add") continue;
249  for (int ii = 0; ii < body->node(nn).input_size(); ++ii)
250  for (casadi_int j = 0; j < nout; ++j)
251  if (body->node(nn).input(ii) == call->output(j)) reduce_out[j] = true;
252  }
253 
254  // Base input shapes: captures from the outer tensor, scanned ones from the body input
255  std::vector<std::pair<casadi_int, casadi_int>> in_shapes(nin);
256  for (casadi_int j = 0; j < nin; ++j) {
257  if (reduce_in[j]) {
258  MX cap = value_map.at(call->input(j));
259  in_shapes[j] = {cap.size1(), cap.size2()};
260  } else {
261  for (int b = 0; b < body->input_size(); ++b)
262  if (body->input(b).name() == call->input(j)) {
263  const onnx::TensorShapeProto& sh = body->input(b).type().tensor_type().shape();
264  in_shapes[j] = {get_dimension(sh, 0), get_dimension(sh, 1)};
265  break;
266  }
267  }
268  }
269 
270  const onnx::FunctionProto* fp = find_function(call->op_type(), call->domain());
271  casadi_assert(fp, "reduce-map base function '" + call->op_type() + "' not found");
272  Function base = function_from_function_proto(*fp, in_shapes, op_type + "_base");
273 
274  // base.map args (base order): captures broadcast, repeated come from scan node inputs
275  std::vector<MX> args(nin);
276  casadi_int n = 0, r = 0;
277  for (casadi_int j = 0; j < nin; ++j) {
278  if (reduce_in[j]) {
279  args[j] = value_map.at(call->input(j));
280  } else {
281  args[j] = node_inputs[M + r];
282  if (n == 0) n = args[j].size2() / base.size2_in(j);
283  ++r;
284  }
285  }
286  casadi_assert(n > 0, "reduce-map import: could not infer map size");
287  std::vector<MX> outs = base.map(n, reduce_in, reduce_out)(args);
288 
289  // Node outputs are [state accumulators (reduce_out), scan outputs (repeated)], base order
290  casadi_int si = 0, ci = 0;
291  for (casadi_int j = 0; j < nout; ++j) {
292  if (reduce_out[j]) value_map[node.output(si++)] = outs[j];
293  else
294  value_map[node.output(M + ci++)] = outs[j];
295  }
296  continue;
297 
298  // ========== If: two captured branches combined with if_else ==========
299  } else if (op_type == "If") {
300  const onnx::GraphProto* then_b = get_graph_attribute(node, "then_branch");
301  const onnx::GraphProto* else_b = get_graph_attribute(node, "else_branch");
302  casadi_assert(then_b && else_b, "If node requires then_branch and else_branch");
303  std::vector<MX> t = eval_captured_subgraph(*then_b, value_map);
304  std::vector<MX> e = eval_captured_subgraph(*else_b, value_map);
305  MX cond = node_inputs[0];
306  for (int j = 0; j < node.output_size(); ++j) {
307  value_map[node.output(j)] = if_else(cond, t[j], e[j]);
308  }
309  continue;
310 
311  // ========== Control Flow Operations (not supported) ==========
312  } else if (op_type == "Loop") {
313  casadi_error("ONNX import: 'Loop' control flow operator is not supported.");
314 
315  // ========== Standard Operations (delegated to helper) ==========
316  } else {
317  // A non-empty domain marks a function call.
318  std::string node_domain = node.domain();
319  if (!node_domain.empty()) {
320  const onnx::FunctionProto* func_proto = find_function(op_type, node_domain);
321 
322  if (func_proto != nullptr) {
323  if (verbose) {
324  uout() << " Function call to: " << node_domain << "." << op_type << std::endl;
325  }
326 
327  // Inline the function body: only its nodes are needed (process_graph_nodes reads
328  // graph.node() only); formal inputs/outputs are wired through func_value_map below.
329  onnx::GraphProto func_graph;
330  func_graph.set_name(func_proto->name());
331  for (int n = 0; n < func_proto->node_size(); ++n) {
332  *func_graph.add_node() = func_proto->node(n);
333  }
334 
335  // Map the function's formal inputs to the call-site values.
336  std::map<std::string, MX> func_value_map;
337  for (size_t n = 0; n < node_inputs.size() && n < func_proto->input_size(); ++n) {
338  func_value_map[func_proto->input(n)] = node_inputs[n];
339  }
340 
341  process_graph_nodes(func_graph, func_value_map, false);
342 
343  // Store the function outputs in the outer value_map.
344  for (int n = 0; n < node.output_size() && n < func_proto->output_size(); ++n) {
345  std::string func_output_name = func_proto->output(n);
346  casadi_assert(func_value_map.count(func_output_name),
347  "Function output '" + func_output_name + "' not found");
348  value_map[node.output(n)] = func_value_map[func_output_name];
349 
350  if (verbose) {
351  uout() << " -> " << node.output(n) << std::endl;
352  }
353  }
354 
355  // Skip the normal single-output handling
356  continue;
357  }
358  // Fall through to error if function not found
359  }
360 
361  // All other (single-output) operations
362  output = process_node_operation(op_type, node, node_inputs);
363  }
364 
365  // Store output (assume single output for now)
366  casadi_assert(node.output_size() >= 1,
367  "Node must have at least one output");
368 
369  std::string output_name = node.output(0);
370  value_map[output_name] = output;
371 
372  if (verbose) {
373  uout() << " -> " << output_name << std::endl;
374  }
375  }
376  }
377 
378  // Collect graph outputs from value_map
379  void Onnx::collect_graph_outputs(
380  const onnx::GraphProto& graph,
381  const std::map<std::string, MX>& value_map,
382  std::vector<MX>& func_outputs,
383  std::vector<std::string>& output_names,
384  bool verbose) const {
385 
386  for (int i = 0; i < graph.output_size(); ++i) {
387  const onnx::ValueInfoProto& output = graph.output(i);
388  std::string output_name = output.name();
389 
390  casadi_assert(value_map.count(output_name),
391  "Unknown output tensor: " + output_name +
392  ". This usually means the ONNX graph contains unsupported operations.");
393 
394  // No output overlay: each op restores its own output sparsity from seeds during export, so
395  // the value already carries the exact CasADi pattern (re-propagated natively from the seeds).
396  func_outputs.push_back(value_map.at(output_name));
397  output_names.push_back(output_name);
398 
399  if (verbose) {
400  uout() << " Graph output: " << output_name << std::endl;
401  }
402  }
403  }
404 
405  const onnx::FunctionProto* Onnx::find_function(const std::string& name,
406  const std::string& domain) const {
407  for (int f = 0; f < model_.functions_size(); ++f)
408  if (model_.functions(f).name() == name && model_.functions(f).domain() == domain)
409  return &model_.functions(f);
410  return nullptr;
411  }
412 
413  Function Onnx::create(const std::string& name) {
414  casadi_assert(has_model_, "No ONNX model loaded. Call load() first.");
415  return function_from_graph(model_.graph(), name);
416  }
417 
418  Function Onnx::function_from_graph(const onnx::GraphProto& graph,
419  const std::string& name) {
420  std::map<std::string, MX> value_map;
421  std::vector<MX> inputs, outputs;
422  std::vector<std::string> input_names, output_names;
423 
424  if (verbose_) {
425  uout() << "Building CasADi Function '" << name << "' from graph '" << graph.name()
426  << "' (" << graph.initializer_size() << " initializers, "
427  << graph.input_size() << " inputs, " << graph.output_size() << " outputs, "
428  << graph.node_size() << " nodes)" << std::endl;
429  }
430 
431  process_graph_initializers(graph, value_map, verbose_);
432  process_graph_inputs(graph, value_map, inputs, input_names, verbose_);
433  process_graph_nodes(graph, value_map, verbose_);
434  collect_graph_outputs(graph, value_map, outputs, output_names, verbose_);
435 
436  return Function(name, inputs, outputs, input_names, output_names);
437  }
438 
439  Function Onnx::function_from_function_proto(
440  const onnx::FunctionProto& fp,
441  const std::vector<std::pair<casadi_int, casadi_int>>& in_shapes,
442  const std::string& name) {
443  // A FunctionProto carries no shapes; wrap it as a graph with caller-supplied input shapes
444  onnx::GraphProto g;
445  g.set_name(fp.name());
446  for (int j = 0; j < fp.input_size(); ++j) {
447  onnx::ValueInfoProto* vi = g.add_input();
448  vi->set_name(fp.input(j));
449  onnx::TypeProto::Tensor* tt = vi->mutable_type()->mutable_tensor_type();
450  tt->set_elem_type(real_type());
451  tt->mutable_shape()->add_dim()->set_dim_value(in_shapes[j].first);
452  tt->mutable_shape()->add_dim()->set_dim_value(in_shapes[j].second);
453  }
454  for (int k = 0; k < fp.node_size(); ++k) *g.add_node() = fp.node(k);
455  for (int j = 0; j < fp.output_size(); ++j) g.add_output()->set_name(fp.output(j));
456  return function_from_graph(g, name);
457  }
458 
459  std::vector<MX> Onnx::eval_captured_subgraph(const onnx::GraphProto& graph,
460  std::map<std::string, MX> scope) {
461  // No formal inputs: the branch captures outer tensors, already present in `scope`
462  process_graph_initializers(graph, scope, verbose_);
463  process_graph_nodes(graph, scope, verbose_);
464  std::vector<MX> outputs;
465  for (int i = 0; i < graph.output_size(); ++i)
466  outputs.push_back(scope.at(graph.output(i).name()));
467  return outputs;
468  }
469 
470 
471  // Convert a constant tensor input to a vector of integers
472  static std::vector<casadi_int> constant_ints(const MX& m) {
473  casadi_assert(m.is_constant(), "Expected a constant integer tensor");
474  DM dm = static_cast<DM>(m);
475  std::vector<casadi_int> v;
476  for (casadi_int k = 0; k < dm.numel(); ++k)
477  v.push_back(static_cast<casadi_int>(dm(k).scalar()));
478  return v;
479  }
480 
481  MX Onnx::process_node_operation(
482  const std::string& op_type,
483  const onnx::NodeProto& node,
484  const std::vector<MX>& node_inputs) {
485 
486  MX output;
487 
488  // Simple ops via the centralized lookup table
489  const OpMapping* mapping = get_op_mapping_by_name(op_type);
490  if (mapping) {
491  const MX& x = node_inputs[0];
492  if (mapping->arity == 1) {
493  casadi_assert(node_inputs.size() >= 1, op_type + " requires 1 input");
494  switch (mapping->casadi_op) {
495  case OP_SIN: return sin(x);
496  case OP_COS: return cos(x);
497  case OP_TAN: return tan(x);
498  case OP_ASIN: return asin(x);
499  case OP_ACOS: return acos(x);
500  case OP_ATAN: return atan(x);
501  case OP_SINH: return sinh(x);
502  case OP_COSH: return cosh(x);
503  case OP_TANH: return tanh(x);
504  case OP_ASINH: return asinh(x);
505  case OP_ACOSH: return acosh(x);
506  case OP_ATANH: return atanh(x);
507  case OP_EXP: return exp(x);
508  case OP_LOG: return log(x);
509  case OP_SQRT: return sqrt(x);
510  case OP_NEG: return -x;
511  case OP_FABS: return fabs(x);
512  case OP_CEIL: return ceil(x);
513  case OP_FLOOR: return floor(x);
514  case OP_SIGN: return sign(x);
515  case OP_ERF: return erf(x);
516  case OP_INV: return 1.0 / x;
517  case OP_TRANSPOSE: return x.T();
518  case OP_NORM1: return norm_1(x);
519  case OP_NORM2: return norm_2(x);
520  case OP_NORMF: return norm_fro(x);
521  case OP_MMIN: return mmin(x);
522  case OP_MMAX: return mmax(x);
523  default: break;
524  }
525  } else if (mapping->arity == 2) {
526  casadi_assert(node_inputs.size() >= 2, op_type + " requires 2 inputs");
527  const MX& y = node_inputs[1];
528  switch (mapping->casadi_op) {
529  case OP_ADD: return x + y;
530  case OP_SUB: return x - y;
531  case OP_MUL: return x * y;
532  case OP_DIV: return x / y;
533  case OP_POW: return pow(x, y);
534  default: break;
535  }
536  }
537  }
538 
539  // Everything else needs attributes, multiple inputs, etc.
540 
541  if (op_type == "Less") {
542  casadi_assert(node_inputs.size() >= 2, "Less requires 2 inputs");
543  output = if_else(node_inputs[0] < node_inputs[1], MX(1.0), MX(0.0));
544 
545  } else if (op_type == "Equal") {
546  casadi_assert(node_inputs.size() >= 2, "Equal requires 2 inputs");
547  output = !ne(node_inputs[0], node_inputs[1]); // CasADi has ne() but no eq()
548 
549  } else if (op_type == "LessOrEqual") {
550  casadi_assert(node_inputs.size() >= 2, "LessOrEqual requires 2 inputs");
551  output = if_else(node_inputs[0] <= node_inputs[1], MX(1.0), MX(0.0));
552 
553  } else if (op_type == "Min") {
554  casadi_assert(node_inputs.size() >= 2, "Min requires 2 inputs");
555  output = fmin(node_inputs[0], node_inputs[1]);
556 
557  } else if (op_type == "Max") {
558  casadi_assert(node_inputs.size() >= 2, "Max requires 2 inputs");
559  output = fmax(node_inputs[0], node_inputs[1]);
560 
561  } else if (op_type == "Mod") {
562  casadi_assert(node_inputs.size() >= 2, "Mod requires 2 inputs");
563  output = fmod(node_inputs[0], node_inputs[1]);
564 
565  } else if (op_type == "ReduceSum") {
566  // Sum all elements (ReduceMin/Max/L1/L2 are in the op_map table)
567  casadi_assert(node_inputs.size() >= 1, "ReduceSum requires 1 input");
568  output = sum1(sum2(node_inputs[0]));
569 
570  } else if (op_type == "Not") {
571  casadi_assert(node_inputs.size() >= 1, "Not requires 1 input");
572  output = logic_not(node_inputs[0]);
573 
574  } else if (op_type == "And") {
575  casadi_assert(node_inputs.size() >= 2, "And requires 2 inputs");
576  output = logic_and(node_inputs[0], node_inputs[1]);
577 
578  } else if (op_type == "Or") {
579  casadi_assert(node_inputs.size() >= 2, "Or requires 2 inputs");
580  output = logic_or(node_inputs[0], node_inputs[1]);
581 
582  } else if (op_type == "Where") {
583  casadi_assert(node_inputs.size() >= 3, "Where requires 3 inputs");
584  output = if_else(node_inputs[0], node_inputs[1], node_inputs[2]);
585 
586  } else if (op_type == "Identity") {
587  casadi_assert(node_inputs.size() >= 1, "Identity requires 1 input");
588  output = node_inputs[0];
589 
590  } else if (op_type == "Cast") {
591  // Everything is double in CasADi (converted at the tensor_to_dm boundary), so Cast is an
592  // identity during symbolic computation; the 'to' attribute is irrelevant here.
593  casadi_assert(node_inputs.size() >= 1, "Cast requires 1 input");
594  output = node_inputs[0];
595 
596  } else if (op_type == "MatMul") {
597  // Transpose-rep: ONNX MatMul(P,Q)=P*Q is the transpose-rep of q*p, so swap operands.
598  casadi_assert(node_inputs.size() >= 2, "MatMul requires 2 inputs");
599  output = mtimes(node_inputs[1], node_inputs[0]);
600 
601  } else if (op_type == "Gemm") {
602  // Transpose-rep: the stored result is (opQ(Q)^T)*(opP(P)^T); recover the CasADi value by
603  // swapping operands and the transA/transB roles.
604  casadi_assert(node_inputs.size() >= 2, "Gemm requires at least 2 inputs");
605  MX A = get_int_attribute(node, "transB", 0) ? node_inputs[1].T() : node_inputs[1];
606  MX B = get_int_attribute(node, "transA", 0) ? node_inputs[0].T() : node_inputs[0];
607  output = get_float_attribute(node, "alpha", 1.0) * mtimes(A, B);
608  if (node_inputs.size() >= 3) {
609  output = output + get_float_attribute(node, "beta", 1.0) * node_inputs[2];
610  }
611 
612  } else if (op_type == "Sum") {
613  // Variadic elementwise sum
614  casadi_assert(node_inputs.size() >= 1, "Sum requires at least 1 input");
615  output = node_inputs[0];
616  for (casadi_int idx = 1; idx < node_inputs.size(); ++idx) output = output + node_inputs[idx];
617 
618  } else if (op_type == "Pad") {
619  // Inverse of the block-diagonal Pad: drop the (dense) block into a zero frame at its offset.
620  // Transpose-rep: pads are [axis0_begin, axis1_begin, axis0_end, axis1_end] on the stored
621  // (C x R) tensor, i.e. [col_off, row_off, col_end, row_end].
622  casadi_assert(node_inputs.size() >= 2, "Pad requires data and pads");
623  MX block = node_inputs[0];
624  std::vector<casadi_int> pads = constant_ints(node_inputs[1]);
625  casadi_int col_off = pads[0], row_off = pads[1];
626  casadi_int br = block.size1(), bc = block.size2();
627  casadi_int C = col_off + bc + pads[2];
628  // Pad with SPARSE zero blocks via horz/vertcat: the block's pattern is preserved and no
629  // project/densify is introduced (a triangular block stays triangular), so the block matrix's
630  // sparsity emerges natively from CasADi's own propagation.
631  MX padded = block;
632  if (col_off > 0 || pads[2] > 0) {
633  padded = horzcat(MX(Sparsity(br, col_off)), padded, MX(Sparsity(br, pads[2])));
634  }
635  if (row_off > 0 || pads[3] > 0) {
636  padded = vertcat(MX(Sparsity(row_off, C)), padded, MX(Sparsity(pads[3], C)));
637  }
638  output = padded;
639 
640  } else if (op_type == "Einsum") {
641  // Reconstruct a CasADi einstein contraction (inverse of the export envelope). The
642  // operands arrive as the 2-D reshaped tensors; their column-major vec is the original.
643  casadi_assert(node_inputs.size() >= 2, "Einsum requires 2 inputs");
644  std::string eq;
645  for (char ch : get_string_attribute(node, "equation")) if (ch != ' ') eq += ch;
646  size_t comma = eq.find(','), arrow = eq.find("->");
647  casadi_assert(comma != std::string::npos && arrow != std::string::npos,
648  "ONNX import: only binary Einsum 'a,b->c' is supported");
649  std::string sa = eq.substr(0, comma);
650  std::string sb = eq.substr(comma + 1, arrow - comma - 1);
651  std::string sc = eq.substr(arrow + 2);
652 
653  // Transpose-rep: the exporter reversed each subscript to label the axis-reversed ONNX
654  // tensors. node_inputs here are the imported CasADi values (transposed back to LOGICAL axis
655  // order), so reverse the subscripts back to natural order to pair them with the logical axes
656  // (size1,size2).
657  std::reverse(sa.begin(), sa.end());
658  std::reverse(sb.begin(), sb.end());
659  std::reverse(sc.begin(), sc.end());
660 
661  // Each letter's size, read from the operand shapes (axes follow the subscript order). A
662  // single-index (vector) operand is stored as a rank-1 ONNX tensor that imports with an
663  // ambiguous 1xN/Nx1 orientation, so take its length from numel() (orientation-independent);
664  // a 2-index operand reads size1/size2 directly.
665  std::map<char, casadi_int> lsize;
666  for (int t = 0; t < 2; ++t) {
667  const std::string& s = (t == 0) ? sa : sb;
668  const MX& m = node_inputs[t];
669  if (s.size() == 1) {
670  lsize[s[0]] = m.numel();
671  } else if (s.size() >= 2) {
672  lsize[s[0]] = m.size1();
673  lsize[s[1]] = m.size2();
674  }
675  }
676  // Assign a label to each distinct letter
677  std::map<char, casadi_int> lab;
678  casadi_int next_label = -1;
679  for (char ch : sa + sb + sc) if (!lab.count(ch)) lab[ch] = next_label--;
680 
681  // Labels in natural order; operands' column-major vec is the original einstein input
682  std::vector<casadi_int> da, db, dc, La, Lb, Lc;
683  for (char ch : sa) { da.push_back(lsize[ch]); La.push_back(lab[ch]); }
684  for (char ch : sb) { db.push_back(lsize[ch]); Lb.push_back(lab[ch]); }
685  for (char ch : sc) { dc.push_back(lsize[ch]); Lc.push_back(lab[ch]); }
686  output = einstein(vec(node_inputs[0]), vec(node_inputs[1]), da, db, dc, La, Lb, Lc);
687 
688  } else if (op_type == "Det") {
689  casadi_assert(node_inputs.size() >= 1, "Det requires 1 input");
690  output = det(node_inputs[0]);
691 
692  } else if (op_type == "ReduceLogSumExp") {
693  // log(sum(exp(x))) over all elements
694  casadi_assert(node_inputs.size() >= 1, "ReduceLogSumExp requires 1 input");
695  output = log(sum1(sum2(exp(node_inputs[0]))));
696 
697  } else if (op_type == "Constant") {
698  // A dense constant uses the 'value' attribute; a sparse one uses 'sparse_value' (COO)
699  const onnx::AttributeProto* value_attr = nullptr;
700  const onnx::AttributeProto* sparse_attr = nullptr;
701  for (int a = 0; a < node.attribute_size(); ++a) {
702  const std::string& an = node.attribute(a).name();
703  if (an == "value") value_attr = &node.attribute(a);
704  else if (an == "sparse_value") sparse_attr = &node.attribute(a);
705  }
706  if (sparse_attr != nullptr) {
707  output = MX(sparse_tensor_to_dm(sparse_attr->sparse_tensor()));
708  } else {
709  casadi_assert(value_attr != nullptr,
710  "Constant node must have a 'value' or 'sparse_value' attribute");
711  output = MX(tensor_to_dm(value_attr->t()));
712  }
713 
714  // Complex tensor operations (Transpose is handled by the op_map table)
715  } else if (op_type == "Reshape") {
716  casadi_assert(node_inputs.size() >= 2,
717  "Reshape operation requires 2 inputs (data and shape)");
718  // Second input is the target shape - should be a constant
719  casadi_assert(node_inputs[1].is_constant(),
720  "Reshape shape must be a constant");
721  DM shape_dm = static_cast<DM>(node_inputs[1]);
722  // ONNX Reshape is PSEUDO-DENSE (it operates on the full numel), so densify the operand here:
723  // this is the ONE place the imported graph must drop sparsity, because CasADi's own reshape
724  // PRESERVES it (a sparse operand would reshape to a flat with only nnz entries, shifting
725  // every
726  // downstream Gather/Scatter index). True sparsity propagates everywhere else.
727  if (shape_dm.numel() > 2) {
728  // 3-D reshape (the Map/Scan lift envelope): CasADi is 2-D, so pass through unchanged
729  output = densify(node_inputs[0]);
730  } else {
731  // Transpose-rep: the ONNX target (s0,s1) is the reverse of the CasADi target.
732  casadi_int s0 = static_cast<casadi_int>(shape_dm(0).scalar());
733  casadi_int s1 = (shape_dm.numel() > 1) ? static_cast<casadi_int>(shape_dm(1).scalar()) : 1;
734  output = reshape(densify(node_inputs[0]), s1, s0);
735  }
736 
737  } else if (op_type == "Concat") {
738  // Transpose-rep: ONNX axis 0 is CasADi columns (horzcat), axis 1 is CasADi rows (vertcat)
739  casadi_int axis = get_int_attribute(node, "axis", 0);
740  if (axis == 0) {
741  output = horzcat(node_inputs);
742  } else if (axis == 1) {
743  output = vertcat(node_inputs);
744  } else {
745  casadi_error("Concat with axis=" + std::to_string(axis) +
746  " not supported. Only axis=0 (vertcat) and axis=1 (horzcat) are supported.");
747  }
748 
749  } else if (op_type == "Slice") {
750  // Inputs: data, starts, ends, [axes], [steps].
751  casadi_assert(node_inputs.size() >= 3,
752  "Slice requires at least 3 inputs (data, starts, ends)");
753 
754  MX data = node_inputs[0];
755  casadi_assert(node_inputs[1].is_constant() && node_inputs[2].is_constant(),
756  "Slice starts and ends must be constants");
757 
758  DM starts_dm = static_cast<DM>(node_inputs[1]);
759  DM ends_dm = static_cast<DM>(node_inputs[2]);
760 
761  // Axes (default [0, 1, ...]) and steps (default all 1s)
762  std::vector<casadi_int> axes, steps;
763  if (node_inputs.size() >= 4 && !node_inputs[3].is_empty()) {
764  axes = constant_ints(node_inputs[3]);
765  } else {
766  for (casadi_int k = 0; k < starts_dm.numel(); ++k) axes.push_back(k);
767  }
768  if (node_inputs.size() >= 5 && !node_inputs[4].is_empty()) {
769  steps = constant_ints(node_inputs[4]);
770  } else {
771  steps.assign(starts_dm.numel(), 1);
772  }
773 
774  casadi_assert(axes.size() <= 2, "Slice: only up to 2D slicing supported");
775 
776  // Transpose-rep: ONNX axis 0 is CasADi columns, axis 1 is CasADi rows. Build a row- and a
777  // column-Slice (default = all) from whichever axes are present, with their steps, then index.
778  Slice row_slice, col_slice;
779  casadi_int nrow = data.size1(), ncol = data.size2();
780  for (casadi_int a = 0; a < static_cast<casadi_int>(axes.size()); ++a) {
781  casadi_int st = static_cast<casadi_int>(starts_dm(a).scalar());
782  casadi_int en = static_cast<casadi_int>(ends_dm(a).scalar());
783  casadi_int sp = steps[a];
784  if (axes[a] == 0) {
785  col_slice = Slice(st, en > ncol ? ncol : en, sp); // -> CasADi cols
786  } else {
787  row_slice = Slice(st, en > nrow ? nrow : en, sp); // -> CasADi rows
788  }
789  }
790  output = data(row_slice, col_slice);
791 
792  } else if (op_type == "Gather") {
793  // Extract element(s) at the given indices along an axis.
794  casadi_assert(node_inputs.size() >= 2, "Gather requires data and indices");
795 
796  // Gather indexes the pseudo-dense column-major layout -> densify a sparse operand.
797  MX data = densify(node_inputs[0]);
798  MX indices_mx = node_inputs[1];
799  casadi_int axis = get_int_attribute(node, "axis", 0);
800 
801  casadi_assert(indices_mx.is_constant(), "Gather indices must be constant");
802  DM indices_dm = static_cast<DM>(indices_mx);
803 
804  if (data.size2() == 1) {
805  // Transpose-rep getnonzeros: data is a column-flat (numel x 1); gather elements by flat
806  // index regardless of the ONNX axis.
807  if (indices_dm.numel() == 1) {
808  output = data(static_cast<casadi_int>(indices_dm(0).scalar()), Slice());
809  } else {
810  output = data(constant_ints(indices_mx), Slice());
811  }
812  } else if (indices_dm.numel() == 1) {
813  casadi_int idx = static_cast<casadi_int>(indices_dm(0).scalar());
814  if (axis == 0) {
815  output = data(idx, Slice()); // a row
816  } else if (axis == 1) {
817  output = data(Slice(), idx); // a column
818  } else {
819  casadi_error("Gather: only axis 0 and 1 supported for 2D tensors");
820  }
821  } else {
822  // Multiple indices: gather the rows (axis 0) / columns (axis 1).
823  std::vector<casadi_int> indices = constant_ints(indices_mx);
824  if (axis == 0) {
825  std::vector<MX> rows;
826  for (casadi_int idx : indices) rows.push_back(data(idx, Slice()));
827  output = vertcat(rows);
828  } else if (axis == 1) {
829  std::vector<MX> cols;
830  for (casadi_int idx : indices) cols.push_back(data(Slice(), idx));
831  output = horzcat(cols);
832  } else {
833  casadi_error("Gather: only axis 0 and 1 supported for 2D tensors");
834  }
835  }
836 
837  } else if (op_type == "ScatterElements") {
838  // data with data.nz[indices] {=,+=} updates -- nz indexing is pseudo-dense, so densify the
839  // target. Constant indices -> (set/add)nonzeros; runtime indices -> the _PARAM variants.
840  casadi_assert(node_inputs.size() >= 3, "ScatterElements requires data, indices, updates");
841  MX se_data = densify(node_inputs[0]);
842  MX se_idx = node_inputs[1];
843  MX se_upd = vec(node_inputs[2]);
844  if (get_string_attribute(node, "reduction") == "add") {
845  // {add,addparam}nonzeros: out = data with data.nz[idx] += updates, accumulating DUPLICATE
846  // idx. No direct MX builder; it is the reverse-mode adjoint of a (parametric)
847  // getnonzeros --
848  // jtimes(w.nz[idx], w, updates, true) is the scatter-add (dups summed) into a zero frame.
849  MX w = MX::sym("scel_w", se_data.numel(), 1), g;
850  if (se_idx.is_constant()) {
851  w.get_nz(g, false, Matrix<casadi_int>(constant_ints(se_idx)));
852  } else {
853  w.get_nz(g, false, vec(se_idx)); // runtime indices -> getnonzeros_param
854  }
855  output = se_data + reshape(jtimes(g, w, se_upd, true), se_data.size1(), se_data.size2());
856  } else {
857  output = se_data;
858  if (se_idx.is_constant()) {
859  output.set_nz(se_upd, false, Matrix<casadi_int>(constant_ints(se_idx)));
860  } else {
861  output.set_nz(se_upd, false, vec(se_idx)); // runtime indices -> setnonzeros_param
862  }
863  }
864 
865  } else if (op_type == "ScatterND") {
866  // setnonzeros: overwrite x's cells at the given (onnx_row, onnx_col) coordinates. With x
867  // stored as x^T, coord (a,b) is CasADi cell (b,a) -> column-major position a*nrow+b, which is
868  // exactly the original nz position, so reconstruct via set_nz on the 2-D value.
869  casadi_assert(node_inputs.size() >= 3, "ScatterND requires data, indices, updates");
870  MX data = densify(node_inputs[0]); // nz indexing is pseudo-dense -> densify the target
871  std::vector<casadi_int> coords = constant_ints(node_inputs[1]); // [r0,c0,r1,c1,...]
872  casadi_int R = data.size1();
873  std::vector<casadi_int> pos(coords.size() / 2);
874  for (casadi_int i = 0; i < static_cast<casadi_int>(pos.size()); ++i) {
875  pos[i] = coords[2 * i] * R + coords[2 * i + 1];
876  }
877  if (get_string_attribute(node, "reduction") == "add") {
878  // ADDNONZEROS: out = data with out.nz[pos] += updates, accumulating DUPLICATE pos. An
879  // addnonzeros MX cannot be built directly; it is the reverse-mode adjoint of a getnonzeros.
880  // Build g = w.nz[pos] on a dense numel-vector w, then jtimes(g, w, updates, /*tr*/true) is
881  // exactly the scatter-add of updates into a zero frame (duplicates summed); add it to data.
882  MX w = MX::sym("addnz_w", data.numel(), 1), g;
883  w.get_nz(g, false, Matrix<casadi_int>(pos));
884  MX scatter = jtimes(g, w, vec(node_inputs[2]), true);
885  output = data + reshape(scatter, data.size1(), data.size2());
886  } else {
887  output = data;
888  output.set_nz(node_inputs[2], false, Matrix<casadi_int>(pos));
889  }
890 
891  } else if (op_type == "Tile") {
892  // Repeat the tensor along each dimension by repeats = [rows, cols].
893  casadi_assert(node_inputs.size() >= 2, "Tile requires data and repeats inputs");
894  MX data = node_inputs[0];
895  MX repeats_mx = node_inputs[1];
896  casadi_assert(repeats_mx.is_constant(), "Tile repeats must be constant");
897  DM repeats_dm = static_cast<DM>(repeats_mx);
898 
899  casadi_int rows_repeat = 1, cols_repeat = 1;
900  if (repeats_dm.numel() >= 1) rows_repeat = static_cast<casadi_int>(repeats_dm(0).scalar());
901  if (repeats_dm.numel() >= 2) cols_repeat = static_cast<casadi_int>(repeats_dm(1).scalar());
902 
903  // Transpose-rep: ONNX repeats [rows,cols] map to CasADi repmat(data, cols, rows)
904  output = repmat(data, cols_repeat, rows_repeat);
905 
906  } else if (op_type == "GatherElements") {
907  // out[..,j,..] = data[.., idx[..,j,..], ..] along `axis`. The export of OP_GETNONZEROS_PARAM
908  // emits this on a 1 x N flat row with a 1 x M index row (-> imported as N x 1 / M x 1 cols),
909  // which is exactly the parametric getnonzeros y = data.nz[idx]. Reconstruct via get_nz so the
910  // sparse/runtime composition (loc-mapping gather then data gather) rebuilds the right MX.
911  casadi_assert(node_inputs.size() >= 2, "GatherElements requires data and indices");
912  MX data = densify(node_inputs[0]); // nz indexing is pseudo-dense -> densify the operand
913  MX indices_mx = node_inputs[1];
914  // Orient indices as a column vector of nz-indices (a 1 x M ONNX row imports as M x 1).
915  MX idx_vec = vec(indices_mx);
916  MX flat = vec(data); // column-major flat of data: flat.nz[k] = data's k-th nonzero
917  if (indices_mx.is_constant()) {
918  // Constant indices -> the plain constant getnonzeros (OP_GETNONZEROS) path.
919  flat.get_nz(output, false, Matrix<casadi_int>(constant_ints(indices_mx)));
920  } else {
921  // Runtime indices -> parametric getnonzeros (OP_GETNONZEROS_PARAM).
922  flat.get_nz(output, false, idx_vec);
923  }
924 
925  } else {
926  casadi_error("Unsupported operation '" + op_type + "'");
927  }
928 
929  return output;
930  }
931 
932 } // namespace casadi
Function object.
Definition: function.hpp:60
casadi_int numel() const
Get the number of elements.
static MX sym(const std::string &name, casadi_int nrow=1, casadi_int ncol=1)
Create an nrow-by-ncol symbolic primitive.
bool verbose_
Verbose – for debugging.
MX - Matrix expression.
Definition: mx.hpp:92
static MX kron(const MX &x, const MX &b)
Definition: mx.cpp:2077
bool is_constant() const
Check if constant.
Definition: mx.cpp:799
void get_nz(MX &m, bool ind1, const Slice &kk) const
Definition: mx.cpp:405
Function create(const std::string &name)
Create a CasADi Function from the loaded ONNX graph.
onnx::ModelProto model_
ONNX model protocol buffer.
Definition: onnx_model.hpp:110
bool has_model_
Whether a model has been loaded.
Definition: onnx_model.hpp:116
The casadi namespace.
Definition: archiver.cpp:28
template class CASADI_EXPORT Matrix< casadi_int >
static std::vector< casadi_int > constant_ints(const MX &m)
T norm_1(const std::vector< T > &x)
double if_else(double x, double y, double z)
Definition: calculus.hpp:296
double sign(double x)
Sign function, note that sign(nan) == nan.
Definition: calculus.hpp:270
double get_float_attribute(const onnx::NodeProto &node, const std::string &name, double default_value)
Read a float node attribute by name, or default_value if absent.
const onnx::GraphProto * get_graph_attribute(const onnx::NodeProto &node, const std::string &name)
Read a subgraph (GraphProto) node attribute by name, or nullptr if absent.
const OpMapping * get_op_mapping_by_name(const std::string &onnx_name)
Lookup operation mapping by ONNX name (for import)
casadi_int get_int_attribute(const onnx::NodeProto &node, const std::string &name, casadi_int default_value)
Read an integer node attribute by name, or default_value if absent.
T norm_2(const std::vector< T > &x)
std::string get_string_attribute(const onnx::NodeProto &node, const std::string &name)
Read a string node attribute by name, or "" if absent.
Matrix< double > DM
Definition: dm_fwd.hpp:33
std::ostream & uout()
@ OP_SIGN
Definition: calculus.hpp:71
@ OP_COS
Definition: calculus.hpp:68
@ OP_ERF
Definition: calculus.hpp:72
@ OP_SINH
Definition: calculus.hpp:74
@ OP_COSH
Definition: calculus.hpp:74
@ OP_ASINH
Definition: calculus.hpp:75
@ OP_ACOS
Definition: calculus.hpp:69
@ OP_MMAX
Definition: calculus.hpp:181
@ OP_ATAN
Definition: calculus.hpp:69
@ OP_SQRT
Definition: calculus.hpp:67
@ OP_INV
Definition: calculus.hpp:73
@ OP_EXP
Definition: calculus.hpp:66
@ OP_MMIN
Definition: calculus.hpp:181
@ OP_SIN
Definition: calculus.hpp:68
@ OP_ASIN
Definition: calculus.hpp:69
@ OP_ACOSH
Definition: calculus.hpp:75
@ OP_CEIL
Definition: calculus.hpp:71
@ OP_SUB
Definition: calculus.hpp:65
@ OP_ATANH
Definition: calculus.hpp:75
@ OP_POW
Definition: calculus.hpp:66
@ OP_FABS
Definition: calculus.hpp:71
@ OP_LOG
Definition: calculus.hpp:66
@ OP_TANH
Definition: calculus.hpp:74
@ OP_NORM1
Definition: calculus.hpp:178
@ OP_ADD
Definition: calculus.hpp:65
@ OP_NORM2
Definition: calculus.hpp:178
@ OP_DIV
Definition: calculus.hpp:65
@ OP_TRANSPOSE
Definition: calculus.hpp:106
@ OP_FLOOR
Definition: calculus.hpp:71
@ OP_NEG
Definition: calculus.hpp:66
@ OP_MUL
Definition: calculus.hpp:65
@ OP_NORMF
Definition: calculus.hpp:178
@ OP_TAN
Definition: calculus.hpp:68