|
| 1 | +(ns eca.perf.stream-tool-calls |
| 2 | + "Small ad-hoc benchmarks for streamed tool-call argument processing. |
| 3 | +
|
| 4 | + Run with: |
| 5 | + clojure -M:dev -m eca.perf.stream-tool-calls |
| 6 | +
|
| 7 | + These benchmarks avoid live LLM and editor dependencies so the same workload can |
| 8 | + be replayed before/after optimizations. Numbers are intentionally simple wall |
| 9 | + clock timings; compare relative changes on the same machine/JVM." |
| 10 | + (:require |
| 11 | + [cheshire.core :as json] |
| 12 | + [eca.features.chat.tool-calls :as tc] |
| 13 | + [eca.features.tools :as f.tools] |
| 14 | + [eca.llm-util :as llm-util] |
| 15 | + [eca.messenger :as messenger] |
| 16 | + [eca.shared :as shared]) |
| 17 | + (:import |
| 18 | + [java.io BufferedReader StringReader])) |
| 19 | + |
| 20 | +(set! *warn-on-reflection* true) |
| 21 | + |
| 22 | +(def default-chunks 5000) |
| 23 | +(def default-chunk-size 128) |
| 24 | + |
| 25 | +(defn- now [] |
| 26 | + (System/nanoTime)) |
| 27 | + |
| 28 | +(defn- elapsed-ms [start-ns] |
| 29 | + (/ (double (- (now) start-ns)) 1000000.0)) |
| 30 | + |
| 31 | +(defn- fmt-ms [n] |
| 32 | + (format "%.2f" (double n))) |
| 33 | + |
| 34 | +(defn- chunk-text [chunk-size] |
| 35 | + (apply str (take chunk-size (cycle "abcdefghijklmnopqrstuvwxyz0123456789")))) |
| 36 | + |
| 37 | +(defn- make-db [] |
| 38 | + {:workspace-folders [{:uri "file:///tmp" :name "tmp"}] |
| 39 | + :chats {"perf-chat" {:id "perf-chat" |
| 40 | + :status :running |
| 41 | + :messages []}} |
| 42 | + :tool-servers {}}) |
| 43 | + |
| 44 | +(defn- make-config [] |
| 45 | + {:toolCall {:approval {:byDefault "allow"} |
| 46 | + :readFile {:maxLines 2000}} |
| 47 | + :disabledTools []}) |
| 48 | + |
| 49 | +(defrecord CountingMessenger [state* mode] |
| 50 | + messenger/IMessenger |
| 51 | + (chat-content-received [_this data] |
| 52 | + (let [json-bytes (when (= mode :json) |
| 53 | + (count (json/generate-string data)))] |
| 54 | + (swap! state* |
| 55 | + (fn [state] |
| 56 | + (cond-> (-> state |
| 57 | + (update :notifications (fnil inc 0)) |
| 58 | + (update :argument-bytes + (count (or (get-in data [:content :arguments-text]) "")))) |
| 59 | + json-bytes (update :json-bytes + json-bytes)))))) |
| 60 | + (chat-cleared [_this _params]) |
| 61 | + (chat-status-changed [_this _params]) |
| 62 | + (chat-deleted [_this _params]) |
| 63 | + (chat-opened [_this _params]) |
| 64 | + (rewrite-content-received [_this _data]) |
| 65 | + (tool-server-updated [_this _params]) |
| 66 | + (tool-server-removed [_this _params]) |
| 67 | + (config-updated [_this _params]) |
| 68 | + (provider-updated [_this _params]) |
| 69 | + (jobs-updated [_this _params]) |
| 70 | + (showMessage [_this _msg]) |
| 71 | + (progress [_this _params]) |
| 72 | + (editor-diagnostics [_this _uri]) |
| 73 | + (ask-question [_this _params])) |
| 74 | + |
| 75 | +(defn- make-chat-ctx [db* messenger] |
| 76 | + {:chat-id "perf-chat" |
| 77 | + :request-id "perf-request" |
| 78 | + :db* db* |
| 79 | + :messenger messenger}) |
| 80 | + |
| 81 | +(defn bench-transition-prepare! |
| 82 | + "Benchmark only the tool-call state transition + messenger notification path." |
| 83 | + [{:keys [chunks chunk-size messenger-mode] |
| 84 | + :or {chunks default-chunks |
| 85 | + chunk-size default-chunk-size |
| 86 | + messenger-mode :noop}}] |
| 87 | + (let [db* (atom (make-db)) |
| 88 | + state* (atom {:notifications 0 |
| 89 | + :argument-bytes 0 |
| 90 | + :json-bytes 0}) |
| 91 | + m (->CountingMessenger state* messenger-mode) |
| 92 | + chat-ctx (make-chat-ctx db* m) |
| 93 | + arg-chunk (chunk-text chunk-size) |
| 94 | + event-data {:name "write_file" |
| 95 | + :server "eca" |
| 96 | + :full-name "eca__write_file" |
| 97 | + :origin :native |
| 98 | + :arguments-text arg-chunk} |
| 99 | + start (now)] |
| 100 | + (dotimes [_ chunks] |
| 101 | + (tc/transition-tool-call! db* chat-ctx "call-1" :tool-prepare event-data)) |
| 102 | + (assoc @state* |
| 103 | + :bench :transition-prepare |
| 104 | + :messenger-mode messenger-mode |
| 105 | + :chunks chunks |
| 106 | + :chunk-size chunk-size |
| 107 | + :elapsed-ms (elapsed-ms start)))) |
| 108 | + |
| 109 | +(defn bench-on-prepare-like! |
| 110 | + "Benchmark the heavier chat.clj-like callback path that recomputes all-tools, |
| 111 | + resolves the tool, computes summary, and then transitions for every delta." |
| 112 | + [{:keys [chunks chunk-size messenger-mode cache-tools?] |
| 113 | + :or {chunks default-chunks |
| 114 | + chunk-size default-chunk-size |
| 115 | + messenger-mode :noop |
| 116 | + cache-tools? false}}] |
| 117 | + (let [db* (atom (make-db)) |
| 118 | + config (make-config) |
| 119 | + agent nil |
| 120 | + state* (atom {:notifications 0 |
| 121 | + :argument-bytes 0 |
| 122 | + :json-bytes 0}) |
| 123 | + m (->CountingMessenger state* messenger-mode) |
| 124 | + chat-ctx (make-chat-ctx db* m) |
| 125 | + cached-tools (when cache-tools? |
| 126 | + (vec (f.tools/all-tools "perf-chat" agent @db* config))) |
| 127 | + arg-chunk (chunk-text chunk-size) |
| 128 | + start (now)] |
| 129 | + (dotimes [_ chunks] |
| 130 | + (let [all-tools (or cached-tools |
| 131 | + (vec (f.tools/all-tools "perf-chat" agent @db* config))) |
| 132 | + full-name "eca__write_file" |
| 133 | + tool (f.tools/resolve-tool full-name all-tools)] |
| 134 | + (tc/transition-tool-call! |
| 135 | + db* chat-ctx "call-1" :tool-prepare |
| 136 | + {:name (or (:name tool) full-name) |
| 137 | + :server (:name (:server tool)) |
| 138 | + :full-name (or (:full-name tool) full-name) |
| 139 | + :origin (or (:origin tool) :unknown) |
| 140 | + :arguments-text arg-chunk |
| 141 | + :summary (f.tools/tool-call-summary all-tools full-name nil config @db*)}))) |
| 142 | + (assoc @state* |
| 143 | + :bench :on-prepare-like |
| 144 | + :messenger-mode messenger-mode |
| 145 | + :cache-tools? cache-tools? |
| 146 | + :chunks chunks |
| 147 | + :chunk-size chunk-size |
| 148 | + :elapsed-ms (elapsed-ms start)))) |
| 149 | + |
| 150 | +(defn bench-all-tools! |
| 151 | + [{:keys [iterations] |
| 152 | + :or {iterations default-chunks}}] |
| 153 | + (let [db (make-db) |
| 154 | + config (make-config) |
| 155 | + start (now) |
| 156 | + last-count* (volatile! nil)] |
| 157 | + (dotimes [_ iterations] |
| 158 | + (vreset! last-count* (count (f.tools/all-tools "perf-chat" nil db config)))) |
| 159 | + {:bench :all-tools |
| 160 | + :iterations iterations |
| 161 | + :tool-count @last-count* |
| 162 | + :elapsed-ms (elapsed-ms start)})) |
| 163 | + |
| 164 | +(defn bench-camel-case! |
| 165 | + [{:keys [iterations argument-size] |
| 166 | + :or {iterations default-chunks |
| 167 | + argument-size default-chunk-size}}] |
| 168 | + (let [payload {:chat-id "perf-chat" |
| 169 | + :role :assistant |
| 170 | + :content {:type :toolCallPrepare |
| 171 | + :id "call-1" |
| 172 | + :name "write_file" |
| 173 | + :server "eca" |
| 174 | + :origin :native |
| 175 | + :arguments-text (chunk-text argument-size)}} |
| 176 | + start (now) |
| 177 | + last* (volatile! nil)] |
| 178 | + (dotimes [_ iterations] |
| 179 | + (vreset! last* (shared/map->camel-cased-map payload))) |
| 180 | + {:bench :camel-case |
| 181 | + :iterations iterations |
| 182 | + :argument-size argument-size |
| 183 | + :last-keys (keys @last*) |
| 184 | + :elapsed-ms (elapsed-ms start)})) |
| 185 | + |
| 186 | +(defn bench-event-data-seq! |
| 187 | + [{:keys [chunks chunk-size] |
| 188 | + :or {chunks default-chunks |
| 189 | + chunk-size default-chunk-size}}] |
| 190 | + (let [delta (chunk-text chunk-size) |
| 191 | + line (str "data: " (json/generate-string {:type "response.function_call_arguments.delta" |
| 192 | + :delta delta}) "\n\n") |
| 193 | + body (apply str (repeat chunks line)) |
| 194 | + rdr (BufferedReader. (StringReader. body)) |
| 195 | + start (now) |
| 196 | + parsed-count (count (llm-util/event-data-seq rdr))] |
| 197 | + {:bench :event-data-seq |
| 198 | + :chunks chunks |
| 199 | + :chunk-size chunk-size |
| 200 | + :parsed-count parsed-count |
| 201 | + :elapsed-ms (elapsed-ms start)})) |
| 202 | + |
| 203 | +(defn- print-result! [{:keys [elapsed-ms chunks iterations] :as result}] |
| 204 | + (let [n (or chunks iterations) |
| 205 | + per-op (when (and n (pos? n)) (/ elapsed-ms n))] |
| 206 | + (println |
| 207 | + (str (name (:bench result)) |
| 208 | + " " |
| 209 | + (pr-str (dissoc result :elapsed-ms)) |
| 210 | + " elapsed-ms=" (fmt-ms elapsed-ms) |
| 211 | + (when per-op |
| 212 | + (str " ms/op=" (fmt-ms per-op))))))) |
| 213 | + |
| 214 | +(defn run-suite! [{:keys [chunks chunk-size] |
| 215 | + :or {chunks default-chunks |
| 216 | + chunk-size default-chunk-size}}] |
| 217 | + (println "Streaming tool-call benchmark") |
| 218 | + (println (str "chunks=" chunks " chunk-size=" chunk-size)) |
| 219 | + (println "") |
| 220 | + ;; Warm up enough to load namespaces/classes and reduce first-call noise. |
| 221 | + (bench-transition-prepare! {:chunks 100 :chunk-size chunk-size :messenger-mode :noop}) |
| 222 | + (bench-on-prepare-like! {:chunks 100 :chunk-size chunk-size :messenger-mode :noop}) |
| 223 | + (doseq [result [(bench-transition-prepare! {:chunks chunks :chunk-size chunk-size :messenger-mode :noop}) |
| 224 | + (bench-transition-prepare! {:chunks chunks :chunk-size chunk-size :messenger-mode :json}) |
| 225 | + (bench-on-prepare-like! {:chunks chunks :chunk-size chunk-size :messenger-mode :noop :cache-tools? false}) |
| 226 | + (bench-on-prepare-like! {:chunks chunks :chunk-size chunk-size :messenger-mode :noop :cache-tools? true}) |
| 227 | + (bench-on-prepare-like! {:chunks chunks :chunk-size chunk-size :messenger-mode :json :cache-tools? false}) |
| 228 | + (bench-on-prepare-like! {:chunks chunks :chunk-size chunk-size :messenger-mode :json :cache-tools? true}) |
| 229 | + (bench-all-tools! {:iterations chunks}) |
| 230 | + (bench-camel-case! {:iterations chunks :argument-size chunk-size}) |
| 231 | + (bench-event-data-seq! {:chunks chunks :chunk-size chunk-size})]] |
| 232 | + (print-result! result))) |
| 233 | + |
| 234 | +(defn- parse-long-or [s default] |
| 235 | + (if (some? s) |
| 236 | + (Long/parseLong s) |
| 237 | + default)) |
| 238 | + |
| 239 | +(defn -main [& args] |
| 240 | + (let [[chunks chunk-size] args] |
| 241 | + (run-suite! {:chunks (parse-long-or chunks default-chunks) |
| 242 | + :chunk-size (parse-long-or chunk-size default-chunk-size)}))) |
0 commit comments