ieee80211: add AirtimeFairnessQueue, a per-station airtime-fair transmit queue - #1123
Conversation
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. 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. 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. The branch submodules are spliced into the queue rather than left inside a compound, because the scheduler and gates are a matched pair and the scheduler must address each gate directly; the queue asserts that splicing is enabled rather than failing later with an opaque cast error. ~AirtimeFairnessCompoundQueue exists only to correct the shared-capacity overflow path of ~CompoundPacketQueueBase for a sub-queue-based compound. The base class removes the victim frame via removePacket() and then dropPacket(), which emits both packetRemoved and packetDropped, so the frame is subtracted twice from the queue-length statistic and the length drifts; the frame also remains owned by its sub-queue, so deleting it warns about deleting an object owned by another module. The override removes the victim directly from the sub-queue collection, takes ownership, and drops it exactly once.
| 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.
| 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.
|
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? |
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