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.
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.
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.
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).
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
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.
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()
| Model | Parameters | Operators | Op types | Capture (s) | TFLOP | Weights read (GB) | Types with a cost rule |
|---|---|---|---|---|---|---|---|
| llama3-8b | 8.03 B | 3,557 | 30 | 0.65 | 32.97 | 15.03 | 30 |
| llama3-70b | 70.55 B | 8,837 | 30 | 1.21 | 295.83 | 139.04 | 30 |
| mistral-7b | 7.24 B | 3,685 | 31 | 0.66 | 31.36 | 14.24 | 31 |
| qwen2.5-0.5b | 0.49 B | 2,677 | 31 | 0.40 | 2.39 | 0.99 | 31 |
| gpt2 | 0.12 B | 771 | 27 | 0.18 | 0.67 | 0.25 | 27 |
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.
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:
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
.item() on a computed tensor, and the meta device cannot produce a value: RuntimeError: Tensor.item() cannot be called on meta tensors. With the cache on, that check is skipped, so meta prefill traces keep the cache on.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.
| Trace | Weights | matmul | attention | copy | elementwise | Total |
|---|---|---|---|---|---|---|
| meta trace | 15.01 | 17.18 | 0.00 | 5.66 | 2.16 | 25.02 |
| fake-CPU trace | 15.01 | 15.01 | 0.27 | 0.54 | 0.01 | 15.84 |
Source: examples/results.md in Torch_Sim_Frontend
repeat_kv copying 8 KV heads into 32 for the math path..item() and uses SDPA's native grouped-query attention. Under fake tensors, torch.export or torch.compile, it builds an explicit mask and repeats the KV heads. A test pins this down.The lesson: a trace is evidence about one code path on one device. Say which.
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).
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.
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:
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)
include_initializers=False, keep_initializers_as_inputs=True drops the data but keeps every weight's shape. Llama-3-8B exports to an 11.3 MB file. The exporter's optimiser is off: it needs real values, and it renames the weights it pre-transposes.infer_shapes(model, data_prop=True). Without data_prop, Llama's reshape targets are computed, not constant, and every attention MatMul is left without dimensions.Transpose of a weight, the rotary frequencies) costs nothing per inference. A layout op on a weight is still the weight.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).
Llama-3-8B, one 2,048-token prefill, on the meta device unless stated:
| Front end | Operators | Op types | Matmul FLOPs | Fused-attention FLOPs | Total TFLOP | Bytes moved (GB) | Weights read (GB) | Capture (s) |
|---|---|---|---|---|---|---|---|---|
| dispatch (meta) | 3,557 | 30 | 32,938,104,455,168 | 0 | 32.970 | 214.35 | 15.027 | 0.5 |
| torch.export + Core ATen | 3,850 | 37 | 32,938,104,455,168 | 0 | 32.988 | 333.97 | 15.027 | 13.6 |
| torch.compile backend | 3,426 | 35 | 32,938,104,455,168 | 0 | 32.970 | 187.40 | 15.027 | 12.1 |
| ONNX (exported without weights) | 6,007 | 35 | 32,938,104,193,024 | 0 | 32.980 | 169.10 | 15.027 | 0.7 |
| dispatch (fake CPU tensors) | 4,577 | 37 | 30,739,081,199,616 | 2,220,498,092,032 | 32.966 | 70.09 | 15.027 | 0.8 |
Source: examples/results.md in Torch_Sim_Frontend
Transpose, Expand and Slice materialise, and each decomposition upcasts and copies in different places. The arithmetic belongs to the model; the traffic belongs to the decomposition, and a cost model must say which it assumes.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:
| Quantity | Closed form (InfSim) | Operator trace | Difference | Why | Closed form before 2026-10-03 |
|---|---|---|---|---|---|
| Prefill matmul FLOPs, weights | 30,739,080,937,472 | 30,739,080,937,472 | 0 | exact | 30,739,080,937,472 |
| Prefill attention FLOPs | 1,100,048,498,688 (causal) | 2,199,023,255,552 (unmasked) | 1.999x | the trace counts what the kernel computes, masked scores included | same |
| Decode matmul FLOPs | 16,083,582,976 | 16,083,583,104 | 128 | the rotary-frequency matmul (128), not modelled | 16,083,058,688 (no self-attention: -524,288) |
| Decode weight bytes per step | 15,009,325,056 | 15,009,857,536 | 532,480 | the RMSNorm weights (532,480), not modelled | 16,059,990,016 (whole embedding table: +1,050,664,960) |
Source: examples/results.md in Torch_Sim_Frontend
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.
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")
| Workload | Unfused (ms) | Unfused: time in compute-bound ops | Fused bound (ms) | Closed form (ms) | Closed-form bound |
|---|---|---|---|---|---|
| Prefill 2,048, meta trace (math attention) | 126.68 | 44.6% | 71.01 | 58.53 | compute |
| Prefill 2,048, fake-CPU trace (fused attention) | 76.14 | 79.6% | 60.62 | 58.53 | compute |
| Decode @2,048, meta trace | 9.34 | 0.0% | 6.41 | 5.70 | memory |
| Decode @2,048, fake-CPU trace | 5.91 | 0.0% | 5.70 | 5.70 | memory |
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.
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.
Two different coverage questions, both worth tracking release over release:
SplitToSequence and SequenceAt, and rules were added.PyTorch's own FlopCounterMode shows why the first question matters:
| Trace | FlopCounterMode | simfront matmul | simfront matmul + attention |
|---|---|---|---|
| meta (math attention) | 32,938,104,455,168 | 32,938,104,455,168 | 32,938,104,455,168 |
| fake CPU (fused attention) | 30,739,081,199,616 | 30,739,081,199,616 | 32,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).
examples/results.md.data_prop.