24 Commits

Author SHA1 Message Date
lyogavin 5765c21730 chore: refresh star history chart 2026-08-22 06:12:14 +00:00
lyogavin cfe456e5e1 chore: refresh star history chart 2026-08-21 06:47:58 +00:00
lyogavin 8e45623588 chore: refresh star history chart 2026-08-20 06:47:48 +00:00
lyogavin 29582c8711 chore: refresh star history chart 2026-08-19 06:45:38 +00:00
Gavin Li 604be9bedc Merge pull request #343 from lyogavin/feat/qwen3.8-27b-support
Support Qwen3.8-27B (3.2.0)
2026-08-18 18:11:34 -05:00
Yu Li 04c4b6fbf6 release: 3.2.0
Qwen3.8-27B support. Bump the package version so a GitHub Release tagged
3.2.0 can publish to PyPI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 18:10:55 -05:00
Yu Li c3312e2198 docs: record Qwen3.8-27B running in 3.33GB on an RTX 3090
Measured end to end: split in 2.5 min, init 164s at 0.87GB (vision
resident), greedy decode peak 3.33GB, output "Paris".

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 14:27:41 -05:00
Yu Li 9d3c1a5e0f feat: support Qwen3.8-27B (Qwen3.5 dense VL)
Qwen3.8-27B is Qwen3_5ForConditionalGeneration: decoder nested under
model.language_model, vision tower at model.visual, hybrid Gated DeltaNet
+ Gated Attention. AutoModelForCausalLM maps qwen3_5 onto a text-only class,
so empty-model construction now tries the image-text Auto factories first
and rejects a class whose module tree does not match layer_names_dict.

Also read dtype from nested text_config (this checkpoint has no top-level
torch_dtype) and drop the unused mtp.* head, matching transformers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 12:50:16 -05:00
lyogavin 8c14c8e6d3 chore: refresh star history chart 2026-08-18 06:45:32 +00:00
lyogavin 5d91fb6bdd chore: refresh star history chart 2026-08-17 06:58:16 +00:00
lyogavin c7724737cc chore: refresh star history chart 2026-08-16 06:41:20 +00:00
lyogavin f640e23b10 chore: refresh star history chart 2026-08-15 06:39:11 +00:00
lyogavin 9f3276980f chore: refresh star history chart 2026-08-14 07:27:01 +00:00
lyogavin 18231431ac chore: refresh star history chart 2026-08-13 07:29:18 +00:00
lyogavin 44a926a274 chore: refresh star history chart 2026-08-12 07:26:38 +00:00
lyogavin 1c19ca6506 chore: refresh star history chart 2026-08-11 07:09:58 +00:00
lyogavin a945057284 chore: refresh star history chart 2026-08-10 07:35:32 +00:00
lyogavin cc05a0f324 chore: refresh star history chart 2026-08-09 06:59:18 +00:00
lyogavin e7440b7646 chore: refresh star history chart 2026-08-08 06:54:19 +00:00
lyogavin 3a0ad55f76 chore: refresh star history chart 2026-08-06 08:40:02 +00:00
lyogavin 2431069d53 chore: refresh star history chart 2026-08-05 22:50:58 +00:00
Yu Li ce134f7987 fix(ci): build star history from GraphQL so it needs no PAT
REST /stargazers broke this job twice on token permissions: GitHub first
restricted it to collaborators, then refused fine-grained tokens on it
outright. GraphQL serves the same public starredAt data without that gate,
so the built-in Actions token is enough and the secret becomes unnecessary.

Its cursors encode a timestamp rather than an offset, so sampled pages can
no longer be fetched directly and the connection has to be walked. Both
themes now render from one walk to keep that cost paid once.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:48:39 -05:00
Yu Li 64a4e4fc37 release: 3.1.0
Kimi K3 support. Records K3's three requirements in the README, since none of
them are optional and hitting them at runtime is a confusing failure:
compressed-tensors, flash-attn (K3's model code mandates it, a CUDA 12 torch
build, and transformers 4.56.x.

compressed-tensors stays out of install_requires because only checkpoints in
that format need it and transformers already names it when it is missing.
EOF
)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 20:05:48 -05:00
Gavin Li 290dc6ef2d Merge pull request #316 from lyogavin/feat/kimi-k3-support
Support Kimi K3 (2.8T) — runs on a single card in 3.72GB
2026-07-28 20:02:35 -05:00
11 changed files with 430 additions and 67 deletions
+13 -14
View File
@@ -6,12 +6,14 @@ name: Refresh Star History
# star-history.com SVG API, which is rate-limited and returns an empty chart
# for large repos. The README embeds the committed PNG, so it always renders.
#
# Since 2026-06-30 GitHub restricts GET /repos/{owner}/{repo}/stargazers to
# admins/collaborators. The Actions GITHUB_TOKEN is an installation token and
# gets 403, so this workflow needs a collaborator PAT in STAR_HISTORY_TOKEN:
# fine-grained: Metadata (read) on this repo, or
# classic: public_repo
# gh secret set STAR_HISTORY_TOKEN
# The chart is built from GraphQL, not REST. GitHub restricted REST /stargazers to
# admins/collaborators in 2026-06, and then refused fine-grained tokens on it outright
# ("Resource not accessible by personal access token"), which broke this job twice on
# token permissions alone. GraphQL returns the same public starredAt data without that
# gate, so the default Actions GITHUB_TOKEN is enough and no PAT is required.
#
# The STAR_HISTORY_TOKEN secret this used to need is now unused and can be deleted; do
# not add a PAT back if this fails, since a token was never the real fix.
on:
schedule:
@@ -35,15 +37,12 @@ jobs:
- name: Generate star history charts (light + dark)
env:
# Collaborator/admin PAT; Actions GITHUB_TOKEN cannot list stargazers.
GITHUB_TOKEN: ${{ secrets.STAR_HISTORY_TOKEN }}
# The built-in token is enough for GraphQL, so no secret is involved.
GITHUB_TOKEN: ${{ github.token }}
run: |
if [ -z "$GITHUB_TOKEN" ]; then
echo "::error::Missing secret STAR_HISTORY_TOKEN. Create a collaborator PAT (fine-grained Metadata:read, or classic public_repo) and run: gh secret set STAR_HISTORY_TOKEN"
exit 1
fi
python scripts/gen_star_history.py "${{ github.repository }}" assets/star-history.png light
python scripts/gen_star_history.py "${{ github.repository }}" assets/star-history-dark.png dark
python scripts/gen_star_history.py "${{ github.repository }}" \
assets/star-history.png light \
assets/star-history-dark.png dark
- name: Commit if changed
uses: stefanzweifel/git-auto-commit-action@v5
+6 -2
View File
@@ -31,7 +31,9 @@
* [Bloome — build & run AI agent teams in the cloud, zero setup](https://bloome.im/app?ref=G6BYnov0&utm_medium=github&utm_source=lyogavin-airllm-ivor-202606)
## Updates
[2026/07] **Kimi K3 (2.8T)** support: the largest open-source model runs on a single card in **3.72GB** of VRAM, measured end to end on one RTX 6000 Ada. Per-expert streaming loads only the experts a token actually routes to. Note that K3 requires `flash-attn` — its own model code mandates it regardless of what you request — and therefore a CUDA 12 build of torch.
[2026/08] **Qwen3.8-27B** support: Qwen's new dense VL (Gated DeltaNet + Gated Attention, native vision) runs in **3.33GB** of VRAM, measured end to end on one RTX 3090. Needs `transformers` 5.8+.
[2026/07] **Kimi K3 (2.8T)** support: the largest open-source model runs on a single card in **3.72GB** of VRAM, measured end to end on one RTX 6000 Ada. Per-expert streaming loads only the experts a token actually routes to. K3 brings three requirements of its own: `pip install compressed-tensors flash-attn` (its model code mandates flash attention regardless of what you request), a CUDA 12 build of torch, since no prebuilt flash-attn wheel exists for CUDA 13 yet, and `transformers` 4.56.x, as its remote code does not load on 5.x.
[2026/06] **v3.0**: FP8 model support + the latest models. Run **DeepSeek-V3 (671B) on ~12GB** and **Qwen3-235B on ~3GB**, plus Qwen3, Llama 3.x/4, DeepSeek V2/V3, Phi-4, Gemma and more — all through a single `AutoModel`.
@@ -103,6 +105,7 @@ MAX_LENGTH = 128
model = AutoModel.from_pretrained("Qwen/Qwen3-32B")
# go bigger with the exact same one line:
#model = AutoModel.from_pretrained("Qwen/Qwen3.8-27B") # 27B dense VL, 3.33GB
#model = AutoModel.from_pretrained("Qwen/Qwen3-235B-A22B") # 235B, runs in ~3GB
#model = AutoModel.from_pretrained("deepseek-ai/DeepSeek-V3") # 671B, runs in ~12GB
@@ -272,7 +275,7 @@ model.tokenizer.decode(generation_output.sequences[0])
AirLLM works out of the box with **virtually every popular open LLM** — just pass its Hugging Face ID to `AutoModel.from_pretrained(...)`. That covers all the major families:
**Llama** (2 / 3 / 3.1 / 3.3 / 4) · **Qwen** (1 / 2 / 2.5 / 3, including MoE and FP8) · **DeepSeek** (V2 / V3 / R1) · **Mistral & Mixtral** · **Phi** · **Gemma** · **ChatGLM** · **Baichuan** · **InternLM** · **Yi** — and most new models the day they're released.
**Llama** (2 / 3 / 3.1 / 3.3 / 4) · **Qwen** (1 / 2 / 2.5 / 3 / 3.5 / 3.8, including MoE, FP8, and native VL) · **DeepSeek** (V2 / V3 / R1) · **Mistral & Mixtral** · **Phi** · **Gemma** · **ChatGLM** · **Baichuan** · **InternLM** · **Yi** · **Kimi K3** — and most new models the day they're released.
### Tiny GPU, huge models
@@ -282,6 +285,7 @@ The trick: AirLLM only ever keeps **one layer on the GPU at a time**, so the VRA
|---|---|---|
| Qwen3 / Mistral / Phi (≈8B) | 8B | **~12 GB** |
| Qwen3-30B / Mixtral (MoE) | 3047B | **~13 GB** |
| Qwen3.8-27B (dense VL) | 27B | **3.33 GB** |
| Qwen3-235B (MoE) | 235B | **~3 GB** |
| Llama 3.x 70B (full precision) | 70B | **~4 GB** |
| Llama 3.1 405B | 405B | **~8 GB** |
+1
View File
@@ -32,6 +32,7 @@ else:
("AirLLMMistral", ".airllm_mistral"),
("AirLLMMixtral", ".airllm_mixtral"),
("AirLLMKimiK3", ".airllm_kimi_k3"),
("AirLLMQwen3_5", ".airllm_qwen3_5"),
):
try:
_mod = __import__(__name__ + _module, fromlist=[_name])
+93 -13
View File
@@ -6,7 +6,8 @@ import time
from concurrent.futures import ThreadPoolExecutor
import torch
from transformers import AutoConfig, AutoModelForCausalLM, AutoTokenizer, GenerationConfig
import transformers
from transformers import AutoConfig, AutoTokenizer, GenerationConfig
from transformers.generation import GenerationMixin
from accelerate import init_empty_weights
from accelerate.utils.modeling import set_module_tensor_to_device
@@ -158,6 +159,11 @@ class AirLLMBaseModel:
# avoids it. Users can still override via dtype=.
if dtype is None:
cfg_dtype = getattr(self.config, "torch_dtype", None)
if cfg_dtype is None:
cfg_dtype = getattr(self.config, "dtype", None)
if cfg_dtype is None:
text_cfg = getattr(self.config, "text_config", None)
cfg_dtype = getattr(text_cfg, "torch_dtype", None) or getattr(text_cfg, "dtype", None)
if isinstance(cfg_dtype, str):
cfg_dtype = getattr(torch, cfg_dtype, None)
dtype = cfg_dtype if isinstance(cfg_dtype, torch.dtype) else torch.float16
@@ -230,25 +236,99 @@ class AirLLMBaseModel:
walk(self.config)
def _model_matches_layer_names(self, model):
"""True when the instantiated class actually has the modules we plan to stream.
Auto factories key off ``model_type``, so a VL checkpoint can land on a text-only or
backbone class whose tree doesn't match ``layer_names_dict``. Reject those and keep trying.
"""
try:
for key in ('embed', 'layer_prefix', 'norm', 'lm_head'):
mod = model
for attr in self.layer_names_dict[key].split('.'):
mod = getattr(mod, attr)
return True
except AttributeError:
return False
def _auto_model_classes(self):
"""Auto* factories to try, in order, when building the empty model.
``AutoModelForCausalLM`` maps some VL model_types (notably ``qwen3_5``) onto a text-only
``*ForCausalLM`` class. That class expects a text config and either crashes or builds a
model whose module names don't match the checkpoint. Conditional-generation architectures
therefore try the image-text factories first.
"""
archs = getattr(self.config, "architectures", None) or []
arch = archs[0] if archs else ""
names = []
if any(tag in arch for tag in ("ConditionalGeneration", "ImageTextToText", "Multimodal")):
names.extend(["AutoModelForImageTextToText", "AutoModelForMultimodalLM"])
names.extend([
"AutoModelForCausalLM",
"AutoModelForImageTextToText",
"AutoModelForMultimodalLM",
"AutoModel",
])
seen = set()
classes = []
for name in names:
if name in seen:
continue
seen.add(name)
cls = getattr(transformers, name, None)
if cls is not None:
classes.append((name, cls))
return classes
def _instantiate_on_meta(self, attn_implementation):
"""Build the transformers model on the meta device for one attention implementation."""
self._propagate_attn_implementation(attn_implementation)
kwargs = {
"attn_implementation": attn_implementation,
"trust_remote_code": self.trust_remote_code,
}
errors = []
for name, cls in self._auto_model_classes():
try:
with init_empty_weights(include_buffers=False):
model = cls.from_config(self.config, **kwargs)
except TypeError:
# Older Auto factories don't take attn_implementation.
try:
with init_empty_weights(include_buffers=False):
model = cls.from_config(self.config, trust_remote_code=self.trust_remote_code)
except Exception as e: # noqa: BLE001 - try the next factory
errors.append(f"{name}: {type(e).__name__}: {e}")
continue
except Exception as e: # noqa: BLE001 - try the next factory
errors.append(f"{name}: {type(e).__name__}: {e}")
continue
if not self._model_matches_layer_names(model):
errors.append(
f"{name}: built {type(model).__name__} but it is missing "
f"{self.layer_names_dict['layer_prefix']}"
)
continue
print(f"built empty {type(model).__name__} via {name} (attn={attn_implementation})")
return model
raise RuntimeError(
"Could not instantiate the model on meta from config. "
f"architecture={getattr(self.config, 'architectures', None)} "
f"model_type={getattr(self.config, 'model_type', None)}. "
f"Tried: {'; '.join(errors)}"
)
def init_model(self):
# Build the real model on meta (no memory). include_buffers=False so non-persistent
# buffers such as rotary inv_freq are actually computed (they aren't in the checkpoint).
self.model = None
try:
self._propagate_attn_implementation("sdpa")
with init_empty_weights(include_buffers=False):
self.model = AutoModelForCausalLM.from_config(
self.config, attn_implementation="sdpa", trust_remote_code=self.trust_remote_code)
except (ValueError, TypeError) as e:
self.model = self._instantiate_on_meta("sdpa")
except Exception as e:
print(f"attn_implementation='sdpa' not available ({e}), falling back to eager attention")
self.model = None
if self.model is None:
# Some (often remote-code) architectures don't support sdpa and also default to it, so we
# must request eager explicitly; otherwise transformers re-selects sdpa and errors again.
self._propagate_attn_implementation("eager")
with init_empty_weights(include_buffers=False):
self.model = AutoModelForCausalLM.from_config(
self.config, attn_implementation="eager", trust_remote_code=self.trust_remote_code)
self.model = self._instantiate_on_meta("eager")
quantization_config = getattr(self.config, "quantization_config", None)
if quantization_config is None:
+30
View File
@@ -0,0 +1,30 @@
from .airllm_base import AirLLMBaseModel
class AirLLMQwen3_5(AirLLMBaseModel):
"""Qwen3.5 / Qwen3.8 dense VL (``Qwen3_5ForConditionalGeneration``).
Qwen3.8-27B is this architecture: a native vision-language wrapper whose decoder lives under
``model.language_model``, with a SigLIP-style tower at ``model.visual``. The language stack is
a hybrid of Gated DeltaNet (linear attention) and Gated Attention, 64 layers, bf16, ~27B.
Transformers ignores the checkpoint's ``mtp.*`` Multi-Token Prediction head
(``_keys_to_ignore_on_load_unexpected``), so we do not stream or load it. The vision tower is
kept resident: text-only ``generate()`` never runs it, but leaving it on meta would crash the
moment a caller passes ``pixel_values``.
Needs transformers 5.8+ (the class is in-tree; this repo ships no remote modeling code).
Optional CUDA kernels ``fla`` / ``causal-conv1d`` speed up DeltaNet; transformers falls back
to a PyTorch implementation when they are missing.
"""
def set_layer_names_dict(self):
self.layer_names_dict = {
'embed': 'model.language_model.embed_tokens',
'layer_prefix': 'model.language_model.layers',
'norm': 'model.language_model.norm',
'lm_head': 'lm_head',
'resident': [
'model.visual',
],
}
+1
View File
@@ -22,6 +22,7 @@ ARCH_OVERRIDES = {
"BaiChuanForCausalLM": "AirLLMBaichuan",
"InternLMForCausalLM": "AirLLMInternLM",
"KimiK3ForConditionalGeneration": "AirLLMKimiK3",
"Qwen3_5ForConditionalGeneration": "AirLLMQwen3_5",
}
+5 -2
View File
@@ -15,11 +15,12 @@ for _readme in (os.path.join(here, "README.md"), os.path.join(here, os.pardir, "
setuptools.setup(
name="airllm",
version="3.0.1",
version="3.2.0",
author="Gavin Li",
author_email="gavinli@animaai.cloud",
description="AirLLM runs 70B large language models on a single 4GB GPU without quantization, "
"distillation or pruning. 405B Llama 3.1 on 8GB, DeepSeek-V3 671B on ~12GB.",
"distillation or pruning. 405B Llama 3.1 on 8GB, DeepSeek-V3 671B on ~12GB, "
"Kimi K3 2.8T on under 4GB, Qwen3.8-27B on 3.3GB.",
long_description=long_description,
long_description_content_type="text/markdown",
url="https://github.com/lyogavin/airllm",
@@ -36,6 +37,8 @@ setuptools.setup(
'scipy',
'sentencepiece',
# 'bitsandbytes' is optional (used only for --compression); we fall back gracefully when absent.
# 'compressed-tensors' is optional too: only checkpoints stored in that format (Kimi K3's
# MXFP4 weights) need it, and transformers raises a clear error naming it when it is missing.
],
classifiers=[
"Programming Language :: Python :: 3",
+166
View File
@@ -0,0 +1,166 @@
"""Structural tests for Qwen3.5 / Qwen3.8 VL checkpoints.
Qwen3.8-27B is ``Qwen3_5ForConditionalGeneration``: the decoder is nested under
``model.language_model``, the vision tower is ``model.visual``, and the checkpoint also carries an
``mtp.*`` Multi-Token Prediction head that transformers ignores. These tests build a miniature
checkpoint with that shape (several layers per shard, like the real 18-file dump) and check that
the splitter writes every streamed module, keeps the vision tower, and drops MTP.
"""
import json
import os
import sys
import tempfile
import types
import unittest
from pathlib import Path
import torch
from safetensors.torch import save_file, load_file
_AIRLLM_DIR = Path(__file__).resolve().parents[1] / 'airllm'
if 'airllm' not in sys.modules:
_pkg = types.ModuleType('airllm')
_pkg.__path__ = [str(_AIRLLM_DIR)]
sys.modules['airllm'] = _pkg
from airllm.persist import model_persister as _persister_mod
from airllm.persist.safetensor_model_persister import SafetensorModelPersister
_persister_mod.model_persister = SafetensorModelPersister()
from airllm.utils import split_and_save_layers
N_LAYERS = 4
LAYER_PREFIX = "model.language_model.layers"
QWEN38_LAYER_NAMES = {
'embed': 'model.language_model.embed_tokens',
'layer_prefix': LAYER_PREFIX,
'norm': 'model.language_model.norm',
'lm_head': 'lm_head',
'resident': [
'model.visual',
],
}
def build_fake_checkpoint(root):
"""Several decoder layers per shard, plus a vision tower mixed into shard 0 and MTP in the last."""
shards = {}
shard0 = {
"model.visual.patch_embed.proj.weight": torch.randn(8, 3, 2, 2),
"model.visual.blocks.0.attn.qkv.weight": torch.randn(8, 8),
"model.visual.merger.linear_fc1.weight": torch.randn(8, 8),
f"{LAYER_PREFIX}.0.input_layernorm.weight": torch.randn(8),
f"{LAYER_PREFIX}.0.linear_attn.A_log": torch.randn(4),
f"{LAYER_PREFIX}.0.linear_attn.in_proj_a.weight": torch.randn(4, 8),
f"{LAYER_PREFIX}.0.mlp.gate_proj.weight": torch.randn(8, 8),
f"{LAYER_PREFIX}.1.input_layernorm.weight": torch.randn(8),
f"{LAYER_PREFIX}.1.self_attn.q_proj.weight": torch.randn(8, 8),
f"{LAYER_PREFIX}.1.mlp.down_proj.weight": torch.randn(8, 8),
}
shards["model-00001-of-00003.safetensors"] = shard0
shard1 = {
"model.language_model.embed_tokens.weight": torch.randn(16, 8),
f"{LAYER_PREFIX}.2.input_layernorm.weight": torch.randn(8),
f"{LAYER_PREFIX}.2.linear_attn.out_proj.weight": torch.randn(8, 8),
f"{LAYER_PREFIX}.3.input_layernorm.weight": torch.randn(8),
f"{LAYER_PREFIX}.3.self_attn.o_proj.weight": torch.randn(8, 8),
"model.language_model.norm.weight": torch.randn(8),
}
shards["model-00002-of-00003.safetensors"] = shard1
shard2 = {
"lm_head.weight": torch.randn(16, 8),
"mtp.fc.weight": torch.randn(8, 8),
"mtp.layers.0.mlp.gate_proj.weight": torch.randn(8, 8),
"mtp.norm.weight": torch.randn(8),
}
shards["model-00003-of-00003.safetensors"] = shard2
weight_map = {}
for fname, sd in shards.items():
save_file(sd, str(Path(root) / fname))
for k in sd:
weight_map[k] = fname
with open(Path(root) / "model.safetensors.index.json", "w") as f:
json.dump({"metadata": {"total_size": 1}, "weight_map": weight_map}, f)
return shards, weight_map
class TestQwen38Split(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name)
self.shards, self.weight_map = build_fake_checkpoint(self.root)
self.out = Path(split_and_save_layers(self.root, layer_names=QWEN38_LAYER_NAMES))
def tearDown(self):
self.tmp.cleanup()
def _split_file(self, module):
return self.out / f"{module}.safetensors"
def test_architecture_maps_to_the_qwen3_5_subclass(self):
src = (_AIRLLM_DIR / 'auto_model.py').read_text()
self.assertIn('"Qwen3_5ForConditionalGeneration": "AirLLMQwen3_5"', src)
init_src = (_AIRLLM_DIR / '__init__.py').read_text()
self.assertIn('AirLLMQwen3_5', init_src)
def test_every_streamed_and_resident_module_is_split_out(self):
expected = ([QWEN38_LAYER_NAMES['embed']]
+ [f"{LAYER_PREFIX}.{i}" for i in range(N_LAYERS)]
+ [QWEN38_LAYER_NAMES['norm'], QWEN38_LAYER_NAMES['lm_head']]
+ QWEN38_LAYER_NAMES['resident'])
for module in expected:
self.assertTrue(self._split_file(module).exists(), f"missing split for {module}")
def test_vision_tower_is_one_shard_and_excludes_decoder_weights(self):
sd = load_file(str(self._split_file("model.visual")))
self.assertTrue(sd, "vision split is empty")
self.assertTrue(all(k.startswith("model.visual.") for k in sd),
f"vision split leaked decoder tensors: {set(sd)}")
self.assertIn("model.visual.patch_embed.proj.weight", sd)
self.assertIn("model.visual.blocks.0.attn.qkv.weight", sd)
def test_mtp_is_dropped(self):
"""transformers ignores mtp.*; keeping it would waste a resident slot for a dead module."""
recovered = {}
for f in self.out.glob('*.safetensors'):
recovered.update(load_file(str(f)))
self.assertFalse(any(k.startswith("mtp.") for k in recovered),
f"MTP leaked into the split: {[k for k in recovered if k.startswith('mtp.')]}")
self.assertFalse(any(p.name.startswith("mtp") for p in self.out.glob('*.safetensors')))
def test_decoder_and_head_round_trip(self):
original = {}
for fname in self.shards:
original.update(load_file(str(self.root / fname)))
keep = {k: v for k, v in original.items() if not k.startswith("mtp.")}
recovered = {}
for f in self.out.glob('*.safetensors'):
recovered.update(load_file(str(f)))
self.assertEqual(set(keep.keys()), set(recovered.keys()))
for k, v in keep.items():
self.assertEqual(v.dtype, recovered[k].dtype, f"{k} changed dtype")
self.assertTrue(torch.equal(v, recovered[k]), f"{k} changed value")
def test_shared_shards_are_copied_not_linked(self):
"""The real 27B dump packs several layers per file, so passthrough must not fire."""
src_inodes = {os.stat(self.root / f).st_ino
for f in os.listdir(self.root) if f.endswith('.safetensors')}
for module in ([f"{LAYER_PREFIX}.{i}" for i in range(N_LAYERS)]
+ ['model.language_model.embed_tokens', 'model.visual', 'lm_head']):
dst = self._split_file(module)
self.assertNotIn(os.stat(dst).st_ino, src_inodes,
f"{module} was linked, but this layout should be copied")
if __name__ == '__main__':
unittest.main(verbosity=2)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 63 KiB

+115 -36
View File
@@ -7,21 +7,32 @@ so it works reliably for repos with tens of thousands of stars. Output is a PNG,
always renders in GitHub markdown.
Usage:
GITHUB_TOKEN=... python scripts/gen_star_history.py [owner/repo] [output.png] [theme]
GITHUB_TOKEN=... python scripts/gen_star_history.py [owner/repo] [output.png theme]...
theme is "light" (default) or "dark".
theme is "light" (default) or "dark". Pass several output/theme pairs to render them all
from a single pass over the API.
"""
import datetime
import json
import math
import os
import sys
import urllib.error
import urllib.request
GRAPHQL_URL = "https://api.github.com/graphql"
REPO = sys.argv[1] if len(sys.argv) > 1 else os.environ.get("REPO", "lyogavin/airllm")
OUT = sys.argv[2] if len(sys.argv) > 2 else "assets/star-history.png"
THEME = (sys.argv[3] if len(sys.argv) > 3 else os.environ.get("THEME", "light")).lower()
# Remaining args are output/theme pairs. Walking the stargazer connection is by far the
# slowest part of this script, so rendering every theme from one walk beats invoking the
# script once per theme.
_rest = sys.argv[2:]
if _rest:
TARGETS = [(_rest[i], _rest[i + 1].lower() if i + 1 < len(_rest) else "light")
for i in range(0, len(_rest), 2)]
else:
TARGETS = [("assets/star-history.png", os.environ.get("THEME", "light").lower())]
TOKEN = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
PER_PAGE = 100
MAX_SAMPLES = 30
@@ -47,48 +58,115 @@ def gh(url, accept="application/vnd.github+json"):
except json.JSONDecodeError:
msg = body
hint = ""
if e.code == 403 and "stargazers" in url:
if e.code == 401:
hint = (
"\nGitHub restricts /stargazers to admins/collaborators; the "
"Actions GITHUB_TOKEN cannot access it. Use a collaborator PAT "
"via the STAR_HISTORY_TOKEN secret."
"\nThe token was rejected outright, which means it is expired or malformed "
"rather than under-permissioned. Issue a new one and re-run: "
"gh secret set STAR_HISTORY_TOKEN"
)
elif e.code == 403 and "personal access token" in msg.lower():
# A fine-grained PAT reaching an endpoint its permissions do not cover. This is
# distinct from a missing token, and reads identically in logs unless called out:
# fine-grained grants have never been enough for /stargazers.
hint = (
"\nA fine-grained PAT cannot read /stargazers. Use a *classic* token with the "
"public_repo scope (https://github.com/settings/tokens) and re-run: "
"gh secret set STAR_HISTORY_TOKEN"
)
elif e.code == 403 and "stargazers" in url:
hint = (
"\nGitHub restricts /stargazers to admins/collaborators. The token in "
"STAR_HISTORY_TOKEN must belong to one, and must be a classic token with "
"the public_repo scope."
)
raise SystemExit(f"GitHub API {e.code} for {url}: {msg}{hint}") from e
def gql(query, variables):
payload = json.dumps({"query": query, "variables": variables}).encode()
headers = {
"Accept": "application/vnd.github+json",
"Content-Type": "application/json",
"User-Agent": "airllm-star-history",
}
if TOKEN:
headers["Authorization"] = f"Bearer {TOKEN}"
req = urllib.request.Request(GRAPHQL_URL, data=payload, headers=headers)
try:
with urllib.request.urlopen(req, timeout=30) as r:
body = json.load(r)
except urllib.error.HTTPError as e:
detail = e.read().decode("utf-8", errors="replace")
extra = "" if TOKEN else "\nNo token found in GITHUB_TOKEN or GH_TOKEN."
raise SystemExit(f"GitHub GraphQL {e.code}: {detail}{extra}") from e
if body.get("errors"):
raise SystemExit(f"GitHub GraphQL error: {json.dumps(body['errors'])}")
return body["data"]
STARGAZER_QUERY = """
query($owner:String!, $name:String!, $after:String) {
repository(owner:$owner, name:$name) {
stargazerCount
stargazers(first:%d, after:$after, orderBy:{field:STARRED_AT, direction:ASC}) {
pageInfo { hasNextPage endCursor }
edges { starredAt }
}
}
}
""" % PER_PAGE
def fetch_starred_at():
"""Every stargazer timestamp, oldest first, plus the repo's current star count.
Uses GraphQL rather than REST. GitHub restricted REST /stargazers to admins and
collaborators in 2026, and then tightened it again so that fine-grained tokens are
refused outright -- the same chart broke twice on token permissions. GraphQL serves
the identical public data without that gate.
Its cursors encode a timestamp and user id rather than an offset, so unlike REST we
cannot jump to sampled pages and have to walk the whole connection. That is a few
hundred requests for a repo this size, which is fine for a once-a-day job.
"""
owner, _, name = REPO.partition("/")
stamps, cursor, total = [], None, 0
while True:
data = gql(STARGAZER_QUERY, {"owner": owner, "name": name, "after": cursor})
repo = data.get("repository")
if repo is None:
raise SystemExit(f"repo {REPO} not found, or the token cannot see it")
total = int(repo["stargazerCount"])
conn = repo["stargazers"]
stamps.extend(e["starredAt"] for e in conn["edges"])
if not conn["pageInfo"]["hasNextPage"]:
return stamps, total
cursor = conn["pageInfo"]["endCursor"]
def main():
info = gh(f"https://api.github.com/repos/{REPO}")
total = int(info["stargazers_count"])
if total <= 0:
stamps, total = fetch_starred_at()
if total <= 0 or not stamps:
raise SystemExit("repo has no stars")
max_page = max(1, math.ceil(total / PER_PAGE))
if max_page == 1:
pages = [1]
else:
n = min(MAX_SAMPLES, max_page)
pages = sorted({1, max_page} | {
round(1 + i * (max_page - 1) / (n - 1)) for i in range(n)
})
# Thin the full history down to a readable number of vertices. Walking every
# stargazer would plot tens of thousands of points on top of each other.
step = max(1, len(stamps) // MAX_SAMPLES)
points = []
for p in pages:
data = gh(
f"https://api.github.com/repos/{REPO}/stargazers?per_page={PER_PAGE}&page={p}",
accept="application/vnd.github.star+json",
)
if not data:
continue
starred_at = data[0]["starred_at"]
cumulative = (p - 1) * PER_PAGE + 1
dt = datetime.datetime.fromisoformat(starred_at.replace("Z", "+00:00"))
points.append((dt, cumulative))
for i in range(0, len(stamps), step):
dt = datetime.datetime.fromisoformat(stamps[i].replace("Z", "+00:00"))
points.append((dt, i + 1))
points.append((datetime.datetime.now(datetime.timezone.utc), total))
points = sorted(set(points))
if len(points) < 2:
raise SystemExit("not enough data points to plot")
for out, theme in TARGETS:
render(points, total, out, theme)
def render(points, total, out, theme):
import matplotlib
matplotlib.use("Agg")
import matplotlib.dates as mdates
@@ -97,7 +175,7 @@ def main():
xs = [d for d, _ in points]
ys = [c for _, c in points]
c = THEMES.get(THEME, THEMES["light"])
c = THEMES.get(theme, THEMES["light"])
fig, ax = plt.subplots(figsize=(10, 6))
fig.patch.set_facecolor(c["bg"])
ax.set_facecolor(c["bg"])
@@ -115,9 +193,10 @@ def main():
fig.autofmt_xdate()
fig.tight_layout()
os.makedirs(os.path.dirname(OUT) or ".", exist_ok=True)
fig.savefig(OUT, dpi=130, facecolor=c["bg"])
print(f"wrote {OUT} ({THEME}): {len(points)} points, {total} stars")
os.makedirs(os.path.dirname(out) or ".", exist_ok=True)
fig.savefig(out, dpi=130, facecolor=c["bg"])
plt.close(fig)
print(f"wrote {out} ({theme}): {len(points)} points, {total} stars")
if __name__ == "__main__":