Background
Richard Startin's loop fission article demonstrates that splitting loops with multiple distinct operations into separate loops allows C2 to optimize each loop more aggressively — vectorization, unrolling, and better register allocation. C2 does not perform loop fission automatically.
Current code
BytOutput.writeStringSegmentAsUTF8() fuses ASCII detection with byte writing in a single loop:
private void writeStringSegmentAsUTF8(String s, int start, int end) {
int segLen = end - start;
ensureCapacity(segLen * 3);
for (int i = start; i < end; i++) {
char c = s.charAt(i);
if (c < 0x80) {
buf[pos++] = (byte) c; // ASCII fast path
} else if (c < 0x800) {
buf[pos++] = (byte) (0xC0 | (c >> 6)); // 2-byte UTF-8
buf[pos++] = (byte) (0x80 | (c & 0x3F));
} else if (Character.isHighSurrogate(c) && i + 1 < end) {
// 4-byte UTF-8 surrogate pair
...
} else {
// 3-byte UTF-8
...
}
}
}
The multi-byte UTF-8 branches prevent C2 from vectorizing or aggressively unrolling the ASCII copy, even though for h5m's production data (93% strings, almost entirely ASCII), the branch is never taken for most strings.
Proposed optimization
Split into an ASCII scan + bulk copy, with a fallback for non-ASCII:
private void writeStringSegmentAsUTF8(String s, int start, int end) {
ensureCapacity((end - start) * 3);
// Scan for the end of the ASCII prefix
int asciiEnd = start;
while (asciiEnd < end && s.charAt(asciiEnd) < 0x80) asciiEnd++;
// Bulk copy the ASCII prefix — tight loop with no branches
for (int i = start; i < asciiEnd; i++) {
buf[pos++] = (byte) s.charAt(i);
}
// Handle remaining non-ASCII characters (if any)
if (asciiEnd < end) {
writeStringSegmentAsUTF8Full(s, asciiEnd, end);
}
}
The ASCII copy loop (buf[pos++] = (byte) s.charAt(i)) has no branches in the body — C2 can unroll it aggressively and potentially vectorize the copy. The multi-byte encoding is moved to a separate method that C2 optimizes independently.
Why this helps
From the Startin article: a fused loop "advances at the pace of the slowest operation." The multi-byte UTF-8 branches are the slowest operation — they prevent C2 from treating the loop as a simple copy. Splitting them out lets the ASCII loop run at full speed.
For h5m's production data:
- 93% of JSON values are strings
- Strings are almost entirely ASCII (field names, URLs, timestamps, metric names)
- Each string's
writeStringSegmentAsUTF8 call would use the branchless ASCII loop
- Only strings with non-ASCII content (rare) fall through to the multi-byte path
What's already fissioned in jjq
jjq already applies loop fission in several places:
needsEscaping() scans for escape chars, then escapeJson() does the escaping — two separate loops
- SWAR string scanning (
parseStringBytes) scans for quote/backslash in bulk, then processes escapes separately
- SWAR whitespace skipping scans 8 bytes at a time, then falls back to scalar
This change would extend the fission pattern to the UTF-8 encoding path.
Expected impact
Low-medium. The existing serialization is already 60% faster than Jackson on 14MB production data. The benefit would be most visible for workloads with many short ASCII strings where the per-string writeStringSegmentAsUTF8 overhead is proportionally larger.
Should benchmark before and after to verify. The serialization path is not the primary bottleneck for h5m (parsing and query execution dominate).
Reference
Background
Richard Startin's loop fission article demonstrates that splitting loops with multiple distinct operations into separate loops allows C2 to optimize each loop more aggressively — vectorization, unrolling, and better register allocation. C2 does not perform loop fission automatically.
Current code
BytOutput.writeStringSegmentAsUTF8()fuses ASCII detection with byte writing in a single loop:The multi-byte UTF-8 branches prevent C2 from vectorizing or aggressively unrolling the ASCII copy, even though for h5m's production data (93% strings, almost entirely ASCII), the branch is never taken for most strings.
Proposed optimization
Split into an ASCII scan + bulk copy, with a fallback for non-ASCII:
The ASCII copy loop (
buf[pos++] = (byte) s.charAt(i)) has no branches in the body — C2 can unroll it aggressively and potentially vectorize the copy. The multi-byte encoding is moved to a separate method that C2 optimizes independently.Why this helps
From the Startin article: a fused loop "advances at the pace of the slowest operation." The multi-byte UTF-8 branches are the slowest operation — they prevent C2 from treating the loop as a simple copy. Splitting them out lets the ASCII loop run at full speed.
For h5m's production data:
writeStringSegmentAsUTF8call would use the branchless ASCII loopWhat's already fissioned in jjq
jjq already applies loop fission in several places:
needsEscaping()scans for escape chars, thenescapeJson()does the escaping — two separate loopsparseStringBytes) scans for quote/backslash in bulk, then processes escapes separatelyThis change would extend the fission pattern to the UTF-8 encoding path.
Expected impact
Low-medium. The existing serialization is already 60% faster than Jackson on 14MB production data. The benefit would be most visible for workloads with many short ASCII strings where the per-string
writeStringSegmentAsUTF8overhead is proportionally larger.Should benchmark before and after to verify. The serialization path is not the primary bottleneck for h5m (parsing and query execution dominate).
Reference