Skip to content

Commit ea7bf7e

Browse files
committed
[ExecuTorch][WebGPU] Add opt-in native f16 KV cache (−280 MiB, default-OFF)
**Add an opt-in native f16 KV cache for WebGPU SDPA — −280 MiB device memory, decode-neutral, default-OFF (byte-identical when off).** **Problem:** The SDPA K/V cache is stored fp32, so at long context it dominates WebGPU device memory. On a device that negotiated the shader-f16 feature the cache can be stored as native f16 (half the bytes) while attention still accumulates in f32, at no measured decode-speed cost. **Solution:** Behind a new opt-in build flag `EXECUTORCH_WEBGPU_KV_F16` (defines `WGPU_BACKEND_KV_F16`, default OFF), and only when the device supports shader-f16 (fail-closed to f32 otherwise): - **Before:** the K/V caches are allocated fp32 (`numel*4` bytes) and read by the f32 SDPA kernels. - **After:** the K/V caches are allocated on a dedicated f16 buffer (`numel*2` bytes, zero-init); SDPA and FlashDecoding select the generated f16 (`_half`) kernel variants that bind the cache as f16 and widen to f32 on read — compute and accumulation are unchanged. **Implementation:** - Retires the 4 SDPA/decode kernels (`sdpa_compute_attn_weights`, `sdpa_compute_out`, `sdpa_fd_split`, `update_cache`) into `DTYPE`-templated variants via the landed WGSL shader-variant codegen: each `<kernel>.wgsl` + a `<kernel>.yaml` (`DTYPE: float/half`) generates BOTH the existing f32 header (regenerated byte-identical) and a new `_half` header — the f16 variant is generated, not a hand-duplicated file. The f16 delta is storage-only: `$if DTYPE == "half"` enables f16, the K/V cache binding becomes `array<${buffer_gvec_type(DTYPE, 4)}>` (QK/AV) or `array<${buffer_scalar_type(DTYPE)}>` (fd_split/update_cache), widened to f32 on read (`vec4<f32>(...)` / `f32(...)`); `update_cache` writes `f16(...)`. Mirrors the codegen usage of the landed `rms_norm` (an existing kernel retired into a template) and `steel_f16`. - `WebGPUGraph::build`: collect the sdpa K/V cache value ids (`args[3]`, `args[4]`), allocate them as a dedicated half-size f16 buffer at the constant-allocation site, plus a defensive guard that throws if a non-sdpa op would misread an f16 cache. - `Sdpa.cpp` / `SdpaFdDecode.cpp`: select the generated `_half` shader when `kv_f16()` via a local `const char*` (mirrors the steel-f16 gate — no function-signature/ABI change). - Analogous to Vulkan's whole-graph `force_fp16` fp32→fp16 storage downcast (`backends/vulkan/serialization/vulkan_graph_builder.py` `get_effective_dtype`); here scoped to the K/V cache and gated on the negotiated shader-f16 feature rather than applied graph-wide. **Constraints:** the default build (flag OFF) is byte-identical — all f16 code is `#ifdef WGPU_BACKEND_KV_F16`-gated, the templated f32 headers regenerate byte-identical (drift-check verified), no ABI change, no KV-cache-layout migration; the opt-in build fail-closes to f32 on a non-shader-f16 device; f16 storage requires `head_dim % 4 == 0` (already required and guarded). Co-authored-with: Claude Code. Pull Request resolved: #20772 ghstack-source-id: 401515180 @exported-using-ghexport Differential Revision: [D110919974](https://our.internmc.facebook.com/intern/diff/D110919974/)
1 parent 544ff6a commit ea7bf7e

17 files changed

Lines changed: 704 additions & 20 deletions

backends/webgpu/runtime/WebGPUBackend.cpp

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,23 @@ Result<DelegateHandle*> WebGPUBackend::init(
7979
return Error::DelegateInvalidCompatibility;
8080
}
8181

82+
// Load-time backend option (BackendOption / LoadBackendOptionsMap), keyed by
83+
// the registered backend name; default false. Mirrors the CoreML/XNNPACK
84+
// runtime-spec pattern -- no compile flag and no .pte re-export needed.
85+
bool enable_f16_kv_cache = false;
86+
{
87+
Result<bool> spec = context.get_runtime_spec<bool>("enable_f16_kv_cache");
88+
if (spec.ok()) {
89+
enable_f16_kv_cache = spec.get();
90+
}
91+
}
92+
8293
try {
83-
graph->build(flatbuffer_data, constant_data, context.get_named_data_map());
94+
graph->build(
95+
flatbuffer_data,
96+
constant_data,
97+
context.get_named_data_map(),
98+
enable_f16_kv_cache);
8499
} catch (const std::exception& e) {
85100
ET_LOG(Error, "WebGPU graph build failed: %s", e.what());
86101
graph->~WebGPUGraph();

backends/webgpu/runtime/WebGPUGraph.cpp

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,8 @@ WebGPUGraph::~WebGPUGraph() {
310310
void WebGPUGraph::build(
311311
const void* flatbuffer_data,
312312
const uint8_t* constant_data,
313-
const executorch::runtime::NamedDataMap* named_data_map) {
313+
const executorch::runtime::NamedDataMap* named_data_map,
314+
bool f16_kv_cache) {
314315
if (!device_) {
315316
auto* ctx = get_default_webgpu_context();
316317
if (ctx) {
@@ -331,6 +332,11 @@ void WebGPUGraph::build(
331332
constant_data_ = constant_data;
332333
named_data_map_ = named_data_map;
333334

335+
// f16 KV cache (runtime opt-in): store K/V caches as f16 iff the opt-in is
336+
// set AND the device negotiated shader-f16 (fail-closed).
337+
const WebGPUContext* kv_ctx = get_default_webgpu_context();
338+
kv_f16_ = f16_kv_cache && (kv_ctx != nullptr && kv_ctx->shader_f16_supported);
339+
334340
// Phase 1: Create all values
335341
const auto* values = graph->values();
336342
const int num_vals = values ? values->size() : 0;
@@ -358,6 +364,13 @@ void WebGPUGraph::build(
358364
if (!a) {
359365
continue;
360366
}
367+
// f16 KV: tag sdpa K/V cache values (args[3],[4]) for half-size alloc.
368+
// Inert unless kv_f16_ (runtime opt-in) is set.
369+
if (kv_f16_ && a->size() > 4 &&
370+
oc->name()->str() == "sdpa_with_kv_cache.default") {
371+
kv_cache_ids_.insert(static_cast<int>(a->Get(3)));
372+
kv_cache_ids_.insert(static_cast<int>(a->Get(4)));
373+
}
361374
for (unsigned j = 0; j < a->size(); j++) {
362375
int id = static_cast<int>(a->Get(j));
363376
if (is_prepack && j == 0) {
@@ -378,6 +391,29 @@ void WebGPUGraph::build(
378391
}
379392
}
380393

394+
// f16 KV defensive guard: fail loud if a non-sdpa op reads an f16 cache.
395+
// Inert unless kv_f16_ (runtime opt-in) is set.
396+
if (kv_f16_ && !kv_cache_ids_.empty() && chain_prescan) {
397+
for (unsigned ci = 0; ci < chain_prescan->size(); ci++) {
398+
const auto* oc = chain_prescan->Get(ci);
399+
const std::string nm = oc->name()->str();
400+
if (nm == "sdpa_with_kv_cache.default" || nm == kPrepackOpName) {
401+
continue;
402+
}
403+
const auto* a = oc->args();
404+
if (!a) {
405+
continue;
406+
}
407+
for (unsigned j = 0; j < a->size(); j++) {
408+
if (kv_cache_ids_.count(static_cast<int>(a->Get(j))) != 0) {
409+
throw std::runtime_error(
410+
"WebGPU f16 KV: cache tensor consumed by non-sdpa op '" + nm +
411+
"' would misread the f16 buffer");
412+
}
413+
}
414+
}
415+
}
416+
381417
for (int i = 0; i < num_vals; i++) {
382418
const auto* val = values->Get(i);
383419
if (!val || val->value_type() == vkgraph::GraphTypes::NONE) {
@@ -407,6 +443,23 @@ void WebGPUGraph::build(
407443
tensor.cur_dims = tensor.dims;
408444
tensor.cur_nbytes = tensor.nbytes;
409445

446+
// f16 KV cache: dedicated half-size array<f16> buffer. WebGPU
447+
// zero-initializes freshly-created buffers, so no explicit clear is
448+
// needed. Inert unless kv_f16_ (runtime opt-in) is set.
449+
if (kv_f16_ && kv_cache_ids_.count(i) != 0) {
450+
tensor.elem_size = 2;
451+
tensor.nbytes = numel * 2;
452+
tensor.cur_nbytes = tensor.nbytes;
453+
tensor_mem_obj_ids_[i] = -1;
454+
WGPUBufferDescriptor buf_desc = {};
455+
buf_desc.size = std::max(tensor.nbytes, size_t(4));
456+
buf_desc.usage = WGPUBufferUsage_Storage | WGPUBufferUsage_CopyDst |
457+
WGPUBufferUsage_CopySrc;
458+
buf_desc.mappedAtCreation = false;
459+
tensor.buffer = wgpuDeviceCreateBuffer(device_, &buf_desc);
460+
break;
461+
}
462+
410463
int constant_id = vk_tensor->constant_id();
411464
int mem_obj_id = vk_tensor->mem_obj_id();
412465

backends/webgpu/runtime/WebGPUGraph.h

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,8 @@ class WebGPUGraph {
104104
void build(
105105
const void* flatbuffer_data,
106106
const uint8_t* constant_data,
107-
const executorch::runtime::NamedDataMap* named_data_map = nullptr);
107+
const executorch::runtime::NamedDataMap* named_data_map = nullptr,
108+
bool f16_kv_cache = false);
108109

109110
// Copy input tensor data from host pointers into GPU buffers.
110111
void copy_inputs(const std::vector<InputData>& inputs);
@@ -314,6 +315,16 @@ class WebGPUGraph {
314315
return value_types_[id];
315316
}
316317

318+
public:
319+
// True when the sdpa K/V cache is stored f16-packed (runtime opt-in).
320+
bool kv_f16() const {
321+
return kv_f16_;
322+
}
323+
324+
private:
325+
bool kv_f16_ = false;
326+
std::unordered_set<int> kv_cache_ids_;
327+
317328
private:
318329
WGPUInstance instance_ = nullptr;
319330
WGPUDevice device_ = nullptr;

backends/webgpu/runtime/ops/sdpa/Sdpa.cpp

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,13 @@
99
#include <executorch/backends/webgpu/runtime/WebGPUGraph.h>
1010
#include <executorch/backends/webgpu/runtime/WebGPUUtils.h>
1111
#include <executorch/backends/webgpu/runtime/ops/OperatorRegistry.h>
12+
#include <executorch/backends/webgpu/runtime/ops/sdpa/sdpa_compute_attn_weights_half_wgsl.h>
1213
#include <executorch/backends/webgpu/runtime/ops/sdpa/sdpa_compute_attn_weights_wgsl.h>
14+
#include <executorch/backends/webgpu/runtime/ops/sdpa/sdpa_compute_out_half_wgsl.h>
1315
#include <executorch/backends/webgpu/runtime/ops/sdpa/sdpa_compute_out_wgsl.h>
1416
#include <executorch/backends/webgpu/runtime/ops/sdpa/sdpa_softmax_wgsl.h>
1517
#include <executorch/backends/webgpu/runtime/ops/sdpa_fd_decode/SdpaFdDecode.h>
18+
#include <executorch/backends/webgpu/runtime/ops/update_cache/update_cache_half_wgsl.h>
1619
#include <executorch/backends/webgpu/runtime/ops/update_cache/update_cache_wgsl.h>
1720

1821
#include <webgpu/webgpu.h>
@@ -255,9 +258,13 @@ static WGPUBuffer record_update_cache_dispatch(
255258
WGPUBuffer ubuf = graph.make_uniform_buffer(&uc, sizeof(uc));
256259
BufferBinding bindings[2] = {
257260
{cache.buffer, cache.nbytes}, {src.buffer, src.nbytes}};
261+
const char* uc_src = kUpdateCacheWGSL;
262+
if (graph.kv_f16()) {
263+
uc_src = kUpdateCacheHalfWGSL;
264+
}
258265
build_dispatch(
259266
graph,
260-
kUpdateCacheWGSL,
267+
uc_src,
261268
bindings,
262269
2,
263270
ubuf,
@@ -494,9 +501,13 @@ void sdpa_with_kv_cache_impl(WebGPUGraph& graph, const std::vector<int>& args) {
494501
{attn_weights, aw_bytes},
495502
{q.buffer, q.nbytes},
496503
{k_cache.buffer, k_cache.nbytes}};
504+
const char* qk_src = kSdpaComputeAttnWeightsWGSL;
505+
if (graph.kv_f16()) {
506+
qk_src = kSdpaComputeAttnWeightsHalfWGSL;
507+
}
497508
build_dispatch(
498509
graph,
499-
kSdpaComputeAttnWeightsWGSL,
510+
qk_src,
500511
bindings,
501512
3,
502513
ubuf,
@@ -547,9 +558,13 @@ void sdpa_with_kv_cache_impl(WebGPUGraph& graph, const std::vector<int>& args) {
547558
{out.buffer, out.nbytes},
548559
{attn_weights_softmax, aw_bytes},
549560
{v_cache.buffer, v_cache.nbytes}};
561+
const char* av_src = kSdpaComputeOutWGSL;
562+
if (graph.kv_f16()) {
563+
av_src = kSdpaComputeOutHalfWGSL;
564+
}
550565
build_dispatch(
551566
graph,
552-
kSdpaComputeOutWGSL,
567+
av_src,
553568
bindings,
554569
3,
555570
ubuf,

backends/webgpu/runtime/ops/sdpa/sdpa_compute_attn_weights.wgsl

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
$if DTYPE == "half":
2+
enable f16;
13
@group(0) @binding(0) var<storage, read_write> t_attn_weights: array<f32>;
24
@group(0) @binding(1) var<storage, read> t_q: array<vec4<f32>>;
3-
@group(0) @binding(2) var<storage, read> t_k_cache: array<vec4<f32>>;
5+
@group(0) @binding(2) var<storage, read> t_k_cache: array<${buffer_gvec_type(DTYPE, 4)}>;
46

57
struct Params {
68
S: u32,
@@ -36,7 +38,10 @@ fn load_k_vec4(c: u32, kvh: u32, d4: u32) -> vec4<f32> {
3638
return vec4<f32>(0.0, 0.0, 0.0, 0.0);
3739
}
3840
let base = c * params.Hkv * params.D + kvh * params.D + d4;
39-
return t_k_cache[base / 4u];
41+
$if DTYPE == "half":
42+
return vec4<f32>(t_k_cache[base / 4u]);
43+
$else:
44+
return t_k_cache[base / 4u];
4045
}
4146

4247
fn store_qk(s: u32, c: u32, h: u32, raw: f32) {
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
sdpa_compute_attn_weights:
2+
parameter_names_with_default_values:
3+
DTYPE: float
4+
generate_variant_forall:
5+
DTYPE:
6+
- VALUE: float
7+
SUFFIX: ""
8+
- VALUE: half
9+
SUFFIX: half
10+
shader_variants:
11+
- NAME: sdpa_compute_attn_weights
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
* All rights reserved.
4+
*
5+
* This source code is licensed under the BSD-style license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
#pragma once
10+
11+
#include <cstdint>
12+
13+
namespace executorch::backends::webgpu {
14+
15+
// @generated from sdpa_compute_attn_weights.wgsl - DO NOT EDIT.
16+
// wgsl-sha256: c8795d66b9b51516795fb0113b21fd55086b4a54d9a4b81c7f394ffd96d117b3
17+
inline constexpr const char* kSdpaComputeAttnWeightsHalfWGSL = R"(
18+
enable f16;
19+
@group(0) @binding(0) var<storage, read_write> t_attn_weights: array<f32>;
20+
@group(0) @binding(1) var<storage, read> t_q: array<vec4<f32>>;
21+
@group(0) @binding(2) var<storage, read> t_k_cache: array<vec4<f16>>;
22+
23+
struct Params {
24+
S: u32,
25+
Hq: u32,
26+
Hkv: u32,
27+
D: u32,
28+
context_len: u32,
29+
input_pos: u32,
30+
g: u32,
31+
scale: f32,
32+
}
33+
@group(0) @binding(3) var<uniform> params: Params;
34+
35+
// WGSL forbids literal -inf; large finite negative is a WGSL-safe stand-in.
36+
const NEG_INF: f32 = -1.0e30;
37+
38+
override wg_size: u32 = 64;
39+
40+
const TM: u32 = 4u;
41+
const TN: u32 = 4u;
42+
43+
// D is a multiple of 4 (host-guarded), so a d4 chunk is fully in-bounds — no per-lane check.
44+
fn load_q_vec4(s: u32, h: u32, d4: u32) -> vec4<f32> {
45+
if (s >= params.S) {
46+
return vec4<f32>(0.0, 0.0, 0.0, 0.0);
47+
}
48+
let base = s * params.Hq * params.D + h * params.D + d4;
49+
return t_q[base / 4u];
50+
}
51+
52+
fn load_k_vec4(c: u32, kvh: u32, d4: u32) -> vec4<f32> {
53+
if (c >= params.context_len) {
54+
return vec4<f32>(0.0, 0.0, 0.0, 0.0);
55+
}
56+
let base = c * params.Hkv * params.D + kvh * params.D + d4;
57+
return vec4<f32>(t_k_cache[base / 4u]);
58+
}
59+
60+
fn store_qk(s: u32, c: u32, h: u32, raw: f32) {
61+
if (s >= params.S || c >= params.context_len) {
62+
return;
63+
}
64+
var val = raw * params.scale;
65+
// Causal mask: position c may not attend beyond s + input_pos.
66+
if (c > s + params.input_pos) {
67+
val = NEG_INF;
68+
}
69+
let idx = h * params.S * params.context_len + s * params.context_len + c;
70+
t_attn_weights[idx] = val;
71+
}
72+
73+
@compute @workgroup_size(wg_size, 1, 1)
74+
fn main(
75+
@builtin(global_invocation_id) gid: vec3<u32>,
76+
@builtin(num_workgroups) num_workgroups: vec3<u32>) {
77+
let nrt = (params.S + TM - 1u) / TM;
78+
let nct = (params.context_len + TN - 1u) / TN;
79+
let tiles = nrt * nct;
80+
let total = tiles * params.Hq;
81+
// 2D dispatch fold: recover the linear tile index across x/y.
82+
let idx = gid.x + gid.y * (num_workgroups.x * wg_size);
83+
if (idx >= total) {
84+
return;
85+
}
86+
87+
let h = idx / tiles;
88+
let rem = idx % tiles;
89+
let row_tile = rem / nct;
90+
let col_tile = rem % nct;
91+
let kvh = h / params.g;
92+
let s0 = row_tile * TM;
93+
let c0 = col_tile * TN;
94+
95+
var acc: array<vec4<f32>, 4>;
96+
acc[0] = vec4<f32>(0.0, 0.0, 0.0, 0.0);
97+
acc[1] = vec4<f32>(0.0, 0.0, 0.0, 0.0);
98+
acc[2] = vec4<f32>(0.0, 0.0, 0.0, 0.0);
99+
acc[3] = vec4<f32>(0.0, 0.0, 0.0, 0.0);
100+
101+
// Skip fully-masked causal tiles; mirrors Vulkan attn_weights_tiled.glsl.
102+
let skip_tile = c0 > s0 + (TM - 1u) + params.input_pos;
103+
var d4: u32 = 0u;
104+
loop {
105+
if (d4 >= params.D || skip_tile) {
106+
break;
107+
}
108+
var q: array<vec4<f32>, TM>;
109+
var k: array<vec4<f32>, TN>;
110+
for (var i: u32 = 0u; i < TM; i = i + 1u) {
111+
q[i] = load_q_vec4(s0 + i, h, d4);
112+
}
113+
for (var j: u32 = 0u; j < TN; j = j + 1u) {
114+
k[j] = load_k_vec4(c0 + j, kvh, d4);
115+
}
116+
for (var i: u32 = 0u; i < TM; i = i + 1u) {
117+
acc[i] += vec4<f32>(
118+
dot(q[i], k[0]),
119+
dot(q[i], k[1]),
120+
dot(q[i], k[2]),
121+
dot(q[i], k[3]));
122+
}
123+
d4 = d4 + 4u;
124+
}
125+
126+
var m: u32 = 0u;
127+
loop {
128+
if (m >= TM) {
129+
break;
130+
}
131+
let av = acc[m];
132+
store_qk(s0 + m, c0 + 0u, h, av.x);
133+
store_qk(s0 + m, c0 + 1u, h, av.y);
134+
store_qk(s0 + m, c0 + 2u, h, av.z);
135+
store_qk(s0 + m, c0 + 3u, h, av.w);
136+
m = m + 1u;
137+
}
138+
}
139+
)";
140+
141+
inline constexpr uint32_t kSdpaComputeAttnWeightsHalfWorkgroupSizeX = 64;
142+
inline constexpr uint32_t kSdpaComputeAttnWeightsHalfWorkgroupSizeY = 1;
143+
inline constexpr uint32_t kSdpaComputeAttnWeightsHalfWorkgroupSizeZ = 1;
144+
145+
} // namespace executorch::backends::webgpu

0 commit comments

Comments
 (0)