Skip to content

Commit d79f4ec

Browse files
committed
test(parser): track number of allocations (#12555)
- related: oxc-project/backlog#5 This PR adds a reliable method of tracking the number of allocations made while running the parser. This includes both arena allocations (via a new feature), as well as system allocations (by creating a new global allocator). Reductions in these numbers should correlate to real-world performance improvements that aren't quantified in CodSpeed currently. This will also help prevent regressions where we accidentally allocate lots more memory than we expect. Originally I had included total system bytes allocated, but it was tricky to get this number to match exactly between platforms. I opted not try and make this perfect but instead use the total number of allocations which is a good proxy for bytes anyway.
1 parent d93e373 commit d79f4ec

File tree

12 files changed

+348
-3
lines changed

12 files changed

+348
-3
lines changed

.cargo/config.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ codecov = "llvm-cov --workspace --ignore-filename-regex tasks"
66
coverage = "run -p oxc_coverage --profile coverage --"
77
benchmark = "bench -p oxc_benchmark"
88
minsize = "run -p oxc_minsize --profile coverage --"
9+
allocs = "run -p oxc_track_memory_allocations --profile coverage --"
910
rule = "run -p rulegen"
1011

1112
# Build oxlint in release mode

.github/workflows/ci.yml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,36 @@ jobs:
282282
cargo minsize
283283
git diff --exit-code
284284
285+
allocs:
286+
name: Allocations
287+
runs-on: ubuntu-latest
288+
steps:
289+
- uses: taiki-e/checkout-action@b13d20b7cda4e2f325ef19895128f7ff735c0b3d # v1.3.1
290+
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
291+
id: filter
292+
with:
293+
filters: |
294+
src:
295+
- '.github/workflows/ci.yml'
296+
- 'crates/oxc_ast/**'
297+
- 'crates/oxc_data_structures/**'
298+
- 'crates/oxc_parser/**'
299+
- 'crates/oxc_allocator/**'
300+
- 'tasks/allocs/**'
301+
302+
- uses: oxc-project/setup-rust@cd82e1efec7fef815e2c23d296756f31c7cdc03d # v1.0.0
303+
if: steps.filter.outputs.src == 'true'
304+
with:
305+
cache-key: allocs
306+
save-cache: ${{ github.ref_name == 'main' }}
307+
308+
- name: Check allocations
309+
if: steps.filter.outputs.src == 'true'
310+
run: |
311+
cargo allocs
312+
git diff --exit-code ||
313+
(echo 'Allocations have changed. Run the `cargo allocs` command to update the allocation snapshot, otherwise please fix the regression.' && exit 1)
314+
285315
ast_changes:
286316
name: AST Changes
287317
runs-on: ubuntu-latest

Cargo.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/oxc_allocator/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,5 @@ fixed_size = ["from_raw_parts", "dep:oxc_ast_macros"]
4040
disable_fixed_size = []
4141
from_raw_parts = []
4242
serialize = ["dep:serde", "oxc_estree/serialize"]
43+
track_allocations = []
44+
disable_track_allocations = []

crates/oxc_allocator/src/alloc.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,25 @@ impl Alloc for Bump {
9595
/// Panics if reserving space for `layout` fails.
9696
#[inline(always)]
9797
fn alloc(&self, layout: Layout) -> NonNull<u8> {
98+
// SAFETY: We only use `Bump` inside of `Allocator` in oxc, so the `self` reference should
99+
// also be pointing to a valid `Allocator` struct, which we can use for finding the stats fields.
100+
// This will go away when we add a custom allocator to oxc.
101+
#[cfg(all(feature = "track_allocations", not(feature = "disable_track_allocations")))]
102+
unsafe {
103+
use crate::Allocator;
104+
use std::{
105+
mem::offset_of,
106+
ptr,
107+
sync::atomic::{AtomicUsize, Ordering},
108+
};
109+
#[expect(clippy::cast_possible_wrap)]
110+
const OFFSET: isize = (offset_of!(Allocator, num_alloc) as isize)
111+
- (offset_of!(Allocator, bump) as isize);
112+
let num_alloc_ptr = ptr::from_ref(self).byte_offset(OFFSET).cast::<AtomicUsize>();
113+
let num_alloc = num_alloc_ptr.as_ref().unwrap_unchecked();
114+
num_alloc.fetch_add(1, Ordering::SeqCst);
115+
}
116+
98117
self.alloc_layout(layout)
99118
}
100119

@@ -133,6 +152,25 @@ impl Alloc for Bump {
133152
/// Panics / aborts if reserving space for `new_layout` fails.
134153
#[inline(always)]
135154
unsafe fn grow(&self, ptr: NonNull<u8>, old_layout: Layout, new_layout: Layout) -> NonNull<u8> {
155+
// SAFETY: We only use `Bump` inside of `Allocator` in oxc, so the `self` reference should
156+
// also be pointing to a valid `Allocator` struct, which we can use for finding the stats fields.
157+
// This will go away when we add a custom allocator to oxc.
158+
#[cfg(all(feature = "track_allocations", not(feature = "disable_track_allocations")))]
159+
unsafe {
160+
use crate::Allocator;
161+
use std::{
162+
mem::offset_of,
163+
ptr,
164+
sync::atomic::{AtomicUsize, Ordering},
165+
};
166+
#[expect(clippy::cast_possible_wrap)]
167+
const OFFSET: isize = (offset_of!(Allocator, num_realloc) as isize)
168+
- (offset_of!(Allocator, bump) as isize);
169+
let num_realloc_ptr = ptr::from_ref(self).byte_offset(OFFSET).cast::<AtomicUsize>();
170+
let num_realloc = num_realloc_ptr.as_ref().unwrap_unchecked();
171+
num_realloc.fetch_add(1, Ordering::SeqCst);
172+
}
173+
136174
// SAFETY: Safety requirements of `Allocator::grow` are the same as for this method
137175
let res = unsafe { Allocator::grow(&self, ptr, old_layout, new_layout) };
138176
match res {

crates/oxc_allocator/src/allocator.rs

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,20 @@ use oxc_data_structures::assert_unchecked;
214214
/// [`HashMap::new_in`]: crate::HashMap::new_in
215215
#[derive(Default)]
216216
pub struct Allocator {
217+
#[cfg(not(all(feature = "track_allocations", not(feature = "disable_track_allocations"))))]
217218
bump: Bump,
219+
// NOTE: We need to expose `bump` publicly here for calculating its field offset in memory.
220+
#[cfg(all(feature = "track_allocations", not(feature = "disable_track_allocations")))]
221+
#[doc(hidden)]
222+
pub bump: Bump,
223+
/// Used to track the total number of allocations made in this allocator when the `track_allocations` feature is enabled.
224+
#[cfg(all(feature = "track_allocations", not(feature = "disable_track_allocations")))]
225+
#[doc(hidden)]
226+
pub num_alloc: std::sync::atomic::AtomicUsize,
227+
/// Used to track the total number of re-allocations made in this allocator when the `track_allocations` feature is enabled.
228+
#[cfg(all(feature = "track_allocations", not(feature = "disable_track_allocations")))]
229+
#[doc(hidden)]
230+
pub num_realloc: std::sync::atomic::AtomicUsize,
218231
}
219232

220233
impl Allocator {
@@ -241,7 +254,13 @@ impl Allocator {
241254
#[expect(clippy::inline_always)]
242255
#[inline(always)]
243256
pub fn new() -> Self {
244-
Self { bump: Bump::new() }
257+
Self {
258+
bump: Bump::new(),
259+
#[cfg(all(feature = "track_allocations", not(feature = "disable_track_allocations")))]
260+
num_alloc: std::sync::atomic::AtomicUsize::new(0),
261+
#[cfg(all(feature = "track_allocations", not(feature = "disable_track_allocations")))]
262+
num_realloc: std::sync::atomic::AtomicUsize::new(0),
263+
}
245264
}
246265

247266
/// Create a new [`Allocator`] with specified capacity.
@@ -252,7 +271,13 @@ impl Allocator {
252271
#[expect(clippy::inline_always)]
253272
#[inline(always)]
254273
pub fn with_capacity(capacity: usize) -> Self {
255-
Self { bump: Bump::with_capacity(capacity) }
274+
Self {
275+
bump: Bump::with_capacity(capacity),
276+
#[cfg(all(feature = "track_allocations", not(feature = "disable_track_allocations")))]
277+
num_alloc: std::sync::atomic::AtomicUsize::new(0),
278+
#[cfg(all(feature = "track_allocations", not(feature = "disable_track_allocations")))]
279+
num_realloc: std::sync::atomic::AtomicUsize::new(0),
280+
}
256281
}
257282

258283
/// Allocate an object in this [`Allocator`] and return an exclusive reference to it.
@@ -276,6 +301,9 @@ impl Allocator {
276301
pub fn alloc<T>(&self, val: T) -> &mut T {
277302
const { assert!(!std::mem::needs_drop::<T>(), "Cannot allocate Drop type in arena") };
278303

304+
#[cfg(all(feature = "track_allocations", not(feature = "disable_track_allocations")))]
305+
self.num_alloc.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
306+
279307
self.bump.alloc(val)
280308
}
281309

@@ -297,6 +325,9 @@ impl Allocator {
297325
#[expect(clippy::inline_always)]
298326
#[inline(always)]
299327
pub fn alloc_str<'alloc>(&'alloc self, src: &str) -> &'alloc str {
328+
#[cfg(all(feature = "track_allocations", not(feature = "disable_track_allocations")))]
329+
self.num_alloc.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
330+
300331
self.bump.alloc_str(src)
301332
}
302333

@@ -317,6 +348,9 @@ impl Allocator {
317348
#[expect(clippy::inline_always)]
318349
#[inline(always)]
319350
pub fn alloc_slice_copy<T: Copy>(&self, src: &[T]) -> &mut [T] {
351+
#[cfg(all(feature = "track_allocations", not(feature = "disable_track_allocations")))]
352+
self.num_alloc.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
353+
320354
self.bump.alloc_slice_copy(src)
321355
}
322356

@@ -329,6 +363,9 @@ impl Allocator {
329363
///
330364
/// Panics if reserving space matching `layout` fails.
331365
pub fn alloc_layout(&self, layout: Layout) -> NonNull<u8> {
366+
#[cfg(all(feature = "track_allocations", not(feature = "disable_track_allocations")))]
367+
self.num_alloc.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
368+
332369
self.bump.alloc_layout(layout)
333370
}
334371

@@ -379,6 +416,9 @@ impl Allocator {
379416
"attempted to create a string longer than `isize::MAX` bytes"
380417
);
381418

419+
#[cfg(all(feature = "track_allocations", not(feature = "disable_track_allocations")))]
420+
self.num_alloc.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
421+
382422
// Create actual `&str` in a separate function, to ensure that `alloc_concat_strs_array`
383423
// is inlined, so that compiler has knowledge to remove the overflow checks above.
384424
// When some of `strings` are static, this function is usually only a few instructions.
@@ -471,6 +511,12 @@ impl Allocator {
471511
#[expect(clippy::inline_always)]
472512
#[inline(always)]
473513
pub fn reset(&mut self) {
514+
#[cfg(all(feature = "track_allocations", not(feature = "disable_track_allocations")))]
515+
{
516+
self.num_alloc.store(0, std::sync::atomic::Ordering::SeqCst);
517+
self.num_realloc.store(0, std::sync::atomic::Ordering::SeqCst);
518+
}
519+
474520
self.bump.reset();
475521
}
476522

@@ -592,7 +638,13 @@ impl Allocator {
592638
#[expect(clippy::inline_always)]
593639
#[inline(always)]
594640
pub(crate) fn from_bump(bump: Bump) -> Self {
595-
Self { bump }
641+
Self {
642+
bump,
643+
#[cfg(all(feature = "track_allocations", not(feature = "disable_track_allocations")))]
644+
num_alloc: std::sync::atomic::AtomicUsize::new(0),
645+
#[cfg(all(feature = "track_allocations", not(feature = "disable_track_allocations")))]
646+
num_realloc: std::sync::atomic::AtomicUsize::new(0),
647+
}
596648
}
597649
}
598650

justfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,10 @@ benchmark:
132132
benchmark-one *args:
133133
cargo benchmark --bench {{args}} --no-default-features --features {{args}}
134134

135+
# Update memory allocation snapshots.
136+
allocs:
137+
cargo allocs
138+
135139
# Automatically DRY up Cargo.toml manifests in a workspace.
136140
autoinherit:
137141
cargo binstall cargo-autoinherit
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
[package]
2+
name = "oxc_track_memory_allocations"
3+
version = "0.0.0"
4+
edition.workspace = true
5+
license.workspace = true
6+
publish = false
7+
8+
[lints]
9+
workspace = true
10+
11+
[lib]
12+
test = false
13+
doctest = false
14+
15+
[[bin]]
16+
name = "oxc_track_memory_allocations"
17+
test = false
18+
doctest = false
19+
20+
[dependencies]
21+
oxc_allocator = { workspace = true, features = ["track_allocations"] }
22+
oxc_parser = { workspace = true }
23+
oxc_tasks_common = { workspace = true }
24+
25+
humansize = { workspace = true }
26+
mimalloc-safe = { workspace = true }
27+
28+
[features]
29+
# Sentinel flag that should only get enabled if we're running under `--all-features`
30+
is_all_features = []
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Oxc allocations stats
2+
3+
This task keeps track of the number of system allocations as well as arena allocations and the total number of bytes allocated. This is used for monitoring possible regressions in allocations and improvements in memory usage.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
File | File size || Sys allocs | Sys reallocs || Arena allocs | Arena reallocs | Arena bytes
2+
-------------------------------------------------------------------------------------------------------------------------------------------
3+
checker.ts | 2.92 MB || 10161 | 21 || 268665 | 23341 | 12.422 MB
4+
5+
cal.com.tsx | 1.06 MB || 2209 | 54 || 138188 | 13712 | 7.768 MB
6+
7+
RadixUIAdoptionSection.jsx | 2.52 kB || 4 | 0 || 365 | 66 | 19.104 kB
8+
9+
pdf.mjs | 567.30 kB || 688 | 71 || 90678 | 8148 | 4.308 MB
10+
11+
antd.js | 4.12 MB || 6860 | 282 || 509752 | 55282 | 25.574 MB
12+
13+
binder.ts | 193.08 kB || 540 | 7 || 16807 | 1475 | 873.704 kB
14+

0 commit comments

Comments
 (0)