Skip to content

Commit

Permalink
Qute: try to reduce the number of array allocations during rendering
Browse files Browse the repository at this point in the history
- try to optimize the initial capacity of the StringBuilder used to
render the template, i.e. do not use the default or the hard-coded value
but try to precompute the value based on the template complexity and
also track the maximum length of results
- previously a hard-coded value of ~1K was always used as the initial
capacity
- also make it possible to set the initial capacity as an attribute of a template instance to override the default behavior
  • Loading branch information
mkouba committed Aug 30, 2024
1 parent 95a246a commit 3de3a73
Show file tree
Hide file tree
Showing 4 changed files with 132 additions and 3 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -1339,6 +1339,10 @@ public SectionHelper initialize(SectionInitContext context) {
private static final BlockNode BLOCK_NODE = new BlockNode();
static final CommentNode COMMENT_NODE = new CommentNode();

static boolean isDummyNode(TemplateNode node) {
return node == COMMENT_NODE || node == BLOCK_NODE;
}

// A dummy node for section blocks, it's only used when removing standalone lines
private static class BlockNode implements TemplateNode {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ class TemplateImpl implements Template {
private final List<ParameterDeclaration> parameterDeclarations;
private final LazyValue<Map<String, Fragment>> fragments;

// The initial capacity of the StringBuilder used to render the template
final Capacity capacity;

TemplateImpl(EngineImpl engine, SectionNode root, String templateId, String generatedId, Optional<Variant> variant) {
this.engine = engine;
this.root = root;
Expand All @@ -47,6 +50,7 @@ class TemplateImpl implements Template {
this.parameterDeclarations = ImmutableList.copyOf(root.getParameterDeclarations());
// Use a lazily initialized map to avoid unnecessary performance costs during parsing
this.fragments = initFragments(root);
this.capacity = new Capacity();
}

@Override
Expand Down Expand Up @@ -231,8 +235,30 @@ protected Engine engine() {
}

private CompletionStage<String> renderAsyncNoTimeout() {
StringBuilder builder = new StringBuilder(1028);
return renderData(data(), builder::append).thenApply(v -> builder.toString());
StringBuilder builder = new StringBuilder(getCapacity());
return renderData(data(), builder::append).thenApply(v -> {
String str = builder.toString();
capacity.update(str.length());
return str;
});
}

private int getCapacity() {
if (!attributes.isEmpty()) {
Object c = getAttribute(TemplateInstance.CAPACITY);
if (c != null) {
if (c instanceof Number) {
return ((Number) c).intValue();
} else {
try {
return Integer.parseInt(c.toString());
} catch (NumberFormatException e) {
LOG.warnf("Invalid capacity value set for " + toString() + ": " + c);
}
}
}
}
return capacity.get();
}

private CompletionStage<Void> renderData(Object data, Consumer<String> consumer) {
Expand Down Expand Up @@ -280,6 +306,64 @@ public String toString() {

}

class Capacity {

static final int LIMIT = 64 * 1024;

final int computed;
// intentionally not volatile; it's not a big deal if working with an outdated value
int max;

Capacity() {
this.computed = Math.min(computeCapacity(root.blocks.get(0)), LIMIT);
}

void update(int length) {
if (length > max) {
max = length < LIMIT ? length : LIMIT;
}
}

int get() {
return Math.max(max, computed);
}

private int computeCapacity(SectionBlock block) {
// This is a bit tricky because a template can contain a lot of dynamic parts
// Our approach is rather conservative, i.e. try not to overestimate/waste memory
int ret = 0;
for (TemplateNode node : block.nodes) {
if (Parser.isDummyNode(node)) {
continue;
}
if (node.isText()) {
ret += node.asText().getValue().length();
} else if (node.isExpression()) {
// Reserve 10 characters per expression
ret += 10;
} else if (node.isSection()) {
SectionHelper helper = node.asSection().getHelper();
if (LoopSectionHelper.class.isInstance(helper)) {
// Loop secion - multiply the capacity of the main block by 10
ret += 10 * computeCapacity(node.asSection().blocks.get(0));
} else if (IncludeSectionHelper.class.isInstance(helper)) {
// At this point we don't really know - the included template can be tiny or huge
// So we just reserve 500 characters
ret += 500;
} else if (UserTagSectionHelper.class.isInstance(helper)) {
// For user tags we don't expect large templates
ret += 200;
} else {
for (SectionBlock b : node.asSection().blocks) {
ret += computeCapacity(b);
}
}
}
}
return ret;
}
}

class FragmentImpl extends TemplateImpl implements Fragment {

FragmentImpl(EngineImpl engine, SectionNode root, String fragmentId, String generatedId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ public interface TemplateInstance {
*/
String LOCALE = "locale";

/**
* Attribute key - the initial capacity of the StringBuilder used to render the template.
*/
String CAPACITY = "capacity";

/**
* Set the the root data object. Invocation of this method removes any data set previously by
* {@link #data(String, Object)} and {@link #computedData(String, Function)}.
Expand Down Expand Up @@ -204,13 +209,23 @@ default TemplateInstance setLocale(Locale locale) {
/**
* Sets the variant attribute that can be used to select a specific variant of the template.
*
* @param variant the variant
* @param variant
* @return self
*/
default TemplateInstance setVariant(Variant variant) {
return setAttribute(SELECTED_VARIANT, variant);
}

/**
* Sets the initial capacity of the StringBuilder used to render the template.
*
* @param capacity
* @return self
*/
default TemplateInstance setCapacity(int capacity) {
return setAttribute(CAPACITY, capacity);
}

/**
* This component can be used to initialize a template instance, i.e. the data and attributes.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;

import org.junit.jupiter.api.Test;

import io.quarkus.qute.TemplateImpl.Capacity;

public class TemplateInstanceTest {

@Test
Expand Down Expand Up @@ -94,4 +98,26 @@ public void testVariant() {
String render = hello.instance().setVariant(Variant.forContentType(Variant.TEXT_HTML)).render();
assertEquals("Hello text/html!", render);
}

@Test
public void testCapacity() {
Engine engine = Engine.builder().addDefaults().build();
assertCapacity(engine, "foo", 3, 3, Map.of());
assertCapacity(engine, "{! comment is ignored !}foo", 3, 3, Map.of());
assertCapacity(engine, "{foo} and bar", 10 + 8, 28, Map.of("foo", "bazzz".repeat(4)));
assertCapacity(engine, "{#each foo}bar{/}", 10 * 3, 3, Map.of("foo", List.of(1)));
assertCapacity(engine, "{#include bar /} and bar", 500 + 8, -1, Map.of());
// limit reached
assertCapacity(engine, "{#each}{foo}{/}".repeat(1000), Capacity.LIMIT, -1, Map.of());
assertCapacity(engine, "{foo}", 10, Capacity.LIMIT, Map.of("foo", "b".repeat(70_000)));
}

private void assertCapacity(Engine engine, String val, int expectedComputed, int expectedMax, Map<String, Object> data) {
TemplateImpl template = (TemplateImpl) engine.parse(val);
assertEquals(expectedComputed, template.capacity.computed);
if (expectedMax != -1) {
template.render(data);
assertEquals(expectedMax, template.capacity.max);
}
}
}

0 comments on commit 3de3a73

Please sign in to comment.