Skip to content

feat(tools): add runntime quantize, a uv Python CLI that writes int8/int4 checkpoints in format v1 - #93

Merged
chmjkb merged 11 commits into
mainfrom
@chmjkb/python-quantizer
Nov 6, 2026
Merged

chmjkb merged 11 commits into
mainfrom
@chmjkb/python-quantizer

Conversation

@chmjkb

@chmjkb chmjkb commented Oct 30, 2026 •

Copy link
Copy Markdown
Collaborator

Description

Phase 3 of #70: runntime quantize, a small Python CLI that writes int8/int4 checkpoints in format v1, the format the loader from #80–#82 reads.

cd tools/python
uv run runntime quantize model.safetensors model.int8.safetensors --bits 8 --f16
  • Project: a uv package in tools/python (Python 3.11+, numpy only). pyright strict on src, ruff with a broad rule set, pytest.
  • Input: one .safetensors file or a Hugging Face model folder, sharded or not. Output is one .safetensors file. Untouched tensors are copied as stored (BF16 included) and the header metadata is kept, with runntime.quant added.
  • Selection: 2D *.weight tensors; embeddings (*embed*), position embeddings (*pos_emb*) and lm_head stay as stored. --include / --exclude globs replace those defaults. A tensor is skipped when it would not load: in features a multiple of 8 and of the group size, out features a multiple of 4 (int8) or 8 (int4), the same rule as Linear.loadQuantized. Tensors that are not 2D (convs, experts) are skipped.
  • Options: --group-size defaults to 32, which divides the 288 and 416 inputs of Moonshine Tiny and Base. --f16 stores the tensors that stay float as F16, and fails on a value beyond the f16 range. --drop leaves matching tensors out of the output, like MiniLM's unused pooler.
  • Algorithm: per group, scale = absmax / (2^(bits-1) - 1) rounded to f16, int = round(w / scale) (half to even) clamped to the symmetric range. int4 packs the even index in the low nibble.
  • Report: one line per tensor, the max and mean absolute error for quantized ones and the one rule a skipped tensor breaks.
  • Golden files: tools/python/tests/golden holds a tiny float checkpoint and its int4 and int8 versions. The Python tests check the CLI reproduces them byte for byte and that the official safetensors library reads them; a new test in safetensors.test.ts loads the same files through fromSafetensors and checks every value is within half a step of the float source.

Introduces a breaking change?

  • Yes
  • No

Type of change

  • Bug fix (change which fixes an issue)
  • New feature (change which adds functionality)
  • Documentation update (improves or adds clarity to existing documentation)
  • Performance (kernel tuning, graph building, anything related to performance)
  • Other (chores, tests, code style improvements etc.)

Testing instructions

  1. cd tools/python && uv sync && uv run pytest (29 pass)
  2. uv run ruff check && uv run ruff format --check && uv run pyright
  3. pnpm typecheck && pnpm lint && pnpm --filter runntime exec vitest run --project unit test/core/weights/safetensors.test.ts (103 pass, 2 new)

Related issues

Part of #70. Stacked on #82.

Checklist

  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have updated the documentation accordingly
  • My changes generate no new warnings

Additional notes

  • Checked on the real Moonshine Base f32 checkpoint: --bits 8 --f16 writes 81.5 MB, against 123 MB for the f16 file.
  • The tool reads and writes safetensors itself (about 40 lines) instead of depending on the safetensors package, whose numpy API cannot read or write BF16. The package is a dev dependency, used to check the output.
  • No CI job runs the Python checks yet. Proposed job, to add in this PR once agreed:
  python-tools:
    if: github.repository == 'software-mansion/runntime'
    runs-on: ubuntu-latest
    permissions:
      contents: read
    defaults:
      run:
        working-directory: tools/python
    steps:
      - uses: actions/checkout@v6
      - uses: astral-sh/setup-uv@v7
      - run: uv sync --locked
      - run: uv run ruff check
      - run: uv run ruff format --check
      - run: uv run pyright
      - run: uv run pytest

@chmjkb
chmjkb force-pushed the @chmjkb/quant-minilm-e2e branch from f510084 to 9513176 Compare October 30, 2026 11:26
@chmjkb
chmjkb force-pushed the @chmjkb/python-quantizer branch from 4255299 to ce5baab Compare October 30, 2026 11:26
@chmjkb
chmjkb force-pushed the @chmjkb/quant-minilm-e2e branch from 9513176 to 83bbd9e Compare October 30, 2026 12:47
@chmjkb
chmjkb force-pushed the @chmjkb/python-quantizer branch from ce5baab to 38b4f11 Compare October 30, 2026 12:47
@chmjkb
chmjkb force-pushed the @chmjkb/quant-minilm-e2e branch from 83bbd9e to 8306f44 Compare October 30, 2026 13:26
@chmjkb
chmjkb force-pushed the @chmjkb/python-quantizer branch from 38b4f11 to 2bf87e9 Compare October 30, 2026 13:26
@chmjkb
chmjkb force-pushed the @chmjkb/quant-minilm-e2e branch from 8306f44 to d7432dd Compare November 2, 2026 09:11
@chmjkb
chmjkb force-pushed the @chmjkb/python-quantizer branch from 2bf87e9 to b415564 Compare November 2, 2026 09:11
@chmjkb
chmjkb force-pushed the @chmjkb/quant-minilm-e2e branch from d7432dd to 8d1853d Compare November 2, 2026 17:32
@chmjkb
chmjkb force-pushed the @chmjkb/python-quantizer branch 2 times, most recently from 73334aa to 5e92e44 Compare November 4, 2026 09:32
@chmjkb
chmjkb force-pushed the @chmjkb/quant-minilm-e2e branch from 8d1853d to 506da9c Compare November 4, 2026 09:32
@chmjkb
chmjkb force-pushed the @chmjkb/python-quantizer branch from 5e92e44 to 3c4e31a Compare November 4, 2026 10:16
@chmjkb
chmjkb marked this pull request as ready for review November 4, 2026 15:05
Base automatically changed from @chmjkb/quant-minilm-e2e to main November 5, 2026 09:06
raise ValueError("no tensor was quantized:\n" + "\n".join(report))
setting = {"version": FORMAT_VERSION, "bits": options.bits, "groupSize": options.group_size, "scheme": "symmetric"}
metadata = checkpoint.metadata | {QUANT_METADATA_KEY: json.dumps(setting, separators=(",", ":"))}
write_safetensors(destination, tensors, metadata)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the output path is the same as the input, the input gets wiped. Runntime quantize m.safetensors m.safetensors --bits 8 crashes and leaves an empty file. Maybe write to a temp file first and rename it at the end?

continue
reason = skip_reason(name, tensor, options)
if reason:
tensors[name] = to_float16(name, tensor) if options.float16 else tensor

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Running the tool on an already quantized file gives a file runntime can't load: --bits 4 on an int8 file exits 0, but the old int8 weights stay and the header says int4. I guess refuse a source that's already quantized?

Comment thread tools/python/README.md
- It prints the max and mean absolute error of every quantized tensor and
why each other tensor was skipped. Exclude the layers that lose too much.

## Development

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't we want to test this in CI too, on every change to tools/python?

@chmjkb
chmjkb force-pushed the @chmjkb/python-quantizer branch from d284954 to 0aaf88d Compare November 5, 2026 11:42
@chmjkb
chmjkb requested a review from xxpopielxx November 6, 2026 10:12
@chmjkb
chmjkb merged commit f6db0ea into main Nov 6, 2026
3 checks passed
@chmjkb
chmjkb deleted the @chmjkb/python-quantizer branch November 6, 2026 15:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants