这一周完成了 vllm 迁移到 SGLang 的工作,其实迁移本身非常简单,基本上将之前 vllm 的 serving 指令更换为 SGLang 的 serving 指令即可。不过自己在这个过程学会了很多现代的 serving feature,在知乎简单写写这一过程的体验,完全小白视角,甚至漏洞百出,希望大家能在评论区指正。
Why SGLang
简单来说,SGLang 的吞吐能力比起 vllm 是相当甚至要好一些的(由于我纯小白,所以没法用指标客观描述,见谅),并且 SGLang 支持更多的前端 feature,譬如生成 Structured Generation Language (这也是 SGLang 名字的由来)。由于我最近在写一些 Agents 项目,这个 feature 的潜力相当大。
最简单的迁移
这是我原本的 vllm 指令:
CUDA_VISIBLE_DEVICES=0,1,2,3 vllm serve --model-path 70bins \
--dtype auto --api-key token-abc123 --tensor-parallel-size 4 \
--max-model-len 65536 --enable-chunked-prefill --enforce-eager \
--disable-custom-all-reduce --port 7140 --gpu_memory_utilization 0.95
简单来说,我用 vllm serve 了一个 70b 的 Llama3.1(在我本地起名叫做 70bins),然后部署的端口在 7140,自动选择数据类型,最大输入序列长度是 64k。
这是迁移后的 SGLang 指令:
CUDA_VISIBLE_DEVICES=0,1,2,3 python -m sglang.launch_server \
--model-path 70bins --api-key token-abc123 --context-length 65536 \
--tensor-parallel-size 4 --chunked-prefill-size 8192 \
--enable-p2p-check --host 0.0.0.0 --port 7140 --mem-fraction-static 0.85
基本上参数是接近的,其中有两三个参数不太一样:
enable-p2p-check:测试 GPU 直接通讯是否被允许,如果不开启这个测试,则直接进行 GPU P2P(Peer-to-Peer)通讯。
chunked-prefill-size:在 vllm 中直接通过 enable-chunked-prefill 开启了 chunked prefill(长序列输入分块并预处理,因为我的序列长度是 64K),而 SGLang 中指定了 chunked-prefill-size 为 8k(老实说我觉得 vllm 应该也有类似的参数,但我没有摸索过)
mem-fraction-static:和 vllm 的 gpu_memory_utilization 是类似的,但是一般而言需要开的比 vllm 小一些,并且随着大量的请求,显存内还会占用一部分空间存储 cache,所以建议不要启动 server 后就把 GPU 压满
--host 0.0.0.0:这个参数很有意思,简单来说这是允许 server 接收广播的请求。具体来说,当我在某台服务器上启用了 SGLang 服务后,不添加 host 0.0.0.0 参数,则如下这两个 clients,上面的会 fail,下方的才可以 post 成功;反之,添加了 host 0.0.0.0 后就可以广播了,并且同一个区域网内的其他服务器也能 post 到 server 上
# This would fail
client = openai.OpenAI(
base_url=f"http://{MY_IP_ADDRESS}/:{sserver.port}/v1",
api_key="EMPTY",
)
# This would success
client = openai.OpenAI(
base_url=f"http://127.0.0.1/:{sserver.port}/v1",
api_key="EMPTY",
)
其他参数
SGLang 目前只是初具规模,尚在大规模开发阶段,因此缺乏详尽的文档,我调参完全依靠读取 ServerArgs 类的源代码加上直接在 Slack 群里请教作者团队(Slack 可以直接加入,而且作者基本高强度在线)
https://github.com/sgl-project/sglang/blob/main/python/sglang/srt/server_args.py这里稍微拎出 --enable-torch-compile 参数讲讲,实际上 torch-compile 是 SGLang 相比 vllm 一个很重要的 feature,目前对于单卡 serving 8B Llama3.1 有很好的效果。因此,如果要在单卡 serve 8B model,建议开启此参数(不过 compile 的时间确实较长,而不 compile 的启动速度非常快)。反之,对于 70B model,目前 torch compile 尚且不能很好支持,建议不开启。
显卡真的被高效利用了么?
这是一个非常非常有趣的问题,考虑到我的水平有限,这里仅仅分享我有幸学习到的一些 metric(从一个纯小白视角讲讲)。打开 SGLang 的 serving log(这里我将我的 IP 地址略去):
[gpu=0] Decode batch. #running-req: 30, #token: 197625, token usage: 0.88, gen throughput (token/s): 123.73, #queue-req: 7
[gpu=0] Prefill batch. #new-seq: 1, #new-token: 3667, #cached-token: 1693, cache hit rate: 84.74%, #running-req: 34, #queue-req: 16
INFO: [IP REDACTED]:42368 - "POST /v1/chat/completions HTTP/1.1" 200 OK
INFO: [IP REDACTED]:51620 - "POST /v1/chat/completions HTTP/1.1" 200 OK
[gpu=0] Prefill batch. #new-seq: 2, #new-token: 8192, #cached-token: 3078, cache hit rate: 84.57%, #running-req: 27, #queue-req: 16
INFO: [IP REDACTED]:38756 - "POST /v1/chat/completions HTTP/1.1" 200 OK
INFO: [IP REDACTED]:52310 - "POST /v1/chat/completions HTTP/1.1" 200 OK
INFO: [IP REDACTED]:54354 - "POST /v1/chat/completions HTTP/1.1" 200 OK
[gpu=0] Decode batch. #running-req: 27, #token: 186686, token usage: 0.83, gen throughput (token/s): 117.80, #queue-req: 8
INFO: [IP REDACTED]:59848 - "POST /v1/chat/completions HTTP/1.1" 200 OK
INFO: [IP REDACTED]:36226 - "POST /v1/chat/completions HTTP/1.1" 200 OK
INFO: [IP REDACTED]:46200 - "POST /v1/chat/completions HTTP/1.1" 200 OK
INFO: [IP REDACTED]:34854 - "POST /v1/chat/completions HTTP/1.1" 200 OK
INFO: [IP REDACTED]:35366 - "POST /v1/chat/completions HTTP/1.1" 200 OK
[gpu=0] Prefill batch. #new-seq: 3, #new-token: 8192, #cached-token: 13549, cache hit rate: 86.19%, #running-req: 27, #queue-req: 5
[gpu=0] Prefill batch. #new-seq: 3, #new-token: 6929, #cached-token: 21179, cache hit rate: 84.78%, #running-req: 27, #queue-req: 16
[gpu=0] Prefill batch. #new-seq: 2, #new-token: 8192, #cached-token: 7533, cache hit rate: 84.10%, #running-req: 27, #queue-req: 6
INFO: [IP REDACTED]:34840 - "POST /v1/chat/completions HTTP/1.1" 200 OK
INFO: [IP REDACTED]:60192 - "POST /v1/chat/completions HTTP/1.1" 200 OK
INFO: [IP REDACTED]:51740 - "POST /v1/chat/completions HTTP/1.1" 200 OK
INFO: [IP REDACTED]:33968 - "POST /v1/chat/completions HTTP/1.1" 200 OK
INFO: [IP REDACTED]:52378 - "POST /v1/chat/completions HTTP/1.1" 200 OK
[gpu=0] Prefill batch. #new-seq: 3, #new-token: 1782, #cached-token: 22713, cache hit rate: 85.47%, #running-req: 33, #queue-req: 19
[gpu=0] Decode batch. #running-req: 32, #token: 189677, token usage: 0.84, gen throughput (token/s): 132.71, #queue-req: 7
INFO: [IP REDACTED]:56672 - "POST /v1/chat/completions HTTP/1.1" 200 OK
[gpu=0] Prefill batch. #new-seq: 1, #new-token: 8192, #cached-token: 1754, cache hit rate: 85.63%, #running-req: 29, #queue-req: 6
INFO: [IP REDACTED]:52276 - "POST /v1/chat/completions HTTP/1.1" 200 OK
INFO: [IP REDACTED]:43330 - "POST /v1/chat/completions HTTP/1.1" 200 OK
INFO: [IP REDACTED]:40438 - "POST /v1/chat/completions HTTP/1.1" 200 OK
INFO: [IP REDACTED]:58294 - "POST /v1/chat/completions HTTP/1.1" 200 OK
INFO: [IP REDACTED]:41594 - "POST /v1/chat/completions HTTP/1.1" 200 OK
INFO: [IP REDACTED]:40094 - "POST /v1/chat/completions HTTP/1.1" 200 OK
INFO: [IP REDACTED]:42040 - "POST /v1/chat/completions HTTP/1.1" 200 OK
[gpu=0] Prefill batch. #new-seq: 6, #new-token: 1789, #cached-token: 51498, cache hit rate: 85.52%, #running-req: 29, #queue-req: 11
INFO: [IP REDACTED]:60278 - "POST /v1/chat/completions HTTP/1.1" 200 OK
INFO: [IP REDACTED]:56280 - "POST /v1/chat/completions HTTP/1.1" 200 OK
INFO: [IP REDACTED]:53194 - "POST /v1/chat/completions HTTP/1.1" 200 OK
INFO: [IP REDACTED]:40930 - "POST /v1/chat/completions HTTP/1.1" 200 OK
INFO: [IP REDACTED]:59412 - "POST /v1/chat/completions HTTP/1.1" 200 OK
INFO: [IP REDACTED]:32814 - "POST /v1/chat/completions HTTP/1.1" 200 OK
[gpu=0] Prefill batch. #new-seq: 1, #new-token: 8192, #cached-token: 1680, cache hit rate: 84.74%, #running-req: 29, #queue-req: 15
[gpu=0] Decode batch. #running-req: 28, #token: 167234, token usage: 0.74, gen throughput (token/s): 131.25, #queue-req: 19
[gpu=0] Prefill batch. #new-seq: 5, #new-token: 3765, #cached-token: 32717, cache hit rate: 84.57%, #running-req: 28, #queue-req: 17
INFO: [IP REDACTED]:42864 - "POST /v1/chat/completions HTTP/1.1" 200 OK
INFO: [IP REDACTED]:37262 - "POST /v1/chat/completions HTTP/1.1" 200 OK
[gpu=0] Prefill batch. #new-seq: 3, #new-token: 8192, #cached-token: 8822, cache hit rate: 85.47%, #running-req: 27, #queue-req: 16
这里就是 default log level 下的所有 log info。由于我同时将 8 个卡上的 8 个 8B model 的 serve 的所有 log 输出到了同一个 console 里面,因此显得很庞大
我们注意如下的两行:
[gpu=0] Prefill batch. #new-seq: 1, #new-token: 8192, #cached-token: 1680, cache hit rate: 84.74%, #running-req: 29, #queue-req: 15
[gpu=0] Decode batch. #running-req: 32, #token: 189677, token usage: 0.84, gen throughput (token/s): 132.71, #queue-req: 7
这是(我一个小白能理解的)最重要的两行 log,第一行是 prefill 时的信息,我们主要关注 cache hit rate,可以见到我们的 cache hit rate 是相对高的,这符合预期。因为我的 prompt 中夹着非常长的 system prompt,所以 hit rate 不会低于 80%。接着是 running-req 和 queue-req,代表着当前 batch 正在 prefill 的 req(request) 与当前在队列中等待 prefill 的 req。直观上,running-req 越高越好,而 queue-req 则越低越好。
我个人认为,queue-req 最本质的区别在 0 和非 0。因为前者意味着我们还可以进一步加大请求烈度(而单个请求的 latency 几乎不会受影响),而后者代表着达到了负载上限,再加大烈度会显著加大 latency。
同样的,我们观察 Decode batch 的 log,在 running/queue-req 之外,还有两个参数值得玩味。首先是 token-usage,大致代表着这张显卡的实际利用率。可以看到上方我的 token usage 是 0.84,这是个相当高的 usage 了,以至于出现了 queue。
基于本小白如上的一些描述,当 SGLang 运行过程中相关 metric 较小时,可以放心加大请求量。当然,请注意你的请求本身能否维持稳定。在我自己的使用过程中,随着使用时间增大,prompt 会更加复杂,在 system prompt 之外的内容会越来越多,导致 latency 增大了很多。(其实这事情我自己也觉得有些诧异,随着我的 prompt 越发复杂,cache-hit rate 从 99.91% 跌倒了 84.74%,导致我的 token-usage 从 0.21 增长到了 0.84,而 gen throughput 从接近 1k 到了 100 左右)
One More Thing:究竟快了多少呢?
用我的实际体验来描述下我感受到迭代过程中的加速比。请注意,这里对比的是相比我最初 trivial 的实现取得的加速比,而不是 SGLang 对 vllm 的加速比。(vllm 和 SGLang 的吞吐差别可以在官方 Github 找到,期待此后官方能够出文档进一步参数如何极致利用 SGLang 框架)由于我的 baseline 选择的非常不科学(我本人并不懂如何更科学的 serve vllm),因此加速比非常非常主观。如果你觉得加速比非常高,请不要质疑 vllm 的水平(应该质疑的是我的水平)
阶段零
我在运行的每次程序可以称为单个 game。在最初的版本中,我的每个 game 会利用最基础的 vllm 接口:
from vllm import LLM
llm = LLM("facebook/opt-13b", tensor_parallel_size=4)
在这个版本中,我只有 8 张卡,每张卡上只能 12h 运行完一个 game,因此我的 game / (GPU_num * day) = 2。
阶段二
改用了 vllm 的 OpenAI Compatible Server:
https://docs.vllm.ai/en/latest/serving/openai_compatible_server.html然后,我有幸得到了更多的 GPU,稍微加大了胆量,12h 可以用 20 张卡运行完 60 个 game。这里其实 game 的参数有细微区别,彼此间的计算量差异也蛮大的,但我这里粗略视为相同。此时我的 game / (GPU_num * day) = 6。
阶段三
在请教了 SGLang 作者团队后,我将 vllm 框架替换到了 SGLang 框架,并且我自己写了新的轮子 ModelServer 类。现在我可以利用 8 张卡大致用 48h 的时间运行 400 个任务。此时我的 game / (GPU_num * day) = 12.5。可以看到相比我一周前的 trivial 实现,目前我的实现带来了 10X 倍的效率提升,这让我非常欣喜。
值得强调的是,我完全不理解 vllm 和 SGLang 的高级 feature,我也只是四处生搬硬套想办法提高了我自己任务上的推理效率。我个人的例子完全没法得出 SGLang 推理效率优于 vllm 的结论。如果我所写的主观内容让你感到了 vllm 的效果不如 SGLang,请质疑我的水平,而非 vllm 团队的水平。不过 vllm 也是 SGLang 的一个重要 baseline,在官方的 readme 上有具体对比。出于个人水平,不便多加解释。
自己造的轮子
基于自己对 serving LLM 的理解,搭建了一个很粗糙的 ModelServer 框架。献丑分享,希望大家指正如何更好的利用 SGLang 的 cache / 高并发等特性。
https://github.com/zhaochenyang20/ModelServer