Skip to content

sys.inputs forces a UTF-16 round trip and a second JSON encode, which dominates allocation for large inputs #45

Description

@msallin

sys.inputs is the one remaining input that still forces a UTF-16 round trip, and for a large payload it dominates the managed allocation of a compile.

The dictionary is serialized to a JSON string and then transcoded into unmanaged memory (TypstCompiler.cs:566):

var sysInputsJson = sysInputs == null ? "{}" : JsonSerializer.Serialize<Dictionary<string, string>>(sysInputs, sourceGenOptions);
var sysInputsPtr = Marshal.StringToCoTaskMemUTF8(sysInputsJson);

SetSysInputs repeats it verbatim (TypstCompiler.cs:1159), and the Rust side unwraps it as a C string before handing it to serde (lib.rs:267 and lib.rs:310):

let sys_inputs_str = unsafe { CStr::from_ptr(sys_inputs).to_str().unwrap_or("{}") };
...
let inputs: Dict = serde_json::from_str(sys_inputs_str).unwrap_or_default();

This is the transport #34 deliberately left alone, and that reasoning still holds: a path cannot contain a NUL and JSON escapes one rather than emitting the byte, so NUL truncation is not a correctness risk here. The problem is allocation, not truncation.

What it costs

The interesting case is a caller whose input is a document, rather than a handful of short scalars. I generate shipping labels: one sys.inputs entry holds a JSON array of label field dictionaries, and the template reads it with json(bytes(sys.inputs.at("data"))). At 1000 labels that value is about 0.7 MB of UTF-8.

Following one such value through:

  1. My own JsonSerializer.Serialize(...) produces a UTF-16 string, roughly 1.4 MB.
  2. JsonSerializer.Serialize<Dictionary<string, string>> re-encodes that string as a JSON string value, so every " in my payload becomes \". Another UTF-16 string, slightly larger again.
  3. Marshal.StringToCoTaskMemUTF8 copies the whole thing into unmanaged memory.
  4. serde_json::from_str allocates the value a fourth time as an EcoString inside the Dict.

So the payload is JSON-encoded twice, JSON-parsed twice, and exists as four full copies before Typst has looked at it. Steps 1 and 2 are both large enough to land on the LOH, and step 3 is invisible to MemoryDiagnoser, so the true cost is worse than it measures. In my benchmark a 1000-label document allocates 18.9 MB managed per compile, and this path is the single largest contributor.

The double encoding is the part that grates most. I hand you JSON, you escape it into a JSON string, Rust parses the outer JSON to recover exactly the bytes I started with, and then my template parses the inner JSON a second time.

What I would like

Mirror what #34 did for input_source: take the sys inputs as raw UTF-8 with an explicit length, and let the caller supply the complete sys.inputs object.

public void SetSysInputs(ReadOnlySpan<byte> utf8Json);

public static TypstCompiler FromFile(
    string path,
    Fonts? fonts = null,
    ReadOnlySpan<byte> sysInputsUtf8 = default,
    ...);

That lets me write straight into a pooled buffer with Utf8JsonWriter and hand you the span, which removes both UTF-16 strings and the unmanaged copy. On the Rust side it is serde_json::from_slice over a borrowed slice instead of from_str over a CStr.

Two things fall out of it that are worth more than the allocation saving:

  • The existing Dictionary<string, string> overloads get cheaper for free, because JsonSerializer.SerializeToUtf8Bytes replaces Serialize plus StringToCoTaskMemUTF8. Every current user benefits without changing a line.
  • Because the caller controls the whole JSON object, a value can be a real nested object rather than an escaped string. If Dict deserializes nested values the way I expect, my template drops its inner json(bytes(...)) parse entirely. I have not verified that against typst's Dict deserializer, so treat it as a hoped-for bonus rather than part of the ask.

create_compiler and set_sys_inputs are internal in generated code and ship in the same package as the native library, so changing the FFI signature is not a public break. The Dictionary overloads stay as they are.

I will put a PR together unless you would rather shape the API differently first — in particular whether the span should be the whole sys.inputs object, as above, or a per-key SetSysInput(string key, ReadOnlySpan<byte> utf8Value). I prefer the former because it is one call and one buffer, but the latter keeps the "values are strings" contract more visible.

Implementation hints

Notes for whoever picks this up, me included.

Branch from develop, not main. Targets are net8;net9;net10.0 (src/typstsharp/typstsharp.csproj), so ReadOnlySpan<byte> needs no shims and there is no netstandard2.0 to keep happy.

Rust — src/typst_core/src/lib.rs. Change sys_inputs: *const c_char to sys_inputs: *const u8, sys_inputs_len: usize on both create_compiler (~:193) and set_sys_inputs (:383). Replace the CStr::from_ptr(...).to_str() at :267 and :393 with std::slice::from_raw_parts, guarding null and zero length, and swap serde_json::from_str at :310 for from_slice. Copy the shape of input_source / input_source_len immediately above at :261 — it is the same pattern and already reviewed. Update the # Safety doc block at :170:176, which currently promises sys_inputs is NUL-terminated, and the matching sentence on set_sys_inputs.

Do not hand-edit src/typstsharp/Bindings.g.cs. It is csbindgen output; the header says so. It is regenerated by src/typst_core/build.rs, which runs csbindgen::Builder::default().input_extern_file("src/lib.rs").csharp_dll_name("typst_core").generate_csharp_file("../typstsharp/Bindings.g.cs"). Run cargo build in src/typst_core and commit the regenerated file. The declarations to expect changes in are at Bindings.g.cs:62 and :125.

C# — src/typstsharp/TypstCompiler.cs. Three touch points: the private constructor at :477, the serialize/marshal pair at :566:571, and SetSysInputs at :1148. Pin the buffer with fixed exactly as inputSourcePtr already is inside the same try block, and drop sysInputsPtr from the finally that calls Marshal.FreeCoTaskMem (:631). Keep sourceGenOptions and the source-generated context in src/typstsharp/JsonSerialisation.cs; SerializeToUtf8Bytes uses it unchanged.

Watch the empty case. Today sysInputs == null sends the literal "{}"; with a length-carrying transport, default/empty span must be treated as "no inputs" rather than being passed to serde as an empty slice, which would error instead of yielding an empty Dict.

Tests. C# lives in src/typstsharp.tests, Rust integration tests in src/typst_core/tests (compile.rs, input_path.rs). Worth covering: a value containing quotes, backslashes and non-ASCII text, to prove the escaping change did not alter what the template observes; a value in the hundreds of KB; and an empty/omitted inputs case. A test asserting sys.inputs content is easy to write by compiling a one-line source that emits sys.inputs.at("k").

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions