PyTorch#

PyTorch is the path LEAPP is built around. Tensors handed to input_tensors() come back as TracedTensor, a torch.Tensor subclass that shares storage with the tensor you passed in, and every torch operation applied to one is recorded into the graph that gets exported.

Nothing is translated along the way. NumPy tracing looks each call up in a table of torch equivalents and Warp hands whole regions to APIC capture, but a torch call is recorded as itself. See How it works for the interception model and Limitations for what it cannot represent.

Example: a two-node policy pipeline#

Normalization and the policy are separate nodes, so they can be exported with different backends and deployed as separate models.

import torch
import leapp
from leapp import annotate

STATE_MEAN = torch.zeros(6)
STATE_STD = torch.full((6,), 0.5)

class Policy(torch.nn.Module):
    def __init__(self):
        super().__init__()
        self.net = torch.nn.Linear(9, 6)

    def forward(self, obs: torch.Tensor) -> torch.Tensor:
        return torch.tanh(self.net(obs))

def preprocess(state, velocity):
    state, velocity = annotate.input_tensors("preprocess", {
        "state": state,
        "velocity": velocity,
    })

    state_norm = torch.clamp((state - STATE_MEAN) / STATE_STD, -5.0, 5.0)
    obs = torch.cat([state_norm, velocity])

    annotate.output_tensors("preprocess", {"obs": obs}, export_with="jit")
    return obs

def run_policy(policy, obs):
    traced_obs = annotate.input_tensors("policy", {"obs": obs})
    action = policy(traced_obs.unsqueeze(0)).squeeze(0)
    annotate.output_tensors("policy", {"action": action}, export_with="onnx")
    return action

def main():
    policy = Policy().eval()

    leapp.start(name="torch_pipeline")
    obs = preprocess(torch.zeros(6), torch.zeros(3))
    run_policy(policy, obs)
    leapp.stop()
    leapp.compile_graph()

Each input_tensors and output_tensors pair marks one node. obs leaves preprocess as a published output and arrives at policy as an input, which is what connects the two nodes in the exported pipeline.

This is the graph LEAPP records for preprocess:

%state             = placeholder[target=state]
%velocity          = placeholder[target=velocity]
%_tensor_constant0 = get_attr[target=_tensor_constant0]
%sub               = call_function[target=torch.sub](args = (%state, %_tensor_constant0))
%_tensor_constant1 = get_attr[target=_tensor_constant1]
%div               = call_function[target=torch.div](args = (%sub, %_tensor_constant1))
%clamp             = call_function[target=torch.clamp](args = (%div, -5.0, 5.0))
%cat               = call_function[target=torch.cat](args = ([%clamp, %velocity],))
return cat

Every operation appears under its own torch name, and the two traced inputs are placeholders. STATE_MEAN and STATE_STD never passed through input_tensors(), so they are frozen into the graph as get_attr constants instead.