Skip to main content

sie_sdk/wire/
ndarray.rs

1//! Decoder for the `msgpack-numpy` tensor encoding.
2//!
3//! `/v1/encode`, `/v1/score`, `/v1/extract` and job result chunks are msgpack, and every
4//! tensor inside them is a map with binary keys:
5//!
6//! ```text
7//! {b"nd": true, b"type": "<f4", b"kind": b"", b"shape": [rows, cols], b"data": <raw bytes>}
8//! ```
9//!
10//! `data` holds the raw buffer in C order, in the byte order the `type` string declares.
11
12use rmpv::Value;
13
14use crate::error::{Error, Result};
15
16/// Element type of a numpy buffer, as declared by its dtype string.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18#[allow(missing_docs)]
19pub enum Element {
20    F16,
21    F32,
22    F64,
23    I8,
24    I16,
25    I32,
26    I64,
27    U8,
28    U16,
29    U32,
30    U64,
31}
32
33impl Element {
34    fn width(self) -> usize {
35        match self {
36            Self::I8 | Self::U8 => 1,
37            Self::F16 | Self::I16 | Self::U16 => 2,
38            Self::F32 | Self::I32 | Self::U32 => 4,
39            Self::F64 | Self::I64 | Self::U64 => 8,
40        }
41    }
42}
43
44/// Byte order declared by the dtype string's first character.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46enum ByteOrder {
47    Little,
48    Big,
49}
50
51/// A decoded numpy buffer, still in its wire element type.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct RawArray {
54    /// The dtype the server declared.
55    pub element: Element,
56    /// Dimensions, in C order.
57    pub shape: Vec<usize>,
58    order: ByteOrder,
59    data: Vec<u8>,
60}
61
62fn parse_dtype(descr: &str) -> Result<(ByteOrder, Element)> {
63    let (order_char, kind) = descr
64        .split_at_checked(1)
65        .ok_or_else(|| Error::decode(format!("empty numpy dtype descriptor: {descr:?}")))?;
66    let order = match order_char {
67        ">" => ByteOrder::Big,
68        // "<" little, "|" not applicable (single-byte), "=" native. Every platform the SDK
69        // runs on is little-endian, so native and not-applicable both mean little here.
70        "<" | "|" | "=" => ByteOrder::Little,
71        _ => {
72            return Err(Error::decode(format!(
73                "unsupported numpy byte-order marker in {descr:?}"
74            )));
75        }
76    };
77    let element = match kind {
78        "f2" => Element::F16,
79        "f4" => Element::F32,
80        "f8" => Element::F64,
81        "i1" => Element::I8,
82        "i2" => Element::I16,
83        "i4" => Element::I32,
84        "i8" => Element::I64,
85        "u1" => Element::U8,
86        "u2" => Element::U16,
87        "u4" => Element::U32,
88        "u8" => Element::U64,
89        other => return Err(Error::decode(format!("unsupported numpy dtype {other:?}"))),
90    };
91    Ok((order, element))
92}
93
94/// Read a map key that may have been packed as a string or as binary.
95fn key_str(key: &Value) -> Option<&str> {
96    match key {
97        Value::String(text) => text.as_str(),
98        Value::Binary(bytes) => std::str::from_utf8(bytes).ok(),
99        _ => None,
100    }
101}
102
103fn lookup<'a>(entries: &'a [(Value, Value)], name: &str) -> Option<&'a Value> {
104    entries
105        .iter()
106        .find(|(key, _)| key_str(key) == Some(name))
107        .map(|(_, value)| value)
108}
109
110fn as_bytes(value: &Value) -> Option<&[u8]> {
111    match value {
112        Value::Binary(bytes) => Some(bytes),
113        Value::String(text) => text.as_bytes().into(),
114        _ => None,
115    }
116}
117
118fn as_text(value: &Value) -> Option<&str> {
119    match value {
120        Value::String(text) => text.as_str(),
121        Value::Binary(bytes) => std::str::from_utf8(bytes).ok(),
122        _ => None,
123    }
124}
125
126/// Whether a value looks like a msgpack-numpy array.
127pub fn is_array(value: &Value) -> bool {
128    matches!(value, Value::Map(entries) if lookup(entries, "nd").is_some_and(|nd| nd.as_bool() == Some(true)))
129}
130
131/// Decode a msgpack-numpy array.
132pub fn decode(value: &Value) -> Result<RawArray> {
133    let Value::Map(entries) = value else {
134        return Err(Error::decode("expected a msgpack map for a numpy array"));
135    };
136    if lookup(entries, "nd").and_then(Value::as_bool) != Some(true) {
137        return Err(Error::decode(
138            "msgpack map is not a numpy array (missing nd: true)",
139        ));
140    }
141
142    let descr = lookup(entries, "type")
143        .and_then(as_text)
144        .ok_or_else(|| Error::decode("numpy array is missing its dtype descriptor"))?;
145    let (order, element) = parse_dtype(descr)?;
146
147    let shape: Vec<usize> = match lookup(entries, "shape") {
148        Some(Value::Array(dims)) => dims
149            .iter()
150            .map(|dim| {
151                dim.as_u64().map(|value| value as usize).ok_or_else(|| {
152                    Error::decode("numpy array shape contains a non-integer dimension")
153                })
154            })
155            .collect::<Result<_>>()?,
156        _ => return Err(Error::decode("numpy array is missing its shape")),
157    };
158
159    let data = lookup(entries, "data")
160        .and_then(as_bytes)
161        .ok_or_else(|| Error::decode("numpy array is missing its data buffer"))?;
162
163    let expected = shape.iter().product::<usize>() * element.width();
164    if data.len() != expected {
165        return Err(Error::decode(format!(
166            "numpy array buffer is {} bytes, but shape {shape:?} of {descr} needs {expected}",
167            data.len()
168        )));
169    }
170
171    Ok(RawArray {
172        element,
173        shape,
174        order,
175        data: data.to_vec(),
176    })
177}
178
179macro_rules! read_elements {
180    ($self:expr, $ty:ty, $convert:expr) => {{
181        let width = std::mem::size_of::<$ty>();
182        $self
183            .data
184            .chunks_exact(width)
185            .map(|chunk| {
186                let raw: [u8; std::mem::size_of::<$ty>()] =
187                    chunk.try_into().expect("chunks_exact width");
188                let value = match $self.order {
189                    ByteOrder::Little => <$ty>::from_le_bytes(raw),
190                    ByteOrder::Big => <$ty>::from_be_bytes(raw),
191                };
192                #[allow(clippy::redundant_closure_call)]
193                $convert(value)
194            })
195            .collect()
196    }};
197}
198
199impl RawArray {
200    /// Total element count.
201    pub fn len(&self) -> usize {
202        self.shape.iter().product()
203    }
204
205    /// Whether the buffer holds no elements.
206    pub fn is_empty(&self) -> bool {
207        self.len() == 0
208    }
209
210    /// Widen every element to `f32`.
211    ///
212    /// Integer dtypes are converted, not reinterpreted: a quantized `int8` embedding comes
213    /// back as the integer values the server sent.
214    pub fn to_f32(&self) -> Vec<f32> {
215        match self.element {
216            Element::F16 => read_elements!(self, u16, |bits| half::f16::from_bits(bits).to_f32()),
217            Element::F32 => read_elements!(self, f32, |value| value),
218            Element::F64 => read_elements!(self, f64, |value: f64| value as f32),
219            Element::I8 => self
220                .data
221                .iter()
222                .map(|byte| f32::from(*byte as i8))
223                .collect(),
224            Element::I16 => read_elements!(self, i16, f32::from),
225            Element::I32 => read_elements!(self, i32, |value: i32| value as f32),
226            Element::I64 => read_elements!(self, i64, |value: i64| value as f32),
227            Element::U8 => self.data.iter().map(|byte| f32::from(*byte)).collect(),
228            Element::U16 => read_elements!(self, u16, f32::from),
229            Element::U32 => read_elements!(self, u32, |value: u32| value as f32),
230            Element::U64 => read_elements!(self, u64, |value: u64| value as f32),
231        }
232    }
233
234    /// Read every element as `f16`, preserving the wire precision when the server sent f16.
235    pub fn to_f16(&self) -> Vec<half::f16> {
236        match self.element {
237            Element::F16 => read_elements!(self, u16, half::f16::from_bits),
238            _ => self.to_f32().into_iter().map(half::f16::from_f32).collect(),
239        }
240    }
241
242    /// Read every element as `u32`, for sparse indices.
243    pub fn to_u32(&self) -> Result<Vec<u32>> {
244        let values: Vec<i64> = match self.element {
245            Element::I8 => self
246                .data
247                .iter()
248                .map(|byte| i64::from(*byte as i8))
249                .collect(),
250            Element::I16 => read_elements!(self, i16, i64::from),
251            Element::I32 => read_elements!(self, i32, i64::from),
252            Element::I64 => read_elements!(self, i64, |value| value),
253            Element::U8 => self.data.iter().map(|byte| i64::from(*byte)).collect(),
254            Element::U16 => read_elements!(self, u16, i64::from),
255            Element::U32 => read_elements!(self, u32, i64::from),
256            Element::U64 => read_elements!(self, u64, |value: u64| value as i64),
257            Element::F16 | Element::F32 | Element::F64 => {
258                return Err(Error::decode(
259                    "sparse indices arrived as a floating-point array",
260                ));
261            }
262        };
263        values
264            .into_iter()
265            .map(|value| {
266                u32::try_from(value)
267                    .map_err(|_| Error::decode(format!("sparse index {value} is out of range")))
268            })
269            .collect()
270    }
271
272    /// Rows of a 2-D array, as `f32`.
273    pub fn rows_f32(&self) -> Result<Vec<Vec<f32>>> {
274        let cols = self.cols()?;
275        Ok(self.to_f32().chunks(cols).map(<[f32]>::to_vec).collect())
276    }
277
278    /// Rows of a 2-D array, preserving `f16` precision.
279    pub fn rows_f16(&self) -> Result<Vec<Vec<half::f16>>> {
280        let cols = self.cols()?;
281        Ok(self
282            .to_f16()
283            .chunks(cols)
284            .map(<[half::f16]>::to_vec)
285            .collect())
286    }
287
288    fn cols(&self) -> Result<usize> {
289        match self.shape.as_slice() {
290            [_, cols] if *cols > 0 => Ok(*cols),
291            [_, _] => Ok(1),
292            other => Err(Error::decode(format!(
293                "expected a 2-D array, got shape {other:?}"
294            ))),
295        }
296    }
297}
298
299#[cfg(test)]
300pub(crate) mod fixtures {
301    use super::*;
302
303    /// Build the msgpack-numpy encoding of an `f32` array, as `msgpack_numpy` would.
304    pub(crate) fn f32_array(shape: &[usize], values: &[f32]) -> Value {
305        raw_array(
306            "<f4",
307            shape,
308            values.iter().flat_map(|v| v.to_le_bytes()).collect(),
309        )
310    }
311
312    pub(crate) fn f16_array(shape: &[usize], values: &[f32]) -> Value {
313        raw_array(
314            "<f2",
315            shape,
316            values
317                .iter()
318                .flat_map(|v| half::f16::from_f32(*v).to_bits().to_le_bytes())
319                .collect(),
320        )
321    }
322
323    pub(crate) fn i32_array(shape: &[usize], values: &[i32]) -> Value {
324        raw_array(
325            "<i4",
326            shape,
327            values.iter().flat_map(|v| v.to_le_bytes()).collect(),
328        )
329    }
330
331    pub(crate) fn raw_array(descr: &str, shape: &[usize], data: Vec<u8>) -> Value {
332        Value::Map(vec![
333            (Value::Binary(b"nd".to_vec()), Value::Boolean(true)),
334            (Value::Binary(b"type".to_vec()), Value::String(descr.into())),
335            (Value::Binary(b"kind".to_vec()), Value::Binary(Vec::new())),
336            (
337                Value::Binary(b"shape".to_vec()),
338                Value::Array(shape.iter().map(|dim| Value::from(*dim as u64)).collect()),
339            ),
340            (Value::Binary(b"data".to_vec()), Value::Binary(data)),
341        ])
342    }
343}
344
345#[cfg(test)]
346mod tests {
347    // These assertions are about exact values, so exact comparison is the point.
348    #![allow(clippy::float_cmp)]
349
350    use super::*;
351    use fixtures::*;
352
353    #[test]
354    fn decodes_a_dense_f32_vector() {
355        let value = f32_array(&[3], &[0.5, -1.5, 2.0]);
356        assert!(is_array(&value));
357        let array = decode(&value).unwrap();
358        assert_eq!(array.element, Element::F32);
359        assert_eq!(array.shape, vec![3]);
360        assert_eq!(array.to_f32(), vec![0.5, -1.5, 2.0]);
361    }
362
363    #[test]
364    fn decodes_f16_without_losing_the_wire_precision() {
365        let value = f16_array(&[2, 2], &[1.0, 0.25, -3.5, 0.1]);
366        let array = decode(&value).unwrap();
367        assert_eq!(array.element, Element::F16);
368        let rows = array.rows_f16().unwrap();
369        assert_eq!(rows.len(), 2);
370        assert_eq!(rows[0][1], half::f16::from_f32(0.25));
371        // 0.1 is not representable in f16; the decoder must return the wire bits, not a
372        // silently re-rounded value.
373        assert_eq!(rows[1][1], half::f16::from_f32(0.1));
374        assert_eq!(array.rows_f32().unwrap()[0][0], 1.0);
375    }
376
377    #[test]
378    fn decodes_sparse_indices() {
379        let array = decode(&i32_array(&[4], &[3, 17, 900, 0])).unwrap();
380        assert_eq!(array.to_u32().unwrap(), vec![3, 17, 900, 0]);
381    }
382
383    #[test]
384    fn rejects_negative_sparse_indices() {
385        let array = decode(&i32_array(&[2], &[-1, 4])).unwrap();
386        assert!(array.to_u32().is_err());
387    }
388
389    #[test]
390    fn rejects_float_arrays_as_indices() {
391        let array = decode(&f32_array(&[2], &[1.0, 2.0])).unwrap();
392        assert!(array.to_u32().is_err());
393    }
394
395    #[test]
396    fn honours_big_endian_buffers() {
397        let data: Vec<u8> = [1.5f32, -2.0]
398            .iter()
399            .flat_map(|v| v.to_be_bytes())
400            .collect();
401        let array = decode(&raw_array(">f4", &[2], data)).unwrap();
402        assert_eq!(array.to_f32(), vec![1.5, -2.0]);
403    }
404
405    #[test]
406    fn widens_quantized_int8_embeddings() {
407        let array = decode(&raw_array("|i1", &[3], vec![0xff, 0x01, 0x80])).unwrap();
408        assert_eq!(array.to_f32(), vec![-1.0, 1.0, -128.0]);
409    }
410
411    #[test]
412    fn rejects_a_buffer_that_does_not_match_its_shape() {
413        let truncated = raw_array("<f4", &[4], vec![0; 8]);
414        let err = decode(&truncated).unwrap_err();
415        assert!(err.to_string().contains("needs 16"), "{err}");
416    }
417
418    #[test]
419    fn rejects_unsupported_dtypes_and_non_arrays() {
420        assert!(decode(&raw_array("<c8", &[1], vec![0; 8])).is_err());
421        assert!(decode(&Value::Nil).is_err());
422        assert!(!is_array(&Value::Map(vec![(
423            Value::String("dense".into()),
424            Value::Nil
425        )])));
426    }
427
428    #[test]
429    fn accepts_string_keys_as_well_as_binary_keys() {
430        let value = Value::Map(vec![
431            (Value::String("nd".into()), Value::Boolean(true)),
432            (Value::String("type".into()), Value::String("<f4".into())),
433            (
434                Value::String("shape".into()),
435                Value::Array(vec![Value::from(2u64)]),
436            ),
437            (
438                Value::String("data".into()),
439                Value::Binary([1.0f32, 2.0].iter().flat_map(|v| v.to_le_bytes()).collect()),
440            ),
441        ]);
442        assert_eq!(decode(&value).unwrap().to_f32(), vec![1.0, 2.0]);
443    }
444
445    #[test]
446    fn round_trips_through_real_msgpack_bytes() {
447        let packed = rmp_serde::to_vec(&f32_array(&[2, 2], &[1.0, 2.0, 3.0, 4.0])).unwrap();
448        let value: Value = rmp_serde::from_slice(&packed).unwrap();
449        let array = decode(&value).unwrap();
450        assert_eq!(
451            array.rows_f32().unwrap(),
452            vec![vec![1.0, 2.0], vec![3.0, 4.0]]
453        );
454    }
455}