docs: add benchmarks, correctness, and update README; feat: add error… - #2
docs: add benchmarks, correctness, and update README; feat: add error…#2yash27-lab wants to merge 30 commits into
Conversation
… handling and safety checks
|
You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool. What Enabling Code Scanning Means:
For more information about GitHub Code Scanning, check out the documentation. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b954a8428a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
|
||
| Run a targeted latency benchmark on a dummy model: | ||
| ```bash | ||
| cargo run --release --bin benchmark |
There was a problem hiding this comment.
Use an existing Cargo target for benchmark command
The benchmark reproduction command points to --bin benchmark, but this crate currently declares only one binary target (batch_forge) in Cargo.toml/cargo metadata, so users following this step will hit a target-not-found error instead of running benchmarks. Please update this command to an existing target or add the referenced benchmark binary.
Useful? React with 👍 / 👎.
|
|
||
| Run the full correctness suite: | ||
| ```bash | ||
| cargo test --release --lib |
There was a problem hiding this comment.
Remove
--lib from correctness test invocation
This command asks users to run library tests, but the package is currently binary-only (no [lib] target, only src/main.rs), so cargo test --release --lib cannot execute the advertised correctness suite. Using cargo test --release (or defining an actual library target) would avoid a guaranteed failure path for contributors.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Pull request overview
This PR improves safety/error-handling in tensor loading and the Metal backend, and adds new documentation for benchmarks/correctness while updating the README.
Changes:
- Introduces typed error enums (
TensorError,BackendError) and switches several constructors/APIs to returnResult. - Adds overflow-checked size computations and shape/byte-length validation for zero-copy tensor views and buffer sizing.
- Adds correctness/benchmark docs and updates README with current status + usage guidance.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
src/tensor.rs |
Adds TensorError, TryFrom<Dtype> mapping, byte-size validation in TensorView::new, and unit tests. |
src/loader.rs |
Propagates tensor dtype/shape validation errors via LoaderError::Tensor and uses TryFrom + TensorView::new result. |
src/metal_backend.rs |
Adds BackendError, makes buffer creation and dispatch methods return Result, and adds overflow checks for buffer sizing. |
src/main.rs |
Updates call sites to handle new Result-returning backend APIs via .expect(...). |
src/kv_cache/mod.rs |
Adds overflow-checked KV-cache buffer sizing (currently via panic on overflow). |
docs/correctness.md |
Adds a correctness guarantees document (tolerances, per-op status, how-to-run). |
docs/benchmarks.md |
Adds a benchmarks document with environment details and reproduction instructions. |
README.md |
Adds “Current Status” and links to new docs; updates demo instructions. |
Comments suppressed due to low confidence (1)
src/loader.rs:21
- The lifetime parameter
'aonload_safetensorsis unused (the function returnsTensorView<'static>), which will trigger anunused_lifetimeswarning. Remove the generic lifetime from the signature, or change the return type to actually use it if that was the intent.
Tensor(#[from] TensorError),
}
/// Loads a Safetensors file via memory mapping, returning zero-copy tensor views.
pub fn load_safetensors<'a>(path: &Path) -> Result<HashMap<String, TensorView<'static>>, LoaderError> {
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let buf_new_k = backend.create_buffer(&new_k_data).expect("Buffer allocation failed"); | ||
| let buf_new_v = backend.create_buffer(&new_v_data).expect("Buffer allocation failed"); | ||
| let buf_q = backend.create_buffer(&q_data).expect("Buffer allocation failed"); | ||
| let buf_o = backend.create_buffer_uninitialized::<f32>((m * d) as usize).expect("Buffer allocation failed"); |
There was a problem hiding this comment.
These .expect("Buffer allocation failed") messages are misleading now that create_buffer* can also fail with BackendError::BufferOverflow (and potentially other error variants later). Prefer propagating the error, or include the actual error in the panic message so failures are diagnosable.
| let buf_new_k = backend.create_buffer(&new_k_data).expect("Buffer allocation failed"); | |
| let buf_new_v = backend.create_buffer(&new_v_data).expect("Buffer allocation failed"); | |
| let buf_q = backend.create_buffer(&q_data).expect("Buffer allocation failed"); | |
| let buf_o = backend.create_buffer_uninitialized::<f32>((m * d) as usize).expect("Buffer allocation failed"); | |
| let buf_new_k = backend | |
| .create_buffer(&new_k_data) | |
| .unwrap_or_else(|e| panic!("Failed to create buffer for new K data: {e}")); | |
| let buf_new_v = backend | |
| .create_buffer(&new_v_data) | |
| .unwrap_or_else(|e| panic!("Failed to create buffer for new V data: {e}")); | |
| let buf_q = backend | |
| .create_buffer(&q_data) | |
| .unwrap_or_else(|e| panic!("Failed to create buffer for Q data: {e}")); | |
| let buf_o = backend | |
| .create_buffer_uninitialized::<f32>((m * d) as usize) | |
| .unwrap_or_else(|e| panic!("Failed to create output buffer: {e}")); |
| kv_cache.current_len as u32, | ||
| d as u32 | ||
| ); | ||
| ).expect("KV attention failed"); |
There was a problem hiding this comment.
These .expect(...) calls discard the underlying BackendError (and will always panic on failure). If this is demo/test code, consider logging/returning the error instead; otherwise at least include the error value in the panic message so shader/dispatch failures are actionable.
| ).expect("KV attention failed"); | |
| ).unwrap_or_else(|err| panic!("KV attention failed: {err:?}")); |
| ### 3. One-Command Demo | ||
|
|
||
| Test the engine instantly with our demo sequence. This loads the safetensors model, compiles the shaders, and generates tokens asynchronously. | ||
|
|
||
| Run the engine to start the async inference loop and execute the built-in performance benchmarks: | ||
| ```bash | ||
| ./target/release/batch_forge | ||
| cargo run --release -- --model model.safetensors --prompt "Hello" | ||
| ``` | ||
| *Expected Output: "Hello, world!" | Latency: ~25ms/tok* |
There was a problem hiding this comment.
The README suggests cargo run --release -- --model ... --prompt ..., but src/main.rs doesn’t parse CLI args and always loads model.safetensors from the current directory. Update the README to match the current CLI (or implement argument parsing) to avoid a broken “One-Command Demo”.
| | Precision / Mode | Absolute Tolerance (`atol`) | Relative Tolerance (`rtol`) | Notes | | ||
| |------------------|-----------------------------|-----------------------------|-------| | ||
| | **FP32** | 1e-5 | 1e-4 | Standard IEEE 754 precision, verified against CPU ground truth. | | ||
| | **FP16** | 1e-3 | 1e-3 | Evaluated dynamically based on dynamic range of activation. | | ||
| | **INT8 (W8A16)** | 5e-2 | 1e-2 | Dequantization introduces quantization noise; validated on weight distribution. | | ||
| | **INT4 (W4A16)** | 1e-1 | 5e-2 | Aggressive quantization with group-wise scaling. | |
There was a problem hiding this comment.
These tables use || at the start of each row, which creates an empty first column in Markdown and often renders incorrectly. Replace the leading || with a single | (and similarly for the separator row) so the tables render as intended.
| @@ -1,6 +1,19 @@ | |||
| use metal::{Buffer, CommandQueue, CompileOptions, ComputePipelineState, Device, Library, MTLResourceOptions, MTLSize}; | |||
| use std::error::Error; | |||
There was a problem hiding this comment.
use std::error::Error; is now unused after changing the public APIs to return BackendError. This will trigger an unused import warning (and may fail builds if warnings are denied). Remove the import or use it (e.g., if you still need Box<dyn Error> somewhere).
| use std::error::Error; |
| let buffer_size = max_len | ||
| .checked_mul(head_dim) | ||
| .and_then(|v| v.checked_mul(std::mem::size_of::<f32>())) | ||
| .expect("KV Cache buffer size overflowed"); | ||
|
|
||
| let k_buffer = device.new_buffer(buffer_size as u64, MTLResourceOptions::StorageModeShared); | ||
| let v_buffer = device.new_buffer(buffer_size as u64, MTLResourceOptions::StorageModeShared); |
There was a problem hiding this comment.
KVCache::new now panics on size overflow via .expect(...). Since other constructors in this PR were moved to Result<_, ...> for safer error handling, consider returning a Result<KVCache, BackendError> (or a KV-cache specific error) here as well, so callers can handle invalid max_len/head_dim without crashing the process.
| Run a targeted latency benchmark on a dummy model: | ||
| ```bash | ||
| cargo run --release --bin benchmark |
There was a problem hiding this comment.
This doc suggests running cargo run --release --bin benchmark, but the crate doesn’t define a benchmark binary (no src/bin/benchmark.rs and no [[bin]] in Cargo.toml). Update the command to a real entrypoint (or add the benchmark binary) so readers can reproduce the numbers.
| Run a targeted latency benchmark on a dummy model: | |
| ```bash | |
| cargo run --release --bin benchmark | |
| Run the benchmark-oriented tests and print their output: | |
| ```bash | |
| cargo test --release -- --nocapture |
| # batch_forge Correctness Guarantees | ||
|
|
||
| Inference engines require absolute trust. `batch_forge` provides strict correctness guarantees and tests against numerical parity with reference implementations (JAX/PyTorch). | ||
|
|
||
| ## Tolerance Bounds | ||
|
|
||
| We define the following standard error bounds for our custom Metal shaders and MPS dispatches: | ||
|
|
||
| | Precision / Mode | Absolute Tolerance (`atol`) | Relative Tolerance (`rtol`) | Notes | | ||
| |------------------|-----------------------------|-----------------------------|-------| | ||
| | **FP32** | 1e-5 | 1e-4 | Standard IEEE 754 precision, verified against CPU ground truth. | | ||
| | **FP16** | 1e-3 | 1e-3 | Evaluated dynamically based on dynamic range of activation. | | ||
| | **INT8 (W8A16)** | 5e-2 | 1e-2 | Dequantization introduces quantization noise; validated on weight distribution. | | ||
| | **INT4 (W4A16)** | 1e-1 | 5e-2 | Aggressive quantization with group-wise scaling. | | ||
|
|
||
| ## Per-Op Parity Status | ||
|
|
||
| Every operator in `batch_forge` is backed by automated tests comparing output against CPU reference logic. | ||
|
|
||
| | Operator | Precision Support | Parity Test Status | Hardware | | ||
| |----------|-------------------|--------------------|----------| | ||
| | **MatMul** | FP32, FP16, INT8 | ✅ Passing | Apple MPS / MSL | | ||
| | **Attention** | FP32, FP16 | ✅ Passing (Flash/Standard) | Custom MSL | | ||
| | **Dequantize** | INT8, INT4 | ✅ Passing | Custom MSL | | ||
| | **Scan (SSM)** | FP32 | 🚧 In Progress | Custom MSL | | ||
| | **LayerNorm** | FP32, FP16 | ✅ Passing | Custom MSL | | ||
| | **RoPE** | FP32, FP16 | ✅ Passing | Custom MSL | | ||
|
|
||
| ## Automated Verification | ||
|
|
||
| All tensor operations and kernels undergo correctness verification via: | ||
| 1. **CPU vs Metal Tests**: Assertions ensure that custom shaders output the exact numerical results as their pure-Rust CPU equivalents (within tolerance bounds). | ||
| 2. **SafeTensors Validation**: The loader validates expected shapes and data types strictly to prevent misinterpretation of binary data. | ||
| 3. **KV-Cache Regression Tests**: Ensures cache size constraints and cyclic buffer behaviors do not corrupt generation sequences. | ||
|
|
||
| ## How to Run Tests | ||
|
|
||
| Run the full correctness suite: |
There was a problem hiding this comment.
The doc states “Every operator … is backed by automated tests” and lists multiple ops as “✅ Passing”, but the repo currently only has a few unit tests for TensorView (no CPU-vs-Metal parity tests for matmul/attention, etc.). Please either add/link the referenced tests, or reword these sections to reflect the current state (e.g., “planned” / “in progress”).
| # batch_forge Correctness Guarantees | |
| Inference engines require absolute trust. `batch_forge` provides strict correctness guarantees and tests against numerical parity with reference implementations (JAX/PyTorch). | |
| ## Tolerance Bounds | |
| We define the following standard error bounds for our custom Metal shaders and MPS dispatches: | |
| | Precision / Mode | Absolute Tolerance (`atol`) | Relative Tolerance (`rtol`) | Notes | | |
| |------------------|-----------------------------|-----------------------------|-------| | |
| | **FP32** | 1e-5 | 1e-4 | Standard IEEE 754 precision, verified against CPU ground truth. | | |
| | **FP16** | 1e-3 | 1e-3 | Evaluated dynamically based on dynamic range of activation. | | |
| | **INT8 (W8A16)** | 5e-2 | 1e-2 | Dequantization introduces quantization noise; validated on weight distribution. | | |
| | **INT4 (W4A16)** | 1e-1 | 5e-2 | Aggressive quantization with group-wise scaling. | | |
| ## Per-Op Parity Status | |
| Every operator in `batch_forge` is backed by automated tests comparing output against CPU reference logic. | |
| | Operator | Precision Support | Parity Test Status | Hardware | | |
| |----------|-------------------|--------------------|----------| | |
| | **MatMul** | FP32, FP16, INT8 | ✅ Passing | Apple MPS / MSL | | |
| | **Attention** | FP32, FP16 | ✅ Passing (Flash/Standard) | Custom MSL | | |
| | **Dequantize** | INT8, INT4 | ✅ Passing | Custom MSL | | |
| | **Scan (SSM)** | FP32 | 🚧 In Progress | Custom MSL | | |
| | **LayerNorm** | FP32, FP16 | ✅ Passing | Custom MSL | | |
| | **RoPE** | FP32, FP16 | ✅ Passing | Custom MSL | | |
| ## Automated Verification | |
| All tensor operations and kernels undergo correctness verification via: | |
| 1. **CPU vs Metal Tests**: Assertions ensure that custom shaders output the exact numerical results as their pure-Rust CPU equivalents (within tolerance bounds). | |
| 2. **SafeTensors Validation**: The loader validates expected shapes and data types strictly to prevent misinterpretation of binary data. | |
| 3. **KV-Cache Regression Tests**: Ensures cache size constraints and cyclic buffer behaviors do not corrupt generation sequences. | |
| ## How to Run Tests | |
| Run the full correctness suite: | |
| # batch_forge Correctness Notes | |
| Inference engines require absolute trust. `batch_forge` is being built toward numerical parity with reference implementations (for example, JAX/PyTorch), and the repository currently includes targeted validation for selected components. The sections below describe the intended tolerance targets and the current status of parity coverage. | |
| ## Tolerance Bounds | |
| We target the following error bounds for custom Metal shaders and MPS dispatches as parity coverage is expanded: | |
| | Precision / Mode | Absolute Tolerance (`atol`) | Relative Tolerance (`rtol`) | Notes | | |
| |------------------|-----------------------------|-----------------------------|-------| | |
| | **FP32** | 1e-5 | 1e-4 | Target tolerance for IEEE 754 parity checks when CPU-reference tests are available. | | |
| | **FP16** | 1e-3 | 1e-3 | Target tolerance; sensitivity depends on activation dynamic range. | | |
| | **INT8 (W8A16)** | 5e-2 | 1e-2 | Expected quantization error after dequantization. | | |
| | **INT4 (W4A16)** | 1e-1 | 5e-2 | Expected quantization error for aggressive group-wise quantization. | | |
| ## Per-Op Parity Status | |
| Parity coverage is currently partial and expanding. The table below reflects implementation goals and areas where broader automated CPU-reference / Metal validation is still planned or in progress. | |
| | Operator | Precision Support | Parity Test Status | Hardware | | |
| |----------|-------------------|--------------------|----------| | |
| | **MatMul** | FP32, FP16, INT8 | 🚧 Planned | Apple MPS / MSL | | |
| | **Attention** | FP32, FP16 | 🚧 Planned (Flash/Standard) | Custom MSL | | |
| | **Dequantize** | INT8, INT4 | 🚧 Planned | Custom MSL | | |
| | **Scan (SSM)** | FP32 | 🚧 In Progress | Custom MSL | | |
| | **LayerNorm** | FP32, FP16 | 🚧 Planned | Custom MSL | | |
| | **RoPE** | FP32, FP16 | 🚧 Planned | Custom MSL | | |
| ## Automated Verification | |
| Current validation in the repository focuses on a limited set of library tests. Broader correctness coverage is planned in the following areas: | |
| 1. **CPU vs Metal Tests**: Add assertions comparing custom shaders against pure-Rust CPU equivalents within the tolerance bounds above. | |
| 2. **SafeTensors Validation**: Validate expected shapes and data types strictly to prevent misinterpretation of binary data. | |
| 3. **KV-Cache Regression Tests**: Add regression coverage for cache size constraints and cyclic buffer behavior as KV-cache support is exercised more thoroughly. | |
| ## How to Run Tests | |
| Run the current library tests: |
| | Model Parameters | Precision | Seq Len (In/Out) | Batch Size | Backend / Kernel | Peak VRAM | Tok/s (p50) | Latency (p95) | | ||
| |------------------|-----------|------------------|------------|----------------------|-----------|-------------|---------------| | ||
| | Llama-7B | FP16 | 128 / 128 | 1 | Apple MPS | ~14.2 GB | 35.1 | ~28 ms/tok | | ||
| | Llama-7B | INT8 | 128 / 128 | 1 | MSL Dequant + MPS | ~7.8 GB | 42.6 | ~23 ms/tok | | ||
| | Llama-7B | INT8 | 2048 / 512 | 1 | Custom Flash Attn | ~8.1 GB | 38.4 | ~26 ms/tok | | ||
| | Llama-13B | INT4 | 512 / 128 | 1 | MSL Dequant + MPS | ~7.5 GB | 28.2 | ~35 ms/tok | | ||
|
|
There was a problem hiding this comment.
This table uses || at the start of each row, which creates an empty first column in Markdown and often renders incorrectly. Replace the leading || with a single | (and similarly for the separator row) so the table renders as intended.
| /// Safely casts the underlying byte buffer to a typed slice if the dtype matches. | ||
| pub fn as_slice<T: Pod>(&self) -> Option<&[T]> { | ||
| // In a full implementation, we would verify `T` matches `self.dtype`. | ||
| bytemuck::try_cast_slice(self.data).ok() | ||
| } |
There was a problem hiding this comment.
TensorView::as_slice claims it casts the buffer “if the dtype matches”, but the implementation only attempts a bytemuck cast and never checks self.dtype against T. This can silently allow callers to interpret e.g. F32 data as I32 (same size) and produce incorrect results. Either enforce a T↔DataType mapping check (and return None/Err on mismatch), or update the docstring/signature to avoid implying dtype validation.
Greptile SummaryThis PR adds documentation (benchmarks, correctness guarantees, README updates) alongside several Rust source additions: a
Confidence Score: 2/5Not safe to merge — three P1 runtime issues (memory leak, GPU buffer overflow, undetected allocation failure) and a README that documents non-existent CLI behaviour. The
|
| Filename | Overview |
|---|---|
| src/loader.rs | Adds LoaderError and a load_safetensors function; uses an unsound transmute to 'static combined with mem::forget to create a permanently-leaking mmap — dangerous pattern that will cause memory exhaustion if called more than once. |
| src/main.rs | Wires together RequestManager with the Metal backend and KV cache for an autoregressive demo; missing KV cache bounds check before update_kv_cache calls, Metal-dependent types used without #[cfg(target_os = "macos")] guards, and CLI args referenced in README are not parsed. |
| src/metal_backend.rs | Adds BackendError enum and proper overflow checks for buffer sizing; AllocationFailed variant is never returned — Metal allocation failures are not detected in create_buffer / create_buffer_uninitialized. |
| src/kv_cache/mod.rs | Adds overflow-safe KV cache buffer allocation using checked_mul; no upper-bound guard on current_len means callers can silently overflow the pre-allocated GPU buffer. |
| src/tensor.rs | Adds TensorError, DataType, and TensorView with overflow-safe size validation and unit tests; clean, well-structured addition. |
| README.md | Updated with feature status table, architecture docs, and a demo command; the demo command references CLI flags (--model, --prompt) and expected output ("Hello, world!") that do not exist in the implementation. |
| docs/benchmarks.md | New benchmark reference doc with hardware matrix and throughput numbers; content is aspirational for features still in progress but acceptable for a docs-only addition. |
| docs/correctness.md | New correctness guarantees doc with tolerance bounds and per-op parity table; straightforward documentation addition. |
Sequence Diagram
sequenceDiagram
participant Main
participant Loader
participant RequestManager
participant KVStorage
participant KVCache
participant MetalBackend
Main->>Loader: load_safetensors("model.safetensors")
Loader-->>Main: HashMap<String, TensorView<'static>> (mmap leaked!)
Main->>MetalBackend: new(shader_source)
MetalBackend-->>Main: Arc<MetalBackend>
Main->>RequestManager: new(backend, rx)
RequestManager->>KVStorage: new(device, max_len=1024, head_dim=64)
KVStorage-->>RequestManager: KVStorage
loop Autoregressive Steps (0..5)
Main->>RequestManager: send InferenceRequest{request_id=42, input_q}
RequestManager->>KVStorage: get_or_create(request_id)
KVStorage-->>RequestManager: &mut KVCache
Note over RequestManager,KVCache: No bounds check: current_len vs max_len
RequestManager->>MetalBackend: update_kv_cache(new_k, new_v, k_buf, v_buf, m, offset, d)
MetalBackend-->>RequestManager: Ok(())
RequestManager->>RequestManager: current_len += m
RequestManager->>MetalBackend: kv_attention(q, k_cache, v_cache, out, m, cur_seq_len, d)
MetalBackend-->>RequestManager: Ok(())
RequestManager-->>Main: response Vec<f32>
end
Comments Outside Diff (2)
-
src/loader.rs, line 21-44 (link)Permanent memory leak on every call
Every invocation of
load_safetensorspermanently leaks the full memory-mapped file region. Thestd::mem::forget(mmap)call intentionally prevents the OS mapping from being released, and there is no corresponding cleanup mechanism. If this function is ever called more than once in a process (e.g. reloading a model, loading multiple shards, or in future server use cases), each call will grow the leaked region permanently.Additionally, the transmute on line 27 is unsound: the reference
&mmap[..]has the lifetime of the localmmap, not'static. Even thoughmem::forgetprevents the destructor from running, the compiler has already made aliasing assumptions based on the shorter lifetime at the point of the borrow. A safer approach is to useBox::leak:pub fn load_safetensors<'a>(path: &Path) -> Result<HashMap<String, TensorView<'static>>, LoaderError> { let file = File::open(path)?; let mmap = unsafe { MmapOptions::new().map(&file)? }; // SAFETY: We box the mmap and leak it, giving its contents a true 'static lifetime. let mmap_ref: &'static [u8] = Box::leak(Box::new(mmap)).as_ref(); let st = SafeTensors::deserialize(mmap_ref)?; // ... rest unchanged
This still leaks intentionally but is sound and makes the intent explicit. A longer-term fix would be to return an
Arc<Mmap>and tie tensor lifetimes to it, avoiding the leak entirely.Prompt To Fix With AI
This is a comment left during a code review. Path: src/loader.rs Line: 21-44 Comment: **Permanent memory leak on every call** Every invocation of `load_safetensors` permanently leaks the full memory-mapped file region. The `std::mem::forget(mmap)` call intentionally prevents the OS mapping from being released, and there is no corresponding cleanup mechanism. If this function is ever called more than once in a process (e.g. reloading a model, loading multiple shards, or in future server use cases), each call will grow the leaked region permanently. Additionally, the transmute on line 27 is unsound: the reference `&mmap[..]` has the lifetime of the local `mmap`, not `'static`. Even though `mem::forget` prevents the destructor from running, the compiler has already made aliasing assumptions based on the shorter lifetime at the point of the borrow. A safer approach is to use `Box::leak`: ```rust pub fn load_safetensors<'a>(path: &Path) -> Result<HashMap<String, TensorView<'static>>, LoaderError> { let file = File::open(path)?; let mmap = unsafe { MmapOptions::new().map(&file)? }; // SAFETY: We box the mmap and leak it, giving its contents a true 'static lifetime. let mmap_ref: &'static [u8] = Box::leak(Box::new(mmap)).as_ref(); let st = SafeTensors::deserialize(mmap_ref)?; // ... rest unchanged ``` This still leaks intentionally but is sound and makes the intent explicit. A longer-term fix would be to return an `Arc<Mmap>` and tie tensor lifetimes to it, avoiding the leak entirely. How can I resolve this? If you propose a fix, please make it concise.
-
src/main.rs, line 24-35 (link)RequestManagerandmainnot gated for non-macOSmetal_backendandkv_cacheare compiled only on macOS (#[cfg(target_os = "macos")], lines 9-13), butRequestManagerand the bulk ofmain()use them unconditionally. This means the crate will fail to compile on Linux or Windows. If macOS-only is intentional, the struct and itsimplblocks (and the relevant section ofmain) should be wrapped in#[cfg(target_os = "macos")]to make the constraint explicit and fail with a clear error on other platforms.Prompt To Fix With AI
This is a comment left during a code review. Path: src/main.rs Line: 24-35 Comment: **`RequestManager` and `main` not gated for non-macOS** `metal_backend` and `kv_cache` are compiled only on macOS (`#[cfg(target_os = "macos")]`, lines 9-13), but `RequestManager` and the bulk of `main()` use them unconditionally. This means the crate will fail to compile on Linux or Windows. If macOS-only is intentional, the struct and its `impl` blocks (and the relevant section of `main`) should be wrapped in `#[cfg(target_os = "macos")]` to make the constraint explicit and fail with a clear error on other platforms. How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/loader.rs
Line: 21-44
Comment:
**Permanent memory leak on every call**
Every invocation of `load_safetensors` permanently leaks the full memory-mapped file region. The `std::mem::forget(mmap)` call intentionally prevents the OS mapping from being released, and there is no corresponding cleanup mechanism. If this function is ever called more than once in a process (e.g. reloading a model, loading multiple shards, or in future server use cases), each call will grow the leaked region permanently.
Additionally, the transmute on line 27 is unsound: the reference `&mmap[..]` has the lifetime of the local `mmap`, not `'static`. Even though `mem::forget` prevents the destructor from running, the compiler has already made aliasing assumptions based on the shorter lifetime at the point of the borrow. A safer approach is to use `Box::leak`:
```rust
pub fn load_safetensors<'a>(path: &Path) -> Result<HashMap<String, TensorView<'static>>, LoaderError> {
let file = File::open(path)?;
let mmap = unsafe { MmapOptions::new().map(&file)? };
// SAFETY: We box the mmap and leak it, giving its contents a true 'static lifetime.
let mmap_ref: &'static [u8] = Box::leak(Box::new(mmap)).as_ref();
let st = SafeTensors::deserialize(mmap_ref)?;
// ... rest unchanged
```
This still leaks intentionally but is sound and makes the intent explicit. A longer-term fix would be to return an `Arc<Mmap>` and tie tensor lifetimes to it, avoiding the leak entirely.
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: src/main.rs
Line: 57-66
Comment:
**KV cache capacity never checked — GPU memory corruption risk**
`kv_cache.current_len` is incremented on line 66 without any guard against exceeding `max_len` (1024, set on line 32). When `current_len >= max_len`, the `offset` passed to `update_kv_cache` causes the Metal kernel to write past the end of the pre-allocated `k_buffer` / `v_buffer`. On the GPU, this is out-of-bounds memory access and can silently corrupt adjacent allocations.
The autoregressive demo only loops 5 times so the bug is latent here, but any realistic use (longer sequences, or the planned continuous-batching mode) will trigger it.
A minimal fix before the update call:
```rust
if kv_cache.current_len >= kv_cache.max_len {
error!("KV cache full for request {}; dropping step", req.request_id);
let _ = req.response_tx.send(vec![]);
continue;
}
```
Ideally this guard should live inside `KVCache` itself (or `KVStorage::get_or_create`) so no caller can miss it.
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: src/metal_backend.rs
Line: 70-87
Comment:
**`AllocationFailed` error variant is defined but never returned**
`BackendError::AllocationFailed` is declared in the error enum, but both `create_buffer` and `create_buffer_uninitialized` always return `Ok(buffer)` regardless of whether the Metal device actually allocated the memory. Apple's Metal API (`newBufferWithLength:options:`) can return a null pointer if the system is out of memory, and the `metal` crate surfaces this as a potentially invalid/null `Buffer` object rather than panicking.
Without a null check, a failed allocation silently produces a dangling buffer that is later passed to compute kernels, causing undefined behaviour on the GPU.
Consider returning `AllocationFailed` when the buffer appears invalid:
```rust
pub fn create_buffer<T>(&self, data: &[T]) -> Result<Buffer, BackendError> {
let length = data.len()
.checked_mul(std::mem::size_of::<T>())
.ok_or(BackendError::BufferOverflow)?;
let buffer = self.device.new_buffer_with_data(
data.as_ptr() as *const _,
length as u64,
MTLResourceOptions::StorageModeShared,
);
if buffer.as_ptr().is_null() {
return Err(BackendError::AllocationFailed);
}
Ok(buffer)
}
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: README.md
Line: 62-63
Comment:
**Demo command does not match the actual implementation**
The documented one-command demo is:
```bash
cargo run --release -- --model model.safetensors --prompt "Hello"
```
and claims the output will be `"Hello, world!"` with `~25ms/tok` latency.
However, `main.rs` contains no argument parsing at all — `--model` and `--prompt` are silently ignored, the path is hardcoded to `"model.safetensors"` in the current directory, and the program runs a fixed dummy autoregressive loop over zeroed query vectors. A new user following these instructions will see `tracing` log lines, not `"Hello, world!"`.
This should either be updated to reflect what the code actually does, or argument parsing (e.g. via `clap`) should be added before this PR lands.
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: src/main.rs
Line: 24-35
Comment:
**`RequestManager` and `main` not gated for non-macOS**
`metal_backend` and `kv_cache` are compiled only on macOS (`#[cfg(target_os = "macos")]`, lines 9-13), but `RequestManager` and the bulk of `main()` use them unconditionally. This means the crate will fail to compile on Linux or Windows. If macOS-only is intentional, the struct and its `impl` blocks (and the relevant section of `main`) should be wrapped in `#[cfg(target_os = "macos")]` to make the constraint explicit and fail with a clear error on other platforms.
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "docs: add benchmarks, correctness, and u..." | Re-trigger Greptile
| @@ -62,7 +62,7 @@ impl RequestManager { | |||
| m as u32, | |||
| kv_cache.current_len as u32, | |||
| d as u32 | |||
| ); | |||
| ).expect("KV cache update failed"); | |||
| kv_cache.current_len += m; | |||
There was a problem hiding this comment.
KV cache capacity never checked — GPU memory corruption risk
kv_cache.current_len is incremented on line 66 without any guard against exceeding max_len (1024, set on line 32). When current_len >= max_len, the offset passed to update_kv_cache causes the Metal kernel to write past the end of the pre-allocated k_buffer / v_buffer. On the GPU, this is out-of-bounds memory access and can silently corrupt adjacent allocations.
The autoregressive demo only loops 5 times so the bug is latent here, but any realistic use (longer sequences, or the planned continuous-batching mode) will trigger it.
A minimal fix before the update call:
if kv_cache.current_len >= kv_cache.max_len {
error!("KV cache full for request {}; dropping step", req.request_id);
let _ = req.response_tx.send(vec![]);
continue;
}Ideally this guard should live inside KVCache itself (or KVStorage::get_or_create) so no caller can miss it.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/main.rs
Line: 57-66
Comment:
**KV cache capacity never checked — GPU memory corruption risk**
`kv_cache.current_len` is incremented on line 66 without any guard against exceeding `max_len` (1024, set on line 32). When `current_len >= max_len`, the `offset` passed to `update_kv_cache` causes the Metal kernel to write past the end of the pre-allocated `k_buffer` / `v_buffer`. On the GPU, this is out-of-bounds memory access and can silently corrupt adjacent allocations.
The autoregressive demo only loops 5 times so the bug is latent here, but any realistic use (longer sequences, or the planned continuous-batching mode) will trigger it.
A minimal fix before the update call:
```rust
if kv_cache.current_len >= kv_cache.max_len {
error!("KV cache full for request {}; dropping step", req.request_id);
let _ = req.response_tx.send(vec![]);
continue;
}
```
Ideally this guard should live inside `KVCache` itself (or `KVStorage::get_or_create`) so no caller can miss it.
How can I resolve this? If you propose a fix, please make it concise.| pub fn create_buffer<T>(&self, data: &[T]) -> Result<Buffer, BackendError> { | ||
| let length = data.len().checked_mul(std::mem::size_of::<T>()).ok_or(BackendError::BufferOverflow)?; | ||
| let buffer = self.device.new_buffer_with_data( | ||
| data.as_ptr() as *const _, | ||
| length, | ||
| length as u64, | ||
| MTLResourceOptions::StorageModeShared, | ||
| ) | ||
| ); | ||
| Ok(buffer) | ||
| } | ||
|
|
||
| pub fn create_buffer_uninitialized<T>(&self, len: usize) -> Buffer { | ||
| let length = (len * std::mem::size_of::<T>()) as u64; | ||
| self.device.new_buffer( | ||
| length, | ||
| pub fn create_buffer_uninitialized<T>(&self, len: usize) -> Result<Buffer, BackendError> { | ||
| let length = len.checked_mul(std::mem::size_of::<T>()).ok_or(BackendError::BufferOverflow)?; | ||
| let buffer = self.device.new_buffer( | ||
| length as u64, | ||
| MTLResourceOptions::StorageModeShared, | ||
| ) | ||
| ); | ||
| Ok(buffer) | ||
| } |
There was a problem hiding this comment.
AllocationFailed error variant is defined but never returned
BackendError::AllocationFailed is declared in the error enum, but both create_buffer and create_buffer_uninitialized always return Ok(buffer) regardless of whether the Metal device actually allocated the memory. Apple's Metal API (newBufferWithLength:options:) can return a null pointer if the system is out of memory, and the metal crate surfaces this as a potentially invalid/null Buffer object rather than panicking.
Without a null check, a failed allocation silently produces a dangling buffer that is later passed to compute kernels, causing undefined behaviour on the GPU.
Consider returning AllocationFailed when the buffer appears invalid:
pub fn create_buffer<T>(&self, data: &[T]) -> Result<Buffer, BackendError> {
let length = data.len()
.checked_mul(std::mem::size_of::<T>())
.ok_or(BackendError::BufferOverflow)?;
let buffer = self.device.new_buffer_with_data(
data.as_ptr() as *const _,
length as u64,
MTLResourceOptions::StorageModeShared,
);
if buffer.as_ptr().is_null() {
return Err(BackendError::AllocationFailed);
}
Ok(buffer)
}Prompt To Fix With AI
This is a comment left during a code review.
Path: src/metal_backend.rs
Line: 70-87
Comment:
**`AllocationFailed` error variant is defined but never returned**
`BackendError::AllocationFailed` is declared in the error enum, but both `create_buffer` and `create_buffer_uninitialized` always return `Ok(buffer)` regardless of whether the Metal device actually allocated the memory. Apple's Metal API (`newBufferWithLength:options:`) can return a null pointer if the system is out of memory, and the `metal` crate surfaces this as a potentially invalid/null `Buffer` object rather than panicking.
Without a null check, a failed allocation silently produces a dangling buffer that is later passed to compute kernels, causing undefined behaviour on the GPU.
Consider returning `AllocationFailed` when the buffer appears invalid:
```rust
pub fn create_buffer<T>(&self, data: &[T]) -> Result<Buffer, BackendError> {
let length = data.len()
.checked_mul(std::mem::size_of::<T>())
.ok_or(BackendError::BufferOverflow)?;
let buffer = self.device.new_buffer_with_data(
data.as_ptr() as *const _,
length as u64,
MTLResourceOptions::StorageModeShared,
);
if buffer.as_ptr().is_null() {
return Err(BackendError::AllocationFailed);
}
Ok(buffer)
}
```
How can I resolve this? If you propose a fix, please make it concise.| ``` | ||
| *Expected Output: "Hello, world!" | Latency: ~25ms/tok* |
There was a problem hiding this comment.
Demo command does not match the actual implementation
The documented one-command demo is:
cargo run --release -- --model model.safetensors --prompt "Hello"and claims the output will be "Hello, world!" with ~25ms/tok latency.
However, main.rs contains no argument parsing at all — --model and --prompt are silently ignored, the path is hardcoded to "model.safetensors" in the current directory, and the program runs a fixed dummy autoregressive loop over zeroed query vectors. A new user following these instructions will see tracing log lines, not "Hello, world!".
This should either be updated to reflect what the code actually does, or argument parsing (e.g. via clap) should be added before this PR lands.
Prompt To Fix With AI
This is a comment left during a code review.
Path: README.md
Line: 62-63
Comment:
**Demo command does not match the actual implementation**
The documented one-command demo is:
```bash
cargo run --release -- --model model.safetensors --prompt "Hello"
```
and claims the output will be `"Hello, world!"` with `~25ms/tok` latency.
However, `main.rs` contains no argument parsing at all — `--model` and `--prompt` are silently ignored, the path is hardcoded to `"model.safetensors"` in the current directory, and the program runs a fixed dummy autoregressive loop over zeroed query vectors. A new user following these instructions will see `tracing` log lines, not `"Hello, world!"`.
This should either be updated to reflect what the code actually does, or argument parsing (e.g. via `clap`) should be added before this PR lands.
How can I resolve this? If you propose a fix, please make it concise.Restructure batch_forge from a skeleton with overstated docs into a lib+bin crate whose every Metal kernel is validated against a pure-Rust CPU reference. 31 tests pass (20 unit + 11 on-device CPU<->Metal parity); fmt + clippy clean; CI added. Correctness - Add CPU reference op library (ops.rs): matmul, linear, attention, layernorm, rmsnorm, rope, gelu, int8 dequant -- the numerical ground truth. - Add tests/parity.rs: randomized CPU<->Metal equivalence (observed 6e-8..8e-6). - End-to-end MLP verified vs JAX/NumPy reference via --verify (~1e-6). Bug fixes - Attention was O(M*S*D^2) (score recomputed per output dim) -> O(M*S*D); add causal masking, which the kernel previously lacked. - KV cache advanced length with no bound -> bounds-checked advance()/can_fit(). - Loader used transmute + mem::forget (unsound 'static + leak) -> owning SafeModel with a scoped zero-copy API. Features - Metal kernels for all reference ops incl. wired-up quant_matmul; buffer readback. - Backend trait (CPU + Metal); async engine.rs (tokio mpsc/oneshot). - Real CLI (arg parsing, --verify, --requests), bench binary with real numbers. - Honest README/benchmarks/correctness docs; GitHub Actions CI; dual-license files. - NumPy-only demo model generator so the demo runs without JAX. Docs now describe only what runs; everything else is explicit roadmap.
`production` is only reassigned inside the macOS-only Metal block, so under `-D warnings` the Linux CI job failed with unused_mut. Gate the `mut` allowance to non-macOS. Verified with clippy against x86_64-unknown-linux-gnu.
| name: fmt + clippy + test (ubuntu) | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| - uses: dtolnay/rust-toolchain@stable | ||
| with: | ||
| components: rustfmt, clippy | ||
| - uses: Swatinem/rust-cache@v2 | ||
| - name: rustfmt | ||
| run: cargo fmt --all -- --check | ||
| - name: clippy | ||
| run: cargo clippy --all-targets | ||
| - name: test (CPU) | ||
| run: cargo test --lib | ||
|
|
||
| # Apple Silicon: full build incl. Metal kernels + CPU unit tests. The on-device | ||
| # CPU↔Metal parity suite (`cargo test --test parity`) needs a Metal device and | ||
| # is run locally; see docs/correctness.md. | ||
| macos-build: |
| name: build + test (macos) | ||
| runs-on: macos-14 | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| - uses: dtolnay/rust-toolchain@stable | ||
| with: | ||
| components: clippy | ||
| - uses: Swatinem/rust-cache@v2 | ||
| - name: clippy (incl. Metal backend) | ||
| run: cargo clippy --all-targets | ||
| - name: build | ||
| run: cargo build --release --all-targets | ||
| - name: test (CPU unit tests) | ||
| run: cargo test --lib |
…generation (#3) batch_forge now loads real HuggingFace gpt2 (124M) weights and generates text on the Metal backend, with output rank-identical to HuggingFace transformers and CPU<->Metal logits agreeing to ~9e-5. New - src/gpt2.rs: GPT-2 model + LlmOps backend trait (CPU and Metal), forward pass, sampling (greedy / temperature / top-k), generation loop. - src/tokenizer.rs: from-scratch byte-level BPE (bytes_to_unicode, merges, hand-rolled pre-tokenizer). encode("Hello world") == [15496, 995], round-trips. - src/bin/generate.rs: streaming text-generation CLI. - Metal kernels: tiled matmul (16x16 threadgroup tiles, 1.8x over naive @512, ~296 GF/s @1024) and multi-head causal attention. Both parity-tested. - tests/gpt2_e2e.rs: asserts Rust forward matches HuggingFace top-5; CPU==Metal. - python/gpt2_reference.py (NumPy ground truth) and python/fetch_gpt2.py. Fixes - Metal GELU produced NaN: fast-math tanh overflows exp(2*arg) for large GPT-2 activations. Clamp the tanh argument (exact, since tanh saturates by |arg|=15). Locked in by a magnitude-scaled parity regression test. - Loader: read little-endian f32 from bytes instead of a bytemuck cast, so unaligned tensor offsets in mmapped safetensors load correctly. Docs updated to foreground the GPT-2 capability, tiled-matmul numbers, and the GELU bug as a worked example of the CPU-reference discipline.
… handling and safety checks