天池ADB高性能数据分析引擎比赛复盘

Posted by Timer on March 14, 2022

Buffer和Cache

  • Buffers 是对原始磁盘块的临时存储,也就是用来缓存磁盘的数据,通常不会特别大(20MB 左右)。这样,内核就可以把分散的写集中起来,统一优化磁盘的写入,比如可以把多次小的写合并成单次大的写等等。
  • Cached 是从磁盘读取文件的页缓存,也就是用来缓存从文件读取的数据。这样,下次访问这些文件数据时,就可以直接从内存中快速获取,而不需要再次访问缓慢的磁盘。

FileChannel需要知道的

  • 4K * N?

    FileChannel 只有在一次写入 4kb 的整数倍时,才能发挥出实际的性能,这得益于 FileChannel 采用了 ByteBuffer 这样的内存缓冲区,让我们可以非常精准的控制写盘的大小,这是普通 IO 无法实现的。4kb 一定快吗?也不严谨,这主要取决你机器的磁盘结构,并且受到操作系统,文件系统,CPU 的影响,所以benchmark很重要,有时候32KB或者64KB才是最佳选择。

  • 潮汐刷盘

    操作系统最终帮我们完成了 PageCache 到磁盘的最终写入,所以 FileChannel 提供了一个 force() 方法强制刷盘!

  • 并发读ByteBuffer不要加锁!!!

    使用 ByteBuffer 提供的 duplicate 和 slice 这两个方法

MMAP需要知道的

MMAP主要是省去了内核缓冲区复制到用户空间的过程,文件中的位置在虚拟内存中有了对应的地址,可以像操作内存一样操作这个文件,相当于已经把整个文件放入内存,但在真正使用到这些数据前却不会消耗物理内存,只有真正使用这些数据时,虚拟内存管理系统 VMS 才根据缺页加载的机制从磁盘加载对应的数据块到物理内存进行渲染。

但是要注意的是!!!

  • MMAP 一次写入很小量数据的场景下才能表现出比 FileChannel 稍微优异的性能
  • MMAP JAVA里一次只能映射2g,很恶心
  • MMAP 使用的是虚拟内存,和 PageCache 一样是由操作系统来控制刷盘的,也要通过 force() 来手动控制
  • MMAP回收要反射,很麻烦,单靠GC不是很及时

PMEM需要知道的

内存 DRAM 的访问延时在 80~100ns,固态硬盘 SSD 的访问延时在 10~100us,而持久内存介于两者之间,比内存慢 10 倍,比固态硬盘快 10~100 倍。

PMem 工作在 Memory Mode 时,是易失性的,这时候,你需要使用专门的一套系统指令去进行存取.

PMem 工作在 AppDirect Mode 时,可以直接把 PMem 当成一块磁盘来用,PMem 背后适配了一整套文件系统的指令,使得常规的文件操作可以完全兼容的跑在 PMem 之上。

总结架构

可以先把任务化成以下几块:

  • load

    • 读数据
    • 解析数据
    • 分桶
    • 落盘
  • query

    • 查桶
    • 并发快速选择

我最大的错误在于虽然划分了子任务,但是每个子任务依然把读+解析+分+落盘全都交给一个线程处理,当时的我太过于年轻,脑内没有整个过程的抽象。

读是IO密集操作,解析是CPU密集操作,写又是IO密集操作,也就是代表如果四种任务用一个线程去处理的话,那这个线程在任意时刻,它的CPU或者IO有一个会处于空闲状态。

那怎么办?8核机器用16个线程?又是一个错误的思路,线程上下文切换的频率远超我们想象,尤其在这种小IO的情况下。

所以总体思路应该是消费者生产者模型,而不能all in:

读任务设置一个线程组,解析任务设置一个线程组,分桶任务设置一个线程组,落盘设置一个线程组。

线程组和线程组之间用ConcurrentLinkedQueue交流。

这样就可以类似流水指令流的方式,读完成后交给解析,同时又开始读,解析完交给分桶,然后立刻解析,如此往复,整体IO和CPU是拉满的。

分桶任务之所以要单独设计为一个线程组,是因为分桶的同时要写到临时缓存中。先说一下为什么要在CPU和内存之间放一个临时缓存。

  1. 如果不放临时缓存,直接写到对应桶的文件中,那多个线程当前可能会有写冲突,那就要给2048个fd加写锁,效率很低很低。
  2. 放临时缓存,也可以顺应CPU Cache的结构,把CPU缓存最大化利用。

该服务器L2Cache为1M,分2048个桶,也就是说每个桶最大可以利用1M/2048 B的缓存,可以存放1024*1024/2048/8 = 64个long数据,分桶的时候先把数据放到临时缓存中,由于整体数据分布是均匀的,如果但凡有一个桶的数据满了,那么就把所有桶的数据放到写缓存里,这样可以节省掉很多无用的判断,同时防止阻塞。写缓存的思路参考OS缓存里的Buffer,将小IO整理成一个大IO,大批量写入,具体写缓存设置多少还得继续benchmark。

小优化合集

  • Parse Long

    首先判断19位之后是不是逗号或者换行,如果是的话,那直接循环展开,方便CPU向量化。

    如果不是,照常乘法,但是可以把每次减去字符0的操作提出来,最后统一减。

    最好的方案:使用nlogn的方案,把十进制的long转换成2进制的,其主要思想是将一个long拆成2个,long1保留奇数位的字节,long2保留偶数位的字节,然后执行 `long2*10 + (long1»8)。比循环展开快50%!

    https://kholdstare.github.io/technical/2020/05/26/faster-integer-parsing.html?spm=5176.21852664.0.0.51f05155CDGYNF

  • 分支预测优化

    输入文件大概有 10G 左右,所有的字节都会经过 if 判断一次,而实际上,大多数的字符并不是 \n, 。这会导致 CPU 被浪费在分支预测上。直接用while取代if。可优化24s!

  • DirectByteBuffer和HeapByteBuffer

    不管采用哪个,JVM均会将数据首先拷贝至堆外内存中,所以无形中,使用HeapByteBuffer会多一次数据拷贝。

    (其实还是JAVA垃圾回收带来的问题,在垃圾回收时,堆内存的数据地址会发生移动并重排,而native方法接收的是address以及写入大小,address变动会带来致命的问题,但总不能设定在IO操作时,不能进行GC吧。又因为堆外内存不受GC约束,所以设计者将数据主动拷贝一份至堆外,来避免垃圾回收带来的尴尬;在java doc中也标注使用者尽量使用堆外内存以提高性能,此处不再赘述)

  • 分桶策略

    如果分桶数量少了,那么load阶段耗时变小,但分桶数量变少必然导致每个分桶内的数据增多,那么查询阶段的耗时也会骤增。

    如果分桶数量多了,其结果正好相反,即load阶段的耗时增加、query阶段的耗时减少

    像这种此消彼长的模型一定存在一个抛物线的最高点,让load和query的消耗相加最小,所以要大量的benchmark,对于这个项目而言,2048或者4096是一个最佳分桶数目。

  • 数据压缩

    假设分桶需要前10位作为桶号,那么意味着前8位可以丢弃,压缩数据大小到7/8。但是这里要注意字节序要为小端模式。

  • 开辟堆外空间优化

    堆外空间开辟一个非常浪费时间的地方在于它要赋0操作,可以用反射去绕过这个赋0操作。

  • 基于采样的快速选择

    这个思路是一种投机取巧的方案,可以用在查询和写入的时候。

    快速选择再每次递归中,数轴P的选取决定了剩余递归次数,接近于K的数轴P质量较好,反之较差。

    一个桶里的数据还是相当多的,如果我现在想要求当前桶的top k,比如100w个数据里我要求top10w是什么,那么可以猜测这个数轴应该在90w个long这个位置左右。

    所以我想要第90w个long之前靠近的数作为pivot,这样logn可以几乎降到常数级别。

    具体做法

    每次快速选择递归的时候,如果当前选择的区间大于一定阈值,那么就对其中百分之一或者千分之一的数进行小规模采样。

    比如小样本我能获得top85%大概是x,那么这个数肯定是和我的理想pivot距离很近的。

    这里还涉及到一个上下浮动的取舍,要看具体的分布均匀的程度。