Repository navigation
Fix value nodes returning empty literals when read concurrently - #10518
Draft
tobias-tengler wants to merge 4 commits into
Draft
tobias-tengler wants to merge 4 commits into
tobias-tengler wants to merge 4 commits into
Conversation
Contributor
Patch coverage83.2% of changed lines covered (114/137)
Uncovered changed lines (JSON){
"sha": "9e86fabffc18844b568d59945ac66a9fa6993ceb",
"files": [
{ "path": "src/HotChocolate/Language/src/Language.SyntaxTree/IntValueNode.cs", "ranges": [[326, 326], [339, 339], [365, 365], [378, 378], [391, 391], [404, 404], [417, 417], [428, 428], [430, 430], [432, 432], [435, 435], [437, 437], [440, 440], [455, 455], [457, 457], [460, 460], [477, 477], [501, 501]] },
{ "path": "src/HotChocolate/Language/src/Language.SyntaxTree/FloatValueNode.cs", "ranges": [[202, 202], [207, 207], [231, 231], [249, 249], [254, 254]] }
]
}Project coverage: 58.6% (314039/536107 lines) |
tobias-tengler
force-pushed
the
tte/fix-value-node-concurrent-reads
branch
from
October 9, 2026 10:26
8534cd5 to
d6609e5
Compare
FloatValueNode stores double and decimal values inline and formats them on first read instead of in the constructor. StringValueNode publishes its lazily encoded segment through a volatile flag instead of an extra field.
FloatValueNode stores one inline double and a volatile state byte that fits in padding, so the node stays 72 B. Text nodes cache the parsed double on the first ToDouble, double nodes format lazily and publish the segment through the state, decimal nodes format eagerly and parse on conversion. EnumValueNode keeps one object field that holds the string, or a holder with the string and its UTF-8 bytes once a string node is encoded, so the node stays 64 B and text nodes are unchanged. IntValueNode parses floating point conversions as long first and throws through ThrowHelper when the text is not numeric.
tobias-tengler
force-pushed
the
tte/fix-value-node-concurrent-reads
branch
from
October 10, 2026 16:50
67984b1 to
620a8ed
Compare
FloatValueNode no longer stores State.Text in its text constructor, since a new node starts in that state. StringValueNode sets its segment flag unconditionally instead of evaluating IsEmpty. EnumValueNode moves the encode fallback of AsSpan and AsMemorySegment into a non-inlined helper so that the fast path inlines into callers.
This branch has not been deployed
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.
Fixes #10506
Value nodes cached their lazily formatted UTF-8 text in a 32-byte
ReadOnlyMemorySegmentfield. That write is not atomic, so two threads reading a node for the first time could see a half-written segment and get an empty literal, which surfaced as SDL like@constraint(minLength: ).This PR publishes every lazily computed value through a single reference or a volatile flag.
IntValueNodeformats in its constructor and parses on conversion.FloatValueNodekeeps one inlinedoubleand a volatile state byte: a node built from text caches the parsed double behind the state, a node built from adoubleformats its text lazily and publishes it behind the state, and a node built from adecimalformats eagerly.StringValueNodepublishes its encoded bytes behind a volatile flag.EnumValueNodekeeps its string in one reference field and swaps in a small holder with the string and its UTF-8 bytes once encoded. No node is larger than onmain.Benchmarks
The float and string rows for a new text node and the enum
AsSpan()rows were re-measured after def4ec3, which trims the text constructors and letsEnumValueNode.AsSpan()inline, withmainand the PR in the same session. Every other row is from the original pass.IntValueNodemainToByte()mainToByte()ToSByte()mainToSByte()ToInt16()mainToInt16()ToUInt16()mainToUInt16()ToInt32()mainToInt32()ToUInt32()mainToUInt32()ToInt64()mainToInt64()ToUInt64()mainToUInt64()ToDouble()mainToDouble()AsSpan()mainAsSpan()ValuemainValueulongmainulonglongmainlonguintmainuintintmainintushortmainushortshortmainshortsbytemainsbytebytemainbytebyte,ToByte()mainbyte,ToByte()sbyte,ToSByte()mainsbyte,ToSByte()short,ToInt16()mainshort,ToInt16()ushort,ToUInt16()mainushort,ToUInt16()int,ToInt32()mainint,ToInt32()uint,ToUInt32()mainuint,ToUInt32()long,ToInt64()mainlong,ToInt64()ulong,ToUInt64()mainulong,ToUInt64()int,ToDouble()mainint,ToDouble()ulong,AsSpan()mainulong,AsSpan()long,AsSpan()mainlong,AsSpan()uint,AsSpan()mainuint,AsSpan()ushort,AsSpan()mainushort,AsSpan()short,AsSpan()mainshort,AsSpan()sbyte,AsSpan()mainsbyte,AsSpan()byte,AsSpan()mainbyte,AsSpan()int,AsSpan()mainint,AsSpan()ulong,Valuemainulong,Valuelong,Valuemainlong,Valueuint,Valuemainuint,Valueushort,Valuemainushort,Valueshort,Valuemainshort,Valuesbyte,Valuemainsbyte,Valuebyte,Valuemainbyte,Valueint,Valuemainint,ValueToByte()mainToByte()ToSByte()mainToSByte()ToInt16()mainToInt16()ToUInt16()mainToUInt16()ToInt32()mainToInt32()ToUInt32()mainToUInt32()ToInt64()mainToInt64()ToUInt64()mainToUInt64()ToDouble()mainToDouble()AsSpan()mainAsSpan()ValuemainValuebyte,ToByte()mainbyte,ToByte()sbyte,ToSByte()mainsbyte,ToSByte()short,ToInt16()mainshort,ToInt16()ushort,ToUInt16()mainushort,ToUInt16()int,ToInt32()mainint,ToInt32()uint,ToUInt32()mainuint,ToUInt32()long,ToInt64()mainlong,ToInt64()ulong,ToUInt64()mainulong,ToUInt64()int,ToDouble()mainint,ToDouble()ulong,AsSpan()mainulong,AsSpan()long,AsSpan()mainlong,AsSpan()uint,AsSpan()mainuint,AsSpan()ushort,AsSpan()mainushort,AsSpan()short,AsSpan()mainshort,AsSpan()sbyte,AsSpan()mainsbyte,AsSpan()byte,AsSpan()mainbyte,AsSpan()int,AsSpan()mainint,AsSpan()ulong,Valuemainulong,Valuelong,Valuemainlong,Valueuint,Valuemainuint,Valueint,Valuemainint,Valueushort,Valuemainushort,Valueshort,Valuemainshort,Valuesbyte,Valuemainsbyte,Valuebyte,Valuemainbyte,ValueVerdict: a net improvement. On the hot path, Hot Chocolate coerces each integer variable 32% faster with 46% less allocation, because the node no longer allocates a
byte[]on its first conversion, and Fusion forwards each integer variable 8 B smaller at the same speed. Cost analysis reads literal slicing arguments as doubles 5 times faster, because the text is parsed as alongfirst. A reused node parses its text on every conversion, which costs about 1 ns more per call and only reaches the hot path for arguments that mix literals and variables. Building a node from anintcosts 2.5 ns more because the text is formatted eagerly, and allocates 16 B less; printing or forwarding such a node is 2 to 3 times faster. The reusedToUInt64()row is an artifact of the long pass: the out-of-process pass measured 2.07 ns against 0.89 ns onmain, in line with the other widths.FloatValueNodemainToDouble()mainToDouble()ToDecimal()mainToDecimal()AsSpan()mainAsSpan()ValuemainValuedecimalmaindecimaldoublemaindoubledecimal,ToDouble()maindecimal,ToDouble()double,ToDouble()maindouble,ToDouble()double,ToDecimal()maindouble,ToDecimal()double,AsSpan()maindouble,AsSpan()decimal,ToDecimal()maindecimal,ToDecimal()decimal,AsSpan()maindecimal,AsSpan()decimal,Valuemaindecimal,Valuedouble,Valuemaindouble,ValueToDouble()mainToDouble()ToDecimal()mainToDecimal()AsSpan()mainAsSpan()ValuemainValuedecimal,ToDouble()maindecimal,ToDouble()double,ToDouble()maindouble,ToDouble()double,ToDecimal()maindouble,ToDecimal()decimal,ToDecimal()maindecimal,ToDecimal()decimal,AsSpan()maindecimal,AsSpan()double,AsSpan()maindouble,AsSpan()decimal,Valuemaindecimal,Valuedouble,Valuemaindouble,ValueVerdict: a net improvement on the hot path. The node stays at 72 B, so Fusion forwards float variables unchanged, Hot Chocolate coerces each float variable 13% faster with 40% less allocation, and a reused node returns its cached double in 0.3 ns instead of parsing. Nodes built from
doubleare faster and smaller on every operation. Nodes built fromdecimal, which onlyDecimalType.ValueToLiteralcreates, format their text eagerly and parse it back on conversion, which costs 3 to 4 times more on those paths.ToDecimal()on a reused text node parses on every call, 11 ns more, because the node has no room for a cached decimal. TheValuerow on a new text node shows +16% in the long pass and -3% in the out-of-process pass, so it is noise.StringValueNodemainAsSpan()mainAsSpan()ValuemainValueValuethenAsSpan()mainValuethenAsSpan()stringmainstringstring,AsSpan()mainstring,AsSpan()string,Valuemainstring,Valuestring,ValuethenAsSpan()mainstring,ValuethenAsSpan()AsSpan()mainAsSpan()ValuemainValuestring,AsSpan()mainstring,AsSpan()string,Valuemainstring,ValueVerdict: neutral to positive. Node size is unchanged, construction and
Valueare within noise, and encoding a node built from astringis 20% faster on the first call and 57% faster on a reused node. The two reusedValuerows differ by 0.2 ns in the long pass and are equal in the out-of-process pass.EnumValueNodemainAsSpan()mainAsSpan()ValuemainValuemainstringmainstringAsSpan()mainAsSpan()string,Valuemainstring,Valuestring,AsSpan()mainstring,AsSpan()ValuemainValueAsSpan()mainAsSpan()ValuemainValueAsSpan()mainAsSpan()string,AsSpan()mainstring,AsSpan()ValuemainValuestring,Valuemainstring,ValueVerdict: neutral on the hot path. Every node stays at 64 B, Fusion forwards enum variables unchanged, and reading
Valueon a reused node is twice as fast. The two "fromstring" construction rows did not reproduce in the out-of-process pass (7.07 ns onmainagainst 6.46 ns, and 8.34 ns against 8.91 ns withValue): the remaining 0.6 ns onValueis one type check that the JIT folds away onmainonly when construction and read sit in the same method, which they do not in production. Encoding a node built from astringallocates a 32 B holder once; no request path does that. Nodes built from an enum value are equal on construction andValue, and encoding one allocates the same 32 B holder.