Thank you for your interest in contributing to mlxcel! This document covers the basics for getting started. The deeper working contract lives in the docs/ directory: docs/architecture.md for the runtime and module map, docs/code-guidelines.md for the shared-function conventions and the file-size and module-split thresholds, and docs/adding-models.md for the model-porting checklist.
| You want to... | Read |
|---|---|
| Report a security vulnerability | SECURITY.md — do not open a public issue |
| File a bug or feature request | GitHub Issues (use the templates) |
| Build and test locally | docs/installation.md |
| Understand the architecture | docs/architecture.md |
| Add a new model family | docs/adding-models.md |
Add or validate an embedding or reranker family (/v1/embeddings, /v1/rerank, mlxcel embed, mlxcel rerank) |
docs/embeddings.md |
| Understand the code guidelines (shared functions, file size and module splits) | docs/code-guidelines.md |
- Search existing issues first.
- Use the bug-report or feature-request template — they prompt for the information we need to act on the issue.
- Include the mlxcel version (
mlxcel --version), platform (macOS Apple Silicon / Linux CUDA + GPU model), and the checkpoint you were running. - For inference correctness or performance reports, also include the prompt and seed so the run is reproducible.
-
Fork the repository and create a feature branch off
main:git checkout -b feat/short-description
-
Make your changes. Keep one PR scoped to one logical change — a model port, an MLX bump, and a CLI rename are three PRs.
-
Build and test for your target:
# macOS (Apple Silicon) cargo build --release --features metal,accelerate cargo test --workspace --profile test-fast --features metal,accelerate # Linux / CUDA cargo build --release --features cuda cargo test --workspace --profile test-fast --features cuda -- --test-threads=1
-
Run the local quality gates:
cargo fmt --all -- --check # gated at PR time; fmt violations block merge cargo clippy --workspace --all-targets --features metal,accelerate -- -D warnings # NOT gated at PR time; yours to run cargo test --workspace --profile test-fast --features metal,accelerate --no-fail-fast -- --test-threads=1 # NOT gated at PR time; yours to run cargo deny check # gated at PR time (advisories + licenses + sources)
PR-time CI runs only the cheap gates:
cargo fmt,cargo denyand a workspace crate-version consistency check inci.yml, plus a path-filtered clippy and adistributed::-scopedcargo testinpipeline-parallel-ci.ymlwhen you touch pipeline-parallel code. Clippy and the general unit suite are not enforced on your PR. They were moved in #21 and removed in #23 because ~30 min per run on the shared self-hosted Apple Silicon runner blocked PRs and releases for failures thatmake verifycatches locally in a fraction of the time.nightly-verify.ymlruns the fullmake verifyonce a day onself-hosted-macos-26-arm64and files an issue whenmaingoes red or when the run does not finish, so a broken suite surfaces within a day rather than on the next contributor'smake verify. Treat that as a backstop, not a substitute: run the two commands above yourself before you push. CUDA verification is not gated at PR time either; that stays exclusive torelease.yml.make verifyruns six prerequisite targets:verify-versions,verify-kernel-dtype-keys,verify-llama-compat,verify-fmt,verify-clippy, andverify-test. The last three are the fmt, clippy, and test commands above;cargo deny check, the fourth command in that block, is not one of them, and PR CI is what gates it. The first three are cheap consistency gates:verify-versionsasserts every version-tracking workspace crate carries the rootmlxcelversion,verify-kernel-dtype-keysasserts every CUDA JIT kernel launch keys its cache on the input dtypes (#1053, #1054), andverify-llama-compatvalidates the checked-in llama-server b10621 compatibility manifest undercompat/llama-server/b10621/structurally, with no network access required (#1443). The nightly invokes those same Makefile targets, so the local gate and CI cannot drift apart. Tests build under[profile.test-fast]rather than--release:opt-level = 3is kept so MLX numerics stay representative, while cross-crate LTO and the single codegen unit that make full--releasetest links expensive are dropped.make test-fast/make test-fast-cudaare the same profile with--test-threads=1and aFILTERhook for narrowing the run while you iterate. Reach forcargo test --release --features metal,accelerateby hand only when you suspect a defect specific to release codegen. Seedocs/installation.mdfor the measured comparison.The plain
make test,make test-verbose, andmake test-libtargets stay on Cargo's defaultdevprofile and now exportRUST_MIN_STACK=16777216for you. That stack bump is there forserver::reasoning_effort_tests: its repeated renders of the pinned 8,952-byte Qwen3.8 chat template can overflow libtest's default 2 MiB per-test thread stack under unoptimizeddev, even though the same suite passes undertest-fastandrelease. If you bypass the Makefile and run thedev-profile server tests directly, keep the same prefix yourself:RUST_MIN_STACK=16777216 cargo test --lib --features metal,accelerate server::.make verify-testis intentionally unaffected because it already builds under[profile.test-fast].--workspaceis not optional, and leaving it off is how the gate went blind before (#1007). This repository's workspace root is itself themlxcelpackage, so a barecargo testorcargo clippyresolves to-p mlxceland never buildsmlxcel-core,mlxcel-mlx-pin,mlxcel-surgeryormlxcel-xlaat all, let alone their test targets. That hid 1754 tests, 1354 of them inmlxcel-core, which is the crate holding the MLXcxxbridge,layers.rs, the KV cache and the quantization loaders. It also hid test-only lint debt, since--all-targetswithout--workspacedoes not compile a member's test target either.cargo fmt --allwas already workspace-wide, which is why the fmt gate never had the hole. Each member builds at the feature set the root selects:mlxcel-coregetsmetalandacceleratethrough the root's forwarding,mlxcel-mlx-pin,mlxcel-surgeryandmlxcel-xlaget their empty defaults.mlxcel-xla'sireefeature stays off, so the gate needs no IREE distribution and the code behindiree,diagnosticsandmicro-oracleremains ungated.--no-fail-fastgoes with it. Once the run covers five members, the first failing test binary would otherwise end it and hide the other four; cargo still exits non-zero, so the gate is no weaker. Cargo runs the test binaries one at a time, so themlxcel-coresuite never shares the Metal device with the root suite, which is the condition that corrupts results in #1008.--test-threads=1goes with it too, on macOS as well as on CUDA (#1092). Cargo's one-at-a-time sequencing bounds concurrency between binaries and says nothing about concurrency inside one, and libtest defaults to one test thread per logical CPU. That is what tookmainred on 2026-08-16: themlxcel-corebinary died withsignal: 11, SIGSEGVand published no panic and notest resultline, so cargo reported a failed target with nothing to read. The crash report from the local repro on an 18-core M5 Max has 18 libtest workers live at the fault, all in MLX-backed cache tests, two insideiokit_user_client_trap, faulting on an unmapped address. Do not reach for--jobs 1:--jobsbounds the build, which has finished before any test runs. Serializing costs +7.2s on the whole workspace (69.17s to 76.39s, 101 binaries, 8128 tests, warm), because the work already serializes on the one Metal device;mlxcel-corecosts +23s while the root suite gains 12s.make test-fasthas serialized on macOS since #809, so the gate now agrees with the edit-test loop. Unlike CUDA there is no guard test, because a parallelcargo test -p mlxcel-core --libis three times faster and nearly always succeeds; narrowed hand-runs are meant to stay parallel.On Linux/NVIDIA the gate is
make verify-test-cuda, and it must run single threaded. That target iscargo test --workspace --profile test-fast --features cuda --no-fail-fast -- --test-threads=1. CUDA tests are gated nowhere in CI, not even nightly (release.ymlbuilds under--features cudabut runs no tests), so this local run is the entire gate for the backend.--test-threads=1is not a formality: driving MLX from the many host threads libtest spawns by default takes the process down with SIGABRT partway through the suite, at a different test and with a different CUDA error each run, so the abort reads as if whichever test was running is broken. Measured on GB10 at MLX pin2c46b953, the default 20-thread run dies atcudaStreamEndCapture ... previous error during capturewhile the same binary serialized reports a verdict on 1410 tests in 88 seconds (#1048). Do not reach forMLX_USE_CUDA_GRAPHS=0: with graph capture disabled the 20-thread run still aborts, ascuLaunchKernelEx ... invalid argument, so capture only selects the symptom and concurrency is the cause. Capture stays fully on under the gate.mlxcel-corecarries athe_cuda_test_suite_must_run_single_threadedguard, so a hand-runcargo test --workspace --features cudathat forgets the flag fails by name with the right command rather than aborting anonymously; narrowed runs whose filter does not match the guard's own name filter it out and stay parallel, which is fine, because it is whole-suite runs that abort. A filter that does match it,--lib cudafor one, trips the guard on a run that would have been safe; setMLXCEL_ALLOW_PARALLEL_CUDA_TESTS=1for that case.If you touch the video path, also run
make verify-test-video. That target isMLXCEL_TEST_VIDEO=1 cargo test --profile test-fast --features metal,accelerate -p mlxcel --lib -- --include-ignored --skip bench_single_pass_768_frames multimodal::video vision::processors::gemma4::tests::process_videos_pixel_values_match_input_color, and it needsffmpeg5.0 or newer onPATH. The two positional filters are ORed by libtest, and they sit after the--becausecargo testitself takes only one TESTNAME. The second one is the Gemma 4 processor's pixel-content test: it decodes a synthetic clip through the sameload_videoentry point before running it throughprocess_videos, so it is ffmpeg-backed too even though it lives outsidemultimodal::video. It is deliberately not part ofmake verify, so a contributor without ffmpeg still gets a green gate. The ffmpeg-backed tests carry#[ignore], which is why--include-ignoredis required to select them at all: stable libtest counts a test that inspectsPATHand returns early as a pass, so the older runtime skip made a host with no ffmpeg report34 passed; 0 failedwhile a host with ffmpeg reported31 passed; 3 failed. The broken host was the one that looked healthy, and every video path in the runtime stayed broken for as long as it took ffmpeg 8 to remove-vsync(#1172).MLXCEL_TEST_VIDEO=1closes the other half: on a run that is supposed to exercise video, a missing ffmpeg is a hard failure rather than a skip.nightly-verify.ymlinstalls ffmpeg and runs this target as its own reported step.make test-fastandmake test-fast-cudaare edit-test-loop targets, not gates. They stay on the root package, sotest-fast-cudaruns none ofmlxcel-core's 1410 tests. -
If the change moves the numbers, measure what it moved. Quantization, kernel selection, fused ops, block widths and anything else that changes arithmetic needs a measurement rather than an assertion, and the two obvious measurements are both traps on their own. Byte-identity is a yes/no that says nothing once the answer is no, and on Apple GPU generation 15 and newer it is already no for reasons you did not choose. Perplexity is a corpus-level scalar that a kernel reordering can leave unmoved while flipping percents of the greedy tokens a user sees. Use the teacher-forced logit trace instead (
examples/logit_tracewithscripts/compare_logit_traces.py, documented under Judging a change that moves the numbers) and put its numbers in the PR body. Two things decide whether the answer means anything: gate on disagreement at decided positions, because a position the reference was indifferent about has no right answer to get wrong; and trace at the width the code under test actually runs at, behind a realistic context, because the forward width selects which quantized-matmul kernel MLX dispatches and therefore selects what is being measured. The same comparison has read 20.6% disagreement at width 8 and 0.0% at width 32. -
For inference changes, validate against a real checkpoint. Synthetic or build-only validation is not enough: a shape-compatible change can compile, pass unit tests, and still produce wrong logits on an actual quantized checkpoint. Fetch one with
mlxcel download mlx-community/<model-id>, and seedocs/supported-models.mdfor the families each code path covers. A change to a shared component should be smoke-tested against at least two families. -
Commit with a conventional prefix (see below) and a clear message.
-
Push to your fork and open a Pull Request. The PR template will prompt for a summary, test plan, and linked issues.
Write commits, PR titles, and issue comments in English. Use Conventional Commits prefixes:
| Prefix | When |
|---|---|
feat: |
New user-visible feature |
fix: |
Bug fix |
perf: |
Performance improvement with measurable evidence |
refactor: |
Internal restructuring without behavior change |
chore: |
Build, CI, dependencies, release infrastructure |
docs: |
Documentation |
test: |
Tests only |
- Follow standard Rust conventions:
rustfmt,clippy -D warnings, idiomatic ownership and error handling. - Tests live next to the code (
_tests.rsfiles) for unit tests, and undertests/for end-to-end integration. - When modifying a function shared by multiple models, update the
// Used by: Model1, Model2, …comment above it. Seedocs/code-guidelines.md. - Keep files under the size and module-split thresholds in
docs/code-guidelines.md; move inline tests to a sibling_tests.rsfile once they outgrow the guidance there. - Do not introduce Python on the inference request path. Python is acceptable only for benchmarks and out-of-band tooling.
#NNN auto-links to lablup/mlxcel, so use a bare #NNN only for issues and PRs in this repository. Any reference to another repository must be qualified so it resolves correctly and never leaks a private-repo number:
- Upstream references are written
org/repo#NNN—ml-explore/mlx-lm#1240,Blaizzy/mlx-vlm#1181,ml-explore/mlx#3475,huggingface/transformers#NNN. mlxcel-internal(private) numbers must never appear anywhere — code comments, docs, commit subjects, or PR bodies. Map an internal reference to its public-equivalent PR/issue when one demonstrably exists; otherwise describe the change without a number.
Pre-flight before pushing — review every bare 3+-digit reference you add:
git diff origin/main...HEAD | grep -nE '#[0-9]{3,}'
# or, scoped and classified (advisory by default; STRICT=1 to gate):
python3 scripts/ci/check_cross_repo_refs.pyWhen GH_TOKEN or GITHUB_TOKEN is available, the helper asks GitHub for the
current highest issue/PR number in lablup/mlxcel and treats any larger bare
ref as likely cross-repository. Lines that explicitly name an upstream project
are still flagged regardless of the number. Offline, unauthenticated, or
failed-API runs stay advisory, print the fallback reason, and leave non-upstream
bare refs in the manual-review bucket.
CI runs the same check on every pull request (advisory). Same-repository pull
requests pass github.token so the live boundary is exercised there too; fork
pull requests intentionally use the offline fallback rather than exposing the
base repository's token to PR-controlled code. The same CI step also runs
scripts/ci/check_cross_repo_refs_test.sh, the companion shell test for the
classifier.
See docs/adding-models.md for the full checklist. The short version: land one working checkpoint plus tests before broadening, mirror the mlx-lm / mlx-vlm directory shape where it helps, and update docs/supported-models.md plus the detection table in src/models/detection.rs.
Edit one line: GIT_TAG in the FetchContent_Declare(mlx ...) block of src/lib/mlx-cpp/CMakeLists.txt. That is the commit CMake fetches, and since #1047 it is the only place the value is written down. It must be a full 40-character lowercase hex SHA; a branch or tag name is rejected, because the build-cache marker and the fetched-HEAD check both compare against an exact commit.
Everything else derives from it:
| Consumer | How it reads the pin |
|---|---|
src/lib/mlxcel-core/build.rs |
Parses the GIT_TAG line at build time via build_support/mlx_pin.rs, then drives _deps/ purging, the _deps/.mlx-build-commit marker, the post-build check that the fetched _deps/mlx-src HEAD really is that commit, and the MLXCEL_MLX_COMMIT value baked into the binary |
.github/workflows/release.yml |
Runs scripts/ci/mlx_pinned_commit.sh inside each "Validate MLX build cache" step |
Both parsers scope themselves to the declaration whose GIT_REPOSITORY names the MLX repository, so a second FetchContent_Declare cannot supply the pin by accident, and both fail loudly rather than guess when the line is missing, duplicated, or malformed. The Rust half is unit-tested by cargo test -p mlxcel-mlx-pin, which runs in seconds because it does not compile mlxcel-core.
This used to be three literals in three files with nothing checking that they agreed. A partial bump left _deps/ looking valid, so FetchContent never re-ran and the build linked the previous MLX while reporting the new commit. The workflow's copy had in fact already fallen a bump behind by the time #1047 was filed.
After bumping the pin, re-validate the in-tree fused Metal kernel launchers in src/lib/mlx-cpp/turbo/, which are runtime-JIT paths a breaking MLX API change can silently regress:
sparse_v_sdpa.cpp, testsparse_v_kernel_threshold_zero_matches_graph.turbo4_delegated_sdpa.cpp::turbo4_delegated_cold_weighted_sum, testdelegated_fused_kernel_matches_reference_over_200_steps.turbo4_delegated_sdpa.cpp::turbo4_delegated_steel_sdpa, testdelegated_steel_envelope_matches_cold_only_fused_over_200_steps.
All three should produce output within RMS < 5e-3 of the graph reference on Apple Silicon.
Detailed setup instructions are in docs/installation.md.
Minimum:
- Rust 1.97+ (project uses edition 2024)
- CMake available on
PATHon both platforms (required by themlxcel-coreandsentencepiece-sysbuild scripts) - macOS: Apple Silicon Mac on macOS Sonoma+; Xcode Command Line Tools
- Linux: CUDA 13+ toolchain, OpenBLAS, LAPACK (see
docs/installation.mdfor the package list)
Recommended local tooling:
cargo install cargo-deny --locked
cargo install cargo-audit --lockedThis project follows the Contributor Covenant Code of Conduct. By participating, you agree to abide by its terms.
- General questions, design discussion: open a GitHub Discussion (when enabled) or a
questionissue. - Security: see
SECURITY.md.
By contributing to mlxcel, you agree that your contributions will be licensed under the Apache License 2.0.