/*
* iolite - portable storage capability probe.
*
* Copyright (C) 2026 The iolite authors.
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Answers two questions honestly, in about ten minutes:
*
* 1. How many durable transactions per second?
* 2. How fast can blobs be ingested, once burst credit is exhausted?
*
* The second clause is the whole point. Cloud volumes are token buckets: an
* idle tenant gets to burst well above its sustained rate until the accumulated
* credit runs out. A short benchmark that starts measuring immediately reports
* the burst and calls it capacity. iolite therefore drains the bucket first,
* measures the burst while draining it (that number is worth knowing too), and
* gates every later phase on a steady-state detector.
*
* Design rules carried over from a 6-hour composite run on a quota-limited
* KVM guest, where each of these turned out to matter:
*
* - O_DIRECT everywhere, working set sized against RAM, so the page cache
* is not what is being measured.
* - Block content is a keyed function of (nonce, file, offset): unique so it
* cannot be deduplicated, full-entropy so it cannot be compressed, and
* recomputable so integrity is checked without storing checksums.
* - Transactions are modelled as read-modify-write plus a WAL append plus a
* flush, not as bare fsync calls. Bare flush rate overstates transaction
* rate by orders of magnitude.
* - Transaction latency is also measured open-loop, against a fixed arrival
* schedule, so a stall shows up as latency instead of as absent samples
* (coordinated omission).
* - The instrument self-checks: histogram bucket round-trip, and Little's Law
* on every closed-loop phase.
*
* The evidence behind each rule below - which host, which run, which number -
* lives in results/README.md and docs/. It is deliberately not repeated here:
* measurements date, and this file should not.
*
* Build: cc -O2 -D_FORTIFY_SOURCE=2 -static -pthread -o iolite iolite.c
* The hardening flag is not decoration. v1.0 resolved a path into a
* fixed buffer that glibc may write past; an unfortified build ran
* straight through it and a hardened one aborted before its first line
* of output. Drop -static only where static libc is unavailable.
* Deps: libc + pthreads. No libm, no external libraries.
*/
/* ------------------------------------------------------------------ licence
*
* iolite is free software: you can redistribute it and/or modify it under the
* terms of the GNU General Public License as published by the Free Software
* Foundation, either version 3 of the License, or (at your option) any later
* version.
*
* iolite is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
* details. You should have received a copy of the License along with this
* program, in COPYING; if not, see .
*
* ADDITIONAL PERMISSION under GNU GPL version 3 section 7
*
* The measurements are yours. Output produced by running this program - the
* report, the JSON record, the CSV series, and any table, plot or figure
* derived from them - is not a covered work, and you may use, publish,
* quote, advertise and sell it for any purpose, commercial or otherwise,
* with no obligation whatsoever under this License. Publishing a number
* measured with iolite obliges you to publish nothing else.
*
* This is deliberate and it is the point of the tool. iolite exists to say
* what a volume delivers once burst credit is gone, and that number is only
* worth measuring if the people who own the volume can put it in front of a
* customer. A licence that encumbered a provider's own results would simply
* guarantee they published results from a benchmark that flatters them
* instead, which is the outcome this program was written against.
*
* The permission covers output, not the program. Conveying iolite itself,
* modified or not, or any work based on this source, remains subject to the
* GPL in full. Anyone receiving a copy from you must be able to get the
* source and change it, exactly as you did.
*/
#define _GNU_SOURCE
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#define ALIGN 4096u
#define MAX_THREADS 64
#define HIST_BUCKETS 1920
/* The dense report identifies the BINARY, not the box. Which host produced a
* run is already in --json and in the -vv header; what the one-screen report
* needs to say is which version of the rules produced this verdict, because
* the thresholds move between versions and two runs from different versions
* are not comparable however similar they look. */
#define IOLITE_VERSION "1.0.1"
#define IOLITE_CODENAME "Pleochroic"
#define GIB (1024ULL * 1024 * 1024)
#define MIB (1024ULL * 1024)
/* ----------------------------------------------------------------- util */
static uint64_t now_ns(void) {
struct timespec t;
clock_gettime(CLOCK_MONOTONIC, &t);
return (uint64_t)t.tv_sec * 1000000000ULL + t.tv_nsec;
}
static void sleep_ns(uint64_t ns) {
struct timespec t = {(time_t)(ns / 1000000000ULL), (long)(ns % 1000000000ULL)};
while (nanosleep(&t, &t) == -1 && errno == EINTR) { }
}
static double dsqrt(double x) { /* avoid -lm for portability */
if (x <= 0) return 0;
double r = x, p = 0;
for (int i = 0; i < 60 && r != p; i++) { p = r; r = 0.5 * (r + x / r); }
return r;
}
static void die(const char *fmt, ...) {
va_list ap; va_start(ap, fmt);
fprintf(stderr, "iolite: "); vfprintf(stderr, fmt, ap);
fprintf(stderr, "\n"); va_end(ap);
exit(2);
}
/* ------------------------------------------------------------------ rng */
static inline uint64_t splitmix64(uint64_t x) {
x += 0x9E3779B97F4A7C15ULL;
x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9ULL;
x = (x ^ (x >> 27)) * 0x94D049BB133111EBULL;
return x ^ (x >> 31);
}
typedef struct { uint64_t s[4]; } rng_t;
static void rng_seed(rng_t *r, uint64_t seed) {
for (int i = 0; i < 4; i++) { seed = splitmix64(seed); r->s[i] = seed; }
}
static inline uint64_t rotl(uint64_t x, int k) { return (x << k) | (x >> (64 - k)); }
static inline uint64_t rng_next(rng_t *r) { /* xoshiro256** */
uint64_t *s = r->s, res = rotl(s[1] * 5, 7) * 9, t = s[1] << 17;
s[2] ^= s[0]; s[3] ^= s[1]; s[1] ^= s[2]; s[0] ^= s[3]; s[2] ^= t;
s[3] = rotl(s[3], 45);
return res;
}
/* Block content = f(nonce, fid, offset, salt): unique, incompressible,
* recomputable. Undedupable and uncompressible by construction, so a storage
* layer that compresses or deduplicates cannot make itself look faster.
*
* The salt exists because offset alone is not enough. A benchmark that wraps
* and rewrites its working set would otherwise put byte-identical data back at
* the same offset on every pass, and a backend that hashes incoming blocks -
* dedup, CoW, or a host cache that skips clean-page overwrites - can drop that
* write entirely. The result is a rewrite loop running at memory speed and a
* "sustained" figure that never touched the device. Every write therefore
* carries a fresh salt, which makes each pass genuinely new data.
*
* The salt is stored in the block header so a reader can recover it and still
* verify byte-exact with no stored checksums: it reads word 3, regenerates,
* and compares. Corruption changes the body; a misdirected write changes the
* offset in word 2. Both are still caught. */
static void gen_block(uint8_t *dst, uint64_t nonce, uint32_t fid, uint64_t off,
uint64_t salt) {
rng_t r;
rng_seed(&r, splitmix64(nonce ^ (0x9E3779B97F4A7C15ULL * (fid + 1)) ^ off
^ (0xD6E8FEB86659FD93ULL * salt)));
uint64_t *w = (uint64_t *)dst;
w[0] = 0x494F4C4954455F32ULL; /* "IOLITE_2" */
w[1] = nonce;
w[2] = ((uint64_t)fid << 48) | (off & 0xFFFFFFFFFFFFULL);
w[3] = salt;
for (size_t i = 4; i < ALIGN / 8; i++) w[i] = rng_next(&r);
}
static void gen_region(uint8_t *dst, size_t len, uint64_t nonce, uint32_t fid,
uint64_t off, uint64_t salt) {
for (size_t p = 0; p < len; p += ALIGN)
gen_block(dst + p, nonce, fid, off + p, salt);
}
/* Verifies a region read back from disk. The salt of each block is recovered
* from its own header, so this checks data written by any pass. Returns the
* number of mismatching blocks. */
static uint64_t verify_region(const uint8_t *got, uint8_t *scratch, size_t len,
uint64_t nonce, uint32_t fid, uint64_t off) {
uint64_t bad = 0;
for (size_t p = 0; p < len; p += ALIGN) {
uint64_t salt = ((const uint64_t *)(got + p))[3];
gen_block(scratch, nonce, fid, off + p, salt);
if (memcmp(got + p, scratch, ALIGN) != 0) bad++;
}
return bad;
}
/* ------------------------------------------------------------ histogram */
static inline int hist_idx(uint64_t v) {
if (v < 64) return (int)v;
int msb = 63 - __builtin_clzll(v);
int b = msb - 5;
int sub = (int)((v >> b) & 31);
int i = 64 + (b - 1) * 32 + sub;
return i < HIST_BUCKETS ? i : HIST_BUCKETS - 1;
}
/* Exact inverse of hist_idx. hist_idx maps v to sub = (v>>b)-32, so the bucket
* floor is (32+sub)<b[hist_idx(v)]++; }
static void hist_merge(hist_t *d, const hist_t *s) {
for (int i = 0; i < HIST_BUCKETS; i++) d->b[i] += s->b[i];
}
static uint64_t hist_count(const hist_t *h) {
uint64_t n = 0;
for (int i = 0; i < HIST_BUCKETS; i++) n += h->b[i];
return n;
}
static uint64_t hist_pct(const hist_t *h, double p) {
uint64_t total = hist_count(h), want = (uint64_t)(total * p / 100.0), acc = 0;
if (!total) return 0;
for (int i = 0; i < HIST_BUCKETS; i++) {
acc += h->b[i];
if (acc >= want) return hist_val(i);
}
return hist_val(HIST_BUCKETS - 1);
}
static uint64_t hist_max(const hist_t *h) {
for (int i = HIST_BUCKETS - 1; i >= 0; i--) if (h->b[i]) return hist_val(i);
return 0;
}
static double hist_mean(const hist_t *h) {
uint64_t n = 0; double s = 0;
for (int i = 0; i < HIST_BUCKETS; i++)
if (h->b[i]) { n += h->b[i]; s += (double)h->b[i] * hist_val(i); }
return n ? s / n : 0;
}
/* An instrument that is not checked is an assumption. */
static int hist_selftest(void) {
int bad = 0;
for (uint64_t v = 1; v < (1ULL << 42); v += 1 + v / 61) {
int i = hist_idx(v);
uint64_t lo = hist_val(i);
if (lo > v) bad++; /* floor above value */
if (i + 1 < HIST_BUCKETS && hist_val(i + 1) <= v) bad++; /* not tightest */
}
for (int i = 1; i < HIST_BUCKETS; i++)
if (hist_val(i) <= hist_val(i - 1)) bad++; /* monotonicity */
return bad;
}
/* The salt has to actually change the bytes, or the rewrite-absorption it
* exists to prevent comes back silently. Checks that two salts at the same
* offset differ, that verify_region accepts a block it generated, and that it
* rejects a single flipped bit. */
static int gen_selftest(void) {
int bad = 0;
/* plain malloc: nothing here touches a file, so O_DIRECT alignment is
irrelevant and xalloc is not declared yet at this point in the file */
uint8_t *a = malloc(ALIGN), *b = malloc(ALIGN), *s = malloc(ALIGN);
if (!a || !b || !s) { free(a); free(b); free(s); return 1; }
gen_block(a, 12345, 0, 4096, 1);
gen_block(b, 12345, 0, 4096, 2);
if (memcmp(a, b, ALIGN) == 0) bad++; /* salt did nothing */
if (verify_region(a, s, ALIGN, 12345, 0, 4096) != 0) bad++;
if (verify_region(b, s, ALIGN, 12345, 0, 4096) != 0) bad++;
b[ALIGN - 1] ^= 0x01;
if (verify_region(b, s, ALIGN, 12345, 0, 4096) != 1) bad++;
gen_block(a, 12345, 0, 8192, 1);
if (verify_region(a, s, ALIGN, 12345, 0, 4096) != 1) bad++; /* misdirected */
free(a); free(b); free(s);
return bad;
}
/* --------------------------------------------------- steady-state gate */
/* Rolling coefficient of variation over the last WIN samples. Steady state is
* declared when the window is flat; the burst is whatever was delivered above
* that level before it settled. */
#define SS_WIN 8
#define SS_MAX 4096
typedef struct {
double v[SS_MAX];
int n;
double peak, sustained, burst_bytes, settle_s;
bool settled;
} ss_t;
static double ss_cv(const double *v, int n) {
if (n < 2) return 1e9;
double m = 0;
for (int i = 0; i < n; i++) m += v[i];
m /= n;
if (m <= 0) return 1e9;
double s = 0;
for (int i = 0; i < n; i++) s += (v[i] - m) * (v[i] - m);
return dsqrt(s / (n - 1)) / m;
}
/* Drift across the window, as a fraction of its mean. A low CV alone is not
* steady state: a series climbing smoothly by 15% across the window still has
* a CV under 3%, and calling that "sustained" reports a number below what the
* volume actually delivers. Require flat *and* not trending. */
static double ss_drift(const double *v, int n) {
if (n < 3) return 1e9;
double xm = (n - 1) / 2.0, ym = 0;
for (int i = 0; i < n; i++) ym += v[i];
ym /= n;
if (ym <= 0) return 1e9;
double num = 0, den = 0;
for (int i = 0; i < n; i++) { num += (i - xm) * (v[i] - ym); den += (i - xm) * (i - xm); }
if (den == 0) return 1e9;
double slope = num / den;
return (slope * (n - 1)) / ym; /* signed, fraction of mean */
}
/* `allow_settle` gates acceptance on having written enough to evict any cache
* that could be holding the working set. On a virtualised host the hypervisor's
* page cache is invisible from inside the guest and can be far larger than
* guest RAM, so O_DIRECT and a working set sized against guest RAM are both
* insufficient - only write volume defeats it.
*
* A plateau must be paid for in its own currency. Eight flat seconds at a high
* rate is not evidence of a fast volume; it is a few GiB of writing, which any
* host with a write-back cache absorbs without noticing. A gate that scales
* with the working set (DRAIN_PASSES full rewrites) is satisfied almost
* immediately on a small guest, whose absorber may still be orders of magnitude
* deeper than anything it has written. Require instead that the drain has
* written a full minute at the candidate rate: fast plateaus must then survive
* proportionally more volume than slow ones, which is exactly the asymmetry the
* absorber exploits. */
#define SS_MIN_SECS 60
static bool ss_push(ss_t *s, double rate_mibps, double eps, bool allow_settle,
uint64_t written) {
if (s->n < SS_MAX) s->v[s->n++] = rate_mibps;
if (rate_mibps > s->peak) s->peak = rate_mibps;
if (s->settled || s->n < SS_WIN || !allow_settle) return false;
const double *w = s->v + s->n - SS_WIN;
double drift = ss_drift(w, SS_WIN);
if (drift > 2 * eps) return false; /* still ramping up */
if (drift < -2 * eps) return false; /* still decaying: credit remains */
if (ss_cv(w, SS_WIN) < eps) {
double m = 0;
for (int i = s->n - SS_WIN; i < s->n; i++) m += s->v[i];
m /= SS_WIN;
if ((double)written < SS_MIN_SECS * m * (double)MIB) return false;
s->sustained = m;
s->settle_s = s->n;
for (int i = 0; i < s->n - SS_WIN; i++) /* credit spent above it */
if (s->v[i] > s->sustained) s->burst_bytes += (s->v[i] - s->sustained) * MIB;
s->settled = true;
return true;
}
return false;
}
/* -------------------------------------------------------------- config */
typedef struct {
char dir[512];
uint64_t ws_bytes; /* data file */
uint64_t wal_bytes;
uint64_t nonce;
int page; /* transaction page size */
int clients;
int tx_reads, tx_writes;
int drain_max_s, phase_s, tx_s, meta_files;
double ss_eps;
bool json;
char json_path[512];
bool keep;
} cfg_t;
static cfg_t C;
static char PATH_DATA[600], PATH_WAL[600], PATH_BLOB[600];
/* --------------------------------------------------------------- verbosity
*
* Four tiers, and the split between them is by QUESTION, not by how much text
* someone can bear:
*
* -v what did I buy, is it negotiable, and how wrong can this be?
* One screen. Nothing that needs a second number to interpret.
* -vv the numbers behind each of those verdicts - for a reader checking
* the conclusion.
* -vvv the instrument's own state: CPU, schedule backlog, gate criteria,
* which guards fired and which stayed silent - for a reader who
* suspects the tool rather than the volume.
* -vvvv the raw series, emitted as CSV so it can be replotted - for the
* operator on the other side of the invoice, who is entitled to
* reconstruct the curve rather than take a verdict on trust.
*
* A withdrawal or a warning is never demoted by tier. If a verdict was taken
* back, that fact appears at -v, because the whole failure mode this program
* exists to prevent is a true number quoted without its caveat. */
static int V = 1;
static void vp(int lvl, const char *fmt, ...) {
if (V < lvl) return;
va_list ap;
va_start(ap, fmt);
vprintf(fmt, ap);
va_end(ap);
}
/* Phase headers. At -vv and above they are part of the transcript on stdout;
* at -v they go to stderr instead, because a 25-minute run that prints nothing
* until the end looks hung, and stdout at -v must stay exactly the report so
* that `./iolite > run.txt` captures the report and nothing else. */
static void phase(const char *fmt, ...) {
va_list ap;
va_start(ap, fmt);
if (V >= 2) vprintf(fmt, ap); else vfprintf(stderr, fmt, ap);
va_end(ap);
fflush(V >= 2 ? stdout : stderr);
}
/* 101300 -> "101.3k". The dense report has one column for a rate and one for
* the count behind it, and six digits of IOPS does not fit in either. */
static void fmt_si(char *b, size_t n, double v) {
if (v >= 1e6) snprintf(b, n, "%.1fM", v / 1e6);
else if (v >= 1e3) snprintf(b, n, "%.1fk", v / 1e3);
else snprintf(b, n, "%.0f", v);
}
/* ------------------------------------------------------- run profiles
*
* The binary has to be useful to someone who types `./iolite` and walks away,
* so every knob below has a defensible default and the only thing worth
* choosing is HOW LONG. That choice is not taste: GAME.md section 2 shows a
* test covering fraction tau of a planning window can be overstated by at most
* 1/tau however deep the seller's reservoir, so duration buys a bound and the
* bound is arithmetic. Each profile therefore names the window it defends
* rather than just a runtime.
*
* normal 25 min defends 1 h <= 2.4x
* extended 1 h defends 4 h <= 4.0x
* long 6 h defends 24 h <= 4.0x
* extra 24 h defends 7 d <= 7.0x
*
* The bound is NOT monotone in runtime, and that is the correct behaviour: a
* longer run defends a longer window, not more strongly. Pick by the window
* you must plan for. `budget_s` is a hard wall clock - every phase clamps
* itself against it - because the common deployment is a cron entry on a box
* someone else is using.
*
* soak_cycles > 0 repeats a compact re-measurement after the full probe. This
* exists because a single sustained figure is not a capacity: identical runs on
* one host can spread by half with no trend while another host holds a few
* percent, so the spread is a property of the host and only repetition tells
* you which kind you are on. soak_idle_s inserts idle between cycles; at 40 min
* it is past the gate at which burst credit has been seen to return, which
* makes `extra` the profile that can answer whether burst is recurrent. */
typedef struct {
const char *name;
int budget_s; /* hard wall clock for the whole run */
int window_s; /* planning window this profile claims to defend */
int drain_max_s; /* cap on the elastic phase */
int phase_s, tx_s, meta_files;
int soak_cycles; /* 0 = single probe */
int soak_idle_s; /* idle inserted before each soak cycle */
} profile_t;
static const profile_t PROFILES[] = {
{"normal", 25 * 60, 3600, 8 * 60, 45, 60, 20000, 0, 0},
{"extended", 60 * 60, 4 * 3600, 25 * 60, 90, 150, 50000, 0, 0},
{"long", 6 * 3600, 24 * 3600, 90 * 60, 120, 180, 50000, 4, 0},
{"extra", 24 * 3600, 7 * 86400, 3 * 3600, 120, 180, 50000, 8, 40 * 60},
};
#define NPROFILES ((int)(sizeof PROFILES / sizeof PROFILES[0]))
static const profile_t *P = &PROFILES[0];
static uint64_t G_deadline_ns; /* 0 = unbudgeted (flag-driven run) */
static const profile_t *profile_by_name(const char *s) {
if (!s || !*s) return NULL;
for (int i = 0; i < NPROFILES; i++) {
const char *n = PROFILES[i].name;
size_t k = 0;
while (n[k] && s[k] &&
(n[k] == (s[k] >= 'A' && s[k] <= 'Z' ? s[k] + 32 : s[k]))) k++;
if (!n[k] && !s[k]) return &PROFILES[i];
}
return NULL;
}
/* Estimated cost of the nine phases after the drain; defined near main, where
* the constants it sums over live. The drain needs it to know how much of the
* budget it is allowed to spend. */
static int tail_estimate_s(void);
/* Seconds left in the budget, or a very large number when unbudgeted. */
static int budget_left_s(void) {
if (!G_deadline_ns) return 1 << 28;
uint64_t now = now_ns();
if (now >= G_deadline_ns) return 0;
return (int)((G_deadline_ns - now) / 1000000000ULL);
}
/* ------------------------------------------------------------ io helpers */
static int open_direct(const char *p, int flags) {
int fd = open(p, flags | O_DIRECT, 0644);
if (fd < 0 && errno == EINVAL) {
fprintf(stderr, "iolite: O_DIRECT unsupported on %s, falling back "
"(results will include page-cache effects)\n", p);
fd = open(p, flags, 0644);
}
if (fd < 0) die("open %s: %s", p, strerror(errno));
return fd;
}
static void *xalloc(size_t n) {
void *p = NULL;
if (posix_memalign(&p, ALIGN, n) != 0) die("posix_memalign(%zu)", n);
memset(p, 0, n);
return p;
}
/* ------------------------------------------------ generic worker engine */
/* Sequential cursor shared by the threads of ONE cell. It must not be global:
* the concurrent ingest+query phase runs two cells at the same time, and a
* shared cursor (or shared thread array) would have them corrupt each other. */
typedef struct {
pthread_mutex_t lk;
uint64_t cur;
} seq_t;
/* A histogram loses the time axis, and the time axis is where a token bucket
* gives itself away: it refills on a timer, so stalls recur at a fixed period.
* One thread of one cell records its completions so that series can be
* autocorrelated afterwards. Sampling a single thread is enough - every thread
* stalls on the same empty bucket - and it keeps the timed path lock-free. */
#define TRACE_MAX 400000
typedef struct {
uint32_t n, cap;
uint32_t *t_us; /* completion, microseconds since cell start */
uint32_t *lat_us;
} trace_t;
typedef struct {
int tid, fd;
const cfg_t *c;
int bs, qd_unused;
int pattern; /* 0 rand, 1 seq */
int rw; /* 0 read, 1 write */
uint64_t span;
uint64_t ops, bytes;
hist_t h;
volatile int *stop;
uint64_t nonce_fid;
bool verify;
uint64_t bad;
seq_t *seq;
/* Every thread traces, into a private slice of one shared array, so the
timed path stays lock-free. The slices are compacted after the join. */
trace_t *tr;
uint32_t tr_base, tr_room, tr_n;
uint64_t cell_t0;
} w_t;
static void *worker(void *arg) {
w_t *w = (w_t *)arg;
uint8_t *buf = xalloc((size_t)w->bs);
uint8_t *vbuf = w->verify ? xalloc((size_t)w->bs) : NULL;
rng_t rng; rng_seed(&rng, w->c->nonce ^ (0x1234567ULL * (w->tid + 1)) ^ now_ns());
uint64_t nblk = w->span / (uint64_t)w->bs;
if (!nblk) nblk = 1;
while (!*w->stop) {
uint64_t off;
if (w->pattern == 1) {
pthread_mutex_lock(&w->seq->lk);
off = w->seq->cur;
w->seq->cur += (uint64_t)w->bs;
if (w->seq->cur + (uint64_t)w->bs > w->span) w->seq->cur = 0;
pthread_mutex_unlock(&w->seq->lk);
} else {
off = (rng_next(&rng) % nblk) * (uint64_t)w->bs;
}
uint64_t t0 = now_ns();
ssize_t n;
if (w->rw) {
/* Fresh salt per write, unique across the run: no offset is ever
overwritten with the bytes it already holds, so a dedup or
clean-page-skipping layer cannot absorb a rewrite pass. */
uint64_t salt = ((uint64_t)(w->tid + 1) << 48) | (w->ops + 1);
gen_region(buf, (size_t)w->bs, w->c->nonce, (uint32_t)w->nonce_fid,
off, salt);
n = pwrite(w->fd, buf, (size_t)w->bs, (off_t)off);
} else {
n = pread(w->fd, buf, (size_t)w->bs, (off_t)off);
}
if (n != (ssize_t)w->bs) {
if (n < 0 && errno == EINTR) continue;
die("io %s: %s", w->rw ? "write" : "read", strerror(errno));
}
uint64_t t1 = now_ns();
hist_add(&w->h, t1 - t0);
if (w->tr && w->tr_n < w->tr_room) {
uint32_t i = w->tr_base + w->tr_n++;
w->tr->t_us[i] = (uint32_t)((t1 - w->cell_t0) / 1000);
w->tr->lat_us[i] = (uint32_t)((t1 - t0) / 1000);
}
/* Deliberately outside the timed region above. */
if (vbuf)
w->bad += verify_region(buf, vbuf, (size_t)w->bs, w->c->nonce,
(uint32_t)w->nonce_fid, off);
w->ops++; w->bytes += (uint64_t)w->bs;
}
free(buf); free(vbuf);
return NULL;
}
static double ncpu_online(void) {
long n = sysconf(_SC_NPROCESSORS_ONLN);
return n < 1 ? 1.0 : (double)n;
}
/* System-wide CPU busy, for telling the guest's own exhaustion apart from the
* storage it is trying to measure.
*
* Process CPU is the wrong instrument and was tried first. getrusage() counts
* only this process's threads, and on a small guest the expensive part of a
* high IOPS rate is not in the process at all - it is softirq and virtio
* completion handling, charged to no one. A guard built on it reads a
* comfortable fraction while latency climbs by two orders of magnitude.
*
* iowait is deliberately NOT counted as busy: a CPU idle because the benchmark
* is blocked on I/O is exactly the healthy case. steal IS counted - CPU the
* hypervisor took away is CPU this guest did not have, and it inflates latency
* for a reason that has nothing to do with the volume. */
typedef struct { uint64_t busy, total; } cpustat_t;
static cpustat_t sys_cpu(void) {
cpustat_t c = {0, 0};
FILE *f = fopen("/proc/stat", "r");
if (!f) return c;
char tag[16];
uint64_t v[10] = {0};
if (fscanf(f, "%15s %" SCNu64 " %" SCNu64 " %" SCNu64 " %" SCNu64 " %" SCNu64
" %" SCNu64 " %" SCNu64 " %" SCNu64,
tag, &v[0], &v[1], &v[2], &v[3], &v[4], &v[5], &v[6], &v[7]) >= 5
&& !strcmp(tag, "cpu")) {
uint64_t user = v[0], nice = v[1], sys = v[2], idle = v[3];
uint64_t iowait = v[4], irq = v[5], softirq = v[6], steal = v[7];
c.busy = user + nice + sys + irq + softirq + steal;
c.total = c.busy + idle + iowait;
}
fclose(f);
return c;
}
/* Busy fraction between two samples. Returns 0 when /proc/stat is unavailable,
* which disables the guard rather than firing it on missing data. */
static double cpu_busy_frac(cpustat_t a, cpustat_t b) {
if (b.total <= a.total) return 0;
double dt = (double)(b.total - a.total);
double db = (double)(b.busy - a.busy);
double f = db / dt;
return f < 0 ? 0 : f > 1 ? 1 : f;
}
typedef struct {
double iops, mibps, secs;
uint64_t ops;
uint64_t p50, p99, p999, max;
double mean_us, inflight, little_ratio, cpu_frac;
int qd;
uint64_t bad;
} cell_t;
static cell_t run_cell_ex(const char *path, int bs, int qd, int pattern, int rw,
uint64_t span, int secs, uint32_t fid, bool verify,
int extra_flags, trace_t *tr) {
cell_t r; memset(&r, 0, sizeof r);
int fd = open_direct(path, (rw ? O_RDWR : O_RDONLY) | extra_flags);
if (qd > MAX_THREADS) qd = MAX_THREADS;
if (qd < 1) qd = 1;
/* per-cell, not static: two cells run concurrently in phase 6 */
w_t *w = calloc((size_t)qd, sizeof *w);
pthread_t *th = calloc((size_t)qd, sizeof *th);
if (!w || !th) die("calloc");
seq_t seq; pthread_mutex_init(&seq.lk, NULL); seq.cur = 0;
volatile int stop = 0;
cpustat_t cpu0 = sys_cpu();
uint64_t t0 = now_ns();
for (int i = 0; i < qd; i++) {
w[i] = (w_t){.tid = i, .fd = fd, .c = &C, .bs = bs, .pattern = pattern,
.rw = rw, .span = span, .stop = &stop, .nonce_fid = fid,
.verify = verify && !rw, .seq = &seq,
.tr = tr, .cell_t0 = t0,
.tr_base = tr ? (uint32_t)i * (tr->cap / (uint32_t)qd) : 0,
.tr_room = tr ? tr->cap / (uint32_t)qd : 0};
pthread_create(&th[i], NULL, worker, &w[i]);
}
sleep_ns((uint64_t)secs * 1000000000ULL);
stop = 1;
for (int i = 0; i < qd; i++) pthread_join(th[i], NULL);
double el = (double)(now_ns() - t0) / 1e9;
if (tr) { /* close the gaps between the slices */
uint32_t out = 0;
for (int i = 0; i < qd; i++)
for (uint32_t j = 0; j < w[i].tr_n; j++, out++) {
tr->t_us[out] = tr->t_us[w[i].tr_base + j];
tr->lat_us[out] = tr->lat_us[w[i].tr_base + j];
}
tr->n = out;
}
hist_t H; memset(&H, 0, sizeof H);
uint64_t ops = 0, bytes = 0, bad = 0;
for (int i = 0; i < qd; i++) {
hist_merge(&H, &w[i].h); ops += w[i].ops; bytes += w[i].bytes; bad += w[i].bad;
}
r.bad = bad;
close(fd);
pthread_mutex_destroy(&seq.lk);
r.secs = el; r.ops = ops;
r.iops = ops / el;
r.mibps = (double)bytes / MIB / el;
r.p50 = hist_pct(&H, 50); r.p99 = hist_pct(&H, 99);
r.p999 = hist_pct(&H, 99.9); r.max = hist_max(&H);
r.mean_us = hist_mean(&H) / 1000.0;
r.qd = qd;
/* Little's Law: in-flight should equal the thread count on a blocking
engine. A ratio far from 1 means the instrument, not the storage. */
r.inflight = r.iops * (hist_mean(&H) / 1e9);
r.little_ratio = qd ? r.inflight / qd : 0;
/* Little's Law cannot catch this one: run-queue delay on an oversubscribed
guest is indistinguishable from service time to a blocking engine, so the
ratio stays at 1.00 while the latency is the scheduler's. Measure it. */
r.cpu_frac = cpu_busy_frac(cpu0, sys_cpu());
free(w); free(th);
return r;
}
static cell_t run_cell_f(const char *path, int bs, int qd, int pattern, int rw,
uint64_t span, int secs, uint32_t fid, bool verify,
int extra_flags) {
return run_cell_ex(path, bs, qd, pattern, rw, span, secs, fid, verify,
extra_flags, NULL);
}
/* ------------------------------------ refill quantum: does the ceiling tick?
*
* A token bucket is refilled by a timer, not continuously. Work that arrives
* with the bucket empty waits for the next tick, so the latency series carries
* a period that saturated media has no reason to produce. Recovering it names
* the throttle's own parameters: the tick, and depth = rate x tick.
*
* Method: bin the per-op completion series at 1 ms and keep the worst latency
* in each bin, so a stall is a spike rather than being averaged away. Empty
* bins take the series mean, which contributes nothing to the correlation.
* Then autocorrelate and take the strongest peak away from the origin. */
#define TICK_BIN_US 1000
#define TICK_MIN_LAG 5 /* bins; below this it is only serial correlation */
#define TICK_MAX_LAG 2000 /* 2 s: longer than any plausible refill period */
#define TICK_MIN_R 0.20
#define TICK_MIN_PROM 0.15 /* peak must stand this far above the mean r */
#define TICK_MIN_RATIO 20.0 /* period must exceed this multiple of mean service */
typedef struct {
double period_ms, strength, depth_mib;
double half_ms[2], half_r[2], mean_ratio;
bool found, reproduced, above_service;
uint32_t samples;
} tick_t;
/* Find the period in one binned latency series. Split out of tick_analyze so
* the same rules can be applied to a subrange, which is what makes the
* reproducibility test below possible. Returns the lag in bins, 0 for none,
* and writes the correlation at that lag to *out_r. */
static uint32_t tick_find(const double *raw, uint32_t n, double *out_r,
bool emit) {
*out_r = 0;
if (n < 4 * TICK_MIN_LAG) return 0;
double *v = calloc(n, sizeof *v);
if (!v) return 0;
double sum = 0; uint32_t filled = 0;
for (uint32_t i = 0; i < n; i++) { sum += raw[i]; if (raw[i] != 0) filled++; }
if (filled < n / 4) { free(v); return 0; }
double mean = sum / n;
for (uint32_t i = 0; i < n; i++) v[i] = (raw[i] == 0 ? 0 : raw[i] - mean);
double denom = 0;
for (uint32_t i = 0; i < n; i++) denom += v[i] * v[i];
if (denom <= 0) { free(v); return 0; }
uint32_t maxlag = n / 4;
if (maxlag > TICK_MAX_LAG) maxlag = TICK_MAX_LAG;
if (maxlag <= TICK_MIN_LAG) { free(v); return 0; }
double *r = calloc(maxlag + 1, sizeof *r);
if (!r) { free(v); return 0; }
for (uint32_t lag = TICK_MIN_LAG - 1; lag <= maxlag; lag++) {
double acc = 0;
for (uint32_t i = 0; i + lag < n; i++) acc += v[i] * v[i + lag];
r[lag] = acc / denom;
}
/* A period is a local maximum. Without that test the strongest correlation
* is routinely the smallest lag examined - not a tick, but the tail of
* ordinary serial correlation leaking past the cutoff.
*
* Local maximality alone is not enough either: at the first eligible lag
* the test compares against a single neighbour inside the excluded region,
* so the shoulder of that same serial correlation clears it. Two further
* conditions, both of which a real timer meets and a shoulder does not:
*
* prominence the peak must stand above the typical correlation in the
* search range, not merely above its two neighbours;
* harmonic a periodic stall recurs, so lag 2k must also be elevated.
* A monotone decay has nothing at 2k. */
double rsum = 0; uint32_t rn = 0;
for (uint32_t lag = TICK_MIN_LAG; lag <= maxlag; lag++) { rsum += r[lag]; rn++; }
double rbar = rn ? rsum / rn : 0;
double best = 0; uint32_t best_lag = 0;
for (uint32_t lag = TICK_MIN_LAG; lag + 1 <= maxlag; lag++) {
if (!(r[lag] > best && r[lag] > r[lag - 1] && r[lag] >= r[lag + 1])) continue;
if (r[lag] < rbar + TICK_MIN_PROM) continue;
uint32_t h = 2 * lag;
if (h <= maxlag && r[h] < TICK_MIN_R * 0.5) continue;
best = r[lag]; best_lag = lag;
}
/* A periodic series correlates at every multiple of its period, so the
* strongest peak is not necessarily the period - it is only the multiple
* that happened to survive the noise, and which multiple that is changes
* between runs while the fundamental does not. Take the smallest qualifying
* peak within 70% of the strongest: the fundamental, not whichever harmonic
* won that day. */
if (best_lag) {
for (uint32_t lag = TICK_MIN_LAG; lag < best_lag; lag++) {
if (!(r[lag] > r[lag - 1] && r[lag] >= r[lag + 1])) continue;
if (r[lag] < rbar + TICK_MIN_PROM) continue;
if (r[lag] < 0.70 * best) continue;
uint32_t h = 2 * lag;
if (h <= maxlag && r[h] < TICK_MIN_R * 0.5) continue;
best = r[lag]; best_lag = lag;
break;
}
}
/* The autocorrelogram is the entire evidence for a refill tick, and a
* verdict of "policy" partly rests on it. At -vvvv it is emitted in full
* so the operator being told their volume is throttled can look at the
* same curve rather than at a conclusion drawn from it. */
if (emit && V >= 4) {
vp(4, "CSV acf_mean,%.4f\n", rbar);
for (uint32_t lag = TICK_MIN_LAG; lag <= maxlag; lag++)
vp(4, "CSV acf,%.1f,%.5f\n", lag * TICK_BIN_US / 1000.0, r[lag]);
}
free(r); free(v);
*out_r = best;
return best_lag;
}
static tick_t tick_analyze(const trace_t *tr, double mibps, double mean_us) {
tick_t k; memset(&k, 0, sizeof k);
k.samples = tr ? tr->n : 0;
if (!tr || tr->n < 2000) return k;
uint32_t last = 0; /* threads trace independently, so unsorted */
for (uint32_t i = 0; i < tr->n; i++) if (tr->t_us[i] > last) last = tr->t_us[i];
uint32_t span_ms = last / TICK_BIN_US + 1;
if (span_ms > 60000) span_ms = 60000; /* 60 s of bins is plenty */
if (span_ms < 4 * TICK_MIN_LAG) return k;
double *raw = calloc(span_ms, sizeof *raw);
if (!raw) return k;
for (uint32_t i = 0; i < tr->n; i++) {
uint32_t b = tr->t_us[i] / TICK_BIN_US;
if (b >= span_ms) break;
if ((double)tr->lat_us[i] > raw[b]) raw[b] = tr->lat_us[i];
}
double best = 0;
uint32_t best_lag = tick_find(raw, span_ms, &best, true);
/* Reproducibility, decided inside one run.
*
* A single-run detection is a fitted parameter, and this search has enough
* freedom - nearly two thousand candidate lags - to fit noise. Across
* repeat runs the difference is stark: a real timer keeps its period, and
* a fitted one wanders.
*
* That test needs two runs, which is one more than a report can assume.
* The same evidence is available inside a single run by halving the trace
* and asking the two halves independently: a genuine tick is in both, a
* peak fitted to noise is in neither, and one fitted to a transient is in
* only one. Agreement within a bin is required to report `found`. */
uint32_t half = span_ms / 2;
double r1 = 0, r2 = 0;
uint32_t lag1 = tick_find(raw, half, &r1, false);
uint32_t lag2 = tick_find(raw + half, span_ms - half, &r2, false);
k.half_ms[0] = (double)lag1 * TICK_BIN_US / 1000.0;
k.half_ms[1] = (double)lag2 * TICK_BIN_US / 1000.0;
k.half_r[0] = r1;
k.half_r[1] = r2;
/* Agreement has to include the whole-trace peak, not just the two halves
* with each other. Halves can land within a bin of each other, and so pass
* a halves-only test, while agreeing with neither the full trace nor with
* any previous run on the same host. A period that moves when you look at
* a different slice of the same trace is a fitted parameter wherever it is
* measured. */
uint32_t d1 = lag1 > best_lag ? lag1 - best_lag : best_lag - lag1;
uint32_t d2 = lag2 > best_lag ? lag2 - best_lag : best_lag - lag2;
k.reproduced = lag1 && lag2 && best_lag
&& r1 >= TICK_MIN_R && r2 >= TICK_MIN_R
&& (lag1 > lag2 ? lag1 - lag2 : lag2 - lag1) <= 1
&& d1 <= 1 && d2 <= 1;
free(raw);
/* The period must also be large compared with the cell's own service time.
* Below a few multiples of it, the autocorrelation structure IS the service
* process - a heavy-tailed latency distribution is self-similar within a
* run, so it reproduces across both halves and clears every test above
* while being nothing but the device. A timer that ticks every few service
* times is indistinguishable from the service; one that ticks every fifty
* is not. */
/* From best_lag, not k.period_ms: that field is not assigned until the
branch below, so reading it here yields 0 and withdraws every genuine
detection. */
double cand_ms = (double)best_lag * TICK_BIN_US / 1000.0;
k.mean_ratio = mean_us > 0 ? (cand_ms * 1000.0) / mean_us : 0;
k.above_service = k.mean_ratio >= TICK_MIN_RATIO;
if (best >= TICK_MIN_R && best_lag && k.reproduced && k.above_service) {
k.found = true;
k.period_ms = (double)best_lag * TICK_BIN_US / 1000.0;
k.strength = best;
k.depth_mib = mibps * k.period_ms / 1000.0;
} else {
k.strength = best;
k.period_ms = (double)best_lag * TICK_BIN_US / 1000.0;
}
return k;
}
/* --------------------------------------- phase 1: burst drain, and its exit
*
* The drain's whole purpose is to outlast the reservoir, so once the cliff is
* found the remaining budget buys nothing and the run should stop. The only
* question is what counts as finding it, and the answer has to be asymmetric,
* because the two mistakes cost different amounts:
*
* accept a fall that was noise -> a fabricated cliff, a sustained figure
* below capacity, and an early exit that
* removes the evidence
* miss a real cliff -> the run costs its full budget, which is
* what the budget was for
*
* So a step is accepted only when it is too large to be anything else. Half
* the plateau is that threshold, because a noisy host's own spread between two
* runs of the SAME configuration can reach a third of its rate. A 10-20% fall
* is inside that, and on a single run it cannot be told from the host having a
* bad minute. Falls in that band are
* therefore not accepted as cliffs and not used to end the run - they are
* handed to the overstatement model instead, which answers the question the
* cliff would have answered ("how wrong can the quoted number be?") without
* pretending to have seen a knee.
*
* Both the plateau and the current rate are medians of five 1 s samples. A
* mean lets one dropout manufacture a 50% fall, and one spike inflate the
* plateau that every later fall is measured against. */
#define CLIFF_DEEP 0.50 /* fall below the plateau that ends the run */
#define CLIFF_SHALLOW 0.10 /* below this, nothing happened at all */
#define CLIFF_MED 5 /* samples in the running median */
#define CLIFF_HOLD_S 120 /* confirm the post-cliff plateau for 2 min */
static double median_of(const double *v, int n) {
if (n <= 0) return 0;
double *t = malloc((size_t)n * sizeof *t);
if (!t) return v[n - 1];
memcpy(t, v, (size_t)n * sizeof *t);
for (int i = 1; i < n; i++) { /* insertion sort */
double x = t[i]; int j = i - 1;
while (j >= 0 && t[j] > x) { t[j + 1] = t[j]; j--; }
t[j + 1] = x;
}
double m = t[n / 2];
free(t);
return m;
}
static double med5(const double *v, int n) {
int k = n < CLIFF_MED ? n : CLIFF_MED;
return k ? median_of(v + n - k, k) : 0;
}
/* A run may propose a cliff several times; after this many withdrawals it stops
* proposing. A rate that swings by half repeatedly is a noisy host, not a
* reservoir, and continuing to test for a step wastes the budget on a series
* that has already answered the question. */
#define CLIFF_MAX_WITHDRAW 3
typedef struct {
double plateau; /* highest median rate held before the fall */
double post; /* median rate over the confirmation hold */
double drop; /* 1 - post/plateau */
double at_s; /* when the fall was proposed */
bool deep; /* proposed and confirmed by the hold */
bool shallow; /* in [CLIFF_SHALLOW, CLIFF_DEEP) */
bool early_exit; /* the hold confirmed and the drain stopped */
int withdrawn; /* proposals the hold refused */
double dip_drop; /* deepest proposal that failed to hold */
double dip_at_s;
bool locked; /* too many withdrawals: stop proposing */
} cliff_t;
/* One sample of the classifier, kept pure so it can be exercised without a
* volume. `plateau` is the caller's running high-water mark; it stops moving
* while a proposal is being held, otherwise a recovery during the hold would
* quietly redefine what the fall was measured against. */
static void cliff_step(cliff_t *cl, double *plateau, double cur, double t_s) {
if (cl->deep) return; /* holding: the caller collects samples */
if (cur > *plateau) *plateau = cur;
if (cl->locked) return;
double drop = *plateau > 0 ? 1.0 - cur / *plateau : 0;
if (drop < CLIFF_SHALLOW) return;
if (drop >= CLIFF_DEEP) {
cl->plateau = *plateau; cl->post = cur; cl->drop = drop; cl->at_s = t_s;
cl->deep = true; cl->shallow = false;
} else if (!cl->shallow) { /* record the first, do not act on it */
cl->plateau = *plateau; cl->post = cur; cl->drop = drop; cl->at_s = t_s;
cl->shallow = true;
}
}
/* The hold is a TEST, not a wait. A cliff is a step that stays down, so the
* proposal is only accepted if the median over the whole hold is still below
* the plateau by the same margin that raised it.
*
* This was not here at first, and the first run on real storage caught it: a
* momentary dip cleared the five-sample median, the rate was back at the
* plateau by the end of the hold, and the program reported a fraction of a
* percent as a deep cliff. Two minutes of plateau cannot be faked by a
* five-second dip; five samples can. */
static bool cliff_confirm(cliff_t *cl, const double *hold, int n) {
double hm = median_of(hold, n);
double d = cl->plateau > 0 ? 1.0 - hm / cl->plateau : 0;
cl->post = hm;
if (d >= CLIFF_DEEP) { cl->drop = d; return true; }
/* Withdrawn. Keep what was seen - a momentary halving is a fact about the
host worth reporting - but do not call it a cliff or end the run on it. */
if (cl->drop > cl->dip_drop) { cl->dip_drop = cl->drop; cl->dip_at_s = cl->at_s; }
cl->deep = false; cl->drop = d;
if (++cl->withdrawn >= CLIFF_MAX_WITHDRAW) cl->locked = true;
return false;
}
typedef struct {
double burst_peak_mibps, sustained_mibps, burst_gib, settle_s, fill_mibps;
bool settled;
uint64_t written;
double dsync_pre_mibps; /* O_DSYNC while the absorber is still charged */
cliff_t cliff;
double drain_s; /* actual duration of stage 2 */
bool budget_cut; /* stage 2 ended on the wall clock, not the data */
bool by_cliff; /* sustained accepted by the step, not by CV */
} drain_t;
/* Full rewrites of the working set required before steady state may be
* declared. Two passes evicts a host-side cache that held the whole set. */
#define DRAIN_PASSES 2
#define BLOB_BS (1 * MIB)
#define BLOB_QD 4
/* Writes sequentially, wrapping, until throughput flattens. Provisioning the
* working set and exhausting burst credit are the same operation, so this does
* both and reports what the burst was worth.
*
* Runs at the same block size and thread count as the later blob phase, so
* "sustained" and "raw sequential ingestion" are the same measurement taken at
* two different times rather than two numbers that cannot be compared. */
static drain_t phase_drain(void) {
drain_t r; memset(&r, 0, sizeof r);
{
int fd = open_direct(PATH_DATA, O_RDWR | O_CREAT);
if (ftruncate(fd, (off_t)C.ws_bytes) != 0) die("ftruncate: %s", strerror(errno));
close(fd);
}
w_t *w = calloc(BLOB_QD, sizeof *w);
pthread_t *th = calloc(BLOB_QD, sizeof *th);
if (!w || !th) die("calloc");
seq_t seq; pthread_mutex_init(&seq.lk, NULL); seq.cur = 0;
volatile int stop = 0;
int fd = open_direct(PATH_DATA, O_RDWR);
ss_t ss; memset(&ss, 0, sizeof ss);
/* Stage 1: one full pass over the freshly-truncated file. Writing into
* never-allocated space costs block allocation and extent conversion, which
* is a different regime from overwriting, and on a journalling filesystem
* the two differ substantially. Sampling across both produces a rising
* series that is neither number. So fill first, report it separately, then
* measure. */
phase(" stage 1: first write pass over %.1f GiB (allocating) ...\n",
(double)C.ws_bytes / GIB);
fflush(stdout);
uint64_t f0 = now_ns();
for (int i = 0; i < BLOB_QD; i++) {
w[i] = (w_t){.tid = i, .fd = fd, .c = &C, .bs = BLOB_BS, .pattern = 1,
.rw = 1, .span = C.ws_bytes, .stop = &stop, .nonce_fid = 0,
.seq = &seq};
pthread_create(&th[i], NULL, worker, &w[i]);
}
/* The fill gets its own deadline, not --drain-max. Every later phase reads
* this file and verifies content, so a working set that was only partly
* written turns into integrity "mismatches" that are really unwritten
* blocks. Budget enough to finish at a pessimistic 50 MiB/s. */
uint64_t fill_deadline = f0 + ((uint64_t)C.drain_max_s
+ C.ws_bytes / (50 * MIB) + 30) * 1000000000ULL;
for (;;) {
sleep_ns(100000000ULL);
uint64_t tot = 0;
for (int i = 0; i < BLOB_QD; i++) tot += w[i].bytes;
if (tot >= C.ws_bytes) break;
if (now_ns() > fill_deadline) break;
}
stop = 1;
for (int i = 0; i < BLOB_QD; i++) pthread_join(th[i], NULL);
uint64_t filled = 0;
for (int i = 0; i < BLOB_QD; i++) filled += w[i].bytes;
r.fill_mibps = (double)filled / MIB / ((double)(now_ns() - f0) / 1e9);
vp(2, " %.1f MiB/s while allocating\n", r.fill_mibps);
if (filled < C.ws_bytes)
vp(2, " WARNING: only %.1f of %.1f GiB written before the\n"
" deadline. Later integrity counts will include blocks\n"
" that were never written.\n",
(double)filled / GIB, (double)C.ws_bytes / GIB);
/* Stage 1b: the bypass claim has to be tested while the absorber still has
* credit, otherwise it proves nothing - after the drain every write path
* reads the same because there is nothing left to hide behind. Allocation
* has consumed only the working set so far, so most of the reservoir is
* still available here. If O_DSYNC reports the post-drain rate now, the
* absorber genuinely cannot hold these writes. */
phase(" stage 1b: O_DSYNC bypass check, absorber still charged ...\n");
fflush(stdout);
{
cell_t pre = run_cell_f(PATH_DATA, BLOB_BS, 16, 1, 1, C.ws_bytes,
10, 0, false, O_DSYNC);
r.dsync_pre_mibps = pre.mibps;
vp(2, " %.1f MiB/s (vs %.1f MiB/s buffered fill)\n",
pre.mibps, r.fill_mibps);
}
/* Stage 2: keep writing over allocated blocks, now sampling for the knee. */
phase(" stage 2: draining burst credit (%d x %zu MiB sequential) ...\n",
BLOB_QD, (size_t)(BLOB_BS / MIB));
fflush(stdout);
memset(w, 0, BLOB_QD * sizeof *w);
stop = 0;
seq.cur = 0;
uint64_t t0 = now_ns();
for (int i = 0; i < BLOB_QD; i++) {
w[i] = (w_t){.tid = i, .fd = fd, .c = &C, .bs = BLOB_BS, .pattern = 1,
.rw = 1, .span = C.ws_bytes, .stop = &stop, .nonce_fid = 0,
.seq = &seq};
pthread_create(&th[i], NULL, worker, &w[i]);
}
uint64_t tick = t0, prev = 0, hard = t0 + (uint64_t)C.drain_max_s * 1000000000ULL;
uint64_t budget_hard = G_deadline_ns ? G_deadline_ns
- (uint64_t)tail_estimate_s() * 1000000000ULL : 0;
if (budget_hard && budget_hard < hard) hard = budget_hard;
double cliff_plateau = 0; /* best median rate seen so far */
uint64_t hold_until = 0; /* set when a deep fall is seen */
double hold_v[CLIFF_HOLD_S + 8]; /* the confirmation window */
int hold_n = 0;
cliff_t cl; memset(&cl, 0, sizeof cl);
while (now_ns() < hard || hold_until) {
sleep_ns(200000000ULL);
uint64_t now = now_ns();
if (now - tick < 1000000000ULL) continue;
uint64_t tot = 0;
for (int i = 0; i < BLOB_QD; i++) tot += w[i].bytes;
double dt = (double)(now - tick) / 1e9;
double mibps = (double)(tot - prev) / MIB / dt;
/* Require DRAIN_PASSES full rewrites of the working set before any
window is allowed to count as steady state. */
bool enough = tot >= DRAIN_PASSES * C.ws_bytes;
bool just = ss_push(&ss, mibps, C.ss_eps, enough, tot);
prev = tot; tick = now;
/* Why the gate did or did not accept this window. Without it, "never
* reached steady state" is an outcome with no visible cause, and the
* three reasons it can happen - still ramping, still decaying, or a
* host too noisy to ever satisfy the CV - need completely different
* responses from whoever is running this. */
double g_cv = 0, g_drift = 0;
if (V >= 3 && ss.n >= SS_WIN) {
const double *win = ss.v + ss.n - SS_WIN;
g_cv = ss_cv(win, SS_WIN); g_drift = ss_drift(win, SS_WIN);
if (V == 3 && !ss.settled)
vp(3, " gate: cv %.3f (need <%.3f) drift %+.3f"
" (need |x|<%.3f) passes %.2f/%d\n",
g_cv, C.ss_eps, g_drift, 2 * C.ss_eps,
(double)tot / (double)C.ws_bytes, DRAIN_PASSES);
}
/* Cliff test on the median series, not the raw one. */
const char *mark = "";
double cur_med = med5(ss.v, ss.n);
if (ss.n >= CLIFF_MED) {
bool was_deep = cl.deep;
cliff_step(&cl, &cliff_plateau, cur_med, (double)(now - t0) / 1e9);
if (cl.deep && !was_deep) {
hold_until = now + (uint64_t)CLIFF_HOLD_S * 1000000000ULL;
if (budget_hard && hold_until > budget_hard) hold_until = budget_hard;
hold_n = 0;
mark = " <- step down, holding 2 min to confirm";
} else if (cl.deep) {
if (hold_n < (int)(sizeof hold_v / sizeof *hold_v))
hold_v[hold_n++] = cur_med;
}
}
if (!*mark)
mark = just ? " <- steady state" : (enough ? "" : " (pre-drain)");
/* The per-second series is the raw evidence for the cliff, so it is a
* -vvv artefact rather than default chatter. At -vvvv it becomes CSV:
* this is the one curve an operator on the other side of the invoice
* would want to replot rather than take on trust. */
if (V >= 4) {
/* A separate token: the display marks carry leading arrows and
spaces, and slicing them by a fixed offset produced "e-drain)". */
const char *csv_mark = cl.deep ? "step_proposed"
: just ? "steady_state"
: enough ? "" : "pre_drain";
vp(4, "CSV drain,%.1f,%.2f,%.4f,%.2f,%.4f,%+.4f,%s\n",
(double)(now - t0) / 1e9, mibps, (double)tot / GIB,
cur_med, g_cv, g_drift, csv_mark);
}
else
vp(3, " t=%3.0fs %8.1f MiB/s %6.1f GiB written%s\n",
(double)(now - t0) / 1e9, mibps, (double)tot / GIB, mark);
fflush(stdout);
if (hold_until) {
if (now < hold_until) continue; /* the hold is not negotiable */
if (cliff_confirm(&cl, hold_v, hold_n)) { cl.early_exit = true; break; }
/* Refused. Resume draining; cliff_step has already cleared `deep`
so the plateau starts tracking again from the next sample. */
hold_until = 0; hold_n = 0;
vp(3, " step WITHDRAWN: %.0f%% of the plateau was back"
" within %ds%s\n", (1 - cl.drop) * 100, CLIFF_HOLD_S,
cl.locked ? " - no longer testing for a step" : "");
continue;
}
/* hold past the knee so the sustained figure is not one lucky window */
if (ss.settled && (double)ss.n >= ss.settle_s + 3) break;
}
r.drain_s = (double)(now_ns() - t0) / 1e9;
r.budget_cut = budget_hard && now_ns() >= budget_hard && !cl.early_exit;
r.cliff = cl;
stop = 1;
for (int i = 0; i < BLOB_QD; i++) pthread_join(th[i], NULL);
uint64_t total = filled;
for (int i = 0; i < BLOB_QD; i++) total += w[i].bytes;
if (cl.early_exit) {
/* A 2x step with two confirmed flat minutes underneath it is a stronger
* statement than CV < 3% over eight seconds, so it wins even when the
* CV gate already accepted something. It has to: the gate can settle on
* the PRE-cliff plateau, and then the run would quote burst speed as
* capacity with a confirmed cliff sitting in the same output. */
int k = ss.n < CLIFF_HOLD_S ? ss.n : CLIFF_HOLD_S;
ss.sustained = cl.post > 0 ? cl.post : median_of(ss.v + ss.n - k, k);
ss.settle_s = cl.at_s;
ss.burst_bytes = 0;
for (int i = 0; i < ss.n - k; i++) /* credit spent above the plateau */
if (ss.v[i] > ss.sustained)
ss.burst_bytes += (ss.v[i] - ss.sustained) * MIB;
ss.settled = true;
r.by_cliff = true;
} else if (!ss.settled) { /* never flattened: report honestly */
int k = ss.n < SS_WIN ? ss.n : SS_WIN;
double m = 0;
for (int i = ss.n - k; i < ss.n; i++) m += ss.v[i];
ss.sustained = k ? m / k : 0;
}
fsync(fd);
close(fd);
r.burst_peak_mibps = ss.peak;
r.sustained_mibps = ss.sustained;
r.burst_gib = ss.burst_bytes / (double)GIB;
r.settle_s = ss.settle_s;
r.settled = ss.settled;
r.written = total;
pthread_mutex_destroy(&seq.lk);
free(w); free(th);
return r;
}
static cell_t run_cell_v(const char *path, int bs, int qd, int pattern, int rw,
uint64_t span, int secs, uint32_t fid, bool verify) {
return run_cell_f(path, bs, qd, pattern, rw, span, secs, fid, verify, 0);
}
static cell_t run_cell(const char *path, int bs, int qd, int pattern, int rw,
uint64_t span, int secs, uint32_t fid) {
return run_cell_f(path, bs, qd, pattern, rw, span, secs, fid, false, 0);
}
/* ------------------------------------- phase: O_DSYNC bypass of the absorber
*
* Filling a host write-back buffer is brute force: it costs as much writing as
* the buffer is deep, and on a host with a large enough absorber it cannot be
* afforded at all. A write issued O_DIRECT|O_DSYNC cannot be held in one - the
* syscall does not return until the host acknowledges durability - so at most
* qd writes are ever outstanding and the absorber's depth stops mattering.
*
* This yields the sustained write rate in seconds rather than in however long
* it takes to fill the buffer. Two ways it misleads, both detectable:
*
* too high - the host acknowledges from volatile cache. Caught by the
* cross-check against the read-side ceiling, which reads cannot
* fake because a read must be served.
* too low - some stacks issue a full cache flush per write, which costs
* more than steady write-back.
*
* So it is a lower bound and the buffered drain is an upper bound. Reported as
* a bracket rather than as a single number pretending to be capacity. */
#define DSYNC_CELLS 3
typedef struct {
int qd[DSYNC_CELLS];
double mibps[DSYNC_CELLS];
uint64_t p99[DSYNC_CELLS];
double best;
bool supported;
} dsync_t;
static dsync_t phase_dsync(int secs) {
dsync_t d; memset(&d, 0, sizeof d);
static const int qds[DSYNC_CELLS] = {8, 16, 32};
d.supported = true;
for (int i = 0; i < DSYNC_CELLS; i++) {
cell_t c = run_cell_f(PATH_DATA, BLOB_BS, qds[i], 1, 1, C.ws_bytes,
secs, 0, false, O_DSYNC);
d.qd[i] = qds[i];
d.mibps[i] = c.mibps;
d.p99[i] = c.p99;
if (c.mibps > d.best) d.best = c.mibps;
vp(2, " qd%-2d 1 MiB O_DSYNC %8.1f MiB/s p99 %7.1f ms\n",
qds[i], c.mibps, (double)c.p99 / 1e6);
fflush(stdout);
}
return d;
}
/* --------------------------------------------- dispersion: policy or physics?
*
* A token-bucket throttle and saturated media deliver the same throughput with
* different latency statistics. A pacer injects a deterministic delay once
* tokens run out, so service intervals are near-constant and p99 sits just
* above the mean. Real media queueing at its limit has a heavy tail.
*
* The mean is taken from the histogram, and Little's Law on the same cell
* confirms the blocking model, so the ratio is not an artefact of the
* instrument. Anything below ~1.5 is a pacer; media does not put p99 within
* 8% of the mean while saturated. */
#define DISP_CELLS 4
#define DISP_PACER 1.5 /* below this: deterministic pacing */
#define DISP_MEDIA 3.0 /* above this: queueing against real service time */
typedef struct {
double ratio[DISP_CELLS];
double worst, cpu_worst;
bool cpu_bound;
const char *verdict;
} disp_t;
static disp_t dispersion(const cell_t *c) {
disp_t d; memset(&d, 0, sizeof d);
d.worst = 0;
for (int i = 0; i < DISP_CELLS; i++) {
double mean_ns = c[i].mean_us * 1000.0;
d.ratio[i] = mean_ns > 0 ? (double)c[i].p99 / mean_ns : 0;
if (d.ratio[i] > d.worst) d.worst = d.ratio[i];
if (c[i].cpu_frac > d.cpu_worst) d.cpu_worst = c[i].cpu_frac;
}
/* A starved run queue inflates a tail exactly the way contended media does.
The "physics" reading is the one at risk - a pacer cannot be faked into
looking deterministic by CPU shortage, but a deterministic pacer can be
made to look like queueing. So only that direction is withdrawn. */
d.cpu_bound = d.cpu_worst > 0.80;
d.verdict = d.worst < DISP_PACER ? "policy"
: d.worst > DISP_MEDIA ? (d.cpu_bound ? "instrument-limited" : "physics")
: "ambiguous";
return d;
}
/* ------------------------------- unthrottled service time: what is the media?
*
* At qd1 and a few percent of the ceiling the bucket never empties, so nothing
* is left in the measurement but the device plus the virtualization path. That
* does not give the hardware's throughput - a quota hides that permanently -
* but the service time is a class, and a class is a real fact about the media
* underneath a number that is otherwise pure policy.
*
* The span must be far larger than any plausible host RAM or this measures a
* cache hit; it runs over the whole working set, which is already sized above
* guest RAM, and the caller states the ratio. */
typedef struct {
uint64_t p50, p99, p999;
double iops, load_frac;
const char *class_;
} svc_t;
static svc_t phase_service(int secs, double read_ceiling_iops) {
svc_t s; memset(&s, 0, sizeof s);
cell_t c = run_cell_f(PATH_DATA, 4096, 1, 0, 0, C.ws_bytes, secs, 0, false, 0);
s.p50 = c.p50; s.p99 = c.p99; s.p999 = c.p999;
s.iops = c.iops;
s.load_frac = read_ceiling_iops > 0 ? c.iops / read_ceiling_iops : 0;
double us = (double)c.p50 / 1000.0;
s.class_ = us < 150 ? "local NVMe"
: us < 500 ? "SATA/SAS SSD, or network-attached SSD"
: us < 2000 ? "network-attached, or heavily virtualized"
: us < 4000 ? "network-attached under load, or a shared array"
: "spinning media";
return s;
}
/* ------------------------------------- latency versus load: the decisive test
*
* Dispersion at saturation is free but circumstantial. This is the discriminator
* that separates a pacer from real service capacity outright, because the two
* have different shapes and not merely different tails.
*
* A token bucket injects delay only once the tokens are gone, so p50 is flat
* against offered load and then goes vertical within a few percent of the
* ceiling. Queueing against real service time follows M/M/1: the wait rises
* smoothly and convexly from about half the ceiling, because a device with a
* finite service time is already queueing at 50% utilization.
*
* Open loop is mandatory here. Under a closed loop the offered rate is defined
* by the completion rate, so the curve cannot bend - it measures itself. */
#define LOAD_PTS 8
typedef struct {
int tid, fd, nthreads, bs;
uint64_t span, t0;
double rate; /* offered ops/s for the whole cell */
volatile int *stop;
uint64_t ops, behind;
hist_t h; /* scheduled arrival -> completion */
rng_t rng;
} p_t;
static void *paced_worker(void *arg) {
p_t *p = (p_t *)arg;
uint8_t *buf = xalloc((size_t)p->bs);
uint64_t nblk = p->span / (uint64_t)p->bs;
if (!nblk) nblk = 1;
uint64_t k = (uint64_t)p->tid;
while (!*p->stop) {
/* The arrival schedule is fixed in advance: a stall becomes latency for
everything queued behind it rather than simply fewer samples. */
uint64_t sched = p->t0 + (uint64_t)((double)k / p->rate * 1e9);
uint64_t now = now_ns();
if (sched > now) sleep_ns(sched - now);
else if (now - sched > 1000000ULL) p->behind++;
if (*p->stop) break;
uint64_t off = (rng_next(&p->rng) % nblk) * (uint64_t)p->bs;
if (pread(p->fd, buf, (size_t)p->bs, (off_t)off) != (ssize_t)p->bs) {
if (errno == EINTR) continue;
die("paced read: %s", strerror(errno));
}
hist_add(&p->h, now_ns() - sched);
p->ops++;
k += (uint64_t)p->nthreads;
}
free(buf);
return NULL;
}
/* The paced phase gets its own thread cap. MAX_THREADS = 64 is right for the
* closed-loop cells, where it IS the queue depth being measured, but here it
* silently becomes a rate limit: n threads issuing blocking reads cannot offer
* more than the volume's own throughput at queue depth n, no matter what rate
* the schedule asks for. */
#define LOAD_MAX_THREADS 256
typedef struct {
double frac[LOAD_PTS], offered[LOAD_PTS], delivered[LOAD_PTS];
uint64_t p50[LOAD_PTS], p99[LOAD_PTS], p999[LOAD_PTS];
double cpu_frac[LOAD_PTS], backlog[LOAD_PTS];
int nthr[LOAD_PTS];
double knee_frac, rise_at_50, cpu_at_90, backlog_at_90;
const char *verdict;
bool cpu_bound, pool_bound;
int n;
} latload_t;
static latload_t phase_latload(int secs_per_pt, double ceiling_iops) {
latload_t L; memset(&L, 0, sizeof L);
static const double fr[LOAD_PTS] = {0.10, 0.25, 0.50, 0.75, 0.90, 0.95, 1.00, 1.10};
if (ceiling_iops <= 0) { L.verdict = "not run"; return L; }
L.n = LOAD_PTS;
int fd = open_direct(PATH_DATA, O_RDONLY);
for (int i = 0; i < LOAD_PTS; i++) {
double rate = ceiling_iops * fr[i];
/* Enough threads that the offered rate is reachable even when each op
costs the saturated service time, plus headroom. */
int n = (int)(rate * 0.004) + 4;
if (n > LOAD_MAX_THREADS) n = LOAD_MAX_THREADS;
cpustat_t cpu0 = sys_cpu();
p_t *p = calloc((size_t)n, sizeof *p);
pthread_t *th = calloc((size_t)n, sizeof *th);
if (!p || !th) die("calloc");
volatile int stop = 0;
uint64_t t0 = now_ns();
for (int j = 0; j < n; j++) {
p[j] = (p_t){.tid = j, .fd = fd, .nthreads = n, .bs = 4096,
.span = C.ws_bytes, .t0 = t0, .rate = rate,
.stop = &stop};
rng_seed(&p[j].rng, C.nonce ^ (0x5EEDULL * (uint64_t)(j + 1)) ^ (uint64_t)i);
pthread_create(&th[j], NULL, paced_worker, &p[j]);
}
sleep_ns((uint64_t)secs_per_pt * 1000000000ULL);
stop = 1;
for (int j = 0; j < n; j++) pthread_join(th[j], NULL);
double el = (double)(now_ns() - t0) / 1e9;
hist_t H; memset(&H, 0, sizeof H);
uint64_t ops = 0;
for (int j = 0; j < n; j++) { hist_merge(&H, &p[j].h); ops += p[j].ops; }
L.frac[i] = fr[i];
L.offered[i] = rate;
L.delivered[i] = ops / el;
L.p50[i] = hist_pct(&H, 50);
L.p99[i] = hist_pct(&H, 99);
L.p999[i] = hist_pct(&H, 99.9);
L.cpu_frac[i] = cpu_busy_frac(cpu0, sys_cpu());
L.nthr[i] = n;
/* Little's Law on the paced cell, and the one place it has teeth.
* Latency is charged from the SCHEDULED time, so an operation is
* outstanding from the moment it was due - including while its thread
* is still busy with the previous one. The number in flight can
* therefore exceed the thread count, and when it does, what is being
* measured is the backlog in this program's own schedule rather than
* anything the volume did. n threads cannot offer more than the volume
* serves at queue depth n, however fast the schedule asks. */
L.backlog[i] = n > 0 ? L.delivered[i] * (hist_mean(&H) / 1e9) / n : 0;
/* cpu and L/n are the instrument's own state, so the full row is a
* -vvv artefact; -vv gets the shape without the diagnostics, and
* -vvvv gets it as CSV because this curve is the whole argument for
* "policy" and an operator disputing it will want to replot it. */
if (V >= 4)
vp(4, "CSV latload,%.2f,%.0f,%.0f,%.0f,%.0f,%.0f,%.4f,%.3f,%d\n",
fr[i], rate, L.delivered[i], (double)L.p50[i] / 1000,
(double)L.p99[i] / 1000, (double)L.p999[i] / 1000,
L.cpu_frac[i], L.backlog[i], n);
else if (V >= 3)
vp(3, " %5.0f%% of ceiling offered %8.0f delivered %8.0f IOPS"
" p50 %7.0f us p99 %8.0f us cpu %3.0f%% L/n %4.1f n=%d\n",
fr[i] * 100, rate, L.delivered[i],
(double)L.p50[i] / 1000, (double)L.p99[i] / 1000,
L.cpu_frac[i] * 100, L.backlog[i], n);
else
vp(2, " %5.0f%% of ceiling delivered %8.0f IOPS"
" p50 %7.0f us p99 %8.0f us\n",
fr[i] * 100, L.delivered[i],
(double)L.p50[i] / 1000, (double)L.p99[i] / 1000);
fflush(stdout);
free(p); free(th);
}
close(fd);
/* Shape, not magnitude. Baseline is the 10% point; the bucket signature is
a p50 still near baseline at 75% that has exploded by 100%. */
double base = (double)L.p50[0];
if (base <= 0) base = 1;
L.rise_at_50 = (double)L.p50[2] / base;
double at100 = (double)L.p50[6] / base, at110 = (double)L.p50[7] / base;
L.knee_frac = 0;
for (int i = 1; i < LOAD_PTS; i++)
if (!L.knee_frac && (double)L.p50[i] > 3 * base) L.knee_frac = fr[i];
/* The wall sits just PAST the ceiling, not at it: offering exactly the
* measured rate is served without queueing, and only the overload point
* shows the cliff. Looking for the jump at 100% finds nothing and calls a
* textbook bucket ambiguous - which is what the first version of this did.
*
* The onset of real queueing is likewise not pinned to 50%. M/M/1 doubles
* by half the ceiling, but a device with several independent service
* channels behaves like M/M/c and bends later. Testing only the 50% point
* calls a plainly progressive rise "ambiguous". So:
*
* policy flat everywhere up to the ceiling, then a step past it
* physics a progressive rise that has arrived by 90%
*/
double step = at110 / (at100 > 1 ? at100 : 1);
double flat = 0; /* worst rise anywhere up to 100% */
for (int i = 1; i <= 6; i++) {
double x = (double)L.p50[i] / base;
if (x > flat) flat = x;
}
double at90 = (double)L.p50[4] / base;
/* Before believing a rising curve, check whose queue it is.
*
* The paced worker charges each op from its SCHEDULED time, not from issue
* - that is deliberate, it is what makes the number a response time rather
* than a service time. But it also means a guest that cannot issue the
* offered rate charges its own run queue to the storage. A guest with too
* few cores for the rate it is asked to offer spends most of them in
* syscall and completion handling; latency then climbs smoothly across the
* sweep and the shape rule reads a textbook physics curve, when what
* saturated was the CPU.
*
* There is no way to separate the two after the fact, so the tool measures
* the confound directly and refuses the verdict rather than reporting a
* property of the load generator as a property of the volume. */
L.cpu_at_90 = L.cpu_frac[4];
L.cpu_bound = L.cpu_at_90 > 0.80;
L.backlog_at_90 = L.backlog[4];
L.pool_bound = L.backlog_at_90 > 1.5;
if (flat < 1.5 && step > 5) L.verdict = "policy";
else if (L.cpu_bound || L.pool_bound) L.verdict = "instrument-limited";
else if (at90 > 1.5) L.verdict = "physics";
else L.verdict = "ambiguous";
return L;
}
/* --------------------------------------- phase: concurrent read + write
*
* On a quota-limited volume reads and writes are usually charged to separate
* buckets, so a one-direction benchmark measures one bucket and reports it as
* the capacity of the volume. Running both at once shows whether ingestion can
* proceed at full rate while queries are being served - which is the normal
* operating condition, and changes ingestion planning substantially. */
typedef struct { double w_mibps, r_mibps, total; bool separate; } conc_t;
typedef struct {
const char *path; int bs, qd, pattern, rw; uint64_t span; int secs;
cell_t out;
} carg_t;
static void *conc_thread(void *a) {
carg_t *g = (carg_t *)a;
g->out = run_cell(g->path, g->bs, g->qd, g->pattern, g->rw, g->span, g->secs, 0);
return NULL;
}
static conc_t phase_concurrent(int secs, double solo_write, double solo_read) {
conc_t c; memset(&c, 0, sizeof c);
carg_t wa; memset(&wa, 0, sizeof wa);
wa.path = PATH_DATA; wa.bs = BLOB_BS; wa.qd = 2; wa.pattern = 1; wa.rw = 1;
wa.span = C.ws_bytes; wa.secs = secs;
carg_t ra; memset(&ra, 0, sizeof ra);
ra.path = PATH_DATA; ra.bs = 64 * 1024; ra.qd = 8; ra.pattern = 0; ra.rw = 0;
ra.span = C.ws_bytes; ra.secs = secs;
pthread_t tw, tr;
pthread_create(&tw, NULL, conc_thread, &wa);
pthread_create(&tr, NULL, conc_thread, &ra);
pthread_join(tw, NULL);
pthread_join(tr, NULL);
c.w_mibps = wa.out.mibps;
c.r_mibps = ra.out.mibps;
c.total = c.w_mibps + c.r_mibps;
/* If the combined rate clearly exceeds the better single-direction figure,
the two directions are not sharing one budget. */
double best_solo = solo_write > solo_read ? solo_write : solo_read;
c.separate = c.total > best_solo * 1.25;
return c;
}
/* --------------------------------------------- phase: transactions */
typedef struct {
int tid;
int fd_data, fd_wal;
rng_t rng;
uint64_t txns;
hist_t h;
volatile int *stop;
double rate; /* 0 = closed loop */
uint64_t t0;
int nthreads;
uint64_t behind; /* open loop: arrivals we were already late for */
uint64_t salt; /* per-thread, so no rewrite repeats prior content */
} tx_t;
static pthread_mutex_t g_wal_lock = PTHREAD_MUTEX_INITIALIZER;
static uint64_t g_wal_off = 0;
/* One transaction: index lookups, then read-modify-write of data pages, then a
* WAL append made durable. This is deliberately not a bare fsync loop - the
* flush rate alone overstates transaction throughput by orders of magnitude,
* because it ignores both the page reads and the eventual write-back. */
static void do_txn(tx_t *t, uint8_t *pg, uint8_t *wal) {
uint64_t npg = C.ws_bytes / (uint64_t)C.page;
if (!npg) npg = 1;
for (int i = 0; i < C.tx_reads; i++) {
uint64_t off = (rng_next(&t->rng) % npg) * (uint64_t)C.page;
if (pread(t->fd_data, pg, (size_t)C.page, (off_t)off) != C.page)
die("txn read: %s", strerror(errno));
}
for (int i = 0; i < C.tx_writes; i++) {
uint64_t off = (rng_next(&t->rng) % npg) * (uint64_t)C.page;
if (pread(t->fd_data, pg, (size_t)C.page, (off_t)off) != C.page)
die("txn rmw read: %s", strerror(errno));
/* Salt makes the dirtied page differ from what that offset already
holds. Without it a rewrite is a no-op to a dedup layer, and the
transaction never pays for its write. */
gen_region(pg, (size_t)C.page, C.nonce, 0, off, t->salt++);
if (pwrite(t->fd_data, pg, (size_t)C.page, (off_t)off) != C.page)
die("txn rmw write: %s", strerror(errno));
}
uint64_t woff;
pthread_mutex_lock(&g_wal_lock);
woff = g_wal_off;
g_wal_off += ALIGN;
if (g_wal_off + ALIGN > C.wal_bytes) g_wal_off = 0;
pthread_mutex_unlock(&g_wal_lock);
gen_block(wal, C.nonce, 7, woff, t->salt++);
if (pwrite(t->fd_wal, wal, ALIGN, (off_t)woff) != (ssize_t)ALIGN)
die("wal append: %s", strerror(errno));
if (fdatasync(t->fd_wal) != 0) die("fdatasync: %s", strerror(errno));
}
static void *tx_worker(void *arg) {
tx_t *t = (tx_t *)arg;
uint8_t *pg = xalloc((size_t)C.page), *wal = xalloc(ALIGN);
uint64_t k = (uint64_t)t->tid;
while (!*t->stop) {
uint64_t sched = 0;
if (t->rate > 0) {
/* Open loop: the arrival schedule is fixed in advance, so a stall
shows up as latency for everything queued behind it instead of
simply producing fewer samples (coordinated omission). */
sched = t->t0 + (uint64_t)((double)k / t->rate * 1e9);
uint64_t now = now_ns();
if (sched > now) sleep_ns(sched - now);
else if (now - sched > 1000000ULL) t->behind++;
if (*t->stop) break;
} else {
sched = now_ns();
}
do_txn(t, pg, wal);
hist_add(&t->h, now_ns() - sched);
t->txns++;
k += (uint64_t)t->nthreads;
}
free(pg); free(wal);
return NULL;
}
typedef struct {
double tps, secs;
uint64_t txns, behind;
uint64_t p50, p99, p999, max;
double mean_us;
} tx_res_t;
static tx_res_t run_tx(int secs, double rate) {
tx_res_t r; memset(&r, 0, sizeof r);
static tx_t t[MAX_THREADS];
static pthread_t th[MAX_THREADS];
volatile int stop = 0;
int n = C.clients > MAX_THREADS ? MAX_THREADS : C.clients;
memset(t, 0, sizeof t);
int fdd = open_direct(PATH_DATA, O_RDWR);
int fdw = open_direct(PATH_WAL, O_RDWR);
uint64_t t0 = now_ns();
for (int i = 0; i < n; i++) {
t[i].tid = i; t[i].fd_data = fdd; t[i].fd_wal = fdw;
t[i].stop = &stop; t[i].rate = rate ? rate : 0;
t[i].t0 = t0; t[i].nthreads = n;
/* disjoint salt ranges: the closed-loop phase must not hand the
open-loop phase a salt it has already written */
t[i].salt = ((uint64_t)(i + 1) << 40) ^ (uint64_t)t0;
rng_seed(&t[i].rng, C.nonce ^ (0xABCDEFULL * (i + 1)));
pthread_create(&th[i], NULL, tx_worker, &t[i]);
}
sleep_ns((uint64_t)secs * 1000000000ULL);
stop = 1;
for (int i = 0; i < n; i++) pthread_join(th[i], NULL);
double el = (double)(now_ns() - t0) / 1e9;
hist_t H; memset(&H, 0, sizeof H);
for (int i = 0; i < n; i++) {
hist_merge(&H, &t[i].h); r.txns += t[i].txns; r.behind += t[i].behind;
}
close(fdd); close(fdw);
r.secs = el; r.tps = r.txns / el;
r.p50 = hist_pct(&H, 50); r.p99 = hist_pct(&H, 99);
r.p999 = hist_pct(&H, 99.9); r.max = hist_max(&H);
r.mean_us = hist_mean(&H) / 1000.0;
return r;
}
/* ------------------------------------------------ phase: blob ingestion */
typedef struct { double raw_mibps, copy_mibps, concurrent_w, concurrent_r; } blob_t;
/* A read-then-write copy loop, which is what cp and most import tools do. It
* routinely lands well under the raw write ceiling because the two directions
* are not overlapped, and that gap is the number an operator actually sees. */
static double copy_stream(int secs) {
int src = open_direct(PATH_DATA, O_RDONLY);
int dst = open_direct(PATH_BLOB, O_RDWR | O_CREAT | O_TRUNC);
size_t bs = 1 * MIB;
uint8_t *buf = xalloc(bs);
uint64_t cap = C.ws_bytes / 4;
if (cap < 256 * MIB) cap = 256 * MIB;
if (ftruncate(dst, (off_t)cap) != 0) die("ftruncate blob: %s", strerror(errno));
uint64_t t0 = now_ns(), end = t0 + (uint64_t)secs * 1000000000ULL;
uint64_t off = 0, moved = 0;
while (now_ns() < end) {
if (pread(src, buf, bs, (off_t)off) != (ssize_t)bs) break;
if (pwrite(dst, buf, bs, (off_t)off) != (ssize_t)bs) break;
moved += bs;
off += bs;
if (off + bs > cap) off = 0;
}
fsync(dst);
double el = (double)(now_ns() - t0) / 1e9;
free(buf); close(src); close(dst);
return (double)moved / MIB / el;
}
/* ------------------------------------------------------ phase: metadata */
typedef struct { double create_s, unlink_s; } meta_t;
/* Small blobs are bound by inode and journal work, not bandwidth. An ingestion
* plan built on the MiB/s figure alone will be wrong by orders of magnitude for
* small objects, so this measures the other regime. */
static meta_t phase_meta(void) {
meta_t m = {0, 0};
char p[700];
uint8_t *buf = xalloc(ALIGN);
gen_block(buf, C.nonce, 9, 0, 0);
snprintf(p, sizeof p, "%s/iolite_meta", C.dir);
mkdir(p, 0755);
uint64_t t0 = now_ns();
for (int i = 0; i < C.meta_files; i++) {
snprintf(p, sizeof p, "%s/iolite_meta/f%06d", C.dir, i);
int fd = open(p, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0) die("meta create: %s", strerror(errno));
if (write(fd, buf, ALIGN) != (ssize_t)ALIGN) die("meta write");
close(fd);
}
sync();
m.create_s = C.meta_files / ((double)(now_ns() - t0) / 1e9);
t0 = now_ns();
for (int i = 0; i < C.meta_files; i++) {
snprintf(p, sizeof p, "%s/iolite_meta/f%06d", C.dir, i);
unlink(p);
}
sync();
m.unlink_s = C.meta_files / ((double)(now_ns() - t0) / 1e9);
snprintf(p, sizeof p, "%s/iolite_meta", C.dir);
rmdir(p);
free(buf);
return m;
}
/* ------------------------------------------------- phase: soak (long, extra)
*
* One sustained figure is not a capacity, and the evidence runs both ways:
* some hosts repeat to a few percent under identical treatment and others
* spread by half with no trend. The spread is a property of the host, and
* nothing measurable inside a single run distinguishes "this volume delivers
* X" from "this volume delivers somewhere in a wide band, and today it was X".
* Only repetition does.
*
* So the long profiles do not run one slow probe - they run the full probe
* once and then re-measure on a cadence, reporting the spread. The `extra`
* profile additionally idles between cycles for longer than the 15-30 min gate
* at which burst credit was observed to return, which makes each cycle's
* credit measurement a direct test of whether burst is recurrent on the day.
* That question cannot be asked at all by a run shorter than the gate. */
#define SOAK_MAX 32
typedef struct { double peak, sustained, credit_gib; } soakw_t;
/* Sequential write over the already-provisioned working set, sampled at 1 Hz.
* Deliberately not phase_drain: that one truncates and refills, which would
* destroy the file every later cycle reads, and its steady-state gate is built
* for a first encounter with an unknown volume rather than a re-check of a
* volume whose sustained rate is already known. */
static soakw_t soak_write(int secs) {
soakw_t r; memset(&r, 0, sizeof r);
w_t *w = calloc(BLOB_QD, sizeof *w);
pthread_t *th = calloc(BLOB_QD, sizeof *th);
if (!w || !th) die("calloc");
seq_t seq; pthread_mutex_init(&seq.lk, NULL); seq.cur = 0;
volatile int stop = 0;
int fd = open_direct(PATH_DATA, O_RDWR);
ss_t ss; memset(&ss, 0, sizeof ss);
uint64_t t0 = now_ns(), tick = t0, prev = 0;
uint64_t hard = t0 + (uint64_t)secs * 1000000000ULL;
for (int i = 0; i < BLOB_QD; i++) {
w[i] = (w_t){.tid = i, .fd = fd, .c = &C, .bs = BLOB_BS, .pattern = 1,
.rw = 1, .span = C.ws_bytes, .stop = &stop, .nonce_fid = 0,
.seq = &seq};
pthread_create(&th[i], NULL, worker, &w[i]);
}
while (now_ns() < hard) {
sleep_ns(200000000ULL);
uint64_t now = now_ns();
if (now - tick < 1000000000ULL) continue;
uint64_t tot = 0;
for (int i = 0; i < BLOB_QD; i++) tot += w[i].bytes;
double mibps = (double)(tot - prev) / MIB / ((double)(now - tick) / 1e9);
if (ss.n < SS_MAX) ss.v[ss.n++] = mibps;
prev = tot; tick = now;
double m = med5(ss.v, ss.n);
if (ss.n >= CLIFF_MED && m > r.peak) r.peak = m;
}
stop = 1;
for (int i = 0; i < BLOB_QD; i++) pthread_join(th[i], NULL);
fsync(fd); close(fd);
/* Sustained = median of the last eight seconds, so one stalled second
* cannot set it. Credit = everything delivered above that line, which is
* the same accounting the first drain used. */
if (ss.n) {
int k = ss.n < SS_WIN ? ss.n : SS_WIN;
double t[SS_WIN];
for (int i = 0; i < k; i++) t[i] = ss.v[ss.n - k + i];
for (int i = 1; i < k; i++) {
double x = t[i]; int j = i - 1;
while (j >= 0 && t[j] > x) { t[j + 1] = t[j]; j--; }
t[j + 1] = x;
}
r.sustained = t[k / 2];
double credit_mib = 0;
for (int i = 0; i < ss.n - k; i++)
if (ss.v[i] > r.sustained) credit_mib += ss.v[i] - r.sustained;
r.credit_gib = credit_mib / 1024.0;
}
pthread_mutex_destroy(&seq.lk);
free(w); free(th);
return r;
}
typedef struct {
int n;
double at_min[SOAK_MAX], idle_s[SOAK_MAX];
double sust[SOAK_MAX], peak[SOAK_MAX], credit_gib[SOAK_MAX];
double r4_iops[SOAK_MAX], w64_mibps[SOAK_MAX], tps[SOAK_MAX];
double sust_lo, sust_hi, sust_spread, sust_cv;
double credit_max;
double loaded_s; /* seconds actually under load, excluding idle */
bool recurrent; /* credit came back after an idle gap */
} soak_t;
static void soak_stats(soak_t *s, double first_sust, double first_credit) {
s->sust_lo = first_sust; s->sust_hi = first_sust;
s->credit_max = 0;
double sum = first_sust, sq = 0;
int n = 1;
for (int i = 0; i < s->n; i++) {
if (s->sust[i] < s->sust_lo) s->sust_lo = s->sust[i];
if (s->sust[i] > s->sust_hi) s->sust_hi = s->sust[i];
if (s->credit_gib[i] > s->credit_max) s->credit_max = s->credit_gib[i];
sum += s->sust[i]; n++;
}
double mean = sum / n;
sq = (first_sust - mean) * (first_sust - mean);
for (int i = 0; i < s->n; i++) sq += (s->sust[i] - mean) * (s->sust[i] - mean);
s->sust_cv = (n > 1 && mean > 0) ? dsqrt(sq / (n - 1)) / mean : 0;
s->sust_spread = s->sust_lo > 0 ? s->sust_hi / s->sust_lo : 0;
/* Recurrence needs a reservoir to have existed in the first place: without
* one, "no credit returned" is not a measurement of the refill rate. */
s->recurrent = first_credit >= 1.0 && s->credit_max >= first_credit * 0.25;
}
static soak_t phase_soak(uint64_t run_t0) {
soak_t s; memset(&s, 0, sizeof s);
int cycles = P->soak_cycles;
if (cycles > SOAK_MAX) cycles = SOAK_MAX;
for (int c = 0; c < cycles; c++) {
int left = budget_left_s();
int remaining = cycles - c;
int cycle_s = left / remaining;
/* Idle is capped at a third of the cycle so a short budget cannot spend
the whole thing waiting and measure nothing. */
int idle = P->soak_idle_s;
if (idle > cycle_s / 3) idle = cycle_s / 3;
int meas = cycle_s - idle;
if (meas < 90) break; /* not enough left to say anything */
if (idle > 0) {
vp(2, " cycle %d/%d: idle %.0f min ...\n", c + 1, cycles, idle / 60.0);
fflush(stdout);
uint64_t until = now_ns() + (uint64_t)idle * 1000000000ULL;
while (now_ns() < until) sleep_ns(1000000000ULL);
}
int wsec = meas / 2, csec = meas / 6;
if (csec < 10) csec = 10;
soakw_t sw = soak_write(wsec);
cell_t r4 = run_cell(PATH_DATA, 4096, 32, 0, 0, C.ws_bytes, csec, 0);
cell_t w64 = run_cell(PATH_DATA, 64 * 1024, 32, 0, 1, C.ws_bytes, csec, 0);
tx_res_t tx = run_tx(csec, 0);
int i = s.n++;
s.at_min[i] = (double)(now_ns() - run_t0) / 6e10;
s.idle_s[i] = idle;
s.sust[i] = sw.sustained; s.peak[i] = sw.peak;
s.credit_gib[i] = sw.credit_gib;
s.r4_iops[i] = r4.iops; s.w64_mibps[i] = w64.mibps; s.tps[i] = tx.tps;
/* Idle does not count towards coverage of the planning window: the
overstatement bound is about the fraction of the window the volume
was actually under load, not the fraction the program was alive. */
s.loaded_s += wsec + 3 * csec;
if (V >= 4)
vp(4, "CSV soak,%d,%.1f,%.0f,%.2f,%.3f,%.0f,%.1f\n",
c + 1, s.at_min[i], s.idle_s[i], sw.sustained, sw.credit_gib,
r4.iops, tx.tps);
else
vp(2, " cycle %d/%d at t=%.0f min: sustained %.1f MiB/s, credit %.1f GiB,"
" %.0f read IOPS, %.0f tx/s\n",
c + 1, cycles, s.at_min[i], sw.sustained, sw.credit_gib,
r4.iops, tx.tps);
fflush(stdout);
if (budget_left_s() < 120) break;
}
return s;
}
/* ------------------------------ --refill: the burst recovery staircase
*
* The one measurement here that can see *through* the quota. When the absorber
* is full and the guest stops writing, the host still has to flush it to
* hardware, and that flush is host-internal I/O which a guest-facing throttle
* has no particular reason to meter. So the rate at which burst credit comes
* back is a measurement of the layer below the absorber, taken without issuing
* any guest I/O at all.
*
* Exhaust the credit, idle for a gap g, then write again and see how much is
* absorbed above the sustained rate before the throttle reappears. Staircase g
* and the slope of credit against gap is the flush rate F.
*
* F >> r the host does not meter its own flush, and F is a hardware lower
* bound obtained from inside a guest that is not allowed to write
* that fast. This is the only number here that is about the media.
* F ~= r the host meters flush and guest I/O alike.
* F << r refill is its own policy parameter - a credit bucket, not a
* buffer draining as fast as the array will take it.
*
* Cost is dominated by the gaps, which are idle, so it is cheap in I/O and
* safe to run on a live box. It also yields f from the capacity model, the
* parameter that decides whether burst is an hourly allowance or a one-off. */
#define REFILL_MAX_PTS 12
typedef struct {
int n;
double gap_s[REFILL_MAX_PTS], credit_gib[REFILL_MAX_PTS], probe_s[REFILL_MAX_PTS];
double sustained, depth_gib, f_mibps, full_min, r2;
double onset_lo_s, onset_hi_s, f_avg_mibps, f_window_mibps;
double onset_credit_gib;
bool latent;
} refill_t;
/* Writes until the credit is spent, returning the volume absorbed above the
* sustained rate. Same definition the drain phase uses, so the staircase and
* the headline burst figure are the same quantity measured twice. */
static double refill_probe(double sustained, int cap_s, double *probe_secs,
double *peak) {
int fd = open_direct(PATH_DATA, O_RDWR);
w_t *w = calloc(BLOB_QD, sizeof *w);
pthread_t *th = calloc(BLOB_QD, sizeof *th);
if (!w || !th) die("calloc");
seq_t seq; pthread_mutex_init(&seq.lk, NULL); seq.cur = 0;
volatile int stop = 0;
uint64_t t0 = now_ns();
for (int i = 0; i < BLOB_QD; i++) {
w[i] = (w_t){.tid = i, .fd = fd, .c = &C, .bs = BLOB_BS, .pattern = 1,
.rw = 1, .span = C.ws_bytes, .stop = &stop, .nonce_fid = 0,
.seq = &seq, .cell_t0 = t0};
pthread_create(&th[i], NULL, worker, &w[i]);
}
double excess = 0;
int quiet = 0;
uint64_t tick = t0, prev = 0, hard = t0 + (uint64_t)cap_s * 1000000000ULL;
*peak = 0;
while (now_ns() < hard && quiet < 3) {
sleep_ns(200000000ULL);
uint64_t now = now_ns();
if (now - tick < 1000000000ULL) continue;
uint64_t tot = 0;
for (int i = 0; i < BLOB_QD; i++) tot += w[i].bytes;
double dt = (double)(now - tick) / 1e9;
double mibps = (double)(tot - prev) / MIB / dt;
prev = tot; tick = now;
if (mibps > *peak) *peak = mibps;
if (mibps > sustained * 1.15) { excess += (mibps - sustained) * dt; quiet = 0; }
else quiet++;
}
stop = 1;
for (int i = 0; i < BLOB_QD; i++) pthread_join(th[i], NULL);
*probe_secs = (double)(now_ns() - t0) / 1e9;
fsync(fd); close(fd);
pthread_mutex_destroy(&seq.lk);
free(w); free(th);
return excess * MIB / (double)GIB;
}
static refill_t phase_refill(const double *gaps, int ngaps, double sustained) {
refill_t R; memset(&R, 0, sizeof R);
R.sustained = sustained;
R.n = ngaps > REFILL_MAX_PTS ? REFILL_MAX_PTS : ngaps;
for (int i = 0; i < R.n; i++) {
vp(2, " idling %.0f s ", gaps[i]);
fflush(stdout);
uint64_t until = now_ns() + (uint64_t)(gaps[i] * 1e9);
while (now_ns() < until) {
sleep_ns(10000000000ULL > (until - now_ns()) ? (until - now_ns())
: 10000000000ULL);
vp(2, "."); fflush(stdout);
}
/* Cap the probe generously: credit cannot exceed what the gap could
have restored, and three quiet windows end it early anyway. */
int cap = (int)(gaps[i] / 4) + 60;
double psec = 0, peak = 0;
double cr = refill_probe(sustained, cap, &psec, &peak);
R.gap_s[i] = gaps[i];
R.credit_gib[i] = cr;
R.probe_s[i] = psec;
if (cr > R.depth_gib) R.depth_gib = cr;
vp(2, " probe %.0f s: %.2f GiB recovered (peak %.0f MiB/s)\n",
psec, cr, peak);
fflush(stdout);
}
/* Dead time first, because it decides whether a rate means anything.
*
* The linear model behind f assumes credit accrues in proportion to the
* gap. On a real host it need not: nothing may come back for the first
* several gaps and then a large fraction of the reservoir arrive at once.
* Recovery then has a threshold in it - a periodic replenishment, or a
* host flush running on its own schedule - and no single rate describes
* the curve, so f is reported as a bracket rather than as a slope.
*
* Worse, a saturation rule that drops points at the top of the observed
* range can exclude exactly the point carrying the signal, fit the slope
* to the leading zeros, and report f = 0 for a host that plainly returned
* credit. Hence the "another point reached the same level" test below. */
int first = -1;
for (int i = 0; i < R.n; i++)
if (R.credit_gib[i] >= 0.05) { first = i; break; }
if (first > 0) {
R.latent = true;
R.onset_lo_s = R.gap_s[first - 1];
R.onset_hi_s = R.gap_s[first];
R.onset_credit_gib = R.credit_gib[first];
}
if (first >= 0 && R.gap_s[first] > 0) {
/* Averaged over the whole gap: the rate a planner would see. */
R.f_avg_mibps = R.credit_gib[first] / R.gap_s[first] * 1024.0;
/* Averaged over just the window in which it could have happened:
an upper bound on the instantaneous rate. */
double win = R.gap_s[first] - (first > 0 ? R.gap_s[first - 1] : 0);
if (win > 0) R.f_window_mibps = R.credit_gib[first] / win * 1024.0;
}
/* Slope through the origin over the points that have not yet saturated:
once credit stops growing with the gap the reservoir is full, and those
points describe the depth, not the rate. Only exclude a point as
saturated if another point actually reached the same level - one lone
maximum is the measurement, not a plateau. */
int at_max = 0;
for (int i = 0; i < R.n; i++)
if (R.credit_gib[i] > R.depth_gib * 0.9) at_max++;
double sxy = 0, sxx = 0;
int used = 0;
for (int i = 0; i < R.n; i++) {
if (at_max > 1 && R.credit_gib[i] > R.depth_gib * 0.9 && i > 0) continue;
sxy += R.gap_s[i] * R.credit_gib[i];
sxx += R.gap_s[i] * R.gap_s[i];
used++;
}
if (sxx > 0) {
double slope = sxy / sxx; /* GiB per second */
R.f_mibps = slope * 1024.0;
double ss_res = 0, ss_tot = 0, ym = 0;
for (int i = 0; i < R.n; i++) ym += R.credit_gib[i];
ym /= R.n ? R.n : 1;
for (int i = 0; i < R.n; i++) {
double e = R.credit_gib[i] - slope * R.gap_s[i];
ss_res += e * e;
ss_tot += (R.credit_gib[i] - ym) * (R.credit_gib[i] - ym);
}
R.r2 = ss_tot > 0 ? 1 - ss_res / ss_tot : 0;
if (slope > 0) R.full_min = R.depth_gib / slope / 60.0;
}
(void)used;
return R;
}
/* -------------------------------- --scale: is the quota per volume or per VM?
*
* Everything else in this tool yields a lower bound on the hardware, because a
* per-VM quota cannot be seen past from inside the VM. This is the exception,
* and it is why it needs volumes that only someone with host access can attach.
*
* Drive N volumes at once. If aggregate throughput scales with N the quota is
* charged per volume and the knee, when it arrives, is the hardware. If it does
* not scale at all the quota is per VM - which answers the question in the
* other direction and is worth just as much. */
#define SCALE_MAX 8
typedef struct {
char path[600];
int fd;
uint64_t bytes;
volatile int *stop;
uint64_t t0;
int idx;
} scale_t;
static void *scale_worker(void *arg) {
scale_t *s = (scale_t *)arg;
size_t bs = BLOB_BS;
uint8_t *buf = xalloc(bs);
uint64_t off = 0, salt = 1;
uint64_t cap = 2 * GIB;
while (!*s->stop) {
gen_region(buf, bs, C.nonce, (uint32_t)(s->idx + 20), off, salt++);
if (pwrite(s->fd, buf, bs, (off_t)off) != (ssize_t)bs)
die("scale write %s: %s", s->path, strerror(errno));
s->bytes += bs;
off += bs;
if (off + bs > cap) off = 0;
}
free(buf);
return NULL;
}
static double scale_run(scale_t *v, int n, int secs) {
volatile int stop = 0;
pthread_t th[SCALE_MAX];
uint64_t t0 = now_ns();
for (int i = 0; i < n; i++) {
v[i].bytes = 0; v[i].stop = &stop; v[i].t0 = t0;
pthread_create(&th[i], NULL, scale_worker, &v[i]);
}
sleep_ns((uint64_t)secs * 1000000000ULL);
stop = 1;
uint64_t tot = 0;
for (int i = 0; i < n; i++) { pthread_join(th[i], NULL); tot += v[i].bytes; }
return (double)tot / MIB / ((double)(now_ns() - t0) / 1e9);
}
static int mode_scale(char *dirs, int secs) {
scale_t v[SCALE_MAX];
memset(v, 0, sizeof v);
int n = 0;
for (char *tok = strtok(dirs, ","); tok && n < SCALE_MAX; tok = strtok(NULL, ",")) {
snprintf(v[n].path, sizeof v[n].path, "%s/iolite_scale.bin", tok);
v[n].idx = n;
v[n].fd = open_direct(v[n].path, O_RDWR | O_CREAT | O_DSYNC);
if (ftruncate(v[n].fd, (off_t)(2 * GIB)) != 0)
die("ftruncate %s: %s", v[n].path, strerror(errno));
n++;
}
if (n < 2) die("--scale needs at least two comma-separated directories, "
"each on a different volume");
/* Two paths on one filesystem measure contention, not scaling, and the
verdict below would be meaningless. Say so rather than printing it. */
for (int i = 0; i < n; i++)
for (int j = i + 1; j < n; j++) {
struct stat a, b;
if (!stat(v[i].path, &a) && !stat(v[j].path, &b) && a.st_dev == b.st_dev)
vp(2, " WARNING: %s and %s are on the same device. This will\n"
" measure contention between two streams, not\n"
" whether the quota is charged per volume.\n\n",
v[i].path, v[j].path);
}
vp(2, "==============================================================\n");
vp(2, "iolite --scale : per-volume or per-VM quota?\n");
vp(2, "==============================================================\n");
vp(2, " volumes : %d, %d s per point, 1 MiB O_DSYNC sequential each\n\n",
n, secs);
double solo = scale_run(v, 1, secs);
vp(2, " 1 volume %10.1f MiB/s\n", solo);
fflush(stdout);
double agg[SCALE_MAX + 1];
agg[1] = solo;
for (int k = 2; k <= n; k++) {
agg[k] = scale_run(v, k, secs);
vp(2, " %d volumes %10.1f MiB/s (%.2fx of one, ideal %d.00x)\n",
k, agg[k], solo > 0 ? agg[k] / solo : 0, k);
fflush(stdout);
}
double eff = solo > 0 ? agg[n] / (solo * n) : 0;
vp(2, "\n scaling efficiency at %d volumes: %.2f\n", n, eff);
if (eff > 0.80)
vp(2, " -> PER VOLUME. The quota is charged per volume, so aggregate\n"
" throughput is not capped by it. %.1f MiB/s is a hardware\n"
" lower bound; add volumes until it stops scaling and that\n"
" knee is the hardware ceiling.\n", agg[n]);
else if (eff < 0.35)
vp(2, " -> PER VM. Extra volumes bought nothing: one budget is shared\n"
" across them. The hardware ceiling is not recoverable from\n"
" inside this guest by any means, and %.1f MiB/s remains a\n"
" lower bound only.\n", agg[n]);
else
vp(2, " -> MIXED. Partial scaling: either a per-volume quota against a\n"
" nearby hardware limit, or a per-VM cap above the single-volume\n"
" rate. Repeat with more volumes to separate them.\n");
vp(2, "==============================================================\n");
for (int i = 0; i < n; i++) { close(v[i].fd); if (!C.keep) unlink(v[i].path); }
return 0;
}
/* ------------------------------------------------------------- system */
static uint64_t ram_bytes(void) {
FILE *f = fopen("/proc/meminfo", "r");
if (!f) return 0;
char k[64]; unsigned long long v = 0;
while (fscanf(f, "%63s %llu kB\n", k, &v) == 2)
if (!strcmp(k, "MemTotal:")) { fclose(f); return v * 1024ULL; }
fclose(f);
return 0;
}
static uint64_t free_bytes(const char *dir) {
struct statvfs s;
if (statvfs(dir, &s) != 0) return 0;
return (uint64_t)s.f_bavail * s.f_frsize;
}
/* Longest matching mount point in /proc/mounts, so we can refuse to "benchmark"
* a RAM-backed filesystem. Pointing a storage probe at tmpfs measures memcpy;
* it is an easy mistake because /tmp is the obvious scratch directory. */
static void fs_info(const char *dir, char *type, size_t tn, char *mnt, size_t mn) {
snprintf(type, tn, "unknown"); snprintf(mnt, mn, "?");
/* realpath() into a caller-supplied buffer may write up to PATH_MAX and has
* no parameter for saying the buffer is smaller than that, so a fixed
* char[1024] here was a latent stack smash for any directory resolving past
* 1024 bytes. glibc's _FORTIFY_SOURCE refuses the call on the destination's
* size alone, which is why a build with hardening on aborted every run
* before printing anything while an unfortified build appeared to work.
* Letting realpath allocate removes the limit rather than raising it. */
char *rp = realpath(dir, NULL);
const char *real = rp ? rp : dir; /* unresolvable: match on what we got */
FILE *f = fopen("/proc/mounts", "r");
if (f) {
char dev[512], mp[512], ty[128];
size_t best = 0;
while (fscanf(f, "%511s %511s %127s %*[^\n]\n", dev, mp, ty) == 3) {
size_t L = strlen(mp);
if (L > best && !strncmp(real, mp, L) &&
(real[L] == '/' || real[L] == '\0' || L == 1)) {
best = L;
snprintf(type, tn, "%s", ty);
snprintf(mnt, mn, "%s", mp);
}
}
fclose(f);
}
free(rp);
}
static bool fs_is_ram(const char *type) {
return !strcmp(type, "tmpfs") || !strcmp(type, "ramfs") || !strcmp(type, "devtmpfs");
}
/* --------------------------------------- what a test this long can be told
*
* GAME.md section 2, made executable. A volume delivering sustained rate r with
* a reservoir of depth B answers at beta*r until the reservoir empties. A test
* of length T that never reaches the cliff quotes beta*r; true delivery over a
* planning window W is r*(tau*beta + 1 - tau) with tau = T/W. The ratio
*
* rho = beta / (tau*beta + 1 - tau)
*
* is how much the quoted figure overstates the deliverable one, and it needs no
* bad faith from anyone: every number the buyer measured was true. The worked
* example is a 17 min test against a 1 h window, tau = 0.283, beta = 10:
* 0.283*10 + 0.717 = 3.547, so rho = 10/3.547 = 2.82. Quote 10, get 3.55.
*
* Two things fall out that this binary reports instead of leaving to taste:
*
* bound = 1/tau the most any reservoir can overstate this run, however
* deep, because rho -> 1/tau as beta -> infinity. This holds
* even when the cliff was never reached, which is exactly the
* case where nothing else can be said.
* B* = beta*r*T the reservoir depth that would have hidden the cliff for
* the whole run. Comparing it to the credit actually observed
* says whether this host is anywhere near sized to defeat the
* test it was just given.
*
* When a shallow fall was seen, beta is a LOWER bound - the reservoir may not
* have been reached - so rho is reported as the bracket [rho(beta_obs), 1/tau]
* rather than as a number. */
typedef struct {
double tau, beta, rho, bound, test_s, window_s, b_star_gib;
const char *need; /* profile that would hold rho <= 2x, NULL if none */
bool valid;
} game_t;
static game_t game_model(double test_s, double window_s, double peak,
double sust, double r_mibps) {
game_t g; memset(&g, 0, sizeof g);
if (test_s <= 0 || window_s <= 0 || sust <= 0) return g;
g.test_s = test_s; g.window_s = window_s;
g.tau = test_s / window_s;
if (g.tau > 1) g.tau = 1;
g.beta = peak > sust ? peak / sust : 1.0;
g.rho = g.beta / (g.tau * g.beta + 1 - g.tau);
g.bound = 1.0 / g.tau;
g.b_star_gib = g.beta * r_mibps * test_s / 1024.0;
/* Inverting rho <= k for all beta gives tau >= 1/k; at k = 2 that is a test
* covering half the window. Name the shortest stock profile that does it. */
for (int i = 0; i < NPROFILES; i++)
if ((double)PROFILES[i].budget_s / window_s >= 0.5) { g.need = PROFILES[i].name; break; }
g.valid = true;
return g;
}
/* Both of the rules above decide something - whether to end the run early, and
* what to tell the buyer when it does not. This campaign's most expensive bugs
* were quantities that were computed and displayed but never tested against a
* threshold, so both get exercised on synthetic series with known answers
* before any storage is touched. */
static int cliff_selftest(void) {
int bad = 0;
double v[400];
cliff_t cl; double plat;
/* 1. A step from 1000 to 200 is a cliff, and the plateau it is measured
against is the pre-step rate, not the post-step one. */
memset(&cl, 0, sizeof cl); plat = 0;
for (int i = 0; i < 200; i++) v[i] = (i < 100) ? 1000 : 200;
for (int i = CLIFF_MED - 1; i < 200; i++)
cliff_step(&cl, &plat, med5(v, i + 1), i);
if (!cl.deep || cl.shallow) bad++;
if (cl.plateau < 999 || cl.plateau > 1001) bad++;
if (cl.drop < 0.79 || cl.drop > 0.81) bad++;
/* 2. A 12% fall is NOT a cliff. This is the case the whole shallow branch
exists for: it must be recorded and must not end the run. */
memset(&cl, 0, sizeof cl); plat = 0;
for (int i = 0; i < 200; i++) v[i] = (i < 100) ? 1000 : 880;
for (int i = CLIFF_MED - 1; i < 200; i++)
cliff_step(&cl, &plat, med5(v, i + 1), i);
if (cl.deep || !cl.shallow) bad++;
if (cl.drop < 0.11 || cl.drop > 0.13) bad++;
/* 3. One stalled second is not a cliff. A mean over the same window would
read 820 against a 1000 plateau and fire; the median must not. */
memset(&cl, 0, sizeof cl); plat = 0;
for (int i = 0; i < 200; i++) v[i] = (i == 120) ? 100 : 1000;
for (int i = CLIFF_MED - 1; i < 200; i++)
cliff_step(&cl, &plat, med5(v, i + 1), i);
if (cl.deep || cl.shallow) bad++;
/* 4. A slow recovery after a proposal must not redefine the plateau, or the
drop shrinks back under the threshold and the finding evaporates. */
memset(&cl, 0, sizeof cl); plat = 0;
for (int i = 0; i < 300; i++)
v[i] = (i < 100) ? 1000 : (i < 200 ? 200 : 900);
for (int i = CLIFF_MED - 1; i < 300; i++)
cliff_step(&cl, &plat, med5(v, i + 1), i);
if (!cl.deep) bad++;
if (cl.plateau < 999 || cl.plateau > 1001) bad++;
/* 5. THE HOLD IS A TEST. A momentary dip proposes a cliff and must be
refused, because the hold window is back at the plateau. This is the
case a real run produced, and that the first version of this code
reported as a deep cliff. */
memset(&cl, 0, sizeof cl);
cl.plateau = 1000; cl.deep = true; cl.drop = 0.8;
for (int i = 0; i < 120; i++) v[i] = 995;
if (cliff_confirm(&cl, v, 120)) bad++;
if (cl.deep) bad++;
if (cl.withdrawn != 1) bad++;
if (cl.dip_drop < 0.79 || cl.dip_drop > 0.81) bad++; /* what was seen is kept */
/* 6. A step that stays down is confirmed, and the reported drop is measured
over the whole hold rather than from the sample that proposed it. */
memset(&cl, 0, sizeof cl);
cl.plateau = 1000; cl.deep = true; cl.drop = 0.55;
for (int i = 0; i < 120; i++) v[i] = 200;
if (!cliff_confirm(&cl, v, 120)) bad++;
if (cl.drop < 0.79 || cl.drop > 0.81) bad++;
if (cl.post < 199 || cl.post > 201) bad++;
/* 7. After enough refusals it stops proposing, so a host that swings by
half all day cannot spend the whole budget in confirmation holds. */
memset(&cl, 0, sizeof cl); plat = 1000;
for (int i = 0; i < CLIFF_MAX_WITHDRAW; i++) {
cl.plateau = 1000; cl.deep = true;
for (int j = 0; j < 120; j++) v[j] = 995;
cliff_confirm(&cl, v, 120);
}
if (!cl.locked) bad++;
cliff_step(&cl, &plat, 100, 500); /* a 90% fall, while locked */
if (cl.deep) bad++;
return bad;
}
static int game_selftest(void) {
int bad = 0;
/* The worked example from GAME.md S2: a 17 min test against a 1 h window
is tau = 0.283, and at beta = 10 the denominator is 0.283*10 + 0.717 =
3.547, so rho = 2.82. Pinned here because the number appears in the
paper, in the README and in the output, and three copies of an
arithmetic result is two too many to leave unchecked. */
game_t g = game_model(17 * 60, 3600, 1000, 100, 100);
if (g.beta < 9.99 || g.beta > 10.01) bad++;
if (g.tau < 0.282 || g.tau > 0.284) bad++;
if (g.rho < 2.81 || g.rho > 2.83) bad++;
/* The bound is 1/tau and must hold for any beta, which is the claim that
makes a never-reached cliff reportable at all. */
if (g.bound < 3.52 || g.bound > 3.54) bad++;
game_t h = game_model(17 * 60, 3600, 1e9, 100, 100);
if (h.rho > g.bound) bad++;
/* A test covering the whole window cannot be overstated at all. */
game_t w = game_model(3600, 3600, 1000, 100, 100);
if (w.rho < 0.99 || w.rho > 1.01 || w.bound < 0.99 || w.bound > 1.01) bad++;
return bad;
}
/* --------------------------------------------------------------- main */
/* Rough cost of everything after the drain, used to decide how much of the
* budget the drain may spend. Deliberately an over-estimate: overrunning the
* declared runtime on someone else's production box is worse than a drain cut
* slightly short, and a drain that is cut short is reported as such. */
static int tail_estimate_s(void) {
int qs = C.phase_s / 3 < 10 ? 10 : C.phase_s / 3;
int lsec = C.phase_s / 6 < 5 ? 5 : C.phase_s / 6;
return DSYNC_CELLS * qs /* [2] O_DSYNC bypass */
+ 2 * C.phase_s /* [3] blob + copy loop */
+ 2 * C.tx_s /* [4][5] transactions */
+ 4 * qs /* [6] limit probe */
+ qs /* [7] service time */
+ LOAD_PTS * lsec /* [8] latency vs load */
+ C.phase_s /* [9] concurrent */
+ 60 /* [10] metadata, plus slack */
+ 30;
}
/* --version prints the version on the FIRST line and the mascot after it, so
* `iolite --version | head -1` stays parseable by anything that wants just the
* string. Pure ASCII, no box-drawing: this binary is built static precisely so
* it can run on a minimal host, and such a host's terminal cannot be assumed
* to be UTF-8. See docs/MASCOT.md for what the two objects mean. */
static void print_version(void) {
printf("iolite %s \"%s\"\n", IOLITE_VERSION, IOLITE_CODENAME);
printf(
"\n"
" _..._\n"
" .;;;;;;;.\n"
" ;; o o ;; .-----------.\n"
" ;; ^ ;; | |\n"
" ';. '-' .;' | | burst\n"
" '-...-' | |\n"
" .-'` | `'-.____________ +-----------+ <- the cliff\n"
" .' /|\\ '. |;;;;;;;;;;;|\n"
" / / | \\ \\ |;;;;;;;;;;;| sustained\n"
" | .-' | '-. | |;;;;;;;;;;;|\n"
" | .'###########'. | '-----------'\n"
" '-'###############'-'\n"
" '---------------' what is left\n"
" || || is what you get\n"
" (__) (__)\n"
"\n"
" she is holding the number you were quoted, and it has already fallen\n"
"\n");
/* The binary travels alone - it is fetched over HTTP with no COPYING beside
* it - so the terms and the output exception have to be reachable from the
* program itself, not only from the repository it was built in. */
printf(
" Free software under GPL-3.0-or-later, with an additional permission: the\n"
" measurements this program produces are yours, and may be published, quoted\n"
" or sold with no obligation under the licence. The program itself is still\n"
" copyleft. Terms: https://www.gnu.org/licenses/gpl-3.0.html\n"
" This program comes with ABSOLUTELY NO WARRANTY.\n"
"\n");
}
static void usage(void) {
printf(
"iolite " IOLITE_VERSION " \"" IOLITE_CODENAME "\" - portable storage capability probe\n"
"\n"
" usage: ./iolite [profile] [options]\n"
"\n"
" With no arguments at all it runs the `normal` profile in the current\n"
" directory and needs nothing else. The profile is the only choice worth\n"
" making, and it is a choice of planning window, not of patience:\n"
"\n"
" normal 25 min defends a 1 h window <= 2.4x overstatement\n"
" extended 1 h defends a 4 h window <= 4.0x\n"
" long 6 h defends a 24 h window <= 4.0x + 4 repeat cycles\n"
" extra 24 h defends a 7 d window <= 7.0x + 8 cycles, idle\n"
" between them\n"
" A test covering fraction tau of a planning window can be overstated by at\n"
" most 1/tau however deep the seller's burst reservoir (GAME.md S2), so a\n"
" longer run defends a LONGER window rather than the same window better.\n"
" Pick by the window you must plan for. Each profile is a hard wall clock.\n"
"\n"
" The profile may also be given as $IOLITE_PROFILE, or by the name of the\n"
" binary itself - a copy or symlink called iolite-long runs the long profile\n"
" with no arguments, which is what a cron entry or unit file wants.\n"
"\n"
" Verbosity is a choice of QUESTION, not of patience:\n"
"\n"
" -v (default) what did I buy, is it negotiable, how wrong can this\n"
" be? One screen, ~900 characters, nothing that needs a\n"
" second number to interpret.\n"
" -vv the numbers behind each of those verdicts - for a\n"
" reader checking the conclusion.\n"
" -vvv the instrument's own state: CPU, schedule backlog,\n"
" steady-state gate criteria, provenance, and which\n"
" guards stayed silent - for a reader who suspects the\n"
" tool rather than the volume.\n"
" -vvvv the raw series as CSV lines (drain, load curve,\n"
" autocorrelogram, soak) - for the operator on the other\n"
" side of the invoice, who is entitled to replot the\n"
" curve rather than accept a verdict drawn from it.\n"
"\n"
" A withdrawal or a warning is never demoted by tier: if a verdict was taken\n"
" back, that appears at -v. A true number quoted without its caveat is the\n"
" exact failure this program exists to prevent.\n"
"\n"
" --dir PATH working directory (default: .)\n"
" --size GIB working set (default: 2x RAM, bounded by free space)\n"
" --clients N concurrent transaction clients (default 8)\n"
" --page BYTES transaction page size (default 8192)\n"
" --reads N index lookups per transaction (default 4)\n"
" --writes N pages dirtied per transaction (default 2)\n"
" --quick ~4 min, no budget (overrides the profile)\n"
" --full ~25 min, no budget (overrides the profile)\n"
" --drain-max SEC cap on burst-drain time (default: from the profile)\n"
" --drain-eps F steady-state tolerance, CV over 8 samples (default 0.03).\n"
" Raise it for a host whose own rate is noisy; the value is\n"
" reported with the result, because a sustained figure means\n"
" nothing without the criterion that accepted it.\n"
" --version print version and exit\n"
" -v .. -vvvv verbosity, see above (default -v)\n"
" --json FILE write the -v tier as a flat JSON record. Deliberately\n"
" only that tier: it is an ingest row for a database that\n"
" will hold many runs, and such a schema is useful only\n"
" while it stays small, flat and stable. The curves live\n"
" in the -vvvv CSV, which is the right shape for a plot\n"
" and the wrong shape for a row.\n"
" --keep do not delete the working files on exit\n"
" --selftest run instrument checks and exit\n"
"\n"
" campaign modes (run instead of the standard probe):\n"
" --refill burst-recovery staircase: measures how fast credit comes\n"
" back, and with it the host's flush rate. ~1-2 h, nearly\n"
" all of it idle.\n"
" --gaps A,B,C idle gaps in seconds (default 30,60,120,300,600,1200,2400)\n"
" --scale D1,D2,.. drive N volumes at once to decide whether the quota is\n"
" charged per volume or per VM. Needs volumes attached by\n"
" someone with host access.\n");
}
int main(int argc, char **argv) {
/* Line-buffer even when stdout is a file. A run takes 18 minutes and is
normally redirected by systemd; block buffering makes it look stalled at
whatever phase last filled 4 KiB. */
setvbuf(stdout, NULL, _IOLBF, 0);
uint64_t run_t0 = now_ns();
memset(&C, 0, sizeof C);
snprintf(C.dir, sizeof C.dir, ".");
C.page = 8192; C.clients = 8; C.tx_reads = 4; C.tx_writes = 2;
C.ss_eps = 0.03;
C.nonce = (uint64_t)time(NULL) * 1000003ULL ^ (uint64_t)getpid();
uint64_t size_arg = 0;
bool do_refill = false;
bool budgeted = true;
char scale_dirs[1024] = "";
char gaps_arg[512] = "30,60,120,300,600,1200,2400";
/* Profile, weakest source first so the stronger one wins: the binary's own
* name, then the environment, then a bare word anywhere on the command
* line. The name route is there so a deployment can be a copy called
* iolite-long and an ExecStart with no arguments to get wrong; the bare
* word is accepted at any position because the orchestrator appends its
* extra arguments after --dir and --json, and a probe that only reads a
* profile from argv[1] would reject it there. */
{
const char *base = strrchr(argv[0], '/');
base = base ? base + 1 : argv[0];
const char *dash = strrchr(base, '-');
const profile_t *p = dash ? profile_by_name(dash + 1) : NULL;
if (!p) p = profile_by_name(getenv("IOLITE_PROFILE"));
if (p) P = p;
}
/* Explicit timing flags beat the profile, so the profile's own values are
* applied after parsing to whatever the flags did not claim. */
bool set_timing = false, set_drain_max = false;
for (int i = 1; i < argc; i++) {
const char *a = argv[i];
#define NEXT (i + 1 < argc ? argv[++i] : (die("%s needs a value", a), ""))
if (a[0] != '-') {
const profile_t *p = profile_by_name(a);
if (!p) die("unknown argument \"%s\" - expected a profile (normal, "
"extended, long, extra) or a flag; see --help", a);
P = p;
}
else if (!strcmp(a, "--dir")) snprintf(C.dir, sizeof C.dir, "%s", NEXT);
else if (!strcmp(a, "--size")) size_arg = strtoull(NEXT, NULL, 10) * GIB;
else if (!strcmp(a, "--clients")) C.clients = atoi(NEXT);
else if (!strcmp(a, "--page")) C.page = atoi(NEXT);
else if (!strcmp(a, "--reads")) C.tx_reads = atoi(NEXT);
else if (!strcmp(a, "--writes")) C.tx_writes = atoi(NEXT);
else if (!strcmp(a, "--drain-max")) { C.drain_max_s = atoi(NEXT); set_drain_max = true; }
else if (!strcmp(a, "--drain-eps")) C.ss_eps = atof(NEXT);
else if (!strcmp(a, "--quick")) { C.phase_s = 20; C.tx_s = 25; C.drain_max_s = 120; C.meta_files = 8000; budgeted = false; set_timing = set_drain_max = true; }
else if (!strcmp(a, "--full")) { C.phase_s = 90; C.tx_s = 150; C.drain_max_s = 420; C.meta_files = 50000; budgeted = false; set_timing = set_drain_max = true; }
else if (!strcmp(a, "--json")) { C.json = true; snprintf(C.json_path, sizeof C.json_path, "%s", NEXT); }
else if (!strcmp(a, "--version")) { print_version(); return 0; }
else if (!strcmp(a, "-v")) V = 1;
else if (!strcmp(a, "-vv")) V = 2;
else if (!strcmp(a, "-vvv")) V = 3;
else if (!strcmp(a, "-vvvv")) V = 4;
else if (!strcmp(a, "--keep")) C.keep = true;
else if (!strcmp(a, "--refill")) do_refill = true;
else if (!strcmp(a, "--gaps")) snprintf(gaps_arg, sizeof gaps_arg, "%s", NEXT);
else if (!strcmp(a, "--scale")) snprintf(scale_dirs, sizeof scale_dirs, "%s", NEXT);
else if (!strcmp(a, "--selftest")) {
int hb = hist_selftest(), gb = gen_selftest();
int cb = cliff_selftest(), mb = game_selftest();
printf("histogram round-trip: %s (%d defects)\n", hb ? "FAIL" : "PASS", hb);
printf("block salt/verify : %s (%d defects)\n", gb ? "FAIL" : "PASS", gb);
printf("cliff classifier : %s (%d defects)\n", cb ? "FAIL" : "PASS", cb);
printf("overstatement model : %s (%d defects)\n", mb ? "FAIL" : "PASS", mb);
return (hb || gb || cb || mb) ? 1 : 0;
}
else if (!strcmp(a, "-h") || !strcmp(a, "--help")) { usage(); return 0; }
else die("unknown option %s", a);
}
if (!set_timing) {
C.phase_s = P->phase_s; C.tx_s = P->tx_s; C.meta_files = P->meta_files;
}
if (!set_drain_max) C.drain_max_s = P->drain_max_s;
int selfbad = hist_selftest();
if (selfbad) die("histogram self-test failed (%d defects) - refusing to "
"report latency from a broken instrument", selfbad);
selfbad = gen_selftest();
if (selfbad) die("block generator self-test failed (%d defects) - refusing "
"to report throughput that a dedup layer could absorb",
selfbad);
selfbad = cliff_selftest();
if (selfbad) die("cliff classifier self-test failed (%d defects) - refusing "
"to end a run early on a rule that cannot classify a "
"synthetic step", selfbad);
selfbad = game_selftest();
if (selfbad) die("overstatement model self-test failed (%d defects) - "
"refusing to quote a bound from arithmetic that does not "
"reproduce the worked example", selfbad);
if (C.page % (int)ALIGN) die("--page must be a multiple of %u", ALIGN);
uint64_t ram = ram_bytes(), freeb = free_bytes(C.dir);
if (!freeb) die("cannot stat %s", C.dir);
char fstype[128], fsmnt[512];
fs_info(C.dir, fstype, sizeof fstype, fsmnt, sizeof fsmnt);
if (fs_is_ram(fstype) && !getenv("IOLITE_ALLOW_RAMFS"))
die("%s is on %s (%s) - a RAM-backed filesystem.\n"
" Benchmarking it measures memory bandwidth, not storage.\n"
" Pick a directory on real storage, or set IOLITE_ALLOW_RAMFS=1.",
C.dir, fsmnt, fstype);
C.wal_bytes = 1 * GIB;
if (size_arg) C.ws_bytes = size_arg;
else {
C.ws_bytes = ram ? ram * 2 : 8 * GIB;
uint64_t room = freeb > (3 * GIB) ? (freeb - 3 * GIB) * 2 / 3 : 0;
if (C.ws_bytes > room) C.ws_bytes = room;
}
C.ws_bytes = (C.ws_bytes / MIB) * MIB;
if (C.ws_bytes < 512 * MIB) die("need at least ~4 GiB free in %s (have %.1f GiB)",
C.dir, (double)freeb / GIB);
if (C.wal_bytes > C.ws_bytes / 4) C.wal_bytes = C.ws_bytes / 4;
snprintf(PATH_DATA, sizeof PATH_DATA, "%s/iolite_data.bin", C.dir);
snprintf(PATH_WAL, sizeof PATH_WAL, "%s/iolite_wal.bin", C.dir);
snprintf(PATH_BLOB, sizeof PATH_BLOB, "%s/iolite_blob.bin", C.dir);
struct utsname un; uname(&un);
double ws_ram = ram ? (double)C.ws_bytes / ram : 0;
/* The campaign modes have their own schedules - a refill staircase is
* mostly idle by design and a scale run is bounded by its own phases - so
* the profile budget applies to the standard probe only. */
if (do_refill || scale_dirs[0]) budgeted = false;
if (budgeted) G_deadline_ns = now_ns() + (uint64_t)P->budget_s * 1000000000ULL;
vp(2, "==============================================================\n");
vp(2, "iolite %s \"%s\" - storage capability probe\n",
IOLITE_VERSION, IOLITE_CODENAME);
vp(2, "==============================================================\n");
vp(2, " host : %s %s %s\n", un.nodename, un.sysname, un.release);
if (budgeted) {
char win[32];
if (P->window_s >= 86400) snprintf(win, sizeof win, "%.0f d", P->window_s / 86400.0);
else snprintf(win, sizeof win, "%.0f h", P->window_s / 3600.0);
vp(2, " profile : %s - %.0f min budget, defends a %s planning window\n"
" (overstatement bounded at %.1fx however deep the"
" reservoir)\n",
P->name, P->budget_s / 60.0, win,
(double)P->window_s / P->budget_s);
} else
vp(2, " profile : none (explicit timing flags; no wall-clock budget)\n");
vp(2, " dir : %s (%.1f GiB free, %s on %s)\n", C.dir,
(double)freeb / GIB, fstype, fsmnt);
vp(2, " RAM : %.1f GiB\n", (double)ram / GIB);
vp(2, " workingset: %.1f GiB (%.1fx RAM)%s\n", (double)C.ws_bytes / GIB, ws_ram,
ws_ram < 1.95 ? " <-- WARNING: below 2x RAM" : "");
vp(2, " txn model : %d lookups + %d page RMW + WAL append + fdatasync, "
"%d clients\n", C.tx_reads, C.tx_writes, C.clients);
vp(2, " nonce : %" PRIu64 "\n", C.nonce);
vp(2, " instrument: histogram round-trip PASS, block salt/verify PASS\n");
/* Provenance. Everything needed to say whether two runs are comparable, and
* the things that most often differ without anyone noticing: the scheduler,
* the cgroup the run landed in, and how the working set was chosen. */
if (V >= 3) {
vp(3, " timing : drain cap %ds, phase %ds, tx %ds, meta %d files,"
" drain-eps %.3f\n", C.drain_max_s, C.phase_s, C.tx_s,
C.meta_files, C.ss_eps);
vp(3, " thresholds: cliff deep %.0f%% / shallow %.0f%%, hold %ds,"
" median %d\n"
" dispersion pacer <%.1f media >%.1f, tick r>=%.2f"
" prom>=%.2f ratio>=%.0fx\n",
CLIFF_DEEP * 100, CLIFF_SHALLOW * 100, CLIFF_HOLD_S, CLIFF_MED,
DISP_PACER, DISP_MEDIA, TICK_MIN_R, TICK_MIN_PROM,
(double)TICK_MIN_RATIO);
char pbuf[256];
FILE *pf = fopen("/proc/self/cgroup", "r");
if (pf) {
if (fgets(pbuf, sizeof pbuf, pf)) {
pbuf[strcspn(pbuf, "\n")] = 0;
vp(3, " cgroup : %s\n", pbuf);
}
fclose(pf);
}
/* The scheduler decides how much of the observed queueing is the
* kernel's rather than the volume's, and it is the single most common
* silent difference between two hosts that "look the same".
*
* Resolved from the working directory's own st_dev rather than by
* guessing a device name, which produces a confident report of some
* other disk's scheduler - worse than printing nothing. For a partition
* the request queue lives on the parent disk, hence the "..". */
struct stat sb;
if (stat(C.dir, &sb) == 0) {
char devp[256];
unsigned maj = major(sb.st_dev), min = minor(sb.st_dev);
snprintf(devp, sizeof devp,
"/sys/dev/block/%u:%u/../queue/scheduler", maj, min);
pf = fopen(devp, "r");
if (!pf) { /* whole-disk device, not a partition */
snprintf(devp, sizeof devp,
"/sys/dev/block/%u:%u/queue/scheduler", maj, min);
pf = fopen(devp, "r");
}
if (pf) {
if (fgets(pbuf, sizeof pbuf, pf)) {
pbuf[strcspn(pbuf, "\n")] = 0;
vp(3, " scheduler : %s (dev %u:%u)\n", pbuf, maj, min);
}
fclose(pf);
} else {
vp(3, " scheduler : not readable for dev %u:%u\n", maj, min);
}
}
}
if (V >= 4)
vp(4, "\nCSV columns:\n"
"CSV drain,t_s,mibps,cumulative_gib,median5_mibps,gate_cv,gate_drift,mark\n"
"CSV latload,frac_of_ceiling,offered_iops,delivered_iops,p50_us,"
"p99_us,p999_us,cpu_frac,backlog_L_over_n,threads\n"
"CSV acf,lag_ms,r (acf_mean is the baseline a peak must stand"
" above)\n"
"CSV soak,cycle,at_min,idle_s,sustained_mibps,credit_gib,"
"read_iops,tps\n");
vp(2, "\n");
if (scale_dirs[0]) return mode_scale(scale_dirs, C.phase_s);
phase("[1/%d] burst credit + sustained write\n", do_refill ? 2 : 10);
drain_t d = phase_drain();
vp(2, "\n");
/* The staircase needs exactly what the drain just produced - an exhausted
* reservoir and the sustained rate to measure recovery against - so it
* branches here rather than duplicating the drain. */
if (do_refill) {
double gaps[REFILL_MAX_PTS];
int ng = 0;
for (char *tok = strtok(gaps_arg, ","); tok && ng < REFILL_MAX_PTS;
tok = strtok(NULL, ","))
gaps[ng++] = atof(tok);
double total = 0;
for (int i = 0; i < ng; i++) total += gaps[i] + gaps[i] / 4 + 60;
phase("[2/2] burst recovery staircase (%d gaps, ~%.0f min)\n",
ng, total / 60);
vp(2, " sustained %.1f MiB/s, credit spent, reservoir empty\n\n",
d.sustained_mibps);
refill_t R = phase_refill(gaps, ng, d.sustained_mibps);
vp(2, "\n==============================================================\n");
vp(2, "BURST RECOVERY\n");
vp(2, "==============================================================\n");
vp(2, " idle gap credit recovered probe\n");
for (int i = 0; i < R.n; i++)
vp(2, " %8.0f s %10.2f GiB %5.0f s\n",
R.gap_s[i], R.credit_gib[i], R.probe_s[i]);
if (R.latent)
vp(2, "\n RECOVERY HAS A DEAD TIME - no single rate describes it.\n"
" nothing returned through %.0f s; %.2f GiB had returned by\n"
" %.0f s. A proportional refill would have returned %.1f GiB\n"
" at the %.0f s point and did not.\n"
" averaged over the whole gap %8.1f MiB/s (%.2fx the quota)\n"
" averaged over the window it\n"
" could have happened in %8.1f MiB/s (%.2fx, upper bound)\n",
R.onset_lo_s, R.onset_credit_gib, R.onset_hi_s,
R.onset_credit_gib * R.onset_lo_s
/ (R.onset_hi_s > 0 ? R.onset_hi_s : 1),
R.onset_lo_s,
R.f_avg_mibps, R.sustained > 0 ? R.f_avg_mibps / R.sustained : 0,
R.f_window_mibps,
R.sustained > 0 ? R.f_window_mibps / R.sustained : 0);
else
vp(2, "\n refill rate f %10.1f MiB/s (R^2 %.2f over %d points)\n",
R.f_mibps, R.r2, R.n);
vp(2, " deepest credit seen %10.2f GiB\n", R.depth_gib);
vp(2, " time to a full reservoir %8.0f min\n", R.full_min);
vp(2, " guest sustained rate %10.1f MiB/s\n", R.sustained);
if (R.latent)
vp(2, " -> the linear model is REJECTED by its own data, so f is not\n"
" reported as a rate. Recovery is gated on something with a\n"
" threshold in it - a periodic replenishment, or a host flush\n"
" that runs on its own schedule - and between %.0f and %.0f s\n"
" is where that gate opens on this host.\n"
" For planning: burst is NOT available again within %.0f min\n"
" of exhausting it, and IS at least partly available by\n"
" %.0f min. Anything between those is unmeasured.\n",
R.onset_lo_s, R.onset_hi_s, R.onset_lo_s / 60.0,
R.onset_hi_s / 60.0);
else if (R.depth_gib < 0.05 && d.settled && d.burst_gib >= 1)
/* The strong case, and the one worth stating plainly: phase 1
demonstrated a reservoir and emptied it, and then nothing came
back. That is a measurement of f, not an absence of one. */
vp(2, " -> NO CREDIT RETURNED AT ANY GAP, up to %.0f min. Phase 1 found\n"
" and exhausted %.0f GiB of reservoir in this same run, so\n"
" there was demonstrably something to refill and it did not\n"
" refill. f is below the resolution of this test (~%.2f MiB/s\n"
" over the longest gap) against a guest quota of %.1f MiB/s.\n"
" Burst is a one-off allowance on this timescale, not an\n"
" hourly one: after a burst, plan on the sustained rate.\n",
R.gap_s[R.n - 1] / 60.0, d.burst_gib,
R.gap_s[R.n - 1] > 0 ? 0.05 * 1024 / R.gap_s[R.n - 1] : 0,
R.sustained);
else if (R.depth_gib < 0.05)
vp(2, " -> no credit came back at any gap. Either this volume has no\n"
" burst reservoir to recover, or phase 1 never found one to\n"
" exhaust - check whether it reported a knee. With no\n"
" absorber there is nothing here to measure, and f is\n"
" undefined rather than zero.\n");
else if (R.f_mibps > R.sustained * 2)
vp(2, " -> the host does NOT meter its own flush. %.1f MiB/s is a\n"
" HARDWARE LOWER BOUND, measured from inside a guest that is\n"
" not permitted to write that fast. This is the only figure\n"
" here that is about the media rather than the policy.\n",
R.f_mibps);
else if (R.f_mibps > R.sustained * 0.8)
vp(2, " -> flush and guest I/O run at about the same rate: the host\n"
" meters its own write-back too, so this yields no view past\n"
" the quota. Worth knowing; not a hardware measurement.\n");
else
vp(2, " -> refill is SLOWER than the guest's own write quota (%.2fx).\n"
" That is not a buffer draining as fast as the array will\n"
" take it - it is a credit bucket with its own policy rate.\n"
" Burst is therefore not recurrent on the hour.\n",
R.sustained > 0 ? R.f_mibps / R.sustained : 0);
vp(2, "\n Confound: other tenants share the array, so f is what was spare\n"
" during this window, not the total. Repeat at other hours; the\n"
" maximum observed is the better lower bound.\n");
vp(2, "==============================================================\n");
if (C.json) {
FILE *f = fopen(C.json_path, "w");
if (!f) die("open %s: %s", C.json_path, strerror(errno));
fprintf(f, "{\n \"mode\": \"refill\", \"host\": \"%s\",\n"
" \"sustained_mibps\": %.2f, \"f_mibps\": %.2f,"
" \"r2\": %.3f, \"depth_gib\": %.3f, \"full_min\": %.1f,\n"
" \"latent\": %s, \"onset_lo_s\": %.0f, \"onset_hi_s\": %.0f,\n"
" \"f_avg_mibps\": %.2f, \"f_window_mibps\": %.2f,\n"
" \"points\": [", un.nodename, R.sustained, R.f_mibps,
R.r2, R.depth_gib, R.full_min,
R.latent ? "true" : "false", R.onset_lo_s, R.onset_hi_s,
R.f_avg_mibps, R.f_window_mibps);
for (int i = 0; i < R.n; i++)
fprintf(f, "%s\n {\"gap_s\": %.0f, \"credit_gib\": %.3f,"
" \"probe_s\": %.0f}",
i ? "," : "", R.gap_s[i], R.credit_gib[i], R.probe_s[i]);
fprintf(f, "\n ]\n}\n");
fclose(f);
vp(2, "wrote %s\n", C.json_path);
}
if (!C.keep) unlink(PATH_DATA);
return 0;
}
/* The drain is the only phase whose length is set by the data rather than
* by the clock, so it is also the only one that can overrun the profile.
* Whatever it spent, the remaining nine have to fit in what is left - and
* they are scaled together rather than run at full length until the budget
* runs out and the last of them is truncated to nothing. Nine slightly
* short phases beat six full ones and three missing. */
bool squeezed = false;
if (budgeted) {
int left = budget_left_s(), need = tail_estimate_s();
if (need > left && need > 0) {
double k = (double)left / need;
if (k < 0.15) k = 0.15; /* below this the phases stop meaning anything */
C.phase_s = (int)(C.phase_s * k);
C.tx_s = (int)(C.tx_s * k);
C.meta_files = (int)(C.meta_files * k);
if (C.phase_s < 10) C.phase_s = 10;
if (C.tx_s < 15) C.tx_s = 15;
if (C.meta_files < 2000) C.meta_files = 2000;
squeezed = true;
vp(2, " NOTE: the drain used %.0f min of the %.0f min budget, so the\n"
" remaining phases run at %.0f%% length. Their rates stay\n"
" comparable; their percentiles have fewer samples behind\n"
" them. Use a longer profile if that matters.\n\n",
(P->budget_s - left) / 60.0, P->budget_s / 60.0, k * 100);
}
}
/* WAL file, provisioned so transaction flushes are not extending the file */
{
int fd = open_direct(PATH_WAL, O_RDWR | O_CREAT);
if (ftruncate(fd, (off_t)C.wal_bytes) != 0) die("ftruncate wal");
size_t bs = 1 * MIB;
uint8_t *b = xalloc(bs);
for (uint64_t o = 0; o + bs <= C.wal_bytes; o += bs) {
gen_region(b, bs, C.nonce, 7, o, 0);
if (pwrite(fd, b, bs, (off_t)o) != (ssize_t)bs) die("wal fill");
}
fsync(fd); close(fd); free(b);
}
phase("[2/10] O_DSYNC bypass (absorber cannot hold these writes)\n");
int ds = C.phase_s / 3 < 10 ? 10 : C.phase_s / 3;
dsync_t dsy = phase_dsync(ds);
vp(2, "\n");
phase("[3/10] blob ingestion (post-burst)\n");
cell_t bw = run_cell(PATH_DATA, BLOB_BS, BLOB_QD, 1, 1, C.ws_bytes, C.phase_s, 0);
vp(2, " raw sequential write %8.1f MiB/s\n", bw.mibps);
double cp = copy_stream(C.phase_s);
vp(2, " read+write copy loop %8.1f MiB/s (%.0f%% of raw)\n",
cp, bw.mibps > 0 ? cp / bw.mibps * 100 : 0);
vp(2, "\n");
phase("[4/10] transactions - closed loop (saturation)\n");
tx_res_t tsat = run_tx(C.tx_s, 0);
vp(2, " %.1f tx/s p50 %.2f ms p99 %.2f ms p99.9 %.2f ms\n",
tsat.tps, tsat.p50 / 1e6, tsat.p99 / 1e6, tsat.p999 / 1e6);
vp(2, "\n");
double target = tsat.tps * 0.70;
phase("[5/10] transactions - open loop at %.0f tx/s (70%% of saturation)\n", target);
tx_res_t topen = run_tx(C.tx_s, target);
vp(2, " delivered %.1f tx/s p50 %.2f ms p99 %.2f ms p99.9 %.2f ms"
" max %.1f ms\n",
topen.tps, topen.p50 / 1e6, topen.p99 / 1e6, topen.p999 / 1e6,
topen.max / 1e6);
vp(2, " late arrivals: %" PRIu64 " of %" PRIu64 "\n", topen.behind, topen.txns);
vp(2, "\n");
phase("[6/10] limit probe (which ceiling binds?)\n");
int qs = C.phase_s / 3 < 10 ? 10 : C.phase_s / 3;
/* Trace the saturated 4k read cell. Reads are the direction that cannot be
absorbed, so anything periodic in this series belongs to the throttle. */
trace_t tr = {0, TRACE_MAX, xalloc(TRACE_MAX * sizeof(uint32_t)),
xalloc(TRACE_MAX * sizeof(uint32_t))};
cell_t r4 = run_cell_ex(PATH_DATA, 4096, 32, 0, 0, C.ws_bytes, qs, 0, true, 0, &tr);
cell_t r64 = run_cell_v(PATH_DATA, 64 * 1024, 32, 0, 0, C.ws_bytes, qs, 0, true);
cell_t w4 = run_cell(PATH_DATA, 4096, 32, 0, 1, C.ws_bytes, qs, 0);
cell_t w64 = run_cell(PATH_DATA, 64 * 1024, 32, 0, 1, C.ws_bytes, qs, 0);
vp(2, " rand read 4k qd32 %10.0f IOPS %8.1f MiB/s p99 %7.0f us\n",
r4.iops, r4.mibps, (double)r4.p99 / 1000);
vp(2, " rand read 64k qd32 %10.0f IOPS %8.1f MiB/s p99 %7.0f us\n",
r64.iops, r64.mibps, (double)r64.p99 / 1000);
vp(2, " rand write 4k qd32 %10.0f IOPS %8.1f MiB/s p99 %7.0f us\n",
w4.iops, w4.mibps, (double)w4.p99 / 1000);
vp(2, " rand write 64k qd32 %10.0f IOPS %8.1f MiB/s p99 %7.0f us\n",
w64.iops, w64.mibps, (double)w64.p99 / 1000);
vp(3, " Little's Law check %.2f %.2f %.2f %.2f (want ~1.00)\n",
r4.little_ratio, r64.little_ratio, w4.little_ratio, w64.little_ratio);
cell_t dcells[DISP_CELLS] = {r4, r64, w4, w64};
disp_t dp = dispersion(dcells);
vp(2, " dispersion p99/mean %.2f %.2f %.2f %.2f (pacer <%.1f, media >%.1f)\n",
dp.ratio[0], dp.ratio[1], dp.ratio[2], dp.ratio[3],
DISP_PACER, DISP_MEDIA);
tick_t tk = tick_analyze(&tr, r4.mibps, r4.mean_us);
free(tr.t_us); free(tr.lat_us);
if (tk.found)
vp(2, " refill quantum %.0f ms period (r=%.2f, %u samples)"
" -> bucket %.0f MiB\n"
" halves agree: %.0f / %.0f ms"
" (r=%.2f / %.2f)\n",
tk.period_ms, tk.strength, tk.samples, tk.depth_mib,
tk.half_ms[0], tk.half_ms[1], tk.half_r[0], tk.half_r[1]);
else if (tk.strength >= TICK_MIN_R && tk.reproduced && !tk.above_service)
vp(2, " refill quantum WITHDRAWN: %.0f ms is only %.0fx the mean"
" service time\n"
" (%.2f ms). Below %.0fx that is the service"
" process, not a timer.\n",
tk.period_ms, tk.mean_ratio, r4.mean_us / 1000.0,
(double)TICK_MIN_RATIO);
else if (tk.strength >= TICK_MIN_R && tk.period_ms > 0)
vp(2, " refill quantum WITHDRAWN: %.0f ms at r=%.2f over the"
" whole trace,\n"
" but the halves say %.0f ms (r=%.2f) and"
" %.0f ms (r=%.2f).\n"
" A timer keeps its period; this one"
" moved.\n",
tk.period_ms, tk.strength, tk.half_ms[0], tk.half_r[0],
tk.half_ms[1], tk.half_r[1]);
else
vp(2, " refill quantum none found (best r=%.2f at %.0f ms,"
" %u samples)\n", tk.strength, tk.period_ms, tk.samples);
vp(2, " integrity %" PRIu64 " mismatches in %" PRIu64 " verified reads\n",
r4.bad + r64.bad, r4.ops + r64.ops);
vp(2, "\n");
phase("[7/10] unthrottled service time (qd1, bucket never empties)\n");
svc_t sv = phase_service(qs, r4.iops);
vp(2, " 4k random read qd1 %10.0f IOPS = %.1f%% of the read ceiling\n",
sv.iops, sv.load_frac * 100);
vp(2, " p50 %6.0f us p99 %6.0f us p99.9 %6.0f us\n",
(double)sv.p50 / 1000, (double)sv.p99 / 1000, (double)sv.p999 / 1000);
vp(2, " -> media class: %s\n", sv.class_);
vp(2, "\n");
phase("[8/10] latency vs load (open loop against the read ceiling)\n");
int lsec = C.phase_s / 6 < 5 ? 5 : C.phase_s / 6;
latload_t ll = phase_latload(lsec, r4.iops);
vp(2, "\n");
phase("[9/10] concurrent ingest + query\n");
conc_t cc = phase_concurrent(C.phase_s, bw.mibps, r64.mibps);
vp(2, " write stream %8.1f MiB/s\n", cc.w_mibps);
vp(2, " read stream %8.1f MiB/s\n", cc.r_mibps);
vp(2, " combined %8.1f MiB/s %s\n", cc.total,
cc.separate ? "<- separate read/write budgets"
: "<- one shared budget");
vp(2, "\n");
phase("[10/10] small-object rate (metadata bound)\n");
meta_t mt = phase_meta();
vp(2, " create+write 4k %10.0f files/s\n", mt.create_s);
vp(2, " unlink %10.0f files/s\n", mt.unlink_s);
vp(2, "\n");
/* ---------------------------------------------------------- summary */
double sust = d.sustained_mibps;
/* Cross-check the drain against the phases that cannot be faked.
*
* The drain assumes a write that returns has been accepted by the volume at
* the volume's own rate. Where a host-side write-back buffer sits between
* the guest and the quota, that assumption fails and no amount of draining
* from inside the guest will recover it: O_DIRECT reaches the hypervisor,
* not through it. The symptom is a sustained write figure several times the
* bandwidth ceiling measured by the very same tool.
*
* Reads have no such escape - a read must be served - so the limit probe,
* the copy loop and the concurrent phase all measure the real quota. When
* the drain disagrees with them, they are right. */
double bwcap = r64.mibps > w64.mibps ? r64.mibps : w64.mibps;
bool w_buffered = bwcap > 0 && sust > bwcap * 1.5;
/* Corroborated write capacity: the slowest of three genuine write paths. */
double w_real = w64.mibps;
if (cp > 0 && cp < w_real) w_real = cp;
if (cc.w_mibps > 0 && cc.w_mibps < w_real) w_real = cc.w_mibps;
if (dsy.best > 0 && dsy.best < w_real) w_real = dsy.best;
vp(2, "==============================================================\n");
vp(2, "SUMMARY\n");
vp(2, "==============================================================\n");
if (w_buffered) {
vp(2, " WRITE PATH IS BUFFERED - the drain figure is not capacity.\n");
vp(2, " drain reported %10.1f MiB/s after writing %.0f GiB\n",
sust, (double)d.written / GIB);
vp(2, " bandwidth ceiling %10.1f MiB/s (64k random, same run)\n",
bwcap);
vp(2, " -> the drain is %.1fx the ceiling this tool measured minutes\n",
sust / bwcap);
vp(2, " later. Writes are being absorbed by a cache below the\n");
vp(2, " guest, so they are timed at buffer speed, not volume\n");
vp(2, " speed. O_DIRECT reaches the hypervisor, not through it,\n");
vp(2, " and a longer drain cannot fix that.\n");
vp(2, "\n");
vp(2, " SUSTAINED write %10.1f MiB/s <- use this for capacity\n",
w_real);
vp(2, " corroborated by paths the absorber cannot hold:\n");
vp(2, " O_DSYNC (unbufferable) %7.1f MiB/s\n", dsy.best);
vp(2, " rand write 64k %10.1f MiB/s\n", w64.mibps);
vp(2, " read+write copy %10.1f MiB/s\n", cp);
vp(2, " write while reading %10.1f MiB/s\n", cc.w_mibps);
vp(2, " first-write (alloc) %10.1f MiB/s (also buffered)\n",
d.fill_mibps);
vp(2, " burst credit not measurable from inside this guest\n");
} else {
vp(2, " first-write (alloc) %10.1f MiB/s\n", d.fill_mibps);
vp(2, " burst peak write %10.1f MiB/s\n", d.burst_peak_mibps);
vp(2, " SUSTAINED write %10.1f MiB/s <- use this for capacity\n", sust);
if (d.by_cliff)
vp(2, " accepted by a %.1fx step down at t=%.0fs, held flat for\n"
" %d s underneath it (not by the CV gate)\n",
d.cliff.post > 0 ? d.cliff.plateau / d.cliff.post : 0,
d.cliff.at_s, CLIFF_HOLD_S);
else if (d.settled)
vp(2, " accepted by CV < %.1f%% over %d samples (--drain-eps %.3f)\n",
C.ss_eps * 100, SS_WIN, C.ss_eps);
if (d.burst_peak_mibps > sust * 1.15 && d.settled) {
vp(2, " burst credit %10.2f GiB before the knee, at %.0fs\n",
d.burst_gib, d.settle_s);
vp(2, " -> a benchmark shorter than %.0fs on an idle volume would have\n",
d.settle_s);
vp(2, " reported up to %.0f MiB/s, overstating capacity by %.1fx.\n",
d.burst_peak_mibps, sust > 0 ? d.burst_peak_mibps / sust : 0);
} else if (!d.settled) {
vp(2, " burst credit never flattened within %ds after writing\n",
C.drain_max_s);
vp(2, " %.1f GiB - the sustained figure is an UPPER\n",
(double)d.written / GIB);
vp(2, " BOUND, not a capacity.\n");
vp(2, " -> if the rate rose or stayed high, the working set is probably\n");
vp(2, " fitting inside a cache below the guest (hypervisor page cache\n");
vp(2, " or array controller). O_DIRECT cannot reach it. Re-run with a\n");
vp(2, " larger --size and a longer --drain-max.\n");
vp(2, " -> if instead it hovered but never held still, the host is noisy,\n");
vp(2, " not still draining: a volume whose own rate swings more than\n");
vp(2, " %.1f%% can never satisfy this test. Raise --drain-eps, and quote\n",
C.ss_eps * 100);
vp(2, " the value alongside the result.\n");
} else {
vp(2, " burst credit none detected (flat from the start)\n");
}
}
/* Bracket, not a point estimate. O_DSYNC forces every write through the
absorber and can only be pessimistic if the stack flushes more than it
must; the buffered drain can only be optimistic. The truth is between. */
vp(2, " write bracket %10.1f .. %.1f MiB/s (O_DSYNC .. drain)\n",
dsy.best, sust > dsy.best ? sust : dsy.best);
/* The bypass is only demonstrated if O_DSYNC read the SUSTAINED rate while
* the absorber still had credit. Measured before the drain, at stage 1b,
* and compared against the sustained rate rather than against the buffered
* fill - the fill is itself absorbed, so it is not a baseline for anything.
*
* If the pre-drain figure comes back near burst speed, O_DSYNC did not
* reach the metered layer: the host returned the durability acknowledgement
* from volatile cache. That is worth more than the bypass would have been,
* because it says this guest cannot obtain a durable write at all, and
* every fsync-per-commit number measured on a charged reservoir is fiction. */
if (d.dsync_pre_mibps > 0 && sust > 0) {
double lift = d.dsync_pre_mibps / sust;
vp(2, " O_DSYNC before drain %10.1f MiB/s (sustained %.1f, buffered fill %.1f)\n",
d.dsync_pre_mibps, sust, d.fill_mibps);
if (!d.settled)
vp(2, " -> INCONCLUSIVE: the drain never reached steady state, so\n"
" there is no trustworthy sustained rate to compare against\n"
" and no confirmed reservoir. Re-run with a larger --size\n"
" and a longer --drain-max.\n");
/* Order matters: with no reservoir there is nothing to confirm, and
agreement between the two probes is then vacuous rather than good
news. Check for an absorber before crediting the bypass with one. */
else if (d.burst_gib < 1)
vp(2, " -> nothing to bypass: no burst credit was found, so this\n"
" says the volume has no absorber, not that a bypass\n"
" worked. Re-test after enough idle time to recharge.\n");
else if (lift <= 1.25)
vp(2, " -> bypass CONFIRMED: with %.0f GiB of credit still in the\n"
" reservoir, synchronous writes ran at the sustained rate.\n"
" The absorber cannot hold them, and the drain is optional.\n",
d.burst_gib);
else
vp(2, " -> BYPASS FAILED, and this is the more interesting result.\n"
" O_DSYNC ran %.1fx the sustained rate with the reservoir\n"
" charged, so the host acknowledged durability from a cache\n"
" the guest cannot reach. Consequences:\n"
" - O_DSYNC is NOT a valid capacity probe on this host and\n"
" the %.1f MiB/s figure below must not be quoted;\n"
" - the absorber must be filled, which costs O(depth);\n"
" - this guest cannot force a durable write, so any\n"
" commit rate measured on a charged reservoir is not\n"
" a commit rate.\n",
lift, dsy.best);
}
if (dsy.best > 0 && sust > 0) {
double gap = sust > dsy.best ? sust / dsy.best : dsy.best / sust;
if (gap <= 1.05)
vp(2, " -> the two agree within %.0f%%: the absorber is not hiding\n"
" anything and either number can be quoted.\n",
(gap - 1) * 100);
else
vp(2, " -> they differ by %.1fx. O_DSYNC is the defensible figure;\n"
" the drain saw %.0f GiB of absorber before the truth.\n",
gap, d.burst_gib);
}
vp(2, "\n");
vp(2, " BLOB INGESTION\n");
if (w_buffered && bw.mibps > bwcap * 1.5) {
vp(2, " raw sequential (%.0f MiB/s reported - buffered, see above)\n",
bw.mibps);
vp(2, " achievable ingestion %10.1f MiB/s = %.0f GiB/h\n",
w_real, w_real * 3600 / 1024);
} else {
vp(2, " raw sequential %10.1f MiB/s = %.0f GiB/h\n",
bw.mibps, bw.mibps * 3600 / 1024);
}
vp(2, " via read+write copy %10.1f MiB/s = %.0f GiB/h\n",
cp, cp * 3600 / 1024);
vp(2, " small objects %10.0f files/s (bandwidth is not the limit here)\n",
mt.create_s);
vp(2, " while serving reads %10.1f MiB/s write + %.1f MiB/s read = %.1f total\n",
cc.w_mibps, cc.r_mibps, cc.total);
if (cc.separate)
vp(2, " -> reads and writes have separate budgets: ingesting while queries\n"
" run costs little, and one-direction tests understate the volume.\n");
vp(2, "\n");
vp(2, " TRANSACTIONS (%d lookups + %d RMW + durable WAL append)\n",
C.tx_reads, C.tx_writes);
vp(2, " saturation %10.1f tx/s p99 %7.2f ms\n",
tsat.tps, tsat.p99 / 1e6);
vp(2, " at 70%% load (honest) %10.1f tx/s p99 %7.2f ms p99.9 %7.2f ms\n",
topen.tps, topen.p99 / 1e6, topen.p999 / 1e6);
if (topen.p99 > tsat.p99 * 1.5)
vp(2, " -> open-loop p99 is %.1fx the closed-loop figure: the saturation\n"
" number hides queueing that a real arrival stream would feel.\n",
(double)topen.p99 / (double)(tsat.p99 ? tsat.p99 : 1));
vp(2, "\n");
vp(2, " BINDING LIMIT\n");
vp(2, " read IOPS ceiling %10.0f (4k random)\n", r4.iops);
vp(2, " write IOPS ceiling %10.0f (4k random)\n", w4.iops);
vp(2, " bandwidth ceiling %10.1f MiB/s (64k random)\n", bwcap);
vp(2, " -> small-block work is capped by IOPS; anything above ~%.0fk\n",
r4.iops > 0 ? bwcap * 1024 / r4.iops : 0);
vp(2, " is capped by bandwidth.\n");
vp(2, "\n");
vp(2, " IS THE CEILING POLICY OR PHYSICS?\n");
vp(2, " p99/mean at saturation %.2f %.2f %.2f %.2f worst %.2f%s\n",
dp.ratio[0], dp.ratio[1], dp.ratio[2], dp.ratio[3], dp.worst,
dp.cpu_bound ? " (CPU-bound, withdrawn)" : "");
/* Three independent discriminators. They are reported together because any
one of them alone is circumstantial: dispersion is a tail statistic, the
load curve is a shape, the quantum is a period. Agreement between things
that could disagree is what makes the verdict worth quoting. */
vp(2, " p50 vs offered load %.2fx at 50%%, %.2fx at 100%%, %.0fx at 110%%"
" of ceiling\n",
ll.p50[0] ? (double)ll.p50[2] / (double)ll.p50[0] : 0,
ll.p50[0] ? (double)ll.p50[6] / (double)ll.p50[0] : 0,
ll.p50[0] ? (double)ll.p50[7] / (double)ll.p50[0] : 0);
vp(2, " shape verdict %s%s\n", ll.verdict,
!strcmp(ll.verdict, "policy")
? " (flat, then vertical near the ceiling)"
: !strcmp(ll.verdict, "physics")
? " (smooth convex rise from ~50%)"
: !strcmp(ll.verdict, "instrument-limited")
? " (the guest ran out of CPU first)" : "");
if (ll.cpu_bound)
vp(2, " NOT A STORAGE RESULT: this guest's %.0f vCPU were %.0f%% busy at\n"
" 90%% of the ceiling (excluding iowait, which\n"
" is the healthy case, and including steal).\n"
" The rising latency is the run queue, not the\n"
" volume. Re-run with more vCPU, or cap the\n"
" ceiling this phase paces against.\n",
ncpu_online(), ll.cpu_at_90 * 100);
if (ll.pool_bound)
vp(2, " NOT A STORAGE RESULT: at 90%% of the ceiling %.0f operations were\n"
" in flight against %d issuing threads (L/n =\n"
" %.1f). More work was outstanding than this\n"
" program can have had outstanding, so the\n"
" latency is backlog in its own schedule.\n"
" The offered rate exceeded what the volume\n"
" serves at queue depth %d - which is a fact\n"
" about the load generator, not the volume.\n",
ll.backlog_at_90 * ll.nthr[4], ll.nthr[4], ll.backlog_at_90,
ll.nthr[4]);
if (tk.found)
vp(2, " refill quantum %.0f ms, bucket ~%.0f MiB"
" (media has no period,\n"
" and both halves of the trace agree)\n",
tk.period_ms, tk.depth_mib);
else if (tk.strength >= TICK_MIN_R && tk.reproduced && !tk.above_service)
vp(2, " refill quantum withdrawn (%.0f ms is %.0fx mean service,"
" need %.0fx)\n", tk.period_ms, tk.mean_ratio, (double)TICK_MIN_RATIO);
else if (tk.strength >= TICK_MIN_R && tk.period_ms > 0)
vp(2, " refill quantum not reproducible (%.0f ms overall,"
" %.0f/%.0f ms by half)\n",
tk.period_ms, tk.half_ms[0], tk.half_ms[1]);
else
vp(2, " refill quantum not detected (best r=%.2f)\n", tk.strength);
vp(2, " service time at qd1 p50 %.0f us -> %s\n",
(double)sv.p50 / 1000, sv.class_);
if (!strcmp(dp.verdict, "policy")) {
vp(2, " -> POLICY. A deterministic pacer delivers near-constant service\n");
vp(2, " intervals; saturated media does not put p99 within %.0f%% of the\n",
(dp.worst - 1) * 100);
vp(2, " mean. The hardware beneath is strictly faster than %.1f MiB/s -\n",
bwcap);
vp(2, " this ceiling is what was sold, not what the media can do.\n");
} else if (!strcmp(dp.verdict, "physics")) {
vp(2, " -> PHYSICS. The tail is heavy enough to be queueing against real\n");
vp(2, " service time, so %.1f MiB/s is close to what the media gives.\n",
bwcap);
} else if (dp.cpu_bound || ll.cpu_bound || ll.pool_bound) {
/* Both discriminators read a heavy tail off latency, and both take it
on faith that the latency is the volume's. Two things break that
faith, and both break it the same way: a guest too small to issue
its own offered rate charges its run queue to the storage, and a
load generator asked for more than it can issue charges its own
backlog to the storage. The failure is one-directional - either one
manufactures evidence for physics and neither can manufacture
evidence for policy - so a physics reading from a saturated
instrument is not a weak result, it is no result. */
vp(2, " -> NO VERDICT. The instrument saturated before the storage did.\n");
if (dp.cpu_bound || ll.cpu_bound)
vp(2, " CPU: %.0f vCPU, %.0f%% busy in the saturation cells, %.0f%% at\n"
" 90%% of the read ceiling.\n",
ncpu_online(), dp.cpu_worst * 100, ll.cpu_at_90 * 100);
if (ll.pool_bound)
vp(2, " Schedule: L/n = %.1f at 90%% of the ceiling - more work\n"
" outstanding than %d threads can have outstanding.\n",
ll.backlog_at_90, ll.nthr[4]);
vp(2, " Both discriminators read a tail, and neither a starved run\n");
vp(2, " queue nor a backlogged schedule can be told from contended\n");
vp(2, " media by looking at latency. The ceilings above stand - they\n");
vp(2, " are rates, not latencies.\n");
} else {
vp(2, " -> AMBIGUOUS on dispersion alone; the load curve above is the\n");
vp(2, " tie-breaker, and it says %s.\n", ll.verdict);
}
if ((ll.cpu_bound || ll.pool_bound) && !dp.cpu_bound
&& strcmp(dp.verdict, "ambiguous"))
vp(2, " NOTE: this verdict rests on ONE discriminator. The load curve is\n"
" normally the stronger of the two - a shape across eight\n"
" operating points rather than a single tail - and it was\n"
" discarded here because the instrument saturated first.\n"
" A tail alone is circumstantial; treat this as provisional\n"
" until it is reproduced somewhere with cores to spare.\n");
else if (strcmp(dp.verdict, ll.verdict) && strcmp(ll.verdict, "not run")
&& !dp.cpu_bound && !ll.cpu_bound && !ll.pool_bound)
vp(2, " NOTE: the two discriminators disagree (dispersion says %s,\n"
" the load curve says %s). Trust the load curve - it is a\n"
" shape across eight operating points, not one tail.\n",
dp.verdict, ll.verdict);
if (ws_ram < 1.95)
vp(2, "\n WARNING: working set was only %.1fx RAM. Results may include\n"
" page-cache effects and should not be quoted as capacity.\n",
ws_ram);
/* Sizing the working set against GUEST RAM is the wrong test on a small
* guest. The drain phase has just demonstrated, from the outside, how much
* buffer the host is willing to hold: either the credit it absorbed, or -
* if it never flattened - everything written without reaching a knee. If
* the working set fits inside that, every read in this run could have been
* served from it, and the read ceilings and the qd1 media class are
* properties of the host's memory, not of any device.
*
* A small guest passes the 2x-RAM test comfortably while sitting on a host
* buffer orders of magnitude larger than its whole working set. */
{
double host_buf_gib = d.burst_gib;
if (!d.settled && (double)d.written / GIB > host_buf_gib)
host_buf_gib = (double)d.written / GIB;
double ws_gib = (double)C.ws_bytes / GIB;
if (host_buf_gib > ws_gib * 2) {
vp(2, "\n WARNING: this run demonstrated a host-side buffer of at least\n");
vp(2, " %.0f GiB, and the working set is %.1f GiB. The read\n",
host_buf_gib, ws_gib);
vp(2, " phases fit inside that buffer %.0fx over, so the read\n",
host_buf_gib / ws_gib);
vp(2, " ceilings and the qd1 service time (%.0f us -> \"%s\")\n",
(double)sv.p50 / 1000, sv.class_);
vp(2, " may be measuring host memory rather than storage.\n");
vp(2, " 2x guest RAM is not enough on a guest this small:\n");
vp(2, " size --size against the buffer above, not against RAM.\n");
}
}
/* ------------------------------------- how wrong can this run be?
*
* Every number above is true of the interval it was measured over. The
* question a buyer actually has is how far it can be from what the volume
* delivers over a planning window, and that has an answer that does not
* depend on trusting the seller: a test covering fraction tau of the window
* can be overstated by at most 1/tau, however deep the reservoir. */
double run_s = (double)(now_ns() - run_t0) / 1e9;
game_t g = game_model(run_s, budgeted ? P->window_s : 3600,
d.burst_peak_mibps, sust, sust);
if (g.valid) {
vp(2, "\n HOW WRONG CAN THIS RUN BE?\n");
vp(2, " %-22s%10.0f min over a %.0f h planning window (tau %.3f)\n",
"test length", run_s / 60, g.window_s / 3600.0, g.tau);
vp(2, " %-22s%10.1fx = the most ANY reservoir can hide from a\n"
" %-22s%10s test this long, whatever its depth\n",
"bound on overstatement", g.bound, "", "");
if (d.cliff.deep) {
vp(2, " %-22s%10.1fx burst %.0f -> sustained %.0f MiB/s\n",
"observed", g.beta, d.burst_peak_mibps, sust);
vp(2, " -> THE CLIFF WAS REACHED, so this run is not exposed to the\n");
vp(2, " bound: the sustained figure is the deliverable rate, not an\n");
vp(2, " interval average. The reservoir would have had to be\n");
vp(2, " %.0f GiB to hide it for the whole run, and %.0f GiB is what\n",
g.b_star_gib, d.burst_gib);
vp(2, " it held.\n");
} else if (d.cliff.shallow) {
/* The band where a single run cannot tell a knee from a bad minute.
Modelling is the honest output here, not a verdict. */
vp(2, " %-22s%10.0f%% %.0f -> %.0f MiB/s at t=%.0fs\n",
"observed fall", d.cliff.drop * 100, d.cliff.plateau,
d.cliff.post, d.cliff.at_s);
vp(2, " -> NOT ACCEPTED AS A CLIFF. A fall of %.0f%% is inside what a\n",
d.cliff.drop * 100);
vp(2, " noisy host does to itself: six identical runs on one host of\n");
vp(2, " this campaign spread 1.53x with no trend, which is a 35%% step\n");
vp(2, " between two runs of the same configuration. On a single run a\n");
vp(2, " fall this size cannot be told from a bad minute, so the run\n");
vp(2, " was NOT cut short and no knee is claimed.\n");
vp(2, " What can be said instead, from beta = %.2f as a LOWER bound\n",
g.beta);
vp(2, " (the reservoir may simply not have been reached):\n");
vp(2, " overstatement is between %.2fx and %.1fx\n", g.rho, g.bound);
vp(2, " a reservoir of %.0f GiB would have hidden the cliff for\n",
g.b_star_gib);
vp(2, " this whole run; %.0f GiB was observed\n", d.burst_gib);
if (g.need)
vp(2, " To close the bracket, re-run with the `%s` profile.\n",
g.need);
} else {
vp(2, " %-22s%10.1fx (no fall of %.0f%% or more occurred)\n",
"observed", g.beta, CLIFF_SHALLOW * 100);
vp(2, " -> the rate never stepped down, so either there is no reservoir\n");
vp(2, " or it is deeper than this test. Those two look identical from\n");
vp(2, " inside, and only the bound above separates the consequences:\n");
vp(2, " %.1f MiB/s cannot be overstating the %.0f h deliverable rate\n",
sust, g.window_s / 3600.0);
vp(2, " by more than %.1fx whichever it is. A reservoir would have\n",
g.bound);
vp(2, " needed %.0f GiB to stay hidden for this run.\n", g.b_star_gib);
}
if (!budgeted)
vp(2, " (no profile was in force, so the window above is assumed to be\n"
" 1 h. Run a named profile to state the window you plan against.)\n");
}
vp(2, "==============================================================\n");
/* ------------------------------------------------------- soak cycles */
soak_t sk; memset(&sk, 0, sizeof sk);
if (budgeted && P->soak_cycles > 0 && budget_left_s() > 300) {
vp(2, "\n[soak] %d re-measurement cycles over the remaining %.0f min%s\n",
P->soak_cycles, budget_left_s() / 60.0,
P->soak_idle_s ? ", with idle between them" : "");
sk = phase_soak(run_t0);
soak_stats(&sk, sust, d.burst_gib);
if (sk.n) {
vp(2, "\n==============================================================\n");
vp(2, "REPEATABILITY (%d cycles after the first probe)\n", sk.n);
vp(2, "==============================================================\n");
vp(2, " cycle t idle sustained credit read IOPS tx/s\n");
vp(2, " 1 %4.0fm - %8.1f %7.1f %9.0f %6.0f\n",
0.0, sust, d.burst_gib, r4.iops, tsat.tps);
for (int i = 0; i < sk.n; i++)
vp(2, " %4d %4.0fm %5.0fm %8.1f %7.1f %9.0f %6.0f\n",
i + 2, sk.at_min[i], sk.idle_s[i] / 60.0, sk.sust[i],
sk.credit_gib[i], sk.r4_iops[i], sk.tps[i]);
vp(2, "\n sustained write %.1f .. %.1f MiB/s spread %.2fx,"
" CV %.1f%%\n", sk.sust_lo, sk.sust_hi, sk.sust_spread,
sk.sust_cv * 100);
if (sk.sust_spread > 1.25)
vp(2, " -> QUOTE THE RANGE, NOT A NUMBER. This volume's own rate moves\n"
" by %.2fx between identical measurements, so any single run -\n"
" including the one above - is a sample, not a capacity. The\n"
" spread is the result here.\n", sk.sust_spread);
else
vp(2, " -> the sustained figure is repeatable to %.1f%%, so the single\n"
" number above can be quoted as a capacity.\n",
sk.sust_cv * 100);
if (P->soak_idle_s > 0) {
vp(2, "\n burst recurrence first probe %.1f GiB, best later"
" cycle %.1f GiB\n", d.burst_gib, sk.credit_max);
if (d.burst_gib < 1)
vp(2, " -> undefined: the first probe found no reservoir, so there\n"
" was nothing to come back. Not a measurement of refill.\n");
else if (sk.recurrent)
vp(2, " -> BURST IS RECURRENT on this cadence. %.0f min of idle\n"
" restored %.0f%% of the original credit, so a workload that\n"
" bursts less often than that gets the burst rate each\n"
" time and the sustained rate is the wrong planning number.\n",
P->soak_idle_s / 60.0,
sk.credit_max / d.burst_gib * 100);
else
vp(2, " -> BURST IS NOT RECURRENT on this cadence: %.0f min of idle\n"
" returned %.1f GiB against %.1f GiB originally. Plan on the\n"
" sustained rate; the burst is close to a one-off.\n",
P->soak_idle_s / 60.0, sk.credit_max, d.burst_gib);
}
{
/* Re-state the bound over everything measured, counting only the
time the volume was actually loaded. */
double cov = run_s + sk.loaded_s;
vp(2, "\n coverage after soak %.0f min under load of a %.0f h"
" window (tau %.3f)\n"
" bound on overstatement %.1fx (was %.1fx after the first"
" probe alone)\n",
cov / 60, P->window_s / 3600.0, cov / P->window_s,
P->window_s / cov, g.bound);
}
vp(2, "==============================================================\n");
}
}
/* ------------------------------------------------- the -v report
*
* Everything above is optional. This is not: it is what the run was for,
* and it is built to be read in one pass and pasted into a ticket. Three
* rates people already know how to compare, then the three things this
* program exists to say that a rate table cannot - what the capacity is
* once burst is gone, whether the ceiling is negotiable, and how far the
* whole run could still be from the truth.
*
* Anything withdrawn or warned about appears HERE, at the lowest tier. A
* caveat that only shows up at -vv is a caveat that will not be read, and
* a true number quoted without its caveat is the exact failure this
* campaign is about. */
{
char b4r[16], b4w[16], b64r[16], b64w[16];
fmt_si(b4r, sizeof b4r, r4.iops); fmt_si(b4w, sizeof b4w, w4.iops);
fmt_si(b64r, sizeof b64r, r64.iops); fmt_si(b64w, sizeof b64w, w64.iops);
bool no_verdict = dp.cpu_bound || ll.cpu_bound || ll.pool_bound;
bool one_disc = no_verdict && strcmp(dp.verdict, "ambiguous") && !dp.cpu_bound;
const char *ceiling = no_verdict && !one_disc ? "NO VERDICT"
: !strcmp(dp.verdict, "policy") ? "POLICY"
: !strcmp(dp.verdict, "physics") ? "physics"
: "ambiguous";
char tick[40];
if (tk.found) snprintf(tick, sizeof tick, "tick %.0f ms", tk.period_ms);
else if (tk.strength >= TICK_MIN_R) snprintf(tick, sizeof tick, "tick withdrawn");
else snprintf(tick, sizeof tick, "no tick");
vp(1, "\niolite %s \"%s\" %s %s ws %.1f GiB (%.1fx RAM) %s %.0fm\n",
IOLITE_VERSION, IOLITE_CODENAME, fstype, fsmnt,
(double)C.ws_bytes / GIB, ws_ram,
budgeted ? P->name : "unbudgeted", run_s / 60);
vp(1, "--------------------------------------------------------------------\n");
vp(1, " Block | 4k random (IOPS) | 64k random (IOPS)\n");
vp(1, " Read | %9.1f MiB/s (%7s) | %9.1f MiB/s (%7s)\n",
r4.mibps, b4r, r64.mibps, b64r);
vp(1, " Write | %9.1f MiB/s (%7s) | %9.1f MiB/s (%7s)\n",
w4.mibps, b4w, w64.mibps, b64w);
vp(1, " 1M seq | write %9.1f MiB/s | copy R+W %9.1f MiB/s\n",
bw.mibps, cp);
vp(1, " Txn | %6.0f tx/s p50 %.2f ms p99 %.2f ms"
" (%dr+%dw+fsync x%d)\n",
tsat.tps, tsat.p50 / 1e6, tsat.p99 / 1e6, C.tx_reads, C.tx_writes,
C.clients);
vp(1, "--------------------------------------------------------------------\n");
vp(1, " SUSTAINED | %.1f MiB/s%s", w_buffered ? w_real : sust,
w_buffered ? " (corroborated; drain figure is buffered)\n" : "");
if (!w_buffered) {
if (d.burst_gib >= 1 && d.burst_peak_mibps > sust * 1.15)
vp(1, " after %.0f GiB of burst at %.0f MiB/s (%.1fx)\n",
d.burst_gib, d.burst_peak_mibps, d.burst_peak_mibps / sust);
else
vp(1, " (no burst credit detected)\n");
}
vp(1, " CEILING | %-10s disp %.2f | curve %s | %s\n",
ceiling, dp.worst, ll.verdict, tick);
if (g.valid) {
if (d.cliff.deep)
vp(1, " EXPOSURE | <=%.1fx over %.0fh cliff reached at t=%.0fs,"
" B* was %.0f GiB\n",
g.bound, g.window_s / 3600.0, d.cliff.at_s, g.b_star_gib);
else if (d.cliff.shallow)
vp(1, " EXPOSURE | %.2f-%.1fx over %.0fh %.0f%% fall NOT a cliff;"
" %.0f GiB hides one\n",
g.rho, g.bound, g.window_s / 3600.0, d.cliff.drop * 100,
g.b_star_gib);
else
vp(1, " EXPOSURE | <=%.1fx over %.0fh no cliff seen;"
" %.0f GiB would hide one\n",
g.bound, g.window_s / 3600.0, g.b_star_gib);
}
if (sk.n)
vp(1, " REPEAT | %.0f-%.0f MiB/s over %d cycles (%.2fx spread)%s\n",
sk.sust_lo, sk.sust_hi, sk.n + 1, sk.sust_spread,
P->soak_idle_s ? (sk.recurrent ? ", burst recurs" : ", burst does not recur") : "");
/* Warnings. Never demoted by tier, never softened. */
if (no_verdict && !one_disc)
vp(1, " ! NO VERDICT: the instrument saturated before the storage did,"
" on %.0f vCPU.\n"
" %s The rates above stand; the verdict does not.\n",
ncpu_online(),
dp.cpu_bound && !ll.cpu_bound && !ll.pool_bound ? "CPU was the limit in"
" the saturation cells." :
ll.pool_bound && !ll.cpu_bound ? "The load generator's own schedule"
" backlogged (L/n > 1.5)." :
"CPU and/or schedule backlog bound it at 90% of the ceiling.");
else if (one_disc)
vp(1, " ! ONE DISCRIMINATOR ONLY: the load curve was withdrawn"
" (instrument-limited),\n"
" so \"%s\" rests on the tail alone. Provisional.\n", dp.verdict);
if (w_buffered)
vp(1, " ! WRITE PATH BUFFERED: the drain read %.1fx the bandwidth"
" ceiling measured\n"
" minutes later. Quote %.1f MiB/s, not %.1f.\n",
sust / bwcap, w_real, sust);
if (!d.settled)
vp(1, " ! SUSTAINED IS AN UPPER BOUND: the drain never flattened"
" within %ds after\n %.0f GiB. Use a longer profile.\n",
C.drain_max_s, (double)d.written / GIB);
if (d.cliff.withdrawn)
vp(1, " ! %d MOMENTARY DIP%s (deepest %.0f%% at t=%.0fs) DID NOT HOLD"
" for %ds and\n %s\n",
d.cliff.withdrawn, d.cliff.withdrawn > 1 ? "S" : "",
d.cliff.dip_drop * 100, d.cliff.dip_at_s, CLIFF_HOLD_S,
d.cliff.locked
? "the drain stopped testing for a step. This host is noisy,"
" not stepped."
: "were not counted as a cliff. Noise, or a real stall that"
" recovered.");
if (d.budget_cut)
vp(1, " ! DRAIN CUT BY THE BUDGET, not by the data.\n");
if (squeezed)
vp(1, " ! PHASES RUN SHORT: the drain took most of the budget.\n");
if (ws_ram < 1.95)
vp(1, " ! WORKING SET %.1fx RAM: page-cache effects possible.\n", ws_ram);
{
double hb = d.burst_gib;
if (!d.settled && (double)d.written / GIB > hb) hb = (double)d.written / GIB;
if (hb > (double)C.ws_bytes / GIB * 2)
vp(1, " ! HOST BUFFER >=%.0f GiB vs a %.1f GiB working set:"
" the read figures\n may be host memory.\n",
hb, (double)C.ws_bytes / GIB);
}
if (r4.bad + r64.bad)
vp(1, " ! %" PRIu64 " INTEGRITY MISMATCHES in %" PRIu64 " verified reads.\n",
r4.bad + r64.bad, r4.ops + r64.ops);
if (V == 1)
vp(1, " (-vv for the numbers behind each verdict, -vvv for the"
" instrument's own\n state, -vvvv for the raw series as CSV)\n");
}
if (C.json) {
/* The JSON is the -v tier and nothing else, deliberately. It is an
* ingest record for a database that will hold many runs from many
* hosts over many months, and such a schema is only useful if it is
* small, flat and stable. Everything below is a scalar with a fixed
* name; there are no per-load-point arrays, because the day one is
* added is the day the table has to change to accept it.
*
* The deep tiers are text on purpose. -vvvv emits the curves as CSV
* lines, which is the right shape for a plot and the wrong shape for
* a row. If a curve needs to be archived, archive the -vvvv output
* next to the row rather than growing the row. */
FILE *f = fopen(C.json_path, "w");
if (!f) die("open %s: %s", C.json_path, strerror(errno));
bool no_verdict = dp.cpu_bound || ll.cpu_bound || ll.pool_bound;
fprintf(f,
"{\n"
" \"schema\": 1, \"iolite_version\": \"%s\", \"codename\": \"%s\",\n"
" \"epoch\": %lld, \"nonce\": %" PRIu64 ",\n"
" \"host\": \"%s\", \"kernel\": \"%s\", \"dir\": \"%s\", \"fs\": \"%s\","
" \"mount\": \"%s\",\n"
" \"ncpu\": %ld, \"ram_bytes\": %" PRIu64 ", \"ws_bytes\": %" PRIu64 ","
" \"ws_over_ram\": %.3f,\n"
" \"profile\": \"%s\", \"budget_s\": %d, \"window_s\": %d, \"run_s\": %.0f,\n"
"\n"
" \"read_4k_mibps\": %.2f, \"read_4k_iops\": %.0f,\n"
" \"write_4k_mibps\": %.2f, \"write_4k_iops\": %.0f,\n"
" \"read_64k_mibps\": %.2f, \"read_64k_iops\": %.0f,\n"
" \"write_64k_mibps\": %.2f, \"write_64k_iops\": %.0f,\n"
" \"seq_write_mibps\": %.2f, \"copy_mibps\": %.2f,\n"
" \"tx_per_s\": %.1f, \"tx_p50_us\": %.1f, \"tx_p99_us\": %.1f,\n"
"\n"
" \"sustained_mibps\": %.2f, \"burst_peak_mibps\": %.2f,"
" \"burst_credit_gib\": %.2f,\n"
" \"accepted_by\": \"%s\", \"settled\": %s, \"write_path_buffered\": %s,\n"
" \"qd1_p50_us\": %.1f, \"media_class\": \"%s\",\n"
"\n"
" \"ceiling\": \"%s\", \"dispersion_worst\": %.3f, \"curve_verdict\": \"%s\",\n"
" \"tick_ms\": %.1f, \"tick_found\": %s,\n"
" \"instrument_saturated\": %s, \"cpu_at_90\": %.3f, \"backlog_at_90\": %.2f,\n"
"\n"
" \"tau\": %.4f, \"beta\": %.3f, \"rho\": %.3f, \"bound\": %.3f,"
" \"b_star_gib\": %.1f,\n"
" \"cliff\": \"%s\", \"cliff_drop\": %.3f, \"cliff_at_s\": %.0f,\n"
" \"dips_withdrawn\": %d, \"deepest_dip\": %.3f, \"cliff_test_locked\": %s,\n"
"\n"
" \"soak_cycles\": %d, \"soak_sust_lo_mibps\": %.2f,"
" \"soak_sust_hi_mibps\": %.2f,\n"
" \"soak_spread\": %.3f, \"burst_recurrent\": %s,\n"
" \"integrity_mismatches\": %" PRIu64 ", \"integrity_reads\": %" PRIu64 "\n"
"}\n",
IOLITE_VERSION, IOLITE_CODENAME,
(long long)time(NULL), C.nonce,
un.nodename, un.release, C.dir, fstype, fsmnt,
sysconf(_SC_NPROCESSORS_ONLN), ram, C.ws_bytes, ws_ram,
budgeted ? P->name : "none", budgeted ? P->budget_s : 0,
budgeted ? P->window_s : 0, run_s,
r4.mibps, r4.iops, w4.mibps, w4.iops,
r64.mibps, r64.iops, w64.mibps, w64.iops,
bw.mibps, cp,
tsat.tps, tsat.p50 / 1000.0, tsat.p99 / 1000.0,
w_buffered ? w_real : sust, d.burst_peak_mibps, d.burst_gib,
d.by_cliff ? "cliff-step" : (d.settled ? "cv-gate" : "none"),
d.settled ? "true" : "false", w_buffered ? "true" : "false",
sv.p50 / 1000.0, sv.class_,
no_verdict ? "no-verdict" : dp.verdict, dp.worst, ll.verdict,
tk.found ? tk.period_ms : 0.0, tk.found ? "true" : "false",
no_verdict ? "true" : "false", ll.cpu_at_90, ll.backlog_at_90,
g.tau, g.beta, g.rho, g.bound, g.b_star_gib,
d.cliff.deep ? "deep" : (d.cliff.shallow ? "shallow" : "none"),
d.cliff.drop, d.cliff.at_s,
d.cliff.withdrawn, d.cliff.dip_drop,
d.cliff.locked ? "true" : "false",
sk.n, sk.sust_lo, sk.sust_hi, sk.sust_spread,
sk.recurrent ? "true" : "false",
r4.bad + r64.bad, r4.ops + r64.ops);
fclose(f);
vp(2, "wrote %s\n", C.json_path);
}
if (!C.keep) { unlink(PATH_DATA); unlink(PATH_WAL); unlink(PATH_BLOB); }
else vp(2, "kept working files in %s\n", C.dir);
return 0;
}