diff --git a/.gitignore b/.gitignore index 00603ee..7b7ba1d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ /Models/ /build/ .DS_Store + +# MNN 预编译二进制:由 scripts/build-mnn-xcframework.sh 本地生成,不入库防历史膨胀 +/Frameworks/MNN.xcframework/ diff --git a/scripts/build-mnn-xcframework.sh b/scripts/build-mnn-xcframework.sh new file mode 100644 index 0000000..2fb7884 --- /dev/null +++ b/scripts/build-mnn-xcframework.sh @@ -0,0 +1,50 @@ +#!/bin/sh +# 构建 MNN.xcframework(device arm64 + simulator arm64),含 LLM 引擎 + SME2。 +# 产物输出到 康康/../Frameworks/MNN.xcframework(被 .gitignore,不入库,防历史膨胀)。 +# +# 用法: +# MNN_SRC=/path/to/MNN sh scripts/build-mnn-xcframework.sh +# 需求:CMake 3.14+、Xcode、约 10-40 分钟。 +# +# 关键 flag: +# MNN_BUILD_LLM=ON —— 编入 llm 引擎(并导出 llm/llm.hpp),自动开 MNN_LOW_MEMORY +# MNN_SME2=ON —— CMake 默认 ON,A19/iPhone17 运行时经 KleidiAI 自动启用,A17 回退 NEON +# MNN_METAL=OFF —— 考核走 CPU+SME2,关 Metal 保持精简 +# MNN_BUILD_LLM_OMNI —— 如需 VL(图→文)再开,会额外拉 OpenCV/Audio(本脚本默认不开,文本优先) +set -e + +MNN_SRC="${MNN_SRC:-/Users/xuhuayong/apps/MNN-src}" +OUT_DIR="$(cd "$(dirname "$0")/.." && pwd)/Frameworks" +TOOLCHAIN_NEW="${MNN_SRC}/cmake/ios.toolchain.new.cmake" +EXTRA="-DMNN_BUILD_LLM=ON -DMNN_METAL=OFF -DMNN_ARM82=true -DMNN_SME2=ON" +COMMON="-DCMAKE_BUILD_TYPE=Release -DENABLE_BITCODE=0 -DMNN_AAPL_FMWK=1 -DMNN_SEP_BUILD=0 -DMNN_BUILD_SHARED_LIBS=false -DMNN_USE_THREAD_POOL=OFF" + +export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer" +cd "$MNN_SRC" + +# 新版 ios-cmake toolchain(支持 SIMULATORARM64;MNN 自带的旧版只支持 x86_64 模拟器) +if [ ! -f "$TOOLCHAIN_NEW" ]; then + curl -sL "https://raw.githubusercontent.com/leetal/ios-cmake/master/ios.toolchain.cmake" -o "$TOOLCHAIN_NEW" +fi + +# device arm64 +rm -rf build-dev-arm64 && mkdir build-dev-arm64 && cd build-dev-arm64 +cmake .. $COMMON $EXTRA -DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN_NEW" -DPLATFORM=OS64 -DDEPLOYMENT_TARGET=17.0 +make MNN -j16 +cd .. + +# simulator arm64 +rm -rf build-sim-arm64 && mkdir build-sim-arm64 && cd build-sim-arm64 +cmake .. $COMMON $EXTRA -DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN_NEW" -DPLATFORM=SIMULATORARM64 -DDEPLOYMENT_TARGET=17.0 +make MNN -j16 +cd .. + +# 合成 xcframework +rm -rf "$OUT_DIR/MNN.xcframework" +mkdir -p "$OUT_DIR" +xcrun xcodebuild -create-xcframework \ + -framework build-dev-arm64/MNN.framework \ + -framework build-sim-arm64/MNN.framework \ + -output "$OUT_DIR/MNN.xcframework" + +echo "✅ 输出: $OUT_DIR/MNN.xcframework" diff --git a/康康.xcodeproj/project.pbxproj b/康康.xcodeproj/project.pbxproj index 1962343..ec8b831 100644 --- a/康康.xcodeproj/project.pbxproj +++ b/康康.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ FEED000000000000DEAD0001 /* MLXLLM in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0003 /* MLXLLM */; }; FEED000000000000DEAD0002 /* MLXLMCommon in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0004 /* MLXLMCommon */; }; FEED000000000000DEAD0005 /* MLXVLM in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0006 /* MLXVLM */; }; + FEEDFACE000000000000F002 /* MNN.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = FEEDFACE000000000000F001 /* MNN.xcframework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -33,6 +34,7 @@ 5E463CF92FC403BB0089145B /* 康康.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "康康.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 5E463D082FC403BC0089145B /* 康康Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "康康Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 5E463D122FC403BC0089145B /* 康康UITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "康康UITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; + FEEDFACE000000000000F001 /* MNN.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = MNN.xcframework; path = Frameworks/MNN.xcframework; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -61,6 +63,7 @@ FEED000000000000DEAD0001 /* MLXLLM in Frameworks */, FEED000000000000DEAD0002 /* MLXLMCommon in Frameworks */, FEED000000000000DEAD0005 /* MLXVLM in Frameworks */, + FEEDFACE000000000000F002 /* MNN.xcframework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -88,6 +91,7 @@ 5E463D0B2FC403BC0089145B /* 康康Tests */, 5E463D152FC403BC0089145B /* 康康UITests */, 5E463CFA2FC403BB0089145B /* Products */, + FEEDFACE000000000000F001 /* MNN.xcframework */, ); sourceTree = ""; }; @@ -443,6 +447,8 @@ PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; + FRAMEWORK_SEARCH_PATHS = "$(PROJECT_DIR)/Frameworks"; + SWIFT_OBJC_BRIDGING_HEADER = "康康/康康-Bridging-Header.h"; SDKROOT = auto; STRING_CATALOG_GENERATE_SYMBOLS = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -497,6 +503,8 @@ PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; + FRAMEWORK_SEARCH_PATHS = "$(PROJECT_DIR)/Frameworks"; + SWIFT_OBJC_BRIDGING_HEADER = "康康/康康-Bridging-Header.h"; SDKROOT = auto; STRING_CATALOG_GENERATE_SYMBOLS = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; diff --git a/康康/AI/MNN/MNNLLMBridge.h b/康康/AI/MNN/MNNLLMBridge.h new file mode 100644 index 0000000..afe64c5 --- /dev/null +++ b/康康/AI/MNN/MNNLLMBridge.h @@ -0,0 +1,55 @@ +// +// MNNLLMBridge.h +// 康康 +// +// Objective-C 接口,封装 MNN-LLM(Qwen)的加载与流式推理。 +// 真实实现在 .mm 中以 ObjC++ 调用 ;模拟器下编为可用性返回 NO 的桩 +// (MNN.framework 仅 device arm64 切片有真实 CPU/SME2 内核,模拟器走 MLX 兜底)。 +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +/// 末次生成的性能统计(取自 MNN LlmContext)。 +@interface MNNGenerateStats : NSObject +@property (nonatomic, readonly) int promptTokens; +@property (nonatomic, readonly) int genTokens; +@property (nonatomic, readonly) double prefillMs; +@property (nonatomic, readonly) double decodeMs; +/// 解码速率 tok/s = genTokens / (decodeMs/1000)。demo 卖点 #6 / Live Activity 用。 +@property (nonatomic, readonly) double decodeTokensPerSecond; +@end + +@interface MNNLLMBridge : NSObject + +/// 本构建是否含真实 MNN 运行时(device=YES,simulator 桩=NO)。 ++ (BOOL)isAvailable; +/// CPU 是否支持 SME2(运行时探测);A19/iPhone17 YES,A17/iPhone15Pro NO。仅用于 UI 展示加速状态。 ++ (BOOL)cpuSupportsSME2; + +/// 用 MNN llm 的 config.json 路径加载模型(目录含 llm.mnn / 权重 / tokenizer)。失败返回 nil。 +- (nullable instancetype)initWithConfigPath:(NSString *)configPath; + +@property (nonatomic, readonly) BOOL isLoaded; + +/// 纯文本流式生成。onToken 每解码出一段文本回调一次(在调用线程,同步阻塞直到生成结束)。 +/// 返回末次统计。 +- (MNNGenerateStats *)generateText:(NSString *)prompt + maxTokens:(int)maxTokens + onToken:(void (^)(NSString *piece))onToken; + +/// 图→文(VL,需 MNN_BUILD_LLM_OMNI 构建)。imagePaths 为本地文件路径。 +/// 当前文本构建未含 OMNI 时返回 nil 并置 error。 +- (nullable MNNGenerateStats *)analyzeImages:(NSArray *)imagePaths + prompt:(NSString *)prompt + maxTokens:(int)maxTokens + onToken:(void (^)(NSString *piece))onToken + error:(NSError *_Nullable *_Nullable)error; + +/// 请求取消当前生成(best-effort:置标志,后续 token 不再回调)。 +- (void)cancel; + +@end + +NS_ASSUME_NONNULL_END diff --git a/康康/AI/MNN/MNNLLMBridge.mm b/康康/AI/MNN/MNNLLMBridge.mm new file mode 100644 index 0000000..8e134e2 --- /dev/null +++ b/康康/AI/MNN/MNNLLMBridge.mm @@ -0,0 +1,178 @@ +// +// MNNLLMBridge.mm +// 康康 +// +// ObjC++ 实现。device 真机用 ;模拟器编为桩(返回不可用,上层回退 MLX)。 +// + +#import "MNNLLMBridge.h" +#include + +// MARK: - 性能统计(私有 readwrite 重声明) +@interface MNNGenerateStats () +@property (nonatomic, readwrite) int promptTokens; +@property (nonatomic, readwrite) int genTokens; +@property (nonatomic, readwrite) double prefillMs; +@property (nonatomic, readwrite) double decodeMs; +@end + +@implementation MNNGenerateStats +- (double)decodeTokensPerSecond { + return self.decodeMs > 0 ? (self.genTokens / (self.decodeMs / 1000.0)) : 0; +} +@end + +// MARK: - SME2 / 可用性探测(device + simulator 都可编) + +static BOOL kk_sysctlFlag(const char *name) { + int64_t v = 0; size_t sz = sizeof(v); + if (sysctlbyname(name, &v, &sz, NULL, 0) != 0) return NO; + return v != 0; +} + +#if TARGET_OS_SIMULATOR + +// ============ 模拟器桩:无真实 MNN ============ +@implementation MNNLLMBridge ++ (BOOL)isAvailable { return NO; } ++ (BOOL)cpuSupportsSME2 { return NO; } +- (nullable instancetype)initWithConfigPath:(NSString *)configPath { return nil; } +- (BOOL)isLoaded { return NO; } +- (MNNGenerateStats *)generateText:(NSString *)prompt maxTokens:(int)maxTokens + onToken:(void (^)(NSString *))onToken { return [MNNGenerateStats new]; } +- (nullable MNNGenerateStats *)analyzeImages:(NSArray *)imagePaths prompt:(NSString *)prompt + maxTokens:(int)maxTokens onToken:(void (^)(NSString *))onToken + error:(NSError **)error { + if (error) *error = [NSError errorWithDomain:@"MNN" code:-1 + userInfo:@{NSLocalizedDescriptionKey: @"MNN 在模拟器不可用"}]; + return nil; +} +- (void)cancel {} +@end + +#else + +// ============ 真机:真实 MNN-LLM ============ +#include +#include +#include +#include +#include + +using MNN::Transformer::Llm; + +namespace { +/// 把 MNN 写入 ostream 的解码文本转成 NSString 回调;按 UTF-8 完整边界聚合,避免截断多字节。 +class TokenStreamBuf : public std::streambuf { +public: + TokenStreamBuf(void (^onToken)(NSString *), std::atomic *cancel) + : _onToken(onToken), _cancel(cancel) {} + void flush() { + if (_pending.empty()) return; + emitPending(); // 末尾尽力 emit(即便非完整 UTF-8 也交出去) + _pending.clear(); + } +protected: + std::streamsize xsputn(const char *s, std::streamsize n) override { + append(s, (size_t)n); + return n; + } + int overflow(int c) override { + if (c != EOF) { char ch = (char)c; append(&ch, 1); } + return c; + } +private: + void append(const char *s, size_t n) { + if (_cancel && _cancel->load()) return; // 已取消,吞掉不回调 + _pending.append(s, n); + // 仅当整个 pending 是合法 UTF-8 才 emit(token 通常是完整字/词,边界自然对齐) + NSString *str = [[NSString alloc] initWithBytes:_pending.data() + length:_pending.size() + encoding:NSUTF8StringEncoding]; + if (str) { if (_onToken) _onToken(str); _pending.clear(); } + } + void emitPending() { + NSString *str = [[NSString alloc] initWithBytes:_pending.data() + length:_pending.size() + encoding:NSUTF8StringEncoding]; + if (str && _onToken) _onToken(str); + } + void (^_onToken)(NSString *); + std::atomic *_cancel; + std::string _pending; +}; +} // namespace + +@implementation MNNLLMBridge { + Llm *_llm; + std::atomic _cancel; + BOOL _loaded; +} + ++ (BOOL)isAvailable { return YES; } + ++ (BOOL)cpuSupportsSME2 { + // Apple 通过 sysctl 暴露 ARM 特性位:FEAT_SME2(A19/iPhone17+)。 + return kk_sysctlFlag("hw.optional.arm.FEAT_SME2"); +} + +- (nullable instancetype)initWithConfigPath:(NSString *)configPath { + self = [super init]; + if (!self) return nil; + _cancel = false; + _llm = Llm::createLLM(std::string(configPath.UTF8String)); + if (_llm == nullptr) return nil; + _loaded = _llm->load(); + if (!_loaded) { Llm::destroy(_llm); _llm = nullptr; return nil; } + return self; +} + +- (void)dealloc { + if (_llm) { Llm::destroy(_llm); _llm = nullptr; } +} + +- (BOOL)isLoaded { return _loaded; } + +- (void)cancel { _cancel = true; } + +- (MNNGenerateStats *)generateText:(NSString *)prompt + maxTokens:(int)maxTokens + onToken:(void (^)(NSString *))onToken { + _cancel = false; + TokenStreamBuf buf(onToken, &_cancel); + std::ostream os(&buf); + if (_llm) { + _llm->response(std::string(prompt.UTF8String), &os, nullptr, maxTokens); + } + buf.flush(); + return [self statsFromContext]; +} + +- (nullable MNNGenerateStats *)analyzeImages:(NSArray *)imagePaths + prompt:(NSString *)prompt + maxTokens:(int)maxTokens + onToken:(void (^)(NSString *))onToken + error:(NSError **)error { + // VL 需 MNN_BUILD_LLM_OMNI 构建(OpenCV 解码图像)。当前文本构建不含,显式报错。 + if (error) *error = [NSError errorWithDomain:@"MNN" code:-2 + userInfo:@{NSLocalizedDescriptionKey: @"当前 MNN 构建未含 VL(OMNI),请用 OMNI 框架"}]; + return nil; +} + +- (MNNGenerateStats *)statsFromContext { + MNNGenerateStats *s = [MNNGenerateStats new]; + if (_llm) { + const MNN::Transformer::LlmContext *ctx = _llm->getContext(); + if (ctx) { + s.promptTokens = ctx->prompt_len; + s.genTokens = ctx->gen_seq_len; + s.prefillMs = ctx->prefill_us / 1000.0; + s.decodeMs = ctx->decode_us / 1000.0; + } + } + return s; +} + +@end + +#endif diff --git a/康康/康康-Bridging-Header.h b/康康/康康-Bridging-Header.h new file mode 100644 index 0000000..2d869fa --- /dev/null +++ b/康康/康康-Bridging-Header.h @@ -0,0 +1,6 @@ +// +// 康康-Bridging-Header.h +// 把 Objective-C 接口暴露给 Swift。 +// + +#import "AI/MNN/MNNLLMBridge.h"