Skip to content
RetrogamingDeep Dive Published Updated 7 min readViews unavailable

Libretro Performance Interface: Counters, Timers, and CPU Feature Detection

Use the libretro perf callback to register counters, measure hot paths, query CPU SIMD features, and log results without host-dependent tooling.

A libretro core can ask the frontend for a performance interface that provides timing callbacks, CPU feature detection, and a counter registration API. It lets a core instrument its own hot paths-CPU emulation, rendering, or audio mixing-without embedding a host-specific timer. It is not a portable interface to hardware PMU event counters. The interface is obtained through RETRO_ENVIRONMENT_GET_PERF_INTERFACE (value 28) and returns a retro_perf_callback structure whose function pointers are frontend-supplied.

The contract is intentionally narrow. The frontend decides what timer source to use, what a “tick” represents, and how perf_log surfaces results. The header says the frontend sets the callback functions, but does not define feature-by-feature negotiation for a partial callback table. Defensive pointer checks are useful for resilience, but should not be presented as a standard partial-interface contract. Also never assume registered counters persist across core unload or content change.

Obtain the interface early and guard every call

Request the interface during core initialization or content setup, such as from retro_init or retro_load_game, after retaining the environment callback passed to retro_set_environment. This keeps the query out of the callback-installation step and makes the chosen lifecycle explicit; the libretro header does not specify a required profiling-subsystem initialization point. Store the returned retro_perf_callback in a core-global or context structure and check it before each use:

static struct retro_perf_callback perf_cb = {0};

static bool init_perf(retro_environment_t environ_cb)
{
    return environ_cb(RETRO_ENVIRONMENT_GET_PERF_INTERFACE, &perf_cb);
}

If the call returns false, the frontend does not expose the interface. Proceed without profiling rather than falling back to host clock_gettime or rdtsc if portability is required. When the call succeeds, the header describes all callback functions as frontend-supplied; a defensive core may still check each pointer before use, but a missing pointer is an incomplete implementation rather than a documented negotiated subset.

CPU feature detection is for runtime dispatch, not display

get_cpu_features returns a bitmask of RETRO_SIMD_* flags (SSE, SSE2, AVX, NEON, VMX, etc.). This is a frontend-reported view of the host CPU, not a guarantee that the core’s process can execute those instructions. The operating system, hypervisor, or sandbox may mask features that the hardware supports. Use the bitmask only to select between pre-compiled implementation variants at runtime:

if (perf_cb.get_cpu_features) {
    uint64_t features = perf_cb.get_cpu_features();
    if (features & RETRO_SIMD_AVX2)
        use_avx2_path();
    else if (features & RETRO_SIMD_SSE2)
        use_sse2_path();
    else
        use_scalar_path();
}

Do not log or display the raw bitmask as a complete system specification. It is the frontend’s detected feature set for dispatch, not a hardware inventory or a portable statement about every execution environment.

Register counters once, start/stop many times

A retro_perf_counter is owned by the core. Initialize its ident to a unique, human-readable string (the header example uses the macro #name to stringify the variable name), zero start, total, call_cnt, and registered, then pass it to perf_register. The header says a call does nothing if that counter was already registered; it does not permit different counters to reuse the same identifier. After registration, perf_start and perf_stop bracket the measured region:

static struct retro_perf_counter cpu_emulation_counter = {
    .ident = "cpu_emulation"
};

void retro_run(void)
{
    if (perf_cb.perf_register && !cpu_emulation_counter.registered)
        perf_cb.perf_register(&cpu_emulation_counter);

    if (cpu_emulation_counter.registered &&
        perf_cb.perf_start && perf_cb.perf_stop) {
        perf_cb.perf_start(&cpu_emulation_counter);
        emulate_cpu_frame();
        perf_cb.perf_stop(&cpu_emulation_counter);
    } else {
        emulate_cpu_frame();
    }

    /* ... video, audio, input ... */
}

The counter’s total accumulates ticks across frames, and call_cnt increments on each perf_start. The tick unit is frontend-defined (retro_perf_tick_t, typically nanoseconds or CPU cycles). Do not convert ticks to seconds unless the frontend documents its unit; instead, compare relative totals between counters or across runs on the same frontend build.

Counter lifetime and frontend cleanup

The header states: “The frontend must also clean up any references to a core’s performance counters before unloading it, otherwise undefined behavior may occur.” A core must not rely on counter inspection after its unload lifecycle begins. If a frontend supports live counter inspection, it may query the counter structs while the core is loaded. When the core unloads, the frontend must clean up references; do not retain assumptions about its internal tracking afterward.

If the core supports multiple content loads in one session (e.g., via subsystems or retro_load_game called again), follow the selected frontend’s documented counter lifetime. Do not assume that calling perf_register again clears registered or resets the frontend’s accumulated values; the header does not promise either behavior. If the frontend requires new counter objects, retire the old objects only after its references have been cleaned up.

Timer and raw counter access

get_time_usec returns system time in microseconds, but the header does not specify its epoch or monotonicity. get_perf_counter returns ticks from an unspecified epoch; their meaning depends on the platform and frontend. Use perf_start/perf_stop for the interface’s standard measured-region pattern, and use raw timer differences only when the selected frontend documents the needed semantics. The header also does not promise thread-safety; keep access on a controlled core thread unless the frontend documents broader guarantees.

Logging results

perf_log asks the frontend to log or display performance-counter state; its format and destination are frontend-defined. Check the chosen frontend’s current documentation or implementation to learn where results appear. Call it sparingly or at a lifecycle point such as retro_deinit; do not call it every frame, where formatting and output can overwhelm the code being measured.

void retro_deinit(void)
{
    if (perf_cb.perf_log)
        perf_cb.perf_log();
}

Caveats and version boundaries

  • The callback structure has no separate interface-version field. The header says the frontend sets the functions and does not define partial-interface negotiation; defensive null checks can protect against incomplete implementations, but a missing callback is not a documented subset contract.
  • Tick units are not standardized. Comparing total values across different frontends, frontend versions, or host architectures is not meaningful. Only intra-run, same-frontend comparisons are valid.
  • get_cpu_features returns the frontend’s detected feature bitmask for runtime dispatch. The header does not define when that value is refreshed; do not assume it tracks later changes to the execution environment.
  • perf_register returns no status value, but the header specifies that it sets counter->registered true on success and false otherwise. Registration may fail if a frontend-defined maximum (if any) has been reached; no portable numeric limit is specified.
  • The interface does not provide sampling, call graphs, or memory profiling. It is a manual instrumentation API, not a profiler frontend.
  • The header documents perf_start/perf_stop for registered counters. Always register a counter before starting it; do not rely on behavior outside that contract.
  • The header’s RETRO_PERFORMANCE_INIT example checks !name.registered before registering. Treat that as the header’s example pattern, not a general reset or reload contract: the header does not define how a frontend handles a reused counter object after content changes or core reload.

Test strategy

  1. Compile against the canonical libretro.h at a pinned revision (e.g., the libretro-common commit referenced below). Record that revision in test results.
  2. Run the core on a frontend whose exact build has been verified to expose the performance interface and its callbacks. Record the frontend version/configuration, then verify the documented perf_log destination and that total and call_cnt change as measured regions execute.
  3. Run the core on a frontend that returns false for RETRO_ENVIRONMENT_GET_PERF_INTERFACE. Verify emulation proceeds without crashes or null-pointer dereferences.
  4. Add a fake test frontend that returns true but deliberately omits callbacks. Verify defensive checks fail closed without crashing; treat this only as a robustness test, not as a supported partial-interface contract.
  5. Exercise content unload/reload using the chosen frontend. Record whether it retains or resets counters; the header says registering an already-registered counter is a no-op, so do not assume that call resets the counter or that totals are isolated by content session.
  6. Test CPU feature dispatch on at least two distinct host architectures (e.g., x86-64 with AVX2 and ARM64 with NEON) to confirm the bitmask drives the correct code path.

Related:

Sources:

Comments