Tối ưu hóa dấu thời gian trên Linux: Cách tăng tốc 30% và vượt qua giới hạn vDSO

26 tháng 4, 2026·6 phút đọc

Bài viết này phân tích sâu việc tối ưu hóa việc lấy dấu thời gian trên Linux cho các hệ thống có độ trễ cực thấp. Bằng cách tự triển khai bộ đếm thời gian tùy chỉnh và thao tác trực tiếp trên bộ nhớ vvar thay vì dùng vDSO chuẩn, tác giả đã giảm thời gian xử lý từ 47ns xuống khoảng 20ns, giúp giải quyết vấn đề hiệu năng trong distributed tracing.

Tối ưu hóa dấu thời gian trên Linux: Cách tăng tốc 30% và vượt qua giới hạn vDSO

Tối ưu hóa dấu thời gian trên Linux: Cách tăng tốc 30% và vượt qua giới hạn vDSO

Trong một dự án cá nhân gần đây tại công ty cũ, tôi đã dành nhiều thời gian để đưa tính năng distributed tracing (truy vết phân tán) vào một pipeline có độ trễ thấp (khoảng 1–10 micro-giây cho mỗi giai đoạn) bằng OpenTelemetry. Một phần quan trọng của công việc này là thiết kế và tối ưu hóa thư viện client C++ của riêng chúng tôi, vì client chính thức có quá nhiều chi phí phụ (overhead). Mục tiêu của tôi là giữ độ trễ tác động dưới 5% cho mỗi thành phần, tương đương với ngân sách khoảng 50–100 ns cho mỗi span.

Một chi tiết nhỏ nhưng không kém phần quan trọng là cách chúng ta gán dấu thời gian (timestamp) cho các span này. Bài viết dưới đây sẽ chia sẻ cách chúng tôi tăng tốc độ lấy dấu thời gian trên x86 Linux lên 30% mà vẫn giữ nguyên độ chính xác của đồng hồ hệ thống.

Đo lường hiệu năng của bộ đếm thời gian

OpenTelemetry sử dụng hai trường thời gian cho mỗi span: thời gian bắt đầu và kết thúc. Thư viện client chính thức thực hiện việc này bằng cách gọi đồng hồ hệ thống và đồng hồ đơn điệu (monotonic clock) để tính toán khoảng thời gian.

Tuy nhiên, việc gọi hệ thống 3 lần cho mỗi span có ảnh hưởng lớn đến hiệu năng không? Trên thực tế, hầu hết các ứng dụng sử dụng C library đều định tuyến lệnh gọi clock_gettime() thông qua vDSO (virtual Dynamic Shared Object) để tránh chi phí chuyển đổi ngữ cảnh (context-switch) vào kernel. Nhưng hãy xem một bài kiểm tra nhanh (benchmark) trên máy tính xách tay của tôi:

Benchmark DurationBenchmark Duration

Kết quả cho thấy thời gian lặp lại từ 46 đến 49 ns—gần như ăn hết toàn bộ ngân sách thời gian cho một span chỉ để lấy dấu thời gian! Rõ ràng cách tiếp cận "ngây thơ" này không đủ tốt.

Hiểu về TSC (Time Stamp Counter)

Bất kỳ ai từng viết benchmark về kiến trúc vi xử lý trên nền tảng x86 đều quen thuộc với bộ đếm dấu thời gian của CPU, hay TSC. Đây là bộ đếm 64-bit tăng lên với tốc độ không đổi bất kể trạng thái nguồn của CPU (Invariant TSC). Đặc tính này cho phép hệ điều hành sử dụng TSC làm nguồn thời gian cho đồng hồ tường (wall clock).

Việc đọc TSC hiệu quả hơn nhiều so với các phương pháp khác, nhưng nó không miễn phí. Chi phí bao gồm hai phần: chính lệnh đọc TSC (như rdtsc) khá chậm, và luồng lệnh cần được tuần tự hóa (serialize) trước khi đọc để đảm bảo độ chính xác, thường dùng lfence hoặc rdtscp.

Khi Syscall không thực sự là Syscall

Để tránh chuyển đổi sang kernel mode, Linux sử dụng vDSO. Thay vì gọi syscall, thư viện C sẽ gọi một hàm trong vùng nhớ chia sẻ được ánh xạ vào tiến trình (vDSO). Vùng nhớ này chứa dữ liệu từ kernel gọi là trang dữ liệu vvar (hoặc data page).

Trên trang dữ liệu này, kernel lưu trữ các thông tin cần thiết để tính toán thời gian hiện tại, bao gồm số chu kỳ (cycles) của TSC và các hệ số để chuyển đổi chu kỳ thành nano-giây. Điều này cho phép người dùng (userspace) tính toán thời gian mà không cần gọi kernel.

Tuy nhiên, vDSO vẫn thực hiện nhiều việc thừa thãi trong trường hợp sử dụng của chúng ta: nó phải tải dữ liệu, đọc TSC, chuyển đổi chu kỳ sang nano-giây và chuẩn hóa ra giây/giây.

Đồng hồ Monotonic nhanh hơn

Để tối ưu, chúng ta có thể bỏ qua việc chuyển đổi sang định dạng giây/nano-giây phức tạp và chỉ làm việc với chu kỳ CPU.

Bước đầu tiên là thay thế việc gọi đồng hệ thống bằng cách đọc TSC trực tiếp. Kết quả cho thấy việc sử dụng ước tính chu kỳ (period estimate) để nhân thay vì chia giúp cải thiện hiệu năng đáng kể.

Tuy nhiên, chúng ta vẫn đọc TSC hai lần ở đầu span và thực hiện các phép chuyển đổi thừa. Liệu có thể đi xa hơn nữa?

Tự tạo vDSO của riêng mình

Chúng ta biết rằng vDSO chỉ là một giao diện thuận tiện để đọc trang dữ liệu vvar. Không có gì ngăn cản chúng ta thực hiện logic này cho riêng mình. Chúng ta có thể lấy địa chỉ của trang dữ liệu vvar từ auxiliary vector của tiến trình và đọc trực tiếp.

Delta DurationDelta Duration

Bằng cách viết một lớp VdsoTimer tùy chỉnh, chúng ta có thể:

  1. Sử dụng atomic C++ để xử lý seqlock.
  2. Đọc TSC bên trong vòng lặp để cải thiện hiệu năng trung bình.
  3. Cache bộ nhân và dịch (multiplier/shift) ngay trong bộ đếm thời gian để tránh đọc lại trang dữ liệu.

Kết quả là chúng ta đã cắt giảm hơn một nửa chi phí thời gian ban đầu!

Đo lường độ trễ cực đại (Tail Latency)

Tuy nhiên, hiệu năng trung bình không phải là tất cả. Vấn đề nằm ở những khoảnh khắc kernel cập nhật trang dữ liệu (mỗi tick). Điều này có thể gây ra cache miss L1/L2 và buộc bộ đếm thời gian phải chờ (spin).

L1 MissesL1 Misses

Như biểu đồ trên cho thấy, mỗi khi cập nhật dữ liệu (tại các khoảng 1ms), độ trễ có thể tăng vọt lên hơn 200 ns so với trung bình—gấp 4 lần ngân sách của chúng ta.

Bộ đếm thời gian ổn định (Stable Timers)

Để giải quyết vấn đề độ trễ cực đại, chúng ta có thể cache dữ liệu từ trang vvar. Các hệ thống ổn định hiếm khi điều chỉnh đồng hồ một cách gián đoạn, và các điều chỉnh tần số diễn ra đủ chậm nên việc bỏ sót một vài lần cập nhật là chấp nhận được.

Chúng ta có thể làm mới cache này ở tần suất thấp (ví dụ: trong vòng lặp sự kiện chính) khi biết rằng mình có đủ chu kỳ CPU dư thừa.

Cached PerformanceCached Performance

Cả hai triển khai TscCacheTimerVdsoCacheTimer đều hoàn toàn tránh được các độ trễ cực đại tai hại này, mang lại hiệu năng cực kỳ ổn định.

Kết luận

Chúng ta đã triển khai thành công hai bộ đếm thời gian hiệu quả với hiệu năng dễ dự đoán (không có độ trễ cực đại). Tuy nhiên, việc l обход qua (bypass) vDSO có một nhược điểm rõ ràng: mỗi khi bố trí trang dữ liệu thay đổi (ví dụ trong Linux 6.15), chúng ta phải cập nhật mã nguồn.

Do đó, hầu hết các ứng dụng nên cân nhắc sử dụng TscCacheTimer, miễn là việc mất một chút độ chính xác không phải là vấn đề lớn.

Quan trọng hơn, bài học ở đây là các bài benchmark thông thường thường chỉ phản ánh một phần bức tranh. Nếu bạn cần độ trễ dễ dự đoán, bạn không thể chỉ dựa vào các số liệu thống kê trung bình. Bạn cần hiểu sâu những gì nằm dưới lớp trừu tượng mà chúng ta dựa vào để đoán xem điều gì có thể ảnh hưởng đến hiệu năng của chúng ta.

Lưu ý: Hầu như không ai nên làm điều này trừ khi bạn đang xây dựng hệ thống có độ trễ cực thấp (low-latency) như high-frequency trading hoặc tracing library chuyên sâu.

Bài viết được tổng hợp và biên soạn bằng AI từ các nguồn tin tức công nghệ. Nội dung mang tính tham khảo. Xem bài gốc ↗