PyTorch 模型性能分析和优化 - 第 2 部分

news2024/12/29 9:23:42

动动发财的小手,点个赞吧!

这是有关分析和优化在 GPU 上运行的 PyTorch 模型主题的系列文章的第二部分。在第一篇文章中,我们演示了使用 PyTorch Profiler 和 TensorBoard 迭代分析和优化 PyTorch 模型的过程以及巨大潜力。在这篇文章中,我们将重点关注 PyTorch 中由于使用急切执行而特别普遍的特定类型的性能问题:模型执行部分对 CPU 的依赖。识别此类问题的存在和根源可能非常困难,并且通常需要使用专用的性能分析器。在这篇文章[1]中,我们将分享一些在使用 PyTorch Profiler 和 PyTorch Profiler TensorBoard 插件时识别此类性能问题的技巧。

吸引点

PyTorch 的主要吸引力之一是其执行模式。在 Eager 模式下,形成模型的每个 PyTorch 操作一旦到达就会独立执行。这与图模式相反,在图模式中,整个模型以最适合在 GPU 上运行并作为整体执行的方式预编译为单个图。通常,这种预编译会带来更好的性能(例如,请参见此处)。在急切模式下,编程上下文在每次操作后返回到应用程序,从而允许我们访问和评估任意张量。这使得构建、分析和调试 ML 模型变得更加容易。另一方面,它也使我们的模型更容易(有时是意外地)插入次优代码块。正如我们将演示的,了解如何识别和修复此类代码块会对模型的速度产生重大影响。

玩具示例

在以下块中,我们介绍将用于演示的玩具示例。该代码非常宽松地基于我们上一篇文章中的示例以及本 PyTorch 教程中定义的损失函数。

我们首先定义一个简单的分类模型。它的架构对于本文来说并不重要。

import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim
import torch.profiler
import torch.utils.data
import torchvision.models
import torchvision.transforms as T
from torchvision.datasets.vision import VisionDataset
import numpy as np
from PIL import Image


# sample model
class Net(nn.Module):
    def __init__(self):
        super().__init__()
        self.conv1 = nn.Conv2d(383, padding=1)
        self.conv2 = nn.Conv2d(8123, padding=1)
        self.conv3 = nn.Conv2d(12163, padding=1)
        self.conv4 = nn.Conv2d(16203, padding=1)
        self.conv5 = nn.Conv2d(20243, padding=1)
        self.conv6 = nn.Conv2d(24283, padding=1)
        self.conv7 = nn.Conv2d(28323, padding=1)
        self.conv8 = nn.Conv2d(32103, padding=1)
        self.pool = nn.MaxPool2d(22)

    def forward(self, x):
        x = self.pool(F.relu(self.conv1(x)))
        x = self.pool(F.relu(self.conv2(x)))
        x = self.pool(F.relu(self.conv3(x)))
        x = self.pool(F.relu(self.conv4(x)))
        x = self.pool(F.relu(self.conv5(x)))
        x = self.pool(F.relu(self.conv6(x)))
        x = self.pool(F.relu(self.conv7(x)))
        x = self.pool(F.relu(self.conv8(x)))
        x = torch.flatten(x, 1# flatten all dimensions except batch
        return x

接下来,我们定义一个非常标准的交叉熵损失函数。这个损失函数将是我们讨论的主要焦点。

def log_softmax(x):
    return x - x.exp().sum(-1).log().unsqueeze(-1)

def weighted_nll(pred, target, weight):
    assert target.max() < 10
    nll = -pred[range(target.shape[0]), target]
    nll = nll * weight[target]
    nll = nll / weight[target].sum()
    sum_nll = nll.sum()
    return sum_nll

# custom loss definition
class CrossEntropyLoss(nn.Module):
    def forward(self, input, target):
        pred = log_softmax(input)
        loss = weighted_nll(pred, target, torch.Tensor([0.1]*10).cuda())
        return loss

最后,我们定义数据集和训练循环:

# dataset with random images that mimics the properties of CIFAR10
class FakeCIFAR(VisionDataset):
    def __init__(self, transform):
        super().__init__(root=None, transform=transform)
        self.data = np.random.randint(low=0,high=256,size=(10000,32,32,3),dtype=np.uint8)
        self.targets = np.random.randint(low=0,high=10,size=(10000),dtype=np.uint8).tolist()

    def __getitem__(self, index):
        img, target = self.data[index], self.targets[index]
        img = Image.fromarray(img)
        if self.transform is not None:
            img = self.transform(img)
        return img, target

    def __len__(self) -> int:
        return len(self.data)

transform = T.Compose(
    [T.Resize(256),
     T.PILToTensor()])

train_set = FakeCIFAR(transform=transform)
train_loader = torch.utils.data.DataLoader(train_set, batch_size=1024,
                               shuffle=True, num_workers=8, pin_memory=True)

device = torch.device("cuda:0")
model = Net().cuda(device)
criterion = CrossEntropyLoss().cuda(device)
optimizer = torch.optim.SGD(model.parameters(), lr=0.001, momentum=0.9)
model.train()


# training loop wrapped with profiler object
with torch.profiler.profile(
        schedule=torch.profiler.schedule(wait=1, warmup=4, active=3, repeat=1),
        on_trace_ready=torch.profiler.tensorboard_trace_handler(’./log/example’),
        record_shapes=True,
        profile_memory=True,
        with_stack=True
as prof:
    for step, data in enumerate(train_loader):
        inputs = data[0].to(device=device, non_blocking=True)
        labels = data[1].to(device=device, non_blocking=True)
        inputs = (inputs.to(torch.float32) / 255. - 0.5) / 0.5
        if step >= (1 + 4 + 3) * 1:
            break
        outputs = model(inputs)
        loss = criterion(outputs, labels)
        optimizer.zero_grad(set_to_none=True)
        loss.backward()
        optimizer.step()
        prof.step()

经验丰富的 PyTorch 开发人员可能已经注意到,我们的示例在损失函数中包含许多低效的代码行。与此同时,它并没有什么明显的问题,而且这种类型的低效率现象并不少见。如果您想测试您的 PyTorch 熟练程度,请在继续阅读之前看看您是否能找到我们实现交叉熵损失的三个问题。在接下来的部分中,我们将假设我们无法自己找到这些问题,并展示如何使用 PyTorch Profiler 及其关联的 TensorBoard 插件来识别它们。

与我们之前的文章一样,我们将迭代地运行实验,识别性能问题并尝试修复它们。我们将在 Amazon EC2 g5.2xlarge 实例(包含 NVIDIA A10G GPU 和 8 个 vCPU)上运行实验,并使用官方 AWS PyTorch 2.0 Docker 映像。

初始性能结果

在下图中,我们显示了上述脚本的性能报告的“概述”选项卡。

alt

正如我们所看到的,我们的 GPU 利用率相对较高,为 92.04%,步长为 216 毫秒。 (正如我们之前的文章中一样,torch-tb-profiler 0.4.1 版本中的概述总结了所有三个训练步骤的步骤时间。)仅从这份报告中,您可能不会认为我们的模型有任何问题。然而,性能报告的跟踪视图讲述了一个完全不同的故事:

alt

如上所述,仅交叉熵损失的前向传递就占用了训练步骤 216 毫秒中的 211 毫秒!这清楚地表明出现了问题。与模型相比,我们的损失函数包含少量计算,并且当然不应该占步骤时间的 98%。仔细观察调用堆栈,我们可以看到一些函数调用增强了我们的怀疑,包括“to”、“copy_”和“cudaStreamSynchronize”。这种组合通常表明数据正在从 CPU 复制到 GPU——我们不希望在损失计算过程中发生这种情况。在这种情况下,我们的性能问题也与 GPU 利用率的短暂下降相关,如图中突出显示的那样。然而,这并非总是如此。通常,GPU 利用率的下降与性能问题并不相符,或者可能根本看不到。

我们现在知道损失函数存在性能问题,并且很可能与将张量从主机复制到 GPU 有关。但是,这可能不足以确定导致问题的精确代码行。为了方便我们的搜索,我们将使用标记为 torch.profiler.record_function 上下文管理器的每行代码进行包装,并重新运行分析分析。

# custom loss definition
class CrossEntropyLoss(nn.Module):
    def forward(self, input, target):
        with torch.profiler.record_function('log_softmax'):
            pred = log_softmax(input)
        with torch.profiler.record_function('define_weights'):
            weights = torch.Tensor([0.1]*10).cuda()
        with torch.profiler.record_function('weighted_nll'):
            loss = weighted_nll(pred, target, torch.Tensor([0.1]*10).cuda())
        return loss

添加标签可以帮助我们识别权重定义,或者更准确地说,将权重复制到 GPU 中,作为有问题的代码行。

alt

优化1:从训练步骤中删除冗余的主机到 GPU 副本

一旦我们确定了第一个问题,解决它就相当简单了。在下面的代码块中,我们在损失初始化函数中将权重向量复制到 GPU 一次:

class CrossEntropyLoss(nn.Module):
    def __init__(self):
        super().__init__()
        self.weight = torch.Tensor([0.1]*10).cuda()

    def forward(self, input, target):
        with torch.profiler.record_function('log_softmax'):
            pred = log_softmax(input)
        with torch.profiler.record_function('weighted_nll'):
            loss = weighted_nll(pred, target, self.weight)
        return loss

下图显示了此修复后的性能分析结果:

alt

令人失望的是,我们的第一次优化对步骤时间的影响非常小。如果我们查看跟踪视图报告,我们可以看到我们有一个需要解决的新的严重性能问题。

alt

我们的新报告表明我们的weighted_nll 函数存在问题。和以前一样,我们使用 torch.profiler.record_function 来识别有问题的代码行。在本例中,它是断言调用。

def weighted_nll(pred, target, weight):
    with torch.profiler.record_function('assert'):
        assert target.max() < 10
    with torch.profiler.record_function('range'):
        r = range(target.shape[0])
    with torch.profiler.record_function('index'):
        nll = -pred[r, target]
    with torch.profiler.record_function('nll_calc'):
        nll = nll * weight[target]
        nll = nll/ weight[target].sum()
        sum_nll = nll.sum()
    return sum_nll

请注意,这个问题也存在于基础实验中,但被我们之前的性能问题隐藏了。在性能优化过程中,以前被其他问题隐藏的严重问题突然以这种方式出现的情况并不罕见。

对调用堆栈的仔细分析显示了对“item”、“_local_scalar_dense”和“cudaMemcpyAsync”的调用。这通常表明数据正在从 GPU 复制到主机。事实上,我们在 CPU 上执行的断言调用需要访问驻留在 GPU 上的目标张量,从而调用效率极低的数据复制。

优化2:从训练步骤中删除冗余的 GPU 到主机副本

虽然验证输入标签的合法性可能是有必要的,但其方式应该不会对我们的训练性能产生如此负面的影响。在我们的例子中,解决问题很简单,只需在将标签复制到 GPU 之前将断言移动到数据输入管道即可。删除断言后,我们的性能仍然基本保持不变:

alt

重要提示:虽然我们的目标通常是尝试减少前向传播中主机和 GPU 之间的副本,但有时这是不可能的(例如,如果我们需要 GPU 不支持的内核)或不受欢迎的(例如,如果在 CPU 上运行特定内核会提高性能)。

分析跟踪视图向我们介绍了下一个性能问题:

alt

我们再次看到之前的优化发现了一个新的严重性能问题,这次是在索引我们的 pred 张量时。索引由 r 和目标张量定义。虽然目标张量已经驻留在 GPU 上,但上一行定义的 r 张量却没有。这再次触发低效的主机到 GPU 数据复制。

优化3:用 torch.arange 替换 range

Python 的 range 函数在 CPU 上输出一个列表。训练步骤中任何列表的存在都应该是一个危险信号。在下面的代码块中,我们用 torch.arange 替换 range 的使用,并将其配置为直接在 GPU 上创建输出张量:

def weighted_nll(pred, target, weight):
    with torch.profiler.record_function('range'):
        r = torch.arange(target.shape[0], device="cuda:0")
    with torch.profiler.record_function('index'):
        nll = -pred[r, target]
    with torch.profiler.record_function('nll_calc'):
        nll = nll * weight[target]
        nll = nll/ weight[target].sum()
        sum_nll = nll.sum()
    return sum_nll

本次优化的结果如下所示:

alt

现在我们正在说话!!我们的步长时间已降至 5.8 毫秒,性能提升了 3700%。

更新后的跟踪视图显示损失函数已降至非常合理的 0.5 毫秒。

alt

但仍有改进的空间。让我们仔细看看weighted_nll函数的跟踪视图,它占据了损失计算的大部分。

alt

从跟踪中我们可以看到,该函数由多个小块组成,每个小块最终映射到一个单独的 CUDA 内核,该内核通过 CudaLaunchKernel 调用加载到 GPU 上。理想情况下,我们希望减少 GPU 内核的总数,从而减少 CPU 和 GPU 之间的交互量。一种方法是尽可能选择更高级别的 PyTorch 运算符,例如 torch.nn.NLLLoss。此类函数被认为将底层操作“融合”在一起,因此需要较少数量的总体内核。

优化5:避免在训练步骤中初始化对象

在下面的代码块中,我们修改了损失实现,以便在 init 函数中创建 torch.nn.NLLLoss 的单个实例。

class CrossEntropyLoss(nn.Module):
    def __init__(self):
        super().__init__()
        self.weight = torch.Tensor([0.1]*10).cuda()
        self.nll = torch.nn.NLLLoss(self.weight) 

    def forward(self, input, target):
        pred = log_softmax(input)
        loss = self.nll(pred, target)
        return loss

结果显示步骤时间进一步改善,现在为 5.2 毫秒。

alt

优化6:使用 torch.nn.CrossEntropyLoss 而不是自定义损失

PyTorch 包含一个内置的 torch.nn.CrossEntropyLoss,我们现在对其进行评估并与我们的自定义损失实现进行比较。

criterion = torch.nn.CrossEntropyLoss().cuda(device)

由此产生的步长时间达到了 5 毫秒的新低,整体性能提升了 4200%(与我们开始时的 216 毫秒相比)。

alt

损失计算的前向传递的性能提升更加显着:从 211 毫秒的起始点,我们一路下降到 79 微秒(!!),如下所示:

alt

优化7:编译损失函数

对于我们的最终优化尝试,我们将使用 torch.compile API 将损失函数配置为在图形模式下运行。正如我们在本文中详细讨论并在本文前传中演示的那样,torch.compile 将使用内核融合和乱序执行等技术,以以下方式将损失函数映射到低级计算内核:最适合底层训练加速器。

criterion = torch.compile(torch.nn.CrossEntropyLoss().cuda(device))

下图显示了该实验的 Trace View 结果。

alt

我们首先看到的是包含“OptimizedModule”和“dynamo”的术语的出现,它们表明了 torch.compile 的使用。我们还可以看到,在实践中,模型编译并没有减少损失函数加载的内核数量,这意味着它没有识别任何额外内核融合的机会。事实上,在我们的例子中,损失编译实际上导致损失函数的前向传递时间从 79 微秒增加到 154 微秒。看来 CrossEntropyLoss 还不够丰富,无法从这种优化中受益。

您可能想知道为什么我们不能将 torch 编译应用于我们的初始损失函数并依靠它以最佳方式编译我们的代码。这可以省去我们上面描述的逐步优化的所有麻烦。这种方法的问题在于,尽管 PyTorch 2.0 编译(截至撰写本文时)确实优化了某些类型的 GPU 到 CPU 交叉,但某些类型会使图形编译崩溃,而另一些类型将导致创建多个小图而不是单个大图。最后一类会导致图表中断,这从本质上限制了 torch.compile 功能提高性能的能力。 (解决此问题的一种方法是调用 torch.compile,并将 fullgraph 标志设置为 True。)

结果

在下表中,我们总结了我们运行的实验的结果:

alt

我们的连续优化带来了令人惊叹的 4143% 性能提升!回想一下,我们从一个看起来很无辜的损失函数开始。如果没有对应用程序的行为进行深入分析,我们可能永远不会知道有什么问题,并且会继续我们的生活,同时支付比我们需要的多 41 倍(!!)的费用。

您可能已经注意到,在我们的最终试验中,GPU 利用率显着下降。这表明进一步性能优化的巨大潜力。虽然我们的示威已接近尾声,但我们的工作还没有完成。

总结

让我们总结一下我们学到的一些东西。我们将摘要分为两部分。首先,我们描述了一些可能影响训练性能的编码习惯。在第二部分中,我们推荐一些性能分析技巧。请注意,这些结论基于我们在本文中分享的示例,可能不适用于您自己的用例。机器学习模型的属性和行为差异很大。因此,强烈建议您根据自己项目的细节来评估这些结论。

Reference

[1]

Source: https://towardsdatascience.com/pytorch-model-performance-analysis-and-optimization-part-2-3bc241be91

本文由 mdnice 多平台发布

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

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

36、springboot --- 对 tomcat服务器 和 undertow服务器 配置访客日志

springboot 配置访客日志 ★ 配置访客日志&#xff1a; 访客日志&#xff1a; Web服务器可以将所有访问用户的记录都以日志的形式记录下来&#xff0c;主要就是记录来自哪个IP的用户、在哪个时间点、访问了哪个资源。 Web服务器可将所有访问记录以日志形式记录下来&#xff…

Vue2向Vue3过度核心技术进阶语法

目录 1 v-model简化代码1.目标&#xff1a;2.如何简化&#xff1a;3.代码示例 2 sync修饰符1.作用2.场景3.本质4.语法5.代码示例6.总结 3 ref和$refs1.作用2.特点&#xff1a;3.语法4.注意5.代码示例 4 异步更新 & $nextTick1.需求2.代码实现3.问题4.解决方案 1 v-model简化…

MES管理系统如何让传统汽车行业从“制造”走向“智造”

在传统制造业中&#xff0c;“数字化转型”是一个老生常谈的话题&#xff0c;然而&#xff0c;许多传统制造业仍处于“信息化”的阶段&#xff0c;距离真正的数字化还有很长的路要走。如果要在所有传统制造行业中寻找那些可以成为转型“先行者”的领域&#xff0c;那么深度与广…

水果flstudio好用吗?中文版FL21最新版本如何下载

FL Studio21版是一款功能强大的音乐制作软件&#xff0c;广泛应用于电子音乐、流行音乐、电影配乐等领域。它提供了丰富多样的音频合成和编辑工具&#xff0c;使音乐制作变得更加灵活多样。无论是初学者还是专业音乐制作人&#xff0c;都可以通过直观的界面和丰富的音频特效来实…

【IMX6ULL驱动开发学习】12.Linux SPI驱动实战:DAC驱动设计流程

基础回顾&#xff1a; 【IMX6ULL驱动开发学习】10.Linux I2C驱动实战&#xff1a;AT24C02驱动设计流程_阿龙还在写代码的博客-CSDN博客 【IMX6ULL驱动开发学习】11.Linux之SPI驱动_阿龙还在写代码的博客-CSDN博客 一、编写驱动 查看芯片手册&#xff0c;有两种DAC数据格式&a…

量化QAT QLoRA GPTQ

模型量化的思路可以分为PTQ&#xff08;Post-Training Quantization&#xff0c;训练后量化&#xff09;和QAT&#xff08;Quantization Aware Training&#xff0c;在量化过程中进行梯度反传更新权重&#xff0c;例如QLoRA&#xff09;&#xff0c;GPTQ是一种PTQ的思路。 QAT…

CORBA-架构师真题(一十三)

1、某企业内部主要功能已封装web服务。为了扩展业务&#xff0c;需要将现有业务功能 进行多种组合&#xff0c;形成新的业务功能。针对业务灵活组合的要求&#xff0c;采用&#xff08;&#xff09;架构风格最为合适。 答案&#xff1a; 解释器 解析&#xff1a; 解释器分下…

Windows 转 mac 记录

初次从Windows转mac可能会不适应&#xff0c;建议先看看 【6分钟搞定MacBook】不懂时无所适从&#xff0c;学会后越用越爽&#xff01;_哔哩哔哩_bilibili 我主要是做一些补充记录 1、Windows的右键等于mac的双击触控板、control单击触控板 2、运行中的应用下方会有一个点&…

项目部署之后页面没有刷新怎么办?

最近项目部署成功之后&#xff0c;突然产品找我&#xff0c;上线之后&#xff0c;页面没有生效&#xff0c;这是怎么回事&#xff1f;我这是第一次部署这个项目,也不太清楚历史问题,接下来就慢慢寻找答案吧, 如果心急的可以直接看后面的总结&#xff0c;下面我们好好聊聊缓存的…

桃子叶片病害识别(Python代码,pyTorch框架,深度卷积网络模型,很容易替换为其它模型,带有GUI识别界面)

1.分为三类 健康的桃子叶片 &#xff0c;251张 桃疮痂病一般&#xff0c;857张 桃疮痂病严重&#xff0c;770 张 2. GUI界面识别效果和predict.py识别效果如视频所示桃子叶片病害识别&#xff08;Python代码&#xff0c;pyTorch框架&#xff0c;深度卷积网络模型&#xff0…

python实现的淘宝自动发货脚本

pyhton # 文件用ini # 自动应答&#xff0c;自动点发货

从零做软件开发项目系列之五——系统开发过程

前言 在软件项目的设计开发过程中&#xff0c;除了前期的用户需求调研确认&#xff0c;系统设计、数据库设计等工作之外&#xff0c;还有一些重要的工作需要考虑&#xff0c;比如软件开发模式&#xff0c;如何制定开发计划&#xff0c;如何协调开发人员开展开发工作等。本文将…

分布式事务篇-2.4 Spring-Boot整合Seata

文章目录 前言一、pom jar导入:二、项目配置&#xff1a;2.1 配置 说明&#xff1a;2.1 .1 seata server 端:2.1 .2 seata client 端: 2.2 开启seata 对于数据源的代理:2.3 seata-client 的注册中心&#xff1a;2.4 seata-client 的配置中心&#xff1a;2.5 去掉手写的数据源代…

-9501 MAL系统没有配置或者服务器不是企业版(dm8达梦数据库)

dm8达梦数据库 -9501 MAL系统没有配置或者服务器不是企业版&#xff09; 环境介绍1 环境检查2 问题原因 环境介绍 搭建主备集群时&#xff0c;遇到报错-9501 MAL系统没有配置或者服务器不是企业版 1 环境检查 检查dmmal.ini配置文件权限正确 dmdba:dinstall&#xff0c;内容正…

2023年下软考信息安全工程师报名时间及汇总(附备考攻略)

信息安全工程师是近几年新增的中级考试科目&#xff0c;一般在下半年考试&#xff0c;难度还是有的&#xff0c;但是只要你有恒心&#xff0c;愿意付出&#xff0c;认真刷题备考&#xff0c;拿下证书还是没有问题的&#xff01; 2023年下半年由于考试改革&#xff0c;报名时间…

kubernetes如何使用kruise-rollout进行分批灰度发布

前言 部署在 Kubernetes 集群中的应用&#xff0c;在升级发布时可能会存在的问题&#xff1a; 1&#xff0c;由于 Kuberneter 底层 Pod 容器生命周期与网络组件生命周期是异步管理的&#xff0c;在升级时如果没有处理好应用优雅退出的问题&#xff0c;就很容易导致 http 访问请…

【JAVA】抽象类与接口--下

⭐ 作者&#xff1a;小胡_不糊涂 &#x1f331; 作者主页&#xff1a;小胡_不糊涂的个人主页 &#x1f4c0; 收录专栏&#xff1a;浅谈Java &#x1f496; 持续更文&#xff0c;关注博主少走弯路&#xff0c;谢谢大家支持 &#x1f496; 抽象类与接口 1. 实现多个接口2. 接口间…

Linux常用命令_文件处理命令

文章目录 1. 命令格式与目录处理命令ls1.1 命令格式1.2 目录处理命令&#xff1a;ls 2. 目录处理命令2.1 目录处理命令&#xff1a;mkdir2.2 目录处理命令&#xff1a;cd2.3 目录处理命令&#xff1a;pwd2.4 目录处理命令&#xff1a;rmdir2.5 目录处理命令&#xff1a;cp2.6 目…

openGauss学习笔记-50 openGauss 高级特性-DB4AI

文章目录 openGauss学习笔记-50 openGauss 高级特性-DB4AI50.1 关键字解析50.2 使用指导 openGauss学习笔记-50 openGauss 高级特性-DB4AI openGauss当前版本支持了原生DB4AI能力&#xff0c;通过引入原生AI算子&#xff0c;简化操作流程&#xff0c;充分利用数据库优化器、执…

【PHP】字符串数组

文章目录 字符串类型字符串转义字符串相关函数 数组定义方式数组特点二维数组数组遍历foreach遍历语法for循环遍历数组相关函数相关题目 字符串类型 定义语法 引号方式&#xff1a;比较适合定义那些比较短&#xff08;不超过一行&#xff09;或者没有结构要求的字符串 1&#…