blob: c550b7400c7e5a18cab92cbfbe01410f12d5d968 [file]
# Copyright 2026 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Extracts and publishes cumulative block/storage I/O rate metrics from Inspect."""
import json
import logging
import os
import pathlib
import zipfile
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from typing import Any, Protocol
from perf_publish import publish
from reporting import metrics
_LOGGER: logging.Logger = logging.getLogger(__name__)
# Maps block driver Inspect tree name (metadata.name) to root child node name.
_BLOCK_DRIVER_NODES: dict[str, str] = {
"ftl": "ftl",
"sdmmc": "sdmmc_core",
"ufs": "ufs",
"virtio-block": "virtio_block",
}
_BLOCK_OPERATIONS: tuple[tuple[str, str], ...] = (
("read", "Read"),
("write", "Write"),
("flush", "Flush"),
("trim", "Trim"),
)
STORAGE_INSPECT_SELECTORS: list[str] = [
"bootstrap/*-drivers*:[name=ftl]root",
"bootstrap/*-drivers*:[name=sdmmc]root",
"bootstrap/*-drivers*:[name=ufs]root",
"bootstrap/*-drivers*:[name=virtio-block]root",
]
class InspectDataProvider(Protocol):
"""Protocol for a Honeydew device object that can query Inspect data."""
def get_inspect_data(
self,
*,
selectors: list[str] | None = None,
) -> Any:
"""Returns an InspectDataCollection for the requested selectors."""
@dataclass(frozen=True)
class InspectEntry:
"""Normalized Inspect entry from a snapshot zip or Honeydew query."""
moniker: str | None
name: str | None
timestamp_ns: int
root: Mapping[str, Any]
@classmethod
def from_dict(cls, entry: Mapping[str, Any]) -> "InspectEntry | None":
"""Parses a raw JSON Inspect entry dictionary into an `InspectEntry`."""
metadata = entry.get("metadata")
if not isinstance(metadata, Mapping):
return None
payload = entry.get("payload")
if not isinstance(payload, Mapping):
return None
root = payload.get("root")
if not isinstance(root, Mapping):
return None
timestamp = metadata.get("timestamp", 0)
name = metadata.get("name")
moniker = entry.get("moniker")
return cls(
moniker=str(moniker) if moniker is not None else None,
name=str(name) if name is not None else None,
timestamp_ns=int(timestamp or 0),
root=root,
)
def load_inspect_entries(
dut: InspectDataProvider | None = None,
snapshot_dirs: Sequence[str | os.PathLike[str]] = (),
) -> list[InspectEntry]:
"""Loads Inspect entries from the newest `Snapshot_*.zip` in `snapshot_dirs`,
falling back to `dut.get_inspect_data()` if no snapshot zip is found.
Checking multiple directories (e.g. `[self._teardown_class_artifacts, self.log_path]`)
supports base test classes that save teardown snapshots in either
`<log_path>/teardown_class` or `<log_path>`.
Logs a warning and returns `[]` on any snapshot read or Inspect query error
rather than raising.
"""
for directory in snapshot_dirs:
if not directory:
continue
dir_path = pathlib.Path(directory)
if not dir_path.exists():
continue
snapshot_zips = sorted(dir_path.glob("Snapshot_*.zip"))
for snapshot_zip in reversed(snapshot_zips):
try:
with zipfile.ZipFile(snapshot_zip, "r") as zf:
if "inspect.json" in zf.namelist():
data = json.loads(zf.read("inspect.json"))
if isinstance(data, list):
entries = [
parsed
for item in data
if isinstance(item, Mapping)
and (parsed := InspectEntry.from_dict(item))
is not None
]
if entries:
return entries
except Exception as e:
_LOGGER.warning(
"Failed to read inspect.json from %s: %s", snapshot_zip, e
)
if dut is None:
return []
try:
collection = dut.get_inspect_data(selectors=STORAGE_INSPECT_SELECTORS)
entries = []
for item in collection.data:
payload = item.payload
root = payload.get("root") if isinstance(payload, Mapping) else None
if not isinstance(root, Mapping):
continue
name = getattr(item.metadata, "name", None)
if name is None:
for driver_name, node_name in _BLOCK_DRIVER_NODES.items():
if isinstance(root.get(node_name), Mapping):
name = driver_name
break
entries.append(
InspectEntry(
moniker=getattr(item, "moniker", None),
name=name,
timestamp_ns=int(item.metadata.timestamp.nanoseconds()),
root=root,
)
)
return entries
except Exception as e:
_LOGGER.warning("Failed to query fallback storage inspect data: %s", e)
return []
def _resolve_driver_and_node(
entry: InspectEntry,
) -> tuple[str, str, Mapping[str, Any]] | None:
"""Resolves `(driver_name, node_name, node_dict)` from an `InspectEntry`.
Entries loaded from `Snapshot_*.zip` have `entry.name` set to the Inspect
tree name (e.g. `"ftl"`), whereas entries constructed from `fuchsia_inspect`
without `metadata.name` may have `entry.name=None`. In both cases,
`entry.root` contains the driver's unique child node (`"ftl"`,
`"sdmmc_core"`, `"ufs"`, or `"virtio_block"`).
"""
if entry.timestamp_ns <= 0:
return None
if entry.name in _BLOCK_DRIVER_NODES:
node_name = _BLOCK_DRIVER_NODES[entry.name]
node = entry.root.get(node_name)
if isinstance(node, Mapping):
return entry.name, node_name, node
for driver_name, node_name in _BLOCK_DRIVER_NODES.items():
node = entry.root.get(node_name)
if isinstance(node, Mapping):
return driver_name, node_name, node
return None
def _has_block_op_counters(node: Mapping[str, Any]) -> bool:
"""Returns True if `node` has `.bytes` and `.count` counters for all block ops."""
return all(
f"block.{op_key}.bytes" in node and f"block.{op_key}.count" in node
for op_key, _ in _BLOCK_OPERATIONS
)
def _extract_hardware_block_rates(
entry: InspectEntry,
) -> dict[str, tuple[float, float]] | None:
"""Attempts to extract `{op_label: (bytes_per_sec, ops_per_sec)}` for Read,
Write, Flush, and Trim from a hardware block driver `InspectEntry` (`ftl`,
`sdmmc_core`, `ufs`, or `virtio_block`).
"""
resolved = _resolve_driver_and_node(entry)
if resolved is None:
return None
_, _, node = resolved
if not _has_block_op_counters(node):
return None
uptime_sec = float(entry.timestamp_ns) / 1e9
return {
op_label: (
int(node[f"block.{op_key}.bytes"]) / uptime_sec,
int(node[f"block.{op_key}.count"]) / uptime_sec,
)
for op_key, op_label in _BLOCK_OPERATIONS
}
def _normalize_entries(
inspect_entries: Sequence[InspectEntry | Mapping[str, Any]],
) -> list[InspectEntry]:
"""Converts a sequence of `InspectEntry` or raw dict entries into a
deterministically ordered list of `InspectEntry` objects.
"""
normalized: list[InspectEntry] = []
for entry in inspect_entries:
parsed: InspectEntry | None = None
if isinstance(entry, InspectEntry):
parsed = entry
elif isinstance(entry, Mapping):
parsed = InspectEntry.from_dict(entry)
if parsed is None:
continue
if parsed.name is None:
resolved = _resolve_driver_and_node(parsed)
if resolved is not None:
parsed = InspectEntry(
moniker=parsed.moniker,
name=resolved[0],
timestamp_ns=parsed.timestamp_ns,
root=parsed.root,
)
normalized.append(parsed)
return sorted(
normalized,
key=lambda e: (e.name or "", e.moniker or "", e.timestamp_ns),
)
def diff_inspect_entries(
start_entries: Sequence[InspectEntry | Mapping[str, Any]],
end_entries: Sequence[InspectEntry | Mapping[str, Any]],
) -> list[InspectEntry]:
"""Computes per-interval delta `InspectEntry` items between `start_entries` and `end_entries`.
For each hardware block driver entry in `end_entries`, subtracts the baseline
cumulative bytes/ops from `start_entries` and sets `timestamp_ns` to
`end.timestamp_ns - start.timestamp_ns`. Prefers active storage drivers
(non-zero cumulative operations since boot) over idle secondary block
interfaces (such as SDIO Wi-Fi controllers) so zero-I/O test windows still
produce `0.0` rate metrics for the active storage device.
"""
valid_end: list[tuple[InspectEntry, str, Mapping[str, Any], int]] = []
for entry in _normalize_entries(end_entries):
resolved = _resolve_driver_and_node(entry)
if resolved is None:
continue
driver_name, node_name, node = resolved
if not _has_block_op_counters(node):
continue
total_ops = sum(
int(node[f"block.{op_key}.count"])
for op_key, _ in _BLOCK_OPERATIONS
)
normalized_end = InspectEntry(
moniker=entry.moniker,
name=driver_name,
timestamp_ns=entry.timestamp_ns,
root=entry.root,
)
valid_end.append((normalized_end, node_name, node, total_ops))
if any(total_ops > 0 for _, _, _, total_ops in valid_end):
valid_end = [item for item in valid_end if item[3] > 0]
start_by_key: dict[tuple[str | None, str | None], InspectEntry] = {}
start_by_name: dict[str | None, InspectEntry] = {}
for s_entry in _normalize_entries(start_entries):
resolved = _resolve_driver_and_node(s_entry)
if resolved is None:
continue
s_driver_name, _, s_node = resolved
if not _has_block_op_counters(s_node):
continue
normalized_start = InspectEntry(
moniker=s_entry.moniker,
name=s_driver_name,
timestamp_ns=s_entry.timestamp_ns,
root=s_entry.root,
)
start_by_key[(s_driver_name, s_entry.moniker)] = normalized_start
s_total_ops = sum(
int(s_node[f"block.{op_key}.count"])
for op_key, _ in _BLOCK_OPERATIONS
)
if s_driver_name not in start_by_name or s_total_ops > 0:
start_by_name[s_driver_name] = normalized_start
delta_entries: list[InspectEntry] = []
for end_entry, node_name, end_node, _ in valid_end:
start_entry = start_by_key.get(
(end_entry.name, end_entry.moniker)
) or start_by_name.get(end_entry.name)
if (
start_entry is None
or end_entry.timestamp_ns <= start_entry.timestamp_ns
):
delta_entries.append(end_entry)
continue
start_node = start_entry.root.get(node_name)
if not isinstance(start_node, Mapping):
delta_entries.append(end_entry)
continue
if any(
int(end_node[f"block.{op_key}.bytes"])
< int(start_node[f"block.{op_key}.bytes"])
or int(end_node[f"block.{op_key}.count"])
< int(start_node[f"block.{op_key}.count"])
for op_key, _ in _BLOCK_OPERATIONS
):
delta_entries.append(end_entry)
continue
delta_node = dict(end_node)
for op_key, _ in _BLOCK_OPERATIONS:
delta_node[f"block.{op_key}.bytes"] = int(
end_node[f"block.{op_key}.bytes"]
) - int(start_node[f"block.{op_key}.bytes"])
delta_node[f"block.{op_key}.count"] = int(
end_node[f"block.{op_key}.count"]
) - int(start_node[f"block.{op_key}.count"])
delta_entries.append(
InspectEntry(
moniker=end_entry.moniker,
name=end_entry.name,
timestamp_ns=end_entry.timestamp_ns - start_entry.timestamp_ns,
root={**end_entry.root, node_name: delta_node},
)
)
return delta_entries
def extract_storage_io_metrics(
inspect_entries: Sequence[InspectEntry | Mapping[str, Any]],
label_prefix: str | None = None,
*,
baseline_entries: Sequence[InspectEntry | Mapping[str, Any]] | None = None,
) -> list[metrics.TestCaseResult]:
"""Extracts block/storage I/O rate metrics (`Block{Read,Write,Flush,Trim}{Bytes,Ops}PerSec`)
from hardware block driver Inspect entries (`ftl`, `sdmmc_core`, `ufs`,
`virtio_block`).
When `baseline_entries` is provided, computes per-interval delta rates
between `baseline_entries` and `inspect_entries` via `diff_inspect_entries`.
Otherwise, computes cumulative rates over device uptime.
Target devices have a single primary block storage driver. To guarantee
deterministic behavior independent of the input order in `inspect_entries`,
entries are sorted by `(name, moniker, timestamp_ns)` and any active block
driver with non-zero I/O operations takes precedence over idle instances.
"""
if baseline_entries is not None:
inspect_entries = diff_inspect_entries(
baseline_entries, inspect_entries
)
rates: dict[str, tuple[float, float]] | None = None
# Target devices only have one primary block storage driver, but entries in
# `inspect.json` are not guaranteed to be ordered. Sort entries first, and
# prefer an active driver (non-zero total I/O ops) over an idle one.
for entry in _normalize_entries(inspect_entries):
candidate = _extract_hardware_block_rates(entry)
if candidate is None:
continue
if rates is None:
rates = candidate
if sum(ops for _, ops in candidate.values()) > 0:
rates = candidate
break
if rates is None:
return []
prefix = f"{label_prefix}/" if label_prefix else ""
results: list[metrics.TestCaseResult] = []
for op_key, op_label in _BLOCK_OPERATIONS:
bytes_per_sec, ops_per_sec = rates[op_label]
results.extend(
[
metrics.TestCaseResult(
label=f"{prefix}Block{op_label}BytesPerSec",
unit=metrics.Unit.bytesPerSecond,
values=[bytes_per_sec],
doc=f"Cumulative block {op_key} bytes per second of device uptime.",
direction=metrics.Direction.smallerIsBetter,
),
metrics.TestCaseResult(
label=f"{prefix}Block{op_label}OpsPerSec",
unit=metrics.Unit.count,
values=[ops_per_sec],
doc=f"Cumulative block {op_key} operations per second of device uptime.",
direction=metrics.Direction.smallerIsBetter,
),
]
)
return results
def capture(
dut: InspectDataProvider | None = None,
snapshot_dirs: Sequence[str | os.PathLike[str]] = (),
label_prefix: str | None = None,
inspect_entries: Sequence[InspectEntry | Mapping[str, Any]] | None = None,
baseline_entries: Sequence[InspectEntry | Mapping[str, Any]] | None = None,
) -> metrics.Report:
"""Captures block/storage I/O rate metrics as a `metrics.Report`.
Can be combined with trace or memory metrics via `report.extend(storage.capture(dut=self.dut))`.
"""
entries = (
inspect_entries
if inspect_entries is not None
else load_inspect_entries(dut=dut, snapshot_dirs=snapshot_dirs)
)
structured = extract_storage_io_metrics(
entries,
label_prefix=label_prefix,
baseline_entries=baseline_entries,
)
description = metrics.describe_callable(capture)
return metrics.Report(
structured,
{},
[
metrics.MetricsProcessorDescription(
doc=description["doc"],
code_path=description["code_path"],
line_no=description["line_no"],
metrics=[tcr.describe() for tcr in structured],
)
],
)
def publish_teardown_storage_io_metrics(
test_instance: Any,
test_suite: str,
expected_metric_names_filename: str | None = None,
label_prefix: str | None = None,
runtime_deps_dir: str | os.PathLike[str] | None = None,
test_data_module: Any | None = None,
baseline_entries: Sequence[InspectEntry | Mapping[str, Any]] | None = None,
) -> list[metrics.TestCaseResult]:
"""One-line helper for `FuchsiaBaseTest` subclasses' `teardown_class()` that
loads the teardown snapshot, extracts storage I/O rates, writes
`<teardown_class_artifacts>/<test_suite>-benchmark.fuchsiaperf.json`, and
publishes it via `publish.publish_fuchsiaperf()`.
"""
try:
snapshot_dirs: list[str] = []
for attr in ("_teardown_class_artifacts", "log_path"):
val = getattr(test_instance, attr, None)
if val:
snapshot_dirs.append(str(val))
inspect_entries = load_inspect_entries(
dut=getattr(test_instance, "dut", None),
snapshot_dirs=snapshot_dirs,
)
resolved_prefix = (
test_instance.__class__.__name__
if label_prefix is None
else label_prefix
)
storage_metrics = extract_storage_io_metrics(
inspect_entries,
label_prefix=resolved_prefix,
baseline_entries=baseline_entries,
)
if not storage_metrics:
_LOGGER.warning(
"No block/storage I/O inspect metrics found in teardown snapshot."
)
return []
output_dir = getattr(
test_instance, "_teardown_class_artifacts", None
) or getattr(test_instance, "log_path")
os.makedirs(output_dir, exist_ok=True)
fuchsiaperf_json_path = (
pathlib.Path(output_dir)
/ f"{test_suite}-benchmark.fuchsiaperf.json"
)
metrics.TestCaseResult.write_fuchsiaperf_json(
results=storage_metrics,
test_suite=test_suite,
output_path=fuchsiaperf_json_path,
)
publish_kwargs: dict[str, Any] = {
"fuchsia_perf_file_paths": [fuchsiaperf_json_path],
"expected_metric_names_filename": (
expected_metric_names_filename or f"{test_suite}.txt"
),
}
if runtime_deps_dir is not None:
publish_kwargs["runtime_deps_dir"] = runtime_deps_dir
if test_data_module is not None:
publish_kwargs["test_data_module"] = test_data_module
publish.publish_fuchsiaperf(**publish_kwargs)
return storage_metrics
except Exception as e:
_LOGGER.warning("Failed to publish storage I/O metrics: %s", e)
return []