Commit 0c76093a by luoqi

test(gap): 对拍工具补批量并发标定(--conc=N --subset=M)

只读:每条子场景 SQL 外套 count(*),不写库。判据用**墙钟**,不看各查询耗时之和
(并发下单条会被争抢拉长,和不可比 —— 这正是 2026-08-29 那次误判的成因之一)。

背景:代码注释里「 别指望靠并发提速」的依据是「并发3=24.1分 vs 串行23.3分」,
3% 的差距落在 ±25% 的环境噪音里,什么也没证明,却成了生产不开并发的理由。
今晚测试机 C 轮(legacy,并发4)各子场景耗时之和 1,736s 而场景段墙钟仅 794s
—— 墙钟远小于求和 = 并发确实在重叠,与「瓶颈是共享I/O、并行无用」的旧归因矛盾。

本地(30K,8000 患者子集,两对交替):
  conc=4  4.8s / 5.2s
  conc=1 10.5s / 10.0s     → 稳定 ×2.0,可复现

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent ae793cfa
Pipeline #3629 failed in 0 seconds
...@@ -40,6 +40,10 @@ interface Args { ...@@ -40,6 +40,10 @@ interface Args {
samples: number; samples: number;
/// >0 时改跑画像消费方对拍:抽 N 位患者,逐个跑两版 selectForPatient 比 gap 列表 /// >0 时改跑画像消费方对拍:抽 N 位患者,逐个跑两版 selectForPatient 比 gap 列表
persona: number; persona: number;
/// 并发标定:批量路径的子场景并发度(--conc=N),配 --subset=M 只读跑一轮
conc: number;
/// 并发标定的患者子集规模(0=全量)
subset: number;
/// >0 时改跑**交互路径**对拍:抽 N 位患者,按 scope.patientId 单患者跑召回 SQL 两版 /// >0 时改跑**交互路径**对拍:抽 N 位患者,按 scope.patientId 单患者跑召回 SQL 两版
/// —— 详情页「刷新」(plan.controller recomputeForPatient)走的就是这条,直接面向用户, /// —— 详情页「刷新」(plan.controller recomputeForPatient)走的就是这条,直接面向用户,
/// 批量快不快是运维的事,这条慢了是用户当场感受得到的。 /// 批量快不快是运维的事,这条慢了是用户当场感受得到的。
...@@ -47,7 +51,7 @@ interface Args { ...@@ -47,7 +51,7 @@ interface Args {
} }
function parseArgs(argv: string[]): Args { function parseArgs(argv: string[]): Args {
const a: Args = { host: 'demo', self: false, bench: false, samples: 20, persona: 0, single: 0 }; const a: Args = { host: 'demo', self: false, bench: false, samples: 20, persona: 0, single: 0, conc: 0, subset: 0 };
for (const s of argv) { for (const s of argv) {
if (s.startsWith('--host=')) a.host = s.slice('--host='.length); if (s.startsWith('--host=')) a.host = s.slice('--host='.length);
else if (s.startsWith('--sub=')) a.sub = s.slice('--sub='.length); else if (s.startsWith('--sub=')) a.sub = s.slice('--sub='.length);
...@@ -56,6 +60,8 @@ function parseArgs(argv: string[]): Args { ...@@ -56,6 +60,8 @@ function parseArgs(argv: string[]): Args {
else if (s === '--bench') a.bench = true; else if (s === '--bench') a.bench = true;
else if (s.startsWith('--persona=')) a.persona = Number(s.slice('--persona='.length)) || 0; else if (s.startsWith('--persona=')) a.persona = Number(s.slice('--persona='.length)) || 0;
else if (s.startsWith('--single=')) a.single = Number(s.slice('--single='.length)) || 0; else if (s.startsWith('--single=')) a.single = Number(s.slice('--single='.length)) || 0;
else if (s.startsWith('--conc=')) a.conc = Number(s.slice('--conc='.length)) || 0;
else if (s.startsWith('--subset=')) a.subset = Number(s.slice('--subset='.length)) || 0;
} }
return a; return a;
} }
...@@ -94,6 +100,58 @@ async function bootstrap(): Promise<number> { ...@@ -94,6 +100,58 @@ async function bootstrap(): Promise<number> {
// 🔴 now 固定一次:两版必须拿同一个时间锚,否则 cooldown 边界上的信号会来回抖。 // 🔴 now 固定一次:两版必须拿同一个时间锚,否则 cooldown 边界上的信号会来回抖。
const now = new Date(); const now = new Date();
// ══ 批量路径并发标定(--conc=N [--subset=M])══
// 只读:每条子场景 SQL 外面套一层 count(*),不写库。
// 目的:量出「子场景并发」在**这台机器**上到底有没有扩展性 ——
// 2026-08-29 曾以「并发3=24.1分 vs 串行23.3分」判其无效并写进代码注释,
// 但 3% 落在 ±25% 的环境噪音里,那次测试什么也没证明(见方案 §11)。
// 判据用**墙钟**,不看各查询耗时之和(并发下单条会被争抢拉长,和不可比)。
if (args.conc > 0) {
let subsetIds: string[] | undefined;
if (args.subset > 0) {
const rows = await prisma.$queryRaw<{ id: string }[]>(Prisma.sql`
SELECT p.id FROM patients p
WHERE p.host_id = ${host.id}::uuid AND p.active = true
ORDER BY p.id LIMIT ${args.subset}`);
subsetIds = rows.map((r) => r.id);
}
const scopeB: ScenarioScope = {
hostId: host.id,
tenantId: tenants[0]!.tenantId,
now,
...(subsetIds ? { patientIds: subsetIds } : {}),
};
const variant: GapVariant = args.self ? 'setbased' : 'legacy';
const jobs = Object.entries(TreatmentInitiationRecallScenario.SUB_SCENARIOS).map(
([subKey, cfg]) => {
const rule = lookupDxTreatment(cfg.primaryCode);
if (!rule) throw new Error(`${subKey} 无 rule`);
return { subKey, sql: scenario.buildScenarioSql(scopeB, cfg.primaryCode, rule, variant) };
},
);
const runOne = async (j: { subKey: string; sql: Prisma.Sql }): Promise<string> => {
const t = Date.now();
const [, rows] = await prisma.$transaction([
prisma.$executeRaw`SET LOCAL work_mem = '256MB'`,
prisma.$queryRaw<{ n: bigint }[]>(Prisma.sql`SELECT count(*)::bigint AS n FROM (${j.sql}) q`),
]);
return `${j.subKey}=${Date.now() - t}ms/${Number(rows[0]?.n ?? 0)}`;
};
out(
` 并发标定 conc=${args.conc} 形态=${variant} 患者域=${subsetIds ? `${subsetIds.length} 位子集` : '全量'}`,
);
const t0 = Date.now();
const results: string[] = [];
for (let i = 0; i < jobs.length; i += args.conc) {
const chunk = await Promise.all(jobs.slice(i, i + args.conc).map(runOne));
results.push(...chunk);
}
const wall = Date.now() - t0;
out(` ${results.join(' ')}`);
out(` 墙钟 = ${wall}ms (${(wall / 1000).toFixed(1)}s) 这是唯一可比的数`);
return 0;
}
// ══ 交互路径对拍(详情页「刷新」:单患者召回)══ // ══ 交互路径对拍(详情页「刷新」:单患者召回)══
// 批量慢是运维问题,这条慢是**用户当场感受得到**的问题 —— 必须单独量。 // 批量慢是运维问题,这条慢是**用户当场感受得到**的问题 —— 必须单独量。
if (args.single > 0) { if (args.single > 0) {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment