公司内部要搭建chatglm2-6b聊天助手,领导希望我们研究一下,如何部署,主要是要在能够稳定服务公司内部的同时,节省GPU。我们内部讨论了一些主要有几个方向:
- 多实例部署:这个最简单,但就是费显卡,一个实例一个显卡(当然MIG的话可以一卡多用)
- dynamic batching:动态组batch这个肯定好呀,一次推理10个20个问题的,那多好
- fastllm:这个是chatglm-6b模型的C++优化版本,推理效率提升2~3倍至少
前前后后忙活了好一阵,回头看,只如初见。
今天来看看我们找到的Mosec库,在dynamic batching方面的优势,以及踩过的坑。有路过的大神,还请多指导。
文章目录
所谓dynamic batching,我是这样理解的,两个指标:
- batch_size
- timeout
在timeout之前如果达到batch_size,就传递batch_size数据进行推理。
在batch_size之前如果达到timeout,就传递当前batch数进行推理。
本着这个原则,搜索了很长时间,找到了mosec这个库,非常符合我的要求。对模型、对推理完全无侵入,简单方便的实现dynamic batching需求。
1.ChatGLM部署的三个需求,MOSEC都支持
1.dynamic batching
下面是mosec dynamic batching的最简单实现,直接就可以验证是否有效的启用了dynamic batching。就是因为简单,我简单验证了一下,直接成功。
- max_batch_size:动态batch_size最大值
- max_wait_time:dynamic batching等待最大时间
- 启动命令
python filename.py --port=8000 --timeout=100000
:可以指定端口和请求超时时间 - 验证地址http://ip:port/inference:post请求,你的所有参数都被封装到了forward的data里面接收了
from mosec import Server, Worker
class ChatGLMWorker(Worker):
def __init__(self):
super().__init__()
def forward(self, data: str) -> Returns:
# 验证动态batch是否生效,len>1说明生效
print(len(data))
return data
if __name__ == "__main__":
server = Server()
server.append_worker(ChatGLMWorker, max_batch_size=64, max_wait_time=1000)
server.run()
2.多实例
既然这么简单就可以开启dynamic batching,那这推理性能岂不是提升几倍都是没有任何问题的。但是一个推理实例肯定也不够用,支持多实例吗?支持,还是很简单。
- server.append_worker中num参数指定worker数量
- server.append_worker中env参数指定环境变量:显卡、权重等,这样切换显卡就很方便。我的需求就是每个显卡跑一个实例
from mosec import Server, Worker
class ChatGLMWorker(Worker):
def __init__(self):
super().__init__()
self.device = os.environ["CUDA_DEVICES"]
self.checkpoint = os.environ["CHECKPOINT_PATH"]
def forward(self, data: str) -> Returns:
# 验证动态batch是否生效,len>1说明生效
print(len(data))
return data
if __name__ == "__main__":
# gpu nums
NUM_DEVICE = 4
# model weight
checkpoint_path = "chatglm2-6b"
def _get_worker_config(cid: int) -> dict:
return {"CUDA_DEVICES": str(cid), "CHECKPOINT_PATH": checkpoint_path}
server = Server()
server.append_worker(ChatGLMWorker,
num=NUM_DEVICE,
max_batch_size=64,
max_wait_time=1000,
env=[_get_worker_config(x) for x in range(NUM_DEVICE)])
server.run()
3.流式输出
LLM时代,问答时间肯定是不能保证的,3S以下那是肯定不可能的。因此不怎么被提及的流式输出(Server Sent Event实现)变得一下子流行起来了。我需要流式输出,Mosec能做吗?Mosec做的复杂吗?能做,还简单。
- 继承SSEWorker类,该类实现了send_stream_event方法:参数1是文本内容,参数2是batch索引号
- forward方法中获取模型推理结果后,循环调用send_stream_event即可
- forward方法直接返回data,官方示例让返回None,但是实际放回None会抛异常
from mosec import Server, SSEWorker
class ChatGLMWorker(SSEWorker):
def __init__(self):
super().__init__()
self.device = os.environ["CUDA_DEVICES"]
self.checkpoint = os.environ["CHECKPOINT_PATH"]
def forward(self, data: str) -> Returns:
# 验证动态batch是否生效,len>1说明生效
print(len(data))
for i in data:
self.send_stream_event(i, index=i)
return data
if __name__ == "__main__":
# gpu nums
NUM_DEVICE = 4
# model weight
checkpoint_path = "chatglm2-6b"
def _get_worker_config(cid: int) -> dict:
return {"CUDA_DEVICES": str(cid), "CHECKPOINT_PATH": checkpoint_path}
server = Server()
server.append_worker(ChatGLMWorker,
num=NUM_DEVICE,
max_batch_size=64,
max_wait_time=1000,
env=[_get_worker_config(x) for x in range(NUM_DEVICE)])
server.run()
2.Mosec部署ChatGLM的问题
既然我的需要,Mosec都支持,那还等什么,开始撸代码吧。Mosec这么简单,集成起来真的是不费九牛二虎之力。很快我就可以在我的A800上测试了,很快出现了一些意料之外的问题。
1.直接调用chatglm.stream_generate实现批量推理
- chatglm提供的chat接口、stream_chat接口是不支持批量推理的
def chat(self, tokenizer, query: str, history: List[Tuple[str, str]] = None, max_length: int = 2048, num_beams=1,
do_sample=True, top_p=0.7, temperature=0.95, logits_processor=None, **kwargs):
pass
def stream_chat(self, tokenizer, query: str, history: List[Tuple[str, str]] = None, max_length: int = 2048,
do_sample=True, top_p=0.7, temperature=0.95, logits_processor=None, **kwarg):
pass
- chatglm官方提供了一个batch推理的调用方式
def test_batch_generation(self):
model, tokenizer = get_model_and_tokenizer()
sentences = [
"你好",
"介绍一下清华大学"
]
parameters = [(False, 2048, 1),
(False, 64, 1),
(True, 2048, 1),
(True, 64, 1),
(True, 2048, 4)]
expected_out_sentences = [
['你好 你好👋!我是人工智能助手 ChatGLM-6B,很高兴见到你,欢迎问我任何问题。',
'介绍一下清华大学 清华大学是中国著名的综合性大学,位于北京市海淀区双清路30号,其历史可以追溯到1911年创建的清华学堂,1925年更名为清华学校,1937年抗日战争全面爆发后南迁长沙,1946年迁回清华园。新中国成立后,清华学校更名为清华大学。\n\n清华大学是中国最顶尖的大学之一,在工程、科学、技术、经济、管理等领域都有很高的学术声誉和影响力。学校拥有世界一流的教学设施和科学研究平台,有多个学院和研究中心,包括工程学院、自然科学学院、人文学院、社会科学学院、经济管理学院、法学院、美术学院、医学院、器学院等。\n\n清华大学的本科生招生始于2000年,实行全面二孩政策后,本科生招生规模不断扩大。截至2022年,清华大学共有本科生近3万人,研究生近2万人,其中国际学生占比约为10%。清华大学的本科生教育注重通识教育和个性化培养,强调实践、创新、国际化和综合素质。'],
[
'你好 你好👋!我是人工智能助手 ChatGLM-6B,很高兴见到你,欢迎问我任何问题。',
'介绍一下清华大学 清华大学是中国著名的综合性大学,位于北京市海淀区双清路30号,其历史可以追溯到1911年创建的清华学堂,1925年更名为清华学校,1937年抗日战争全面爆发后南迁长沙,1946年迁回'
],
[
'你好 你好👋!我是人工智能助手 ChatGLM-6B,很高兴见到你,欢迎问我任何问题。',
'介绍一下清华大学 清华大学是中国著名的综合性研究型大学,位于北京市海淀区双清路 30 号,其溯源于 1911 年创建的清华学堂, 1925 年更名为清华学校, 1937 年秋抗日战争全面爆发后闭校。1949 年 10 月开学复校,成为我国第一个社会主义大学生活了的高校。截至 2023 年,清华学校共管辖 2 个学院、13 个系,有本科专业 60 个,研究生专业 190 个。'
],
[
'你好 你好👋!我是人工智能助手 ChatGLM-6B,很高兴见到你,欢迎问我任何问题。',
'介绍一下清华大学 清华大学是中国著名的综合性研究型大学,位于北京市海淀区双清路 30 号,其溯源于 1911 年创建的清华学堂, 1925 年更名为清华学校, 1937 年秋抗日战争全面爆发后'
],
[
'你好 你好👋!我是人工智能助手 ChatGLM-6B,很高兴见到你,欢迎问我任何问题。',
'介绍一下清华大学 清华大学是中国著名的综合性研究型大学,位于北京市海淀区双清路30号,其历史可以追溯到1911年创建的清华学堂,1925年更名为清华学校,1937年抗日战争全面爆发后南迁长沙,与北京大学、南开大学组建国立长沙临时大学,1938年迁至 昆明改名为国立西南联合大学,1946年迁回北京。新中国成立后,清华学校更名为清华大学。'
]
]
for (do_sample, max_length, num_beams), expected_output_sentence in zip(parameters, expected_out_sentences):
set_random_seed(42)
inputs = tokenizer(sentences, return_tensors="pt", padding=True)
inputs = inputs.to(torch_device)
outputs = model.generate(
**inputs,
do_sample=do_sample,
max_length=max_length,
num_beams=num_beams
)
batch_out_sentence = tokenizer.batch_decode(outputs, skip_special_tokens=True)
print(batch_out_sentence)
self.assertListEqual(expected_output_sentence, batch_out_sentence)
我一开始是直接把这个实现加到了代码中,但是发现输出不规范,而且输出内容有种飘忽不定的感觉。刚遇到还以为是模型在批量推理时表现出来某种不稳定呢。后来突然想到官方的stream_chat和chat是如何实现的呢。翻看一番,才明白了需要增加一些前置的prompt处理,大概像下面这个样子:
"""
参数结构
{
"history": [["问题","答案"]],
"question": "你是谁"
}
"""
def forward(self, data):
gen_kwargs = {
"max_length": self.max_length, "num_beams": self.num_beams, "do_sample": self.do_sample,
"top_p": self.top_p, "temperature": self.temperature, "logits_processor": self.logits_processor}
inputs_list = []
for item in data:
item_query = item["question"]
item_history = item["history"]
inputs = self.tokenizer.build_prompt(item_query, history=item_history)
inputs_list.append(inputs)
inputs_tensor = self.tokenizer(inputs_list, return_tensors="pt", padding=True)
inputs_tensor = inputs_tensor.to(self.torch_device)
for outputs in self.model.stream_generate(**inputs_tensor, past_key_values=None,
return_past_key_values=False, **gen_kwargs):
outputs_data = self.tokenizer.batch_decode(outputs, skip_special_tokens=True)
for i in range(len(outputs_data)):
answer = outputs_data[i][len(inputs_list[i]):]
self.send_stream_event(answer, index=i)
with torch.cuda.device(self.torch_device):
torch.cuda.empty_cache()
torch.cuda.ipc_collect()
return data
经过上面的一番处理,我的答案就基本稳定了。
2.eos_token_id结束符判定
不看不知道,一看吓一跳。在提交测试部门进行多线程并发压力测试的时候,发现一个莫名其妙的问题。在dynamic batching时,当长短不一致的问题在同一个batch时。短问题后面会附件一些无效信息,跟这个问题完全无关的信息。这下全懵逼了,不管是跟官方的代码对照,还是百度谷歌,有人有类似的问题,就是没有给出有效的答案。
- 其实这是官方代码参照已无意义,因为官方的stream_chat调用stream_generate是batch=1来调用的,因此不会遇到我这样的问题
- 最终我在fastllm的c++实现中受到了启发,解决了这个问题:在批量推理结果返回时,判断eos_token_id,遇到这个标记,该batch_id就不再流式输出内容了。
while (true) {
auto st = std::chrono::system_clock::now();
int ret = Forward(inputIds, attentionMask, positionIds, pastKeyValues, generationConfig, tokens);
tokens.units[0].Push(ret);
if (ret == eos_token_id) {
break;
}
...
}
chatglm2-6b在huggingface的config.j中配置是这样的:
"eos_token_id": 2,
"pad_token_id": 0
因此我的实现大概像下面这个样子:
- 循环判断输出返回的tensor最后是否是eos_token_id,遇到则end_flag=1
- 循环流式输出时,判断end_flag==1时,标记end_flag=2并输出内容
- 循环流式输出时,判断end_flag==2时,不再输出内容
- 循环流式输出时,其他情况正常输出
def forward(self, data):
gen_kwargs = {
"max_length": self.max_length, "num_beams": self.num_beams, "do_sample": self.do_sample,
"top_p": self.top_p, "temperature": self.temperature, "logits_processor": self.logits_processor}
inputs_list = []
for item in data:
item_query = item["question"]
item_history = item["history"]
inputs = self.tokenizer.build_prompt(item_query, history=item_history)
inputs_list.append(inputs)
inputs_tensor = self.tokenizer(inputs_list, return_tensors="pt", padding=True)
inputs_tensor = inputs_tensor.to(self.torch_device)
batch_size = len(data)
end_flag = [0] * batch_size
for outputs in self.model.stream_generate(**inputs_tensor, past_key_values=None,
return_past_key_values=False, **gen_kwargs):
for batch_id in range(batch_size):
if end_flag[batch_id] == 0 and outputs[batch_id][-1] == 2:
end_flag[batch_id] = 1
outputs_data = self.tokenizer.batch_decode(outputs, skip_special_tokens=True)
for i in range(len(outputs_data)):
if end_flag[i] == 2:
continue
if end_flag[i] == 1:
end_flag[i] = 2
answer = outputs_data[i][len(inputs_list[i]):]
self.send_stream_event(answer, index=i)
# clear
logger.info("clear gpu cache")
with torch.cuda.device(self.torch_device):
torch.cuda.empty_cache()
torch.cuda.ipc_collect()
return data
3.Mosec部署chatglm2-6b效果怎么样
其实一开始我对batch_size有很大期待的,我觉得能有5~10的提升,甚至更多,毕竟我有A800,但是最终效果不尽如人意。
- 同一batch内问答长短不一,由于压力测试只能计算整体响应时间,也就是长问题回答完成的时间,是整个批次的推理时间。而且,从测试结果看,组batch后,整体问答的推理时间会变长。
- 同一batch内问答长短不一,因此batch_size不能设置过大,否则容易爆显存,最终batch_size越调越小,dynamic batching的收益也就更小。这个问题我认为可以通过后端对请求进行长短分类再提交组batch,但是没有去实现验证。
- 基于上述的问题,我认为规整的图像推理或者小型的文本推理可能更适合dynamic batching,接下来找项目验证吧。
总的来说,Mosec的框架里面还是不错的。实现了dynamic batching,且非常容易整合部署,点赞。