2026-3-23 Pthread多线程编程实战

1 实验环境
 CPU:AMD Ryzen 7 7735H (8C/16T);L1, L2, L3缓存的大小分别为512KB, 4MB, 16MB;内存为15.2GB;在vscode里面编写代码,通过WSL2编译运行代码查看结果。基本内容是这些,其他的用AI一个个检查缺少哪个下哪个就好了。
2 计算PI

/*
* 串行程序:积分法计算圆周率
* 对应PPT第51页
*/
// 我知道C语言都忘得差不多了
// #include是预处理器指令,在编译前把文件内容复制进来
// <>表示导入系统头文件,从系统目录里面找该文件
#include <stdio.h>  // 标准输入输出库
#include <stdlib.h> // 系统库
#include <time.h>   // 时间相关函数

// main函数的参数是用来接受命令行参数用的
// int main(int argc, char *argv[])
// argc是命令行参数的个数,argv是命令行参数的字符串数组,这两个可以固定用,注意第二个数组存储的是字符串,所以数组里面每一个都是一个指针
// 比如命令行输入./pi_serial_1 1000,这里argv[0]就是程序名本身,argv[1]就是第二个参数传入的数字,注意这里还是字符串。argc就是2
int main(int argc, char *argv[])
{
    long long n = 1000000000;
    double ans = 0.0;

    // 如果命令行有多于两个参数,就需要读取其他参数
    if(argc > 1)
    {
        // atoll()表示ASCII to long long,表示把字符串变成longlong的整数
        // 类似的还有atoi(),ASCII to int,atof(),ASCII to double
        // 然后读取第二个参数argv[1]把字符串变成longlong的整数
        n = atoll(argv[1]);
    }

    printf("串行计算PI\n");
    printf("N = %lld\n", n);

    // struct timespec是一个结构体,后面的start和end是变量名称
    // 结构体长这样
    //     struct 结构体名 {
    //     成员1类型 成员1名;
    //     成员2类型 成员2名;
    // };
    // struct timespec是系统定义好的一个结构体
    //     struct timespec {
    //     time_t tv_sec;   // 秒(整数)这里的time_t理解为longlong的别名就好了
    //     long   tv_nsec;  // 纳秒(0~999999999)
    // };
    struct timespec start, end;

    // int clock_gettime(clockid_t clk_id, struct timespec *tp);
    // 第一个参数clk_id表示时钟类型,用哪个时钟
    // CLOCK_MONOTONIC是一个单调时钟,从系统启动开始计时,不受系统时间调整影响,测程序运行时间的时候用
    // tp是一个出参,用来存放具体的结构体
    clock_gettime(CLOCK_MONOTONIC, &start);

    // 串行程序,我们后期并行要改造的部分
    for(double i = 0;i < n;i++)
    {
        ans += (4/(1 + ((i + 0.5)/n)*((i + 0.5)/n)))/n;
    }

    clock_gettime(CLOCK_MONOTONIC, &end);

    // elapsed,经过的、流逝的(时间)之意
    // 计算公式就是秒+纳秒,纳秒/1e9就是秒,统一单位为秒
    double elapsed = (end.tv_sec - start.tv_sec) + (end.tv_nsec - start.tv_nsec) / 1e9;

    printf("计算结果:PI = %.15f\n", ans);
    printf("用时:%.6f秒\n", elapsed);

    return 0;
}

// gcc -o pi_serial_1 pi_serial_1.c -O2 -Wall
// 编译命令,意思是用gcc编译器,-o表示指定输出名称为后面的值
// 然后pi_serial_1.c是需要编译的源代码文件
// -O2是采用优化级别为2的优化,编译稍慢,运行更快
// -Wall是显示所有警告

// ./pi_serial_1,输出:
// 串行计算PI
// N = 1000000000
// 计算结果:PI = 3.141592653589377
// 用时:3.358315秒
/*
* 并行程序:积分法计算圆周率
* 并行其实就是数据划分,也就是把for循环展开
* 然后要知道每个线程自己负责哪个部分,因此需要用自己的编号,目的是让对应的线程拿到对应的数据
* 所以我们可以先大概理解并行的模板
* 这里就不关注时间了,为了理解程序而不是理解性能,一切从简
*/

// 首先,导入pthread库,使用里面的API,就是课上介绍的常用API
// pthread_create()
// pthread_exit()
// pthread_join()
// pthread_mutex_init()
// pthread_mutex_destroy()
// pthread_mutex_lock()
// pthread_mutex_unlock()
#include <stdlib.h>
#include <stdio.h>
#include <pthread.h>

// 定义互斥量,类型就是pthread_mutex_t
pthread_mutex_t mutex;
// 定义全局变量,根据任务的要求确定属性,全局变量是放在全局/静态资源区的,任何线程可以访问,因此需要互斥量加锁访问
double global_sum = 0;

// 这里是定义一些常量,根据任务来就可以
#define N 1000000000
#define thread_count 4

// 定义每个线程的函数,要求返回值和输入值必须是void*
// 由于void*无法解引用,因此必须转换成需要的类型(这里是int),然后解引用得到每个线程的参数
void* thread_func(void* arg)
{
    // 由于这里需要知道每个线程自己是多少号,所以首先把void*强制转换成int*,然后解引用得到里面的值,就是0-thread_count-1
    int rank = *(int*)arg;

    // 这里是体现数据分解的地方,my_n是对于每个任务,每个线程负责多大的数据,总次数N/总线程数量thread_count
    // my_start意思是这个线程负责哪部分,通过这个线程拿到多少号my_rank*my_n就是每个线程负责的部分
    // my_end是从my_start开始,走my_n大小得到的值,中间就是这个线程所负责的数据
    int my_n = N / thread_count;
    int my_start = my_n * rank;
    int my_end = my_start + my_n;

    // 每个线程有自己的局部变量,局部变量是存放在栈区,线程结束自动回收,由于这里不需要每个线程返回,所以直接使用
    double local_sum = 0;
    // 这里就是每个线程完成的任务,其实就是串行程序里面的任务,只不过每个线程只负责自己的数据
    for(int i = my_start; i < my_end; i++)
    {
        // 这里就是根据任务的内容来的了,相当于把i切分成每个线程的部分,所以还是i和N
        local_sum += (4/(1 + ((i + 0.5)/N)*((i + 0.5)/N)))/N;
    }

    // 当每个线程执行完自己的任务,需要对全局变量进行修改的时候,需要用到互斥量
    // 使用pthread_mutex_lock()对互斥量加锁,传入的参数是地址,这是由函数签名决定的
    // 线程拿到锁以后,才可以执行临界区代码,对全局变量进行修改
    pthread_mutex_lock(&mutex);
    global_sum += local_sum;
    // 修改完成之后,线程需要解锁,谁加锁,谁解锁
    pthread_mutex_unlock(&mutex);

    // 前面说过,由于不需要返回值,因此线程执行完自己的任务之后直接exit,回传NULL,否则需要回传堆区的指针
    pthread_exit(NULL);
}

int main()
{
    // 主函数里面,通过pthread_mutex_init()动态初始化互斥量,这个和前面的互斥量的定义是配套使用的,同样需要传地址,这是出参
    pthread_mutex_init(&mutex, NULL);
    // 这里的pthread_t是线程的句柄,不过第一个是给操作系统使用的,和我们上面所说的线程rank区分开来,这里只是提供pthread_create()的第一个参数
    pthread_t threads[thread_count];
    // 这里是我们给每个线程的id,负责指示这是几号线程,负责哪部分数据
    int ranks[thread_count];

    // 开始创建线程,ranks是每个线程的人为ID,从0到thread_count-1,首先初始化
    for(int i = 0; i < thread_count; i++)
    {
        ranks[i] = i;
        // 这个是固定写法,传入句柄(取引用),属性(NULL),每个线程需要执行的函数(名称),以及每个线程需要的参数(取引用)
        pthread_create(&threads[i], NULL, thread_func, &ranks[i]);
    }

    // 创建完之后,每个线程就会执行自己的部分,这个join负责等待所有线程回传exit(),代表所有线程执行完毕
    for(int i = 0; i < thread_count; i++)
    {
        // 固定写法,注意这里不需要取引用了,因为第一个参数不是出参,只是需要知道这个句柄的线程结束而已,第二个参数比较复杂,固定传NULL即可
        pthread_join(threads[i], NULL);
    }

    // 所有线程完毕,在线程内部使用完了互斥量修改全局变量之后,销毁互斥量
    pthread_mutex_destroy(&mutex);
    
    // 输出结果
    printf("结果:%.15f\n", global_sum);

    return 0;
}

// gcc -o pi_parallel_1 pi_parallel_1.c -lpthread -O2 -Wall -g
// 这里一定要加上-lpthread参数,也就是链接上pthread库,才可以用里面的API

// 输出:结果:3.141592653589567
#include <stdio.h>
#include <stdlib.h>

int main()
{
    double m = 0;
    long long n = 1000000000;

    // 蒙特卡洛方法相当于是随机投n次,统计落在四分之一圆弧里面的次数m,然后计算PI
    for (int i = 0; i < n; i++)
    {
        srand(i);   // 设置随机种子,决定从哪里开始
        double x = 1.0 * rand() / RAND_MAX; //rand()是生成一个伪随机数,最大是RAND_MAX,这个就相当于在计算x和y是不是在0-1之间
        double y = 1.0 * rand() / RAND_MAX;
        if(x*x + y*y <= 1.0) m++;   // 如果在四分之一圆弧里面,就相当于模拟到了一次,m+1
    }

    double ans = 4.0 * m / n;   // 蒙特卡洛的计算方法,任务会给
    
    printf("结果:%.15f", ans);
}

// gcc -o pi_serial_2 pi_serial_2.c -O2 -Wall -g
// 输出:结果:3.141560880000000
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

#define N 10000
#define thread_count 8

pthread_mutex_t mutex;
double global_m = 0;

void* thread_func(void* arg)
{
    int my_rank = *(int*)arg;

    int my_n = N / thread_count;
    int my_start = my_n * my_rank;
    int my_end = my_start + my_n;

    double my_m = 0;
    for (int i = my_start; i < my_end; i++)
    {
        srand(i);   // 设置随机种子,决定从哪里开始
        double x = 1.0 * rand() / RAND_MAX; //rand()是生成一个伪随机数,最大是RAND_MAX,这个就相当于在计算x和y是不是在0-1之间
        double y = 1.0 * rand() / RAND_MAX;
        if(x*x + y*y <= 1.0) my_m++;   // 如果在四分之一圆弧里面,就相当于模拟到了一次,m+1
    }

    pthread_mutex_lock(&mutex);
    global_m += my_m;
    pthread_mutex_unlock(&mutex);

    pthread_exit(NULL);
}

int main()
{
    pthread_mutex_init(&mutex, NULL);

    pthread_t threads[thread_count];
    int ranks[thread_count];

    for (int i = 0; i < thread_count; i++)
    {
        ranks[i] = i;
        pthread_create(&threads[i], NULL, thread_func, &ranks[i]);
    }

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

    pthread_mutex_destroy(&mutex);

    printf("结果:%.15f", 4 * global_m / N);
}

// gcc -o pi_parallel_2 pi_parallel_2.c -lpthread -O2 -Wall -g
// 输出:结果:3.164000000000000
#include <stdio.h>
#include <stdlib.h>

int main()
{
    long long n = 1000000000;  // 迭代次数
    double sum = 0.0;

    for (long long i = 0; i < n; i++)  // 从0开始
    {
        // 计算符号: i=0时flag=1, i=1时flag=-1, i=2时flag=1...
        int flag = (i % 2 == 0) ? 1 : -1;
        
        // 分母: i=0时为1, i=1时为3, i=2时为5...
        sum += flag * 1.0 / (2 * i + 1);
    }

    double ans = 4.0 * sum;

    printf("结果:%.15f\n", ans);
    return 0;
}

// gcc -o pi_serial_3 pi_serial_3.c -O2 -Wall -g
// 结果:3.14159265258805
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

#define N 1000000000
#define thread_count 8

pthread_mutex_t mutex;
double global_sum = 0;

void* thread_func(void* arg)
{
    int my_rank = *(int*)arg;

    int my_n = N / thread_count;
    int my_start = my_n * my_rank;
    int my_end = my_start + my_n;

    double my_sum = 0;
    for (int i = my_start; i < my_end; i++)
    {
        // 计算符号: i=0时flag=1, i=1时flag=-1, i=2时flag=1...
        int flag = (i % 2 == 0) ? 1 : -1;
        
        // 分母: i=0时为1, i=1时为3, i=2时为5...
        my_sum += flag * 1.0 / (2 * i + 1);
    }

    pthread_mutex_lock(&mutex);
    global_sum += my_sum;
    pthread_mutex_unlock(&mutex);

    pthread_exit(NULL);
}

int main()
{
    pthread_mutex_init(&mutex, NULL);

    pthread_t threads[thread_count];
    int ranks[thread_count];

    for (int i = 0; i < thread_count; i++)
    {
        ranks[i] = i;
        pthread_create(&threads[i], NULL, thread_func, &ranks[i]);
    }

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

    pthread_mutex_destroy(&mutex);

    printf("结果:%.15f", 4 * global_sum);

    return 0;
}

3 作业1
image

#include <stdio.h>
#include <stdlib.h>

int main()
{
    long long n = 1000000;
    long long ans = 0;

    for (int i = 1; i <= n; i++)
    {
        ans += i;
    }

    printf("结果:%lld\n", ans);
}

// gcc -o sum1e6_serial sum1e6_serial.c -O2 -Wall -g
// 结果:500000500000
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

#define N 1000000
#define thread_count 2

pthread_mutex_t mutex;
long long global_sum = 0;

void* thread_func(void* arg)
{
    int my_rank = *(int*)arg;

    int my_n = N / thread_count;
    int my_start = my_n * my_rank;
    int my_end = my_start + my_n;

    long long my_sum = 0;
    for (int i = my_start+1; i <= my_end; i++)
    {
        my_sum += i;
    }

    pthread_mutex_lock(&mutex);
    global_sum += my_sum;
    pthread_mutex_unlock(&mutex);

    pthread_exit(NULL);
}

int main()
{
    pthread_mutex_init(&mutex, NULL);

    pthread_t threads[thread_count];
    int ranks[thread_count];

    for (int i = 0; i < thread_count; i++)
    {
        ranks[i] = i;
        pthread_create(&threads[i], NULL, thread_func, &ranks[i]);
    }

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

    pthread_mutex_destroy(&mutex);

    printf("结果:%lld\n", global_sum);

    return 0;
}

// gcc -o sum1e6_parallel sum1e6_parallel.c -lpthread -O2 -Wall -g
// 结果:500000500000

4 24位BMP文件的水平翻转和垂直翻转
image
image
通过Windows的画图功能可以直接画出一个512512的24位.bmp文件。在vscode里面下载插件Hex editor,然后打开刚刚保存的图片文件,就可以看见字节形式的图片。下面图片颜色顺序有误,不过不影响,应该是BRG。512512是像素的大小,我们知道1个像素对应3个字节,因此这个文件的整个大小就是5125123=786432字节,和文件系统显示的768KB相同。所以对图片进行操作,也就是对像素进行操作,而一个像素是三个字节,所以每次要交换一个像素,就是交换这两个像素对应的字节。
image
image
串行程序,详细的介绍都在代码里面

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

// ============================================
// 数据结构定义
// ============================================

// 图像属性结构体:存储整个图像的全局信息
// 这个结构体只有一个实例(全局变量ip),保存从文件头解析出来的信息
struct ImgProp {
    int Hpixels;           // 图像宽度(像素数),如512
    int Vpixels;           // 图像高度(像素数),如512  
    // unsigned char无符号字符型,范围是0-255,用来保存每个字节的元素
    unsigned char HeaderInfo[54];  // BMP文件头(54字节),原封不动保留
                                   // 包含:文件类型"BM"、文件大小、数据偏移、宽度、高度、位深等
    // unsigned long是修饰符 int是基本类型,简单理解为能够保存更大的每行实际字节数就行
    unsigned long int Hbytes;      // 每行实际占用的字节数(含填充)
                                   // 这里的计算公式表示的是不小于Hpixels*3的最小4的倍数,是为了给每一行填充用的,提升效率
                                   // 这里我们的像素就是512*512,暂时不管他,知道Hbytes是1536字节就行,也就是每一行的大小
                                   // 计算公式:((Hpixels * 3 + 3) / 4) * 4
                                   // 例如512*3=1536,正好4字节对齐,Hbytes=1536
};

// 像素结构体:临时存储一个像素的BGR三个字节
// 用于交换两个像素时做临时变量,避免覆盖
struct Pixel {
    unsigned char R;   // 红色分量(1字节,0-255)
    unsigned char G;   // 绿色分量(1字节,0-255)
    unsigned char B;   // 蓝色分量(1字节,0-255)
    // 注意:BMP文件中是BGR顺序,但结构体里写RGB是为了代码可读性
    // 实际存储时B在前,存到buffer[0];G在中间,存到buffer[1];R在后,存到buffer[2]
};

// 全局图像属性变量,所有函数都能访问
// 这样不需要把宽高等信息作为参数传来传去
struct ImgProp ip;

// ============================================
// 读取BMP文件:把文件内容读入二级指针
// ============================================

// 返回值:unsigned char** 是指向"行指针数组"的指针
// 即:一个数组,每个元素是一个指针,指向该行的第一个字节
// 

// img(行指针数组)
// ├── img[0] ──→ [B][G][R][B][G][R]...(第0行,1536个字节)
// │                ↑
// │                这个指针指向第0行的第0个字节
// │
// ├── img[1] ──→ [B][G][R][B][G][R]...(第1行,1536个字节)
// │                ↑
// │                这个指针指向第1行的第0个字节
// │
// ├── img[2] ──→ [B][G][R][B][G][R]...(第2行,1536个字节)
// │
// ...
// │
// └── img[511] ─→ [B][G][R][B][G][R]...(第511行,1536个字节)

// 内存布局:
// img[0] → 指向第0行的起始地址(1536字节)
// img[1] → 指向第1行的起始地址(1536字节)
// ...
// img[511] → 指向第511行的起始地址(1536字节)
//
// 为什么要二级指针?因为每行单独malloc,内存不一定连续!
unsigned char** ReadBMP(char* filename) {
    // fopen(文件名称,打开方式),rb表示二进制只读,wb表示二进制只写,二进制模式会原封不动读取每个字节
    // 成功就会返回一个FILE*文件指针
    FILE* fp = fopen(filename, "rb");
    if (!fp) {
        printf("无法打开文件\n");
        exit(1);
    }
    
    // 步骤1:读取54字节文件头,存入ip.HeaderInfo
    // 这54字节原封不动,后面写文件时直接写回去
    // fread(存到哪里,每个多大,读几个,从哪读),每个多大的意思是把后面的读几个里面的内容一个个读放进第一个参数里面
    // 前面fp就是通过二进制只读的方式读了这个图片,然后把前54个字节的文件头按照每个1个字节放入HeaderInfo里面,保存下来

    //     磁盘文件: [42][4d][36][00][0c][00]...[28][00]  ← 54个字节
    //             ↑
    //            文件开头

    // fread(HeaderInfo, 1, 54, fp) 的过程:

    // 第1次读:读 0x42 → HeaderInfo[0]
    // 第2次读:读 0x4d → HeaderInfo[1]
    // 第3次读:读 0x36 → HeaderInfo[2]
    // ...
    // 第54次读:读 0x28 → HeaderInfo[53]

    // 读完:HeaderInfo = {0x42, 0x4d, 0x36, ..., 0x28}
    fread(ip.HeaderInfo, 1, 54, fp);
   
    // 读完后文件指针自动后移
    // 文件: [头54字节][像素行0][像素行1][像素行2]...
    //          ↑
    //         fp初始在这里

    // fread(HeaderInfo, 1, 54, fp) 后:
    //         [头54字节][像素行0][像素行1]...
    //                    ↑
    //                   fp现在在这里(自动后移54字节)

    // fread(img[0], 1, 1536, fp) 后:
    //         [头54字节][像素行0][像素行1]...
    //                               ↑
    //                              fp又后移1536字节

    //     磁盘文件继续: [ff][ff][ff][ff][ff][ff]...  ← 1536个字节(第0行像素)
    //                 ↑
    //                fp现在在这里

    // fread(img[0], 1, 1536, fp) 的过程:

    // 第1次读:读 0xff → img[0][0]  (第0行第0字节)
    // 第2次读:读 0xff → img[0][1]  (第0行第1字节)
    // 第3次读:读 0xff → img[0][2]  (第0行第2字节)
    // ...
    // 第1536次读:读 0xff → img[0][1535] (第0行最后1字节)

    // 读完:img[0] = {0xff, 0xff, 0xff, ...}  (整行像素数据)

    // 步骤2:从文件头解析关键信息(小端序)
    // 文件头结构(重要字段):
    // 偏移0-1:   "BM" 文件标识
    // 偏移2-5:   文件大小(4字节)
    // 偏移10-13: 数据偏移(4字节),通常是54
    // 偏移14-17: 信息头大小(4字节),通常是40
    // 偏移18-21: 图像宽度(4字节)← 这里取ip.Hpixels
    // 偏移22-25: 图像高度(4字节)← 这里取ip.Vpixels
    // 偏移28-29: 位深度(2字节),24表示24位
    
    // *(int*)&ip.HeaderInfo[18] 的意思是:
    // &ip.HeaderInfo[18] 取第18字节的地址
    // (int*) 强制转换成int指针(4字节),意思是int指针就是四个字节,所以这里先定位起始地址,然后按照四字节读取,随后解引用取值
    // * 解引用,读出4字节作为一个整数
    ip.Hpixels = *(int*)&ip.HeaderInfo[18];   // 宽度,如512
    ip.Vpixels = *(int*)&ip.HeaderInfo[22];   // 高度,如512
    
    // 步骤3:计算每行字节数(4字节对齐),这里就是每一行的大小都要是4的倍数,我们这里图片就是512,可以不深究这个
    // BMP要求每行必须是4的倍数,不足要填充0
    // 公式:((宽度*3 + 3) / 4) * 4
    // 例如:512*3=1536,(1536+3)/4*4 = 1539/4*4 = 384*4 = 1536 ✓
    // 例如:513*3=1539,(1539+3)/4*4 = 1542/4*4 = 385*4 = 1540(填充1字节)
    ip.Hbytes = ((ip.Hpixels * 3 + 3) / 4) * 4;
    
    printf("图像尺寸: %d x %d, 每行%ld字节\n", ip.Hpixels, ip.Vpixels, ip.Hbytes);
    
    // 步骤4:申请"行指针数组"
    // 这是一个数组,有Vpixels个元素(因为按照行来存,V是高度),每个元素是unsigned char*,意思是每行都是一个指向这一行所有字节的指针
    // 即:数组里存的是指针,每个指针指向一行的数据,img是指针的指针,所以是unsigned char **
    // 注意我们的img存的是指针,所以一共有Vpixels行,每一行都是一个指向unsigned char类型的指针,所以是sizeof(unsigned char*),因为每一行是一个指针
    // 前面的(unsigned char**)是强制类型转换,本来malloc()的返回值是void*,所以强制转换一下表示这一块空间存的是unsigned char**,即img指针的指针
    unsigned char** img = (unsigned char**)malloc(ip.Vpixels * sizeof(unsigned char*));
    // 这里给指针数组申请的内存是连续的,因为只调用了一次malloc()
    //     内存地址:  0x1000    0x1008    0x1010    0x1018   ...   0x1FF8
    //            ├─────────┼─────────┼─────────┼─────────┼ ... ├─────────┤
    // img →      │img[0]   │img[1]   │img[2]   │img[3]   │     │img[511] │
    //            │0x2000   │0x3000   │0x4000   │0x5000   │     │0x...    │
    //            └─────────┴─────────┴─────────┴─────────┴ ... ┴─────────┘
    //             ↑ 这512个指针是连续的!每个占8字节(64位系统)
    if (!img) {
        printf("内存分配失败\n");
        exit(1);
    }
    
    // 步骤5:逐行申请内存并读取像素数据
    // 上面的img是512个指针数组,也就是img的元素是指向unsigned char*的一个个指针
    // 现在做的就是给每一个指针,512个指针,分配它的一个个unsigned char字节
    // 注意:BMP文件里像素是从下往上存的(第0行在文件最下面)
    // 但这里我们按从上往下的顺序读,存到img[0]、img[1]...
    // 这样img[0]对应图像顶部,img[Vpixels-1]对应图像底部
    for (int row = 0; row < ip.Vpixels; row++) {
        // 每行申请Hbytes字节
        img[row] = (unsigned char*)malloc(ip.Hbytes);
        // 这里给每一行申请的字节数组是不连续的,因为malloc()调用了多次,每次都是随机找空闲内存
        // img[0] → 0x2000  ├─[B][G][R][B][G][R]...1536字节─┤  ← 第0行数据
        //                  ↑ 在0x2000-0x25FF

        // img[1] → 0x3000  ├─[B][G][R][B][G][R]...1536字节─┤  ← 第1行数据  
        //                  ↑ 在0x3000-0x35FF(中间隔了0x2600-0x2FFF,不连续!)

        // img[2] → 0x4000  ├─[B][G][R][B][G][R]...1536字节─┤  ← 第2行数据
        //                  ↑ 又隔了一大段

        // ...以此类推
        if (!img[row]) {
            printf("内存分配失败\n");
            exit(1);
        }
        // 从文件读取一行像素数据(Hbytes字节)
        // 这里前面读取了文件头,所以已经自动往后偏移了
        fread(img[row], 1, ip.Hbytes, fp);
    }
    
    fclose(fp);
    return img;  // 返回行指针数组
//     img
// ├── 类型:unsigned char**
// ├── 本质:指针数组(大小 = 行数 = 512)
// │
// ├── img[0] → unsigned char* → [B][G][R][B][G][R]...(1536个unsigned char)下标是0-1535
// ├── img[1] → unsigned char* → [B][G][R][B][G][R]...(1536个unsigned char)
// ├── img[2] → unsigned char* → [B][G][R][B][G][R]...(1536个unsigned char)
// │   ...
// └── img[511] → unsigned char* → [B][G][R][B][G][R]...(1536个unsigned char)
//     ↑              ↑                    ↑
//    数组元素      指针类型            数组(字节)
}

// ============================================
// 写入BMP文件:把二级指针写回文件
// ============================================

void WriteBMP(unsigned char** img, char* filename) {
    FILE* fp = fopen(filename, "wb");
    if (!fp) {
        printf("无法创建文件\n");
        exit(1);
    }
    
    // 步骤1:原封不动写回54字节文件头
    // 这样输出的BMP文件格式完全正确
    // size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);
    // 分别表示,要写入的数据的起始地址,每次写多少,一共写多少,和文件指针
    // 从 ip.HeaderInfo[0] 开始,连续写 54×1 = 54字节,写入fp里面
    fwrite(ip.HeaderInfo, 1, 54, fp);
    
    // 步骤2:逐行写入像素数据
    for (int row = 0; row < ip.Vpixels; row++) {
        // 这里类似,每次从这一行的头开始,一次写一个,一共写这一行的字节数量个,写入fp里面
        fwrite(img[row], 1, ip.Hbytes, fp);
    }
    
    fclose(fp);
}

// ============================================
// 水平翻转:左右镜像(FlipImageH)
// ============================================

// 核心思想:每一行,左边像素和右边像素交换
// 例如:第0行,像素0和像素511交换,像素1和像素510交换...
//
// 访问方式:img[row][col] 
// - row:行号,0 ~ Vpixels-1
// - col:字节偏移,0 ~ Hbytes-1
// 
// 像素(col/3, col%3)的BGR:
// - B在 img[row][col]
// - G在 img[row][col+1]  
// - R在 img[row][col+2]

unsigned char** FlipImageH(unsigned char** img) {
    // 定义像素是因为交换图片就是交换像素,一个像素又是三个字节构成,所以就是交换这个像素的三个字节
    struct Pixel pix;  // 临时像素,用于交换
    int row, col;   //所以把这里的行列理解为像素的坐标,更好理解,通过+1 +2表示字节
    
    // 外层循环:遍历每一行
    // 水平翻转是"行内操作",每行独立处理
    // 注意这里的行是row++,因为每一行处理,我们是一行行存的
    for (row = 0; row < ip.Vpixels; row++) {
        
        col = 0;
        // 内层循环:遍历左半边像素(只到一半,否则交换两次又回去了)
        // Hpixels是宽度,是像素的大小
        // 条件:col < (ip.Hpixels * 3) / 2
        // ip.Hpixels * 3 = 总字节数(因为每像素3字节)
        // 除以2 = 只处理左半边
        while (col < (ip.Hpixels * 3) / 2) {
            
            // 步骤1:保存左边像素(当前col位置的BGR三个字节)
            pix.B = img[row][col];      // 蓝色
            pix.G = img[row][col + 1];  // 绿色
            pix.R = img[row][col + 2];  // 红色
            
            // 步骤2:计算右边镜像位置,把右边像素移到左边
            // 镜像公式:ip.Hpixels * 3 - (col + 3)
            // 解释:
            // - ip.Hpixels * 3 = 一行总字节数(如1536)
            // - col是当前像素的B位置(如0, 3, 6...)
            // - 当前像素的G在col+1,R在col+2
            // - 镜像像素的R在 (总字节-1),G在(总字节-2),B在(总字节-3)
            // - 所以镜像像素的B在:总字节 - (col + 3)
            //
            // 例子:col=0(第0个像素)
            //   左边像素B在0,G在1,R在2
            //   镜像像素B在1536-3=1533,G在1536-2=1534,R在1536-1=1535
            //   这就是最后一个像素(第511个)的BGR
            
            img[row][col]     = img[row][ip.Hpixels * 3 - (col + 3)];
            img[row][col + 1] = img[row][ip.Hpixels * 3 - (col + 2)];
            img[row][col + 2] = img[row][ip.Hpixels * 3 - (col + 1)];
            
            // 步骤3:把临时保存的左边像素,放到右边镜像位置
            img[row][ip.Hpixels * 3 - (col + 3)] = pix.B;
            img[row][ip.Hpixels * 3 - (col + 2)] = pix.G;
            img[row][ip.Hpixels * 3 - (col + 1)] = pix.R;
            
            // 移动到下一个像素(3字节)
            col += 3;
        }
    }
    
    return img;  // 返回修改后的图像
}

// ============================================
// 垂直翻转:上下镜像(FlipImageV)
// ============================================

// 核心思想:每一列,上面像素和下面像素交换
// 例如:第0列,第0行和第511行交换,第1行和第510行交换...
//
// 注意:这里是按列遍历!col+=3表示每次处理一列(3个字节)

unsigned char** FlipImageV(unsigned char** img) {
    struct Pixel pix;
    int row, col;
    
    // 外层循环:遍历每一列(按字节偏移,每次跳3个字节=1个像素)
    // 垂直翻转是"列内操作",每列独立处理
    // 如果是处理列,我们列是按照字节存的,所以col+=3才是处理下一个像素
    for (col = 0; col < ip.Hbytes; col += 3) {
        
        row = 0;
        // 内层循环:遍历上半部分行(只到一半)
        // ip.Vpixels / 2 = 高度的一半
        while (row < ip.Vpixels / 2) {
            
            // 步骤1:保存上边像素(当前row行,col列的BGR)
            pix.B = img[row][col];
            pix.G = img[row][col + 1];
            pix.R = img[row][col + 2];
            
            // 步骤2:计算下边镜像行,把下边像素移到上边
            // 镜像公式:ip.Vpixels - (row + 1)
            // 解释:
            // - row=0(第0行,顶部),镜像行=512-1=511(第511行,底部)
            // - row=1(第1行),镜像行=512-2=510(第510行)
            // - 以此类推
            
            img[row][col]     = img[ip.Vpixels - (row + 1)][col];
            img[row][col + 1] = img[ip.Vpixels - (row + 1)][col + 1];
            img[row][col + 2] = img[ip.Vpixels - (row + 1)][col + 2];
            
            // 步骤3:把临时保存的上边像素,放到下边镜像位置
            img[ip.Vpixels - (row + 1)][col]     = pix.B;
            img[ip.Vpixels - (row + 1)][col + 1] = pix.G;
            img[ip.Vpixels - (row + 1)][col + 2] = pix.R;
            
            row++;  // 下一行
        }
    }
    
    return img;
}

// ============================================
// 主函数:程序入口
// ============================================

int main(int argc, char** argv) {
    // 简单版本:固定处理ForPthread.bmp,水平翻转输出output.bmp
    
    // 步骤1:读取BMP文件
    // img是二级指针,img[row][col]访问第row行第col个字节
    unsigned char** img = ReadBMP("ForPthread.bmp");
    
    // 步骤2:选择翻转方式(这里用水平翻转)
    // 可以改成 FlipImageV(img) 做垂直翻转
    img = FlipImageV(img);
    
    // 步骤3:写入输出文件
    WriteBMP(img, "output.bmp");
    
    // 步骤4:释放内存(每行free,再free行指针数组)
    for (int i = 0; i < ip.Vpixels; i++) {
        free(img[i]);  // 释放每行的内存
    }
    free(img);  // 释放行指针数组本身
    
    printf("翻转完成,输出到output.bmp\n");
    return 0;
}

这个例子主要是拆解两层循环:以水平翻转为例,水平翻转的外层循环就相当于每一个线程对图片进行横着拆分,也就是按行拆分,每个线程负责自己的这几行。第二层循环就是在每个线程内部,需要对全部的列,或者说左半列和右半列进行交换,这就拆解了第二层循环。需要注意的是要把指针的指针放到全局初始化然后读取文件,这样子线程函数才可以使用。
image

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>

struct ImgProp {
    int Hpixels;
    int Vpixels;
    unsigned char HeaderInfo[54];
    unsigned long int Hbytes;
};

struct Pixel {
    unsigned char R;
    unsigned char G;
    unsigned char B;
};

struct ImgProp ip;

// 确定线程数量
#define thread_count 4

// 全局图像指针,这样子函数才能使用
unsigned char** TheImage;

unsigned char** ReadBMP(char* filename) {
    FILE* fp = fopen(filename, "rb");
    if (!fp) {
        printf("无法打开文件\n");
        exit(1);
    }
    
    fread(ip.HeaderInfo, 1, 54, fp);
    ip.Hpixels = *(int*)&ip.HeaderInfo[18];
    ip.Vpixels = *(int*)&ip.HeaderInfo[22];
    ip.Hbytes = ((ip.Hpixels * 3 + 3) / 4) * 4;
    
    printf("图像尺寸: %d x %d, 每行%ld字节\n", ip.Hpixels, ip.Vpixels, ip.Hbytes);
    
    unsigned char** img = (unsigned char**)malloc(ip.Vpixels * sizeof(unsigned char*));
    if (!img) {
        printf("内存分配失败\n");
        exit(1);
    }
    
    for (int row = 0; row < ip.Vpixels; row++) {
        img[row] = (unsigned char*)malloc(ip.Hbytes);
        if (!img[row]) {
            printf("内存分配失败\n");
            exit(1);
        }
        fread(img[row], 1, ip.Hbytes, fp);
    }
    
    fclose(fp);
    return img;
}

void WriteBMP(unsigned char** img, char* filename) {
    FILE* fp = fopen(filename, "wb");
    if (!fp) {
        printf("无法创建文件\n");
        exit(1);
    }
    
    fwrite(ip.HeaderInfo, 1, 54, fp);
    
    for (int row = 0; row < ip.Vpixels; row++) {
        fwrite(img[row], 1, ip.Hbytes, fp);
    }
    
    fclose(fp);
}

// 水平翻转的并行程序,水平翻转按行来划分
void* FlipImageHP(void* arg)
{
    struct Pixel pix;
    int row, col;

    int my_rank = *(int*)arg;

    int my_n = ip.Vpixels / thread_count;
    int my_start = my_rank * my_n;
    int my_end = my_start + my_n;

    for (row = my_start; row < my_end; row++) //处理属于自己的行
    {
        // 处理自己的这几行的列,注意列每次是跳3,所以要用字节来判断
        for (col = 0; col < (ip.Hpixels * 3) / 2; col += 3)
        {
            pix.B = TheImage[row][col];
            pix.G = TheImage[row][col + 1];
            pix.R = TheImage[row][col + 2];
            
            TheImage[row][col]     = TheImage[row][ip.Hpixels * 3 - (col + 3)];
            TheImage[row][col + 1] = TheImage[row][ip.Hpixels * 3 - (col + 2)];
            TheImage[row][col + 2] = TheImage[row][ip.Hpixels * 3 - (col + 1)];
            
            TheImage[row][ip.Hpixels * 3 - (col + 3)] = pix.B;
            TheImage[row][ip.Hpixels * 3 - (col + 2)] = pix.G;
            TheImage[row][ip.Hpixels * 3 - (col + 1)] = pix.R;
        }
    }

    pthread_exit(NULL);
}


// 垂直翻转的并行,垂直翻转按照列来划分
void* FlipImageVP(void* arg)
{
    struct Pixel pix;
    int row, col;

    int my_rank = *(int*)arg;

    int my_n = ip.Hbytes / thread_count;   //注意这里就是字节数量了
    int my_start = my_n * my_rank;
    int my_end = my_start + my_n;

    for (col = my_start; col < my_end; col += 3)
    {
        for (row = 0; row < ip.Vpixels / 2; row++)
        {
            pix.B = TheImage[row][col];
            pix.G = TheImage[row][col + 1];
            pix.R = TheImage[row][col + 2];
            
            TheImage[row][col]     = TheImage[ip.Vpixels - (row + 1)][col];
            TheImage[row][col + 1] = TheImage[ip.Vpixels - (row + 1)][col + 1];
            TheImage[row][col + 2] = TheImage[ip.Vpixels - (row + 1)][col + 2];
            
            TheImage[ip.Vpixels - (row + 1)][col]     = pix.B;
            TheImage[ip.Vpixels - (row + 1)][col + 1] = pix.G;
            TheImage[ip.Vpixels - (row + 1)][col + 2] = pix.R;
        }
    }

    pthread_exit(NULL);
}

int main(int argc, char** argv) {
    // 读取到全局变量,让线程能访问
    TheImage = ReadBMP("ForPthread.bmp");
    
    pthread_t threads[thread_count];
    int rank[thread_count];

    for (int i = 0; i < thread_count; i++)
    {
        rank[i] = i;
        pthread_create(&threads[i], NULL, FlipImageHP, &rank[i]);
    }

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

    WriteBMP(TheImage, "output水平.bmp");
    
    for (int i = 0; i < ip.Vpixels; i++) {
        free(TheImage[i]);
    }
    free(TheImage);
    
    printf("翻转完成,输出到output水平.bmp\n");
    return 0;
}

5 图像翻转的一些定性知识点
 关于图像翻转的代码实操就是上面的内容,其实主要的内容就是你要知道对于24位的BMP图片,在读取了文件头之后,图片的像素信息就是一个像素对应三个字节,从而把图片转换成用img表示的一个二维数组。这个二维数组的第一维度是unsigned char*类型,也就是512个指针,第二个维度也就是每个指针指向的都是一个一维数组,数据类型是unsigned char,就是一个个字节。不管是水平翻转还是垂直翻转,都是对于这个二维数组img的操作:比如对于水平翻转,就是对于每一行,也就是512个指针,里面的每三个字节,与他的对应像素的的三个字节进行交换;对于垂直翻转,就是对于每一列,三个三个字节的和他对应的值进行交换。至于具体的交换公式,只要你知道水平翻转就是开了列的序号,垂直翻转考虑行的序号,前者是字节,后者是像素坐标,就可以很容易推导出来。把串行变成并行程序的时候,就是注意水平翻转,每个线程应该横着切分,负责自己的这几行的内容;对于垂直翻转,线程应该竖着切分,每个线程负责自己这几列的内容。下面主要是补充一些其他的知识点。
image
 这一页想说的是,在整个程序运行的时候,大概是这三个过程:从磁盘读取图片,写入img -> 对img进行操作,也就是翻转操作 -> 最后把img写回磁盘保存。这三段里面,第一段和第三段的运行时间,取决于硬件,对于单线程程序都会吃满带宽,并且时间也不可控,所以我们优化的目标并不是这两段。而中间的翻转操作才是我们并行算法优化的主空间,我们的优化应该聚焦在中间的CPU计算和内存访问部分。反正就是说如果我们要计时,我们应该从开始操作img到结束翻转为止,不要记录磁盘读和磁盘写的时间。
image
 这一页还是在讲计时问题,上面知道了只需要记录操作img的时间,问题是怎么操作呢:PPT给出了原因,OS会对电脑的程序进行干扰,所以可以多次执行取平均;必须要奇数次循环,否则图片就又翻回来了相当于没有翻转,就是两个小问题。
 后面的内容,涉及计算机的体系结构,我们首先要了解磁盘、主存(内存)、三级缓存(Cache)、寄存器。

┌─────────────────────────────────────────────────────────┐
│  磁盘(lena.bmp文件)                                    │
│  - 持久化存储,断电不丢失                                 │
│  - 速度:SSD ~500MB/s,HDD ~100MB/s                     │
└─────────────────────────────────────────────────────────┘
                           ↓  ReadBMP():OS发起I/O请求
                           ↓  通过DMA直接写入DRAM(不经过CPU)
┌─────────────────────────────────────────────────────────┐
│  主存/DRAM(您的二维数组TheImage[][]所在位置)            │
│  - 程序运行时"可见"的全部内存                            │
│  - 容量大,但速度相对CPU很慢                              │
│  - 您的代码直接操作的是这里的地址                          │
└─────────────────────────────────────────────────────────┘
                           ↓  CPU访问数据时,硬件自动处理
                           ↓  对程序员透明(您感知不到)
        ┌─────────────────────────────────────┐
        │  L3 Cache(8MB,所有核心共享)        │
        │  - 比DRAM快10-20倍                   │
        │  - 自动缓存最近访问的DRAM数据          │
        └─────────────────────────────────────┘
                      ↓  更频繁访问的数据
        ┌─────────────────────────────────────┐
        │  L2 Cache(256KB/核)               │
        │  L1 Cache(32KB/核,分指令+数据)    │
        │  - 比L3快5-10倍                      │
        │  - 只有当前核心正在处理的数据          │
        └─────────────────────────────────────┘
                      ↓
        ┌─────────────────────────────────────┐
        │  CPU寄存器 → 执行指令                │
        └─────────────────────────────────────┘

&emsp;第一个结论:对于图像翻转,根据不同的存储方式有:如果是行有限存储,那么水平翻转会比垂直翻转快;如果是列优先存储,那么垂直翻转比水平翻转快。这是由程序的局部性原理得出的。以行优先存储为例,比如我们上面的例子,虽然每一行之间的内存不连续,但是一行内的字节是连续的。而对于水平翻转就是要处理每一行里面的字节交换,所以程序在读入第一个字节的时候,根据程序的局部性原理,会把这一行的其他字节也放入L1缓存中,这样子后续对这一行的其他字节处理的时候,会直接命中L1Cache的缓存;而在这种情况的垂直翻转,由于垂直翻转是要对每一列的行进行交换,在读到这一列的第一行像素时,跟他交换的最后一行的像素,缓存中没有,相当于未命中,重新回到主存里面找数据,因此会慢。

内存地址递增方向 →
[0,0][0,1][0,2]...[0,639] [1,0][1,1]...[1,639] [2,0]...
└────── 行0 ──────┘ └────── 行1 ──────┘ └─...
// 行优先存储
内存地址递增方向 →
[0,0][1,0][2,0]...[479,0] [0,1][1,1]...[479,1] [0,2]...
└────── 列0 ──────┘ └────── 列1 ──────┘ └─...
// 列优先存储

 局部性原理:空间局部性:访问某地址后,临近地址很可能被访问;时间局部性:访问某地址后,该地址本身可能被再次访问。

访问 DRAM(主存) 地址 0x1000
    ↓
硬件自动把 [0x1000 ~ 0x1040](一个Cache行,64B)装入Cache
    ↓
后续访问 0x1004, 0x1008... → Cache命中 ✓
Cache(高速缓冲存储器)
├── L1 Cache(一级)← 最快,最小,每个核心私有
├── L2 Cache(二级)← 较快,较大,每个核心私有  
└── L3 Cache(三级)← 较慢,最大,所有核心共享

image
 考虑这样一个问题,对于上面的三种数据(512512 768KB, 10241024 3MB, 3200*2400 21.9MB)在一个L3缓存大小为8MB的机器上,执行翻转动作,时间的情况。首先我们需要知道,L1, L2缓存是每个核心内部私有的,L3才是一个CPU上多个核心共享的缓存。所以对于串行程序,程序执行的时候,就会随机绑定到CPU的一个核上,用自己私有的两个缓存,加上外层的L3缓存,完成程序。对于并行,就是多核利用自己的缓存,竞争使用L3来完成程序。知道了这些之后,根据前面的分析,磁盘读取图片存到主存,CPU里面的核从主存读取数据放到缓存里面然后计算,那么对于前两种数据,他们的大小都小于L3缓存,所以cache每次都可以命中,不需要多次去主存找图片数据。但是对于第三种数据,大于L3缓存,也就是说,只能有部分的数据能够被放入L3缓存里面,大多数情况不会命中,需要去主存找,从而效率变慢。

// 1024*1024
水平翻转(H): 8.2326 ms  (7.851 ns/像素)
垂直翻转(V): 9.9302 ms  (9.470 ns/像素)

结论:水平比垂直快约20%,差距不大
原因:3MB < 8MB,**完全在L3内**,Cache都命中
// 3200*2400
水平翻转(H):  59.8295 ms  (7.790 ns/像素)
垂直翻转(V): 116.5194 ms  (15.172 ns/像素)  ⚠️ 接近2倍!

结论:垂直翻转严重恶化,水平翻转相对稳定

 原因是访问内存比访问片内缓存数据更大,导致访问内存不确定性,也就是有灾难性的内存访问。

// 小图
L3 Cache (8MB)
├── 图像 0.75MB ✓ 一次性装入
└── 剩余 7.25MB 空闲

129次重复执行:
    第1次:从DRAM→L3(稍慢)
    第2-129次:纯L3访问(极快)
    无Cache失效,性能稳定
// 大图
L3 Cache (8MB)
├── 只能装 8MB / 22MB = 36% 的数据
└── 64% 的数据必须在DRAM中

处理过程:
    访问区域A → 装入L3 → 处理
    访问区域B → 驱逐A → 装入B → 处理  
    访问区域C → 驱逐B → 装入C → 处理
    ...
    再次访问A → A已被驱逐 → 重新从DRAM加载!
    
    ⚠️ Cache抖动(Thrashing):不断清空-填充,效率暴跌

 所以后续就是要优化:让工作集匹配Cache容量,让访问模式匹配存储布局(行存储)

程序性能 = f(您的设计, 硬件行为)

您的设计:
    1. 数据总量(工作集)→ 决定能否装入某级Cache
    2. 访问顺序(行/列)→ 决定Cache利用效率

硬件行为:
    局部性原理 → 自动预取相邻数据,但前提是"相邻访问"
    Cache替换策略 → LRU(最近最少用),自动但被动

匹配成功:工作集 < Cache容量 + 访问连续 → 性能极佳(小图片,水平翻转)
匹配失败:工作集 > Cache容量 或 访问跳跃 → 性能暴跌(大图片,垂直翻转)

image
image
 到这里,我们知道了1)水平翻转比垂直翻转快的原因,即行优先存储下局部性原理导致的Cache多命中;2)即使图片变大(超过L3缓存),水平翻转依旧比垂直翻转快(但是由于超过L3缓存,都要访问DRAM,所以这两个都会慢于前面第一种情况),并且没有垂直翻转的灾难性内存访问,这是因为垂直翻转Cache命中率小,需要多次访问DRAM主存。那么既然垂直翻转慢是因为DRAM访问多,并行优化的时候多线程都要竞争访问DRAM,反而会负优化;可以用Buffer技巧把数据分批装入L1从而避开DRAM,我们后续会涉及。

垂直翻转的困境:
    慢的原因:DRAM访问太多
    并行风险:多线程 → 更多并发DRAM请求 → 带宽饱和 → 更慢
    解决思路:Buffer → 批量读入L1 → 处理时只访问L1 → 避开DRAM

image
 这是在把串行程序按照最简单的数据划分之后的并行程序在多个线程下的实验分析。4C/8T,说明是有超线程,得到这几个结论:首先不管是水平还是垂直,线程不是越多越好,主要原因是线程对于资源的竞争,会影响操作的时间。比如水平翻转,会竞争L3Cache,垂直翻转竞争主存的访问;垂直翻转本来就是DRAM密集型,多线程反而加剧了内存的带宽竞争;超线程的作用有限,甚至有害,因为超线程线程多了,对于垂直翻转,就会竞争。
 第一个问题:结果会不同,并且更差。原因是核心数量减半,并行度上限减半,并且不支持超线程,线程数量超过2后性能快速饱和。
 第二个问题:结果会更好,但是提升有限,因为DRAM的访问特性,6核的时候内存带宽可能饱和,超线程反而带来竞争。
 第三个问题和第四个问题前面已经分析过了,就是考虑L3大小和程序的局部性原理来分析。
image
 后面的内容都在分析存储密集型,也就是对于并行优化再进行优化,从减少DRAM的角度进行分析,把数据存入Buffer装入L1,从而减少DRAM访问,不深究。

PPT逻辑链条:

1. 定义问题  →  翻转是"存储密集型"(您的理解✓)
       ↓
2. 分析现状  →  当前代码逐字节访问DRAM(效率低)
       ↓
3. 指出瓶颈  →  DRAM访问次数太多(您的理解✓)
       ↓
4. 提出方向  →  用Buffer批量装入L1,减少DRAM访问
       ↓
5. 后续验证  →  imflipPM.c 的优化效果

6 矩阵计算

#include <stdio.h>
#include <stdlib.h>

// 考虑一个A是M*N的矩阵,x是一个N的向量,y=A*x
#define M 1000
#define N 1000

int main()
{
    double A[M][N];
    double x[N];

    // 矩阵*向量,每一行跟向量来乘,得到M的向量
    double y[M];

    // 初始化
    for(int i = 0; i < M; i++)
    {
        for(int j = 0; j < N; j++)
        {
            A[i][j] = 1.0;
        }
    }

    for (int i = 0; i < N; i++)
    {
        x[i] = 1.0;
    }

    // 串行代码,外层遍历矩阵的每一行
    for (int i = 0; i < M; i++)
    {
        // 初始化y[i]
        y[i] = 0;
        // 内层遍历这一行的每一列,这一行的每一列的元素相乘求和就是这一行的结果
        for (int j = 0; j < N; j++)
        {
            y[i] += A[i][j] * x[j];
        }
    }

    // 方法2:统计验证(推荐)
    int correct = 1;
    for (int i = 0; i < M; i++) {
        if (y[i] != 1000.0) {  // 期望N=1000
            correct = 0;
            printf("错误: y[%d] = %f\n", i, y[i]);
            break;
        }
    }
    printf("验证结果: %s (期望所有值 = %d)\n", 
           correct ? "✓ 正确" : "✗ 错误", N);

    return 0;
}
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

#define M 1000
#define N 1000

// 定义线程数量
#define thread_count 4

double A[M][N];
double x[N];

double y[M];

void* thread_func(void* arg)
{
    int my_rank = *(int*)arg;

    // 并行的思路是,每个线程处理自己的这部分行
    int my_n = M / thread_count;
    int my_start = my_rank * my_n;
    int my_end = my_start + my_n;

    // 并行代码,外层遍历矩阵的属于自己的这几行
    for (int i = my_start; i < my_end; i++)
    {
        // 初始化y[i]
        // 注意这里不需要互斥量,因为每个线程写的内容都是这个答案的不同元素,也就是没有多个线程访问同一个的情况
        y[i] = 0;
        // 内层遍历这一行的每一列,这一行的每一列的元素相乘求和就是这一行的结果
        // 并行里面内层还是每一列,所以是0-N
        for (int j = 0; j < N; j++)
        {
            y[i] += A[i][j] * x[j];
        }
    }

    pthread_exit(NULL);
}

int main()
{
    // 初始化
    for(int i = 0; i < M; i++)
    {
        for(int j = 0; j < N; j++)
        {
            A[i][j] = 1.0;
        }
    }

    for (int i = 0; i < N; i++)
    {
        x[i] = 1.0;
    }

    pthread_t threads[thread_count];
    int ranks[thread_count];

    for (int i = 0; i < thread_count; i++)
    {
        ranks[i] = i;
        pthread_create(&threads[i], NULL, thread_func, &ranks[i]);
    }

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

    // 方法2:统计验证(推荐)
    int correct = 1;
    for (int i = 0; i < M; i++) {
        if (y[i] != 1000.0) {  // 期望N=1000
            correct = 0;
            printf("错误: y[%d] = %f\n", i, y[i]);
            break;
        }
    }
    printf("验证结果: %s (期望所有值 = %d)\n", 
           correct ? "✓ 正确" : "✗ 错误", N);

    return 0;
}
#include <stdio.h>
#include <stdlib.h>

// 这个大小还不能取太大,会栈溢出
#define SIZE 10

int main()
{
    double A[SIZE][SIZE];
    double B[SIZE][SIZE];

    // C = A * B
    double C[SIZE][SIZE];

    for (int i = 0; i < SIZE; i++)
        for (int j = 0; j < SIZE; j++) {
            A[i][j] = 1.0;
            B[i][j] = 1.0;
        }

    // 串行算法,外层遍历A的行
    for (int i = 0; i < SIZE; i++)
    {
        // 第二层循环,遍历B的列
        for (int j = 0; j < SIZE; j++)
        {
            // 矩阵运算,A的第一行*B的第一列所有行得到C的第一行第一列,随后第一行*第二列所有行得到C的第一行第二列
            // 根据矩阵运算,A的行和B的列就是答案C的行和列,所以初始化C
            C[i][j] = 0;
            // 第三层循环,用来遍历A的这一行的所有元素和B的这一列的所有元素
            for (int k = 0; k < SIZE; k++)
            {
                C[i][j] += A[i][k] * B[k][j];
            }
        }
    }

    int correct = 1;
    for (int i = 0; i < SIZE; i++)
        for (int j = 0; j < SIZE; j++)
            if (C[i][j] != SIZE) {
                correct = 0;
                printf("错误: C[%d][%d] = %f\n", i, j, C[i][j]);
                break;
            }

    printf("验证结果: %s\n", correct ? "✓ 正确" : "✗ 错误");

    return 0;
}
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

#define SIZE 12

double A[SIZE][SIZE];
double B[SIZE][SIZE];

double C[SIZE][SIZE];

// 并行的线程数量
#define thread_count 4

void* thread_func(void* arg)
{
    int my_rank = *(int*)arg;

    // 并行思路,把A矩阵切分给每个线程,B矩阵作为全局变量
    int my_n = SIZE / thread_count;
    int my_start = my_n * my_rank;
    int my_end = my_start + my_n;

    // 外层遍历每个属于每个线程的这几行
    for (int i = my_start; i < my_end; i++)
    {
        // 第二层遍历全局B的所有列
        for (int j = 0; j < SIZE; j++)
        {
            // 同样的行列和C的对应,并且不存在临界区所以不用互斥
            C[i][j] = 0;
            // 最内层用来遍历行和列的每一个元素
            for (int k = 0; k < SIZE; k++)
            {
                C[i][j] += A[i][k] * B[k][j];
            }
        }
    }

    pthread_exit(NULL);
}

int main()
{
    // 初始化
    for (int i = 0; i < SIZE; i++)
        for (int j = 0; j < SIZE; j++) {
            A[i][j] = 1.0;
            B[i][j] = 1.0;
        }

    pthread_t threads[thread_count];
    int ranks[thread_count];

    for (int i = 0; i < thread_count; i++)
    {
        ranks[i] = i;
        pthread_create(&threads[i], NULL, thread_func, &ranks[i]);
    }

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


    // 验证
    int correct = 1;
    for (int i = 0; i < SIZE; i++)
        for (int j = 0; j < SIZE; j++)
            if (C[i][j] != SIZE) {
                correct = 0;
                printf("错误: C[%d][%d] = %f\n", i, j, C[i][j]);
                break;
            }

    printf("验证结果: %s\n", correct ? "✓ 正确" : "✗ 错误");

    return 0;
}
posted @ 2026-03-23 15:51  yyyyhc0214  阅读(7)  评论(0)    收藏  举报