Repository navigation
Make save raise on failure; let save move file-backed data into the output instead of copying it - #25099
Make save raise on failure; let save move file-backed data into the output instead of copying it#25099shoumikhin wants to merge 2 commits into
Conversation
🔗 Helpful Links🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/25099
Note: Links to docs will display an error until the docs builds have been completed. ✅ No FailuresAs of commit 2b2d2e5 with merge base b3b565b ( This comment was automatically generated by Dr. CI and updates every 15 minutes. |
|
@claude review effort=max |
|
Claude finished @shoumikhin's task in 28m 14s —— View job Request changes: the first write to reach a shared PR Review: #25099SummaryThe PR adds a The main problem is the design:
How this was checked: by reading the code and the CPython stdlib sources. The job wasn't allowed to run Python, so nothing was executed. pytorch/TensorRT#5045 was not reviewed. Backward Compatibility
Code Quality
Testing
Performance
Specific Comments
RecommendationRequest Changes What blocks approval:
|
9fab878 to
fcfc152
Compare
|
@claude review effort=max |
Review of PR #25099Request changes: after a consuming save, a second or retried PR Review: #25099SummaryThe PR adds an opt-in The main problems:
Python commands aren't permitted in this job, so the tests weren't run. Everything below comes from reading the code. Code Quality
API Design
Backward Compatibility
Testing
Performance
Specific Comments
RecommendationRequest Changes. Four findings can each lose data that only a full re-export brings back, and each has a small fix:
The Windows test failures will also block CI.
|
fcfc152 to
a97f7bf
Compare
|
@claude review effort=max |
|
Claude finished @shoumikhin's task in 40m 34s —— View job PR Review: #25099
Request Changes: a write after a consuming write is only caught after the output has been opened with Detailed reviewSummaryThe PR adds
Code Quality
Testing
API Design
Performance
Specific Comments
RecommendationRequest Changes Two things block this PR:
The silent copy and the sync per chunk are open to discussion. But on a large model they decide whether |
a97f7bf to
4c8247d
Compare
|
@claude review effort=max |
|
Claude finished @shoumikhin's task in 37m 13s —— View job Review of PR #25099Request Changes: if a consuming Detailed reviewPR Review: #25099SummaryThe PR adds Request Changes, for two reasons:
Points the previous review raised that are still open are marked (still open). I couldn't run the tests because running Python in this job needs approval, so every finding comes from reading the code. Code Quality
Testing
API Design
Performance
Specific Comments
Fix this → (all three) RecommendationRequest Changes. The killed-save problem under Code Quality blocks approval. Because the move writes in place, an interrupted consuming save becomes a Review steps
|
…tead of copying them `compile_and_save` writes each engine to a temporary file as soon as it is built. The ExecuTorch save then copies that file into the `.pte` (or the `.ptd`), and the file is deleted only when the call returns. So while the program is written, every engine is on disk twice. For a model whose engine is tens of gigabytes, the copy needs that much more free disk, and an export can fail on a device that has room for the result. The backend now hands each of these engine files to ExecuTorch as data that owns the file (`FileBackedData(path, cleanup=True)`); it is deleted after the save anyway. Inside `compile_and_save`, the save then writes the program and the `.ptd` with `consume=True`, so ExecuTorch moves each engine into the program: it copies it back to front and truncates the file as it goes, and each engine is on disk once. ExecuTorch does this only when the temporary directory, where the engine files are, is on the same file system as the program; otherwise it copies. With `generate_etrecord`, the save copies the engines instead. The ETRecord reads the engines after the program is written, so the save order stays the same as before, and the program is kept when the ETRecord can't be written. Every ExecuTorch save now passes `consume` (as `False` outside `compile_and_save`), so this needs the `consume` argument from pytorch/executorch#25099. It lands after that change, together with an ExecuTorch pin that has it, which also raises the minimum ExecuTorch version. Test plan: - `tests/py/dynamo/executorch/test_api.py`, CPU, with the lowering stubbed: - New `test_compile_and_save_moves_the_engines`: inside `compile_and_save`'s offload block, the program and the `.ptd` are written with `consume=True`. It fails without this change. - New `test_compile_and_save_keeps_the_program_when_the_etrecord_fails`: in the same block with an ETRecord, the program and the `.ptd` are written first, without `consume`, and the `.pte` is kept when the ETRecord save raises. - New `test_save_executorch_keeps_the_program_when_the_etrecord_fails`: the same for a plain save. - New `test_save_executorch_does_not_consume`: a plain save writes without `consume`. - `tests/py/dynamo/executorch/test_compile_and_save.py` on an A100: - The streaming test now also checks that each engine reaches ExecuTorch as data that owns its file. - New `test_engines_move_into_the_program_without_a_second_copy` checks that each engine file is empty after the program is written. - New `test_an_etrecord_makes_the_save_copy_the_engines`: with an ETRecord, the engine files keep their bytes, and both the `.pte` and the ETRecord are written.
`ExecutorchProgramManager.save` caught any error from the write, logged it and returned normally. `save_pte_program` did the same for the `.pte` and the `.ptd` files, and then returned the path of a file that might be empty or cut short. A caller had no way to notice, short of reading the log. Now both let the error reach the caller. `save` still logs the path when it succeeds. `save_pte_program` now writes the `.pte` through `save` instead of repeating its code, so both paths behave the same way. Behavior change for callers: - `save` and `save_pte_program` raise on a failed write, where they used to log and return. - `save_pte_program` needs an object with `save`, as its type hint already says (`ExecutorchProgramManager`). The legacy `ExecutorchProgram` that the deprecated `exir.capture(...).to_edge().to_executorch()` path returns has no `save`, so passing it now raises `AttributeError` instead of writing the file. Such code can call the legacy object's own `write_to_file` instead. Test plan: `exir/program/test/test_program.py`. New tests check that `save` raises when the write fails, and that `save_pte_program` writes the `.pte` and `.ptd` files, returns the path, and raises when either write fails. Both fail on the code before this change.
`FileBackedData` keeps a large blob, such as a delegate's compiled engine, in a file until the program is written. Writing it copies the file into the `.pte` or `.ptd`. So until the caller deletes the file afterwards, the blob is on disk twice. For an engine of tens of gigabytes, that copy alone can make an export run out of disk. This adds a keyword-only `consume=True` to `ExecutorchProgramManager.save` and `write_tensor_data_to_file`. With it, data that owns its file (`cleanup=True`, as from `move_from`) is moved into the output: - Chunks are copied from the end of the data back to the start, each to its final offset. The source is truncated after each chunk, once the chunk was written in full, flushed and synced. - The part of the output not yet written stays a hole (a sparse file), so the disk holds the moved data once, plus one chunk. On a file system without sparse files the peak is the same as a copy. - It trades time for space: the sync per chunk makes a move several times slower than a copy (3 to 9 times in two measurements on btrfs). - The output is a temporary file next to the final one (`<name>.<random>.partial`, logged before the move starts), renamed onto the final name only when the write succeeds. A save that fails or is killed partway leaves the final name as it was, never a full-length file with missing data. If data was already moved, a note on the error names the temporary file, which holds it; otherwise the temporary file is deleted. An old file at the final name keeps its space until the rename. A symlink at the final name is followed, and the file it names keeps its mode; a hard link at the final name is replaced, as any rename replaces it. - Moved data can't be read or written again, by the program or by the lowered program it came from, so any ETRecord has to be saved first. Reading a `FileBackedData` whose file lost bytes raises, because the check is on the file's size, not a flag. `save` and `write_tensor_data_to_file` run that check before they open any output, so writing again to the same path can't wipe the only copy. They also refuse to write over the data's own file. - A buffer that deduplication put in both the `.pte` and a `.ptd`, or twice in one file, is copied, because no single write knows it is the last one to need it. - A deep copy of a `FileBackedData` is the same object, so a program made from a deep copy of an edge program still owns its data and can move it. `consume` is offered only where the library opens the output itself. `write_to_file` methods, which take a file the caller opened, always copy as before. The source file is opened once, without following links, and judged on what was opened. Anything else gets a copy: data that does not own its file, a link, a file with another hard link or that is not writable, a source on another file system than the output (where the move would free nothing), and Windows, where files are not sparse by default. The save also falls back to a plain copy, exactly as without `consume`, when the final name is not a new or writable regular file (a pipe or a device, for example) or when no temporary file can be created next to it. A move that fails before its first truncation leaves the data intact. One that fails later leaves that source file in place and adds a note to the error that says which bytes are still in it and where the rest is in the temporary file. If earlier buffers or `.ptd` files were already done, notes list them, since they now hold the only copy of what moved into them. Other API changes: `FileBackedData` gains `check()` and a `path` property, and `Cord` gains `file_backed_buffers()`. Test plan: `exir/_serialize/test/test_cord.py` and `exir/program/test/test_program.py`. New tests check: - a test backend whose data is file-backed, lowered through `to_edge_transform_and_lower`: a consuming save and `.ptd` write empty its file, and the data is in the `.pte` (or the `.ptd` when external); the same from a deep copy of the edge program; - the source still holds each range when it reaches the output, and allocated blocks stay well under twice the data (a copy's peak) while the move runs; - a consuming save moves owned data into the `.pte`, and a consuming `.ptd` write after it moves more; a save without `consume` copies and can be repeated; - a consuming save or `.ptd` write stopped partway leaves the final name as it was (absent, or the old file), the note and the log name the temporary file, and one stopped before anything moved leaves no temporary file; a failed multi-file `.ptd` write lists the files already written; a consuming save follows a symlink and keeps the file's mode, and its temporary file never allows more than that mode; it copies into a pipe, into a read-only file (which raises, as without `consume`) and into a directory where no temporary file can be created; - writing again to the same path after a consuming write, also through `save_pte_program`, raises and leaves the first output whole; writing over the data's own file raises, with or without `consume`, and so does a move into the source file itself; - `consume` can't be passed by position; - the copy fallbacks, each checked on both the source and the output: unowned data, a link (whose target stays whole), a second hard link, a read-only file, another file system, Windows, and buffers the write was not asked to move; - a short write, a short read, a failed sync, a failed truncation, a failure on a later chunk (the note's offsets are checked against the bytes), an interrupt right after a truncation, a failed final seek, and a failure in a second buffer after the first was moved; - every read of a moved or changed file raises; a buffer shared by the `.pte` and a `.ptd`, in either order, and one repeated in one file are copied and stay whole; - `save` without `consume` still calls a subclass's own `write_to_file`. The tests of the move itself are skipped on Windows, which always copies. `test_named_data_store.py` and `exir/tests/test_serde.py` also pass.
4c8247d to
2b2d2e5
Compare
…tead of copying them `compile_and_save` writes each engine to a temporary file as soon as it is built. The ExecuTorch save then copies that file into the `.pte` (or the `.ptd`), and the file is deleted only when the call returns. So while the program is written, every engine is on disk twice. For a model whose engine is tens of gigabytes, the copy needs that much more free disk, and an export can fail on a device that has room for the result. The backend now hands each of these engine files to ExecuTorch as data that owns the file (`FileBackedData(path, cleanup=True)`); it is deleted after the save anyway. Inside `compile_and_save`, the program is then written with ExecuTorch's `save(file_path, consume=True)` and the `.ptd` with `write_tensor_data_to_file(..., consume=True)`, so ExecuTorch moves each engine into the program: it copies it back to front and truncates the file as it goes, and each engine is on disk once. ExecuTorch does this only when the temporary directory, where the engine files are, is on the same file system as the program; otherwise it copies. Its `save` takes only a `.pte` path, so a program saved under another name is written as before, with a copy. With `generate_etrecord`, the save copies the engines instead. The ETRecord is written after the program and reads the engines, so a move would leave it nothing to read. The program is still written first, as before, so it is kept when the ETRecord can't be written. The `save` docstring now says that `compile_and_save` can write an ETRecord. This needs the `consume` argument of `save` and `write_tensor_data_to_file` from pytorch/executorch#25099. It lands after that change, together with an ExecuTorch pin that has it, which also raises the minimum ExecuTorch version. Only the move passes `consume`, so every other save calls ExecuTorch exactly as before and keeps working with releases that don't have it. Test plan: - `tests/py/dynamo/executorch/test_api.py`, CPU, with the lowering stubbed: - New `test_compile_and_save_moves_the_engines`: inside `compile_and_save`'s offload block, the program and the `.ptd` are written with `consume=True`. It fails without this change. - New `test_compile_and_save_copies_the_engines_into_a_path_without_pte`: the same block with a `.bin` path writes both without `consume`. - New `test_compile_and_save_keeps_the_program_when_the_etrecord_fails`: in the same block with an ETRecord, the program and the `.ptd` are written first, without `consume`, and the `.pte` is kept when the ETRecord save raises. - New `test_save_executorch_keeps_the_program_when_the_etrecord_fails`: the same for a plain save. - New `test_save_executorch_does_not_consume`: a plain save writes without `consume`. The stubbed ExecuTorch raises TypeError for any `consume` other than `True`, as releases without it do, so this test fails if a plain save passes `consume=False`. - `tests/py/dynamo/executorch/test_compile_and_save.py` on an A100: - The streaming test now also checks that each engine reaches ExecuTorch as data that owns its file. - New `test_engines_move_into_the_program_without_a_second_copy` checks that ExecuTorch copied no engine and that both are in the `.pte`. - New `test_an_etrecord_makes_the_save_copy_the_engines`: with an ETRecord, the engine files keep their bytes, and both the `.pte` and the ETRecord are written.
|
@claude review effort=max |
|
Claude finished @shoumikhin's task in 44m 0s —— View job PR Review: #25099Request Changes: a consuming write that fails or is interrupted can't be retried (even for data it never moved), overwriting an existing output saves no disk space, and data that can't be moved is copied without notice; the other findings are minor. Detailed reviewSummaryThe PR makes API Design
Testing
Performance
Code Quality
Specific Comments
RecommendationRequest Changes. The retry finding blocks approval: a consuming write that fails or is interrupted can't be retried, even for data it never moved. The overwrite finding removes the disk saving for re-exports to the same path. The silent fallback is still open from the earlier reviews. The other findings are minor. Review steps
|
Related: pytorch/TensorRT#5045 uses this to move its compiled engines into the program.
What is wrong today
FileBackedDatakeeps a large blob, such as a delegate's compiled engine, in a file until the program is written. Writing it copies that file into the.pteor.ptd. Until the caller deletes the file afterwards, the blob is on disk twice. For an engine of tens of gigabytes, that copy alone can make an export run out of disk.Separately,
ExecutorchProgramManager.saveandsave_pte_programcatch a failed write, log it and return normally, so a caller can end up with a broken file and no error.What this change does
Commit 1:
saveandsave_pte_programraise when a write fails.save_pte_programnow writes the.ptethroughsave. Behavior change: the legacyExecutorchProgram, which the deprecatedexir.capture(...).to_edge().to_executorch()path returns, has nosave, sosave_pte_programno longer accepts it (its type hint already saysExecutorchProgramManager).Commit 2: a keyword-only
consume=Trueonsaveandwrite_tensor_data_to_file. This is new public API. With it, data that owns its file (cleanup=True, as frommove_from) is moved into the output instead of copied:FileBackedDatais the same object, so a program lowered from a deep-copied edge program can still move its data.write_to_filemethods, which take a file the caller opened, always copy, as before. Anything that can't be moved safely is copied too: data that does not own its file, a link, a file with another hard link or that is not writable, a source on another file system, and Windows. The save also falls back to a plain copy, exactly as withoutconsume, when the final name is not a new or writable regular file (a pipe, for example) or when no temporary file can be created next to it.Other API changes:
FileBackedData.check()andFileBackedData.path,Cord.file_backed_buffers(), and reading aFileBackedDatawhose file changed size raises.Example:
Tests
exir/program/test/test_program.pyandexir/_serialize/test/test_cord.py, on Linux x86_64. Each commit's tests of new behavior fail on the code before it; a few tests in commit 2 check behavior that must not change, and pass on both. They cover a test backend, lowered withto_edge_transform_and_lower, whose data is moved into the.pteor.ptd; the disk peak during a move (allocated blocks must stay under 1.5 times the data, where a copy needs 2 times; 64 MiB of data in 4 MiB chunks, so the holes APFS fills with zeros, up to about 16 MiB, still fit); a save stopped partway; writing again after a consuming write; writing over the data's own file; every copy fallback, checked on both the source and the output; and failures at each step of the move, with the notes checked against the bytes on disk. The tests of the move itself are skipped on Windows, which always copies.