从脚本语言每分钟插入 130M SQLite 行

2021-07-25 04:58:35

前一周,我偶然发现了在 SQLite 中插入十亿行一分钟的帖子,这是一个有趣的巧合,因为我正在对我自己的 SQLite/Objective-Sadapter 进行一些调整。 (帖子的标题后来加上了“Towards”,因为作者还没有接近达到那个目标)。这个 SQLite 适配器是我之前关于优化 JSON 性能的系列文章的衍生产品,它本身是由 Swift Coding 在这个相当简单和相关的任务中表现得非常糟糕而触发的。回顾一下:Swift 的 JSON 编码器的时钟速度约为 10MB/s。通过使用流媒体方法和一些调整,我们得到了大约 200MB/s。从那时起,我一直致力于使 Objective-S 对 UI 工作更加有用,对象字面量语法使得定义 UI 与各种“声明性”功能方法(例如 React 或 SwiftUI)一样方便。除非它仍然使用我们熟悉和喜爱的 AppKit 或 UIKit 对象,并且不会强迫我们接受 UI 是模型的纯函数的愚蠢观念。哦,你会得到真正有效的实时预览。但稍后会详细介绍。所以我正在慢慢地做一个 ToDoMVC,一个对我来说感觉很自然的基准。虽然我仍然非常倾向于转储 JSON 文件,而且之前的系列文章希望表明这种方法足够快,但我意识到很多人更喜欢“真正的”数据库,尤其是在后端,我想要也可以构建它。我对 Objective-S 的众多基准测试之一是,用它构建更好的 Rails 应该是可能的。 (此时我很确定我会达到该基准)。判断你是否有一个好的设计的方法之一是对其进行压力测试。一个非常有用的压力测试是看看它可以运行多快,因为这会告诉你你构建的东西是否精益,或者你是否放入了不必要的层和间接。这在脚本组件 (pdf) 系统中特别有趣,该系统将相对缓慢但灵活的交互式脚本语言与快速、优化的组件相结合。问题是您是否可以在获得快速组件的好处的同时真正结合脚本语言的灵活性,而不是必须深入适应和优化每个用例的组件,或者尽管组件快速,但性能却很慢。我的预感是,我已经使用了一段时间的流媒体方法对于 JSON 和 Objective-C 非常有效,在这个更具挑战性的环境中也能很好地工作。剧透警报:确实如此!基准测试是脚本的稍微修改版本,用作任务后端。就像上述示例脚本一样,它还创建了一个任务数据库并插入了一些示例行。它不是插入两行,而是插入 1000 万。或者一亿。 #!env stsh#- taskbench: dbref#class Task { var id.完成。 var 标题。 -描述 { ” ”。 } +sqlForCreate { '( [id] INTEGER PRIMARY KEY, [title] VARCHAR(220) NOT NULL, [done] INTEGER );'. }}.scheme todo : MPWAbstractStore { var db. var 任务表。 -initWithRef:ref { this:db := (MPWStreamQLite 分配 initWithPath:ref 路径)。 this:tasksTable := #MPWSQLTable{ #db: this:db , #tableClass: Task, #name: 'tasks' }.这:数据库打开。自己。 } - createTable { this:tasksTable 创建。 this:tasksTable := this:db 表在:'tasks'。 this:tasksTable createEncoderMethodForClass: 任务。 } -createTaskListToInsert: log10ofSize { baseList ← #( #Task{ #title: 'Clean Room', #done: false }, #Task{ #title: 'Check Twitter', #done: true } )。 ...复制...任务列表。 } - insertTasks { taskList := self createTaskListToInsert:6。 1 到:10 做:{ this:tasksTable insert:taskList. }. }}.todo := todo alloc initWithRef:dbref.todo createTable.todo insertTasks。 (我已经删除了将 2 个任务复制到我们需要插入的数百万个任务的列表中的方法的主体。它很庞大而且不相关。)在这个示例中,我们定义了 Task 类并使用它来创建 SQL 表.我们也可以简单地创建表并从中生成一个 Tasks 类。无论如何,运行此脚本会产生以下结果。 > 时间 ./taskbench-sqlite.st /tmp/tasks1.db ./taskbench-sqlite.st /tmp/tasks1.db 4.07s 用户 0.20s 系统 98% cpu 4.328 总计> ls -al /tmp/tasks1.db* -rw-r--r-- 1 marcel wheel 214M Jul 24 20:11 /tmp/tasks1.db> sqlite3 /tmp/tasks1.db 'select count(id) from tasks;' 10000000 所以我们在 4.328 秒内插入了 10M 行,产生了数百兆字节的 SQLite 数据。如果我们让它运行一分钟,这将是 138M 行。好的。相比之下,原始文章的数字对于 CPython 是 1100 万行/分钟,对于 PyPy 是 4000 万行/分钟,对于 Rust 是 1.81 亿行/分钟,尽管在速度较慢的英特尔 MacBookPro 上运行,而我在 M1 Air 上运行它。我在我的 M1 Air 上编译并运行了 Rust 版本,它在 21 秒内完成了 100M 行,所以比我的 Objective-S 脚本快两倍多,尽管使用了更简单的模式(CHAR(6) 而不是 VARCHAR(220)) ) 和更少的数据(1.5GB 与 100M 行的 2.1GB)。脚本的初始版本要慢得多,而且起初它是,呃,SQLite 的“次优”使用,这是主要的罪魁祸首,主要是在没有批处理的情况下单独插入每一行。当 SQLite 看到事务中未包含的 INSERT(或与此相关的 UPDATE)时,它会自动将该 INSERT 包装在生成的事务中,并在处理 INSERT 后提交该事务。由于 SQLite 非常注重确保事务以原子方式到达磁盘,因此速度很慢。非常慢。处理 SQLite 插入的类是一个多态写入流,因此它知道数组是什么。当它遇到一个数组时,它会向自己发送 beginArray 消息,写入数组的内容并通过向自己发送 endArray 消息来完成。因为编写一个数组意味着你想写所有它,这是一个插入事务的好地方:所以现在,如果你想将一堆对象作为单个事务编写,只需将它们写为一个数组,如基准代码可以。还有一些其他的小问题,但在那之后不到 10% 的总时间花在 SQLite 上,所以是时候优化调用者,我的代码了。在这一点上,我的猜测是剩下的最大的减速将是我的,呃,“雄伟的”Objective-Sinterpreter。我错了,这是可可字符串处理。我不仅是动态创建 SQLite 参数占位符键,因此为每一行的每一列分配新的 NSString 对象,而且现在从 NSString 对象获取字符数据涉及使用编码转换流的一些非常复杂和缓慢的内部机制。 -UTF8String 不是您的朋友,其他方法似乎相当一致地使用相同的慢速机制。我想让 NSString 慢得可怕是一种让其他字符串处理看起来更好的方法。经过几次转换后,代码将在字典中查找传入的 NSString 键,并将其映射到 SQLite 参数索引。避免了字符串处理和字符访问。关于基准代码中的类定义,您可能已经注意到的一件事是没有编码器方法,它只定义了它的实例变量和一些其他实用程序。那么SQLTable 的classdata 是如何编码的呢? KVC?不,这会有点慢,尽管它可能是一个很好的后备。神奇的是 createEncoderMethodForClass: 方法。顾名思义,该方法通过将多个块粘贴在一起来创建编码器方法,使用 imp_implementationWithBlock() 将顶级方法转换为方法,然后最后使用 class_addMethod() 将该方法添加到相关类中。 -(void)createEncoderMethodForClass:(Class)theClass{ NSArray *ivars=[theClass allIvarNames]; if ( [[ivars lastObject] hasPrefix:@"_"]) { ivars=(NSArray*)[[ivars collect] substringFromIndex:1]; } NSMutableArray *copiers=[[NSMutableArray arrayWithCapacity:ivars.count] 保留]; for (NSString *ivar in ivars) { MPWPropertyBinding *accessor=[[MPWPropertyBinding valueForName:ivar] 保留]; [ivar 保留]; [访问器 bindToClass:theClass]; id objBlock=^(id object, MPWFlattenStream* stream){ [stream writeObject:[accessor valueForTarget:object] forKey:ivar]; }; id intBlock=^(id object, MPWFlattenStream* stream){ [stream writeInteger:[accessor integerValueForTarget:object] forKey:ivar]; }; int typeCode = [访问器类型代码]; if ( typeCode == 'i' || typeCode == 'q' || typeCode == 'l' || typeCode == 'B' ) { [copyers addObject:Block_copy(intBlock)]; } else { [复印机 addObject:Block_copy(objBlock)]; } } void (^encoder)( id object, MPWFlattenStream *writer) = Block_copy( ^void(id object, MPWFlattenStream *writer) { for ( id block in copiers ) { void (^encodeIvar)(id object, MPWFlattenStream *writer) =block; encodeIvar(object, writer); } }); void (^encoderMethod)( id blockself, MPWFlattenStream *writer) = ^void(id blockself, MPWFlattenStream *writer) { [writer writeDictionaryLikeObject:blockself withContentBlock:encoder]; }; IMP encoderMethodImp = imp_implementationWithBlock(encoderMethod); class_addMethod(theClass, [self streamWriterMessage], encoderMethodImp, "v@:@" );} 巧妙的是我实际上并没有为这个特定用例编写那个方法:我已经为 JSON 编码创建了它。由于 JSON 编码器和 SQLite 编写器都是多态写入流(作为相应解码器/解析器的目标),因此相同的方法开箱即用。 (应该注意的是,这个编码器-生成器目前不能处理所有种类的数据类型;这是故意的)。编码器方法使用 MPWPropertyBinding 对象通过对象的访问器高效访问实例变量,缓存 IMP 并根据需要转换数据,因此它们既高效又灵活。然而,Objective-S 为其实例变量生成的实际访问器相当巴洛克,因为它们使用与 Objective-S 方法相同的基本机制,只能处理对象,不能处理原始数据类型。为了与Objective-C无缝互操作,期望方法可以采用对象以外的数据类型,所有非对象方法参数在传入时转换为对象,返回值在传出时从对象转换为原始值。因此,即使是原始类型的访问器,例如整数“id”或布尔值“done”,也会通过接口机制将它们的值转换为对象或从对象转换。正如我上面提到的,我有点惊讶,这种低效率被基于 NSString 的密钥处理所掩盖。事实上,追求 SQLite 插入基准的原因之一是有一个最终解决这个 Rube-Goldberg 机制的理由。最后,实际解决这个问题远没有我担心的那么复杂,该技术与上面用于编码器-生成器的技术非常相似,只是更简单。根据类型,我们使用不同的块,该块使用实例变量的偏移量进行参数化。我在下面展示了 setter-generator,因为由于保留计数处理,对象案例的代码实际上是不同的: #define pointerToVarInObject( type, anObject ,offset) ((type*)(((char*)anObject) + offset ))#ifndef __clang_analyzer__// 这个泄漏是因为我们正在安装到运行时,无法删除 after-(void)installInClass:(Class)aClass{ SEL aSelector=NSSelectorFromString([self objcMessageName]); const char *typeCode=NULL; int ivarOffset = (int)[ivarDef offset]; IMP getterImp=NULL; switch ( ivarDef.objcTypeCode ) { case 'd': case '@': typeCode = "v@:@"; void (^objectSetterBlock)(id object,id arg) = ^void(id object,id arg) { id *p=pointerToVarInObject(id,object,ivarOffset); if ( *p != arg ) { [*p 释放]; [参数保留]; *p=arg; } }; getterImp=imp_implementationWithBlock(objectSetterBlock);休息; case 'i': case 'l': case 'B': typeCode = "v@:l"; void (^intSetterBlock)(id object,long arg) = ^void(id object,long arg) { *pointerToVarInObject(long,object,ivarOffset)=arg; }; getterImp=imp_implementationWithBlock(intSetterBlock);休息;默认值:[NSException raise:@"invalidtype" format:@"Don't know how to generate set accessor for type '%c'",ivarDef.objcTypeCode];休息; } if ( getterImp && typeCode ) { class_addMethod(aClass, aSelector, getterImp, typeCode );在这一点上,配置文件开始接近大约三分之二的时间花在 sqlite_ 函数上,因此优化工作开始进入收益递减区域。明显开销的最后一个值得注意的点是参数索引映射的(字符串)键,上面的优化在从 NSString 到 NSNumber 的 NSDictionary 映射中留下了它。您可能知道,NSDictionary 并不是最快的。一个想法是用 MPWFastrStringTable 替换该查找,但这意味着要么需要解决快速访问 NSString 字符数据的问题,要么需要更改协议。因此,我决定强制执行它:我将指向 NSString 对象的实际指针存储在由 SQLite 参数索引索引的 C-Array 中。在我进行其他查找之前,为了安全起见,我使用传入的字符串指针对该表进行了线性扫描。这个小技巧很大程度上从我的配置文件中删除了参数索引查找。通过这些最后的调整,代码可能非常接近它所要获得的速度。与 Rust 代码相比,它的性能较慢可归因于它正在处理更多数据和更复杂的模式,以及必须从物化对象中实际获取数据,而 Rust 代码只是即时生成 SQlite 调用.所有这一切都是通过一种缓慢的解释性脚本语言实现的,所有可变部分(数据类、控制代码)都在所述缓慢的脚本语言中定义。因此,虽然我期待 Objective-S 的本机编译器,但很高兴知道它对于出色的性能并不是绝对必要的,而且这些 API 的基本设计是合理的。