Sớm muộn mọi cấu trúc dữ liệu cũng phải rời khỏi bộ nhớ: lưu ra file, gửi qua mạng, ghi vào cơ sở dữ liệu. Việc chuyển một cấu trúc trong RAM thành một chuỗi byte gọi là serialize (tuần tự hóa), và dựng lại nó từ byte là deserialize.

Nghĩ về hai cách đóng gói đồ chuyển nhà. Cách một: bê nguyên cả căn phòng — bàn ghế xếp y nguyên vị trí — đặt lên xe. Cực nhanh, khỏi tháo lắp. Nhưng chỉ được nếu nhà mới có đúng kích thước phòng, đúng ổ cắm, đúng mọi thứ — sang một căn khác kiểu là mọi thứ lệch hết. Cách hai: tháo rời từng món, dán nhãn, ghi kích thước, rồi lắp lại ở nhà mới. Chậm hơn nhiều vì phải tháo-lắp từng món, nhưng chuyển đâu cũng được. Đó chính xác là binary thô so với text khi serialize: một bên gần như chỉ sao chép layout, một bên format/parse từng trường. Nhiều người mặc định "serialize chỉ là copy dữ liệu ra byte, nhanh thôi". Tôi đo hai cách trong container gcc:13, và con số cho thấy khoảng cách hàng chục lần, cùng một cái bẫy về tính di động.

memcpy thẳng so với format/parse từng trường

Có hai cách kinh điển để biến một bản ghi thành byte:

  • Binary thô: memcpy thẳng layout bộ nhớ của struct (hoặc mảng struct) ra một vùng byte, và đọc ngược lại y hệt. Nhanh nhất — gần như chỉ là sao chép, đạt băng thông bộ nhớ (bê nguyên cả phòng). Nhưng nó không di động, vì ba lý do:

    • Endianness: máy little-endian và big-endian sắp thứ tự byte của một số khác nhau — cùng 4 byte đọc ra hai giá trị khác nhau.
    • Padding: byte đệm trong struct đi kèm khi copy, và cách đệm khác nhau giữa trình biên dịch/nền tảng.
    • Con trỏ: một con trỏ là địa chỉ trong không gian bộ nhớ của tiến trình này — copy nó sang máy khác (hoặc cả tiến trình khác) là vô nghĩa.
  • Text / field-by-field (CSV, JSON…): ghi từng trường ra chuỗi (số → chuỗi ký tự) và parse ngược lại. Di động, người đọc được, độc lập với endianness/padding/con trỏ (tháo rời, dán nhãn). Nhưng phải format và parse mỗi trường — chậm hơn memcpy nhiều lần, và output lớn hơn (một số 32-bit thành nhiều ký tự).

Đánh đổi cốt lõi: tốc độ (binary) đổi lấy tính di động + đọc được (text).

Đo: binary ~38 GB/s, text chậm 29–52 lần

Tôi dựng 5 triệu bản ghi, mỗi bản ghi 5 trường số (20 byte), rồi đo serialize và deserialize cho cả hai cách:

5 triệu bản ghi, Rec = 20 byte (5 trường số), g++ -O2:

A. BINARY (memcpy layout thẳng):
   serialize   : 0,48 ns/bản ghi   (~39.700 MB/s)
   deserialize : 0,52 ns/bản ghi   (~37.000 MB/s)
   kích thước  : 95 MB   (20 byte/bản ghi)

B. TEXT (format/parse từng trường):
   serialize   : 25,1 ns/bản ghi   (~1.140 MB/s)
   deserialize : 14,9 ns/bản ghi   (~1.918 MB/s)
   kích thước  : 143 MB   (~30 byte/bản ghi)

TỈ SỐ:
   text serialize   / binary : chậm ~52 lần
   text deserialize / binary : chậm ~29 lần
   text: lớn hơn ~1,5 lần

Nhìn A — binary: serialize một bản ghi chỉ mất 0,48 ns, deserialize 0,52 ns, cả hai đạt gần 38 GB/s — đúng là "chỉ sao chép", bị giới hạn bởi băng thông bộ nhớ chứ không phải tính toán. Đây là lý do binary thô nhanh nhất có thể.

Nhìn B — text: serialize một bản ghi mất 25,1 ns, deserialize 14,9 ns. So với binary, text serialize chậm ~52 lần và deserialize chậm ~29 lần. Toàn bộ khác biệt là chi phí format/parse từng trường: để ghi một số nguyên ra chuỗi, phải tách từng chữ số; để đọc lại, phải nhân-cộng từng ký tự. Đây không còn là "chỉ copy" nữa — nó là tính toán thật trên mỗi trường (tháo-lắp từng món). Và output text lớn hơn ~1,5 lần (30 vs 20 byte/bản ghi), vì một số 32-bit có thể thành 5–10 ký tự.

Có một điều đo được khiến tôi chú ý: text serialize (25,1 ns) lại chậm hơn text deserialize (14,9 ns) — ngược với câu "parse thường đắt hơn format" mà tôi hay nghe. Lý do trung thực: với số nguyên, format (số → chuỗi) cần phép chia/lấy dư để tách từng chữ số, mà chia là phép đắt trên CPU; còn parse (chuỗi → số) chỉ cần nhân-cộng, rẻ hơn. Nên ở đây format nặng hơn parse. (Câu "deserialize đắt hơn" đúng cho các định dạng phức tạp — JSON với số thực, escape, kiểm tra hợp lệ — nơi parse mới là phần nặng; nhưng nó không phải quy luật phổ quát, và đo mới biết chiều nào nặng hơn cho dữ liệu của bạn.)

Hai niềm tin đối nghịch, cùng sai

Tôi vào đo với niềm tin: "serialize chỉ là chép dữ liệu ra byte, chuyện nhỏ, nhanh thôi". Con số cho thấy điều đó chỉ đúng với binary memcpy (0,48 ns/bản ghi, 38 GB/s). Ngay khi bạn cần một định dạng text (CSV, JSON — thứ dùng khắp nơi để trao đổi dữ liệu), việc format và parse từng trường chậm hơn memcpy 29–52 lần. "Ghi một số ra chuỗi" không phải là copy — nó là tính toán trên mỗi trường, và với hàng triệu bản ghi, nó thành nút cổ chai thật. Serialize không rẻ khi nó phải chuyển sang dạng người đọc được.

Nhưng nếu vì thấy binary nhanh gấp mấy chục lần mà tin "cứ dùng memcpy cho mọi thứ" thì sa vào cái bẫy nguy hiểm hơn: memcpy một struct thô không di động. Ghi nó ra file rồi đọc trên một máy endianness khác — mọi số sai. Struct có padding khác giữa hai trình biên dịch — layout lệch. Struct chứa con trỏ — địa chỉ đó vô nghĩa ở tiến trình/máy khác, đọc lại là truy cập bộ nhớ rác. Binary thô chỉ an toàn trong cùng một chương trình, cùng một build, hoặc khi bạn chốt một layout cố định (kích thước trường, thứ tự byte tường minh) — đúng như "bê nguyên phòng" chỉ được nếu nhà mới giống hệt. Chọn memcpy để trao đổi dữ liệu giữa các hệ thống là một quả bom chờ nổ.

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

Việc đầu tiên: chọn định dạng theo ai đọc và ở đâu. Trong cùng một chương trình (cache trên đĩa, giao tiếp giữa các tiến trình bạn kiểm soát cùng build), binary layout cố định là nhanh nhất. Trao đổi giữa các hệ thống/ngôn ngữ/máy khác nhau, hoặc cần người đọc/debug được, thì text (JSON/CSV) đáng cái giá chậm hơn — nhưng biết rằng nó có giá đó, đừng dùng cho đường siêu nóng mà không đo.

Việc thứ hai: khi cần cả nhanh và di động, dùng định dạng binary có schema. Đây là chỗ Protocol Buffers, FlatBuffers, Cap'n Proto, MessagePack nằm giữa: chúng là binary (nhanh, gọn) nhưng có schema và quy ước endian/kích thước cố định (di động), tránh cả sự chậm của text lẫn sự mong manh của memcpy thô. FlatBuffers/Cap'n Proto còn cho đọc thẳng không cần deserialize (zero-copy) — gần với tốc độ memcpy mà vẫn di động. Đừng tự phát minh lại memcpy struct làm định dạng trao đổi.

Cái trục tốc-độ–di-động này chi phối rất nhiều quyết định thực tế mà lập trình viên tầng cao chạm hằng ngày. Vì sao API dùng JSON dù nó chậm và cồng kềnh? Vì di động và người đọc được quan trọng hơn tốc độ ở biên hệ thống. Vì sao các hệ thống hiệu năng cao (gRPC, Kafka nội bộ, lưu trữ cột) chuyển sang protobuf/Avro/Parquet? Vì ở đường nóng, cái giá parse JSON hàng triệu lần thành nút cổ chai thật — chính là con số 29–52x ở trên nhân lên quy mô lớn. Còn cái bẫy memcpy không di động thì Java đã học đau: java.io.Serializable gắn chặt với layout class và version, đổi class một chút là dữ liệu cũ đọc không ra, nên cộng đồng gần như bỏ nó để dùng định dạng có schema. Nguyên tắc chung: "serialize" không phải một thao tác đồng nhất — nó là một điểm trên trục nhanh nhưng mong manh ↔ chậm nhưng di động, và chọn điểm nào là quyết định kiến trúc.

Muốn tự thấy thì tạo một mảng vài triệu bản ghi nhỏ (vài trường số). Serialize theo hai cách: một là memcpy cả mảng ra một buffer byte; hai là ghi từng bản ghi thành một dòng text (dùng snprintf hoặc tự viết số → chuỗi, ngăn cách bằng dấu phẩy). Bấm giờ cả hai — bạn sẽ thấy memcpy nhanh hơn hàng chục lần, vì nó không format gì cả. Rồi deserialize: memcpy ngược lại so với parse từng trường (strtol/tự viết) — parse cũng chậm hơn nhiều lần. So kích thước output: text lớn hơn. Cuối cùng, thử một thí nghiệm tưởng tượng (hoặc thật nếu có máy khác): ghi bản memcpy ra file, rồi đọc trên một máy endianness khác — mọi số sẽ sai. Ba mươi giây đó cho thấy điều mà "serialize chỉ là copy dữ liệu" giấu đi: chỉ binary thô mới "chỉ là copy", và chính vì thế nó không di động; mọi định dạng di động đều phải trả giá format/parse, và chọn đúng chỗ đứng trên trục tốc-độ–di-động là một quyết định thật, không phải chuyện phụ.