Skip to main content

tract_core/ops/cnn/conv/
im2col.rs

1use tract_linalg::mmm::{
2    EagerPackedInput, MMMInputFormat, MMMInputValue, MatMatMul, PackedExoticFact,
3    PackedMatrixStorage,
4};
5use tract_linalg::pack::{PackedFormat, PackedI8K4, PackingWriter};
6
7use crate::internal::*;
8use ndarray::prelude::*;
9use num_integer::Integer;
10
11use crate::ops::cnn::pools::{ConcretePoolGeometry, PoolGeometry};
12use crate::ops::cnn::{GeometryBound, PoolSpec, ResolveTo};
13use crate::ops::nn::{BaseDataShape, DataFormat, DataShape};
14
15#[derive(Debug, Clone, PartialEq, Eq, Hash)]
16pub struct Im2Col {
17    pub pool_spec: PoolSpec,
18    pub group: usize,
19    geometry: GeometryBound<SymbolicGeometry, ConcreteGeometry>,
20}
21
22#[derive(Debug, Clone, Hash, PartialEq, Eq)]
23struct SymbolicGeometry {
24    group: usize,
25    pool_spec: PoolSpec,
26    pool_geometry: PoolGeometry,
27    // The kernel's activation packing: PackedFormat (K-major) or PackedI8K4 (K=4-inner).
28    out_format: Box<dyn MMMInputFormat>,
29    k: usize,
30}
31
32#[derive(Debug, Clone, Hash, PartialEq, Eq)]
33struct ConcreteGeometry {
34    pool: ConcretePoolGeometry,
35    pub n: usize,
36    k: usize,
37    pub out_format: Box<dyn MMMInputFormat>,
38    pub ci_per_group: usize,
39    patcher: Patcher,
40    input_shape_with_n: DataShape,
41    packed_shape: TVec<usize>, // always Batch,Group
42}
43
44impl GeometryBound<SymbolicGeometry, ConcreteGeometry> {
45    pub fn out_format(&self) -> &dyn MMMInputFormat {
46        match self {
47            GeometryBound::Symbolic(s) => &*s.out_format,
48            GeometryBound::Concrete(s) => &*s.out_format,
49        }
50    }
51    pub fn k(&self) -> usize {
52        match self {
53            GeometryBound::Symbolic(s) => s.k,
54            GeometryBound::Concrete(s) => s.k,
55        }
56    }
57}
58
59impl ResolveTo<ConcreteGeometry> for SymbolicGeometry {
60    type Param = [usize];
61    fn resolve(&self, input_full_shape: &[usize]) -> TractResult<ConcreteGeometry> {
62        let pool = self.pool_geometry.to_concrete(input_full_shape)?.into_owned();
63        let patcher = match (pool.patch.rank(), pool.patch.padded) {
64            (1, false) => Patcher::Valid1d,
65            (1, true) => Patcher::Padded1d,
66            (2, false) => Patcher::Valid2d,
67            (2, true) => Patcher::Padded2d,
68            _ => Patcher::Generic,
69        };
70        let ci_per_group = pool.input_shape.c_dim() / self.group;
71        let n = pool.output_shape.hw_dims().iter().product();
72        let input_shape_with_n = match self.pool_spec.data_format {
73            DataFormat::HWC => DataFormat::NHWC.from_n_c_hw(
74                1,
75                *pool.input_shape.c(),
76                pool.input_shape.hw_dims(),
77            )?,
78            DataFormat::CHW => DataFormat::NCHW.from_n_c_hw(
79                1,
80                *pool.input_shape.c(),
81                pool.input_shape.hw_dims(),
82            )?,
83            _ => pool.input_shape.clone(),
84        };
85        let packed_shape = Im2Col::packed_shape(&pool.input_shape, self.group)?;
86        Ok(ConcreteGeometry {
87            pool,
88            n,
89            k: self.k,
90            ci_per_group,
91            out_format: self.out_format.clone(),
92            patcher,
93            input_shape_with_n,
94            packed_shape,
95        })
96    }
97}
98
99impl Im2Col {
100    pub fn new(
101        pool_spec: PoolSpec,
102        group: usize,
103        k: usize,
104        input_full_shape: &ShapeFact,
105        mmm: Box<dyn MatMatMul>,
106        packing: usize,
107    ) -> TractResult<Im2Col> {
108        let out_format = dyn_clone::clone_box(&*mmm.packings()[packing].1);
109        let pool_geometry = pool_spec.compute_geo(input_full_shape)?;
110        let geometry: GeometryBound<_, _> =
111            SymbolicGeometry { group, pool_spec: pool_spec.clone(), pool_geometry, out_format, k }
112                .into();
113        let geometry = geometry.optimize_if(input_full_shape.as_concrete())?;
114        Ok(Im2Col { pool_spec, group, geometry })
115    }
116
117    // packed shape is Batch,Group
118    fn packed_shape<D: DimLike>(
119        input_shape: &BaseDataShape<D, TVec<D>>,
120        group: usize,
121    ) -> TractResult<TVec<D>> {
122        let mut output_shape: TVec<D> = tvec!();
123        output_shape.push(input_shape.n().cloned().unwrap_or_else(|| 1.into()));
124        output_shape.push(group.into());
125        Ok(output_shape)
126    }
127}
128
129impl Op for Im2Col {
130    fn name(&self) -> StaticName {
131        "Im2col".into()
132    }
133
134    fn info(&self) -> TractResult<Vec<String>> {
135        Ok(vec![format!("groups:{}", self.group)])
136    }
137
138    op_as_typed_op!();
139}
140
141impl EvalOp for Im2Col {
142    fn is_stateless(&self) -> bool {
143        true
144    }
145
146    fn eval(&self, mut inputs: TVec<TValue>) -> TractResult<TVec<TValue>> {
147        let geometry = self.geometry.to_concrete(inputs[0].shape())?;
148        unsafe {
149            let mut input = inputs.remove(0).into_tensor();
150            let pad_value: Option<&Tensor> = if inputs.len() > 0 { Some(&inputs[0]) } else { None };
151            if !self.pool_spec.data_format.has_n() {
152                input.insert_axis(0)?;
153            }
154            let dt = input.datum_type();
155            let r = geometry.out_format.r();
156            // Buffer geometry. zero_init for PackedI8K4: the K=4-inner writer skips
157            // the K-padding lanes (k..k_aligned), which SMOPA accumulates — they must
158            // be 0. PackedFormat has no K padding; its mn-padding lanes are computed
159            // on (then discarded) by the kernel, so the partial last panel still
160            // needs zeroing — garbage there decodes to denormals that stall the fp
161            // pipeline. Done after allocation below.
162            let (single_panel_len, buf_align, zero_init) =
163                if let Some(pf) = geometry.out_format.downcast_ref::<PackedFormat>() {
164                    (pf.single_panel_len(geometry.k), pf.alignment(), false)
165                } else if let Some(p4) = geometry.out_format.downcast_ref::<PackedI8K4>() {
166                    (p4.single_panel_len(geometry.k), p4.alignment(), true)
167                } else {
168                    bail!("Im2Col: unsupported packing format {:?}", geometry.out_format)
169                };
170            let panel_bytes = single_panel_len * dt.size_of();
171
172            let n_batches = *geometry.input_shape_with_n.n().unwrap_or(&1);
173            let n_groups = self.group;
174            let mut values: TVec<Box<dyn MMMInputValue>> =
175                TVec::with_capacity(n_batches * n_groups);
176
177            for i in 0..n_batches {
178                let input = input.view_at_prefix(&[i])?;
179                for g in 0..n_groups {
180                    let n =
181                        if geometry.pool.output_shape.shape.contains(&0) { 0 } else { geometry.n };
182                    let mut data = Tensor::uninitialized_aligned_dt(
183                        dt,
184                        &[n.divceil(r) * single_panel_len],
185                        buf_align,
186                    )?;
187                    if zero_init {
188                        data.as_bytes_mut().fill(0);
189                    } else if n % r != 0 {
190                        data.as_bytes_mut()[(n / r) * panel_bytes..].fill(0);
191                    }
192                    if n > 0 {
193                        dispatch_copy_by_size!(Patcher::patch(dt)(
194                            &geometry.patcher,
195                            &geometry,
196                            &input,
197                            &mut data.view_mut(),
198                            g,
199                            pad_value
200                        ))?;
201                    }
202                    values.push(Box::new(EagerPackedInput {
203                        fact: PackedExoticFact {
204                            format: geometry.out_format.clone(),
205                            k: geometry.k,
206                            mn: n.to_dim(),
207                        },
208                        packed: data.into_blob()?.into(),
209                        panel_bytes: if n > 0 { panel_bytes } else { 0 },
210                        mn: n,
211                    }));
212                }
213            }
214
215            let output = PackedMatrixStorage::new_batched(&geometry.packed_shape, values)
216                .into_tensor(input.datum_type());
217            Ok(tvec!(output.into_tvalue()))
218        }
219    }
220}
221
222impl TypedOp for Im2Col {
223    as_op!();
224
225    fn output_facts(&self, inputs: &[&TypedFact]) -> TractResult<TVec<TypedFact>> {
226        let input_shape = self.pool_spec.data_format.shape(inputs[0].shape.to_tvec())?;
227        let output_shape = self.pool_spec.output_shape(&inputs[0].shape)?;
228        let mn = output_shape.hw_dims().iter().product::<TDim>();
229        let pof = PackedExoticFact {
230            format: dyn_clone::clone_box(self.geometry.out_format()),
231            k: self.geometry.k(),
232            mn,
233        };
234        Ok(tvec!(
235            inputs[0]
236                .datum_type
237                .fact(&[input_shape.n().cloned().unwrap_or(1.into()), self.group.into()])
238                .with_exotic_fact(pof)
239        ))
240    }
241
242    fn declutter(
243        &self,
244        model: &TypedModel,
245        node: &TypedNode,
246    ) -> TractResult<Option<TypedModelPatch>> {
247        let input_fact = model.outlet_fact(node.inputs[0])?;
248        if node.inputs.len() == 2
249            && model.outlet_fact(node.inputs[1])?.konst.as_ref().and_then(|t| t.as_uniform())
250                == Some(Tensor::zero_scalar_dt(input_fact.datum_type)?)
251        {
252            Ok(Some(
253                TypedModelPatch::replace_single_op(model, node, &node.inputs[0..1], self.clone())?
254                    .with_context("b0 is zero"),
255            ))
256        } else {
257            Ok(None)
258        }
259    }
260}
261
262#[derive(Copy, Clone, Debug, Hash, PartialEq, Eq)]
263enum Patcher {
264    Generic,
265    Valid1d,
266    Valid2d,
267    Padded1d,
268    Padded2d,
269}
270
271impl Patcher {
272    fn patch<'p, T: Copy + Datum + num_traits::Zero>(
273        &self,
274        geo: &'p ConcreteGeometry,
275        input: &TensorView,
276        pack: &'p mut TensorView,
277        g: usize,
278        pad_value: Option<&Tensor>,
279    ) -> TractResult<()> {
280        // Pick the packing writer for the kernel's output format, then run the
281        // (writer-generic) patcher. PackedFormat keeps the K-major fast path;
282        // PackedI8K4 writes the SMOPA K=4-inner layout in the same single pass.
283        let ptr = unsafe { pack.as_slice_mut_unchecked::<T>().as_mut_ptr() };
284        if let Some(pf) = geo.out_format.downcast_ref::<PackedFormat>() {
285            let mut w = pf.write_with_k_outer(ptr, geo.k, geo.n);
286            self.run::<T, _>(geo, input, g, pad_value, &mut w)
287        } else if let Some(p4) = geo.out_format.downcast_ref::<PackedI8K4>() {
288            let mut w = p4.write_with_k_outer(ptr, geo.k, geo.n);
289            self.run::<T, _>(geo, input, g, pad_value, &mut w)
290        } else {
291            bail!("Im2Col: unsupported packing format {:?}", geo.out_format)
292        }
293    }
294
295    fn run<T: Copy + Datum + num_traits::Zero, W: PackingWriter<T>>(
296        &self,
297        geo: &ConcreteGeometry,
298        input: &TensorView,
299        g: usize,
300        pad_value: Option<&Tensor>,
301        writer: &mut W,
302    ) -> TractResult<()> {
303        match self {
304            Patcher::Valid1d => Self::valid_1d::<T, W>(geo, input, g, writer),
305            Patcher::Valid2d => Self::valid_2d::<T, W>(geo, input, g, writer),
306            Patcher::Padded1d => Self::padded_1d::<T, W>(
307                geo,
308                input,
309                g,
310                pad_value.unwrap_or(&Tensor::zero_scalar::<T>()?),
311                writer,
312            ),
313            Patcher::Padded2d => Self::padded_2d::<T, W>(
314                geo,
315                input,
316                g,
317                pad_value.unwrap_or(&Tensor::zero_scalar::<T>()?),
318                writer,
319            ),
320            _ => Self::generic::<T, W>(
321                geo,
322                input,
323                g,
324                pad_value.unwrap_or(&Tensor::zero_scalar::<T>()?),
325                writer,
326            ),
327        }
328    }
329
330    #[inline(never)]
331    fn generic<T: Copy + Datum, W: PackingWriter<T>>(
332        geometry: &ConcreteGeometry,
333        input: &TensorView,
334        g: usize,
335        pad_value: &Tensor,
336        writer: &mut W,
337    ) -> TractResult<()> {
338        unsafe {
339            let pad_value = *pad_value.to_scalar_unchecked();
340            let mut mega_matrix = Tensor::uninitialized::<T>(&[geometry.k, geometry.n])?;
341            let mut mega_matrix_view = mega_matrix.to_array_view_mut_unchecked::<T>();
342            let ptr = input.as_ptr_unchecked::<T>();
343            let ptr = ptr.add(geometry.input_shape_with_n.c_stride() * (g * geometry.ci_per_group));
344            for (spatial, mut col) in ndarray::indices(&*geometry.pool.patch.output_shape)
345                .into_iter()
346                .zip(mega_matrix_view.axis_iter_mut(Axis(1)))
347            {
348                let mut col = col.iter_mut();
349                for ci in 0..geometry.ci_per_group {
350                    let ptr = ptr.add(geometry.input_shape_with_n.c_stride() * ci);
351                    for v in geometry.pool.patch.at(spatial.slice()) {
352                        *col.next().expect("geometry error in conv") =
353                            v.map(|o| *ptr.offset(o)).unwrap_or(pad_value);
354                    }
355                }
356            }
357            // mega_matrix is [k, n] (k-major); feed K-outer to the writer, which
358            // lays out the kernel's packing (K-major for PackedFormat, K=4-inner
359            // for PackedI8K4) — byte-identical to PackedFormat::pack for the former.
360            let mv = mega_matrix.as_slice_unchecked::<T>();
361            for kk in 0..geometry.k {
362                writer.write_slice(&mv[kk * geometry.n..(kk + 1) * geometry.n]);
363            }
364            Ok(())
365        }
366    }
367
368    #[inline(never)]
369    fn valid_1d<T: Copy + Datum, W: PackingWriter<T>>(
370        geometry: &ConcreteGeometry,
371        input: &TensorView,
372        g: usize,
373        writer: &mut W,
374    ) -> TractResult<()> {
375        unsafe {
376            let x_stride = *geometry.input_shape_with_n.h_stride() as isize
377                * geometry.pool.patch.spec.strides[0] as isize;
378            let c_stride = *geometry.input_shape_with_n.c_stride() as isize;
379            let iptr = input.as_ptr_unchecked::<T>();
380            let iptr = iptr.add(g * geometry.ci_per_group * geometry.input_shape_with_n.c_stride());
381            let output_x = *geometry.pool.patch.output_shape.get_unchecked(0);
382            // Fast path: stride-1 contiguous read along x. Replaces the
383            // per-element pointer-arithmetic loop with a single write_slice
384            // (memcpy when the slice fits in the current panel).
385            // Byte-identical to the slow path (write_slice's contract).
386            let contiguous_x = x_stride == 1;
387            for ci in 0..geometry.ci_per_group {
388                let iptr = iptr.offset(ci as isize * c_stride);
389                for koffset in &geometry.pool.patch.standard_layout_data_field {
390                    let iptr = iptr.offset(*koffset);
391                    if contiguous_x {
392                        let row = std::slice::from_raw_parts(iptr, output_x);
393                        writer.write_slice(row);
394                    } else {
395                        // Hoist multiplication out of inner loop.
396                        let mut iptr_x = iptr;
397                        for _ in 0..output_x {
398                            writer.write(*iptr_x);
399                            iptr_x = iptr_x.offset(x_stride);
400                        }
401                    }
402                }
403            }
404            Ok(())
405        }
406    }
407
408    #[inline(never)]
409    fn padded_1d<T: Copy + Datum, W: PackingWriter<T>>(
410        geometry: &ConcreteGeometry,
411        input: &TensorView,
412        g: usize,
413        pad_value: &Tensor,
414        writer: &mut W,
415    ) -> TractResult<()> {
416        unsafe {
417            let pad_value = *pad_value.to_scalar_unchecked();
418            let shape = &geometry.input_shape_with_n;
419            let x_stride = geometry.pool.patch.spec.strides[0] as isize;
420            let x_stride_ptr = x_stride * *shape.h_stride() as isize;
421            let c_stride_ptr = *shape.c_stride() as isize;
422            let input_width = shape.hw_dims()[0] as isize;
423            let kernel_len = geometry.pool.patch.standard_layout_data_field.len();
424            let iptr = input.as_ptr_unchecked::<T>();
425            let iptr = iptr.add(g * geometry.ci_per_group * shape.c_stride());
426            let output_width = *geometry.pool.patch.output_shape.get_unchecked(0);
427            for ci in 0..geometry.ci_per_group {
428                let iptr = iptr.offset(ci as isize * c_stride_ptr);
429                for kitem in 0..kernel_len {
430                    let dx = *geometry.pool.patch.data_field.as_ptr().add(kitem);
431                    let valid_x_start =
432                        Integer::div_ceil(&-dx, &x_stride).max(0).min(output_width as _);
433                    let valid_x_end = Integer::div_ceil(&(input_width - dx), &x_stride)
434                        .max(0)
435                        .min(output_width as _);
436                    let iptr = iptr.offset(
437                        *geometry.pool.patch.standard_layout_data_field.get_unchecked(kitem),
438                    );
439                    Self::padded_invalid_x_loop(valid_x_start as usize, pad_value, &mut *writer);
440                    Self::padded_valid_x_loop(
441                        valid_x_start,
442                        valid_x_end,
443                        x_stride_ptr,
444                        iptr,
445                        &mut *writer,
446                    );
447                    Self::padded_invalid_x_loop(
448                        output_width - valid_x_end as usize,
449                        pad_value,
450                        &mut *writer,
451                    );
452                }
453            }
454        }
455        Ok(())
456    }
457
458    #[inline(never)]
459    fn padded_2d<T: Copy + Datum, W: PackingWriter<T>>(
460        geometry: &ConcreteGeometry,
461        input: &TensorView,
462        g: usize,
463        pad_value: &Tensor,
464        writer: &mut W,
465    ) -> TractResult<()> {
466        unsafe {
467            let pad_value = *pad_value.to_scalar_unchecked();
468            let y_stride = geometry.pool.patch.spec.strides[0] as isize;
469            let x_stride = geometry.pool.patch.spec.strides[1] as isize;
470            let shape = &geometry.input_shape_with_n;
471            let y_stride_ptr = y_stride * *shape.h_stride() as isize;
472            let x_stride_ptr = x_stride * *shape.w_stride() as isize;
473            let c_stride_ptr = *shape.c_stride() as isize;
474            let input_heigth = shape.hw_dims()[0] as isize;
475            let input_width = shape.hw_dims()[1] as isize;
476            let kernel_len = geometry.pool.patch.standard_layout_data_field.len();
477            let iptr = input.as_ptr_unchecked::<T>();
478            let iptr = iptr.add(g * geometry.ci_per_group * shape.c_stride());
479            let output_width = *geometry.pool.patch.output_shape.get_unchecked(1);
480            for ci in 0..geometry.ci_per_group {
481                let iptr = iptr.offset(ci as isize * c_stride_ptr);
482                for kitem in 0..kernel_len {
483                    let dy = *geometry.pool.patch.data_field.as_ptr().offset(kitem as isize * 2);
484                    let dx =
485                        *geometry.pool.patch.data_field.as_ptr().offset(1 + kitem as isize * 2);
486                    let valid_x_start =
487                        Integer::div_ceil(&-dx, &x_stride).max(0).min(output_width as _);
488                    let valid_x_end = Integer::div_ceil(&(input_width - dx), &x_stride)
489                        .max(0)
490                        .min(output_width as _);
491
492                    let iptr = iptr.offset(
493                        *geometry.pool.patch.standard_layout_data_field.get_unchecked(kitem),
494                    );
495                    for yo in 0..*geometry.pool.patch.output_shape.get_unchecked(0) {
496                        let y = yo as isize * y_stride + dy;
497                        let iptr = iptr.offset(yo as isize * y_stride_ptr);
498                        if y >= 0 && y < input_heigth {
499                            Self::padded_invalid_x_loop(
500                                valid_x_start as usize,
501                                pad_value,
502                                &mut *writer,
503                            );
504                            Self::padded_valid_x_loop(
505                                valid_x_start,
506                                valid_x_end,
507                                x_stride_ptr,
508                                iptr,
509                                &mut *writer,
510                            );
511                            Self::padded_invalid_x_loop(
512                                output_width - valid_x_end as usize,
513                                pad_value,
514                                &mut *writer,
515                            );
516                        } else {
517                            Self::padded_invalid_x_loop(output_width, pad_value, &mut *writer);
518                        }
519                    }
520                }
521            }
522        }
523        Ok(())
524    }
525
526    #[inline(never)]
527    unsafe fn padded_invalid_x_loop<T: Copy + Datum, W: PackingWriter<T>>(
528        count: usize,
529        pad_value: T,
530        writer: &mut W,
531    ) {
532        for _ in 0..count {
533            writer.write(pad_value);
534        }
535    }
536
537    #[inline(never)]
538    unsafe fn padded_valid_x_loop<T: Copy + Datum, W: PackingWriter<T>>(
539        x_min: isize,
540        x_max: isize,
541        x_stride_ptr: isize,
542        iptr: *const T,
543        writer: &mut W,
544    ) {
545        // Fast path: x_stride_ptr == 1 means consecutive x values are at
546        // consecutive memory addresses, so the inner loop is a contiguous
547        // slice write — byte-identical to the per-element loop.
548        if x_stride_ptr == 1 && x_max > x_min {
549            unsafe {
550                let row = std::slice::from_raw_parts(iptr.offset(x_min), (x_max - x_min) as usize);
551                writer.write_slice(row);
552            }
553        } else {
554            for x in x_min..x_max {
555                writer.write(unsafe { *iptr.offset(x * x_stride_ptr) });
556            }
557        }
558    }
559
560    #[inline(never)]
561    fn valid_2d<T: Copy + Datum, W: PackingWriter<T>>(
562        geometry: &ConcreteGeometry,
563        input: &TensorView,
564        g: usize,
565        writer: &mut W,
566    ) -> TractResult<()> {
567        unsafe {
568            let shape = &geometry.input_shape_with_n;
569            let y_stride = geometry.pool.patch.spec.strides[0] as isize;
570            let x_stride = geometry.pool.patch.spec.strides[1] as isize;
571            let y_stride_ptr = y_stride * *shape.h_stride() as isize;
572            let x_stride_ptr = x_stride * *shape.w_stride() as isize;
573            let c_stride_ptr = *shape.c_stride() as isize;
574            let iptr = input.as_ptr_unchecked::<T>();
575            let iptr = iptr.add(g * geometry.ci_per_group * shape.c_stride());
576            let output_y = *geometry.pool.patch.output_shape.get_unchecked(0);
577            let output_x = *geometry.pool.patch.output_shape.get_unchecked(1);
578            // Fast path: stride-1 contiguous reads along x within each y-row.
579            // Each y-row becomes a single write_slice (memcpy when the slice
580            // fits in the current panel). Byte-identical to the slow path.
581            let contiguous_x = x_stride_ptr == 1;
582            for ci in 0..geometry.ci_per_group {
583                let iptr = iptr.offset(ci as isize * c_stride_ptr);
584                for koffset in &geometry.pool.patch.standard_layout_data_field {
585                    let iptr = iptr.offset(*koffset);
586                    let mut iptr_y = iptr;
587                    for _ in 0..output_y {
588                        if contiguous_x {
589                            let row = std::slice::from_raw_parts(iptr_y, output_x);
590                            writer.write_slice(row);
591                        } else {
592                            // Hoist x multiplication out of inner loop.
593                            let mut iptr_x = iptr_y;
594                            for _ in 0..output_x {
595                                writer.write(*iptr_x);
596                                iptr_x = iptr_x.offset(x_stride_ptr);
597                            }
598                        }
599                        iptr_y = iptr_y.offset(y_stride_ptr);
600                    }
601                }
602            }
603            Ok(())
604        }
605    }
606}