GPU 系列 - 1. GPU 的两种同步方式

GPU 系列 (Pytorch) - 1. GPU 的两种同步方式

0. pytorch 不同版本的安装

在开始说明 pytorch 不同版本的安装

在 pytorch 的官网查看安装指导 - GPU 安装 regular - CPU 安装 regular

关于 GPU 的常见误区

  • ❌“有 GPU 就一定更快”:小任务/小 batch/频繁传输时未必。
  • ❌“提速只能靠换卡”:数据加载、批量、传输模式、算子融合,往往是更便宜的提升空间。

接下来通过几个例子初步认识一下 GPU 的运行机制

1. GPU 的预热现象

例1 进行两次 GPU 乘法计算,分别查看耗时(注意,其实这里统计的时间,不是 GPU 计算乘法真正耗时)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import torch, time

N = 4000

# case1:GPU 乘法计算
a1 = torch.randn(N, N, device="cuda")
b1 = torch.randn(N, N, device="cuda")
t0 = time.perf_counter()
c1 = a1 @ b1 # GPU:第 1 次执行乘法
t1 = time.perf_counter()
print(f"[GPU] 耗时: {t1 - t0:.6f}s")

# case2:GPU 乘法计算
a2 = torch.randn(N, N, device="cuda")
b2 = torch.randn(N, N, device="cuda")
t0 = time.perf_counter()
c2 = a2 @ b2 # GPU:第 2 次执行乘法
t1 = time.perf_counter()
print(f"[GPU] 耗时: {t1 - t0:.6f}s")
执行得到以下结果:
1
2
[GPU] 耗时: 0.077134s
[GPU] 耗时: 0.001915s
可以看到,第一次耗时明显比第二次耗时更长,这是因为

  • 第 1 次计算是“冷启动”

  • 第 2 次计算是“热身后”

  • 第 1 次 GPU 计算耗时 >> 第 2 次 GPU 计算耗时

2. GPU 的预热耗时

例2 进行两次 GPU 乘法计算,分别查看耗时(注意,其实这里统计的时间,不是 GPU 计算乘法真正耗时)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import torch, time

N = 4

# case1:GPU 乘法计算
a1 = torch.randn(N, N, device="cuda")
b1 = torch.randn(N, N, device="cuda")
t0 = time.perf_counter()
c1 = a1 @ b1 # GPU:第 1 次执行乘法
t1 = time.perf_counter()
print(f"[GPU] 耗时: {t1 - t0:.6f}s")

# case2:GPU 乘法计算
a2 = torch.randn(N, N, device="cuda")
b2 = torch.randn(N, N, device="cuda")
t0 = time.perf_counter()
c2 = a2 @ b2 # GPU:第 2 次执行乘法
t1 = time.perf_counter()
print(f"[GPU] 耗时: {t1 - t0:.6f}s")
执行得到以下结果:
1
2
[GPU] 耗时: 0.078570s
[GPU] 耗时: 0.000069s
可以看到,计算量从 \(N=4000\)\(N=4\), - 第 1 次计算耗时从 0.077134s 到 0.078570s (几乎没变) - 第 2 次计算耗时从 0.001915s 到 0.000069s (显著减少) 这是因为 - 第 1 次计算时,GPU 还没有预热,需要花一些时间来初始化 - 第 2 次计算时,GPU 已经预热,所以耗时更短 - 对于 \(N=4\)\(N=4000\),可知 - 每次 GPU 预热耗时大致一样 - GPU 预热后,计算量越大 GPU 计算耗时也越大

3. GPU 的异步工作方式

例3 使用 CPU 和 GPU 进行乘法计算,分别查看耗时(注意,其实这里统计 GPU 工作时间有两种方式,第二种更科学)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import torch, time

N = 4000

# Case1: CPU 第一次计算
a1 = torch.randn(N, N)
b1 = torch.randn(N, N)
t0 = time.perf_counter()
c1 = a1 @ b1 # CPU:调用会一直等到乘法真正算完才返回
t1 = time.perf_counter()
print(f"[CPU] 耗时: {t1 - t0:.6f}s")

# Case2: CPU 第二次计算
a2 = torch.randn(N, N)
b2 = torch.randn(N, N)
t0 = time.perf_counter()
c2 = a2 @ b2 # CPU:调用会一直等到乘法真正算完才返回
t1 = time.perf_counter()
print(f"[CPU] 耗时: {t1 - t0:.6f}s")

# Case3: GPU(预热计时)
a3 = torch.randn(N, N, device="cuda")
b3 = torch.randn(N, N, device="cuda")
t0 = time.perf_counter()
c3 = a3 @ b3 # GPU:只是把任务放进队列,立即返回
t1 = time.perf_counter()
print(f"[GPU] 耗时: {t1 - t0:.6f}s")

# case4:GPU(错误计时示范)
a4 = torch.randn(N, N, device="cuda")
b4 = torch.randn(N, N, device="cuda")
t0 = time.perf_counter()
c4 = a4 @ b4 # GPU:只是把任务放进队列,立即返回
t1 = time.perf_counter()
print(f"[GPU] 耗时: {t1 - t0:.6f}s")

# case5:GPU(正确计时示范)
a5 = torch.randn(N, N, device="cuda")
b5 = torch.randn(N, N, device="cuda")
t0 = time.perf_counter()
c5 = a5 @ b5 # GPU:只是把任务放进队列,立即返回
torch.cuda.synchronize() # 【强制同步】等待GPU完成!
t1 = time.perf_counter()
print(f"[GPU] 耗时: {t1 - t0:.6f}s")

\(N = 4000\),执行得到以下结果:

1
2
3
4
5
[CPU] 耗时: 0.149812s
[CPU] 耗时: 0.119700s
[GPU] 耗时: 0.077278s
[GPU] 耗时: 0.001844s
[GPU] 耗时: 0.027026s
\(N = 4\),执行得到以下结果:
1
2
3
4
5
[CPU] 耗时: 0.000254s
[CPU] 耗时: 0.000051s
[GPU] 耗时: 0.079781s
[GPU] 耗时: 0.000082s
[GPU] 耗时: 0.000241s
可以看出: - case 1 vs 2:CPU不存在预热,CPU每次运行耗时一样 - case 3 vs 4 vs 5:GPU存在预热,GPU第一次运行耗时较长,后续运行耗时较短 - case 4:GPU异步执行模式(错误计时),耗时较短是因为 GPU 只是把任务放进队列,立即返回,没有等待计算完成 - case 5:GPU异步执行模式(正确计时),耗时较长是因为 GPU 只是把任务放进队列,立即返回,没有等待计算完成,需要【强制同步】等待 GPU 完成计算 - Case 1 vs 5 (\(N = 4\)):GPU 比 CPU 略快,加速效果不明显 - Case 1 vs 5 (\(N = 4000\)):GPU 比 CPU 快很多,加速明显,说明 GPU 更适合大规模计算

Case 1 vs 4 vs 5 对应的运行机制如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
┌─────────────────────────────────────────────────────────────────────┐
│ CPU 同步执行模式 (正确计时) │
└─────────────────────────────────────────────────────────────────────┘

Python主线程: ───┬─────────────────────────────────────┬─────────>
t0 │ │ t1
│ c_cpu = a_cpu @ b_cpu │
▼ ▼
CPU计算: [████████████████████████████████████]
│← 4000×4000 矩阵乘法,阻塞等待 →│
│ │
└─────── 实际计算时间 ≈ t1-t0 ────┘

特点: 计算 c_cpu = a_cpu @ b_cpu
会【阻塞】等待计算完成才返回
✓ t1-t0 准确反映了真实计算时间


┌─────────────────────────────────────────────────────────────────────┐
│ GPU 异步执行模式 (错误计时) │
└─────────────────────────────────────────────────────────────────────┘

Python主线程: ───┬─────────┬────────────────────────────────────>
t0 │←-------→│ t1 (仅是提交时间)
│ ← 只是把任务放进队列,立即返回
│ │← 返回结束
│ │
│ │
t0 │←-------→│ t1
GPU后台异步执行: [█████████████████████████]
│←-- 4000×4000 矩阵乘法 --→│
|------------------------→│
(Python继续运行!)

特点: c = a @ b 只是【发射】kernel,不等待完成
✗ t1-t0 只测量了提交时间 (~0.0001s)
✗ 真实计算时间被隐藏在后台


┌─────────────────────────────────────────────────────────────────────┐
│ 正确的GPU计时方法 │
└─────────────────────────────────────────────────────────────────────┘

Python主线程: ───┬─────┬────────────────────────────┬─────────>
t0│ │ [███████]│t1
│ │ [███████]
│ │ [███████]
│←---→| [███████]
▼ ▼
GPU队列: [提交] [████████████████████] [同步点]
│←----- GPU计算 -----→│ │
│←----→|

torch.cuda.synchronize() →|
强制等待GPU完成!


4. GPU 两种同步方式

例4 考虑以下两种 GPU 同步方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import torch, time

N = 4000

# GPU 预热
a0 = torch.randn(N, N, device="cuda")
b0 = torch.randn(N, N, device="cuda")
t0 = time.perf_counter()
c0 = a0 @ b0 # 提交 “a @ b”
torch.cuda.synchronize() # 【强制同步】等待GPU完成!
t1 = time.perf_counter()
print(f"[GPU] 预热耗时: {t1 - t0:.6f}s")

# case 1: GPU 同步方式 1
a1 = torch.randn(N, N, device="cuda")
b1 = torch.randn(N, N, device="cuda")
t0 = time.perf_counter()
c1 = a1 @ b1 # 提交 “a @ b”
torch.cuda.synchronize() # 【强制同步】等待GPU完成!
t1 = time.perf_counter()
print(f"[GPU] 同步1耗时: {t1 - t0:.6f}s")


# case 2: GPU 同步方式 2
# 在 GPU 内创建两个大矩阵
a = torch.randn(N, N, device="cuda")
b = torch.randn(N, N, device="cuda")
# 创建两个 CUDA 事件(Event):在 GPU 时间线上插两个“标记点”
start = torch.cuda.Event(enable_timing=True)
end = torch.cuda.Event(enable_timing=True)

start.record() # 开始标记
c = a @ b # 提交 “a @ b” (不会立即执行完)
end.record() # 结束标记
end.synchronize() # 到这里为止,计时结束
T = start.elapsed_time(end) # 统计从 start.record() 到 end.synchronize() 之间的耗时

print(f"[GPU] 同步2耗时: {T/1000:.6f}s") # T/1000:把毫秒变成秒

执行后结果

1
2
3
[GPU] 预热耗时: 0.083418s
[GPU] 同步1耗时: 0.013568s
[GPU] 同步2耗时: 0.005645s
可以看出 - 已经排除了预热的耗时 - 第一种同步方式耗时更长:等「这个 device 上所有的所有任务」完成,所以这种计时还是有误差的 - 第二种同步方式时间更短:等「这个 event 之前的任务」完成,所以这种计时更加精准 - 这两种同步方式,都包含了执行【乘法任务】的时间(这区别于例1,因为例1没有同步,只是在统计提交任务的时间)

两种同步方式的机制如下图所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
┌─────────────────────────────────────────────────────────────────────┐
│ GPU 的同步方式 1 │
└─────────────────────────────────────────────────────────────────────┘

Python主线程: ───┬─────┬────────────────────────────┬─────────>
t0│ │ [███████]│t1
│ │ [███████]
│ │ [███████]
│←---→| [███████]
▼ ▼
GPU队列: [提交][████████████████████] [同步点]
│←----- GPU计算 -----→│ │
│←----→|

torch.cuda.synchronize() →|
强制等待GPU完成!

┌─────────────────────────────────────────────────────────────────────┐
│ GPU 的同步方式 2 │
└─────────────────────────────────────────────────────────────────────┘

Python主线程: ───┬─────┬──────────────────────────┬─────────>
t0│ │ │t1
│ │ │
│ │ end.record()→[████]
│←---→| │ │
▼ │ ▼
GPU队列: [提交][████████████████████]
│←----- GPU计算 -----→│

[同步点]
end.synchronize() →|

GPU 系列 - 1. GPU 的两种同步方式
http://yylustb.github.io/2025/11/10/code/GPU/gpu_pytorch_1/
作者
yylustb
发布于
2025年11月10日
许可协议