DEV Community

Cover image for Implementing Strict Priority and Deficit Round Robin Schedulers in NS-3
Shreyas Yadav
Shreyas Yadav

Posted on

Implementing Strict Priority and Deficit Round Robin Schedulers in NS-3

Stack: NS-3 · C++ · nlohmann/json · Wireshark

When a router's outgoing link is slower than the traffic arriving at it, packets wait in a queue, and the router has to decide which packet goes next. That decision is what Quality of Service (QoS) is about. I implemented two classic answers to that question in NS-3: Strict Priority Queueing (SPQ) and Deficit Round Robin (DRR). Both plug into a small Differentiated Services (DiffServ) framework that I wrote, and both are configured from JSON.

This post covers the design, one classification bug that is easy to hit in NS-3, the results in Wireshark, and what I would do differently.

Code: github.com/Shreyas-Yadav/QoS-NS3


The Setup: Creating a Bottleneck

The topology is three nodes in a line:

Client ──(4 Mbps, 10 ms)──> Router ──(1 Mbps, 10 ms)──> Server
Enter fullscreen mode Exit fullscreen mode

Each UDP client sends a 1000-byte packet every 2 ms, which is about 4 Mbps per flow. The router can only forward 1 Mbps, so its egress queue fills up quickly. That queue is where the scheduler runs. I replaced the router's default queue on the server-facing device with my own:

Ptr<PointToPointNetDevice> routerEgress =
    m_routerNode->GetDevice(1)->GetObject<PointToPointNetDevice>();
routerEgress->SetQueue(spq);  // or drr
Enter fullscreen mode Exit fullscreen mode

I capture PCAP traces on both sides of the router (Pre_* and Post_*) so I can compare the traffic before and after scheduling.


Design

Flowchart of a packet moving through DiffServ classification, the SPQ or DRR scheduler, and router transmission
How a packet moves through classification, the SPQ or DRR scheduler, and out of the router.

Classification: Filters and Traffic Classes

Every packet must be placed in a traffic class before a scheduler can pick from it. The classification logic has three layers:

  • FilterElement checks a single field: source or destination IP, subnet mask, source or destination port, or protocol.
  • Filter combines elements with AND logic, so all of them must match.
  • TrafficClass holds a list of filters combined with OR logic, plus its own queue, packet limit, and priority or weight.

If no class matches a packet, it goes to the class marked Default. If there is no default class, the packet is dropped.

The Scheduler Interface

DiffServ is the base class. It handles enqueue (classify, then push to the matching class) and dequeue. Subclasses only implement one method:

virtual Ptr<const Packet> Schedule() const = 0;
Enter fullscreen mode Exit fullscreen mode

Schedule() returns the packet that should go next, and the base class removes it from the correct queue. SPQ and DRR differ only in how they implement this one method.

JSON Configuration

The scheduler type and the traffic classes come from a config file:

{
  "name": "drr",
  "queues": [
    { "no": 1, "MaxPackets": 3000, "Weight": 10, "DestPort": 9000 },
    { "no": 2, "MaxPackets": 3000, "Weight": 20, "DestPort": 10000 },
    { "no": 3, "MaxPackets": 3000, "Weight": 30, "DestPort": 11000 }
  ]
}
Enter fullscreen mode Exit fullscreen mode

A Gotcha: The PPP Header

My first version of the port filter matched nothing. At the point where a packet sits in a point-to-point device's queue, NS-3 has already added a PPP header in front of the IPv4 header. You have to remove that header before you can read anything else:

Ptr<Packet> copy = pkt->Copy();   // never modify the real packet

PppHeader ppp;
if (!copy->RemoveHeader(ppp)) return false;

Ipv4Header ip;
if (!copy->RemoveHeader(ip)) return false;

if (ip.GetProtocol() == 17) {     // UDP
    UdpHeader udp;
    copy->PeekHeader(udp);
    return udp.GetDestinationPort() == m_port;
}
Enter fullscreen mode Exit fullscreen mode

Working on a copy matters too. RemoveHeader changes the packet, and the real packet still has to leave the router with all its headers.


Strict Priority Queueing

SPQ is simple: always serve the highest-priority queue that has packets. A lower priority number means a higher priority.

for (auto tc : classes) {
    if (!tc->IsEmpty() && tc->GetPriority() < highestPriority) {
        best = tc;
        highestPriority = tc->GetPriority();
    }
}
return best ? best->Peek() : nullptr;
Enter fullscreen mode Exit fullscreen mode

Test scenario: the low-priority flow (port 10001) starts at 0 s. The high-priority flow (port 10000) starts at 15 s.

Wireshark I/O graph before the router: the low-priority and high-priority flows share the 4 Mbps link equally after 15 seconds
Before the router (4 Mbps link). Red: low priority (port 10001). Green: high priority (port 10000). The two flows share the link equally.

Wireshark I/O graph after the router: the low-priority flow drops to zero while the high-priority flow uses the full 1 Mbps link
After the router (1 Mbps bottleneck). While the high-priority queue has packets, the low-priority flow gets nothing.

After the router, the low-priority flow uses the full link (about 120 packets/s) until 15 s. When the high-priority flow starts, the low-priority flow drops to zero. It gets no bandwidth until the high-priority queue is empty, and then it recovers.

This is exactly what SPQ promises, and it also shows SPQ's weakness: starvation. A heavy high-priority flow can block everything else indefinitely.


Deficit Round Robin

DRR shares bandwidth in proportion to weights instead. Each queue has a credit counter. On each visit, the queue earns credit equal to its weight. If its credit covers the size of the packet at the front, it sends that packet and pays for it. Otherwise, the scheduler moves on to the next queue.

while (true) {
    if (!queues[i]->IsEmpty()) {
        credit[i] += queues[i]->GetWeight();
        uint32_t size = queues[i]->Peek()->GetSize();
        if (size <= credit[i]) {
            credit[i] -= size;
            return queues[i]->Peek();
        }
    }
    i = (i + 1) % queues.size();
}
Enter fullscreen mode Exit fullscreen mode

Peeking Must Not Spend Credit

NS-3 calls Peek() on a queue separately from Dequeue(). If Schedule() updated the real credit counters, a single peek would spend credit for a packet that was never sent.

To avoid this, Schedule() works on temporary state (m_tempCreditBalance, m_scheduledQueueIndex). Dequeue() copies that state into the real counters only after a packet actually leaves:

Ptr<Packet> DRR::Dequeue() {
    Ptr<Packet> p = DiffServ::Dequeue();
    if (p) {
        m_currentQueueIndex = m_scheduledQueueIndex;
        m_creditBalance = m_tempCreditBalance;
    }
    return p;
}
Enter fullscreen mode Exit fullscreen mode

Test scenario: three flows with weights 10, 20, and 30, all starting at 0 s.

Wireshark I/O graph before the router: three UDP flows arriving at the same rate
Before the router. All three flows arrive at the same rate.

Wireshark I/O graph after the router: three flows at about 20, 40, and 60 packets per second, matching DRR weights of 10, 20, and 30
After the router. Blue: weight 10. Teal: weight 20. Yellow: weight 30.

After the router, the flows settle at about 20, 40, and 60 packets/s, which is the 1:2:3 ratio of the weights.

The graph also shows DRR's other property. When the weight-30 flow's queue empties at about 22 s, its share is redistributed to the remaining flows. They move to about 40 and 80 packets/s, which keeps their 1:2 ratio. When the weight-20 queue empties at about 27 s, the last flow gets the full link. DRR never leaves the link idle while there are packets waiting.


What I Would Change

Looking back at the code, these are the things I would fix:

  • The quantum is smaller than a packet. The weights (10–30) are added as bytes of credit, but packets are about 1000 bytes. The DRR loop therefore goes around dozens of times before any queue can send. The ratio is still correct, but the loop wastes work. In textbook DRR, the quantum is at least one MTU, so a queue can send on every visit.
  • Credit is not reset when a queue empties. In standard DRR, an empty queue loses its leftover credit, so it can't save up credit and send a burst later. My implementation keeps the credit.
  • Queue limits count packets instead of bytes. A queue full of small packets and a queue full of large packets hold very different amounts of data.
  • Classification assumes IPv4 over PPP. Any other link layer or IPv6 would not match.
  • The config file has no schema validation. A typo in a field name silently falls back to a default value.
  • DiffServ does too much. Classification, scheduling, and queue storage all live in one class. Splitting them into a Classifier, a Scheduler, and a Queue Manager would make each part easier to test and replace.

Run It Yourself

Copy the source files into scratch/final-project/ in an NS-3 (3.36 or later) installation, install nlohmann-json3-dev, and run:

./ns3 run scratch/final-project/driver.cc -- scratch/final-project/spq_config.json
./ns3 run scratch/final-project/driver.cc -- scratch/final-project/drr_config.json
Enter fullscreen mode Exit fullscreen mode

Then open the PCAP files in Wireshark, go to Statistics → I/O Graph, and add one line per flow with a filter such as udp.dstport == 10000.

The full code is on GitHub.

Top comments (1)

Collapse
 
antibote profile image
ANTIBOT DEV •

Awеsome guidе, very helрful, kееp pushing оut more content.
Sincerely, DЕV Suppоrt