为什么该程序随着更多线程而变慢?

时间:2018-07-25 09:55:42

标签: multithreading performance rust

与仅使用一个线程运行相比,使用两个线程运行时,我的程序执行时间要长两倍。

我已经使用a minimal example program with the same problem创建了scoped-pool

#![feature(test)]

extern crate scoped_pool;
extern crate test;

use scoped_pool::Pool;
use test::Bencher;

/// This is a minimized program exhibiting a performance problem
/// Why is this program twice as fast, when the number of threads is set to 1 instead of 2?
#[bench]
pub fn test_bench_alt(b: &mut Bencher) {
    let parallellism = 1;
    let data_size = 500_000;

    let mut pool = Pool::new(parallellism);

    {
        let mut data = Vec::new();
        for _ in 0..data_size {
            data.push(0);
        }

        let mut output_data = Vec::<Vec<i32>>::new();
        for _ in 0..parallellism {
            let mut t = Vec::<i32>::with_capacity(data_size / parallellism);
            output_data.push(t);
        }
        b.iter(move || {
            for i in 0..parallellism {
                output_data[i].clear();
            }
            {
                let mut output_data_ref = &mut output_data;
                let data_ref = &data;
                pool.scoped(move |scope| {
                    for (idx, output_data_bucket) in output_data_ref.iter_mut().enumerate() {
                        scope.execute(move || {
                            for item in &data_ref[(idx * (data_size / parallellism))
                                                      ..((idx + 1) * (data_size / parallellism))]
                            {
                                //Yes, this is a logic bug when parallellism does not evenely divide data_size. I could use "chunks" to avoid this, but I wanted to keep this simple for this analysis.
                                output_data_bucket.push(*item);
                            }
                        });
                    }
                });
            }
            let mut output_data_ref = &mut output_data;
            pool.scoped(move |scope| {
                for sub in output_data_ref.iter_mut() {
                    scope.execute(move || {
                        for sublot in sub {
                            assert!(*sublot != 42);
                        }
                    });
                }
            });
        });
    }
}

fn main() {}

这是一个程序,它接收输入向量,在每个线程中处理该向量的一部分,将每个线程的输出汇总为一个向量,然后处理所得向量。实际程序更复杂,但是即使最小化了,它也没有表现出任何价值。

正在运行的载货台:

一个线程:

test test_bench_alt ... bench:     781,105 ns/iter (+/- 1,103)

有两个线程:

test test_bench_alt ... bench:   1,537,465 ns/iter (+/- 154,499)

为什么有两个线程运行时程序变慢?怎样做才能使其更快?

更新:

以下经过高度优化的C ++程序可以完成几乎相同的工作,并且(在我的机器上)最多可以扩展到19个线程,证明工作负载实际上可以并行化。

#include <stdio.h>
#include <stdlib.h>
#include <iostream>
#include <vector>
#include <chrono>
#include <sched.h>
#include <atomic>



#define PAR 1
#define DATASIZE 524288

std::vector<std::vector<int>> output;
std::vector<int> input;


int run_job1(int task) {

    int l = DATASIZE/PAR;
    int off = task*(DATASIZE/PAR);
    auto temp = &output[task][0];
    auto ip = &input[off];
    for(int i=0;i<l;++i){
        *temp=*ip;//+off;
        temp+=1;
        ip+=1;
    }
    return 0;
}


int run_job2(int task) {
    auto& temp = output[task];
    auto temp_p = &output[task][0];
    auto temp_p2 = temp_p + DATASIZE/PAR;
    int expected = task*(DATASIZE/PAR);
    while(temp_p!=temp_p2) {
        if (*temp_p!=expected)
            printf("Woha!\n");
        temp_p+=1;
        expected+=1;
    }
    return 0;
}

std::atomic_int valsync=0;
std::atomic_int valdone=0;

void* threadfunc(void* p) {
    int i = (int)(long)p;
    cpu_set_t set;
    CPU_ZERO(&set);
    CPU_SET(i, &set);
        sched_setaffinity(0, sizeof(set),&set);

    int expect=1;
    while(true) {
        while(valsync.load()!=expect) {
        }
        expect+=1;        
        run_job1(i);
        valdone+=1;

        while(valsync.load()!=expect) {
        }
        expect+=1;        
        run_job2(i);    
        valdone+=1;
    }

}

int main() {

    for(int i=0;i<DATASIZE;++i) {
        input.push_back(i);
    }
    for(int i=0;i<PAR;++i) {
        std::vector<int> t;
        for(int j=0;j<DATASIZE/PAR;++j)
            t.push_back(0);
        output.push_back(t);
    }
    for (int i = 0; i < PAR ; ++i)
    {
        pthread_t thread_id;
        if(pthread_create(&thread_id, NULL, threadfunc, (void*)i)) {

            fprintf(stderr, "Error creating thread\n");
            return 1;

        }   
    }
    for(int run=0;run<20;++run)
    {
        std::chrono::steady_clock::time_point t1 = std::chrono::steady_clock::now();
        for(int j=0;j<1000;++j) {

            std::atomic_fetch_add(&valsync,1);
            while(true)  {
                int expected=PAR;
                if (std::atomic_compare_exchange_strong(&valdone,&expected,0))
                    break;

            }


            std::atomic_fetch_add(&valsync,1);
            while(true)  {
                int expected=PAR;
                if (std::atomic_compare_exchange_strong(&valdone,&expected,0))
                    break;
            }
        }
        std::chrono::steady_clock::time_point t2= std::chrono::steady_clock::now(); 
        auto delta  = t2-t1;

        std::cout<<"Time: "<<std::chrono::duration_cast<std::chrono::nanoseconds>(delta).count()/1000<<" ns per iter \n";
    }

    return 0;
}

2 个答案:

答案 0 :(得分:7)

主要问题是该基准几乎没有意义。分配和比较数字不是计算密集型操作,这意味着并行化这些操作几乎没有任何价值。如这些测量所示,添加更多线程只会降低性能。

令人惊讶的是,最大的瓶颈可能在于构建输出向量时出现的其他琐碎指令,而迭代器可以避免这些琐碎指令。与向量的大多数交互都依赖于索引运算符[]来迭代集合,这是非常规且不建议的。这是相同基准测试的改进版本。更改汇总如下:

  • 可以使用vec宏:vec![0; data_size]来初始化带有特定元素的向量。
  • 一个人也可以使用迭代器来构建N个初始向量。用Vec::new创建的空向量不会分配堆内存,因此这很好。
  • 将作业分配给每个工作人员时,输入存储块和输出向量可以压缩在一起。这两个块都会自动进行迭代,所需的边界检查要少得多。同样由于chunks,如果为最后一个工作程序分配了较小的切片,它将不会尝试越界访问。
  • 还可以使用迭代器来完成每个线程的工作并将其收集到一个新的向量中,而不是在每个步骤中产生一个将新值推送到现有可变向量的循环。使用这种方法,编译器可以避免许多冗余检查。
  • 最后,基准测试的第二部分不需要对“已处理”内容进行可变访问。
#[bench]
pub fn test_bench_alt(b: &mut Bencher) {
    let parallellism = 1;
    let data_size = 500_000;

    let pool = Pool::new(parallellism);

    {
        let data = vec![0; data_size];

        let mut output_data: Vec<_> = (0..parallellism).map(|_| Vec::new()).collect();

        b.iter(move || {
            for vec in &mut output_data {
                vec.clear();
            }

            {
                let data_ref = &data;
                pool.scoped(|scope| {
                    for (output_data_bucket, input_data_chunk) in (&mut output_data)
                        .into_iter()
                        .zip(data_ref.chunks(data_size / parallellism))
                    {
                        scope.execute(move || {
                            *output_data_bucket = input_data_chunk.into_iter().cloned().collect();
                        })
                    }
                });
            }
            pool.scoped(|scope| {
                for sub in &output_data {
                    scope.execute(move || {
                        for sublot in sub {
                            assert_ne!(*sublot, 42);
                        }
                    });
                }
            });
        });
    }
}

之前:

test test_bench_alt ... bench:   1,352,071 ns/iter (+/- 516,762)

之后:

test test_bench_alt ... bench:     533,573 ns/iter (+/- 213,486)

这些数字可能只会在线程数更多的情况下稍微好一些,而方差更大。对于并行度= 2:

test test_bench_alt ... bench:     314,662 ns/iter (+/- 340,636)

如果将计算密集型算法引入方程式,则可以牢记这些想法再次尝试。

答案 1 :(得分:1)

在对问题进行了广泛的研究之后,并从E_net4的出色回答中获得了很多启发,我找到了导致原始程序缩放错误的确切原因。

我们必须在这里考虑两个单独的问题:

  1. 为什么程序这么慢?

  2. 为什么不扩展到超过1个CPU?

问题1的答案已经由E_net4精确地回答了,而且精度很高。问题2的答案是output_data向量的false sharing /缓存行颠簸。

现代多核CPU访问主内存时,它们将从内存访问的数据存储在自己的专用缓存中。可以从快速高速缓存而不是从相对较慢的主内存处理对同一内存的后续请求。

如果一个内核写入另一个内核已缓存的内存会怎样?每当这种情况发生时,必须更新或删除所有核心中的所有缓存副本。这是通过使用MOESI-protocol之类的方法跟踪每个存储的缓存行的状态来实现的。对于每个高速缓存行,CPU都会跟踪它是否是唯一所有者。

每个缓存通常为64个字节。整个高速缓存行由一个核心拥有。现在考虑问题程序中保存output_data向量的字节。每个Vec是8 * 3字节(在64位计算机上)= 24字节。这意味着前两个输出向量可能存储在同一缓存行中。

无论何时执行Vec::pushlen的{​​{1}}字段都会递增。这是一次写操作,因此要求高速缓存行由执行内核拥有。内核之间将有一些信令,并且高速缓存行将被传输到执行内核。考虑另一个内核也将很快执行Vec。发生这种情况时,缓存行将迁移回另一个核心。缓存行的所有权在各个内核之间相互模仿。

解决此问题的一种方法是在Vec::push向量中的各个Vec元素之间引入填充,如下所示:

output_data

平行主义= 1:

#[bench]
pub fn test_bench_alt(b: &mut Bencher) {
    let parallellism = 4;
    let data_size = 500_000;

    let mut pool = Pool::new(parallellism);

    struct Filler {
        odata: Vec<i32>,
        padding: [u8; 64],
    }

    {
        let mut data = Vec::new();
        for _ in 0..data_size {
            data.push(0);
        }

        let mut output_data = Vec::<Filler>::new();
        for _ in 0..parallellism {
            let mut t = Vec::<i32>::with_capacity(data_size / parallellism);
            output_data.push(Filler {
                odata: t,
                padding: [0; 64],
            });
        }
        b.iter(move || {
            for i in 0..parallellism {
                output_data[i].odata.clear();
            }
            {
                let mut output_data_ref = &mut output_data;
                let data_ref = &data;
                pool.scoped(move |scope| {
                    for (idx, output_data_bucket) in output_data_ref.iter_mut().enumerate() {
                        scope.execute(move || {
                            for item in &data_ref[(idx * (data_size / parallellism))
                                                      ..((idx + 1) * (data_size / parallellism))]
                            {
                                //Yes, this is a logic bug when parallellism does not evenely divide data_size. I could use "chunks" to avoid this, but I wanted to keep this simple for this analysis.
                                output_data_bucket.odata.push(*item);
                            }
                        });
                    }
                });
            }
            pool.scoped(|scope| {
                for sub in &output_data {
                    scope.execute(move || {
                        for sublot in &sub.odata {
                            assert!(*sublot != 42);
                        }
                    });
                }
            });
        });
    }
}

平行主义= 2:

test test_bench_alt  ... bench:     729,826 ns/iter (+/- 16,718)

平行主义= 4:

test test_bench_alt  ... bench:     374,167 ns/iter (+/- 9,933)

请注意,通过更好地使用迭代器和test test_bench_alt ... bench: 206,906 ns/iter (+/- 10,559) 来避免边界检查,E_net4的程序仍然效率更高,并且通过将向量构建为临时向量然后将其分配来避免虚假共享的不良影响。每次迭代仅对collect元素进行一次,而不是不断更新output_data元素。