大语言模型能否正确遗忘?

大多数 LLM 推理运行时不知道存在物理截止日期。这个拒绝入场而不是错过 33 毫秒的机器人控制周期,通过意义而不是年龄驱逐 KV 缓存,并且完全用手写 CUDA 编写 - 没有 cuBLAS,没有 libtorch。 帖子 Can an LLM Forget the Right Things? 帖子 Can an LLM Forget the Right Things?首先出现在《走向数据科学》上。

来源:走向数据科学

关键要点

  • 将普通的面向聊天的LLM运行时指向实时机器人摄像头,三个问题会同时发生:(i) 来自永无止境的视频流的显存溢出,(ii) 控制循环截止时间被静默错过,(iii) 60Hz摄像头轻松超越速度慢得多的推理步骤。
  • 解决方案:一个准入控制器,通过在线指数移动平均估算推理块的代价,并拒绝启动而非在33ms截止时间上赌博;一个KV缓存,通过余弦相似度驱逐最冗余的帧,而非自动选择最旧的帧;一个无锁双缓冲区,使感知永远不会阻塞于积压的陈旧数据。
  • 引擎室:Qwen2.5-Coder-1.5B-Instruct的完整transformer——RMSNorm、RoPE、分组查询注意力、SwiGLU——全部用CUDA手写,无cuBLAS、无libtorch;与真实HuggingFace前向传播验证,余弦相似度≥0.999。
  • 诚实部分:本文是架构设计,而非基准测试。基于单个云NVIDIA Hopper GPU(sm_90)构建,8GB显存上限是代码中的建模预算,而非在Jetson或任何其他物理边缘板上测得的数据。
  • TL;DR:将普通LLM服务器指向实时机器人摄像头,它会愉快地填满显存、错过控制循环截止时间,并在比自身大脑更快的摄像头面前窒息。vla-edge-backend是一个手写CUDA运行时,拒绝无法在33ms内完成的推理,按"含义"而非仅按"年龄"驱逐KV缓存,且决不让感知阻塞于推理。它是架构设计,而非基准;构建于云Hopper GPU之上,8GB上限是设计约束,而非实测数据。

    架构思维模型:60Hz摄像头 → 无锁双缓冲区 → 准入控制器(接纳/拒绝)→ [视觉编码器 → KV管理器(语义驱逐)→ 手写CUDA transformer] → 动作或回退。以下所有内容都是对该流程中某一部分的评述。

    GitHub仓库:https://github.com/AnubhabBanerjee/vla-edge-backend

    1. 永不停止的时钟

    在这个项目的某处,摄像头每16.7毫秒产生一帧新图像。它不关心下游是否准备好。它不暂停、不重试,当然也不会提交支持工单。它只是继续运行,每秒60次,永远如此,因为固定在机器人上的摄像头就是这样的。

    与此同时,连接视觉编码器的小语言模型有更艰巨的任务:获取最新的帧,决定如何处理,并在33毫秒窗口内完成决策。不是"通常",不是"运气好的时候"。33毫秒,作为参考,比人类眨眼闭合的一半还快,眼睑闭合大约需要50到100毫秒。机器人不能眨眼思考。

    这个数字不是凭感觉定的。它是代码库顶部的编译时常量,位于include/physical_ai/common.hpp中:

    constexpr double DEADLINE_MS = 33.0;
    constexpr double SAFETY_MARGIN_MS = 2.0;

    这个运行时的每一部分要么是为了达到这个数字而存在,要么是有意大声失败而非静默错过——如果你曾与对截止时间视而不见的系统打过交道,这已经是一种性格升级。

    在继续之前先坦白一点,因为开启新系列的全部意义就是拒绝将一种真相伪装成另一种:这个运行时是在云开发机上的单个NVIDIA Hopper GPU(sm_90)上构建和测试的。这里没有Jetson。你即将读到很多的"8GB显存上限"是编码到代码中的设计约束,是KV管理器被构建为遵守的预算,而非在机器人板载计算上测得的数字。如果你来寻找实验室机械臂上的基准表,这里没有;那是未来的工作,我宁愿现在就说清楚,而不是把它埋在文末。

    以下是一个系统的架构,它将硬截止时间和固定内存预算视为最高优先级的约束,而不是像大多数LLM栈那样:假装两者都不存在,直到某样东西崩溃——通常代价不菲。

    2. 为什么你最爱的LLM运行时一旦装上机器人就会崩溃

    拿任何LLM世界已有的优秀服务栈,指向实时摄像头而非聊天窗口。三个问题几乎会同时发生。

    显存爆炸,因为面向聊天的运行时假设对话最终会结束。机器人的摄像头永远不会发送"再见"标记。它每帧产生新的视觉标记,永远如此,不断增长的上下文窗口最终会在边缘GPU上耗尽显存,而且快得令人不安,因为边缘GPU没有数据中心的备用空间。

    截止时间被静默错过,因为聊天完成从2秒变成4秒只是轻微恼人,而机器人控制步骤从33ms变成45ms则是在本应发生之后才发生的物理动作。标准推理运行时完全没有截止时间的概念。它们计算多久算多久,然后想什么时候返回答案就什么时候返回,就像一个承诺"周二某个时候到"的承包商。

    频率不匹配,因为摄像头可以以60Hz推送,而进行真正推理的视觉-语言-动作模型跟不上这个速度。必须有什么东西坐在两者之间,否则感知要么阻塞摄像头(现在它在撒谎事件发生的时间),要么在推理进一步落后于现实时堆积过时帧的积压。

    用三行来说,读起来像没人想发的状态报告:

  • 显存:像没人忍心退出的群聊一样填满。
  • 截止时间:被静默错过,运行时中甚至没有人记录事件。
  • 摄像头和模型:以两种不同的速度进行两场不同的赛跑,只有一方注意到了。
  • 这都不是对vLLM、TensorRT-LLM或llama.cpp服务器的贬低,它们在各自擅长的领域表现出色:尽可能多地服务数据中心能吞吐的聊天式请求。机器人的摄像头不是聊天式请求;它是一个连续的、不可阻挡的流,绑着一个物理时钟,这种工作负载问的是不同的问题,不是"每秒多少token",而是"我现在在哪个33ms窗口内,我能在它关闭前完成吗"。

    vla-edge-backend从第一行代码就把这三种故障模式视为设计约束,而不是事后将它们改造到面向聊天的运行时上。

    3. 从不请求许可的摄像头

    这个运行时的感知侧只有一个工作:以60Hz持续发布最新帧,并且在任何情况下都不阻塞等待推理侧赶上。推理可以慢一点。摄像头不允许在意。

    机制是无锁单生产者/单消费者双缓冲区:两个帧槽和一个原子版本计数器,指示当前哪个有效。把它想象成一个反向的"抢椅子"游戏,恰好有两把椅子和一次恰好一个玩家:总有一把空椅子,所以没人需要站着等音乐停止。

    struct PerceptionPipeline {
      PerceptionFrame frame_buffers[2];
      std::atomic<std::uint64_t> published_version;
      std::thread producer_thread;
      std::atomic<bool> producer_running;
      std::vector<std::uint8_t> clip_frame_storage;
      std::uint32_t clip_num_frames;
      std::uint32_t clip_read_cursor;
      double producer_start_time_sec;
      PerceptionFrame last_consumed_frame;
      bool has_consumed_frame;
    };

    生产者线程写入当前未被读取的缓冲区,然后翻转计数器:

    const int write_buffer_index = static_cast<int>(local_version & 1U);
    PerceptionFrame* write_frame = &pipeline->frame_buffers[write_buffer_index];
    std::memcpy(write_frame->pixels, &pipeline->clip_frame_storage[frame_offset], frame_byte_count);
    write_frame->frame_index = frame_index;
    const double elapsed_sec =
        physical_ai_steady_time_sec() - pipeline->producer_start_time_sec;
    write_frame->capture_timestamp_sec = elapsed_sec;
    ++local_version;
    pipeline->published_version.store(local_version, std::memory_order_release);

    消费者永不阻塞。它检查计数器自上次查看以来是否移动,然后取用最新的:

    bool perception_pipeline_consume_latest(PerceptionPipeline* pipeline,
                                            PerceptionFrame* out_frame,
                                            std::uint64_t* last_seen_version) {
      const std::uint64_t current_version =
          pipeline->published_version.load(std::memory_order_acquire);
      if (current_version == 0U) {
        return false;
      }
      if (current_version != *last_seen_version) {
        const int read_buffer_index = static_cast<int>((current_version - 1U) & 1U);
        pipeline->last_consumed_frame = pipeline->frame_buffers[read_buffer_index];
        pipeline->has_consumed_frame = true;
        *last_seen_version = current_version;
      }
      if (!pipeline->has_consumed_frame) {
        return false;
      }
      *out_frame = pipeline->last_consumed_frame;
      return true;
    }

    没有版本变化,就没有新拷贝,读取者只是复用已有的内容,而不是像没别的事做一样等待。原子变量上的acquire/release对是整个安全性论证:无互斥锁、无阻塞、无"请稍候"音乐。

    从中获得真正的60Hz——而不是"大约60Hz,运气好的话"——需要一个混合睡眠/自旋循环,因为大多数操作系统上的sleep_for精确到大约1毫秒,当你有几十毫秒的余量时这是个不错的误差范围,而当你的整个周期预算只有几十毫秒时,这就令人尴尬了:

    void hybrid_sleep_until(const std::chrono::steady_clock::time_point& target_time) {
      while (true) {
        const auto now_time = std::chrono::steady_clock::now();
        if (now_time >= target_time) {
          return;
        }
        const auto remaining = target_time - now_time;
        const auto remaining_ms =
            std::chrono::duration_cast<std::chrono::milliseconds>(remaining).count();
        if (remaining_ms > 1) {
          std::this_thread::sleep_for(std::chrono::milliseconds(remaining_ms - 1));
        }
      }
    }

    睡眠除最后一毫秒外的所有时间,然后忙等剩下的路。用一小部分CPU换取真正准时到达,而不是"差不多准时"。同样的技巧在准入控制器的循环中几乎逐字出现。同样的问题,同样的解决方案,重复使用好主意并不丢人。

    4. 教大脑说"不"

    这是运行时中做出真正不寻常决定的部分:当运行时不确定能否及时完成时,它不只是尽力而为然后寄希望于最好的结果。它拒绝开始。没有乐观。没有"我们在解码时补回来"。只是一个干脆的"不",在任何GPU周期被浪费于亲身体验之前发出。据我所知,这是这个代码库中边界比大多数人在周一早上处理得更干净的部分。

    这是实际决策,短到一口气读完:

    bool admission_controller_should_admit(const AdmissionEmaState* ema_state,
                                           double elapsed_since_cycle_start_ms) {
      if (!ema_state->has_bootstrap_sample) {
        return true;
      }
      const double remaining_budget_ms =
          DEADLINE_MS - elapsed_since_cycle_start_ms - SAFETY_MARGIN_MS;
      const double estimated_chunk_ms =
          static_cast<double>(ema_state->ema_prefill_per_token_ms) *
              static_cast<double>(VISION_TOKENS_PER_FRAME) +
          static_cast<double>(ema_state->ema_decode_per_token_ms) *
              static_cast<double>(ACTION_DECODE_TOKENS);
      return estimated_chunk_ms <= remaining_budget_ms;
    }

    用通俗语言说:取33ms预算的剩余部分,减去2ms安全边际,然后与基于近期历史估计的下一个推理块(32个预填充token,8个解码token)的代价进行比较。装不下?不启动。回退到重复上一个动作,而不是在错过上赌博。

    一个值得精确说明的接线细节:在当前主循环中,这个检查在循环顶部运行,在任何GPU工作开始之前,调用点总是传入elapsed_since_cycle_start_ms = 0.0。所以目前比较实际上是"我的估计代价是否适合DEADLINE_MS − SAFETY_MARGIN_MS",每个周期检查一次,而不是在块中间持续重新检查。它是对估计值的启发式守卫,而非硬保证——看起来安全的块仍可能运行超时,运行时只是将其记录为截止时间错过,而不是假装不可能。信心,而非先见之明。

    估计来自指数移动平均——平淡无奇、古老且极其有效:

    ema_state->ema_prefill_per_token_ms =
        (1.0f - ADMISSION_EMA_ALPHA) * ema_state->ema_prefill_per_token_ms +
        ADMISSION_EMA_ALPHA * measured_prefill_per_token_ms;
    ema_state->ema_decode_per_token_ms =
        (1.0f - ADMISSION_EMA_ALPHA) * ema_state->ema_decode_per_token_ms +
        ADMISSION_EMA_ALPHA * measured_decode_per_token_ms;

    ADMISSION_EMA_ALPHA为0.2f,意味着新测量值占20%的权重,历史保留其余80%。预填充和解码有意使用独立的EMA,作为两个不同的边界计时,而不是一个数字对半分。预填充是32-token并行传递;解码是八次逐个token的传递,每次都有自己的transformer前向传播和自己的argmax。不同的形状,不同的代价,不假装一样。第一个周期总是被接纳,因为还没有可怀疑的历史,之后的每个决策都基于运行时在自己的硬件上测量的结果,而非从规格表上抄来的数字。

    给任何在电信网络领域待过的人一个小注释:"准入控制"不是这个项目发明的术语。蜂窝网络和ATM网络几十年来一直在决定是否接受新呼叫而不破坏其他人的服务质量。机器人的33ms周期问"我能否在不违背唯一承诺的情况下接纳这个块"正是同样的决策,只是每秒做30次左右而非每次电话一次。

    5. 内存问题:故意遗忘

    每个处理的视觉帧消耗KV缓存中的32个槽位,缓存总容量恰好为N_MAX_TOKENS = 4096个槽位,在系统提示、所有保留帧和解码期间的一些临时动作token之间共享。做一下除法,上限很快就显现:最坏情况下,这个缓存容纳floor(4096 / 32) = 128帧,大约每33ms周期接纳一帧,几秒钟内就会填满。之后,每来一帧就必须淘汰一帧,有趣的决定是淘汰哪一帧。

    FIFO,明显的基线,总是淘汰最旧的帧,不问任何问题。它是淘汰政策中的虚无主义者:一切都是暂时的,年轻是唯一的罪行。

    // FIFO淘汰基线——每次溢出时淘汰单个最旧的保留帧块。
    bool kv_manager_fifo_evict_if_needed(KvManager* manager,
                                         int slots_needed,
                                         double eviction_timestamp_sec) {
      // 重复淘汰最旧帧,直到有足够的空闲物理槽位容纳新帧。
      while (manager->num_free_slots < slots_needed) {
        if (manager->num_retained_frames <= 0) {
          return false;
        }
        // FIFO始终移除时间上最旧的保留帧——不计算相似度。
        kv_manager_evict_oldest_frame(manager, eviction_timestamp_sec);
      }
      return true;
    }

    语义淘汰更具辨别力。它问的是"哪一帧最不可能被遗漏",而非"哪一帧最旧"。每个保留帧已经有一个经过视觉编码器的1536维池化嵌入。该策略遍历每对相邻的保留帧,计算余弦相似度,并淘汰相似度最高那一对中较旧的那一帧——与其邻居最冗余的帧,而非仅仅存在时间最长的帧:

    int best_pair_index = 0;
    float best_similarity = -2.0f;
    for (int pair_index = 0; pair_index < manager->num_retained_frames - 1; ++pair_index) {
      const float* embedding_a =
          &manager->pooled_embeddings[pair_index * HIDDEN_SIZE];
      const float* embedding_b =
          &manager->pooled_embeddings[(pair_index + 1) * HIDDEN_SIZE];
      const float similarity = kv_manager_cosine_similarity_host(embedding_a, embedding_b);
      if (similarity > best_similarity) {
        best_similarity = similarity;
        best_pair_index = pair_index;
      }
    }
    // ... 淘汰best_pair_index中较旧的帧,下移保留帧,重建索引 ...

    把它想象成淘汰有几乎相同室友的帧,而不是淘汰先搬进来的帧。如果两个连续帧看起来几乎相同——手臂没有移动,场景没有变化——丢弃一个几乎不会丢失任何信息。如果一帧捕捉到真正不同的内容,它幸存下来,因为没有同样无聊的邻居与它配对并归咎于它。这就是直接设计进淘汰规则而非事后附加的"显著性保留"。余弦相似度本身并不奇特——1536维点积和两个范数,在主机上计算:

    float kv_manager_cosine_similarity_host(const float* embedding_a, const float* embedding_b) {
      float dot_product = 0.0f;
      float norm_a = 0.0f;
      float norm_b = 0.0f;
      for (int hidden_index = 0; hidden_index < HIDDEN_SIZE; ++hidden_index) {
        const float value_a = embedding_a[hidden_index];
        const float value_b = embedding_b[hidden_index];
        dot_product += value_a * value_b;
        norm_a += value_a * value_a;
        norm_b += value_b * value_b;
      }
      const float denominator = std::sqrt(norm_a) * std::sqrt(norm_b);
      if (denominator < 1.0e-8f) {
        return 0.0f;
      }
      return dot_product / denominator;
    }

    滑动窗口,第三种策略,在其源文件中直接写了诚实说明——我引用它而非转述,因为它说得比我好:

    // 滑动窗口等价说明(§6.1):
    // 在本项目固定的统一32-token视觉帧块大小和相同的溢出行为下,sliding_window和fifo产生数学上等价的淘汰结果:
    // 两者都在容量满时淘汰时间上最旧的保留帧。本文件根据规范将sliding_window实现为独立的主动代码路径(在插入前强制执行上限),
    // 而非别名为fifo——在最终基准报告(§9)中如实报告这种等价性。

    由于每帧恰好消耗相同的32个槽位,"保留最后N帧"和"满则淘汰最旧"最终是同一政策的两套不同装束——就像滑动窗口穿着FIFO的服装出现在化装派对上,门口没人注意到。它仍然有自己的代码路径,并且在插入之前主动淘汰,当帧数达到上限时,而不是在插入期间槽位耗尽时被动淘汰,但最终状态与FIFO完全匹配。这不是藏在石头下的bug。这是统一帧大小的诚实数学结果,记录在实现它的文件中,而不是三个月后被困惑的读者发现。

    6. GPU上实际运行的是什么

    到目前为止的一切都是关于调度的。本节是关于被调度的东西:一个完全手写的transformer,没有cuBLAS、没有libtorch、没有ONNX运行时挡在代码和硅片之间。

    模型是Qwen2.5-Coder-1.5B-Instruct,其维度是从模型自身配置中提取的编译时常量,而非会在凌晨2点坑人的手推猜测:

    constexpr int HIDDEN_SIZE = 1536;
    constexpr int NUM_LAYERS = 28;
    constexpr int NUM_ATTENTION_HEADS = 12;
    constexpr int NUM_KV_HEADS = 2;
    constexpr int HEAD_DIM = 128;
    constexpr int INTERMEDIATE_SIZE = 8960;
    constexpr int VOCAB_SIZE = 151936;

    28个解码层。12个查询头共享仅2个KV头——6比1的分组查询注意力比率。这个比率是KV缓存能够完全适配4,096槽位固定预算的重要原因:只有2个KV头而非12个,每个缓存的token只消耗完整多头注意力的一小部分。整个KV缓存,跨所有28层、K和V、在完整的4,096槽位上限下,大约为235MB——舒适地处于8GB预算内。KV缓存从来不是突破内存上限的元凶。永无止境的视觉流才是真正的罪魁祸首。

    将摄像头帧转换为token。128×64 RGB帧被分割成16×16的patch——每帧32个,恰好匹配VISION_TOKENS_PER_FRAME——每个patch被线性投影为1536维向量。每个patch一个块,每块一个线程完成所有工作(对于这么小的东西不需要花哨的并行):

    __global__ void vision_patch_embed_kernel(float* __restrict__ patch_embeddings,
                                              float* __restrict__ pooled_embedding,
                                              const std::uint8_t* __restrict__ frame_rgb,
                                              const float* __restrict__ vision_projection_weight,
                                              const float* __restrict__ vision_projection_bias,
                                              const int frame_width,
                                              const int frame_height,
                                              const int patch_size,
                                              const int hidden_size) {
      const int patch_index = blockIdx.x;
      const int patches_per_row = frame_width / patch_size;
      const int patch_row = patch_index / patches_per_row;
      const int patch_col = patch_index % patches_per_row;
    
      // 按行主序展平patch像素,每个像素RGB交错——匹配Python预处理约定。
      float flattened_patch[768];
      int flat_index = 0;
      for (int pixel_row = 0; pixel_row < patch_size; ++pixel_row) {
        for (int pixel_col = 0; pixel_col < patch_size; ++pixel_col) {
          const int image_row = patch_row * patch_size + pixel_row;
          const int image_col = patch_col * patch_size + pixel_col;
          const int pixel_offset = (image_row * frame_width + image_col) * 3;
          for (int channel_index = 0; channel_index < 3; ++channel_index) {
            const float normalized_pixel =
                (static_cast<float>(frame_rgb[pixel_offset + channel_index]) / 127.5f) - 1.0f;
            flattened_patch[flat_index++] = normalized_pixel;
          }
        }
      }
    
      // 线性投影:patch_embedding[hidden] = W[hidden,768] @ flat_patch + bias。
      float* output_patch_ptr = patch_embeddings + patch_index * hidden_size;
      for (int hidden_index = 0; hidden_index < hidden_size; ++hidden_index) {
        float accumulated = vision_projection_bias[hidden_index];
        for (int flat_dim_index = 0; flat_dim_index < 768; ++flat_dim_index) {
          accumulated += flattened_patch[flat_dim_index] *
                         vision_projection_weight[hidden_index * 768 + flat_dim_index];
        }
        output_patch_ptr[hidden_index] = accumulated;
      }
      // ... 此处省略跨所有32个patch的池化均值累加到pooled_embedding ...
    }

    值得坦白的是:投影权重在启动时由确定性PRNG(splitmix64馈入Box-Muller变换)生成一次,未经任何训练。相同的种子、相同的权重,每次运行——这使得系统行为完全可重现,而视觉编码器本身保持原样:一个第一天上班就非常自信的实习生。形状正确,文书按时提交,内存占用量精确到字节——但还没有人教它它实际在看什么。这是有意的范围裁剪,不是我想掩盖的东西。

    注意力kernel是代码库中工程决策被争论和修订痕迹最清晰的部分。其上的注释讲述了整个故事:

    // 分组查询因果注意力:每个查询头一个CUDA块(gridDim.x = num_query_heads),
    // 每块使用ATTENTION_BLOCK_THREADS个协作线程,取代之前单线程实现。
    // 这取代了O(1)线程的完全串行化kernel(将每个点积、softmax归约和加权值求和强制到单条车道,
    // 零warp级内存延迟隐藏),换成了随num_valid_slots扩展的块并行归约。

    早期版本在每个头上运行单线程——易于审计,并行度大约相当于周五早晨的单车道收费站。当前版本每个查询头启动一个块(每块128个协作线程),并在三个同步阶段中执行注意力。线程分布在KV槽位上做点积,而不是堆在车道零上:

    float thread_max_logit = -1.0e30f;
    for (int slot_list_index = threadIdx.x; slot_list_index < num_valid_slots;
         slot_list_index += blockDim.x) {
      const int physical_slot = valid_slot_indices[slot_list_index];
      const float* key_head_ptr =
          key_cache + (static_cast<std::size_t>(kv_head_index) * N_MAX_TOKENS + physical_slot) *
                          head_dim;
      float dot_product = 0.0f;
      for (int dim_index = 0; dim_index < head_dim; ++dim_index) {
        dot_product += query_head_ptr[dim_index] * key_head_ptr[dim_index];
      }
      const float scaled_logit = dot_product / sqrtf(static_cast<float>(head_dim));
      my_logits_scratch[slot_list_index] = scaled_logit;
      thread_max_logit = fmaxf(thread_max_logit, scaled_logit);
    }

    两次共享内存树归约之后(最大值,然后是softmax求和),加权值求和遇到了一个真正有趣的问题:每个线程拥有不相交的KV槽位集,但所有线程都写入同一个头维度的输出向量,128个同事同时争抢同一个共享白板。解决方案是直接使用atomicAdd写入共享内存:在Kepler以来的任何GPU上都没问题,而且比手工编写第二次归约来避免少量争用简单得多:

    for (int dim_index = 0; dim_index < head_dim; ++dim_index) {
      atomicAdd(&shared_output[dim_index], attention_weight * value_head_ptr[dim_index]);
    }

    由于每个查询头的注意力完全独立于其他头,所有12个块可以真正地在不同流式多处理器上同时运行,这就是拆掉旧单线程kernel并编写这个kernel的全部性能论据。

    层的其余部分:RMSNorm、RoPE、SwiGLU,每个投影背后的每个矩阵乘法都遵循同样的手写、无库规则,每层运行一次,共28层,每个推理块两次:视觉帧的32-token并行预填充,然后是八个顺序解码步骤。这种形状上的不匹配正是预填充和解码在第4节中获得独立EMA估计器的原因——将32宽并行传递和8个单token传递视为同类工作只会使截止时间估计更差。

    证明它不是在对你说谎。手写自己的transformer是向RoPE引入微妙符号错误并三周都不注意的绝佳方式。项目的保险是一道正确性门控,将手写栈与真实HuggingFace前向传播在相同权重和相同token上运行,并要求它们一致:

    if (hidden_cosine < 0.999f) {
      std::fprintf(stderr, "hidden cosine %.6f < 0.999 threshold\n", hidden_cosine);
      return false;
    }
    if (max_hidden_abs_diff > 5.0e-2f) {
      std::fprintf(stderr, "hidden max abs diff %.6f > 5e-2 threshold\n", max_hidden_abs_diff);
      return false;
    }

    与HuggingFace输出的余弦相似度至少0.999,每维最大差异低于0.05。这不是基准测试,而是一个更基本的声明:这个手写数学实际计算的是它声称计算的东西,而不仅仅是产生看起来合理且运行快速的数字——尽管我为严格的33ms截止时间构建了脚手架,而老实说,transformer本身目前大约慢100倍(约3.1秒)。本文中其他一切都取决于这个门控首先通过。

    7. 本文(尚)不是什么的诚实清单

    如果你读到这里期待延迟数据表:没有,这是有意的。把这看作是运行时坐下来、眼神接触并在你自己发现之前列出自身局限的部分。

  • 本文中没有实测性能数据。没有截止时间错过率、没有p50/p95/p99延迟、没有回退率、没有峰值显存测量——尽管运行时完全配备了计算其中每一项的工具,并且tools/run_benchmarks.py已连接以连续运行所有三种淘汰策略。本文是架构;测量是独立的、未注明日期的工作。
  • 在Hopper GPU(sm_90)上构建和测试,而非边缘板。CMakeLists.txt硬编码CMAKE_CUDA_ARCHITECTURES 90。没有Jetson、没有DRIVE、该项目开发流程中没有任何地方的物理机器人。
  • 8GB显存上限是建模的,而非实测的。VRAM_CEILING_BYTES是一个常量,采样线程每100ms检查一次,针对大小舒适地适合其下的KV缓存——这是一个设计目标,而非实际撞上的墙。
  • 视觉编码器未经训练。每次运行相同的PRNG种子权重,真实的形状和内存占用,从未见过任何标记样本。任何关于它感知到什么的主张都是虚构的;本文仅声称关于系统工程的内容,从不关于感知质量。
  • 滑动窗口和FIFO在这里数学上是相同的策略,如第5节已经承认——仍然值得重复,因为一个不那么诚实的项目会悄悄掩埋它。
  • 正确性门控覆盖transformer,而非整个流程。它没有说明准入控制器的代价估计或语义策略在真实视频上的行为。那是未来基准测试的任务。
  • 这些都不改变架构主张:硬截止时间、固定内存上限和不匹配的传感器频率都可以是一等设计约束而非事后补充。只是意味着目前的主张是关于系统的形状,而非图表上的数字。

    8. 这留下了什么

    这是我在称之为"物理AI系统"系列的第一篇文章——不是因为我已写好了五部分路线图(我没有,我也不会为了听起来有条理而编造一个),而是因为本项目提出的问题比一个仓库更大。一个模型在无法协商的时钟下运行到底需要什么?"机器人AI"中有多少只是穿着更友好名字的系统工程?

    如果你以构建推理基础设施为生:去问问任何为你的模型提供服务的东西,老实说,当输入永不停止且输出有截止时间时会发生什么。如果答案是"它只是继续计算直到完成",你就找到了这个项目试图弥合的同一差距。

    如果你以制造机器人为生:我真的很想听听4096个token和8GB离你实际硬件预算有多远,以及语义淘汰策略能否在与真实视频(而非回放片段)接触后存活。

    如果这是你第一次真正看到推理运行时的内部:你现在知道准入控制器、KV淘汰策略和分组查询注意力kernel各自做什么,以及为什么机器人需要三者同时运行。大多数教程直接跳到model.generate(),好像困难的部分是说"请"。这就是那个调用下面的东西,对于一个不允许迟到的负载。

    现在去弄清楚你自己的推理栈在没人看着时钟时做什么。

    免责声明:本文中的插图是在AI图像工具的帮助下生成的。它们是说明性的,非摄影性的,图像中可见的任何标签都是风格化的而非权威的——精确的函数名、常量值和架构细节请参考正文和代码本身。文章正文、代码引用和常量值非AI生成。