首页 > 其他分享 >Rust In Action 五 Data in depth

Rust In Action 五 Data in depth

时间:2022-12-08 20:11:23浏览次数:33  
标签:exponent .. bits f32 depth let Action bit Data

这一章包含

  • 学习计算机如何表示数据
  • 构建一个可以工作的CPU模拟器
  • 创建你自己的数字类型
  • 理解浮点数

这一章完全是关于理解0与1是如何构成像文本、图片以及声音这样的大型对象的,我们也将了解计算机如何进行计算。

在这一章的末尾,我们将模拟一个功能完备的,具有CPU、内存以及用户定义方法的计算机,你将分解浮点数以构建一个你自己的,只占用一字节的数字类型。这一章还会引入一些术语,比如字节顺序(endianness)以及整数溢出(integer overflow),这些属于对于并没有做过系统编程的程序员来说可能不太熟悉。

位模式以及类型

一个小而重要的事是——一个位模式(在不同情况下)可能代表不同的东西,像Rust这样的高级语言的类型系统只是对现实世界的人工抽象,在你开始挖掘这些抽象之下的东西,并且想要得到对计算机如何运行的更深层次的理解之前,知道这件事是很有必要的。

Listing 5.1 (ch5-int-vs-int.rs)是一个使用相同的位模式来表示两种不同数字类型的例子,区分这些位模式的类型的工作是由类型系统(并不是CPU)来做的。下面展示了listing的输出:

a: 1100001111000011 50115
b: 1100001111000011 -15421
// LISTING 5.1
fn main() {
    let a: u16 = 50115;
    let b: i16 = -15421;

    // 这两个值具有相同的位模式 但它们类型不同
    println!("a: {:016b} {}", a, a);
    println!("b: {:016b} {}", b, b);
}

在位字符串和数字之间的不同映射解释了二进制文件和文本文件之间的部分区别。文本文件仅仅是遵循了在位字符串以及字符之间的某种一致的映射的二进制文件,这一种映射就称为一个编码。Arbitrary files don’t describe their meaning to the outside world, which makes these opaque.

我们可以更深一步,当我们让Rust将一个类型产生的位模式当成另一种类型使用时,会发生什么?下面的listing提供了一个答案。

// Listing 5.2
fn main() {
    let a: f32 = 42.42;

    let frankentype: u32 = unsafe {
        std::mem::transmute(a)
    };
    // 将f32类型的42.42的位串按小数查看
    println!("{}", frankentype);
    // {:032b}代表以32位通过std::fmt::Binary trait来格式化,左面补0
    println!("{:032b}", frankentype);

    let b: f32 = unsafe {
        std::mem::transmute(frankentype)
    };
    println!("{}", b);
    // 确定操作是对称的
    assert_eq!(a, b); 
}

编译并运行,Listing 5.2中的代码产生了如下输出:

1110027796
01000010001010011010111000010100
42.42

mem::transmute()方法告诉rust将f32解释成一个u32,但不修改任何底层位。

在程序中(像如上那样)混用数据类型容易造成混乱,所以我们需要在unsafe块中包裹这些操作。unsafe告诉Rust编译器,“别动,我会自己小心的”,这是你给编译器的一个信号,你告诉它你具有比它更多的上下文来验证程序的正确性。

使用unsafe关键字并不意味着(其内部的)代码就是不安全的了。举个例子,它并不允许你忽视rust的借用检查,它只是指出了编译器无法靠自己来保证程序的内存正确性。使用unsafe意味着程序员具有必须保证程序的完好的责任。

警告: 一些允许在unsafe块中使用的功能可能比其它的更加难以验证。比如,std::mem::transmute()方法就是语言中最不安全的方法之一,它打破了所有的类型安全。在你使用之前,请确认是否有其它的替代办法。

在Rust社区中,不必要的使用unsafe块是非常不推荐的,这可能让你的软件暴露在严重的安全风险下。它的主要目的是允许Rust与外部代码交互,例如一些使用其它语言编写的库以及OS接口。比起其它项目来说,这本书会经常使用unsafe块,这是因为我们的代码只是教学的工具,而不是工业软件。unsafe允许你查看和修改独立的字节,这是那些想去理解计算机如何工作的人的基础知识。

整数的一生

在前面的章节中,我们花了一些时间来讨论i32u8usize这些整数类型的意义。整数就像小巧精妙的鱼,它们完美的完成它们该做的,但是如果将它们带离它们的自然水域(range 范围),它们很快就会去世。

整数具有固定的范围(range)。每一个整数类型,当我们在计算机中表示它们时,它们都占用一个固定的位数。不像浮点数,整数不能牺牲它们的精度去扩展它们的范围。一旦这些位已经被填满1,唯一的前进方向就是所有位都回到0。

一个16位的整数可以表示0~65535(不包含)这些数字,当你想加到65536时会发生什么?我们来试试。

我们需要研究的这类技术术语称作——整数溢出(integer overflow)。溢出整数的一个方式是一直递增。

// Listing 5.3
fn main() {
    let mut i: u16 = 0;
    print!("{}..", i);
    loop {
        i += 1000;
        print!("{}..", i);
        if i % 10000 == 0 {
            println!("");
        }
    }
}

当我们尝试运行listing 5.3,我们的程序并没有正常的结束,让我们来看下输出:

0..1000..2000..3000..4000..5000..6000..7000..8000..9000..10000..
11000..12000..13000..14000..15000..16000..17000..18000..19000..20000..
21000..22000..23000..24000..25000..26000..27000..28000..29000..30000..
31000..32000..33000..34000..35000..36000..37000..38000..39000..40000..
41000..42000..43000..44000..45000..46000..47000..48000..49000..50000..
51000..52000..53000..54000..55000..56000..57000..58000..59000..60000..
thread 'main' panicked at 'attempt to add with overflow', examples/integer_overflow.rs:6:9
stack backtrace:
    ... omit some lines ...
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.
61000..62000..63000..64000..65000..
Process finished with exit code 101

一个panic了的程序是一个死程序(dead program 也许是说是一个错误的程序),panic意味着程序员让程序做了一些做不到的事,程序不知道该怎么处理,于是它把自己关掉了。

为了了解为什么这是一类严重的bug,让我们看看底层发生了什么。Listing 5.4(ch5/ch5-bit-patterns.rs)打印了以位模式字面量形式定义的六个数字。

// Listing 5.4
fn main() {
    let zero: u16 = 0b0000_0000_0000_0000;
    let one: u16 = 0b0000_0000_0000_0001;
    let two: u16 = 0b0000_0000_0000_0010;
    // ...
    let sixtyfivethousand_533: u16 = 0b1111_1111_1111_1101;
    let sixtyfivethousand_534: u16 = 0b1111_1111_1111_1110;
    let sixtyfivethousand_535: u16 = 0b1111_1111_1111_1111;

    print!("{}, {}, {}, ..., ", zero, one, two);
    println!("{}, {}, {}", sixtyfivethousand_533, sixtyfivethousand_534, sixtyfivethousand_535);
}

当编译后,listing打印了下面这短短的一行:

0, 1, 2, ..., 65533, 65534, 65535

尝试通过rustc -O ch5-to-oblivioin.rs在开启优化的情况下编译代码,并运行编译后的可执行文件。行为有点不同,我们感兴趣的问题是当没有剩余的位时会发生什么,65536无法通过u16表示。(应该是在说我们无法通过字面量形式将65535赋值给u16变量,这样无法通过编译)

有一个更简单的使用类似技术杀掉一个程序的方法。在listing 5.5中,我们让Rust向u8中填入400,而它实际只能容纳255个值。看下面的listing中的内容:

// 下面这行是必须的,rust编译器可以检测到这种明显的溢出情况(所以必须加上它让编译器忽略)
#[allow(arithmetic_overflow)]
fn main() {
    let (a, b) = (200, 200);
    let c: u8 = a + b;
    println!("200 + 200 = {}", c);
}

代码编译了,但是下面两件事发生了:

  • 程序panic:

    thread 'main' panicked at 'attempt to add with overflow', examples/ch5-bit-patterns.rs:4:17
    stack backtrace:
        ... omit some lines ...
    note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.
    

    这个行为可以在使用rustc的默认选项下发生

    rustc ch5-impossible-add.rs && ch5-impossible-add
    
  • 程序给了你错误的答案:

    200 + 200 = 144
    

    这个行为会在使用-O参数执行rustc时发生

    rustc -O ch5-impossible-add.rs && ch5-impossible-add
    

这里有两个小知识:

  • 理解你所使用的类型的限制是很重要的
  • 即使Rust很强,用Rust编写的程序依然可能会歇菜

开发防止整数溢出的策略是系统程序员区别于其它程序员的方式之一。只有动态类型语言经验的程序员几乎不太可能遇到整数溢出。动态语言通常检测整数表达式的结果可以匹配类型的限制,当它们不能时,接收结果的变量就会变成一个更宽的整数类型。

当开发一个性能很重要的代码时,你可以选择要调整的参数。如果你使用一个固定大小的类型,你获得了速度,但是你需要接受一些风险。为了减轻这些风险,你可以检查溢出不会在运行时发生,同时,施加这些检查会拖慢你的效率。另外,更广泛的一个选项是,牺牲空间,使用更大的整数类型,比如i64。当你仍需要更大时,你可以选择任意大的整数,当然,它们也有自己的成本。

理解字节顺序

CPU厂商们争论的是构成整数的独立字节该如何排列。一些CPU将多个字节的序列从左到右排列,而另一些则从右到左排列,这被称为CPU的字节顺序(endianness)。这也是为什么复制一台计算机上的可执行程序到另一台计算机上可能不可用的原因之一。

译者:字节顺序是指多个字节之间的排序方式,别用位来思考,昨天没细看我就陷进去了,数了半天数

让我们考虑一个32位的整数,它其中有四个字节:AABBCC以及DD。Listing 5.6中,在我们的老朋友sys::mem::transmute()的帮助下,展示了字节序带来的问题。当编译并执行后,listing5.6中的代码可能是两种输出其中之一,这取决于你机器的字节顺序。大多数人们日常工作的计算机会打印如下内容:

-573785174 vs. -1430532899

但是更加少见的硬件会交换两个数,就像这样:

-1430532899 vs. -573785174
// Listing 5.6
use std::mem::transmute;

fn main() {
    let big_endian: [u8; 4] = [0xAA, 0xBB, 0xCC, 0xDD];
    let little_endian: [u8; 4] = [0xDD, 0xCC, 0xBB, 0xAA];

    let a: i32 = unsafe { transmute(big_endian) };
    let b: i32 = unsafe { transmute(little_endian) };

    println!("{} vs. {}", a, b);

}

术语来自于字节序列中字节的意义,我们回到学习加法的时候,我们可以将数字123分解成三个部分:

表达式 结果
100x1 100
10x2 20
1x3 3

将所有这些部分相加,我们得到了初始的数字。第一部分,100,是重要的部分(系数最大)。当我们使用惯常的方式来写这个数字时,我们把123写作123,这就是大端法格式。当我们反转这个顺序,将123写成321,这就是小端法格式。

二进制数也是一样的。每一个数字部分是2的一个幂次(\(2^0, 2^1, 2^2, ..., 2^n\))。

在1990年代后期,字节顺序是一个大问题,尤其是在服务器市场上。由于忽略了很多处理器可以支持双端字节序这一事实,Sun Microsystems、Cray、Motorola以及SGI都选择了其中一条路(大端或小端)。ARM开发了一款双端架构,Intel则走向了另一条路,显然另一条路赢了。整数几乎全部以小端法存储。

除了多字节序列的问题,这还有一个相关的字节问题。一个代表3的u8类型数据,它应该看起来像0000_0011还是1100_0000?计算机的独立位布局偏好被称为位顺序(bit endiannes或bit numbering)。不过无论如何,这种内部顺序很难影响到你每天的编程。如果想进一步了解的话,去看看你的平台的文档,看看最重要的位被排在了哪一边?

表示小数数字

我在这一章开始的时候说过,更多的了解位模式可以让你压缩你的数据。让我们到实践中来。在这一部分,你将学到如何提取位来表示一个浮点数,并且将这些注入到一个你自己创造的单字节格式中。

现在,我们手里有一些困难。机器学习的那些实践者们通常需要存储以及分派很大的模型,我们这里的目标中的一个模型仅仅是一个数字(小数)数组。这些模型中的数字经常落在0..=1-1..=1的范围中,这取决于具体的应用。从给定的信息来看,我们不需要f32f64的完整范围,为什么要使用所有的字节呢?让我们看看只使用1个字节能走多远。因为我们有一个已知的有限范围,所以创建一个可以紧凑的模拟这个范围的的十进制数字格式是可能的。

开始前,我们需要学学,在当今的计算机中,小数数据是如何表示的。

浮点数

每一个浮点数在内存中都使用科学计数法表示,如果你不熟悉科学计数法,这是一个简单的入门

科学家们使用\(1.898 \times 10^{27}\)来描述木星的质量,使用\(3.801 \times 10^{-4}\)来描述蚂蚁的质量。这一计数法的本质是可以使用相同数量的字符来描述具有截然不同的规模的数字。计算机科学家们将这一优点提取,来创建一个固定宽度的格式来编码大量的数字。一个数字中的每一位在科学计数法中都有一个角色:

  • 符号:我们的两个例子中没有显式出现,它用于表示负数(负无穷到零)
  • 尾数/数值部分(mantissa/significand):例子中的1.898、3.801
  • 基数(radix/base):我们例子中的10
  • 指数(exponent):数值的规模,也就是我们例子中的27和-4

可以很轻易地从浮点数中找到这些概念,一个浮点数是一个具有三个属性的容器:

  • 一个符号位
  • 一个指数
  • 一个数值部分

看看f32内部

图5.1展示了Rust中f32类型的内存布局。这个布局在IEEE 754-2019IEEE 754-2008标准中被称作binary32,它们的前身在IEE 754-1985中被称作single

42.42被编码成具有位模式01000010001010011010111000010100f32数据。这个位模式可以更紧凑的写成0x4229AE14。表5.1展示了(浮点数的)三个属性的值以及它们代表了什么

img

img

下面的等式将浮点数的属性解码成单一数字(用人话来说就是具有三个属性的浮点数位模式如何代表一个具体的小数)。标准中的变量(Radix、Bias)使用首字母大写的格式来写,位模式中的变量(sign_bit、mantissa、exponent)使用小写字母来写。

\[n=-1^{sign\_bit}\times mantissa\times Radix^{(exponent-Bias)}\\ n=-1^{sign\_bit}\times mantissa\times Radix^{(exponent-127)}\\ n=-1^{sign\_bit}\times mantissa\times Radix^{(132-127)}\\ n=-1^{sign\_bit}\times mantissa\times 2^{(132-127)}\\ n=-1^{sign\_bit}\times 1.325625\times 2^{(132-127)}\\ n=-1^{0}\times 1.325625\times 2^{5}\\ n=1\times 1.325625\times 32\\ n=42.42 \]

浮点数中的一个怪事是,sign_bit允许正0和负0出现,也就是说,不同的位模式比较起来可能是相同的(0和-0),并且相同的位模式比较起来可能是不同的(NAN值)。

抽取符号位

为了抽取符号位,需要将其它的位移开。比如f32,右移31位(>>31)。下面的listing是一个简短的执行右移的代码片段:

// Listing 5.7
fn main() {
    let n: f32 = 42.42;
    let n_bits: u32 = n.to_bits();
    let sign_bit = n_bits >> 31;
    // 译者自己添加的代码
    // if sign_bit == 0 {
    //     println!("positive");
    // } else {
    //     println!("negative");
    // }
}

为了保证你对发生了什么有一个更深的直觉,下面用图形绘制了详情:

  1. 从一个f32值开始
    let n: f32 = 42.42;
    
  2. 使用u32来解释f32的位,以允许位运算:
    let n_bits: u32 = n.to_bits();
    
    img
  3. 将位右移31个位置:
    let sign_bit = n_bits >> 31;
    
    img

抽取指数

为了抽取指数,必须有两个位运算。第一个,执行一个右移来覆盖数值位(>>23),然后使用AND掩码(& 0xff)来排除符号位(因为指数位有8位,右移23后剩9位,掩码排除了最高位)。

指数位仍需要进行解码,为了解码指数位,将它解释为一个8位的有符号整数,然后从结果中减去127。(就像我们在5.3.2节中讨论的,127是bias)下面的listing展示了描述上两段的步骤的代码。

let n: f32 = 42.42;
let n_bits: u32 = n.to_bits();
let exponent_ = n_bits >> 23;
let exponent_ = exponent_ & 0xff;
let exponent = (exponent_ as i32) - 127;

解释:

  1. f32数字开始

    let n: f32 = 42.42;
    
  2. 解释f32u32以允许位运算

    let n_bits: u32 = n.to_bits();
    

    img

  3. 移动指数的8位到右侧,以覆盖数值位:

    let exponent_ = n_bits >> 23;
    

    img

  4. 使用AND掩码过滤符号位,只有右侧八位可以通过掩码:

    let exponent_ = exponent_ & 0xff;
    

    img

  5. 将剩下的位解释成一个有符号整数然后减去标准中定义的bias

    let exponent = (exponent_ as i32) - 127;
    

抽取数值位

为了抽取数值位的23位,你可以使用一个AND掩码区溢出符号位以及指数位(& 0x7fffff)。然而你实际上不需要这样做,因为随后的解码步骤可以简单的忽略这些无关的位。不幸的是,数值位的解码步骤要比指数复杂很多。

为了解码数值位,使用每一位的权重乘以每一位,然后将结果相加。第一位的权重是0.5(\(2^{-1}\)),后续的每一个位的权重都是当前权重的一半,例如:0.5(\(2^{-1}\))、0.25(\(2^{-2}\))、...、0.00000011920928955078125 (\(2^{–23}\))。一个隐式的第24位可以表示为1.0(\(2^{-0}\))总被认为是存在的,除非触发特殊情况。特殊情况可以被如下的指数状态触发:

  • 当指数位全为0,数值位将作为次正规数表示(也叫非正规数)。实际而言,这个改变让小数可以表示更加接近0的数,一个次正规数是一个在0与正规数行为下能表示的最小数之间的数。
  • 当指数位全为1,小数表示无穷大和负无穷,或者Not a Number(NANNAN值代表数值结果在数学上未定义的特殊情况(比如0 / 0)或者其它无效值。

引入NAN值的操作通常是违反直觉的。例如,测试两个值是否相等总会返回false,尽管两个位模式实际是一样的。一个有趣的事儿是f32有大约4.2亿个(\(~2^{22}\))个位模式可以表示NAN

下面的listing提供了非特殊情况下代码的实现:

// Listing 5.9
let n: f32 = 42.42;
let n_bits: u32 = n.to_bits();
let mut mantissa: f32 = 1.0;
for i in 0..23 {
    let mask = 1 << i;
    let one_at_bit_i = n_bits & mask;
    if one_at_bit_i != 0 {
        let i_ = i as f32;
        let weight = 2_f32.powf( i_ - 23.0 );
        mantissa += weight;
    }
}

再次慢放上面的过程:

  1. f32值开始:

    let n: f32 = 42.42;
    
  2. f32转为u32以允许位运算:

    let n_bits: u32 = n.to_bits();
    
  3. 创建一个可变的f32值初始化为1.0(\(2^{-0}\))。这代表隐含的24位的权重:

    let mut mantissa: f32 = 1.0;
    
  4. 迭代计算数值位的小数位,添加这些这些位所代表的值到mantissa变量中:

    for i in 0..23 {
        let mask = 1 << i;
        let one_at_bit_i = n_bits & mask;
        if one_at_bit_i != 0 {
            let i_ = i as f32;
            let weight = 2_f32.powf( i_ - 23.0 );
            mantissa += weight;
        }
    }
    
    1. 使用一个临时变量i作为迭代数,从0迭代到23
      for i in 0..23 {
      
    2. 创建一个位掩码,将迭代号i作为允许通过的位,并将结果分配给mask
      let mask = 1 << i;
      
    3. 使用mask作为一个保存在n_bits中的原始数的过滤器,当原始数的第\(i\)位不是0时,one_at_bit_i将被赋予一个非零值
      let one_at_bit_i = n_bits & mask;
      
    4. 如果one_at_bit_i是非零,则执行:
      if one_at_bit_i != 0 {
      
    5. 计算在位置\(i\)处的位的权重,公式是:\(2^{i-23}\)
      let i_ = i as f32;
      let weight = 2_f32.powf( i_ - 23.0 );
      
    6. 原地添加权重到mantissa
      mantissa += weight;
      

解析Rust的浮点字面量比看起来要难
Rust的数字具有方法。为了返回离1.2更近的整数,rust使用1.2_f32.ceil()方法而不是ceil(1.2)这一函数调用。虽然这通常很方便,但这会在编译器解析你代码时导致一些问题。

举例来说,一元负号的优先级低于方法调用,这意味着可能发生预期之外的数学异常。使用括号来向编译器澄清你的意图是有帮助的。为了计算\(-1^0\),将\(1.0\)包裹在括号中:

(-1.0_f32).powf(0.0)

而不是

-1.0_f32.powf(0.0)

这将会被解释成\(-(1^0)\),因为\(-1^0\)和\(-(1^0)\)在数学上都是有效的,Rust将不会在你省略括号时抗议。

解剖一个浮点数

就像在5.4节开始时提到的,浮点数是一个具有三个属性的容器的格式,5.4.1到5.4.3节已经给了我们解剖每一个属性的工具,让我们把它们放到工作中。

Listing 5.10干了一个往返,它解剖了数字42.42的每一个属性,编码为f32的多个独立部分,然后再将它们整合起来创建另一个数字。为了将一个浮点数中的位转换成另一个浮点数,有三个任务要做:

  1. 从容器中解开这些值的位(1到26行上的to_parts()
  2. 从它们的原始位模式中解码每个值到实际的值(28到47行的decode()
  3. 执行转换科学计数法到一个普通数字的转换(49到55行的from_parts()

当我们运行listing 5.10时,它提供了编码成一个f32的数——42.42内部的两个视图:、

42.42 -> 42.42
field     |  as bits | as real number
sign      |        0 | 1
exponent  | 10000100 | 32
mantissa  | 01010011010111000010100 | 1.325625

在listing 5.10中,deconstruct_f32()使用位运算技术解剖了浮点数值的每一个属性。decode_f32_parts()展示了如何转换这些属性到相关的数。f32_from_parts()方法组合这些去创建一个单一的小数。

// Listing 5.10 复制它的代码比较难,译者这个代码和原书的稍有出入
fn to_parts(float: f32) -> (u32, u32, u32) {
    let n_bits: u32 = float.to_bits();
    let sign_bit = n_bits >> 31;
    let exponent_bit = (n_bits >> 23) & 0xff;
    let mantissa_bit = n_bits & 0x7fffff;
    (sign_bit, exponent_bit, mantissa_bit)
}

fn decode_f32_parts(
    sign_bit: u32,
    exponent_bit: u32,
    mantissa_bit: u32
) -> (f32, f32, f32) {
    let sign = (-1.0f32).powf(sign_bit as f32);
    let exponent = (exponent_bit as i32) - 127;
    let exponent = 2f32.powf(exponent as f32);

    let mut mantissa = 1.0;

    for i in 0..23 {
        let mask = 1 << i;
        if mantissa_bit & mask != 0 {
            let weight = 2f32.powf((i as f32) - 23.0);
            mantissa += weight;
        }
    }

    (sign, exponent, mantissa)
}

fn from_parts(sign: f32, exponent: f32, mantissa: f32) -> f32 {
    sign * exponent * mantissa
}

fn main() {
    let n: f32 = 42.42;

    let (sign_bit, exponent_bit, mantissa_bit)  = to_parts(n);
    let (sign, exponent, mantissa) = decode_f32_parts(sign_bit, exponent_bit, mantissa_bit);
    let result = from_parts(sign, exponent, mantissa);

    println!("{}", result);
}

理解如何从字节中解出位,意味着在你的职业生涯中,当你面对着需要解释从网络中传递过来的无类型字节的问题时,你将处于更有利的位置。

未完...无力气了

标签:exponent,..,bits,f32,depth,let,Action,bit,Data
From: https://www.cnblogs.com/lilpig/p/16967157.html

相关文章