- Merge cos/sin into single freqs_cis tensor [batch, seq, dim/2, 2] throughout the pipeline: RotaryEmbedding buffer, forward return type, apply_rotary_emb signature, CUDA kernel interface - CUDA kernel now takes freqs_cis directly and reads cos/sin via stride offset internally, eliminating Python-side slice/copy overhead - Kernel interface: rotary_emb(x, freqs_cis) replaces rotary_emb(x, cos, sin) - All call sites pass rotary_emb as Tensor (was tuple), type annotations consistent - Update build threads from 8 to 16 - Fix all docs: get-started, inference, training, cuda_kernels, architecture, internals — reflect new rotary interface, KVCache fields, rotary backend dispatch, .so path, kernel registry count, file layout
298 lines
10 KiB
Markdown
298 lines
10 KiB
Markdown
# Inference
|
|
|
|
## Contents
|
|
|
|
- [KV Cache](#kv-cache)
|
|
- [KVCache System](#kvcache-system)
|
|
- [Attention Backend](#attention-backend)
|
|
- [Continuous Batching](#continuous-batching)
|
|
- [Sampling](#sampling-strategy-pattern)
|
|
- [Protocol Handlers](#protocol-handlers-strategy-pattern)
|
|
- [Engine & GenerateResult](#engine--generateresult)
|
|
- [HTTP API](#http-api) — endpoints, SSE, errors, stats
|
|
- [Engine API](#engine-api)
|
|
|
|
## KV Cache
|
|
|
|
At decode time, only the last query token matters. All previous K/V are cached to avoid recomputation:
|
|
|
|
$$
|
|
o_n = \sum_j \text{softmax}\left(\frac{q_n k_j}{\sqrt{d_k}}\right) v_j
|
|
$$
|
|
|
|
RoPE is applied **before** KV cache write, not after — otherwise position encoding drift occurs.
|
|
|
|
## KVCache System
|
|
|
|
Three-layer separation (SGLang-inspired): storage, index table, allocator.
|
|
|
|
```
|
|
PagePool (top-level manager, orchestrates all layers)
|
|
├── KVStorage k_buffer / v_buffer [n_layers, size, n_kv_heads, head_dim]
|
|
├── ReqToTokenPool req_to_token [num_reqs, max_ctx_len] → physical token slot
|
|
├── Allocator bitmask-based page allocator + ref-count + LRU (paged mode only)
|
|
└── PrefixCache hash-based prefix matching (paged mode only)
|
|
```
|
|
|
|
`PagePool` supports two modes:
|
|
|
|
- **Contiguous (default)**: pre-allocates `max_batch_size * max_seq_len` token slots. `req_to_token` is a trivial linear mapping (`slot = req_idx * max_seq_len + pos`). No dynamic allocation.
|
|
- **Paged** (`page_size=1` or `>1` with `n_tokens` set): shared token pool with on-demand allocation. Allocator + PrefixCache enable prefix sharing and LRU eviction.
|
|
|
|
`bind_tasks()` returns a `KVCache` dataclass — pure data, no methods:
|
|
|
|
```
|
|
KVCache
|
|
├── k_buffer, v_buffer [n_layers, size, n_kv_heads, head_dim]
|
|
├── req_to_token [num_reqs, max_ctx_len]
|
|
├── req_pool_indices [batch_size]
|
|
├── seq_lens [batch_size]
|
|
├── out_cache_loc [batch, seq_len] — write indices for this forward
|
|
├── max_len int — max(seq_lens), avoids GPU sync in decode
|
|
├── page_table [batch, max_len] — precomputed gather indices for decode (None for prefill)
|
|
└── decode_mask [batch, max_len] bool — precomputed position validity mask (None for single-batch decode)
|
|
```
|
|
|
|
Attention layers do raw buffer indexing: `k_buffer[layer_id, out_cache_loc] = k` to write, `k_buffer[layer_id, indices]` to gather.
|
|
|
|
## Attention Backend
|
|
|
|
Attention computation (cache I/O + SDPA/kernel dispatch) is decoupled from the model via `AttentionBackend` ABC:
|
|
|
|
```
|
|
AttentionBackend (ABC)
|
|
├── TorchNativeBackend SDPA + indirect KV cache gather (default)
|
|
└── CudaBackend CUDA kernel dispatch (attn_paged_decode, attn_prefill)
|
|
```
|
|
|
|
Select via context manager (mirrors `torch.nn.attention.sdpa_kernel`):
|
|
|
|
```python
|
|
from astrai.extension import attn_backend, ATTN_BACKEND
|
|
|
|
with attn_backend(ATTN_BACKEND.CUDA):
|
|
engine.generate("hello")
|
|
```
|
|
|
|
`CudaBackend` decode path: writes K/V to cache, then calls `attn_paged_decode` with `page_size=1` — the `req_to_token` table serves directly as the page table, each token slot is a single-token "page". No explicit K/V gather needed.
|
|
|
|
`CudaBackend` prefill path: writes K/V, gathers full-sequence K/V via indirect indexing (same as `TorchNativeBackend`), then calls `attn_prefill`.
|
|
|
|
Fallback: `CudaBackend` delegates to `TorchNativeBackend` when a CUDA kernel is not available.
|
|
|
|
### Rotary Embedding Backend
|
|
|
|
Rotary embedding is applied via `apply_rotary_emb` in `astrai/extension/rotary_backend.py`, which auto-dispatches:
|
|
|
|
- **CUDA kernel** (`rotary_emb.cu`): fused cos/sin lookup + rotation in a single kernel, used when the kernel is available, input is on CUDA, and `torch.is_grad_enabled()` is `False` (inference mode)
|
|
- **Torch fallback**: complex multiply path (`torch.view_as_complex` → `torch.complex` multiply → `torch.view_as_real`), used during training (supports autograd backward) or when the CUDA kernel is not available
|
|
|
|
`RotaryEmbedding` stores `cos_table`/`sin_table` as f32 buffers and returns a `(cos, sin)` tuple from `forward()`. Both attention backends share the same rotary dispatch — it is backend-agnostic.
|
|
|
|
## Continuous Batching
|
|
|
|
`InferenceScheduler` runs a daemon thread with a 4-phase loop:
|
|
|
|
```
|
|
1. Cleanup → Remove finished tasks, free KV cache slots/pages
|
|
2. Refill → Pop from waiting_queue, task_alloc resources, activate
|
|
3. Prefill → Group by (prompt_len, start_pos), run full forward
|
|
4. Decode → Run single-token forward for each same-position group
|
|
```
|
|
|
|
## Sampling (Strategy Pattern)
|
|
|
|
```
|
|
BaseSamplingStrategy (ABC)
|
|
├── TemperatureStrategy
|
|
├── TopKStrategy
|
|
├── TopPStrategy
|
|
└── SamplingPipeline
|
|
```
|
|
|
|
`SamplingPipeline` composes them: Temperature → Top-K → Top-P → softmax → multinomial.
|
|
`sample()` is a convenience shortcut for one-shot usage.
|
|
|
|
## Protocol Handlers (Strategy Pattern)
|
|
|
|
```python
|
|
class ProtocolHandler: # concrete orchestrator
|
|
def __init__(self, request, engine, builder): ...
|
|
async def handle(self):
|
|
prompt, ctx, stops = builder.prepare(request, engine)
|
|
agen = engine.generate_async(prompt, ...)
|
|
if stream: self._handle_stream(agen, ctx, stops)
|
|
else: return await self._handle_non_stream(agen, ctx, stops)
|
|
```
|
|
|
|
`ResponseBuilder` (ABC): `prepare()`, `format_stream_start()`, `format_chunk()`, `format_stream_end()`, `format_response()`.
|
|
|
|
`OpenAIResponseBuilder` → `/v1/chat/completions`, `AnthropicResponseBuilder` → `/v1/messages`.
|
|
|
|
Adding a protocol = one builder file, no handler subclassing needed.
|
|
|
|
## Engine & GenerateResult
|
|
|
|
```
|
|
InferenceEngine
|
|
├── generate(prompt, stream, ...) → str | List[str] | Generator
|
|
├── generate_with_request(req) → same
|
|
├── generate_async(prompt, ...) → AsyncGenerator
|
|
├── get_stats() → Dict
|
|
└── shutdown()
|
|
```
|
|
|
|
`GenerateResult` uses `Condition` for non-streaming (`wait_completion()`) and `Event` for streaming (`wait()`). Stream callback is `cb(token)`.
|
|
|
|
## HTTP API
|
|
|
|
```
|
|
POST /v1/chat/completions OpenAI
|
|
POST /v1/messages Anthropic
|
|
GET /health {"status":"ok","model_loaded":true}
|
|
GET /stats scheduler statistics
|
|
```
|
|
|
|
### OpenAI
|
|
|
|
```bash
|
|
curl -X POST http://localhost:8000/v1/chat/completions \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"messages":[{"role":"user","content":"Hello"}],"max_tokens":512}'
|
|
```
|
|
|
|
Response:
|
|
```json
|
|
{
|
|
"id": "chatcmpl-abc123",
|
|
"object": "chat.completion",
|
|
"created": 1717000000,
|
|
"model": "astrai",
|
|
"choices": [{"index": 0, "message": {"role": "assistant", "content": "Hello!"}, "finish_reason": "stop"}],
|
|
"usage": {"prompt_tokens": 5, "completion_tokens": 10, "total_tokens": 15}
|
|
}
|
|
```
|
|
|
|
Streaming SSE: `object: "chat.completion.chunk"` — starts with role delta, then token chunks, ends with finish chunk + usage stats, then `data: [DONE]`.
|
|
|
|
### Anthropic
|
|
|
|
```bash
|
|
curl -X POST http://localhost:8000/v1/messages \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"model":"astrai","system":"You are helpful.","messages":[{"role":"user","content":"Hello"}],"max_tokens":512}'
|
|
```
|
|
|
|
Supports `stop_sequences` and streaming via `event: content_block_delta`.
|
|
|
|
### GenerationRequest Parameters
|
|
|
|
| Param | Type | Default | Description |
|
|
|-------|------|---------|-------------|
|
|
| `messages` | List[dict] | required | Chat messages (role, content) |
|
|
| `top_k` | int | 50 | Top-k count |
|
|
| `top_p` | float | 1.0 | Nucleus threshold |
|
|
| `temperature` | float | 1.0 | Sampling temperature (> 0.0) |
|
|
| `max_tokens` | Optional[int] | None | Max generation length |
|
|
| `stream` | bool | False | Stream output |
|
|
| `stop` | Optional[Union[str, List[str]]] | None | Stop sequences |
|
|
| `frequency_penalty` | float | 0.0 | Frequency penalty |
|
|
| `tools` | Optional[List[dict]] | None | Tool definitions for function calling |
|
|
| `tool_choice` | Optional[str] | None | Tool selection mode |
|
|
|
|
### SSE Streaming Format
|
|
|
|
**OpenAI** (`/v1/chat/completions`, `stream=true`):
|
|
|
|
```
|
|
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","created":...,"model":"astrai",
|
|
"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}
|
|
|
|
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","created":0,"model":"astrai",
|
|
"choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}
|
|
|
|
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","created":...,"model":"astrai",
|
|
"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
|
|
|
|
data: {"prompt_tokens":5,"completion_tokens":1,"total_tokens":6}
|
|
|
|
data: [DONE]
|
|
```
|
|
|
|
**Anthropic** (`/v1/messages`, `stream=true`):
|
|
|
|
```
|
|
event: message_start
|
|
data: {"type":"message_start","message":{"id":"msg_...","model":"astrai","role":"assistant",
|
|
"content":[],"usage":{"input_tokens":0}}}
|
|
|
|
event: content_block_start
|
|
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
|
|
|
|
event: content_block_delta
|
|
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}
|
|
|
|
event: content_block_stop
|
|
data: {"type":"content_block_stop","index":0}
|
|
|
|
event: message_delta
|
|
data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{...}}
|
|
|
|
event: message_stop
|
|
data: {"type":"message_stop"}
|
|
```
|
|
|
|
### Error Responses
|
|
|
|
The server returns standard HTTP status codes. Pydantic validation errors (e.g. missing required fields)
|
|
are handled automatically by FastAPI with 422 status. The only application-level error is engine initialization:
|
|
|
|
| Status | Meaning |
|
|
|--------|---------|
|
|
| 200 | Success |
|
|
| 422 | Unprocessable entity (Pydantic validation) |
|
|
| 503 | Service unavailable (model not loaded, engine not ready) |
|
|
|
|
Error response body (503):
|
|
|
|
```json
|
|
{
|
|
"detail": "Engine not initialized"
|
|
}
|
|
```
|
|
|
|
### Stats Endpoint
|
|
|
|
```
|
|
GET /stats
|
|
```
|
|
|
|
Response:
|
|
|
|
```json
|
|
{
|
|
"total_tasks": 128,
|
|
"total_tokens": 10240,
|
|
"active_tasks": 3,
|
|
"waiting_queue": 2
|
|
}
|
|
```
|
|
|
|
## Engine API
|
|
|
|
```python
|
|
# Non-streaming
|
|
engine.generate("Hello", stream=False) # -> str
|
|
engine.generate(["A", "B"], stream=False) # -> List[str]
|
|
|
|
# Streaming
|
|
engine.generate("Hello", stream=True) # -> Generator[str]
|
|
engine.generate(["A", "B"], stream=True) # -> Generator[Tuple[int, str]]
|
|
|
|
# Async
|
|
async for token in engine.generate_async("Hello", ...): # -> AsyncGenerator[str]
|
|
print(token)
|
|
```
|
|
|
|
> Document Update Time: 2026-07-31
|