--- /dev/null
+#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;
+}
--- /dev/null
+#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