La delta compression, cette belle inconnue
November 30, 2011 in C++, Scandale
La delta compression est une technique de compression des données. Si je m’arrêtais là, je pourrais me contenter de faire un tour sur twitter ou de dormir plus tôt. Au lieu de cela, je vous ai concocté un petit exemple adapté aux jeux vidéo en ligne. C’est beaucoup plus rigolo et d’autre part ça me permet de dérouiller mon C++. Définissons une structure de données assez simple qui représente l’envoi d’informations périodiques d’un joueur au serveur de jeu. Généralement un jeu comme un FPS envoi à intervalle régulier l’état de son personnage au serveur. Valve appelle cela un “tick rate” car cela représente la fréquence de synchronisation entre le client et le serveur. Tout ce qui se passe entre ces intervalles de temps est interpolé. Cette structure de données est décomposée en deux parties : l’en-tête et le corps. L’en-tête représente l’action jouée par le joueur, une BITMAP et un checksum. La BITMAP va nous servir pour l’algorithme de compression. Le checksum est inutile. Le très bon Fabien Sanglard décrit l’utilisation d’un tel checksum dans le protocole de Quake2 comme un moyen de vérifier que les paquets n’ont pas été altérés (autrement dit, l’anti-triche du pauvre). Je l’ai juste ajouté par soucis de réalisme à l’exemple (et aussi pour aligner la structure en mémoire mais ne le dites pas trop fort). Le corps contient les informations sur le monde du jeu. Ce sont ces données là qui seront compressées.
typedef struct header
{
unsigned char action:4, compress:4;
unsigned char csum;
} t_header;
typedef struct body
{
unsigned short int orientation;
unsigned short int pos_x;
unsigned short int pos_y;
} t_body;
typedef struct message
{
t_header head;
t_body body;
} t_message; /* 8 bytes */
Notre message ressemble a ça :
|action|compress|csum|orientation|pos_x|pos_y|
Nous allons simuler le déplacement d’un personnage imaginaire suivant le chemin tracé ci-dessous (sur l’axe des x et des y).
Notez que sur le premier tronçon, le personnage ne change pas d’orientation et que sur les deux derniers il s’oriente aléatoirement. Il y a une dernière étape non visible sur le tracé et pour cause, le personnage ne bouge plus, il se contente juste de regarder à droite et gauche suivant son envie (aussi connue sous le nom “la pause du campeur”). Avec cette simulation nous allons voir de manière immédiate quel est l’intérêt de la delta compression. Vous me direz que je tourne autour du pot depuis le début, et bien c’est vrai. Prenons juste le paquet présenté plus haut.
|action|compress|csum|orientation|pos_x|pos_y|
Supposons que je le compare avec celui envoyé précédemment ou autrement dit p(t) avec p(t-1). Nous ne touchons pas à l’en-tête du paquet, mais seulement aux trois derniers champs. Ainsi si le personnage n’a pas bougé, une opération comme p(t) – p(t-1) nous donnera :
|action|compress|csum|
La delta compression est une technique basée sur la comparaison avec les précédents envois. Elle part du principe qu’il ne sert à rien de renvoyer une information qui n’a pas changé. Si l’on refait un exemple avec des valeurs numérique cette fois:
t-1 : |action|compress|csum|60|10|10| (personnage regardant à 60° et positionné en (10,10))
t : |action|compress|csum|60|15|10| (personnage regardant à 60° et positionné en (15,10))
Le paquet résultant sera :
|action|compress|csum|15|
Et là, vous devez immédiatement vous demander comment le destinataire va pouvoir recomposer le message original. Et bien grâce au champ de bits “compress”. Plongeons dans l’algorithme.
bool Network::Compress(unsigned short int *orig_arg, unsigned short int *old_arg, unsigned short int* current_ptr, int *len, char *mask)
{
// true if no compression
bool inc = false;
// search changed
if(*orig_arg != *old_arg)
{
// put it in buffer
memcpy(current_ptr, orig_arg, sizeof(*orig_arg));
*len += sizeof(short int);
inc = true;
}
else
{
// save unchanged
*mask += 1;
}
// shift
*mask <<=1;
return inc;
}
Déroulons l’algorithme dans l’ordre d’exécution. Les deux premiers arguments sont les mêmes paramètres séparés d’un intervalle de temps. Dans l’exemple précédent pour pos_x, les valeurs 10 et 15. Si ces deux valeurs sont différentes, la delta compression ne s’applique pas. On place la dernière valeur dans le buffer du message qui sera envoyé par un socket (en augmentant la taille du buffer de la taille de l’argument ajouté). La partie intéressante est juste après. Si les deux arguments avaient été égaux, on aurait juste ajouté 1 à la valeur du BITMAP. Et dans les deux cas nous décalerons ensuite. Le BITMAP permet de dire à l’autre bout qui devra recomposer le message quel morceaux ont été enlevé ou non. Je vous rappel que mon champ ‘compress’ tient sur 4 bits. Reprenons l’exemple précedent.
t-1 : |action|compress|csum|60|10|10| (personnage regardant à 60° et positionné en (10,10))
t : |action|compress|csum|60|15|10| (personnage regardant à 60° et positionné en (15,10))
Je compare 60 et 60. C’est égal donc je delta compresse. J’ajoute un au mask et je décale à gauche. Donc compress = ’0010′ Je prends ensuite 10 et 15 qui sont différents. Je ne delta compresse pas mais je décale encore une fois à gauche. Donc compress = ’0100′. Pour le dernier cas, 10 et 10, je delta compresse également. Donc compress = ’1001. (vous remarquerez le bug de mon implémentation, le deuxième zéro est faux car je n’aurai pas du décaler pour le premier passage). Pour corriger l’exemple disons que le résultat obtenu est compress =’0101′. Comme nous n’avons que 3 arguments, nous ne regardons que les trois derniers bits. Un 1 signifie que l’argument a été compressé et un 0 non. Ainsi le serveur sait en recevant ce message que pour le reconstituer il doit insérer les arguments désignés par les 1 (autrement dit, le premier argument et le dernier). L’algorithme complet donne ceci:
int Network::Send(t_message *message)
{
char *snd_buffer;
int snd_len = 0;
if(delta_compress)
{
// can be allocate just one time, simpler to do it here dynamically
snd_buffer = (char *)malloc(sizeof(t_message));
snd_len = 2*sizeof(char);
unsigned short int *current_ptr = (unsigned short int*)(snd_buffer + snd_len);
// copy header into buffer
memcpy(snd_buffer, &(message->head), snd_len);
// change mask
char mask = 0x00;
if(Compress(&(message->body.orientation), &(old_message.body.orientation), current_ptr, &snd_len, &mask)) current_ptr++;
if(Compress(&(message->body.pos_x), &(old_message.body.pos_x), current_ptr, &snd_len, &mask)) current_ptr++;
if(Compress(&(message->body.pos_y), &(old_message.body.pos_y), current_ptr, &snd_len, &mask)) current_ptr++;
// set new mask
snd_buffer[1] = mask;
// save (new) old buffer, we save the whole message but we can only save the body since compression only apply to it
memcpy(&old_message, message, sizeof(t_message));
}
else
{
snd_buffer = (char*)message;
snd_len = sizeof(t_message);
}
sendto(sock, snd_buffer, snd_len, 0, (struct sockaddr*)&sout, sizeof(struct sockaddr));
if(delta_compress) free(snd_buffer);
return snd_len;
}
La classe Network qui encapsule toute la logique permet de désactiver la delta compression (symbolisée par le booléen delta_compress). Voilà rien d’extraordinaire, j’ai quand même réussi à laisser passer un beau bug sur le bitmap. En terme de performance et d’optimisation de la bande passante, les résultats sont assez bons:
Ce graphique représente la taille de messages (en octet) dans le temps. On distingue très nettement les 4 paliers (avancer sur x, diagonale en tournant, avancer sur x en tournant, immobile en tournant). La ligne rouge représente la même expérimentation sans delta compression (on retrouve bien la taille de 8 octets de notre message). On aperçoit des variations dans les 3 derniers paliers à cause du caractère aléatoire de la rotation. Calculons un ratio de compression juste pour avoir quelques chiffres tangibles. Chaque palier est constitué de 200 échantillons. Le pire des cas envoie donc 800*8 octets = 6400 octets. En oubliant les oscillations et en conservant le pire des résultats avec la delta compression, nous obtenons 4*200 + 8*200 + 6*200 + 4*200 = 4400 octets. Soit un ratio de 4400 / 6400 = 68% donc 32% de compression. Bon, ce n’est pas vraiment représentatif de l’activité réelle d’un joueur mais c’est tout de même un beau résultat. Surtout que dans le cas d’un si petit paquet, une compression de type Inflate (Zlib) était à proscrire car l’overhead de l’en-tête de zlib est déjà de 4 octets (au minimum).

Très bel exemple qui permet de bien comprendre la delta compression.
Merci pour tes explications Deckard !
Ton truc est struct fag un peu au début mais ça change du python.
).
Mais ça manque de tests sur des paquets plus gros pour voir si c’est utile de mettre de la compression standard par dessus ou pas (graph avec/sans entête Zlib si ça te parle
Et surtout j’espère que c’est juste un début et qu’un article sur le lag compensation/interpolation suivra : le vrai gros point technique quoi. A ce propos t’as oublié le champs timestamp dans ton header.
Timestamp ou numérotation, that’s the question.
L’interpolation mérite un article rédigé sur plus d’une soirée. Je posterai peut être les tests que j’avais fais sur postworld (lib réseau python avec delta compression), mais en gros si on Zlib, mieux vaut ne pas toucher les données. L’effet zlib + delta compression est souvent plus mauvais que zlib seul (pour un gros paquet j’entends).
A confirmer cependant mais c’est vraiment un domaine intéressant. Il me faudrait un projet un peu plus consistant (comprendre, avec un vrai proto derrière) pour pouvoir benchmarker ça correctement.
Merci pour ton tutoriel qui me sera sans doute bien utile. Tu pourrais juste revenir sur ton bug au niveau des décalages successifs j’ai pas tout suivi.
Très bon article, tu es bon pédagogue.
Même sans être familier avec le sujet, on accroche et on comprend bien.
Continue comme ça.
@Marine merci !
@Antisarko (au passage heureusement qu’il y a marqué lulz dans la catch phrase du site sinon je t’aurais modéré la face): Je décale dès le premier appel de la fonction alors que je devrais seulement décaler au deuxième appel. Dans mon algo, le cas 001 est impossible alors que c’est un cas totu à fait légal. Benji a proposé la solution de décaler suivant la place de l’argument dans la structure (c’est à dire avec un mask comme 0×02 ou 0×04) mais j’aime moins car cela oblige de conserver la position des arguments. Avantage par rapport à mon algo, on peut injecter les arguments dans le désordre, super …
Sinon gros léo en privé me dit :
“c vrai que meme pour de l’embarqué c interessant
par exemple pour les mesures d’un dac
adc pardon
genre un ensemble de capteurs reliés en spi à un serveur central
à part l’init il n’y aurait pas besoin de beaucoup de bande passante
enfin le problème c ensuite la fiabilité”.
Je ne sais pas quoi répondre
un adc c’est pas pareil parce que y’a qu’une seule valeur : Si elle bouge pas tu la send pas c’est tout, tu send pas un truc qui dit que ça change pas. si tu fais ça c’est plus pour justement être sur que ton fil est pas coupé et que c’est bien la valeur qui ne change pas et dans ce cas tu augmentes la fiabilité.
Parce que le spi tu parles à un mec à la fois donc “l’ensemble de capteur” va pas construire un message commun pour donner toute leur info d’un coup ils vont être interrogés par le master un par un.
Un ADC avec un seul channel ? On est plus à l’époque des trains à vapeur Benji, de nos jours le moindre petit ADC il supporte ses 8 canaux et il te fait tout remonter en SPI, donc si tu veux les faire travailler en réseau, c’est typiquement le genre d’algo facile à implémenter qui pourrait t’aider à le faire :p
Bien vu, c’est toujours ce genre d’algo qui faut garder en tête.
Dans le même genre, j’avais utilisé une compression RLE par le passé, adapté aux longues chaînes répétées (http://en.wikipedia.org/wiki/Run-length_encoding)
Plusieurs petite remarques sur ton article
1. Tu dis que ton algo (buggé) permet d’obtenir 0000 > 0010 > 0100 > 1001. Il me semble que tu obtiens plutôt 0000 > 0010 > 0100 > 1010
2. Je n’ai peut-être pas compris la difficulté, mais le fait de faire le bitshift avant la comparaison te permettrait d’avoir une chaîne de type 0000 > 0001 > 0010 > 0101 corrigeant ton bug ? (http://pastebin.com/ukq7e6fc)
3. Ton algo rend le protocole moins robuste aux lags/pertes : imaginons qu’un delta de position est perdu, le serveur aura toujours l’ancienne position tant que le joueur ne se sera pas redéplacé. Or pour corriger ce problème, tu vas devoir échangé des infos, ce qui limite un peu l’intérêt de ta compression.
4. Pour les ADC c’est possible, mais l’intérêt est limité si tu ne met pas une fenêtre de tolérance : en gros tu delta une data qui a changé de +/- 5. Mais ce problème fait référence aux comparaison de flottants plus qu’à un probleme de l’algo elle même.
5. Ta fonction Send n’est pas thread-safe. La valeur de delta_compress peut avoir changé entre temps, typiquement quand ton thread réseau envoie des messages et que ton utilisateur décide de désactiver la delta compression.
Ca c’est du Vicos comme on l’aime !
RLE c’est en effet excellent.
1. 2. Il faudrait que je fasse tourner l’algo pour être sûr, mais pour moi bug. Le bitshift avant la comparaison est en effet plus malin et règle le problème.
3. Pour la résistance aux pertes, il faut ajouter un mécanisme de numéro de séquence avec l’envoi de messages niveau serveur qui ack/piggyback les messages clients. Pour récupérer d’une perte, pas besoin de tout renvoyer, c’est le serveur qui fait foi. S’il, y a trop de loss entre toi et le serveur, tu peux tout simplement désactiver la compression et ça n’aura pas plus d’impact (ce qui est assez beau comme concept).
4. Je ne vais pas répondre aux trolls de benji et leo.
5. Au début je croyais que tu parlais du fait que je ne copiais pas le message d’entrée directement (ce que je pourrai faire au début au lieu de le faire à la fin). Mais tu as raison, il faut faire attention au delta compress. Enfin, tu risques d’avoir un message en trop en delta compression (la variable n’est évalué qu’une fois dans tout l’algo il me semble). A mon sens, ce n’est pas à l’utilisateur d’activer ou désactiver ce mécanisme mais à la pile réseau qui detecterait trop de pertes, etc.
Note, dans ton algo sur pastebin tu as oublié de mettre le *mask += 1 dans le else il me semble.
non, je shift le mask dans tous les cas. Je redéroule pour être clair :
je pars avec un mask à b0000
1st call: b0000 (shift) b0000 (save unchanged) b0001
2nd call: b0001 (shift) b0010
3th call: b0010 (shift) b0100 (save unchanged) b0101
Je me corrige, oublie le précédent comment : la partie vrai du ‘if’ (cad quand la valeur à changer) tu as un return qui me permet d’éviter de mettre un bloc else. Coté compilateur ça évite d’avoir à faire un jmp pour le bloc else je pense… Bien que maintenant l’optimisation du code des compilateurs est asses efficace.
1&2. Je confirme qu’il y a bug, mais ton article annonce que tu obtiens b1001 avec ton algo buggé et je pense que tu obtiens plutôt b1010.
5. Si entre les deux “if(delta_compress)” (ligne 6 & ligne 38) la valeur a changé alors tu fais un free de quelque chose de non mallocé ou alors tu malloc un buffer sans faire de free derrière.
Ça n’aura aucun impact sur l’algo elle-même.