diff --git a/YaeBlog.Core/Services/EssayScanService.cs b/YaeBlog.Core/Services/EssayScanService.cs index 29c5cac..db14f83 100644 --- a/YaeBlog.Core/Services/EssayScanService.cs +++ b/YaeBlog.Core/Services/EssayScanService.cs @@ -63,8 +63,9 @@ public partial class EssayScanService( private async Task> ScanContentsInternal(DirectoryInfo directory) { + // 扫描以md结果的但是不是隐藏文件的文件 IEnumerable markdownFiles = from file in directory.EnumerateFiles() - where file.Extension == ".md" + where file.Extension == ".md" && !file.Name.StartsWith('.') select file; ConcurrentBag<(string, string)> fileContents = []; diff --git a/YaeBlog/Controllers/FilesController.cs b/YaeBlog/Controllers/FilesController.cs index ace5d47..646d11c 100644 --- a/YaeBlog/Controllers/FilesController.cs +++ b/YaeBlog/Controllers/FilesController.cs @@ -9,12 +9,20 @@ public class FilesController : ControllerBase [HttpGet("{*filename}")] public IActionResult Images(string filename) { + // 这里疑似有点太愚蠢了 string contentType = "image/png"; + if (filename.EndsWith("jpg") || filename.EndsWith("jpeg")) { contentType = "image/jpeg"; } + if (filename.EndsWith("svg")) + { + contentType = "image/svg+xml"; + } + + FileInfo imageFile = new(filename); if (!imageFile.Exists) diff --git a/YaeBlog/appsettings.json b/YaeBlog/appsettings.json index 454c218..4efd31b 100644 --- a/YaeBlog/appsettings.json +++ b/YaeBlog/appsettings.json @@ -24,10 +24,16 @@ "AvatarImage": "https://zzachary.top/img/ztqy_hub928259802d192ff5718c06370f0f2c4_48203_300x0_resize_q75_box.jpg" }, { - "Name": "Chenxu", + "Name": "不会写程序的晨旭", "Description": "一个普通大学生", "Link": "https://chenxutalk.top", "AvatarImage": "https://www.chenxutalk.top/img/photo.png" + }, + { + "Name": "万木长风", + "Description": "世界渲染中...", + "Link": "https://ryohai.fun", + "AvatarImage": "https://ryohai.fun/icon.jpg" } ] } diff --git a/YaeBlog/source/posts/aspnet-authorization.md b/YaeBlog/source/posts/aspnet-authorization.md new file mode 100644 index 0000000..3c39df7 --- /dev/null +++ b/YaeBlog/source/posts/aspnet-authorization.md @@ -0,0 +1,407 @@ +--- +title: 在ASP.NET Core中集成认证和授权流程 +date: 2024-09-08T22:27:17.0328669+08:00 +tags: +- ASP.NET Core +- 技术笔记 +--- + +以[Martina](https://github.com/post-guard/Martina)为例,记录如何典型的ASP.NET Core应用中集成认证和授权的流程。 + + + +## 业务需求概述 + +[Martina](https://github.com/post-guard/Martina)系统是一个酒店的空调和入住管理系统,项目中对于认证和授权的要求是一个典型的多权限、多用户模式,具体来说: + +- 系统中所有的接口均需要在登录之后才能调用; +- 系统中安装不同管理领域将用户的权限划分为一大类、三小类:一个超级管理员权限和客房、空调、账单三个领域管理员权限; +- 普通用户的权限有时间和使用房间的要求:只能在入住时间段内访问入住房间的空调相关接口。 + +可以看出,上述这些要求基本上覆盖了一个常见系统的中所有关于认证和授权的使用场景,因此本篇便以该系统为例介绍如何在ASP.NET Core框架中实现上述业务要求。 + +## 身份认证和授权的基础知识 + +身份认证是指由用户提供凭据,然后将其与存储在操作系统、数据库、应用和资源中的凭据进行比较的过程。而授权过程发生在身份认证成功之后:在凭据匹配成功之后,用户身份验证成功,可执行已向其授权的操作。授权就是判断允许用户执行操作的过程。 + +在ASPNET.Core中,这是通过两个**中间件**,`UseAuthenication`和`UseAuthorization`来完成的,还是来看这张经典的中间件工作流程: + +![ASP.NET Core 中间件管道](./aspnet-authorization/middleware-pipeline.svg) + +可以看到在中间件的管道中,认证中间价将在授权中间件运行之前运行——这两个顺序是不能颠倒的,如果授权中间件在认证中间件运行之前运行,那授权中间件就无法为用户授予任何权限,所有需要权限的接口均会返回401错误码。 + +> 为什么我知道的如此清楚捏? +> +> 因为我真的写反过,最后还是在框架代码里面打断点才发现授权中间件拿不到用户登录的信息,当时还在GitHub的工单里面翻找相关的bug,感觉可以评选为人生十大傻逼bug之一。 + +概览完认证和授权之后,首先来谈谈认证。认证的基本过程就是一个开锁的过程:用户提供一个凭据,也就是钥匙,系统验证凭据的有效性,就是锁的工作。这里主要的问题就是这个钥匙的形状长什么样子,也就是凭据的表现形式。常见的凭据表现形式有`Cookies`和`JWT`两种。 + +`Cookies`是一种服务器发送到用户浏览器并保存在本地上的一小块文本文件,用户浏览器在保存这些文本文件之后会在每次向同一服务器发送请求时在请求体中携带一些文本文件信息。`Cookies`是一种非常古老的技术,这种技术使得无状态的HTTP协议可以记录稳定的状态信息,因此在这个技术常被应用来认证网络用户的身份。 + +`JWT`的全称是JSON Web Token,是一种使用JSON对象表示格式在两方之前安全且有效的传输信息的方法,使用该方法的信息可以使用指定的密钥或者是公钥-私钥对验证信息的有效性。因此`JWT`作为一种通用的、可验证的令牌格式用来完成网络中认证的过程。在服务器验证某一个用户的身份之后(例如通过验证账号密码、通过第三方的验证)可以签发一个`JWT`令牌给用户浏览器,浏览器可以使用`localstorage`等技术将该令牌存储在用户浏览器中并在每次向服务器发送请求的过程中将该令牌携带在一个特定的请求头`Authorization`中。 + +> 在`Authorization`请求头中常常会以`Bearer `的格式进行,这其中的`Bearer`是指定的身份认证的模式(Scheme),这里的详细解释可以见[MDN文档](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication)。 + +谈完认证之后,再来看看授权。授权的实现是一个和业务逻辑高度相关的过程,一个常见的业务逻辑是用户分为不同的层级——例如普通用户和管理员,而不同层级的用户可以调用的接口不同,这就是**基于策略的授权模式**的典型应用场景,该模式允许为每个接口指定一个或者多个认证策略。另外一个常见的业务逻辑是用户只能访问自己所拥有的资源——例如用户只能删除自己创建的记录,这就是**基于资源的授权模式**的典型应用场景,该模式允许为一种资源编写一段授权逻辑,并通过依赖注入的方式供服务器或者控制器使用。 + +## 身份认证和授权的实践 + +在本个系统中,身份认证将采用`JWT`令牌,而授权的部分将会覆盖到上文中提到的两种典型模式,通过研究本系统的实现可以理解在ASP.NET Core中集成身份认证和授权的流程。 + +在ASP.NET Core系统中集成`JWT`令牌的认证方式需要先安装一个包`Microsoft.AspNetCore.Authentication.JwtBearer`。 + +### 身份认证部分 + +身份认证部分主要分为令牌签发和令牌验证两个部分,令牌认证的部分主要在于使用`AddAuthentication`向主机容器中注入服务,而令牌签发的部分则通常是实现一个接口,在验证用户输入的账号和密码之后生成该用户对于的令牌。这两个过程是高度关联的,在签发过程中设置的令牌信息需要在验证令牌的过程设置对应的部分,否则签发的令牌就无法验证。因此先介绍签发令牌的部分。 + +签发令牌之前先介绍一下`JWT`令牌的组成,一个兼容的`JWT`令牌一般有三个部分组成: + +- 头部`Header`:头部在一般情况下只有两个字段组成,一个`tpy`字段存储固定值为`JWT`指定这是一个`JWT`令牌,一个`alg`字段指定验证该令牌的算法是`HMCA SHA256`还是`RSA`: + + ```json + { + "alg": "HS256", + "typ": "JWT" + } + ``` + +- 负载`Payload`:包含各种关于实体(用户)的宣称列表。宣称可以分成三种类型,已注册的类型、公开的类型和私有的类型,这三种的类型的区别可以从[RFC7519](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1)中具体查看,简而言之就是已注册的类型就是推荐在签发令牌时设置的,包括签发者和到期时间等的内容,公开的类型是公开注册可以共享的名称,而私有的就是自行指定的。 + + ```json + { + "sub": "1234567890", + "name": "John Doe", + "admin": true + } + ``` + +- 签名`signature`:验证令牌的签名部分,在使用`HMCA SHA256`算法的情况下,签名的计算公示如下所示: + + ``` + HMACSHA256( + base64UrlEncode(header) + "." + + base64UrlEncode(payload), + secret) + ``` + +在学习了这些`JWT`的基础知识之后就可以很容易的写出如下的令牌生成代码: + +```csharp + public string GenerateJsonWebToken(User user) + { + List claims = + [ + new Claim(ClaimTypes.Name, user.Username), + new Claim(ClaimTypes.NameIdentifier, user.UserId) + ]; + + JwtSecurityToken token = new( + issuer: _option.Issuer, + audience: user.UserId, + notBefore: DateTime.Now, + expires: DateTime.Now.AddDays(7), + claims: claims, + signingCredentials: _signingCredentials + ); + + return _jwtSecurityTokenHandler.WriteToken(token); + } +``` + +签发令牌的凭据使用下面的方式创建: + +```csharp +private readonly SigningCredentials _signingCredentials = + new(new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jsonWebTokenOption.Value.JsonWebTokenKey)), + SecurityAlgorithms.HmacSha256); +``` + +签发的过程中部分重要的参数使用配置的方式提供,例如签发者和密钥,配置实体类如下所示: + +```csharp +public class JsonWebTokenOption +{ + public const string OptionName = "JWT"; + + /// + /// JWT令牌的签发者 + /// + public required string Issuer { get; set; } + + /// + /// JWT令牌的签发密钥 + /// + public required string JsonWebTokenKey { get; set; } +} + +``` + +签发好令牌之后就可以编写验证令牌的部分了: + +```csharp +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer( + options => + { + JsonWebTokenOption? jsonWebTokenOption = builder.Configuration.GetSection(JsonWebTokenOption.OptionName) + .Get(); + + if (jsonWebTokenOption is null) + { + throw new InvalidOperationException("Failed to get JWT options"); + } + + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = jsonWebTokenOption.Issuer, + ValidateAudience = false, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jsonWebTokenOption.JsonWebTokenKey)), + ValidAlgorithms = [SecurityAlgorithms.HmacSha256] + }; + }); + +``` + +在验证令牌的部分,指定验证令牌的签发者和签名。 + +编写完上述代码之后就可以增加身份验证和授权的中间件验证上述代码的正确性了。 + +```csharp +application.UseAuthentication(); +application.UseAuthorization(); +``` + +### 授权的部分 + +#### 按照策略进行授权 + +系统中一个典型的场景就是不同级别的用户能访问的接口不同,例如在本系统中用户的级别分为: + +```csharp +[Flags] +public enum Roles +{ + User = 0b_0000_0000, + RoomAdministrator = 0b_0000_0001, + AirConditionerAdministrator = 0b_0000_0010, + BillAdministrator = 0b_0000_0100, + Administrator = 0b_0000_1000 +} +``` + +为了方便给不同的接口指定不同的访问策略,首先创建一个对用户级别的要求(Requirement): + +```csharp +public class HotelRoleRequirement(Roles hotelRole) : IAuthorizationRequirement +{ + public Roles HotelRole { get; } = hotelRole; +} +``` + +然后实现一个处理该要求的验证程序: + +```csharp +public class HotelRoleHandler(MartinaDbContext dbContext) : AuthorizationHandler +{ + protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, + HotelRoleRequirement requirement) + { + Claim? userId = context.User.FindFirst(c => c.Type == ClaimTypes.NameIdentifier); + + if (userId is null) + { + return; + } + + User? user = await dbContext.Users + .Include(u => u.Permission) + .Where(u => u.UserId == userId.Value) + .FirstOrDefaultAsync(); + + if (user is null) + { + return; + } + + // 如果要求的权限是超级管理员 + // 则判断是否是超级管理员 + if ((requirement.HotelRole & Roles.Administrator) == Roles.Administrator) + { + if (user.Permission.IsAdministrator) + { + context.Succeed(requirement); + } + else + { + context.Fail(); + } + } + + // 剩下的权限 + // 如果用户是超级管理员则直接有权限 + if (user.Permission.IsAdministrator) + { + context.Succeed(requirement); + return; + } + + if ((requirement.HotelRole & Roles.BillAdministrator) == Roles.BillAdministrator) + { + if (user.Permission.BillAdminstrator) + { + context.Succeed(requirement); + } + else + { + context.Fail(); + } + } + + if ((requirement.HotelRole & Roles.RoomAdministrator) == Roles.RoomAdministrator) + { + if (user.Permission.RoomAdministrator) + { + context.Succeed(requirement); + } + else + { + context.Fail(); + } + } + + if ((requirement.HotelRole & Roles.AirConditionerAdministrator) == Roles.AirConditionerAdministrator) + { + if (user.Permission.AirConditionorAdministrator) + { + context.Succeed(requirement); + } + else + { + context.Fail(); + } + } + } +} +``` + +框架要求在处理程序使用依赖注入到主机的容器中,这里因为在验证的过程中使用了数据库的服务`DbContext`因此被注册为一个范围内(Scope)服务。 + +```csharp +builder.Services.AddScoped(); +``` + +为了方便在`[Authorize]`注解中使用字符串指定不同的授权策略,在`AddAuthoriztion`进行配置: + +```csharp +builder.Services.AddAuthorization(options => + { + options.AddPolicy("Administrator", policy => + { + policy.AddRequirements(new HotelRoleRequirement(Roles.Administrator)); + }); + + options.AddPolicy("RoomAdministrator", policy => + policy.AddRequirements(new HotelRoleRequirement(Roles.RoomAdministrator))); + + options.AddPolicy("AirConditionerAdministrator", policy => + policy.AddRequirements(new HotelRoleRequirement(Roles.AirConditionerAdministrator))); + + options.AddPolicy("BillAdministrator", policy => + policy.AddRequirements(new HotelRoleRequirement(Roles.BillAdministrator))); + }); +``` + +使用该方法注册之后就可以直接在`[Authorize]`注解中指定需要使用的授权策略: + +```csharp + [HttpGet("revenue")] + [Authorize(policy: "BillAdministrator")] + [ProducesResponseType(400)] + [ProducesResponseType(200)] + public async Task QueryRevenueTrend([FromQuery] DateTimeOffset begin, [FromQuery] DateTimeOffset end) + { + if (begin >= end) + { + return BadRequest(new ExceptionMessage("开始时间不能晚于结束时间")); + } + + RevenueTrend trend = new() + { + TotalUsers = await managerService.QueryCurrentUser(), + TotalCheckin = await managerService.QueryCurrentCheckin(), + DailyRevenues = await managerService.QueryDailyRevenue(begin, end) + }; + + return Ok(trend); + } +``` + +#### 按照资源进行授权 + +系统中一个典型的需求就是一个用户只能修改资源池中部分自己拥有权限的资源,在本系统中就是用户只能开启和关闭当前入住房间中的空调。 + +按照资源进行授权的总体流程和安装策略进行授权总体上差别不大,除了无法在注解中设置需要使用的策略。首先仍然是设计一个授权的要求: + +```csharp +public class CheckinRequirement : IAuthorizationRequirement; +``` + +然后为该要求实现一个授权处理程序,注意在这里集成泛型基类`AuthorizationHandler`时除了需要指定要求类还需要指定资源类型: + +```csharp +public class CheckinHandler( + RoomService roomService, + MartinaDbContext dbContext) + : AuthorizationHandler +{ + protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, + CheckinRequirement requirement, + Room resource) + { + Claim? userId = context.User.FindFirst(c => c.Type == ClaimTypes.NameIdentifier); + + if (userId is null) + { + return; + } + + User? user = await dbContext.Users.AsNoTracking() + .Where(u => u.UserId == userId.Value) + .FirstOrDefaultAsync(); + + if (user is { Permission.IsAdministrator: true } || user is { Permission.AirConditionorAdministrator: true }) + { + context.Succeed(requirement); + return; + } + + CheckinRecord? record = await roomService.QueryUserCurrentStatus(userId.Value); + + if (record?.RoomId == resource.Id) + { + context.Succeed(requirement); + } + } +} +``` + +在使用该授权方法时,通过依赖注入获得一个`IAuthorizationService`的接口对象并调用对应的授权接口进行验证,传入需要访问的资源和当前`HttpContext`中的用户`User`,这个`User`实际上就是`JWT`令牌中的负载部分。 + +```csharp + AuthorizationResult result = await authorizationService.AuthorizeAsync(User, room, [new CheckinRequirement()]); + + if (!result.Succeeded) + { + return Forbid(); + } + + if (!airConditionerManageService.VolidateAirConditionerRequest(roomObjectId, request, out string? message)) + { + return BadRequest(new ExceptionMessage(message)); + } +``` + +## 总结 + +通过清晰的定义身份认证和授权两个环节,并提供了一个要求——处理程序的授权模型,ASP.NET Core提供了一套简单易用、扩展性高的接口安全系统。 diff --git a/YaeBlog/source/posts/aspnet-authorization/middleware-pipeline.svg b/YaeBlog/source/posts/aspnet-authorization/middleware-pipeline.svg new file mode 100644 index 0000000..186e100 --- /dev/null +++ b/YaeBlog/source/posts/aspnet-authorization/middleware-pipeline.svg @@ -0,0 +1,324 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/YaeBlog/source/posts/dotnet-performance-8.md b/YaeBlog/source/posts/dotnet-performance-8.md new file mode 100644 index 0000000..e2af79d --- /dev/null +++ b/YaeBlog/source/posts/dotnet-performance-8.md @@ -0,0 +1,625 @@ +--- +title: 从.NET 6到.NET 8中的性能提升综述 +date: 2024-08-31T18:51:06.9233598+08:00 +tags: +- dotnet +- 技术笔记 +- 编译原理 +--- + +JIT编译就一定比AOT编译慢吗? + + + +长期以来,我们已经习惯了调侃Java较慢的运行速度,并将其原因归咎于Java使用了字节码加虚拟机的JIT编译方式。但是对于同样采用了这样方式的.NET,微软的开发人员却认为——"(虽然这样说很难让人信服,)但是许多人都认为托管应用程序的性能实际上超过了非托管应用程序。有许多原因使我们对此深信不疑。例如,当JIT编译器在运行时将IL代码编译成本地代码时,编译器对于执行环境的认识比非托管编译器更深刻。"(摘自Jeffrey Richter的《CLR via C#》)。 + +既然微软的开发人员对此深信不疑,同时在.NET Core之后,.NET的内部开发流程逐渐公开化,在[Github](https://github.com/dotnet/runtime)和[.NET 官方博客](https://devblogs.microsoft.com/dotnet/)上都能看到。那么我们就在本篇文章中梳理一下.NET平台从.NET 6到.NET 8三个版本中所有主要的性能提升,主要聚焦于JIT编译器、内存管理等少数几个部分。 + +## JIT + +JIT(Just In Time,即时编译)编译器是运行时中的基础,负责将前端编译器生成的IL(Intermediate Language,就是一套微软规定的中间表示形式)转换为汇编语言,在AOT(Ahead Of Time,提前编译)编译时也是调用的该编译器。这里可以解释一下.NET代码执行的三种模型: + +- JIT编译执行:最为“传统“的执行模型,所有的IL代码都需要在执行前通过JIT编译为本机代码再执行。 +- 即时运行(ReadyToRun, R2R):在程序编译阶段先调用JIT编译器将IL代码编译为本机代码,在程序运行时首先运行编译好的本机代码以提高应用启动的速度,在运行过程中再次调用JIT编译对热点代码进行优化编译。为了提高启动速度,.NET中的所有核心库都以R2R的形式提供,程序员可以自行决定编写的代码是否使用R2R的方式运行。 +- 提前编译:在程序编译阶段直接调用JIT编译器将IL代码编译为本机代码,程序运行时就执行这一套代码。 + +### 分层编译、栈上替换和PGO + +在.NET从6到8的版本演进过程中,最为重磅的性能更新莫过于在.NET 6便引入的动态PGO(dynamic Profile-Guided Optimization)在.NET 8中终于默认启用了。为了介绍动态PGO,我们必须首先理解JIT对于IL代码的分层编译机制。 + +#### 分层编译 + +在JIT编译器最初的设计模型中,每个方法只会被编译一次:每个方法只会在调用被编译为汇编代码,该代码被缓存起来以备下次调用。但是这种设计却导致许多矛盾:一个根本性的矛盾就是JIT编译花费在编译优化上的时间同从优化中能得到的效果之间的矛盾。在编译过程中对代码进行优化几乎是编译器工作过程中最耗时的部分,尤其是对于一个JIT编译器来说,编译的时间几乎直接决定了应用启动的时间,如果对一个方法进行优化需要耗费一秒钟的时间,但是仅能使该方法的运行时间从10毫秒下降到1毫秒,在该方法在运行过程只会调用一次的情况下,编译器引入该优化只会让程序的运行时间增加。因此,编译器必须要在程序运行时的效率和启动时间之间做取舍。尤其是考虑到程序的**空间局部性**原理:程序中的大多数函数只会在运行时被调用少数几次,对于这些函数在启动时耗费大量的优化时间是纯纯的浪费。 + +**分层编译**的引入从根本上解决了这个问题:该编译策略允许一个方法在运行时被编译多次。 + +在第一次调用时,方法会被编译到第0层(Tier 0)。在这个编译层级上只会应用少数的编译优化策略,这些编译优化策略被称为最小优化策略(Minimal Optimization,Min Opts)。需要指出的这些策略实际上也不少,包含了那些可以使JIT编译器更快运行的优化策略,例如可以生成更少量的本机代码。在优化的同时JIT编译器还会注入一些短短的代码片段(stub),这些代码片段使得运行时可以统计每个方法的调用次数。 + +运行时可以监控这些方法的调用次数,当某个方法的调用次数超过某个预先设定的阈值时,这个方法将被加入重新编译的队列。这次编译将会把方法编译到第1层(Tier 1),JIT编译器将会在编译的过程中应用所有可能的优化策略。在整个程序的运行过程中,只有少数被多次调用的方法会编译到第1层。同时编译器也可以通过收集方法在第0层的运行过程中的信息来进行第1层编译过程中的优化。例如对于`static readonly`类型的变量,当方法在第0层执行之后,这些类型的变量已经完成初始化且无法再发生更改,此时编译器就可以将这些变量当作是`const`类型的常量,将所有应用于常量的优化策略扩展到该类型的变量上进行应用。 + +在大多数情况下,使用分层编译可以使用程序同时获得良好的启动速度和运行效率,除了某些特定的情况。这些特定情况的一个典型例子就是运行时间非常长的方法:在上述的优化策略中只重视了调用次数非常多的方法,但是运行时间非常长的方法也对于效率有着非常明显的影响。而在分层编译的情况下,这些长运行时间但是少调用次数的函数将会只被编译到第0层,这会造成明显的性能下降。因此在.NET 7之前,所有含有回溯分支的方法都会直接编译到第1层。 + +.NET 7引入的栈上替换改进了这一点。 + +>这里可能有人会争论:对于少数运行时间长的方法在启动时多施加一些优化策略真的会导致明显的启动时间增加吗,有必要引入更复杂的策略针对这点蚊子腿进行优化吗? +> +>的确,对这点启动时间进行优化很可能是不明显的,但是别忘了编译器可以在第0层的运行过程中收集信息进行第1层的优化,这实际上也是动态PGO机制引入的基础之一。 + +#### 栈上替换 + +分层编译很好,除了在面对运行时间长的方法时。例如对于下面这个包含一万次循环的方法: + +```csharp +class Program +{ + static void Main() + { + var sw = new System.Diagnostics.Stopwatch(); + while (true) + { + sw.Restart(); + for (int trial = 0; trial < 10_000; trial++) + { + int count = 0; + for (int i = 0; i < char.MaxValue; i++) + if (IsAsciiDigit((char)i)) + count++; + } + sw.Stop(); + Console.WriteLine(sw.Elapsed); + } + + static bool IsAsciiDigit(char c) => (uint)(c - '0') <= 9; + } +} +``` + +当在.NET 6平台运行时,我们可以比较在启用对方法的分层编译和不启用的情况下的性能对比。 + +| 编号 | 启用分层编译 | 不启用 | +| ---- | ---------------- | ---------------- | +| 1 | 00:00:01.2841397 | 00:00:00.5734352 | +| 2 | 00:00:01.2693485 | 00:00:00.5526667 | +| 3 | 00:00:01.2755646 | 00:00:00.5675267 | +| 4 | 00:00:01.2656678 | 00:00:00.5588724 | +| 5 | 00:00:01.2679925 | 00:00:00.5616028 | + +栈上替换(On Stack Replacement)就是为了解决这个问题而引入的:没人规定说一个方法执行的本机代码只能在执行的间隙被替换,在执行的过程中也可以替换掉方法执行的本机代码,也就是当方法还在运行栈上时执行替换。在第0层编译时,编译器不仅可以为函数的调用生成统计调用次数的片段代码,也可以为循环的执行生成运行次数的片段代码。当运行时监控到某一个循环的执行次数超过设定的阈值时,编译器就可以将该方法编译到第1层,运行时将会把方法此时调用的所有寄存器和本地变量复制到一个新的方法调用中,而新的调用使用的本机代码已经是优化之后的本机代码了。 + +在分层编译和栈上替换的协作下,程序的启动实现和运行性能之前就可以达到一个较好的平衡了。当然,分层编译和栈上替换的能力并不仅限于优化应用的启动时间,在动态PGO中这两者将会发挥更大的作用。 + +#### 动态PGO + +采样制导的优化(Profile-Guided Optimization)并不是一个新鲜的概念,在数十年前就出现,并在多种编程语言和运行时中得到的应用。PGO的一个典型工作流程一般如下: + +1. 在插入一些特定指令的情况下构建应用程序; +2. 将应用程序放在典型的应用场景下进行运行,并通过这些特定指令收集运行的信息; +3. 在这些信息的指导下重新构建应用程序,得到针对运行场景的特定优化。 + +这种工作流程被称作是静态的PGO,这些工作流往往额外的应用知识、特定的工具和构建-上线流程的反复执行。 + +回到.NET的执行过程中,既然分层编译已经可以将程序生成为第0层和第1层两个版本,为什么不在第0层程序的运行过程中收集一些有用的信息输入到第1层的编译过程中呢,这样编译器还可以生成更加优化的第1层本机代码。这个过程中传统静态PGO流程中的构建-运行-再构建流程完全一致,不过现在优化的层级可以聚焦在方法上,而不是针对整个程序进行优化,以及最为重要的是,这一切都在程序运行的过程中由JIT编译器自动的进行,不需要任何额外的开发工作或者是针对性的构建流程。 + +在.NET 6到.NET 8整整三个大版本对于动态PGO的迭代过程中引入了大量的优化,这里仅能介绍一小部分。 + +首先是为了更好发挥动态PGO的性能,JIT编译器中为分层编译引入了更多的编译层数。需要引入更多编译层数的原因主要有两点。第一,插入各种采样的指令和代码是需要代价的,考虑到第0层编译的主要目标是为了降低编译的时间,提高应用的启动速度,在第0层编译过程中就不能插入太多的采样指令。因此编译器首先增加了一个新的编译层——采样第0层来解决这个问题。大部分的方法将在第一次运行时编译到缺少优化、缺少采样指令的第0层,在运行时发现该方法被调用了多次之后,JIT编译器将这个方法重新编译到采样第0层,再经过一系列的调用之后,JIT编译器将利用采样得到的信息对该方法重新进行编译并优化。第二,在原始编译器模型中使用即时运行(R2R)方法编译的代码不能参加到动态PGO中,尤其是考虑到几乎所有应用程序都会调用的核心库代码是采用R2R的方式进行运行的,如果这部分的代码不能参加动态PGO将不能够完全发挥动态PGO的效果,虽然核心库在提前编译的过程中会使用静态PGO进行一部分的优化。因此JIT编译器为R2R编译好的代码增加了一个新的编译器,在运行时发现这部分代码被调用多次之后将会被JIT编译器编译到含有优化和采样代码的采样第1层,随着调用次数的增加这部分的代码将可以利用采样得到的信息进行优化。下面这张图展现了不同编译方法在运行过程中可能达到的编译层级。 + +![image-20240828135354598](./dotnet-performance-8/image-20240828135354598.png) + +JIT编译器也在第0层编译的过程中引入了更多的优化。虽然第0层编译的目的是缩短编译的时间,但是许多的优化可以通过减少需要生成的代码数量来达到这个目的。常量折叠(Constant Folding)就是一个很好的例子。虽然这会让JIT编译器在第0层编译时花费更多的时间同运行时中的虚拟机交互来解析各种变量的类型,但是这可以大量的减少JIT编译器需要生成的代码量,尤其是对于下面这种涉及到类型判断的例子。 + +```csharp +MaybePrint(42.0); + +static void MaybePrint(T value) +{ + if (value is int) + { + Console.WriteLine(value); + } +} +``` + +现在在第0层编译的过程中,JIT编译器可以发现`MaybePrint`方法在运行过程中不会运行任何实际的代码路径,因此可以直接优化掉这段代码。 + +```assembly +; Assembly listing for method Program:<
$>g__MaybePrint|0_0[double](double) (Tier0) +; Emitting BLENDED_CODE for X64 with AVX - Windows +; Tier0 code +; rbp based frame +; partially interruptible + +G_M000_IG01: ;; offset=0x0000 + push rbp + mov rbp, rsp + vmovsd qword ptr [rbp+0x10], xmm0 + +G_M000_IG02: ;; offset=0x0009 + +G_M000_IG03: ;; offset=0x0009 + pop rbp + ret + +; Total bytes of code 11 +``` + +插入的采样代码片段也会造成一些性能上的问题。为了优化JIT编译器往往需要统计各种方法和分支的调用和运行次数,但是问题是这些统计调用次数的代码应该如何编写?尤其是考虑到代码片段是一个静态的“数据”,会在各种不同的运行线程之间共享,如何设计一个线程安全同时高效的统计方法? + +最初的统计方式是设计一个朴素、没有线程同步的方法,例如`_branches[branchId]++`。虽然这种方法没有在运行时引入大量的同步开销,但是这也意味着在某个方法被多个线程同时调用时会损失掉大量的统计数据,这会造成一个本应该提前进入动态PGO的方法得到优化的时间严重滞后。这方面一个容易想到的方式是使用同步的方法进行统计,例如给数据加锁或者是使用原子指令(`Interlocked.Add`)。但是这种方式会严重的导致性能下降。为了解决这个问题,开发者们设计了一种非常巧妙的解决方法,这种方法的C#实现如下所示。 + +```csharp +static void Count(ref uint sharedCounter) +{ + uint currentCount = sharedCounter, delta = 1; + if (currentCount > 0) + { + int logCount = 31 - (int)uint.LeadingZeroCount(currentCount); + if (logCount >= 13) + { + delta = 1u << (logCount - 12); + uint random = (uint)Random.Shared.NextInt64(0, uint.MaxValue + 1L); + if ((random & (delta - 1)) != 0) + { + return; + } + } + } + + Interlocked.Add(ref sharedCounter, delta); +} +``` + +在计数器的值没有超过8192时,计数逻辑直接使用原子指令进行统计。当计数器的数值超过8192之后,计数逻辑将采用一个随机的增加策略。首先按照50%的概率给计数器增加2,然后按照25%的概率增加4,然后按照12.5%的概率增加8,依次类推。随着计数器值的增加,但是需要调用原子指令的频率也就越低。 + +为了验证该计数逻辑的有效性,可以使用下面的代码进行验证。 + +```csharp +using System.Diagnostics; + +uint counter = 0; +const int ItersPerThread = 1_000_000_00; + +while (true) +{ + Run("Interlock", _ => + { + for (int i = 0; i < ItersPerThread; i++) Interlocked.Increment(ref counter); + }); + Run("Racy ", _ => + { + for (int i = 0; i < ItersPerThread; i++) counter++; + }); + Run("Scalable ", _ => + { + for (int i = 0; i < ItersPerThread; i++) Count(ref counter); + }); + Console.WriteLine(); +} + +void Run(string name, Action body) +{ + counter = 0; + long start = Stopwatch.GetTimestamp(); + Parallel.For(0, Environment.ProcessorCount, body); + long end = Stopwatch.GetTimestamp(); + Console.WriteLine( + $"{name} => Expected: {Environment.ProcessorCount * ItersPerThread:N0}, Actual: {counter,13:N0}, Elapsed: {Stopwatch.GetElapsedTime(start, end).TotalMilliseconds}ms"); +} +``` + +运行得到的数据如下所示: + +| 类型 | 期望数值 | 实际数值 | 运行时间 | +| -------- | ------------- | ------------- | ------------ | +| 原子指令 | 2,000,000,000 | 2,000,000,000 | 22241.9848ms | +| 朴素 | 2,000,000,000 | 220,525,235 | 277.3435ms | +| 随机 | 2,000,000,000 | 2,024,587,268 | 527.5323ms | + +从数据上就可以发现,新方法可以在和朴素方法接近的运行时间下获得和使用原子指令接近的实际数值,而且运行时间会随着数值的增加进一步的减少,逐渐逼近朴素方法的运行时间。 + +如何准确而低成本的技术并不是采样过程中唯一的问题。另一个问题是如何统计在接口或者是虚拟方法调用时哪个类型是最可能被调用到的类型,如果JIT能够得到这种信息,就可以为该类型生成一条更加快速的调用路径。正如上一个算法所揭示的,准确统计每一个类型被调用的次数是非常昂贵的,因此在这里开发者引入了一种被称作蓄水池采样(Reservoir Sampling)的方法进行统计。例如对于一个含有60%的`'a'`、30%的`'b'`和10%的`‘c'`的字符序列,如何快速而准确的统计其中哪个字符出现的频率最高?利用蓄水池采样算法,可以写出如下的统计代码: + +> 蓄水池采样算法设计的目的是为了解决这样一个问题:给出一个数据流,这个数据流的长度很大或者是未知,并且对于该数据流中的数据只能访问一次。请设计一个随机选择算法,使得数据里中所有数据被选中的概率相等。 + +```csharp +// Create random input for testing, with 60% a, 30% b, 10% c +char[] chars = new char[1_000_000]; +Array.Fill(chars, 'a', 0, 600_000); +Array.Fill(chars, 'b', 600_000, 300_000); +Array.Fill(chars, 'c', 900_000, 100_000); +Random.Shared.Shuffle(chars); + +for (int trial = 0; trial < 5; trial++) +{ + // Reservoir sampling + char[] reservoir = new char[32]; // same reservoir size as the JIT + int next = 0; + for (int i = 0; i < reservoir.Length && next < chars.Length; i++, next++) + { + reservoir[i] = chars[i]; + } + for (; next < chars.Length; next++) + { + int r = Random.Shared.Next(next + 1); + if (r < reservoir.Length) + { + reservoir[r] = chars[next]; + } + } + + // Print resulting percentages + Console.WriteLine($"a: {reservoir.Count(c => c == 'a') * 100.0 / reservoir.Length}"); + Console.WriteLine($"b: {reservoir.Count(c => c == 'b') * 100.0 / reservoir.Length}"); + Console.WriteLine($"c: {reservoir.Count(c => c == 'c') * 100.0 / reservoir.Length}"); + Console.WriteLine(); +} +``` + +程序的输出是5次次采样统计的结果: + +![image-20240828155556375](./dotnet-performance-8/image-20240828155556375.png) + +需要指出的是,虽然在上面的代码中使用和运行时代码中一样的“蓄水池”大小,但是在运行时并没有提前获得所有需要统计的数据,调用的统计数据是由多个不同的运行线程同时写入蓄水池中的。从结果中可以看出,虽然数值上并不准确,但是该算法准确的统计出了各个字符的出现趋势。 + +在上述两个例子中,算法中都引入了随机数的概念进行统计,这就导致每次运行的结果都在一定程度上有着不同,同时这也会导致在每次程序运行的过程中,动态PGO所做的优化都会有轻微的不同。有的开发者可能会担心这些随机的引入是否会造成程序运行行为的不可确定性从而导致程序的调试变得困难,但是实际上在引入这些随机数之后这些代码路径已经就有一定的不确定性(例如那个朴素的调用次数统计算法),同时开发过程中已经有大量的数据证实这些代码的行为是总体上稳定且可重现的。 + +本篇文章中介绍动态PGO的部分就大致到这里,但是文章后续的部分中仍然可以在各个地方中看到动态PGO的身影,这也可以侧面看出动态PGO对于整个优化的巨大作用。 + +### 函数内联 + +函数内联是JIT编译器能完成的重要优化之一,其的运行逻辑是取消对于某个方法的直接调用,而是将该方法的执行代码直接插入到当前的控制流中。函数内联最显而易见的优化是减小了调用函数过程中压栈和弹栈带来的开销,但除了对于某些在热点路径上的小型方法,这点减少的开销实际上并不是函数内联实际上带来的主要优化。 + +函数内联带来的主要优化是其将被调用者的逻辑暴露给了调用者,或者反过来。例如,当调用者将一个常数作为参数传递给被调用的方法时,如果被调用的方法没有进行内联,对该方法进行编译时编译器就无从得知一个常数被传递了过来,但是如果该方法被内联了,进行编译的编译器就可以应用一切对于常数可以应用的优化,包括删除死代码、分支预测、常量折叠等等。 + +按照这个逻辑分析,那么在编译的时候应该应内联尽内联,但是内联有可能会增加编译之后的指令条数。而指令条数的增加可能会造成指令缓存效率的下降——当需要读取内存的次数越多时,缓存的效率就会越低。例如考虑一个方法,这个方法在整个程序中被内联了100次,而这一百次都内联编译为一份不同的本机代码序列,这一百次调用就完全不能高效的利用指令缓存,而如果对于这个方法没有进行内联,这一百次调用都可以指向同一个内存地址,这就让指令缓存感到非常舒适。因此在JIT编译器编译一个方法时,如果编译器聪明到可以判断出内联之后编译得到的指令序列将少于直接调用得到的指令序列那么编译器就可以执行内联操作,反之编译器就需要衡量内联方法得到的吞吐量提高和增长的指令序列造成的运行效率了。 + +因此就需要JIT编译器合理的判断哪些方法在编译过程中需要进行内联,哪些方法在编译过程中进行内联。这方面编译器做出的主要更新是让内联更好的能够判断需要被内联方法的内容,尤其是在方法没有被分层编译或者是方法直接跳过了第0层编译的情况下。再考虑到在运行时库中引入的大量可以低成本调用的硬件加速指令方法,这些方法也可以有效的进行内联。 + +### 去虚拟化 + +在调用一个接口类型的变量上的方法时,运行时需要做的一个重要工作就是判断实际上应该调用哪个类型的对象上的方法,这在对于接口、虚拟成员方法、泛型方法和委托类型的调用上都是适用的。 + +因此JIT编译器引入一种被称为保险去虚拟化(Guarded Devirtualization,GDV)的机制进行优化,这种机制也是在动态PGO的帮助下引入的。具体地说,在运行时将会统计具体被调用的类型或者方法的频率,然后在进行优化编译时为最常出现的类型提供一条快速调用的路径。对于下面这种例子来说: + +```csharp +public class Tests +{ + internal interface IValueProducer + { + int GetValue(); + } + + class Producer : IValueProducer + { + public int GetValue() => 42; + } + + private IValueProducer _valueProducer; + private int _factor = 2; + + public void Setup() => _valueProducer = new Producer42(); + + public int GetValue() => _valueProducer.GetValue() * _factor; +} +``` + +对于其中的`GetValue`方法,在没有动态PGO和GDV的参与下,这个方法中将会被编译为一种普通的接口方法调用。但是在启用了动态PGO的环境下,编译器将会注意到对于`IValueProducer`最常见的实现是`Producer`,这样JIT编译器就可以为`Producer`生成一条快速路径,对应与下面的C#实现: + +```csharp +int result = _valueProducer.GetType() == typeof(Producer) ? + Unsafe.As(_valueProducer).GetValue() : + _valueProducer.GetValue(); +return result * _factor; +``` + +.NET中实现的GDV优化可以支持生成多个GDV,也就是在进行接口调用同时为多个类型生成快速路径。但是这个默认的运行条件下是关闭,需要用户通过一个特定的环境变量进行设置`DOTNET_JitGuardedDevirutalizationMaxTypeChecks`。这一优化在使用AOT编译器直接编译到本机代码时还有一个非常有趣的效果,考虑到在进行AOT编译时会对程序集进行裁剪,也就是删除掉最终的应用程序中没有用到的类型,这就让编译器可以在编译时知道实现了某一特定接口的类型总共有哪些,并且在这些类型的数量较少时直接为这些类型都生成调用时的快速路径而完全避免在运行时进行判断。 + +在上文中已经提到GDV不仅可以在调用接口上定义方法时使用,也可以在调用委托的时候使用。这使用GDV在和循环克隆(Loop Cloning)等优化技术配合时能够发挥出更大的功能,例如对于下面这个例子: + +```csharp +public class Tests +{ + private readonly Func _func = i => i + 1; + + public int Sum() => Sum(_func); + + private static int Sum(Func func) + { + int sum = 0; + for (int i = 0; i < 10_000; i++) + { + sum += func(i); + } + + return sum; + } +} +``` + +在上面的示例代码的循环中调用了一个委托`func`,在动态PGO和GDV的参与下,编译器可以知道这个委托最常见的实现(其实是唯一的)是一个固定的Lambda函数(暂且称之为Known Lambda),因此编译器可以将`Sum`函数的编译器为如下的等价C#代码: + +```csharp +private static int Sum(Func func) +{ + int sum = 0; + for (int i = 0; i < 10_000; i++) + { + sum += func.Method == KnownLambda ? i + 1 : func(i); + } + + return sum; +} +``` + +> 这里需要注意的是,这些代码都是**等价**C#代码,实际上编译器并不是先编译为一种C#形式的代码,而是直接生成为汇编代码。 + +显然,在循环内部反复的进行一个相同的判断并不是一个理想的状态。因此在变量提升(hoisting)优化技术的帮助下,编译器可以将循环内部一个相同的判断提升到循环外部执行,这将产生如下的等价代码。 + +```csharp +private static int Sum(Func func) +{ + int sum = 0; + bool isAdd = func.Method == KnownLambda; + for (int i = 0; i < 10_000; i++) + { + sum += isAdd ? i + 1 : func(i); + } + + return sum; +} +``` + +这还不是优化的极限,注意到在每个循环中还有个重复的三元表达式,这个的结果在各次循环之前也应该是稳定的,因此在循环克隆优化的指导下,编译器将生成如下的等价代码。 + +```csharp +private static int Sum(Func func) +{ + int sum = 0; + if (func.Method == KnownLambda) + { + for (int i = 0; i < 10_000; i++) + { + sum += i + 1; + } + } + else + { + for (int i = 0; i < 10_000; i++) + { + sum += func(i); + } + } + return sum; +} +``` + +这可以说,在动态PGO和GDV优化策略的加持下,一些“传统的”优化策略又被编译器榨出了新的潜能,从实际的跑分上也可以验证这惊人的优化。 + +| 方法 | 条件 | 平均运行时间 | +| ---- | ---------------- | ------------ | +| Sum | 开启动态PGO和GDV | 2.320us | +| Sum | 关闭动态PGO和GDV | 16.546us | + +### 分支 + +分支代码几乎是所有的代码片段中都会涉及到的模式,包括各种循环、判断和三元表达式种种。但是考虑到现代处理器都是多发射的超标量流水线处理器,而各种分支代码往往会打断这些高速运行的流水线,尽管处理器的设计者会通过分支预测器等技术进行猜测,而且往往还猜得很准,但是如果预测出错就需要清空流水线重新运行。因此如何减少代码中的分支是编译器优化的重要课题。 + +删除重复的分支判断是一个常见的分支优化,尤其常见与用户代码和库代码进行交互的过程中。例如对于下面这个例子: + +```csharp + public ReadOnlySpan SliceOrDefault(ReadOnlySpan span, int i) + { + if ((uint)i < (uint)span.Length) + { + return span.Slice(i); + } + + return default; + } +``` + +这段代码中首先判断索引起始的位置是否小于切片的长度再调用对应的切片方法,但是在`ReadOnlySpan.Slice`的源代码中还有一个几乎一致的判断: + +```csharp + public ReadOnlySpan Slice(int start) + { + if ((uint)start > (uint)_length) + ThrowHelper.ThrowArgumentOutOfRangeException(); + + return new ReadOnlySpan(ref Unsafe.Add(ref _reference, (nint)(uint)start /* force zero-extension */), _length - start); + } +``` + +这就让生成的本机代码中出现两个冗余的判断。编译器可以针对这种冗余的判断进行检查并删除这些重复判断。这种类似的分支删除后面还会在“消除边界检查”章节中提到。 + +灵活的应用各种位运算也是一种常见的分支优化策略。例如对于下面这种对于一个有符号整数的判断`i >= o && j >= 0`可以直接被优化为`(i | j) >= 0`,通过引入一个位运算就减少了一个分支判断。除了灵活的应用位运算之外,使用指令集提供的各种条件移动指令也是一种有效的分支优化策略,比如`x86/64`指令集中提供的`cmov`指令和`arm`指令集中提供的`csel`指令,这些指令都将一个条件判断封装到一条指令中。 + +C#编译器也可以在分支消除中贡献一份属于自己的力量。考虑.NET中非常常见的一个类型`System.Boolean`,在使用中这个类型是一个两值类型,有且仅有两个取值`true`和`false`。但是实际上在运行时中会使用一个字节大小的空间来存储一个类型,这意味实际上该类型有着256个取值,并且将0视为`false`,将`[1,255]`视为`true`。当然开发者可以使用`unsafe`代码绕过一些编译器的限制,但是“普通的”的开发者和核心库都只会给这个类型的赋予0或者1两个值。因此,在设计一类特殊的算法——无分支判断算法时,开发者可能会写出如下的代码: + +```csharp +static int ConditionalSelect(bool condition, int whenTrue, int whenFalse) => + (whenTrue * condition) + + (whenFalse * !condition); +``` + +但是上述的代码并不能被C#接受,因为C#编译器限制不能让布尔类型参加运算,因此这类算法的开发者不得不因此引入两个多余的分支判断: + +```csharp +static int ConditionalSelect(bool condition, int whenTrue, int whenFalse) => + (whenTrue * (condition ? 1 : 0)) + + (whenFalse * (condition ? 0 : 1)); +``` + +但是现在C#编译器可以消除掉这两个多余的分支判断,因为在.NET世界中编译器可以确保布尔变量的取值只能有1或者0两种情况。 + +#### 消除边界检查 + +.NET提供的一种特性就是运行时安全,这其中重要的一点就是对于数组、字符串和切片在运行时进行边界检查。但是这些边界检查就会在实际生成的代码中生成大量的分支判断,这会导致程序运行的效率严重下降。因此如何让编译器在能够保证访问安全的情况下消除掉部分不必要的边界检查是编译器优化中的一个重要课题。 + +例如在一个常用数据结构——哈希表中,通常的实现是计算键的哈希值,并利用该哈希值作为下标在数组中获得存储的对象。考虑到哈希值是一个`int`类型的变量,但是哈希表中很少需要存储高达21亿对象,因此往往需要对哈希值取模之后再作为数组的下标,此时取模的值常常就是数组的长度。也就是说,在这种情况下对于数组的访问是不可能出现越界的情况下。因此编译器可以为类似与如下的代码取消访问数组时的边界检查: + +```csharp +public class Tests +{ + private readonly int[] _array = new int[7]; + + public int GetBucket() => GetBucket(_array, 42); + + private static int GetBucket(int[] buckets, int hashcode) => + buckets[(uint)hashcode % buckets.Length]; +} +``` + +同样的,对于下面这些代码,编译器也可以取消访问数组时的边界检查: + +```csharp +public class Tests +{ + private readonly string _s = "\"Hello, World!\""; + + public bool IsQuoted() => IsQuoted(_s); + + private static bool IsQuoted(string s) => + s.Length >= 2 && s[0] == '"' && s[^1] == '"'; +} +``` + +### 常量折叠 + +常量折叠(Constant Folding)同样是一个编译器在生成代码时可以进行的重要优化,这让编译器在计算在编译器时就可以确定的值,而不是让他们留到运行时进行。最朴素的常量折叠——例如计算一个数学表达式的值——在这里不在赘述。在上面介绍函数内联时也涉及到了常量折叠的内容,分层编译的引入也会使得常量折叠的应用范围变广,这些都不在这里重复。 + +进行常量折叠优化时一个重要的问题是“教会”编译器哪些变量是常量。这方面编译器得到的提升有: + +- 可以将一个字面值字符串的长度视为一个常数; +- 在进行空安全的检查时字面值字符串是必定不为空的; +- 编译器在编译时除了可以进行一些简单的数学运算,现在整个`System.Math`命名空间中提供的算法都可以在编译时进行运算; +- `static readonly`类型的字符串和数组长度被视为一个常数; +- `obj.GetType()`现在在JIT编译器明确了解类型的情况下可以被替换为一个常量; +- `DateTime`等时间类型初始化时可以在编译期计算内存存储的时间。例如对于`new DateTime(2023, 9, 1)`将会直接被编译到`new DateTime(0x8DBAA7E629B4000)`。 + +上述这些并不能完全覆盖在.NET 6到.NET 8三个大版本之中引入的所有JIT编译器优化,但是从中也可以一窥编译器优化的精巧之处。首先,编译器的优化并不是一个个独立优化策略的组合,而且各种优化策略的有机组合。方法的内联就是一个典型例子,通过将被调用方法的内容暴露给调用者(或者反过来)让其他的各种优化策略发挥更大的作用。其次,JIT编译器在编译优化方面可以发挥更伟大的作用。通过在程序运行时对于运行环境和程序本身有着更加深刻的理解,JIT编译器可以在运行时发挥出更高的性能。 + +## 内存管理 + +.NET中的垃圾回收器(GC)负责管理应用程序的内存分配和释放。每当有对象新建时,运行时都会将从托管堆为对象分配内存,主要托管堆中还有地址空间,运行时就会从托管堆为对象分配内存。不过内存并不是无限的,垃圾回收器就负责执行垃圾回收来释放一些内存。垃圾回收器的优化引擎会根据所执行的分配来确定执行收回的最佳时机。 + +.NET中内存管理中的一个显著变更为将内存的抽象从段(Segment)修改为区域(Region)。段和区域之前最明显的区别是大小,段是较大的内存——在64位的机器上一个段的大小万网是1GB、2GB或者是4GB,而区域是非常小的单元,在默认情况下只有4MB的大小。从宏观上来说,之前的GC是为每个代的堆维持一个GB级别的内存范围,而现在GC则是维持了许多个较小的内存区域,这些内存区域可以被分配给各个代的堆(或者其他可能涉及的堆)使用。 + +垃圾回收器中还有两个引人注意的特性增加。第一个是动态的代提升和下降(Dynamic Promotion and Demotion,`DPAD`),第二个是动态适应应用程序大小(Dynamic Adaptive To Application Size,`DATAS`)。`DPAD`特性允许GC在工作的过程中动态的设置一个区域的代数,例如直接将一个可能存活时间非常长的对象配置为第2代,而这在之前的GC模型中需要通过两次垃圾回收才能实现。而第二个特性`DATAS`旨在适应应用程序的内存要求,即应用程序堆的大小和长期数据大小大致成正比,即使在不同规格的计算机上执行相同的工作时,运行时中堆的大小也是类似的。相比如下,传统的服务器模式下的GC旨在提高程序的吞吐量,允许内存的分配量基于吞吐量而不是应用程序的大小。`DATAS`对于各种突发类型的工作负载是非常有利的,同时通过允许堆大小按照工作负载的要求进行调整,这将让一些内存首先的环境直接受益。 + +### 无垃圾回收的堆 + +在程序中大量会涉及到使用常量字符串的情形,例如下面这个例子: + +```csharp +public class Tests +{ + public string GetPrefix() => "https://"; +} +``` + +在.NET 7平台上这个方法会被JIT编译器编译之后得到下面这段本机代码: + +```assembly +; Tests.GetPrefix() + mov rax,126A7C01498 + mov rax,[rax] + ret +; Total bytes of code 14 +``` + +在这段代码中使用了两个`mov`指令,其中第一个指令加载存储这个字符串对象地址的地址,第二个读取该地址。从这段本机代码可以看见,尽管已经是在处理一个常量的字符串,但是编译器和运行时仍然需要为这个字符串在堆上分配一个`string`对象:因为一个在堆上分配的对象在GC的控制下会在内存中发生移动,编译器就不能为这个对象使用一个固定的内存地址,需用从一个指定的地址读取该对象所在的地址。如果能让这个常量字符串分配在不会移动的内存区域中就能从编译器和GC两个方面上提高程序运行的效率。 + +为了优化这种生成周期和程序一致对象的内存管理,.NET 8中引入了一个新的堆——没有内存管理的堆。JIT编译器将会保证这些常量类型的对象将会被分配在这个堆中,这种没有GC管理的堆也意味着JIT编译器可以为这些对象使用一个固定的内存地址,在使用时避免掉了一次内存读取。 + +![Heaps where .NET Objects Live](./dotnet-performance-8/HeapsWhereNetObjectsLive.png) + +将上述提高的示例代码使用.NET 8版本进行编译得到的代码如下,从中也可以看出JIT编译器生成的代码只有一条`mov`指令,避免了一次内存访问。 + +```assembly +; Tests.GetPrefix() + mov rax,227814EAEA8 + ret +; Total bytes of code 11 +``` + +这个没有内存管理的堆引入还可以让其他的类型受益。例如对于`typeof(T)`返回的类型对象,容易想到一个程序集中所有类型对象的生命周期应该是和程序一致的,因此也可以在这个堆上分配所有这些类型对象。`Array.Empty`也可以利用类似的思路分配在这个堆上。 + +### 值类型 + +因为可以避免在堆上分配内存,值类型已经在.NET的高性能代码中得到了广泛的应用,虽然频繁的内存拷贝可能带来额外的性能开销。因此编译器对于值类型的各种优化就显得至关重要。 + +这部分优化中一个引人注目的点是值类型的“推广”(promotion)这里的推广意味着将一个结果划分为组成它的各种字段来区别对待。可以利用下面这个示例代码进行理解: + +```csharp +public class Tests +{ + private ParsedStat _stat; + + [Benchmark] + public ulong GetTime() + { + ParsedStat stat = _stat; + return stat.utime + stat.stime; + } + + internal struct ParsedStat + { + internal int pid; + internal string comm; + internal char state; + internal int ppid; + internal int session; + internal ulong utime; + internal ulong stime; + internal long nice; + internal ulong starttime; + internal ulong vsize; + internal long rss; + internal ulong rsslim; + } +} +``` + +在这段代码中有一个较大的结构类型,其的大小是80个字节。在没有启用推广的条件下进行运行,`GetTime`方法编译得到的本机代码如下所示。在汇编代码中将下载栈上分配一片88字节的空间,再将整个结构体直接复制到当前方法的栈上,在复制完成之后计算两个字段的和并返回。 + +```assembly +; Tests.GetTime() + push rdi + push rsi + sub rsp,58 + lea rsi,[rcx+8] + lea rdi,[rsp+8] + mov ecx,0A + rep movsq + mov rax,[rsp+10] + add rax,[rsp+18] + add rsp,58 + pop rsi + pop rdi + ret +; Total bytes of code 40 +``` + +而在打开推广的情况下运行得到的本机代码如下所示: + +```assembly +; Tests.GetTime() + add rcx,8 + mov rax,[rcx+8] + mov rcx,[rcx+10] + add rax,rcx + ret +; Total bytes of code 16 +``` + +在这段汇编代码中,JIT编译器只复制了两个需要使用的字段到当前方法的栈上,这就大幅减少了值类型在方法调用之前产生内存复制开销。 + +## 还有更多…… + +行文至此,本篇已经字数超过一万字了,毫无疑问这将成为博客历史上最长的一篇文章。在这点字数中我们还只是**简略**的介绍了一下.NET平台过去的几个版本中涉及到的优化,还主要聚焦于JIT编译器和内存管理的部分,在这两个部分之后还有一个线程管理部分也是影响性能的关键组件,同时.NET还提供了一个由数千个API组成的运行库,这些类型中无论是基元类型还是泛型集合类型都获得了若干提升,这些部分共同组成了这几个版本的性能奇迹。 + +本篇文章中的主要内容来自于.NET运行时仓库中的[Book of the Runtime](https://github.com/dotnet/runtime/blob/main/docs/design/coreclr/botr/README.md)和微软开发者博客上的[Performance Improvements in .NET 6](https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-6/)、[Performance Improvements in .NET 7](https://devblogs.microsoft.com/dotnet/performance_improvements_in_net_7/)和[Performance Improvements in .NET 8](https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-8/#whats-next)等几篇文章,上述没有覆盖到的内容推荐读者这些文章。同时算算时间,.NET 9版本引入的性能提升文章应该也要发布了。 + +回到文章最开始时的问题:JIT编译就一定比AOT编译慢吗?从启动速度上来说,JIT编译当然是完败AOT编译,但是在程序长时间运行,各项设备(JIT编译器、运行时和GC等)预热完成之后,则是鹿死谁手,犹未可知了。 + diff --git a/YaeBlog/source/posts/dotnet-performance-8/HeapsWhereNetObjectsLive.png b/YaeBlog/source/posts/dotnet-performance-8/HeapsWhereNetObjectsLive.png new file mode 100644 index 0000000..f313abe --- /dev/null +++ b/YaeBlog/source/posts/dotnet-performance-8/HeapsWhereNetObjectsLive.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:51d6acd5b3bb8134b3a56602328f948e391679492753934d7514a2ff10851d25 +size 21885 diff --git a/YaeBlog/source/posts/dotnet-performance-8/image-20240828135354598.png b/YaeBlog/source/posts/dotnet-performance-8/image-20240828135354598.png new file mode 100644 index 0000000..aa123bb --- /dev/null +++ b/YaeBlog/source/posts/dotnet-performance-8/image-20240828135354598.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6274b29f3b6dd75841ad0c36f2595403f9f326f2c90ed5ef062a3366a4f3fd9c +size 62003 diff --git a/YaeBlog/source/posts/dotnet-performance-8/image-20240828155556375.png b/YaeBlog/source/posts/dotnet-performance-8/image-20240828155556375.png new file mode 100644 index 0000000..763322f --- /dev/null +++ b/YaeBlog/source/posts/dotnet-performance-8/image-20240828155556375.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f38ca49381035d50cea7549223f5b2b2f4ea27eebb286920b67b6f3784a8982b +size 38001 diff --git a/YaeBlog/source/posts/llvm-naive-0.md b/YaeBlog/source/posts/llvm-naive-0.md new file mode 100644 index 0000000..100a501 --- /dev/null +++ b/YaeBlog/source/posts/llvm-naive-0.md @@ -0,0 +1,272 @@ +--- +title: LLVM入门笔记 +date: 2024-08-25T17:19:45.6572088+08:00 +tags: +- 编译原理 +- LLVM +- 技术笔记 +--- + +为什么说LLVM是神? + + + +LLVM是一系列模块化的编译器和工具链技术。虽然名字中有着VM两个字母,但是项目实际上和传统意义上的虚拟机已经没有太多的关系了,LLVM仅作为一个项目代号存在。 + +LLVM最开始伊利诺伊大学的一个研究项目,最初的目标是为任意语言的静态编译和动态编译提供一种现代的、基于静态单赋值的编译策略。从那之后,LLVM逐渐发展为了一系列相互关联项目的集合,主要的LLVM子项目有: + +- LLVM核心项目库。这个库围绕一系列**良好定义**的中间表示形式(LLVM IR)构建,提供了一个独立于源代码和目标机器的优化器和一个支持许多CPU架构的代码生成器。 +- Clang。一个LLVM原生实现的C/C++/Objective-C编译器,致力于提供极快的编译速度,已读的错误和警告信息和一个用于开发源代码级别工具的开发平台。Clang静态分析工具和Clang Tidy代码格式化工具都是使用Clang为基础开发工具的优秀样例。 +- LLDB。基于LLVM和Clang的调试器。 +- `libc++`和`libc++ ABI`,C++标准库的实现。 +- `compiler-rt`没看懂,感觉像是为了调试设计的运行时库。 +- MLIR。一个为了构建可扩展、可复用编译基础设施的新颖尝试。需要指出的是MLIR是Multi Level Intermediate Representation的缩写,和机器学习似乎没有太大的关系。 +- `OpenMP`。提供了并行计算框架OpenMP的一个Clang实现。 +- `polly`。提供了自动并行化和自动向量化的缓存本地化优化器。 +- `libclc`。OpenCL异构计算框架的标准库实现。 +- `klee`。可以遍历程序中的所有动态路径以发现问题和验证程序功能的符号虚拟机(Symbolic Virtual Machine)。 +- `LLD`。一个新的链接器 +- `BOLT`。一个在链接之后工作的优化器。通过采样分析器得到的数据优化二进制程序的布局来提升运行效率的工具。 + +因此,在学习LLVM的过程中我们将重点关注LLVM的核心库,主要学习同LLVM IR交互和编写优化的方式。 + +## LLVM提供的工具使用 + +在ArchLinux上安装`llvm`和`clang`两个包就可以使用大多数LLVM提供的工具了。在这段中使用一段简单的C语言代码为例子演示各种工具链的使用。 + +```c +#include + +int main() +{ + printf("Hello, LLVM.\n"); + return 0; +} + +``` + +将上述文件保存为C语言源文件`hello.c`。首先,使用`clang`将源代码编译为可执行文件: + +```shell +clang hello.c -o hello +``` + +> 在默认情况下的`clang`行为和`gcc`编译器的行为表现一致,使用`-S`和`-c`参数能生成原生的汇编代码文件和可重定位文件。 + +编译完成之后可以正常执行: + +![image-20240819213039409](./llvm-naive-0/image-20240819213039409.png) + +然后尝试将这个C语言文件编译为LLVM的字节码形式: + +```shell +clang -O3 -emit-llvm hello.c -c -o hello.bc +``` + +当使用`-emit-llvm`参数之后,使用`-S`选项将产生LLVM中间代码的文本形式`.ll`,使用`-c`选项将产生LLVM中间代码的字节码形式。上面这行命令就是编译为了LLVM的字节码形式。 + +得到字节码形式之后,可以使用JIT编译器直接执行: + +```shell +lli hello.bc +``` + +![image-20240819213624927](./llvm-naive-0/image-20240819213624927.png) + +可以使用反编译器将字节码转换为人类可读的文本形式: + +```shell +llvm-dis < hello.bc > hello.ll +``` + +得到的文件内容为: + +```ir +; ModuleID = '' +source_filename = "hello.c" +target datalayout = "e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-f80:128-n8:16:32:64-S128" +target triple = "x86_64-pc-linux-gnu" + +@str = private unnamed_addr constant [13 x i8] c"Hello, LLVM.\00", align 1 + +; Function Attrs: nofree nounwind sspstrong uwtable +define dso_local noundef i32 @main() local_unnamed_addr #0 { + %1 = tail call i32 @puts(ptr nonnull dereferenceable(1) @str) + ret i32 0 +} + +; Function Attrs: nofree nounwind +declare noundef i32 @puts(ptr nocapture noundef readonly) local_unnamed_addr #1 + +attributes #0 = { nofree nounwind sspstrong uwtable "min-legal-vector-width"="0" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="x86-64" "target-features"="+cmov,+cx8,+fxsr,+mmx,+sse,+sse2,+x87" "tune-cpu"="generic" } +attributes #1 = { nofree nounwind } + +!llvm.module.flags = !{!0, !1, !2, !3} +!llvm.ident = !{!4} + +!0 = !{i32 1, !"wchar_size", i32 4} +!1 = !{i32 8, !"PIC Level", i32 2} +!2 = !{i32 7, !"PIE Level", i32 2} +!3 = !{i32 7, !"uwtable", i32 2} +!4 = !{!"clang version 18.1.8"} +``` + +也可以编译器将字节码转换为汇编代码: + +```shell +llc hello.bc -o hello.s +``` + +```assembly + .text + .file "hello.c" + .globl main # -- Begin function main + .p2align 4, 0x90 + .type main,@function +main: # @main + .cfi_startproc +# %bb.0: + pushq %rax + .cfi_def_cfa_offset 16 + movl $.Lstr, %edi + callq puts@PLT + xorl %eax, %eax + popq %rcx + .cfi_def_cfa_offset 8 + retq +.Lfunc_end0: + .size main, .Lfunc_end0-main + .cfi_endproc + # -- End function + .type .Lstr,@object # @str + .section .rodata.str1.1,"aMS",@progbits,1 +.Lstr: + .asciz "Hello, LLVM." + .size .Lstr, 13 + + .ident "clang version 18.1.8" + .section ".note.GNU-stack","",@progbits +``` + +可以使用`gcc`编译器将这段汇编代码转换为可执行文件。 + +下面这张图展示了LLVM中各种文件的转换关系和使用的对应工具。 + +![image-20240820221413791](./llvm-naive-0/image-20240820221413791.png) + +## LLVM IR + +LLVM中间语言是一个基于静态单赋值的,类型安全的低级别中间表示形式。中间语言一般情况下有三种表示形式:内存中的数据结构、便于JIT编译器解析执行的字节码形式和人类可读的文本形式。 + +> **良好定义(Well formed)** 的中间语言:中间语言可以是在语义上没有问题的,但是并不是良好定义的。例如: +> +> ``` +> %x = add i32 1, %x +> ``` +> +> 这段IR在语法上没有任何问题,但是变量`%x`的定义并不在所有的使用之前。 +> +> LLVM提供了一个Pass在运行所有的优化之前验证输入的IR是否是良好定义的。 + +### 语法 + +#### 标识符 + +LLVM的标识符有两种基本的类型,全局符号和本地符号。全局符号以`@`开头,本地符号以`%`开头。另外标识符的命名也有着三种不同的规则: + +- 命名变量:有字符开头的字符串作为名称 +- 未命名变量:由无符号整数作为名称 +- 常量:在常量章节介绍 + +LLVM中的关键词同其他语言中的关键词也非常类似,例如对于不同操作的关键词`add`、`bitcast`和`ret`等,对于各种基元类型的`void`、`i32`等。 + +下面是一段将命名变量`%X`乘以8的代码: + +``` +%result = mul i32 %X, 8 +``` + +可以对这种代码优化为如下的代码: + +``` +%0 = add i32 %X, %X ; yields i32:%0 +%1 = add i32 %0, %0 ; yields i32:%1 +%result = add i32 %1, %1 +``` + +从这段优化之后的代码中我们可以发现: + +- 使用`;`字符开始一个单行的注释。 +- 未命名变量(临时变量)是在在计算中结果还没有传递给一个命名变量时存储中间结果使用的。 +- 默认情况下未命名变量的名称就是一个从0开始的自增变量。 + +#### 常量字符串 + +使用双引号定义一个常量字符串。使用`\`定义字符串中的转义字符: + +- `\\`表示一个实际的`\`字符。 +- `\`后面接两个十六进制的整数表示对应的字符,例如`\00`表示一个空字符。 + +### 高级别表示 + +这里使用LLVM在Rust中的高级别封装[inkwell](https://github.com/TheDan64/inkwell)示范如何使用LLVM IR编写一个简单的程序。在这个程序中涉及到LLVM几个重要的基础概念。 + +- `context`,LLVM中的上下文。这个对象中保存了LLVM IR中的一些重要全局状态,借助这个变量我们可以方便的将LLVM并行运行起来。 +- `module`,LLVM的模块,一个编译的单元,可以包含各种函数和全局变量。 +- `type`,LLVM中对于数据类型的抽象,通过基础类型的各种组合可以构建出更复杂的类型,例如函数和结构体。 +- `function`:LLVM中的函数,函数需要通过函数类型和名称来定义,函数类型需要通过输入参数类型和返回类型来定义。函数中可以通过附加上基本块来定义函数的实现。 +- `basic_block`:LLVM中的基本块,组成控制流的基本单元,中间包含从上到下依次执行的一系列指令序列。 + +```rust + fn main() -> Result<(), Box> { + let context = Context::create(); + let module = context.create_module("main"); + + let puts_function_type = context.i32_type().fn_type( + &[context.ptr_type(AddressSpace::default()).into()], false + ); + let puts_function = module.add_function("puts", puts_function_type, None); + + let main_function_type = context.i32_type().fn_type(&[], false); + let main_function = module.add_function("main", main_function_type, None); + let entry_basic_block = context.append_basic_block(main_function, "entry"); + + let builder = context.create_builder(); + builder.position_at_end(entry_basic_block); + + let hello_string = builder.build_global_string_ptr("Hello, LLVM!\n", "str")?; + + builder.build_call(puts_function, &[hello_string.as_pointer_value().into()], "")?; + builder.build_return(Some(&context.i32_type().const_int(0, false)))?; + + module.print_to_file("hello.ll")?; + println!("{}", module.print_to_string()); + Ok(()) +} +``` + +执行上面的Rust代码,可以得到一段生成的LLVM IR代码: + +```llvm +; ModuleID = 'main' +source_filename = "main" + +@str = private unnamed_addr constant [13 x i8] c"Hello, LLVM!\00", align 1 +@format = private unnamed_addr constant [4 x i8] c"%d\0A\00", align 1 + +declare i32 @puts(ptr) + +declare i32 @printf(ptr, i32, ...) + +define i32 @main() { +entry: + %0 = call i32 @puts(ptr @str) + %1 = call i32 (ptr, i32, ...) @printf(ptr @format, i32 3) + ret i32 0 +} +``` + +使用`lli`解释器可以直接运行这段代码: + +![image-20240825171858276](./llvm-naive-0/image-20240825171858276.png) + diff --git a/YaeBlog/source/posts/llvm-naive-0/image-20240819213039409.png b/YaeBlog/source/posts/llvm-naive-0/image-20240819213039409.png new file mode 100644 index 0000000..39a5267 --- /dev/null +++ b/YaeBlog/source/posts/llvm-naive-0/image-20240819213039409.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ce8ce7601d7b1d041f25d781622657b2864be26f632bf30740f800e3f15ab63d +size 14787 diff --git a/YaeBlog/source/posts/llvm-naive-0/image-20240819213624927.png b/YaeBlog/source/posts/llvm-naive-0/image-20240819213624927.png new file mode 100644 index 0000000..c9f1cdd --- /dev/null +++ b/YaeBlog/source/posts/llvm-naive-0/image-20240819213624927.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb82eae62bca2af0eb0c42c0e46a314a1663a161d5573ccd938a5c1d1916e67e +size 9215 diff --git a/YaeBlog/source/posts/llvm-naive-0/image-20240820221413791.png b/YaeBlog/source/posts/llvm-naive-0/image-20240820221413791.png new file mode 100644 index 0000000..5d1c75b --- /dev/null +++ b/YaeBlog/source/posts/llvm-naive-0/image-20240820221413791.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:18ff733fbd8f02ece52b3b7fd22f2df94b209f11db9af082125ad448bee6cf07 +size 81957 diff --git a/YaeBlog/source/posts/llvm-naive-0/image-20240825171858276.png b/YaeBlog/source/posts/llvm-naive-0/image-20240825171858276.png new file mode 100644 index 0000000..6f13bdf --- /dev/null +++ b/YaeBlog/source/posts/llvm-naive-0/image-20240825171858276.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f198e93178db7a98fd7e2c0ffb3fc387d5b8793b37bed1cd0068d9f177a49ba6 +size 13107