Skip to content

Commit 041b86e

Browse files
authored
perf(rendering): make rendering parallel (#440)
This PR makes rendering of trees fully parallel <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/iamgio/quarkdown/pull/440" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end -->
2 parents 7836622 + 6f82afa commit 041b86e

File tree

12 files changed

+324
-18
lines changed

12 files changed

+324
-18
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
## [Unreleased]
44

5+
### Changed
6+
7+
#### Parallel rendering
8+
9+
Rendering now runs in parallel across sibling elements, improving performance on large documents.
10+
511
## [1.15.1] - 2026-03-31
612

713
### Changed

quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/Node.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.quarkdown.core.ast
22

3+
import com.quarkdown.core.util.mapParallel
34
import com.quarkdown.core.visitor.node.NodeVisitor
45

56
/**
@@ -37,3 +38,23 @@ interface SingleChildNestableNode<T : Node> : NestableNode {
3738
override val children: List<Node>
3839
get() = listOf(child)
3940
}
41+
42+
/**
43+
* Accepts a visitor for each node sequentially.
44+
* @param visitor the visitor to accept
45+
* @return the list of results from each visit, preserving order
46+
*/
47+
fun <T> List<Node>.acceptAll(visitor: NodeVisitor<T>): List<T> = map { it.accept(visitor) }
48+
49+
/**
50+
* Accepts a visitor for each node, executing visits in parallel when beneficial.
51+
* Falls back to sequential execution for small lists.
52+
* @param visitor the visitor to accept
53+
* @param minItems minimum number of nodes required for parallel execution
54+
* @return the list of results from each visit, preserving order
55+
* @see mapParallel
56+
*/
57+
fun <T> List<Node>.parallelAcceptAll(
58+
visitor: NodeVisitor<T>,
59+
minItems: Int = 50,
60+
): List<T> = mapParallel(minItems) { it.accept(visitor) }
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package com.quarkdown.core.ast.attributes.reference
2+
3+
import com.quarkdown.core.ast.quarkdown.bibliography.BibliographyCitation
4+
import com.quarkdown.core.context.Context
5+
import com.quarkdown.core.context.MutableContext
6+
import com.quarkdown.core.property.Property
7+
8+
/**
9+
* Pre-computed citation label for a [BibliographyCitation] node.
10+
*
11+
* Labels are computed eagerly in document order during the
12+
* [com.quarkdown.core.context.hooks.reference.BibliographyCitationResolverHook] phase,
13+
* so that parallel rendering can read them without depending on visit order.
14+
* @param value the formatted citation label (e.g. `"[1]"`, `"[1, 2]"`)
15+
*/
16+
data class CitationLabelProperty(
17+
override val value: String,
18+
) : Property<String> {
19+
companion object : Property.Key<String>
20+
21+
override val key = CitationLabelProperty
22+
}
23+
24+
/**
25+
* Retrieves the pre-computed citation label for this node.
26+
* @param context context where the property is stored
27+
* @return the citation label, or `null` if not pre-computed
28+
*/
29+
fun BibliographyCitation.getCitationLabel(context: Context): String? = context.attributes.of(this)[CitationLabelProperty]
30+
31+
/**
32+
* Stores a pre-computed citation label on this node.
33+
* @param context context where the property is stored
34+
* @param label the citation label to store
35+
*/
36+
fun BibliographyCitation.setCitationLabel(
37+
context: MutableContext,
38+
label: String,
39+
) {
40+
context.attributes.of(this) += CitationLabelProperty(label)
41+
}

quarkdown-core/src/main/kotlin/com/quarkdown/core/context/hooks/reference/BibliographyCitationResolverHook.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.quarkdown.core.context.hooks.reference
22

3+
import com.quarkdown.core.ast.attributes.reference.ReferenceNode
4+
import com.quarkdown.core.ast.attributes.reference.setCitationLabel
35
import com.quarkdown.core.ast.iterator.ObservableAstIterator
46
import com.quarkdown.core.ast.quarkdown.bibliography.BibliographyCitation
57
import com.quarkdown.core.ast.quarkdown.bibliography.BibliographyView
@@ -11,6 +13,10 @@ import com.quarkdown.core.context.MutableContext
1113
* Hook that associates bibliography entries to each [BibliographyCitation]
1214
* that can be linked to entries of a [Bibliography]
1315
* within a [BibliographyView].
16+
*
17+
* After each citation is resolved, this hook pre-computes its citation label in document order
18+
* and stores it as a [com.quarkdown.core.ast.attributes.reference.CitationLabelProperty] on the node,
19+
* so that parallel rendering can safely read it without depending on visit order.
1420
*/
1521
class BibliographyCitationResolverHook(
1622
context: MutableContext,
@@ -32,4 +38,13 @@ class BibliographyCitationResolverHook(
3238
}
3339
bibliography to (entries to bibliography)
3440
}
41+
42+
override fun onResolved(
43+
reference: ReferenceNode<BibliographyCitation, Pair<List<BibliographyEntry>, BibliographyView>>,
44+
definition: Pair<List<BibliographyEntry>, BibliographyView>,
45+
) {
46+
val (entries, view) = definition
47+
val label = view.style.labelProvider.getCitationLabel(entries)
48+
reference.reference.setCitationLabel(context, label)
49+
}
3550
}

quarkdown-core/src/main/kotlin/com/quarkdown/core/context/hooks/reference/ReferenceDefinitionResolverHook.kt

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ abstract class ReferenceDefinitionResolverHook<R, DN : Node, D>(
2626
indexReferences(references).forEach { (index, reference) ->
2727
val definition = findDefinitionPair(reference.reference, definitions, index)
2828
definition?.let {
29-
reference.setDefinition(context, transformDefinitionPair(it))
29+
val transformed = transformDefinitionPair(it)
30+
reference.setDefinition(context, transformed)
31+
onResolved(reference, transformed)
3032
}
3133
}
3234
}
@@ -71,4 +73,15 @@ abstract class ReferenceDefinitionResolverHook<R, DN : Node, D>(
7173
* @return the definition to be associated with the reference
7274
*/
7375
protected open fun transformDefinitionPair(definition: Pair<DN, D>): D = definition.second
76+
77+
/**
78+
* Called after a reference has been successfully resolved and associated with its definition.
79+
* Subclasses can override this to perform additional processing per resolved pair.
80+
* @param reference the resolved reference node
81+
* @param definition the definition associated with the reference
82+
*/
83+
protected open fun onResolved(
84+
reference: ReferenceNode<R, D>,
85+
definition: D,
86+
) {}
7487
}

quarkdown-core/src/main/kotlin/com/quarkdown/core/property/AssociatedProperties.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
package com.quarkdown.core.property
22

3+
import java.util.concurrent.ConcurrentHashMap
4+
import java.util.concurrent.ConcurrentMap
5+
36
/**
47
* Associations between a key of type [T] and a [PropertyContainer].
58
*
@@ -30,7 +33,7 @@ interface AssociatedProperties<T, V> {
3033
* Mutable implementation of [AssociatedProperties].
3134
*/
3235
class MutableAssociatedProperties<T, V> : AssociatedProperties<T, V> {
33-
private val properties: MutableMap<T, MutablePropertyContainer<V>> = mutableMapOf()
36+
private val properties: ConcurrentMap<T, MutablePropertyContainer<V>> = ConcurrentHashMap()
3437

35-
override fun of(key: T): MutablePropertyContainer<V> = properties.getOrPut(key) { MutablePropertyContainer() }
38+
override fun of(key: T): MutablePropertyContainer<V> = properties.computeIfAbsent(key) { MutablePropertyContainer() }
3639
}

quarkdown-core/src/main/kotlin/com/quarkdown/core/rendering/tag/TagBuilder.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.quarkdown.core.rendering.tag
22

33
import com.quarkdown.core.ast.Node
4+
import com.quarkdown.core.ast.parallelAcceptAll
45

56
/**
67
* A builder of a generic output code wrapped within tags (of any kind) which can be unlimitedly nested.
@@ -63,12 +64,12 @@ abstract class TagBuilder(
6364
}
6465

6566
/**
66-
* Appends a sequence of nodes to this tag's content.
67+
* Appends a sequence of nodes to this tag's content, rendering in parallel when beneficial.
6768
* Their string representation is given by this [TagBuilder]'s [renderer].
6869
* Usage: `+someNode.children`
6970
*/
7071
operator fun List<Node>.unaryPlus() {
71-
forEach { +it }
72+
parallelAcceptAll(renderer).forEach { +it }
7273
}
7374
}
7475

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.quarkdown.core.util
2+
3+
import java.util.stream.Collectors
4+
5+
/**
6+
* Default minimum number of items required for parallel execution to be worthwhile.
7+
* Below this threshold, the overhead of thread scheduling exceeds the benefit.
8+
*/
9+
private const val DEFAULT_MIN_ITEMS_FOR_PARALLELISM = 4
10+
11+
/**
12+
* Maps each element of this list using [transform], executing transformations in parallel
13+
* when the list is large enough to benefit from concurrency.
14+
* Falls back to sequential mapping for small lists where parallelism overhead exceeds benefit.
15+
*
16+
* Uses [java.util.stream.Stream.parallel] with the common [java.util.concurrent.ForkJoinPool],
17+
* which handles nested parallelism via work-stealing without risking deadlocks.
18+
*
19+
* Results are returned in the same order as the input list.
20+
* @param minItems minimum number of items required for parallel execution.
21+
* Lists smaller than this are mapped sequentially
22+
* @param transform the transformation to apply to each element
23+
* @return the list of transformed results, preserving input order
24+
*/
25+
fun <T, R> List<T>.mapParallel(
26+
minItems: Int = DEFAULT_MIN_ITEMS_FOR_PARALLELISM,
27+
transform: (T) -> R,
28+
): List<R> {
29+
if (size < minItems) {
30+
return map(transform)
31+
}
32+
return parallelStream().map(transform).collect(Collectors.toList())
33+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package com.quarkdown.core.util
2+
3+
import kotlin.test.Test
4+
import kotlin.test.assertEquals
5+
import kotlin.test.assertFailsWith
6+
import kotlin.test.assertTrue
7+
8+
class MapParallelTest {
9+
@Test
10+
fun `empty list`() {
11+
val result = emptyList<Int>().mapParallel { it * 2 }
12+
assertEquals(emptyList(), result)
13+
}
14+
15+
@Test
16+
fun `below threshold is sequential`() {
17+
val result = listOf(1, 2, 3).mapParallel { it * 2 }
18+
assertEquals(listOf(2, 4, 6), result)
19+
}
20+
21+
@Test
22+
fun `at threshold is parallel`() {
23+
val result = listOf(1, 2, 3, 4).mapParallel { it * 2 }
24+
assertEquals(listOf(2, 4, 6, 8), result)
25+
}
26+
27+
@Test
28+
fun `above threshold preserves order`() {
29+
val result =
30+
(1..100).toList().mapParallel { element ->
31+
Thread.sleep(10 - (element % 10).toLong()) // Varying delay to provoke reordering
32+
element * 3
33+
}
34+
assertEquals((1..100).map { it * 3 }, result)
35+
}
36+
37+
@Test
38+
fun `nested calls do not deadlock`() {
39+
// Outer list triggers parallelism, each inner list also exceeds the threshold.
40+
val result =
41+
(1..8).toList().mapParallel { outer ->
42+
(1..8).toList().mapParallel { inner ->
43+
outer * 10 + inner
44+
}
45+
}
46+
val expected = (1..8).map { outer -> (1..8).map { inner -> outer * 10 + inner } }
47+
assertEquals(expected, result)
48+
}
49+
50+
@Test
51+
fun `triple nesting does not deadlock`() {
52+
val result =
53+
(1..4).toList().mapParallel { a ->
54+
(1..4).toList().mapParallel { b ->
55+
(1..4).toList().mapParallel { c ->
56+
a * 100 + b * 10 + c
57+
}
58+
}
59+
}
60+
val expected =
61+
(1..4).map { a ->
62+
(1..4).map { b ->
63+
(1..4).map { c -> a * 100 + b * 10 + c }
64+
}
65+
}
66+
assertEquals(expected, result)
67+
}
68+
69+
@Test
70+
fun `exception propagation`() {
71+
assertFailsWith<IllegalStateException> {
72+
(1..10).toList().mapParallel { element ->
73+
if (element == 5) error("fail")
74+
element
75+
}
76+
}
77+
}
78+
79+
@Test
80+
fun `runs concurrently above threshold`() {
81+
val threads =
82+
java.util.concurrent.ConcurrentHashMap
83+
.newKeySet<String>()
84+
(1..8).toList().mapParallel {
85+
threads += Thread.currentThread().name
86+
Thread.sleep(50)
87+
it
88+
}
89+
assertTrue(threads.size > 1, "Expected multiple threads, got: $threads")
90+
}
91+
}

quarkdown-html/src/main/kotlin/com/quarkdown/rendering/html/node/QuarkdownHtmlNodeRenderer.kt

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import com.quarkdown.core.ast.attributes.id.getId
77
import com.quarkdown.core.ast.attributes.localization.LocalizedKind
88
import com.quarkdown.core.ast.attributes.location.LocationTrackableNode
99
import com.quarkdown.core.ast.attributes.location.getLocationLabel
10+
import com.quarkdown.core.ast.attributes.reference.getCitationLabel
1011
import com.quarkdown.core.ast.attributes.reference.getDefinition
1112
import com.quarkdown.core.ast.base.TextNode
1213
import com.quarkdown.core.ast.base.block.BlockQuote
@@ -59,7 +60,6 @@ import com.quarkdown.core.ast.quarkdown.invisible.PageNumberReset
5960
import com.quarkdown.core.ast.quarkdown.invisible.SlidesConfigurationInitializer
6061
import com.quarkdown.core.ast.quarkdown.reference.CrossReference
6162
import com.quarkdown.core.ast.quarkdown.reference.CrossReferenceableNode
62-
import com.quarkdown.core.bibliography.BibliographyEntry
6363
import com.quarkdown.core.context.Context
6464
import com.quarkdown.core.context.localization.localizeOrNull
6565
import com.quarkdown.core.context.shouldAutoPageBreak
@@ -477,10 +477,7 @@ class QuarkdownHtmlNodeRenderer(
477477
}
478478

479479
override fun visit(node: BibliographyCitation): CharSequence {
480-
val (entries: List<BibliographyEntry>, view: BibliographyView) =
481-
node.getDefinition(context) ?: return Text("[???]").accept(this)
482-
483-
val label = view.style.labelProvider.getCitationLabel(entries)
480+
val label = node.getCitationLabel(context) ?: return Text("[???]").accept(this)
484481
return Text(label).accept(this)
485482
}
486483

0 commit comments

Comments
 (0)