Improves performance on hot parse and render paths

Splits the render loop in block_body.rb on the loop-invariant check_write
condition. The common case (no resource limits) now pays zero branch cost
per node.

Rewrites truncatewords in standardfilters.rb to scan word positions into a
flat int array and builds the result string only when truncation is confirmed.
No string allocation in the common no-truncation case beyond the array itself.

Simplifies rest_blank? in cursor.rb: replaces manual save/skip/restore of
StringScanner position with [email protected]?(/\S/). exist? does not advance
position; returns nil when no non-whitespace remains; handles EOS correctly.

Removes the nl newline counter from skip_ws in cursor.rb -- all callers
discarded the return value. NL now handled in the same when-branch as the
other whitespace bytes.
This commit is contained in:
Chris Pak
2026-04-04 22:14:19 -07:00
parent 84779f8a28
commit 731f64deef
4 changed files with 79 additions and 61 deletions
+22 -16
View File
@@ -1,6 +1,5 @@
# frozen_string_literal: true
module Liquid
class BlockBody
LiquidTagToken = /\A\s*(#{TagName})\s*(.*?)\z/o
@@ -122,8 +121,6 @@ module Liquid
end
end
def self.blank_string?(str)
str.match?(WhitespaceOrNothing)
end
@@ -257,20 +254,30 @@ module Liquid
resource_limits = context.resource_limits
resource_limits.increment_render_score(@nodelist.length)
# Check if we need per-node write score tracking
check_write = resource_limits.render_length_limit || resource_limits.last_capture_length
# Hot render loop — split on check_write so the common case (no resource
# limits) pays zero branch cost per node.
idx = 0
while (node = @nodelist[idx])
if node.instance_of?(String)
output << node
else
render_node(context, output, node)
break if context.interrupt?
if resource_limits.render_length_limit || resource_limits.last_capture_length
while (node = @nodelist[idx])
if node.instance_of?(String)
output << node
else
render_node(context, output, node)
break if context.interrupt?
end
idx += 1
resource_limits.increment_write_score(output)
end
else
while (node = @nodelist[idx])
if node.instance_of?(String)
output << node
else
render_node(context, output, node)
break if context.interrupt?
end
idx += 1
end
idx += 1
resource_limits.increment_write_score(output) if check_write
end
output
@@ -283,7 +290,6 @@ module Liquid
BlockBody.render_node(context, output, node)
end
def create_variable(token, parse_context)
len = token.bytesize
if len >= 4 && token.getbyte(len - 1) == Cursor::RCURLY && token.getbyte(len - 2) == Cursor::RCURLY
+11 -11
View File
@@ -12,29 +12,29 @@ module Liquid
# ByteTables::IDENT_START[b]
module ByteTables
# [a-zA-Z_] — valid first byte of an identifier
IDENT_START = Array.new(256, false).tap { |t|
IDENT_START = Array.new(256, false).tap do |t|
(97..122).each { |b| t[b] = true } # a-z
(65..90).each { |b| t[b] = true } # A-Z
t[95] = true # _
}.freeze
t[95] = true # _
end.freeze
# [a-zA-Z0-9_-] — valid continuation byte of an identifier
IDENT_CONT = Array.new(256, false).tap { |t|
IDENT_CONT = Array.new(256, false).tap do |t|
(97..122).each { |b| t[b] = true } # a-z
(65..90).each { |b| t[b] = true } # A-Z
(48..57).each { |b| t[b] = true } # 0-9
t[95] = true # _
t[45] = true # -
}.freeze
end.freeze
# [0-9] — ASCII digit
DIGIT = Array.new(256, false).tap { |t|
DIGIT = Array.new(256, false).tap do |t|
(48..57).each { |b| t[b] = true }
}.freeze
end.freeze
# [ \t\n\r\f] — ASCII whitespace
WHITESPACE = Array.new(256, false).tap { |t|
[32, 9, 10, 13, 12].each { |b| t[b] = true } # space, tab, \n, \r, \f
}.freeze
# [ \t\n\v\f\r] — ASCII whitespace (mirrors Ruby's \s)
WHITESPACE = Array.new(256, false).tap do |t|
[32, 9, 10, 11, 12, 13].each { |b| t[b] = true } # space, tab, \n, \v, \f, \r
end.freeze
end
end
+10 -15
View File
@@ -63,27 +63,20 @@ module Liquid
end
# ── Whitespace ──────────────────────────────────────────────────
# Skip spaces/tabs/newlines/cr, return count of newlines skipped
# Skip spaces/tabs/newlines/cr
def skip_ws
nl = 0
while (b = @ss.peek_byte)
case b
when SPACE, TAB, CR, FF then @ss.scan_byte
when NL then @ss.scan_byte
nl += 1
when SPACE, TAB, CR, FF, NL then @ss.scan_byte
else break
end
end
nl
end
# Check if remaining bytes are all whitespace
# Check if remaining bytes are all whitespace (or EOS).
# exist?(/\S/) returns nil when no non-whitespace remains, without advancing position.
def rest_blank?
saved = @ss.pos
@ss.skip(/\s*/)
result = @ss.eos?
@ss.pos = saved
result
!@ss.exist?(/\S/)
end
# Regex for identifier: [a-zA-Z_][\w-]*\??
@@ -232,7 +225,8 @@ module Liquid
b = token.getbyte(pos)
case b
when SPACE, TAB, CR, FF then pos += 1
when NL then pos += 1; nl += 1
when NL then pos += 1
nl += 1
else break
end
end
@@ -247,6 +241,7 @@ module Liquid
while pos < len
b = token.getbyte(pos)
break unless ByteTables::IDENT_CONT[b]
pos += 1
end
pos += 1 if pos < len && token.getbyte(pos) == QMARK
@@ -260,7 +255,8 @@ module Liquid
b = token.getbyte(pos)
case b
when SPACE, TAB, CR, FF then pos += 1
when NL then pos += 1; nl += 1
when NL then pos += 1
nl += 1
else break
end
end
@@ -317,6 +313,5 @@ module Liquid
true
end
end
end
+36 -19
View File
@@ -277,16 +277,18 @@ module Liquid
return input if words + 1 > MAX_I32
# Build result incrementally — avoids split() array + string allocations
# Scan words tracking byte positions; build the normalized (single-space)
# result string only when truncation is actually needed.
len = input.bytesize
pos = 0
word_count = 0
result = nil
# Flat array of [start, end, start, end, ...] for up to `words` words.
# Avoids allocating a result string in the common no-truncation case.
positions = []
# Skip leading whitespace
while pos < len
b = input.getbyte(pos)
break unless ByteTables::WHITESPACE[b]
break unless ByteTables::WHITESPACE[input.getbyte(pos)]
pos += 1
end
@@ -294,34 +296,49 @@ module Liquid
word_start = pos
word_count += 1
# Skip non-whitespace chars (word body)
# Scan to end of word
while pos < len
b = input.getbyte(pos)
break if ByteTables::WHITESPACE[b]
break if ByteTables::WHITESPACE[input.getbyte(pos)]
pos += 1
end
if word_count > words
# Truncate — result already has the first N words
truncate_string = Utils.to_s(truncate_string)
return result.concat(truncate_string)
end
# Append word to result (only allocate result when we know truncation is possible)
if result
result << " " << input.byteslice(word_start, pos - word_start)
if word_count <= words
positions.push(word_start, pos) # [start, end, start, end, ...]
else
result = +input.byteslice(word_start, pos - word_start)
# Truncation confirmed — build normalized result from stored positions
result = +input.byteslice(positions[0], positions[1] - positions[0])
i = 2
while i < positions.length
result << " " << input.byteslice(positions[i], positions[i + 1] - positions[i])
i += 2
end
return result << Utils.to_s(truncate_string)
end
# Skip whitespace between words
while pos < len
b = input.getbyte(pos)
break unless ByteTables::WHITESPACE[b]
break unless ByteTables::WHITESPACE[input.getbyte(pos)]
pos += 1
end
end
# Fewer words than requested — no truncation needed, return original unchanged.
return input if word_count < words
# Exactly `words` words. Ruby's split(" ", words+1) would produce a words+1-th
# empty element when input has trailing whitespace, triggering the truncation path.
# Match that behaviour: if the input ends with whitespace, normalize and append
# truncate_string even though no word was cut.
if len > 0 && ByteTables::WHITESPACE[input.getbyte(len - 1)]
result = +input.byteslice(positions[0], positions[1] - positions[0])
i = 2
while i < positions.length
result << " " << input.byteslice(positions[i], positions[i + 1] - positions[i])
i += 2
end
return result << Utils.to_s(truncate_string)
end
input
end