Skip to content

feat(eloq): key module registry by type and add a configurable visit order - #27

Open
liangjchen wants to merge 2 commits into
masterfrom
configurable-module-visit-order
Open

feat(eloq): key module registry by type and add a configurable visit order#27
liangjchen wants to merge 2 commits into
masterfrom
configurable-module-visit-order

Conversation

@liangjchen

Copy link
Copy Markdown

Background

This change comes out of an extended benchmarking effort on EloqKV: driving read-only and 1:1 read/write workload over 10M keys against a memory-limited node, measuring where throughput and tail latency actually go, and turning what we learned into targeted optimizations. Over that effort throughput improved several-fold and the tail latency that motivated the work fell from seconds to milliseconds. This PR is the brpc half of that work; companion PRs land in eloqstore and tx_service.

The optimization

With ELOQ_MODULE_ENABLED, a brpc worker does not just run bthreads — it also drives the ring listener, the tx service, and EloqStore as modules, cooperatively multiplexed on its own stack in ProcessModulesTask(). Whatever that worker is currently doing, the other roles wait. How often each module is visited per pass is therefore a scheduling decision, and until now it was fixed at "each module once, in whatever order they happened to register".

That order is not optimal. Under read-heavy load the shard accumulates completed IO faster than one visit per pass can drain, so completions wait for the next full rotation. Visiting EloqStore twice per pass — once before and once after the tx service — shortens the gap between an IO completing and the shard draining it.

--module_visit_order makes the order configurable, so this is tunable per deployment rather than baked in. Measured on our internal benchmark rig with --module_visit_order=ring,eloqstore,txservice,eloqstore against the default, alternating between the two configurations and driving load from a separate client host, the ordering is a consistent win on read-heavy traffic:

  • Read-only throughput improves, by a small but repeatable margin that grows with connection count. Every ordered run beat every default run, so the effect is separated from run-to-run spread rather than inferred from a single pair.
  • Read latency improves alongside it, by a comparable relative margin at p99. At the higher connection count the median moves too, not just the tail —which is what the mechanism predicts: the deeper the queue, the more completions are waiting on each pass.
  • Read-write throughput stays within run-to-run variance, so I make no claim there. Its far tail does improve consistently across every round while the median and p99 stay flat. Writes are gated by checkpoint flush and WAL rather than by read-completion drain, so that shape is expected.

Change details

Module identity. register_module() previously assigned the first free slot and unregister_module() compacted the array, so a module's index depended on registration order and shifted whenever another module unregistered. EloqStore restarts once during normal startup, which renumbered the tx service underneath it — the registry only settled at ring/txservice/eloqstore after that shuffle. Indices were unusable for addressing a module.

EloqModule now declares a ModuleTypeRing, TxService, EloqStore —and that type is the module's slot, so every module has a reserved place in the registry. Registration writes its own slot; unregistration clears it in place. A slot therefore always denotes the same kind of module — across a module being absent (RingModule without io_uring) and across a module restarting. Slots may be sparse as a result, which is exactly what makes them stable.

The flag. --module_visit_order takes module names, e.g. ring,eloqstore,txservice,eloqstore. A name may repeat, which drives that module more than once per pass; naming a module that is not registered is harmless. Empty preserves today's behavior exactly: every registered module once, in slot order. The order is resolved once in TaskGroup::init() into an inline
fixed-size array — ProcessModulesTask() runs millions of times a second, so reading it must not cost a thread-safe-static guard check or a heap indirection per pass. An unknown name aborts at startup rather than silently visiting the wrong module.

One consequence. Sparse slots mean a module can no longer be located by counting. ExtThdStart() for modules new to a worker moves into CheckAndUpdateModules(), which already diffs the module set and runs only when that set changes — so this adds nothing to the per-pass path. The old loop indexed registered_modules_ with a population count and would skip, or read past, an empty slot. This is not theoretical: EloqStoreModule::ExtThdStart() binds the worker's shard pointer, so skipping it left that thread-local null and io_uring initialization aborted at startup.

…order

register_module() assigned the first free slot and unregister_module()
compacted the array, so a module's index depended on registration order
and shifted whenever another module unregistered. EloqStore restarts once
during normal startup, which renumbered TxService underneath it: the
registry settled at ring/txservice/eloqstore only after that shuffle.
Indices were therefore unusable for addressing a module.

EloqModule now declares a ModuleType, and the enumerator is the slot:

  kRing = 0, kTxService = 1, kEloqStore = 2

Registration writes its own slot and unregistration clears it in place,
so a slot always denotes the same kind of module -- across a module being
absent (RingModule without io_uring) and across a module restarting.
Slots may therefore be sparse, which is what makes them stable.

On top of that, --module_visit_order takes module names giving the visit
order of one ProcessModulesTask() pass, e.g.

  --module_visit_order=ring,eloqstore,txservice,eloqstore

A name may repeat, which drives that module more than once per pass. The
order is resolved once in TaskGroup::init() into an inline fixed array,
because ProcessModulesTask() runs millions of times a second and must not
pay a guard check or a heap indirection per pass. An unknown name aborts
at startup rather than silently visiting the wrong module. Empty keeps
the previous behavior: every registered module once, in slot order.

Sparse slots also mean a module can no longer be located by counting, so
ExtThdStart() for modules new to a worker moves into
CheckAndUpdateModules(), which already diffs the module set and runs only
when that set changes. The old loop indexed registered_modules_ with a
population count and would skip -- or dereference past -- an empty slot;
EloqStore binds its per-thread shard in ExtThdStart(), so skipping it left
the thread-local shard null and io_uring initialization aborted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

Comment thread src/bthread/eloq_module.h
enum class ModuleType : size_t {
kRing = 0,
kTxService = 1,
kEloqStore = 2,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also need a kMongo module

…ice, EloqStore

Benchmarking the previous default (every module once, in slot order)
against driving EloqStore twice per pass showed the latter consistently
ahead on read-heavy load -- separated from run-to-run spread, with the
margin growing as connection count rises -- and no worse on a mixed
read/write load, where only the far tail moves. A shard accumulates
completed IO faster than one visit per pass can drain, so the second
visit shortens the interval between an IO completing and the shard
draining it.

Make that the built-in default rather than something each deployment has
to discover and opt into. --module_visit_order still overrides it, and
"ring,txservice,eloqstore" restores one visit each.

Naming a module that is not registered stays harmless: slots are keyed by
module type, so an absent module leaves its slot empty rather than
shifting the others, and the visit loop skips it. A deployment on another
storage backend therefore visits Ring and TxService and pays a null check
for the two EloqStore entries; the same holds for Ring without io_uring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants