Skip to content

perf(base64): index-based loops instead of for-in iteration#241

Open
mizchi wants to merge 3 commits into
moonbitlang:mainfrom
mizchi:pr-base64-index-loop
Open

perf(base64): index-based loops instead of for-in iteration#241
mizchi wants to merge 3 commits into
moonbitlang:mainfrom
mizchi:pr-base64-index-loop

Conversation

@mizchi

@mizchi mizchi commented May 23, 2026

Copy link
Copy Markdown
Contributor

Summary

Both Encoder::encode_to and Decoder::decode_to in codec/base64/base64.mbt walk their input with for byte in bytes / for ch in input. That desugars through BytesView::iter / StringView::iter plus an Iter::next call per element. On a 64 KiB benchmark those iterators account for ~32% of the encode self time and ~35% of the decode self time — even though the inner-loop body is a tiny match.

Switch both to index loops:

  • encode_to: pull the backing Bytes and start_offset once and index data[start + idx] per byte.
  • decode_to: iterate 0..<input.length() and read input.unsafe_get(off) (UTF-16 code unit) directly. base64's alphabet is pure ASCII, so reading by code unit is safe; if a payload contains a high surrogate it isn't valid base64 anyway and char_to_index raises InvalidChar as before.

Benchmark

Scenarios: bench-x/cmd/base64_encode/main.mbt and bench-x/cmd/base64_decode/main.mbt — 64 KiB payload, 500 iterations, Linux x86_64 native release, 3-run median wall time.

workload baseline patched delta
base64_encode 710 ms 564 ms -20.6%
base64_decode 685 ms 432 ms -36.9%

Callgrind self-time delta (encode, top symbols):

symbol before after
BytesView::iter closure (for byte in) 19.68% gone
Iter::next<Byte> 12.20% gone
Encoder::encode_to 14.56% (now sees the loop body directly)

Same shape on decode: 23.37% StringView::iter + 11.91% Iter::next<Char> collapse to a single index step.

Tests

moonbitlang/x/codec/base64    3 / 3 pass

Background

Same iter-overhead pattern as the moonbitlang/async crc32_update patch: for byte in BytesView / for ch in StringView is convenient to write but, in tight per-byte loops, the iter closure allocation + Iter::next dispatch dominates over the loop body. Direct indexing of the backing buffer is intrinsic.

mizchi and others added 3 commits May 23, 2026 19:22
Encoder::encode_to and Decoder::decode_to walked their input with
'for byte in bytes' / 'for ch in input', which desugars through
BytesView::iter / StringView::iter + Iter::next per element. On a
64 KiB benchmark those iterators account for ~32% of encode self
time and ~35% of decode self time even though the loop body is a
tiny match.

Switch both to index loops:
- encode_to pulls backing Bytes + start_offset once and indexes
  data[start + idx] per byte.
- decode_to iterates 0..<input.length() and reads input.unsafe_get(off)
  directly. base64's alphabet is ASCII so a code-unit read is safe;
  any surrogate is rejected by char_to_index as before.

base64 bench (native, 3-run median, 64 KiB x 500 iters):
  encode  : 710 -> 564 ms  (-20.6%)
  decode  : 685 -> 432 ms  (-36.9%)
@peter-jerry-ye

Copy link
Copy Markdown
Collaborator

Even though the fix is correct, I would personally not fix it in this way. The performance regression means that something's wrong with the compiler implementation that made the ideal way of writing code less performant.

Can you please take a look? @Yu-zh

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.

2 participants