Prhub

#2844 [multi-lora v2 5/7] Tinker gateway protocol face

原始 PR 作者 yueming-yuan 合并时间 2026-09-16 16:25 文件变更 13 提交数 1 评论 9 代码增减 +1702 / -0

执行摘要

新增 Tinker 网关协议层与每模型调度器,纯进程内无 runtime 依赖

Tinker SDK 客户端(tinker 0.26.2)需要一个可直连的网关:既要遵守 SDK 的 wire 契约(pydantic 请求模型 + protobuf 二进制 + zstd 压缩),又要在单节点上把多个租户、多个 LoRA adapter(slot)的训练与采样请求按 seq 顺序正确落到同一个 trainer 上。PR body 明确说这些协议层“do not import miles.ray or miles.backends;runtime wiring follows in #2845”,也就是先把可单测、可复用的协议与调度语义定下来;同时 body 强调失败隔离的边界:request-local 的 forward-only/sampling 失败只结束该请求,checkpoint load/save/export 的执行失败会终止共享 trainer cell 与服务,而“automatic recovery and stream replay are not implemented”。

值得精读,尤其是三处:core/service.py 的失败分级与 _close_model 的 slot quarantine 语义、core/scheduler.py 的“arrival 选 seed + pack_key 打包 + optim barrier 合并”三件套、以及 review 线程本身——它展示了一个很典型的踩坑模式:给外部 SDK 写服务端时,request_id 必须由 SDK 的 (session/seq) 幂等键派生,自己随机生成的 id 在客户端重试下必然重复占资源。阅读时建议对照 #2845 的 runtime 接线和 #2849 的测试,确认 review 中“Non-blocking”的几条(完整 outcome map 校验、loss_fn_config_v2 优先级)在最终实现里是否真的闭环。

讨论亮点

8 条 review 意见全部来自 nanjiangwill,主题高度集中:不是代码风格,而是“外部 SDK 会重试,所以服务端必须自己提供幂等键”。作者在后续版本中把幂等键改为 SDK 请求体自带的 (tenant, session_id, model_seq_id) 与 (sampling_session_id, seq_id),并引入 validate_seq_id 统一拒绝非法 seq_id;body 里也补充了“creation/training/sampling 的重试若结果已过期会拿到稳定终态失败,而不是重放后端工作”。本 PR 已获 yushengsu-thu approve,但 8 条意见中有若干是“Non-blocking”,说明作者按建议做了结构性修正而非逐条打补丁。

实现拆解

1) 定义内部数据契约与配置(miles/tinker/core/types.py)。新增 CommandOp 枚举(6 个 op)并为每个 op 挂 is_batch()(只有 FORWARD_BACKWARD/FORWARD_ONLY 进 BatchUnit)与 requires_model_close_on_failure()(FORWARD_BACKWARD/OPTIM_STEP/LOAD_STATE 失败要关模型);LOSS_INPUT_KEYS 把 wire 的 logprobs 映射到内部 sampling_logprobs,LOSS_FN_INPUTS 声明每个 loss_fn 必读的输入,从而在进入共享 batch 之前就拒绝缺字段的 datum;GatewayConfig 集中承载配额(max_datums_per_request、max_tokens_per_batch、max_samples_per_request、max_lora_rank)、lease_timeout_s=300、batch_token_budget=262144;Command.arrival 是全局提交序,ModelRecord 用 request_id_by_seq 做幂等表,SessionRecord/SamplingSessionRecord 保存 models_by_seq 与 samples_by_seq。datum 的约定写在模块 docstring 里:tokens = model_input + target_tokens[-1:],target_len 计全部 label 位置。

2) 每模型严格顺序 + 跨模型打包的调度器(core/model_queue.py、core/scheduler.py)。ModelRequestQueue 只用 last_enqueued_seq_id 与 pending_by_seq 做顺序闸门,submit() 只放行 seq 连续的指令;open_batch_run() 返回队首连续的 batch run(遇到 barrier 或带 validation_error 的请求即停),ready_barrier() 只在前面 batch 全部完成后才返回头 barrier,因此“同一模型的先后顺序”是队列层面的硬保证而不是调度器的启发式。RequestScheduler.schedule_next() 用全局 arrival 在 datum 与 barrier 之间选 seed(barrier 的 arrival 更早则优先),_pack_batch() 以 pack_key()=(op, loss_fn, sorted(loss_fn_config.items())) 为等价类、在 batch_token_budget 内跨模型跨租户打包 datum,_merge_barriers() 把不同模型的 OPTIM_STEP 合成一次 trainer 调用、其它 barrier 保持单条执行。这样做的收益是 GPU 批利用率与 optimizer 调用次数,代价是打包失败时会放大到同 batch 的所有请求。

3) 服务编排与失败策略(core/service.py、core/future.py)。TinkerService.run() 是单事件循环:每轮在 _trainer_lock 内先结算 ready_rejections(validation_error 结成为 user 失败),再 schedule_next() 并执行 _run_batch()/_run_barrier(),随后检查 backend.trainer_dead() 并主动退出;finally 里 cancel 并 gather 掉 sweeper、_create_tasks、_sample_tasks,且用 return_exceptions=True 保证清理失败不会覆盖真正的退出原因。_observe_background_task 把后台任务异常写进 _background_error 并唤醒主循环。create_model 走两阶段:先在锁外占 slot、建 future 与 ModelRecord、注册 ModelRequestQueue,再由 _run_create_model 在锁内调 backend.load_slot();_close_model 幂等,负责移除队列、失败所有挂起请求、把原因写入有界(4*n_slots)的 _close_reasons,并只在 unload_slot 成功后才把 slot 放回 free_slots。RequestFutureStore 提供 create/resolve/fail/get/request_id_for_retry,_FINISHED_TTL_S=3600,过期后的重试由 request_id_for_retry 直接返回一个“result expired after retention”的终态失败,不重放后端工作。

4) 协议边界与输入校验(miles/tinker/server/app.py、server/encoding.py、server/proto_codec.py、core/input_validation.py、core/utils.py)。app.py 用 _tenant() 从 X-API-Key 或 Bearer token 建立租户身份,统一把 UserInputError 映射为 400、OwnershipError 映射为 403,retrieve_future 对 PENDING 的 future 做 30s 长轮询(避免 SDK 无退避地空转),并按 Accept 头在 JSON 与 protobuf 之间选择渲染器。encoding.py 把“pin 住的 SDK pydantic 模型”当作 wire 契约(validate_against_sdk),decode_command 在信封可识别时把内容错误降级为携带 validation_error 的 payload,使该请求仍能按 seq 在正确位置结成为 user 失败;build_datum/tensor_data_to_list 统一 1-D 与 CSR 稀疏输入。proto_codec.py 处理 zstd 解压、ForwardBackwardRequest 解析(loss_fn_config_v2 优先)、dense/sparse_csr tensor 解码与结果二进制编码。input_validation.py 承担模型布局一致性(train_attn/mlp/unembed 必须与服务器启动参数一致)、rank 区间、token id 区间、loss_fn 必读输入与长度对齐、seq_id >= 1、checkpoint 路径段白名单与元数据兼容性;utils.py 的 parse_tinker_path/resolve_checkpoint_dir 用 segment 白名单 + realpath 前缀断言防路径逃逸,read_checkpoint_metadata 用 sha256(tenant) 的 tenant_digest 做跨租户归属校验,resolve_sampler_checkpoint 明确拒绝从训练 checkpoint 采样。

5) 测试、配置与部署配套:本 PR 不含任何测试文件,也无可配置开关;三个新增的 init.py 只是包骨架。作者在 body 中说明回归测试放在 #2849(tests/fast/tinker/*、tests/e2e/lora/test_slot_checkpoint_resume.py),且“This implements failure handling and isolation; automatic recovery and stream replay are not implemented”。这意味着本 PR 定义的行为契约在合入时没有自动化验证网,只能依赖 review 与后继 PR 覆盖。

文件 模块 状态 重要度
miles/tinker/core/service.py 服务编排 added 9.17
miles/tinker/core/scheduler.py 请求调度 added 8.88
miles/tinker/core/model_queue.py 模型队列 added 8.75
miles/tinker/core/input_validation.py 输入校验 added 9.04
miles/tinker/core/types.py 核心类型 added 8.89
miles/tinker/core/future.py 请求结果 added 8.58
miles/tinker/core/utils.py 检查点工具 added 8.53
miles/tinker/server/encoding.py JSON 编解码 added 8.89
miles/tinker/server/proto_codec.py 二进制协议 added 8.53
miles/tinker/server/app.py HTTP 接口 added 8.66
miles/tinker/__init__.py 包骨架 added 3.86
miles/tinker/core/__init__.py 包骨架 added 4.11
miles/tinker/server/__init__.py 包骨架 added 3.86

关键符号

TinkerService.run TinkerService.create_model TinkerService._run_create_model TinkerService._close_model TinkerService.submit TinkerService._run_batch TinkerService._run_barrier TinkerService._dispatch_barrier_op TinkerService._observe_background_task RequestScheduler.schedule_next RequestScheduler._pack_batch RequestScheduler._merge_barriers ModelRequestQueue._enqueue_contiguous_commands ModelRequestQueue.open_batch_run PendingRequest.record_output RequestFutureStore.request_id_for_retry validate_batch_payload validate_model_config validate_seq_id build_datum decode_command decode_forward_backward_request _decode_tensor resolve_sampler_checkpoint

关键源码片段

miles/tinker/core/scheduler.py core-logic

调度核心:DatumRef/BatchUnit/BarrierUnit 与 RequestScheduler.schedule_next 定义“先做谁、哪些可以合批、哪些 barrier 可以合并”,是跨模型跨租户利用率与顺序正确性的分界点。

@dataclass
class BatchUnit:
    """One forward pass on the trainer: datums packed from any number of requests."""
​
    op: CommandOp # FORWARD_BACKWARD | FORWARD_ONLY
    loss_fn: str | None
    loss_fn_config: dict | None
    datums: list[DatumRef] # 每条指向 request.datums[local_index],输出按同一顺序写回
​
​
@dataclass
class BarrierUnit:
    """One barrier call after all preceding batch requests in its model queue finish."""
​
    op: CommandOp # OPTIM_STEP | SAVE_STATE | LOAD_STATE | SAVE_WEIGHTS_FOR_SAMPLER
    entries: list[tuple[ModelRequestQueue, PendingRequest]]
​
​
class RequestScheduler:
    def schedule_next(self) -> BatchUnit | BarrierUnit | None:
        """Arrival order selects the seed; compatible work may overtake intervening requests."""
        datums = self._ready_datums()
        barriers = self._ready_barriers()
        # 全局 arrival 决定谁先出发:batch 与 barrier 之间也按 arrival 比较
        datum_seed = min(datums, key=lambda ref: ref.arrival) if datums else None
        barrier_seed = min(barriers, key=lambda e: e[1].command.arrival) if barriers else None
        if datum_seed is None and barrier_seed is None:
            return None
        if barrier_seed is not None and (datum_seed is None or barrier_seed[1].command.arrival < datum_seed.arrival):
            return self._merge_barriers(barrier_seed, barriers)
        return self._pack_batch(datum_seed, datums)
​
    def _pack_batch(self, seed: DatumRef, datums: list[DatumRef]) -> BatchUnit:
        pack_key = seed.request.pack_key() # (op, loss_fn, sorted(loss_fn_config)) 等价类
        compatible = sorted(
            (ref for ref in datums if ref.request.pack_key() == pack_key),
            key=lambda ref: (ref.arrival, ref.local_index),
        )
        picked: list[DatumRef] = []
        tokens = 0
        for ref in compatible:
            datum_tokens = len(ref.datum["tokens"])
            # 只受 token 预算约束:跨模型、跨租户的 datum 可以进同一个 BatchUnit
            if picked and tokens + datum_tokens > self.batch_token_budget:
                break
            picked.append(ref)
            tokens += datum_tokens
        for ref in picked:
            ref.request.num_issued_datums += 1 # 记账放在打包时,重试不会重复发 datum
        command = seed.request.command
        return BatchUnit(
            op=command.op,
            loss_fn=command.payload.get("loss_fn"),
            loss_fn_config=command.payload.get("loss_fn_config"),
            datums=picked,
        )
​
    def _merge_barriers(
        self,
        seed: tuple[ModelRequestQueue, PendingRequest],
        barriers: list[tuple[ModelRequestQueue, PendingRequest]],
    ) -> BarrierUnit:
        op = seed[1].command.op
        if op == CommandOp.OPTIM_STEP:
            # 不同模型的 optim barrier 合并成一次 trainer 调用
            entries = [(model_queue, barrier) for model_queue, barrier in barriers if barrier.command.op == op]
        else:
            entries = [seed] # save/load/save_weights_for_sampler 等 barrier 仍单条执行
        return BarrierUnit(op=op, entries=entries)
miles/tinker/server/proto_codec.py data-contract

protobuf 边界的实现:zstd 解压、ForwardBackwardRequest 解析、loss_fn_config_v2 优先策略与 dense/sparse_csr 张量解码,直接决定 SDK 二进制路径能否被兼容。

def _decode_forward_backward(message) -> dict:
    """ForwardBackwardRequest proto -> 内部 payload(含 datum 归一化)。"""
    datums = []
    for index, datum in enumerate(message.data):
        tokens: list[int] = []
        for chunk in datum.model_input:
            if chunk.WhichOneof("chunk") != "encoded_text":
                raise UserInputError(f"unsupported model_input chunk type: {chunk.WhichOneof('chunk')}")
            tokens.extend(np.frombuffer(chunk.encoded_text.tokens, dtype=np.int32).tolist())
        inputs = {name: _decode_tensor(name, tensor) for name, tensor in datum.loss_fn_inputs.items()}
        datums.append(build_datum(tokens, inputs, index))
​
    loss_fn_config = dict(message.loss_fn_config)
    # Tinker SDK 的 v2 protobuf config 同时支持 number 与 text 取值:
    # 数值会双写到两张表,字符串只写 v2,因此 v2 存在时必须以它为准,
    # 否则像 dro(beta) 之外的字符串设置会被静默丢弃
    if message.loss_fn_config_v2:
        loss_fn_config = {}
        for name, value in message.loss_fn_config_v2.items():
            kind = value.WhichOneof("value")
            if kind is None:
                raise UserInputError(f"loss_fn_config[{name!r}]: missing number or text value")
            loss_fn_config[name] = getattr(value, kind)
​
    return {
        "model_id": message.model_id,
        "seq_id": message.seq_id,
        "datums": datums,
        "loss_fn": message.loss_fn,
        "loss_fn_config": loss_fn_config,
    }

评论区精华

create_model 必须按 (tenant, session_id, model_seq_id) 幂等 正确性

nanjiangwill 指出 CreateModelRequest 带了 session_id 与 model_seq_id,但实现忽略两者、每次都随机分配模型与 slot;tinker 0.26.2 生成的 POST 客户端默认重试 10 次且 body 不变,响应丢失会让 load_slot() 跑两遍并吃掉两个 slot;且模型按 tenant 租约,同 API Key 的另一个活跃会话会让它一直存活。

结论:最终 head 先 _session_for(tenant, session_id) 校验父会话,validate_seq_id(model_seq_id) 后用 session.models_by_seq 缓存 (request_id, model_id) 并复用,重试不再重复占槽或重复 load_slot;结果过期时由 request_id_for_retry 生成终态失败。 · 已修复

load_slot 失败后不能直接把 slot 放回 free_slots 正确性

nanjiangwill:load_slot() 可能在 init_adapter_slot、梯度清零或优化器重置之后才失败,此时 slot 已被部分污染;原实现无条件归还 slot,而测试在发生任何变更前就失败了,覆盖不到该状态。建议复用前先 unload_slot(),只有清理成功才归还,否则隔离该 slot。

结论:最终实现用 record.slot_initialized 标记“后端已触碰该 slot”,_close_model 里只有 unload_slot 成功才 free_slots.add,失败则记日志并保留占用(quarantine);trainer 已死时直接 return,不再归还。 · 已按建议实现(仍缺针对性测试)

create_sampling_session 的绑定信息与创建去重 正确性

nanjiangwill:CreateSamplingSessionRequest 带 session_id、sampling_session_seq_id 以及 base_model/model_path 绑定,但实现只存 model_path;于是 base_model="other" 的会话会被接受,而后续 sample 请求通常只发 sampling_session_id,最终静默采样了网关自己配置的基座;同时稳定键被忽略导致重试创建出多个会话。

结论:head 版把租户、session_id、model_path 都放进 SamplingSessionRecord,并在 SessionRecord 里用 sampling_sessions_by_seq 做去重;base_model 兼容性下沉到 resolve_sampler_checkpoint,在采样时按 checkpoint 元数据校验。 · 已按结构重组修复

采样请求的 seq_id 丢失导致重试重复生成 正确性

nanjiangwill:decode_sample_request() 丢弃 seq_id,submit_sample() 每次都新建 future 与任务;tinker 0.26.2 用同一个请求 ID 走 execute_with_retries(),响应丢失会真的再生成一次并返回不同 sequence id。

结论:head 版在 decode_sample_request 里保留 seq_id,SamplingSessionRecord.samples_by_seq 以 (sampling_session_id, seq_id) 保留 (request_id, sequence_ids),重试返回已保留的结果,过期后给终态失败。 · 已修复

seq_id <= 0 或非整数会让 future 永远 pending 正确性

nanjiangwill(Non-blocking):流式推进只从 watermark 0 到 1,但 submit() 接受 seq_id <= 0 与非整数,这类条目永远匹配不上,future 永远不结算;protobuf 缺省值是 0,这个边界必须覆盖。

结论:head 版引入 validate_seq_id(value, name, minimum=1),在 create_model 与 submit 的入口统一拒绝,并在注释里写明“请求 seq_id 从 1 开始,幂等键从 0 开始”的差异。 · 已修复

优化器清理失败时的 poison 状态管理 正确性

nanjiangwill:_poisoned_slots.pop() 在 zero_grads() 之前执行,若清理抛异常,本次 optim_step 会失败但 poison 已经丢失,下一次 optim_step 可能在语义不明的梯度上执行;应保留 poison 直到清理成功,清理失败则 retire/quarantine 该模型。

结论:head 版已经不存在 _poisoned_slots —— 规划层被重写为 model_queue + scheduler 架构,同类问题由“barrier 执行失败即结束该模型的流并由 _close_model 隔离 slot”覆盖。 · 随架构重写被替代

barrier 结果集必须先整体校验再结算 正确性

nanjiangwill(Non-blocking):原实现在循环里逐个 resolve 并移除 stream,如果后续某个 slot 的 outcome 缺失或格式错误,_run_barrier 会对已移除的条目再次 finish(),抛 ValueError 直接终止 service.run();建议先做一遍校验再统一结算。

结论:head 版把结果产出与结算分离:_dispatch_barrier_op 先返回与 entries 一一对应的 outcomes 列表(单条异常在内部被收敛为 user 错误),_run_barrier 再统一 _finish_request;_close_model 与 remove_model_queue 也保持幂等。 · 已按建议重构

protobuf loss_fn_config 的 v2 兼容与优先级 正确性

nanjiangwill(Non-blocking):原实现只读 legacy 的 loss_fn_config;tinker 0.26.2 会把数值双写到两张表,但字符串只写 loss_fn_config_v2,所以字符串设置会被静默丢弃。建议 v2 存在时优先、否则回退 legacy。

结论:head 版的 _decode_forward_backward 在 message.loss_fn_config_v2 非空时重建整个字典,用 WhichOneof("value") 取出 number 或 text,缺失取值则显式报错;作者在 body 中补充了 SDK 0.26.2 对 legacy/v2 优先级、零值与空字符串的探针验证。 · 已修复

风险与影响

1) 无随行测试:13 个新增文件对应 0 个测试文件,幂等、租约回收、barrier 合并这些语义在合入时没有回归网;作者把验证放到 #2849,等于让协议层先裸奔合入。
2) 跨租户打包的失败放大:_pack_batch 只按 (op, loss_fn, loss_fn_config) 判兼容,跨模型跨租户共用一次 forward,_fail_batch 会把该 BatchUnit 里所有请求一起判失败(body 把跨租户打包写成设计目标,但隔离性只在时延与失败域上被弱化)。
3) 进程内状态无持久化:futures、sessions、leases、幂等表、未保存的梯度累积都在进程内,future 结果 1 小时后由 request_id_for_retry 转为终态失败(core/future.py 的 _FINISHED_TTL_S),SDK 的长重试窗口可能踩到这条线。
4) SDK 版本耦合:encoding.py 在 import 时物化 ADAM_PARAM_DEFAULTS = tinker_types.AdamParams().model_dump(),proto_codec.py 依赖 tinker_public_pb2 的字段布局,升 tinker 版本会直接改变服务端接受的契约。
5) 路径与鉴权:checkpoint 归属靠 tenant_digest 而不是签名,API Key 明文即租户标识;resolve_checkpoint_dir 的逃逸防护除了 segment 白名单还依赖 assert,在 python -O 下会被剥离(白名单仍在,风险有限)。
6) 长轮询带来的连接占用:retrieve_future 允许挂起 30 秒,高并发 client 会积累大量未完成请求。
7) 后端契约违反是 fail-fast:_run_batch 的 assert len(outputs) == len(refs) 与 trainer_dead() 检查会直接终止 service.run(),这是 body 声明的设计(共享 trainer cell 失败即退出),但对上层运维意味着需要外部重启与连接拒绝策略。

对用户:Tinker SDK 客户端可以不改代码指向该网关,获得会话/心跳、模型创建、训练指令、save/load、save_weights_for_sampler 与采样等全部端点,并支持 JSON 与 protobuf 两种传输;但 ttl_seconds、user_metadata、lora_config.seed、从训练 checkpoint 采样等能力被显式拒绝,需要在文档与错误信息里对齐预期。对系统:这是 multi-lora v2 的协议面,把“谁先训练、哪些 datum 能合批、失败后谁被终止”这些决策从 runtime 里解耦出来,#2845 直接在其上接 ExecutorBackend 与 serve 入口,任何契约变化都会波及 6/7 与 7/7。对团队:本 PR 事实上定义了网关模块的核心不变量(幂等键来自 SDK、slot 只有 unload 成功才可复用、checkpoint 归属靠 tenant_digest、失败策略按 op 分级),后续所有改动都要围绕这些不变量评审。

无随行测试覆盖 进程内状态无持久化 跨租户打包失败放大 SDK 版本契约耦合 长轮询占连接 路径防护含 assert

关联 Issue

#2849 [multi-lora v2 7/7] Tinker gateway example and tests

完整报告

参与讨论