用 Pthread 计算 sin(x):四种同步策略的并行性能对比


用 Pthread 计算 sin(x):四种同步策略的并行性能对比

这次并行计算实验,我选的题目是“多线程计算正弦值”。题目本身不复杂:用正弦函数的泰勒展开式近似计算 sin(x),然后用 Pthread 把计算并行化,再比较不同线程同步策略带来的性能差异。

真正有意思的地方不在“能不能并行”,而在“怎么并行更合适”。同样是多线程,有的实现把时间花在计算上,有的实现把时间花在锁竞争和缓存一致性上。这个实验的价值,也正在于把这些差异跑成可观察的数据。

1. 问题是什么

实验输入包含三个参数:

  • 弧度值 x
  • 展开上界 N
  • 线程数 T

计算目标是:

sin(x)=i=0N(1)ix2i+1(2i+1)!\sin(x)=\sum_{i=0}^{N}\frac{(-1)^i x^{2i+1}}{(2i+1)!}

从源码实现看,程序实际按照 i = 0i <= N 累加,因此当 N = 100000 时,实际计算的是 100001 项。

每一项都可以独立计算,所以这个问题天然适合做数据并行。不过,项与项之间的计算开销并不一致。第 i 项要做 2i+1 次乘法,越往后单项越重。如果简单按连续区间切分,很容易导致后面的线程更忙、前面的线程更早结束。

2. 我为什么选“循环划分”

这次实验里,我采用的是循环划分,也就是把级数项按线程编号轮转分配:

  • 线程 0 负责 0, T, 2T, ...
  • 线程 1 负责 1, T+1, 2T+1, ...
  • 线程 2 负责 2, T+2, 2T+2, ...

这样做的好处很直接:大项和小项会被交错分配给不同线程,负载更均衡。

流程图如下:

并行流程图

核心循环可以概括成下面这样:

for (i = arg->begin; i <= arg->max_n; i += arg->step) {
    tmp = 1.0;
    for (j = 1; j <= 2 * i + 1; j++) {
        tmp *= arg->radian / j;
    }

    if (i % 2 > 0) {
        local_sum -= tmp;
    } else {
        local_sum += tmp;
    }
}

这段逻辑决定了实验的基本性能特征:计算量足够大,并且线程内部主要是算术运算,因此很适合观察同步机制对总耗时的影响。

3. 四种实现策略

这次实验一共做了四个版本,重点不是比“谁能跑出来”,而是比“谁的同步代价更低”。

在进入不同同步策略之前,先看一下几个版本共享的主线程框架。四个程序的主体结构基本一致,都是:

  1. 读取命令行参数
  2. 初始化线程参数
  3. 创建 pthread
  4. 等待所有线程结束
  5. 根据不同方案决定是否再做一次主线程归并

主线程核心代码大致如下:

typedef struct Args {
    float radian;
    long max_n;
    long begin;
    long step;
} Args;

int main(int argc, char *argv[]) {
    long i;
    double radian = atof(argv[1]);
    long max_n = atol(argv[2]);
    int threads = atoi(argv[3]);
    Args *arg;

    pthread_t *pid;
    pid = (pthread_t *)malloc(threads * sizeof(pthread_t));

    for (i = 0; i < threads; i++) {
        arg = (Args *)malloc(sizeof(Args));
        arg->radian = radian;
        arg->max_n = max_n;
        arg->begin = i;
        arg->step = threads;
        pthread_create(&pid[i], NULL, cal, (void *)arg);
    }

    for (i = 0; i < threads; i++) {
        pthread_join(pid[i], NULL);
    }
}

这里最关键的是:

  • begin = i 指定线程从第几个级数项开始
  • step = threads 保证线程按固定步长轮转取任务
  • 每个线程都只处理自己的项序列,不需要在计算阶段互相协调

3.1 nl:无锁归并

nl 的思路最直接。每个线程先把自己的结果算到局部变量 local_sum,最后只写一次 res_arr[tid],主线程在 join 之后统一归并。

local_sum = 0.0;
for (i = arg->begin; i <= arg->max_n; i += arg->step) {
    ...
}
res_arr[arg->begin] = local_sum;

这个版本几乎没有线程间同步,理论上应该是很标准的共享内存归约写法。

把它展开一点,实际工作线程长这样:

void *cal(void *_arg) {
    long i, j;
    double tmp, local_sum;
    Args *arg = (Args *)_arg;

    local_sum = 0.0;
    for (i = arg->begin; i <= arg->max_n; i += arg->step) {
        tmp = 1.0;
        for (j = 1; j <= 2 * i + 1; j++) {
            tmp *= arg->radian / j;
        }

        if (i % 2 > 0) {
            local_sum -= tmp;
        } else {
            local_sum += tmp;
        }
    }

    res_arr[arg->begin] = local_sum;
    free(arg);
    return NULL;
}

主线程最后再统一求和:

for (i = 0; i < threads; i++) {
    res += res_arr[i];
}
printf("%lf\n", res);

这也是为什么 nl 从结构上看最“干净”。

3.2 mtx:互斥锁归并

mtx 的做法是每个线程先完成局部求和,最后只在归并阶段进入一次临界区:

pthread_mutex_lock(&lock);
res += val;
pthread_mutex_unlock(&lock);

它虽然用了锁,但锁的进入次数非常少,临界区也非常短,所以未必会很慢。

对应的线程函数片段如下:

void *cal(void *_arg) {
    long i, j;
    double val, tmp;
    Args *arg = (Args *)_arg;

    val = 0.0;
    for (i = arg->begin; i <= arg->max_n; i += arg->step) {
        tmp = 1.0;
        for (j = 1; j <= 2 * i + 1; j++) {
            tmp *= arg->radian / j;
        }
        if (i % 2 > 0) {
            val -= tmp;
        } else {
            val += tmp;
        }
    }

    pthread_mutex_lock(&lock);
    res += val;
    pthread_mutex_unlock(&lock);
    free(arg);
    return NULL;
}

注意这里的关键点不是“用了锁”,而是“只在最后用一次锁”。如果把锁放到内层循环里,每算一项就加锁,这个版本的结果会差很多。

3.3 bw:忙等待自旋锁归并

bwmtx 的结构基本一致,只是把互斥锁换成了自旋锁:

while (__sync_lock_test_and_set(&spin_lock, 1)) {
}
res += val;
__sync_lock_release(&spin_lock);

如果临界区很短,自旋锁理论上可能避免线程睡眠和唤醒的代价;但如果竞争明显,它也会额外消耗 CPU。

它在源码里是这样定义的:

double res;
volatile int spin_lock = 0;

然后在线程结束时手动做加锁和释放:

while (__sync_lock_test_and_set(&spin_lock, 1)) {
}
res += val;
__sync_lock_release(&spin_lock);

这个版本很适合和 mtx 对照看,因为两者除了锁实现之外,几乎没有别的结构差异。

3.4 fs:伪共享实验版本

fs 版本不做局部归约,而是在循环内部直接更新 res_arr[tid]

if (i % 2 > 0) {
    res_arr[arg->begin] -= tmp;
} else {
    res_arr[arg->begin] += tmp;
}

虽然不同线程写的是不同数组元素,但这些元素可能落在同一条 cache line 上,于是会引入典型的伪共享问题。这个版本的目的,就是故意把这种缓存一致性开销放大出来。

这一版完整一点的核心代码如下:

void *cal(void *_arg) {
    long i, j;
    double tmp;
    Args *arg = (Args *)_arg;

    for (i = arg->begin; i <= arg->max_n; i += arg->step) {
        tmp = 1.0;
        for (j = 1; j <= 2 * i + 1; j++) {
            tmp *= arg->radian / j;
        }
        if (i % 2 > 0) {
            res_arr[arg->begin] -= tmp;
        } else {
            res_arr[arg->begin] += tmp;
        }
    }

    free(arg);
    return NULL;
}

nl 相比,最大的区别就是 fs 没有先放到线程私有变量里累计,而是每轮循环都直接写共享数组。这正是它容易出现伪共享的原因。

4. 实验环境与测试方法

实验运行在学院平台管理中心提供的高性能计算集群上,报告中给出的环境如下:

  • 集群结构:1 个管理节点 + 7 个计算节点
  • CPU:2 × Intel(R) Xeon(R) Gold 5218
  • 内存:256 GB
  • 硬盘:3 × 600 GB
  • 存储方式:RAID5,可用容量约 1.2 TB

程序使用 C++ + Pthread 实现,作业通过 PBS 提交。为了稳定收集数据,我还写了一个批量提交脚本 start.sh,用来重复提交同一个 PBS 文件并限制最大并发作业数。

批量提交脚本的核心逻辑如下:

while true; do
    running=0
    for id in "${jobids[@]:-}"; do
        status="$(qstat "$id" 2>/dev/null | awk 'NR==3 {print $5}' || echo "X")"
        if [[ "$status" == "R" || "$status" == "Q" ]]; then
            running=$((running + 1))
        fi
    done

    if [[ $submitted -lt $REPEAT_TIMES ]] && [[ $running -lt $MAX_JOBS ]]; then
        submit_out="$(qsub "$PBS_FILE" 2>&1)"
        job_id="$(echo "$submit_out" | awk '{print $1}')"
        jobids+=("$job_id")
        submitted=$((submitted + 1))
        continue
    fi

    if [[ $submitted -eq $REPEAT_TIMES ]] && [[ $running -eq 0 ]]; then
        break
    fi

    sleep 2
done

这个脚本的作用不在算法本身,而在于把实验过程自动化。对这种需要重复运行 10 次以上的性能测试来说,自动提交和控制并发能省很多时间。

本次统计口径如下:

  • 固定输入:x = 3.14
  • 展开规模:N = 100000
  • 线程数:1 / 2 / 4 / 8 / 16
  • 对比版本:fs / mtx / nl / bw
  • 每组配置重复运行 10 次,结果取平均

加速比和并行效率按下面的公式计算:

Sp=T1TpS_p=\frac{T_1}{T_p} Ep=Spp=T1pTpE_p=\frac{S_p}{p}=\frac{T_1}{p \cdot T_p}

5. 实验结果

5.1 平均运行时间

下面这张表汇总了四种方案在不同线程数下的平均耗时:

方案1 线程2 线程4 线程8 线程16 线程
fs58.5212s30.8419s15.2019s8.2659s4.9735s
mtx58.3452s30.3030s15.2908s8.3484s4.9665s
nl57.8847s30.4238s17.5954s9.8970s4.9783s
bw57.9764s30.2796s15.8410s9.8828s4.9750s

整体趋势很清楚:

  • 线程数增加后,四个版本都获得了明显加速
  • 48 线程阶段,各方案差异开始拉开
  • 16 线程时,四组结果又重新收敛到约 4.97s

总览图如下:

总体运行时间统计

5.2 以 mtx 为例看加速比和效率

mtx 组是这次实验里表现比较稳定的一组,它的关键结果如下:

线程数平均时间加速比并行效率
158.3452s1.00001.0000
230.3030s1.92540.9627
415.2908s3.81570.9539
88.3484s6.98880.8736
164.9665s11.74770.7342

对应图表如下:

运行时间柱状图

加速比曲线

并行效率曲线

可以看到几个关键点:

  • 1 线程到 16 线程,运行时间从 58.3452s 降到 4.9665s
  • 加速比最高达到 11.7477
  • 并行效率则从接近 1 逐步下降到 0.7342

这说明并行化确实有效,但线程数变多以后,调度、同步和串行部分的影响也会越来越明显。

5.3 四种策略放在一起看

如果把四组数据放在一起观察,我觉得最值得记录的是下面几个现象。

第一,四个版本都拿到了不错的并行加速。到了 16 线程时,加速比基本都在 11.611.8 之间,说明这个问题本身具有很好的可并行性。

第二,mtx 并没有表现出直觉上的“锁一定更慢”。原因也很简单:它不是每算一项就加锁,而是每个线程先局部求和,最后只在结束时进入一次临界区。锁进入次数少,临界区短,互斥锁开销被大量计算掩盖了。

第三,bwmtx 的差距并不大,甚至 mtx 略优。这说明在这个实验里,锁类型不是决定性因素,真正关键的是锁进入频率和临界区长度。

第四,fs 没有表现出预想中的明显最差。理论上它更容易触发伪共享,但从当前数据看,它在 4 线程和 8 线程阶段反而比较靠前。这说明伪共享虽然是一个真实风险,但它是否会成为主导瓶颈,仍然取决于具体平台、样本波动和整体计算占比,不能只凭经验判断。

第五,nl4 线程和 8 线程时反而弱于 mtxfs。这也提醒我,理论上“无锁更好”并不一定会直接体现在最终结果里,实际性能仍要以实验数据为准。

6. 这次实验真正说明了什么

如果只看最终结论,我认为这次实验至少说明了三件事。

6.1 计算密集型任务非常适合并行化

这个问题的主开销来自每一项中的大量乘法运算。只要任务划分合理,多线程确实可以明显缩短总时间。

6.2 同步机制的设计比“有没有锁”更重要

这次实验里,性能最好的并不是“完全无锁”的绝对理想模型,而是“局部计算 + 最后一次归并”的实现思路。换句话说,降低同步频率往往比单纯更换锁类型更有效。

6.3 伪共享和缓存问题必须靠实验验证

从理论上看,fs 的写法明显更危险;但从实际结果看,它并没有稳定地成为最差方案。这说明共享内存程序里的性能问题并不总能靠直觉判断,最终还是要回到真实平台上的测试数据。

7. 还能怎么继续优化

这份实验已经验证了并行化有效,但代码本身还有不少优化空间。

  • 目前每一项都从头计算 x^(2i+1)/(2i+1)!,重复工作很多,可以考虑用递推关系减少乘法次数
  • 可以针对 fs 版本显式做 cache line padding,再重新测试伪共享是否被削弱
  • 可以把线程创建成本和计算成本进一步拆开,分析不同线程数下的时间占比
  • 如果继续扩展实验,也可以加入 OpenMP 或 SIMD 版本做更横向的比较

8. 总结

这次实验让我对并行程序的理解更具体了一些。以前我更容易把“开多线程”理解成一个简单的加速开关,但实际做完以后会发现,真正拉开差距的是任务划分、归并方式、锁进入频率,以及缓存层面的细节。

同一个 sin(x) 计算题,换一种同步策略,性能表现就会明显不同。也正因为这样,并行计算的重点从来不只是“能跑”,而是“为什么这样写”“瓶颈到底在哪里”“数据为什么会长成这样”。


文章作者: 牛天淏
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 牛天淏 !
评论
  目录