Repository navigation
[C API] Add an efficient public PyUnicodeWriter API #119182
Description
Feature or enhancement
Creating a Python string object in an efficient way is complicated. Python has private _PyUnicodeWriter API. It's being used by these projects:
Affected projects (5):
- Cython (3.0.9)
- asyncpg (0.29.0)
- catboost (1.2.3)
- frozendict (2.4.0)
- immutables (0.20)
I propose making the API public to promote it and help C extensions maintainers to write more efficient code to create Python string objects.
API:
typedef struct PyUnicodeWriter PyUnicodeWriter;
PyAPI_FUNC(PyUnicodeWriter*) PyUnicodeWriter_Create(void);
PyAPI_FUNC(void) PyUnicodeWriter_Discard(PyUnicodeWriter *writer);
PyAPI_FUNC(PyObject*) PyUnicodeWriter_Finish(PyUnicodeWriter *writer);
PyAPI_FUNC(void) PyUnicodeWriter_SetOverallocate(
PyUnicodeWriter *writer,
int overallocate);
PyAPI_FUNC(int) PyUnicodeWriter_WriteChar(
PyUnicodeWriter *writer,
Py_UCS4 ch);
PyAPI_FUNC(int) PyUnicodeWriter_WriteUTF8(
PyUnicodeWriter *writer,
const char *str, // decoded from UTF-8
Py_ssize_t len); // use strlen() if len < 0
PyAPI_FUNC(int) PyUnicodeWriter_Format(
PyUnicodeWriter *writer,
const char *format,
...);
// Write str(obj)
PyAPI_FUNC(int) PyUnicodeWriter_WriteStr(
PyUnicodeWriter *writer,
PyObject *obj);
// Write repr(obj)
PyAPI_FUNC(int) PyUnicodeWriter_WriteRepr(
PyUnicodeWriter *writer,
PyObject *obj);
// Write str[start:end]
PyAPI_FUNC(int) PyUnicodeWriter_WriteSubstring(
PyUnicodeWriter *writer,
PyObject *str,
Py_ssize_t start,
Py_ssize_t end);The internal writer buffer is overallocated by default. PyUnicodeWriter_Finish() truncates the buffer to the exact size if the buffer was overallocated.
Overallocation reduces the cost of exponential complexity when adding short strings in a loop. Use PyUnicodeWriter_SetOverallocate(writer, 0) to disable overallocation just before the last write.
The writer takes care of the internal buffer kind: Py_UCS1 (latin1), Py_UCS2 (BMP) or Py_UCS4 (full Unicode Character Set). It also implements an optimization if a single write is made using PyUnicodeWriter_WriteStr(): it returns the string unchanged without any copy.
Example of usage (simplified code from Python/unionobject.c):
static PyObject *
union_repr(PyObject *self)
{
unionobject *alias = (unionobject *)self;
Py_ssize_t len = PyTuple_GET_SIZE(alias->args);
PyUnicodeWriter *writer = PyUnicodeWriter_Create();
if (writer == NULL) {
return NULL;
}
for (Py_ssize_t i = 0; i < len; i++) {
if (i > 0 && PyUnicodeWriter_WriteUTF8(writer, " | ", 3) < 0) {
goto error;
}
PyObject *p = PyTuple_GET_ITEM(alias->args, i);
if (PyUnicodeWriter_WriteRepr(writer, p) < 0) {
goto error;
}
}
return PyUnicodeWriter_Finish(writer);
error:
PyUnicodeWriter_Discard(writer);
return NULL;
}Linked PRs
- gh-119182: Add PyUnicodeWriter C API #119184
- gh-119396: Optimize PyUnicode_FromFormat() UTF-8 decoder #119398
- gh-119182: Decode PyUnicode_FromFormat() format string from UTF-8 #120248
- gh-119182: Use strict error handler in PyUnicode_FromFormat() #120307
- gh-119182: Add PyUnicodeWriter_DecodeUTF8Stateful() #120639
- gh-119182: Optimize PyUnicode_FromFormat() #120796
- gh-119182: Use public PyUnicodeWriter API in union_repr() #120797
- gh-119182: Use public PyUnicodeWriter API in ga_repr() #120799
- gh-119182: Use public PyUnicodeWriter in contextvar_tp_repr() #120809
- gh-119182: Rewrite PyUnicodeWriter tests in Python #120845
- gh-119182: Add PyUnicodeWriter_WriteUCS4() function #120849
- gh-119182: Use PyUnicodeWriter_WriteWideChar() #120851
- gh-119182: Add checks to PyUnicodeWriter APIs #120870
- gh-119182: Complete PyUnicodeWriter documentation #127607
- gh-119182: Use public PyUnicodeWriter in wrap_strftime() #129206
- gh-119182: Use public PyUnicodeWriter in time_strftime() #129207
- gh-119182: Use public PyUnicodeWriter in ast_unparse.c #129208
- gh-119182: Use public PyUnicodeWriter in Python-ast.c #129209
- gh-119182: Use public PyUnicodeWriter in stringio.c #129243
- gh-119182: Use public PyUnicodeWriter in _json.c #129249
Activity
pythongh-119182: Add PyUnicodeWriter C API
vstinner commentedon May 20, 2024
Benchmark using:
PyUnicodeWriteris 2.3x faster thanPyUnicode_Concat()+PyUnicode_Append().The difference comes from overallocation: if I add
PyUnicodeWriter_SetOverallocate(writer, 0);afterPyUnicodeWriter_Create(),PyUnicodeWriterhas the same performance thanPyUnicode_Concat()+PyUnicode_Append(). Overallocation avoidsstr += strquadratic complexity (well, at least, it reduces the complexity).The
PyUnicodeWriterAPI makes overallocation easy to use.cc @serhiy-storchaka
vstinner commentedon May 20, 2024
By the way, PyPy provides
__pypy__.builders.StringBuilderfor "Fast String Concatenation": https://doc.pypy.org/en/latest/__pypy__-module.html#fast-string-concatenation to work around thestr += strquadratic complexity.vstinner commentedon May 20, 2024
Article about this performance problem in Python: https://lwn.net/Articles/816415/
gvanrossum commentedon May 20, 2024
Curious if this warrants a further API
PyUnicodeWriter_WriteStr(writer, obj)which appendsrepr(obj)(just asWriteStr(writer, obj)can be seen to appendstr(obj)), and eventually the development of a new type slot that writes the repr or str of an object to a writer rather than returning a string object. (And maybe even an "WriteAscii" to writeascii(obj)) andWriteFormatto do something with formats. :-)I know, I know, hyper-generalization, yet this is what the Union example is screaming for... I suppose we can add those later.
How long has the internal writer API existed?
Would these be in the Stable ABI / Limited API from the start? (API-wise these look stable.)
vstinner commentedon May 20, 2024
I suppose that you mean
PyUnicodeWriter_WriteRepr().There is already a collection of helper function accepting a writer and I find this really cool. It's not "slot-based", since each function has many formatting options.
These functions avoid memory copies. For example,
_PyLong_FormatWriter()writes directly digits in the writter buffer, without the need of a temporary buffer.12 years: I added it in 2012.
I wrote this API to fix the major performance regression after PEP 393 – Flexible String Representation was implemented. After my optimization work, many string operations on Unicode objects became faster than Python 2 operations on bytes! Especially when treating only ASCII characters which is the most common case. I mostly optimized
str.format()andstr % argswhere are powerful but complex.In 2016, I wrote an article about the two "writer" APIs that I wrote to optimize: https://vstinner.github.io/pybyteswriter.html
I would prefer to not add it to the limited C API directly, but wait one Python version to see how it goes.
gvanrossum commentedon May 20, 2024
(Yes, I meant WriteRepr.) I like these other helpers -- can we just add them all to the public API? Or are there issues with any of them?
vstinner commentedon May 20, 2024
I added the following function which should fit most of these use cases:
Example to write
repr(obj):Example to write
str(obj):It's the same format than
PyUnicode_FromFormat(). Example:encukou commentedon May 21, 2024
Thank you, this looks very useful!
I see that
PyUnicodeWriter_Finishfrees the writer. That's great; it allows optimizations we can also use in other writers/builders in the future. (Those should have a consistent API.)One thing to note is that
PyUnicodeWriter_Finishshould free the writer even when an error occurs.Maybe
PyUnicodeWriter_Freeshould be named e.g.PyUnicodeWriter_Discardto emphasize that you should only call it if you didn'tFinish.The va_arg function is problematic for non-C languages, but it's possible to get the functionality with other functions – especially if we add a number-writing helper, so I'm OK with adding it.
The proposed API is nice and minimal. My bet about what users will ask for next goes to
PyUnicodeWriter_WriteUTF8String(for IO) &PyUnicodeWriter_WriteUTF16String(for Windows or Java interop).Name bikeshedding:
PyUnicodeWriter_WriteUCS4Charrather thanPyUnicodeWriter_WriteChar-- character is an overloaded term, let's be specific.PyUnicodeWriter_WriteFormat(orWriteFromFormat?) rather thanPyUnicodeWriter_FromFormat-- it's writing, not creating a writer.I see the PR hides underscored API that some existing projects use. I thought we weren't doing that any more.
vstinner commentedon May 21, 2024
"WriteChar" name comes from PyUnicode_ReadChar() and PyUnicode_WriteChar() names. I don't think that mentioning UCS4 is useful.
I would prefer just "PyUnicodeWriter_Format()". I prefer to not support str.format() which is more a "Python API" than a C API. It's less convenient to use in C. If we don't support str.format(), "PyUnicodeWriter_Format()" is fine for the "PyUnicode_FormFormat()" variant.
encukou commentedon May 21, 2024
Yeah,
PyUnicodeWriter_Formatsounds good. It avoids thePyX_FromYscheme we use for constructing new objects.I think that using unqualified
Charfor a UCS4 codepoint was a mistake we shouldn't continue, but I'm happy to be outvoted on that.pythongh-119182: Add PyUnicodeWriter C API
vstinner commentedon May 21, 2024
I propose to add PyUnicodeWriter_WriteString() which decodes from UTF-8 (in strict mode).
PyUnicodeWriter_WriteASCIIString() has an undefined behavior if the string contains non-ASCII characters. Maybe it should be removed in favor of PyUnicodeWriter_WriteString() which is safer (well defined behavior for non-ASCII characters: decode them from UTF-8).
99 remaining items
pythongh-119182: Use public PyUnicodeWriter in wrap_strftime()
pythongh-119182: Use public PyUnicodeWriter in wrap_strftime()
pythongh-119182: Use public PyUnicodeWriter in wrap_strftime()
pythongh-119182: Use public PyUnicodeWriter in time_strftime()
pythongh-119182: Use public PyUnicodeWriter in ast_unparse.c
gh-119182: Use public PyUnicodeWriter in time_strftime() (#129207)
gh-119182: Use public PyUnicodeWriter in wrap_strftime() (#129206)
pythongh-119182: Use public PyUnicodeWriter in Python-ast.c
gh-119182: Use public PyUnicodeWriter in ast_unparse.c (#129208)
gh-119182: Use public PyUnicodeWriter in Python-ast.c (#129209)
pythongh-119182: Use public PyUnicodeWriter in stringio.c
gh-119182: Use public PyUnicodeWriter in stringio.c (#129243)
pythongh-119182: Use public PyUnicodeWriter in _json.c
pythongh-119182: Use public PyUnicodeWriter in _json.c
gh-119182: Use public PyUnicodeWriter in _json.c (#129249)