Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Kubernetes HFT tuning

Every latency setting in this guide, where it goes, and its evidence. Measured links to a committed run below. Not measured here means the setting rests on kernel, vendor or Kubernetes documentation only. Node needs node-level access (boot line, kubelet, root on the host); a pod cannot set it. Pod works from the pod spec or the app.

Terms: deployment glossary.

This chapter is the list. It is not an image and it is not a second chart. The shared-CPU grant is Shared CPUs: public image ghcr.io/mimran1980/ergon-shared-cpus:0.1.0 and chart oci://ghcr.io/mimran1980/charts/ergon-shared-cpus version 0.1.0. The refusal of an exec probe on a pod labelled ergon.io/latency: hot is the admission policy in Latency Pods. Hugepages, the isolated cpuset partition, io_uring seccomp and the NIC PHC are opt-in grants in that same image. Chart 0.1.0 does not write seccomp, ptp or schedFifo, so those stay off. The kind sample k8s/nri-shared-cpus/examples/try installs the public chart, prints the shared-CPU grant, and shows the admission denial. It does not measure the rows below.

Settings

settingwhat it doeswhereevidenceapply
CPU isolationkeeps tasks, the timer tick, RCU callbacks and new interrupts off the busy CPUsnodemeasured: isolationboot line isolcpus=managed_irq,domain,<busy> nohz_full=<busy> rcu_nocbs=<busy> irqaffinity=<housekeeping> (hosts)
NIC queue interrupts off the loop’s CPU, irqbalance off, re-pinned after every NIC resetthe interrupt handler does not run on the loop’s CPUnodemeasured: interrupt placementhosts; EKS: node/irq-affinity.sh watch
ENA moderation off: adaptive-rx off rx-usecs 0one interrupt per packet; no adaptive-moderation work on the polling CPUnodemeasured, x86, polling backend: moderationethtool -C <if> adaptive-rx off rx-usecs 0, again after every NIC reset
SMT off (x86)one Kubernetes CPU is one physical corenodemeasured, one case (SQPOLL on the loop’s sibling): SMT siblingEC2 CpuOptions ThreadsPerCore: 1, or nosmt (SMT off)
static CPU manager, full-pcpus-onlyGuaranteed whole-CPU containers get CPUs of their ownnodeKubernetes CPU management; not measured herekubelet block
reservedSystemCPUs = the housekeeping CPUssystem daemons stay off the exclusive CPUsnodesame Kubernetes page; not measured herekubelet block
no CPU quota on a busy containerthe kernel never stops the container mid-periodnode + podsched-bwc, Kubernetes PR #127525 (kubelet 1.33+); not measured here (tune/src/quota.rs T9)kubelet 1.33+, static CPU manager, Guaranteed pod, whole-number cpu. Not cpuCFSQuota: false (why)
Guaranteed QoS, whole-number cputhe only pods the static CPU manager gives exclusive CPUspodKubernetes CPU management page; not measured herepod block
each busy thread pinned to one CPU of the pod’s cpusetthe thread stays on its exclusive CPUpodthe isolation runs pinned every loop; pinning alone is not measuredsched_setaffinity from Cpus_allowed_list (io: Kubernetes)
uring-sqpoll: the SQPOLL thread on its own CPUthe kernel’s polling thread does not share the loop’s corepodmeasured at 10 and 100 connections: SMT siblingergon-io picks a CPU of the pod’s own outside the shared CPUs (shared CPUs)
transparent hugepages neverno khugepaged merging under busy threadsnodetranshuge; not measured heretransparent_hugepage=never on the boot line
Aeron directory on a sized 2 MiB hugetlbfs, AERON_FILE_PAGE_SIZE=2097152, AERON_TERM_BUFFER_SPARSE_FILE=falseno 4 KiB page faults on the log buffersnode (pool, mount) + driver podhugetlbpage; not measured here. An unsized hugetlbfs reports no free space, which Aeron’s storage check refuses (aeron_fileutil.c:952-959 in rusteron-media-driver 0.2.11)hosts; driver env as in md-dpdk/deploy/aws-eks/media-driver-config.yaml
AERON_CLIENT_PRE_TOUCH_MAPPED_MEMORY=truea client faults its mapped terms in at startpod (client)aeronc.h:170 (rusteron-client 0.2.11); not measured herepod block
net.core.rmem_max, wmem_max ≥ Aeron’s socket bufferslets the kernel grant AERON_SOCKET_SO_RCVBUFnodefunctional, not latency: the driver refuses a window larger than the granted SO_RCVBUF (aeron_driver.c:407-456, rusteron-media-driver 0.2.11). Aeron’s default is 128 KiB (aeron_driver_context.c:191); only a raised buffer, such as the EKS ConfigMap’s 2 MiB, needs itsysctl -w net.core.rmem_max=16777216 net.core.wmem_max=16777216
automatic NUMA balancing offno balancing page faults on pinned threadsnodenot measured here (every lab host had one NUMA node)sysctl -w kernel.numa_balancing=0
topology manager single-numa-nodea pod’s CPUs and devices on one NUMA nodenodeKubernetes topology manager docs; not measured here (one-node lab hosts)kubelet block
C-states capped at C1 (x86)an idle housekeeping core wakes fasternodeVM: measured, mixed (C-states); metal: no gain (hosts). Not recommended on this evidenceleave unset (the EKS node sets no cap)
SCHED_FIFO on the loopreal-time prioritypodmeasured, mixed: SCHED_FIFO. Off by defaultergon-cpus --fifo (latency pods)
ENA receive ring rx 4096room for burstsnodenot measured hereethtool -G <if> rx 4096
global busy polling (net.core.busy_read, busy_poll)every socket read on the host spinsnodeergon-io: not measured as a global sysctl; recv-busy sets SO_BUSY_POLL per socket instead (fair run). Aeron’s media driver, kernel UDP: measured, polling not isolated. x86 round 7, with a 4,096 ring, lower p50 than plain kernel UDP (two settings differ); Graviton G1 lost at 1 M msgs/s in 5 of 5 reps (four settings differ) (BENCHMARKS.md)not recommended on this evidence: leave unset. ergon-md-dpdk check M4 reports the state as information
quiet-core spin testfinds CPUs the platform stallsnodenot measured here: the cloud lab skips CPUs that stalled 300 µs in a 120 s spin (md-dpdk/benches/cloud/vm.sh:2022-2029). A pod cannot choose its coresrun it on new nodes; cordon or replace noisy ones
no exec probes, no restartPolicy: Always sidecars on hot podsno probe process lands on the loop’s CPUpodan exec starts on any CPU of the cpuset (k8s/nri-shared-cpus/README.md, Limits); not measured herehttpGet/tcpSocket/grpc probes; the admission policy enforces it
both hosts in one cluster placement groupshorter network path between the pairnode (cloud)AWS docs; not measured herethe node group’s placement group
build for the CPU (-mcpu, target-cpu)Graviton: inline atomics instead of gcc’s outline helpersbuildnot measured herehosts
chrony on a PTP clockCLOCK_REALTIME stays close to UTCnodevendor docs; not a latency setting (Clocks)time sync

Evidence

Every row: one run, arms compared only with the same run’s baseline. µs. Raw tables: git show <commit>:<path> as named. Hosts: AWS c7i.2xlarge receiver (SMT on), c7i.xlarge sender, kernel 7.0; Azure Standard_D8ds_v6, kernel 6.17; Graviton c8g.2xlarge (io/benches/BENCHMARK.md). Boot lines and NIC settings per run are in the committed env-rcv*.txt next to each run id under io/benches/results/.

Isolation

A loop that only reads the CPU counter, on the CPU the busy loop uses, records every gap. One sample per boot line; the loop sends nothing. Sources: git show 2517309d:io/benches/results/flags-aws-20261014T163114Z/tables.md and …/flags-azure-20261014T144146Z/tables.md, “Platform noise”.

hostboot linegaps >5 µs /min>20 µs /min>100 µs /minlongest gap (max)
AWS c7inone60,120120.034.5146.95
AWS c7iisolcpus=managed_irq,domain + irqaffinity60,063.54.00.052.30
AWS c7i+ nohz_full, rcu_nocbs50.04.51.0133.90
Azurenone17,189120.03.0612.94
Azureisolcpus=managed_irq,domain + irqaffinity10,65647.00.058.94
Azure+ nohz_full, rcu_nocbs239.537.00.067.82

isolcpus cut gaps over 20 µs. Adding nohz_full cut gaps over 5 µs (about 1,000 a second before it, the tick rate it stops: NO_HZ) by 97.7 % (Azure) and 99.9 % (AWS). The longest gap is one event per sample and does not rank the two isolated lines.

Interrupt placement

Graviton (fair run, io/benches/results/flags-aws-20261019T175248Z/fair.md, committed): WebSocket round trip, interrupt-driven backends (epoll-spin, mio, uring-spin), moderation off everywhere, two rounds. Six points’ senders fell behind; the file’s top note names them.

connectionsqueue IRQs on the loop’s CPU: p99 / maxon a core of their own: p99 / max
145.87–47.17 / 134.98–161.3046.97–48.37 / 107.72–145.15
4–6452.31–86.46 / 128.56–640.7549.15–53.85 / 135.64–425.45

Same file, own-core IRQs, p50: at 1 connection the interrupt backends read 38.78–39.33 against 36.01–37.04 for NIC polling; at 4 connections the two families overlap. From 16 connections recv-busy (48.98–49.63 at 16, 78.14–78.29 at 64, from-due p99 9.7 s) and recv-busy-pq (44.26–44.73) sit above the interrupt backends (39.27–40.42); uring-sqpoll stays level.

x86 (git show 2517309d:io/benches/results/flags-aws-20261015T062428Z/tables.md, irqlocal arm: every queue IRQ on the loop’s CPU), recv-busy market data, kernel arrival → decoded:

armp50p99max
baseline, rounds 1 / 23.42 / 2.867.87 / 8.0536.31 / 47.96
IRQs on the loop’s CPU, rounds 1 / 26.49 / 4.6826.09 / 20.1198.61 / 73.64

The WebSocket round trip in that run showed no consistent change.

Interrupt moderation

git show 2517309d:io/benches/results/flags-aws-20261015T070339Z/tables.md, AWS c7i, recv-busy, 10,000 round trips a second. Baseline: adaptive-rx on (io/benches/scripts/vm-setup.sh nodim). Rounds 1 / 2.

armWebSocket p50p99.99maxHTTP p99.99max
baseline43.90 / 40.65118.85 / 116.531,023.80 / 259.23118.88 / 77.06445.88 / 432.70
adaptive-rx off40.88 / 40.9371.89 / 76.02354.05 / 970.07116.06 / 83.44980.65 / 201.50
adaptive-rx off rx-usecs 041.90 / 41.5578.50 / 80.41233.02 / 343.6880.99 / 73.78201.65 / 226.79

WebSocket p99.99 fell 36–47 µs in both rounds. HTTP p99.99 moved no more than the 42 µs gap between its own two baselines. One run. The interrupt path with moderation on is not measured.

SMT sibling

git show 2517309d:io/benches/results/flags-aws-20261015T122127Z/tables.md, AWS c7i, uring-sqpoll market data, kernel arrival → decoded, three rounds: the SQPOLL kernel thread on its own core against the loop’s SMT sibling.

connectionsown core: p50 / p99 / maxloop’s sibling: p50 / p99 / max
13.46–3.79 / 8.42–12.92 / 31.57–118.344.01–4.30 / 6.99–11.69 / 34.72–45.83
105.18–6.04 / 20.41–23.81 / 77.03–109.427.53–8.37 / 29.46–32.77 / 95.85–349.01
1005.00–5.28 / 20.56–21.98 / 76.01–301.087.46–7.81 / 32.46–34.17 / 94.51–111.56

At 1 connection the own core is lower at p50 and the sibling at p99. At 10 and 100 connections the own core is lower at p50 and p99 in every round. This is one busy sibling (SQPOLL); other sibling loads are not measured.

C-states

…/flags-aws-20261015T062428Z/tables.md, cstate arm: every idle state deeper than C1 off, AWS c7i VM. Rounds 1 / 2.

armWebSocket p50p99.99maxHTTP p99.99max
baseline36.92 / 37.69115.88 / 104.24454.17 / 967.90112.84 / 106.211,009.61 / 1,049.79
C1 cap39.86 / 39.78133.30 / 128.17464.79 / 1,035.66106.05 / 90.25492.76 / 227.01

WebSocket worse at p50 and p99.99, HTTP better at p99.99: no consistent gain.

SCHED_FIFO

…/flags-aws-20261015T070339Z/tables.md, rt arm: the loop under chrt -f 50, kernel.sched_rt_runtime_us=-1. Rounds 1 / 2.

armWebSocket p99.99maxHTTP p99.99maxloop CPU timer interrupts /s
baseline118.85 / 116.531,023.80 / 259.23118.88 / 77.06445.88 / 432.7011–23
SCHED_FIFO119.81 / 73.87579.59 / 1,008.9275.62 / 75.47982.27 / 229.95533–947

Mixed. Under the policy the loop’s nohz_full CPU took 533–947 timer interrupts a second, against 11–23 without it.

Copy-paste

Kubelet (EKS NodeConfig; k3s and AKS: Kubernetes):

apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
cpuManagerPolicy: static
cpuManagerPolicyOptions:
  full-pcpus-only: "true"
reservedSystemCPUs: "0"          # the housekeeping cores
topologyManagerPolicy: single-numa-node
# cpuCFSQuota stays true: the kubelet drops it for exclusive CPUs (1.33+)

App pod: Guaranteed, whole cores, Aeron’s directory on the node’s hugetlbfs. AERON_FILE_PAGE_SIZE and AERON_TERM_BUFFER_SPARSE_FILE are read only by the media driver: set them on the driver’s pod.

apiVersion: v1
kind: Pod
spec:
  priorityClassName: <your latency class>
  containers:
  - name: app
    resources:
      requests: { cpu: "2", memory: 1Gi, hugepages-2Mi: 1Gi }
      limits:   { cpu: "2", memory: 1Gi, hugepages-2Mi: 1Gi }
    env:
    - { name: AERON_CLIENT_PRE_TOUCH_MAPPED_MEMORY, value: "true" }
    volumeMounts:
    - { name: huge, mountPath: /mnt/huge-aeron }
  volumes:
  - name: huge
    hostPath: { path: /mnt/huge-aeron, type: Directory }

Pin each busy thread to one CPU of the pod’s cpuset before the loop starts (io: Kubernetes).

Check it

# 1. no CPU quota on the busy container
cat /sys/fs/cgroup/cpu.max               # expect: max 100000
grep throttled /sys/fs/cgroup/cpu.stat   # nr_throttled stays flat

# 2. the pod got whole cores of its own
grep Cpus_allowed_list /proc/self/status

# 3. the host: ergon-tune names each missing setting and its fix
#    (runs at start-up in ergon-io's Driver::new and the media driver)

ERGON_HOST_CHECKS=require (media driver alias RUSTERON_MEDIA_DRIVER_HOST_CHECK) refuses to start while any check warns or a proof check stays unproven (ergon-tune). In a stock static-CPU-manager pod ergon-io’s K2 stays unproven, so require refuses it (checks inside a pod).

Open

  • No committed run measures THP, hugepages, the RX ring, NUMA settings or the CPU quota on latency.
  • Most maxima above are not traced to a cause.

More

pagefor
Kubernetesthe EKS manifests, No CPU quota, SMT off, keeping other pods off the node
Hoststhe same settings on a VM, and how to apply each
Benchmarksplatform and cost side by side
Shared CPUshelper threads on the node’s housekeeping CPUs (k8s/nri-shared-cpus)
Latency podshugepages, io_uring, PHC devices on a pod (k8s/nri-latency-pods)