Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,12 @@ recurrence), and the shared expert resident in VRAM; routed-expert dispatch
auto-selects between an SLRU GPU cache and CPU mmap reads based on what
fraction of experts can be cached at boot. Override with `SHARPI_CPU_MOE=0|1`.

On hybrid GDN paths the GPU KV cache stores bf16 by default (`SHARPI_KV_DTYPE
=bf16`, see issue #27); arithmetic still runs in fp32 (bf16→fp32 promotion on
the read). The 2× cache shrink frees ~128 MiB at ctx=4096 — enough to fit one
extra dense-FFN layer on a 12 GB card, lifting Qwen3.6-27B-MTP `--temp 0` decode
from 4.3 → 6.4 t/s. Override with `SHARPI_KV_DTYPE=fp32` to bisect precision.

On Ampere+ (sm_80+) the CUDA backend auto-selects bf16 cuBLAS GEMM, which
matches fp32 for almost all workloads. `SHARPI_CUDA_PRECISION=fp32|fp16|bf16|fp8`
overrides the compute type — handy for bisecting whether an output divergence
Expand Down
82 changes: 80 additions & 2 deletions src/SharpInference.Cuda/CudaBackend.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ public sealed unsafe class CudaBackend : IComputeBackend, IImageOpsBackend, IDis
private nint _sigmoidMulInPlaceKernel;
private nint _splitQgKernel;
private nint _kvAppendKernel;
// Issue #27: bf16-store KV cache for hybrid GDN models. Halves cache VRAM
// vs the fp32 ring; arithmetic still happens in fp32 (the bf16 → fp32
// promotion is done at the kernel read sites).
private nint _kvAppendBf16Kernel;
private nint _attentionBf16Kernel;
private nint _embedLookupF32Kernel;
private nint _embedLookupQ4KKernel;
private nint _embedLookupQ5KKernel;
Expand Down Expand Up @@ -1368,6 +1373,65 @@ public void Attention(Tensor q, Tensor kCache, Tensor vCache, Tensor output,
if (r != 0) throw new InvalidOperationException($"cuLaunchKernel(attention) failed: {r}");
}

/// <summary>
/// Bf16-store variant of <see cref="KvAppend"/>. Inputs stay fp32; the K/V
/// cache tensors must be <see cref="DType.BFloat16"/>-allocated (half the
/// element count of an fp32 cache). See issue #27.
/// </summary>
public void KvAppendBf16(Tensor kInput, Tensor vInput, Tensor kCache, Tensor vCache,
int kvDim, int position, int maxSeqLen)
{
EnsureImageKernels();
if (!_imageKernelsAvailable)
throw new NotSupportedException("NVRTC kernels are not available.");

nint kPtr = GetDevPtr(kInput);
nint vPtr = GetDevPtr(vInput);
nint kcP = GetDevPtr(kCache);
nint vcP = GetDevPtr(vCache);
int pKD = kvDim, pPos = position, pMSL = maxSeqLen;
nint* args = stackalloc nint[7]
{
(nint)(&kPtr), (nint)(&vPtr),
(nint)(&kcP), (nint)(&vcP),
(nint)(&pKD), (nint)(&pPos), (nint)(&pMSL)
};
uint grid = (uint)((kvDim + 255) / 256);
int r = NvrtcInterop.LaunchKernel(_kvAppendBf16Kernel, grid, 1, 1, 256, 1, 1, 0, _stream, args, null);
if (r != 0) throw new InvalidOperationException($"cuLaunchKernel(kv_append_bf16) failed: {r}");
}

/// <summary>
/// Bf16-read variant of <see cref="Attention"/>. K/V cache tensors must be
/// <see cref="DType.BFloat16"/>; query, output, and the score scratch stay
/// fp32. Arithmetic precision matches the fp32 kernel — only the cache
/// footprint changes.
/// </summary>
public void AttentionBf16(Tensor q, Tensor kCache, Tensor vCache, Tensor output,
Tensor? scoresScratch,
int numHeads, int numKvHeads, int headDim, int seqLen, int maxSeqLen)
{
EnsureImageKernels();
if (!_imageKernelsAvailable)
throw new NotSupportedException("NVRTC kernels are not available.");

nint qP = GetDevPtr(q);
nint kP = GetDevPtr(kCache);
nint vP = GetDevPtr(vCache);
nint oP = GetDevPtr(output);
nint ssP = scoresScratch is { } sv ? GetDevPtr(sv) : nint.Zero;
int pNH = numHeads, pNKV = numKvHeads, pHD = headDim, pSL = seqLen, pMSL = maxSeqLen;
nint* args = stackalloc nint[10]
{
(nint)(&qP), (nint)(&kP), (nint)(&vP), (nint)(&oP),
(nint)(&ssP),
(nint)(&pNH), (nint)(&pNKV), (nint)(&pHD),
(nint)(&pSL), (nint)(&pMSL)
};
int r = NvrtcInterop.LaunchKernel(_attentionBf16Kernel, (uint)numHeads, 1, 1, 256, 1, 1, 0, _stream, args, null);
if (r != 0) throw new InvalidOperationException($"cuLaunchKernel(attention_bf16) failed: {r}");
}

// ================================================================
// TurboQuant KV-cache compression
// ================================================================
Expand Down Expand Up @@ -1549,13 +1613,24 @@ public void EmbedLookupQ5K(Tensor embTable, Tensor output, int tokenId, int embD
}

/// <summary>Set every element of <paramref name="dst"/> to zero.</summary>
/// <remarks>
/// The kernel writes fp32-sized lanes; for sub-fp32 dtypes (e.g. BFloat16)
/// the byte count is converted to a 4-byte lane count so we don't overrun
/// the underlying buffer. For non-fp32 element sizes that aren't a multiple
/// of 4 bytes, callers should instead use a memset path (none exposed yet).
/// </remarks>
public void Clear(Tensor dst)
{
EnsureImageKernels();
if (!_imageKernelsAvailable)
throw new NotSupportedException("NVRTC kernels are not available.");

int n = (int)dst.ElementCount;
long byteCount = dst.ElementCount * DTypeInfo.BytesPerElement(dst.DType);
if ((byteCount & 3) != 0)
throw new InvalidOperationException(
$"Clear: dst byte count ({byteCount}) is not a multiple of 4; " +
"dtype-aware memset path is not implemented yet.");
int n = (int)(byteCount >> 2);
nint dP = GetDevPtr(dst);
int pN = n;
nint* args = stackalloc nint[2] { (nint)(&dP), (nint)(&pN) };
Expand Down Expand Up @@ -1950,10 +2025,11 @@ private void ForceEagerJit()
_rmsNormKernel, _headNormKernel, _headNormPureKernel, _siluMulKernel, _sigmoidKernel,
_softmaxKernel, _ropeInterleavedKernel, _ropeNeoxKernel, _ropeNeoxPartialKernel,
_mulKernel, _sigmoidMulInPlaceKernel, _splitQgKernel, _kvAppendKernel,
_kvAppendBf16Kernel,
_embedLookupF32Kernel, _embedLookupQ4KKernel, _embedLookupQ5KKernel,
_matvecF32Kernel, _matvecQ4KKernel, _matvecQ5KKernel, _matvecQ6KKernel,
_matvecF32N2Kernel, _matvecQ4KN2Kernel, _matvecQ5KN2Kernel, _matvecQ6KN2Kernel,
_attentionKernel, _clearF32Kernel, _quantizeQ81Kernel,
_attentionKernel, _attentionBf16Kernel, _clearF32Kernel, _quantizeQ81Kernel,
_tqRotateQueryKernel, _tqKvAppendKernel, _tqAttentionKernel,
_siluInplaceKernel, _gdnConv1dDecodeKernel, _gdnL2NormPerHeadKernel,
_gdnTileHeadsKernel, _gdnRecurrenceDecodeKernel,
Expand Down Expand Up @@ -1992,6 +2068,7 @@ private void LoadKernelFunctions()
_sigmoidMulInPlaceKernel = GetKernelFunc("llm_sigmoid_mul_inplace");
_splitQgKernel = GetKernelFunc("llm_split_qg");
_kvAppendKernel = GetKernelFunc("llm_kv_append");
_kvAppendBf16Kernel = GetKernelFunc("llm_kv_append_bf16");
_embedLookupF32Kernel = GetKernelFunc("llm_embed_lookup_f32");
_embedLookupQ4KKernel = GetKernelFunc("llm_embed_lookup_q4k");
_embedLookupQ5KKernel = GetKernelFunc("llm_embed_lookup_q5k");
Expand All @@ -2004,6 +2081,7 @@ private void LoadKernelFunctions()
_matvecQ5KN2Kernel = GetKernelFunc("llm_matvec_q5k_n2");
_matvecQ6KN2Kernel = GetKernelFunc("llm_matvec_q6k_n2");
_attentionKernel = GetKernelFunc("llm_attention");
_attentionBf16Kernel = GetKernelFunc("llm_attention_bf16");
_clearF32Kernel = GetKernelFunc("llm_clear_f32");
_quantizeQ81Kernel = GetKernelFunc("llm_quantize_q8_1");
_bwBaselineKernel = GetKernelFunc("llm_bw_baseline");
Expand Down
137 changes: 137 additions & 0 deletions src/SharpInference.Cuda/CudaTextKernels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,28 @@ __device__ __forceinline__ unsigned int sharpi_fp32_to_fp16(float f)
return (unsigned int)h;
}

// ── BF16 ↔ FP32 (no header) ───────────────────────────────────────────────
// bfloat16 is just the high 16 bits of an IEEE-754 fp32. Decode = left shift;
// encode = round-to-nearest-even on bit 15 of the discarded mantissa half.
// We don't bother special-casing NaN — the KV cache is written from RoPE'd
// activations that are never NaN under normal operation, and any NaN that
// does appear will round-trip as a NaN (sign/exponent bits survive) which is
// the same outcome as fp32.
__device__ __forceinline__ float sharpi_bf16_to_fp32(unsigned int bits)
{
unsigned int f = (bits & 0xFFFFu) << 16;
return __int_as_float(f);
}

__device__ __forceinline__ unsigned int sharpi_fp32_to_bf16(float f)
{
unsigned int bits = __float_as_uint(f);
unsigned int lsb = (bits >> 16) & 1u;
unsigned int rb = 0x7FFFu + lsb;
bits += rb;
return (bits >> 16) & 0xFFFFu;
}

// Read one byte from a uint32-stride buffer at absolute byte offset B.
__device__ __forceinline__ unsigned int sharpi_byte_at(const unsigned int* __restrict__ buf, long B)
{
Expand Down Expand Up @@ -390,6 +412,25 @@ __device__ __forceinline__ int sharpi_int8_at(const unsigned int* __restrict__ b
v_cache[offset] = v_in[i];
}

// ── KV cache append (bf16 store) ───────────────────────────────────────────
// FP32 K/V activations in → bf16 K/V cache out. Halves the KV-cache footprint
// at the cost of one fp32→bf16 conversion per element on the write. Read-side
// recovery happens in `llm_attention_bf16`. Used by `CudaHybridGdnForwardPass`
// for the hybrid GDN models (qwen35, qwen35moe, qwen35-MTP) — see issue #27.
extern ""C"" __global__ void llm_kv_append_bf16(
const float* __restrict__ k_in,
const float* __restrict__ v_in,
unsigned short* __restrict__ k_cache,
unsigned short* __restrict__ v_cache,
int kv_dim, int position, int max_seq_len)
{
int i = (int)(blockIdx.x * blockDim.x + threadIdx.x);
if (i >= kv_dim) return;
long offset = (long)position * (long)kv_dim + (long)i;
k_cache[offset] = (unsigned short)sharpi_fp32_to_bf16(k_in[i]);
v_cache[offset] = (unsigned short)sharpi_fp32_to_bf16(v_in[i]);
}

// ── Embedding lookup (F32 table) ───────────────────────────────────────────
extern ""C"" __global__ void llm_embed_lookup_f32(
const float* __restrict__ emb_table,
Expand Down Expand Up @@ -1798,6 +1839,102 @@ __device__ __forceinline__ int sharpi_int8_at(const unsigned int* __restrict__ b
}
}

// ── Scaled dot-product attention with GQA (bf16 K/V cache) ─────────────────
// Bit-for-bit copy of `llm_attention` except K/V cache is read as bfloat16
// (stored as raw unsigned short, decoded via sharpi_bf16_to_fp32). Score
// scratch, query, and output stay fp32; softmax accumulates in fp32 too.
// Bf16 → fp32 promotion happens at the dot/weighted-sum read points, so all
// arithmetic precision (and overflow head-room) matches the fp32 kernel —
// only the cache footprint is halved. See issue #27.
extern ""C"" __global__ void llm_attention_bf16(
const float* __restrict__ q,
const unsigned short* __restrict__ k_cache,
const unsigned short* __restrict__ v_cache,
float* __restrict__ out,
float* __restrict__ scores_scratch,
int num_heads, int num_kv_heads, int head_dim,
int seq_len, int max_seq_len)
{
const int MAX_STORED_SCORES = 4096;
__shared__ float shared_scores[MAX_STORED_SCORES];
__shared__ float sdata[256];

unsigned int tid = threadIdx.x;
unsigned int h = blockIdx.x;
if ((int)h >= num_heads) return;

int kv_head = (int)h / (num_heads / num_kv_heads);
int kv_dim = num_kv_heads * head_dim;
float scale = rsqrtf((float)head_dim);
long q_off = (long)h * (long)head_dim;
long out_off = q_off;

bool use_shared = (seq_len <= MAX_STORED_SCORES);
float* head_scratch = scores_scratch + (long)h * (long)max_seq_len;

for (int t = (int)tid; t < seq_len; t += 256) {
float dot = 0.f;
long k_off = (long)t * (long)kv_dim + (long)kv_head * (long)head_dim;
for (int d = 0; d < head_dim; d++)
dot += q[q_off + d] * sharpi_bf16_to_fp32((unsigned int)k_cache[k_off + d]);
float score = dot * scale;
if (use_shared) shared_scores[t] = score;
else head_scratch[t] = score;
}
if (use_shared) {
for (int t = seq_len + (int)tid; t < MAX_STORED_SCORES; t += 256)
shared_scores[t] = sharpi_neg_inf();
}
__syncthreads();

float local_max = sharpi_neg_inf();
for (int t = (int)tid; t < seq_len; t += 256) {
float s = use_shared ? shared_scores[t] : head_scratch[t];
local_max = fmaxf(local_max, s);
}
sdata[tid] = local_max;
__syncthreads();
for (unsigned int s = 128; s > 0; s >>= 1) {
if (tid < s) sdata[tid] = fmaxf(sdata[tid], sdata[tid + s]);
__syncthreads();
}
float max_val = sdata[0];
__syncthreads();

float local_sum = 0.f;
for (int t = (int)tid; t < seq_len; t += 256) {
float s = use_shared ? shared_scores[t] : head_scratch[t];
float e = __expf(s - max_val);
if (use_shared) shared_scores[t] = e;
else head_scratch[t] = e;
local_sum += e;
}
sdata[tid] = local_sum;
__syncthreads();
for (unsigned int s = 128; s > 0; s >>= 1) {
if (tid < s) sdata[tid] += sdata[tid + s];
__syncthreads();
}
float inv_sum = 1.0f / sdata[0];
__syncthreads();

for (int t = (int)tid; t < seq_len; t += 256) {
if (use_shared) shared_scores[t] *= inv_sum;
else head_scratch[t] *= inv_sum;
}
__syncthreads();

for (int d = (int)tid; d < head_dim; d += 256) {
float acc = 0.f;
for (int t = 0; t < seq_len; t++) {
float weight = use_shared ? shared_scores[t] : head_scratch[t];
long v_off = (long)t * (long)kv_dim + (long)kv_head * (long)head_dim;
acc += weight * sharpi_bf16_to_fp32((unsigned int)v_cache[v_off + d]);
}
out[out_off + d] = acc;
}
}

// ── Element-wise SiLU in place ─────────────────────────────────────────────
// x[i] = x[i] / (1 + exp(-x[i])). One thread per element.
extern ""C"" __global__ void llm_silu_inplace(float* __restrict__ x, int n)
Expand Down
Loading