[2024测01]内存缓存性能突破10亿OPS提升499%

程序员有二十年 2024-09-17 13:32:50

自开启v11以来,NewLife组件对内存使用做了大量优化,大幅降低GC压力。率先开启的基准压测是内存缓存,领略到降低GC所带来的巨大性能提升。

基准测试使用实验室理想环境,代表着各组件所能达到的性能上限,取决于硬件和网络环境等多方因素。基准测试同时给应用优化指明方向。

测试结果

结论:单机性能突破10亿OPS,提升499%

对比:上次压测峰值是1.67亿OPS,2017年12月6日

代码:https://github.com/NewLifeX/X/tree/dev/Test (拉取20240916最新提交)

日志:

13:06:33.453 1 N - Memory性能测试[顺序],批大小[0],逻辑处理器 20 个13:06:33.454 1 N - 测试 100,000,000 项, 1 线程13:06:35.380 1 N - 赋值 耗时 1,919ms 速度 52,110,474 ops13:06:37.179 1 N - 读取 耗时 1,797ms 速度 55,648,302 ops13:06:38.449 1 N - 累加 耗时 1,268ms 速度 78,864,353 ops13:06:39.407 1 N - 删除 耗时 956ms 速度 104,602,510 ops13:06:39.407 1 N - 测试 200,000,000 项, 2 线程13:06:41.191 1 N - 赋值 耗时 1,783ms 速度 112,170,499 ops13:06:42.875 1 N - 读取 耗时 1,682ms 速度 118,906,064 ops13:06:44.146 1 N - 累加 耗时 1,270ms 速度 157,480,314 ops13:06:45.229 1 N - 删除 耗时 1,081ms 速度 185,013,876 ops13:06:45.229 1 N - 测试 400,000,000 项, 4 线程13:06:47.088 1 N - 赋值 耗时 1,858ms 速度 215,285,252 ops13:06:48.774 1 N - 读取 耗时 1,685ms 速度 237,388,724 ops13:06:50.048 1 N - 累加 耗时 1,272ms 速度 314,465,408 ops13:06:50.999 1 N - 删除 耗时 949ms 速度 421,496,311 ops13:06:50.999 1 N - 测试 800,000,000 项, 8 线程13:06:53.035 1 N - 赋值 耗时 2,034ms 速度 393,313,667 ops13:06:55.152 1 N - 读取 耗时 2,114ms 速度 378,429,517 ops13:06:56.653 1 N - 累加 耗时 1,500ms 速度 533,333,333 ops13:06:57.818 1 N - 删除 耗时 1,164ms 速度 687,285,223 ops13:06:57.818 1 N - 测试 2,000,000,000 项, 20 线程13:07:02.036 1 N - 赋值 耗时 4,217ms 速度 474,270,808 ops13:07:05.406 1 N - 读取 耗时 3,369ms 速度 593,647,966 ops13:07:07.385 1 N - 累加 耗时 1,978ms 速度 1,011,122,345 ops13:07:09.230 1 N - 删除 耗时 1,842ms 速度 1,085,776,330 ops13:07:09.230 1 N - 测试 2,000,000,000 项, 64 线程13:07:13.688 1 N - 赋值 耗时 4,456ms 速度 448,833,034 ops13:07:17.445 1 N - 读取 耗时 3,755ms 速度 532,623,169 ops13:07:19.783 1 N - 累加 耗时 2,335ms 速度 856,531,049 ops13:07:23.060 1 N - 删除 耗时 3,275ms 速度 610,687,022 ops13:07:23.060 1 N - 总测试数据:22,000,000,042

压测环境

主机:10代I9,32G内存,硬盘 512G+1T+4T,RTX4060

内存:dotMemory2024 (新生命团队开源授权)

性能:dotTrace2024

关键优化点

性能提升的关键优化点,主要是减少了内存分配,降低了GC压力。下面简单描述几个关键修改点,具体修改可参考相关源代码。

减少字符串拼接

测试过程中,为了读写不同key,需要大量拼接字符串“key+i”。调整为提前初始化keys数组。

// 提前准备Keys,减少性能测试中的干扰var key = "b_";var max = cpu > 64 ? cpu : 64;var maxTimes = times * max;if (!rand) maxTimes = max;_keys = new String[maxTimes];var sb = new StringBuilder();for (var i = 0; i < _keys.Length; i++){ sb.Clear(); sb.Append(key); sb.Append(i); _keys[i] = sb.ToString();}

可变参数导致创建数组

ICache接口的Remove方法,原来有可变参数,既支持删除单个key,也支持删除多个key。大量的使用场景传入单个key,导致大量创建仅有一个元素的字符串数组。增加一个单key的Remove即可,修改后代码如下:

/// <summary>移除缓存项</summary>/// <param name="key">键</param>/// <returns></returns>public abstract Int32 Remove(String key);/// <summary>批量移除缓存项</summary>/// <param name="keys">键集合</param>/// <returns></returns>public abstract Int32 Remove(params String[] keys);

减少装箱

内存缓存MemoryCache内部本质是并行字典,TValue是内嵌类CacheItem,其中使用Object Value保存各种数据引用。在累加测试中,发现数字被保存为Object,每次使用取出来转回来Int64,累加完成后再保存回去。这里产生大量装箱拆箱操作。

优化方案,CacheItem增加一个Int64类型的_valueLong字段,配合TypeCode专门处理数字操作,完全消除装箱与拆箱。同时CacheItem的Get/Set/Visit改为泛型版本,减少Object转换。

启发:不要轻易把普通数据转为Object。

代码:

/// <summary>缓存项</summary>protected CacheItem {/// <summary>数值类型</summary>public TypeCode TypeCode { get; set; }private Int64 _valueLong;private Object? _value;/// <summary>数值</summary>public Object? Value { get => IsInt() ? _valueLong : _value; }/// <summary>过期时间。系统启动以来的毫秒数</summary>public Int64 ExpiredTime { get; set; }/// <summary>是否过期</summary>public Boolean Expired => ExpiredTime <= Runtime.TickCount64;/// <summary>访问时间</summary>public Int64 VisitTime { get; private set; }/// <summary>构造缓存项</summary>/// <param name="value"></param>/// <param name="expire"></param>public CacheItem(Object? value, Int32 expire) => Set(value, expire);/// <summary>设置数值和过期时间</summary>/// <param name="value"></param>/// <param name="expire">过期时间,秒</param>public void Set<T>(T value, Int32 expire) {var type = typeof(T); TypeCode = type.GetTypeCode();if (IsInt()) _valueLong = value.ToLong();else _value = value;var now = VisitTime = Runtime.TickCount64;if (expire <= 0) ExpiredTime = Int64.MaxValue;else ExpiredTime = now + expire * 1000; }/// <summary>设置数值和过期时间</summary>/// <param name="value"></param>/// <param name="expire">过期时间,秒</param>public void Set<T>(T value, TimeSpan expire) {var type = typeof(T); TypeCode = type.GetTypeCode();if (IsInt()) _valueLong = value.ToLong();else _value = value; SetExpire(expire); }/// <summary>设置过期时间</summary>/// <param name="expire"></param>public void SetExpire(TimeSpan expire) {var now = VisitTime = Runtime.TickCount64;if (expire == TimeSpan.Zero) ExpiredTime = Int64.MaxValue;else ExpiredTime = now + (Int64)expire.TotalMilliseconds; }private Boolean IsInt() => TypeCode >= TypeCode.SByte && TypeCode <= TypeCode.UInt64;//private Boolean IsDouble() => TypeCode is TypeCode.Single or TypeCode.Double or TypeCode.Decimal;/// <summary>更新访问时间并返回数值</summary>/// <returns></returns>public T? Visit<T>() { VisitTime = Runtime.TickCount64;if (IsInt()) {// 存入取出相同,大多数时候走这里if (_valueLong is T n) return n;return _valueLong.ChangeType<T>(); }else {var rs = _value;if (rs == ) return default;// 存入取出相同,大多数时候走这里if (rs is T t) return t;// 复杂类型返回空值,避免ChangeType失败抛出异常if (typeof(T).GetTypeCode() == TypeCode.Object) return default;return rs.ChangeType<T>(); } }/// <summary>递增</summary>/// <param name="value"></param>/// <returns></returns>public Int64 Inc(Int64 value) {// 如果不是整数,先转为整数if (!IsInt()) { _valueLong = _value.ToLong(); TypeCode = TypeCode.Int64; }// 原子操作var newValue = Interlocked.Add(ref _valueLong, value); VisitTime = Runtime.TickCount64;return newValue; }/// <summary>递增</summary>/// <param name="value"></param>/// <returns></returns>public Double Inc(Double value) {// 原子操作 Double newValue; Object? oldValue;do { oldValue = _value; newValue = (oldValue is Double n ? n : oldValue.ToDouble()) + value; } while (Interlocked.CompareExchange(ref _value, newValue, oldValue) != oldValue); VisitTime = Runtime.TickCount64;return newValue; }/// <summary>递减</summary>/// <param name="value"></param>/// <returns></returns>public Int64 Dec(Int64 value) {// 如果不是整数,先转为整数if (!IsInt()) { _valueLong = _value.ToLong(); TypeCode = TypeCode.Int64; }// 原子操作var newValue = Interlocked.Add(ref _valueLong, -value); VisitTime = Runtime.TickCount64;return newValue; }/// <summary>递减</summary>/// <param name="value"></param>/// <returns></returns>public Double Dec(Double value) {// 原子操作 Double newValue; Object? oldValue;do { oldValue = _value; newValue = (oldValue is Double n ? n : oldValue.ToDouble()) - value; } while (Interlocked.CompareExchange(ref _value, newValue, oldValue) != oldValue); VisitTime = Runtime.TickCount64;return newValue; } }

使用Span<T>优化数字类型转换

累加测试中用到ToLong。前面使用了字符串,后面累加时会先调用ToLong转为长整型。ToLong的优化对本轮压测帮助较小,但是对其它场景影响重大。

修改前,字符串切分与全角半角转换都会分配堆内存:

// 特殊处理字符串,也是最常见的if (value is String str) {// 拷贝而来的逗号分隔整数 str = str.Replace(",", ); str = ToDBC(str).Trim();return str.IsOrEmpty() ? defaultValue : Int32.TryParse(str, out var n) ? n : defaultValue; }

修改后,使用Span<Char>配合stackalloc栈分配,全程没有GC:

// 特殊处理字符串,也是最常见的if (value is String str) {// 拷贝而来的逗号分隔整数 Span<Char> tmp = stackalloc Char[str.Length];var rs = TrimNumber(str.AsSpan(), tmp);if (rs == 0) return defaultValue;#if NETCOREAPP || NETSTANDARD2_1_OR_GREATERreturn Int32.TryParse(tmp[..rs], out var n) ? n : defaultValue;#elsereturn Int32.TryParse(new String(tmp[..rs].ToArray()), out var n) ? n : defaultValue;#endif }/// <summary>清理整数字符串,去掉常见分隔符,替换全角数字为半角数字</summary>/// <param name="input"></param>/// <param name="output"></param>/// <returns></returns>private static Int32 TrimNumber(ReadOnlySpan<Char> input, Span<Char> output) {var rs = 0;for (var i = 0; i < input.Length; i++) {// 去掉逗号分隔符var ch = input[i];if (ch == ',' || ch == '_' || ch == ' ') continue;// 全角空格if (ch == 0x3000) ch = (Char)0x20;else if (ch is > (Char)0xFF00 and < (Char)0xFF5F) ch = (Char)(input[i] - 0xFEE0);// 数字和小数点 以外字符,认为非数字if (ch is not '.' and (< '0' or > '9')) return 0; output[rs++] = ch; }return rs; }

0 阅读:0

程序员有二十年

简介:感谢大家的关注