mosec部署chatglm2-6B

公司内部要搭建chatglm2-6b聊天助手,领导希望我们研究一下,如何部署,主要是要在能够稳定服务公司内部的同时,节省GPU。我们内部讨论了一些主要有几个方向:

  1. 多实例部署:这个最简单,但就是费显卡,一个实例一个显卡(当然MIG的话可以一卡多用)
  2. dynamic batching:动态组batch这个肯定好呀,一次推理10个20个问题的,那多好
  3. 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
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,但是最终效果不尽如人意。

  1. 同一batch内问答长短不一,由于压力测试只能计算整体响应时间,也就是长问题回答完成的时间,是整个批次的推理时间。而且,从测试结果看,组batch后,整体问答的推理时间会变长。
  2. 同一batch内问答长短不一,因此batch_size不能设置过大,否则容易爆显存,最终batch_size越调越小,dynamic batching的收益也就更小。这个问题我认为可以通过后端对请求进行长短分类再提交组batch,但是没有去实现验证。
  3. 基于上述的问题,我认为规整的图像推理或者小型的文本推理可能更适合dynamic batching,接下来找项目验证吧。

总的来说,Mosec的框架里面还是不错的。实现了dynamic batching,且非常容易整合部署,点赞。

  • 5
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 7
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

爱在一瞬间

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值