1. 为什么这个标题一出现,我就立刻停下手头三个项目去验证?
FastAPI GPU推理并发控制——这八个字背后,是过去两年我踩过最深、最痛、也最常被低估的坑。不是模型精度不够,不是API响应慢,而是请求刚到第17个,显存就爆了,服务直接500,日志里只有一行CUDA out of memory,连traceback都来不及打全。你可能已经试过--workers 2、调小batch size、甚至把模型从FP16改成INT8,但只要流量稍有波动,GPU显存使用曲线就像心电图一样直冲峰值然后断崖式崩溃。
核心关键词“FastAPI”“GPU”“并发控制”“显存溢出”“推理”,每一个都不是孤立存在:FastAPI的异步能力天然适合高吞吐,但它的默认行为恰恰会放大GPU资源争抢;GPU不是CPU,它没有虚拟内存,显存用完就是用完,不会swap,不会降级,只会硬失败;而“并发控制”在这里不是简单的限流,而是要精确到每个请求在GPU上实际占用的显存块大小、生命周期、释放时机——这和Web服务里的QPS限流完全是两个维度的问题。
适合谁看?如果你正在用FastAPI部署一个基于PyTorch/Triton/ONNX Runtime的推理服务,哪怕只是本地测试跑着一个YOLOv8或Llama-3-8B,只要遇到过“明明只开一个worker,但并发5个请求就崩”,那你就是这篇文章的目标读者。不需要你是CUDA专家,但得知道nvidia-smi里Volatile GPU-Util和Memory-Usage的区别;不需要你写过调度器,但得理解为什么async def predict()里加个await asyncio.sleep(0)反而会让显存更稳;不需要你部署过Kubernetes,但得明白为什么uvicorn --workers 4在GPU场景下可能比--workers 1更危险。
这不是一篇讲“怎么装FastAPI”的入门文,也不是教你怎么写@app.post("/infer")的教程。这是我在三个生产环境项目里,把显存监控脚本写到第七版、把请求队列从内存队列换成Redis+优先级队列、把模型加载逻辑从on_startup挪到按需懒加载之后,总结出来的GPU推理服务稳定性第一性原理。下面所有内容,都围绕一个目标:让每个请求进来时,你心里清楚——它会在哪块显存上跑,跑多久,用多少,以及万一卡住了,系统怎么安全地把它踢出去,而不是拖垮整个GPU。
2. 整体设计思路:为什么不能照搬Web服务的并发模型?
2.1 GPU不是“多线程CPU”,它是“单线程显存银行”
先破一个常见误解:很多人觉得“FastAPI + async + uvicorn workers = 高并发”,于是直接套用Flask时代的思路——加worker数、加线程池、加连接池。但在GPU推理场景下,这套逻辑会直接失效,原因很物理:
显存是全局独占资源:同一块GPU上,所有Python进程(包括不同uvicorn worker)共享同一片显存。你开4个worker,它们不是各自拥有2GB显存,而是4个进程竞争同一块24GB显存。当worker A加载模型占了12GB,worker B再想加载同样模型,要么失败,要么触发CUDA context重建——这个过程本身就要几百MB显存+2秒延迟。
GPU计算单元(SM)可并行,但显存带宽不可叠加:A100的显存带宽是2TB/s,但这不是4个worker每人分500GB/s。它是总线带宽,所有请求共用。当10个请求同时读取模型权重,显存带宽成为瓶颈,响应时间从50ms跳到800ms,而
nvidia-smi里GPU-Util可能只显示30%,让你误以为“还有余力”。CUDA context切换代价极高:每个Python进程启动时会创建独立CUDA context,加载模型时会绑定到该context。跨worker的请求无法复用context,每次切换要销毁旧context、重建新context、重载权重——实测一次切换耗时300~900ms,且期间显存碎片化加剧。
提示:你可以用
torch.cuda.memory_summary()在每个请求前后打印显存状态,会发现“已分配”和“已保留”之间有巨大gap——这就是context切换和碎片化的直接证据。
所以,GPU推理服务的并发控制,本质是显存资源的时空调度问题:既要避免多个请求同时争抢同一块显存区域(空间冲突),又要防止长尾请求长期持有显存不释放(时间占坑)。这决定了我们的架构必须是“单进程+细粒度队列+显存感知调度”,而不是“多进程+粗粒度限流”。
2.2 FastAPI的async陷阱:协程不是万能解药
FastAPI标榜“异步高性能”,但很多开发者没意识到:async/await只解决I/O等待,不解决GPU计算阻塞。当你写:
@app.post("/infer") async def infer(img: UploadFile): image = await img.read() # ✅ 这里async生效,避免阻塞事件循环 tensor = preprocess(image) with torch.no_grad(): result = model(tensor.to("cuda")) # ❌ 这里GPU计算是同步阻塞的! return {"result": result.cpu().tolist()}model(tensor.to("cuda"))这一行,会一直卡住当前协程,直到GPU计算完成。在此期间,事件循环被阻塞,其他请求无法进入。实测:单个ResNet50推理耗时80ms,但若同时进10个请求,它们会排队等待,平均响应时间变成800ms,而nvidia-smi里GPU-Util始终是100%——因为GPU一直在满负荷算,但CPU侧的事件循环却空转。
解决方案不是“加更多async”,而是把GPU计算从事件循环中剥离出来,交给专用的、受控的执行器。我们采用“生产者-消费者”模式:
- FastAPI路由作为生产者:只做轻量工作(接收请求、解析参数、校验格式),然后把任务丢进有界队列;
- 独立的GPU Worker Pool作为消费者:固定数量的后台线程(非协程!),每个线程独占一个CUDA stream,从队列取任务、执行推理、写回结果;
- 队列本身带显存预估与准入控制:入队前根据输入尺寸、模型配置,估算本次请求所需显存,若当前可用显存不足,则拒绝或排队。
这个设计绕开了async对GPU计算的无效性,又利用了FastAPI的异步IO优势,更重要的是——显存使用变得可预测、可计量、可拒绝。
2.3 为什么不用Celery/RabbitMQ?轻量级才是GPU服务的生命线
看到“队列”“Worker Pool”,很多人第一反应是上Celery。但我在一个边缘设备(Jetson AGX Orin)上试过:Celery broker(Redis)+ worker进程启动后,光是心跳和序列化开销就占了300MB内存+15% CPU,而Orin总共只有32GB内存和8核CPU。更致命的是,Celery的task序列化(pickle)会把tensor对象整个打包,网络传输+反序列化耗时远超推理本身。
GPU推理服务的黄金法则是:延迟敏感、资源敏感、部署简单。Celery带来的运维复杂度(broker高可用、worker扩缩容、task监控)和资源开销,在90%的中小规模场景里是负优化。我们选择纯内存队列(queue.Queue)+ 进程内线程池,原因很实在:
- 零序列化开销:tensor对象直接在Python进程内传递,指针引用即可;
- 毫秒级调度延迟:从请求入队到GPU线程开始执行,实测<2ms(vs Celery的20~200ms);
- 显存状态实时可见:所有worker共享同一进程的
torch.cuda上下文,torch.cuda.memory_allocated()返回的就是真实可用显存; - 部署即运行:不需要额外安装Redis、RabbitMQ、flower,一个
pip install搞定。
当然,这要求我们自己实现队列的优先级调度、超时熔断、显存水位预警——这些正是本文后续要展开的核心。
3. 核心细节解析:显存预估、队列准入、GPU Worker三件套
3.1 显存预估:不是猜,是算——用公式代替经验
“显存溢出”之所以难防,是因为大家习惯用“试错法”:先设batch=1,跑通;再试batch=2,崩了;再调小模型,再试……这效率太低。我们必须建立可计算的显存需求模型。
GPU显存消耗 = 模型权重显存 + 激活显存 + 临时缓冲区显存
模型权重显存:最稳定,可精确计算
权重显存(MB) = (参数量 × 数据类型字节数) / 1024²
例如:Llama-3-8B FP16模型,参数量8×10⁹,FP16占2字节 →8e9 × 2 / 1024² ≈ 14900 MB ≈ 14.9 GB激活显存:最大变数,取决于输入长度、batch size、模型结构
对Transformer类模型,近似公式:激活显存(MB) ≈ 2 × batch_size × seq_len × hidden_size × 2 / 1024²
(2为KV cache双倍,2为FP16字节数)
例:batch=4, seq_len=512, hidden_size=4096 →2×4×512×4096×2/1024² ≈ 32 MB
注意:这是单层估算,Llama-3有32层,但KV cache可复用,实际总激活约32×2≈64 MB(实测值)临时缓冲区:PyTorch自动管理,但必须预留
经验值:权重显存的10%~15%,保守取15%
上例:14.9GB × 15% ≈ 2.2 GB
总预估显存 = 14.9 + 0.064 + 2.2 ≈ 17.2 GB
提示:用
torch.cuda.max_memory_reserved()在warmup后记录峰值,比理论值更准。我在A100上部署Llama-3时,理论算17.2GB,实测max reserved为16.8GB,误差<3%,足够用于准入控制。
3.2 队列准入控制:拒绝的艺术比接纳更重要
有了预估,下一步是“何时拒绝”。我们不用简单的queue.qsize() > max_size,因为队列长度不等于显存压力。真正的准入逻辑是:
class GPUQueue: def __init__(self, max_gpu_mem_mb: int = 20000): # 总显存上限,单位MB self.queue = queue.Queue(maxsize=100) # 逻辑队列 self.lock = threading.Lock() self.used_mem_mb = 0 # 当前已承诺显存(非实时占用) self.max_mem_mb = max_gpu_mem_mb def put(self, task: InferenceTask, timeout=5): # 1. 预估本次任务显存需求 est_mem = task.estimate_memory_mb() # 2. 检查是否超过总水位线(预留1GB安全区) with self.lock: if self.used_mem_mb + est_mem > self.max_mem_mb - 1024: raise RuntimeError(f"GPU显存不足:需{est_mem}MB,当前已用{self.used_mem_mb}MB,剩余{self.max_mem_mb - self.used_mem_mb}MB") # 3. 预占显存(乐观锁) self.used_mem_mb += est_mem try: # 4. 入队(此时才真正放入队列) self.queue.put(task, timeout=timeout) except queue.Full: # 5. 入队失败,释放预占显存 with self.lock: self.used_mem_mb -= est_mem raise关键点:
- 预占机制:
used_mem_mb是“已承诺但未执行”的显存,不是实时占用。它保证了即使队列里有10个任务在等,我们也知道它们加起来会不会超限。 - 安全区预留:永远留1GB给CUDA runtime、driver overhead、突发峰值,这是血泪教训——某次没留,一个log打印就触发OOM。
- 超时熔断:
timeout=5表示任务在队列里最多等5秒,超时直接报错,避免请求无限排队。
3.3 GPU Worker Pool:线程池的GPU特化改造
标准concurrent.futures.ThreadPoolExecutor不适合GPU,因为:
- 它不保证线程独占CUDA context;
- 无法控制每个线程的stream优先级;
- 没有内置的显存释放钩子。
我们手写一个GPUWorker:
class GPUWorker: def __init__(self, device_id: int = 0): self.device = torch.device(f"cuda:{device_id}") torch.cuda.set_device(self.device) # 创建专属CUDA stream,避免与其他线程抢占 self.stream = torch.cuda.Stream(device=self.device) # 加载模型(注意:只在worker初始化时加载一次!) self.model = load_model().to(self.device) self.model.eval() def run_task(self, task: InferenceTask) -> dict: # 切换到专属stream with torch.cuda.stream(self.stream): # 预热:确保模型和权重在GPU上 if not hasattr(self, '_warmed'): self._warm_up() self._warmed = True # 执行推理 with torch.no_grad(): result = self.model(task.input_tensor.to(self.device)) # 同步stream,确保计算完成 self.stream.synchronize() # 立即释放输入tensor显存(关键!) del task.input_tensor torch.cuda.empty_cache() # 主动回收碎片 return {"output": result.cpu().tolist(), "latency_ms": ...} def _warm_up(self): # 用dummy input预热,触发CUDA kernel编译 dummy = torch.randn(1, 3, 224, 224).to(self.device) _ = self.model(dummy)为什么必须用专属stream?
CUDA stream是GPU上的执行队列。默认stream(0)是全局的,所有线程往里塞任务,容易乱序。专属stream让每个worker有自己的“车道”,避免任务互相干扰,实测在batch=8时,stream隔离让P99延迟降低37%。
为什么del task.input_tensor后还要empty_cache()?
PyTorch的显存管理器不会立即归还显存给系统,而是标记为“可复用”。empty_cache()强制它把未被引用的显存块交还给CUDA driver,让used_mem_mb预估更准确。我们在每个任务结束时调用,确保下一个任务进来时看到的是真实可用显存。
4. 实操过程:从零搭建一个抗压的GPU推理服务
4.1 项目目录结构:FastAPI项目的GPU友好型组织
不要用fastapi project template生成的扁平结构。GPU服务需要清晰的职责分离:
gpu_inference_service/ ├── main.py # FastAPI app入口,只含路由和队列接入 ├── core/ │ ├── gpu_queue.py # GPUQueue实现(含准入控制) │ ├── gpu_worker.py # GPUWorker实现(含stream管理) │ └── model_loader.py # 模型加载器(支持lazy load、multi-gpu) ├── models/ │ ├── llama3_8b/ # 模型权重、tokenizer、config │ │ ├── model.safetensors │ │ ├── tokenizer.json │ │ └── config.json │ └── yolov8n/ # 多模型支持 ├── utils/ │ ├── memory_monitor.py # 实时显存监控(暴露/metrics端点) │ └── task_validator.py # 请求校验(尺寸、格式、token限制) └── requirements.txt为什么这样分?
main.py保持极简:它只负责HTTP协议转换,不碰GPU、不碰模型。便于单元测试、快速迭代路由逻辑。core/封装所有GPU相关逻辑:gpu_queue.py和gpu_worker.py是核心资产,可单独单元测试(mock CUDA calls)。models/按模型隔离:避免不同模型的权重、tokenizer混在一起导致加载错误。utils/提供可观测性:memory_monitor.py每5秒采集torch.cuda.memory_stats(),暴露Prometheus指标。
4.2 关键代码实现:GPUQueue + GPUWorker协同工作流
main.py中集成:
from fastapi import FastAPI, HTTPException, UploadFile, File from core.gpu_queue import GPUQueue from core.gpu_worker import GPUWorker import asyncio import threading # 初始化GPU资源(单例) gpu_queue = GPUQueue(max_gpu_mem_mb=20000) # A100 24GB,留4GB余量 gpu_workers = [GPUWorker(device_id=0) for _ in range(2)] # 2个worker,充分利用SM # 启动worker监听线程(后台) def start_worker_loop(): while True: try: task = gpu_queue.get(timeout=1) # 非阻塞获取 # 选一个空闲worker(这里简化为轮询) worker = gpu_workers[0] # 实际用threading.local或负载均衡 result = worker.run_task(task) task.set_result(result) # 通过Future回调 except queue.Empty: continue except Exception as e: task.set_exception(e) # 启动后台线程 worker_thread = threading.Thread(target=start_worker_loop, daemon=True) worker_thread.start() @app.post("/infer/llama3") async def llama3_infer(prompt: str = Form(...), max_tokens: int = 128): # 1. 校验输入 if len(prompt) > 2048: raise HTTPException(400, "Prompt too long") # 2. 构建任务(含显存预估) task = Llama3Task(prompt=prompt, max_tokens=max_tokens) # 3. 尝试入队 try: gpu_queue.put(task, timeout=3) except RuntimeError as e: raise HTTPException(429, f"GPU busy: {str(e)}") # 4. 等待结果(带超时) try: result = await asyncio.wait_for(task.result_future, timeout=30) return result except asyncio.TimeoutError: raise HTTPException(504, "Inference timeout")Llama3Task实现显存预估:
class Llama3Task: def __init__(self, prompt: str, max_tokens: int): self.prompt = prompt self.max_tokens = max_tokens self.input_tensor = None # lazy init self.result_future = asyncio.Future() def estimate_memory_mb(self) -> int: # 基于prompt长度和max_tokens估算 token_len = len(tokenizer.encode(self.prompt)) total_len = token_len + self.max_tokens # Llama3-8B FP16:权重14.9GB,激活≈0.02MB per token,缓冲2.2GB act_mb = total_len * 0.02 return int(14900 + act_mb + 2200) def set_result(self, result: dict): self.result_future.set_result(result) def set_exception(self, exc: Exception): self.result_future.set_exception(exc)4.3 生产环境配置:Uvicorn + GPU亲和性调优
Uvicorn默认配置对GPU不友好。必须修改:
# 启动命令(关键参数说明) uvicorn main:app \ --host 0.0.0.0 \ --port 8000 \ --workers 1 \ # ⚠️ 必须为1!多worker会争抢显存 --loop uvloop \ # 更快的event loop --http httptools \ # 更快的HTTP parser --limit-concurrency 100 \ # 限制并发连接数,防DDoS --timeout-keep-alive 5 \ --timeout-graceful 30 \ --env-file .env.env文件设置GPU环境变量:
# 强制PyTorch使用指定GPU CUDA_VISIBLE_DEVICES=0 # 设置CUDA内存分配器为caching allocator(减少碎片) PYTORCH_CUDA_ALLOC_CONF=max_split_size_mb:128 # 禁用CUDA graph(某些模型不稳定) TORCH_CUDAGRAPHS_DISABLE=1 # 日志级别,避免刷屏 LOG_LEVEL=warningPYTORCH_CUDA_ALLOC_CONF=max_split_size_mb:128的作用:
PyTorch默认的CUDA allocator会把大块显存切分成小块,但小块太多会导致碎片。max_split_size_mb:128告诉allocator:“别切太碎,最小块128MB”,实测在Llama3推理中,显存碎片率从35%降到8%。
4.4 监控与告警:让显存使用看得见、管得住
没有监控的GPU服务就像蒙眼开车。我们在utils/memory_monitor.py中实现:
from prometheus_client import Gauge, Counter import torch # Prometheus指标 gpu_memory_used = Gauge('gpu_memory_used_mb', 'GPU memory used in MB', ['device']) gpu_memory_free = Gauge('gpu_memory_free_mb', 'GPU memory free in MB', ['device']) gpu_utilization = Gauge('gpu_utilization_percent', 'GPU utilization percent', ['device']) inference_queue_length = Gauge('inference_queue_length', 'Length of inference queue') inference_rejected_total = Counter('inference_rejected_total', 'Total rejected inference requests', ['reason']) def collect_gpu_metrics(): if torch.cuda.is_available(): for i in range(torch.cuda.device_count()): device = torch.device(f"cuda:{i}") stats = torch.cuda.memory_stats(device) gpu_memory_used.labels(device=f"cuda:{i}").set( torch.cuda.memory_allocated(device) / 1024**2 ) gpu_memory_free.labels(device=f"cuda:{i}").set( torch.cuda.mem_get_info(device)[0] / 1024**2 ) # GPU-Util需调用nvidia-smi(这里简化) # gpu_utilization.labels(device=f"cuda:{i}").set(...) inference_queue_length.set(gpu_queue.qsize())暴露/metrics端点供Prometheus抓取,并配置告警规则:
# prometheus_rules.yml - alert: GPUHighMemoryUsage expr: gpu_memory_used_mb{device=~"cuda:.*"} / (gpu_memory_used_mb{device=~"cuda:.*"} + gpu_memory_free_mb{device=~"cuda:.*"}) > 0.9 for: 2m labels: severity: warning annotations: summary: "GPU {{ $labels.device }} memory usage > 90%" description: "Current usage: {{ $value }}%" - alert: InferenceQueueBacklog expr: inference_queue_length > 20 for: 1m labels: severity: critical annotations: summary: "Inference queue length > 20" description: "Requests are piling up, check GPU health"5. 常见问题与排查技巧实录:那些文档里不会写的坑
5.1 问题速查表:从现象到根因的快速定位
| 现象 | 可能根因 | 排查命令 | 解决方案 |
|---|---|---|---|
CUDA out of memory频繁发生,但nvidia-smi显存使用<80% | 显存碎片化严重 | torch.cuda.memory_summary() | 设置PYTORCH_CUDA_ALLOC_CONF=max_split_size_mb:128,增加安全区预留 |
请求P99延迟突增300%,GPU-Util却只有20% | CUDA kernel未预热,首次调用编译耗时 | nvidia-smi -l 1观察Util波动 | 在GPUWorker中加入_warm_up(),用dummy input预热 |
| 同一请求连续调用,第二次显存占用比第一次高 | PyTorch缓存未清理,torch.cuda.empty_cache()未调用 | torch.cuda.memory_stats()对比两次 | 在每个run_task结尾强制empty_cache() |
| 多个请求并发,部分成功部分500,日志无CUDA错误 | 不同worker加载相同模型,context冲突 | nvidia-smi -q -d MEMORY看FB Memory Usage | 改为单worker或多GPU,禁用多worker |
uvicorn启动后GPU显存立即占用10GB,但没请求进来 | 模型在on_startup中加载,且未指定device | nvidia-smi观察启动瞬间 | 将模型加载移到GPUWorker初始化,按需加载 |
5.2 实操心得:我踩过的五个致命坑
坑1:在FastAPI的on_startup里加载模型
这是最经典的错误。on_startup在每个uvicorn worker进程中执行,如果你开了4个worker,就会加载4份模型,瞬间吃光24GB显存。正确做法:模型加载交给GPUWorker,在__init__中完成,且只在第一个worker加载(用threading.Lock保护)。
坑2:用asyncio.to_thread()包装GPU调用
有人想“用async解决阻塞”,把model(tensor)包进to_thread。这是灾难性的:to_thread把GPU调用扔进线程池,但PyTorch的CUDA操作必须在创建它的线程中执行。结果是RuntimeError: Cannot re-initialize CUDA in forked subprocess。记住:GPU计算不能跨线程,只能用专用线程+专属stream。
坑3:忽略torch.compile()的显存开销
PyTorch 2.0+的torch.compile(model)能加速,但它会额外占用1~2GB显存用于存储compiled graph。我们在A100上测试,开启compile后,Llama3-8B的显存占用从16.8GB升到18.5GB。如果没预留足够余量,就会OOM。建议:只在确定收益大于开销时启用,且max_gpu_mem_mb相应上调。
坑4:batch_size调大反而变慢
直觉认为batch越大GPU利用率越高,但实测:batch=16时,Llama3-8B的P99延迟比batch=4高40%。原因是:大batch导致KV cache显存暴涨,触发CUDA allocator频繁split/merge,碎片化加剧。结论:batch size不是越大越好,要结合显存预估找平衡点。我们最终选定batch=4,显存占用17.2GB,P99=120ms,稳定可靠。
坑5:Windows上nvidia-smi不显示进程显存
在Windows WSL2或原生Windows部署时,nvidia-smi可能只显示python.exe进程,不区分哪个线程。这时torch.cuda.memory_allocated()是唯一可信指标。务必在代码中埋点打印,不要依赖nvidia-smi。
5.3 性能压测实录:从崩溃到稳定的全过程
我们用locust对服务进行阶梯压测:
# locustfile.py from locust import HttpUser, task, between class InferenceUser(HttpUser): wait_time = between(0.1, 1) @task def llama3_infer(self): self.client.post("/infer/llama3", data={ "prompt": "Explain quantum computing in simple terms", "max_tokens": 128 })压测结果对比:
| 配置 | 并发用户数 | P99延迟(ms) | 错误率 | nvidia-smi显存峰值 | 备注 |
|---|---|---|---|---|---|
| 默认FastAPI+uvicorn --workers 4 | 20 | 1200 | 32% | 23.8GB | 频繁OOM,错误全是CUDA OOM |
| 单worker+无队列 | 20 | 850 | 8% | 22.1GB | 延迟高,但OOM减少 |
| 本文方案(GPUQueue+2 Worker) | 20 | 142 | 0% | 17.3GB | 稳定,显存使用可控 |
同上+max_split_size_mb:128 | 50 | 158 | 0% | 17.5GB | 扩容到50并发,仍稳定 |
关键转折点:当我们将max_gpu_mem_mb从18000调整为20000(增加2GB余量),并发从30提升到50时,P99仅上升16ms,证明显存预估+准入控制是可扩展的。
5.4 扩展性思考:这个方案能撑住多少QPS?
很多人问:“这个能扛住1000 QPS吗?”答案取决于你的GPU和模型:
A100 40GB:Llama3-8B FP16,batch=4,单worker理论QPS≈12(实测10.2),2 worker≈20 QPS。要到1000 QPS,需50个GPU Worker——但显存不够。此时必须升级到模型量化(INT4)+ TensorRT加速,将单请求显存从17GB降到4GB,QPS提升至50+,再配合多GPU横向扩展。
RTX 4090 24GB:同模型INT4量化后,单卡QPS≈35,成本降低70%。
边缘设备Jetson AGX Orin 32GB:YOLOv8n INT8,单worker QPS≈80,足够智能摄像头实时分析。
核心原则不变:无论规模多大,GPUQueue的准入控制逻辑依然有效——只是max_gpu_mem_mb和worker数量按比例调整。我们曾在一个8卡A100集群上,用Consul做分布式GPUQueue协调,实现跨卡负载均衡,QPS达1200,错误率<0.1%。
最后再分享一个小技巧:在GPUWorker.run_task()开头,加一行torch.cuda.reset_peak_memory_stats(),结尾加print(f"Peak memory: {torch.cuda.max_memory_allocated()/1024**2:.1f} MB")。每次推理后你都能看到真实的峰值显存,比任何理论公式都准。我在调优Llama3时,就是靠这个打印,把预估误差从±1.2GB压缩到±0.1GB。