Trong mã nguồn, trộn intfloat trông vô hại — float f = f + count; hay int n = ratio * 100; — vì "đều là số". Nhưng số nguyên và số thực dấu phẩy động là hai thế giới khác nhau trong CPU: dùng thanh ghi riêng, đơn vị thực thi riêng. Đổi qua lại cần một lệnh chuyển đổi thật (scvtf, fcvtzs), có độ trễ — và các phép ép kiểu ngầm trong biểu thức trộn kích hoạt chúng mà bạn không viết ra. Phần 27 cho thấy chọn kiểu ảnh hưởng tốc độ; phần này đo chi phí đổi giữa các kiểu, trong container gcc:13 trên host ARM, và trên đường tới hạn nó đắt đến bất ngờ.

int và float: chi phí ẩn khi đổi

Đổi kiểu là một lệnh, không phải gán suông

int sống trong thanh ghi số nguyên; float/double sống trong thanh ghi dấu phẩy động. Một số nguyên như 5 và số thực 5.0fbiểu diễn bit hoàn toàn khác nhau, nên "gán int cho float" không phải sao chép bit — CPU phải chạy lệnh scvtf (signed convert to float) để tính lại biểu diễn, và fcvtzs cho chiều ngược (float→int). Mỗi lệnh ấy có độ trễ, cộng thêm việc chuyển dữ liệu giữa hai nhóm thanh ghi. Để đo, tôi dựng một chuỗi phụ thuộc khứ hồi (round-trip) so với một phép cộng số nguyên thuần:

Host ARM, ns:

   ĐỘ TRỄ (chuỗi phụ thuộc)
     add int thuần (v = v+1)              : 0,225 ns  (~1 chu kỳ)
     round-trip (int)((float)v + 1.0f)    : 4,324 ns  (~19 chu kỳ !)

   THÔNG LƯỢNG (mảng 8192 trong L1, ns/phần tử)
     tổng int thuần        : 0,219
     tổng int-as-float (convert mỗi phần tử) : 0,580

Nhìn cột độ trễ: một phép cộng số nguyên trong chuỗi phụ thuộc tốn ~1 chu kỳ (0,225 ns). Nhưng một vòng khứ hồi v = (int)((float)v + 1.0f) — đổi int sang float, cộng, rồi đổi ngược về int — tốn 4,324 ns, tức ~19 chu kỳ, gấp 19 lần. Mỗi vòng thực hiện scvtf + fadd + fcvtzs, tất cả nối đuôi trên đường tới hạn (mỗi bước cần kết quả bước trước). Assembly xác nhận đúng các lệnh scvtf/fcvtzs. Đổi kiểu không miễn phí — trên chuỗi phụ thuộc nó đắt gần hai chục lần một phép cộng.

Cột thông lượng dịu hơn: tổng một mảng int bằng cách đổi từng phần tử sang float (0,580 ns) chậm hơn tổng int thuần (0,219) khoảng 2,6 lần — vì ở đây các phép độc lập chồng lên nhau (phần 15) nên convert bị che bớt, dù vẫn cộng thêm chi phí đáng kể.

Cạm bẫy ẩn: ép kiểu ngầm

Điều nguy hiểm là các chuyển đổi này thường ẩn. Viết float f = f + count; với countint — trình biên dịch chèn scvtf để đổi count sang float. Viết int n = avg; với avgdouble — chèn fcvtzs. Một vòng lặp nóng trộn int chỉ số với float dữ liệu có thể rải đầy scvtf/fcvtzs mà mã nguồn trông rất sạch. Tệ hơn, trộn kiểu còn làm trình biên dịch khó vector hóa hơn (phải xen kẽ convert giữa các lệnh SIMD), nên mất luôn lợi ích SIMD.

Một lần tôi đo hớ: "gán int cho float là miễn phí" và "convert đắt nên tránh mọi lúc"

Tôi vào đo với giả định hồn nhiên: "int và float đều là số, gán qua lại chỉ là chép giá trị, miễn phí". Đo phá tan: đổi kiểu là một lệnh có độ trễ, và một round-trip int→float→int trên chuỗi phụ thuộc tốn 19 chu kỳ — gấp 19 lần một phép cộng int. Trong một vòng nóng trộn kiểu, những scvtf/fcvtzs ẩn ấy cộng dồn và có thể chi phối thời gian chạy, dù mã nguồn không có dòng "convert" nào rõ ràng.

Nhưng đo cũng chỉnh một niềm tin ngược — cái người vừa thấy convert đắt dễ mắc: "vậy phải tránh mọi phép đổi kiểu bằng mọi giá". Sai/quá đà: một convert lẻ, độc lập (không nằm trên chuỗi phụ thuộc) khá rẻ và chồng lên nhau được — ở thông lượng nó chỉ thêm chút. Convert chỉ thực sự đau khi (1) nằm trên chuỗi phụ thuộc (round-trip 19 chu kỳ), (2) lặp hàng triệu lần trong vòng nóng, hoặc (3) chặn vector hóa. Một phép ép kiểu ở đường lạnh (chạy hiếm) không đáng để bóp méo code. Như mọi tối ưu, phải hỏi nó có ở đường nóng không.

Bài học đo lường: đổi int<->float KHÔNG miễn phí — có lệnh scvtf (int->float) / fcvtzs (float->int) với độ trễ. Đo: round-trip (int)((float)v+1) trên chuỗi phụ thuộc = 4,324 ns ~19 chu kỳ, gấp ~19x một phép cộng int (0,225 ns); throughput int-as-float ~2,6x int thuần. Ép kiểu NGẦM trong biểu thức trộn int/float sinh convert bạn không thấy, và cản vector hóa. NHƯNG 1 convert lẻ/độc lập rẻ; chỉ đau khi trên chuỗi phụ thuộc / lặp triệu lần / chặn SIMD. 'Gán int cho float miễn phí' và 'convert đắt nên tránh mọi lúc' đều SAI. Nếu tin "miễn phí" tôi rải convert khắp vòng nóng; nếu tin "tránh mọi lúc" tôi bóp méo code vì một cast vô hại.

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

Hệ quả đầu tiên: giữ vòng nóng thuần một kiểu; tránh đổi int↔float mỗi phần tử. Nếu xử lý mảng int, làm số học bằng int; nếu mảng float, dùng float. Đừng để chỉ số int và dữ liệu float trộn vào cùng biểu thức trong vòng — mỗi lần trộn là một scvtf/fcvtzs. Nếu buộc phải đổi, gom chuyển đổi ra ngoài vòng (đổi cả mảng một lần) thay vì đổi trong lõi.

Hệ quả thứ hai: cảnh giác với ép kiểu ngầm và hằng số sai kiểu. x * 2 khác x * 2.0 nếu xfloat (cái sau có thể ép lên double rồi ép về). Dùng đúng hậu tố (2.0f cho float, 2 cho int), và đọc cảnh báo -Wconversion của trình biên dịch — nó chỉ ra chỗ bạn đổi kiểu mà không để ý. Xem assembly (scvtf/fcvtzs) khi nghi vòng nóng có convert ẩn.

Hệ quả thứ ba là tinh thần đo lường: giữa các kiểu số có một hàng rào phần cứng, và bước qua nó tốn tiền — nhất là trên đường tới hạn. Con số mang theo: round-trip int<->float ~19 chu kỳ (vs int add 1 chu kỳ); convert ẩn trong biểu thức trộn + cản SIMD; nhưng convert lẻ độc lập thì rẻ. Giữ vòng nóng thuần kiểu, gom convert ra ngoài. Cùng một "phép tính", làm thuần một kiểu thì nhanh, trộn int với float thì rước lấy những lệnh chuyển đổi vô hình đắt gấp chục lần — đo mới thấy cái giá của việc bước qua ranh giới kiểu.

Thử ba mươi giây

Viết một chuỗi phụ thuộc số nguyên thuần: int v=1; for(...) v = v + 1; (dùng volatile hoặc asm chặn tối ưu), bấm giờ ns mỗi vòng — bạn được ~0,2 ns, một chu kỳ. Rồi đổi thành một khứ hồi qua float: v = (int)((float)v + 1.0f); và đo lại: bạn sẽ thấy nó chậm cả chục lần — mỗi vòng giờ gồm một scvtf (int→float), một cộng float, và một fcvtzs (float→int), tất cả nối đuôi. Xem assembly (g++ -O2 -S) để thấy đúng các lệnh chuyển đổi ấy. Cuối cùng, thử tổng một mảng int bằng cách cộng vào biến float so với cộng vào biến int — bản đổi-sang-float chậm hơn dù bạn "chỉ đổi kiểu tích lũy". Ba mươi giây đó cho bạn thấy điều mà "int với float đều là số" giấu đi: chúng nằm ở hai phía của một hàng rào phần cứng, và mỗi lần code của bạn bước qua — dù chỉ là một phép ép kiểu ngầm — CPU phải trả một lệnh chuyển đổi có độ trễ, thứ có thể đắt gấp chục lần phép tính bạn tưởng mình đang đo.