|
| 1 | +"""Benchmark KerasHub presets served through vLLM. |
| 2 | +
|
| 3 | +Measures output token throughput for one of three configurations, all serving |
| 4 | +the same weights: |
| 5 | +
|
| 6 | + keras_hub `CausalLM.generate()`, static full-batch decoding. |
| 7 | + vllm_native vLLM's own model implementation, loaded from the |
| 8 | + equivalent Hugging Face checkpoint. |
| 9 | + keras_hub_vllm The KerasHub integration, `keras_hub.vllm.KerasHubLLM`. |
| 10 | +
|
| 11 | +Keras reads its backend at import, so set these before running. The last one |
| 12 | +is required: vLLM otherwise forks a worker process, which deadlocks against an |
| 13 | +already-initialized JAX. |
| 14 | +
|
| 15 | +``` |
| 16 | +export KERAS_BACKEND=jax |
| 17 | +export KERAS_NNX_ENABLED=true |
| 18 | +export VLLM_ENABLE_V1_MULTIPROCESSING=0 |
| 19 | +``` |
| 20 | +
|
| 21 | +Run one configuration per invocation: |
| 22 | +
|
| 23 | +``` |
| 24 | +python3 benchmarks/vllm_serving.py \ |
| 25 | + --config keras_hub_vllm \ |
| 26 | + --preset gemma3_instruct_1b |
| 27 | +``` |
| 28 | +
|
| 29 | +One config per run. A vLLM engine holds its memory until the process exits. |
| 30 | +
|
| 31 | +Runs 32 and 512 word prompts at 1, 32 and 64 concurrent requests, 128 |
| 32 | +generated tokens each, greedy. One warmup pass then 20 timed passes per cell. |
| 33 | +Throughput counts generated tokens only. |
| 34 | +""" |
| 35 | + |
| 36 | +import time |
| 37 | + |
| 38 | +from absl import app |
| 39 | +from absl import flags |
| 40 | + |
| 41 | +import keras_hub |
| 42 | + |
| 43 | +# The presets served by the merged integration, mapped to the Hugging Face |
| 44 | +# checkpoint holding the same weights for the vllm_native configuration. |
| 45 | +PRESETS = { |
| 46 | + "gpt2_base_en": "openai-community/gpt2", |
| 47 | + "gpt2_large_en": "openai-community/gpt2-large", |
| 48 | + "qwen2.5_coder_0.5b": "Qwen/Qwen2.5-Coder-0.5B", |
| 49 | + "llama3.2_instruct_1b": "meta-llama/Llama-3.2-1B-Instruct", |
| 50 | + "gemma_2b_en": "google/gemma-2b", |
| 51 | + "gemma2_2b_en": "google/gemma-2-2b", |
| 52 | + "gemma3_instruct_1b": "google/gemma-3-1b-it", |
| 53 | +} |
| 54 | + |
| 55 | +CONFIGS = ("keras_hub", "vllm_native", "keras_hub_vllm") |
| 56 | + |
| 57 | +INPUT_WORDS = (32, 512) |
| 58 | +CONCURRENCY = (1, 32, 64) |
| 59 | +OUTPUT_TOKENS = 128 |
| 60 | +WARMUP_RUNS = 1 |
| 61 | +TIMED_RUNS = 20 |
| 62 | +MAX_MODEL_LEN = 1024 |
| 63 | +# Caps how much prefill the engine batches, and so how many shapes it compiles |
| 64 | +# at startup. |
| 65 | +MAX_NUM_BATCHED_TOKENS = 512 |
| 66 | +# Matches the highest concurrency measured, so no request waits on a slot. |
| 67 | +MAX_NUM_SEQS = 64 |
| 68 | +DTYPE = "bfloat16" |
| 69 | + |
| 70 | +FLAGS = flags.FLAGS |
| 71 | + |
| 72 | +flags.DEFINE_enum( |
| 73 | + "config", |
| 74 | + "keras_hub_vllm", |
| 75 | + CONFIGS, |
| 76 | + "Which configuration to measure.", |
| 77 | +) |
| 78 | +flags.DEFINE_enum( |
| 79 | + "preset", |
| 80 | + "gemma3_instruct_1b", |
| 81 | + list(PRESETS), |
| 82 | + "Which preset to measure.", |
| 83 | +) |
| 84 | +flags.DEFINE_string( |
| 85 | + "output", |
| 86 | + None, |
| 87 | + "CSV output path. Defaults to <config>_<preset>.csv.", |
| 88 | +) |
| 89 | + |
| 90 | + |
| 91 | +def build_prompt(word_count): |
| 92 | + """Returns a prompt of the requested length in words.""" |
| 93 | + words = "The future of artificial intelligence is".split() |
| 94 | + return " ".join((words * (word_count // len(words) + 1))[:word_count]) |
| 95 | + |
| 96 | + |
| 97 | +def keras_hub_generate(preset): |
| 98 | + """Returns generate and count functions for `CausalLM.generate()`.""" |
| 99 | + from keras import ops |
| 100 | + |
| 101 | + model = keras_hub.models.CausalLM.from_preset(preset, dtype=DTYPE) |
| 102 | + # `CausalLM.compile` defaults to top_k, which is stochastic. The vLLM |
| 103 | + # configurations use temperature=0.0, so this has to be greedy to match. |
| 104 | + model.compile(sampler="greedy") |
| 105 | + tokenizer = model.preprocessor.tokenizer |
| 106 | + |
| 107 | + # The prompt length comes from generate_preprocess, which counts the <bos> |
| 108 | + # the tokenizer alone would miss. |
| 109 | + max_lengths = {} |
| 110 | + for word_count in INPUT_WORDS: |
| 111 | + prompt = build_prompt(word_count) |
| 112 | + preprocessed = model.preprocessor.generate_preprocess([prompt]) |
| 113 | + prompt_tokens = int(ops.sum(preprocessed["padding_mask"][0])) |
| 114 | + max_lengths[prompt] = prompt_tokens + OUTPUT_TOKENS |
| 115 | + |
| 116 | + def generate(prompts): |
| 117 | + return model.generate( |
| 118 | + prompts, |
| 119 | + max_length=max_lengths[prompts[0]], |
| 120 | + strip_prompt=True, |
| 121 | + ) |
| 122 | + |
| 123 | + def count(outputs): |
| 124 | + return sum(len(tokenizer(output)) for output in outputs) |
| 125 | + |
| 126 | + return generate, count |
| 127 | + |
| 128 | + |
| 129 | +def vllm_generate(model, keras_hub_integration): |
| 130 | + """Returns generate and count functions for a vLLM engine.""" |
| 131 | + from vllm import SamplingParams |
| 132 | + |
| 133 | + if keras_hub_integration: |
| 134 | + from keras_hub.vllm import KerasHubLLM |
| 135 | + |
| 136 | + engine = KerasHubLLM( |
| 137 | + f"keras_hub:{model}", |
| 138 | + dtype=DTYPE, |
| 139 | + max_model_len=MAX_MODEL_LEN, |
| 140 | + max_num_batched_tokens=MAX_NUM_BATCHED_TOKENS, |
| 141 | + max_num_seqs=MAX_NUM_SEQS, |
| 142 | + ) |
| 143 | + else: |
| 144 | + from vllm import LLM |
| 145 | + |
| 146 | + engine = LLM( |
| 147 | + model=model, |
| 148 | + dtype=DTYPE, |
| 149 | + max_model_len=MAX_MODEL_LEN, |
| 150 | + max_num_batched_tokens=MAX_NUM_BATCHED_TOKENS, |
| 151 | + max_num_seqs=MAX_NUM_SEQS, |
| 152 | + ) |
| 153 | + |
| 154 | + params = SamplingParams(temperature=0.0, max_tokens=OUTPUT_TOKENS) |
| 155 | + |
| 156 | + def generate(prompts): |
| 157 | + return engine.generate(prompts, params) |
| 158 | + |
| 159 | + def count(outputs): |
| 160 | + return sum(len(output.outputs[0].token_ids) for output in outputs) |
| 161 | + |
| 162 | + return generate, count |
| 163 | + |
| 164 | + |
| 165 | +def measure(generate, count, prompts): |
| 166 | + """Returns tokens generated over time taken. Only generation is timed.""" |
| 167 | + for _ in range(WARMUP_RUNS): |
| 168 | + generate(prompts) |
| 169 | + |
| 170 | + total_tokens = 0 |
| 171 | + total_time = 0.0 |
| 172 | + for _ in range(TIMED_RUNS): |
| 173 | + start = time.perf_counter() |
| 174 | + outputs = generate(prompts) |
| 175 | + total_time += time.perf_counter() - start |
| 176 | + total_tokens += count(outputs) |
| 177 | + return total_tokens / total_time |
| 178 | + |
| 179 | + |
| 180 | +def main(_): |
| 181 | + preset = FLAGS.preset |
| 182 | + config = FLAGS.config |
| 183 | + |
| 184 | + if config == "keras_hub": |
| 185 | + generate, count = keras_hub_generate(preset) |
| 186 | + elif config == "vllm_native": |
| 187 | + generate, count = vllm_generate( |
| 188 | + PRESETS[preset], keras_hub_integration=False |
| 189 | + ) |
| 190 | + else: |
| 191 | + generate, count = vllm_generate(preset, keras_hub_integration=True) |
| 192 | + |
| 193 | + path = FLAGS.output or f"{config}_{preset}.csv" |
| 194 | + with open(path, "w") as results: |
| 195 | + results.write( |
| 196 | + "preset,config,input_words,concurrency,tokens_per_second\n" |
| 197 | + ) |
| 198 | + for input_words in INPUT_WORDS: |
| 199 | + prompt = build_prompt(input_words) |
| 200 | + for concurrency in CONCURRENCY: |
| 201 | + throughput = measure(generate, count, [prompt] * concurrency) |
| 202 | + print( |
| 203 | + f"{preset} {config} input_words={input_words} " |
| 204 | + f"concurrency={concurrency} " |
| 205 | + f"{throughput:.1f} tokens/s" |
| 206 | + ) |
| 207 | + results.write( |
| 208 | + f"{preset},{config},{input_words},{concurrency}," |
| 209 | + f"{throughput:.2f}\n" |
| 210 | + ) |
| 211 | + results.flush() |
| 212 | + |
| 213 | + print(f"Wrote {path}") |
| 214 | + |
| 215 | + |
| 216 | +if __name__ == "__main__": |
| 217 | + app.run(main) |
0 commit comments