[RFC PATCH 6/8] selftests/bpf: add iocost cost model test

From: Tao Cui

Date: Tue Sep 08 2026 - 06:38:20 EST


From: Tao Cui <cuitao@xxxxxxxxxx>

Add an example cost model implementing iocost_model_ops with the
builtin linear HDD formula at double cost, and a runner which
registers it as a struct_ops, switches a device to ctrl=bpf through
io.cost.model and verifies the readback, restoring ctrl=auto
afterwards. The runner first checks the negative case: writing
ctrl=bpf while no model is registered is rejected. Under the same
workload the doubled model should charge exactly twice the builtin
model, which makes it a convenient way to verify that accounting
goes through the BPF path; the accounting itself needs real IO and
is covered by the kernel-side validation described in the cover
letter. The runner is skipped unless $IOCOST_TEST_DEV gives a
major:minor of a device with iocost enabled.

Signed-off-by: Tao Cui <cuitao@xxxxxxxxxx>
---
.../selftests/bpf/prog_tests/iocost_model.c | 124 ++++++++++++++++++
.../selftests/bpf/progs/iocost_model.c | 91 +++++++++++++
2 files changed, 215 insertions(+)
create mode 100644 tools/testing/selftests/bpf/prog_tests/iocost_model.c
create mode 100644 tools/testing/selftests/bpf/progs/iocost_model.c

diff --git a/tools/testing/selftests/bpf/prog_tests/iocost_model.c b/tools/testing/selftests/bpf/prog_tests/iocost_model.c
new file mode 100644
index 0000000000000..386026241d5b4
--- /dev/null
+++ b/tools/testing/selftests/bpf/prog_tests/iocost_model.c
@@ -0,0 +1,124 @@
+// SPDX-License-Identifier: GPL-2.0
+/* Copyright (c) 2026 Tao Cui */
+#include <test_progs.h>
+#include "iocost_model.skel.h"
+
+/*
+ * Switch a device to ctrl=bpf through io.cost.model and verify the
+ * readback, restoring ctrl=auto afterwards. Returns 0 on success.
+ * Shared by the example-model tests below.
+ */
+static int switch_to_bpf(const char *dev)
+{
+ char path[] = "/sys/fs/cgroup/io.cost.model";
+ char word[256], line[256], buf[300];
+ FILE *fp;
+ int err, found = 0;
+
+ fp = fopen(path, "r+");
+ if (!fp) {
+ TH_LOG("open %s: %s", path, strerror(errno));
+ return -1;
+ }
+
+ while (fgets(line, sizeof(line), fp)) {
+ if (sscanf(line, "%255s", word) == 1 && !strcmp(word, dev)) {
+ found = 1;
+ break;
+ }
+ }
+ if (!found) {
+ TH_LOG("%s has no iocost line", dev);
+ fclose(fp);
+ return -1;
+ }
+
+ snprintf(buf, sizeof(buf), "%s ctrl=bpf", dev);
+ err = fprintf(fp, "%s\n", buf);
+ if (err <= 0) {
+ TH_LOG("write ctrl=bpf failed");
+ fclose(fp);
+ return -1;
+ }
+ fflush(fp);
+
+ rewind(fp);
+ found = 0;
+ while (fgets(line, sizeof(line), fp)) {
+ if (sscanf(line, "%255s", word) == 1 && !strcmp(word, dev)) {
+ found = strstr(line, "ctrl=bpf") != NULL;
+ break;
+ }
+ }
+ if (!found)
+ TH_LOG("readback does not show ctrl=bpf");
+
+ fprintf(fp, "%s ctrl=auto\n", dev);
+ fclose(fp);
+ return found ? 0 : -1;
+}
+
+static int dev_has_iocost(void)
+{
+ FILE *fp = fopen("/sys/fs/cgroup/io.cost.qos", "r");
+
+ if (fp)
+ fclose(fp);
+ return fp != NULL;
+}
+
+/*
+ * Register the example cost model and switch a device to ctrl=bpf.
+ * IO accounting itself is not checked here; it needs a device doing
+ * real IO under iocost and is covered by the kernel-side validation
+ * described in the cover letter.
+ *
+ * Requires root, cgroup v2 and a device with iocost support. The
+ * device must be given as major:minor in $IOCOST_TEST_DEV, otherwise
+ * the test is skipped.
+ */
+void serial_test_iocost_model(void)
+{
+ struct iocost_model *skel;
+ char word[256], *dev;
+ int err;
+
+ dev = getenv("IOCOST_TEST_DEV");
+ if (!dev || geteuid() != 0) {
+ test__skip();
+ return;
+ }
+ if (!ASSERT_TRUE(dev_has_iocost(), "iocost_mounted"))
+ return;
+
+ /* negative: ctrl=bpf must be rejected while no model is
+ * registered, so a typo cannot silently disable cost model
+ * updates */
+ if (sscanf(dev, "%255s", word) != 1)
+ return;
+ {
+ char path[] = "/sys/fs/cgroup/io.cost.model";
+ char buf[300];
+ FILE *fp = fopen(path, "w");
+
+ snprintf(buf, sizeof(buf), "%s ctrl=bpf\n", word);
+ err = 0;
+ if (fp) {
+ err = fprintf(fp, "%s", buf) <= 0;
+ fclose(fp);
+ }
+ ASSERT_TRUE(err, "ctrl_bpf_without_model_rejected");
+ }
+
+ skel = iocost_model__open_and_load();
+ if (!ASSERT_OK_PTR(skel, "skel_open_load"))
+ return;
+
+ /* attaching the struct_ops registers the model; a second
+ * registration of the same ops would fail with EBUSY */
+ err = iocost_model__attach(skel);
+ if (ASSERT_OK(err, "attach"))
+ ASSERT_OK(switch_to_bpf(dev), "switch_and_readback");
+
+ iocost_model__destroy(skel);
+}
diff --git a/tools/testing/selftests/bpf/progs/iocost_model.c b/tools/testing/selftests/bpf/progs/iocost_model.c
new file mode 100644
index 0000000000000..70f7987f8eb6a
--- /dev/null
+++ b/tools/testing/selftests/bpf/progs/iocost_model.c
@@ -0,0 +1,91 @@
+// SPDX-License-Identifier: GPL-2.0
+/* Copyright (c) 2026 Tao Cui */
+/*
+ * Example iocost cost model: the builtin linear HDD formula with all
+ * costs doubled.
+ *
+ * The constants mirror what calc_lcoefs() derives from the AUTOP_HDD
+ * defaults (rbps=174019176 rseqiops=41708 rrandiops=370, w-side
+ * analog) in vtime units where 1s == 2^37, expressed with the same
+ * round-up divisions so they cannot drift from the kernel, so a
+ * device switched to ctrl=bpf with this model registered charges
+ * exactly twice the builtin model under the same workload, which
+ * makes it a convenient way to verify that accounting goes through
+ * the BPF path.
+ */
+#include "vmlinux.h"
+#include <bpf/bpf_helpers.h>
+#include <bpf/bpf_tracing.h>
+
+/* VTIME_PER_SEC comes from vmlinux.h (a BTF enum constant) */
+#define IOC_PAGE_SIZE 4096
+#define IOC_SECT_TO_PAGE_SHIFT 3 /* 512B sectors to 4kB pages */
+#define LCOEF_RANDIO_PAGES 4096 /* 16MB seek threshold */
+#define IOCOST_COST_F_MERGE (1ULL << 0) /* not in BTF: a plain macro */
+
+/* DIV64_U64_ROUND_UP / DIV_ROUND_UP_ULL equivalents, folded at
+ * compile time */
+#define RU(x, y) ((x) / (y) + (((x) % (y)) ? 1 : 0))
+
+#define RBPS 174019176ULL
+#define RSEQIOPS 41708ULL
+#define RRANDIOPS 370ULL
+#define WBPS 178075866ULL
+#define WSEQIOPS 42705ULL
+#define WRANDIOPS 378ULL
+
+#define RPAGE (RU(VTIME_PER_SEC, RU(RBPS, IOC_PAGE_SIZE)))
+#define RSEQIO (RU(VTIME_PER_SEC, RSEQIOPS) - RPAGE)
+#define RRANDIO (RU(VTIME_PER_SEC, RRANDIOPS) - RPAGE)
+#define WPAGE (RU(VTIME_PER_SEC, RU(WBPS, IOC_PAGE_SIZE)))
+#define WSEQIO (RU(VTIME_PER_SEC, WSEQIOPS) - WPAGE)
+#define WRANDIO (RU(VTIME_PER_SEC, WRANDIOPS) - WPAGE)
+
+static __always_inline u64 builtin_cost(u64 op, u64 nbytes, u64 sector,
+ u64 cursor, u64 flags)
+{
+ u64 pages, seek_pages = 0, base, coef_page;
+
+ if (!nbytes)
+ return 0;
+
+ switch (op) {
+ case REQ_OP_READ:
+ base = RSEQIO;
+ coef_page = RPAGE;
+ break;
+ case REQ_OP_WRITE:
+ base = WSEQIO;
+ coef_page = WPAGE;
+ break;
+ default:
+ return 0;
+ }
+
+ if (cursor) {
+ seek_pages = sector > cursor ? sector - cursor
+ : cursor - sector;
+ seek_pages >>= IOC_SECT_TO_PAGE_SHIFT;
+ if (seek_pages > LCOEF_RANDIO_PAGES)
+ base = (op == REQ_OP_READ) ? RRANDIO : WRANDIO;
+ }
+
+ pages = RU(nbytes, IOC_PAGE_SIZE);
+ if (flags & IOCOST_COST_F_MERGE)
+ base = 0;
+ return base + pages * coef_page;
+}
+
+SEC("struct_ops")
+u64 BPF_PROG(iocost_2x_calc_cost, u64 op, u64 nbytes, u64 sector,
+ u64 cursor, u64 iocg_id, u64 flags)
+{
+ return 2 * builtin_cost(op, nbytes, sector, cursor, flags);
+}
+
+SEC(".struct_ops")
+struct iocost_model_ops iocost_2x = {
+ .calc_cost = (void *)iocost_2x_calc_cost,
+};
+
+char LICENSE[] SEC("license") = "GPL";
--
2.43.0