feat(gemm): the annex is a matrix, not a list — scored by the same GEMM

The per-film annex was folded in after the gallery multiply by a host-side
cosine loop over a vector of {embedding, actor} structs, justified in-comment
by "tens of embeddings". AR-018/AR-019 retired that assumption: every owned
track promotes, so the annex grows with cast size and film length.

TrackGallery now holds it as a contiguous row-major matrix with a parallel
actor index — the flat_emb_/flat_actor_ shape the baked gallery already uses —
and hands newly promoted rows to the matcher once per frame. The matcher pushes
them into the similarity engine's resident matrix through a new
ISimilarityEngine::append_rows, so one SGEMM covers baked and promoted
references alike and best-of-N is a single pass over one similarity column.
Capacity doubles on overflow, and the GPU backends grow device-to-device, so a
promotion never re-uploads the gallery across the bus.

Absorbing promotions runs once per frame, after every face has been scored.
Appending mid-frame would invalidate the similarity pointer the chunk loop is
still reading, and it also removes an incidental dependence on face order
within a frame — a promotion helps subsequent frames, never the one that
produced it, which is the semantics the expansion store already documented.

OpenBLAS becomes a requirement of the CPU GEMM backend rather than an
opportunistic upgrade. That path is what CI and the cpu builder image run, so
falling back to the scalar loop in silence meant AR-027 could be measured — or
believed — on a kernel no release uses. The loop survives as the correctness
oracle the BLAS backends are diffed against, behind SAE_ALLOW_SCALAR_GEMM.

Call site 3, the deferred TBI pass, is untouched: it does not exist until
AR-020, so AR-026 stays In Progress.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

TRACES: AR-026 | UT-004, UT-005 | SR-001
This commit is contained in:
2026-08-04 21:20:31 +02:00
co-authored by Claude Opus 5
parent f33403fff8
commit c1155cb607
11 changed files with 461 additions and 81 deletions
+57 -17
View File
@@ -46,18 +46,19 @@
// cosines this replaces, expand_novelty_sim and expand_track_spread_max, are
// retired under AR-024.
//
// The annex is CPU-side and in-memory: it is small (tens of embeddings) so the
// matcher scans it with a scalar loop, and it is discarded when the process
// exits. Promoted embeddings only help SUBSEQUENT frames and later tracks of A —
// the pipeline stays streaming, no emitted output is buffered or relabelled.
// TRACES: AR-026 | SR-001
// The annex is in-memory and discarded when the process exits, but it is NOT
// small: every owned track contributes, so it grows with cast size and film
// length. It is therefore held as a contiguous row-major matrix with a parallel
// actor index — the same flat_emb_/flat_actor_ shape the baked gallery uses —
// and the matcher hands promoted rows to the similarity engine rather than
// scanning them with a host-side loop. The deferred pass (AR-020) needs the same
// contiguous operand to score the TBI queue against in one multiply.
//
// Promoted embeddings only help SUBSEQUENT frames and later tracks of A — the
// pipeline stays streaming, no emitted output is buffered or relabelled.
struct TrackGallery {
// One promoted reference view held in the per-actor annex.
struct AnnexEntry {
Embedding emb;
int actor_idx{-1};
};
explicit TrackGallery(const Config& cfg)
: enabled_(cfg.expand_gallery)
, buffer_size_(std::max(1, cfg.expand_buffer_size))
@@ -80,10 +81,39 @@ struct TrackGallery {
bool enabled() const { return enabled_; }
// Current annex contents (empty when disabled). The matcher scans these
// alongside the baked gallery so a promoted view can win best-of-N for its
// actor. Returned by const-ref; only grows, never reordered.
const std::vector<AnnexEntry>& annex() const { return annex_; }
/// TRACES: AR-026 | SR-001
/// The annex as a contiguous row-major matrix (annex_size() × 512) plus the
/// parallel actor index. Only ever grows, never reordered, so a row index is
/// stable for the life of the film — which is what lets the similarity
/// engine hold the same rows and the actor mapping stay a plain vector.
int annex_size() const { return static_cast<int>(annex_actor_.size()); }
const float* annex_data() const { return annex_emb_.data(); }
const std::vector<int>& annex_actors() const { return annex_actor_; }
/// One annex row (512 floats). The deferred pass (AR-020) scores the whole
/// matrix at once via annex_data(); this is for inspecting a single view.
const float* annex_row(int i) const {
return annex_emb_.data() + static_cast<size_t>(i) * kEmbDim;
}
/// TRACES: AR-026 | SR-001
/// Hand the caller every row promoted since the previous call, appending to
/// its buffers, and return how many. The matcher pushes these into the
/// similarity engine so the next frame's single GEMM covers the annex —
/// draining rather than re-reading the whole matrix keeps that O(promoted),
/// not O(annex), per frame.
int drain_promotions(std::vector<float>& emb_out, std::vector<int>& actor_out) {
const int pending = annex_size() - drained_;
if (pending <= 0) return 0;
emb_out.insert(emb_out.end(),
annex_emb_.begin() + static_cast<size_t>(drained_) * kEmbDim,
annex_emb_.end());
actor_out.insert(actor_out.end(),
annex_actor_.begin() + drained_, annex_actor_.end());
drained_ = annex_size();
return pending;
}
// Offer one observed face to its track's diversity buffer.
// track_id : face_tracker track (1 = untracked, ignored)
@@ -245,7 +275,10 @@ private:
int added = 0;
for (const auto& be : ts.buf) {
annex_.push_back({be.emb, actor});
// Row-major append: the matrix stays contiguous so the matcher can
// hand whole blocks of new rows to the GEMM path (AR-026).
annex_emb_.insert(annex_emb_.end(), be.emb.begin(), be.emb.end());
annex_actor_.push_back(actor);
if (!debug_dir_.empty() && !be.crop.empty())
dump_mugshot(track_id, actor, added, be);
++added;
@@ -255,7 +288,7 @@ private:
<< " confirmed actor " << actor
<< " (" << ts.accepted_frames << " accepted frames, worst "
<< "pairwise P=" << worst << ") — promoted " << added
<< " views; annex now " << annex_.size() << "\n";
<< " views; annex now " << annex_size() << "\n";
}
/// Prefer the registry's verdict; fall back to the local tally only when no
@@ -320,5 +353,12 @@ private:
std::string debug_dir_;
std::map<int, TrackState> tracks_;
std::vector<AnnexEntry> annex_;
/// TRACES: AR-026 | SR-001
/// Contiguous annex matrix and its parallel actor index. `drained_` marks
/// how much of it the similarity engine already holds.
static constexpr int kEmbDim = 512;
std::vector<float> annex_emb_; ///< annex_size() × 512, row-major
std::vector<int> annex_actor_; ///< actor index per annex row
int drained_{0};
};