Project

General

Profile

Feature #9111 » afp-optimize-iface.sh

Lukas Sismis, 09/22/2026 03:36 PM

 
#!/usr/bin/env bash
set -Eeuo pipefail

#
# Suricata AF_PACKET / high-speed NIC tuning
# Oracle Linux 9 / RHEL 9
#
# Defaults for your host:
# IFNAME=ens2f1np1
# PCIE=0000:05:00.1
# QUEUES=10
#
# WARNING:
# - briefly takes the capture interface DOWN
# - stops irqbalance
#

IFNAME="${IFNAME:-ens2f1np1}"
PCIE="${PCIE:-0000:05:00.1}"
QUEUES="${QUEUES:-10}"

# Starting point for interrupt moderation.
# You can later sweep 0 / 25 / 50 / 125 for the burst test.
RX_USECS="${RX_USECS:-125}"

STOP_IRQBALANCE="${STOP_IRQBALANCE:-1}"

#
# Optional manual CPU assignment:
#
# IRQ_CPUS=1-10 \
# WORKER_CPUS=11-20 \
# ./suricata-afp-tune.sh
#
# Otherwise CPU sets are selected automatically from the NIC-local NUMA node.
#
IRQ_CPUS="${IRQ_CPUS:-}"
WORKER_CPUS="${WORKER_CPUS:-}"


log()
{
printf '\n==> %s\n' "$*"
}

warn()
{
printf 'WARN: %s\n' "$*" >&2
}

die()
{
printf 'ERROR: %s\n' "$*" >&2
exit 1
}

need()
{
command -v "$1" >/dev/null 2>&1 ||
die "missing command: $1"
}


for c in \
ip \
ethtool \
awk \
grep \
sort \
sed \
systemctl \
sysctl \
readlink \
basename \
cat
do
need "$c"
done


[[ $EUID -eq 0 ]] ||
die "run as root"


[[ -d "/sys/class/net/$IFNAME" ]] ||
die "interface $IFNAME does not exist"


[[ -e "/sys/bus/pci/devices/$PCIE" ]] ||
die "PCI device $PCIE does not exist"


###############################################################################
# Verify IFNAME <-> PCI mapping
###############################################################################

ACTUAL_PCIE="$(
basename "$(
readlink -f "/sys/class/net/$IFNAME/device"
)"
)"

[[ "$ACTUAL_PCIE" == "$PCIE" ]] ||
die "$IFNAME maps to $ACTUAL_PCIE, not $PCIE"


[[ "$QUEUES" =~ ^[0-9]+$ && "$QUEUES" -gt 0 ]] ||
die "QUEUES must be a positive integer"


###############################################################################
# Helpers
###############################################################################

try()
{
printf '+ '
printf '%q ' "$@"
printf '\n'

"$@" ||
{
warn "command failed/unsupported: $*"
return 0
}
}


expand_cpulist()
{
local spec="$1"
local part a b i
local -a parts

IFS=',' read -ra parts <<< "$spec"

for part in "${parts[@]}"
do

if [[ "$part" =~ ^([0-9]+)-([0-9]+)$ ]]
then

a="${BASH_REMATCH[1]}"
b="${BASH_REMATCH[2]}"

(( a <= b )) ||
die "bad CPU range: $part"

for ((i=a; i<=b; i++))
do
printf '%s\n' "$i"
done

elif [[ "$part" =~ ^[0-9]+$ ]]
then

printf '%s\n' "$part"

else

die "bad CPU-list token: $part"

fi

done
}


join_by_comma()
{
local IFS=,
echo "$*"
}


###############################################################################
# NIC identification
###############################################################################

log "NIC identity"

ethtool -i "$IFNAME"


if command -v lspci >/dev/null 2>&1
then
lspci -s "$PCIE" -nnk || true
fi


NODE="$(
cat "/sys/bus/pci/devices/$PCIE/numa_node"
)"


printf \
'interface=%s pci=%s numa_node=%s queues=%s\n' \
"$IFNAME" \
"$PCIE" \
"$NODE" \
"$QUEUES"


###############################################################################
# CPU selection
#
# Use sysfs topology, not lscpu --parse.
#
# One logical CPU is selected from each physical core.
###############################################################################

log "Selecting CPUs"


if (( NODE >= 0 )) &&
[[ -r "/sys/devices/system/node/node${NODE}/cpulist" ]]
then

CPU_SPEC="$(
cat "/sys/devices/system/node/node${NODE}/cpulist"
)"

else

warn "NIC has no usable NUMA node; using all online CPUs"

CPU_SPEC="$(
cat /sys/devices/system/cpu/online
)"

fi


mapfile -t CANDIDATE_CPUS < <(
expand_cpulist "$CPU_SPEC"
)


declare -A SEEN_CORE=()

LOCAL_PHYS=()


for cpu in "${CANDIDATE_CPUS[@]}"
do

#
# Leave CPU0 for housekeeping.
#
(( cpu == 0 )) &&
continue


TOPO="/sys/devices/system/cpu/cpu${cpu}/topology"


[[ -r "$TOPO/core_id" ]] ||
continue

[[ -r "$TOPO/physical_package_id" ]] ||
continue


CORE_ID="$(
cat "$TOPO/core_id"
)"

PACKAGE_ID="$(
cat "$TOPO/physical_package_id"
)"


KEY="${PACKAGE_ID}:${CORE_ID}"


if [[ -z "${SEEN_CORE[$KEY]+x}" ]]
then

SEEN_CORE["$KEY"]=1

LOCAL_PHYS+=(
"$cpu"
)

fi

done


printf \
'NIC NUMA CPU list: %s\n' \
"$CPU_SPEC"


printf \
'Usable physical-core CPUs: %s\n' \
"$(join_by_comma "${LOCAL_PHYS[@]}")"


((${#LOCAL_PHYS[@]} >= QUEUES)) ||
die \
"need $QUEUES physical cores on NUMA node $NODE, only found ${#LOCAL_PHYS[@]}"


###############################################################################
# Decide IRQ / Suricata worker CPUs
###############################################################################

if [[ -n "$IRQ_CPUS" || -n "$WORKER_CPUS" ]]
then

[[ -n "$IRQ_CPUS" && -n "$WORKER_CPUS" ]] ||
die "set both IRQ_CPUS and WORKER_CPUS, or neither"


mapfile -t IRQ_ARR < <(
expand_cpulist "$IRQ_CPUS"
)


mapfile -t WORKER_ARR < <(
expand_cpulist "$WORKER_CPUS"
)


((${#IRQ_ARR[@]} >= QUEUES)) ||
die "IRQ_CPUS provides fewer than $QUEUES CPUs"


((${#WORKER_ARR[@]} >= QUEUES)) ||
die "WORKER_CPUS provides fewer than $QUEUES CPUs"


IRQ_ARR=(
"${IRQ_ARR[@]:0:QUEUES}"
)


WORKER_ARR=(
"${WORKER_ARR[@]:0:QUEUES}"
)


CPU_MODE="manual"

else

#
# Best case:
# separate physical cores for NIC IRQ/NAPI and Suricata workers.
#
if ((${#LOCAL_PHYS[@]} >= 2 * QUEUES))
then

IRQ_ARR=(
"${LOCAL_PHYS[@]:0:QUEUES}"
)


WORKER_ARR=(
"${LOCAL_PHYS[@]:QUEUES:QUEUES}"
)


CPU_MODE="dedicated physical IRQ + worker cores"

else

#
# Not enough physical cores for separate sets.
#
# Use the same deterministic physical cores for both.
#
WORKER_ARR=(
"${LOCAL_PHYS[@]:0:QUEUES}"
)


IRQ_ARR=(
"${WORKER_ARR[@]}"
)


CPU_MODE="shared IRQ/worker physical cores"

fi

fi


IRQ_CPUS="$(
join_by_comma "${IRQ_ARR[@]}"
)"


WORKER_CPUS="$(
join_by_comma "${WORKER_ARR[@]}"
)"


printf \
'CPU mode: %s\n' \
"$CPU_MODE"


printf \
'IRQ CPUs: %s\n' \
"$IRQ_CPUS"


printf \
'Suricata worker CPUs: %s\n' \
"$WORKER_CPUS"


###############################################################################
# irqbalance
###############################################################################

if [[ "$STOP_IRQBALANCE" == 1 ]]
then

log "Stopping irqbalance"

systemctl stop irqbalance 2>/dev/null ||
true

fi


###############################################################################
# Queue capability
###############################################################################

log "Checking queue capability"


CHANNELS="$(
ethtool -l "$IFNAME"
)"


printf '%s\n' "$CHANNELS"


MAX_COMBINED="$(
printf '%s\n' "$CHANNELS" |
awk '
/Pre-set maximums:/ {
p=1
next
}

/Current hardware settings:/ {
p=0
}

p && $1=="Combined:" {
print $2
exit
}
'
)"


[[ "$MAX_COMBINED" =~ ^[0-9]+$ ]] ||
die "could not determine maximum combined queues"


(( QUEUES <= MAX_COMBINED )) ||
die "requested $QUEUES queues; NIC maximum is $MAX_COMBINED"


###############################################################################
# Link down while modifying queue topology
###############################################################################

log "Reconfiguring NIC -- link will bounce"


ip link set dev "$IFNAME" down


ethtool -L "$IFNAME" combined "$QUEUES"


###############################################################################
# Offloads
###############################################################################

#
# Preserve checksum offloading.
#
try ethtool -K "$IFNAME" rx on
try ethtool -K "$IFNAME" tx on


#
# Disable aggregation/segmentation features which alter packet semantics.
#
try ethtool -K "$IFNAME" gro off
try ethtool -K "$IFNAME" lro off
try ethtool -K "$IFNAME" gso off
try ethtool -K "$IFNAME" tso off
try ethtool -K "$IFNAME" ufo off
try ethtool -K "$IFNAME" sg off


#
# Preserve VLAN tags for Suricata.
#
try ethtool -K "$IFNAME" rxvlan off
try ethtool -K "$IFNAME" txvlan off


#
# Hardware GRO / UDP GRO if supported.
#
try ethtool -K "$IFNAME" rx-gro-hw off
try ethtool -K "$IFNAME" rx-udp-gro-forwarding off


#
# RSS / classification support.
#
try ethtool -K "$IFNAME" rxhash on
try ethtool -K "$IFNAME" ntuple on


###############################################################################
# Pause frames / EEE
###############################################################################

#
# Passive IDS should not change sender behavior through PAUSE frames.
#
try ethtool -A "$IFNAME" rx off tx off


#
# Avoid Energy Efficient Ethernet power transitions.
#
try ethtool --set-eee "$IFNAME" eee off


###############################################################################
# Maximum hardware RX descriptor ring
###############################################################################

RING="$(
ethtool -g "$IFNAME" 2>/dev/null ||
true
)"


MAX_RX="$(
printf '%s\n' "$RING" |
awk '
/Pre-set maximums:/ {
p=1
next
}

/Current hardware settings:/ {
p=0
}

p && $1=="RX:" {
print $2
exit
}
'
)"


if [[ "$MAX_RX" =~ ^[0-9]+$ ]]
then

log \
"Setting RX descriptor ring to NIC maximum: $MAX_RX"


try ethtool -G "$IFNAME" \
rx "$MAX_RX"

else

warn \
"could not determine maximum RX ring; leaving unchanged"

fi


###############################################################################
# Bring interface back
###############################################################################

ip link set dev "$IFNAME" up

ip link set dev "$IFNAME" promisc on


###############################################################################
# Verify queue count
###############################################################################

CUR_COMBINED="$(
ethtool -l "$IFNAME" |
awk '
/Current hardware settings:/ {
p=1
next
}

p && $1=="Combined:" {
print $2
exit
}
'
)"


[[ "$CUR_COMBINED" == "$QUEUES" ]] ||
die \
"combined queues are $CUR_COMBINED; expected $QUEUES"


###############################################################################
# Interrupt moderation
###############################################################################

log "Interrupt moderation"


try ethtool -C "$IFNAME" \
adaptive-rx off \
adaptive-tx off \
rx-usecs "$RX_USECS"


###############################################################################
# RSS
###############################################################################

log "RSS Toeplitz + equal indirection"


try ethtool -X "$IFNAME" \
hfunc toeplitz


#
# Detect RSS key size exposed by the driver.
#
RSS_HEX="$(
ethtool -x "$IFNAME" 2>/dev/null |
awk '
/RSS hash key:/ {
p=1
next
}

/RSS hash function:/ {
p=0
}

p {
gsub(/[^0-9A-Fa-f]/, "")
printf "%s", $0
}
'
)"


if [[ -n "$RSS_HEX" &&
$(( ${#RSS_HEX} % 2 )) -eq 0 ]]
then

KEY_BYTES=$(( ${#RSS_HEX} / 2 ))

SYM_KEY=""


for ((i=0; i<KEY_BYTES; i++))
do

if (( i % 2 == 0 ))
then
B="6D"
else
B="5A"
fi


[[ -n "$SYM_KEY" ]] &&
SYM_KEY+=":"


SYM_KEY+="$B"

done


printf \
'RSS key length: %d bytes\n' \
"$KEY_BYTES"


try ethtool -X "$IFNAME" \
hkey "$SYM_KEY" \
equal "$QUEUES"

else

warn \
"could not determine RSS key length"


try ethtool -X "$IFNAME" \
equal "$QUEUES"

fi


###############################################################################
# RSS hash fields
###############################################################################

for proto in tcp4 udp4 tcp6 udp6
do

if ethtool -N "$IFNAME" \
rx-flow-hash "$proto" sdfn \
>/dev/null 2>&1
then

printf \
'+ ethtool -N %s rx-flow-hash %s sdfn\n' \
"$IFNAME" \
"$proto"

else

warn \
"$proto sdfn unsupported; trying sd"


try ethtool -N "$IFNAME" \
rx-flow-hash "$proto" sd

fi

done


###############################################################################
# Disable software RPS/RFS
###############################################################################

log "Disabling software RPS/RFS"


sysctl -w \
net.core.rps_sock_flow_entries=0 \
>/dev/null


for q in /sys/class/net/"$IFNAME"/queues/rx-*
do

if [[ -e "$q/rps_cpus" ]]
then
echo 0 > "$q/rps_cpus"
fi


if [[ -e "$q/rps_flow_cnt" ]]
then
echo 0 > "$q/rps_flow_cnt"
fi

done


###############################################################################
# OL9/RHEL9 NAPI / softirq headroom
###############################################################################

log "Increasing OL9 networking softirq headroom"


sysctl -w \
net.core.netdev_budget=600 \
>/dev/null


sysctl -w \
net.core.netdev_budget_usecs=4000 \
>/dev/null


sysctl -w \
net.core.netdev_max_backlog=8192 \
>/dev/null


###############################################################################
# IRQ affinity
###############################################################################

log "Pinning NIC queue IRQs"


mapfile -t IRQS < <(

grep -F "$IFNAME" /proc/interrupts |

grep -Ei \
'TxRx|rx[-_ ]|queue' |

awk '
{
gsub(":", "", $1)

if ($1 ~ /^[0-9]+$/)
print $1
}
' |

sort -n -u

)


#
# Driver naming varies.
#
# If the specific queue matching didn't work,
# fall back to every IRQ containing IFNAME.
#
if ((${#IRQS[@]} == 0))
then

warn \
"could not identify queue IRQ names; falling back to all IRQs mentioning $IFNAME"


mapfile -t IRQS < <(

grep -F "$IFNAME" /proc/interrupts |

awk '
{
gsub(":", "", $1)

if ($1 ~ /^[0-9]+$/)
print $1
}
' |

sort -n -u

)

fi


if ((${#IRQS[@]} == 0))
then

warn \
"no IRQs found for $IFNAME; inspect /proc/interrupts manually"

else

for i in "${!IRQS[@]}"
do

irq="${IRQS[$i]}"

idx=$(( i % ${#IRQ_ARR[@]} ))

cpu="${IRQ_ARR[$idx]}"


if [[ -w "/proc/irq/$irq/smp_affinity_list" ]]
then

echo "$cpu" \
> "/proc/irq/$irq/smp_affinity_list" ||

warn \
"could not pin IRQ $irq to CPU $cpu"

else

warn \
"IRQ $irq affinity not writable; possibly a managed IRQ"

fi

done

fi


###############################################################################
# Final state
###############################################################################

log "Final NIC state"


printf '\n-- channels --\n'

ethtool -l "$IFNAME"


printf '\n-- RX/TX rings --\n'

ethtool -g "$IFNAME" 2>/dev/null ||
true


printf '\n-- interrupt coalescing --\n'

ethtool -c "$IFNAME" 2>/dev/null ||
true


printf '\n-- RSS --\n'

ethtool -x "$IFNAME" 2>/dev/null ||
true


printf '\n-- important offloads --\n'

ethtool -k "$IFNAME" |
grep -E \
'(^rx-checksumming:|^tx-checksumming:|scatter-gather:|tcp-segmentation|udp-fragmentation|generic-segmentation|generic-receive|large-receive|rx-vlan-offload|tx-vlan-offload|receive-hashing|ntuple)' ||
true


printf '\n-- IRQ affinity --\n'


for irq in "${IRQS[@]:-}"
do

if [[ -r "/proc/irq/$irq/effective_affinity_list" ]]
then

printf \
'IRQ %-5s effective=%s\n' \
"$irq" \
"$(
cat \
"/proc/irq/$irq/effective_affinity_list"
)"

fi

done


printf '\n-- drop/error counters --\n'


ethtool -S "$IFNAME" 2>/dev/null |
grep -Ei \
'rx.*(drop|disc|miss|err|no.?buf)|drop|discard|missed|error|no.?buf' ||
true


###############################################################################
# Recommended Suricata configuration summary
###############################################################################

cat <<EOF

============================================================
Recommended matching Suricata configuration
============================================================

interface: $IFNAME
AF_PACKET threads: $QUEUES
cluster-type: cluster_qm

IRQ CPUs:
$IRQ_CPUS

Suricata worker CPUs:
$WORKER_CPUS

AF_PACKET:
tpacket-v3: yes
ring-size: 100000
block-size: 1048576

Before and after every benchmark:

ethtool -S $IFNAME | egrep -i 'drop|disc|miss|error|no.?buf'

cat /proc/net/softnet_stat

If /proc/net/softnet_stat column 3 increases during the test,
increase net.core.netdev_budget and
net.core.netdev_budget_usecs further.

============================================================

EOF
(1-1/5)