Repository navigation
Keep two tests stable under xdist and machine load, and judge the GIL scan tests one way #760
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
el-marvin
wants to merge
2
commits into
duckdb:v2.0-cyanoptera
Choose a base branch
from
evertlammerts:fix-flaky-tests
base: v2.0-cyanoptera
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.
+155
−114
Open
Changes from all commits
Commits
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
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,77 @@ | ||
| """Measures how much a scan stalls when a Python UDF holds the GIL at its first row. | ||
|
|
||
| A scan that takes the GIL for every vector waits out the hold on every thread but the one that holds it; a scan that | ||
| reads without the GIL does not. The profiler's TABLE_SCAN timing is wall time summed over the threads that ran the | ||
| scan, so it grows by the time those threads waited. | ||
| """ | ||
|
|
||
| import ctypes | ||
| import json | ||
|
|
||
| import duckdb | ||
|
|
||
| HOLD_SECONDS = 0.2 | ||
| ROUNDS = 5 | ||
|
|
||
|
|
||
| def scan_seconds(source, held_row, threads): | ||
| """Summed TABLE_SCAN seconds of one scan of the source, and how many times the UDF ran. | ||
|
|
||
| The UDF holds the GIL at the row whose ``i`` equals ``held_row``; a row the source does not have, such as -1, | ||
| gives a free run of the same query. | ||
| """ | ||
| libc = ctypes.PyDLL(None) | ||
| libc.usleep.argtypes = [ctypes.c_uint] | ||
| calls = [0] | ||
|
|
||
| # A call through PyDLL keeps the GIL for the whole sleep. | ||
| def hold_gil(value): | ||
| calls[0] += 1 | ||
| libc.usleep(int(HOLD_SECONDS * 1_000_000)) | ||
| return value | ||
|
|
||
| with duckdb.connect(config={"threads": threads}) as con: | ||
| con.create_function("hold_gil", hold_gil, ["BIGINT"], "BIGINT") | ||
| con.register("src", source) | ||
| con.execute("PRAGMA enable_profiling = 'no_output'") | ||
| con.execute(f"SELECT count(x), sum(CASE WHEN i = {held_row} THEN hold_gil(i) ELSE i END) FROM src").fetchall() | ||
| operators = list(json.loads(con.get_profiling_information())["operator"]) | ||
| seconds = 0.0 | ||
| while operators: | ||
| operator = operators.pop() | ||
| if operator["type"] == "TABLE_SCAN": | ||
| seconds += operator["timing"] | ||
| operators.extend(operator.get("children", [])) | ||
| return seconds, calls[0] | ||
|
|
||
|
|
||
| class ScanTimings: | ||
| """Held and free scan times of one source, one of each per round, and the UDF calls each pair of runs made.""" | ||
|
|
||
| def __init__(self, threads) -> None: | ||
| self.threads = threads | ||
| self.held = [] | ||
| self.free = [] | ||
| self.calls = set() | ||
|
|
||
| def add_round(self, source): | ||
| held, held_calls = scan_seconds(source, 0, self.threads) | ||
| free, free_calls = scan_seconds(source, -1, self.threads) | ||
| self.held.append(held) | ||
| self.free.append(free) | ||
| self.calls.add((held_calls, free_calls)) | ||
|
|
||
| def growth_in_holds(self): | ||
| """The fastest held run minus the fastest free run, for bounding a stall from above. | ||
|
|
||
| Load adds time to any run, so each kind counts at its fastest. | ||
| """ | ||
| return (min(self.held) - min(self.free)) / HOLD_SECONDS | ||
|
|
||
| def best_round_in_holds(self): | ||
| """The largest held minus free difference of one round, for bounding a stall from below. | ||
|
|
||
| One round that shows the stall is enough; load can take stall away from a held run when fewer threads wait | ||
| out the hold, so the fastest held run would understate it. | ||
| """ | ||
| return max(held - free for held, free in zip(self.held, self.free, strict=True)) / HOLD_SECONDS |
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.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TestGil'sobject_framelower bound still fails under a pinned one core load, so the description compares round 1's "4 of 24" with eight clean runs made under a different load.Severity: minor. Category: docs. Evidence: ran. Fix: containment. Origin: repository. Base: pre-existing.
The description sets the old judgement's "4 of 24 runs" on one core beside sixteen busy loops (my round 1 figure, made by pinning the test and the loops to one core with
taskset) against "eight of eight runs under that load" for the new one, which were made under a one CPU quota. The two loads behave differently for this bound. Eight repeats of this parameter each:assert 0.4780491800000003 >= 1, 7 passed.assert 0.9154351050000004 >= 1, 7 passed.In twelve recorded rounds of the same object column and query, pinned, the best of five rounds fell under one hold in 2 of 8 windows at 16 loops (lowest 0.99) and in 3 of 8 at 32 loops (lowest 0.73), where the description says it "reads at least 1.91 holds" over every recorded window. That figure holds for the quota recordings, not for these. The base's best of three fell under one hold in 4 of 10 and 5 of 10 windows of the same recordings, so the change makes this bound more robust, not less.
The cause is the measurement under saturation. When the four scan threads share one core with the loops, a thread that waits for the GIL would have waited for the CPU anyway, so the hold adds little summed scan time to the held run. The str test reads that state as inconclusive and skips (4 of 8 windows of the same recording); this bound fails.
Ask: say in the description which load the eight clean runs and the 1.91 figure come from, and that pinned to one core this bound still fails about one run in eight as the base does. Or, since one round that shows the stall is enough, stop the
object_frameloop at the first round that reaches one hold and allow more rounds before failing, which is also faster in the ordinary case.