Repository: dmlc/dgl

Issue:
AttributeError: 'Column' object has no attribute `deferred_dtype`
## 🐛 Bug

@ndickson-nvidia @BarclayII 

The introduction of `deferred_dtype` in PR #3890 seems to be causing issues when loading a graph file saved with an older version of DGL. Do you have any ideas on fixing the compatibility? We might also want to add a unit test for loading a graph data file saved with an older version of DGL.

![WechatIMG8012](https://user-images.githubusercontent.com/19576924/173212115-6cfe418c-6718-4cce-adbc-d06e193bb1c0.jpeg)

## Expected behavior

<!-- A clear and concise description of what you expected to happen. -->

## Environment

 - DGL Version (e.g., 1.0): 0.8.2
 - Backend Library & Version (e.g., PyTorch 0.4.1, MXNet/Gluon 1.3):
 - OS (e.g., Linux):
 - How you installed DGL (`conda`, `pip`, source):
 - Build command you used (if compiling from source):
 - Python version:
 - CUDA/cuDNN version (if applicable):
 - GPU models and configuration (e.g. V100):
 - Any other relevant information:
AttributeError: 'Column' object has no attribute `deferred_dtype`
## 🐛 Bug

@ndickson-nvidia @BarclayII 

The introduction of `deferred_dtype` in PR #3890 seems to be causing issues when loading a graph file saved with an older version of DGL. Do you have any ideas on fixing the compatibility? We might also want to add a unit test for loading a graph data file saved with an older version of DGL.

![WechatIMG8012](https://user-images.githubusercontent.com/19576924/173212115-6cfe418c-6718-4cce-adbc-d06e193bb1c0.jpeg)

## Expected behavior

<!-- A clear and concise description of what you expected to happen. -->

## Environment

 - DGL Version (e.g., 1.0): 0.8.2
 - Backend Library & Version (e.g., PyTorch 0.4.1, MXNet/Gluon 1.3):
 - OS (e.g., Linux):
 - How you installed DGL (`conda`, `pip`, source):
 - Build command you used (if compiling from source):
 - Python version:
 - CUDA/cuDNN version (if applicable):
 - GPU models and configuration (e.g. V100):
 - Any other relevant information:


Discussion:
Do we see the same issue for `pinned_by_dgl` as well? Or do we have an established method for handling new attributes?
Are there any plans to add a more robust system for saving/loading graph data?  Relying on just serializing Python objects, like what `dgl.data.utils.save_info` and `dgl.data.utils.load_info` do, seems like a risky approach that may not be able to support any sort of compatibility, especially since there's no version information.  I'd expect that a few data members of `Column`, for example, never need to be saved or loaded, since `device`, `index`, and `deferred_dtype` should be resolved and cleared before saving to disk, and there are probably other similar examples in other classes.
博主，您这个问题怎么解决的
Blogger, how did you solve this problem
> Blogger, how did you solve this problem

For now, downgrading to `0.8.1` should address the issue.
thank you ,my problem has solved.
Hello, how can you downgrade to 0.8.1 from 0.8.2? I have a similar problem.
@natalie-0430 That will depend on how you installed it. If you used pip for example:
```
pip uninstall dgl-cu113
pip install dgl-cu113==0.8.1 dglgo -f https://data.dgl.ai/wheels/repo.html
```

@mufeili What's your preferred fix for this? Should we add `hasattr` to check if the unpickled object has the new attribute, or should we override `__setstate__` (https://docs.python.org/3/library/pickle.html#object.__setstate__) to explicitly populate missing attributes from old versions?
> @natalie-0430 That will depend on how you installed it. If you used pip for example:
> 
> ```
> pip uninstall dgl-cu113
> pip install dgl-cu113==0.8.1 dglgo -f https://data.dgl.ai/wheels/repo.html
> ```
> 
> @mufeili What's your preferred fix for this? Should we add `hasattr` to check if the unpickled object has the new attribute, or should we override `__setstate__` (https://docs.python.org/3/library/pickle.html#object.__setstate__) to explicitly populate missing attributes from old versions?

@jermainewang @BarclayII What do you think? Probably overriding `__setstate__` will be more general for future changes?
I vote for overriding `__setstate__` and `__getstate__`. Two other related questions:
* Shall we always make pickling/unpickling backward compatible? This may prevent us from making changes to `DGLGraph` and `Frame`.
* How to properly test pickling/unpickling? This is somewhat related to the question above.
Do we see the same issue for `pinned_by_dgl` as well? Or do we have an established method for handling new attributes?
Are there any plans to add a more robust system for saving/loading graph data?  Relying on just serializing Python objects, like what `dgl.data.utils.save_info` and `dgl.data.utils.load_info` do, seems like a risky approach that may not be able to support any sort of compatibility, especially since there's no version information.  I'd expect that a few data members of `Column`, for example, never need to be saved or loaded, since `device`, `index`, and `deferred_dtype` should be resolved and cleared before saving to disk, and there are probably other similar examples in other classes.
博主，您这个问题怎么解决的
Blogger, how did you solve this problem
> Blogger, how did you solve this problem

For now, downgrading to `0.8.1` should address the issue.
thank you ,my problem has solved.
Hello, how can you downgrade to 0.8.1 from 0.8.2? I have a similar problem.
@natalie-0430 That will depend on how you installed it. If you used pip for example:
```
pip uninstall dgl-cu113
pip install dgl-cu113==0.8.1 dglgo -f https://data.dgl.ai/wheels/repo.html
```

@mufeili What's your preferred fix for this? Should we add `hasattr` to check if the unpickled object has the new attribute, or should we override `__setstate__` (https://docs.python.org/3/library/pickle.html#object.__setstate__) to explicitly populate missing attributes from old versions?
> @natalie-0430 That will depend on how you installed it. If you used pip for example:
> 
> ```
> pip uninstall dgl-cu113
> pip install dgl-cu113==0.8.1 dglgo -f https://data.dgl.ai/wheels/repo.html
> ```
> 
> @mufeili What's your preferred fix for this? Should we add `hasattr` to check if the unpickled object has the new attribute, or should we override `__setstate__` (https://docs.python.org/3/library/pickle.html#object.__setstate__) to explicitly populate missing attributes from old versions?

@jermainewang @BarclayII What do you think? Probably overriding `__setstate__` will be more general for future changes?
I vote for overriding `__setstate__` and `__getstate__`. Two other related questions:
* Shall we always make pickling/unpickling backward compatible? This may prevent us from making changes to `DGLGraph` and `Frame`.
* How to properly test pickling/unpickling? This is somewhat related to the question above.

Resolving patch:
diff --git a/python/dgl/frame.py b/python/dgl/frame.py
--- a/python/dgl/frame.py
+++ b/python/dgl/frame.py
@@ -182,14 +182,9 @@ class Column(TensorStorage):
     index : Tensor
         Index tensor
     """
-    def __init__(self, storage, scheme=None, index=None, device=None, deferred_dtype=None):
+    def __init__(self, storage, *args, **kwargs):
         super().__init__(storage)
-        self.scheme = scheme if scheme else infer_scheme(storage)
-        self.index = index
-        self.device = device
-        self.deferred_dtype = deferred_dtype
-        self.pinned_by_dgl = False
-        self._data_nd = None
+        self._init(*args, **kwargs)
 
     def __len__(self):
         """The number of features (number of rows) in this column."""
@@ -243,6 +238,8 @@ def data(self):
     def data(self, val):
         """Update the column data."""
         self.index = None
+        self.device = None
+        self.deferred_dtype = None
         self.storage = val
         self._data_nd = None  # should unpin data if it was pinned.
         self.pinned_by_dgl = False
@@ -430,8 +427,43 @@ def __repr__(self):
 
     def __getstate__(self):
         if self.storage is not None:
-            _ = self.data               # evaluate feature slicing
-        return self.__dict__
+            # flush any deferred operations
+            _ = self.data
+        state = self.__dict__.copy()
+        # data pinning does not get serialized, so we need to remove that from
+        # the state
+        state['_data_nd'] = None
+        state['pinned_by_dgl'] = False
+        return state
+
+    def __setstate__(self, state):
+        index = None
+        device = None
+        if 'storage' in state and state['storage'] is not None:
+            assert 'index' not in state or state['index'] is None
+            assert 'device' not in state or state['device'] is None
+        else:
+            # we may have a column with only index information, and that is
+            # valid
+            index = None if 'index' not in state else state['index']
+            device = None if 'device' not in state else state['device']
+        assert 'deferred_dtype' not in state or state['deferred_dtype'] is None
+        assert 'pinned_by_dgl' not in state or state['pinned_by_dgl'] is False
+        assert '_data_nd' not in state or state['_data_nd'] is None
+
+        self.__dict__ = state
+        # properly initialize this object
+        self._init(self.scheme if hasattr(self, 'scheme') else None,
+                   index=index,
+                   device=device)
+
+    def _init(self, scheme=None, index=None, device=None, deferred_dtype=None):
+        self.scheme = scheme if scheme else infer_scheme(self.storage)
+        self.index = index
+        self.device = device
+        self.deferred_dtype = deferred_dtype
+        self.pinned_by_dgl = False
+        self._data_nd = None
 
     def __copy__(self):
         return self.clone()


Test patch:
diff --git a/tests/compute/test_frame.py b/tests/compute/test_frame.py
--- a/tests/compute/test_frame.py
+++ b/tests/compute/test_frame.py
@@ -4,6 +4,7 @@
 import numpy as np
 import backend as F
 import unittest
+import pickle
 from test_utils import parametrize_idtype
 
 def test_column_subcolumn():
@@ -14,7 +15,7 @@ def test_column_subcolumn():
                                [0., 2., 4., 0.]]), F.ctx())
     original = Column(data)
 
-    # subcolumn from cpu context 
+    # subcolumn from cpu context
     i1 = F.tensor([0, 2, 1, 3], dtype=F.int64)
     l1 = original.subcolumn(i1)
 
@@ -37,3 +38,47 @@ def test_column_subcolumn():
     i1i2i3 = F.copy_to(F.gather_row(i1i2, F.copy_to(i3, F.context(i1i2))), F.ctx())
     assert F.array_equal(l3.data, F.gather_row(data, i1i2i3))
 
+def test_serialize_deserialize_plain():
+    data = F.copy_to(F.tensor([[1., 1., 1., 1.],
+                               [0., 2., 9., 0.],
+                               [3., 2., 1., 0.],
+                               [1., 1., 1., 1.],
+                               [0., 2., 4., 0.]]), F.ctx())
+    original = Column(data)
+
+    serial = pickle.dumps(original)
+    new = pickle.loads(serial)
+    print("new = {}".format(new))
+
+    assert F.array_equal(new.data, original.data)
+
+def test_serialize_deserialize_subcolumn():
+    data = F.copy_to(F.tensor([[1., 1., 1., 1.],
+                               [0., 2., 9., 0.],
+                               [3., 2., 1., 0.],
+                               [1., 1., 1., 1.],
+                               [0., 2., 4., 0.]]), F.ctx())
+    original = Column(data)
+
+    # subcolumn from cpu context
+    i1 = F.tensor([0, 2, 1, 3], dtype=F.int64)
+    l1 = original.subcolumn(i1)
+
+    serial = pickle.dumps(l1)
+    new = pickle.loads(serial)
+
+    assert F.array_equal(new.data, l1.data)
+
+def test_serialize_deserialize_dtype():
+    data = F.copy_to(F.tensor([[1., 1., 1., 1.],
+                               [0., 2., 9., 0.],
+                               [3., 2., 1., 0.],
+                               [1., 1., 1., 1.],
+                               [0., 2., 4., 0.]]), F.ctx())
+    original = Column(data)
+    original = original.astype(F.int64)
+
+    serial = pickle.dumps(original)
+    new = pickle.loads(serial)
+
+    assert new.dtype == F.int64