GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — Use a managed dict for the _io types · Issue #161184 · python/cpython

Use a managed dict for the _io types #161184

New issue
Copy link
New issue
Copy link
Open
Open
Use a managed dict for the _io types#161184
Copy link
Labels
extension-modulesC modules in the Modules dirC modules in the Modules dirperformancePerformance or resource usagePerformance or resource usagetopic-IOtype-featureA feature request or enhancementA feature request or enhancement

Description

@eendebakpt
eendebakpt
opened on Nov 2, 2026
Issue body actions

Feature or enhancement

_io._IOBase and its C subclasses (BufferedReader/Writer/Random/RWPair, BytesIO, FileIO, StringIO, TextIOWrapper, _WindowsConsoleIO) each carry an explicit PyObject *dict field exposed through __dictoffset__. This has two costs:

  • Every instance attribute write allocates a real dict. open() always does this: (e.g. FileIO gets name, TextIOWrapper gets mode, etc.)
  • Instance attribute access never specialises. specialize_dict_access bails with SPEC_FAIL_ATTR_NOT_MANAGED_DICT for types without Py_TPFLAGS_MANAGED_DICT, so LOAD_ATTR/STORE_ATTR stay generic. Because the legacy tp_dictoffset is inherited, this applies to every Python subclass of an io base as well.

Switching _IOBase to Py_TPFLAGS_MANAGED_DICT (as #160616 did for ast.AST) removes the eight dict fields and the __dictoffset__ members; the flag is inherited by subclasses.

Benchmarks

Benchmark main branch
http.client: HTTPResponse(sock).begin() + read() of a 2-byte body 50.0 us 46.4 us: 1.08x faster
http.client: read() of a chunked body (1000 chunks) 1.63 ms 1.34 ms: 1.22x faster
http.client: read(100) x 1000 650 us 411 us: 1.58x faster
http.client: for line in response (1000 lines) 724 us 453 us: 1.60x faster
zipfile: zf.read(name) for 200 small members 2.55 ms 1.99 ms: 1.28x faster
zipfile: for line in zf.open(name) (1000 lines) 583 us 403 us: 1.44x faster
zipfile: ZipExtFile.read(100) x 1000 545 us 369 us: 1.48x faster
gzip: GzipFile.read(100) x 1000 497 us 382 us: 1.30x faster
gzip: GzipFile.write(100 bytes) x 1000 646 us 519 us: 1.24x faster
tempfile: SpooledTemporaryFile.write(100 bytes) x 1000 340 us 241 us: 1.41x faster
urllib3: HTTPResponse.read(100) x 1000 3.94 ms 3.12 ms: 1.26x faster
urllib3: for chunk in response.stream(100) 4.93 ms 4.12 ms: 1.20x faster
control (list(range(20000))) 1.36 ms 1.41 ms: 1.03x slower
Geometric mean 1.30x faster

The gain comes from LOAD_ATTR/STORE_ATTR on self specialising in the Python-level read/readline/write methods of these classes, and from __init__ no longer allocating and growing a dict per instance. The C types themselves are not the target; for them the change is close to neutral.

Benchmark script
"""Real-world code paths that run Python methods on io subclasses."""
import gzip, io, sys, tempfile, zipfile, pyperf
import http.client
from urllib3.response import HTTPResponse as U3Response

CHUNK = 100
N = 1000
BODY = bytes(range(256)) * (CHUNK * N // 256)          # ~100 KB
LINES = b"".join(b"%06d: some text on this line, to be read\n" % i for i in range(N))

class FakeSocket:
    def __init__(self, data):
        self.data = data
    def makefile(self, mode):
        return io.BytesIO(self.data)
    def close(self):
        pass

HEADERS = (b"HTTP/1.1 200 OK\r\nServer: bench\r\nDate: Mon, 02 Nov 2026 10:00:00 GMT\r\n"
           b"Content-Type: text/plain\r\nCache-Control: no-cache\r\n"
           b"Connection: keep-alive\r\nX-Request-Id: 0123456789abcdef\r\n")

def chunked_body(data, size):
    out = []
    for i in range(0, len(data), size):
        c = data[i:i + size]
        out.append(b"%x\r\n%s\r\n" % (len(c), c))
    out.append(b"0\r\n\r\n")
    return b"".join(out)

RESP_CL = HEADERS + b"Content-Length: %d\r\n\r\n" % len(BODY) + BODY
RESP_LINES = HEADERS + b"Content-Length: %d\r\n\r\n" % len(LINES) + LINES
RESP_CHUNKED = HEADERS + b"Transfer-Encoding: chunked\r\n\r\n" + chunked_body(BODY, CHUNK)
RESP_SMALL = HEADERS + b"Content-Length: 2\r\n\r\nok"

def _response(raw):
    r = http.client.HTTPResponse(FakeSocket(raw), method="GET")
    r.begin()
    return r

def httpclient_begin(loops):
    """HTTPResponse(sock).begin(): one per HTTP request."""
    r = range(loops); t0 = pyperf.perf_counter()
    for _ in r:
        _response(RESP_SMALL).read()
    return pyperf.perf_counter() - t0

def httpclient_read_chunked(loops):
    """read() of a chunked body with 1000 100-byte chunks."""
    r = range(loops); t0 = pyperf.perf_counter()
    for _ in r:
        _response(RESP_CHUNKED).read()
    return pyperf.perf_counter() - t0

def httpclient_read_small(loops):
    """read(100) x 1000 on a Content-Length body."""
    r = range(loops); t0 = pyperf.perf_counter()
    for _ in r:
        resp = _response(RESP_CL)
        read = resp.read
        while read(CHUNK):
            pass
    return pyperf.perf_counter() - t0

def httpclient_iter_lines(loops):
    """for line in response: (1000 lines)."""
    r = range(loops); t0 = pyperf.perf_counter()
    for _ in r:
        for line in _response(RESP_LINES):
            pass
    return pyperf.perf_counter() - t0

# --- zipfile ---------------------------------------------------------------
_zbuf = io.BytesIO()
with zipfile.ZipFile(_zbuf, "w", zipfile.ZIP_DEFLATED) as zf:
    for i in range(200):
        zf.writestr("member%03d.txt" % i, b"x" * 100)
    zf.writestr("lines.txt", LINES)
    zf.writestr("body.bin", BODY)
ZF = zipfile.ZipFile(_zbuf)
MEMBERS = [n for n in ZF.namelist() if n.startswith("member")]

def zipfile_read_members(loops):
    """zf.read(name) for 200 small members (ZipExtFile created per member)."""
    r = range(loops); read = ZF.read; t0 = pyperf.perf_counter()
    for _ in r:
        for name in MEMBERS:
            read(name)
    return pyperf.perf_counter() - t0

def zipfile_iter_lines(loops):
    """for line in zf.open(name): (1000 lines)."""
    r = range(loops); t0 = pyperf.perf_counter()
    for _ in r:
        with ZF.open("lines.txt") as f:
            for line in f:
                pass
    return pyperf.perf_counter() - t0

def zipfile_read_small(loops):
    """f.read(100) x 1000 from a deflated member."""
    r = range(loops); t0 = pyperf.perf_counter()
    for _ in r:
        with ZF.open("body.bin") as f:
            read = f.read
            while read(CHUNK):
                pass
    return pyperf.perf_counter() - t0

# --- gzip -------------------------------------------------------------------
GZ = gzip.compress(BODY)

def gzip_read_small(loops):
    """GzipFile.read(100) x 1000."""
    r = range(loops); t0 = pyperf.perf_counter()
    for _ in r:
        with gzip.GzipFile(fileobj=io.BytesIO(GZ)) as f:
            read = f.read
            while read(CHUNK):
                pass
    return pyperf.perf_counter() - t0

def gzip_write_small(loops):
    """GzipFile.write(100 bytes) x 1000."""
    r = range(loops); piece = BODY[:CHUNK]; t0 = pyperf.perf_counter()
    for _ in r:
        with gzip.GzipFile(fileobj=io.BytesIO(), mode="wb") as f:
            write = f.write
            for _ in range(N):
                write(piece)
    return pyperf.perf_counter() - t0

# --- tempfile ---------------------------------------------------------------
def spooled_write_small(loops):
    """SpooledTemporaryFile.write(100 bytes) x 1000 (stays in memory)."""
    r = range(loops); piece = BODY[:CHUNK]; t0 = pyperf.perf_counter()
    for _ in r:
        with tempfile.SpooledTemporaryFile(max_size=10**7) as f:
            write = f.write
            for _ in range(N):
                write(piece)
    return pyperf.perf_counter() - t0

# --- urllib3 ----------------------------------------------------------------
def _u3():
    return U3Response(body=io.BytesIO(BODY), headers={"content-length": str(len(BODY))},
                      status=200, preload_content=False, decode_content=True)

def urllib3_read_small(loops):
    """urllib3 HTTPResponse.read(100) x 1000."""
    r = range(loops); t0 = pyperf.perf_counter()
    for _ in r:
        resp = _u3(); read = resp.read
        while read(CHUNK):
            pass
    return pyperf.perf_counter() - t0

def urllib3_stream(loops):
    """for chunk in resp.stream(100): (1000 chunks)."""
    r = range(loops); t0 = pyperf.perf_counter()
    for _ in r:
        for chunk in _u3().stream(CHUNK):
            pass
    return pyperf.perf_counter() - t0

def control_list(loops):
    """Unaffected code: noise floor of the two-binary comparison."""
    r = range(loops); q = range(20000); t0 = pyperf.perf_counter()
    for _ in r:
        list(q); list(q); list(q); list(q); list(q)
    return pyperf.perf_counter() - t0

BENCHES = (httpclient_begin, httpclient_read_chunked, httpclient_read_small, httpclient_iter_lines,
           zipfile_read_members, zipfile_iter_lines, zipfile_read_small,
           gzip_read_small, gzip_write_small, spooled_write_small,
           urllib3_read_small, urllib3_stream, control_list)

if __name__ == "__main__":
    if "--check" in sys.argv:
        for f in BENCHES:
            print(f.__name__, "%.3f ms" % (f(1) * 1e3))
        sys.exit()
    runner = pyperf.Runner()
    for f in BENCHES:
        runner.bench_time_func(f.__name__, f)

PR: https://github.com/eendebakpt/cpython/tree/io-managed-dict (branch, PR to follow)

Has this already been discussed elsewhere?

No response given

Links to previous discussion of this feature:

No response

Linked PRs

  • gh-161184: Use a managed dict and weakref list for the _io types #161236

Activity

  1. eendebakpt
    added
    type-featureA feature request or enhancementA feature request or enhancement
    on Nov 2, 2026
  2. NekoAsakura
    added
    performancePerformance or resource usagePerformance or resource usage
    extension-modulesC modules in the Modules dirC modules in the Modules dir
    topic-IO
    on Nov 2, 2026
  3. cmaloney commented on Nov 2, 2026

    @cmaloney
    cmaloney
    on Nov 2, 2026
    Contributor
    More actions

    Overall 👍 to this. Wonder if should move to Py_TPFLAGS_MANAGED_WEAKREF as part of the same change. Will need to make sure __IOBase_closed and the _finalizing attributes keep working right especially in late interpreter shutdown.

    Subclasses that set __dictoffset__ will need an update / is a compatibility break but I think that is reasonable.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    extension-modulesC modules in the Modules dirC modules in the Modules dirperformancePerformance or resource usagePerformance or resource usagetopic-IOtype-featureA feature request or enhancementA feature request or enhancement

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      You are visitor 01402503 — GeezerVison does not store the pages you look up.
      Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.