Luke-Qian
Luke-Qian
发布于 2026-03-29 / 2 阅读
0
0

从头开始手写一个Qwen的Naive CUDA Kernel推理引擎(二)

Tokenizer

概述

下一步是Tokenizer(分词器),Tokenizer是Embedding之前最重要的一步。

它是用来将本文拆分成模型或机器可以处理的小单位,并分配一个唯一的标识符(Token)。

分词流程

分词流程包括Normalization, Pre-tokenization, Model和Post-tokenization

在这里,Model是分词的核心,在Pre-tokenization的基础上,根据选定的模型或算法(BPE,WordPiece, Unigram语言模型或者SentencePiece等)进行更细致的处理,包括通过大量文本数据,根据算法规则生成词汇表,然后根据词汇表,将文本拆分为Token。

其中,Qwen2.5使用的分词模型是BPE(Byte Pair Encoding),下面我们详细来看下BPE.

BPE

训练阶段

1.1 构建基本词汇表

BPE 训练的第一步是计算语料库中使用的唯一单词集,然后利用构成这些单词的所有符号来构建词汇表。(这里之所以没说字母是因为,除了字母以外还会有结束标记,或者其他Unicode字符)。

让我们举个简单的例子,假设我们的语料库只包含这五个单词:

"hug", "pug", "pun", "bun", "hugs"

那么基本词汇表就是["b", "g", "h", "n", "p", "s", "u"]。在实际应用中,这个基本词汇表至少会包含所有 ASCII 字符,因为语料库中可能会有表情,因此可能还会有一些Unicode 字符。

BPE有一个问题:如果推理阶段,要被分词的文本中包含了训练语料库中没有的字符,该字符就会被转换为未知标记 (unknown token)。导致分析能力降低。

GPT-2/RoBERTa 的分词器有一个巧妙的解决方法:它们不是将单词视为 Unicode 字符的组合,而是将其视为字节的组合。这样一来,基本词汇表的大小就很小 (只有 256),但同时又能包含所有可能的字符,避免了未知标记的出现。这个技巧被称为Byte-level BPE

1.2 词汇合并

在得到这个基本词汇表之后,我们通过合并 (merges)来不断添加新的词汇,压缩词汇表,直到词汇表大小到达阈值。这些合并规则会将现有词汇表中的两个元素合并成一个新元素。因此,在训练初期,这些合并主要是创建双字符的词汇,随着训练的进行,会逐渐形成更长的子词。

训练的每一步中,BPE 算法都会寻找现有词汇表中出现最频繁的一对标记 (这里的"一对"指的是单词中相邻的两个标记)。找到这个最频繁的标记对后,就将其合并,然后重复这个过程进行下一步。

让我们回到之前的例子,假设单词在语料库中的出现频率如下:

("hug", 10), ("pug", 5), ("pun", 12), ("bun", 4), ("hugs", 5) 

"hug"在语料库中出现了 10 次,"pug"5 次,"pun"12 次,"bun"4 次,"hugs"5 次。我们首先将每个单词拆分成单个字符 (这些字符构成了我们的初始词汇表),这样每个单词就变成了一个标记列表:

"h" "u" "g", 10), ("p" "u" "g", 5), ("p" "u" "n", 12), ("b" "u" "n", 4), ("h" "u" "g" "s", 5) 
1.2.1 第一次合并

我们在单词上使用一个长度为2的滑动窗口,从而计算每个相邻字符对出现的次数。比如,字符对("h", "u")出现在单词"hug""hugs"中,在整个语料库中总共出现了 15 次。出现最多的字符对是("u", "g"),它出现在"hug""pug""hugs"这三个词中,在整个词汇表中总共出现了 20 次。

因此,分词器学习的第一个合并规则 (merge rule) 是("u", "g") -> "ug""ug"将被添加到词汇表中,并且在语料库的所有单词中,"u" 和 "g" 相邻出现时都会被合并成 "ug"。

在这个阶段结束时,词汇表 (vocabulary) 和语料库 (corpus) 变成了这样:

词汇表: ["b", "g", "h", "n", "p", "s", "u", "ug"]
语料库: ("h" "ug", 10), ("p" "ug", 5), ("p" "u" "n", 12), ("b" "u" "n", 4), ("h" "ug" "s", 5)
1.2.2 第二次合并

现在我们看到一些字符对可以生成长度超过两个字符的标记了。比如,字符对("h", "ug")在语料库中出现了 15 次。不过,在这个阶段出现最频繁的字符对其实是("u", "n"),它在语料库中出现了 16 次。

所以,第二个学到的合并规则是("u", "n") -> "un"。将 "un" 添加到词汇表并在语料库中进行合并后,我们得到:

词汇表: ["b", "g", "h", "n", "p", "s", "u", "ug", "un"]
语料库: ("h" "ug", 10), ("p" "ug", 5), ("p" "un", 12), ("b" "un", 4), ("h" "ug" "s", 5)
1.2.3 第三次合并

出现最频繁的字符对变成了("h", "ug"),所以我们学习到的下一个合并规则是("h", "ug") -> "hug"。得到了第一个三字母的标记。合并后,语料库变成了这样:

词汇表: ["b", "g", "h", "n", "p", "s", "u", "ug", "un", "hug"]
语料库: ("hug", 10), ("p" "ug", 5), ("p" "un", 12), ("b" "un", 4), ("hug" "s", 5)

我们会继续这个过程,直到词汇表达到预设的大小

推理阶段

分词过程与训练过程密切相关。对新输入进行分词时,会按以下步骤进行:

  1. 标准化 (Normalization)

  2. 预分词 (Pre-tokenization)

  3. 将单词拆分成单个字符

  4. 按顺序应用学习到的合并规则到这些拆分结果上

让我们回顾一下训练时的例子,我们学到了三个合并规则:

("u", "g") -> "ug"
("u", "n") -> "un"
("h", "ug") -> "hug"

用这些规则来分词,单词"bug"将被分解为["b", "ug"]。但是,"mug"会被分解为["[UNK]", "ug"],因为字母"m"不在基本词汇表中。这里的[UNK]表示未知标记 (unknown token)。同样,单词"thug"将被分解为["[UNK]", "hug"]:字母"t"不在基本词汇表中,而应用合并规则会先将"u""g"合并,然后再将"h""ug"合并。

BPE代码实现

这里首先是pre_tokenize函数。在这里,这个函数所做的工作就是normalization和pre-tokenization。因为Qwen 2.5模型是使用的GPT2的BPE token化处理方式。所以下面是这个pre-tokenie的的具体实现。

std::vector<std::string> Tokenizer::pre_tokenization(const std::string &text) const {
    std::vector<std::string> pieces;
    size_t i = 0;

    while (i < text.size()) {
        size_t leading_space_begin = i;
        while (i < text.size() && text[i] == ' ') {
            ++i;
        }
        const bool has_leading_space = i > leading_space_begin;
        const size_t leading_space_count = i - leading_space_begin;

        if (i >= text.size()) {
            if (leading_space_count > 0) {
                pieces.push_back(text.substr(leading_space_begin, leading_space_count));
            }
            break;
        }

        const unsigned char ch = static_cast<unsigned char>(text[i]);

        if (is_ascii_space(ch) && ch != ' ') {
            size_t begin = i;
            while (i < text.size() && is_ascii_space(static_cast<unsigned char>(text[i])) && text[i] != ' ') {
                ++i;
            }
            pieces.push_back(text.substr(begin, i - begin));
            continue;
        }

        std::string contraction;
        if (starts_with_contraction(text, i, contraction)) {
            if (has_leading_space) {
                contraction.insert(0, leading_space_count, ' ');
            }
            pieces.push_back(contraction);
            i += contraction[0] == ' ' ? contraction.size() - leading_space_count : contraction.size();
            continue;
        }

        if (is_word_like_byte(ch)) {
            const size_t begin = i;
            while (i < text.size() && is_word_like_byte(static_cast<unsigned char>(text[i]))) {
                ++i;
            }
            std::string piece = text.substr(begin, i - begin);
            if (has_leading_space) {
                piece.insert(0, leading_space_count, ' ');
            }
            pieces.push_back(std::move(piece));
            continue;
        }

        if (is_number_like_byte(ch)) {
            const size_t begin = i;
            while (i < text.size() && is_number_like_byte(static_cast<unsigned char>(text[i]))) {
                ++i;
            }
            std::string piece = text.substr(begin, i - begin);
            if (has_leading_space) {
                piece.insert(0, leading_space_count, ' ');
            }
            pieces.push_back(std::move(piece));
            continue;
        }

        size_t begin = i;
        while (i < text.size()) {
            const unsigned char current = static_cast<unsigned char>(text[i]);
            if (current == ' ' || is_ascii_space(current) || is_word_like_byte(current) || is_number_like_byte(current)) {
                break;
            }
            std::string ignored;
            if (starts_with_contraction(text, i, ignored)) {
                break;
            }
            ++i;
        }

        std::string piece = text.substr(begin, i - begin);
        if (has_leading_space) {
            piece.insert(0, leading_space_count, ' ');
        }
        pieces.push_back(std::move(piece));
    }

    return pieces;
}

然后是normalization,注意这里的normalization不是常见NLP里的文本规范化,而是把每个piece映射成byte-encoder字符串,下面是代码:

std::vector<std::string> Tokenizer::normalization(const std::vector<std::string> &pieces) const {
    std::vector<std::string> normalized_pieces;
    normalized_pieces.reserve(pieces.size());

    for (const std::string &piece : pieces) {
        normalized_pieces.push_back(normalize_bpe_piece(piece));
    }

    return normalized_pieces;
}

std::string normalize_bpe_piece(const std::string &input) {
    static const std::array<std::string, 256> kByteEncoder = build_bpe_byte_encoder();

    std::string normalized;
    normalized.reserve(input.size() * 2);

    for (const char ch : input) {
        normalized += kByteEncoder[static_cast<unsigned char>(ch)];
    }

    return normalized;
}

std::array<std::string, 256> build_bpe_byte_encoder() {
    std::array<std::string, 256> encoder{};
    uint32_t extra_codepoint = 256;

    for (size_t byte_value = 0; byte_value < encoder.size(); ++byte_value) {
        const uint32_t codepoint =
            should_preserve_bpe_byte(byte_value) ? static_cast<uint32_t>(byte_value) : extra_codepoint++;
        append_utf8(encoder[byte_value], codepoint);
    }

    return encoder;
}

之后是核心的model步骤,它对每个normalized piece拆成最小utf-8符号,按照merge_ranks反复找最佳相邻对,逐渐合并到不能再合并为止,最后把结果追加到总输出。

std::vector<std::string> Tokenizer::model(const std::vector<std::string> &pieces) const {
    std::vector<std::string> model_pieces;
    for (const std::string &piece : pieces) {
        if (piece.empty()) {
            continue;
        }

        std::vector<std::string> symbols = split_utf8_codepoints(piece);
        if (symbols.size() < 2) {
            model_pieces.insert(model_pieces.end(), symbols.begin(), symbols.end());
            continue;
        }

        while (symbols.size() > 1) {
            size_t best_pair_index = symbols.size();
            uint32_t best_rank = std::numeric_limits<uint32_t>::max();

            for (size_t i = 0; i + 1 < symbols.size(); ++i) {
                const auto it = merge_ranks.find(merge_key(symbols[i], symbols[i + 1]));
                if (it != merge_ranks.end() && it->second < best_rank) {
                    best_rank = it->second;
                    best_pair_index = i;
                }
            }

            if (best_pair_index == symbols.size()) {
                break;
            }

            symbols[best_pair_index] += symbols[best_pair_index + 1];
            symbols.erase(symbols.begin() + static_cast<std::ptrdiff_t>(best_pair_index + 1));
        }

        model_pieces.insert(model_pieces.end(), symbols.begin(), symbols.end());
    }

    return model_pieces;
}

最后则是post_tokenization,这里将处理好的model_pieces换为编码好的token.

std::vector<int32_t> Tokenizer::post_tokenization(const std::vector<std::string> &pieces) const {
    std::vector<int32_t> token_ids;
    if (add_bos_token && bos_token_id >= 0) {
        token_ids.push_back(bos_token_id);
    }

    for (const std::string &piece : pieces) {
        token_ids.push_back(require_token_id(piece));
    }

    return token_ids;
}


评论