The CPU path was a scalar triple loop. It is the correctness oracle for the GPU backends, but it is also what CI runs — there is no GPU on the N100 host — and since AR-003 removed the per-frame face cap, a crowded frame now scores many faces against a library-scale gallery. Scoring one face against 5000 embeddings is 2.6 MFLOP; in scalar that does not hold up (AR-027). S(g,f) viewed as row-major [n_faces x n_gallery] is exactly query * gallery^T, so the loop nest collapses into a single cblas_sgemm. OpenBLAS is optional in the build: found via pkg-config, and the scalar path remains when it is absent so no hard dependency is added and the two can be diffed when a similarity looks wrong. The configure step warns rather than failing, since a developer without it should still get a working tree. The test target links it too. Without that the suite compiles the scalar fallback while the builder image ships CBLAS, so CI would be verifying a kernel that is not the one running in production — the same class of mistake as testing a path the gate never executes. Recorded as required (not optional) in the DP-007 image, for the same reason. Suite: 92 cases, 6136 assertions, with CBLAS compiled in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: AR-026, AR-027, DP-007 | SR-001
272 lines
12 KiB
C++
272 lines
12 KiB
C++
// ── Gallery GEMM backend ──────────────────────────────────────────────────────
|
||
// Similarity engine for the identity matcher. Uploads the reference gallery
|
||
// once and computes the per-frame similarity matrix with a single SGEMM. The
|
||
// math backend is selected at compile time by CMake (SAE_GEMM_BACKEND):
|
||
// cuBLAS/CUDA, rocBLAS/HIP, or a portable CPU reference (SAE_GEMM_CPU). The two
|
||
// GPU paths are the ONLY translation units that include cublas/cuda or
|
||
// rocblas/hip headers; the CPU path pulls in no GPU headers at all and exists so
|
||
// the pipeline can build and be tested on a machine without a GPU (CI).
|
||
|
||
#include "inference/similarity.hpp"
|
||
|
||
#if defined(SAE_GEMM_CUDA)
|
||
#include <cublas_v2.h>
|
||
#include <cuda_runtime_api.h>
|
||
#elif defined(SAE_GEMM_ROCM)
|
||
#include <hip/hip_runtime.h>
|
||
#include <rocblas/rocblas.h>
|
||
#elif defined(SAE_GEMM_CPU)
|
||
// no external headers — portable reference implementation below
|
||
#else
|
||
#error "gemm_backend.cpp requires SAE_GEMM_CUDA, SAE_GEMM_ROCM or SAE_GEMM_CPU to be defined"
|
||
#endif
|
||
|
||
#include <cstring>
|
||
#include <iostream>
|
||
#include <memory>
|
||
#include <stdexcept>
|
||
#include <string>
|
||
#include <vector>
|
||
|
||
namespace {
|
||
|
||
constexpr int kDim = 512;
|
||
|
||
#if defined(SAE_GEMM_CPU)
|
||
|
||
#if defined(SAE_GEMM_CBLAS)
|
||
#include <cblas.h>
|
||
#endif
|
||
|
||
// ── CPU reference engine ──────────────────────────────────────────────────────
|
||
// 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
|
||
// 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 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.
|
||
class SimilarityEngine final : public ISimilarityEngine {
|
||
public:
|
||
SimilarityEngine(const float* gallery_row_major, int n_gallery, int max_faces)
|
||
: n_gallery_(n_gallery), max_faces_(max_faces),
|
||
gallery_(gallery_row_major,
|
||
gallery_row_major + static_cast<size_t>(n_gallery) * kDim)
|
||
{
|
||
host_sims_.resize(static_cast<size_t>(max_faces_) * n_gallery_);
|
||
std::cerr << "[similarity] CPU engine ("
|
||
#if defined(SAE_GEMM_CBLAS)
|
||
<< "CBLAS"
|
||
#else
|
||
<< "scalar fallback — no CBLAS; expect poor scaling on a large gallery"
|
||
#endif
|
||
<< "): gallery resident in host RAM ("
|
||
<< (gallery_.size() * sizeof(float)) / (1024 * 1024) << " MiB)\n";
|
||
}
|
||
|
||
int max_faces() const override { return max_faces_; }
|
||
|
||
const float* compute(const float* query_row_major, int n_faces) override {
|
||
if (n_faces <= 0) return host_sims_.data();
|
||
if (n_faces > max_faces_)
|
||
throw std::runtime_error("SimilarityEngine: n_faces exceeds max_faces");
|
||
|
||
// S(g, f) col-major = dot(gallery[g], query[f]). Viewed as row-major
|
||
// [n_faces x n_gallery] that is exactly query * gallery^T, so it is one
|
||
// GEMM rather than a loop nest.
|
||
#if defined(SAE_GEMM_CBLAS)
|
||
cblas_sgemm(CblasRowMajor, CblasNoTrans, CblasTrans,
|
||
/*M=*/n_faces, /*N=*/n_gallery_, /*K=*/kDim,
|
||
/*alpha=*/1.0f,
|
||
query_row_major, /*lda=*/kDim,
|
||
gallery_.data(), /*ldb=*/kDim,
|
||
/*beta=*/0.0f,
|
||
host_sims_.data(), /*ldc=*/n_gallery_);
|
||
#else
|
||
for (int f = 0; f < n_faces; ++f) {
|
||
const float* q = query_row_major + static_cast<size_t>(f) * kDim;
|
||
float* out = host_sims_.data() + static_cast<size_t>(f) * n_gallery_;
|
||
for (int g = 0; g < n_gallery_; ++g) {
|
||
const float* row = gallery_.data() + static_cast<size_t>(g) * kDim;
|
||
float acc = 0.f;
|
||
for (int d = 0; d < kDim; ++d) acc += row[d] * q[d];
|
||
out[g] = acc;
|
||
}
|
||
}
|
||
#endif
|
||
return host_sims_.data();
|
||
}
|
||
|
||
private:
|
||
int n_gallery_{0};
|
||
int max_faces_{0};
|
||
std::vector<float> gallery_; // n_gallery × 512, row-major
|
||
std::vector<float> host_sims_; // max_faces × n_gallery, column-major
|
||
};
|
||
|
||
} // namespace
|
||
|
||
std::unique_ptr<ISimilarityEngine> make_similarity_engine(
|
||
const float* gallery_row_major, int n_gallery, int max_faces) {
|
||
return std::make_unique<SimilarityEngine>(gallery_row_major, n_gallery, max_faces);
|
||
}
|
||
|
||
#else // GPU backends (CUDA / ROCM)
|
||
|
||
struct GpuError : std::runtime_error {
|
||
using std::runtime_error::runtime_error;
|
||
};
|
||
|
||
#if defined(SAE_GEMM_CUDA)
|
||
|
||
using stream_t = cudaStream_t;
|
||
using blas_handle_t = cublasHandle_t;
|
||
|
||
inline void check_gpu(cudaError_t e, const char* what) {
|
||
if (e != cudaSuccess)
|
||
throw GpuError(std::string(what) + ": " + cudaGetErrorString(e));
|
||
}
|
||
inline void check_blas(cublasStatus_t s, const char* what) {
|
||
if (s != CUBLAS_STATUS_SUCCESS)
|
||
throw GpuError(std::string(what) + ": cublas error " + std::to_string(s));
|
||
}
|
||
|
||
inline void gpu_malloc(void** p, size_t bytes) { check_gpu(cudaMalloc(p, bytes), "cudaMalloc"); }
|
||
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 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"); }
|
||
inline void blas_create(blas_handle_t* h) { check_blas(cublasCreate(h), "cublasCreate"); }
|
||
inline void blas_destroy(blas_handle_t h) { cublasDestroy(h); }
|
||
inline void blas_set_stream(blas_handle_t h, stream_t s) { check_blas(cublasSetStream(h, s), "cublasSetStream"); }
|
||
inline void blas_sgemm(blas_handle_t h, int m, int n, int k,
|
||
const float* A, const float* B, float* C) {
|
||
const float alpha = 1.f, beta = 0.f;
|
||
check_blas(cublasSgemm(h, CUBLAS_OP_T, CUBLAS_OP_N,
|
||
m, n, k, &alpha, A, k, B, k, &beta, C, m),
|
||
"cublasSgemm");
|
||
}
|
||
inline const char* backend_name() { return "cuBLAS/CUDA"; }
|
||
|
||
#else // SAE_GEMM_ROCM
|
||
|
||
using stream_t = hipStream_t;
|
||
using blas_handle_t = rocblas_handle;
|
||
|
||
inline void check_gpu(hipError_t e, const char* what) {
|
||
if (e != hipSuccess)
|
||
throw GpuError(std::string(what) + ": " + hipGetErrorString(e));
|
||
}
|
||
inline void check_blas(rocblas_status s, const char* what) {
|
||
if (s != rocblas_status_success)
|
||
throw GpuError(std::string(what) + ": rocblas error " + std::to_string(s));
|
||
}
|
||
|
||
inline void gpu_malloc(void** p, size_t bytes) { check_gpu(hipMalloc(p, bytes), "hipMalloc"); }
|
||
inline void gpu_free(void* p) { (void)hipFree(p); }
|
||
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 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"); }
|
||
inline void blas_create(blas_handle_t* h) { check_blas(rocblas_create_handle(h), "rocblas_create_handle"); }
|
||
inline void blas_destroy(blas_handle_t h) { rocblas_destroy_handle(h); }
|
||
inline void blas_set_stream(blas_handle_t h, stream_t s) { check_blas(rocblas_set_stream(h, s), "rocblas_set_stream"); }
|
||
inline void blas_sgemm(blas_handle_t h, int m, int n, int k,
|
||
const float* A, const float* B, float* C) {
|
||
const float alpha = 1.f, beta = 0.f;
|
||
// rocblas_sgemm is column-major; same transposition trick as cuBLAS:
|
||
// C(m×n) = A(k×m)^T * B(k×n) → S(N_gallery × n_faces) = G^T * Q
|
||
check_blas(rocblas_sgemm(h, rocblas_operation_transpose, rocblas_operation_none,
|
||
m, n, k, &alpha, A, k, B, k, &beta, C, m),
|
||
"rocblas_sgemm");
|
||
}
|
||
inline const char* backend_name() { return "rocBLAS/HIP"; }
|
||
|
||
#endif
|
||
|
||
class SimilarityEngine final : public ISimilarityEngine {
|
||
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));
|
||
|
||
stream_create(&stream_);
|
||
blas_create(&handle_);
|
||
blas_set_stream(handle_, stream_);
|
||
|
||
host_sims_.resize(static_cast<size_t>(max_faces_) * n_gallery_);
|
||
|
||
std::cerr << "[similarity] " << backend_name() << " engine: gallery resident on GPU ("
|
||
<< (gallery_floats * sizeof(float)) / (1024 * 1024) << " MiB)\n";
|
||
}
|
||
|
||
~SimilarityEngine() override {
|
||
if (d_gallery_) gpu_free(d_gallery_);
|
||
if (d_query_) gpu_free(d_query_);
|
||
if (d_sims_) gpu_free(d_sims_);
|
||
if (handle_) blas_destroy(handle_);
|
||
if (stream_) stream_destroy(stream_);
|
||
}
|
||
|
||
SimilarityEngine(const SimilarityEngine&) = delete;
|
||
SimilarityEngine& operator=(const SimilarityEngine&) = delete;
|
||
|
||
int max_faces() const override { return max_faces_; }
|
||
|
||
const float* compute(const float* query_row_major, int n_faces) override {
|
||
if (n_faces <= 0) return host_sims_.data();
|
||
if (n_faces > max_faces_)
|
||
throw std::runtime_error("SimilarityEngine: n_faces exceeds max_faces");
|
||
|
||
gpu_memcpy_h2d(d_query_, query_row_major,
|
||
static_cast<size_t>(n_faces) * kDim * sizeof(float), stream_);
|
||
|
||
// S (N_gallery × n_faces) col-major = G(512 × N_gallery)^T * Q(512 × n_faces)
|
||
blas_sgemm(handle_, n_gallery_, n_faces, kDim, d_gallery_, d_query_, d_sims_);
|
||
|
||
gpu_memcpy_d2h(host_sims_.data(), d_sims_,
|
||
static_cast<size_t>(n_gallery_) * n_faces * sizeof(float), stream_);
|
||
stream_sync(stream_);
|
||
return host_sims_.data();
|
||
}
|
||
|
||
private:
|
||
int n_gallery_{0};
|
||
int max_faces_{0};
|
||
float* d_gallery_{nullptr};
|
||
float* d_query_{nullptr};
|
||
float* d_sims_{nullptr};
|
||
std::vector<float> host_sims_;
|
||
stream_t stream_{};
|
||
blas_handle_t handle_{};
|
||
};
|
||
|
||
} // namespace
|
||
|
||
std::unique_ptr<ISimilarityEngine> make_similarity_engine(
|
||
const float* gallery_row_major, int n_gallery, int max_faces) {
|
||
return std::make_unique<SimilarityEngine>(gallery_row_major, n_gallery, max_faces);
|
||
}
|
||
|
||
#endif // SAE_GEMM_CPU / GPU backends
|