Skip to content

Commit 03f1e79

Browse files
bradleyhdfacebook-github-bot
authored andcommitted
introduce kernel for converting e4m3fn kv_cache to e4m3fnuz (#4113)
Summary: X-link: facebookresearch/FBGEMM#1197 As titled. NV uses e4m3fn for fp8 KV cache, and AMD uses e4m3fnuz. If we want to do HH serving with NV prefill and AMD decode, we need a way to convert between the two. In practice this can be done with two operations: overwriting -0 (which are valid in e4m3fn but not in uz) with +0, and multiplying the scale qparam by 2, since the exponent bias differs by 1 between the two formats. The scale qparam is packed as the low bits of a __half2 (fp16x2). Differential Revision: D74502334
1 parent 44fe934 commit 03f1e79

4 files changed

Lines changed: 223 additions & 18 deletions

File tree

fbgemm_gpu/experimental/gen_ai/src/kv_cache/kv_cache.cpp

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ TORCH_LIBRARY_FRAGMENT(fbgemm, m) {
4949
DEFAULT_PAGE_SIZE) ") -> (Tensor, Tensor)");
5050
m.def(
5151
"quantize_qkv_per_head(Tensor amax, Tensor XQKV, Tensor varseq_seqpos, Tensor? varseq_batch, Tensor q_seqstarts, Tensor cache_K, Tensor cache_V, Tensor XQ_O, int max_seq_len, Tensor? qparam_k=None, Tensor? qparam_v=None) -> Tensor");
52+
m.def(
53+
"convert_e4m3fn_kv_cache_to_e4m3fnuz_inplace(Tensor cache_K, Tensor cache_V, Tensor qparam_K, Tensor qparam_V) -> ()");
5254
}
5355

5456
TORCH_LIBRARY_IMPL(fbgemm, CPU, m) {
@@ -73,6 +75,9 @@ TORCH_LIBRARY_IMPL(fbgemm, CUDA, m) {
7375
m.impl("dequantize_int4_cache", dequantize_int4_cache);
7476
m.impl("dequantize_fp8_cache", dequantize_fp8_cache);
7577
m.impl("quantize_qkv_per_head", quantize_qkv_per_head);
78+
m.impl(
79+
"convert_e4m3fn_kv_cache_to_e4m3fnuz_inplace",
80+
fbgemm_gpu::convert_e4m3fn_kv_cache_to_e4m3fnuz_inplace);
7681
}
7782

7883
at::Tensor rope_qkv_varseq_prefill_meta(
@@ -295,6 +300,13 @@ at::Tensor quantize_qkv_per_head_meta(
295300
at::empty_symint({B_KV, N_KVH}, cache_K.options().dtype(at::kFloat));
296301
return at::empty_like(XQKV);
297302
}
303+
304+
void convert_e4m3fn_kv_cache_to_e4m3fnuz_inplace_meta(
305+
at::Tensor cache_K,
306+
at::Tensor cache_V,
307+
at::Tensor qparam_K,
308+
at::Tensor qparam_v) {};
309+
298310
TORCH_LIBRARY_IMPL(fbgemm, Meta, m) {
299311
m.impl("rope_qkv_varseq_prefill", rope_qkv_varseq_prefill_meta);
300312
m.impl("rope_qkv_decoding", rope_qkv_decoding_meta);
@@ -304,6 +316,9 @@ TORCH_LIBRARY_IMPL(fbgemm, Meta, m) {
304316
m.impl("xpos_qkv_decoding", xpos_qkv_decoding_meta);
305317
m.impl("dequantize_int4_cache", dequantize_int4_cache_meta);
306318
m.impl("dequantize_fp8_cache", dequantize_fp8_cache_meta);
319+
m.impl(
320+
"convert_e4m3fn_kv_cache_to_e4m3fnuz_inplace",
321+
convert_e4m3fn_kv_cache_to_e4m3fnuz_inplace_meta);
307322
}
308323

309324
} // namespace fbgemm_gpu

fbgemm_gpu/experimental/gen_ai/src/kv_cache/kv_cache.cu

Lines changed: 122 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,6 @@ __global__ void dequantize_int4_cache_kernel(
7272
cache_V_dq // [B][MAX_T][N_KVH][D_H]
7373
) {
7474
auto N_KVH = cache_K.size(2);
75-
auto MAX_T = cache_K.size(1);
7675
auto D_H = cache_K_dq.size(3);
7776

7877
auto b = blockIdx.x;
@@ -2331,6 +2330,112 @@ at::Tensor xpos_qkv_decoding(
23312330
return XQ_O;
23322331
}
23332332
2333+
#if (defined(USE_ROCM))
2334+
/**
2335+
* Converts the contents of a FP8 KV cache from e4m3fn (NV) to e4m3fnuz (AMD).
2336+
* These formats differ in their support for negative zero, and in their
2337+
* exponent bias. Negative zeros are replaced with positive zero, and the scale
2338+
* qparam is multiplied by 2.0, because we know that the scale will be applied
2339+
* to the k/v value and is equivalent to recomputing the exponent bias.
2340+
*
2341+
* This in an inplace operation.
2342+
*
2343+
* It is assumed that inputs will have been generated with scale_ub = max(fp16)
2344+
* / 2 to avoid overflow. Some debug mode assertions are in place, but there are
2345+
* no runtime guarantees.
2346+
*
2347+
* As written, this kernel is only valid on AMD, because it relies on threads
2348+
* 32-63 to convert the V tensors. NV only has threads 0-31 per warp.
2349+
*/
2350+
__global__ void convert_e4m3fn_kv_cache_to_e4m3fnuz_inplace_kernel(
2351+
at::PackedTensorAccessor64<uint8_t, 4, at::RestrictPtrTraits>
2352+
cache_K, // [B][MAX_T][N_KVH][D_H]
2353+
at::PackedTensorAccessor64<uint8_t, 4, at::RestrictPtrTraits>
2354+
cache_V, // [B][MAX_T][N_KVH][D_H]
2355+
at::PackedTensorAccessor64<uint32_t, 4, at::RestrictPtrTraits> qparam_K,
2356+
at::PackedTensorAccessor64<uint32_t, 4, at::RestrictPtrTraits> qparam_V) {
2357+
auto N_KVH = cache_K.size(2);
2358+
auto MAX_T = cache_K.size(1);
2359+
auto D_H = cache_K.size(3);
2360+
CUDA_KERNEL_ASSERT(D_H == 128);
2361+
2362+
auto b = blockIdx.x;
2363+
int h = 0, t = 0;
2364+
uint8_t* head;
2365+
__half* qparam_scale_ptr;
2366+
2367+
for (auto t_h = threadIdx.y + blockIdx.y * blockDim.y; t_h < MAX_T * N_KVH;
2368+
t_h += blockDim.y * gridDim.y) {
2369+
h = t_h % N_KVH;
2370+
t = t_h / N_KVH;
2371+
2372+
auto tidx = threadIdx.x;
2373+
if (threadIdx.x < 32) {
2374+
head = &cache_K[b][t][h][0];
2375+
qparam_scale_ptr = reinterpret_cast<__half*>(&qparam_K[b][t][h][0]);
2376+
} else {
2377+
head = &cache_V[b][t][h][0];
2378+
qparam_scale_ptr = reinterpret_cast<__half*>(&qparam_V[b][t][h][0]);
2379+
tidx -= 32;
2380+
}
2381+
auto D_H_idx = tidx * 4; // Reading 4 bytes at once.
2382+
2383+
// Our only goal here is to detect negative zeros that are valid
2384+
// in e4m3fn, but not valid in e4m3fnuz, and overwrite them with zeros.
2385+
// E.g. 0x80708070u ^ 0x80808080u = 0xf000f0u & 0x80708070u = 0x00700070u
2386+
uint32_t packed_fp8x4_vals = *reinterpret_cast<uint32_t*>(&head[D_H_idx]);
2387+
*reinterpret_cast<uint32_t*>(&head[D_H_idx]) =
2388+
(packed_fp8x4_vals ^ 0x80808080) & packed_fp8x4_vals;
2389+
2390+
// Multiply qparam scale (member x) by 2 to compensate for the exponent
2391+
// bias difference (1) between e4m3fn and e4m3fnuz. We only need to do this
2392+
// once per row. In debug mode, assert that 2.0*scale as a float would not
2393+
// exceed the max value of __half.
2394+
if (tidx == 0) {
2395+
CUDA_KERNEL_ASSERT(
2396+
__half2float(*qparam_scale_ptr) * 2.0f <=
2397+
__half2float(std::numeric_limits<__half>::max()));
2398+
*qparam_scale_ptr = __float2half(__half2float(*qparam_scale_ptr) * 2.0f);
2399+
}
2400+
}
2401+
}
2402+
2403+
void convert_e4m3fn_kv_cache_to_e4m3fnuz_inplace(
2404+
at::Tensor cache_K,
2405+
at::Tensor cache_V,
2406+
at::Tensor qparam_K,
2407+
at::Tensor qparam_V) {
2408+
TORCH_CHECK(cache_K.is_cuda());
2409+
TORCH_CHECK(cache_V.is_cuda());
2410+
2411+
auto B = cache_K.size(0);
2412+
2413+
constexpr int32_t kMaxBlocks = 1;
2414+
dim3 blocks(B, std::max<int32_t>(1, kMaxBlocks / B));
2415+
dim3 threads(kThreadsPerWarp, kWarpsPerBlock);
2416+
2417+
convert_e4m3fn_kv_cache_to_e4m3fnuz_inplace_kernel<<<
2418+
blocks,
2419+
threads,
2420+
0,
2421+
at::cuda::getCurrentCUDAStream()>>>(
2422+
cache_K.packed_accessor64<uint8_t, 4, at::RestrictPtrTraits>(),
2423+
cache_V.packed_accessor64<uint8_t, 4, at::RestrictPtrTraits>(),
2424+
qparam_K.packed_accessor64<uint32_t, 4, at::RestrictPtrTraits>(),
2425+
qparam_V.packed_accessor64<uint32_t, 4, at::RestrictPtrTraits>());
2426+
C10_CUDA_KERNEL_LAUNCH_CHECK();
2427+
}
2428+
#else
2429+
void convert_e4m3fn_kv_cache_to_e4m3fnuz_inplace(
2430+
at::Tensor cache_K,
2431+
at::Tensor cache_V,
2432+
at::Tensor qparam_K,
2433+
at::Tensor qparam_V) {
2434+
throw std::runtime_error(
2435+
"convert_e4m3fn_kv_cache_to_e4m3fnuz_inplace is only supported on AMD");
2436+
}
2437+
#endif
2438+
23342439
#if (defined(USE_ROCM) && ROCM_VERSION >= 60200) || \
23352440
(defined(CUDA_VERSION) && CUDA_VERSION >= 12000)
23362441
@@ -2550,7 +2655,6 @@ __global__ void dequantize_fp8_cache_kernel_paged(
25502655
int32_t block_tables_b_stride,
25512656
int32_t page_size) {
25522657
auto N_KVH = cache_K.size(2);
2553-
auto MAX_T = cache_K.size(1);
25542658
auto D_H = cache_K_dq.size(3);
25552659
auto D_H_q = cache_K.size(3);
25562660
CUDA_KERNEL_ASSERT(D_H == 128);
@@ -2647,20 +2751,20 @@ std::tuple<at::Tensor, at::Tensor> dequantize_fp8_cache(
26472751
auto D_H = (D_HQ - fp8_qparam_offset);
26482752
26492753
// TODO:
2650-
// The below allocates Tensors that have the same shape as cache_K and cache_V
2651-
// to store their dequantize results. For paged KV cache, this can be a bit
2652-
// inefficient because it has the shape of [1 x (MAX_PAGES * PAGE_SIZE) x
2653-
// N_KVH x D_H] to accommodate pages globally across batch instances, and
2654-
// if we have very large MAX_PAGES then we are essentially allocating a very
2655-
// huge Tensor here. The benefit is that the following users of this
2656-
// dequantized results can reuse the existing block_tables to access their
2657-
// elements. If we want to be more efficient, there are two possible
2658-
// approaches: (1) Allocate a shorter Tensor here and store the dequantize
2659-
// results in a more compact manner, but that requires creating a new
2660-
// block_tables here and making sure the following users all use the
2661-
// correct block_tables. (2) From outside, keep a persistent buffer that has a
2662-
// matching shape with the original paged KV and feed the same buffer
2663-
// into this function at every layer to reuse it and prevent allocation.
2754+
// The below allocates Tensors that have the same shape as cache_K and
2755+
// cache_V to store their dequantize results. For paged KV cache, this can
2756+
// be a bit inefficient because it has the shape of [1 x (MAX_PAGES *
2757+
// PAGE_SIZE) x N_KVH x D_H] to accommodate pages globally across batch
2758+
// instances, and if we have very large MAX_PAGES then we are essentially
2759+
// allocating a very huge Tensor here. The benefit is that the following
2760+
// users of this dequantized results can reuse the existing block_tables to
2761+
// access their elements. If we want to be more efficient, there are two
2762+
// possible approaches: (1) Allocate a shorter Tensor here and store the
2763+
// dequantize results in a more compact manner, but that requires creating a
2764+
// new block_tables here and making sure the following users all use the
2765+
// correct block_tables. (2) From outside, keep a persistent buffer that has
2766+
// a matching shape with the original paged KV and feed the same buffer into
2767+
// this function at every layer to reuse it and prevent allocation.
26642768
26652769
auto cache_K_dq = at::empty(
26662770
{B_KV, MAX_T, N_KVH, D_H}, cache_K.options().dtype(at::kBFloat16));
@@ -2889,8 +2993,8 @@ at::Tensor quantize_qkv_per_head(
28892993
float* const scale_q_ptr = scale_q.data_ptr<float>();
28902994
// Launch the kernel
28912995
// TODO: Launch the kernel with B_T * N_H_L blocks only in case of decode.
2892-
// Currently, we are launching the kernel with B_T * HH blocks for decode and
2893-
// KV blocks just return when qparam_k is passed as nullptr.
2996+
// Currently, we are launching the kernel with B_T * HH blocks for decode
2997+
// and KV blocks just return when qparam_k is passed as nullptr.
28942998
quantizeQKVPerHead<<<
28952999
grid_size,
28963000
block_size,

fbgemm_gpu/experimental/gen_ai/src/kv_cache/kv_cache.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,4 +193,10 @@ at::Tensor mqa_attn(
193193
std::optional<at::Tensor> qparam_k,
194194
std::optional<at::Tensor> qparam_v);
195195

196+
void convert_e4m3fn_kv_cache_to_e4m3fnuz_inplace(
197+
at::Tensor cache_K,
198+
at::Tensor cache_V,
199+
at::Tensor qparam_K,
200+
at::Tensor qparam_v);
201+
196202
} // namespace fbgemm_gpu

fbgemm_gpu/experimental/gen_ai/test/kv_cache/kv_cache_test.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ def _get_varseq_batch_seqpos(
7070
return varseq_batch, varseq_seqpos
7171

7272

73+
@unittest.skipIf(not torch.cuda.is_available(), "Skipping because no GPU is available.")
7374
class KVCacheTests(unittest.TestCase):
7475
@classmethod
7576
def setUpClass(cls) -> None:
@@ -353,6 +354,85 @@ def test_fp8_kv_cache(self, MAX_T: int, N_KVH_L: int) -> None:
353354
cache_v[:, :T], cache_v_bf16[:, :T], atol=1.0e-2, rtol=5.0e-2
354355
)
355356

357+
@settings(deadline=None)
358+
@given(
359+
MAX_T=st.sampled_from([8000, 16384]),
360+
N_KVH_L=st.sampled_from([1, 2]),
361+
)
362+
@unittest.skipIf(
363+
not torch.cuda.is_available() or not HAS_XFORMERS or not torch.version.hip,
364+
"Skip when no AMD GPU or xformers is not available",
365+
)
366+
def test_fp8_kv_e4m3fn_convert_to_e4m3fnuz(self, MAX_T: int, N_KVH_L: int) -> None:
367+
B = 2
368+
D_H = 128
369+
370+
qparam_offset = 0
371+
372+
# Init K to all 1s
373+
cache_fp8x4_k = torch.full(
374+
size=(B, MAX_T, N_KVH_L, int(D_H) + qparam_offset),
375+
fill_value=0x01,
376+
dtype=torch.uint8,
377+
device=self.device,
378+
)
379+
380+
# Choose random elements to set to negative zero, 0x80
381+
cache_fp8x4_k_flat = cache_fp8x4_k.flatten()
382+
random_indices = torch.randperm(cache_fp8x4_k_flat.size(0))[
383+
: cache_fp8x4_k_flat.size(0) // 2
384+
]
385+
cache_fp8x4_k_flat[random_indices] = 0x80
386+
cache_fp8x4_k = cache_fp8x4_k_flat.reshape(cache_fp8x4_k.shape)
387+
388+
# Expected K has +0 in place of -0
389+
cache_fp8x4_k_expected_flat = cache_fp8x4_k_flat.clone()
390+
cache_fp8x4_k_expected_flat[random_indices] = 0x00
391+
cache_fp8x4_k_expected = cache_fp8x4_k_expected_flat.reshape(
392+
cache_fp8x4_k.shape
393+
)
394+
395+
# Repeat for V
396+
cache_fp8x4_v = torch.full(
397+
size=(B, MAX_T, N_KVH_L, int(D_H) + qparam_offset),
398+
fill_value=0x01,
399+
dtype=torch.uint8,
400+
device=self.device,
401+
)
402+
cache_fp8x4_v_flat = cache_fp8x4_v.flatten()
403+
random_indices = torch.randperm(cache_fp8x4_v_flat.size(0))[
404+
: cache_fp8x4_v_flat.size(0) // 2
405+
]
406+
cache_fp8x4_v_flat[random_indices] = 0x80
407+
cache_fp8x4_v = cache_fp8x4_v_flat.reshape(cache_fp8x4_v.shape)
408+
cache_fp8x4_v_expected_flat = cache_fp8x4_v_flat.clone()
409+
cache_fp8x4_v_expected_flat[random_indices] = 0x00
410+
cache_fp8x4_v_expected = cache_fp8x4_v_expected_flat.reshape(
411+
cache_fp8x4_v.shape
412+
)
413+
414+
qparam_fp16x2_k = torch.full(
415+
size=(B, MAX_T, N_KVH_L, 1), fill_value=0x3C003C00, dtype=torch.uint32
416+
).cuda()
417+
qparam_fp16x2_k_expected = torch.full(
418+
size=(B, MAX_T, N_KVH_L, 1), fill_value=0x3C004000, dtype=torch.uint32
419+
).cuda()
420+
qparam_fp16x2_v = torch.full(
421+
size=(B, MAX_T, N_KVH_L, 1), fill_value=0x3C003C00, dtype=torch.uint32
422+
).cuda()
423+
qparam_fp16x2_v_expected = torch.full(
424+
size=(B, MAX_T, N_KVH_L, 1), fill_value=0x3C004000, dtype=torch.uint32
425+
).cuda()
426+
427+
torch.ops.fbgemm.convert_e4m3fn_kv_cache_to_e4m3fnuz_inplace(
428+
cache_fp8x4_k, cache_fp8x4_v, qparam_fp16x2_k, qparam_fp16x2_v
429+
)
430+
431+
assert torch.equal(cache_fp8x4_k, cache_fp8x4_k_expected)
432+
assert torch.equal(cache_fp8x4_v, cache_fp8x4_v_expected)
433+
assert torch.equal(qparam_fp16x2_k, qparam_fp16x2_k_expected)
434+
assert torch.equal(qparam_fp16x2_v, qparam_fp16x2_v_expected)
435+
356436
@settings(deadline=None)
357437
@given(
358438
prefill=st.booleans(),

0 commit comments

Comments
 (0)