A pocket-sized LLM inference engine: continuous batching + paged KV-cache, FlashInfer as the reference attention backend, hand-written CuTe DSL kernels where we can beat it. Runs Qwen2.5-0.5B (bf16) end-to-end on an RTX 4050 Laptop (sm_89, 6 GB).
flashlet/
βββ flashlet/
β βββ scheduler.py continuous batching + chunked prefill + radix prefix reuse
β βββ kv_cache.py paged KV pool, free-list allocator, radix cache w/ refcounts
β βββ model.py Qwen2.5 forward pass (HF weights, our own loop)
β βββ engine.py step(): schedule -> plan -> attention -> MLP -> sample
β βββ backends/
β β βββ base.py AttentionBackend ABC: plan() / run()
β β βββ torch_backend.py dense SDPA reference (correctness oracle)
β β βββ flashinfer_backend.py BatchPrefillWithPagedKVCacheWrapper, persistent bufs
β βββ bench/ harness + workloads + `python -m flashlet.bench`
βββ kernels/ standalone CuTe DSL kernels (M4)
βββ tests/ pytest suite
βββ scripts/m0_check.py end-to-end sanity check vs HF generate
pip install torch flashinfer-python transformers fastapi uvicorn
python3 scripts/m0_check.py # greedy gen + HF parity + TPS
python3 -m pytest tests/ -q # unit tests
python3 -m flashlet.bench --backend flashinfer --concurrency 1 8 32 128 \
--max-new-tokens 32 --max-num-pages 8192 --out results.json
python3 -m flashlet.server --backend flashinfer --port 8000 # HTTP server
curl -s localhost:8000/health
curl -s localhost:8000/generate -H 'Content-Type: application/json' \
-d '{"prompt": "The capital of France is", "max_new_tokens": 32}'
curl -s -N localhost:8000/generate_stream -H 'Content-Type: application/json' \
-d '{"prompt": "Tell me a story", "max_new_tokens": 64}' # SSE, token by tokenQwen2.5-0.5B-Instruct, bf16, RTX 4050 Laptop (sm_89), greedy, max_new_tokens=32,
mixed prompt lengths (32-768 tok), closed-loop, --max-num-pages 8192.
| backend | c | decode tok/s | TTFT p50/p99 (ms) | ITL p50 (ms) | peak VRAM |
|---|---|---|---|---|---|
| flashinfer | 1 | 112.5 | 459 / 906 | 8.61 | 2.78 GB |
| flashinfer | 8 | 868 | 581 / 1126 | 8.26 | 5.54 GB |
| flashinfer | 32 | 3,204 | 887 / 1,682 | 7.12 | 5.55 GB |
| flashinfer | 128 | 5,961 | 2,147 / 4,254 | 2.13 | 5.57 GB |
| torch ref | 1 | 76.6 | 662 / 1,284 | 12.64 | 2.64 GB |
| torch ref | 8 | 215 | 1,950 / 3,764 | 33.31 | 5.26 GB |
| torch ref | 32 | 290 | 5,671 / 11,152 | 78.72 | 5.26 GB |
| torch ref | 128 | 315 | 19,929 / 46,438 | 40.39 | 5.28 GB |
Saturated-batch microbenchmark (128 seqs Γ 300-tok prompts, pure decode): 11,238 tok/s at 11.4 ms/step with the FlashInfer backend.
Single-stream peak: ~124 tok/s (flashinfer) vs ~101 tok/s (torch reference).
TTFT includes queue wait in closed-loop batches (later requests wait for earlier ones at low concurrency). Logits agree with HF transformers on 99.3% of argmax positions; greedy outputs diverge on near-ties due to bf16 kernel reduction order, as expected.
- One ragged
BatchPrefillWithPagedKVCacheWrapper.plan()/run()call per step handles chunked prefills AND decodes together (qo_indptr= [512,1,1,...], causal mask covers both). - Metadata lives in persistent device buffers updated in-place each step; CUDA-graph-friendly by construction.
- KV pool layout
[layers, pages, page_size, kv_heads, head_dim]matches FlashInfer NHD exactly:set_kvis a singleindex_copy_, zero copies. - Radix prefix cache with explicit page refcounts; adoption never covers the final prompt token (guarantees a query token exists); LRU-free eviction on pool pressure.
- Chunked prefill shares one token budget across all active prefills; decoders always step every iteration.
A standalone CuTe DSL megakernel for Qwen2.5 (whole-model fused kernel:
GEMV, GQA attention, RMSNorm, RoPE and residual ops in one launch graph,
see qwen_2.5_megakernal) already measures 140 tok/s single-stream
decode on the same RTX 4050. That is close to the practical roofline for
this GPU: bf16 weights are about 1 GB, so 140-160 tok/s means streaming
roughly 140-160 GB/s of weight reads per token, near the sustained
bandwidth ceiling of the laptop's memory bus.
Integration into flashlet as a third attention/model backend is planned: the megakernel would replace the per-layer op sequence for pure-decode steps while flashlet keeps the scheduler, paged KV pool and continuous batching around it. Expect single-stream numbers to land in the 140-160 tok/s band once wired in.
- M0 skeleton: greedy decode, coherent text, HF parity
- M1 paged KV cache: alloc/free cycles, pinnedβdevice metadata, radix reuse
- M2 scheduler: continuous batching + chunked prefill, mixed-batch tests
- M3 FlashInfer backend: plan/run integration, parity vs reference, bench table
- M4 CuTe DSL paged GQA decode kernel (sm_89)
- M5 RoPE+residual fusion kernel
- M6 Blackwell port (stretch)