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. Nghe như một việc phụ, nhưng với hệ thống xử lý hàng triệu bản ghi, chi phí serialize/deserialize có thể lớn hơn cả phần logic chính. 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 — binary thô (memcpy) và text (format/parse từng trường) — 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ô:
memcpythẳ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ớ. 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ỏ. Nhưng phải format và parse mỗi trường — chậm hơn
memcpynhiề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. 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.)
Một lần tôi đo hớ: "serialize chỉ là copy dữ liệu" và "cứ binary memcpy cho nhanh"
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". Đo phá tan: đ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 đo cũng phá luôn niềm tin ngược: "vậy binary memcpy nhanh gấp mấy chục lần, cứ dùng nó cho mọi thứ". Sai — và đây là cái bẫy nguy hiểm hơn: memcpy một struct thô không di động. Nếu bạn ghi nó ra file rồi đọc trên một máy endianness khác, mọi số sai. Nếu struct có padding khác giữa hai trình biên dịch, layout lệch. Nếu 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). Chọn memcpy để trao đổi dữ liệu giữa các hệ thống là một quả bom chờ nổ.
Bài học đo lường: serialize/deserialize — BINARY thô (memcpy layout thẳng) nhanh nhất (0,48/0,52 ns/bản ghi, ~38 GB/s, gần như chỉ sao chép) NHƯNG không di động (endianness, padding, con trỏ vô nghĩa qua máy/tiến trình khác); TEXT/field-by-field (CSV/JSON) di động + đọc được nhưng format/parse từng trường chậm 29–52x (25,1/14,9 ns/bản ghi) và lớn hơn ~1,5x. 'Serialize chỉ là copy' chỉ đúng với binary; ở đây format số (chia) còn đắt hơn parse (nhân). Nếu tôi tin "chỉ là copy" tôi coi thường chi phí text; nếu tôi tin "cứ memcpy" tôi tạo dữ liệu vỡ khi qua máy khác.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầ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.
Hệ quả 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.
Hệ quả thứ ba là tinh thần đo lường: "chỉ là copy" là một trực giác che giấu cả một trục chi phí — và cái nhanh nhất thường là cái kém an toàn nhất. Con số mang theo: binary memcpy ~0,5 ns/bản ghi (~38 GB/s) nhưng không di động (endianness/padding/con trỏ); text format/parse chậm 29–52x (25,1/14,9 ns/bản ghi) và lớn 1,5x nhưng di động + đọc được. Đo cả serialize lẫn deserialize riêng (chiều nào nặng hơn tùy dữ liệu — ở đây format số nặng hơn parse). Cần nhanh + di động: binary có schema (protobuf/FlatBuffers). Chi phí serialize không phải hằng số — nó phụ thuộc định dạng, và định dạng là một quyết định kỹ thuật, không phải mặc định.
Thử ba mươi giây
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 bạn 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ụ.