]> git.hungrycats.org Git - bees/commitdiff
heatmap: a generic power-of-two 2D event collector
authorZygo Blaxell <bees@furryterror.org>
Sun, 14 Jun 2026 03:54:11 +0000 (23:54 -0400)
committerZygo Blaxell <bees@furryterror.org>
Sun, 30 Aug 2026 23:48:46 +0000 (19:48 -0400)
A small self-contained collector for counting events bucketed by an integer
magnitude (e.g. a region or boundary count) against an elapsed time, both on
floor(log2) axes.  add() is lock-free; print() renders the populated sub-grid
with per-row and per-column totals, so the time-axis marginal is a plain
power-of-two-seconds histogram and the magnitude marginal is its dual.

It carries no bees/crucible dependencies so one collector can back several
unrelated metrics — e.g. an algorithm-specific boundary count and an
algorithm-agnostic input-region count against the same elapsed time.

Assisted-by: Claude-Code:claude-opus-4-8
Signed-off-by: Zygo Blaxell <bees@furryterror.org>
src/Makefile
src/bees-heatmap.cc [new file with mode: 0644]
src/bees-heatmap.h [new file with mode: 0644]

index c656cdd620bf07c7881ab075d398ebfc43c3b4a4..e82ef08e241e0aac541f3e1ae382b5f6542c3378 100644 (file)
@@ -15,6 +15,7 @@ BEES_OBJS = \
        bees-context.o \
        bees-fdcache.o \
        bees-hash.o \
+       bees-heatmap.o \
        bees-lib.o \
        bees-log.o \
        bees-roots.o \
diff --git a/src/bees-heatmap.cc b/src/bees-heatmap.cc
new file mode 100644 (file)
index 0000000..98cc4f2
--- /dev/null
@@ -0,0 +1,88 @@
+#include "bees-heatmap.h"
+
+#include "crucible/table.h"
+
+#include <algorithm>
+#include <cstdint>
+#include <vector>
+
+using namespace crucible;
+using namespace std;
+
+ostream &
+BeesHeatmap::print(ostream &os) const
+{
+       // Snapshot the populated bounds.
+       int x_lo = X_BUCKETS, x_hi = -1, t_lo = T_BUCKETS, t_hi = -1;
+       uint64_t cell[X_BUCKETS][T_BUCKETS];
+       for (int xi = 0; xi < X_BUCKETS; ++xi) {
+               for (int ti = 0; ti < T_BUCKETS; ++ti) {
+                       const uint64_t c =
+                               m_cells[xi][ti].load(memory_order_relaxed);
+                       cell[xi][ti] = c;
+                       if (c) {
+                               x_lo = min(x_lo, xi);
+                               x_hi = max(x_hi, xi);
+                               t_lo = min(t_lo, ti);
+                               t_hi = max(t_hi, ti);
+                       }
+               }
+       }
+       os << "heatmap " << m_name;
+       if (x_hi < 0) {
+               os << ": (empty)\n";
+               return os;
+       }
+       os << " (rows: magnitude >= N, cols: time >= T, cells: count)\n";
+
+       // Lay the populated sub-grid out as a Table; it sizes each column to
+       // its widest cell on its own, so the row/column labels and counts line
+       // up without any manual setw() bookkeeping.
+       Table::Table table;
+
+       // Column (time) header: "N/time", one per time bucket, then the
+       // per-row total column.
+       vector<Table::Content> header;
+       header.push_back(Table::Text("N/time"));
+       for (int ti = t_lo; ti <= t_hi; ++ti) {
+               header.push_back(Table::Text(t_label(ti)));
+       }
+       header.push_back(Table::Text("row_total"));
+       table.insert_row(Table::endpos, header);
+
+       // Magnitude rows with per-row totals.  Empty cells render as ".".
+       for (int xi = x_lo; xi <= x_hi; ++xi) {
+               vector<Table::Content> row;
+               row.push_back(Table::Text(x_label(xi)));
+               uint64_t row_total = 0;
+               for (int ti = t_lo; ti <= t_hi; ++ti) {
+                       const uint64_t c = cell[xi][ti];
+                       row_total += c;
+                       row.push_back(c ? Table::Number(c) : Table::Text("."));
+               }
+               row.push_back(Table::Number(row_total));
+               table.insert_row(Table::endpos, row);
+       }
+
+       // Column (time) totals plus the grand total in the corner.
+       vector<Table::Content> footer;
+       footer.push_back(Table::Text("col_total"));
+       uint64_t grand = 0;
+       for (int ti = t_lo; ti <= t_hi; ++ti) {
+               uint64_t col_total = 0;
+               for (int xi = x_lo; xi <= x_hi; ++xi) {
+                       col_total += cell[xi][ti];
+               }
+               grand += col_total;
+               footer.push_back(Table::Number(col_total));
+       }
+       footer.push_back(Table::Number(grand));
+       table.insert_row(Table::endpos, footer);
+
+       // Borderless, space-separated columns to match the previous layout.
+       table.left("");
+       table.mid(" ");
+       table.right("");
+       os << table;
+       return os;
+}
diff --git a/src/bees-heatmap.h b/src/bees-heatmap.h
new file mode 100644 (file)
index 0000000..c6a9bce
--- /dev/null
@@ -0,0 +1,154 @@
+#ifndef BEES_HEATMAP_H
+#define BEES_HEATMAP_H
+
+#include <atomic>
+#include <cmath>
+#include <cstdint>
+#include <cstdio>
+#include <ostream>
+#include <string>
+#include <utility>
+
+/// Generic two-dimensional power-of-two heatmap.
+///
+/// Counts events bucketed by an integer magnitude @p x (e.g. a region or
+/// boundary count) on one axis and an elapsed time in seconds on the other.
+/// Both axes use floor(log2) buckets: magnitude bucket k>=1 covers
+/// [2^(k-1), 2^k) and bucket 0 is exactly zero; time bucket for exponent e
+/// covers [2^e, 2^(e+1)) seconds.  Out-of-range values clamp to the edge
+/// buckets.
+///
+/// add() is lock-free and safe to call from any worker thread; counts
+/// accumulate for the life of the run.  print() renders the populated
+/// sub-grid with per-row (magnitude) and per-column (time) totals, so the
+/// marginal of either axis is a plain one-dimensional histogram.
+///
+/// The collector itself depends on nothing but the standard library, so the
+/// same instance can back several unrelated metrics — e.g. one keyed on an
+/// algorithm-specific boundary count and another on an algorithm-agnostic
+/// input-region count, both against the same elapsed time.  Only print()
+/// reaches into libcrucible (Table), and only for layout.
+class BeesHeatmap {
+public:
+       explicit BeesHeatmap(std::string name) :
+               m_name(std::move(name)) {}
+
+       /// Record one event of magnitude @p x that took @p seconds.
+       void add(uint64_t x, double seconds)
+       {
+               m_cells[x_bucket(x)][t_bucket(seconds)]
+                       .fetch_add(1, std::memory_order_relaxed);
+       }
+
+       /// Zero all buckets.  Safe to call concurrently with add()/print();
+       /// individual cells reset atomically, so a concurrent add() is either
+       /// counted or not, never corrupted.
+       void reset()
+       {
+               for (int xi = 0; xi < X_BUCKETS; ++xi) {
+                       for (int ti = 0; ti < T_BUCKETS; ++ti) {
+                               m_cells[xi][ti].store(0, std::memory_order_relaxed);
+                       }
+               }
+       }
+
+       /// Total number of events recorded.
+       uint64_t count() const
+       {
+               uint64_t total = 0;
+               for (int xi = 0; xi < X_BUCKETS; ++xi) {
+                       for (int ti = 0; ti < T_BUCKETS; ++ti) {
+                               total += m_cells[xi][ti].load(std::memory_order_relaxed);
+                       }
+               }
+               return total;
+       }
+
+       const std::string &name() const { return m_name; }
+
+       /// Render the populated sub-grid (magnitude rows x time columns) with
+       /// row and column totals.  An empty heatmap prints a single line.
+       std::ostream &print(std::ostream &os) const;
+
+private:
+       // Magnitude exponents 2^0 .. 2^X_EXP_MAX, plus a dedicated zero bucket.
+       // 2^32 is far more than we expect to count; the spare rows are a few KB
+       // and print() hides the unpopulated ones anyway.
+       static constexpr int X_EXP_MAX = 32;            // up to ~4 billion
+       static constexpr int X_BUCKETS = X_EXP_MAX + 2; // [0] = zero, [1..] = 2^(k-1)
+       // Time exponents 2^T_EXP_MIN .. 2^T_EXP_MAX seconds (~1ms .. ~12 days).
+       // The top end covers the longest Task lifetimes (which include queueing,
+       // restarts, and loadavg throttling, so a 20-hour wall time is normal);
+       // the bottom bucket absorbs every sub-millisecond plan.
+       static constexpr int T_EXP_MIN = -10;
+       static constexpr int T_EXP_MAX = 20;
+       static constexpr int T_BUCKETS = T_EXP_MAX - T_EXP_MIN + 1;
+
+       static int x_bucket(uint64_t x)
+       {
+               if (x == 0) {
+                       return 0;
+               }
+               int e = static_cast<int>(
+                       std::floor(std::log2(static_cast<double>(x))));
+               if (e < 0) {
+                       e = 0;
+               }
+               if (e > X_EXP_MAX) {
+                       e = X_EXP_MAX;
+               }
+               return e + 1;
+       }
+
+       static int t_bucket(double seconds)
+       {
+               if (!(seconds > 0.0)) {
+                       return 0;
+               }
+               int e = static_cast<int>(std::floor(std::log2(seconds)));
+               if (e < T_EXP_MIN) {
+                       e = T_EXP_MIN;
+               }
+               if (e > T_EXP_MAX) {
+                       e = T_EXP_MAX;
+               }
+               return e - T_EXP_MIN;
+       }
+
+       // Lower-bound label for magnitude bucket @p xi ("0", "1", "2", "4", ...).
+       static std::string x_label(int xi)
+       {
+               if (xi == 0) {
+                       return "0";
+               }
+               return std::to_string(uint64_t(1) << (xi - 1));
+       }
+
+       // Compact lower-bound label for time bucket @p ti (e.g. "4us", "2ms",
+       // "8s", "1h").
+       static std::string t_label(int ti)
+       {
+               const int e = ti + T_EXP_MIN;
+               const double secs = std::exp2(static_cast<double>(e));
+               char buf[32];
+               if (secs < 1e-3) {
+                       std::snprintf(buf, sizeof(buf), "%.0fus", secs * 1e6);
+               } else if (secs < 1.0) {
+                       std::snprintf(buf, sizeof(buf), "%.0fms", secs * 1e3);
+               } else if (secs < 60.0) {
+                       std::snprintf(buf, sizeof(buf), "%.0fs", secs);
+               } else if (secs < 3600.0) {
+                       std::snprintf(buf, sizeof(buf), "%.0fm", secs / 60.0);
+               } else if (secs < 86400.0) {
+                       std::snprintf(buf, sizeof(buf), "%.0fh", secs / 3600.0);
+               } else {
+                       std::snprintf(buf, sizeof(buf), "%.0fd", secs / 86400.0);
+               }
+               return buf;
+       }
+
+       std::string m_name;
+       std::atomic<uint64_t> m_cells[X_BUCKETS][T_BUCKETS] {};
+};
+
+#endif // BEES_HEATMAP_H