Day 23 — Modify BPF DCTCP and instrument it
Today’s mission: take yesterday’s BPF DCTCP, learn what TCP’s congestion control actually does (so the numbers mean something), add per-ACK telemetry to a ringbuf, run a real
iperf3, and watch TCP’s internal state in real time. Total time: ~110 minutes.
Why this exercise
Yesterday loaded a stranger’s BPF DCTCP. Today changes one and observes the effect. This is the most important struct_ops skill: small, surgical modifications to working code.
Goal: emit one event per ACK to a ringbuf, with the connection’s current cwnd, in_flight, and srtt. Watch a transfer; correlate transfer events to internal TCP state.
This pattern — instrument an existing struct_ops module without changing its policy — is invaluable. You can debug a misbehaving CC algorithm, study how an unfamiliar one works, or feed real-time TCP state into your own monitoring system.
But here’s the catch: the payoff of this whole chapter is that you can read the four numbers you’re about to log — cwnd, in_flight, srtt, ssthresh — and know what they mean. Day 22 introduced cwnd and ssthresh and let you watch cwnd:10 scroll by in ss -ti. Today we add the two numbers it didn’t cover — in_flight (how full the pipe is right now) and srtt (the smoothed RTT) — plus the framing that ties all four together: the ACK clock. We’ll recap cwnd/ssthresh in a sentence each and link back to Day 22 rather than rebuild them. Skip this section and the telemetry is just noise; read it and the ringbuf becomes a window into TCP’s heartbeat.
The thing TCP is fighting: the ACK clock
Picture a sender with a 1 GB file and a network of unknown capacity. If it blasts all 1 GB onto the wire at once, it overruns some router’s queue, that router drops packets, and everyone’s throughput collapses. So TCP does the opposite of blasting: it sends a little, waits to hear that it arrived, and only then sends a little more. The “hearing that it arrived” is the ACK — the receiver acknowledges bytes it got.
This feedback loop is the ACK clock. Each ACK that comes back is permission to send roughly one more segment’s worth of data. The connection self-paces to the speed of the slowest link, because that link is what governs how fast ACKs trickle back. Everything TCP congestion control does is bookkeeping around this one idea: how much am I allowed to have outstanding, and how do I adjust that as ACKs arrive?
That bookkeeping is exactly four numbers.

cwnd — the congestion window (the speed limit)
Recap from Day 22: snd_cwnd is the cap on how many segments may be unacknowledged — in flight — at any instant, measured in packets (MSS-sized segments), not bytes. New connections start at the kernel’s initial window of 10 segments (cwnd:10 is what you saw in ss -ti) and grow it as the path proves it can take more. The only addition for today: cwnd is the speed limit that in_flight rides just underneath.
/* include/linux/tcp.h:225 */
u32 snd_cwnd; /* Sending congestion window */
in_flight — how full the pipe currently is
in_flight is the count of segments sent but not yet ACKed. The fundamental rule of the ACK clock is:
the sender may keep sending only while
in_flight < cwnd.
When a segment is sent, in_flight goes up. When an ACK arrives covering that segment, in_flight drops and a slot in the window frees up — which is precisely why in_ack_event is the natural instrumentation hook. That callback fires on each incoming ACK, i.e. each tick of the ACK clock, each moment the window opens a little. Watching the ringbuf is literally watching the clock tick.
The kernel doesn’t store in_flight as a field; it computes it (we’ll see the exact formula below) from packets_out:
/* include/linux/tcp.h:308 */
u32 packets_out; /* Packets which are "in flight" */
ssthresh — the gear shift between two growth modes
Recap from Day 22: snd_ssthresh (the slow-start threshold) is the boundary between TCP’s two growth phases — slow start below it (cwnd grows ~exponentially, doubling per RTT) and congestion avoidance at/above it (cwnd grows ~linearly, +1 per RTT). Day 22 derived both; the kernel’s test is one comparison:
/* include/linux/tcp.h:248 */
u32 snd_ssthresh; /* Slow start size threshold */
/* include/net/tcp.h:1520 */
static inline bool tcp_in_slow_start(const struct tcp_sock *tp)
{
return tcp_snd_cwnd(tp) < tp->snd_ssthresh;
}
What’s new for today is the gear-shift / cutback framing: DCTCP’s bpf_dctcp_ssthresh callback is what computes the new ssthresh when a congestion signal arrives — it decides where the gear shift lands after a cutback, which feeds the sawtooth below.
Loss: the sawtooth collapse
What happens on congestion (a loss, or for DCTCP an ECN signal)? TCP cuts cwnd and sets ssthresh to the reduced value. Classic Reno halves it; that’s the famous sawtooth: a slow linear climb, a sudden halving, climb again, halve again. This collapse is the entire basis of the anomaly-detection use case at the end of today — a sudden drop in in_flight/cwnd correlated with an RTT spike is a loss event. (And it’s why the loopback caveat later matters: over 127.0.0.1 there is no loss, so you only ever see the monotonic climb, never the teeth.)

srtt — the smoothed round-trip time
The last number is timing. srtt_us is the smoothed RTT: an exponentially weighted moving average (EWMA) of measured round-trip samples. TCP uses it to set retransmit timeouts — “if I haven’t heard back in roughly srtt plus some slack, assume the segment is lost.” A single fluky sample shouldn’t swing the timeout wildly, so TCP smooths.
/* include/linux/tcp.h:307 */
u32 srtt_us; /* smoothed round trip time << 3 in usecs */
Note the << 3: the field is stored in eighths of a microsecond. The kernel keeps three extra low bits of precision so the EWMA can track sub-microsecond changes without floating point. To read microseconds you shift right by 3 — which is exactly the >> 3 you’ll write in Step 2, and the bug you’ll deliberately introduce later if you forget it.
Putting it together
One ACK arrives → tcp_in_ack_event calls your in_ack_event callback → inside it cwnd may have just grown, in_flight has dropped by the acked amount, and srtt has been updated from the new RTT sample. Three of the four numbers move on every ACK; ssthresh moves only at the gear shift or a cutback. Logging them per ACK is logging the connection’s pulse.
Where DCTCP fits. Plain Reno reacts only to loss. DCTCP (Data Center TCP) reacts earlier and more smoothly to ECN marks — routers set a Congestion Experienced bit before their queues overflow. DCTCP counts what fraction of recently-acked packets were ECN-marked and scales its window reduction by that fraction, tracked as an EWMA called
alpha. The marked-packet counter isdelivered_ce:/* include/linux/tcp.h:310-311 */ u32 delivered; /* Total data packets delivered incl. rexmits */ u32 delivered_ce; /* Like the above but only ECE marked packets */The function you are about to edit —
bpf_dctcp_update_alpha— is the one that consumesdelivered_ceto updatealpha. You’re inserting telemetry at the top of it and leaving that alpha math untouched.
What’s in bpf_dctcp.c already
Open tools/testing/selftests/bpf/progs/bpf_dctcp.c. Take a minute to skim. Key callbacks DCTCP overrides:
init(bpf_dctcp_init) — set up per-socket DCTCP state (alpha, EWMA params).ssthresh(bpf_dctcp_ssthresh) — slow-start threshold computation (uses ECN ratio). This is the “gear shift / cutback” callback from the model above.in_ack_event(bpf_dctcp_update_alpha) — fires per ACK; updates the ECN/alpha EWMA fromdelivered_ce. This is where we’ll add our telemetry.cwnd_event(bpf_dctcp_cwnd_event) — handle congestion-window events.cong_avoid/undo_cwnd/set_state— Reno-style cwnd growth, loss recovery, and CA-state transitions.
(Note: DCTCP does not implement pkts_acked. Its per-ACK accounting lives entirely in in_ack_event / bpf_dctcp_update_alpha.)
The full vtable is in SEC(".struct_ops") struct tcp_congestion_ops dctcp = { ... } near the bottom.
The in_ack_event callback is ideal for our purpose: it fires per incoming ACK, which is roughly per outgoing-data segment ACKed — one tick of the ACK clock. The argument is struct sock *sk plus a flags bitmap.
The instrumentation
Step 1: declare a ringbuf
The repo-owned lab lives under ebpf/labs/day23/ and is a derivative of the
kernel’s bpf_dctcp.c (see the SPDX/provenance header in each file): it keeps the
real DCTCP algorithm, drops the selftest-only fault-injection scaffolding, and
adds the telemetry below. The shared record sits in its own header so the BPF
producer and the userspace consumer cannot drift —
ebpf/labs/day23/logged_dctcp.h:
#ifndef PRACTICAL_EBPF_LOGGED_DCTCP_H
#define PRACTICAL_EBPF_LOGGED_DCTCP_H
/*
* Shared ring-buffer record ABI for the Day 23 telemetry DCTCP derivative.
*
* The BPF producer (logged_dctcp.bpf.c) and the userspace consumer
* (logged_dctcp.c) both include this one header so a layout change cannot
* silently desynchronize the two sides. Ringbuf transports raw bytes, so the
* struct must be identical on both. Fixed-width integers are spelled with the
* plain C types that have the required width on every LP64 Linux target the
* lab supports (BPF is always 64-bit); this avoids pulling <linux/types.h>
* into the BPF object, which already gets these widths from vmlinux.h.
*/
struct tcp_event {
unsigned long long ts_ns; /* bpf_ktime_get_ns() monotonic timestamp */
unsigned int srtt_us; /* smoothed RTT in microseconds (srtt >> 3) */
unsigned int cwnd; /* snd_cwnd, in segments */
unsigned int in_flight; /* segments sent but not yet ACKed */
unsigned long long sk_cookie; /* per-flow id (kernel socket address) */
};
#endif /* PRACTICAL_EBPF_LOGGED_DCTCP_H */
The ring buffer is declared near the top of the derivative,
ebpf/labs/day23/logged_dctcp.bpf.c:
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 256 * 1024);
} rb SEC(".maps");
Step 2: add telemetry without replacing the policy
Do not replace .in_ack_event with a logging-only callback. DCTCP’s alpha update depends on that callback. Instead, insert the telemetry at the top of the existing bpf_dctcp_update_alpha function (the BPF program bound to the .in_ack_event slot) and leave the original logic below it unchanged. This is exactly how the lab source is structured — the telemetry emit, then the untouched DCTCP alpha update:
SEC("struct_ops")
void BPF_PROG(bpf_dctcp_update_alpha, struct sock *sk, __u32 flags)
{
const struct tcp_sock *tp = tcp_sk(sk);
struct bpf_dctcp *ca = inet_csk_ca(sk);
/* --- Day 23 telemetry: one record per ACK, pure read-only observation. */
struct tcp_event *e = bpf_ringbuf_reserve(&rb, sizeof(*e), 0);
if (e) {
__builtin_memset(e, 0, sizeof(*e));
e->ts_ns = bpf_ktime_get_ns();
e->srtt_us = tp->srtt_us >> 3; /* srtt is stored in eighths of a us */
e->cwnd = tp->snd_cwnd; /* kernel C uses the tcp_snd_cwnd() accessor */
e->in_flight = tp->packets_out - (tp->sacked_out + tp->lost_out) +
tp->retrans_out;
e->sk_cookie = (__u64)(unsigned long)sk; /* per-flow id; see chapter note */
bpf_ringbuf_submit(e, 0);
}
/* --- end telemetry; the original DCTCP alpha update runs unchanged below. */
/* Expired RTT */
if (!before(tp->snd_una, ca->next_seq)) {
__u32 delivered_ce = tp->delivered_ce - ca->old_delivered_ce;
__u32 alpha = ca->dctcp_alpha;
/* alpha = (1 - g) * alpha + g * F */
alpha -= min_not_zero(alpha, alpha >> dctcp_shift_g);
if (delivered_ce) {
__u32 delivered = tp->delivered - ca->old_delivered;
/* If dctcp_shift_g == 1, a 32bit value would overflow
* after 8 M packets.
*/
delivered_ce <<= (10 - dctcp_shift_g);
delivered_ce /= max(1U, delivered);
alpha = min(alpha + delivered_ce, DCTCP_MAX_ALPHA);
}
ca->dctcp_alpha = alpha;
dctcp_reset(tp, ca);
}
}
Each field maps straight back to the mental model: srtt_us >> 3 is the smoothed RTT in real microseconds; snd_cwnd is the current speed limit in segments; the in_flight expression is how full the pipe is right now. When you watch these scroll by, you’re watching the ACK clock tick.
If you prefer a wrapper, rename the original body to a helper and call it from the wrapper after emitting the event. Either way, the original alpha update must still run.
Why not
bpf_get_socket_cookie(sk)? It is not available totcp_congestion_opsprograms. The helper set for this program type is whateverbpf_tcp_ca_get_func_proto()(net/ipv4/bpf_tcp_ca.c) exposes —tcp_send_ack,bpf_sk_storage_get/_delete,bpf_{set,get}sockopt,ktime_get_coarse_ns— plus the base helpers;get_socket_cookieis only offered to skb/sock_ops/sock_addr program types. The verifier rejects it at load (program of this type cannot use helper bpf_get_socket_cookie), so the program never attaches. We instead cast the trustedstruct sock *skto a scalar: under root/CAP_PERFMON (allow_ptr_leaksis on, the normal case for loading struct_ops) the trustedPTR_TO_BTF_IDcasts cleanly, giving a stable per-flow id for the life of the connection.sk_cookiethen prints the kernel socket address rather than an SO_COOKIE id. If you need a true SO_COOKIE-style identity, stash one in aBPF_MAP_TYPE_SK_STORAGEviabpf_sk_storage_get(&map, sk, &init, BPF_SK_STORAGE_GET_F_CREATE)— that helper is in the tcp_ca set.
Sidebar: what BPF_MAP_TYPE_SK_STORAGE is
That last sentence drops a new map type on you, and it’s worth four sentences because it’s the right way to mint per-flow state. Socket-local storage attaches a private value to an individual socket — the value lives and dies with that socket. Unlike a hash map keyed by the sk pointer (where you’d have to remember to delete the entry, and risk a stale key if a new socket reused the address), the kernel reclaims SK_STORAGE automatically when the socket is freed — no stale-key problem, ever. You touch it with one helper, which gets-or-creates per-flow state in a single call:
struct flow_id *fid = bpf_sk_storage_get(&sk_ids, sk, &init,
BPF_SK_STORAGE_GET_F_CREATE);
With BPF_SK_STORAGE_GET_F_CREATE it allocates and initializes the blob on first touch, then returns the same blob on every later call — the canonical get-or-create per-flow state idiom, and the kernel-blessed alternative to casting the raw sk pointer. (The helper is in the tcp_ca allowlist, as the citation at the end of the previous note shows.) We won’t build a full SK_STORAGE lab today — for our purposes the raw-pointer cast is enough — but now you know the proper tool when you outgrow it.
Step 3: keep the callback slot, change only the algorithm name
Find the SEC(".struct_ops") struct tcp_congestion_ops dctcp = { ... } block. Keep the .in_ack_event slot pointed at the DCTCP implementation and change the name to avoid colliding with the original. The lab’s assembled vtable — the full required-plus-optional slot set, named bpf_dctcp_log:
SEC(".struct_ops")
struct tcp_congestion_ops dctcp = {
.init = (void *)bpf_dctcp_init,
.in_ack_event = (void *)bpf_dctcp_update_alpha, /* per-ACK telemetry hook */
.cwnd_event = (void *)bpf_dctcp_cwnd_event,
.ssthresh = (void *)bpf_dctcp_ssthresh, /* required */
.cong_avoid = (void *)bpf_dctcp_cong_avoid, /* required (or cong_control) */
.undo_cwnd = (void *)bpf_dctcp_cwnd_undo, /* required */
.set_state = (void *)bpf_dctcp_state,
.flags = TCP_CONG_NEEDS_ECN,
.name = "bpf_dctcp_log",
};
Name length matters. Congestion-control names are capped at
TCP_CA_NAME_MAX - 1= 15 usable characters because the struct field ischar name[16]and needs room for a trailing NUL. Since we load this CC as a BPF struct_ops module, a 16-character name fails at load time, not at selection. When the kernel initializes thenamemember,bpf_tcp_ca_init_member()(net/ipv4/bpf_tcp_ca.c:228) callsbpf_obj_name_cpy(tcp_ca->name, ..., sizeof(name)=16). That helper (kernel/bpf/syscall.c:1208) copies until it hits a NUL within the 16-byte window; with 16 non-NUL chars there’s no room for a terminator,srcreachesend, and it returns-EINVAL.init_memberpropagates that negative return, and__bpf_struct_ops_map_update_elem(kernel/bpf/bpf_struct_ops.c:763) doesgoto reset_unlock— so the whole struct_ops map update fails with EINVAL. A 16-character name likebpf_dctcp_loggedtherefore never registers at all: it never appears intcp_available_congestion_control, so there is no “registers fine,” no later truncated lookup, and noENOENT-on-select. (That truncated-select-then-ENOENTstory is only how the legacy kernel-module registration path behaves, which is not what this chapter does.) Keep the name to 15 chars or fewer:bpf_dctcp_logis 13 characters and safe.
Two field-access notes. (1) We read
tp->snd_cwnddirectly, which works in BPF (the realbpf_dctcp.cdoes the same). Kernel C convention, however, is thetcp_snd_cwnd(tp)accessor (include/net/tcp.h) — don’t be surprised when the C source uses the helper instead of the bare field. (2) The full kernel formula istcp_packets_in_flight(tp) = packets_out - (sacked_out + lost_out) + retrans_out; we includeretrans_outabove. Omitting it (as a simplification) undercounts in-flight bytes during loss recovery.
Userspace consumer
logged_dctcp is a new, out-of-tree libbpf program — not something make inside selftests/bpf produces (that builds test_progs). In this repo it is owned at ebpf/labs/day23/logged_dctcp.c and built by the lab Makefile. It is the Day 3 ringbuf loader plus a struct_ops attach.
The load is a struct_ops attach (distinct from a normal program attach), then a standard ringbuf poll loop:

The full loader — bpf_map__attach_struct_ops to register the CC, the ring-buffer poll loop, and a SIGINT/SIGTERM path that destroys the link (detaching and unregistering the CC) before freeing the consumer and skeleton:
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <string.h>
#include <bpf/libbpf.h>
#include "logged_dctcp.h"
#include "logged_dctcp.skel.h"
static volatile sig_atomic_t exiting;
static void handle_signal(int signal_number)
{
(void)signal_number;
exiting = 1;
}
static int install_signal_handlers(void)
{
const struct sigaction action = {
.sa_handler = handle_signal,
};
if (sigaction(SIGINT, &action, NULL) != 0 ||
sigaction(SIGTERM, &action, NULL) != 0) {
fprintf(stderr, "failed to install signal handlers: %s\n",
strerror(errno));
return -errno;
}
return 0;
}
static int handle_event(void *ctx, void *data, size_t size)
{
const struct tcp_event *e = data;
(void)ctx;
if (size != sizeof(*e)) {
fprintf(stderr, "unexpected ringbuf record size: %zu\n", size);
return 0;
}
printf("[sk=%llu t=%lluns] cwnd=%u in_flight=%u srtt=%uus\n",
(unsigned long long)e->sk_cookie, (unsigned long long)e->ts_ns,
e->cwnd, e->in_flight, e->srtt_us);
return 0;
}
int main(void)
{
struct logged_dctcp_bpf *skel = NULL;
struct bpf_link *link = NULL;
struct ring_buffer *ringbuf = NULL;
int err;
setvbuf(stdout, NULL, _IOLBF, 0);
err = install_signal_handlers();
if (err)
return 1;
skel = logged_dctcp_bpf__open_and_load();
if (!skel) {
fprintf(stderr, "failed to open and load logged_dctcp skeleton\n");
return 1;
}
/* struct_ops attach registers "bpf_dctcp_log" in the TCP CC framework. */
link = bpf_map__attach_struct_ops(skel->maps.dctcp);
if (!link) {
err = -errno;
fprintf(stderr,
"failed to attach struct_ops (need root or CAP_BPF): %s\n",
strerror(errno));
goto cleanup;
}
ringbuf = ring_buffer__new(bpf_map__fd(skel->maps.rb), handle_event,
NULL, NULL);
if (!ringbuf) {
err = -errno;
fprintf(stderr, "failed to create ring buffer: %s\n", strerror(errno));
goto cleanup;
}
fprintf(stderr,
"bpf_dctcp_log registered; select it per-socket with "
"'-C bpf_dctcp_log'. Ctrl-C to detach.\n");
while (!exiting) {
err = ring_buffer__poll(ringbuf, 100 /* ms */);
if (err == -EINTR) {
err = 0;
break;
}
if (err < 0) {
fprintf(stderr, "ring buffer poll failed: %s\n", strerror(-err));
break;
}
}
cleanup:
ring_buffer__free(ringbuf);
bpf_link__destroy(link); /* detaches + unregisters bpf_dctcp_log */
logged_dctcp_bpf__destroy(skel);
return err ? 1 : 0;
}
handle_event prints one line per event, validating the record size first:
[sk=18446612345678900 t=12345...] cwnd=10 in_flight=0 srtt=24us
Build it with the lab Makefile so make produces the logged_dctcp binary the Run section uses.
Run
Loading a struct_ops CC, adding it to the allowed list, running iperf3, and
then unwinding all of that cleanly is fiddly to do by hand — and a half-finished
run leaves a registered algorithm and a mutated sysctl behind. The repo ships a
safe runner, ebpf/labs/day23/run.sh, that owns every piece of live state and
restores it on any exit (success, error, or Ctrl-C): it captures
tcp_allowed_congestion_control and restores it, starts the loader and SIGTERMs
it (so the struct_ops link is destroyed and bpf_dctcp_log unregistered),
builds and tears down a netns + veth + netem path, and owns the iperf3
server and client by PID. Like scripts/smoke.sh, it is opt-in:
make -C ebpf/labs day23 # build the loader first (see the Lab environment page)
# veth+netem path (default) — the lossy path that makes the sawtooth visible:
EBPF_LABS_ALLOW_PRIVILEGED=1 \
sudo --preserve-env=EBPF_LABS_ALLOW_PRIVILEGED ebpf/labs/day23/run.sh veth
# or the always-available loopback path (monotonic climb only):
EBPF_LABS_ALLOW_PRIVILEGED=1 \
sudo --preserve-env=EBPF_LABS_ALLOW_PRIVILEGED ebpf/labs/day23/run.sh loopback
The loader prints one line per ACK to its stdout (the sk= value is the kernel
socket address — a stable per-flow id for the life of the connection, not an
SO_COOKIE id):
[sk=18446612345678900 t=12345...] cwnd=10 in_flight=0 srtt=24us
[sk=18446612345678900 t=12346...] cwnd=11 in_flight=10 srtt=31us
[sk=18446612345678900 t=12348...] cwnd=14 in_flight=12 srtt=29us
...
You’re now seeing TCP’s internal CC decisions in real time, per ACK, in a flow that runs through your custom BPF-defined algorithm. Read it against the model: cwnd starts at 10 (the kernel’s initial window) and grows — and because each ACK bumps it by ~1, you’re watching slow start’s exponential ramp. srtt updates per ACK as new RTT samples arrive. in_flight tracks how full the pipe is, hugging just under cwnd — exactly the in_flight curve from the sawtooth diagram. What you won’t see on loopback is the teeth, and the next box explains why.
Loopback caveat. Over
127.0.0.1there is no packet loss and the RTT is microseconds, so you only ever seecwndgrow monotonically — you will not see the loss-drivencwndcollapse and RTT spikes that the anomaly-detection use case below depends on. That is exactly why the runner defaults to thevethmode: it puts the peer in a separate netns and appliesnetem delay 20ms loss 1%to both directions, so with 1% loss injected you finally seecwndcut on a drop and climb back — the sawtooth made real.run.sh loopbackis the fallback when your host can’t create a netns.
The runner handles cleanup itself — on exit it unregisters bpf_dctcp_log, restores the exact tcp_allowed_congestion_control value it captured at start, and deletes the netns/veth it created. Its full text (read it before running anything privileged):
ebpf/labs/day23/run.sh
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-2.0
#
# run.sh — safe, self-cleaning runner for the Day 23 telemetry DCTCP lab.
#
# It owns every piece of live state it creates and restores/destroys all of it
# on ANY exit (success, error, or signal), in reverse order of creation:
#
# 1. captures net.ipv4.tcp_allowed_congestion_control and restores it;
# 2. starts the logged_dctcp loader (which registers "bpf_dctcp_log") and
# SIGTERMs it on exit, so the struct_ops link is destroyed and the CC is
# unregistered deterministically;
# 3. (veth mode) creates a network namespace + veth pair + netem qdisc and
# tears them down;
# 4. starts an iperf3 server and client it owns by PID and kills them.
#
# Like scripts/smoke.sh this is opt-in and refuses to run without an explicit
# acknowledgement and root. It never installs packages.
#
# Usage:
# EBPF_LABS_ALLOW_PRIVILEGED=1 sudo --preserve-env=EBPF_LABS_ALLOW_PRIVILEGED \
# ./run.sh [veth|loopback]
#
# Environment knobs (all optional):
# DURATION iperf3 transfer seconds (default 10)
# NETEM netem parameters in veth mode (default "delay 20ms loss 1%")
set -euo pipefail
HERE=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
ROOT=$(cd -- "$HERE/.." && pwd)
LOADER="$ROOT/.output/day23/logged_dctcp"
MODE=${1:-veth}
DURATION=${DURATION:-10}
NETEM=${NETEM:-delay 20ms loss 1%}
CC_NAME="bpf_dctcp_log"
SUFFIX=$$
OCTET=$((SUFFIX % 200 + 20))
NS="ebpf-day23-$SUFFIX"
VETH="d23h$SUFFIX"
VPEER="d23p$SUFFIX"
HOST_IP="10.223.$OCTET.1"
PEER_IP="10.223.$OCTET.2"
PREFIX=24
LOADER_PID=
IPERF_SERVER_PID=
ORIG_ALLOWED=
NS_CREATED=0
VETH_CREATED=0
ALLOWED_CHANGED=0
log() { printf 'day23: %s\n' "$*" >&2; }
fail() { printf 'day23: %s\n' "$*" >&2; exit 1; }
cleanup() {
local status=$?
trap - EXIT INT TERM
if [[ -n $IPERF_SERVER_PID ]] && kill -0 "$IPERF_SERVER_PID" 2>/dev/null; then
kill -TERM "$IPERF_SERVER_PID" 2>/dev/null || true
wait "$IPERF_SERVER_PID" 2>/dev/null || true
fi
if [[ -n $LOADER_PID ]] && kill -0 "$LOADER_PID" 2>/dev/null; then
# SIGTERM triggers the loader's deterministic detach + unregister.
kill -TERM "$LOADER_PID" 2>/dev/null || true
wait "$LOADER_PID" 2>/dev/null || true
fi
if (( VETH_CREATED )); then
ip link del "$VETH" 2>/dev/null || true
fi
if (( NS_CREATED )); then
ip netns del "$NS" 2>/dev/null || true
fi
if (( ALLOWED_CHANGED )) && [[ -n $ORIG_ALLOWED ]]; then
sysctl -q -w "net.ipv4.tcp_allowed_congestion_control=$ORIG_ALLOWED" \
2>/dev/null || true
log "restored tcp_allowed_congestion_control to: $ORIG_ALLOWED"
fi
exit "$status"
}
trap cleanup EXIT INT TERM
# --- Preconditions -----------------------------------------------------------
if [[ ${EBPF_LABS_ALLOW_PRIVILEGED:-0} != 1 ]]; then
fail 'set EBPF_LABS_ALLOW_PRIVILEGED=1 to acknowledge privileged BPF attachment'
fi
if (( EUID != 0 )); then
fail 'run with sudo after setting EBPF_LABS_ALLOW_PRIVILEGED=1'
fi
[[ ${EBPF_LABS_ALLOW_STRUCT_OPS:-0} == 1 ]] ||
fail 'set EBPF_LABS_ALLOW_STRUCT_OPS=1 to acknowledge TCP struct_ops registration'
[[ -x $LOADER ]] || fail "missing loader $LOADER; build day23 first (see chapter)"
for tool in ip sysctl iperf3 ss; do
command -v "$tool" >/dev/null 2>&1 || fail "missing required command: $tool"
done
case $MODE in veth|loopback) ;; *) fail "mode must be 'veth' or 'loopback'";; esac
# --- 1. Capture the CC allowed-list before touching anything -----------------
ORIG_ALLOWED=$(sysctl -n net.ipv4.tcp_allowed_congestion_control)
log "saved tcp_allowed_congestion_control: $ORIG_ALLOWED"
# --- 2. Start the loader; wait until bpf_dctcp_log is registered -------------
"$LOADER" &
LOADER_PID=$!
for _ in $(seq 1 50); do
if ! kill -0 "$LOADER_PID" 2>/dev/null; then
wait "$LOADER_PID" 2>/dev/null || true
fail 'loader exited before registering (need root/CAP_BPF and a struct_ops-capable kernel)'
fi
if grep -qw "$CC_NAME" /proc/sys/net/ipv4/tcp_available_congestion_control; then
break
fi
sleep 0.1
done
grep -qw "$CC_NAME" /proc/sys/net/ipv4/tcp_available_congestion_control ||
fail "timed out waiting for $CC_NAME in tcp_available_congestion_control"
log "$CC_NAME is registered and available"
# --- 3. Allow the algorithm for the client socket ----------------------------
sysctl -q -w "net.ipv4.tcp_allowed_congestion_control=$ORIG_ALLOWED $CC_NAME"
ALLOWED_CHANGED=1
# --- 4. Wire up the transfer path and run iperf3 -----------------------------
if [[ $MODE == veth ]]; then
log "building netns/veth path with netem: $NETEM"
ip netns add "$NS"; NS_CREATED=1
ip link add "$VETH" type veth peer name "$VPEER"; VETH_CREATED=1
ip link set "$VPEER" netns "$NS"
ip addr add "$HOST_IP/$PREFIX" dev "$VETH"
ip link set "$VETH" up
ip -n "$NS" addr add "$PEER_IP/$PREFIX" dev "$VPEER"
ip -n "$NS" link set "$VPEER" up
ip -n "$NS" link set lo up
# netem on both directions so loss/delay is symmetric.
# shellcheck disable=SC2086
tc qdisc add dev "$VETH" root netem $NETEM
# shellcheck disable=SC2086
ip netns exec "$NS" tc qdisc add dev "$VPEER" root netem $NETEM
ip netns exec "$NS" iperf3 -s -1 -B "$PEER_IP" >/dev/null 2>&1 &
IPERF_SERVER_PID=$!
sleep 0.5
log "running: iperf3 -c $PEER_IP -C $CC_NAME -t $DURATION"
iperf3 -c "$PEER_IP" -C "$CC_NAME" -t "$DURATION" || true
log "socket CC (should show $CC_NAME):"
ss -ti "dst $PEER_IP" | grep -w "$CC_NAME" || log "(no live socket sampled)"
else
log "running over loopback (no loss; expect a monotonic cwnd climb only)"
iperf3 -s -1 >/dev/null 2>&1 &
IPERF_SERVER_PID=$!
sleep 0.5
log "running: iperf3 -c 127.0.0.1 -C $CC_NAME -t $DURATION"
iperf3 -c 127.0.0.1 -C "$CC_NAME" -t "$DURATION" || true
log "socket CC (should show $CC_NAME):"
ss -ti "dst 127.0.0.1" | grep -w "$CC_NAME" || log "(no live socket sampled)"
fi
log "transfer complete; telemetry above came from the loader on fd 1"
log "cleaning up (unregister $CC_NAME, restore allowed-list, delete netns/veth)"
What to do with this data
A few things you couldn’t do before:
- Per-flow performance graphs: export to a TSDB, plot cwnd over time per
sk_cookie. - Anomaly detection: alert when
in_flightcollapses (loss event), correlated with RTT spikes — that’s the sawtooth tooth, and on a real lossy path you can catch it live. - Verify CC behavior: see whether your tuning is actually changing TCP’s reaction.
- Capacity planning: understand the cwnd distribution of your real workload, not synthetic benchmarks.
There are no Dumb Questions
Q: Why cast the sk pointer to a scalar instead of using a socket cookie?
A: bpf_get_socket_cookie isn’t in the tcp_ca helper set, so the verifier rejects it at load (see the instrumentation step). Casting the trusted struct sock *sk to a u64 gives a stable per-flow id for the life of the connection at zero cost — it’s the kernel socket address, which doesn’t change while the socket lives. If you need a true SO_COOKIE-style identity that survives address reuse, mint one in BPF_MAP_TYPE_SK_STORAGE instead (that helper is allowed).
Q: Why instrument in_ack_event and not pkts_acked?
A: DCTCP doesn’t implement pkts_acked at all — its per-ACK accounting lives entirely in in_ack_event / bpf_dctcp_update_alpha. Hooking the callback the algorithm actually runs means our telemetry rides the exact code path that already fires per ACK, with no extra slot to wire up. (You can add a pkts_acked callback as a brand-new slot — that’s the “Add another callback” exercise below — but for logging the four numbers, in_ack_event is where the data already flows.)
Q: Does emitting to the ringbuf change what the CC algorithm does?
A: No. We only read fields and submit a copy; the original alpha-update math runs untouched right below the telemetry. Pure observation is safe — it’s mutation of TCP state from a callback that needs care (see the Check question).
What to break
Wrong field semantics
e->srtt_us = tp->srtt_us; /* without >> 3 */
tp->srtt_us is stored in eighths of a microsecond (gives sub-µs precision without floats — the << 3 in the model section). Reading it directly produces values 8× too large. Symptom: latency reports look like RTT is hundreds of ms when it’s actually tens. Lesson: kernel field semantics matter — read the field’s docstring (or include/linux/tcp.h) before assuming.
Forget to release
struct tcp_event *e = bpf_ringbuf_reserve(&rb, sizeof(*e), 0);
if (!e) return;
e->ts_ns = ...;
/* forgot bpf_ringbuf_submit */
return;
Verifier rejects: Unreleased reference id=N alloc_insn=M. The ringbuf-reserve return is a refcounted resource, exactly like a kfunc acquire. Same rules: every path must submit or discard.
Run with high concurrency
Run 100 parallel iperf3 streams. Watch ringbuf drops via the percpu drop counter (Day 13 pattern). At ~100K events/sec, you’ll start dropping. Fix:
- Filter (only emit when cwnd changes by > N).
- Size up the ringbuf.
- Aggregate per-sk in a map; emit summary periodically.
Add another callback
DCTCP itself doesn’t use pkts_acked, but tcp_congestion_ops has the slot — so you can add it as a new callback in your module to capture per-ACK packet/ECN accounting:
SEC("struct_ops/dctcp_pkts_acked_logged")
void BPF_PROG(my_pkts_acked, struct sock *sk, const struct ack_sample *sample)
{
/* emit packet count, RTT sample, etc. */
}
Add .pkts_acked = (void *)my_pkts_acked to the vtable. Now you have two BPF programs in the same struct_ops module, both fed real-time data. (This is a new slot you’re populating, not a DCTCP callback you’re overriding — the upstream DCTCP leaves pkts_acked NULL.)
What to read in the kernel
-
net/ipv4/tcp_input.c— searchin_ack_event. The C call site that invokes your BPF callback per incoming ACK. Trace the call path fromtcp_v4_rcvdown to thein_ack_eventinvocation. Note that the kernel calls only the socket’s selected CC’s callback — a single indirect call throughicsk->icsk_ca_ops->in_ack_event— so your callback runs only for connections actually using your algorithm, not for every registered CC. -
include/uapi/linux/tcp.h—struct tcp_info. The same per-connection fields you read off the BTFstruct tcp_sockpointer are also surfaced to userspace here viagetsockopt(TCP_INFO). When you wonder “what other state can I expose?” — this is the catalog. (Notebpf_get_socket_cookiereturns an SO_COOKIEu64, not atcp_info, and isn’t available here — see the instrumentation step.) -
include/linux/tcp.h— the kernel-internalstruct tcp_sock. ~170 fields. Read once. The relationship:struct tcp_info(UAPI) is a curated subset ofstruct tcp_sock(internal); BPF programs can read either by castingstruct sock *sk → struct tcp_sock * = (void *)sk. The four numbers you logged today live at lines 225 (snd_cwnd), 248 (snd_ssthresh), 307 (srtt_us), and 308 (packets_out). -
include/net/tcp.h— the accessors and helpers from the model:tcp_snd_cwnd()(:1509),tcp_in_slow_start()(:1520), andtcp_packets_in_flight()(:1502) =packets_out - tcp_left_out(tp) + retrans_out, withtcp_left_out()(:1483) =sacked_out + lost_out. This is where thein_flightformula you typed comes from. -
tools/testing/selftests/bpf/progs/bpf_cubic.c— another struct_ops example, full Cubic implementation. Compare againstbpf_dctcp.cfor stylistic differences. -
net/ipv4/tcp_cong.c— CC framework.tcp_register_congestion_control. How yourbpf_dctcp_logends up callable. Day 16 (network book) covered this in detail.
Bullet Points
- The ACK clock is the whole game. TCP self-paces: each incoming ACK frees window space and is permission to send ~1 more segment.
in_ack_eventfires on each tick, which is why it’s the natural per-segment hook. - Four numbers tell the story.
cwnd(snd_cwnd) is the speed limit in segments;in_flightis how many are outstanding (cwndcaps it);ssthreshis the gear shift — below it slow start doublescwndper RTT, at/above it congestion avoidance adds ~1 per RTT;srtt_us(stored<< 3, eighths of a µs) is the smoothed RTT for timeouts. Loss/ECN cutscwndandssthresh— the sawtooth. - struct_ops modules are ordinary BPF programs you can edit, instrument, and test.
- Add a ringbuf to one callback to get per-event telemetry without modifying the kernel.
- The verifier still applies; standard ringbuf reserve/submit and reference rules.
BPF_MAP_TYPE_SK_STORAGE(bpf_sk_storage_get(..., BPF_SK_STORAGE_GET_F_CREATE)) is the kernel-blessed per-socket store — auto-reclaimed with the socket, and in thetcp_caallowlist — when you need a real per-flow id.- The pattern works for any struct_ops vtable: TCP CC, sched_ext, future ones.
- For high-rate observability, drop counters and rate-limiting filters in BPF are essential.
Check question
You add bpf_ringbuf_reserve to a struct_ops callback that fires per ACK at line rate. What’s the worst-case impact on TCP performance?
Click to reveal answer
Answer: Each callback adds ~50–100 ns. At 1 Mpps (a high-rate flow on a fast link), that’s 5–10% extra CPU just for the BPF reserve+submit cost. If the ringbuf fills (consumer can’t keep up), bpf_ringbuf_reserve returns NULL and your code skips the emit entirely; TCP itself is unaffected — the original CC logic still runs.
The bigger risk is if your BPF logic blocks somehow. It can’t — non-sleepable struct_ops can’t sleep, take regular mutexes, or do anything that schedules. It also can’t modify TCP state in a way the algorithm wasn’t expecting (you can — be careful). Pure observation (read fields, emit to ringbuf) is safe up to whatever overhead you can tolerate; mutation needs explicit care because you’re now changing what the CC algorithm does, not just watching it.
For 99% of telemetry use cases, the worst case is: (a) some events get dropped under load (handle via drop counter); (b) ~5% extra CPU on hot connections. Both manageable.
Tomorrow
Day 24: BTF spelunking. Find a kfunc on your kernel that you’ve never used, read its signature, write a program that calls it.