benchmaker 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,79 @@
1
+ """Workload-type protocol.
2
+
3
+ A `WorkloadType` knows how to talk to a specific *kind* of HTTP service
4
+ (generic HTTP, OpenAI chat completions, …). It does NOT own the inputs —
5
+ inputs come from a `Workload` (see `benchmaker.workloads.datasets`), which
6
+ yields opaque per-request items.
7
+
8
+ Pairing:
9
+ bench = WorkloadType("how to talk") + Workload("what to send") + LoadModel("when")
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from abc import ABC, abstractmethod
15
+ from typing import Any, Optional
16
+
17
+ from benchmaker.types import Request, Response, Sample, TicketContext, maybe_await
18
+
19
+
20
+ class WorkloadType(ABC):
21
+ """A request protocol/shape.
22
+
23
+ Subclasses turn a workload item (any object yielded by a `Workload`) into a
24
+ `Request`, and the resulting `Response` into a `Sample`. Workload-specific
25
+ metrics (TTFT, tokens/sec, …) are attached via `Sample.extra`.
26
+ """
27
+
28
+ name: str = "workload-type"
29
+ streaming: bool = False # if True, runner reads the response chunk-by-chunk
30
+
31
+ @abstractmethod
32
+ async def make_request(self, item: Any) -> Request:
33
+ """Build a `Request` from a workload item.
34
+
35
+ `item` may be `None` if the workload yields placeholders (e.g. a fixed-
36
+ request benchmark with no per-request data).
37
+ """
38
+
39
+ async def make_sample(self, item: Any, request: Request, response: Response,
40
+ start_ts: float) -> Sample:
41
+ """Build a Sample. Override to attach workload-type-specific metrics."""
42
+ return Sample(
43
+ start_ts=start_ts,
44
+ latency_s=response.elapsed_s,
45
+ status=response.status,
46
+ ok=response.ok,
47
+ request_ok=response.ok,
48
+ bytes_recv=len(response.body) if response.body else 0,
49
+ bytes_sent=_estimate_request_size(request),
50
+ error=response.error,
51
+ workload=self.name,
52
+ meta=dict(request.meta),
53
+ )
54
+
55
+ async def run_ticket(self, ctx: TicketContext) -> Sample:
56
+ """Default ticket flow: one make_request → one fire → one make_sample.
57
+
58
+ Override to implement multi-step protocols (e.g. create → exec → delete).
59
+ Use `ctx.fire(req)` to issue requests; it applies pre-hooks and uses
60
+ the shared aiohttp session.
61
+ """
62
+ req = await self.make_request(ctx.item)
63
+ resp = await ctx.fire(req)
64
+ sample = await self.make_sample(ctx.item, req, resp, ctx.start_mono)
65
+ for hook in ctx.post_hooks:
66
+ sample = await maybe_await(hook(req, resp, sample))
67
+ return sample
68
+
69
+ async def aclose(self) -> None:
70
+ return None
71
+
72
+
73
+ def _estimate_request_size(req: Request) -> int:
74
+ if req.body is not None:
75
+ return len(req.body)
76
+ if req.json is not None:
77
+ import json as _json
78
+ return len(_json.dumps(req.json).encode("utf-8"))
79
+ return 0
@@ -0,0 +1,156 @@
1
+ """Workloads = datasets / input sources.
2
+
3
+ A `Workload` is the source of per-request data. It yields opaque items that a
4
+ `WorkloadType` knows how to interpret. Built-ins cover the common shapes;
5
+ custom workloads are a one-method subclass.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import itertools
12
+ import json
13
+ import os
14
+ import random
15
+ from abc import ABC, abstractmethod
16
+ from typing import Any, AsyncIterator, Awaitable, Callable, Iterable, Optional, Union
17
+
18
+
19
+ class Workload(ABC):
20
+ """A source of per-request items (a dataset, replay log, prompt list, …)."""
21
+
22
+ name: str = "workload"
23
+
24
+ @abstractmethod
25
+ async def next_item(self) -> Any:
26
+ """Return the next item. Raise `StopAsyncIteration` to halt the bench."""
27
+
28
+ async def aclose(self) -> None:
29
+ return None
30
+
31
+
32
+ class StaticWorkload(Workload):
33
+ """Cycle through a fixed list of items forever (or `max_items` times).
34
+
35
+ Use this for trivial benchmarks ("send the same JSON repeatedly") or when
36
+ your dataset fits in memory.
37
+ """
38
+
39
+ def __init__(self, items: Iterable[Any] = (None,), name: str = "static",
40
+ shuffle: bool = False, seed: Optional[int] = None,
41
+ max_items: Optional[int] = None):
42
+ self.name = name
43
+ items = list(items)
44
+ if not items:
45
+ items = [None]
46
+ if shuffle:
47
+ rng = random.Random(seed)
48
+ rng.shuffle(items)
49
+ self._items = items
50
+ self._cycle = itertools.cycle(items)
51
+ self._max = max_items
52
+ self._n = 0
53
+
54
+ async def next_item(self) -> Any:
55
+ if self._max is not None and self._n >= self._max:
56
+ raise StopAsyncIteration
57
+ self._n += 1
58
+ return next(self._cycle)
59
+
60
+
61
+ class JsonlWorkload(Workload):
62
+ """Stream items from a JSONL file.
63
+
64
+ Args:
65
+ path: path to .jsonl file.
66
+ field: if set, yield only this top-level field of each line (e.g. "prompt").
67
+ loop: if True (default), restart the file when exhausted.
68
+ max_items: cap on total items yielded across loops.
69
+ """
70
+
71
+ def __init__(self, path: str, field: Optional[str] = None, loop: bool = True,
72
+ max_items: Optional[int] = None, name: Optional[str] = None):
73
+ if not os.path.exists(path):
74
+ raise FileNotFoundError(path)
75
+ self.name = name or f"jsonl:{os.path.basename(path)}"
76
+ self._path = path
77
+ self._field = field
78
+ self._loop = loop
79
+ self._max = max_items
80
+ self._n = 0
81
+ self._fh = open(path, "r")
82
+ self._lock = asyncio.Lock()
83
+
84
+ async def next_item(self) -> Any:
85
+ if self._max is not None and self._n >= self._max:
86
+ raise StopAsyncIteration
87
+ async with self._lock:
88
+ line = self._fh.readline()
89
+ if not line:
90
+ if not self._loop:
91
+ raise StopAsyncIteration
92
+ self._fh.seek(0)
93
+ line = self._fh.readline()
94
+ if not line:
95
+ raise StopAsyncIteration # empty file
96
+ obj = json.loads(line)
97
+ if self._field is not None:
98
+ obj = obj[self._field]
99
+ self._n += 1
100
+ return obj
101
+
102
+ async def aclose(self) -> None:
103
+ try:
104
+ self._fh.close()
105
+ except Exception:
106
+ pass
107
+
108
+
109
+ class CallableWorkload(Workload):
110
+ """Wrap a callable (sync or async) that returns the next item on each call.
111
+
112
+ The callable can be a closure that walks a generator, queries an external
113
+ service, etc. Raise `StopAsyncIteration` from the callable to halt.
114
+ """
115
+
116
+ def __init__(self, fn: Callable[[], Union[Any, Awaitable[Any]]],
117
+ name: str = "callable"):
118
+ self.name = name
119
+ self._fn = fn
120
+
121
+ async def next_item(self) -> Any:
122
+ result = self._fn()
123
+ if hasattr(result, "__await__"):
124
+ return await result # type: ignore[return-value]
125
+ return result
126
+
127
+
128
+ class IterableWorkload(Workload):
129
+ """Wrap any (async) iterable as a workload. Halts when iterable is exhausted."""
130
+
131
+ def __init__(self, iterable: Union[Iterable[Any], AsyncIterator[Any]],
132
+ name: str = "iterable", loop: bool = False):
133
+ self.name = name
134
+ self._loop = loop
135
+ if hasattr(iterable, "__aiter__"):
136
+ self._aiter = iterable.__aiter__() # type: ignore[union-attr]
137
+ self._items_cache: Optional[list] = None
138
+ self._is_async = True
139
+ else:
140
+ self._items_cache = list(iterable) # type: ignore[arg-type]
141
+ self._iter = iter(self._items_cache)
142
+ self._is_async = False
143
+
144
+ async def next_item(self) -> Any:
145
+ if self._is_async:
146
+ try:
147
+ return await self._aiter.__anext__()
148
+ except StopAsyncIteration:
149
+ raise
150
+ try:
151
+ return next(self._iter)
152
+ except StopIteration:
153
+ if self._loop and self._items_cache:
154
+ self._iter = iter(self._items_cache)
155
+ return next(self._iter)
156
+ raise StopAsyncIteration