(自学《Deep-Learning-with-PyTorch》使用,仅供参考)
请求批处理
本书中的异步编程主要做的是允许函数非阻塞地等待计算结果或者事件。
为了进行请求批处理,我们需要将请求处理从运行模型中分离出来(解耦)。
上图显示了数据流,顶部三个是发出请求的客户端(CLIENT),右边三个箭头表示它们一个接一个通过请求处理器(REQUEST PROCESSOR)的上半部分,与工作项(WORK ITEM)一起进入队列,而当一个完整的批处理(WORK BATCH)已经排队或者最老的请求已经等待了指定的最大时间的时候,模型运行器(MODEL RUNNER)从队列中获取一个批处理,处理它,并将结果附加到工作项(WORK ITEM RESULT)上,最后,请求处理器(REQUEST PROCESSOR)的下半部分会处理这些请求。
当我们要运行模型时,先要组装一批的输入,然后再在第二线程中运行模型并返回结果,接着,请求处理器(REQUEST PROCESSOR)解码请求、入列输入、等待处理完成并返回输出结果。
这个与用桶接水灌满鱼缸一个原理,打开水龙头用桶接水,当桶的里水满了或者时间差不多的时候,就将桶里得水倒进鱼缸中。我们将新请求放到队列中,在需要的时候触发处理,并等待结果,然后将它们作为请求的答案发送出去。
异步服务器由三个块组成:请求处理器(REQUEST PROCESSOR)、模型运行器(MODEL RUNNER)和模型执行(MODEL EXECUTION)。
这些块有点像函数,但前两个块归顺于事件循环。
如前面所说,我们处理事件有两种时机:①当我们积累了完整的批处理的时候②当最老请求达到最长等待时间的时候。对于②,我们可以通过设置计数器来解决。
我们使用ModelRunner加载我们的模型,并处理一些管理工作。
class ModelRunner:
def __init__(self, model_name):
self.model_name = model_name
# 首先我们要将我们的请求输入到队列中,在Python中可以用列表来实现。
# 队列的原则是先进后出,所以我们可以在后面添加工作项,在前面删除它们。
self.queue = [] # 队列
# 当我们修改队列时,我们希望防止下面的其他任务改变队列。为此,引入queue_lock,这个队列锁将成为asynico模块提供的asyncio.Lock
# 书中用的asyncio对象都需要知道事件循环,该循环只有在初始化应用程序后才能使用,因此我们在实例化中先临时设为None。
# 如果我们有多个worker,我们需要查看锁定
# 注意:Python的异步锁不是线程安全的
self.queue_lock = None
# 加载并实例化模型
self.model = get_pretrained_model(self.model_name, map_location=device)
# 这是我们运行模型的信号,ModelRunner在无事要做的时候会等待,我们需要从Request-Processor传递信号告诉它继续工作,不要偷懒。
self.needs_processing = None
# 使用一个计时器保证最大的等待时间,计时器会设置needs_processing事件,我们现在只订一个位子。
# 我们会在一个批处理完成或者计时器达到最大计数值的时候直接设置一个事件。
# 当我们在计时器达到最大计数值前处理一个批次,我们要清除它,从而避免太多工作。
self.needs_processing_timer = None
接下来,我们需要对请求进行排队。我们在第一个异步方法(process_input)中实现了这一点。
def schedule_processing_if_needed(self):
if len(self.queue) >= MAX_BATCH_SIZE:
logger.debug("next batch ready when processing a batch")
self.needs_processing.set()
elif self.queue:
logger.debug("queue nonempty when processing a batch, setting next timer")
self.needs_processing_timer = app.loop.call_at(self.queue[0]["time"] + MAX_WAIT, self.needs_processing.set)
async def process_input(self, input): # 异步方法实现排列请求
# 设置一个字典保存任务信息
our_task = {"done_event": asyncio.Event(loop=app.loop),#关键字done_event保存处理任务完成标志
"input": input, # 关键字input保存进程输入
"time": app.loop.time()}# 关键字time保存排队时间
# 处理进程添加了一个输出,我们持有队列锁,并且我们把任务添加到队列中,并在需要时调度处理
# 重要的是使用循环时间,这个跟time.time()不同,否则,我们可能会在排队之前就得到计划处理的事件,或者根本不处理它们。
# 队列锁
async with self.queue_lock:
if len(self.queue) >= MAX_QUEUE_SIZE:
raise HandlingError("I'm too busy", code=503)
# 把任务添加到队列中
self.queue.append(our_task)
logger.debug("enqueued task. new queue size {}".format(len(self.queue)))
# 在需要时调度处理:如果我们有一个完整的批处理,Processing将设置needs_processing
self.schedule_processing_if_needed()
# 等待任务被处理,然后返回它。
await our_task["done_event"].wait()
return our_task["output"]
如图2所示,model_runner进行一些设置,然后进行无限循环。
def run_model(self, batch): # runs in other thread
return self.model(batch.to(device)).to('cpu')
# 在实例化应用程序时调用
async def model_runner(self):
# 设置queue_lock队列锁
self.queue_lock = asyncio.Lock(loop=app.loop)
# 设置needs_processing事件
self.needs_processing = asyncio.Event(loop=app.loop)
logger.info("started model runner for {}".format(self.model_name))
# 进入循环
while True:
# 等待needs_processing时间
await self.needs_processing.wait()
self.needs_processing.clear()
# 当一个事件出现时,我们先检查是否设置了时间,如果设置了(is not None),就清除它,因为我们现在正在处理事情
if self.needs_processing_timer is not None:
self.needs_processing_timer.cancel()
self.needs_processing_timer = None
async with self.queue_lock:
if self.queue:
longest_wait = app.loop.time() - self.queue[0]["time"]
else: # oops
longest_wait = None
logger.debug("launching processing. queue size: {}. longest wait: {}".format(len(self.queue), longest_wait))
# 从model_runner的队列中抓取一个队列,如果需要,安排在下一个批次进行处理
to_process = self.queue[:MAX_BATCH_SIZE]
del self.queue[:len(to_process)]
self.schedule_processing_if_needed()
# so here we copy, it would be neater to avoid this
batch = torch.stack([t["input"] for t in to_process], dim=0)
# we could delete inputs here...
# 从单个任务中组装批处理,并使用asyncio的app.loop.run_in_executor启动一个评估模型的新线程。
# 在一个单独的线程中运行模型,将数据移动到设备,然后将数据移交给模型。我们在它完成后继续处理。
result = await app.loop.run_in_executor(
None, functools.partial(self.run_model, batch)
)
# 最后,将输出(“output”)添加到任务并设置(“done_event")
for t, r in zip(to_process, result):
t["output"] = r
t["done_event"].set()
del to_process
web框架——大致看起来像带有async和await的Flask——需要一个小包装器。
我们需要在事件循环上启动model_runner函数。
(如果我们没有多个运行器从队列中获取信息并可能相互中断,那么锁定队列实际上是没有必要的,但是对于其他项目来说,如果不这样做,就会有丢失请求的安全隐患)
【注意】:
虽然服务器实现了对GPU的请求进行批量处理并且异步运行的功能,但我们仍然使用的是Python模式,所以GIL阻碍了我们的模型在主线程中并行运行请求服务。
这存在不安全的潜在敌对环境,如互联网。
尤其,解码请求数据的功能既不是速度最佳的,也不是完全安全的。
一般来说,如果我们能够进行解码,将请求流和预先分配的内存块传递给函数,并由该函数从流中解码图像,那就更好了。
但我们不知道有哪个库是这样做的。