先定义问题,数字才有含义
推理 benchmark 不是一个 time.time() 差值。至少要先固定:
- 语义:A/B 输出应相等到明确容差;采样路径则比较分布或固定随机流。
- workload:模型/权重、prompt/output 长度分布、batch/concurrency、dtype、sampling、cache 命中率。
- 测量边界:单 kernel、一个 decode step、模型 forward、TTFT/TPOT,还是包含 queue/tokenization/network 的端到端请求。
- 环境:GPU、driver、CUDA/PyTorch/runtime 版本、clock/power、并行度与 backend 配置。
- 统计:warmup、样本数、median/p95/p99、异常值与是否包含 cold start。
只报告平均 tokens/s 会掩盖 queue 和尾延迟;只报告 p99 又不能说明容量。服务层至少同时观察 TTFT、TPOT、throughput 与满足目标的 goodput。
三种计时边界
对同一 X:[256,1024] @ W:[1024,1024] FP16 GEMM,本机示例得到:
| 方法 | 本次中位数 | 包含什么 |
|---|---|---|
| CPU submit、无同步 | 5.971 µs | Python/ATen dispatch 与 launch 提交;GPU 可能尚未完成 |
perf_counter + 前后 cuda.synchronize() | 22.786 µs | host 侧等待、launch 和 GPU 完成的 wall-clock 边界 |
| 同 stream CUDA Events | 20.480 µs | 两个 event 间的 device timeline elapsed;不含 event 前的 host queue time |
这些数值只属于当前 shape、RTX 3080 和软件版本。Event 并非“永远最真实”:多 streams、跨设备、排队、CPU preprocessing、network 与 scheduler 延迟要用相应 trace/端到端边界。每轮强制同步也会破坏生产中的 overlap,所以微基准和真实服务 trace 应同时存在。
Warmup 与统计
Warmup 用来排除 lazy module loading、allocator 初始化、autotuning、JIT/compile、cache coldness 和 graph capture 等一次性成本。若 cold start 本身是目标,就应单独报告 first request,而不是与 steady-state 混成一个平均数。
Median 对偶发 OS 抖动较稳健;p95/p99 对 SLO 更相关,但需要足够样本。并发服务还应保存 arrival trace 和 completed/cancelled/failed 分类,避免只对幸存请求统计。
正确性优先的可运行计时与容量实验
实验问题:未同步提交、同步 wall-clock 与 CUDA Event 有何差异?PyTorch allocated、reserved、peak 是否会在临时 tensor 删除后表现不同?给定权重/运行时预算,逻辑 KV token capacity 如何计算?
import platform,statistics,time
import torch
torch.manual_seed(181)
DEVICE=torch.device("cuda" if torch.cuda.is_available() else "cpu")
def median_us(samples): return statistics.median(samples)
def benchmark_cuda(fn,warmup=10,rounds=30):
for _ in range(warmup): fn()
torch.cuda.synchronize(); submit=[]; synchronized=[]; events=[]
for _ in range(rounds):
start=time.perf_counter(); fn()
submit.append((time.perf_counter()-start)*1e6)
torch.cuda.synchronize(); start=time.perf_counter(); fn(); torch.cuda.synchronize()
synchronized.append((time.perf_counter()-start)*1e6)
begin=torch.cuda.Event(enable_timing=True); end=torch.cuda.Event(enable_timing=True)
begin.record(); fn(); end.record(); end.synchronize()
events.append(begin.elapsed_time(end)*1e3)
return median_us(submit),median_us(synchronized),median_us(events)
def benchmark_cpu(fn,warmup=5,rounds=20):
for _ in range(warmup): fn()
samples=[]
for _ in range(rounds):
start=time.perf_counter(); fn(); samples.append((time.perf_counter()-start)*1e6)
value=median_us(samples); return value,value,value
def capacity_model(total_gib,weights_gib,runtime_gib,layers,kv_heads,head_dim,element_bytes,utilization):
usable=int(total_gib*utilization*2**30)
fixed=int((weights_gib+runtime_gib)*2**30)
kv_per_token=2*layers*kv_heads*head_dim*element_bytes
return usable,fixed,kv_per_token,max(0,(usable-fixed)//kv_per_token)
def main():
dtype=torch.float16 if DEVICE.type=="cuda" else torch.float32
m,k,n=(256,1024,1024) if DEVICE.type=="cuda" else (64,256,256)
x=torch.randn(m,k,device=DEVICE,dtype=dtype)
weight=torch.randn(k,n,device=DEVICE,dtype=dtype)
output=x@weight; reference=x.float()@weight.float()
tol=(2e-3,2e-2) if dtype==torch.float16 else (1e-5,1e-6)
torch.testing.assert_close(output.float(),reference,rtol=tol[0],atol=tol[1])
fn=lambda:x@weight
if DEVICE.type=="cuda":
submit_us,sync_us,event_us=benchmark_cuda(fn)
torch.cuda.synchronize()
base_a=torch.cuda.memory_allocated(); base_r=torch.cuda.memory_reserved()
torch.cuda.reset_peak_memory_stats()
temporary=torch.empty((2048,2048),device=DEVICE,dtype=torch.float32)
temporary.fill_(1); fn(); torch.cuda.synchronize()
peak_a=torch.cuda.max_memory_allocated(); peak_r=torch.cuda.max_memory_reserved()
del temporary; torch.cuda.synchronize()
after_a=torch.cuda.memory_allocated(); after_r=torch.cuda.memory_reserved()
else:
submit_us,sync_us,event_us=benchmark_cpu(fn)
base_a=base_r=peak_a=peak_r=after_a=after_r=0
usable,fixed,kv_per_token,token_capacity=capacity_model(
20,14,1.5,32,8,128,2,.90)
assert kv_per_token==131072 and token_capacity==20480
name=torch.cuda.get_device_name(0) if DEVICE.type=="cuda" else "CPU"
print(f"python={platform.python_version()} torch={torch.__version__}")
print(f"device={DEVICE} name={name} dtype={dtype} X={tuple(x.shape)} W={tuple(weight.shape)}")
print(f"correctness_max_abs_diff={(output.float()-reference).abs().max():.3e}")
print(f"timing_median_us submit_without_sync={submit_us:.3f} synchronized_wall={sync_us:.3f} cuda_event={event_us:.3f}")
if DEVICE.type=="cuda":
print("memory_MiB "
f"baseline_allocated={base_a/2**20:.3f} baseline_reserved={base_r/2**20:.3f} "
f"peak_allocated={peak_a/2**20:.3f} peak_reserved={peak_r/2**20:.3f} "
f"after_allocated={after_a/2**20:.3f} after_reserved={after_r/2**20:.3f}")
print(f"capacity usable_GiB={usable/2**30:.3f} fixed_GiB={fixed/2**30:.3f} "
f"KV_bytes_per_token={kv_per_token} token_capacity={token_capacity}")
if __name__=="__main__":
with torch.inference_mode(): main()
完整文件位于 examples/benchmark_capacity.py。2026-08-13 实测 output/reference 最大差 6.058e-02,在 FP16 容差内通过。临时 16 MiB tensor 使 allocated 从 12.125 升至 peak 28.625 MiB;删除后 allocated 回到 12.125 MiB,reserved 仍为 44 MiB,展示 caching allocator 保留可复用 segment,而不是等同泄漏。
脚本没有比较两个算法的快慢;它只固定计时与内存口径。若做 A/B,必须让两边使用同输入/shape/dtype/device、先 assert_close、各自 warmup,并避免第一条路径为第二条路径预热不对称资源。
容量模型:理论 payload 不是可服务上限
单 rank 可用于动态状态的预算可写成:
是允许使用的显存比例。标准 KV 每 token bytes 为:
所以跨所有 active sequences 的逻辑 token 上限:
代入 20 GiB device、、14 GiB weights、1.5 GiB 合并 runtime/safety、:可用 18 GiB,固定 15.5 GiB,KV 每 token 131,072 bytes(128 KiB),得到 20,480 active cached tokens。这不是“可同时服务 20,480 个请求”;10 条各 2,048 tokens 已用尽该逻辑预算,且尚未显式计入 block fragmentation、prefill activations、CUDA Graph pools、NCCL buffers 与峰值 workspace。
PagedAttention减少外部碎片并按 blocks 管理容量,但最后一个 partial block、prefix sharing/refcount 与 scheduler reservation 仍改变可 admission tokens。KV 量化改变 payload/metadata,MLA改变缓存字段,容量公式要跟实际 layout 一致。
allocated、reserved、peak、理论容量
- theoretical payload:由 shape × dtype 算出的活 tensor 数据。
- allocated:PyTorch allocator 当前交给 live tensors 的 bytes。
- reserved:allocator 从 CUDA 保留的 segments,包含 allocated 与可复用空闲区。
- peak allocated/reserved:reset 后窗口内高水位;测量窗口必须覆盖目标路径。
- device free/used:还包含其他进程、CUDA context、非 PyTorch allocations。
只看 nvidia-smi 不能区分这些口径,只看 allocated 又可能忽略 graph/NCCL/custom allocator。容量测试应实际跑 admission/OOM 边界,并用理论模型解释差值。
Profiler、NVTX 与 Goodput
Profiler 用来把时间归因到 operator、kernel、memcpy、collective 和 idle gap;它不是 benchmark 替代品,因为 tracing 自身有开销。Backend Dispatch页面展示了如何用 profiler 证明实际 SDPA/GEMM 路径。
GPU Runtime用 stream/event 建立跨队列 happens-before,并实测 torch.compile pointwise fusion 的 kernel count;这些 trace 证据仍需本页的同步、warmup 和端到端边界才能形成性能结论。
NVTX range 应围绕稳定语义区间,例如 request/queue、prefill、decode_step、a2a_dispatch,并携带 request/batch/shape 标识;不要给每个细小 Python helper 加 range 造成 trace 噪声。多 stream 时还要查看依赖与 overlap,不能把横向长度直接机械相加。
若 SLO 定义为 TTFT≤x 且 TPOT≤y,请求 goodput 可以写成:
必须说明 cancelled/failed 是否进入分母、输出长度分布与测量窗口。吞吐提高但大量请求违反 SLO 时,goodput 可以下降。Continuous Batching的 scheduler benchmark 应在同一 arrival trace 上同时报告 queue、TTFT、TPOT、throughput 与 goodput。
服务可靠性、Backpressure 与 Observability继续定义在线证据:低基数 metrics 保存总体分布,request traces 连接 gateway/scheduler/worker 因果链,结构化 logs 记录离散状态跃迁;只对成功请求统计延迟会产生 survivor bias。