#!/usr/bin/env python3 """ plot_fio_latency.py — visualize fio json+ latency CSVs. Designed for the wide-format CSV produced by fio_jsonplus_clat2csv in fio 3.x, which has columns like: nsec, read_slat_ns_count, read_slat_ns_cumulative, read_slat_ns_percentile, read_clat_ns_count, read_clat_ns_cumulative, read_clat_ns_percentile, ... (one triple per metric) ... Only the metric(s) that were actually exercised contain data. Examples -------- python plot_fio_latency.py scan_latency_job0.csv python plot_fio_latency.py scan_latency_job0.csv --metric read_clat --unit ms python plot_fio_latency.py scan_latency_job0.csv --metric read_clat --cdf --log-y python plot_fio_latency.py scan_latency_job0.csv --all-metrics --cdf-only """ import argparse import os import sys from pathlib import Path import numpy as np import pandas as pd import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt from matplotlib.ticker import FuncFormatter UNIT_FACTORS = {"ns": 1.0, "us": 1e3, "ms": 1e6, "s": 1e9} UNIT_LABELS = {"ns": "ns", "us": "µs", "ms": "ms", "s": "s"} # --------------------------------------------------------------------------- # Loading # --------------------------------------------------------------------------- def load_latency_csv(path: str) -> pd.DataFrame: """ Parse the wide fio_jsonplus CSV into a long-format DataFrame with columns: metric, latency_ns, count, cumulative, percentile Transforms wide-format columns (e.g., read_clat_ns_count) into rows with a metric identifier, making it easier to group and analyze by operation type. """ df = pd.read_csv(path, skipinitialspace=True) df.columns = [str(c).strip() for c in df.columns] # Drop trailing unnamed / empty columns df = df.loc[:, ~df.columns.str.match(r"^Unnamed")] df = df.loc[:, df.columns != ""] nsec_col = df.columns[0] if "nsec" not in nsec_col.lower(): raise ValueError( f"expected first column to be 'nsec', got {nsec_col!r}" ) # Coerce the nsec column to numeric (it should always be numeric, but # be defensive about stray whitespace). df[nsec_col] = pd.to_numeric(df[nsec_col], errors="coerce") df = df.dropna(subset=[nsec_col]) # Discover metric triples: _count / _cumulative / _percentile metrics = {} for col in df.columns: if col == nsec_col: continue for suffix in ("_count", "_cumulative", "_percentile"): if col.endswith(suffix): base = col[: -len(suffix)] # e.g. read_clat_ns metrics.setdefault(base, {})[suffix[1:]] = col if not metrics: raise ValueError("no *_count columns found in CSV") frames = [] for base, cols in metrics.items(): count_col = cols["count"] # Coerce count to numeric — blanks become NaN, whitespace is ignored. counts = pd.to_numeric(df[count_col], errors="coerce") # Keep only rows where the count column actually has a number. mask = counts.notna() if not mask.any(): continue sub = pd.DataFrame({ "latency_ns": df.loc[mask, nsec_col].values, "count": counts[mask].astype(int).values, }) if "cumulative" in cols: sub["cumulative"] = pd.to_numeric( df.loc[mask, cols["cumulative"]], errors="coerce" ).values if "percentile" in cols: sub["percentile"] = pd.to_numeric( df.loc[mask, cols["percentile"]], errors="coerce" ).values # read_clat_ns -> read_clat sub["metric"] = base.replace("_ns", "") frames.append(sub) if not frames: raise ValueError("no metric had any numeric count data") out = pd.concat(frames, ignore_index=True) out = out[out["count"] > 0].reset_index(drop=True) return out # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def to_unit(values_ns, unit: str) -> np.ndarray: """Convert nanosecond values to the specified unit (us, ms, or s).""" return np.asarray(values_ns, dtype=float) / UNIT_FACTORS[unit] def human_count(x, _pos=None): """Format large numbers with K/M/G suffixes for axis labels.""" if x >= 1e9: return f"{x/1e9:.1f}G" if x >= 1e6: return f"{x/1e6:.1f}M" if x >= 1e3: return f"{x/1e3:.0f}k" return f"{x:.0f}" def percentile_table(df: pd.DataFrame, metric: str, unit: str, pcts) -> str: """ Generate a formatted table showing latency values at specified percentiles (e.g., p50, p99, p99.9) for a given metric. """ g = df[df["metric"] == metric].sort_values("latency_ns") if g.empty: return f"(no data for metric {metric})" counts = g["count"].values.astype(float) total = counts.sum() cdf = np.cumsum(counts) / total * 100.0 lat = to_unit(g["latency_ns"].values, unit) lines = [f"\nMetric: {metric} (total I/Os: {int(total):,})", f" {'percentile':>12} {'latency':>14}"] for p in pcts: idx = min(np.searchsorted(cdf, p), len(lat) - 1) lines.append(f" {p:>11.2f}% {lat[idx]:>12.3f} {UNIT_LABELS[unit]}") return "\n".join(lines) # --------------------------------------------------------------------------- # Plotting # --------------------------------------------------------------------------- def draw_histogram(ax, df, metrics, unit, log_y, log_x, normalize): """ Draw a histogram showing the distribution of latencies for each metric. Uses a step plot (where="post") to show the discrete nature of the data. If normalize=True, Y-axis shows probability density instead of counts. """ for metric in metrics: g = df[df["metric"] == metric].sort_values("latency_ns") if g.empty: continue x = to_unit(g["latency_ns"].values, unit) y = g["count"].values.astype(float) if normalize: s = y.sum() if s > 0: y = y / s ax.step(x, y, where="post", lw=1.4, label=metric) ax.fill_between(x, y, step="post", alpha=0.25) ax.set_xlabel(f"Latency ({UNIT_LABELS[unit]})") ax.set_ylabel("Probability" if normalize else "Number of I/Os") ax.set_title("Completion latency distribution") ax.grid(True, which="both", ls=":", alpha=0.5) if not normalize: ax.yaxis.set_major_formatter(FuncFormatter(human_count)) if log_y: ax.set_yscale("log") if log_x: ax.set_xscale("log") if len(metrics) > 1: ax.legend(title="metric", fontsize=9) def draw_cdf(ax, df, metrics, unit, log_x): """ Draw a Cumulative Distribution Function (CDF) showing the percentage of I/Os completed within each latency threshold. Each metric gets its own line; the Y-axis ranges from 0% to 100%. """ for metric in metrics: g = df[df["metric"] == metric].sort_values("latency_ns") if g.empty: continue x = to_unit(g["latency_ns"].values, unit) counts = g["count"].values.astype(float) total = counts.sum() if total <= 0: continue cdf = np.cumsum(counts) / total * 100.0 ax.plot(x, cdf, lw=1.6, label=f"{metric} (n={int(total):,})") ax.set_xlabel(f"Latency ({UNIT_LABELS[unit]})") ax.set_ylabel("Cumulative % of I/Os") ax.set_title("Cumulative latency distribution") ax.set_ylim(0, 100) ax.grid(True, which="both", ls=":", alpha=0.5) ax.yaxis.set_major_formatter(FuncFormatter(lambda v, _: f"{v:.0f}%")) if log_x: ax.set_xscale("log") ax.legend(fontsize=9) # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- def main(): p = argparse.ArgumentParser( description="Plot fio json+ wide-format latency CSV." ) p.add_argument("csv", help="CSV from fio_jsonplus_clat2csv") p.add_argument("--metric", default=None, help="Metric to plot (e.g. read_clat). " "If omitted, the metric with the most I/Os is used.") p.add_argument("--all-metrics", action="store_true", help="Plot every metric that has data") p.add_argument("--unit", choices=list(UNIT_FACTORS), default="us", help="X-axis latency unit (default: us)") p.add_argument("--log-y", action="store_true") p.add_argument("--log-x", action="store_true") p.add_argument("--cdf", action="store_true", help="Add a CDF subplot below the histogram") p.add_argument("--cdf-only", action="store_true") p.add_argument("--normalize", action="store_true", help="Normalize histogram to a probability density") p.add_argument("--percentiles", default="50,90,99,99.9,99.99") p.add_argument("--no-percentiles", action="store_true") p.add_argument("--output", "-o", default=None) p.add_argument("--dpi", type=int, default=150) p.add_argument("--title", default="") p.add_argument("--show", action="store_true") args = p.parse_args() if not os.path.isfile(args.csv): sys.exit(f"error: file not found: {args.csv}") df = load_latency_csv(args.csv) if df.empty: sys.exit("error: no latency data in CSV") available = sorted(df["metric"].unique()) print("Metrics with data:", ", ".join(available)) # Select which metrics to plot if args.all_metrics: metrics = available elif args.metric: if args.metric not in available: sys.exit(f"error: metric {args.metric!r} not in {available}") metrics = [args.metric] else: # Auto-select: pick the metric with the largest total count totals = df.groupby("metric")["count"].sum().sort_values(ascending=False) metrics = [totals.index[0]] print(f"Auto-selected metric: {metrics[0]}") # ------------------------------------------------------------------ # Build figure # ------------------------------------------------------------------ # Create subplot layout based on user's request: # --cdf-only: CDF only # --cdf: Histogram + CDF stacked vertically # default: Histogram only if args.cdf_only: fig, ax = plt.subplots(figsize=(11, 6)) draw_cdf(ax, df, metrics, args.unit, args.log_x) elif args.cdf: fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(11, 10)) draw_histogram(ax1, df, metrics, args.unit, args.log_y, args.log_x, normalize=args.normalize) draw_cdf(ax2, df, metrics, args.unit, args.log_x) else: fig, ax = plt.subplots(figsize=(11, 6)) draw_histogram(ax, df, metrics, args.unit, args.log_y, args.log_x, normalize=args.normalize) if args.title: fig.suptitle(args.title, fontsize=13, y=0.995) fig.tight_layout() # Save plot to file (default: _latency.png) out = args.output or f"{Path(args.csv).stem}_latency.png" fig.savefig(out, dpi=args.dpi, bbox_inches="tight") print(f"Saved plot to {out}") # Print percentile table (p50, p90, p99, p99.9, p99.99 by default) if not args.no_percentiles: try: pcts = [float(x) for x in args.percentiles.split(",") if x.strip()] except ValueError: sys.exit("error: --percentiles must be comma-separated numbers") for m in metrics: print(percentile_table(df, m, args.unit, pcts)) # Optionally display the plot interactively if args.show: plt.show() if __name__ == "__main__": main()