Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ cactus run Cactus-Compute/needle [--tools my_tools.json] # OpenAI function-call
│ │
│ cactus convert <model> [dir] HuggingFace -> Cactus CQ weights │
│ --bits 1|2|3|4 CQ quantization (default: 4) │
│ --calibration-manifest <path> representative data for GPTQ calibration │
│ --token <token> HuggingFace token (gated models) │
│ --reconvert force weight conversion from source │
│ --lora <path> merge a LoRA adapter before converting │
Expand Down
22 changes: 22 additions & 0 deletions docs/cactus_quants.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,28 @@ cactus convert google/gemma-4-E2B-it ./gemma4-weights --bits 2
cactus run google/gemma-4-E2B-it
```

For a finetuned checkpoint, provide representative data so GPTQ-aware CQ tensors
are calibrated against the checkpoint's activation distribution:

```json
{
"language": {"path": "calibration.jsonl"}
}
```

```bash
cactus convert ./finetuned-model ./finetuned-cq4 --bits 4 \
--calibration-manifest calibration-manifest.json
```

Language calibration JSONL rows may contain `messages`, `prompt_text` plus
`completion_text`, or `prompt` plus `completion`. Without representative samples,
GPTQ-eligible tensors use calibration-free RTN when samples are missing or the
collected Hessian cannot be applied. The converter reports both fallback cases in the
terminal and records their extent in `conversion_summary.json`; affected tensors and
their reasons are identified in `conversion_manifest.json`. Supplying a manifest when
the output bundle already exists is an error unless `--reconvert` is also passed.

For models on huggingface.co/Cactus-Compute, `cactus run <model-id>` (or `cactus
download <model-id>`) fetches the pre-built bundle.

Expand Down
1 change: 1 addition & 0 deletions docs/cactus_transpiler.md
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,7 @@ slot, so single-stream decode stays lean while fixed-batch decode (`decode_batch
| Option | Description |
|--------|-------------|
| `--bits 1\|2\|3\|4` | CQ quantization bits (default: 4) |
| `--calibration-manifest <path>` | Representative calibration data for GPTQ-aware CQ conversion |
| `--weights-only` | Stop after CQ quantization; skip the runtime graph |
| `--weights-dir <path>` | Path to converted CQ weights (default: `weights/<model_name>`) |
| `--task <name>` | Force task type (default: `auto` — inferred from model config). Choices: `causal_lm_logits`, `multimodal_causal_lm_logits`, `ctc_logits`, `encoder_hidden_states`, `seq2seq_transcription`, `tdt_transcription` |
Expand Down
4 changes: 4 additions & 0 deletions python/cactus/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,10 @@ def create_parser():
help="Output directory (default: weights/<model>)")
convert_parser.add_argument("--bits", type=int, choices=[1, 2, 3, 4], default=4,
help="CQ quantization bits (default: 4)")
convert_parser.add_argument(
"--calibration-manifest",
help="JSON manifest of representative calibration data for GPTQ-aware CQ conversion",
)
convert_parser.add_argument("--token", help="HuggingFace token")
convert_parser.add_argument("--lora",
help="Path or HF id of a LoRA adapter to merge before converting (requires `peft`)")
Expand Down
1 change: 1 addition & 0 deletions python/cactus/cli/convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ def cmd_convert(args):
reconvert=args.reconvert,
output_dir=output_dir,
skip_model_load=bool(getattr(args, "skip_model_load", False)),
calibration_manifest=getattr(args, "calibration_manifest", None),
)
if getattr(args, "weights_only", False):
return 0
Expand Down
30 changes: 28 additions & 2 deletions python/cactus/cli/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,15 @@ def package_handoff_probe(output_dir, model_id):
print_color(YELLOW, f"Warning: failed to package cloud handoff probe: {exc}")


def _convert_from_source(model_id, *, bits, token, weights_dir, skip_model_load=False):
def _convert_from_source(
model_id,
*,
bits,
token,
weights_dir,
skip_model_load=False,
calibration_manifest=None,
):
"""Download from HuggingFace and run CQ conversion."""
if bits not in (1, 2, 3, 4):
raise SystemExit(
Expand All @@ -57,6 +65,8 @@ def _convert_from_source(model_id, *, bits, token, weights_dir, skip_model_load=
]
if skip_model_load:
cq_args.append("--skip-model-load")
if calibration_manifest:
cq_args.extend(["--calibration-manifest", str(calibration_manifest)])
if token:
os.environ["HF_TOKEN"] = token
os.environ["HUGGING_FACE_HUB_TOKEN"] = token
Expand All @@ -66,7 +76,16 @@ def _convert_from_source(model_id, *, bits, token, weights_dir, skip_model_load=
return weights_dir


def ensure_weights(model_id, *, bits=4, token=None, reconvert=False, output_dir=None, skip_model_load=False):
def ensure_weights(
model_id,
*,
bits=4,
token=None,
reconvert=False,
output_dir=None,
skip_model_load=False,
calibration_manifest=None,
):
from .download import get_bundle_dir

weights_dir = Path(output_dir) if output_dir else get_bundle_dir(model_id, bits=bits)
Expand All @@ -76,6 +95,12 @@ def ensure_weights(model_id, *, bits=4, token=None, reconvert=False, output_dir=
shutil.rmtree(weights_dir)

if weights_dir.exists() and (weights_dir / "config.txt").exists():
if calibration_manifest is not None:
raise RuntimeError(
"A calibration manifest is only applied while converting weights, but a cached "
f"bundle already exists at {weights_dir}. Pass --reconvert to rebuild it with "
"calibration, or choose a different output directory."
)
print_color(GREEN, f"Model weights found at {weights_dir}")
return weights_dir

Expand All @@ -89,6 +114,7 @@ def ensure_weights(model_id, *, bits=4, token=None, reconvert=False, output_dir=
token=token,
weights_dir=weights_dir,
skip_model_load=skip_model_load,
calibration_manifest=calibration_manifest,
)


Expand Down
7 changes: 7 additions & 0 deletions python/cactus/convert/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,7 @@ def convert(args: argparse.Namespace) -> None:
emit_source_names = emission.source_names or source_names
status = emit_policy.action
precision = emit_policy.precision
gptq_expected = bool(emit_policy.action == "convert" and emit_policy.use_gptq)
gptq_used = False
hessian_missing_reason = None
module_name = adapter.module_target_name(name, model) or _module_name(name)
Expand Down Expand Up @@ -560,6 +561,11 @@ def convert(args: argparse.Namespace) -> None:
input_scale=input_scale,
)
cq = _scale_cq_norms(cq, adapter.scale_factor(out_path.name))
if gptq_expected and not cq.gptq_used:
if hessian_missing_reason is None:
hessian_missing_reason = "collected GPTQ Hessian could not be applied"
if args.strict:
raise RuntimeError(f"{name}: {hessian_missing_reason} ({module_name})")
if getattr(emit_policy, "layout", "row_major") == "interleaved_4row":
cq = replace(cq, interleaved_4row=True)
write_cq_tensor(out_path, cq)
Expand Down Expand Up @@ -593,6 +599,7 @@ def convert(args: argparse.Namespace) -> None:
"required": bool(emit_policy.action != "ignored"),
"fallback_reason": emit_policy.fallback_reason,
"hessian_samples": int(hessian_samples.get(module_name, 0)),
"gptq_expected": gptq_expected,
"gptq_used": bool(gptq_used),
"bytes": _tensor_bytes(out_path),
"scale_factor": float(adapter.scale_factor(out_path.name)) if out_path else 1.0,
Expand Down
33 changes: 33 additions & 0 deletions python/cactus/convert/export/reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,31 @@ def write_reports(out_dir: Path, rows: list[dict[str, Any]]) -> dict[str, Any]:
by_component = Counter(r["component"] for r in rows)
by_precision = Counter(r["precision"] for r in rows)
fallback = Counter(r.get("fallback_reason") or "" for r in rows if r["status"] in {"fallback", "unrecognized"})
gptq_expected = [r for r in rows if r.get("gptq_expected")]
gptq_used = [r for r in gptq_expected if r.get("gptq_used")]
gptq_uncalibrated = [r for r in gptq_expected if not r.get("gptq_used")]
gptq_zero_samples = [
r for r in gptq_uncalibrated
if int(r.get("hessian_samples", 0) or 0) <= 0
]
gptq_unusable_hessian = [
r for r in gptq_uncalibrated
if int(r.get("hessian_samples", 0) or 0) > 0
]
total_bytes = sum(int(r.get("bytes", 0) or 0) for r in rows)
summary = {
"total_tensors": len(rows),
"counts_by_status": dict(by_status),
"counts_by_component": dict(by_component),
"counts_by_precision": dict(by_precision),
"fallback_reasons": dict(fallback),
"gptq": {
"expected_tensors": len(gptq_expected),
"calibrated_tensors": len(gptq_used),
"uncalibrated_tensors": len(gptq_uncalibrated),
"zero_sample_tensors": len(gptq_zero_samples),
"unusable_hessian_tensors": len(gptq_unusable_hessian),
},
"total_bytes": total_bytes,
}
(out_dir / "conversion_summary.json").write_text(json.dumps(summary, indent=2, sort_keys=True), encoding="utf-8")
Expand All @@ -52,3 +70,18 @@ def print_summary(summary: dict[str, Any]) -> None:
for key in ["converted", "fallback", "ignored", "unrecognized"]:
print(f"{key:13s} {summary.get('counts_by_status', {}).get(key, 0)}")
print(f"{'total bytes':13s} {summary.get('total_bytes', 0)}")
gptq = summary.get("gptq", {})
expected = int(gptq.get("expected_tensors", 0) or 0)
calibrated = int(gptq.get("calibrated_tensors", 0) or 0)
uncalibrated = int(gptq.get("uncalibrated_tensors", 0) or 0)
zero_samples = int(gptq.get("zero_sample_tensors", 0) or 0)
unusable_hessian = int(gptq.get("unusable_hessian_tensors", 0) or 0)
if expected:
print(f"{'GPTQ calibrated':13s} {calibrated}/{expected}")
if uncalibrated:
print(
f"\nWARNING: {uncalibrated} GPTQ-eligible CQ tensors used RTN fallback "
f"({zero_samples} zero-sample, {unusable_hessian} unusable Hessian). "
"Provide --calibration-manifest with representative data; see "
"conversion_manifest.json for affected tensors."
)
56 changes: 56 additions & 0 deletions python/cactus/convert/tests/test_hessian.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
from __future__ import annotations

import numpy as np
import torch

from cactus.convert.calibration.hessian import HessianStats, collect_manifest_hessians
from cactus.convert.cli import _load_hessian_artifacts, _save_hessian_artifacts
from cactus.convert.quantization.cq import quantize_hadamard
from cactus.models.needle.configuration_needle import NeedleConfig
from cactus.models.needle.modeling_needle import NeedleForCausalLM


class TinyModel(torch.nn.Module):
Expand Down Expand Up @@ -54,3 +58,55 @@ def test_hessian_artifact_cache_roundtrip(tmp_path):
assert loaded.samples == {"layers.0": 7}
assert torch.equal(loaded.hessians["layers.0"], torch.eye(2))
assert torch.equal(loaded.diag["layers.0"], torch.ones(2))


def test_needle_manifest_hessian_is_used_by_gptq(tmp_path):
rows = tmp_path / "language.jsonl"
rows.write_text('{"prompt_text":"hello","completion_text":"world"}\n', encoding="utf-8")
manifest = {"language": {"path": str(rows)}}
config = NeedleConfig(
vocab_size=16,
hidden_size=64,
d_model=64,
num_attention_heads=2,
num_key_value_heads=1,
num_encoder_layers=1,
num_decoder_layers=1,
torch_dtype="float32",
)
model = NeedleForCausalLM(config).float()
target = "model.encoder.layers.0.self_attn.q_proj"

stats = collect_manifest_hessians(
model,
TinyTokenizer(),
manifest,
{"language": 1},
{target},
"cpu",
)

assert stats.errors == {}
assert stats.unresolved_targets == []
assert stats.samples[target] > 0
assert stats.hessians[target].shape == (64, 64)

result = quantize_hadamard(
model.state_dict()[f"{target}.weight"],
bits=4,
hessian=stats.hessians[target].numpy(),
use_gptq=True,
)

assert result.gptq_used is True


def test_hadamard_rejects_mismatched_hessian_for_gptq():
result = quantize_hadamard(
torch.randn(4, 64),
bits=4,
hessian=np.eye(32, dtype=np.float32),
use_gptq=True,
)

assert result.gptq_used is False
59 changes: 58 additions & 1 deletion python/cactus/convert/tests/test_reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import json
from pathlib import Path

from cactus.convert.export.reports import write_reports
from cactus.convert.export.reports import print_summary, write_reports


def test_weights_manifest_includes_bias_tensors(tmp_path: Path) -> None:
Expand Down Expand Up @@ -34,3 +34,60 @@ def test_weights_manifest_includes_bias_tensors(tmp_path: Path) -> None:

assert "decoder.layer_0_mlp_fc1.bias" in output_names
assert "decoder.layer_0_mlp_fc1.weights" in output_names


def test_reports_make_all_gptq_fallbacks_visible(tmp_path: Path, capsys) -> None:
summary = write_reports(
tmp_path,
[
{
"source_name": "layers.0.q_proj.weight",
"output_file": "model.layer_0_attention_q_proj.weights",
"shape": [4, 4],
"precision": "CQ4",
"status": "converted",
"component": "language",
"gptq_expected": True,
"gptq_used": False,
"hessian_samples": 0,
},
{
"source_name": "layers.0.k_proj.weight",
"output_file": "model.layer_0_attention_k_proj.weights",
"shape": [4, 4],
"precision": "CQ4",
"status": "converted",
"component": "language",
"gptq_expected": True,
"gptq_used": True,
"hessian_samples": 8,
},
{
"source_name": "layers.0.v_proj.weight",
"output_file": "model.layer_0_attention_v_proj.weights",
"shape": [4, 4],
"precision": "CQ4",
"status": "converted",
"component": "language",
"gptq_expected": True,
"gptq_used": False,
"hessian_samples": 8,
},
],
)

assert summary["gptq"] == {
"expected_tensors": 3,
"calibrated_tensors": 1,
"uncalibrated_tensors": 2,
"zero_sample_tensors": 1,
"unusable_hessian_tensors": 1,
}
saved = json.loads((tmp_path / "conversion_summary.json").read_text())
assert saved["gptq"] == summary["gptq"]

print_summary(summary)
output = capsys.readouterr().out
assert "GPTQ calibrated 1/3" in output
assert "WARNING: 2 GPTQ-eligible CQ tensors used RTN fallback" in output
assert "1 zero-sample, 1 unusable Hessian" in output
Loading