Simulation Engineering Toolkit — Presentation 10

From PyTorch and ONNX to an Accelerator Model

A working front end for an accelerator simulator: real model configurations (Llama-3-8B and -70B, Mistral, Qwen, GPT-2) traced without weights on the meta device and under fake tensors, torch.export, a torch.compile backend and an ONNX graph walk, all four agreeing exactly on the arithmetic, checked against closed forms and PyTorch's FLOP counter, costed on a roofline, with operator coverage counted three ways.

Meta device Fake tensors torch.export torch.compile ONNX Operator coverage
Model → Capture → Trace → Check → Cost → Cover
00

Topics We'll Cover

Concepts used here, and where they are explained. Each links to a glossary entry: this series glossary, or the glossaries of LLM Inference Simulators and FHE Accelerator Simulators for concepts those series already explain.

01

Why a Simulator Needs a Front End

An accelerator simulator is only as useful as the workloads it can run. Hand-written workloads ("a 4096 × 4096 matmul, then a softmax") answer the questions the architect already thought of. Real users arrive with a PyTorch model or an ONNX file. A front end turns either into the operator stream the cost model consumes, so the simulator sees what the framework would actually execute.

LLM Inference Simulators 09 surveys the integration routes. This deck builds four of them in Torch_Sim_Frontend, runs them on published model configurations (Llama-3-8B and -70B, Mistral-7B, Qwen2.5-0.5B, GPT-2) on a CPU-only machine without a single weight, and checks them against each other, against closed forms and against PyTorch's own FLOP counter.

The questions a front end must answer
02

One Trace Format, Four Front Ends

dispatch traceTorchDispatchMode, meta/fake torch.exportCore ATen FX graph torch.compile backendDynamo + AOTAutograd ONNX graph walkshape inference, folding simfront-trace/1ops, shapes, sizes,tensor ids, weight flag Rooflineunfused / fused bounds Offloaddevice + host + link coverage reportops, FLOPs, time

Every front end produces the same record per operator. Tensor identities let a cost model follow data from producer to consumer, which is what an offload model needs to charge a transfer. The weight flag separates parameter traffic from activations, and is propagated through views (W.t() before mm is still a weight).

src/simfront/trace.py: one operator source
class Op:
    """One operator call. ``name`` is canonical: ``aten.mm`` or ``onnx.MatMul``."""

    name: str
    inputs: list[TensorMeta]
    outputs: list[TensorMeta]
    attrs: dict = field(default_factory=dict)
    category: str = "unknown"
    flops: int = 0
    bytes_read: int = 0
    bytes_written: int = 0
    weight_bytes: int = 0      # the part of bytes_read that is parameters
03

Real Models, No Weights: the Meta Device

A tensor on the meta device has a shape, a dtype and a stride, but no storage. Building a model inside torch.device("meta") gives every parameter that form, so a 70-billion-parameter model is instantiated in under a second and costs no memory. The configurations are the architectural fields of the published Hugging Face config.json files, bundled with their source URLs.

src/simfront/models.py source
def build(cfg, device: str = "meta", dtype: torch.dtype = torch.bfloat16, seed: int = 0) -> torch.nn.Module:
    """``AutoModelForCausalLM.from_config`` on ``device``; ``cfg`` is a config or a registry name."""
    from transformers import AutoModelForCausalLM

    if isinstance(cfg, str):
        cfg = config(cfg)
    torch.manual_seed(seed)
    with torch.device(device):
        model = AutoModelForCausalLM.from_config(cfg, dtype=dtype)
    return model.eval()
ModelParametersOperatorsOp typesCapture (s)TFLOPWeights read (GB)Types with a cost rule
llama3-8b8.03 B3,557300.6532.9715.0330
llama3-70b70.55 B8,837301.21295.83139.0430
mistral-7b7.24 B3,685310.6631.3614.2431
qwen2.5-0.5b0.49 B2,677310.402.390.9931
gpt20.12 B771270.180.670.2527

Source: examples/results.md in Torch_Sim_Frontend

Every parameter was checked to be a meta tensor. The whole results script, which traces all of these and more, peaks at 647 MB of resident memory.

04

Front End 1: the Dispatch Trace

Every PyTorch operator passes through the dispatcher. A TorchDispatchMode sees each call after autograd and Python-level decompositions, with the real argument tensors, so it records exactly what would execute. The mode below also gives each tensor an identity and marks views of parameters as weights:

src/simfront/capture/dispatch.py: OpTrace source
def __torch_dispatch__(self, func, types, args=(), kwargs=None):
    kwargs = kwargs or {}
    out = func(*args, **kwargs)
    name = str(func.overloadpacket)                # e.g. "aten.mm"
    flat_in, _ = tree_flatten((args, kwargs))
    flat_out, _ = tree_flatten(out)
    ins = [self._meta(t) for t in flat_in if isinstance(t, torch.Tensor)]
    # A view of a parameter (W.t() before mm) is still weight traffic.
    is_view = name.split(".", 1)[1] in VIEW_PACKETS
    param_view = is_view and any(t.param for t in ins)
    outs = [self._meta(t, param=param_view) for t in flat_out if isinstance(t, torch.Tensor)]
    attrs = {k: v for k, v in kwargs.items() if isinstance(v, (int, float, bool, str))}
    self.ops.append(apply_aten_rule(Op(name, ins, outs, attrs)))
    return out
05

Meta or Fake Tensors: What the Trace Means

The dispatch trace shows what PyTorch would run on the device the tensors claim to be on. On the meta device, scaled_dot_product_attention has no fused kernel and decomposes to bmm, _safe_softmax and a mask over the whole 2,048 × 2,048 score matrix per head. Under a FakeTensorMode the tensors claim to be on the CPU, and attention is one fused flash-attention operator per layer.

TraceWeightsmatmulattentioncopyelementwiseTotal
meta trace15.0117.180.005.662.1625.02
fake-CPU trace15.0115.010.270.540.0115.84

Source: examples/results.md in Torch_Sim_Frontend

The lesson: a trace is evidence about one code path on one device. Say which.

06

Front Ends 2 and 3: torch.export and a torch.compile Backend

torch.export traces the whole model into one FX graph with fake-tensor metadata on every node, and run_decompositions() lowers it to Core ATen (InfSim 09, slide 03). It works on the meta device, with one restriction: transformers' DynamicCache is not a registered pytree, so prefill is exported with use_cache=False. That is safe because transformers recognises the fake tensors and takes its tracing path.

A torch.compile backend is any callable that takes a GraphModule and returns a callable (InfSim 09, slide 04). Dynamo's graphs are at the torch level (linear, scaled_dot_product_attention), so this backend hands them to AOTAutograd for an ATen graph, walks it with the same rules as the other front ends, and returns the graph's own forward. The user's program runs unchanged and gives identical outputs (tested).

src/simfront/capture/compile.py source
class SimBackend:
    """``torch.compile(model, backend=SimBackend())``: records an operator trace per captured graph."""

    def __init__(self):
        self.graphs: list[list[Op]] = []

    def __call__(self, gm: torch.fx.GraphModule, example_inputs):
        # Dynamo names lifted parameters after their module path ("..._parameters_weight_");
        # AOTAutograd keeps the input order, so the i-th placeholder below is the same tensor.
        is_param = [n.op == "placeholder" and "_parameters_" in n.name for n in gm.graph.nodes
                    if n.op == "placeholder"]

        def fw_compiler(fgm: torch.fx.GraphModule, _inputs):
            ph = [n for n in fgm.graph.nodes if n.op == "placeholder"]
            params = {n.name for n, p in zip(ph, is_param, strict=True) if p}
            self.graphs.append(walk_graph(fgm.graph, params))
            return fgm.forward

        return aot_autograd(fw_compiler=fw_compiler)(gm, example_inputs)

Lifted parameters keep Dynamo's module-path names (..._parameters_weight_), and AOTAutograd keeps the input order, which is how the walk knows which graph inputs are weights.

07

Front End 4: ONNX Without the Weights

ONNX is the framework-neutral route: whatever produced the model, the cost model sees standard operators with versioned semantics. Three things were needed to make it work on a weightless model:

src/simfront/capture/onnx_walk.py: export without weights source
prog = torch.onnx.export(model, args, kwargs=kwargs or {}, dynamo=True, optimize=False, verbose=False)
if weights:
    prog.save(str(path))
else:
    prog.save(str(path), include_initializers=False, keep_initializers_as_inputs=True)

ONNX does not tell parameters from buffers (both are initializers), so the export returns the parameter names, including tied aliases (GPT-2's lm_head.weight is transformer.wte.weight).

08

Four Front Ends, One Answer

Llama-3-8B, one 2,048-token prefill, on the meta device unless stated:

Front endOperatorsOp typesMatmul FLOPsFused-attention FLOPsTotal TFLOPBytes moved (GB)Weights read (GB)Capture (s)
dispatch (meta)3,5573032,938,104,455,168032.970214.3515.0270.5
torch.export + Core ATen3,8503732,938,104,455,168032.988333.9715.02713.6
torch.compile backend3,4263532,938,104,455,168032.970187.4015.02712.1
ONNX (exported without weights)6,0073532,938,104,193,024032.980169.1015.0270.7
dispatch (fake CPU tensors)4,5773730,739,081,199,6162,220,498,092,03232.96670.0915.0270.8

Source: examples/results.md in Torch_Sim_Frontend

09

Checking the Trace Against the Closed Form

Disaggregated_Inference_Sim costs a step from a closed form (InfSim 02). An operator trace is an independent count of the same thing, so each checks the other:

QuantityClosed form (InfSim)Operator traceDifferenceWhyClosed form before 2026-10-03
Prefill matmul FLOPs, weights30,739,080,937,47230,739,080,937,4720exact30,739,080,937,472
Prefill attention FLOPs1,100,048,498,688 (causal)2,199,023,255,552 (unmasked)1.999xthe trace counts what the kernel computes, masked scores includedsame
Decode matmul FLOPs16,083,582,97616,083,583,104128the rotary-frequency matmul (128), not modelled16,083,058,688 (no self-attention: -524,288)
Decode weight bytes per step15,009,325,05615,009,857,536532,480the RMSNorm weights (532,480), not modelled16,059,990,016 (whole embedding table: +1,050,664,960)

Source: examples/results.md in Torch_Sim_Frontend

10

Costing the Trace

The first cost model is the InfSim roofline, applied per operator: time = max(FLOPs / FLOP rate, bytes / byte rate), with the same derated H100 rates as Disaggregated_Inference_Sim (a test checks they are the same numbers). Unfused assumes every non-view operator reads its inputs from memory and writes its outputs back. Fused is the ideal-fusion bound, where only matmul, attention and gather operators touch memory.

src/simfront/cost.py: Roofline source
def op_cost(self, op: Op) -> OpCost:
    if op.category in ("view", "unknown"):
        return OpCost(0.0, "free", "-")
    nbytes = op.bytes if (self.memory == "unfused" or op.category in MEMORY_OPS) else 0
    tc, tm = op.flops / self.flops_rate, nbytes / self.byte_rate
    return OpCost(max(tc, tm) + self.op_overhead, "compute" if tc >= tm else "memory", "accel")
WorkloadUnfused (ms)Unfused: time in compute-bound opsFused bound (ms)Closed form (ms)Closed-form bound
Prefill 2,048, meta trace (math attention)126.6844.6%71.0158.53compute
Prefill 2,048, fake-CPU trace (fused attention)76.1479.6%60.6258.53compute
Decode @2,048, meta trace9.340.0%6.415.70memory
Decode @2,048, fake-CPU trace5.910.0%5.705.70memory

Source: examples/results.md in Torch_Sim_Frontend

Prefill is compute-bound as a whole. Yet in the meta trace, the materialised score matrix makes more than half its time memory-bound: 126.68 ms, against 76.14 ms with fused attention and 58.53 ms from the closed form. That gap is the case for flash attention, measured from a trace. The per-operator sum is never below the whole-trace roofline, because small memory-bound operators cannot overlap with large matmuls in this model.

11

Interactive: Operator Coverage, Counted Three Ways

The hypothetical optical MAC part from Disaggregated_Inference_Sim (4,000 TFLOP/s peak at 40% efficiency, HBM-class memory) runs only the operator categories you choose. Everything else runs on an illustrative host CPU, and a tensor crosses the link the first time an operator on the other side reads it. Llama-3-8B, fused-attention traces. Every value is a recorded run.

12

Operator Coverage as an Engineering Metric

Two different coverage questions, both worth tracking release over release:

PyTorch's own FlopCounterMode shows why the first question matters:

TraceFlopCounterModesimfront matmulsimfront matmul + attention
meta (math attention)32,938,104,455,16832,938,104,455,16832,938,104,455,168
fake CPU (fused attention)30,739,081,199,61630,739,081,199,61632,959,579,291,648

Source: examples/results.md in Torch_Sim_Frontend

It has no formula for _scaled_dot_product_flash_attention_for_cpu, so on the fused trace it silently drops 2.22 TFLOP of attention, about 7% of the prefill. In CI, the arithmetic of reference traces is gated exactly; operator counts and bytes are reported as drift when a library upgrade changes the decomposition (deck 07).

13

What to Take Away