Measurement Precision
Every timing measurement has a precision limit: the smallest timing difference that can be reliably detected. This page explains how measurement precision affects your tests and what to do when you can’t achieve the precision you want.
What determines precision
Section titled “What determines precision”Three factors determine the smallest timing difference you can detect:
-
Timer resolution: How finely the hardware timer counts time. A timer that ticks every 42ns cannot distinguish differences smaller than that.
-
System noise: Background processes, CPU frequency changes, and cache effects add random variation to measurements. This noise makes small timing differences hard to distinguish from random fluctuation.
-
Sample count: More samples reduce the impact of noise through averaging, improving precision.
The library combines these factors into a measurement floor (θ_floor): the minimum detectable effect for your particular setup.
The measurement floor
Section titled “The measurement floor”When you request a threshold (say, SharedHardware at 0.6ns), the library checks whether that precision is actually achievable. If not, it automatically elevates the threshold to something measurable.
Example scenario:
You’re testing on Apple Silicon without elevated privileges:
- Timer resolution: 42ns (standard
cntvct_el0counter at 24 MHz) - After accounting for noise and sample count, θ_floor ≈ 21ns
You requested SharedHardware (0.6ns), but:
- The library cannot distinguish 0.6ns differences with a 42ns timer
- The effective threshold becomes 21ns instead
- Results tell you whether there’s a leak above 21ns, not 0.6ns
This isn’t a bug; it’s honest reporting. The library could pretend to test at 0.6ns and give you a false sense of confidence, but instead it tells you what precision was actually achieved.
Timer matrix
Section titled “Timer matrix”tacet supports multiple timing sources across platforms and languages. This table shows all available timers, including both standard and cycle-accurate options:
| Language | Platform | Timer | Precision | Requirements | Notes |
|---|---|---|---|---|---|
| Rust | x86_64 | rdtsc | ~0.3–1ns | None | CPU timestamp counter (cycle-accurate) |
| Rust | macOS ARM64 | cntvct_el0 | ~42ns | None | 24 MHz virtual timer (default) |
| Rust | macOS ARM64 | kperf PMU | ~0.3–1ns | sudo + single-threaded | Cycle-accurate via Apple’s kperf framework Enable: TimerSpec::CyclePrecision |
| Rust | Linux ARM64 | cntvct_el0 | 18–42ns or ~1ns | None | SoC-dependent (ARMv8.6+ is ~1ns) |
| Rust | Linux ARM64 | perf_event | ~0.3–1ns | sudo or CAP_PERFMON | Cycle-accurate via Linux perf_event API Enable: TimerSpec::CyclePrecision |
| Rust | Other | Instant::now() | ~100ns+ | None | OS-dependent fallback |
| C/C++ | x86_64 | rdtsc | ~0.3–1ns | None | Inline assembly (cycle-accurate) |
| C/C++ | macOS ARM64 | cntvct_el0 | ~42ns | None | Inline assembly (24 MHz virtual timer) |
| C/C++ | macOS ARM64 | kperf PMU | ~0.3–1ns | sudo + single-threaded | Cycle-accurate (planned) |
| C/C++ | Linux ARM64 | cntvct_el0 | 18–42ns or ~1ns | None | Inline assembly (SoC-dependent) |
| C/C++ | Linux ARM64 | perf_event | ~0.3–1ns | sudo or CAP_PERFMON | Cycle-accurate (planned) |
| Go | x86_64 | rdtsc | ~0.3–1ns | None | Assembly (cycle-accurate) |
| Go | macOS ARM64 | cntvct_el0 | ~42ns | None | Assembly (24 MHz virtual timer) |
| Go | macOS ARM64 | kperf PMU | ~0.3–1ns | sudo + single-threaded | Cycle-accurate (planned) |
| Go | Linux ARM64 | cntvct_el0 | 18–42ns or ~1ns | None | Assembly (SoC-dependent) |
| Go | Linux ARM64 | perf_event | ~0.3–1ns | sudo or CAP_PERFMON | Cycle-accurate (planned) |
| Go | Other | time.Now() | ~100ns+ | None | OS-dependent fallback |
| JavaScript/WASM | Linux | clock_gettime(CLOCK_MONOTONIC) | ~1–10ns | None | Via Bun.nanoseconds() or process.hrtime.bigint()vDSO fast path |
| JavaScript/WASM | macOS | mach_absolute_time() | ~10–100ns | None | Via Bun.nanoseconds() or process.hrtime.bigint() |
| JavaScript/WASM | Windows | QueryPerformanceCounter() | ~10–100ns | None | Via Bun.nanoseconds() or process.hrtime.bigint() |
Key points:
- Automatic timer selection: By default, tacet automatically selects the best available timer for your platform. You don’t need to configure anything unless you specifically want cycle-accurate timers on ARM64.
- Precision notification: If the available timer precision isn’t sufficient to measure at your requested threshold (e.g.,
SharedHardwareat 0.6ns on Apple Silicon with standard timer), tacet will:- Notify you that the threshold was elevated to match measurement capabilities
- Report the actual threshold tested in the results (
theta_effvstheta_user) - Still give you valid results at the achievable precision level
- x86_64: Cycle-accurate by default via
rdtsc— no special setup needed across all languages - ARM64 standard timers: Use
cntvct_el0virtual timer (42ns on Apple Silicon, varies on other SoCs) - ARM64 cycle-accurate timers (bold rows): Require elevated privileges but provide ~0.3–1ns precision
- macOS: kperf PMU requires both
sudoAND--test-threads=1(single-threaded restriction) - Linux: perf_event requires
sudoORCAP_PERFMONcapability - Rust: Available now via
TimerSpec::CyclePrecision - C/C++/Go: Planned support
- macOS: kperf PMU requires both
- JavaScript/WASM: Cannot access CPU cycle counters; relies on OS monotonic clocks (still sub-microsecond)
When do you need cycle-accurate timers?
Section titled “When do you need cycle-accurate timers?”Most users don’t need cycle-accurate timers. Consider your attacker model:
| Attacker Model | Threshold | Achievable With |
|---|---|---|
RemoteNetwork | 50 μs (~50,000ns) | ✓ All standard timers on all platforms |
AdjacentNetwork | 100ns | ✓ All standard timers on all platforms |
SharedHardware | 0.6ns (~2 cycles) | ✓ x86_64 standard timer ✓ ARM64 cycle-accurate timer (kperf/perf_event) |
Research | 0ns (any difference) | ✓ x86_64 standard timer ✓ ARM64 cycle-accurate timer (kperf/perf_event) |
Recommendations:
- Most users: Standard timers are sufficient.
AdjacentNetwork(100ns) works perfectly with all standard timers, including Apple Silicon’s 42ns timer. - Testing SharedHardware on ARM64: Use cycle-accurate timers (kperf/perf_event). Currently available in Rust; planned for C/C++/Go.
- JavaScript/WASM users: Standard timers only. Use
AdjacentNetworkthreshold, or test on x86_64 for better precision. - CI/cloud environments: Expect higher measurement floors due to virtualization noise;
AdjacentNetworkis a practical threshold.
Improving precision
Section titled “Improving precision”If your tests are hitting the measurement floor, you have several options:
Enable cycle-accurate timers (recommended)
Section titled “Enable cycle-accurate timers (recommended)”Use the CyclePrecision timer preset to request cycle-accurate measurements:
use tacet::{TimingOracle, AttackerModel, TimerSpec, helpers::InputPair};
let inputs = InputPair::new( || [0u8; 32], || rand::random::<[u8; 32]>());
TimingOracle::for_attacker(AttackerModel::SharedHardware) .timer_spec(TimerSpec::CyclePrecision) // Explicitly request cycle-accurate timer .test(inputs, |data| my_function(data));import { TimingOracle, AttackerModel } from '@tacet/tacet';
// Note: Cycle-accurate timers are not available in JavaScript/WASM// The library automatically uses the best available timer
const result = await TimingOracle.forAttacker(AttackerModel.SharedHardware) .test({ baseline: () => new Uint8Array(32), sample: () => crypto.getRandomValues(new Uint8Array(32)) }, (data) => myFunction(data));#include <tacet/tacet.h>
// Note: Cycle-accurate timers are not available in the C API// The library automatically uses the best available timer (rdtsc on x86_64)
to_config_t config = { .attacker_model = TO_ATTACKER_SHARED_HARDWARE, .time_budget_ms = 30000, .max_samples = 100000};
to_result_t result;int status = to_test(&config, my_callback, ctx, &result);#include <tacet/tacet.hpp>
// Note: Cycle-accurate timers are not available in the C++ API// The library automatically uses the best available timer (rdtsc on x86_64)
auto result = tacet::Oracle::forAttacker(tacet::AttackerModel::SharedHardware) .test([](std::span<uint64_t> baseline, std::span<uint64_t> sample) { // Generate baseline: all zeros std::fill(baseline.begin(), baseline.end(), 0);
// Generate sample: random data std::random_device rd; std::mt19937_64 gen(rd()); std::uniform_int_distribution<uint64_t> dist; for (auto& val : sample) { val = dist(gen); }
// Measure operation myFunction(baseline.data()); });package main
import "github.com/tacet-project/tacet-go"
// Note: Cycle-accurate timers are not available in the Go API// The library automatically uses the best available timer (rdtsc on x86_64)
func main() { result := tacet.Test( func(class tacet.Class) []byte { if class == tacet.Baseline { return make([]byte, 32) // All zeros } data := make([]byte, 32) rand.Read(data) return data }, func(data []byte) { myFunction(data) }, 32, tacet.WithAttacker(tacet.SharedHardware), )}Cycle-accurate timers require elevated privileges on ARM64:
# kperf requires both sudo AND single-threaded executionsudo -E cargo test --test my_tests -- --test-threads=1The --test-threads=1 is required because macOS kperf can only be accessed by one thread at a time.
# Option 1: Run as rootsudo cargo test
# Option 2: Grant capability (persistent)sudo setcap cap_perfmon+ep target/debug/my_test_binaryAccept the elevated threshold
Section titled “Accept the elevated threshold”If you can’t use cycle-accurate timers, consider whether the elevated threshold is acceptable:
- θ_floor of 21ns still catches most timing leaks
AdjacentNetwork(100ns) works well even with coarse timers- Only
SharedHardware(0.6ns) truly requires cycle-accurate precision
Use a custom threshold
Section titled “Use a custom threshold”Set a threshold that’s above your measurement floor:
use tacet::{TimingOracle, AttackerModel};
// If your floor is ~21ns, test at 25nsTimingOracle::for_attacker(AttackerModel::Custom { threshold_ns: 25.0 }) .test(inputs, |data| my_function(data));import { TimingOracle, AttackerModel } from '@tacet/tacet';
// If your floor is ~21ns, test at 25nsconst result = await TimingOracle.forAttacker({ custom: 25.0 }) .test({ baseline: () => new Uint8Array(32), sample: () => crypto.getRandomValues(new Uint8Array(32)) }, (data) => myFunction(data));#include <tacet/tacet.h>
// If your floor is ~21ns, test at 25nsto_config_t config = { .attacker_model = TO_ATTACKER_CUSTOM, .custom_threshold_ns = 25.0, .time_budget_ms = 30000, .max_samples = 100000};
to_result_t result;int status = to_test(&config, my_callback, ctx, &result);#include <tacet/tacet.hpp>
// If your floor is ~21ns, test at 25nsauto result = tacet::Oracle::forAttacker(tacet::AttackerModel::custom(25.0)) .test([](std::span<uint64_t> baseline, std::span<uint64_t> sample) { // ... });package main
import "github.com/tacet-project/tacet-go"
func main() { // If your floor is ~21ns, test at 25ns result := tacet.Test( generator, operation, 32, tacet.WithCustomThreshold(25.0), )}This gives you conclusive results at an achievable precision level.
When SharedHardware is achievable
Section titled “When SharedHardware is achievable”The SharedHardware preset (0.6ns, ~2 CPU cycles) is designed for detecting cycle-level leaks exploitable in SGX enclaves, containers, or cross-VM attacks. It’s achievable on:
- x86_64: Works with the standard
rdtsctimer - ARM64 with cycle-accurate timer: Works when running with sudo (+
--test-threads=1on macOS) - ARM64 without cycle-accurate timer: Not achievable (θ_floor is ~21ns)
If you need SharedHardware-level precision but can’t achieve it, consider:
- Testing on x86_64 hardware where rdtsc provides inherent precision
- Using
Custom { threshold_ns: 10.0 }as a realistic “shared hardware” threshold for ARM64 without cycle-accurate timing - Accepting that some ultra-fine-grained leaks may not be detectable on your platform
Reading precision in results
Section titled “Reading precision in results”The outcome includes diagnostic information about precision:
use tacet::Outcome;
match outcome { Outcome::Pass { diagnostics, .. } | Outcome::Fail { diagnostics, .. } => { // What you requested let requested = diagnostics.theta_user;
// What was actually tested let effective = diagnostics.theta_eff;
if effective > requested { println!( "Note: Threshold elevated from {:.1}ns to {:.1}ns", requested, effective ); } } // ...}import { Outcome } from '@tacet/tacet';
if (result.outcome === Outcome.Pass || result.outcome === Outcome.Fail) { const { diagnostics } = result;
// What you requested const requested = diagnostics.thetaUser;
// What was actually tested const effective = diagnostics.thetaEff;
if (effective > requested) { console.log( `Note: Threshold elevated from ${requested.toFixed(1)}ns to ${effective.toFixed(1)}ns` ); }}#include <tacet/tacet.h>
if (result.outcome == TO_OUTCOME_PASS || result.outcome == TO_OUTCOME_FAIL) { // What you requested double requested = result.diagnostics.theta_user;
// What was actually tested double effective = result.diagnostics.theta_eff;
if (effective > requested) { printf("Note: Threshold elevated from %.1fns to %.1fns\n", requested, effective); }}#include <tacet/tacet.hpp>
if (result.outcome == tacet::Outcome::Pass || result.outcome == tacet::Outcome::Fail) { const auto& diagnostics = result.diagnostics;
// What you requested double requested = diagnostics.theta_user;
// What was actually tested double effective = diagnostics.theta_eff;
if (effective > requested) { std::cout << "Note: Threshold elevated from " << std::fixed << std::setprecision(1) << requested << "ns to " << effective << "ns\n"; }}package main
import ( "fmt" "github.com/tacet-project/tacet-go")
func main() { result := tacet.Test(/* ... */)
if result.Outcome == tacet.Pass || result.Outcome == tacet.Fail { // What you requested requested := result.Diagnostics.ThetaUser
// What was actually tested effective := result.Diagnostics.ThetaEff
if effective > requested { fmt.Printf("Note: Threshold elevated from %.1fns to %.1fns\n", requested, effective) } }}A ThresholdElevated flag in diagnostics indicates when elevation occurred.