Skip to content

Comments

perf(rolldown): re-implement renamer to avoid renaming non-root symbols#4014

Closed
Dunqing wants to merge 25 commits intomainfrom
04-03-refactor_rolldown_simplify_renamer_logic
Closed

perf(rolldown): re-implement renamer to avoid renaming non-root symbols#4014
Dunqing wants to merge 25 commits intomainfrom
04-03-refactor_rolldown_simplify_renamer_logic

Conversation

@Dunqing
Copy link
Collaborator

@Dunqing Dunqing commented Apr 2, 2025

Description

close: #4045

This PR is to re-implement the renamer logic to get rid of the expensive rename_non_root_symbol method, and can also address the #4045 problem.

Whether from a new output or performance perspective, I think this change is the correct direction and worth considering.

How does it work?

This implementation follows a principle that does NOT rename to a name that has been declared, even declared in child scopes. Plus, we regard the entry module as a base module because bundling will bundle every referenced code into the entry module. In other words, we ensure that all entry module names are maintained as-is.

Let's take an example to see how and why:

// entry.js
import './a.js'
const a = 0;

function func(a$1) {
  console.log(a, a$1)
}

// a.js
const a = 0;
console.log(a)

Before output:

//#region a.js
const a$1 = 0;
console.log(a$1);

//#endregion
//#region entry.js
const a = 0;
function func(a$1$1) {
	console.log(a, a$1$1);
}

//#endregion

After output:

//#region a.js
const a$2 = 0;
console.log(a$2);

//#endregion
//#region entry.js
const a = 0;
function func(a$1) {
	console.log(a, a$1);
}

//#endregion

Let's take a look at the before output first, as you can see, after bundling, the a in a.js has been renamed to a$1, which conflicts with func's param a$1, that lead param a$1 has to rename to a$1$1 to avoid conflict. And let's look at the after output, the a in a.js has been renamed to a$2 because a$1 has been declared in child scopes, as I mentioned principle earlier, so that param a$1 doesn't need to be renamed because no conflict here. In this way, we no longer need to call an expensive rename_non_root_symbol.

Another key change is that if the NamedImport has an alias name, then use it as its new name

// entry.js
import { y as x } from './y.js'
let y = "ENTRY"

Before output:

//#region y.js
const y = "y";
console.log(y);

//#endregion
//#region entry.js
let y$1 = "ENTRY";

//#endregion

After output:

//#region y.js
const x = "y";
console.log(x);

//#endregion
//#region entry.js
let y = "ENTRY";

//#endregion

Let's compare the before and the after output. Unlike the before implementation, we follow the principle we've mentioned earlier, we won't rename entry module names. So there is no change in the entry file. Let's see the changed part, the y in y.js has been renamed to x because x is the alias name of y they use, so that we don't need to calculate the name for it, just use the alias name!

Pefromance

https://codspeed.io/rolldown/rolldown/branches/04-03-refactor_rolldown_simplify_renamer_logic
image

I guess the main performance improvement comes in which we removed rename_non_root_symbols.

Further improvement

After this PR, the assumption that canonical_names will store the names of all symbols no longer exists. If canonical_names doesn't contain that symbol, that means this symbol doesn't need to be renamed. We should return early to avoid unnecessary steps. In this PR, I avoided introducing too many changes, so I changed canonical_name_for method to return the symbol name if it doesn't exist in canonical_names. I guess if we refactor this, that would be a notable performance improvement.

@Dunqing Dunqing marked this pull request as draft April 2, 2025 18:27
@github-actions
Copy link
Contributor

github-actions bot commented Apr 2, 2025

Benchmarks Rust

  • target: main(9140bc7)
  • pr: 04-03-refactor_rolldown_simplify_renamer_logic(c043c4a)
group                                                               pr                                     target
-----                                                               --                                     ------
bundle/bundle@multi-duplicated-top-level-symbol                     1.07     88.4±1.75ms        ? ?/sec    1.00     82.3±3.03ms        ? ?/sec
bundle/bundle@multi-duplicated-top-level-symbol-minify              1.07    111.7±2.08ms        ? ?/sec    1.00    104.0±2.34ms        ? ?/sec
bundle/bundle@multi-duplicated-top-level-symbol-minify-sourcemap    1.08    127.6±1.94ms        ? ?/sec    1.00    117.7±0.74ms        ? ?/sec
bundle/bundle@multi-duplicated-top-level-symbol-sourcemap           1.04     96.4±0.95ms        ? ?/sec    1.00     92.9±1.74ms        ? ?/sec
bundle/bundle@rome-ts                                               1.03    133.4±3.06ms        ? ?/sec    1.00    129.4±1.58ms        ? ?/sec
bundle/bundle@rome-ts-minify                                        1.04   245.7±11.47ms        ? ?/sec    1.00    235.9±3.20ms        ? ?/sec
bundle/bundle@rome-ts-minify-sourcemap                              1.00    275.6±2.53ms        ? ?/sec    1.02    280.9±5.49ms        ? ?/sec
bundle/bundle@rome-ts-sourcemap                                     1.01    143.4±1.34ms        ? ?/sec    1.00    141.5±1.32ms        ? ?/sec
bundle/bundle@threejs                                               1.00     45.3±2.05ms        ? ?/sec    1.00     45.4±2.18ms        ? ?/sec
bundle/bundle@threejs-minify                                        1.05    104.0±2.93ms        ? ?/sec    1.00     98.8±3.75ms        ? ?/sec
bundle/bundle@threejs-minify-sourcemap                              1.00    123.2±1.92ms        ? ?/sec    1.00    123.0±2.41ms        ? ?/sec
bundle/bundle@threejs-sourcemap                                     1.00     50.4±0.60ms        ? ?/sec    1.01     50.9±0.36ms        ? ?/sec
bundle/bundle@threejs10x                                            1.00    444.5±4.44ms        ? ?/sec    1.01    450.5±7.33ms        ? ?/sec
bundle/bundle@threejs10x-minify                                     1.00   1110.5±6.87ms        ? ?/sec    1.00   1113.1±8.87ms        ? ?/sec
bundle/bundle@threejs10x-minify-sourcemap                           1.00  1285.0±14.92ms        ? ?/sec    1.01  1296.5±11.13ms        ? ?/sec
bundle/bundle@threejs10x-sourcemap                                  1.00    519.1±4.15ms        ? ?/sec    1.02    529.0±4.83ms        ? ?/sec
remapping/remapping                                                 1.19     26.9±0.58ms        ? ?/sec    1.00     22.6±0.24ms        ? ?/sec
remapping/render-chunk-remapping                                    1.00     73.8±5.70ms        ? ?/sec    1.01     74.8±5.95ms        ? ?/sec
scan/scan@rome-ts                                                   1.03    106.5±1.33ms        ? ?/sec    1.00    103.8±1.31ms        ? ?/sec
scan/scan@threejs                                                   1.02     35.3±1.81ms        ? ?/sec    1.00     34.6±0.47ms        ? ?/sec
scan/scan@threejs10x                                                1.00    342.5±4.39ms        ? ?/sec    1.00    341.4±3.70ms        ? ?/sec

@Dunqing Dunqing force-pushed the 04-03-refactor_rolldown_simplify_renamer_logic branch 5 times, most recently from 63d6ab3 to e0c4471 Compare April 3, 2025 14:36
@Boshen Boshen force-pushed the next-oxc branch 2 times, most recently from f667596 to 0fcfb22 Compare April 7, 2025 07:08
@Dunqing Dunqing changed the base branch from next-oxc to main April 7, 2025 09:06
@Dunqing Dunqing force-pushed the 04-03-refactor_rolldown_simplify_renamer_logic branch from cda9a39 to 2bb9e72 Compare April 7, 2025 09:11
@netlify
Copy link

netlify bot commented Apr 7, 2025

Deploy Preview for rolldown-rs canceled.

Name Link
🔨 Latest commit c043c4a
🔍 Latest deploy log https://app.netlify.com/sites/rolldown-rs/deploys/67f62cb7a934960008082ac7

@Dunqing Dunqing force-pushed the 04-03-refactor_rolldown_simplify_renamer_logic branch from d7ac6f5 to 6ae4e9e Compare April 7, 2025 16:30
@Dunqing Dunqing force-pushed the 04-03-refactor_rolldown_simplify_renamer_logic branch from a0ca66a to 63c2e95 Compare April 8, 2025 08:39
@Dunqing Dunqing requested a review from Copilot April 8, 2025 08:44
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot reviewed 111 out of 125 changed files in this pull request and generated no comments.

Files not reviewed (14)
  • crates/rolldown/tests/esbuild/dce/cross_module_constant_folding_number/artifacts.snap: Language not supported
  • crates/rolldown/tests/esbuild/dce/cross_module_constant_folding_string/artifacts.snap: Language not supported
  • crates/rolldown/tests/esbuild/default/argument_default_value_scope_no_bundle/artifacts.snap: Language not supported
  • crates/rolldown/tests/esbuild/default/arguments_special_case_no_bundle/artifacts.snap: Language not supported
  • crates/rolldown/tests/esbuild/default/arrow_fn_scope/artifacts.snap: Language not supported
  • crates/rolldown/tests/esbuild/default/comment_preservation_preserve_jsx/artifacts.snap: Language not supported
  • crates/rolldown/tests/esbuild/default/direct_eval_tainting_no_bundle/artifacts.snap: Language not supported
  • crates/rolldown/tests/esbuild/default/exports_and_module_format_common_js/artifacts.snap: Language not supported
  • crates/rolldown/tests/esbuild/default/forbid_string_import_names_no_bundle/artifacts.snap: Language not supported
  • crates/rolldown/tests/esbuild/default/import_forms_with_minify_identifiers_and_no_bundle/artifacts.snap: Language not supported
  • crates/rolldown/tests/esbuild/default/import_forms_with_no_bundle/artifacts.snap: Language not supported
  • crates/rolldown/tests/esbuild/default/import_re_export_es6_issue149/artifacts.snap: Language not supported
  • crates/rolldown/tests/esbuild/default/inject/artifacts.snap: Language not supported
  • crates/rolldown/tests/esbuild/default/inject_jsx_dot_names/artifacts.snap: Language not supported

@Dunqing Dunqing changed the title refactor(rolldown): simplify renamer logic perf(rolldown): re-implement renamer to avoid renaming non-root symbols Apr 9, 2025
.module_used_names
.entry(canonical_ref.owner)
.or_insert_with(|| Self::get_module_used_names(self.symbol_db, canonical_ref))
.contains(candidate_name.as_str())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about merge the manual_reserved /used_names/entry_module_used_names/module_used_names into one used_names. It could reduce many hash.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

manual_reserved and entry_module_used_names has removed.

Unfortunately, used_names and module_used_names can't be merged into one due to their different uses.
used_names is a FxHashSet<Rstr> that is used to store names that are used for renaming.
module_used_names is a FxHashSet<&name Rstr> that stores the module used names, and is exactly used in what I said in #4014 (comment).

@IWANABETHATGUY
Copy link
Member

// entry.js
import { y as x } from './y.js'
let y = "ENTRY"
Before output:

//#region y.js
const y = "y";
console.log(y);

//#endregion
//#region entry.js
let y$1 = "ENTRY";

//#endregion
After output:

//#region y.js
const x = "y";
console.log(x);

//#endregion
//#region entry.js
let y = "ENTRY";

//#endregion

Can you revert this change? for a bundler responsibility, we should preserve the original definition as much as possible, before
we change a -> a$1, after you change x -> y, this make it hard to search the definition in source code, AFAIK, there is no bunlder rename like that when conflict.

@Dunqing
Copy link
Collaborator Author

Dunqing commented Apr 9, 2025

Can you revert this change? for a bundler responsibility, we should preserve the original definition as much as possible, before we change a -> a$1, after you change x -> y, this make it hard to search the definition in source code, AFAIK, there is no bunlder rename like that when conflict.

Good point! Yes, we can. The main reason I did this was to fix https://github.com/rollup/rollup/blob/64870c31062983a741832d8cd2d4bcdf55e4de55/test/function/samples/class-name-conflict-2 test fails. I found this case fails due to the treeshake option, after I disable, it works. It really weird.

@IWANABETHATGUY
Copy link
Member

Can you revert this change? for a bundler responsibility, we should preserve the original definition as much as possible, before we change a -> a$1, after you change x -> y, this make it hard to search the definition in source code, AFAIK, there is no bunlder rename like that when conflict.

Good point! Yes, we can. The main reason I did this was to fix https://github.com/rollup/rollup/blob/64870c31062983a741832d8cd2d4bcdf55e4de55/test/function/samples/class-name-conflict-2 test fails. I found this case fails due to the treeshake option, after I disable, it works. It really weird.

Fixing one bug should not introduce another bug, or use a workaround, Let's revert it first, and see if we could solve it in other way.

@Dunqing
Copy link
Collaborator Author

Dunqing commented Apr 9, 2025

Fixing one bug should not introduce another bug, or use a workaround, Let's revert it first, and see if we could solve it in other way.

Yes, fixed in fc6683f. When treeshake is enabled, the import statements may be excluded, but the current implementation needs to use them.

@Dunqing Dunqing force-pushed the 04-03-refactor_rolldown_simplify_renamer_logic branch from ccd1bde to 3f59a09 Compare April 9, 2025 07:04
@Dunqing
Copy link
Collaborator Author

Dunqing commented Apr 9, 2025

These test outputs are just not expected,

for case:

// main.js
import dep from './dep.js';
dep();


// dep.js

export default function a() {
  a = someGlobal;
  return a();
}

This branch

//#region dep.js
function dep() {
	dep = someGlobal;
	return dep();
}

//#endregion
//#region main.js
dep();

//#endregion

Main and rollup output

//#region dep.js
function a() {
  a = someGlobal;
  return a();
}

a();


//#endregion

It no longer happens in the latest commit.

@IWANABETHATGUY
Copy link
Member

I am hesitant to merge because, according to the snapshot, some of them are improved, others regressed, also lots of the internal code has been changed. I will try to fix the bug based on main branch, If I can't find a better way then I will merge it.

@Dunqing
Copy link
Collaborator Author

Dunqing commented Apr 9, 2025

I am hesitant to merge because, according to the snapshot, some of them are improved, others regressed, also lots of the internal code has been changed. I will try to fix the bug based on main branch, If I can't find a better way then I will merge it.

That's ok! But aside from fixing the bug, there is a 1%-4% performance improvement in this PR. I think this is also worth considering!

For the output regression, I know the reason why it's happening, but to fix it, I need to dig deep into Rolldown, which will take forever. If you want to fix it, I’d be happy to pair with you!

@Boshen
Copy link
Member

Boshen commented Apr 9, 2025

It seems like we are in the right direction, but need to be cautious about the changes. Are we able to build this with rolldown-vite and test in ecosystem CI?

@Dunqing Dunqing marked this pull request as draft April 14, 2025 04:28
@Boshen Boshen closed this May 9, 2025
Dunqing added a commit to Dunqing/rolldown that referenced this pull request Jan 13, 2026
This is the first step towards removing the expensive `rename_non_root_symbol`
method. Changes include:

1. Add new fields to Renamer struct:
   - `entry_module_idx`: Track the entry module index
   - `entry_module`: Reference to entry module's symbol database
   - `module_used_names`: Cache of non-root symbol names per module
   - `used_names`: Track names used during renaming

2. Update `Renamer::new()` to accept `base_module_index` parameter and
   pre-cache entry module symbol names

3. Add helper methods for the new algorithm:
   - `is_entry_root_binding()`: Check if name is entry module's root binding
   - `is_name_available()`: Check if name doesn't conflict with existing names
   - `get_or_create_module_used_names()`: Lazily compute non-root symbol names

These changes prepare for eliminating the expensive nested scope iteration
in `rename_non_root_symbol` by making `add_symbol_in_root_scope` smarter
about which names to avoid.

Part of reworking PR rolldown#4014 for ~4% performance improvement.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Dunqing added a commit to Dunqing/rolldown that referenced this pull request Jan 13, 2026
This is the first step towards removing the expensive `rename_non_root_symbol`
method. Changes include:

1. Add new fields to Renamer struct:
   - `entry_module_idx`: Track the entry module index
   - `entry_module`: Reference to entry module's symbol database
   - `module_used_names`: Cache of non-root symbol names per module
   - `used_names`: Track names used during renaming

2. Update `Renamer::new()` to accept `base_module_index` parameter and
   pre-cache entry module symbol names

3. Add helper methods for the new algorithm:
   - `is_entry_root_binding()`: Check if name is entry module's root binding
   - `is_name_available()`: Check if name doesn't conflict with existing names
   - `get_or_create_module_used_names()`: Lazily compute non-root symbol names

These changes prepare for eliminating the expensive nested scope iteration
in `rename_non_root_symbol` by making `add_symbol_in_root_scope` smarter
about which names to avoid.

Part of reworking PR rolldown#4014 for ~4% performance improvement.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
shulaoda pushed a commit to Dunqing/rolldown that referenced this pull request Jan 14, 2026
This is the first step towards removing the expensive `rename_non_root_symbol`
method. Changes include:

1. Add new fields to Renamer struct:
   - `entry_module_idx`: Track the entry module index
   - `entry_module`: Reference to entry module's symbol database
   - `module_used_names`: Cache of non-root symbol names per module
   - `used_names`: Track names used during renaming

2. Update `Renamer::new()` to accept `base_module_index` parameter and
   pre-cache entry module symbol names

3. Add helper methods for the new algorithm:
   - `is_entry_root_binding()`: Check if name is entry module's root binding
   - `is_name_available()`: Check if name doesn't conflict with existing names
   - `get_or_create_module_used_names()`: Lazily compute non-root symbol names

These changes prepare for eliminating the expensive nested scope iteration
in `rename_non_root_symbol` by making `add_symbol_in_root_scope` smarter
about which names to avoid.

Part of reworking PR rolldown#4014 for ~4% performance improvement.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
shulaoda pushed a commit that referenced this pull request Jan 14, 2026
…ot_symbol` pass (#7867)

## Summary

This PR optimizes the symbol renamer by eliminating the expensive
`rename_non_root_symbol` pass and fixes a bug that could generate
duplicate declarations.

### Background
This is a rework of #4014, split into atomic commits for easier review.
The original PR was rejected due to too many changes at once. Issue
#6586 has since been fixed by #7859, but the performance optimization
approach from #4014 still provides significant gains.

### Key Changes

**1. Eliminate `rename_non_root_symbol` pass**
- During root scope renaming, check against nested scopes upfront via
`is_name_available()`
- Most nested scope symbols keep their original names
- Only handle edge cases (cross-module shadowing, CJS params) in
lightweight `register_nested_scope_symbols()`

**2. Skip storing original-named nested symbols**
- Add `canonical_name_for_or_original()` method that falls back to
original name
- Only store symbols in `canonical_names` when actually renamed
- Reduces HashMap insertions for ~80-90% of nested symbols

**3. Fix duplicate declaration bug**
- When renaming nested symbols (e.g., `a` → `a$1`), check if the
candidate name already exists in the same module
- Prevents generating invalid code like `function test(a$1, a$1)` when
both `a` and `a$1` exist as parameters

### How It Works

**Old algorithm:**
1. Rename all root scope symbols
2. Iterate through ALL nested scope symbols to check for conflicts
3. Rename nested symbols that conflict with renamed root symbols

**New algorithm:**
1. During root scope renaming, pick names that don't conflict with
nested scopes upfront
2. Most nested scope symbols keep original names (no storage needed)
3. Only rename nested symbols that shadow cross-module or renamed
top-level symbols
4. Check for duplicate declarations when generating renamed names

```text
 ┌─────────────────────────────────────────────────────────────────┐
 │ Phase 1: Root Scope Renaming                                    │
 ├─────────────────────────────────────────────────────────────────┤
 │  for each top-level symbol:                                     │
 │    → Check: used_canonical_names (top-level conflicts)          │
 │    → Check: entry module nested scopes (avoid capture)          │
 │    → Check: own module nested scopes (avoid capture)            │
 │    → Pick name that avoids ALL conflicts upfront                │
 └─────────────────────────────────────────────────────────────────┘
                               ↓
 ┌─────────────────────────────────────────────────────────────────┐
 │ Phase 2: Nested Scope Renaming (lightweight)                    │
 ├─────────────────────────────────────────────────────────────────┤
 │  for each nested symbol:                                        │
 │    ├─ shadows cross-module top-level? ──→ rename                │
 │    ├─ shadows renamed top-level?      ──→ rename                │
 │    ├─ is CJS `exports`/`module`?      ──→ rename                │
 │    └─ otherwise ──→ keep original (no storage needed)           │
 │                                                                 │
 │  When renaming, check candidate doesn't exist in same module    │
 │  to prevent duplicate declarations:                             │
 │                                                                 │
 │    Input: function test(a, a$1) { ... }                         │
 │           where `a` shadows top-level from another module       │
 │                                                                 │
 │    Rename `a`:                                                  │
 │      try `a$1` → exists in module scope → skip                  │
 │      try `a$2` → available → use it ✓                           │
 │                                                                 │
 │    Result: function test(a$2, a$1) { ... }                      │
 └─────────────────────────────────────────────────────────────────┘
```

### Test Case

Added `nested_scope_rename_counter` test to verify the duplicate
declaration fix:

```js
// other.js
export const a = 'from-other';

// module_with_nested.js - has both `a` and `a$1` as parameters
export function test(a, a$1) { return [a, a$1]; }

// main.js
import { a } from './other.js';
import { test } from './module_with_nested.js';
```
**Before fix**: `function test(a$1, a$1)` - duplicate declarations!

**After fix**: `function test(a$2, a$1)` - correctly skips `a$1` since
it already exists

### Snapshot Changes
The optimization produces slightly different (but equally correct)
variable names:

- Avoids ugly `$1$1` double-suffix patterns
- Names are chosen to avoid nested scope conflicts upfront

---------

Co-authored-by: Claude Opus 4.5 <[email protected]>
Co-authored-by: Yunfei He <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: recover esbuild test case named_function_expression_argument_collision

4 participants