跳转至

实验二:将 LLM 推理封装为内核 AI Service

实验说明

本实验默认你已经完成 HW2,并且已经在 HW2 中读过配套材料、跑通过最小推理链路。

本实验的重点不是“重新实现一次模型推理”,而是把 HW2 中已经能运行的用户态推理后端,封装成一个更像操作系统服务的接口。完成后,你应当能够让上层程序通过 ai_callai_submitai_queryai_wait 使用这条服务链路,而不是像Hw2的AI分支一样直接操作 llmrun_*

关于AI辅助编程(Vibe Coding)的说明:

  • 本课程旨在鼓励大家拥抱新技术,我们不主张将古法编程与AI辅助编程对立。培养大家使用AI工具解决实际开发问题的能力,是今年实验课改革的重要目标。如果你现在还没有接触过AI辅助编程,建议查看ai编程发展;如果你现在不知道如何开始,可以查看助教推荐,该文档会不定时更新。至少,你应该有Github的学生会员,并且已经学会使用Copliot

  • 在代码提交方面,课程不设代码查重机制,允许提交由AI生成或辅助编写的代码。但这同时也对大家的代码掌控力提出了更高要求:课程的最终区分度将重点通过线下问答(答辩)来体现,所以如果你对代码并不理解,线下问答扣分是正常的,但是我们线下检查控制在30%,也不会特别影响大家的成绩,主要还是为了督促大家理解实验在做什么,培养基础。在使用AI生成代码时,大家需要深入理解代码的运行逻辑与设计意图。能够透彻解释AI为何如此实现,才是顺利通过考核的关键。

实验给分说明

本实验的基础部分总分为 20 分,按与 lab1 相同的方式折算计入最终成绩: - 代码测试结果:按 70% 比例计入基础部分总分 - 线下提问检查:按 30% 比例计入基础部分总分

此外,本实验还有一个选做 Bonus,满分 5 分。 - Bonus 不计入平台自动测试; - Bonus 需要找助教进行人工检查; - 建议先完成基础部分,再考虑 Bonus

实验代码提交说明

本实验采用希冀平台作为测试平台,网址为 https://cscourse.ustc.edu.cn

在在线作业 lab2 的代码提交界面,需要粘贴你自己的 GitLab 代码仓库链接。由于本次实验使用的是 lab2 分支,因此需要额外指定待测分支,可使用下面两种格式:

  • https://cscourse.ustc.edu.cn/vdir/Gitlab/username/yourproj.git --branch=lab2
  • https://cscourse.ustc.edu.cn/vdir/Gitlab/username/yourproj.git lab2

平台上的自动测试主要覆盖基础部分的主线功能。若你发现线上测试结果和本地结果存在明显出入,请保留本地运行截图与线上结果截图,并联系助教确认是代码问题还是测试脚本问题。

本次测评方法,会直接拉取你的仓库中的kernel/core/ai_service.c文件,替换到原先的仓库中,所以一般不会出现编译问题。线上测评包含了一些仓库中没有提供的测评样例,所以手动测评通过,但是线上测评不是满分是正常的,请检查你的实验代码(尤其关注下面实验文档中的函数说明)。

实验时间安排

注:此处为实验发布时的安排计划,请以课程主页和课程群内最新公告为准

  • 4.16 晚实验课,讲解实验二并检查实验
  • 4.23 晚实验课,检查实验
  • 4.30 晚实验课,检查实验
  • 5.7 晚实验课,检查实验
  • 5.14 晚及之后实验课,补检查实验

补检查分数照常给分,但会记录此次检查未按时完成,此记录在最后综合分数时作为一种参考。

本次实验难度较高,建议不要在最后几天完成!

检查时间、地点:周四晚 18:30-22:00,电三楼 406/408。
基础实验周期为三周,之后为补检查。

获取实验代码(切换到 lab2 分支)

本实验假设你已经在本机 clone 过课程仓库。接下来请拉取远端 lab2 分支,并切换到该分支完成实验。

在仓库根目录执行:

git fetch --all
git checkout -b lab2 upstream/lab2
# 或者
# git switch -c lab2 --track upstream/lab2
当你完成实验后,push代码时,请注意指定远端仓库:

git push origin
  • 若提示找不到 upstream/lab2,请先确认远端是否已经发布该分支。
  • 切换分支前请先用 git status 检查当前工作区,避免未提交改动被分支切换打断。

如果你本地已经有 lab2 分支

直接切换即可:

git checkout lab2
# 或者
# git switch lab2

开始前你需要完成的前置准备

本实验默认你已经完成 HW2 的准备工作,因此这里不再重复展开 HW2 中已经讲过的原理。

开始之前,请先确认下面几件事

  • 你已经完成 HW2 的环境准备,并能启动带有模型文件的实验镜像。
  • 你已经理解 HW2 中“请求队列 + 后台 worker”的基本思路。
  • 你已经理解为什么 wait() 类语义会自然引出“所有权检查”“状态查询”“一次性结果消费”等约束。
  • 你当前使用的是只包含一个活跃模型目录的镜像,不要同时把 SmolQwen 都打进同一个运行镜像里。

如果你需要回顾背景知识,建议先重新阅读下面几页:

初步启动:先把带模型资产的镜像跑起来

开始写 ai_service 之前,仍然需要先确认仓库根目录已经准备好了模型资产,并且能够成功启动带 LLM 文件的镜像。

如果你已经做过 HW2

若你是在同一个本地仓库里先完成 HW2,再切换到 lab2 分支,那么工作区里通常已经保留了 models/ 目录。此时一般不需要重新创建目录,只需要确认里面的模型压缩包还在即可。

思考:为什么切换分支后models文件夹还保留着?

如果你当前还没有 models/ 目录,请在仓库根目录手动创建该目录,并把助教提供的模型压缩包放进去。文件名仍与 HW2 保持一致,例如:

$ mkdir -p models
$ ls models
   smol.zip
your-os-repo/
├── Makefile
├── models/
│   ├── smol.zip
│   └── qwen.zip
└── ...

如果你本机还没有这些压缩包,请回看 Hw2 环境准备与最小检查 中的下载说明。

不要使用普通的 make qemu。后者不会把 LLM 模型文件打进镜像,因此 init 也无法正确检查模型目录并自动拉起 ai_daemon

请在仓库根目录执行:

$ make qemu-llm
xxxxxxx  #编译打包信息
[NexOS] Mounting filesystem...
[ai_daemon] loading LLM assets
[ai_daemon] cfg kind=0 dim=576 hidden=1536 layers=30 heads=9 kv_heads=3 head_dim=64 vocab=49152 runtime_seq=256
[ai_daemon] EMB.BIN loaded (28508160 bytes)
[ai_daemon] layers 0..4 loaded
[ai_daemon] layers 0..9 loaded
[ai_daemon] layers 0..14 loaded
[ai_daemon] layers 0..19 loaded
[ai_daemon] layers 0..24 loaded
[ai_daemon] layers 0..29 loaded
[ai_daemon] layer mix full=30 linear=0
[ai_daemon] model loaded and cached in memory
ai_daemon: ai_worker_register failed  # 显示failed是正常的,是由于没有完成实验
[init] ai_daemon did not signal ready
[init] disabling ai_daemon restarts after startup failure
[init] ai_daemon exited status=1

若启动后没有看到:

[init] ai_daemon xxx

请优先回头检查模型压缩包是否已经放到 models/ 下,以及镜像是否确实是通过 make qemu-llm 生成的。

若看到了[init] ai_daemon xxx建议先在 shell 中运行一次:

$ aitest

这是课程已经提供的最小同步 ai_call() 检查,用来快速确认镜像、模型资产和 syscall 链路没有明显问题。

代码架构

本实验真正需要你重点关注的文件不多。建议你先把下面几处文件的职责看清楚,再开始写代码。

kernel/core/ai_service.c

这是本实验的核心文件,也是必做部分唯一需要完成的地方。

当前代码中的 TODO 基本都集中在这里。建议你也把主要精力放在这里。

user/ai_daemon.c

这是用户态常驻 worker。它的大致工作流程是:

  1. 选择当前活跃模型目录;
  2. 初始化已有的 LLM runtime;
  3. 调用 ai_worker_register() 把自己注册为唯一 worker;
  4. 在循环中不断执行 ai_worker_get()、真实推理、ai_worker_complete()

Bonus实验的主要完成部分。

user/aitest.c

这是本实验最重要的测试入口。它已经封装了同步、异步、状态查询、非法等待、二次等待、所有权隔离、批量提交等多种测试模式。

你不需要把 aitest.c 里的所有模式都记住,但至少要知道:

  • aitest 可以测同步路径;
  • aitest --async 可以测异步路径;
  • aitest --async --query 可以测状态查询;
  • 其它参数可以帮助你检查边界行为。

注意,该文件只用于手动验证,其中的测试是线上测评的子集!!!

user/init.c

init 会在系统启动时检查模型资产,并在满足条件时自动拉起 ai_daemon。如果一切正常,你通常会在启动日志里看到:

[init] ai_daemon ready (pid=...) # 如果刚拉取代码,这里显示Failed

如果你始终看不到这行输出,那么本实验两个正式 Part 的异步 service 主线通常都不会正常工作;但课程已经提供的同步 ai_call() 快速检查仍然可以先帮助你判断环境和 syscall 链路有没有明显问题。

syscall 相关文件

下面这些文件说明 service 接口已经被接进系统调用链路(Lab1的内容我们Lab2就不再做重复工作了):

  • kernel/include/syscall.h
  • kernel/core/syscall.c
  • kernel/include/defs.h
  • user/user.h
  • user/syscall.c

也就是说,syscall 编号、分发和用户态封装基本都已经准备好了。你在必做部分真正要补的是 ai_service.c 中的控制逻辑。

代码执行流程

本次实验本质上是一个生产者消费者问题(理论课 ch5)。

PCProblem

本次实验的生产者消费者架构图等助教有空了更新,先用理论课的顶一下/(ㄒoㄒ)/~~

1. 流程总览

  1. 用户程序(aitest.c)通过 ai_submit()ai_call() 提交请求。
  2. 内核在 ai_service_submit() -> ai_service_enqueue_tokens() 中创建请求对象、保存输入并把请求放入队列。
  3. ai_daemon 注册为 worker 后,通过 ai_service_worker_get() 从队列中取任务。
  4. worker 在完成推理后,调用 ai_worker_complete() 把执行结果交还给内核。
  5. 前台程序可以通过 ai_query() 查询状态,也可以通过 ai_wait() 等待完成并取回结果。

2. 提交请求

  1. 用户态程序(例如 aitest.c)调用 ai_submit()ai_call() 提交请求。例如:

    ai_submit(tokens, token_count, predict_count);
    
  2. 以内核如何处理 ai_submit() 为例。用户态程序发起系统调用后,会进入 sys_ai_submit(),再调用内核态的 ai_service_submit()

    // kernel/core/syscall.c
    static uint64 sys_ai_submit(void) {
        struct proc *p = myproc();
        if (p == 0) {
            return (uint64)-1;
        }
        return (uint64)ai_service_submit(
            p->trapframe->a0,
            (int)p->trapframe->a1,
            (int)p->trapframe->a2
        );
    }
    
  3. ai_service_submit() 本质上会继续调用 ai_service_enqueue_tokens(),来将请求的具体内容放到请求队列中,具体说明见Part1部分。

  4. 到这里为止,这次请求已经从“前台的一次函数调用”变成了“内核队列中的一个受管对象”。

3. worker 取任务并完成请求

  1. ai_daemon 启动后,会先调用 ai_worker_register();其内核侧对应的函数是 ai_service_worker_register()(具体说明见Part1部分)。

  2. 注册成功后,worker 会不断调用 ai_worker_get();其内核侧对应的函数是 ai_service_worker_get()

  3. 如果此时队列为空,worker 不应该一直轮询抢占 CPU,而是先阻塞等待;等到有新请求入队后,再继续取任务。

  4. 成功取到任务时,内核会把当前请求中的 tokensreqidpredict_count 回写给用户态 worker。于是 ai_daemon 就真正拿到了这次要执行的任务内容。

  5. worker 在用户态完成模型推理后,需要调用下面这个接口把结果交还给内核:

    ai_worker_complete(reqid, result, result_len, status);
    
  6. ai_worker_complete() 是一个系统调用,其内核侧对应的函数是 ai_service_worker_complete()。这个函数可以简单理解成“worker 向内核提交本次任务的结算结果”。

  7. ai_service_worker_complete() 会先检查两件事(具体说明见Part1部分):

    • 当前调用者是不是已经注册成功的合法 worker;
    • reqid 对应的请求是否真的存在,并且当前处于 RUNNING 状态。
  8. 检查通过后,内核会把这次执行结果正式写回请求对象,并把状态改成 DONEFAILED;随后再唤醒可能阻塞在 ai_wait() 中的前台进程。到这里为止,异步主线才真正闭环。

4. 前台查询与等待结果

  1. 如果前台只想知道某个请求目前是什么状态,可以调用:

    ai_query(reqid, &st);
    
  2. 如果前台需要真正等待完成并拿到结果,可以调用:

    int n = ai_wait(reqids[i], result, sizeof(result));
    
  3. ai_query()ai_wait() 进入内核后,并不会只凭一个 reqid 就直接访问请求对象,而是都会先调用 ai_find_req_locked(reqid, p->pid)

  4. ai_find_req_locked() 的作用可以简单理解成:先按 reqid 找到请求,再进一步确认这个请求是不是当前进程自己的请求。

  5. 如果这一步检查失败,query() / wait() 都应直接返回 -1;否则才可以继续读取状态或等待结果。

  6. ai_query() 的职责比较简单:把当前请求的状态、错误码、结果长度等信息整理后返回给用户态。

  7. ai_wait() 则更进一步:如果请求尚未完成,就阻塞等待;如果请求已经完成,就返回结果或错误,并在成功消费后回收请求槽位。

5. 辅助函数1:copyin() / copyout()

这两个函数可以先把它们理解成“内核帮你做的一次数据拷贝”:

  • copyin():把用户态程序给出的数据复制到内核自己的缓冲区里;
  • copyout():把内核里已经整理好的数据复制回用户态程序提供的缓冲区里。

原理上的直观理解可以先记到这里;写代码时,重点关注它们在代码里怎么写、关键变量分别填什么。

常见写法如下:

copyin(p->pagetable, (char *)kbuf, user_uva, nbytes);
copyout(p->pagetable, user_uva, (char *)kbuf, nbytes);

可以先按下面这对应关系来记:

  • p->pagetable:通常直接传当前进程的页表,实验里一般写成 p->pagetable(不用管,就这么写╮(╯▽╰)╭)
  • kbuf:内核里的缓冲区变量,也就是你准备读入或写出的那块内存
  • user_uva:用户程序传进来的地址变量,通常名字里会带 uva (理论课第七章的内容)
  • nbytes:本次要处理的字节数,通常写成“元素个数 × 每个元素大小”

在本实验里,几个典型用法:

  • copyin(p->pagetable, (char *)tokens, token_uva, token_count * sizeof(uint32)) 这里的 tokens 是内核里的 token 缓冲区,token_uva 是用户传进来的 token 地址
  • copyout(p->pagetable, token_uva, (char *)tokens, token_count * sizeof(uint32)) 这里通常表示把内核里的 tokens 内容按相同长度写回某个用户缓冲区
  • copyout(p->pagetable, reqid_uva, (char *)&reqid, sizeof(reqid)) 这里的 reqid_uva 是用户提供的整型输出地址,&reqid 是内核里的整型变量地址
  • copyout(p->pagetable, predict_uva, (char *)&predict_count, sizeof(predict_count)) predict_uva&predict_count 的对应关系与上面相同
  • copyin(p->pagetable, result, out_uva, out_len) 这里的 result 是内核临时结果缓冲区,out_uva 是 worker 传进来的结果地址
  • copyout(p->pagetable, st_uva, (char *)&st, sizeof(st)) 这里的 st_uva 是用户传进来的状态结构地址,&st 是内核整理好的状态结构

写的时候最容易看乱的,其实就是两件事:

  • 第一个地址参数和第二个地址参数,哪个是内核变量,哪个是用户地址
  • 长度到底该写 sizeof(x),还是 count * sizeof(type)

6. 辅助函数2:sleep() / wakeup()

它们的原型大致如下:

// kernel/core/proc.c
void sleep(void *chan, struct spinlock *lk){
    ...
    acquire(&p->lock);
    release(lk);

    p->chan = chan;
    p->state = SLEEPING;
    sched();
    ...
}
void wakeup(void *chan){
    ...
    if (p->state == SLEEPING && p->chan == chan) {
            p->state = RUNNABLE;
    }
    ...
}

可以先按下面这种方式理解这两个函数的用法:

  • sleep(chan, lk):表示“当前进程先挂到 chan 这个等待条件上,然后进入睡眠”;其中 lk 是当前正在保护共享状态的那把锁
  • wakeup(chan):表示“唤醒所有睡在这个 chan 上的进程”,让它们重新参与调度并再次检查条件

这两个函数在代码里最需要看清的是两个参数:

  • chan:你到底在等哪件事发生。sleep()wakeup() 两边必须使用同一个 chan
  • lk:当前保护共享状态的锁;在本实验里通常就是 &aisvc.lock

先把 chan 理解成什么?

chan 可以先把它理解成“等待条件的标识”。内核在 sleep(chan, lk) 时,会把当前进程记到这个 chan 上;之后只有别人对同一个 chan 调用 wakeup(chan),这个进程才会被唤醒。

在本实验里,chan 不需要理解成某种复杂的数据结构;更直接地说,它通常就是“你正在等哪件事发生”的那个稳定地址。只要地址稳定、sleep()wakeup() 两边使用的是同一个地址,并且它能清楚表示“你在等什么事情”,就可以作为 chan

例如:

  • ai_service_worker_get() 中,worker 等待的是“消息队列从空变成非空”,所以 chan 可以选为队列对象或 service 对象本身的地址;
  • ai_service_wait() 中,前台进程等待的是“某个具体请求完成”,所以 chan 更适合选为该请求对象自己的地址。

可以先把常见写法理解成下面这样:

acquire(&svc->lock);
while (queue_empty(...)) {
    sleep(svc, &svc->lock);
}
...
release(&svc->lock);
acquire(&svc->lock);
...
wakeup(svc);
release(&svc->lock);

7. 辅助函数3:acquire() / release()

为什么锁总和阻塞一起出现?

acquire(&aisvc.lock)release(&aisvc.lock) 不是“固定代码”,而是用来保护共享状态的。

  • 在进入队列、取出队列、修改 qcount / qhead / qtail、推进请求状态时,都应该先加锁;(回想一下Hw2)
  • 完成这一轮共享状态修改后,再释放锁;
  • 如果已经决定让当前进程阻塞,不要自己手工先 release()sleep(),而应直接写成 sleep(chan, &aisvc.lock)
  • 更稳妥的顺序通常是:先在持锁状态下把条件真的改好,再调用 wakeup(),最后 release()

Part 1:实现异步 AI Service 主路径

1.1 目标

第一部分需要让系统具备下面这条结构:

  • 用户程序提交请求;
  • 内核把请求组织成队列中的受管对象;
  • ai_daemon 注册为唯一 worker;
  • worker 取任务、执行推理并回写结果。

第一部分结束后,Kernel才第一次具有“后台 service”结构。

1.2 你要实现的函数

这一部分需要完成四个函数:

  • ai_service_enqueue_tokens()
  • ai_service_worker_register()
  • ai_service_worker_get()
  • ai_service_worker_complete()

1.3 建议按三个任务推进

如果你觉得补全代码有些抽象,可以参考下面三个小任务推进,而不是四个函数各写各的。

  1. 先让 ai_daemon 能注册成功
    • 重点完成 ai_service_worker_register()
    • 先把“唯一 worker”这件事建立起来。
  2. 再打通“提交 -> 入队 -> 取任务”
    • 重点完成 ai_service_enqueue_tokens()ai_service_worker_get()
    • 先让请求真的能从前台走到 worker。
  3. 最后补全“完成回写 -> 唤醒等待者”
    • 重点完成 ai_service_worker_complete()
    • 到这里为止,异步主路径才算真正闭环。

1.4 功能要求

请至少保证下面这些要求成立(几乎每一点要求都会有线上测评的corner case,当你的线上测评有问题时,不妨回头看看这里):

  1. ai_service_enqueue_tokens()
    • 当系统中还没有合法 worker 注册时,应拒绝新请求;
    • 当请求槽位耗尽时,应等待可用槽位;
    • 需要把用户态传入的 token 数据 copyin 到内核请求对象;
    • 请求入队后,应能唤醒等待任务的 worker。

思考: 为什么token id需要拷贝到内核,而不是直接用指针传递?

  1. ai_service_worker_register()
    • 当前系统中只能有一个合法 worker;
    • 如果已经有另一个 worker 在线,新注册应失败;
    • 如果当前调用者就是已经登记的 worker,则重复注册应视为合法的幂等操作,而不是失败;
    • 注册成功后,需要记录 worker 身份和在线状态。

思考:允许同一个调用者(worker)重复注册能带来什么好处?

  1. ai_service_worker_get()
    • 只有当前已注册的合法 worker 才能取任务;否则直接返回 -1
    • 队列为空时,worker 应阻塞等待,直到有新请求入队后再继续。
    • 这里可把“等待队列非空”这件事对应到一个固定的 chan 上,例如 service/队列对象本身的地址;sleep(chan, &lock) 和生产者入队后的 wakeup(chan) 必须使用同一个 chan
    • 检查或修改 qcountqhead、队列槽位内容以及请求状态时,都应在 aisvc.lock 的保护下进行。
    • 成功取到任务时,需要从队列中弹出一个请求,并将该请求状态推进到“正在执行”。
    • 成功时需要把请求的 token 数据、reqidpredict_count 回写给用户态 worker,并返回本次请求的 token_count
    • 如果 worker 提供的 token_cap 小于该请求实际的 token_count,本次取任务应失败;同时不能把请求永久留在 RUNNING 状态。
    • 如果向用户态 worker 回写 token、reqidpredict_count 的任一步失败,也不能把请求永久卡在运行中。
    • 上面两类失败都应把该请求正确收尾为失败态,例如设置负错误码、清空结果长度,并唤醒可能在等待该请求结果的前台进程,而不是直接返回-1

思考,为什么不同的失败的处理方式不一样?为什么有些需要回收请求,有些直接返回-1?

  1. ai_service_worker_complete()
    • 只有当前已注册的合法 worker 才能完成任务;否则直接返回 -1
    • 只能完成当前处于 RUNNING 状态的请求;如果 reqid 无效、请求不存在、请求不在运行态,或同一请求被重复完成,也应直接返回 -1
    • 在处理用户态结果指针前,不要长时间持有锁;应先确认身份与请求状态,再在锁外完成必要的用户态数据复制。
    • status == 0 时,表示 worker 认为本次推理成功:
      • 需要检查输出长度是否合法;
      • 需要把 out_uva 指向的结果复制到内核临时缓冲区;
      • 如果长度非法或复制失败,本次完成应被视为“请求失败”,而不是把请求继续留在 RUNNING 状态。
    • 重新加锁后,需要再次按 reqid 查找请求,并重新确认它仍处于 RUNNING 状态,再写回最终结果。
    • 成功完成时,需要:
      • 设置 err = 0
    • 失败完成时,需要:
      • err 设为负值;
      • result_len 清零;
    • 无论最终是 DONE 还是 FAILED,只要这是一次对合法 RUNNING 请求的完成操作,都应唤醒睡眠在 ai_wait() 中的调用者。

梳理一下 ai_service_worker_complete() 的返回情况

这个函数最容易让人看乱的地方在于:函数返回值请求最终状态不是同一个维度。先分开看

  1. return -1 表示“这次 complete 调用本身不合法”,而不是“请求执行失败”。 常见情况包括:

    • 当前调用者根本不是合法 worker;
    • reqid 无效;
    • 根本找不到这个请求;
    • 这个请求已经不在 RUNNING 状态;
    • 重新加锁后发现该请求已经被别人改动,不再是当前这次合法的完成对象。
  2. return 0 且请求被写成 DONE 表示“这次 complete 调用合法,并且 worker 提交的是一个成功结果”。 这时通常要求:

    • status == 0
    • 输出长度合法;
    • 从用户态复制结果到内核临时缓冲区成功;
    • 重新加锁后请求仍然处于 RUNNING 状态。
  3. return 0 且请求被写成 FAILED 表示“这次 complete 调用本身合法,但是这次请求应被判定为执行失败”。 常见情况包括:

    • worker 主动上报失败,即 status != 0
    • status == 0,但输出长度非法;
    • status == 0,但从用户态复制结果失败。

因此,可以用一句话记这个函数:

  • return -1:说明“这次完成操作不成立”;
  • return 0 + DONE:说明“这次完成操作成立,而且请求成功完成”;
  • return 0 + FAILED:说明“这次完成操作成立,但请求被正式收尾为失败态”。

最后再特别注意一点:只要是“对一个合法 RUNNING 请求做了正式收尾”,无论最后写成 DONE 还是 FAILED,都应该 wakeup(req);但如果这次调用一开始就是非法的、直接 return -1,则不应把它当成一次合法完成来唤醒等待者。

1.6 测试方法

建议先确认启动日志中已经出现:

[init] ai_daemon ready (pid=...)

再运行下面这些主线检查:

aitest --async
aitest --async --batch 4

你可以这样理解这些命令分别在检查什么:

  • aitest --async 检查请求是否真的经过“提交 -> 队列 -> worker -> 完成回写”。
  • aitest --async --batch 4 检查多个请求是否能共享同一个 service 主干,而不是只对单次调用生效。

注意,上述检查只是线上测评的子集!!!

Part 2:实现查询、等待与所有权语义

2.1 目标

Part 1 完成后,Part2是为了把这条 service 补成一个真正像操作系统服务的接口:

  • 请求只能被拥有者查询和等待;
  • wait 需要正确阻塞;
  • 结果只能成功消费一次;
  • 请求完成后,槽位需要被回收。

2.2 你要实现的函数

  • ai_find_req_locked()
  • ai_service_query()
  • ai_service_wait()

2.3 功能要求

请至少保证下面这些语义成立:

  1. ai_find_req_locked()
    • 不能只看 reqid
    • 还要检查当前请求是否属于当前进程;
    • 这是后续 query / wait 隔离语义的基础。
  2. ai_service_query()
    • 只能查询当前进程自己提交的请求;
    • 需要把请求状态、错误码、结果长度等信息整理后返回给用户态;
    • 查询本身不应消费结果。
  3. ai_service_wait()
    • 只能等待当前进程自己提交的请求;
    • 请求未完成时应阻塞等待;
    • 这里更适合把 chan 设计成“这个请求对象自己的地址”,表示等待“该请求完成”;随后在 ai_service_worker_complete() 中把请求写成 DONEFAILED 后,再对同一个 chan 调用 wakeup()
    • 查找请求、检查请求状态以及最终回收请求槽位时,都应在 aisvc.lock 的保护下完成;不要在无锁状态下直接读写共享请求对象。
    • 请求失败时应返回错误;
    • 成功取回结果后,要把请求槽位回收;
    • 同一个请求结果不能被成功取走两次。

2.4 提示

这一部分容易写错的地方

  • 只检查 reqid,忘记检查 owner_pid,会导致跨进程信息泄露。
  • wait 成功后不回收槽位,跑一阵子就会发现系统再也没有空位。
  • 过早回收请求对象,可能导致结果还没 copyout 完就被清空。
  • 第二次 wait 还能成功,通常说明 one-shot 语义没有真正建立起来。

这一部分结束前,做一次同步接口一致性检查

starter code 里已经给你提供了一条最小同步 ai_call() 路径。当前面的 query/wait 语义都稳定之后,需要再回头修改:

submit + wait

也就是说,用户态看到的同步接口可以保留,但底层不应再绕开异步 service 主线。

2.5 测试方法

建议在 Part 1 通过后,再做下面这些主线检查:

aitest --async --query
aitest --badwait
aitest --async --doublewait
aitest --async --fork-unauth
aitest

你可以这样理解这些命令分别在检查什么:

  • aitest --async --query 检查 ai_query() 是否能返回合法状态。
  • aitest --badwait 检查非法 reqid 是否会被正确拒绝。
  • aitest --async --doublewait 检查结果是否只能成功消费一次。
  • aitest --async --fork-unauth 检查子进程是否会被错误地允许等待父进程的请求。
  • aitest 作为最后的同步一致性检查,确认 ai_call() 已经和异步路径使用同一套底层语义。

测试与验收建议

测试范围说明

下面列的是建议你自己先做的检查,目的是帮助你尽快常见的问题。它不是完整的线上测评用例。

建议按下面顺序做自测:

  1. 先做一次课程提供的同步快速检查:

    aitest
    
  2. 再确认后台 worker 已经拉起:

    [init] ai_daemon ready (pid=...)
    
  3. 再检查 Part 1 的异步主路径:

    aitest --async
    aitest --async --batch 4
    
  4. 再检查 Part 2 的查询与边界语义:

    aitest --async --query
    aitest --badwait
    aitest --async --doublewait
    aitest --async --fork-unauth
    
  5. 最后再做一次同步一致性检查:

    aitest
    

如果上面这些命令都能稳定通过,你的实现通常已经完成了实验的主线。

Bonus

本实验的 Bonus 为选做内容,满分 5 分,需要助教进行人工检查

Bonus 的详细要求、代码入口和建议测试方式见 实验二 Bonus:在 ai_daemon 中实现最小前缀缓存

实验结论

从课程结构上看,可以把这句话作为本实验的最终认识:

HW2 让模型能在用户态跑起来;Lab2 让这条能力开始像一个操作系统服务。