Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 73 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/llvm-snapshot.gpg
echo "deb [signed-by=/usr/share/keyrings/llvm-snapshot.gpg] https://apt.llvm.org/noble/ llvm-toolchain-noble main" | sudo tee /etc/apt/sources.list.d/llvm.list
sudo apt-get update
sudo apt-get install -y clang-format-20 shfmt
sudo apt-get install -y clang-format-20 shfmt black
env:
DEBIAN_FRONTEND: noninteractive
- name: Check format
Expand Down Expand Up @@ -62,3 +62,75 @@ jobs:
env:
CC: ${{ matrix.cc }}
CFLAGS: ${{ matrix.cflags }}

wcet:
needs: [coding-style, build-and-test]
runs-on: ${{ matrix.runner }}
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-24.04
arch: x86-64
- runner: ubuntu-24.04-arm
arch: arm64
steps:
- uses: actions/checkout@v6
- name: Install dependencies
run: sudo apt-get update && sudo apt-get install -y python3-matplotlib
env:
DEBIAN_FRONTEND: noninteractive
- name: Build (release, no assert/check overhead)
run: make all CFLAGS="-Iinclude -std=gnu11 -O2 -Wall -Wextra -Wconversion"
- name: WCET measurement
run: ./build/wcet -i 5000 -w 500 -r build/wcet_raw.csv | tee build/wcet_output.txt
- name: Generate plots
run: python3 scripts/wcet_plot.py build/wcet_raw.csv -o build/wcet
- name: Job summary
if: always()
run: |
{
echo "## WCET Results (${{ matrix.arch }})"
echo ""
echo '```'
cat build/wcet_output.txt 2>/dev/null || echo "WCET measurement failed or was skipped."
echo '```'
echo ""
echo "Box plot and histogram available in the **wcet-${{ matrix.arch }}** artifact."
} >> "$GITHUB_STEP_SUMMARY"
- name: Comment on PR
if: >-
always() && github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository
env:
GH_TOKEN: ${{ github.token }}
run: |
MARKER="<!-- wcet-${{ matrix.arch }} -->"
{
echo "$MARKER"
echo "## WCET Results (${{ matrix.arch }})"
echo ""
echo '```'
cat build/wcet_output.txt 2>/dev/null || echo "WCET measurement failed or was skipped."
echo '```'
} > /tmp/comment.md

COMMENT_ID=$(gh api "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments" \
--paginate --jq ".[] | select(.body | startswith(\"$MARKER\")) | .id" | head -1)

if [ -n "$COMMENT_ID" ]; then
gh api "repos/${{ github.repository }}/issues/comments/$COMMENT_ID" \
-X PATCH -F body=@/tmp/comment.md
else
gh pr comment "${{ github.event.pull_request.number }}" -F /tmp/comment.md
fi
- name: Upload WCET artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: wcet-${{ matrix.arch }}
path: |
build/wcet_raw.csv
build/wcet_boxplot.png
build/wcet_histogram.png
25 changes: 23 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ OUT = build

TARGETS = \
test \
bench
bench \
wcet
TARGETS := $(addprefix $(OUT)/,$(TARGETS))

all: $(TARGETS)
Expand Down Expand Up @@ -34,6 +35,9 @@ $(OUT)/test: $(OBJS) tests/test.c
$(OUT)/bench: $(OBJS) tests/bench.c
$(CC) $(CFLAGS) -o $@ -MMD -MF $@.d $^ $(LDFLAGS) -lm

$(OUT)/wcet: $(OBJS) tests/wcet.c
$(CC) $(CFLAGS) -o $@ -MMD -MF $@.d $^ $(LDFLAGS) -lm

$(OUT)/%.o: src/%.c
@mkdir -p $(OUT)
$(CC) $(CFLAGS) -c -o $@ -MMD -MF $@.d $<
Expand All @@ -43,10 +47,27 @@ check: $(TARGETS)
MALLOC_CHECK_=3 ./build/bench -l 10000 -i 3 -w 1
MALLOC_CHECK_=3 ./build/bench -s 32 -l 10000 -i 3 -w 1
MALLOC_CHECK_=3 ./build/bench -s 10:12345 -l 10000 -i 3 -w 1
./build/wcet -i 100 -w 10

# Full WCET measurement (10000 iterations, 1000 warmup)
wcet: all
./build/wcet

# Quick WCET check for development
wcet-quick: all
./build/wcet -i 1000 -w 100

# WCET with raw output and analysis plots
wcet-plot: all
@mkdir -p $(OUT)
./build/wcet -i 10000 -r $(OUT)/wcet_raw.csv -c > $(OUT)/wcet_summary.csv
python3 scripts/wcet_plot.py $(OUT)/wcet_raw.csv -o $(OUT)/wcet

clean:
$(RM) $(TARGETS) $(OBJS) $(deps)
$(RM) $(OUT)/wcet_raw.csv $(OUT)/wcet_summary.csv
$(RM) $(OUT)/wcet_boxplot.png $(OUT)/wcet_histogram.png

.PHONY: all check clean bench bench-quick
.PHONY: all check clean bench bench-quick wcet wcet-quick wcet-plot

-include $(deps)
233 changes: 233 additions & 0 deletions scripts/wcet_plot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
#!/usr/bin/env python3
"""
WCET analysis plots for TLSF allocator.

Reads raw sample data from 'wcet -r' output and generates latency
distribution plots. Falls back to text summary when matplotlib is
unavailable (e.g., CI environments).

Usage:
build/wcet -r samples.csv && python3 scripts/wcet_plot.py samples.csv
build/wcet -r samples.csv && python3 scripts/wcet_plot.py samples.csv -o build/wcet

The output prefix (-o) controls where PNG files are written. Two plots
are generated:
{prefix}_boxplot.png - Box plot of all scenarios per size class
{prefix}_histogram.png - Latency histograms per scenario
"""

import argparse
import csv
import sys
from collections import defaultdict

try:
import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt

HAS_MATPLOTLIB = True
except ImportError:
HAS_MATPLOTLIB = False


def read_raw_csv(path):
"""Read raw sample CSV: scenario,size,[unit,]value -> (data, unit)."""
data = defaultdict(lambda: defaultdict(list))
unit = None
with open(path) as f:
reader = csv.DictReader(f)
for row in reader:
scenario = row["scenario"]
size = int(row["size"])
value = int(row["value"])
data[scenario][size].append(value)
if unit is None and "unit" in row:
unit = row["unit"]
return data, unit


def percentile(sorted_data, p):
"""Return the p-th percentile from pre-sorted data."""
if not sorted_data:
return 0
idx = int(len(sorted_data) * p / 100.0)
if idx >= len(sorted_data):
idx = len(sorted_data) - 1
return sorted_data[idx]


def text_report(data):
"""Print text summary to stdout."""
scenarios = sorted(data.keys())
for scenario in scenarios:
print(f"\n {scenario}")
print(
f" {'Size':>6s} {'Min':>8s} {'P50':>8s} {'P90':>8s} "
f"{'P99':>8s} {'P99.9':>8s} {'Max':>8s}"
)
for size in sorted(data[scenario].keys()):
v = sorted(data[scenario][size])
print(
f" {size:>6d} {v[0]:>8d} {percentile(v, 50):>8d} "
f"{percentile(v, 90):>8d} {percentile(v, 99):>8d} "
f"{percentile(v, 99.9):>8d} {v[-1]:>8d}"
)


def detect_unit(data, csv_unit=None):
"""Return the tick unit, preferring the CSV-embedded value."""
if csv_unit:
return csv_unit
# Fallback heuristic for legacy CSV files without a unit column
all_vals = []
for scenario in data.values():
for size_vals in scenario.values():
all_vals.extend(size_vals[:100])
if not all_vals:
return "ticks"
median = sorted(all_vals)[len(all_vals) // 2]
if median > 100000:
return "ns"
if median < 30:
return "ticks"
return "cycles"


def plot_boxplot(data, output_path, csv_unit=None):
"""Box plot: all scenarios side by side for each allocation size."""
scenarios = sorted(data.keys())
sizes = sorted(next(iter(data.values())).keys())
unit = detect_unit(data, csv_unit)

n_sizes = len(sizes)
n_scenarios = len(scenarios)

fig, axes = plt.subplots(1, n_sizes, figsize=(3.5 * n_sizes, 5), sharey=False)
if n_sizes == 1:
axes = [axes]

colors = {
"malloc_worst": "#e74c3c",
"malloc_best": "#3498db",
"free_worst": "#e67e22",
"free_best": "#2ecc71",
}

for ax, size in zip(axes, sizes):
box_data = []
labels = []
box_colors = []
for scenario in scenarios:
vals = data[scenario].get(size, [])
box_data.append(vals)
label = scenario.replace("malloc_", "m/").replace("free_", "f/")
labels.append(label)
box_colors.append(colors.get(scenario, "#95a5a6"))

bp = ax.boxplot(
box_data,
labels=labels,
showfliers=False,
patch_artist=True,
widths=0.6,
medianprops={"color": "black", "linewidth": 1.5},
)
for patch, color in zip(bp["boxes"], box_colors):
patch.set_facecolor(color)
patch.set_alpha(0.7)

ax.set_title(f"{size}B", fontweight="bold")
ax.set_ylabel(f"Latency ({unit})" if ax == axes[0] else "")
ax.grid(True, alpha=0.3, axis="y")
ax.tick_params(axis="x", rotation=30)

fig.suptitle("TLSF WCET: Per-Operation Latency", fontsize=13, y=1.02)
plt.tight_layout()
plt.savefig(output_path, dpi=150, bbox_inches="tight")
print(f" Box plot: {output_path}")


def plot_histogram(data, output_path, csv_unit=None):
"""Latency histograms: one subplot per scenario, all sizes overlaid."""
scenarios = sorted(data.keys())
sizes = sorted(next(iter(data.values())).keys())
unit = detect_unit(data, csv_unit)

n_scenarios = len(scenarios)
fig, axes = plt.subplots(
n_scenarios, 1, figsize=(10, 3 * n_scenarios), sharex=False
)
if n_scenarios == 1:
axes = [axes]

size_colors = plt.cm.viridis(
[i / max(len(sizes) - 1, 1) for i in range(len(sizes))]
)

for ax, scenario in zip(axes, scenarios):
for si, size in enumerate(sizes):
vals = data[scenario].get(size, [])
if not vals:
continue
# Clip outliers beyond p99.5 for cleaner histograms
sv = sorted(vals)
clip = sv[min(int(len(sv) * 0.995), len(sv) - 1)]
clipped = [v for v in vals if v <= clip]
ax.hist(
clipped,
bins=50,
alpha=0.5,
label=f"{size}B",
color=size_colors[si],
density=True,
)

ax.set_title(scenario, fontweight="bold")
ax.set_ylabel("Density")
ax.legend(fontsize=8, loc="upper right")
ax.grid(True, alpha=0.3, axis="y")

axes[-1].set_xlabel(f"Latency ({unit})")
fig.suptitle("TLSF WCET: Latency Distributions", fontsize=13, y=1.01)
plt.tight_layout()
plt.savefig(output_path, dpi=150, bbox_inches="tight")
print(f" Histogram: {output_path}")


def main():
parser = argparse.ArgumentParser(
description="Generate WCET analysis plots from raw sample data."
)
parser.add_argument("input", help="Raw CSV file from 'wcet -r'")
parser.add_argument(
"-o",
"--output",
default="wcet",
help="Output file prefix (default: wcet)",
)
args = parser.parse_args()

data, csv_unit = read_raw_csv(args.input)
if not data:
print("No data found in input file", file=sys.stderr)
return 1

# Always print text summary
print("WCET Summary:")
text_report(data)
print()

if HAS_MATPLOTLIB:
plot_boxplot(data, f"{args.output}_boxplot.png", csv_unit)
plot_histogram(data, f"{args.output}_histogram.png", csv_unit)
else:
print("Note: matplotlib not available, skipping plot generation.")
print("Install with: pip install matplotlib")

return 0


if __name__ == "__main__":
sys.exit(main())
Loading