IDL: Encodings, Mappings, and Backward Compatibility

 
Author:  Follow: TwitterFacebook
Job Title:Sarcastic Architect
Hobbies:Thinking Aloud, Arguing with Managers, Annoying HRs,
Calling a Spade a Spade, Keeping Tongue in Cheek
 
 


Pages: 1 2

Example: Encoding

We’ve already discussed IDL and Mapping (and can now use our generated stubs and specify how we want them to look). Now let’s see what Encoding is about. First, let’s see what will happen if we use “naive” encoding for our C++ struct Character, and will transfer it as a C struct (except for inventory field, which we’ll delta-compress to avoid transferring too much of it). In this case, we’ll get about 60bytes/Character/network-tick (with 6 doubles responsible for 48 bytes out of it).

Now let’s consider the following Encoding:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
ENCODING(MYENCODING1) PUBLISHABLE_STRUCT Character {
  VLQ character_id;
  DELTA {
    FIXED_POINT(0.01) x;//for rendering purposes, we need our coordinates
                        //only with precision of 1cm
                        //validity range is already defined in IDL
                        //NB: given the range and precision,
                        // 'x' has 20'000'000 possible values,
                        // so it can be encoded with 21 bits
    FIXED_POINT(0.01) y;
    FIXED_POINT(0.01) z;
    FIXED_POINT(0.01) vx;
    FIXED_POINT(0.01) vy;
    FIXED_POINT(0.01) vz;
  }
  DELTA FIXED_POINT(0.01) angle;//given the range specified in IDL,
                                //  FIXED_POINT(0.01) can be encoded
                                //  with 16 bits
  DELTA BIT(2) Animation;//can be omitted, as 2-bit is default
                         //  for 3-value enum in MYENCODING1
  DELTA VLQ animation_frame;
  DELTA SEQUENCE<Item> inventory;
};

VLQ A variable-length quantity (VLQ) is a universal code that uses an arbitrary number of binary octets (eight-bit bytes) to represent an arbitrarily large integer.— Wikipedia —Here we’re heavily relying on the properties of MYENCODING1, which is used to marshal our struct. For the example above, let’s assume that MYENCODING1 is a quite simple bit-oriented encoding which supports delta-compression (using 1 bit from bit stream to specify whether the field has been changed), and also supports VLQ-style encoding; also let’s assume that it is allowed to use rounding for FIXED_POINT fields.

As soon as we take these assumptions, specification of our example Encoding above should become rather obvious; one thing which needs to be clarified in this regard, is that DELTA {} implies that we’re saying that the whole block of data within brackets is likely to change together, so that our encoding will be using only a single bit to indicate that the whole block didn’t change.

Now let’s compare this encoding (which BTW is not the best possible one) to our original naive encoding. Statistically, even if Character is moving, we’re looking at about 20 bytes/Character/network-tick, which is 3x better than naive encoding.

Even more importantly, this change in encoding can be done completely separately from all the application code(!) – merely by changing Encoding declaration

It means that we can develop our code without caring about specific encodings, and then, even at closed beta stages, find out an optimal encoding and get that 3x improvement by changing only Encoding declaration.

Such separation between the code and Encodings is in fact very useful; in particular, it allows to use lots of optimizations which are too cumbersome to think of when you’re developing application-level code.

To continue our example and as a further optimization, we can add dead reckoning, and it can be as simple as rewriting Encoding above into

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
ENCODING(MYENCODING2) PUBLISHABLE_STRUCT Character {
  VLQ character_id;
  DELTA {
    DEAD_RECKONING(0.02) {//0.02 is maximum acceptable coordinate
                          // deviation due to dead reckoning
      FIXED_POINT(0.01) x;
      FIXED_POINT(0.01) vx;
    }
    DEAD_RECKONING(0.02) {
      FIXED_POINT(0.01) y;
      FIXED_POINT(0.01) vy;
    }
    DEAD_RECKONING { //by default, maximum acceptable deviation
                     //  due to dead reckoning
                     // is the same as for coordinate
                     //  (0.01 in this case)
      FIXED_POINT(0.01) z;
      FIXED_POINT(0.01) vz;
    }
  }//DELTA
  DELTA FIXED_POINT(0.01) angle;
  DELTA BIT(2) Animation;
  DELTA VLQ animation_frame;
  DELTA SEQUENCE<Item> inventory;
};

Inquisitive hare:How much can be gained by each of such specialized encodings – still depends on the game, but if you can try-and-test a dozen of different encodings within a few hours – it will usually allow you to learn quite a few things about your traffic (and to optimize things both visually and traffic-wise too).When manipulating encodings is this simple, then experimenting with encodings to find out a reasonably optimal one becomes a breeze. How much can be gained by each of such specialized encodings – still depends on the game, but if you can try-and-test a dozen of different encodings within a few hours – it will usually allow you to learn quite a few things about your traffic (and to optimize things both visually and traffic-wise too).

Backward Compatibility

One very important (and almost-universally-ignored) feature of IDLs is backward compatibility. When our game becomes successful, features are added all the time. And adding a feature often implies a protocol change. With Continuous Deployment it happens many times a day.

And one of the requirements in this process is that the new protocol always remains backward-compatible with the old one. While for text-based1 protocols backward compatibility can usually be achieved relatively easily, for binary protocols (and games almost-universally use binary encodings due to the traffic constraints, see “Publishable State: Delivery, Updates, Interest Management, and Compression” section above for discussion) it is a much more difficult endeavour, and requires certain features from the IDL.

What we need from an IDL compiler is a mode when it tells whether one IDL qualifies as a “backward-compatible version” of another one

Ok, this feature would certainly be nice for code maintenance (and as a part of build process), but are we sure that it is possible to implement such a feature? The answer is “yes, it is possible”, and there are at least two ways how it can be implemented. In any case, let’s observe that two most common changes of the protocols are (a) adding a new field, and (b) extending an existing field.2 While other protocol changes (such as removing a field) do happen in practice, it is usually rare enough occurrence, so that we will ignore it for the purposes of our discussion here.

The first way to allow adding fields is to have field names (or other kind of IDs) transferred alongside with the fields themselves. This is the approach taken by Google Protocol Buffers, where everything is always transferred as a key-value pair (with keys depending on field IDs, which can be explicitly written to the Protocol Buffer’s IDL). Therefore, to add a field, you just adding a field with a new field-ID, that’s it. To be able to extend fields (and also to skip those optional-fields-you-dont-know-about), you need to have size for each of the fields, and Google Protocol Buffers have it too (usually implicitly, via field type). This approach works good, but it has its cost: those 8-additional-bits-per-field3 (to contain the field ID+type) are not free.

The second way to allow adding fields into encoded data is a bit more complicated, but allows to deal with not-explicitly-separated (and therefore not incurring those 8-bits-per-field cost) data streams, including bitstreams. To add/extend fields to such non-discriminated streams, we may implement the following approach:

  • Arguing hare:introduce a concept of “fence” into our Encodingsintroduce a concept of “fence” into our Encodings. There can be “fences” within structs, and/or within RPC calls

    • one possible implementation for “fences” is assuming an implicit “fence” after each field; while this approach rules out certain encodings, it does guarantee correctness

    • between “fences”, IDL compiler is allowed to reorder/combine fields as it wishes (though any such combining/reordering MUST be strictly deterministic).

    • across “fences”, no such reordering/combining is allowed

  • then, adding a field immediately after the “fence” is guaranteed to be backward-compatible as soon as we define it with a default value

    • within a single protocol update, several fields can be added/extended simultaneously only after one “fence”

    • to add another field in a separate protocol update, another “fence” will be necessary

  • extending a field can be implemented as adding a (sub-)field, with a special interpretation of this (sub-)field, as described in the example below


1 such as XML-based
2 that is, until we’re throwing everything away and rewriting the whole thing from scratch
3 Google Protocol Buffers use overhead of 8 bits per field; in theory, you may use something different while using key-value encodings, but the end result won’t be that much different

 

Let’s see how it may work if we want to extend the following Encoding:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ENCODING(MYENCODINGA) PUBLISHABLE_STRUCT Character {
  UINT16 character_id;
  DELTA {
    FIXED_POINT(0.01) x;
    FIXED_POINT(0.01) y;
    FIXED_POINT(0.01) z;
    FIXED_POINT(0.01) vx;
    FIXED_POINT(0.01) vy;
    FIXED_POINT(0.01) vz;
  }
};
//MYENCODINGA is a stream-based encoding
//  and simply serialized all the fields
//  in the specified order

Let’s assume that we want to extend our UINT16 character_id field into UINT32, and to add another field UINT32 some_data. Then, after making appropriate changes to the IDL, our extended-but-backward-compatible Encoding may look as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
ENCODING(MYENCODINGA) PUBLISHABLE_STRUCT Character {
  UINT16 character_id;
  DELTA {
    FIXED_POINT(0.01) x;
    FIXED_POINT(0.01) y;
    FIXED_POINT(0.01) z;
    FIXED_POINT(0.01) vx;
    FIXED_POINT(0.01) vy;
    FIXED_POINT(0.01) vz;
  }
  //Up to this point, the stream is exactly the same
  //  as for "old" encoding
   
  FENCE
 
  EXTEND character_id TO UINT32;
    //at this point in the stream, there will be additional 2 bytes placed
    // with high-bytes of character_id
    // if after-FENCE portion is not present – character_id
    // will use only lower-bytes from pre-FENCE portion
 
  UINT32 some_data DEFAULT=23;
    // if the marshalled data doesn't have after-FENCE portion,
    // application code will get 23
};

As we can see, for the two most common changes of the protocols, making a compatible IDL is simple; moreover, making an IDL compiler to compare these two IDLs to figure out that they’re backward-compatible – is trivial. Formally, IDL B qualifies as a backward-compatible version of IDL A, if all of the following stands:

  • IDL B starts with full IDL A
  • after IDL A, in IDL B there is a FENCE declaration
  • after the FENCE declaration, all the declarations are either EXTEND declarations, or new declarations with specified DEFAULT.

On Google Protocol Buffers

Google Protocol Buffers is one IDL which has recently got a lot of popularity. It is binary, it is extensible, and it is reasonably efficient (or at least not unreasonably inefficient). Overall, it is one of the best choices for a usual business app.

Judging hare:Therefore, while I agree that Google Protocol Buffers are good enough for most of the business apps out there, I insist that for games you usually need something better. MUCH better.However, when it comes to games, I still strongly prefer my own IDL with my own IDL compiler. The main reason for it is that in Google Protocol Buffers there is only one encoding, and the one which is not exactly optimized for games. Delta compression is not supported, there are no acceptable ranges for values, no rounding, no dead reckoning, and no bit-oriented encodings. Which means that if you use Google Protocol Buffers to marshal your in-app data structures directly, then compared to your own optimized IDL, it will cost you in terms of traffic, and cost a lot.

Alternatively, you may implement yourself most of the compression goodies mentioned above, and then to use Google Protocol Buffers to transfer this compressed data, but it will clutter your application-level code with this compression stuff, and still won’t be able to match traffic-wise some of the encodings possible with your own IDL (in particular, bit-oriented streams and Huffman coding will be still out of question4).

Therefore, while I agree that Google Protocol Buffers are good enough for most of the business apps out there, I insist that for games you usually need something better. MUCH better.


4 that is, unless you’re using Google Protocol Buffers just to transfer pre-formatted bytes, which usually doesn’t make much sense

 

[[To Be Continued…

Tired hare:This concludes beta Chapter 3(d) from the upcoming book “Development and Deployment of Massively Multiplayer Games (from social games to MMOFPS, with social games in between)”. Stay tuned for beta Chapter 7, “Engine-Centric Architecture: Unity 5, Unreal Engine 4, and Photon Server from MMO point of view”]]

Don't like this post? Comment↯ below. You do?! Please share: ...on LinkedIn...on Reddit...on Twitter...on Facebook

Acknowledgement

Cartoons by Sergey GordeevIRL from Gordeev Animation Graphics, Prague.

Join our mailing list: