GeezerVisonThe World Wide Web, restored to 800 pixels of glory. | |||||||||||||||||||||||||||||||||||||||||||||||||||
| Home|About|Gaming cabinet|Archives|How it works|Starter sites | |||||||||||||||||||||||||||||||||||||||||||||||||||
GeezerVison is showing github.com — perf: pause the GC while rows are built (optional, stacked on #1039) by vuanhphung · Pull Request #1043 · databricks/databricks-sql-python Conversation
|
| Python | conversion: main / #1039 / this PR | + later GC: main / #1039 / this PR |
|---|---|---|
| 3.9 | 25.9 / 23.7 / 17.8 s | 27.5 / 25.4 / 20.7 s |
| 3.10 | 26.2 / 24.1 / 18.3 s | 27.8 / 25.7 / 21.0 s |
| 3.11 | 24.4 / 22.7 / 14.7 s | 26.6 / 25.0 / 18.8 s |
| 3.12 | 26.5 / 24.5 / 17.4 s | 28.3 / 26.3 / 20.8 s |
| 3.13 | 23.7 / 21.2 / 14.9 s | 26.1 / 23.7 / 19.0 s |
| 3.14 | 21.3 / 19.6 / 13.7 s | 23.5 / 21.9 / 17.7 s |
To check concurrency, four threads converted 1M-row results at once for 90 s while a fifth thread created about 10 MB/s of cyclic garbage:
| Python | per conversion: #1039 / this PR | longest GC-off window | most uncollected garbage: #1039 / this PR |
|---|---|---|---|
| 3.11 | 19.0 / 14.2 s | 1.8 s | 1.6 / 2.9 MB |
| 3.13 | 18.1 / 13.8 s | 2.6 s | 2.0 / 3.4 MB |
| 3.14 | 16.8 / 13.9 s | 2.4 s | 1.3 / 3.1 MB |
The GC was enabled at the end of every run. The longest window exceeds 0.5 s because a thread holding the pause can wait for the GIL while other threads run to_pandas(). By comparison, disabling the GC for a whole conversion gave windows of 22–26 s and 23–27 MB of uncollected garbage.
This pull request and its description were written by Isaac.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
Verdict: 1 Low
Looks good. Building rows with tuple.__new__(Row, v) plus setting __fields__ matches what _create_row does, and the tests check the new rows thoroughly against rows from the factory. One low concern: the shared GC pause re-enables the GC unconditionally, so a concurrent gc.disable() by other code during a pause is undone.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
Verdict: 1 Low
Looks good, with one low test-coverage gap. Building rows with tuple.__new__(Row, v) plus the __fields__ assignment produces the same objects as Row.__call__/_create_row, with a fallback that keeps the too-many-values error. The shared GC pause handles exceptions, overlapping threads, a GC the caller already disabled, and the 0.5 s cap correctly. Nit: the _disable_pandas path still builds rows with ResultRow(*...) and gets no speedup, so the CHANGELOG line could say the speedup applies to the pandas conversion path.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was an error while loading. Please reload this page.
|
Review-body nit: agreed. The |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
✅ No issues identified by the review bot.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
3a03036 to
5721937
Compare
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
✅ No issues identified by the review bot.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
Every Row and its __dict__ is tracked by the cyclic GC, whose collections rescan all rows built so far: about a third of fetchall() conversion time for large results. Pause the GC while each batch of up to 100,000 rows is built. The pause is shared by threads, batches stop joining it once it is 0.5 s old, it never re-enables a GC that was disabled when a batch started, it ends with a young-generation collection, and a fork child resets it. Signed-off-by: Vu Anh Phung <[email protected]> Co-authored-by: Isaac <[email protected]>
5721937 to
056f35a
Compare
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
Verdict: 1 Low
Looks good: one low robustness concern in _GCPause.__enter__. The counter, the 0.5 s join limit, the fork reset and the exception safety all hold up, and the tests cover each guard. Nit: the CHANGELOG says the GC "is re-enabled between batches", but when conversions overlap it is only re-enabled when the last batch in a shared pause finishes.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was an error while loading. Please reload this page.
An async exception (e.g. KeyboardInterrupt) arriving after a batch joined the pause but before __enter__ returned left the pause count at 1, and the GC disabled, for the rest of the process, since __exit__ never runs for a failed __enter__. Roll the join back in that case. The CHANGELOG now says the GC is re-enabled as soon as no batch is being built. Signed-off-by: Vu Anh Phung <[email protected]> Co-authored-by: Isaac <[email protected]>
|
Review-body nit: agreed. When conversions overlap, the GC is only re-enabled when the last batch in a shared pause finishes. In ef58abe the CHANGELOG now says it "is re-enabled as soon as no batch is being built, so between batches unless conversions overlap". |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
Verdict: 2 Low
The pause logic looks correct, with 2 low concerns. A rare interrupt between the counter update and the _joined flag can leave the GC disabled for the whole process, and the CHANGELOG doesn't mention that the pause can re-enable a GC the application disabled while a pause was running.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was an error while loading. Please reload this page.
There was an error while loading. Please reload this page.
__exit__ cleared _joined before taking the lock, so an interrupt at the lock acquisition could skip the decrement. Clear it inside the lock, with no call between it and the decrement (as already in __enter__). The CHANGELOG now says that a gc.disable() made while a batch is being built is undone when the pause ends, while a GC disabled before fetchall() is left alone. Signed-off-by: Vu Anh Phung <[email protected]> Co-authored-by: Isaac <[email protected]>
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
Verdict: 1 Low
Looks good overall, with one low concern. If an interrupt arrives at the end of a pause on Python 3.9, the GC can stay disabled permanently. Otherwise the shared-counter design, the 0.5 s join limit, the fork reset and the exception paths are sound and well tested.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
| self._joined = False | ||
| _GCPause.active -= 1 | ||
| if _GCPause.active: | ||
| return |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Low — The comment says no async exception can split these statements, and the code depends on that. On Python 3.9, which this PR supports, that isn't guaranteed. With computed gotos, 3.9's DISPATCH() checks eval_breaker after almost every opcode, so a pending SIGINT can raise KeyboardInterrupt between any two of these bytecodes. Python 3.10 narrowed those checks to backward jumps and calls.
The risky window is between _GCPause.active -= 1 reaching 0 and gc.enable(). If the interrupt lands there:
active is 0 and the GC stays disabled.__exit__ has no try/finally, so nothing re-enables it.__enter__ sees active == 0 and not gc.isenabled() and refuses to pause, so nothing ever turns the GC back on.The result is a process with the GC off permanently. The window is tiny, but Ctrl-C during a long fetchall() in the main thread is exactly when it could happen, and the damage is silent and process-wide. test_interrupt_while_ending_a_pause_keeps_state_consistent only injects the interrupt after gc.enable() runs, so it doesn't cover this ordering.
A more robust order is to re-enable before giving up the last slot, e.g. if _GCPause.active == 1: gc.enable() and then _GCPause.active -= 1. An interrupt between those lines only leaves active stuck at 1. That costs speed (no more pauses), not GC correctness. Alternatively, wrap the decrement and enable in try/finally.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
f1d40610
Deployed
There was an error while loading. Please reload this page.
Successfully merging this pull request may close these issues.
There was an error while loading. Please reload this page.