Skip to main content

memra_engine/
vision.rs

1//! Vision tower for Qwen3.8-27B multimodal input (lane/vision, 2026-08-15).
2//!
3//! The qwen3_5_vision ViT (depth 27, hidden 1152, heads 16, gelu_pytorch_tanh, patch 16,
4//! spatial_merge 2, temporal_patch 2, LEARNED pos embeddings on a 48x48 grid) lives in the
5//! official checkpoint's `outside.safetensors` (the unquantized shard) — the quantized
6//! trunks (ct-NVFP4 etc.) strip it. `MEMRA_VISION_DIR` points at any directory carrying
7//! that shard; the tower output is plain [n_tokens, out_width] embeddings (out_width =
8//! merger fc2 out_features, derived from the shard — 5120 on q38-27B, 2048 on
9//! Ornith-1.5-35B-A3B), so vision requests serve on any trunk whose n_embd matches the
10//! shard's. Text side uses standard sequential rope (rope_scaling is null on these
11//! models — no M-RoPE), so spliced image tokens take ordinary positions.
12//!
13//! v1 posture: correctness-first — cuBLASLt f32 GEMMs (`Engine::linear` + bias epilogue),
14//! `sdpa_naive(causal=false)` for the bidirectional attention, host-side permutes between
15//! stages (the tower is a small fraction of a vision request; optimize later). Parity gate:
16//! merger-output cosine vs the HF reference per VISION-LANE.md.
17
18use crate::Engine;
19use cudarc::driver::CudaSlice;
20use memra_gguf::dequant::bf16_to_f32;
21use memra_gguf::safetensors::StShard;
22use std::path::Path;
23
24pub const V_HIDDEN: usize = 1152;
25pub const V_HEADS: usize = 16;
26pub const V_HEAD_DIM: usize = V_HIDDEN / V_HEADS; // 72
27pub const V_INTER: usize = 4304;
28pub const V_DEPTH: usize = 27;
29pub const V_PATCH: usize = 16;
30pub const V_MERGE: usize = 2;
31pub const V_TEMPORAL: usize = 2;
32pub const V_POS_GRID: usize = 48; // 2304 learned positions = 48x48
33pub const V_PATCH_IN: usize = 3 * V_TEMPORAL * V_PATCH * V_PATCH; // 1536
34pub const V_MERGED_IN: usize = V_HIDDEN * V_MERGE * V_MERGE; // 4608
35const LN_EPS: f32 = 1e-6;
36
37/// Mixed-embedding prime overlay: image embeddings that replace `<|image_pad|>` token
38/// embeddings at prompt-relative positions during `prime_cache_overlaid`. `rows` holds all
39/// images' merger outputs concatenated ([total_rows, n_embd]); each span is
40/// `(prompt_pos, row_off, n_rows)` — rows `[row_off, row_off+n_rows)` land at prompt
41/// positions `[prompt_pos, prompt_pos+n_rows)`. Spans must not overlap.
42pub struct EmbedOverlay {
43    pub rows: CudaSlice<f32>,
44    pub spans: Vec<(usize, usize, usize)>,
45}
46
47impl EmbedOverlay {
48    /// Sub-window for a prime call covering prompt-relative `[off, off+len)`: spans clipped
49    /// and rebased so the callee sees call-relative positions (the serve prefill tick primes
50    /// a prompt across multiple `prime_cache_overlaid` calls). `rows` is an Arc clone, not a
51    /// copy. None = no image rows in this window (caller may prime plain).
52    pub fn window(&self, off: usize, len: usize) -> Option<EmbedOverlay> {
53        let spans: Vec<(usize, usize, usize)> = self
54            .spans
55            .iter()
56            .filter_map(|&(pos, row_off, n_rows)| {
57                let lo = pos.max(off);
58                let hi = (pos + n_rows).min(off + len);
59                (lo < hi).then(|| (lo - off, row_off + (lo - pos), hi - lo))
60            })
61            .collect();
62        (!spans.is_empty()).then(|| EmbedOverlay {
63            rows: self.rows.clone(),
64            spans,
65        })
66    }
67
68    /// The mixed-embedding splice itself: image rows overwrite placeholder-token embeddings
69    /// inside a prime call's prompt-relative window `[chunk_off, chunk_off + t)`, BEFORE any
70    /// downstream transform (stream expansion, trunk walk). ONE implementation shared by the
71    /// single-engine hyper walk and the ppN stage-0 intake (lane/glm5-vision-default-on) so
72    /// the splice point cannot drift between arms. `embedded` is the `[t, n_embd]` token
73    /// embedding buffer of this call; `rows` must live on `e`'s device.
74    pub fn splice_into(
75        &self,
76        e: &Engine,
77        embedded: &mut CudaSlice<f32>,
78        chunk_off: usize,
79        t: usize,
80        n_embd: usize,
81    ) -> Result<(), Box<dyn std::error::Error>> {
82        for &(pos, row_off, n_rows) in &self.spans {
83            let lo = pos.max(chunk_off);
84            let hi = (pos + n_rows).min(chunk_off + t);
85            if lo < hi {
86                let src_row = row_off + (lo - pos);
87                let view = self
88                    .rows
89                    .slice(src_row * n_embd..(src_row + (hi - lo)) * n_embd);
90                e.copy_view_into(
91                    embedded,
92                    (lo - chunk_off) * n_embd,
93                    &view,
94                    (hi - lo) * n_embd,
95                )?;
96            }
97        }
98        Ok(())
99    }
100}
101
102struct Lin {
103    w: CudaSlice<f32>,
104    b: CudaSlice<f32>,
105    in_f: usize,
106    out_f: usize,
107}
108
109struct VisBlock {
110    norm1_w: CudaSlice<f32>,
111    norm1_b: CudaSlice<f32>,
112    norm2_w: CudaSlice<f32>,
113    norm2_b: CudaSlice<f32>,
114    qkv: Lin,
115    proj: Lin,
116    fc1: Lin,
117    fc2: Lin,
118}
119
120pub struct VisionTower {
121    patch: Lin,
122    /// Host copy of the learned pos table [2304, 1152] — bilinear-interpolated per grid.
123    pos: Vec<f32>,
124    blocks: Vec<VisBlock>,
125    merger_norm_w: CudaSlice<f32>,
126    merger_norm_b: CudaSlice<f32>,
127    merger_fc1: Lin,
128    merger_fc2: Lin,
129}
130
131fn read_f32(sh: &StShard, name: &str) -> Result<Vec<f32>, Box<dyn std::error::Error>> {
132    let (info, raw) = sh
133        .raw(name)
134        .ok_or_else(|| format!("vision tensor missing: {name}"))?;
135    match info.dtype.as_str() {
136        "BF16" => Ok(raw
137            .chunks_exact(2)
138            .map(|c| bf16_to_f32(u16::from_le_bytes([c[0], c[1]])))
139            .collect()),
140        "F32" => Ok(raw
141            .chunks_exact(4)
142            .map(|c| f32::from_le_bytes(c.try_into().unwrap()))
143            .collect()),
144        other => Err(format!("vision tensor {name}: unsupported dtype {other}").into()),
145    }
146}
147
148fn load_lin(
149    e: &Engine,
150    sh: &StShard,
151    stem: &str,
152    in_f: usize,
153    out_f: usize,
154) -> Result<Lin, Box<dyn std::error::Error>> {
155    let w = read_f32(sh, &format!("{stem}.weight"))?;
156    let b = read_f32(sh, &format!("{stem}.bias"))?;
157    assert_eq!(w.len(), in_f * out_f, "{stem}.weight shape");
158    assert_eq!(b.len(), out_f, "{stem}.bias shape");
159    Ok(Lin {
160        w: e.htod(&w)?,
161        b: e.htod(&b)?,
162        in_f,
163        out_f,
164    })
165}
166
167impl VisionTower {
168    /// Load the tower from a directory containing `outside.safetensors`.
169    pub fn load(e: &Engine, dir: &Path) -> Result<Self, Box<dyn std::error::Error>> {
170        let sh = StShard::open(dir.join("outside.safetensors"))?;
171        let p = "model.visual";
172        let patch = {
173            // conv [1152, 3, 2, 16, 16] flattens to Linear 1536 -> 1152 (HF patchify order:
174            // channel-major within the (c, t, h, w) patch — the preprocessor emits the
175            // matching flat order).
176            let w = read_f32(&sh, &format!("{p}.patch_embed.proj.weight"))?;
177            let b = read_f32(&sh, &format!("{p}.patch_embed.proj.bias"))?;
178            assert_eq!(w.len(), V_HIDDEN * V_PATCH_IN);
179            Lin {
180                w: e.htod(&w)?,
181                b: e.htod(&b)?,
182                in_f: V_PATCH_IN,
183                out_f: V_HIDDEN,
184            }
185        };
186        let pos = read_f32(&sh, &format!("{p}.pos_embed.weight"))?;
187        assert_eq!(pos.len(), V_POS_GRID * V_POS_GRID * V_HIDDEN);
188        let mut blocks = Vec::with_capacity(V_DEPTH);
189        for il in 0..V_DEPTH {
190            let bp = format!("{p}.blocks.{il}");
191            blocks.push(VisBlock {
192                norm1_w: e.htod(&read_f32(&sh, &format!("{bp}.norm1.weight"))?)?,
193                norm1_b: e.htod(&read_f32(&sh, &format!("{bp}.norm1.bias"))?)?,
194                norm2_w: e.htod(&read_f32(&sh, &format!("{bp}.norm2.weight"))?)?,
195                norm2_b: e.htod(&read_f32(&sh, &format!("{bp}.norm2.bias"))?)?,
196                qkv: load_lin(e, &sh, &format!("{bp}.attn.qkv"), V_HIDDEN, 3 * V_HIDDEN)?,
197                proj: load_lin(e, &sh, &format!("{bp}.attn.proj"), V_HIDDEN, V_HIDDEN)?,
198                fc1: load_lin(e, &sh, &format!("{bp}.mlp.linear_fc1"), V_HIDDEN, V_INTER)?,
199                fc2: load_lin(e, &sh, &format!("{bp}.mlp.linear_fc2"), V_INTER, V_HIDDEN)?,
200            });
201        }
202        let merger_norm_w = e.htod(&read_f32(&sh, &format!("{p}.merger.norm.weight"))?)?;
203        let merger_norm_b = e.htod(&read_f32(&sh, &format!("{p}.merger.norm.bias"))?)?;
204        let merger_fc1 = load_lin(
205            e,
206            &sh,
207            &format!("{p}.merger.linear_fc1"),
208            V_MERGED_IN,
209            V_MERGED_IN,
210        )?;
211        let merger_fc2 = {
212            // Output width is the TRUNK's embedding width (5120 on q38, 2048 on ornith15) —
213            // derived from the shard's merger shape, never assumed. Admission compares the
214            // serving trunk's n_embd against `out_width()`.
215            let w = read_f32(&sh, &format!("{p}.merger.linear_fc2.weight"))?;
216            let b = read_f32(&sh, &format!("{p}.merger.linear_fc2.bias"))?;
217            assert_eq!(w.len() % V_MERGED_IN, 0, "merger.linear_fc2.weight shape");
218            let out_f = w.len() / V_MERGED_IN;
219            assert_eq!(b.len(), out_f, "merger.linear_fc2.bias shape");
220            Lin {
221                w: e.htod(&w)?,
222                b: e.htod(&b)?,
223                in_f: V_MERGED_IN,
224                out_f,
225            }
226        };
227        eprintln!(
228            "[vision] tower loaded from {} ({} blocks, out_width {}, f32-resident)",
229            dir.display(),
230            V_DEPTH,
231            merger_fc2.out_f
232        );
233        Ok(Self {
234            patch,
235            pos,
236            blocks,
237            merger_norm_w,
238            merger_norm_b,
239            merger_fc1,
240            merger_fc2,
241        })
242    }
243
244    /// Embedding width this tower emits per merged token (the merger fc2 out_features,
245    /// i.e. the trunk n_embd of the checkpoint the shard came from).
246    pub fn out_width(&self) -> usize {
247        self.merger_fc2.out_f
248    }
249
250    fn linear_bias(
251        &self,
252        e: &Engine,
253        x: &CudaSlice<f32>,
254        l: &Lin,
255        m: usize,
256    ) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
257        let mut y = e.linear(x, &l.w, m, l.in_f, l.out_f)?;
258        // Row-broadcast bias via add_row_inplace (per-row launch; the tower is small and
259        // v1 is correctness-first — the cuBLASLt bias epilogue is the later optimization).
260        for r in 0..m {
261            e.add_row_inplace(&mut y, &l.b, l.out_f, r * l.out_f)?;
262        }
263        Ok(y)
264    }
265
266    /// Bilinear-interpolate the 48x48 learned pos table to [gh, gw] and return host
267    /// [gh*gw, 1152] (added to the patch embeddings).
268    fn pos_for_grid(&self, gh: usize, gw: usize) -> Vec<f32> {
269        let g = V_POS_GRID as f32;
270        let mut out = vec![0f32; gh * gw * V_HIDDEN];
271        for y in 0..gh {
272            for x in 0..gw {
273                // HF fast_pos_embed_interpolate: linspace(0, 47, g) == align_corners=TRUE
274                let sy = if gh > 1 {
275                    y as f32 * (g - 1.0) / (gh as f32 - 1.0)
276                } else {
277                    0.0
278                };
279                let sx = if gw > 1 {
280                    x as f32 * (g - 1.0) / (gw as f32 - 1.0)
281                } else {
282                    0.0
283                };
284                let (y0, x0) = (sy.floor() as usize, sx.floor() as usize);
285                let (y1, x1) = ((y0 + 1).min(V_POS_GRID - 1), (x0 + 1).min(V_POS_GRID - 1));
286                let (fy, fx) = (sy - y0 as f32, sx - x0 as f32);
287                let dst = &mut out[(y * gw + x) * V_HIDDEN..(y * gw + x + 1) * V_HIDDEN];
288                #[allow(clippy::needless_range_loop)]
289                // allow: the explicit index loop keeps the offset arithmetic visible and aligned with the device-side indexing
290                for c in 0..V_HIDDEN {
291                    let p00 = self.pos[(y0 * V_POS_GRID + x0) * V_HIDDEN + c];
292                    let p01 = self.pos[(y0 * V_POS_GRID + x1) * V_HIDDEN + c];
293                    let p10 = self.pos[(y1 * V_POS_GRID + x0) * V_HIDDEN + c];
294                    let p11 = self.pos[(y1 * V_POS_GRID + x1) * V_HIDDEN + c];
295                    dst[c] = p00 * (1.0 - fy) * (1.0 - fx)
296                        + p01 * (1.0 - fy) * fx
297                        + p10 * fy * (1.0 - fx)
298                        + p11 * fy * fx;
299                }
300            }
301        }
302        out
303    }
304
305    /// Forward one image's patches -> [gh*gw/4, 5120] merged embeddings (device).
306    /// `patches` is host [gh*gw, 1536] in the preprocessor's (c, t, ph, pw) flat order.
307    pub fn forward(
308        &self,
309        e: &Engine,
310        patches: &[f32],
311        gh: usize,
312        gw: usize,
313    ) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
314        self.forward_seq(e, patches, 1, gh, gw)
315    }
316
317    /// Forward `groups` temporal groups of one video (or a single image at groups=1):
318    /// host patches [groups*gh*gw, 1536], frame-major -> [groups*gh*gw/4, 5120] merged
319    /// embeddings, frame-major. HF cu_seqlens law (vision_utils.get_vision_cu_seqlens,
320    /// merge_temporal=False — the qwen2_vl/qwen3_vl/qwen3_5 convention): EACH temporal
321    /// group is its own attention segment, and pos table / rope / merger are all
322    /// frame-local too — so a video is exactly its groups run through the single-image
323    /// forward, concatenated. (Joint clip attention is the kimi_k25 convention only;
324    /// parity receipt: joint span scored mean_cos 0.92 vs the HF oracle, per-group 1.0.)
325    pub fn forward_seq(
326        &self,
327        e: &Engine,
328        patches: &[f32],
329        groups: usize,
330        gh: usize,
331        gw: usize,
332    ) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
333        let n = groups * gh * gw;
334        assert_eq!(patches.len(), n * V_PATCH_IN, "patch buffer shape");
335        if groups > 1 {
336            let frame = gh * gw;
337            let out_per = frame / (V_MERGE * V_MERGE) * self.merger_fc2.out_f;
338            let mut out = e.uninit(groups * out_per)?;
339            for g in 0..groups {
340                let emb = self.forward_one(
341                    e,
342                    &patches[g * frame * V_PATCH_IN..(g + 1) * frame * V_PATCH_IN],
343                    gh,
344                    gw,
345                )?;
346                e.dtod_copy_into(&emb, &mut out, g * out_per)?;
347            }
348            return Ok(out);
349        }
350        self.forward_one(e, patches, gh, gw)
351    }
352
353    /// One attention segment (a single image, or one temporal group of a video).
354    fn forward_one(
355        &self,
356        e: &Engine,
357        patches: &[f32],
358        gh: usize,
359        gw: usize,
360    ) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
361        let groups = 1usize;
362        let n = gh * gw;
363        assert_eq!(patches.len(), n * V_PATCH_IN, "patch buffer shape");
364        if n > 12288 {
365            return Err(format!(
366                "vision segment {n} patches exceeds the sdpa shared-memory ceiling (12288); \
367                 lower the pixel budget"
368            )
369            .into());
370        }
371        let xd = e.htod(patches)?;
372        let mut x = self.linear_bias(e, &xd, &self.patch, n)?;
373        // + interpolated pos embed (same table every temporal group)
374        let pos_one = self.pos_for_grid(gh, gw);
375        let mut pos = Vec::with_capacity(n * V_HIDDEN);
376        for _ in 0..groups {
377            pos.extend_from_slice(&pos_one);
378        }
379        let pos_d = e.htod(&pos)?;
380        let mut x2 = e.zeros(n * V_HIDDEN)?;
381        e.add(&x, &pos_d, &mut x2, n * V_HIDDEN)?;
382        x = x2;
383        // dev-only stage dumps for the HF parity bisect (row-major grid order, f32 LE)
384        let dbg = std::env::var("MEMRA_VISION_DEBUG").ok();
385        let dump = |tag: &str, buf: &[f32]| {
386            if let Some(dir) = dbg.as_deref() {
387                let raw: Vec<u8> = buf.iter().flat_map(|v| v.to_le_bytes()).collect();
388                let _ = std::fs::write(format!("{dir}/rust_{tag}.bin"), raw);
389            }
390        };
391        if dbg.is_some() {
392            dump("pre_blocks", &e.dtoh(&x)?);
393        }
394        let scale = 1.0 / (V_HEAD_DIM as f32).sqrt();
395        // 2D vision rope (Qwen3_5VisionRotaryEmbedding, theta 10000): per token (y, x) the
396        // head_dim/2 = 36 rotation angles are [y * inv_freq[0..18], x * inv_freq[0..18]],
397        // GPT-NeoX pairing (d, d+36). Same table for every block/head — precompute cos/sin.
398        let half = V_HEAD_DIM / 2; // 36
399        let quarter = half / 2; // 18
400        let inv_freq: Vec<f32> = (0..quarter)
401            .map(|i| 10000f32.powf(-(i as f32) / quarter as f32))
402            .collect();
403        let mut rope_cos = vec![0f32; n * half];
404        let mut rope_sin = vec![0f32; n * half];
405        for t in 0..n {
406            let f = t % (gh * gw); // frame-local index (rope has no temporal axis here)
407            let (y, x) = (f / gw, f % gw);
408            for d in 0..half {
409                let f = if d < quarter {
410                    y as f32 * inv_freq[d]
411                } else {
412                    x as f32 * inv_freq[d - quarter]
413                };
414                rope_cos[t * half + d] = f.cos();
415                rope_sin[t * half + d] = f.sin();
416            }
417        }
418        for (ib, blk) in self.blocks.iter().enumerate() {
419            // attn: ln1 -> qkv -> sdpa(causal=false) -> proj -> +res
420            let mut h = e.zeros(n * V_HIDDEN)?;
421            e.layer_norm_bias(&x, &blk.norm1_w, &blk.norm1_b, &mut h, V_HIDDEN, n, LN_EPS)?;
422            let qkv = self.linear_bias(e, &h, &blk.qkv, n)?;
423            // sdpa_naive consumes token-major [T, n_head, head_dim] — exactly the qkv GEMM
424            // row layout, so q/k/v are column splits of each row (no permute). Host pass
425            // applies the vision rope to q/k on the way (v untouched).
426            let qkv_h = e.dtoh(&qkv)?;
427            let mut qh = vec![0f32; n * V_HIDDEN];
428            let mut kh = vec![0f32; n * V_HIDDEN];
429            let mut vh = vec![0f32; n * V_HIDDEN];
430            for t in 0..n {
431                let row = &qkv_h[t * 3 * V_HIDDEN..(t + 1) * 3 * V_HIDDEN];
432                let dst = t * V_HIDDEN;
433                vh[dst..dst + V_HIDDEN].copy_from_slice(&row[2 * V_HIDDEN..3 * V_HIDDEN]);
434                for hd in 0..V_HEADS {
435                    let o = hd * V_HEAD_DIM;
436                    // rotate-half pairs (d, d+36), angles shared across heads
437                    for d in 0..half {
438                        let (c, sn) = (rope_cos[t * half + d], rope_sin[t * half + d]);
439                        let (qa, qb) = (row[o + d], row[o + d + half]);
440                        qh[dst + o + d] = qa * c - qb * sn;
441                        qh[dst + o + d + half] = qb * c + qa * sn;
442                        let (ka, kb) = (row[V_HIDDEN + o + d], row[V_HIDDEN + o + d + half]);
443                        kh[dst + o + d] = ka * c - kb * sn;
444                        kh[dst + o + d + half] = kb * c + ka * sn;
445                    }
446                }
447            }
448            let (qd, kd, vd) = (e.htod(&qh)?, e.htod(&kh)?, e.htod(&vh)?);
449            let mut od = e.zeros(n * V_HIDDEN)?;
450            e.sdpa_naive(
451                &qd, &kd, &vd, &mut od, V_HEAD_DIM, V_HEADS, V_HEADS, n, n, scale, false,
452            )?;
453            let attn = self.linear_bias(e, &od, &blk.proj, n)?;
454            let mut xr = e.zeros(n * V_HIDDEN)?;
455            e.add(&x, &attn, &mut xr, n * V_HIDDEN)?;
456            // mlp: ln2 -> fc1 -> gelu_tanh -> fc2 -> +res
457            let mut h2 = e.zeros(n * V_HIDDEN)?;
458            e.layer_norm_bias(
459                &xr,
460                &blk.norm2_w,
461                &blk.norm2_b,
462                &mut h2,
463                V_HIDDEN,
464                n,
465                LN_EPS,
466            )?;
467            let f1 = self.linear_bias(e, &h2, &blk.fc1, n)?;
468            let mut g = e.zeros(n * V_INTER)?;
469            e.gelu_tanh(&f1, &mut g, n * V_INTER)?;
470            let f2 = self.linear_bias(e, &g, &blk.fc2, n)?;
471            let mut xn = e.zeros(n * V_HIDDEN)?;
472            e.add(&xr, &f2, &mut xn, n * V_HIDDEN)?;
473            x = xn;
474            if dbg.is_some() && ib == 0 {
475                dump("blk0", &e.dtoh(&x)?);
476            }
477        }
478        if dbg.is_some() {
479            dump("post_blocks", &e.dtoh(&x)?);
480        }
481        // merger: LN over [n, 1152], then 2x2 spatial concat -> [n/4, 4608] -> fc1 -> gelu -> fc2
482        let mut ln = e.zeros(n * V_HIDDEN)?;
483        e.layer_norm_bias(
484            &x,
485            &self.merger_norm_w,
486            &self.merger_norm_b,
487            &mut ln,
488            V_HIDDEN,
489            n,
490            LN_EPS,
491        )?;
492        let lh = e.dtoh(&ln)?;
493        let (mh, mw) = (gh / V_MERGE, gw / V_MERGE);
494        let nm = groups * mh * mw;
495        let mut merged = vec![0f32; nm * V_MERGED_IN];
496        for g in 0..groups {
497            for my in 0..mh {
498                for mx in 0..mw {
499                    let out_t = (g * mh + my) * mw + mx;
500                    let dst = &mut merged[out_t * V_MERGED_IN..(out_t + 1) * V_MERGED_IN];
501                    for sy in 0..V_MERGE {
502                        for sx in 0..V_MERGE {
503                            let t = g * gh * gw + (my * V_MERGE + sy) * gw + (mx * V_MERGE + sx);
504                            let seg = (sy * V_MERGE + sx) * V_HIDDEN;
505                            dst[seg..seg + V_HIDDEN]
506                                .copy_from_slice(&lh[t * V_HIDDEN..(t + 1) * V_HIDDEN]);
507                        }
508                    }
509                }
510            }
511        }
512        let md = e.htod(&merged)?;
513        let f1 = self.linear_bias(e, &md, &self.merger_fc1, nm)?;
514        let mut g = e.zeros(nm * V_MERGED_IN)?;
515        e.gelu_tanh(&f1, &mut g, nm * V_MERGED_IN)?;
516        self.linear_bias(e, &g, &self.merger_fc2, nm)
517    }
518}