Repository navigation
perf(thrift): decompress many-batch inline Arrow results in one call - #1037
vuanhphung wants to merge 4 commits into
Conversation
The warehouse returns small inline results as dozens of LZ4 frames (72 for both 100 and 1,000 rows of range()). Decompressing each frame with lz4.frame.decompress releases and reacquires the GIL per frame, so with many busy threads a 1,000-row statement made ~600 context switches and spent most of its time waiting for the GIL. For three or more compressed batches, decode the concatenated frames with one pyarrow CompressedInputStream read. One- and two-batch results keep the python-lz4 path, as does any pyarrow build without the lz4 codec. Co-authored-by: Isaac <no-reply@databricks.com> Signed-off-by: Vu Anh Phung <vu.phung@databricks.com>
There was a problem hiding this comment.
Verdict: 1 Low · 1 Nit
Looks good: the change is well-scoped and well-tested, with two low-severity points. One is that the exception type for corrupt payloads now depends on how many batches the result has; the other is a test that assumes the environment's pyarrow has the lz4 codec.
Other findings
- ⚪ Nit —
test_convert_arrow_based_set_decodes_many_lz4_batches_in_one_callexpects(72, 0)decompress calls without checkingutils._PYARROW_HAS_LZ4. On a pyarrow build without the lz4 codec (exactly the case the fallback handles), this test fails instead of skipping. Pin the flag withpatch.object(utils, "_PYARROW_HAS_LZ4", True)plus a skip whenpyarrow.Codec.is_available("lz4")is False, or just skip when the codec is missing.
pyarrow raises OSError where python-lz4 raises RuntimeError, so the error type depended on how many batches a result had. Also skip the one-call test on pyarrow builds without the lz4 codec. Co-authored-by: Isaac <no-reply@databricks.com> Signed-off-by: Vu Anh Phung <vu.phung@databricks.com>
|
Nit from the review body (one-call test assumes the lz4 codec): fixed in 2140f9d. |
There was a problem hiding this comment.
Verdict: 2 Low
Looks good overall. The batched LZ4 path is correct and tested with real compressed data, and the fallback paths are unchanged. I have two low concerns: real out-of-memory errors get reported as decompression failures, and corruption in a middle frame isn't tested.
ArrowMemoryError subclasses both MemoryError and ArrowException, so an allocation failure was reported as an LZ4 decompression error. Also cover truncated and corrupted middle frames, with checksums as the warehouse sets them. Co-authored-by: Isaac <no-reply@databricks.com> Signed-off-by: Vu Anh Phung <vu.phung@databricks.com>
There was a problem hiding this comment.
Verdict: 3 Low
No blocking issues: the single-call decode is correctly limited to 3+ LZ4 batches and falls back to the old path when pyarrow lacks the lz4 codec. The error types match the old path (MemoryError is re-raised unwrapped; other decode errors become RuntimeError). I found 3 low-severity items: a test that will fail on pyarrow builds without lz4, a decode stream that is never closed, and a difference from main on malformed frames.
| ) | ||
|
|
||
| def test_convert_arrow_based_set_lz4_memory_error_is_not_wrapped(self): | ||
| _, arrow_batches, schema = self._arrow_set(72, 14, True) |
There was a problem hiding this comment.
🔵 Low — test_convert_arrow_based_set_lz4_memory_error_is_not_wrapped patches pyarrow.CompressedInputStream, but that call only happens when _PYARROW_HAS_LZ4 is true. On a pyarrow build without the lz4 codec, the function uses the lz4.frame.decompress fallback and returns normally, so assertRaises(MemoryError) fails. Your PR description lists this setup as supported. test_convert_arrow_based_set_decodes_many_lz4_batches_in_one_call is already guarded; this test needs the same @unittest.skipUnless(utils._PYARROW_HAS_LZ4, ...) decorator.
There was a problem hiding this comment.
Fixed in c6891ff: test_convert_arrow_based_set_lz4_memory_error_is_not_wrapped now has the same @unittest.skipUnless(utils._PYARROW_HAS_LZ4, ...) guard.
There was a problem hiding this comment.
I checked the current head and the fix isn't there yet. The only @unittest.skipUnless(utils._PYARROW_HAS_LZ4, ...) in tests/unit/test_thrift_backend.py is at line 2238, which guards test_convert_arrow_based_set_decodes_many_lz4_batches_in_one_call. Line 2291, just above test_convert_arrow_based_set_lz4_memory_error_is_not_wrapped at line 2292, has no guard. Maybe c6891ff hasn't been pushed to this branch, or the decorator ended up on a different test. Once the guard is directly above that test on the PR head, this thread is resolved.
Close the CompressedInputStream with a with block so its buffers are freed even when an exception traceback keeps the frame alive. Skip the memory error test without the lz4 codec, test truncated frames without checksums, and note that each batch is one LZ4 frame. Co-authored-by: Isaac <no-reply@databricks.com> Signed-off-by: Vu Anh Phung <vu.phung@databricks.com>
There was a problem hiding this comment.
Verdict: 1 Low
Looks good overall, with one low-severity note. The many-batch LZ4 path is well tested against real compressed data: 1, 2, 3 and 72 batches, corrupted frames, MemoryError passthrough, the no-codec fallback, and outputs much larger than the first read size. Errors are mapped to RuntimeError so the error type stays the same on both paths.
What type of PR is this?
Description
The warehouse returns small inline results as dozens of LZ4-compressed Arrow batches:
range(100)andrange(1000)both arrive as 72 batches.convert_arrow_based_set_to_arrow_tabledecompressed each batch withlz4.frame.decompress, which releases and reacquires the GIL on every call. When many threads run statements, each reacquire waits behind the other runnable threads. Against a mock that replays the captured reply with 50 ms RTT, a 1,000-row statement at 64 threads made ~600 voluntary context switches and used 5.3 ms of CPU, half of it sys time (1.4 ms with one thread), and throughput stalled at ~390 statements/s. Replaying the decompressed bytes from a dict, with no C call, removed the effect, which confirmed the cause.For three or more compressed batches, the frames are now concatenated and decoded with one
pyarrow.CompressedInputStreamread. Arrow decodes concatenated LZ4 frames natively; python-lz4's one-shotdecompresssilently returns only the first frame. One- and two-batch results such asSELECT 1, uncompressed results, and pyarrow builds without the lz4 codec keep the existing path. CloudFetch, SEA and the kernel backend are unchanged.Trade-off: Arrow takes the stream's decode buffers from pyarrow's memory pool, which keeps a heap per thread, so peak RSS grows by about 0.25 MB per busy thread (+21 MB at 64 threads, +50 MB at 200 in the mock runs).
How is this tested?
New unit tests decode real LZ4-compressed Arrow batches (1, 2, 3 and 72 batches, compressed and not, a highly compressible result that needs several reads, and the no-codec fallback) and check that 72 batches no longer call
lz4.frame.decompress(72 calls onmain). They pass on pyarrow 14.0.1, 23.0.1 and 26.0.1, and the full unit suite passes. Live e2e (test_driver.pycore, large wide/narrow and async suites): 132 passed; the 6test_staging_ingestion_*failures are a missing ingestion user in my environment (stage://tmp/None/...permission denied) and fail the same way onmain.Performance:
mainvs this branch in fresh, interleaved processes, median of 5 runs, 1,000-row result, one connection and a cursor per statement per thread. Mock = captured warehouse reply with 50 ms RTT; live = shared warehouse.fetchall_arrowfetchall_arrowfetchall_arrowfetchallfetchall_arrowfetchallOn the live warehouse, 64-thread
fetchall_arrowand all 1- and 16-thread runs were capped by the shared warehouse, and the higher live p99 at 200 threads coincides with ~30% more load on it.SELECT 1(one batch, unchanged path) and the kernel backend measured the same on both sides.Related Tickets & Documents
None.
This pull request and its description were written by Isaac.