Skip to content

chore: bench_decode.sh all measures duplicate checkpoints twice #1615

Closed

Description

@inureyes

Body rewritten 2026-09-04. The body originally filed here was a copy of #1616 (batched MoE decode scaling) and did not describe this issue at all; the two differed only in cosmetic wording. Everything below is reconstructed from the script and from the committed benchmark CSVs. The title was correct.

Problem / Background

bench_decode.sh all enumerates "$MODELS_DIR"/*/ and benchmarks every directory it finds (scripts/bench_decode.sh:658 and :666). Nothing deduplicates, so a checkpoint present under two directory names is loaded, prefilled, decoded and cooled down twice, and lands in the CSV as two rows under two names. Downstream the duplicate rows are treated as two distinct models.

Evidence

Scanning models/mlx on M1 Ultra by sha256(config.json) plus total *.safetensors bytes finds 15 duplicate groups covering 16 redundant directories out of 199, holding 124.5 GB of redundant weights.

Group Copies Weights each
hunyuan-13b / hunyuan-a13b-instruct-4bit 2 45.22 GB
nemotron-h-30b-4bit / nemotron-nas-30b-4bit 2 17.78 GB
qwen3-30b-a3b-4bit / qwen3-moe-4bit 2 17.17 GB
qwen2.5-vl-3b-hf / qwen2.5-vl-3b-instruct 2 7.51 GB
pixtral-12b / pixtral-12b-4bit 2 7.14 GB
qwen3-vl-8b-4bit / qwen3-vl-8b-instruct-4bit 2 5.76 GB
llama-3.1-8b-4bit / meta-llama-3.1-8b-instruct-4bit 2 4.52 GB
qwen2.5-7b / qwen2.5-7b-4bit / qwen2.5-7b-instruct-4bit 3 4.28 GB
gemma-3-4b-it-4bit / gemma3-4b-4bit 2 3.40 GB
qwen3-vl-4b-4bit / qwen3-vl-4b-instruct-4bit 2 3.09 GB
qwen3-vl-2b / qwen3-vl-2b-4bit 2 1.78 GB
qwen2-vl-2b / qwen2-vl-2b-4bit 2 1.25 GB
gemma-3-1b-it-4bit / gemma3-1b-4bit 2 0.73 GB
qwen3-0.6b / qwen3-0.6b-4bit 2 0.34 GB
qwen2-0.5b / qwen2.5-0.5b-4bit 2 0.28 GB

Six groups were additionally spot-checked by shard count plus a sha256 over the first and last 1 MiB of every shard: all six match, and none share inodes, so these are independent full copies rather than hardlinks or APFS clones. Names are misleading in both directions: pixtral-12b and pixtral-12b-4bit are the same 4-bit checkpoint, and hunyuan-13b is the same file set as hunyuan-a13b-instruct-4bit.

The duplication is in every committed sweep, not just on this host. Counting group members that co-occur in each committed CSV:

Sweep Rows Redundant rows
metal_m1ultra_2026-07-12.csv 168 15
metal_m1ultra_2026-07-06.csv 169 14
metal_m1ultra_2026-06-15.csv 151 13
metal_m5max_2026-07-12.csv 176 14
metal_m5max_2026-06-15.csv 151 13
metal_m5max_2026-04-04.csv 95 2

The paired numbers agree to within run noise, which is itself confirmation that the two rows measure one checkpoint: qwen2.5-7b / qwen2.5-7b-4bit / qwen2.5-7b-instruct-4bit decode at 108.85 / 108.47 / 108.86 tok/s in metal_m1ultra_2026-07-12.csv, and nemotron-h-30b-4bit / nemotron-nas-30b-4bit at 91.75 / 90.37. The VLM sweeps carry the same redundancy at the same counts.

It reaches published documentation. Three duplicate groups appear as separate table rows in each of docs/benchmark_results/model_tests_m1ultra.md and docs/benchmark_results/model_tests_m5max.md (gemma-3-4b-it-4bit / gemma3-4b-4bit, nemotron-h-30b-4bit / nemotron-nas-30b-4bit, pixtral-12b / pixtral-12b-4bit, qwen3-30b-a3b-4bit / qwen3-moe-4bit depending on the file), so one checkpoint is presented as two supported models. The parity comparison tables in the same documents list only one member per group, which means the deduplication currently exists only as unrecorded manual curation between the CSV and the document. That is not reproducible and it silently drifts every time someone regenerates a table.

Current Behavior

if [[ "$MODEL_ARG" == "all" ]]; then
  # First pass: run all models except known GPU-crash models
  for dir in "$MODELS_DIR"/*/; do
    [[ -d "$dir" ]] || continue
    is_gpu_crash_model "$dir" && continue
    result=$(bench_one "$dir")

The two-pass split at :658 and :666 exists to defer GPU_CRASH_MODELS (:603) to the end; each directory is still visited exactly once per sweep. The defect is that two directories holding one checkpoint are two directories. bench_one (:414) keys everything on the directory basename, and emit (:595) appends whatever it returns, so the CSV inherits the directory naming with no identity notion at all.

Proposed Solution

Deduplicate by checkpoint identity in all mode, before the first pass, and keep single-model mode untouched so an explicit path always measures exactly what was named.

  • Compute a cheap identity key per candidate directory: sha256(config.json) plus the sorted list of (*.safetensors basename, size). This costs one small read plus a stat per shard, needs no weight hashing, and correctly separates checkpoints that differ only in quantization (their config.json differs in the quantization block).
  • Also collapse directories that resolve to the same path, so a symlink such as models/mlx/gemma-3-1b-it-4bit into the shared model store never doubles a row.
  • Measure one member per group and skip the rest, emitting one row per skipped alias with a SKIP:duplicate_of=<name> status so the CSV stays self-describing and a reader can see the alias set rather than silently wondering where a model went. Row count then stays stable across sweeps, which matters because docs/benchmark_results/model_tests.md indexes sweeps by measured row count. The survivor only has to be deterministic; as implemented it is the first directory the sweep's own glob reaches, which means the longer name wins for a prefix pair (pixtral-12b-4bit over pixtral-12b).
  • Print the collapsed groups to stderr at sweep start so the operator sees what was folded before a multi-hour run begins.
  • Add --no-dedup to restore today's behavior for anyone who deliberately wants to measure two copies, for example when checking whether a re-download is bit-identical.

Rejected: deleting the redundant directories. models/ is gitignored local state, the aliases are how various scripts and documents already refer to these checkpoints (scripts/bench_embeddings.py, the deferred-pairing tables), and a sweep must not be silently wrong on a host that happens to hold two copies. The harness has to be correct regardless of how the model store is arranged.

Scope

In scope: scripts/bench_decode.sh (all mode enumeration, the new dedup helper, --no-dedup, usage text at :285), and a note in docs/benchmarks.md describing the identity key and the SKIP:duplicate_of status.

Out of scope: deleting or renaming any directory under models/; regenerating past sweeps; correcting the duplicate rows already present in committed CSVs and in model_tests_m1ultra.md and model_tests_m5max.md, which is a documentation pass that should follow once sweeps stop producing them; scripts/bench_embeddings.py and scripts/bench_serving_concurrency.py, which take explicit model lists rather than enumerating a directory.

Acceptance Criteria

  • bench_decode.sh all measures each distinct checkpoint exactly once, with the alias names emitted as SKIP:duplicate_of=<name> rows so total row count is unchanged.
  • The identity key does not collapse checkpoints that differ only in quantization or in weight dtype; a test or a documented check demonstrates this on a real pair from models/mlx, for example bitnet-b1.58-2b-4t against bitnet-b1.58-2b-4t-4bit.
  • A directory that is a symlink to another measured directory is collapsed as well.
  • --no-dedup reproduces the current behavior exactly.
  • Single-model mode (bench_decode.sh models/<name>) is unaffected and still measures the named path.
  • The collapsed groups are printed to stderr before the first measurement.
  • docs/benchmarks.md documents the identity key and the new status string.

Verification

# Sanity-check the script and see the grouping the sweep would apply. The
# collapsed groups print to stderr before the first model is measured, so a
# sweep against a small temporary MODELS_DIR shows them without a long run.
bash -n scripts/bench_decode.sh

# Cheap end-to-end check: build a temporary MODELS_DIR of symlinks holding two
# small duplicate groups (gemma-3-1b-it-4bit / gemma3-1b-4bit and qwen2-0.5b /
# qwen2.5-0.5b-4bit) plus one non-duplicate control, then run both arms.
MODELS_DIR=<tmpdir> ./scripts/bench_decode.sh all --output /tmp/dedup.csv
MODELS_DIR=<tmpdir> ./scripts/bench_decode.sh all --no-dedup --output /tmp/nodedup.csv

A pass is: one measured row per distinct checkpoint, the alias rows present and labelled, the sweep's total row count equal to the directory count so the CSV index in docs/benchmark_results/model_tests.md stays meaningful, and --no-dedup measuring every directory. There is no --dry-run; an earlier draft of this body referenced one that was never implemented, and the stderr group report plus a temporary MODELS_DIR cover the same need.

Technical Considerations

Relevant to #1614, which compares per-checkpoint prefill across two sweeps: duplicate rows make a "checkpoint" ambiguous in exactly the comparison that issue performs. Filed alongside #1612, #1613, #1614 and #1616 from the same 2026-09-04 benchmark campaign.

Activity

  1. added
    type:choreMaintenance tasks (build, CI, etc.)
    area:benchmarkBenchmark harness and performance measurement (bench_*.sh, /update-benchmarks)
    and removed on Sep 4, 2026
  2. inureyes commented on Sep 4, 2026

    @inureyes
    MemberAuthor

    Implemented in PR #1617, commit 5e810adf8

    This landed inside the open benchmark-refresh PR rather than as its own PR, because that branch already rewrites the same bench_decode.sh emit path: it renames mlx_version to mlxcel_version and appends mlxcel_commit and mlx_commit, taking the row from 15 fields to 17. A separate PR would have emitted the alias row in the old shape and conflicted on every touched line. #1617's body now carries Closes #1615.

    What shipped. all mode computes a checkpoint identity key before the first model runs: sha256(config.json) plus the sorted (basename, byte size) list of every *.safetensors shard, with no weight hashing. Directories resolving to the same physical path collapse first, so a symlink into a shared model store never doubles a row; a directory with no config.json is never grouped by content and is always measured. The survivor is the first directory the sweep's own glob reaches, and every other member is emitted as its own row with the trailing status SKIP:duplicate_of=<survivor> and no cooldown, so total row count is unchanged. --no-dedup restores the previous behavior exactly and single-model mode is untouched. The new status token is documented in the script header, in --help, and in docs/benchmarks.md.

    Verification. The grouping helper was run against MODELS_DIR=models/mlx and independently re-run by the orchestrator: both found the same 15 groups and 16 redundant directories in the evidence table above, from 202 enumerated directories. The quantization-variant guard was confirmed on bitnet-b1.58-2b-4t against bitnet-b1.58-2b-4t-4bit, whose config.json hashes differ. A real all sweep was run against a temporary MODELS_DIR of symlinks covering two small duplicate groups plus one control: the dedup run produced two measured rows, two SKIP:duplicate_of= rows and the control, and --no-dedup on the same directory measured all five. bash -n and shellcheck are clean. No cargo command was run because nothing under src/ changed.

    One behavior worth knowing. The survivor is chosen by the glob's own sort over paths that carry a trailing slash, so where one name is a strict prefix of another the longer name wins: pixtral-12b-4bit survives over pixtral-12b, and qwen2.5-7b-4bit over both qwen2.5-7b and qwen2.5-7b-instruct-4bit. That is deterministic and stable, and it happens to keep the names the published tables already use (docs/benchmark_results/model_tests_m1ultra.md lists llama-3.1-8b-4bit and qwen2.5-7b-4bit), so no existing table row changes name.

    Not done here, deliberately. The duplicate rows already present in committed CSVs and the three duplicate groups appearing as separate table rows in model_tests_m1ultra.md and model_tests_m5max.md are untouched. That documentation pass should follow once sweeps stop producing duplicates, and it is now mechanical: the alias table maintained by hand in model_tests_m5max.md is derivable from a dedup-enabled sweep.

    No technical report was written under this issue's number. The work sits in #1617, which will carry one report for the whole sweep; the convention in TECHNICAL_REPORTS/ is one report per PR number and every existing number has exactly one.

  3. self-assigned this
    on Oct 20, 2026
  4. achimnol commented on Oct 20, 2026

    @achimnol
    Member

    Closing as completed. The implementation described in the comment above shipped in #1617 (merged 2026-09-08), but that PR's body did not register a closing reference, so this issue stayed open.

    Re-checked against main at 287a676:

    • scripts/bench_decode.sh computes the identity key from sha256(config.json) plus the sorted (basename, byte size) of every *.safetensors shard, and collapses directories that resolve to the same path via realpath first.
    • Skipped aliases are emitted as SKIP:duplicate_of=<survivor> rows, so the row count still equals the directory count, and the collapsed groups print to stderr before the first measurement.
    • --no-dedup is documented in --help and restores the previous behavior; single-model mode never dedups.
    • docs/benchmarks.md documents the identity key, the survivor rule and the new status string.
    • bash -n scripts/bench_decode.sh passes.

    The follow-up documentation pass for duplicate rows already in committed CSVs and in model_tests_m1ultra.md / model_tests_m5max.md remains out of scope, as the issue states.

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

Metadata

Metadata

Assignees

Labels

area:benchmarkBenchmark harness and performance measurement (bench_*.sh, /update-benchmarks)priority:lowLow prioritystatus:doneCompletedtype:choreMaintenance tasks (build, CI, etc.)

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions