Files
abr/graphs.c
T
retoorandClaude Opus 5 6c830181c8
Build and Test / build-c (push) Successful in 45s
Build and Test / build-python (push) Successful in 39s
Build and Test / valgrind (push) Successful in 42s
feat: rebuild the terminal output around a single column-chart renderer
The visualization was six unrelated widgets - a histogram, a timeline, an
error-rate bar, percentile bars, a fill gauge, plus a dashboard, heatmap and
"real-time monitor" charting simulated CPU/memory/disk data. Colour was
decorative and inconsistent, and the result did not read as a benchmark tool.

Replace all of it with one drawing primitive: a vertical column chart with a
zero-based labelled y-axis, a baseline and a labelled x-axis. Four charts share
that shape - throughput over time (failures stacked in red), response time over
time, response time distribution, and response time percentiles. Colour now
carries meaning only: cyan is data, amber is the tail (p95+), red is failure.
Charts size themselves to the terminal width.

The summary keeps every number it had, laid out as aligned key/value blocks
under section rules, with the ApacheBench percentile table as a 3-column grid.

Fixes found along the way:

- "Latency over the run" plotted the sorted durations, so it was always a
  monotonic ramp rather than a timeline. Requests now record a start offset
  relative to the run, so the time-axis charts show what actually happened.
- The throughput gauge was scaled against a hardcoded 1000.0 req/s, so it read
  a near-zero percentage regardless of the target.
- The live progress line recomputed statistics over every completed request on
  every completion, an O(n^2) cost inside the benchmark loop. It is now
  throttled to ~20 Hz and skipped entirely when stdout is not a terminal.

Colour and the progress bar are gated on isatty(), so redirected output is
plain, greppable text with no escape codes.

A heavy tail (p50 57 ms, max 1525 ms) previously collapsed the distribution
into a single column. The chart now clips its axis at p95 when the maximum
dwarfs it and states so beneath the chart; well-behaved runs are never clipped
and the full range stays in the footer either way.

Also drop 77 lines of dead simulation code from main.c, wire graphs.c into the
Makefile, untrack the built binary, and add a .gitignore.

Builds warning-free at -Wall -Wextra; valgrind clean on the loaded,
single-request and all-failed paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GCMSMcgcwnH9a8hhaLsg6j
2026-09-08 06:43:57 +00:00

409 lines
14 KiB
C

// retoor <retoor@molodetz.nl>
#include "graphs.h"
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <unistd.h>
/* Layout ------------------------------------------------------------------ */
#define AXIS_W 7 /* five columns of y-label, a space, the axis glyph */
#define LABEL_MAX 12
#define MAX_BARS 48
#define PLOT_H 10 /* labelled every other row, so gridlines land round */
/* Colour language: one accent for data, amber for the tail, red for failure.
Nothing else is coloured. */
#define C_RESET "\033[0m"
#define C_BOLD "\033[1m"
#define C_DIM "\033[2m"
#define C_ACCENT "\033[36m"
#define C_TAIL "\033[33m"
#define C_FAIL "\033[31m"
static int color_enabled = 1;
void graph_set_color(int enabled) { color_enabled = enabled ? 1 : 0; }
static const char *sgr(const char *code) { return color_enabled ? code : ""; }
int graph_width(void) {
struct winsize ws;
int w = 78;
if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 && ws.ws_col > 0) {
w = ws.ws_col;
}
if (w > 96) w = 96;
if (w < 56) w = 56;
return w;
}
/* How many bars fit while keeping each one at least min_cell columns wide. */
static int bar_capacity(int min_cell) {
int n = (graph_width() - AXIS_W) / min_cell;
if (n > MAX_BARS) n = MAX_BARS;
if (n < 1) n = 1;
return n;
}
/* Numbers ----------------------------------------------------------------- */
/* Round up to the next 1/2/2.5/5/10 x 10^n so the y-axis reads cleanly. */
static double nice_ceil(double v) {
if (!(v > 0.0)) return 1.0;
double exp10 = pow(10.0, floor(log10(v)));
double m = v / exp10;
double nice = (m <= 1.0) ? 1.0 : (m <= 2.0) ? 2.0 : (m <= 2.5) ? 2.5
: (m <= 5.0) ? 5.0 : 10.0;
return nice * exp10;
}
/* Seconds on an x-axis: just enough precision for the length of the run. */
static void fmt_seconds(char *buf, size_t size, double v, double total) {
if (total < 2.0) snprintf(buf, size, "%.2f", v);
else if (total < 20.0) snprintf(buf, size, "%.1f", v);
else snprintf(buf, size, "%.0f", v);
}
static void fmt_num(char *buf, size_t size, double v) {
double a = fabs(v);
if (a >= 1000.0) snprintf(buf, size, "%.0f", v);
else if (a >= 100.0) snprintf(buf, size, "%.0f", v);
else if (a >= 10.0) snprintf(buf, size, "%.1f", v);
else if (a >= 1.0) snprintf(buf, size, "%.2f", v);
else snprintf(buf, size, "%.3f", v);
}
/* Printed width of a string: skips ANSI escapes and UTF-8 continuation bytes. */
static int visible_len(const char *str) {
int n = 0;
for (const unsigned char *p = (const unsigned char *)str; *p; p++) {
if (*p == 0x1b) {
while (*p && *p != 'm') p++;
if (!*p) break;
} else if ((*p & 0xC0) != 0x80) {
n++;
}
}
return n;
}
/* Axis labels: only as many decimals as the gridline step actually needs. */
static void fmt_axis(char *buf, size_t size, double v, double step) {
int decimals = 0;
if (step < 1.0) {
decimals = (step * 10.0 == floor(step * 10.0)) ? 1 : 2;
} else if (step < 10.0 && step != floor(step)) {
decimals = 1;
}
snprintf(buf, size, "%.*f", decimals, v);
}
static int cmp_double(const void *a, const void *b) {
double x = *(const double *)a, y = *(const double *)b;
return (x < y) ? -1 : (x > y) ? 1 : 0;
}
/* Renderer ---------------------------------------------------------------- */
void graph_section(const char *title) {
int width = graph_width();
int len = (int)strlen(title);
printf("\n%s%s%s ", sgr(C_BOLD), title, sgr(C_RESET));
printf("%s", sgr(C_DIM));
for (int i = len + 1; i < width; i++) printf("─");
printf("%s\n", sgr(C_RESET));
}
/*
* The single drawing primitive. `base` is the main series; `top` (optional)
* stacks on it and is drawn in red; `emph` (optional) flags columns to draw in
* the tail colour. Bars use half blocks so a column reads to half a row.
*/
static void render_bars(const char *title, const char *unit,
const double *base, const double *top,
const unsigned char *emph,
int count, const char labels[][LABEL_MAX],
const char *footer) {
if (count <= 0) return;
int width = graph_width();
int plot_w = width - AXIS_W;
int cell = plot_w / count;
if (cell < 1) cell = 1;
int bar_w = (cell > 1) ? cell - 1 : 1;
plot_w = cell * count;
double ymax = 0.0;
for (int i = 0; i < count; i++) {
double v = base[i] + (top ? top[i] : 0.0);
if (v > ymax) ymax = v;
}
ymax = nice_ceil(ymax);
/* Title left, unit right, on one line. */
int tlen = visible_len(title);
int ulen = unit ? visible_len(unit) : 0;
int pad = width - tlen - ulen;
if (pad < 1) pad = 1;
printf("\n%s%s%s%*s%s%s%s\n", sgr(C_BOLD), title, sgr(C_RESET),
pad, "", sgr(C_DIM), unit ? unit : "", sgr(C_RESET));
for (int row = PLOT_H - 1; row >= 0; row--) {
char ylab[LABEL_MAX] = "";
if ((PLOT_H - 1 - row) % 2 == 0) {
fmt_axis(ylab, sizeof ylab, ymax * (row + 1) / PLOT_H, ymax * 2.0 / PLOT_H);
}
printf("%s%5s ┤%s", sgr(C_DIM), ylab, sgr(C_RESET));
/* Stop at the last column that has ink, so rows carry no trailing blanks. */
int last = -1;
for (int i = 0; i < count; i++) {
if ((base[i] + (top ? top[i] : 0.0)) / ymax * PLOT_H - row >= 0.25) last = i;
}
for (int i = 0; i <= last; i++) {
double filled = (base[i] + (top ? top[i] : 0.0)) / ymax * PLOT_H - row;
double solid = base[i] / ymax * PLOT_H - row;
const char *glyph = (filled >= 0.75) ? "█"
: (filled >= 0.25) ? "▄" : NULL;
if (glyph) {
const char *color = (solid > 0.0)
? ((emph && emph[i]) ? sgr(C_TAIL) : sgr(C_ACCENT))
: sgr(C_FAIL);
printf("%s", color);
for (int c = 0; c < bar_w; c++) printf("%s", glyph);
printf("%s", sgr(C_RESET));
} else {
for (int c = 0; c < bar_w; c++) putchar(' ');
}
if (i < last) {
for (int c = bar_w; c < cell; c++) putchar(' ');
}
}
putchar('\n');
}
printf("%s%5s └", sgr(C_DIM), "0");
for (int i = 0; i < plot_w; i++) printf("─");
printf("%s\n", sgr(C_RESET));
/* X labels, centred under their bar and dropped where they would collide. */
if (labels) {
char line[256];
int n = (plot_w < (int)sizeof(line) - 1) ? plot_w : (int)sizeof(line) - 1;
memset(line, ' ', (size_t)n);
line[n] = '\0';
int last_end = -1;
for (int i = 0; i < count; i++) {
int len = (int)strlen(labels[i]);
if (len == 0 || len > n) continue;
int start = i * cell + bar_w / 2 - len / 2;
if (start < 0) start = 0;
if (start + len > n) start = n - len;
if (start <= last_end) continue;
memcpy(line + start, labels[i], (size_t)len);
last_end = start + len;
}
while (n > 0 && line[n - 1] == ' ') line[--n] = '\0';
printf("%s%*s%s%s\n", sgr(C_DIM), AXIS_W, "", line, sgr(C_RESET));
}
/* The footer may hold several lines; each is indented under the plot. */
for (const char *p = footer; p && *p; ) {
const char *nl = strchr(p, '\n');
int len = nl ? (int)(nl - p) : (int)strlen(p);
printf("%s%*s%.*s%s\n", sgr(C_DIM), AXIS_W, "", len, p, sgr(C_RESET));
p = nl ? nl + 1 : p + len;
}
}
/* Charts ------------------------------------------------------------------ */
void graph_latency_histogram(const double *durations_ms, int count) {
if (count <= 0) return;
int buckets = bar_capacity(5);
if (buckets > 14) buckets = 14;
double *sorted = malloc(sizeof(double) * (size_t)count);
if (!sorted) return;
memcpy(sorted, durations_ms, sizeof(double) * (size_t)count);
qsort(sorted, (size_t)count, sizeof(double), cmp_double);
double lo = sorted[0];
double hi = sorted[count - 1];
double sum = 0.0;
for (int i = 0; i < count; i++) sum += durations_ms[i];
double mean = sum / count;
double var = 0.0;
for (int i = 0; i < count; i++) var += (durations_ms[i] - mean) * (durations_ms[i] - mean);
double sd = (count > 1) ? sqrt(var / (count - 1)) : 0.0;
/* A long tail would squash every column into the first bucket. When the
slowest request dwarfs p95, clip the axis there and say so in the
footer rather than quietly dropping the outliers. */
int p95_idx = (int)(count * 0.95) - 1;
if (p95_idx < 0) p95_idx = 0;
double p95 = sorted[p95_idx];
double axis_hi = hi;
int clipped = 0;
if (hi > p95 * 3.0 && p95 > lo) {
axis_hi = p95;
for (int i = count - 1; i >= 0 && sorted[i] > axis_hi; i--) clipped++;
}
free(sorted);
/* A single distinct value has no distribution; show it as one column. */
double span = axis_hi - lo;
if (span <= 0.0) {
span = 1.0;
buckets = 1;
}
double values[MAX_BARS] = {0};
char labels[MAX_BARS][LABEL_MAX];
for (int i = 0; i < count; i++) {
if (durations_ms[i] > axis_hi) continue;
int b = (int)((durations_ms[i] - lo) / span * buckets);
if (b < 0) b = 0;
if (b >= buckets) b = buckets - 1;
values[b] += 1.0;
}
for (int b = 0; b < buckets; b++) {
fmt_num(labels[b], LABEL_MAX, lo + span * b / buckets);
}
char footer[160];
int written = snprintf(footer, sizeof footer,
"%d samples mean %.1f ms sd %.1f ms range %.0f-%.0f ms",
count, mean, sd, lo, hi);
if (clipped > 0 && written > 0 && written < (int)sizeof footer) {
snprintf(footer + written, sizeof footer - (size_t)written,
"\naxis clipped at p95 %.0f ms; %d slower requests are off the chart",
axis_hi, clipped);
}
render_bars("Response time distribution", "y: requests x: ms",
values, NULL, NULL, buckets, labels, footer);
}
void graph_throughput(const double *finish_ms, const int *failed, int count,
double total_seconds) {
if (count <= 0 || total_seconds <= 0.0) return;
int buckets = bar_capacity(3);
if (buckets > 30) buckets = 30;
double slice = total_seconds / buckets;
if (slice <= 0.0) return;
double ok[MAX_BARS] = {0}, bad[MAX_BARS] = {0};
char labels[MAX_BARS][LABEL_MAX];
int failures = 0;
for (int i = 0; i < count; i++) {
int b = (int)(finish_ms[i] / 1000.0 / slice);
if (b < 0) b = 0;
if (b >= buckets) b = buckets - 1;
if (failed[i]) { bad[b] += 1.0; failures++; }
else { ok[b] += 1.0; }
}
for (int b = 0; b < buckets; b++) {
ok[b] /= slice;
bad[b] /= slice;
fmt_seconds(labels[b], LABEL_MAX, b * slice, total_seconds);
}
char footer[128];
snprintf(footer, sizeof footer,
"%.1f req/s mean %d ok %d failed (%.2f%%)",
count / total_seconds, count - failures, failures,
(double)failures / count * 100.0);
char unit[128];
snprintf(unit, sizeof unit, "y: req/s x: seconds %s█%s ok %s█%s failed",
sgr(C_ACCENT), sgr(C_DIM), sgr(C_FAIL), sgr(C_DIM));
render_bars("Throughput over time", unit, ok, bad, NULL, buckets, labels, footer);
}
void graph_latency_over_time(const double *finish_ms, const double *durations_ms,
const int *failed, int count, double total_seconds) {
if (count <= 0 || total_seconds <= 0.0) return;
int buckets = bar_capacity(3);
if (buckets > 30) buckets = 30;
double slice = total_seconds / buckets;
if (slice <= 0.0) return;
double sum[MAX_BARS] = {0}, values[MAX_BARS] = {0};
int n[MAX_BARS] = {0};
char labels[MAX_BARS][LABEL_MAX];
for (int i = 0; i < count; i++) {
if (failed[i]) continue;
int b = (int)(finish_ms[i] / 1000.0 / slice);
if (b < 0) b = 0;
if (b >= buckets) b = buckets - 1;
sum[b] += durations_ms[i];
n[b]++;
}
double peak = 0.0;
for (int b = 0; b < buckets; b++) {
values[b] = n[b] ? sum[b] / n[b] : 0.0;
if (values[b] > peak) peak = values[b];
fmt_seconds(labels[b], LABEL_MAX, b * slice, total_seconds);
}
char footer[128];
snprintf(footer, sizeof footer, "mean per %.2f s slice peak %.0f ms", slice, peak);
render_bars("Response time over time", "y: ms x: seconds", values, NULL, NULL,
buckets, labels, footer);
}
void graph_percentiles(const double *durations_ms, int count) {
if (count <= 0) return;
static const int points[] = {50, 66, 75, 80, 90, 95, 98, 99, 100};
const int n_points = (int)(sizeof points / sizeof points[0]);
double *sorted = malloc(sizeof(double) * (size_t)count);
if (!sorted) return;
memcpy(sorted, durations_ms, sizeof(double) * (size_t)count);
qsort(sorted, (size_t)count, sizeof(double), cmp_double);
double values[MAX_BARS];
unsigned char emph[MAX_BARS];
char labels[MAX_BARS][LABEL_MAX];
for (int i = 0; i < n_points; i++) {
int idx = (int)(count * points[i] / 100.0) - 1;
if (idx < 0) idx = 0;
if (idx >= count) idx = count - 1;
values[i] = sorted[idx];
emph[i] = (points[i] >= 95);
snprintf(labels[i], LABEL_MAX, "p%d", points[i]);
}
char footer[128];
snprintf(footer, sizeof footer,
"median %.0f ms p95 %.0f ms p99 %.0f ms max %.0f ms",
values[0], values[5], values[7], values[8]);
char unit[128];
snprintf(unit, sizeof unit, "y: ms %s█%s tail (p95+)", sgr(C_TAIL), sgr(C_DIM));
render_bars("Response time percentiles", unit, values, NULL, emph, n_points,
labels, footer);
free(sorted);
}