From 04708bfb879579af0d8764d8f65f489cdc561e6c Mon Sep 17 00:00:00 2001 From: gufengc Date: Wed, 24 Jun 2026 20:50:56 +0800 Subject: [PATCH 1/2] feat: remove old paged attention kernel --- src/parallax/metal/paged_attention/kernel.py | 249 ------------------ .../paged_attention/paged_attention.metal | 127 --------- .../paged_attention_deepseek_v32.metal | 180 ------------- .../paged_attention_gpt_oss.metal | 177 ------------- src/parallax/models/deepseek_v2.py | 200 -------------- src/parallax/models/deepseek_v3.py | 183 +++++++++---- src/parallax/server/cache/dsa_cache.py | 36 ++- src/parallax/server/cache_manager.py | 205 ++++++++------ src/parallax/server/executor/base_executor.py | 7 - src/parallax/server/executor/mlx_executor.py | 23 +- src/parallax/server/scheduler.py | 10 +- src/parallax/utils/layer_types.py | 20 ++ src/parallax/utils/tokenizer_utils.py | 207 +-------------- src/parallax/utils/utils.py | 36 ++- src/parallax_extensions/CMakelists.txt | 2 + src/parallax_extensions/bindings.cpp | 32 +++ .../kernels/mla_paged_attention.cpp | 138 ++++++++++ .../kernels/mla_paged_attention.h | 42 +++ .../kernels/mla_paged_attention.metal | 163 ++++++++++++ .../lib/_ext.cpython-312-darwin.so | Bin 218488 -> 218504 bytes .../lib/libparallax_ext.dylib | Bin 153720 -> 156168 bytes .../lib/parallax_ext.metallib | Bin 19756416 -> 20358496 bytes src/parallax_extensions/ops.py | 52 ++++ .../test_dsa_paged_attention.py | 81 +++++- .../test_paged_attention_v1.py | 114 +------- tests/test_deepseek_v32.py | 59 +++++ tests/test_utils.py | 48 ++++ 27 files changed, 952 insertions(+), 1439 deletions(-) delete mode 100644 src/parallax/metal/paged_attention/kernel.py delete mode 100644 src/parallax/metal/paged_attention/paged_attention.metal delete mode 100644 src/parallax/metal/paged_attention/paged_attention_deepseek_v32.metal delete mode 100644 src/parallax/metal/paged_attention/paged_attention_gpt_oss.metal delete mode 100644 src/parallax/models/deepseek_v2.py create mode 100644 src/parallax/utils/layer_types.py create mode 100644 src/parallax_extensions/kernels/mla_paged_attention.cpp create mode 100644 src/parallax_extensions/kernels/mla_paged_attention.h create mode 100644 src/parallax_extensions/kernels/mla_paged_attention.metal diff --git a/src/parallax/metal/paged_attention/kernel.py b/src/parallax/metal/paged_attention/kernel.py deleted file mode 100644 index 9ee315bb..00000000 --- a/src/parallax/metal/paged_attention/kernel.py +++ /dev/null @@ -1,249 +0,0 @@ -import os -from typing import Dict, List, Optional - -import mlx.core as mx - -# Cache for compiled kernels -_KERNELS: Dict[str, object] = {} - - -def _get_metal_source(filename): - path = os.path.join(os.path.dirname(__file__), filename) - with open(path, "r") as f: - return f.read() - - -def _type_to_string(dtype: mx.Dtype) -> str: - if dtype == mx.float32: - return "float" - elif dtype == mx.float16: - return "half" - elif dtype == mx.bfloat16: - # Metal 3.1+ supports bfloat, typically via bfloat16_t or using half - # For now we map to bfloat16_t assuming compiler support - return "bfloat16_t" - else: - raise ValueError(f"Unsupported dtype for paged attention: {dtype}") - - -def _get_kernel( - name: str, - filename: str, - input_names: List[str], - output_names: List[str], - dtype: mx.Dtype = mx.float32, -): - type_str = _type_to_string(dtype) - kernel_key = f"{name}_{type_str}" - - if kernel_key not in _KERNELS: - source = _get_metal_source(filename) - # Simple template substitution - source = source.replace("{{T}}", type_str) - - header = """ -#include -using namespace metal; -""" - _KERNELS[kernel_key] = mx.fast.metal_kernel( - name=name, # Internal name for MLX JIT cache (not used for dispatch if we hold the object) - input_names=input_names, - output_names=output_names, - source=source, - header=header, - ) - return _KERNELS[kernel_key] - - -def paged_attention( - queries: mx.array, - key_cache: mx.array, - value_cache: mx.array, - block_tables: mx.array, - context_lengths: mx.array, - block_size: int, - scale: float, - num_kv_heads: int, - v_head_dim: Optional[int] = None, - top_k_indices: Optional[mx.array] = None, - window_size: Optional[int] = None, - sinks: Optional[mx.array] = None, -) -> mx.array: - """ - Paged Attention using Metal Kernel. - """ - batch_size = queries.shape[0] - num_heads = queries.shape[1] - dtype = queries.dtype - - if queries.ndim == 4: - if queries.shape[2] != 1: - pass - queries = queries.squeeze(2) - - k_head_dim = queries.shape[2] - if v_head_dim is None: - v_head_dim = k_head_dim - - num_layers = key_cache.shape[0] - num_total_blocks = key_cache.shape[1] - max_blocks = block_tables.shape[1] - - # Prepare Constants - def mk_int(val): - return mx.array(val, dtype=mx.int32) - - c_num_heads = mk_int(num_heads) - c_num_kv_heads = mk_int(num_kv_heads) - c_k_head_dim = mk_int(k_head_dim) - c_v_head_dim = mk_int(v_head_dim) - c_block_size = mk_int(block_size) - c_max_blocks = mk_int(max_blocks) - c_num_layers = mk_int(num_layers) - c_num_total_blocks = mk_int(num_total_blocks) - c_scale = mx.array(scale, dtype=queries.dtype) - - if top_k_indices is not None: - index_topk = top_k_indices.shape[1] - c_index_topk = mk_int(index_topk) - - inputs = [ - queries, - key_cache, - value_cache, - block_tables, - context_lengths, - top_k_indices, - c_num_heads, - c_num_kv_heads, - c_k_head_dim, - c_v_head_dim, - c_block_size, - c_max_blocks, - c_num_layers, - c_num_total_blocks, - c_scale, - c_index_topk, - ] - - input_names = [ - "queries", - "key_cache", - "value_cache", - "block_tables", - "context_lengths", - "top_k_indices", - "num_heads", - "num_kv_heads", - "k_head_dim", - "v_head_dim", - "block_size", - "max_blocks", - "num_layers", - "num_total_blocks", - "scale", - "index_topk", - ] - kernel_name = "paged_attention_deepseek_v32_kernel" - filename = "paged_attention_deepseek_v32.metal" - elif window_size is not None and sinks is not None: - c_window_size = mk_int(window_size) - c_sinks = sinks - inputs = [ - queries, - key_cache, - value_cache, - block_tables, - context_lengths, - c_sinks, - c_num_heads, - c_num_kv_heads, - c_k_head_dim, - c_v_head_dim, - c_block_size, - c_max_blocks, - c_num_layers, - c_num_total_blocks, - c_scale, - c_window_size, - ] - - input_names = [ - "queries", - "key_cache", - "value_cache", - "block_tables", - "context_lengths", - "sinks", - "num_heads", - "num_kv_heads", - "k_head_dim", - "v_head_dim", - "block_size", - "max_blocks", - "num_layers", - "num_total_blocks", - "scale", - "window_size", - ] - kernel_name = "paged_attention_gpt_oss_kernel" - filename = "paged_attention_gpt_oss.metal" - else: - inputs = [ - queries, - key_cache, - value_cache, - block_tables, - context_lengths, - c_num_heads, - c_num_kv_heads, - c_k_head_dim, - c_v_head_dim, - c_block_size, - c_max_blocks, - c_num_layers, - c_num_total_blocks, - c_scale, - ] - - input_names = [ - "queries", - "key_cache", - "value_cache", - "block_tables", - "context_lengths", - "num_heads", - "num_kv_heads", - "k_head_dim", - "v_head_dim", - "block_size", - "max_blocks", - "num_layers", - "num_total_blocks", - "scale", - ] - kernel_name = "paged_attention_kernel" - filename = "paged_attention.metal" - - kernel = _get_kernel( - name=kernel_name, - filename=filename, - input_names=input_names, - output_names=["output"], - dtype=dtype, # This will generate paged_attention_kernel_half etc. - ) - - grid = (num_heads * 32, batch_size, 1) - thread_group = (32, 1, 1) - - outputs = kernel( - inputs=inputs, - grid=grid, - threadgroup=thread_group, - output_shapes=[(batch_size, num_heads, v_head_dim)], - output_dtypes=[dtype], # Output matches input dtype - verbose=False, - ) - - out = outputs[0] - return out[:, :, None, :] diff --git a/src/parallax/metal/paged_attention/paged_attention.metal b/src/parallax/metal/paged_attention/paged_attention.metal deleted file mode 100644 index 6780a411..00000000 --- a/src/parallax/metal/paged_attention/paged_attention.metal +++ /dev/null @@ -1,127 +0,0 @@ - -// Inputs: -// queries, key_cache, value_cache, block_tables, context_lengths -// output (output array) -// num_heads, num_kv_heads, k_head_dim, v_head_dim, block_size, max_blocks, -// num_layers, num_total_blocks, scale (All pointers) - -uint3 gid = thread_position_in_grid; -uint3 tid = thread_position_in_threadgroup; - -// Each threadgroup handles one head. -// Assuming threadgroup size is 32 (SIMD width). -// head_idx comes from the group index. -// Or we can calculate it from gid if grid is linear. -// grid.x = num_heads * 32. - -int head_idx = gid.x / 32; -int batch_idx = gid.y; - -// Dereference constants -int _num_heads = num_heads; -int _num_kv_heads = num_kv_heads; -int _k_head_dim = k_head_dim; -int _v_head_dim = v_head_dim; -int _block_size = block_size; -int _max_blocks = max_blocks; -int _num_total_blocks = num_total_blocks; -float _scale = scale; - -if (head_idx >= _num_heads) - return; - -int kv_head_idx = head_idx / (_num_heads / _num_kv_heads); - -// Load Query -// Q: [batch, num_heads, k_head_dim] -// Thread i loads elements i, i+32, ... - -// Support up to 256 head dim (8 * 32) -float q_vec[8] = {0.0f}; - -int q_offset = batch_idx * _num_heads * _k_head_dim + head_idx * _k_head_dim; - -for (int i = tid.x; i < _k_head_dim; i += 32) { - if (i < 256) { - q_vec[i / 32] = queries[q_offset + i]; - } -} - -// Running statistics for Softmax -float m_i = -INFINITY; -float l_i = 0.0f; -float acc_vec[8] = {0.0f}; - -int context_len = context_lengths[batch_idx]; -int num_context_blocks = (context_len + _block_size - 1) / _block_size; - -// Strides for Key -long k_block_stride = _num_kv_heads * _block_size * _k_head_dim; -long k_head_stride = _block_size * _k_head_dim; - -// Strides for Value -long v_block_stride = _num_kv_heads * _block_size * _v_head_dim; -long v_head_stride = _block_size * _v_head_dim; - -// Iterate over blocks -for (int b = 0; b < num_context_blocks; b++) { - int block_num = block_tables[batch_idx * _max_blocks + b]; - - long k_block_base = - block_num * k_block_stride + kv_head_idx * k_head_stride; - long v_block_base = - block_num * v_block_stride + kv_head_idx * v_head_stride; - - int tokens_in_block = _block_size; - if (b == num_context_blocks - 1) { - tokens_in_block = context_len % _block_size; - if (tokens_in_block == 0) - tokens_in_block = _block_size; - } - - for (int t = 0; t < tokens_in_block; t++) { - // Compute Dot Product Q * K[t] - float score = 0.0f; - for (int i = tid.x; i < _k_head_dim; i += 32) { - // offset inside block: t * k_head_dim + i - float k_val = key_cache[k_block_base + t * _k_head_dim + i]; - - if (i < 256) { - score += q_vec[i / 32] * k_val; - } - } - - // SIMD Reduction for score - score = simd_sum(score); - score *= _scale; - - // Softmax update - float m_prev = m_i; - m_i = max(m_prev, score); - float alpha = exp(m_prev - m_i); - float beta = exp(score - m_i); - - l_i = l_i * alpha + beta; - - // Accumulate V - for (int i = tid.x; i < _v_head_dim; i += 32) { - float v_val = value_cache[v_block_base + t * _v_head_dim + i]; - if (i < 256) { - acc_vec[i / 32] = acc_vec[i / 32] * alpha + v_val * beta; - } - } - } -} - -// Finalize Output -for (int i = 0; i < 8; i++) { - acc_vec[i] /= l_i; -} - -int out_offset = batch_idx * _num_heads * _v_head_dim + head_idx * _v_head_dim; - -for (int i = tid.x; i < _v_head_dim; i += 32) { - if (i < 256) { - output[out_offset + i] = ({{T}})acc_vec[i / 32]; - } -} diff --git a/src/parallax/metal/paged_attention/paged_attention_deepseek_v32.metal b/src/parallax/metal/paged_attention/paged_attention_deepseek_v32.metal deleted file mode 100644 index 900fa104..00000000 --- a/src/parallax/metal/paged_attention/paged_attention_deepseek_v32.metal +++ /dev/null @@ -1,180 +0,0 @@ -// Inputs: -// queries, key_cache, value_cache, block_tables, context_lengths, top_k_indices -// output (output array) -// num_heads, num_kv_heads, k_head_dim, v_head_dim, block_size, max_blocks, -// num_layers, num_total_blocks, scale, index_topk (All pointers) - -uint3 gid = thread_position_in_grid; -uint3 tid = thread_position_in_threadgroup; - -// Each threadgroup handles one head. -// Assuming threadgroup size is 32 (SIMD width). -// head_idx comes from the group index. -// Or we can calculate it from gid if grid is linear. -// grid.x = num_heads * 32. - -int head_idx = gid.x / 32; -int batch_idx = gid.y; - -// Dereference constants -int _num_heads = num_heads; -int _num_kv_heads = num_kv_heads; -int _k_head_dim = k_head_dim; -int _v_head_dim = v_head_dim; -int _block_size = block_size; -int _max_blocks = max_blocks; -int _num_total_blocks = num_total_blocks; -float _scale = scale; -int _index_topk = index_topk; - -if (head_idx >= _num_heads) - return; - -int kv_head_idx = head_idx / (_num_heads / _num_kv_heads); - -// Load Query -// Q: [batch, num_heads, k_head_dim] -// Thread i loads elements i, i+32, ... - -// Support up to 256 head dim (8 * 32) -float q_vec[8] = {0.0f}; - -int q_offset = batch_idx * _num_heads * _k_head_dim + head_idx * _k_head_dim; - -for (int i = tid.x; i < _k_head_dim; i += 32) { - if (i < 256) { - q_vec[i / 32] = queries[q_offset + i]; - } -} - -// Running statistics for Softmax -float m_i = -INFINITY; -float l_i = 0.0f; -float acc_vec[8] = {0.0f}; - -int context_len = context_lengths[batch_idx]; - -// Strides for Key -long k_block_stride = _num_kv_heads * _block_size * _k_head_dim; -long k_head_stride = _block_size * _k_head_dim; - -// Strides for Value -long v_block_stride = _num_kv_heads * _block_size * _v_head_dim; -long v_head_stride = _block_size * _v_head_dim; - -// Check if we use Full Attention or Sparse Attention -// We check the first element of top_k_indices for this batch -int first_topk_idx = top_k_indices[batch_idx * _index_topk]; - -if (first_topk_idx == -1) { - // ------------------------------------------------------------------------- - // Full Attention (Standard Paged Attention) - // ------------------------------------------------------------------------- - int num_context_blocks = (context_len + _block_size - 1) / _block_size; - - for (int b = 0; b < num_context_blocks; b++) { - int block_num = block_tables[batch_idx * _max_blocks + b]; - - long k_block_base = - block_num * k_block_stride + kv_head_idx * k_head_stride; - long v_block_base = - block_num * v_block_stride + kv_head_idx * v_head_stride; - - int tokens_in_block = _block_size; - if (b == num_context_blocks - 1) { - tokens_in_block = context_len % _block_size; - if (tokens_in_block == 0) - tokens_in_block = _block_size; - } - - for (int t = 0; t < tokens_in_block; t++) { - // Compute Dot Product Q * K[t] - float score = 0.0f; - for (int i = tid.x; i < _k_head_dim; i += 32) { - float k_val = key_cache[k_block_base + t * _k_head_dim + i]; - if (i < 256) { - score += q_vec[i / 32] * k_val; - } - } - - // SIMD Reduction for score - score = simd_sum(score); - score *= _scale; - - // Softmax update - float m_prev = m_i; - m_i = max(m_prev, score); - float alpha = exp(m_prev - m_i); - float beta = exp(score - m_i); - - l_i = l_i * alpha + beta; - - // Accumulate V - for (int i = tid.x; i < _v_head_dim; i += 32) { - float v_val = value_cache[v_block_base + t * _v_head_dim + i]; - if (i < 256) { - acc_vec[i / 32] = acc_vec[i / 32] * alpha + v_val * beta; - } - } - } - } -} else { - // ------------------------------------------------------------------------- - // Sparse Attention (Top-K Indices) - // ------------------------------------------------------------------------- - for (int k = 0; k < _index_topk; k++) { - int token_idx = top_k_indices[batch_idx * _index_topk + k]; - - // Map logical token index to physical location - int block_idx_in_table = token_idx / _block_size; - int block_offset = token_idx % _block_size; - int block_num = block_tables[batch_idx * _max_blocks + block_idx_in_table]; - - long k_block_base = - block_num * k_block_stride + kv_head_idx * k_head_stride; - long v_block_base = - block_num * v_block_stride + kv_head_idx * v_head_stride; - - // Compute Dot Product Q * K[token_idx] - float score = 0.0f; - for (int i = tid.x; i < _k_head_dim; i += 32) { - float k_val = key_cache[k_block_base + block_offset * _k_head_dim + i]; - if (i < 256) { - score += q_vec[i / 32] * k_val; - } - } - - // SIMD Reduction for score - score = simd_sum(score); - score *= _scale; - - // Softmax update - float m_prev = m_i; - m_i = max(m_prev, score); - float alpha = exp(m_prev - m_i); - float beta = exp(score - m_i); - - l_i = l_i * alpha + beta; - - // Accumulate V - for (int i = tid.x; i < _v_head_dim; i += 32) { - float v_val = value_cache[v_block_base + block_offset * _v_head_dim + i]; - if (i < 256) { - acc_vec[i / 32] = acc_vec[i / 32] * alpha + v_val * beta; - } - } - } -} - -// Finalize Output -for (int i = 0; i < 8; i++) { - acc_vec[i] /= l_i; -} - -int out_offset = batch_idx * _num_heads * _v_head_dim + head_idx * _v_head_dim; - -for (int i = tid.x; i < _v_head_dim; i += 32) { - if (i < 256) { - output[out_offset + i] = ({{T}})acc_vec[i / 32]; - } -} diff --git a/src/parallax/metal/paged_attention/paged_attention_gpt_oss.metal b/src/parallax/metal/paged_attention/paged_attention_gpt_oss.metal deleted file mode 100644 index 4a63d62a..00000000 --- a/src/parallax/metal/paged_attention/paged_attention_gpt_oss.metal +++ /dev/null @@ -1,177 +0,0 @@ - -// Inputs: -// queries, key_cache, value_cache, block_tables, context_lengths, sinks -// output (output array) -// num_heads, num_kv_heads, k_head_dim, v_head_dim, block_size, max_blocks, -// num_layers, num_total_blocks, scale, window_size (All pointers) - -uint3 gid = thread_position_in_grid; -uint3 tid = thread_position_in_threadgroup; - -// Each threadgroup handles one head. -// Assuming threadgroup size is 32 (SIMD width). -// head_idx comes from the group index. -// Or we can calculate it from gid if grid is linear. -// grid.x = num_heads * 32. - -int head_idx = gid.x / 32; -int batch_idx = gid.y; - -// Dereference constants -int _num_heads = num_heads; -int _num_kv_heads = num_kv_heads; -int _k_head_dim = k_head_dim; -int _v_head_dim = v_head_dim; -int _block_size = block_size; -int _max_blocks = max_blocks; -int _num_total_blocks = num_total_blocks; -float _scale = scale; -int _window_size = window_size; - -if (head_idx >= _num_heads) - return; - -int kv_head_idx = head_idx / (_num_heads / _num_kv_heads); - -// Load Query -// Q: [batch, num_heads, k_head_dim] -// Thread i loads elements i, i+32, ... - -// Support up to 256 head dim (8 * 32) -float q_vec[8] = {0.0f}; - -int q_offset = batch_idx * _num_heads * _k_head_dim + head_idx * _k_head_dim; - -for (int i = tid.x; i < _k_head_dim; i += 32) { - if (i < 256) { - q_vec[i / 32] = queries[q_offset + i]; - } -} - -// Running statistics for Softmax -float m_i = -INFINITY; -float l_i = 0.0f; -float acc_vec[8] = {0.0f}; - -// ------------------------------------------------------------------------- -// Handle Implicit Sink Token (KV = 0, Not in Block Table) -// ------------------------------------------------------------------------- -// The sink token is assumed to be an extra token (logical pos 0? or just extra) -// that is NOT stored in the paged KV blocks. -// Its Key is 0, Value is 0. -// Its Attention Score = (Q * K_sink) * scale + sinks_bias -// = (Q * 0) * scale + sinks[head_idx] -// = sinks[head_idx] - -float sink_score = sinks[head_idx]; - -// Initialize Softmax stats with Sink Token if it's not masked out (-inf) -// We use a threshold slightly larger than -INFINITY to avoid precision issues -if (sink_score > -3.40282e+38f) { // roughly > -FLT_MAX - m_i = sink_score; - l_i = 1.0f; - // acc_vec remains 0.0f because V_sink is 0. -} - -int context_len = context_lengths[batch_idx]; -int num_context_blocks = (context_len + _block_size - 1) / _block_size; - -// Strides for Key -long k_block_stride = _num_kv_heads * _block_size * _k_head_dim; -long k_head_stride = _block_size * _k_head_dim; - -// Strides for Value -long v_block_stride = _num_kv_heads * _block_size * _v_head_dim; -long v_head_stride = _block_size * _v_head_dim; - -// Iterate over blocks -for (int b = 0; b < num_context_blocks; b++) { - int block_num = block_tables[batch_idx * _max_blocks + b]; - - // Calculate logic position range for this block - int block_start_pos = b * _block_size; - int block_end_pos = block_start_pos + _block_size; // exclusive - - // Handle Window Attention Skip - // Current query position is at (context_len - 1) - // Window range is [context_len - 1 - window_size, context_len - 1] - - // NOTE: Sink token is handled separately above, so we don't need to protect block 0 - // unless block 0 also contains other vital tokens (it usually does). - - if (_window_size > 0) { - int window_start = context_len - 1 - _window_size; - if (block_end_pos <= window_start) { - // Entire block is out of window - continue; - } - } - - long k_block_base = - block_num * k_block_stride + kv_head_idx * k_head_stride; - long v_block_base = - block_num * v_block_stride + kv_head_idx * v_head_stride; - - int tokens_in_block = _block_size; - if (b == num_context_blocks - 1) { - tokens_in_block = context_len % _block_size; - if (tokens_in_block == 0) - tokens_in_block = _block_size; - } - - for (int t = 0; t < tokens_in_block; t++) { - int token_pos = block_start_pos + t; - - // Check if token is within window - if (_window_size > 0) { - int window_start = context_len - 1 - _window_size; - if (token_pos < window_start) { - continue; - } - } - - // Compute Dot Product Q * K[t] - float score = 0.0f; - for (int i = tid.x; i < _k_head_dim; i += 32) { - // offset inside block: t * k_head_dim + i - float k_val = key_cache[k_block_base + t * _k_head_dim + i]; - - if (i < 256) { - score += q_vec[i / 32] * k_val; - } - } - - // SIMD Reduction for score - score = simd_sum(score); - score *= _scale; - - // Softmax update - float m_prev = m_i; - m_i = max(m_prev, score); - float alpha = exp(m_prev - m_i); - float beta = exp(score - m_i); - - l_i = l_i * alpha + beta; - - // Accumulate V - for (int i = tid.x; i < _v_head_dim; i += 32) { - float v_val = value_cache[v_block_base + t * _v_head_dim + i]; - if (i < 256) { - acc_vec[i / 32] = acc_vec[i / 32] * alpha + v_val * beta; - } - } - } -} - -// Finalize Output -for (int i = 0; i < 8; i++) { - acc_vec[i] /= l_i; -} - -int out_offset = batch_idx * _num_heads * _v_head_dim + head_idx * _v_head_dim; - -for (int i = tid.x; i < _v_head_dim; i += 32) { - if (i < 256) { - output[out_offset + i] = ({{T}})acc_vec[i / 32]; - } -} diff --git a/src/parallax/models/deepseek_v2.py b/src/parallax/models/deepseek_v2.py deleted file mode 100644 index b8b4774b..00000000 --- a/src/parallax/models/deepseek_v2.py +++ /dev/null @@ -1,200 +0,0 @@ -""" -hidden_dimefines the Qwen3 model. -""" - -from typing import Any, List, Optional - -from parallax_utils.logging_config import get_logger - -logger = get_logger(__name__) - -import mlx.core as mx -from mlx_lm.models.base import scaled_dot_product_attention -from mlx_lm.models.deepseek_v2 import DeepseekV2Attention as MLXDeepseekV2Attention -from mlx_lm.models.deepseek_v2 import DeepseekV2DecoderLayer as MLXDeepseekV2Block -from mlx_lm.models.deepseek_v2 import ModelArgs - -from parallax.metal.paged_attention.kernel import paged_attention -from parallax.server.cache.base import BaseCache -from parallax.utils.prefix_cache_utils import compute_attention_with_prefix_cache -from parallax_extensions.ops import reshape_and_cache - - -class ParallaxDeepSeekV2Attention(MLXDeepseekV2Attention): - """A custom attention module for Parallax, extending the DeepseekV2 Attention class. - - We apply explicit KV cache handling and passing in `offset` directly from Request. - This version returns the new K and V states for external caching. - """ - - def __call__( - self, - x: mx.array, - mask: Optional[mx.array] = None, - cache: Optional[BaseCache] = None, - block_tables: Optional[mx.array] = None, - context_lengths: Optional[mx.array] = None, - slot_mapping: Optional[mx.array] = None, - prefix_lens: Optional[mx.array] = None, - **kwargs, - ) -> mx.array: - """ - Attention forward pass with explicit KV cache handling. - - Args: - x: (batch, target_len, hidden_dim) - Input hidden states for the current query segment. - mask: (batch, n_q_heads, target_len, source_len) - cache: BaseCache object containing the layer cache. - block_tables: (batch, max_blocks) - PagedKV block tables. - context_lengths: (batch,) - PagedKV sequence lengths. - slot_mapping: (batch * target_len,) - Flattened slot mapping. - prefix_lens: (batch,) - Number of prefix tokens already cached (for RoPE offset). - - Returns: - output_h: (batch, target_len, hidden_dim) - Output hidden states. - """ - batch, target_len, _ = x.shape - if self.q_lora_rank is None: - q = self.q_proj(x) - else: - q = self.q_b_proj(self.q_a_layernorm(self.q_a_proj(x))) - - q = q.reshape(batch, target_len, self.num_heads, self.q_head_dim).transpose(0, 2, 1, 3) - q_nope, q_pe = mx.split(q, [self.qk_nope_head_dim], axis=-1) - compressed_kv = self.kv_a_proj_with_mqa(x) - compressed_kv, k_pe = mx.split(compressed_kv, [self.kv_lora_rank], axis=-1) - k_pe = k_pe.reshape(batch, target_len, 1, self.qk_rope_head_dim).transpose(0, 2, 1, 3) - kv = self.kv_b_proj(self.kv_a_layernorm(compressed_kv)) - kv = kv.reshape(batch, target_len, self.num_heads, -1) - - k_nope, values = mx.split(kv, [self.qk_nope_head_dim], axis=-1) - k_nope = k_nope.transpose(0, 2, 1, 3) - - key_cache_global, value_cache_global = cache.get_cache() - - # Compute RoPE offsets for all batch items at once - if target_len == 1: - # Decode phase: position is context_length - 1 - current_pos = context_lengths - 1 - elif prefix_lens is not None: - # Prefill phase - start from prefix_len if using prefix cache - current_pos = prefix_lens - else: - # Prefill phase - no prefix cache - current_pos = 0 - - # Apply RoPE to all batch items at once - q_pe = self.rope(q_pe, offset=current_pos) - k_pe = self.rope(k_pe, offset=current_pos) - - k_pe = mx.repeat(k_pe, self.num_heads, axis=1) - queries = mx.concatenate([q_nope, q_pe], axis=-1) - - # Construct full keys - keys = mx.concatenate([k_nope, k_pe], axis=-1) - - block_size = key_cache_global.shape[3] - - reshape_and_cache( - keys.transpose(0, 2, 1, 3), - values, - key_cache_global, - value_cache_global, - block_tables, - context_lengths, - block_size, - slot_mapping=slot_mapping, - ) - - if target_len == 1: - # Decode Phase: Use Paged Attention Kernel - output = paged_attention( - queries, - key_cache_global, - value_cache_global, - block_tables, - context_lengths, - block_size, - self.scale, - self.num_heads, # num_kv_heads (MQA/MLA, here num_heads == num_kv_heads effectively after repeat?) - v_head_dim=values.shape[-1], - ) - output = output.transpose(0, 2, 1, 3).reshape(batch, target_len, -1) - else: - # Prefill Phase: Need to attend to both cached prefix and new tokens - # Check if any request has prefix cache - has_prefix_cache = prefix_lens is not None and bool(mx.any(prefix_lens > 0)) - - if has_prefix_cache: - # Use shared prefix cache handling with batch processing - k_new = keys # (batch, num_heads, target_len, head_dim) - v_new = values.transpose(0, 2, 1, 3) # (batch, num_heads, target_len, head_dim) - output = compute_attention_with_prefix_cache( - queries, # (batch, num_heads, target_len, head_dim) - k_new, - v_new, - cache, - block_tables, - prefix_lens, - target_len, - self.scale, - self.num_heads, # num_kv_heads (in DeepSeekV2, num_heads == num_kv_heads after repeat) - mask=mask, - ) - else: - # No prefix cache, use standard self-attention on local data only - output = scaled_dot_product_attention( - queries, - keys, - values.transpose(0, 2, 1, 3), - scale=self.scale, - mask=mask, - cache=None, - ) - output = output.transpose(0, 2, 1, 3).reshape(batch, target_len, -1) - - return self.o_proj(output) - - -class ParallaxDeepSeekV2Block(MLXDeepseekV2Block): - """A custom transformer block for Parallax, extending the Qwen3 Block class. - This version handles the KV cache explicitly and returns new K and V states. - """ - - def __init__(self, args: ModelArgs, layer_idx: int, local_layer_idx: int): - super().__init__(args, layer_idx=layer_idx) - self.self_attn = ParallaxDeepSeekV2Attention(args) - self.layer_idx = layer_idx - self.local_layer_idx = local_layer_idx - - def __call__( - self, - x: mx.array, - mask: Optional[mx.array] = None, - cache: Optional[List[Any]] = None, - block_tables: Optional[mx.array] = None, - context_lengths: Optional[mx.array] = None, - slot_mapping: Optional[mx.array] = None, - **kwargs, - ): - r = self.self_attn( - self.input_layernorm(x), - mask, - cache[self.local_layer_idx], - block_tables=block_tables, - context_lengths=context_lengths, - slot_mapping=slot_mapping, - **kwargs, - ) - h = x + r - r = self.mlp(self.post_attention_layernorm(h)) - out = h + r - return out - - @classmethod - def get_architecture(cls): - """Get the architecture name for the block.""" - return "DeepseekV2ForCausalLM" - - -EntryClass = ParallaxDeepSeekV2Block diff --git a/src/parallax/models/deepseek_v3.py b/src/parallax/models/deepseek_v3.py index 2d34b2d1..2a4ccfbb 100644 --- a/src/parallax/models/deepseek_v3.py +++ b/src/parallax/models/deepseek_v3.py @@ -14,10 +14,9 @@ from mlx_lm.models.deepseek_v3 import DeepseekV3DecoderLayer as MLXDeepseekV3Block from mlx_lm.models.deepseek_v3 import ModelArgs -from parallax.metal.paged_attention.kernel import paged_attention from parallax.server.cache.base import BaseCache -from parallax.utils.prefix_cache_utils import compute_attention_with_prefix_cache -from parallax_extensions.ops import reshape_and_cache +from parallax.server.cache.dsa_cache import DeepSeekSparseCache +from parallax_extensions.ops import mla_paged_attention, reshape_and_cache class ParallaxDeepSeekV3Attention(MLXDeepseekV3Attention): @@ -27,6 +26,62 @@ class ParallaxDeepSeekV3Attention(MLXDeepseekV3Attention): This version returns the new K and V states for external caching. """ + def _read_mla_batch( + self, + cache: DeepSeekSparseCache, + block_tables: mx.array, + lengths: mx.array, + max_len: int, + dtype: mx.Dtype, + ) -> tuple[mx.array, mx.array]: + batch = lengths.shape[0] + latent = mx.zeros((batch, 1, max_len, self.kv_lora_rank), dtype=dtype) + rope = mx.zeros((batch, 1, max_len, self.qk_rope_head_dim), dtype=dtype) + + for i in range(batch): + length = int(lengths[i]) + if length <= 0: + continue + latent_i, rope_i = cache.read_prefix_mla(block_tables[i], length) + latent[i, :, :length, :] = latent_i + rope[i, :, :length, :] = rope_i + return latent, rope + + def _mla_attention( + self, + q_nope: mx.array, + q_pe: mx.array, + kv_latent: mx.array, + k_pe: mx.array, + mask: Optional[mx.array], + ) -> mx.array: + pe_scores = (q_pe * self.scale) @ k_pe.swapaxes(-1, -2) + if mask is not None: + pe_scores = pe_scores + mask.astype(pe_scores.dtype) + + if q_nope.shape[2] == 1: + q_latent = self.embed_q(q_nope) + output = scaled_dot_product_attention( + q_latent, + kv_latent, + kv_latent, + scale=self.scale, + mask=pe_scores, + cache=None, + ) + return self.unembed_out(output) + + k_nope = self.embed_q(kv_latent, transpose=False) + values = self.unembed_out(kv_latent) + return scaled_dot_product_attention( + q_nope, + k_nope, + values, + scale=self.scale, + mask=pe_scores, + cache=None, + ) + def __call__( self, x: mx.array, @@ -56,6 +111,9 @@ def __call__( output_h: (batch, target_len, hidden_dim) - Output hidden states. """ batch, target_len, _ = x.shape + if not isinstance(cache, DeepSeekSparseCache): + raise TypeError("ParallaxDeepSeekV3Attention requires DeepSeekSparseCache.") + if self.q_lora_rank is None: q = self.q_proj(x) else: @@ -66,13 +124,8 @@ def __call__( compressed_kv = self.kv_a_proj_with_mqa(x) compressed_kv, k_pe = mx.split(compressed_kv, [self.kv_lora_rank], axis=-1) k_pe = k_pe.reshape(batch, target_len, 1, self.qk_rope_head_dim).transpose(0, 2, 1, 3) - kv = self.kv_b_proj(self.kv_a_layernorm(compressed_kv)) - - kv = kv.reshape(batch, target_len, self.num_heads, -1) - k_nope, values = mx.split(kv, [self.qk_nope_head_dim], axis=-1) - k_nope = k_nope.transpose(0, 2, 1, 3) - - key_cache_global, value_cache_global = cache.get_cache() + kv_latent = self.kv_a_layernorm(compressed_kv) + kv_latent = kv_latent[:, None, :, :] # Compute RoPE offsets using array operations instead of loops if target_len == 1: @@ -89,73 +142,87 @@ def __call__( q_pe = self.rope(q_pe, offset=current_pos) k_pe = self.rope(k_pe, offset=current_pos) - k_pe = mx.repeat(k_pe, self.num_heads, axis=1) - queries = mx.concatenate([q_nope, q_pe], axis=-1) - keys = mx.concatenate([k_nope, k_pe], axis=-1) - - block_size = key_cache_global.shape[3] + latent_cache, rope_cache = cache.get_cache() reshape_and_cache( - keys.transpose(0, 2, 1, 3), - values, - key_cache_global, - value_cache_global, + kv_latent.transpose(0, 2, 1, 3), + k_pe.transpose(0, 2, 1, 3), + latent_cache, + rope_cache, block_tables, context_lengths, - block_size, + cache.block_size, slot_mapping=slot_mapping, ) if target_len == 1: - # Decode phase: Use Paged Attention - output = paged_attention( - queries, - key_cache_global, - value_cache_global, + q_latent = self.embed_q(q_nope) + output = mla_paged_attention( + q_latent, + q_pe, + latent_cache, + rope_cache, block_tables, context_lengths, - block_size, + cache.block_size, self.scale, - self.num_heads, - v_head_dim=values.shape[-1], ) + output = self.unembed_out(output) output = output.transpose(0, 2, 1, 3).reshape(batch, target_len, -1) else: - # Prefill Phase: Need to attend to both cached prefix and new tokens - # Check if any request has prefix cache has_prefix_cache = prefix_lens is not None and bool(mx.any(prefix_lens > 0)) if has_prefix_cache: - # Use shared prefix cache handling with batch processing - # keys: (batch, num_heads, target_len, head_dim) - # values: (batch, target_len, num_heads, head_dim) -> transpose to (batch, num_heads, target_len, head_dim) - k_new = keys # (batch, num_heads, target_len, head_dim) - v_new = values.transpose(0, 2, 1, 3) # (batch, num_heads, target_len, head_dim) - output = compute_attention_with_prefix_cache( - queries, # (batch, num_heads, target_len, head_dim) - k_new, - v_new, - cache, - block_tables, - prefix_lens, - target_len, - self.scale, - self.num_heads, # In deepseek_v3, num_heads equals n_kv_heads after MQA processing - mask=mask, - ) + max_prefix_len = int(mx.max(prefix_lens)) + if max_prefix_len > 0: + prefix_latent, prefix_k_pe = self._read_mla_batch( + cache, + block_tables, + prefix_lens, + max_prefix_len, + kv_latent.dtype, + ) + kv_full = mx.concatenate([prefix_latent, kv_latent], axis=2) + k_pe_full = mx.concatenate([prefix_k_pe, k_pe], axis=2) + else: + kv_full = kv_latent + k_pe_full = k_pe + + row_indices = mx.arange(target_len, dtype=mx.int32) + new_lens = context_lengths - prefix_lens + q_positions = prefix_lens[:, None] + row_indices[None, :] + + if max_prefix_len > 0: + prefix_positions = mx.arange(max_prefix_len, dtype=mx.int32) + prefix_positions = mx.broadcast_to( + prefix_positions[None, :], (batch, max_prefix_len) + ) + prefix_valid = prefix_positions < prefix_lens[:, None] + else: + prefix_positions = mx.zeros((batch, 0), dtype=mx.int32) + prefix_valid = mx.zeros((batch, 0), dtype=mx.bool_) + + new_positions = prefix_lens[:, None] + row_indices[None, :] + new_valid = row_indices[None, :] < new_lens[:, None] + key_positions = mx.concatenate([prefix_positions, new_positions], axis=1) + key_valid = mx.concatenate([prefix_valid, new_valid], axis=1) + valid = key_valid[:, None, :] & new_valid[:, :, None] + valid = valid & (key_positions[:, None, :] <= q_positions[:, :, None]) + mask = mx.where(valid[:, None, :, :], 0.0, -float("inf")).astype(q_nope.dtype) + + output = self._mla_attention(q_nope, q_pe, kv_full, k_pe_full, mask) + output = output.transpose(0, 2, 1, 3).reshape(batch, target_len, -1) else: - # No prefix cache, use standard self-attention on local data only if mask is not None: - mask = mx.array(mask, dtype=queries.dtype) - - output = scaled_dot_product_attention( - queries, - keys, - values.transpose(0, 2, 1, 3), - scale=self.scale, - mask=mask, - cache=None, - ) + if mask.ndim == 2: + mask = mask[None, None, :, :] + elif mask.ndim == 3: + mask = mask[:, None, :, :] + if mask.dtype == mx.bool_: + mask = mx.where(mask, 0.0, -float("inf")) + mask = mask.astype(q_nope.dtype) + + output = self._mla_attention(q_nope, q_pe, kv_latent, k_pe, mask) output = output.transpose(0, 2, 1, 3).reshape(batch, target_len, -1) return self.o_proj(output) diff --git a/src/parallax/server/cache/dsa_cache.py b/src/parallax/server/cache/dsa_cache.py index 1f61f627..c903576c 100644 --- a/src/parallax/server/cache/dsa_cache.py +++ b/src/parallax/server/cache/dsa_cache.py @@ -25,10 +25,10 @@ def __init__( head_dim: int, head_dim_v: int, dtype: mx.Dtype, - index_head_dim: int, - index_n_heads: int, kv_lora_rank: int, qk_rope_head_dim: int, + index_head_dim: Optional[int] = None, + index_n_heads: Optional[int] = None, index_key_heads: int = 1, ): self.num_blocks = num_blocks @@ -39,6 +39,9 @@ def __init__( self.dtype = dtype self.kv_lora_rank = kv_lora_rank self.qk_rope_head_dim = qk_rope_head_dim + if (index_head_dim is None) != (index_n_heads is None): + raise ValueError("index_head_dim and index_n_heads must be provided together.") + self.index_head_dim = index_head_dim self.index_n_heads = index_n_heads self.index_key_heads = index_key_heads @@ -51,17 +54,21 @@ def __init__( (1, num_blocks, 1, block_size, qk_rope_head_dim), dtype=dtype, ) - self.indexer_key_cache = mx.zeros( - ( - 1, - num_blocks, - index_key_heads, - block_size, - index_head_dim, - ), - dtype=dtype, - ) - mx.eval(self.latent_cache, self.rope_cache, self.indexer_key_cache) + self.indexer_key_cache = None + if index_head_dim is not None: + self.indexer_key_cache = mx.zeros( + ( + 1, + num_blocks, + index_key_heads, + block_size, + index_head_dim, + ), + dtype=dtype, + ) + mx.eval(self.latent_cache, self.rope_cache, self.indexer_key_cache) + else: + mx.eval(self.latent_cache, self.rope_cache) def get_cache(self) -> Tuple[mx.array, mx.array]: return self.latent_cache, self.rope_cache @@ -133,6 +140,9 @@ def read_index_k( Returns: index_k: (index_heads, context_len, index_head_dim) """ + if self.indexer_key_cache is None: + raise ValueError("DeepSeekSparseCache was created without an indexer cache.") + positions = mx.arange(context_len) block_indices = positions // self.block_size offsets = positions % self.block_size diff --git a/src/parallax/server/cache_manager.py b/src/parallax/server/cache_manager.py index 6f2df219..7466edab 100644 --- a/src/parallax/server/cache_manager.py +++ b/src/parallax/server/cache_manager.py @@ -2,6 +2,14 @@ import mlx.core as mx +from parallax.utils.layer_types import ( + ATTENTION, + ATTENTION_LAYER_TYPES, + DSA_ATTENTION, + LINEAR, + MLA_ATTENTION, + MSA_ATTENTION, +) from parallax.server.block_radix_cache import BlockRadixCache from parallax.server.cache.allocator import BlockAllocator, SlotAllocator from parallax.server.cache.base import BaseCache @@ -35,8 +43,7 @@ def __init__( index_key_heads: Optional[int] = None, kv_lora_rank: Optional[int] = None, qk_rope_head_dim: Optional[int] = None, - sparse_cache_type: Optional[str] = None, - # Hybrid Config: List of 'attention' or 'linear' or None (default 'attention') + # Hybrid Config: List of cache layer types or None (default 'attention') layer_types: Optional[List[str]] = None, # Linear Model / State Cache Params conv_dim: Optional[int] = None, @@ -59,7 +66,6 @@ def __init__( self.index_key_heads = index_key_heads self.kv_lora_rank = kv_lora_rank self.qk_rope_head_dim = qk_rope_head_dim - self.sparse_cache_type = sparse_cache_type self.dtype = dtype self.block_size = block_size self.max_num_seqs = max_num_seqs @@ -82,14 +88,14 @@ def __init__( # Determine layer types if layer_types is None: - self.layer_types = ["attention"] * num_layers + self.layer_types = [ATTENTION] * num_layers else: assert len(layer_types) == num_layers, "layer_types length must match num_layers" self.layer_types = layer_types # Check if we need blocks (any attention layer) and slots (any linear layer) - self.needs_blocks = any(t == "attention" for t in self.layer_types) - self.needs_slots = any(t == "linear" for t in self.layer_types) + self.needs_blocks = any(t in ATTENTION_LAYER_TYPES for t in self.layer_types) + self.needs_slots = any(t == LINEAR for t in self.layer_types) self.num_gpu_blocks, self.num_linear_prefix_slots = self._calculate_cache_allocation( self.cache_memory_fraction, self.dtype @@ -115,12 +121,12 @@ def __init__( if self.needs_blocks: logger.info( - f"Allocated Paged KV Cache for {self.layer_types.count('attention')} layers: " + f"Allocated Paged KV Cache for {self._num_attention_layers()} layers: " f"{self.num_gpu_blocks} blocks, {self.block_size} block_size, max_tokens: {self.num_gpu_blocks * self.block_size}" ) if self.needs_slots: logger.info( - f"Allocated Linear State Cache for {self.layer_types.count('linear')} layers: " + f"Allocated Linear State Cache for {self._num_linear_layers()} layers: " f"{self.max_num_seqs} active slots, " f"{self.num_linear_prefix_slots} prefix slots" ) @@ -162,50 +168,82 @@ def _on_prefix_linear_slot_evict(self, slot: int): self.prefix_slot_allocator.free(slot) logger.debug(f"Freed evicted prefix linear slot: {slot}") + def _num_attention_layers(self) -> int: + return sum(1 for t in self.layer_types if t in ATTENTION_LAYER_TYPES) + + def _num_linear_layers(self) -> int: + return sum(1 for t in self.layer_types if t == LINEAR) + + def _validate_mla_cache_params(self, layer_type: str): + if self.kv_lora_rank is None or self.qk_rope_head_dim is None: + raise ValueError( + f"{layer_type} requires kv_lora_rank and qk_rope_head_dim " + "for compressed MLA storage." + ) + + def _validate_index_cache_params(self, layer_type: str): + if self.index_head_dim is None or self.index_n_heads is None: + raise ValueError(f"{layer_type} requires index_head_dim and index_n_heads.") + def _create_cache(self, layer_type: str) -> BaseCache: - if layer_type == "attention": - if self.index_head_dim is not None and self.index_n_heads is not None: - if self.sparse_cache_type == "minimax_m3": - return MiniMaxM3SparseCache( - num_blocks=self.num_gpu_blocks, - block_size=self.block_size, - num_kv_heads=self.num_kv_heads, - head_dim=self.head_dim, - head_dim_v=self.head_dim_v, - dtype=self.dtype, - index_head_dim=self.index_head_dim, - index_n_heads=self.index_n_heads, - index_key_heads=self.index_key_heads or 1, - ) - if self.kv_lora_rank is None or self.qk_rope_head_dim is None: - raise ValueError( - "DeepSeek/GLM DSA cache requires kv_lora_rank and qk_rope_head_dim " - "for compressed MLA storage." - ) - return DeepSeekSparseCache( - num_blocks=self.num_gpu_blocks, - block_size=self.block_size, - num_kv_heads=self.num_kv_heads, - head_dim=self.head_dim, - head_dim_v=self.head_dim_v, - dtype=self.dtype, - index_head_dim=self.index_head_dim, - index_n_heads=self.index_n_heads, - kv_lora_rank=self.kv_lora_rank, - qk_rope_head_dim=self.qk_rope_head_dim, - index_key_heads=self.index_key_heads or 1, - ) - else: - return KVCachePacked( - num_blocks=self.num_gpu_blocks, - block_size=self.block_size, - num_kv_heads=self.num_kv_heads, - head_dim=self.head_dim, - head_dim_v=self.head_dim_v, - dtype=self.dtype, - ) + if layer_type == ATTENTION: + return KVCachePacked( + num_blocks=self.num_gpu_blocks, + block_size=self.block_size, + num_kv_heads=self.num_kv_heads, + head_dim=self.head_dim, + head_dim_v=self.head_dim_v, + dtype=self.dtype, + ) + + if layer_type == MLA_ATTENTION: + self._validate_mla_cache_params(layer_type) + return DeepSeekSparseCache( + num_blocks=self.num_gpu_blocks, + block_size=self.block_size, + num_kv_heads=self.num_kv_heads, + head_dim=self.head_dim, + head_dim_v=self.head_dim_v, + dtype=self.dtype, + index_head_dim=None, + index_n_heads=None, + kv_lora_rank=self.kv_lora_rank, + qk_rope_head_dim=self.qk_rope_head_dim, + index_key_heads=None, + ) + + if layer_type == DSA_ATTENTION: + self._validate_mla_cache_params(layer_type) + self._validate_index_cache_params(layer_type) + return DeepSeekSparseCache( + num_blocks=self.num_gpu_blocks, + block_size=self.block_size, + num_kv_heads=self.num_kv_heads, + head_dim=self.head_dim, + head_dim_v=self.head_dim_v, + dtype=self.dtype, + index_head_dim=self.index_head_dim, + index_n_heads=self.index_n_heads, + kv_lora_rank=self.kv_lora_rank, + qk_rope_head_dim=self.qk_rope_head_dim, + index_key_heads=self.index_key_heads or 1, + ) - elif layer_type == "linear": + if layer_type == MSA_ATTENTION: + self._validate_index_cache_params(layer_type) + return MiniMaxM3SparseCache( + num_blocks=self.num_gpu_blocks, + block_size=self.block_size, + num_kv_heads=self.num_kv_heads, + head_dim=self.head_dim, + head_dim_v=self.head_dim_v, + dtype=self.dtype, + index_head_dim=self.index_head_dim, + index_n_heads=self.index_n_heads, + index_key_heads=self.index_key_heads or 1, + ) + + if layer_type == LINEAR: # We assume uniform linear config for all linear layers for now return LinearCache( max_num_seqs=self.max_linear_slots, @@ -217,8 +255,8 @@ def _create_cache(self, layer_type: str) -> BaseCache: linear_num_v_heads=self.linear_num_v_heads, dtype=self.dtype, ) - else: - raise ValueError(f"Unknown layer type: {layer_type}") + + raise ValueError(f"Unknown layer type: {layer_type}") def _needs_prefix_linear_slots(self) -> bool: return self.enable_prefix_cache and self.needs_blocks and self.needs_slots @@ -228,7 +266,7 @@ def _dtype_size(self, dtype: mx.Dtype) -> int: def _calculate_linear_slot_bytes(self, dtype_size: int) -> int: """Calculate memory needed for one linear slot across all linear layers.""" - num_linear_layers = self.layer_types.count("linear") + num_linear_layers = self._num_linear_layers() if num_linear_layers == 0: return 0 @@ -257,32 +295,39 @@ def _calculate_linear_cache_bytes(self, dtype_size: int, num_slots: int) -> int: def _calculate_kv_block_bytes(self, dtype_size: int) -> int: """Calculate memory needed for one KV block across all attention layers.""" - if ( - self.index_head_dim is not None - and self.index_n_heads is not None - and self.sparse_cache_type != "minimax_m3" - and self.kv_lora_rank is not None - and self.qk_rope_head_dim is not None - ): - one_layer_block_bytes = ( - self.block_size * (self.kv_lora_rank + self.qk_rope_head_dim) * dtype_size - ) - else: - one_layer_block_bytes = ( - self.num_kv_heads * self.block_size * (self.head_dim + self.head_dim_v) * dtype_size - ) - if self.index_head_dim is not None and self.index_n_heads is not None: - if self.sparse_cache_type == "minimax_m3": - index_heads = self.index_key_heads or 1 - elif self.kv_lora_rank is not None and self.qk_rope_head_dim is not None: - index_heads = self.index_key_heads or 1 + total_bytes = 0 + standard_block_bytes = ( + self.num_kv_heads * self.block_size * (self.head_dim + self.head_dim_v) * dtype_size + ) + for layer_type in self.layer_types: + if layer_type == ATTENTION: + total_bytes += standard_block_bytes + elif layer_type == MLA_ATTENTION: + self._validate_mla_cache_params(layer_type) + total_bytes += ( + self.block_size * (self.kv_lora_rank + self.qk_rope_head_dim) * dtype_size + ) + elif layer_type == DSA_ATTENTION: + self._validate_mla_cache_params(layer_type) + self._validate_index_cache_params(layer_type) + total_bytes += ( + self.block_size * (self.kv_lora_rank + self.qk_rope_head_dim) * dtype_size + ) + total_bytes += ( + (self.index_key_heads or 1) * self.block_size * self.index_head_dim * dtype_size + ) + elif layer_type == MSA_ATTENTION: + self._validate_index_cache_params(layer_type) + total_bytes += standard_block_bytes + total_bytes += ( + (self.index_key_heads or 1) * self.block_size * self.index_head_dim * dtype_size + ) + elif layer_type == LINEAR: + continue else: - index_heads = self.index_n_heads - one_layer_block_bytes += ( - index_heads * self.block_size * self.index_head_dim * dtype_size - ) + raise ValueError(f"Unknown layer type: {layer_type}") - return one_layer_block_bytes * self.layer_types.count("attention") + return total_bytes def _calculate_prefix_linear_bytes( self, @@ -325,14 +370,14 @@ def _calculate_cache_allocation( ) # Total bytes per block = Sum over all attention layers - num_attention_layers = self.layer_types.count("attention") + num_attention_layers = self._num_attention_layers() total_block_bytes = self._calculate_kv_block_bytes(dtype_size) if total_block_bytes == 0: if active_linear_cache_bytes > 0: logger.info( f"Linear cache will use {active_linear_cache_bytes / 1024**3:.2f} GB " - f"for {self.layer_types.count('linear')} layers " + f"for {self._num_linear_layers()} layers " f"({self.max_num_seqs} active slots, 0 prefix slots)" ) return 0, 0 @@ -368,7 +413,7 @@ def _calculate_cache_allocation( ) logger.info( f"Linear cache will use {total_linear_cache_bytes / 1024**3:.2f} GB " - f"for {self.layer_types.count('linear')} layers " + f"for {self._num_linear_layers()} layers " f"({self.max_num_seqs} active slots, " f"{num_linear_prefix_slots} prefix slots)" ) diff --git a/src/parallax/server/executor/base_executor.py b/src/parallax/server/executor/base_executor.py index 456099b5..cc56488c 100755 --- a/src/parallax/server/executor/base_executor.py +++ b/src/parallax/server/executor/base_executor.py @@ -516,13 +516,6 @@ def recv_requests_from_peer(self) -> Tuple[List[Request], str]: self.weight_version = int(recv_req[2].decode("ascii")) else: raise ValueError(f"Unknown request type: {recv_req[0]}") - # First peer is responsible for tokenization - # if self.is_first_peer and isinstance(recv_req, InitialRequest): - # recv_req.input_ids = self.tokenizer.encode(recv_req.prompt) - # recv_req.prompt_len = len(recv_req.input_ids) - # recv_req.max_total_length = min( - # recv_req.max_total_length, recv_req.prompt_len + recv_req.max_new_tokens - # ) except zmq.ZMQError: break diff --git a/src/parallax/server/executor/mlx_executor.py b/src/parallax/server/executor/mlx_executor.py index 33c1646b..1f1f43d4 100755 --- a/src/parallax/server/executor/mlx_executor.py +++ b/src/parallax/server/executor/mlx_executor.py @@ -10,6 +10,7 @@ import mlx.core as mx import numpy as np +from parallax.utils.layer_types import INDEX_CACHE_LAYER_TYPES, MLA_CACHE_LAYER_TYPES from parallax.server.cache_manager import CacheManager from parallax.server.executor.base_executor import BaseExecutor from parallax.server.request import ( @@ -131,8 +132,6 @@ def __init__( f"MLX sharded model loaded in {(time.time() - t0) * 1000:.1f} ms; num_layers={self.config.get('num_hidden_layers')}" ) - is_minimax_m3 = self.config.get("model_type") == "minimax_m3" - # TODO: Duplicate code to BaseExecutor since num_shard_layers and dtype are needed for initializing kv cache self.num_shard_layers = end_layer - start_layer self.dtype = get_device_dtype(dtype, device) @@ -173,16 +172,11 @@ def __init__( index_head_dim = self.config.get("index_head_dim", None) index_n_heads = self.config.get("index_n_heads", None) kv_lora_rank = self.config.get("kv_lora_rank", None) - is_mla_dsa = ( - index_head_dim is not None - and index_n_heads is not None - and not is_minimax_m3 - and kv_lora_rank is not None - and qk_rope_head_dim is not None - ) - index_key_heads = 1 if (is_mla_dsa or is_minimax_m3) else None layer_types = get_layer_types(self.config, start_layer, end_layer) + uses_mla_cache = any(t in MLA_CACHE_LAYER_TYPES for t in layer_types) + uses_index_cache = any(t in INDEX_CACHE_LAYER_TYPES for t in layer_types) + index_key_heads = 1 if uses_index_cache else None sliding_window = self.config.get("sliding_window", None) use_sliding_window = self.config.get("use_sliding_window", None) if use_sliding_window is False: @@ -227,12 +221,11 @@ def __init__( block_size=kv_block_size, cache_memory_fraction=kv_cache_memory_fraction, head_dim_v=v_head_dim, - index_head_dim=index_head_dim, - index_n_heads=index_n_heads, + index_head_dim=index_head_dim if uses_index_cache else None, + index_n_heads=index_n_heads if uses_index_cache else None, index_key_heads=index_key_heads, - kv_lora_rank=kv_lora_rank if is_mla_dsa else None, - qk_rope_head_dim=qk_rope_head_dim if is_mla_dsa else None, - sparse_cache_type="minimax_m3" if is_minimax_m3 else None, + kv_lora_rank=kv_lora_rank if uses_mla_cache else None, + qk_rope_head_dim=qk_rope_head_dim if uses_mla_cache else None, layer_types=layer_types, max_num_seqs=max_batch_size // micro_batch_ratio, conv_dim=conv_dim, diff --git a/src/parallax/server/scheduler.py b/src/parallax/server/scheduler.py index df7fb7a6..ff163b71 100644 --- a/src/parallax/server/scheduler.py +++ b/src/parallax/server/scheduler.py @@ -124,16 +124,8 @@ def get_running_request(self, request_id: str) -> Optional[Request]: """Gets a request that is currently in the running state.""" return self._running_requests.get(request_id) - def _prompt_string_to_request(self, request_str: str) -> InitialRequest: - """Convert the prompt string to InitialRequest.""" - assert self.is_first_peer, "Only first peer can enqueue InitialRequest." - input_ids = self.tokenizer.encode(request_str) - return InitialRequest.from_prompt_ids(input_ids, self.max_new_tokens, self.max_total_length) - - def enque_request(self, request: Request | str): + def enque_request(self, request: Request): """Enque a request to the scheduler's wait queue.""" - if isinstance(request, str): - request = self._prompt_string_to_request(request) if request.is_finished: logger.warning( diff --git a/src/parallax/utils/layer_types.py b/src/parallax/utils/layer_types.py new file mode 100644 index 00000000..38adccbe --- /dev/null +++ b/src/parallax/utils/layer_types.py @@ -0,0 +1,20 @@ +"""Cache layer type labels shared by executor and cache manager.""" + +from typing import Final + +ATTENTION: Final = "attention" +MLA_ATTENTION: Final = "mla_attention" +DSA_ATTENTION: Final = "dsa_attention" +MSA_ATTENTION: Final = "msa_attention" +LINEAR: Final = "linear" + +ATTENTION_LAYER_TYPES: Final = frozenset( + { + ATTENTION, + MLA_ATTENTION, + DSA_ATTENTION, + MSA_ATTENTION, + } +) +MLA_CACHE_LAYER_TYPES: Final = frozenset({MLA_ATTENTION, DSA_ATTENTION}) +INDEX_CACHE_LAYER_TYPES: Final = frozenset({DSA_ATTENTION, MSA_ATTENTION}) diff --git a/src/parallax/utils/tokenizer_utils.py b/src/parallax/utils/tokenizer_utils.py index 1d5f9d6c..2fb1e082 100755 --- a/src/parallax/utils/tokenizer_utils.py +++ b/src/parallax/utils/tokenizer_utils.py @@ -1,223 +1,18 @@ -""" -Implements parallax detokenizers for performance. -""" +"""Tokenizer loading helpers.""" -import json -import uuid -from dataclasses import dataclass -from functools import partial -from json import JSONDecodeError -from typing import Any, Callable, Dict, List, Optional, Tuple - -from mlx_lm.tokenizer_utils import ( - BPEStreamingDetokenizer, - NaiveStreamingDetokenizer, - SPMStreamingDetokenizer, - _is_bpe_decoder, - _is_spm_decoder, - _is_spm_decoder_no_space, -) from mlx_lm.tokenizer_utils import load as _mlx_load_tokenizer -class ParallaxNaiveStreamingDetokenizer(NaiveStreamingDetokenizer): - """A custom BPE streaming detokenizer that add an argument 'tokenizer'""" - - def __init__(self, tokenizer, tokenmap): - self._tokenizer = tokenizer - self._tokenizer.decode([0]) - self.reset() - - -class ParallaxBPEStreamingDetokenizer(BPEStreamingDetokenizer): - """A custom BPE streaming detokenizer that skips initializing tokenmap""" - - def __init__(self, tokenizer, tokenmap): - self.clean_spaces = tokenizer.clean_up_tokenization_spaces - self.tokenmap = tokenmap - self.reset() - self.make_byte_decoder() - - -class ParallaxSPMStreamingDetokenizer(SPMStreamingDetokenizer): - """A custom SPM streaming detokenizer that skips initializing tokenmap""" - - def __init__(self, tokenizer, tokenmap, trim_space=True): - self.trim_space = trim_space - self._sep = "\u2581".encode() - self.tokenmap = tokenmap - self.reset() - - -def _get_spm_tokenmap(tokenizer): - """Initialize spm tokenmap for reuse""" - # Extract the tokens in a list from id to text - tokenmap = [""] * (max(tokenizer.vocab.values()) + 1) - for value, tokenid in tokenizer.vocab.items(): - if value.startswith("<0x"): - # Replace bytes with their value - tokenmap[tokenid] = bytes([int(value[3:5], 16)]) - else: - tokenmap[tokenid] = value.encode() - return tokenmap - - -def _get_bpe_tokenmap(tokenizer): - """Initialize bpe tokenmap for reuse""" - # Extract the tokens in a list from id to text - tokenmap = [None] * len(tokenizer.vocab) - for value, tokenid in tokenizer.vocab.items(): - tokenmap[tokenid] = value - return tokenmap - - -def load_detokenizer(model_path, tokenizer): - """Load a huggingface tokenizer and try to infer the type of streaming - detokenizer to use. - - Note, to use a fast streaming tokenizer, pass a local file path rather than - a Hugging Face repo ID. - """ - detokenizer_class = ParallaxNaiveStreamingDetokenizer - tokenmap = None - - tokenizer_file = model_path / "tokenizer.json" - if tokenizer_file.exists(): - with open(tokenizer_file, "r", encoding="utf-8") as fid: - try: - tokenizer_content = json.load(fid) - except JSONDecodeError as e: - raise JSONDecodeError("Failed to parse tokenizer.json", e.doc, e.pos) - - if "decoder" in tokenizer_content: - if _is_spm_decoder(tokenizer_content["decoder"]): - detokenizer_class = ParallaxSPMStreamingDetokenizer - tokenmap = _get_spm_tokenmap(tokenizer) - elif _is_spm_decoder_no_space(tokenizer_content["decoder"]): - detokenizer_class = partial(ParallaxSPMStreamingDetokenizer, trim_space=False) - tokenmap = _get_spm_tokenmap(tokenizer) - elif _is_bpe_decoder(tokenizer_content["decoder"]): - detokenizer_class = ParallaxBPEStreamingDetokenizer - tokenmap = _get_bpe_tokenmap(tokenizer) - - return detokenizer_class, tokenmap - - def load_tokenizer(model_path, trust_remote_code=True, tokenizer_config_extra=None, **kwargs): """ Wrapper function for MLX load_tokenizer that defaults trust_remote_code to True. This is needed for models like Kimi-K2 that contain custom code. - - Args: - model_path: Path to the model - trust_remote_code: Whether to trust remote code (defaults to True) - tokenizer_config_extra: Extra config to pass to AutoTokenizer.from_pretrained - **kwargs: Additional arguments to pass to the original load_tokenizer - - Returns: - The loaded tokenizer """ if tokenizer_config_extra is None: tokenizer_config_extra = {} - # Add trust_remote_code to the tokenizer config if trust_remote_code: tokenizer_config_extra = tokenizer_config_extra.copy() tokenizer_config_extra["trust_remote_code"] = True return _mlx_load_tokenizer(model_path, tokenizer_config_extra=tokenizer_config_extra, **kwargs) - - -@dataclass -class ToolCallState: - has_tool_calling: bool - tool_call_start: Optional[str] - tool_call_end: Optional[str] - tool_parser: Optional[Callable[[str, Any], Dict[str, Any]]] - tools: Optional[List[Any]] - stream: bool - in_tool_call: bool = False - tool_text: str = "" - tool_call_idx: int = 0 - made_tool_call: bool = False - - @classmethod - def from_tokenizer(cls, tokenizer, tools: Optional[List[Any]], stream: bool): - has_tool_calling = bool(getattr(tokenizer, "has_tool_calling", False)) - tool_parser = getattr(tokenizer, "tool_parser", None) - tool_call_start = getattr(tokenizer, "tool_call_start", None) - tool_call_end = getattr(tokenizer, "tool_call_end", None) - if not (has_tool_calling and tool_parser and tool_call_start and tool_call_end): - return cls( - has_tool_calling=False, - tool_call_start=None, - tool_call_end=None, - tool_parser=None, - tools=tools, - stream=stream, - ) - return cls( - has_tool_calling=True, - tool_call_start=tool_call_start, - tool_call_end=tool_call_end, - tool_parser=tool_parser, - tools=tools, - stream=stream, - ) - - def _format_tool_call(self, tool_call: Dict[str, Any]): - tool_call_id = tool_call.pop("id", None) or str(uuid.uuid4()) - tool_call["arguments"] = json.dumps(tool_call["arguments"], ensure_ascii=False) - out = { - "function": tool_call, - "type": "function", - "id": tool_call_id, - } - if self.stream: - out["index"] = self.tool_call_idx - self.tool_call_idx += 1 - return out - - def _parse_tool_text(self, tool_text: str) -> Tuple[List[Dict[str, Any]], Optional[str]]: - try: - parsed = self.tool_parser(tool_text, self.tools) - except Exception: - fallback_text = f"{self.tool_call_start}{tool_text}{self.tool_call_end}" - return [], fallback_text - if isinstance(parsed, list): - return [self._format_tool_call(tc) for tc in parsed], None - return [self._format_tool_call(parsed)], None - - def extract_from_segment(self, segment: str) -> Tuple[str, List[Dict[str, Any]]]: - if not self.has_tool_calling or not segment: - return segment, [] - output_chunks: List[str] = [] - new_tool_calls: List[Dict[str, Any]] = [] - idx = 0 - while idx < len(segment): - if not self.in_tool_call: - start_pos = segment.find(self.tool_call_start, idx) - if start_pos == -1: - output_chunks.append(segment[idx:]) - break - if start_pos > idx: - output_chunks.append(segment[idx:start_pos]) - self.in_tool_call = True - self.made_tool_call = True - self.tool_text = "" - idx = start_pos + len(self.tool_call_start) - else: - end_pos = segment.find(self.tool_call_end, idx) - if end_pos == -1: - self.tool_text += segment[idx:] - break - self.tool_text += segment[idx:end_pos] - parsed_calls, fallback_text = self._parse_tool_text(self.tool_text) - if parsed_calls: - new_tool_calls.extend(parsed_calls) - if fallback_text: - output_chunks.append(fallback_text) - self.tool_text = "" - self.in_tool_call = False - idx = end_pos + len(self.tool_call_end) - return "".join(output_chunks), new_tool_calls diff --git a/src/parallax/utils/utils.py b/src/parallax/utils/utils.py index 33d10d8e..9addb90e 100644 --- a/src/parallax/utils/utils.py +++ b/src/parallax/utils/utils.py @@ -12,6 +12,13 @@ import torch import zmq +from parallax.utils.layer_types import ( + ATTENTION, + DSA_ATTENTION, + LINEAR, + MLA_ATTENTION, + MSA_ATTENTION, +) from parallax.utils.model_download import download_model_file @@ -420,8 +427,27 @@ def initialize_nccl_port(): return nccl_port +def _attention_cache_layer_type(config: dict) -> str: + model_type = config.get("model_type") + if model_type == "minimax_m3": + return MSA_ATTENTION + + has_mla_cache = ( + config.get("kv_lora_rank") is not None and config.get("qk_rope_head_dim") is not None + ) + has_dsa_index = ( + config.get("index_head_dim") is not None and config.get("index_n_heads") is not None + ) + if has_mla_cache and has_dsa_index: + return DSA_ATTENTION + if has_mla_cache and model_type in {"deepseek_v3", "kimi_k2"}: + return MLA_ATTENTION + return ATTENTION + + def get_layer_types(config: dict, start_layer: int, end_layer: int) -> List[str]: num_shard_layers = end_layer - start_layer + attention_type = _attention_cache_layer_type(config) # Case 1: Explicit layer types (e.g., DeepSeek with layers_block_type) layer_types = config.get("layers_block_type", None) @@ -429,7 +455,7 @@ def get_layer_types(config: dict, start_layer: int, end_layer: int) -> List[str] if len(layer_types) >= end_layer: layer_types = layer_types[start_layer:end_layer] return [ - "linear" if t in ["mamba", "linear_attention"] else "attention" for t in layer_types + LINEAR if t in ["mamba", "linear_attention"] else attention_type for t in layer_types ] # Case 2: linear_attn_config with full_attn_layers (e.g., Kimi) @@ -439,9 +465,9 @@ def get_layer_types(config: dict, start_layer: int, end_layer: int) -> List[str] layer_types = [] for i in range(start_layer, end_layer): if i in full_attn_layers: - layer_types.append("attention") + layer_types.append(attention_type) else: - layer_types.append("linear") + layer_types.append(LINEAR) return layer_types # Case 3: full_attention_interval (e.g., Qwen3Next) @@ -450,8 +476,8 @@ def get_layer_types(config: dict, start_layer: int, end_layer: int) -> List[str] layer_types = [] for i in range(start_layer, end_layer): is_linear = (i + 1) % full_attention_interval != 0 - layer_types.append("linear" if is_linear else "attention") + layer_types.append(LINEAR if is_linear else attention_type) return layer_types # Default: all attention layers - return ["attention"] * num_shard_layers + return [attention_type] * num_shard_layers diff --git a/src/parallax_extensions/CMakelists.txt b/src/parallax_extensions/CMakelists.txt index 41630ff0..7bf25e4b 100755 --- a/src/parallax_extensions/CMakelists.txt +++ b/src/parallax_extensions/CMakelists.txt @@ -36,6 +36,7 @@ target_sources( parallax_ext PUBLIC ${CMAKE_CURRENT_LIST_DIR}/kernels/dsa_paged_attention.cpp ${CMAKE_CURRENT_LIST_DIR}/kernels/dsa_indexer.cpp + ${CMAKE_CURRENT_LIST_DIR}/kernels/mla_paged_attention.cpp ${CMAKE_CURRENT_LIST_DIR}/kernels/paged_attention.cpp ${CMAKE_CURRENT_LIST_DIR}/kernels/sparse_indexer.cpp ${CMAKE_CURRENT_LIST_DIR}/kernels/sparse_paged_attention.cpp @@ -62,6 +63,7 @@ if(MLX_BUILD_METAL) ${CMAKE_CURRENT_LIST_DIR}/kernels/float8.metal ${CMAKE_CURRENT_LIST_DIR}/kernels/dsa_paged_attention.metal ${CMAKE_CURRENT_LIST_DIR}/kernels/dsa_indexer.metal + ${CMAKE_CURRENT_LIST_DIR}/kernels/mla_paged_attention.metal ${CMAKE_CURRENT_LIST_DIR}/kernels/paged_attention.metal ${CMAKE_CURRENT_LIST_DIR}/kernels/sparse_indexer.metal ${CMAKE_CURRENT_LIST_DIR}/kernels/sparse_paged_attention.metal diff --git a/src/parallax_extensions/bindings.cpp b/src/parallax_extensions/bindings.cpp index 4ecd37b3..edfc3deb 100755 --- a/src/parallax_extensions/bindings.cpp +++ b/src/parallax_extensions/bindings.cpp @@ -3,6 +3,7 @@ #include "kernels/dsa_indexer.h" #include "kernels/dsa_paged_attention.h" +#include "kernels/mla_paged_attention.h" #include "kernels/paged_attention.h" #include "kernels/reshape_and_cache.h" #include "kernels/sparse_indexer.h" @@ -34,6 +35,37 @@ NB_MODULE(_ext, m) { array: ``Dummy output`` )"); + m.def( + "mla_paged_attention", + ¶llax_ext::mla_paged_attention, + "q_latent"_a, + "q_pe"_a, + "latent_cache"_a, + "rope_cache"_a, + "block_tables"_a, + "seq_lens"_a, + "block_size"_a, + "scale"_a, + nb::kw_only(), + "stream"_a = nb::none(), + R"( + Decode dense MLA paged attention over compressed MLA cache. + + Args: + q_latent (array): MLA latent query [num_seqs, num_heads, latent_dim]. + q_pe (array): RoPE query [num_seqs, num_heads, rope_dim]. + latent_cache (array): Paged MLA latent cache [1, num_blocks, 1, block_size, latent_dim]. + rope_cache (array): Paged RoPE key cache [1, num_blocks, 1, block_size, rope_dim]. + block_tables (array): Input array [num_seqs, max_num_blocks_per_seq]. + seq_lens (array): Input array [num_seqs]. + block_size (int): Cache block size. + scale (float): Attention scale. + stream (Stream or Device): Stream on which to schedule the operation. + + Returns: + array: latent-space attention result [num_seqs, num_heads, latent_dim] + )"); + m.def( "dsa_paged_attention", ¶llax_ext::dsa_paged_attention, diff --git a/src/parallax_extensions/kernels/mla_paged_attention.cpp b/src/parallax_extensions/kernels/mla_paged_attention.cpp new file mode 100644 index 00000000..73815a35 --- /dev/null +++ b/src/parallax_extensions/kernels/mla_paged_attention.cpp @@ -0,0 +1,138 @@ +#include +#include + +#include "mla_paged_attention.h" +#include "mlx/backend/metal/device.h" +#include "mlx/backend/metal/utils.h" +#include "utils.h" + +namespace parallax_ext { + +mx::array mla_paged_attention( + const mx::array& q_latent, + const mx::array& q_pe, + const mx::array& latent_cache, + const mx::array& rope_cache, + const mx::array& block_tables, + const mx::array& seq_lens, + const int64_t block_size, + const float scale, + mx::StreamOrDevice s /* = {} */ +) { + auto out_dtype = q_latent.dtype(); + auto out_shape = q_latent.shape(); + const std::vector inputs = { + q_latent, q_pe, latent_cache, rope_cache, block_tables, seq_lens}; + return mx::array( + out_shape, + out_dtype, + std::make_shared(to_stream(s), block_size, scale), + inputs); +} + +void MLAPagedAttention::eval_cpu( + const std::vector& inputs, + std::vector& outputs) { + return; +} + +void MLAPagedAttention::eval_gpu( + const std::vector& inputs, + std::vector& outputs) { + assert(inputs.size() == 6); + auto& q_latent = inputs[0]; + auto& q_pe = inputs[1]; + auto& latent_cache = inputs[2]; + auto& rope_cache = inputs[3]; + auto& block_tables = inputs[4]; + auto& seq_lens = inputs[5]; + auto& out = outputs[0]; + + const int latent_dim = q_latent.shape(2); + const int rope_dim = q_pe.shape(2); + if (latent_dim > 512) { + std::ostringstream msg; + msg << "MLAPagedAttention supports latent_dim <= 512, got " << latent_dim; + throw std::runtime_error(msg.str()); + } + if (rope_dim > 128) { + std::ostringstream msg; + msg << "MLAPagedAttention supports rope_dim <= 128, got " << rope_dim; + throw std::runtime_error(msg.str()); + } + if (q_pe.shape(0) != q_latent.shape(0) || q_pe.shape(1) != q_latent.shape(1)) { + throw std::runtime_error("MLAPagedAttention requires q_latent and q_pe batch/heads to match"); + } + if (latent_cache.dtype() != q_latent.dtype() || rope_cache.dtype() != q_latent.dtype()) { + throw std::runtime_error( + "MLAPagedAttention requires q_latent, latent_cache, and rope_cache to share dtype"); + } + if (latent_cache.shape(4) != latent_dim || rope_cache.shape(4) != rope_dim) { + throw std::runtime_error("MLAPagedAttention cache dims must match query dims"); + } + + auto& s = stream(); + auto& d = mx::metal::device(s.device); + auto lib = d.get_library("parallax_ext", current_binary_dir()); + auto& compute_encoder = mx::metal::get_command_encoder(s); + + out.set_data(mlx::core::allocator::malloc(out.nbytes())); + + constexpr int num_threads = 32; + constexpr int num_simd_lanes = 32; + const int64_t num_seqs = q_latent.shape(0); + const int64_t num_heads = q_latent.shape(1); + const int64_t max_num_blocks_per_seq = block_tables.shape(1); + + std::string kname = "mla_paged_attention_" + get_type_string(out.dtype()); + kname += "_cache_" + get_type_string(latent_cache.dtype()); + kname += "_ld" + std::to_string(latent_dim); + kname += "_rd" + std::to_string(rope_dim); + kname += "_nt" + std::to_string(num_threads); + kname += "_nsl" + std::to_string(num_simd_lanes); + + auto kernel = d.get_kernel(kname, lib); + compute_encoder.set_compute_pipeline_state(kernel); + + float scale_32 = static_cast(scale_); + int32_t block_size_32 = static_cast(block_size_); + int32_t max_num_blocks_per_seq_32 = static_cast(max_num_blocks_per_seq); + int32_t q_latent_seq_stride_32 = static_cast(q_latent.strides(0)); + int32_t q_latent_head_stride_32 = static_cast(q_latent.strides(1)); + int32_t q_pe_seq_stride_32 = static_cast(q_pe.strides(0)); + int32_t q_pe_head_stride_32 = static_cast(q_pe.strides(1)); + int32_t latent_block_stride_32 = static_cast(latent_cache.strides(1)); + int32_t latent_token_stride_32 = static_cast(latent_cache.strides(3)); + int32_t rope_block_stride_32 = static_cast(rope_cache.strides(1)); + int32_t rope_token_stride_32 = static_cast(rope_cache.strides(3)); + + compute_encoder.set_output_array(out, 0); + compute_encoder.set_input_array(q_latent, 1); + compute_encoder.set_input_array(q_pe, 2); + compute_encoder.set_input_array(latent_cache, 3); + compute_encoder.set_input_array(rope_cache, 4); + compute_encoder.set_input_array(block_tables, 5); + compute_encoder.set_input_array(seq_lens, 6); + compute_encoder.set_bytes(scale_32, 7); + compute_encoder.set_bytes(block_size_32, 8); + compute_encoder.set_bytes(max_num_blocks_per_seq_32, 9); + compute_encoder.set_bytes(q_latent_seq_stride_32, 10); + compute_encoder.set_bytes(q_latent_head_stride_32, 11); + compute_encoder.set_bytes(q_pe_seq_stride_32, 12); + compute_encoder.set_bytes(q_pe_head_stride_32, 13); + compute_encoder.set_bytes(latent_block_stride_32, 14); + compute_encoder.set_bytes(latent_token_stride_32, 15); + compute_encoder.set_bytes(rope_block_stride_32, 16); + compute_encoder.set_bytes(rope_token_stride_32, 17); + + MTL::Size grid = MTL::Size(num_heads, num_seqs, 1); + MTL::Size threadgroup = MTL::Size(num_threads, 1, 1); + compute_encoder.dispatch_threadgroups(grid, threadgroup); +} + +bool MLAPagedAttention::is_equivalent(const mx::Primitive& other) const { + const MLAPagedAttention& r_other = static_cast(other); + return block_size_ == r_other.block_size_ && scale_ == r_other.scale_; +} + +} // namespace parallax_ext diff --git a/src/parallax_extensions/kernels/mla_paged_attention.h b/src/parallax_extensions/kernels/mla_paged_attention.h new file mode 100644 index 00000000..20416a00 --- /dev/null +++ b/src/parallax_extensions/kernels/mla_paged_attention.h @@ -0,0 +1,42 @@ +#include "mlx/primitives.h" +#include "mlx/utils.h" + +namespace mx = mlx::core; + +namespace parallax_ext { + +mx::array mla_paged_attention( + const mx::array& q_latent, + const mx::array& q_pe, + const mx::array& latent_cache, + const mx::array& rope_cache, + const mx::array& block_tables, + const mx::array& seq_lens, + const int64_t block_size, + const float scale, + mx::StreamOrDevice s = {}); + +class MLAPagedAttention : public mx::Primitive { + public: + explicit MLAPagedAttention(mx::Stream stream, int64_t block_size, float scale) + : mx::Primitive(stream), block_size_(block_size), scale_(scale) {}; + + void eval_cpu( + const std::vector& inputs, + std::vector& outputs) override; + void eval_gpu( + const std::vector& inputs, + std::vector& outputs) override; + + const char* name() const override { + return "MLAPagedAttention"; + } + + bool is_equivalent(const mx::Primitive& other) const override; + + private: + int64_t block_size_; + float scale_; +}; + +} // namespace parallax_ext diff --git a/src/parallax_extensions/kernels/mla_paged_attention.metal b/src/parallax_extensions/kernels/mla_paged_attention.metal new file mode 100644 index 00000000..2437d3fa --- /dev/null +++ b/src/parallax_extensions/kernels/mla_paged_attention.metal @@ -0,0 +1,163 @@ +#include "./utils.metal" +#include + +using namespace metal; + +#define DIVIDE_ROUND_UP(a, b) (((a) + (b) - 1) / (b)) + +template +[[kernel]] void mla_paged_attention( + device T *out [[buffer(0)]], + device const T *q_latent [[buffer(1)]], + device const T *q_pe [[buffer(2)]], + device const CACHE_T *latent_cache [[buffer(3)]], + device const CACHE_T *rope_cache [[buffer(4)]], + device const int32_t *block_tables [[buffer(5)]], + device const int32_t *context_lens [[buffer(6)]], + const constant float &scale [[buffer(7)]], + const constant int &block_size [[buffer(8)]], + const constant int &max_num_blocks_per_seq [[buffer(9)]], + const constant int &q_latent_seq_stride [[buffer(10)]], + const constant int &q_latent_head_stride [[buffer(11)]], + const constant int &q_pe_seq_stride [[buffer(12)]], + const constant int &q_pe_head_stride [[buffer(13)]], + const constant int &latent_block_stride [[buffer(14)]], + const constant int &latent_token_stride [[buffer(15)]], + const constant int &rope_block_stride [[buffer(16)]], + const constant int &rope_token_stride [[buffer(17)]], + uint3 threadgroup_position_in_grid [[threadgroup_position_in_grid]], + uint3 threadgroups_per_grid [[threadgroups_per_grid]], + uint3 thread_position_in_threadgroup [[thread_position_in_threadgroup]]) { + const int head_idx = threadgroup_position_in_grid.x; + const int seq_idx = threadgroup_position_in_grid.y; + const int tid = thread_position_in_threadgroup.x; + const int context_len = context_lens[seq_idx]; + const int num_context_blocks = DIVIDE_ROUND_UP(context_len, block_size); + + const device T *q_latent_ptr = + q_latent + seq_idx * q_latent_seq_stride + head_idx * q_latent_head_stride; + const device T *q_pe_ptr = + q_pe + seq_idx * q_pe_seq_stride + head_idx * q_pe_head_stride; + + float q_latent_vec[16] = {0.0f}; + float q_pe_vec[4] = {0.0f}; + float acc_vec[16] = {0.0f}; + + for (int i = tid; i < LATENT_DIM; i += NUM_SIMD_LANES) { + q_latent_vec[i / NUM_SIMD_LANES] = (float)q_latent_ptr[i]; + } + for (int i = tid; i < ROPE_DIM; i += NUM_SIMD_LANES) { + q_pe_vec[i / NUM_SIMD_LANES] = (float)q_pe_ptr[i]; + } + + float m_i = -FLT_MAX; + float l_i = 0.0f; + const device int32_t *block_table = + block_tables + seq_idx * max_num_blocks_per_seq; + + for (int block_idx = 0; block_idx < num_context_blocks; ++block_idx) { + const int physical_block_number = block_table[block_idx]; + const int block_start = block_idx * block_size; + const int tokens_in_block = min(block_size, context_len - block_start); + + const device CACHE_T *latent_block = + latent_cache + (int64_t)physical_block_number * latent_block_stride; + const device CACHE_T *rope_block = + rope_cache + (int64_t)physical_block_number * rope_block_stride; + + for (int token_offset = 0; token_offset < tokens_in_block; ++token_offset) { + const device CACHE_T *latent_ptr = + latent_block + token_offset * latent_token_stride; + const device CACHE_T *rope_ptr = + rope_block + token_offset * rope_token_stride; + + float score = 0.0f; + for (int i = tid; i < LATENT_DIM; i += NUM_SIMD_LANES) { + score += q_latent_vec[i / NUM_SIMD_LANES] * (float)latent_ptr[i]; + } + for (int i = tid; i < ROPE_DIM; i += NUM_SIMD_LANES) { + score += q_pe_vec[i / NUM_SIMD_LANES] * (float)rope_ptr[i]; + } + score = simd_sum(score) * scale; + + const float m_prev = m_i; + m_i = max(m_prev, score); + const float alpha = exp(m_prev - m_i); + const float beta = exp(score - m_i); + l_i = l_i * alpha + beta; + + for (int i = tid; i < LATENT_DIM; i += NUM_SIMD_LANES) { + const int idx = i / NUM_SIMD_LANES; + acc_vec[idx] = acc_vec[idx] * alpha + (float)latent_ptr[i] * beta; + } + } + } + + device T *out_ptr = + out + (seq_idx * threadgroups_per_grid.x + head_idx) * LATENT_DIM; + + const float inv_l = l_i > 0.0f ? 1.0f / l_i : 0.0f; + for (int i = tid; i < LATENT_DIM; i += NUM_SIMD_LANES) { + out_ptr[i] = T(acc_vec[i / NUM_SIMD_LANES] * inv_l); + } +} + +#define instantiate_mla_paged_attention_inner( \ + type, cache_type, latent_dim, rope_dim, num_threads, num_simd_lanes) \ + template [[host_name("mla_paged_attention_" #type "_cache_" #cache_type \ + "_ld" #latent_dim "_rd" #rope_dim "_nt" #num_threads \ + "_nsl" #num_simd_lanes)]] [[kernel]] void \ + mla_paged_attention( \ + device type *out [[buffer(0)]], device const type *q_latent \ + [[buffer(1)]], device const type *q_pe [[buffer(2)]], \ + device const cache_type *latent_cache [[buffer(3)]], \ + device const cache_type *rope_cache [[buffer(4)]], \ + device const int32_t *block_tables [[buffer(5)]], \ + device const int32_t *context_lens [[buffer(6)]], \ + const constant float &scale [[buffer(7)]], \ + const constant int &block_size [[buffer(8)]], \ + const constant int &max_num_blocks_per_seq [[buffer(9)]], \ + const constant int &q_latent_seq_stride [[buffer(10)]], \ + const constant int &q_latent_head_stride [[buffer(11)]], \ + const constant int &q_pe_seq_stride [[buffer(12)]], \ + const constant int &q_pe_head_stride [[buffer(13)]], \ + const constant int &latent_block_stride [[buffer(14)]], \ + const constant int &latent_token_stride [[buffer(15)]], \ + const constant int &rope_block_stride [[buffer(16)]], \ + const constant int &rope_token_stride [[buffer(17)]], \ + uint3 threadgroup_position_in_grid [[threadgroup_position_in_grid]], \ + uint3 threadgroups_per_grid [[threadgroups_per_grid]], \ + uint3 thread_position_in_threadgroup [[thread_position_in_threadgroup]]); + +#define instantiate_mla_rope_dims(type, cache_type, latent_dim, num_threads, \ + num_simd_lanes) \ + instantiate_mla_paged_attention_inner(type, cache_type, latent_dim, 2, \ + num_threads, num_simd_lanes); \ + instantiate_mla_paged_attention_inner(type, cache_type, latent_dim, 32, \ + num_threads, num_simd_lanes); \ + instantiate_mla_paged_attention_inner(type, cache_type, latent_dim, 64, \ + num_threads, num_simd_lanes); \ + instantiate_mla_paged_attention_inner(type, cache_type, latent_dim, 128, \ + num_threads, num_simd_lanes); + +#define instantiate_mla_latent_dims(type, cache_type, num_threads, \ + num_simd_lanes) \ + instantiate_mla_rope_dims(type, cache_type, 4, num_threads, \ + num_simd_lanes); \ + instantiate_mla_rope_dims(type, cache_type, 64, num_threads, \ + num_simd_lanes); \ + instantiate_mla_rope_dims(type, cache_type, 128, num_threads, \ + num_simd_lanes); \ + instantiate_mla_rope_dims(type, cache_type, 256, num_threads, \ + num_simd_lanes); \ + instantiate_mla_rope_dims(type, cache_type, 512, num_threads, \ + num_simd_lanes); + +#define instantiate_mla_paged_attention(type, cache_type, num_simd_lanes) \ + instantiate_mla_latent_dims(type, cache_type, 32, num_simd_lanes); + +instantiate_mla_paged_attention(float, float, 32); +instantiate_mla_paged_attention(bfloat16_t, bfloat16_t, 32); +instantiate_mla_paged_attention(half, half, 32); diff --git a/src/parallax_extensions/lib/_ext.cpython-312-darwin.so b/src/parallax_extensions/lib/_ext.cpython-312-darwin.so index a47bf235dd6eb5615b2ec7b92e049b9215217633..62fb6eace0704cfc849554093f3d3ce534156ec3 100755 GIT binary patch delta 42674 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 K#$ClHss0z4n?@f1 delta 36851 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 delta 24860 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 OyLT^aY3tK$_WvKJK8J_^ diff --git a/src/parallax_extensions/lib/parallax_ext.metallib b/src/parallax_extensions/lib/parallax_ext.metallib index e12aa3ca915341fcd1b048801d96ca06d846b4f7..6f19dc7af431f99aebe2af79c13b7bf97cbf0a4d 100644 GIT binary patch delta 271504 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 V6k3A#{oI?Q$b)ll#*@)2{y(&2Mc4oU delta 3959 zcmZYC3s_U<9R~1o_=3g@G31D1w2IXtl^{`(QM47Qcd=|572AqMHw!u^*lw*lN6~7EpfuhPyb-)L>NDy4o&0?`vp&!7A0zL3zAri7 zlc4)}Z3oBo{T*gaPD+TE9e$Etl5)C8lH|yakntcTi-A9@9X%@w^ikXFjWN~E}l;vo@goTu6S49sP;@C`q(1*U9Dc`0Qa=Sx{Lk;?66;ZrkM_re_;{p6s+ zgl1U|6tC65b8A?rseroD0NQ?ky_Kw!ndDg zZ<%&c%1Eg=r{-D>11)@VEql#WN~z!{D;E1w=f#S#LoEDOEqlXMPN@(|Ne9$apfynY zMjei}@SJ;0;l*{#7FbE0FzQ(O+a;_gS1%kt+`^;IvDv1h6tYstR_9y3;vAN*8foE2 zJZu1Ot!H}PdX9}URZ~8O^2TOge#HgkZ^vu%yU(*xN)6@XD4!Pa6F+&G4Pdns)f=Jk zLAO~~-vah@){AG?vrxrNt?|?<4X4(@#HU&ll}5_iC@W2*?3R?LviicCc}zBN+c@0r zlRsh|4|S8NYs;kW()3?+Un~{fbs6ZcaHH#`ZW?uqa@B5_-@IQ`*H(#Yp9?HXkv{U> zuuN)sKA{z3vnLCby}iEMb}&zEL>7*44U>($FqH-H zg3D-xQNu|M#|Ua{*^EQE0Ee>j3hT=wui#KvDO*6<>J%?;>ELJPQyN$RTe`iikgmi~ zwurK}CEDz~Pc3|}9T$J82^Zfs4{yvD4cJW_wRSyOfooeSd$ldwe6-T9qBWjcrBBxS z(wl$sdo!UdU74dd4GW}M?`xZjtqVHb8P z)ley9il+3~O(nYvrN_%q;@44HOeHs!N*8KMw;HKbdKV@2WKFt(l7mW(REqmhQ>yn^ z_?{bh5vwWXp;Yx}npBln{e)F`_DwcRaZ=Pv(X?ZT)|>g`n=HWZu*8eb;`0T`o`sI~ z?c@O8{%736Rtl&o(N|{W)wU+vsgDq!eS>&wy2rq`rQ${Fno%mElAcN-?H=+u!LpgB zw&0L#GEyrt2dzF^(b_9oyQpQPmb1UMl^aa0k?-Irrg~5)r9vad&Usn|_<3oc$stuSi2S7=+WS|h|)r25I6svM=Pm9n;N+U(ev zwrnpR65K+Es_PvrQ%%(vs#YG-R-@yzRa-Qw>e2d2RBNajM^$TGTh-t7+;bdCQ+lFMmCPe{-LuD-!)8mq}?)ntJUq z|95S_`4Q?;V_q4Zl$bCr$tc~|N$T&1YwW(COrJhsv{9;@;P_V8Jp9CtPI>!`-b2I7 ztX`Iq%I#jZiCfm{YRWdw(D7kBZKiH$8J@@J%0}nw_6>Z`RiOiaXb0^<4;`Q*bb`(h z00!s+MhFBGD9{yxpc{0DVCVs6=n1{x8R!jtAO!ltv(OLv!*dV{10W2Z2eG$sus{S1 zf=IB!U>E{JAqs{;H2ex;;01_<;V=STgqL6>#K9~0VcyM zFa>Nd6%t_@B*Aoe6=py(%!Cy9HOzwFKq}0JG?)YFFc)5f*C7Lb3z_f+%!B!`02acV z@D|wNZODS(!8?!*i{M>Y3`<}sEQ8;}A7DAG00-p2O86uE306Taya%h{eOLn@z=yCF z*1>vkLLO{@eE2hLgiXMq05-!G_z1SbUtk*)LJ@3-zrx4x3G9HKPz<}^Q}_%%huu&D zd*BNwg}ty3{sw=C{qQA}K{*_NgYXYH1pkD?Pyv;21ipf=;a~7?_y)d(qi_t4!++oe z{1>X=JE(?}a0o3;JGKQZRObneE{&K~|`gU^E$FgC4_m%Bs z480f~#ONqSCowvU5g>*^j4omr#RwF`B!(hJS22Rb=q5&YF@nYDA% ModuleType: _ext = load_extension_module() _ext_dsa_indexer_scores_with_update = _ext.dsa_indexer_scores_with_update +_ext_mla_paged_attention = _ext.mla_paged_attention _ext_dsa_paged_attention = _ext.dsa_paged_attention _ext_paged_attention_v1 = _ext.paged_attention_v1 _ext_paged_attention_v2 = _ext.paged_attention_v2 @@ -59,6 +60,57 @@ def load_extension_module() -> ModuleType: _PAGED_ATTENTION_V1_MAX_LENGTH = 8192 +def mla_paged_attention( + q_latent: mx.array, + q_pe: mx.array, + latent_cache: mx.array, + rope_cache: mx.array, + block_tables: mx.array, + context_lengths: mx.array, + block_size: int, + scale: float, +) -> mx.array: + """ + Dense MLA decode attention over compressed paged cache. + + Computes: + softmax(scale * (q_latent @ latent_cache.T + q_pe @ rope_cache.T)) @ latent_cache + over the full logical context for each sequence. + """ + if q_latent.ndim == 4: + if q_latent.shape[2] != 1: + raise ValueError("mla_paged_attention only supports one query token.") + q_latent = q_latent.squeeze(2) + if q_pe.ndim == 4: + if q_pe.shape[2] != 1: + raise ValueError("mla_paged_attention only supports one query token.") + q_pe = q_pe.squeeze(2) + if q_latent.ndim != 3 or q_pe.ndim != 3: + raise ValueError("q_latent and q_pe must be shaped (batch, heads, dim).") + if q_latent.shape[:2] != q_pe.shape[:2]: + raise ValueError("q_latent and q_pe batch/head dimensions must match.") + if latent_cache.ndim != 5 or rope_cache.ndim != 5: + raise ValueError("latent_cache and rope_cache must be paged cache tensors.") + if latent_cache.shape[2] != 1 or rope_cache.shape[2] != 1: + raise ValueError("mla_paged_attention expects one MLA cache head.") + if latent_cache.shape[-1] != q_latent.shape[-1]: + raise ValueError("latent cache dim must match q_latent dim.") + if rope_cache.shape[-1] != q_pe.shape[-1]: + raise ValueError("rope cache dim must match q_pe dim.") + + output = _ext_mla_paged_attention( + mx.contiguous(q_latent), + mx.contiguous(q_pe), + latent_cache, + rope_cache, + mx.contiguous(block_tables.astype(mx.int32)), + mx.contiguous(context_lengths.astype(mx.int32)), + block_size, + scale, + ) + return output[:, :, None, :] + + def store_indexer_cache( key: mx.array, key_cache: mx.array, diff --git a/tests/parallax_extensions_tests/test_dsa_paged_attention.py b/tests/parallax_extensions_tests/test_dsa_paged_attention.py index f4af54d5..64b539b4 100644 --- a/tests/parallax_extensions_tests/test_dsa_paged_attention.py +++ b/tests/parallax_extensions_tests/test_dsa_paged_attention.py @@ -4,7 +4,7 @@ import pytest from parallax.server.cache.dsa_cache import DeepSeekSparseCache -from parallax_extensions.ops import dsa_paged_attention, reshape_and_cache +from parallax_extensions.ops import dsa_paged_attention, mla_paged_attention, reshape_and_cache pytestmark = pytest.mark.skipif(sys.platform != "darwin", reason="MLX tests require macOS") @@ -146,6 +146,85 @@ def test_dsa_paged_attention_dense_minus_one_row_match_reference(): assert mx.allclose(out[0], ref, rtol=1e-4, atol=1e-4) +def test_mla_paged_attention_dense_matches_reference(): + latent_seq = mx.array( + [ + [0.1, -0.2, 0.3, -0.4], + [0.5, -0.6, 0.7, -0.8], + [0.9, -1.0, 1.1, -1.2], + [1.3, -1.4, 1.5, -1.6], + [1.7, -1.8, 1.9, -2.0], + [2.1, -2.2, 2.3, -2.4], + ], + dtype=mx.float32, + ) + rope_seq = mx.array( + [ + [0.2, 0.1], + [-0.3, 0.4], + [0.5, -0.6], + [0.7, 0.8], + [-0.9, 1.0], + [1.1, -1.2], + ], + dtype=mx.float32, + ) + block_size = 4 + cache = DeepSeekSparseCache( + num_blocks=2, + block_size=block_size, + num_kv_heads=1, + head_dim=latent_seq.shape[-1], + head_dim_v=latent_seq.shape[-1], + dtype=mx.float32, + kv_lora_rank=latent_seq.shape[-1], + qk_rope_head_dim=rope_seq.shape[-1], + ) + block_tables = mx.array([[0, 1]], dtype=mx.int32) + context_lengths = mx.array([5], dtype=mx.int32) + reshape_and_cache( + latent_seq[None, :, None, :], + rope_seq[None, :, None, :], + cache.latent_cache, + cache.rope_cache, + block_tables, + mx.array([latent_seq.shape[0]], dtype=mx.int32), + block_size, + slot_mapping=mx.arange(latent_seq.shape[0], dtype=mx.int64), + ) + mx.eval(cache.latent_cache, cache.rope_cache) + + q_latent = mx.array( + [[[0.2, 0.1, -0.5, 0.4], [-0.3, 0.5, 0.2, -0.1]]], + dtype=mx.float32, + ) + q_pe = mx.array([[[0.3, -0.2], [0.6, 0.1]]], dtype=mx.float32) + scale = 0.5 + + out = mla_paged_attention( + q_latent, + q_pe, + cache.latent_cache, + cache.rope_cache, + block_tables, + context_lengths, + block_size, + scale, + ).squeeze(2) + ref = _reference( + q_latent, + q_pe, + latent_seq, + rope_seq, + mx.arange(int(context_lengths[0]), dtype=mx.int32), + scale, + ) + mx.eval(out, ref) + + assert cache.get_indexer_cache() is None + assert mx.allclose(out[0], ref, rtol=1e-4, atol=1e-4) + + def test_dsa_paged_attention_glm_dimensions_smoke(): latent_dim = 512 rope_dim = 64 diff --git a/tests/parallax_extensions_tests/test_paged_attention_v1.py b/tests/parallax_extensions_tests/test_paged_attention_v1.py index 31468166..95911a6f 100644 --- a/tests/parallax_extensions_tests/test_paged_attention_v1.py +++ b/tests/parallax_extensions_tests/test_paged_attention_v1.py @@ -5,15 +5,12 @@ import numpy as np import pytest -from parallax.metal.paged_attention.kernel import paged_attention as old_paged_attention from parallax_extensions.ops import ( paged_attention_v1, reshape_and_cache, sparse_paged_attention, ) -old_reshape_and_cache = reshape_and_cache - def get_packing_factor(dtype): if dtype == mx.float32: @@ -129,57 +126,6 @@ def reference_paged_attention( return output -def _bench_old( - q_old, - k_cache, - v_cache, - block_tables, - context_lengths, - block_size, - scale, - num_kv_heads, - iters, - warmup, - window_size=None, - sinks=None, -): - kwargs = {} - if window_size is not None: - kwargs["window_size"] = window_size - if sinks is not None: - kwargs["sinks"] = sinks - for _ in range(warmup): - out = old_paged_attention( - q_old, - k_cache, - v_cache, - block_tables, - context_lengths, - block_size, - scale, - num_kv_heads, - **kwargs, - ) - mx.eval(out) - mx.synchronize() - start = time.perf_counter() - for _ in range(iters): - out = old_paged_attention( - q_old, - k_cache, - v_cache, - block_tables, - context_lengths, - block_size, - scale, - num_kv_heads, - **kwargs, - ) - mx.eval(out) - mx.synchronize() - return (time.perf_counter() - start) / iters * 1000.0 - - def _bench_new( q, k_cache, @@ -558,20 +504,6 @@ def test_reference_cases(self): k_prefill = k_seq.transpose(0, 2, 1, 3) v_prefill = v_seq.transpose(0, 2, 1, 3) - old_key_cache = mx.zeros((1, num_blocks, num_kv_heads, block_size, head_dim), dtype=dtype) - old_value_cache = mx.zeros((1, num_blocks, num_kv_heads, block_size, head_dim), dtype=dtype) - - old_reshape_and_cache( - k_prefill, - v_prefill, - old_key_cache, - old_value_cache, - block_tables, - context_lengths, - block_size, - slot_mapping=slot_mapping, - ) - x = get_packing_factor(dtype) new_key_cache = mx.zeros( (num_blocks, num_kv_heads, head_dim // x, block_size, x), dtype=dtype @@ -589,12 +521,12 @@ def test_reference_cases(self): slot_mapping=slot_mapping, ) - mx.eval(old_key_cache, old_value_cache, new_key_cache, new_value_cache) + mx.eval(new_key_cache, new_value_cache) mx.synchronize() sinks = mx.array(np.random.uniform(-0.5, 0.5, size=(num_heads,)), dtype=mx.float32) window_sizes = [block_size // 2, block_size] - cases = [{"name": "baseline", "window_size": 0, "sinks": None, "use_old": True}] + cases = [{"name": "baseline", "window_size": 0, "sinks": None}] for ws in window_sizes: cases.append({"name": f"window-{ws}", "window_size": ws, "sinks": None}) cases.append({"name": "sink-only", "window_size": 0, "sinks": sinks}) @@ -604,7 +536,6 @@ def test_reference_cases(self): for case in cases: window_size = case["window_size"] sinks_case = case["sinks"] - use_old = case.get("use_old", False) ref_output = reference_paged_attention( q, @@ -619,24 +550,6 @@ def test_reference_cases(self): mx.eval(ref_output) atol = 1e-2 if sinks_case is not None else 1e-3 - if use_old: - old_output = old_paged_attention( - q, - old_key_cache, - old_value_cache, - block_tables, - context_lengths, - block_size, - scale, - num_kv_heads, - ) - mx.eval(old_output) - diff_old = mx.abs(ref_output.astype(mx.float32) - old_output.astype(mx.float32)) - max_diff_old = mx.max(diff_old).item() - assert mx.allclose( - ref_output.astype(mx.float32), old_output.astype(mx.float32), atol=1e-3 - ).item(), f"Old kernel mismatch (max diff {max_diff_old:.6f})" - new_output = paged_attention_v1( q, new_key_cache, @@ -681,11 +594,6 @@ def test_benchmark_paged_vs_native(self): q = mx.random.normal(shape=(bs, n_heads, dim)).astype(dtype) k_cont = mx.random.normal(shape=(bs, n_kv_heads, seq_len, dim)).astype(dtype) v_cont = mx.random.normal(shape=(bs, n_kv_heads, seq_len, dim)).astype(dtype) - q_old = q[:, :, None, :] - - old_k_cache = mx.random.normal((1, total_blocks, n_kv_heads, block_size, dim)).astype(dtype) - old_v_cache = mx.random.normal((1, total_blocks, n_kv_heads, block_size, dim)).astype(dtype) - mx.eval(old_k_cache, old_v_cache) print(f"\n[Benchmark BS={bs}, Len={seq_len}, Float16]") @@ -714,21 +622,6 @@ def test_benchmark_paged_vs_native(self): sinks_case = case["sinks"] label = case["name"] - old_ws = None if ws == 0 else ws - old_ms = _bench_old( - q_old, - old_k_cache, - old_v_cache, - block_tables, - seq_lens, - block_size, - scale, - n_kv_heads, - iters=100, - warmup=10, - window_size=old_ws, - sinks=sinks_case, - ) new_ms = _bench_new( q, key_cache, @@ -743,7 +636,4 @@ def test_benchmark_paged_vs_native(self): window_size=ws, sinks=sinks_case, ) - speedup = old_ms / new_ms if new_ms > 0 else float("inf") - print(f"Old kernel ({label}): {old_ms:.3f} ms") print(f"Paged Attn V1 ({label}): {new_ms:.3f} ms") - print(f"Speedup ({label}): {speedup:.2f}x") diff --git a/tests/test_deepseek_v32.py b/tests/test_deepseek_v32.py index 8ed9ebc3..ea7f753b 100644 --- a/tests/test_deepseek_v32.py +++ b/tests/test_deepseek_v32.py @@ -7,6 +7,7 @@ import mlx.core as mx +from parallax.utils.layer_types import DSA_ATTENTION, MLA_ATTENTION from parallax.models.deepseek_v32 import ( ModelArgs, ParallaxDeepSeekV32Attention, @@ -14,6 +15,7 @@ derive_indexer_types, ) from parallax.server.cache.dsa_cache import DeepSeekSparseCache +from parallax.server.cache.kv_cache import KVCachePacked from parallax.server.cache_manager import CacheManager from parallax.utils.utils import create_causal_mask @@ -167,6 +169,7 @@ def test_cache_manager_counts_compressed_mla_and_unexpanded_indexer_bytes(monkey index_key_heads=1, kv_lora_rank=4, qk_rope_head_dim=2, + layer_types=[DSA_ATTENTION, DSA_ATTENTION], ) dtype_size = 4 @@ -174,6 +177,62 @@ def test_cache_manager_counts_compressed_mla_and_unexpanded_indexer_bytes(monkey assert manager._calculate_kv_block_bytes(dtype_size) == expected +def test_cache_manager_counts_dense_mla_without_indexer(monkeypatch): + monkeypatch.setattr( + mx.metal, + "device_info", + lambda: {"max_recommended_working_set_size": 1024 * 1024}, + ) + monkeypatch.setattr(mx, "get_active_memory", lambda: 0) + + manager = CacheManager( + num_layers=2, + num_kv_heads=2, + head_dim=4, + head_dim_v=4, + dtype=mx.float32, + block_size=4, + cache_memory_fraction=1.0, + kv_lora_rank=4, + qk_rope_head_dim=2, + layer_types=[MLA_ATTENTION, MLA_ATTENTION], + ) + + dtype_size = 4 + expected = 2 * 4 * (4 + 2) * dtype_size + assert manager._calculate_kv_block_bytes(dtype_size) == expected + assert isinstance(manager.caches[0], DeepSeekSparseCache) + assert manager.caches[0].get_indexer_cache() is None + + +def test_cache_manager_does_not_infer_cache_type_from_sparse_params(monkeypatch): + monkeypatch.setattr( + mx.metal, + "device_info", + lambda: {"max_recommended_working_set_size": 1024 * 1024}, + ) + monkeypatch.setattr(mx, "get_active_memory", lambda: 0) + + manager = CacheManager( + num_layers=1, + num_kv_heads=2, + head_dim=4, + head_dim_v=4, + dtype=mx.float32, + block_size=4, + cache_memory_fraction=1.0, + index_head_dim=4, + index_n_heads=2, + kv_lora_rank=4, + qk_rope_head_dim=2, + ) + + dtype_size = 4 + expected = 2 * 4 * (4 + 4) * dtype_size + assert manager._calculate_kv_block_bytes(dtype_size) == expected + assert isinstance(manager.caches[0], KVCachePacked) + + def test_indexer_types_default_to_all_full(): assert derive_indexer_types(4) == ["full", "full", "full", "full"] diff --git a/tests/test_utils.py b/tests/test_utils.py index 5c35988b..19008153 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,7 @@ from types import SimpleNamespace from unittest.mock import patch +from parallax.utils.layer_types import DSA_ATTENTION, LINEAR, MLA_ATTENTION, MSA_ATTENTION from parallax.utils import utils @@ -56,3 +57,50 @@ def test_load_config_only_downloads_config_json(tmp_path): filename="config.json", local_files_only=True, ) + + +def test_get_layer_types_marks_dense_mla_attention(): + config = { + "model_type": "deepseek_v3", + "kv_lora_rank": 512, + "qk_rope_head_dim": 64, + } + + assert utils.get_layer_types(config, 0, 2) == [MLA_ATTENTION, MLA_ATTENTION] + + +def test_get_layer_types_marks_dsa_attention(): + config = { + "model_type": "glm_moe_dsa", + "kv_lora_rank": 512, + "qk_rope_head_dim": 64, + "index_head_dim": 128, + "index_n_heads": 4, + } + + assert utils.get_layer_types(config, 0, 2) == [DSA_ATTENTION, DSA_ATTENTION] + + +def test_get_layer_types_preserves_hybrid_linear_layers_with_mla_cache(): + config = { + "model_type": "kimi_k2", + "kv_lora_rank": 512, + "qk_rope_head_dim": 64, + "linear_attn_config": {"full_attn_layers": [1, 3]}, + } + + assert utils.get_layer_types(config, 0, 4) == [ + LINEAR, + MLA_ATTENTION, + LINEAR, + MLA_ATTENTION, + ] + + +def test_get_layer_types_marks_msa_attention_cache(): + config = {"model_type": "minimax_m3"} + + assert utils.get_layer_types(config, 0, 2) == [ + MSA_ATTENTION, + MSA_ATTENTION, + ] From 3cc209a55d1062d841e0ee1d8e347297d63db7c1 Mon Sep 17 00:00:00 2001 From: gufengc Date: Wed, 24 Jun 2026 20:52:07 +0800 Subject: [PATCH 2/2] update --- src/parallax/server/cache_manager.py | 14 +++++++------- src/parallax/server/executor/mlx_executor.py | 2 +- .../test_dsa_paged_attention.py | 6 +++++- tests/test_deepseek_v32.py | 2 +- tests/test_utils.py | 7 ++++++- 5 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/parallax/server/cache_manager.py b/src/parallax/server/cache_manager.py index 7466edab..30ea93aa 100644 --- a/src/parallax/server/cache_manager.py +++ b/src/parallax/server/cache_manager.py @@ -2,6 +2,13 @@ import mlx.core as mx +from parallax.server.block_radix_cache import BlockRadixCache +from parallax.server.cache.allocator import BlockAllocator, SlotAllocator +from parallax.server.cache.base import BaseCache +from parallax.server.cache.dsa_cache import DeepSeekSparseCache +from parallax.server.cache.kv_cache import KVCachePacked +from parallax.server.cache.linear_cache import LinearCache +from parallax.server.cache.minimax_m3_cache import MiniMaxM3SparseCache from parallax.utils.layer_types import ( ATTENTION, ATTENTION_LAYER_TYPES, @@ -10,13 +17,6 @@ MLA_ATTENTION, MSA_ATTENTION, ) -from parallax.server.block_radix_cache import BlockRadixCache -from parallax.server.cache.allocator import BlockAllocator, SlotAllocator -from parallax.server.cache.base import BaseCache -from parallax.server.cache.dsa_cache import DeepSeekSparseCache -from parallax.server.cache.kv_cache import KVCachePacked -from parallax.server.cache.linear_cache import LinearCache -from parallax.server.cache.minimax_m3_cache import MiniMaxM3SparseCache from parallax_utils.logging_config import get_logger logger = get_logger(__name__) diff --git a/src/parallax/server/executor/mlx_executor.py b/src/parallax/server/executor/mlx_executor.py index 1f1f43d4..6fd20831 100755 --- a/src/parallax/server/executor/mlx_executor.py +++ b/src/parallax/server/executor/mlx_executor.py @@ -10,7 +10,6 @@ import mlx.core as mx import numpy as np -from parallax.utils.layer_types import INDEX_CACHE_LAYER_TYPES, MLA_CACHE_LAYER_TYPES from parallax.server.cache_manager import CacheManager from parallax.server.executor.base_executor import BaseExecutor from parallax.server.request import ( @@ -25,6 +24,7 @@ complete_local_middle_chunk, filter_middle_chunk_next_batch, ) +from parallax.utils.layer_types import INDEX_CACHE_LAYER_TYPES, MLA_CACHE_LAYER_TYPES from parallax.utils.mac_prefill_adder import AddReqResult, MACPrefillAdder from parallax.utils.utils import ( combine_padding_and_causal_masks, diff --git a/tests/parallax_extensions_tests/test_dsa_paged_attention.py b/tests/parallax_extensions_tests/test_dsa_paged_attention.py index 64b539b4..d7056bab 100644 --- a/tests/parallax_extensions_tests/test_dsa_paged_attention.py +++ b/tests/parallax_extensions_tests/test_dsa_paged_attention.py @@ -4,7 +4,11 @@ import pytest from parallax.server.cache.dsa_cache import DeepSeekSparseCache -from parallax_extensions.ops import dsa_paged_attention, mla_paged_attention, reshape_and_cache +from parallax_extensions.ops import ( + dsa_paged_attention, + mla_paged_attention, + reshape_and_cache, +) pytestmark = pytest.mark.skipif(sys.platform != "darwin", reason="MLX tests require macOS") diff --git a/tests/test_deepseek_v32.py b/tests/test_deepseek_v32.py index ea7f753b..b55d4bb8 100644 --- a/tests/test_deepseek_v32.py +++ b/tests/test_deepseek_v32.py @@ -7,7 +7,6 @@ import mlx.core as mx -from parallax.utils.layer_types import DSA_ATTENTION, MLA_ATTENTION from parallax.models.deepseek_v32 import ( ModelArgs, ParallaxDeepSeekV32Attention, @@ -17,6 +16,7 @@ from parallax.server.cache.dsa_cache import DeepSeekSparseCache from parallax.server.cache.kv_cache import KVCachePacked from parallax.server.cache_manager import CacheManager +from parallax.utils.layer_types import DSA_ATTENTION, MLA_ATTENTION from parallax.utils.utils import create_causal_mask diff --git a/tests/test_utils.py b/tests/test_utils.py index 19008153..79ddca8f 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,8 +1,13 @@ from types import SimpleNamespace from unittest.mock import patch -from parallax.utils.layer_types import DSA_ATTENTION, LINEAR, MLA_ATTENTION, MSA_ATTENTION from parallax.utils import utils +from parallax.utils.layer_types import ( + DSA_ATTENTION, + LINEAR, + MLA_ATTENTION, + MSA_ATTENTION, +) def test_is_metal_available_uses_mlx_metal_is_available(monkeypatch):