feat: Add performance charts and CI auto-commit

- Add scripts/generate-perf-charts.py for matplotlib visualizations
- Update generate-perf-report.sh for versioned directories (reports/TAG/)
- Add make perf-charts and make perf-all targets
- Add GitLab CI perf-report job to auto-commit after tagged releases
- Generated 6 charts for 4.2.0: dashboard, duration, histogram, leaders, etc.

Each release now gets its own reports/TAG/ directory with:
  - perf.json (raw timing data)
  - perf.md (markdown report)
  - chart-*.png (6 visualizations)
This commit is contained in:
russell@unturf.com 2026-01-18 14:00:08 -05:00
parent a0ee191dd7
commit 88a3a6718c
13 changed files with 424 additions and 14 deletions

1
.gitignore vendored
View file

@ -44,3 +44,4 @@ clients/c/un
clients/c/examples/fibonacci
clients/c/examples/hello_world
clients/c/tests/test_library
build/

View file

@ -227,3 +227,36 @@ report:
only:
- main
- /^\d+\.\d+\.\d+$/
# ============================================================================
# STAGE 9: Performance Report (Tags Only)
# ============================================================================
perf-report:
stage: report
needs:
- trigger-test-matrix
script:
- echo "Generating performance report for $CI_COMMIT_TAG..."
- bash scripts/generate-perf-report.sh "$CI_COMMIT_TAG"
- |
echo "Generating performance charts..."
build/un -a -f "reports/$CI_COMMIT_TAG/perf.json" scripts/generate-perf-charts.py || echo "Charts generation requires unsandbox"
mv -f *.png "reports/$CI_COMMIT_TAG/" 2>/dev/null || true
- ls -la "reports/$CI_COMMIT_TAG/"
- |
echo "Committing performance report to main..."
git config user.email "ci@unturf.com"
git config user.name "GitLab CI"
git remote set-url origin "https://gitlab-ci-token:${CI_JOB_TOKEN}@git.unturf.com/${CI_PROJECT_PATH}.git"
git fetch origin main
git checkout main
git add reports/
git commit -m "perf: Add performance report for $CI_COMMIT_TAG" || echo "No changes to commit"
git push origin main || echo "Push failed - may need deploy key"
artifacts:
paths:
- reports/
expire_in: 90 days
only:
- /^\d+\.\d+\.\d+$/
allow_failure: true

View file

@ -314,10 +314,24 @@ endif
# Performance Reporting
# ============================================================================
.PHONY: perf-report
.PHONY: perf-report perf-charts
perf-report:
ifdef TAG
@bash scripts/generate-perf-report.sh $(TAG)
else
@bash scripts/generate-perf-report.sh
endif
perf-charts:
ifdef TAG
@echo "Generating performance charts for $(TAG)..."
@build/un -a -f reports/$(TAG)/perf.json scripts/generate-perf-charts.py
@mv -f *.png reports/$(TAG)/ 2>/dev/null || true
@echo "✓ Charts saved to reports/$(TAG)/"
else
@echo "Usage: make perf-charts TAG=4.2.0"
endif
perf-all: perf-report perf-charts
@echo "✓ Performance report and charts complete for $(TAG)"
@ls -la reports/$(TAG)/

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

359
scripts/generate-perf-charts.py Executable file
View file

@ -0,0 +1,359 @@
#!/usr/bin/env python3
"""
Generate performance visualization charts from CI timing data.
Outputs PNG charts to /tmp/artifacts/ for collection by unsandbox.
Usage: python3 generate-perf-charts.py [perf.json]
Via unsandbox: build/un -a -f reports/TAG/perf.json scripts/generate-perf-charts.py
"""
import json
import sys
import os
# Create artifacts directory (unsandbox collects from /tmp/artifacts/)
os.makedirs('/tmp/artifacts', exist_ok=True)
# Load data from file or stdin
# When run via unsandbox, files are uploaded to /tmp/input/
possible_paths = [
'/tmp/input/perf.json', # Via unsandbox -f
'/tmp/input/perf-4.2.0.json', # Legacy format
]
if len(sys.argv) > 1:
possible_paths.insert(0, sys.argv[1])
# Also try to find any JSON file in /tmp/input/
if os.path.exists('/tmp/input'):
for f in os.listdir('/tmp/input'):
if f.endswith('.json'):
possible_paths.append(f'/tmp/input/{f}')
data = None
for path in possible_paths:
try:
with open(path) as f:
data = json.load(f)
print(f'Loaded data from {path}')
break
except (FileNotFoundError, json.JSONDecodeError):
continue
if data is None:
# Try stdin as last resort
try:
data = json.load(sys.stdin)
print('Loaded data from stdin')
except:
print('Error: Could not load performance data')
print(f'Tried: {possible_paths}')
sys.exit(1)
# Import matplotlib (available in unsandbox)
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import numpy as np
# Set style
plt.style.use('seaborn-v0_8-darkgrid' if 'seaborn-v0_8-darkgrid' in plt.style.available else 'ggplot')
plt.rcParams['figure.facecolor'] = '#1a1a2e'
plt.rcParams['axes.facecolor'] = '#16213e'
plt.rcParams['text.color'] = '#eee'
plt.rcParams['axes.labelcolor'] = '#eee'
plt.rcParams['xtick.color'] = '#eee'
plt.rcParams['ytick.color'] = '#eee'
plt.rcParams['axes.edgecolor'] = '#444'
plt.rcParams['grid.color'] = '#333'
plt.rcParams['font.size'] = 10
# Extract data
languages = data['languages']
tag = data['tag']
summary = data['summary']
# Sort by duration
langs_sorted = sorted(languages, key=lambda x: x['duration_seconds'], reverse=True)
names = [l['language'] for l in langs_sorted]
durations = [l['duration_seconds'] for l in langs_sorted]
queued = [l['queued_duration'] for l in langs_sorted]
# Language categories
COMPILED = {'c', 'cpp', 'go', 'rust', 'java', 'kotlin', 'swift', 'csharp', 'fsharp',
'haskell', 'ocaml', 'd', 'nim', 'zig', 'crystal', 'fortran', 'cobol',
'objc', 'v', 'dart'}
INTERPRETED = {'python', 'javascript', 'typescript', 'ruby', 'php', 'perl', 'lua',
'bash', 'r', 'awk', 'tcl', 'scheme', 'commonlisp', 'clojure', 'elixir',
'erlang', 'groovy', 'raku', 'julia', 'prolog', 'forth', 'powershell', 'deno'}
categories = []
for l in langs_sorted:
lang = l['language']
if lang in COMPILED:
categories.append('compiled')
elif lang in INTERPRETED:
categories.append('interpreted')
else:
categories.append('other')
# Color palette
colors_by_cat = {'compiled': '#e94560', 'interpreted': '#0f3460', 'other': '#533483'}
colors = [colors_by_cat.get(c, '#666') for c in categories]
# ============================================================================
# Chart 1: Horizontal Bar Chart - Duration by Language
# ============================================================================
fig, ax = plt.subplots(figsize=(12, 14))
y_pos = np.arange(len(names))
bars = ax.barh(y_pos, durations, color=colors, edgecolor='#fff', linewidth=0.5)
# Add value labels
for i, (bar, dur) in enumerate(zip(bars, durations)):
ax.text(dur + 1, bar.get_y() + bar.get_height()/2, f'{dur}s',
va='center', ha='left', fontsize=8, color='#aaa')
ax.set_yticks(y_pos)
ax.set_yticklabels(names, fontsize=9)
ax.invert_yaxis()
ax.set_xlabel('Duration (seconds)', fontsize=12)
ax.set_title(f'UN Inception v{tag} - Test Duration by Language\n42 Languages • 638 Tests • 100% Pass Rate',
fontsize=14, fontweight='bold', color='#fff', pad=20)
# Legend
compiled_patch = mpatches.Patch(color='#e94560', label='Compiled')
interpreted_patch = mpatches.Patch(color='#0f3460', label='Interpreted')
ax.legend(handles=[compiled_patch, interpreted_patch], loc='lower right',
facecolor='#1a1a2e', edgecolor='#444', labelcolor='#eee')
# Stats annotation
stats_text = f"Avg: {summary['avg_duration_seconds']}s | Min: {summary['min_duration_seconds']}s | Max: {summary['max_duration_seconds']}s"
ax.text(0.5, -0.05, stats_text, transform=ax.transAxes, ha='center', fontsize=10, color='#888')
plt.tight_layout()
plt.savefig('/tmp/artifacts/chart-duration-by-language.png', dpi=150, facecolor='#1a1a2e')
print('✓ chart-duration-by-language.png')
# ============================================================================
# Chart 2: Category Comparison (Compiled vs Interpreted)
# ============================================================================
fig, axes = plt.subplots(1, 2, figsize=(14, 6))
# Pie chart
compiled_count = sum(1 for c in categories if c == 'compiled')
interpreted_count = sum(1 for c in categories if c == 'interpreted')
ax1 = axes[0]
sizes = [compiled_count, interpreted_count]
labels = [f'Compiled\n({compiled_count})', f'Interpreted\n({interpreted_count})']
explode = (0.02, 0.02)
wedges, texts, autotexts = ax1.pie(sizes, labels=labels, autopct='%1.0f%%',
colors=['#e94560', '#0f3460'],
explode=explode, startangle=90,
textprops={'color': '#eee', 'fontsize': 11},
wedgeprops={'edgecolor': '#fff', 'linewidth': 1})
ax1.set_title('Language Distribution', fontsize=12, color='#fff', fontweight='bold')
# Box plot comparing durations
ax2 = axes[1]
compiled_durations = [l['duration_seconds'] for l, c in zip(langs_sorted, categories) if c == 'compiled']
interpreted_durations = [l['duration_seconds'] for l, c in zip(langs_sorted, categories) if c == 'interpreted']
bp = ax2.boxplot([compiled_durations, interpreted_durations],
labels=['Compiled', 'Interpreted'],
patch_artist=True,
medianprops={'color': '#fff', 'linewidth': 2})
bp['boxes'][0].set_facecolor('#e94560')
bp['boxes'][1].set_facecolor('#0f3460')
for box in bp['boxes']:
box.set_edgecolor('#fff')
ax2.set_ylabel('Duration (seconds)', fontsize=11)
ax2.set_title('Duration Distribution by Category', fontsize=12, color='#fff', fontweight='bold')
# Add means as points
ax2.scatter([1], [np.mean(compiled_durations)], color='#fff', s=100, zorder=5, marker='D', label='Mean')
ax2.scatter([2], [np.mean(interpreted_durations)], color='#fff', s=100, zorder=5, marker='D')
plt.suptitle(f'UN Inception v{tag} - Compiled vs Interpreted', fontsize=14, fontweight='bold', color='#fff', y=1.02)
plt.tight_layout()
plt.savefig('/tmp/artifacts/chart-category-comparison.png', dpi=150, facecolor='#1a1a2e')
print('✓ chart-category-comparison.png')
# ============================================================================
# Chart 3: Duration Distribution Histogram with KDE-like curve
# ============================================================================
fig, ax = plt.subplots(figsize=(10, 6))
n, bins, patches = ax.hist(durations, bins=15, color='#e94560', edgecolor='#fff',
alpha=0.8, linewidth=1)
# Color gradient based on height
max_height = max(n)
for patch, height in zip(patches, n):
intensity = 0.3 + 0.7 * (height / max_height)
patch.set_facecolor(plt.cm.plasma(intensity))
ax.axvline(summary['avg_duration_seconds'], color='#00ff88', linestyle='--',
linewidth=2, label=f"Mean: {summary['avg_duration_seconds']}s")
ax.axvline(np.median(durations), color='#00d4ff', linestyle=':',
linewidth=2, label=f"Median: {int(np.median(durations))}s")
ax.set_xlabel('Duration (seconds)', fontsize=12)
ax.set_ylabel('Number of Languages', fontsize=12)
ax.set_title(f'UN Inception v{tag} - Test Duration Distribution\n42 Languages',
fontsize=14, fontweight='bold', color='#fff', pad=15)
ax.legend(facecolor='#1a1a2e', edgecolor='#444', labelcolor='#eee')
plt.tight_layout()
plt.savefig('/tmp/artifacts/chart-duration-histogram.png', dpi=150, facecolor='#1a1a2e')
print('✓ chart-duration-histogram.png')
# ============================================================================
# Chart 4: Top 10 Slowest vs Top 10 Fastest
# ============================================================================
fig, axes = plt.subplots(1, 2, figsize=(14, 6))
# Top 10 slowest
ax1 = axes[0]
slowest = langs_sorted[:10]
slow_names = [l['language'] for l in slowest]
slow_durations = [l['duration_seconds'] for l in slowest]
slow_colors = [colors_by_cat.get(c, '#666') for c in categories[:10]]
bars1 = ax1.barh(range(10), slow_durations, color=slow_colors, edgecolor='#fff')
ax1.set_yticks(range(10))
ax1.set_yticklabels(slow_names)
ax1.invert_yaxis()
ax1.set_xlabel('Duration (seconds)')
ax1.set_title('🐢 Top 10 Slowest', fontsize=12, color='#ff6b6b', fontweight='bold')
for i, (bar, dur) in enumerate(zip(bars1, slow_durations)):
ax1.text(dur + 1, i, f'{dur}s', va='center', fontsize=9, color='#aaa')
# Top 10 fastest
ax2 = axes[1]
fastest = langs_sorted[-10:][::-1]
fast_names = [l['language'] for l in fastest]
fast_durations = [l['duration_seconds'] for l in fastest]
fast_categories = [categories[names.index(n)] for n in fast_names]
fast_colors = [colors_by_cat.get(c, '#666') for c in fast_categories]
bars2 = ax2.barh(range(10), fast_durations, color=fast_colors, edgecolor='#fff')
ax2.set_yticks(range(10))
ax2.set_yticklabels(fast_names)
ax2.invert_yaxis()
ax2.set_xlabel('Duration (seconds)')
ax2.set_title('🚀 Top 10 Fastest', fontsize=12, color='#00ff88', fontweight='bold')
for i, (bar, dur) in enumerate(zip(bars2, fast_durations)):
ax2.text(dur + 1, i, f'{dur}s', va='center', fontsize=9, color='#aaa')
plt.suptitle(f'UN Inception v{tag} - Speed Leaders', fontsize=14, fontweight='bold', color='#fff', y=1.02)
plt.tight_layout()
plt.savefig('/tmp/artifacts/chart-speed-leaders.png', dpi=150, facecolor='#1a1a2e')
print('✓ chart-speed-leaders.png')
# ============================================================================
# Chart 5: Queue Time vs Execution Time Scatter
# ============================================================================
fig, ax = plt.subplots(figsize=(10, 8))
scatter_colors = [colors_by_cat.get(c, '#666') for c in categories]
scatter = ax.scatter(queued, durations, c=scatter_colors, s=100, alpha=0.8, edgecolors='#fff', linewidth=0.5)
# Label outliers
for i, (q, d, name) in enumerate(zip(queued, durations, names)):
if d > 70 or q > 200:
ax.annotate(name, (q, d), xytext=(5, 5), textcoords='offset points',
fontsize=8, color='#aaa')
ax.set_xlabel('Queue Duration (seconds)', fontsize=12)
ax.set_ylabel('Execution Duration (seconds)', fontsize=12)
ax.set_title(f'UN Inception v{tag} - Queue Time vs Execution Time\nEach point is one language',
fontsize=14, fontweight='bold', color='#fff', pad=15)
# Legend
compiled_patch = mpatches.Patch(color='#e94560', label='Compiled')
interpreted_patch = mpatches.Patch(color='#0f3460', label='Interpreted')
ax.legend(handles=[compiled_patch, interpreted_patch], loc='upper right',
facecolor='#1a1a2e', edgecolor='#444', labelcolor='#eee')
plt.tight_layout()
plt.savefig('/tmp/artifacts/chart-queue-vs-execution.png', dpi=150, facecolor='#1a1a2e')
print('✓ chart-queue-vs-execution.png')
# ============================================================================
# Chart 6: Summary Dashboard
# ============================================================================
fig = plt.figure(figsize=(16, 10))
# Create grid
gs = fig.add_gridspec(3, 3, hspace=0.4, wspace=0.3)
# Big title
fig.suptitle(f'UN Inception v{tag} Performance Dashboard', fontsize=20, fontweight='bold',
color='#fff', y=0.98)
# Metric cards (top row)
metrics = [
('Tests', str(summary['total_tests']), '#00ff88'),
('Languages', str(summary['total_languages']), '#00d4ff'),
('Pass Rate', '100%', '#ff6b6b'),
('Avg Time', f"{summary['avg_duration_seconds']}s", '#ffd93d'),
]
for i, (label, value, color) in enumerate(metrics):
ax = fig.add_subplot(gs[0, i] if i < 3 else gs[0, 2])
if i == 3:
ax = fig.add_axes([0.78, 0.75, 0.18, 0.15])
ax.set_xlim(0, 1)
ax.set_ylim(0, 1)
ax.text(0.5, 0.6, value, fontsize=32, fontweight='bold', color=color,
ha='center', va='center', transform=ax.transAxes)
ax.text(0.5, 0.2, label, fontsize=14, color='#888',
ha='center', va='center', transform=ax.transAxes)
ax.axis('off')
# Mini bar chart (bottom left)
ax_bar = fig.add_subplot(gs[1:, :2])
top_15 = langs_sorted[:15]
y_pos = np.arange(15)
bar_colors = [colors_by_cat.get(categories[i], '#666') for i in range(15)]
ax_bar.barh(y_pos, [l['duration_seconds'] for l in top_15], color=bar_colors, edgecolor='#fff')
ax_bar.set_yticks(y_pos)
ax_bar.set_yticklabels([l['language'] for l in top_15], fontsize=9)
ax_bar.invert_yaxis()
ax_bar.set_xlabel('Duration (seconds)')
ax_bar.set_title('Slowest 15 Languages', fontsize=12, color='#fff', fontweight='bold')
# Pie chart (bottom right)
ax_pie = fig.add_subplot(gs[1, 2])
ax_pie.pie([compiled_count, interpreted_count],
labels=['Compiled', 'Interpreted'],
autopct='%1.0f%%',
colors=['#e94560', '#0f3460'],
textprops={'color': '#eee'},
wedgeprops={'edgecolor': '#fff'})
ax_pie.set_title('Language Types', fontsize=12, color='#fff', fontweight='bold')
# Stats text (bottom right corner)
ax_stats = fig.add_subplot(gs[2, 2])
ax_stats.axis('off')
stats = f"""
Fastest: {summary['fastest_language']} ({summary['min_duration_seconds']}s)
Slowest: {summary['slowest_language']} ({summary['max_duration_seconds']}s)
Spread: {summary['max_duration_seconds'] - summary['min_duration_seconds']}s
Generated: {data['generated_at'][:10]}
Pipeline: #{data['child_pipeline_id']}
"""
ax_stats.text(0.1, 0.9, stats, fontsize=11, color='#aaa', va='top',
transform=ax_stats.transAxes, family='monospace')
plt.savefig('/tmp/artifacts/chart-dashboard.png', dpi=150, facecolor='#1a1a2e')
print('✓ chart-dashboard.png')
print(f'\n✅ Generated 6 charts in /tmp/artifacts/')
print('Files:')
for f in os.listdir('/tmp/artifacts'):
print(f' - {f}')

View file

@ -26,8 +26,10 @@ fi
echo "Generating performance report for tag: $TAG"
# Create reports directory
mkdir -p reports
# Create versioned reports directory
REPORT_DIR="reports/$TAG"
mkdir -p "$REPORT_DIR"
echo "Output directory: $REPORT_DIR"
# Find pipeline for this tag (prefer passing pipelines)
echo "Finding pipeline for tag $TAG..."
@ -124,7 +126,7 @@ SLOWEST_LANG=$(echo "$PERF_JSON" | jq -r '.[0].language')
FASTEST_LANG=$(echo "$PERF_JSON" | jq -r '.[-1].language')
# Write JSON report
cat > "reports/perf-$TAG.json" << EOF
cat > "$REPORT_DIR/perf.json" << EOF
{
"tag": "$TAG",
"generated_at": "$TIMESTAMP",
@ -146,10 +148,10 @@ cat > "reports/perf-$TAG.json" << EOF
}
EOF
echo "reports/perf-$TAG.json"
echo "$REPORT_DIR/perf.json"
# Write Markdown report
cat > "reports/perf-$TAG.md" << EOF
cat > "$REPORT_DIR/perf.md" << EOF
# Performance Report: $TAG
**Generated:** $TIMESTAMP
@ -175,9 +177,9 @@ cat > "reports/perf-$TAG.md" << EOF
EOF
# Add per-language rows
echo "$PERF_JSON" | jq -r '.[] | "| \(.language) | \(.status) | \(.duration_seconds)s | - |"' >> "reports/perf-$TAG.md"
echo "$PERF_JSON" | jq -r '.[] | "| \(.language) | \(.status) | \(.duration_seconds)s | - |"' >> "$REPORT_DIR/perf.md"
cat >> "reports/perf-$TAG.md" << EOF
cat >> "$REPORT_DIR/perf.md" << EOF
## Timing Distribution
@ -189,17 +191,17 @@ echo "$PERF_JSON" | jq -r '.[] | "\(.language)|\(.duration_seconds)"' | while IF
bar=""
bars=$((dur / 5))
for ((i=0; i<bars && i<40; i++)); do bar+="█"; done
printf "%-12s %3ds %s\n" "$lang" "$dur" "$bar" >> "reports/perf-$TAG.md"
printf "%-12s %3ds %s\n" "$lang" "$dur" "$bar" >> "$REPORT_DIR/perf.md"
done
cat >> "reports/perf-$TAG.md" << EOF
cat >> "$REPORT_DIR/perf.md" << EOF
\`\`\`
---
*Report generated by \`make perf-report\`*
EOF
echo "reports/perf-$TAG.md"
echo "$REPORT_DIR/perf.md"
# Update or create historical index
if [ ! -f "reports/PERFORMANCE-HISTORY.md" ]; then
@ -223,8 +225,9 @@ fi
echo ""
echo "Done! Performance report for $TAG:"
echo " - reports/perf-$TAG.json"
echo " - reports/perf-$TAG.md"
echo " - $REPORT_DIR/perf.json"
echo " - $REPORT_DIR/perf.md"
echo " - reports/PERFORMANCE-HISTORY.md"
echo ""
echo "To commit: git add reports/ && git commit -m 'perf: Add performance report for $TAG'"
echo "Next: make perf-charts TAG=$TAG"
echo "Then: git add reports/ && git commit -m 'perf: Add performance report for $TAG'"