Skip to content

ieee80211: add AirtimeFairnessQueue, a per-station airtime-fair transmit queue - #1123

Open
adamgeorge309 wants to merge 4 commits into
topic/gy/queueing-dynamic-classifierfrom
topic/gy/ieee80211-airtime-fairness
Open

ieee80211: add AirtimeFairnessQueue, a per-station airtime-fair transmit queue#1123
adamgeorge309 wants to merge 4 commits into
topic/gy/queueing-dynamic-classifierfrom
topic/gy/ieee80211-airtime-fairness

Conversation

@adamgeorge309

@adamgeorge309 adamgeorge309 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Adds AirtimeFairnessQueue, a per-station airtime deficit round-robin transmit queue for IEEE 802.11, modelled on the Linux mac80211 airtime fairness feature. It drops into the pendingQueue slot of an Edcaf or Dcaf via 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 DynamicClassifier routes each frame to a per-receiver sub-queue, each sub-queue is followed by an AirtimeFairnessGate, and an AirtimeFairnessScheduler serves 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/Hcf emit a new frameTransmittedAirtime signal carrying an Ieee80211AirtimeInd (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 packetCapacity is 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 = false the gates stay open and the queue degrades to a plain per-station round robin — the frame-fair baseline to contrast against.

AirtimeFairnessCompoundQueue exists only to correct CompoundPacketQueueBase's shared-capacity overflow path for a sub-queue-based compound: the base class emits both packetRemoved and packetDropped for 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/hiddennode with **.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


Open in Devin Review

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.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Devin Review found 2 potential issues.

View 2 additional findings in Devin Review.

Open in Devin Review

Comment on lines +68 to +73
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 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).
Open in Devin Review

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 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.

Suggested change
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
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@levy

levy commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

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?

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.

2 participants