Skip to content

perf: encode the Typst source straight into native memory - #5

Closed
msallin wants to merge 1 commit into
developfrom
feat/source-utf8-native-encode
Closed

perf: encode the Typst source straight into native memory#5
msallin wants to merge 1 commit into
developfrom
feat/source-utf8-native-encode

Conversation

@msallin

@msallin msallin commented Sep 6, 2026

Copy link
Copy Markdown
Member

Encoding.UTF8.GetBytes(inputSource) copied the whole source onto the managed heap, pinned it for one native call, and left it as garbage afterwards. The Rust side copies the bytes into an owned String, so the managed array was never more than a staging buffer.

Measured for a 200 KB source: 200,024 bytes on the managed heap, against zero when encoding into native memory. Anything at or above 85,000 bytes goes to the large object heap, which is only reclaimed by a gen2 collection. This is the path the one-shot statics take, since TypstCompiler.CompilePdf(source) builds a compiler per call.

The change also removes the new byte[1] placeholder. It existed only because fixed over an empty array yields a null pointer, which the native side reads as "no in-memory source"; an explicit one-byte allocation carries that distinction directly. The (pointer, length) contract is unchanged, so a source containing NUL bytes still survives intact.

Also in this change

The native allocations now happen inside the try whose finally releases them. They were made before it, and two statements in between can throw: fontPaths.ToList() over a caller-supplied lazy sequence, and JsonSerializer.Serialize for the system inputs. Either would leak every native block allocated up to that point. Adding one more allocation to that window without closing it would have made an existing leak worse.

Tests

Two safety-net tests added before the refactor and confirmed green against the old implementation:

  • EmptySourceIsDistinguishedFromNoSourceAtAll pins the empty-source-versus-null-pointer distinction the placeholder used to carry.
  • LargeSourceWithMultiByteCharactersIsCompiledInFull covers a source where UTF-8 byte count and char count diverge.

58/58 tests pass. Builds clean on net8, net9 and net10 with zero warnings.

Encoding.UTF8.GetBytes copied the whole source onto the managed heap for the
duration of one native call and left it as garbage afterwards. The Rust side
copies the bytes into an owned String, so the managed array was never more than
a staging buffer. A 200 KB source measured 200,024 bytes of managed heap; large
sources land on the large object heap, which is only reclaimed by a gen2
collection.

Encoding into a native block instead removes that entirely, and also removes
the one-byte placeholder array the empty-source case needed to avoid handing
across a null pointer.

The native allocations now happen inside the try whose finally releases them.
They were made before it, so a throw from the caller-supplied font path
sequence or from serializing the system inputs leaked everything allocated up
to that point.
@msallin
msallin force-pushed the feat/source-utf8-native-encode branch from d895d84 to f767fd4 Compare September 6, 2026 19:16
@msallin

msallin commented Sep 6, 2026

Copy link
Copy Markdown
Member Author

Reopened upstream against the parent repository: evolvedlight#48

@msallin msallin closed this Sep 6, 2026
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