_ZHOURUI_H_头像
关注
Unity 极限压缩网络协议:把每一个 Bit 都榨干,序列化还能做到多小?封面图

Unity 极限压缩网络协议:把每一个 Bit 都榨干,序列化还能做到多小?

上一篇讲了 MyFramework 普通的字节序列化。但网络消息如果发送得足够频繁,哪怕每条消息只少几个字节,长期累计下来也是非常可观的。

所以 MyFramework 里还有另外一套 SerializerBitWrite / SerializerBitRead:它不满足于“按字节压缩”,而是直接深入到 Bit 级别,整数长度、最高位、符号位、列表长度,能省的地方继续往下省。

项目地址:

https://github.com/ZHOURUIH/MyFramework

这篇不讲使用方式,直接拆里面几个真正的技术点。

一、第一刀:整数只保存真正有效的 Bit

一个 int 固定占 32 Bit,但数值本身不一定需要这么多。

例如:

5 = 00000000 00000000 00000000 00000101

真正有意义的只有:

101

所以第一步就是计算:

这个整数最高的 1 在哪里?

MyFramework 没有每次都循环 32 位寻找,而是预生成了一张:

byte[65536] mBitCountTable;

里面保存 0 ~ 65535 每个数需要多少 Bit。

例如:

1   -> 1 Bit
5   -> 3 Bit
255 -> 8 Bit

uint 和 ulong 则拆成多个 16 Bit 区间,先判断高区间是否为 0,再直接查表。

也就是说,计算整数有效位数本身也被优化掉了。


二、第二刀:连“长度”本身都压缩

知道 5 只需要 3 Bit 还不够。

接收方怎么知道应该读取 3 Bit?

所以还需要保存:

数据用了多少 Bit

但是这个长度同样不需要一个完整字节。

MyFramework 中:

byte   的长度信息使用 3 Bit
short  使用 4 Bit
int    使用 5 Bit
long   使用 6 Bit

例如一个 int 最多只有几十种可能的数据长度,用 5 Bit 描述已经足够。

于是:

int value = 5

不再是:

32 Bit

而是接近:

长度信息 + 真正的数据

这就是整个算法最基础的一层。


三、第三刀:最高位的 1 甚至都不用传

假设我们已经知道:

5 需要 3 Bit

那么二进制一定是:

1xx

因为如果最高位不是 1,它就根本不需要 3 Bit。

既然接收方已经知道:

这个数长度 = 3

那最高位一定是 1。

这个 1 还传它干什么?

所以在允许的编码路径中,MyFramework 会直接把最高位丢掉。

例如:

5 = 101

真正发送:

01

反序列化时知道长度是 3,重新把最高位补成:

101

又省 1 Bit。

单个字段看起来微不足道,但如果一个消息里有几十个整数,这种优化就开始有意义了。


四、第四刀:整个消息没有负数,符号位全部不要

有符号整数还有一个问题:

100
-100

数据绝对值一样,但必须区分正负。

最直接的方案是每个数字增加一个符号位。

但 MyFramework 在 NetPacketBit 上又做了一层:

public bool hasSign()

发送消息之前,先扫描这个消息中所有可能出现负数的字段。

如果整个消息都是非负数:

hasSign = false

那么这个消息里所有有符号整数都不再写符号位。

假设一次消息有:

20 个 int
10 个 long
5 个 short

并且全部为正数,那么一次就直接少掉:

35 Bit

只有消息中真的出现负数,才进入带符号位的编码路径。

这相当于把:

每个字段是否需要符号位

提升成了:

整个消息是否需要符号位

五、第五刀:多个数字共用一个“长度”

这个是整套算法里我认为比较有意思的一点。

假设有:

10
12
15
13

如果每个整数单独编码,就需要:

长度 + 数据
长度 + 数据
长度 + 数据
长度 + 数据

但这四个数字大小非常接近。

那完全可以只记录一次:

统一长度 = 4 Bit

然后:

1010
1100
1111
1101

四个数字共用一个长度描述。

MyFramework 的 isUnityCountShorter() 会真正计算两种方案:

方案A:每个值独立长度

方案B:取最大 Bit 数,
      所有元素使用统一长度

哪个占用 Bit 更少,就自动使用哪个。

数据差异很小时,共用长度更划算。

例如:

100, 101, 105, 110

非常适合统一长度。

但如果是:

1, 2, 3, 1000000

最大值会把其他数字全部拉长,此时独立编码反而可能更小。

所以它并不是固定使用某一种方案,而是序列化之前现场计算哪一种更省。


六、第六刀:上层字段也可以合并计算

这也是为什么源码里会看到这样的写法:

writer.write(
	stackalloc int[2]
	{
		mValue,
		mConditionID
	},
	needWriteSign);

业务上:

mValue
mConditionID

是两个完全不同的字段。

但对于底层序列化来说,它们都是 int。

于是可以暂时看成:

[int, int]

一起计算最优的长度编码方式。

也就是说:

业务上的字段边界,不一定要成为二进制上的编码边界。

这一步把前面的“列表统一长度”优化继续扩展到了普通消息字段。


七、Float 也不直接保存 IEEE 754

float 如果原样传输通常就是 32 Bit。

但很多游戏数据根本不需要完整浮点精度。

例如:

移动速度 = 3.125
CD       = 1.500
概率     = 0.325

MyFramework 默认把 float 保留 3 位小数:

round(value * 1000)

于是:

1.500f

先转换成:

1500

然后再走前面的整数 Bit 压缩。

double 默认则保留 4 位。

这实际上是:

浮点数
↓
定点整数
↓
Bit压缩

代价也很明确:这是有精度限制的有损转换。

所以它适合游戏协议里那些业务上本来就只需要固定小数精度的数据。


八、Vector2、Vector3 继续合并

例如:

Vector3 position;

不会简单写三个 float。

而是:

x * 1000
y * 1000
z * 1000

↓ round

[int, int, int]

然后三个坐标一起进入前面的统一长度算法。

这样 Vector2、Vector3、Vector4 本质上都能复用:

浮点定点化
+
整数压缩
+
多字段统一长度

这一整套优化。


九、不是所有东西都强行按 Bit 搞

字符串最终还是 UTF-8 字节。

MyFramework 会先压缩字符串长度,但真正写 byte[] 时会:

fillZeroToByteEnd();

先对齐到下一个完整字节,再直接复制 Buffer。

也就是说它没有为了节省最后几个 Bit,强行让大量字符串字节都走逐 Bit 写入。

这是空间和 CPU 之间的取舍:

数值字段:
尽可能连续按 Bit 排列

原始 byte[]:
对齐字节后批量复制

“极限压缩”不代表所有地方都不计性能成本。


十、最后来点实际的:同一个消息和 Protobuf 比一下

直接拿项目里的真实消息:

public class SCMissionConditionProgress : NetPacketBit
{
	public BIT_LONG mMissionInstanceID = new();
	public BIT_INT mValue = new();
	public BIT_INT mConditionID = new();
}

假设这一次的数据是:

mMissionInstanceID = 123456789
mValue             = 25
mConditionID       = 3

并且全部为正数。

MyFramework 当前源码中,后两个 int 已经被主动合并:

writer.write(
	stackalloc int[2]
	{
		mValue,
		mConditionID
	},
	needWriteSign);

MyFramework

123456789 需要 27 个有效 Bit。

long 的长度描述占 6 Bit,整个消息没有负数,所以不需要符号位,再去掉可以恢复的最高位:

6 + 26 = 32 Bit

接下来:

25 = 11001 -> 5 Bit
3  = 00011 -> 最长按 5 Bit

两个 int 选择统一长度:

1 Bit   编码模式
5 Bit   统一长度
5 Bit   value
5 Bit   conditionID

刚好:

16 Bit

整个消息体最终:

32 + 16 = 48 Bit

= 6 Byte

按照当前源码的 Bit 顺序推导,消息体为:

5B 45 F3 D6 4B 1E

这里比较的只是消息体,不包含 TCP 包头、序列号、CRC 等额外传输信息。


如果用一个对应的 Protobuf 定义:

message SCMissionConditionProgress
{
	int64 mission_instance_id = 1;
	int32 value = 2;
	int32 condition_id = 3;
}

Protobuf 的整数使用 Varint,同时每个字段还需要携带由字段编号和 wire type 组成的 tag;小正整数的 Varint 本身很紧凑,但字段 tag 仍然存在。

同一组数据序列化后是:

08 95 9A EF 3A
10 19
18 03

一共:

9 Byte

于是这一次具体数据:

序列化方式消息体
MyFramework Bit6 Byte
Protobuf9 Byte

这一组数据下减少了:

3 Byte,也就是约 33.3%。

但这并不意味着“MyFramework 永远比 Protobuf 小”。

Protobuf 的 Varint 本身已经会让小整数使用更少字节,sint32/sint64 还可以通过 ZigZag 高效处理小负数;数值型 repeated 字段也支持 packed 编码。

MyFramework 还能继续往下压,核心原因是它做出了更激进的取舍:

不保存每个字段的 Tag
协议双方严格共享字段顺序

整个消息共享符号信息

相邻同类型字段可以合并编码

长度精确到 Bit

已知最高位可以直接省略

Float 可以牺牲无用精度转为整数

也正因为如此,它不是一种追求高度通用性的序列化格式。

它做的事情更加简单粗暴:

既然客户端和服务器都知道这条协议长什么样,那就不要在网络里重复发送双方早就已经知道的信息。

然后把剩下的数据,再一个 Bit 一个 Bit 地往下榨。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/zr7851310190/article/details/163646381

文章来源转载

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--