dev.to #ai短讯
保留队列图:缓冲区满载才是瓶颈,而非模型本身
文章指出在流式处理中,性能瓶颈往往在于数据缓冲区的堆积(Queue Depth),而非模型推理能力。作者通过观察进程内部字节滞留情况,将解析器比作排水口,强调等待数据闭合导致的延迟问题,主张关注首字生成时间之外的实际吞吐效率。
慢的往往是你的缓冲区,而不是模型。我保留队列图,并丢弃平均值。一个相当普通的平均值是否仍然掩盖了一个卡住的读取器?
首字延迟是另一个问题,所以我把它放在一边。这篇笔记关注的是字节在你自己的进程内部滞留的情况。既然块已经到达,为什么还要持有整个主体?
想象一个水龙头还在流水的水槽。排水口是你的解析器,它在等待闭合。上涨的水位是队列深度,而不是模型能力。
我不需要新的头条新闻来看到这一点。新闻周期变化很快,而卡住的缓冲区不会。你曾经因为自己未读的套接字而责怪过模型吗?
免费的端点让这种错误更容易重复。你可以整个下午都在调用它,但仍然一无所获。除了一个经过的时间数字,你还记录了什么?
我想要一次请求上的四个标记,而不是排行榜。接受开始于套接字准备好接收主体时。读取结束于下一个块落入用户空间时。
解析是完整事件变成结构体的时刻。交接是你的代理能够基于该结构体采取行动的时刻。如果解析等待最后一个字节,你的队列就会臃肿不堪。
那个最后的形状是我一直画出的 bug。模型在远端可能已经空闲。你的进程仍然像奖杯一样紧握着一个字符串。
这是我真正想要留在磁盘上的测试工具。它是一种方法,而不是来自秘密实验室的战利品。运行它,不要借用我的空白模板。
import json, time, csv
from collections import deque
# Unexecuted example. No network. No secrets. No claimed timings.
def sample_loop(chunks, gap_s=0.0):
q = deque()
rows = []
t0 = time.perf_counter()
held = 0
for i, chunk in enumerate(chunks):
t_read = time.perf_counter()
q.append(chunk)
held += len(chunk)
parsed = None
if chunk.endswith(b"\n"):
blob = b"".join(q)
parsed = json.loads(blob.decode())
q.clear()
held = 0
rows.append({
"i": i,
"t_ms": round((t_read - t0) * 1000, 3),
"held_bytes": held,
"parsed": parsed is not None,
})
if gap_s:
time.sleep(gap_s)
return rows
def buffered_path(chunks):
t0 = time.perf_counter()
blob = b"".join(chunks) # holds every chunk before parse
t_join = time.perf_counter()
parsed = json.loads(blob.decode())
t_parse = time.perf_counter()
return {
"join_ms": round((t_join - t0) * 1000, 3),
"parse_ms": round((t_parse - t_join) * 1000, 3),
"keys": sorted(parsed)[:4],
}
def write_graph(rows, path="queue_depth.csv"):
with open(path, "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=["i", "t_ms", "held_bytes", "parsed"])
w.writeheader()
w.writerows(rows)增量循环记录每个块之后的保持字节数。缓冲路径先连接,然后只解析一次。在审查中,你更愿意捍卫哪张图?
gap_s 旋钮仅用于本地固定装置。不要将那个 sleep 复制到实时读取回调中。一个会睡眠的探针会发明它所报告的停滞。
我会向两条路径提供相同的假块进行单元测试。没有网络,没有英雄般的数字,没有借用的基准。测试只检查形状:当一行闭合时,保持字节数应该下降。
def test_held_bytes_fall_on_close():
chunks = [b'{"a":1}', b"\n", b'{"b":2}', b"\n"]
rows = sample_loop(chunks)
assert rows[0]["held_bytes"] > 0
assert rows[1]["held_bytes"] == 0
assert rows[1]["parsed"] is True
assert rows[3]["held_bytes"] == 0那个测试不会让你的产品更快。它阻止你对排水口撒谎。你能说出队列实际何时清空吗?
对于实时调用,以相同的方式包装套接字读取。标记接受、首个块、每个块以及卡住的解析。写入 CSV,然后将保持字节数与 t_ms 绘图。
我保留那张图,并把仪表板截图扔掉。截图没有我可以重新计算的轴。CSV 下周还会与我争辩。
如果读取器运行正常,队列图应该呈现什么形态?已持有字节数应呈锯齿状波动:每接收一个数据块时上升,每次关闭连接时下降。一条只升不降的阶梯线意味着你正在缓冲整个世界。
零值的平坦直线也可能是一种欺骗。你可能解析得太晚,导致采样点永远无法捕捉到堆积的数据。请在读取回调函数内部进行采样,而不是在处理器返回之后。
为了公平起见,不要在该回调函数中休眠。休眠调用会将瓶颈转移至你的探针之中。我希望探针比它所监控的排水口更“薄”(即开销更小)。
如果你已经在流式传输服务器发送的事件(SSE),请根据空行进行分割。解析完一个完整的事件后,立即释放这些字节。将剩余部分留在双端队列中,供下一次读取使用。
def pop_events(q):
blob = b"".join(q)
parts = blob.split(b"\n\n")
done, tail = parts[:-1], parts[-1]
q.clear()
if tail:
q.append(tail)
return done
def release_or_flag(blob):
try:
return json.loads(blob.decode()), False
except json.JSONDecodeError:
return None, True这段代码浓缩了整个课程的核心要点。已完成的事件应立即从双端队列中移除。未完成的尾部数据则保留,直到下一个数据块到来。
为什么要将尾部数据与已完成的事件拼接在一起并等待呢?现在进入实验环节,这其实比人们想象的要简单。你需要一个可重复调用的调用者以及一个运行测试框架的地方。
你不需要在白板上绘制新的集群架构图。披露声明:本文是作为 MonkeyCode 的产品推广活动的一部分准备的。MonkeyCode 提供免费模型访问权限和免费服务器选项。
这些是我唯一会依赖的产品事实。这两个事实的重要性仅在于提供一个低成本的环境来重复执行相同的调用。如果你已有免费模型端点,请将测试框架指向它。
免费服务器是一个安静的盒子,而非规模效应的证明。我不提及具体的模型、配额、硬件规格或截止时间。这些细节经常变动,而我面前没有主要的参考笔记。
如果落地页与此段内容不符,请以落地页为准。不要将这个免费服务器变成负载大炮。针对单个读取器的性能分析并不能作为容量测试的依据。
你将了解的是自己的缓冲区,而非对方的调度器。谁应该跳过这篇笔记并离开?如果你正在为你拥有的 GPU 调整内核,请跳过它。
如果你需要为客户承诺合同规定的延迟指标,请跳过它。如果你的问题是身份验证、路由或提示词错误,请跳过它。队列图无法修复错误的工具模式定义。
如果你无法在不包含敏感信息的 CSV 文件中存储数据,请跳过它。在绘制任何图表之前,先剥离提示词和输出内容。仅记录长度、标志位和已持有字节数,除此之外无需记录其他内容。
你会把客户消息粘贴到图表标题中吗?在相同的数据块上比较两条路径,然后停止。如果增量解析能够降低已持有字节数和操作时间,就保留它。
如果未能降低,那么停滞问题出在其他地方。图表已经告诉了你这一点,所以要相信它。将其视为手电筒,而非平台。
框架会增加各种旋钮(配置项),而这些旋钮会掩盖下一个满缓冲区的真相。观察导出器,因为它可能会堵塞水槽。如果在每个数据块上都阻塞的追踪数据发送器,就会成为新的水槽堵塞点。
在读取路径之外,对数据进行采样、缓冲样本,并按定时器刷新。另一个陷阱隐藏在那些读取至文件末尾(EOF)的 JSON 库中。这种调用方式本质上是一条披着流式外衣的缓冲路径。
在吹嘘事件处理之前,先检查函数名称。保持诚实的关键在于一个小规模的实验计划。在你于自己的机器上运行之前,将其标记为“未执行”。
构建三十个伪造的数据块,并只关闭其中一半。连续运行增量路径和缓冲路径。在同一进程中对这一对测试重复五次。
只改变一个变量:你释放字节的时机。不要同时更改数据块大小。如果你改变了两个旋钮,图表就无法指责其中任何一个。
将获胜方案写成一句话,而不是一种感觉。一个公平的实验结果陈述应指明旋钮及其方向。它不应提及供应商、奖牌或百分比。
如果你写不出那句话,你就没有成果。这仍然只是一个本地测试用例,而非生产环境的证明。安静的笔记本风扇并不代表服务等级。
上面的 release_or_flag 辅助函数是故障行。注入一个无效的 JSON 块。解析器应记录错误标志并释放该坏事件。
如果它永远保留这些坏字节,你就制造了第二个内存泄漏。那个泄漏明天早上看起来就像模型变慢。问问自己,当队列从未下降时,谁来买单。
你的用户以等待时间付费,你的进程以 RAM 付费。空闲的服务器不会替你支付这笔账单。
我从运行中保留一张图表:随时间变化的持有字节数。我不保留平均值、奖牌或三局两胜制的线程。当一个卡住的读者填满接收端时,平均值就下班了。
如果锯齿波从未回落,先修复释放逻辑。然后再看模型、线路和提示词。顺序很重要,因为满缓冲会使所有上游组件看起来都有罪。
你明天可以重新运行这个测试,无需新的供应商故事。保持相同的块、相同的 CSV 列、相同的问题。当事件关闭时,队列下降了吗?
那就是我会贴在显示器上方的笔记。它不是口号,也不是分数。只是一张仍能适配单个坐标轴的图表。
译文已达到本站中文翻译的字数上限,剩余内容请查看原文。