In io_model.py, the AbstractDevice class has an optimization to avoid using its _bandwidth_manager() function.
The point of the bandwidth manager is to maintain a set of active requests, predict the next completion time, and credit each request in the active queue with processed time.
This largely simulates PS queue system, where each request is served in parallel by a factor of max concurrency that the user specifies in their OPAL config.
There is an optimization to avoid the bandwidth manager: if the the request is small enough such that the product of the bandwidth and the device latency is bigger than this it means that the request will be gated by latency, so it skips the bandwidth manager. This quantity is called min_bytes_per_latency and expresses the amount of bytes you could move in a latency period assuming you had access to the device's full bandwidth.
In AbstractDevice._process_request_io
request.arrival_time = self.simpy_env.now
# self.log.debug(f"{self.simpy_env.now} IORequest.{request.id} arrives (size={request.size})")
# Wait for a concurrency slot
with self.concurrency.request() as req_slot:
# self.log.debug(f"{self.simpy_env.now} IORequest[{request.id}] queued")
yield req_slot # WAIT IN QUEUE
# Enforce minimum latency and check if request can complete within latency period
if self.latency_per_request_sec > 0:
# self.log.debug(f"{self.simpy_env.now} IORequest[{request.id}] starts latency {self.latency_per_request_sec}")
yield self.simpy_env.timeout(self.latency_per_request_sec)
If you receive a concurrency slot, and your request is individually small, you get to skip the bandwidth manager.
The issue is that this result starts to break down if there are multiple requests in the same latency window.
For a single request, this works out well, because the model assumes that the single request occupies all of the device bandwidth over the latency window.
There are a few cases where this produces the wrong result:
- Assume that initially all concurrency slots are empty. Assume that a batch size of
B requests such that B <= C where is C is concurrency setting are enqueued. Assume that each request in the batch is bigger than the min_bytes_per_latency. The code will yield B completion time events of simpy_env.now + latency_per_request_sec. This means it is possible for up to min_bytes_per_latency * C requests or C * bandwidth * latency_per_request_sec to compete in a time of latency_per_request_sec. This effectively allows you to achieve a max bandwidth of C * bandwidth (the latency_per_request_sec terms cancel), thus giving you C times more bandwidth than you actually configured!
- Assume that the device has
C-1 concurrency slots occupied, making your request the Cth request, and assume that the previous C-1 requests were slightly larger than min_bytes_per_latency (let us say 1 byte more), arrived roughly all at the same time, and that they are in the bandwidth manager. The problem with this situation is that the Cth request will given a sojourn time as if it had access to all of the bandwidth in the latency period because it skips the bandwidth manager code path. The Cth request finishes at time now + latency_per_request_sec, whereas the other requests all finish at (C-1) *min_bytes_per_latency / B = (C-1) * latency_per_request_sec. Add these parts up, and you get C * min_bytes_per_latency * latency_per_request_sec all finish in that latency_per_request_sec window, so the result of getting C* bandwidth holds true in both cases.
I had Claude code produce some stubs with custom abstract devices to show that the C * bandwidth result is achievable in real code.
There is another flavor of bug here, and that is the Bandwidth manager gives new arrivals retroactive credit.
Let's say request is interrupted and added self.active_requests.
The issue occurs from this block of code in _bandwidth_manager
for req_id, req_data in self.active_requests.items():
req_data["remaining"] -= bytes_processed_per_req
# Check if request completed
if req_data["remaining"] <= 0:
request = req_data["request"]
request.finish_time = self.simpy_env.now
request.event.succeed(request)
completed_requests.append(req_id)
# self.log.debug(f"{self.simpy_env.now} IORequest[{req_id}] completed")
This code block runs after the device is interrupted or the first request completes, and bytes_processed_per_req is computed prior to the interruption. Prior to that interruption, the new request is inserted into self.active_requests`
Thus, it would be possible for the request to arrive just as the next period would complete, and the actual average bandwidth observed would exceed configured bandwidth.
Normally we have bw_per_req = self.bytes_per_sec / num_active. Let's say there are C-1 requests active that can be interrupted, which means bw_per_req = B / (C-1).
We have all of the C-1 requests completing at time T. If that final request interrupts after time T, C * B / (C-1) * T bytes moved in time T.
Without the bug, this should have been (C-1) * B / (C-1) * T) which simplifies to B * T bytes moved over time T.
Because of the retroactive credit, Bandwidth in that time becomes (C) / (C-1) * B, which is strictly greater than B. Thus, like the fast path bug above, configured bandwidth can be exceeded.
In
io_model.py, theAbstractDeviceclass has an optimization to avoid using its_bandwidth_manager()function.The point of the bandwidth manager is to maintain a set of active requests, predict the next completion time, and credit each request in the active queue with processed time.
This largely simulates PS queue system, where each request is served in parallel by a factor of max
concurrencythat the user specifies in their OPAL config.There is an optimization to avoid the bandwidth manager: if the the request is small enough such that the product of the bandwidth and the device latency is bigger than this it means that the request will be gated by latency, so it skips the bandwidth manager. This quantity is called
min_bytes_per_latencyand expresses the amount of bytes you could move in a latency period assuming you had access to the device's full bandwidth.In
AbstractDevice._process_request_ioIf you receive a concurrency slot, and your request is individually small, you get to skip the bandwidth manager.
The issue is that this result starts to break down if there are multiple requests in the same latency window.
For a single request, this works out well, because the model assumes that the single request occupies all of the device bandwidth over the latency window.
There are a few cases where this produces the wrong result:
Brequests such thatB <= Cwhere isCisconcurrencysetting are enqueued. Assume that each request in the batch is bigger than themin_bytes_per_latency. The code will yieldBcompletion time events ofsimpy_env.now + latency_per_request_sec. This means it is possible for up tomin_bytes_per_latency * Crequests orC * bandwidth * latency_per_request_secto compete in a time oflatency_per_request_sec. This effectively allows you to achieve a max bandwidth ofC * bandwidth(thelatency_per_request_secterms cancel), thus giving you C times more bandwidth than you actually configured!C-1concurrency slots occupied, making your request theCthrequest, and assume that the previous C-1 requests were slightly larger thanmin_bytes_per_latency(let us say 1 byte more), arrived roughly all at the same time, and that they are in the bandwidth manager. The problem with this situation is that theCthrequest will given a sojourn time as if it had access to all of the bandwidth in the latency period because it skips the bandwidth manager code path. TheCthrequest finishes at timenow + latency_per_request_sec, whereas the other requests all finish at(C-1) *min_bytes_per_latency / B = (C-1) * latency_per_request_sec. Add these parts up, and you getC * min_bytes_per_latency * latency_per_request_secall finish in thatlatency_per_request_secwindow, so the result of gettingC* bandwidthholds true in both cases.I had Claude code produce some stubs with custom abstract devices to show that the
C * bandwidthresult is achievable in real code.There is another flavor of bug here, and that is the Bandwidth manager gives new arrivals retroactive credit.
Let's say request is interrupted and added
self.active_requests.The issue occurs from this block of code in
_bandwidth_managerThis code block runs after the device is interrupted or the first request completes, and
bytes_processed_per_reqis computed prior to the interruption. Prior to that interruption, the new request is inserted intoself.active_requests`Thus, it would be possible for the request to arrive just as the next period would complete, and the actual average bandwidth observed would exceed configured bandwidth.
Normally we have
bw_per_req = self.bytes_per_sec / num_active. Let's say there areC-1requests active that can be interrupted, which meansbw_per_req = B / (C-1).We have all of the C-1 requests completing at time T. If that final request interrupts after time T,
C * B / (C-1) * Tbytes moved in time T.Without the bug, this should have been
(C-1) * B / (C-1) * T)which simplifies toB * Tbytes moved over time T.Because of the retroactive credit, Bandwidth in that time becomes
(C) / (C-1)* B, which is strictly greater than B. Thus, like the fast path bug above, configured bandwidth can be exceeded.