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
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
//! GLiNER's span head on **wgpu** — the piece that makes the engine faster than torch at every
//! size, not just on short text.
//!
//! The head's cost grows with the SPAN COUNT (`n_spans ≈ max_width · W`), so on a 176-word input it
//! is ~2,300 spans and ~3.6 GMAC — and on the CPU the `gemm` crate delivers ~51 GFLOP/s against
//! torch dispatching to Apple's AMX-backed BLAS. That is a fight the CPU cannot win. On the GPU the
//! same arithmetic is a handful of dispatches.
//!
//! Everything here rides the encoder's existing WGSL GEMM (`enc_gemm_src`) — `y = act(x·Wᵀ + b)` —
//! except one new kernel, [`SPAN_ASSEMBLE`], which materializes the per-span rows. So this is
//! wgpu, i.e. Metal / Vulkan / DX12 / GL, not a Mac-specific path.
//!
//! ```text
//! words [W, d] (uploaded once per call)
//! ├─ GEMM ─ GEMM(relu) ──────────► relu(start) [W, d]
//! ├─ GEMM ─ GEMM(relu) ──────────► relu(end) [W, d]
//! ├─ GEMM A = W_left ·relu(start) [W, 4d] ┐ the concat-seam split: each half is
//! ├─ GEMM B = W_right·relu(end) [W, 4d] ┘ projected PER WORD, not per span
//! ├─ SPAN_ASSEMBLE h[s] = relu(A[l] + B[l+k]) [n_spans, 4d]
//! ├─ GEMM span_reps = h · W_downᵀ + b [n_spans, d]
//! └─ GEMM scores = span_reps · promptᵀ [n_spans, C] ← the only readback (tiny)
//! ```
use anyhow::{Context, Result};
use crate::GpuCtx;
use crate::encoder::{
GEMM2_TILES, GEMM3_TILES, act_code, enc_gemm2_src, enc_gemm3_src, gemm2_tier, gemm2_tile,
gemm3_tier, gemm3_tile,
};
use crate::encoder_weights::Act;
use crate::forward::{make_bg, pipeline, uni};
/// `h[s] = relu(A[l] + B[min(l+k, W-1)])` for span `s = l·max_width + k`.
///
/// The span index is derived IN-SHADER from the workgroup id — no `[n_spans, 2]` index buffer to
/// build and upload. Spans that run past the text (`l + k >= W`) are computed against a clamped
/// row and simply ignored by the caller, which is cheaper than branching around them and matches
/// GLiNER's own `span_mask`.
const SPAN_ASSEMBLE: &str = r#"
struct Meta { n_spans: u32, up_w: u32, max_width: u32, n_words: u32 }
@group(0) @binding(0) var<storage, read> a: array<f32>;
@group(0) @binding(1) var<storage, read> b: array<f32>;
@group(0) @binding(2) var<storage, read_write> h: array<f32>;
@group(0) @binding(3) var<uniform> mt: Meta;
@compute @workgroup_size(64)
fn main(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_index) t: u32) {
let s = wg.x;
if (s >= mt.n_spans) { return; }
let l = s / mt.max_width;
let k = s % mt.max_width;
let e = min(l + k, mt.n_words - 1u);
let ao = l * mt.up_w;
let bo = e * mt.up_w;
let ho = s * mt.up_w;
for (var j = t; j < mt.up_w; j += 64u) {
h[ho + j] = max(a[ao + j] + b[bo + j], 0.0);
}
}
"#;
/// One `nn.Linear` resident on the GPU (`W` as f32, plus a bias — the head is small enough that
/// f16 buys nothing and costs accuracy).
struct GpuLinear {
w: wgpu::Buffer,
b: wgpu::Buffer,
n: u32,
k: u32,
/// Uploaded `[k, n]`-transposed for the v3 kernels (per-weight choice — see `new_v3`).
v3: bool,
}
impl GpuLinear {
fn new(ctx: &GpuCtx, w: &[f32], b: &[f32], n: usize, k: usize) -> Self {
Self {
w: ctx.storage(w),
b: ctx.storage(b),
n: n as u32,
k: k as u32,
v3: false,
}
}
/// v3 upload: `[k, n]`-transposed at load, exactly like the encoder's `upload_linear`. The
/// choice is PER WEIGHT because the head's `m` differs per GEMM: the n=2048 up-projections
/// sit in v3's proven wide band at every m, and the span `down` runs at m = n_spans ≥ 132
/// where plain v3 wins the mid band (the deberta-shape measurements) — while `ps_down`/
/// `pe_down` run at m = n_words (as low as 11) and the runtime-weight score GEMM cannot
/// pre-transpose, so those stay v2.
fn new_v3(ctx: &GpuCtx, w: &[f32], b: &[f32], n: usize, k: usize) -> Self {
let mut wt = vec![0f32; n * k];
for nn in 0..n {
for kk in 0..k {
wt[kk * n + nn] = w[nn * k + kk];
}
}
Self {
w: ctx.storage(&wt),
b: ctx.storage(b),
n: n as u32,
k: k as u32,
v3: true,
}
}
}
/// The span head, resident on the GPU. Weights upload once at load; per call only `words` goes up
/// and `scores` comes back.
pub(crate) struct GlinerGpuHead {
/// GEMM v2 [wide, skinny] — selected per call by the row count.
gemm: Vec<wgpu::ComputePipeline>,
/// GEMM v3 (vec4, `[k,n]`-transposed weights) — the up-projections and the span down ride
/// these; tile picked per call like the encoder's.
gemm3: Vec<wgpu::ComputePipeline>,
assemble: wgpu::ComputePipeline,
ps_up: GpuLinear,
ps_down: GpuLinear,
pe_up: GpuLinear,
pe_down: GpuLinear,
up_left: GpuLinear,
up_right: GpuLinear,
down: GpuLinear,
/// Scratch, sized for `max_words` so a call allocates nothing.
words: wgpu::Buffer,
mid_a: wgpu::Buffer,
mid_b: wgpu::Buffer,
start: wgpu::Buffer,
end: wgpu::Buffer,
a: wgpu::Buffer,
b: wgpu::Buffer,
h: wgpu::Buffer,
span_reps: wgpu::Buffer,
scores: wgpu::Buffer,
/// The entity-type reps, uploaded per call (they depend on the prompt) into a fixed buffer.
prompt: wgpu::Buffer,
/// The score GEMM has no bias; a zero vector bound in its place.
no_bias: wgpu::Buffer,
d: usize,
up_w: usize,
max_width: usize,
max_words: usize,
max_types: usize,
}
impl GlinerGpuHead {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
ctx: &GpuCtx,
ps: (&[f32], &[f32], &[f32], &[f32]), // project_start: up.w, up.b, down.w, down.b
pe: (&[f32], &[f32], &[f32], &[f32]), // project_end
up_left: (&[f32], &[f32]), // span_up.left (w, b=zeros)
up_right: (&[f32], &[f32]), // span_up.right (w, b=b_up)
down: (&[f32], &[f32]), // span_down
d: usize,
up_w: usize,
max_width: usize,
max_words: usize,
max_types: usize,
) -> Result<Self> {
let n_spans = max_words * max_width;
let zero = |n: usize| ctx.storage(&vec![0f32; n]);
Ok(Self {
// The head's own GEMMs ride the register-blocked v2 family, like the encoder's.
// One pipeline per tile: the span GEMMs are M≈2300 and the per-word ones M=W, and the
// right tile is a function of the GRID (M *and* the output width), not of M alone.
gemm: GEMM2_TILES
.iter()
.map(|&(bm, bn)| {
pipeline(
ctx,
&format!("gliner_gemm2_{bm}x{bn}"),
&enc_gemm2_src(false, bm, bn),
)
})
.collect(),
gemm3: GEMM3_TILES
.iter()
.map(|&(bm, bn, bk)| {
pipeline(
ctx,
&format!("gliner_gemm3_{bm}x{bn}x{bk}"),
&enc_gemm3_src(false, bm, bn, bk),
)
})
.collect(),
assemble: pipeline(ctx, "gliner_span_assemble", SPAN_ASSEMBLE),
ps_up: GpuLinear::new_v3(ctx, ps.0, ps.1, up_w, d),
ps_down: GpuLinear::new(ctx, ps.2, ps.3, d, up_w),
pe_up: GpuLinear::new_v3(ctx, pe.0, pe.1, up_w, d),
pe_down: GpuLinear::new(ctx, pe.2, pe.3, d, up_w),
up_left: GpuLinear::new_v3(ctx, up_left.0, up_left.1, up_w, d),
up_right: GpuLinear::new_v3(ctx, up_right.0, up_right.1, up_w, d),
down: GpuLinear::new_v3(ctx, down.0, down.1, d, up_w),
words: zero(max_words * d),
mid_a: zero(max_words * up_w),
mid_b: zero(max_words * up_w),
start: zero(max_words * d),
end: zero(max_words * d),
a: zero(max_words * up_w),
b: zero(max_words * up_w),
h: zero(n_spans * up_w),
span_reps: zero(n_spans * d),
scores: zero(n_spans * max_types),
prompt: zero(max_types * d),
no_bias: zero(max_types),
d,
up_w,
max_width,
max_words,
max_types,
})
}
/// `words` `[W, d]` (post-BiLSTM) and `prompt` `[C, d]` → raw scores `[n_spans, C]`, with
/// `n_spans = W · max_width` and span `s = l·max_width + k` meaning the word range `[l, l+k]`.
/// The whole head runs in ONE submit; only the (small) score matrix comes back.
pub(crate) fn scores(
&self,
ctx: &GpuCtx,
words: &[f32],
prompt: &[f32],
n_words: usize,
n_types: usize,
) -> Result<Vec<f32>> {
anyhow::ensure!(
n_words <= self.max_words,
"gliner gpu head: {n_words} words exceeds the {} it was sized for",
self.max_words
);
anyhow::ensure!(
n_types <= self.max_types,
"gliner gpu head: {n_types} entity types exceeds the {} it was sized for",
self.max_types
);
let (d, up_w) = (self.d, self.up_w);
let n_spans = n_words * self.max_width;
ctx.queue
.write_buffer(&self.words, 0, bytemuck::cast_slice(words));
// `prompt` is the RHS of the score GEMM, so it plays the role of a weight matrix [C, d].
ctx.queue
.write_buffer(&self.prompt, 0, bytemuck::cast_slice(prompt));
// Bind groups + uniforms for the whole head, built up front, then dispatched in ONE compute
// pass. Within a pass WebGPU orders dispatches and makes their writes visible to the next,
// so the chain is safe — and a pass per GEMM (8 of them) is pure per-call overhead. This is
// the same plan-replay shape `EncoderGpu::encode` uses.
let mut passes: Vec<(&wgpu::ComputePipeline, wgpu::BindGroup, u32, u32)> = Vec::new();
let mut keep: Vec<wgpu::Buffer> = Vec::new(); // uniforms must outlive the submit
// `gemm!` rather than a closure: it needs &mut on both `passes` and `keep`, and a closure
// capturing them would hold those borrows across every call.
macro_rules! gemm {
($x:expr, $lw:expr, $y:expr, $m:expr, $act:expr, $bias:expr) => {{
let lw: &GpuLinear = $lw;
let m: usize = $m;
let flags = u32::from($bias as bool) | (act_code($act) << 8);
let meta = uni(ctx, bytemuck::cast_slice(&[m as u32, lw.n, lw.k, flags]));
let (pl, bm, bn) = if lw.v3 {
let tile = gemm3_tile(m, lw.n as usize);
(&self.gemm3[gemm3_tier(tile)], tile.0, tile.1)
} else {
let tile = gemm2_tile(m, lw.n as usize);
(&self.gemm[gemm2_tier(tile)], tile.0, tile.1)
};
let bg = make_bg(ctx, pl, &[$x, &lw.w, &lw.b, $y], &meta);
passes.push((
pl,
bg,
lw.n.div_ceil(bn as u32),
(m as u32).div_ceil(bm as u32),
));
keep.push(meta);
}};
}
// project_start / project_end: Linear → ReLU → Linear (GLiNER's create_projection_layer),
// activations folded into the GEMM epilogue.
//
// The SECOND ReLU is SpanMarkerV0's, applied to the concatenation `relu(start ‖ end)`.
// ReLU is elementwise, so `relu(start ‖ end) == relu(start) ‖ relu(end)` — which is what
// lets the concat seam be split, and it means `start`/`end` leave these GEMMs already
// rectified. No extra pass.
let r = Some(Act::Relu);
gemm!(&self.words, &self.ps_up, &self.mid_a, n_words, r, true);
gemm!(&self.mid_a, &self.ps_down, &self.start, n_words, r, true);
gemm!(&self.words, &self.pe_up, &self.mid_b, n_words, r, true);
gemm!(&self.mid_b, &self.pe_down, &self.end, n_words, r, true);
// A = W_left·relu(start), B = W_right·relu(end) + b_up — the concat-seam split.
gemm!(&self.start, &self.up_left, &self.a, n_words, None, false);
gemm!(&self.end, &self.up_right, &self.b, n_words, None, true);
// h[s] = relu(A[l] + B[l+k])
let meta = uni(
ctx,
bytemuck::cast_slice(&[
n_spans as u32,
up_w as u32,
self.max_width as u32,
n_words as u32,
]),
);
let bg = make_bg(ctx, &self.assemble, &[&self.a, &self.b, &self.h], &meta);
passes.push((&self.assemble, bg, n_spans as u32, 1));
keep.push(meta);
// span_reps = h · W_downᵀ + b → scores = span_reps · promptᵀ
gemm!(&self.h, &self.down, &self.span_reps, n_spans, None, true);
let score_w = GpuLinear {
w: self.prompt.clone(),
b: self.no_bias.clone(),
n: n_types as u32,
k: d as u32,
v3: false, // per-call upload in [C, d] — no load-time transpose to ride
};
gemm!(
&self.span_reps,
&score_w,
&self.scores,
n_spans,
None,
false
);
let mut enc = ctx
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
{
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor::default());
for (pl, bg, gx, gy) in &passes {
pass.set_pipeline(pl);
pass.set_bind_group(0, bg, &[]);
pass.dispatch_workgroups(*gx, *gy, 1);
}
}
ctx.queue.submit([enc.finish()]);
let out = ctx
.read(&self.scores, n_spans * n_types)
.context("gliner gpu head readback")?;
drop(keep);
Ok(out)
}
}