59a0a3844c
PR Test AMD / cancel-on-close (push) Has been skipped
PR Test NVIDIA ARM / scan (push) Has been skipped
PR Test NVIDIA / cancel-on-close (push) Has been skipped
PR Test AMD / scan (push) Has been skipped
PR Test NVIDIA ARM / cancel-on-close (push) Has been skipped
PR Test NVIDIA / scan (push) Has been skipped
Release Docker Images / build (cu129-torch-2.11.0) (push) Has been skipped
Release Docker Images / build (cu130-torch-2.11.0) (push) Has been skipped
Release PyPI / publish (push) Has been skipped
Scheduler Python Test / test (push) Successful in 27m19s
Docs / build (push) Successful in 28m8s
Scheduler C++ Test / test (push) Successful in 28m19s
Scheduler C++ Test / test-flat (push) Successful in 28m18s
Docs / deploy (push) Has been cancelled
PR Test AMD / finish (push) Has been cancelled
PR Test NVIDIA / finish (push) Has been cancelled
PR Test NVIDIA ARM / finish (push) Has been cancelled
PR Test NVIDIA ARM / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
PR Test AMD / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
PR Test NVIDIA / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
269 lines
7.8 KiB
Python
269 lines
7.8 KiB
Python
# Copyright (c) 2026 LightSeek Foundation
|
|
#
|
|
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
# of this software and associated documentation files (the "Software"), to deal
|
|
# in the Software without restriction, including without limitation the rights
|
|
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
# copies of the Software, and to permit persons to whom the Software is
|
|
# furnished to do so, subject to the following conditions:
|
|
#
|
|
# The above copyright notice and this permission notice shall be included in
|
|
# all copies or substantial portions of the Software.
|
|
#
|
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
# SOFTWARE.
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import pytest
|
|
import tokenspeed_kernel.profiling as profiling
|
|
import torch
|
|
|
|
|
|
class _FakeScope:
|
|
def __init__(self, log: list[str]):
|
|
self._log = log
|
|
|
|
def __enter__(self):
|
|
self._log.append("enter")
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
_ = exc_type, exc, tb
|
|
self._log.append("exit")
|
|
|
|
|
|
class _FakeProton:
|
|
def __init__(self):
|
|
self.start_calls: list[tuple] = []
|
|
self.finalize_calls: list[tuple] = []
|
|
self.scope_calls: list[tuple[str, dict[str, object]]] = []
|
|
self.scope_log: list[str] = []
|
|
|
|
def start(self, *args, **kwargs):
|
|
self.start_calls.append((args, kwargs))
|
|
return 123
|
|
|
|
def finalize(self, *args, **kwargs):
|
|
self.finalize_calls.append((args, kwargs))
|
|
|
|
def scope(self, name: str, metrics: dict[str, object] | None = None):
|
|
self.scope_calls.append((name, {} if metrics is None else dict(metrics)))
|
|
return _FakeScope(self.scope_log)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_state():
|
|
profiling.stop_profiling()
|
|
profiling.ProfilingState.reset()
|
|
capture = profiling.ShapeCapture.get()
|
|
capture.enabled = False
|
|
capture.clear()
|
|
profiling.ShapeCapture.reset()
|
|
profiling._BOOTSTRAPPED = False
|
|
yield
|
|
profiling.stop_profiling()
|
|
profiling.ProfilingState.reset()
|
|
capture = profiling.ShapeCapture.get()
|
|
capture.enabled = False
|
|
capture.clear()
|
|
profiling.ShapeCapture.reset()
|
|
profiling._BOOTSTRAPPED = False
|
|
|
|
|
|
def test_start_and_stop_calls_proton(monkeypatch):
|
|
fake = _FakeProton()
|
|
monkeypatch.setattr(profiling, "_HAS_PROTON", True)
|
|
monkeypatch.setattr(profiling, "proton", fake)
|
|
|
|
cfg = profiling.ProfilingConfig(
|
|
output="run_profile",
|
|
data="trace",
|
|
backend="cupti",
|
|
mode="pcsampling",
|
|
hook="triton",
|
|
output_format="chrome_trace",
|
|
)
|
|
session = profiling.start_profiling(cfg)
|
|
assert session == 123
|
|
assert profiling.ProfilingState.get().active
|
|
|
|
profiling.stop_profiling()
|
|
assert not profiling.ProfilingState.get().active
|
|
|
|
assert fake.start_calls == [
|
|
(
|
|
("run_profile",),
|
|
{
|
|
"data": "trace",
|
|
"backend": "cupti",
|
|
"mode": "pcsampling",
|
|
"hook": "triton",
|
|
},
|
|
)
|
|
]
|
|
assert fake.finalize_calls == [((123, "chrome_trace"), {})]
|
|
|
|
|
|
def test_start_warns_when_proton_missing(monkeypatch):
|
|
monkeypatch.setattr(profiling, "_HAS_PROTON", False)
|
|
monkeypatch.setattr(profiling, "proton", None)
|
|
|
|
with pytest.warns(UserWarning, match="Proton not installed"):
|
|
session = profiling.start_profiling()
|
|
|
|
assert session is None
|
|
assert not profiling.ProfilingState.get().active
|
|
|
|
|
|
def test_kernel_scope_noop_when_inactive():
|
|
scope = profiling.kernel_scope(
|
|
"gemm",
|
|
"mm",
|
|
torch.float16,
|
|
kernel_name="triton_mm_fp8_scaled",
|
|
M=16,
|
|
N=32,
|
|
K=64,
|
|
)
|
|
assert scope is profiling._NOOP_SCOPE
|
|
with scope:
|
|
pass
|
|
|
|
|
|
def test_kernel_scope_uses_proton_scope_when_active(monkeypatch):
|
|
fake = _FakeProton()
|
|
monkeypatch.setattr(profiling, "_HAS_PROTON", True)
|
|
monkeypatch.setattr(profiling, "proton", fake)
|
|
profiling.start_profiling()
|
|
|
|
with profiling.kernel_scope(
|
|
"gemm",
|
|
"mm",
|
|
torch.float16,
|
|
kernel_name="triton_mm_fp8_scaled",
|
|
M=32,
|
|
N=64,
|
|
K=128,
|
|
):
|
|
pass
|
|
|
|
assert fake.scope_calls == [
|
|
(
|
|
"gemm.mm[triton_mm_fp8_scaled]",
|
|
{
|
|
"dtype": "torch.float16",
|
|
"M": 32,
|
|
"N": 64,
|
|
"K": 128,
|
|
},
|
|
)
|
|
]
|
|
assert fake.scope_log == ["enter", "exit"]
|
|
|
|
|
|
def test_bootstrap_reads_env_and_only_runs_once(monkeypatch):
|
|
fake = _FakeProton()
|
|
registrations: list[object] = []
|
|
monkeypatch.setattr(profiling, "_HAS_PROTON", True)
|
|
monkeypatch.setattr(profiling, "proton", fake)
|
|
monkeypatch.setattr(
|
|
profiling.atexit,
|
|
"register",
|
|
lambda fn: registrations.append(fn),
|
|
)
|
|
monkeypatch.setenv("TOKENSPEED_KERNEL_PROFILE", "1")
|
|
monkeypatch.setenv("TOKENSPEED_KERNEL_PROFILE_OUTPUT", "env_profile")
|
|
monkeypatch.setenv("TOKENSPEED_KERNEL_PROFILE_DATA", "trace")
|
|
|
|
profiling.bootstrap_profiling_from_env()
|
|
profiling.bootstrap_profiling_from_env()
|
|
|
|
assert len(fake.start_calls) == 1
|
|
assert fake.start_calls[0] == (
|
|
("env_profile",),
|
|
{
|
|
"data": "trace",
|
|
"backend": None,
|
|
"mode": None,
|
|
"hook": "triton",
|
|
},
|
|
)
|
|
assert len(registrations) == 2
|
|
|
|
|
|
def test_shape_capture_records_dump_and_clear(tmp_path):
|
|
output = tmp_path / "shapes.json"
|
|
|
|
profiling.start_shape_capture()
|
|
cap = profiling.ShapeCapture.get()
|
|
cap.record("gemm", "mm", "k1", torch.float16, {"M": 16, "N": 32, "K": 64})
|
|
cap.record(
|
|
"attention",
|
|
"decode",
|
|
"k2",
|
|
torch.bfloat16,
|
|
{"batch": 2, "seq_len": 128, "num_q_heads": 8, "head_dim": 64},
|
|
)
|
|
|
|
profiling.stop_shape_capture(output)
|
|
|
|
payload = json.loads(output.read_text())
|
|
assert len(payload) == 2
|
|
assert payload[0]["family"] == "gemm"
|
|
assert payload[0]["shape_params"] == {"K": 64, "M": 16, "N": 32}
|
|
assert payload[1]["family"] == "attention"
|
|
assert payload[1]["shape_params"]["batch"] == 2
|
|
assert not profiling.ShapeCapture.get().enabled
|
|
|
|
|
|
def test_shape_capture_context_manager_writes_output(tmp_path):
|
|
output = tmp_path / "ctx_shapes.json"
|
|
|
|
with profiling.shape_capture(output):
|
|
profiling.ShapeCapture.get().record(
|
|
"gemm",
|
|
"mm",
|
|
"k1",
|
|
torch.float32,
|
|
{"M": 8, "N": 8, "K": 8},
|
|
)
|
|
|
|
payload = json.loads(output.read_text())
|
|
assert len(payload) == 1
|
|
assert payload[0]["kernel_name"] == "k1"
|
|
|
|
|
|
def test_bootstrap_shape_capture_from_env_and_atexit_dump(tmp_path, monkeypatch):
|
|
registrations: list[object] = []
|
|
output = tmp_path / "env_shapes.json"
|
|
|
|
monkeypatch.setattr(
|
|
profiling.atexit,
|
|
"register",
|
|
lambda fn: registrations.append(fn),
|
|
)
|
|
monkeypatch.setenv("TOKENSPEED_KERNEL_CAPTURE_SHAPES", "1")
|
|
monkeypatch.setenv("TOKENSPEED_KERNEL_CAPTURE_SHAPES_OUTPUT", str(output))
|
|
|
|
profiling.bootstrap_profiling_from_env()
|
|
|
|
cap = profiling.ShapeCapture.get()
|
|
assert cap.enabled
|
|
cap.record("gemm", "mm", "k1", torch.float16, {"M": 4, "N": 4, "K": 4})
|
|
|
|
for fn in registrations:
|
|
fn()
|
|
|
|
payload = json.loads(output.read_text())
|
|
assert len(payload) == 1
|
|
assert payload[0]["shape_params"] == {"K": 4, "M": 4, "N": 4}
|
|
assert not profiling.ShapeCapture.get().enabled
|