EXPERIMENT REPORT

实验二:推理吞吐量与 Continuous Batching 压测

2025-11-25 10 MIN READ

1. 实验二:基础实验

注:

  • 本实验所有机器均属于AutoDL 平台www.autodl.com,执行前需自行注册账号并完成相关认证。
  • Happy Path:正常情况
  • Sad Path:异常情况

1.1 第一阶段:实例租赁与环境准备

目标:获取一台 24GB 显存的机器(RTX 4090 为佳),并配置 PyTorch 环境。

  1. 租赁实例

    • 算力选型:在 AutoDL 算力市场,选择 RTX 4090 (24GB)。如果为了对比测试 OOM,也可以选 RTX 3090 (24GB) 或更小的卡(如 A4000 16GB,更容易触发 OOM)。
    • 镜像选择:vLLM 对环境要求较高,建议选择 PyTorch 2.1.2 + CUDA 12.1PyTorch 2.3.0 + CUDA 12.1 的镜像。
    • 启动:开机后,点击“JupyterLab”进入控制台。
  2. 打开终端 (Terminal)

    在 JupyterLab 页面底部点击“终端”图标。

  3. 安装依赖库

    hljs bash
    1
    2
    3
    4
    5
    6
    7
    8
    9
    # 升级 pip
    pip install --upgrade pip
    
    # 安装 vLLM (过程可能需要几分钟,包含编译好的 CUDA 扩展)
    # 这里的版本选择是为了兼顾稳定性和新特性(如 Chunked Prefill)
    pip install vllm>=0.5.0 modelscope pandas numpy
    
    # 安装用于量化模型支持的库
    pip install auto-gptq optimum
    

1.2 第二阶段:模型下载(解决国内下载慢的问题)

目标:为了给 KV Cache 留出更多显存以测试“吞吐量极限”,我们使用 Int4 版本的 Qwen2.5-7B。

  1. 创建下载脚本

    在终端输入以下命令创建一个下载脚本:

    hljs bash
    1
    touch download_model.py
    
  2. 编辑并运行下载代码

    双击左侧文件列表中的 download_model.py,粘贴以下代码:

    hljs python
    1
    2
    3
    4
    5
    6
    from modelscope import snapshot_download
    
    # 下载 Qwen2.5-7B-Instruct-GPTQ-Int4
    # 这款模型显存仅占约 5-6GB,在 24GB 显卡上能留出 18GB 给 KV Cache,非常适合压测
    model_dir = snapshot_download('Qwen/Qwen2.5-7B-Instruct-GPTQ-Int4', cache_dir='/root/autodl-tmp')
    print(f"Model downloaded to: {model_dir}")
    
  3. 执行下载

    hljs bash
    1
    python download_model.py
    

    记录下终端输出的模型最终路径(例如 /root/autodl-tmp/qwen/Qwen2-5-7B-Instruct),下一步代码要用。


1.3 第三阶段:构建实验代码 (The Experiment)

目标:将逻辑转化为可执行的 Python 脚本

  1. 创建主实验脚本

    hljs bash
    1
    touch basic_exp_throughput.py
    
  2. 编写代码(请将 model_path 替换为你上一步下载的实际路径):

    hljs python
    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
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    import asyncio
    import time
    import numpy as np
    from vllm import AsyncLLMEngine, EngineArgs, SamplingParams
    
    # ================= 实验配置 =================
    # 请确保已下载该量化模型,以腾出显存给 KV Cache
    MODEL_PATH = "/root/autodl-tmp/Qwen/Qwen2.5-7B-Instruct-GPTQ-Int4"
    
    # 基础提示词 (约 50 tokens)
    PROMPT_TEMPLATE = "请列举 5 个关于人工智能未来发展的关键挑战,并简要说明原因。"
    # ===========================================
    
    async def run_benchmark_batch(engine, concurrency, scenario_name):
        """
        核心压测函数:模拟并发请求并统计指标
        """
        print(f"\n{'-'*20} 正在运行: {scenario_name} (并发数: {concurrency}) {'-'*20}")
        
        # 1. 构造请求参数
        # max_tokens=128: 控制生成长度,模拟标准问答
        sampling_params = SamplingParams(temperature=0.8, max_tokens=128)
        
        # 构造 batch
        req_list = [f"{scenario_name}_req_{i}" for i in range(concurrency)]
        prompts = [PROMPT_TEMPLATE for _ in range(concurrency)]
        
        start_time = time.time()
        ttft_list = []      # 首字延迟 (Time To First Token)
        completed_reqs = 0
        total_gen_tokens = 0
        
        async def process_request(req_id, prompt):
            nonlocal completed_reqs, total_gen_tokens
            req_start = time.time()
            first_token_received = False
            tokens_in_this_req = 0
            
            try:
                # vLLM 的 generate 是一个异步生成器
                async for request_output in engine.generate(prompt, sampling_params, request_id=req_id):
                    # 捕捉首字延迟
                    if not first_token_received:
                        ttft = time.time() - req_start
                        ttft_list.append(ttft)
                        first_token_received = True
                    
                    # 记录生成的 token 数(取最后一个 output 的长度)
                    if request_output.outputs:
                        tokens_in_this_req = len(request_output.outputs[0].token_ids)
                
                total_gen_tokens += tokens_in_this_req
                completed_reqs += 1
                
            except Exception as e:
                print(f"Request {req_id} failed: {e}")
    
        # 2. 并发发射所有请求 (Continuous Batching 开始工作)
        tasks = [process_request(rid, p) for rid, p in zip(req_list, prompts)]
        await asyncio.gather(*tasks)
        
        total_time = time.time() - start_time
        
        # 3. 计算核心指标
        tps = total_gen_tokens / total_time
        avg_ttft = np.mean(ttft_list) * 1000 if ttft_list else 0
        p99_ttft = np.percentile(ttft_list, 99) * 1000 if ttft_list else 0
        
        print(f"📊 [{scenario_name}] 结果报告:")
        print(f"  > 耗时: {total_time:.2f} s")
        print(f"  > 吞吐量 (TPS): {tps:.2f} tokens/s")
        print(f"  > 平均首字延迟 (Avg TTFT): {avg_ttft:.2f} ms")
        print(f"  > 尾部延迟 (P99 TTFT):     {p99_ttft:.2f} ms")
        
        return tps, p99_ttft
    
    async def main():
        print(f"🚀 初始化 vLLM 引擎 (Model: {MODEL_PATH})...")
        
        # 初始化引擎
        # gpu_memory_utilization=0.9: 预留 90% 显存,确保有足够空间给 KV Cache 
        # max_model_len=2048: 限制上下文长度,防止 OOM
        engine_args = EngineArgs(
            model=MODEL_PATH,
            quantization="gptq",
            gpu_memory_utilization=0.9,
            max_model_len=2048,
            disable_log_requests=True
        )
        engine = AsyncLLMEngine.from_engine_args(engine_args)
        
        # Warup (预热)
        print("🔥 正在预热 (Warmup)...")
        await run_benchmark_batch(engine, concurrency=1, scenario_name="Warmup")
    
        # ==========================================
        # 2.3 Happy Path: 梯度并发测试
        # 目标:寻找吞吐量饱和点 (Saturation Point)
        # ==========================================
        print("\n>>> 开始 Happy Path: 梯度并发测试 <<<")
        concurrency_levels = [1, 8, 16, 32, 64]
        
        results = []
        for c in concurrency_levels:
            tps, p99 = await run_benchmark_batch(engine, c, "HappyPath")
            results.append((c, tps, p99))
            # 冷却一小会儿
            await asyncio.sleep(1)
    
        print("\n📈 [Happy Path] 趋势汇总:")
        print("并发数 | TPS (吞吐) | P99 TTFT (延迟)")
        for c, t, p in results:
            print(f"{c:<6} | {t:<10.2f} | {p:.2f} ms")
    
        # ==========================================
        # 2.4 Sad Path: 队列雪崩 (Queue Saturation)
        # 目标:制造延迟尖刺,验证高负载下的调度能力
        # ==========================================
        print("\n>>> 开始 Sad Path: 突发压力测试 (Burst) <<<")
        # 瞬间发送 128 个请求 (对于单卡 4090 来说压力较大)
        burst_concurrency = 128 
        
        print(f"⚠️ 警告: 即将瞬间发射 {burst_concurrency} 个请求,观察 P99 延迟是否飙升...")
        await run_benchmark_batch(engine, burst_concurrency, "SadPath_Burst")
    
    if __name__ == "__main__":
        asyncio.run(main())
    
    hljs output

1.4 第四阶段:执行与观测

你需要同时看代码输出和系统监控。

  1. 开启系统级监控(分屏):在 JupyterLab 中,新建第二个终端窗口。输入以下命令,实时观察 GPU 显存变化:

    hljs bash
    1
    watch -n 0.5 nvidia-smi
    
  2. 运行实验:回到第一个终端,运行 Python 脚本:

    hljs bash
    1
    python vram_anatomy.py
    
  3. 实验结果示例:点击示例代码右上角的执行按钮,查看示例结果


1.5 第五阶段:实验结果分析指南

当脚本运行时,请重点观察以下数据,验证“理论背景”

  • 看 Happy Path 汇总表
    • 随着并发从 1 增加到 64,TPS 应该会快速上升,然后趋于平缓(这就是 vLLM 的 Continuous Batching 在发挥作用)。
    • P99 TTFT 应该保持相对稳定,只有轻微增加。
  • 看 Sad Path 结果
    • 对比 HappyPath (并发64) 和 SadPath_Burst (并发128)。
    • 预期现象:Sad Path 的 P99 TTFT 可能会显著飙升(例如从 50ms 变成 500ms+)。这是因为并发超过了 GPU 的瞬时处理能力,请求被迫在调度队列中排队,导致“首字”迟迟出不来。

2. 实验二:进阶实验

为什么需要进阶实验:基础实验考虑了“量”的堆积,但忽略了“质”的冲突。在生产环境中,最可怕的不是请求多,而是请求长短不一

2.1 进阶 1:长短文混合与 Chunked Prefill (The Prefill Bottleneck)

  • 缺失点:Continuous Batching 解决了解码(Decode)阶段的空泡,但没解决预填充(Prefill)阶段的阻塞。如果一个超长 Prompt(比如 4000 token)进来,GPU 会全力计算它的 KV Cache,导致后面排队的 100 个短请求全部卡住,TTFT 瞬间爆炸。
  • 操作
    • 构造混合流量:90% 的短请求(User: "你好") + 10% 的长文档总结请求(4k Context)。
    • 开启/关闭 Chunked Prefill:这是 vLLM 的高级特性,将长 Prompt 切成小块分批计算,允许短请求“插队”。
  • 观察:对比开启前后的 P99 TTFT。
  • 锐评:这是 N 卡和国产卡拉开差距的地方。Chunked Prefill 需要极高频的 Kernel 切换,如果驱动层面的 Kernel Launch Latency 过高(这是国产驱动的通病),切分反而会导致总吞吐下降。

实验逻辑:我们模拟 1 个超长请求 (4K Context) 正在处理(Prefill 阶段),此时突发 20 个短请求

  • Case A (关):短请求必须等待长请求算完 KV Cache 才能开始,TTFT 会极高。
  • Case B (开):长请求被切碎,短请求可以插队,TTFT 显著降低。

执行步骤

  1. 创建文件exp2a_prefill_OFF.py(对照组:关闭特性)

    目标:测试短请求必须等待长请求算完 KV Cache 才能开始,TTFT 会极高。

    hljs python
    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
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    import asyncio
    import time
    import numpy as np
    from vllm import AsyncLLMEngine, EngineArgs, SamplingParams
    
    # ================= 配置 =================
    MODEL_PATH = "/root/autodl-tmp/Qwen/Qwen2.5-7B-Instruct-GPTQ-Int4"
    # =======================================
    
    async def main():
        print(f"\n{'='*10} 进阶 1-A: 关闭 Chunked Prefill (The Blocking) {'='*10}")
        
        # 【核心配置】强制关闭 Chunked Prefill
        engine_args = EngineArgs(
            model=MODEL_PATH,
            quantization="gptq",
            gpu_memory_utilization=0.9,
            enable_chunked_prefill=False,   # <--- 关
            max_num_seqs=256,
            disable_log_requests=True
        )
        engine = AsyncLLMEngine.from_engine_args(engine_args)
    
        # 1. 构造负载:90% 短请求 + 10% 长请求
        # 长 Prompt: 约 3500-4000 tokens
        long_prompt = "在人工智能领域,显存带宽与计算能力的平衡是至关重要的。" * 300 
        short_prompt = "你好"
        
        # 2. 模拟场景:长请求先到,短请求紧随其后
        print(">>> 场景复现:1 个长请求 (4k tokens) 正在进入,20 个短请求紧随其后...")
        
        ttft_shorts = []
        
        async def run_req(rid, prompt, is_short):
            params = SamplingParams(temperature=0.7, max_tokens=10)
            start = time.time()
            first_flag = False
            async for _ in engine.generate(prompt, params, request_id=rid):
                if not first_flag:
                    if is_short:
                        ttft_shorts.append(time.time() - start)
                    first_flag = True
    
        tasks = []
        # 发射 1 个长请求
        tasks.append(run_req("LONG_REQ", long_prompt, False))
        
        # 稍作微小延迟,确保长请求先进入 Prefill 阶段占据 GPU
        await asyncio.sleep(0.05) 
        
        # 发射 20 个短请求
        for i in range(20):
            tasks.append(run_req(f"SHORT_{i}", short_prompt, True))
    
        await asyncio.gather(*tasks)
        
        print(f"\n📊 [Chunked OFF] 短请求 P99 TTFT: {np.percentile(ttft_shorts, 99)*1000:.2f} ms")
        print(f"   (专家预期:这个数值会很高,因为短请求被长请求的 Prefill 堵住了)")
    
    if __name__ == "__main__":
        asyncio.run(main())
    
    hljs output
  2. 运行文件

    hljs bash
    1
    python exp1_kv_cache.py
    
  3. 观察结果:在页面中点击示例代码右上角的执行按钮,查看示例结果

  4. 创建文件exp2a_prefill_ON.py(实验组:打开特性)

    目标:测试长请求被切碎,短请求可以插队,TTFT 显著降低。

    hljs python
    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
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    import asyncio
    import time
    import numpy as np
    from vllm import AsyncLLMEngine, EngineArgs, SamplingParams
    
    MODEL_PATH = "/root/autodl-tmp/Qwen/Qwen2.5-7B-Instruct-GPTQ-Int4"
    
    async def main():
        print(f"\n{'='*10} 进阶 1-B: 开启 Chunked Prefill (The Interleaving) {'='*10}")
        
        # 【核心配置】开启 Chunked Prefill
        # max_num_batched_tokens=512 意味着长 Prompt 每次只算 512 个 token 就暂停,
        # 看看有没有短请求要插队,有就让短请求先走。
        engine_args = EngineArgs(
            model=MODEL_PATH,
            quantization="gptq",
            gpu_memory_utilization=0.9,
            enable_chunked_prefill=True,        # <--- 开
            max_num_batched_tokens=512,         # <--- 切分粒度
            disable_log_requests=True
        )
        engine = AsyncLLMEngine.from_engine_args(engine_args)
    
        # 同样的负载
        long_prompt = "在人工智能领域,显存带宽与计算能力的平衡是至关重要的。" * 300 
        short_prompt = "你好"
        
        print(">>> 场景复现:Chunking 开启,短请求尝试插队...")
        
        ttft_shorts = []
        
        async def run_req(rid, prompt, is_short):
            params = SamplingParams(temperature=0.7, max_tokens=10)
            start = time.time()
            first_flag = False
            async for _ in engine.generate(prompt, params, request_id=rid):
                if not first_flag:
                    if is_short:
                        ttft_shorts.append(time.time() - start)
                    first_flag = True
    
        tasks = []
        tasks.append(run_req("LONG_REQ", long_prompt, False))
        await asyncio.sleep(0.05) 
        for i in range(20):
            tasks.append(run_req(f"SHORT_{i}", short_prompt, True))
    
        await asyncio.gather(*tasks)
        
        print(f"\n📊 [Chunked ON] 短请求 P99 TTFT: {np.percentile(ttft_shorts, 99)*1000:.2f} ms")
        print(f"   (专家预期:这个数值应显著低于 Case A,证明插队成功)")
    
    if __name__ == "__main__":
        asyncio.run(main())
    
    hljs output
  5. 观察结果


2.2 进阶 2:显存换入换出 (Swapping Penalty)

  • 缺失点:你在 Sad Path 中提到了 KV Cache 耗尽,但 vLLM 不会直接崩,而是会触发 Swap-out(把 KV Block 搬到 CPU 内存)。
  • 操作
    • 极度压榨显存:设置 gpu_memory_utilization = 0.95。
    • 发送超过显存容量的并发长文本。
  • 观察
    • 监控 musa-smi 或 nvidia-smi 的 PCIe 带宽利用率。
    • 观察推理速度是否出现“波浪式”骤降(GPU 等待 CPU 数据搬回的时刻)。
  • 技术解剖:这时候拼的不是算力,是 PCIe 4.0/5.0 的带宽。如果国产 CPU 搭配的 PCIe 控制器弱,这里就是瓶颈。

实验逻辑:设置 gpu_memory_utilization = 0.95,然后疯狂发送长文本,直到 vLLM 被迫将 KV Block 换出到 CPU RAM。此时通过观察 Token 生成间隔(TPOT)的抖动来验证 PCIe 带宽。

执行步骤

  1. 创建文件exp3_swapping_extreme.py

    目标:测试 PCIe 瓶颈导致的 "Stalling" (停顿)

    hljs python
    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
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    import asyncio
    import time
    import numpy as np
    from vllm import AsyncLLMEngine, EngineArgs, SamplingParams
    
    MODEL_PATH = "/root/autodl-tmp/Qwen/Qwen2.5-7B-Instruct-GPTQ-Int4"
    
    async def main():
        print(f"\n{'='*10} 进阶 2: Swapping Penalty (PCIe 压力测试) {'='*10}")
        
        # 1. 极限显存配置
        print(">>> 初始化引擎:显存占用设为 0.95 (接近物理极限)...")
        engine_args = EngineArgs(
            model=MODEL_PATH,
            quantization="gptq",
            gpu_memory_utilization=0.95,  # 专家要求的 95%
            max_model_len=4096,           # 允许长文
            swap_space=4,                 # 给 CPU Swap 留 4GB 空间 (重要!否则直接 OOM 崩溃)
            disable_log_requests=True
        )
        engine = AsyncLLMEngine.from_engine_args(engine_args)
    
        # 2. 构造饱和攻击流量
        # 为了触发 0.95 下的 Swap,我们需要迅速填满剩下的 5% 显存
        # 发送 50 个并发请求,每个请求 Prompt 长 2000,生成 500
        # 这会产生巨大的 KV Cache 压力
        concurrency = 60 
        prompt = "详细阐述半导体制造工艺中光刻机的原理及其在制程微缩中的作用。" * 15 # ~1500 tokens
        sampling_params = SamplingParams(temperature=0.8, max_tokens=200) # 生成 200 个
    
        print(f"\n🚀 发射 {concurrency} 个长并发请求,目标是打爆显存并触发 Swap-out...")
        print(">>> 请立即观察第二个终端的 'watch -n 0.1 nvidia-smi',关注 PCIe RX/TX 或 GPU Util 波动")
    
        latencies = []
    
        async def run_req(rid):
            start = time.time()
            token_count = 0
            try:
                async for _ in engine.generate(prompt, sampling_params, request_id=rid):
                    token_count += 1
            except Exception as e:
                print(f"Req {rid} error: {e}")
            
            duration = time.time() - start
            if token_count > 0:
                tpot = (duration / token_count) * 1000 # ms per token
                latencies.append(tpot)
    
        # 并发执行
        await asyncio.gather(*[run_req(f"req_{i}") for i in range(concurrency)])
        
        # 3. 分析结果
        if not latencies:
            print("所有请求失败,可能直接 OOM 了。")
            return
    
        avg_tpot = np.mean(latencies)
        p99_tpot = np.percentile(latencies, 99)
        
        print(f"\n📊 压力测试报告:")
        print(f"  Avg TPOT: {avg_tpot:.2f} ms/token")
        print(f"  P99 TPOT: {p99_tpot:.2f} ms/token")
        
        print("\n🔍 专家诊断:")
        print("  正常 Int4 7B 模型并发 TPOT 应该在 20-50ms 左右。")
        print("  如果 P99 TPOT 超过 200ms 甚至 500ms,说明发生了严重的 Swapping。")
        print("  此时 GPU 在等待数据通过 PCIe 从 CPU 内存搬回来,导致计算停滞。")
    
    if __name__ == "__main__":
        asyncio.run(main())
    
    hljs output
  2. 运行文件(如果之前运行过 vLLM 脚本,必须重启 Kernel 或在终端 kill 掉之前的 python 进程,因为 vLLM 占住显存不放)

    hljs bash
    1
    python exp3_swapping_extreme.py
    
  3. 观察结果:在页面中点击示例代码右上角的执行按钮,查看示例结果


3. 实验总结与核心知识点

3.1【核心结论】

一句话结论

3.2【技术解剖:显存三态】

  1. 技术1:xxxx

3.3【关键概念 (Knowledge Points)】

  • 概念1:xxxx

一句话总结