第一版的NativeBuffering[上篇][下篇])发布之后,我又对它作了多轮迭代,对性能作了较大的优化。比如确保所有类型的数据都是内存对齐的,内部采用了池化机器确保真正的“零内存分配”等。对于字典类型的数据成员,原来只是“表现得像个字段”,这次真正使用一段连续的内存构架了一个“哈希表”。我们知道对于每次.NET新版本的发布,原生的JSON序列化(System.Text.Json)的性能都作了相应的提升,本篇文章通过具体的性能测试比较NativeBuffering和它之间的性能差异。

一、一种“特别”的序列化解决方案

二、Source Generator驱动的编程模式

三、序列化性能比较

四、原生类型性能“友好”

五、Unmanaged 类型“性能加速”

六、无需反序列化

七、数据读取的成本

一、一种“特别”的序列化解决方案

一般的序列化/发序列化都是数据对象和序列化结果(字符串或者单纯的字节序列)之间的转换。以下图为例,我们定义了一个名为Person的数据类型,如果采用典型的JSON序列化方案,序列化器会将该对象转换成一段具有JSON格式的字符串,这段字符串可以通过反序列化的方式“恢复”成一个Person对象。

如果采用NativeBuffering序列化解决方案,它会引入一个新的数据类型PersonBufferedMessage,我们采用Source Generator的方式根据Person的数据结构自动生成PersonBufferedMessage类型。除此之外,PersonBufferedMessage还会为Person生成额外的方式将自身对象以字节的方式写入提供的缓冲区。

换句话说,Person对象会转换成一段连续的字节序列化,PersonBufferedMessage就是对这段字节序列的封装。它的数据成员(Name、Age和City)不再定义成“地段”,而被定义成“只读属性”,它能找到对应的数据成员在这段字节序列中的位置,从而将其读出来。为了提供数据读取的性能,所有的数据成员在序列化字节序列中总是按照“原生(Native)”的形式存储,并且确保是内存对齐的。也正是这个原因,NativeBuffering并不是一个跨平台的序列化解决方案。

二、Source Generator驱动的编程模式

NativeBuffering的整个编程围绕着“Source Generator”进行的,接下来我们简单演示一下如何使用它。我们在一个控制台程序中添加NativeBuffering相关的两个NuGet包NativeBuffering和NativeBuffering.Generator(使用最新版本),并定义如下这个数据类型Person。由于我们需要为Person生成额外的类型成员,我们必须将其定义成partial class。

[BufferedMessageSource]
public partial class Person
{
public string Name { get; set; }
public int Age { get; set; }
public string[] Hobbies { get; set; }
public string Address { get; set; }
public string PhoneNumber { get; set; }
public string Email { get; set; }
public string Gender { get; set; }
public string Nationality { get; set; }
public string Occupation { get; set; }
public string EducationLevel { get; set; }
public string MaritalStatus { get; set; }
public string SpouseName { get; set; }
public int NumberOfChildren { get; set; }
public string[] ChildrenNames { get; set; }
public string[] LanguagesSpoken { get; set; }
public bool HasPets { get; set; }
public string[] PetNames { get; set; } public static Person Instance = new Person
{
Name = "Bill",
Age = 30,
Hobbies = new string[] { "Reading", "Writing", "Coding" },
Address = "123 Main St.",
PhoneNumber = "555-555-5555",
Email = "bill@gmail.com",
Gender = "M",
Nationality = "China",
Occupation = "Software Engineer",
EducationLevel = "Bachelor's",
MaritalStatus = "Married",
SpouseName = "Jane",
NumberOfChildren = 2,
ChildrenNames = new string[] { "John", "Jill" },
LanguagesSpoken = new string[] { "English", "Chinese" },
HasPets = true,
PetNames = new string[] { "Fido", "Spot" }
};
}

我们在类型上标注BufferedMessageSourceAttribute特性将其作为BufferedMessage的“源”。此时如果我们查看VS的Solution Explorer,就会从项目的Depedences/Analyers/NativeBuffering.Generator看到生成的两个.cs文件。我们使用的PersonBufferedMessage就定义在PersonBufferedMessage.g.cs文件中。为Person额外添加的类型成员就定义在Person.g.cs文件中。

我们使用下的代码来演示针对Person和PersonBufferedMessage的序列化和反序列化。如下面的代码片段所示,我们利用Instance静态属性得到Person单例对象,直接调用其WriteToAsync方法(Person.g.cs文件会使Person类型实现IBufferedObjectSource接口,WriteToAsync方法使针对该接口定义的扩展方法)对自身进行序列化,并将作为序列化结果的字节序列存储到指定的文件(person.bin)文件中。

using NativeBuffering;
var fileName = "person.bin";
await Person.Instance.WriteToAsync(fileName);
using (var pooledBufferedMessage = await BufferedMessage.LoadAsync<PersonBufferedMessage>(fileName))
{
var bufferedMessage = pooledBufferedMessage.BufferedMessage;
Console.WriteLine(
@$"{nameof(bufferedMessage.Name),-17}: {bufferedMessage.Name}
{nameof(bufferedMessage.Age),-17}: {bufferedMessage.Age}
{nameof(bufferedMessage.Hobbies),-17}: {string.Join(", ", bufferedMessage.Hobbies)}
{nameof(bufferedMessage.Address),-17}: {bufferedMessage.Address}
{nameof(bufferedMessage.PhoneNumber),-17}: {bufferedMessage.PhoneNumber}
{nameof(bufferedMessage.Email),-17}:{bufferedMessage.Email}
{nameof(bufferedMessage.Nationality),-17}:{bufferedMessage.Nationality},
{nameof(bufferedMessage.Occupation),-17}:{bufferedMessage.Occupation},
{nameof(bufferedMessage.EducationLevel),-17}:{bufferedMessage.EducationLevel}
{nameof(bufferedMessage.MaritalStatus),-17}:{bufferedMessage.MaritalStatus}
{nameof(bufferedMessage.SpouseName),-17}:{bufferedMessage.SpouseName}
{nameof(bufferedMessage.NumberOfChildren),-17}:{bufferedMessage.NumberOfChildren}
{nameof(bufferedMessage.ChildrenNames),-17}: {string.Join(", ", bufferedMessage.ChildrenNames)}
{nameof(bufferedMessage.LanguagesSpoken),-17}: {string.Join(", ", bufferedMessage.LanguagesSpoken)}
{nameof(bufferedMessage.HasPets),-17}:{bufferedMessage.HasPets}
{nameof(bufferedMessage.PetNames),-17}: {string.Join(", ", bufferedMessage.PetNames)}");
}

然后我们调用BufferedMessage的静态方法LoadAsync<PersonBufferedMessage>加载该文件的内容。该方法会返回一个PooledBufferedMessage<PersonBufferedMessage>对象,它的BufferedMessage返回我们需要的PersonBufferedMessage对象。PersonBufferedMessage具有与Person一致的数据成员,我们将它们的内容一一输出,可以看出PersonBufferedMessage承载的内容与Person对象使完全一致的。

NativeBuffering之所以能供实现真正意义的“零内存分配”,得益于对“池化机制”的应用。LoadAsync<T>方法返回的PooledBufferedMessage<T>使用一段池化的缓存区来存储序列化的字节,当我们不再使用的时候,需要调用其Dispose方法缓存区释放到缓存池内。

三、序列化性能比较

接下来我们以就以上面定义的Person类型为例,利用BenchmarkDotNet比较一下NativeBuffering与JSON序列化在性能上的差异。如下面的代码片段所示,针对JSON序列化的Benchmark方法直接调用JsonSerializer的Serialize方法将Person单例对象序列化成字符串。

[MemoryDiagnoser]
public class Benchmark
{
private static readonly Func<int, byte[]> _bufferFactory = ArrayPool<byte>.Shared.Rent; [Benchmark]
public string SerializeAsJson() => JsonSerializer.Serialize(Person.Instance); [Benchmark]
public void SerializeNativeBuffering()
{
var arraySegment = Person.Instance.WriteTo(_bufferFactory);
ArrayPool<byte>.Shared.Return(arraySegment.Array!);
}
}

在针对NativeBuffering的Benchmark方法中,我们调用Person单例对象的WriteTo扩展方法对齐进行序列化,并利用一个ArraySegment<T>结构返回序列化结果。WriteTo方法具有一个类型为Func<int, byte[]>的参数,我们使用它来提供一个存放序列化结果的字节数组。作为Func<int, byte[]>输入参数的整数代表序列化结果的字节长度,这样我们才能确保提供的字节数组具有充足的存储空间。

为了避免内存分配,我们利用这个委托从ArrayPool<byte>.Shared表示的“数组池”中借出一个大小适合的字节数组,并在完成序列化之后将其释放。这段性能测试结果如下,可以看出从耗时来看,针对NativeBuffering的序列化稍微多了一点,但是从内存分配来看,它真正做到了内存的“零分配”,而JSON序列化则分配了1K多的内存。

四、原生类型性能“友好”

从上面展示的性能测试结果可以看出,NativeBuffering在序列化上确实可以不用分配额外的内存,但是耗时似乎多了点。那么是否意味着NativeBuffering不如JSON序列化高效吗?其实也不能这么说。NativeBuffering会使用一段连续的内存(而不是多段缓存的拼接)来存储序列化结果,所以它在序列化之前需要先计算字节数。由于Person定义的绝大部分数据成员都是字符串,这导致了它需要计算字符串编码后的字节数,这个计算会造成一定的耗时。

所以字符串不是NativeBuffering的强项,对于其他数据类型,NativeBuffering性能其实很高的。现在我们重新定义如下这个名为Entity的数据类型,它将常用的Primitive类型和一个字符串数组作为数据成员

[BufferedMessageSource]
public partial class Entity
{
public byte ByteValue { get; set; }
public sbyte SByteValue { get; set; }
public short ShortValue { get; set; }
public ushort UShortValue { get; set; }
public int IntValue { get; set; }
public uint UIntValue { get; set; }
public long LongValue { get; set; }
public ulong ULongValue { get; set; }
public float FloatValue { get; set; }
public double DoubleValue { get; set; }
public decimal DecimalValue { get; set; }
public bool BoolValue { get; set; }
public char CharValue { get; set; }
public byte[] Bytes { get; set; } public static Entity Instance = new Entity
{
ByteValue = 1,
SByteValue = 2,
ShortValue = 3,
UShortValue = 4,
IntValue = 5,
UIntValue = 6,
LongValue = 7,
ULongValue = 8,
FloatValue = 9,
DoubleValue = 10,
DecimalValue = 11,
BoolValue = true,
CharValue = 'a',
Bytes = Enumerable.Range(0, 128).Select(it => (byte)it).ToArray()
};
}

然后我们将性能测试的两个Benchmark方法使用的数据类型从Person改为Entity。

[MemoryDiagnoser]
public class Benchmark
{
private static readonly Func<int, byte[]> _bufferFactory = ArrayPool<byte>.Shared.Rent; [Benchmark]
public string SerializeAsJson() => JsonSerializer.Serialize(Entity.Instance); [Benchmark]
public void SerializeNativeBuffering()
{
var arraySegment = Entity.Instance.WriteTo(_bufferFactory);
ArrayPool<byte>.Shared.Return(arraySegment.Array!);
}
}

再来看看如下的测试结果,可以看出NativeBuffering序列化的耗时差不多是JSON序列化的一般,并且它依然没有任何内存分配。

五、Unmanaged 类型“性能加速”

NativeBuffering不仅仅对Primitive类型“友好”,对于自定义的unmanaged结构,更能体现其性能优势。原因很简单,Unmanaged类型(含Primitive类型和自定义的unmanaged结构)的内存布局就是连续的,NativeBuffering在进行序列化的适合不需要对它进行“分解”,直接拷贝这段内存的内容就可以了。

作为演示,我们定义了如下这个Foobarbazqux结构体,可以看出它满足unmanaged结构的要求。作为序列化数据类型的Record中,我们定义了一个Foobarbazqux数组类型的属性Data。Instance静态字段表示的单例对象的Data属性包含100个Foobarbazqux对象。

[BufferedMessageSource]
public partial class Record
{
public Foobarbazqux[] Data { get; set; } = default!;
public static Record Instance = new Record { Data = Enumerable.Range(1, 100).Select(_ => new Foobarbazqux(new Foobarbaz(new Foobar(111, 222), 1.234f), 3.14d)).ToArray()};
} public readonly record struct Foobar(int Foo, long Bar);
public readonly record struct Foobarbaz(Foobar Foobar, float Baz);
public readonly record struct Foobarbazqux(Foobarbaz Foobarbaz, double Qux);

我们同样只需要将性能测试的数据类型改成上面定义的Record就可以了。

[MemoryDiagnoser]
public class Benchmark
{
private static readonly Func<int, byte[]> _bufferFactory = ArrayPool<byte>.Shared.Rent; [Benchmark]
public string SerializeAsJson() => JsonSerializer.Serialize(Record.Instance); [Benchmark]
public void SerializeNativeBuffering()
{
var arraySegment = Record.Instance.WriteTo(_bufferFactory);
ArrayPool<byte>.Shared.Return(arraySegment.Array!);
}
}

这次NativeBuffering针对JSON序列化的性能优势完全是“碾压式”的。耗时:72us/3us。JSON序列化不仅带来了26K的内存分配,还将部分内存提升到了Gen1。

六、无需反序列化

对于序列化来说,NativeBuffering不仅仅可以避免内存的分配。如果不是大规模涉及字符串,它在耗时方面依然具有很大的优势。即使大规模使用字符串,考虑到JSON字符串最终还是需要编码转换成字节序列化,两者之间的总体耗时其实差别也不大。NativeBuffering针对反序列化的性能优势更是毋庸置疑,因为我们使用的BufferedMessage就是对序列化结果的封装,所以反序列化的成本几乎可以忽略(经过测试耗时在几纳秒)。

为了让大家能够感觉到与JSON分序列化的差异,我们将读取数据成员的操作也作为反序列化的一部分。如下面这个Benchmark所示,我们在初始化自动执行的Setup方法中,针对同一个Entity对象的两种序列化结果(字节数组)存储在_encodedJson 和_payload字段中。

[MemoryDiagnoser]

public class Benchmark
{
private byte[] _encodedJson = default!;
private byte[] _payload = default!; [GlobalSetup]
public void Setup()
{
_encodedJson = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(Entity.Instance));
_payload = new byte[Entity.Instance.CalculateSize()];
Person.Instance.WriteToAsync(new MemoryStream(_payload), true);
} [Benchmark]
public void DeserializeFromJson()
{
var entity = JsonSerializer.Deserialize<Entity>(Encoding.UTF8.GetString(_encodedJson))!;
Process(entity.ByteValue);
Process(entity.SByteValue);
Process(entity.ShortValue);
Process(entity.UShortValue);
Process(entity.IntValue);
Process(entity.UIntValue);
Process(entity.LongValue);
Process(entity.ULongValue);
Process(entity.FloatValue);
Process(entity.DoubleValue);
Process(entity.DecimalValue);
Process(entity.BoolValue);
Process(entity.CharValue);
Process(entity.Bytes);
} [Benchmark]
public void DeserializeFromNativeBuffering()
{
unsafe
{
fixed (byte* _ = _payload)
{
var entity = new EntityBufferedMessage(new NativeBuffer(_payload));
Process(entity.ByteValue);
Process(entity.SByteValue);
Process(entity.ShortValue);
Process(entity.UShortValue);
Process(entity.IntValue);
Process(entity.UIntValue);
Process(entity.LongValue);
Process(entity.ULongValue);
Process(entity.FloatValue);
Process(entity.DoubleValue);
Process(entity.DecimalValue);
Process(entity.BoolValue);
Process(entity.CharValue);
Process(entity.Bytes);
}
}
} [MethodImpl(MethodImplOptions.NoInlining)]
private void Process<T>(T expected)
{ }
}

针对JSON反序列化的Benchmark方法利用JsonSerializer将解码生成的字符串反序列化成Entity对象,并调用Process方法读取每个数据成员。在针对NativeBuffering的Benchmark方法中,我们需要创建一个fixed上下文将字节数组内存地址固定,因为BufferedMessage的读取涉及很多Unsafe的内存地址操作,然后将这个字节数组封装成NativeBuffer对象,并据此将EntityBufferedMessage创建出来。这个方法的耗时花在后面针对数据成员的读取上。如下所示的两种“反序列”方式的测试结果。从如下所示的测试结果可以看出相对于NativeBuffering的无需反序列化,JSON反序列化的成本还是巨大的,不仅反映在耗时上,同时也反映在内存分配上。

七、数据读取的成本

上面的测试结果也体现了NativeBuffering针对数据读取的成本。和普通类型直接读取字段的值不同,NativeBuffering生成的BufferedMessage对象是对一段连续字节序列的封装,此字节序列就是序列化的结果。如下所示的是这段字节序列的布局:整个序列包括两个部分,后面一部分依次存储每个字段的内容,前面一部分则存储每个字段内容在整个字节序列的位置(偏移量)。

BufferedMessage的每个数据成员都是只读属性,针对数据成员的读取至少需要两个步骤:

  • 根据数据成员的序号读取存储内容的偏移量;
  • 将偏移量转换成内存地址,结合当前数据类型将数据读出来;

所以NativeBuffering最大的问题就是:读取数据成员的性能肯定比直接读取字段值要高。从上面的测试结果大体可以测出单次读取耗时大体在1-2纳米之间(24.87ns包括创建EntityBufferedMessage和调用空方法Process的耗时),也就是说1秒中可以完成5-10亿次读取。我想这个读取成本大部分应用是可以接受的,尤其是相对于它在序列化/反序列化在耗时和内存分配带来的巨大优势来说更是如此。

NativeBuffering,一种高性能、零内存分配的序列化解决方案[性能测试篇]的更多相关文章

  1. JVM之---Java内存分配参数(第四篇)

    1.内存分配参数---大纲 Ø如何设置堆内存 Ø如何设置栈内存 Ø如何设置方法区 Ø如何设置对的分配比率 Ø设置参数打印堆栈: ØJava程序的两种模式:Server&Client 2.设置堆 ...

  2. [Java] 理解JVM之二:类加载步骤及内存分配

    一.类加载器 ClassLoader 能根据需要将 class 文件加载到 JVM 中,它使用双亲委托模型,在加载类的时候会判断如果类未被自己加载过,就优先让父加载器加载.另外在使用 instance ...

  3. Netty 中的内存分配浅析

    Netty 出发点作为一款高性能的 RPC 框架必然涉及到频繁的内存分配销毁操作,如果是在堆上分配内存空间将会触发频繁的GC,JDK 在1.4之后提供的 NIO 也已经提供了直接直接分配堆外内存空间的 ...

  4. molloc堆区的动态内存分配

    [前言]前面有一篇文章介绍了堆区栈区的区别.栈区的核心主要集中在操作一个栈结构,一般由操作系统维护.堆区,主要是我们程序员来维护,核心就是动态内存分配. 这篇笔记结束就不在高新CSAPP的读书笔记了, ...

  5. 【转载】Ogre的内存分配策略

    原文:Ogre的内存分配策略 读这个之前,强烈建议看一下Alexandrescu的modern c++的第一章关于policy技术的解释.应该是这哥们发明的,这里只是使用. 首先列出涉及到的头文件:( ...

  6. GlusterFS源代码解析 —— GlusterFS 内存分配方式

    原文地址:http://blog.csdn.net/wangyuling1234567890/article/details/24564891 GlusterFS 的内存分配主要有两种方式,一种是内存 ...

  7. NET的堆和栈04,对托管和非托管资源的垃圾回收以及内存分配

    在" .NET的堆和栈01,基本概念.值类型内存分配"中,了解了"堆"和"栈"的基本概念,以及值类型的内存分配.我们知道:当执行一个方法的时 ...

  8. .NET的堆和栈03,引用类型对象拷贝以及内存分配

    在" .NET的堆和栈01,基本概念.值类型内存分配"中,了解了"堆"和"栈"的基本概念,以及值类型的内存分配.我们知道:当执行一个方法的时 ...

  9. 高性能go服务之高效内存分配

    高性能go服务之高效内存分配 手动内存管理真的很坑爹(如C C++),好在我们有强大的自动化系统能够管理内存分配和生命周期,从而解放我们的双手. 但是呢,如果你想通过调整JVM垃圾回收器参数或者是优化 ...

  10. Android内存管理(5)*官方教程:Logcat内存日志各字段含义,查看当前内存快照,跟踪记录内存分配,用adb查看内存情况时各行列的含义,捕获内存快照的3种方法,如何让程序暴漏内存泄漏的方法

    Investigating Your RAM Usage In this document Interpreting Log Messages                 内存分析日志中各消息的含 ...

随机推荐

  1. Valine评论插件因为LeanCloud国内域名解析问题无法正常使用的解决方法

    近日,LeanCloud 国内域名解析存在问题,Valine评论插件的评论内容都储存在LeanCloud,使用Valine评论插件的个人博客的评论及阅读数会显示失败. 关于 LeanCloud 国内域 ...

  2. 借助 mkcert 和批处理命令生成局域网证书

    借助 mkcert 和批处理命令生成局域网证书 自动获取ipv4,一键生成很方便 cd /d %~dp0 ipconfig |find "IPv4" > ipv4 set / ...

  3. python 导出项目需要的库

    输入命令: pip freeze > requirements.txt 产生的文件内容如下: asgiref==3.4.0 Django==3.2.4 django-debug-toolbar= ...

  4. APubPlat 一款Devops自动化部署、持续集成、堡垒机开源项目、友好的Web Terminal

    嗨.很高心你能进入这里,我是zane,  在这里给你介绍一款完整的Devops自动化部署工具 APubPlat - 一款完整的Devops自动化部署.持续集成.堡垒机.并且友好的Web Termina ...

  5. 无linux基础也能熟练掌握git的基本操作

    git是一个用来管理项目的工具,它的远程仓库有github.gitee.gitlab代码托管中心,既可以用于个人共享代码,又可以用于团队进行项目的协作与发布,那么我们一起来了解一下git该如何使用~ ...

  6. JavaScript迭代协议

    JavaScript迭代协议解读 迭代协议分为可迭代协议和迭代器协议. 协议指约定俗成的一系列规则. 可迭代协议 可迭代协议规定了怎么样算是一个可迭代对象:可迭代对象或其原型链上必须有一个键为[Sym ...

  7. c# .NET 高级编程 高并发必备技巧(二) - 分布式锁

    上一篇文章简单的介绍了单机的情况下如何进行加锁,防止高并发带来的问题. 然而现实中,一般会高并发的应用,很少会单机部署.当用户量达到一定的程度,分布式.集群部署是必然的选择.在分布式部署的情况下,之前 ...

  8. 推荐一款免费好用的远程桌面:Getscreen

    因为平时有多台设备要用,所以远程桌面是我经常要使用的工具. 最近,正好看到一款不错的远程桌面软件,马上拿出来推荐给大家,如果有需要的可以看看. 今天要推荐的远程桌面软件就是这款叫Getscreen的软 ...

  9. 使用API调用获取商品数据的完整方案

    ​ 在电子商务应用程序中,商品详情接口是不可或缺的一部分.它用于从电商平台或自己的数据库中获取商品数据,并将其提供给应用程序的其他部分使用.本文将详细介绍如何设计一个完整的商品详情接口方案,其中包括使 ...

  10. 淘宝详情api接口的应用

    淘宝详情API接口是一个基于HTTP协议的接口服务,可用于获取淘宝商品的具体信息.下面将介绍如何调用淘宝详情API接口获取淘宝商品数据的步骤. 1.注册账号并创建应用 首先,我们需要进行账号注册.实名 ...