Introducción

Si tu aplicación requiere timestamps con precisión de submicrosegundos y bajo overhead (ej: pipelines de 1–10μs por etapa, tracing distribuido con OpenTelemetry, o benchmarks de microarquitectura), el método estándar de Linux mediante clock_gettime() puede consumir entre el 40% y 60% de tu presupuesto de latencia por timestamp. En un escenario típico, cada llamada a now() en C++ invoca tres accesos a relojes distintos (REALTIME y MONOTONIC), cada uno enrutado a través de vDSO (Virtual Dynamic Shared Object), que aunque evita el syscall, aún introduce overhead por:

  • Lectura secuencial de la data page compartida entre kernel y userspace.
  • Protección con seqlock para sincronización.
  • Cálculo de conversión de ciclos a nanosegundos en userspace.

Esta guía demuestra cómo reducir ese overhead un 30% usando directamente el Timestamp Counter (TSC) del CPU, evitando vDSO y manteniendo precisión sub-microsegundo. El enfoque es válido para kernels Linux ≥6.1 y CPUs x86 con TSC invariant (verificable con grep constant_tsc /proc/cpuinfo).

Qué es y para qué sirve

El problema: overhead oculto en timestamps estándar

El método convencional para obtener timestamps en Linux (vía std::chrono::steady_clock en C++ o System.currentTimeMillis() en Java) se apoya en clock_gettime(CLOCK_MONOTONIC, ...), que:

  1. No es un syscall real: la libc lo redirige a vDSO, que lee una estructura compartida (vdso_data) actualizada por el kernel en cada tick (tipicamente 1ms).
  2. Requiere cálculos en userspace: la conversión de ciclos TSC a nanosegundos se hace con multiplicadores (mult/shift) almacenados en la data page.
  3. Duplica trabajo: Para un span de tracing, necesitas tres timestamps (start realtime, monotonic start, monotonic end), multiplicando el overhead.
Resultado: En un CPU Skylake, cada llamada a clock_gettime() mediante vDSO ronda 46–49ns, un presupuesto inviable para aplicaciones que requieran <100ns por timestamp.

La solución: usar TSC directamente

El Timestamp Counter (TSC) es un registro de 64 bits en el CPU que:

  • Invariant TSC: No se detiene con C-states ni cambios de frecuencia (verificar con grep nonstop_tsc /proc/cpuinfo).
  • Precisión de ciclos: La frecuencia base es típicamente 1–3GHz (ej: 3.0GHz = 0.33ns por ciclo).
  • Sin overhead de vDSO: Leer TSC requiere solo la instrucción rdtsc (o rdtscp para serialización), sin accesos a memoria compartida.
Objetivo: Implementar un wrapper en C++ que:
  1. Use rdtscp para leer TSC con serialización.
  2. Convierta ciclos TSC a nanosegundos usando parámetros del kernel (obtenidos vía /sys/devices/system/clocksource/clocksource0/current_clocksource y /proc/cpuinfo).
  3. Evite vDSO y syscalls.

Prerequisitos

ComponenteVersión/Configuración mínimaCómo verificar
Kernel Linux≥6.1 (cambios en *vdso* desde 6.15)BLOCK22
CPU x86Soporte TSC invariant (Intel Nehalem+)BLOCK23
CompiladorGCC ≥12 o Clang ≥16BLOCK24
LibreríasBLOCK25, BLOCK26BLOCK27
PermisosAcceso a BLOCK28BLOCK29
Acceso a CPUModo usuario sin restricciones (no *isolated*)BLOCK30
Nota crítica: Si tu CPU no soporta TSC invariant (falta constant_tsc o nonstop_tsc), este método fallará y deberías usar clock_gettime() con vDSO. Verificá con:
grep -E 'constant_tsc|nonstop_tsc' /proc/cpuinfo

Si no hay salida, abortá y usá el método estándar.

Guía paso a paso

Paso 1: Verificar el clocksource actual y parámetros TSC

Obtené los parámetros necesarios para convertir ciclos TSC a nanosegundos:

# Clocksource activo (debe ser "tsc" para este método)
cat /sys/devices/system/clocksource/clocksource0/current_clocksource

# Frecuencia base del TSC (en kHz; ejemplo: 2994000 = 2.994GHz)
cat /sys/devices/system/clocksource/clocksource0/current_clocksource_data | grep tsc_khz

# Multiplicador y shift del kernel (usados para conversión fixed-point)
cat /proc/cpuinfo | grep -E 'tsc:|cpu MHz'
Resultado esperado:
  • current_clocksource debe ser tsc.
  • tsc_khz debe ser un valor entre 1,000,000 y 4,000,000 (1–4GHz).
  • Los valores de tsc: ... y cpu MHz deben coincidir (ej: tsc: 2994 MHz).
Error común: Si current_clocksource es hpet o acpi_pm, no podés usar TSC. Cambiá el clocksource en el kernel con:
sudo grubby --update-kernel=ALL --args="clocksource=tsc tsc=reliable"

Luego reiniciá.

Paso 2: Crear un wrapper C++ para timestamps basados en TSC

Crea el archivo tsc_timer.hpp con el siguiente código:

#include <cstdint>
#include <chrono>
#include <stdexcept>
#include <fstream>
#include <string>

class TscTimer {
public:
    // Inicializa parámetros TSC desde /sys/devices
    static void init() {
        std::ifstream tsc_khz_file("/sys/devices/system/clocksource/clocksource0/current_clocksource_data");
        if (!tsc_khz_file) {
            throw std::runtime_error("No se pudo leer current_clocksource_data");
        }

        std::string line;
        while (std::getline(tsc_khz_file, line)) {
            if (line.find("tsc_khz:") != std::string::npos) {
                tsc_khz = std::stoul(line.substr(line.find(":") + 1));
                break;
            }
        }

        if (tsc_khz == 0) {
            throw std::runtime_error("TSC no soportado o clocksource no es tsc");
        }

        // nanosegundos por ciclo = 1e9 / (tsc_khz * 1000)
        tsc_ns_per_cycle = 1000000000.0 / static_cast<double>(tsc_khz * 1000);
    }

    // Obtiene timestamp en nanosegundos (monotónico)
    static uint64_t now_ns() {
        uint32_t lo, hi;
        // Usar rdtscp para serialización implícita
        asm volatile ("rdtscp" : "=a"(lo), "=d"(hi) : : "rcx");
        uint64_t tsc = ((uint64_t)hi << 32) | lo;

        // Ajuste por offset de inicio (opcional, si querés sincronizar con CLOCK_MONOTONIC)
        // tsc -= tsc_start_offset;
        return static_cast<uint64_t>(tsc * tsc_ns_per_cycle);
    }

private:
    static inline uint64_t tsc_khz = 0;        // Frecuencia TSC en kHz
    static inline double tsc_ns_per_cycle = 0.0; // Nanosegundos por ciclo TSC
};
Detalles clave:
  • rdtscp serializa automáticamente las instrucciones previas (evita out-of-order execution).
  • La conversión a nanosegundos usa aritmética de punto flotante para precisión (el kernel usa fixed-point, pero para <1μs de error, el error es despreciable).
  • Offset opcional: Si querés sincronizar con CLOCK_MONOTONIC, medí un timestamp de referencia al inicio y restalo a todos los timestamps.

Paso 3: Benchmarkear el overhead

Crea benchmark_tsc.cpp para comparar TscTimer vs std::chrono::steady_clock:

#include "tsc_timer.hpp"
#include <chrono>
#include <iostream>
#include <vector>

int main() {
    TscTimer::init();

    const int kIterations = 1'000'000;
    std::vector<uint64_t> tsc_timestamps;
    std::vector<uint64_t> chrono_timestamps;

    // Benchmark TSC
    auto tsc_start = TscTimer::now_ns();
    for (int i = 0; i < kIterations; ++i) {
        tsc_timestamps.push_back(TscTimer::now_ns());
    }
    auto tsc_end = TscTimer::now_ns();
    uint64_t tsc_total_ns = tsc_end - tsc_start;

    // Benchmark std::chrono
    auto chrono_start = std::chrono::steady_clock::now();
    for (int i = 0; i < kIterations; ++i) {
        chrono_timestamps.push_back(std::chrono::steady_clock::now().time_since_epoch().count());
    }
    auto chrono_end = std::chrono::steady_clock::now();
    auto chrono_total_ns = std::chrono::duration_cast<std::chrono::nanoseconds>(chrono_end - chrono_start).count();

    std::cout << "TSC overhead: " << (tsc_total_ns / kIterations) << " ns/iter\n";
    std::cout << "std::chrono overhead: " << (chrono_total_ns / kIterations) << " ns/iter\n";
}
Compilación:
g++ -O3 -march=native -std=c++20 benchmark_tsc.cpp -o benchmark_tsc
Resultado esperado (ejemplo en un i7-1185G7 @3.0GHz):
TSC overhead: 12 ns/iter
std::chrono overhead: 48 ns/iter
Diferencia: ~75% más rápido, dentro del presupuesto para tracing distribuido (<50ns por span).

Paso 4: Integrar con OpenTelemetry (ejemplo mínimo)

Extendé TscTimer para soportar timestamps para OpenTelemetry. Crea otel_tsc_adapter.hpp:

#include "tsc_timer.hpp"
#include <opentelemetry/sdk/trace/tracer_provider.h>
#include <opentelemetry/exporters/otlp/otlp_grpc_exporter.h>

class OtelTscAdapter {
public:
    static void configure_tracer() {
        auto provider = opentelemetry::sdk::trace::TracerProvider::Create();
        auto tracer = provider->GetTracer("tsc-tracer");
        // Usar TscTimer::now_ns() para timestamps en ns
        auto span = tracer->StartSpan("benchmark-tsc");
        span->SetAttribute("tsc.ns", TscTimer::now_ns());
    }
};
Integración con tu aplicación:
  1. Inicializá TscTimer::init() al inicio del proceso.
  2. Remplazá todas las llamadas a std::chrono::steady_clock::now() por TscTimer::now_ns() en tu cliente de tracing.

Paso 5: Validar precisión vs clock_gettime

Para asegurarte de que la conversión TSC→ns es precisa, compará contra clock_gettime(CLOCK_MONOTONIC, ...) en un bucle:

#include <time.h>

uint64_t clock_gettime_ns() {
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);
    return ts.tv_sec * 1'000'000'000ULL + ts.tv_nsec;
}

// En main():
auto tsc_ns = TscTimer::now_ns();
auto monotonic_ns = clock_gettime_ns();
std::cout << "Diferencia TSC vs MONOTONIC: " << (tsc_ns - monotonic_ns) << " ns\n";
Resultado esperado: Diferencia <1μs (el error típico es por redondeo en la conversión fixed-point del kernel). Nota: Si la diferencia es >10μs, tu CPU no tiene TSC invariant o el clocksource no es tsc.

Consideraciones y buenas prácticas

Limitaciones y riesgos

  1. TSC no invariant:
Síntoma: Diferencias >10μs entre TSC y clock_gettime().

Solución: Usá clock_gettime(CLOCK_MONOTONIC, ...) con vDSO o medí un offset dinámico.

  1. Cambios de frecuencia (Turbo Boost/thermal throttling):
Síntoma: Variaciones en la frecuencia TSC (verificá con watch -n 0.1 "grep MHz /proc/cpuinfo").

Solución: Usá CLOCK_MONOTONIC_RAW (si tu kernel es ≥5.0) o calculá un slope dinámico.

  1. Multithreading y migración de hilos:
Síntoma: Timestamp inconsistente entre cores (aunque TSC invariant sincroniza cores, la data page de vDSO no).

Solución: Fijá los hilos a un core con pthread_setaffinity_np o usá CLOCK_MONOTONIC.

  1. Kernel ≥6.15:
Cambio: La data page de vDSO se movió. Si tu kernel es ≥6.15, ajustá el offset en tsc_timer.hpp a 128 (antes era 0).

Alternativas si TSC no es viable

MétodoOverhead aproximadoPrecisiónRequisitos
BLOCK6446–49nsSub-microsegundovDSO activo (default)
BLOCK65 (kernel ≥5.0)30–35nsSub-microsegundoKernel ≥5.0
BLOCK6650+ nsSub-microsegundoKernel ≥5.0, PTP configurado
TSC directo (este método)10–15nsSub-microsegundoTSC invariant, kernel ≥6.1
Recomendación: Si no cumplís los prerequisitos para TSC, usá CLOCK_MONOTONIC_RAW (soportado en kernels modernos).

Conclusión

Implementar timestamps basados en TSC en Linux es un trade-off entre complejidad y performance crítica. Para aplicaciones donde cada nanosegundo cuenta (ej: tracing distribuido con OpenTelemetry en pipelines de 1–10μs, benchmarks de microarquitectura, o sistemas de trading de alta frecuencia), el ahorro de ~36ns por timestamp (un 75% de mejora) justifica el esfuerzo.

Pasos clave para implementarlo en producción:
  1. Verificá que tu CPU tenga TSC invariant (constant_tsc y nonstop_tsc).
  2. Asegurate de que el clocksource sea tsc (/sys/devices/system/clocksource/clocksource0/current_clocksource).
  3. Usá rdtscp con serialización y convertí ciclos TSC a nanosegundos usando la frecuencia base del kernel.
  4. Validá la precisión contra clock_gettime(CLOCK_MONOTONIC, ...) en tu entorno.
  5. Integrá el wrapper en tu cliente de tracing o benchmark.
Advertencia final: Este método es solo para casos dónde el overhead de vDSO es un cuello de botella demostrado. Para el 99.9% de las aplicaciones, std::chrono::steady_clock es suficiente y más portable.

Fuentes

Por Gustavo

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *