SJTU Xflops 招新啦!
初见 试题,就被充实的考试内容、详尽友好的
readme.md
和几乎已完成的代码/测试/构建框架震撼了;Xflops 真是为了本次招新花了很大心思。向命题组致敬🫡!
总共五题,涉及了 OpenMP/MPI 基础、简单算法编写、性能调优等知识。话不多说,直接开始做题!
Bithack
由于本人是按照字母顺序做题的,这是我最开始上手的一道题,是刚开始以为最简单的一道题(看题目资源限制看的), 却是最晚写完的,也是本人体感难度最高的一道题;这可能是因为我不太擅长搓算法,尤其是需要精细处理、仔细思考的那些(还是刷题少了)。
由于题目要求不能开堆空间,最开始我想弄一个足够大的 buffer 然后 rotate 时写到 buffer 里然后一块块慢慢搬(如图);然后发现一越界循环就寄了。
遂放弃额外 buffer 的算法。Chatgpt 告诉我有个 in-place rotate 算法:
reverse_vec(vec, left, right);
reverse_vec(vec, left, left+rotate-1);
reverse_vec(vec, left+rotate, right);
看起来十分可靠。感谢 Chatgpt!
一分钟搓了一个基线 reverse
static void reverse_the_bit_vector_slow (
bit_vector_t* const bit_vector,
size_t bit_from,
size_t bit_to
) {
while (bit_from < bit_to) {
bool temp = bit_vector_get(bit_vector, bit_from);
bit_vector_set(bit_vector, bit_from, bit_vector_get(bit_vector, bit_to));
bit_vector_set(bit_vector, bit_to, temp);
bit_from++;
bit_to--;
}
}
看起来依旧开销满满,但成绩还不错,毕竟是真 O(n) 算法:
check result: PASSED
performance of -s: 27
performance of -m: 31
performance of -l: 36
------score--------
-s : 65.00 /100
-m : 68.18 /100
-l : 72.00 /100
total score: 69.45 /100
距离满分还有大约 $1.618^7\approx30$ 倍的性能差距。那就优化常数吧!搓一个更快的 reverse
函数。
基本想法就是能用上 64 位就好了,直接 64 倍速度!
我查到了 arm64 指令集有一个很不错的指令 rbit
:
一键反向位,正和胃口啊。后面才知道可以用 __builtin_arm_rbit64
,写的时候由于已经写了不少汇编了,这里就直接汇编了(编译还吃了 c99 的坑)。
然后就是吭哧吭哧搓了(算法有点像快速排序)。过程中试图让 Chatgpt 帮我打工但是看起来它还不是很聪明,就懒癌发作先做其他题了…
最终实现还是有不少 bug/feature:
- 只适用于 little-endian 处理器,不过大多数处理器都是的;不可修
- 只适用于 64 位对齐的 buffer,不过只要是 malloc 出来的 buffer 都是满足的,很好修
- 会写到边界后最多 16Byte,如果恰好到 Segment 边界就 Segment Fault 了;概率很小,但确实是真 bug。(修了)
以及给的框架有的 bug
bit_vector_randfill
有时会超界写,把 malloc header 搞坏掉,导致 free 时爆炸getopt
用了 char 类型来接收并比较 -1,然而 arm64 里 char 是 unsigned,这段直接编译器优化成死循环了
最终性能提升比预计还要高:大约 $1.618^10\approx120$ 倍。汇编赛高!
check result: PASSED
performance of -s: 37
performance of -m: 41
performance of -l: 46
------score--------
-s : 100.00 /100
-m : 100.00 /100
-l : 100.00 /100
total score: 100.00 /100
后面搓了 Rust 版但是更慢了??
p.s. 为了测试稳定性排 bug 搓了一个检测器,留作记录:
#include <time.h>
#include <stdlib.h>
#include <stdio.h>
static int check_with(int N, int a, int b, int o);
static int cmp_int(const void* l, const void* r);
int _main() {
const int T=1000;
const int MAX=4096;
srand(time(0));
for(int i=0; i<T; i++) {
#define R (rand()%MAX)
int nums[4]={R,R,R,R};
#undef R
qsort(nums,4,4,cmp_int);
const int N=nums[3];
const int a=nums[0], b=nums[2]-a, o=nums[1];
if (check_with(N,a,b,o)!=0)
return -1;
printf("%d/%d check passed!\n", i, T);
}
return 0;
}
static void print_u64_le(unsigned long x) {
for(int i=0; i<16; i++) {
char c = x&15;
if(c<10) c+='0';
else c+='a'-10;
putchar(c);
x>>=4;
}
}
static void print_buf(unsigned long *b) {
for(int i=-1; i<3; i++) {
print_u64_le(b[i]);
putchar(' ');
}
}
static int check_with(int N, int a, int b, int o) {
bit_vector_t *bv1 = bit_vector_new(N), *bv2=bit_vector_new(N), *bv3=bit_vector_new(N);
for (int i=0; i<(bv1->bit_sz+7)/8; i++){
bv1->buf[i] = rand();
}
int or=o%b;
printf("Rotate %d bits on bit %d-%d in %d bit bit_vector (%d-%d-%d)\n", or,a,a+b,N, a, a+or, a+b);
memcpy(bv2->buf, bv1->buf, (N+7)/8);
memcpy(bv3->buf, bv1->buf, (N+7)/8);
const size_t bit_right_real = o%b;
reverse_the_bit_vector(bv1, a, a+b-1);
reverse_the_bit_vector_slow(bv2, a, a+b-1);
for(int i=0; i<N; i++) {
bool b1=bit_vector_get(bv1,i), b2=bit_vector_get(bv2,i);
// printf("check on bit %d(%x/%x)\n",i, bv2->buf[i/8], bv1->buf[i/8]);
if(b1!=b2) {
printf("check failed on bit %d(%d/%d)\n",i, bit_vector_get(bv1,i), bv1->buf[i/8]);
unsigned long *b1 = (unsigned long*)((size_t)(bv1->buf+i/8)&(~7ul));
unsigned long *b2 = (unsigned long*)((size_t)(bv2->buf+i/8)&(~7ul));
unsigned long *b3 = (unsigned long*)((size_t)(bv3->buf+i/8)&(~7ul));
int ii = (i&(~63))-64;
printf(" %-8d %-8d %-8d %-8d \n", ii, ii+64, ii+128, ii+192);
printf("got: ");
print_buf(b1);
putchar('\n');
printf("expect: ");
print_buf(b2);
putchar('\n');
printf("origin: ");
print_buf(b3);
putchar('\n');
return -1;
}
}
bit_vector_free(bv1); bit_vector_free(bv2); bit_vector_free(bv3);
return 0;
}
static int cmp_int(const void* l, const void* r) {
int f = *((int*)l);
int s = *((int*)r);
if (f > s) return 1;
if (f < s) return -1;
return 0;
}
Clever_Clang
一点也不 clever…
一看加速一个循环,略改一下 gd 算法减少常数乘除法计算(感谢精度不杀之恩),直接上 openmp: 四倍速度,但时间还是 860.0ms 860.0ms 40s 3.2s。几乎没分!
static void run_gd(float *point, uint32_t M, float eta, const PolyParams* params) {
if (M==0) return;
float x=*point, x2, x3, grad;
float a=4*params->a*eta, b=3*params->b*eta, c=2*params->c*eta, d=params->d*eta;
uint32_t j = 0;
for (uint32_t i = 0; i < M; i++) {
float x2 = x * x;
float x3 = x2 * x;
x -= 4 * params->a * x3 + 3 * params->b * x2 + 2 * params->c * x + params->d;
}
*point = x;
}
看到 readme.md
里的自动向量化字眼,遂尝试 #pragma clang loop vectorize(enable) interleave(enable)
,
手动内联函数,和 Chatgpt 扯皮,但始终没法让聪明的 Clang 向量化它。折腾两个小时后决定:求人不如求己!我自己汇编!
(最后才发现 官网
有手把手教程,就差喂给我们了!)
想法就是四个一组,放在一个 Vd.4s
里面,然后全程用向量寄存器写。然后就有了初稿
(V4-7 是四个系数,V8-V10 是 x x^2 x^3, 其他是临时变量,仅放循环体内部)
fmul v9.4s, v8.4s, v8.4s ; x2
fmul v10.4s, v9.4s, v8.4s ; x3
fmul v11.4s, v4.4s, v10.4s ; a
fmul v12.4s, v5.4s, v9.4s ; b
fadd v11.4s, v11.4s, v12.4s
fmul v12.4s, v6.4s, v8.4s ; c
fadd v11.4s, v11.4s, v12.4s
fadd v11.4s, v11.4s, v7.4s; d
fsub v8.4s, v8.4s, v11.4s
本来想省掉累加,直接减的,但是被精度制裁了。所以还是得全累加然后减。
这样真的获得了近乎四倍速度。令人奇怪的是,Case4 满分了,其他 Case 咋都没分呢?
我当时没想那么多,硬是想自己的原因(事实证明确实是自己的原因),然后绞尽脑汁优化了一下汇编(见最终代码),获得了 173ms 173ms 8.0s 680ms 的速度。还是只有 50 分!
想破头也没想出哪里还有数倍的提升空间;看输出想到一个稍微有点作弊的方法:相邻数据点值是差不多的, 那么似乎可以间隔选点,然后把相邻值相同的直接复制到其之间的所有点上;相邻值不同的再仔细算。由于我需要四倍的性能, 就直接四选一了。于是优化到了 50ms 50ms 2.0s 680ms (最后一个数据点由于数据点不密,就没有四选一了)~
但是零分!吓得我看了看 outx.data
,发现那些相邻值不同的数据点重算后变得十分离谱,甚至有的 nan。
我排查了三个小时也没看出是啥问题。最后发现 O2 能过,O3 不能过!大概又是哪个寄存器冲突了吧。开始用
#pragma clang optimize off
保平安,后来发现 __attribute__((noinline))
就够了。深入排查就算了,
反正热的是函数内部又不是函数调用。
(由于最后删掉了,就放在这了)
void gradient_descent(float *points, uint32_t N, uint32_t M, float eta, const PolyParams* params) {
if (N<128) return _gradient_descent_simd(points, N, M, eta, params);
// select 1/4 points
float *part = (float*)malloc(N+20);
uint32_t Npart = (N+3)/4;
for (uint32_t i = 0; i < Npart; i++) part[i]=points[i*4];
_gradient_descent_simd(part, Npart, M, eta, params);
// 20ms/2000ms profit in task3
#pragma omp parallel num_threads(4)
#pragma omp for
for (uint32_t i = 0; i < Npart-1; i++) {
float p=part[i];
float* point=points+i*4;
if (_fabs(p-part[i+1])<1e-7) {
asm("dup v8.4s, %w0" : : "r"(p) :"v8");
asm("str q8, [%0]" : :"r"(point) :"v8");
} else {
run_gd_simd(point, M, eta, params);
}
}
run_gd_simd(points+(Npart-1)*4, M, eta, params);
free(part);
}
希望没有选点中间有突变点的测试点🙏 好的被数据点制裁了QwQ
经过 P 佬的提醒,胡编数据时发现当 $\eta$ 小到无法收敛时,上面的做法就寄了。还是优化速度吧。
上课摸鱼在本地 x86 测试发现好像超标量有戏;于是把汇编代码复制了四份…竟然真的四倍速度了!可喜可贺 可喜可贺!btw 这 CPU 这么多冗余的 FPU 嘛…
最后整理完代码,觉得甚至有奇妙的美感。闲来无事,搓了一个 Rust 版的。
代码
use core::slice;
type Float4 = wide::f32x4;
#[no_mangle]
pub extern "C" fn gradient_descent(points: *mut f32, n: u32, m: u32, eta: f32, params: &[f32;4]) {
if m==0 {return;}
let p = [4.*params[0]*eta, 3.*params[1]*eta, 2.*params[2]*eta, params[3]*eta];
let n = n as usize;
assert!(points as usize % 8 == 0); // alignment check
// just assume 4 threads
let slice_16 = unsafe{ slice::from_raw_parts_mut(points as *mut [Float4;4], n/16) };
parallel_for(slice_16, 4, &|v| run_gd_16(v, m, &p));
let slice_4 = unsafe{ slice::from_raw_parts_mut((points.add(n/16*16)) as *mut Float4, (n%16)/4) };
parallel_for(slice_4, 4, &|v| run_gd_4(v, m, &p));
let slice_1 = unsafe{ slice::from_raw_parts_mut((points.add(n/4*4)) as *mut f32, n%4) };
parallel_for(slice_1, 4, &|v| run_gd(v, m, &p));
}
pub fn parallel_for<T: Send>(slice: &mut [T], n_threads: usize, f: &(dyn Fn(&mut T)+Sync)) {
let chunk_size = (slice.len()+n_threads-1)/n_threads;
if chunk_size==0 {return;}
std::thread::scope(|s| {
for ch in slice.chunks_mut(chunk_size) {
s.spawn(move || { for i in ch { f(i); } });
}
});
}
pub fn run_gd(px: &mut f32, rounds: u32, params: &[f32;4]) {
let [a,b,c,d] = *params;
let mut x = *px;
for _ in 0..rounds {
let x2 = x*x;
let ax = a*x;
x -= (ax*x2+b*x2)+(c*x+d);
}
*px = x;
}
pub fn run_gd_4(px: &mut Float4, rounds: u32, params: &[f32;4]) {
let [a, b, c, d] = params.map(|v| Float4::splat(v));
let mut x = *px;
for _ in 0..rounds {
let x2 = x*x;
let ax = a*x;
x -= (ax*x2+b*x2)+(c*x+d);
}
*px = x;
}
pub fn run_gd_16(px: &mut [Float4;4], rounds: u32, params: &[f32;4]) {
let [a, b, c, d] = params.map(|v| Float4::splat(v));
let mut x = *px;
for _ in 0..rounds {
for i in 0..4 {
let (x2, ax) = (x[i]*x[i], a*x[i]);
x[i] -= (ax*x2+b*x2)+(c*x[i]+d);
}
}
*px = x;
}
静态链接到 gd
后性能进一步提升到了
Case 1 score: 100.00/100 avg_time: 43.0
Case 2 score: 100.00/100 avg_time: 43.0
Case 3 score: 100.00/100 avg_time: 2090.0
Case 4 score: 100.00/100 avg_time: 654.0
Cluster
体感最水的一道题(x
看提示,放弃思考算法,直接搜 NOIP 国王游戏,就是个排序嘛!Stack Overflow 上抄了个 openmp 归并排序,就满分过了…
HPL
这题对于我这个老 mjj+前 Gentoo 党来说可谓如鱼得水; 但由于特殊的计分方式,他是唯一一道让我做完还惴惴不安的一题。
看 SJTU HPC Doc 可以看到,目标性能应当接近 7.7 Tflops;所以当我第一次编译通过的时候发现只有 xe-2 Gflops 时是崩溃的…
当然,在 How do I tune my HPL.dat file? 的帮助下,轻松通过纯 mpi 获得了 1.0 Tflops 的成绩,但距离目标还是有七倍多差距啊!万一有大佬调到了 8 Tflops 岂不是只有最多十几分。(最后才发现是看错了)
在选择的纯华为系 hmpi+kml 下,怎么调多线程都超不过 0.5 Tflops;用 hmpi+openblas 倒是能调到 1.16 Tflops。
心慌之际,忽然发现看成思源一号集群的成绩了…官网上 arm 集群没调跑出来的成绩是 865 Gflops;说明我基本已经调到极限了!就这样吧…
不过看有群友调到了 1.20 Tflops,还是很厉害的。
N 体问题
看起来问题超级复杂,但是是个人体感第二水的一道题,就比 Cluster 多查点东西。
openmp 部分就把两个 O(N^2) 循环全放在 openmp for 里,再把那个 N(N-1)/2 循环调度从 static 换成 dynamic(2) 就过了。
mpi 部分校验方式有点抽象,但按部就班写完就过了。
后记
题目发布的时候我还在国创赛志愿者,浅浅看了一眼主文档,看到有五道题,提供不同算力。我就认为算力越多,系统越复杂, 题目就越难,就开始看第一题了(没想到这是我做下来体感最难的一道);当时想了好久,最后一点也没用…
不过除了两道算力很少的题,其他题我做起来还是蛮顺的。这就是大力出奇迹吗!
那两道很繁的题,实际上最后的程序结构都十分简单清晰,不少空想的复杂结构都被证明毫无用处。简单就是美!