Skip to content
Merged
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 packages/benchmark-core/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
profiles/
*.cpuprofile
5 changes: 4 additions & 1 deletion packages/benchmark-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
"type": "module",
"description": "Benchmark harness for @automapper/core hot path (map / mapArray)",
"scripts": {
"bench": "tsx src/bench.ts"
"bench": "node --expose-gc --import tsx src/bench.ts",
"bench:startup": "node --expose-gc --import tsx src/startup-bench.ts",
"bench:profile": "node --cpu-prof --cpu-prof-dir ./profiles --import tsx src/profile-formember.ts",
"bench:profile:analyze": "tsx src/analyze-cpuprofile.ts"
},
"dependencies": {
"@automapper/core": "workspace:*",
Expand Down
86 changes: 86 additions & 0 deletions packages/benchmark-core/src/analyze-cpuprofile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';

// Aggregate self-time from the newest V8 .cpuprofile in ./profiles by function
// (functionName @ url:line), so the dominant cost of the forMember/mapFrom path
// is attributable instead of guessed. Usage: pnpm tsx src/analyze-cpuprofile.ts
//
// Reads from the fixed ./profiles dir (where `bench:profile` writes via
// --cpu-prof-dir). The directory is a constant — no CLI/external input feeds the
// path, so there is no path-injection surface.
const dir = './profiles';
const files = readdirSync(dir)
.filter((f) => f.endsWith('.cpuprofile'))
.map((f) => ({ f, t: statSync(join(dir, f)).mtimeMs }))
.sort((a, b) => b.t - a.t);

if (!files.length) {
console.error(`No .cpuprofile found in ${dir}`);
process.exit(1);
}

const path = join(dir, files[0].f);
const prof = JSON.parse(readFileSync(path, 'utf8')) as {
nodes: Array<{
id: number;
hitCount?: number;
callFrame: { functionName: string; url: string; lineNumber: number };
}>;
samples: number[];
timeDeltas: number[];
};

const byId = new Map<number, (typeof prof.nodes)[number]>();
for (const n of prof.nodes) byId.set(n.id, n);

// accurate self time: sum the inter-sample delta charged to each sampled node
const selfUs = new Map<number, number>();
for (let i = 0; i < prof.samples.length; i++) {
const id = prof.samples[i];
const dt = prof.timeDeltas[i] ?? 0;
selfUs.set(id, (selfUs.get(id) ?? 0) + dt);
}

const isAutomapper = (url: string) =>
url.includes('/packages/core/') ||
url.includes('/packages/classes/') ||
url.includes('@automapper');

const agg = new Map<string, { us: number; automapper: boolean }>();
let total = 0;
for (const [id, us] of selfUs) {
const node = byId.get(id);
if (!node) continue;
const cf = node.callFrame;
const name = cf.functionName || '(anonymous)';
const file = cf.url
? cf.url.replace(/^.*\/packages\//, 'packages/').replace(/^file:\/\//, '')
: '(native)';
const key = `${name} ${file}:${cf.lineNumber + 1}`;
const cur = agg.get(key) ?? { us: 0, automapper: isAutomapper(cf.url) };
cur.us += us;
agg.set(key, cur);
total += us;
}

const rows = [...agg.entries()].sort((a, b) => b[1].us - a[1].us).slice(0, 30);
console.log(`\nCPU profile: ${path}`);
console.log(`Total sampled self-time: ${(total / 1000).toFixed(1)} ms\n`);
console.log(' self% self(ms) fn @ file:line');
console.log(' ------ -------- --------------');
let amTotal = 0;
for (const [key, { us, automapper }] of rows) {
const pct = (100 * us) / total;
if (automapper) amTotal += us;
console.log(
` ${pct.toFixed(1).padStart(5)}% ${(us / 1000)
.toFixed(1)
.padStart(7)} ${automapper ? '*' : ' '} ${key}`
);
}
console.log(
`\n (* = @automapper frame). @automapper self-time in top 30 ≈ ${(
(100 * amTotal) /
total
).toFixed(1)}% of total.\n`
);
96 changes: 60 additions & 36 deletions packages/benchmark-core/src/bench.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
import 'reflect-metadata';
import { createMap, createMapper, forMember, mapFrom } from '@automapper/core';
import { createMap, createMapper } from '@automapper/core';
import { AutoMap, classes } from '@automapper/classes';
import { pojos, PojosMetadataMap } from '@automapper/pojos';
import { bench, group, run } from 'mitata';
import {
CamelUserDto,
makeSnakeUser,
makeUser,
registerUserMaps,
SnakeUser,
User,
UserDto,
UserView,
} from './fixtures';

// ===========================================================================
// Shared fixtures (plain source objects — map() reads by property name)
// Fixtures (plain source objects — map() reads by property name). The User /
// Snake model + mappings are shared via ./fixtures; Address/Profile below are
// bench-only (nested-mapping scenarios).
// ===========================================================================
const makeUser = (i: number) => ({
firstName: `First${i}`,
lastName: `Last${i}`,
email: `user${i}@example.com`,
age: 20 + (i % 50),
active: i % 2 === 0,
role: i % 3 === 0 ? 'admin' : 'user',
score: i * 1.5,
createdAt: 'Fri Jun 19 2026',
});
const makeProfile = (i: number) => ({
id: `id-${i}`,
username: `user${i}`,
Expand All @@ -25,10 +27,20 @@ const makeProfile = (i: number) => ({
});

const user = makeUser(1);
const users100 = Array.from({ length: 100 }, (_, i) => makeUser(i));
const users1000 = Array.from({ length: 1000 }, (_, i) => makeUser(i));
const profile = makeProfile(1);
const profiles1000 = Array.from({ length: 1000 }, (_, i) => makeProfile(i));
const snakeUsers1000 = Array.from({ length: 1000 }, (_, i) => makeSnakeUser(i));

// Rotating source pools (>=64 distinct objects). mitata reuses the same fixture
// each iteration, which lets V8 constant-fold a resolver over one fixed object
// and overstate the win; rotating through a pool keeps the measured cost
// representative of production polymorphism.
const POOL = 64;
const userPool = Array.from({ length: POOL }, (_, i) => makeUser(i));
const snakePool = Array.from({ length: POOL }, (_, i) => makeSnakeUser(i));
let userIdx = 0;
let snakeIdx = 0;

// ===========================================================================
// pojos strategy
Expand Down Expand Up @@ -83,7 +95,8 @@ createMap(pojosMapper, 'Profile', 'ProfileDto');

// ===========================================================================
// classes strategy (explicit @AutoMap types — esbuild/tsx has no
// emitDecoratorMetadata, so design:type is provided explicitly)
// emitDecoratorMetadata, so design:type is provided explicitly). Address /
// Profile are bench-only nested fixtures; the User* set comes from ./fixtures.
// ===========================================================================
class Address {
@AutoMap(() => String) street!: string;
Expand All @@ -95,26 +108,6 @@ class AddressDto {
@AutoMap(() => String) city!: string;
@AutoMap(() => String) zip!: string;
}
class User {
@AutoMap(() => String) firstName!: string;
@AutoMap(() => String) lastName!: string;
@AutoMap(() => String) email!: string;
@AutoMap(() => Number) age!: number;
@AutoMap(() => Boolean) active!: boolean;
@AutoMap(() => String) role!: string;
@AutoMap(() => Number) score!: number;
@AutoMap(() => String) createdAt!: string;
}
class UserDto {
@AutoMap(() => String) firstName!: string;
@AutoMap(() => String) lastName!: string;
@AutoMap(() => String) email!: string;
@AutoMap(() => Number) age!: number;
@AutoMap(() => Boolean) active!: boolean;
@AutoMap(() => String) role!: string;
@AutoMap(() => Number) score!: number;
@AutoMap(() => String) createdAt!: string;
}
class Profile {
@AutoMap(() => String) id!: string;
@AutoMap(() => String) username!: string;
Expand All @@ -130,12 +123,19 @@ class ProfileDto {

const classesMapper = createMapper({ strategyInitializer: classes() });
createMap(classesMapper, Address, AddressDto);
createMap(classesMapper, User, UserDto);
createMap(classesMapper, Profile, ProfileDto);
// User -> UserDto, User -> UserView (forMember+mapFrom), SnakeUser -> CamelUserDto
registerUserMaps(classesMapper);

// ===========================================================================
// Benchmarks (mitata uses the returned value as a sink to defeat DCE)
// Benchmarks (mitata uses the returned value as a sink to defeat DCE).
// Run under `--expose-gc` (see package.json `bench` script) so mitata reports
// the heap/gc columns; allocation is the dominant variable cost and the metric
// most likely to regress. `.gc('inner')` is set on the resolver/naming groups
// to surface a clean per-iteration heap delta.
// ===========================================================================

// --- Identity auto-map baseline (regression guard; latency-focused) ---
group('pojos / flat (8 primitive members)', () => {
bench('map x1', () => pojosMapper.map(user, 'User', 'UserDto'));
bench('mapArray x1000', () =>
Expand All @@ -161,4 +161,28 @@ group('classes / nested (object member + array)', () => {
);
});

// --- forMember + mapFrom (2 auto + 6 mapFrom) ---
group('classes / forMember+mapFrom (2 auto + 6 mapFrom)', () => {
bench('map x1 (rotating pool)', () =>
classesMapper.map(userPool[userIdx++ & (POOL - 1)], User, UserView)
).gc('inner');
bench('mapArray x1000', () =>
classesMapper.mapArray(users1000, User, UserView)
).gc('inner');
});

// --- snake_case -> camelCase naming convention ---
group('classes / naming snake->camel (6 members)', () => {
bench('map x1 (rotating pool)', () =>
classesMapper.map(
snakePool[snakeIdx++ & (POOL - 1)],
SnakeUser,
CamelUserDto
)
).gc('inner');
bench('mapArray x1000', () =>
classesMapper.mapArray(snakeUsers1000, SnakeUser, CamelUserDto)
).gc('inner');
});

await run();
134 changes: 134 additions & 0 deletions packages/benchmark-core/src/fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import 'reflect-metadata';
import {
CamelCaseNamingConvention,
createMap,
forMember,
mapFrom,
type Mapper,
namingConventions,
SnakeCaseNamingConvention,
} from '@automapper/core';
import { AutoMap } from '@automapper/classes';

// Shared classes-strategy fixtures used by bench.ts, verify.ts, and
// profile-formember.ts so the model/factory/mapping setup lives in one place.

export const makeUser = (i: number) => ({
firstName: `First${i}`,
lastName: `Last${i}`,
email: `user${i}@example.com`,
age: 20 + (i % 50),
active: i % 2 === 0,
role: i % 3 === 0 ? 'admin' : 'user',
score: i * 1.5,
createdAt: 'Fri Jun 19 2026',
});

// snake_case source — exercises the source->dest naming-convention rename path
export const makeSnakeUser = (i: number) => ({
first_name: `First${i}`,
last_name: `Last${i}`,
email_address: `user${i}@example.com`,
user_age: 20 + (i % 50),
is_active: i % 2 === 0,
user_role: i % 3 === 0 ? 'admin' : 'user',
});

// Explicit @AutoMap types — esbuild/tsx has no emitDecoratorMetadata, so the
// design:type is provided explicitly.
export class User {
@AutoMap(() => String) firstName!: string;
@AutoMap(() => String) lastName!: string;
@AutoMap(() => String) email!: string;
@AutoMap(() => Number) age!: number;
@AutoMap(() => Boolean) active!: boolean;
@AutoMap(() => String) role!: string;
@AutoMap(() => Number) score!: number;
@AutoMap(() => String) createdAt!: string;
}
export class UserDto {
@AutoMap(() => String) firstName!: string;
@AutoMap(() => String) lastName!: string;
@AutoMap(() => String) email!: string;
@AutoMap(() => Number) age!: number;
@AutoMap(() => Boolean) active!: boolean;
@AutoMap(() => String) role!: string;
@AutoMap(() => Number) score!: number;
@AutoMap(() => String) createdAt!: string;
}
// 2 auto-mapped (same-name) + 6 mapFrom-derived members — a common real-world
// configuration the identity groups don't exercise.
export class UserView {
@AutoMap(() => String) firstName!: string; // auto (same name)
@AutoMap(() => String) lastName!: string; // auto (same name)
@AutoMap(() => String) fullName!: string; // mapFrom
@AutoMap(() => String) emailLower!: string; // mapFrom
@AutoMap(() => String) ageGroup!: string; // mapFrom
@AutoMap(() => Boolean) isActive!: boolean; // mapFrom
@AutoMap(() => String) roleLabel!: string; // mapFrom
@AutoMap(() => Number) scoreRounded!: number; // mapFrom
}
export class SnakeUser {
@AutoMap(() => String) first_name!: string;
@AutoMap(() => String) last_name!: string;
@AutoMap(() => String) email_address!: string;
@AutoMap(() => Number) user_age!: number;
@AutoMap(() => Boolean) is_active!: boolean;
@AutoMap(() => String) user_role!: string;
}
export class CamelUserDto {
@AutoMap(() => String) firstName!: string;
@AutoMap(() => String) lastName!: string;
@AutoMap(() => String) emailAddress!: string;
@AutoMap(() => Number) userAge!: number;
@AutoMap(() => Boolean) isActive!: boolean;
@AutoMap(() => String) userRole!: string;
}

/**
* Register the three classes-strategy mappings on a classes() mapper:
* - User -> UserDto (identity / auto-map)
* - User -> UserView (2 auto + 6 forMember(mapFrom))
* - SnakeUser -> CamelUserDto (snake_case -> camelCase naming convention)
*/
export function registerUserMaps(mapper: Mapper): void {
createMap(mapper, User, UserDto);
createMap(
mapper,
User,
UserView,
forMember(
(d) => d.fullName,
mapFrom((s) => `${s.firstName} ${s.lastName}`)
),
forMember(
(d) => d.emailLower,
mapFrom((s) => s.email.toLowerCase())
),
forMember(
(d) => d.ageGroup,
mapFrom((s) => (s.age < 30 ? 'young' : 'adult'))
),
forMember(
(d) => d.isActive,
mapFrom((s) => s.active)
),
forMember(
(d) => d.roleLabel,
mapFrom((s) => s.role.toUpperCase())
),
forMember(
(d) => d.scoreRounded,
mapFrom((s) => Math.round(s.score))
)
);
createMap(
mapper,
SnakeUser,
CamelUserDto,
namingConventions({
source: new SnakeCaseNamingConvention(),
destination: new CamelCaseNamingConvention(),
})
);
}
Loading
Loading