Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/install/lockfile.zig
Original file line number Diff line number Diff line change
Expand Up @@ -902,7 +902,9 @@ pub fn hoist(
.manager = manager,
.install_root_dependencies = install_root_dependencies,
.workspace_filters = workspace_filters,
.hoist_visited_packages = std.AutoHashMap(Tree.PackageID, void).init(allocator),
};
defer builder.hoist_visited_packages.deinit();

try (Tree{}).processSubtree(
Tree.root_dep_id,
Expand Down
35 changes: 33 additions & 2 deletions src/install/lockfile/Tree.zig
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,8 @@ pub fn Builder(comptime method: BuilderMethod) type {
sort_buf: std.ArrayListUnmanaged(DependencyID) = .{},
workspace_filters: if (method == .filter) []const WorkspaceFilter else void = if (method == .filter) &.{},
install_root_dependencies: if (method == .filter) bool else void,
// Reusable set for cycle detection during hoisting to avoid repeated allocations
hoist_visited_packages: std.AutoHashMap(PackageID, void),

pub fn maybeReportError(this: *@This(), comptime fmt: string, args: anytype) void {
this.log.addErrorFmt(null, logger.Loc.Empty, this.allocator, fmt, args) catch {};
Expand Down Expand Up @@ -557,6 +559,33 @@ fn hoistDependency(
comptime method: BuilderMethod,
builder: *Builder(method),
) !HoistDependencyResult {
// Clear the reusable visited set for this top-level hoisting operation
builder.hoist_visited_packages.clearRetainingCapacity();
return hoistDependencyWithVisited(this, as_defined, hoist_root_id, package_id, dependency, dependency_lists, trees, method, builder, &builder.hoist_visited_packages);
}

fn hoistDependencyWithVisited(
this: *Tree,
comptime as_defined: bool,
hoist_root_id: Id,
package_id: PackageID,
dependency: *const Dependency,
dependency_lists: []Lockfile.DependencyIDList,
trees: []Tree,
comptime method: BuilderMethod,
builder: *Builder(method),
visited_packages: *std.AutoHashMap(PackageID, void),
) !HoistDependencyResult {
// Check if we've already visited this package during hoisting
// This prevents infinite recursion in circular dependency scenarios
if (visited_packages.contains(package_id)) {
return .dependency_loop;
}

// Mark this package as visited
try visited_packages.put(package_id, {});
// Ensure we remove this package from visited set when function exits
defer _ = visited_packages.remove(package_id);
const this_dependencies = this.dependencies.get(dependency_lists[this.id].items);
for (0..this_dependencies.len) |i| {
const dep_id = this_dependencies[i];
Expand Down Expand Up @@ -614,7 +643,7 @@ fn hoistDependency(

// this dependency was not found in this tree, try hoisting or placing in the next parent
if (this.parent != invalid_id and this.id != hoist_root_id) {
const id = trees[this.parent].hoistDependency(
const id = trees[this.parent].hoistDependencyWithVisited(
false,
hoist_root_id,
package_id,
Expand All @@ -623,6 +652,7 @@ fn hoistDependency(
trees,
method,
builder,
visited_packages,
) catch unreachable;
if (!as_defined or id != .dependency_loop) return id; // 1 or 2
}
Expand All @@ -645,6 +675,8 @@ pub const TreeFiller = std.fifo.LinearFifo(FillItem, .Dynamic);
const string = []const u8;
const stringZ = [:0]const u8;

pub const PackageID = install.PackageID;

const std = @import("std");
const Allocator = std.mem.Allocator;

Expand All @@ -662,7 +694,6 @@ const String = bun.Semver.String;
const install = bun.install;
const Dependency = install.Dependency;
const DependencyID = install.DependencyID;
const PackageID = install.PackageID;
const PackageNameHash = install.PackageNameHash;
const Resolution = install.Resolution;
const invalid_dependency_id = install.invalid_dependency_id;
Expand Down
190 changes: 190 additions & 0 deletions test/cli/install/performance-benchmark.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDirWithFiles } from "harness";

// Performance benchmarks for dependency hoisting with cycle detection
test("benchmark: small dependency tree (10 packages)", async () => {
const packageJson = {
name: "bench-small",
dependencies: {},
};

// Create 10 packages with linear dependencies: pkg-0 -> pkg-1 -> pkg-2 -> ... -> pkg-9
const files = { "package.json": JSON.stringify(packageJson) };

for (let i = 0; i < 10; i++) {
const deps = i < 9 ? { [`pkg-${i + 1}`]: `file:./pkg-${i + 1}` } : {};
files[`pkg-${i}/package.json`] = JSON.stringify({
name: `pkg-${i}`,
dependencies: deps,
});
packageJson.dependencies[`pkg-${i}`] = `file:./pkg-${i}`;
}

const dir = tempDirWithFiles("bench-small", files);

const start = performance.now();

await using proc = Bun.spawn({
cmd: [bunExe(), "install"],
env: bunEnv,
cwd: dir,
stderr: "pipe",
stdout: "pipe",
});

const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

const duration = performance.now() - start;

expect(exitCode).toBe(0);
expect(stderr).not.toContain("panic");
console.log(`Small tree (10 packages): ${duration.toFixed(2)}ms`);
}, 30000);

test("benchmark: medium dependency tree (50 packages)", async () => {
const packageJson = {
name: "bench-medium",
dependencies: {},
};

// Create 50 packages with linear dependencies
const files = { "package.json": JSON.stringify(packageJson) };

for (let i = 0; i < 50; i++) {
const deps = i < 49 ? { [`pkg-${i + 1}`]: `file:./pkg-${i + 1}` } : {};
files[`pkg-${i}/package.json`] = JSON.stringify({
name: `pkg-${i}`,
dependencies: deps,
});
packageJson.dependencies[`pkg-${i}`] = `file:./pkg-${i}`;
}

const dir = tempDirWithFiles("bench-medium", files);

const start = performance.now();

await using proc = Bun.spawn({
cmd: [bunExe(), "install"],
env: bunEnv,
cwd: dir,
stderr: "pipe",
stdout: "pipe",
});

const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

const duration = performance.now() - start;

expect(exitCode).toBe(0);
expect(stderr).not.toContain("panic");
console.log(`Medium tree (50 packages): ${duration.toFixed(2)}ms`);
}, 30000);

test("benchmark: wide dependency tree (20 packages, each depends on 5 others)", async () => {
const packageJson = {
name: "bench-wide",
dependencies: {},
};

// Create 20 packages where each depends on 5 others (wide tree)
const files = { "package.json": JSON.stringify(packageJson) };

for (let i = 0; i < 20; i++) {
const deps = {};
// Each package depends on the next 5 packages (cyclically)
for (let j = 1; j <= 5; j++) {
const depIndex = (i + j) % 20;
deps[`pkg-${depIndex}`] = `file:./pkg-${depIndex}`;
}

files[`pkg-${i}/package.json`] = JSON.stringify({
name: `pkg-${i}`,
dependencies: deps,
});
packageJson.dependencies[`pkg-${i}`] = `file:./pkg-${i}`;
}

const dir = tempDirWithFiles("bench-wide", files);

const start = performance.now();

await using proc = Bun.spawn({
cmd: [bunExe(), "install"],
env: bunEnv,
cwd: dir,
stderr: "pipe",
stdout: "pipe",
});

const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

const duration = performance.now() - start;

expect(exitCode).toBe(0);
expect(stderr).not.toContain("panic");
console.log(`Wide tree (20x5 deps): ${duration.toFixed(2)}ms`);
}, 30000);

test("benchmark: complex dependency tree with multiple cycles", async () => {
const packageJson = {
name: "bench-complex",
dependencies: {},
};

// Create a complex dependency structure with multiple cycles
const files = { "package.json": JSON.stringify(packageJson) };

// Create 15 packages with complex interdependencies
const depStructure = {
0: [1, 2, 3], // pkg-0 -> pkg-1, pkg-2, pkg-3
1: [4, 5], // pkg-1 -> pkg-4, pkg-5
2: [6, 7], // pkg-2 -> pkg-6, pkg-7
3: [8, 9], // pkg-3 -> pkg-8, pkg-9
4: [10, 0], // pkg-4 -> pkg-10, pkg-0 (cycle)
5: [11, 1], // pkg-5 -> pkg-11, pkg-1 (cycle)
6: [12, 2], // pkg-6 -> pkg-12, pkg-2 (cycle)
7: [13, 3], // pkg-7 -> pkg-13, pkg-3 (cycle)
8: [14, 4], // pkg-8 -> pkg-14, pkg-4
9: [0, 5], // pkg-9 -> pkg-0, pkg-5 (cycle)
10: [6, 7], // pkg-10 -> pkg-6, pkg-7
11: [8, 9], // pkg-11 -> pkg-8, pkg-9
12: [10, 11], // pkg-12 -> pkg-10, pkg-11
13: [12, 4], // pkg-13 -> pkg-12, pkg-4
14: [13, 5], // pkg-14 -> pkg-13, pkg-5
};

for (let i = 0; i < 15; i++) {
const deps = {};
const depIndices = depStructure[i] || [];

for (const depIndex of depIndices) {
deps[`pkg-${depIndex}`] = `file:./pkg-${depIndex}`;
}

files[`pkg-${i}/package.json`] = JSON.stringify({
name: `pkg-${i}`,
dependencies: deps,
});
packageJson.dependencies[`pkg-${i}`] = `file:./pkg-${i}`;
}

const dir = tempDirWithFiles("bench-complex", files);

const start = performance.now();

await using proc = Bun.spawn({
cmd: [bunExe(), "install"],
env: bunEnv,
cwd: dir,
stderr: "pipe",
stdout: "pipe",
});

const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

const duration = performance.now() - start;

expect(exitCode).toBe(0);
expect(stderr).not.toContain("panic");
console.log(`Complex tree (15 packages, multiple cycles): ${duration.toFixed(2)}ms`);
}, 30000);
Loading