1# Guidance for Operator Developer 2 3PyTorch’s operators sometimes require changes for different reasons (e.g. from improving their usability to fixing bugs). These changes can be backward compatibility (BC) breaking, where older programs will no longer run as expected (or at all) on the latest version of PyTorch (an old program / new runtime problem), or forward compatibility (FC) breaking, where new programs will not run on older versions of PyTorch (a new program / old runtime problem). This guidance focuses on the requirements for maintaining backwards compatibility when making changes to an operator. 4In order to do this we introduce the concept of the *upgrader*: a method to adapt the new operator to mimic the old operator behavior. 5When a new runtime reads an old program containing the old operator definition, the upgrader will adapt the old operator definition to comply with the new operator implementation. As you would expect, an upgrader is only applied when an old operation definition is encountered (i.e. if there are no "old" operators in the program, no upgrader would be used). 6For more details on the reasoning behind this new requirement please refer to the [PyTorch Operator Versioning RFC](https://github.com/pytorch/rfcs/blob/master/RFC-0017-PyTorch-Operator-Versioning.md). 7 8If the change to the operator is BC-breaking in either the schema or the semantics, you are responsible for writing an upgrader to prevent the change from becoming BC breaking. 9 10You can determine if your change in the operator is BC breaking, if it fails `test/forward_backward_compatibility/check_forward_backward_compatibility.py `. 11 12### Some examples BC breaking changes 13 14When making changes to the operators, the first thing to identify is if it's BC/FC breaking. Again, we only targetting for BC breaking changes on this guidance. Here are some examples to help understanding what a BC changes may look like: 15 16#### Backward Compatibility Breakage: 17 18- Return types are more generic than the older version 19 - Old: `foo(Tensor self, int a) -> int` 20 - New: `foo(Tensor self, int a) -> Scalar` 21- Argument types are more specific than the older version 22 - Old: `foo(Tensor self, Scalar a) -> int` 23 - New: `foo(Tensor self, int a) -> int` 24- Added new arguments don’t have associated default values 25 - Old: `foo(Tensor self, int a) -> int` 26 - New: `foo(Tensor self, int a, int b) -> int` 27- Internal implementation change even when the schema remains the same 28- Deprecating an operator 29 30 31### The steps to write upgrader: 32 33### 1.Preparation 34 35[Build PyTorch from souce](https://github.com/pytorch/pytorch#from-source) and prepare a test model before making changes to the operator, following the process below. A test model before making the operator changes is needed to test the upgrader. Otherwise, after the change to operator, the new runtime will no longer be able to produce a model with the historic operator and can't test it anymore. 36 37 1. Add a test module in `test/jit/fixtures_srcs/fixtures_src.py`. In `test/jit/fixtures_srcs/generate_models.py`, 38 ``` 39 class TestVersionedLinspaceV7(torch.nn.Module): 40 def __init__(self) -> None: 41 super().__init__() 42 43 def forward(self, a: Union[int, float, complex], b: Union[int, float, complex]): 44 c = torch.linspace(a, b, steps=5) 45 d = torch.linspace(a, b) 46 return c, d 47 ``` 48 Please make sure the module uses the changed operator and follow the name schema ` TestVersioned{${OpnameOverloadedname}}V${kProducedFileFormatVersion}`. [`kProducedFileFormatVersion`](https://github.com/pytorch/pytorch/blob/master/caffe2/serialize/versions.h#L82) can be found in `versions.h`. The example operator usage can be found on [PyTorch Docs](https://pytorch.org/docs/stable/index.html), like [linspace operator](https://pytorch.org/docs/stable/generated/torch.linspace.html) 49 2. Register its corresponding changed operator in ALL_MODULES like following. Use an instance as the key and the changed operator as the value. It will ensure the test model covers everything needed. It's important to check in a valid test model before making the change to the runtime, as it will be really challenging to switch to the revision of the source code and regenerate the test model after the change is merged. 50 51 ``` 52 # key: test module instance, value: changed operator name 53 ALL_MODULES = { 54 TestVersionedLinspaceV7(): "aten::linspace", 55 } 56 ``` 57 58 This module should include the changed operator. If the operator isn't covered in the model, the model export process will fail. 59 60 3. Export the model to `test/jit/fixtures` by running 61 ``` 62 python test/jit/fixtures_src/generate_models.py 63 ``` 64 65 4. Commit the change and submit a pull request. 66 67### 2. Make changes to the operator and write an upgrader. 68 1. Make the operator change. 69 2. Write an upgrader in `torch/csrc/jit/operator_upgraders/upgraders_entry.cpp` file inside a map `kUpgradersEntryMap`. The softly enforced naming format is `<operator_name>_<operator_overload>_<start>_<end>`. The start and end means the upgrader can be applied to the operator exported during when [the global operator version](https://github.com/pytorch/pytorch/blob/master/caffe2/serialize/versions.h#L82) within the range `[start, end]`. Let's take an operator `linspace` with the overloaded name `out` as an example. The first thing is to check if the upgrader exists in [upgraders_entry.cpp](https://github.com/pytorch/pytorch/blob/master/torch/csrc/jit/operator_upgraders/upgraders_entry.cpp). 70 1. If the upgrader doesn't exist in `upgraders_entry.cpp`, the upgrader name can be `linspace_out_0_{kProducedFileFormatVersion}`, where [`kProducedFileFormatVersion`](https://github.com/pytorch/pytorch/blob/master/caffe2/serialize/versions.h#L82) can be found in [versions.h](https://github.com/pytorch/pytorch/blob/master/caffe2/serialize/versions.h). 71 2. If the upgrader exist in `upgraders_entry.cpp`, for example `linspace_out_0_7` (means `linspace.out` operator is changed when operator version is bumped from 7 to 8), 72 1. If it's possible to write an upgrader valid for `linspace` before versioning bumping to 8, after versioning bumping to 8, write an upgrader `linspace_out_0_{kProducedFileFormatVersion}` 73 2. If it's impossible to write an upgrader valid for `linspace` before versioning bumping to 8, check the date when the version is bumped to 8 at [`versions.h`](https://github.com/pytorch/pytorch/blob/master/caffe2/serialize/versions.h#L82). If it has been 180 days, write an upgrader `linspace_out_8_{kProducedFileFormatVersion}` for `linspace.out` after bumping to 8, and deprecate the old upgrader. If it hasn't been 180 days, wait until 180 days and do the same changes as above. 74 75 To write an upgrader, you would need to know how the new runtime with the new `linspace` operator can handle an old model with the old `linspace` operator. When `linspace` is bumped to 8, the change is to make `step` a required argument, instead of an optional argument. The old schema is: 76 ``` 77 linspace(start: Union[int, float, complex], end: Union[int, float, complex], steps: Optional[int], dtype: Optional[int], layout: Optional[int], 78 device: Optional[Device], pin_memory: Optional[bool]): 79 ``` 80 And the new schema is: 81 ``` 82 linspace(start: Union[int, float, complex], end: Union[int, float, complex], steps: int, dtype: Optional[int], layout: Optional[int], 83 device: Optional[Device], pin_memory: Optional[bool]): 84 ``` 85 An upgrader will only be applied to an old model and it won't be applied to a new model. The upgrader can be written with the following logic: 86 ``` 87 def linspace_0_7(start: Union[int, float, complex], end: Union[int, float, complex], steps: Optional[int], *, dtype: Optional[int], layout: Optional[int], 88 device: Optional[Device], pin_memory: Optional[bool]): 89 if (steps is None): 90 return torch.linspace(start=start, end=end, steps=100, dtype=dtype, layout=layout, device=device, pin_memory=pin_memory) 91 return torch.linspace(start=start, end=end, steps=steps, dtype=dtype, layout=layout, device=device, pin_memory=pin_memory) 92 ``` 93 94 The actual upgrader needs to be written as [TorchScript](https://pytorch.org/docs/stable/jit.html), and the below example is the actual upgrader of the operator `linspace.out `and the operator ` linspace` exported at version from 0 to 7. 95 ``` 96 static std::unordered_map<std::string, std::string> kUpgradersEntryMap( 97 { 98 {"linspace_0_7", R"SCRIPT( 99 def linspace_0_7(start: Union[int, float, complex], end: Union[int, float, complex], steps: Optional[int], *, dtype: Optional[int], layout: Optional[int], 100 device: Optional[Device], pin_memory: Optional[bool]): 101 if (steps is None): 102 return torch.linspace(start=start, end=end, steps=100, dtype=dtype, layout=layout, device=device, pin_memory=pin_memory) 103 return torch.linspace(start=start, end=end, steps=steps, dtype=dtype, layout=layout, device=device, pin_memory=pin_memory) 104 )SCRIPT"}, 105 } 106 ``` 107 With the upgrader, when a new runtime loads an old model, it will first check the operator version of the old model. If it's older than the current runtime, it will replace the operator from the old model with the upgrader above. 108 109 3. Bump [`kMaxSupportedFileFormatVersion`](https://github.com/pytorch/pytorch/blob/master/caffe2/serialize/versions.h#L15) the [`kProducedFileFormatVersion`](https://github.com/pytorch/pytorch/blob/master/caffe2/serialize/versions.h#L82) by 1 and provide the reasons under [`versions.h`](https://github.com/pytorch/pytorch/blob/master/caffe2/serialize/versions.h#L73-L81) 110 ``` 111 112 constexpr uint64_t kMaxSupportedFileFormatVersion = 0x9L; 113 114 ... 115 // We describe new operator version bump reasons here: 116 // 1) [01/24/2022] 117 // We bump the version number to 8 to update aten::linspace 118 // and aten::linspace.out to error out when steps is not 119 // provided. (see: https://github.com/pytorch/pytorch/issues/55951) 120 // 2) [01/30/2022] 121 // Bump the version number to 9 to update aten::logspace and 122 // and aten::logspace.out to error out when steps is not 123 // provided. (see: https://github.com/pytorch/pytorch/issues/55951) 124 constexpr uint64_t kProducedFileFormatVersion = 0x9L; 125 ``` 126 127 4. In `torch/csrc/jit/operator_upgraders/version_map.cpp`, add changes like below. You will need to make sure that the entry is **SORTED** by the bumped to version number. 128 ``` 129 {{${operator_name.overloaded_name}, 130 {{${bump_to_version}, 131 "${upgrader_name}", 132 "${old operator schema}"}}}, 133 ``` 134 For the example operator `linspace`, if there are two version bumps, one is bumped to 8 and one is bumped to 12, the sorted result is: 135 ``` 136 {{"aten::linspace", 137 {{12, 138 "linspace_0_11", 139 "aten::linspace(Scalar start, Scalar end, int? steps=None, *, ScalarType? dtype=None, Layout? layout=None, Device? device=None, bool? pin_memory=None) -> Tensor"}}}, 140 {{8, 141 "linspace_0_7", 142 "aten::linspace(Scalar start, Scalar end, int? steps=None, *, ScalarType? dtype=None, Layout? layout=None, Device? device=None, bool? pin_memory=None) -> Tensor"}}}, 143 ``` 144 145 5. After [rebuilding PyTorch](https://github.com/pytorch/pytorch#from-source), run the following command to auto update the file [`torch/csrc/jit/mobile/upgrader_mobile.cpp`](https://github.com/pytorch/pytorch/blob/8757e21c6a4fc00e83539aa7f9c28eb11eff53c1/torch/csrc/jit/mobile/upgrader_mobile.cpp). After rebuild PyTorch from source (`python setup.py`), run 146 147 ``` 148 python pytorch/torchgen/operator_versions/gen_mobile_upgraders.py 149 ``` 150 151 6. Add a test. With the model generated from step 1, you will need to add tests in `test/test_save_load_for_op_versions.py`. Following is an example to write a test 152 153 ``` 154 @settings(max_examples=10, deadline=200000) # A total of 10 examples will be generated 155 @given( 156 sample_input=st.tuples(st.integers(min_value=5, max_value=199), st.floats(min_value=5.0, max_value=199.0)) 157 ) # Generate a pair (integer, float) 158 @example((2, 3, 2.0, 3.0)) # Ensure this example will be covered 159 def test_versioned_div_scalar(self, sample_input): 160 # Step 1. Write down the old behavior of this operator, if possible 161 def historic_div_scalar_float(self, other: float): 162 return torch.true_divide(self, other) 163 164 # Step 2. Write down how current module should look like 165 class MyModuleFloat(torch.nn.Module): 166 def __init__(self) -> None: 167 super().__init__() 168 169 def forward(self, a, b: float): 170 return a / b 171 try: 172 # Step 3. Load the old model and it will apply upgrader 173 v3_mobile_module_float = _load_for_lite_interpreter( 174 pytorch_test_dir + "/jit/fixtures/test_versioned_div_scalar_float_v2.ptl") 175 v3_server_module_float = torch.jit.load( 176 pytorch_test_dir + "/jit/fixtures/test_versioned_div_scalar_float_v2.ptl") 177 except Exception as e: 178 self.skipTest("Failed to load fixture!") 179 180 # Step4. Load the new model and it won't apply the upgrader 181 current_mobile_module_float = self._save_load_mobile_module(MyModuleFloat) 182 current_server_module_float = self._save_load_module(MyModuleFloat) 183 184 for val_a, val_b in product(sample_input, sample_input): 185 a = torch.tensor((val_a,)) 186 b = val_b 187 188 def _helper(m, fn): 189 m_result = self._try_fn(m, a, b) 190 fn_result = self._try_fn(fn, a, b) 191 192 if isinstance(m_result, Exception): 193 self.assertTrue(fn_result, Exception) 194 else: 195 self.assertEqual(m_result, fn_result) 196 197 # Ensure the module loaded from the old model with upgrader 198 # has the same result as the module loaded from the new model 199 _helper(v3_mobile_module_float, current_mobile_module_float) 200 _helper(v3_mobile_module_float, current_server_module_float) 201 202 # Ensure the module loaded from the new model with upgrader 203 # has the same result as the module loaded from the new model 204 _helper(current_mobile_module_float, torch.div) 205 _helper(current_server_module_float, torch.div) 206 ``` 207 208 7. Commit all changes made in step 2 in a single pull request and submit it. 209 210You can look at following PRs to get the rough idea of what needs to be done: 2111. [PR that adds `logspace` test modules](https://github.com/pytorch/pytorch/pull/72052) 2122. [PR that updates `logspace`](https://github.com/pytorch/pytorch/pull/72051) 213 214--- 215**NOTE** 216 2171. Adding arguments with a default value to an operator is not BC breaking, and thus does not require an upgrader. For example, the following change to operator `foo` is backwards compatible: 218``` 219# before 220def foo(x, y): 221 return x, y 222``` 223``` 224# after 225def foo(x, y, z=100): 226 return x, y, z 227``` 228 2292. To help understanding the BC/FC breakage changes, here are some FC breaking changes examples. The solution to resolve it is not there yet. If it's desired, please report it in either [PyTorch Forum](https://discuss.pytorch.org/) or [PyTorch GitHub](https://github.com/pytorch/pytorch). We will prioritize it accordingly. 230 231 - Adding new default argument: 232 - Adding a new default argument not RIGHT BEFORE the out arguments which can be 0 or more. 233 - Old: `foo(Tensor self, int a, int b=1, Tensor(a!) out) -> (Tensor(a!))` 234 - New: `foo(Tensor self, int a, int c=1, int b=1, Tensor(a!) out) -> (Tensor(a!))` 235 236 - Adding out argument NOT at the end of the schema. 237 - Old: `foo(Tensor self, int a, int b=1, Tensor(a!) out) -> (Tensor(a!))` 238 - New: `foo(Tensor self, int a, Tensor(d!), int b=1, Tensor(a!) out) -> (Tensor(a!), Tensor(d!))` 239 240 - Adding default arguments with container types such as ListType or DictType (list or dict). 241 - Old: `foo(Tensor self, int a, int b=1, Tensor(a!) out) -> (Tensor(a!))` 242 - New: `foo(Tensor self, int a, int b=1, int[2] c=1, Tensor(a!) out) -> (Tensor(a!))` 243 - Changing default argument’s name 244 - This will only work when the default argument always uses the default value (so that serialization will ignore it). In all other cases, it will fail. 245 - Old: `foo(Tensor self, int a, int b=1, Tensor(a!) out) -> (Tensor(a!))` 246 - New: `foo(Tensor self, int a, int c=1, Tensor(a!) out) -> (Tensor(a!))` 247 - Changing default argument’s default value. This will break when this argument is saved with the default value in newer runtime. Older runtime will use its old default value which will lead to wrong output. 248 - Old: `foo(Tensor self, int a, int b=1, Tensor(a!) out) -> (Tensor(a!))` 249 - New: `foo(Tensor self, int a, int b=4, Tensor(a!) out) -> (Tensor(a!))` 250 - Adding new operator 251 252--- 253