mirror of
https://github.com/kvcache-ai/AgentENV.git
synced 2026-11-05 01:17:36 +00:00
[GH-ISSUE #47] feat: support weight-based I/O scheduling across sandboxes at ublk layer #23
Labels
No labels
bug
documentation
enhancement
enhancement
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
kvcache-ai/AgentENV#23
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Originally created by @rogeroger-yu on GitHub (Jul 28, 2026).
Original GitHub issue: https://github.com/kvcache-ai/AgentENV/issues/47
Summary
Add a node-wide weighted fair I/O scheduler at the ublk device layer. Each sandbox's block device is assigned a weight, and the total node bandwidth budget is distributed proportionally among active devices. The scheduler is work-conserving: idle sandboxes' bandwidth is automatically redistributed to active ones.
Motivation
While absolute rate limiting (Firecracker TokenBucket) prevents starvation, it wastes bandwidth when sandboxes are idle. A weight-based scheduler enables:
Proposed Solution
Implement
IoWeightScheduleras a global singleton in the ublk daemon process:device_weight / sum(active_weights)refill_interval_ms(default 100ms), tokens are replenishedhandle_io_requestcallsacquire(bytes)before each I/O operationConfiguration
Test Results
@yingdi-shan commented on GitHub (Jul 28, 2026):
Thanks for the proposal and benchmark data. My main concern is the placement of the scheduler.
At the ublk boundary, we only see the logical byte count requested by the guest. That does not necessarily represent the node resource actually consumed. Different ublk devices can resolve to the same immutable OverlayBD lower layer and shared cache entry. The same-sized request may be:
For example, one device may trigger a remote fetch and warm a shared layer, while another device later reads the same data from cache. Charging both devices the same number of tokens at the ublk layer does not reflect their disk or network consumption. Consequently, per-device ublk limiting controls guest-visible block throughput, but it can drift significantly from weighted sharing of the node’s actual storage and network resources.
I suggest first adding per-device metrics for logical ublk bytes, local backend bytes, and remote backend bytes. That will show how much these values diverge under realistic shared-image workloads before we choose the scheduling boundary.
@rogeroger-yu commented on GitHub (Jul 29, 2026):
Thanks for the detailed feedback — this is a valid concern.
You're right that at the ublk boundary, logical bytes don't always correlate with actual backend resource consumption, especially under shared-image workloads with mixed cache/remote hits.
I'll take the metrics-first approach:
Add per-device I/O source metrics in the overlaybd read path, breaking down each request into:
logical_bytes: guest-requested size at ublk layerlocal_bytes: served from local file (committed layers + upper)cache_bytes: served from block cache / page cacheremote_bytes: fetched from registry or P2PExpose via Prometheus on the existing daemon metrics endpoint (
0.0.0.0:9103), labeled bydev_id.Run a shared-image benchmark — multiple sandboxes from the same base image doing concurrent reads (cold start + warm steady state) — and report the divergence between logical and actual I/O.
Once we have real data showing how much these values drift, we can make an informed decision on where to place the scheduling boundary (ublk entry vs. backend I/O vs. a hybrid approach).
I'll update PR #49 to start with the metrics instrumentation instead of the scheduler, and share the benchmark results here.
@yingdi-shan commented on GitHub (Jul 29, 2026):
Thank you so much!
@rogeroger-yu commented on GitHub (Jul 30, 2026):
Follow-up: per-device logical / local / remote byte metrics + measured divergence
Per review feedback, I added the three per-device metrics @yingdi-shan asked for and
measured how much they diverge under a realistic shared-image workload before we lock
in the scheduling boundary.
New metrics
agentenv_ublk_device_read_bytes_total{dev_id}— logical bytes read by the guest (already existed)agentenv_overlaybd_device_local_read_bytes_total{dev_id}— bytes served by the LocalFile backendagentenv_overlaybd_device_remote_read_bytes_total{dev_id}— bytes served by the registryfs backenddev_idis threaded throughIoCtxacross ublk → ImageFile → LSMT → leaf backends(LocalFile / registryfs), so reads are attributed per device without touching the
backends' existing global metrics.
Method
Same overlaybd-native image (8 compressed zfile layers, rootfs ~700 MB) and the same
read workload (
find /usr -type f | xargs cat, ~127 MiB across ~5,200 reads). Onedevice measured per backend state, each after
drop_caches:Results
Conclusion: divergence is significant — the current ublk-layer scheduling approach needs adjustment
hit, backend ≈ logical (1.0x); on a remote/cold read, backend I/O is 1.5x (bulk
sequential) to ~5x (cold boot, small random metadata reads) the logical bytes, due
to block-granularity fetches + decompression + over-fetch.
node and network cost by up to ~5x for cold/remote tenants, while remaining accurate
for local-cache-hit tenants. Fairness at the logical layer is not the same as fairness
over the physical resource (registry bandwidth / network egress / decompression CPU).
Proposed adjustment directions (for discussion)
remote separately, with a higher weight on remote to reflect network cost), so throttling
tracks real node cost.
and add a separate remote-bandwidth budget in the overlaybd backend, treating cold fetch
as a one-time warmup cost that caching amortizes.
Which direction do we prefer? I'll adjust the implementation once we align.
@drmfsltdoh commented on GitHub (Jul 30, 2026):
It is okay
@yingdi-shan commented on GitHub (Jul 30, 2026):
Before moving forward, we should also account for host page cache. Since multiple sandboxes can share the same block layers, one sandbox may end up populating the cache on behalf of others. The LocalFile metric counts backend requests, but not all of those requests translate into physical disk I/O.
Kernel readahead adds another layer of complexity on top of page caching. One potential approach is to run each device's or tenant's local-read worker in a separate cgroup, then use cgroup v2's io.stat for physical-read accounting and io.max/io.weight for control — under this model, page-cache hits would naturally incur no disk charge. That said, this approach would likely require a substantial refactor of the current I/O model, so I'm also considering whether a better solution exists.
@rogeroger-yu commented on GitHub (Aug 3, 2026):
Thanks for pushing on this — I ran the boundary validation end-to-end and want to share the raw findings across the approaches I tried.
Test setup: local overlaybd-native image (8 compressed zfile layers), buffered ioEngine (page cache on), guest
ddon the extra drive, host cache dropped between cold runs. Ground truth for "physical disk bytes" is the kernel's own/proc/self/ioread_bytes/write_bytes(the same countercgroup io.statdraws from).Approaches evaluated:
*_local_read_bytes_total{dev_id})*_local_read_seconds{dev_id})dd36.1 MB/s ≈ cold 37.5 MB/s (decompression-bound). Time can't discriminate cache from disk here.RWF_NOWAITin-process cache-residency byte meter (probe cache; miss → real read + charge, hit → free)read_bytes(readahead fills neighbors "for free" to the probe). (3) Read-only:pwritev2(RWF_NOWAIT)returnsENOTSUPon ext4 buffered writes — no write-path analog.io.stat/io.max/io.weightMeasured
RWF_NOWAITcharged/actual (kernelread_bytes): sequential+readahead 34.1%, sequential+FADV_RANDOM50.3%, random 64.1% — all material undercounts. On the write side there's no in-process analog at all (pwritev2(RWF_NOWAIT)→ENOTSUP), and physical write cost is decoupled from thewrite()call anyway (writeback + journal + LSMT amplification).Question for the community: given that physical disk-I/O weighting inherently spans both reads and writes, and cgroup v2 is the only signal above that faithfully covers both — is there an existing plan or preferred direction for adopting cgroup v2 (
io.weight/io.max) here? I'd like to align on scope before investing in the per-device cgroup/worker refactor.@yingdi-shan commented on GitHub (Aug 4, 2026):
Refactoring the I/O model around cgroups carries real risk and will likely require substantial testing before it can be safely enabled. That said, I believe this work is necessary to properly support multi-tenancy and enforce per-tenant quotas. Given the scope, I'd suggest draft an RFC first to align on the approach before proceeding.