Skip to content

Commit

Permalink
[Xamarin.Android.Build.Tasks] Add $(AndroidEnableAssemblyCompression) (
Browse files Browse the repository at this point in the history
…#4686)

Currently, Xamarin.Android supports compression of managed assemblies
within the `.apk` if the app is built with
[`$(BundleAssemblies)`=True][0], with the compressed assembly data
stored within `libmonodroid_bundle_app.so` using gzip compression and
placed in an array inside the data section of the shared library.

There are two problems with this approach:

 1. `mkbundle` emits C code, which requires a C compiler which requires
    the full Android NDK, and thus requires Visual Studio Enterprise.

 2. Reliance on Mono's `mkbundle` results in possible issues around
    [filename globbing][1] such that
    `Xamarin.AndroidX.AppCompat.Resources.dll` is improperly treated
    as a [satellite assembly][2].

Because of (2), we are planning on [removing support][3] for
`$(BundleAssemblies)` in .NET 6 ([née .NET 5][4]), which resulted in
[some pushback][5] because `.apk` size is very important for some
customers, and the startup overheads we believed to be inherent to
`$(BundleAssemblies)` turned out to be somewhat over-estimated.

To resolve the above issues, add an assembly compression mechanism
that doesn't rely on `mkbundle` and the NDK: separately compress the
assemblies and store the compressed data within the `.apk`.
Compression is performed using the [managed implementation][6] of the
excellent [LZ4][7] algorithm.  This gives us a decent compression ratio
and a much faster (de)compression speed than gzip/zlib offer.  Also,
assemblies are stored directly in the APK in their usual directory,
which allows us to [**mmap**(2)][8] them in the runtime directly from
the `.apk`.  The build process calculates the size required to store
the decompressed assemblies and adds a data section to
`libxamarin-app.so` which causes *Android* to allocate all the required
memory when the DSO is loaded, thus removing the need of dynamic memory
allocation and making the startup faster.

Compression is supported only in `Release` builds and is enabled by
default, but it can be turned off by setting the
`$(AndroidEnableAssemblyCompression)` MSBuild property to `False`.
Compression can be disabled for an individual assembly by setting the
`%(AndroidSkipCompression)` MSBuild item metadata to True for the
assembly in question, e.g. via:

	<AndroidCustomMetaDataForReferences Include="MyAssembly.dll">
	  <AndroidSkipCompression>true</AssemblySkipCompression>
	</AndroidCustomMetaDataForReferences>

The compressed assemblies still use their original name, e.g.
`Mono.Android.dll`, so that we don't have to perform any string
matching on the runtime in order to detect whether the assembly we are
asked to load is compressed or not.  Instead, the compression code
*prepends* a short header to each `.dll` file (in pseudo C code):

	struct CompressedAssemblyHeader {
	    uint32_t magic;                 // 0x5A4C4158; 'XALZ', little-endian
	    uint32_t descriptor_index;      // Index into an internal assembly descriptor table
	    uint32_t uncompressed_length;   // Size of assembly, uncompressed
	};

The decompression code looks at the `mmap`ed data and checks whether
the above header is present.  If yes, the assembly is decompressed,
otherwise it's loaded as-is.

It is important to remember that the assemblies are compressed at
build time using LZ4 block compression, which requires assembly data
to be entirely loaded into memory before compression; we do this
instead of using the LZ4 frame format to make decompression at runtime
faster.  The compression output also requires a separate buffer, thus
memory consumption at *build* time will be roughly 1.5x the size of the
largest assembly, which is reused across all assemblies.

~~ Application Size ~~

A Xamarin.Forms "Hello World" application `.apk` shrinks by 27% with
this approach for a single ABI:

|    Before (bytes) |   LZ4 (bytes) |     Δ     |
|------------------:|--------------:|:---------:|
|        23,305,194 |    16,813,034 |  -27.85%  |

Size comparison between this commit and `.apk`s created with
`$(BundleAssemblies)` =True depends on the number of enabled ABI
targets in the application.  For each ABI, `$(BundleAssemblies)`=True
creates a separate shared library, so the amount of space consumed
increases by the size of the bundle shared library.

The new compression scheme shares the compressed assemblies among all
the enabled ABIs, thus effectively creating smaller multi-ABI `.apk`s.

In the tables below, `mkbundle` refers to the APK created with
`$(BundleAssemblies)`=True, `lz4` refers to the `.apk` build with
the new compression scheme:

|                                  ABIs |  mkbundle (bytes) |   LZ4 (bytes) |    Δ    |
|--------------------------------------:|------------------:|--------------:|---------|
|   armeabi-v7a, arm64-v8a, x86, x86_64 |        27,130,240 |    16,813,034 | -38.03% |
|                             arm64-v8a |         7,783,449 |     8,746,878 | +11.01% |

The single API case is ~11% larger because gzip offers better
compression, at the cost of higher runtime startup overhead.

~~ Startup Performance ~~

When launching the Xamarin.Forms "Hello World" application on a
Pixel 3 XL, the use of LZ4-compressed assemblies has at worst a ~1.58%
increase in the Activity Displayed time (64-bit app w/ assembly
preload enabled), while slightly faster on 32-bit apps, but is *always*
faster than the mkbundle startup time for all configurations:

|                                   |           |               |           |  LZ4 vs  |   LZ4 vs   |
|                       Description | None (ms) | mkbundle (ms) |  LZ4 (ms) |  None Δ  | mkbundle Δ |
|----------------------------------:|----------:|--------------:|----------:|:--------:|:----------:|
|     preload enabled; 32-bit build |     795.8 |         855.6 |     783.8 | -0.25% ✓ |  -7.22% ✓  |
|    preload disabled; 32-bit build |     777.1 |         843.0 |     780.5 | +0.44% ✗ |  -7.41% ✓  |
|     preload enabled; 64-bit build |     779.0 |         843.0 |     791.5 | +1.58% ✗ |  -6.82% ✓  |
|    preload disabled; 64-bit build |     776.0 |         841.6 |     781.5 | +0.69% ✗ |  -7.15% ✓  |

[0]: https://docs.microsoft.com/en-us/xamarin/android/deploy-test/release-prep/?tabs=windows#bundle-assemblies-into-native-code
[1]: dotnet/android-libraries#64
[2]: https://github.com/mono/mono/blob/9b4736d4c271e9d4e04cafa258ddd58961f1a39f/mcs/tools/mkbundle/mkbundle.cs#L1315-L1317
[3]: dotnet/android-libraries#64 (comment)
[4]: https://devblogs.microsoft.com/dotnet/announcing-net-5-preview-4-and-our-journey-to-one-net/
[5]: dotnet/android-libraries#64 (comment)
[6]: https://www.nuget.org/packages/K4os.Compression.LZ4/
[7]: https://github.com/lz4/lz4
[8]: https://linux.die.net/man/2/mmap
  • Loading branch information
grendello authored and jonpryor committed May 26, 2020
1 parent a98853f commit 444ca3f
Show file tree
Hide file tree
Showing 28 changed files with 785 additions and 46 deletions.
4 changes: 4 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,7 @@
path = external/xamarin-android-tools
url = https://github.com/xamarin/xamarin-android-tools
branch = d16-7
[submodule "lz4"]
path = external/lz4
url = https://github.com/lz4/lz4.git
branch = master
4 changes: 4 additions & 0 deletions Documentation/building/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,7 @@ Overridable MSBuild properties include:
Xamarin.Android install to the local build output. This enables proprietary
features such as debugging and fast deployment. Since a "normal" OSS build would
not include proprietary files, this flag also emits a warning when enabled.

* `$(AndroidEnableAssemblyCompression)`: Defaults to `True`. When enabled, all the
assemblies placed in the APK will be compressed in `Release` builds. `Debug`
builds are not affected.
66 changes: 66 additions & 0 deletions Documentation/release-notes/4686.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
### Smaller app package sizes

This version introduces compression of managed assemblies by default for Release
configuration builds, resulting in significantly smaller APK and App Bundle
sizes. Assemblies are compressed with the [LZ4][lz4] algorithm during builds
and then decompressed on device during app startup.

For a small example Xamarin.Forms application, this reduced the APK size from
about 23 megabytes to about 17 megabytes while only increasing the time to
display the first page of the app from about 780 milliseconds to about 790
milliseconds in the least favorable configuration.

If needed, the new behavior can been disabled for a particular project by
setting the `AndroidEnableAssemblyCompression` MSBuild property to `false` in
the _.csproj_ file:

```xml
<PropertyGroup>
<AndroidEnableAssemblyCompression>false</AndroidEnableAssemblyCompression>
</PropertyGroup>
```

> [!NOTE]
> This feature is intended to replace the older **Bundle assemblies into native
> code** Visual Studio Enterprise feature for purposes of app size savings. The
> `AndroidEnableAssemblyCompression` property takes precedence if both features
> are enabled. Project authors who no longer need the **Bundle assemblies into
> native code** feature enabled can now disable it or remove the
> `BundleAssemblies` MSBuild property from the _.csproj_ file:
>
> ```diff
> <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
> <DebugSymbols>True</DebugSymbols>
> <DebugType>portable</DebugType>
> <Optimize>True</Optimize>
> <OutputPath>bin\Release\</OutputPath>
> <DefineConstants>TRACE</DefineConstants>
> <ErrorReport>prompt</ErrorReport>
> <WarningLevel>4</WarningLevel>
> <AndroidManagedSymbols>true</AndroidManagedSymbols>
> <AndroidUseSharedRuntime>False</AndroidUseSharedRuntime>
> <AndroidLinkMode>SdkOnly</AndroidLinkMode>
> <EmbedAssembliesIntoApk>True</EmbedAssembliesIntoApk>
> - <BundleAssemblies>true</BundleAssemblies>
> </PropertyGroup>
> ```
#### Background information
For comparison, for the small test Xamarin.Forms application, the **Bundle
assemblies into native code** feature increases the APK size from about 23
megabytes to about 26 megabytes while increasing the time to display the
first page of the app from about 780 milliseconds to about 850 milliseconds
in the least favorable configuration. Size comparison is for an application
which enables **all** of the ABIs supported by Xamarin.Android. It needs to
be noted that in this scenario, the **Bundle assemblies into native code**
feature will add a copy of the shared library with compressed assemblies per
ABI, thus the size increase. The new compression shares all the compressed
assemblies between all the ABIs, thus enabling a new one won't cause the APK
to grow. If sizes are compared for application shipping just a single ABI, the
**Bundle assemblies into native code** feature decreases the the APK size from
around 16 megabytes to around 7.5 megabytes, while the new scheme decreases the
size to around 8 megabytes. Display times are not affected by the number of ABIs
present in the APK.
[lz4]: https://github.com/lz4/lz4
1 change: 1 addition & 0 deletions build-tools/installers/create-installers.targets
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@
<_MSBuildFiles Include="$(MSBuildSrcDir)\Xamarin.Android.VisualBasic.targets" />
<_MSBuildFiles Include="$(MSBuildSrcDir)\Xamarin.Build.AsyncTask.dll" />
<_MSBuildFiles Include="$(MSBuildSrcDir)\Xamarin.Build.AsyncTask.pdb" />
<_MSBuildFiles Include="$(MSBuildSrcDir)\K4os.Compression.LZ4.dll" />
</ItemGroup>
<ItemGroup>
<_MSBuildTargetsSrcFiles Include="$(MSBuildTargetsSrcDir)\Xamarin.Android.Bindings.After.targets" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using System;
using System.Collections.Generic;
using System.IO;

namespace Xamarin.Android.Prepare
{
[TPN]
class MiloszKrajewski_K4os_Compression_LZ4_TPN : ThirdPartyNotice
{
static readonly Uri url = new Uri ("https://github.com/MiloszKrajewski/K4os.Compression.LZ4/");

public override string LicenseFile => String.Empty;
public override string Name => "MiloszKrajewski/K4os.Compression.LZ4";
public override Uri SourceUrl => url;
public override string LicenseText => @"
MIT License
Copyright (c) 2017 Milosz Krajewski
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the ""Software""), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED ""AS IS"", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
";
public override bool Include (bool includeExternalDeps, bool includeBuildDeps) => includeExternalDeps;
}
}
20 changes: 20 additions & 0 deletions build-tools/xaprepare/xaprepare/ThirdPartyNotices/lz4.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using System;
using System.Collections.Generic;
using System.IO;

namespace Xamarin.Android.Prepare
{
[TPN]
class lz4_lz4_TPN : ThirdPartyNotice
{
static readonly Uri url = new Uri ("https://github.com/lz4/lz4/");
static readonly string licenseFile = Path.Combine (Configurables.Paths.ExternalDir, "lz4", "lib", "LICENSE");

public override string LicenseFile => licenseFile;
public override string Name => "lz4/lz4";
public override Uri SourceUrl => url;
public override string LicenseText => String.Empty;

public override bool Include (bool includeExternalDeps, bool includeBuildDeps) => includeExternalDeps;
}
}
2 changes: 2 additions & 0 deletions build-tools/xaprepare/xaprepare/xaprepare.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@
<Compile Include="ThirdPartyNotices\aapt2.cs" />
<Compile Include="ThirdPartyNotices\bundletool.cs" />
<Compile Include="ThirdPartyNotices\Java.Interop.cs" />
<Compile Include="ThirdPartyNotices\K4os.Compression.LZ4.cs" />
<Compile Include="ThirdPartyNotices\lz4.cs" />
<Compile Include="ThirdPartyNotices\mono.cs" />
<Compile Include="ThirdPartyNotices\opentk.cs" />
<Compile Include="ThirdPartyNotices\proguard.cs" />
Expand Down
1 change: 1 addition & 0 deletions external/lz4
Submodule lz4 added at fdf2ef
66 changes: 64 additions & 2 deletions src/Xamarin.Android.Build.Tasks/Tasks/BuildApk.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ public class BuildApk : AndroidTask
public string UncompressedFileExtensions { get; set; }
public bool InterpreterEnabled { get; set; }

// Make it required after https://github.com/xamarin/monodroid/pull/1094 is merged
//[Required]
public bool EnableCompression { get; set; }

[Output]
public ITaskItem[] OutputFiles { get; set; }

Expand Down Expand Up @@ -289,7 +293,15 @@ public override bool RunTask ()
private void AddAssemblies (ZipArchiveEx apk)
{
bool debug = _Debug;
bool compress = !debug && EnableCompression;
bool use_shared_runtime = String.Equals (UseSharedRuntime, "true", StringComparison.OrdinalIgnoreCase);
string sourcePath;
AssemblyCompression.AssemblyData compressedAssembly = null;
IDictionary<string, CompressedAssemblyInfo> compressedAssembliesInfo = null;

if (compress) {
compressedAssembliesInfo = BuildEngine4.GetRegisteredTaskObject (CompressedAssemblyInfo.CompressedAssembliesInfoKey, RegisteredTaskObjectLifetime.Build) as IDictionary<string, CompressedAssemblyInfo>;
}

int count = 0;
foreach (ITaskItem assembly in ResolvedUserAssemblies) {
Expand All @@ -300,8 +312,11 @@ private void AddAssemblies (ZipArchiveEx apk)
if (MonoAndroidHelper.IsReferenceAssembly (assembly.ItemSpec)) {
Log.LogCodedWarning ("XA0107", assembly.ItemSpec, 0, Properties.Resources.XA0107, assembly.ItemSpec);
}

sourcePath = CompressAssembly (assembly);

// Add assembly
AddFileToArchiveIfNewer (apk, assembly.ItemSpec, GetTargetDirectory (assembly.ItemSpec) + "/" + Path.GetFileName (assembly.ItemSpec), compressionMethod: UncompressedMethod);
AddFileToArchiveIfNewer (apk, sourcePath, GetTargetDirectory (assembly.ItemSpec) + "/" + Path.GetFileName (assembly.ItemSpec), compressionMethod: UncompressedMethod);

// Try to add config if exists
var config = Path.ChangeExtension (assembly.ItemSpec, "dll.config");
Expand Down Expand Up @@ -336,10 +351,13 @@ private void AddAssemblies (ZipArchiveEx apk)
Log.LogDebugMessage ($"Skipping {assembly.ItemSpec} due to 'AndroidSkipAddToPackage' == 'true' ");
continue;
}

if (MonoAndroidHelper.IsReferenceAssembly (assembly.ItemSpec)) {
Log.LogCodedWarning ("XA0107", assembly.ItemSpec, 0, Properties.Resources.XA0107, assembly.ItemSpec);
}
AddFileToArchiveIfNewer (apk, assembly.ItemSpec, AssembliesPath + Path.GetFileName (assembly.ItemSpec), compressionMethod: UncompressedMethod);

sourcePath = CompressAssembly (assembly);
AddFileToArchiveIfNewer (apk, sourcePath, AssembliesPath + Path.GetFileName (assembly.ItemSpec), compressionMethod: UncompressedMethod);
var config = Path.ChangeExtension (assembly.ItemSpec, "dll.config");
AddAssemblyConfigEntry (apk, config);
// Try to add symbols if Debug
Expand All @@ -360,6 +378,50 @@ private void AddAssemblies (ZipArchiveEx apk)
count = 0;
}
}

void EnsureCompressedAssemblyData (string sourcePath, uint descriptorIndex)
{
if (compressedAssembly == null)
compressedAssembly = new AssemblyCompression.AssemblyData (sourcePath, descriptorIndex);
else
compressedAssembly.SetData (sourcePath, descriptorIndex);
}

string CompressAssembly (ITaskItem assembly)
{
if (!compress) {
return assembly.ItemSpec;
}

if (bool.TryParse (assembly.GetMetadata ("AndroidSkipCompression"), out bool value) && value) {
Log.LogDebugMessage ($"Skipping compression of {assembly.ItemSpec} due to 'AndroidSkipCompression' == 'true' ");
return assembly.ItemSpec;
}

if (compressedAssembliesInfo.TryGetValue (Path.GetFileName (assembly.ItemSpec), out CompressedAssemblyInfo info) && info != null) {
EnsureCompressedAssemblyData (assembly.ItemSpec, info.DescriptorIndex);
AssemblyCompression.CompressionResult result = AssemblyCompression.Compress (compressedAssembly);
if (result != AssemblyCompression.CompressionResult.Success) {
switch (result) {
case AssemblyCompression.CompressionResult.EncodingFailed:
Log.LogMessage ($"Failed to compress {assembly.ItemSpec}");
break;

case AssemblyCompression.CompressionResult.InputTooBig:
Log.LogMessage ($"Input assembly {assembly.ItemSpec} exceeds maximum input size");
break;

default:
Log.LogMessage ($"Unknown error compressing {assembly.ItemSpec}");
break;
}
return assembly.ItemSpec;
}
return compressedAssembly.DestinationPath;
}

return assembly.ItemSpec;
}
}

bool AddFileToArchiveIfNewer (ZipArchiveEx apk, string file, string inArchivePath, CompressionMethod compressionMethod = CompressionMethod.Default)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
using System;
using System.Collections.Generic;
using System.IO;
using Microsoft.Build.Framework;

namespace Xamarin.Android.Tasks
{
public class GenerateCompressedAssembliesNativeSourceFiles : AndroidTask
{
public override string TaskPrefix => "GCANSF";

[Required]
public ITaskItem[] ResolvedAssemblies { get; set; }

[Required]
public string [] SupportedAbis { get; set; }

[Required]
public string EnvironmentOutputDirectory { get; set; }

[Required]
public bool Debug { get; set; }

[Required]
public bool EnableCompression { get; set; }

public override bool RunTask ()
{
GenerateCompressedAssemblySources ();
return !Log.HasLoggedErrors;
}

void GenerateCompressedAssemblySources ()
{
if (Debug || !EnableCompression) {
Generate (null);
return;
}

var assemblies = new SortedDictionary<string, CompressedAssemblyInfo> (StringComparer.Ordinal);
foreach (ITaskItem assembly in ResolvedAssemblies) {
if (bool.TryParse (assembly.GetMetadata ("AndroidSkipAddToPackage"), out bool value) && value) {
continue;
}

if (assemblies.ContainsKey (assembly.ItemSpec)) {
continue;
}

var fi = new FileInfo (assembly.ItemSpec);
if (!fi.Exists) {
Log.LogError ($"Assembly {assembly.ItemSpec} does not exist");
continue;
}

assemblies.Add (Path.GetFileName (assembly.ItemSpec), new CompressedAssemblyInfo (checked((uint)fi.Length)));
}

uint index = 0;
foreach (var kvp in assemblies) {
kvp.Value.DescriptorIndex = index++;
}

BuildEngine4.RegisterTaskObject (CompressedAssemblyInfo.CompressedAssembliesInfoKey, assemblies, RegisteredTaskObjectLifetime.Build, false);
Generate (assemblies);

void Generate (IDictionary<string, CompressedAssemblyInfo> dict)
{
foreach (string abi in SupportedAbis) {
NativeAssemblerTargetProvider asmTargetProvider = GeneratePackageManagerJava.GetAssemblyTargetProvider (abi);
string baseAsmFilePath = Path.Combine (EnvironmentOutputDirectory, $"compressed_assemblies.{abi.ToLowerInvariant ()}");
string asmFilePath = $"{baseAsmFilePath}.s";
var asmgen = new CompressedAssembliesNativeAssemblyGenerator (dict, asmTargetProvider, baseAsmFilePath);

using (var sw = MemoryStreamPool.Shared.CreateStreamWriter ()) {
asmgen.Write (sw);
sw.Flush ();
if (MonoAndroidHelper.CopyIfStreamChanged (sw.BaseStream, asmFilePath)) {
Log.LogDebugMessage ($"File {asmFilePath} was regenerated");
}
}
}
}
}
}
}
Loading

0 comments on commit 444ca3f

Please sign in to comment.