软硬件协同编程 - C#玩转CPU高速缓存(附示例)_weixin_30752699的博客-程序员信息网

技术标签: c#  

写在前面

好久没有写博客了,一直在不断地探索响应式DDD,又get到了很多新知识,解惑了很多老问题,最近读了Martin Fowler大师一篇非常精彩的博客The LMAX Architecture,里面有一个术语Mechanical Sympathy,姑且翻译成软硬件协同编程(Hardware and software working together in harmony),很有感悟,说的是要把编程与底层硬件协同起来,这样对于开发低延迟、高并发的系统特别地重要,为什么呢,今天我们就来讲讲CPU的高速缓存。

电脑的缓存系统

1082769-20180925222129456-318615655.png
电脑的缓存系统分了很多层级,从外到内依次是主内存、三级高速缓存、二级高速缓存、一级高速缓存,所以,在我们的脑海里,觉点磁盘的读写速度是很慢的,而内存的读写速度确是快速的,的确如此,从上图磁盘和内存距离CPU的远近距离就看出来。这里先说明一个概念,主内存被所有CPU共享;三级缓存被同一个插槽内的CPU所共享;单个CPU独享自己的一级、二级缓存,即高速缓存。CPU是真正做事情的地方,它会先从高速缓存中去获取所需的数据,如果找不到,再去三级缓存中查找,如果还是找不到最终就去会主内存查找,并且找到数据后,先要复制到缓存(L1、L2、L3),然后在返回数据;如果每一次都这样来来回回地复制和读取数据,那么无疑是非常耗时。如果能够把数据缓存到高速缓存中就好了,这样不仅CPU第一次就可以直接从高速缓存中命中数据,而且每个CPU都独占自己的高速缓存,多线程下也不存在临界资源的问题,这才是真正的低延迟,但是这个地方对高层开发人员而言根本不透明,肿么办?

对于CPU而言,只有第一、二、三级才是缓存区,主内存不是,如果需要到主内存读取数据,这种情况称为缓存未命中(cache miss)。

探索高速缓存的构造

我们先来看一张使用鲁大师检测的处理器信息截图,如下:
1082769-20180925100145710-1970573672.png
从上图可以看到,CPU高速缓存(一、二级)的存储单元为Line,大小为64 bytes,也就是说无论我们的数据大小是多少,高速缓存都是以64 bytes为单位缓存数据,比如一个8位的long类型数组,即使只有第一位有数据,每次高速缓存加载数据的时候,都会顺带把后面7位数据也一起加载(因为数组内元素的内存地址是连续的),这就是底层硬件CPU的工作机制,所以我们要利用这个天然的优势,让数据独占整个缓存行,这样CPU命中的缓存行中就一定有我们的数据。

示例

使用不同的线程数,对一个long类型的数值计数500亿次。

备注:统计分析图表和总结在最后。

1. 一般的实现方式

大多数程序员都会这样子构造数据,老铁没毛病。

代码

/ <summary>
/ CPU伪共享高速缓存行条目(伪共享)
/ </summary>
public class FalseSharingCacheLineEntry
{
    public long Value = 0L;
}

单线程

1082769-20180925105240816-201206260.png
平均响应时间 = 1508.56 毫秒。

双线程

1082769-20180925105948419-158071879.png
平均响应时间 = 4460.40 毫秒。

三线程

1082769-20180925105919407-1625372992.png
平均响应时间 = 7719.02 毫秒。

四线程

1082769-20180925110252600-1254314884.png
平均响应时间 = 10404.30 毫秒。

2. 独占缓存行,直接命中高速缓存。

2.1 直接填充

代码

/// <summary>
/// CPU高速缓存行条目(直接填充)
/// </summary>
public class CacheLineEntry
{
    protected long P1, P2, P3, P4, P5, P6, P7;
    public long Value = 0L;
    protected long P9, P10, P11, P12, P13, P14, P15;
}

为了保证高速缓存行中一定有我们的数据,所以前后都填充7个long。

单线程

1082769-20180925110603267-2120922765.png
平均响应时间 = 1516.33 毫秒。

双线程

1082769-20180925110724855-467806013.png
平均响应时间 = 1529.97 毫秒。

三线程

1082769-20180925110906964-1701280505.png
平均响应时间 = 1563.65 毫秒。

四线程

1082769-20180925111038660-138432086.png
平均响应时间 = 1616.12 毫秒。

2.2 内存布局填充

作为一个C#程序员,必须写出优雅的代码,可以使用StructLayout、FieldOffset来控制class、struct的内存布局。

备注:就是上面直接填充的优雅实现方式而已。

代码

/// <summary>
/// CPU高速缓存行条目(控制内存布局)
/// </summary>
[StructLayout(LayoutKind.Explicit, Size = 120)]
public class CacheLineEntryOne
{
    [FieldOffset(56)]
    private long _value;

    public long Value
    {
        get => _value;
        set => _value = value;
    }
}

单线程

1082769-20180925111442365-1967468231.png
平均响应时间 = 2008.12 毫秒。

双线程

1082769-20180925111612695-317162267.png
平均响应时间 = 2046.33 毫秒。

三线程

1082769-20180925111732713-1761829.png
平均响应时间 = 2081.75 毫秒。

四线程

1082769-20180925111903886-61636529.png
平均响应时间 = 2163.092 毫秒。

3. 统计分析

1082769-20180925143835947-1235589402.png
上面的图表已经一目了然了吧,一般实现方式的持续时间随线程数呈线性增长,多线程下表现的非常糟糕,而通过直接、内存布局方式填充了数据后,响应时间与线程数的多少没有无关,达到了真正的低延迟。其中直接填充数据的方式,效率最高,内存布局方式填充次之,在四线程的情况下,一般实现方式持续时间为10.4秒多,直接填充数据的方式为1.6秒,内存布局填充方式为2.2秒,延迟还是比较明显,为什么会有这么大的差距呢?

刨根问底

在C#下,一个long类型占8 byte,对于一般的实现方式,在多线程的情况下,隶属于每个独立线程的数据会共用同一个缓存行,所以只要有一个线程更新了缓存行的数据,那么整个缓存行就自动失效,这样就导致CPU永远无法直接从高速缓存中命中数据,每次都要经过一、二、三级缓存到主内存中重新获取数据,时间就是被浪费在了这样的来来回回中。而对数据进行填充后,隶属于每个独立线程的数据不仅被缓存到了CPU的高速缓存中,而且每个数据都独占整个缓存行,其他的线程更新数据,并不会导致自己的缓存行失效,所以每次CPU都可以直接命中,不管是单线程也好,还是多线程也好,只要线程数小于等于CPU的核数都和单线程一样的快速,正如我们经常在一些性能测试软件,都会看到的建议,线程数最好小于等于CPU核数,最多为CPU核数的两倍,这样压测的结果才是比较准确的,现在明白了吧。

最后来看一下大师们总结的未命中缓存的测试结果

从CPU到 大约需要的 CPU 周期 大约需要的时间
主存 约60-80纳秒
QPI 总线传输 (between sockets, not drawn) 约20ns
L3 cache 约40-45 cycles 约15ns
L2 cache 约10 cycles, 约3ns
L1 cache 约3-4 cycles 约1ns
寄存器 寄存器

每一个开发人员都应该知道计算机硬件IO的延迟数传送门

源码参考:
https://github.com/justmine66/MDA/blob/master/tests/MDA.Test.Disruptor/FalseSharingTest.cs

延伸阅读

Magic cache line padding
The LMAX Architecture

补充

感谢@ firstrose同学主动测试后的提醒,大家应该向他学习,带着疑惑看博客,不明白的自己动手测试。对于内存布局填充方式,去掉属性后,经过测试性能与直接填充方式几乎无差别了,不过本示例代码仅仅作为一个测试参考,主要目的是给大家布道如何利用CPU高速缓存工作机制,通过缓存行的填充来避免假共享,从而写出真正低延迟的代码。

/// <summary>
/// CPU高速缓存行条目(控制内存布局)
/// </summary>
[StructLayout(LayoutKind.Explicit, Size = 120)]
public class CacheLineEntryOne
{
    [FieldOffset(56)]
    public long Value;
}

总结

编写单、多线程下表现都相同的代码,历来都是非常困难的,需要不断地从深度、广度上积累知识,学无止境,无痴迷,不成功,希望大家能有所收获。

写在最后

如果有什么疑问和见解,欢迎评论区交流。
如果你觉得本篇文章对您有帮助的话,感谢您的【推荐】。
如果你对.NET高性能编程感兴趣的话可以【关注我】,我会定期的在博客分享我的学习心得。
欢迎转载,请在明显位置给出出处及链接

转载于:https://www.cnblogs.com/justmine/p/9696160.html

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/weixin_30752699/article/details/95164492

智能推荐

pytorch之NIN_(ノへ ̄、)。的博客-程序员信息网

LeNet、AlexNet和VGG在设计上的共同之处是:先以由卷积层构成的模块充分抽取空间特征,再以由全连接层构成的模块来输出分类结果。其中,AlexNet和VGG对LeNet的改进主要在于如何对这两个模块加宽(增加通道数)和加深。本节我们介绍网络中的网络(NiN)。它提出了另外一个思路,即串联多个由卷积层和“全连接”层构成的小网络来构建一个深层网络import timeimport torchfrom torch import nn, optimimport syssys.path.app

如何调整虚拟机中Ubuntu系统显示尺寸大小_苏君1号的博客-程序员信息网

1、使用CTRL+alt+t打开终端2、使用命令xrandr  查看相应的屏幕分辨率的相关参数3、使用命令 xrandr -s 相应分辨率(如1280x800)即可改为相应的显示尺寸

bootstrap 表格全选、反选、取消操作_Java小飞侠的博客-程序员信息网_bootstrap全选

先说一下要实现的效果。列表如图所示,点击全选按钮则使所有数据为选中状态;点击取消按钮,则所有数据为未选中状态;点击反选按钮,则把已选中的数据置为未选中,未选中的数据置为选中状态。代码为:JS代码为:代码虽然简单,但是却花费了很长时间,主要时间花费在反选功能上。全选和取消操作网上有很多,但是反选功能却始终没有找到。在被逼无奈下,只好从bootstrap的js源码看起。...

OpenGL ES之3D模型加载和渲染_╰つ栺尖篴夢ゞ的博客-程序员信息网_opengl 渲染模型

OpenGL ES 3D 模型本质上是由一系列三角形在 3D 空间(OpenGL 坐标系)中构建而成,另外还包含了用于描述三角形表面的纹理、光照、材质等信息。构建一些规则的 3D 物体,如立方体、球体、椎体等,我们自己可以手动轻易实现,但是在实际开发中往往会用到复杂的 3D 物体,如人体、汽车等,这就需要设计师和专业的建模工具软件(像 3DS Max、Maya )来生成。......

MySQL中boolean类型_xia_codings的博客-程序员信息网_mysql boolean

MySQL保存Boolean值时,用1代表TRUE,0代表FALSE,boolean类型在MySQL里的类型为tinyint(1)。1.创建表create table test( id int PRIMARY key, status boolean)这样是可以创建成功。查看建表后的语句会发现,mysql把它替换成tinyint(1)。CREATE TABLE `test` ( `id` int NOT NULL, `status` tinyint(1) DEFAULT N

使用Visual Studio Code (VS Code)写C51代码(配置指南)__祥子@的博客-程序员信息网_vs编程c51

用Keil写代码是会让人发疯的,以前一直用VS stdio 写,后来发现VS Code更强大,于是开始用,但是发现写C51有些关键字是不支持的,老是提出错误,也是几经折腾,终于摸索出一些解决办法,记录下来,碰到这些问题的可以参考一下。一、安装VS Code后,需要安装以下两个插件:1、C/C++ Intellisense 插件2、Chinese (Simplified)Languge 中文语言插件有时安装VS Code后输入代码没有提示,是因为没有安装.net framework 4.5.2 ,安

随便推点

U-Boot2010.06移植(2440)-----基本移植_曼巴精神传承人的博客-程序员信息网

1、修改顶层Makefile文件(1)添加(第二行前面有个TAB):smdk2440_config :[email protected]$(MKCONFIG) $(@:_config=) arm arm920t smdk2440 samsung s3c24x0(2)指定交叉编译器:CROSS_COMPILE ?=arm-linux-2、在board/samsung目录下新建smdk24

u-boot-2016.09移植(2)-uboot启动简易分析_未名湖畔的落叶的博客-程序员信息网

不管什么版本的uboot都是在arch/arm/cpu/u-boot-spl.lds和arch/arm/cpu/u-boot.lds中制定了入口函数ENTRY(_start),u-boot-2016.09也不例外,搜索发现_start在arch/arm/lib/vectors.S中实现:

三维激光LiDAR点云数据处理应用技术交流活动_优案科技的博客-程序员信息网

一、行业痛点随着LiDAR三维激光扫描技术的迅速发展,LiDAR点云数据硬件采集装备不断精进,为空间实景三维信息的高效精准采集带来了质的飞跃,精细化空间三维数据的采集变得越来越简单。然而,在大量项目点云数据面向行业应用这一重要环节,LiDAR硬件装备的用户们往往会遇到点云数据处理难的各种瓶颈问题,导致难以高效率地挖掘出点云中所蕴藏着的价值信息。本次活动的主要目的是为LiDAR硬件装备使用人员们提供面向行业应用的点云数据后处理综合解决方案。车载移动测绘点云数据应用解决方案TopoDOT...

CentOS 8.0 安装docker 报错:Problem package docker-ce-3 19.03.4-3.el7.x86_64 require_简简单单OnlineZuozuo的博客-程序员信息网

文章目录CentOS 8.0 安装docker 报错:Problem: package docker-ce-3:19.03.4-3.el7.x86_64 requires containerd.io &gt;= 1.2.2-31、错误内容2、分析原因3、解决4、检查是否安装成功CentOS 8.0 安装docker 报错:Problem: package docker-ce-3:19.03.4-...

mysql nexttime_盘点MySQL那些与日期和时间相关的函数_郴桕的博客-程序员信息网

本文主要向大家介绍了MySQL那些与日期和时间相关的函数,通过具体的内容向大家展现,希望对大家学习MySQL数据库有所帮助。日期函数可能是比较常使用的一种函数。下面介绍一些最为常用的日期函数及一些容易忽略的问题。1. NOW、CURRENT_TIMESTAMP和SYSDATE这些函数都能返回当前的系统时间,它们之间有区别吗?先来看个例子。mysql&gt;SELECTNOW(),CURRENT...

音视频开发基础(3):什么是视频编解码_令狐掌门的博客-程序员信息网

视频编解码技术是指对视频进行压缩、解压缩的技术。在日常生活中,视頻编解码技术应用非常广泛。例如十几年前在DVD(MPEG・2)、VCD(MPEG-1)、高清电视以及现在的互底网上都有大量的应用。 视频信号数字化之后会产生十分庞大的数据量,需要大量的磁盘空间。一帧没有压缩的PAL制电视画面包含442368个像素。转换成数字视频后,毎个像素必须由3字节即24位信息表示R...

推荐文章

热门文章

相关标签