File size: 7,325 Bytes
bbb6388 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 | // src/kernels/bf16_gemv_parity.cpp - P2.S5's test for the BF16 GEMV.
//
// THREE THINGS ARE CHECKED, and the third is the one the engine's correctness rests on:
//
// 1. both kernels against a host reference that reads the SAME bf16 bits - so what is under test is the
// reduction, not the conversion, and the tolerance is set by summation order and nothing else;
// 2. the naive and the split kernel agree with each other, because the naive one is the reference the
// split one exists to be checked against and a disagreement there means one of them is wrong;
// 3. **THE ACTIVATION CONTRACT IS OBSERVABLE.** A BF16 weight sees a BF16 activation; feeding it fp16 or
// f32 instead is a 2^-9 = 1.95e-03 relative change, which is above any 1e-3 tolerance. An fp16
// activation is the rival reading, computed and required to differ, because it is what the engine did
// before `docs/activation-contract.md` settled the question and it produces perfectly plausible output.
#include "strata/kernels/bf16_bits.hpp"
#include "strata/kernels/bf16_gemv.hpp"
#include "strata/kernels/f16_bits.hpp"
#include <cuda_runtime.h>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <random>
#include <string>
#include <vector>
namespace {
using strata::kernels::bf16_from_f32;
using strata::kernels::f32_from_bf16;
void check(cudaError_t e, const char* what) {
if (e != cudaSuccess) {
std::fprintf(stderr, "%s: %s\n", what, cudaGetErrorString(e));
std::exit(1);
}
}
double rel_l1(const std::vector<float>& a, const std::vector<float>& b) {
double d = 0, m = 0;
for (size_t i = 0; i < a.size(); ++i) {
d += std::fabs((double) a[i] - (double) b[i]);
m += std::fabs((double) a[i]);
}
return d / (m > 1e-30 ? m : 1e-30);
}
/// The host reference, in DOUBLE, over the same bf16 bits.
void reference(const std::vector<uint16_t>& x, const std::vector<uint16_t>& w, long long n_in, long long n_out,
std::vector<float>& y) {
y.assign((size_t) n_out, 0.0f);
for (long long o = 0; o < n_out; ++o) {
double acc = 0;
for (long long i = 0; i < n_in; ++i)
acc += (double) f32_from_bf16(x[(size_t) i]) * (double) f32_from_bf16(w[(size_t) (o * n_in + i)]);
y[(size_t) o] = (float) acc;
}
}
} // namespace
int main(int argc, char** argv) {
bool selftest = false;
for (int i = 1; i < argc; ++i) {
if (std::string(argv[i]) == "--selftest") selftest = true;
else { std::fprintf(stderr, "usage: bf16_gemv_parity [--selftest]\n"); return 2; }
}
int bad = 0;
// THE REAL SHAPES, because a kernel that only works on a square toy is not a kernel this engine can use.
// `ssm_alpha` is [2560, 48] - a wide reduction and 48 outputs - and `indexer.k_proj` is [2560, 128].
struct Shape { long long n_in, n_out; const char* what; };
const Shape shapes[] = {
{2560, 48, "ssm_alpha.weight [2560, 48]"},
{2560, 128, "indexer.k_proj [2560, 128]"},
{2560, 512, "indexer.q_proj [2560, 512]"},
{2560, 2560, "ple_value [2560, 2560]"},
};
for (const Shape& s : shapes) {
std::mt19937 rng(4242);
std::normal_distribution<float> g(0.0f, 1.0f);
std::vector<float> fx((size_t) s.n_in), fw((size_t) (s.n_in * s.n_out));
for (auto& v : fx) v = g(rng);
// a realistic weight scale: these are projections, not embeddings
for (auto& v : fw) v = g(rng) * 0.05f;
// THE ACTIVATION IS ROUNDED TO BF16 FIRST, which is the contract, and the WEIGHT is already bf16 in
// the pack - the loaded bytes ARE the high 16 bits of an f32.
std::vector<uint16_t> x((size_t) s.n_in), w((size_t) (s.n_in * s.n_out));
for (size_t i = 0; i < fx.size(); ++i) x[i] = bf16_from_f32(fx[i]);
for (size_t i = 0; i < fw.size(); ++i) w[i] = bf16_from_f32(fw[i]);
std::vector<float> want;
reference(x, w, s.n_in, s.n_out, want);
uint16_t *d_x = nullptr, *d_w = nullptr;
float *d_y = nullptr;
check(cudaMalloc(&d_x, x.size() * 2), "x");
check(cudaMalloc(&d_w, w.size() * 2), "w");
check(cudaMalloc(&d_y, (size_t) s.n_out * 4), "y");
check(cudaMemcpy(d_x, x.data(), x.size() * 2, cudaMemcpyHostToDevice), "cx");
check(cudaMemcpy(d_w, w.data(), w.size() * 2, cudaMemcpyHostToDevice), "cw");
std::vector<float> naive((size_t) s.n_out), warp((size_t) s.n_out), split((size_t) s.n_out);
strata::kernels::bf16_gemv(d_x, d_w, d_y, s.n_in, s.n_out, nullptr);
check(cudaMemcpy(naive.data(), d_y, naive.size() * 4, cudaMemcpyDeviceToHost), "cy1");
strata::kernels::bf16_gemv_split(d_x, d_w, d_y, s.n_in, s.n_out, 32, nullptr);
check(cudaMemcpy(warp.data(), d_y, warp.size() * 4, cudaMemcpyDeviceToHost), "cy2");
strata::kernels::bf16_gemv_split(d_x, d_w, d_y, s.n_in, s.n_out, 256, nullptr);
check(cudaMemcpy(split.data(), d_y, split.size() * 4, cudaMemcpyDeviceToHost), "cy3");
const double rn = rel_l1(want, naive), rw = rel_l1(want, warp), rs = rel_l1(want, split);
std::printf(" %-34s naive %.2e warp %.2e split256 %.2e\n", s.what, rn, rw, rs);
// The products are EXACT - both sides are bf16-valued, so f32 represents each product exactly - and
// the only difference is the order the 2560 terms are added. A tight bound is therefore meaningful.
if (!(rn <= 1e-5 && rw <= 1e-5 && rs <= 1e-5)) { std::printf(" *** over 1e-5 ***\n"); ++bad; }
// ---- 3. THE ACTIVATION CONTRACT. The rival: an fp16 activation instead of bf16, which is what the
// engine did before the contract was settled. It must be observable, or check 1 above would pass
// against either and the contract would be untested.
{
std::vector<float> fp16_as_f32((size_t) s.n_in);
std::vector<uint16_t> x16((size_t) s.n_in);
for (size_t i = 0; i < fx.size(); ++i) {
x16[i] = strata::kernels::f16_from_f32(fx[i]);
fp16_as_f32[i] = strata::kernels::f32_from_f16(x16[i]);
}
// Round the fp16 value to bf16 - i.e. give the kernel a bf16 activation whose VALUES came through
// fp16. That is the whole difference between the two contracts at this call site.
for (size_t i = 0; i < fp16_as_f32.size(); ++i) x[i] = bf16_from_f32(fp16_as_f32[i]);
check(cudaMemcpy(d_x, x.data(), x.size() * 2, cudaMemcpyHostToDevice), "cx2");
std::vector<float> rival((size_t) s.n_out);
strata::kernels::bf16_gemv(d_x, d_w, d_y, s.n_in, s.n_out, nullptr);
check(cudaMemcpy(rival.data(), d_y, rival.size() * 4, cudaMemcpyDeviceToHost), "cy4");
const double r = rel_l1(want, rival);
const bool visible = r > 1e-4;
std::printf(" %-30s %s (%.4f%% apart)\n", "bf16 vs fp16 activation",
visible ? "yes" : "*** NO ***", r * 100);
if (!visible) ++bad;
}
cudaFree(d_x); cudaFree(d_w); cudaFree(d_y);
}
std::printf("\nbf16_gemv: %d failures\n", bad);
if (bad) return 1;
if (selftest) std::printf("bf16_gemv_parity OK\n");
return 0;
}
|