Apple Minions:闲置 Mac 能接走哪些推理任务,先算模型副本与传输预算
先确认每台机器装得下模型,再算数据搬运与可用窗口。附离线字节预算计算器,区分论文报告、假设算例和待实测项目。
1. Apple Minions 解决了哪一段等待
有一批可以拆开处理的文档,而办公室里几台 Mac 每天有一段空闲时间,这正是 Apple Minions 论文值得关注的场景。对准备给文档做向量化、批量生成摘要的团队,第一步可以先做两张预算:每台机器能否独立装下模型,以及整个作业要搬运多少数据。算清这两件事后,再决定是否值得安排小规模试跑。
Apple 的研究页面介绍了这套利用空闲 Mac 承接推理任务的系统。论文 v1 于 2026 年 10 月 9 日提交。文中描述的任务以独立子任务形式交给执行端;执行端拉取任务,在本机缓存模型,通过对象存储读取输入和回传结果。协调服务使用 REST 接口和 PostgreSQL 记录归属;嵌入任务使用 Hugging Face Transformers,文本生成使用 Ollama。设备按五分钟间隔发送心跳,监控程序会把中断的工作重新入队。这些是论文描述的实现,本篇没有运行该系统。
论文报告实际使用中排队等待从数小时缩短到数分钟。这个观察可以支持“增加可用执行资源可能缓解排队”的判断,却不足以计算某个模型快了几倍。文中没有给出可用于复算的设备规模、模型大小、吞吐分布和完整功耗账单。等待时间、单条推理时间、整批完成时间需要分开衡量;论文里的零成本表述也不能当作已经核实的总成本。设备、电力、网络和维护都仍然需要预算。论文全文说明了这套架构与报告边界。
2. 先筛工作负载,再数设备
比较适合先评估的是离线向量化、逐条分类、可独立完成的批量文本生成。每条输入应能用一个固定版本的模型处理,任务之间不依赖上一条输出;任务块中断后,还应允许安全重做。一天内完成即可的后台作业,通常比要求稳定毫秒级响应的在线请求更容易容忍机器暂时退出。
可以用三个问题做第一轮筛选。每台参与设备是否都有足够可用内存?数据是否获准落到这些机器的缓存中?作业是否有足够宽的完成窗口,容得下设备被主人收回、任务重试和结果汇总?任一项答案未知,都应先补证据。尤其不要把四台机器的内存相加,直接用来判断一个大模型是否能运行:论文架构没有证明跨 Mac 模型切分或显存、统一内存合池。
下图把本篇算例画成四份独立副本。不同任务块可以各自推进,但每台机器都需要自己容纳完整模型及其运行开销。
内存检查也要区分下载文件大小与加载后的驻留量。量化格式、运行时、上下文长度和批大小都会影响后者。下面把两者都假设为 4 GB,只为展示计算;实际选型时,应分别填入测得或可靠确认的值。设备标称内存不能直接填成可用内存,先扣除操作系统和正常工作所需的保留量。
3. 四台 Mac 的副本与传输账
下面全部使用十进制单位:1 GB = 1,000,000,000 字节,1 MB = 1,000,000 字节。假设四台 Mac 每台可供本作业使用 8 GB,模型驻留量为 4 GB,额外给运行时、激活和工作数据留 2 GB。每台需求是 6 GB,预算余量为 2 GB。这个余量检查只核对输入假设,并不能证明真实运行不会超内存。
每台先下载一个 4 GB 的模型文件,冷缓存时共传输 16 GB;每台缓存占用 4 GB。现在处理 1,000,000 条记录,每条输入 2,000 字节,输入合计 2 GB。每条结果为 768 维 float32 向量,每维 4 字节,原始输出合计 1,000,000 × 768 × 4 = 3,072,000,000 字节,即 3.072 GB。一遍处理的输入加输出是 5.072 GB。
因此冷缓存合计传输 21.072 GB,所有模型缓存命中时合计 5.072 GB。缓存只省去这个假设里的模型下载,文档和结果仍要传输。如果四台里只有两台缓存命中,模型传输变为 8 GB,总量为 13.072 GB。这里每条数据只算一次;没有计入记录 ID、JSON 编码、HTTP 头、对象存储请求、失败重传或重复计算产生的数据。
再假设上述字节全部经过同一个共享瓶颈,持续可用吞吐上限为 100 MB/s。仅搬运这些字节,冷缓存下限为 210.72 秒,热缓存下限为 50.72 秒,两台命中为 130.72 秒。这是传输字节数除以假设带宽所得的下界,不能与推理时间相加就声称是作业总时长。真实链路可能上下行不对称,也可能并行或与计算重叠;拓扑不满足这个假设时,应重新划分各条链路的账。
4. 用一个离线脚本换入自己的数字
下面的 Python 3 脚本只用标准库,读取本地 JSON 并计算字节与内存预算,不连接任何服务、不启动模型,也不模拟 Minions 的调度。保存为 sizing.py,将后面的完整输入保存为 sample-input.json,执行 python3 sizing.py sample-input.json。结果写到标准输出;输入不合要求时,错误写到标准错误,退出码为 2。
available_memory_bytes 每个元素代表一台参与机器分给该作业的内存预算,列表长度就是机器数;每台固定计算一份副本。model_download_bytes 是每份下载字节数,model_resident_bytes 是每台模型驻留量,extra_memory_bytes 是每台统一预留的其他作业内存。cached_workers 表示已有正确版本完整模型缓存的机器数。其余字段依次描述记录数、每条输入字节、输出维度、每维字节及共享链路每秒字节数。异构机器应分别确认这些假设;这版脚本没有每台不同的额外内存或多个模型并发参数。
完整脚本:sizing.py
#!/usr/bin/env python3
"""Byte and memory budget only. No network, model execution or speedup estimate."""
import argparse
import json
import sys
from decimal import Decimal, DecimalException
FIELDS = {'available_memory_bytes', 'model_download_bytes', 'model_resident_bytes',
'extra_memory_bytes', 'cached_workers', 'records', 'input_bytes_per_record',
'vector_dimensions', 'bytes_per_component', 'shared_bytes_per_second'}
def integer(v, name, minimum=0):
if type(v) is not int or v < minimum:
raise ValueError(f'{name} must be an integer >= {minimum}')
return v
def calculate(d):
if not isinstance(d, dict) or set(d) != FIELDS:
raise ValueError('input must contain exactly these fields: ' + ', '.join(sorted(FIELDS)))
memory = d['available_memory_bytes']
if not isinstance(memory, list) or not memory:
raise ValueError('available_memory_bytes must be a nonempty list')
for i, value in enumerate(memory):
integer(value, f'available_memory_bytes[{i}]', 1)
n = len(memory)
positive = ['model_download_bytes', 'model_resident_bytes', 'records',
'vector_dimensions', 'bytes_per_component']
for key in positive:
integer(d[key], key, 1)
for key in ['extra_memory_bytes', 'cached_workers', 'input_bytes_per_record']:
integer(d[key], key)
if d['cached_workers'] > n:
raise ValueError('cached_workers cannot exceed worker count')
rate_value = d['shared_bytes_per_second']
if isinstance(rate_value, bool) or not isinstance(rate_value, (str, int, float)):
raise ValueError('shared_bytes_per_second must be a positive finite number')
try:
rate = Decimal(str(rate_value))
except DecimalException:
raise ValueError('shared_bytes_per_second must be a positive finite number')
if not rate.is_finite() or rate <= 0:
raise ValueError('shared_bytes_per_second must be a positive finite number')
required = d['model_resident_bytes'] + d['extra_memory_bytes']
bad = [i + 1 for i, m in enumerate(memory) if m < required]
if bad:
raise ValueError(f'insufficient memory budget on workers {bad}; each needs {required} bytes')
model_transfer = (n - d['cached_workers']) * d['model_download_bytes']
incoming = d['records'] * d['input_bytes_per_record']
outgoing = d['records'] * d['vector_dimensions'] * d['bytes_per_component']
dataset = incoming + outgoing
try:
lower_bound = str(Decimal(dataset + model_transfer) / rate)
except DecimalException:
raise ValueError('shared_bytes_per_second is outside the supported calculation range')
return {
'workers': n, 'replicas_per_worker': 1,
'required_memory_bytes_per_worker': required,
'memory_headroom_bytes_per_worker': [m - required for m in memory],
'model_cache_bytes_per_worker': d['model_download_bytes'],
'model_transfer_bytes': model_transfer,
'input_bytes': incoming, 'raw_output_bytes': outgoing,
'dataset_transfer_bytes': dataset, 'total_transfer_bytes': dataset + model_transfer,
'shared_transfer_lower_bound_seconds': lower_bound,
'assumptions': 'One model replica per worker; each record processed once; all counted bytes traverse one shared bottleneck; payload only; no timing or speedup prediction.'
}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('input_json')
args = parser.parse_args()
try:
with open(args.input_json, encoding='utf-8') as f:
data = json.load(f)
result = calculate(data)
except (OSError, ValueError) as exc:
print(f'error: {exc}', file=sys.stderr)
return 2
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
if __name__ == '__main__':
sys.exit(main())
完整输入:四台机器,全部冷缓存
{
"available_memory_bytes": [8000000000, 8000000000, 8000000000, 8000000000],
"model_download_bytes": 4000000000,
"model_resident_bytes": 4000000000,
"extra_memory_bytes": 2000000000,
"cached_workers": 0,
"records": 1000000,
"input_bytes_per_record": 2000,
"vector_dimensions": 768,
"bytes_per_component": 4,
"shared_bytes_per_second": 100000000
}
对应完整输出
{
"workers": 4,
"replicas_per_worker": 1,
"required_memory_bytes_per_worker": 6000000000,
"memory_headroom_bytes_per_worker": [
2000000000,
2000000000,
2000000000,
2000000000
],
"model_cache_bytes_per_worker": 4000000000,
"model_transfer_bytes": 16000000000,
"input_bytes": 2000000000,
"raw_output_bytes": 3072000000,
"dataset_transfer_bytes": 5072000000,
"total_transfer_bytes": 21072000000,
"shared_transfer_lower_bound_seconds": "210.72",
"assumptions": "One model replica per worker; each record processed once; all counted bytes traverse one shared bottleneck; payload only; no timing or speedup prediction."
}
把 cached_workers 改为 4,输出的模型传输量为 0,总传输量为 5,072,000,000 字节,下界为 50.72 秒;改为 2,总量为 13,072,000,000 字节,下界为 130.72 秒。把第一台可用内存改为 5,000,000,000,程序会拒绝计算,并指出第 1 台不满足每台 6,000,000,000 字节需求。缓存机器数填 5、吞吐填 0 或负数、记录数填小数、遗漏字段或添加未知字段也都会报错。极端带宽指数(如字符串 1e-1000000)超出计算范围时,也会输出简短错误并以退出码 2 结束。
记录数必须大于零;内存、文件字节、维度与每维字节必须为正整数,输入字节与额外内存允许为零。向量输出按定长原始数值计算;如果实际保存为文本 JSON、带元数据的文件或压缩格式,应先测出真实输出大小,这个定长模型未必适用。脚本接受的正数带宽只是用户假设;它不会探测网络或保证这条链路存在。
5. 决定试跑之前,需要拿到什么
一个可执行的下一步,是先用获准处理的小样本在单台机器上记录实际模型驻留峰值、批处理速率和输出文件大小。随后再观察多个执行端在一个真实空闲窗口里能参与多久,分别记下入队到开始的等待、第一条结果到达时间和整批结束时间。比较时固定模型版本、输入、批大小与基线机器,保留中断和失败记录。这些测试尚未在本文执行,脚本结果不替代它们。
恢复机制需要单独验收。任务重新分配以后,旧执行端若稍后完成,是否还能覆盖新结果?下游写入能否按任务标识去重?应要求具体的幂等或归属代次校验证据。数据库里只有一个归属记录,不足以单独保证外部写入只发生一次。这里列的是采用前的审查问题,不能据此断言论文系统存在某个缺陷。
团队还应明确谁同意机器参与、何时让出资源、凭据如何限权、缓存何时清理,以及敏感输入是否允许留在这些设备。把用电、存储请求、网络计费和维护时间算入成本,再与现有排队损失比较。若每台都装不下模型,或数据不能落在执行端,这条路线就不适合当前约束;若任务独立、可重做、内存可容纳且有宽松窗口,再安排受控试跑更有意义。
6. 公开资料与本文验证范围
截至 2026 年 10 月 14 日,已核对的 Apple 官方研究页面和论文没有提供 Minions 代码仓库或安装入口;本次检索也未确认公开发布。这一结论限定于已核对的页面与检索范围。网上另有同名项目,不能据此给这篇 Apple 论文拼装安装命令。
本篇完成的是公开资料核对、原创假设算例、标准库计算器及其本地算术与输入校验测试。Minions 部署、Mac 模型加载、网络吞吐、功耗、任务恢复与任何加速比均未实测。模型训练在论文中属于未来工作。读者可以用上述预算排除明显不合适的方案,采用与性能结论仍需自己的设备和工作负载证据。