There is a number on the server that carries this blog, and its name makes me a promise:
$ cat /proc/sys/net/core/netdev_budget_usecs 2000 The usecs on the end means microseconds. The kernel documentation agrees: "maximum number
of microseconds in one NAPI polling cycle." Two thousand microseconds, two milliseconds —
the time the softirq that processes packets from the network card grants itself.
I wanted to find out how well that promise is kept. On a disposable virtual machine I set
the value to 1, then 500, then 1000. All three times the "I got squeezed" counter rose about
286 times a second, with no difference between the three that measurement noise could not
explain. Then I wrote 1001. The counter dropped to zero and stayed there.
A 999-microsecond change did nothing. A 1-microsecond change did everything. Because this
knob's unit is not the microsecond; it is the kernel's clock tick.
My Server's Counter Says Fifty-Three Million
I opened the real ledger first. /proc/net/softnet_stat gives one line per CPU, every field
in hex: packets processed, packets dropped, time_squeeze. The blog's server is an 18-core
KVM guest running kernel 6.8.0-147-generic, up for 19 days and 14 hours. The totals:
| Counter | Value |
|---|---|
| processed | 6,217,038,679 |
| dropped | 120 |
time_squeeze | 53,077,384 |
One hundred and twenty dropped packets in nineteen days. Against a ledger that clean, a
"squeeze" counter at fifty-three million looks strange. I took three separate thirty-second
samples (median): processed rises by 90,372, time_squeeze by 1,091. That is 3,012 packets
and 36 squeezes a second. The hypervisor is not taking the vCPU out from under me either —
but do not look for that in softnet_stat, which has no such field; stolen time lives in the
eighth column of the cpu line in /proc/stat, and its cumulative value over nineteen days
is zero.
So I wanted to ask: is my packet budget running out, or my time budget? The counter is called
time_squeeze. But reading the source showed me it cannot answer that question.
Two Limits, One Counter
The function that does the softirq's receive work is net_rx_action. In kernel v7.3 its body
opens and closes like this:
unsigned long time_limit = jiffies + usecs_to_jiffies(READ_ONCE(net_hotdata.netdev_budget_usecs)); int budget = READ_ONCE(net_hotdata.netdev_budget); ... budget -= napi_poll(n, &repoll); /* If softirq window is exhausted then punt. * Allow this to run for 2 jiffies since which will allow * an average latency of 1.5/HZ. */ if (unlikely(budget <= 0 || time_after_eq(jiffies, time_limit))) { WRITE_ONCE(sd->time_squeeze, sd->time_squeeze + 1); break; } Two conditions, one WRITE_ONCE. The same counter rises whether the packet budget ran out or
the time limit passed. So time_squeeze, despite its name, does not say "I stopped because of
time"; it says "I stopped because of one of two limits." How many of the fifty-three million
on my server are which — there is no way to learn that from this counter. The one observation
tool I had was refusing my question.
There is also that comment: "Allow this to run for 2 jiffies since which will allow an
average latency of 1.5/HZ." On first reading I took it for a line the code had outgrown —
the duration is now whatever number you write. Reading it to the end showed me I was wrong.
That "average latency of 1.5/HZ" is precisely the conclusion I reach by measurement a few
paragraphs from here, "one jiffy guaranteed, two at most": the average of the two is 1.5
ticks. Nor is the comment stale — the default has been exactly 2 jiffies since v5.7 and the
floor since v6.14, so on any machine that left the knob alone it still tells the truth. What
had aged was not the comment but the way I read it.
What It Calls "Microseconds" Is a Tick
The critical line is usecs_to_jiffies. In include/linux/jiffies.h, when HZ divides
USEC_PER_SEC exactly, the conversion is this:
static inline unsigned long _usecs_to_jiffies(const unsigned int u) { return (u + (USEC_PER_SEC / HZ) - 1) / (USEC_PER_SEC / HZ); } Integer division, rounding up. On a kernel with HZ=1000 one jiffy is 1,000 microseconds, so
every value from 1 to 1,000 becomes 1 jiffy, and every value from 1,001 to 2,000 becomes
- The microsecond digits of your number never enter the arithmetic; only which tick-sized box it falls into does.
That jiffy count then becomes a deadline, jiffies + N, checked with
time_after_eq(jiffies, time_limit). There is a second subtlety here: the moment you enter
net_rx_action is a random point between two ticks of the clock. If you wrote N=1, what you
have is "until the next tick" — sometimes a full jiffy, sometimes almost nothing. With N=2, a
tick arriving right after entry still leaves a whole jiffy behind it. Guaranteed one jiffy,
at most two.
I Measured the Plateau
Reading the theory is one thing; moving the counter is another. On Docker Desktop's linuxkit
virtual machine I opened a --privileged --network host container: kernel 6.10.14-linuxkit,
CONFIG_HZ=1000 (/proc/timer_list advanced 2,005 jiffies in two seconds — an independent
check), default netdev_budget_usecs 2000.
One more thing belongs here: this knob is not per network namespace. net_core_table is
registered for init_net only, so the value I wrote from a privileged container is the
host's global one. That is why I picked a disposable virtual machine; open the same
container on your own machine and you have changed the network stack for every container
on it at once.
Not everyone can repeat this sweep today, and I should say so up front: the values 1, 500 and
1,000 below sit under the lower bound on kernels after v6.14 and v6.12.18, and are rejected
there. My lab kernel predates that bound, which is why I can see the cliff at all; I will come
to the bound itself a few sections from here.
The design is this. So the packet budget could never bind, I raised netdev_budget from 300
to 1,000,000; that leaves the time limit as the only thing able to move the counter. For load
I ran 64-byte UDP over loopback (iperf3 -u -b 0 -l 64). For each setting I took three
ten-second measurements and recorded the median, logging packet rate too so I could see the
load held steady. In the same window I counted net_rx_action calls with the ftrace function
profiler. The last column is the ratio of the two medians on the same row, so you can recompute
the table yourself.
usecs | jiffy | packets/s | squeezes/s | net_rx_action/s | squeezes per million calls |
|---|---|---|---|---|---|
| 1 | 1 | 666,930 | 288 | 648,631 | 444 |
| 500 | 1 | 670,177 | 282 | 652,922 | 432 |
| 1000 | 1 | 664,931 | 286 | 648,443 | 441 |
| 1001 | 2 | 664,640 | 0 | 646,507 | 0 |
| 2000 | 2 | 661,689 | 0 | 644,536 | 0 |
The spread between 288, 282 and 286 in the first three rows is no larger than the variation
between three repeats of a single setting (279–290). A thousandfold change between 1 and 1000
is, to this counter, indistinguishable. The single microsecond in the fourth row goes well
past distinguishable: the counter is exactly zero.
I ran the same sweep again with ftrace off, and the packet rate rose to 1.37 million: 205,
208 and 207 squeezes for 1/500/1000, and zero again from 1001 up. Two independent runs, the
same cliff, different load. One entertaining side effect: with tracing on, the packet rate
halved yet the squeeze rate went up — lengthening every call also raises the odds of being
caught by a tick. The observation tool feeds what it measures.
There is one thing I could not measure, and I will not hide it: I never made the packet budget
bind. With netdev_budget left at 300 and the time limit raised to 1,000,000 microseconds, the
squeeze counter stayed at zero even with 1.35 million packets a second flowing. The table above
explains why: with tracing on, 666,930 packets a second arrive against 648,631 net_rx_action
calls — roughly one packet per call. The backlog on loopback never reaches 300. So my "two limits, one counter"
finding comes from the source; I could not make the packet side show up in the counter in this
lab.
Why the Cliff Is Exactly There
The numbers fit the mechanism. About 440 squeezes per million calls is 4.4 per ten
thousand. The overwhelming majority of calls exit quietly because the queue emptied; the ones
that move the counter are the unlucky minority that met a clock tick while work was still
queued. With N=1 that minority appears whenever the "until the next tick" window lands short.
With N=2 there is a full jiffy behind the entry, and a poll of one or two packets cannot
consume it — hence zero.
This is exactly the observation recorded in the kernel tree in 2020. The rationale of commit
a4837980fd9f says it in one sentence: below HZ 1000, 2000 microseconds rounds up to 1 jiffy,
"but expires randomly because next timer interrupt could come shortly after starting softirq."
The gap between 286 and 0 in my lab is that sentence's footprint in a counter.
There Is a Second Tick-Based Clock Outside
I had assumed that if I made the knob large enough, my packets would be processed without
interruption. I was wrong: there is a second time limit on the same path, and nobody can tune
it. From kernel/softirq.c:
#define MAX_SOFTIRQ_TIME msecs_to_jiffies(2) #define MAX_SOFTIRQ_RESTART 10 ... unsigned long end = jiffies + MAX_SOFTIRQ_TIME; ... if (pending) { if (time_before(jiffies, end) && !need_resched() && --max_restart) goto restart; wakeup_softirqd(); } This limit does not cut a running handler in half; once net_rx_action is in, it spends its
own budget to the end. What it cuts off is the right to re-enter: if softirqs are still
pending at the end of a round and jiffies has passed this deadline, it does not try again
and hands the work to the ksoftirqd thread. So blowing up netdev_budget_usecs lengthens a
single poll, not the chain of polls.
What stands out is that this outer limit is subject to the same rounding. msecs_to_jiffies
rounds up just like _usecs_to_jiffies, so the window the source comment calls "2 ms" varies
by machine:
| HZ | msecs_to_jiffies(2) | guaranteed | upper bound |
|---|---|---|---|
| 1000 | 2 jiffies | 1 ms | 2 ms |
| 250 | 1 jiffy | 0 | 4 ms |
| 100 | 1 jiffy | 0 | 10 ms |
I split that column deliberately, because the random entry phase from earlier applies here too:
the deadline is again jiffies + N and the check is again time_before. On the rows where N=1
there is no floor at all — the window can fall to nothing. Writing a single "actual window"
number would have been me doing, in my own table, exactly what I criticise the tuning recipes for.
Next to the comment it says these limits "have been established via experimentation." Whatever
HZ the machine in that experiment had, the upper bound on your machine today may be two or five
times different. One of them I can tune, the other I cannot: both clocks run on the same tick, and neither
one's unit is the microsecond.
One Knob, Three Commits
The knob's history is the story of how the word "microseconds" was given and taken back.
v4.12 (2017). The constant jiffies + 2 inside net_rx_action became a sysctl. My
favourite part of the commit message is the author's reasoning: "a very fast machine might
tune this to 1000 microseconds, while my regression testing 486DX-25 needs it to be 4000
microseconds on a nearly idle network to prevent time_squeeze from being incremented." And at
the end: "Version 2: changed jiffies to microseconds for predictable units." The intent is
explicit; the unit was changed to make it predictable. The default was written as 2000 and the
lower bound left at zero.
v5.7 (2020). The regression above was found. The fix was to tie the default to HZ:
.netdev_budget_usecs = 2 * USEC_PER_SEC / HZ. A comment went in beside it, typo included:
"Must be at least 2 jiffes to guarantee 1 jiffy timeout". The default is no longer a fixed
microsecond value; it is what "two ticks" happens to be in microseconds.
v6.14 (2025). Someone noticed a user could still sysctl their way below that bound, and
extra1 was pinned to 2 jiffies. The commit message spells out the HZ equivalents: "2 ms for
1000 Hz, 8ms for 250 Hz, 20 ms for 100 Hz." The fix was also backported to the 6.12 LTS line:
absent in v6.12.17, present in v6.12.18.
Which Number Applies on Your Kernel
For the common HZ values that divide USEC_PER_SEC exactly — derived from
2 * USEC_PER_SEC / HZ and the round-up formula:
| HZ | 1 jiffy | default usecs | v6.14+ lower bound | range giving identical behaviour |
|---|---|---|---|---|
| 100 | 10,000 µs | 20,000 | 20,000 | 10,001–20,000 |
| 250 | 4,000 µs | 8,000 | 8,000 | 4,001–8,000 |
| 1000 | 1,000 µs | 2,000 | 2,000 | 1,001–2,000 |
Values such as HZ=300, which do not divide USEC_PER_SEC exactly, fall into the
multiply-and-shift branch of the conversion; do not apply the round-up arithmetic above to
that branch as is.
I checked the table against machines too. I have two different HZ values on hand: the blog
server and the linuxkit VM are both HZ=1000, and both default to 2000. I started a parked
Debian 13 virtual machine — kernel 6.12.115+deb13.1-cloud-amd64, CONFIG_HZ=250 — and read:
$ cat /proc/sys/net/core/netdev_budget_usecs 8000 Same sysctl, same documentation, three machines, two different defaults. Most of the tuning
recipes online say "set net.core.netdev_budget_usecs=2000". On an HZ=250 machine that is not
raising anything; it is dividing the default by four — and going back to behaviour recorded
as a regression in 2020. I was curious, so I tried it:
$ sudo sysctl -w net.core.netdev_budget_usecs=2000 sysctl: setting key "net.core.netdev_budget_usecs": Invalid argument I did not expect that. By the version notes above the lower bound arrived in v6.14, and this
machine is on the 6.12 series. The rejection showed me the fix had been backported to the LTS
line; the v6.12.18 pin came afterwards, by going back to the source. In a world with
backports, checking a version tag in the mainline tree and declaring "this kernel does not have
it" is not enough. I am glad I tried the write.
I did not try that write on the live blog server. I do not know whether the 6.8 series has
the bound; finding out by nudging a production network setting is too high a price for
curiosity. Anyone who wants to ask the same question should ask it on a disposable machine.
Do Not Touch It Without Measuring
The practical list that came out of my measurements:
- Learn HZ first.
grep CONFIG_HZ= /boot/config-$(uname -r)or/proc/config.gz. Without that number, anything you write tonetdev_budget_usecsis a guess. - Do not make changes smaller than a jiffy. On HZ=1000, going from 2,400 to 2,900 is nothing — both are three ticks; going from 2,000 to 2,001 is a jiffy. A change is only a change if it changes the box. (I picked both examples above 2,000 deliberately: on current kernels you cannot write below it.)
- Do not aim below the default. The default is already "two ticks"; going under it means losing the guaranteed window. New kernels protect you from this, old ones do not.
- A squeeze is a deferred packet, not a dropped one. When the counter rises the poll stops and the work goes to
ksoftirqd; the packet stays queued. My own server's ledger says this plainly: 53 million squeezes against 120 dropped packets. Do not read a squeeze as a fault on its own — read it next to your latency measurement. - If
time_squeezeis rising, the counter cannot tell you which limit it was. To separate them, raise one limit absurdly high and watch the counter — the way I did in the lab, not in production. The counter stays silent, but arithmetic narrows it: at 3,012 packets a second spread over 18 CPUs, filling a 300-packet budget in one call would need a hundred milliseconds of backlog. - The real levers are elsewhere. If latency really does track squeezes, the first knob to reach for is not
netdev_budget_usecs: it isnetdev_budgeton the packet side, RSS/RPS on the distribution side, IRQ coalescing on the card. And after v6.14 the only legal direction here is up — on HZ=1000 your decision space is2,000, 3,000, 4,000…, which is just the question "how many ticks". Raising the tick count means letting a single poll hold a CPU in softirq context that long; it is not free. - A high packet rate alone does not mean squeezes. With 1.35 million packets a second on loopback, the 300-packet budget never bound once. Where the queue builds up is what matters.
Conclusion
The unit in a knob's name is a promise about its resolution. netdev_budget_usecs made that
promise deliberately in 2017 — the commit message says "predictable units" — but the clock
underneath it does not count microseconds, it counts ticks. And the kernel keeps only the
promise its clock can keep: it takes your value, rounds it up, and quietly discards the rest.
That silence continues in the documentation. I searched
Documentation/admin-guide/sysctl/net.rst for "HZ", "jiffy" and "jiffies": none of them
appear. Someone reading that file has no way to learn that the default varies from machine to
machine, that there is a lower bound, or that the number they write is rounded up. When I
wrote ten different values to tcp_adv_win_scale and watched the window not
move, and when
I measured the clock that declares 1 ms walking in 41-nanosecond
steps, the same gap
was there: the unit of the interface is not the unit of the mechanism beneath it.
Ask it of your own setup: of the lines you added to sysctl.conf in the last six months, how
many did you measure as having changed anything? For this one the measurement is above, and the
answer turned out to be "whichever box my number landed in." The remaining digits just sit in the ledger, looking
as though they are doing something.
Official Sources
- net.core sysctl documentation — netdev_budget_usecs
- net_rx_action and time_squeeze — net/core/dev.c (v7.3)
- The HZ-dependent default — net/core/hotdata.c (v7.3)
- usecs_to_jiffies round-up — include/linux/jiffies.h (v7.3)
- a4837980fd9f — "revert default NAPI poll timeout to 2 jiffies"
- c180188ec022 — "set the minimum for netdev_budget_usecs"
- 7acf8a1e8a28 — the commit where the knob was born


