Hàm cộng hai mảng void add(int *c, int *a, int *b, int n){ for(i) c[i]=a[i]+b[i]; } trông vô hại, nhưng nó chứa một câu hỏi khó cho trình biên dịch: c có thể trỏ vào cùng vùng nhớ với a hoặc b không? Nếu có, mỗi lần ghi c[i] có thể đổi giá trị a[j] đọc sau đó, và compiler phải cẩn thận. Từ khóa restrict là cách bạn nói "không, chúng không chồng lấn" — một lời hứa như UB mở khóa tối ưu. Tôi vào đo xem nó đáng giá bao nhiêu, và tìm ra nó ít quan trọng hơn danh tiếng, mà nguy hiểm hơn tôi tưởng.

restrict và chồng lấn

restrict hứa gì

restrict khác với strict aliasing: strict aliasing suy đoán không chồng lấn dựa trên kiểu (hai con trỏ khác kiểu không alias), còn restrict là lời hứa tường minh về địa chỉ — bạn khẳng định với compiler rằng hai con trỏ này không bao giờ trỏ tới vùng nhớ chung. Với lời hứa đó, compiler được giữ giá trị trong thanh ghi thay vì nạp lại sau mỗi lần ghi, và quan trọng nhất là vector hóa vòng lặp: xử lý nhiều phần tử một lúc mà không sợ ghi phần tử này làm hỏng phần tử kia.

Tôi tưởng "không có restrict thì compiler bó tay, không vector hóa được, vòng chạy chậm hẳn". Soi mã máy trong container gcc:13 (ARM AArch64) bằng -O3 cho thấy tôi sai.

Đo: compiler tự lo bằng kiểm tra lúc chạy

objdump hai phiên bản của cùng hàm add:

add_plain    (không restrict) : 60 lệnh, có 12 lệnh so sánh con trỏ, CÓ vector hóa
add_restrict (có restrict)    : 43 lệnh, KHÔNG có so sánh con trỏ, có vector hóa

Cả hai đều vector hóa! Bản không restrict không hề bó tay — gcc -O3 tự chèn một kiểm tra alias lúc chạy (runtime alias check): ở đầu hàm, nó so sánh địa chỉ của c, a, b; nếu chúng không chồng lấn, nhảy vào bản vector hóa nhanh; nếu chồng lấn, dùng bản vô hướng dự phòng an toàn. Đó là 12 lệnh so sánh và một hàm to gần gấp rưỡi (60 so với 43 lệnh) — hai bản mã, một check chọn đường. restrict chỉ đơn giản bỏ được cái check đó vì bạn đã hứa trước.

Vậy restrict giúp nhanh bao nhiêu? Đo trên hai kiểu tải:

vòng LỚN n=8.000.000 (mảng rời):
  add_plain    : 0,1236 ns/phần tử
  add_restrict : 0,1246 ns/phần tử   -> BẰNG nhau

vòng NHỎ n=8, gọi 30 triệu lần:
  add_plain    : 1,438 ns/gọi
  add_restrict : 1,193 ns/gọi        -> restrict nhanh ~20%

Đây là đo hớ. Với vòng lớn, restrict gần như không giúp gì: kiểm tra alias chạy một lần ở đầu hàm rồi 8 triệu vòng lặp chạy bản vector, nên cái check bị khấu hao thành số không. Bản không restrict cũng nhanh y hệt. restrict chỉ thắng ở vòng nhỏ gọi nhiều lần: với n=8 gọi 30 triệu lần, cái check phải chạy mỗi lời gọi, và với một vòng chỉ 8 phần tử thì chi phí check chiếm tỉ lệ đáng kể — bỏ nó đi cho nhanh 20%. restrict là một tinh chỉnh cho hàm nhỏ, hot, gọi nhiều; không phải phép màu tăng tốc chung.

Mặt nguy hiểm: restrict là lời hứa bạn giữ

Có một lý do khác để không rắc restrict bừa: nó là lời hứa mà compiler tin tuyệt đối, không kiểm tra. Nếu bạn khai restrict nhưng thực tế hai con trỏ chồng lấn, kết quả sai — im lặng. Tôi minh họa với c[i] = a[i] + a[i-1] gọi tại chỗ (ca là cùng mảng):

plain    (đúng): 1 3 6 10 15 21 28 36 45 55 66 78
restrict (UB)  : 1 3 5 7 9 15 13 15 17 27 21 33

Bản plain cho kết quả đúng theo ngữ nghĩa tại chỗ. Bản restrict — vì đã hứa c, a rời nhau nên compiler vector hóa, đọc các giá trị a theo lô từ bản gốc thay vì bản vừa ghi — cho ra một dãy sai bét. Không cảnh báo, không crash; chỉ những con số sai. Đây đúng là loại lỗi khó lần nhất: chương trình chạy, chỉ là ra sai, và chỉ sai ở mức tối ưu cao khi vector hóa được kích hoạt.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên: đừng thêm restrict với kỳ vọng "nhanh hơn" chung chung. Với vòng lớn xử lý mảng, compiler đã tự vector hóa qua kiểm tra alias lúc chạy, và restrict không thêm gì đáng kể. Nó chỉ đáng khi bạn có một hàm nhỏ, hot, gọi rất nhiều lần, nơi cái check cố định mỗi lời gọi trở thành tỉ lệ lớn — ví dụ một hạt nhân toán học gọi trong vòng trong cùng. Đo trước; thường thì lợi ích là con số không.

Hệ quả thứ hai: restrict là hợp đồng, không phải gợi ý — sai là hỏng. Chỉ khai restrict khi bạn chắc chắn các con trỏ không chồng lấn trong mọi lời gọi. Một hàm thư viện nhận con trỏ từ người dùng mà khai restrict là mời gọi lỗi: ai đó gọi tại chỗ (in-place) sẽ nhận kết quả sai âm thầm. Nếu không chắc, đừng khai — để compiler tự kiểm tra lúc chạy, an toàn hơn nhiều so với một con số sai.

Hệ quả thứ ba là bài học đo lường: "compiler cần restrict để vector hóa" là một nửa sự thật đã lỗi thời. Compiler hiện đại vector hóa được cả khi thiếu restrict nhờ versioning (tạo hai bản, chọn lúc chạy); cái giá là mã to hơn và một check, không phải mất vector hóa. Con số mang theo: gcc -O3 tự chèn kiểm tra alias lúc chạy rồi vector hóa cả khi thiếu restrict (mã 60 so 43 lệnh); restrict bỏ được check nên KHÔNG giúp vòng lớn (0,1236 so 0,1246 ns, check khấu hao qua 8 triệu vòng) mà CHỈ giúp vòng nhỏ gọi nhiều (~20%, 1,438 so 1,193 ns); và restrict là lời hứa compiler không kiểm — khai sai khi con trỏ thật sự trùng cho kết quả SAI im lặng (1 3 6 10... thành 1 3 5 7 9...). Dùng restrict như một hợp đồng cẩn trọng cho hàm nhỏ hot, không phải một cái công tắc "cho nhanh".

Thử ba mươi giây

Viết void add(int *c, int *a, int *b, int n){ for(int i=0;i<n;i++) c[i]=a[i]+b[i]; } và bản add_r y hệt nhưng ba con trỏ đều restrict. Biên dịch gcc -O3 -S -o - t.c và so hai hàm: bản không restrict sẽ có một cụm lệnh so sánh con trỏ ở đầu (kiểm tra alias) rồi mới tới vòng vector; bản restrict vào thẳng vòng vector, ngắn hơn. Rồi thử điều nguy hiểm: gọi add_r(x, x, b, n) — truyền ca là cùng mảng — và so kết quả với bản plain. Với vòng c[i]=a[i]+a[i-1] chúng sẽ khác nhau. Ba mươi giây đó cho bạn thấy restrict không phải nút tăng tốc, mà là một lời hứa có hậu quả thật khi bạn lỡ nói dối compiler.