ieee80211: add AirtimeFairnessQueue, a per-station airtime-fair transmit queue - #1123
Conversation
| if (!fairnessEnabled || gate->getDeficit() >= SIMTIME_ZERO) { | ||
| // Eligible -> serve this station. Airtime-fair keeps the cursor on it so it | ||
| // drains its whole airtime quantum (many small frames) before yielding; the | ||
| // async airtime charge lands before the next pull and eventually closes its | ||
| // gate, at which point it is topped up and rotated. Frame-fair rotates now. | ||
| cursor = fairnessEnabled ? index : (index + 1) % n; |
There was a problem hiding this comment.
🔴 Broadcast and multicast traffic can permanently monopolize the transmit queue and starve all client stations
The station being served keeps its turn indefinitely (cursor = fairnessEnabled ? index : (index + 1) % n; at src/inet/linklayer/ieee80211/mac/queue/AirtimeFairnessScheduler.cc:72) until it is billed for the airtime it used, but group-addressed traffic is never billed, so a steady stream of broadcast/multicast frames blocks every other station forever.
Impact: While broadcast or multicast frames keep arriving, an access point stops sending anything to its individual clients.
Cursor pinning relies on an airtime charge that never arrives for group addresses
The deficit round robin deliberately leaves cursor on the served input so the station can drain its whole quantum; the comment states "the async airtime charge lands before the next pull and eventually closes its gate". Both coordination functions explicitly skip group addresses when reporting airtime (if (!receiver.isMulticast()) in src/inet/linklayer/ieee80211/mac/coordinationfunction/Dcf.cc:251 and src/inet/linklayer/ieee80211/mac/coordinationfunction/Hcf.cc:380), and MacAddress::isMulticast() is also true for the broadcast address.
Ieee80211ReceiverAddressClassifier (src/inet/linklayer/ieee80211/mac/queue/Ieee80211ReceiverAddressClassifier.cc:31) creates a per-receiver branch for the broadcast/multicast address just like for any unicast peer. That branch's gate therefore keeps deficit == 0 forever, is always eligible, and — because the cursor never moves off an eligible, backlogged input — schedulePacket() returns it on every pull for as long as it is backlogged. Unicast branches are never reached.
The same pinning also means any frame that is dequeued but never actually transmitted (e.g. dropped in the recovery procedure) leaves the cursor parked on that station.
Prompt for agents
AirtimeFairnessScheduler::schedulePacket() intentionally keeps the round-robin cursor on the station it just served (fairnessEnabled branch), relying on the asynchronous airtime charge from Dcf/Hcf to eventually drive that station's deficit negative and close its gate. However Dcf::transmissionComplete() and Hcf::transmissionComplete() deliberately do not report airtime for group-addressed (multicast/broadcast) receivers, while Ieee80211ReceiverAddressClassifier still allocates a per-receiver branch for the broadcast/multicast address. That branch's deficit stays at zero forever, so as long as it is backlogged the cursor never leaves it and all unicast stations are starved. Consider one of: charging airtime for group-addressed transmissions as well (attributing them to the group branch), routing all group-addressed frames to a branch that is not subject to the pinning behaviour, or bounding how long the cursor may stay on one gate (e.g. advance the cursor whenever the served gate's deficit did not decrease since it was selected).
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Fixed in 946a8f6198. The cursor now only stays on the served station if its deficit actually moved since the last time that station was served, which covers the unbilled group-address branch and the dequeued-but-never-transmitted case with one rule rather than special-casing group addresses.
Reproduced first: with a saturating multicast flow alongside the three unicast flows, all three stations received 0 packets over 2s while the AP put 7085 multicast frames on air. After the fix, 1508/1508/1506.
| double weight = default(1); // per-station airtime weight; equal weight means equal airtime share | ||
| bool fairnessEnabled = default(true); // when false, degrades to a plain per-station round robin (for OFF/ON contrast) | ||
| string subqueueTypename = default("inet.queueing.queue.PacketQueue"); // NED type of each per-station sub-queue | ||
| dropperClass = default("inet::ieee80211::Ieee80211LongestFlowDropper"); // on overflow, drop the tail frame of the longest per-station backlog |
There was a problem hiding this comment.
🟡 The new queue has no size limit by default, so its documented overload protection never runs
The queue ships with an overflow drop policy but no default limit on how many frames it may hold (dropperClass = default(...) at src/inet/linklayer/ieee80211/mac/queue/AirtimeFairnessQueue.ned:52 without a packetCapacity default), so it grows without bound and the drop-from-longest rule never triggers.
Impact: Replacing the standard pending queue silently removes its 100-frame limit, letting a saturated access point accumulate unbounded backlog and latency.
Inherited default is unlimited, unlike the queue it replaces
CompoundPacketQueueBase.ned:20 defines int packetCapacity = default(-1) (no limit), and AirtimeFairnessQueue.ned does not override it. CompoundPacketQueueBase::isOverloaded() returns false when packetCapacity == -1, so AirtimeFairnessCompoundQueue::pushPacket() never calls the dropper and Ieee80211LongestFlowDropper is dead code in the default configuration.
This contradicts the module documentation ("A shared packetCapacity is enforced across all stations with a drop-from-longest overflow policy ... so a slow station's backlog cannot lock the others out"). The module it is meant to replace, PendingQueue.ned:24, defaults to packetCapacity = 100, so a typename override changes an bounded queue into an unbounded one.
| dropperClass = default("inet::ieee80211::Ieee80211LongestFlowDropper"); // on overflow, drop the tail frame of the longest per-station backlog | |
| packetCapacity = default(100); // shared capacity across all per-station sub-queues | |
| dropperClass = default("inet::ieee80211::Ieee80211LongestFlowDropper"); // on overflow, drop the tail frame of the longest per-station backlog |
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Fixed in bb0854334a — packetCapacity = default(100), matching PendingQueue, which this queue replaces by typename override.
|
This one seems to be mostly ok, except for the addInput method on the IDynamicInputScheduler, should use a signal listener on gate connected. I'm not sure about the frameTransmittedAirtime signal, is subscribing happens on the same node that is emitting the signal? |
|
On the Checked on the |
a1a0db5 to
bb08543
Compare
|
The It is folded into Two more changes, from the Devin findings:
One thing to flag for the baselines: this queue's fingerprint moves from Still open, not addressed here: the branch submodules spliced in by |
|
The splice vector naming defect is fixed in the base PR (#1122, What is still open there, and worth knowing for this queue: an ini assignment naming a spliced submodule by its final path is silently ignored, because parameters are finalized under the temporary name as well -- so |
bb08543 to
92bfcdb
Compare
2ad8345 to
47ba427
Compare
cc81132 to
6916dc3
Compare
47ba427 to
ad7b15b
Compare
…atistics On overflow, CompoundPacketQueueBase removed the victim packet with its own removePacket(), which emits packetRemoved, and then dropped it with dropPacket(), which emits packetDropped. The queueLength statistic subtracts both signals, so every overflow drop was subtracted twice and the recorded queue length drifted downwards. The victim was also still owned by the submodule it was queued in, so the delete inside dropPacket() warned about deleting an object owned by another module. Remove the victim directly from the underlying collection -- the submodule it leaves emits its own packetRemoved, which the compound's localSignal() statistics rightly ignore -- and take it before the delete. Affects every compound queue configured with a dropperClass.
6916dc3 to
f706e92
Compare
Adds an Ieee80211AirtimeInd-carrying frameTransmittedAirtime signal, emitted by ~Dcf and ~Hcf from transmissionComplete() for every unicast data/mgmt frame, naming the receiver and the on-air duration. The duration is computed from the mode actually selected for the frame and the frame length, so it is exact rather than estimated, and because it is emitted per completed transmission rather than per queued frame, retransmissions are reported individually. Control frames and group-addressed frames are not reported: they are not attributable to a single peer. This is the input an airtime-fair transmit scheduler needs in order to charge a station for what it actually consumed on the medium.
…duler The two halves of a per-station airtime-fair transmit scheduler, modelled on the Linux mac80211 airtime fairness feature. They form a matched pair: ~AirtimeFairnessGate sits on one station's transmit path and owns that station's airtime deficit and its open/closed eligibility. It learns which receiver it serves from the first frame that passes through it, and subscribes to frameTransmittedAirtime on the containing network interface to charge the deficit from the frame's actual on-air time. This mirrors the way a ~PeriodicGate gates one sub-queue in a ~GatingPriorityQueue, except that eligibility is driven by consumed airtime instead of by a schedule. ~AirtimeFairnessScheduler owns the rotation: it visits the gates in round-robin order, serves the first backlogged station whose gate is still open, and grants an ineligible-but-backlogged station one quantum * weight of credit before moving on. In fair mode the cursor stays on the served station so it drains its whole airtime quantum -- which is what distinguishes airtime fairness from frame fairness when one station's frames take much longer on the air. It stays there only while the station's deficit actually moves between visits, though: a station that is never charged -- a group-address branch, group frames being deliberately unreported since they are not attributable to a single peer, or a station whose dequeued frame was dropped instead of transmitted -- would otherwise park the cursor and starve every other station. A station that is not being billed gets frame fairness rather than the whole medium. Two consequences of gating a queue this way needed explicit handling. A closed gate would hide its backlog from the generic pull interface, so the gate reports the true upstream backlog through ~IPacketCollection regardless of its state, and forwards backlog-change notifications even while closed -- a station can become backlogged while out of credit, and the scheduler must still learn about it in order to top it up. For the same reason the scheduler reports a pullable packet whenever any station is backlogged, even when every gate is momentarily shut, since it can always grant credit. Stations come and go, so the gates are wired to the scheduler as the receivers first appear (by the ~DynamicClassifier of the enclosing queue), not from the NED topology. The scheduler picks such an input up by listening for the POST_MODEL_CHANGE notification of its own input gate being connected, so nothing has to call into it and no contract is needed between it and whatever builds the branch; it finds the airtime gate of the new branch at the far end of the connection path arriving at its input, which may cross a branch compound's boundary. It deliberately does not notify its downstream collector at that point: the branch is still empty and its modules are not initialized yet, and the frame whose arrival created the station notifies through the branch a moment later anyway. With fairnessEnabled = false the gates stay open and the pair degrades to a plain per-station round robin, which is the frame-fair baseline to contrast against.
…policy Two small registered functions that an airtime-fair queue needs to divide traffic into per-station branches and to stay fair under overload. Ieee80211ReceiverAddressClassifier assigns a dense class index per receiver MAC address in first-seen order, so a dynamic classifier can open one branch per destination station. Ieee80211LongestFlowDropper is the overflow policy: it drops the tail frame of the station with the most queued frames, rather than the frame that has just arrived. Under a shared capacity this matters -- a slow station drains slowly and would otherwise fill the whole queue and lock the other stations out, defeating the fairness the scheduler provides. It is the drop-from-longest rule used by FQ-CoDel, for the same reason.
…mit queue Assembles the parts into a drop-in replacement for the pendingQueue of an ~Edcaf or ~Dcaf: a ~DynamicClassifier routes each frame to a per-receiver sub-queue, each sub-queue is followed by an ~AirtimeFairnessGate, and an ~AirtimeFairnessScheduler serves the gates so that every backlogged station gets an equal share of on-air time rather than an equal share of frames. This addresses the downlink form of the 802.11 rate anomaly: when an access point saturates a mix of fast and slow clients, a FIFO -- or even a frame-fair round robin -- lets the slow client's long frames drag the fast clients down toward its throughput, because a frame is a frame regardless of how long it occupies the medium. The per-station branches are created on demand as receivers appear, so the queue follows the set of stations an access point actually serves instead of requiring it to be declared up front. A branch is one ~PerStationAirtimeQueue compound (sub-queue -> gate) in the branch submodule vector; the scheduler finds the airtime gate of a branch at the far end of the connection path arriving at its input gate, across the compound boundary. The branch is created with its final name and index, so a single station can also be configured individually, as in branch[2].weight = 2, and its statistics are recorded under pendingQueue.branch[k].queue and pendingQueue.branch[k].gate. packetCapacity defaults to 100, the same limit as the ~PendingQueue this queue replaces: ~CompoundPacketQueueBase defaults it to -1, no limit, which would silently turn a 100-frame queue into an unbounded one where isOverloaded() never fires and the shared-capacity overflow policy is dead code. ~AirtimeFairnessCompoundQueue only publishes the number of per-station branches created so far as a numStations watch, for the queue's display string. The module test drives the airtime disparity with frame sizes rather than rates, which keeps it independent of per-receiver rate configuration: the access point saturates one station with long frames and two with short ones, all at 54 Mbps, and the short-frame stations must recover to well over 0.25x the long-frame station's bytes (frame fairness leaves them at 0.14x). The quantum is set fine enough (100us) for the deficit to bind on every frame, because the coordination function pulls the next frame before the previous frame's airtime is reported, so at the default quantum a whole burst passes between charges and the schedule degrades toward frame fairness.
While the branch was flattened into the queue, the airtime deficit of a station
and the frames waiting for it were written above the gate and the sub-queue
directly. Now that a branch is a compound module, put the same information on
the branch itself: PerStationAirtimeQueue extends Module and states its
displayStringTextFormat, with the ModuleMixin expression resolver reading the
state from the submodules -- {.gate.deficit} for the deficit held by the gate,
{.queue.numPackets} and {.queue.totalLength} for the frames waiting in the
sub-queue.
The submodule references need the leading dot: getModuleByPath() takes a bare
`gate.deficit` as an absolute path in OMNeT++ 6, so it throws instead of
finding the gate submodule, and that would break every refreshDisplay.
f706e92 to
ff3907c
Compare
Adds
AirtimeFairnessQueue, a per-station airtime deficit round-robin transmit queue for IEEE 802.11, modelled on the Linuxmac80211airtime fairness feature. It drops into thependingQueueslot of anEdcaforDcafvia a typename override.Why. This addresses the downlink form of the 802.11 rate anomaly: when an access point saturates a mix of fast and slow clients, a FIFO — or even a frame-fair round robin — lets the slow client's long frames drag the fast clients down toward its throughput, because a frame is a frame regardless of how long it occupies the medium.
Structure. A
DynamicClassifierroutes each frame to a per-receiver sub-queue, each sub-queue is followed by anAirtimeFairnessGate, and anAirtimeFairnessSchedulerserves the gates. The gate owns one station's airtime deficit and its open/closed eligibility; the scheduler owns the rotation and the top-up trigger. Per-station branches are created on demand as receivers appear, so the queue follows the set of stations an access point actually serves.Airtime accounting.
Dcf/Hcfemit a newframeTransmittedAirtimesignal carrying anIeee80211AirtimeInd(receiver + on-air duration) for every unicast data/mgmt frame. The duration comes from the mode actually selected and the frame length, so it is exact; because it is emitted per completed transmission rather than per queued frame, retransmissions are charged individually.Overload. A shared
packetCapacityis enforced with a drop-from-longest policy (Ieee80211LongestFlowDropper, the FQ-CoDel rule), so a slow station's slowly-draining backlog cannot fill the queue and lock the others out.With
fairnessEnabled = falsethe gates stay open and the queue degrades to a plain per-station round robin — the frame-fair baseline to contrast against.AirtimeFairnessCompoundQueueexists only to correctCompoundPacketQueueBase's shared-capacity overflow path for a sub-queue-based compound: the base class emits bothpacketRemovedandpacketDroppedfor the victim, so it is subtracted twice from the queue-length statistic, and the frame stays owned by its sub-queue so deleting it warns.Based on
topic/gy/queueing-dynamic-classifier— please review/merge that first.Test
Builds at every commit. Instantiated and run in
examples/wireless/hiddennodewith**.mac.dcf.channelAccess.pendingQueue.typename = "AirtimeFairnessQueue"— 38k events over 2 simulated seconds, exercising dynamic branch creation, submodule splicing, and the gate/scheduler pair. Inert unless explicitly configured.🤖 Generated with Claude Code