Skip to content

Commit 0335086

Browse files
JakeStevensmeta-codesync[bot]
authored andcommitted
Add ETDump profiling workflow for CpuBackend (#24335)
Summary: Pull Request resolved: #24335 `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 f21116e commit 0335086

5 files changed

Lines changed: 378 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>
@@ -40,8 +41,25 @@ int run_against_golden(
4041
data[index] = static_cast<float>(index % 251) / 250.0f;
4142
}
4243
auto input = executorch::extension::from_blob(data.data(), sizes);
43-
for (int run = 0; run < 2; ++run) {
44+
const int warmup_runs = std::getenv("ET_CPU_WARMUP_RUNS")
45+
? std::max(0, std::atoi(std::getenv("ET_CPU_WARMUP_RUNS")))
46+
: 1;
47+
const int timed_runs = std::getenv("ET_CPU_TIMED_RUNS")
48+
? std::max(1, std::atoi(std::getenv("ET_CPU_TIMED_RUNS")))
49+
: 1;
50+
const float atol = std::getenv("ET_CPU_ATOL")
51+
? std::max(0.0f, std::strtof(std::getenv("ET_CPU_ATOL"), nullptr))
52+
: 1e-4f;
53+
const float rtol = std::getenv("ET_CPU_RTOL")
54+
? std::max(0.0f, std::strtof(std::getenv("ET_CPU_RTOL"), nullptr))
55+
: 1e-4f;
56+
std::vector<double> latencies;
57+
for (int run = 0; run < warmup_runs + timed_runs; ++run) {
58+
const auto start = std::chrono::steady_clock::now();
4459
auto outputs = module.forward(executorch::runtime::EValue(*input));
60+
const double latency_ms = std::chrono::duration<double, std::milli>(
61+
std::chrono::steady_clock::now() - start)
62+
.count();
4563
if (!outputs.ok()) {
4664
fprintf(
4765
stderr,
@@ -65,7 +83,7 @@ int run_against_golden(
6583
const float error = std::abs(actual[index] - golden[index]);
6684
max_error = std::max(max_error, error);
6785
if (!std::isfinite(actual[index]) || !std::isfinite(golden[index]) ||
68-
error > 1e-4f + 1e-4f * std::abs(golden[index])) {
86+
error > atol + rtol * std::abs(golden[index])) {
6987
fprintf(
7088
stderr,
7189
"Mismatch at %zu: actual=%g golden=%g\n",
@@ -80,7 +98,19 @@ int run_against_golden(
8098
run,
8199
golden.size(),
82100
max_error);
101+
printf("Inference: run=%d latency_ms=%.9g\n", run, latency_ms);
102+
if (run >= warmup_runs) {
103+
latencies.push_back(latency_ms);
104+
}
83105
}
106+
std::sort(latencies.begin(), latencies.end());
107+
printf(
108+
"Latency: median_ms=%g min_ms=%g max_ms=%g warmup=%d iterations=%d\n",
109+
latencies[latencies.size() / 2],
110+
latencies.front(),
111+
latencies.back(),
112+
warmup_runs,
113+
timed_runs);
84114
return 0;
85115
}
86116

‎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: 30 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,32 @@ 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 `-c executorch.cpu_xnnpack_operator_shapes=true`, each operator
46+
event also carries its input and output shapes as JSON delegate metadata
47+
(`shapes` in `profile.json`). Tracing compiles out unless the runner is built
48+
with the event tracer enabled, and the shapes, which read XNNPACK's internal
49+
runtime, compile out unless that option is also set:
50+
51+
```bash
52+
buck build -j 2 @fbcode//mode/opt -c executorch.event_tracer_enabled=true -c executorch.cpu_xnnpack_operator_shapes=true fbcode//executorch/backends/cpu:runner fbcode//executorch/backends/cpu:etdump_cpu --show-full-json-output
53+
"$CPU_ETDUMP" --runner "$CPU_TRACED_RUNNER" --model artifacts/cpu/model.pte --golden artifacts/cpu/golden.bin --output results/etdump
54+
```
55+
56+
`:runner` links only XNNPACK and ET, so XNNPACK takes the whole ConvNeXt graph.
57+
58+
The runner writes `model.etdump` when `ET_CPU_ETDUMP` is set. The workflow drops
59+
warmup samples and writes `profile.json` with median/min/max per event. Provider
60+
operator events nest inside their step, so step and provider totals are reported
61+
separately. Trailing positional arguments are passed to the runner as provider
62+
preferences.

0 commit comments

Comments
 (0)