docs(perf): profile CUDA graph coverage and PLE costs on Spark

This commit is contained in:
2026-09-18 01:22:13 +08:00
parent f1a8964072
commit d7e1e745c3
23 changed files with 8384 additions and 1 deletions
+38
View File
@@ -0,0 +1,38 @@
# Nsight Systems 诊断脚本
固定 nightly `0bfc7a15`、当前 PLE 适配,单 GPU、串行请求。
方案见 [plan.md](plan.md)。这些脚本是实验工具,不是默认启动入口。
- `prepare.py prepare --data ...`:使用正在运行的同一模型生成 8K/32K 固定检索数据并预检 token 数。
- `instrument.py`:读取同目录 `vllm_ple_mmap.original.py``cudagraph_utils.original.py`
精确匹配源码后生成临时 NVTX 版本;保持算子签名与计算不变。原始文件来自容器,不提交重复副本。
- `client.py --label eager|graph`:在测试容器内运行,数据挂到 `/profiles/data`,密钥读挂载文件。
三轮正常计时和三次独立 trace;所有请求相同,temperature=0、seed=42、reasoning_effort=low。
- `monitor.py`:在 Spark 宿主机运行,写资源采样到 stdout;以脚本目录中的文件发出停止信号。
必须在启动实验前启动监控,并配套退出后恢复原配置的 runner。保护是尽力而为,不能保证拦截瞬时 OOM。
- `summarize_trace.py <trace.sqlite> ...`:导出 CUDA/NVTX 汇总,不读取进程命令行和环境。
Nsight 使用主机安装目录只读挂载到 `/opt/nsight`。临时服务添加:
```text
--max-model-len 131072 --profiler-config '{"profiler":"cuda"}'
```
在原有 `vllm serve ...` 前包裹:
```text
/opt/nsight/bin/nsys profile --sample=none --trace=cuda,nvtx,osrt \
--capture-range=cudaProfilerApi --capture-range-end=repeat:3 --kill=none \
--trace-fork-before-exec=true --cuda-graph-trace=node --force-overwrite=true \
-o /profiles/<label>/trace vllm serve ...
```
临时将服务绑定到容器回环地址,客户端使用 `docker exec`;保留已有缓存、代理和 secret 挂载。
PLE 和 CudaGraphManager 的标记文件分别只读覆盖原模块,不修改镜像或权重。
测试时自动重启关闭;原 Compose 保留备份,退出时恢复并做健康及 smoke 检查。
计时阶段没有启动 capture,但 Nsight launcher 和 NVTX 包装仍存在;本轮不是完全卸载 profiler 的独立测速。
node 级采样可能增加开销,因此 trace 时长只用作诊断,不能代替无采样阶段排名。
CPU API、NVTX 和 GPU 时间存在重叠;GPU busy 是 kernel/copy/memset 区间并集,
分母为首次至末次 GPU 活动的时间跨度,不是 SM 利用率、带宽利用率或完整 HTTP 延迟。
原始 `.nsys-rep` / SQLite 可能包含进程参数,只保留在 Spark 的受限目录,不提交 Git。
+67
View File
@@ -0,0 +1,67 @@
import argparse,json,hashlib,time,urllib.request,signal
from pathlib import Path
parser=argparse.ArgumentParser();parser.add_argument('--label',required=True);args=parser.parse_args()
base='http://127.0.0.1:8000'
key=Path('/run/secrets/qwen_api_key').read_text().strip()
headers={'Authorization':'Bearer '+key,'Content-Type':'application/json'}
def emit(r):print(json.dumps({'label':args.label,'time':time.time(),**r},ensure_ascii=False),flush=True)
def request(path,payload=None):
req=urllib.request.Request(base+path,headers=headers,data=json.dumps(payload).encode() if payload is not None else None)
with urllib.request.urlopen(req,timeout=60) as res:return res.read()
def metrics():
result={}
for l in request('/metrics').decode().splitlines():
if l.startswith('vllm:ple_mmap_') or l.startswith('vllm:prefix_cache_'):
name=l.split('{')[0].split()[0]
if name.endswith('_total'):result[name]=result.get(name,0)+float(l.split()[-1])
return result
def deadline(*_):raise TimeoutError('Request exceeded 180 seconds')
signal.signal(signal.SIGALRM,deadline)
def run(name,prompt,expected=None,profile=False):
m=[{'role':'user','content':prompt}]
count=json.loads(request('/tokenize',{'model':'qwen3.8-flash-next','messages':m,'chat_template_kwargs':{'reasoning_effort':'low'}}))['count']
assert count+2048<=131072
before=metrics();payload={'model':'qwen3.8-flash-next','messages':m,'temperature':0,'seed':42,'reasoning_effort':'low','max_tokens':2048 if expected else 512,'stream':True,'stream_options':{'include_usage':True}}
req=urllib.request.Request(base+'/v1/chat/completions',headers=headers,data=json.dumps(payload,ensure_ascii=False).encode())
emit({'event':'REQUEST_START','name':name,'profile':profile,'prompt_tokens_preflight':count})
if profile:request('/start_profile',{})
t=time.monotonic();first=last=visible=None;content=reasoning='';usage={};finish=None
signal.alarm(180)
try:
with urllib.request.urlopen(req,timeout=180) as res:
for raw in res:
line=raw.decode().strip()
if not line.startswith('data: ') or line=='data: [DONE]':continue
x=json.loads(line[6:])
if x.get('error'):raise RuntimeError('Streaming error')
if x.get('usage'):usage=x['usage']
for c in x.get('choices',[]):
d=c.get('delta',{});a=d.get('content') or '';r=d.get('reasoning') or d.get('reasoning_content') or ''
if a or r:
now=time.monotonic();first=first if first is not None else now;last=now
if a:visible=visible if visible is not None else now
content+=a;reasoning+=r;finish=c.get('finish_reason') or finish
finally:
signal.alarm(0)
if profile:request('/stop_profile',{})
elapsed=time.monotonic()-t
after=metrics()
correct=bool(content.strip()) and (expected is None or content.strip()==expected) and finish=='stop'
emit({'event':'RESULT','name':name,'profile':profile,'correct':correct,'content':content,'finish_reason':finish,'usage':usage,'max_tokens':payload['max_tokens'],'prompt_sha256':hashlib.sha256(prompt.encode()).hexdigest(),'output_sha256':hashlib.sha256((reasoning+'\0'+content).encode()).hexdigest(),'ttft_s':first-t if first else None,'first_content_s':visible-t if visible else None,'elapsed_s':elapsed,'decode_tps_approx':(usage.get('completion_tokens',0)-1)/(last-first) if last and first and last>first else None,'metrics_delta':{k:after[k]-before.get(k,0) for k in after}})
assert correct and usage['prompt_tokens']==count,name
return prompt
short='用中文写一段约150字的说明,解释数据库索引为什么能加快查询,以及它对写入有什么影响。'
run('warmup','计算17乘19,只输出结果。','323')
# Same prefix fixtures across configurations; phase/round identity is before the document.
fixtures={n:json.loads(Path(f'/profiles/data/ctx-{n}-1.json').read_text())['cases'][1] for n in [8192,32768]}
for i in range(3):
run(f'short-{i}',short)
for n,c in fixtures.items():
prompt=f'测试文档批次 BENCH-{i}-{n}\n'+c['messages'][0]['content']
run(f'prefill-{n}-{i}',prompt,c['expected'])
run(f'reuse-{n}-{i}',prompt,c['expected'])
run('trace-short',short,profile=True)
c=fixtures[32768];prompt='测试文档批次 TRACE-32768。\n'+c['messages'][0]['content']
run('trace-prefill-32768',prompt,c['expected'],profile=True)
run('trace-reuse-32768',prompt,c['expected'],profile=True)
emit({'event':'PROFILE_SUITE_PASS'})
+29
View File
@@ -0,0 +1,29 @@
from pathlib import Path
import ast
p=Path(__file__).resolve().parent
s=(p/'vllm_ple_mmap.original.py').read_text()
replacements=[
(' torch.cuda.current_stream(ids.device).synchronize()', ' with torch.cuda.nvtx.range("PLE_wait_for_GPU"):\n torch.cuda.current_stream(ids.device).synchronize()'),
(' rows = table.gather(uniq) # uint8 [U, row_bytes], fresh & writable',' with torch.cuda.nvtx.range("PLE_CPU_gather"):\n rows = table.gather(uniq) # uint8 [U, row_bytes], fresh & writable'),
(' uniq, inverse = np.unique(ids_np, return_inverse=True)', ' with torch.cuda.nvtx.range("PLE_CPU_dedup"):\n uniq, inverse = np.unique(ids_np, return_inverse=True)')]
for before,after in replacements:
assert s.count(before)==1,(before,s.count(before))
s=s.replace(before,after)
s+='''\n# Temporary diagnostic annotations; no tensor or arithmetic changes.
_profile_original_lookup_ids = _lookup_ids_impl
def _lookup_ids_impl(ngram_ids: torch.Tensor, output: torch.Tensor, layer_name: str) -> None:
with torch.cuda.nvtx.range("PLE_lookup_tokens=" + str(ngram_ids.shape[0])):
return _profile_original_lookup_ids(ngram_ids, output, layer_name)
'''
ast.parse(s);(p/'vllm_ple_mmap.profile.py').write_text(s)
s=(p/'cudagraph_utils.original.py').read_text()
s+='''\n# Diagnostic dispatch marker; does not change graph selection.
_profile_original_dispatch = CudaGraphManager.dispatch
def _profile_dispatch(self, *args, **kwargs):
desc = _profile_original_dispatch(self, *args, **kwargs)
actual = args[1] if len(args) > 1 else kwargs.get("num_tokens", -1)
torch.cuda.nvtx.mark(f"CG_DISPATCH:actual={actual}:mode={desc.cg_mode.name}:padded={desc.num_tokens}")
return desc
CudaGraphManager.dispatch = _profile_dispatch
'''
ast.parse(s);(p/'cudagraph_utils.profile.py').write_text(s)
+41
View File
@@ -0,0 +1,41 @@
import json,time,subprocess,os
from pathlib import Path
out=Path(__file__).resolve().parent
low=[]
critical=[]
initial_oom=None
while not (out/'monitor.stop').exists():
mem={l.split(':')[0]:int(l.split()[1]) for l in Path('/proc/meminfo').read_text().splitlines()}
vm=dict(l.split() for l in Path('/proc/vmstat').read_text().splitlines())
state=subprocess.run(['docker','inspect','--format','{{json .State}}','qwen38-flash-vllm'],capture_output=True,text=True)
status=json.loads(state.stdout) if state.returncode==0 else {}
if initial_oom is None: initial_oom=int(vm.get('oom_kill',0))
swapped=(int(vm['pswpin'])+int(vm['pswpout']))*os.sysconf('SC_PAGE_SIZE')
rec={'time':time.time(),'available_kib':mem['MemAvailable'],'swap_used_kib':mem['SwapTotal']-mem['SwapFree'],
'pswpin':int(vm['pswpin']),'pswpout':int(vm['pswpout']),'status':status.get('Status'),
'oom_killed':status.get('OOMKilled'),'host_oom_kills':int(vm.get('oom_kill',0)),'memory_pressure':Path('/proc/pressure/memory').read_text().strip()}
print(json.dumps(rec),flush=True)
if mem['MemAvailable']<1024*1024:
(out/'GUARD_STOP').write_text('MemAvailable below 1 GiB')
subprocess.run(['docker','stop','-t','2','qwen38-flash-vllm'])
break
if mem['MemAvailable']<2*1024*1024:
critical.append(time.monotonic())
else:
critical=[]
if int(vm.get('oom_kill',0))>initial_oom or (len(critical)>=2 and critical[-1]-critical[0]>=5):
(out/'GUARD_STOP').write_text('Host OOM counter increased or MemAvailable below 2 GiB for 5s')
subprocess.run(['docker','stop','-t','5','qwen38-flash-vllm'])
break
if mem['MemAvailable']<4*1024*1024:
low.append((time.monotonic(),swapped))
else:
low=[]
if len(low)>=7 and low[-1][0]-low[0][0]>=30 and low[-1][1]-low[0][1]>512*1024**2:
(out/'GUARD_STOP').write_text('MemAvailable below 4 GiB for 30s with >512 MiB swapping')
subprocess.run(['docker','stop','-t','5','qwen38-flash-vllm'])
break
if status.get('OOMKilled'):
(out/'GUARD_STOP').write_text('Docker reported OOMKilled')
break
time.sleep(1)
+17
View File
@@ -0,0 +1,17 @@
# CUDA Graph 性能剖析方案(2026-09-18
目的:定位当前 CUDA Graph 约 4.4% 收益的限制,确认捕获范围与 PLE 图外路径的影响。
先采证据,再决定是否扩大图覆盖;不将推断写成瓶颈结论。
1. 小型 CUDA 程序验证主机 Nsight Systems 在当前镜像中可采集 CUDA 时间线。
2. 临时服务上下文上限 131072,串行输入不超过 128K;原镜像、模型、MTP=2、BF16 KV、内存比例 0.80 不变。
3. 对照 eager、小尺寸图 [1,2,4,8,12]。若证据支持,再试含 2048-token 预填充尺寸的图配置。
4. 固定合成短问答、8K/32K 首次/重复输入;记录延迟、输出/思考 tokens、PLE 分项计数、图重放和内存。
5. Nsight 使用 API 控制的短时间窗口,只采选定请求;计时基准在采样关闭时运行,trace 不作性能定量排名。
6. 用 CPU CUDA API、GPU 活动、同步、PLE 指标判断瓶颈;这些时间可能重叠,不简单相加当百分比。
7. 主机 OOM 计数增加、MemAvailable 单次低于 1 GiB 或持续低于 2 GiB 即停止。
保留并检查内核 OOM 日志;测试后恢复原服务和 smoke test。128K 仅在候选通过后做资源/正确性验证。
8. 原始大型 trace 留在 Spark,仓库保存方案、配置、脚本、摘要及证据索引,不提交凭据。
基础事实:当前图模式为 breakable PIECEWISEPLE 哈希+CPU mmap 查表在 eager break 中运行。
大于已捕获 token 数的 batch 无匹配图时回退无图执行。扩大图尺寸仍需实测兼容性与额外内存。
+198
View File
@@ -0,0 +1,198 @@
"""Generate reproducible long-context retrieval fixtures and test a running vLLM.
Run inside the model container. Never prints the mounted API key.
"""
import argparse
import hashlib
import json
import random
import signal
import time
import urllib.request
from pathlib import Path
SNAPSHOT = '/root/.cache/huggingface/hub/models--nvidia--Qwen3.8-Flash-Next-NVFP4/snapshots/fc694b54fb0174e0913e6adf86691ef85a4ead47'
MODEL = 'qwen3.8-flash-next'
MAX_OUTPUT = 2048
MAX_CONTEXT = 131072
BASE = 'http://127.0.0.1:8000'
def emit(record):
print(json.dumps(record, ensure_ascii=False), flush=True)
def digest(value):
return hashlib.sha256(value.encode()).hexdigest()
def headers():
key = Path('/run/secrets/qwen_api_key').read_text().strip()
return {'Authorization': 'Bearer ' + key, 'Content-Type': 'application/json'}
def request(path, payload=None, timeout=120):
req = urllib.request.Request(BASE + path, headers=headers(),
data=json.dumps(payload, ensure_ascii=False).encode() if payload is not None else None)
with urllib.request.urlopen(req, timeout=timeout) as response:
return json.load(response)
def metrics():
req = urllib.request.Request(BASE + '/metrics', headers=headers())
with urllib.request.urlopen(req, timeout=15) as response:
lines = response.read().decode().splitlines()
names = ['vllm:prefix_cache_hits_total', 'vllm:prefix_cache_queries_total',
'vllm:num_preemptions_total']
return {name: sum(float(l.split()[-1]) for l in lines if l.split('{')[0].split(' ')[0] == name)
for name in names}
def messages(document, project):
prompt = ('以下是一份虚构项目预算档案,请按项目编号检索其中的数据。\n' + document +
'\n档案结束。\n问题:项目编号 ' + project + ' 的核定预算是多少元?只输出数字。')
return [{'role': 'user', 'content': prompt}]
def prepare(folder):
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained(SNAPSHOT, local_files_only=True)
folder.mkdir(parents=True, exist_ok=True)
for target in [8192, 32768]:
for sample in [1, 2]:
name = f'ctx-{target}-{sample}'
rng = random.Random(target * 10 + sample)
amounts = [str(rng.randrange(10000, 99999)) for _ in range(3)]
projects = [f'TARGET-{position}-{sample}' for position in ['FRONT', 'MIDDLE', 'END']]
filler = [f'项目编号 BG-{i:06d};核定预算 {rng.randrange(10000,99999)} 元;类别 C{i%19:02d}' for i in range(20000)]
def build(n):
rows = filler[:n].copy()
positions = [int(n * f) for f in [0.05, 0.5, 0.95]]
for pos, project, amount in zip(positions, projects, amounts):
rows[pos] = f'项目编号 {project};核定预算 {amount} 元;类别 C20。'
return '文档编号:PROFILE128K-' + name + '\n' + '\n'.join(rows), positions, rows
low, high = 20, len(filler)
while low < high:
n = (low + high + 1) // 2
doc, _, _ = build(n)
count = len(tok.apply_chat_template(messages(doc, projects[1]), tokenize=True, add_generation_prompt=True, return_dict=False))
if count <= target:
low = n
else:
high = n - 1
doc, positions, rows = build(low)
cases = []
for label, index in [('front', 0), ('middle', 1), ('end', 2), ('front_repeat', 0)]:
msgs = messages(doc, projects[index])
count = request('/tokenize', {'model': MODEL, 'messages': msgs, 'add_generation_prompt': True, 'chat_template_kwargs': {'reasoning_effort': 'low'}})['count']
assert target - 128 <= count <= target + 32, (name, count, target)
assert count + MAX_OUTPUT <= MAX_CONTEXT, (name, count)
cases.append({'case': label, 'messages': msgs, 'expected': amounts[index],
'prompt_tokens_preflight': count, 'prompt_sha256': digest(msgs[0]['content']),
'max_tokens': MAX_OUTPUT})
fractions = [round(len(tok.encode('文档编号:PROFILE128K-'+name+'\n'+'\n'.join(rows[:pos]), add_special_tokens=False)) / cases[0]['prompt_tokens_preflight'], 4)
for pos in positions]
fixture = {'document': name, 'target_input_tokens': target, 'sample': sample,
'document_sha256': digest(doc), 'needle_token_fractions': fractions, 'cases': cases}
(folder / (name + '.json')).write_text(json.dumps(fixture, ensure_ascii=False))
emit({'event': 'FIXTURE_READY', **{k:v for k,v in fixture.items() if k != 'cases'},
'prompt_tokens': [c['prompt_tokens_preflight'] for c in cases]})
def deadline(signum, frame):
raise TimeoutError('Request exceeded the 1200 second wall-clock limit')
def run_case(label, fixture, case, attempt):
before = metrics()
payload = {'model': MODEL, 'messages': case['messages'], 'temperature': 0, 'seed': 42,
'reasoning_effort': 'low', 'max_tokens': MAX_OUTPUT,
'stream': True, 'stream_options': {'include_usage': True}}
req = urllib.request.Request(BASE + '/v1/chat/completions', headers=headers(),
data=json.dumps(payload, ensure_ascii=False).encode())
start = time.monotonic()
first = first_content = last = None
content = reasoning = ''
usage = {}
finish = None
signal.alarm(1200)
try:
with urllib.request.urlopen(req, timeout=1200) as response:
for raw in response:
line = raw.decode().strip()
if not line.startswith('data: ') or line == 'data: [DONE]':
continue
item = json.loads(line[6:])
if item.get('error'):
raise RuntimeError('Server returned a streaming error')
if item.get('usage'):
usage = item['usage']
for choice in item.get('choices', []):
delta = choice.get('delta', {})
answer = delta.get('content') or ''
thought = delta.get('reasoning') or delta.get('reasoning_content') or ''
if answer or thought:
now = time.monotonic()
first = first if first is not None else now
last = now
if answer:
first_content = first_content if first_content is not None else now
content += answer
reasoning += thought
finish = choice.get('finish_reason') or finish
finally:
signal.alarm(0)
if finish is None or not usage:
raise RuntimeError('Incomplete response or missing usage')
after = metrics()
elapsed = time.monotonic() - start
record = {'event': 'RESULT', 'label': label, 'document': fixture['document'],
'target_input_tokens': fixture['target_input_tokens'], 'sample': fixture['sample'],
'case': case['case'], 'attempt': attempt, 'expected': case['expected'],
'content': content, 'correct': content.strip() == case['expected'] and finish == 'stop',
'finish_reason': finish, 'usage': usage, 'max_tokens': MAX_OUTPUT,
'prompt_tokens_preflight': case['prompt_tokens_preflight'], 'prompt_sha256': case['prompt_sha256'],
'output_sha256': digest(reasoning + '\0' + content),
'ttft_s': round(first-start, 4) if first else None,
'first_content_s': round(first_content-start, 4) if first_content else None,
'elapsed_s': round(elapsed, 4),
'decode_tps_approx': round((usage['completion_tokens']-1)/(last-first), 3) if last and first and last>first else None,
'metrics_delta': {k: after[k]-before[k] for k in after}}
emit(record)
assert usage['prompt_tokens'] + MAX_OUTPUT <= MAX_CONTEXT
assert usage['prompt_tokens'] == case['prompt_tokens_preflight'], 'Server tokenization differs from preflight'
return record
def run(folder, label):
signal.signal(signal.SIGALRM, deadline)
failures = []
for target in [8192, 32768]:
emit({'event': 'STAGE_START', 'label': label, 'target_input_tokens': target})
for sample in [1, 2]:
fixture = json.loads((folder / f'ctx-{target}-{sample}.json').read_text())
emit({'event': 'DOCUMENT_START', 'label': label, 'document': fixture['document'],
'document_sha256': fixture['document_sha256'], 'needle_token_fractions': fixture['needle_token_fractions']})
for case in fixture['cases']:
emit({'event': 'REQUEST_START', 'label': label, 'document': fixture['document'], 'case': case['case']})
result = run_case(label, fixture, case, 1)
if not result['correct']:
failures.append([fixture['document'], case['case']])
run_case(label, fixture, case, 2)
emit({'event': 'STAGE_COMPLETED', 'label': label, 'target_input_tokens': target})
emit({'event': 'CONTEXT_SUITE_COMPLETED', 'label': label, 'first_attempt_failures': failures})
# Quality failures are reported above, not hidden; infrastructure failures raise.
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('mode', choices=['prepare', 'run'])
parser.add_argument('--data', type=Path, default=Path('/tmp/context262k-data'))
parser.add_argument('--label', default='current')
args = parser.parse_args()
if args.mode == 'prepare':
prepare(args.data)
else:
run(args.data, args.label)
@@ -0,0 +1,20 @@
import json,statistics,sys
from pathlib import Path
result={}
for fn in sys.argv[1:]:
rows=[]
for line in Path(fn).read_text().splitlines():
try:r=json.loads(line)
except ValueError:continue
if r.get('event')=='RESULT':rows.append(r)
label=rows[0]['label'];groups={}
for name in ['short','prefill-8192','reuse-8192','prefill-32768','reuse-32768']:
rr=[r for r in rows if not r['profile'] and r['name'].rsplit('-',1)[0]==name]
if not rr:continue
groups[name]={'n':len(rr),'correct':sum(r['correct'] for r in rr)}
for k in ['ttft_s','elapsed_s','decode_tps_approx']:
values=[r[k] for r in rr];groups[name][k]={'median':statistics.median(values),'values':values}
groups[name]['output_tokens']=[r['usage']['completion_tokens'] for r in rr]
groups[name]['reasoning_tokens']=[r['usage'].get('completion_tokens_details',{}).get('reasoning_tokens') for r in rr]
result[label]={'total_requests':len(rows),'correct':sum(r['correct'] for r in rows),'groups':groups}
print(json.dumps(result,indent=2))
@@ -0,0 +1,52 @@
"""Export only CUDA/NVTX aggregates; omit process arguments and environment."""
import sqlite3,json,sys,collections
from pathlib import Path
def merge(intervals):
out=[]
for a,b in sorted(intervals):
if b<=a:continue
if out and a<=out[-1][1]:out[-1]=(out[-1][0],max(out[-1][1],b))
else:out.append((a,b))
return out
def duration(xs):return sum(b-a for a,b in xs)/1e9
def overlap(a,b):
i=j=0;total=0
while i<len(a) and j<len(b):
total+=max(0,min(a[i][1],b[j][1])-max(a[i][0],b[j][0]))
if a[i][1]<b[j][1]:i+=1
else:j+=1
return total/1e9
for fn in sys.argv[1:]:
db=sqlite3.connect(fn);db.row_factory=sqlite3.Row
tables={r[0] for r in db.execute("select name from sqlite_master where type='table'")}
strings=dict(db.execute('select id,value from StringIds'))
gpu=[];kerns=[];api=collections.defaultdict(lambda:[0,0]);nv=collections.defaultdict(list);dispatch=collections.Counter();dispatch_timeline=[]
for table in ['CUPTI_ACTIVITY_KIND_KERNEL','CUPTI_ACTIVITY_KIND_MEMCPY','CUPTI_ACTIVITY_KIND_MEMSET']:
if table in tables:
rows=list(db.execute('select * from '+table));gpu.extend((r['start'],r['end']) for r in rows)
if table.endswith('KERNEL'):kerns=rows
for table in ['CUPTI_ACTIVITY_KIND_RUNTIME','CUPTI_ACTIVITY_KIND_DRIVER']:
if table in tables:
for r in db.execute('select start,end,nameId from '+table):
name=strings.get(r['nameId'],str(r['nameId']));api[name][0]+=1;api[name][1]+=(r['end']-r['start'])/1e9
if 'NVTX_EVENTS' in tables:
for r in db.execute('select * from NVTX_EVENTS'):
keys=r.keys();name=r['text'] if 'text' in keys else None
if not name and 'textId' in keys:name=strings.get(r['textId'],'')
name=name or ''
if name.startswith('CG_DISPATCH:'):
dispatch[name]+=1;dispatch_timeline.append({'start_ns':r['start'],'name':name})
if name.startswith('PLE_') and r['end'] is not None:nv[name].append((r['start'],r['end']))
merged=merge(gpu);span=(merged[-1][1]-merged[0][0])/1e9 if merged else 0
nvout={}
for name,ranges in nv.items():
u=merge(ranges);nvout[name]={'count':len(ranges),'sum_s':duration(ranges),'union_s':duration(u),'gpu_overlap_s':overlap(u,merged)}
kernel_names=collections.defaultdict(lambda:[0,0])
for r in kerns:
name=strings.get(r['shortName'],'?');kernel_names[name][0]+=1;kernel_names[name][1]+=(r['end']-r['start'])/1e9
result={'file':Path(fn).name,'gpu_activity_span_s':span,'gpu_busy_union_s':duration(merged),'gpu_idle_within_span_s':span-duration(merged),'kernel_count':len(kerns),'graph_node_kernel_count':sum(bool(r['graphNodeId']) for r in kerns),'api':dict(sorted(api.items(),key=lambda x:-x[1][1])),'nvtx':nvout,'dispatch':dict(dispatch),'dispatch_timeline':dispatch_timeline,'top_kernels':sorted(kernel_names.items(),key=lambda x:-x[1][1])[:20],'notes':'GPU busy is interval union, not utilization counter. CPU/API/NVTX times overlap GPU work and each other; do not sum them as exclusive costs.'}
dest=Path(fn).with_suffix('.summary.json');dest.write_text(json.dumps(result,indent=2));print(dest)