diff --git a/src/types.hpp b/src/types.hpp index 3227a2b..58904d7 100644 --- a/src/types.hpp +++ b/src/types.hpp @@ -184,3 +184,111 @@ struct ActorGallery { bool calib_valid{false}; uint64_t calib_hash{0}; }; + +// ── Channel byte accounting ─────────────────────────────────────────────────── +/// TRACES: AR-004 | SR-002 +/// +/// KPN measures a channel's occupancy in *items* and its bandwidth in bytes, +/// and gets the byte figure from `kpn::ChannelDataSize`. That primary +/// template returns `sizeof(T)` — right for a POD, badly wrong for every type +/// below, each of which is a handful of vectors and a `cv::Mat` header owning +/// megabytes on the heap. +/// +/// Unspecialised, the diagnostics reported roughly 200 bytes for a message +/// carrying a full decoded frame — off by four orders of magnitude at 1080p. +/// That is not merely a cosmetic stat: it is the one instrument for choosing +/// channel capacities against a memory ceiling, which is the open half of +/// AR-004, and it was reading fiction. +/// +/// **What the number means.** `cv::Mat` is reference-counted, so one decoded +/// frame referenced from several messages is counted once per reference. The +/// sum is therefore an upper bound on distinct bytes, and the right bound for +/// the question being asked: how much would this channel keep alive if nothing +/// else held it. +/// +/// Declared against a forward declaration rather than including +/// `` here, so the message definitions keep no dependency on +/// the framework that carries them — and so any translation unit that can see +/// these types also sees their sizes, which is what stops one channel being +/// instantiated with the default and another with the specialisation. + +namespace kpn { template struct ChannelDataSize; } + +namespace sae::bytes { + +inline std::size_t of(const cv::Mat& m) { + return m.empty() ? 0u : m.total() * m.elemSize(); +} +inline std::size_t of(const std::vector& v) { + std::size_t n = 0; + for (const auto& m : v) n += of(m); + return n; +} +inline std::size_t of(const Frame& f) { return sizeof(Frame) + of(f.image); } + +inline std::size_t of(const std::vector& v) { + std::size_t n = v.size() * sizeof(IdentifiedActor); + for (const auto& a : v) { + n += of(a.crop); + // The id strings are short but there is one set per actor per frame, + // and a crowd frame carries dozens. + n += a.name.capacity() + a.imdb_id.capacity() + + a.tmdb_id.capacity() + a.jellyfin_id.capacity(); + } + return n; +} + +} // namespace sae::bytes + +template<> struct kpn::ChannelDataSize { + static std::size_t bytes(const Frame& f) { return sae::bytes::of(f); } +}; + +template<> struct kpn::ChannelDataSize { + static std::size_t bytes(const SceneFrame& v) { + return sizeof(SceneFrame) + sae::bytes::of(v.source) + + v.faces.size() * sizeof(DetectedFace); + } +}; + +template<> struct kpn::ChannelDataSize { + static std::size_t bytes(const AlignedSceneFrame& v) { + return sizeof(AlignedSceneFrame) + sae::bytes::of(v.source) + + v.faces.size() * sizeof(DetectedFace) + + sae::bytes::of(v.crops); + } +}; + +template<> struct kpn::ChannelDataSize { + static std::size_t bytes(const EmbeddedSceneFrame& v) { + return sizeof(EmbeddedSceneFrame) + sae::bytes::of(v.source) + + v.faces.size() * sizeof(DetectedFace) + + sae::bytes::of(v.crops) + + v.embeddings.size() * sizeof(Embedding); + } +}; + +template<> struct kpn::ChannelDataSize { + static std::size_t bytes(const TrackedSceneFrame& v) { + return sizeof(TrackedSceneFrame) + sae::bytes::of(v.source) + + v.faces.size() * sizeof(DetectedFace) + + sae::bytes::of(v.crops) + + v.track_ids.size() * sizeof(int) + + v.embeddings.size() * sizeof(Embedding); + } +}; + +template<> struct kpn::ChannelDataSize { + static std::size_t bytes(const MatchedSceneFrame& v) { + return sizeof(MatchedSceneFrame) + sae::bytes::of(v.source) + + sae::bytes::of(v.actors); + } +}; + +template<> struct kpn::ChannelDataSize { + static std::size_t bytes(const SceneAnnotation& v) { + return sizeof(SceneAnnotation) + sae::bytes::of(v.visible_actors); + } +}; + +// CutEvent owns nothing on the heap, so the default sizeof(T) is already right. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d6e628c..ece8a25 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -28,6 +28,7 @@ add_executable(sae_tests test_embedding_dump.cpp test_audio_signature.cpp test_benchmark.cpp + test_channel_bytes.cpp ${CMAKE_SOURCE_DIR}/src/backends/gemm_backend.cpp ${CMAKE_SOURCE_DIR}/src/gallery/gallery_store.cpp ${CMAKE_SOURCE_DIR}/src/audio_signature.cpp diff --git a/tests/test_channel_bytes.cpp b/tests/test_channel_bytes.cpp new file mode 100644 index 0000000..c22d256 --- /dev/null +++ b/tests/test_channel_bytes.cpp @@ -0,0 +1,109 @@ +// Channel byte accounting for the pipeline message types. +// +// TRACES: AR-004 | SR-002 +// +// kpn::ChannelDataSize is what a channel reports as bytes pushed, and its +// primary template returns sizeof(T). Every message type here is a handful of +// vectors and a cv::Mat header owning megabytes on the heap, so unspecialised +// the diagnostics reported ~200 bytes for a message carrying a full decoded +// frame — off by four orders of magnitude at 1080p. +// +// That is the instrument for choosing channel capacities against a memory +// ceiling, which is the open half of AR-004. These cases assert it measures the +// payload rather than the header, because a stat that is quietly wrong is worse +// than no stat: it was read as evidence. +#include + +#include + +#include "types.hpp" + +namespace { + +Frame frame_with_image(int w, int h) { + Frame f; + f.image = cv::Mat(h, w, CV_8UC3, cv::Scalar(0, 0, 0)); + f.timestamp_sec = 1.0; + return f; +} + +} // namespace + +TEST_CASE("frame bytes count the decoded image, not the header", "[channel_bytes]") { + const Frame f = frame_with_image(1920, 1080); + const std::size_t got = kpn::ChannelDataSize::bytes(f); + + // 1920 * 1080 * 3 = 6,220,800 payload bytes. + REQUIRE(got >= 1920u * 1080u * 3u); + // The header is a rounding error next to it; this is the assertion that + // fails on the unspecialised default. + CHECK(got > 100u * sizeof(Frame)); +} + +TEST_CASE("an empty frame costs only its header", "[channel_bytes]") { + // The eof sentinel carries no image, and must not be charged for one. + Frame eof; + eof.eof = true; + CHECK(kpn::ChannelDataSize::bytes(eof) == sizeof(Frame)); +} + +TEST_CASE("crops and embeddings are counted on top of the frame", "[channel_bytes]") { + // The case AR-003 created: a crowd frame occupies one slot exactly as an + // empty one does, and only the byte figure distinguishes them. + EmbeddedSceneFrame v; + v.source = frame_with_image(640, 360); + const std::size_t bare = kpn::ChannelDataSize::bytes(v); + + constexpr int kFaces = 60; + for (int i = 0; i < kFaces; ++i) { + v.faces.push_back({}); + v.crops.emplace_back(112, 112, CV_8UC3, cv::Scalar(0, 0, 0)); + v.embeddings.emplace_back(); + } + const std::size_t crowded = kpn::ChannelDataSize::bytes(v); + + // 60 crops at 112*112*3 = 2,257,920 bytes, plus 60 * 2 KiB of embeddings. + CHECK(crowded - bare >= kFaces * (112u * 112u * 3u + sizeof(Embedding))); + // And the crowd frame really is the multiple of the empty one that the + // item-count capacity cannot see: 640x360x3 is ~691 KB, the crops ~2.26 MB. + CHECK(crowded > 3 * bare); +} + +TEST_CASE("every message type on a channel measures its payload", "[channel_bytes]") { + // A specialisation missing for any one of these silently reverts that + // channel to sizeof(T), which is exactly how this went unnoticed. + const Frame f = frame_with_image(320, 240); + const std::size_t img = 320u * 240u * 3u; + + SceneFrame sf; sf.source = f; + AlignedSceneFrame af; af.source = f; + EmbeddedSceneFrame ef; ef.source = f; + TrackedSceneFrame tf; tf.source = f; + MatchedSceneFrame mf; mf.source = f; + + CHECK(kpn::ChannelDataSize::bytes(sf) >= img); + CHECK(kpn::ChannelDataSize::bytes(af) >= img); + CHECK(kpn::ChannelDataSize::bytes(ef) >= img); + CHECK(kpn::ChannelDataSize::bytes(tf) >= img); + CHECK(kpn::ChannelDataSize::bytes(mf) >= img); + + // SceneAnnotation carries no source frame — only the actors it identified, + // each with its own crop. + SceneAnnotation sa; + sa.visible_actors.push_back({}); + sa.visible_actors.back().crop = cv::Mat(112, 112, CV_8UC3, cv::Scalar(0, 0, 0)); + CHECK(kpn::ChannelDataSize::bytes(sa) >= 112u * 112u * 3u); +} + +TEST_CASE("a shared image is charged to each message holding it", "[channel_bytes]") { + // cv::Mat is reference-counted, so a frame referenced from several messages + // is counted once per reference. The sum is an upper bound on distinct + // bytes, and the right bound for "what would this channel keep alive if + // nothing else held it" — which is the question a capacity answers. + const Frame f = frame_with_image(320, 240); + SceneFrame a; a.source = f; + SceneFrame b; b.source = f; // shares the same pixel buffer + + CHECK(kpn::ChannelDataSize::bytes(a) + == kpn::ChannelDataSize::bytes(b)); +}