finetune: explicit LoRAMerge before HF export + GGUF conversion rails
Browse filessave_hf_pretrained's claimed adapter auto-merge silently exported base
weights; merge explicitly (LoRAMerge -> strip adapters -> unwrap), assert
non-zero adapter B norms and a changed probe weight before writing.
run_gguf_spark2.sh: containerized convert_hf_to_gguf under the same
memory cap + watchdog. Chain verified end-to-end: merged HF export
hash-differs from base on LoRA targets only; Q4_K_S passes the eval gate.
finetune/nemo/run_gguf_spark2.sh
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Convert the merged Warden HF checkpoint to GGUF — ON SPARK2, same host-safety
|
| 3 |
+
# rails as run_merge_spark2.sh (cgroup cap + MemAvailable watchdog).
|
| 4 |
+
#
|
| 5 |
+
# Step 1 (this script, containerized): HF safetensors -> BF16 GGUF via
|
| 6 |
+
# llama.cpp's convert_hf_to_gguf.py (lazy tensor streaming, low peak RAM,
|
| 7 |
+
# but rails stay on out of policy after the 2026-06-12 freeze).
|
| 8 |
+
# Step 2 (separate, cheap): llama-quantize BF16 -> Q4_K_S, mmap-streamed.
|
| 9 |
+
set -uo pipefail
|
| 10 |
+
|
| 11 |
+
NAME=warden-gguf
|
| 12 |
+
MODELS_DIR=/home/imjonezz/models
|
| 13 |
+
LLAMA_DIR=/home/imjonezz/llama.cpp
|
| 14 |
+
MIN_AVAIL_KB=$((12 * 1024 * 1024)) # 12GB floor for the host
|
| 15 |
+
|
| 16 |
+
mkdir -p "$MODELS_DIR/gguf"
|
| 17 |
+
docker rm -f "$NAME" >/dev/null 2>&1 || true
|
| 18 |
+
|
| 19 |
+
docker run -d --name "$NAME" \
|
| 20 |
+
--memory=100g --memory-swap=100g \
|
| 21 |
+
-e HF_HUB_OFFLINE=1 -e TRANSFORMERS_OFFLINE=1 \
|
| 22 |
+
-e PYTHONPATH=/llama.cpp/gguf-py \
|
| 23 |
+
-v "$MODELS_DIR":/models \
|
| 24 |
+
-v "$LLAMA_DIR":/llama.cpp \
|
| 25 |
+
--entrypoint python3 nvcr.io/nvidia/nemo:25.11.nemotron_3_nano \
|
| 26 |
+
/llama.cpp/convert_hf_to_gguf.py /models/nemotron-3-nano-30b-warden-hf \
|
| 27 |
+
--outfile /models/gguf/warden-nemotron-30b-bf16.gguf \
|
| 28 |
+
--outtype bf16 || exit 1
|
| 29 |
+
|
| 30 |
+
echo "container started; watchdog polling every 5s (kill if MemAvailable < 12GB)"
|
| 31 |
+
while [ -n "$(docker ps -q -f name="$NAME")" ]; do
|
| 32 |
+
avail_kb=$(awk '/MemAvailable/{print $2}' /proc/meminfo)
|
| 33 |
+
if [ "$avail_kb" -lt "$MIN_AVAIL_KB" ]; then
|
| 34 |
+
echo "WATCHDOG TRIPPED: MemAvailable ${avail_kb}kB < 12GB — killing $NAME"
|
| 35 |
+
docker kill "$NAME"
|
| 36 |
+
docker logs "$NAME" --tail 30
|
| 37 |
+
exit 2
|
| 38 |
+
fi
|
| 39 |
+
sleep 5
|
| 40 |
+
done
|
| 41 |
+
|
| 42 |
+
code=$(docker inspect "$NAME" --format '{{.State.ExitCode}}')
|
| 43 |
+
echo "=== container exited with code $code; last log lines: ==="
|
| 44 |
+
docker logs "$NAME" --tail 40 2>&1
|
| 45 |
+
docker rm "$NAME" >/dev/null 2>&1 || true
|
| 46 |
+
exit "$code"
|
finetune/nemo/run_merge_hf.py
CHANGED
|
@@ -25,7 +25,8 @@ def main():
|
|
| 25 |
import torch
|
| 26 |
from megatron.core import dist_checkpointing
|
| 27 |
from megatron.bridge.models.conversion.auto_bridge import AutoBridge
|
| 28 |
-
from megatron.bridge.peft.lora import LoRA
|
|
|
|
| 29 |
from megatron.bridge.training.checkpointing import (
|
| 30 |
_generate_model_state_dict,
|
| 31 |
apply_peft_adapter_filter_to_state_dict,
|
|
@@ -37,6 +38,11 @@ def main():
|
|
| 37 |
base_dir = os.environ.get("MEGATRON_CKPT", "/models/nemotron-3-nano-30b-megatron")
|
| 38 |
out_dir = os.environ.get("OUT_DIR", "/models/nemotron-3-nano-30b-warden-hf")
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
print(f"[merge-hf] base={base_dir} lora={lora_dir} -> {out_dir}", flush=True)
|
| 41 |
bridge = AutoBridge.from_hf_pretrained(hf_dir, trust_remote_code=True)
|
| 42 |
|
|
@@ -64,15 +70,53 @@ def main():
|
|
| 64 |
sharded_sd = apply_peft_adapter_filter_to_state_dict(sharded_sd, lora_peft)
|
| 65 |
loaded = dist_checkpointing.load(sharded_sd, str(lora_dir))
|
| 66 |
key = "model" if "model" in loaded else next(k for k in loaded if k.startswith("model"))
|
| 67 |
-
|
|
|
|
|
|
|
| 68 |
n_adapter = len(loaded[key])
|
| 69 |
-
print(f"[merge-hf] loaded {n_adapter} adapter tensors (unexpected: {
|
| 70 |
if n_adapter == 0:
|
| 71 |
raise RuntimeError("no adapter tensors loaded — refusing to export an unmodified base model")
|
| 72 |
|
| 73 |
-
#
|
| 74 |
-
#
|
| 75 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
bridge.save_hf_pretrained(model, out_dir, source_path=hf_dir)
|
| 77 |
print("[merge-hf] export complete", flush=True)
|
| 78 |
|
|
|
|
| 25 |
import torch
|
| 26 |
from megatron.core import dist_checkpointing
|
| 27 |
from megatron.bridge.models.conversion.auto_bridge import AutoBridge
|
| 28 |
+
from megatron.bridge.peft.lora import LoRA, LoRAMerge
|
| 29 |
+
from megatron.bridge.peft.lora_layers import LoRALinear
|
| 30 |
from megatron.bridge.training.checkpointing import (
|
| 31 |
_generate_model_state_dict,
|
| 32 |
apply_peft_adapter_filter_to_state_dict,
|
|
|
|
| 38 |
base_dir = os.environ.get("MEGATRON_CKPT", "/models/nemotron-3-nano-30b-megatron")
|
| 39 |
out_dir = os.environ.get("OUT_DIR", "/models/nemotron-3-nano-30b-warden-hf")
|
| 40 |
|
| 41 |
+
# clear any previous (possibly bad) export — the container runs as root
|
| 42 |
+
import shutil
|
| 43 |
+
|
| 44 |
+
shutil.rmtree(out_dir, ignore_errors=True)
|
| 45 |
+
|
| 46 |
print(f"[merge-hf] base={base_dir} lora={lora_dir} -> {out_dir}", flush=True)
|
| 47 |
bridge = AutoBridge.from_hf_pretrained(hf_dir, trust_remote_code=True)
|
| 48 |
|
|
|
|
| 70 |
sharded_sd = apply_peft_adapter_filter_to_state_dict(sharded_sd, lora_peft)
|
| 71 |
loaded = dist_checkpointing.load(sharded_sd, str(lora_dir))
|
| 72 |
key = "model" if "model" in loaded else next(k for k in loaded if k.startswith("model"))
|
| 73 |
+
# MegatronModule.load_state_dict returns None, not torch's NamedTuple
|
| 74 |
+
ret = model[0].load_state_dict(loaded[key], strict=False)
|
| 75 |
+
unexpected = len(ret.unexpected_keys) if ret is not None else "n/a"
|
| 76 |
n_adapter = len(loaded[key])
|
| 77 |
+
print(f"[merge-hf] loaded {n_adapter} adapter tensors (unexpected: {unexpected})", flush=True)
|
| 78 |
if n_adapter == 0:
|
| 79 |
raise RuntimeError("no adapter tensors loaded — refusing to export an unmodified base model")
|
| 80 |
|
| 81 |
+
# Lesson from the first export: save_hf_pretrained alone wrote a model
|
| 82 |
+
# byte-identical to the base. Merge explicitly (as the stock merge_lora.py
|
| 83 |
+
# does) and prove the adapters are non-trivial before exporting.
|
| 84 |
+
b_norm = sum(
|
| 85 |
+
m.adapter.linear_out.weight.float().norm().item()
|
| 86 |
+
for m in model[0].modules()
|
| 87 |
+
if isinstance(m, LoRALinear)
|
| 88 |
+
)
|
| 89 |
+
print(f"[merge-hf] sum of adapter B-matrix norms: {b_norm:.4f}", flush=True)
|
| 90 |
+
if b_norm == 0.0:
|
| 91 |
+
raise RuntimeError("all adapter B matrices are zero — checkpoint load did not take")
|
| 92 |
+
|
| 93 |
+
probe = max(
|
| 94 |
+
(m for m in model[0].modules() if isinstance(m, LoRALinear)),
|
| 95 |
+
key=lambda m: m.adapter.linear_out.weight.float().norm().item(),
|
| 96 |
+
)
|
| 97 |
+
before = probe.to_wrap.weight.detach().clone()
|
| 98 |
+
|
| 99 |
+
merged = LoRAMerge()(model[0], training=False)
|
| 100 |
+
for m in merged.modules():
|
| 101 |
+
if hasattr(m, "adapter"):
|
| 102 |
+
delattr(m, "adapter")
|
| 103 |
+
|
| 104 |
+
def _unwrap(module):
|
| 105 |
+
for name, child in list(module.named_children()):
|
| 106 |
+
if isinstance(child, LoRALinear):
|
| 107 |
+
setattr(module, name, child.to_wrap)
|
| 108 |
+
else:
|
| 109 |
+
_unwrap(child)
|
| 110 |
+
|
| 111 |
+
_unwrap(merged)
|
| 112 |
+
model[0] = merged
|
| 113 |
+
|
| 114 |
+
if torch.equal(before, probe.to_wrap.weight):
|
| 115 |
+
raise RuntimeError("probe weight unchanged by LoRAMerge — refusing to export")
|
| 116 |
+
print("[merge-hf] LoRAMerge applied; probe weight changed as expected", flush=True)
|
| 117 |
+
|
| 118 |
+
# source_path preserves the custom Nemotron-H modeling files so the
|
| 119 |
+
# result is from_pretrained-loadable.
|
| 120 |
bridge.save_hf_pretrained(model, out_dir, source_path=hf_dir)
|
| 121 |
print("[merge-hf] export complete", flush=True)
|
| 122 |
|