feat(benchmark): per-node cost and bottleneck attribution for a run
--benchmark <path> reports cumulative CPU and wall time per node and names the node pacing the run. The pacing node is located from sampled channel occupancy, not from time-in-node: backpressure inflates time-in-node for everything downstream of the real bottleneck, so the obvious measure names the victim rather than the cause. Sampling starts with the network and stops before it is destroyed. Channel fill is instantaneous and everything has drained by shutdown, so a single read at the end reports an idle pipeline however congested it was. kill -USR1 dumps the table from a running or wedged process. Channel occupancy identifies a stalled node -- full input, empty output -- without a debug build or a debugger, which is the difference between diagnosing the AR-004 hang in seconds and reproducing it under gdb. Two knobs this exposes for measurement rather than sets: SAE_CV_THREADS, because OpenCV's TBB arena and KPN's thread-per-node are two schedulers unaware of each other on the same cores; and SAE_CUDA_BLOCKING_SYNC, because the default spin-wait held the embedder thread at 99.7% user time while nvidia-powerd cut the GPU's clock from 1005 to 210 MHz. Neither default changes until a measurement says it should. TRACES: VR-015 | PR-004
This commit is contained in:
+105
-2
@@ -44,10 +44,15 @@
|
||||
// --expand-band-hi <p> store admission ceiling, P(same person) (default: 0.95)
|
||||
// --expand-min-anchor <N> accepted frames before a track confirms (default: 3)
|
||||
// --expand-debug-dir <p> dump promoted mugshots + embeddings here (SAE_DEBUG)
|
||||
// --benchmark <path> write a per-node timing + bottleneck report (JSON) and
|
||||
// print it at shutdown. Says where the run's time went
|
||||
// and which node is pacing it. See src/benchmark.hpp.
|
||||
// --benchmark-interval-ms <N> channel-occupancy sampling period (default: 100)
|
||||
// (SAE_DEBUG only)
|
||||
// --debug-dir <path> debug frames output dir (default: debug_frames)
|
||||
// --crop-context <f> bbox expansion factor for context crops (default: 1.5)
|
||||
|
||||
#include "benchmark.hpp"
|
||||
#include "config.hpp"
|
||||
#include "types.hpp"
|
||||
#include "gallery/embedder_stamp.hpp"
|
||||
@@ -71,9 +76,14 @@
|
||||
|
||||
#include <kpn/kpn.hpp>
|
||||
|
||||
#include <opencv2/core/utility.hpp> // cv::setNumThreads (SAE_CV_THREADS)
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <csignal>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
@@ -96,6 +106,20 @@ static constexpr std::size_t kSceneJoinDepth = 256;
|
||||
/// actually worked.
|
||||
static std::shared_ptr<SceneBoundaries> scene_stats;
|
||||
|
||||
/// TRACES: VR-015, AR-004 | PR-004
|
||||
/// Set by SIGUSR1, serviced by the wait loop. `kill -USR1 <pid>` on a running
|
||||
/// or WEDGED run prints the benchmark table immediately — channel occupancy
|
||||
/// names the stalled node (full input, empty output) without a debug build or a
|
||||
/// debugger, which is the difference between diagnosing the AR-004 hang in
|
||||
/// seconds and reproducing it under gdb.
|
||||
///
|
||||
/// The handler only stores a flag; all printing happens on the main thread,
|
||||
/// since nothing in the report is async-signal-safe.
|
||||
static std::atomic<bool> g_dump_request{false};
|
||||
extern "C" void sae_on_dump_signal(int) {
|
||||
g_dump_request.store(true, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
static Config parse_args(int argc, char** argv) {
|
||||
Config cfg;
|
||||
cfg.detector_model = kDefaultDetectorModel;
|
||||
@@ -114,6 +138,8 @@ static Config parse_args(int argc, char** argv) {
|
||||
else if (arg("--gallery")) cfg.gallery_path = next();
|
||||
else if (arg("--output")) cfg.output_path = next();
|
||||
else if (arg("--dump-embeddings")) cfg.dump_embeddings_path = next();
|
||||
else if (arg("--benchmark")) cfg.benchmark_path = next();
|
||||
else if (arg("--benchmark-interval-ms")) cfg.benchmark_interval_ms = std::stoi(next());
|
||||
else if (arg("--fps")) cfg.sample_fps = std::stof(next());
|
||||
else if (arg("--max-decode-fps")) cfg.max_decode_fps = std::stof(next());
|
||||
else if (arg("--start")) cfg.start_sec = std::stod(next());
|
||||
@@ -174,6 +200,21 @@ static Config parse_args(int argc, char** argv) {
|
||||
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
/// TRACES: VR-015 | PR-004
|
||||
// OpenCV here is built against TBB, so cv::parallel_for_ opens an arena of
|
||||
// nproc-1 workers (19 on a 20-core box) *on top of* KPN's one thread per
|
||||
// node. Two schedulers, neither aware of the other, on the same cores.
|
||||
//
|
||||
// SAE_CV_THREADS=1 hands concurrency entirely to KPN, which is where this
|
||||
// pipeline's parallelism is supposed to come from. Worth measuring rather
|
||||
// than assuming: TBB fan-out inside warpAffine is free speed when the
|
||||
// pipeline is otherwise idle, so this can cut either way. Unset = default.
|
||||
if (const char* t = std::getenv("SAE_CV_THREADS")) {
|
||||
const int n = std::atoi(t);
|
||||
cv::setNumThreads(n);
|
||||
std::cerr << "[opencv] cv::setNumThreads(" << n << ")\n";
|
||||
}
|
||||
|
||||
Config cfg;
|
||||
try {
|
||||
cfg = parse_args(argc, argv);
|
||||
@@ -236,7 +277,15 @@ int main(int argc, char** argv) {
|
||||
// AR-016: a film ends with faces on screen and those tracks have not timed
|
||||
// out. Without this flush the closing scene's cast is silently never
|
||||
// emitted — a loss that reads as a recognition miss, not a bookkeeping bug.
|
||||
sink_fn.set_pre_write_hook([registry](double last_ts) { registry->flush(last_ts); });
|
||||
/// TRACES: VR-015 | PR-004
|
||||
// Last timestamp the pipeline reached, latched on the way out. It is what
|
||||
// turns wall-clock seconds into the number that matters — seconds of film
|
||||
// per second of run — and the sink is the only node that knows it.
|
||||
std::atomic<double> film_sec{0.0};
|
||||
sink_fn.set_pre_write_hook([registry, &film_sec](double last_ts) {
|
||||
film_sec.store(last_ts, std::memory_order_release);
|
||||
registry->flush(last_ts);
|
||||
});
|
||||
#ifdef SAE_DEBUG
|
||||
DebugRendererFunc debug_fn {cfg};
|
||||
#endif
|
||||
@@ -302,8 +351,23 @@ int main(int argc, char** argv) {
|
||||
return false;
|
||||
});
|
||||
|
||||
/// TRACES: VR-015 | PR-004
|
||||
// Sampling must start with the network and stop before it is destroyed:
|
||||
// channel fill is instantaneous, and by the time a run ends everything
|
||||
// has drained, so a single read at shutdown reports an idle pipeline no
|
||||
// matter how congested it was.
|
||||
sae::bench::BenchmarkRecorder bench{cfg.benchmark_interval_ms};
|
||||
const bool benchmarking = !cfg.benchmark_path.empty();
|
||||
|
||||
std::cerr << "[main] starting pipeline…\n";
|
||||
net.start();
|
||||
if (benchmarking) {
|
||||
bench.start([&net] { return net.network_snapshot(); });
|
||||
std::signal(SIGUSR1, sae_on_dump_signal);
|
||||
std::cerr << "[benchmark] sampling every " << cfg.benchmark_interval_ms
|
||||
<< "ms — `kill -USR1 " << getpid()
|
||||
<< "` to dump the table now (works while hung)\n";
|
||||
}
|
||||
|
||||
// Wait until BOTH terminal branches finish: result_sink (face pipeline)
|
||||
// and, when enabled, scene_detector (the dense TransNetV2 branch, which
|
||||
@@ -311,12 +375,51 @@ int main(int argc, char** argv) {
|
||||
// pre-set true when scene detection is disabled.
|
||||
while ((!done.load(std::memory_order_acquire) ||
|
||||
!scene_done.load(std::memory_order_acquire)) &&
|
||||
!node_crashed.load(std::memory_order_acquire))
|
||||
!node_crashed.load(std::memory_order_acquire)) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
/// TRACES: VR-015, AR-004 | PR-004
|
||||
if (g_dump_request.exchange(false, std::memory_order_relaxed))
|
||||
bench.dump_live(std::cerr, film_sec.load(std::memory_order_acquire));
|
||||
}
|
||||
|
||||
// Latch the counters before stop(): they stay readable afterwards, but
|
||||
// only while the network object is alive, and this keeps the numbers
|
||||
// describing the run rather than the teardown.
|
||||
if (benchmarking) bench.stop();
|
||||
|
||||
net.stop();
|
||||
net.print_diagnostics();
|
||||
|
||||
/// TRACES: VR-015 | PR-004
|
||||
if (benchmarking && bench.has_data()) {
|
||||
const double film = film_sec.load(std::memory_order_acquire);
|
||||
bench.print(std::cerr, film);
|
||||
|
||||
nlohmann::json run_cfg{
|
||||
{"movie", cfg.movie_path},
|
||||
{"gallery", cfg.gallery_path},
|
||||
{"gallery_actors", gallery.actors.size()},
|
||||
{"sample_fps", cfg.sample_fps},
|
||||
{"min_face_px", cfg.min_face_px},
|
||||
{"max_faces", cfg.max_faces},
|
||||
{"embed_batch", cfg.embed_batch_size},
|
||||
{"expand_gallery", cfg.expand_gallery},
|
||||
{"scene_detect", cfg.scene_detect},
|
||||
{"detector_engine", cfg.detector_engine},
|
||||
{"arcface_engine", cfg.arcface_engine},
|
||||
{"detector_model", cfg.detector_model},
|
||||
{"arcface_model", cfg.arcface_model},
|
||||
};
|
||||
std::ofstream bf(cfg.benchmark_path);
|
||||
if (bf) {
|
||||
bf << bench.to_json(run_cfg, film).dump(2) << "\n";
|
||||
std::cerr << "[benchmark] wrote " << cfg.benchmark_path << "\n";
|
||||
} else {
|
||||
std::cerr << "[benchmark] ERROR: could not write "
|
||||
<< cfg.benchmark_path << "\n";
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: AR-004 | SR-002
|
||||
// A dropped frame does not degrade a result, it silently changes one —
|
||||
// the output is a claim about footage that was never analysed, and
|
||||
|
||||
Reference in New Issue
Block a user