Understanding Aidge Intermediate Representation (IR)#

Binder

In this notebook, we will explore the Aidge Intermediate Representation (IR).

The Aidge framework represents a Deep Neural Network (DNN) model as a directed computational graph, where vertices correspond to computational operations and edges define the flow of data between these operations.

The graph is explicitly defined by the user and not inferred from code.

By the end, you will understand how Aidge defines a graph and how to manipulate it.

Install requirements#

Ensure that the Aidge modules are properly installed in the current environment. If it is the case, the following setup steps can be skipped.
Note: When running this notebook on Binder, all required components are pre-installed.
[ ]:
%pip install aidge-core
[ ]:
# After install, import module
import aidge_core

Operator#

An Aidge Operator is a data structure that characterizes an operation. For example, we can instantiate the LeakyReLU operator as follows:

[2]:
leaky_relu_operator = aidge_core.LeakyReLUOp(negative_slope=1)

This operator has an attribute that defines the probability of setting some elements of the input tensor to zero. One can see the attribute of an operator using the attr attribute:

[ ]:
leaky_relu_operator.attr

An Operator is merely a data structure and does not include an implementation. If the user wants to perform inference, training, or code generation with their graph, they must provide an implementation, which can be done using the setBackend function. However, in this tutorial, we will focus on the IR and will not cover how to do this.

An interesting point is that since an Operator lacks an implementation, we can introduce the concept of a GenericOperator. This is a simple shell that can represent any operator. Such a GenericOperator allows loading any operator not defined in the Aidge framework, enabling users to define its behavior.

[ ]:
gen_op = aidge_core.GenericOperatorOp(
    type="CustomOp", nb_data=1, nb_param=0, nb_outputs=1
)
print(gen_op)

We can then add any attributes we want using the following functions:

[ ]:
if not gen_op.attr.has_attr("test"):
    gen_op.attr.add_attr("test", True)
else:
    gen_op.attr.set_attr("test", False)
print(gen_op.attr)

The Operator also holds a reference to the Inputs/Outputs tensors. Which one can get using get_output() function:

[ ]:
print(
    f"DropOut operator input: {leaky_relu_operator.get_input(0)}"
)  # get the 0th input of the dropout operator.
print(
    f"DropOut operator output: {leaky_relu_operator.get_output(0)}"
)  # get the 0th output of the dropout operator.

Node#

The Node carries the topological information of the graph. Each node maintains references to its outgoing and incoming edges, which connect it to its child and parent nodes, respectively. These connections represent the flow of data between nodes. The term “data” is deliberately broad, as it can be specialized into different types, such as tensors, sparse tensors, or spikes.

A Node must contain an Operator. One can create a node using the following:

[ ]:
leaky_relu_node = aidge_core.Node(leaky_relu_operator)
print(leaky_relu_node)

Creating nodes and operators manually can be tedious. Aidge simplifies this with factory functions that generate nodes with embedded operators. These functions omit the Op suffix (reserved for operator classes).

Eample: Creating a LeakyReLU node:

[ ]:
leaky_relu_node = aidge_core.LeakyReLU(negative_slope=0.1, name="myLeakyReLU")
print(leaky_relu_node)
print(
    leaky_relu_node.get_operator()
)  # <- Equivalent to out previous leaky_relu_operator

As stated above, the Node contains the neighbors informations:

[ ]:
print(leaky_relu_node.get_parents())
print(leaky_relu_node.get_children())

Nodes can be connected to each other like this:

[ ]:
node_0 = aidge_core.ReLU(name="relu0")
leaky_relu_node.add_child(node_0)
print(node_0.get_parents())
print(leaky_relu_node.get_children())

GraphView#

While these two objects already define a graph, performing operations on the entire graph at once would be difficult without a dedicated graph object. This is why we introduce the GraphView concept. As its name implies, this object provides a view of the graph rather than being the graph itself.

[ ]:
graph_view = aidge_core.GraphView()

graph_view.add(node_0)
graph_view.add(leaky_relu_node)
print(graph_view)

Graph1

This allows, for example, setting the datatype of both Nodes in a single line:

[12]:
graph_view.set_datatype(aidge_core.dtype.float32)

You can insert a new Node into the graph using add_child() method:

[ ]:
node_1 = aidge_core.ReLU(name="relu1")
graph_view.add_child(node_1)
print(graph_view)

⚠️ Important: Connecting nodes to each other does not automatically add them to a GraphView.

[ ]:
print(f"Before connecting nodes, graphview has {len(graph_view.get_nodes())} nodes.")
node_2 = aidge_core.ReLU(name="relu2")
node_1.add_child(node_2)
print(f"After connecting nodes, graphview has {len(graph_view.get_nodes())} nodes.")

Graph(2)

To add node_2 to the graph, you need to call the add() method as follows:

[ ]:
graph_view.add(node_2)
print(f"And now, graphview has {len(graph_view.get_nodes())} nodes.")

Graph(4)

Connectors#

Aidge supports a functional programming style through its Connector system. Nodes can be treated as functions, accepting Connectors as inputs and returning new Connectors as outputs.

[16]:
x = aidge_core.Connector()
y = aidge_core.ReLU()(aidge_core.FC(784, 300)(x))

Alternatively, the same process can be implemented step-by-step as follows:

[ ]:
x = aidge_core.Connector()
x = aidge_core.FC(784, 300)(x)
x = aidge_core.ReLU()(x)
x = aidge_core.FC(300, 10)(x)

gv = aidge_core.generate_graph([x])
print(f"The graph has {len(gv.get_nodes())} nodes.")

A node can accept multiple connectors as inputs. Each connector is mapped to a corresponding input of the node.

⚠️ Important: Passing more connectors than the node has available inputs will result in a runtime error.

[ ]:
n1 = aidge_core.GenericOperator("type", 1, 0, 1, "op1")
n2 = aidge_core.GenericOperator("type", 1, 0, 1, "op2")
n3 = aidge_core.GenericOperator("type", 2, 0, 1, "op3")  # Node with 2 inputs

x = aidge_core.Connector()
x = n3(n1(x), n2(x))
print(n3.get_parents())
print(n3.get_parent(0))
print(n3.get_parent(1))

After defining your graph using connectors, you can generate a GraphView to manipulate the entire graph structure:

[ ]:
x = aidge_core.Connector()
x = aidge_core.FC(784, 300)(x)
x = aidge_core.ReLU()(x)
x = aidge_core.FC(300, 100)(x)
x = aidge_core.ReLU()(x)
x = aidge_core.FC(100, 10)(x)
gv = aidge_core.generate_graph([x])
print(f"The new graph view has {len(gv.get_nodes())} nodes.")

Graph Helpers#

For simplified graph construction, Aidge provides the sequential helper function. This utility automatically connects the first output of each node to the first input of the subsequent node, creating a linear pipeline.

[ ]:
sequentialGraphView = aidge_core.sequential(
    [
        aidge_core.FC(784, 300, name="fc0"),
        aidge_core.ReLU(),
        aidge_core.FC(300, 100, name="fc1"),
        aidge_core.ReLU(),
        aidge_core.FC(100, 10, name="fc2"),
    ]
)
print(f"The new graph view has {len(sequentialGraphView.get_nodes())} nodes.")

Sequential