about summary refs log tree commit diff
diff options
context:
space:
mode:
authorMel <mel@rnrd.eu>2026-06-15 21:57:35 +0200
committerMel <mel@rnrd.eu>2026-06-15 22:36:05 +0200
commit1ea9aed16053cd4b5dbffbef69fb68b0f6fabddd (patch)
tree3f7d04c9a9211772b3ec309785d807e5fd0e83b6
downloadvcpu_steal-1ea9aed16053cd4b5dbffbef69fb68b0f6fabddd.tar.zst
vcpu_steal-1ea9aed16053cd4b5dbffbef69fb68b0f6fabddd.zip
Add vcpu_steal utility
Signed-off-by: Mel <mel@rnrd.eu>
-rwxr-xr-xvcpu_steal.c261
1 files changed, 261 insertions, 0 deletions
diff --git a/vcpu_steal.c b/vcpu_steal.c
new file mode 100755
index 0000000..ab391e9
--- /dev/null
+++ b/vcpu_steal.c
@@ -0,0 +1,261 @@
+/*
+ * vcpu_steal
+ *
+ * measures actual vcpu steal time from a host that's running as a
+ * virtual guest inside of kvm, like a vps from most common providers.
+ * we do this by busy-looping a cycle counter on a singular pinned vcpu,
+ * trying to find gaps between any iteration that count as a preemption
+ * by the hypervisor.
+ *
+ * usually the actual steal time is not exposed on cheap vps providers,
+ * and even more expensive providers, and the value in `/proc/stat`
+ * which should represent it is often just empty.
+ * since we fully pin a single cpu and run a busy loop, the only events
+ * that can interrupt us are guest-kernel preemption (timer ticks, irqs)
+ * and the host stealing our vcpu. the guest-kernel events are short and
+ * usually sub-microsecond or just a little higher, so our 5us threshold
+ * for found gaps filters them out as noise, leaving the host preemption
+ * as the primary thing producing the longer gaps we measure.
+ * this results in an error of +1-2% at the absolute most.
+ *
+ * reports the cycle counter gap value, alongside the kernel wall vs.
+ * cpu time for comparison.
+ * note that the guest kernel doesn't really know what's going on and
+ * thinks that any time stolen from a task by the hypervisor belongs
+ * to the running task, thus this is only here to contrast the tested
+ * value we compute.
+ *
+ * usage: ./vcpu_steal [seconds] [cpu]
+ *
+ * Copyright (c) 2026, Mel G. <mel@rnrd.eu>
+ *
+ * SPDX-License-Identifier: MPL-2.0
+ */
+
+#define _GNU_SOURCE
+
+#include <sched.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <time.h>
+#include <unistd.h>
+
+// recognition threshold for a preemption gap in nanoseconds.
+#define GAP_THRESHOLD_NS 5000.0
+
+// gap-size histogram buckets in microseconds.
+// last bucket collects all higher values.
+#define HISTOGRAM_BUCKET_COUNT 8
+static const double HISTOGRAM_BUCKETS_US[HISTOGRAM_BUCKET_COUNT - 1] = {
+    10, 50, 100, 500, 1000, 5000, 10000,
+};
+static const char* HISTOGRAM_LABELS[HISTOGRAM_BUCKET_COUNT] = {
+    "<10us", "10-50us", "50-100us", "100-500us",
+    "500us-1ms", "1-5ms", "5-10ms", ">=10ms",
+};
+
+// reads a monotonic cycle counter that keeps advancing while the host
+// runs someone else on our vCPU -- which is exactly what we want for
+// steal detection. on x86 we use rdtscp because it serializes prior
+// loads and stores past itself; on aarch64 we use cntvct_el0 with an
+// isb in front for the same reason.
+// reads monotonic cycle counter for our current vcpu,
+// depending on the architecture.
+#if defined(__x86_64__) || defined(__i386__)
+static inline uint64_t
+cycles_now(void)
+{
+    uint32_t lo, hi, aux;
+    // serializes prior loads and stores past itself
+    __asm__ volatile("rdtscp" : "=a"(lo), "=d"(hi), "=c"(aux));
+    return ((uint64_t)hi << 32) | lo;
+}
+#elif defined(__aarch64__)
+static inline uint64_t
+cycles_now(void)
+{
+    uint64_t v;
+    // w/ isb to match x86 behavior of prior synchronization
+    __asm__ volatile("isb; mrs %0, cntvct_el0" : "=r"(v));
+    return v;
+}
+#else
+#error "no cycle counter on this architecture, sorry :("
+#endif
+
+double
+timespec_diff_ns(struct timespec a, struct timespec b)
+{
+    return (b.tv_sec - a.tv_sec) * 1e9 + (b.tv_nsec - a.tv_nsec);
+}
+
+struct Calibration {
+    double cycles_per_second;
+};
+
+// calibrates cycle counter to find out rough time of cycles per second.
+struct Calibration
+calibrate(void)
+{
+    struct timespec t0, t1;
+    struct timespec hundred_ms = { .tv_sec = 0, .tv_nsec = 100 * 1000 * 1000 };
+
+    // both cycle and monotonic counters tick during preemption,
+    // making calibration work even if the vcpu gets stolen in the meantime!
+    clock_gettime(CLOCK_MONOTONIC, &t0);
+    uint64_t c0 = cycles_now();
+    nanosleep(&hundred_ms, NULL);
+    clock_gettime(CLOCK_MONOTONIC, &t1);
+    uint64_t c1 = cycles_now();
+
+    return (struct Calibration){
+        .cycles_per_second = (double)(c1 - c0) / (timespec_diff_ns(t0, t1) / 1e9),
+    };
+}
+
+void
+pin_to_cpu(int cpu)
+{
+    cpu_set_t set;
+    CPU_ZERO(&set);
+    CPU_SET(cpu, &set);
+    if (sched_setaffinity(0, sizeof(set), &set) != 0) {
+        perror("couldn't pin to cpu, sorry :(");
+        exit(EXIT_FAILURE);
+    }
+}
+
+struct Measurement {
+    // kernel values for cpu time and wall time
+    // if kernel can't access kvm steal-time msr, the difference should be near zero.
+    double wall_ns;
+    double cpu_ns;
+
+    // computed steal, as detected by cycle count gap detection
+    uint64_t gap_count;
+    double gap_total_ns;
+    double gap_max_ns;
+    uint64_t gap_histogram[HISTOGRAM_BUCKET_COUNT];
+
+    uint64_t iterations;
+};
+
+// finds bucket for a cycle count cap.
+uint32_t
+histogram_bucket_for(double gap_us)
+{
+    // start from largest catch-all and find last fitting bucket
+    for (uint32_t b = 0; b < HISTOGRAM_BUCKET_COUNT - 1; b++)
+        if (gap_us < HISTOGRAM_BUCKETS_US[b]) return b;
+    return HISTOGRAM_BUCKET_COUNT - 1;
+}
+
+// runs gap detection for a given amount of seconds.
+struct Measurement
+measure(double seconds, struct Calibration calibration)
+{
+    struct Measurement m = { 0 };
+
+    struct timespec wall_start, wall_end, cpu_start, cpu_end;
+    clock_gettime(CLOCK_MONOTONIC, &wall_start);
+    clock_gettime(CLOCK_THREAD_CPUTIME_ID, &cpu_start);
+
+    uint64_t start_cycles = cycles_now();
+    uint64_t end_cycles = start_cycles + (uint64_t)(calibration.cycles_per_second * seconds);
+    uint64_t prev = start_cycles;
+
+    while (1) {
+        uint64_t now = cycles_now();
+        double gap_ns = (double)(now - prev) * 1e9 / calibration.cycles_per_second;
+        // any gap larger than threshold counts as stolen time
+        if (gap_ns > GAP_THRESHOLD_NS) {
+            m.gap_count++;
+            m.gap_total_ns += gap_ns;
+            if (gap_ns > m.gap_max_ns) m.gap_max_ns = gap_ns;
+            m.gap_histogram[histogram_bucket_for(gap_ns / 1000.0)]++;
+        }
+        prev = now;
+        m.iterations++;
+        if (now >= end_cycles) break;
+    }
+
+    clock_gettime(CLOCK_MONOTONIC, &wall_end);
+    clock_gettime(CLOCK_THREAD_CPUTIME_ID, &cpu_end);
+
+    m.wall_ns = timespec_diff_ns(wall_start, wall_end);
+    m.cpu_ns = timespec_diff_ns(cpu_start, cpu_end);
+    return m;
+}
+
+void
+report(struct Measurement m, struct Calibration cal)
+{
+    printf("ran at %.3f GHz, for %lu iterations!\n\n",
+           cal.cycles_per_second / 1e9, m.iterations);
+
+    // what the kernel thinks
+    // only works if kernel has access to kvm steal-time msr.
+    double kernel_steal_ns = m.wall_ns - m.cpu_ns;
+    printf("kernel says:   %.3f%% stolen (wall=%.2fms, cpu=%.2fms, diff=%.2fms)\n",
+           kernel_steal_ns * 100.0 / m.wall_ns,
+           m.wall_ns / 1e6,
+           m.cpu_ns / 1e6,
+           kernel_steal_ns / 1e6);
+
+    // what we have detected
+    // how much the cycle counter advanced without us seeing progress on our loop.
+    printf("we say:        %.3f%% stolen (gaps=%lu, total=%.2fms, max=%.3fms)\n",
+           m.gap_total_ns * 100.0 / m.wall_ns,
+           m.gap_count,
+           m.gap_total_ns / 1e6,
+           m.gap_max_ns / 1e6);
+
+    printf("\nhistogram:\n");
+    for (uint32_t b = 0; b < HISTOGRAM_BUCKET_COUNT; b++)
+        printf("    %-9s = %6lu\n", HISTOGRAM_LABELS[b], m.gap_histogram[b]);
+    printf("\n");
+}
+
+struct Arguments {
+    double seconds;
+    int cpu;
+};
+
+struct Arguments
+parse_arguments(int argc, char* argv[])
+{
+    struct Arguments a = { .seconds = 10.0, .cpu = 0 };
+    if (argc > 1) a.seconds = atof(argv[1]);
+    if (argc > 2) a.cpu = atoi(argv[2]);
+    return a;
+}
+
+void
+usage(const char* program)
+{
+    fprintf(stderr,
+            "usage: %s [seconds] [cpu]\n"
+            "\n"
+            "    seconds  how long to probe for (default: 10.0)\n"
+            "    cpu      which cpu to pin to     (default: 0)\n",
+            program);
+}
+
+int
+main(int argc, char* argv[])
+{
+    if (argc > 1 && (argv[1][0] == '-' || argv[1][0] == 'h')) {
+        usage(argv[0]);
+        return EXIT_SUCCESS;
+    }
+
+    struct Arguments args = parse_arguments(argc, argv);
+    pin_to_cpu(args.cpu);
+
+    struct Calibration calibration = calibrate();
+    struct Measurement measurement = measure(args.seconds, calibration);
+    report(measurement, calibration);
+
+    return EXIT_SUCCESS;
+}