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
+79 -14
View File
@@ -42,17 +42,20 @@ constexpr int kDim = 512;
// Used for CI and as the correctness oracle for the GPU backends.
//
// TRACES: AR-026, AR-027 | SR-001
// Backed by CBLAS (OpenBLAS) when available, falling back to a scalar loop when
// not. The fallback is portable but scales badly: scoring one face against a
// Backed by CBLAS (OpenBLAS), which CMake now REQUIRES for this backend. The
// scalar loop below is portable but scales badly: scoring one face against a
// 5000-embedding gallery is 2.6 MFLOP, and a crowded frame multiplies that by
// the face count. Since AR-003 removed the per-frame face cap and CI has no GPU,
// the CPU path is now the one that has to hold up under a library-scale gallery
// (AR-027) rather than merely be correct.
// the CPU path is the one that has to hold up under a library-scale gallery
// (AR-027) rather than merely be correct — so falling back to it silently would
// mean measuring AR-027 on a path no release runs.
//
// The fallback is kept rather than made mandatory so the build has no hard new
// dependency, and so the two can be diffed when a similarity looks wrong. The gallery is L2-normalised (as are the queries), so each
// similarity is a plain dot product. S is stored column-major to match the GPU
// backends: the gallery similarities for face fi start at result + fi*n_gallery.
// The fallback is kept as the correctness oracle the two BLAS backends are
// diffed against when a similarity looks wrong, and is reachable only via
// -DSAE_ALLOW_SCALAR_GEMM=ON. The gallery is L2-normalised (as are the queries),
// so each similarity is a plain dot product. S is stored column-major to match
// the GPU backends: the gallery similarities for face fi start at
// result + fi*n_gallery().
class SimilarityEngine final : public ISimilarityEngine {
public:
SimilarityEngine(const float* gallery_row_major, int n_gallery, int max_faces)
@@ -72,6 +75,19 @@ public:
}
int max_faces() const override { return max_faces_; }
int n_gallery() const override { return n_gallery_; }
/// TRACES: AR-026 | SR-001
/// Promotions join the resident matrix, so the annex is scored by the same
/// SGEMM as the baked references. std::vector already grows geometrically,
/// so this is amortised O(1) per row.
void append_rows(const float* rows_row_major, int n_rows) override {
if (n_rows <= 0) return;
gallery_.insert(gallery_.end(), rows_row_major,
rows_row_major + static_cast<size_t>(n_rows) * kDim);
n_gallery_ += n_rows;
host_sims_.resize(static_cast<size_t>(max_faces_) * n_gallery_);
}
const float* compute(const float* query_row_major, int n_faces) override {
if (n_faces <= 0) return host_sims_.data();
@@ -143,6 +159,7 @@ inline void gpu_free(void* p) { cudaFree(p)
inline void gpu_memcpy_h2d(void* dst, const void* src, size_t n, stream_t s) { check_gpu(cudaMemcpyAsync(dst, src, n, cudaMemcpyHostToDevice, s), "H2D"); }
inline void gpu_memcpy_d2h(void* dst, const void* src, size_t n, stream_t s) { check_gpu(cudaMemcpyAsync(dst, src, n, cudaMemcpyDeviceToHost, s), "D2H"); }
inline void gpu_memcpy_h2d_sync(void* dst, const void* src, size_t n) { check_gpu(cudaMemcpy(dst, src, n, cudaMemcpyHostToDevice), "H2D_sync"); }
inline void gpu_memcpy_d2d_sync(void* dst, const void* src, size_t n) { check_gpu(cudaMemcpy(dst, src, n, cudaMemcpyDeviceToDevice), "D2D_sync"); }
inline void stream_create(stream_t* s) { check_gpu(cudaStreamCreate(s), "cudaStreamCreate"); }
inline void stream_destroy(stream_t s) { cudaStreamDestroy(s); }
inline void stream_sync(stream_t s) { check_gpu(cudaStreamSynchronize(s), "cudaStreamSync"); }
@@ -177,6 +194,7 @@ inline void gpu_free(void* p) { (void)hipFr
inline void gpu_memcpy_h2d(void* dst, const void* src, size_t n, stream_t s) { check_gpu(hipMemcpyAsync(dst, src, n, hipMemcpyHostToDevice, s), "H2D"); }
inline void gpu_memcpy_d2h(void* dst, const void* src, size_t n, stream_t s) { check_gpu(hipMemcpyAsync(dst, src, n, hipMemcpyDeviceToHost, s), "D2H"); }
inline void gpu_memcpy_h2d_sync(void* dst, const void* src, size_t n) { check_gpu(hipMemcpy(dst, src, n, hipMemcpyHostToDevice), "H2D_sync"); }
inline void gpu_memcpy_d2d_sync(void* dst, const void* src, size_t n) { check_gpu(hipMemcpy(dst, src, n, hipMemcpyDeviceToDevice), "D2D_sync"); }
inline void stream_create(stream_t* s) { check_gpu(hipStreamCreate(s), "hipStreamCreate"); }
inline void stream_destroy(stream_t s) { (void)hipStreamDestroy(s); }
inline void stream_sync(stream_t s) { check_gpu(hipStreamSynchronize(s), "hipStreamSync"); }
@@ -201,14 +219,15 @@ public:
SimilarityEngine(const float* gallery_row_major, int n_gallery, int max_faces)
: n_gallery_(n_gallery), max_faces_(max_faces)
{
const size_t gallery_floats = static_cast<size_t>(n_gallery_) * kDim;
gpu_malloc(reinterpret_cast<void**>(&d_gallery_), gallery_floats * sizeof(float));
gpu_memcpy_h2d_sync(d_gallery_, gallery_row_major, gallery_floats * sizeof(float));
gpu_malloc(reinterpret_cast<void**>(&d_query_),
static_cast<size_t>(max_faces_) * kDim * sizeof(float));
gpu_malloc(reinterpret_cast<void**>(&d_sims_),
static_cast<size_t>(max_faces_) * n_gallery_ * sizeof(float));
// Allocates d_gallery_/d_sims_ at the initial row count; append_rows()
// grows them geometrically from here.
reserve_rows(std::max(n_gallery_, 1));
const size_t gallery_floats = static_cast<size_t>(n_gallery_) * kDim;
if (gallery_floats)
gpu_memcpy_h2d_sync(d_gallery_, gallery_row_major, gallery_floats * sizeof(float));
stream_create(&stream_);
blas_create(&handle_);
@@ -232,6 +251,24 @@ public:
SimilarityEngine& operator=(const SimilarityEngine&) = delete;
int max_faces() const override { return max_faces_; }
int n_gallery() const override { return n_gallery_; }
/// TRACES: AR-026 | SR-001
/// Promotions join the GPU-resident matrix, so the annex is scored by the
/// same SGEMM as the baked references rather than by a host-side loop.
/// Capacity doubles on overflow, so the gallery is re-uploaded O(log n)
/// times over a film rather than once per promotion.
void append_rows(const float* rows_row_major, int n_rows) override {
if (n_rows <= 0) return;
const int want = n_gallery_ + n_rows;
if (want > capacity_) reserve_rows(std::max(want, capacity_ * 2));
gpu_memcpy_h2d_sync(d_gallery_ + static_cast<size_t>(n_gallery_) * kDim,
rows_row_major,
static_cast<size_t>(n_rows) * kDim * sizeof(float));
n_gallery_ = want;
host_sims_.resize(static_cast<size_t>(max_faces_) * n_gallery_);
}
const float* compute(const float* query_row_major, int n_faces) override {
if (n_faces <= 0) return host_sims_.data();
@@ -251,7 +288,35 @@ public:
}
private:
// Grow the resident gallery (and the similarity output sized against it) to
// `rows` capacity, preserving the n_gallery_ rows already there. The copy is
// device-to-device, so a promotion never re-uploads the baked gallery across
// the bus.
void reserve_rows(int rows) {
if (rows <= capacity_) return;
float* d_new_gallery = nullptr;
gpu_malloc(reinterpret_cast<void**>(&d_new_gallery),
static_cast<size_t>(rows) * kDim * sizeof(float));
if (d_gallery_ && n_gallery_ > 0)
gpu_memcpy_d2d_sync(d_new_gallery, d_gallery_,
static_cast<size_t>(n_gallery_) * kDim * sizeof(float));
if (d_gallery_) gpu_free(d_gallery_);
d_gallery_ = d_new_gallery;
// S is (capacity × n_faces); its contents are rewritten by every
// compute(), so this one is a plain reallocation with nothing to keep.
float* d_new_sims = nullptr;
gpu_malloc(reinterpret_cast<void**>(&d_new_sims),
static_cast<size_t>(max_faces_) * rows * sizeof(float));
if (d_sims_) gpu_free(d_sims_);
d_sims_ = d_new_sims;
capacity_ = rows;
}
int n_gallery_{0};
int capacity_{0};
int max_faces_{0};
float* d_gallery_{nullptr};
float* d_query_{nullptr};
+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};
};
+23 -2
View File
@@ -15,6 +15,15 @@
// by CMake (SAE_GEMM_BACKEND); backends/gemm_backend.cpp provides
// make_similarity_engine(). The core matcher node sees only this interface and
// holds no CUDA/HIP/BLAS headers.
//
// TRACES: AR-026 | SR-001
// The resident matrix GROWS. Per-film expansion (AR-018/AR-019) promotes new
// reference views mid-film, and those have to be scored by the same multiply as
// the baked references rather than by a side loop — "this set is small" is not
// an exception, because the annex grows with cast size and film length. Rows are
// therefore appended to the resident matrix and the next compute() covers baked
// and promoted references alike, in one GEMM. The deferred pass (AR-020) then
// inherits a single contiguous operand to score the TBI queue against.
struct ISimilarityEngine {
virtual ~ISimilarityEngine() = default;
@@ -22,11 +31,23 @@ struct ISimilarityEngine {
// Largest n_faces accepted by compute() per call (bounds GPU buffer sizes).
virtual int max_faces() const = 0;
// Rows currently resident: the baked gallery plus every appended promotion.
// This is compute()'s column stride, and it changes as rows are appended —
// read it per call rather than caching it across frames.
virtual int n_gallery() const = 0;
/// TRACES: AR-026 | SR-001
/// Append n_rows unit-norm embeddings (row-major, 512 floats each) to the
/// resident matrix. Amortised O(1) per row: capacity grows geometrically, so
/// a promotion does not re-upload the gallery. Invalidates any pointer
/// previously returned by compute().
virtual void append_rows(const float* rows_row_major, int n_rows) = 0;
// Compute similarities for n_faces query embeddings.
// query_row_major: n_faces × 512, row fi at query + fi*512.
// Returns a pointer to host memory holding S column-major: the gallery
// similarities for face fi start at result + fi*n_gallery. The pointer is
// owned by the engine and valid until the next compute() call.
// similarities for face fi start at result + fi*n_gallery(). The pointer is
// owned by the engine and valid until the next compute() or append_rows().
virtual const float* compute(const float* query_row_major, int n_faces) = 0;
};
+51 -11
View File
@@ -39,6 +39,13 @@
// uploaded and a single SGEMM computes the full similarity matrix in well under
// a millisecond. The GPU math backend (cuBLAS or rocBLAS) lives behind
// ISimilarityEngine (backends/gemm_backend.cpp) and is selected at compile time.
//
// TRACES: AR-026 | SR-001
// That resident matrix grows during a film: per-film expansion (AR-019) promotes
// pose-varied views, and they are APPENDED to it rather than scored separately,
// so one multiply covers baked and promoted references alike and best-of-N is a
// single pass over one similarity column. There is no second similarity path in
// this node to fall out of step with the first.
struct IdentityMatcherFunc {
static constexpr std::string_view label() { return "identity_matcher"; }
@@ -183,29 +190,30 @@ struct IdentityMatcherFunc {
tf.embeddings[base + k].data(), 512 * sizeof(float));
}
// S (N_gallery × chunk) col-major: face k's gallery sims at sims + k*n_gallery.
/// TRACES: AR-026 | SR-001
// One GEMM now covers baked references AND the per-film annex: promoted
// rows were appended to the engine's resident matrix, so they are just
// more gallery rows with an entry in flat_actor_. The annex used to be
// folded in afterwards by a host-side cosine loop, justified by "tens of
// embeddings" — an assumption AR-018/AR-019 retired, since every owned
// track promotes and the annex grows with cast size and film length.
//
// n_gallery() is read per frame, not cached: it grows as promotions land.
const int n_gal = sim_engine_->n_gallery();
const float* host_sims = sim_engine_->compute(host_query.data(), chunk);
for (int ci = 0; ci < chunk; ++ci) {
const int fi = base + ci;
const float* sims = host_sims + static_cast<size_t>(ci) * n_gallery_;
const float* sims = host_sims + static_cast<size_t>(ci) * n_gal;
std::vector<float> best_sim(gallery_.actors.size(),
-std::numeric_limits<float>::max());
for (int ei = 0; ei < n_gallery_; ++ei) {
for (int ei = 0; ei < n_gal; ++ei) {
float sim = sims[ei];
int ai = flat_actor_[ei];
if (sim > best_sim[ai]) best_sim[ai] = sim;
}
// Fold in the per-film annex (CPU-side, tens of embeddings). Promoted
// pose-varied views compete for best-of-N exactly like baked refs, so
// a face at a pose the gallery lacked can now win its true actor.
for (const auto& ae : track_gallery_.annex()) {
float sim = cosine_similarity(tf.embeddings[fi], ae.emb);
if (sim > best_sim[ae.actor_idx]) best_sim[ae.actor_idx] = sim;
}
int best_actor = -1;
int second_actor = -1;
float best_s = -std::numeric_limits<float>::max();
@@ -299,10 +307,34 @@ struct IdentityMatcherFunc {
}
} // chunk loop
absorb_promotions();
return {std::move(tf.source), std::move(actors)};
}
private:
/// TRACES: AR-026 | SR-001
/// Move rows promoted during this frame into the resident gallery matrix,
/// extending the actor mapping in lockstep so row i keeps naming the actor
/// at flat_actor_[i]. 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 is also the semantics the expansion store
/// documents — a promotion helps SUBSEQUENT frames, never the one that
/// produced it, so identification cannot depend on face order within a frame.
void absorb_promotions() {
if (!track_gallery_.enabled()) return;
pending_emb_.clear();
pending_actor_.clear();
const int n = track_gallery_.drain_promotions(pending_emb_, pending_actor_);
if (n == 0) return;
sim_engine_->append_rows(pending_emb_.data(), n);
flat_actor_.insert(flat_actor_.end(),
pending_actor_.begin(), pending_actor_.end());
n_gallery_ = sim_engine_->n_gallery();
}
ActorGallery gallery_;
GalleryCalibration cal_;
float prob_threshold_;
@@ -310,10 +342,18 @@ private:
float threshold_;
float ratio_;
float ratio_ceil_;
/// flat_emb_ is the BAKED reference set only — it is the calibration fit's
/// input (AR-023) and is not touched again after construction. flat_actor_,
/// by contrast, is the actor mapping parallel to the *engine's* rows, so it
/// grows with every promotion absorbed (AR-026) and is the longer of the two.
std::vector<Embedding> flat_emb_;
std::vector<int> flat_actor_;
int n_gallery_{0};
// Reused across frames so absorbing a promotion allocates nothing.
std::vector<float> pending_emb_;
std::vector<int> pending_actor_;
std::unique_ptr<ISimilarityEngine> sim_engine_;
TrackGallery track_gallery_;
std::shared_ptr<TrackRegistry> registry_;