fix(AR-004): make the channel byte counter measure the payload
`kpn::ChannelDataSize<T>` is what a channel reports as bytes pushed, and its primary template returns `sizeof(T)`. It was never specialised in this repo — only in a KPN example — so every message type reported its header size. Each of them is a few vectors and a `cv::Mat` header owning megabytes on the heap, so a message carrying a full decoded frame was reported at roughly 200 bytes against 5.9 MB at 1080p. Four orders of magnitude. That is not a cosmetic stat. It is the one instrument for choosing channel capacities against a memory ceiling — the open half of AR-004 — and anyone who read the MB/s column to size a channel was reading fiction. The gap could not be measured with the tool that exists to measure it. Every message embeds `Frame source`, so this is not confined to the crop-carrying channels: the full decoded image rides the whole chain, and the byte figure now says so. Two things worth stating about what the number means. `cv::Mat` is reference-counted, so one frame referenced from several messages is counted once per reference — 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. And an eof sentinel carries no image, so it costs only its header and is not charged for one. Declared against a forward declaration of the primary template rather than by including <kpn/channel.hpp>, so the message definitions keep no dependency on the framework carrying them, and any translation unit that can see these types also sees their sizes — which is what stops one channel being instantiated with the default while another gets the specialisation. Verified in both directions: with the specialisations removed, three of the five cases fail, `SceneAnnotation` reporting 40 bytes against the 37,632 its single 112x112 crop occupies. 145/145 with them. No behaviour change — this only corrects what is reported. Choosing capacities against the corrected numbers is the next commit. TRACES: AR-004 | SR-002
This commit is contained in:
+108
@@ -184,3 +184,111 @@ struct ActorGallery {
|
|||||||
bool calib_valid{false};
|
bool calib_valid{false};
|
||||||
uint64_t calib_hash{0};
|
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<T>`. 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
|
||||||
|
/// `<kpn/channel.hpp>` 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<typename T> 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<cv::Mat>& 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<IdentifiedActor>& 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<Frame> {
|
||||||
|
static std::size_t bytes(const Frame& f) { return sae::bytes::of(f); }
|
||||||
|
};
|
||||||
|
|
||||||
|
template<> struct kpn::ChannelDataSize<SceneFrame> {
|
||||||
|
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<AlignedSceneFrame> {
|
||||||
|
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<EmbeddedSceneFrame> {
|
||||||
|
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<TrackedSceneFrame> {
|
||||||
|
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<MatchedSceneFrame> {
|
||||||
|
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<SceneAnnotation> {
|
||||||
|
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.
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ add_executable(sae_tests
|
|||||||
test_embedding_dump.cpp
|
test_embedding_dump.cpp
|
||||||
test_audio_signature.cpp
|
test_audio_signature.cpp
|
||||||
test_benchmark.cpp
|
test_benchmark.cpp
|
||||||
|
test_channel_bytes.cpp
|
||||||
${CMAKE_SOURCE_DIR}/src/backends/gemm_backend.cpp
|
${CMAKE_SOURCE_DIR}/src/backends/gemm_backend.cpp
|
||||||
${CMAKE_SOURCE_DIR}/src/gallery/gallery_store.cpp
|
${CMAKE_SOURCE_DIR}/src/gallery/gallery_store.cpp
|
||||||
${CMAKE_SOURCE_DIR}/src/audio_signature.cpp
|
${CMAKE_SOURCE_DIR}/src/audio_signature.cpp
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// Channel byte accounting for the pipeline message types.
|
||||||
|
//
|
||||||
|
// TRACES: AR-004 | SR-002
|
||||||
|
//
|
||||||
|
// kpn::ChannelDataSize<T> 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 <catch2/catch_test_macros.hpp>
|
||||||
|
|
||||||
|
#include <kpn/channel.hpp>
|
||||||
|
|
||||||
|
#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<Frame>::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<Frame>::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<EmbeddedSceneFrame>::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<EmbeddedSceneFrame>::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<SceneFrame>::bytes(sf) >= img);
|
||||||
|
CHECK(kpn::ChannelDataSize<AlignedSceneFrame>::bytes(af) >= img);
|
||||||
|
CHECK(kpn::ChannelDataSize<EmbeddedSceneFrame>::bytes(ef) >= img);
|
||||||
|
CHECK(kpn::ChannelDataSize<TrackedSceneFrame>::bytes(tf) >= img);
|
||||||
|
CHECK(kpn::ChannelDataSize<MatchedSceneFrame>::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<SceneAnnotation>::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<SceneFrame>::bytes(a)
|
||||||
|
== kpn::ChannelDataSize<SceneFrame>::bytes(b));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user