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
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Conv3D for Qwen3-VL temporal patch embedding, decomposed into
//! two parallel Conv2Ds.
//!
//! Qwen3-VL receives video as `(B, in_c, T, H, W)` and applies a 3D
//! patch embedding with kernel `(2, kH, kW)` and stride `(2, kH, kW)`.
//! Because the temporal kernel depth is 2 (i.e. each output frame
//! consumes exactly two input frames), the convolution decomposes
//! cleanly into a pair of 2D convs whose outputs are summed:
//!
//! ```text
//! y[..., t_out, :, :] = conv2d(x[..., 2*t_out + 0, :, :], w[..., 0, :, :])
//! + conv2d(x[..., 2*t_out + 1, :, :], w[..., 1, :, :])
//! ```
//!
//! There is no native lazy Conv3D op — adding one would be
//! significantly more involved than the single (Qwen3-VL) consumer
//! warrants. This module ports
//! `fuel_transformers::models::multimodal::qwen3_vl::conv3d_temporal_2`
//! using only the existing [`Tensor::conv2d`] primitive plus
//! `narrow` / `squeeze` / `add` / `unsqueeze`.
//!
//! ## Scope
//!
//! - **v1 (this module)**: kernel depth exactly 2. Input must have
//! `T == 2` along the temporal axis; output has `T == 1`. The eager
//! `Conv3dNoBias::forward` is bit-for-bit identical in scope.
//! - **Future**: generalize to arbitrary even kernel depth `N` if a
//! second consumer needs it (sum of N parallel 2D convs over chunks
//! of N consecutive frames, stride N).
use fuel_ir::Shape;
use fuel_ir::error::Result;
use fuel_tensor::lazy::Tensor;
use std::sync::Arc;
/// 3D convolution config restricted to the temporal-patch-2 case.
/// Matches eager `Conv3dConfig` byte-for-byte; `dilation` is accepted
/// only when equal to 1 since the underlying lazy Conv2D primitive
/// doesn't expose dilation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Conv3dTemporal2Config {
pub padding: usize,
pub stride: usize,
pub dilation: usize,
pub groups: usize,
}
impl Default for Conv3dTemporal2Config {
fn default() -> Self {
Self {
padding: 0,
stride: 1,
dilation: 1,
groups: 1,
}
}
}
/// Weight storage for a temporal-patch-2 Conv3D.
///
/// Eager stores the raw 3D weight `(Cout, Cin/groups, 2, kH, kW)`
/// and slices it at construction time to produce the two 2D weights
/// `w1 = ws[:, :, 0, :, :]` and `w2 = ws[:, :, 1, :, :]`. The lazy
/// port does the same split at construction so each forward pass
/// just emits two const nodes (cheap Arc-clones) — no per-step
/// slicing in the graph.
#[derive(Debug, Clone)]
pub struct Conv3dTemporal2Weights {
/// `(Cout, Cin/groups, kH, kW)` — temporal slice index 0.
pub w1: Arc<[f32]>,
/// `(Cout, Cin/groups, kH, kW)` — temporal slice index 1.
pub w2: Arc<[f32]>,
pub out_channels: usize,
pub in_channels_per_group: usize,
pub kernel_h: usize,
pub kernel_w: usize,
pub cfg: Conv3dTemporal2Config,
}
impl Conv3dTemporal2Weights {
/// Build from a flat raw weight buffer in PyTorch
/// `(Cout, Cin/groups, 2, kH, kW)` row-major order.
///
/// Splits the temporal axis into two `(Cout, Cin/groups, kH, kW)`
/// slabs by interleaved indexing: the temporal axis is stride
/// `kH * kW` within each `(Cout, Cin/groups, 2, ...)` block of
/// length `2 * kH * kW`.
pub fn from_raw_weight(
raw_weight: &[f32],
out_channels: usize,
in_channels_per_group: usize,
kernel_h: usize,
kernel_w: usize,
cfg: Conv3dTemporal2Config,
) -> Result<Self> {
let expected = out_channels * in_channels_per_group * 2 * kernel_h * kernel_w;
if raw_weight.len() != expected {
return Err(fuel_ir::error::Error::Msg(format!(
"Conv3dTemporal2Weights::from_raw_weight: expected {expected} floats \
({out_channels} * {in_channels_per_group} * 2 * {kernel_h} * {kernel_w}), got {}",
raw_weight.len(),
)));
}
if cfg.dilation != 1 {
return Err(fuel_ir::error::Error::Msg(format!(
"Conv3dTemporal2Weights: dilation must be 1 (lazy Conv2D primitive does not support \
dilation), got {}",
cfg.dilation,
)));
}
let plane = kernel_h * kernel_w;
let outer = out_channels * in_channels_per_group;
let mut w1 = Vec::with_capacity(outer * plane);
let mut w2 = Vec::with_capacity(outer * plane);
for o in 0..outer {
let base = o * 2 * plane;
w1.extend_from_slice(&raw_weight[base..base + plane]);
w2.extend_from_slice(&raw_weight[base + plane..base + 2 * plane]);
}
Ok(Self {
w1: Arc::from(w1),
w2: Arc::from(w2),
out_channels,
in_channels_per_group,
kernel_h,
kernel_w,
cfg,
})
}
/// Apply the decomposed Conv3D to a temporal-pair input.
///
/// `input` must have shape `(B, in_channels, 2, H, W)` where
/// `in_channels = in_channels_per_group * cfg.groups`. Returns
/// `(B, out_channels, 1, H_out, W_out)` where
/// `H_out = (H + 2*padding - kernel_h) / stride + 1` (and
/// likewise for W).
pub fn apply(&self, input: &Tensor) -> Result<Tensor> {
let dims = input.shape();
let dims = dims.dims();
if dims.len() != 5 {
return Err(fuel_ir::error::Error::Msg(format!(
"Conv3dTemporal2Weights::apply: input must be rank 5 (B, Cin, T, H, W), \
got rank {}",
dims.len(),
)));
}
let in_channels = self.in_channels_per_group * self.cfg.groups;
if dims[1] != in_channels {
return Err(fuel_ir::error::Error::Msg(format!(
"Conv3dTemporal2Weights::apply: input has Cin={}, expected {in_channels} \
({} per group * {} groups)",
dims[1], self.in_channels_per_group, self.cfg.groups,
)));
}
if dims[2] != 2 {
return Err(fuel_ir::error::Error::Msg(format!(
"Conv3dTemporal2Weights::apply: temporal-patch-2 requires T=2, got T={}",
dims[2],
)));
}
// Slice along temporal axis (dim=2) and squeeze.
let xs1 = input.narrow(2_usize, 0, 1)?.squeeze(2_usize)?;
let xs2 = input.narrow(2_usize, 1, 1)?.squeeze(2_usize)?;
let w_shape = Shape::from_dims(&[
self.out_channels,
self.in_channels_per_group,
self.kernel_h,
self.kernel_w,
]);
let w1 = input.const_f32_like(Arc::clone(&self.w1), w_shape.clone())?;
let w2 = input.const_f32_like(Arc::clone(&self.w2), w_shape)?;
let stride = (self.cfg.stride, self.cfg.stride);
let padding = (self.cfg.padding, self.cfg.padding);
let y1 = xs1.conv2d(&w1, None, stride, padding, self.cfg.groups)?;
let y2 = xs2.conv2d(&w2, None, stride, padding, self.cfg.groups)?;
let y = y1.add(&y2)?;
// Re-insert temporal axis of size 1.
y.unsqueeze(2_usize)
}
}
#[cfg(test)]
mod tests {
use super::*;
use fuel_tensor::Device;
fn raw_weight(out_c: usize, in_c_per_g: usize, kh: usize, kw: usize) -> Vec<f32> {
// Deterministic small values keep all arithmetic in
// normal-float range and let us hand-compute expected.
(0..out_c * in_c_per_g * 2 * kh * kw)
.map(|i| (i as f32) * 0.01 - 0.5)
.collect()
}
#[test]
fn from_raw_weight_splits_temporal_axis() {
// (out=2, in=3, T=2, kH=1, kW=1) → 12 floats, w1/w2 each 6.
let raw: Vec<f32> = (0..12).map(|i| i as f32).collect();
let w = Conv3dTemporal2Weights::from_raw_weight(
&raw,
2,
3,
1,
1,
Conv3dTemporal2Config::default(),
)
.unwrap();
// outer iteration order: o in 0..(out * in/g) = 6 outer slabs of (2 * 1 * 1) = 2 floats each.
// For outer o=0: w1 picks raw[0], w2 picks raw[1].
// outer o=1: w1 picks raw[2], w2 picks raw[3]. … etc.
let expected_w1 = vec![0.0, 2.0, 4.0, 6.0, 8.0, 10.0];
let expected_w2 = vec![1.0, 3.0, 5.0, 7.0, 9.0, 11.0];
assert_eq!(&*w.w1, expected_w1.as_slice());
assert_eq!(&*w.w2, expected_w2.as_slice());
}
#[test]
fn from_raw_weight_rejects_dilation_other_than_1() {
let raw = raw_weight(2, 3, 1, 1);
let cfg = Conv3dTemporal2Config {
dilation: 2,
..Default::default()
};
assert!(Conv3dTemporal2Weights::from_raw_weight(&raw, 2, 3, 1, 1, cfg).is_err());
}
#[test]
fn from_raw_weight_rejects_size_mismatch() {
let raw = raw_weight(2, 3, 1, 1);
// Asking for out_channels=4 needs 24 floats, but raw has 12.
let r = Conv3dTemporal2Weights::from_raw_weight(
&raw,
4,
3,
1,
1,
Conv3dTemporal2Config::default(),
);
assert!(r.is_err());
}
#[test]
fn apply_kernel_1x1_matches_hand_computed() {
// The simplest decomposition test: kernel (1, 1) so the
// Conv2D is just per-pixel matmul along channels, no
// spatial structure to worry about.
// out_channels=1, in_channels=2, T=2, kH=kW=1.
// Weight raw: 4 floats laid out (1, 2, 2, 1, 1) row-major:
// [w[0,0,0,0,0], w[0,0,1,0,0], w[0,1,0,0,0], w[0,1,1,0,0]]
// So per-outer-slab (out=0, in=0): w1=[raw[0]], w2=[raw[1]];
// (out=0, in=1): w1=[raw[2]], w2=[raw[3]].
let raw = vec![1.0_f32, 2.0, 3.0, 4.0];
let w = Conv3dTemporal2Weights::from_raw_weight(
&raw,
1,
2,
1,
1,
Conv3dTemporal2Config::default(),
)
.unwrap();
// Input (B=1, C=2, T=2, H=1, W=1): just 4 scalars.
// x[0, 0, 0, 0, 0] = 10
// x[0, 0, 1, 0, 0] = 20
// x[0, 1, 0, 0, 0] = 30
// x[0, 1, 1, 0, 0] = 40
let x_data: Vec<f32> = vec![10.0, 20.0, 30.0, 40.0];
let input = Tensor::from_f32(
Arc::from(x_data),
Shape::from_dims(&[1, 2, 2, 1, 1]),
&Device::cpu(),
)
.unwrap();
let y = w.apply(&input).unwrap();
assert_eq!(y.shape().dims(), &[1, 1, 1, 1, 1]);
// Expected:
// y[0,0,0,0,0] = conv2d_1(xs1) + conv2d_2(xs2)
// where xs1 = x[:,:,0] = [10, 30] (channels in dim 1, single pixel)
// xs2 = x[:,:,1] = [20, 40]
// conv2d_1 with w1 = [1.0 (cin=0), 3.0 (cin=1)]:
// y1[0,0,0,0] = 1.0*10 + 3.0*30 = 10 + 90 = 100
// conv2d_2 with w2 = [2.0, 4.0]:
// y2[0,0,0,0] = 2.0*20 + 4.0*40 = 40 + 160 = 200
// sum = 300
let got = y.realize_f32();
assert_eq!(got.len(), 1);
assert!(
(got[0] - 300.0).abs() < 1e-5,
"expected 300.0, got {}",
got[0]
);
}
#[test]
fn apply_rejects_t_other_than_2() {
let raw = vec![0.1_f32; 4];
let w = Conv3dTemporal2Weights::from_raw_weight(
&raw,
1,
2,
1,
1,
Conv3dTemporal2Config::default(),
)
.unwrap();
// Input shape (1, 2, 1, 1, 1) → 2 elements; T=1 should error.
let input = Tensor::from_f32(
Arc::from(vec![1.0_f32; 2]),
Shape::from_dims(&[1, 2, 1, 1, 1]),
&Device::cpu(),
)
.unwrap();
assert!(w.apply(&input).is_err());
}
#[test]
fn apply_strided_qwen3_vl_shape_smoke() {
// Qwen3-VL canonical patch: kernel=(2, 14, 14), stride=(2, 14, 14).
// Tiny version: out_c=4, in_c=3, kH=kW=2, stride=2. Input H=W=4 →
// H_out = W_out = (4 - 2)/2 + 1 = 2.
let raw = raw_weight(4, 3, 2, 2);
let cfg = Conv3dTemporal2Config {
stride: 2,
..Default::default()
};
let w = Conv3dTemporal2Weights::from_raw_weight(&raw, 4, 3, 2, 2, cfg).unwrap();
let x_len = 1 * 3 * 2 * 4 * 4;
let x_data: Vec<f32> = (0..x_len).map(|i| (i as f32) * 0.01).collect();
let input = Tensor::from_f32(
Arc::from(x_data),
Shape::from_dims(&[1, 3, 2, 4, 4]),
&Device::cpu(),
)
.unwrap();
let y = w.apply(&input).unwrap();
assert_eq!(y.shape().dims(), &[1, 4, 1, 2, 2]);
for &v in &y.realize_f32() {
assert!(v.is_finite(), "non-finite output: {v}");
}
}
}