|
| 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])]) |
0 commit comments