IMJONEZZ commited on
Commit
d2fa034
·
1 Parent(s): 7bd2c00

finetune: explicit LoRAMerge before HF export + GGUF conversion rails

Browse files

save_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
- missing = model[0].load_state_dict(loaded[key], strict=False)
 
 
68
  n_adapter = len(loaded[key])
69
- print(f"[merge-hf] loaded {n_adapter} adapter tensors (unexpected: {len(missing.unexpected_keys)})", flush=True)
70
  if n_adapter == 0:
71
  raise RuntimeError("no adapter tensors loaded — refusing to export an unmodified base model")
72
 
73
- # save_hf_pretrained merges LoRALinear wrappers into dense weights during
74
- # its streaming export; source_path preserves the custom Nemotron-H
75
- # modeling files so the result is from_pretrained-loadable.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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