Files
scene-actor-extraction/src/benchmark.hpp
T
dtourolle 88c42573a5 fix(pipeline): finish three changes that had only been half applied
Each of these was recorded as done and was done in one place out of two.

AR-011 -- the TransNetV2 dedup window. The derived window
(dedup_window_sec, median observed interval halved) reached scenes.json
and nothing else. SceneBoundaries, the path that actually feeds
is_scene_boundary to the tracker, kept the literal 0.04 s under a
comment claiming it "matches the dedup scenes.json applies, so the two
views agree". They did not agree. 0.04 is one frame at 25 fps and wider
than a frame at 30, so two cuts on consecutive frames merged into one
and the loss was invisible: the pipeline simply saw fewer boundaries.
The detector now supplies the window it derived.

AR-019 -- ownership. The register says ownership "comes from the
registry, not a second local tally". Both existed: promotion fired on a
local accepted-frame count and fell back to a local per-actor plurality
when the registry had not yet claimed the track. That fallback was
reachable in the live pipeline, not just in tests -- three accepted
frames arrive well before a posterior crosses the ownership threshold --
so in practice the plurality usually decided, and it could not see the
AR-025 correlation discounting it was meant to defer to. The tally is
gone; promotion now requires the registry's verdict, with the accepted
-frame count demoted to an explicit evidence floor.

AR-017 -- the route. DeadTrack carried belief but no route, and the sink
wrote the literal string "live", so a field the schema publishes could
not distinguish anything. AR-017's own verification asks for "deferred
and pooled routes distinguishable". Route is now an enum on the claim.
Only `live` occurs today; `deferred` exists so AR-020's pass has
somewhere to write instead of a serialisation change to make.

Also: TrackGallery::forget had no callers, under a comment asserting the
matcher called it "on a cut or track disappearance". The cut half was
true by another route; the disappearance half was not, so a track that
died quietly kept its diversity buffer until the next cut cleared
everything. Replaced with prune_dead against the registry's own
liveness, the same shape as the tracker's prune_boxes -- a second
opinion about which tracks exist is a second thing that can be wrong.

Removes dead logistic/logit helpers and fixes five TRACES tags that used
a comma where a pipe separates requirement types, which the gate had
been reporting as diagnostics.

TRACES: AR-011, AR-017, AR-019 | IR-002 | SR-002, SR-005
2026-08-05 17:33:12 +02:00

591 lines
28 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#pragma once
/// TRACES: VR-015 | PR-004
///
/// Pipeline throughput benchmark — how much of a run is spent in each node.
///
/// The KPN network already counts most of what an optimiser needs, and
/// `print_diagnostics()` throws nearly all of it away: it prints frames and
/// `ema` per node, and passes `elapsed_s = 0`, which zeroes throughput. Two
/// things had to change before "time per node" could be answered honestly.
///
/// **`ema` is not a total.** It is an exponentially weighted average, so
/// `frames * ema` tracks the end of the run rather than the whole of it. On a
/// film that is a real difference — a detector costs one thing in a crowd scene
/// and another over a landscape. `NodeStats::total_exec_us` (added alongside
/// this) is the true sum.
///
/// **Wall time inside a node is not all work.** `PoolObjectNode::fire_once`
/// times the functor *and* `push_outputs`, and `push_outputs` parks on a full
/// downstream channel (AR-004). A node that is merely backpressured therefore
/// bills the time it spent waiting to whoever is ahead of it: SuperHero's
/// `frame_source` reported 141.9 ms/frame against a decoder logging 12-18 ms.
/// Optimising against that number means optimising the fastest node in the
/// graph.
///
/// So each node is reported three ways, and the three together are what
/// identify a cost:
///
/// - `exec_ms` — cumulative wall time in the node, work *and* parked pushes
/// - `cpu_ms` — thread CPU time (CLOCK_THREAD_CPUTIME_ID). Backpressure
/// cannot inflate it, because a parked node holds no thread.
/// - `pressure` — mean fill of its input channels minus that of its outputs
///
/// **`cpu_ms` cannot tell real work from a spinning GPU wait.** CUDA's default
/// sync policy (`cudaDeviceScheduleAuto`) spin-waits before yielding, so
/// `cudaStreamSynchronize` burns the calling thread's CPU while the GPU works.
/// A node that is purely GPU-bound can therefore report a high `cpu_ms` and read
/// as CPU-bound. `cudaSetDeviceFlags(cudaDeviceScheduleBlockingSync)` settles it
/// in one line: if a node's `cpu_ms` collapses under blocking sync, that CPU was
/// spin, not work.
///
/// **`cpu_ms` counts one thread only.** `CLOCK_THREAD_CPUTIME_ID` is per-thread,
/// and OpenCV here is built against TBB, so any node whose functor goes through
/// `cv::parallel_for_` (histogram compare, `warpAffine`, colour conversion) has
/// that work executed on TBB's arena — 19 workers on a 20-core box — and billed
/// to those threads rather than to the node. Such a node reads *cheaper* than it
/// is, and the difference shows up in `stall/f` instead, indistinguishable from a
/// GPU wait. `exec_ms` does capture it, since the functor does not return until
/// the parallel region joins: a node where `exec/f` greatly exceeds `cpu/f`
/// while its output channel is empty is fanning out, not waiting.
///
/// Work piles up *in front of* a bottleneck and starves everything *after* it,
/// so `pressure` is maximal at the node setting the pace. `cpu_ms` then says
/// which repair applies: high pressure with a saturated thread is CPU-bound and
/// the work must get cheaper, while high pressure with an idle thread is
/// waiting on a device, where batch size and engine precision are the knobs.
///
/// Occupancy has to be sampled during the run. `current_fill` is instantaneous
/// and every channel has drained by the time the network stops, so a single
/// read at the end reports an idle pipeline however congested it was.
///
/// Nothing here is specific to this pipeline's topology: the node graph is
/// recovered from KPN's channel names, so it keeps working when the graph
/// changes.
#include <kpn/diagnostics.hpp>
#include <nlohmann/json.hpp>
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cstdint>
#include <functional>
#include <iomanip>
#include <map>
#include <ostream>
#include <sstream>
#include <string>
#include <thread>
#include <vector>
namespace sae::bench {
// ── Edge naming ──────────────────────────────────────────────────────────────
/// TRACES: VR-015 | PR-004
/// KPN names a channel "<src>:<idx> → <dst>:<idx>" (static_network.hpp).
/// Recovering the two node names from it is what keeps attribution
/// topology-agnostic: the graph is read back out of the channel names rather
/// than hard-coded here, so a new node or a re-wired branch needs no change.
/// Leaves both outputs untouched if the name does not carry an arrow.
inline void split_edge_name(const std::string& name,
std::string& producer, std::string& consumer) {
static const std::string kArrow = " \xe2\x86\x92 "; // " → "
const auto arrow = name.find(kArrow);
if (arrow == std::string::npos) return;
auto strip_port = [](std::string s) {
const auto colon = s.rfind(':');
return colon == std::string::npos ? s : s.substr(0, colon);
};
producer = strip_port(name.substr(0, arrow));
consumer = strip_port(name.substr(arrow + kArrow.size()));
}
// ── Channel occupancy, time-averaged ─────────────────────────────────────────
/// TRACES: VR-015 | PR-004
/// One channel's fill level integrated over the run. `peak_fill` is already
/// cumulative in `ChannelStats`, but a peak cannot distinguish "full once" from
/// "full throughout", and those are opposite diagnoses. A mean can.
struct ChannelOccupancy {
std::string name; // "src:0 → dst:0", as KPN names it
std::string producer; // node name left of the arrow
std::string consumer; // node name right of the arrow
std::size_t capacity{0};
std::uint64_t samples{0};
double fill_sum{0.0};
std::uint64_t samples_full{0};
std::uint64_t samples_empty{0};
// Final-snapshot totals (monotonic counters, so the last read is the total).
std::size_t peak_fill{0};
std::uint64_t pushes{0};
std::uint64_t pops{0};
std::uint64_t drops{0};
std::uint64_t overflows{0};
std::uint64_t bytes_pushed{0};
double mean_fill() const { return samples ? fill_sum / static_cast<double>(samples) : 0.0; }
double mean_fill_pct() const { return capacity ? 100.0 * mean_fill() / static_cast<double>(capacity) : 0.0; }
double peak_pct() const { return capacity ? 100.0 * static_cast<double>(peak_fill) / static_cast<double>(capacity) : 0.0; }
double full_pct() const { return samples ? 100.0 * static_cast<double>(samples_full) / static_cast<double>(samples) : 0.0; }
double empty_pct() const { return samples ? 100.0 * static_cast<double>(samples_empty) / static_cast<double>(samples) : 0.0; }
double bandwidth_mbs(double wall_s) const {
return wall_s > 0.0 ? static_cast<double>(bytes_pushed) / wall_s / 1e6 : 0.0;
}
};
// ── Per-node attributed cost ─────────────────────────────────────────────────
/// TRACES: VR-015 | PR-004
struct NodeCost {
std::string name;
std::uint64_t frames{0};
// Cumulative wall time in the node — the answer to "where did the run go",
// but only for a node that is not backpressured; it includes parked pushes.
double exec_ms{0.0};
double exec_ms_per_frame{0.0}; // true mean, not the EMA
double exec_share{0.0}; // exec_ms / wall_ms, 0..1
double ema_exec_ms{0.0}; // KPN's EMA, kept for continuity with the old report
double max_exec_ms{0.0};
// Thread CPU time: excludes sleeping, parking and waiting on a device, so it
// is the one number backpressure cannot inflate.
double cpu_ms{0.0};
double cpu_ms_per_frame{0.0};
double cpu_share{0.0}; // cpu_ms / wall_ms — thread saturation, 0..1
double cpu_pct_of_pipeline{0.0}; // this node's share of all nodes' CPU time
// Per frame, time inside the node not spent on its own CPU: parked on a
// full output channel, or waiting on the GPU. `pressure` separates those —
// a backpressured node has a full output, a device-bound one does not.
double stall_ms_per_frame{0.0};
// Queue occupancy either side of the node, in percent of capacity.
double in_fill_pct{0.0};
double out_fill_pct{0.0};
double pressure{0.0}; // in out; maximal at the pacing node
bool has_input{false};
bool has_output{false};
double queue_wait_ms{0.0};
bool is_bottleneck{false};
/// TRACES: VR-015 | AR-004 | PR-004
/// Live scheduling state, so a wedged run says *why* it is wedged rather
/// than only that it is. With `queued=0, wake=1` a wake was recorded and
/// never consumed; with `queued=0, wake=0` and a full input, no wake was
/// ever generated. Those are different bugs in different files.
bool queued{false};
bool wake_pending{false};
};
/// TRACES: VR-015 | PR-004
/// Attribute cost to nodes from a KPN node snapshot plus sampled channel
/// occupancy. Pure — no clocks, no threads, no network — so the ranking is
/// unit-testable on CI hardware that can never run the pipeline itself.
///
/// A node with several inputs takes the **minimum** input fill: it can only run
/// once every input has data, so the emptiest one gates it, and a full sibling
/// channel means that channel's producer is blocked rather than this node being
/// slow. A node with several outputs takes the **maximum** output fill, since
/// parking on any one branch stops the node.
///
/// Terminals are the infinite-reservoir limit of the same rule: a source has
/// unlimited work available (input treated as 100% full) and a sink unlimited
/// drain (output treated as empty), so both stay rankable against the interior
/// nodes instead of dropping out of the comparison.
inline std::vector<NodeCost> attribute_cost(
const std::vector<kpn::NodeSnapshot>& nodes,
const std::vector<ChannelOccupancy>& channels,
double wall_sec)
{
const double wall_ms = wall_sec * 1000.0;
double cpu_total = 0.0;
for (const auto& n : nodes) cpu_total += n.total_cpu_ms;
std::vector<NodeCost> out;
out.reserve(nodes.size());
for (const auto& n : nodes) {
NodeCost c;
c.name = n.name;
c.frames = n.frames_processed;
c.exec_ms = n.total_exec_ms;
c.ema_exec_ms = n.ema_exec_ms;
c.max_exec_ms = n.max_exec_ms;
c.cpu_ms = n.total_cpu_ms;
c.queue_wait_ms = n.queue_wait_ms;
c.queued = n.queued;
c.wake_pending = n.wake_pending;
c.exec_ms_per_frame = c.frames ? c.exec_ms / static_cast<double>(c.frames) : 0.0;
c.cpu_ms_per_frame = c.frames ? c.cpu_ms / static_cast<double>(c.frames) : 0.0;
c.exec_share = wall_ms > 0.0 ? c.exec_ms / wall_ms : 0.0;
c.cpu_share = wall_ms > 0.0 ? c.cpu_ms / wall_ms : 0.0;
c.cpu_pct_of_pipeline = cpu_total > 0.0 ? 100.0 * c.cpu_ms / cpu_total : 0.0;
c.stall_ms_per_frame = c.exec_ms_per_frame - c.cpu_ms_per_frame;
if (c.stall_ms_per_frame < 0.0) c.stall_ms_per_frame = 0.0;
double in_min = 0.0; bool have_in = false;
double out_max = 0.0; bool have_out = false;
for (const auto& ch : channels) {
if (ch.consumer == n.name) {
const double f = ch.mean_fill_pct();
if (!have_in || f < in_min) in_min = f;
have_in = true;
}
if (ch.producer == n.name) {
const double f = ch.mean_fill_pct();
if (!have_out || f > out_max) out_max = f;
have_out = true;
}
}
c.has_input = have_in;
c.has_output = have_out;
c.in_fill_pct = have_in ? in_min : 100.0; // source: always has work
c.out_fill_pct = have_out ? out_max : 0.0; // sink: never blocks
c.pressure = c.in_fill_pct - c.out_fill_pct;
out.push_back(std::move(c));
}
// Rank, but only among nodes that actually ran: a node with zero frames has
// no cost to attribute and its neighbouring channels never moved.
auto best = out.end();
for (auto it = out.begin(); it != out.end(); ++it) {
if (it->frames == 0) continue;
if (best == out.end() || it->pressure > best->pressure) best = it;
}
if (best != out.end()) best->is_bottleneck = true;
return out;
}
/// TRACES: VR-015 | PR-004
/// One line of plain English about the winning node, since the point of the
/// report is to say what to change next. A saturated thread means the node's
/// own work is the limit; an idle thread under pressure means it is waiting on
/// a device, and those are different repairs.
inline std::string verdict(const std::vector<NodeCost>& costs) {
for (const auto& c : costs) {
if (!c.is_bottleneck) continue;
std::ostringstream os;
os << std::fixed << c.name << " sets the pace: ";
// A source's 100% input is the infinite-reservoir convention, not a
// measured queue — saying "work is backed up in front of it" would be
// asserting something no counter observed.
if (!c.has_input)
os << "nothing downstream is waiting on it (output "
<< std::setprecision(1) << c.out_fill_pct
<< "% full), so the pipeline is running as fast as this node can feed it. ";
else if (!c.has_output)
os << std::setprecision(1) << c.in_fill_pct
<< "% full input and nothing to block on, so it is the drain. ";
else
os << std::setprecision(1) << c.in_fill_pct << "% full input, "
<< c.out_fill_pct << "% full output. ";
os << std::setprecision(2) << c.cpu_ms_per_frame << " ms/frame on CPU. ";
if (c.cpu_share >= 0.85)
os << "CPU-bound — its thread is busy " << std::setprecision(0)
<< (100.0 * c.cpu_share) << "% of the run, so the work itself has to get"
" cheaper or be split across more threads.";
else if (c.cpu_share <= 0.35 && c.stall_ms_per_frame > c.cpu_ms_per_frame)
os << "Device-bound — its thread is busy only " << std::setprecision(0)
<< (100.0 * c.cpu_share) << "% of the run and it spends "
<< std::setprecision(2) << c.stall_ms_per_frame
<< " ms/frame off-CPU, so it is waiting on the GPU or the disk: batch size,"
" engine precision and the decode path are the knobs, not the C++.";
else
os << "Mixed — thread busy " << std::setprecision(0) << (100.0 * c.cpu_share)
<< "% of the run, " << std::setprecision(2) << c.stall_ms_per_frame
<< " ms/frame off-CPU.";
return os.str();
}
return "no node processed a frame — nothing to attribute";
}
// ── Recorder ─────────────────────────────────────────────────────────────────
/// TRACES: VR-015 | PR-004
/// Samples the live network on a timer and emits the report at the end.
///
/// The sampler only reads relaxed atomics, so it does not perturb what it
/// measures — which matters, since this exists to be trusted as a timing
/// measurement.
class BenchmarkRecorder {
public:
using Sampler = std::function<kpn::NetworkSnapshot()>;
explicit BenchmarkRecorder(int sample_interval_ms = 100)
: interval_(std::chrono::milliseconds(sample_interval_ms)) {}
~BenchmarkRecorder() { stop(); }
void start(Sampler sampler) {
sampler_ = std::move(sampler);
running_.store(true, std::memory_order_release);
thread_ = std::thread([this] {
while (running_.load(std::memory_order_acquire)) {
accumulate(sampler_());
std::this_thread::sleep_for(interval_);
}
});
}
/// Stops sampling and latches the final counter values. Call while the
/// network object is still alive: the monotonic counters stay valid after
/// `net.stop()`, but they die with the object.
void stop() {
if (!running_.exchange(false, std::memory_order_acq_rel)) return;
if (thread_.joinable()) thread_.join();
if (sampler_) {
final_ = sampler_();
// Occupancy is deliberately NOT accumulated from this last read:
// the pipeline has drained by now, and folding an idle sample into
// the mean biases every channel toward "never congested".
for (const auto& ch : final_.channels) {
auto& occ = occupancy_[ch.name];
if (occ.name.empty()) { // a channel that never moved
occ.name = ch.name;
occ.capacity = ch.capacity;
split_edge_name(ch.name, occ.producer, occ.consumer);
}
occ.peak_fill = ch.peak_fill;
occ.pushes = ch.pushes;
occ.pops = ch.pops;
occ.drops = ch.drops;
occ.overflows = ch.overflows;
occ.bytes_pushed = ch.bytes_pushed;
}
}
stopped_ = true;
}
bool has_data() const { return stopped_ && !final_.nodes.empty(); }
double wall_sec() const { return final_.elapsed_s; }
std::vector<ChannelOccupancy> channels() const {
std::vector<ChannelOccupancy> v;
v.reserve(occupancy_.size());
for (const auto& [_, occ] : occupancy_) v.push_back(occ);
return v;
}
std::vector<NodeCost> costs() const {
return attribute_cost(final_.nodes, channels(), final_.elapsed_s);
}
/// TRACES: VR-015 | PR-004
/// Machine-readable report, for sweeping configurations and diffing runs.
/// `film_sec` is the last timestamp the pipeline reached, so
/// `realtime_factor` answers what the optimiser is really asking: seconds
/// of film per second of wall clock. It is 0 for a topology with no result
/// sink (the dump-only path), and the field is then omitted rather than
/// reported as zero throughput.
nlohmann::json to_json(const nlohmann::json& run_config, double film_sec) const {
using nlohmann::json;
const double wall = final_.elapsed_s;
const auto chans = channels();
const auto cost = attribute_cost(final_.nodes, chans, wall);
json j;
j["schema_version"] = 1;
j["config"] = run_config;
json summary;
summary["wall_sec"] = wall;
summary["sample_count"] = sample_count_;
summary["sample_interval_ms"] = interval_.count();
if (film_sec > 0.0) {
summary["film_sec"] = film_sec;
summary["realtime_factor"] = wall > 0.0 ? film_sec / wall : 0.0;
}
for (const auto& c : cost)
if (c.is_bottleneck) { summary["bottleneck"] = c.name; break; }
summary["verdict"] = verdict(cost);
j["summary"] = summary;
json jnodes = json::array();
for (const auto& c : cost) {
jnodes.push_back({
{"name", c.name},
{"frames", c.frames},
{"fps", wall > 0.0 ? c.frames / wall : 0.0},
{"exec_ms", c.exec_ms},
{"exec_ms_per_frame", c.exec_ms_per_frame},
{"exec_share", c.exec_share},
{"ema_exec_ms", c.ema_exec_ms},
{"max_exec_ms", c.max_exec_ms},
{"cpu_ms", c.cpu_ms},
{"cpu_ms_per_frame", c.cpu_ms_per_frame},
{"cpu_share", c.cpu_share},
{"cpu_pct_of_pipeline", c.cpu_pct_of_pipeline},
{"stall_ms_per_frame", c.stall_ms_per_frame},
{"queue_wait_ms", c.queue_wait_ms},
{"in_fill_pct", c.in_fill_pct},
{"out_fill_pct", c.out_fill_pct},
{"pressure", c.pressure},
{"is_bottleneck", c.is_bottleneck},
{"queued", c.queued},
{"wake_pending", c.wake_pending},
});
}
j["nodes"] = std::move(jnodes);
json jch = json::array();
for (const auto& ch : chans) {
jch.push_back({
{"name", ch.name},
{"producer", ch.producer},
{"consumer", ch.consumer},
{"capacity", ch.capacity},
{"mean_fill", ch.mean_fill()},
{"mean_fill_pct", ch.mean_fill_pct()},
{"peak_fill", ch.peak_fill},
{"peak_pct", ch.peak_pct()},
{"full_pct", ch.full_pct()},
{"empty_pct", ch.empty_pct()},
{"pushes", ch.pushes},
{"pops", ch.pops},
{"drops", ch.drops},
{"overflows", ch.overflows},
{"mb_per_sec", ch.bandwidth_mbs(wall)},
});
}
j["channels"] = std::move(jch);
return j;
}
/// TRACES: VR-015 | PR-004
/// Human-readable form of the same data, so a run is legible without
/// opening the JSON.
void print(std::ostream& os, double film_sec) const {
print_impl(os, final_, film_sec);
}
/// TRACES: VR-015 | AR-004 | PR-004
/// Dump the report from a LIVE snapshot, mid-run, without stopping anything.
///
/// A report that only exists at shutdown is no use against the failure this
/// pipeline actually has: a wedged run never reaches shutdown, so the one
/// moment the numbers matter most is the one moment they were unavailable.
/// Channel occupancy names the stalled node directly — it is the one whose
/// input is full and whose output is empty — which is otherwise a debug-build
/// and a gdb session away.
///
/// Safe to call from the wait loop while the pipeline is running or hung: it
/// takes the same lock-free snapshot the sampler does.
void dump_live(std::ostream& os, double film_sec) const {
if (!sampler_) { os << "[benchmark] no sampler — run with --benchmark\n"; return; }
print_impl(os, sampler_(), film_sec);
}
private:
void print_impl(std::ostream& os, const kpn::NetworkSnapshot& snap,
double film_sec) const {
const double wall = snap.elapsed_s;
const auto chans = channels();
const auto cost = attribute_cost(snap.nodes, chans, wall);
os << "\n┌─ Pipeline benchmark (VR-015) ──────────────────────────────────────────────\n";
os << "│ wall " << std::fixed << std::setprecision(1) << wall << "s";
if (film_sec > 0.0)
os << " film " << film_sec << "s realtime x" << std::setprecision(2)
<< (wall > 0.0 ? film_sec / wall : 0.0);
os << " samples " << sample_count_ << "\n\n";
os << "│ node frames cpu_s cpu%run cpu%tot cpu/f"
" exec/f stall/f in% out% press q/w\n";
for (const auto& c : cost) {
os << "│ " << (c.is_bottleneck ? "▶ " : " ") << std::left << std::setw(16)
<< c.name << std::right
<< std::setw(7) << c.frames
<< std::setw(10) << std::setprecision(1) << (c.cpu_ms / 1000.0)
<< std::setw(9) << std::setprecision(0) << (100.0 * c.cpu_share)
<< std::setw(9) << std::setprecision(0) << c.cpu_pct_of_pipeline
<< std::setw(8) << std::setprecision(2) << c.cpu_ms_per_frame
<< std::setw(8) << std::setprecision(2) << c.exec_ms_per_frame
<< std::setw(9) << std::setprecision(2) << c.stall_ms_per_frame
<< std::setw(7) << std::setprecision(0) << c.in_fill_pct
<< std::setw(7) << std::setprecision(0) << c.out_fill_pct
<< std::setw(8) << std::setprecision(1) << c.pressure
<< " " << int(c.queued) << "/" << int(c.wake_pending)
<< "\n";
}
/// TRACES: VR-015 | AR-004 | PR-004
// Fires only when the scheduling state is actually wrong, so a healthy
// run stays quiet and a wedged one names the fault — instead of leaving
// it to be reconstructed under a debugger that suppresses the bug.
for (const auto& c : cost) {
if (c.queued || !c.has_input) continue;
if (c.wake_pending)
os << "│ !! " << c.name << " idle with a wake outstanding"
" (queued=0 wake=1): the wake was recorded and never"
" consumed — submit/release handshake.\n";
else if (c.in_fill_pct > 50.0)
os << "│ !! " << c.name << " idle with a "
<< std::setprecision(0) << c.in_fill_pct
<< "% full input and no wake pending: the wake was never"
" generated — channel edge detection.\n";
}
os << "│\n│ channel cap mean% peak% full%"
" empty% MB/s\n";
for (const auto& ch : chans) {
os << "│ " << std::left << std::setw(36) << ch.name << std::right
<< std::setw(5) << ch.capacity
<< std::setw(7) << std::setprecision(1) << ch.mean_fill_pct()
<< std::setw(7) << ch.peak_pct()
<< std::setw(7) << ch.full_pct()
<< std::setw(7) << ch.empty_pct()
<< std::setw(9) << std::setprecision(1) << ch.bandwidth_mbs(wall)
<< "\n";
}
os << "│\n│ " << verdict(cost) << "\n";
os << "└────────────────────────────────────────────────────────────────────────────\n";
os << " cpu_s / cpu%tot is where the run's compute actually went. exec/f is wall\n"
" time in the node INCLUDING time parked on a full output channel, so it\n"
" overstates a backpressured node — compare it against cpu/f, which cannot\n"
" be inflated that way. press = input fill output fill, and locates the\n"
" node that work is queueing up in front of.\n"
" cpu_s counts THIS node's thread only: work OpenCV fans out via TBB is\n"
" billed to the TBB arena, so a node using cv::parallel_for_ reads cheaper\n"
" than it is and the difference surfaces in stall/f.\n";
}
private:
void accumulate(const kpn::NetworkSnapshot& snap) {
++sample_count_;
for (const auto& ch : snap.channels) {
auto& occ = occupancy_[ch.name];
if (occ.name.empty()) {
occ.name = ch.name;
occ.capacity = ch.capacity;
split_edge_name(ch.name, occ.producer, occ.consumer);
}
occ.fill_sum += static_cast<double>(ch.current_fill);
++occ.samples;
if (ch.capacity && ch.current_fill >= ch.capacity) ++occ.samples_full;
if (ch.current_fill == 0) ++occ.samples_empty;
}
}
std::chrono::milliseconds interval_;
Sampler sampler_;
std::thread thread_;
std::atomic<bool> running_{false};
bool stopped_{false};
std::uint64_t sample_count_{0};
std::map<std::string, ChannelOccupancy> occupancy_;
kpn::NetworkSnapshot final_{};
};
} // namespace sae::bench