Repository: python-graphblas/python-graphblas

Issue:
Add SuiteSparse:GraphBLAS (de)serialization methods
`GxB_Matrix_serialize` and `GxB_Vector_serialize` (and deserialize).

I'm not sure whether we should use these for pickling.  The current method to pickle uses `x.ss.export()`, and then a system like Dask can choose to compress the resulting arrays if it determines doing so is worthwhile.  Until we can measure communication performance in Dask's distributed scheduler, perhaps we could add a configuration for which method to use to pickle.

We could still choose to expose (de)serialize as e.g. `x.ss.serialize()`.

Discussion:
coveralls: 
[![Coverage Status](https://coveralls.io/builds/49662646/badge)](https://coveralls.io/builds/49662646)

Coverage decreased (-0.08%) to 99.235% when pulling **7633810d39e64de68ad566a33748ef3dffdc9a33 on eriknw:ss_serialize** into **5d0b36128bec6418073fff1ee552ff69634efecf on python-graphblas:main**.

jim22k: numpy array of uint8 should be fine. With serialize/deserialize, speed is the main concern, so making things a little less intuitive to achieve the fastest roundtrip feels justified.
eriknw: Alright, I updated this to support (de)serializing of UDTs.  We use the name (which has finite-width) to save the numpy repr from which we can recreate the dtype using `eval`.  This pretty much "just works", but there are some sub-optimal behaviors:
- The name can be long and ugly, and may not be understood by other libraries or languages
  - If the UDT was given a name (and it's not anonymous), should we always use it? (this has other challenges)
- Recreating the dtype requires using `eval`.  I'm not sure how to get around this.
  - The _user_ can get around this by providing `dtype` to `deserialize`.

I'd like to move forward as-is to see if it's workable, and we can update these strategies if/when needed.

Patch:
diff --git a/graphblas/_ss/descriptor.py b/graphblas/_ss/descriptor.py
new file mode 100644
index 0000000..f827a62
--- /dev/null
+++ b/graphblas/_ss/descriptor.py
@@ -0,0 +1,79 @@
+from graphblas import ffi, lib
+from graphblas.descriptor import Descriptor, _desc_map
+from graphblas.exceptions import check_status
+
+str_to_compression = {
+    "none": lib.GxB_COMPRESSION_NONE,
+    "default": lib.GxB_COMPRESSION_DEFAULT,
+    "lz4": lib.GxB_COMPRESSION_LZ4,
+    "lz4hc": lib.GxB_COMPRESSION_LZ4HC,
+}
+
+
+def get_nthreads_descriptor(nthreads, _cache=True):
+    nthreads = max(0, int(nthreads))
+    key = ("nthreads", nthreads)
+    if _cache and key in _desc_map:
+        return _desc_map[key]
+    desc_obj = ffi.new("GrB_Descriptor*")
+    lib.GrB_Descriptor_new(desc_obj)
+    desc = Descriptor(desc_obj[0], f"nthreads{nthreads}")
+    check_status(
+        lib.GxB_Desc_set(
+            desc._carg,
+            lib.GxB_NTHREADS,
+            ffi.cast("GrB_Desc_Value", nthreads),
+        ),
+        desc,
+    )
+    if _cache:
+        _desc_map[key] = desc
+    return desc
+
+
+def get_compression_descriptor(compression="default", level=None, nthreads=None):
+    if compression is None:
+        comp = str_to_compression["none"]
+    elif not isinstance(compression, str) or compression.lower() not in str_to_compression:
+        valid = ", ".join(sorted(map(repr, str_to_compression)))
+        raise ValueError(f"compression argument should be one of {valid}")
+    else:
+        compression = compression.lower()
+        comp = str_to_compression[compression]
+    if level is not None:
+        if compression != "lz4hc":
+            raise TypeError('level argument is only valid when using "lz4hc" compression')
+        level = int(level)
+        if level < 1 or level > 9:
+            raise ValueError(
+                f"level argument should be an integer between 1 and 9 (got {level}).  "
+                "1 is the fastest, 9 is the most compression (default is 9)."
+            )
+        comp += level
+    if nthreads is not None:
+        nthreads = max(0, int(nthreads))
+        key = frozenset([("compression", comp), ("nthreads", nthreads)])
+    else:
+        key = frozenset([("compression", comp)])
+    if key in _desc_map:
+        return _desc_map[key]
+    if nthreads is not None:
+        desc = get_nthreads_descriptor(nthreads, _cache=False)
+    else:
+        desc_obj = ffi.new("GrB_Descriptor*")
+        lib.GrB_Descriptor_new(desc_obj)
+        desc = Descriptor(desc_obj[0], "")
+    name = f"compression_{compression}{level or ''}"
+    if nthreads and nthreads > 0:
+        name += f"_nthreads{nthreads}"
+    desc.name = name
+    check_status(
+        lib.GxB_Desc_set(
+            desc._carg,
+            lib.GxB_COMPRESSION,
+            ffi.cast("GrB_Desc_Value", comp),
+        ),
+        desc,
+    )
+    _desc_map[key] = desc
+    return desc
diff --git a/graphblas/_ss/matrix.py b/graphblas/_ss/matrix.py
index 22f5a31..772fc9a 100644
--- a/graphblas/_ss/matrix.py
+++ b/graphblas/_ss/matrix.py
@@ -1,3 +1,4 @@
+import itertools
 import warnings
 from numbers import Integral, Number
 
@@ -23,6 +24,7 @@ from ..utils import (
     values_to_numpy_buffer,
     wrapdoc,
 )
+from .descriptor import get_compression_descriptor, get_nthreads_descriptor
 from .utils import get_order
 
 ffi_new = ffi.new
@@ -3950,6 +3952,115 @@ class ss:
             name=name,
         )
 
+    def serialize(self, compression="default", level=None, *, nthreads=None):
+        """Serialize a Matrix to bytes (as numpy array) using SuiteSparse GxB_Matrix_serialize.
+
+        Parameters
+        ----------
+        compression : {"default", "lz4", "lz4hc", "none", None}, optional
+            Whether and how to compress the data.
+            - "default": the default in SuiteSparse:GraphBLAS, which is currently LZ4
+            - "lz4": the default LZ4 compression
+            - "lz4hc": LZ4 compression that allows the compression level (1-9) to be set.
+              Low compression level (1) is faster, high (9) is more compact.  Default is 9.
+            - "none" or None: no compression
+        level : int [1-9], optional
+            The compression level, between 1 to 9, to use with "lz4hc" compression.
+            (1) is the fastest and largest, (9) is the slowest and most compressed.
+            Level 9 is the default when using "lz4hc" compression.
+        nthreads : int, optional
+            The maximum number of threads to use when serializing the Matrix.
+            None, 0 or negative nthreads means to use the default number of threads.
+
+        For best performance, this function returns a numpy array with uint8 dtype.
+        Use `Matrix.ss.deserialize(blob)` to create a Matrix from the result of serialization
+
+        This method is intended to support all serialization options from SuiteSparse:GraphBLAS.
+        """
+        desc = get_compression_descriptor(compression, level=level, nthreads=nthreads)
+        blob_handle = ffi_new("void**")
+        blob_size_handle = ffi_new("GrB_Index*")
+        parent = self._parent
+        check_status(
+            lib.GxB_Matrix_serialize(
+                blob_handle,
+                blob_size_handle,
+                parent._carg,
+                desc._carg,
+            ),
+            parent,
+        )
+        return claim_buffer(ffi, blob_handle[0], blob_size_handle[0], np.dtype(np.uint8))
+
+    @classmethod
+    def deserialize(cls, data, dtype=None, *, unsafe=False, nthreads=None, name=None):
+        """Deserialize a Matrix from bytes, buffer, or numpy array using GxB_Matrix_deserialize.
+
+        The data should have been previously serialized with a compatible version of
+        SuiteSparse:GraphBLAS.  For example, from the result of `data = matrix.ss.serialize()`.
+
+        Examples
+        --------
+        >>> data = matrix.serialize()
+        >>> new_matrix = Matrix.ss.deserialize(data)
+        >>> new_matrix.isequal(matrix)
+        True
+
+        Parameters
+        ----------
+        dtype : DataType, optional
+            If given, this should specify the dtype of the object.  This is usually unnecessary.
+            If the dtype doesn't match what is in the serialized metadata, deserialize will fail.
+            You need to specify the dtype to _safely_ load user-defined types.
+        unsafe : bool, default False
+            Ignored for builtin types.  Automatic loading of user-defined types (if not given via
+            the `dtype=` argument) may require `eval`, which is often considered unsafe in Python
+            if you don't trust the data.  Hence, you must opt-in by specifying `unsafe=True`.
+            Automatically loading UDTs will probably only work for objects saved by this library.
+        nthreads : int, optional
+            The maximum number of threads to use when deserializing.
+            None, 0 or negative nthreads means to use the default number of threads.
+        """
+        if isinstance(data, np.ndarray):
+            data = ints_to_numpy_buffer(data, np.uint8)
+        else:
+            data = np.frombuffer(data, np.uint8)
+        data_obj = ffi.from_buffer("void*", data)
+        # Get the dtype name first
+        if dtype is None:
+            cname = ffi_new(f"char[{lib.GxB_MAX_NAME_LEN}]")
+            info = lib.GxB_deserialize_type_name(
+                cname,
+                data_obj,
+                data.nbytes,
+            )
+            if info != lib.GrB_SUCCESS:
+                raise _error_code_lookup[info]("Matrix deserialize failed to get the dtype name")
+            dtype_name = b"".join(itertools.takewhile(b"\x00".__ne__, cname)).decode()
+            try:
+                dtype = lookup_dtype(dtype_name)
+            except ValueError:
+                if not unsafe:
+                    raise
+                np_dtype = np.dtype(eval(dtype_name, np.__dict__))
+                dtype = lookup_dtype(np_dtype)
+        else:
+            dtype = lookup_dtype(dtype)
+        if nthreads is not None:
+            desc_obj = get_nthreads_descriptor(nthreads)._carg
+        else:
+            desc_obj = NULL
+        gb_obj = ffi_new("GrB_Matrix*")
+        check_status_carg(
+            lib.GxB_Matrix_deserialize(gb_obj, dtype._carg, data_obj, data.nbytes, desc_obj),
+            "Matrix",
+            gb_obj[0],
+        )
+        rv = gb.Matrix._from_obj(gb_obj, dtype, -1, -1, name=name)
+        rv._nrows = rv.nrows
+        rv._ncols = rv.ncols
+        return rv
+
 
 @numba.njit(parallel=True)
 def argsort_values(indptr, indices, values):  # pragma: no cover
diff --git a/graphblas/_ss/vector.py b/graphblas/_ss/vector.py
index ea563e3..b04a63f 100644
--- a/graphblas/_ss/vector.py
+++ b/graphblas/_ss/vector.py
@@ -1,3 +1,4 @@
+import itertools
 import warnings
 
 import numpy as np
@@ -12,6 +13,7 @@ from ..dtypes import _INDEX, INT64, UINT64, lookup_dtype
 from ..exceptions import _error_code_lookup, check_status, check_status_carg
 from ..scalar import Scalar, _as_scalar
 from ..utils import _CArray, ints_to_numpy_buffer, libget, values_to_numpy_buffer, wrapdoc
+from .descriptor import get_compression_descriptor, get_nthreads_descriptor
 from .matrix import MatrixArray, _concat_mn, normalize_chunks
 from .prefix_scan import prefix_scan
 from .utils import get_order
@@ -194,7 +196,7 @@ class ss:
 
         tile_nrows, _ = normalize_chunks([chunks, None], (self._parent._size, 1))
         m = len(tile_nrows)
-        tiles = ffi.new("GrB_Matrix[]", m)
+        tiles = ffi_new("GrB_Matrix[]", m)
         parent = self._parent._as_matrix()
         call(
             "GxB_Matrix_split",
@@ -214,14 +216,14 @@ class ss:
             name = self._parent.name
         for i, size in enumerate(tile_nrows):
             # Copy to a new handle so we can free `tiles`
-            new_vector = ffi.new("GrB_Vector*")
+            new_vector = ffi_new("GrB_Vector*")
             new_vector[0] = ffi.cast("GrB_Vector", tiles[i])
             tile = Vector._from_obj(new_vector, dtype, size, name=f"{name}_{i}")
             rv.append(tile)
         return rv
 
     def _concat(self, tiles, m):
-        ctiles = ffi.new("GrB_Matrix[]", m)
+        ctiles = ffi_new("GrB_Matrix[]", m)
         for i, tile in enumerate(tiles):
             ctiles[i] = tile.gb_obj[0]
         call(
@@ -277,7 +279,7 @@ class ss:
         )
 
     def _begin_iter(self, seek):
-        it_ptr = ffi.new("GxB_Iterator*")
+        it_ptr = ffi_new("GxB_Iterator*")
         info = lib.GxB_Iterator_new(it_ptr)
         it = it_ptr[0]
         success = lib.GrB_SUCCESS
@@ -1519,6 +1521,114 @@ class ss:
             name=name,
         )
 
+    def serialize(self, compression="default", level=None, *, nthreads=None):
+        """Serialize a Vector to bytes (as numpy array) using SuiteSparse GxB_Vector_serialize.
+
+        Parameters
+        ----------
+        compression : {"default", "lz4", "lz4hc", "none", None}, optional
+            Whether and how to compress the data.
+            - "default": the default in SuiteSparse:GraphBLAS, which is currently LZ4
+            - "lz4": the default LZ4 compression
+            - "lz4hc": LZ4 compression that allows the compression level (1-9) to be set.
+              Low compression level (1) is faster, high (9) is more compact.  Default is 9.
+            - "none" or None: no compression
+        level : int [1-9], optional
+            The compression level, between 1 to 9, to use with "lz4hc" compression.
+            (1) is the fastest and largest, (9) is the slowest and most compressed.
+            Level 9 is the default when using "lz4hc" compression.
+        nthreads : int, optional
+            The maximum number of threads to use when serializing the Vector.
+            None, 0 or negative nthreads means to use the default number of threads.
+
+        For best performance, this function returns a numpy array with uint8 dtype.
+        Use `Vector.ss.deserialize(blob)` to create a Vector from the result of serialization·
+
+        This method is intended to support all serialization options from SuiteSparse:GraphBLAS.
+        """
+        desc = get_compression_descriptor(compression, level=level, nthreads=nthreads)
+        blob_handle = ffi_new("void**")
+        blob_size_handle = ffi_new("GrB_Index*")
+        parent = self._parent
+        check_status(
+            lib.GxB_Vector_serialize(
+                blob_handle,
+                blob_size_handle,
+                parent._carg,
+                desc._carg,
+            ),
+            parent,
+        )
+        return claim_buffer(ffi, blob_handle[0], blob_size_handle[0], np.dtype(np.uint8))
+
+    @classmethod
+    def deserialize(cls, data, dtype=None, *, unsafe=False, nthreads=None, name=None):
+        """Deserialize a Vector from bytes, buffer, or numpy array using GxB_Vector_deserialize.
+
+        The data should have been previously serialized with a compatible version of
+        SuiteSparse:GraphBLAS.  For example, from the result of `data = vector.ss.serialize()`.
+
+        Examples
+        --------
+        >>> data = vector.serialize()
+        >>> new_vector = Vector.ss.deserialize(data)
+        >>> new_vector.isequal(vector)
+        True
+
+        Parameters
+        ----------
+        dtype : DataType, optional
+            If given, this should specify the dtype of the object.  This is usually unnecessary.
+            If the dtype doesn't match what is in the serialized metadata, deserialize will fail.
+            You need to specify the dtype to _safely_ load user-defined types.
+        unsafe : bool, default False
+            Ignored for builtin types.  Automatic loading of user-defined types (if not given via
+            the `dtype=` argument) may require `eval`, which is often considered unsafe in Python
+            if you don't trust the data.  Hence, you must opt-in by specifying `unsafe=True`.
+            Automatically loading UDTs will probably only work for objects saved by this library.
+        nthreads : int, optional
+            The maximum number of threads to use when deserializing.
+            None, 0 or negative nthreads means to use the default number of threads.
+        """
+        if isinstance(data, np.ndarray):
+            data = ints_to_numpy_buffer(data, np.uint8)
+        else:
+            data = np.frombuffer(data, np.uint8)
+        data_obj = ffi.from_buffer("void*", data)
+        if dtype is None:
+            # Get the dtype name first
+            cname = ffi_new(f"char[{lib.GxB_MAX_NAME_LEN}]")
+            info = lib.GxB_deserialize_type_name(
+                cname,
+                data_obj,
+                data.nbytes,
+            )
+            if info != lib.GrB_SUCCESS:
+                raise _error_code_lookup[info]("Vector deserialize failed to get the dtype name")
+            dtype_name = b"".join(itertools.takewhile(b"\x00".__ne__, cname)).decode()
+            try:
+                dtype = lookup_dtype(dtype_name)
+            except ValueError:
+                if not unsafe:
+                    raise
+                np_dtype = np.dtype(eval(dtype_name, np.__dict__))
+                dtype = lookup_dtype(np_dtype)
+        else:
+            dtype = lookup_dtype(dtype)
+        if nthreads is not None:
+            desc_obj = get_nthreads_descriptor(nthreads)._carg
+        else:
+            desc_obj = NULL
+        gb_obj = ffi_new("GrB_Vector*")
+        check_status_carg(
+            lib.GxB_Vector_deserialize(gb_obj, dtype._carg, data_obj, data.nbytes, desc_obj),
+            "Vector",
+            gb_obj[0],
+        )
+        rv = gb.Vector._from_obj(gb_obj, dtype, -1, name=name)
+        rv._size = rv.size
+        return rv
+
 
 @njit
 def random_choice(n, k):  # pragma: no cover
diff --git a/graphblas/dtypes.py b/graphblas/dtypes.py
index 954ab72..73b73db 100644
--- a/graphblas/dtypes.py
+++ b/graphblas/dtypes.py
@@ -1,3 +1,5 @@
+import warnings
+
 import numba
 import numpy as np
 from numpy import find_common_type, promote_types
@@ -102,7 +104,24 @@ def register_anonymous(dtype, name=None):
         from .exceptions import check_status_carg
 
         gb_obj = ffi.new("GrB_Type*")
-        status = lib.GrB_Type_new(gb_obj, dtype.itemsize)
+        if hasattr(lib, "GxB_MAX_NAME_LEN"):
+            # SS, SuiteSparse-specific: naming dtypes
+            # We name this so that we can serialize and deserialize UDTs
+            # We don't yet have C definitions
+            np_repr = repr(dtype).encode()
+            if len(np_repr) > lib.GxB_MAX_NAME_LEN:
+                if name is not None:
+                    np_repr = name.encode()[: lib.GxB_MAX_NAME_LEN]
+                else:
+                    np_repr = np_repr[: lib.GxB_MAX_NAME_LEN]
+                warnings.warn(
+                    f"UDT repr is too large to serialize ({len(repr(dtype).encode())} > "
+                    f"{lib.GxB_MAX_NAME_LEN}).  It will use the following name, "
+                    f"and the dtype may need to be specified when deserializing: {np_repr}"
+                )
+            status = lib.GxB_Type_new(gb_obj, dtype.itemsize, np_repr, ffi.NULL)
+        else:  # pragma: no cover
+            status = lib.GrB_Type_new(gb_obj, dtype.itemsize)
         check_status_carg(status, "Type", gb_obj[0])
     # For now, let's use "opaque" unsigned bytes for the c type.
     if name is None:


Test patch:
diff --git a/graphblas/tests/test_matrix.py b/graphblas/tests/test_matrix.py
index 1cc63c2..e01572c 100644
--- a/graphblas/tests/test_matrix.py
+++ b/graphblas/tests/test_matrix.py
@@ -14,6 +14,7 @@ from graphblas.exceptions import (
     DimensionMismatch,
     EmptyObject,
     IndexOutOfBound,
+    InvalidObject,
     InvalidValue,
     NotImplementedException,
     OutputNotEmpty,
@@ -3438,6 +3439,13 @@ def test_udt():
     with pytest.raises(ValueError, match="shape mismatch"):
         A[[0, 1], [1]] = [[(2, 3), (4, 5)]]
 
+    AA = Matrix.ss.deserialize(A.ss.serialize(), unsafe=True)
+    assert A.isequal(AA, check_dtype=True)
+    AA = Matrix.ss.deserialize(A.ss.serialize(), dtype=A.dtype)
+    assert A.isequal(AA, check_dtype=True)
+    with pytest.raises(ValueError):
+        Matrix.ss.deserialize(A.ss.serialize())
+
     np_dtype = np.dtype("(3,)uint16")
     udt = dtypes.register_anonymous(np_dtype, "has_subdtype")
     A = Matrix(udt, nrows=2, ncols=2)
@@ -3460,6 +3468,8 @@ def test_udt():
     expected = Matrix.from_values([0, 1], [1, 1], [[2, 3, 4], [5, 6, 7]], dtype=udt)
     assert A.isequal(expected)
     A[[0, 1], [1]] = [[[2, 3, 4]], [[5, 6, 7]]]
+    AA = Matrix.ss.deserialize(A.ss.serialize(), unsafe=True)
+    assert A.isequal(AA, check_dtype=True)
     with pytest.raises(ValueError, match="shape mismatch"):
         A[[0, 1], [1]] = [[[2, 3, 4], [5, 6, 7]]]
     with pytest.raises(ValueError, match="shape mismatch"):
@@ -3554,3 +3564,27 @@ def test_bool_as_mask(A):
     expected *= 3
     A(A < 3, binary.plus, replace=True) << A + A
     assert A.isequal(expected)
+
+
+def test_ss_serialize(A):
+    for compression, level, nthreads in itertools.product(
+        [None, "none", "default", "lz4", "lz4hc"], [None, 1, 5, 9], [None, -1, 1, 10]
+    ):
+        if level is not None and compression != "lz4hc":
+            with pytest.raises(TypeError, match="level argument"):
+                A.ss.serialize(compression, level, nthreads=nthreads)
+            continue
+        a = A.ss.serialize(compression, level, nthreads=nthreads)
+        C = Matrix.ss.deserialize(a, nthreads=nthreads)
+        assert A.isequal(C, check_dtype=True)
+    b = a.tobytes()
+    C = Matrix.ss.deserialize(b)
+    assert A.isequal(C, check_dtype=True)
+    with pytest.raises(ValueError, match="compression argument"):
+        A.ss.serialize("bad")
+    with pytest.raises(ValueError, match="level argument"):
+        A.ss.serialize("lz4hc", -1)
+    with pytest.raises(ValueError, match="level argument"):
+        A.ss.serialize("lz4hc", 0)
+    with pytest.raises(InvalidObject):
+        Matrix.ss.deserialize(a[:-5])
diff --git a/graphblas/tests/test_vector.py b/graphblas/tests/test_vector.py
index f757a1c..4c216e7 100644
--- a/graphblas/tests/test_vector.py
+++ b/graphblas/tests/test_vector.py
@@ -14,6 +14,7 @@ from graphblas.exceptions import (
     DimensionMismatch,
     EmptyObject,
     IndexOutOfBound,
+    InvalidObject,
     InvalidValue,
     OutputNotEmpty,
 )
@@ -1945,6 +1946,12 @@ def test_udt():
     v.clear()
     v[[0, 1]] = [(2, 3), (4, 5)]
     expected = Vector.from_values([0, 1], [(2, 3), (4, 5)], dtype=udt, size=v.size)
+    vv = Vector.ss.deserialize(v.ss.serialize(), unsafe=True)
+    assert v.isequal(vv, check_dtype=True)
+    vv = Vector.ss.deserialize(v.ss.serialize(), dtype=v.dtype)
+    assert v.isequal(vv, check_dtype=True)
+    with pytest.raises(ValueError):
+        Vector.ss.deserialize(v.ss.serialize())
 
     # arrays as dtypes!
     np_dtype = np.dtype("(3,)uint16")
@@ -1994,6 +2001,29 @@ def test_udt():
     v[[0, 1]] = [[2, 3, 4], [5, 6, 7]]
     expected = Vector.from_values([0, 1], [[2, 3, 4], [5, 6, 7]], dtype=udt2, size=v.size)
     assert v.isequal(expected)
+    vv = Vector.ss.deserialize(v.ss.serialize(), unsafe=True)
+    assert v.isequal(vv, check_dtype=True)
+
+    long_dtype = np.dtype([("x", np.bool_), ("y" * 1000, np.float64)], align=True)
+    with pytest.warns(UserWarning, match="too large"):
+        long_udt = dtypes.register_anonymous(long_dtype)
+    v = Vector(long_udt, size=3)
+    v[0] = 0
+    v[1] = (1, 5.6)
+    vv = Vector.ss.deserialize(v.ss.serialize(), dtype=long_udt)
+    assert v.isequal(vv, check_dtype=True)
+    with pytest.raises(Exception):
+        # The size of the UDT name is limited
+        Vector.ss.deserialize(v.ss.serialize(), unsafe=True)
+    # May be able to look up non-anonymous dtypes by name if their names are too long
+    named_long_dtype = np.dtype([("x", np.bool_), ("y" * 1000, np.float64)], align=False)
+    with pytest.warns(UserWarning, match="too large"):
+        named_long_udt = dtypes.register_new("LongUDT", named_long_dtype)
+    v = Vector(named_long_udt, size=3)
+    v[0] = 0
+    v[1] = (1, 5.6)
+    vv = Vector.ss.deserialize(v.ss.serialize())
+    assert v.isequal(vv, check_dtype=True)
 
 
 def test_infix_outer():
@@ -2197,3 +2227,27 @@ def test_get(v):
     with pytest.raises(ValueError):
         # Not yet supported
         v.get([0, 1])
+
+
+def test_ss_serialize(v):
+    for compression, level, nthreads in itertools.product(
+        [None, "none", "default", "lz4", "lz4hc"], [None, 1, 5, 9], [None, -1, 1, 10]
+    ):
+        if level is not None and compression != "lz4hc":
+            with pytest.raises(TypeError, match="level argument"):
+                v.ss.serialize(compression, level, nthreads=nthreads)
+            continue
+        a = v.ss.serialize(compression, level, nthreads=nthreads)
+        w = Vector.ss.deserialize(a, nthreads=nthreads)
+        assert v.isequal(w, check_dtype=True)
+    b = a.tobytes()
+    w = Vector.ss.deserialize(b)
+    assert v.isequal(w, check_dtype=True)
+    with pytest.raises(ValueError, match="compression argument"):
+        v.ss.serialize("bad")
+    with pytest.raises(ValueError, match="level argument"):
+        v.ss.serialize("lz4hc", -1)
+    with pytest.raises(ValueError, match="level argument"):
+        v.ss.serialize("lz4hc", 0)
+    with pytest.raises(InvalidObject):
+        Vector.ss.deserialize(a[:-5])