Skip to content

Commit 30e6781

Browse files
JakeStevensfacebook-github-bot
authored andcommitted
Add ETDump profiling workflow for CpuBackend (pytorch#24335)
Summary: `etdump_cpu` runs a CPU delegate model through a traced runner with `ET_CPU_ETDUMP`, drops warmup samples, and writes `profile.json` with median/min/max latency per plan step and XNNPACK operator, plus each XNNPACK operator's input and output shapes. Each CPU delegate's plan is reassembled from the runner log, so a program with several CPU delegates is profiled as a whole. Events with an unexpected sample count are reported with a warning, and malformed operator metadata degrades to no shapes. It fails if any plan step or, for XNNPACK regions, operator events are missing from the ETDump. The model runner gains configurable warmups (`ET_CPU_WARMUP_RUNS`), timed iterations (`ET_CPU_TIMED_RUNS`), golden tolerances (`ET_CPU_ATOL`, `ET_CPU_RTOL`) and a latency summary. The workflows README now covers export and profiling. Authored with Claude Code. Differential Revision: D128598106
1 parent 00e551d commit 30e6781

5 files changed

Lines changed: 377 additions & 3 deletions

File tree

‎backends/cpu/BUCK‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,28 @@ fbcode_target(
8888
],
8989
)
9090

91+
fbcode_target(
92+
_kind = runtime.python_library,
93+
name = "etdump_lib",
94+
srcs = ["workflows/etdump.py"],
95+
deps = ["//executorch/devtools/inspector:lib"],
96+
)
97+
98+
fbcode_target(
99+
_kind = runtime.python_binary,
100+
name = "etdump_cpu",
101+
par_style = "xar",
102+
main_module = "executorch.backends.cpu.workflows.etdump",
103+
deps = [":etdump_lib"],
104+
)
105+
106+
fbcode_target(
107+
_kind = runtime.python_test,
108+
name = "test_etdump",
109+
srcs = ["test/test_etdump.py"],
110+
deps = [":etdump_lib"],
111+
)
112+
91113
fbcode_target(
92114
_kind = runtime.python_test,
93115
name = "test_aot",

‎backends/cpu/test/runner.cpp‎

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
#include <executorch/extension/threadpool/threadpool_guard.h>
1212

1313
#include <algorithm>
14+
#include <chrono>
1415
#include <cmath>
1516
#include <cstdio>
1617
#include <cstdlib>
@@ -98,8 +99,25 @@ int main(int argc, char** argv) {
9899
data[index] = static_cast<float>(index % 251) / 250.0f;
99100
}
100101
auto input = executorch::extension::from_blob(data.data(), sizes);
101-
for (int run = 0; run < 2; ++run) {
102+
const int warmup_runs = std::getenv("ET_CPU_WARMUP_RUNS")
103+
? std::max(0, std::atoi(std::getenv("ET_CPU_WARMUP_RUNS")))
104+
: 1;
105+
const int timed_runs = std::getenv("ET_CPU_TIMED_RUNS")
106+
? std::max(1, std::atoi(std::getenv("ET_CPU_TIMED_RUNS")))
107+
: 1;
108+
const float atol = std::getenv("ET_CPU_ATOL")
109+
? std::max(0.0f, std::strtof(std::getenv("ET_CPU_ATOL"), nullptr))
110+
: 1e-4f;
111+
const float rtol = std::getenv("ET_CPU_RTOL")
112+
? std::max(0.0f, std::strtof(std::getenv("ET_CPU_RTOL"), nullptr))
113+
: 1e-4f;
114+
std::vector<double> latencies;
115+
for (int run = 0; run < warmup_runs + timed_runs; ++run) {
116+
const auto start = std::chrono::steady_clock::now();
102117
auto outputs = module.forward(executorch::runtime::EValue(*input));
118+
const double latency_ms = std::chrono::duration<double, std::milli>(
119+
std::chrono::steady_clock::now() - start)
120+
.count();
103121
if (!outputs.ok()) {
104122
fprintf(
105123
stderr,
@@ -123,7 +141,7 @@ int main(int argc, char** argv) {
123141
const float error = std::abs(actual[index] - golden[index]);
124142
max_error = std::max(max_error, error);
125143
if (!std::isfinite(actual[index]) || !std::isfinite(golden[index]) ||
126-
error > 1e-4f + 1e-4f * std::abs(golden[index])) {
144+
error > atol + rtol * std::abs(golden[index])) {
127145
fprintf(
128146
stderr,
129147
"Mismatch at %zu: actual=%g golden=%g\n",
@@ -138,7 +156,19 @@ int main(int argc, char** argv) {
138156
run,
139157
golden.size(),
140158
max_error);
159+
printf("Inference: run=%d latency_ms=%.9g\n", run, latency_ms);
160+
if (run >= warmup_runs) {
161+
latencies.push_back(latency_ms);
162+
}
141163
}
164+
std::sort(latencies.begin(), latencies.end());
165+
printf(
166+
"Latency: median_ms=%g min_ms=%g max_ms=%g warmup=%d iterations=%d\n",
167+
latencies[latencies.size() / 2],
168+
latencies.front(),
169+
latencies.back(),
170+
warmup_runs,
171+
timed_runs);
142172
if (etdump) {
143173
const auto result = etdump->get_etdump_data();
144174
std::unique_ptr<void, decltype(&free)> dump_data(result.buf, &free);

‎backends/cpu/test/test_etdump.py‎

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
from __future__ import annotations
8+
9+
import json
10+
import unittest
11+
from pathlib import Path
12+
from types import SimpleNamespace
13+
from unittest.mock import patch
14+
15+
from executorch.backends.cpu.workflows import etdump
16+
17+
PLAN = {
18+
"regions": [
19+
{"provider": "XNNPACK", "implementation": "subgraph", "nodes": ["conv", "norm"]}
20+
]
21+
}
22+
STEP = "XNNPACK/subgraph:conv+1"
23+
24+
25+
def chunk_lines(plan: dict) -> list[str]:
26+
payload = json.dumps(plan).encode()
27+
chunks = [payload[start : start + 16] for start in range(0, len(payload), 16)]
28+
return [
29+
f"I CPUBackend.cpp:259] CPU_PLAN_CHUNK {index} {len(chunks)} {chunk.hex()}"
30+
for index, chunk in enumerate(chunks)
31+
]
32+
33+
34+
def event(name, samples, delegated=True, metadata=b""):
35+
return SimpleNamespace(
36+
name=name,
37+
perf_data=SimpleNamespace(raw=samples),
38+
is_delegated_op=delegated,
39+
raw_delegate_debug_metadatas=[metadata],
40+
)
41+
42+
43+
class ETDumpTest(unittest.TestCase):
44+
def summarize(self, events):
45+
inspector = SimpleNamespace(event_blocks=[SimpleNamespace(events=events)])
46+
with patch.object(etdump, "Inspector", return_value=inspector):
47+
return etdump.summarize(
48+
Path("model.etdump"), [PLAN], warmup=1, iterations=2
49+
)
50+
51+
def test_decode_plans_reassembles_each_delegate(self):
52+
other = {
53+
"regions": [
54+
{
55+
"provider": "Reference",
56+
"implementation": "registry",
57+
"nodes": ["gelu"],
58+
}
59+
]
60+
}
61+
log = "\n".join(chunk_lines(PLAN) + ["unrelated"] + chunk_lines(other))
62+
self.assertEqual(etdump.decode_plans(log), [PLAN, other])
63+
64+
def test_decode_plans_rejects_incomplete_or_inconsistent_traces(self):
65+
lines = chunk_lines(PLAN)
66+
total = f" {len(lines)} "
67+
mixed = lines[:1] + [
68+
line.replace(total, f" {len(lines) + 1} ") for line in lines[1:]
69+
]
70+
for invalid in (lines[:-1], lines[1:], list(reversed(lines)), mixed, []):
71+
with self.subTest(invalid=invalid), self.assertRaises(RuntimeError):
72+
etdump.decode_plans("\n".join(invalid))
73+
74+
def test_summarize_drops_warmup_and_reports_shapes(self):
75+
shapes = {"inputs": [[1, 3]], "outputs": [[1, 3]]}
76+
profile = self.summarize(
77+
[
78+
event(STEP, [9.0, 2.0, 4.0]),
79+
event(
80+
"Convolution #1",
81+
[8.0, 1.0, 3.0],
82+
metadata=json.dumps(shapes).encode(),
83+
),
84+
event("Method::init", [5.0], delegated=False),
85+
]
86+
)
87+
rows = {row["name"]: row for row in profile["events"]}
88+
self.assertEqual(rows[STEP]["kind"], "step")
89+
self.assertEqual(rows[STEP]["median_ms"], 3.0)
90+
self.assertEqual(rows["Convolution #1"]["shapes"], shapes)
91+
self.assertEqual(rows["Method::init"]["kind"], "init")
92+
self.assertEqual(profile["totals_ms"], {"step": 3.0, "provider": 2.0})
93+
94+
def test_summarize_skips_empty_samples_and_ignores_malformed_metadata(self):
95+
with self.assertLogs(etdump.logger, "WARNING"):
96+
profile = self.summarize(
97+
[
98+
event(STEP, [9.0, 2.0, 4.0]),
99+
event("Convolution #1", [8.0, 1.0, 3.0], metadata=b"not json"),
100+
event("Empty #1", []),
101+
]
102+
)
103+
rows = {row["name"]: row for row in profile["events"]}
104+
self.assertNotIn("Empty #1", rows)
105+
self.assertIsNone(rows["Convolution #1"]["shapes"])
106+
107+
def test_summarize_warns_about_unexpected_delegate_sample_counts(self):
108+
with self.assertLogs(etdump.logger, "WARNING") as logs:
109+
profile = self.summarize(
110+
[
111+
event(STEP, [9.0, 2.0, 4.0]),
112+
event("Convolution #1", [8.0, 1.0, 3.0]),
113+
event("Transpose #1", [1.0] * 5),
114+
]
115+
)
116+
self.assertIn("Transpose #1 has 5 samples", logs.output[0])
117+
rows = {row["name"]: row for row in profile["events"]}
118+
self.assertEqual(rows["Transpose #1"]["kind"], "init")
119+
120+
def test_summarize_rejects_missing_steps(self):
121+
with self.assertRaisesRegex(RuntimeError, "lacks CPU plan steps"):
122+
self.summarize([event("Convolution #1", [8.0, 1.0, 3.0])])

‎backends/cpu/workflows/README.md‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# CPU torchvision exports
1+
# Torchvision CPU workflows
22

33
List classifiers or export a model with the registered CPU or XNNPACK FP32 recipe:
44

@@ -31,3 +31,31 @@ preprocessing provenance, and runtime/artifact identity. `golden.bin` holds the
3131
edge-program logits for ET exports and matches `eager.bin` for Native exports. A
3232
runner can feed `input.bin` to either artifact and compare its output against
3333
`eager.bin` to validate it before measuring latency.
34+
35+
The local CPU runners support repeatable warm-latency checks through
36+
`ET_CPU_WARMUP_RUNS` and `ET_CPU_TIMED_RUNS`. Golden checks use `ET_CPU_ATOL` and
37+
`ET_CPU_RTOL`; both default to `1e-4`.
38+
39+
## ETDump
40+
41+
Every CPU plan step records a delegate event named
42+
`provider/implementation:first_node[+N]`, so all providers are traced without
43+
provider changes. The XNNPACK provider also enables XNNPACK runtime profiling and
44+
records `<operator> #<n>` events, such as `Transpose (ND, X32) #3`, inside its
45+
step, with the operator's input and output shapes as JSON delegate metadata
46+
(`shapes` in `profile.json`).
47+
Tracing, and the XNNPACK internal-header dependency that reads those shapes,
48+
compile out unless the runner is built with the event tracer enabled:
49+
50+
```bash
51+
buck build -j 2 @fbcode//mode/opt -c executorch.event_tracer_enabled=true fbcode//executorch/backends/cpu:runner fbcode//executorch/backends/cpu:etdump_cpu --show-full-json-output
52+
"$CPU_ETDUMP" --runner "$CPU_TRACED_RUNNER" --model artifacts/cpu/model.pte --golden artifacts/cpu/golden.bin --output results/etdump
53+
```
54+
55+
`:runner` links only XNNPACK and ET, so XNNPACK takes the whole ConvNeXt graph.
56+
57+
The runner writes `model.etdump` when `ET_CPU_ETDUMP` is set. The workflow drops
58+
warmup samples and writes `profile.json` with median/min/max per event. Provider
59+
operator events nest inside their step, so step and provider totals are reported
60+
separately. Trailing positional arguments are passed to the runner as provider
61+
preferences.

0 commit comments

Comments
 (0)