Openwrt one Poor man's bufferbloat mitigation

ECN (Explicit Congestion Notification) is an extension to TCP/IP, defined in RFC 3618 and updated in RFC 9330. It allows end-to-end notification of packet congestion, without having to drop packets. It does this by marking the packets to signal impending congestion, which causes the upstream endpoint to dial back its send rate.

It works for upload and download bandwidth.

But, to get it to work, you’ve got more configuring to do than with typical bufferbloat mitigation strategies… you have to configure each lan device and the router to enable ECN for each.

For the router:

vi /etc/sysctl.conf

… press i to enter editing mode, then paste the following into the file:

net.ipv4.tcp_ecn = 1
net.ipv4.tcp_ecn_fallback = 1

… then press Esc to exit editing mode, then :wq to save and exit, then issue:

sysctl -p

… to load the settings.

That setting enables ECN for IPv4 and IPv6. If you’re running https-dns-proxy, that’ll set ECN for DNS queries.

If you’re using https-dns-proxy, you can ascertain that it’s working by issuing:

tcpdump -v -i eth0 -c 5 "tcp port 443"

… and you’ll see output like:

19:56:56.576950 IP6 (class 0x02, flowlabel 0x8a1ba, hlim 127, next-header TCP (6), payload length 32) 2601:2c1:9480:a918:201d:ae47:5ad9:758e.55294 > 2603:1063:16:120::365:7ea3.443: Flags [SEW], cksum 0x517b (correct), seq 1731337521, win 65535, options [mss 1440,nop,wscale 8,nop,nop,sackOK], length 0

class 0x02 is the IPv6 Traffic Class field. In hexadecimal, 0x02 translates to binary 00000010. The last two bits are 10 , which explicitly stands for ECT(0) (ECN-Capable Transport).

[SEW] stands for:

  • S = SYN (Synchronize, establishing a connection).
  • E = ECE (ECN-Echo capability advertisement).
  • W = CWR (Congestion Window Reduced capability advertisement).

The response packet: Packet #5 (Inbound SYN-ACK Response)

19:56:56.606035 IP6 (class 0x02, flowlabel 0x4cea4 … ) REMOTE_IP.443 > LAN_IP.55294: Flags [S.E]

  1. class 0x02: The remote internet server responded with 0x02 (ECT(0)), confirming that it also supports ECN and has enabled it for this download stream.
  2. Flags [S.E]:
    • S = SYN
    • E = ECE (ECN-Echo)

Because the remote server sent back the E flag in response to the router’s request, the ECN negotiation completed successfully.

ECN in a router depends upon some form of AQM (Active Queue Management) being present. Because OpenWRT uses FQ-CoDel out-of-the-box (it’s just unthrottled, because the developers have no way of knowing what bandwidth you’ve paid for), ECN works without having to change anything as regards the stock traffic shaper.

That takes care of traffic to / from the router itself. Now let’s look at the lan devices:

For Windows:

Start Powershell as Administrator, then issue:

netsh int tcp set global ecncapability=enabled

… it should return ‘Ok.’.

For Apple (macOS) devices:

Start terminal, then issue:

sudo sysctl -w net.inet.tcp.ecn=1

For iOS devices:

For modern (11+) iOS devices, ECN is enabled by default.

For Linux:

Start terminal, then issue:

sudo sysctl -w net.ipv4.tcp_ecn=1

… to set it until the next reboot, or follow the instructions for the router (above) to make it permanent.

For Android:

You must have a rooted Android device. It’s not possible to enable ECN on a non-rooted device.

Connect to the phone from your computer via ADB (Android Debug Bridge).

In the Debug shell, issue:

su
sysctl -w net.ipv4.tcp_ecn=1

… to set it until the next reboot. You’ll have to use something like Kernel Adjuter or Magisk to run a script each time the OS is rebooted, to set it each time.

For Chrome:

Put your Chrome device into Developer Mode.

Open crosh, type shell, and run sudo sysctl -w net.ipv4.tcp_ecn=1

It should be noted that the Chrome browser automatically enables ECN.

[ EDIT: Whew! Getting the firewall to mark packets is labrinthine! Working now. ]

Now the fun part… you can tune ECN to start marking packets as soon as the first hint of congestion occurs. Out-of-the-box, FQ-CoDel lets 5 msec worth of bufferbloat build up. The code below drops that baseline down to 2500 usec (2.5 msec), and clamps down as far as 500 usec (0.5 msec) on high load.

The code below shapes both upload and download. It varies ecn ce_threshold as the datarate varies, to make the traffic shaping smoother.

I’ve updated the code below to give higher priority to https-dns-proxy traffic. If you don’t have https-dns-proxy installed, you can comment those lines out, or remove them. The firewall marks the DoH packets, then wan_throttle uses that mark to chuck those packets into their own, higher-priority data stream.

NOTE: The code below works perfectly even with Software Flow Offloading enabled. We steal the inbound packets for shaping before SFO even sees them. We shape at Layer 2 and offload at Layer 3. Once they’re shaped, they go up the stack to Layer 3. Hence we get inbound traffic shaping and low CPU utilization.

Use:

ip link show

… to enumerate your interfaces. Find the interface which your router uses to connect to the modem… for the OpenWRT One router, it’s usually eth0.

We can adapt the original wan_throttle from my prior posts to enable ECN and L4S… copy and paste this into the terminal window, then press Enter:

cat << 'EOF' > /etc/init.d/wan_throttle
#!/bin/sh /etc/rc.common

START=95

# Register 'status' as a custom command
EXTRA_COMMANDS="status"
EXTRA_HELP="	status	Show traffic control statistics"

# ===	===	===	===	===	===	===
# USER SETTINGS - DO NOT CHANGE ANYTHING ELSE!

LOGGING=1 # Options: 0 - disabled | 1 - enabled

# QUANTUM SHOULD BE A MULTIPLE OF MTU + ETHERNET HEADER SIZE
# DEFAULT: 1500 + 14 = 1514
QUANTUM=3028

WAN_INTERFACE="eth0"

# THROTTLED SPEEDS (wan_throttle started)
THROTTLE_UP_SPEED="33mbit"
THROTTLE_DOWN_SPEED="200mbit"

# LINE SPEEDS (wan_throttle stopped)
FULL_UP_SPEED="45mbit"   # ISP upload speed
FULL_DOWN_SPEED="1000mbit" # ISP download speed
# ===	===	===	===	===	===	===

# Account for protocol overhead
THROTTLE_UP="$(( (u = ${THROTTLE_UP_SPEED%mbit}) + (u / 10) ))mbit"
THROTTLE_DOWN="$(( (u = ${THROTTLE_DOWN_SPEED%mbit}) + (u / 10) ))mbit"

BASE_CE_THRESH=2500
MIN_UPLOAD_CE=500
MIN_DOWNLOAD_CE=500
UP_PID_FILE="/tmp/wan_throttle_up.pid"
DOWN_PID_FILE="/tmp/wan_throttle_down.pid"

setup_structure_if_missing() {
    insmod sch_htb >/dev/null 2>/dev/null
    insmod act_mirred >/dev/null 2>/dev/null
    insmod cls_u32 >/dev/null 2>/dev/null

    if lsmod | grep -q "^ifb" && ! ip link show dev ifb0 >/dev/null 2>&1; then
        rmmod ifb 2>/dev/null
    fi

    modprobe ifb numifbs=1 >/dev/null 2>&1
    ip link set dev ifb0 up >/dev/null 2>&1

    # Provision TX queue
    if ! tc qdisc show dev ${WAN_INTERFACE} | grep -q "root htb"; then
        tc qdisc del dev ${WAN_INTERFACE} root 2>/dev/null

        # Set up root HTB
        tc qdisc add dev ${WAN_INTERFACE} root handle 10: htb default 11

        # Fast-track lane (class 10:10) for DoH queries
        tc class add dev ${WAN_INTERFACE} parent 10: classid 10:10 htb rate 5mbit ceil ${FULL_UP_SPEED} prio 0

        # General traffic (class 10:11)
        tc class add dev ${WAN_INTERFACE} parent 10: classid 10:11 htb rate ${THROTTLE_UP} ceil ${THROTTLE_UP} quantum ${QUANTUM} prio 1

        # Attach fq_codel leaf to class 10:11
        tc qdisc add dev ${WAN_INTERFACE} parent 10:11 handle 110: fq_codel quantum ${QUANTUM} target 5ms interval 100ms ecn ce_threshold "${BASE_CE_THRESH}us" ce_threshold_selector 0x01/0x01 memory_limit 32Mb drop_batch 0

        # ====================
        # https-dns-proxy higher-priority bypass via Firewall Mark (fwmark)
        # Matches any packet internally stamped with mark '4' and fast-tracks it
        tc filter add dev ${WAN_INTERFACE} parent 10: protocol ip prio 1 handle 4 fw flowid 10:10
        # ====================
        # Catch-all: All remaining traffic to the standard fq_codel leaf queue
        tc filter add dev ${WAN_INTERFACE} parent 10: protocol all prio 2 u32 match u32 0 0 flowid 10:11
    fi

    # Provision RX queue
    if ! tc qdisc show dev ${WAN_INTERFACE} | grep -q "clsact" || ! tc qdisc show dev ifb0 | grep -q "htb"; then
        tc qdisc del dev ${WAN_INTERFACE} clsact 2>/dev/null
        tc qdisc del dev ifb0 root 2>/dev/null

        tc qdisc add dev ${WAN_INTERFACE} clsact

        tc filter add dev ${WAN_INTERFACE} ingress protocol all u32 match u32 0 0 action mirred egress redirect dev ifb0

        tc qdisc add dev ifb0 root handle 1: htb default 11
        tc class add dev ifb0 parent 1: classid 1:11 htb rate ${THROTTLE_DOWN} ceil ${THROTTLE_DOWN} quantum ${QUANTUM}
        tc qdisc add dev ifb0 parent 1:11 handle 110: fq_codel quantum ${QUANTUM} target 5ms interval 100ms ecn ce_threshold "${BASE_CE_THRESH}us" ce_threshold_selector 0x01/0x01 memory_limit 32Mb drop_batch 0
    fi
}

run_upload_monitor() {
    local UP_MODIFIED=0

    while true; do
        local SYN_COUNT=$(ss -H -t state syn-sent | wc -l)
        if [ "${SYN_COUNT}" -gt 0 ]; then
            local NEW_CE=$(( BASE_CE_THRESH - (SYN_COUNT * 250) ))
            [ "${NEW_CE}" -lt "${MIN_UPLOAD_CE}" ] && NEW_CE=${MIN_UPLOAD_CE}

            tc qdisc change dev "${WAN_INTERFACE}" parent 10:11 handle 110: fq_codel quantum ${QUANTUM} target 5ms interval 100ms ecn ce_threshold "${NEW_CE}us" ce_threshold_selector 0x01/0x01 memory_limit 32Mb drop_batch 0
            [ "${LOGGING}" -eq 1 ] && logger -t wan_throttle "CONGESTION++: ${SYN_COUNT} SYN-SENTs.	Upload ECN set to ${NEW_CE}us."
            UP_MODIFIED=1
        else
            if [ "${UP_MODIFIED}" -eq 1 ]; then
                tc qdisc change dev "${WAN_INTERFACE}" parent 10:11 handle 110: fq_codel quantum ${QUANTUM} target 5ms interval 100ms ecn ce_threshold "${BASE_CE_THRESH}us" ce_threshold_selector 0x01/0x01 memory_limit 32Mb drop_batch 0
                [ "${LOGGING}" -eq 1 ] && logger -t wan_throttle "CONGESTION--. ${SYN_COUNT} SYN-SENTs.	Upload ECN set to ${BASE_CE_THRESH}us."
                UP_MODIFIED=0
            fi
        fi
        sleep 2
    done
}

run_download_monitor() {
    local OLD_BYTES
    OLD_BYTES=$(cat /sys/class/net/ifb0/statistics/rx_bytes 2>/dev/null)
    local DOWN_MODIFIED=0

    while true; do
        sleep 1
        local NEW_BYTES
        NEW_BYTES=$(cat /sys/class/net/ifb0/statistics/rx_bytes 2>/dev/null)
        [ -z "${NEW_BYTES}" ] || [ -z "${OLD_BYTES}" ] && continue

        local BYTES_DIFF=$((NEW_BYTES - OLD_BYTES))
        OLD_BYTES=${NEW_BYTES}
        local LIVE_MBPS=$(( (BYTES_DIFF * 8) / 1048576 ))

        if [ -n "${DOWN_RAW}" ] && [ "${DOWN_RAW}" -gt 0 ]; then
            local UTILIZATION=$(( (LIVE_MBPS * 100) / DOWN_RAW ))
        else
            local UTILIZATION=0
        fi

        if [ "${UTILIZATION}" -gt 80 ]; then
            local LOAD_OVER_80=$((UTILIZATION - 80))
            local NEW_CE=$(( BASE_CE_THRESH - (LOAD_OVER_80 * 50) ))
            [ "${NEW_CE}" -lt "${MIN_DOWNLOAD_CE}" ] && NEW_CE=${MIN_DOWNLOAD_CE}

            tc qdisc change dev ifb0 parent 1:11 handle 110: fq_codel quantum ${QUANTUM} target 5ms interval 100ms ecn ce_threshold "${NEW_CE}us" ce_threshold_selector 0x01/0x01 memory_limit 32Mb drop_batch 0
            [ "${LOGGING}" -eq 1 ] && logger -t wan_throttle "HIGH LOAD: ${UTILIZATION}% (${LIVE_MBPS} Mbps).	Download ECN set to ${NEW_CE}us."
            DOWN_MODIFIED=1
        else
            if [ "${DOWN_MODIFIED}" -eq 1 ]; then
                tc qdisc change dev ifb0 parent 1:11 handle 110: fq_codel quantum ${QUANTUM} target 5ms interval 100ms ecn ce_threshold "${BASE_CE_THRESH}us" ce_threshold_selector 0x01/0x01 memory_limit 32Mb drop_batch 0
                [ "${LOGGING}" -eq 1 ] && logger -t wan_throttle "LOW LOAD: ${UTILIZATION}% (${LIVE_MBPS} Mbps).	Download ECN set to ${BASE_CE_THRESH}us."
                DOWN_MODIFIED=0
            fi
        fi
    done
}

start() {
    setup_structure_if_missing

    tc qdisc del dev ifb0 root 2>/dev/null
    tc qdisc add dev ifb0 root handle 1: htb default 11
    tc class add dev ifb0 parent 1: classid 1:11 htb rate ${THROTTLE_DOWN} ceil ${THROTTLE_DOWN} quantum ${QUANTUM}
    tc qdisc add dev ifb0 parent 1:11 handle 110: fq_codel quantum ${QUANTUM} target 5ms interval 100ms ecn ce_threshold "${BASE_CE_THRESH}us" ce_threshold_selector 0x01/0x01 memory_limit 32Mb drop_batch 0

    [ -f "${UP_PID_FILE}" ] && kill -9 "$(cat "${UP_PID_FILE}")" 2>/dev/null
    [ -f "${DOWN_PID_FILE}" ] && kill -9 "$(cat "${DOWN_PID_FILE}")" 2>/dev/null
    run_upload_monitor & echo $! > "${UP_PID_FILE}"
    run_download_monitor & echo $! > "${DOWN_PID_FILE}"

    logger -t wan_throttle "wan_throttle started. Up: ${THROTTLE_UP_SPEED}, Down: ${THROTTLE_DOWN_SPEED}"
}

stop() {
    # Terminate monitor subroutines via PID files
    if [ -f "${UP_PID_FILE}" ]; then
        kill -9 "$(cat "${UP_PID_FILE}")" 2>/dev/null
        rm -f "${UP_PID_FILE}"
    fi
    if [ -f "${DOWN_PID_FILE}" ]; then
        kill -9 "$(cat "${DOWN_PID_FILE}")" 2>/dev/null
        rm -f "${DOWN_PID_FILE}"
    fi

    # Remove traffic shaping structures (WAN and IFB)
    # This removes class 10:10 & 10:11, filters, and fq_codel leaves.
    tc qdisc del dev ${WAN_INTERFACE} root 2>/dev/null
    tc qdisc del dev ${WAN_INTERFACE} clsact 2>/dev/null
    tc qdisc del dev ifb0 root 2>/dev/null

    # Set IFB loopback device in down state.
    ip link set dev ifb0 down 2>/dev/null

    logger -t wan_throttle "wan_throttle stopped."
}

status() {
    echo "===	===	===	===	===	===" | tee >(logger -t wan_throttle)
    echo "===	===	UL STATUS	===	===" | tee >(logger -t wan_throttle)
    echo "===	===	===	===	===	===" | tee >(logger -t wan_throttle)
    WAN_STAT=$( tc -s qdisc show dev ${WAN_INTERFACE} ) && echo "${WAN_STAT}" | tee >(logger -t wan_throttle)
    echo "===	===	===	===	===	===" | tee >(logger -t wan_throttle)
    echo "===	===	DL STATUS	===	===" | tee >(logger -t wan_throttle)
    echo "===	===	===	===	===	===" | tee >(logger -t wan_throttle)
    WAN_STAT=$( tc -s qdisc show dev ifb0 ) && echo "${WAN_STAT}" | tee >(logger -t wan_throttle)
}
EOF

Make it executable

chmod +x /etc/init.d/wan_throttle

Enable the service:

/etc/init.d/wan_throttle enable

Start the service:

/etc/init.d/wan_throttle start

Create the file that marks https-dns-proxy packets:

vi /usr/share/nftables.d/ruleset-post/90-doh-tc-bypass.nft

… press i to enter editing mode, then paste the following in:

table inet fw4 {
    chain mangle_postrouting {
        meta skuid nobody th dport 443 meta mark set 4 comment "Stamp both TCP and UDP HTTP/3 local DoH with mark 4"
    }
}

… press Esc to exit editing mode, then :wq to save and exit, then reload the firewall:

fw4 reload

… and restart wan_throttle:

/etc/init.d/wan_throttle stop
/etc/init.d/wan_throttle start

Note the code above:

meta skuid nobody
           ^^^^^^

image

… it marks only those packets coming from https-dns-proxy, so wan_throttle can give those packets their own fast-lane.

Make sure the files survive firmware updates. Put:

/etc/init.d/wan_throttle
/usr/share/nftables.d/ruleset-post/90-doh-tc-bypass.nft

… into the list at System >> Backup / Flash Firmware >> Configuration.

Look at the tc stats for class 10:10 (the higher-priority bypass for https-dns-proxy traffic):

tc -s class show dev eth0

… you should see something like this:

class htb 10:11 root leaf 110: prio 0 rate 36Mbit ceil 36Mbit burst 1600b cburst 1600b
 Sent 25995848 bytes 66956 pkt (dropped 0, overlimits 6950 requeues 0) 
 backlog 0b 0p requeues 0
 lended: 56471 borrowed: 0 giants: 0
 tokens: 5226 ctokens: 5226

class htb 10:10 root prio 0 rate 5Mbit ceil 45Mbit burst 1600b cburst 1600b
 Sent 194306 bytes 1485 pkt (dropped 0, overlimits 0 requeues 0) 
 backlog 0b 0p requeues 0
 lended: 1485 borrowed: 0 giants: 0
 tokens: 38350 ctokens: 4261

class fq_codel 110:7a parent 110: 
 (dropped 0, overlimits 0 requeues 0) 
 backlog 0b 0p requeues 0
  deficit 286 count 0 lastcount 0 ldelay 754us
class fq_codel 110:1c7 parent 110: 
 (dropped 0, overlimits 0 requeues 0) 
 backlog 0b 0p requeues 0
  deficit 2933 count 0 lastcount 0 ldelay 4us

… 1485 packets through the class 10:10 higher-priority bypass lane.

That last class (110:7a) is dynamic, set up by FQ_CoDel, one for each data stream… you’ll see it creating and removing classes as the traffic rate varies. It uses those to attempt to give fair bandwidth to each stream.

You can ascertain that the packet mangling is working by issuing:

nft list chain inet fw4 mangle_postrouting

… which will output something like:

table inet fw4 {
	chain mangle_postrouting {
		type filter hook postrouting priority mangle; policy accept;
		oifname "eth0" tcp flags & (fin | syn | rst) == syn tcp option maxseg size set rt mtu comment "!fw4: Zone wan IPv4/IPv6 egress MTU fixing"
		oifname "phy2-sta0" tcp flags & (fin | syn | rst) == syn tcp option maxseg size set rt mtu comment "!fw4: Zone hotspot IPv4/IPv6 egress MTU fixing"
		meta skuid 65534 th dport 443 meta mark set 0x00000004 comment "Stamp both TCP and UDP HTTP/3 local DoH with mark 4"
	}
}

Now, your https-dns-proxy DoH will work, even when you’re pushing bandwidth to the limits you’ve set in the wan_throttle script, because it’s at a higher priority.

You’ll have to experiment with it to get the right bandwidth settings for THROTTLE_UP_SPEED and THROTTLE_DOWN_SPEED… it should be the slowest-speed interface. For me, that’s WiFi for THROTTLE_DOWN_SPEED, and the ISP’s upload bandwidth limit for THROTTLE_UP_SPEED. If I set it to 200 Mbps download speed, I get +4 ms latency on high download rate, that bufferbloat occurring at the phy0-ap0 and phy1-ap0 interfaces (or at br-lan, given that this is a WiFi-only network).

If you’ve got logging enabled, during high-bandwidth periods, you’ll see logs something like:

wan_throttle: HIGH LOAD: 100% (201 Mbps). Download ECN set to 1500us.
wan_throttle: HIGH LOAD: 107% (215 Mbps). Download ECN set to 1150us.
wan_throttle: HIGH LOAD: 107% (214 Mbps). Download ECN set to 1150us.
wan_throttle: LOW LOAD: 63% (126 Mbps). Download ECN set to 2500us.
wan_throttle: HIGH LOAD: 103% (206 Mbps). Download ECN set to 1350us.
wan_throttle: HIGH LOAD: 107% (215 Mbps). Download ECN set to 1150us.
wan_throttle: HIGH LOAD: 109% (218 Mbps). Download ECN set to 1050us.
wan_throttle: HIGH LOAD: 103% (207 Mbps). Download ECN set to 1350us.
wan_throttle: LOW LOAD: 15% (30 Mbps). Download ECN set to 2500us.
wan_throttle: HIGH LOAD: 88% (177 Mbps). Download ECN set to 2100us.
wan_throttle: HIGH LOAD: 104% (209 Mbps). Download ECN set to 1300us.
wan_throttle: HIGH LOAD: 105% (211 Mbps). Download ECN set to 1250us.
wan_throttle: HIGH LOAD: 107% (214 Mbps). Download ECN set to 1150us.
wan_throttle: HIGH LOAD: 85% (170 Mbps). Download ECN set to 2250us.
wan_throttle: LOW LOAD: 0% (0 Mbps). Download ECN set to 2500us.

Take a look at the download stats:

===	 ===	===	===	===	===
===	=== DL STATUS	===	===
===	 === ===	===	===	===
qdisc htb 1: root refcnt 2 r2q 10 default 0x11 direct_packets_stat 0 direct_qlen 32
Sent 400692714 bytes 307245 pkt (dropped 1, overlimits 130172 requeues 18)
backlog 0b 0p requeues 18
qdisc fq_codel 110: parent 1:11 limit 10240p flows 1024 quantum 3028 target 5ms ce_threshold 2.5ms ce_threshold_selector 0x1/0x1 interval 100ms memory_limit 32Mb ecn drop_batch 64
Sent 400692714 bytes 307245 pkt (dropped 1, overlimits 0 requeues 0)
backlog 0b 0p requeues 0
maxpacket 60576 drop_overlimit 0 new_flow_count 24678 ecn_mark 1497 ce_mark 1497
new_flows_len 0 old_flows_len 4

1 packet dropped, and 1497 ecn_mark’d.

Here’s a screenshot from btop:

An enhancement:

Install the kmod-tcp-bbr package, and issue:

vi /etc/sysctl.conf

… press i to enter editing mode, then paste this in:

# Enable Explicit Congestion Notification and BBR Congestion Control for wan_throttle use
net.ipv4.tcp_ecn = 1
net.ipv4.tcp_ecn_fallback = 1
net.ipv4.tcp_congestion_control = bbr

… press Esc to exit editing mode, then :wq to save and exit. Then issue:

sysctl -p

… to load the new setting.

This caused my bufferbloat test jitter to reduce to single-digit values, which finally got all the checkmarks in the lower-left panel (results with THROTTLE_UP_SPEED="33mbit", and THROTTLE_DOWN_SPEED="200mbit"):

If you use BBR instead of CUBIC on all of the configurable lan devices, as well, the traffic shaping is that much smoother.

For instance, for Linux:

sudo modprobe tcp_bbr
sudo sysctl -w net.ipv4.tcp_congestion_control=bbr
sudo sysctl -w net.core.default_qdisc = fq_codel
sudo sysctl -p

And for Windows:

Run this first and record the results, so you can reset it to defaults if you want:

Get-NetTCPSetting | Select SettingName, CongestionProvider

Then switch to BBR:

netsh int tcp set supplemental Template=Internet CongestionProvider=bbr2
netsh int tcp set supplemental Template=Datacenter CongestionProvider=bbr2
netsh int tcp set supplemental Template=Compat CongestionProvider=bbr2
netsh int tcp set supplemental Template=DatacenterCustom CongestionProvider=bbr2
netsh int tcp set supplemental Template=InternetCustom CongestionProvider=bbr2

Get-NetTCPSetting | Select SettingName, CongestionProvider

… which should return:

SettingName        CongestionProvider
-----------        ------------------
Automatic
InternetCustom     BBR2
DatacenterCustom   BBR2
Compat             BBR2
Datacenter         BBR2
Internet           BBR2

Another enhancement… for the router and all the lan devices you can configure, set these in sysctl.conf (in the router, it’s at /etc/sysctl.conf):

net.ipv4.tcp_pacing_ca_ratio = 105
net.ipv4.tcp_pacing_ss_ratio = 105

The first one allows the device to seek for 5% more bandwidth if it’s available, but at a lower rate of increase than default, which slows down the bandwidth surges and dips.

The second one slows down the ramp-up of slow-start to +5% of current bandwidth… it still ramps pretty quickly, but it’s not slamming wide open like it does by default. It allows wan_throttle to settle into a narrow ECN range that keeps maximum bandwidth pretty stable.

Be sure to reload the settings via:

sysctl -p

Another enhancement:

I was reading here about how CloudFlare set their servers up for high throughput and low latency. While I can’t do a lot of what CloudFlare did (which would require recompiling the kernel), we can limit the size of the unsent bytes in the write queue. CloudFlare sets their net.ipv4.tcp_notsent_lowat = 131072 (128 KB), but that spikes CPU utilization (increases sendmsg() calls when using non-blocking sockets, and increases number of context switches for blocking sockets). CloudFlare can afford that, we can’t. I’ve set net.ipv4.tcp_notsent_lowat = 524288 (512 KB). I’m still testing to find the best value.

vi /etc/sysctl.conf

… press i to enter editing mode, then paste:

# Reduce net.ipv4.tcp_notsent_lowat from 4294967295 to 524288
# Cloudflare sets theirs to 131072 to limit the size of unsent bytes in the write queue.
net.ipv4.tcp_notsent_lowat = 524288

… press Esc to exit editing mode, then :wq to save and exit, then issue:

sysctl -p

… which got me:

image

… for the first time on the bufferspeed.com bufferbloat test (with THROTTLE_UP_SPEED="33mbit" and THROTTLE_DOWN_SPEED="200mbit").

Final note: You’ll notice in your logs:

daemon.notice: ttyd[3664]: [2026/10/12 19:13:39:0701] N: rops_handle_POLLIN_netlink: DELADDR

… when you stop /etc/init.d/wan_throttle.

When ttyd (the terminal daemon) starts up, it opens a Netlink routing socket (NETLINK_ROUTE). In Linux, Netlink is a kernel-to-user-space messaging system used to broadcast networking events (like an interface going up/down, or an IP address being assigned/removed).

By default, a program listening to NETLINK_ROUTE receives a stream of network change messages for all interfaces on the router, not just the interface the program is bound to.

When wan_throttle puts ifb0 into a down state, the Linux kernel removes any IP/address properties associated with that interface. The kernel broadcasts an RTM_DELADDR (Delete Address) message over Netlink to notify the system.

Because ttyd is listening globally, its Netlink polling loop captures the kernel’s broadcast event and spits out rops_handle_POLLIN_netlink: DELADDR into the system logs

If you want to use CAKE, you can issue:

rm -f /usr/share/nftables.d/ruleset-post/90-doh-tc-bypass.nft

… to get rid of the firewall’s marking of packets from user ‘nobody’ (ie: https-dns-proxy).

Why? Because we’re going to have https-dns-proxy mark the packets itself.

… click the Edit button for each DoH listener… image

… and set DSCP Codepoint to 46 for each DoH listener.

In /etc/config/https-dns-proxy, it’ll look like this:

config https-dns-proxy
	option bootstrap_dns '127.0.0.1,127.0.0.2,127.0.0.3,::1'
	option resolver_url 'https://dnsforge.de/dns-query'
	option listen_port '5050'
	option listen_addr '127.0.0.1'
	option force_ip_family 'ipv4'
	option dscp_codepoint '46'

We set option dscp_codepoint '46' for each DoH listener in https-dns-proxy to classify DoH traffic into CAKE’s Voice / Signaling Tin (queue), which gives it higher upload and download priority.

Then we set up the /etc/init.d/wan_throttle code:

cat << 'EOF' > /etc/init.d/wan_throttle
#!/bin/sh /etc/rc.common
START=97

EXTRA_COMMANDS="status"
EXTRA_HELP="	status	Show traffic control statistics"

# USER SETTINGS - DO NOT CHANGE ANYTHING ELSE!
# ===	===	===	===	===	===	===
WAN_PROTO="docsis"		# Options: "docsis" | ""

WAN_INTERFACE="eth0"		# The interface connected to your modem

THROTTLE_UP_SPEED="33mbit"	# Target upload speed
THROTTLE_DOWN_SPEED="200mbit"	# Target download speed
# ===	===	===	===	===	===	===

setup_structure_if_missing() {
    local DOCSIS=""
    [ "${WAN_PROTO}" = "docsis" ] && DOCSIS="overhead 22 mpu 64"

    # Load CAKE module
    insmod sch_cake >/dev/null 2>/dev/null

    # --- Provision TX queues ---
    if ! tc qdisc show dev ${WAN_INTERFACE} | grep -q "root cake"; then
        tc qdisc del dev ${WAN_INTERFACE} root 2>/dev/null
        tc qdisc add dev ${WAN_INTERFACE} root handle 10: cake bandwidth ${THROTTLE_UP_SPEED} egress diffserv4 triple-isolate ack-filter wash ${DOCSIS}
    fi

    # --- Provision RX queues ---
    if ! tc qdisc show dev ${WAN_INTERFACE} | grep -q "ingress"; then
        # Start with a clean slate
        tc filter del dev ${WAN_INTERFACE} ingress 2>/dev/null
        tc qdisc del dev ${WAN_INTERFACE} ingress 2>/dev/null
        tc qdisc del dev ifb0 root 2>/dev/null

        # Load ifb and act_mirred to mirror inbound traffic to the IFB
        insmod ifb >/dev/null 2>/dev/null
        insmod act_mirred >/dev/null 2>/dev/null
        ip link set dev ifb0 up

        # Create the ingress hook
        tc qdisc add dev ${WAN_INTERFACE} ingress

        # Use mirred to mirror all RX packets over to ifb0
        tc filter add dev ${WAN_INTERFACE} parent ffff: protocol all prio 1 u32 match u32 0 0 action mirred egress redirect dev ifb0

        # Attach CAKE to ifb0
        tc qdisc add dev ifb0 root handle 20: cake bandwidth ${THROTTLE_DOWN_SPEED} diffserv4 triple-isolate wash ${DOCSIS}
    fi
}

start() {
    setup_structure_if_missing
    logger -t wan_throttle "wan_throttle started. Up: ${THROTTLE_UP_SPEED}, Down: ${THROTTLE_DOWN_SPEED}"
}

stop() {
    tc qdisc del dev ${WAN_INTERFACE} root 2>/dev/null
    tc qdisc del dev ${WAN_INTERFACE} ingress 2>/dev/null
    tc qdisc del dev ${WAN_INTERFACE} clsact 2>/dev/null
    tc qdisc del dev ifb0 root 2>/dev/null
    ip link set dev ifb0 down 2>/dev/null
    logger -t wan_throttle "wan_throttle stopped."
}

status() {
    echo "===	===	UL STATUS	===	===" | tee >(logger -t wan_throttle)
    echo "===	===	===	===	===	===" | tee >(logger -t wan_throttle)
    WAN_STAT=$( tc -s qdisc show dev ${WAN_INTERFACE} root ) && echo "${WAN_STAT}" | tee >(logger -t wan_throttle)
    echo "===	===	DL STATUS	===	===" | tee >(logger -t wan_throttle)
    echo "===	===	===	===	===	===" | tee >(logger -t wan_throttle)
    WAN_STAT=$( tc -s qdisc show dev ifb0 root ) && echo "${WAN_STAT}" | tee >(logger -t wan_throttle)
}
EOF

If you’re not on a DOCSIS (cable) connection, set WAN_PROTO="". Setting WAN_PROTO="docsis" just gives Linux Traffic Control a bit more room for the DOCSIS protocol overhead. We set DOCSIS="overhead 22 mpu 64" despite DOCSIS overhead being 18 bytes, just in case the ISP is using the 4-byte VLAN tag.

For networks which are solely or majority-WiFi, we must set up the CAKE queues to mimic the WiFi queues under the WMM (WiFi MultiMedia) standard for IEEE 802.11e Quality of Service (QoS), otherwise known as EDCA (Enhanced Distributed Channel Access):

WiFi queue | CAKE queue

  1. Voice (AC_VO) → Maps to CAKE’s Voice / Signaling Tin
  2. Video (AC_VI) → Maps to CAKE’s Video Tin
  3. Best Effort (AC_BE) → Maps to CAKE’s Best Effort Tin
  4. Background (AC_BK) → Maps to CAKE’s Bulk Tin

… this matches the 4 datastream classifications of WiFi to CAKE’s 4 datasteam classifications under diffserv4. If you set a higher or lower diffserv, and you have WiFi, the NIC has to chunk between the classes. For instance, if you set diffserv8, the NIC has to convert CAKE’s 8 queues into its own 4 queues. That happens outside of CAKE’s control, which can break CAKE’s buffer calculations, resulting in WiFi bufferbloat and jitter.

NOTE: When you first run /etc/init.d/wan_throttle, then do a bufferbloat test, you’ll notice that download speed is really low (mine was only 25.2 Mbps down and 27 Mbps up)… CAKE is in ‘learning mode’… after it figures out your internet connection’s parameters (which will take a few minutes of you using the internet… the harder you use it while CAKE is in learning mode, the quicker it’ll learn), you’ll see the download rate rise to your set limit.

/etc/init.d/wan_throttle status
===	===	UL STATUS	===	===
===	===	===	===	===	===
qdisc cake 10: root refcnt 17 bandwidth 33Mbit diffserv4 triple-isolate nonat wash ack-filter split-gso rtt 100ms noatm overhead 22 mpu 64 
Sent 165713954 bytes 299324 pkt (dropped 11873, overlimits 481022 requeues 2) 
backlog 0b 0p requeues 2
memory used: 1301760b of 4Mb
capacity estimate: 33Mbit
min/max network layer size:           28 /    1500
min/max overhead-adjusted size:       64 /    1522
active queues:                         0
average network hdr offset:           14

                   Bulk  Best Effort        Video        Voice
  thresh       2062Kbit       33Mbit    16500Kbit     8250Kbit
  target         8.81ms          5ms          5ms          5ms
  interval        104ms        100ms        100ms        100ms
  pk_delay          0us        882us        318us         11us
  av_delay          0us        187us         16us          0us
  sp_delay          0us          5us          6us          0us
  backlog            0b           0b           0b           0b
  pkts                0       311112           71           14
  bytes               0    166471932        31395          588
  way_inds            0            0            0            0
  way_miss            0          350            3            1
  way_cols            0            0            0            0
  drops               0            9            0            0
  marks               0        59396            0            0
  ack_drop            0        11864            0            0
  sp_flows            0            7            1            1
  bk_flows            0            1            0            0
  un_flows            0            0            0            0
  max_len             0        33967         2545           42
  quantum           300         1007          503          300
===	===	DL STATUS	===	===
===	===	===	===	===	===
qdisc cake 20: root refcnt 2 bandwidth 200Mbit diffserv4 triple-isolate nonat wash no-ack-filter split-gso rtt 100ms noatm overhead 22 mpu 64 
 Sent 1016431945 bytes 806113 pkt (dropped 437, overlimits 1000637 requeues 0) backlog 0b 0p requeues 0
 memory used: 7412Kb of 10000000b
 capacity estimate: 200Mbit
 min/max network layer size:           46 /    1500
 min/max overhead-adjusted size:       68 /    1522
 active queues:                         0
 average network hdr offset:           14

                   Bulk  Best Effort        Video        Voice
  thresh      12500Kbit      200Mbit      100Mbit       50Mbit
  target            5ms          5ms          5ms          5ms
  interval        100ms        100ms        100ms        100ms
  pk_delay          0us        992us          0us          7us
  av_delay          0us        498us          0us          0us
  sp_delay          0us          2us          0us          0us
  backlog            0b           0b           0b           0b
  pkts                0       806536            0           14
  bytes               0   1016950939            0          840
  way_inds            0          424            0            0
  way_miss            0          538            0            1
  way_cols            0            0            0            0
  drops               0          437            0            0
  marks               0        49538            0            0
  ack_drop            0            0            0            0
  sp_flows            0            1            0            1
  bk_flows            0            1            0            0
  un_flows            0            0            0            0
  max_len             0        58140            0           60
  quantum           381         1514         1514         1514

Note the 14 DoH packets, inbound and outbound, were shuttled over the highest-priority (Voice) queue, with a peak delay of 11us up / 7 us down and an average delay of 0 us.

On upload, the router dropped 11864 ACKs (to save upload bandwidth) and 9 packets, for a total of ‘dropped 11873’. It ECN-marked 59396 packets.

On download, the router dropped 0 ACKs and 437 packets, for a total of ‘dropped 437’. It ECN-marked 49538 packets. The 437 dropped packets is likely because not all of my lan devices can have ECN enabled, and because I was pushing bandwidth to the limit.

The router processed 806536 total packets, ECN-marking 49538 (6.142%) to slow down ECN-compatible traffic. It resorted to dropping 437 (0.0542%).

I’m going to now investigate methods of minimizing CPU utilization and Linux Load Average with this code.