mlxcel-core: MLX C++ FFI 브리지¶
목차¶
직접 C++ 바인딩이 필요한 이유¶
MLX는 C++로 작성된 Apple Silicon 네이티브 ML 프레임워크입니다. 안정적인 ABI를 제공하는 C 래퍼 레이어(mlx-c)가 이미 있지만 여기에는 오버헤드가 따릅니다:
- 할당: C 래퍼는 모든 배열 참조에 대해 힙에 불투명 핸들 객체를 할당합니다.
- 복사: 일부 연산은 C ABI 경계를 넘기 위해 중간 복사가 필요합니다.
- 누락된 연산: C 래퍼가 모든 MLX 연산을 노출하지 않아 우회책이 필요합니다.
mlxcel은 C 래퍼를 완전히 우회하고 cxx 크레이트를 통해 MLX C++ API에 직접 바인딩합니다. 그 결과:
- 배열 전달에 오버헤드가 없습니다 (C++
UniquePtr<MlxArray>가 기본mlx::core::array에 직접 매핑). - 전체 MLX 연산 세트에 접근할 수 있습니다.
- Rust에서 커스텀 MLX 커널을 작성하고 등록할 수 있습니다.
mlxcel-core 크레이트는 src/lib/mlxcel-core/에 있습니다.
cxx 브리지 아키텍처¶
브리지는 cxx 크레이트의 #[cxx::bridge] 매크로를 사용하여 src/lib/mlxcel-core/src/lib.rs에 정의되어 있습니다:
#[cxx::bridge(namespace = "mlx_cxx")]
mod ffi {
unsafe extern "C++" {
include!("mlx_cxx_bridge.h");
/// Opaque wrapper for mlx::core::array
type MlxArray;
/// Opaque wrapper for mlx::core::Stream
type MlxStream;
// Operations are declared here and implemented in C++
fn matmul(a: &MlxArray, b: &MlxArray) -> UniquePtr<MlxArray>;
// ...
}
}
mlx_cxx_bridge.h 헤더는 mlx::core 연산을 래핑하는 C++ 심 함수를 선언합니다. 이 심 함수들은 UniquePtr/참조 시맨틱을 내부에서 쓰이는 mlx::core::array 값 시맨틱으로 변환합니다.
graph LR
subgraph "Rust (mlxcel-core)"
R[Model code<br/>e.g., Llama3Model] -->|ffi::matmul| B[cxx bridge<br/>lib.rs]
end
subgraph "C++ Shim Layer"
B -->|mlx_cxx::matmul| SH[mlx_cxx_bridge.cpp]
end
subgraph "MLX C++ Core"
SH -->|mlx::core::matmul| MLX[mlx::core]
MLX -->|Metal GPU kernel| GPU[Apple GPU]
end 모듈 개요¶
| 모듈 | 파일 | 설명 |
|---|---|---|
ffi | lib.rs | 핵심 cxx 브리지: 배열 팩토리, 연산, 스트림 관리 |
generate | generate.rs | LanguageModel 트레잇, SamplingConfig, 모델 상태 스냅샷 |
engine | engine/ | 배치 네이티브 디코드 엔진: Engine(open, prefill, step, submit/finish_rows, close), raw-completion 클라이언트 DirectEngine, 토큰 전용 투기적 루프 |
prefill_plan | prefill_plan.rs | PrefillPlan: 단일 프리필 형상(채택한 접두사, 스냅샷 분할, 청크 조각, 타일 패딩) |
cache | cache.rs, cache/ | KV 캐시 타입(KVCache, RotatingKVCache, ChunkedKVCache, 페이지드 풀)과 어텐션 디스패치(cache/attend.rs: KVCache::attend, cache::attend_batched) |
layers | layers.rs | 공유 레이어 연산 (RMSNorm, RoPE, 어텐션, 선형 등) |
sampling | sampling.rs | 토큰 샘플링: 온도, top-p, top-k, min-p, XTC |
sampling_row_step | sampling_row_step.rs | RowSampler: 엔진이 서버와 CLI 양쪽에서 쓰는 행 단위 샘플링 단계 |
sampling_token_bias | sampling_token_bias.rs | compose_token_bias: logit_bias, 언어 바이어스, 토큰 억제를 하나로 합성 |
weights | weights.rs | SafeTensors 로딩(load_weights_from_dir과 필터 변형) |
speculative | speculative/ | Engine::verify_round가 구동하는 토큰 전용 드래프터(PromptLookupDrafter, FullModelDrafter)와 MTP 라운드 루프 |
weight_materialize | weight_materialize.rs | 가중치 materialization 정책: 모델 로드 시점의 체크포인트 텐서 eager 읽기와 업로드(MLXCEL_WEIGHT_MATERIALIZATION) |
ops | ops.rs | 고수준 연산 구성 |
dtype | dtype.rs | Dtype 상수 및 변환 |
streams | streams.rs | 스트림 관리 유틸리티 |
generation_policy | generation_policy.rs | 공유 디코드 설정: 캐시 초기화, EOS 병합, RNG 시딩 |
utils | utils.rs | 유틸리티 함수 |
핵심 타입¶
MlxArray¶
MlxArray는 mlx::core::array에 대한 불투명 핸들입니다. Rust에서는 항상 UniquePtr<MlxArray>로 소유하며 이 포인터는 배열 값을 보유하는 힙 할당 래퍼입니다.
// Creating arrays
let zeros = ffi::zeros(&[batch, seq_len, hidden], ffi::FLOAT16);
let ones = ffi::ones(&[heads, head_dim], ffi::FLOAT32);
// Operations return UniquePtr<MlxArray>
let result: UniquePtr<MlxArray> = ffi::matmul(&a, &b);
// Extract scalar
let value: f32 = ffi::item_f32(&logits);
// Get shape
let shape: Vec<i32> = ffi::array_shape(&arr);
MlxStream¶
MlxStream은 MLX 연산 스트림을 나타냅니다. 동일한 스트림에 제출된 연산은 순서대로 실행되며 다른 스트림의 연산은 병렬로 실행될 수 있습니다.
mlxcel은 전용 생성 스트림을 사용하여 연산과 메모리 전송을 겹칩니다 (룩어헤드 파이프라이닝):
let gen_stream = ffi::new_stream_on_device(true); // GPU stream
// Operations on gen_stream run asynchronously
let output = ffi::matmul_stream(&a, &b, &gen_stream);
ffi::synchronize_stream(&gen_stream); // wait for completion
KVCache¶
KVCache 구조체는 하나의 트랜스포머 레이어에 대한 키 및 값 행렬을 보유합니다:
pub struct KVCache {
pub keys: Option<UniquePtr<MlxArray>>, // [batch, heads, seq_len, head_dim]
pub values: Option<UniquePtr<MlxArray>>, // [batch, heads, seq_len, head_dim]
pub offset: usize, // current sequence position
}
핵심 모듈¶
engine/과 generate.rs¶
generate.rs는 모든 모델이 구현하는 LanguageModel 트레잇을 정의하고, 디코드 루프는 engine/에 있습니다. Engine은 모델과 함께 열린 모든 시퀀스의 KV 상태를 담는 CachePool을 소유하며, 모델 순전파와 샘플러 추출, 종료 단계가 모두 이 엔진을 거칩니다. 서버의 BatchScheduler는 Engine을 통해서만 모델을 구동하고, DirectEngine(engine/direct.rs)은 mlxcel generate, mlxcel-bench-decode, 추론 세션이 쓰는 단일 시퀀스 raw-completion 클라이언트입니다. 단일 시퀀스는 크기 1인 스텝 배치로 처리하므로 별도의 단일 시퀀스 디코드 루프는 없습니다. 오류는 시퀀스 단위로 처리되어, 한 행이 실패하면 그 행만 오류로 끝나고 나머지 배치는 계속 디코딩합니다.
flowchart TD
A[Input tokens] --> B[Embed tokens]
B --> C{First step?}
C -->|Yes| D[Prefill: full prompt forward pass]
C -->|No| E[Decode: single token forward pass]
D --> F[Sample token from logits]
E --> F
F --> G{Stop condition?}
G -->|EOS or max_tokens| H[Return generated text]
G -->|No| I[Append token to input]
I --> E 핵심 최적화: 룩어헤드 파이프라이닝. CPU가 토큰 n의 샘플링 처리를 마치기 전에 토큰 n+1의 순전파가 GPU 스트림에 제출되므로, CPU 측 샘플링 시간이 GPU 실행 시간과 겹칩니다. 엔진은 이를 분할 스텝(Engine::submit 후 Engine::finish_rows)으로 제공하고 DirectEngine과 BatchScheduler가 모두 이 스텝을 사용하며, MLXCEL_FORCE_SYNC를 설정하면 꺼집니다.
layers.rs¶
모든 모델 구현이 사용하는 공유 레이어 연산:
rms_norm(x, weight, eps): RMSNormlayer_norm(x, weight, bias, eps): LayerNormlinear(x, weight, bias): 선형 프로젝션rope(x, cos, sin, traditional): Rotary Position Embeddingsattention(q, k, v, mask, scale): 스케일 내적 어텐션scaled_dot_product_attention(q, k, v, scale, mask): 플래시 어텐션 변형silu(x)/gelu(x)/relu(x): 활성화 함수take(x, indices, axis): 임베딩 조회- 4비트 / 8비트 가중치를 위한 다양한 양자화 행렬 곱셈 연산
sampling.rs¶
샘플링 스택을 다음 순서로 구현합니다:
- 온도 스케일링 적용:
logits / temperature - 로짓 바이어스 적용 (제공된 경우)
- top-k 필터링 적용: 상위 k개의 로짓만 유지
- top-p (핵) 필터링 적용
- min-p 필터링 적용
- XTC (Exclude Top Choices) 샘플링 적용
- DRY (Don't Repeat Yourself) 페널티 적용
- 반복 페널티 적용
- 빈도/존재 페널티 적용
- 결과 분포에서 샘플링 (또는 그리디의 경우 argmax)
weights.rs¶
SafeTensors 파일 로딩을 처리합니다:
// Load all .safetensors shards from a directory into a WeightMap
let weights = load_weights_from_dir(&model_path)?;
speculative/¶
투기적 디코딩의 드래프터를 구현합니다. 자체 draft-model 디코드 루프는 없습니다. 토큰 전용 드래프터(PromptLookupDrafter, FullModelDrafter)는 엔진의 Engine::verify_round가 구동하고, MTP와 DFlash 드래프터는 각자의 라운드 루프(speculative/mtp/, drafter/)를 가집니다. 한 라운드는 다음과 같습니다.
- 드래프터가 최대
k개의 후보 토큰을 제안합니다.FullModelDrafter는 작은 체크포인트를 자체 KV 캐시로 실행해 argmax를 제안하고,PromptLookupDrafter는 시퀀스 끝부분이 앞서 나온 위치의 뒤를 이어 복사합니다. - 타깃 모델이 단일 순전파로 현재 토큰과 제안 토큰을 검증합니다.
- 제안 토큰은 해당 위치의 타깃 샘플러 출력(temperature 0에서는 argmax)과 같을 때 수용됩니다(sampler-match). 경로별 규칙은
docs/speculative-acceptance.md에 있습니다. - 처음 거부된 위치에서는 타깃 자신의 샘플을 내보내고, 거부된 제안은 타깃과 드래프터 캐시에서 trim합니다.
- 대부분의 라운드에서 토큰이 둘 이상 수용되면 실효 처리량이 올라갑니다.
빌드 시스템¶
빌드 시스템은 src/lib/mlxcel-core/build.rs에 있습니다. src/lib/mlxcelverse/의 CMake 프로젝트로 MLX를 빌드한 뒤, 그 헤더를 대상으로 C++ 심 레이어를 컴파일합니다.
주요 단계:
- MLX 빌드:
src/lib/mlxcelverse/의 CMake 프로젝트를 실행합니다.CMakeLists.txt가 고정된 커밋(FetchContent_Declare(mlx ...)블록의GIT_TAG)의 upstream MLX를 가져오고,overlays/{cpu,metal,cuda,rocm}/의 백엔드별 소스 오버레이를 가져온 트리 위에 복사한 다음libmlx를 컴파일합니다. - 백엔드 선택: macOS에서는 Metal과 Accelerate가 기본으로 켜지고(
MLXCEL_BUILD_METAL=OFF로 Metal을 끌 수 있음),cuda와rocmCargo feature가 CUDA와 ROCm 백엔드를 선택합니다. - 컴파일:
cpp/mlx_cxx_bridge.cpp를 비롯한 브리지 소스와src/lib/mlxcelverse/kernels/의 mlxcel 자체 커널 런처를-std=c++20으로 컴파일합니다. - 링킹: 정적
libmlx.a에 링크하고, macOS에서는 Metal과 Accelerate 프레임워크에도 링크합니다.
src/lib/mlxcel-core/
build.rs # Build script
src/
lib.rs # cxx bridge declaration
*.rs # Rust modules
cpp/
mlx_cxx_bridge.h # C++ header (declares shim functions)
mlx_cxx_bridge.cpp # C++ implementation (calls mlx::core)
lib.rs 수정 후 cxx 바인딩을 재생성하려면 다음을 실행합니다:
cxx 크레이트는 프로시저럴 매크로를 통해 빌드 시 필요한 글루 코드를 생성하므로 별도의 코드 생성 단계는 필요하지 않습니다.
새 연산 추가하기¶
Rust에 새 MLX 연산을 노출하려면:
-
심 함수를 선언합니다.
cpp/mlx_cxx_bridge.h에: -
심을 구현합니다.
cpp/mlx_cxx_bridge.cpp에: -
바인딩을 선언합니다.
src/lib.rs에: -
Rust 래퍼를 추가합니다. 적절한 모듈(예:
src/lib/mlxcel-core/src/ops.rs)에: -
테스트를 추가합니다.
src/lib/mlxcel-core/src/ffi_tests.rs에:
여러 모델 계열이 사용하는 연산을 추가할 때는 src/lib/mlxcel-core/src/layers.rs의 사용 주석을 갱신해 모든 호출 위치를 나열하십시오. 이 주석은 해당 연산을 나중에 변경할 때 재테스트 목록으로 사용됩니다.