Skip to content

Commit 608bc1f

Browse files
committed
add html save functionality
1 parent a1355c0 commit 608bc1f

File tree

9 files changed

+348
-8
lines changed

9 files changed

+348
-8
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ dependencies = [
5656
"pyyaml>=6.0.0",
5757
"rich",
5858
"transformers",
59+
"pyhumps>=3.8.0",
5960
]
6061

6162
[project.optional-dependencies]

src/guidellm/__main__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ def cli():
206206
help=(
207207
"The path to save the output to. If it is a directory, "
208208
"it will save benchmarks.json under it. "
209-
"Otherwise, json, yaml, or csv files are supported for output types "
209+
"Otherwise, json, yaml, csv, or html files are supported for output types "
210210
"which will be read from the extension for the file path."
211211
),
212212
)

src/guidellm/benchmark/output.py

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import csv
22
import json
33
import math
4+
import humps
45
from collections import OrderedDict
56
from datetime import datetime
67
from pathlib import Path
@@ -27,7 +28,8 @@
2728
)
2829
from guidellm.scheduler import strategy_display_str
2930
from guidellm.utils import Colors, split_text_list_by_length
30-
31+
from guidellm.utils.injector import create_report
32+
from guidellm.presentation import UIDataBuilder
3133
__all__ = [
3234
"GenerativeBenchmarksConsole",
3335
"GenerativeBenchmarksReport",
@@ -67,6 +69,9 @@ def load_file(path: Union[str, Path]) -> "GenerativeBenchmarksReport":
6769

6870
if type_ == "csv":
6971
raise ValueError(f"CSV file type is not supported for loading: {path}.")
72+
73+
if type_ == "html":
74+
raise ValueError(f"HTML file type is not supported for loading: {path}.")
7075

7176
raise ValueError(f"Unsupported file type: {type_} for {path}.")
7277

@@ -114,6 +119,9 @@ def save_file(self, path: Union[str, Path]) -> Path:
114119
if type_ == "csv":
115120
return self.save_csv(path)
116121

122+
if type_ == "html":
123+
return self.save_html(path)
124+
117125
raise ValueError(f"Unsupported file type: {type_} for {path}.")
118126

119127
def save_json(self, path: Union[str, Path]) -> Path:
@@ -220,11 +228,44 @@ def save_csv(self, path: Union[str, Path]) -> Path:
220228

221229
return path
222230

231+
def save_html(self, path: str | Path) -> Path:
232+
"""
233+
Download html, inject report data and save to a file.
234+
If the file is a directory, it will create the report in a file named
235+
benchmarks.html under the directory.
236+
237+
:param path: The path to create the report at.
238+
:return: The path to the report.
239+
"""
240+
241+
# json_data = json.dumps(data, indent=2)
242+
# thing = f'window.{variable_name} = {json_data};'
243+
244+
data_builder = UIDataBuilder(self.benchmarks)
245+
data = data_builder.to_dict()
246+
camel_data = humps.camelize(data)
247+
ui_api_data = {
248+
f"window.{humps.decamelize(k)} = {{}};": f'window.{humps.decamelize(k)} = {json.dumps(v, indent=2)};\n'
249+
for k, v in camel_data.items()
250+
}
251+
print("________")
252+
print("________")
253+
print("________")
254+
print("________")
255+
print("ui_api_data")
256+
print(ui_api_data)
257+
print("________")
258+
print("________")
259+
print("________")
260+
print("________")
261+
create_report(ui_api_data, path)
262+
return path
263+
223264
@staticmethod
224265
def _file_setup(
225266
path: Union[str, Path],
226-
default_file_type: Literal["json", "yaml", "csv"] = "json",
227-
) -> tuple[Path, Literal["json", "yaml", "csv"]]:
267+
default_file_type: Literal["json", "yaml", "csv", "html"] = "json",
268+
) -> tuple[Path, Literal["json", "yaml", "csv", "html"]]:
228269
path = Path(path) if not isinstance(path, Path) else path
229270

230271
if path.is_dir():
@@ -242,6 +283,9 @@ def _file_setup(
242283
if path_suffix in [".csv"]:
243284
return path, "csv"
244285

286+
if path_suffix in [".html"]:
287+
return path, "html"
288+
245289
raise ValueError(f"Unsupported file extension: {path_suffix} for {path}.")
246290

247291
@staticmethod

src/guidellm/config.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,10 @@ class Environment(str, Enum):
3030

3131

3232
ENV_REPORT_MAPPING = {
33-
Environment.PROD: "https://guidellm.neuralmagic.com/local-report/index.html",
34-
Environment.STAGING: "https://staging.guidellm.neuralmagic.com/local-report/index.html",
35-
Environment.DEV: "https://dev.guidellm.neuralmagic.com/local-report/index.html",
36-
Environment.LOCAL: "tests/dummy/report.html",
33+
Environment.PROD: "https://neuralmagic.github.io/ui/latest/index.html",
34+
Environment.STAGING: "https://neuralmagic.github.io/ui/staging/latest/index.html",
35+
Environment.DEV: "https://neuralmagic.github.io/ui/dev/index.html",
36+
Environment.LOCAL: "https://neuralmagic.github.io/ui/dev/index.html",
3737
}
3838

3939

@@ -86,6 +86,12 @@ class OpenAISettings(BaseModel):
8686
base_url: str = "http://localhost:8000"
8787
max_output_tokens: int = 16384
8888

89+
class ReportGenerationSettings(BaseModel):
90+
"""
91+
Report generation settings for the application
92+
"""
93+
94+
source: str = ""
8995

9096
class Settings(BaseSettings):
9197
"""
@@ -140,6 +146,9 @@ class Settings(BaseSettings):
140146
)
141147
openai: OpenAISettings = OpenAISettings()
142148

149+
# Report settings
150+
report_generation: ReportGenerationSettings = ReportGenerationSettings()
151+
143152
# Output settings
144153
table_border_char: str = "="
145154
table_headers_border_char: str = "-"
@@ -148,6 +157,8 @@ class Settings(BaseSettings):
148157
@model_validator(mode="after")
149158
@classmethod
150159
def set_default_source(cls, values):
160+
if not values.report_generation.source:
161+
values.report_generation.source = ENV_REPORT_MAPPING.get(values.env)
151162
return values
152163

153164
def generate_env_file(self) -> str:
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from .builder import UIDataBuilder
2+
from .data_models import (Bucket, Model, Dataset, RunInfo, TokenDistribution, TokenDetails, Server, WorkloadDetails, BenchmarkDatum)
3+
from .injector import (create_report, inject_data)
4+
5+
__all__ = [
6+
"UIDataBuilder",
7+
"Bucket",
8+
"Model",
9+
"Dataset",
10+
"RunInfo",
11+
"TokenDistribution",
12+
"TokenDetails",
13+
"Server",
14+
"WorkloadDetails",
15+
"BenchmarkDatum",
16+
"create_report",
17+
"inject_data",
18+
]
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from typing import Any
2+
from .data_models import RunInfo, WorkloadDetails, BenchmarkDatum
3+
from guidellm.benchmark.benchmark import GenerativeBenchmark
4+
5+
__all__ = ["UIDataBuilder"]
6+
7+
8+
class UIDataBuilder:
9+
def __init__(self, benchmarks: list[GenerativeBenchmark]):
10+
self.benchmarks = benchmarks
11+
12+
def build_run_info(self):
13+
return RunInfo.from_benchmarks(self.benchmarks)
14+
15+
def build_workload_details(self):
16+
return WorkloadDetails.from_benchmarks(self.benchmarks)
17+
18+
def build_benchmarks(self):
19+
return [ BenchmarkDatum.from_benchmark(b) for b in self.benchmarks ]
20+
21+
def to_dict(self) -> dict[str, Any]:
22+
return {
23+
"run_info": self.build_run_info().dict(),
24+
"workload_details": self.build_workload_details().dict(),
25+
"benchmarks": [b.dict() for b in self.build_benchmarks()],
26+
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
from collections import defaultdict
2+
from math import ceil
3+
from pydantic import BaseModel
4+
import random
5+
from typing import List, Optional, Tuple
6+
7+
from guidellm.benchmark.benchmark import GenerativeBenchmark
8+
from guidellm.objects.statistics import DistributionSummary
9+
10+
__all__ = ["Bucket", "Model", "Dataset", "RunInfo", "TokenDistribution", "TokenDetails", "Server", "WorkloadDetails", "BenchmarkDatum"]
11+
12+
class Bucket(BaseModel):
13+
value: float
14+
count: int
15+
16+
@staticmethod
17+
def from_data(
18+
data: List[float],
19+
bucket_width: Optional[float] = None,
20+
n_buckets: Optional[int] = None
21+
) -> Tuple[List["Bucket"], float]:
22+
if not data:
23+
return [], 1.0
24+
25+
min_v = min(data)
26+
max_v = max(data)
27+
range_v = max_v - min_v
28+
29+
if bucket_width is None:
30+
if n_buckets is None:
31+
n_buckets = 10
32+
bucket_width = range_v / n_buckets
33+
else:
34+
n_buckets = ceil(range_v / bucket_width)
35+
36+
bucket_counts = defaultdict(int)
37+
for val in data:
38+
idx = int((val - min_v) // bucket_width)
39+
if idx >= n_buckets:
40+
idx = n_buckets - 1
41+
bucket_start = min_v + idx * bucket_width
42+
bucket_counts[bucket_start] += 1
43+
44+
buckets = [Bucket(value=start, count=count) for start, count in sorted(bucket_counts.items())]
45+
return buckets, bucket_width
46+
47+
48+
class Model(BaseModel):
49+
name: str
50+
size: int
51+
52+
class Dataset(BaseModel):
53+
name: str
54+
55+
class RunInfo(BaseModel):
56+
model: Model
57+
task: str
58+
timestamp: float
59+
dataset: Dataset
60+
61+
@classmethod
62+
def from_benchmarks(cls, benchmarks: list[GenerativeBenchmark]):
63+
model = benchmarks[0].worker.backend_model or 'N/A'
64+
timestamp = max(bm.run_stats.start_time for bm in benchmarks if bm.start_time is not None)
65+
return cls(
66+
model=Model(name=model, size=0),
67+
task='N/A',
68+
timestamp=timestamp,
69+
dataset=Dataset(name="N/A")
70+
)
71+
72+
class TokenDistribution(BaseModel):
73+
statistics: Optional[DistributionSummary] = None
74+
buckets: list[Bucket]
75+
bucket_width: float
76+
77+
78+
class TokenDetails(BaseModel):
79+
samples: list[str]
80+
token_distributions: TokenDistribution
81+
82+
class Server(BaseModel):
83+
target: str
84+
85+
class RequestOverTime(BaseModel):
86+
num_benchmarks: int
87+
requests_over_time: TokenDistribution
88+
89+
class WorkloadDetails(BaseModel):
90+
prompts: TokenDetails
91+
generations: TokenDetails
92+
requests_over_time: RequestOverTime
93+
rate_type: str
94+
server: Server
95+
@classmethod
96+
def from_benchmarks(cls, benchmarks: list[GenerativeBenchmark]):
97+
target = benchmarks[0].worker.backend_target
98+
rate_type = benchmarks[0].args.profile.type_
99+
successful_requests = [req for bm in benchmarks for req in bm.requests.successful]
100+
sample_indices = random.sample(range(len(successful_requests)), min(5, len(successful_requests)))
101+
sample_prompts = [successful_requests[i].prompt.replace("\n", " ").replace("\"", "'") for i in sample_indices]
102+
sample_outputs = [successful_requests[i].output.replace("\n", " ").replace("\"", "'") for i in sample_indices]
103+
104+
prompt_tokens = [req.prompt_tokens for bm in benchmarks for req in bm.requests.successful]
105+
output_tokens = [req.output_tokens for bm in benchmarks for req in bm.requests.successful]
106+
107+
prompt_token_buckets, _prompt_token_bucket_width = Bucket.from_data(prompt_tokens, 1)
108+
output_token_buckets, _output_token_bucket_width = Bucket.from_data(output_tokens, 1)
109+
110+
prompt_token_stats = DistributionSummary.from_values(prompt_tokens)
111+
output_token_stats = DistributionSummary.from_values(output_tokens)
112+
prompt_token_distributions = TokenDistribution(statistics=prompt_token_stats, buckets=prompt_token_buckets, bucket_width=1)
113+
output_token_distributions = TokenDistribution(statistics=output_token_stats, buckets=output_token_buckets, bucket_width=1)
114+
115+
min_start_time = benchmarks[0].run_stats.start_time
116+
117+
all_req_times = [
118+
req.start_time - min_start_time
119+
for bm in benchmarks
120+
for req in bm.requests.successful
121+
if req.start_time is not None
122+
]
123+
number_of_buckets = len(benchmarks)
124+
request_over_time_buckets, bucket_width = Bucket.from_data(all_req_times, None, number_of_buckets)
125+
request_over_time_distribution = TokenDistribution(buckets=request_over_time_buckets, bucket_width=bucket_width)
126+
return cls(
127+
prompts=TokenDetails(samples=sample_prompts, token_distributions=prompt_token_distributions),
128+
generations=TokenDetails(samples=sample_outputs, token_distributions=output_token_distributions),
129+
requests_over_time=RequestOverTime(requests_over_time=request_over_time_distribution, num_benchmarks=number_of_buckets),
130+
rate_type=rate_type,
131+
server=Server(target=target)
132+
)
133+
134+
class BenchmarkDatum(BaseModel):
135+
requests_per_second: float
136+
tpot: DistributionSummary
137+
ttft: DistributionSummary
138+
throughput: DistributionSummary
139+
time_per_request: DistributionSummary
140+
141+
@classmethod
142+
def from_benchmark(cls, bm: GenerativeBenchmark):
143+
return cls(
144+
requests_per_second=bm.metrics.requests_per_second.successful.mean,
145+
tpot=bm.metrics.inter_token_latency_ms.successful,
146+
ttft=bm.metrics.time_to_first_token_ms.successful,
147+
throughput=bm.metrics.output_tokens_per_second.successful,
148+
time_per_request=bm.metrics.request_latency.successful,
149+
)

0 commit comments

Comments
 (0)