Skip to content

cut: support multibyte characters in non-UTF-8 locales - #12635

Open
sylvestre wants to merge 3 commits into
uutils:mainfrom
sylvestre:fix-cut-mb-non-utf8
Open

cut: support multibyte characters in non-UTF-8 locales#12635
sylvestre wants to merge 3 commits into
uutils:mainfrom
sylvestre:fix-cut-mb-non-utf8

Conversation

@sylvestre

Copy link
Copy Markdown
Contributor

No description provided.

@sylvestre
sylvestre force-pushed the fix-cut-mb-non-utf8 branch from 36730e9 to 1c826d5 Compare June 5, 2026 11:02
@codspeed-hq

codspeed-hq Bot commented Jun 5, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 4.67%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 1 improved benchmark
❌ 2 regressed benchmarks
✅ 342 untouched benchmarks
⏩ 46 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Simulation cut_characters 17.1 ms 23.3 ms -26.55%
Simulation du_deep_tree[(100, 3)] 2.3 ms 2.3 ms -3.67%
Simulation df_with_path 699.4 µs 571.2 µs +22.44%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing sylvestre:fix-cut-mb-non-utf8 (e34f4db) with main (a0cb024)

Open in CodSpeed

Footnotes

  1. 46 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@github-actions

github-actions Bot commented Jun 5, 2026

Copy link
Copy Markdown

GNU testsuite comparison:

Skipping an intermittent issue tests/pr/bounded-memory (passes in this run but fails in the 'main' branch)
Skipping an intermittent issue tests/tail/tail-n0f (passes in this run but fails in the 'main' branch)
Congrats! The gnu test tests/cut/mb-non-utf8 is no longer failing!
Note: The gnu test tests/cp/link-heap is now being skipped but was previously passing.
Note: The gnu test tests/rm/many-dir-entries-vs-OOM is now being skipped but was previously passing.

Comment thread tests/by-util/test_cut.rs
@sylvestre
sylvestre force-pushed the fix-cut-mb-non-utf8 branch from 1c826d5 to 43545e3 Compare June 9, 2026 08:31
Comment thread tests/by-util/test_cut.rs
@sylvestre
sylvestre force-pushed the fix-cut-mb-non-utf8 branch from 43545e3 to b468c15 Compare July 4, 2026 16:36
@sylvestre

Copy link
Copy Markdown
Contributor Author

@codspeedbot fix this regression

@codspeed-hq

codspeed-hq Bot commented Jul 30, 2026

Copy link
Copy Markdown

@codspeedbot fix this regression

I reproduced the cut_characters regression locally (32.6 ms, matching the 32.8 ms reported here) and pushed a fix: 32.8 ms → 12.0 ms.

What caused it

With a UTF-8 LANG (the case on the CI runners), -c no longer takes the plain byte path, and the new cut_chars loop was doing two expensive things per line:

  • it called write_all once per character instead of once per range — ~26 buffered writes per line on this benchmark;
  • it restarted the character walk from the beginning of the line for every range.

What changed

src/uu/cut/src/cut.rs only:

  • the line is walked once for all ranges (Range::from_list returns them sorted and disjoint, so each range maps to a contiguous slice) and each range is written with a single write_all;
  • when a range reaches past the end of the line, the remaining characters are not decoded at all — a character can never end before its own byte offset, so everything left is covered. This also makes the very common cut -c1-N on short lines nearly free;
  • ASCII bytes are handled inline, keeping them out of the decoder — they are single-byte characters in every encoding handled by charmap.

Correctness

Output is byte-for-byte identical to the current implementation:

  • 800k randomised cases (lines with valid/invalid/truncated multi-byte sequences, multiple ranges, -c and -b -n, with and without an output delimiter);
  • 360 invocations of the old vs new binary over UTF-8, GB18030, EUC-JP and single-byte locales, on the benchmark data, GB18030 data, 200 KB of random bytes and an empty file, covering ranges, complements and custom output delimiters;
  • the cut test suite (61 tests) passes, along with clippy and fmt.

Remaining gap

cut_characters stays above the 7.4 ms of main because main never looks at character boundaries for -c — that decoding is the point of this PR. Locally the whole cut -c5-30 run costs 30.1M instructions versus 19.0M for the byte path; a few further variants I tried (word-at-a-time ASCII scanning, #[inline] on mb_char_len, specialising the loop on -c vs -b -n) all measured worse, so I kept the simplest version.

One note: uucore::i18n::charmap::mb_chars is no longer used by cut after this change. I left it in place as a public helper — say the word and I'll remove it.

Summary
  • Warming up the performance wizard...
  • Investigating cut_characters regression — Root cause identified: the new -c path writes one character at a time and rescans the line per range
  • Building and measuring the baseline — Reproduced the regression locally: cut_characters at 32.6 ms with a UTF-8 locale
  • Optimizing the multibyte cut path — cut_characters: 32.8 ms -> 12.0 ms, output unchanged; commit pushed to the PR branch
  • Handling comment — Comment handling finished successfully

Was this response helpful? React with 👍 or 👎 to help us improve CodSpeedBot.

@sylvestre
sylvestre force-pushed the fix-cut-mb-non-utf8 branch 2 times, most recently from 9b73daf to b7bb157 Compare July 31, 2026 11:39
@uutils uutils deleted a comment from codspeed-hq Bot Jul 31, 2026
@sylvestre
sylvestre force-pushed the fix-cut-mb-non-utf8 branch from a9a6ca1 to 9980d47 Compare July 31, 2026 20:08
@sylvestre
sylvestre force-pushed the fix-cut-mb-non-utf8 branch 3 times, most recently from 4780f68 to a3d2145 Compare August 2, 2026 22:01
wasmtime's argument marshaling requires valid UTF-8, so a raw non-UTF-8
byte delimiter can't be passed through to the WASI binary the way it
can natively on Linux.
The character path walked the line one character at a time through the
encoding decoder and looked the locale encoding up per character, which
dominated the per-line cost on mixed ASCII and multi-byte text.

Take runs of ASCII bytes a machine word at a time. They are single-byte
characters in every encoding handled here, so byte offset and character
position move together across a run, and only the bytes above 0x7F need
the decoder. Bundle the parts that are fixed for the whole run -- the
ranges, the output delimiter and whether it was given, the position mode
and the encoding -- into a CharCut built once by cut_chars, and make the
line body a method on it, so the per-line call passes one pointer rather
than seven arguments.

In uucore, MbEncoding becomes the public Encoding and locale_encoding()
returns it by value, so a caller decoding many characters resolves the
locale once and keeps it in a register instead of reaching through a
OnceLock per character. is_multibyte_locale() had no callers left.

Instruction counts against the parent commit (cachegrind, LC_ALL=C.UTF-8):

  -c 5-30, 100k mixed short lines       30.44M -> 28.95M
  -c 20-70, 20k long multibyte lines    44.38M -> 41.02M

That is roughly 1.1x in wall clock on both shapes; the machine was too
loaded to quote a tighter figure. The single-byte path (LC_ALL=C) and
field mode are unchanged.

Selecting a range of characters still costs more than the same range of
bytes, and always will: -c used to be an alias for -b, and characters
have to be decoded to be counted.

Tests cover advance directly -- character counting for -c, byte counting
for -b -n, and the word boundary crossings -- plus a cut -c case over
mixed ASCII and multi-byte lines.
@sylvestre
sylvestre force-pushed the fix-cut-mb-non-utf8 branch from a3d2145 to e34f4db Compare August 3, 2026 16:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants