Coverage for transformer_lens/model_bridge/generalized_components/gated_delta_net.py: 88%

186 statements  

« prev     ^ index     » next       coverage.py v7.10.1, created at 2026-09-01 16:23 +0000

1"""GatedDeltaNet bridge for Qwen3.5/Qwen3Next linear-attention layers. 

2 

3Reimplements forward (prefill only) to expose mech-interp-relevant intermediate 

4states. Falls back to HF native forward during autoregressive generation where 

5cache state management is required. 

6""" 

7from typing import Any, Dict, Optional 

8 

9import torch 

10import torch.nn.functional as F 

11 

12from transformer_lens.ActivationCache import ActivationCache 

13from transformer_lens.hook_points import HookPoint 

14from transformer_lens.model_bridge.generalized_components.base import ( 

15 GeneralizedComponent, 

16) 

17from transformer_lens.model_bridge.generalized_components.ssm_protocol import ( 

18 SSMStateHookMixin, 

19) 

20 

21 

22class GatedDeltaNetBridge(SSMStateHookMixin, GeneralizedComponent): 

23 """Bridge for GatedDeltaNet linear-attention with full hook decomposition. 

24 

25 Hooks (prefill, in execution order): 

26 hook_in: input hidden_states [batch, seq, d_model] 

27 hook_q_pre_conv: Q after projection, before conv [batch, seq, n_k_heads, head_k_dim] 

28 hook_k_pre_conv: K after projection, before conv [batch, seq, n_k_heads, head_k_dim] 

29 hook_v_pre_conv: V after projection, before conv [batch, seq, n_v_heads, head_v_dim] 

30 hook_q: Q after conv, pre-GQA-expansion [batch, seq, n_k_heads, head_k_dim] 

31 Note: on standard attn layers, hook_q is post-projection. Here it's 

32 post-conv — use hook_q_pre_conv for the projection-only output. 

33 hook_k: K after conv [batch, seq, n_k_heads, head_k_dim] 

34 hook_v: V after conv [batch, seq, n_v_heads, head_v_dim] 

35 hook_beta_logit: pre-sigmoid write gate logit, per v-head [batch, seq, n_v_heads] 

36 hook_beta: write strength sigmoid(b), per v-head [batch, seq, n_v_heads] 

37 hook_log_decay: log-space decay g (NEGATIVE; multiplicative decay = exp(g)), 

38 per v-head [batch, seq, n_v_heads] 

39 hook_recurrence_out: output of linear recurrence [batch, seq, n_v_heads, head_v_dim] 

40 hook_gate_input: z tensor (pre-silu) for GatedRMSNorm [batch, seq, n_v_heads, head_v_dim] 

41 hook_ssm_state: recurrent state trajectory S_t [batch, seq, n_v_heads, head_k_dim, 

42 head_v_dim] — fires ONLY on the opt-in eager-scan path (eager_scan=True), 

43 which swaps the fused kernel for a Python delta-rule scan so S_t can be 

44 read/patched. hook_ssm_write (alias -> hook_beta) is the write strength and 

45 propagates through the scan. 

46 hook_out: final output to residual stream [batch, seq, d_model] 

47 

48 During generation (cache_params present), only hook_in/hook_out fire. 

49 

50 Property aliases: 

51 W_in_proj_qkvz, W_in_proj_ba, W_out_proj, A_log, dt_bias 

52 """ 

53 

54 hook_aliases = { 

55 "hook_linear_attn_in": "hook_in", 

56 "hook_linear_attn_out": "hook_out", 

57 # Canonical SSM vocabulary (additive) — maps onto GDN's existing hooks so 

58 # interp tools can find these quantities by the same name across families. 

59 # Semantic mapping: q reads the state (~C), k writes to it (~B), the gate 

60 # g is the decay, beta is the per-step write strength. 

61 "hook_ssm_out": "hook_out", 

62 "hook_ssm_C": "hook_q", 

63 "hook_ssm_B": "hook_k", 

64 "hook_ssm_decay": "hook_log_decay", 

65 "hook_ssm_write": "hook_beta", 

66 } 

67 

68 property_aliases = { 

69 "W_in_proj_qkvz": "in_proj_qkvz.weight", 

70 "W_in_proj_ba": "in_proj_ba.weight", 

71 "W_out_proj": "out_proj.weight", 

72 "A_log": "A_log", 

73 "dt_bias": "dt_bias", 

74 } 

75 

76 def __init__( 

77 self, 

78 name: str, 

79 config: Optional[Any] = None, 

80 submodules: Optional[Dict[str, GeneralizedComponent]] = None, 

81 **kwargs, 

82 ): 

83 super().__init__(name, config=config, submodules=submodules or {}, **kwargs) 

84 # Pre-conv (after projection split, before causal conv mixes positions) 

85 self.hook_q_pre_conv = HookPoint() 

86 self.hook_k_pre_conv = HookPoint() 

87 self.hook_v_pre_conv = HookPoint() 

88 # Post-conv (pre-GQA-expansion, pre-recurrence) 

89 self.hook_q = HookPoint() 

90 self.hook_k = HookPoint() 

91 self.hook_v = HookPoint() 

92 # Gate parameters (per v-head) 

93 self.hook_beta_logit = HookPoint() 

94 self.hook_beta = HookPoint() 

95 self.hook_log_decay = HookPoint() 

96 # Recurrence output + gated norm input 

97 self.hook_recurrence_out = HookPoint() 

98 self.hook_gate_input = HookPoint() 

99 # hook_ssm_state + eager_scan come from SSMStateHookMixin; hook_ssm_write is 

100 # an alias onto hook_beta (the delta rule's write is state-dependent). 

101 

102 def forward(self, *args: Any, **kwargs: Any) -> Any: 

103 if self.original_component is None: 103 ↛ 104line 103 didn't jump to line 104 because the condition on line 103 was never true

104 raise RuntimeError(f"Original component not set for {self.name}.") 

105 

106 if kwargs.get("cache_params") is not None: 

107 return self._native_forward(*args, **kwargs) 

108 return self._hooked_forward(*args, **kwargs) 

109 

110 def _native_forward(self, *args: Any, **kwargs: Any) -> Any: 

111 """Delegate to HF with hook_in/hook_out only (generation path).""" 

112 assert self.original_component is not None 

113 if "hidden_states" in kwargs: 113 ↛ 115line 113 didn't jump to line 115 because the condition on line 113 was always true

114 kwargs["hidden_states"] = self.hook_in(kwargs["hidden_states"]) 

115 elif len(args) > 0 and isinstance(args[0], torch.Tensor): 

116 args = (self.hook_in(args[0]),) + args[1:] 

117 

118 output = self.original_component(*args, **kwargs) 

119 

120 if isinstance(output, tuple) and len(output) > 0: 120 ↛ 121line 120 didn't jump to line 121 because the condition on line 120 was never true

121 first = output[0] 

122 if isinstance(first, torch.Tensor): 

123 return (self.hook_out(first),) + output[1:] 

124 return output 

125 if isinstance(output, torch.Tensor): 125 ↛ 127line 125 didn't jump to line 127 because the condition on line 125 was always true

126 return self.hook_out(output) 

127 return output 

128 

129 def _hooked_forward(self, *args: Any, **kwargs: Any) -> Any: 

130 """Reimplemented forward exposing all intermediate states (prefill).""" 

131 hf: Any = self.original_component 

132 

133 if "hidden_states" in kwargs: 

134 hidden_states = kwargs["hidden_states"] 

135 elif len(args) > 0 and isinstance(args[0], torch.Tensor): 135 ↛ 138line 135 didn't jump to line 138 because the condition on line 135 was always true

136 hidden_states = args[0] 

137 else: 

138 raise ValueError("Could not find hidden_states") 

139 

140 attention_mask = kwargs.get("attention_mask") 

141 if attention_mask is not None: 141 ↛ 143line 141 didn't jump to line 143 because the condition on line 141 was never true

142 # Inline masking — avoids hard dependency on qwen3_next module 

143 hidden_states = hidden_states * attention_mask.unsqueeze(-1) 

144 

145 hidden_states = self.hook_in(hidden_states) 

146 batch_size, seq_len, _ = hidden_states.shape 

147 

148 # --- Projections (two layouts: fused vs split) --- 

149 if hasattr(hf, "in_proj_qkvz"): 

150 # Qwen3Next: fused Q+K+V+Z projection, fused beta+alpha 

151 projected_qkvz = hf.in_proj_qkvz(hidden_states) 

152 projected_ba = hf.in_proj_ba(hidden_states) 

153 query, key, value, z, b, a = hf.fix_query_key_value_ordering( 

154 projected_qkvz, projected_ba 

155 ) 

156 else: 

157 # Qwen3.5: separate projections (in_proj_qkv, in_proj_z, in_proj_b, in_proj_a) 

158 mixed_qkv_flat = hf.in_proj_qkv(hidden_states) 

159 z = hf.in_proj_z(hidden_states).reshape(batch_size, seq_len, -1, hf.head_v_dim) 

160 b = hf.in_proj_b(hidden_states) 

161 a = hf.in_proj_a(hidden_states) 

162 # Split QKV and reshape to per-head for pre-conv hooks 

163 q_flat, k_flat, v_flat = torch.split( 

164 mixed_qkv_flat, [hf.key_dim, hf.key_dim, hf.value_dim], dim=-1 

165 ) 

166 query = q_flat.reshape(batch_size, seq_len, -1, hf.head_k_dim) 

167 key = k_flat.reshape(batch_size, seq_len, -1, hf.head_k_dim) 

168 value = v_flat.reshape(batch_size, seq_len, -1, hf.head_v_dim) 

169 

170 # --- Pre-conv hooks (per-head shape, before conv mixes positions) --- 

171 query = self.hook_q_pre_conv(query) 

172 key = self.hook_k_pre_conv(key) 

173 value = self.hook_v_pre_conv(value) 

174 

175 # Flatten for conv 

176 query, key, value = (x.reshape(x.shape[0], x.shape[1], -1) for x in (query, key, value)) 

177 

178 # --- Causal Convolution --- 

179 mixed_qkv = torch.cat((query, key, value), dim=-1).transpose(1, 2) 

180 if hf.causal_conv1d_fn is not None: 180 ↛ 181line 180 didn't jump to line 181 because the condition on line 180 was never true

181 mixed_qkv = hf.causal_conv1d_fn( 

182 x=mixed_qkv, 

183 weight=hf.conv1d.weight.squeeze(1), 

184 bias=hf.conv1d.bias, 

185 activation=hf.activation, 

186 seq_idx=None, 

187 ) 

188 else: 

189 mixed_qkv = F.silu(hf.conv1d(mixed_qkv)[:, :, :seq_len]) 

190 mixed_qkv = mixed_qkv.transpose(1, 2) 

191 

192 # Split post-conv into per-head Q, K, V 

193 query, key, value = torch.split( 

194 mixed_qkv, 

195 [hf.key_dim, hf.key_dim, hf.value_dim], 

196 dim=-1, 

197 ) 

198 query = query.reshape(batch_size, seq_len, -1, hf.head_k_dim) 

199 key = key.reshape(batch_size, seq_len, -1, hf.head_k_dim) 

200 value = value.reshape(batch_size, seq_len, -1, hf.head_v_dim) 

201 

202 # --- Post-conv hooks (pre-GQA-expansion, pre-recurrence) --- 

203 query = self.hook_q(query) 

204 key = self.hook_k(key) 

205 value = self.hook_v(value) 

206 

207 # --- Gate parameters (per v-head) --- 

208 b = self.hook_beta_logit(b) 

209 beta = self.hook_beta(b.sigmoid()) 

210 

211 # g is log-space decay (NEGATIVE); multiplicative decay = exp(g) 

212 g = -hf.A_log.float().exp() * F.softplus(a.float() + hf.dt_bias) 

213 g = self.hook_log_decay(g) 

214 

215 # GQA expansion (Q/K from n_k_heads → n_v_heads) 

216 if hf.num_v_heads // hf.num_k_heads > 1: 

217 repeat = hf.num_v_heads // hf.num_k_heads 

218 query = query.repeat_interleave(repeat, dim=2) 

219 key = key.repeat_interleave(repeat, dim=2) 

220 

221 # --- Core linear recurrence --- 

222 # Default: HF's opaque fused kernel. eager_scan: a Python delta-rule scan 

223 # that fires hook_ssm_state so the state trajectory can be intervened on. 

224 if self.eager_scan: 

225 _, core_out = self._gated_delta_scan(query, key, value, g, beta, fire_hook=True) 

226 core_out = core_out.to(value.dtype) 

227 else: 

228 core_out, _ = hf.chunk_gated_delta_rule( 

229 query, 

230 key, 

231 value, 

232 g=g, 

233 beta=beta, 

234 initial_state=None, 

235 output_final_state=False, 

236 use_qk_l2norm_in_kernel=True, 

237 ) 

238 core_out = self.hook_recurrence_out(core_out) 

239 

240 # --- Gated RMSNorm: norm(core_out) * silu(z) --- 

241 z = self.hook_gate_input(z) 

242 z_shape = z.shape 

243 core_out = hf.norm( 

244 core_out.reshape(-1, core_out.shape[-1]), 

245 z.reshape(-1, z.shape[-1]), 

246 ) 

247 core_out = core_out.reshape(z_shape).reshape(batch_size, seq_len, -1) 

248 

249 # --- Output projection --- 

250 output = hf.out_proj(core_out) 

251 return self.hook_out(output) 

252 

253 @staticmethod 

254 def _l2norm(x: torch.Tensor, eps: float = 1e-6) -> torch.Tensor: 

255 """Match the kernel's internal Q/K L2 normalization (use_qk_l2norm_in_kernel).""" 

256 return x / torch.sqrt((x * x).sum(-1, keepdim=True) + eps) 

257 

258 def _gated_delta_scan( 

259 self, 

260 query: torch.Tensor, 

261 key: torch.Tensor, 

262 value: torch.Tensor, 

263 g: torch.Tensor, 

264 beta: torch.Tensor, 

265 fire_hook: bool, 

266 ) -> tuple[torch.Tensor, torch.Tensor]: 

267 """Eager gated-delta-rule recurrence — the readable form of the fused kernel. 

268 

269 Reproduces HF ``torch_recurrent_gated_delta_rule``: L2-normalize Q/K, scale 

270 Q by ``1/sqrt(head_k_dim)``, then per step:: 

271 

272 S_t = exp(g_t) · S_{t-1} (decay) 

273 delta_t = (v_t - S_t^T k_t) · beta_t (delta rule: remove then write) 

274 S_t = S_t + k_t ⊗ delta_t -> hook_ssm_state trajectory 

275 o_t = S_t^T q_t 

276 

277 Q/K are assumed already GQA-expanded to ``n_v_heads``. Matches the fused 

278 kernel only to fp tolerance (≈1e-6 fp32), never bit-for-bit; O(seq) Python. 

279 

280 Args: 

281 query, key: ``[batch, seq, n_v_heads, head_k_dim]`` (post-conv, pre-norm). 

282 value: ``[batch, seq, n_v_heads, head_v_dim]``. 

283 g: log-space decay ``[batch, seq, n_v_heads]`` (NEGATIVE; decay = exp(g)). 

284 beta: write strength ``[batch, seq, n_v_heads]``. 

285 fire_hook: if True, fire ``hook_ssm_state`` on the trajectory and 

286 recompute ``o_t`` from the (possibly patched) state. 

287 

288 Returns: 

289 ``(state_traj, core_out)`` — state ``[batch, seq, n_v_heads, head_k_dim, 

290 head_v_dim]`` and output ``[batch, seq, n_v_heads, head_v_dim]``. 

291 """ 

292 q = self._l2norm(query.float()) 

293 k = self._l2norm(key.float()) 

294 v = value.float() 

295 q = q * (q.shape[-1] ** -0.5) 

296 g_f = g.float() 

297 beta_f = beta.float() 

298 

299 batch, seq_len, n_v, k_dim = q.shape 

300 v_dim = v.shape[-1] 

301 state = torch.zeros(batch, n_v, k_dim, v_dim, dtype=q.dtype, device=q.device) 

302 states = [] 

303 outs = [] 

304 for t in range(seq_len): 

305 q_t, k_t, v_t = q[:, t], k[:, t], v[:, t] # [batch, n_v, dim] 

306 decay = g_f[:, t].exp()[:, :, None, None] # [batch, n_v, 1, 1] 

307 beta_t = beta_f[:, t][:, :, None] # [batch, n_v, 1] 

308 state = state * decay 

309 kv_mem = (state * k_t[:, :, :, None]).sum(dim=-2) # [batch, n_v, v_dim] 

310 delta = (v_t - kv_mem) * beta_t 

311 state = state + k_t[:, :, :, None] * delta[:, :, None, :] 

312 states.append(state) 

313 outs.append((state * q_t[:, :, :, None]).sum(dim=-2)) 

314 

315 state_traj = torch.stack(states, dim=1) # [batch, seq, n_v, k_dim, v_dim] 

316 if fire_hook: 

317 state_traj = self.hook_ssm_state(state_traj) 

318 # Recompute o_t = S_t^T q_t from the (possibly patched) trajectory. 

319 core_out = torch.einsum("bshkv,bshk->bshv", state_traj, q) 

320 else: 

321 core_out = torch.stack(outs, dim=1) # [batch, seq, n_v, v_dim] 

322 return state_traj, core_out 

323 

324 def compute_ssm_state( 

325 self, 

326 cache: ActivationCache, 

327 layer_idx: int, 

328 time_step: Optional[int] = None, 

329 ) -> torch.Tensor: 

330 """Reconstruct the recurrent state ``S`` of the gated delta rule from cache. 

331 

332 Read-only: replays the eager delta-rule scan (``_gated_delta_scan``) on the 

333 cached hook_q/k/v/beta/log_decay — no ``forward()`` re-run. Faithful (the 

334 full delta rule, key-removal included), unlike ``compute_effective_attention`` 

335 which is a gated-linear-attention heuristic that drops key removal. 

336 

337 Requires the interior hooks, which fire only on the hooked prefill path: 

338 call ``run_with_cache(tokens, use_cache=False)`` so ``cache_params`` is None. 

339 

340 On padded batches the cached hooks are unmasked, so ``S`` is exact only at 

341 non-pad positions; pad-position state is out of contract. 

342 

343 Args: 

344 cache: ActivationCache from ``run_with_cache(..., use_cache=False)``. 

345 layer_idx: Block index for this linear_attn layer. 

346 time_step: If given, return only ``S`` at that position 

347 (``[batch, n_v_heads, head_k_dim, head_v_dim]``). None returns 

348 every step. 

349 

350 Returns: 

351 ``[batch, seq, n_v_heads, head_k_dim, head_v_dim]`` for all steps, or 

352 ``[batch, n_v_heads, head_k_dim, head_v_dim]`` for a single ``time_step``. 

353 

354 Memory is O(batch · n_v_heads · seq · head_k_dim · head_v_dim); pass 

355 ``time_step`` (or short sequences) when that is too large. 

356 """ 

357 prefix = f"blocks.{layer_idx}.linear_attn" 

358 q_key = f"{prefix}.hook_q" 

359 k_key = f"{prefix}.hook_k" 

360 v_key = f"{prefix}.hook_v" 

361 beta_key = f"{prefix}.hook_beta" 

362 decay_key = f"{prefix}.hook_log_decay" 

363 

364 for key in (q_key, k_key, v_key, beta_key, decay_key): 

365 if key not in cache: 

366 raise RuntimeError( 

367 f"compute_ssm_state needs {key!r} in cache. Run " 

368 "run_with_cache(tokens, use_cache=False) on the bridge first." 

369 ) 

370 

371 q = cache[q_key].float() # [batch, seq, n_k_heads, head_k_dim] 

372 k = cache[k_key].float() 

373 v = cache[v_key].float() # [batch, seq, n_v_heads, head_v_dim] 

374 beta = cache[beta_key].float() # [batch, seq, n_v_heads] 

375 g = cache[decay_key].float() # [batch, seq, n_v_heads] 

376 

377 # GQA expansion to n_v_heads (Q/K carry n_k_heads). 

378 n_v = v.shape[2] 

379 if q.shape[2] < n_v: 379 ↛ 380line 379 didn't jump to line 380 because the condition on line 379 was never true

380 repeat = n_v // q.shape[2] 

381 q = q.repeat_interleave(repeat, dim=2) 

382 k = k.repeat_interleave(repeat, dim=2) 

383 

384 state_traj, _ = self._gated_delta_scan(q, k, v, g, beta, fire_hook=False) 

385 if time_step is not None: 

386 return state_traj[:, time_step] 

387 return state_traj 

388 

389 def compute_effective_attention( 

390 self, 

391 cache: ActivationCache, 

392 layer_idx: int, 

393 ) -> torch.Tensor: 

394 """Materialize a heuristic effective-attention matrix from cached hooks. 

395 

396 Uses the gated-linear-attention form of the recurrence (the exact gated 

397 *delta* rule additionally removes the key being written):: 

398 

399 S_t ≈ exp(g_t) * S_{t-1} + beta_t * v_t @ k_t^T 

400 o_t = S_t^T @ q_t 

401 M[i,j] = (q_i^T @ k_j) * beta_j * prod_{t=j+1}^{i} exp(g_t) 

402 

403 so ``M`` is an interpretability heuristic, not a faithful output decomposition. 

404 

405 Requires the interior hooks (hook_q/k/beta/log_decay), which fire only on 

406 the hooked prefill path: call ``run_with_cache(tokens, use_cache=False)`` 

407 so ``cache_params`` is None. The default cached path exposes only 

408 hook_in/hook_out and this method then raises. 

409 

410 **Measured divergence (tiny random-init test fixture, seed-stable):** 

411 

412 - *L2-norm gap.* The fused kernel L2-normalizes Q/K internally 

413 (``use_qk_l2norm_in_kernel=True``) but the hooked Q/K are pre-norm, so 

414 ``M`` differs from the normalized form by ≈1.0 relative when Q/K norms 

415 are small/non-uniform (the random-init regime); the gap shrinks toward 0 

416 as norms equalize after training. 

417 - *Delta-rule omission.* Even with normalized Q/K, ``M @ V`` reconstructs 

418 the fused-kernel ``hook_recurrence_out`` only to O(1) relative error 

419 because the key-removal term is dropped. 

420 

421 Args: 

422 cache: ActivationCache from ``run_with_cache(..., use_cache=False)``. 

423 layer_idx: Block index for this linear_attn layer. 

424 

425 Returns: 

426 ``[batch, n_v_heads, seq, seq]`` causal matrix (upper triangle zero). 

427 

428 Cost is O(batch * n_heads * seq^2); use on short sequences. 

429 """ 

430 prefix = f"blocks.{layer_idx}.linear_attn" 

431 q_key = f"{prefix}.hook_q" 

432 k_key = f"{prefix}.hook_k" 

433 beta_key = f"{prefix}.hook_beta" 

434 decay_key = f"{prefix}.hook_log_decay" 

435 

436 for key in [q_key, k_key, beta_key, decay_key]: 

437 if key not in cache: 

438 raise RuntimeError( 

439 f"compute_effective_attention needs {key!r} in cache. " 

440 "Run run_with_cache() on the bridge first." 

441 ) 

442 

443 # [batch, seq, n_k_heads, head_k_dim] — pre-GQA-expansion 

444 q = cache[q_key].float() 

445 k = cache[k_key].float() 

446 beta = cache[beta_key].float() # [batch, seq, n_v_heads] 

447 g = cache[decay_key].float() # [batch, seq, n_v_heads] 

448 

449 # GQA expansion to match n_v_heads 

450 if q.shape[2] < beta.shape[-1]: 450 ↛ 451line 450 didn't jump to line 451 because the condition on line 450 was never true

451 repeat = beta.shape[-1] // q.shape[2] 

452 q = q.repeat_interleave(repeat, dim=2) 

453 k = k.repeat_interleave(repeat, dim=2) 

454 

455 batch, seq, n_heads, d_head = q.shape 

456 

457 # QK similarity: [batch, n_heads, seq_i, seq_j] 

458 q_perm = q.permute(0, 2, 1, 3) 

459 k_perm = k.permute(0, 2, 1, 3) 

460 qk = torch.matmul(q_perm, k_perm.transpose(-2, -1)) 

461 

462 # Cumulative decay: L[i,j] = exp(sum g[j+1..i]) 

463 g_perm = g.permute(0, 2, 1) # [batch, n_heads, seq] 

464 cumsum_g = torch.cumsum(g_perm, dim=-1) 

465 L_log = cumsum_g[:, :, :, None] - cumsum_g[:, :, None, :] 

466 

467 causal_mask = torch.tril(torch.ones(seq, seq, dtype=torch.bool, device=q.device)) 

468 L = torch.where(causal_mask[None, None], torch.exp(L_log), torch.zeros_like(L_log)) 

469 

470 # M[i,j] = qk[i,j] * beta[j] * L[i,j] 

471 beta_col = beta.permute(0, 2, 1)[:, :, None, :] 

472 return qk * beta_col * L