容器网络是现代云原生基础设施的底座。Docker、Kubernetes 依赖 Linux 内核提供的四大网络虚拟化原语——Network Namespace、veth pair、Linux Bridge、VXLAN——把跑在同一台物理机或跨越多台主机的容器连接成一张逻辑网络。本文基于 Linux 6.4-rc1 源码,逐层拆解这四个模块的内核实现,并在最后串联出一条完整的容器间数据包路径。

Read more »

在前六篇中,我们依次拆解了 sk_buff 生命周期、网卡驱动收发路径、TCP/IP 协议栈、路由子系统、套接字层以及 NAPI/GRO 机制。本篇聚焦数据包过滤与处理的核心框架:Netfilter hook 体系、nftables 规则执行引擎、conntrack 连接追踪,以及近年来高速演进的 eBPF 网络程序(XDP、tc BPF、Socket Filter)。所有代码片段均源自 Linux 6.4-rc1。

Read more »

Socket 是用户态程序与内核网络协议栈之间的唯一接口。它不仅是一个文件描述符,更是一套精心设计的多层抽象体系——从 VFS 层的 struct socket,到协议无关的 struct sock,再到 IPv4 专用的 struct inet_sock 和 TCP 专用的 struct tcp_sock,每一层都有其清晰的职责边界。本文基于 Linux 6.4-rc1 源码,深入剖析 socket 系统调用的完整实现路径、epoll 的内核机制,以及缓冲区管理与内存压力控制。

本系列前五篇分别覆盖了网卡驱动收发、NAPI、协议栈 IP/TCP 层、路由子系统和 netfilter,这一篇聚焦于最接近用户态的 socket 接口层,梳理从系统调用入口到数据进出内核缓冲区的全链路,并以 epoll 的内部实现为重点,阐释高并发服务器底层事件通知的工作机制。

Read more »

TCP 拥塞控制是互联网稳定运行的核心机制之一。自 1988 年 Van Jacobson 在 SIGCOMM 发表奠基论文以来,拥塞控制算法经历了从 Tahoe、Reno、BIC 到 CUBIC,再到基于测量的 BBR 的演进历程。本文以 Linux 6.4-rc1 源码为基础,深入剖析内核拥塞控制框架的实现细节,覆盖从插件注册到状态机转换、从 CUBIC 三次函数到 BBR 带宽估计的完整链路。

拥塞控制算法面对的核心挑战是:如何在不知道网络容量的前提下,以尽可能高的速率发送数据,同时不引发严重拥塞?这个问题的本质是分布式资源竞争——互联网中每条流都是自私的,拥塞控制协议需要在个体利益(最大化自身吞吐)和集体利益(网络整体公平稳定)之间取得平衡。Jacobson 的原始方案用丢包作为拥塞的唯一信号,这在当时(网络带宽以 Kbps 计)是合理的。但随着网络带宽扩展到 Gbps、Tbps 量级,以及 bufferbloat(路由器缓冲区过大导致的延迟膨胀)问题的出现,基于丢包的方案暴露出越来越多的局限性,催生了 CUBIC 和 BBR 等新一代算法的诞生。

Read more »

TCP 是互联网的基石协议,其可靠性、有序性和流量控制能力建立在一套精密的状态机和连接管理机制之上。本文基于 Linux 6.4-rc1 内核源码,深入剖析 TCP 协议栈从数据结构设计、三次握手、数据收发,到四次挥手和 TIME_WAIT 的完整生命周期,并给出实用的内核级诊断工具链。

Read more »

IP 层(网络层)是 Linux 协议栈的核心,负责报文的寻址、路由、转发与分片重组。本文基于 Linux 6.4-rc1 源码,从 ip_rcv 入口函数出发,逐层拆解接收路径、FIB 路由查找、发送路径、分片重组和 netfilter 钩子的完整实现,并给出实用的内核诊断技巧。文中所有代码均来自 net/ipv4/ip_input.cnet/ipv4/ip_output.cnet/ipv4/fib_trie.cnet/ipv4/ip_fragment.cnet/ipv4/ip_forward.cnet/netfilter/core.c 等实际文件。

Read more »

本文是 Linux 网络内核协议栈系列的第二篇,聚焦于网卡驱动框架与 NAPI(New API)收发包机制。我们将深入 Linux 6.4-rc1 源码,逐层拆解从网卡硬件中断到 sk_buff 进入协议栈的完整链路,并对称地分析发送路径。所有代码片段均直接取自内核源文件,确保与实际内核行为一致。

Read more »

本系列基于 Linux 6.4-rc1 源码(commit ac9a78681b92),所有代码片段均从真实源文件读取,路径为 include/linux/skbuff.hnet/core/skbuff.c


一、为什么要读懂 Linux 网络协议栈

网络性能调优、eBPF 程序开发、内核模块编写,乃至排查一个”神秘的丢包”——这些工作的底层都有一个共同的核心:sk_buff(socket buffer)。它是 Linux 内核网络子系统中最关键的数据结构,每一个在内核中流动的网络报文都以 sk_buff 的形式存在。

本系列文章将从零到深,系统拆解 Linux 网络协议栈。第一篇先建立全局视图,再深入 sk_buff 的内存模型与操作原语。


二、网络协议栈完整架构

2.1 接收路径(RX Path)

从网卡收到一帧,到用户态 read() 返回数据,内核经历的完整调用链如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
硬件中断
└─ napi_schedule() // 将 NAPI poll 加入软中断队列
└─ net_rx_action() // softirq: NET_RX_SOFTIRQ
└─ napi->poll() // 驱动的 poll 函数,如 igb_poll()
└─ napi_gro_receive()
└─ netif_receive_skb()
└─ __netif_receive_skb_core()
├─ deliver_skb() → packet socket tap
└─ ip_rcv() // IPv4 入口
└─ ip_rcv_finish()
└─ ip_local_deliver()
└─ ip_local_deliver_finish()
└─ tcp_v4_rcv() // TCP 入口
└─ tcp_rcv_established()
└─ tcp_queue_rcv()
└─ sk->sk_data_ready()
└─ 唤醒用户态 read()

关键节点说明:

NAPI(New API) 是现代驱动的标准轮询机制。硬中断只负责触发 napi_schedule(),将 poll 工作推给软中断 NET_RX_SOFTIRQ,从而避免高速网卡的中断风暴。驱动在 poll 函数中用 napi_build_skb()__napi_alloc_skb() 创建 skb,并调用 napi_gro_receive() 进入 GRO(Generic Receive Offload)聚合层。

netif_receive_skb() 是协议层的分发入口。它通过 ptype_base 哈希表查找与 skb->protocol 对应的协议处理函数(如 ip_rcvipv6_rcv)。

ip_rcv() 完成 IP 首部校验、路由查找(ip_route_input_noref()),然后决定报文去向:本地投递还是转发。本地投递进入 ip_local_deliver(),这里完成 IP 分片重组,再调用上层协议(TCP/UDP/ICMP)的接收函数。

tcp_v4_rcv() 根据四元组找到 socket,将报文挂入 sk->sk_receive_queue,并通过 sk->sk_data_ready 回调唤醒阻塞在 read() 的用户进程。

2.2 发送路径(TX Path)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
用户态 write() / sendmsg()
└─ sock_sendmsg()
└─ tcp_sendmsg()
└─ tcp_push_one() / __tcp_push_pending_frames()
└─ tcp_write_xmit()
└─ tcp_transmit_skb() // 封装 TCP 首部
└─ ip_queue_xmit() // 封装 IP 首部 + 路由
└─ ip_output()
└─ ip_finish_output()
└─ ip_finish_output2()
└─ neigh_output() // ARP/邻居子系统
└─ dev_queue_xmit() // 入队
└─ qdisc_run() // 流量控制
└─ dev_hard_start_xmit()
└─ ndo_start_xmit() // 驱动发送

发送路径的核心在于 TCP 在 sk_write_queue 中维护待发送的 skb。TCP 实现了可靠传输,skb 只有在收到 ACK 后才会通过 tcp_clean_rtx_queue() 释放。发送时,TCP 先调用 skb_clone() 产生一个 fclone(fast-clone),将克隆体交给 IP 层,自己保留原始 skb 用于重传。


三、sk_buff 结构体深度分析

3.1 什么是 sk_buff

sk_buff 是 Linux 内核网络子系统中报文的载体。它本身不直接存储报文数据,而是一个元数据描述符,通过指针指向实际的数据缓冲区。其设计目标是:在协议栈各层之间传递报文时,尽量避免数据拷贝,只修改指针和元数据。

3.2 核心内存布局:四个指针

skbuff.h 中的官方注释给出了最清晰的内存模型(源码第 706-728 行):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* DOC: Basic sk_buff geometry
*
* ---------------
* | sk_buff |
* ---------------
* ,--------------------------- + head
* / ,----------------- + data
* / / ,----------- + tail
* | | | , + end
* | | | |
* v v v v
* -----------------------------------------------
* | headroom | data | tailroom | skb_shared_info |
* -----------------------------------------------
* + [page frag]
* + [page frag]
* + frag_list --> | sk_buff |
*/

四个核心指针在结构体末尾(源码第 1053-1057 行):

1
2
3
4
5
6
7
/* These elements must be at the end, see alloc_skb() for details.  */
sk_buff_data_t tail;
sk_buff_data_t end;
unsigned char *head,
*data;
unsigned int truesize;
refcount_t users;

各指针含义:

指针 含义
head 分配的缓冲区起始地址,固定不变
data 当前有效数据的起始位置(随 skb_push/pull 移动)
tail 当前有效数据的结束位置(随 skb_put 移动)
end 缓冲区末尾(紧接 skb_shared_info),固定不变

在 64 位系统(NET_SKBUFF_DATA_USES_OFFSET 宏定义时),tailend 存储的是相对于 head偏移量,而非指针,节省内存并方便原子操作。

Headroom(头部预留空间)data - head 的字节数,发送时用于各层协议依次 skb_push() 写入自己的首部,无需移动数据。Tailroomend - tail 的字节数(不含 skb_shared_info),接收时用于 skb_put() 追加数据。

3.3 len、data_len 与 truesize 的区别

这三个字段是初学者最容易混淆的:

1
2
unsigned int		len,         // 源码第 896 行
data_len;
  • skb->len:报文的逻辑总长度,等于线性区域长度加上所有分片的长度。skb_headlen(skb) + skb->data_len
  • skb->data_len:仅计算非线性部分(page frags + frag_list)的字节数。线性 skb 的 data_len 为 0。
  • **truesize**:内核为这个 skb 实际占用的内存大小,包含 sk_buff 结构体本身、数据缓冲区以及 skb_shared_info。用于 socket 发送/接收缓冲区的内存计费(sk_wmem_alloc / sk_rmem_alloc)。
1
2
3
4
/* return minimum truesize of one skb containing X bytes of data */
#define SKB_TRUESIZE(X) ((X) + \
SKB_DATA_ALIGN(sizeof(struct sk_buff)) + \
SKB_DATA_ALIGN(sizeof(struct skb_shared_info)))

truesize 的精确计算在 __finalize_skb_around() 中(net/core/skbuff.c 第 352-375 行):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static inline void __finalize_skb_around(struct sk_buff *skb, void *data,
unsigned int size)
{
struct skb_shared_info *shinfo;

size -= SKB_DATA_ALIGN(sizeof(struct skb_shared_info));

/* Assumes caller memset cleared SKB */
skb->truesize = SKB_TRUESIZE(size);
refcount_set(&skb->users, 1);
skb->head = data;
skb->data = data;
skb_reset_tail_pointer(skb);
skb_set_end_offset(skb, size);
// ...
}

3.4 cb[48]:各层私有控制块

1
char    cb[48] __aligned(8);   // 源码第 880 行

这 48 字节是各协议层的私有暂存区,对上下层完全透明。内核使用宏来访问它:

IP 层(include/net/ip.h):

1
2
3
4
5
6
7
8
struct inet_skb_parm {
int iif;
struct ip_options opt; /* Compiled IP options */
u16 flags;
// IPSKB_FORWARDED, IPSKB_XFRM_TUNNEL_SIZE, ...
};

#define IPCB(skb) ((struct inet_skb_parm*)((skb)->cb))

TCP 层(include/net/tcp.h):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
struct tcp_skb_cb {
__u32 seq; /* Starting sequence number */
__u32 end_seq; /* SEQ + FIN + SYN + datalen */
union {
__u32 tcp_tw_isn;
struct {
u16 tcp_gso_segs;
u16 tcp_gso_size;
};
};
// tx 路径:发送时间戳、delivered 计数等
// rx 路径:ip header 信息(h4/h6)
union {
struct {
u32 is_app_limited:1;
// ...
} tx;
union {
struct inet_skb_parm h4;
struct inet6_skb_parm h6;
} header;
};
};

#define TCP_SKB_CB(__skb) ((struct tcp_skb_cb *)&((__skb)->cb[0]))

TCP 在发包时用 TCP_SKB_CB(skb)->seq 记录序列号,在收包时用 TCP_SKB_CB(skb)->header.h4 读取 IP 层留下的信息。由于 sizeof(struct tcp_skb_cb) <= 48,两者都合法地复用同一块内存。

3.5 sk 与 dev 字段

1
2
3
4
union {
struct sock *sk; // 所属 socket
int ip_defrag_offset; // IP 分片重组时复用
};

skb->sk 指向拥有这个报文的 socket。在发送路径中,skb 一直持有对 socket 的引用(用于内存计费);在接收路径进入 TCP/UDP 处理后也会被赋值。ip_defrag_offset 是 IP 分片重组阶段对 sk 字段的临时复用,无需额外内存。

1
2
3
4
5
6
7
8
9
10
11
union {
struct {
struct sk_buff *next;
struct sk_buff *prev;
union {
struct net_device *dev;
unsigned long dev_scratch;
};
};
// ...
};

skb->dev 指向 skb 当前关联的网络设备struct net_device)。在接收路径,它指向收包的网卡;在发送路径,它指向出口网卡。某些协议(如 UDP 接收路径)会将 dev 置 NULL 并用 dev_scratch 存储其他临时信息。

3.6 _skb_refdst:路由缓存

1
2
3
4
5
6
7
union {
struct {
unsigned long _skb_refdst;
void (*destructor)(struct sk_buff *skb);
};
// ...
};

_skb_refdst 存储指向 struct dst_entry 的指针(路由缓存条目)。最低位被用作标志位 SKB_DST_NOREF,表示是否持有引用计数:

1
2
3
4
5
6
7
8
9
#define SKB_DST_NOREF   1UL
#define SKB_DST_PTRMASK ~(SKB_DST_NOREF)

static inline struct dst_entry *skb_dst(const struct sk_buff *skb)
{
WARN_ON((skb->_skb_refdst & SKB_DST_NOREF) &&
!rcu_read_lock_held() && !rcu_read_lock_bh_held());
return (struct dst_entry *)(skb->_skb_refdst & SKB_DST_PTRMASK);
}

在快速转发路径(如 ip_forward())中,内核使用 skb_dst_set_noref() 以 RCU 方式持有路由缓存,避免昂贵的引用计数操作。

3.7 GSO/GRO 相关字段

GSO(Generic Segmentation Offload)和 GRO(Generic Receive Offload)是 Linux 将分段/聚合工作推迟到驱动层或硬件的机制,核心信息存储在 skb_shared_info(见下节)中:

1
2
3
4
5
6
7
struct skb_shared_info {
// ...
unsigned short gso_size; // 单个 MSS 大小
unsigned short gso_segs; // 待分段数量
unsigned int gso_type; // SKB_GSO_TCPV4, SKB_GSO_TCPV6, ...
// ...
};

发送路径中,TCP 构造一个大 skb(可包含多个 MSS 的数据),设置 gso_size = tp->mss_cachegso_segs = 数量,并设置 ip_summed = CHECKSUM_PARTIAL,由驱动或网卡完成实际切割和 checksum。

接收路径中,GRO 将多个小包聚合成一个大 skb,减少协议栈处理次数。skb->slow_gro 标志指示该 skb 在 GRO 合并时携带了额外状态(连接跟踪、路由等),需要在释放时特殊处理。

3.8 Checksum Offload 字段

1
2
3
4
5
6
7
8
__u8    ip_summed:2;    // CHECKSUM_NONE/UNNECESSARY/COMPLETE/PARTIAL
union {
__wsum csum;
struct {
__u16 csum_start;
__u16 csum_offset;
};
};

四种状态的含义(源码 include/linux/skbuff.h 第 99-250 行有完整文档):

场景 含义
CHECKSUM_NONE RX 硬件未校验,软件需要验证
CHECKSUM_UNNECESSARY RX 硬件已验证,无需软件重算
CHECKSUM_COMPLETE RX 硬件提供了整包 checksum(存于 skb->csum
CHECKSUM_PARTIAL TX 软件填写了伪首部,请求硬件完成最终 checksum

TX 路径中,csum_start 是从 skb->head 开始计算 checksum 的起始偏移,csum_offset 是相对于 csum_start 的 checksum 写入位置。

3.9 skb_shinfo() 与 skb_shared_info

skb_shared_info 紧跟在数据缓冲区的末尾(end 指针处):

1
#define skb_shinfo(SKB) ((struct skb_shared_info *)(skb_end_pointer(SKB)))

完整结构(源码第 574-599 行):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct skb_shared_info {
__u8 flags; // SKBFL_ZEROCOPY_ENABLE 等零拷贝标志
__u8 meta_len;
__u8 nr_frags; // page frags 数量(最多 MAX_SKB_FRAGS=17)
__u8 tx_flags; // SKBTX_HW_TSTAMP 等时间戳标志
unsigned short gso_size;
unsigned short gso_segs;
struct sk_buff *frag_list; // 指向下一个 skb(用于 IP 分片链、超大帧)
struct skb_shared_hwtstamps hwtstamps;
unsigned int gso_type;
u32 tskey;
/*
* Warning : all fields before dataref are cleared in __alloc_skb()
*/
atomic_t dataref; // 数据区引用计数(clone 计数)
unsigned int xdp_frags_size;
void *destructor_arg;
/* must be last field, see pskb_expand_head() */
skb_frag_t frags[MAX_SKB_FRAGS];
};

frags[] 是 Scatter-Gather IO 的核心。每个 skb_frag_t(实为 struct bio_vec)描述一个页片段:

1
2
3
4
5
6
7
8
typedef struct bio_vec skb_frag_t;

// bio_vec 定义:
struct bio_vec {
struct page *bv_page;
unsigned int bv_len;
unsigned int bv_offset;
};

网卡驱动通过 DMA 将数据直接写入这些页,frags[] 只存储(页, 偏移, 长度)三元组,完全避免数据拷贝。

frag_list 是一个 skb 链表,用于两种场景:

  1. IP 分片重组:各个分片 skb 通过 frag_list 链接在一起,skb->len 是全部分片的总长度。
  2. GSO fraglistSKB_GSO_FRAGLIST 模式下,将多个小 skb 链成链表交给驱动。

datarefskb_shared_info 中最重要的计数器,分为两个 16 位字段:低 16 位是总引用数,高 16 位是 payload-only 引用数(nohdr clone)。skb_clone() 共享数据区时递增此计数,skb_release_data() 递减,归零后才真正释放数据缓冲区。


四、sk_buff 操作函数详解

4.1 alloc_skb / __alloc_skb:slab 缓存分配

内核为 sk_buff 头部维护了专用的 slab 缓存(net/core/skbuff.c 第 89-90 行):

1
2
struct kmem_cache *skbuff_cache __ro_after_init;
static struct kmem_cache *skbuff_fclone_cache __ro_after_init;

核心分配函数 __alloc_skb()(源码第 625-687 行):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
struct sk_buff *__alloc_skb(unsigned int size, gfp_t gfp_mask,
int flags, int node)
{
struct kmem_cache *cache;
struct sk_buff *skb;
bool pfmemalloc;
u8 *data;

cache = (flags & SKB_ALLOC_FCLONE)
? skbuff_fclone_cache : skbuff_cache;

if (sk_memalloc_socks() && (flags & SKB_ALLOC_RX))
gfp_mask |= __GFP_MEMALLOC;

/* Get the HEAD */
if ((flags & (SKB_ALLOC_FCLONE | SKB_ALLOC_NAPI)) == SKB_ALLOC_NAPI &&
likely(node == NUMA_NO_NODE || node == numa_mem_id()))
skb = napi_skb_cache_get(); // NAPI 路径:per-CPU 缓存
else
skb = kmem_cache_alloc_node(cache, gfp_mask & ~GFP_DMA, node);
if (unlikely(!skb))
return NULL;
prefetchw(skb);

data = kmalloc_reserve(&size, gfp_mask, node, &pfmemalloc);
if (unlikely(!data))
goto nodata;

memset(skb, 0, offsetof(struct sk_buff, tail));
__build_skb_around(skb, data, size);
skb->pfmemalloc = pfmemalloc;

if (flags & SKB_ALLOC_FCLONE) {
struct sk_buff_fclones *fclones;
fclones = container_of(skb, struct sk_buff_fclones, skb1);
skb->fclone = SKB_FCLONE_ORIG;
refcount_set(&fclones->fclone_ref, 1);
}
return skb;
nodata:
kmem_cache_free(cache, skb);
return NULL;
}

分配分两步:

  1. 从 slab 缓存分配 sk_buff 结构体(仅元数据,约 232 字节)
  2. 调用 kmalloc_reserve() 分配数据缓冲区

kmalloc_reserve() 内部会优先尝试 skb_small_head_cache(专为 TCP 头部优化的小块缓存,大小为 SKB_HEAD_ALIGN(MAX_TCP_HEADER)),对于不超过 1024 字节的小报文走快速路径。

NAPI 路径还有额外优化:napi_skb_cache_get() 维护一个 per-CPU 的 64 个 skb 的批量缓存,使用 kmem_cache_alloc_bulk() 批量分配,大幅减少 slab 调用开销。

4.2 skb_reserve:为各层头部预留空间

1
2
3
4
5
static inline void skb_reserve(struct sk_buff *skb, int len)
{
skb->data += len;
skb->tail += len;
}

在 skb 刚分配、尚未写入数据时调用,同时移动 datatail,使 headdata 之间形成 headroom。后续各层通过 skb_push() 向前扩展,写入自己的首部,无需移动已有数据。

驱动分配接收 skb 时通常调用:

1
2
skb_reserve(skb, NET_SKB_PAD);          // 驱动预留(NET_SKB_PAD = 32 字节)
skb_reserve(skb, NET_SKB_PAD + NET_IP_ALIGN); // NAPI 路径额外对齐

发送路径中,sock_alloc_send_skb() 预留的空间要包含所有协议层首部的总长度:

1
NET_SKB_PAD + MAX_HEADER(ETH + IP + TCP 首部之和)

4.3 skb_push / skb_pull / skb_put:指针操作

这三个函数是协议栈各层操作数据的核心原语(源码第 2383-2428 行):

skb_put() — 向 tail 方向扩展,用于接收路径追加数据:

1
2
3
4
5
6
7
8
9
10
void *skb_put(struct sk_buff *skb, unsigned int len)
{
void *tmp = skb_tail_pointer(skb);
SKB_LINEAR_ASSERT(skb);
skb->tail += len;
skb->len += len;
if (unlikely(skb->tail > skb->end))
skb_over_panic(skb, len, __builtin_return_address(0));
return tmp; // 返回新区域的起始地址
}

skb_push() — 向 head 方向扩展,用于发送路径在 headroom 中写入首部:

1
2
3
4
5
6
7
8
void *skb_push(struct sk_buff *skb, unsigned int len)
{
skb->data -= len;
skb->len += len;
if (unlikely(skb->data < skb->head))
skb_under_panic(skb, len, __builtin_return_address(0));
return skb->data;
}

skb_pull() — 从 data 头部移除数据,用于接收路径跳过已处理的首部:

1
2
3
4
5
6
7
8
9
10
void *skb_pull(struct sk_buff *skb, unsigned int len)
{
return skb_pull_inline(skb, len);
}

static inline void *__skb_pull(struct sk_buff *skb, unsigned int len)
{
skb->len -= len;
return skb->data += len;
}

三者关系图示(发送路径,从上层到下层):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
初始状态(alloc + reserve 后):
[headroom...................| tailroom | skb_shared_info]
^ ^ ^
head data=tail end

tcp_push() → skb_push(TCP_hdr_len):
[headroom......|TCP hdr|data| tailroom | skb_shared_info]
^ ^
data tail

ip_output() → skb_push(IP_hdr_len):
[headroom..|IP hdr|TCP hdr|data| tailroom | skb_shared_info]
^ ^
data tail

dev_hard_start_xmit() → 发出后 ip_finish_output2() → skb_push(ETH_hdr_len):
[ETH hdr|IP hdr|TCP hdr|data| tailroom | skb_shared_info]
^ ^
data (= head) tail

4.4 skb_clone vs skb_copy:引用计数 vs 深拷贝

**skb_clone()**(源码第 1853-1881 行):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct sk_buff *skb_clone(struct sk_buff *skb, gfp_t gfp_mask)
{
struct sk_buff_fclones *fclones = container_of(skb,
struct sk_buff_fclones, skb1);
struct sk_buff *n;

if (skb->fclone == SKB_FCLONE_ORIG &&
refcount_read(&fclones->fclone_ref) == 1) {
n = &fclones->skb2; // 快路径:使用预分配的 fclone
refcount_set(&fclones->fclone_ref, 2);
n->fclone = SKB_FCLONE_CLONE;
} else {
n = kmem_cache_alloc(skbuff_cache, gfp_mask);
if (!n) return NULL;
n->fclone = SKB_FCLONE_UNAVAILABLE;
}
return __skb_clone(n, skb);
}

skb_clone() 只复制 sk_buff 元数据结构,共享数据缓冲区(递增 shinfo->dataref)。克隆体不能修改数据内容,但可以修改自己的首部指针(data 等)。适用于:

  • TCP 发送时保留原始 skb 用于重传
  • Netfilter 分支处理

fclone 优化:TCP 通过 SKB_ALLOC_FCLONE 标志分配 sk_buff_fclones(两个 skb 紧挨着分配),第一次 clone 时直接使用伴生的 skb2,无需额外 slab 分配。

**skb_copy()**(源码第 1933-1953 行):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct sk_buff *skb_copy(const struct sk_buff *skb, gfp_t gfp_mask)
{
int headerlen = skb_headroom(skb);
unsigned int size = skb_end_offset(skb) + skb->data_len;
struct sk_buff *n = __alloc_skb(size, gfp_mask,
skb_alloc_rx_flag(skb), NUMA_NO_NODE);
if (!n) return NULL;

/* Set the data pointer */
skb_reserve(n, headerlen);
/* Set the tail pointer and length */
skb_put(n, skb->len);

BUG_ON(skb_copy_bits(skb, -headerlen, n->head, headerlen + skb->len));
skb_copy_header(n, skb);
return n;
}

skb_copy() 完全复制数据,包括 headroom、线性数据以及所有 page frags,返回一个完全独立的 skb。代价是需要实际的数据拷贝。适用于需要修改数据内容的场景。

操作 复制元数据 复制数据 共享 shinfo 可修改数据
skb_clone()
skb_copy()
pskb_copy() 仅线性部分 是(frags) 仅线性部分

4.5 skb_linearize:处理分散/聚集 IO

1
2
3
4
5
6
7
8
9
static inline int skb_linearize(struct sk_buff *skb)
{
return skb_is_nonlinear(skb) ? __skb_linearize(skb) : 0;
}

static inline int __skb_linearize(struct sk_buff *skb)
{
return __pskb_pull_tail(skb, skb->data_len) ? 0 : -ENOMEM;
}

当代码需要顺序访问报文所有字节,但 skb 是非线性的(data_len > 0,存在 page frags 或 frag_list)时,调用 skb_linearize() 将所有 page frags 的数据拷贝到线性区域,整合为线性 skb。

这是一个较重的操作,会触发内存分配和拷贝。协议栈中有大量 pskb_may_pull() 调用——它只确保 skb 的首部位于线性区域(仅 pull 必要的字节),比完整的 skb_linearize() 代价小得多。

4.6 kfree_skb vs consume_skb:释放路径的语义区别

两者都会递减 skb->users 引用计数,归零后调用 __kfree_skb() 释放内存,但语义不同,这对 tracing 工具(如 drop_monitorskb:kfree_skb tracepoint)至关重要:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// kfree_skb:报文被丢弃(drop),通常意味着异常
void kfree_skb_reason(struct sk_buff *skb, enum skb_drop_reason reason)
{
if (__kfree_skb_reason(skb, reason))
__kfree_skb(skb);
}

// consume_skb:报文被正常消费,不是 drop
void consume_skb(struct sk_buff *skb)
{
if (!skb_unref(skb))
return;
trace_consume_skb(skb, __builtin_return_address(0)); // 触发 consume tracepoint
__kfree_skb(skb);
}

释放链路(__kfree_skb() 路径):

1
2
3
4
5
6
7
8
9
10
11
12
13
__kfree_skb(skb)
└─ skb_release_all(skb, reason, false)
├─ skb_release_head_state(skb)
│ ├─ skb_dst_drop(skb) // 释放路由缓存引用
│ ├─ skb->destructor(skb) // 如 sock_wfree() 归还 socket 内存配额
│ ├─ nf_conntrack_put() // 释放连接跟踪引用
│ └─ skb_ext_put(skb) // 释放扩展数据
└─ skb_release_data(skb, reason, false)
├─ 递减 shinfo->dataref
├─ napi_frag_unref() // 释放 page frags(可能触发 page_pool 回收)
├─ kfree_skb_list(frag_list)
└─ skb_free_head() // 释放数据缓冲区
└─ kfree_skbmem(skb) // 归还 sk_buff 到 slab 缓存

五、零拷贝相关机制

5.1 skb_frag_t 页片段机制

skb_frag_t 等价于 struct bio_vec,每个 frag 持有一个 struct page * 引用:

1
2
3
4
5
6
7
typedef struct bio_vec skb_frag_t;

// 访问 frag 的工具函数:
static inline unsigned int skb_frag_size(const skb_frag_t *frag)
{
return frag->bv_len;
}

驱动在接收时通常这样操作:

  1. 预先分配 page(通常通过 page_pool 管理),通过 DMA 映射给网卡
  2. 网卡将数据写入 page,触发中断/NAPI
  3. 驱动调用 skb_add_rx_frag() 将 page 引用填入 frags[]
1
2
3
4
5
6
7
8
void skb_add_rx_frag(struct sk_buff *skb, int i, struct page *page,
int off, int size, unsigned int truesize)
{
skb_fill_page_desc(skb, i, page, off, size);
skb->len += size;
skb->data_len += size;
skb->truesize += truesize;
}

page_pool 是 6.x 内核引入的高性能页缓存机制,通过 skb->pp_recycle 标志和 skb_pp_recycle() 函数,在 skb 释放时直接将 page 归还给 page_pool 而非系统内存池,避免了 page allocator 的开销,实现”原地回收”。

5.2 sendfile/splice 的 skb 复用

sendfile()splice() 系统调用走的是 SKBFL_SHARED_FRAG 路径:文件页缓存中的 page 直接被加入 skb 的 frags[],内核标记该 frag 为可能被外部修改:

1
2
3
4
5
6
/* This indicates at least one fragment might be overwritten
* (as in vmsplice(), sendfile() ...)
* If we need to compute a TX checksum, we'll need to copy
* all frags to avoid possible bad checksum
*/
SKBFL_SHARED_FRAG = BIT(1),

此时 skb 的数据直接来自 page cache,全程无需拷贝到内核网络缓冲区。但如果需要计算 TX checksum(ip_summed == CHECKSUM_PARTIAL 但硬件不支持),内核必须通过 skb_copy_ubufs() 先复制 frags 内容,再计算 checksum,这是 zero-copy sendfile 的一个隐含成本。

用户态零拷贝(MSG_ZEROCOPYSKBFL_ZEROCOPY_ENABLE)则通过 ubuf_info 回调机制,在 DMA 传输完成后通知用户态释放缓冲区,实现用户态缓冲区到网卡的真正零拷贝。


六、关键结构体尺寸速查(x86-64,Linux 6.4)

结构体 大小
struct sk_buff ~232 字节
struct skb_shared_info ~320 字节(含 17 个 frags)
skb->cb[48] 48 字节
struct tcp_skb_cb 44 字节(含 IPv6 时)
struct inet_skb_parm ~20 字节
skb_frag_t (bio_vec) 12 字节

七、系列文章导航

本系列共计划 10 篇,系统覆盖 Linux 网络内核的核心子系统:

篇次 标题 核心内容
Part 1 总览架构与 sk_buff 生命周期(本文) 协议栈全路径、sk_buff 内存模型、操作函数、零拷贝
Part 2 网卡驱动与 NAPI 机制 ndo_open/ndo_start_xmit、中断合并、NAPI poll、page_pool、XDP
Part 3 IP 层:路由、分片与转发 fib_lookup、路由缓存、ip_fragment、conntrack hook 点
Part 4 TCP 实现(一):连接管理与状态机 三次握手、TIME_WAIT、tcp_rcv_state_process、SYN cookies
Part 5 TCP 实现(二):拥塞控制与发送缓冲区 CUBIC/BBR、tcp_write_xmit、TSQ、Nagle 算法
Part 6 TCP 实现(三):接收与流量控制 tcp_rcv_established、OOO 队列、接收窗口、tcp_recvmsg
Part 7 UDP 与多播 udp_sendmsgudp_rcv、GRO for UDP、多播路由
Part 8 Socket 层与 VFS 接口 sock_allocinet_stream_opspoll/epoll 实现、零拷贝 recv
Part 9 Netfilter 与 iptables/nftables hook 框架、连接跟踪(nf_conntrack)、NAT 实现原理
Part 10 性能调优与 eBPF 网络编程 RSS/RPS/RFS、SO_REUSEPORT、XDP、TC eBPF、sockmap

参考资料

  • Linux 6.4-rc1 源码:include/linux/skbuff.hnet/core/skbuff.c
  • Linux 内核文档:Documentation/networking/skbuff.rst(内核源码树内)
  • Jonathan Corbet 等:Linux Device Drivers, 3rd Edition,Chapter 17
  • Jesper Dangaard Brouer:Network stack receive path
  • Thomas Graf:Kernel Networking Walkthrough(netdev 历届会议资料)

第三部分:Scheduler——vLLM 的大脑

简介

Scheduler 是 vLLM 的核心调度器。在每个微秒级的时间窗口内,它都要做出关键决策:处理哪些 request、计算多少个 token、何时 preempt request,以及如何最大化 GPU 利用率。本文将深入探讨 Scheduler 的算法与实现。

调度挑战

问题空间

在任意时刻,Scheduler 必须处理:

  • 等待中的 request:等待处理的新 prompt
  • 运行中的 request:正处于 decode 阶段的持续生成任务
  • 资源限制:有限的 GPU 内存和计算预算
  • 目标冲突:最小化延迟 vs. 最大化吞吐量
  • 动态负载:request 异步到达和完成

没有简单的解决方案

与传统批处理不同,LLM 推理服务面临独特挑战:

  1. 请求长度不固定:无法预测完成时间
  2. 内存随序列长度增长:不仅仅受计算限制
  3. prefill 与 decode 的不对称性:prefill 每个 token 耗时约为 decode 的 100 倍
  4. 共享 KV cache:内存决策影响所有 request

Continuous Batching

vLLM 的核心创新:continuous batching(又称迭代级批处理)。

传统静态批处理

1
2
3
4
5
6
7
8
9
10
11
12
13
# Static batching - wait for batch to fill
batch = []
while len(batch) < batch_size:
batch.append(wait_for_request())

# Process entire batch
outputs = model.forward(batch)

# Wait for ALL requests to finish
while any_request_incomplete(batch):
outputs = model.forward(batch)

# All done - start next batch

问题

  • 队头阻塞:快速 request 需等待慢速 request
  • 低 GPU 利用率:随着 request 完成,batch 规模不断缩小
  • 高延迟:必须等待 batch 填满

Continuous Batching

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Continuous batching - add/remove every iteration
running = []

while True:
# Remove finished requests
running = [r for r in running if not r.finished]

# Add new requests if there's capacity
while can_fit_new_request() and waiting_requests:
running.append(waiting_requests.pop())

# Process current batch
outputs = model.forward(running)

# Immediately continue - no waiting!

优点

  • 无队头阻塞:request 完成后立即离队
  • 持续高 GPU 利用率:始终维持最大 batch 大小
  • 更低延迟:新 request 可立即开始处理

Scheduler 架构

文件位置vllm/v1/core/sched/scheduler.py

核心数据结构

Scheduler 维护 requestswaitingrunningskipped_waiting,并持有 KVCacheManager / EncoderCacheManager 等资源管理器。其中 max_num_scheduled_tokens 的来源是:

1
2
3
4
5
self.max_num_scheduled_tokens = (
self.scheduler_config.max_num_scheduled_tokens
if self.scheduler_config.max_num_scheduled_tokens
else self.scheduler_config.max_num_batched_tokens
)

也就是说,max_num_batched_tokens 现在是 fallback,而不是唯一来源。

Request 状态

当前 V1 的 request 状态比“WAITING → RUNNING → FINISHED”更细:

  • WAITING
  • WAITING_FOR_STRUCTURED_OUTPUT_GRAMMAR
  • WAITING_FOR_REMOTE_KVS
  • WAITING_FOR_STREAMING_REQ
  • RUNNING
  • PREEMPTED
  • 多个 finished 子状态:FINISHED_STOPPEDFINISHED_LENGTH_CAPPEDFINISHED_ABORTEDFINISHED_IGNOREDFINISHED_ERRORFINISHED_REPETITION

因此,更准确的流转应理解为:

  • 新请求通常从 WAITING 进入 RUNNING
  • 被抢占的请求会变成 PREEMPTED 并重新入队
  • 恢复执行时是 PREEMPTED → RUNNING
  • 结束时会进入某个具体的 finished 子状态,而不是单一的 FINISHED

Request 队列

当前 RequestQueue 的核心 API 是:

1
2
3
4
queue.add_request(request)
request = queue.pop_request()
request = queue.peek_request()
queue.prepend_request(request)

如果启用 priority 策略,优先级大小规律也与很多直觉相反:数值越小,优先级越高PriorityRequestQueue 直接依赖 Request.__lt__,比较顺序是:

  1. priority(更小者优先)
  2. arrival_time(更早者优先)
  3. request_id

调度算法

整体流程

schedule() 的核心思路仍然是“先处理 running,再在条件允许时接纳 waiting”。SchedulerOutput 会分别携带:

  • scheduled_new_reqs
  • scheduled_cached_reqs
  • num_scheduled_tokens: dict[str, int]
  • total_num_scheduled_tokens
  • scheduled_spec_decode_tokens
  • scheduled_encoder_inputs
  • finished_req_ids
  • preempted_req_ids
  • 以及 prefix / encoder / connector 相关的附加信息

可以把主循环简化理解为:

1
2
3
4
5
6
7
8
def schedule(self) -> SchedulerOutput:
token_budget = self.max_num_scheduled_tokens

# 1. 先调度 running request
# 2. 必要时执行 preemption
# 3. 仅在本轮没有 preempt 时尝试接纳 waiting request
# 4. 构造包含 per-request token 预算、spec decode、
# encoder inputs 和 finished/preempted IDs 的 SchedulerOutput

第一步:Token Budget

token budget 的概念没有变化:它仍然是当前迭代最重要的“算力 / latency / activation memory”上限之一。当前实现用 self.max_num_scheduled_tokens 作为每轮 budget,并在 chunked prefill、running request 续跑、spec decode 等路径上共享这份预算。

第二步:调度运行中的 Request

当前 scheduler 不再显式维护“prefill phase / decode phase”两套完全独立的 API。更准确的做法是根据:

  • request.num_computed_tokens
  • request.num_tokens_with_spec
  • request.num_output_placeholders

之间的差值来决定本轮还需要为该 request 安排多少 token。

对 running request 来说,scheduler 主要会:

  1. 计算本轮 num_new_tokens
  2. 结合 long_prefill_token_threshold 等配置裁剪超长 prefill
  3. 为已命中的 prefix cache / 外部 KV transfer / speculative lookahead 计算额外上下文
  4. 调用 allocate_slots() 尝试拿到新 slot
  5. 若拿不到 slot,则按当前策略 preempt 其他 request 或 preempt 当前 request

因此,比起“给 request 写回 request.num_scheduled_tokens = ...”,当前实现更准确的说法是:每轮调度数量被记录在 SchedulerOutput.num_scheduled_tokens[request_id]

第三步:调度等待中的 Request

一个新 request 要进入本轮 batch,通常会经历这些检查:

  1. waitingskipped_waiting 中选择可调度的队列
  2. 处理 structured output grammar、remote KV、streaming 等阻塞状态
  3. 先调用 get_computed_blocks() 查找 prefix cache 命中
  4. 再用 can_fit_full_sequence() 判断完整序列是否可以被系统接纳
  5. 通过 allocate_slots() 为本轮真正需要的 token 分配 slot

这里有两个关键点:

  • scheduler 维护的是 waiting + skipped_waiting 两条队列,而不是单一 waiting queue
  • 只有当本轮没有发生 preemption 时,scheduler 才会继续接纳 waiting request

第四步:处理 Preemption

preemption 通过 _preempt_request() 完成,流程更接近:

1
2
3
4
5
6
def _preempt_request(self, request, timestamp):
self.kv_cache_manager.free(request)
self.encoder_cache_manager.free(request)
request.status = RequestStatus.PREEMPTED
request.num_preemptions += 1
self.waiting.prepend_request(request)

需要注意:

  • request 会以 PREEMPTED 状态重新入队,而不是简单地回到 WAITING
  • preemption 后确实需要重新调度,但如果 prefix cache 还能命中完整 block,后续恢复执行时不一定要“从零开始重算”
  • victim 选择也不是固定的“进度最少者优先”:在 priority 模式下,会先牺牲较差优先级的 running request;FCFS 模式下则更接近按当前运行队列尾部顺序处理

高级调度策略

Chunked Prefill

chunked prefill 仍然存在,但当前实现不是 self.chunk_size 这种字段驱动。更贴近源码的配置包括:

  • enable_chunked_prefill
  • long_prefill_token_threshold
  • max_num_partial_prefills
  • max_long_partial_prefills

因此,当前行为更像是:

  • 对超长 prefill request 截断本轮 num_new_tokens
  • 在 token budget 内把长 prompt 分摊到多轮
  • 让长 prompt 不至于一次耗尽整个 batch 配额

优先级调度

当前 vLLM 只支持两种调度策略:

  • fcfs
  • priority

priority 模式下数字越小越先执行。可以这样理解:

1
2
3
# Lower numeric value = higher scheduling priority
priority = 0 # interactive / urgent
priority = 10 # background

支持 Speculative Decoding

当前 scheduler 对 speculative decoding 的感知体现在:

  • request 上的 spec_token_ids
  • SchedulerOutput.scheduled_spec_decode_tokens
  • num_lookahead_tokens
  • 输出更新阶段对 draft token 的接受 / 拒绝处理

因此,比起 main_model.verify(draft_tokens) 这种直观伪代码,更准确的描述是:scheduler 会为 lookahead / draft token 预留 slot,并通过 scheduled_spec_decode_tokens 把本轮 spec decode 信息传给后续执行与输出更新路径。

Request 重排序

当前本地源码里并没有一个按“预估 prefix cache 命中率”对 waiting requests 进行重排序的 reorder_for_cache_hits() 实现。waiting 请求的顺序主要由 FCFS / priority queue 决定,再叠加 skipped_waiting 的恢复逻辑。

资源管理

KV Cache 分配

更贴近源码的准入流程是:

1
2
3
4
5
6
7
8
9
10
11
12
13
computed_blocks, num_cached_tokens = kv_cache_manager.get_computed_blocks(request)

if kv_cache_manager.can_fit_full_sequence(
request,
num_new_computed_tokens=num_cached_tokens,
new_computed_blocks=computed_blocks,
):
new_blocks = kv_cache_manager.allocate_slots(
request,
num_new_tokens,
num_new_computed_tokens=num_cached_tokens,
new_computed_blocks=computed_blocks,
)

当存在 sliding window、external KV transfer、spec decode 或 encoder-decoder cross-attention 时,allocate_slots() 还会综合这些上下文一起计算需要分配的 block 数量。

内存压力处理

当前 scheduler 的内存压力处理更接近“尝试为 running request 分配 slot,失败时根据当前策略 preempt”。prefix cache 的显式淘汰接口是 kv_cache_manager.evict_blocks(block_ids),它按 block id 清理缓存索引。

Encoder Cache 管理

多模态 / encoder-decoder 场景下,当前 request 并不是暴露 encoder_input_ids 这个简化字段。scheduler 会通过 request.mm_features_try_schedule_encoder_inputs(),以及 EncoderCacheManager.can_allocate() / allocate() / check_and_update_cache() 来判断 encoder side 是否有容量。

性能指标

Scheduler 统计信息

SchedulerStats 的重点字段包括:

  • num_running_reqs
  • num_waiting_reqs
  • kv_cache_usage
  • encoder_cache_usage
  • prefix_cache_stats
  • connector_prefix_cache_stats
  • kv_cache_eviction_events
  • spec_decoding_stats
  • perf_stats

而“本轮 preempt 了多少 request”这类信息,当前是放在 IterationStats.num_preempted_reqs 里,而不是 SchedulerStats.num_preempted

优化目标

因此,当前 scheduler 更像是在平衡这些目标:

  1. 最大化吞吐量:尽可能在 token budget 内安排更多有效计算
  2. 最小化延迟:优先保持 running request 的连续推进
  3. 控制长 prompt 影响:通过 chunked prefill / long prefill threshold 避免大 prompt 独占
  4. 减少无谓 preemption:让 KV cache 与 encoder cache 的压力保持可控
  5. 尽可能利用 prefix cache / external KV:减少重复 prefill 计算

示例:调度时间线

下面给出一个更接近当前实现语义的简化时间线。注意这里使用的是 scheduler_output.num_scheduled_tokens[req_id] 这个“每轮输出”,而不是给 request 对象写回一个持久字段。

初始状态

1
2
3
waiting = [A(prompt=100), B(prompt=50)]
running = []
token_budget = 200

迭代 1

  • AB 都还是新请求
  • scheduler 分别检查 prefix cache 命中、完整序列准入与 KV slot 分配
  • 若两者都能被接纳,则输出里会类似地记录:
1
2
3
4
scheduler_output.num_scheduled_tokens = {
"A": 100,
"B": 50,
}

迭代 2

  • AB 进入 running 集合
  • 若此时到来一个超长请求 C,scheduler 会先为 running request 预留预算,再根据 long_prefill_token_threshold 与剩余 budget 决定 C 这一轮最多能推进多少 token
  • 如果本轮发生了 preemption,则 waiting request 的接纳会被延后到下一轮

后续迭代

  • request 的自然完成不是在 schedule() 里直接调用某个 finish_request() helper,而是要等 model output 返回后,由 scheduler.update_from_output(...) 更新状态并判断 stop / length cap / error 等 finished 原因
  • B 被 preempt,scheduler 会把它以 PREEMPTED 状态重新入队;等资源恢复后,它会再次进入 RUNNING

调度策略

当前 vLLM 暴露的调度策略只有两种:

FCFS(先到先服务)

  • 队列类型:FCFSRequestQueue
  • 主要接口:add_request() / pop_request() / peek_request()
  • 优点:简单、可预测
  • 缺点:长 request 更容易拖慢后续 waiting request 的进入时机

基于优先级

  • 队列类型:PriorityRequestQueue
  • 排序规则:priority 数值越小越先执行;若相同则按 arrival_time,再按 request_id
  • 优点:可以显式优先保障交互式 / 重要流量
  • 缺点:如果优先级设计不当,低优先级 request 可能长期等待

排查 Scheduler 问题

高 Preemption 率

更贴近当前指标的观察方式是关注:

  • IterationStats.num_preempted_reqs
  • SchedulerStats.kv_cache_usage
  • SchedulerStats.encoder_cache_usage

如果 preemption 频繁发生,通常优先检查:

  1. KV cache 是否过小
  2. max_num_seqs 是否过高
  3. 是否启用了 chunked prefill,以及 long_prefill_token_threshold 是否合理

等待队列持续增长

如果 SchedulerStats.num_waiting_reqs 长时间偏高,通常意味着:

  1. token budget 太保守
  2. 长 prompt 占用了过多 budget
  3. priority / FCFS 策略与业务流量类型不匹配

高延迟

如果 TTFT 偏高,通常优先检查:

  1. 是否启用 chunked prefill
  2. max_num_scheduled_tokens 是否过大
  3. prefix cache / external KV 是否发挥作用
  4. 高优先级交互流量是否需要单独的 priority 策略

关键要点

  1. Continuous batching 通过每次迭代动态增减 request,消除了队头阻塞

  2. Token budget 控制 batch 大小,平衡延迟与吞吐量的权衡

  3. 运行中的 request 拥有优先权,以最小化 decode 延迟

  4. Preemption 是内存紧张时的最后手段:它会中断当前运行进度,但后续恢复时仍可能重新命中 prefix cache

  5. Chunked prefill 在处理长 prompt 与交互式 request 之间取得平衡

  6. 当前的 SchedulerOutput 是结构化输出,会同时携带 per-request token 分配、spec decode、encoder 输入与 finished/preempted request 信息

下一步

在第四部分中,我们将探索 Request Processing——request 如何从 tokenization 到流式输出在系统中流转,以及跨迭代的状态管理机制。

参考资料


Scheduler 是 vLLM 的大脑,每秒钟要做出成千上万个瞬间决策,在满足延迟 SLA 的同时最大化 GPU 利用率。接下来,我们将看到 request 如何在系统中端到端地流转。

0%