【问题标题】:Why does a binary serialization of a float use 55 bytes?为什么浮点数的二进制序列化使用 55 个字节?
【发布时间】:2016-02-16 21:53:42
【问题描述】:

我的任务是获取数百万个浮点数并将它们以 5,000 个成批存储在数据库中,作为二进制文件。这迫使我学习有关序列化性能的有趣知识。

让我吃惊的是序列化数据的大小,比我预期的高出十倍。这个测试告诉我,一个四字节浮点数被序列化为 55 个字节,一个八字节双精度数被序列化为 59 个字节。

这里发生了什么?我希望它可以简单地将浮点值拆分为四个字节。其他 51 个字节是什么?

private void SerializeFloat()
{
    Random rnd = new Random();
    IFormatter iFormatter = new BinaryFormatter();

    using (MemoryStream memoryStream = new MemoryStream(10000000))
    {
        memoryStream.Capacity = 0;
        iFormatter.Serialize(memoryStream, (Single)rnd.NextDouble());
        iFormatter.Serialize(memoryStream, rnd.NextDouble());
    }
}

【问题讨论】:

  • 因为BinaryFormatter 产生了大量的垃圾。
  • 你的例子不现实。当然,每个序列化的单个项目都有元数据。测量大型双精度数组或List<double> 的序列化会更现实。
  • 您是否尝试将浮点数存储为 5000 个元素的 数组 而不是 5000 个单独的元素?我想一个 5000 个元素数组的开销不会比单个浮点数的标题大多少,它会在 5000 个元素上摊销。

标签: c# serialization


【解决方案1】:

序列化不仅仅是将位和字节传输到流中。序列化是结构化输出。这种结构解释了您的实际差异。框架对附加信息进行编码,使其了解序列化数据中对象的类型和数量,以及许多其他可能性。这是一个实现细节,最好不要管它。

如果您需要非结构化输出,you could use BinaryWriter instead

【讨论】:

    【解决方案2】:

    因为有人可能会感兴趣,所以我决定写这篇关于序列化 .NET 对象的二进制格式是什么样的以及我们如何正确解释它?

    我的所有研究都基于.NET Remoting: Binary Format Data Structure 规范。



    示例类:

    为了有一个工作示例,我创建了一个名为 A 的简单类,其中包含 2 个属性,一个字符串和一个整数值,它们分别称为 SomeStringSomeValue

    A 类看起来像这样:

    [Serializable()]
    public class A
    {
        public string SomeString
        {
            get;
            set;
        }
    
        public int SomeValue
        {
            get;
            set;
        }
    }
    

    对于序列化,我当然使用了BinaryFormatter

    BinaryFormatter bf = new BinaryFormatter();
    StreamWriter sw = new StreamWriter("test.txt");
    bf.Serialize(sw.BaseStream, new A() { SomeString = "abc", SomeValue = 123 });
    sw.Close();
    

    可以看出,我传递了一个新的类 A 实例,其中包含 abc123 作为值。



    结果数据示例:

    如果我们在十六进制编辑器中查看序列化结果,我们会得到如下内容:



    让我们解读示例结果数据:

    根据上述规范(这里是 PDF 的直接链接:[MS-NRBF].pdf),流中的每条记录都由RecordTypeEnumeration 标识。 2.1.2.1 RecordTypeNumeration 部分声明:

    此枚举标识记录的类型。每条记录(MemberPrimitiveUnTyped 除外)都以记录类型枚举开头。枚举的大小为 1 BYTE。



    SerializationHeaderRecord:

    所以如果我们回顾一下我们得到的数据,我们就可以开始解释第一个字节了:

    2.1.2.1 RecordTypeEnumeration 中所述,0 的值标识在2.6.1 SerializationHeaderRecord 中指定的SerializationHeaderRecord

    SerializationHeaderRecord 记录必须是二进制序列化中的第一条记录。此记录具有格式的主要版本和次要版本以及顶部对象和标题的 ID。

    它包括:

    • RecordTypeEnum(1 字节)
    • RootId(4 字节)
    • HeaderId(4 字节)
    • 主要版本(4 个字节)
    • 次要版本(4 个字节)



    有了这些知识,我们就可以解释包含 17 个字节的记录:

    00 代表RecordTypeEnumeration,在我们的例子中是SerializationHeaderRecord

    01 00 00 00 代表RootId

    如果序列化流中既没有 BinaryMethodCall 也没有 BinaryMethodReturn 记录,则该字段的值必须包含序列化流中包含的 Class、Array 或 BinaryObjectString 记录的 ObjectId。

    所以在我们的例子中,这应该是 ObjectId 的值 1(因为数据是使用 little-endian 序列化的),我们希望能再次看到它;-)

    FF FF FF FF 代表HeaderId

    01 00 00 00 代表MajorVersion

    00 00 00 00 代表MinorVersion



    二进制库:

    按照规定,每条记录必须以RecordTypeEnumeration 开头。当最后一个记录完成时,我们必须假设一个新的记录开始了。

    让我们解释下一个字节:

    如我们所见,在我们的示例中,SerializationHeaderRecord 后面是 BinaryLibrary 记录:

    BinaryLibrary 记录将 INT32 ID(如 [MS-DTYP] 第 2.2.22 节中指定的)与库名称相关联。这允许其他记录通过使用 ID 来引用图书馆名称。当有多个记录引用同一个库名称时,这种方法会减小连线大小。

    它包括:

    • RecordTypeEnum(1 字节)
    • LibraryId(4 字节)
    • LibraryName(可变字节数(LengthPrefixedString))



    2.1.1.6 LengthPrefixedString中所述...

    LengthPrefixedString 代表一个字符串值。该字符串以 UTF-8 编码字符串的长度为前缀(以字节为单位)。长度编码在一个可变长度字段中,最小为 1 个字节,最大为 5 个字节。为了最小化线路尺寸,长度被编码为可变长度字段。

    在我们的简单示例中,长度始终使用1 byte 编码。有了这些知识,我们可以继续解释流中的字节:

    0C 代表RecordTypeEnumeration,它标识了BinaryLibrary 记录。

    02 00 00 00 代表LibraryId,在我们的例子中是2



    现在LengthPrefixedString 如下:

    42表示LengthPrefixedString的长度信息,其中包含LibraryName

    在我们的例子中,42(十进制 66)的长度信息告诉我们,我们需要读取接下来的 66 个字节并将它们解释为LibraryName

    如前所述,字符串是UTF-8 编码的,因此上面字节的结果将类似于:_WorkSpace_, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null



    ClassWithMembersAndTypes:

    再一次,记录是完整的,所以我们解释下一个的RecordTypeEnumeration

    05 标识ClassWithMembersAndTypes 记录。 2.3.2.1 ClassWithMembersAndTypes 部分声明:

    ClassWithMembersAndTypes 记录是最详细的 Class 记录。它包含有关成员的元数据,包括成员的名称和远程处理类型。它还包含一个引用类的库名称的库 ID。

    它包括:

    • RecordTypeEnum(1 字节)
    • ClassInfo(可变字节数)
    • MemberTypeInfo(可变字节数)
    • LibraryId(4 字节)



    类信息:

    2.3.1.1 ClassInfo 中所述,记录包括:

    • ObjectId(4 字节)
    • 名称(可变字节数(同样是LengthPrefixedString))
    • 成员计数(4 字节)
    • MemberNames(这是LengthPrefixedString 的序列,其中项目数必须等于MemberCount 字段中指定的值。)



    回到原始数据,一步一步来:

    01 00 00 00 代表ObjectId。我们已经看到了这个,它在SerializationHeaderRecord 中被指定为RootId

    0F 53 74 61 63 6B 4F 76 65 72 46 6C 6F 77 2E 41 表示使用LengthPrefixedString 表示的类的Name。如前所述,在我们的示例中,字符串的长度定义为 1 个字节,因此第一个字节 0F 指定必须使用 UTF-8 读取和解码 15 个字节。结果看起来像这样:StackOverFlow.A - 显然我使用了StackOverFlow 作为命名空间的名称。

    02 00 00 00 代表MemberCount,它告诉我们有 2 个成员,都用 LengthPrefixedString 代表将跟随。

    第一位成员姓名:

    1B 3C 53 6F 6D 65 53 74 72 69 6E 67 3E 6B 5F 5F 42 61 63 6B 69 6E 67 46 69 65 6C 64 代表第一个 MemberName1B 又是字符串的长度,长度为 27 个字节,结果如下:<SomeString>k__BackingField

    第二位成员姓名:

    1A 3C 53 6F 6D 65 56 61 6C 75 65 3E 6B 5F 5F 42 61 63 6B 69 6E 67 46 69 65 6C 64代表第二个MemberName1A指定字符串为26字节长。结果是这样的:<SomeValue>k__BackingField



    会员类型信息:

    ClassInfo 之后是MemberTypeInfo

    2.3.1.2 - MemberTypeInfo 部分指出,该结构包含:

    • BinaryTypeEnums(长度可变)

    表示正在传输的成员类型的 BinaryTypeEnumeration 值序列。数组必须:

    • 具有与 ClassInfo 结构的 MemberNames 字段相同数量的项。

    • 进行排序,使 BinaryTypeEnumeration 对应于 ClassInfo 结构的 MemberNames 字段中的成员名称。

    • AdditionalInfos(长度可变),取决于 BinaryTpeEnum 附加信息可能存在也可能不存在。

    | BinaryTypeEnum | AdditionalInfos |
    |----------------+--------------------------|
    | Primitive | PrimitiveTypeEnumeration |
    | String | None |

    所以考虑到这一点,我们几乎就在那里...... 我们期望 2 个 BinaryTypeEnumeration 值(因为我们在 MemberNames 中有 2 个成员)。



    再次回到完整的 MemberTypeInfo 记录的原始数据:

    01 表示第一个成员的BinaryTypeEnumeration,根据2.1.2.2 BinaryTypeEnumeration,我们可以预期String,它使用LengthPrefixedString 表示。

    00 代表第二个成员的BinaryTypeEnumeration,同样,根据规范,它是Primitive。如上所述,Primitive 后面是附加信息,在本例中为 PrimitiveTypeEnumeration。这就是为什么我们需要读取下一个字节,即08,将其与2.1.2.3 PrimitiveTypeEnumeration 中所述的表相匹配,并惊讶地注意到我们可以预期一个Int32,它由4 个字节表示,如一些所述有关基本数据类型的其他文档。



    图书馆 ID:

    MemerTypeInfo之后跟LibraryId,用4个字节表示:

    02 00 00 00 代表LibraryId,即 2。



    价值观:

    2.3 Class Records中所述:

    类成员的值必须序列化为该记录之后的记录,如第 2.7 节所述。记录的顺序必须与 ClassInfo(第 2.3.1.1 节)结构中指定的 MemberNames 的顺序相匹配。

    这就是为什么我们现在可以期待成员的价值。

    让我们看看最后几个字节:

    06 标识一个BinaryObjectString。它代表我们SomeString 属性的值(准确地说是<SomeString>k__BackingField)。

    根据2.5.7 BinaryObjectString,它包含:

    • RecordTypeEnum(1 字节)
    • ObjectId(4 字节)
    • 值(可变长度,表示为LengthPrefixedString



    所以知道了这一点,我们就可以清楚地识别出这一点

    03 00 00 00 代表ObjectId

    03 61 62 63 表示Value,其中03 是字符串本身的长度,61 62 63 是转换为abc 的内容字节。

    希望您能记住还有第二个成员,Int32。知道Int32用4个字节表示,我们可以得出结论,

    必须是我们第二个成员的Value7B 十六进制等于 123 十进制,这似乎符合我们的示例代码。

    所以这里是完整的ClassWithMembersAndTypes 记录:



    消息结束:

    最后一个字节0B代表MessageEnd记录。

    【讨论】:

      【解决方案3】:

      二进制序列化是类型安全的。它确保当您反序列化数据时,您将获得完全相同的对象。

      为了实现这一点,BinaryFormatter 添加了有关您序列化的对象类型的附加数据。您会看到额外的开销。您可以通过序列化为 FileStream 并使用十六进制查看器查看生成的文件来查看它。您将看到返回的字符串,例如“System.Single”,类型名称和“m_value”,存储值的字段的名称。一个减少开销的好方法是,例如,序列化一个数组。

      BinaryWriter 正好相反,非常紧凑但不是类型安全的。两者之间有很多替代方案。

      【讨论】:

        【解决方案4】:

        .NET 序列化会抛出一堆信息,而不是 double 的实际 8 个字节(类型信息等)。您可以使用文件Stream,然后写入由byte[] BitConverter.GetBytes(double)BinaryWriter 类获取的字节。

        .NET 序列化有很多替代方案:

        • 文本格式
          • XML
          • JSON
        • 二进制格式
          • Google 协议缓冲区
          • 消息包

        这些都有其优点和缺点。我特别喜欢MessagePack 并鼓励你看看它。例如,它将使用 9 个字节来存储一个自描述的double

        【讨论】:

        • 虽然第一句话是真的。 “替代品”也是如此。
        • @Euphoric 9 字节来存储自描述 double 似乎与 55 字节几乎“相同”。
        猜你喜欢
        • 2015-07-20
        • 1970-01-01
        • 2011-04-23
        • 1970-01-01
        • 2019-04-14
        • 2015-07-22
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        相关资源
        最近更新 更多