prefix_cache.py 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
  1. #!/usr/bin/env python3
  2. """Does a prefix-cache hit change what the model generates?
  3. Standard library only. Point it at a running vLLM OpenAI server with prefix
  4. caching on and no other traffic:
  5. python prefix_cache.py --url http://127.0.0.1:8000 --model glm53
  6. For each shared-prefix length L: request A (the first L + 700 tokens of a
  7. document) fills the cache under a salt. Request B (the same first L tokens,
  8. then 600 tokens of other text) runs twice: under A's salt, so it can hit the
  9. cache, and twice under fresh salts, so it prefills cold. All three generate
  10. greedily. The warm run is compared with the first cold run, and the two cold
  11. runs with each other: that second comparison is the noise floor, the difference
  12. two runs show without the cache involved. A length whose cache hit differs by
  13. more than the floor runs twice more with new salts: corruption repeats at the
  14. same length, while occasional decode noise (the verify length picked from step
  15. timing, for example) does not. The hit size comes from the
  16. server's prefix-cache hit counter, since usage.cached_tokens can read 0 on
  17. hybrid models.
  18. Run determinism.py first: this compares two runs, so it means something only
  19. when two cold runs already agree. Generated logprobs are used because
  20. prompt_logprobs requests skip the prefix cache.
  21. The default lengths are multiples and half multiples of the attention block
  22. and the mamba block, read from the server's cache_config_info.
  23. """
  24. import argparse
  25. import json
  26. import os
  27. import random
  28. import string
  29. import urllib.request
  30. def post(base, path, payload, timeout=1800):
  31. request = urllib.request.Request(
  32. base + path, data=json.dumps(payload).encode(), headers={"content-type": "application/json"})
  33. with urllib.request.urlopen(request, timeout=timeout) as response:
  34. return json.loads(response.read())
  35. def metrics(base):
  36. return urllib.request.urlopen(base + "/metrics", timeout=30).read().decode()
  37. def counter(text, name):
  38. return sum(float(line.split()[-1]) for line in text.splitlines() if line.startswith(name))
  39. def label(text, name):
  40. return text.split(f'{name}="', 1)[1].split('"', 1)[0]
  41. def tokens(base, model, n, seed):
  42. """n token ids of pseudo-words from a fixed seed."""
  43. rng = random.Random(seed)
  44. ids = []
  45. while len(ids) < n:
  46. text = " ".join("".join(rng.choice(string.ascii_lowercase) for _ in range(rng.randint(2, 9)))
  47. for _ in range(3000)) + ".\n"
  48. ids += post(base, "/tokenize", {"model": model, "prompt": text, "add_special_tokens": False})["tokens"]
  49. return ids[:n]
  50. def generate(base, model, prompt, salt, max_tokens):
  51. before = metrics(base)
  52. out = post(base, "/v1/completions", {"model": model, "prompt": prompt, "temperature": 0,
  53. "max_tokens": max_tokens, "logprobs": 1, "cache_salt": salt})
  54. after = metrics(base)
  55. hit = counter(after, "vllm:prefix_cache_hits_total") - counter(before, "vllm:prefix_cache_hits_total")
  56. others = counter(after, "vllm:request_success_total") - counter(before, "vllm:request_success_total") - 1
  57. lp = out["choices"][0]["logprobs"]
  58. return lp["tokens"], lp["token_logprobs"], int(hit), int(others)
  59. def compare(a, b):
  60. """(first differing generated token or None, largest logprob difference, same tokens)."""
  61. diffs = [abs(x - y) for x, y in zip(a[1], b[1])]
  62. return next((i for i, d in enumerate(diffs) if d), None), max(diffs, default=0.0), a[0] == b[0]
  63. def describe(first, largest, same_tokens):
  64. if first is None and same_tokens:
  65. return "identical"
  66. return f"from token {first}, max {largest:.4f}" + ("" if same_tokens else ", tokens differ")
  67. def main():
  68. parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
  69. parser.add_argument("--url", default="http://127.0.0.1:8000")
  70. parser.add_argument("--model", default="glm53")
  71. parser.add_argument("--lengths", type=lambda s: [int(x) for x in s.split(",")])
  72. parser.add_argument("--max-tokens", type=int, default=48)
  73. args = parser.parse_args()
  74. text = metrics(args.url)
  75. block, mamba = int(label(text, "block_size")), int(label(text, "mamba_block_size"))
  76. print(f"block_size {block}, mamba_block_size {mamba}, mamba_cache_mode {label(text, 'mamba_cache_mode')}")
  77. lengths = args.lengths or sorted({b * h // 2 for b in (block, mamba) for h in (3, 4, 5, 6, 8, 9, 14, 15)})
  78. doc = tokens(args.url, args.model, max(lengths) + 800, seed=1)
  79. tail = tokens(args.url, args.model, 600, seed=2)
  80. def trial(n):
  81. """(hit, warm vs cold, cold vs cold, other requests, worse than the floor) for length n."""
  82. salt = os.urandom(8).hex()
  83. generate(args.url, args.model, doc[:n + 700], salt, args.max_tokens)
  84. prompt = doc[:n] + tail
  85. warm = generate(args.url, args.model, prompt, salt, args.max_tokens)
  86. cold = generate(args.url, args.model, prompt, os.urandom(8).hex(), args.max_tokens)
  87. cold2 = generate(args.url, args.model, prompt, os.urandom(8).hex(), args.max_tokens)
  88. wf, wl, ws = compare(warm, cold)
  89. cf, cl, cs = compare(cold, cold2)
  90. # Worse than the floor: an earlier first difference, or a larger one.
  91. worse = (wf is not None or not ws) and (cf is None or (wf or 0) < cf or wl > 2 * cl)
  92. return warm[2], (wf, wl, ws), (cf, cl, cs), warm[3] + cold[3] + cold2[3], worse
  93. failed = 0
  94. for n in lengths:
  95. hit, w, c, others, worse = trial(n)
  96. verdict = ""
  97. if worse:
  98. repeats = sum(trial(n)[4] for _ in range(2))
  99. verdict = f" <- worse than noise, and again in {repeats} of 2 reruns"
  100. failed += repeats == 2
  101. print(f"L {n:6d} (mod {block} = {n % block:5d}, mod {mamba} = {n % mamba:5d}) hit {hit:6d} "
  102. f"warm vs cold: {describe(*w)}; cold vs cold: {describe(*c)}{verdict}"
  103. + (f" ({others} other requests)" if others else ""))
  104. print("PASS" if not failed else f"{failed} of {len(lengths)} lengths repeatedly worse than the noise floor")
  105. if __name__ == "__main__":
  106. main()