自开启v11以来,NewLife组件对内存使用做了大量优化,大幅降低GC压力。率先开启的基准压测是内存缓存,领略到降低GC所带来的巨大性能提升。
基准测试使用实验室理想环境,代表着各组件所能达到的性能上限,取决于硬件和网络环境等多方因素。基准测试同时给应用优化指明方向。
测试结果
结论:单机性能突破10亿OPS,提升499%
对比:上次压测峰值是1.67亿OPS,2017年12月6日
程序:https://github.com/NewLifeX/X/releases/tag/Bench2024_MemoryCache (下载运行)
代码: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 ops
13:06:37.179 1 N - 读取 耗时 1,797ms 速度 55,648,302 ops
13:06:38.449 1 N - 累加 耗时 1,268ms 速度 78,864,353 ops
13:06:39.407 1 N - 删除 耗时 956ms 速度 104,602,510 ops
13:06:39.407 1 N - 测试 200,000,000 项, 2 线程
13:06:41.191 1 N - 赋值 耗时 1,783ms 速度 112,170,499 ops
13:06:42.875 1 N - 读取 耗时 1,682ms 速度 118,906,064 ops
13:06:44.146 1 N - 累加 耗时 1,270ms 速度 157,480,314 ops
13:06:45.229 1 N - 删除 耗时 1,081ms 速度 185,013,876 ops
13:06:45.229 1 N - 测试 400,000,000 项, 4 线程
13:06:47.088 1 N - 赋值 耗时 1,858ms 速度 215,285,252 ops
13:06:48.774 1 N - 读取 耗时 1,685ms 速度 237,388,724 ops
13:06:50.048 1 N - 累加 耗时 1,272ms 速度 314,465,408 ops
13:06:50.999 1 N - 删除 耗时 949ms 速度 421,496,311 ops
13:06:50.999 1 N - 测试 800,000,000 项, 8 线程
13:06:53.035 1 N - 赋值 耗时 2,034ms 速度 393,313,667 ops
13:06:55.152 1 N - 读取 耗时 2,114ms 速度 378,429,517 ops
13:06:56.653 1 N - 累加 耗时 1,500ms 速度 533,333,333 ops
13:06:57.818 1 N - 删除 耗时 1,164ms 速度 687,285,223 ops
13:06:57.818 1 N - 测试 2,000,000,000 项, 20 线程
13:07:02.036 1 N - 赋值 耗时 4,217ms 速度 474,270,808 ops
13:07:05.406 1 N - 读取 耗时 3,369ms 速度 593,647,966 ops
13:07:07.385 1 N - 累加 耗时 1,978ms 速度 1,011,122,345 ops
13:07:09.230 1 N - 删除 耗时 1,842ms 速度 1,085,776,330 ops
13:07:09.230 1 N - 测试 2,000,000,000 项, 64 线程
13:07:13.688 1 N - 赋值 耗时 4,456ms 速度 448,833,034 ops
13:07:17.445 1 N - 读取 耗时 3,755ms 速度 532,623,169 ops
13:07:19.783 1 N - 累加 耗时 2,335ms 速度 856,531,049 ops
13:07:23.060 1 N - 删除 耗时 3,275ms 速度 610,687,022 ops
13: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 class 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 == null) 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(",", null);
str = ToDBC(str).Trim();
return str.IsNullOrEmpty() ? 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_GREATER
return Int32.TryParse(tmp[..rs], out var n) ? n : defaultValue;
#else
return 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;
}