Arm: how much of each LeRobot policy the Arm backend (VGF, Ethos-U85) takes, and what blocks the rest

Ouverte
#24,697 0 commentaires 0 réactions 0 personnes assignées Voir sur GitHub

Les mainteneurs répondent en général sous 1 jour

Personne n'a encore pris cette issue.

Évaluation

Difficulté
4/5
Temps estimé
3-5 jours
Accessibilité débutants
43/100
Type d'issue
Bug
Clarté
Plutôt claire
Activité
Active
Stack technique
python, pytorch

Piste de recherche

Start with the ExecuTorch table under “Known issues” and run the short repro for one row using the ExecuTorch 1.6.0 wheel with its Arm extras; no device is needed. The issue lists six problems, including quantizer failures, unsupported lowerings, and slow partitioning, but does not select one to fix. Done means addressing an unowned problem and confirming its repro succeeds or the documented behavior changes.

Rédigé par le modèle d'indexation à partir du texte de l'issue.

Description

module: arm

This issue tracks LeRobot policies exported to ExecuTorch with its Arm backend: the VGF path (VgfPartitioner, Arm's ML extensions for Vulkan) and the Ethos-U85 NPU (EthosUPartitioner, int8). It shows how much of each policy the backend takes, whether the program exports, and what each project still needs to look at. Other backends have their own issues: TensorRT in #24353, OpenVINO in #24325, XNNPACK in #24667, Qualcomm in #24668, Core ML and MLX in #24669, the CUDA backend in #24684, and Vulkan in #24688.

Why this matters for LeRobot: many low-cost robot boards are built on Arm chips, for example the Raspberry Pi 5 that LeRobot's LeKiwi mobile robot carries. The Arm CPU itself is XNNPACK's job (#24667). The Arm backend covers Arm's accelerators: VGF targets Arm GPUs through Arm's ML extensions for Vulkan (on GPUs without native support they run through Arm's emulation layer), and Ethos-U is Arm's NPU for microcontrollers and small chips, so only the smallest policies can fit it. This issue checks export only so far; nothing has run on a device yet.

Coverage

VGF (fp32)
Policy Ops on VGF Heavy math on VGF VGF programs Exports to .pte
act 1,479 / 1,480 (100%) 100% 1 $\color{#2da44e}{\checkmark}$
diffusion 596 / 603 (99%) 96% 5 $\color{#2da44e}{\checkmark}$
dm05 ⏳ ⏳ ⏳ ⏳
eo1 ⏳ ⏳ ⏳ ⏳
evo1 10,383 / 12,347 (84%)
with +bf16: 12,334 / 12,347 (100%)
26%
with +bf16: 100%
61
with +bf16: 4
$\color{#d4a017}{\textsf{slow}}$
with +bf16: $\color{#d4a017}{\textsf{slow}}$
fastwam ⏳ ⏳ ⏳ ⏳
fineart_vla 16,817 / 16,904 (99%) 100% 4 $\color{#d4a017}{\textsf{slow}}$
flux3 ⏳ ⏳ ⏳ ⏳
gaussian_actor 104 / 121 (86%) 100% 4 $\color{#2da44e}{\checkmark}$
groot ⏳ ⏳ ⏳ ⏳
lawam 12,118 / 14,419 (84%) 44% 132 $\color{#d4a017}{\textsf{slow}}$
lingbot_va ⏳ ⏳ ⏳ ⏳
lingbot_vla_v2 ⏳ ⏳ ⏳ ⏳
molmoact2 ⏳ ⏳ ⏳ ⏳
multi_task_dit 1,109 / 1,113 (100%) 100% 3 $\color{#2da44e}{\checkmark}$
pi0 ⏳ ⏳ ⏳ ⏳
pi05 ⏳ ⏳ ⏳ ⏳
pi0_fast 5,159 / 5,191 (99%) 100% 5 $\color{#d4a017}{\textsf{slow}}$
smolvla 2,859 / 5,836 (49%)
with +bf16: 5,636 / 5,836 (97%)
16%
with +bf16: 100%
177
with +bf16: 5
$\color{#2da44e}{\checkmark}$
with +bf16: $\color{#2da44e}{\checkmark}$
tdmpc 3,532 / 3,617 (98%) 100% 14 $\color{#2da44e}{\checkmark}$
vla_jepa 5,781 / 8,877 (65%)
with +bf16: 8,843 / 8,877 (100%)
6%
with +bf16: 100%
192
with +bf16: 11
$\color{#d4a017}{\textsf{slow}}$
with +bf16: $\color{#2da44e}{\checkmark}$
vqbet 469 / 483 (97%) 100% 3 ❌
wall_oss_05 ⏳ ⏳ ⏳ ⏳
wall_x ⏳ ⏳ ⏳ ⏳
xvla 13,146 / 13,148 (100%) 100% 1 ❌
Ethos-U85 (int8, small policies only)
Policy Ops on Ethos-U85 Heavy math on Ethos-U85 Ethos-U85 programs Exports to .pte
act 1,655 / 1,656 (100%) 100% 1 $\color{#2da44e}{\checkmark}$
diffusion 765 / 1,001 (76%) 96% 32 $\color{#2da44e}{\checkmark}$
gaussian_actor 120 / 130 (92%) 100% 3 $\color{#2da44e}{\checkmark}$
multi_task_dit 1,618 / 1,623 (100%) 100% 4 $\color{#2da44e}{\checkmark}$
tdmpc ⏳ ⏳ ⏳ ⏳
vqbet 757 / 772 (98%) 100% 5 $\color{#2da44e}{\checkmark}$

Legend:

  • "Ops on ...": the graph ops the partitioner takes, out of all ops, over all programs of the policy. Ops are counted after to_edge.
  • "Heavy math on ...": the share of multiply-adds in matmuls, linear layers, convolutions and attention that the backend takes. A program counts as many times as one action chunk runs it (for example, once per denoising step).
  • "... programs": how many delegate partitions the policy is split into. Between 2 partitions, the data goes back to the CPU.
  • "Exports to .pte": $\color{#2da44e}{\checkmark}$ means to_edge_transform_and_lower(...).to_executorch() finished. $\color{#d4a017}{\textsf{slow}}$ means partitioning or lowering did not finish within 15 minutes; a longer run is queued. ❌ means it fails; the cause is in the tables below.
  • ⏳: this cell is being measured (running or queued).

Notes:

  • So far this is export only, with VgfCompileSpec() and EthosUCompileSpec("ethos-u85-256") at their defaults.
  • Ethos-U85 runs are int8, quantized with the Arm backend's EthosUQuantizer (symmetric, per channel) on the one frame the export traces. They cover only the small policies; the larger ones cannot fit a microcontroller NPU.
  • LeRobot has no Arm backend for lerobot-export yet (LeRobot table). So each policy is traced with the trace step of lerobot-export, the same way as the Vulkan rows of #24688, and each traced program is lowered with the Arm partitioner.
  • diffusion, dm05, eo1, fineart_vla, gaussian_actor, lingbot_vla_v2, molmoact2, multi_task_dit, tdmpc, vla_jepa, vqbet and wall_x use random weights, made by the same script and seed as in #24353. The other policies use their real weights.
  • evo1 and smolvla keep their bf16 weights, so with the default spec their bf16 parts stay on the CPU (row 5 below).

Built on

  • ExecuTorch: the official 1.6.0 wheel (the final release) with its vgf and ethos-u extras from pip: tosa-tools 2026.5.0, ethos-u-vela 5.2.0, ai_ml_sdk_model_converter 0.11.0, ai_ml_emulation_layer_for_vulkan 0.11.0. No source build and no patches.
  • torch 2.15.0, transformers 5.22.1.
  • LeRobot: the lerobot-export-stack branch of shoumikhin/lerobot as it was on Nov 3, pinned by the export-stack-2026-11-03 tag (see #24353).

Known issues

"Fix" is an open pull request unless it says merged. "Needs a decision" is a design question for the maintainers. "Needs an owner" means nobody is fixing it yet.

ExecuTorch
  • Pick up the rows marked "needs an owner". The first 2 rows block whole policies.
  • Every row has a short repro below the table. It needs only the ExecuTorch 1.6.0 wheel with its Arm extras, no device.
Problem Policies Fix Status
The Arm quantizer fails on the module that ExportedProgram.module() returns by default in torch 2.15: the guard node it adds is a call_module, and prepare_pt2e raises call_module is not supported. module(check_guards=False) works. every Ethos-U85 export needs an owner Repro ready (repro 1)
An int64 index that export lifts as a tensor constant (not a buffer) fails the whole VGF lowering: index.Tensor requires index dtype must be int32. The same index as a buffer is cast to int32 and works. vqbet, xvla needs an owner Repro ready (repro 2)
The Ethos-U quantizer does not annotate Mish, so each Mish stays in float on the CPU and splits the graph. diffusion (38 Mish, 32 programs) needs an owner Repro ready (repro 3)
No TOSA lowering for transposed 1D convolution, argmin, topk with K above 4, rows picked by such indices, or int64 index math. Each one cuts the graph. diffusion (transposed conv1d), tdmpc (its planner's argmin, topk and index math) needs an owner Repro ready (repro 4)
bf16 is not in the default VGF TOSA spec, so bf16 ops stay on the CPU with no warning. Adding +bf16 to the spec makes them lower. evo1, smolvla needs a decision: enable bf16 by default, or warn Repro ready (repro 5)
Partitioning big graphs is slow. The Arm partitioners use torch.fx's CapabilityBasedPartitioner, and its merge step grows much faster than the graph (same as Vulkan repro 4 in #24688). evo1, fineart_vla, and the larger policies needs an owner Repro ready (repro 6): 200 blocks take 19 s, 400 blocks take 134 s
Repros (export only, ExecuTorch 1.6.0, torch 2.15.0)
import time

import torch
from executorch.backends.arm.ethosu import EthosUCompileSpec, EthosUPartitioner
from executorch.backends.arm.quantizer import EthosUQuantizer, get_symmetric_quantization_config
from executorch.backends.arm.vgf import VgfCompileSpec, VgfPartitioner
from executorch.exir import EdgeCompileConfig, to_edge, to_edge_transform_and_lower
from torchao.quantization.pt2e.quantize_pt2e import convert_pt2e, prepare_pt2e

CONFIG = EdgeCompileConfig(_check_ir_validity=False)
SKIP = ("executorch_call_delegate", "getitem", "quantize_per", "dequantize_per")


class Fn(torch.nn.Module):
    def __init__(self, fn, *modules):
        super().__init__()
        self.fn = fn
        self.layers = torch.nn.ModuleList(modules)

    def forward(self, x):
        return self.fn(self, x)


def summary(edge):
    """How many Arm programs the model lowers to, and which ops stay on the CPU."""
    names = [str(n.target).split("::")[-1].split("(")[0]
             for n in edge.exported_program().graph.nodes if n.op == "call_function"]
    return names.count("executorch_call_delegate"), sorted({n for n in names if not any(s in n for s in SKIP)})


def root(error):
    while error.__cause__ is not None:
        error = error.__cause__
    return f"{type(error).__name__}: {str(error).splitlines()[0][:140]}"


def vgf(model, x, spec=None):
    try:
        ep = torch.export.export(model.eval(), (x,))
        edge = to_edge_transform_and_lower(ep, partitioner=[VgfPartitioner(VgfCompileSpec(spec))], compile_config=CONFIG)
        return summary(edge)
    except Exception as error:
        return root(error)


def quantizer():
    q = EthosUQuantizer(EthosUCompileSpec("ethos-u85-256"))
    q.set_global(get_symmetric_quantization_config(is_per_channel=True))
    return q


def u85(model, x):
    m = prepare_pt2e(torch.export.export(model.eval(), (x,)).module(check_guards=False), quantizer())
    m(x)
    ep = torch.export.export(convert_pt2e(m), (x,))
    edge = to_edge_transform_and_lower(ep, partitioner=[EthosUPartitioner(EthosUCompileSpec("ethos-u85-256"))],
                                       compile_config=CONFIG)
    return summary(edge)


def case(label, result):
    print(f"{label:46s} -> {result}", flush=True)


# 1. The Arm quantizer fails on the module that ExportedProgram.module() returns by default.
conv_mean = Fn(lambda s, t: s.layers[0](t).mean(dim=(2, 3)), torch.nn.Conv2d(3, 8, 3))
ep = torch.export.export(conv_mean.eval(), (torch.randn(1, 3, 16, 16),))
for check_guards in (True, False):
    try:
        prepare_pt2e(ep.module(check_guards=check_guards), quantizer())
        result = "OK"
    except Exception as error:
        result = root(error)
    case(f"1. module(check_guards={check_guards}) -> prepare_pt2e", result)


# 2. An int64 index that is a tensor constant (not a buffer) fails the whole lowering.
class Buffer(torch.nn.Module):
    def __init__(self):
        super().__init__()
        self.register_buffer("idx", torch.tensor([0, 2]))

    def forward(self, x):
        return x[:, self.idx]


class Constant(torch.nn.Module):
    def __init__(self):
        super().__init__()
        self.idx = torch.tensor([0, 2])  # a plain tensor attribute, which export lifts as a constant

    def forward(self, x):
        return x[:, self.idx]


x = torch.randn(1, 4, 8)
case("2. VGF x[:, idx], idx an int64 buffer", vgf(Buffer(), x))
case("2. VGF x[:, idx], idx an int64 constant", vgf(Constant(), x))


# 3. The Ethos-U quantizer does not annotate Mish, so it stays in float on the CPU.
def block(act):
    return torch.nn.Sequential(torch.nn.Conv1d(16, 16, 3, padding=1), act, torch.nn.Conv1d(16, 16, 3, padding=1))


x = torch.randn(1, 16, 32)
for label, act in (("SiLU", torch.nn.SiLU()), ("ELU", torch.nn.ELU()), ("Mish", torch.nn.Mish())):
    case(f"3. U85 Conv1d -> {label} -> Conv1d", u85(block(act), x))

# 4. Ops with no TOSA lowering stay on the CPU and split the graph.
case("4. VGF ConvTranspose1d", vgf(torch.nn.ConvTranspose1d(16, 8, 4, stride=2, padding=1), x))
case("4. VGF ConvTranspose2d (for comparison)", vgf(torch.nn.ConvTranspose2d(16, 8, 4, stride=2, padding=1), x.unsqueeze(-1)))
s = torch.randn(64, 16)
case("4. VGF argmax (for comparison)", vgf(Fn(lambda _, t: t.argmax(-1).to(torch.int32)), s))
case("4. VGF argmin", vgf(Fn(lambda _, t: t.argmin(-1).to(torch.int32)), s))
case("4. VGF topk(4) of a [64, 16] score", vgf(Fn(lambda _, t: t.topk(4).values), s))
case("4. VGF topk(8) of a [64, 16] score", vgf(Fn(lambda _, t: t.topk(8).values), s))
case("4. VGF rows picked by topk(8) indices", vgf(Fn(lambda _, t: t[t.sum(-1).topk(8).indices]), s))
case("4. VGF index % 7 (int64 math)", vgf(Fn(lambda _, t: (t.argmax(-1) % 7).to(torch.float32)), s))

# 5. bf16 is not in the default VGF TOSA spec, so bf16 ops stay on the CPU without a warning.
lin16 = torch.nn.Linear(64, 64).to(torch.bfloat16)
x16 = torch.randn(4, 64, dtype=torch.bfloat16)
case("5. VGF bf16 Linear, default VgfCompileSpec()", vgf(lin16, x16))
case("5. VGF bf16 Linear, TOSA spec with +bf16", vgf(lin16, x16, "TOSA-1.0+FP+INT+int4+int16+bf16"))


# 6. Partitioning time grows fast with graph size when CPU ops sit on side branches.
class Blocks(torch.nn.Module):
    def __init__(self, n):
        super().__init__()
        self.layers = torch.nn.ModuleList(torch.nn.Linear(32, 32) for _ in range(n))

    def forward(self, x):
        for layer in self.layers:
            a = layer(x)
            x = a * torch.sigmoid(a) + torch.atan2(a, a + 2)  # atan2 stays on the CPU
        return x


for n in (50, 100, 200, 400):
    ep = to_edge(torch.export.export(Blocks(n).eval(), (torch.randn(1, 32),)), compile_config=CONFIG).exported_program()
    start = time.time()
    tags = VgfPartitioner(VgfCompileSpec()).partition(ep).partition_tags
    print(f"6. {n:4d} blocks: {len(tags):4d} VGF partitions, partition() took {time.time() - start:6.1f} s", flush=True)

Output:

1. module(check_guards=True) -> prepare_pt2e   -> ExportPassBaseError: call_module is not supported.
1. module(check_guards=False) -> prepare_pt2e  -> OK
2. VGF x[:, idx], idx an int64 buffer          -> (1, [])
2. VGF x[:, idx], idx an int64 constant        -> AssertionError: index.Tensor requires index dtype must be int32
3. U85 Conv1d -> SiLU -> Conv1d                -> (1, [])
3. U85 Conv1d -> ELU -> Conv1d                 -> (1, [])
3. U85 Conv1d -> Mish -> Conv1d                -> (2, ['exp', 'gt.Scalar', 'log1p', 'mul.Tensor', 'tanh', 'where.self'])
4. VGF ConvTranspose1d                         -> (0, ['convolution'])
4. VGF ConvTranspose2d (for comparison)        -> (1, [])
4. VGF argmax (for comparison)                 -> (1, [])
4. VGF argmin                                  -> (0, ['_to_dim_order_copy', 'argmin'])
4. VGF topk(4) of a [64, 16] score             -> (1, [])
4. VGF topk(8) of a [64, 16] score             -> (0, ['topk'])
4. VGF rows picked by topk(8) indices          -> (2, ['_to_dim_order_copy', 'topk'])
4. VGF index % 7 (int64 math)                  -> (0, ['_to_dim_order_copy', 'argmax', 'remainder.Scalar'])
5. VGF bf16 Linear, default VgfCompileSpec()   -> (0, ['linear'])
5. VGF bf16 Linear, TOSA spec with +bf16       -> (1, [])
6.   50 blocks:   51 VGF partitions, partition() took    0.4 s
6.  100 blocks:  101 VGF partitions, partition() took    2.2 s
6.  200 blocks:  201 VGF partitions, partition() took   18.9 s
6.  400 blocks:  401 VGF partitions, partition() took  133.6 s

LeRobot
  • Decide the rows marked "needs a decision".
Problem Policies Fix Status
lerobot-export has no Arm backend, so there is no Arm bundle for lerobot-rollout --bundle.path to run. all needs a decision: an executorch_arm backend in LeRobot, next to executorch_xnnpack and executorch_openvino Open
LeRobot's export folds constants after quantizing, which turns the dequantized weights back into float, so the Ethos-U partitioner rejects convolutions and linear layers as "not quantized". Folding before quantizing works. all Ethos-U85 exports needs an owner (LeRobot) Measured
lerobot-export and the torch-free lerobot-rollout --bundle.path exist only on the lerobot-export-stack branch. all needs a decision: how to bring them upstream in reviewable pieces (same row as #24684) Open

cc @digantdesai @freddan80 @per @zingo @oscarandersson8218 @mansnils @Sebastian-Larsson @robell @rascani

Langage dominant
Python
Étoiles
5.2k
Forks
1.2k
Merge moyen
2 j 37 min
PR mergées (30 j)
693

Préparer son environnement

Par où commencer

  1. Lisez l'issue en entier, puis le guide de contribution du projet.
  2. Signalez en commentaire que vous la prenez — cela évite que deux personnes fassent le même travail.
  3. Forkez le dépôt et travaillez sur une branche.
  4. Ouvrez une pull request qui référence le numéro de l'issue.

Autres issues de pytorch/executorch

Toutes les issues de pytorch/executorch

Issues similaires

Plus d'issues Python

Recevez les nouvelles issues par e-mail

Un résumé court des issues GitHub adaptées aux débutants.