skip to content

On-Device PyTorch (ExecuTorch)

Running PyTorch models on phones: export the model ahead of time, quantize it, and load the result in a small on-device runtime. The supported runtime is ExecuTorch — the older PyTorch Mobile / TorchScript lite-interpreter path is legacy. Interviews focus on size and latency budgets and on the operators that refuse to export.

on this pageshow

questions

12

Why does a data-dependent if in forward() break torch.export for on-device use?

level: middleimportance: must knowfreq 58%

answer

  1. one static graph, no Python on device
  2. the condition needs data that is not there yet
  3. fails loudly, or freezes one branch silently
  4. torch.cond keeps both branches
  5. or hoist the decision into the app

basics

~20 s

Export captures one static graph, so a branch whose condition is a tensor value either fails with a data-dependent guard error or gets frozen to whichever side the example input took. The device runtime has no Python to re-evaluate it.

solid answer

~50 s

`torch.export` traces the model once with example inputs and must emit a single complete graph, because the on-device runtime is a C++ interpreter with no Python left to fall back on. A branch like `if x.sum() > 0:` asks for a concrete boolean from a value that is only symbolic at export time. Two outcomes follow: on a symbolic tensor value, export raises a data-dependent guard error; if the condition happens to reduce to a Python constant during tracing, it silently specializes and the exported graph contains only the branch your example input took — which is the dangerous case, because nothing fails until production inputs hit the other path. The fixes are `torch.cond(pred, true_fn, false_fn, operands)`, which emits both branches as a real runtime conditional, rewriting the branch as arithmetic such as `torch.where`, or hoisting the decision into the calling app.

code

python · 20 lines
python
import torch
from torch.export import export

class Branchy(torch.nn.Module):
    def forward(self, x):
        if x.sum() > 0:          # data-dependent: not exportable
            return x * 2
        return x - 1

class Exportable(torch.nn.Module):
    def forward(self, x):
        return torch.cond(
            x.sum() > 0,
            lambda t: t * 2,
            lambda t: t - 1,
            (x,),
        )

ep = export(Exportable().eval(), (torch.randn(4),))
print(ep.graph_module.code)

go deeper

for a junior

Recall that a model destined for a phone must become one fixed graph, and that an if statement depending on tensor values is the classic thing that stops that from working.

for a middle

Be able to explain both outcomes — a data-dependent guard error, or silent specialization to the branch the example input took — and name torch.cond as the construct that preserves a real runtime branch.

for a senior

Demonstrate the diagnosis: read the export error, distinguish semantic branches from incidental debug or training branches, and insist on parity tests that exercise every path before the artifact ships to users.

for a principal

Set the authoring rules that keep models exportable in the first place — no data-dependent control flow in forward(), decisions hoisted into application code, exportability checked in CI on every model change rather than discovered at release time.

## What export is trying to do `torch.export.export` produces one static graph that describes the model for every input it will ever see. It does that by running your `forward()` once with example inputs while substituting symbolic tensors for real data. Any Python construct that needs to *look at the data* to decide what to do next is therefore a problem: at trace time the data is not there, and at run time the Python is not there. This is the sharpest difference between export and `torch.compile`. `torch.compile` may hit something it cannot capture, insert a **graph break**, and let plain Python execute that region before resuming compiled code. Export has no such escape hatch, and neither does the ExecuTorch runtime on the device — it walks a serialized instruction stream. Full-graph capture is mandatory. ## The two failure modes Write this in a module: ``` def forward(self, x): if x.sum() > 0: return self.a(x) return self.b(x) ``` `x.sum() > 0` is a zero-dimensional tensor. Python's `if` calls `__bool__` on it, which forces a concrete answer. What happens next depends on how the value was produced: 1. **Loud failure.** If the condition depends on a value the exporter is tracking symbolically, export raises a data-dependent error — it cannot guard on an expression whose truth value is unknown. This is the good outcome: you find out on your build machine. 2. **Silent specialization.** If the condition collapses to a plain Python `bool` during tracing — for instance because it depends on a shape that got specialized, on a configuration attribute, or on a constant — export happily records only the taken branch. The other branch simply does not exist in the graph. The exported model runs, is fast, and is wrong for half your inputs. The second is the classic on-device bug, because the eager model in your notebook and the `.pte` on the phone disagree only for inputs your example did not cover. It is also why output-parity testing across a *varied* input set, not one sample, belongs in the export CI job. ## The same trap in loops `for i in range(x.shape[0])` behaves the same way. If the batch dimension is specialized, the loop is unrolled to exactly that many iterations and baked into the graph; feed a different batch size and the guard rejects it (or, worse, the model quietly processes the wrong number of elements if you built the loop over a Python integer you also passed in). `while` loops with tensor conditions fail outright. ## Fix 1 — `torch.cond` `torch.cond(pred, true_fn, false_fn, operands)` is the supported way to keep a genuine runtime branch: ``` return torch.cond(x.sum() > 0, self.a, self.b, (x,)) ``` It is a higher-order operator: both branches are traced and both end up in the graph, and the predicate is evaluated at run time. The constraints are real and worth stating in an interview: both branches must take the same operands, return the same number of outputs with matching dtypes and compatible shapes, must not mutate their inputs or close over tensors outside `operands`, and each must itself be exportable. Backend delegation is also less complete inside conditionals than in straight-line code, so a `torch.cond` region may execute on the runtime's own kernels rather than the accelerator. ## Fix 2 — make it arithmetic Many "branches" are really elementwise selection. `torch.where(cond, a, b)` produces a data-dependent *result* with no control flow at all. The cost is that both sides are always computed, so this is right for cheap expressions and wrong for skipping an expensive subnetwork. ## Fix 3 — hoist the decision out of the model Often the cleanest answer, and the one senior candidates reach for: if the branch decides between two genuinely different networks, export **two** methods (or two `.pte` files) and let the application code pick. The branch condition becomes app logic in Kotlin or Swift, where branching is free and debuggable, and each exported graph stays straight-line and fully delegatable. Preprocessing decisions — orientation, language, model size — usually belong here rather than inside `forward()`. ## Practical shape of the answer When an interviewer hands you "this model won't export", the useful sequence is: read the error to see whether it is a data-dependent guard or an unsupported operator; find the offending `if`/`while` in `forward()`; decide whether the branch is *semantic* (needs `torch.cond` or two exported methods) or *incidental* (a debug path, a training-only branch, an assertion) and simply delete it for the export path; then re-export and diff outputs against eager on inputs that exercise **both** sides of the original branch.

  • Why is silent specialization more dangerous than an export error?
    An error stops your build; specialization ships. The exported graph contains only the branch the example input happened to take, so the model loads, runs fast, and returns confident wrong answers for every input that should have gone the other way. Nothing raises on device, which is why export CI must compare outputs against eager on inputs covering both branches.
  • What constraints does torch.cond place on its two branch functions?
    They must accept the same operands, return the same number of outputs with matching dtypes and compatible shapes, avoid mutating their inputs, avoid closing over tensors not passed in operands, and each must itself be exportable. Both branches are traced into the graph, so both pay their capture cost even though only one executes per call.
  • When would you export two separate methods instead of using torch.cond?
    When the branch selects between genuinely different networks rather than two cheap expressions. Two straight-line graphs delegate to a backend more completely than one containing a conditional region, each can be memory-planned tightly, and the decision becomes ordinary application code in Kotlin or Swift where it is easy to log and test.
  • How does a Python for loop over x.shape[0] behave under export?
    If that dimension is specialized to the example input, the loop is fully unrolled into the graph at exactly that trip count, so the artifact only accepts that size and the graph grows with it. Declaring the dimension dynamic does not rescue a Python loop; you need a vectorized formulation or a supported loop construct instead.

saying these in an interview costs you the question

  • Expects export to fall back to Python like a graph break
  • Assumes both branches always end up in the graph
  • Thinks torch.where gives real control flow rather than elementwise selection
  • Believes the mobile runtime can evaluate the Python condition
  • Tests export parity on a single input that exercises one branch

context

open as a page

How does a PyTorch nn.Module become an ExecuTorch .pte file?

level: middleimportance: must knowfreq 70%

basics

~20 s

Three ahead-of-time steps: torch.export.export captures the model as an ExportedProgram graph, to_edge_transform_and_lower converts it to the Edge dialect and hands supported subgraphs to a backend, and to_executorch emits the flatbuffer you write out as .pte.

open as a page

Why does PT2 export quantization need a backend quantizer like XNNPACKQuantizer?

level: middleimportance: must knowfreq 48%

basics

~20 s

Because only the backend knows which operator patterns it can actually execute in int8. The quantizer encodes that contract, annotating exactly the nodes the delegate can fuse; quantizing anything else adds quantize/dequantize work around an op that still runs in float.

open as a page

In ExecuTorch int8 quantization, when do you need calibration data?

level: middleimportance: must knowfreq 52%

basics

~20 s

Only for static quantization, where activation scales are frozen ahead of time and must come from observed data. Dynamic quantization quantizes weights offline and computes activation scales at runtime per input, so no calibration set is required.

open as a page

What do prepare_pt2e and convert_pt2e do to an exported PyTorch graph?

level: middleimportance: must knowfreq 60%

basics

~20 s

prepare_pt2e inserts observer modules at the tensors a quantizer annotated, so a calibration or training run records their value ranges. convert_pt2e then replaces each observer with quantize/dequantize op pairs carrying the scale and zero-point it computed.

open as a page

How do you load and run an ExecuTorch .pte model in a mobile app?

level: juniorimportance: should knowfreq 36%

basics

~20 s

Ship the .pte to a readable file path, open it with the ExecuTorch Module API for your platform, wrap input tensors in the runtime's value type, call forward, and unwrap the returned tensor. No Python and no PyTorch training stack are involved.

open as a page

Which PyTorch API quantizes a model for on-device ExecuTorch use today?

level: juniorimportance: should knowfreq 45%

basics

~10 s

PT2 export quantization: capture the model with torch.export, then run prepare_pt2e and convert_pt2e from torchao, driven by a backend quantizer such as XNNPACKQuantizer. The older eager torch.ao.quantization path is deprecated.

open as a page

Why does an exported ExecuTorch model reject a different input size on device?

level: seniorimportance: should knowfreq 48%

basics

~20 s

torch.export traces with example inputs and specializes every dimension you did not declare dynamic, recording the concrete sizes as guards. Anything else violates a guard at run time. Declare flexible dimensions with torch.export.Dim through the dynamic_shapes argument.

open as a page

What does XnnpackPartitioner do to the graph when lowering an ExecuTorch model?

level: seniorimportance: should knowfreq 45%

basics

~20 s

It walks the Edge-dialect graph and claims the subgraphs XNNPACK can execute. Each claimed subgraph is compiled into an opaque delegate blob and replaced by a call-delegate node; everything it declines stays as ordinary operators run by the runtime's own kernels.

open as a page

Int8 PTQ cost you 4 points of accuracy on device — how do you recover it?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Work cheapest-first: verify calibration coverage, switch weights to per-channel, then find the sensitive layers by comparing float and quantized intermediates and exempt them. Only if that falls short move to QAT with prepare_qat_pt2e and a short fine-tune.

open as a page

When is the TorchScript lite-interpreter path still the right on-device choice?

level: principalimportance: nice to knowfreq 20%

basics

~20 s

Rarely, and only as an interim position: an existing shipped pipeline whose scripted models still refuse to export, where the migration cost outweighs the near-term benefit. ExecuTorch is the supported path, so the lite interpreter is a bridge, not a destination.

open as a page

On a phone latency and app-size budget, do you quantize or ship a smaller model?

level: principalimportance: nice to knowfreq 30%

basics

~20 s

Usually both, in order: fix the budgets and accuracy floor first, take int8 quantization because it is the cheapest lever, and change architecture when quantization alone misses the target. They compose — a smaller model quantizes too.

open as a page