一:背景

1. 讲故事

最近时间相对比较宽裕,多写点文章来充实社区吧,这篇文章主要还是来自于最近遇到的几例线程饥饿
(Task.Result)
引发的一系列的反思和总结,我觉得.NET8容易引发饥饿的原因,更多的在于异步回调之后底层会反复的将结果丢到线程池所致,因为数据进线程池容易,再用线程到池中去捞就没有那么简单了,可能今天的话题比较有争议,当然我个人的思考也不见得一定对,算是给大家提供一个角度吧,话不多说,开干!

二:为什么会容易饥饿

1. 测试代码

为了方便讲述异步回调的路径,这里我用简单的
FileStream
的异步读取来演示,当然实际的场景更多的是网络IO,最后我再上一个 .NET6 和 .NET8 的对比,先看一下参考代码。


    internal class Program
    {
        static void Main(string[] args)
        {
            UseAwaitAsync();

            Console.ReadLine();
        }

        static async Task<string> UseAwaitAsync()
        {
            string filePath = "D:\\dumps\\trace-1\\GenHome.DMP";
            Console.WriteLine($"{DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss:fff")} 请求发起...");
            using (FileStream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 16, useAsync: true))
            {
                byte[] buffer = new byte[fileStream.Length];

                int bytesRead = await fileStream.ReadAsync(buffer, 0, buffer.Length);

                string content = Encoding.UTF8.GetString(buffer, 0, bytesRead);

                var query = $"{DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss:fff")} 获取到结果:{content.Length}";

                Console.WriteLine(query);

                return query;
            }
        }
    }

卦中 await 之后的回调,很多人可能会想当然的以为是
IO线程
一撸到底,其实在.NET8中并不是这样的,它会经历两次 Enqueue 到线程池,步骤如下:

  1. IO线程 封送 event 到 线程池队列。
  2. Worker线程 读取 event 拆解出 ValueTaskSourceAsTask(ReadAsync) 再次入 线程池队列。
  3. Worker线程 读取 ValueTaskSourceAsTask(ReadAsync) 拆解出编译器生成的状态机
    <UseAwaitAsync>d__1
    ,回到用户代码。

这里我姑且定义成三阶段吧,可能有些朋友有点模糊,我画一张简图给大家辅助一下。

代码和图都有了,接下来就是眼见为实的阶段了。

2. 如何眼见为实

这个相对来说比较简单,在合适的位置埋上断点,然后观察线程栈即可。

  1. 观察第一阶段

自 C# 重写了ThreadPool之后,底层会用一个单独的线程轮询IO完成端口队列(GetQueuedCompletionStatusEx),参考代码如下:


    internal sealed class PortableThreadPool
    {
        private unsafe void Poll()
        {
            int num;
            while (Interop.Kernel32.GetQueuedCompletionStatusEx(this._port, this._nativeEvents, 1024, out num, -1, false))
            {
                for (int i = 0; i < num; i++)
                {
                    Interop.Kernel32.OVERLAPPED_ENTRY* ptr = this._nativeEvents + i;
                    if (ptr->lpOverlapped != null)
                    {
                        this._events.BatchEnqueue(new PortableThreadPool.IOCompletionPoller.Event(ptr->lpOverlapped, ptr->dwNumberOfBytesTransferred));
                    }
                }
                this._events.CompleteBatchEnqueue();
            }
            ThrowHelper.ThrowApplicationException(Marshal.GetHRForLastWin32Error());
        }
    }

从卦中看,一旦 GetQueuedCompletionStatusEx 获取到了数据就开始封送 event,并投送到线程池的
高优先级队列
中,我们可以在 UnsafeQueueHighPriorityWorkItemInternal 上下断点即可,然后观察线程栈,截图如下:

  1. 观察第二阶段

当IO线程将数据丢到队列之后,接下来就需要用 Worker线程 去取了,这里就有了一个重大隐患,这个隐患在于如果当前存在线程饥饿,而线程的动态注入又比较慢,所以这个event存在不能及时取出来的情况。

按照模型图描述,这个阶段是从 event 中拆解出 ValueTaskSourceAsTask,这中间还涉及到了 ThreadPoolBoundHandleOverlapped 的解包逻辑,我在上篇
聊一聊 C#异步中的Overlapped是如何寻址的

和大家聊过,这里就不赘述了,接下来在
ManualResetValueTaskSourceCore<TResult>.SignalCompletion()
上下一个断点观察。

上面卦中的
_continuationState
就是最终拆解的 ValueTaskSourceAsTask(ReadAsync),截图如下:

有些朋友可能会有疑惑,
ReadAsync
返回的是
Task<int>
,怎么就变成了
ValueTaskSourceAsTask
呢?这是因为 ReadAsync 的底层做了一个
ValueTask<int> -> Task<int>
的转换,参考代码如下:


        public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
        {
            ValueTask<int> valueTask = this.ReadAsync(new Memory<byte>(buffer, offset, count), cancellationToken);
            if (!valueTask.IsCompletedSuccessfully)
            {
                return valueTask.AsTask();
            }
            return this._lastSyncCompletedReadTask.GetTask(valueTask.Result);
        }

反正不管怎么说,确实是真真切切的再次将数据(ValueTaskSourceAsTask) 丢入了线程池的线程本地队列,可二次丢入又放大了饥饿的风险。

  1. 观察第三阶段

数据进了队列之后,需要线程池线程再次提取,这个逻辑就比较简单了,提取
ValueTaskSourceAsTask
中的延续字段
continuationObject
来解构状态机最终回到用户代码,要想观察直接在用户方法 UseAwaitAsync() 的 await 之后下一个断点即可。

3. .NET6 会这样吗

很多朋友可能会说 .NET8 是这样,那之前的版本也是这样吗? 也有一些朋友可能会说,我的饥饿发生在
网络IO
,并没有看到类似
文件IO
的情况。

在我的dump分析之旅中,确实几乎所有的饥饿都发生在
网络IO
上,并且 .NET6 和 .NET8 在
网络IO
上的行为已经完全不一样了。

  1. .NET6 是IO线程 一撸到底。
  2. .NET8 则需要 Worker线程 做二次处理。

说了这么多,我们上一个
网络IO
的例子,然后观察 .NET6 和 .NET8 在处理回调上的不同,参考代码如下:


    internal class Program
    {
        static async Task Main(string[] args)
        {
            var task = await GetContentLengthAsync("http://baidu.com");

            Console.ReadLine();
        }
        static async Task<int> GetContentLengthAsync(string url)
        {
            using (HttpClient client = new HttpClient())
            {
                var content = await client.GetStringAsync(url);

                Debug.WriteLine($"线程编号:{Environment.CurrentManagedThreadId}, content.length={content.Length}");

                Debugger.Break();

                return content.Length;
            }
        }
    }

  1. .NET6 下的WinDbg观察

从卦中可以看到
tid=10
后面有一个
Threadpool Completion Port
标记,这就表明确实是 IO线程 一撸到底。

  1. .NET8 下的WinDbg观察

从卦中可以看到
tid=9
后面是
Threadpool Worker
标记,这就说明复杂了哈。。。

三:总结

可以肯定的是减少callback重入队列次数可以尽可能的避免线程饥饿,但怎么说呢?
.NET8
的线程池综合性能绝对比
.NET6
要强悍的多,但.NET8中的设计理念可能也不能达到100%的全域领跑,可能在某些1%的场景下还不如 .NET6 的简单粗暴。
图片名称

一、实验原因

CH552或CH549进入USB下载,通常需要两个按键,一个控制电源的通断,一个通过串联电阻(一头接VCC或V33)冷启动时抬高UDP电平。时序上是这样的:断电--按下接UDP的轻触开关--通电--松开接UDP的轻触开关。这样的话,操作上一般需要双手并用,比较麻烦。

二、电路改进

本制作是在前面纯硬件实现CH549或CH552单键进入USB下载模式(详见开源项目
https://oshwhub.com/xxkj2010/single-key-bring-in-circuit-test
)的基础上进行修改的,即利用手头闲置的STC8G1K08写入延时代码,通过一定顺序控制电源输出和UDP电平,从而为CH552或CH549提供进入USB下载模式的时序。

三、板子实物

板子实物图(USB母口未接)如下:

原设计欠缺R5,目前用直插电阻补焊到板子的背面。

与CH549连接的实物图如下:

四、开源项目

开源项目中的线路和PCB已经补上R5,欢迎朋友们直接使用。

开源项目网址:

https://oshwhub.com/xxkj2010/experiment-of-ch549-single-key-e

五、两点说明

1、原设计上的K2和K3是为了测试双按键进入USB下载模式功能的,非必要可以不用焊上。K1为单键进入下载模式轻触开关。

2、鉴于开发板上的电源滤波电容容量各不相同,所以附件提供了两个HEX文件,一般情况下两个都可以用。选择上可以根据板载电容容量选择。如果你的实验板使用大容量电容,则可以选择 时间较长.hex,相反则选择 时间较短.hex。

六、使用方法

打开WCHISPStudio并选择芯片系列,下载接口选择USB,连接好线,按一下K1,即可令CH549或CH552进入USB下载模式。

七、测试效果

image

因个人业余水平有限,电路和源代码仍然有修改和完善的需要,所以敬请各位大佬提出修改意见,谢谢!

2025.1.7

欢迎关注本人公众号,了解更多。
image

1. 多指令流单数据流

多指令流单数据流(Multiple Instruction Stream, Single Data Stream,简称 MISD)是一种处理器设计概念,
它允许处理器在单个时钟周期内从不同的程序流中发射多条指令
。这种设计旨在提高处理器的指令级并行性(Instruction-Level Parallelism, ILP),从而提升性能。下面是多指令流单数据流的一些关键特点:

  1. 指令级并行性
    :MISD 处理器能够识别并行执行来自不同程序流的指令,这些指令流可以是独立的,不会相互干扰。

  2. 指令窗口
    :处理器维护一个较大的指令窗口,用于存储和管理来自不同指令流的指令。

  3. 执行单元
    :MISD 处理器包含多个执行单元,可以同时处理多条指令,这些执行单元可以是专用的,也可以是通用的。

  4. 调度器
    :MISD 处理器需要一个复杂的调度器来管理来自不同指令流的指令,确保指令执行的顺序和数据依赖性得到正确处理。

  5. 分支预测
    :MISD 处理器通常配备先进的分支预测机制,以减少分支指令对性能的影响。

  6. 寄存器重命名
    :为了处理多个指令流,ISD 处理器使用寄存器重命名技术来避免不同指令流之间的寄存器冲突。

  7. 内存访问
    :ISD 处理器可能会对内存访问进行优化,以支持同时从不同的指令流中加载和存储数据。

  8. 应用场景
    :MISD 设计可以用于高性能计算场景,如科学计算、图形处理和人工智能,这些场景可以从指令级并行中获益。

  9. 挑战
    :MISD 处理器面临的挑战包括增加的硬件复杂性、功耗和设计难度。此外,软件编译器需要能够识别和利用指令流之间的并行性。

多指令流单数据流处理器的设计目标是提高指令吞吐量,即单位时间内处理器可以执行的指令数量。这种设计可以提高处理器的峰值性能,但同时也需要复杂的硬件支持和优化的编译器来充分利用其潜力。

它和多指令流多数据流的区别?

多指令流单数据流(Multiple Instruction Stream, Single Data Stream,MISD)和多指令流多数据流(Multiple Instruction Stream, Multiple Data Stream,MIMDS)是两种不同的处理器设计概念,它们在指令和数据流的处理方式上有所区别:

  1. 指令流


    • MISD:处理器可以同时从多个指令流中获取指令,但这些指令流是独立的,不会相互干扰。这意味着处理器可以同时处理来自不同程序或同一程序不同部分的指令。
    • MIMD:处理器同样可以同时从多个指令流中获取指令,但这些指令流之间可能存在数据依赖关系,需要更复杂的协调和调度。
  2. 数据流


    • MISD:尽管处理器可以处理来自多个指令流的指令,但它只有一个数据流,这意味着所有指令流共享相同的数据路径和执行单元。
    • MIMD:处理器有多个数据流,允许同时处理多个数据流,这可以进一步提高并行度,因为不同的数据流可以独立地执行。
  3. 执行单元


    • MISD:可能需要较少的执行单元,因为数据流是单一共用的,执行单元可以被多个指令流共享。
    • MIMD:可能需要更多的执行单元,以支持多个数据流的并行处理。
  4. 调度器复杂性


    • MISD:调度器需要处理来自不同指令流的指令,但数据流的调度相对简单。
    • MIMD:调度器必须处理来自多个指令流的指令以及多个数据流的数据,这增加了调度的复杂性。
  5. 硬件复杂性


    • MISD:硬件设计相对简单,因为数据路径和执行单元是共享的。
    • MIMD:硬件设计更复杂,需要更多的资源来支持多个数据流。
  6. 性能潜力


    • MISD:可以提高指令吞吐量,但可能受限于单个数据流的带宽。
    • MIMD:可以提供更高的性能潜力,因为它可以同时处理更多的数据。
  7. 应用场景


    • MISD:适用于需要高指令吞吐量但数据流依赖性较低的场景。
    • MIMD:适用于需要高并行度和高数据吞吐量的场景,如高性能计算和图形处理。
  8. 编译器支持


    • MISD:编译器需要能够识别和优化来自不同指令流的指令。
    • MIMD:编译器需要能够处理更复杂的数据流依赖和指令调度。

总的来说,MIMDS 设计提供了更高的并行度和性能潜力,但同时也带来了更高的硬件复杂性和设计挑战。MISD 设计则在保持一定程度的并行度的同时,简化了硬件设计和调度器的复杂性。

指令流

在计算机体系结构和编程领域,"指令流"(Instruction Stream)指的是在程序执行过程中,指令按照特定顺序排列的序列。以下是对指令流的详细解释:

  1. 指令序列
    :指令流是由一系列指令组成的序列,这些指令按照程序的逻辑顺序执行。每条指令都包含操作码(Opcode),它指定要执行的操作,以及操作数(Operands),它们是操作的输入。

  2. 程序执行
    :在程序执行过程中,处理器从内存中获取指令,解码指令流中的指令,并按照指令的操作码执行相应的操作。

  3. 控制流
    :指令流中的指令通常包含控制流指令,如分支(Branch)和跳转(Jump),它们改变指令流的执行顺序。

  4. 流水线
    :现代处理器使用流水线技术来提高指令流的执行效率。在流水线中,指令流被分解为多个阶段,允许多个指令同时处于不同的执行阶段。

  5. 指令级并行(ILP)
    :指令级并行是指处理器能够同时发射、执行和完成多条指令的能力。这是通过分析指令流中的指令依赖关系来实现的,允许处理器在不违反程序顺序的情况下执行指令。

  6. 指令缓存
    :为了提高指令流的获取速度,处理器通常使用指令缓存(Instruction Cache)来存储最近或频繁使用的指令。

  7. 指令调度
    :处理器中的调度器负责管理指令流,决定哪些指令可以并行执行,以及处理数据依赖和控制依赖。

  8. 指令集架构(ISA)
    :指令流是基于特定的指令集架构的,它定义了处理器能够理解和执行的指令类型。

指令流是处理器执行程序的基础,它直接影响程序的性能和效率。通过优化指令流的处理,如分支预测、指令级并行和流水线技术,可以显著提高程序的执行速度。

为什么要提高存储器的带宽?带宽是什么,会影响什么?

存储器带宽(Memory Bandwidth)是衡量存储器性能的一个重要指标,它指的是单位时间内存储器能够传输数据的最大量。带宽通常以字节每秒(Bytes per second, Bps)或位每秒(bits per second, bps)来表示。存储器带宽的大小直接影响到计算机系统的性能和响应速度,高带宽意味着系统能够更快地进行数据读写操作,从而提升整体性能。

提高存储器带宽的原因主要包括:

  1. 提升性能
    :增加带宽可以显著提升数据处理能力和运算密集型任务的执行效率。

  2. 满足需求
    :随着应用程序和数据处理需求的增长,对存储器带宽的要求也随之提高。

  3. 减少瓶颈
    :存储器带宽是计算机系统中的一个潜在瓶颈,提高带宽可以减少因存储器速度不足导致的性能瓶颈。

  4. 适应技术进步
    :随着新技术的发展,如DDR5、GDDR6等新一代内存技术,对带宽的要求也在增加。

  5. 优化系统设计
    :在系统设计阶段,考虑到未来可能的性能需求,预先设计较高的带宽可以提供更好的扩展性和适应性。

影响存储器带宽的因素包括:

  • 总线频率
    :内存的工作频率,即内存时钟频率,与带宽成正比。
  • 数据位宽
    :内存数据总线的宽度,即每次传输可以处理的数据位数。
  • 存储体数量
    :在多体并行系统中,增加存储体可以提高带宽。
  • 存储器架构
    :如低位交叉存储器设计,可以提高存储器的数据传输速率。

提高存储器带宽的方法可以采取以下措施:

  1. 缩短存取周期
    :减少存储器完成一次读写操作所需的时间。

  2. 增加存储字长
    :使每个存取周期可读/写更多的二进制位数。

  3. 增加存储体
    :在多体并行系统中,增加存储体可以提高带宽。

  4. 优化内存频率和通道配置
    :提高内存的工作频率和优化通道配置可以增加带宽。

  5. 数据传输优化
    :通过数据压缩与解压缩的优化策略,可以提高数据传输效率。

  6. 使用高速存储器
    :采用更高速的存储器,如NVMe、3D XPoint等。

  7. 合理设置内存通道及总线宽度
    :增加内存通道数量和总线宽度可以提升带宽。

通过这些方法,可以显著提升存储器的数据处理能力,满足现代计算机系统对高速数据传输的需求。


2. 分析下图

g
这两张图片展示了一个简化的计算机系统模型,包括程序计数器(PC)、算术逻辑单元(ALU)、存储器以及指令执行的流程。下面是对每张图片内容的分析:

第一张图片:程序计数器(PC)和存储器的关系

  1. 程序计数器(PC)
    :这是处理器中的一个寄存器,用于存储下一条将要执行的指令的地址。在这张图中,PC 指向存储器中的一个特定地址
    0x2FF0

  2. PC_Offset
    :这可能表示程序计数器的偏移量,用于计算下一条指令的地址。在这个例子中,偏移量可能是指令长度或者是基于当前指令的特定偏移。

  3. 存储器
    :存储器用于存储程序指令和数据。图中显示了程序指令存储在存储器中。

  4. 执行指令
    :图中显示了一个指令
    read a1
    正在被执行。这可能意味着从地址
    a1
    读取数据。

第二张图片:指令执行流程

  1. 指令解码
    :指令首先被解码,以确定其操作码(opcode),这里是
    lw
    ,代表加载字(load word)指令。

  2. 寄存器堆
    :指令中的寄存器部分(如
    a0
    )被写入或读取。在这个例子中,
    a0
    被写入。

  3. ALU(算术逻辑单元)
    :ALU 执行算术或逻辑操作。在这个流程中,ALU 计算
    a1 + 4
    ,这可能是为了计算数据的内存地址。

  4. 存储器
    :ALU 的结果(
    a1 + 4
    )被用来从存储器中读取数据。这里,数据从存储器的地址
    a1 + 4
    被加载。

  5. 数据和存储器
    :显示了数据如何从存储器中被加载到寄存器堆。

这两张图片共同描述了一个典型的指令执行周期,包括指令的获取、解码、执行以及结果的存储。这种模型是理解计算机体系结构中指令如何被处理和执行的基础。


3. 流水线冲突与K级单发射理想流水线

在计算机体系结构中,流水线(Pipelining)是一种提高处理器性能的技术,它允许多个指令在不同的阶段并行执行。流水线中的“冲突”(Hazard)是指在指令执行过程中由于资源竞争或数据依赖而产生的延迟或阻塞。而“K级单发射”(K-out-of-N)是指在流水线中每个时钟周期可以发射的指令数量。

  1. 流水线冲突


    • 数据冲突
      :当多条指令需要访问同一数据或寄存器时,可能会发生冲突。例如,如果一条指令正在写入数据,而另一条指令需要读取相同的数据,这就需要解决数据冲突。
    • 控制冲突
      :分支指令可能会改变程序的执行流程,导致后续指令的执行路径不确定,从而引发控制冲突。
    • 结构冲突
      :当多条指令需要同一功能单元(如ALU)时,可能会发生结构冲突。
  2. K级单发射


    • 这是流水线设计中的一个参数,表示每个时钟周期可以发射的指令数量。例如,如果一个处理器是5级单发射的,那么它每个时钟周期可以发射5条指令。
    • K级单发射可以提高指令吞吐量,即单位时间内处理器可以执行的指令数量。这有助于提高处理器的并行度和性能。
  3. 理想流水线


    • 理想流水线是指没有冲突、延迟或瓶颈的流水线。在这样的流水线中,指令可以无阻碍地通过各个阶段,每个阶段都在处理不同的指令。
    • 理想流水线通常作为性能分析的参考模型,但在实际中很难实现,因为各种冲突和延迟是不可避免的。

在实际的处理器设计中,为了减少冲突和提高性能,会采用多种技术和策略,如:

  • 分支预测
    :提前预测分支指令的结果,减少控制冲突。
  • 乱序执行
    :允许指令乱序执行,但保持程序的正确性。
  • 超标量执行
    :通过超标量发射和执行指令来隐藏延迟。
  • 寄存器重命名
    :通过寄存器重命名技术来解决数据冲突。

这些技术和策略的目的是使流水线尽可能接近理想状态,提高处理器的效率和性能。

K级单发射理想流水线的定义和功能

K级单发射理想流水线是一种理论上的流水线模型,它描述了一个流水线在没有冲突和延迟的理想状态下的执行情况。在这种模型中,每个时钟周期可以发射K条指令,这些指令能够无冲突地顺利通过流水线的各个阶段。以下是K级单发射理想流水线的定义和功能:

  1. 定义
    :K级单发射理想流水线是指在流水线的每个周期内可以发射K条指令,且这些指令在整个流水线中无冲突地顺利执行。

  2. 功能


    • 高吞吐量
      :理想流水线能够在每个周期发射多条指令,从而提高处理器的指令吞吐量。
    • 无冲突执行
      :在理想状态下,流水线的各个阶段都能连续工作,不会出现数据冲突或控制冲突。
    • 无延迟
      :指令在流水线中不会遇到任何延迟,每个阶段都能及时处理指令,没有停顿(stall)。
    • 资源利用
      :流水线的各个功能单元能够被充分利用,没有闲置的资源。
  3. 特点


    • 阶段数相同
      :所有指令经过相同的流水线阶段。
    • 各段时延相同
      :各段传输延迟一致,不存在等待现象。
    • 无资源冲突
      :设计时考虑最慢的处理过程,以避免资源冲突。
  4. 实际应用
    :理想流水线模型通常用于性能分析和理论计算,它提供了一个上限,展示了在没有冲突和延迟的情况下,流水线的最大性能潜力。在实际的处理器设计中,由于数据冲突、控制冲突和结构限制,很难达到这种理想状态。

  5. 性能参数


    • 吞吐率(Throughput)
      :理想情况下,吞吐量可以达到K倍于单发射单数据流的性能。
    • 加速比(Speedup)
      :相对于非流水线(非流水)系统,理想流水线的加速比可以达到K倍。
    • 效率(Efficiency)
      :在理想情况下,流水线的效率可以达到100%。

K级单发射理想流水线的概念有助于理解流水线的潜在性能和设计目标,但在实际应用中,需要通过各种技术和策略(如分支预测、乱序执行、寄存器重命名等)来减少冲突和延迟,以接近理想状态。

纠正自己的一个想法

在一个周期内可以发送多条指令的概念并不是因为存在多个处理器同时工作,而是因为流水线技术允许多个指令在流水线的不同阶段同时进行处理。这里的关键点在于理解流水线的工作原理和多发射(Multi-Issue)的概念:

  1. 流水线(Pipelining)
    :流水线是处理器内部的一种组织方式,它将指令执行过程分解为多个阶段,如取指(Fetch)、解码(Decode)、执行(Execute)、访存(Memory Access)、写回(Write-back)等。每个阶段可以同时处理不同的指令。

  2. 多发射(Multi-Issue)
    :多发射处理器设计允许在一个时钟周期内将多条指令发送到流水线的不同阶段。这意味着在一个周期内,可以有多个指令处于流水线的不同阶段。

  3. 并行性(Concurrency)
    :在多发射流水线中,指令的执行是并行的。例如,当一条指令在执行阶段时,另一条指令可能在解码阶段,而第三条指令可能在取指阶段。这种并行性允许在一个周期内同时处理多条指令。

  4. 资源分配
    :为了实现多发射,处理器需要有足够的硬件资源,如寄存器、执行单元和流水线阶段,来同时处理多条指令。

  5. 指令调度
    :处理器中的调度器负责决定哪些指令可以并行执行,以及它们的执行顺序。这需要复杂的逻辑来确保指令之间的数据依赖和控制依赖得到正确处理。

  6. 乱序执行(Out-of-Order Execution)
    :在一些高级处理器中,指令可以乱序执行,即不按照程序中的顺序执行,而是按照它们可以被处理器执行的顺序执行。这进一步提高了并行性和吞吐量。

  7. 超标量(Superscalar)
    :超标量处理器是一种具有多个执行单元的处理器,它可以在一个周期内执行多条指令,这些指令可能来自不同的流水线阶段。

总结来说,一个周期内可以发送多条指令是因为现代处理器采用了流水线和多发射技术,允许多个指令在流水线的不同阶段同时进行处理,而不是因为存在多个处理器。这种设计提高了处理器的指令吞吐量和性能。

等于在同一个流水线的不同阶段的操作都是基于指令,我之前理解的狭隘了,以为是指单纯的译码或取指的某个阶段


4. 多发射技术与超标量技术

多发射(multiple issue)技术旨在允许处理器在一个时钟周期内译码并发射多条指令到待执行单元中,使得每个周期可以产生多条准备好进入执行阶段的指令

所谓超标量技术,是指处理器拥有多个并行的流水线执行单元,从而使得处理器每个周期可以完成多条指令的执行阶段

多发射技术和超标量技术是现代微处理器设计中用于提高指令吞吐量和性能的两种关键技术。它们允许处理器在单个时钟周期内执行多于一条的指令,从而提高处理器的效率和性能。下面是对这两种技术的详细解释:

  1. 多发射(Multi-Issue)技术


    • 多发射技术指的是在一个时钟周期内,处理器可以同时发射多条指令到不同的执行单元。这种设计允许更充分地利用处理器的硬件资源,提高指令的并行度。
    • 多发射处理器通过增加硬件资源(如更多的译码器、执行单元等)来实现在一个周期内处理多条指令的能力。这需要复杂的调度器来管理指令的发射和执行,以避免资源冲突和数据依赖问题。
    • 多发射技术可以是超标量处理器设计的一部分,但也可以单独实现。它通过在每个周期内发射多条指令,提高了指令级并行(ILP)。
  2. 超标量(Superscalar)技术


    • 超标量技术是一种特定的多发射技术,它涉及到在单个处理器核心内实现多条并行的流水线。超标量处理器在一个时钟周期内可以执行多条指令,这些指令可以来自不同的流水线阶段。
    • 超标量设计通过增加处理器内部的流水线数量,允许同时处理多条指令。这种设计以空间换取时间,即通过增加硬件资源来提高处理器的性能。
    • 超标量处理器的关键特点包括乱序执行(Out-of-Order Execution),即指令可以乱序执行,只要最终结果与顺序执行时相同。这需要复杂的重排序逻辑来确保指令的正确顺序。
  3. 区别


    • 多发射可以泛指任何在一个周期内发射多条指令的技术,而超标量特指具有多条流水线的处理器设计。
    • 超标量技术通常涉及到更复杂的硬件设计,包括乱序执行和动态调度,而多发射技术可能更侧重于增加发射的指令数量。
    • 超标量处理器的性能提升通常比多发射处理器更为显著,因为它涉及到更深层次的并行执行和资源利用。
  4. 实际应用


    • 现代高性能处理器,如Intel和AMD的x86系列,广泛采用了多发射和超标量技术。这些技术使得处理器能够在单个时钟周期内执行多条指令,显著提高了处理器的吞吐吐量和响应速度。
    • 在实际应用中,这些技术允许处理器更有效地处理复杂的计算任务,如图形处理、科学计算和人工智能等。
  5. 挑战


    • 实现多发射和超标量技术需要解决指令之间的数据依赖和控制依赖问题,这可能需要复杂的调度算法和硬件支持。
    • 随着发射数量的增加,处理器的功耗和热量管理也变得更加复杂,需要先进的冷却技术和电源管理策略。

总的来说,多发射和超标量技术是提高处理器性能的重要手段,它们通过在一个时钟周期内执行多条指令来提升处理器的吞吐量和效率。这些技术在现代处理器设计中发挥着核心作用,推动了计算能力的巨大进步。


5. 局部预测器的缺陷

它只考虑了每一条指令各自的局部历史,而没有考虑分支指令之间的相关性

DeepLearning4j (DL4J) 是一个开源的深度学习库,专为 Java 和 Scala 设计。它可以用于构建、训练和部署深度学习模型。以下是关于如何使用 DL4J 的基本指南以及一个简单的模型训练示例。

本例中使用了MNIST数据集,MNIST(modified national institute of standard and technology)数据集是由Yann LeCun及其同事于1994年创建一个大型手写数字数据库(包含0~9十个数字)。MNIST数据集的原始数据来源于美国国家标准和技术研究院(national institute of standard and technology)的两个数据集:special database 1和special database 3。它们分别由NIST的员工和美国高中生手写的0-9的数字组成。原始的这两个数据集由128×128像素的黑白图像组成。LeCun等人将其进行归一化和尺寸调整后得到的是28×28的灰度图像。

DeepLearning4j 使用指南

安装与配置

  1. 环境要求


    • Java Development Kit (JDK) 8 或以上版本
    • Maven(推荐)或 Gradle 用于项目管理
  2. 创建 Maven 项目
    在你的 IDE 中创建一个新的 Maven 项目,并在
    pom.xml
    文件中添加以下依赖:

    <dependencies>
        <!-- DL4J Core -->
        <dependency>
            <groupId>org.deeplearning4j</groupId>
            <artifactId>deeplearning4j-core</artifactId>
            <version>1.0.0-M1.1</version>
        </dependency>
        <!-- ND4J (Numpy for Java) -->
        <dependency>
            <groupId>org.nd4j</groupId>
            <artifactId>nd4j-native-platform</artifactId>
            <version>1.0.0-M1.1</version>
        </dependency>
        <!-- DataVec for data preprocessing -->
        <dependency>
            <groupId>org.datavec</groupId>
            <artifactId>datavec-api</artifactId>
            <version>1.0.0-M1.1</version>
        </dependency>
    </dependencies>
    
  3. 更新 Maven 依赖
    确保你的 IDE 更新了 Maven 依赖,下载所需的库。

简单的模型训练

下面是一个使用 DL4J 训练简单神经网络的示例,目标是对手写数字进行分类(MNIST 数据集)。

代码示例
import org.deeplearning4j.datasets.iterator.impl.MnistDataSetIterator;
import org.deeplearning4j.nn.conf.MultiLayerConfiguration;
import org.deeplearning4j.nn.conf.NeuralNetConfiguration;
import org.deeplearning4j.nn.multilayer.MultiLayerNetwork;
import org.deeplearning4j.optimize.listeners.ScoreIterationListener;
import org.nd4j.linalg.activations.Activation;
import org.nd4j.linalg.dataset.api.iterator.DataSetIterator;
import org.nd4j.linalg.learning.config.Adam;
import org.nd4j.linalg.lossfunctions.LossFunctions;

public class MnistExample {
    public static void main(String[] args) throws Exception {
        // 加载 MNIST 数据集
        DataSetIterator mnistTrain = new MnistDataSetIterator(128, true, 12345);
        
        // 配置神经网络
        MultiLayerConfiguration conf = new NeuralNetConfiguration.Builder()
                .seed(123)
                .updater(new Adam(0.001))
                .list()
                .layer(0, new DenseLayer.Builder().nIn(784).nOut(256)
                        .activation(Activation.RELU)
                        .build())
                .layer(1, new OutputLayer.Builder(LossFunctions.LossFunction.NEGATIVELOGLIKELIHOOD)
                        .activation(Activation.SOFTMAX)
                        .nIn(256).nOut(10).build())
                .build();

        // 创建并初始化网络
        MultiLayerNetwork model = new MultiLayerNetwork(conf);
        model.init();
        model.setListeners(new ScoreIterationListener(100));  // 每100次迭代输出一次分数

        // 训练模型
        for (int i = 0; i < 10; i++) { // 训练10个epoch
            model.fit(mnistTrain);
        }

        System.out.println("训练完成!");

        // 加载 MNIST 测试数据集
        DataSetIterator mnistTest = new MnistDataSetIterator(128, false, 12345);

        // 评估模型
        double accuracy = model.evaluate(mnistTest).accuracy();
        System.out.println("模型准确率: " + accuracy);

        // 保存模型到文件
        File modelFile = new File("mnist_model.zip");
        ModelSerializer.writeModel(model, modelFile, true);
    }
}
代码说明
  1. 加载数据集
    :使用
    MnistDataSetIterator
    加载 MNIST 数据集。
  2. 配置神经网络

    • 使用
      NeuralNetConfiguration.Builder
      构建神经网络配置。
    • 添加输入层(DenseLayer)和输出层(OutputLayer)。
  3. 创建和初始化模型
    :使用
    MultiLayerNetwork
    创建模型并初始化。
  4. 训练模型
    :通过循环调用
    fit()
    方法训练模型。

运行示例

确保你的环境已正确设置,然后运行上述代码。模型将在 MNIST 数据集上进行训练,训练完成后会输出“训练完成!”的信息。

模型评估

在训练完模型后,通常需要对其进行评估,以了解模型在未见数据上的表现。你可以使用测试集来评估模型的准确性和其他性能指标。

保存与加载模型

训练完成后,你可能希望保存模型以便以后使用。DL4J 提供了简单的方法来保存和加载模型。

调整与优化模型

根据评估结果,你可能需要调整模型的超参数或架构。可以尝试以下方法:

  • 增加层数或节点数
    :增加模型的复杂性。
  • 改变学习率
    :试验不同的学习率以找到最佳值。
  • 使用不同的激活函数
    :例如,尝试
    LeakyReLU

    ELU
  • 正则化
    :添加 Dropout 层或 L2 正则化以防止过拟合。

部署模型

如果你打算将模型应用于生产环境,可以考虑将其部署为服务。可以使用以下方式之一:

  • REST API
    :将模型包装为 RESTful 服务,方便客户端调用。
  • 嵌入式应用
    :将模型嵌入到 Java 应用程序中,直接进行预测。

模型的测试

使用 Java 和 DeepLearning4j 来训练自己的手写数字图像(例如 0 到 9 的标准图像)是一个很好的项目。下面是一个简单的步骤指南,帮助你实现这个目标。

步骤概述

  1. 准备数据
    :将你的数字图像准备为合适的格式。
  2. 创建和配置模型
    :使用 DeepLearning4j 创建神经网络模型。
  3. 训练模型
    :使用你的图像数据训练模型。
  4. 评估和测试模型
    :验证模型的性能。

1. 准备数据

首先,你需要将你的 0-9 数字图像准备好。假设你有 10 张图像,每张图像都是 28x28 像素的灰度图像,并且它们存储在本地文件系统中。

模型测试的步骤

步骤 1: 使用 MNIST 数据集训练模型

  1. 加载数据集
    :使用
    MnistDataSetIterator
    加载 MNIST 数据集。
  2. 构建模型
    :根据你的需求,构建一个适合的神经网络模型。
  3. 训练模型
    :使用 MNIST 数据集对模型进行训练。
  4. 保存模型
    :将训练好的模型保存到文件中(例如,保存为
    .zip
    文件)。

步骤 2: 准备手写数字图片

  1. 手写数字
    :自己手写一个数字 1,并拍照或扫描成图片。
  2. 预处理图片

    • 将图片转换为灰度图像。
    • 调整图片大小为 28x28 像素(MNIST 数据集中的标准尺寸)。
    • 对图像进行归一化处理(通常将像素值缩放到 [0, 1] 范围内)。

步骤 3: 比较手写数字与 MNIST 数据集

  1. 加载保存的模型
    :从 zip 文件中加载之前训练好的模型。
  2. 预测手写数字
    :将预处理后的手写数字图片输入到模型中进行预测。
  3. 输出结果
    :模型将输出手写数字的预测结果。你可以将这个结果与 MNIST 数据集中相应的标签进行比较。

注意事项

  • 数据预处理
    :确保手写数字的预处理方式与训练时一致,包括图像大小、颜色通道和归一化。
  • 模型评估
    :在比较之前,可以先在测试集上评估模型的性能,以确保其准确性。
  • 可视化结果
    :可以通过可视化工具(如 matplotlib)展示手写数字及其预测结果,以便更好地理解模型的表现。

示例代码

以下是一个简单的示例代码框架,展示了如何实现这些步骤
[MnistUtils.java]

/**
 * @author lind
 * @date 2025/1/7 14:27
 * @since 1.0.0
 */
public class MnistUtils {
    /**
     * 将图像转换为灰度图像
     *
     * @param original
     * @return
     */
    private static BufferedImage convertToGrayscale(BufferedImage original) {
        BufferedImage grayImage = new BufferedImage(original.getWidth(), original.getHeight(), BufferedImage.TYPE_BYTE_GRAY);
        Graphics g = grayImage.getGraphics();
        g.drawImage(original, 0, 0, null);
        g.dispose();
        return grayImage;
    }

    /**
     * 调整图像大小
     *
     * @param original
     * @param width
     * @param height
     * @return
     */
    private static BufferedImage resizeImage(BufferedImage original, int width, int height) {
        Image scaledImage = original.getScaledInstance(width, height, Image.SCALE_SMOOTH);
        BufferedImage resizedImage = new BufferedImage(width, height, BufferedImage.TYPE_BYTE_GRAY);
        Graphics2D g2d = resizedImage.createGraphics();
        g2d.drawImage(scaledImage, 0, 0, null);
        g2d.dispose();
        return resizedImage;
    }

    /**
     * 加载图像
     *
     * @param fileName
     * @return
     */
    public static INDArray loadGrayImg(String fileName) {
        try {
            // 1. 加载图片
            BufferedImage originalImage = ImageIO.read(new File(fileName));
            // 2. 转换为灰度图像
            BufferedImage grayImage = convertToGrayscale(originalImage);
            // 3. 调整大小为 28x28 像素
            BufferedImage resizedImage = resizeImage(grayImage, 28, 28);
            // 4. 进行归一化处理
            return normalizeImage(resizedImage);
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }

    /**
     * 对图像进行归一化处理并生成 INDArray
     *
     * @param image
     * @return
     */
    private static INDArray normalizeImage(BufferedImage image) {
        int width = image.getWidth();
        int height = image.getHeight();
        double[] normalizedData = new double[width * height]; // 创建一维数组

        for (int y = 0; y < height; y++) {
            for (int x = 0; x < width; x++) {
                // 获取灰度值(0-255)
                int grayValue = image.getRGB(x, y) & 0xFF; // 只取灰度部分
                // 归一化到 [0, 1] 范围
                normalizedData[y * width + x] = grayValue / 255.0; // 填充一维数组
            }
        }

        // 将一维数组转换为 INDArray,并添加批次维度
        INDArray indArray = Nd4j.create(normalizedData).reshape(1, 784); // reshape to [1, 784]
        return indArray;
    }
}

[MnistTest.java]

public static void main(String[] args) throws IOException {
        // 加载已训练的模型
        MultiLayerNetwork model = MultiLayerNetwork.load(new File("E:\\github\\lind-deeplearning4j\\mnist_model.zip"), true);
        // 测试图像路径
        String testImagePath = "d:\\dlj4\\img\\";
        // 假设你有10个测试图像,命名为 0.png 到 9.png,当我从MNIST数据集网站下载9张图片后,这个大模型确实可以给我识别出来
        for (int i = 0; i <= 3; i++) {
            String fileName = testImagePath + i + ".png";
            System.out.println("fileName=" + fileName);
            INDArray testImage = loadGrayImg(fileName);
            INDArray output = model.output(testImage); // 进行预测

            // 获取预测结果
            int predictedClass = Nd4j.argMax(output, 1).getInt(0);
            System.out.println("测试图像 " + i + " 的预测结果: " + predictedClass);
        }
    }

模型测试结果,
它会根据0-3的图片,将图片上面的数字分析出来,这个事实上是根据我们训练的MINIST数据集得到的结果

当数据量比较大时,使用常规的方式来判重就不行了。例如,使用 MySQL 数据库判重,或使用 List.contains() 或 Set.contains() 判重就不行了,因为数据量太大会导致内存放不下,或查询速度太慢等问题。

1.空间占用量预测

正常情况下,如果将 40 亿 QQ 号存储在 Java 中的 int 类型的话,一个 int 占 4 字节(byte)那么 40 亿占用空间大小为:

4000000000*4/1024/1024/1024=14.9 GB

1GB=1024MB,1MB=1024KB,1KB=1024B(byte)

所以,我们无法使用正常的手段进行 40 亿 QQ 号的存储和去重判断,那怎么实现呢?

2.解决方案

此问题的常见解决方案有两种:

  1. 使用位数组 BitMap 实现判重。
  2. 使用
    布隆过滤器
    实现判重。

具体来说。

2.1 位数组实现判重

位数组是指使用位(bit)组成的数组,每个 QQ 号使用 1 位(bit)来存储,如下图所示:

其中下标用来标识具体的数字,例如以上图片标识 1、3 数字存在,如果值为 0 表示不存在,这样的话 40 亿占用的位数组空间位 40 亿 bit,也就是 4000000000/1024/1024/1024/8=0.465 GB,不到 1G 的内存就可以存储 40 亿 QQ 号了,查询某个 QQ 号是否在线,只需要看这个 QQ 下标对应的位置是否为 1,1 表示存在,0 表示不存在。

位数组代码实现

位数组可以使用 Java 自带的 BitSet 来实现,它位于 java.util 包中,具体实现代码如下:

import java.util.BitSet;

public class BitmapExample {
    public static void main(String[] args) {
        // 创建一个BitSet实例
        BitSet bitmap = new BitSet();

        // 设置第5个位置为1,表示第5个元素存在
        bitmap.set(5);

        // 检查第5个位置是否已设置
        boolean exists = bitmap.get(5);
        System.out.println("Element exists: " + exists);  // 输出: Element exists: true

        // 设置从索引10到20的所有位置为1
        bitmap.set(10, 21);  // 参数是包含起始点和不包含终点的区间

        // 计算bitset中所有值为1的位的数量,相当于计算设置了的元素个数
        int count = bitmap.cardinality();
        System.out.println("Number of set bits: " + count);

        // 清除第5个位置
        bitmap.clear(5);

        // 判断位图是否为空
        boolean isEmpty = bitmap.isEmpty();
        System.out.println("Is the bitset empty? " + isEmpty);
    }
}

2.2 布隆过器实现

布隆过滤器是基于位数组实现的,它是一种高效的数据结构,由布隆在 1970 年提出。
它主要用于判断一个元素可能是否存在于集合中
,其核心特性包括高效的插入和查询操作,但
存在一定的假阳性(False Positives)
可能性。

布隆过滤器实现如下图所示:

根据 key 值计算出它的存储位置,然后将此位置标识全部标识为 1(未存放数据的位置全部为 0),查询时也是查询对应的位置是否全部为 1,如果全部为 1,则说明
数据是可能存在的,否则一定不存在

布隆过器特性
:如果布隆过滤器说一个元素不在集合中,那么它一定不在这个集合中;但如果它说一个元素在集合中,则有可能是不存在的(存在误差,假阳性)。

布隆过器代码实现

布隆过滤器的常见实现有以下几种方式:

  1. 使用 Google Guava BloomFilter 实现布隆过滤器,具体实现代码如下:
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
public class BloomFilterExample {
    public static void main(String[] args) {
        // 创建一个布隆过滤器,设置期望插入的数据量为10000,期望的误判率为0.01
        BloomFilter<String> bloomFilter = 
        BloomFilter.create(Funnels.unencodedCharsFunnel(), 10000, 0.01);
        // 向布隆过滤器中插入数据
        bloomFilter.put("data1");
        bloomFilter.put("data2");
        bloomFilter.put("data3");
        // 查询元素是否存在于布隆过滤器中
        System.out.println(bloomFilter.mightContain("data1")); // true
        System.out.println(bloomFilter.mightContain("data4")); // false
    }
}
  1. 使用 Hutool 框架 BitMapBloomFilter 实现布隆过滤器,如下代码所示:
// 初始化
BitMapBloomFilter filter = new BitMapBloomFilter(10);
// 存放数据
filter.add("123");
filter.add("abc");
filter.add("ddd");
// 查找
filter.contains("abc");
  1. 使用 Redisson 框架中的 RBloomFilter 实现布隆过滤器,如下代码所示:
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
RedissonClient redissonClient = Redisson.create(config);
// 创建布隆过滤器,设置名称和期望容量与误报率
RBloomFilter<String> bloomFilter = 
redissonClient.getBloomFilter("myBloomFilter");
bloomFilter.tryInit(10000, 0.03); // 期望容量 10000,误报率 3%
// 添加元素到布隆过滤器
String element1 = "element1";
bloomFilter.add(element1);
// 判断元素是否存在
boolean mightExist = bloomFilter.contains(element1);
System.out.println("元素 " + element1 + " 可能存在: " + mightExist);
String element2 = "element2";
boolean mightExist2 = bloomFilter.contains(element2);
System.out.println("元素 " + element2 + " 可能存在: " + mightExist2);

其中 Google Guava BloomFilter 和 Hutool 框架 BitMapBloomFilter 为单机版的布隆过滤器实现,不适用分布式环境。
分布式环境要使用 Redisson 框架中的 RBloomFilter 来实现布隆过滤器
,因为它的数据是保存在 Redis 中间件的,而中间件天生支持分布式系统。

小结

位数组和布隆过滤器的区别如下:

  • 位数组
    :没有误判,但空间利用率低。
  • 布隆过滤器
    :空间利用率高,但存在对已经存在的数据的误判(不存在的数据没有误判)。

因此,如果对精准度要求高可以使用位数组;如果对空间要求苛刻,可以考虑布隆过滤器。

本文已收录到我的面试小站
www.javacn.site
,其中包含的内容有:场景题、并发编程、MySQL、Redis、Spring、Spring MVC、Spring Boot、Spring Cloud、MyBatis、JVM、设计模式、消息队列等模块。