Thanks to visit codestin.com
Credit goes to github.com

Skip to content

gh-155742: Use PyBytesWriter in marshal - #155748

Open
vstinner wants to merge 5 commits into
python:mainfrom
vstinner:marshal
Open

vstinner wants to merge 5 commits into
python:mainfrom
vstinner:marshal

Conversation

@vstinner

@vstinner vstinner commented Aug 13, 2026

Copy link
Copy Markdown
Member

Replace soft deprecated PyBytes_FromStringAndSize() and _PyBytes_Resize() with PyBytesWriter.

Replace soft deprecated PyBytes_FromStringAndSize() and
_PyBytes_Resize() with PyBytesWriter.
No PyBytesWriter is needed.

@serhiy-storchaka serhiy-storchaka left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not think the current code is broken.

Comment thread Python/marshal.c
Comment thread Python/marshal.c
@vstinner

Copy link
Copy Markdown
Member Author

I do not think the current code is broken.

I didn't say that the current code is broken. The PR only just avoids the soft deprecated PyBytes_FromStringAndSize() function.

I reworked the error handling.

@serhiy-storchaka: Please review the updated PR.

@vstinner

Copy link
Copy Markdown
Member Author

I wrote a script to test manually this PR by injecting MemoryError at different places:

import marshal
import io
import _testcapi
obj = b'x' * (1024 * 1024)
file = io.BytesIO()
for i in range(10):
    try:
        try:
            _testcapi.set_nomemory(i)
            res = marshal.dump(obj, file)
        finally:
            _testcapi.remove_mem_hooks()
    except Exception as exc:
        print(f"marshal.dump failed: {exc!r}")
    else:
        print(f"{res=}")

Before, the code failed with an assertion error. With my latest change, the code works is all cases (always raise MemoryError as expected).

@vstinner

vstinner commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

Ah, I noticed that the PyMarshal C API is not tested by test_capi currently. So I wrote PR gh-156890 to add tests.

@vstinner

vstinner commented Sep 13, 2026

Copy link
Copy Markdown
Member Author

I extracted the TYPE_STRING change: it does in fact fix an issue, using PyBytes_FromStringAndSize(str, n) allows getting 1-byte singletons: PR gh-157398.

@serhiy-storchaka

Copy link
Copy Markdown
Member

I do not think we need this change. It looks to me like a code churn which makes the code more complicated.

@vstinner

vstinner commented Sep 14, 2026

Copy link
Copy Markdown
Member Author

UPDATE: Oh sorry, at my first attempt, I ran benchmarks on a debug build! I replaced results with a benchmark on a release build.

I ran a quick benchmark on marshal.dumps():

import pyperf
import marshal

def noop_func():
    pass

runner = pyperf.Runner()
for obj in (b'abc', True, 123):
    runner.bench_func(f'dumps {obj!r}', marshal.dumps, obj)

runner.bench_func('dumps code object', marshal.dumps, noop_func.__code__)
runner.bench_func('dumps list(range(20))', marshal.dumps, list(range(20)))
runner.bench_func("dumps '\u20ac' * 10", marshal.dumps, '\u20ac' * 10)
runner.bench_func("dumps 'long line '*1000", marshal.dumps, 'long line '*1000)

Results:

Benchmark ref change
dumps b'abc' 125 ns 141 ns: 1.12x slower
dumps True 98.3 ns 89.4 ns: 1.10x faster
dumps 123 126 ns 124 ns: 1.02x faster
dumps code object 499 ns 513 ns: 1.03x slower
dumps list(range(20)) 1.01 us 1.05 us: 1.04x slower
dumps '€' * 10 207 ns 228 ns: 1.10x slower
dumps 'long line '*1000 293 ns 367 ns: 1.25x slower
Geometric mean (ref) 1.06x slower

@vstinner

Copy link
Copy Markdown
Member Author

I ran a second benchmark building code objects of the stdlib top 10 largest files:

import pyperf
import marshal
import tokenize

# Top 10 largest stdlib Python files
files = (
    'subprocess.py',
    'pydoc.py',
    'doctest.py',
    'argparse.py',
    'tarfile.py',
    'inspect.py',
    'typing.py',
    'pdb.py',
    'turtle.py',
    '_pydecimal.py',
)

runner = pyperf.Runner()
for filename in files:
    with tokenize.open("Lib/" + filename) as fp:
        code = fp.read()
        code = compile(code, filename, "exec")
        runner.bench_func(f'dumps {filename}', marshal.dumps, code)

Results:

Benchmark stdlib_ref stdlib_change
dumps subprocess.py 101 us 105 us: 1.05x slower
dumps pydoc.py 215 us 236 us: 1.10x slower
dumps doctest.py 127 us 135 us: 1.06x slower
dumps argparse.py 157 us 163 us: 1.04x slower
dumps inspect.py 209 us 240 us: 1.15x slower
dumps typing.py 289 us 242 us: 1.20x faster
dumps pdb.py 262 us 281 us: 1.07x slower
dumps turtle.py 261 us 237 us: 1.10x faster
Geometric mean (ref) 1.01x slower

Benchmark hidden because not significant (2): dumps tarfile.py, dumps _pydecimal.py

@vstinner

Copy link
Copy Markdown
Member Author

Aha, so using marshal.c overallocation (delta = size + 1024) + PyBytesWriter overallocation is less efficient than the current code. I made a tiny change: disable PyBytesWriter overallocation, and now this change makes marshal.dumps() faster.

First benchmark on simple small objects:

Benchmark ref no_overalloc
dumps b'abc' 125 ns 121 ns: 1.04x faster
dumps True 98.3 ns 77.1 ns: 1.28x faster
dumps 123 126 ns 116 ns: 1.09x faster
dumps code object 499 ns 456 ns: 1.09x faster
dumps list(range(20)) 1.01 us 845 ns: 1.19x faster
dumps '€' * 10 207 ns 194 ns: 1.07x faster
dumps 'long line '*1000 293 ns 281 ns: 1.04x faster
Geometric mean (ref) 1.11x faster

Second benchmark on large objects from stdlib modules:

Benchmark stdlib_ref stdlib_no_overalloc
dumps subprocess.py 101 us 90.1 us: 1.12x faster
dumps pydoc.py 215 us 201 us: 1.07x faster
dumps doctest.py 127 us 113 us: 1.12x faster
dumps argparse.py 157 us 141 us: 1.11x faster
dumps tarfile.py 194 us 164 us: 1.19x faster
dumps inspect.py 209 us 180 us: 1.16x faster
dumps typing.py 289 us 262 us: 1.11x faster
dumps pdb.py 262 us 240 us: 1.09x faster
dumps turtle.py 261 us 237 us: 1.10x faster
dumps _pydecimal.py 240 us 216 us: 1.11x faster
Geometric mean (ref) 1.12x faster

@vstinner

Copy link
Copy Markdown
Member Author

I'm surprised that this change makes marshal.dumps() faster. I expected same performance or slower. So I reran the benchmark with CPU isolation. It's still faster on all benchmarks, except of 'long line '*1000 (1.01x slower, minor difference in fact).

First benchmark on simple small objects:

Benchmark ref change
dumps b'abc' 229 ns 225 ns: 1.02x faster
dumps True 174 ns 144 ns: 1.21x faster
dumps 123 223 ns 218 ns: 1.02x faster
dumps code object 877 ns 841 ns: 1.04x faster
dumps list(range(20)) 1.80 us 1.60 us: 1.12x faster
dumps '€' * 10 359 ns 354 ns: 1.01x faster
dumps 'long line '*1000 499 ns 504 ns: 1.01x slower
Geometric mean (ref) 1.06x faster

Second benchmark on large objects from stdlib modules:

Benchmark stdlib_ref stdlib_change
dumps subprocess.py 183 us 169 us: 1.08x faster
dumps pydoc.py 401 us 372 us: 1.08x faster
dumps doctest.py 234 us 214 us: 1.10x faster
dumps argparse.py 286 us 260 us: 1.10x faster
dumps tarfile.py 330 us 300 us: 1.10x faster
dumps inspect.py 332 us 305 us: 1.09x faster
dumps typing.py 456 us 416 us: 1.10x faster
dumps pdb.py 420 us 402 us: 1.05x faster
dumps turtle.py 426 us 384 us: 1.11x faster
dumps _pydecimal.py 385 us 350 us: 1.10x faster
Geometric mean (ref) 1.09x faster

These results are on the latest version of this PR, where the latest commit disables PyBytesWriter overallocation.

@vstinner

Copy link
Copy Markdown
Member Author

marshal.dumps(True) is a single byte (b'T').

  • Currently, dumps() starts with PyBytes_FromStringAndSize(50) and then calls _PyBytes_Resize(1).
  • Using PyBytesWriter, it uses the PyBytesWriter small buffer (256 bytes) and then calls PyBytes_FromStringAndSize(1) which doesn't even allocate an object but returns a singleton. That explains why this case is the fastest (1.21x faster).
  • Previously, the bytes object had to be resized from 50 to 1 bytes. With this change, no bytes object is created.
  • In the common case, PyBytesWriter_Create() doesn't allocate heap memory for the writer, but use the free list, so the allocation is very fast.

I compared the PyBytes calls before/after on _pydecimal.py (1.10x faster)

Before:

  • marshal: 0 => PyBytes_FromStringAndSize(50)
  • w_reserve(): 50 => _PyBytes_Resize(2096)
  • w_reserve(): 2096 => _PyBytes_Resize(5216)
  • w_reserve(): 5216 => _PyBytes_Resize(11456)
  • w_reserve(): 11456 => _PyBytes_Resize(23936)
  • w_reserve(): 23936 => _PyBytes_Resize(48896)
  • w_reserve(): 48896 => _PyBytes_Resize(98816)
  • w_reserve(): 98816 => _PyBytes_Resize(198656)
  • w_reserve(): 198656 => _PyBytes_Resize(398336)
  • marshal final: 398336 => _PyBytes_Resize(228842)
  • dumps(): 8 calls to w_reserve()

After:

  • --- use PyBytesWriter small buffer (256 bytes) ---
  • PyBytesWriter_Resize(): 255 => PyBytes_FromStringAndSize(2096)
  • PyBytesWriter_Resize(): 2096 => _PyBytes_Resize(5216)
  • PyBytesWriter_Resize(): 5216 => _PyBytes_Resize(11456)
  • PyBytesWriter_Resize(): 11456 => _PyBytes_Resize(23936)
  • PyBytesWriter_Resize(): 23936 => _PyBytes_Resize(48896)
  • PyBytesWriter_Resize(): 48896 => _PyBytes_Resize(98816)
  • PyBytesWriter_Resize(): 98816 => _PyBytes_Resize(198656)
  • PyBytesWriter_Resize(): 198656 => _PyBytes_Resize(398336)
  • PyBytesWriter_FinishWithSize(): 398336 => _PyBytes_Resize(228842)
  • dumps(): 8 calls to w_reserve()

The only difference is that using PyBytesWriter, the bytes object is created with 2096 bytes, whereas it's created with 50 bytes currently.


I checked the PyBytes calls before/after on subprocess.py (1.08x faster).

To serialize {"emscripten", "wasi", "ios", "tvos", "watchos"} literal set, w_complex_object() calls _PyMarshal_WriteObjectToString() on each string. Since PyBytesWriter is faster for small objects (up to 256 bytes), it serializes the set literal faster.

Before:

  • marshal: 0 => PyBytes_FromStringAndSize(50)
  • w_reserve(): 50 => _PyBytes_Resize(1128)
  • w_reserve(): 1128 => _PyBytes_Resize(3280)
  • w_reserve(): 3280 => _PyBytes_Resize(7584)
  • w_reserve(): 7584 => _PyBytes_Resize(16192)
  • w_reserve(): 16192 => _PyBytes_Resize(33408)
  • w_reserve(): 33408 => _PyBytes_Resize(67840)
  • w_reserve(): 67840 => _PyBytes_Resize(136704)
    • marshal: 0 => PyBytes_FromStringAndSize(50) -- nested call to serialize a set member (str)
    • marshal final: 50 => _PyBytes_Resize(12)
    • marshal: 0 => PyBytes_FromStringAndSize(50) -- nested call to serialize a set member (str)
    • marshal final: 50 => _PyBytes_Resize(9)
    • marshal: 0 => PyBytes_FromStringAndSize(50) -- nested call to serialize a set member (str)
    • marshal final: 50 => _PyBytes_Resize(6)
    • marshal: 0 => PyBytes_FromStringAndSize(50) -- nested call to serialize a set member (str)
    • marshal final: 50 => _PyBytes_Resize(5)
    • marshal: 0 => PyBytes_FromStringAndSize(50) -- nested call to serialize a set member (str)
    • marshal final: 50 => _PyBytes_Resize(6)
  • marshal final: 136704 => _PyBytes_Resize(94617)
  • dumps(): 7 calls to w_reserve()

After:

  • PyBytesWriter_Resize(): 255 => PyBytes_FromStringAndSize(1128)
  • PyBytesWriter_Resize(): 1128 => _PyBytes_Resize(3280)
  • PyBytesWriter_Resize(): 3280 => _PyBytes_Resize(7584)
  • PyBytesWriter_Resize(): 7584 => _PyBytes_Resize(16192)
  • PyBytesWriter_Resize(): 16192 => _PyBytes_Resize(33408)
  • PyBytesWriter_Resize(): 33408 => _PyBytes_Resize(67840)
  • PyBytesWriter_Resize(): 67840 => _PyBytes_Resize(136704)
  • PyBytesWriter_FinishWithSize(): 136704 => _PyBytes_Resize(94617)
  • dumps(): 7 calls to w_reserve()

@serhiy-storchaka

Copy link
Copy Markdown
Member

I tried to reproduce the benchmark: release build of main vs main + this PR (07d4266), pinned to one core, min of 15 repeats, 3 interleaved rounds.

The only measurable win is for tiny outputs like True (67 → 60 ns), where the 50-byte bytes object is no longer allocated and shrunk. For code objects of stdlib modules the difference is within noise (if anything, 1–2% slower). This agrees with your own trace: the sequence of _PyBytes_Resize() calls is identical except for the first allocation, so there is no reason to expect a 10% difference.

So the performance argument does not hold, and the code is not simpler. wf.writer->overallocate = 0 writes to a private field of a structure which is opaque in the public API. That marshal has to disable the writer's growth policy to keep its own shows that PyBytesWriter is a poor fit here: it is used only as a holder for a bytes object plus a 256-byte stack buffer.

Other notes, in case you want to pursue this anyway:

  • The final PyBytesWriter_Resize(wf.writer, wf.ptr - base) does not shrink the buffer, PyBytesWriter_Finish() does. PyBytesWriter_FinishWithPointer(wf.writer, wf.ptr) would do the same in one call, and there would be no need to resize before discarding on the error path.
  • PyBytesWriter_Create(50) uses only 50 of the 256 bytes of the small buffer. Any output between 51 and 256 bytes still allocates a 1124-byte bytes object and then shrinks it.

I still think that this change is not needed.

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

Labels

awaiting core review interpreter-core (Objects, Python, Grammar, and Parser dirs) skip news

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants