Skip to content

Commit 10c157f

Browse files
perf: optimize lazy source map lookups
1 parent 634536f commit 10c157f

3 files changed

Lines changed: 233 additions & 43 deletions

File tree

benchmarks/real-world.mjs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@ const LOOKUP_COUNT = 1_000;
1818
const LOOKUP_MAX_COLUMN = 200;
1919
const LOOKUP_SEED = 0x5eed1234;
2020

21+
const createOrderedLookups = (count, maxLine, descending) =>
22+
Array.from({ length: count }, (_, index) => {
23+
const line = Math.floor((index * maxLine) / count);
24+
return {
25+
line: descending ? maxLine - 1 - line : line,
26+
column: (index * 13) % LOOKUP_MAX_COLUMN,
27+
};
28+
});
29+
2130
// ── Load fixtures ────────────────────────────────────────────────
2231

2332
const FIXTURES = [
@@ -172,6 +181,60 @@ for (const { name, json, size } of maps) {
172181
);
173182
}
174183

184+
// ── Fast-lazy lookup order ───────────────────────────────────────
185+
186+
console.log("\n--- Fast-Lazy Lookup Order ---\n");
187+
188+
for (const { name, json, size, lines } of maps) {
189+
console.log(`### ${name}\n`);
190+
191+
const midLine = Math.floor(lines / 2);
192+
const patterns = [
193+
{ name: "ascending", lookups: createOrderedLookups(LOOKUP_COUNT, lines, false) },
194+
{ name: "descending", lookups: createOrderedLookups(LOOKUP_COUNT, lines, true) },
195+
{
196+
name: "repeated",
197+
lookups: Array.from({ length: LOOKUP_COUNT }, () => ({ line: midLine, column: 20 })),
198+
},
199+
{
200+
name: "randomized",
201+
lookups: createDeterministicLookups(LOOKUP_COUNT, lines, LOOKUP_MAX_COLUMN, LOOKUP_SEED),
202+
},
203+
].map((pattern) => ({ ...pattern, map: new FastSourceMap(json) }));
204+
205+
const isLargeMap = size > 1024 * 1024;
206+
const bench = createBench({
207+
warmupIterations: isLargeMap ? 5 : 20,
208+
iterations: isLargeMap ? 50 : 200,
209+
});
210+
const prefix = `real_world_lazy_lookup_1000x[${name}]`;
211+
212+
for (const pattern of patterns) {
213+
bench.add(`${prefix} ${pattern.name}`, () => {
214+
for (const { line, column } of pattern.lookups) {
215+
pattern.map.originalPositionFor(line, column);
216+
}
217+
});
218+
}
219+
220+
await bench.run();
221+
222+
console.table(
223+
bench.tasks.map((task) => ({
224+
Name: task.name,
225+
"ops/sec": Math.round(throughputHz(task)).toLocaleString(),
226+
"avg (μs)": (latencyMeanMs(task) * 1000).toFixed(1),
227+
"per lookup (ns)": Math.round(
228+
(latencyMeanMs(task) * 1_000_000) / LOOKUP_COUNT,
229+
).toLocaleString(),
230+
})),
231+
);
232+
233+
for (const pattern of patterns) {
234+
pattern.map.free();
235+
}
236+
}
237+
175238
// ── Single lookup ────────────────────────────────────────────────
176239

177240
console.log("\n--- Single Lookup ---\n");

crates/sourcemap/benches/parse.rs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,58 @@ fn batch_lookup_input() -> BatchLookupInput {
596596
BatchLookupInput { sm, lookups }
597597
}
598598

599+
struct LazyLookupInput {
600+
sm: LazySourceMap,
601+
lookups: Vec<(u32, u32)>,
602+
}
603+
604+
const LAZY_LOOKUP_LINES: u32 = 500;
605+
const LAZY_LOOKUP_COUNT: u32 = 1_000;
606+
607+
fn lazy_lookup_input(lookups: Vec<(u32, u32)>) -> LazyLookupInput {
608+
let sm = LazySourceMap::from_json_fast(&json_medium()).unwrap();
609+
LazyLookupInput { sm, lookups }
610+
}
611+
612+
fn ascending_lazy_lookup_input() -> LazyLookupInput {
613+
lazy_lookup_input(
614+
(0..LAZY_LOOKUP_COUNT)
615+
.map(|index| (index * LAZY_LOOKUP_LINES / LAZY_LOOKUP_COUNT, 30))
616+
.collect(),
617+
)
618+
}
619+
620+
fn descending_lazy_lookup_input() -> LazyLookupInput {
621+
lazy_lookup_input(
622+
(0..LAZY_LOOKUP_COUNT)
623+
.map(|index| {
624+
(LAZY_LOOKUP_LINES - 1 - index * LAZY_LOOKUP_LINES / LAZY_LOOKUP_COUNT, 30)
625+
})
626+
.collect(),
627+
)
628+
}
629+
630+
fn repeated_lazy_lookup_input() -> LazyLookupInput {
631+
lazy_lookup_input(vec![(LAZY_LOOKUP_LINES / 2, 30); LAZY_LOOKUP_COUNT as usize])
632+
}
633+
634+
fn randomized_lazy_lookup_input() -> LazyLookupInput {
635+
let mut state = 0x5eed_1234_u32;
636+
let lookups = std::iter::repeat_with(|| {
637+
state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
638+
(state % LAZY_LOOKUP_LINES, (state >> 16) % 200)
639+
})
640+
.take(LAZY_LOOKUP_COUNT as usize)
641+
.collect();
642+
lazy_lookup_input(lookups)
643+
}
644+
645+
fn run_lazy_lookups(input: &mut LazyLookupInput) {
646+
for &(line, column) in &input.lookups {
647+
black_box(input.sm.original_position_for(black_box(line), black_box(column)));
648+
}
649+
}
650+
599651
fn bench_lookup(criterion: &mut Criterion) {
600652
bench_with_input(criterion, "lookup_single_original_position_for", lookup_input, |sm| {
601653
sm.original_position_for(black_box(250), black_box(30))
@@ -612,6 +664,33 @@ fn bench_lookup(criterion: &mut Criterion) {
612664
);
613665
}
614666

667+
fn bench_lazy_lookup(criterion: &mut Criterion) {
668+
bench_with_input(
669+
criterion,
670+
"lazy_lookup_1000x_ascending",
671+
ascending_lazy_lookup_input,
672+
run_lazy_lookups,
673+
);
674+
bench_with_input(
675+
criterion,
676+
"lazy_lookup_1000x_descending",
677+
descending_lazy_lookup_input,
678+
run_lazy_lookups,
679+
);
680+
bench_with_input(
681+
criterion,
682+
"lazy_lookup_1000x_repeated",
683+
repeated_lazy_lookup_input,
684+
run_lazy_lookups,
685+
);
686+
bench_with_input(
687+
criterion,
688+
"lazy_lookup_1000x_randomized",
689+
randomized_lazy_lookup_input,
690+
run_lazy_lookups,
691+
);
692+
}
693+
615694
fn bench_vlq_decode(criterion: &mut Criterion) {
616695
bench_with_input(criterion, "vlq_decode_large_mappings_only", json_large_no_content, |json| {
617696
SourceMap::from_json(black_box(json)).unwrap()
@@ -637,6 +716,7 @@ criterion_group!(
637716
bench_vlq_isolation,
638717
bench_json_only,
639718
bench_lookup,
719+
bench_lazy_lookup,
640720
bench_vlq_decode,
641721
);
642722
criterion_main!(benches);

crates/sourcemap/src/lib.rs

Lines changed: 90 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
//! assert_eq!(pos.column, 0);
2424
//! ```
2525
26-
use std::cell::{Cell, OnceCell, RefCell};
26+
use std::cell::{OnceCell, RefCell};
2727
use std::collections::HashMap;
2828
use std::fmt;
2929
use std::io;
@@ -32,6 +32,9 @@ use serde::Deserialize;
3232
use srcmap_codec::{DecodeError, vlq_encode_unsigned};
3333
use srcmap_scopes::{Binding, CallSite, GeneratedRange, OriginalScope, Position, ScopeInfo};
3434

35+
#[cfg(test)]
36+
use std::cell::Cell;
37+
3538
pub mod js_identifiers;
3639
pub mod offset_lookup;
3740
pub mod source_view;
@@ -1987,10 +1990,11 @@ pub struct LazySourceMap {
19871990
/// If true, decode_line must decode sequentially from the start.
19881991
fast_scan: bool,
19891992

1990-
/// Highest line fully decoded so far (for progressive decode in fast-scan mode).
1991-
/// VLQ state at the end of this line is stored in `decode_state`.
1992-
decode_watermark: Cell<u32>,
1993-
decode_state: Cell<VlqState>,
1993+
/// Cumulative VLQ states indexed by the next undecoded line.
1994+
state_checkpoints: RefCell<Vec<VlqState>>,
1995+
1996+
#[cfg(test)]
1997+
decode_work: Cell<u32>,
19941998
}
19951999

19962000
impl LazySourceMap {
@@ -2028,8 +2032,13 @@ impl LazySourceMap {
20282032
decoded_lines: RefCell::new(HashMap::new()),
20292033
source_map,
20302034
fast_scan,
2031-
decode_watermark: Cell::new(0),
2032-
decode_state: Cell::new(VlqState::default()),
2035+
state_checkpoints: RefCell::new(if fast_scan {
2036+
vec![VlqState::default()]
2037+
} else {
2038+
Vec::new()
2039+
}),
2040+
#[cfg(test)]
2041+
decode_work: Cell::new(0),
20332042
}
20342043
}
20352044

@@ -2311,70 +2320,71 @@ impl LazySourceMap {
23112320
Ok((mappings, state))
23122321
}
23132322

2314-
/// Decode a single line's mappings on demand.
2315-
///
2316-
/// Returns the cached result if the line has already been decoded.
2317-
/// The line index is 0-based.
2318-
pub fn decode_line(&self, line: u32) -> Result<Vec<Mapping>, DecodeError> {
2319-
// Check cache first
2320-
if let Some(cached) = self.decoded_lines.borrow().get(&line) {
2321-
return Ok(cached.clone());
2323+
fn ensure_line_decoded(&self, line: u32) -> Result<(), DecodeError> {
2324+
if self.decoded_lines.borrow().contains_key(&line) {
2325+
return Ok(());
23222326
}
23232327

23242328
let line_idx = line as usize;
23252329
if line_idx >= self.line_info.len() {
2326-
return Ok(Vec::new());
2330+
return Ok(());
23272331
}
23282332

23292333
if self.fast_scan {
2330-
// In fast-scan mode, VLQ state is not pre-computed.
2331-
// Decode sequentially from the watermark (or line 0 for backward seeks).
2332-
// For both forward and backward walks, use cached lines where available
2333-
// and only walk VLQ bytes to compute state for already-decoded lines.
2334-
let watermark = self.decode_watermark.get();
2335-
let start = if line >= watermark { watermark } else { 0 };
2336-
let mut state = if line >= watermark {
2337-
self.decode_state.get()
2338-
} else {
2339-
VlqState { source_index: 0, original_line: 0, original_column: 0, name_index: 0 }
2340-
};
2334+
let mut checkpoints = self.state_checkpoints.borrow_mut();
2335+
let start = line_idx.min(checkpoints.len() - 1);
2336+
let mut state = checkpoints[start];
2337+
2338+
for current_line_idx in start..=line_idx {
2339+
#[cfg(test)]
2340+
self.decode_work.set(self.decode_work.get() + 1);
23412341

2342-
for l in start..=line {
2343-
let info = &self.line_info[l as usize];
2344-
if self.decoded_lines.borrow().contains_key(&l) {
2342+
let current_line = current_line_idx as u32;
2343+
let info = &self.line_info[current_line_idx];
2344+
if self.decoded_lines.borrow().contains_key(&current_line) {
23452345
// Already cached — just walk VLQ bytes to compute end-state
23462346
let bytes = self.raw_mappings.as_bytes();
23472347
state = walk_vlq_state(bytes, info.byte_offset, info.byte_end, state)?;
23482348
} else {
2349-
let (mappings, new_state) = self.decode_line_with_state(l, state)?;
2349+
let (mappings, new_state) = self.decode_line_with_state(current_line, state)?;
23502350
state = new_state;
2351-
self.decoded_lines.borrow_mut().insert(l, mappings);
2351+
self.decoded_lines.borrow_mut().insert(current_line, mappings);
23522352
}
2353-
}
23542353

2355-
// Update watermark (only advance, never regress)
2356-
if line + 1 > self.decode_watermark.get() {
2357-
self.decode_watermark.set(line + 1);
2358-
self.decode_state.set(state);
2354+
let next_line_idx = current_line_idx + 1;
2355+
if next_line_idx == checkpoints.len() {
2356+
checkpoints.push(state);
2357+
} else {
2358+
checkpoints[next_line_idx] = state;
2359+
}
23592360
}
23602361

2361-
let cached = self.decoded_lines.borrow().get(&line).cloned();
2362-
return Ok(cached.unwrap_or_default());
2362+
return Ok(());
23632363
}
23642364

2365-
// Normal mode: line_info has pre-computed VLQ state
23662365
let state = self.line_info[line_idx].state;
23672366
let (mappings, _) = self.decode_line_with_state(line, state)?;
2368-
self.decoded_lines.borrow_mut().insert(line, mappings.clone());
2369-
Ok(mappings)
2367+
self.decoded_lines.borrow_mut().insert(line, mappings);
2368+
Ok(())
2369+
}
2370+
2371+
/// Decode a single line's mappings on demand.
2372+
///
2373+
/// Returns the cached result if the line has already been decoded.
2374+
/// The line index is 0-based.
2375+
pub fn decode_line(&self, line: u32) -> Result<Vec<Mapping>, DecodeError> {
2376+
self.ensure_line_decoded(line)?;
2377+
Ok(self.decoded_lines.borrow().get(&line).cloned().unwrap_or_default())
23702378
}
23712379

23722380
/// Look up the original source position for a generated position.
23732381
///
23742382
/// Both `line` and `column` are 0-based.
23752383
/// Returns `None` if no mapping exists or the mapping has no source.
23762384
pub fn original_position_for(&self, line: u32, column: u32) -> Option<OriginalLocation> {
2377-
let line_mappings = self.decode_line(line).ok()?;
2385+
self.ensure_line_decoded(line).ok()?;
2386+
let decoded_lines = self.decoded_lines.borrow();
2387+
let line_mappings = decoded_lines.get(&line)?;
23782388

23792389
if line_mappings.is_empty() {
23802390
return None;
@@ -7292,6 +7302,43 @@ mod tests {
72927302
assert_eq!(loc0.line, 0);
72937303
}
72947304

7305+
#[test]
7306+
fn lazy_sourcemap_backward_cache_miss_resumes_from_checkpoint() {
7307+
let mappings = std::iter::repeat_n("AACA", 64).collect::<Vec<_>>().join(";");
7308+
let json =
7309+
format!(r#"{{"version":3,"sources":["a.js"],"names":[],"mappings":"{mappings}"}}"#);
7310+
let sm = LazySourceMap::from_json_fast(&json).unwrap();
7311+
7312+
sm.decode_line(63).unwrap();
7313+
sm.decoded_lines.borrow_mut().remove(&15);
7314+
sm.decode_work.set(0);
7315+
7316+
sm.decode_line(15).unwrap();
7317+
7318+
assert_eq!(sm.decode_work.get(), 1, "must resume at the line-15 checkpoint");
7319+
}
7320+
7321+
#[test]
7322+
fn lazy_sourcemap_fast_scan_matches_prescan_in_descending_and_randomized_order() {
7323+
let mappings = std::iter::repeat_n("AACA,CAAC", 64).collect::<Vec<_>>().join(";");
7324+
let json =
7325+
format!(r#"{{"version":3,"sources":["a.js"],"names":[],"mappings":"{mappings}"}}"#);
7326+
let fast = LazySourceMap::from_json_fast(&json).unwrap();
7327+
let prescan = LazySourceMap::from_json_no_content(&json).unwrap();
7328+
let position = |map: &LazySourceMap, line, column| {
7329+
map.original_position_for(line, column)
7330+
.map(|loc| (loc.source, loc.line, loc.column, loc.name))
7331+
};
7332+
7333+
for line in (0..64).rev() {
7334+
assert_eq!(position(&fast, line, 1), position(&prescan, line, 1));
7335+
}
7336+
7337+
for line in [17, 2, 61, 9, 42, 0, 31, 5, 63, 23, 17, 42] {
7338+
assert_eq!(position(&fast, line, 2), position(&prescan, line, 2));
7339+
}
7340+
}
7341+
72957342
#[test]
72967343
fn lazy_sourcemap_fast_scan_vs_prescan_consistency() {
72977344
// Verify fast_scan and prescan produce identical lookup results

0 commit comments

Comments
 (0)