Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/heft-native/.cargo/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[target.x86_64-unknown-linux-gnu]
rustflags = ["-C", "target-feature=+crt-static"]
42 changes: 32 additions & 10 deletions apps/heft-native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,28 @@ cargo build --release
The binary is written to `apps/heft-native/target/release/heft` (`target/` is ignored by Git). The crate has
no dependencies; `Cargo.lock` only lists `heft-native` itself.

On x86_64 Linux the release binary is linked statically against glibc (`.cargo/config.toml` adds
`-C target-feature=+crt-static`), as a position-independent executable whose relative relocations are packed
(`-z pack-relative-relocs`, added by `build.rs` only when the build machine has glibc 2.36 or newer, whose static
startup code applies them). A static binary starts without the dynamic loader, so it starts faster and uses less
memory. The binary provides its own C `main` (`src/sys/process_entry.rs`) instead of the Rust runtime's, which
skips work the binary does not need at startup, such as reading `/proc/self/maps` to locate the main thread's
stack.

To build a dynamically linked binary instead, override the flags:

```bash
RUSTFLAGS="-C target-feature=-crt-static" cargo build --release
```

### Profile-guided build (optional)

`bash pgo-release-build.sh` builds an instrumented binary, runs it on a generated project that only uses Heft's
built-in tasks and on the `--help` pages of the build tests, merges the profile with `llvm-profdata` (from
`rustup component add llvm-tools`) and rebuilds `target/release/heft` with it. The profile is regenerated from the
current code on every run, so it never goes stale. The result is a few percent faster on native runs but about
60 KB larger, so it is not the default build.

## Using it

Run the binary instead of `heft` from a project folder:
Expand Down Expand Up @@ -84,13 +106,6 @@ file descriptors) makes the host refuse and the binary run cold. When no host ac
cold as usual and starts a host (`lib-commonjs/host/WarmHostEntry.js`) in the background for the next run, unless
four hosts are already running for the user.

## Static build (optional)

`cargo build --release --target x86_64-unknown-linux-musl` (after `rustup target add x86_64-unknown-linux-musl`)
produces a statically linked binary in `target/x86_64-unknown-linux-musl/release/heft`. It is about as fast as the
default build and uses less memory (about 1 MB instead of 2.8 MB peak for a native build), but it is about 90 KB
larger.

## Layout

Each folder under `src/` is a module with a single owner:
Expand All @@ -101,8 +116,15 @@ Each folder under `src/` is a module with a single owner:
| `cli` | command line model, parsing, help and error rendering |
| `config` | `heft.json`, rigs, `heft-plugin.json`, plugin options, package resolution |
| `graph`, `run`, `builtin`, `terminal` | phase and task graph, execution, native built-in plugins, terminal output |
| `sys` | the only place with `unsafe` code (minimal operating system calls) |
| `simd` | SSE2/AVX2 byte scans used by the JSON lexer and writer, each with a scalar twin that gives identical results |
| `sys` | minimal operating system calls; with `simd` the only places with `unsafe` code |
| `process`, `version`, `host_link` | running Node.js, selecting the Heft version, the connection to the JavaScript plugin host |

The code follows these rules: only the Rust standard library, `#![deny(unsafe_code)]` outside `src/sys`, no
comments in the code, source files of at most 200 lines, and a stripped release binary of at most 1 MB.
The code follows these rules: only the Rust standard library, `#![deny(unsafe_code)]` outside `src/sys` and
`src/simd`, no comments in the code, source files of at most 200 lines, and a stripped release binary of at most
1 MB.

SIMD code runs only on x86_64. AVX2 is used after `is_x86_feature_detected!("avx2")` confirms it, and SSE2
otherwise; every other architecture uses the scalar twins. Setting `HEFT_NATIVE_NO_SIMD=1` (any non-empty value
except `0`, or `HEFT_NATIVE_SIMD=0`) makes the whole process use the scalar twins, which is how the SIMD paths are
checked against them.
35 changes: 35 additions & 0 deletions apps/heft-native/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
const GLIBC_FEATURES_HEADER: &str = "/usr/include/features.h";
const FIRST_GLIBC_MINOR_VERSION_WITH_STATIC_RELR: u32 = 36;

fn main() {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed={GLIBC_FEATURES_HEADER}");
if static_glibc_startup_applies_packed_relative_relocations() {
println!("cargo:rustc-link-arg-bins=-Wl,-z,pack-relative-relocs");
}
}

fn static_glibc_startup_applies_packed_relative_relocations() -> bool {
let target_is_static_glibc = std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("linux")
&& std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("gnu")
&& std::env::var("CARGO_CFG_TARGET_FEATURE")
.unwrap_or_default()
.split(',')
.any(|target_feature| target_feature == "crt-static");
target_is_static_glibc && glibc_version_of_build_host().is_some_and(|(major, minor)| {
major > 2 || (major == 2 && minor >= FIRST_GLIBC_MINOR_VERSION_WITH_STATIC_RELR)
})
}

fn glibc_version_of_build_host() -> Option<(u32, u32)> {
let features_header = std::fs::read_to_string(GLIBC_FEATURES_HEADER).ok()?;
let defined_number = |macro_name: &str| {
features_header.lines().find_map(|header_line| {
let mut words = header_line.split_whitespace();
(words.next() == Some("#define") && words.next() == Some(macro_name))
.then(|| words.next()?.parse::<u32>().ok())
.flatten()
})
};
Some((defined_number("__GLIBC__")?, defined_number("__GLIBC_MINOR__")?))
}
102 changes: 102 additions & 0 deletions apps/heft-native/pgo-release-build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#!/usr/bin/env bash
set -euo pipefail

crate_folder="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
heft_package_folder="$(cd "$crate_folder/../heft" && pwd)"
build_tests_folder="$(cd "$crate_folder/../.." && pwd)/build-tests"
host_triple="$(rustc -vV | sed -n 's/^host: //p')"
llvm_profdata="$(rustc --print sysroot)/lib/rustlib/$host_triple/bin/llvm-profdata"
profile_folder="$crate_folder/target/pgo-profiles"
instrumented_target_folder="$crate_folder/target/pgo-instrumented"
flag_separator=$'\x1f'

if [ ! -x "$llvm_profdata" ]; then
echo "llvm-profdata was not found; install it with: rustup component add llvm-tools" >&2
exit 1
fi

rm -rf "$profile_folder"
CARGO_ENCODED_RUSTFLAGS="-Ctarget-feature=+crt-static${flag_separator}-Cprofile-generate=$profile_folder" \
cargo build --release --manifest-path "$crate_folder/Cargo.toml" --target-dir "$instrumented_target_folder"
instrumented_heft="$instrumented_target_folder/release/heft"

workload_folder="$(mktemp -d)"
trap 'rm -rf "$workload_folder"' EXIT
mkdir -p "$workload_folder/config" "$workload_folder/src/assets" "$workload_folder/temp/scratch" "$workload_folder/node_modules/@rushstack"
ln -s "$heft_package_folder" "$workload_folder/node_modules/@rushstack/heft"
cat > "$workload_folder/package.json" <<'PACKAGE_JSON'
{
"name": "heft-native-pgo-workload",
"version": "1.0.0",
"private": true,
"devDependencies": {
"@rushstack/heft": "*"
}
}
PACKAGE_JSON
cat > "$workload_folder/config/heft.json" <<'HEFT_JSON'
{
"$schema": "https://developer.microsoft.com/json-schemas/heft/v0/heft.schema.json",
"phasesByName": {
"build": {
"cleanFiles": [{ "includeGlobs": ["lib"] }],
"tasksByName": {
"set-env": {
"taskPlugin": {
"pluginPackage": "@rushstack/heft",
"pluginName": "set-environment-variables-plugin",
"options": { "environmentVariablesToSet": { "HEFT_NATIVE_PGO_WORKLOAD": "1" } }
}
},
"copy-assets": {
"taskDependencies": ["set-env"],
"taskPlugin": {
"pluginPackage": "@rushstack/heft",
"pluginName": "copy-files-plugin",
"options": {
"copyOperations": [
{ "sourcePath": "src/assets", "destinationFolders": ["lib/assets"], "fileExtensions": [".txt", ".json"] }
]
}
}
},
"delete-scratch": {
"taskDependencies": ["copy-assets"],
"taskPlugin": {
"pluginPackage": "@rushstack/heft",
"pluginName": "delete-files-plugin",
"options": { "deleteOperations": [{ "sourcePath": "temp/scratch", "includeGlobs": ["**/*"] }] }
}
}
}
}
}
}
HEFT_JSON
for asset_number in $(seq 1 40); do
echo "heft native pgo asset $asset_number" > "$workload_folder/src/assets/asset-$asset_number.txt"
done
echo '{"workload":"pgo"}' > "$workload_folder/src/assets/data.json"

run_native_workload() {
for repetition in 1 2 3 4 5; do
"$instrumented_heft" --help || true
"$instrumented_heft" --version || true
"$instrumented_heft" build --help || true
"$instrumented_heft" build --clean || true
"$instrumented_heft" build || true
"$instrumented_heft" nosuch-action || true
"$instrumented_heft" clean || true
done
}
(cd "$workload_folder" && run_native_workload) < /dev/null > /dev/null 2>&1

for project_folder in "$build_tests_folder"/*/; do
if [ -e "$project_folder/node_modules/@rushstack/heft/package.json" ]; then
(cd "$project_folder" && { "$instrumented_heft" --help || true; "$instrumented_heft" build --help || true; }) < /dev/null > /dev/null 2>&1
fi
done

"$llvm_profdata" merge -o "$profile_folder/merged.profdata" "$profile_folder"/*.profraw
CARGO_ENCODED_RUSTFLAGS="-Ctarget-feature=+crt-static${flag_separator}-Cprofile-use=$profile_folder/merged.profdata" \
cargo build --release --manifest-path "$crate_folder/Cargo.toml"
58 changes: 24 additions & 34 deletions apps/heft-native/src/builtin/build_info.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
use std::collections::HashMap;
use std::fs::{self, File};
use std::io::{BufWriter, ErrorKind, Write};

use super::build_info_json::{is_array_index_key, parse_build_info_json};
use super::build_info_json::{is_array_index_key, parse_build_info_json, BuildInfoJson};
use super::javascript_json::append_json_string;
use super::node_file_system_error::{is_node_not_exist_error, NodeFileSystemError};
use super::posix_path::{directory_name, relative_path, resolve_path};
use super::path_hash::path_hash_set_with_capacity;
use super::posix_path::{
directory_name, is_normalized_absolute_folder_path, relative_path, resolve_path, resolve_relative_path_against_normalized_folder,
};

pub struct IncrementalBuildInfo {
pub configuration_hash: String,
Expand All @@ -24,42 +26,30 @@ pub fn try_read_build_info(build_info_path: &str) -> BuildInfoReadResult {
Err(error) if is_node_not_exist_error(&error) => return BuildInfoReadResult::Missing,
Err(_) => return BuildInfoReadResult::NeedsJavaScript,
};
let Some(parsed) = std::str::from_utf8(&bytes).ok().and_then(parse_build_info_json) else {
let Some(BuildInfoJson { configuration_hash, input_file_versions: relative_file_versions }) =
std::str::from_utf8(&bytes).ok().and_then(parse_build_info_json)
else {
return BuildInfoReadResult::NeedsJavaScript;
};
drop(bytes);
let base_folder_path = directory_name(build_info_path);
let mut input_file_versions: Vec<(String, String)> = parsed
.input_file_versions
let base_folder_is_normalized = is_normalized_absolute_folder_path(base_folder_path);
let input_file_versions: Vec<(String, String)> = relative_file_versions
.into_iter()
.map(|(relative_file_path, version)| (resolve_path(base_folder_path, &relative_file_path), version))
.map(|(relative_file_path, version)| {
let absolute_file_path = base_folder_is_normalized
.then(|| resolve_relative_path_against_normalized_folder(base_folder_path, &relative_file_path))
.flatten()
.unwrap_or_else(|| resolve_path(base_folder_path, &relative_file_path));
(absolute_file_path, version)
})
.collect();
let mut duplicates: Vec<(usize, usize)> = Vec::new();
{
let mut first_index_by_path: HashMap<&str, usize> = HashMap::with_capacity(input_file_versions.len());
for (index, (absolute_file_path, _)) in input_file_versions.iter().enumerate() {
if let Some(&first_index) = first_index_by_path.get(absolute_file_path.as_str()) {
duplicates.push((first_index, index));
} else {
first_index_by_path.insert(absolute_file_path, index);
}
}
}
if !duplicates.is_empty() {
for &(first_index, duplicate_index) in &duplicates {
input_file_versions[first_index].1 = std::mem::take(&mut input_file_versions[duplicate_index].1);
}
let mut is_duplicate = vec![false; input_file_versions.len()];
for &(_, duplicate_index) in &duplicates {
is_duplicate[duplicate_index] = true;
}
let mut flags = is_duplicate.into_iter();
input_file_versions.retain(|_| !flags.next().unwrap_or(false));
drop(bytes);
let mut seen_paths = path_hash_set_with_capacity::<&str>(input_file_versions.len());
if !input_file_versions.iter().all(|(absolute_file_path, _)| seen_paths.insert(absolute_file_path)) {
return BuildInfoReadResult::NeedsJavaScript;
}
BuildInfoReadResult::Found(IncrementalBuildInfo {
configuration_hash: parsed.configuration_hash,
input_file_versions,
})
drop(seen_paths);
BuildInfoReadResult::Found(IncrementalBuildInfo { configuration_hash, input_file_versions })
}

pub fn write_build_info<'entries>(
Expand All @@ -71,7 +61,7 @@ pub fn write_build_info<'entries>(
let relative_entries = input_file_versions.map(|(absolute_file_path, version)| (relative_path(base_folder_path, absolute_file_path), version));
let mut array_index_entries: Vec<(String, &str)> =
relative_entries.clone().filter(|(key, _)| is_array_index_key(key)).collect();
array_index_entries.sort_by_key(|(key, _)| key.parse::<u64>().unwrap_or(0));
array_index_entries.sort_unstable_by_key(|(key, _)| key.parse::<u64>().unwrap_or(0));
let named_entries = relative_entries.filter(|(key, _)| !is_array_index_key(key));
let file = create_file_ensuring_folder_exists(build_info_path)?;
let mut writer = BufWriter::with_capacity(16384, file);
Expand Down
48 changes: 29 additions & 19 deletions apps/heft-native/src/builtin/build_info_json.rs
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
pub struct BuildInfoJson {
use std::borrow::Cow;

pub struct BuildInfoJson<'text> {
pub configuration_hash: String,
pub input_file_versions: Vec<(String, String)>,
pub input_file_versions: Vec<(Cow<'text, str>, String)>,
}

pub fn parse_build_info_json(text: &str) -> Option<BuildInfoJson> {
pub fn parse_build_info_json(text: &str) -> Option<BuildInfoJson<'_>> {
let mut reader = StrictJsonReader { bytes: text.as_bytes(), position: 0 };
let mut configuration_hash = None;
let mut input_file_versions = None;
reader.expect_byte(b'{')?;
loop {
let key = reader.read_string()?;
reader.expect_byte(b':')?;
match key.as_str() {
"configHash" if configuration_hash.is_none() => configuration_hash = Some(reader.read_string()?),
match &*key {
"configHash" if configuration_hash.is_none() => configuration_hash = Some(reader.read_string()?.into_owned()),
"inputFileVersions" if input_file_versions.is_none() => input_file_versions = Some(reader.read_string_map()?),
_ => return None,
}
Expand Down Expand Up @@ -42,7 +44,7 @@ struct StrictJsonReader<'text> {
position: usize,
}

impl StrictJsonReader<'_> {
impl<'text> StrictJsonReader<'text> {
fn skip_whitespace(&mut self) {
while let Some(b' ' | b'\t' | b'\n' | b'\r') = self.bytes.get(self.position) {
self.position += 1;
Expand All @@ -65,10 +67,9 @@ impl StrictJsonReader<'_> {
}
}

fn read_string_map(&mut self) -> Option<Vec<(String, String)>> {
fn read_string_map(&mut self) -> Option<Vec<(Cow<'text, str>, String)>> {
self.expect_byte(b'{')?;
let mut entries: Vec<(String, String)> = Vec::new();
let mut seen_keys: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut entries: Vec<(Cow<'text, str>, String)> = Vec::new();
self.skip_whitespace();
if self.bytes.get(self.position) == Some(&b'}') {
self.position += 1;
Expand All @@ -77,8 +78,8 @@ impl StrictJsonReader<'_> {
loop {
let key = self.read_string()?;
self.expect_byte(b':')?;
let value = self.read_string()?;
if is_array_index_key(&key) || !seen_keys.insert(key.clone()) {
let value = self.read_string()?.into_owned();
if is_array_index_key(&key) {
return None;
}
entries.push((key, value));
Expand All @@ -88,23 +89,32 @@ impl StrictJsonReader<'_> {
}
}

fn read_string(&mut self) -> Option<String> {
fn read_string(&mut self) -> Option<Cow<'text, str>> {
self.expect_byte(b'"')?;
let mut value = String::new();
let bytes: &'text [u8] = self.bytes;
let mut value: Option<String> = None;
loop {
let start = self.position;
while let Some(&byte) = self.bytes.get(self.position) {
while let Some(&byte) = bytes.get(self.position) {
if byte == b'"' || byte == b'\\' || byte < 0x20 {
break;
}
self.position += 1;
}
value.push_str(std::str::from_utf8(&self.bytes[start..self.position]).ok()?);
let byte = *self.bytes.get(self.position)?;
let unescaped_run = std::str::from_utf8(&bytes[start..self.position]).ok()?;
let byte = *bytes.get(self.position)?;
self.position += 1;
match byte {
b'"' => return Some(value),
b'\\' => value.push(self.read_escape()?),
match (byte, value.as_mut()) {
(b'"', None) => return Some(Cow::Borrowed(unescaped_run)),
(b'"', Some(escaped_value)) => {
escaped_value.push_str(unescaped_run);
return value.map(Cow::Owned);
}
(b'\\', _) => {
let escaped_value = value.get_or_insert_with(String::new);
escaped_value.push_str(unescaped_run);
escaped_value.push(self.read_escape()?);
}
_ => return None,
}
}
Expand Down
Loading