-
Notifications
You must be signed in to change notification settings - Fork 57
fix[next]: make OTF build caches crash-consistent #2691
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
havogt
wants to merge
7
commits into
GridTools:main
Choose a base branch
from
havogt:fix/crash-consistent-cache
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
f0522af
fix[next]: make OTF build caches crash-consistent
havogt c620624
Merge upstream/main (compile/load split #2587)
havogt fbc600f
fix[next]: crash-consistent compiledb template cache (F6)
havogt c03c671
docs[next]: condense ADR 0024 to the recovery mechanisms
havogt 1f35e51
style[next]: drop working failure-mode labels from docs and tests
havogt 811dec4
refactor[core]: rename cache_utils to file_utils per review
havogt 61d72a0
Merge remote-tracking branch 'upstream/main' into fix/crash-consisten…
havogt File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
103 changes: 103 additions & 0 deletions
103
docs/development/ADRs/next/0024-Crash_Consistent_Build_Caches.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| --- | ||
| tags: [] | ||
| --- | ||
|
|
||
| # [Crash-Consistent Build Caches] | ||
|
|
||
| - **Status**: valid | ||
| - **Authors**: Hannes Vogt (@havogt) | ||
| - **Created**: 2026-06-22 | ||
| - **Updated**: 2026-07-03 | ||
|
|
||
| In the context of the `gt4py.next` OTF build caches, facing user reports that | ||
| an interrupted pipeline (Ctrl-C, `SIGKILL`/OOM, full disk) can leave a cache | ||
| entry that a key lookup reports as a HIT but whose payload is truncated or | ||
| missing — so the next run fails on load instead of rebuilding — we decided to | ||
| make every cache write an **atomic publish** (temp sibling + `os.replace`) and | ||
| every cache read **validate + self-heal** (any load failure → treat as miss → | ||
| rebuild). We considered relying on the file lock, `fsync`-ing writes, and | ||
| whole-directory renames, and accept that a hard power loss may still require | ||
| regenerating the last in-flight entry. | ||
|
|
||
| ## Context | ||
|
|
||
| [ADR 0023](0023-Fingerprinting.md) made cache *keys* correct, but a key only | ||
| answers "is there an entry for this input?" — not whether the entry's payload | ||
| is complete. Both backends use two independent fingerprint-keyed layers, each | ||
| with its own present-but-broken states: | ||
|
|
||
| | Layer | Broken state after an interruption | Old behavior on re-run | | ||
| | ------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------ | | ||
| | translation cache (`FileCache`) | truncated pickle | `EOFError`/`UnpicklingError` aborts | | ||
| | gtfn build folder | truncated `gt4py.json` | `JSONDecodeError` aborts | | ||
| | gtfn build folder | status `COMPILED`, module deleted (e.g. scratch cleanup) | `CompilationError` | | ||
| | dace build folder | truncated `lib<name>.so` (kill mid-link) | accepted by dace's existence-only `use_cache` gate; `dlopen` fails on every `load()` | | ||
| | compiledb template (shared) | truncated `compile_commands.json` | `JSONDecodeError` for *every* program with that configuration | | ||
|
|
||
| (A build interrupted *before* reaching `COMPILED` already resumes correctly via | ||
| the gtfn status machine and must keep doing so.) | ||
|
|
||
| The file lock is orthogonal: it serializes concurrent builders but a killed | ||
| lock holder releases the lock and leaves its partial entry behind. Mature | ||
| compiler caches (CPython `.pyc`, Triton, TorchInductor, Numba, ccache, sccache) | ||
| all converge on the same write-atomically / validate-on-read combination used | ||
| here. | ||
|
|
||
| ## Decision | ||
|
|
||
| A shared helper `gt4py._core.file_utils.atomic_write_bytes/_text` writes to a | ||
| uniquely-named sibling temp file and `os.replace`s it into place, so a reader | ||
| sees old-or-new content, never a torn file. The temp file is created with a | ||
| plain `open` so the result keeps umask-derived permissions. No `fsync`: it only | ||
| adds power-loss durability, costs real latency on parallel filesystems, and | ||
| validate-on-read absorbs the rare torn write. | ||
|
|
||
| Per layer: | ||
|
|
||
| - **Translation cache** — `FileCache.__setitem__` publishes atomically; | ||
| `__getitem__` treats any unpicklable entry (`EOFError`, `UnpicklingError`, | ||
| `OSError`, and `AttributeError`/`ImportError` from version skew) as a miss, | ||
| evicting the file and raising `KeyError` so `CachedStep` recomputes. | ||
| - **gtfn build folder** — `build_data.write_data` publishes atomically, so the | ||
| `COMPILED` status is a proper commit marker written last; `read_data` returns | ||
| `None` for unreadable metadata. The cache-HIT gate is a single predicate | ||
| `is_usable(data, src_dir)` = status `COMPILED` *and* module file present; | ||
| anything else rebuilds (preserving the partial-build resume states). | ||
| - **compiledb template** — published atomically; `_cc_find_compiledb` | ||
| validates the JSON on HIT and evicts an unreadable template so it is | ||
| regenerated under the existing lock. | ||
| - **dace build folder** — dace's `use_cache` gate accepts a library on | ||
| mere existence, and the compile step never loads it | ||
| (`sdfg.compile(return_program_handle=False)` since #2587), so a truncated | ||
| library survives until `DaCeCompilationArtifact.load()` — which has neither | ||
| the build lock nor the dace config context to recompile. Instead, the locked | ||
| compile step writes a `.gt4py_compile_complete` marker **after** each | ||
| completed compile (and removes it before compiling); if the marker is absent, | ||
| the previous build was interrupted and the stale `lib<name>.*` / | ||
| `libdacestub_<name>.*` files are deleted so dace rebuilds them. A *missing* | ||
| library needs no handling: `SDFG.regenerate_code` defaults to `True`, so dace | ||
| regenerates it anyway. | ||
|
|
||
| ## Alternatives considered | ||
|
|
||
| - **Lock-only / status quo**: gives no crash consistency (see Context). | ||
| - **Whole-directory atomic publish**: strongest guarantee, but fights the | ||
| in-place CMake/Ninja incremental builds, dace's `build_folder` binding, and | ||
| the partial-build resume behavior. Remains an option if marker + self-heal | ||
| proves insufficient. | ||
| - **`fsync` on every write**: protects only against power loss, not the | ||
| reported process-kill cases; rejected as default (a knob can be added if | ||
| power-loss corruption is ever observed). | ||
| - **Self-heal on load failure for dace**: worked before #2587, but the | ||
| compile/load split moved loading out of the step that can rebuild. | ||
|
|
||
| ## Consequences | ||
|
|
||
| - An interrupted run no longer poisons the cache; the affected entry is rebuilt | ||
| transparently on the next run. Version-skew pickles also become clean misses. | ||
| - Corrupt entries are evicted silently (`_core` has no logging infrastructure, | ||
| and per-entry warnings would spam multi-rank runs); a persistent failure | ||
| still surfaces as rebuild-then-error, not a loop. | ||
| - Known gaps: a gtfn module that is present but truncated *after* a completed | ||
| build (torn write at power loss) is not self-healed; orphaned `.tmp` files | ||
| from kills between write and rename are not swept. |
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This file is so generic that could belong to |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| # GT4Py - GridTools Framework | ||
| # | ||
| # Copyright (c) 2014-2024, ETH Zurich | ||
| # All rights reserved. | ||
| # | ||
| # Please, refer to the LICENSE file in the root directory. | ||
| # SPDX-License-Identifier: BSD-3-Clause | ||
|
|
||
| """Helpers for atomic file writes.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import os | ||
| import pathlib | ||
| import uuid | ||
|
|
||
|
|
||
| def atomic_write_bytes(target: str | os.PathLike, data: bytes) -> None: | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we unit test this? |
||
| """Write ``data`` to ``target`` atomically. | ||
|
|
||
| The bytes are written to a temporary file in ``target``'s directory and then | ||
| renamed into place with ``os.replace``. A reader (or the next process after a | ||
| crash) therefore sees either the previous contents or the complete new | ||
| contents, never a half-written file. The temporary file is a sibling of | ||
| ``target`` so the rename stays on a single filesystem, and is created with a | ||
| plain ``open`` so the result gets the usual umask-derived permissions | ||
| (``tempfile.mkstemp`` would pin it to ``0600``). | ||
|
|
||
| This protects against process kill / Ctrl-C / OOM during the write. It does | ||
| *not* fsync, so a power loss may still lose the entry (callers tolerate this | ||
| by treating an unreadable entry as a cache miss). | ||
| """ | ||
| target = pathlib.Path(target) | ||
| tmp = target.parent / f".{target.name}.{uuid.uuid4().hex}.tmp" | ||
| try: | ||
| with open(tmp, "wb") as f: | ||
| f.write(data) | ||
| os.replace(tmp, target) | ||
| except BaseException: | ||
| tmp.unlink(missing_ok=True) | ||
| raise | ||
|
|
||
|
|
||
| def atomic_write_text(target: str | os.PathLike, text: str, *, encoding: str = "utf-8") -> None: | ||
| """Write ``text`` to ``target`` atomically (see `atomic_write_bytes`).""" | ||
| atomic_write_bytes(target, text.encode(encoding)) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.