Skip to content

gh-159191: Add a fast path to bytes.join() for exact bytes items - #159096

Open
christianaurichzm wants to merge 3 commits into
python:mainfrom
christianaurichzm:gh-158803-join-borrow
Open

christianaurichzm wants to merge 3 commits into
python:mainfrom
christianaurichzm:gh-158803-join-borrow

Conversation

@christianaurichzm

@christianaurichzm christianaurichzm commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Follow-up to #158910 (see #158910 (comment)).

When every item is exact bytes and the result is below the 1 MiB threshold for releasing the GIL, join() now copies straight from the items, without filling a Py_buffer or taking a reference for each one. Nothing in that path runs Python code or suspends the critical section, so the list keeps the items alive, as in str.join(). Other inputs take the general path, which releases exact bytes items with a plain decref since bytes has no bf_releasebuffer. The fast path is @eendebakpt's (#159096 (comment)).

Release builds, b",".join(seq) with distinct 8-byte items, median of 7 runs:

FT main FT PR default main default PR
list of 10 170 ns 92 ns 138 ns 79 ns
list of 1000 11.0 µs 4.3 µs 10.0 µs 4.0 µs
list of 1000, half bytearray 17.6 µs 17.3 µs 13.2 µs 12.0 µs
999 bytes + 1 bytearray 11.5 µs 10.7 µs 10.1 µs 10.1 µs
8 threads joining one shared list of 100 0.60 s 0.18 s

For reference, the 8-thread case was 0.35-0.55 s before #158910.

The existing __buffer__ mutation test used an immortal b'a', so it couldn't catch a freed item; it now uses bytes(2). The new FT test covers the 1 MiB path, where the critical section is suspended during the copy, and test_join now covers a one-byte separator, which the fast path stores directly. -R 3:3 passes on debug default and FT builds, and TSan is clean for test_free_threading, test_bytes and test_capi.test_bytes.

Co-authored-by: Pieter Eendebak pieter.eendebak@gmail.com

… locked

bytes.join() and bytearray.join() took a new reference to every exact
bytes item. In the free-threaded build that is an atomic operation on
objects shared between threads, and since pythonGH-158910 it runs while the
list's lock is held.

The critical section keeps the items alive, so borrow them, as
_PyUnicode_JoinArray() does. References are taken only before something
can suspend the critical section: PyObject_GetBuffer() on an item that
is not bytes, or releasing the thread state to copy a large result.
@eendebakpt

Copy link
Copy Markdown
Contributor

@christianaurichzm We can do better by not using the buffers[i] in the case of exact bytes. Core idea: main...eendebakpt:cpython:bytes-join-exact-fast. That gives a speedup of a factor 2x in mainy common cases.

Due to some binary layout changes the uncommon case becomes 10% slower. I was still investigating whether we can resolve that. (if not, I think the PR as is works also).

(feel free to take the branch)

When every item is an exact bytes object and the result is below the
1 MiB threshold, copy straight from the items, without filling a
Py_buffer or taking a reference for each one. The caller's critical
section keeps the items alive.

Items could only stay borrowed under the previous commit in that same
case, so the per-item borrow bookkeeping is dropped and the general
path takes references as before.

In the general path, release exact bytes items with a plain decref,
since bytes has no bf_releasebuffer.

Co-authored-by: Pieter Eendebak <pieter.eendebak@gmail.com>
@christianaurichzm christianaurichzm changed the title gh-158803: Borrow bytes items in bytes.join() while the list is locked gh-158803: Add a fast path to bytes.join() for exact bytes items Oct 11, 2026
@christianaurichzm

Copy link
Copy Markdown
Contributor Author

Thanks, I pulled it in (ad4a180) and added you as co-author.

With your fast path, the borrowing from my first commit doesn't buy anything anymore. Items only stayed borrowed when the whole list was exact bytes under 1 MiB, and that case now goes through the fast path. So I removed it, and the general path takes references like on main again. Other than that, I reworded the fast-path comment a bit and added a test with a one-byte separator.

I can't reproduce the 10% slowdown on the uncommon cases. Release builds, b",".join(seq) with distinct 8-byte items, median of 7:

FT main FT first commit FT now default main default now
list of 10 170 ns 132 ns 92 ns 138 ns 79 ns
list of 1000 11.0 µs 7.7 µs 4.3 µs 10.0 µs 4.0 µs
1000, half bytearray 17.6 µs 17.2 µs 17.3 µs 13.2 µs 12.0 µs
999 bytes + 1 bytearray 11.5 µs 11.5 µs 10.7 µs 10.1 µs 10.1 µs
8 threads, one shared list of 100 0.60 s 0.36 s 0.18 s

The Py_DECREF in the cleanup loop matters, by the way: without it, "999 bytes + 1 bytearray" was 7-8% slower than main on both builds.

@eendebakpt

Copy link
Copy Markdown
Contributor

I can't reproduce the 10% slowdown on the uncommon cases. Release builds, b",".join(seq) with distinct 8-byte items, median of 7:

For me the 10% slowdown was due to the compiler no longer inlining the PyBuffer_Release. This is system dependent, so it could very well be why you do not observe it. I suspect the bytes.join for non-exact bytes is not executed in the PGO builds. I think 10% regression is fine though for the the uncommon case (and the common case gaining a factor 2).

@eendebakpt

eendebakpt commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

@christianaurichzm Can you create a new issue for this? The PR is not really related to the issue linked to now.
@vstinner This is the followup from the other bytes.join optimization.

@christianaurichzm christianaurichzm changed the title gh-158803: Add a fast path to bytes.join() for exact bytes items gh-159191: Add a fast path to bytes.join() for exact bytes items Oct 11, 2026
@bedevere-app bedevere-app Bot added the type-feature A feature request or enhancement label Oct 11, 2026
@christianaurichzm

Copy link
Copy Markdown
Contributor Author

Done, opened gh-159191 and moved the PR there. And thanks for the PyBuffer_Release inlining explanation, that makes sense.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

awaiting review type-feature A feature request or enhancement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants