Repository navigation
Conversation
Quest-Claim: 235c0d59cc4969ea
Ops 17 to 20 of the codec fixture's bench code a broadcast path as moq-net's Encoder and Decoder do, join and strip one as Path::concat and strip_prefix do, and key a HashMap by String, each over an ASCII path and one with multi-byte chars. str-routes.py rewrites a translation into two models of a String for just ts bench --route: a JS string with its UTF-8 length and capacity, and UTF-8 bytes in an rt.ByteVec. Prototype only, for the decision in the PR. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pacity # Conflicts: # ts/fixtures/codec/expected/bench.ts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
A translated
Stringis a JSstring, which holds no capacity, soString::capacityand everything that takes it (into_bytes,Vec::from,Bytes::from) is refused or guessed. The quest (ts-string-capacity) makesStringa runtime type,rs.String, as #555 did forVec. Its first step is a decision: measure anrs.Stringover a JS string against one over UTF-8 bytes, and bring the numbers back.This PR holds the measurement only. The translation waits on the pick below.
Approach
Workloads: the codec bench gains
str/workloads shaped on moq-net's string paths, each over an ASCII path (demo/room-42/alice/camera) and one with 2-, 3-, and 4-byte chars (démo/salle-42/アリス/📷):str/<path> encodeanddecode: 256 size-prefixed strings through the fixture'sEncoder::stringandDecoder::string(moq-net'scoding).str/join/<path>:Path::concat'sString::with_capacitypluspush_str/push, thenPath::strip_prefix's byte-boundary check.str/keyed/<path>: 256format!ed paths asHashMap<String, _>keys, each looked up four times by&str(moq-net finds a broadcast by path this way).fmt/lines(existing):writeln!into oneString.The hand-written route (
bench/strings.ts) uses moq's ownWriter.stringandCursor.string.Prototypes:
ts/bench/prototype/str-routes.pyrewrites a translation into each model on the paths those workloads run, as feat(ts): a Vec as rt.Vec, holding std's exact capacity #555'svec-routes.pydid. Each edit is what the translator would emit. Every route's output matches native Rust's (the bench checks this).js:rs.Stringis{ s: string, len, cap }: the JS string, its UTF-8 length, and std's exact capacity, grown per push asRawVec::grow_amortizedgrows it.&strandBox<str>stay plain JS strings, since they hold no capacity. AHashMap<String, _>goes by.sin a JSMapand keeps each keyStringbeside its value.utf8:rs.Stringis anrs.ByteVec, whose storage length is the exact capacity, and&stris anrs.ByteSlice. A literal's bytes are made once when its module loads.fmtwrites encode into the vector's room, and a&strargument is copied as bytes. AHashMap<String, _>hashes the bytes through the runtime's hasher, as an object key does today.Measured
Host: Ryzen 9 5900XT, shared, load 8 to 20 on 32 threads during the runs. Ratios are route ÷ main's translation, timed in the same run: the median over 3
just ts benchruns per model, with min and max in parentheses. Main's translation here is 90e0f35 plus these workloads, before the merge of #555 and #578; neither changes the string paths.rs.Stringover a JS string (js) ÷ main:fmt/linesstr/ascii encodestr/utf8 encodestr/ascii decodestr/utf8 decodestr/join/asciistr/join/utf8str/keyed/asciistr/keyed/utf8rs.Stringover UTF-8 bytes (utf8) ÷ main:fmt/linesstr/ascii encodestr/utf8 encodestr/ascii decodestr/utf8 decodestr/join/asciistr/join/utf8str/keyed/asciistr/keyed/utf8utf8÷js, from the medians above:fmt/linesstr/* encodestr/* decodestr/join/*str/keyed/*Main is slow on these paths for reasons separate from the model. Its
stringLenandstringAsBytesencode the whole string withTextEncoderon every call, andpush_stris a concatenation. So much of each model's gain ondecodeandjoinis just dropping those calls. Theutf8÷jstable compares the two models directly.The
keyedgap inutf8is the map, not the key. Split in Node, per key over an insert plus four gets:Mapof JS stringsutf8over the runtime's hasher (as measured)utf8over FNV-1a into aMap<number, chain>utf8keyed by a byte-per-char JS stringutf8/ mainEngines hash and intern JS strings natively. Bytes can't get that without making a JS string first, which is the decode the model exists to avoid.
Bundles, minified by Bun as the bench ships them, in bytes (codec bench fixture;
utf8includes its literals' encodes):jsutf8Trade-offs
js: JS string + length + capacityutf8: UTF-8 bytes&strandBox<str>stay JS strings, so a hand-written caller passes"name"as is. Only aStringcrosses asrs.String(rs.stringFrom(s)in,.sout).&strandStringcrosses as bytes. A caller encodes each argument (rs.strFrom("name")) and decodes each result.str.ts).push_strscans the pushed text for its UTF-8 length; a literal's is known at compile time.find, andis_char_boundaryindex bytes as Rust does.as_bytes,into_bytes,Bytes::fromTextEncoder), as main does.into_bytesgets the capacity fromcap.into_bytesandBytes::fromtake the storage, so aBytes' capacity is theString's with no copy. This is the refusal the quest lifts.Display,{}&strinto aStringcopies bytes. Any other writer (fmt::Formatterto JS,panic!,Debug) decodes.HashMap<String, _>js, decode 1.2-2x, fmt 1.2-1.5x, join mixed (Firefox 1.8x slower).Decisions needed
Which representation should
rs.Stringtake?js: a JS string, its UTF-8 length, and its capacity (recommended).&strandBox<str>stay plain JS strings. It keeps native interop and engine string hashing. Its only regressions on main areHashMap<String, _>at 0.9-2.3x (wrapper keys) andfmt/linesat 1.07-1.22x (a UTF-8 length scan per write). The map cost is likely reducible by keeping the keyStringonly when its capacity differs from its length. It gives uputf8's encode speed:as_bytesandinto_bytesstill copy throughTextEncoder, no worse than main today. Byte offsets keep today's UTF-16 scans.utf8: anrs.ByteVec, with&stranrs.ByteSlice. It is the closest to std's semantics: byte offsets are native, andinto_bytes/Bytes::fromshare storage. It is the fastest on moq-net's wire paths (encode 3-14x, decode 1.2-2x overjs). But it is 5.6-9.8x slower than main onString-keyed maps, which moq-net uses to find broadcasts. It costs +3.1% bundle, and every hand-written&strargument needs a conversion.utf8plus a lazily cached JS string (decoded once, kept while theStringis unmutated) forDisplay, map keys, and the boundary. Map keys are immutable in Rust, so a key's cache never goes stale. A lookup by a&strthat borrows a cachedStringwould hit, but one from a wire slice would still decode. This costs another prototype round.Recommendation: 1. moq-net's wire paths match or beat main under
js, its map lookups stay within 2.3x of main, and hand-written TS keeps passing JS strings, which the maintainer's boundary-helper direction on #555 favors.utf8's encode gain is real but is lost back on everyString-keyed map.Impact
Consumer: moq. The
str/workloads time moq-net's own string paths (Encoder::string,Decoder::string,Path::concat,Path::strip_prefix, keying by path).Public API: none yet. The bench gains
str/workloads. The prototype underts/bench/prototype/goes before anything lands, as #555's did.Alternatives
Map<number, chain>, forutf8's maps: about 1.4-3.6x faster than the runtime's hasher, still 13-33x a JS stringMap. Not timed in the full bench.utf8: slower than hashing, as it builds a string a char at a time.Follow-ups
rs.stringLenandrs.stringAsBytescallTextEncoder.encodeon every use. Under either modellenbecomes a field, so this goes away with the translation, not separately.Review
Not yet reviewed: draft awaiting the decision above.
🤖 Generated with Claude Code