开启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;
    }