Counter System
Overview
Chronon provides per-unit counters via the EventCounter class. Each unit instance declares its own counters as members, and they are automatically registered with the observation system when the unit's context is attached.
EventCounter (Per-Unit)
Declare counters as unit members for automatic per-instance tracking:
class ALUUnit : public TickableUnit, public ObservableUnit {
// Per-instance counters - each ALU has its own
EventCounter ops_{this, "ops", "Operations executed", "ops"};
EventCounter stalls_{this, "stalls", "Stall cycles", "cycles"};
public:
void tick() override {
++ops_; // ~2-3ns
stalls_ += 5; // Add 5
}
};
Output (CSV)
Pivoted format (default, csv_format: pivoted):
Rows are dump cycles, columns are counters — compact and written incrementally:
cycle,alu0.ops,alu1.ops,alu2.ops
10000,2500,2400,2550
20000,5100,4900,5050
30000,7650,7300,7600
Each periodic dump appends one row, so the file can be monitored during simulation (like log files). Column names are discovered from the first dump and written as the CSV header.
Both schedulers use one owner-based push implementation. Sequential execution publishes at the exact boundary. Epoch-free workers snapshot and reset the counters owned by their scheduler clusters when local progress crosses the boundary, then publish records through their lock-free SPSC observation queue. Rows use the nominal periodic cycle; epoch-free contributors may be sampled within one configured run-ahead window. Periodic output never introduces a run split, intermediate MPSC port flush, or worker relaunch.
Chronon supports up to 64 observation producer threads in one process. If that pool is exhausted, periodic counter execution fails explicitly instead of silently omitting owner rows.
Counter names and output-column mappings are registered once during simulation
initialization. At a dump boundary, a worker normally sends one metadata-indexed
value batch per scheduler owner (bounded chunks for very large plans) and resets
those counters in the same pass. Repeated unit/counter strings never enter the
worker queue. CSV formatting and Perfetto serialization remain asynchronous in
the observation backend, so periodic
output adds no branch, atomic operation, or buffer switch to add()/++.
Periodic Counter Profiling
The standalone benchmark isolates the update path, snapshot scheduling and queueing, and the CSV/Perfetto sinks:
cmake -S . -B build-release -DCMAKE_BUILD_TYPE=Release \
-DCHRONON_BUILD_BENCHMARKS=ON
cmake --build build-release --target chronon_counter_periodic_benchmark -j
# One configuration
./build-release/benchmark/chronon_counter_periodic_benchmark \
--mode snapshot-only --counters 256 --threads 8 \
--period 1000 --cycles 1000000
# Controlled sweep; emits CSV
./scripts/profile_periodic_counters.py \
--counters 16,64,256 --threads 1,4,8 --periods 100,1000,10000
Modes are updates-off (theoretical upper bound), updates-only,
snapshot-only, csv, perfetto, and full. Reported fields include
simulation throughput, snapshot CPU time, queue bytes/drops, and output
bandwidth.
Long format (csv_format: long):
One row per (cycle, unit, counter, value) — traditional streaming format:
cycle,unit_name,counter_name,value
10000,alu0,ops,2500
10000,alu1,ops,2400
10000,alu2,ops,2550
Delta Semantics
Counter values are per-interval deltas — each row shows only the count for that dump interval. Counters reset to zero at the source after each periodic dump snapshot.
cycle,alu0.ops,alu1.ops
10000,2500,2400
20000,2600,2500
30000,2550,2400
This makes interval-local rate metrics (hit rate, IPC, stall rate) directly computable from a single row without post-processing.
Counter API
class EventCounter {
public:
EventCounter(ObservableUnit* owner,
std::string_view name,
std::string_view description = "",
std::string_view unit = "");
EventCounter& operator++() noexcept;
EventCounter& operator+=(uint64_t delta) noexcept;
void add(uint64_t delta = 1) noexcept;
template <FixedString Name, typename Cat, typename... Items>
void mark(Cat category, Items&&... items);
uint64_t get() const noexcept;
void reset() noexcept;
};
Hierarchical Naming
Counter paths include the unit's full tree hierarchy:
# With unit.setTreeNode(node)
cpu0.alu0.ops
cpu0.alu1.ops
cpu1.alu0.ops
Note: Hierarchical naming depends on the unit being attached to a TreeNode hierarchy via setTreeNode(). Without this, counter names use the unit's local name only.
Storage Architecture
SimpleCounter
Optimized for Chronon's single-threaded-per-unit model:
struct SimpleCounter {
uint64_t value = 0;
uint64_t epoch_base = 0; // For rollback
void increment(uint64_t delta = 1) noexcept { value += delta; }
uint64_t get() const noexcept { return value; }
void commitEpoch() noexcept; // epoch_base = value
void rollbackEpoch() noexcept; // value = epoch_base
};
FixedCounterStorage
Dynamic array that grows as counters are added:
class FixedCounterStorage {
public:
// Constructor starts empty; grows via addCounter()
explicit FixedCounterStorage(std::string name);
CounterId addCounter(const std::string& name,
const std::string& description = "",
const std::string& unit = "");
SimpleCounter& getUnchecked(CounterId id) noexcept; // ~1-2ns
void commitAllEpochs();
void rollbackAllEpochs();
void resetAll();
};
Sizing Behavior:
- Starts empty, grows as
Countermembers calladdCounter()during context attachment - Enables O(1)
getUnchecked()access without bounds checking
Memory Comparison
Memory usage scales with the number of counters per unit:
| Architecture | Per-Unit (17 counters) | 9 Units (17 counters each) |
|---|---|---|
| Old (Dense) | 64 KB | 576 KB |
| New (Sparse) | 272 bytes | 2.4 KB |
| Savings | 99.6% | 99.6% |
Formula: Per-unit memory = N counters × 16 bytes
Registration and Snapshot Paths
Counters register with ObservationManager at initialization:
1. Unit Construction
└─► Counter members created (pending)
2. Context Attachment
└─► Counter::onContextAttached(ctx)
└─► ctx.counters().addCounter(name, desc, unit)
3. Periodic Counter Snapshots
└─► scheduler owner snapshots its cluster counters
└─► worker SPSC queue ─► observation backend
4. Final Counter Snapshot
└─► manager.dumpFinalCounterSnapshot(final_cycle)
└─► Read remaining values from registered addresses
Epoch Operations
For lookahead/speculative execution:
SimpleCounter counter;
counter.increment(100);
counter.commitEpoch(); // epoch_base = 100
// Speculative execution
counter.increment(50); // value = 150
// Rollback on misprediction
counter.rollbackEpoch(); // value = 100
// Or commit on success
counter.commitEpoch(); // epoch_base = 150
Derived Counters
Derived counters compute values from raw counters at CSV dump time. They add zero overhead to the simulation hot path — all computation happens in the ObservationBackend thread.
Declaration
class Fetch : public TickableUnit, public ObservableUnit {
EventCounter hits_{this, "hits", "Cache hits"};
EventCounter misses_{this, "misses", "Cache misses"};
DerivedCounter hit_rate_{this, "hit_rate", "Cache hit rate",
{hits_, misses_}, DerivedFormula::Ratio};
};
The source names are captured when DerivedCounter is constructed. Incrementing
an EventCounter does not perform any derived-counter work; formulas still run
only on the observation backend thread when a CSV row is emitted.
Available Convenience Formulas
| Formula | Computation | Use Case |
|---|---|---|
DerivedFormula::Ratio | a / (a + b) | Hit rates, miss rates |
DerivedFormula::Divide | a / b | IPC, throughput |
DerivedFormula::PerKilo | a * 1000 / b | MPKI metrics |
Custom lambdas receive a std::span<const uint64_t> with the per-interval delta values of each source counter (in declaration order) and return a double.
CSV Output
Pivoted format:
cycle,fetch.hits,fetch.misses,fetch.hit_rate,fetch.ipc
10000,850,150,0.850000,1.234567
20000,900,100,0.900000,1.345678
Long format:
cycle,unit,counter_name,value
10000,fetch,hits,850
10000,fetch,misses,150
10000,fetch,hit_rate,0.850000
Edge Cases
| Case | Behavior |
|---|---|
| Division by zero (all sources = 0) | Depends on lambda; convenience formulas return 0.0 |
| Source counter not in first dump batch | Derived counter skipped with stderr warning |
| Any number of source counters | Supported (not limited to 2) |
Backend Semantics
Derived values are computed from per-interval deltas (same as raw counters). In pivoted mode, the derived columns appear after all raw counter columns. The computation function is called once per CSV row flush.
Complete Example
#include "chronon/Chronon.hpp"
using namespace chronon;
class ALUUnit : public TickableUnit, public ObservableUnit {
EventCounter ops_{this, "ops", "Operations", "ops"};
uint32_t id_;
public:
ALUUnit(uint32_t id)
: TickableUnit("alu" + std::to_string(id)), id_(id) {}
void tick() override {
++ops_; // Per-instance
}
uint64_t getOps() const { return ops_.get(); }
};
int main() {
TickSimulation sim;
auto* alu0 = sim.createUnit<ALUUnit>(0);
auto* alu1 = sim.createUnit<ALUUnit>(1);
// ... setup observation contexts ...
sim.run(1000000);
std::cout << "ALU0 ops: " << alu0->getOps() << "\n";
std::cout << "ALU1 ops: " << alu1->getOps() << "\n";
}