《互联网产品的 AI 架构·推理性能与成本工程》7.3 批处理调度与成本模型

12 万条作业在 06:00 没有关上窗口,团队却把半成品当成完整结果发布。本节用 12,000 条确定性到达样本比较批大小与等待窗口、限流与并发两个产能上限、单位成本随批大小的下降曲线,并给出错过窗口时的降级顺序、产能判据与可运行的成本模型。

06:00 没有关上的窗口

「回声」的日报作业要在 06:00 前处理 12 万条会话。团队把并发调到 64,批大小设为 1,认为「并发高就快」。实际跑到 06:00 只完成了 96,000 条,剩下 24,000 条。值班同学为了让日报按时发出,把已完成部分直接发布,并在标题上写了「全量」。两个问题同时发生。第一,批大小为 1 时,窗口产能受调用速率限制,不是受并发限制;并发再高,每秒能发的请求数没变。第二,错过窗口后没有降级预案,只能在「发半成品」和「什么都不发」之间二选一,最后选了最糟的那个:把不完整的数据伪装成完整。本节把窗口当成一等资源:批大小与等待窗口决定批次形态,限流与并发决定窗口产能,单位成本决定批大小上限,错过窗口则按固定顺序降级。数字来自本机确定性模拟,标注为示例样本上的运行结果。

批大小与等待窗口

一个批次在两个条件下关闭:满员,或者等待窗口到期。只有满员会牺牲延迟,只有时间会牺牲摊薄。示例样本 12,000 条、每小时均匀到达(每 300ms 一条),五种策略:

批大小 / 等待批次数平均批P50 等待P95 等待单位成本总成本(微元)
1 / 012,0001.0001,32015,840,000
32 / 2s1,7157.01,1002,0005496,583,500
32 / 5s70617.02,6005,0004735,675,400
64 / 20s18863.89,30018,0004355,209,200
128 / 30s119100.815,00028,5004295,147,100

32 / 5s 这一行值得注意:批大小是 32,但到达率只有 3.33 条/秒,等满 32 条需要 9.6 秒,超过 5 秒窗口,所以实际平均只有 17 条。批大小是上限,不是承诺。把窗口从 2 秒放宽到 5 秒,批次数从 1,715 降到 706,单位成本从 549 降到 473,代价是 P95 等待从 2 秒涨到 5 秒。再放大到 64 / 20s,单位成本只降到 435,P95 等待却到 18 秒。从 128 继续放大,单位成本从 428 降到 422,收益已经很薄,等待继续上升。批大小不是越大越好,拐点在「固定开销摊到可忽略」的位置,示例里大约在 64 到 128 之间。

窗口产能:限流与并发谁先到

窗口能处理多少条,取两个上限的较小值:

限流产能 = 每秒调用数 × 批大小
并发产能 = 并发数 × 批大小 / 单批服务时间

示例参数:每秒 2 次调用、并发 4、每批固定 900ms、每条 45ms,窗口 2 小时:

批大小窗口产能(条)受限于单位成本
114,400调用速率1,320
8115,200调用速率533
32393,846并发449
128553,513并发428
512615,939并发422

批大小为 1 时,并发 4 也救不了:每秒只能发 2 次调用,2 小时就是 14,400 条,50,000 条的作业必然错过窗口。批大小为 8 时,限流产能 16 条/秒,已经能覆盖。批大小到 32,瓶颈从调用速率切到并发;再往上加批大小,产能提升变缓,因为单批服务时间随条数线性增长。这也是「并发调到 64 却仍然跑不完」的原因:并发不是吞吐,吞吐是并发、批大小与服务时间的组合。1.2 已经建立了请求级的服务时间口径,本节把它用在小时级窗口上。

单位成本曲线

单位成本 =(每批固定开销 + 批大小 × 条目成本)/ 批大小。固定开销永远摊不干净,所以曲线单调下降但趋近条目成本。示例里条目成本 420 微元,批大小 1 时单位成本 1,320,8 时 533,32 时 449,128 时 428,512 时 422。从 1 到 32,单位成本降了 66%;从 32 到 128,只再降 4.7%;从 128 到 512,降 1.4%。如果等待带来的时效损失按每条每 10 秒 1 微元计,64 / 20s 比 32 / 5s 多等 13 秒,每条多付 1.3 微元,但省下 38 微元,仍然划算;128 / 30s 比 64 / 20s 多等 10.5 秒,只省 6 微元,就不一定划算。判据是「单位成本节省 > 等待的时效损失」,不是「批越大越省」。

错过窗口的降级顺序

降级不是「发半成品」。固定顺序是:

  1. 先用窗口结束后的剩余产能收尾。
  2. 允许延期且在最大延期内,申请延期并标记 late。
  3. 延期不够,把必须当天产出的关键子集升级到在线路径。
  4. 其余条目顺延到下一窗口;不允许部分发布时整批扣住。
  5. 任何一步都不把旧结果标记成新结果。

示例:12 万条作业,06:00 时剩 24,000 条,窗口后产能 4,000 条/小时,最大延期 2 小时。需要 6 小时,延期不够;关键子集 3,000 条,其余 21,000 条顺延:

处置条数单位成本小计(微元)
关键子集走在线3,0001,3203,960,000
其余顺延21,0004288,988,000
合计增量24,000—12,948,000

全部走在线是 31,680,000 微元,全部顺延是 10,272,000 微元,但关键子集会迟到。混合方案多花 2,676,000 微元买当天时效,这个数字应该由业务确认,而不是由值班同学在 06:00 临时决定。如果作业不允许部分发布,同一份输入会返回 hold:只发布独立的关键子集,其余 21,000 条整批扣住,等修复后重跑。半成品不能因为「只差 20%」就发布。

实现

下面的模块包含批次形成、窗口产能、单位成本与错过窗口的降级计划。它只做计划,不发请求;服务时间与成本参数由调用方按自己的模型档案填入。

// file: src/perf/batch-schedule.ts
// tier: L1
// verify: node --test test/perf/batch.test.ts

import { AiError } from "../core/errors.ts";

/** 作业级批处理参数;服务时间口径沿用 1.2,但这里关心的是小时级窗口。 */
export interface BatchPolicy {
  maxBatchSize: number;
  maxWaitMs: number;
  fixedCallMicros: number;
  perItemMicros: number;
  serviceFixedMs: number;
  servicePerItemMs: number;
  callsPerSecond: number;
  concurrency: number;
}

export interface Arrival {
  key: string;
  atMs: number;
}

export interface PlannedBatch {
  items: Arrival[];
  closeAtMs: number;
  serviceMs: number;
}

export interface BatchPlan {
  batches: PlannedBatch[];
  totalCostMicros: number;
}

function fail(message: string): never {
  throw new AiError("invalid-request", `batch-schedule: ${message}`);
}

function requireInt(value: number, field: string, min: number): number {
  if (!Number.isInteger(value) || value < min) fail(`${field} must be an integer >= ${min}`);
  return value;
}

function requireNumber(value: number, field: string, min: number): number {
  if (typeof value !== "number" || !Number.isFinite(value) || value < min) {
    fail(`${field} must be a finite number >= ${min}`);
  }
  return value;
}

/** 同一批在“满员”或“窗口到期”时关闭;两个条件都要有,缺一个就会牺牲另一头。 */
export function planBatches(arrivals: readonly Arrival[], policy: BatchPolicy): BatchPlan {
  requireInt(policy.maxBatchSize, "maxBatchSize", 1);
  requireInt(policy.maxWaitMs, "maxWaitMs", 0);
  requireInt(policy.fixedCallMicros, "fixedCallMicros", 0);
  requireInt(policy.perItemMicros, "perItemMicros", 0);
  requireNumber(policy.serviceFixedMs, "serviceFixedMs", 0);
  requireNumber(policy.servicePerItemMs, "servicePerItemMs", 0);
  for (let index = 1; index < arrivals.length; index += 1) {
    if (arrivals[index].atMs < arrivals[index - 1].atMs) fail("arrivals must be sorted by atMs");
  }

  const batches: PlannedBatch[] = [];
  let cursor = 0;
  while (cursor < arrivals.length) {
    const openAtMs = arrivals[cursor].atMs;
    const items: Arrival[] = [];
    let closedBy: "size" | "window" | "end" = "end";
    while (cursor < arrivals.length) {
      if (items.length >= policy.maxBatchSize) {
        closedBy = "size";
        break;
      }
      const next = arrivals[cursor];
      if (items.length > 0 && next.atMs > openAtMs + policy.maxWaitMs) {
        closedBy = "window";
        break;
      }
      items.push(next);
      cursor += 1;
    }
    const lastAtMs = items[items.length - 1].atMs;
    const closeAtMs = closedBy === "window" ? openAtMs + policy.maxWaitMs : lastAtMs;
    const serviceMs = policy.serviceFixedMs + items.length * policy.servicePerItemMs;
    batches.push({ items, closeAtMs, serviceMs });
  }

  return {
    batches,
    totalCostMicros: batches.length * policy.fixedCallMicros + arrivals.length * policy.perItemMicros,
  };
}

export interface BatchStats {
  batches: number;
  items: number;
  averageBatchSize: number;
  p50WaitMs: number;
  p95WaitMs: number;
  maxWaitMs: number;
  unitCostMicros: number;
  totalCostMicros: number;
}

function percentileNearestRank(values: readonly number[], ratio: number): number {
  if (values.length === 0) fail("percentile of empty sample");
  const sorted = [...values].sort((left, right) => left - right);
  const rank = Math.min(sorted.length - 1, Math.max(0, Math.ceil(ratio * sorted.length) - 1));
  return sorted[rank];
}

/** 等待 = 批次关闭时刻 - 条目到达时刻;这是条目在窗口内的排队代价。 */
export function summarizePlan(plan: BatchPlan, windowMs: number): BatchStats {
  if (plan.batches.length === 0) fail("cannot summarize an empty plan");
  requireNumber(windowMs, "windowMs", 1);
  const waits: number[] = [];
  for (const batch of plan.batches) {
    for (const item of batch.items) waits.push(batch.closeAtMs - item.atMs);
  }
  return {
    batches: plan.batches.length,
    items: waits.length,
    averageBatchSize: waits.length / plan.batches.length,
    p50WaitMs: percentileNearestRank(waits, 0.5),
    p95WaitMs: percentileNearestRank(waits, 0.95),
    maxWaitMs: percentileNearestRank(waits, 1),
    unitCostMicros: Math.ceil(plan.totalCostMicros / waits.length),
    totalCostMicros: plan.totalCostMicros,
  };
}

export interface WindowCapacity {
  items: number;
  limitedBy: "rate" | "concurrency";
}

/** 窗口产能取限流与并发两个上限的较小值,不能只按并发算。 */
export function windowCapacity(policy: BatchPolicy, windowMs: number, batchSize: number): WindowCapacity {
  requireInt(batchSize, "batchSize", 1);
  requireNumber(windowMs, "windowMs", 1);
  const serviceMs = policy.serviceFixedMs + batchSize * policy.servicePerItemMs;
  const byRate = policy.callsPerSecond * batchSize;
  const byConcurrency = (policy.concurrency * 1_000 * batchSize) / serviceMs;
  const throughputPerSecond = Math.min(byRate, byConcurrency);
  return {
    items: Math.floor((throughputPerSecond * windowMs) / 1_000),
    limitedBy: byRate <= byConcurrency ? "rate" : "concurrency",
  };
}

/** 单位成本 =(每批固定开销 + 条目成本)/ 批大小;固定开销永远摊不干净。 */
export function unitCostMicros(policy: BatchPolicy, batchSize: number): number {
  requireInt(batchSize, "batchSize", 1);
  return Math.ceil((policy.fixedCallMicros + batchSize * policy.perItemMicros) / batchSize);
}

export interface MissedWindowInput {
  remainingItems: number;
  /** 窗口结束后每小时还能处理多少条。 */
  capacityPerHour: number;
  hoursLeft: number;
  /** 必须当天产出、可以改走在线路径的条数。 */
  criticalItems: number;
  allowLate: boolean;
  maxLateHours: number;
  allowPartialPublish: boolean;
  onlineUnitMicros: number;
  carryOverUnitMicros: number;
}

export interface WindowAction {
  action: "finish" | "extend" | "escalate-online" | "carry-over" | "hold";
  items: number;
  micros: number;
  detail: string;
}

export interface MissedWindowPlan {
  actions: WindowAction[];
  published: number;
  unpublished: number;
  lateHours: number;
  extraMicros: number;
}

/**
 * 错过窗口的降级顺序:先用剩余产能、再申请延期、再升级关键子集、
 * 最后才顺延或扣住。任何一步都不能把旧结果伪装成新结果发布。
 */
export function planMissedWindow(input: MissedWindowInput): MissedWindowPlan {
  requireInt(input.remainingItems, "remainingItems", 0);
  requireInt(input.capacityPerHour, "capacityPerHour", 0);
  requireNumber(input.hoursLeft, "hoursLeft", 0);
  requireInt(input.criticalItems, "criticalItems", 0);
  requireNumber(input.maxLateHours, "maxLateHours", 0);
  requireInt(input.onlineUnitMicros, "onlineUnitMicros", 0);
  requireInt(input.carryOverUnitMicros, "carryOverUnitMicros", 0);

  const capacityLeft = Math.floor(input.capacityPerHour * input.hoursLeft);
  if (input.remainingItems <= capacityLeft) {
    return {
      actions: [{ action: "finish", items: input.remainingItems, micros: 0, detail: "剩余产能足够" }],
      published: input.remainingItems,
      unpublished: 0,
      lateHours: 0,
      extraMicros: 0,
    };
  }

  const shortfall = input.remainingItems - capacityLeft;
  const requiredLateHours = Math.ceil(shortfall / Math.max(1, input.capacityPerHour));
  if (input.allowLate && requiredLateHours <= input.maxLateHours) {
    return {
      actions: [{ action: "extend", items: input.remainingItems, micros: 0, detail: `延期 ${requiredLateHours} 小时` }],
      published: input.remainingItems,
      unpublished: 0,
      lateHours: requiredLateHours,
      extraMicros: 0,
    };
  }

  const critical = Math.min(input.criticalItems, input.remainingItems);
  const carryOver = Math.max(0, input.remainingItems - capacityLeft - critical);
  const actions: WindowAction[] = [];
  if (capacityLeft > 0) {
    actions.push({ action: "finish", items: capacityLeft, micros: 0, detail: "窗口结束前已完成" });
  }
  if (critical > 0) {
    actions.push({
      action: "escalate-online",
      items: critical,
      micros: critical * input.onlineUnitMicros,
      detail: "关键子集改走在线,换时效",
    });
  }
  const carryAction = input.allowPartialPublish ? "carry-over" : "hold";
  if (carryOver > 0) {
    actions.push({
      action: carryAction,
      items: carryOver,
      micros: carryOver * input.carryOverUnitMicros,
      detail: input.allowPartialPublish ? "其余顺延到下一窗口" : "不允许部分发布,整批扣住",
    });
  }

  return {
    actions,
    published: input.allowPartialPublish ? capacityLeft + critical : critical,
    unpublished: input.allowPartialPublish ? carryOver : input.remainingItems - critical,
    lateHours: 0,
    extraMicros: critical * input.onlineUnitMicros + carryOver * input.carryOverUnitMicros,
  };
}

planBatches 要求到达时间有序。乱序输入会让批次边界随输入顺序漂移,同一份数据两次运行得到不同批次,成本与等待都不可复现。生产上应在进入计划器前按到达时间排序,并记录排序键。

windowCapacity 取限流与并发的较小值,但没有考虑排队。窗口末尾的批次可能排到窗口之外,实际产能要再打一个折扣;这个折扣应该用 1.3 的压测基线校准,而不是拍一个 80%。

planMissedWindow 的降级顺序是硬编码的。顺序本身就是策略:先延期、再升级、最后顺延或扣住。把顺序做成可配置,等于把 06:00 的决策重新交给值班同学。

测试与运行结果

调度与窗口相关测试:

node --test --test-name-pattern="批大小与等待窗口|放大批大小|单条批次|窗口产能|单位成本|剩余产能|允许延期|错过窗口|不允许部分|乱序到达" test/perf/batch.test.ts

本机一次运行的真实输出摘要:

ℹ tests 10
ℹ suites 0
ℹ pass 10
ℹ fail 0
ℹ duration_ms 147.464042

测试覆盖了五个策略的批次形态、单条批次最贵、窗口产能的两种瓶颈、单位成本下限,以及降级的四条分支:产能足够、可以延期、混合升级、整批扣住。最后一条最容易写错:不允许部分发布时,published 只剩关键子集,不能把已完成部分算进去。

与 7.1、7.2、1.2 的衔接

7.1 决定作业是否离开在线路径,并按窗口预留预算;7.2 保证重跑不重复计费;本节决定窗口内怎样排布批次、批大小取多少、错过窗口时先救谁。三者共用同一组成本参数:7.1 的每条节省额、7.2 的重试率、本节的固定开销,必须来自同一份模型档案,否则三张表的结论会互相打架。1.2 的批处理度量回答「一次调用服务多少条、吞吐多少」,本节回答「一个窗口能排多少批、单位成本多少」。前者是测量,后者是计划;计划必须用测量校准,不能用计划反推测量。

小结

批大小与等待窗口共同决定批次形态:满员触发省固定开销,时间触发保时效。窗口产能取限流与并发的较小值,单条批次再高的并发也救不了调用速率。单位成本随批大小下降,但拐点在固定开销摊薄之后,继续放大的收益很薄。错过窗口按固定顺序降级:剩余产能、延期、关键子集升级在线、其余顺延,不允许部分发布时整批扣住。任何一步都不能把旧结果当新结果发布。下一章从单个作业走到容量规划:业务量怎样换算成 token 需求,峰值与余量怎样预留。

阅读导航:上一节:7.2 分片、重试与幂等 · 下一节:8.1 从业务量到 token 需求 。

常见错误与边界

  • 只调并发不看批大小。并发不是吞吐,调用速率可能先成为瓶颈。
  • 把批大小当承诺。到达率不足时,时间窗口先触发,实际批次远小于上限。
  • 只设满员触发。低峰期批次永远等不满,延迟无限拉长。
  • 用理论产能当实际产能。没有扣除排队与窗口末尾的溢出。
  • 为了省单位成本无限放大批次。等待的时效损失可能超过省下的微元。
  • 错过窗口就发半成品。不完整数据被标成完整,比晚发更糟。
  • 不允许部分发布却发布已完成部分。这违反全有或全无语义。
  • 三张成本表用不同参数。7.1、7.2、7.3 的结论会互相矛盾。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查