2024年4月

NL2SQL实践系列(1):深入解析Prompt工程在text2sql中的应用技巧

NL2SQL基础系列(1):业界顶尖排行榜、权威测评数据集及LLM大模型(Spider vs BIRD)全面对比优劣分析[Text2SQL、Text2DSL]

NL2SQL基础系列(2):主流大模型与微调方法精选集,Text2SQL经典算法技术回顾七年发展脉络梳理

NL2SQL进阶系列(1):DB-GPT-Hub、SQLcoder、Text2SQL开源应用实践详解

NL2SQL进阶系列(2):DAIL-SQL、DB-GPT开源应用实践详解[Text2SQL]

NL2SQL进阶系列(3):Data-Copilot、Chat2DB、Vanna Text2SQL优化框架开源应用实践详解[Text2SQL]

☆☆NL2SQL进阶系列(4):ConvAI、DIN-SQL、C3-浙大、DAIL-SQL-阿里等16个业界开源应用实践详解[Text2SQL]

☆☆NL2SQL进阶系列(5):论文解读业界前沿方案(DIN-SQL、C3-SQL、DAIL-SQL、SQL-PaLM)、新一代数据集BIRD-SQL解读

NL2SQL实践系列(1):深入解析Prompt工程在text2sql中的应用技巧

NL2SQL实践系列(2):2024最新模型实战效果(Chat2DB-GLM、书生·浦语2、InternLM2-SQL等)以及工业级案例教学

NL2SQL任务的目标是将用户对某个数据库的自然语言问题转化为相应的SQL查询。随着LLM的发展,使用LLM进行NL2SQL已成为一种新的范式。在这一过程中,如何利用提示工程来发掘LLM的NL2SQL能力显得尤为重要。

prompt的组成包四个元素:

  • Instruction(指令,必须)
  • Context(上下文信息,可选)
  • Input Data(需要处理的数据,可选)
  • Output Indicator(要输出的类型或格式,可选)

一个面向复杂任务的prompt的一般都包含Instruction,Context,Input Data,Output Indicator。
所以面向大语言模型的开发应用过程就是如下公式:
LMM(Instruction + Context + Input Data + Output Indicator) = Output
prompt engineering 就是写好这四块东西Instruction,Context,Input Data,Output Indicator,让模型的输出Output越准越好

1.text2sql prompt

> prompt = """
>         现在你是一个数据分析师,SQL大神,请根据用户提供的表的信息,以及用户的需求,写出效率最高的SQL,
>         表信息如下:
>             表名:students;
>             字段:id,name,age,location
>         用户需求:统计一下姓名年龄大于23,姓名包含andy且在beijing,的的学生个数。
>         并且要求输出的SQL以#开头,以#结尾,样例如下:
>                 #SELECT * FROM table#
>                 #SELECT COUNT(*) FROM table#
>         注意不需要分析过程,直接给出SQL语句
>        """
> inputttext ="""<human>:
>      {}
> <aibot>:
> """.format(prompt)

输出结果: #SELECT COUNT(*) FROM students WHERE age > 23 AND name LIKE '%andy%' AND location = 'beijing'#

2.大模型text2sql 微调教程

LLM大模型:
https://huggingface.co/baichuan-inc/Baichuan-13B-Chat

训练数据:
https://huggingface.co/datasets/Clinton/Text-to-sql-v1

Baichuan-13B 是由百川智能继 Baichuan-7B 之后开发的包含 130 亿参数的开源可商用的大规模语言模型,在权威的中文和英文 benchmark 上均取得同尺寸最好的效果。本次发布包含有预训练 (Baichuan-13B-Base) 和对齐 (Baichuan-13B-Chat) 两个版本。Baichuan-13B 有如下几个特点:

  • 更大尺寸、更多数据:Baichuan-13B 在 Baichuan-7B 的基础上进一步扩大参数量到 130 亿,并且在高质量的语料上训练了 1.4 万亿 tokens,超过 LLaMA-13B 40%,是当前开源 13B 尺寸下训练数据量最多的模型。支持中英双语,使用 ALiBi 位置编码,上下文窗口长度为 4096。
  • 同时开源预训练和对齐模型:预训练模型是适用开发者的“基座”,而广大普通用户对有对话功能的对齐模型具有更强的需求。因此本次开源同时发布了对齐模型(Baichuan-13B-Chat),具有很强的对话能力,开箱即用,几行代码即可简单的部署。
  • 更高效的推理:为了支持更广大用户的使用,本次同时开源了 int8 和 int4 的量化版本,相对非量化版本在几乎没有效果损失的情况下大大降低了部署的机器资源门槛,可以部署在如 Nvidia 3090 这样的消费级显卡上。
  • 开源免费可商用:Baichuan-13B 不仅对学术研究完全开放,开发者也仅需邮件申请并获得官方商用许可后,即可以免费商用。
数据格式如下:
"""Below are sql tables schemas paired with instruction that describes a task. Using valid SQLite, write a response that appropriately completes the request for the provided tables. ### Instruction: provide the number of patients whose diagnoses icd9 code is 60000? ### Input: CREATE TABLE procedures (\n    subject_id text,\n    hadm_id text,\n    icd9_code text,\n    short_title text,\n    long_title text\n)\n\nCREATE TABLE prescriptions (\n    subject_id text,\n    hadm_id text,\n    icustay_id text,\n    drug_type text,\n    drug text,\n    formulary_drug_cd text,\n    route text,\n    drug_dose text\n)\n\nCREATE TABLE demographic (\n    subject_id text,\n    hadm_id text,\n    name text,\n    marital_status text,\n    age text,\n    dob text,\n    gender text,\n    language text,\n    religion text,\n    admission_type text,\n    days_stay text,\n    insurance text,\n    ethnicity text,\n    expire_flag text,\n    admission_location text,\n    discharge_location text,\n    diagnosis text,\n    dod text,\n    dob_year text,\n    dod_year text,\n    admittime text,\n    dischtime text,\n    admityear text\n)\n\nCREATE TABLE lab (\n    subject_id text,\n    hadm_id text,\n    itemid text,\n    charttime text,\n    flag text,\n    value_unit text,\n    label text,\n    fluid text\n)\n\nCREATE TABLE diagnoses (\n    subject_id text,\n    hadm_id text,\n    icd9_code text,\n    short_title text,\n    long_title text\n) ### Response:SELECT COUNT(DISTINCT demographic.subject_id) FROM demographic INNER JOIN diagnoses ON demographic.hadm_id = diagnoses.hadm_id WHERE diagnoses.icd9_code = "60000" """

训练代码:text2sqlBaichuan13B.py

2.1 姜子牙系列模型

姜子牙通用大模型V1是基于LLaMa的130亿参数的大规模预训练模型,具备翻译,编程,文本分类,信息抽取,摘要,文案生成,常识问答和数学计算等能力。目前姜子牙通用大模型已完成大规模预训练、多任务有监督微调和人类反馈学习三阶段的训练过程。

https://huggingface.co/IDEA-CCNL/Ziya-LLaMA-13B-v1

https://github.com/IDEA-CCNL/Ziya-Coding

https://www.modelscope.cn/models/Fengshenbang/Ziya-LLaMA-13B-v1/summary

继续预训练 Continual pretraining
原始数据包含英文和中文,其中英文数据来自openwebtext、Books、Wikipedia和Code,中文数据来自清洗后的悟道数据集、自建的中文数据集。在对原始数据进行去重、模型打分、数据分桶、规则过滤、敏感主题过滤和数据评估后,最终得到125B tokens的有效数据。
为了解决LLaMA原生分词对中文编解码效率低下的问题,在LLaMA词表的基础上增加了7k+个常见中文字,通过和LLaMA原生的词表去重,最终得到一个39410大小的词表,并通过复用Transformers里LlamaTokenizer来实现了这一效果。
在增量训练过程中,使用了160张40GB的A100,采用2.6M tokens的训练集样本数量和FP 16的混合精度,吞吐量达到118 TFLOP per GPU per second。因此能够在8天的时间里在原生的LLaMA-13B模型基础上,增量训练110B tokens的数据。

  • 多任务有监督微调 Supervised finetuning
    在多任务有监督微调阶段,采用了课程学习(curiculum learning)和增量训练(continual learning)的策略,用大模型辅助划分已有的数据难度,然后通过“Easy To Hard”的方式,分多个阶段进行SFT训练。SFT训练数据包含多个高质量的数据集,均经过人工筛选和校验:
  1. Self-Instruct构造的数据(约2M):BELLE、Alpaca、Alpaca-GPT4等多个数据集
  2. 内部收集Code数据(300K):包含leetcode、多种Code任务形式
  3. 内部收集推理/逻辑相关数据(500K):推理、申论、数学应用题、数值计算等
  4. 中英平行语料(2M):中英互译语料、COT类型翻译语料、古文翻译语料等
  5. 多轮对话语料(500K):Self-Instruct生成、任务型多轮对话、Role-Playing型多轮对话等

Ziya2-13B-Chat采用"<human>:"和"<bot>:"作为用户和模型的角色识别Prompt,使用"\n"分隔不同角色对话内容。 在推理时,需要将"<human>:"和"<bot>:"作为前缀分别拼接至用户问题和模型回复的前面,并使用"\n"串连各对话内容。

Ziya2-13B-Chat adopts "<human>:" and "<bot>:" as the role recognition prompts for users and models, and uses "\n" to separate the contents of different roles. When doing inference, "<human>:" and "<bot>:" need to be concatenated as prefixes in front of the user's question and the model's reply respectively, and "\n" is used to join the contents of each role.

以下为具体使用方法:

Following are the details of how to use it:

from modelscope import AutoTokenizer, AutoModelForCausalLM, snapshot_download
import torch

device = torch.device("cuda")

messages = [{"role": "user", "content": "手机如果贴膜贴了一张防指纹的钢化膜,那屏幕指纹解锁还有效吗?"}]
user_prefix = "<human>:"
assistant_prefix = "<bot>:"
separator = "\n"

prompt = []
for item in messages:
    prefix = user_prefix if item["role"] == "user" else assistant_prefix
    prompt.append(f"{prefix}{item['content']}")
prompt.append(assistant_prefix)
prompt = separator.join(prompt)

model_dir = snapshot_download('Fengshenbang/Ziya2-13B-Chat', revision='master')
model = AutoModelForCausalLM.from_pretrained(model_dir,torch_dtype=torch.bfloat16).to(device)
tokenizer = AutoTokenizer.from_pretrained(model_dir, use_fast=False)
input_ids = tokenizer(prompt, return_tensors="pt").input_ids.to(device)
generate_ids = model.generate(
            input_ids,
            max_new_tokens=512, 
            do_sample = True, 
            top_p = 0.9, 
            temperature = 0.85, 
            repetition_penalty=1.05, 
            eos_token_id=tokenizer.encode("</s>"), 
            )
output = tokenizer.batch_decode(generate_ids)[0]
print(output)

模型部署

import gradio as gr
import os
import gc
import torch


from transformers import AutoTokenizer
#指定环境的GPU,我的环境是2张A100(40GB)显卡,于是我设置了两张卡,也可以一张80GB的A100
os.environ['CUDA_VISIBLE_DEVICES'] = "0,1"
#这个utils文件直接下载官方给的文件即可
from utils import SteamGenerationMixin


class MindBot(object):
    def __init__(self):
    	#这个model_path为你本地的模型路径
        model_path = './ziya_v1.1'
        self.model = SteamGenerationMixin.from_pretrained(model_path, device_map='auto').half()
        self.model.eval()
        
        self.tokenizer = AutoTokenizer.from_pretrained(model_path, use_fast=False)
    
    def build_prompt(self, instruction, history, human='<human>', bot='<bot>'):
        pmt = ''
        if len(history) > 0:
            for line in history:
                pmt += f'{human}: {line[0].strip()}\n{bot}: {line[1]}\n'
        pmt += f'{human}: {instruction.strip()}\n{bot}: \n'
        return pmt
    
    def interaction(
        self,
        instruction,
        history,
        max_new_tokens,
        temperature,
        top_p,
        max_memory=1024
    ):
               
        prompt = self.build_prompt(instruction, history)
        input_ids = self.tokenizer(prompt, return_tensors="pt").input_ids
        if input_ids.shape[1] > max_memory:
            input_ids = input_ids[:, -max_memory:]
            
        prompt_len = input_ids.shape[1]
        # stream generation method
        try:
            tmp = history.copy()
            output = ''
            with torch.no_grad():
                for generation_output in self.model.stream_generate(
                    input_ids.cuda(),
                    max_new_tokens=max_new_tokens, 
                    do_sample=True,
                    top_p=top_p, 
                    temperature=temperature, 
                    repetition_penalty=1., 
                    eos_token_id=2, 
                    bos_token_id=1, 
                    pad_token_id=0
                ):
                    s = generation_output[0][prompt_len:]
                    output = self.tokenizer.decode(s, skip_special_tokens=True)
                    # output = output.replace('\n', '<br>')
                    output = output.replace('\n', '\n\n')
                    tmp.append((instruction, output))
                    yield  '', tmp
                    tmp.pop()
                    # gc.collect()
                    # torch.cuda.empty_cache()
                history.append((instruction, output))
                print('input -----> \n', prompt)
                print('output -------> \n', output)
                print('history: ======> \n', history)
        except torch.cuda.OutOfMemoryError:
            gc.collect()
            torch.cuda.empty_cache()
            self.model.empty_cache()
            history.append((instruction, "【显存不足,请清理历史信息后再重试】"))
        return "", history
        
    def chat(self):
        
        with gr.Blocks(title='IDEA MindBot', css=".bgcolor {color: white !important; background: #FFA500 !important;}") as demo:
            with gr.Row():
                gr.Column(scale=0.25)
                with gr.Column(scale=0.5):
                    gr.Markdown("<center><h1>IDEA Ziya</h1></center>")
                    gr.Markdown("<center>姜子牙通用大模型V1.1是基于LLaMa的130亿参数的大规模预训练模型,具备翻译,编程,文本分类,信息抽取,摘要,文案生成,常识问答和数学计算等能力。目前姜子牙通用大模型已完成大规模预训练、多任务有监督微调和人类反馈学习三阶段的训练过程。</center>")
                gr.Column(scale=0.25)
            with gr.Row():
                gr.Column(scale=0.25)
                with gr.Column(scale=0.5):
                    chatbot = gr.Chatbot(label='Ziya').style(height=500)
                    msg = gr.Textbox(label="Input")
                # gr.Column(scale=0.25)
                with gr.Column(scale=0.25):
                    max_new_tokens = gr.Slider(0, 2048, value=1024, step=1.0, label="Max_new_tokens", interactive=True)
                    top_p = gr.Slider(0, 1, value=0.85, step=0.01, label="Top P", interactive=True)
                    temperature = gr.Slider(0, 1, value=0.8, step=0.01, label="Temperature", interactive=True)
            with gr.Row():
                gr.Column(scale=0.25)
                with gr.Column(scale=0.25):
                    clear = gr.Button("Clear")
                with gr.Column(scale=0.25):
                    submit = gr.Button("Submit")
                gr.Column(scale=0.25)
                
            msg.submit(self.interaction, [msg, chatbot,max_new_tokens,top_p,temperature], [msg, chatbot])
            clear.click(lambda: None, None, chatbot, queue=False)
            submit.click(self.interaction, [msg, chatbot,max_new_tokens,top_p,temperature], [msg, chatbot])
        return demo.queue(concurrency_count=10).launch(share=False,server_name="127.0.0.1", server_port=7886)
        

if __name__ == '__main__':
    mind_bot = MindBot()
    mind_bot.chat()
  • 人类反馈学习 Reinforcement learning from Human Feedback

基于SFT阶段的模型,Ziya2-13B-Chat针对多种问答、写作以及模型安全性的任务上进行了人类偏好的对齐。自行采集了数万条高质量人类偏好数据,使用Ziya2-13B-Base训练了人类偏好反馈模型,在各任务的偏好数据上达到了72%以上的准确率。

  • 效果评估 Performance
    Ziya2-13B-Base在Llama2-13B的基础上进行了约650B自建高质量中英文数据集的继续训练,在中文、英文、数学、代码等下游理解任务上相对于Llama2-13B取得了明显的提升,相对Ziya-LLaMA-13B也有明显的提升。

3.Prompt升级

参考文章:
https://zhuanlan.zhihu.com/p/635799364?utm_id=0

  • 第一版

尽管模型的输出SQL语句本身都是正确的,却存在着一个明显的问题:它会产生多余的输出。具体来说,模型似乎过度地“幻想”了SQL查询的结果,将一些并不属于实际查询结果的数据也一并输出,这导致了信息冗余和不必要的复杂性。

  • 第二版

经过版本升级后,引入了角色扮演的功能,告知模型它现在是一名数据分析师,且精通SQL。然而,尽管模型的输出SQL语句本身是正确的,但结果呈现的方式却不够结构化,这导致它并不适合后续的操作和分析。期望模型仅输出一段单独的、结构清晰的SQL语句,而不是包含多余或复杂化的输出。作为数据分析师,更注重结果的准确性和实用性,因此希望模型能够在这方面进行改进。

  • 第三版

经过进一步的版本升级,增强了模型的输出引导功能,希望它输出的SQL语句能够以“#”开头,并以“#”结尾。然而,发现尽管模型的SQL语句本身是正确的,但其输出结果却存在错误:结尾部分缺少了一个“#”。这导致了输出格式的不一致和潜在的识别问题。期待模型在输出SQL时能够严格遵守规定的格式,确保每个SQL语句都以“#”完整包围,以满足后续处理和分析的需求。

  • 最终版

经过又一次的版本升级,不仅在输出引导方面进行了增强,还提供了具体的示例,以帮助模型更好地理解的期望。这次,欣喜地发现,模型的输出SQL语句完全符合的需求。通过明确的输出引导和示例,模型能够准确地生成结构清晰、格式规范的SQL语句,为后续的数据处理和分析提供了极大的便利。这一改进不仅提升了模型的性能,也进一步提高了的工作效率和准确性。

至此,已深入掌握面向大模型开发的核心技术,学会如何有效利用大模型的强大能力,以应对各类复杂任务。整个过程可细化为以下关键步骤:

  • 首先,需精心构建高质量的prompt。其中,指令(Instruction)的设定至关重要,需精确、明确地传达的任务需求;上下文信息(Context)作为可选内容,有助于模型更全面地理解问题背景;输入数据(Input Data)是模型处理的具体对象,应根据任务特点灵活选择;输出指引(Output Indicator)则用于规定输出结果的类型、格式及精度,确保模型输出符合专业标准。

  • 随后,需要不断迭代与优化prompt。这是一个精细的调试过程,通过对比分析模型的实际输出与预期结果,可以发现prompt中的不足之处,进而针对性地调整其表述和细节。通过多次迭代,可以逐步完善prompt,使模型输出更加精确、全面。

  • 最后,验证prompt的稳定性和准确性是不可或缺的环节。通过大量的测试与验证,可以评估模型在不同情境下的表现,确保其输出的稳定性和可靠性。此外,还需要关注模型的泛化能力,确保其能够应对各种未知或复杂情况。

更多优质内容请关注公号:汀丶人工智能;会提供一些相关的资源和优质文章,免费获取阅读。

技术背景

自动微分技术,在各大深度学习框架里面得到了广泛的应用。但是其实究其原理,就是一个简单的链式法则。要实现一个自动微分框架是非常容易的事情,难的是高阶的自动微分和端到端的自动微分。这篇文章主要介绍一阶自动微分的基础Python实现,以及一些简单的测试案例。

链式法则

求导的链式法则,这个在高数里面大家就都学过了,形式比较简单:

\[f(g(x))'=f'[g(x)]\cdot g'(x)
\]

或者可以写成这种形式:

\[\frac{df}{dx}=\frac{df}{dg}\cdot\frac{dg}{dx}
\]

自动微分框架的使用

我们先用一些现成的自动微分框架,如MindSpore,来演示一下自动微分的基本用法:

import numpy as np
from mindspore import grad, Tensor
from mindspore import numpy as msnp
# 定义一个自变量x
x = Tensor(np.array([1., 2., 3.], np.float32))
# 定义一个复合函数
f = lambda x: msnp.sin(msnp.cos(x))
# 函数求导
gf = grad(f)
# 计算自动微分结果
print (gf(x))
# [-0.7216062  -0.831692   -0.07743199]

这里面的函数定义为:

\[f(x) = \sin(\cos(x))
\]

其导数解析形式为:

\[f'(x)=-\cos(\cos(x))\sin(x)
\]

也可以用MindSpore做一个简单的验证:

print (-msnp.cos(msnp.cos(x))*msnp.sin(x))
# [-0.7216062  -0.831692   -0.07743199]

可以看到结果是一致的。

手搓自动微分

自己实现自动微分,其实就是把每一个操作函数的导数函数定义好,例如我们可以定义某一个操作的求导函数为
__grad__()
,而求值函数在python中有一个内置的
__call__()
函数。例如我们可以基于
numpy
的函数自定义一个正弦函数的类:

import numpy as np
class SIN:
    def __call__(self, x):
        """计算正弦值"""
        return np.sin(x)
    def __grad__(self, x):
        """计算正弦函数的导数值"""
        return np.cos(x)

然后配套一个grad自动微分函数:

def grad(obj):
    """直接调用输入操作的自动微分函数"""
    return obj.__grad__

甚至可以实现一个
value_and_grad
函数,同时计算值和导数:

class ValueAndGrad:
    def __init__(self, obj):
        """初始化输入对象的求值函数和求导函数"""
        self.obj1 = obj
        self.obj2 = obj.__grad__
    def __call__(self, x):
        """用元组的形式将值和导数的计算结果返回"""
        return (self.obj1(x), self.obj2(x))
def value_and_grad(obj):
    """初始化求值求导对象"""
    return ValueAndGrad(obj)

需要注意的是,因为大多数的场景下都会涉及到复合函数的计算,这也是自动微分技术的核心之一,因此我们自己实现的自动微分框架要能够接收一些外来的操作,然后在内部递归的计算。对应的带有自动微分的类格式变为:

class SIN:
    def __init__(self, obj=None):
        """给定一个其他的函数"""
        self.obj = obj
    def __call__(self, x):
        """没有复合函数时直接返回结果,有复合函数就递归计算"""
        return np.sin(x) if self.obj is None else np.sin(self.obj(x))
    def __grad__(self, x):
        """没有复合函数时直接返回导数结果,有复合函数就按照链式法则递归计算"""
        return COS()(x) if self.obj is None else COS()(self.obj(x))*self.obj.__grad__(x)

最终形成的自动微分实现案例为:

import numpy as np
import mindspore as ms
from mindspore import Tensor
from mindspore import grad as msgrad
from mindspore import numpy as msnp

class SIN:
    """自定义正弦类"""
    def __init__(self, obj=None):
        self.obj = obj
    def __call__(self, x):
        return np.sin(x) if self.obj is None else np.sin(self.obj(x))
    def __grad__(self, x):
        return COS()(x) if self.obj is None else COS()(self.obj(x))*self.obj.__grad__(x)
    
class COS:
    """自定义余弦类"""
    def __init__(self, obj=None):
        self.obj = obj
    def __call__(self, x):
        return np.cos(x) if self.obj is None else np.cos(self.obj(x))
    def __grad__(self, x):
        return -SIN()(x) if self.obj is None else -SIN()(self.obj(x))*self.obj.__grad__(x)

class ValueAndGrad:
    """自定义求值求导类"""
    def __init__(self, obj):
        self.obj1 = obj
        self.obj2 = obj.__grad__
    def __call__(self, x):
        return (self.obj1(x), self.obj2(x))

def grad(obj):
    """自定义求导函数"""
    return obj.__grad__

def value_and_grad(obj):
    """自定义求值求导函数"""
    return ValueAndGrad(obj)

# 定义自变量
x = np.array([0., 1., 2., 3.,], np.float32)
# 单体函数验证
assert np.allclose(SIN()(x), np.sin(x))
# 单体函数求导验证
assert np.allclose(grad(SIN())(x), np.cos(x))
v, g = value_and_grad(SIN())(x)
# 单体函数求值求导验证
assert np.allclose(v, np.sin(x))
assert np.allclose(g, np.cos(x))
# 双复合函数验证
assert np.allclose(SIN(SIN())(x), np.sin(np.sin(x)))
assert np.allclose(SIN(COS())(x), np.sin(np.cos(x)))
assert np.allclose(COS(SIN())(x), np.cos(np.sin(x)))
assert np.allclose(COS(COS())(x), np.cos(np.cos(x)))
# 三复合函数验证
assert np.allclose(SIN(COS(SIN()))(x), np.sin(np.cos(np.sin(x))))
# 双复合函数求导验证
assert np.allclose(grad(SIN(SIN()))(x), np.cos(x)*np.cos(np.sin(x)))
tensor_x = Tensor(x, ms.float32)
ms_func1 = lambda x: msnp.sin(msnp.cos(x))
assert np.allclose(grad(SIN(COS()))(x), msgrad(ms_func1)(tensor_x).asnumpy())
ms_func2 = lambda x: msnp.cos(msnp.sin(x))
assert np.allclose(grad(COS(SIN()))(x), msgrad(ms_func2)(tensor_x).asnumpy())
ms_func3 = lambda x: msnp.cos(msnp.sin(msnp.cos(x)))
# 三复合函数求导验证
assert np.allclose(grad(COS(SIN(COS())))(x), msgrad(ms_func3)(tensor_x).asnumpy())

这里面除了可以跟手推的微分解析形式的计算结果进行比对之外,还可以跟MindSpore等自动微分框架计算出来的结果进行比对,可以看到结果都是一致的。

总结概要

不同于符号微分、手动微分和差分法,自动微分方法有着使用简单、计算精度较高、性能较好等优势,因此在各大深度学习框架中得到了广泛的应用。虽然每个框架所使用的自动微分的原理不尽相同,但大致都是基于链式法则计算结合图计算的一些优化。如果是自己动手来手搓一个自动微分框架的话,大致就只能实现一下一阶的链式法则的自动微分。

版权声明

本文首发链接为:
https://www.cnblogs.com/dechinphy/p/auto-grad.html

作者ID:DechinPhy

更多原著文章:
https://www.cnblogs.com/dechinphy/

请博主喝咖啡:
https://www.cnblogs.com/dechinphy/gallery/image/379634.html

前言

类激活图CAM(class activation mapping)用于可视化深度学习模型的感兴趣区域,增加了神经网络的可解释性。现在常用Grad-CAM可视化,Grad-CAM基于梯度计算激活图,对比传统的CAM更加灵活,且不需要修改模型结构。

虽然计算grad-cam并不复杂,但是本着能导包就导包的原则,想着去用现成的库。

pip install grad-cam

官方文档

开源仓库

简单试用

  1. 加载模型和预训练权重

这里使用PyTorch官方提供的在ImageNet上预训练的Resnet50。
注意
:这里使用现成的模型参数,也需要用它们提供的图片预处理方式

from torchvision.models import resnet50, ResNet50_Weights

# 加载ResNet模型和预训练权重
weights = ResNet50_Weights.DEFAULT
model = resnet50(weights=weights)
model.eval()

preprocess = weights.transforms() # 图片预处理方法
  1. 简单读入一张图片

bird

from PIL import Image

src = 'bird.jpg'
img = Image.open(src)
print(f'The Image size:{img.size}')
img_tensor = preprocess(img)
print(f'The shape of image preprocessed: {img_tensor.shape}')

Output

The Image size:(474, 315)
The shape of image preprocessed: torch.Size([3, 224, 224])
  1. 计算Grad-CAM
rom pytorch_grad_cam import GradCAM

grad_cam = GradCAM(model=model, target_layers=[model.layer4[-1]])   
cam = grad_cam(input_tensor=img_tensor.unsqueeze(0)) # 输入的Shape: B x C x H x W

print(f'Cam.shape: {cam.shape}')
print(f'Cam.max: {cam.max()}, Cam.min: {cam.min()}')

Output

Cam.shape: (1, 224, 224)
Cam.max: 0.9999998807907104, Cam.min: 0.0

这里可以看到计算的CAM值的区间是[0, 1],一些处理长尾数据的图像增强的方法,通过CAM的值与原图像相乘,得到图像的主体或背景(上下文)。

  1. 可视化
from pytorch_grad_cam.utils.image import show_cam_on_image
import uuid
import numpy as np
import torch

def vis_cam(cam: np.ndarray, input_tensor: torch.Tensor):
    def normalization(x: np.ndarray, scale=1):   # 归一化
        x_min = np.min(x)
        x_max = np.max(x)
        return (x - x_min) / (x_max - x_min) * scale 
    
    # 底层是cv2实现的所以要求图像形状为 H x W x C
    input_tensor= input_tensor.permute(1, 2, 0).numpy()
    norm_img = normalization(input_tensor)
    
    # 可视化不支持batch,所以要取cam第一个
    vis = show_cam_on_image(norm_img, cam[0], use_rgb=True)
    
    vis_img = Image.fromarray(vis)
    vis_img.save(f'cam_{uuid.uuid1()}.jpg')
    return vis

vis1 = vis_cam(cam, img_tensor)

结果如下,由于图像经过了预处理,size变味了224x224,所以CAM的大小也是这个尺寸。

另外,这个库也提供了其他CAM方法,如
GradCAMElementWise
,与Grad-CAM相似,将激活值与梯度逐元素相乘,然后在求和之前应用 ReLU 运算。但是简单使用后,肉眼没有察觉差异:

from pytorch_grad_cam import GradCAMElementWise
grad_cam = GradCAMElementWise(model=model, target_layers=[model.layer4[-1]])
cam = grad_cam(input_tensor=img_tensor.unsqueeze(0)) # 输入的Shape: B x C x H x W
vis2 = vis_cam(cam, img_tensor)


将它们做一个横向对比,从左至右分别是原图、
GradCAM

GradCAMElementWise

img_hstack = np.hstack([img.resize(size=(224, 224)), vis1, vis2])
Image.fromarray((img_hstack).astype(np.uint8)).save('cam_compare.jpg')            



其他

有一点很重要,但是文中并没有使用,关于
ClassifierOutputTarget
的使用,文档中它的一种用法:

cam = GradCAM(model=model, target_layers=target_layers, use_cuda=args.use_cuda)

targets = [ClassifierOutputTarget(281)]

grayscale_cam = cam(input_tensor=input_tensor, targets=targets)

输入的参数是图片对应的target,也就是one-hot标签里面的1的下标,但由于使用的是预训练模型,所以不知道具体的标签。而当
cam
这里的
targets=None
时,会自动选择得分最高的类。

关于
grad-cam
还有许多功能,这里仅仅介绍了计算cam和可视化的部分。

运行环境

grad-cam                  1.5.0                    pypi_0    pypi
pytorch                   2.2.2           py3.12_cuda12.1_cudnn8_0    pytorch

图像比例尺

使用ImageJ软件测量图像中的长度、面积等信息时,需要先设置图像的比例尺,比例尺用于将图像中的像素单位转换为真实的世界单位。

加载图像

启动ImageJ程序,从
File
菜单选择
Open Samples
,选择
Leaf
打开树叶的示例图像:
image

设置比例尺

将图片适当放大,使用
直线工具
沿着图片中的直尺刻度绘制一条直线,作为标尺参考线。绘制的时候可以按住shift键保证直线水平,直线如下图所示:
image

转到
Analyze
菜单,选择
Set Scale...
,在弹出的窗口中输入以下内容,点击
OK
确认设置:
image

  • Distance in Pixels(像素距离)
    :测量的直线的像素长度,软件自动计算不需要设置。
  • Known distance(已知距离)
    :刚才画出的直线代表的真实世界长度,这里是10 mm。
  • Global(全局)
    :是否应用于整个工作流程,如果有同批次同镜头视野的多张照片可以勾选,这里选择不勾选。
  • Pixel aspect ratio(像素纵横比)
    :图像中单个像素的宽度与高度之间的比率,一般设置为1。
  • Unit of length(长度单位)
    :真实世界的长度单位,这里选择毫米(mm)。

标定后图片大小85.57mmx75.27mm,在
Image
->
Properties...
中可看到像素大小为0.1687764 mm:
image

标注比例尺

可以把比例尺到图片上,方便测量、观察。选择菜单栏的
Analyze
->
Tools
->
Scale Bar
,在弹出的界面中设置比例尺颜色位置、长度、字体大小等参数:
image

设置完成后效果如下,如果不想标注部分破坏原图,保存时要选择TIFF格式:
image

测量长度面积

ImageJ提供了许多测量功能,可以打开菜单的
Analyze
->
Set Measurements…
,在弹出的对话框中查看并勾选希望测量的功能:
image

下面以测量主叶脉长度和叶子面积为例,介绍常用的测量方法。

测量长度

选择直线工具,在主叶脉上画一条直线,然后点击
Analyze
->
Measure
进行测量。测量结果如下,长度为59.646mm:
image

测量面积

测量面积的操作类似,主要差异在测量目标的选中上,大致有两种选中方法:

  • 用多边形工具手动框选测量区域
  • 用魔棒工具抠图选中测量区域(推荐)

选中魔棒工具,在叶子的绿色部分点击一下选中。然后双击魔棒工具,调节阀值直到选中整个叶面:
image

点击
Analyze
->
Measure
,测量结果为 2411.380 mm²。

参考资料

了解托管堆

许多 Unity 开发者面临的另一个常见问题是托管堆的意外扩展。在 Unity 中,托管堆的扩展比收缩容易得多。此外,Unity 的垃圾收集策略往往会使内存碎片化,因此可能阻止大型堆的收缩。

托管堆的工作原理及其扩展原因

“托管堆”是由项目脚本运行时(Mono 或 IL2CPP)的内存管理器自动管理的一段内存。必须在托管堆上分配托管代码中创建的所有对象(2)(__注意:__严格来说,必须在托管堆上分配所有非 null 引用类型对象和所有装箱值类型对象)。

在上图中,白框表示分配给托管堆的内存量,而其中的彩色框表示存储在托管堆的内存空间中的数据值。当需要更多值时,将从托管堆中分配更多空间。

垃圾回收器定期运行(3)(__注意:__具体运行时间视平台而定)。这时将扫描堆上的所有对象,将任何不再引用的对象标记为删除。然后会删除未引用的对象,从而释放内存。

至关重要的是,Unity 的垃圾收集(使用
Boehm GC 算法
)是非分代的,也是非压缩的。“非分代”意味着 GC 在执行每遍收集时必须扫描整个堆,因此随着堆的扩展,其性能会下降。“非压缩”意味着不会为内存中的对象重新分配内存地址来消除对象之间的间隙。

上图为内存碎片化示例。释放对象时,将释放其内存。但是,释放的空间不会整合成为整个“可用内存”池的一部分。位于释放的对象两侧的对象可能仍在使用中。因此,释放的空间成为其他内存段之间的“间隙”(该间隙由上图中的红色圆圈指示)。因此,新释放的空间仅可用于存储与释放相同大小或更小的对象的数据。

分配对象时,请注意对象在内存空间中的分配地址必须始终为连续空间块。

这导致了内存碎片化这个核心问题:虽然堆中的可用空间总量可能很大,但是可能其中的部分或全部的可分配空间对象之间存在小的“间隙”。这种情况下,即使可用空间总量高于要分配的空间量,托管堆可能也找不到足够大的连续内存块来满足该分配需求。

但是,如果分配了大型对象,却没有足够的连续可用空间来容纳该对象(如上所示),Unity 内存管理器将执行两个操作。

首先,如果垃圾回收器尚未运行,则运行垃圾回收器。此工具会尝试释放足够的空间来满足分配请求。

如果在 GC 运行后,仍然没有足够的连续空间来满足请求的内存量,则必须扩展堆。堆的具体扩展量视平台而定;但是,大多数 Unity 平台会使托管堆的大小翻倍。

堆的主要问题

托管堆扩展方面的核心问题有两个:

  • Unity 在扩展托管堆后不会经常释放分配给托管堆的内存页;它会乐观地保留扩展后的堆,即使该堆的大部分为空时也如此。这是为了防止再次发生大量分配时需要重新扩展堆。

  • 在大多数平台上,Unity 最终会将托管堆的空置部分使用的页面释放回操作系统。发生此行为的间隔时间是不确定的,因此不要指望靠这种方法释放内存。

  • 托管堆使用的地址空间始终不会归还给操作系统。

  • 对于 32 位程序,如果托管堆多次扩展和收缩,则可能导致地址空间耗尽。如果一个程序的可用内存地址空间已耗尽,操作系统将终止该程序。

  • 对于 64 位程序而言,地址空间足够大到可以运行时间超过人类平均寿命的程序,因此地址空间耗尽的这种情况极几乎不可能发生。

临时分配

许多 Unity 项目在每帧都有几十或几百 KB 的临时数据分配给托管堆。这种情况通常对项目的性能极为不利。请考虑以下数学计算:

如果一个程序每帧分配一千字节 (1 KB) 的临时内存,并且以每秒 60 帧的速率运行,那么它必须每秒分配 60 KB 的临时内存。在一分钟内,这会在内存中增加 3.6 MB 的垃圾。每秒调用一次垃圾回收器可能会对性能产生不利影响,但对于内存不足的设备而言每分钟分配 3.6 MB 的内存是个问题。

此外,请考虑加载操作。如果在大型资源加载操作期间生成了大量临时对象,并且对这些对象的引用一直持续到操作完成,则垃圾回收器无法释放这些临时对象,并且托管堆需要进行扩展,即使它包含的许多对象将在不久后释放也是如此。

跟踪托管内存分配情况相对简单。在 Unity 的 CPU 性能分析器中,Overview 表有一个“GC Alloc”列。此列显示了在特定帧中的托管堆上分配的字节数(4)(__注意:__这与给定帧期间临时分配的字节数不同。性能分析器会显示特定帧中分配的字节数,不考虑在后续帧中是否重用了部分/全部已分配的内存)。启用“Deep Profiling”选项后,可以跟踪执行这些分配的方法。

Unity Profiler 不会跟踪在主线程之外发生的分配。因此,“GC Alloc”列不能用于统计用户创建的线程中发生的托管分配。请将代码执行从单独线程切换到主线程以进行调试,或使用
BeginThreadProfiling
API 在时间轴性能分析器 (Timeline Profiler) 中显示例程。

务必使用目标设备上的开发版来分析托管分配。

请注意,某些脚本方法在 Editor 中运行时会产生分配内存,但在构建项目后不会产生分配内存。
GetComponent
是最常见的示例;此方法始终在 Editor 中执行时分配内存,而不会在已构建的项目中分配内存。

通常,强烈建议所有开发人员在项目处于交互状态时最大限度减少托管堆内存分配。非交互操作(例如场景加载)期间的内存分配很少产生问题。

适用于 Visual Studio 的
Jetbrains Resharper 插件
可以帮助找到代码中的内存分配。

使用 Unity 的
深度性能分析 (Deep Profile)
模式可找到托管分配的具体原因。在深度性能分析模式下,所有方法调用都是单独记录的,可更清晰地查看方法调用树中发生托管分配的位置。请注意,深度性能分析模式不仅可在 Editor 中使用,还可借助命令行参数
-deepprofiling
在 Android 和桌面平台上运行。Deep Profiler 按钮在性能分析期间保持灰色。

基本的内存节省方法

可使用一些相对简单的技术来减少托管堆分配。

集合和数组重用

使用 C# 的集合类或数组时,尽可能考虑重用或汇集已分配的集合或数组。集合类开放了一个
Clear
方法,该方法会消除集合内的值,但不会释放分配给集合的内存。

voidUpdate() {

List
<float> nearestNeighbors = new List<float>();

findDistancesToNearestNeighbors(nearestNeighbors);

nearestNeighbors.Sort();
//… 以某种方式使用排序列表 … }

在为复杂计算分配临时“helper”集合时,这尤其有用。下面的代码是一个非常简单的示例:

在此示例中,为了收集一组数据点,每帧都为
nearestNeighbors
List(列表)分配一次内存。将此 List 从方法中提升到包含类中是非常简单的,这样做避免了每帧都为新 List 分配内存:

List<float> m_NearestNeighbors = new List<float>();voidUpdate() {

m_NearestNeighbors.Clear();

findDistancesToNearestNeighbors(NearestNeighbors);

m_NearestNeighbors.Sort();
//… 以某种方式使用排序列表 … }

在此版本中,List 的内存被保留并在多个帧之间重用。仅在 List 需要扩展时才分配新内存。

闭包和匿名方法

使用闭包和匿名方法时需要注意两点。

首先,C# 中的所有方法引用都是引用类型,因此在堆上进行分配。通过将方法引用作为参数传递,可以轻松分配临时内存。无论传递的方法是匿名方法还是预定义的方法,都会发生此分配。

其次,将匿名方法转换为闭包后,为了将闭包传递给接收闭包的方法,所需的内存量将显著增加。

请参考以下代码:

List<float> listOfNumbers =createListOfRandomNumbers();

listOfNumbers.Sort( (x, y)
=>(int)x.CompareTo((int)(y/2))

);

这段代码使用简单的匿名方法来控制在第一行创建的数字列表的排序顺序。但是,如果程序员希望使该代码段可重用,很容易想到将常量
2
替换为局部作用域内的变量,如下所示:

List<float> listOfNumbers =createListOfRandomNumbers();int desiredDivisor =getDesiredDivisor();

listOfNumbers.Sort( (x, y)
=>(int)x.CompareTo((int)(y/desiredDivisor))

);

匿名方法现在要求该方法能够访问方法作用域之外的变量状态,因此已成为闭包。必须以某种方式将
desiredDivisor
变量传递给闭包,以便闭包的实际代码可以使用该变量。

为此,C# 将生成一个匿名类,该类可保存闭包所需的外部作用域变量。当闭包传递给
Sort
方法时,将实例化此类的副本,并用
desiredDivisor
整数的值初始化该副本。

因为执行闭包需要实例化闭包生成类的副本,并且所有类都是 C# 中的引用类型,所以执行闭包需要在托管堆上分配对象。

通常,请尽可能在 C# 中避免使用闭包。应在性能敏感的代码中尽可能减少匿名方法和方法引用,尤其是那些每帧都需要执行的代码中。

装箱 (Boxing)

装箱是 Unity 项目中最常见的非预期临时内存分配来源之一。只要将值类型的值用作引用类型就会发生装箱;这种情况最常发生在将原始值类型的变量(例如
int

float
)传递给对象类型的方法时。

在下面非常简单的示例中,对
x
中的整数进行了装箱以便传递给
object.Equals
方法,因为
object
上的
Equals
方法要求将
object
作为参数传递给它。

int x = 1;object y = new object();

y.Equals(x);

C# IDE(集成开发环境)和编译器通常不会发出关于装箱的警告,即使导致意外的内存分配时也是如此。这是因为 C# 语言的设计理念认为,小型临时分配可以被分代垃圾回收器和对分配大小敏感的内存池有效处理。

虽然 Unity 的分配器实际会使用不同的内存池进行小型和大型分配,但 Unity 的垃圾回收器“不是”分代的,因此无法有效清除由装箱生成的小型、频繁的临时分配。

在为 Unity 运行时编写 C# 代码时,应尽可能避免使用装箱。

识别装箱

装箱在 CPU 跟踪中显示为对某几种特定方法的调用,具体形式取决于使用的脚本后端。这些调用通常采用以下形式之一,其中
<some class>
是其他类或结构的名称,而
...
是一些参数:

  • <some class>::Box(…)

    Box(…)
    <some class>_Box(…)

也可以通过搜索反编译器或 IL 查看器(例如 ReSharper 中内置的 IL 查看器工具或 dotPeek 反编译器)的输出来定位装箱。IL 指令为“box”。

字典和枚举

装箱的一个常见原因是使用
enum
类型作为字典的键。声明
enum
会创建一个新值类型,此类型在后台视为整数,但在编译时实施类型安全规则。

默认情况下,调用
Dictionary.add(key, value)
会导致调用
Object.getHashCode(Object)
。此方法用于获取字典的键的相应哈希代码,并在所有接受键的方法中使用,如:
Dictionary.tryGetValue

Dictionary.remove
等。

Object.getHashCode
方法为引用类型,但
enum
值始终为值类型。因此,对于枚举键字典,每次方法调用都会导致键被装箱至少一次。

以下代码片段展示的一个简单示例说明了此装箱问题:

enumMyEnum { a, b, c };var myDictionary = new Dictionary<MyEnum, object>();

myDictionary.Add(MyEnum.a,
new object());

要解决此问题,则需要编写一个实现
IEqualityComparer
接口的自定义类,并将该类的实例指定为字典的比较器(__注意:__此对象通常是无状态的,因此可与不同的字典实例一起重复使用以节省内存)。

以下是上述代码片段 IEqualityComparer 的简单示例。

public class MyEnumComparer : IEqualityComparer<MyEnum>{public boolEquals(MyEnum x, MyEnum y) {return x ==y;

}
public intGetHashCode(MyEnum x) {return (int)x;

}

}

可将上述类的实例传递给字典的构造函数。

Foreach 循环

在 Unity 的 Mono C# 编译器版本中,使用
foreach
循环会在每次循环终止时强制 Unity 将一个值装箱(__注意:__是在每次整个循环完整执行完成后将该值装箱一次,并非在循环的每次迭代中装箱一次,因此无论循环运行两次还是 200 次,内存使用量都保持不变)。这是因为 Unity 的 C# 编译器生成的 IL 会构造一个通用值类型的枚举器来遍历值集合。

此枚举器将实现
IDisposable
接口;当循环终止时必须调用该接口。但是,在值类型的对象(例如结构和枚举器)上调用接口方法需要将它们装箱。

请参考下面非常简单的示例代码:

int accum = 0;foreach(int x inmyList) {

accum
+=x;

}

以上代码通过 Unity 的 C# 编译器运行后将生成以下中间语言:

   .method private hidebysig instance voidILForeach() cil managed 

{

.maxstack
8.locals init (

[
0] int32 num,

[
1] int32 current,

[
2] valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32>V_2

)
//[67 5 - 67 16] IL_0000: ldc.i4.0IL_0001: stloc.0 //num//[68 5 - 68 74] IL_0002: ldarg.0 //this IL_0003: ldfldclass [mscorlib]System.Collections.Generic.List`1<int32>test::myList

IL_0008: callvirt instance valuetype [mscorlib]System.Collections.Generic.List`
1/Enumerator<!0/*int32*/> class [mscorlib]System.Collections.Generic.List`1<int32>::GetEnumerator()

IL_000d: stloc.
2 //V_2 .try{

IL_000e: br IL_001f
//[72 9 - 72 41] IL_0013: ldloca.s V_2

IL_0015: call instance
!0/*int32*/ valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32>::get_Current()

IL_001a: stloc.
1 //current//[73 9 - 73 23] IL_001b: ldloc.0 //num IL_001c: ldloc.1 //current IL_001d: add

IL_001e: stloc.
0 //num//[70 7 - 70 36] IL_001f: ldloca.s V_2

IL_0021: call instance
bool valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32>::MoveNext()

IL_0026: brtrue IL_0013

IL_002b: leave IL_003c

}
//.try 结束 finally{

IL_0030: ldloc.
2 //V_2 IL_0031: box valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32>IL_0036: callvirt instancevoid[mscorlib]System.IDisposable::Dispose()

IL_003b: endfinally

}
//finally 结束 IL_003c: ret

}
//方法 test::ILForeach结束 }//test 类结束

最相关的代码是靠近底部的
__finally { … }__
代码块。
callvirt
指令在调用
IDisposable.Dispose
方法之前先发现该方法在内存中的位置,并要求将枚举器装箱。

通常,应在 Unity 中避免使用
foreach
循环。原因不仅是这些循环会进行装箱,而且通过枚举器遍历集合的方法调用成本更高,通常比通过
for

while
循环进行的手动迭代慢得多。

请注意,Unity 5.5 中的 C# 编译器升级版本显著提高了 Unity 生成 IL 的能力。特别值得注意的是,已从
foreach
循环中消除装箱操作。因此,节约了与
foreach
循环相关的内存开销。但是,由于方法调用开销,与基于数组的等效代码相比,CPU 性能差距仍然存在。

Unity 数组值 API

虚数组分配的一种更有害和更不明显的原因是重复访问返回数组的 Unity API。返回数组的所有 Unity API 每次被访问时都会创建一个新的数组副本。在不必要的情况下访问数组值 Unity API 是极不适宜的。

例如,下面的代码在每次循环迭代时都会虚化创建
vertices
数组的四个副本。每次访问
.vertices
属性时都会发生分配。

for(int i = 0; i < mesh.vertices.Length; i++)

{
floatx, y, z;

x
=mesh.vertices[i].x;

y
=mesh.vertices[i].y;

z
=mesh.vertices[i].z;//... DoSomething(x, y, z);

}

通过在进入循环之前捕获
vertices
数组,无论循环迭代次数是多少,都可以简单地重构为单个数组分配:

var vertices =mesh.vertices;for(int i = 0; i < vertices.Length; i++)

{
floatx, y, z;

x
=vertices[i].x;

y
=vertices[i].y;

z
=vertices[i].z;//... DoSomething(x, y, z);

}

虽然访问一次属性的 CPU 成本不是很高,但在紧凑循环内重复访问会使得 CPU 性能过热。此外,重复访问会导致托管堆出现不必要的扩展。

此问题在移动端极其常见,因为
Input.touches
API 的行为与上述类似。项目包含以下类似代码是极为常见的,此情况下每次访问
.touches
属性时都会发生分配。

for ( int i = 0; i < Input.touches.Length; i++)

{

Touch touch
=Input.touches[i];// }

当然,通过将数组分配从循环条件中提升出来,可轻松改善该问题:

Touch[] touches =Input.touches;for ( int i = 0; i < touches.Length; i++)

{

Touch touch
=touches[i];// }

但是,现在有许多 Unity API 的版本不会导致内存分配。如果能使用这些版本时,请尽量选择这种版本。

int touchCount =Input.touchCount;for ( int i = 0; i < touchCount; i++)

{

Touch touch
=Input.GetTouch(i);// }

将上面的示例转换为无分配的 Touch API 很简单:

请注意,为了节省调用属性的
get
方法的 CPU 成本,属性访问 (
Input.touchCount
) 仍然保持在循环条件之外。

空数组重用

当数组值方法需要返回空集时,有些开发团队更喜欢返回空数组而不是
null
。这种编码模式在许多托管语言中很常见,特别是 C# 和 Java。

通常情况下,从方法返回零长度数组时,返回零长度数组的预分配单例实例比重复创建空数组要高效得多(5)(__注意:__当然,在返回数组后调整数组大小时是个例外)。

脚注

  • (1) 这是因为大多数平台上从 GPU 内存回读的速度极慢。将纹理从 GPU 内存读入临时缓冲区以供 CPU 代码(例如
    Texture.GetPixel
    )使用将是非常不高效的。

  • (2) 严格来说,必须在托管堆上分配所有非 null 引用类型对象和所有装箱值类型对象。

  • (3) 具体运行时间视平台而定。

  • (4) 注意,这与给定帧期间临时分配的字节数不同。性能分析器会显示特定帧中分配的字节数,不考虑在后续帧中是否重用了部分/全部已分配的内存。

  • (5) 当然,在返回数组后调整数组大小时是个例外。