上一章,我们安装了ECS套件,也进行了一些介绍,但是比较笼统。没有一些基础知识储备,很难开始编写代码。本章首先翻译和整理了部分Unity官方的DOTS知识,需要对面向数据有更深刻的认识。

DOD知识准备

要学习DOTS,你不能只是获取API文档并深入研究,当然API文档也很不健全和友好。
你必须了解:
面向数据设计的基本概念
Unity中的面向数据设计

因为篇幅问题,本章介绍面向数据设计的基本概念。

面向数据设计(DOD)与许多开发人员在其整个职业生涯中都在使用的面向对象编程(OOP) 相比是一个巨大的变化。这意味着 DOTS 的学习曲线可能很陡峭,并且有很多陷阱可能会阻止您获得您希望的性能优势。所以本学习笔记就是对这部分知识的总结和建议。

第一部分:了解面向数据的设计

1. 了解DOD

面向数据的设计(DOD) 是面向对象编程(OOP)的一种根本不同的方法,许多开发人员将其用作他们的主要(或唯一)编程范式。

面向对象编程(OOP)是将你的代码结构化成现实世界的事物类型的类。一个类的实例代表一个单一对象。通常数据部分都隐藏在私有变量中,有一些方法可以对数据进行操作。还有继承的对象来表现相似但是不同的对象,结果是这些单独的对象分散在整个内存中。OOP对人类来说是直观的理解,但是CPU执行效率并不高。

相比之下,DOD考虑的是数据,以及最好的在内存中构建数据,以便让CPU有效的访问。DOD封装的不是整个对象,而是将对象分解为组件,再将组件分组为数组,然后遍历数组进行数据计算和转换。DOD用例考虑的是组件,而不是对象。要成功的使用DOD,你要忘记封装、数据隐藏、继承、多态、引用类型,对你没帮助。

我们通过一个例子来看看OOP和DOD的区别,这是一个虚构游戏“沙滩球模拟器:特别版”的屏幕截图。玩家已经激活了一个能量提升来移动所有的绿球。


在OOP中,代码遍历检查每个类的颜色,并设置位置,虽然数组是连续的,但是内存数据量过大,导致CPU的Cache命中率过低。在DOD中,球体只有颜色和位置数据,同样容量存放的数据更多,这样就可以加大CPU的Cache命中率,从而加快处理速度。

2. Unity中的DOD

大约2018年Unity发布了Mega-City演示(更多其他DOTS资源),MegaCity大约包含了:
*4.5M Mesh renderers
200K Unique objects per building
100K Individual audio sources
5K Dynamic vehicles
60 FPS

要达到这种高性能,关键方面是:
1,缓存友好的内存布局(Cache friendly memory layout)
2,并行化(Parallelization)
3,编译器优化(Compiler optimization)

新的 Unity-Tech-Stack 包含几个新库。它们都是根据这些原则创建的。

  • Job-System让您可以在多个 CPU 上并行工作,这在 Unity 之前是不可能的。
  • Burst-Compiler使用LLVM 生成超快速矢量化代码。
  • Entity-Component-System帮助您以缓存有效的方式存储和访问您的数据
  • Collections-API让您可以直接访问非托管内存
  • Math library 添加了新的向量类型,如float3,您已经从着色器语言中了解了这些类型,并使 Burst-compiler 能够向量化您的数学运算

接下来,我们将解释这3个关键方面来达到高性能。

1,缓存友好的内存布局(Cache friendly memory layout)

缓存未命中(Cache Misses)

DOD的设计就是要组织数据进行有效管理,目标是尽可能的命中缓存,以便尽可能快的为CPU提供数据。
CPU的运行速度非常快,以至于RAM和CPU寄存器(Registers)之间带宽和延迟通常是瓶颈因素,而不是CPU本身。这就是为什么CPU和RAM之间会建立多个缓存的原因。

该图显示了一个金字塔,距离CPU寄存器越近内存就越小,访问速度也就越快。当CPU需要一个值,它首先从L1开始在缓存(Cache)中查找,如果它不在缓存中,就从内存中加载,这非常慢,下表显示了 Intel Core i7-8700K 的缓存大小。

缓存 尺寸
L1 Cache(数据 Data) 192 KB
L1 Cache(说明 Instructions) 192 KB
L2 Cache 1.5 MB
L3 Cache 12 MB

下表包含英特尔酷睿 i7-4770 的(近似)访问时间

操作 CPU 周期
执行典型指令 1
L1 4
L2 12
L3 36
从主存中获取 36 + ~100 纳秒

正如您所看到的,数据离 CPU 越远,将数据加载到寄存器中所需的时间就越长。为了避免那些较长的加载时间,要尽可能避免缓存未命中。因此,您需要了解如何访问缓存。

缓存行(Cache Lines)

今天的 CPU 不会逐字节访问内存。相反,它们以(通常)64 字节的块(称为高速缓存行)获取内存。例如,如果您遍历一个整数数组,则会同时加载 8 个整数值(64 字节缓存行大小/每个整数 4 字节 = 8)。这可以防止每次读取值时缓存未命中。此外,高速缓存也足够智能,可以根据指令预取所需的前一个或下一个高速缓存行。因此,您的访问模式越可预测,性能就会越好。

结构(Struct)与类(Class)的数据布局


结构体数组或者原始类型数组(例如int[]),因为结构大小在编译时是已知的,所以可以连续打包到内存,这不适用于类的数组,由于类的多态性,每个元素可以有不同大小,所以无法连续打包。只有指针指向随机位置,具体取决于什么时候new的,而不是创建数组的时间。

我们编写一个例子测试:

using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using UnityEngine;public struct StructA
{public float f;
}
public class ClassA
{public float f;
}
//UTF8 说明
public class CTest : MonoBehaviour
{int allnum = 10000000;int[] nolist;int[] nolistRandom;// Start is called before the first frame updatevoid Start(){nolist = new int[allnum];for (int i = 0; i < allnum; i++){nolist[i] = i;}nolistRandom = new int[allnum];for (int i = 0; i < allnum; i++){nolistRandom[i] = i;}}// Update is called once per frameprivate void OnEnable(){test();}void test(){//首先模拟内存分布数据StructA[] alist = new StructA[allnum];for (int i = 0; i < allnum; i++){alist[i].f = i;}ClassA[] blist = new ClassA[allnum];for (int i = 0; i < allnum; i++){blist[i] = new ClassA();blist[i].f = i;}ClassA[] clist = new ClassA[allnum];for (int i = 0; i < allnum; i++){clist[i] = new ClassA();clist[i].f = i;}clist = clist.OrderBy(x => Random.value).ToArray();var stopwatch = new Stopwatch();System.GC.Collect();stopwatch.Reset();stopwatch.Start();for (int i = 0; i < allnum; i++){alist[i].f += Random.value;}stopwatch.Stop();UnityEngine.Debug.Log("struct : " + (stopwatch.ElapsedTicks / 1000).ToString("F2"));System.GC.Collect();stopwatch.Reset();stopwatch.Start();for (int i = 0; i < allnum; i++){blist[i].f += Random.value;}stopwatch.Stop();UnityEngine.Debug.Log("Class A : " + (stopwatch.ElapsedTicks / 1000).ToString("F2"));System.GC.Collect();stopwatch.Reset();stopwatch.Start();for (int i = 0; i < allnum; i++){clist[i].f += Random.value;}stopwatch.Stop();UnityEngine.Debug.Log("Class B : " + (stopwatch.ElapsedTicks / 1000).ToString("F2"));}
}


测试中每个类型对象数组创建了1000万个对象,数组每组大小40M。我们先创建好,然后用相同的赋值操作进行赋值进行比对。

struct是按顺序的,ClassA也是顺序的,ClassB是打乱的。
结果中看到struct是最快的,classA稍微多一点,classB就多了5倍左右。这个测试中,我们迭代了每个元素按照顺序的完整数据。
请记住:如果您测试缓存未命中,许多外部环境(如操作系统、线程和其他进程)会伪造您的测试结果,因为它们也使用缓存。

选择性数据访问

在许多用例中,您不需要访问整个数据,而只需要访问其中的一部分。常见的用例是:

  • 你有很大的游戏世界,你只想处理当前可见的实体
  • 您只想在整个网格的一部分上进行操作
  • 您只想处理用户选择的实体
  • 您将数据切成块,只想访问一个

正如我们在上面的测试中看到的,按顺序排列数据以避免随机访问非常重要。在某些用例中,这是不可能的。下一个示例将测试不同的访问模式如何影响缓存未命中。

int[] steps = new[] {1, 2, 4, 8, 16, 32, 64, 128, 256}
for (int k = 0; k < steps.Length; k++)
{int stepSize = steps[k];int[] arr = new int[32* 1024 * 1024];for (int i = 0; i < arr.Length; i += stepSize ){arr[i] *= 3;}
}
// This code is a little bit simplified. With bigger step sizes,
// less samples on the array are done, but you can find the
// full source of all experiments in the Appendix.


正如您所看到的,步长越大,即使完成相同数量的计算,运行时间就越长。进一步增加步长意味着完全随机访问您的内存。有趣的是步长 16 和 32 之间的图形跳转。这里超出了缓存行大小(16 * 4 字节 = 64 字节)。步长为 32 时,每次数据访问相当于缓存未命中,运行时间几乎翻了一番,从 1503 到 2739。

这也是如果你创建一个二维矩阵,它的行主要遍历将比它的列主要遍历更快的原因。一行存储在连续的内存位置中,因此在高速缓存行中被提取。

面向对象与面向数据的数据布局

这个示例中,展示了与面向数据相比,数据如何以面向对象的方式存储。

// The struct defines a sphere in a object oriented way
public struct ObjectOrientedSphere {Vector3 position;Color color;double radius;
};
ObjectOrientedSphere[] objectOrientedSpheres;// The class defines several spheres in a data oriented way. The data is tightly packed into arrays
public class DataOrientedSphere {Vector3[] position;Color[] color;double[] radius;
};// Assume you have a list of ObjectOrientedSpheres that you want to move
// every frame by 1
public void MoveObjectOriented(ObjectOrientedSphere[] spheres)
{for (int i=0; i<spheres.Length; i++){spheres[i].position += 1;}
}
// This code does the same for the DataOrientedSphere
public void MoveDataOriented(DataOrientedSphere spheres)
{  Point[] positions = spheres.position;for (int i=0; i<positions.Length; i++){positions[i] += 1;}
}

该示例以两种不同的方式定义球体。ObjectOrientedSphere 的定义与您对日常程序员生活的期望一样。结构或类包含对象工作所需的所有数据。DataOrientedSphere 以可以更有效地访问数据的方式定义数据,只需创建一个对象并存储每个值的数据,而不是为每个对象存储数据。这里重要的一点是,如果知道需要访问不带颜色和半径的位置数据,则应该将它们彼此分开。
上面的代码中,一个球体大小是24字节,下面的测试就是更改球体大小(通过增加其他属性),那么按顺序进行Move移动球,会发生什么呢?
例如64字节大小的球是:

public struct ObjectOrientedSphere64{public float Position;public Vector3 Blocker1;public Vector4 Blocker2;public Vector4 Blocker3;public Vector4 Blocker4;}

该图表显示,当您更改面向数据的球体的大小时(遍历的数组长度没有变化),性能保持不变。原因很明显,因为其他数据(颜色和半径)存储在完全独立的内存区域的其他数组中。相比之下,面向对象的领域变得越来越慢。在遍历数组时,会产生越来越多的缓存未命中。有趣的是,当您超过 64 字节的高速缓存行大小时,再次看到强烈的性能损失。

对象大小 面向对象 (µs) 面向数据 (µs) 比率
64 109 78 1,4
128 197 78 2,53

访问大小为 128 字节的对象几乎是访问大小为 64 字节的对象的两倍。面向对象的运行时间没有比现在差的原因是 CPU 非常擅长预测您接下来可能需要哪些数据。

如果对象变得越来越大,以面向对象的方式存储数据可以将性能降低多达 6 倍。
在前面的示例中,使用了 32 MB 的数组大小。让我们看看不同的数组大小是否会影响结果。

在垂直轴上标记了数组大小。该测试针对面向数据的球体(标记为 DO 4)和不同大小的面向对象的球体(标记为 4 – 512)运行。两个轴都是对数的。如您所见,所有数组大小和类大小的性能都保持线性。

缓存失效

当数据进入两个不同的缓存位置时会发生什么。例如,变量 float a 可能在CPU核心 1 和核心 2 的 L1 缓存中。当您更新该变量时会发生什么?

这种情况被称为数据竞争。当多个线程同时访问内存中的一个位置并且至少有一个线程打算改变该值时,就会发生这种情况。对我们来说幸运的是,CPU 可以解决这个问题。每当写入指向缓存中的内存位置时,内核对该内存位置的所有缓存引用都将失效。其他内核必须再次从主存储器加载该数据。但是,由于数据总是加载到缓存行中,因此整个缓存行无效,而不仅仅是更改的值。

这给多线程系统带来了新的问题。当两个或多个线程尝试同时修改属于同一缓存行的字节时,大部分时间都浪费在使缓存无效并再次从主内存中读取更新的字节上。这种效应称为虚假共享。

与关系数据库的关联

面向数据设计背后的思想与您对关系数据库的看法非常相似。优化关系数据库还可以更有效地使用缓存,尽管在这种情况下我们处理的不是 CPU 缓存而是内存页面。一个好的数据库设计人员也可能会将不经常访问的数据拆分到一个单独的表中,而不是创建一个包含大量列的表,因为只有少数列被使用过。

结论

对主存储器的随机访问比顺序访问慢大约 6 倍。

今天到这里了,下一章分享Unity中的面向数据设计。

引用:
面向数据的设计

Unity`s “Performance by Default” under the hood

DOTS Best Practices

Unity DOTS 学习笔记2 - 面向数据设计的基本概念(上)相关推荐

  1. Unity DOTS 学习笔记1 - ECS 0.50介绍和安装

    Unity DOTS 学习笔记1 - ECS 0.50介绍和安装 为什么学习这个技术 ECS的全称为Entity Component System,是最早由暴雪在GDC2017上提出的一个新的游戏设计 ...

  2. MySQL学习笔记_5_SQL语言的设计与编写(上)

    SQL语言的设计与编写(上) 一.SQL语句分类 数据定义语言(DDL): 用于定义和管理数据对象,包括数据库.数据表.视图.索引等.例如:CREATE.DROP.ALTER等语句. 数据操作语言(D ...

  3. 学习笔记(01):大数据视频_Hive视频教程(上)-Hive安装_其他操作命令

    立即学习:https://edu.csdn.net/course/play/20038/255179?utm_source=blogtoedu  

  4. 学习笔记(01):大数据视频_Hadoop视频教程(上)-大数据课程

    立即学习:https://edu.csdn.net/course/play/19912/254968?utm_source=blogtoedu 1

  5. 【学习笔记】大数据技术之Scala(下)

    [学习笔记]大数据技术之Scala(上) 大数据技术之Scala 第 6 章 面向对象 6.1 Scala 包 6.1.1 包的命名 6.1.2 包说明(包语句) 6.1.3 包对象 6.1.4 导包 ...

  6. 【DOTS学习笔记】DOTS简介

    目录 前言 DOTS是什么? 核心Package 游戏功能相关Package 谁需要关注DOTS? DOTS可以应用到哪些地方? 为什么需要DOTS 前言 本文是Metaverse大衍神君的<D ...

  7. python aop编程_学习笔记: AOP面向切面编程和C#多种实现

    AOP:面向切面编程   编程思想 OOP:一切皆对象,对象交互组成功能,功能叠加组成模块,模块叠加组成系统 类--砖头     系统--房子 类--细胞     系统--人 面向对象是非常适合做大型 ...

  8. Unity DOTS学习 前置知识(一)

    DOTS是什么 Data-Oriented Technology Stack(面向数据的技术栈) Unity 使用的5个核心包: The C# job system 提供快速安全的多线程操作 The ...

  9. C语言程序设计学习笔记:P1-程序设计与C语言

    本系列博客用于记录学习浙江大学翁恺老师的C语言程序设计,系列笔记链接如下: C语言程序设计学习笔记:P1-程序设计与C语言 C语言程序设计学习笔记:P2-计算 C语言程序设计学习笔记:P3-判断 C语 ...

最新文章

  1. ASIC设计-终极指南
  2. RNA-seq分析流程
  3. 《深入理解计算机系统》读书笔记四:操作系统的抽象
  4. 在长文本中当中使用正则表达式匹配限定长度范围的数字串的方法
  5. 修改 PhpStorm 的字体和样式
  6. 我给这个Python库打101分!
  7. 使用 GraalVM 将基本的 Java 项目打包成 EXE
  8. 求两个整数数组乘积最小值
  9. Quartz.net 2.0的使用说明
  10. SQL Server 索引和表体系结构(三)
  11. 31 天重构学习笔记14. 分离职责
  12. nextcloud icon_聊一聊爱车吉利ICON带给我的用车感受
  13. centos+darwin搭建简单的视频流服务器
  14. STM32F107VCTx I2C通信
  15. 读 Irving M. Copi 之《逻辑学导论》
  16. springSecurity+redis反序列化失败--problem deserializing ‘setterless‘ property (“authorities“)
  17. 全国计算机考试等级考务管理系统,全国计算机等级考试考务管理系统:https://ncre-bm.neea.edu.cn/...
  18. VC++6.0 MSDN下载地址
  19. 标准方程法(正规方程法)
  20. 洛谷P3354 Riv河流 [IOI2005] 树型dp

热门文章

  1. 好的目标管理,让职场人更轻松
  2. 在Mac OS X苹果lion系统上制作USB启动盘
  3. 走出abstract class与interface的困惑
  4. 1D卷积入门:一维卷积是如何处理数字信号的
  5. PKI介绍及搭建Linux私有CA (SSL 示例)
  6. 项目开发流程(简述)
  7. 使用低代码平台 - 危险的赌注
  8. 未能找到引用的组件“Microsoft.Office.Core”
  9. 系统注册表方式修改背景颜色
  10. Python爬虫:scrapy爬取斗鱼直播图片