- 积分
- 10875
- 明经币
- 个
- 注册时间
- 2015-8-18
- 在线时间
- 小时
- 威望
-
- 金钱
- 个
- 贡献
-
- 激情
-
|
本帖最后由 你有种再说一遍 于 2024-9-13 10:00 编辑
不懂硬件是很难写出比懂硬件更好的代码的.
遇到性能不好的时候,就可以来看看.
数据预取:无论是硬盘还是内存,都存在预取,因为CPU实在太快了,其他硬件运算频率不如CPU速度.
内存硬件:内部有多路复用器,所以不是8根导线进入取值就取了,它进入之后还得扫描和等待刷新电容工作完成之后才取值,因此顺序访问才是最快.
电源频率:引起的大小核调度错误,要防止一核有难多核围观,得设置线程亲和性,而且可以命中核心内的L2缓存.
SIMD:CPU有没有SIMD,SIMD版本支持程度,AVX512会不会降频.可以加个配置跳过某些CPU用AVX512,改用SSE,SSE2,SSE3,AVX,AVX2...
硬盘:设置对齐方式,所以结构体对齐为页(通常4k).PG数据库为什么选择了尾追加策略的LSM树,就是因为它在硬件层面就比mysql的B+树好太多了,号称穷人核弹.
CPU核心的连接方式,星型,环形,矩阵,他们虽然在代码层面没啥意义,但是涉及通讯就有意义了.
多线程分为:
网络上面是IO密集:线程复用,降低句柄耗尽,
linux上面的BIO,NIO,AIO以及poll,select,epoll的调用概念.nettiy组件.
图形学上面需要的是CPU密集计算.
算法层面的优化,减少时间复杂度就经常说了.
容器降低申请内存次数,拷贝次数,复用.
无脑用HashMap的KV结构.
位图bitmap:记录内存是否使用.进而延伸出多个哈希的布隆过滤器记录数据是否使用.
通用的优化策略:
1,缩短数据类型:
能byte(1字节)就不要short(2字节),
能short就不要int(4字节),
能int就不要long(8字节).
以及还有整数表示浮点数的定点数.
2,减少函数:内联函数,递归改循环,开辟栈帧会引入新指令和新数据,可能把前面的L2缓存顶到L3去了,L3顶出去就cache miss了.
3,数据对齐:从页表开始,都是整整齐齐的4k.这甚至到硬盘的预取,所谓的512(字节)对齐,4k(4096字节)对齐,就决定了预取颗粒大小,这通常发生在mmap映射磁盘到内存,然后操作系统取内存的时候.同时readFile其实也会.
4,数组预取:数据从硬盘拷贝到主存,主存拷贝到缓存,都是连续的数据结构更好,所以没有比同类型数值数组更好的结构了.
5,SIMD:因为取值/运算这两步并行,它的寄存器和其他寄存器也是分开的,通过组合:多线程并行+数值数组预取+循环展开分支流水线+SIMD指令,得到一个最快的"烤内存方案",直接拉满总线带宽.
6,减少分支预测失败:JIT特化或者SIMD.
7,编译器优化:
太多基础的,死代码消除,无用代码消除,
变量提升:提取到头部声明,防止入栈期间内存不足.
常量传播:求值之后替换变量.
常量折叠:直接编译时候就求循环内的常数,就连-o1也会进行,所以不应该在代码上面有达夫设备(Duff’s Device)的代码.
循环展开:c#需要手写展开,利用CPU的分支流水线技术.每个变量都要独立(c++能数组,c#只能变量并且要用指针方案),因为变量不关联就是流水线的分支,最后才聚合累加.
例如数组全部求和,可以设置4个变量,每次递进4,加到对应的变量上,最后4个变量累加.
循环融合:如果有两个循环分别计算数组a和b的平方和,并且这两个循环是连续的,编译器可以将它们合并为一个循环,同时计算两个平方和.
循环拆分:如果一个循环既更新了一个数组,又计算了另一个数组的值,而这些操作之间存在数据依赖,循环拆分可以将更新操作和计算操作分开,以减少依赖并提高并行度.
循环向量化:转为SIMD指令
8,多线程并行:每个核心都有独立SIMD寄存器和其他寄存器.
9,缓存一致性协议MESI:
volatile:
这个和编译器优化是有关系的,编译器会觉得你某个变量赋值后不修改,然后去帮你删掉不可达的代码了,结果是另一个线程修改这个变量后,其他代码是可达的,因此需要加这个关键字.
你的变量需要volatile才能给不同线程看见,不然它们被拷贝到内核缓存了,而你再对某个线程的变量修改是无效的.
它的硬件原理并不是每次都去主存取值,而是拷贝主存到核心缓存之后,当修改的时候,会通过总线发送给其他核心一个修改信号,其他核心通过检测信号再去主存拷贝到自己的缓存,核心内如果没有用到,就不去拷贝,这样可以减少访问主存.
也就是可见,但是修改时候是不及时的,修改要通过内存屏障和锁才行.
内存屏障:
内存屏障是一种指令,用于控制CPU指令的执行顺序,防止编译器和处理器重排序.
内存屏障通常用于与volatile变量一起使用,以确保在访问volatile变量前后的内存操作顺序.
锁:
锁用于同步多个线程对共享资源的访问,保证在同一时刻只有一个线程可以访问特定的代码段或资源.
锁提供了原子性和可见性保证.
使用锁可以防止多个线程同时进入临界区,从而避免竞态条件和数据不一致的问题.
有什么是volatile+内存屏障,但是不用锁呢?
a:无锁编程CAS上面就有用到了.
b:多线程并行的终止标记.
10,缓存伪共享:它被誉为了无声的性能杀手,缓存是一行的,一行可以容纳好多个变量,如果你频繁修改一个对象,那么另一个对象也在一行,会发生等另一个对象释放缓存之后,你才能抢占到锁.
解决方案:填充/数据对齐/无锁结构
11,线程亲和性:CPU的超线程,也就是大家看到的八核十二线程的宣传语.
同核心是共享L2缓存的,而不同核心之间还可以通过L3缓存去实现.要做到这一点需要绑定两个线程到一个核心上面.
线程亲和性绑定到核心
```c#
using System;
using System.Runtime.InteropServices;
using System.Threading;
class Program
{
// 获取当前线程的CPU核心编号
[DllImport("kernel32.dll")]
private static extern uint GetCurrentProcessorNumber();
// 设置线程绑定到核心
[DllImport("kernel32.dll")]
private static extern uint SetThreadAffinityMask(IntPtr threadHandle, uint processorMask);
static void Main() {
// 创建一个线程
Thread thread = new Thread(DoWork);
// 启动线程
thread.Start();
// 等待线程启动
thread.Join();
// 设置线程亲和性,将线程绑定到CPU核心0
SetThreadAffinityMask(thread.Handle, 1);
// 继续线程执行
thread.Start();
}
static void DoWork() {
for (int i = 0; i < 5; i++) {
Console.WriteLine($"Thread {Thread.CurrentThread.ManagedThreadId} is running on CPU {GetCurrentProcessorNumber()}");
Thread.Sleep(1000);
}
}
}
```
12,磁盘文件处理:读取文件用mmap.
发送文件用0拷贝,DMA直接访问内存.
13,并行的颗粒度
开n个线程处理n个文件,开n个线程处理一个文件的颗粒度是不一样的.
MKL数学库矩阵:矩阵分块拼凑大矩阵,分块颗粒度给不同的线程.
利用核心绑定,Z字访问以命中缓存行,由此解决了需要转置矩阵才能命中缓存行.Z字命中缓存行是因为邻近两个块的元素已经被读取进入了CPU三个缓存区.
你会发现就是MapReduce思想:先分区,并行分区的任务,最后合并.
http://bbs.mjtd.com/thread-190857-1-1.html
http://bbs.mjtd.com/thread-190913-1-1.html
(完) |
评分
-
查看全部评分
|