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    op_out_of_plan!();
143
144    fn eval(&self, _ctx: &EvalContext, mut inputs: TVec<TValue>) -> TractResult<TVec<TValue>> {
145        let geometry = self.geometry.to_concrete(inputs[0].shape())?;
146        unsafe {
147            let mut input = inputs.remove(0).into_tensor();
148            let pad_value: Option<&Tensor> = if inputs.len() > 0 { Some(&inputs[0]) } else { None };
149            if !self.pool_spec.data_format.has_n() {
150                input.insert_axis(0)?;
151            }
152            let dt = input.datum_type();
153            let r = geometry.out_format.r();
154            // Buffer geometry. zero_init for PackedI8K4: the K=4-inner writer skips
155            // the K-padding lanes (k..k_aligned), which SMOPA accumulates — they must
156            // be 0. PackedFormat has no K padding; its mn-padding lanes are computed
157            // on (then discarded) by the kernel, so the partial last panel still
158            // needs zeroing — garbage there decodes to denormals that stall the fp
159            // pipeline. Done after allocation below.
160            let (single_panel_len, buf_align, zero_init) =
161                if let Some(pf) = geometry.out_format.downcast_ref::<PackedFormat>() {
162                    (pf.single_panel_len(geometry.k), pf.alignment(), false)
163                } else if let Some(p4) = geometry.out_format.downcast_ref::<PackedI8K4>() {
164                    (p4.single_panel_len(geometry.k), p4.alignment(), true)
165                } else {
166                    bail!("Im2Col: unsupported packing format {:?}", geometry.out_format)
167                };
168            let panel_bytes = single_panel_len * dt.size_of();
169
170            let n_batches = *geometry.input_shape_with_n.n().unwrap_or(&1);
171            let n_groups = self.group;
172            let mut values: TVec<Box<dyn MMMInputValue>> =
173                TVec::with_capacity(n_batches * n_groups);
174
175            for i in 0..n_batches {
176                let input = input.view_at_prefix(&[i])?;
177                for g in 0..n_groups {
178                    let n =
179                        if geometry.pool.output_shape.shape.contains(&0) { 0 } else { geometry.n };
180                    let mut data = Tensor::uninitialized_aligned_dt(
181                        dt,
182                        &[n.divceil(r) * single_panel_len],
183                        buf_align,
184                    )?;
185                    if zero_init {
186                        data.as_bytes_mut().fill(0);
187                    } else if n % r != 0 {
188                        data.as_bytes_mut()[(n / r) * panel_bytes..].fill(0);
189                    }
190                    if n > 0 {
191                        dispatch_copy_by_size!(Patcher::patch(dt)(
192                            &geometry.patcher,
193                            &geometry,
194                            &input,
195                            &mut data.view_mut(),
196                            g,
197                            pad_value
198                        ))?;
199                    }
200                    values.push(Box::new(EagerPackedInput {
201                        fact: PackedExoticFact {
202                            format: geometry.out_format.clone(),
203                            k: geometry.k,
204                            mn: n.to_dim(),
205                        },
206                        packed: data.into_blob()?.into(),
207                        panel_bytes: if n > 0 { panel_bytes } else { 0 },
208                        mn: n,
209                    }));
210                }
211            }
212
213            let output = PackedMatrixStorage::new_batched(&geometry.packed_shape, values)
214                .into_tensor(input.datum_type());
215            Ok(tvec!(output.into_tvalue()))
216        }
217    }
218}
219
220impl TypedOp for Im2Col {
221    as_op!();
222
223    fn output_facts(&self, inputs: &[&TypedFact]) -> TractResult<TVec<TypedFact>> {
224        let input_shape = self.pool_spec.data_format.shape(inputs[0].shape.to_tvec())?;
225        let output_shape = self.pool_spec.output_shape(&inputs[0].shape)?;
226        let mn = output_shape.hw_dims().iter().product::<TDim>();
227        let pof = PackedExoticFact {
228            format: dyn_clone::clone_box(self.geometry.out_format()),
229            k: self.geometry.k(),
230            mn,
231        };
232        Ok(tvec!(
233            inputs[0]
234                .datum_type
235                .fact(&[input_shape.n().cloned().unwrap_or(1.into()), self.group.into()])
236                .with_exotic_fact(pof)
237        ))
238    }
239
240    fn declutter(
241        &self,
242        model: &TypedModel,
243        node: &TypedNode,
244    ) -> TractResult<Option<TypedModelPatch>> {
245        let input_fact = model.outlet_fact(node.inputs[0])?;
246        if node.inputs.len() == 2
247            && model.outlet_fact(node.inputs[1])?.konst.as_ref().and_then(|t| t.as_uniform())
248                == Some(Tensor::zero_scalar_dt(input_fact.datum_type)?)
249        {
250            Ok(Some(
251                TypedModelPatch::replace_single_op(model, node, &node.inputs[0..1], self.clone())?
252                    .with_context("b0 is zero"),
253            ))
254        } else {
255            Ok(None)
256        }
257    }
258}
259
260#[derive(Copy, Clone, Debug, Hash, PartialEq, Eq)]
261enum Patcher {
262    Generic,
263    Valid1d,
264    Valid2d,
265    Padded1d,
266    Padded2d,
267}
268
269impl Patcher {
270    fn patch<'p, T: Copy + Datum + num_traits::Zero>(
271        &self,
272        geo: &'p ConcreteGeometry,
273        input: &TensorView,
274        pack: &'p mut TensorView,
275        g: usize,
276        pad_value: Option<&Tensor>,
277    ) -> TractResult<()> {
278        // Pick the packing writer for the kernel's output format, then run the
279        // (writer-generic) patcher. PackedFormat keeps the K-major fast path;
280        // PackedI8K4 writes the SMOPA K=4-inner layout in the same single pass.
281        let ptr = unsafe { pack.as_slice_mut_unchecked::<T>().as_mut_ptr() };
282        if let Some(pf) = geo.out_format.downcast_ref::<PackedFormat>() {
283            let mut w = pf.write_with_k_outer(ptr, geo.k, geo.n);
284            self.run::<T, _>(geo, input, g, pad_value, &mut w)
285        } else if let Some(p4) = geo.out_format.downcast_ref::<PackedI8K4>() {
286            let mut w = p4.write_with_k_outer(ptr, geo.k, geo.n);
287            self.run::<T, _>(geo, input, g, pad_value, &mut w)
288        } else {
289            bail!("Im2Col: unsupported packing format {:?}", geo.out_format)
290        }
291    }
292
293    fn run<T: Copy + Datum + num_traits::Zero, W: PackingWriter<T>>(
294        &self,
295        geo: &ConcreteGeometry,
296        input: &TensorView,
297        g: usize,
298        pad_value: Option<&Tensor>,
299        writer: &mut W,
300    ) -> TractResult<()> {
301        match self {
302            Patcher::Valid1d => Self::valid_1d::<T, W>(geo, input, g, writer),
303            Patcher::Valid2d => Self::valid_2d::<T, W>(geo, input, g, writer),
304            Patcher::Padded1d => Self::padded_1d::<T, W>(
305                geo,
306                input,
307                g,
308                pad_value.unwrap_or(&Tensor::zero_scalar::<T>()?),
309                writer,
310            ),
311            Patcher::Padded2d => Self::padded_2d::<T, W>(
312                geo,
313                input,
314                g,
315                pad_value.unwrap_or(&Tensor::zero_scalar::<T>()?),
316                writer,
317            ),
318            _ => Self::generic::<T, W>(
319                geo,
320                input,
321                g,
322                pad_value.unwrap_or(&Tensor::zero_scalar::<T>()?),
323                writer,
324            ),
325        }
326    }
327
328    #[inline(never)]
329    fn generic<T: Copy + Datum, W: PackingWriter<T>>(
330        geometry: &ConcreteGeometry,
331        input: &TensorView,
332        g: usize,
333        pad_value: &Tensor,
334        writer: &mut W,
335    ) -> TractResult<()> {
336        unsafe {
337            let pad_value = *pad_value.to_scalar_unchecked();
338            let mut mega_matrix = Tensor::uninitialized::<T>(&[geometry.k, geometry.n])?;
339            let mut mega_matrix_view = mega_matrix.to_array_view_mut_unchecked::<T>();
340            let ptr = input.as_ptr_unchecked::<T>();
341            let ptr = ptr.add(geometry.input_shape_with_n.c_stride() * (g * geometry.ci_per_group));
342            for (spatial, mut col) in ndarray::indices(&*geometry.pool.patch.output_shape)
343                .into_iter()
344                .zip(mega_matrix_view.axis_iter_mut(Axis(1)))
345            {
346                let mut col = col.iter_mut();
347                for ci in 0..geometry.ci_per_group {
348                    let ptr = ptr.add(geometry.input_shape_with_n.c_stride() * ci);
349                    for v in geometry.pool.patch.at(spatial.slice()) {
350                        *col.next().expect("geometry error in conv") =
351                            v.map(|o| *ptr.offset(o)).unwrap_or(pad_value);
352                    }
353                }
354            }
355            // mega_matrix is [k, n] (k-major); feed K-outer to the writer, which
356            // lays out the kernel's packing (K-major for PackedFormat, K=4-inner
357            // for PackedI8K4) — byte-identical to PackedFormat::pack for the former.
358            let mv = mega_matrix.as_slice_unchecked::<T>();
359            for kk in 0..geometry.k {
360                writer.write_slice(&mv[kk * geometry.n..(kk + 1) * geometry.n]);
361            }
362            Ok(())
363        }
364    }
365
366    #[inline(never)]
367    fn valid_1d<T: Copy + Datum, W: PackingWriter<T>>(
368        geometry: &ConcreteGeometry,
369        input: &TensorView,
370        g: usize,
371        writer: &mut W,
372    ) -> TractResult<()> {
373        unsafe {
374            let x_stride = *geometry.input_shape_with_n.h_stride() as isize
375                * geometry.pool.patch.spec.strides[0] as isize;
376            let c_stride = *geometry.input_shape_with_n.c_stride() as isize;
377            let iptr = input.as_ptr_unchecked::<T>();
378            let iptr = iptr.add(g * geometry.ci_per_group * geometry.input_shape_with_n.c_stride());
379            let output_x = *geometry.pool.patch.output_shape.get_unchecked(0);
380            // Fast path: stride-1 contiguous read along x. Replaces the
381            // per-element pointer-arithmetic loop with a single write_slice
382            // (memcpy when the slice fits in the current panel).
383            // Byte-identical to the slow path (write_slice's contract).
384            let contiguous_x = x_stride == 1;
385            for ci in 0..geometry.ci_per_group {
386                let iptr = iptr.offset(ci as isize * c_stride);
387                for koffset in &geometry.pool.patch.standard_layout_data_field {
388                    let iptr = iptr.offset(*koffset);
389                    if contiguous_x {
390                        let row = std::slice::from_raw_parts(iptr, output_x);
391                        writer.write_slice(row);
392                    } else {
393                        // Hoist multiplication out of inner loop.
394                        let mut iptr_x = iptr;
395                        for _ in 0..output_x {
396                            writer.write(*iptr_x);
397                            iptr_x = iptr_x.offset(x_stride);
398                        }
399                    }
400                }
401            }
402            Ok(())
403        }
404    }
405
406    #[inline(never)]
407    fn padded_1d<T: Copy + Datum, W: PackingWriter<T>>(
408        geometry: &ConcreteGeometry,
409        input: &TensorView,
410        g: usize,
411        pad_value: &Tensor,
412        writer: &mut W,
413    ) -> TractResult<()> {
414        unsafe {
415            let pad_value = *pad_value.to_scalar_unchecked();
416            let shape = &geometry.input_shape_with_n;
417            let x_stride = geometry.pool.patch.spec.strides[0] as isize;
418            let x_stride_ptr = x_stride * *shape.h_stride() as isize;
419            let c_stride_ptr = *shape.c_stride() as isize;
420            let input_width = shape.hw_dims()[0] as isize;
421            let kernel_len = geometry.pool.patch.standard_layout_data_field.len();
422            let iptr = input.as_ptr_unchecked::<T>();
423            let iptr = iptr.add(g * geometry.ci_per_group * shape.c_stride());
424            let output_width = *geometry.pool.patch.output_shape.get_unchecked(0);
425            for ci in 0..geometry.ci_per_group {
426                let iptr = iptr.offset(ci as isize * c_stride_ptr);
427                for kitem in 0..kernel_len {
428                    let dx = *geometry.pool.patch.data_field.as_ptr().add(kitem);
429                    let valid_x_start =
430                        Integer::div_ceil(&-dx, &x_stride).max(0).min(output_width as _);
431                    let valid_x_end = Integer::div_ceil(&(input_width - dx), &x_stride)
432                        .max(0)
433                        .min(output_width as _);
434                    let iptr = iptr.offset(
435                        *geometry.pool.patch.standard_layout_data_field.get_unchecked(kitem),
436                    );
437                    Self::padded_invalid_x_loop(valid_x_start as usize, pad_value, &mut *writer);
438                    Self::padded_valid_x_loop(
439                        valid_x_start,
440                        valid_x_end,
441                        x_stride_ptr,
442                        iptr,
443                        &mut *writer,
444                    );
445                    Self::padded_invalid_x_loop(
446                        output_width - valid_x_end as usize,
447                        pad_value,
448                        &mut *writer,
449                    );
450                }
451            }
452        }
453        Ok(())
454    }
455
456    #[inline(never)]
457    fn padded_2d<T: Copy + Datum, W: PackingWriter<T>>(
458        geometry: &ConcreteGeometry,
459        input: &TensorView,
460        g: usize,
461        pad_value: &Tensor,
462        writer: &mut W,
463    ) -> TractResult<()> {
464        unsafe {
465            let pad_value = *pad_value.to_scalar_unchecked();
466            let y_stride = geometry.pool.patch.spec.strides[0] as isize;
467            let x_stride = geometry.pool.patch.spec.strides[1] as isize;
468            let shape = &geometry.input_shape_with_n;
469            let y_stride_ptr = y_stride * *shape.h_stride() as isize;
470            let x_stride_ptr = x_stride * *shape.w_stride() as isize;
471            let c_stride_ptr = *shape.c_stride() as isize;
472            let input_heigth = shape.hw_dims()[0] as isize;
473            let input_width = shape.hw_dims()[1] as isize;
474            let kernel_len = geometry.pool.patch.standard_layout_data_field.len();
475            let iptr = input.as_ptr_unchecked::<T>();
476            let iptr = iptr.add(g * geometry.ci_per_group * shape.c_stride());
477            let output_width = *geometry.pool.patch.output_shape.get_unchecked(1);
478            for ci in 0..geometry.ci_per_group {
479                let iptr = iptr.offset(ci as isize * c_stride_ptr);
480                for kitem in 0..kernel_len {
481                    let dy = *geometry.pool.patch.data_field.as_ptr().offset(kitem as isize * 2);
482                    let dx =
483                        *geometry.pool.patch.data_field.as_ptr().offset(1 + kitem as isize * 2);
484                    let valid_x_start =
485                        Integer::div_ceil(&-dx, &x_stride).max(0).min(output_width as _);
486                    let valid_x_end = Integer::div_ceil(&(input_width - dx), &x_stride)
487                        .max(0)
488                        .min(output_width as _);
489
490                    let iptr = iptr.offset(
491                        *geometry.pool.patch.standard_layout_data_field.get_unchecked(kitem),
492                    );
493                    for yo in 0..*geometry.pool.patch.output_shape.get_unchecked(0) {
494                        let y = yo as isize * y_stride + dy;
495                        let iptr = iptr.offset(yo as isize * y_stride_ptr);
496                        if y >= 0 && y < input_heigth {
497                            Self::padded_invalid_x_loop(
498                                valid_x_start as usize,
499                                pad_value,
500                                &mut *writer,
501                            );
502                            Self::padded_valid_x_loop(
503                                valid_x_start,
504                                valid_x_end,
505                                x_stride_ptr,
506                                iptr,
507                                &mut *writer,
508                            );
509                            Self::padded_invalid_x_loop(
510                                output_width - valid_x_end as usize,
511                                pad_value,
512                                &mut *writer,
513                            );
514                        } else {
515                            Self::padded_invalid_x_loop(output_width, pad_value, &mut *writer);
516                        }
517                    }
518                }
519            }
520        }
521        Ok(())
522    }
523
524    #[inline(never)]
525    unsafe fn padded_invalid_x_loop<T: Copy + Datum, W: PackingWriter<T>>(
526        count: usize,
527        pad_value: T,
528        writer: &mut W,
529    ) {
530        for _ in 0..count {
531            writer.write(pad_value);
532        }
533    }
534
535    #[inline(never)]
536    unsafe fn padded_valid_x_loop<T: Copy + Datum, W: PackingWriter<T>>(
537        x_min: isize,
538        x_max: isize,
539        x_stride_ptr: isize,
540        iptr: *const T,
541        writer: &mut W,
542    ) {
543        // Fast path: x_stride_ptr == 1 means consecutive x values are at
544        // consecutive memory addresses, so the inner loop is a contiguous
545        // slice write — byte-identical to the per-element loop.
546        if x_stride_ptr == 1 && x_max > x_min {
547            unsafe {
548                let row = std::slice::from_raw_parts(iptr.offset(x_min), (x_max - x_min) as usize);
549                writer.write_slice(row);
550            }
551        } else {
552            for x in x_min..x_max {
553                writer.write(unsafe { *iptr.offset(x * x_stride_ptr) });
554            }
555        }
556    }
557
558    #[inline(never)]
559    fn valid_2d<T: Copy + Datum, W: PackingWriter<T>>(
560        geometry: &ConcreteGeometry,
561        input: &TensorView,
562        g: usize,
563        writer: &mut W,
564    ) -> TractResult<()> {
565        unsafe {
566            let shape = &geometry.input_shape_with_n;
567            let y_stride = geometry.pool.patch.spec.strides[0] as isize;
568            let x_stride = geometry.pool.patch.spec.strides[1] as isize;
569            let y_stride_ptr = y_stride * *shape.h_stride() as isize;
570            let x_stride_ptr = x_stride * *shape.w_stride() as isize;
571            let c_stride_ptr = *shape.c_stride() as isize;
572            let iptr = input.as_ptr_unchecked::<T>();
573            let iptr = iptr.add(g * geometry.ci_per_group * shape.c_stride());
574            let output_y = *geometry.pool.patch.output_shape.get_unchecked(0);
575            let output_x = *geometry.pool.patch.output_shape.get_unchecked(1);
576            // Fast path: stride-1 contiguous reads along x within each y-row.
577            // Each y-row becomes a single write_slice (memcpy when the slice
578            // fits in the current panel). Byte-identical to the slow path.
579            let contiguous_x = x_stride_ptr == 1;
580            for ci in 0..geometry.ci_per_group {
581                let iptr = iptr.offset(ci as isize * c_stride_ptr);
582                for koffset in &geometry.pool.patch.standard_layout_data_field {
583                    let iptr = iptr.offset(*koffset);
584                    let mut iptr_y = iptr;
585                    for _ in 0..output_y {
586                        if contiguous_x {
587                            let row = std::slice::from_raw_parts(iptr_y, output_x);
588                            writer.write_slice(row);
589                        } else {
590                            // Hoist x multiplication out of inner loop.
591                            let mut iptr_x = iptr_y;
592                            for _ in 0..output_x {
593                                writer.write(*iptr_x);
594                                iptr_x = iptr_x.offset(x_stride_ptr);
595                            }
596                        }
597                        iptr_y = iptr_y.offset(y_stride_ptr);
598                    }
599                }
600            }
601            Ok(())
602        }
603    }
604}