// ── 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 #include #elif defined(SAE_GEMM_ROCM) #include #include #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 #include #include #include #include #include namespace { constexpr int kDim = 512; #if defined(SAE_GEMM_CPU) #if defined(SAE_GEMM_CBLAS) #include #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), 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 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 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) : n_gallery_(n_gallery), max_faces_(max_faces), gallery_(gallery_row_major, gallery_row_major + static_cast(n_gallery) * kDim) { host_sims_.resize(static_cast(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_; } 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(n_rows) * kDim); n_gallery_ += n_rows; host_sims_.resize(static_cast(max_faces_) * n_gallery_); } 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(f) * kDim; float* out = host_sims_.data() + static_cast(f) * n_gallery_; for (int g = 0; g < n_gallery_; ++g) { const float* row = gallery_.data() + static_cast(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 gallery_; // n_gallery × 512, row-major std::vector host_sims_; // max_faces × n_gallery, column-major }; } // namespace std::unique_ptr make_similarity_engine( const float* gallery_row_major, int n_gallery, int max_faces) { return std::make_unique(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 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"); } 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 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"); } 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) { gpu_malloc(reinterpret_cast(&d_query_), static_cast(max_faces_) * kDim * 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(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_); blas_set_stream(handle_, stream_); host_sims_.resize(static_cast(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_; } 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(n_gallery_) * kDim, rows_row_major, static_cast(n_rows) * kDim * sizeof(float)); n_gallery_ = want; host_sims_.resize(static_cast(max_faces_) * n_gallery_); } 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(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(n_gallery_) * n_faces * sizeof(float), stream_); stream_sync(stream_); return host_sims_.data(); } 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(&d_new_gallery), static_cast(rows) * kDim * sizeof(float)); if (d_gallery_ && n_gallery_ > 0) gpu_memcpy_d2d_sync(d_new_gallery, d_gallery_, static_cast(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(&d_new_sims), static_cast(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}; float* d_sims_{nullptr}; std::vector host_sims_; stream_t stream_{}; blas_handle_t handle_{}; }; } // namespace std::unique_ptr make_similarity_engine( const float* gallery_row_major, int n_gallery, int max_faces) { return std::make_unique(gallery_row_major, n_gallery, max_faces); } #endif // SAE_GEMM_CPU / GPU backends