GeezerVisonThe World Wide Web, restored to 800 pixels of glory. | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Home|About|Gaming cabinet|Archives|How it works|Starter sites | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
GeezerVison is showing github.com — gh-161242: Cache strerror() messages per errno by cmaloney · Pull Request #161455 · python/cpython Conversation
|
| 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]
Sorry, something went wrong.
There was an error while loading. Please reload this page.
_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]>
os.stat('') doesn't raise on WASI.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
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 |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
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.
There was an error while loading. Please reload this page.
|
This is looking like it needs two cache keys ( 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 |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
|
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 If that would be possible, it should be faster than cached os.strerror(). |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was an error while loading. Please reload this page.
|
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 |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
|
Another different approach: optimize |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
|
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. |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
|
Making |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
Are there projects reading the args member using the C API? Maybe we can put a placeholder value (like None or |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
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]>
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]>
| cache = state->strerror_cache; | ||
| Py_IncRef(cache); |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
| cache = state->strerror_cache; | |
| Py_IncRef(cache); | |
| cache = Py_NewRef(state->strerror_cache); |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
| PyTuple_SET_ITEM(cache, 0, | ||
| PyUnicode_DecodeLatin1(codeset, strlen(codeset), NULL)); | ||
| PyTuple_SET_ITEM(cache, 1, | ||
| PyUnicode_DecodeLatin1(messages, strlen(messages), NULL)); |
There was a problem 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.
There was an error while loading. Please reload this page.
| // 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); |
There was a problem 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.
There was an error while loading. Please reload this page.
| // 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) |
There was a problem 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.
There was an error while loading. Please reload this page.
| static PyObject * | ||
| strerror_ensure_cache(struct _Py_exc_state *state) | ||
| { | ||
| // C code can change the locale without locale.setlocale(). Messages depend |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
| // 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.
There was an error while loading. Please reload this page.
| // NULL. | ||
| PyObject* | ||
| _Py_strerror(int code) | ||
| static PyObject * |
There was a problem 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.
There was an error while loading. Please reload this page.
| PyObject* | ||
| _Py_strerror(int code) | ||
| { | ||
| if (code < 0 || code >= 256) { |
There was a problem 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.
There was an error while loading. Please reload this page.
| def strerror(): | ||
| with self.assertRaises(FileNotFoundError) as cm: | ||
| open("some_hopefully_non_existing_file") | ||
| return cm.exception.strerror |
There was a problem 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.
There was an error while loading. Please reload this page.
| locale.setlocale(locale.LC_MESSAGES, "C") | ||
| message = strerror() | ||
| try: | ||
| locale.setlocale(locale.LC_MESSAGES, "C.UTF-8") |
There was a problem 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.
There was an error while loading. Please reload this page.
| // 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() |
There was a problem 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.
There was an error while loading. Please reload this page.
I'm honestlty surprised that calling |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was an error while loading. Please reload this page.
Successfully merging this pull request may close these issues.
There was an error while loading. Please reload this page.