Repository navigation
perf(thrift): return inline Arrow result memory to the OS when freed - #1036
vuanhphung wants to merge 4 commits into
Conversation
Each inline FetchResults row set was decompressed into a bytearray grown on the malloc heap. The Arrow table reads it zero-copy, so ~3.7 MB per round trip stayed live there, mixed with short-lived reply buffers; after a large result was released glibc kept almost all of it resident (a 1.7 GB inline fetchall_arrow left ~1.6 GB). Copy each set's stream into a private anonymous mmap instead (pre-faulted with MAP_POPULATE where available), which goes back to the OS when the table is freed. Sets under 128 KiB and mmap failures keep the heap path. Signed-off-by: Vu Anh Phung <vu.phung@databricks.com> Co-authored-by: Isaac <no-reply@databricks.com>
There was a problem hiding this comment.
Verdict: 1 Low
Looks good. There is one low note: the lz4 path now briefly needs about twice the row set's memory while the batches are joined. The mmap handling is otherwise correct: anonymous MAP_PRIVATE with MAP_ANONYMOUS added implicitly by CPython, py_buffer keeps the mapping alive, and failures fall back to the heap. The tests cover the large, small, failure and schema-only paths.
Holding every decompressed batch until the copy needed about twice the row set's memory, and releasing the parts did not help because glibc keeps freed chunks resident. Reserve the map from the compressed size instead (untouched pages cost nothing), copy each batch in as it is decompressed, and grow the map with mremap on overflow. Platforms without mremap continue on the heap. A 51 MB row set now converts with no transient, as on main. Signed-off-by: Vu Anh Phung <vu.phung@databricks.com> Co-authored-by: Isaac <no-reply@databricks.com>
There was a problem hiding this comment.
Verdict: 2 Low
Looks good: the change is correct and well contained. My two notes are both low severity: the tests for the grow and fallback paths don't check that those paths actually run, and the 4x over-reservation is never trimmed after the map is filled.
Release the unused part of the size estimate once the stream is complete, so it no longer counts against a strict overcommit limit while the result is alive. Use the map only where MAP_PRIVATE exists: the heap retention is a glibc behaviour, and Windows commits anonymous maps up front and copies on resize. Tests now assert the growth, shrink, and heap-fallback paths are actually taken. Signed-off-by: Vu Anh Phung <vu.phung@databricks.com> Co-authored-by: Isaac <no-reply@databricks.com>
There was a problem hiding this comment.
Verdict: 2 Low
Looks good: the mmap-backed buffer is correct. Buffer lifetime is tied to the Arrow table through py_buffer, the fallbacks are complete, and the tests are thorough. Two low-severity concerns: each live row set holds its own mapping (mapping-count limit), and the heap fallback when the map can't grow makes extra transient copies.
…a memoryview The heap retention this works around is glibc's, so other platforms keep the previous bytearray. When a map can't grow, copy what was written via a memoryview instead of a temporary bytes slice. Signed-off-by: Vu Anh Phung <vu.phung@databricks.com> Co-authored-by: Isaac <no-reply@databricks.com>
There was a problem hiding this comment.
Verdict: 1 Low · 2 Nit
The mmap-backed stream buffer is correct: Thrift's arrowBatches is always a list, so iterating it twice is safe, and resizing only happens before the buffer is handed to pyarrow, so BufferError can't occur. My three findings are all low: some new tests break on non-Linux machines, the grow-or-fall-back step is more aggressive than it needs to be, and the keep-alive test can't actually fail.
Other findings
- ⚪ Nit — Several new tests assume the mmap path is taken, but none of them patch
_INLINE_ARROW_USE_MMAPtoTrue: test_large_set_is_read_from_a_private_memory_mapcallsmmap_spy.assert_called_once().test_map_grows_...reads_RecordingMap.resizes[-1], which raisesIndexErrorwhen the list is empty.test_map_is_shrunk_...assertslen(resizes) == 1.test_falls_back_...assertsattemptsis non-empty.
On macOS or Windows the flag is False, so these tests fail. CI runs only on Linux, so this won't break CI, but contributors running pytest tests/unit locally on a Mac will see failures. Fix: mark them @pytest.mark.skipif(not sys.platform.startswith("linux"), ...), or patch _INLINE_ARROW_USE_MMAP=True and skip only when mmap.MAP_PRIVATE is missing.
- ⚪ Nit —
test_table_keeps_the_mapping_aliveonly deletesbatchesandschema_bytes. Neither of them backs the mapping: the_ArrowStreamBufferand itsmmapare locals insideconvert_arrow_based_set_to_arrow_tableand are already unreachable once it returns. So the test passes as long as the call returns at all, and doesn't show that the table's buffer is what keeps the map alive. A stronger version would hold aweakrefto the mmap object (via the spy/subclass pattern used in the other tests), assert it is still alive whileresultexists, and assert it is collected afterdel result; gc.collect(). That would also cover the claim that memory is returned when the table is freed.
| def append(self, data: bytes) -> None: | ||
| end = self._size + len(data) | ||
| if self._map is not None and end > len(self._map): | ||
| try: |
There was a problem hiding this comment.
🔵 Low — On overflow the map grows straight to max(end, 2 * len(self._map)). If that doubled size fails (strict overcommit, ENOMEM), the code never retries with the smaller end. It copies the whole stream onto the heap instead, and for a moment both the map and the bytearray hold a full copy, so the reported peak-memory guarantee doesn't hold. Retrying resize(end) before falling back would make the fallback rarer and keep peak memory closer to what the PR describes.
What type of PR is this?
Description
After a large inline Arrow result on Thrift is released (for example with
use_cloud_fetch=False), the process keeps almost all of its memory. A 1.7 GBfetchall_arrow()left about 1.6 GB resident on the live warehouse. It is not a leak: after freeing a 562 MB result, glibc'smalloc_stats()shows 590 MB of arena memory with 25 MB in use, andmalloc_trim(0)(as a diagnostic only) brings RSS back to baseline. Each FetchResults row set (25 batches, ~3.7 MB here) was concatenated into abytearraygrown on the malloc heap. The Arrow table reads it zero-copy, so these buffers live as long as the result, interleaved with short-lived reply buffers, and glibc can't give the space back once something above them stays allocated.This PR builds each row set's IPC stream in its own private anonymous
mmap, which goes back to the OS when the table is released. The map is reserved from the compressed size (4x estimate; untouched pages cost no memory) and filled batch by batch as each batch is decompressed, so peak memory per row set is the same as before. If the estimate is too small, the map grows viamremap; once the stream is complete it is shrunk to its exact size. The map is used on Linux only, where glibc showed the retention. Streams under 128 KiB (glibc's default mmap threshold),mmapfailures, and other platforms use the oldbytearray. Decompression, the IPC read and errors are unchanged. Onlyconvert_arrow_based_set_to_arrow_tableinutils.pychanges. #1032 uses the same technique for CloudFetch files; the two are independent, and this PR carries its own small mmap helper becausecloudfetch/downloader.pycan't importutils.py.How is this tested?
New unit tests cover large sets (lz4 on and off) read from a private map, growth when the estimate is too small, the final shrink, the heap fallback when the map can't grow or
mmapfails, non-Linux platforms, small sets staying on the heap, schema-only sets, and the table keeping its map alive after the inputs are gone. They also pass on pyarrow 14.0.2. The full unit suite passes. E2E: theTestPySQLCoreSuitefetch and Arrow tests (inline and CloudFetch) andtest_complex_types.pypass. Manually, inline results (lz4 on and off) matched CloudFetch results for 2M rows of mixed types (nulls, decimals, timestamps, dates, arrays, structs, maps) viafetchall_arrow,fetchmany_arrowandfetchall.Live Thrift warehouse,
use_cloud_fetch=False, 3 fresh processes per cell interleaved withmain, median RSS increase over the pre-query baseline:fetchall_arrow()110 MBfetchall_arrow()562 MBfetchall_arrow()1.7 GBfetchmany_arrow(100k)loop, 562 MBfetchall()rows, 110 MBConversion time, measured in-process during the same runs, is unchanged: 2.36 s vs 2.28 s for the 1.7 GB result. A single 51 MB row set (wide rows) converts with no transient memory on both main and this PR (31 ms vs 32-33 ms).
Related Tickets & Documents
Same technique as #1032 (CloudFetch). Both edit CHANGELOG.md under Unreleased, so whichever merges second has a one-line conflict there.
This pull request and its description were written by Isaac.