Skip to content

Commit 97ce779

Browse files
authored
Merge pull request #3372 from rintaro/arena-auto-compaction
[SwiftParser] Compact incremental-parse arenas past a threshold
2 parents 7c0e2a9 + 5654fcb commit 97ce779

4 files changed

Lines changed: 176 additions & 3 deletions

File tree

Sources/SwiftParser/IncrementalParseTransition.swift

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,28 @@ public typealias ReusedNodeCallback = (_ node: Syntax) -> Void
5252
/// Keeps track of a previously parsed syntax tree and the source edits that
5353
/// occurred since it was created.
5454
public final class IncrementalParseTransition {
55+
/// The default value for `compactArenaThreshold`.
56+
///
57+
/// Bounds the retained arena count to roughly this many while keeping
58+
/// compaction (a full reparse) infrequent enough not to disrupt editing
59+
/// latency on large files.
60+
public static let defaultCompactArenaThreshold = 64
61+
5562
fileprivate let previousIncrementalParseResult: IncrementalParseResult
5663
fileprivate let edits: ConcurrentEdits
5764
fileprivate let reusedNodeCallback: ReusedNodeCallback?
5865

66+
/// When the previous tree retains at least this many arenas, the next
67+
/// incremental parse is replaced by a full reparse that collapses the result
68+
/// back into a single arena.
69+
///
70+
/// Incremental parsing reuses subtrees from the previous tree by reference,
71+
/// which keeps each reused subtree's origin arena alive. Over a long editing
72+
/// session this accumulates arenas (and their memory) without bound. This
73+
/// threshold caps the number of retained arenas, trading an occasional full
74+
/// reparse for bounded memory. `nil` disables compaction.
75+
fileprivate let compactArenaThreshold: Int?
76+
5977
/// - Parameters:
6078
/// - previousTree: The previous tree to do lookups on.
6179
/// - edits: The edits that have occurred since the last parse that resulted
@@ -71,21 +89,43 @@ public final class IncrementalParseTransition {
7189
self.previousIncrementalParseResult = IncrementalParseResult(tree: previousTree, lookaheadRanges: lookaheadRanges)
7290
self.edits = edits
7391
self.reusedNodeCallback = reusedNodeCallback
92+
self.compactArenaThreshold = Self.defaultCompactArenaThreshold
7493
}
7594

7695
/// - Parameters:
7796
/// - previousIncrementalParseResult: The previous incremental parse result to do lookups on.
7897
/// - edits: The edits that have occurred since the last parse that resulted
7998
/// in the new source that is about to be parsed.
8099
/// - reusedNodeCallback: Optional closure to accept information about the re-used node. For each node that gets re-used `reusedNodeCallback` is called.
100+
/// - compactArenaThreshold: If the previous tree retains at least this many
101+
/// arenas, the next incremental parse is replaced by a full reparse to
102+
/// bound memory. Defaults to ``defaultCompactArenaThreshold``; pass `nil`
103+
/// to disable compaction.
81104
public init(
82105
previousIncrementalParseResult: IncrementalParseResult,
83106
edits: ConcurrentEdits,
84-
reusedNodeCallback: ReusedNodeCallback? = nil
107+
reusedNodeCallback: ReusedNodeCallback? = nil,
108+
compactArenaThreshold: Int? = IncrementalParseTransition.defaultCompactArenaThreshold
85109
) {
86110
self.previousIncrementalParseResult = previousIncrementalParseResult
87111
self.edits = edits
88112
self.reusedNodeCallback = reusedNodeCallback
113+
self.compactArenaThreshold = compactArenaThreshold
114+
}
115+
116+
/// Whether the next incremental parse against this transition should instead
117+
/// be a full reparse to collapse accumulated arenas.
118+
///
119+
/// An incremental parse retains at most `previous + 1` arenas (its own new
120+
/// arena plus a subset of the previous tree's arenas), so this can be decided
121+
/// from the previous tree alone, before parsing.
122+
var shouldCompact: Bool {
123+
// When compaction is disabled, avoid computing `retainedArenaCount` at all;
124+
// it walks the retained child-arena set (O(arenas)), which is exactly the
125+
// unbounded quantity we would otherwise be paying every parse. When enabled,
126+
// the count is bounded by the threshold, so the walk is O(threshold).
127+
guard let compactArenaThreshold else { return false }
128+
return previousIncrementalParseResult.tree.raw.arena.retainedArenaCount + 1 > compactArenaThreshold
89129
}
90130
}
91131

Sources/SwiftParser/ParseSourceFile.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,9 @@ extension Parser {
135135
source: String,
136136
parseTransition: IncrementalParseTransition?
137137
) -> IncrementalParseResult {
138+
// Drop the transition (forcing a full reparse) when the previous tree is
139+
// fragmented enough that this parse would reach its compaction threshold.
140+
let parseTransition = (parseTransition?.shouldCompact ?? false) ? nil : parseTransition
138141
return withParser(
139142
source: source,
140143
maximumNestingLevel: nil,
@@ -153,6 +156,7 @@ extension Parser {
153156
maximumNestingLevel: Int? = nil,
154157
parseTransition: IncrementalParseTransition?
155158
) -> IncrementalParseResult {
159+
let parseTransition = (parseTransition?.shouldCompact ?? false) ? nil : parseTransition
156160
return withParser(
157161
source: source,
158162
maximumNestingLevel: maximumNestingLevel,

Sources/SwiftSyntax/Raw/RawSyntaxArena.swift

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,19 @@ public class RawSyntaxArena {
177177
func contains(text: SyntaxText) -> Bool {
178178
return (text.isEmpty || allocator.contains(address: text.baseAddress!))
179179
}
180+
181+
/// Number of distinct arenas (this one plus retained child arenas) that the
182+
/// tree rooted in this arena keeps alive.
183+
public var retainedArenaCount: Int {
184+
var seen: Set<RawSyntaxArenaRef> = [RawSyntaxArenaRef(self)]
185+
var stack: [RawSyntaxArena] = [self]
186+
while let arena = stack.popLast() {
187+
for childRef in arena.childRefs where seen.insert(childRef).inserted {
188+
stack.append(childRef.value)
189+
}
190+
}
191+
return seen.count
192+
}
180193
}
181194

182195
/// RawSyntaxArena for parsing.
@@ -229,6 +242,11 @@ public struct RetainedRawSyntaxArena: @unchecked Sendable {
229242
fileprivate func arenaRef() -> RawSyntaxArenaRef {
230243
return RawSyntaxArenaRef(arena)
231244
}
245+
246+
/// Number of arenas (self + retained children) kept alive by this tree.
247+
public var retainedArenaCount: Int {
248+
arena.retainedArenaCount
249+
}
232250
}
233251

234252
/// Unsafely unowned reference to ``RawSyntaxArena``. The user is responsible to
@@ -245,7 +263,7 @@ struct RawSyntaxArenaRef: Hashable, @unchecked Sendable {
245263
}
246264

247265
/// Returns the ``RawSyntaxArena``
248-
private var value: RawSyntaxArena {
266+
fileprivate var value: RawSyntaxArena {
249267
self._value.takeUnretainedValue()
250268
}
251269

Tests/SwiftParserTest/IncrementalParsingTests.swift

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
//===----------------------------------------------------------------------===//
1212

1313
import SwiftParser
14-
import SwiftSyntax
14+
@_spi(RawSyntax) import SwiftSyntax
1515
import XCTest
1616
import _SwiftSyntaxTestSupport
1717

@@ -466,4 +466,115 @@ class IncrementalParsingTests: ParserTestCase {
466466
]
467467
)
468468
}
469+
470+
// MARK: - Arena compaction
471+
472+
/// A source file of `count` distinct top-level functions. Editing one function
473+
/// reuses the others from earlier arenas, so editing distinct functions in
474+
/// turn accumulates retained arenas.
475+
private func manyFunctions(_ count: Int) -> String {
476+
return (0..<count).map { "func f\($0)() -> Int { return \($0) }\n" }.joined()
477+
}
478+
479+
/// Insert `text` at the start of `needle` in `source`, returning the new
480+
/// source and the corresponding edit.
481+
private func insert(
482+
_ text: String,
483+
before needle: String,
484+
in source: String
485+
) -> (newSource: String, edit: SourceEdit) {
486+
let range = source.range(of: needle)!
487+
let offset = source.utf8.distance(from: source.utf8.startIndex, to: range.lowerBound.samePosition(in: source.utf8)!)
488+
var utf8 = Array(source.utf8)
489+
utf8.insert(contentsOf: Array(text.utf8), at: offset)
490+
let edit = SourceEdit(
491+
range: AbsolutePosition(utf8Offset: offset)..<AbsolutePosition(utf8Offset: offset),
492+
replacement: text
493+
)
494+
return (String(decoding: utf8, as: UTF8.self), edit)
495+
}
496+
497+
/// Edits distinct functions in turn and returns the peak retained arena count
498+
/// observed across the edit sequence. Asserts every intermediate tree
499+
/// round-trips.
500+
private func peakRetainedArenas(functionCount: Int, edits: Int, compactArenaThreshold: Int?) throws -> Int {
501+
var source = manyFunctions(functionCount)
502+
var result = Parser.parseIncrementally(source: source, parseTransition: nil)
503+
var peak = result.tree.raw.arena.retainedArenaCount
504+
for i in 0..<edits {
505+
let (newSource, edit) = insert(" ", before: "return \(i % functionCount) ", in: source)
506+
let transition = IncrementalParseTransition(
507+
previousIncrementalParseResult: result,
508+
edits: try ConcurrentEdits(concurrent: [edit]),
509+
compactArenaThreshold: compactArenaThreshold
510+
)
511+
result = Parser.parseIncrementally(source: newSource, parseTransition: transition)
512+
XCTAssertEqual(result.tree.description, newSource, "tree did not round-trip after edit \(i)")
513+
peak = max(peak, result.tree.raw.arena.retainedArenaCount)
514+
source = newSource
515+
}
516+
return peak
517+
}
518+
519+
/// With compaction disabled, editing distinct functions accumulates arenas
520+
/// well beyond a small bound — this validates that the compaction test below
521+
/// is actually exercising arena accumulation.
522+
public func testArenaCountAccumulatesWithoutCompaction() throws {
523+
let peak = try peakRetainedArenas(functionCount: 16, edits: 16, compactArenaThreshold: nil)
524+
// Each edit reuses the other functions from earlier arenas, so the retained
525+
// count grows by roughly one per edit (peak here is near `1 + edits` ≈ 17).
526+
// This is only a control asserting that accumulation happens at all, so use
527+
// a loose lower bound: comfortably above the compaction-bounded regime (the
528+
// test below caps at 4) yet well under the true peak, so it stays robust to
529+
// small variations in how nodes are pinned across arenas.
530+
XCTAssertGreaterThan(peak, 8, "expected arenas to accumulate without compaction")
531+
}
532+
533+
/// With a low threshold, compaction keeps the retained arena count bounded by
534+
/// the threshold across a long edit sequence, while every tree still
535+
/// round-trips.
536+
public func testArenaCompactionBoundsRetainedArenaCount() throws {
537+
let threshold = 4
538+
let peak = try peakRetainedArenas(functionCount: 16, edits: 32, compactArenaThreshold: threshold)
539+
XCTAssertLessThanOrEqual(peak, threshold, "compaction should keep retained arenas at or below the threshold")
540+
}
541+
542+
/// A tree produced by a compaction (full reparse) can still be used as the
543+
/// basis for a subsequent incremental parse that reuses nodes.
544+
public func testIncrementalParseAfterCompaction() throws {
545+
let source = manyFunctions(16)
546+
var result = Parser.parseIncrementally(source: source, parseTransition: nil)
547+
548+
// Force a compaction on the next parse with threshold 1 (any incremental
549+
// parse's previous tree retains >= 1 arena, so `previous + 1 > 1` always
550+
// holds and forces a compaction).
551+
let (afterFirst, edit1) = insert(" ", before: "return 0 ", in: source)
552+
result = Parser.parseIncrementally(
553+
source: afterFirst,
554+
parseTransition: IncrementalParseTransition(
555+
previousIncrementalParseResult: result,
556+
edits: try ConcurrentEdits(concurrent: [edit1]),
557+
compactArenaThreshold: 1
558+
)
559+
)
560+
XCTAssertEqual(result.tree.raw.arena.retainedArenaCount, 1, "compaction should collapse to a single arena")
561+
XCTAssertEqual(result.tree.description, afterFirst)
562+
563+
// The compacted tree drives a normal incremental parse that reuses nodes.
564+
var reusedCodeBlockItems = 0
565+
let (afterSecond, edit2) = insert(" ", before: "return 5 ", in: afterFirst)
566+
let result2 = Parser.parseIncrementally(
567+
source: afterSecond,
568+
parseTransition: IncrementalParseTransition(
569+
previousIncrementalParseResult: result,
570+
edits: try ConcurrentEdits(concurrent: [edit2]),
571+
reusedNodeCallback: { node in
572+
if node.kind == .codeBlockItem { reusedCodeBlockItems += 1 }
573+
},
574+
compactArenaThreshold: nil
575+
)
576+
)
577+
XCTAssertEqual(result2.tree.description, afterSecond)
578+
XCTAssertGreaterThan(reusedCodeBlockItems, 0, "incremental parse after compaction should reuse nodes")
579+
}
469580
}

0 commit comments

Comments
 (0)