feat: checkpointing and resuming quantization process from last checkpoint - #3057
Conversation
_offload_disk_locked previously rmtree'd the live per-module offload directory and then serialized the new state into it in place. Between those two steps there was a wide window during which the only durable copy of that module's state was destroyed -- fatal for any consumer that treats the offload directory as durable storage. Write the new bundle into a sibling temp directory first, then swap it into place with two renames, keeping the previous version recoverable under a .old suffix until the swap completes. The bundle's index.json must reference the final path rather than the temp path, so _bundle_module_state_dict now takes an explicit index_dir. Also threads through a `force` flag so callers can offload modules below the small-module skip threshold when the on-disk copy needs to be authoritative rather than best-effort.
Sequential GPTQ quantization of very large models can run for tens of hours; a crash partway through previously meant starting over from scratch. This adds an opt-in resume path built entirely on state the looper already persists to disk via offload_to_disk. Design: - Set GPTQMODEL_RESUME=1 on the run you want to be resumable *before* it starts, not only on the restart. The same flag means "make this run resumable" up front (forces synchronous per-layer finalize draining and writes quant_resume_state.json once a layer's modules are confirmed durable on disk) and "resume from where you left off" on a later restart. offload_to_disk users who never set the flag pay none of the added synchronous-drain cost and get the old async behavior unchanged. - The synchronous drain is forced whenever a resume marker path is configured, independent of wait_for_submodule_finalizers (which defaults to False) -- otherwise a default single-GPU config would never actually write a marker and GPTQMODEL_RESUME=1 would silently have nothing to resume from. - On restart, completed transformer layers are replayed forward-only: the original (meta) submodules are swapped for the quantized modules loaded back from the offload directory, one whole-layer forward regenerates the next layer's calibration inputs, and quantization resumes at the first unfinished layer. Input/output embeddings and lm_head are excluded from this fast-forward path -- once layer_index_offset shifts indices, the embeddings step and real layer 0 both carry layer_index=0, so a naive lookup could grab the wrong module. - The forward replay for the single most-recently-completed layer can itself be skipped: save_activation_cache/load_activation_cache persist that layer's output (and any paired shared_kv_cache_dict state) once it's computed, so a second resume that fast-forwards through the same layer reuses the cached result. The cache is validated by actually loading the tensors on read, not only checking metadata, since a corrupt or truncated cache file must not cause layers before the resume target to silently take the wrong replay path. - A resume fingerprint (model identity, calibration content, quant config) guards against silently reusing on-disk layer state from a different checkpoint or calibration run, which would otherwise produce a hybrid model with no error. Capturing calibration content requires hooking in before LoopProcessor.release_calibration_dataset() frees the raw dataset, since by the time any layer's resume marker is written the dataset is already gone. The next commit hardens this fingerprint further against several edge cases found in review. Includes unit tests for the resume-target/fast-forward decision logic and end-to-end coverage via a small dummy-model kill-and-resume driver (bitwise safetensors comparison against an uninterrupted run).
Several ways the resume fingerprint from the previous commit could compare two genuinely different runs as "unchanged" and let resume silently proceed against stale on-disk state: - calibration_dataset_hash only hashed sample sizes/count, and later only raw input_ids concatenation -- length-prefix each sample so differently segmented but token-identical data (e.g. [[1,2],[3]] vs [[1],[2,3]]) no longer collides, and fold in attention_mask too (loop_processor.py already treats it as authoritative over raw input_ids length when computing token counts, so identical tokens with different padding/masking are not actually equivalent). - A hash failure now returns a per-call-unique value instead of a fixed "" sentinel -- two runs that both failed to hash their (different) calibration data must never look like a match. - Broadened the non-calibration fingerprint fields to include quant_method, format, dynamic, pack_dtype, and total calibration token count, so a config or calibration-data change with the same batch count no longer passes as a match. - _checkpoint_fingerprint hashes (relative path, size, mtime) of every checkpoint weight file under model_local_path, folded into the fingerprint. Detects a revision swap or a manually replaced weight file at the same local path, which model_name_or_path/hidden_size/ vocab_size alone could not catch. Reads only filesystem metadata -- a full content hash would be far too slow for checkpoints that can run into the hundreds of GB. - An empty checkpoint_fingerprint (no model_local_path, or no recognized weight file extension under it) can no longer match another empty one -- read_resume_target now refuses any marker when the current run's own checkpoint_fingerprint is empty, since an empty value can never be trusted to mean "unchanged" regardless of what it's compared against. Also loads the resume target's activation cache once in run_layer_stage and threads it into the replay path, instead of re-reading the same safetensors file a second time when that layer is actually replayed. Extends the unit test suite accordingly (hash non-collision across segmentation and padding, GPU-resident tensor hashing, checkpoint weight-file swap detection, empty-fingerprint rejection, cache-hit/ cache-miss branching) and cleans up two test-suite issues found along the way: a leaked tempfile.mkdtemp() directory per pytest invocation, and a duplicated assertion. 47/47 tests pass.
|
Reviewed head
Validation: inspected all five changed files and the surrounding processing, replay, and save paths. Ran isolated checks against extracted PR helper functions for findings 2, 3, and 5; filesystem operations were real, while tensor/model collaborators were stubbed. PyTorch is unavailable in this environment, so I could not run |
|
@okdshin Please resolve the ai reviews before I begin manual review. Thank you for thr pr. |
|
@okdshin Going to refractor this PR with a slighly different design for better usability and future expansion. |
|
Proposed redesign: explicit looper boundaries with checkpointing as an extension The current implementation publishes progress separately from continuation activations and falls back to replaying original weights. That replay does not match GPTQ's normal forward after The looper needs four first-class concepts:
An extension receives boundaries on the orchestration thread. A checkpoint extension decides when to wait for finalization, capture state, and commit. Existing quantization/subset/device scheduling stays in the normal execution path. The looper does not implement filesystem layout, fingerprints, retention, or recovery policies. Conceptual flow: Finalization should return module names, quantization specifications, and artifact references. Committed artifacts must be immutable; ordinary disposable offload paths are not sufficient. A restore adapter builds the model shell, registers completed modules lazily against those artifacts, installs the continuation, and starts directly at the next step. Skipped modules stay backed by the source checkpoint. A checkpoint store writes artifacts and continuation first, flushes them according to its durability contract, then atomically publishes one manifest referencing both. A CURRENT pointer selects a complete manifest. A killed writer leaves the previous complete checkpoint available. Retention keeps at least two generations and removes only unreferenced artifacts. There is no original-weight replay fallback: recovery from a damaged checkpoint uses an older complete continuation or fails clearly. Concurrency: workers own finalization tasks; one coordinator publishes checkpoints after the required futures complete. A run-level lease rejects a second writer to the same checkpoint directory. Independent module artifacts and independent runs can still be written in parallel. SIGINT/SIGTERM set a stop request handled at a boundary; SIGKILL needs no cleanup handler. Proposed public configuration: model.quantize(calibration, checkpoint=CheckpointConfig(
path="/checkpoints/my-run", resume="auto", every_layers=1, keep_last=2,
))
Implementation sequence:
Validation correction: the earlier local Llama driver lacked a Starting with the boundary extension foundation; this is a staged implementation, not a claim that the existing recovery path is already corrected. |
Keep plan/cursor and boundary hooks in the looper; isolate continuation, lazy GPTQ restoration, immutable storage and signal policy. Validate dense/MoE crash recovery and no-GIL concurrency.
|
Implemented the checkpoint redesign in Implementation
ValidationAll runs below used free-threaded Python 3.14.7 with
Environment caveat: the available free-threaded environment has Transformers 5.5.4, older than this repository's declared requirement, and lacks torchvision. Validation used temporary, uncommitted import-compatibility stubs for torchvision and Explicit initial scopeThe first model adapter supports sequential GPTQ for Llama and Qwen3 MoE with disk offload. Dynamic exclusions, rotation, endpoint quantization, GPTAQ/FOEM, adapters and other model families are rejected explicitly pending their own continuation coverage. Initial calibration capture is reconstructed for identity validation on restart. Checkpoint retention bounds generations; private attempt directories remain available for live model save references. Durability assumes a local filesystem with working atomic rename, locking and directory fsync, not arbitrary network/object storage. Architecture, usage and operational limits are documented in |
Hessian recovery and strict GPU topology follow-upImplemented in Recovery guaranteesUncommitted layer work is discarded. Resume starts fresh Hessian collectors from the last committed layer boundary, replaying that layer's calibration batches and quantization steps. Partial Hessians and sample counts are not restored. The subprocess tests now interrupt both early calibration and late calibration after earlier subsets have already been quantized, using actual SIGKILL. They check fresh zero-count collectors, calibration input hashes, final Hessian hashes/counts, committed cursor position, and byte-exact saved tensors. The dense and small MoE recovery matrix also covers graceful signals and injected errors. On GPU-aware snapshots
Review also found cross-device Hessian summation followed worker arrival order. A three-device simulated regression reproduced different floating-point bits in 4 of 6 arrival permutations. Reduction now follows stable device-index order; all 6 permutations pass. Embedding count reduction uses the same ordering. Validation and limitsTargeted suites: 204 passed, 4 skipped (66 checkpoint/extension/staging; 73 passed + 1 skipped store/concurrency/offload/helpers; 12 passed + 3 skipped topology/device; 53 Hessian/deduplication). Additional overlapping byte-exact recovery rechecks passed. Scoped Ruff and This host has one physical GPU. Actual two-GPU tensor placement and dense/MoE E2E tests are included but explicitly skipped here. Mocked multi-GPU topology rejection, scheduler continuation, and reduction ordering tests passed; these are not a substitute for real multi-GPU execution. Free-threaded tests establish observed behavior, not a universal proof of thread safety or bitwise determinism across all kernels/hardware. Environment caveat: installed Transformers 5.5.4 predates the repository's >=5.14 requirement and torchvision is absent. Runs used temporary, uncommitted import compatibility stubs under |
|
@okdshin Putting this to draft mode as major refractor and full quant method/format compat is currently happening so this branch will be unstable. |
|
@okdshin Ready for testing |
|
@okdshin Going to merge now. Passing local tests.. If you find bugs, please open new Pr. |
Summary
Adds an opt-in, crash-survivable resume path for
GPTQModel.quantize()(envGPTQMODEL_RESUME=1), for sequential GPTQ jobs on very large models that can run tens of hours and previously had to restart from scratch after any crash.Follows up on the discussion in #3015, where @Qubitium asked whether this was going to be upstreamed rather than reimplemented independently.
Design
GPTQMODEL_RESUME=1on the original run makes it resumable (forces synchronous per-layer finalize draining, writesquant_resume_state.jsononce a layer's modules are confirmed durable on disk). The same flag on a restart triggers the actual resume.offload_to_diskusers who never set it get the old async-drain behavior unchanged, at no added cost.Split into 3 commits for review:
fix: make disk-offload writes crash-atomic— prerequisite fix, unrelated to resume itself (the old rmtree-then-write offload scheme had a crash window that could destroy the only durable copy of an already-finalized module).feat: crash-survivable mid-quantization resume— the core mechanism.fix: harden resume fingerprint against calibration/checkpoint drift— closes several ways two different runs could compare as "unchanged" and resume against stale state.Validation
tests/test_resume.py, 47 tests) covering the resume-target/fast-forward decision logic, fingerprint edge cases, and activation-cache branching in isolation.Open questions for maintainers
As noted in the original design: marker file format (
quant_resume_state.json), theGPTQMODEL_RESUMEenv var naming/semantics (single flag gating both "make resumable" and "resume"), and the activation-cache replay-skip semantics are all judgment calls I'd like feedback on before considering this final.