char-fork/llama.cpp
git clone https://git.t4t.associates/char-fork/llama.cpp
689a9a470
char/aliceai-t5-moe
1#!/usr/bin/env python3 2 3import argparse 4import json 5import os 6import random 7import sqlite3 8import subprocess 9from time import sleep ,time 10from typing import Optional ,Union 11 12import datasets 13import logging 14import matplotlib .pyplot as plt 15import numpy as np 16import requests 17from tqdm .contrib .concurrent import thread_map 18 19 20logging .basicConfig (level = logging .INFO ,format = '%(message)s' ) 21logger = logging .getLogger ("server-bench" ) 22 23 24def get_prompts_text (dataset_name :str ,n_prompts :int )-> Optional [list [str ]]: 25ret = [] 26if dataset_name .lower ()== "mmlu" : 27logger .info ("Loading MMLU dataset..." ) 28ret = datasets .load_dataset ("cais/mmlu" ,"all" )["test" ]["question" ] 29else : 30return None 31if n_prompts >= 0 : 32ret = ret [:n_prompts ] 33return ret 34 35 36def get_prompt_lengths_rng (n_prompts :int ,prompt_length_min :int ,prompt_length_max :int ,seed_offset :int )-> list [int ]: 37assert n_prompts >= 0 38ret :list [int ]= [] 39for i in range (n_prompts ): 40if seed_offset >= 0 : 41random .seed (3 * (seed_offset + 1000 * i )+ 0 ) 42ret .append (random .randint (prompt_length_min ,prompt_length_max )) 43return ret 44 45 46def get_prompts_rng (prompt_lengths :list [int ])-> list [list [int ]]: 47return [[random .randint (100 ,10000 )for _ in range (pl )]for pl in prompt_lengths ] 48 49 50def get_server (path_server :str ,path_log :Optional [str ])-> dict : 51if path_server .startswith ("http://" )or path_server .startswith ("https://" ): 52return {"process" :None ,"address" :path_server ,"fout" :None } 53if os .environ .get ("LLAMA_ARG_HOST" )is None : 54logger .info ("LLAMA_ARG_HOST not explicitly set, using 127.0.0.1" ) 55os .environ ["LLAMA_ARG_HOST" ]= "127.0.0.1" 56if os .environ .get ("LLAMA_ARG_PORT" )is None : 57logger .info ("LLAMA_ARG_PORT not explicitly set, using 8080" ) 58os .environ ["LLAMA_ARG_PORT" ]= "8080" 59hostname :Optional [str ]= os .environ .get ("LLAMA_ARG_HOST" ) 60port :Optional [str ]= os .environ .get ("LLAMA_ARG_PORT" ) 61assert hostname is not None 62assert port is not None 63address :str = f"http:// { hostname } : { port } " 64logger .info (f"Starting the llama.cpp server under { address } ..." ) 65 66fout = open (path_log .format (port = port ),"w" )if path_log is not None else subprocess .DEVNULL 67process = subprocess .Popen ([path_server ],stdout = fout ,stderr = subprocess .STDOUT ) 68 69n_failures :int = 0 70while True : 71try : 72sleep (1.0 ) 73exit_code = process .poll () 74if exit_code is not None : 75raise RuntimeError (f"llama.cpp server exited unexpectedly with exit code { exit_code } { path_log and f', see { path_log . format ( port = port ) } ' or '' } " ) 76response = requests .get (f" { address } /health" ) 77if response .status_code == 200 : 78break 79except requests .ConnectionError : 80n_failures += 1 81if n_failures >= 10 : 82raise RuntimeError ("llama.cpp server is not healthy after 10 seconds" ) 83 84return {"process" :process ,"address" :address ,"fout" :fout } 85 86 87def get_prompt_length (data :dict )-> int : 88session = data ["session" ] 89server_address :str = data ["server_address" ] 90 91response = session .post ( 92f" { server_address } /apply-template" , 93json = {"messages" : [{"role" :"user" ,"content" :data ["prompt" ],"stream" :True }]} 94 ) 95response .raise_for_status () 96prompt :str = json .loads (response .text )["prompt" ] 97response = session .post ( 98f" { server_address } /tokenize" , 99json = {"content" :prompt ,"add_special" :True } 100 ) 101response .raise_for_status () 102tokens :list [str ]= json .loads (response .text )["tokens" ] 103return len (tokens ) 104 105 106def send_prompt (data :dict )-> tuple [float ,list [float ]]: 107session = data ["session" ] 108server_address :str = data ["server_address" ] 109 110t_submit = time () 111if data ["external_server" ]: 112json_data :dict = { 113"prompt" :data ["prompt" ],"ignore_eos" :True , 114"seed" :data ["seed" ],"max_tokens" :data ["n_predict" ],"stream" :True } 115response = session .post (f" { server_address } /v1/completions" ,json = json_data ,stream = True ) 116elif data ["synthetic_prompt" ]: 117json_data :dict = { 118"prompt" :data ["prompt" ],"ignore_eos" :True ,"cache_prompt" :False , 119"seed" :data ["seed" ],"n_predict" :data ["n_predict" ],"stream" :True } 120response = session .post (f" { server_address } /completion" ,json = json_data ,stream = True ) 121else : 122response = session .post ( 123f" { server_address } /apply-template" , 124json = {"messages" : [{"role" :"user" ,"content" :data ["prompt" ],"stream" :True }]} 125 ) 126response .raise_for_status () 127prompt :str = json .loads (response .text )["prompt" ] 128 129json_data :dict = {"prompt" :prompt ,"seed" :data ["seed" ],"n_predict" :data ["n_predict" ],"stream" :True } 130response = session .post (f" { server_address } /completion" ,json = json_data ,stream = True ) 131response .raise_for_status () 132 133lines = [] 134token_arrival_times :list [float ]= [] 135for line in response .iter_lines (decode_unicode = False ): 136if not line .startswith (b"data: " ): 137continue 138lines .append (line ) 139token_arrival_times .append (time ()) 140token_arrival_times = token_arrival_times [:- 1 ] 141if len (lines )> 1 and "timings" in json .loads (lines [- 2 ][6 :]): 142token_arrival_times = token_arrival_times [:- 1 ] 143 144return (t_submit ,token_arrival_times ) 145 146 147def benchmark ( 148path_server :str ,path_log :Optional [str ],path_db :Optional [str ],name :Optional [str ],prompt_source :str ,n_prompts :int , 149n_predict :int ,n_predict_min :int ,seed_offset :int ): 150external_server :bool = path_server .startswith ("http://" )or path_server .startswith ("https://" ) 151if os .environ .get ("LLAMA_ARG_N_PARALLEL" )is None : 152logger .info ("LLAMA_ARG_N_PARALLEL not explicitly set, using 32" ) 153os .environ ["LLAMA_ARG_N_PARALLEL" ]= "32" 154 155parallel :int = int (os .environ .get ("LLAMA_ARG_N_PARALLEL" ))# type: ignore 156prompts :Union [None ,list [str ],list [list [int ]]]= get_prompts_text (prompt_source ,n_prompts ) 157synthetic_prompts :bool = prompts is None 158prompt_n = [] 159 160if synthetic_prompts : 161prompt_source_split :list [str ]= prompt_source .split ("-" ) 162assert len (prompt_source_split )== 3 163assert prompt_source_split [0 ].lower ()== "rng" 164prompt_length_min :int = int (prompt_source_split [1 ]) 165prompt_length_max :int = int (prompt_source_split [2 ]) 166logger .info ("Generating random prompts..." ) 167prompt_n = get_prompt_lengths_rng (n_prompts ,prompt_length_min ,prompt_length_max ,seed_offset ) 168prompts = get_prompts_rng (prompt_n ) 169else : 170n_predict_min = n_predict 171 172if not external_server and os .environ .get ("LLAMA_ARG_CTX_SIZE" )is None : 173context_per_slot :int = int (1.05 * (n_predict + (np .max (prompt_n )if synthetic_prompts else 2048 ))) 174context_total :int = context_per_slot * parallel 175os .environ ["LLAMA_ARG_CTX_SIZE" ]= str (context_total ) 176logger .info (f"LLAMA_ARG_CTX_SIZE not explicitly set, using { context_total } ( { context_per_slot } per slot)." ) 177 178server :Optional [dict ]= None 179session = None 180try : 181server = get_server (path_server ,path_log ) 182server_address :str = server ["address" ] 183assert external_server == (server ["process" ]is None ) 184 185adapter = requests .adapters .HTTPAdapter (pool_connections = parallel ,pool_maxsize = parallel )# type: ignore 186session = requests .Session () 187session .mount ("http://" ,adapter ) 188session .mount ("https://" ,adapter ) 189 190data :list [dict ]= [] 191 192assert isinstance (prompts ,list ) 193for i ,p in enumerate (prompts ): 194if seed_offset >= 0 : 195random .seed (3 * (seed_offset + 1000 * i )+ 1 ) 196data .append ({ 197"session" :session ,"server_address" :server_address ,"external_server" :external_server ,"prompt" :p , 198"synthetic_prompt" :synthetic_prompts ,"n_predict" :random .randint (n_predict_min ,n_predict ), 199"seed" : (3 * (seed_offset + 1000 * i )+ 2 )if seed_offset >= 0 else - 1 }) 200 201if not synthetic_prompts : 202logger .info ("Getting the prompt lengths..." ) 203prompt_n = [get_prompt_length (d )for d in data ] 204 205logger .info ("Starting the benchmark...\n" ) 206t0 = time () 207results :list [tuple [float ,list [float ]]]= thread_map (send_prompt ,data ,max_workers = parallel ,chunksize = 1 ) 208finally : 209if server is not None and server ["process" ]is not None : 210server ["process" ].terminate () 211server ["process" ].wait () 212if session is not None : 213session .close () 214 215prompt_t = [] 216token_t = [] 217depth_sum :int = 0 218for pn , (t_submit ,tat )in zip (prompt_n ,results ): 219prompt_t .append (tat [0 ]- t_submit ) 220token_t += tat 221n_tokens :int = len (tat ) 222depth_sum += n_tokens * pn 223depth_sum += n_tokens * (n_tokens + 1 )// 2 224assert len (token_t )> 0 225prompt_n = np .array (prompt_n ,dtype = np .int64 ) 226prompt_t = np .array (prompt_t ,dtype = np .float64 ) 227token_t = np .array (token_t ,dtype = np .float64 ) 228 229token_t -= t0 230token_t_last = np .max (token_t ) 231 232logger .info ("" ) 233logger .info (f"Benchmark duration: { token_t_last :.2f } s" ) 234logger .info (f"Request throughput: { n_prompts / token_t_last :.2f } requests/s = { n_prompts / ( token_t_last / 60 ):.2f } requests/min" ) 235logger .info (f"Total prompt length: { np . sum ( prompt_n ) } tokens" ) 236logger .info (f"Average prompt length: { np . mean ( prompt_n ):.2f } tokens" ) 237logger .info (f"Average prompt latency: { 1e3 * np . mean ( prompt_t ):.2f } ms" ) 238logger .info (f"Average prompt speed: { np . sum ( prompt_n ) / np . sum ( prompt_t ):.2f } tokens/s" ) 239logger .info (f"Total generated tokens: { token_t . shape [ 0 ] } " ) 240logger .info (f"Average generation depth: { depth_sum / token_t . shape [ 0 ]:.2f } tokens" ) 241logger .info (f"Average total generation speed: { token_t . shape [ 0 ] / token_t_last :.2f } tokens/s" ) 242logger .info (f"Average generation speed per slot: { token_t . shape [ 0 ] / ( parallel * token_t_last ):.2f } tokens/s / slot" ) 243 244if path_db is not None : 245con = sqlite3 .connect (path_db ) 246cursor = con .cursor () 247cursor .execute ( 248"CREATE TABLE IF NOT EXISTS server_bench" 249"(name TEXT, n_parallel INTEGER, prompt_source TEXT, n_prompts INTEGER, " 250"n_predict INTEGER, n_predict_min INTEGER, seed_offset INTEGER, runtime REAL);" ) 251cursor .execute ( 252"INSERT INTO server_bench VALUES (?, ?, ?, ?, ?, ?, ?, ?);" , 253 [name ,parallel ,prompt_source ,n_prompts ,n_predict ,n_predict_min ,seed_offset ,token_t_last ]) 254con .commit () 255 256plt .figure () 257plt .scatter (prompt_n ,1e3 * prompt_t ,s = 10.0 ,marker = "." ,alpha = 0.25 ) 258plt .xlim (0 ,1.05e0 * np .max (prompt_n )) 259plt .ylim (0 ,1.05e3 * np .max (prompt_t )) 260plt .title (name or "" ) 261plt .xlabel ("Prompt length [tokens]" ) 262plt .ylabel ("Time to first token [ms]" ) 263plt .savefig ("prompt_time.png" ,dpi = 240 ) 264 265bin_max = np .ceil (token_t_last )+ 1 266plt .figure () 267plt .hist (token_t ,np .arange (0 ,bin_max )) 268plt .xlim (0 ,bin_max + 1 ) 269plt .title (name or "" ) 270plt .xlabel ("Time [s]" ) 271plt .ylabel ("Num. tokens generated per second" ) 272plt .savefig ("gen_rate.png" ,dpi = 240 ) 273 274 275if __name__ == "__main__" : 276parser = argparse .ArgumentParser ( 277description = "Tool for benchmarking the throughput of the llama.cpp HTTP server. " 278"Results are printed to console and visualized as plots (saved to current working directory). " 279"To pass arguments such as the model path to the server, set the corresponding environment variables (see llama-server --help). " 280"The reported numbers are the speeds as observed by the Python script and may differ from the performance reported by the server, " 281"particularly when the server is fast vs. the network or Python script (e.g. when serving a very small model)." ) 282parser .add_argument ("--path_server" ,type = str ,default = "llama-server" ,help = "Path to the llama.cpp server binary" ) 283parser .add_argument ("--path_log" ,type = str ,default = "server-bench-{port}.log" ,help = "Path to the model to use for the benchmark" ) 284parser .add_argument ("--path_db" ,type = str ,default = None ,help = "Path to an sqlite database to store the benchmark results in" ) 285parser .add_argument ("--name" ,type = str ,default = None ,help = "Name to label plots and database entries with" ) 286parser .add_argument ( 287"--prompt_source" ,type = str ,default = "rng-1024-2048" , 288help = "How to get the prompts for the benchmark, either 'mmlu' for MMLU questions or " 289"rng-MIN-MAX for synthetic prompts with random lengths in the interval [MIN, MAX]" ) 290parser .add_argument ("--n_prompts" ,type = int ,default = 100 ,help = "Number of prompts to evaluate" ) 291parser .add_argument ("--n_predict" ,type = int ,default = 2048 ,help = "Max. number of tokens to predict per prompt" ) 292parser .add_argument ( 293"--n_predict_min" ,type = int ,default = 1024 , 294help = "Min. number of tokens to predict per prompt (supported for synthetic prompts only)" ) 295parser .add_argument ("--seed_offset" ,type = int ,default = 0 ,help = "Offset for determining the seeds for pseudorandom prompt/generation lengths. " 296"Correlations between seeds can occur when set >= 1000. Negative values mean no seed." ) 297args = parser .parse_args () 298benchmark (** vars (args ))