Skip to content

Copy the rest of a suspended text lexeme once, not at every suspension - #13

Open
robobun wants to merge 1 commit into
bunfrom
robobun/463f6c50/text-suspension-linear
Open

robobun wants to merge 1 commit into
bunfrom
robobun/463f6c50/text-suspension-linear

Conversation

@robobun

@robobun robobun commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A text handler that suspends on each chunk of a long text lexeme makes the rewrite quadratic: a 4 MiB lexeme allocates 8.59 GB. A 16 MiB text node costs Bun 1.4.2 10.2 s, and Bun 1.3.13 0.08 s.
  • The cause is raw_input.get(read..).unwrap_or_default().to_vec() in TextDecoder::feed_decoder_loop (src/rewritable_units/text_decoder.rs:193). resume_feed runs that loop over the parked copy, so the next suspension copies the rest again.

Fix

  • resume_feed lends its bytes to the loop and leaves its record in the suspended slot. A chunk that suspends again only moves offset. The 4 MiB lexeme now allocates 8.72 MB.
  • One #[cold] helper, park, holds the only copy. It records suspensions, not errors.
  • Verified: cargo test --features=_integration_test. Of 5 new tests, 2 fail on the unfixed code.

Background

  • A lexeme is one run of text from the lexer. A < that opens no tag ends it, and so does the end of a write().
  • After a byte that is not UTF-8, and for every lexeme of a text node but the first, the decoder emits 1024-byte chunks.
  • The write() input is gone when resume() runs, so a suspension copies the undecoded rest.
  • Considered a second owner in the Dispatcher (one fact, two structs) and the TransformStream arena (a branch on every write()).

Downsides

  • The parked buffer keeps its size until the lexeme ends. For a 4 MiB lexeme the mean is 4.2 MB (was 2.1 MB) and the peak is 4.2 MB (was 8.4 MB).
  • A suspension on one-chunk text costs 89 more instructions (5,251 to 5,340) and no allocation.
Notes

Origin. Found by measurement of Bun's HTMLRewriter. #12 mentions it under "Found on the way, not changed here". No user reported it. The copy came with the suspension commit (230e863 here, oven-sh/bun#36733, Bun 1.4.0).

Which text takes the decoder. Valid UTF-8 text with no stray <, in one write(), is one chunk and is not affected. These three bodies each give about 4,100 chunks for 4 MiB of text. The times are CPU for one transform() in a release build of Bun with a handler that awaits setImmediate on every chunk, before and after this change (single runs):

Body (one write) before after
<p>, the byte 0xE9, text 277 ms 17 ms
<p>a < , text (valid UTF-8) 250 ms 17 ms
<script> with i<n, text (valid UTF-8) 294 ms 13 ms

A handler whose promise settles in the microtask checkpoint does not suspend and is not affected.

Allocation. A counting global allocator around one rewrite (release build, the handler returns SuspensionRequest on every chunk unless stated):

One text node after the byte 0xE9 e1a306d this PR
1 MiB 537,485,571 bytes 2,183,430 bytes
2 MiB 2,148,707,587 4,362,502
4 MiB 8,592,377,091 (12,325 calls, 4,088 over 8 KiB) 8,720,646 (8,230 calls, 1 over 8 KiB)
4 MiB, an element handler suspends first, so the text is read from the rewriter's buffer 8,596,571,103 12,914,658
4 MiB in 64 KiB writes 136,660,164 8,668,418
4 MiB, the handler returns an error on its second chunk 4,197,119 4,860
4 MiB, the handler never suspends 4,960 (30 calls) 4,960 (30 calls)
1,000 small text nodes, the handler never suspends 132,832 (2,028 calls) 132,832 (2,028 calls)

On the v2.7.2 lineage (725ce49) the rows that suspend differ from these by less than 500 bytes.

The path that never suspends. Its source does not change: the two if res.is_err() arms call park and no longer build the record inline. User-mode instructions (perf_event_open, instructions:u) of a harness with a sync text handler: 3.00 fewer per small text node (4,044.2 before), 1.99 fewer per 1024-byte chunk. With Bun's flags (-Ctarget-cpu=haswell, frame pointers, static relocation, ThinLTO): 4.0 more per text node and 2.0 fewer per chunk. TextFeedSuspension is 40 bytes before and after, and HtmlRewriter is 1,144 bytes before and after. The TextDecoder functions are 1,707 bytes before and 1,949 after, park included.

Tests.

  • rest_of_lexeme_is_copied_once: every suspension of one lexeme parks the same buffer (address and length) with a larger offset.
  • suspended_feed_yields_the_same_chunks and text_suspension_in_the_middle_of_a_lexeme: the chunks, last_in_text_node, the source ranges and the output equal the run that never suspends. The handler suspends on every 1st, 2nd, 3rd or 7th chunk. In the second test a second text handler and an element handler suspend too, over 6 documents: a byte that is not UTF-8 first, a valid prefix first, a < that opens no tag, 3-byte characters, a text node over three writes, and a text node that ends in end().
  • handler_error_parks_nothing and suspension_record_size.
  • The unit tests read the new offset field, so they do not compile on the unfixed code. With those 2 lines removed, rest_of_lexeme_is_copied_once and handler_error_parks_nothing fail on their assertions, and every other test passes.
  • 10 hand-made faults in the new code each fail at least one new test, and none fails a test that existed. The faults: resume_feed feeds an empty slice, the offset goes back to 0, the offset is one byte late, the offset is one chunk early, the buffer is not put back, park records every error, the rest is copied again, the slot holds the old offset, the read prefix is drained at every suspension, and the first copy has the right length and the wrong bytes.

Overlap with #3. #3 contains 6e9ace3, which keeps a valid UTF-8 text node on the fast path: a later lexeme is then one chunk, while no handler suspends. After a suspension, resume_feed runs the decoder loop, the loop creates the streaming decoder, and the later lexemes arrive in 1024-byte chunks. A handler that suspends then sees other chunks than a handler that does not. This commit merges into 6166e4c without a textual conflict, and suspended_feed_yields_the_same_chunks and text_suspension_in_the_middle_of_a_lexeme then fail on that difference. Whichever lands second has to keep the fast path in resume_feed when no input is left to decode.

Overlap with #12. #12 changes feed_text, the decoder loop and resume_dispatch, and keeps both to_vec() calls. Whichever lands second routes its arm through park.

CI. The four jobs of this PR fail in "Set up job", before any step runs. #10 changes the workflows. Run here by hand: cargo fmt -- --check, cargo test --features=_integration_test, and cargo clippy --all --all-targets --features=_integration_test, which reports the same 3 warnings as bun.

Other branches with the same commit.

When a text handler suspends on a chunk, `TextDecoder` copies the
undecoded rest of the lexeme, because the input of `write()` is gone
when the resume runs. `resume_feed` then ran the decoder loop over that
copy, and a chunk that suspended again copied the rest again. A handler
that suspends on every chunk of one N-byte lexeme made N / 1024
suspensions, and each copied N / 2 bytes on average. One 4 MiB text
node allocated 8.59 GB.

`resume_feed` now lends its bytes to the loop and leaves its record in
the `suspended` slot. A chunk that suspends again finds the record
there and only moves `offset`. The same text node allocates 8.72 MB.
The parked buffer keeps its size until the lexeme ends.

Both places that record a suspension call one cold helper, `park`. It
holds the only copy. It records nothing for an error that is not a
suspension: such an error ends the rewrite, so nothing reads the
record.

The path that never suspends has no new statement, and
`TextFeedSuspension` keeps its size.
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.

1 participant