Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5765c21730 | |||
| cfe456e5e1 | |||
| 8e45623588 | |||
| 29582c8711 | |||
| 604be9bedc | |||
| 04c4b6fbf6 | |||
| c3312e2198 | |||
| 9d3c1a5e0f | |||
| 8c14c8e6d3 | |||
| 5d91fb6bdd | |||
| c7724737cc | |||
| f640e23b10 | |||
| 9f3276980f | |||
| 18231431ac | |||
| 44a926a274 | |||
| 1c19ca6506 | |||
| a945057284 | |||
| cc05a0f324 | |||
| e7440b7646 | |||
| 3a0ad55f76 | |||
| 2431069d53 | |||
| ce134f7987 | |||
| 64a4e4fc37 | |||
| 290dc6ef2d |
@@ -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
|
||||
|
||||
@@ -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 | **~1–2 GB** |
|
||||
| Qwen3-30B / Mixtral (MoE) | 30–47B | **~1–3 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** |
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
}
|
||||
@@ -22,6 +22,7 @@ ARCH_OVERRIDES = {
|
||||
"BaiChuanForCausalLM": "AirLLMBaichuan",
|
||||
"InternLMForCausalLM": "AirLLMInternLM",
|
||||
"KimiK3ForConditionalGeneration": "AirLLMKimiK3",
|
||||
"Qwen3_5ForConditionalGeneration": "AirLLMQwen3_5",
|
||||
}
|
||||
|
||||
|
||||
|
||||
+5
-2
@@ -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",
|
||||
|
||||
@@ -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
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user