Trong đầu ta, int và float chỉ là "số" — trộn chúng lại thì có gì đâu. float f = f + count; hay int n = ratio * 100; trông vô hại đến mức chẳng ai nhìn lại. Nhưng với CPU, số nguyên và số thực dấu phẩy động là hai bộ phận tách rời: dùng thanh ghi riêng, đơn vị thực thi riêng. Hình dung một xưởng có hai phân xưởng ở hai đầu nhà máy — một chuyên gia công kim loại (int), một chuyên đồ nhựa (float). Cần chuyển một chi tiết từ bên này sang bên kia, bạn không "gán" nó qua được; phải có người khiêng, và người đó mất thời gian đi lại. Lệnh chuyển đổi int↔float chính là người khiêng đó, và bài này đo xem chuyến đi tốn bao lâu.

Số nguyên 5 và số thực 5.0f có biểu diễn bit hoàn toàn khác nhau, nên "gán int cho float" không phải chép bit — CPU phải chạy lệnh thật để tính lại biểu diễn. Mấy lệnh đó 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 dù bạn chẳng viết chữ "convert" nào. Phần 27 cho thấy chọn kiểu ảnh hưởng tốc độ; ở đây tôi đ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 mức làm tôi giật mình.

Đổ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. Muốn đưa một int sang float, CPU chạy scvtf (signed convert to float) để tính lại biểu diễn; chiều ngược lại (float→int) là fcvtzs. Mỗi lệnh có độ trễ riêng, cộng thêm chi phí chuyển dữ liệu giữa hai nhóm thanh ghi. Để đo cho rõ, tôi dựng một chuỗi phụ thuộc khứ hồi rồi 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ễ trước. 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 khoảng 19 chu kỳ, gấp 19 lần. Mỗi vòng thực hiện scvtf + fadd + fcvtzs, cả ba nối đuôi nhau trên đường tới hạn (mỗi bước cần kết quả bước trước). Xem assembly thấy đúng các lệnh scvtf/fcvtzs nằm đó. Đổi kiểu không hề 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 thì dịu hơn nhiều: tổng một mảng int bằng cách đổi từng phần tử sang float (0,580 ns) chỉ 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ể. Chính khoảng cách giữa hai con số này — 19x khi nối tiếp, 2,6x khi song song — mới là chỗ đáng nhớ, tôi sẽ quay lại ngay dưới đây.

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

Điều làm chuyện này nguy hiểm là các chuyển đổi thường vô hình. Viết float f = f + count; với count là int — trình biên dịch lặng lẽ chèn scvtf để đổi count sang float. Viết int n = avg; với avg là double — chèn fcvtzs. Một vòng lặp nóng trộn chỉ số int với dữ liệu float có thể rải đầy scvtf/fcvtzs trong khi mã nguồn trông sạch bong, không một dòng convert. Tệ hơn, trộn kiểu còn khiến 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 của SIMD.

Hai thái cực đều sai

Tôi vào đo với giả định hồn nhiên: "int với float đều là số, gán qua lại chỉ là chép giá trị, miễn phí". Con số dập tắt ngay: đổ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 lại và có thể chi phối cả thời gian chạy, dù mã nguồn không có dòng "convert" nào rõ ràng.

Nhưng nếu chỉ đọc tới đó rồi rút ra "vậy phải tránh mọi phép đổi kiểu bằng mọi giá" thì lại sa vào cực ngược, cũng sai. 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 các phép khác được — ở thông lượng nó chỉ thêm chút, đúng như con số 2,6x kia cho thấy. Convert chỉ thực sự đau khi nó rơi vào một trong ba tình huống: nằm trên chuỗi phụ thuộc (round-trip 19 chu kỳ), lặp hàng triệu lần trong vòng nóng, hoặc chặn vector hóa. Một phép ép kiểu ở đường lạnh chạy hiếm khi không đáng để bóp méo code cho xấu đi. Như mọi tối ưu, câu hỏi đầu tiên luôn là: nó có nằm trên đường nóng không?

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

Việc đầu tiên: giữ vòng nóng thuần một kiểu; tránh đổi int↔float mỗi phần tử. Xử lý mảng int thì làm số học bằng int; mảng float thì dùng float. Đừng để chỉ số int và dữ liệu float chui vào cùng một 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.

Việc 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 x là float (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à bật đọc cảnh báo -Wconversion của trình biên dịch — nó chỉ thẳng ra chỗ bạn đổi kiểu mà không để ý. Nghi vòng nóng có convert ẩn thì mở assembly (g++ -O2 -S) soi scvtf/fcvtzs.

Cái hàng rào này không chỉ có trong C. Nó theo lên mọi ngôn ngữ, và ở nơi bạn ít ngờ nhất còn đắt hơn. Java autoboxing là ví dụ kinh điển: một Map<Integer, Double> hay một List<Integer> bắt JVM gói/mở-gói int↔Integer liên tục, và cái đó đắt hơn một lệnh scvtf nhiều — nó cấp phát cả một object trên heap, kéo theo áp lực GC. Go thì buộc bạn viết float64(n) tường minh, coi như một lời nhắc ngay trong code rằng đây là một thao tác thật chứ không phải phép màu. Còn trong database, một điều kiện WHERE int_col = 3.5 hay so một cột số nguyên với tham số kiểu thực có thể khiến engine ép kiểu cả cột và vứt luôn index — cùng một "hàng rào kiểu", nhưng cái giá lần này không phải 19 chu kỳ mà là một lần full-scan. Nguyên tắc thì bất biến: giữa các kiểu số có một hàng rào phần cứng (và cả phần mềm), bước qua nó luôn tốn tiền, nên đừng bước qua lại vô cớ trên đường nóng.

Muốn tự thấy thì nhanh thôi. Viết một chuỗi phụ thuộc số nguyên thuần int v=1; for(...) v = v + 1; (dùng volatile hay 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 khứ hồi qua float v = (int)((float)v + 1.0f); và đo lại: chậm cả chục lần, vì mỗi vòng giờ gồm một scvtf, một cộng float, và một fcvtzs nối đuôi. Mở assembly ra là thấy đúng ba lệnh đó. 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 biến tích lũy". Ba mươi giây đó cho thấy điều mà "int với float đều là số" giấu đi: chúng nằm hai phía một hàng rào phần cứng, và mỗi lần code 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.