Hình dung bạn đưa cho một nhân viên hai chồng giấy và bảo "photocopy chồng này ghép với chồng kia". Người cẩn thận sẽ tự liếc xem hai chồng có thật ra là một không rồi mới quyết cách làm — một cái liếc rẻ tiền, nhưng nếu bạn giao cả triệu việc tí hon thì cái liếc lặp lại triệu lần cũng thành tốn. Bạn có thể ký một tờ cam đoan "hai chồng chắc chắn khác nhau" để họ khỏi liếc, làm thẳng cho nhanh. Nhưng nếu bạn ký sai — hai chồng thật ra là một — họ làm ẩu theo lời bạn và cho ra bản sao hỏng, mà chẳng ai báo là hỏng. Từ khóa restrict trong C đúng là tờ cam đoan đó.

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. 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 đo xem nó đáng giá bao nhiêu trong container gcc:13 (ARM), 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 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 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 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. Đúng anh nhân viên tự liếc xem hai chồng giấy có trùng không. restrict chỉ đơn giản bỏ được cái check đó vì bạn đã ký cam đoan 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à chỗ tôi vấp. 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%. Đúng cảnh cái liếc rẻ tiền hóa tốn khi lặp triệu lần trên việc tí hon. 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 phải 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ỏ có 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ỗ (c và a 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 — tờ cam đoan ký sai làm hỏng bản sao mà không ai báo.

Về đến code hằng ngày

Điều đầ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.

Điều 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.

Và cái đánh đổi "aliasing" này chính là một trong những khác biệt hiệu năng nền tảng giữa các ngôn ngữ, đáng biết để hiểu vì sao chúng nhanh chậm khác nhau. C mặc định bi quan (giả định con trỏ có thể trùng) và bắt bạn hứa restrict để mở khóa — nên Fortran từ lâu nhanh hơn C ở tính toán số học mảng đúng vì ngôn ngữ đó cấm aliasing tham số theo mặc định, compiler khỏi cần hỏi. Rust đưa điều này vào hệ kiểu: quy tắc mượn đảm bảo hai tham chiếu khả biến không bao giờ chồng lấn, nên trình biên dịch được tối ưu như thể mọi thứ đều restrict mà bạn không phải hứa gì — và không thể hứa sai. JVM thì né hẳn: nó tự kiểm tra biên mảng và phân tích alias lúc chạy, đánh đổi an toàn lấy một chút chi phí. Mẫu số chung: "hai tham chiếu này có thể trỏ trùng nhau không" là câu hỏi định đoạt việc compiler dám tối ưu tới đâu — C bắt bạn trả lời bằng lời hứa (nguy hiểm nếu dối), các ngôn ngữ sau trả lời bằng kiểu hoặc kiểm tra runtime, an toàn hơn nhưng theo cách khác nhau.

Muốn tự thấy thì 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 c và a 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.