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 部分校验方式有点抽象,但按部就班写完就过了。

后记

题目发布的时候我还在国创赛志愿者,浅浅看了一眼主文档,看到有五道题,提供不同算力。我就认为算力越多,系统越复杂, 题目就越难,就开始看第一题了(没想到这是我做下来体感最难的一道);当时想了好久,最后一点也没用…

不过除了两道算力很少的题,其他题我做起来还是蛮顺的。这就是大力出奇迹吗!

那两道很繁的题,实际上最后的程序结构都十分简单清晰,不少空想的复杂结构都被证明毫无用处。简单就是美!

无端 这几天调参等待运行的时候就在打拔作岛。真是神作啊!