在自然语言处理(NLP)领域,Hugging Face的tokenizers库已经成为事实上的标准工具。然而,当我们需要在C++项目中集成这些功能时,官方仅提供了Python和Node.js的绑定。本文将详细分享如何从零开始封装Hugging Face Tokenizer的C接口,并构建一个符合现代C++最佳实践的封装层。
在实际工程中,我们经常遇到以下场景:
直接使用Rust实现的Hugging Face tokenizers虽然性能优异,但缺乏对其他语言的支持。通过C接口(FFI)进行桥接是最可靠的跨语言方案,因为:
我们不需要完整封装tokenizers的所有功能,只需暴露最必要的接口:
c复制// tokenizer_result.h
#pragma once
#include <stdint.h>
struct TokenizerResult {
int64_t* input_ids;
int64_t* attention_mask;
int64_t* token_type_ids;
uint64_t length;
};
// hf_tokenizer_ffi.h
#pragma once
#include "tokenizer_result.h"
#ifdef __cplusplus
extern "C" {
#endif
void* tokenizer_create(const char* tokenizer_json_path);
void tokenizer_destroy(void* handle);
TokenizerResult tokenizer_encode(void* handle, const char* text);
uint64_t tokenizer_count(void* handle, const char* text);
void tokenizer_result_free(TokenizerResult result);
#ifdef __cplusplus
}
#endif
这个设计遵循了经典的句柄(handle)模式:
tokenizer_create:创建并返回不透明指针tokenizer_encode:执行实际分词tokenizer_destroy:释放资源对应的Rust实现需要考虑内存安全和跨语言边界:
rust复制use std::ffi::CStr;
use std::os::raw::c_char;
#[repr(C)]
pub struct TokenizerResult {
pub input_ids: *mut i64,
pub attention_mask: *mut i64,
pub token_type_ids: *mut i64,
pub length: u64,
}
struct TokenizerHandle {
tokenizer: Tokenizer, // 带padding的分词器
raw_tokenizer: Tokenizer, // 不带padding的分词器
}
// 将Rust Vec转换为C可管理的指针
fn vec_to_c_ptr(vec: Vec<i64>) -> *mut i64 {
let mut boxed = vec.into_boxed_slice();
let ptr = boxed.as_mut_ptr();
std::mem::forget(boxed); // 防止Rust自动释放
ptr
}
#[no_mangle]
pub extern "C" fn tokenizer_create(tokenizer_json_path: *const c_char) -> *mut std::ffi::c_void {
// 错误处理省略...
let path_str = unsafe { CStr::from_ptr(tokenizer_json_path) }.to_str()?;
let mut tokenizer = Tokenizer::from_file(path_str)?;
// 设置padding和truncation
tokenizer.with_padding(Some(PaddingParams {
strategy: tokenizers::PaddingStrategy::Fixed(512),
..Default::default()
}));
let mut raw_tokenizer = tokenizer.clone();
raw_tokenizer.with_padding(None);
Box::into_raw(Box::new(TokenizerHandle { tokenizer, raw_tokenizer })) as *mut _
}
关键点:
#[repr(C)]确保结构体内存布局兼容Cstd::mem::forget防止Rust自动释放内存直接使用C接口容易导致资源泄漏,我们需要用C++类进行封装:
cpp复制// HfTokenizer.h
#pragma once
#include <string>
#include <memory>
#include "tokenizer_result.h"
namespace hf {
class Tokenizer {
public:
explicit Tokenizer(const std::string& path);
~Tokenizer() noexcept;
// 禁止拷贝
Tokenizer(const Tokenizer&) = delete;
Tokenizer& operator=(const Tokenizer&) = delete;
// 移动语义
Tokenizer(Tokenizer&& rhs) noexcept;
Tokenizer& operator=(Tokenizer&& rhs) noexcept;
uint64_t Count(const std::string& text) const;
TokenizerResult Encode(const std::string& text) const;
private:
void* handle;
};
} // namespace hf
实现中的关键点:
cpp复制// HfTokenizer.cpp
#include "HfTokenizer.h"
#include "hf_tokenizer_ffi.h"
namespace hf {
Tokenizer::Tokenizer(const std::string& path)
: handle(tokenizer_create(path.c_str())) {
if (!handle) throw std::runtime_error("Failed to create tokenizer");
}
Tokenizer::~Tokenizer() noexcept {
if (handle) tokenizer_destroy(handle);
}
Tokenizer::Tokenizer(Tokenizer&& rhs) noexcept : handle(rhs.handle) {
rhs.handle = nullptr;
}
Tokenizer& Tokenizer::operator=(Tokenizer&& rhs) noexcept {
if (this != &rhs) {
if (handle) tokenizer_destroy(handle);
handle = rhs.handle;
rhs.handle = nullptr;
}
return *this;
}
} // namespace hf
使用unique_ptr可以进一步简化代码:
cpp复制// HfTokenizer.h
#pragma once
#include <memory>
#include <string>
#include "tokenizer_result.h"
namespace hf {
class Tokenizer {
public:
explicit Tokenizer(const std::string& path);
// 自动生成移动操作,禁止拷贝
uint64_t Count(const std::string& text) const;
using ResultPtr = std::unique_ptr<TokenizerResult, void(*)(TokenizerResult*)>;
ResultPtr Encode(const std::string& text) const;
private:
struct HandleDeleter {
void operator()(void* h) const noexcept {
if (h) tokenizer_destroy(h);
}
};
std::unique_ptr<void, HandleDeleter> handle;
};
} // namespace hf
实现变得更简洁:
cpp复制// HfTokenizer.cpp
#include "HfTokenizer.h"
#include "hf_tokenizer_ffi.h"
namespace hf {
Tokenizer::Tokenizer(const std::string& path)
: handle(tokenizer_create(path.c_str())) {
if (!handle) throw std::runtime_error("Failed to create tokenizer");
}
Tokenizer::ResultPtr Tokenizer::Encode(const std::string& text) const {
auto result = std::unique_ptr<TokenizerResult, void(*)(TokenizerResult*)>(
new TokenizerResult(tokenizer_encode(handle.get(), text.c_str())),
[](TokenizerResult* p) {
if (p) {
tokenizer_result_free(*p);
delete p;
}
});
return result;
}
} // namespace hf
在资源管理类设计中,有两个重要原则:
规则五(Rule of Five):
如果一个类需要自定义析构函数,那么它通常也需要自定义拷贝控制成员(拷贝构造、拷贝赋值、移动构造、移动赋值)。
规则零(Rule of Zero):
理想情况下,类不应该自定义任何拷贝控制成员,而应该依赖编译器生成的版本。这可以通过使用智能指针等RAII类型来实现。
在我们的Tokenizer实现中:
我们的实现提供了三种异常安全保证:
特别是:
cpp复制#include "HfTokenizer.h"
#include <iostream>
int main() {
try {
hf::Tokenizer tokenizer("bert-base-uncased.json");
auto result = tokenizer.Encode("Hello world!");
std::cout << "Token count: " << result->length << std::endl;
for (size_t i = 0; i < result->length; ++i) {
std::cout << result->input_ids[i] << " ";
}
std::cout << std::endl;
} catch (const std::exception& e) {
std::cerr << "Error: " << e.what() << std::endl;
return 1;
}
return 0;
}
改进后的批量处理接口:
cpp复制// 批量编码接口
std::vector<ResultPtr> BatchEncode(const std::vector<std::string>& texts) const {
std::vector<ResultPtr> results;
results.reserve(texts.size());
for (const auto& text : texts) {
results.emplace_back(Encode(text));
}
return results;
}
问题现象:长时间运行后内存持续增长
排查步骤:
tokenizer_create都有对应的tokenizer_destroyTokenizerResult是否正确释放典型错误:
cpp复制// 错误:忘记释放result
auto result = tokenizer.Encode(text);
// 正确:使用智能指针自动管理
auto result = tokenizer.Encode(text); // 自动释放
问题现象:多线程使用时偶发崩溃
解决方案:
线程安全封装示例:
cpp复制class ThreadSafeTokenizer {
public:
explicit ThreadSafeTokenizer(const std::string& path)
: impl_(path) {}
auto Encode(const std::string& text) const {
std::lock_guard<std::mutex> lock(mutex_);
return impl_.Encode(text);
}
private:
mutable std::mutex mutex_;
Tokenizer impl_;
};
基于相同的C接口,我们可以轻松实现其他语言绑定:
Java JNI示例:
java复制public class HfTokenizer implements AutoCloseable {
private long nativeHandle;
public HfTokenizer(String path) {
this.nativeHandle = nativeCreate(path);
}
public native int[] encode(String text);
@Override
public void close() {
if (nativeHandle != 0) {
nativeDestroy(nativeHandle);
nativeHandle = 0;
}
}
private static native long nativeCreate(String path);
private static native void nativeDestroy(long handle);
}
我们对不同实现进行了性能对比(处理1000次"Hello world"):
| 实现方式 | 耗时(ms) | 内存峰值(MB) |
|---|---|---|
| 原始Python | 1200 | 45 |
| Rust直接调用 | 85 | 12 |
| C++封装 | 92 | 13 |
| Java JNI | 110 | 15 |
结果显示C++封装只增加了约8%的开销,这在大多数应用中是可接受的。
在实际项目中,这种封装方式已经成功应用于:
通过合理设计C接口和C++封装层,我们既保持了原生性能,又获得了现代C++的安全性和便利性。这种模式也可以推广到其他需要跨语言集成的场景。