Với 32 kênh đầu vào, 64 kênh đầu ra và kernel 3×3, tách convolution thành depthwise rồi pointwise giảm số MACs 7,89 lần. Trong tám cấu hình PyTorch chạy trên CPU Apple M1 Pro dưới đây, block tách cũng nhanh hơn ở cả tám cấu hình, nhưng tỷ lệ đo được nằm trong khoảng 1,15–3,51 lần. Batch size, kích thước feature map và số luồng làm thay đổi mức giảm độ trễ.
Hai block được so sánh
Block thứ nhất là Conv2d(32, 64, 3, padding=1). Block thứ hai gồm depthwise Conv2d(32, 32, 3, padding=1, groups=32) rồi pointwise Conv2d(32, 64, 1). Cả hai có stride bằng 1 và trả tensor cùng kích thước N×64×H×H. Depthwise có multiplier bằng 1, tức mỗi kênh đầu vào dùng một bộ lọc không gian riêng, sau đó pointwise phối hợp các kênh. Tài liệu Conv2d của PyTorch 2.8
Thí nghiệm tắt bias ở tất cả convolution và chỉ đo các convolution. Hai block được khởi tạo riêng, có cấu trúc tham số khác nhau. Kết quả đo mô tả thời gian forward của hai block này. Để triển khai một mạng hoàn chỉnh, cần đo thêm các lớp còn lại và đánh giá chất lượng dự đoán sau khi huấn luyện. Trong kiến trúc MobileNet V1, BatchNorm và ReLU nằm sau các lớp convolution. Bài báo MobileNets gốc, mục 3.1
Đếm MACs và số tham số
Quy ước một MAC là một phép nhân kèm phép cộng tích lũy. Với batch size $N$, feature map đầu ra $H\times W$, kernel $k\times k$, số kênh vào $C_{in}$ và số kênh ra $C_{out}$, số MACs danh nghĩa là
$$ \mathrm{MAC}_{standard}=NHWk^2C_{in}C_{out} $$$$ \mathrm{MAC}_{depthwise+pointwise}=NHW(k^2C_{in}+C_{in}C_{out}) $$Với convolution không bias, số tham số tương ứng là $k^2C_{in}C_{out}$ và $k^2C_{in}+C_{in}C_{out}$. Thay $k=3$, $C_{in}=32$, $C_{out}=64$ cho kết quả sau.
| Block | Tham số | MACs với N=1, H=W=56 |
|---|---|---|
| Convolution thường | 18.432 | 57.802.752 |
| Depthwise + pointwise | 2.336 | 7.325.696 |
Tỷ lệ MACs của block tách so với convolution thường bằng $1/64+1/9\approx0{,}126736$, tương đương giảm khoảng 87,33%. Tỷ lệ này giữ nguyên trong cả tám cấu hình vì số kênh và kernel đều cố định. Đây là số phép tính danh nghĩa theo công thức của MobileNets. Công thức chi phí convolution trong bài báo gốc
Môi trường và cách đo
Máy chạy dùng Apple M1 Pro, 8 lõi CPU logic và 16 GiB RAM. Mã chạy với Python 3.10.11, PyTorch 2.8.0 trên macOS arm64. Input là tensor CPU float32, thứ tự chiều NCHW và bộ nhớ contiguous. Bản PyTorch này báo nnpack=True, mkldnn=False qua các hàm kiểm tra khả dụng. Benchmark dùng đường thực thi mặc định của bản cài đặt đó.
Mỗi lần chạy tạo input và trọng số bằng seed 20261112, đưa các module về eval() rồi đo bên trong torch.inference_mode(). Mỗi phép đo có 10 lượt warm-up và dùng Timer.blocked_autorange(min_run_time=1.0). Số luồng intra-op lần lượt là 1 và 4, còn inter-op được đặt bằng 1. Thứ tự đo hai block được đảo xen kẽ giữa các cấu hình. Việc tạo input và khởi tạo module diễn ra trước vùng tính thời gian. Độ trễ bao gồm lời gọi module, tính convolution và cấp phát đầu ra. Cách dùng Timer và blocked autorange trong PyTorch 2.8
Trong torch.utils.benchmark, một mẫu có thể gồm nhiều lượt thực thi. Công cụ chia thời gian mẫu cho số lượt rồi tính median và IQR trên các mẫu đã chuẩn hóa đó. IQR là độ chênh giữa phân vị 75 và phân vị 25. Vì mỗi mẫu có thể là trung bình của nhiều lượt, các số này mô tả các block đo đã quy về một lượt forward. Mã nguồn thống kê của PyTorch 2.8.0
Kết quả độ trễ trên CPU
Đơn vị trong hai cột thời gian là mili giây cho một lượt forward của cả batch. Trong ngoặc là độ rộng IQR. Cột cuối lấy median của convolution thường chia cho median của depthwise + pointwise, nên giá trị lớn hơn 1 cho biết block tách nhanh hơn.
| Batch N | H=W | Luồng | Thường, median (IQR) ms | Depthwise + pointwise, median (IQR) ms | Tỷ lệ độ trễ |
|---|---|---|---|---|---|
| 1 | 56 | 1 | 0.4338 (0.0274) | 0.2180 (0.0166) | 1.990× |
| 1 | 56 | 4 | 0.4239 (0.0171) | 0.1563 (0.0080) | 2.713× |
| 8 | 56 | 1 | 6.0759 (0.6477) | 2.0965 (0.1485) | 2.898× |
| 8 | 56 | 4 | 2.8947 (0.1319) | 0.9162 (0.0366) | 3.159× |
| 1 | 112 | 1 | 1.1840 (0.0369) | 1.0256 (0.0587) | 1.154× |
| 1 | 112 | 4 | 0.7938 (0.0397) | 0.6637 (0.0698) | 1.196× |
| 8 | 112 | 1 | 19.2253 (0.3845) | 9.5147 (0.4802) | 2.021× |
| 8 | 112 | 4 | 10.6976 (0.5055) | 3.0444 (0.1496) | 3.514× |
Ở batch 1, feature map 112×112 và một luồng, thời gian chỉ giảm từ 1,1840 ms xuống 1,0256 ms, tương đương 1,154 lần. Cũng feature map đó nhưng batch 8 và bốn luồng, thời gian giảm từ 10,6976 ms xuống 3,0444 ms, tương đương 3,514 lần. Cả hai trường hợp đều có tỷ lệ MACs bằng 7,89 lần.
Tăng từ một lên bốn luồng cũng tạo mức thay đổi khác nhau. Với batch 1 và 56×56, median của convolution thường từ 0,4338 ms thành 0,4239 ms. Với batch 8 và 112×112, nó từ 19,2253 ms thành 10,6976 ms. Số luồng cần được xem như một phần của cấu hình benchmark.
Một MAC trong công thức không chứa thông tin về thuật toán convolution được backend chọn, cách chia công việc giữa các luồng hay chi phí di chuyển tensor. Depthwise + pointwise còn tạo tensor trung gian giữa hai lớp. Đây là những yếu tố cần khảo sát thêm nếu muốn giải thích nguyên nhân của từng số đo. Bảng trên cho biết thời gian forward tổng thể của các cấu hình đã chạy.
Mã chạy lại
Dùng môi trường đã cài PyTorch 2.8.0, lưu đoạn sau thành experiment.py rồi chạy python3 experiment.py. Các phép đếm MACs và tham số giữ nguyên khi chạy lại. Thời gian phụ thuộc vào máy, backend và tải hệ thống ở lúc đo.
1import csv
2import platform
3import sys
4
5import torch
6from torch import nn
7from torch.utils.benchmark import Timer
8
9SEED = 20261112
10CHANNELS_IN = 32
11CHANNELS_OUT = 64
12KERNEL = 3
13MIN_RUN_TIME = 1.0
14
15
16def main():
17 torch.manual_seed(SEED)
18 torch.set_num_interop_threads(1)
19 torch.set_num_threads(1)
20 print(f"Python {platform.python_version()} | PyTorch {torch.__version__} | "
21 f"{platform.system()} {platform.machine()} CPU | float32 NCHW contiguous")
22 print(f"seed={SEED}, interop_threads=1, warmup=10, min_run_time={MIN_RUN_TIME}s")
23 print(f"mkldnn={torch.backends.mkldnn.is_available()}, "
24 f"nnpack={torch.backends.nnpack.is_available()}")
25 writer = csv.writer(sys.stdout, lineterminator="\n")
26 writer.writerow([
27 "batch", "height_width", "threads", "standard_macs", "separable_macs",
28 "standard_median_ms", "standard_iqr_ms", "separable_median_ms",
29 "separable_iqr_ms", "standard_over_separable", "samples_std", "samples_sep",
30 ])
31 case = 0
32 for spatial in (56, 112):
33 for batch in (1, 8):
34 x = torch.randn(batch, CHANNELS_IN, spatial, spatial)
35 standard = nn.Conv2d(CHANNELS_IN, CHANNELS_OUT, KERNEL, padding=1, bias=False).eval()
36 separable = nn.Sequential(
37 nn.Conv2d(CHANNELS_IN, CHANNELS_IN, KERNEL, padding=1,
38 groups=CHANNELS_IN, bias=False),
39 nn.Conv2d(CHANNELS_IN, CHANNELS_OUT, 1, bias=False),
40 ).eval()
41 standard_params = KERNEL ** 2 * CHANNELS_IN * CHANNELS_OUT
42 separable_params = KERNEL ** 2 * CHANNELS_IN + CHANNELS_IN * CHANNELS_OUT
43 assert sum(p.numel() for p in standard.parameters()) == standard_params
44 assert sum(p.numel() for p in separable.parameters()) == separable_params
45 with torch.inference_mode():
46 assert standard(x).shape == separable(x).shape == (batch, CHANNELS_OUT, spatial, spatial)
47 for threads in (1, 4):
48 torch.set_num_threads(threads)
49 modules = [("standard", standard), ("separable", separable)]
50 if case % 2:
51 modules.reverse()
52 measurements = {}
53 for name, module in modules:
54 for _ in range(10):
55 module(x)
56 measurements[name] = Timer(
57 stmt="module(x)", globals={"module": module, "x": x},
58 num_threads=threads,
59 ).blocked_autorange(min_run_time=MIN_RUN_TIME)
60 std, sep = measurements["standard"], measurements["separable"]
61 writer.writerow([
62 batch, spatial, threads,
63 batch * spatial ** 2 * standard_params,
64 batch * spatial ** 2 * separable_params,
65 f"{std.median * 1000:.4f}", f"{std.iqr * 1000:.4f}",
66 f"{sep.median * 1000:.4f}", f"{sep.iqr * 1000:.4f}",
67 f"{std.median / sep.median:.3f}",
68 len(std.raw_times), len(sep.raw_times),
69 ])
70 sys.stdout.flush()
71 case += 1
72
73
74if __name__ == "__main__":
75 main()
Để đưa quyết định sang ứng dụng, đo tiếp cả mô hình với đúng input, batch size, số luồng và runtime sẽ triển khai. Khi thêm BatchNorm và activation trở lại, chạy lại phép đo trên block hoàn chỉnh. Các số liệu ở đây áp dụng cho CPU, tensor NCHW và chế độ eager của PyTorch 2.8.0 đã nêu.
Có thể đọc thêm về kiến trúc, width multiplier và resolution multiplier trong bài MobileNet V1, cùng thí nghiệm về ảnh hưởng của BatchNorm và LayerNorm khi đổi chế độ train/eval. Kiến trúc MobileNet V1 và depthwise separable convolution, BatchNorm và LayerNorm khi batch nhỏ
Bình luận