Skip to content

Commit 0d2e78b

Browse files
committed
fix(hf_ptq): scope layerwise export and resume to the one exporting pass
A sequential algorithm list made export ownership ambiguous. Detection read the first layerwise block, so an exporting block behind a non-exporting one missed the opt-in and exported to the recipe's placeholder; deriving the resume dir had the mirror bug, letting another pass's explicit checkpoint_dir stand in for the exporting one's, and giving every block one shared directory when none was set. Export finalizes shards as calibration walks the layers, so only the final pass can own them: layerwise_export_block() enforces exactly one export_dir on the last entry and every export-driven helper reads through it. resolve_checkpoint_dir now resolves each block against its own base, so two passes cannot collide on one manifest. Signed-off-by: Fridah-nv <201670829+Fridah-nv@users.noreply.github.com>
1 parent 4074508 commit 0d2e78b

3 files changed

Lines changed: 150 additions & 34 deletions

File tree

examples/hf_ptq/example_utils.py

Lines changed: 53 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1152,20 +1152,52 @@ def _layerwise_checkpoint_dir(algorithm) -> str | None:
11521152
)
11531153

11541154

1155+
def layerwise_export_block(algorithm) -> dict | None:
1156+
"""The one ``layerwise`` block that owns per-layer export, or None.
1157+
1158+
Export finalizes each layer's shard during calibration, so a later pass would change
1159+
the model after its checkpoint was written: exactly one entry may set ``export_dir``,
1160+
and it must be the last.
1161+
"""
1162+
entries = algorithm if isinstance(algorithm, list) else [algorithm]
1163+
exporting = [
1164+
(i, e["layerwise"])
1165+
for i, e in enumerate(entries)
1166+
if isinstance(e, dict)
1167+
and isinstance(e.get("layerwise"), dict)
1168+
and e["layerwise"].get("export_dir") is not None
1169+
]
1170+
if not exporting:
1171+
return None
1172+
if len(exporting) > 1:
1173+
raise ValueError(
1174+
f"{len(exporting)} algorithm entries set layerwise.export_dir; only one "
1175+
"calibration pass can own the exported checkpoint."
1176+
)
1177+
index, block = exporting[0]
1178+
if index != len(entries) - 1:
1179+
raise ValueError(
1180+
f"layerwise.export_dir is set on algorithm entry {index} of {len(entries)}; it "
1181+
"must be the last, since a later pass would change the model after its shards "
1182+
"were written."
1183+
)
1184+
return block
1185+
1186+
11551187
def default_layerwise_resume_dir(quant_cfg: dict, export_path: str) -> tuple[dict, bool]:
11561188
"""Derive ``layerwise.checkpoint_dir`` from ``export_path`` when unset.
11571189
11581190
A sibling, not a child: nothing deletes the resume state, so inside ``export_path`` it
11591191
would ship in the checkpoint. An explicit path is left alone.
11601192
"""
1161-
if _layerwise_checkpoint_dir(quant_cfg.get("algorithm")) is not None:
1162-
return quant_cfg, False
1163-
11641193
quant_cfg = copy.deepcopy(quant_cfg)
1165-
blocks = _layerwise_blocks(quant_cfg.get("algorithm"))
1166-
for block in blocks:
1167-
block["checkpoint_dir"] = export_path.rstrip("/") + ".layerwise_resume"
1168-
return quant_cfg, bool(blocks)
1194+
# The exporting block specifically: another pass's explicit checkpoint_dir says nothing
1195+
# about where this one resumes from.
1196+
block = layerwise_export_block(quant_cfg.get("algorithm"))
1197+
if block is None or block.get("checkpoint_dir") is not None:
1198+
return quant_cfg, False
1199+
block["checkpoint_dir"] = export_path.rstrip("/") + ".layerwise_resume"
1200+
return quant_cfg, True
11691201

11701202

11711203
def needs_checkpoint_path_update(quant_cfg: dict) -> bool:
@@ -1182,8 +1214,7 @@ def resolve_checkpoint_dir(quant_cfg: dict, model_path: str) -> tuple[dict, str]
11821214
Returns ``(updated_quant_cfg, resolved_path)`` so the caller can log or
11831215
reference the resolved path without re-deriving the dict shape.
11841216
"""
1185-
base_dir = _layerwise_checkpoint_dir(quant_cfg["algorithm"])
1186-
assert base_dir is not None # guaranteed by needs_checkpoint_path_update
1217+
assert needs_checkpoint_path_update(quant_cfg), "no layerwise.checkpoint_dir to resolve"
11871218

11881219
name = model_path.rstrip("/")
11891220
if "/" in name and not os.path.isabs(name):
@@ -1192,11 +1223,19 @@ def resolve_checkpoint_dir(quant_cfg: dict, model_path: str) -> tuple[dict, str]
11921223
name = Path(name).name
11931224

11941225
config_hash = hashlib.sha256(json.dumps(quant_cfg, default=str).encode()).hexdigest()[:8]
1195-
resolved = os.path.join(base_dir, f"{name}_{config_hash}")
1226+
suffix = f"{name}_{config_hash}"
11961227

11971228
quant_cfg = copy.deepcopy(quant_cfg)
1229+
# Each pass keeps its own base, so two layerwise passes cannot resolve onto one manifest.
1230+
exporting = layerwise_export_block(quant_cfg.get("algorithm"))
1231+
resolved = None
11981232
for block in _layerwise_blocks(quant_cfg.get("algorithm")):
1199-
block["checkpoint_dir"] = resolved
1233+
if block.get("checkpoint_dir") is None:
1234+
continue
1235+
block["checkpoint_dir"] = os.path.join(block["checkpoint_dir"], suffix)
1236+
if resolved is None or block is exporting:
1237+
resolved = block["checkpoint_dir"]
1238+
assert resolved is not None # needs_checkpoint_path_update found one above
12001239
return quant_cfg, resolved
12011240

12021241

@@ -1210,22 +1249,14 @@ def set_layerwise_export_dir(quant_cfg: dict, export_path: str) -> dict:
12101249
"""
12111250
quant_cfg = copy.deepcopy(quant_cfg)
12121251
algorithm = quant_cfg.get("algorithm")
1213-
# Detection accepts one algorithm or a list, so the retarget must too.
1214-
retargeted = 0
1215-
for entry in algorithm if isinstance(algorithm, list) else [algorithm]:
1216-
# Only entries that already opted in: writing export_dir into a layerwise entry
1217-
# that did not ask for it would switch per-layer export on behind the user's back.
1218-
layerwise = entry.get("layerwise") if isinstance(entry, dict) else None
1219-
if isinstance(layerwise, dict) and layerwise.get("export_dir") is not None:
1220-
layerwise["export_dir"] = export_path
1221-
retargeted += 1
1222-
1223-
if not retargeted:
1252+
block = layerwise_export_block(algorithm)
1253+
if block is None:
12241254
raise ValueError(
12251255
"layerwise export is enabled but no layerwise.export_dir was found to retarget "
12261256
f"in algorithm={algorithm!r}. The exported shards would go to the recipe's "
12271257
"placeholder path instead of --export_path."
12281258
)
1259+
block["export_dir"] = export_path
12291260
return quant_cfg
12301261

12311262

examples/hf_ptq/hf_ptq.py

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1200,33 +1200,35 @@ def quantize_main(
12001200
aq_config = None
12011201
fixed_quantize_config = None
12021202

1203-
def _layerwise_cfg(obj):
1204-
"""The recipe's ``layerwise`` block, or None.
1203+
def _layerwise_cfgs(obj) -> list:
1204+
"""The recipe's ``layerwise`` blocks, in algorithm order.
12051205
12061206
An algorithm parsed from YAML arrives as a plain dict, while the deprecated
12071207
``--auto_quantize_*`` path builds config objects, so both shapes reach here.
12081208
"""
12091209
if isinstance(obj, ModelOptPTQRecipe):
1210-
return _layerwise_cfg(obj.quantize.algorithm)
1210+
return _layerwise_cfgs(obj.quantize.algorithm)
12111211
if isinstance(obj, ModelOptAutoQuantizeRecipe):
1212-
return _layerwise_cfg(obj.quantize.algorithm) if obj.quantize is not None else None
1212+
return _layerwise_cfgs(obj.quantize.algorithm) if obj.quantize is not None else []
12131213
if isinstance(obj, list):
1214-
return next((cfg for cfg in map(_layerwise_cfg, obj) if cfg is not None), None)
1215-
if isinstance(obj, dict):
1216-
return obj.get("layerwise")
1217-
return getattr(obj, "layerwise", None)
1214+
return [cfg for entry in obj for cfg in _layerwise_cfgs(entry)]
1215+
cfg = obj.get("layerwise") if isinstance(obj, dict) else getattr(obj, "layerwise", None)
1216+
return [cfg] if cfg is not None else []
12181217

12191218
def _layerwise_get(cfg, key, default=None):
12201219
if cfg is None:
12211220
return default
12221221
return cfg.get(key, default) if isinstance(cfg, dict) else getattr(cfg, key, default)
12231222

1224-
layerwise_cfg = _layerwise_cfg(recipe)
1225-
is_layerwise = bool(_layerwise_get(layerwise_cfg, "enable", False))
1223+
layerwise_cfgs = _layerwise_cfgs(recipe)
1224+
is_layerwise = any(_layerwise_get(cfg, "enable", False) for cfg in layerwise_cfgs)
12261225

12271226
# Setting layerwise.export_dir is the switch; the value is replaced with --export_path
1228-
# below, the way resolve_checkpoint_dir already rewrites layerwise.checkpoint_dir.
1229-
args.layerwise_export = _layerwise_get(layerwise_cfg, "export_dir") is not None
1227+
# below, the way resolve_checkpoint_dir already rewrites layerwise.checkpoint_dir. Any
1228+
# entry may hold it -- set_layerwise_export_dir settles which one legally owns it.
1229+
args.layerwise_export = any(
1230+
_layerwise_get(cfg, "export_dir") is not None for cfg in layerwise_cfgs
1231+
)
12301232
if args.layerwise_export:
12311233
if isinstance(recipe, ModelOptAutoQuantizeRecipe):
12321234
# Only the mono-quantize path retargets export_dir and runs the refusals;

tests/examples/hf_ptq/test_example_utils.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,3 +560,86 @@ def from_pretrained(*args, **kwargs):
560560
example_utils.get_model("checkpoint", device="cpu", trust_remote_code=trust_remote_code)
561561

562562
assert used["path"] == ("bundled" if expect_bundled_code else "builtin")
563+
564+
565+
def _layerwise(**kwargs):
566+
return {"enable": True, **kwargs}
567+
568+
569+
def _blocks(quant_cfg):
570+
algorithm = quant_cfg["algorithm"]
571+
entries = algorithm if isinstance(algorithm, list) else [algorithm]
572+
return [e["layerwise"] for e in entries if "layerwise" in e]
573+
574+
575+
@pytest.mark.parametrize(
576+
("algorithm", "expected"),
577+
[
578+
pytest.param(
579+
{"method": "max", "layerwise": _layerwise(export_dir="/placeholder")},
580+
["/out.layerwise_resume"],
581+
id="single-entry",
582+
),
583+
pytest.param(
584+
[
585+
{"method": "awq", "layerwise": _layerwise()},
586+
{"method": "max", "layerwise": _layerwise(export_dir="/placeholder")},
587+
],
588+
[None, "/out.layerwise_resume"],
589+
id="only-the-exporting-entry",
590+
),
591+
pytest.param(
592+
[
593+
{"method": "awq", "layerwise": _layerwise(checkpoint_dir="/theirs")},
594+
{"method": "max", "layerwise": _layerwise(export_dir="/placeholder")},
595+
],
596+
["/theirs", "/out.layerwise_resume"],
597+
id="another-entrys-explicit-path-is-not-this-ones",
598+
),
599+
],
600+
)
601+
def test_default_layerwise_resume_dir_targets_the_exporting_entry(algorithm, expected):
602+
"""Only the pass that exports gets a derived resume dir, and only if it lacks one."""
603+
updated, changed = example_utils.default_layerwise_resume_dir({"algorithm": algorithm}, "/out")
604+
605+
assert [b.get("checkpoint_dir") for b in _blocks(updated)] == expected
606+
assert changed is True
607+
608+
609+
def test_resolve_checkpoint_dir_keeps_each_entrys_base():
610+
"""Two layerwise passes must not resolve onto one manifest."""
611+
algorithm = [
612+
{"method": "awq", "layerwise": _layerwise(checkpoint_dir="/theirs")},
613+
{"method": "max", "layerwise": _layerwise(checkpoint_dir="/ours", export_dir="/ph")},
614+
]
615+
616+
updated, resolved = example_utils.resolve_checkpoint_dir({"algorithm": algorithm}, "/m/Model")
617+
618+
theirs, ours = (b["checkpoint_dir"] for b in _blocks(updated))
619+
assert theirs.startswith("/theirs/") and ours.startswith("/ours/")
620+
assert theirs != ours
621+
# The exporting pass owns the path the caller reports.
622+
assert resolved == ours
623+
624+
625+
@pytest.mark.parametrize(
626+
("algorithm", "match"),
627+
[
628+
pytest.param(
629+
[
630+
{"layerwise": _layerwise(export_dir="/a")},
631+
{"layerwise": _layerwise(export_dir="/b")},
632+
],
633+
"only one calibration pass",
634+
id="two-exporting-entries",
635+
),
636+
pytest.param(
637+
[{"layerwise": _layerwise(export_dir="/a")}, {"method": "max"}],
638+
"must be the last",
639+
id="a-later-pass-would-change-the-model",
640+
),
641+
],
642+
)
643+
def test_set_layerwise_export_dir_refuses_ambiguous_ownership(algorithm, match):
644+
with pytest.raises(ValueError, match=match):
645+
example_utils.set_layerwise_export_dir({"algorithm": algorithm}, "/out")

0 commit comments

Comments
 (0)