okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

nekomimi.petopenai_api: native utf-8 check and single-byte newline search in SSE framing (-39% reduce time), offline stream benchmark923a9a9

main
13.1 KiB495 linesraw
1//// Run explicitly with: gleam run -m manual/stream_benchmark
2//// Offline: frames and reduces synthetic Responses and Chat Completions
3//// streams, and encodes a large request, printing time per run. Profile a
4//// run with eprof for where the time goes.
5
6import albedo/clock
7import albedo/openai_api/request
8import albedo/openai_api/sse
9import albedo/openai_api/stream
10import albedo/openai_api/types
11import gleam/bit_array
12import gleam/float
13import gleam/int
14import gleam/io
15import gleam/json.{type Json}
16import gleam/list
17import gleam/option.{None, Some}
18import gleam/result
19import gleam/string
20import gleam/string_tree.{type StringTree}
21
22const deltas = 20_000
23
24const chunk_bytes = 1400
25
26const runs = 10
27
28pub fn main() -> Nil {
29  let responses = chunks(responses_stream(deltas))
30  let chat = chunks(chat_stream(deltas))
31  time("responses stream", stream_bytes(responses), fn() {
32    drive(types.Responses, responses)
33  })
34  time("chat stream", stream_bytes(chat), fn() {
35    drive(types.ChatCompletions, chat)
36  })
37  let responses_request = large_request(types.Responses, 400, 40)
38  let chat_request = large_request(types.ChatCompletions, 400, 40)
39  time("encode responses request", 0, fn() {
40    encode(types.Responses, responses_request)
41  })
42  time("encode chat request", 0, fn() {
43    encode(types.ChatCompletions, chat_request)
44  })
45}
46
47fn encode(protocol: types.Protocol, request: types.Request) -> String {
48  request.encode(protocol, request)
49  |> result.map(string_tree.byte_size)
50  |> string.inspect
51}
52
53fn time(label: String, bytes: Int, run: fn() -> String) -> Nil {
54  let summary = run()
55  let started = clock.monotonic_ms()
56  list.each(list.repeat(Nil, runs), fn(_) { run() })
57  let per_run =
58    int.to_float(clock.monotonic_ms() - started) /. int.to_float(runs)
59  let rate = case bytes {
60    0 -> ""
61    _ ->
62      "  "
63      <> float.to_string(float.to_precision(
64        int.to_float(bytes) /. per_run *. 1000.0 /. 1_048_576.0,
65        1,
66      ))
67      <> " MB/s"
68  }
69  io.println(
70    label
71    <> ": "
72    <> float.to_string(float.to_precision(per_run, 2))
73    <> " ms/run"
74    <> rate
75    <> "  "
76    <> summary,
77  )
78}
79
80fn stream_bytes(chunks: List(BitArray)) -> Int {
81  list.fold(chunks, 0, fn(total, chunk) { total + bit_array.byte_size(chunk) })
82}
83
84fn drive(protocol: types.Protocol, chunks: List(BitArray)) -> String {
85  case run(sse.new(8 * 1024 * 1024), stream.new(protocol), chunks) {
86    Ok(turn) ->
87      "turn: "
88      <> int.to_string(list.length(turn.output))
89      <> " items, "
90      <> int.to_string(list.length(turn.tool_calls))
91      <> " calls, "
92      <> string.inspect(turn.finish)
93    Error(error) -> "error: " <> error
94  }
95}
96
97type Step {
98  Done(types.Turn)
99  More(stream.State)
100}
101
102fn run(
103  parser: sse.Parser,
104  state: stream.State,
105  chunks: List(BitArray),
106) -> Result(types.Turn, String) {
107  case chunks {
108    [] -> Error("no terminal event")
109    [chunk, ..rest] -> {
110      use #(parser, events) <- result.try(
111        sse.feed(parser, chunk) |> result.map_error(string.inspect),
112      )
113      case reduce(state, events) {
114        Ok(Done(turn)) -> Ok(turn)
115        Ok(More(state)) -> run(parser, state, rest)
116        Error(error) -> Error(error)
117      }
118    }
119  }
120}
121
122fn reduce(
123  state: stream.State,
124  events: List(sse.Event),
125) -> Result(Step, String) {
126  case events {
127    [] -> Ok(More(state))
128    [event, ..rest] ->
129      case stream.feed(state, event) {
130        Ok(#(state, _, None)) -> reduce(state, rest)
131        Ok(#(_, _, Some(turn))) -> Ok(Done(turn))
132        Error(error) -> Error(string.inspect(error))
133      }
134  }
135}
136
137fn chunks(stream: StringTree) -> List(BitArray) {
138  split(bit_array.from_string(string_tree.to_string(stream)), [])
139}
140
141fn split(bytes: BitArray, acc: List(BitArray)) -> List(BitArray) {
142  case bit_array.byte_size(bytes) <= chunk_bytes {
143    True -> list.reverse([bytes, ..acc])
144    False -> {
145      let assert <<head:bytes-size(chunk_bytes), rest:bytes>> = bytes
146      split(rest, [head, ..acc])
147    }
148  }
149}
150
151fn event(name: String, data: Json) -> StringTree {
152  string_tree.from_strings([
153    "event: ",
154    name,
155    "\ndata: ",
156    json.to_string(data),
157    "\n\n",
158  ])
159}
160
161fn responses_stream(count: Int) -> StringTree {
162  let message = fn(text: String) {
163    json.object([
164      #("id", json.string("msg_001")),
165      #("type", json.string("message")),
166      #("status", json.string("completed")),
167      #("role", json.string("assistant")),
168      #(
169        "content",
170        json.array(
171          [
172            json.object([
173              #("type", json.string("output_text")),
174              #("text", json.string(text)),
175              #("annotations", json.array([], json.string)),
176            ]),
177          ],
178          fn(part) { part },
179        ),
180      ),
181    ])
182  }
183  let calls =
184    range(1, 5)
185    |> list.map(fn(i) {
186      json.object([
187        #("id", json.string("fc_" <> int.to_string(i))),
188        #("type", json.string("function_call")),
189        #("status", json.string("completed")),
190        #("call_id", json.string("call_" <> int.to_string(i))),
191        #("name", json.string("python")),
192        #("arguments", json.string("{\"code\":\"print(1)\"}")),
193      ])
194    })
195  let tokens =
196    range(1, count)
197    |> list.map(fn(i) { "token " <> int.to_string(i) <> " " })
198  let deltas =
199    list.index_map(tokens, fn(token, i) {
200      event(
201        "response.output_text.delta",
202        json.object([
203          #("type", json.string("response.output_text.delta")),
204          #("sequence_number", json.int(i + 2)),
205          #("item_id", json.string("msg_001")),
206          #("output_index", json.int(0)),
207          #("content_index", json.int(0)),
208          #("delta", json.string(token)),
209          #("logprobs", json.array([], json.string)),
210        ]),
211      )
212    })
213  let full = message(string.concat(tokens))
214  string_tree.concat([
215    event(
216      "response.created",
217      json.object([
218        #("type", json.string("response.created")),
219        #("sequence_number", json.int(0)),
220        #(
221          "response",
222          json.object([
223            #("id", json.string("resp_abc123")),
224            #("status", json.string("in_progress")),
225            #("output", json.array([], json.string)),
226          ]),
227        ),
228      ]),
229    ),
230    event(
231      "response.output_item.added",
232      json.object([
233        #("type", json.string("response.output_item.added")),
234        #("sequence_number", json.int(1)),
235        #("output_index", json.int(0)),
236        #("item", message("")),
237      ]),
238    ),
239    string_tree.concat(deltas),
240    event(
241      "response.output_item.done",
242      json.object([
243        #("type", json.string("response.output_item.done")),
244        #("output_index", json.int(0)),
245        #("item", full),
246      ]),
247    ),
248    event(
249      "response.completed",
250      json.object([
251        #("type", json.string("response.completed")),
252        #(
253          "response",
254          json.object([
255            #("id", json.string("resp_abc123")),
256            #("status", json.string("completed")),
257            #("output", json.preprocessed_array([full, ..calls])),
258            #(
259              "usage",
260              json.object([
261                #("input_tokens", json.int(1200)),
262                #(
263                  "input_tokens_details",
264                  json.object([#("cached_tokens", json.int(1000))]),
265                ),
266                #("output_tokens", json.int(count)),
267                #(
268                  "output_tokens_details",
269                  json.object([#("reasoning_tokens", json.int(0))]),
270                ),
271              ]),
272            ),
273          ]),
274        ),
275      ]),
276    ),
277  ])
278}
279
280fn chat_stream(count: Int) -> StringTree {
281  let chunk = fn(delta: Json, finish: Json) {
282    string_tree.from_strings([
283      "data: ",
284      json.to_string(
285        json.object([
286          #("id", json.string("chatcmpl-abc")),
287          #("object", json.string("chat.completion.chunk")),
288          #("created", json.int(1_700_000_000)),
289          #("model", json.string("gpt-x")),
290          #(
291            "choices",
292            json.preprocessed_array([
293              json.object([
294                #("index", json.int(0)),
295                #("delta", delta),
296                #("logprobs", json.null()),
297                #("finish_reason", finish),
298              ]),
299            ]),
300          ),
301          #("usage", json.null()),
302        ]),
303      ),
304      "\n\n",
305    ])
306  }
307  let deltas =
308    range(1, count)
309    |> list.map(fn(i) {
310      chunk(
311        json.object([
312          #("content", json.string("token " <> int.to_string(i) <> " ")),
313        ]),
314        json.null(),
315      )
316    })
317  let tool = fn(i: Int, fields: List(#(String, Json))) {
318    chunk(
319      json.object([
320        #(
321          "tool_calls",
322          json.preprocessed_array([
323            json.object([#("index", json.int(i)), ..fields]),
324          ]),
325        ),
326      ]),
327      json.null(),
328    )
329  }
330  let opened =
331    range(0, 4)
332    |> list.map(fn(i) {
333      tool(i, [
334        #("id", json.string("call_" <> int.to_string(i))),
335        #("type", json.string("function")),
336        #(
337          "function",
338          json.object([
339            #("name", json.string("python")),
340            #("arguments", json.string("")),
341          ]),
342        ),
343      ])
344    })
345  let arguments =
346    range(0, 4)
347    |> list.map(fn(i) {
348      tool(i, [
349        #(
350          "function",
351          json.object([#("arguments", json.string("{\"code\":\"print(1)\"}"))]),
352        ),
353      ])
354    })
355  string_tree.concat([
356    chunk(
357      json.object([
358        #("role", json.string("assistant")),
359        #("content", json.string("")),
360        #("refusal", json.null()),
361      ]),
362      json.null(),
363    ),
364    string_tree.concat(deltas),
365    string_tree.concat(opened),
366    string_tree.concat(arguments),
367    chunk(json.object([]), json.string("tool_calls")),
368    string_tree.from_strings([
369      "data: ",
370      json.to_string(
371        json.object([
372          #("id", json.string("chatcmpl-abc")),
373          #("choices", json.array([], json.string)),
374          #(
375            "usage",
376            json.object([
377              #("prompt_tokens", json.int(1200)),
378              #("completion_tokens", json.int(count)),
379              #(
380                "prompt_tokens_details",
381                json.object([#("cached_tokens", json.int(1000))]),
382              ),
383            ]),
384          ),
385        ]),
386      ),
387      "\n\ndata: [DONE]\n\n",
388    ]),
389  ])
390}
391
392/// `from` through `to`, ascending.
393fn range(from: Int, to: Int) -> List(Int) {
394  int.range(to, from - 1, [], list.prepend)
395}
396
397fn lorem(i: Int) -> String {
398  string.repeat(
399    "the quick brown fox "
400      <> int.to_string(i)
401      <> " jumps over the lazy dog with \"quotes\" and \n newlines. ",
402    20,
403  )
404}
405
406fn tools(count: Int) -> List(types.Tool) {
407  range(1, count)
408  |> list.map(fn(i) {
409    types.Tool(
410      "tool_" <> int.to_string(i),
411      "Execute Python in your persistent session. Provide code (string); timeout_ms is optional.",
412      json.object([
413        #("type", json.string("object")),
414        #(
415          "properties",
416          json.object([
417            #("code", json.object([#("type", json.string("string"))])),
418            #("timeout_ms", json.object([#("type", json.string("integer"))])),
419          ]),
420        ),
421        #("required", json.array(["code"], json.string)),
422        #("additionalProperties", json.bool(False)),
423      ]),
424      False,
425    )
426  })
427}
428
429/// A replayed `tool_1` call as `protocol` would have produced it.
430fn replayed_call(protocol: types.Protocol, i: Int) -> types.Input {
431  let arguments =
432    json.string(json.to_string(json.object([#("code", json.string(lorem(i)))])))
433  let item = case protocol {
434    types.Responses ->
435      json.object([
436        #("id", json.string("fc_" <> int.to_string(i))),
437        #("type", json.string("function_call")),
438        #("status", json.string("completed")),
439        #("call_id", json.string("call_" <> int.to_string(i))),
440        #("name", json.string("tool_1")),
441        #("arguments", arguments),
442      ])
443    types.ChatCompletions ->
444      json.object([
445        #("role", json.string("assistant")),
446        #("content", json.null()),
447        #(
448          "tool_calls",
449          json.preprocessed_array([
450            json.object([
451              #("id", json.string("call_" <> int.to_string(i))),
452              #("type", json.string("function")),
453              #(
454                "function",
455                json.object([
456                  #("name", json.string("tool_1")),
457                  #("arguments", arguments),
458                ]),
459              ),
460            ]),
461          ]),
462        ),
463      ])
464  }
465  let assert Ok(item) =
466    json.parse(json.to_string(item), types.replay_decoder(protocol))
467  types.Replay(item)
468}
469
470/// `inputs` inputs in user, assistant, replayed call, tool output order,
471/// with `tool_count` tools and a long system prompt.
472fn large_request(
473  protocol: types.Protocol,
474  inputs: Int,
475  tool_count: Int,
476) -> types.Request {
477  let input =
478    range(1, inputs / 4)
479    |> list.flat_map(fn(i) {
480      [
481        types.User(lorem(i)),
482        types.Assistant(lorem(i)),
483        replayed_call(protocol, i),
484        types.ToolOutput("call_" <> int.to_string(i), lorem(i), []),
485      ]
486    })
487  types.Request(
488    "gpt-x",
489    Some(string.repeat(lorem(0), 200)),
490    input,
491    tools(tool_count),
492    None,
493    types.defaults,
494  )
495}