Skip to content

gh-156537: Optimize codec.decode by inlining the per-character write in CJK decoders - #156538

Merged
corona10 merged 4 commits into
python:mainfrom
brittanyrey:perf-cjk-inline-outchar
Aug 29, 2026
Merged

gh-156537: Optimize codec.decode by inlining the per-character write in CJK decoders#156538
corona10 merged 4 commits into
python:mainfrom
brittanyrey:perf-cjk-inline-outchar

Conversation

@brittanyrey

@brittanyrey brittanyrey commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Every CJK decoder emits output through OUTCHAR(), which called the exported _PyUnicodeWriter_WriteChar(). Since the codecs are shared extension modules, that's a cross boundary call to the exported function for each output character. Swapping to the _PyUnicodeWriter_WriteCharInline() removes the overhead of crossing the boundary speeding up decoding.

The single macro applies to all of the codec modules.

Benchmark main PR
decode 1.8MB shift_jis 6.87 ms 2.55 ms: 2.70x faster
decode 1.8MB euc_kr 6.34 ms 2.50 ms: 2.54x faster
decode 1.8MB euc_jp 6.36 ms 2.55 ms: 2.49x faster
decode 1.8MB big5 6.27 ms 2.56 ms: 2.45x faster
decode 1kB euc_jp 3.97 us 1.82 us: 2.18x faster
decode 1.8MB euc_jp kana 3.86 ms 3.07 ms: 1.26x faster
Benchmark script
"""CJK decoder throughput (Modules/cjkcodecs)."""
import pyperf

ASCII = ('abc' * 30 + '\n')
KANA = ('あいう' * 10 + '\n')

def dec(d, e):
    d.decode(e)

if __name__ == "__main__":
    r = pyperf.Runner()
    for enc in ("euc_jp", "shift_jis", "big5", "euc_kr"):
        r.bench_func("decode 1.8MB %s" % enc, dec, (ASCII * 20000).encode(enc), enc)
    r.bench_func("decode 1.8MB euc_jp kana", dec, (KANA * 20000).encode("euc_jp"), "euc_jp")
    r.bench_func("decode 1kB euc_jp", dec, (ASCII * 11).encode("euc_jp"), "euc_jp")

Every CJK decoder emits output through OUTCHAR(), which called the
exported _PyUnicodeWriter_WriteChar().  The codecs are shared extensions,
so that is a cross-module call for each output character, and the
function is nothing but the _PyUnicodeWriter_Prepare() macro plus a store
that the caller could have done itself.  OUTCHAR2, directly below it, was
already written inline for exactly this reason.

Use _PyUnicodeWriter_WriteCharInline() instead.  One macro, and it
applies to every codec in the package.

| benchmark               |    before |     after | change | spread |
|-------------------------|----------:|----------:|-------:|-------:|
| big5 decode 1.8MB       |   6.32 ms |   2.56 ms |   -59% |  0.3pp |
| euc_kr decode 1.8MB     |   6.94 ms |   3.18 ms |   -54% |  0.1pp |
| shift_jis decode 1.8MB  |   6.93 ms |   3.17 ms |   -54% |  0.4pp |
| euc_jp decode 1.8MB     |   6.32 ms |   3.18 ms |   -50% |  0.2pp |
| gb2312 decode 1.8MB     |   6.32 ms |   3.18 ms |   -50% |  0.3pp |
| euc_jp decode kana      |   3.26 ms |   2.05 ms |   -37% |  4.0pp |
| iso2022_jp decode       |   9.48 ms |   7.09 ms |   -25% |  8.3pp |

Through the StreamReader wrappers, where Python-level call overhead
dilutes it:

| benchmark                  |    before |     after | change |
|----------------------------|----------:|----------:|-------:|
| euc_jp read() [1 call]     |   6.33 ms |   3.18 ms |   -50% |
| euc_jp read(1024) xN       |   7.06 ms |   3.93 ms |   -44% |
| euc_jp readlines()         |   8.76 ms |   5.60 ms |   -36% |
| shift_jis readline() x20k  |  11.91 ms |   8.25 ms |   -30% |
| euc_jp readline() x20k     |  11.35 ms |   9.27 ms |   -18% |

Decoding every 1- and 2-byte sequence (65536 + 256 inputs) under each of
the 23 CJK codecs, plus 3-byte probes, hashes identically before and
after.

Measured with the two builds in separate worktrees.  These codecs live in
shared .so files, so copying the python executable within one build tree
does not isolate them: both copies load whichever module was built last,
and the change measures as an exact no-op.
@brittanyrey brittanyrey changed the title gh-156537: Inline the per-character write in the CJK decoders gh-156537: Optimize codec.decode by inlining the per-character write in CJK decoders Aug 28, 2026
@corona10 corona10 self-assigned this Aug 28, 2026

@corona10 corona10 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.

lgtm

Thank you for doing this.

@corona10
corona10 enabled auto-merge (squash) August 29, 2026 03:05
@corona10
corona10 merged commit d915492 into python:main Aug 29, 2026
58 checks passed
@brittanyrey
brittanyrey deleted the perf-cjk-inline-outchar branch August 29, 2026 04:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Performance: CJK codec decoders pay a cross-module call per character

2 participants