""" Streamlit chart renderer for performance reports. This module provides functions to render interactive charts using Plotly. """ from typing import Dict import plotly.graph_objects as go import streamlit as st from plotly.subplots import make_subplots def render_single_spu_charts(chart_data: Dict, spu_identifier: str): """Render charts for a single SPU report. Args: chart_data: Chart data from prepare_chart_data() spu_identifier: SPU identifier string """ if not chart_data: st.info("No chart data available") return st.header("Performance Charts") for wf_type, data in sorted(chart_data.items()): st.subheader(f"{wf_type}") tasks = data.get("tasks", []) tps_data = data.get("tps_data", {}) latency_p95_data = data.get("latency_p95_data", {}) metric_type = data.get("metric_type", "TPS") if not tps_data: st.info(f"No data available for {wf_type}") continue # Create subplots: TPS and Latency has_latency = bool(latency_p95_data) if has_latency: fig = make_subplots( rows=1, cols=2, subplot_titles=( f"{metric_type} vs Thread Count", "P95 Latency vs Thread Count", ), specs=[[{"secondary_y": False}, {"secondary_y": False}]], ) else: fig = make_subplots( rows=1, cols=1, subplot_titles=(f"{metric_type} vs Thread Count",), ) # Add TPS traces for each task for task_id in tasks: if task_id not in tps_data: continue task_data = tps_data[task_id] threads = task_data["threads"] values = task_data["values"] fig.add_trace( go.Bar( x=threads, y=values, name=f"Task {task_id}", text=[f"{v:.0f}" if v else "" for v in values], textposition="outside", ), row=1, col=1, ) # Add latency traces if available if has_latency: for task_id in tasks: if task_id not in latency_p95_data: continue task_data = latency_p95_data[task_id] threads = task_data["threads"] values = task_data["values"] fig.add_trace( go.Bar( x=threads, y=values, name=f"Task {task_id}", text=[f"{v:.1f}" if v else "" for v in values], textposition="outside", showlegend=False, # Already shown in first subplot ), row=1, col=2, ) # Update layout fig.update_xaxes(title_text="Thread Count", type="category", row=1, col=1) fig.update_yaxes(title_text=metric_type, row=1, col=1) if has_latency: fig.update_xaxes(title_text="Thread Count", type="category", row=1, col=2) fig.update_yaxes(title_text="Latency (ms)", row=1, col=2) fig.update_layout( height=500, showlegend=True, legend=dict( orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1 ), barmode="group", ) st.plotly_chart(fig, width="stretch") def render_comparison_charts(chart_data: Dict, spu1_name: str, spu2_name: str): """Render comparison charts between two SPUs. Args: chart_data: Chart data from prepare_comparison_chart_data() spu1_name: First SPU identifier spu2_name: Second SPU identifier """ if not chart_data: st.info("No chart data available") return st.header("Performance Comparison Charts") for wf_type, data in sorted(chart_data.items()): st.subheader(f"{wf_type}") threads = data.get("threads", []) metrics = data.get("metrics", {}) if not threads or not metrics: st.info(f"No comparison data available for {wf_type}") continue # Extract differences between SPU names for concise legend spu1_short, spu2_short = _diff_spu_names(spu1_name, spu2_name) # Separate throughput and latency metrics throughput_metrics = [] latency_metrics = [] for metric_name, metric_data in sorted(metrics.items()): if metric_data["is_latency"]: latency_metrics.append((metric_name, metric_data)) else: throughput_metrics.append((metric_name, metric_data)) # Calculate total rows needed total_rows = len(throughput_metrics) + len(latency_metrics) if total_rows == 0: st.info(f"No metrics available for {wf_type}") continue # Create subplots subplot_titles = [] for _, metric_data in throughput_metrics: subplot_titles.append(f"{metric_data['display_name']} Comparison") for _, metric_data in latency_metrics: subplot_titles.append(f"{metric_data['display_name']} Comparison") fig = make_subplots( rows=total_rows, cols=1, subplot_titles=subplot_titles, specs=[[{"secondary_y": True}] for _ in range(total_rows)], vertical_spacing=0.08, ) # Define colors config1_color = "#1f77b4" config2_color = "#ff7f0e" change_color = "#2ca02c" row_idx = 1 # Render throughput metrics for metric_name, metric_data in throughput_metrics: spu1_values = metric_data["spu1_values"] spu2_values = metric_data["spu2_values"] change_pct = metric_data["change_pct"] display_name = metric_data["display_name"] # Add Config 1 bars fig.add_trace( go.Bar( x=threads, y=spu1_values, name=spu1_short, text=[f"{v:,.0f}" for v in spu1_values], textposition="outside", marker_color=config1_color, showlegend=(row_idx == 1), # Only show legend in first chart ), row=row_idx, col=1, secondary_y=False, ) # Add Config 2 bars fig.add_trace( go.Bar( x=threads, y=spu2_values, name=spu2_short, text=[f"{v:,.0f}" for v in spu2_values], textposition="outside", marker_color=config2_color, showlegend=(row_idx == 1), ), row=row_idx, col=1, secondary_y=False, ) # Add change line fig.add_trace( go.Scatter( x=threads, y=change_pct, name="Change %", mode="lines+markers+text", text=[f"{v:+.1f}%" for v in change_pct], textposition="top center", marker=dict(size=8), line=dict(color=change_color, width=2), showlegend=(row_idx == 1), ), row=row_idx, col=1, secondary_y=True, ) # Update axes for this row fig.update_xaxes( title_text="Thread Count", type="category", row=row_idx, col=1 ) fig.update_yaxes( title_text=display_name, row=row_idx, col=1, secondary_y=False ) fig.update_yaxes( title_text="Change (%)", row=row_idx, col=1, secondary_y=True ) row_idx += 1 # Render latency metrics for metric_name, metric_data in latency_metrics: spu1_values = metric_data["spu1_values"] spu2_values = metric_data["spu2_values"] change_pct = metric_data["change_pct"] display_name = metric_data["display_name"] # Add Config 1 bars fig.add_trace( go.Bar( x=threads, y=spu1_values, name=spu1_short, text=[f"{v:.2f}" for v in spu1_values], textposition="outside", marker_color=config1_color, showlegend=False, ), row=row_idx, col=1, secondary_y=False, ) # Add Config 2 bars fig.add_trace( go.Bar( x=threads, y=spu2_values, name=spu2_short, text=[f"{v:.2f}" for v in spu2_values], textposition="outside", marker_color=config2_color, showlegend=False, ), row=row_idx, col=1, secondary_y=False, ) # Add change line fig.add_trace( go.Scatter( x=threads, y=change_pct, name="Change %", mode="lines+markers+text", text=[f"{v:+.1f}%" for v in change_pct], textposition="top center", marker=dict(size=8), line=dict(color=change_color, width=2), showlegend=False, ), row=row_idx, col=1, secondary_y=True, ) # Update axes for this row fig.update_xaxes( title_text="Thread Count", type="category", row=row_idx, col=1 ) fig.update_yaxes( title_text=f"{display_name} (ms)", row=row_idx, col=1, secondary_y=False, ) fig.update_yaxes( title_text="Change (%)", row=row_idx, col=1, secondary_y=True ) row_idx += 1 # Update layout height = 400 * total_rows # 400px per metric fig.update_layout( height=height, showlegend=True, legend=dict( orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1 ), barmode="group", ) st.plotly_chart(fig, width="stretch") def _diff_spu_names(spu1: str, spu2: str) -> tuple[str, str]: """Extract and return only the differing parts of two SPU identifiers. SPU format: {tidb_spu}x{tidb_count}_{tikv_spu}x{tikv_count}_{cloud_provider}_{region}_{version} Args: spu1: First SPU identifier spu2: Second SPU identifier Returns: Tuple of (simplified_spu1, simplified_spu2) showing only differences Examples: # Different TiDB specs Input: ('8C16G-V2x1_8C32Gx1_AWS_aws-us-west-2_v8.5.4', '8C16Gx1_8C32Gx1_AWS_aws-us-west-2_v8.5.4') Output: ('8C16G-V2', '8C16G') # Different versions Input: ('8C16Gx1_8C32Gx1_AWS_aws-us-west-2_v8.5.4', '8C16Gx1_8C32Gx1_AWS_aws-us-west-2_v8.6.0') Output: ('v8.5.4', 'v8.6.0') # Multiple differences Input: ('8C16G-V2x1_8C32Gx1_AWS_aws-us-west-2_v8.5.4', '4C8Gx1_8C32Gx1_AWS_aws-us-east-1_v8.6.0') Output: ('8C16G-V2_aws-us-west-2_v8.5.4', '4C8G_aws-us-east-1_v8.6.0') """ # Split by underscore to get components parts1 = spu1.split("_") parts2 = spu2.split("_") # If format doesn't match, return full identifiers if len(parts1) != len(parts2) or len(parts1) != 5: return (spu1, spu2) # Find differing components diff_parts1 = [] diff_parts2 = [] for i, (p1, p2) in enumerate(zip(parts1, parts2)): if p1 != p2: # For TiDB/TiKV (first two components), strip node count (x1, x2, etc) if i < 2 and "x" in p1 and "x" in p2: p1_base = p1.split("x")[0] p2_base = p2.split("x")[0] # If only count differs, keep the count if p1_base == p2_base: diff_parts1.append(p1) diff_parts2.append(p2) else: # If spec differs, drop count for clarity diff_parts1.append(p1_base) diff_parts2.append(p2_base) else: diff_parts1.append(p1) diff_parts2.append(p2) # If no differences found (shouldn't happen in comparison) if not diff_parts1: # Fallback: return first component (TiDB spec) return ( parts1[0].split("x")[0] if "x" in parts1[0] else parts1[0], parts2[0].split("x")[0] if "x" in parts2[0] else parts2[0], ) # If only one difference, return it directly if len(diff_parts1) == 1: return (diff_parts1[0], diff_parts2[0]) # If multiple differences, join with underscore return ("_".join(diff_parts1), "_".join(diff_parts2))