Llama改进之——分组查询注意力

引言

今天介绍LLAMA2模型引入的关于注意力的改进——分组查询注意力(Grouped-query attention,GQA)1

Transformer中的多头注意力在解码阶段来说是一个性能瓶颈。多查询注意力2通过共享单个key和value头,同时不减少query头来提升性能。多查询注意力可能导致质量下降和训练不稳定,因此常用的是分组查询注意力。

然后我们结合上篇文章3探讨的旋转位置编码,将选择位置编码应用到分组查询注意力上。

多头注意力

我们先回顾以下原始多头注意力的实现。

import torch
from torch import nn, Tensorimport math
from dataclasses import dataclass@dataclass
class ModelArgs:hidden_size: int = 512num_heads: int = 8attention_dropout: float = 0.1class MultiHeadAttention(nn.Module):def __init__(self, args: ModelArgs) -> None:super().__init__()self.hidden_size = args.hidden_sizeself.num_heads = args.num_headsself.head_dim = self.hidden_size // self.num_headsself.attention_dropout = args.attention_dropoutself.q_proj = nn.Linear(self.hidden_size, self.num_heads * self.head_dim, bias=False)self.k_proj = nn.Linear(self.hidden_size, self.num_heads * self.head_dim, bias=False)self.v_proj = nn.Linear(self.hidden_size, self.num_heads * self.head_dim, bias=False)self.o_proj = nn.Linear(self.hidden_size, self.hidden_size, bias=False)def forward(self, hidden_states: Tensor, attention_mask: Tensor = None):batch_size, seq_len, _ = hidden_states.shapequery_states, key_states, value_states = (self.q_proj(hidden_states),self.k_proj(hidden_states),self.v_proj(hidden_states),)query_states = query_states.view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2)key_states = key_states.view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2)value_states = value_states.view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2)attn_weights = torch.matmul(query_states, key_states.transpose(2, 3)) / math.sqrt(self.head_dim)if attention_mask is not None:causal_mask = attention_mask[:, :, :, : key_states.shape[-2]]attn_weights = attn_weights + causal_mask# upcast attention to fp32 see https://github.com/huggingface/transformers/pull/17437attn_weights = nn.functional.softmax(attn_weights, dim=-1, dtype=torch.float32).to(query_states.dtype)attn_weights = nn.functional.dropout(attn_weights, p=self.attention_dropout, training=self.training)attn_output = torch.matmul(attn_weights, value_states)attn_output = attn_output.transpose(1, 2).contiguous()attn_output = attn_output.reshape(batch_size, seq_len, self.hidden_size)attn_output = self.o_proj(attn_output)return attn_output

别忘了测试一下:

    args = ModelArgs()attention = MultiHeadAttention(args)inputs = torch.randn(32, 8, args.hidden_size)print(attention(inputs).shape)
torch.Size([32, 8, 512])

原始多头注意力就不再赘述了,之前的文章有过详细介绍。

分组查询注意力

分组查询注意力使用折中数量的key-value头(超过一个,但少于多头注意力全部的头数量)来提升性能。

多头注意力、分组查询注意力以及多查询注意力之间的区别如下:

image-20240413222803653

该图来自参考1中的论文。

202405301726

如上图所示,分组查询注意力是针对多头注意力的一种改进,每组Query头(这里两个Query一组)共享同一个Key和Value头,使得推理更加高效。

实际上在实现的时候,会将共享的Key和Value头进行广播(复制)成与Query头相同的数量:

202405301734

这样,我们就可以像普通多头注意力一样去计算了。

我们增加num_key_value_heads表示key、value头数;num_heads还是表示query头数。

@dataclass
class ModelArgs:hidden_size: int = 512num_heads: int = 8num_key_value_heads: int = 4attention_dropout: float = 0.1

分组查询注意力和多查询注意力可以合并在一起实现:

class GroupedQueryAttention(nn.Module):def __init__(self, args: ModelArgs) -> None:super().__init__()self.hidden_size = args.hidden_sizeself.num_heads = args.num_heads# 每个头的维度计算和之前一样self.head_dim = self.hidden_size // self.num_heads# 保存key/value头数self.num_key_value_heads = args.num_key_value_heads# 每组内要复制的次数,若为1,即退化为多头注意力;若为num_heads,则为多查询注意力self.num_key_value_groups = self.num_heads // args.num_key_value_headsself.attention_dropout = args.attention_dropoutself.q_proj = nn.Linear(self.hidden_size, self.num_heads * self.head_dim, bias=False)# 注意Key和Value的映射这里节省了参数,加速了推理效率。self.k_proj = nn.Linear(self.hidden_size, self.num_key_value_heads * self.head_dim, bias=False)self.v_proj = nn.Linear(self.hidden_size, self.num_key_value_heads * self.head_dim, bias=False)# 最后的输出映射和之前一样self.o_proj = nn.Linear(self.hidden_size, self.hidden_size, bias=False)def forward(self, hidden_states: Tensor, attention_mask: Tensor = None):batch_size, seq_len, _ = hidden_states.shapequery_states, key_states, value_states = (self.q_proj(hidden_states),self.k_proj(hidden_states),self.v_proj(hidden_states),)query_states = query_states.view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2)# 转换为对应的形状key_states = key_states.view(batch_size, seq_len, self.num_key_value_heads, self.head_dim).transpose(1, 2)value_states = value_states.view(batch_size, seq_len, self.num_key_value_heads, self.head_dim).transpose(1, 2)# 重复num_key_value_groups次,使得和query头数一致key_states = repeat_kv(key_states, self.num_key_value_groups)value_states = repeat_kv(value_states, self.num_key_value_groups)# 后面和普通多头注意力一样计算attn_weights = torch.matmul(query_states, key_states.transpose(2, 3)) / math.sqrt(self.head_dim)if attention_mask is not None:causal_mask = attention_mask[:, :, :, : key_states.shape[-2]]attn_weights = attn_weights + causal_mask# upcast attention to fp32 see https://github.com/huggingface/transformers/pull/17437attn_weights = nn.functional.softmax(attn_weights, dim=-1, dtype=torch.float32).to(query_states.dtype)attn_weights = nn.functional.dropout(attn_weights, p=self.attention_dropout, training=self.training)attn_output = torch.matmul(attn_weights, value_states)attn_output = attn_output.transpose(1, 2).contiguous()attn_output = attn_output.reshape(batch_size, seq_len, self.hidden_size)attn_output = self.o_proj(attn_output)return attn_output

其中num_key_value_groups为每组内要复制的次数,若为1,即退化为多头注意力;若为num_heads,则为多查询注意力。

复制时调用repeat_kv方法,如其名所示,只针对key和value:

def repeat_kv(hidden_states: Tensor, n_rep: int) -> Tensor:"""The hidden states go from (batch, num_key_value_heads, seq_len, head_dim) to (batch, num_attention_heads, seq_len, head_dim)n_rep is the number of repeat times."""batch, num_key_value_heads, seq_len, head_dim = hidden_states.shapeif n_rep == 1:# do nothingreturn hidden_states# add a new dimension and repeat n_rep timeshidden_states = hidden_states[:, :, None, :, :].expand(batch, num_key_value_heads, n_rep, seq_len, head_dim)# reshape to (batch, num_attention_heads, seq_len, head_dim)return hidden_states.reshape(batch, num_key_value_heads * n_rep, seq_len, head_dim)

有了分组查询注意力,下面我们来看如何应用上篇文章3介绍的旋转位置编码到query和key上。

应用旋转位置编码

注意,实现的时候要考虑维度,因此代码和上篇文章的旋转位置编码3有所不同。

首先,我们实现RotaryEmbedding,它缓存了频率张量inv_freq的计算。

class RotaryEmbedding(nn.Module):def __init__(self, dim: int, max_position_embeddings: int = 2048, theta: int = 10000):super().__init__()self.dim = dim  # head dimself.max_position_embeddings = max_position_embeddingsself.theta = thetainv_freq = 1.0 / (theta** (torch.arange(0, self.dim, 2, dtype=torch.int64).float() / self.dim))self.register_buffer("inv_freq", inv_freq, persistent=False)# 不需要计算梯度@torch.no_grad()def forward(self, position_ids: torch.LongTensor):freqs = torch.outer(position_ids, self.inv_freq).float()return torch.polar(torch.ones_like(freqs), freqs)

该实现修改自旋转位置编码文章3中的precompute_freqs_cis函数。

然后我们改写apply_rotary_emb函数,主要是确定了输入和输出维度的正确性:

def apply_rotary_emb(q: Tensor, k: Tensor, freq_cis: Tensor):"""Args:q (Tensor): (batch_size, num_heads, seq_len, head_dim)k (Tensor): (batch_size, num_key_value_heads, seq_len, head_dim)freq_cis (Tensor): (seq_len, batch_size)"""# q_ (batch_size, num_heads, seq_len, head_dim // 2, 2)q_ = q.float().reshape(*q.shape[:-1], -1, 2)# k_ (batch_size, num_key_value_heads, seq_len, head_dim // 2, 2)k_ = k.float().reshape(*k.shape[:-1], -1, 2)# turn to complex# q_ (batch_size, num_heads, seq_len, head_dim // 2)q_ = torch.view_as_complex(q_)# k_ (batch_size, num_key_value_heads, seq_len, head_dim // 2)k_ = torch.view_as_complex(k_)# freq_cis (batch_size, 1, seq_len, 1)freq_cis = reshape_for_broadcast(freq_cis, q_)# 应用旋转操作,然后将结果转回实数# view_as_real (batch_size, num_heads, seq_len, head_dim // 2, 2)# xq_out (batch_size, num_heads, seq_len, head_dim)xq_out = torch.view_as_real(q_ * freq_cis).flatten(-2)# view_as_real (batch_size, num_key_value_heads, seq_len, head_dim // 2, 2)# xk_out (batch_size, num_key_value_heads, seq_len, head_dim)xk_out = torch.view_as_real(k_ * freq_cis).flatten(-2)return xq_out.type_as(q), xk_out.type_as(k)

其中需要调用reshape_for_broadcast将频率张量的维度从(seq_len, batch_size)调整到(batch_size, 1, seq_len, 1)

def reshape_for_broadcast(freqs_cis: torch.Tensor, x: torch.Tensor):"""Args:freqs_cis (torch.Tensor): (seq_len, batch_size)x (torch.Tensor): (batch_size, num_heads, seq_len, head_dim // 2)"""# enumerate(x.shape) = [(0, batch_size), (1, num_heads), (2, seq_len), (3, head_dim // 2)]# (batch_size, 1, seq_len, 1)shape = [d if i == 0 or i == 2 else 1 for i, d in enumerate(x.shape)]return freqs_cis.view(*shape)

我们把每个维度都写出来就不会出错。

再确保下repeat_kv函数的维度:

def repeat_kv(hidden_states: Tensor, n_rep: int) -> Tensor:"""The hidden states go from (batch, num_key_value_heads seq_len, head_dim) to (batch, num_attention_heads, seq_len, head_dim)n_rep is the number of repeat times."""batch, num_key_value_heads, seq_len, head_dim = hidden_states.shapeif n_rep == 1:# do nothingreturn hidden_states# add a new dimension and repeat n_rep timeshidden_states = hidden_states[:, :, None, :, :].expand(batch, num_key_value_heads, n_rep, seq_len, head_dim)# reshape to (batch, num_attention_heads, seq_len, head_dim)return hidden_states.reshape(batch, num_key_value_heads * n_rep, seq_len, head_dim)

最后将旋转位置编码整合到GroupedQueryAttention中:

class GroupedQueryAttention(nn.Module):def __init__(self, args: ModelArgs) -> None:super().__init__()self.hidden_size = args.hidden_sizeself.num_heads = args.num_heads# 每个头的维度计算和之前一样self.head_dim = self.hidden_size // self.num_heads# 保存key/value头数self.num_key_value_heads = args.num_key_value_heads# 每组内要复制的次数,若为1,即退化为多头注意力;若为num_heads,则为多查询注意力self.num_key_value_groups = self.num_heads // args.num_key_value_headsself.attention_dropout = args.attention_dropoutself.max_position_embeddings = args.max_position_embeddingsself.rope_theta = args.thetaself.q_proj = nn.Linear(self.hidden_size, self.num_heads * self.head_dim, bias=False)# 注意Key和Value的映射这里节省了参数,加速了推理效率。self.k_proj = nn.Linear(self.hidden_size, self.num_key_value_heads * self.head_dim, bias=False)self.v_proj = nn.Linear(self.hidden_size, self.num_key_value_heads * self.head_dim, bias=False)# 最后的输出映射和之前一样self.o_proj = nn.Linear(self.num_heads * self.head_dim, self.hidden_size, bias=False)# 定义了RotaryEmbedding实例self.rotary_emb = RotaryEmbedding(self.head_dim,max_position_embeddings=self.max_position_embeddings,theta=self.rope_theta,)def forward(self,hidden_states: Tensor,attention_mask: Tensor = None,position_ids: torch.LongTensor = None,):batch_size, seq_len, _ = hidden_states.shapequery_states, key_states, value_states = (self.q_proj(hidden_states),self.k_proj(hidden_states),self.v_proj(hidden_states),)# query_states(batch_size, num_heads, seq_len, head_dim)query_states = query_states.view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2)# 转换为对应的形状# key_states (batch_size, num_key_value_heads, seq_len, head_dim)key_states = key_states.view(batch_size, seq_len, self.num_key_value_heads, self.head_dim).transpose(1, 2)# value_states (batch_size, num_key_value_heads, seq_len, head_dim)value_states = value_states.view(batch_size, seq_len, self.num_key_value_heads, self.head_dim).transpose(1, 2)# 计算频率张量# freq_cis (seq_len, batch_size)freq_cis = self.rotary_emb(position_ids)# 针对query和key应用旋转位置编码# query_states (batch_size, num_heads, seq_len, head_dim)# key_states (batch_size, num_key_value_heads, seq_len, head_dim)query_states, key_states = apply_rotary_emb(query_states, key_states, freq_cis)# 重复num_key_value_groups次,使得和query头数一致# key_states (batch_size, num_heads, seq_len, head_dim)key_states = repeat_kv(key_states, self.num_key_value_groups)# value_states (batch_size, num_heads, seq_len, head_dim)value_states = repeat_kv(value_states, self.num_key_value_groups)# 后面和普通多头注意力一样计算attn_weights = torch.matmul(query_states, key_states.transpose(2, 3)) / math.sqrt(self.head_dim)if attention_mask is not None:causal_mask = attention_mask[:, :, :, : key_states.shape[-2]]attn_weights = attn_weights + causal_mask# upcast attention to fp32 see https://github.com/huggingface/transformers/pull/17437attn_weights = nn.functional.softmax(attn_weights, dim=-1, dtype=torch.float32).to(query_states.dtype)attn_weights = nn.functional.dropout(attn_weights, p=self.attention_dropout, training=self.training)attn_output = torch.matmul(attn_weights, value_states)attn_output = attn_output.transpose(1, 2).contiguous()attn_output = attn_output.reshape(batch_size, seq_len, self.hidden_size)attn_output = self.o_proj(attn_output)return attn_output

主要修改是在调用repeat_kv之前应用旋转位置编码到(每个Attention的)query和key中:

# 计算频率张量
# freq_cis (seq_len, batch_size)
freq_cis = self.rotary_emb(position_ids)# 针对query和key应用旋转位置编码
# query_states (batch_size, num_heads, seq_len, head_dim)
# key_states (batch_size, num_key_value_heads, seq_len, head_dim)
query_states, key_states = apply_rotary_emb(query_states, key_states, freq_cis)

这里简单探讨下为什么旋转位置编码只是应用到query和key上,没有应用到value上,考虑Attention的计算公式:
a m , n = exp ⁡ ( q m T k n d ) ∑ j = 1 N exp ⁡ q m T k j d o m = ∑ n = 1 N a m , n v n \begin{aligned} a_{m,n} &= \frac{\exp(\frac{\pmb q^T_m \pmb k_n}{\sqrt d})}{\sum_{j=1}^N \exp \frac{\pmb q^T_m \pmb k_j}{\sqrt d}} \\ \pmb o_m &= \sum_{n=1}^N a_{m,n}\pmb v_n \\ \end{aligned} am,nooom=j=1Nexpd qqqmTkkkjexp(d qqqmTkkkn)=n=1Nam,nvvvn

我们可以看到,实际上只有query和key之间会进行交互(点乘),而value只是用于计算加权和,不参与交互,因此没有必要应用旋转位置编码,但也可以尝试应用到value上。

苏神在博客也说了:“通过在q,k中施行该位置编码,那么效果就等价于相对位置编码,而如果还需要显式的绝对位置信息,则可以同时在v上也施行这种位置编码。总的来说,我们通过绝对位置的操作,可以达到绝对位置的效果,也能达到相对位置的效果。”

最后,进行一个简单的测试:

@dataclass
class ModelArgs:hidden_size: int = 512num_heads: int = 8num_key_value_heads: int = 4attention_dropout: float = 0.1max_position_embeddings: int = 2048theta: int = 10000if __name__ == "__main__":args = ModelArgs()attention = GroupedQueryAttention(args)inputs = torch.randn(32, 16, args.hidden_size)seq_len = inputs.size(1)position_ids = torch.arange(seq_len, dtype=torch.long)print(attention(inputs, position_ids=position_ids).shape)
torch.Size([32, 16, 512])

参考


  1. [论文翻译]GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints ↩︎

  2. Fast Transformer Decoding: One Write-Head is All You Need ↩︎

  3. Llama改进之——RoPE旋转位置编码 ↩︎ ↩︎ ↩︎ ↩︎

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.rhkb.cn/news/338008.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

民国漫画杂志《时代漫画》第38期.PDF

时代漫画38.PDF: https://url03.ctfile.com/f/1779803-1248636380-dd7daa?p9586 (访问密码: 9586) 《时代漫画》的杂志在1934年诞生了,截止1937年6月战争来临被迫停刊共发行了39期。 ps: 资源来源网络!

Webrtc支持HEVC之FFMPEG支持HEVC编解码(一)

一、前言 Webrtc使用的FFMPEG(webrtc\src\third_party\ffmpeg)和官方的不太一样,使用GN编译,各个平台使用了不一样的配置文件 以Windows为例,Chrome浏览器也类似 二、修改配置文件 windows:chromium\config\Chrome\win\x64 其他平台: chromium\config\Chrome\YOUR_SYS…

基础—SQL—DQL(数据查询语言)分组查询

一、引言 分组查询的关键字是:GROUP BY。 二、DQL—分组查询 1、语法 SELECT 字段列表 FROM 表名 [ WHERE 条件 ] GROUP BY 分组字段名 [ HAVING 分组后过滤条件 ]; 注意: 1、[ ] 里的内容可以有可以没有。 2、这条SQL语句有两块指定条件的地方&#…

CSS Canvas鼠标点击特效之天女散花(文本粒子动画)

1.效果 2.代码 <!DOCTYPE html> <html lang"en"><head><meta charset"UTF-8"><meta name"viewport" content"widthdevice-width, initial-scale1.0"><style>body,html {margin: 0;padding: 0;wi…

【SQL学习进阶】从入门到高级应用【三范式】

文章目录 什么是数据库设计三范式三范式一对多怎么设计多对多怎么设计一对一怎么设计最终的设计 &#x1f308;你好呀&#xff01;我是 山顶风景独好 &#x1f495;欢迎来到我的博客&#xff0c;很高兴能够在这里和您见面&#xff01; &#x1f495;希望您在这里可以感受到一份…

微信小程序-页面配置

一、页面配置文件的作用 小程序中&#xff0c;每个页面都有自己的.json配置文件&#xff0c;用来对当前页面的窗口外观、页面效果等进行配置 二、页面配置和全局配置的关系 小程序中&#xff0c;app.json中的window节点&#xff0c;可以全局配置小程序中每个页面的窗口表现 …

牛客网刷题 | BC105 菱形图案

目前主要分为三个专栏&#xff0c;后续还会添加&#xff1a; 专栏如下&#xff1a; C语言刷题解析 C语言系列文章 我的成长经历 感谢阅读&#xff01; 初来乍到&#xff0c;如有错误请指出&#xff0c;感谢&#xff01; 描述 KiKi学习了循环&am…

Facebook代运营 | Facebook广告投放步骤及要点

Facebook体量大&#xff0c;素材的更新频率快&#xff0c;通过Facebook进行广告投放的用户也越来越多&#xff0c;Facebook坐拥大量用户&#xff0c;同时有着非常科学的用户画像构建系统和推送机制&#xff0c;对于很多广告涉足的伙伴来说&#xff0c;更加的友好。 1. 创建广告…

「计网」网络初识

&#x1f387;个人主页&#xff1a;Ice_Sugar_7 &#x1f387;所属专栏&#xff1a;计网 &#x1f387;欢迎点赞收藏加关注哦&#xff01; 网络初识 &#x1f349;IP 地址 & 端口号&#x1f349;网络协议&#x1f34c;TCP/IP 网络协议 &#x1f349;封装和分用&#x1f349…

数据隐私新篇章:Facebook如何保护用户信息

随着数字化时代的到来&#xff0c;数据隐私保护成为了社交媒体平台和用户共同关注的焦点。作为全球最大的社交网络之一&#xff0c;Facebook一直致力于保护用户的隐私和数据安全。本文将深入探讨Facebook在数据隐私保护方面的措施和实践&#xff0c;以及其如何开启数据隐私的新…

领域建模(系统操作复习)

习题 问题 考察点 领域建模 识别概念类 固定模板 Conceptual Class Category Examples specifications,designs, or descriptions of things roles of people containers of other things things in a container abstract noun concepts organizations events processes (…

车载电子电器架构 —— 智能座舱技术范围(万字长文精讲)

车载电子电器架构 —— 智能座舱技术范围 我是穿拖鞋的汉子,魔都中坚持长期主义的汽车电子工程师。 老规矩,分享一段喜欢的文字,避免自己成为高知识低文化的工程师: 屏蔽力是信息过载时代一个人的特殊竞争力,任何消耗你的人和事,多看一眼都是你的不对。非必要不费力证明…

0基础认识C语言(理论+实操3)

所有籍籍无名的日子里 我从未看轻自己半分 小伙伴们&#xff0c;一起开始我们今天的话题吧 一、算法操作符 1.双目操作符 为何叫双目操作符呢&#xff1f;其实是因为我们进行加减乘除的时候&#xff0c;至少得需要两个数字进行这些运算&#xff0c;而这个数字就被称为操作数…

算法题解记录27+++随机链表的复制(百日筑基)

一、题目描述&#xff1a; 题目难度&#xff1a;中等 给你一个长度为 n 的链表&#xff0c;每个节点包含一个额外增加的随机指针 random &#xff0c;该指针可以指向链表中的任何节点或空节点。 构造这个链表的 深拷贝。 深拷贝应该正好由 n 个 全新 节点组成&#xff0c;其中每…

Leetcode - 周赛399

目录 一&#xff0c;3162. 优质数对的总数 I 二&#xff0c;3163. 压缩字符串 III 三&#xff0c;3164. 优质数对的总数 II 四&#xff0c; 3165. 不包含相邻元素的子序列的最大和 一&#xff0c;3162. 优质数对的总数 I 假设 x 是 nums1 数组中的值&#xff0c;y 是 nums2…

WebGL实现医学教学软件

使用WebGL实现医学教学软件是一个复杂但非常有益的项目&#xff0c;可以显著提升医学教育的互动性和效果。以下是详细的实现步骤&#xff0c;包括需求分析、技术选型、开发流程和注意事项。北京木奇移动技术有限公司&#xff0c;专业的软件外包开发公司&#xff0c;欢迎交流合作…

Python自动实时查询预约网站的剩余名额并在有余额时发邮件提示

本文介绍基于Python语言&#xff0c;自动、定时监测某体检预约网站中指定日期的体检余额&#xff0c;并在有体检余额时自动给自己发送邮件提醒的方法。 来到春招末期&#xff0c;很多单位进入了体检流程。其中&#xff0c;银行&#xff08;尤其是四大行&#xff09;喜欢“海检”…

404.左叶子之和

计算给定二叉树的所有左叶子之和。 示例&#xff1a; 思路&#xff1a; 通过父节点来判断七子节点是不是我们要收集的元素。因为如果遍历到孩子节点&#xff0c;我们无法判断它是左孩子还是右孩子。 后序遍历&#xff0c;左右中。 判断当前节点是不是左叶子是无法判断的&…

33【Aseprite 作图】树——拆解

1 树叶 画树叶真累啊&#xff0c;可以先画一个轮廓&#xff0c;细节一点点修 2 1 2 &#xff1b;2 2 2 &#xff08;横着横&#xff09;&#xff0c;这样一点点画树叶 填充颜色&#xff0c;用了喷雾工具 2 树干部分 轮廓部分&#xff0c;左边的是3 3 3 &#xff1b;上下都是…

【上海大学计算机组成原理实验报告】六、内存系统实验

一、实验目的 学习内存访问机制。理解代码和数据的分区存放原理和技术。 二、实验原理 根据实验指导书的相关内容&#xff0c;地址寄存器MAR用来存放要进行读或写的存储器EM的地址。其内容经数据总线DBUS写入&#xff0c;因此必须在数据总线上具有数据后&#xff0c;配合MAR允…