trace-lanefix 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- trace_lanefix-0.1.0/LICENSE +21 -0
- trace_lanefix-0.1.0/PKG-INFO +105 -0
- trace_lanefix-0.1.0/README.md +80 -0
- trace_lanefix-0.1.0/pyproject.toml +39 -0
- trace_lanefix-0.1.0/setup.cfg +4 -0
- trace_lanefix-0.1.0/tests/test_fix_overlaps.py +213 -0
- trace_lanefix-0.1.0/trace_lanefix/__init__.py +5 -0
- trace_lanefix-0.1.0/trace_lanefix/cli.py +51 -0
- trace_lanefix-0.1.0/trace_lanefix/core.py +377 -0
- trace_lanefix-0.1.0/trace_lanefix.egg-info/PKG-INFO +105 -0
- trace_lanefix-0.1.0/trace_lanefix.egg-info/SOURCES.txt +13 -0
- trace_lanefix-0.1.0/trace_lanefix.egg-info/dependency_links.txt +1 -0
- trace_lanefix-0.1.0/trace_lanefix.egg-info/entry_points.txt +2 -0
- trace_lanefix-0.1.0/trace_lanefix.egg-info/requires.txt +3 -0
- trace_lanefix-0.1.0/trace_lanefix.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Roman Ralovets
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: trace-lanefix
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Fix overlapping Chrome/Kineto trace slices so Perfetto can render them.
|
|
5
|
+
Author: Roman Ralovets
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/ralovets/trace-lanefix
|
|
8
|
+
Project-URL: Issues, https://github.com/ralovets/trace-lanefix/issues
|
|
9
|
+
Keywords: chrome-trace,kineto,perfetto,profiling,pytorch
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Software Development :: Debuggers
|
|
18
|
+
Classifier: Topic :: System :: Benchmark
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# trace-lanefix
|
|
27
|
+
|
|
28
|
+
`trace-lanefix` rewrites Chrome/Kineto trace JSON so Perfetto can render slices
|
|
29
|
+
that would otherwise be dropped because of crossing overlaps on the same
|
|
30
|
+
`(pid, tid)` track.
|
|
31
|
+
|
|
32
|
+
It keeps properly nested slices on the same track, snaps tiny timestamp-boundary
|
|
33
|
+
crossings, and moves genuine overlaps to synthetic lanes with collision-free
|
|
34
|
+
thread ids.
|
|
35
|
+
|
|
36
|
+
## Before / After
|
|
37
|
+
|
|
38
|
+
Before:
|
|
39
|
+
|
|
40
|
+
<img src="https://raw.githubusercontent.com/ralovets/trace-lanefix/main/assets/profile-before.png" alt="Profile before lane fix" width="100%">
|
|
41
|
+
|
|
42
|
+
After:
|
|
43
|
+
|
|
44
|
+
<img src="https://raw.githubusercontent.com/ralovets/trace-lanefix/main/assets/profile-after.png" alt="Profile after lane fix" width="100%">
|
|
45
|
+
|
|
46
|
+
In Perfetto's trace overview, the rewritten trace reports `TRACE ERRORS=0`
|
|
47
|
+
instead of `TRACE ERRORS=6`, while `IMPORT ERRORS` and `DATA LOSSES` remain
|
|
48
|
+
at `0`. In this example, the JSON trace size changed from `452.76 KiB` before
|
|
49
|
+
rewriting (`463,629` bytes) to `653.96 KiB` after rewriting (`669,651`
|
|
50
|
+
bytes) because the output is pretty-printed for readability.
|
|
51
|
+
|
|
52
|
+
## CLI
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
trace-lanefix input.json output.fixed.json
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Tiny crossings below `5.0us` are snapped by default. To use a different snap
|
|
59
|
+
threshold:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
trace-lanefix --snap-threshold-us 2.5 input.json output.fixed.json
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Example output:
|
|
66
|
+
|
|
67
|
+
This run shows the two repair paths: tiny sub-5.0us timestamp crossings are
|
|
68
|
+
snapped in place, while one genuine overlap is moved to a synthetic lane. Flow
|
|
69
|
+
endpoints are then rewritten to follow the adjusted slice starts and lanes.
|
|
70
|
+
|
|
71
|
+
```text
|
|
72
|
+
trace-lanefix trace.json trace_fixed.json
|
|
73
|
+
processing 3 track(s)
|
|
74
|
+
pid=7334 tid=7334: 2 lane(s), 1 slice(s) moved, 11 snapped
|
|
75
|
+
pid=0 tid=7: 1 lane(s), 0 slice(s) moved, 5 snapped
|
|
76
|
+
snapped 16 sub-5.0us crossing(s)
|
|
77
|
+
updated 15 slice(s): total |start shift|=6.195us, total |end shift|=5.240us, total |boundary shift|=11.436us
|
|
78
|
+
kernel timing summary (5 run(s) across 3 kernel name(s)):
|
|
79
|
+
nvjet_sm120_tst_mma_256x112x64_2_64x56x64_tmaAB_bz_TNNN: runs=2, total delayed=2.848us, total trimmed=0.000us, total boundary shift=2.848us, moved=0, snapped=2
|
|
80
|
+
nvjet_sm120_tst_mma_96x256x64_2_48x64x64_tmaAB_bz_TNNN: runs=2, total delayed=480ns, total trimmed=0.000us, total boundary shift=480ns, moved=0, snapped=2
|
|
81
|
+
nvjet_sm120_tst_mma_128x256x64_2_64x64x64_tmaAB_bz_TNNN: runs=1, total delayed=384ns, total trimmed=0.000us, total boundary shift=384ns, moved=0, snapped=1
|
|
82
|
+
moved slices (1):
|
|
83
|
+
ProfilerStep#5 pid=7334: moved to lane 1 (tid 7334 -> 7334001)
|
|
84
|
+
snapped slices (9):
|
|
85
|
+
layer0_forward pid=7334 tid=7334: start delayed by 2.483us
|
|
86
|
+
torch/autograd/profiler.py(843): __exit__ pid=7334 tid=7334: end trimmed by 2.855us
|
|
87
|
+
torch/_ops.py(1079): __call__ pid=7334 tid=7334: end trimmed by 1.222us
|
|
88
|
+
<built-in method of pybind11_builtins.pybind11_detail_function_record_v1_system_libstdcpp_gxx_abi_1xxx_use_cxx11_abi_1 object at 0x719b5a7004b0> pid=7334 tid=7334: end trimmed by 1.162us
|
|
89
|
+
contextlib.py(141): __exit__ pid=7334 tid=7334: end trimmed by 0.2ns
|
|
90
|
+
profiling.py(19): trace pid=7334 tid=7334: end trimmed by 0.2ns
|
|
91
|
+
torch/profiler/profiler.py(912): _transit_action pid=7334 tid=7334: end trimmed by 0.2ns
|
|
92
|
+
torch/cuda/__init__.py(1161): synchronize pid=7334 tid=7334: end trimmed by 0.2ns
|
|
93
|
+
torch/cuda/__init__.py(609): __exit__ pid=7334 tid=7334: end trimmed by 0.2ns
|
|
94
|
+
rewrote 5 flow endpoint(s) onto rewritten slice starts/lanes
|
|
95
|
+
wrote trace_fixed.json: 988 slices, 1 new lanes
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Python API
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from trace_lanefix import fix_overlaps
|
|
102
|
+
|
|
103
|
+
fix_overlaps("input.json", "output.fixed.json")
|
|
104
|
+
fix_overlaps("input.json", "output.fixed.json", snap_threshold_us=2.5)
|
|
105
|
+
```
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# trace-lanefix
|
|
2
|
+
|
|
3
|
+
`trace-lanefix` rewrites Chrome/Kineto trace JSON so Perfetto can render slices
|
|
4
|
+
that would otherwise be dropped because of crossing overlaps on the same
|
|
5
|
+
`(pid, tid)` track.
|
|
6
|
+
|
|
7
|
+
It keeps properly nested slices on the same track, snaps tiny timestamp-boundary
|
|
8
|
+
crossings, and moves genuine overlaps to synthetic lanes with collision-free
|
|
9
|
+
thread ids.
|
|
10
|
+
|
|
11
|
+
## Before / After
|
|
12
|
+
|
|
13
|
+
Before:
|
|
14
|
+
|
|
15
|
+
<img src="https://raw.githubusercontent.com/ralovets/trace-lanefix/main/assets/profile-before.png" alt="Profile before lane fix" width="100%">
|
|
16
|
+
|
|
17
|
+
After:
|
|
18
|
+
|
|
19
|
+
<img src="https://raw.githubusercontent.com/ralovets/trace-lanefix/main/assets/profile-after.png" alt="Profile after lane fix" width="100%">
|
|
20
|
+
|
|
21
|
+
In Perfetto's trace overview, the rewritten trace reports `TRACE ERRORS=0`
|
|
22
|
+
instead of `TRACE ERRORS=6`, while `IMPORT ERRORS` and `DATA LOSSES` remain
|
|
23
|
+
at `0`. In this example, the JSON trace size changed from `452.76 KiB` before
|
|
24
|
+
rewriting (`463,629` bytes) to `653.96 KiB` after rewriting (`669,651`
|
|
25
|
+
bytes) because the output is pretty-printed for readability.
|
|
26
|
+
|
|
27
|
+
## CLI
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
trace-lanefix input.json output.fixed.json
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Tiny crossings below `5.0us` are snapped by default. To use a different snap
|
|
34
|
+
threshold:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
trace-lanefix --snap-threshold-us 2.5 input.json output.fixed.json
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Example output:
|
|
41
|
+
|
|
42
|
+
This run shows the two repair paths: tiny sub-5.0us timestamp crossings are
|
|
43
|
+
snapped in place, while one genuine overlap is moved to a synthetic lane. Flow
|
|
44
|
+
endpoints are then rewritten to follow the adjusted slice starts and lanes.
|
|
45
|
+
|
|
46
|
+
```text
|
|
47
|
+
trace-lanefix trace.json trace_fixed.json
|
|
48
|
+
processing 3 track(s)
|
|
49
|
+
pid=7334 tid=7334: 2 lane(s), 1 slice(s) moved, 11 snapped
|
|
50
|
+
pid=0 tid=7: 1 lane(s), 0 slice(s) moved, 5 snapped
|
|
51
|
+
snapped 16 sub-5.0us crossing(s)
|
|
52
|
+
updated 15 slice(s): total |start shift|=6.195us, total |end shift|=5.240us, total |boundary shift|=11.436us
|
|
53
|
+
kernel timing summary (5 run(s) across 3 kernel name(s)):
|
|
54
|
+
nvjet_sm120_tst_mma_256x112x64_2_64x56x64_tmaAB_bz_TNNN: runs=2, total delayed=2.848us, total trimmed=0.000us, total boundary shift=2.848us, moved=0, snapped=2
|
|
55
|
+
nvjet_sm120_tst_mma_96x256x64_2_48x64x64_tmaAB_bz_TNNN: runs=2, total delayed=480ns, total trimmed=0.000us, total boundary shift=480ns, moved=0, snapped=2
|
|
56
|
+
nvjet_sm120_tst_mma_128x256x64_2_64x64x64_tmaAB_bz_TNNN: runs=1, total delayed=384ns, total trimmed=0.000us, total boundary shift=384ns, moved=0, snapped=1
|
|
57
|
+
moved slices (1):
|
|
58
|
+
ProfilerStep#5 pid=7334: moved to lane 1 (tid 7334 -> 7334001)
|
|
59
|
+
snapped slices (9):
|
|
60
|
+
layer0_forward pid=7334 tid=7334: start delayed by 2.483us
|
|
61
|
+
torch/autograd/profiler.py(843): __exit__ pid=7334 tid=7334: end trimmed by 2.855us
|
|
62
|
+
torch/_ops.py(1079): __call__ pid=7334 tid=7334: end trimmed by 1.222us
|
|
63
|
+
<built-in method of pybind11_builtins.pybind11_detail_function_record_v1_system_libstdcpp_gxx_abi_1xxx_use_cxx11_abi_1 object at 0x719b5a7004b0> pid=7334 tid=7334: end trimmed by 1.162us
|
|
64
|
+
contextlib.py(141): __exit__ pid=7334 tid=7334: end trimmed by 0.2ns
|
|
65
|
+
profiling.py(19): trace pid=7334 tid=7334: end trimmed by 0.2ns
|
|
66
|
+
torch/profiler/profiler.py(912): _transit_action pid=7334 tid=7334: end trimmed by 0.2ns
|
|
67
|
+
torch/cuda/__init__.py(1161): synchronize pid=7334 tid=7334: end trimmed by 0.2ns
|
|
68
|
+
torch/cuda/__init__.py(609): __exit__ pid=7334 tid=7334: end trimmed by 0.2ns
|
|
69
|
+
rewrote 5 flow endpoint(s) onto rewritten slice starts/lanes
|
|
70
|
+
wrote trace_fixed.json: 988 slices, 1 new lanes
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Python API
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from trace_lanefix import fix_overlaps
|
|
77
|
+
|
|
78
|
+
fix_overlaps("input.json", "output.fixed.json")
|
|
79
|
+
fix_overlaps("input.json", "output.fixed.json", snap_threshold_us=2.5)
|
|
80
|
+
```
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "trace-lanefix"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Fix overlapping Chrome/Kineto trace slices so Perfetto can render them."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "Roman Ralovets"},
|
|
14
|
+
]
|
|
15
|
+
keywords = ["chrome-trace", "kineto", "perfetto", "profiling", "pytorch"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Environment :: Console",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Topic :: Software Development :: Debuggers",
|
|
25
|
+
"Topic :: System :: Benchmark",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
dev = ["pytest>=8"]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Repository = "https://github.com/ralovets/trace-lanefix"
|
|
33
|
+
Issues = "https://github.com/ralovets/trace-lanefix/issues"
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
trace-lanefix = "trace_lanefix.cli:main"
|
|
37
|
+
|
|
38
|
+
[tool.setuptools.packages.find]
|
|
39
|
+
include = ["trace_lanefix*"]
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from collections import defaultdict
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from trace_lanefix import fix_overlaps
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _run_fix(trace, tmp_path, **kwargs):
|
|
10
|
+
in_path = tmp_path / "in.json"
|
|
11
|
+
out_path = tmp_path / "out.json"
|
|
12
|
+
in_path.write_text(json.dumps(trace))
|
|
13
|
+
fix_overlaps(str(in_path), str(out_path), **kwargs)
|
|
14
|
+
return json.loads(out_path.read_text())["traceEvents"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _assert_no_crossings(events):
|
|
18
|
+
slices = [e for e in events if e.get("ph") == "X" and "dur" in e]
|
|
19
|
+
by_track = defaultdict(list)
|
|
20
|
+
for e in slices:
|
|
21
|
+
by_track[(e.get("pid"), e.get("tid"))].append(e)
|
|
22
|
+
|
|
23
|
+
for group in by_track.values():
|
|
24
|
+
group.sort(key=lambda e: (e["ts"], -e["dur"]))
|
|
25
|
+
stack = []
|
|
26
|
+
for e in group:
|
|
27
|
+
start = e["ts"]
|
|
28
|
+
end = start + e["dur"]
|
|
29
|
+
while stack and stack[-1][0] <= start:
|
|
30
|
+
stack.pop()
|
|
31
|
+
if stack:
|
|
32
|
+
top_end, top_start = stack[-1]
|
|
33
|
+
assert top_start <= start and end <= top_end
|
|
34
|
+
stack.append((end, start))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_start_snap_rechecks_outer_parent(tmp_path):
|
|
38
|
+
trace = {
|
|
39
|
+
"traceEvents": [
|
|
40
|
+
{"ph": "X", "cat": "python_function", "name": "outer", "pid": 1, "tid": 1,
|
|
41
|
+
"ts": 0.0, "dur": 3.405},
|
|
42
|
+
{"ph": "X", "cat": "python_function", "name": "inner", "pid": 1, "tid": 1,
|
|
43
|
+
"ts": 0.170, "dur": 3.205},
|
|
44
|
+
{"ph": "X", "cat": "user_annotation", "name": "layer0_forward", "pid": 1, "tid": 1,
|
|
45
|
+
"ts": 1.122, "dur": 10.0},
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
events = _run_fix(trace, tmp_path)
|
|
50
|
+
_assert_no_crossings(events)
|
|
51
|
+
|
|
52
|
+
layer = next(e for e in events if e.get("name") == "layer0_forward")
|
|
53
|
+
assert layer["tid"] == 1
|
|
54
|
+
assert layer["ts"] == pytest.approx(3.405)
|
|
55
|
+
assert layer["dur"] == pytest.approx(7.717)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_flow_endpoint_tracks_start_snap(tmp_path):
|
|
59
|
+
trace = {
|
|
60
|
+
"traceEvents": [
|
|
61
|
+
{"ph": "X", "cat": "cuda_runtime", "name": "cudaLaunchKernel", "pid": 11, "tid": 11,
|
|
62
|
+
"ts": 10.0, "dur": 2.0},
|
|
63
|
+
{"ph": "s", "id": 1, "pid": 11, "tid": 11, "ts": 10.0, "cat": "ac2g", "name": "ac2g"},
|
|
64
|
+
{"ph": "X", "cat": "kernel", "name": "kernel_a", "pid": 0, "tid": 7,
|
|
65
|
+
"ts": 100.0, "dur": 50.0},
|
|
66
|
+
{"ph": "X", "cat": "kernel", "name": "kernel_b", "pid": 0, "tid": 7,
|
|
67
|
+
"ts": 149.7, "dur": 10.0},
|
|
68
|
+
{"ph": "f", "id": 1, "pid": 0, "tid": 7, "ts": 149.7, "cat": "ac2g", "name": "ac2g", "bp": "e"},
|
|
69
|
+
]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
events = _run_fix(trace, tmp_path)
|
|
73
|
+
_assert_no_crossings(events)
|
|
74
|
+
|
|
75
|
+
kernel_b = next(e for e in events if e.get("name") == "kernel_b")
|
|
76
|
+
flow_f = next(e for e in events if e.get("ph") == "f" and e.get("id") == 1)
|
|
77
|
+
|
|
78
|
+
assert kernel_b["tid"] == 7
|
|
79
|
+
assert kernel_b["ts"] == pytest.approx(150.0)
|
|
80
|
+
assert flow_f["tid"] == 7
|
|
81
|
+
assert flow_f["ts"] == pytest.approx(150.0)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_snap_threshold_can_force_synthetic_lane(tmp_path):
|
|
85
|
+
trace = {
|
|
86
|
+
"traceEvents": [
|
|
87
|
+
{"ph": "X", "cat": "kernel", "name": "kernel_a", "pid": 0, "tid": 7,
|
|
88
|
+
"ts": 100.0, "dur": 50.0},
|
|
89
|
+
{"ph": "X", "cat": "kernel", "name": "kernel_b", "pid": 0, "tid": 7,
|
|
90
|
+
"ts": 149.7, "dur": 10.0},
|
|
91
|
+
]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
events = _run_fix(trace, tmp_path, snap_threshold_us=0.1)
|
|
95
|
+
_assert_no_crossings(events)
|
|
96
|
+
|
|
97
|
+
kernel_b = next(e for e in events if e.get("name") == "kernel_b")
|
|
98
|
+
assert kernel_b["tid"] == 7001
|
|
99
|
+
assert kernel_b["ts"] == pytest.approx(149.7)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_child_before_start_snapped_parent_is_rechecked(tmp_path):
|
|
103
|
+
trace = {
|
|
104
|
+
"traceEvents": [
|
|
105
|
+
{"ph": "X", "cat": "cpu", "name": "previous", "pid": 1, "tid": 1,
|
|
106
|
+
"ts": 0.0, "dur": 10.0},
|
|
107
|
+
{"ph": "X", "cat": "cpu", "name": "parent", "pid": 1, "tid": 1,
|
|
108
|
+
"ts": 9.9, "dur": 10.0},
|
|
109
|
+
{"ph": "X", "cat": "cpu", "name": "child", "pid": 1, "tid": 1,
|
|
110
|
+
"ts": 9.95, "dur": 1.0},
|
|
111
|
+
]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
events = _run_fix(trace, tmp_path)
|
|
115
|
+
_assert_no_crossings(events)
|
|
116
|
+
|
|
117
|
+
parent = next(e for e in events if e.get("name") == "parent")
|
|
118
|
+
child = next(e for e in events if e.get("name") == "child")
|
|
119
|
+
assert parent["tid"] == 1
|
|
120
|
+
assert parent["ts"] == pytest.approx(10.0)
|
|
121
|
+
assert child["tid"] == 1
|
|
122
|
+
assert child["ts"] == pytest.approx(10.0)
|
|
123
|
+
assert child["dur"] == pytest.approx(0.95)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_synthetic_lane_tid_avoids_real_track_collision(tmp_path):
|
|
127
|
+
trace = {
|
|
128
|
+
"traceEvents": [
|
|
129
|
+
{"ph": "X", "cat": "cpu", "name": "real_1001", "pid": 1, "tid": 1001,
|
|
130
|
+
"ts": 100.0, "dur": 1.0},
|
|
131
|
+
{"ph": "X", "cat": "cpu", "name": "a", "pid": 1, "tid": 1,
|
|
132
|
+
"ts": 0.0, "dur": 10.0},
|
|
133
|
+
{"ph": "X", "cat": "cpu", "name": "b", "pid": 1, "tid": 1,
|
|
134
|
+
"ts": 5.0, "dur": 10.0},
|
|
135
|
+
]
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
events = _run_fix(trace, tmp_path)
|
|
139
|
+
_assert_no_crossings(events)
|
|
140
|
+
|
|
141
|
+
moved = next(e for e in events if e.get("name") == "b")
|
|
142
|
+
real = next(e for e in events if e.get("name") == "real_1001")
|
|
143
|
+
lane_name = next(
|
|
144
|
+
e for e in events
|
|
145
|
+
if e.get("name") == "thread_name" and e.get("args", {}).get("name") == "tid 1 lane 1"
|
|
146
|
+
)
|
|
147
|
+
assert real["tid"] == 1001
|
|
148
|
+
assert moved["tid"] == "1 lane 1"
|
|
149
|
+
assert lane_name["tid"] == "1 lane 1"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_string_tid_can_move_to_synthetic_lane(tmp_path):
|
|
153
|
+
trace = {
|
|
154
|
+
"traceEvents": [
|
|
155
|
+
{"ph": "X", "cat": "cpu", "name": "a", "pid": 1, "tid": "worker",
|
|
156
|
+
"ts": 0.0, "dur": 10.0},
|
|
157
|
+
{"ph": "X", "cat": "cpu", "name": "b", "pid": 1, "tid": "worker",
|
|
158
|
+
"ts": 5.0, "dur": 10.0},
|
|
159
|
+
]
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
events = _run_fix(trace, tmp_path)
|
|
163
|
+
_assert_no_crossings(events)
|
|
164
|
+
|
|
165
|
+
moved = next(e for e in events if e.get("name") == "b")
|
|
166
|
+
assert moved["tid"] == "worker lane 1"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_output_json_is_pretty_printed(tmp_path):
|
|
170
|
+
trace = {
|
|
171
|
+
"schemaVersion": 1,
|
|
172
|
+
"traceEvents": [
|
|
173
|
+
{"ph": "X", "cat": "cpu", "name": "a", "pid": 1, "tid": 1,
|
|
174
|
+
"ts": 0.0, "dur": 1.0},
|
|
175
|
+
],
|
|
176
|
+
}
|
|
177
|
+
in_path = tmp_path / "in.json"
|
|
178
|
+
out_path = tmp_path / "out.json"
|
|
179
|
+
in_path.write_text(json.dumps(trace))
|
|
180
|
+
|
|
181
|
+
fix_overlaps(str(in_path), str(out_path))
|
|
182
|
+
|
|
183
|
+
text = out_path.read_text()
|
|
184
|
+
assert text.endswith("\n")
|
|
185
|
+
assert "\n \"schemaVersion\": 1,\n" in text
|
|
186
|
+
assert "\n {\n" in text
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_reports_grouped_kernel_summary_sorted_by_total_shift(tmp_path, capsys):
|
|
190
|
+
trace = {
|
|
191
|
+
"traceEvents": [
|
|
192
|
+
{"ph": "X", "cat": "kernel", "name": "kernel_a", "pid": 0, "tid": 7,
|
|
193
|
+
"ts": 100.0, "dur": 50.0},
|
|
194
|
+
{"ph": "X", "cat": "kernel", "name": "kernel_c", "pid": 0, "tid": 7,
|
|
195
|
+
"ts": 120.0, "dur": 40.0},
|
|
196
|
+
{"ph": "X", "cat": "kernel", "name": "kernel_b", "pid": 0, "tid": 7,
|
|
197
|
+
"ts": 149.7, "dur": 10.0},
|
|
198
|
+
{"ph": "X", "cat": "kernel", "name": "kernel_b", "pid": 0, "tid": 7,
|
|
199
|
+
"ts": 159.6, "dur": 10.0},
|
|
200
|
+
]
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
events = _run_fix(trace, tmp_path)
|
|
204
|
+
_assert_no_crossings(events)
|
|
205
|
+
|
|
206
|
+
out = capsys.readouterr().out
|
|
207
|
+
assert "updated 3 slice(s)" in out
|
|
208
|
+
assert "kernel timing summary (3 run(s) across 2 kernel name(s)):" in out
|
|
209
|
+
assert " kernel_b: runs=2, total delayed=400ns, total trimmed=0.000us, total boundary shift=400ns, moved=0, snapped=2" in out
|
|
210
|
+
assert " kernel_c: runs=1, total delayed=0.000us, total trimmed=0.000us, total boundary shift=0.000us, moved=1, snapped=0" in out
|
|
211
|
+
assert "moved slices" not in out
|
|
212
|
+
assert "snapped slices" not in out
|
|
213
|
+
assert out.index(" kernel_b:") < out.index(" kernel_c:")
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Command-line interface for trace-lanefix."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
|
|
5
|
+
from .core import SNAP_THRESHOLD_US, fix_overlaps
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _non_negative_float(value: str) -> float:
|
|
9
|
+
parsed = float(value)
|
|
10
|
+
if parsed < 0:
|
|
11
|
+
raise argparse.ArgumentTypeError("must be non-negative")
|
|
12
|
+
return parsed
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
16
|
+
parser = argparse.ArgumentParser(
|
|
17
|
+
prog="trace-lanefix",
|
|
18
|
+
description="Rewrite a Chrome/Kineto trace so Perfetto can render overlapping slices.",
|
|
19
|
+
)
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
"input",
|
|
22
|
+
nargs="?",
|
|
23
|
+
default="trace.json",
|
|
24
|
+
help="input Chrome trace JSON path (default: trace.json)",
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"output",
|
|
28
|
+
nargs="?",
|
|
29
|
+
default="trace.fixed.json",
|
|
30
|
+
help="output fixed trace JSON path (default: trace.fixed.json)",
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"--snap-threshold-us",
|
|
34
|
+
type=_non_negative_float,
|
|
35
|
+
default=SNAP_THRESHOLD_US,
|
|
36
|
+
help=(
|
|
37
|
+
"maximum timestamp crossing, in microseconds, to snap in place "
|
|
38
|
+
f"instead of moving to a synthetic lane (default: {SNAP_THRESHOLD_US})"
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
return parser
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def main(argv: list[str] | None = None) -> int:
|
|
45
|
+
args = build_parser().parse_args(argv)
|
|
46
|
+
fix_overlaps(args.input, args.output, snap_threshold_us=args.snap_threshold_us)
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
if __name__ == "__main__":
|
|
51
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""Rewrite a Chrome/Kineto trace so Perfetto can render every slice.
|
|
2
|
+
|
|
3
|
+
Perfetto's slice model requires strict stack nesting per (pid, tid): two
|
|
4
|
+
slices on the same track must be disjoint or one must fully contain the
|
|
5
|
+
other. Slices that *cross* a boundary get dropped
|
|
6
|
+
(SLICE_DROP_OVERLAPPING_COMPLETE_EVENT).
|
|
7
|
+
|
|
8
|
+
Strategy — resolve crossings with the minimum visual disturbance:
|
|
9
|
+
|
|
10
|
+
1. Nesting-aware placement. Each lane keeps a live stack of open frames.
|
|
11
|
+
An event joins a lane if, after popping closed frames, the lane is
|
|
12
|
+
empty or the current top strictly contains the event. Nested children
|
|
13
|
+
stay on their parent's lane.
|
|
14
|
+
|
|
15
|
+
2. Snap tiny crossings instead of spawning a lane. Two directions are
|
|
16
|
+
considered for every crossing, and the smaller perturbation wins:
|
|
17
|
+
- end-snap: clamp the child's end down to its parent's end (nests).
|
|
18
|
+
- start-snap: push the child's start forward to its parent's end
|
|
19
|
+
(turns it into a disjoint sibling).
|
|
20
|
+
The smallest shift is the one that actually represents CUPTI/Kineto
|
|
21
|
+
timestamp noise; end-snap fits float-epsilon parent-end overruns,
|
|
22
|
+
start-snap fits the typical GPU-stream case where two kernels appear
|
|
23
|
+
to overlap by ~100ns at the boundary even though they're back-to-back
|
|
24
|
+
on the stream. Only shifts below the configured snap threshold are applied, so
|
|
25
|
+
genuine concurrent slices (ProfilerStep#N, kernels on separate streams
|
|
26
|
+
sharing a track) still go to a secondary lane.
|
|
27
|
+
|
|
28
|
+
3. No category gate. Every track runs through the same pass. GPU streams
|
|
29
|
+
behave identically to the old algorithm (no nesting ⇒ same lane
|
|
30
|
+
decisions) and CPU threads no longer explode.
|
|
31
|
+
|
|
32
|
+
4. Collision-free lane ids. Synthetic tids keep the historical numeric
|
|
33
|
+
`tid * 1000 + lane` form when that id is available, and fall back to a
|
|
34
|
+
string id when it would collide with a real track.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
import json
|
|
38
|
+
from collections import defaultdict
|
|
39
|
+
|
|
40
|
+
SNAP_THRESHOLD_US = 5.0 # absolute shift below which we clamp instead of laning.
|
|
41
|
+
# Calibrated against CUPTI boundary noise between consecutive same-stream kernels
|
|
42
|
+
# (observed max ~2.5us on nvjet tensor-core kernels). Kernels on one CUDA stream
|
|
43
|
+
# cannot physically overlap, so any sub-threshold overlap is timing noise.
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _format_us(value: float) -> str:
|
|
47
|
+
if 0 < abs(value) < 1.0:
|
|
48
|
+
return f"{value * 1000.0:.1f}".rstrip("0").rstrip(".") + "ns"
|
|
49
|
+
return f"{value:.3f}us"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _describe_change(change: dict) -> str:
|
|
53
|
+
start_delta = change["new_start"] - change["orig_start"]
|
|
54
|
+
end_delta = change["new_end"] - change["orig_end"]
|
|
55
|
+
parts = []
|
|
56
|
+
|
|
57
|
+
if change["orig_tid"] != change["new_tid"]:
|
|
58
|
+
parts.append(f"moved to lane {change['lane_idx']} (tid {change['orig_tid']} -> {change['new_tid']})")
|
|
59
|
+
|
|
60
|
+
if start_delta == end_delta and start_delta != 0:
|
|
61
|
+
parts.append(f"shifted {'later' if start_delta > 0 else 'earlier'} by {_format_us(abs(start_delta))}")
|
|
62
|
+
else:
|
|
63
|
+
if start_delta > 0:
|
|
64
|
+
parts.append(f"start delayed by {_format_us(start_delta)}")
|
|
65
|
+
elif start_delta < 0:
|
|
66
|
+
parts.append(f"start moved earlier by {_format_us(-start_delta)}")
|
|
67
|
+
|
|
68
|
+
if end_delta > 0:
|
|
69
|
+
parts.append(f"end extended by {_format_us(end_delta)}")
|
|
70
|
+
elif end_delta < 0:
|
|
71
|
+
parts.append(f"end trimmed by {_format_us(-end_delta)}")
|
|
72
|
+
|
|
73
|
+
return ", ".join(parts) or "timing unchanged"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _change_shift_stats(change: dict) -> dict:
|
|
77
|
+
start_delta = change["new_start"] - change["orig_start"]
|
|
78
|
+
end_delta = change["new_end"] - change["orig_end"]
|
|
79
|
+
return {
|
|
80
|
+
"start_delayed": max(0.0, start_delta),
|
|
81
|
+
"start_earlier": max(0.0, -start_delta),
|
|
82
|
+
"end_extended": max(0.0, end_delta),
|
|
83
|
+
"end_trimmed": max(0.0, -end_delta),
|
|
84
|
+
"boundary_shift": abs(start_delta) + abs(end_delta),
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _summarize_changes_by_name(changes: list[dict]) -> list[dict]:
|
|
89
|
+
summaries = {}
|
|
90
|
+
for change in changes:
|
|
91
|
+
summary = summaries.setdefault(
|
|
92
|
+
change["name"],
|
|
93
|
+
{
|
|
94
|
+
"name": change["name"],
|
|
95
|
+
"runs": 0,
|
|
96
|
+
"start_delayed": 0.0,
|
|
97
|
+
"start_earlier": 0.0,
|
|
98
|
+
"end_extended": 0.0,
|
|
99
|
+
"end_trimmed": 0.0,
|
|
100
|
+
"boundary_shift": 0.0,
|
|
101
|
+
"max_boundary_shift": 0.0,
|
|
102
|
+
"moved": 0,
|
|
103
|
+
"snapped": 0,
|
|
104
|
+
},
|
|
105
|
+
)
|
|
106
|
+
shifts = _change_shift_stats(change)
|
|
107
|
+
summary["runs"] += 1
|
|
108
|
+
summary["start_delayed"] += shifts["start_delayed"]
|
|
109
|
+
summary["start_earlier"] += shifts["start_earlier"]
|
|
110
|
+
summary["end_extended"] += shifts["end_extended"]
|
|
111
|
+
summary["end_trimmed"] += shifts["end_trimmed"]
|
|
112
|
+
summary["boundary_shift"] += shifts["boundary_shift"]
|
|
113
|
+
summary["max_boundary_shift"] = max(summary["max_boundary_shift"], shifts["boundary_shift"])
|
|
114
|
+
summary["moved"] += int(change["orig_tid"] != change["new_tid"])
|
|
115
|
+
summary["snapped"] += int(change["snap_kind"] is not None)
|
|
116
|
+
|
|
117
|
+
return sorted(
|
|
118
|
+
summaries.values(),
|
|
119
|
+
key=lambda summary: (
|
|
120
|
+
-summary["boundary_shift"],
|
|
121
|
+
-summary["runs"],
|
|
122
|
+
-summary["moved"],
|
|
123
|
+
-summary["snapped"],
|
|
124
|
+
-summary["max_boundary_shift"],
|
|
125
|
+
summary["name"],
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _allocate_lane_tid(pid, tid, lane_idx: int, used_tracks: set[tuple]) -> int | str:
|
|
131
|
+
if type(tid) is int:
|
|
132
|
+
candidate = tid * 1000 + lane_idx
|
|
133
|
+
if (pid, candidate) not in used_tracks:
|
|
134
|
+
used_tracks.add((pid, candidate))
|
|
135
|
+
return candidate
|
|
136
|
+
|
|
137
|
+
base = f"{tid} lane {lane_idx}"
|
|
138
|
+
candidate = base
|
|
139
|
+
suffix = 2
|
|
140
|
+
while (pid, candidate) in used_tracks:
|
|
141
|
+
candidate = f"{base} #{suffix}"
|
|
142
|
+
suffix += 1
|
|
143
|
+
|
|
144
|
+
used_tracks.add((pid, candidate))
|
|
145
|
+
return candidate
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def fix_overlaps(in_path: str, out_path: str, snap_threshold_us: float = SNAP_THRESHOLD_US) -> None:
|
|
149
|
+
if snap_threshold_us < 0:
|
|
150
|
+
raise ValueError("snap_threshold_us must be non-negative")
|
|
151
|
+
|
|
152
|
+
with open(in_path) as f:
|
|
153
|
+
trace = json.load(f)
|
|
154
|
+
|
|
155
|
+
events = trace.get("traceEvents", trace) if isinstance(trace, dict) else trace
|
|
156
|
+
|
|
157
|
+
slices, flows, others = [], [], []
|
|
158
|
+
for e in events:
|
|
159
|
+
ph = e.get("ph")
|
|
160
|
+
if ph == "X" and "dur" in e:
|
|
161
|
+
slices.append(e)
|
|
162
|
+
elif ph in ("s", "t", "f"):
|
|
163
|
+
flows.append(e)
|
|
164
|
+
else:
|
|
165
|
+
others.append(e)
|
|
166
|
+
|
|
167
|
+
used_tracks = {
|
|
168
|
+
(e.get("pid"), e.get("tid"))
|
|
169
|
+
for e in events
|
|
170
|
+
if "pid" in e and "tid" in e
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
by_track = defaultdict(list)
|
|
174
|
+
for e in slices:
|
|
175
|
+
by_track[(e.get("pid"), e.get("tid"))].append(e)
|
|
176
|
+
print(f"processing {len(by_track)} track(s)")
|
|
177
|
+
|
|
178
|
+
lane_names = {}
|
|
179
|
+
lane_tids = {}
|
|
180
|
+
endpoint_rewrites = {}
|
|
181
|
+
snapped_total = 0
|
|
182
|
+
per_track_report = []
|
|
183
|
+
changed_slices = []
|
|
184
|
+
|
|
185
|
+
for (pid, tid), group in by_track.items():
|
|
186
|
+
# Outer-first: earliest start, longest dur on ties, so parents are
|
|
187
|
+
# placed before their children.
|
|
188
|
+
group.sort(key=lambda e: (e["ts"], -e["dur"]))
|
|
189
|
+
lanes = [] # each lane is a stack of (end_ts, start_ts)
|
|
190
|
+
moved = 0
|
|
191
|
+
snapped = 0
|
|
192
|
+
|
|
193
|
+
for e in group:
|
|
194
|
+
orig_tid = e["tid"]
|
|
195
|
+
orig_start = e["ts"]
|
|
196
|
+
orig_dur = e["dur"]
|
|
197
|
+
orig_end = orig_start + orig_dur
|
|
198
|
+
start = orig_start
|
|
199
|
+
end = start + orig_dur
|
|
200
|
+
placed_lane = None
|
|
201
|
+
snap_kind = None
|
|
202
|
+
|
|
203
|
+
for lane_idx, stack in enumerate(lanes):
|
|
204
|
+
placement_resolved = False
|
|
205
|
+
while not placement_resolved:
|
|
206
|
+
while stack and stack[-1][0] <= start:
|
|
207
|
+
stack.pop()
|
|
208
|
+
|
|
209
|
+
if not stack:
|
|
210
|
+
stack.append((end, start))
|
|
211
|
+
placed_lane = lane_idx
|
|
212
|
+
placement_resolved = True
|
|
213
|
+
break
|
|
214
|
+
|
|
215
|
+
top_end, top_start = stack[-1]
|
|
216
|
+
|
|
217
|
+
if lane_idx != 0:
|
|
218
|
+
if top_start <= start and end <= top_end:
|
|
219
|
+
stack.append((end, start))
|
|
220
|
+
placed_lane = lane_idx
|
|
221
|
+
placement_resolved = True
|
|
222
|
+
break # Only snap on the primary lane
|
|
223
|
+
|
|
224
|
+
if start < top_start:
|
|
225
|
+
start_shift = top_start - start
|
|
226
|
+
if start_shift < snap_threshold_us:
|
|
227
|
+
start = top_start
|
|
228
|
+
e["ts"] = top_start
|
|
229
|
+
e["dur"] = max(0.0, end - start)
|
|
230
|
+
snapped += 1
|
|
231
|
+
snap_kind = "start-snap"
|
|
232
|
+
continue
|
|
233
|
+
break
|
|
234
|
+
|
|
235
|
+
if end <= top_end:
|
|
236
|
+
stack.append((end, start))
|
|
237
|
+
placed_lane = lane_idx
|
|
238
|
+
placement_resolved = True
|
|
239
|
+
break
|
|
240
|
+
|
|
241
|
+
end_shift = end - top_end
|
|
242
|
+
start_shift = top_end - start
|
|
243
|
+
|
|
244
|
+
if 0 < end_shift <= start_shift and end_shift < snap_threshold_us:
|
|
245
|
+
end = top_end
|
|
246
|
+
e["dur"] = max(0.0, end - start)
|
|
247
|
+
snapped += 1
|
|
248
|
+
snap_kind = "end-snap"
|
|
249
|
+
elif 0 < start_shift < snap_threshold_us:
|
|
250
|
+
start = top_end
|
|
251
|
+
e["ts"] = top_end
|
|
252
|
+
e["dur"] = max(0.0, end - start)
|
|
253
|
+
snapped += 1
|
|
254
|
+
snap_kind = "start-snap"
|
|
255
|
+
else:
|
|
256
|
+
break # Cannot snap, try next lane
|
|
257
|
+
|
|
258
|
+
if placed_lane is not None:
|
|
259
|
+
break
|
|
260
|
+
|
|
261
|
+
if placed_lane is None:
|
|
262
|
+
placed_lane = len(lanes)
|
|
263
|
+
lanes.append([(end, start)])
|
|
264
|
+
|
|
265
|
+
if placed_lane > 0:
|
|
266
|
+
lane_key = (pid, tid, placed_lane)
|
|
267
|
+
if lane_key not in lane_tids:
|
|
268
|
+
lane_tids[lane_key] = _allocate_lane_tid(pid, tid, placed_lane, used_tracks)
|
|
269
|
+
new_tid = lane_tids[lane_key]
|
|
270
|
+
e["tid"] = new_tid
|
|
271
|
+
lane_names[(pid, new_tid)] = (tid, placed_lane)
|
|
272
|
+
moved += 1
|
|
273
|
+
|
|
274
|
+
new_start = e["ts"]
|
|
275
|
+
new_dur = e["dur"]
|
|
276
|
+
new_end = new_start + new_dur
|
|
277
|
+
|
|
278
|
+
if e["tid"] != orig_tid or new_start != orig_start or new_dur != orig_dur:
|
|
279
|
+
changed_slices.append({
|
|
280
|
+
"name": e.get("name", "<unnamed>"),
|
|
281
|
+
"cat": e.get("cat", ""),
|
|
282
|
+
"pid": pid,
|
|
283
|
+
"orig_tid": orig_tid,
|
|
284
|
+
"new_tid": e["tid"],
|
|
285
|
+
"orig_start": orig_start,
|
|
286
|
+
"new_start": new_start,
|
|
287
|
+
"orig_dur": orig_dur,
|
|
288
|
+
"new_dur": new_dur,
|
|
289
|
+
"orig_end": orig_end,
|
|
290
|
+
"new_end": new_end,
|
|
291
|
+
"snap_kind": snap_kind,
|
|
292
|
+
"lane_idx": placed_lane,
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
if e["tid"] != orig_tid or new_start != orig_start:
|
|
296
|
+
endpoint_rewrites[(pid, orig_tid, orig_start)] = (e["tid"], e["ts"])
|
|
297
|
+
|
|
298
|
+
snapped_total += snapped
|
|
299
|
+
if len(lanes) > 1 or snapped:
|
|
300
|
+
per_track_report.append((pid, tid, len(lanes), moved, snapped))
|
|
301
|
+
|
|
302
|
+
for pid, tid, n_lanes, moved, snapped in per_track_report:
|
|
303
|
+
print(f" pid={pid} tid={tid}: {n_lanes} lane(s), {moved} slice(s) moved, {snapped} snapped")
|
|
304
|
+
print(f"snapped {snapped_total} sub-{snap_threshold_us}us crossing(s)")
|
|
305
|
+
|
|
306
|
+
if changed_slices:
|
|
307
|
+
total_start_shift = sum(abs(c["new_start"] - c["orig_start"]) for c in changed_slices)
|
|
308
|
+
total_end_shift = sum(abs(c["new_end"] - c["orig_end"]) for c in changed_slices)
|
|
309
|
+
|
|
310
|
+
print(
|
|
311
|
+
f"updated {len(changed_slices)} slice(s): "
|
|
312
|
+
f"total |start shift|={_format_us(total_start_shift)}, "
|
|
313
|
+
f"total |end shift|={_format_us(total_end_shift)}, "
|
|
314
|
+
f"total |boundary shift|={_format_us(total_start_shift + total_end_shift)}"
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
kernel_changes = [c for c in changed_slices if c["cat"] == "kernel"]
|
|
318
|
+
if kernel_changes:
|
|
319
|
+
kernel_summaries = _summarize_changes_by_name(kernel_changes)
|
|
320
|
+
print(
|
|
321
|
+
"kernel timing summary "
|
|
322
|
+
f"({len(kernel_changes)} run(s) across {len(kernel_summaries)} kernel name(s)):"
|
|
323
|
+
)
|
|
324
|
+
for summary in kernel_summaries:
|
|
325
|
+
print(
|
|
326
|
+
f" {summary['name']}: runs={summary['runs']}, "
|
|
327
|
+
f"total delayed={_format_us(summary['start_delayed'])}, "
|
|
328
|
+
f"total trimmed={_format_us(summary['end_trimmed'])}, "
|
|
329
|
+
f"total boundary shift={_format_us(summary['boundary_shift'])}, "
|
|
330
|
+
f"moved={summary['moved']}, snapped={summary['snapped']}"
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
categories = [
|
|
334
|
+
("moved slices", lambda c: c["cat"] != "kernel" and c["orig_tid"] != c["new_tid"]),
|
|
335
|
+
("snapped slices", lambda c: c["cat"] != "kernel" and c["snap_kind"] is not None),
|
|
336
|
+
]
|
|
337
|
+
|
|
338
|
+
for title, condition in categories:
|
|
339
|
+
filtered = [c for c in changed_slices if condition(c)]
|
|
340
|
+
if filtered:
|
|
341
|
+
print(f"{title} ({len(filtered)}):")
|
|
342
|
+
for c in filtered:
|
|
343
|
+
tid_info = f" tid={c['new_tid']}" if title == "snapped slices" else ""
|
|
344
|
+
print(f" {c['name']} pid={c['pid']}{tid_info}: {_describe_change(c)}")
|
|
345
|
+
|
|
346
|
+
flow_rewrites = 0
|
|
347
|
+
for fe in flows:
|
|
348
|
+
key = (fe.get("pid"), fe.get("tid"), fe.get("ts"))
|
|
349
|
+
if key in endpoint_rewrites:
|
|
350
|
+
fe["tid"], fe["ts"] = endpoint_rewrites[key]
|
|
351
|
+
flow_rewrites += 1
|
|
352
|
+
|
|
353
|
+
print(f"rewrote {flow_rewrites} flow endpoint(s) onto rewritten slice starts/lanes")
|
|
354
|
+
|
|
355
|
+
for (pid, new_tid), (orig_tid, lane_idx) in lane_names.items():
|
|
356
|
+
others.extend([
|
|
357
|
+
{
|
|
358
|
+
"name": "thread_name", "ph": "M", "pid": pid, "tid": new_tid,
|
|
359
|
+
"args": {"name": f"tid {orig_tid} lane {lane_idx}"},
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
"name": "thread_sort_index", "ph": "M", "pid": pid, "tid": new_tid,
|
|
363
|
+
"args": {"sort_index": lane_idx},
|
|
364
|
+
}
|
|
365
|
+
])
|
|
366
|
+
|
|
367
|
+
out_events = others + slices + flows
|
|
368
|
+
if isinstance(trace, dict):
|
|
369
|
+
trace["traceEvents"] = out_events
|
|
370
|
+
else:
|
|
371
|
+
trace = out_events
|
|
372
|
+
|
|
373
|
+
with open(out_path, "w") as f:
|
|
374
|
+
json.dump(trace, f, indent=2)
|
|
375
|
+
f.write("\n")
|
|
376
|
+
|
|
377
|
+
print(f"wrote {out_path}: {len(slices)} slices, {len(lane_names)} new lanes")
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: trace-lanefix
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Fix overlapping Chrome/Kineto trace slices so Perfetto can render them.
|
|
5
|
+
Author: Roman Ralovets
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/ralovets/trace-lanefix
|
|
8
|
+
Project-URL: Issues, https://github.com/ralovets/trace-lanefix/issues
|
|
9
|
+
Keywords: chrome-trace,kineto,perfetto,profiling,pytorch
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Software Development :: Debuggers
|
|
18
|
+
Classifier: Topic :: System :: Benchmark
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# trace-lanefix
|
|
27
|
+
|
|
28
|
+
`trace-lanefix` rewrites Chrome/Kineto trace JSON so Perfetto can render slices
|
|
29
|
+
that would otherwise be dropped because of crossing overlaps on the same
|
|
30
|
+
`(pid, tid)` track.
|
|
31
|
+
|
|
32
|
+
It keeps properly nested slices on the same track, snaps tiny timestamp-boundary
|
|
33
|
+
crossings, and moves genuine overlaps to synthetic lanes with collision-free
|
|
34
|
+
thread ids.
|
|
35
|
+
|
|
36
|
+
## Before / After
|
|
37
|
+
|
|
38
|
+
Before:
|
|
39
|
+
|
|
40
|
+
<img src="https://raw.githubusercontent.com/ralovets/trace-lanefix/main/assets/profile-before.png" alt="Profile before lane fix" width="100%">
|
|
41
|
+
|
|
42
|
+
After:
|
|
43
|
+
|
|
44
|
+
<img src="https://raw.githubusercontent.com/ralovets/trace-lanefix/main/assets/profile-after.png" alt="Profile after lane fix" width="100%">
|
|
45
|
+
|
|
46
|
+
In Perfetto's trace overview, the rewritten trace reports `TRACE ERRORS=0`
|
|
47
|
+
instead of `TRACE ERRORS=6`, while `IMPORT ERRORS` and `DATA LOSSES` remain
|
|
48
|
+
at `0`. In this example, the JSON trace size changed from `452.76 KiB` before
|
|
49
|
+
rewriting (`463,629` bytes) to `653.96 KiB` after rewriting (`669,651`
|
|
50
|
+
bytes) because the output is pretty-printed for readability.
|
|
51
|
+
|
|
52
|
+
## CLI
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
trace-lanefix input.json output.fixed.json
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Tiny crossings below `5.0us` are snapped by default. To use a different snap
|
|
59
|
+
threshold:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
trace-lanefix --snap-threshold-us 2.5 input.json output.fixed.json
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Example output:
|
|
66
|
+
|
|
67
|
+
This run shows the two repair paths: tiny sub-5.0us timestamp crossings are
|
|
68
|
+
snapped in place, while one genuine overlap is moved to a synthetic lane. Flow
|
|
69
|
+
endpoints are then rewritten to follow the adjusted slice starts and lanes.
|
|
70
|
+
|
|
71
|
+
```text
|
|
72
|
+
trace-lanefix trace.json trace_fixed.json
|
|
73
|
+
processing 3 track(s)
|
|
74
|
+
pid=7334 tid=7334: 2 lane(s), 1 slice(s) moved, 11 snapped
|
|
75
|
+
pid=0 tid=7: 1 lane(s), 0 slice(s) moved, 5 snapped
|
|
76
|
+
snapped 16 sub-5.0us crossing(s)
|
|
77
|
+
updated 15 slice(s): total |start shift|=6.195us, total |end shift|=5.240us, total |boundary shift|=11.436us
|
|
78
|
+
kernel timing summary (5 run(s) across 3 kernel name(s)):
|
|
79
|
+
nvjet_sm120_tst_mma_256x112x64_2_64x56x64_tmaAB_bz_TNNN: runs=2, total delayed=2.848us, total trimmed=0.000us, total boundary shift=2.848us, moved=0, snapped=2
|
|
80
|
+
nvjet_sm120_tst_mma_96x256x64_2_48x64x64_tmaAB_bz_TNNN: runs=2, total delayed=480ns, total trimmed=0.000us, total boundary shift=480ns, moved=0, snapped=2
|
|
81
|
+
nvjet_sm120_tst_mma_128x256x64_2_64x64x64_tmaAB_bz_TNNN: runs=1, total delayed=384ns, total trimmed=0.000us, total boundary shift=384ns, moved=0, snapped=1
|
|
82
|
+
moved slices (1):
|
|
83
|
+
ProfilerStep#5 pid=7334: moved to lane 1 (tid 7334 -> 7334001)
|
|
84
|
+
snapped slices (9):
|
|
85
|
+
layer0_forward pid=7334 tid=7334: start delayed by 2.483us
|
|
86
|
+
torch/autograd/profiler.py(843): __exit__ pid=7334 tid=7334: end trimmed by 2.855us
|
|
87
|
+
torch/_ops.py(1079): __call__ pid=7334 tid=7334: end trimmed by 1.222us
|
|
88
|
+
<built-in method of pybind11_builtins.pybind11_detail_function_record_v1_system_libstdcpp_gxx_abi_1xxx_use_cxx11_abi_1 object at 0x719b5a7004b0> pid=7334 tid=7334: end trimmed by 1.162us
|
|
89
|
+
contextlib.py(141): __exit__ pid=7334 tid=7334: end trimmed by 0.2ns
|
|
90
|
+
profiling.py(19): trace pid=7334 tid=7334: end trimmed by 0.2ns
|
|
91
|
+
torch/profiler/profiler.py(912): _transit_action pid=7334 tid=7334: end trimmed by 0.2ns
|
|
92
|
+
torch/cuda/__init__.py(1161): synchronize pid=7334 tid=7334: end trimmed by 0.2ns
|
|
93
|
+
torch/cuda/__init__.py(609): __exit__ pid=7334 tid=7334: end trimmed by 0.2ns
|
|
94
|
+
rewrote 5 flow endpoint(s) onto rewritten slice starts/lanes
|
|
95
|
+
wrote trace_fixed.json: 988 slices, 1 new lanes
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Python API
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from trace_lanefix import fix_overlaps
|
|
102
|
+
|
|
103
|
+
fix_overlaps("input.json", "output.fixed.json")
|
|
104
|
+
fix_overlaps("input.json", "output.fixed.json", snap_threshold_us=2.5)
|
|
105
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
tests/test_fix_overlaps.py
|
|
5
|
+
trace_lanefix/__init__.py
|
|
6
|
+
trace_lanefix/cli.py
|
|
7
|
+
trace_lanefix/core.py
|
|
8
|
+
trace_lanefix.egg-info/PKG-INFO
|
|
9
|
+
trace_lanefix.egg-info/SOURCES.txt
|
|
10
|
+
trace_lanefix.egg-info/dependency_links.txt
|
|
11
|
+
trace_lanefix.egg-info/entry_points.txt
|
|
12
|
+
trace_lanefix.egg-info/requires.txt
|
|
13
|
+
trace_lanefix.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
trace_lanefix
|