Parallel Systems · บทที่ 5 จาก 9

Programming Models: MPI & CUDA

สถาปัตยกรรมในบทที่ 4 (DSM/MPP/Cluster เทียบกับ SIMD/GPU) ต้องการวิธีเขียนโปรแกรมที่ต่างกันโดยสิ้นเชิง — บทนี้ลงมือดูโค้ดจริงสองตระกูลหลัก: message-passing (MPI) สำหรับ cluster และ SIMT (CUDA) สำหรับ GPU

💻
โค้ดตัวอย่างในบทนี้มาจากไฟล์ตัวอย่างประกอบวิชาเดิมโดยตรง (3_sample_mpi_pi.c, gpu1.cu, gpu2.cu) — เป็นโค้ดคลาสสิกที่ใช้สอน MPI และ CUDA กันอย่างแพร่หลาย (ตัวอย่าง MPI-pi มาจากชุดตัวอย่างมาตรฐานของ MPICH ส่วนตัวอย่าง CUDA matrix multiplication มาจาก NVIDIA CUDA Programming Guide)

1สามตระกูลหลักของ Parallel Programming Model

โมเดลเหมาะกับสถาปัตยกรรมวิธีแลกเปลี่ยนข้อมูล
Shared-Memory (Threads/OpenMP)SMP / multicore เครื่องเดียว (บทที่ 4)อ่าน/เขียน memory ที่ใช้ร่วมกันโดยตรง ต้องใช้ lock/mutex ป้องกัน race condition
Message-Passing (MPI)Cluster / MPP / DSM ที่แต่ละโหนดมี memory แยกกันส่ง/รับข้อความชัดเจนระหว่างโหนด ไม่มี memory ที่ใช้ร่วมกันเลย
SIMT (CUDA/GPU)GPU (SIMD-style ตามบทที่ 4)เธรดหลักพัน-หมื่นตัวรัน "kernel" เดียวกัน ใช้ shared memory ระดับ block และ global memory ระดับทั้ง GPU

2Message-Passing Interface (MPI)

แนวคิด
มาตรฐาน (ไม่ใช่ภาษาโปรแกรม) สำหรับเขียนโปรแกรม SPMD (บทที่ 4) บนคลัสเตอร์ — ทุก process รันโค้ดเดียวกัน แต่ตัดสินใจต่างกันตาม rank (หมายเลขประจำตัว) ของตัวเอง สื่อสารกันด้วยการเรียกฟังก์ชัน MPI โดยตรง ไม่ใช่ shared memory

ตัวอย่าง: คำนวณค่า π ด้วยการหาพื้นที่ใต้กราฟ (numerical integration)

#include "mpi.h"
#include <math.h>

int main(argc,argv)
int argc; char *argv[];
{
    int done = 0, n, myid, numprocs, i;
    double PI25DT = 3.141592653589793238462643;
    double mypi, pi, h, sum, x;

    MPI_Init(&argc,&argv);
    MPI_Comm_size(MPI_COMM_WORLD,&numprocs);
    MPI_Comm_rank(MPI_COMM_WORLD,&myid);
    while (!done) {
        if (myid == 0) {
            printf("Enter the number of intervals: (0 quits) ");
            scanf("%d",&n);
        }
        MPI_Bcast(&n, 1, MPI_INT, 0, MPI_COMM_WORLD);
        if (n == 0) break;

        h = 1.0 / (double) n;
        sum = 0.0;
        for (i = myid + 1; i <= n; i += numprocs) {
            x = h * ((double)i - 0.5);
            sum += 4.0 / (1.0 + x*x);
        }
        mypi = h * sum;

        MPI_Reduce(&mypi, &pi, 1, MPI_DOUBLE, MPI_SUM, 0, MPI_COMM_WORLD);
        if (myid == 0)
            printf("pi is approximately %.16f, Error is %.16f\n",
                   pi, fabs(pi - PI25DT));
    }
    MPI_Finalize();
    return 0;
}
ฟังก์ชัน MPIทำหน้าที่อะไร
MPI_Init / MPI_Finalizeเริ่มต้น/สิ้นสุดสภาพแวดล้อม MPI ต้องเรียกครอบทุกอย่างเสมอ
MPI_Comm_size / MPI_Comm_rankถามว่า "ทั้งหมดมีกี่ process" (numprocs) และ "ฉันคือ process ลำดับที่เท่าไร" (myid) — นี่คือกลไก SPMD ที่ทำให้แต่ละ process รู้บทบาทของตัวเอง
MPI_BcastBroadcast — process 0 อ่านค่า n จากผู้ใช้แล้ว "กระจาย" ค่าเดียวกันไปให้ทุก process อื่นรู้ตรงกัน
for (i = myid + 1; i <= n; i += numprocs)นี่คือหัวใจของdata parallelism (บทที่ 3) — แต่ละ process คำนวณเฉพาะ interval ที่เป็นของตัวเอง (แบ่งแบบ round-robin ตาม stride ของ numprocs) ไม่ทับซ้อนกัน
MPI_Reduce(..., MPI_SUM, 0, ...)Reduction — รวมผลลัพธ์ mypi จากทุก process เข้าด้วยกันด้วยการบวก (SUM) แล้วเก็บผลรวมไว้ที่ process 0 เท่านั้น — เป็นรูปแบบ "collective operation" ที่พบบ่อยที่สุดใน MPI
ทำไมตัวอย่างนี้ถึงสอนอะไรได้เยอะ
โค้ดเพียง 30 บรรทัดนี้ครอบคลุมแนวคิดหลักของ MPI ครบ: SPMD execution, broadcast (แจกข้อมูลร่วม), data-parallel work distribution (แบ่งงานตาม rank), และ reduction (รวมผลลัพธ์) — โปรแกรม MPI ขนาดใหญ่แทบทั้งหมดสร้างจากรูปแบบ 4 อย่างนี้ผสมกัน

3CUDA: เขียนโปรแกรมสำหรับ GPU

แนวคิด SIMT (Single Instruction, Multiple Threads)
GPU จัดกลุ่ม thread เป็น block และจัดกลุ่ม block เป็น grid — โค้ดที่รันบน GPU เรียกว่า kernel ถูกรันโดยทุก thread พร้อมกัน แต่ละ thread รู้ตำแหน่งของตัวเองผ่าน threadIdx/blockIdx (คล้ายกับที่ MPI ใช้ rank) เพื่อคำนวณว่าตัวเองรับผิดชอบข้อมูลส่วนไหน

เวอร์ชันไร้เดียงสา: หนึ่งเธรดต่อหนึ่งสมาชิกของเมทริกซ์ผลลัพธ์

__global__ void MatMulKernel(Matrix A, Matrix B, Matrix C)
{
    float Cvalue = 0;
    int row = blockIdx.y * blockDim.y + threadIdx.y;
    int col = blockIdx.x * blockDim.x + threadIdx.x;
    for (int e = 0; e < A.width; ++e)
        Cvalue += A.elements[row * A.width + e] * B.elements[e * B.width + col];
    C.elements[row * C.width + col] = Cvalue;
}
จุดอ่อนของเวอร์ชันนี้
แต่ละ thread อ่านค่าแถวเดียวกันของ A และคอลัมน์เดียวกันของ Bซ้ำ ๆ กันหลายครั้งโดยตรงจาก global memory (memory หลักของ GPU ซึ่งช้าที่สุด) — thread ข้างเคียงกันจำนวนมากอ่านข้อมูลก้อนเดียวกันซ้ำโดยไม่มีการใช้ซ้ำ (reuse) เลย เสียเวลาไปกับการรอ memory มากกว่าการคำนวณจริง

เวอร์ชันปรับปรุง: ใช้ Shared Memory แบบ Tiling

// โหลดชิ้นส่วนย่อย (tile) ของ A และ B มาไว้ใน shared memory ก่อน
__shared__ float As[BLOCK_SIZE][BLOCK_SIZE];
__shared__ float Bs[BLOCK_SIZE][BLOCK_SIZE];
As[row][col] = GetElement(Asub, row, col);
Bs[row][col] = GetElement(Bsub, row, col);
__syncthreads();               // รอให้ทุกเธรดใน block โหลดเสร็จก่อน

for (int e = 0; e < BLOCK_SIZE; ++e)
    Cvalue += As[row][e] * Bs[e][col];   // อ่านจาก shared memory (เร็วกว่า global มาก)
__syncthreads();               // รอให้ทุกเธรดคำนวณ tile นี้เสร็จก่อนโหลด tile ถัดไป
แนวคิดประโยชน์
Shared MemoryMemory ความเร็วสูงที่ thread ใน block เดียวกันใช้ร่วมกันได้ — เร็วกว่า global memory มาก (มักเร็วกว่าเป็นสิบเท่า) แต่มีขนาดเล็กและใช้ได้เฉพาะภายใน block เดียว
Tilingแบ่งเมทริกซ์เป็นชิ้นย่อย (tile) ขนาด BLOCK_SIZE×BLOCK_SIZE โหลดเข้า shared memory ครั้งเดียวแล้วใช้ซ้ำโดยทุก thread ใน block แทนที่จะอ่านจาก global memory ซ้ำแล้วซ้ำเล่า
__syncthreads()จุด synchronization บังคับให้ทุก thread ใน block "รอกัน" ก่อนไปต่อ — จำเป็นเพื่อรับประกันว่าข้อมูลใน shared memory ถูกโหลดครบก่อนที่ thread ใดจะเริ่มอ่านไปใช้คำนวณ
บทเรียนที่สำคัญที่สุดของทั้งสองเวอร์ชัน
Logic การคำนวณ (คูณเมทริกซ์) เหมือนกันทุกประการทั้งสองเวอร์ชัน — สิ่งที่ต่างกันคือรูปแบบการเข้าถึง memory เพียงอย่างเดียว แต่ทำให้ความเร็วต่างกันได้หลายเท่าตัว นี่คือบทเรียนกลางของการเขียนโปรแกรม GPU ทั้งหมด: compute มักไม่ใช่คอขวด — memory bandwidth ต่างหากที่เป็นคอขวดตัวจริง (แนวคิดเดียวกับที่ STREAM benchmark วัดโดยตรง ดูบทที่ 6)

4MPI และ CUDA ในเครื่องมือปัจจุบัน

แล้ว · โค้ดต้นฉบับ (~2010)
ตอนนี้ · 2026
เขียน MPI ด้วยมือทุกบรรทัด กำหนด broadcast/reduce เองทุกจุด
MPI ยังคงเป็นมาตรฐานหลักของงาน HPC จริงจัง (ซูเปอร์คอมพิวเตอร์ Top500 เกือบทั้งหมดยังใช้ MPI อยู่) แต่งานระดับ data science ทั่วไปมักใช้เครื่องมือระดับสูงกว่าแทน เช่น Dask, Ray, หรือ Apache Spark ที่ซ่อนรายละเอียด broadcast/reduce ไว้เบื้องหลัง API ที่ใช้ง่ายกว่า
เขียน CUDA kernel เองทุกตัว ต้อง tiling เองด้วยมือแบบ gpu2.cu
งาน deep learning สมัยใหม่แทบไม่มีใครเขียน matrix multiplication kernel เองแล้ว — ใช้ไลบรารีที่ optimize โดยผู้เชี่ยวชาญมาแล้ว เช่น cuBLAS/cuDNN (NVIDIA เอง) หรือเรียกผ่าน framework อย่าง PyTorch/TensorFlow ที่เลือก kernel ที่เหมาะสมให้อัตโนมัติ — แต่เมื่อไรที่ต้องการ kernel พิเศษที่ไลบรารีสำเร็จรูปไม่มี ภาษาอย่าง Triton (จาก OpenAI) ทำให้เขียน custom GPU kernel ได้ในระดับที่ใกล้เคียง Python มากกว่า CUDA C แบบดั้งเดิม
GPU ยุคนั้นเน้น FP32 (single-precision) เป็นหลัก ยังไม่มีหน่วยเฉพาะทางสำหรับ AI
GPU สมัยใหม่มี Tensor Core — หน่วยฮาร์ดแวร์เฉพาะทางสำหรับคูณเมทริกซ์ขนาดเล็กแบบ mixed-precision (FP16/BF16/INT8) โดยตรง เร็วกว่าการคูณเมทริกซ์แบบทั่วไปด้วย CUDA core มาก และเป็นกำลังหลักเบื้องหลังการเทรน AI ยุคปัจจุบันทั้งหมด — หลักการ tiling และ shared memory ที่ gpu2.cu สอนไว้ยังคงเป็นรากฐานที่ Tensor Core ใช้ภายใน เพียงแต่ทำโดยฮาร์ดแวร์โดยตรงแทนโค้ดที่เขียนเอง
ทำไมยังต้องเรียนเขียนโค้ดระดับล่างแบบนี้อยู่
แม้จะมีไลบรารีระดับสูงให้ใช้แล้ว แต่การเข้าใจว่า tiling/shared memory ทำงานอย่างไรจากตัวอย่าง gpu1.cu vs gpu2.cu ทำให้เข้าใจเหตุผลว่าทำไมไลบรารีสำเร็จรูปถึงเร็ว และช่วยวินิจฉัยปัญหาประสิทธิภาพได้เมื่อโค้ดระดับสูงทำงานช้ากว่าที่ควร — เป็นทักษะพื้นฐานเดียวกับที่วิศวกร ML performance ระดับสูงยังต้องใช้จริงเมื่อต้อง optimize kernel พิเศษที่ไลบรารีมาตรฐานไม่รองรับ
คำถามซ้อมสอบ
  1. อธิบายว่าโค้ด MPI-pi ใช้ MPI_Bcast และ MPI_Reduce ทำหน้าที่อะไรต่างกันอย่างไร
  2. อธิบายว่าบรรทัด for (i = myid + 1; i <= n; i += numprocs) implement data parallelism (บทที่ 3) อย่างไร
  3. อธิบายความแตกต่างระหว่าง gpu1.cu และ gpu2.cu และเหตุผลที่ทำให้เวอร์ชันหลังเร็วกว่า
  4. อธิบายว่าทำไม __syncthreads() จึงจำเป็นในเวอร์ชัน tiling แต่ไม่มีในเวอร์ชันไร้เดียงสา
  5. อธิบายว่าทำไมบทเรียนเรื่อง memory bandwidth เป็นคอขวด (ไม่ใช่ compute) จึงยังคงสำคัญ แม้ปัจจุบันจะมีไลบรารีสำเร็จรูปอย่าง cuBLAS ให้ใช้แล้ว

5Programming Model คือสัญญาระหว่างความคิดกับเครื่อง

Programming model ไม่ใช่เพียงชุดคำสั่ง แต่กำหนดว่าโปรแกรมเมอร์มองงาน ข้อมูล และการสื่อสารอย่างไร Shared-memory model ทำให้หลาย thread เห็น address space เดียว Message-passing model บังคับให้ส่งข้อมูลอย่างชัดเจน ส่วน accelerator model แยก host กับ device และจัดงานเป็น kernels

Modelสิ่งที่โปรแกรมเมอร์ต้องระบุสิ่งที่ runtime ช่วยจัดการ
Threads/OpenMPParallel region, shared/private data และ synchronizationสร้างทีม thread, schedule loop และ barrier
MPIใครส่งอะไรให้ใคร ชนิดข้อมูล tag และ communicatorRouting, buffering บางส่วน และ collective algorithms
CUDAKernel, grid/block, memory placement และ synchronizationSchedule thread blocks และซ่อน latency ผ่าน warps
Task/DataflowTasks, dependency และ resource requirementReady queue, work stealing และ placement บางส่วน
ภาพเปรียบเทียบ
Programming model เหมือนภาษาที่ใช้สั่งทีมงาน ถ้าทุกคนอยู่ห้องเดียวกัน เราชี้เอกสารบนโต๊ะกลางได้ แต่ถ้าอยู่คนละสาขาต้องระบุผู้ส่ง ผู้รับ และเนื้อหาให้ครบ ส่วน GPU เหมือนโรงงานเฉพาะทางที่รับงานเป็นชุดใหญ่ การส่งงานเล็กทีละชิ้นอาจเสียเวลาเปิดประตูโรงงานมากกว่าผลิตจริง

6OpenMP: สะพานจากโปรแกรมลำดับไปสู่ Shared Memory

แม้ชื่อบทเน้น MPI และ CUDA แต่ OpenMP เป็นจุดเริ่มที่สำคัญสำหรับ multicore เพราะเปิด parallel region และแบ่ง loop ด้วย directive โดยยังใช้หน่วยความจำร่วม เหมาะกับการทดลอง decomposition ก่อนขยายไป distributed memory

#pragma omp parallel for reduction(+:sum) schedule(static)
for (long i = 0; i < n; ++i) {
    sum += a[i];
}

reduction สร้างผลย่อยต่อ thread แล้วรวมอย่างปลอดภัย ดีกว่าให้ทุก thread atomic update ตัวแปรเดียวทุก iteration ซึ่งสร้าง contention ส่วน schedule(static) เหมาะเมื่องานแต่ละ iteration ใกล้เคียงกัน หากไม่เท่ากันอาจใช้ dynamic หรือ guided แต่ต้องจ่าย scheduling overhead เพิ่ม

คำถามเรื่อง Data Scope

ใส่ parallel for แล้วผลถูก ไม่ได้แปลว่าเร็ว
Loop เล็กอาจแพ้ต้นทุนสร้างทีมและ barrier การเขียน array ติดกันอาจเกิด false sharing และ nested library อาจสร้าง threads ซ้อนจน oversubscribe ต้องวัดจำนวน threads, affinity และขนาดงานด้วย

7MPI Process, Rank และ Communicator

MPI สร้าง processes ที่มี address space แยกกัน แต่ละ process มี rank ภายใน communicator Rank ไม่ใช่ชื่อเครื่องถาวร แต่เป็นหมายเลขสมาชิกในกลุ่มการสื่อสารเดียวกัน

การแยก communicator มีประโยชน์เมื่อ algorithm มีหลายกลุ่ม เช่น process rows กับ columns ใน matrix grid Message ที่มี source/tag เหมือนกันแต่คนละ communicator จะไม่ชนกัน ช่วยให้ library หลายชิ้นใช้ MPI ร่วมกันได้โดยไม่แย่ง namespace

8Point-to-Point Communication: ส่ง รับ และความหมายของคำว่าเสร็จ

MPI_Send กับ MPI_Recv ดูตรงไปตรงมา แต่คำว่า send เสร็จอาจหมายถึง buffer ต้นทางใช้ซ้ำได้ ไม่จำเป็นต้องหมายถึงผู้รับประมวลผลแล้ว MPI implementation อาจ buffer ข้อมูลหรือรอ rendezvous ตามขนาด message

รูปแบบพฤติกรรมใช้เมื่อ
BlockingCall คืนเมื่อ buffer ปลอดภัยตาม semanticsโค้ดง่าย ลำดับชัด และ overlap ไม่จำเป็น
NonblockingMPI_Isend/Irecv คืน request แล้วใช้ Wait/Test ภายหลังซ้อน communication กับ computation
Synchronous sendผู้ส่งรอจนผู้รับเริ่มรับต้องการ handshake และไม่พึ่ง buffering
Buffered sendคัดลอกไป buffer ที่ผู้ใช้จัดให้ต้องควบคุม buffer โดยชัดเจน
จดหมายกับใบรับฝาก
การหย่อนจดหมายแล้วได้ใบรับฝากหมายถึงเราใช้ซองเดิมต่อได้ แต่ไม่ได้แปลว่าผู้รับอ่านแล้ว Nonblocking send ก็คล้ายกัน ต้องเก็บ request และรอ completion ก่อนแก้ buffer ที่ยังถูกใช้งาน

9Deadlock ใน MPI

ถ้าทุก process เรียก blocking send ของ message ใหญ่ไปหาเพื่อนก่อนเรียก receive ทุกคนอาจรอกัน เพราะ implementation ไม่มี buffer พอ วิธีที่ดูทำงานบน input เล็กอาจค้างเมื่อ message โต

// เสี่ยง deadlock: ทั้งสองฝั่งส่งก่อนรับ
MPI_Send(... peer ...);
MPI_Recv(... peer ...);

// ทางเลือก
MPI_Sendrecv(...);
// หรือ post MPI_Irecv ก่อน MPI_Isend แล้ว Waitall

การป้องกันทำได้ด้วยลำดับส่ง/รับที่ไม่เป็นวง ใช้ MPI_Sendrecv หรือ post receives ล่วงหน้า สิ่งสำคัญคืออย่าพึ่งพา eager buffering ซึ่งเป็นรายละเอียด implementation ไม่ใช่ guarantee ของ algorithm

10Collective Communication: ใช้ความหมายระดับกลุ่ม

Collectiveความหมายตัวอย่าง
Broadcastหนึ่ง process ส่งค่าเดียวให้ทุกคนแจก configuration หรือ matrix tile
Scatter/Gatherแบ่งข้อมูลออก / รวบรวมส่วนกลับแบ่ง array และรวมผล
Reduce/Allreduceรวมค่าด้วย operation และเก็บที่ root/ทุกคนผลรวม, max และ gradient
Allgatherทุกคนแชร์ส่วนของตนให้ทุกคนรวบรวม metadata
All-to-allทุก process ส่งคนละส่วนให้ทุก processTranspose หรือ repartition

Collective ไม่ใช่เพียง shorthand ของ loop ส่งทีละคน Library เลือก tree, ring, recursive doubling หรือ hierarchical algorithm ตาม message size และ topology จึงมักเร็วและปลอดภัยกว่าการเขียนเอง

ทุกคนต้องเรียกให้สอดคล้องกัน
ถ้าบาง rank เรียก Allreduce แต่บาง rank ข้ามไป collective ถัดไป โปรแกรมอาจค้างหรือจับคู่ผิด Collective เป็นสัญญาของทั้ง communicator ไม่ใช่ function ส่วนตัวของ rank หนึ่ง

11Communication Cost: Latency กับ Bandwidth

โมเดล α–β
Tmessage(n) ≈ α + βn
α คือต้นทุนเริ่ม message หรือ latency, β คือต้นทุนต่อ byte และ n คือขนาดข้อมูล Message เล็กจำนวนมากจ่าย α ซ้ำ ส่วน message ใหญ่เริ่มถูกจำกัดด้วย bandwidth

แนวทางลดต้นทุนจึงมีทั้งรวม message เล็ก, ลดจำนวนรอบ, overlap กับ compute และเปลี่ยน decomposition ให้สื่อสารเฉพาะเพื่อนบ้าน แต่การ batch ใหญ่เกินไปเพิ่มเวลารอสะสมข้อมูลและ memory footprint ต้องเลือกตาม latency target

12Domain Decomposition และ Halo Exchange

Simulation grid มักแบ่งพื้นที่เป็น blocks แต่ละ rank คำนวณภายในของตน ส่วนเซลล์ขอบต้องใช้ค่าจากเพื่อนบ้าน จึงเก็บ ghost/halo cells และแลกค่าขอบทุก iteration

  1. Post nonblocking receives จากเพื่อนบ้าน
  2. Post nonblocking sends ของ boundary
  3. คำนวณ interior ที่ไม่ต้องรอข้อมูล
  4. Wait ให้ halo มาถึง
  5. คำนวณ boundary แล้วเข้าสู่รอบถัดไป

โครงนี้ overlap communication กับ computation ได้ หาก block เล็กเกินไป surface-to-volume ratio สูง ข้อมูลขอบมีสัดส่วนมากและ scaling แย่ นี่เชื่อม granularity กับ geometry ของ decomposition โดยตรง

13MPI Derived Datatype และ Packing

ข้อมูลที่ต้องส่งอาจไม่ต่อเนื่อง เช่น column ของ matrix ใน row-major layout เราสามารถ pack ใส่ buffer ต่อเนื่องเอง หรือสร้าง derived datatype ให้ MPI รู้ stride และ layout

Derived datatype ทำให้โค้ดสื่อความหมายและเปิดโอกาสให้ implementation optimize แต่ไม่ได้รับประกันว่าจะไม่มีการ pack ภายใน ต้องวัดเทียบ manual packing โดยเฉพาะ layout ซับซ้อน การจัดข้อมูลใหม่ให้ communication-friendly อาจดีกว่าพึ่ง datatype แก้ทุกครั้ง

14One-Sided Communication

MPI RMA เปิดให้ process อ่านเขียน window ของอีก process ผ่าน Put/Get/Accumulate โดยผู้รับไม่ต้อง post receive ที่ตรงกันทุก message เหมาะกับ irregular access หรือ shared-data abstraction บางรูปแบบ

แต่ one-sided ไม่ได้แปลว่าไม่ต้อง synchronize ต้องกำหนด epoch และ visibility ว่าเมื่อใดข้อมูลใช้ได้ ความซับซ้อนย้ายจากการจับคู่ send/receive ไปสู่ memory model และ synchronization semantics จึงควรเลือกเพราะ pattern เหมาะ ไม่ใช่เพราะชื่อฟังดูเร็วกว่า

15CUDA Execution Hierarchy

Kernel ถูกเรียกด้วย grid ซึ่งมี thread blocks และแต่ละ block มี threads Scheduler วาง block ลง Streaming Multiprocessor (SM) โดย blocks ต้องเป็นอิสระต่อกันในระดับที่รันลำดับใดก็ได้

Global synchronization ระหว่าง blocks ปกติต้องจบ kernel แล้วเรียก kernel ถัดไป เพราะ blocks อาจไม่ได้ resident พร้อมกัน การออกแบบที่สมมติว่าทุก block รอ barrier เดียวกันอาจ deadlock หรือใช้ไม่ได้ตาม model

16Indexing: จาก Thread ไปยังข้อมูล

int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i < n) c[i] = a[i] + b[i];

สูตรนี้ map thread ID ไป element ของ array หาก n ไม่หาร block size ลงตัวต้องตรวจขอบเขต Block size มีผลต่อ occupancy, scheduling และ memory access แต่ค่าที่หารด้วย warp size ลงตัวเป็นเพียงจุดเริ่ม ไม่ใช่คำตอบสากล

17CUDA Memory Hierarchy

หน่วยความจำขอบเขตลักษณะ
Registerต่อ threadเร็วมาก แต่ใช้มากลดจำนวน resident threads และอาจ spill
Shared memoryต่อ blockProgrammer-managed cache เหมาะกับ reuse และ collaboration
Global memoryทั้ง deviceความจุสูง latency มาก ต้องเข้าถึงแบบ coalesced
Constant/texture pathอ่านและ cache ตาม pattern เฉพาะเหมาะกับ read-only หรือ spatial access บางแบบ
Host memoryCPUต้องโอนผ่าน interconnect เว้นแต่ใช้ unified/zero-copy ตามบริบท
โต๊ะ ลิ้นชัก และโกดัง
Register คือของในมือ Shared memory คือลิ้นชักร่วมของทีม Global memory คือโกดังใหญ่ การเดินไปโกดังทุกครั้งทำให้งานช้า จึงควรนำ tile ที่ใช้ซ้ำมาวางในลิ้นชัก แต่ลิ้นชักมีพื้นที่จำกัดและหลายคนอาจเปิดช่องเดียวกันชนกัน

18Coalescing และ Memory Access Pattern

Threads ใน warp ควรอ่าน addresses ที่อยู่ต่อเนื่องหรือรวมเป็น memory transactions จำนวนน้อย หากแต่ละ thread กระโดดห่างหรืออ่านคนละทิศ ระบบต้องสร้าง transactions มากและ bandwidth ที่มีประโยชน์ลดลง

Data layout แบบ Structure of Arrays มักเหมาะกับการอ่าน field เดียวของหลาย objects มากกว่า Array of Structures เพราะค่าเดียวกันอยู่ต่อเนื่อง แต่หากแต่ละ thread ใช้ทุก fields ของ object การตัดสินอาจต่างกัน ต้องวิเคราะห์ access pattern จริง

19Shared Memory Tiling และ Bank Conflict

Matrix multiplication แบบ tiled โหลด A และ B blocks เข้า shared memory แล้ว threads ใช้ซ้ำหลายครั้ง ลด global bytes ต่อ operation หลังโหลดแต่ละ tile ต้อง synchronize threads ใน block ก่อนใช้ข้อมูลร่วม

Shared memory แบ่งเป็น banks หาก threads หลายตัวเข้าคนละ address ที่ตก bank เดียวกัน request อาจถูก serialize เรียกว่า bank conflict Padding matrix tile หนึ่ง column บางครั้งช่วยเปลี่ยน mapping และลด conflict

20Occupancy ไม่ใช่เป้าหมายสุดท้าย

Occupancy คือสัดส่วน active warps ต่อขีดจำกัด SM ช่วยซ่อน latency แต่ occupancy สูงสุดไม่ได้รับประกัน performance สูงสุด Kernel ที่ใช้ register มากอาจลด occupancy แต่คำนวณมีประสิทธิภาพและไม่ spill ส่วนการลด register เพื่อเพิ่ม occupancy อาจทำให้ local-memory traffic สูงขึ้น

เป้าหมายคือมี warps พอซ่อน latency พร้อมใช้ execution units และ bandwidth ได้ดี ต้องอ่าน profiler ทั้ง achieved occupancy, eligible warps, memory throughput, instruction mix และ stall reasons

21CUDA Streams และการซ้อน Transfer กับ Compute

Stream เป็นลำดับงานบน GPU Operations ใน stream เดียวรักษาลำดับ ส่วนคนละ streams อาจ overlap หาก hardware และ dependency อนุญาต เราสามารถแบ่งข้อมูลเป็น chunks แล้วทำ H2D transfer ของ chunk ถัดไประหว่าง kernel ประมวลผล chunk ปัจจุบัน

สายพานขนของ
ถ้ารอรถส่งวัตถุดิบครบทั้งหมดก่อนเปิดโรงงาน แล้วรอโรงงานปิดก่อนขนสินค้ากลับ เครื่องจักรและรถจะว่างสลับกัน Streams ช่วยทำให้การขนส่งและผลิตคาบเกี่ยว แต่ต้องมี pinned host memory และขนาด chunk ที่เหมาะ

22Unified Memory: ใช้ง่ายขึ้น แต่การย้ายข้อมูลยังไม่หาย

Unified Memory ให้ pointer ที่ CPU และ GPU ใช้ได้ ลดงานจัดสรรและ copy ด้วยมือ Runtime ย้าย page ตามการเข้าถึง แต่ถ้า access pattern สลับ host/device ถี่ page อาจ thrash และช้ากว่าการวางข้อมูลอย่างชัดเจน

Prefetch และ memory advice ช่วยบอกตำแหน่งใช้งานที่คาดไว้ Unified addressing เป็น abstraction ที่ดี แต่ไม่ควรตีความว่า CPU กับ GPU มีหน่วยความจำราคาเดียวกัน Data movement ยังคงอยู่ เพียงมีระบบจัดการแทนเรา

23Race Condition และการตรวจความถูกต้อง

GPU มี threads จำนวนมาก Race อาจเกิดเฉพาะบาง scheduling จึงผ่านการทดสอบหลายครั้งก่อนผิด Atomics ช่วย update ค่าร่วมอย่างปลอดภัย แต่ถ้าทุก thread atomic ที่ตำแหน่งเดียว performance จะ serialize

24Hybrid MPI + CUDA

GPU cluster มักใช้ MPI ระหว่าง processes/nodes และ CUDA ภายใน node แต่ต้องวาง rank ให้ใกล้ GPU และ NIC ที่ใช้ รวมถึงหลีกเลี่ยงให้หลาย ranks แย่ง GPU โดยไม่ตั้งใจ

  1. แบ่ง global domain ต่อ MPI rank
  2. แต่ละ rank เก็บ subdomain บน GPU ของตน
  3. คำนวณ interior ด้วย kernel
  4. แลก halo ระหว่าง GPUs ผ่าน CUDA-aware MPI หรือ stage ผ่าน host ตามระบบ
  5. Overlap halo transfer กับ interior compute

GPUDirect และ topology-aware communication ลดการ copy ผ่าน host แต่ performance ขึ้นกับ PCIe/NVLink/NIC placement การรู้เพียงจำนวน GPUs จึงไม่พอ ต้องรู้เส้นทางของ bytes

25Library, Directive และ Framework: ไม่ต้องเขียน Kernel ทุกครั้ง

ระดับตัวอย่างเหมาะเมื่อ
LibraryBLAS, cuBLAS, cuDNN, FFTOperation มาตรฐาน มี implementation ที่ optimize สูง
DirectiveOpenMP target, OpenACCต้องการค่อย ๆ offload โดยรักษาโค้ดระดับสูง
Kernel languageCUDA C/C++, HIP, SYCLต้องควบคุม layout, tiling และ synchronization
Framework/compilerPyTorch, JAX, TensorFlow, Tritonประกอบ operations และให้ compiler/runtime fuse หรือ schedule

การใช้ library ไม่ได้แปลว่าไม่ต้องเข้าใจ architecture เมื่อ performance ไม่ดี เราต้องรู้ว่า tensor layout, batch size, fusion หรือ transfer ทำให้ library ใช้ hardware ไม่เต็มตรงไหน

26กรณีศึกษา: Matrix Multiplication จาก OpenMP สู่ MPI และ CUDA

  1. เริ่มจาก blocked serial matrix multiplication เพื่อใช้ cache ให้ดี
  2. OpenMP แบ่ง output tiles ระหว่าง CPU threads
  3. MPI ใช้ 2D block distribution ระหว่าง nodes และ collective แลก panels
  4. CUDA ให้แต่ละ GPU คำนวณ local tiles ด้วย shared-memory tiling
  5. Hybrid version วางหนึ่ง rank ต่อ GPU และ overlap communication

แต่ละขั้นไม่ได้แทนขั้นก่อนทั้งหมด Blocking ยังจำเป็นบน GPU และ MPI local computation ยังต้อง optimize ภายใน node Parallelism เป็นชั้นซ้อนกัน และ implementation ที่ดีรักษา locality ในทุกชั้น

27แล็บและการตรวจงาน

แล็บ MPI
สร้าง 1D heat diffusion ด้วย halo exchange เปรียบเทียบ blocking กับ nonblocking วัด computation, communication และ overlap เมื่อเพิ่ม ranks และลดขนาด subdomain
แล็บ CUDA
ทำ matrix transpose แบบตรงไปตรงมาและแบบ shared-memory tiled วัด global load/store efficiency และ bank conflict พร้อมอธิบายว่าทำไม padding ช่วย
Design review
ให้นักศึกษาเลือก model โดยตอบหกข้อ: address space, task granularity, data placement, communication pattern, synchronization และ correctness test ห้ามตอบเพียงชื่อ API

28Debugging และ Performance Tooling

Bug ในโปรแกรมขนานมักขึ้นกับลำดับเวลาที่ไม่แน่นอน การใส่ print อาจเปลี่ยน timing จน bug หาย จึงต้องใช้ invariant, input เล็กที่ตรวจด้วยมือ และเครื่องมือเฉพาะ

อาการสาเหตุที่เป็นไปได้แนวตรวจ
ค้างบางจำนวน ranksCollective ไม่ตรง, deadlock หรือ tag mismatchTrace MPI calls และ control path ทุก rank
ผลเปลี่ยนทุกครั้งRace, uninitialized data หรือ reduction orderSanitizer, fixed seed และ reference result
CUDA พังเมื่อ input ใหญ่Out-of-bounds, indexing หรือ resource limitCompute sanitizer และ boundary tests
เร็ว node เดียวแต่ช้าหลาย nodeCommunication, placement หรือ imbalanceTimeline และ network counters

Timeline ช่วยดู compute, communication, idle และ synchronization ส่วน counter ช่วยอธิบาย occupancy, memory throughput และ stall reasons ควรใส่ application markers ให้ trace เชื่อมกับ phase ของ algorithm ได้

29Portability กับ Performance Portability

Code portability หมายถึงย้ายไปรันอีก platform ได้ ส่วน performance portability หมายถึงยังใช้ทรัพยากรได้ดีบนหลาย architecture โค้ด abstraction สูงอาจ compile ได้ทุกที่แต่ช้า เพราะ memory hierarchy และ execution width ต่างกัน

ควรแยก algorithm จาก backend ใช้ library มาตรฐาน เก็บ architecture-specific kernel เฉพาะ hot path และมี benchmark regression ต่อ platform Framework อย่าง SYCL, Kokkos หรือ directive model ช่วยได้ แต่ยังต้อง tune layout และ granularity

30Task Runtime และ Work Stealing

เมื่อ DAG irregular การกำหนด thread ต่อ task ด้วยมือไม่เหมาะ Task runtime ให้ประกาศ tasks กับ dependencies แล้ว schedule บน worker pool Work stealing ให้ worker ว่างขโมยงานจาก queue อื่น ช่วย balance โดยไม่ใช้ scheduler กลางทุกครั้ง

Task ต้องใหญ่พอและการขโมยอาจเสีย locality จึงควรมี cutoff รวม recursive tasks เล็ก และให้ hint เรื่อง affinity เมื่อข้อมูลมีราคาเคลื่อนย้ายสูง

31Checklist ก่อนส่งงาน MPI/CUDA

คำถามทบทวนการเลือก Programming Model

ก่อนเลือก OpenMP, MPI หรือ CUDA ให้เขียนคำตอบสั้น ๆ ว่า address space เป็นแบบใด งานหนึ่งชิ้นใหญ่แค่ไหน ข้อมูลต้องย้ายผ่านเส้นทางใด ใครต้องรอใคร และผลลัพธ์รวมตรงไหน หากตอบไม่ได้ การเลือก API ยังเร็วเกินไป เพราะ model ที่เหมาะไม่ได้ตัดสินจากจำนวนเครื่องอย่างเดียว แต่ตัดสินจากรูปของ dependency กับ data movement

เมื่อระบบมีหลายชั้น ควรระบุ owner ของ parallelism ด้วย เช่น MPI จัดระหว่าง nodes, OpenMP จัด cores และ CUDA จัด GPU threads เพื่อป้องกัน runtime หลายตัวสร้าง workers ซ้อนกันจน oversubscription และแย่ง bandwidth ครับ

32สรุปและขั้นตอนถัดไป

OpenMP, MPI และ CUDA เปิดให้เราแสดง parallelism คนละระดับ OpenMP แชร์หน่วยความจำในเครื่อง MPI ทำให้การสื่อสารข้าม address space ชัดเจน และ CUDA จัดงานจำนวนมากบน accelerator สิ่งที่เชื่อมทั้งสามคือ decomposition, locality, granularity และการรอ

การเขียนโปรแกรมให้รันหลาย worker เป็นเพียงครึ่งแรก ครึ่งที่ยากกว่าคือพิสูจน์ว่าเร็วขึ้นเพราะอะไรและยังคุ้มเมื่อ scale ต่อหรือไม่ บทถัดไปจึงว่าด้วย performance measurement ตั้งแต่ speedup, efficiency และ cost ไปจนถึง Karp–Flatt, isoefficiency, Roofline และ benchmark ที่เทียบกันได้ครับ