This commit is contained in:
Kyle Bader 2026-08-01 17:59:29 -07:00 committed by GitHub
commit d60a300489
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 2490 additions and 29 deletions

View File

@ -7,7 +7,15 @@
* RADOS: PG autoscaler allows overlapping roots. Each root receives a PG target based
on its OSDs, with OSDs shared across multiple roots contributing proportionally
less to each root's allocation.
* RADOS: A new experimental OSD op queue, ``bfq``, is available via
``osd_op_queue = bfq``. Modeled on the Linux BFQ elevator as exposed through
the cgroups v2 io controller, it shares OSD throughput purely proportionally
between a background group and client workload-type streams (block, object,
file -- classified by pool application metadata), using weighted budget fair
queueing with no device capacity estimate. Weights and budgets are tunable at
runtime via the ``osd_bfq_*`` options.
* CephFS: CephFS snapshots metadata is now mutable. It is now possible to add,
update and remove existing key-value pairs that are part of a snapshot
metadata via libcephfs API ceph_do_snap_md_op().

View File

@ -0,0 +1,123 @@
=================
BFQ Op Scheduler
=================
The ``bfq`` op queue (``osd_op_queue = bfq``, experimental) is modeled on
the Linux BFQ (Budget Fair Queueing) I/O scheduler as exposed through the
cgroups v2 io controller. It is an alternative to ``mclock_scheduler``
and ``wpq`` built around a different first principle: **purely
proportional sharing**. Where mclock's reservation and limit tags are
denominated in absolute IOPS -- which is why it needs the boot-time OSD
bench and the ``osd_mclock_max_capacity_iops_*`` estimates -- bfq
distributes whatever throughput the OSD actually delivers according to
weights, and needs no estimate of device capacity at all.
Hierarchy
=========
Service (in scaled bytes) is shared by a two-level B-WF2Q+ hierarchy of
weighted groups, in the spirit of ``io.bfq.weight``::
root
|-- client group (osd_bfq_client_group_weight)
| |-- block (rbd pools) (osd_bfq_client_block_weight)
| |-- object (rgw pools) (osd_bfq_client_object_weight)
| |-- file (cephfs pools) (osd_bfq_client_file_weight)
| `-- other (osd_bfq_client_other_weight)
`-- background group (osd_bfq_background_group_weight)
|-- recovery (osd_bfq_background_recovery_weight)
`-- best_effort (osd_bfq_background_best_effort_weight)
Client ops are classified into workload streams by the application
metadata of their pool (``ceph osd pool application enable``): pools
tagged ``rbd`` map to the block stream, ``rgw`` to object, ``cephfs`` to
file; untagged or ambiguously tagged pools fall into the other stream.
The per-shard pool map refreshes on every OSDMap the shard consumes, so
tag changes take effect without a restart. Background ops are split by
their existing scheduler class (``background_recovery`` vs
``background_best_effort``).
Weights follow the cgroups v2 convention: 1..1000, default 100, purely
relative, adjustable at runtime.
Algorithm
=========
Each backlogged leaf stream is granted a byte *budget*
(``osd_bfq_max_budget`` caps it) and is served exclusively until the
budget is exhausted, the stream empties, or the round exceeds
``osd_bfq_budget_timeout``. Streams and groups are scheduled by WF2Q+:
an entity is *eligible* once its virtual start tag is at or behind the
tree's virtual time, and the eligible entity with the smallest virtual
finish tag runs next. Finish tags are computed over the *assigned*
budget at activation and **back-shifted** to the *consumed* service at
expiration, so unused budget carries no penalty. The next budget adapts
to observed demand: it doubles (up to the cap) when a round ends by
exhaustion and shrinks toward actual usage when the stream empties.
Deliberate exclusions relative to the kernel implementation:
* no weight raising (the interactive/soft-realtime low-latency
heuristics), and
* no device idling/anticipation -- an emptied stream expires
immediately, and ``dequeue()`` never asks the shard worker to wait.
Accounting model
================
Service is charged at *dequeue* (dispatch to a shard worker), not at
device completion; the kernel charges at completion and knows the device
is exclusively occupied, while the OSD does not. Fairness is therefore
over dispatched scaled bytes -- the same accounting model mclock already
uses -- and budget exclusivity means exclusive *selection*, not
exclusive device occupancy, when ``osd_op_num_threads_per_shard > 1``.
Each item is charged ``max(item cost, osd_bfq_min_cost)`` bytes, the
floor standing in for fixed per-IO overhead.
As with mclock, ops of the ``immediate`` class (peering and similar) and
ops at or above ``osd_op_queue_cut_off`` bypass the hierarchy through a
strict priority queue, and requeued ops (``enqueue_front``) re-enter via
that queue rather than through the fair hierarchy.
Each OSD shard runs an independent scheduler instance, so weights
describe per-shard shares; fairness across shards is statistical, via
PG-to-shard hashing (again matching mclock).
Observability
=============
``ceph daemon osd.N dump_op_pq_state`` dumps, per shard, the virtual
times and tags of both tree levels, per-stream queue depths and next
budgets, and the strict queue.
Microbenchmarks
===============
``ceph_bench_op_scheduler`` (built from ``src/test/osd``) runs identical
synthetic workloads through wpq, mclock, and bfq in-process, pacing
dequeues at a simulated device rate so saturation dynamics are
meaningful; mclock's capacity model is calibrated to the same rate. The
scenarios: a backlogged victim vs an aggressor fanning out over 1..16
client sessions (share isolation), a paced victim under a saturating
aggressor (latency isolation), and client-vs-recovery (class weights).
``unittest_scheduler_isolation`` asserts loose bounds on the bfq
isolation properties; because the harness paces in wall time it skips
itself unless ``CEPH_TEST_SCHEDULER_ISOLATION`` is set in the
environment (shared CI executors are too noisy for it).
The headline result: with an rbd-pool victim and an rgw-pool aggressor,
wpq and mclock dilute the victim's share as 1/(sessions+1) -- wpq
round-robins owners, and mclock maps *all* client ops to a single
dmclock client -- while bfq holds the configured weight ratio flat
regardless of session count, and bounds a paced victim's p99 queueing
delay by budget rotation rather than by the aggressor's backlog depth.
Follow-up candidates
====================
* Auto-tuning ``osd_bfq_max_budget`` from the observed per-shard service
rate (the analogue of the kernel's peak-rate estimator -- measured
online, never configured).
* Perf counters mirroring the mclock ones.
* Completion-based charging, which would recover more of BFQ's
device-time fairness but requires feedback from the op pipeline.

View File

@ -0,0 +1,5 @@
overrides:
ceph:
conf:
osd:
osd op queue: bfq

View File

@ -1031,11 +1031,15 @@ options:
desc: Do not store full-object checksums if the backend (bluestore) does its own
checksums. Only usable with all BlueStore OSDs.
default: false
# Weighted Priority Queue (wpq), mClock Scheduler (mclock_scheduler: default)
# or debug_random. "mclock_scheduler" is based on the mClock/dmClock
# Weighted Priority Queue (wpq), mClock Scheduler (mclock_scheduler: default),
# BFQ (bfq) or debug_random. "mclock_scheduler" is based on the mClock/dmClock
# algorithm (Gulati, et al. 2010). "mclock_scheduler" prioritizes based on
# the class the operation belongs to. "wpq" dequeues ops based on their
# priorities. "debug_random" chooses among the two with equal probability.
# priorities. "bfq" is modeled on the Linux BFQ elevator as exposed via the
# cgroups v2 io controller: purely proportional weighted sharing between
# background work and client workload types, with no device capacity
# estimate. "debug_random" chooses between wpq and mclock_scheduler with
# equal probability.
# Note: PrioritzedQueue (prio) implementation is not used for scheduling ops
# within OSDs and is therefore not listed.
- name: osd_op_queue
@ -1044,7 +1048,7 @@ options:
desc: which operation priority queue algorithm to use
long_desc: which operation priority queue algorithm to use
fmt_desc: This sets the type of queue to be used for prioritizing ops
within each OSD. Both queues feature a strict sub-queue which is
within each OSD. All queues feature a strict sub-queue which is
dequeued before the normal queue. The normal queue is different
between implementations. The WeightedPriorityQueue (``wpq``)
dequeues operations in relation to their priorities to prevent
@ -1052,13 +1056,17 @@ options:
are more overloaded than others. The mClockQueue
(``mclock_scheduler``) prioritizes operations based on which class
they belong to (recovery, scrub, snaptrim, client op, osd subop).
See `QoS Based on mClock`_. Requires a restart.
See `QoS Based on mClock`_. The bfq scheduler (``bfq``,
experimental) provides weighted proportional sharing of OSD
throughput between background work and client workload types,
modeled on the Linux BFQ elevator. Requires a restart.
default: mclock_scheduler
see_also:
- osd_op_queue_cut_off
enum_values:
- wpq
- mclock_scheduler
- bfq
- debug_random
with_legacy: true
# Min priority to go to strict queue. (low, high)
@ -1086,6 +1094,169 @@ options:
- high
- debug_random
with_legacy: true
# BFQ (Budget Fair Queueing) op scheduler options. Modeled on the Linux
# BFQ elevator as exposed via the cgroups v2 io controller: OSD service
# is shared between weighted groups purely proportionally, with no
# estimate of the device's absolute capacity. Weights follow the
# cgroups v2 io.bfq.weight convention (1..1000, default 100).
- name: osd_bfq_client_group_weight
type: uint
level: advanced
desc: Weight of the client op group for the bfq op queue
fmt_desc: Weight of the client op group relative to the background op
group when ``osd_op_queue`` is ``bfq``. Client ops receive a share of
OSD throughput proportional to this weight whenever both groups are
backlogged.
default: 100
min: 1
max: 1000
flags:
- runtime
see_also:
- osd_op_queue
- name: osd_bfq_background_group_weight
type: uint
level: advanced
desc: Weight of the background op group for the bfq op queue
fmt_desc: Weight of the background op group (recovery, scrub, snaptrim,
PG deletion) relative to the client op group when ``osd_op_queue`` is
``bfq``.
default: 25
min: 1
max: 1000
flags:
- runtime
see_also:
- osd_op_queue
- name: osd_bfq_client_block_weight
type: uint
level: advanced
desc: Weight of block (rbd pool) client ops within the bfq client group
fmt_desc: Weight of ops from pools tagged with the ``rbd`` application,
relative to the other client streams, when ``osd_op_queue`` is ``bfq``.
default: 100
min: 1
max: 1000
flags:
- runtime
see_also:
- osd_op_queue
- name: osd_bfq_client_object_weight
type: uint
level: advanced
desc: Weight of object (rgw pool) client ops within the bfq client group
fmt_desc: Weight of ops from pools tagged with the ``rgw`` application,
relative to the other client streams, when ``osd_op_queue`` is ``bfq``.
default: 100
min: 1
max: 1000
flags:
- runtime
see_also:
- osd_op_queue
- name: osd_bfq_client_file_weight
type: uint
level: advanced
desc: Weight of file (cephfs pool) client ops within the bfq client group
fmt_desc: Weight of ops from pools tagged with the ``cephfs`` application,
relative to the other client streams, when ``osd_op_queue`` is ``bfq``.
default: 100
min: 1
max: 1000
flags:
- runtime
see_also:
- osd_op_queue
- name: osd_bfq_client_other_weight
type: uint
level: advanced
desc: Weight of client ops from untagged pools within the bfq client group
fmt_desc: Weight of ops from pools without a recognized application tag
(or with ambiguous tags), relative to the other client streams, when
``osd_op_queue`` is ``bfq``.
default: 100
min: 1
max: 1000
flags:
- runtime
see_also:
- osd_op_queue
- name: osd_bfq_background_recovery_weight
type: uint
level: advanced
desc: Weight of recovery ops within the bfq background group
fmt_desc: Weight of recovery and backfill ops relative to best-effort
background ops when ``osd_op_queue`` is ``bfq``.
default: 100
min: 1
max: 1000
flags:
- runtime
see_also:
- osd_op_queue
- name: osd_bfq_background_best_effort_weight
type: uint
level: advanced
desc: Weight of best-effort ops within the bfq background group
fmt_desc: Weight of best-effort background ops (scrub, snaptrim, PG
deletion) relative to recovery ops when ``osd_op_queue`` is ``bfq``.
default: 50
min: 1
max: 1000
flags:
- runtime
see_also:
- osd_op_queue
- name: osd_bfq_max_budget
type: size
level: advanced
desc: Maximum service budget, in bytes, granted to a bfq stream per
scheduling round
fmt_desc: The maximum number of (scaled) bytes a stream may be served
within one scheduling round when ``osd_op_queue`` is ``bfq``. Larger
budgets favor throughput (longer exclusive rounds); smaller budgets
favor latency (faster rotation between streams). Matches the spirit
of the kernel BFQ default of 8 MiB.
default: 8_M
min: 4_K
max: 4_G
flags:
- runtime
see_also:
- osd_op_queue
- osd_bfq_budget_timeout
- name: osd_bfq_min_cost
type: size
level: advanced
desc: Minimum scaled cost, in bytes, charged for any op by the bfq op
queue
fmt_desc: A floor on the per-op service charge accounting for fixed
per-IO overhead when ``osd_op_queue`` is ``bfq``. Unlike mclock this
is a plain configuration value, not derived from a device capacity
estimate.
default: 64_K
min: 1
max: 4_G
flags:
- runtime
see_also:
- osd_op_queue
- name: osd_bfq_budget_timeout
type: millisecs
level: advanced
desc: Maximum wall-clock duration of a bfq scheduling round
fmt_desc: A stream that has not consumed its budget within this time is
rotated out (charged only for what it actually used) when
``osd_op_queue`` is ``bfq``. Matches the kernel BFQ default of 125
milliseconds. A very small timeout degenerates into per-op stream
rotation, defeating budget batching.
default: 125
min: 1
flags:
- runtime
see_also:
- osd_op_queue
- osd_bfq_max_budget
- name: osd_mclock_scheduler_client_res
type: float
level: advanced

View File

@ -33,6 +33,7 @@ set(osd_srcs
Session.cc
SnapMapper.cc
osd_types.cc
scheduler/BfqScheduler.cc
scheduler/OpScheduler.cc
scheduler/OpSchedulerItem.cc
scheduler/mClockScheduler.cc

View File

@ -295,7 +295,7 @@ uint64_t ECSubRead::cost(CephContext *cct, std::pair<int, int>& subchunk_info)
* running with mClock scheduler will interpret this and set
* the cost to 1 accordingly.
*/
if (cct->_conf->osd_op_queue != "mclock_scheduler") {
if (!op_queue_type_uses_qos_cost(cct->_conf->osd_op_queue)) {
return total_cost; // Legacy behavior for WPQ scheduler
}

View File

@ -1729,7 +1729,7 @@ void OSDService::queue_recovery_context(
epoch_t e = get_osdmap_epoch();
uint64_t cost_for_queue = [this, cost] {
if (op_queue_type_t::mClockScheduler == osd->osd_op_queue_type()) {
if (op_queue_type_uses_qos_cost(osd->osd_op_queue_type())) {
return cost;
} else {
/* We retain this legacy behavior for WeightedPriorityQueue. It seems to
@ -1756,7 +1756,7 @@ void OSDService::queue_for_snap_trim(PG *pg, uint64_t cost_per_object)
{
dout(10) << "queueing " << *pg << " for snaptrim" << dendl;
uint64_t cost_for_queue = [this, cost_per_object] {
if (cct->_conf->osd_op_queue == "mclock_scheduler") {
if (op_queue_type_uses_qos_cost(cct->_conf->osd_op_queue)) {
/* The cost calculation is valid for most snap trim iterations except
* for the following cases:
* 1) The penultimate iteration which may return 1 object to trim, in
@ -1925,7 +1925,7 @@ void OSDService::queue_for_pg_delete(spg_t pgid, epoch_t e, int64_t num_objects)
{
dout(10) << __func__ << " on " << pgid << " e " << e << dendl;
uint64_t cost_for_queue = [this, num_objects] {
if (op_queue_type_t::mClockScheduler == osd->osd_op_queue_type()) {
if (op_queue_type_uses_qos_cost(osd->osd_op_queue_type())) {
return num_objects * cct->_conf->osd_pg_delete_cost;
} else {
return cct->_conf->osd_pg_delete_cost;
@ -2088,7 +2088,7 @@ void OSDService::_queue_for_recovery(
ceph_assert(ceph_mutex_is_locked_by_me(recovery_lock));
uint64_t cost_for_queue = [this, &reserved_pushes, &p] {
if (op_queue_type_t::mClockScheduler == osd->osd_op_queue_type()) {
if (op_queue_type_uses_qos_cost(osd->osd_op_queue_type())) {
return p.cost_per_object * reserved_pushes;
} else {
/* We retain this legacy behavior for WeightedPriorityQueue. It seems to
@ -10756,6 +10756,7 @@ void OSDShard::consume_map(
dout(10) << new_osdmap->get_epoch()
<< " (was " << (old_osdmap ? old_osdmap->get_epoch() : 0) << ")"
<< dendl;
scheduler->update_from_osdmap(*new_osdmap);
int queued = 0;
// check slots

View File

@ -1676,9 +1676,10 @@ public:
/// get message priority for recovery messages
int get_recovery_op_priority() const {
if (cct->_conf->osd_op_queue == "mclock_scheduler") {
/* For mclock, we use special priority values which will be
* translated into op classes within PGRecoveryMsg::get_scheduler_class
if (op_queue_type_uses_qos_cost(cct->_conf->osd_op_queue)) {
/* For the QoS schedulers (mclock, bfq), we use special priority
* values which will be translated into op classes within
* PGRecoveryMsg::get_scheduler_class
*/
if (is_forced_recovery_or_backfill()) {
return recovery_msg_priority_t::FORCED;

View File

@ -14239,7 +14239,7 @@ uint64_t PrimaryLogPG::recover_backfill(
spg_t(info.pgid.pgid, bt.shard),
pbi.end, hobject_t());
if (cct->_conf->osd_op_queue == "mclock_scheduler") {
if (op_queue_type_uses_qos_cost(cct->_conf->osd_op_queue)) {
/* This guard preserves legacy WeightedPriorityQueue behavior for
* now, but should be removed after Reef */
m->set_priority(recovery_state.get_recovery_op_priority());
@ -14425,7 +14425,7 @@ uint64_t PrimaryLogPG::recover_backfill(
m = reqs[peer] = new MOSDPGBackfillRemove(
spg_t(info.pgid.pgid, peer.shard),
get_osdmap_epoch());
if (cct->_conf->osd_op_queue == "mclock_scheduler") {
if (op_queue_type_uses_qos_cost(cct->_conf->osd_op_queue)) {
/* This guard preserves legacy WeightedPriorityQueue behavior for
* now, but should be removed after Reef */
m->set_priority(recovery_state.get_recovery_op_priority());
@ -14515,7 +14515,7 @@ uint64_t PrimaryLogPG::recover_backfill(
m->last_backfill = pinfo.last_backfill;
m->stats = pinfo.stats;
if (cct->_conf->osd_op_queue == "mclock_scheduler") {
if (op_queue_type_uses_qos_cost(cct->_conf->osd_op_queue)) {
/* This guard preserves legacy WeightedPriorityQueue behavior for
* now, but should be removed after Reef */
m->set_priority(recovery_state.get_recovery_op_priority());

View File

@ -7027,7 +7027,7 @@ ostream& operator<<(ostream& out, const PushReplyOp &op)
uint64_t PushReplyOp::cost(CephContext *cct) const
{
if (cct->_conf->osd_op_queue == "mclock_scheduler") {
if (op_queue_type_uses_qos_cost(cct->_conf->osd_op_queue)) {
/* In general, we really never want to throttle PushReplyOp messages.
* As long as the object is smaller than osd_recovery_max_chunk (8M at
* time of writing this comment, so this is basically always true),
@ -7120,7 +7120,7 @@ ostream& operator<<(ostream& out, const PullOp &op)
uint64_t PullOp::cost(CephContext *cct) const
{
if (cct->_conf->osd_op_queue == "mclock_scheduler") {
if (op_queue_type_uses_qos_cost(cct->_conf->osd_op_queue)) {
return std::clamp<uint64_t>(
recovery_progress.estimate_remaining_data_to_recover(recovery_info),
1,
@ -7731,6 +7731,8 @@ std::string_view get_op_queue_type_name(const op_queue_type_t &q)
return "mclock_scheduler";
case op_queue_type_t::PrioritizedQueue:
return "PrioritizedQueue";
case op_queue_type_t::BfqScheduler:
return "bfq";
default:
return "unknown";
}
@ -7745,7 +7747,20 @@ std::optional<op_queue_type_t> get_op_queue_type_by_name(
return op_queue_type_t::mClockScheduler;
} else if (s == "PrioritizedQueue") {
return op_queue_type_t::PrioritizedQueue;
} else if (s == "bfq") {
return op_queue_type_t::BfqScheduler;
} else {
return std::nullopt;
}
}
bool op_queue_type_uses_qos_cost(const op_queue_type_t &q)
{
return q == op_queue_type_t::mClockScheduler ||
q == op_queue_type_t::BfqScheduler;
}
bool op_queue_type_uses_qos_cost(const std::string_view &s)
{
return s == "mclock_scheduler" || s == "bfq";
}

View File

@ -7192,10 +7192,24 @@ using missing_map_t = std::map<hobject_t,
enum class op_queue_type_t : uint8_t {
WeightedPriorityQueue = 0,
mClockScheduler,
PrioritizedQueue
PrioritizedQueue,
BfqScheduler
};
std::string_view get_op_queue_type_name(const op_queue_type_t &q);
std::optional<op_queue_type_t> get_op_queue_type_by_name(
const std::string_view &s);
/**
* True if the given op queue distributes QoS costs: byte-denominated
* item costs and recovery priority values that encode a scheduler
* class (see PGRecoveryMsg::get_scheduler_class). False for the
* legacy WeightedPriorityQueue cost conventions.
*
* The string overload matches an osd_op_queue config value; note that
* an unresolved "debug_random" intentionally reads as non-QoS, exactly
* like the string comparisons it replaces.
*/
bool op_queue_type_uses_qos_cost(const op_queue_type_t &q);
bool op_queue_type_uses_qos_cost(const std::string_view &s);
#endif

View File

@ -0,0 +1,556 @@
// -*- mode:C++; tab-width:8; c-basic-offset:2; indent-tabs-mode:nil -*-
// vim: ts=8 sw=2 sts=2 expandtab
/*
* Ceph - scalable distributed file system
*
* Copyright (C) 2026 IBM, Inc.
*
* This is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License version 2.1, as published by the Free Software
* Foundation. See file COPYING.
*
*/
#include <algorithm>
#include "osd/scheduler/BfqScheduler.h"
#include "common/debug.h"
#include "osd/OSDMap.h"
#define dout_context cct
#define dout_subsys ceph_subsys_osd
#undef dout_prefix
#define dout_prefix *_dout << "BfqScheduler(" << shard_id << "): "
namespace ceph::osd::scheduler {
std::string_view bfq_stream_name(bfq_stream_t s)
{
switch (s) {
case bfq_stream_t::client_block:
return "client_block";
case bfq_stream_t::client_object:
return "client_object";
case bfq_stream_t::client_file:
return "client_file";
case bfq_stream_t::client_other:
return "client_other";
case bfq_stream_t::background_recovery:
return "background_recovery";
case bfq_stream_t::background_best_effort:
return "background_best_effort";
default:
return "unknown";
}
}
std::string_view bfq_group_name(bfq_group_t g)
{
switch (g) {
case bfq_group_t::client:
return "client";
case bfq_group_t::background:
return "background";
default:
return "unknown";
}
}
namespace bfq_detail {
void BfqServiceTree::activate(unsigned idx, uint64_t allotted,
uint32_t weight)
{
BfqEntity &e = entities[idx];
ceph_assert(!e.active);
ceph_assert(weight > 0);
e.weight = weight;
e.start = std::max(vtime, e.finish);
e.finish = e.start + static_cast<vtime_t>(allotted) / e.weight;
e.allotted = allotted;
e.active = true;
total_weight += e.weight;
}
void BfqServiceTree::charge(uint64_t served)
{
if (total_weight > 0) {
vtime += static_cast<vtime_t>(served) / total_weight;
}
}
void BfqServiceTree::expire(unsigned idx, uint64_t served,
uint64_t next_allotted, uint32_t next_weight,
bool still_backlogged)
{
BfqEntity &e = entities[idx];
ceph_assert(e.active);
ceph_assert(next_weight > 0);
// back-shift: charge the entity for the service it actually
// received, not the estimate it was tagged with at activation
e.finish = e.start + static_cast<vtime_t>(served) / e.weight;
if (still_backlogged) {
e.start = e.finish;
total_weight -= e.weight;
e.weight = next_weight;
total_weight += e.weight;
e.finish = e.start + static_cast<vtime_t>(next_allotted) / e.weight;
e.allotted = next_allotted;
} else {
e.active = false;
total_weight -= e.weight;
if (total_weight == 0) {
// fully idle: the ideal fluid server has no backlog, so relative
// finish memories no longer matter; renormalize to keep the
// virtual time magnitude (and double precision) bounded
vtime = 0.0;
for (auto &ent : entities) {
ent.start = 0.0;
ent.finish = 0.0;
}
}
}
}
std::optional<unsigned> BfqServiceTree::select()
{
int best = -1;
bool any_active = false;
vtime_t min_start = 0.0;
for (unsigned i = 0; i < entities.size(); ++i) {
const BfqEntity &e = entities[i];
if (!e.active) {
continue;
}
if (!any_active || e.start < min_start) {
min_start = e.start;
any_active = true;
}
if (e.start <= vtime &&
(best < 0 || e.finish < entities[best].finish)) {
best = static_cast<int>(i);
}
}
if (best < 0 && any_active) {
// active entities exist but none is eligible: the ideal server
// idled while V lagged; jump V forward to the earliest pending
// start (WF2Q+ virtual time rule) and pick again
vtime = std::max(vtime, min_start);
for (unsigned i = 0; i < entities.size(); ++i) {
const BfqEntity &e = entities[i];
if (e.active && e.start <= vtime &&
(best < 0 || e.finish < entities[best].finish)) {
best = static_cast<int>(i);
}
}
}
if (best < 0) {
return std::nullopt;
}
return static_cast<unsigned>(best);
}
void BfqServiceTree::dump(ceph::Formatter &f) const
{
f.dump_float("vtime", vtime);
f.dump_unsigned("total_active_weight", total_weight);
f.open_array_section("entities");
for (const auto &e : entities) {
f.open_object_section("entity");
f.dump_bool("active", e.active);
f.dump_unsigned("weight", e.weight);
f.dump_float("vstart", e.start);
f.dump_float("vfinish", e.finish);
f.dump_unsigned("allotted", e.allotted);
f.close_section();
}
f.close_section();
}
} // namespace bfq_detail
BfqScheduler::BfqScheduler(CephContext *cct, int whoami, uint32_t num_shards,
int shard_id, bool is_rotational,
unsigned cutoff_priority)
: cct(cct),
cutoff_priority(cutoff_priority),
shard_id(shard_id),
root_tree(bfq_num_groups),
group_trees{bfq_detail::BfqServiceTree(bfq_num_client_streams),
bfq_detail::BfqServiceTree(bfq_num_background_streams)}
{
ceph_assert(num_shards > 0);
refresh_config();
for (auto &s : streams) {
s.next_budget = std::max(min_budget, max_budget / 2);
}
cct->_conf.add_observer(this);
}
BfqScheduler::~BfqScheduler()
{
cct->_conf.remove_observer(this);
}
void BfqScheduler::refresh_config()
{
const auto &conf = cct->_conf;
max_budget = conf.get_val<Option::size_t>("osd_bfq_max_budget");
min_cost = conf.get_val<Option::size_t>("osd_bfq_min_cost");
// floor budgets so a round always covers at least one scaled item
// and rounds cannot degenerate into per-item switching
min_budget = std::max<uint64_t>(
{1, min_cost, max_budget / 32});
max_budget = std::max(max_budget, min_budget);
budget_timeout =
conf.get_val<std::chrono::milliseconds>("osd_bfq_budget_timeout");
group_weights[idx(bfq_group_t::client)] =
conf.get_val<uint64_t>("osd_bfq_client_group_weight");
group_weights[idx(bfq_group_t::background)] =
conf.get_val<uint64_t>("osd_bfq_background_group_weight");
stream_weights[idx(bfq_stream_t::client_block)] =
conf.get_val<uint64_t>("osd_bfq_client_block_weight");
stream_weights[idx(bfq_stream_t::client_object)] =
conf.get_val<uint64_t>("osd_bfq_client_object_weight");
stream_weights[idx(bfq_stream_t::client_file)] =
conf.get_val<uint64_t>("osd_bfq_client_file_weight");
stream_weights[idx(bfq_stream_t::client_other)] =
conf.get_val<uint64_t>("osd_bfq_client_other_weight");
stream_weights[idx(bfq_stream_t::background_recovery)] =
conf.get_val<uint64_t>("osd_bfq_background_recovery_weight");
stream_weights[idx(bfq_stream_t::background_best_effort)] =
conf.get_val<uint64_t>("osd_bfq_background_best_effort_weight");
dout(10) << __func__ << " max_budget " << max_budget
<< " min_budget " << min_budget
<< " min_cost " << min_cost
<< " budget_timeout " << budget_timeout.count() << "ms"
<< dendl;
}
std::vector<std::string> BfqScheduler::get_tracked_keys() const noexcept
{
using namespace std::literals;
return {
"osd_bfq_client_group_weight"s,
"osd_bfq_background_group_weight"s,
"osd_bfq_client_block_weight"s,
"osd_bfq_client_object_weight"s,
"osd_bfq_client_file_weight"s,
"osd_bfq_client_other_weight"s,
"osd_bfq_background_recovery_weight"s,
"osd_bfq_background_best_effort_weight"s,
"osd_bfq_max_budget"s,
"osd_bfq_min_cost"s,
"osd_bfq_budget_timeout"s
};
}
void BfqScheduler::handle_conf_change(const ConfigProxy &conf,
const std::set<std::string> &changed)
{
// runs on the config thread; fold the change in under the shard
// lock on the next enqueue/dequeue. Weights of currently active
// entities take effect at their next (re)activation.
config_dirty = true;
}
uint64_t BfqScheduler::calc_scaled_cost(int item_cost) const
{
// a pure config floor covering per-IO overhead; unlike mclock this
// is not derived from a device capacity estimate
return std::max(static_cast<uint64_t>(std::max(1, item_cost)), min_cost);
}
bfq_stream_t BfqScheduler::classify(const OpSchedulerItem &item) const
{
switch (item.get_scheduler_class()) {
case SchedulerClass::background_recovery:
return bfq_stream_t::background_recovery;
case SchedulerClass::background_best_effort:
return bfq_stream_t::background_best_effort;
default:
// immediate never reaches the fair hierarchy (handled in enqueue)
ceph_assert(item.get_scheduler_class() == SchedulerClass::client);
[[fallthrough]];
case SchedulerClass::client:
if (auto p = pool_streams.find(item.get_ordering_token().pool());
p != pool_streams.end()) {
return p->second;
}
return bfq_stream_t::client_other;
}
}
void BfqScheduler::update_from_osdmap(const OSDMap &map)
{
std::unordered_map<int64_t, bfq_stream_t> next;
for (const auto &[id, pool] : map.get_pools()) {
std::optional<bfq_stream_t> stream;
bool ambiguous = false;
for (const auto &[app, app_md] : pool.application_metadata) {
std::optional<bfq_stream_t> tagged;
if (app == pg_pool_t::APPLICATION_NAME_RBD) {
tagged = bfq_stream_t::client_block;
} else if (app == pg_pool_t::APPLICATION_NAME_RGW) {
tagged = bfq_stream_t::client_object;
} else if (app == pg_pool_t::APPLICATION_NAME_CEPHFS) {
tagged = bfq_stream_t::client_file;
}
if (tagged) {
if (stream && *stream != *tagged) {
ambiguous = true;
}
stream = tagged;
}
}
if (stream && !ambiguous) {
next[id] = *stream;
}
// untagged and ambiguously tagged pools fall to client_other via
// lookup miss
}
pool_streams = std::move(next);
dout(20) << __func__ << " classified " << pool_streams.size()
<< " of " << map.get_pools().size() << " pools" << dendl;
}
void BfqScheduler::set_pool_streams(
std::unordered_map<int64_t, bfq_stream_t> &&map)
{
pool_streams = std::move(map);
}
void BfqScheduler::activate_stream(bfq_stream_t s)
{
const auto g = bfq_group_of(s);
auto &group_tree = group_trees[idx(g)];
const bool group_was_idle = !group_tree.has_active();
auto &stream = streams[idx(s)];
stream.next_budget = std::clamp(stream.next_budget, min_budget, max_budget);
group_tree.activate(bfq_index_in_group(s), stream.next_budget,
stream_weights[idx(s)]);
if (group_was_idle) {
// the group's expected service is one full budget; the estimate
// is corrected to actual service when the round expires
root_tree.activate(idx(g), max_budget, group_weights[idx(g)]);
}
}
void BfqScheduler::begin_round(bfq_stream_t s)
{
auto &stream = streams[idx(s)];
in_service = s;
stream.budget_remaining = static_cast<int64_t>(stream.next_budget);
stream.served_round = 0;
group_served[idx(bfq_group_of(s))] = 0;
round_start = ceph::coarse_mono_clock::now();
dout(20) << __func__ << " " << bfq_stream_name(s)
<< " budget " << stream.next_budget << dendl;
}
void BfqScheduler::end_round(expire_reason reason)
{
const bfq_stream_t s = *in_service;
auto &stream = streams[idx(s)];
const auto g = bfq_group_of(s);
auto &group_tree = group_trees[idx(g)];
const uint64_t served = stream.served_round;
// budget feedback (simplified from the kernel): grow when the
// stream exhausted its budget while still backlogged, adapt down to
// observed demand when it emptied, pin to actual progress on
// timeout
switch (reason) {
case expire_reason::exhausted:
stream.next_budget = std::min(max_budget, stream.next_budget * 2);
break;
case expire_reason::emptied:
case expire_reason::timed_out:
stream.next_budget = std::clamp(served, min_budget, max_budget);
break;
}
const bool backlogged = !stream.items.empty();
group_tree.expire(bfq_index_in_group(s), served, stream.next_budget,
stream_weights[idx(s)], backlogged);
root_tree.expire(idx(g), group_served[idx(g)], max_budget,
group_weights[idx(g)], group_tree.has_active());
group_served[idx(g)] = 0;
in_service.reset();
dout(20) << __func__ << " " << bfq_stream_name(s)
<< " reason " << static_cast<int>(reason)
<< " served " << served
<< " next_budget " << stream.next_budget
<< dendl;
}
void BfqScheduler::enqueue_high(unsigned priority, OpSchedulerItem &&item,
bool front)
{
if (front) {
high_priority[priority].push_back(std::move(item));
} else {
high_priority[priority].push_front(std::move(item));
}
}
void BfqScheduler::enqueue(OpSchedulerItem &&item)
{
maybe_refresh_config();
const unsigned priority = item.get_priority();
if (SchedulerClass::immediate == item.get_scheduler_class()) {
enqueue_high(immediate_class_priority, std::move(item));
} else if (priority >= cutoff_priority) {
enqueue_high(priority, std::move(item));
} else {
const bfq_stream_t s = classify(item);
const uint64_t scaled = calc_scaled_cost(item.get_cost());
item.set_qos_cost(static_cast<uint32_t>(
std::min<uint64_t>(scaled, std::numeric_limits<uint32_t>::max())));
dout(20) << __func__ << " " << bfq_stream_name(s)
<< " cost " << item.get_cost()
<< " scaled_cost " << scaled
<< dendl;
auto &stream = streams[idx(s)];
const bool was_idle = stream.items.empty();
stream.items.push_back(std::move(item));
++fair_queued;
if (was_idle && in_service != s) {
// an empty stream that is not in service is never on its tree;
// an in-service stream keeps its round until it expires
activate_stream(s);
}
}
}
void BfqScheduler::enqueue_front(OpSchedulerItem &&item)
{
maybe_refresh_config();
const unsigned priority = item.get_priority();
if (SchedulerClass::immediate == item.get_scheduler_class()) {
enqueue_high(immediate_class_priority, std::move(item), true);
} else if (priority >= cutoff_priority) {
enqueue_high(priority, std::move(item), true);
} else {
// like mclock: the fair hierarchy cannot insert at the front, so
// requeued items bypass it via the strict queue at priority 0
enqueue_high(0, std::move(item), true);
}
}
WorkItem BfqScheduler::dequeue()
{
maybe_refresh_config();
if (!high_priority.empty()) {
auto iter = high_priority.begin();
// invariant: high_priority entries are never empty
ceph_assert(!iter->second.empty());
WorkItem ret{std::move(iter->second.back())};
iter->second.pop_back();
if (iter->second.empty()) {
high_priority.erase(iter);
}
return ret;
}
ceph_assert(fair_queued > 0 &&
"Impossible, must have checked empty() first");
return dequeue_fair();
}
OpSchedulerItem BfqScheduler::dequeue_fair()
{
if (in_service) {
auto &stream = streams[idx(*in_service)];
if (stream.items.empty()) {
end_round(expire_reason::emptied);
} else if (stream.budget_remaining <= 0) {
end_round(expire_reason::exhausted);
} else if (ceph::coarse_mono_clock::now() - round_start >
budget_timeout) {
end_round(expire_reason::timed_out);
}
}
if (!in_service) {
// two-level B-WF2Q+ selection: group, then leaf within the group.
// Invariant: every non-empty stream is on its group tree (or in
// service) and every group with active leaves is on the root
// tree, so fair_queued > 0 guarantees both selections succeed.
auto gsel = root_tree.select();
ceph_assert(gsel);
auto lsel = group_trees[*gsel].select();
ceph_assert(lsel);
begin_round(bfq_stream_at(*gsel, *lsel));
}
const bfq_stream_t s = *in_service;
auto &stream = streams[idx(s)];
ceph_assert(!stream.items.empty());
OpSchedulerItem item = std::move(stream.items.front());
stream.items.pop_front();
--fair_queued;
const uint64_t served = calc_scaled_cost(item.get_cost());
stream.budget_remaining -= static_cast<int64_t>(served);
stream.served_round += served;
group_served[idx(bfq_group_of(s))] += served;
group_trees[idx(bfq_group_of(s))].charge(served);
root_tree.charge(served);
return item;
}
void BfqScheduler::dump(ceph::Formatter &f) const
{
f.open_object_section("queue_sizes");
f.dump_unsigned("high_priority_queue", high_priority.size());
f.dump_unsigned("fair_queued", fair_queued);
f.close_section();
f.open_object_section("bfq");
f.dump_string("in_service",
in_service ? std::string(bfq_stream_name(*in_service))
: "none");
f.open_object_section("root_tree");
root_tree.dump(f);
f.close_section();
f.open_array_section("groups");
for (size_t g = 0; g < bfq_num_groups; ++g) {
f.open_object_section("group");
f.dump_string("name", std::string(
bfq_group_name(static_cast<bfq_group_t>(g))));
f.dump_unsigned("weight", group_weights[g]);
f.open_object_section("tree");
group_trees[g].dump(f);
f.close_section();
f.close_section();
}
f.close_section();
f.open_array_section("streams");
for (size_t s = 0; s < bfq_num_streams; ++s) {
f.open_object_section("stream");
f.dump_string("name", std::string(
bfq_stream_name(static_cast<bfq_stream_t>(s))));
f.dump_unsigned("weight", stream_weights[s]);
f.dump_unsigned("queue_size", streams[s].items.size());
f.dump_unsigned("next_budget", streams[s].next_budget);
f.close_section();
}
f.close_section();
f.close_section();
f.open_object_section("HighPriorityQueue");
for (auto it = high_priority.begin(); it != high_priority.end(); it++) {
f.dump_int("priority", it->first);
f.dump_int("queue_size", it->second.size());
}
f.close_section();
}
}

View File

@ -0,0 +1,312 @@
// -*- mode:C++; tab-width:8; c-basic-offset:2; indent-tabs-mode:nil -*-
// vim: ts=8 sw=2 sts=2 expandtab
/*
* Ceph - scalable distributed file system
*
* Copyright (C) 2026 IBM, Inc.
*
* This is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License version 2.1, as published by the Free Software
* Foundation. See file COPYING.
*
*/
#pragma once
#include <array>
#include <atomic>
#include <chrono>
#include <deque>
#include <limits>
#include <list>
#include <map>
#include <optional>
#include <ostream>
#include <unordered_map>
#include <vector>
#include "common/ceph_context.h"
#include "common/ceph_time.h"
#include "common/config.h"
#include "osd/scheduler/OpScheduler.h"
#include "osd/scheduler/OpSchedulerItem.h"
class OSDMap;
namespace ceph::osd::scheduler {
/**
* bfq_stream_t
*
* The leaf queues of the bfq hierarchy. Client ops are subdivided by
* the workload type of their pool (derived from the pool application
* metadata); background ops by their scheduler class.
*/
enum class bfq_stream_t : uint8_t {
client_block = 0, ///< pools tagged with the rbd application
client_object, ///< pools tagged with the rgw application
client_file, ///< pools tagged with the cephfs application
client_other, ///< untagged or ambiguously tagged pools
background_recovery, ///< SchedulerClass::background_recovery
background_best_effort, ///< SchedulerClass::background_best_effort
stream_count
};
enum class bfq_group_t : uint8_t {
client = 0,
background,
group_count
};
inline constexpr size_t bfq_num_streams =
static_cast<size_t>(bfq_stream_t::stream_count);
inline constexpr size_t bfq_num_groups =
static_cast<size_t>(bfq_group_t::group_count);
inline constexpr size_t bfq_num_client_streams = 4;
inline constexpr size_t bfq_num_background_streams = 2;
constexpr bfq_group_t bfq_group_of(bfq_stream_t s) {
return s < bfq_stream_t::background_recovery ?
bfq_group_t::client : bfq_group_t::background;
}
/// index of a stream's entity within its group's service tree
constexpr unsigned bfq_index_in_group(bfq_stream_t s) {
return bfq_group_of(s) == bfq_group_t::client ?
static_cast<unsigned>(s) :
static_cast<unsigned>(s) -
static_cast<unsigned>(bfq_stream_t::background_recovery);
}
constexpr bfq_stream_t bfq_stream_at(unsigned group, unsigned index_in_group) {
return static_cast<bfq_stream_t>(
group == static_cast<unsigned>(bfq_group_t::client) ?
index_in_group :
static_cast<unsigned>(bfq_stream_t::background_recovery) +
index_in_group);
}
std::string_view bfq_stream_name(bfq_stream_t s);
std::string_view bfq_group_name(bfq_group_t g);
namespace bfq_detail {
using vtime_t = double;
struct BfqEntity {
uint32_t weight = 1; ///< share while active, fixed at (re)activation
vtime_t start = 0.0; ///< S: virtual start of the current allotment
vtime_t finish = 0.0; ///< F while active; finish memory while idle
uint64_t allotted = 0; ///< service allotted at activation (scaled bytes)
bool active = false;
};
/**
* BfqServiceTree
*
* WF2Q+ scheduling over a small fixed set of entities addressed by
* index. The kernel maintains augmented rb-trees because a cgroup
* hierarchy may hold thousands of entities; with at most a handful
* per tree we scan linearly instead.
*
* Not thread safe: callers rely on the OSD shard lock.
*/
class BfqServiceTree {
public:
explicit BfqServiceTree(unsigned num_entities) : entities(num_entities) {}
/**
* Make an idle entity backlogged. S = max(V, previous F) -- the
* finish memory prevents a queue from banking service while idle --
* and F = S + allotted/weight.
*/
void activate(unsigned idx, uint64_t allotted, uint32_t weight);
/// account served (scaled) bytes to the tree's virtual time
void charge(uint64_t served);
/**
* Close out an entity's allotment: back-shift F to the service it
* actually received (so unused budget carries no penalty), then
* either re-tag it for another allotment or remove it from the tree.
*/
void expire(unsigned idx, uint64_t served, uint64_t next_allotted,
uint32_t next_weight, bool still_backlogged);
/**
* WF2Q+ selection: the eligible (S <= V) active entity with the
* minimum F. If active entities exist but none is eligible, V
* jumps forward to the earliest pending S first.
*/
std::optional<unsigned> select();
bool has_active() const {
return total_weight > 0;
}
const BfqEntity &entity(unsigned idx) const {
return entities[idx];
}
vtime_t get_vtime() const {
return vtime;
}
void dump(ceph::Formatter &f) const;
private:
std::vector<BfqEntity> entities;
vtime_t vtime = 0.0;
uint64_t total_weight = 0; ///< sum of active entity weights
};
} // namespace bfq_detail
/**
* BfqScheduler
*
* OpScheduler implementation modeled on the Linux BFQ (Budget Fair
* Queueing) I/O scheduler as exposed through the cgroups v2 io
* controller. Service (scaled bytes) is distributed by a two-level
* B-WF2Q+ hierarchy of weighted groups:
*
* root
* |-- client group (osd_bfq_client_group_weight)
* | |-- block (rbd pools) (osd_bfq_client_block_weight)
* | |-- object (rgw pools) (osd_bfq_client_object_weight)
* | |-- file (cephfs pools) (osd_bfq_client_file_weight)
* | `-- other (osd_bfq_client_other_weight)
* `-- background group (osd_bfq_background_group_weight)
* |-- recovery (osd_bfq_background_recovery_weight)
* `-- best_effort (osd_bfq_background_best_effort_weight)
*
* Unlike mclock, bfq is purely proportional-share: it needs no
* estimate of the device's absolute capacity (IOPS or bandwidth).
* Each backlogged leaf is granted a byte budget and served
* exclusively until the budget is exhausted, its timeout elapses, or
* it empties; virtual finish times computed over the assigned budget
* are back-shifted to the consumed service on expiration, and the
* next budget adapts to observed demand.
*
* Deliberate scope exclusions relative to the kernel: no weight
* raising (low-latency heuristics), no device idling/anticipation.
* Service is charged at dequeue (dispatch to a shard worker), not at
* device completion, so fairness is over dispatched scaled bytes and
* budget exclusivity is selection-exclusivity; this mirrors the
* accounting model mclock already uses.
*
* Like mClockScheduler, items of SchedulerClass::immediate and items
* at or above the priority cutoff bypass the fair hierarchy through a
* strict high-priority queue, and enqueue_front lands requeued items
* in that queue at priority 0.
*/
class BfqScheduler final : public OpScheduler, public md_config_obs_t {
public:
BfqScheduler(CephContext *cct, int whoami, uint32_t num_shards,
int shard_id, bool is_rotational, unsigned cutoff_priority);
~BfqScheduler() final;
void enqueue(OpSchedulerItem &&item) final;
void enqueue_front(OpSchedulerItem &&item) final;
bool empty() const final {
return fair_queued == 0 && high_priority.empty();
}
// Never returns the future-time (double) alternative: if empty() is
// false an item is always returned.
WorkItem dequeue() final;
void dump(ceph::Formatter &f) const final;
void print(std::ostream &out) const final {
out << get_op_queue_type_name(get_type())
<< ", cutoff=" << cutoff_priority;
}
op_queue_type_t get_type() const final {
return op_queue_type_t::BfqScheduler;
}
/// rebuild the pool -> client stream map from pool application tags
void update_from_osdmap(const OSDMap &map) final;
// md_config_obs_t
std::vector<std::string> get_tracked_keys() const noexcept final;
void handle_conf_change(const ConfigProxy &conf,
const std::set<std::string> &changed) final;
// exposed for unit tests
uint64_t calc_scaled_cost(int item_cost) const;
bfq_stream_t classify(const OpSchedulerItem &item) const;
void set_pool_streams(std::unordered_map<int64_t, bfq_stream_t> &&map);
private:
enum class expire_reason { emptied, exhausted, timed_out };
static constexpr size_t idx(bfq_stream_t s) {
return static_cast<size_t>(s);
}
static constexpr size_t idx(bfq_group_t g) {
return static_cast<size_t>(g);
}
void refresh_config();
void maybe_refresh_config() {
if (config_dirty.exchange(false)) {
refresh_config();
}
}
void enqueue_high(unsigned prio, OpSchedulerItem &&item, bool front = false);
void activate_stream(bfq_stream_t s);
void begin_round(bfq_stream_t s);
void end_round(expire_reason reason);
OpSchedulerItem dequeue_fair();
CephContext *cct;
const unsigned cutoff_priority;
const int shard_id;
// config cache; refreshed under the shard lock when config_dirty
uint64_t max_budget = 0;
uint64_t min_cost = 0;
uint64_t min_budget = 0;
std::chrono::milliseconds budget_timeout{125};
std::array<uint32_t, bfq_num_streams> stream_weights;
std::array<uint32_t, bfq_num_groups> group_weights;
std::atomic<bool> config_dirty = true;
struct Stream {
std::deque<OpSchedulerItem> items;
uint64_t next_budget = 0; ///< adapted by budget feedback
uint64_t served_round = 0; ///< scaled bytes served this round
int64_t budget_remaining = 0; ///< may go negative for oversized items
};
std::array<Stream, bfq_num_streams> streams;
std::array<uint64_t, bfq_num_groups> group_served = {0, 0};
size_t fair_queued = 0;
bfq_detail::BfqServiceTree root_tree;
std::array<bfq_detail::BfqServiceTree, bfq_num_groups> group_trees;
std::optional<bfq_stream_t> in_service;
ceph::coarse_mono_clock::time_point round_start;
/// pool id -> client stream, rebuilt on every OSDMap the shard consumes
std::unordered_map<int64_t, bfq_stream_t> pool_streams;
using priority_t = unsigned;
using SubQueue = std::map<priority_t,
std::list<OpSchedulerItem>,
std::greater<priority_t>>;
/**
* high_priority
*
* Holds entries to be dequeued in strict order ahead of the fair
* hierarchy. Invariant: entries are never empty.
*/
SubQueue high_priority;
static constexpr priority_t immediate_class_priority =
std::numeric_limits<priority_t>::max();
};
}

View File

@ -18,6 +18,7 @@
#include "osd/scheduler/OpScheduler.h"
#include "common/WeightedPriorityQueue.h"
#include "osd/scheduler/BfqScheduler.h"
#include "osd/scheduler/mClockScheduler.h"
namespace ceph::osd::scheduler {
@ -43,6 +44,10 @@ OpSchedulerRef make_scheduler(
return std::make_unique<
mClockScheduler>(cct, whoami, num_shards, shard_id, is_rotational,
op_queue_cut_off);
} else if (op_queue_type_t::BfqScheduler == osd_scheduler) {
return std::make_unique<
BfqScheduler>(cct, whoami, num_shards, shard_id, is_rotational,
op_queue_cut_off);
} else {
ceph_abort_msg("Invalid choice of wq");
}

View File

@ -25,6 +25,8 @@
#include "include/ceph_assert.h"
class OSDMap;
namespace ceph::osd::scheduler {
using client = uint64_t;
@ -63,6 +65,11 @@ public:
return 0.0;
}
// Notify the scheduler of a new OSDMap. Called under the shard
// lock whenever the shard's map advances; schedulers that classify
// ops by pool properties override this.
virtual void update_from_osdmap(const OSDMap &map) {}
// Destructor
virtual ~OpScheduler() {};
};

View File

@ -96,14 +96,14 @@ private:
/**
* qos_cost
*
* Set by mClockScheduler iff queued into mclock proper and not the
* high/immediate queues. Represents mClockScheduler's adjusted
* cost value.
* Set by the QoS schedulers (mclock, bfq) iff queued into the fair
* queue proper and not the high/immediate queues. Represents the
* scheduler's adjusted cost value.
*/
uint32_t qos_cost = 0;
/// True iff queued via mclock proper, not the high/immediate queues
bool was_queued_via_mclock() const {
/// True iff queued via a fair queue proper, not the high/immediate queues
bool was_queued_via_qos() const {
return qos_cost > 0;
}
@ -181,7 +181,7 @@ public:
out << " prio " << item.get_priority();
if (item.was_queued_via_mclock()) {
if (item.was_queued_via_qos()) {
out << " qos_cost " << item.qos_cost;
}
@ -679,7 +679,7 @@ struct fmt::formatter<ceph::osd::scheduler::OpSchedulerItem> {
// matching existing op_scheduler_item_t::operator<<() format
using class_t =
std::underlying_type_t<SchedulerClass>;
const auto qos_cost = opsi.was_queued_via_mclock()
const auto qos_cost = opsi.was_queued_via_qos()
? fmt::format(" qos_cost {}", opsi.qos_cost)
: "";
const auto pushes =

View File

@ -1086,8 +1086,9 @@ void PgScrubber::select_range_n_notify()
uint64_t PgScrubber::get_scrub_cost(uint64_t num_chunk_objects)
{
const auto& conf = m_pg->get_cct()->_conf;
if (op_queue_type_t::WeightedPriorityQueue == m_osds->osd->osd_op_queue_type()) {
// if the osd_op_queue is WPQ, we will use the default osd_scrub_cost value
if (op_queue_type_t::mClockScheduler != m_osds->osd->osd_op_queue_type()) {
// only mclock derives a per-IO metadata cost from its device capacity
// model; wpq and bfq use the flat osd_scrub_cost value
return conf->osd_scrub_cost;
}
uint64_t cost = 0;

View File

@ -215,6 +215,33 @@ target_link_libraries(unittest_mclock_scheduler
global osd dmclock os
)
# unittest_bfq_scheduler
add_executable(unittest_bfq_scheduler
TestBfqScheduler.cc
)
add_ceph_unittest(unittest_bfq_scheduler)
target_link_libraries(unittest_bfq_scheduler
global osd os
)
# unittest_scheduler_isolation
add_executable(unittest_scheduler_isolation
TestSchedulerIsolation.cc
)
add_ceph_unittest(unittest_scheduler_isolation)
target_link_libraries(unittest_scheduler_isolation
global osd dmclock os
)
# ceph_bench_op_scheduler: isolation micro benchmarks across the op
# schedulers; a manual tool, not part of the unit test suite
add_executable(ceph_bench_op_scheduler
bench_op_scheduler.cc
)
target_link_libraries(ceph_bench_op_scheduler
global osd dmclock os
)
# unittest ECOmapJournal
add_executable(unittest_ec_omap_journal
test_ec_omap_journal.cc
@ -226,6 +253,7 @@ target_link_libraries(unittest_ec_omap_journal osd global ${BLKID_LIBRARIES})
# Not including unittest_osdmap, as it is slow. It is tested elsewhere.
set(OSD_UNITTESTS
unittest_backend_basics
unittest_bfq_scheduler
unittest_ec_omap_journal
unittest_ec_transaction
unittest_ec_transaction_l
@ -241,6 +269,7 @@ set(OSD_UNITTESTS
unittest_osd_types
unittest_osdscrub
unittest_peeringstate
unittest_scheduler_isolation
unittest_pg_transaction
unittest_pglog
unittest_scrubber_be

View File

@ -0,0 +1,531 @@
// -*- mode:C++; tab-width:8; c-basic-offset:2; indent-tabs-mode:nil -*-
// vim: ts=8 sw=2 sts=2 expandtab
#include <chrono>
#include <map>
#include <memory>
#include <thread>
#include "gtest/gtest.h"
#include "global/global_context.h"
#include "global/global_init.h"
#include "common/common_init.h"
#include "osd/scheduler/BfqScheduler.h"
#include "osd/scheduler/OpSchedulerItem.h"
using namespace ceph::osd::scheduler;
int main(int argc, char **argv) {
std::vector<const char*> args(argv, argv+argc);
auto cct = global_init(nullptr, args, CEPH_ENTITY_TYPE_OSD,
CODE_ENVIRONMENT_UTILITY,
CINIT_FLAG_NO_DEFAULT_CONFIG_FILE);
common_init_finish(g_ceph_context);
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
namespace {
const std::vector<std::string> bfq_conf_keys = {
"osd_bfq_client_group_weight",
"osd_bfq_background_group_weight",
"osd_bfq_client_block_weight",
"osd_bfq_client_object_weight",
"osd_bfq_client_file_weight",
"osd_bfq_client_other_weight",
"osd_bfq_background_recovery_weight",
"osd_bfq_background_best_effort_weight",
"osd_bfq_max_budget",
"osd_bfq_min_cost",
"osd_bfq_budget_timeout"
};
}
class BfqSchedulerTest : public testing::Test {
public:
int whoami = 0;
uint32_t num_shards = 1;
int shard_id = 0;
bool is_rotational = false;
unsigned cutoff_priority = 12;
std::unique_ptr<BfqScheduler> q;
void set_conf(const std::string &key, const std::string &val) {
ASSERT_EQ(0, g_ceph_context->_conf.set_val(key, val));
}
// construct the scheduler after any config overrides so the
// constructor picks them up
void create_queue() {
q = std::make_unique<BfqScheduler>(
g_ceph_context, whoami, num_shards, shard_id, is_rotational,
cutoff_priority);
}
void TearDown() override {
q.reset();
for (const auto &key : bfq_conf_keys) {
g_ceph_context->_conf.rm_val(key);
}
}
struct MockBfqItem : public PGOpQueueable {
SchedulerClass scheduler_class;
MockBfqItem(spg_t pgid, SchedulerClass _scheduler_class)
: PGOpQueueable(pgid),
scheduler_class(_scheduler_class) {}
std::ostream &print(std::ostream &rhs) const final { return rhs; }
std::string print() const final {
return std::string();
}
std::optional<OpRequestRef> maybe_get_op() const final {
return std::nullopt;
}
SchedulerClass get_scheduler_class() const final {
return scheduler_class;
}
void run(OSD *osd, OSDShard *sdata, PGRef& pg,
ThreadPool::TPHandle &handle) final {}
};
};
namespace {
spg_t make_pgid(int64_t pool) {
return spg_t(pg_t(0, pool));
}
OpSchedulerItem create_item(
epoch_t e, uint64_t owner, SchedulerClass klass,
int64_t pool = 1, int cost = 4096, unsigned priority = 1)
{
return OpSchedulerItem(
std::make_unique<BfqSchedulerTest::MockBfqItem>(make_pgid(pool), klass),
cost, priority, utime_t(), owner, e);
}
OpSchedulerItem create_high_prio_item(
unsigned priority, epoch_t e, uint64_t owner, SchedulerClass klass)
{
return OpSchedulerItem(
std::make_unique<BfqSchedulerTest::MockBfqItem>(make_pgid(1), klass),
4096, priority, utime_t(), owner, e);
}
OpSchedulerItem get_item(WorkItem item)
{
// throws (fails the test) if the variant holds monostate or a
// future time -- bfq must always return a real item
return std::move(std::get<OpSchedulerItem>(item));
}
} // anonymous namespace
TEST_F(BfqSchedulerTest, TestEmpty) {
create_queue();
ASSERT_TRUE(q->empty());
for (unsigned i = 100; i < 105; i += 2) {
q->enqueue(create_item(i, 1, SchedulerClass::client));
}
ASSERT_FALSE(q->empty());
std::list<OpSchedulerItem> reqs;
reqs.push_back(get_item(q->dequeue()));
reqs.push_back(get_item(q->dequeue()));
ASSERT_EQ(2u, reqs.size());
ASSERT_FALSE(q->empty());
for (auto &&i : reqs) {
q->enqueue_front(std::move(i));
}
reqs.clear();
ASSERT_FALSE(q->empty());
for (int i = 0; i < 3; ++i) {
ASSERT_FALSE(q->empty());
get_item(q->dequeue());
}
ASSERT_TRUE(q->empty());
}
TEST_F(BfqSchedulerTest, TestSingleStreamFifo) {
create_queue();
for (unsigned i = 100; i < 105; ++i) {
q->enqueue(create_item(i, 1, SchedulerClass::client));
}
for (unsigned i = 100; i < 105; ++i) {
auto r = get_item(q->dequeue());
ASSERT_EQ(i, r.get_map_epoch());
}
ASSERT_TRUE(q->empty());
}
TEST_F(BfqSchedulerTest, TestImmediateAndCutoffOrdering) {
create_queue();
ASSERT_TRUE(q->empty());
// fair-queue items
for (unsigned i = 100; i < 102; ++i) {
q->enqueue(create_item(i, 1, SchedulerClass::client));
}
// immediate items
for (unsigned i = 103; i < 105; ++i) {
q->enqueue(create_item(i, 1, SchedulerClass::immediate));
}
// strict high-priority items (priority >= cutoff of 12)
for (unsigned i = 200; i < 202; ++i) {
q->enqueue(create_high_prio_item(i - 180, i, 1, SchedulerClass::client));
}
// immediate class first
ASSERT_EQ(103u, get_item(q->dequeue()).get_map_epoch());
ASSERT_EQ(104u, get_item(q->dequeue()).get_map_epoch());
// then the strict queue, higher priority first
ASSERT_EQ(201u, get_item(q->dequeue()).get_map_epoch());
ASSERT_EQ(200u, get_item(q->dequeue()).get_map_epoch());
// the fair hierarchy drains last
ASSERT_EQ(100u, get_item(q->dequeue()).get_map_epoch());
ASSERT_EQ(101u, get_item(q->dequeue()).get_map_epoch());
ASSERT_TRUE(q->empty());
}
TEST_F(BfqSchedulerTest, TestEnqueueFront) {
create_queue();
for (unsigned i = 100; i < 104; ++i) {
q->enqueue(create_item(i, 1, SchedulerClass::client));
}
auto r = get_item(q->dequeue());
ASSERT_EQ(100u, r.get_map_epoch());
q->enqueue_front(std::move(r));
// the requeued item bypasses the fair hierarchy and comes back first
ASSERT_EQ(100u, get_item(q->dequeue()).get_map_epoch());
ASSERT_EQ(101u, get_item(q->dequeue()).get_map_epoch());
}
TEST_F(BfqSchedulerTest, TestClientStreamWeights) {
set_conf("osd_bfq_max_budget", "16384");
set_conf("osd_bfq_min_cost", "4096");
set_conf("osd_bfq_budget_timeout", "100000");
set_conf("osd_bfq_client_block_weight", "300");
set_conf("osd_bfq_client_object_weight", "100");
create_queue();
q->set_pool_streams({
{1, bfq_stream_t::client_block},
{2, bfq_stream_t::client_object}
});
constexpr unsigned per_stream = 600;
for (unsigned i = 0; i < per_stream; ++i) {
q->enqueue(create_item(i, 1, SchedulerClass::client, 1));
q->enqueue(create_item(i, 2, SchedulerClass::client, 2));
}
// both streams stay backlogged over the measurement window
std::map<uint64_t, unsigned> count;
for (unsigned i = 0; i < 400; ++i) {
ASSERT_FALSE(q->empty());
++count[get_item(q->dequeue()).get_owner()];
}
// weights 300:100 -> expect a 3:1 split of the 400 dequeues
ASSERT_NEAR(300, count[1], 30);
ASSERT_NEAR(100, count[2], 30);
}
TEST_F(BfqSchedulerTest, TestGroupWeights) {
set_conf("osd_bfq_max_budget", "16384");
set_conf("osd_bfq_min_cost", "4096");
set_conf("osd_bfq_budget_timeout", "100000");
set_conf("osd_bfq_client_group_weight", "100");
set_conf("osd_bfq_background_group_weight", "25");
create_queue();
constexpr unsigned per_stream = 600;
for (unsigned i = 0; i < per_stream; ++i) {
q->enqueue(create_item(i, 1, SchedulerClass::client, 5));
q->enqueue(create_item(i, 2, SchedulerClass::background_recovery, 5));
}
std::map<uint64_t, unsigned> count;
for (unsigned i = 0; i < 500; ++i) {
ASSERT_FALSE(q->empty());
++count[get_item(q->dequeue()).get_owner()];
}
// group weights 100:25 -> expect a 4:1 split of the 500 dequeues
ASSERT_NEAR(400, count[1], 40);
ASSERT_NEAR(100, count[2], 40);
}
TEST_F(BfqSchedulerTest, TestBudgetRotation) {
set_conf("osd_bfq_max_budget", "16384");
set_conf("osd_bfq_min_cost", "4096");
set_conf("osd_bfq_budget_timeout", "100000");
create_queue();
q->set_pool_streams({
{1, bfq_stream_t::client_block},
{2, bfq_stream_t::client_object}
});
constexpr unsigned per_stream = 64;
for (unsigned i = 0; i < per_stream; ++i) {
q->enqueue(create_item(i, 1, SchedulerClass::client, 1));
q->enqueue(create_item(i, 2, SchedulerClass::client, 2));
}
// with equal weights and 4-item budgets, service alternates in
// budget-sized runs rather than per-op interleave
uint64_t prev_owner = 0;
unsigned switches = 0;
for (unsigned i = 0; i < 2 * per_stream; ++i) {
auto owner = get_item(q->dequeue()).get_owner();
if (prev_owner && owner != prev_owner) {
++switches;
}
prev_owner = owner;
}
ASSERT_TRUE(q->empty());
// strict alternation would switch 127 times; 4-item rounds yield
// roughly 2*64/4 = 32 switches (a few more during budget ramp-up)
ASSERT_GE(switches, 2u);
ASSERT_LT(switches, 64u);
}
TEST_F(BfqSchedulerTest, TestPoolClassification) {
create_queue();
// no pool map yet: every client op classifies as client_other
auto unmapped = create_item(1, 1, SchedulerClass::client, 42);
ASSERT_EQ(bfq_stream_t::client_other, q->classify(unmapped));
q->set_pool_streams({
{42, bfq_stream_t::client_block},
{7, bfq_stream_t::client_file}
});
auto block = create_item(1, 1, SchedulerClass::client, 42);
auto file = create_item(1, 1, SchedulerClass::client, 7);
auto other = create_item(1, 1, SchedulerClass::client, 9);
ASSERT_EQ(bfq_stream_t::client_block, q->classify(block));
ASSERT_EQ(bfq_stream_t::client_file, q->classify(file));
ASSERT_EQ(bfq_stream_t::client_other, q->classify(other));
// background classes ignore the pool
auto rec = create_item(1, 1, SchedulerClass::background_recovery, 42);
auto be = create_item(1, 1, SchedulerClass::background_best_effort, 42);
ASSERT_EQ(bfq_stream_t::background_recovery, q->classify(rec));
ASSERT_EQ(bfq_stream_t::background_best_effort, q->classify(be));
}
TEST_F(BfqSchedulerTest, TestDrain) {
create_queue();
q->set_pool_streams({
{1, bfq_stream_t::client_block},
{2, bfq_stream_t::client_object},
{3, bfq_stream_t::client_file}
});
unsigned enqueued = 0;
for (unsigned i = 0; i < 20; ++i) {
for (int64_t pool = 1; pool <= 4; ++pool) {
q->enqueue(create_item(i, pool, SchedulerClass::client, pool));
++enqueued;
}
q->enqueue(create_item(i, 5, SchedulerClass::background_recovery));
q->enqueue(create_item(i, 6, SchedulerClass::background_best_effort));
q->enqueue(create_item(i, 7, SchedulerClass::immediate));
enqueued += 3;
}
q->enqueue(create_high_prio_item(20, 1000, 8, SchedulerClass::client));
++enqueued;
// the fast-shutdown pattern: dequeue until empty must terminate
unsigned drained = 0;
while (!q->empty()) {
get_item(q->dequeue());
++drained;
}
ASSERT_EQ(enqueued, drained);
}
TEST_F(BfqSchedulerTest, TestOversizedItemRotation) {
set_conf("osd_bfq_max_budget", "16384");
set_conf("osd_bfq_min_cost", "4096");
set_conf("osd_bfq_budget_timeout", "100000");
create_queue();
q->set_pool_streams({
{1, bfq_stream_t::client_block},
{2, bfq_stream_t::client_object}
});
// stream A leads with an item 4x the entire budget
q->enqueue(create_item(100, 1, SchedulerClass::client, 1, 65536));
for (unsigned i = 101; i < 104; ++i) {
q->enqueue(create_item(i, 1, SchedulerClass::client, 1));
}
for (unsigned i = 200; i < 204; ++i) {
q->enqueue(create_item(i, 2, SchedulerClass::client, 2));
}
// the oversized item must dispatch (budget goes negative, no hang)...
ASSERT_EQ(100u, get_item(q->dequeue()).get_map_epoch());
// ...and the overrun charge makes stream A ineligible, rotating to B
ASSERT_EQ(200u, get_item(q->dequeue()).get_map_epoch());
unsigned drained = 2;
while (!q->empty()) {
get_item(q->dequeue());
++drained;
}
ASSERT_EQ(8u, drained);
}
TEST_F(BfqSchedulerTest, TestBudgetTimeoutExpiry) {
set_conf("osd_bfq_max_budget", "1048576");
set_conf("osd_bfq_min_cost", "4096");
set_conf("osd_bfq_budget_timeout", "1");
create_queue();
// a single backlogged stream: every timeout expiry must re-tag the
// stream and continue in FIFO order with adapted budgets
for (unsigned i = 100; i < 112; ++i) {
q->enqueue(create_item(i, 1, SchedulerClass::client, 1));
}
for (unsigned i = 100; i < 112; ++i) {
if (i % 3 == 0) {
std::this_thread::sleep_for(std::chrono::milliseconds(2));
}
auto r = get_item(q->dequeue());
ASSERT_EQ(i, r.get_map_epoch());
}
ASSERT_TRUE(q->empty());
}
TEST_F(BfqSchedulerTest, TestRuntimeWeightChange) {
set_conf("osd_bfq_max_budget", "16384");
set_conf("osd_bfq_min_cost", "4096");
set_conf("osd_bfq_budget_timeout", "100000");
create_queue();
q->set_pool_streams({
{1, bfq_stream_t::client_block},
{2, bfq_stream_t::client_object}
});
constexpr unsigned per_stream = 800;
for (unsigned i = 0; i < per_stream; ++i) {
q->enqueue(create_item(i, 1, SchedulerClass::client, 1));
q->enqueue(create_item(i, 2, SchedulerClass::client, 2));
}
// equal weights: roughly even split
std::map<uint64_t, unsigned> before;
for (unsigned i = 0; i < 100; ++i) {
++before[get_item(q->dequeue()).get_owner()];
}
ASSERT_NEAR(50, before[1], 15);
// raise block weight at runtime; the observer marks the config
// dirty and the next dequeue folds it in at round re-tag time
set_conf("osd_bfq_client_block_weight", "300");
g_ceph_context->_conf.apply_changes(nullptr);
std::map<uint64_t, unsigned> after;
for (unsigned i = 0; i < 400; ++i) {
ASSERT_FALSE(q->empty());
++after[get_item(q->dequeue()).get_owner()];
}
ASSERT_NEAR(300, after[1], 40);
ASSERT_NEAR(100, after[2], 40);
}
// ---- bfq_detail::BfqServiceTree unit tests ----
using bfq_detail::BfqServiceTree;
TEST(BfqServiceTree, ActivationAndBackShift) {
BfqServiceTree t(2);
ASSERT_FALSE(t.has_active());
ASSERT_EQ(std::nullopt, t.select());
t.activate(0, 1000, 100); // S=0, F=10
t.activate(1, 1000, 100); // S=0, F=10
ASSERT_TRUE(t.has_active());
ASSERT_DOUBLE_EQ(0.0, t.entity(0).start);
ASSERT_DOUBLE_EQ(10.0, t.entity(0).finish);
// tie on F: first index wins
auto sel = t.select();
ASSERT_TRUE(sel);
ASSERT_EQ(0u, *sel);
t.charge(500); // V = 500/200 = 2.5
// entity 0 consumed only half its allotment: back-shift F to 5,
// re-tag S=5, F=5+10=15
t.expire(0, 500, 1000, 100, true);
ASSERT_DOUBLE_EQ(5.0, t.entity(0).start);
ASSERT_DOUBLE_EQ(15.0, t.entity(0).finish);
// entity 1 (F=10, eligible) now beats entity 0 (F=15)
sel = t.select();
ASSERT_TRUE(sel);
ASSERT_EQ(1u, *sel);
}
TEST(BfqServiceTree, EligibilityGate) {
BfqServiceTree t(2);
t.activate(0, 1000, 10); // S=0, F=100 (light weight)
t.activate(1, 100, 100); // S=0, F=1
auto sel = t.select();
ASSERT_TRUE(sel);
ASSERT_EQ(1u, *sel);
t.charge(100); // V = 100/110 ~ 0.909
t.expire(1, 100, 100, 100, true); // F1=1 -> S1=1, F1=2
// entity 1 has the smaller F (2 < 100) but S1=1 > V, so it is not
// eligible; WF2Q+ must pick entity 0 instead of letting 1 run ahead
sel = t.select();
ASSERT_TRUE(sel);
ASSERT_EQ(0u, *sel);
// when only ineligible entities remain, V jumps forward
t.expire(0, 1000, 1000, 10, false); // deactivate 0
sel = t.select();
ASSERT_TRUE(sel);
ASSERT_EQ(1u, *sel);
// full idle renormalizes the tree
t.expire(1, 100, 100, 100, false);
ASSERT_FALSE(t.has_active());
ASSERT_DOUBLE_EQ(0.0, t.get_vtime());
ASSERT_DOUBLE_EQ(0.0, t.entity(0).finish);
}
TEST(BfqServiceTree, WeightedShare) {
BfqServiceTree t(2);
t.activate(0, 1000, 200);
t.activate(1, 1000, 100);
std::array<unsigned, 2> rounds = {0, 0};
for (unsigned i = 0; i < 300; ++i) {
auto sel = t.select();
ASSERT_TRUE(sel);
++rounds[*sel];
t.charge(1000);
t.expire(*sel, 1000, 1000, *sel == 0 ? 200 : 100, true);
}
// weights 200:100 with equal allotments -> 2:1 round share
ASSERT_NEAR(200, rounds[0], 10);
ASSERT_NEAR(100, rounds[1], 10);
}

View File

@ -0,0 +1,90 @@
// -*- mode:C++; tab-width:8; c-basic-offset:2; indent-tabs-mode:nil -*-
// vim: ts=8 sw=2 sts=2 expandtab
/*
* Isolation assertions for the bfq op scheduler, built on the
* scheduler_bench.h harness. The harness paces in wall time, which
* is unfit for shared CI executors, so these tests SKIP unless
* CEPH_TEST_SCHEDULER_ISOLATION is set in the environment. Bounds
* are deliberately loose even then. The full comparative study lives
* in ceph_bench_op_scheduler.
*/
#include <cstdlib>
#include "gtest/gtest.h"
#include "global/global_context.h"
#include "global/global_init.h"
#include "common/common_init.h"
#include "scheduler_bench.h"
using namespace scheduler_bench;
int main(int argc, char **argv) {
std::vector<const char*> args(argv, argv+argc);
auto cct = global_init(nullptr, args, CEPH_ENTITY_TYPE_OSD,
CODE_ENVIRONMENT_UTILITY,
CINIT_FLAG_NO_DEFAULT_CONFIG_FILE);
common_init_finish(g_ceph_context);
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
namespace {
constexpr double kRate = 100e6; // simulated device, bytes/sec
const std::unordered_map<int64_t, bfq_stream_t> kPoolMap = {
{1, bfq_stream_t::client_block},
{2, bfq_stream_t::client_object},
};
}
// wall-clock pacing is unreliable on shared CI executors; opt in
#define REQUIRE_ISOLATION_ENV() \
do { \
if (!std::getenv("CEPH_TEST_SCHEDULER_ISOLATION")) { \
GTEST_SKIP() \
<< "set CEPH_TEST_SCHEDULER_ISOLATION=1 to run " \
"wall-clock isolation checks"; \
} \
} while (0)
// A backlogged rbd-pool victim must hold ~its weight share (50% at
// equal weights) even when the rgw-pool aggressor fans out over 16
// client sessions. This is the property wpq (per-owner round robin)
// and mclock (single dmclock client for all client ops) structurally
// dilute with session count.
TEST(BfqIsolation, ShareUnderSessionScaling) {
REQUIRE_ISOLATION_ENV();
std::vector<StreamSpec> specs = {
{.name = "victim", .pool = 1, .first_owner = 1, .num_owners = 1},
{.name = "aggr", .pool = 2, .first_owner = 100, .num_owners = 16},
};
auto r = run_cell(g_ceph_context, op_queue_type_t::BfqScheduler,
specs, kPoolMap, kRate, 2.0, 0.5);
const double share = r.streams.at("victim").share;
EXPECT_GT(share, 0.35) << "victim diluted by aggressor sessions";
EXPECT_LT(share, 0.65) << "victim over-served";
}
// A paced victim (20% of device rate, under its fair share) must keep
// its offered throughput with queueing delay bounded by budget
// rotation, despite a saturating multi-session aggressor.
TEST(BfqIsolation, PacedVictimServiced) {
REQUIRE_ISOLATION_ENV();
std::vector<StreamSpec> specs = {
{.name = "victim", .pool = 1, .first_owner = 1, .num_owners = 1,
.offered_ops_per_sec = kRate * 0.2 / 65536.0},
{.name = "aggr", .pool = 2, .first_owner = 100, .num_owners = 8},
};
auto r = run_cell(g_ceph_context, op_queue_type_t::BfqScheduler,
specs, kPoolMap, kRate, 2.0, 0.5);
const auto &victim = r.streams.at("victim");
// offered 20 MB/s; allow generous scheduling/pacing slack
EXPECT_GT(victim.mbps, 12.0) << "victim throughput collapsed";
EXPECT_LT(victim.p99_ms, 500.0) << "victim latency unbounded";
}

View File

@ -0,0 +1,186 @@
// -*- mode:C++; tab-width:8; c-basic-offset:2; indent-tabs-mode:nil -*-
// vim: ts=8 sw=2 sts=2 expandtab
/*
* ceph_bench_op_scheduler
*
* Isolation micro benchmarks comparing the OSD op schedulers (wpq,
* mclock_scheduler, bfq) on identical synthetic workloads with a
* simulated device drain rate. See scheduler_bench.h for the
* harness; doc/dev/osd_internals/bfq_scheduler.rst for context.
*
* Usage:
* ceph_bench_op_scheduler [--rate-mb N] [--secs S] [--csv FILE]
* [--scenario share|latency|recovery|all]
*/
#include <cstdio>
#include <cstring>
#include <fstream>
#include <string>
#include <vector>
#include "global/global_context.h"
#include "global/global_init.h"
#include "common/common_init.h"
#include "scheduler_bench.h"
using namespace scheduler_bench;
namespace {
struct Options {
double rate_mb = 100.0;
double secs = 3.0;
double warmup = 0.5;
std::string scenario = "all";
std::string csv_path;
};
const std::vector<op_queue_type_t> kTypes = {
op_queue_type_t::WeightedPriorityQueue,
op_queue_type_t::mClockScheduler,
op_queue_type_t::BfqScheduler,
};
const std::unordered_map<int64_t, bfq_stream_t> kPoolMap = {
{1, bfq_stream_t::client_block},
{2, bfq_stream_t::client_object},
};
std::ofstream csv;
void report_cell(const std::string &scenario, const std::string &cell,
op_queue_type_t type,
const std::vector<StreamSpec> &specs,
const CellResult &r)
{
for (const auto &s : specs) {
const auto &sr = r.streams.at(s.name);
std::printf(" %-16s %-14s %8.1f %7.3f %8.1f %9.1f\n",
std::string(get_op_queue_type_name(type)).c_str(),
s.name.c_str(), sr.mbps, sr.share, sr.p50_ms, sr.p99_ms);
if (csv.is_open()) {
csv << scenario << ',' << cell << ','
<< get_op_queue_type_name(type) << ',' << s.name << ','
<< sr.ops << ',' << sr.bytes << ',' << sr.share << ','
<< sr.mbps << ',' << sr.p50_ms << ',' << sr.p99_ms << '\n';
}
}
}
void run_matrix(const Options &opt, const std::string &scenario,
const std::string &cell,
const std::vector<StreamSpec> &specs)
{
std::printf("--- %s / %s (device %.0f MB/s, %.1fs)\n",
scenario.c_str(), cell.c_str(), opt.rate_mb, opt.secs);
std::printf(" %-16s %-14s %8s %7s %8s %9s\n",
"scheduler", "stream", "MB/s", "share", "p50ms", "p99ms");
for (auto type : kTypes) {
auto r = run_cell(g_ceph_context, type, specs, kPoolMap,
opt.rate_mb * 1e6, opt.secs, opt.warmup);
report_cell(scenario, cell, type, specs, r);
}
std::printf("\n");
}
// A1: both streams saturating; the aggressor fans out over K client
// sessions. Isolation == the victim's share is insensitive to K.
void scenario_share(const Options &opt)
{
for (unsigned k : {1u, 4u, 16u}) {
std::vector<StreamSpec> specs = {
{.name = "victim_rbd", .pool = 1, .first_owner = 1, .num_owners = 1},
{.name = "aggr_rgw", .pool = 2, .first_owner = 100, .num_owners = k},
};
run_matrix(opt, "share", "sessions=" + std::to_string(k), specs);
}
}
// A2: paced victim (20% of device rate) vs a saturating 8-session
// aggressor. Isolation == the victim keeps its offered throughput
// with bounded queueing delay.
void scenario_latency(const Options &opt)
{
const double victim_ops = opt.rate_mb * 1e6 * 0.2 / 65536.0;
std::vector<StreamSpec> specs = {
{.name = "victim_rbd", .pool = 1, .first_owner = 1, .num_owners = 1,
.offered_ops_per_sec = victim_ops},
{.name = "aggr_rgw", .pool = 2, .first_owner = 100, .num_owners = 8},
};
run_matrix(opt, "latency", "victim=20%,sessions=8", specs);
}
// B: saturating client stream vs saturating recovery stream (1M
// chunks, DEGRADED priority encoding).
void scenario_recovery(const Options &opt)
{
std::vector<StreamSpec> specs = {
{.name = "client_rbd", .pool = 1, .first_owner = 1, .num_owners = 2},
{.name = "recovery",
.klass = SchedulerClass::background_recovery,
.pool = 9, .first_owner = 500, .num_owners = 1,
.op_size = 1048576, .priority = 10, .backlog_per_owner = 16},
};
run_matrix(opt, "recovery", "client-vs-recovery", specs);
}
} // anonymous namespace
int main(int argc, char **argv)
{
std::vector<const char*> args(argv, argv + argc);
auto cct = global_init(nullptr, args, CEPH_ENTITY_TYPE_OSD,
CODE_ENVIRONMENT_UTILITY,
CINIT_FLAG_NO_DEFAULT_CONFIG_FILE);
common_init_finish(g_ceph_context);
Options opt;
for (size_t i = 1; i < args.size(); ++i) {
auto need_val = [&](const char *flag) -> const char * {
if (i + 1 >= args.size()) {
std::fprintf(stderr, "%s requires a value\n", flag);
exit(1);
}
return args[++i];
};
if (!std::strcmp(args[i], "--rate-mb")) {
opt.rate_mb = std::stod(need_val("--rate-mb"));
} else if (!std::strcmp(args[i], "--secs")) {
opt.secs = std::stod(need_val("--secs"));
} else if (!std::strcmp(args[i], "--csv")) {
opt.csv_path = need_val("--csv");
} else if (!std::strcmp(args[i], "--scenario")) {
opt.scenario = need_val("--scenario");
}
}
opt.warmup = std::min(opt.warmup, opt.secs / 4);
// calibrate mclock's capacity model to the simulated device so we
// benchmark mclock, not a misconfigured mclock
g_ceph_context->_conf.set_val(
"osd_mclock_max_sequential_bandwidth_ssd",
std::to_string(static_cast<uint64_t>(opt.rate_mb * 1e6)));
g_ceph_context->_conf.set_val(
"osd_mclock_max_capacity_iops_ssd",
std::to_string(opt.rate_mb * 1e6 / 65536.0));
if (!opt.csv_path.empty()) {
csv.open(opt.csv_path);
csv << "scenario,cell,scheduler,stream,ops,bytes,share,mbps,"
"p50_ms,p99_ms\n";
}
if (opt.scenario == "all" || opt.scenario == "share") {
scenario_share(opt);
}
if (opt.scenario == "all" || opt.scenario == "latency") {
scenario_latency(opt);
}
if (opt.scenario == "all" || opt.scenario == "recovery") {
scenario_recovery(opt);
}
return 0;
}

View File

@ -0,0 +1,222 @@
// -*- mode:C++; tab-width:8; c-basic-offset:2; indent-tabs-mode:nil -*-
// vim: ts=8 sw=2 sts=2 expandtab
/*
* Shared harness for op-scheduler isolation micro benchmarks.
*
* Drives synthetic per-stream workloads through any OpScheduler
* implementation in-process, pacing dequeues at a simulated device
* rate so that saturation dynamics (and therefore isolation) are
* meaningful. Runs in real time because mclock's dmclock tags are
* wall-clock based; bfq and wpq are indifferent.
*/
#pragma once
#include <algorithm>
#include <chrono>
#include <cstdint>
#include <map>
#include <memory>
#include <string>
#include <thread>
#include <unordered_map>
#include <vector>
#include "common/Clock.h"
#include "common/ceph_context.h"
#include "osd/scheduler/BfqScheduler.h"
#include "osd/scheduler/OpScheduler.h"
#include "osd/scheduler/OpSchedulerItem.h"
namespace scheduler_bench {
using namespace ceph::osd::scheduler;
struct MockItem : public PGOpQueueable {
SchedulerClass klass;
MockItem(spg_t pgid, SchedulerClass k)
: PGOpQueueable(pgid), klass(k) {}
std::ostream &print(std::ostream &rhs) const final { return rhs; }
std::string print() const final { return std::string(); }
std::optional<OpRequestRef> maybe_get_op() const final {
return std::nullopt;
}
SchedulerClass get_scheduler_class() const final { return klass; }
void run(OSD *osd, OSDShard *sdata, PGRef &pg,
ThreadPool::TPHandle &handle) final {}
};
struct StreamSpec {
std::string name;
SchedulerClass klass = SchedulerClass::client;
int64_t pool = 1;
uint64_t first_owner = 1; ///< owners model client sessions
unsigned num_owners = 1;
uint64_t op_size = 65536; ///< item cost in bytes
unsigned priority = 63; ///< osd_client_op_priority default
double offered_ops_per_sec = 0; ///< 0 => saturating
unsigned backlog_per_owner = 64; ///< target queue depth when saturating
};
struct StreamResult {
uint64_t ops = 0;
uint64_t bytes = 0;
double share = 0; ///< of post-warmup dequeued bytes
double mbps = 0;
double p50_ms = 0;
double p99_ms = 0;
};
struct CellResult {
std::map<std::string, StreamResult> streams;
double total_mbps = 0;
};
inline double percentile(std::vector<double> &v, double p)
{
if (v.empty()) {
return 0;
}
std::sort(v.begin(), v.end());
return v[std::min(v.size() - 1, static_cast<size_t>(p * v.size()))];
}
/**
* Run one benchmark cell: a fresh scheduler of the given type, the
* given streams, a simulated device draining rate_bytes_per_sec, for
* duration_sec of wall time. Samples from the first warmup_sec are
* discarded.
*/
inline CellResult run_cell(
CephContext *cct,
op_queue_type_t type,
const std::vector<StreamSpec> &specs,
const std::unordered_map<int64_t, bfq_stream_t> &pool_map,
double rate_bytes_per_sec,
double duration_sec,
double warmup_sec)
{
using steady = std::chrono::steady_clock;
auto sched = make_scheduler(cct, 0, 1, 0, false /*is_rotational*/,
"bluestore", type, 196 /*cutoff*/);
if (type == op_queue_type_t::BfqScheduler) {
auto pm = pool_map;
static_cast<BfqScheduler*>(sched.get())->set_pool_streams(std::move(pm));
}
std::unordered_map<uint64_t, size_t> owner_stream;
std::unordered_map<uint64_t, unsigned> owner_queued;
for (size_t i = 0; i < specs.size(); ++i) {
for (unsigned o = 0; o < specs[i].num_owners; ++o) {
owner_stream[specs[i].first_owner + o] = i;
owner_queued[specs[i].first_owner + o] = 0;
}
}
std::vector<uint64_t> bytes(specs.size(), 0), ops(specs.size(), 0);
std::vector<std::vector<double>> lat(specs.size());
std::vector<double> credit(specs.size(), 0.0);
std::vector<unsigned> paced_rr(specs.size(), 0);
epoch_t epoch = 1;
auto enqueue_one = [&](size_t si, uint64_t owner) {
const auto &s = specs[si];
sched->enqueue(OpSchedulerItem(
std::make_unique<MockItem>(spg_t(pg_t(0, s.pool)), s.klass),
static_cast<int>(s.op_size), s.priority, ceph_clock_now(), owner,
epoch++));
++owner_queued[owner];
};
const auto t0 = steady::now();
const auto t_warm =
t0 + std::chrono::duration_cast<steady::duration>(
std::chrono::duration<double>(warmup_sec));
const auto t_end =
t0 + std::chrono::duration_cast<steady::duration>(
std::chrono::duration<double>(duration_sec));
auto last = t0;
double tokens = 0;
const double burst_cap = rate_bytes_per_sec / 4;
while (true) {
const auto now = steady::now();
if (now >= t_end) {
break;
}
const double dt = std::chrono::duration<double>(now - last).count();
last = now;
tokens = std::min(tokens + rate_bytes_per_sec * dt, burst_cap);
// load generators
for (size_t i = 0; i < specs.size(); ++i) {
const auto &s = specs[i];
if (s.offered_ops_per_sec > 0) {
credit[i] = std::min(credit[i] + s.offered_ops_per_sec * dt, 1000.0);
while (credit[i] >= 1.0) {
enqueue_one(i, s.first_owner + (paced_rr[i]++ % s.num_owners));
credit[i] -= 1.0;
}
} else {
for (unsigned o = 0; o < s.num_owners; ++o) {
const uint64_t owner = s.first_owner + o;
while (owner_queued[owner] < s.backlog_per_owner) {
enqueue_one(i, owner);
}
}
}
}
// simulated device drain
bool progressed = false;
while (tokens > 0 && !sched->empty()) {
WorkItem wi = sched->dequeue();
if (std::holds_alternative<double>(wi)) {
// mclock says "not ready until later"; let wall time advance
break;
}
auto &item = std::get<OpSchedulerItem>(wi);
const uint64_t owner = item.get_owner();
const size_t si = owner_stream.at(owner);
if (owner_queued[owner] > 0) {
--owner_queued[owner];
}
tokens -= static_cast<double>(item.get_cost());
progressed = true;
if (now >= t_warm) {
bytes[si] += item.get_cost();
++ops[si];
const utime_t sojourn = ceph_clock_now() - item.get_start_time();
lat[si].push_back(sojourn.to_nsec() / 1e6);
}
}
if (!progressed) {
std::this_thread::sleep_for(std::chrono::microseconds(200));
}
}
CellResult result;
const double measured_sec = duration_sec - warmup_sec;
uint64_t total_bytes = 0;
for (auto b : bytes) {
total_bytes += b;
}
for (size_t i = 0; i < specs.size(); ++i) {
StreamResult r;
r.ops = ops[i];
r.bytes = bytes[i];
r.share = total_bytes ? static_cast<double>(bytes[i]) / total_bytes : 0;
r.mbps = bytes[i] / 1e6 / measured_sec;
r.p50_ms = percentile(lat[i], 0.50);
r.p99_ms = percentile(lat[i], 0.99);
result.streams[specs[i].name] = r;
}
result.total_mbps = total_bytes / 1e6 / measured_sec;
return result;
}
} // namespace scheduler_bench

View File

@ -0,0 +1,183 @@
#!/usr/bin/env bash
#
# Tier-2 op scheduler isolation benchmark: drives real rados bench
# workloads through a single-OSD vstart cluster, cycling osd_op_queue
# across schedulers. The tier-1 in-process companion is
# ceph_bench_op_scheduler; this script validates that dispatch-level
# isolation survives the full OSD pipeline with BlueStore underneath.
#
# Run from a built build directory:
# ../src/test/osd/vstart_bench_scheduler.sh
#
# Tunables (env):
# SCHEDULERS default "wpq mclock_scheduler bfq"
# SESSIONS aggressor session counts, default "1 4 16"
# SECS victim measurement window per cell, default 12
# BLOCK op size in bytes, default 65536
# AGGR_QD queue depth per aggressor session, default 8
# VICTIM_QD victim queue depth in share cells, default 16
# THROTTLE_BYTES bluestore throttle, default 4194304; 0 disables
#
# THROTTLE_BYTES bounds BlueStore's in-flight window so that, on fast
# devices, contention surfaces at the op queue where the scheduler can
# act on it. Without it a fast NVMe absorbs the offered load below
# the scheduler and every scheduler degenerates to FIFO passthrough --
# the same reason the mclock tuning guide recommends constraining
# bluestore_throttle_bytes.
#
# Scenarios per scheduler:
# share_k<K> victim (rbd pool) saturating vs K saturating rgw
# sessions; isolation == victim MB/s insensitive to K
# latency_qd1 victim at queue depth 1 (latency probe) vs 8
# saturating sessions; isolation == bounded avg/max lat
#
# Results: CSV + per-cell dump_op_pq_state snapshots under
# ./scheduler-isolation-results/
set -uo pipefail
BUILD_DIR=${BUILD_DIR:-$PWD}
SCHEDULERS=${SCHEDULERS:-"wpq mclock_scheduler bfq"}
SESSIONS=${SESSIONS:-"1 4 16"}
SECS=${SECS:-12}
BLOCK=${BLOCK:-65536}
AGGR_QD=${AGGR_QD:-8}
# The victim must be able to consume its fair share: by Little's law
# it needs QD >= share * op_rate * latency, or it self-caps below any
# scheduler's allocation and every scheduler looks identical.
VICTIM_QD=${VICTIM_QD:-64}
THROTTLE_BYTES=${THROTTLE_BYTES:-4194304}
cd "$BUILD_DIR"
CEPH=./bin/ceph
RADOS=./bin/rados
if [ ! -x "$CEPH" ] || [ ! -x "$RADOS" ]; then
echo "run from a built build directory (./bin/ceph missing)" >&2
exit 1
fi
RESULTS_DIR=$BUILD_DIR/scheduler-isolation-results
CSV=$RESULTS_DIR/results.csv
mkdir -p "$RESULTS_DIR"
echo "scheduler,cell,victim_mbps,victim_avg_lat_s,victim_max_lat_s,aggr_mbps_total,victim_share" > "$CSV"
THROTTLE_OPTS=()
if [ "$THROTTLE_BYTES" -gt 0 ]; then
THROTTLE_OPTS=(-o "bluestore_throttle_bytes = $THROTTLE_BYTES"
-o "bluestore_throttle_deferred_bytes = $THROTTLE_BYTES")
fi
start_cluster() {
local sched=$1
../src/stop.sh >/dev/null 2>&1 || true
if ! MON=1 OSD=1 MGR=1 MDS=0 RGW=0 ../src/vstart.sh -n --without-dashboard \
-o "osd_op_queue = $sched" \
-o "osd_op_num_shards = 1" \
-o "osd_pool_default_size = 1" \
-o "osd_pool_default_min_size = 1" \
-o "mon_allow_pool_size_one = true" \
-o "mon_allow_pool_delete = true" \
"${THROTTLE_OPTS[@]}" \
> "$RESULTS_DIR/vstart_$sched.log" 2>&1; then
echo "vstart failed for $sched, see $RESULTS_DIR/vstart_$sched.log" >&2
exit 1
fi
$CEPH osd set noscrub >/dev/null 2>&1
$CEPH osd set nodeep-scrub >/dev/null 2>&1
local active
active=$($CEPH daemon osd.0 config get osd_op_queue 2>/dev/null |
python3 -c 'import json,sys; print(json.load(sys.stdin)["osd_op_queue"])')
if [ "$active" != "$sched" ]; then
echo "expected osd_op_queue=$sched, got '$active'" >&2
exit 1
fi
}
wait_clean() {
for _ in $(seq 90); do
if $CEPH pg stat --format json 2>/dev/null | python3 -c '
import json, sys
states = json.load(sys.stdin)["pg_summary"]["num_pg_by_state"]
total = sum(s["num"] for s in states)
clean = sum(s["num"] for s in states if s["name"] == "active+clean")
sys.exit(0 if total > 0 and total == clean else 1)'; then
return 0
fi
sleep 1
done
echo "timed out waiting for active+clean pgs" >&2
return 1
}
make_pools() {
$CEPH osd pool create victim 32 >/dev/null 2>&1
$CEPH osd pool create aggr 32 >/dev/null 2>&1
$CEPH osd pool application enable victim rbd >/dev/null 2>&1
$CEPH osd pool application enable aggr rgw >/dev/null 2>&1
wait_clean
}
destroy_pools() {
$CEPH osd pool rm victim victim --yes-i-really-really-mean-it >/dev/null 2>&1
$CEPH osd pool rm aggr aggr --yes-i-really-really-mean-it >/dev/null 2>&1
}
# run_cell <scheduler> <aggr_sessions> <victim_qd> <label>
run_cell() {
local sched=$1 k=$2 victim_qd=$3 label=$4
local tmp="$RESULTS_DIR/${sched}_${label}"
mkdir -p "$tmp"
make_pools
local pids=()
for i in $(seq 1 "$k"); do
$RADOS -p aggr bench $((SECS + 25)) write -b "$BLOCK" -t "$AGGR_QD" \
--no-cleanup > "$tmp/aggr_$i.log" 2>&1 &
pids+=($!)
done
sleep 4 # let the aggressor backlog build
$RADOS -p victim bench "$SECS" write -b "$BLOCK" -t "$victim_qd" \
--no-cleanup > "$tmp/victim.log" 2>&1
# snapshot scheduler state while the aggressors are still running
$CEPH daemon osd.0 dump_op_pq_state > "$tmp/pq_state.json" 2>/dev/null || true
kill "${pids[@]}" >/dev/null 2>&1 || true
wait >/dev/null 2>&1 || true
local vbw vavg vmax abw share
vbw=$(awk '/^Bandwidth \(MB\/sec\)/ {print $3}' "$tmp/victim.log")
vavg=$(awk '/^Average Latency\(s\)/ {print $3}' "$tmp/victim.log")
vmax=$(awk '/^Max latency\(s\)/ {print $3}' "$tmp/victim.log")
# killed benches never print a final summary; use each aggressor's
# last per-second progress line ("avg MB/s" column). Approximate:
# their window is slightly wider than the victim's.
abw=$(for f in "$tmp"/aggr_*.log; do
awk '$1 ~ /^[0-9]+$/ && $5 ~ /^[0-9.]+$/ {last=$5}
END {if (last) print last}' "$f"
done | awk '{s += $1} END {printf "%.1f", s}')
share=$(awk -v v="${vbw:-0}" -v a="${abw:-0}" \
'BEGIN {t = v + a; printf "%.3f", (t > 0 ? v / t : 0)}')
echo "$sched,$label,${vbw:-0},${vavg:-0},${vmax:-0},${abw:-0},$share" >> "$CSV"
printf ' %-16s %-12s victim %8s MB/s share %-6s avg %8ss max %8ss (aggr ~%s MB/s)\n' \
"$sched" "$label" "${vbw:-?}" "$share" "${vavg:-?}" "${vmax:-?}" "${abw:-?}"
destroy_pools
}
echo "device: single vstart OSD, 1 op shard, ${BLOCK}B writes"
for sched in $SCHEDULERS; do
echo "=== $sched ==="
start_cluster "$sched"
for k in $SESSIONS; do
run_cell "$sched" "$k" "$VICTIM_QD" "share_k$k"
done
run_cell "$sched" 8 1 "latency_qd1"
done
../src/stop.sh >/dev/null 2>&1 || true
echo
echo "results: $CSV"
column -s, -t < "$CSV"