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 — gh-161242: Cache strerror() messages per errno by cmaloney · Pull Request #161455 · python/cpython

Conversation

@cmaloney

@cmaloney cmaloney commented Nov 6, 2026 •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown
Contributor

_Py_strerror decodes strerror_r with the locale codec on every call, a large part of the cost of raising OSError. Cache the result for codes 0-255 in a per-interpreter dict; 256 just to keep things from growing unbounded. Error strings are locale specific so _locale.setlocale clears it.

EDIT: Updated Perf numbers with tuple cache key:

PGO+LTO, main vs this change:

Benchmark main strerror cache
os.strerror(2) 128 ns 29.1 ns: 4.41x faster
stat_missing() 948 ns 766 ns: 1.24x faster
open_missing() 1.35 us 1.16 us: 1.16x faster

raise_oserror() was not significantly different.

import os
import pyperf

def stat_missing():
    try:
        os.stat('/nonexistent')
    except OSError:
        pass

def open_missing():
    try:
        open('/nonexistent', 'rb')
    except OSError:
        pass

def raise_oserror():
    try:
        raise OSError(2, 'No such file or directory')
    except OSError:
        pass

runner = pyperf.Runner()
runner.bench_func("stat_missing()", stat_missing)
runner.bench_func("open_missing()", open_missing)
runner.bench_func("raise_oserror()", raise_oserror)
runner.bench_func("os.strerror(2)", os.strerror, 2)

Co-Authored-By: Claude Opus 5.5 [email protected]

  • Issue: Improve performance of exceptions #161242

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
@cmaloney @claude
pythongh-161242: Cache strerror() messages per errno
98e6d6e
_Py_strerror() decodes strerror_r() with the locale codec on every call,
a large part of the cost of raising OSError.  Cache the result for codes
0-255 in a per-interpreter dict; _locale.setlocale() clears it.

PGO+LTO, main vs this change:

| Benchmark       | main    | strerror cache        |
|-----------------|:-------:|:---------------------:|
| os.strerror(2)  | 130 ns  | 21.9 ns: 5.93x faster |
| stat_missing()  | 935 ns  | 724 ns: 1.29x faster  |
| open_missing()  | 1.35 us | 1.15 us: 1.17x faster |
| raise_oserror() | 105 ns  | 107 ns: 1.02x slower  |

Benchmark:

```python
import os
import pyperf

def stat_missing():
    try:
        os.stat('/nonexistent')
    except OSError:
        pass

def open_missing():
    try:
        open('/nonexistent', 'rb')
    except OSError:
        pass

def raise_oserror():
    try:
        raise OSError(2, 'No such file or directory')
    except OSError:
        pass

runner = pyperf.Runner()
runner.bench_func("stat_missing()", stat_missing)
runner.bench_func("open_missing()", open_missing)
runner.bench_func("raise_oserror()", raise_oserror)
runner.bench_func("os.strerror(2)", os.strerror, 2)
```

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@cmaloney
cmaloney requested a review from vstinner November 6, 2026 00:42
@cmaloney
cmaloney requested a review from iritkatriel as a code owner November 6, 2026 00:42
@bedevere-app bedevere-app Bot added awaiting core review type-feature A feature request or enhancement labels Nov 6, 2026
@bedevere-app bedevere-app Bot mentioned this pull request Nov 6, 2026
Improve performance of exceptions #161242
Open
@cmaloney cmaloney added the skip news label Nov 6, 2026
@cmaloney @claude
Use open() of a missing file in test_strerror_cached
1629843
os.stat('') doesn't raise on WASI.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@vstinner

vstinner commented Nov 6, 2026

Copy link
Copy Markdown
Member

Error strings are locale specific so _locale.setlocale clears it.

A C library can change the LC_CTYPE locale. Or when Python is embedded in an application, the application can change the LC_CTYPE locale (without telling Python).

Such optimization would (only?) be safe if it checks if the LC_CTYPE locale changed. Either check setlocale(LC_CTYPE, NULL) string or check nl_langinfo(CODESET) string. I suggest checking nl_langinfo(CODESET).

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@cmaloney
Clear cache when LC_MESSAGES changes.
53d9206
Locale can be changed without telling the interpreter and the error
strings should ideally pick it up. Use `LC_MESSAGES` as that is what
glibc and `man 3 strerror` point to for glibc linux.

For windows use LC_ALL since there is no LC_MESSAGES.
@cmaloney

cmaloney commented Nov 7, 2026 •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown
Contributor Author

This is looking like it needs two cache keys (CODESET + LC_MESSAGES) if we need to invalidate when things outside of python change locale but don't tell Python about it directly... which gets to significant added complexity.

This is safe in terms of "python won't crash". It's not great in "locale may not match exactly". Open to suggestions that keep the complexity from significantly expanding (cutting 20% off each FileNotFoundError when we start python and it looks for .pyc would be really nice)

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@vstinner

vstinner commented Nov 7, 2026

Copy link
Copy Markdown
Member

I don't know if it's feasible, but I always wanted to defer when OSError message are formatted. In short, store components of the error message (like error number, optional path), and only render the message string if it's requested. In the majority of cases, the message is never requested.

One pain point is OSError.args which contains the message, so it should be a getter function. But BaseException constructor stores its positional arguments as the self->args attribute. I'm not sure how to deal with that.

If that would be possible, it should be faster than cached os.strerror().

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@cmaloney

cmaloney commented Nov 7, 2026 •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown
Contributor Author

I'll put this in draft and experiment a bit :). Planning to make the BaseException Argument Clinic Vectorcall shortly which moves performance relative to 3.15 a bit and changes the tuple args handling some.

As part of the posix -> _os I was looking at ntpath vs posixpath and other than some constants the key performance difference is ntpath uses Windows APIs to return "exists" and the like without requiring the exception while posixpath does not. A new primitive could potentially help there as well.... That said "exception on open" is the only safe/secure way to do a lot of things (check than open runs into timing attack issues)

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@cmaloney
cmaloney marked this pull request as draft November 7, 2026 03:53
@bedevere-app bedevere-app Bot removed the awaiting core review label Nov 7, 2026
@vstinner

vstinner commented Nov 7, 2026

Copy link
Copy Markdown
Member

Another different approach: optimize PyUnicode_DecodeLocale() for UTF-8 (most common locale encoding): vstinner#21. My hack makes the function 3.2x faster and makes os.strerror() 2.0x faster.

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@cmaloney

cmaloney commented Nov 7, 2026

Copy link
Copy Markdown
Contributor Author

Have a PR that helps with that #161147 :). Although mainly focused on "We load large mainly-ascii text files a lot". Definitely very aligned here, just a matter of finding what is going to be shippable simplifications. For me generally aiming for do less work -> it runs faster. Eliminate a whole stack of things most direct, optimize them longer path but also very doable.

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@cmaloney

cmaloney commented Nov 8, 2026

Copy link
Copy Markdown
Contributor Author

Making args lazy is hard because PyException_HEAD is publicly exposed and only soft deprecated (a member going from never NULL to starting NULL with helper feels like a bad silent break to me)

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@vstinner

vstinner commented Nov 8, 2026

Copy link
Copy Markdown
Member

Making args lazy is hard because PyException_HEAD is publicly exposed and only soft deprecated (a member going from never NULL to starting NULL with helper feels like a bad silent break to me)

Are there projects reading the args member using the C API? Maybe we can put a placeholder value (like None or "error") until os.strerror() is called lazily.

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

cmaloney and others added 2 commits November 9, 2026 18:27
@cmaloney @claude
Validate the strerror cache against the current locale
ffcbf0f
C code can change the locale without locale.setlocale(), so make strerror_cache
a tuple of nl_langinfo(CODESET), setlocale(LC_MESSAGES, NULL) and the message
dict, or None until it is first built. Each lookup compares both names against
the current locale and replaces the tuple on a mismatch, and locale.setlocale()
no longer clears the cache.

Reading and replacing the tuple take a short critical section on free-threaded
builds, since Py_SETREF() isn't atomic.

PGO+LTO, main vs this change:

| Benchmark      | main    | strerror cache        |
|----------------|:-------:|:---------------------:|
| os.strerror(2) | 128 ns  | 29.1 ns: 4.41x faster |
| stat_missing() | 948 ns  | 766 ns: 1.24x faster  |
| open_missing() | 1.35 us | 1.16 us: 1.16x faster |

raise_oserror() was not significantly different.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@cmaloney @claude
Test a real locale change in test_strerror_cached
98bfddf
Python never sets LC_MESSAGES at startup, so the test always started in
"C" and only checked that the cache was reused. Now it switches from "C"
to "C.UTF-8", where available, and checks that the cache is rebuilt.

Also set strerror_cache to None first thing in _PyExc_InitState().

Co-Authored-By: Claude Opus 5.5 <[email protected]>
vstinner
vstinner reviewed Nov 10, 2026
View reviewed changes
Comment thread Python/fileutils.c
Comment on lines +3415 to +3416
cache = state->strerror_cache;
Py_IncRef(cache);

@vstinner vstinner Nov 10, 2026

Copy link
Copy Markdown
Member

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.

Suggested change
cache = state->strerror_cache;
Py_IncRef(cache);
cache = Py_NewRef(state->strerror_cache);

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
Comment thread Python/fileutils.c
Comment on lines +3432 to +3435
PyTuple_SET_ITEM(cache, 0,
PyUnicode_DecodeLatin1(codeset, strlen(codeset), NULL));
PyTuple_SET_ITEM(cache, 1,
PyUnicode_DecodeLatin1(messages, strlen(messages), NULL));

@vstinner vstinner Nov 10, 2026

Copy link
Copy Markdown
Member

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.

If you use latin1, you don't really care of the encoding. In this case, why not using bytes which may be a little bit faster? PyBytes_FromStringAndSize().

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
Comment thread Python/fileutils.c
// on LC_MESSAGES (language) and the LC_CTYPE codeset (decoding). Windows
// has neither, and its messages are ASCII.
#if defined(HAVE_LANGINFO_H) && defined(CODESET)
const char *codeset = nl_langinfo(CODESET);

@vstinner vstinner Nov 10, 2026

Copy link
Copy Markdown
Member

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.

You should look _Py_GetLocaleEncoding() for a more complete (correct) implementation: need to check _Py_FORCE_UTF8_LOCALE and use "utf-8" if nl_langinfo() returns NULL or an empty string.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
Comment thread Python/fileutils.c
// C code can change the locale without locale.setlocale(). Messages depend
// on LC_MESSAGES (language) and the LC_CTYPE codeset (decoding). Windows
// has neither, and its messages are ASCII.
#if defined(HAVE_LANGINFO_H) && defined(CODESET)

@vstinner vstinner Nov 10, 2026

Copy link
Copy Markdown
Member

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.

According to _Py_GetLocaleEncoding(), Python doesn't both with checking defined(HAVE_LANGINFO_H) && defined(CODESET): it just uses it in all platforms but Windows. So #ifdef MS_WINDOWS should be enough.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
Comment thread Python/fileutils.c
static PyObject *
strerror_ensure_cache(struct _Py_exc_state *state)
{
// C code can change the locale without locale.setlocale(). Messages depend

@vstinner vstinner Nov 10, 2026

Copy link
Copy Markdown
Member

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.

Suggested change
// C code can change the locale without locale.setlocale(). Messages depend
// C code can change the locale without calling Python locale.setlocale(). Messages depend

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
Comment thread Python/fileutils.c
// NULL.
PyObject*
_Py_strerror(int code)
static PyObject *

@vstinner vstinner Nov 10, 2026

Copy link
Copy Markdown
Member

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.

Can you keep the comment please?

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
Comment thread Python/fileutils.c
PyObject*
_Py_strerror(int code)
{
if (code < 0 || code >= 256) {

@vstinner vstinner Nov 10, 2026

Copy link
Copy Markdown
Member

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.

Is it really need to exclude some codes? Maybe just remove that, no?

The result is not cached if decode_strerror() fails anyway.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
Comment thread Lib/test/test_exception_hierarchy.py
def strerror():
with self.assertRaises(FileNotFoundError) as cm:
open("some_hopefully_non_existing_file")
return cm.exception.strerror

@vstinner vstinner Nov 10, 2026

Copy link
Copy Markdown
Member

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.

Why not testing os.sterror() here?

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
Comment thread Lib/test/test_exception_hierarchy.py
locale.setlocale(locale.LC_MESSAGES, "C")
message = strerror()
try:
locale.setlocale(locale.LC_MESSAGES, "C.UTF-8")

@vstinner vstinner Nov 10, 2026

Copy link
Copy Markdown
Member

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.

You may try more locales since locale names change a lot depending on the operating system (and also between Linux distributions? not sure). For example, on my Fedora 45 this locale is called "C.utf8". See maybe test_locale and test__locale for locale names.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
Comment thread Include/internal/pycore_exceptions.h
// The dict mapping from errno codes to OSError subclasses
PyObject *errnomap;
// Tuple of the locale's identity and its _Py_strerror() messages, or None
// if not built yet, see strerror_ensure_cache()

@vstinner vstinner Nov 10, 2026

Copy link
Copy Markdown
Member

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.

You may describe the format here: (codeset, LC_MESSAGES, {code: message}).

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
@vstinner

vstinner commented Nov 10, 2026

Copy link
Copy Markdown
Member

os.strerror(2) 128 ns 29.1 ns: 4.41x faster

I'm honestlty surprised that calling nl_langinfo(CODESET) and setlocale(LC_MESSAGES, NULL) doesn't add overhead. Well, it's nice that it can be optimized!

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

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

Reviewers

@vstinner vstinner vstinner left review comments
@iritkatriel iritkatriel Awaiting requested review from iritkatriel iritkatriel is a code owner

Uh oh!

There was an error while loading. Please reload this page.

Assignees

No one assigned

Labels

skip news type-feature A feature request or enhancement

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

Uh oh!

There was an error while loading. Please reload this page.

2 participants

@cmaloney @vstinner
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.
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.