Skip to content

docs(benchmarks): correct the M5 Max baichuan-m1-14b decode comparison and flag qwen3-0.6b - #49

Merged
inureyes merged 1 commit into
mainfrom
docs/m5max-baichuan-remeasure
May 20, 2026
Merged

inureyes merged 1 commit into
mainfrom
docs/m5max-baichuan-remeasure

Conversation

@inureyes

Copy link
Copy Markdown
Member

docs(benchmarks): correct the M5 Max baichuan-m1-14b decode comparison and flag qwen3-0.6b

The near-parity decode candidates were re-measured back-to-back with full-budget raw prompts that force 100 generated tokens on both mlxcel and the upstream baseline. Most of the 90-94% cluster turned out to be short-output measurement noise rather than a real gap, so only the two current-state facts worth keeping land here.

baichuan-m1-14b-4bit hits end-of-sequence after 7 tokens on the standard chat prompt, which made its vs-mlx-lm decode comparison meaningless at 55.89 tok/s (86%). On a full-budget raw prompt it sustains 64.73 tok/s against mlx-lm's 64.68, so the comparison row is corrected to 100%. The standard-prompt main-table row keeps its 7-token early-EOS reading because that is the real behaviour on that prompt.

qwen3-0.6b-4bit stays at ~93% of mlx-lm even on the full-budget raw prompt, so it is a genuine sub-95% decode gap and is now listed under Known Issues. The M5 Max last-updated date moves to 2026-05-21.

…n and flag qwen3-0.6b

The near-parity decode candidates were re-measured back-to-back with full-budget raw prompts that force 100 generated tokens on both mlxcel and the upstream baseline. Most of the 90-94% cluster turned out to be short-output measurement noise rather than a real gap, so only the two current-state facts worth keeping land here.

`baichuan-m1-14b-4bit` hits end-of-sequence after 7 tokens on the standard chat prompt, which made its vs-mlx-lm decode comparison meaningless at 55.89 tok/s (86%). On a full-budget raw prompt it sustains 64.73 tok/s against mlx-lm's 64.68, so the comparison row is corrected to 100%. The standard-prompt main-table row keeps its 7-token early-EOS reading because that is the real behaviour on that prompt.

`qwen3-0.6b-4bit` stays at ~93% of mlx-lm even on the full-budget raw prompt, so it is a genuine sub-95% decode gap and is now listed under Known Issues. The M5 Max last-updated date moves to 2026-05-21.
@inureyes inureyes added type:docs Documentation improvements or additions area:docs User and developer documentation labels May 20, 2026
@inureyes
inureyes merged commit a03288f into main May 20, 2026
4 checks passed
@inureyes
inureyes deleted the docs/m5max-baichuan-remeasure branch May 20, 2026 23:39
@inureyes inureyes self-assigned this May 25, 2026
@inureyes inureyes added this to the 0.1 milestone Jun 21, 2026
@inureyes inureyes added the status:done Completed label Sep 6, 2026
be-free-snowboy pushed a commit to freestylejs/snowbrain-mlxcel that referenced this pull request Oct 27, 2026
…n and flag qwen3-0.6b (lablup#49)

The near-parity decode candidates were re-measured back-to-back with full-budget raw prompts that force 100 generated tokens on both mlxcel and the upstream baseline. Most of the 90-94% cluster turned out to be short-output measurement noise rather than a real gap, so only the two current-state facts worth keeping land here.

`baichuan-m1-14b-4bit` hits end-of-sequence after 7 tokens on the standard chat prompt, which made its vs-mlx-lm decode comparison meaningless at 55.89 tok/s (86%). On a full-budget raw prompt it sustains 64.73 tok/s against mlx-lm's 64.68, so the comparison row is corrected to 100%. The standard-prompt main-table row keeps its 7-token early-EOS reading because that is the real behaviour on that prompt.

`qwen3-0.6b-4bit` stays at ~93% of mlx-lm even on the full-budget raw prompt, so it is a genuine sub-95% decode gap and is now listed under Known Issues. The M5 Max last-updated date moves to 2026-05-21.
inureyes added a commit that referenced this pull request Oct 30, 2026
## Confirmed mechanism

The abort is a `ModelProvider` worker thread freeing its model after `main` has returned. Reproduced on this box (kernel 7.0.0, same as the failing runner image) with the CI configuration (debug profile, no `cuda` feature, `MLXCEL_CI_PP_MODEL=qwen3-0.6b-4bit`); gdb with a breakpoint on `__cxa_pure_virtual` caught it on 3 of 3 attempts. Aborting thread (frames condensed):

```
#0  __cxa_pure_virtual ()
#1  mlx::core::allocator::Data::~Data  at mlx/allocator.h:79        (allocator().free(buffer))
#11 mlx::core::array::ArrayDesc::~ArrayDesc  at mlx/array.cpp:333
#22 mlx_cxx::MlxArray::~MlxArray  at cpp/mlx_cxx_bridge.h:27
#38 drop_glue<mlxcel_core::cache::paged::PagedBlockPool>
#49 spawn_model_worker_with_batch_config::{closure#0}::{closure#0}  at src/server/model_worker.rs:893
#55 mlxcel::worker_failfast::run_core_thread_or_abort
```

Main thread at the same instant:

```
#6  blas_thread_shutdown_ / #8 gotoblas_quit   (libopenblas finalizer)
#9  _dl_call_fini / #10 _dl_fini
#11 __run_exit_handlers / #12 exit
```

The worker (still joined by nobody: `Drop for ModelProvider` only sends `ModelRequest::Shutdown`, and its `JoinHandle` sits in the `observe_worker_exit` watcher thread, which gdb shows blocked in `pthread_join` on it) is dropping the scheduler's `PagedBlockPool`. Each array free goes through `allocator().free()`. On the CPU backend `allocator()` is `static CommonAllocator allocator_` in `mlx/backend/no_gpu/allocator.cpp`, a function-local static whose destructor runs from `__run_exit_handlers`; after it, the object's vtable is the abstract `Allocator`'s and `free` is pure virtual. The Metal, CUDA and ROCm allocators are heap-leaked on purpose and never reach this state, which is why only the CPU CI build aborts. The kernel change in the runner image only altered the scheduling that exposes the window; the test never joined the two providers.

## Fix

- `ModelProvider::shutdown_and_wait(timeout)` (`src/server/model_provider.rs`): sends the shutdown signal, waits on the existing `WorkerExitObserver`, returns `Err("server model worker did not exit within 120s")` on expiry (the duration is printed with `Debug`, so the production text is unchanged and a sub-second test timeout reads `500ms`, not `0s`) and `Err("server model worker panicked: ...")` on a panic. Idempotent; an exited worker and a closed channel return `Ok`. `WORKER_EXIT_TIMEOUT` (120 s) moves here as `pub` and is re-exported from `mlxcel::server` so the integration test can use it. `InProcessServer::shutdown` delegates to it. `Drop` stays non-blocking.
- `tests/pipeline_ci_multi_stage_real_models.rs`: the shared helper calls `shutdown_and_wait(WORKER_EXIT_TIMEOUT)` on the coordinator provider, then the dense one, and `expect`s both before shutting the stage handles down. `tests/pipeline_server_remote_real_models.rs` now does the same instead of dropping its two providers (CUDA and Metal only today, where the allocators are leaked, but exposed on a CPU runner).
- Unit tests in `src/server/model_provider_tests.rs`: cooperative worker (`Ok`, observed, second call `Ok`), already-exited worker (`Ok` with a zero timeout), worker panicking during teardown (`Err` carrying the message), worker ignoring shutdown (`Err` after a 1 s timeout, then `Ok` once released).

## Production exposure (per path)

- `mlxcel-server` single-model mode (`start_server`): affected. `serve_http` returning (bind or TLS error) and the five side-model resolution errors after the chat worker started loading all returned with the worker alive. Fixed: everything after the provider is created moved into the function `serve_with_model_provider`, whose result flows through `join_model_worker_before_exit`. That logs the serve error at error level first (`server stopped: ...; waiting for the model worker to exit`), so a bind or TLS failure during a long load is not a silent wait, then calls `shutdown_and_wait` on the blocking pool and logs `model worker exited before process exit`. The serve error is returned unchanged for `main` to print, and a shutdown failure never replaces it. SIGINT/SIGTERM in this mode still die by default disposition (no exit handlers run), unchanged. The audio, embedding, image and rerank side workers are trait objects without an exit observer and are not joined here; the same hazard class applies to them on the CPU backend and is tracked in #2745 (with the sleeping-worker reload on shutdown, the uncancellable load past the join timeout, and the `--pp-peer` signal path).
- Router mode (`run_router_server`): partly affected. The Ctrl-C arm already drained through `shutdown_all`, whose `unload` waits on each worker's exit observer (and the entry operation guard makes it wait for an in-flight `build_model_app` too). The `serve_http` error arm returned without draining, so a bind failure after `load-on-startup` exited under loading workers. Fixed: both arms drain before returning. The listener error is logged before the drain starts, and a Ctrl-C handler that cannot be installed now drains before its error is returned instead of skipping `shutdown_all`. Residual: a worker that ignores shutdown past `ROUTER_SHUTDOWN_TIMEOUT` (150 s) still exits with handlers running; the alternative is hanging.
- `--pp-peer` stage (`serve_remote_pipeline_stage`): not affected. `RemoteStageServiceHandle::shutdown` joins the stage thread, which owns the stage model and its current-thread runtime.
- `mlxcel run` / `chat`: not affected. They already join through `InProcessServer::shutdown` (#2224), which now shares the implementation.

`tests/server_exit_joins_worker.rs` drives a real `mlxcel-server` into the bind-failure exit (the test holds the port) for the single-model and the router `load-on-startup` paths and asserts exit code 1, no signal, no C++ abort text, and the join log line. The router test matches `router: lifecycle shutdown completed attempted=1 completed=1`, since a failed `begin_load` prints the same message with `attempted=0`. Both skip without the `qwen3-0.6b-4bit` checkpoint.

## Before / after

Command, on the debug CPU binary exactly as CI runs it:

```
BIN=target/debug/deps/pipeline_ci_multi_stage_real_models-<hash>
for i in $(seq 50); do MLXCEL_CI_PP_MODEL=qwen3-0.6b-4bit "$BIN" pipeline_multi_stage_two_host_logical_smoke --ignored --nocapture --test-threads=1; done
```

- Before the fix (origin/main 8385f40): `aborts: 44/50` (every abort log carries `pure virtual method called`; the other 6 runs exited cleanly)
- After the fix: `aborts: 0/50` (same binary rebuilt from this branch, same box, loops overlapped in time)

## Test plan

- [x] `cargo test --profile test-fast --features cuda -p mlxcel --lib server::model_provider:: -- --test-threads=1`
- [x] `cargo test --profile test-fast --features cuda -p mlxcel --lib server::in_process:: -- --test-threads=1`
- [x] `tests/server_exit_joins_worker.rs` (both tests, real checkpoint, `MLXCEL_REQUIRE_MODELS=1`)
- [x] the smoke test on the debug CPU binary, 50 consecutive runs
- [x] `cargo check --profile test-fast --features cuda,test-utils -p mlxcel --test pipeline_server_remote_real_models`
- [x] `cargo clippy --profile test-fast --features cuda -p mlxcel --lib --tests -- -D warnings`, `cargo fmt --all -- --check`

## CI stability

Ten consecutive `workflow_dispatch` runs of `pipeline-parallel-ci.yml` on this branch at head `03c1e365`, dispatched one at a time (the concurrency group cancels overlapping runs on the same ref). All ten passed, and all ten ran on `ubuntu24/20261025.353`, the runner image every one of the original aborts ran on. No job log contains `pure virtual method called`.

| # | Run | Head | Result | Runner image |
|---|---|---|---|---|
| 1 | [41045436759](https://github.com/lablup/mlxcel/actions/runs/41045436759) | `03c1e365` | success | `ubuntu24/20261025.353` |
| 2 | [41048002710](https://github.com/lablup/mlxcel/actions/runs/41048002710) | `03c1e365` | success | `ubuntu24/20261025.353` |
| 3 | [41049748107](https://github.com/lablup/mlxcel/actions/runs/41049748107) | `03c1e365` | success | `ubuntu24/20261025.353` |
| 4 | [41051499873](https://github.com/lablup/mlxcel/actions/runs/41051499873) | `03c1e365` | success | `ubuntu24/20261025.353` |
| 5 | [41053271228](https://github.com/lablup/mlxcel/actions/runs/41053271228) | `03c1e365` | success | `ubuntu24/20261025.353` |
| 6 | [41055089949](https://github.com/lablup/mlxcel/actions/runs/41055089949) | `03c1e365` | success | `ubuntu24/20261025.353` |
| 7 | [41056817489](https://github.com/lablup/mlxcel/actions/runs/41056817489) | `03c1e365` | success | `ubuntu24/20261025.353` |
| 8 | [41058570988](https://github.com/lablup/mlxcel/actions/runs/41058570988) | `03c1e365` | success | `ubuntu24/20261025.353` |
| 9 | [41060493226](https://github.com/lablup/mlxcel/actions/runs/41060493226) | `03c1e365` | success | `ubuntu24/20261025.353` |
| 10 | [41062232317](https://github.com/lablup/mlxcel/actions/runs/41062232317) | `03c1e365` | success | `ubuntu24/20261025.353` |

The two `pull_request` runs of the same workflow on this PR also passed on `ubuntu24/20261025.353`: [41024288311](https://github.com/lablup/mlxcel/actions/runs/41024288311) at `e4312b89` and [41045135070](https://github.com/lablup/mlxcel/actions/runs/41045135070) at `03c1e365`.

Closes #2738
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:docs User and developer documentation status:done Completed type:docs Documentation improvements or additions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant