test(benchmarks): retain paired scaling samples

This commit is contained in:
_Kerman
2026-10-26 22:38:33 +08:00
parent f17a2c24c0
commit e86b278053
4 changed files with 34 additions and 10 deletions
@@ -21,11 +21,11 @@
en: f07bb6fbbdd01fe4
zh: 9313a0ef2a22af12
/agent-note-linear-credential-matching-and-performance-gates-for-user-text/decision/performance-gate:
en: 37faaffb4662fd54
zh: df0a4bf016393be9
en: 3ceb80f455e9a47d
zh: 89d8bd11b42d31b0
/agent-note-linear-credential-matching-and-performance-gates-for-user-text/alternatives-considered:
en: 559a8037caa9e3aa
zh: 4f3719ed09445cc2
en: b5bfb4cfa231ce0f
zh: 631b9f2978241bc1
/agent-note-linear-credential-matching-and-performance-gates-for-user-text/consequences:
en: 8f9d25b72d3e7660
zh: e5c6ae0194905876
@@ -43,7 +43,7 @@ English | [中文](2026-10-11-user-text-credential-matching.zh.md)
| Item | Content |
|---|---|
| Workloads | Without credentials: ordinary messages, and long prose, code, base64, Markdown, log, JSON, and shell text. With credentials: messages each carrying one kind (model key, GitHub token, cloud key ID, JWT, address password, private-key block), and batches of `.env` keys. Adversarial: repeated input shaped against each format's prefix, length, and separator rules, at two sizes. All generated from constants; credential samples are assembled at run time |
| Timing | Ten unmeasured pairs warm each workload with the same evaluation count as measured pairs. Scaling inputs share one group: every warm-up and measured round evaluates both sizes adjacently and alternates which size goes first, limiting drift between sizes. Five measured pairs alternate baseline-first and matcher-first order; each evaluation builds a fresh registry, document, and matcher instance and times evaluation only. A single-document sample averages twenty evaluations, or forty for the half-sized input of a scaling pair, so both sizes process the same total keys or characters per sample; a 5,000-message sample evaluates the batch once. Garbage collection during evaluation remains included. Each side selects its minimum sample mean, with the mean process CPU time from that sample. The worker JSON in CI logs includes every measured pair and averaging count; the HTML report shows the selected with-credential mean. Registry disposal completes outside the timer before the next evaluation, preventing cleanup and retained registries from accumulating. Warm-up retains no matcher memo |
| Timing | Ten unmeasured pairs warm each workload with the same evaluation count as measured pairs. Scaling inputs share one group: every warm-up and measured round evaluates both sizes adjacently and alternates which size goes first, limiting drift between sizes. Five measured pairs alternate baseline-first and matcher-first order; each evaluation builds a fresh registry, document, and matcher instance and times evaluation only. A single-document sample averages twenty evaluations, or forty for the half-sized input of a scaling pair, so both sizes process the same total keys or characters per sample; a 5,000-message sample evaluates the batch once. Garbage collection during evaluation remains included. Absolute time budgets use each side's minimum sample mean, with the mean process CPU time from that sample. Doubling checks take the median of the five within-round ratios, keeping the adjacent samples paired through aggregation. The worker JSON in CI logs includes every measured pair and averaging count; the HTML report shows the selected with-credential mean. Registry disposal completes outside the timer before the next evaluation, preventing cleanup and retained registries from accumulating. Warm-up retains no matcher memo |
| Allocation | A separate process with `--expose-gc` and a fixed semi-space larger than any evaluation; after warm-up and GC, the matcher alone answers every query once and the young-generation growth is taken; GC event timestamps confirm no GC ran inside the interval |
| Assertions | Without credentials: absolute budgets and a cap on the with/without time ratio; with credentials: absolute budgets and the time ratio when the key count doubles; adversarial: absolute budgets and doubling ratios; allocation: a floor plus caps per credential, per line break inside masks, and per masked character |
| Excluded | Cold code compilation, React rendering, browser paint and layout; allocation results are asserted and printed to the console, not added to the HTML report |
@@ -54,7 +54,7 @@ The reference constants are calibrated for minima of five single evaluations. Mi
## Alternatives considered
- **Finishing one scaling size before measuring the other**: separate phases compare different load and V8 tiering conditions. Identical code measured JWT doubling at 1.91× in one CI run and 2.70× in another; a local Node 24 run also exceeded the unchanged 2.5× limit on a different input. Adjacent, alternating size rounds retain the existing sample counts, minimum-of-five aggregation, GC inclusion, workloads, and limits. A controlled extra evaluation cost on only the 128 KiB input must still fail the doubling assertion.
- **Finishing one scaling size before measuring the other**: separate phases compare different load and V8 tiering conditions. Identical code measured JWT doubling at 1.91× in one CI run and 2.70× in another; a local Node 24 run also exceeded the unchanged 2.5× limit on a different input. Adjacent, alternating size rounds retain the sample counts, GC inclusion, workloads, and limits. Independent minima can still select rounds at different speeds; the median of paired ratios prevents one faster half-sized sample from deciding the scaling result. A controlled extra evaluation cost on only the 128 KiB input must still fail the doubling assertion.
- **Equal evaluation counts at both scaling sizes**: the larger input allocates twice as much per sample, so its fastest sample can include a collection while the smaller input's fastest sample includes none. Equal total input per sample makes the per-evaluation means compare similar allocation volumes without excluding GC or changing the doubling limit.
- **Unwarmed, separate baseline and matcher batches**: sub-millisecond baseline measurements can reach optimized code before the matcher’s newly exercised paths do, and separate batches amplify clock-speed drift. Per-workload warm-up, adjacent pairs, and fixed batches compare warmed code while limiting noise in short intervals and preserving fresh documents and matcher state.
- **One quantified pattern per format, run from every query**: patterns without a hit scan to the end of the text each time, so cost grows quadratically with credentials, and length quantifiers backtrack on failure.
@@ -43,7 +43,7 @@ Status: implemented
| 项 | 内容 |
|---|---|
| 负载 | 无凭据:普通消息,以及散文、代码、base64、Markdown、日志、JSON、shell 长文本。含凭据:每条消息含一种凭据(模型密钥、GitHub 令牌、云密钥 ID、JWT、地址密码、私钥块),以及成批的 `.env` 密钥。对抗:针对每种格式的前缀、长度与分隔规则构造的重复输入,两种长度。全部由常量生成,凭据样本运行时拼接 |
| 计时 | 每个负载先用十组未计入结果的配对预热,每组的求值次数与测量配对相同。翻倍比较的两个输入属于同一组:每轮预热和测量相邻求值两个尺寸,并交替先测的尺寸,减少尺寸之间的漂移。五组测量交替以基线或凭据匹配器开头;每次求值新建注册表、文档和匹配器实例,只计求值。单文档样本取二十次求值的平均值;翻倍比较中的小输入取四十次,使两种大小的每个样本处理相同总量的密钥或字符;5,000 条消息的样本则整批求值一次。求值期间的垃圾回收仍计入耗时。两侧各取样本均值的最小值,并保留对应样本的平均进程 CPU 时间。CI 日志中的 worker JSON 包含全部测量配对和求平均值的次数,HTML 报告展示选中的带凭据匹配器均值。每次求值在计时结束后等待注册表清理完成,再开始下一次,避免清理工作和未释放注册表积累。预热不保留匹配器记忆 |
| 计时 | 每个负载先用十组未计入结果的配对预热,每组的求值次数与测量配对相同。翻倍比较的两个输入属于同一组:每轮预热和测量相邻求值两个尺寸,并交替先测的尺寸,减少尺寸之间的漂移。五组测量交替以基线或凭据匹配器开头;每次求值新建注册表、文档和匹配器实例,只计求值。单文档样本取二十次求值的平均值;翻倍比较中的小输入取四十次,使两种大小的每个样本处理相同总量的密钥或字符;5,000 条消息的样本则整批求值一次。求值期间的垃圾回收仍计入耗时。绝对耗时预算取两侧样本均值的最小值,并保留对应样本的平均进程 CPU 时间。翻倍检查取五个同轮次耗时比的中位数,使相邻样本在汇总时仍保持配对。CI 日志中的 worker JSON 包含全部测量配对和求平均值的次数,HTML 报告展示选中的带凭据匹配器均值。每次求值在计时结束后等待注册表清理完成,再开始下一次,避免清理工作和未释放注册表积累。预热不保留匹配器记忆 |
| 分配 | 单独进程,`--expose-gc`,semi-space 固定为大于任何一次求值的大小;预热后 GC,再单独驱动匹配器回答全部查询一遍,取新生代增量;用 GC 事件的时间戳确认区间内没有 GC |
| 断言 | 无凭据:绝对预算,以及"有/无凭据匹配器"的耗时比上限;含凭据:绝对预算,密钥数翻倍时的耗时比;对抗:绝对预算与翻倍耗时比;分配:底噪加每个凭据、每个遮挡内换行和每个遮挡字符的上限 |
| 排除 | 冷代码编译、React 渲染、浏览器绘制与布局;分配结果只用于断言并输出到控制台,不进 HTML 报告 |
@@ -54,7 +54,7 @@ Status: implemented
## 考虑过的替代方案
- **测完一个翻倍尺寸后再测另一个**:两个阶段比较的负载和 V8 编译层级状态不同。同一份代码的 JWT 翻倍比在两轮 CI 中分别为 1.91 倍和 2.70 倍;本地 Node 24 也在另一个输入上超过既有 2.5 倍上限。相邻、交替的尺寸轮次保留既有样本数、五组取最小值的汇总方式、GC 计入规则、负载与上限。仅给 128 KiB 输入增加受控求值开销时,翻倍断言仍须失败。
- **测完一个翻倍尺寸后再测另一个**:两个阶段比较的负载和 V8 编译层级状态不同。同一份代码的 JWT 翻倍比在两轮 CI 中分别为 1.91 倍和 2.70 倍;本地 Node 24 也在另一个输入上超过既有 2.5 倍上限。相邻、交替的尺寸轮次保留样本数、GC 计入规则、负载与上限。独立选取最小值仍可能选中运行速度不同的轮次;配对比值的中位数避免单个更快的小输入样本决定翻倍结果。仅给 128 KiB 输入增加受控求值开销时,翻倍断言仍须失败。
- **两种大小使用相同求值次数**:大输入每个样本的分配量翻倍,因而其最快样本可能包含一次垃圾回收,而小输入的最快样本没有。让每个样本的输入总量相等,使单次求值均值比较相近的分配量,无需排除垃圾回收或修改翻倍上限。
- **不预热、分批测量基线和匹配器**:亚毫秒基线测量可能先进入优化后的代码,而匹配器新触及的路径还未完成优化;分批测量也会放大时钟频率漂移。逐负载预热、相邻配对和固定批次比较预热后的代码,降低短区间测量的噪声,同时保持文档和匹配器状态全新。
- **每次查询对每个格式各跑一个带量词的正则**:没有命中的正则每次都扫到文末,凭据多时平方增长;长度量词在失败时回溯。
+26 -2
View File
@@ -92,6 +92,25 @@ async function worker<Report>(args: readonly string[], semiSpaceMb?: number): Pr
return run.report
}
/** Compare adjacent scaling samples before aggregating, so unrelated fastest rounds cannot set the ratio. */
function doublingRatio(half: readonly number[], full: readonly number[]): number {
const ratios = half.map((duration, index) => full[index]! / duration).sort((a, b) => a - b)
return ratios[Math.floor(ratios.length / 2)]!
}
describe('credential-matcher scaling measurement', () => {
it('compares adjacent rounds when the runner changes speed', () => {
const half = [1, 2, 2, 2, 2]
const full = [4, 4, 4, 4, 4]
expect(Math.min(...full) / Math.min(...half)).toBeGreaterThan(MAX_DOUBLING_SCALING)
expect(doublingRatio(half, full)).toBe(2)
})
it('rejects superlinear growth even when one round is faster', () => {
expect(doublingRatio([1, 2, 2, 2, 2], [2, 6, 6, 6, 6])).toBeGreaterThan(MAX_DOUBLING_SCALING)
})
})
describe('sent user text with the credential matcher', () => {
let timings: ReadonlyMap<string, UserTextWorkloadTiming>
let allocations: ReadonlyMap<string, UserTextWorkloadAllocation>
@@ -115,6 +134,11 @@ describe('sent user text with the credential matcher', () => {
measures: `${benchmark.measures} Minimum of ${ATTEMPTS} per-sample means of ${measured.evaluationsPerSample} fresh evaluations after warm-up.`,
}, { withSecretMs: { ms: measured.withSecretMs, cpuMs: measured.withSecretCpuMs } }, { withSecretMs: budgetMs })
}
const expectLinear = (half: UserTextWorkloadTiming, full: UserTextWorkloadTiming): void => {
const ratio = doublingRatio(half.samples.map(sample => sample.withSecretMs), full.samples.map(sample => sample.withSecretMs))
console.log(JSON.stringify({ benchmark: `user-text/${full.id}/scaling`, ratio, budget: MAX_DOUBLING_SCALING }))
expect(ratio).toBeLessThanOrEqual(MAX_DOUBLING_SCALING)
}
it.each(Object.entries(UNMATCHED_EXPECTED))('evaluates %s without credentials within its budget and ratio', (id, expected) => {
const measured = timing(id)
@@ -139,7 +163,7 @@ describe('sent user text with the credential matcher', () => {
const half = timing('env-keys-1000')
const full = timing('env-keys-2000')
expect([half.secrets, full.secrets]).toEqual([1_000, 2_000])
expect(full.withSecretMs / half.withSecretMs).toBeLessThanOrEqual(MAX_DOUBLING_SCALING)
expectLinear(half, full)
})
it.each(ADVERSARIAL)('scans adversarial %s input linearly without backtracking', (name) => {
@@ -148,7 +172,7 @@ describe('sent user text with the credential matcher', () => {
const budgetMs = ciTimeBudget(ADVERSARIAL_EXPECTED_MS)
record(benchmarkCase(`adversarial-${name}`, `One 128 KiB document repeating a ${name} unit.`), full, budgetMs)
expect(full.withSecretMs).toBeLessThanOrEqual(budgetMs)
expect(full.withSecretMs / half.withSecretMs).toBeLessThanOrEqual(MAX_DOUBLING_SCALING)
expectLinear(half, full)
})
it.each(workloadIds())('allocates only result nodes and masks for %s', (id) => {